From 8492ce1084d8787fbcb4ab99543b959e88022b5f Mon Sep 17 00:00:00 2001 From: Halica Date: Wed, 19 Aug 2026 10:34:14 +0200 Subject: [PATCH] Version 0.1 basic ovement, 1 ability, basic room layout + enemy movement, boss, flow system --- .gitignore | 1 + pygame test.slnx | 5 + pygame test/Player_01.png | Bin 0 -> 702 bytes pygame test/Player_01_idle1.png | Bin 0 -> 680 bytes pygame test/Player_01_idle2.png | Bin 0 -> 695 bytes pygame test/Player_02.png | Bin 0 -> 879 bytes pygame test/data/config/animation.json | 8 + pygame test/data/config/coin_drops.json | 13 + pygame test/data/config/colors.json | 47 + pygame test/data/config/game_config.json | 75 + pygame test/data/config/npc.json | 38 + pygame test/data/config/physics.json | 44 + pygame test/data/config/ranged.json | 10 + pygame test/data/config/reward_room.json | 6 + pygame test/data/config/ui_layout.json | 28 + pygame test/data/drops/enemy_drops.json | 20 + pygame test/data/enemies/enemy_types.json | 31 + pygame test/data/items/armor.json | 373 ++ pygame test/data/items/necklaces.json | 32 + pygame test/data/items/rarities.json | 5 + pygame test/data/items/sets.json | 177 + pygame test/data/objects/objects.json | 8 + .../sprites/abilities/Tether_dark_ability.png | Bin 0 -> 327 bytes .../abilities/Tether_dark_ability_edge.png | Bin 0 -> 445 bytes .../abilities/Tether_light_ability.png | Bin 0 -> 319 bytes .../abilities/Tether_light_ability_edge.png | Bin 0 -> 443 bytes pygame test/data/sprites/objects/lamp_1.png | Bin 0 -> 313 bytes .../ui/icons/abilities/RealityBreak_dark.png | Bin 0 -> 392 bytes .../ui/icons/abilities/RealityBreak_light.png | Bin 0 -> 614 bytes .../ui/icons/abilities/Shatterstep_dark.png | Bin 0 -> 306 bytes .../ui/icons/abilities/Shatterstep_light.png | Bin 0 -> 257 bytes .../data/ui/icons/abilities/Tether_dark.png | Bin 0 -> 219 bytes .../data/ui/icons/abilities/Tether_light.png | Bin 0 -> 215 bytes pygame test/data/ui/icons/coins/coin_dark.png | Bin 0 -> 305 bytes .../data/ui/icons/coins/coin_light.png | Bin 0 -> 250 bytes .../data/ui/icons/slots/chestplate.png | Bin 0 -> 150 bytes pygame test/data/ui/icons/slots/helmet.png | Bin 0 -> 128 bytes pygame test/data/ui/icons/slots/necklace.png | Bin 0 -> 135 bytes pygame test/data/ui/icons/slots/pants.png | Bin 0 -> 136 bytes pygame test/data/world/rooms/room_01.json | 135 + pygame test/data/world/rooms/room_02.json | 205 + pygame test/data/world/rooms/room_07.json | 426 ++ .../data/world/rooms/room_boss_01.json | 71 + .../data/world/rooms/room_reward_01.json | 116 + .../world/rooms/room_town_gloomreach.json | 70 + pygame test/level_editor_v43.html | 3203 ++++++++++ pygame test/notes.txt | 50 + pygame test/pygame test.pyproj | 36 + pygame test/pygame_test.py | 5494 +++++++++++++++++ 49 files changed, 10727 insertions(+) create mode 100644 .gitignore create mode 100644 pygame test.slnx create mode 100644 pygame test/Player_01.png create mode 100644 pygame test/Player_01_idle1.png create mode 100644 pygame test/Player_01_idle2.png create mode 100644 pygame test/Player_02.png create mode 100644 pygame test/data/config/animation.json create mode 100644 pygame test/data/config/coin_drops.json create mode 100644 pygame test/data/config/colors.json create mode 100644 pygame test/data/config/game_config.json create mode 100644 pygame test/data/config/npc.json create mode 100644 pygame test/data/config/physics.json create mode 100644 pygame test/data/config/ranged.json create mode 100644 pygame test/data/config/reward_room.json create mode 100644 pygame test/data/config/ui_layout.json create mode 100644 pygame test/data/drops/enemy_drops.json create mode 100644 pygame test/data/enemies/enemy_types.json create mode 100644 pygame test/data/items/armor.json create mode 100644 pygame test/data/items/necklaces.json create mode 100644 pygame test/data/items/rarities.json create mode 100644 pygame test/data/items/sets.json create mode 100644 pygame test/data/objects/objects.json create mode 100644 pygame test/data/sprites/abilities/Tether_dark_ability.png create mode 100644 pygame test/data/sprites/abilities/Tether_dark_ability_edge.png create mode 100644 pygame test/data/sprites/abilities/Tether_light_ability.png create mode 100644 pygame test/data/sprites/abilities/Tether_light_ability_edge.png create mode 100644 pygame test/data/sprites/objects/lamp_1.png create mode 100644 pygame test/data/ui/icons/abilities/RealityBreak_dark.png create mode 100644 pygame test/data/ui/icons/abilities/RealityBreak_light.png create mode 100644 pygame test/data/ui/icons/abilities/Shatterstep_dark.png create mode 100644 pygame test/data/ui/icons/abilities/Shatterstep_light.png create mode 100644 pygame test/data/ui/icons/abilities/Tether_dark.png create mode 100644 pygame test/data/ui/icons/abilities/Tether_light.png create mode 100644 pygame test/data/ui/icons/coins/coin_dark.png create mode 100644 pygame test/data/ui/icons/coins/coin_light.png create mode 100644 pygame test/data/ui/icons/slots/chestplate.png create mode 100644 pygame test/data/ui/icons/slots/helmet.png create mode 100644 pygame test/data/ui/icons/slots/necklace.png create mode 100644 pygame test/data/ui/icons/slots/pants.png create mode 100644 pygame test/data/world/rooms/room_01.json create mode 100644 pygame test/data/world/rooms/room_02.json create mode 100644 pygame test/data/world/rooms/room_07.json create mode 100644 pygame test/data/world/rooms/room_boss_01.json create mode 100644 pygame test/data/world/rooms/room_reward_01.json create mode 100644 pygame test/data/world/rooms/room_town_gloomreach.json create mode 100644 pygame test/level_editor_v43.html create mode 100644 pygame test/notes.txt create mode 100644 pygame test/pygame test.pyproj create mode 100644 pygame test/pygame_test.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a34381 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.vs \ No newline at end of file diff --git a/pygame test.slnx b/pygame test.slnx new file mode 100644 index 0000000..933ff8d --- /dev/null +++ b/pygame test.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/pygame test/Player_01.png b/pygame test/Player_01.png new file mode 100644 index 0000000000000000000000000000000000000000..39a509f96938695af042f3c6f873c5f1dc56702b GIT binary patch literal 702 zcmV;v0zv(WP)a%sy-c^sFcGMuqKn68dfiRGrK>`XX z>~sQ2i3c}2>V240G)yYG%`>{qGwIl`KYuB|gR*qs z{5Yk{(G0G??bJte+Y{rhpVwX1cQXO_GL5)$0V*0`TLMlBXw# zYPWOcZIuL|KAJ~;26@wI7pGF+KFy_DtK4%xPAQtJRv*>wFjXK*Wsc=%Dk(rf3R>Pf zt?#1Rg^PTAFn%xqGV`$BL@$Q~wWzV;@TEDvPTi2w&69wzu95dz@EoiI!)Duz@v z7ds(4_b33iRN9VJwj;{&KsL7*fb2GeRltUPclk%hqaQ$*+Unfo7}f?D!b}^$k5ekH zTy;NG-Kx6&d1*D@<{1?$ARtBa_sp?(09LJ?5U*knWDILbRu9z&4gk=+&wdY{6I~(2 k_d|qUTd&vrP5-_A0_60TDQPvMwEzGB07*qoM6N<$f;}HVmjD0& literal 0 HcmV?d00001 diff --git a/pygame test/Player_01_idle1.png b/pygame test/Player_01_idle1.png new file mode 100644 index 0000000000000000000000000000000000000000..71f0a739cf38a66807e7cf26a69d02ea02b8a2cd GIT binary patch literal 680 zcmV;Z0$2TsP)`9oytXY0g=;3K#l8IUbypfQm`M4SPn%H%Rs zhYq+q?)W2BO5O$>zxQ~*KX(8hc<(TDU)XGq1OP7WM*zUZWTqX1#Xx7V;|B$D0NBSN z=~Tdv-+#CP9X}w;!K^^8D}onW$vzo5i;Kz3*ldsF+n14YTC`H20v+;R1nA=b6+kN< z0B*0ZjNNO9`{-O4sA)S&U!o#zudfW?-wfatVw+hYO~G<7gFT(XjS>O?)CJawwu4CN z<<0g;cCTT6eflzoxkv#3YkRCEz>N~ZzGH|Uh=hdCLzs&cXYmOx?MHZ-RS}{y3zXHn zQ9{x?6F18Tc$tOO@BjetGE3V97FumcZKvd2KuLfbB}7EJ8Xoc@?L~m&2PArWD%jp7 z3Iga}Uxq!MY6aDCq;?Qda@p6nDMV=sWaPB{big|g2nEo#qS%ew|lb z;tHVT<{~ZhJ4?>)32lnABEa#3oSemKc;J#ZNrg~=;|JvQWsaNWLr%&PbxmFn;6_PK z{%iLc>75BLvkD|K@}>i+P!#0=w6)5UkadF!p()aB$J)g*a(cbC#|A52N5rU(ynL@* z2Ru^I*3R3eg-R(o{W?*r^|~F?z-xPKl#8y54k!o^6(H{!ZxXf^P-i1M5k+;-N`MG7 z|E%TDlfT+Tq8XulB;)nDP63M64FK^kISW-X(V?ah96uoYI3%onZj_LH99Hf(h4kO> z`=?RsK5ry|9>wmXb0N9v98gJbgAhy|t+7?XR5rjd&b_BY>JJE(5z%mCG zt36%6X6*Xtc?|xf0D$(pg}oe;oSGI}toAh9df-nAINtQ(EPRCJVc1FtYk|0cI6N`Q zX}0y?a{2%OPzog-IUi_R0U~6*McZfIPPb+WCTFa2ASO9gkkgc5n_HtPc_(;R d@(=v?_zmxFiPg*k9nJs%002ovPDHLkV1l2XK&k)$ literal 0 HcmV?d00001 diff --git a/pygame test/Player_02.png b/pygame test/Player_02.png new file mode 100644 index 0000000000000000000000000000000000000000..81ada54f5106dc64ec9df152339bed884198baf0 GIT binary patch literal 879 zcmV-#1CacQP)8nu7(B{SfuaEnG%yTjwPSA{GZ@g+4t@!}MaPca`w)5w58YH`=;+N!vnDVCw8+v9 za?K9OF&`<)(O*)aw?v|2-hKD|kO|NM;waHw>|)z0FH2MkthI@2S;x98+;eyIu+oB>njM3qHL2+**VsfYqJlsgf?7h^QI)r{C}K^Xn^1 zL=zNpCtHruPkLusdwzKo%y7Nm7=c-y)~P-zmb^-3YX7vd8qh14MjDmpM!V?D&F_cJ;Jbliq6-01t^z@$T_ zp)R~#z=_D8Zb5EwSr8}h2z5}ZoEuKV1%SumBe_fo*Xri~pa3F{5`Dej;LZLm0NZ+r zrfD!;z=#Fl^DQn5xo=Bu36LUay9?F>A<2C+ zqP74jf;k5sBc=WQa8Pig9}XH_QUkzlGx_Y2kjS^@E6gge<~W}ZZPpJ5g~H?-Q&eXn znwkGIBnz^~zl5st5_H?EedI2u>tpg)~(MuwJ*{u(^ zVBOE_bb%4TLX4xNaJ145xA6EbkL|jbl0pq+d8(%2!iqxL&hvp?0r-#MszR&-hz`h$ zC3AVVyY==x(C$VQO5!ONmW7g>SnHJd2kiyrYPPE$!R`2dAdX-BA_WzC3LBQ@{99_~GAC8_qET4S|5&6JqAbM{o1QNX7wx9ZF@d}A)9e6 zv(lE+(ZUMZQLE)Q{btGe$2P^)^!C?v`D!m`Z~iHrFg2PxBirVi<_0l))=zeq__h}3 z{rV%m+yB73dn|toRUD$FrQXIb+SRo2P>WJYD@<);T3K0RRkVjt>9; literal 0 HcmV?d00001 diff --git a/pygame test/data/sprites/abilities/Tether_dark_ability_edge.png b/pygame test/data/sprites/abilities/Tether_dark_ability_edge.png new file mode 100644 index 0000000000000000000000000000000000000000..4ecdc05a061f23c7f5c1de193c003b8d2927b246 GIT binary patch literal 445 zcmV;u0Yd(XP)-Bz~mVYNUD?c24sw;&GunJ=5`cQO3N(qrFryp=IW+Vl@*I!32 zrUZ*vdsds5gbI)^R^)Qlc`q411Qk%Sj<@0k08{|Rk}NEBEI-f_R6xy-y}LmG6~F=D z0G!={v*7@o-GQ?iYp$ie8=?Re@V6C~Xv#q+=P=X)-76qzh~z*+#4(#q%{87QFHiwi zfn|H?t1<6T0jUbw_VQ?a#^_!FNlUB++bc%*3NYt$?2l1g1sbC2db@=cp?d`s4Zu7t nu3ext-7A0)LI@#*kWTplh>x3rK-I|@00000NkvXXu0mjfvf{w= literal 0 HcmV?d00001 diff --git a/pygame test/data/sprites/abilities/Tether_light_ability.png b/pygame test/data/sprites/abilities/Tether_light_ability.png new file mode 100644 index 0000000000000000000000000000000000000000..9e108f69ca3e77c5bda53ee2e995610152bdd077 GIT binary patch literal 319 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7T#lEU}W=jaSW-L^Y(@yT|VL1MfY2NV)rfBZ0Ea5e3uMX^Z)2F8NK3HqF_l*4Z`2s*QAfUlj!*6=@ z|3A)`x9YQBN&Q;Z^tUXw!q4H_^wQ+loRKvm=ws8l_+Vuc*8@)xDDQ zLeXRorXu#Jy~^9v*B*JQrcpZY@XuTAA3UTuKlmk-7RGk^hu({_H(P(T*rTDGX*HA5 zwWoVIC)nnmmEZh><;*{(E2*YYUzcUGzmzuq&Hdo%JH~5rf41&!*!Q1XXo7XYI{&6B z8O{yg+KyIve+esi^_}7M&Zg}LeO%v(OMYCdmv@o#K{wP7m+mkg@S45Z__Y`h$VZ;8 KelF{r5}E+=B%5JkT;uBj=~asr0)D-0P2%n=(OT>VDci{}LJ? zAGG9Bp7T~#{1`MK=Q-X=EdZba=yO(KX(RSnh6a@UIJD!y zpO63lTRBtVhS-|ZHdnRuCT~CHHugw2{hU5fWu!)IGJ zWNPQFZQk@>O4^%o(KfDWuR7gA8eXhu+FG?JP*lOnZpxb3({?j0eL6BknuvI}fLBU=09i@^tlcS?83{1OQ!Dc#{AC literal 0 HcmV?d00001 diff --git a/pygame test/data/ui/icons/abilities/RealityBreak_dark.png b/pygame test/data/ui/icons/abilities/RealityBreak_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..e3476e95833dd011aa2aced8b6b04e9984b43ca2 GIT binary patch literal 392 zcmV;30eAk1P) zy%EAN422c$XxV`#Bd}K{U$!A$d`XO7SWH ztKLhY6%g@Vfb?ng9)z#~JO|N=`4$CdYYCuTKpH^weHgu00g$lO2vuLlsF3#o6IdC* m4{pD802^5Q|AzTGZf0+IU;S)uv-DN~00007xV6opSvi&!%90%b%%rAl3RgN&6zw<<#?1Vf%A168IDhz;2D0u^H=Aj;Ua3tQPh z!X3~xj^iW_q2){=4*t%)zWy@+FkD&JG{A0({=@5axnXyd6HXwp6tBD|p%CZ3f z6&*0aEvuPo>Q4-aU>Pp|zPN$3sMEeXSBV# zTF9LRDuA2?%W7r@plyJvT@`Uw7R0vij~e^_sA(I?%>Wrwy$gbf!z<>Fx79msU4GmE zb*7UA@%ef93EK%#F?Srk|9RBP0ymJiuG8?CjS23amZuKP9p>W}0Kj%a9cE00HzSI0 z@V*BC_-?%2caMP|wpDn&F3SrnQ3G=7&Bi3P&3764o`Jq+U_Nf0l?emK^8k10^}39T zE(oGTJ&46-J7J>We3!v?Ld?f49JW;enZwJJvp`jBgZDj8J4Hwli*R~R@H{09xU+@f z&K3rNpNF5Zs43lK!gm=5Q+wajKd0ob10qZofovz_Cf;6e0jRg|vkV-zRZK?@*sRvr ztkxVz>F;|6uLIq*i^`maVlmb`ZEipwdok*xme7^fI$&8%Y*rIYM-LMCv>wD}4(|DL zo)XiLq?(Q-vvD$`8fOA)oSL(b+lLv?*;!Us)4KN7w}>y6p%#-`v3p{07*qoM6N<$f`Q-< AQUCw| literal 0 HcmV?d00001 diff --git a/pygame test/data/ui/icons/abilities/Shatterstep_dark.png b/pygame test/data/ui/icons/abilities/Shatterstep_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..ca0ecdf35ea67c27951ce215855b2ef7dc4798b7 GIT binary patch literal 306 zcmV-20nPr2P)12`_;CKWa>{3D&ar+`S+y-t|-f3owoIt~1F^ zNc}t?ucF^r+d!RgWtPrM4v=b#LpP1{mpB5WAg-kc*@4Lc@B!)^1!||{qQiwg16Vg^ zV-;h^-g;qd0JItX>n~~3GrtLGFHnD2Xw5tVm;+|^2H0$^-7kGO;s5{u07*qoM6N<$ Ef{e0xi2wiq literal 0 HcmV?d00001 diff --git a/pygame test/data/ui/icons/abilities/Shatterstep_light.png b/pygame test/data/ui/icons/abilities/Shatterstep_light.png new file mode 100644 index 0000000000000000000000000000000000000000..ca9aebcd5f01fe8e101441b2ac82ebaef1420db1 GIT binary patch literal 257 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJ6P_-PArY;~DG3Qb&Kn#^P>}sF zzjM~Ix%>XMr<@Ns%V==gvy>%NrXf#Lvu{BcvrlNG#K(`EjH^_gj4m;J6?%BWm)YR- zg~xLv+ykC4#7sN<OUsgqnQkgNWQ8r>#r%PBm$=J`X_JKhhZHmHdb-4- zDedNj8zzTNFlcsZdIfw;pFbgHj=ssX#FPbP)eSKQHBl~47?Ql4Y7|`x6HQc{8nj&X z^b8&-8oXloV&dRA(;+NMvP9A0vPeHKPX)(;tiz1V46=TS&R6>UKwe|;boFyt=akR{ E00iY>0{{R3 literal 0 HcmV?d00001 diff --git a/pygame test/data/ui/icons/abilities/Tether_dark.png b/pygame test/data/ui/icons/abilities/Tether_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..3cb3d4f569baa1bbdac9efef4250c4e4a1d43818 GIT binary patch literal 219 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJMV>B>ArY-_uN(5UCeb%bISNm@>sBBOC@Nrtb zy8hGcUp>XxJw8lSFU(~3xAyTjSR3D`Y2RpXUd=B7G4u-~-_!R$#Z}|8t7oK1iUD2A N;OXk;vd$@?2>{wnPl^Bl literal 0 HcmV?d00001 diff --git a/pygame test/data/ui/icons/abilities/Tether_light.png b/pygame test/data/ui/icons/abilities/Tether_light.png new file mode 100644 index 0000000000000000000000000000000000000000..a2fe3e62501ced5105e395f4d44093e8c5d3b285 GIT binary patch literal 215 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJd7dtgArY-_uN(5UCb9z~qFEE_B_RbooYd7AQIDb!Ik~e8!W_h}dbHemTj~FBlFt8ak zFkWHgn^1YzUe@{b-97J^1cFlD%+m2ctyUYF{?%Qtw_W7r>R%!DTjX}!X}i2>?x*ih zmj2oDZ_-iu$#*)vt#{l`^!S+m_lD_D&ObYERznQrS-@YQQGfn^8bd|DkH(g6pi3D% MUHx3vIVCg!0M%kyYXATM literal 0 HcmV?d00001 diff --git a/pygame test/data/ui/icons/coins/coin_dark.png b/pygame test/data/ui/icons/coins/coin_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..d7ab47f7b414e5a419b99e12fbad3b66ff159fb3 GIT binary patch literal 305 zcmV-10nYx3P)HSPemhy`eFpz&)YK`MHJu9F~@Jg!9>4>ofP zO6lOLx}d*G7G^R<{{hR05A{)qD}Q0Ax|w<+xLRGi}ki zxMZWCEGk(a7XU{0)5{!y{dfaFbE-I8ca{eb035D6(@;qs*8s9UE)3D*VeQ8onp5RE zG)36T@;ov4q4LzXT6)@ffA7xQwmzR;JLG}S^?Td_8g8De2_cX^00000NkvXXu0mjf D+jV}^ literal 0 HcmV?d00001 diff --git a/pygame test/data/ui/icons/coins/coin_light.png b/pygame test/data/ui/icons/coins/coin_light.png new file mode 100644 index 0000000000000000000000000000000000000000..d4681db3caf3218f394590d3989a3f17d5826ef4 GIT binary patch literal 250 zcmV=R1LQSp^)BPw0T!WbW=d(G3njFFlST!OACKI~Ela(K^ z+Qh(s850Z)RES3EB_zCt5Sx^-8iE9{WhRON00e(mO-#BxeEbP0l+XkKE^H_> literal 0 HcmV?d00001 diff --git a/pygame test/data/ui/icons/slots/necklace.png b/pygame test/data/ui/icons/slots/necklace.png new file mode 100644 index 0000000000000000000000000000000000000000..5c2886cba78c02a85d8aaa395ef18b2c6312969c GIT binary patch literal 135 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`ex5FlAr`&K2@)9xI^5scb7mQu zEXqEn-giB$DQiYYu}Gc)56=O%53DL}GJ4{F+!y^{+_{ip59iDO^&N6kB(Pcx_mAPF_*1#W6*E?i{0e!U_x%YS?z(3cqmX#{rqEJPNC|8=mYd<1^@$N#v=l kcH(XL?cry8s(u0AJQiLV2aYZCfJQTTy85}Sb4q9e0KGUY&j0`b literal 0 HcmV?d00001 diff --git a/pygame test/data/world/rooms/room_01.json b/pygame test/data/world/rooms/room_01.json new file mode 100644 index 0000000..e29d63a --- /dev/null +++ b/pygame test/data/world/rooms/room_01.json @@ -0,0 +1,135 @@ +{ + "meta": { + "id": "room_01", + "name": "Room 01 - Tutorial Steps" + }, + "player_spawn": [ + -260, + 500 + ], + "bounds": { + "kill_y": 2000 + }, + "platforms": { + "ground": [ + [ + -500, + 640, + 2060, + 460 + ], + [ + -500, + 560, + 520, + 80 + ], + [ + 1560, + 220, + 120, + 880 + ], + [ + -1100, + -520, + 600, + 1620 + ], + [ + 1680, + -100, + 920, + 1200 + ], + [ + -500, + -520, + 940, + 840 + ], + [ + 440, + -520, + 2160, + 420 + ] + ], + "floating": [ + [ + 800, + 220, + 320, + 40 + ], + [ + 880, + 520, + 240, + 40 + ], + [ + 500, + 400, + 320, + 40 + ], + [ + 440, + 280, + 200, + 40 + ], + [ + 1220, + 220, + 340, + 40 + ] + ] + }, + "enemies": [], + "exits": [ + { + "rect": [ + -420, + 480, + 90, + 80 + ], + "to": "room_reward_01", + "spawn": [ + 120, + 300 + ], + "hint": "Reward" + }, + { + "rect": [ + 1560, + 100, + 120, + 120 + ], + "to": "room_02", + "spawn": [ + -420, + 300 + ], + "hint": "", + "spawn_id": "entry_1" + } + ], + "ability_doors": [], + "entry_spawns": {}, + "props": [ + { + "type": "light_source", + "pos": [ + 0, + 560 + ], + "radius": 180 + } + ] +} \ No newline at end of file diff --git a/pygame test/data/world/rooms/room_02.json b/pygame test/data/world/rooms/room_02.json new file mode 100644 index 0000000..05f820a --- /dev/null +++ b/pygame test/data/world/rooms/room_02.json @@ -0,0 +1,205 @@ +{ + "meta": { + "id": "room_02", + "name": "Room 02 - First Enemies" + }, + "player_spawn": [ + -320, + 300 + ], + "bounds": { + "kill_y": 2000 + }, + "platforms": { + "ground": [ + [ + -500, + 360, + 2440, + 660 + ], + [ + -760, + -580, + 260, + 1600 + ], + [ + 1940, + -580, + 240, + 1600 + ], + [ + -760, + -820, + 2940, + 240 + ] + ], + "floating": [ + [ + 260, + 260, + 140, + 18 + ], + [ + 520, + 220, + 140, + 18 + ], + [ + 760, + 180, + 140, + 18 + ], + [ + 940, + 120, + 170, + 18 + ], + [ + 1200, + 160, + 170, + 18 + ], + [ + 1460, + 100, + 170, + 18 + ], + [ + 1200, + -20, + 280, + 40 + ], + [ + 40, + -140, + 1100, + 60 + ], + [ + -500, + -140, + 460, + 60 + ], + [ + -500, + -260, + 260, + 40 + ], + [ + -500, + -380, + 160, + 40 + ], + [ + -320, + -500, + 1380, + 40 + ], + [ + 1140, + -440, + 340, + 40 + ], + [ + 1560, + -380, + 380, + 40 + ] + ] + }, + "enemies": [ + { + "type": "enemy", + "pos": [ + 240, + -200 + ] + }, + { + "type": "enemy", + "pos": [ + 620, + 140 + ] + }, + { + "type": "enemy", + "pos": [ + 860, + -200 + ] + }, + { + "type": "enemy", + "pos": [ + 480, + -580 + ] + }, + { + "type": "enemy", + "pos": [ + 1740, + 280 + ] + } + ], + "exits": [ + { + "rect": [ + -500, + 300, + 80, + 60 + ], + "to": "room_01", + "spawn": [ + 1560, + 140 + ], + "hint": "Back" + }, + { + "rect": [ + 1860, + -500, + 80, + 120 + ], + "to": "room_07", + "spawn": [ + 180, + 240 + ], + "hint": "" + } + ], + "ability_doors": [], + "entry_spawns": { + "entry_1": [ + -420, + 300 + ], + "entry_3": [ + 1840, + -440 + ] + } +} \ No newline at end of file diff --git a/pygame test/data/world/rooms/room_07.json b/pygame test/data/world/rooms/room_07.json new file mode 100644 index 0000000..ea01baa --- /dev/null +++ b/pygame test/data/world/rooms/room_07.json @@ -0,0 +1,426 @@ +{ + "name": "room_07", + "display_name": "room_07", + "meta": { + "id": "room_07", + "name": "room_07" + }, + "bounds": { + "kill_y": 3200 + }, + "player_spawn": [ + 200, + 220 + ], + "entry_spawns": { + "entry_1": [ + 180, + 240 + ], + "entry_2": [ + 4200, + 2720 + ], + "entry_3": [ + -320, + 2720 + ] + }, + "platforms": [ + { + "x": 60, + "y": 320, + "w": 1460, + "h": 40, + "group": "ground" + }, + { + "x": -1160, + "y": 2780, + "w": 5500, + "h": 120, + "group": "ground" + }, + { + "x": -1160, + "y": -300, + "w": 1220, + "h": 2480, + "group": "ground" + }, + { + "x": -1280, + "y": -300, + "w": 120, + "h": 3200, + "group": "ground" + }, + { + "x": 2300, + "y": 1860, + "w": 2040, + "h": 320, + "group": "ground" + }, + { + "x": 4340, + "y": -300, + "w": 100, + "h": 3200, + "group": "ground" + }, + { + "x": 60, + "y": -300, + "w": 4280, + "h": 80, + "group": "ground" + }, + { + "x": 60, + "y": 1860, + "w": 1600, + "h": 320, + "group": "ground" + }, + { + "x": 1600, + "y": 440, + "w": 1060, + "h": 40, + "group": "ground" + }, + { + "x": 2780, + "y": 320, + "w": 580, + "h": 40, + "group": "ground" + }, + { + "x": 3280, + "y": 800, + "w": 1060, + "h": 60, + "group": "ground" + }, + { + "x": 1660, + "y": 1920, + "w": 360, + "h": 20, + "group": "ground" + }, + { + "x": 1940, + "y": 2040, + "w": 360, + "h": 20, + "group": "ground" + }, + { + "x": 1660, + "y": 2160, + "w": 360, + "h": 20, + "group": "ground" + }, + { + "x": 1940, + "y": 2280, + "w": 360, + "h": 20, + "group": "ground" + }, + { + "x": 1660, + "y": 2400, + "w": 360, + "h": 20, + "group": "ground" + }, + { + "x": 1940, + "y": 2520, + "w": 360, + "h": 20, + "group": "ground" + }, + { + "x": 1660, + "y": 2640, + "w": 360, + "h": 20, + "group": "ground" + }, + { + "x": 1940, + "y": 2720, + "w": 360, + "h": 20, + "group": "ground" + }, + { + "x": 2660, + "y": 880, + "w": 520, + "h": 40, + "group": "ground" + }, + { + "x": 2140, + "y": 880, + "w": 380, + "h": 40, + "group": "ground" + }, + { + "x": 1940, + "y": 880, + "w": 120, + "h": 40, + "group": "ground" + }, + { + "x": 1680, + "y": 880, + "w": 100, + "h": 40, + "group": "ground" + }, + { + "x": 1440, + "y": 880, + "w": 80, + "h": 40, + "group": "ground" + }, + { + "x": 1220, + "y": 880, + "w": 80, + "h": 40, + "group": "ground" + }, + { + "x": 940, + "y": 880, + "w": 180, + "h": 40, + "group": "ground" + }, + { + "x": 660, + "y": 820, + "w": 180, + "h": 40, + "group": "ground" + }, + { + "x": 300, + "y": 740, + "w": 260, + "h": 60, + "group": "ground" + }, + { + "x": 260, + "y": 640, + "w": 40, + "h": 1100, + "group": "ground" + }, + { + "x": 300, + "y": 1260, + "w": 4040, + "h": 40, + "group": "ground" + }, + { + "x": 3240, + "y": 980, + "w": 60, + "h": 40, + "group": "ground" + }, + { + "x": 3100, + "y": 1060, + "w": 60, + "h": 40, + "group": "ground" + }, + { + "x": 3240, + "y": 1140, + "w": 60, + "h": 40, + "group": "ground" + }, + { + "x": 3380, + "y": 440, + "w": 300, + "h": 40, + "group": "ground" + }, + { + "x": 3700, + "y": 560, + "w": 300, + "h": 40, + "group": "ground" + }, + { + "x": 4020, + "y": 680, + "w": 320, + "h": 40, + "group": "ground" + } + ], + "enemies": [ + { + "x": 2080, + "y": 360, + "type": "enemy" + }, + { + "x": 280, + "y": 2680, + "type": "miniboss" + }, + { + "x": 520, + "y": 2700, + "type": "enemy" + }, + { + "x": 680, + "y": 2700, + "type": "enemy" + }, + { + "x": 980, + "y": 2700, + "type": "enemy" + }, + { + "x": 840, + "y": 2700, + "type": "enemy" + }, + { + "x": 820, + "y": 1160, + "type": "enemy" + }, + { + "x": 1040, + "y": 1160, + "type": "enemy" + }, + { + "x": 1340, + "y": 1160, + "type": "enemy" + }, + { + "x": 1700, + "y": 1160, + "type": "enemy" + }, + { + "x": 2140, + "y": 1160, + "type": "enemy" + }, + { + "x": 2480, + "y": 1160, + "type": "enemy" + }, + { + "x": 2700, + "y": 1160, + "type": "enemy" + }, + { + "x": 2740, + "y": 1720, + "type": "miniboss" + } + ], + "props": [], + "exits": [ + { + "x": -1160, + "y": 2640, + "w": 100, + "h": 140, + "to": "room_town_gloomreach", + "spawn_id": "", + "spawn": [ + 120, + 300 + ], + "hint": "" + }, + { + "x": -420, + "y": 2720, + "w": 80, + "h": 60, + "to": "room_boss_01", + "spawn_id": "entry_2", + "spawn": [ + -360, + 300 + ], + "hint": "" + }, + { + "x": 4240, + "y": 2700, + "w": 100, + "h": 80, + "to": "room_reward_01", + "spawn_id": "", + "spawn": [ + 1760, + 300 + ], + "hint": "" + }, + { + "x": 60, + "y": 180, + "w": 100, + "h": 140, + "to": "room_02", + "spawn_id": "", + "spawn": [ + 1840, + -440 + ], + "hint": "" + } + ], + "ability_doors": [ + { + "x": -980, + "y": 2180, + "w": 80, + "h": 600 + }, + { + "x": 4100, + "y": 2180, + "w": 40, + "h": 600 + } + ], + "room_w": 1600, + "room_h": 900 +} \ No newline at end of file diff --git a/pygame test/data/world/rooms/room_boss_01.json b/pygame test/data/world/rooms/room_boss_01.json new file mode 100644 index 0000000..bf10a25 --- /dev/null +++ b/pygame test/data/world/rooms/room_boss_01.json @@ -0,0 +1,71 @@ +{ + "meta": { + "id": "room_boss_01", + "name": "room_boss_01" + }, + "player_spawn": [ + -240, + 300 + ], + "bounds": { + "kill_y": 1400 + }, + "platforms": { + "ground": [ + [ + -500, + 360, + 1500, + 240 + ] + ] + }, + "enemies": [ + { + "type": "boss", + "pos": [ + 280, + 200 + ] + } + ], + "exits": [ + { + "rect": [ + -460, + 300, + 80, + 60 + ], + "to": "room_07", + "spawn": [ + -320, + 2720 + ], + "hint": "Retreat", + "spawn_id": "entry_3" + }, + { + "rect": [ + 880, + 260, + 120, + 100 + ], + "to": "room_reward_01", + "spawn": [ + 100, + 300 + ], + "hint": "Reward", + "spawn_id": "entry_2" + } + ], + "ability_doors": [], + "entry_spawns": { + "entry_2": [ + -360, + 300 + ] + } +} \ No newline at end of file diff --git a/pygame test/data/world/rooms/room_reward_01.json b/pygame test/data/world/rooms/room_reward_01.json new file mode 100644 index 0000000..29f69cd --- /dev/null +++ b/pygame test/data/world/rooms/room_reward_01.json @@ -0,0 +1,116 @@ +{ + "meta": { + "id": "room_reward_01", + "name": "room_reward_01" + }, + "player_spawn": [ + 140, + 300 + ], + "bounds": { + "kill_y": 2000 + }, + "platforms": { + "ground": [ + [ + -500, + 360, + 2400, + 240 + ] + ], + "floating": [ + [ + 650, + 260, + 220, + 18 + ], + [ + 1100, + 260, + 220, + 18 + ] + ] + }, + "enemies": [], + "exits": [ + { + "rect": [ + 0, + 300, + 80, + 60 + ], + "to": "room_boss_01", + "spawn_id": "entry_2", + "spawn": [ + 110, + 260 + ], + "hint": "Back" + }, + { + "rect": [ + 1840, + 300, + 60, + 60 + ], + "to": "room_07", + "spawn_id": "entry_2", + "spawn": [ + 4200, + 2720 + ], + "hint": "Forward" + } + ], + "ability_doors": [ + { + "rect": [ + 1700, + 60, + 60, + 300 + ] + }, + { + "rect": [ + 1580, + 60, + 40, + 300 + ] + } + ], + "entry_spawns": { + "entry_2": [ + 100, + 300 + ], + "entry_1": [ + 1800, + 300 + ] + }, + "props": [ + { + "type": "reward_chest", + "pos": [ + 520, + 330 + ], + "radius": 180 + }, + { + "type": "ability_pickup", + "pos": [ + 820, + 210 + ], + "radius": 180 + } + ] +} diff --git a/pygame test/data/world/rooms/room_town_gloomreach.json b/pygame test/data/world/rooms/room_town_gloomreach.json new file mode 100644 index 0000000..f3ae7e3 --- /dev/null +++ b/pygame test/data/world/rooms/room_town_gloomreach.json @@ -0,0 +1,70 @@ +{ + "meta": { + "id": "room_town_gloomreach", + "name": "room_town_gloomreach" + }, + "player_spawn": [ + 80, + 260 + ], + "bounds": { + "kill_y": 2000 + }, + "platforms": { + "ground": [ + [ + -500, + 360, + 2400, + 240 + ] + ] + }, + "enemies": [], + "exits": [ + { + "rect": [ + 10, + 300, + 80, + 60 + ], + "to": "room_reward_01", + "spawn": [ + 1700, + 260 + ], + "hint": "Back" + } + ], + "ability_doors": [], + "entry_spawns": { + "entry_1": [ + 120, + 300 + ] + }, + "npcs": [ + { + "type": "vendor", + "pos": [ + 700, + 306 + ] + }, + { + "type": "upgrader", + "pos": [ + 860, + 306 + ] + }, + { + "type": "necklace_vendor", + "pos": [ + 1020, + 306 + ] + } + ] +} \ No newline at end of file diff --git a/pygame test/level_editor_v43.html b/pygame test/level_editor_v43.html new file mode 100644 index 0000000..945152d --- /dev/null +++ b/pygame test/level_editor_v43.html @@ -0,0 +1,3203 @@ + + + + + + Room JSON Level Editor (Multi-Room + Linking) + + + +
+
+ +
+

Rooms

+
+ + +
+
+ Load multiple room JSONs here. Click a room to edit it. Linking lets you pick an exit in one room and a spawn in another. +
+
+
+
+
+ + +
+
+ Downloads are per-room. Checked download will save one file per checked room. +
+
+ +
+

Tools

+
+ + + + + + + + +
+
+ Shortcuts: Ctrl+Z undo, + Ctrl+Y redo, + Ctrl+C/V copy/paste, + Del delete, Ctrl+D duplicate. +
+ +
+

Enemy Types

+
Load an enemy types JSON (keyed by enemy id). Used for placement dropdown, hitbox preview and aggro range preview.
+
+ +
+
+ +
+ +
+

Objects Catalog

+
Load objects.json to get default properties (lamp ranges, future objects).
+
+ +
+
No objects loaded.
+
+ +
+
+ +
+

Multi-Room Linking

+
+ 1) Select an Exit in the active room
+ 2) Choose target room + spawn (entry spawn)
+ 3) Click Link +
+
+
+ +
+ +
+ +
+ Link Mode: select an Exit in the active room, then click an entry spawn marker in the compare canvas to auto-link. +
+
+ +
+

Compare View

+
+ + +
+
+ Compare shows a second room canvas (read-only) to visually match door positions. +
+
+ +
+

Properties

+
Select something to edit properties.
+
+ +
+ +
+
+
Active: (none)
+
—
+
+ +
+
+
Active room
+ +
+ + +
+
+
+ + + + diff --git a/pygame test/notes.txt b/pygame test/notes.txt new file mode 100644 index 0000000..3215771 --- /dev/null +++ b/pygame test/notes.txt @@ -0,0 +1,50 @@ +New skills +Tether + - hit an enemy, inflict a visual bind for a few seconds, end effect depends on which form the player is in when it ends + - light form: blind enemy (make them walk the other direction) + - shade form: instakill after a certain enemy HP threshold, slow otherwise + +Reality Break + - light form: make all enemies double their drop rates + - shade form: instantly kill all enemies, halves drop rates + +Shatterstep + - light form: for X seconds, create a platform below your feet when jumping, allowing for multiple jumps in the air + - shade form: when jumping, create aoe below you upon landing + + +Skill upgrades +Dash + - Refund cost upon successful stun/kill + - lower cooldown + - more range + - build up movement speed stacks with every dash, lose stacks upon getting hit + - more damage against targets that are stunned (inflict mark?) + - leave behind afterimage (enemy taunt) + +Ranged Attack + - hold to charge, release for stronger attack (more damage and stun) + - faster speed, higher charge cost + - slower speed but more damage + - upgrade light form: pierce and slow + - upgrade shade form: aoe + - for every enemy kill collect stacks that increase damage, lose stacks upon not finishing enemies + +Tether + - light form: higher hp threshold for kill; if used against boss: slower attacks + - shade form: longer fear; if used against boss: slower movement speed + + +How to obtain skill upgrades +- vendor (smith? trainer?) that can upgrade one spell (selection can be changed but requires paying for it every time) +- item (gem?) that can be slotted into armor to unlock a skill upgrade + + + + + +Ascended armor + - select one awakened armor piece as the main piece and one as the sacrifice + - select X number of traits from the sacrifice to merge onto the main piece + - sacrifice gets destroyed in the process, creating an Ascended armor piece + - cost: many coins/rare resource? \ No newline at end of file diff --git a/pygame test/pygame test.pyproj b/pygame test/pygame test.pyproj new file mode 100644 index 0000000..18b98ba --- /dev/null +++ b/pygame test/pygame test.pyproj @@ -0,0 +1,36 @@ + + + Debug + 2.0 + e4b51f4c-e0be-4f9e-8a8e-d814a78b30c5 + . + pygame_test.py + + + . + . + pygame test + pygame test + unittest + + + true + false + + + true + false + + + + + + + + + + + + \ No newline at end of file diff --git a/pygame test/pygame_test.py b/pygame test/pygame_test.py new file mode 100644 index 0000000..f9377db --- /dev/null +++ b/pygame test/pygame_test.py @@ -0,0 +1,5494 @@ +import os +import json +import random +from pathlib import Path +from copy import deepcopy +import pygame +import math + + + +# --- Boss Shockwave Settings --- +BOSS_SLAM_COOLDOWN = 3.0 # seconds between slams +BOSS_TRIGGER_CLOSE = 140 # px: slam if player is close +BOSS_TRIGGER_FAR = 520 # px: slam if player is far (anti-cheese) +BOSS_JUMP_VEL = -16.0 # slam jump strength +SHOCKWAVE_SPEED = 600.0 # px/sec +SHOCKWAVE_LIFETIME = 2.0 # seconds +SHOCKWAVE_HEIGHT = 14 # hitbox height +SHOCKWAVE_DAMAGE = 2 + +# --- Ranged ability / projectile settings --- +RANGED_CHARGE_COST = 5.0 # costs more than melee (melee costs 1) +RANGED_DAMAGE_MULT = 0.5 # 50% of melee damage +RANGED_COOLDOWN = 0.45 # seconds between shots (feel-tweak) +PROJECTILE_SPEED = 650.0 # px/sec +PROJECTILE_LIFETIME = 1.2 # seconds +PROJECTILE_SIZE = (14, 6) # width, height + +# --- Loot physics --- +LOOT_GRAVITY = 900.0 # px/sec^2 +LOOT_TERMINAL_VEL = 900.0 # px/sec +LOOT_BOUNCE = 0.0 # keep 0.0 for no bounce (clean) +LOOT_FRICTION = 0.85 # x damp when on ground (if you later add x motion) +LOOT_BOB_AMP = 3 # pixels +LOOT_BOB_SPEED = 2.2 # radians/sec-ish feel + +# --- Reward room setup --- +REWARD_ROOM_ID = "room_05.json" +REWARD_CHEST_POS = (520, 330) +REWARD_ABILITY_POS = (820, 210) +REWARD_CHEST_COINS = (30, 30) # light, dark + +# --- NPC settings --- +NPC_TALK_RANGE = 110 # px +NPC_TRADE_COST = 20 + +# --- Player idle animation --- +PLAYER_IDLE_FRAME_TIME = 0.8 +PLAYER_IDLE_START_DELAY = 0.15 +PLAYER_IDLE_BOB_AMP = 2 +PLAYER_IDLE_BOB_SPEED = 2.0 + +# --- Dash ability --- +DASH_COOLDOWN = 2.0 +DASH_DURATION = 0.18 +DASH_SPEED = 16.0 +DASH_LIGHT_CHARGE_COST = 10.0 +DASH_SHADE_HEALTH_COST = 1 +DASH_LIGHT_INVULN = 0.35 +DASH_STUN_NORMAL = 2.0 +DASH_STUN_MINIBOSS = 1.5 +DASH_STUN_BOSS = 1.0 + +# --- Shatterstep ability --- +SHATTERSTEP_DURATION = 5.0 +SHATTERSTEP_COOLDOWN = 6.0 +SHATTER_PLATFORM_LIFETIME = 3.0 +SHATTER_PLATFORM_COOLDOWN = 0.5 +SHATTER_PLATFORM_SIZE = (80, 10) +SHATTER_AOE_SIZE = (140, 30) +SHATTER_AOE_LIFETIME = 0.25 +SHATTER_ICON_SIZE = 32 +SHATTER_ICON_POS = (20, 20) + +# --- Tether ability --- +TETHER_BIND_DURATION = 3.0 +TETHER_COOLDOWN = 6.0 +TETHER_SPEED = 900.0 +TETHER_LIFETIME = 0.55 +TETHER_SIZE = (20, 6) +TETHER_FEAR_SPEED_MULT = 1.5 +TETHER_ICON_SIZE = 32 +TETHER_ICON_POS = (20, 60) +TETHER_SPRITE_DRAW_SIZE = (32, 16) + +# --- Reality Break ability --- +REALITY_BREAK_ICON_SIZE = 32 +REALITY_BREAK_ICON_POS = (20, 100) +REALITY_BREAK_FLASH_DURATION = 0.6 + +# ---------------------------- +# Key bindings (runtime only) +# ---------------------------- +BINDINGS_DEFAULT = { + "move_left": pygame.K_a, + "move_right": pygame.K_d, + "move_down": pygame.K_s, + "jump": pygame.K_w, + "heal": pygame.K_SPACE, + "attack": pygame.K_e, + "ranged": pygame.K_q, + "dash": pygame.K_TAB, + "shatterstep": pygame.K_r, + "tether": pygame.K_x, + "reality_break": pygame.K_c, + "toggle_shade": pygame.K_LSHIFT, + "inventory": pygame.K_i, + "interact": pygame.K_f, + "pause": pygame.K_ESCAPE, + "ui_up": pygame.K_UP, + "ui_down": pygame.K_DOWN, + "ui_left": pygame.K_LEFT, + "ui_right": pygame.K_RIGHT, + "ui_confirm": pygame.K_RETURN, + "ui_confirm_alt": pygame.K_KP_ENTER, + "ui_cancel": pygame.K_ESCAPE, + "debug_1": pygame.K_F5, + "debug_2": pygame.K_F6, + "debug_3": pygame.K_F7, +} + +BINDINGS = dict(BINDINGS_DEFAULT) + +def key_name(key_code: int) -> str: + if key_code is None: + return "-" + try: + return pygame.key.name(key_code) + except Exception: + return str(key_code) + +def key_for(action: str) -> int | None: + return BINDINGS.get(action) + +def key_is(action: str, key: int) -> bool: + return key_for(action) == key + +def key_pressed(keys, action: str) -> bool: + k = key_for(action) + return bool(k is not None and keys[k]) + +def scale_count(base: int, mult: float) -> int: + if mult <= 0: + return 0 + scaled = float(base) * float(mult) + whole = int(scaled) + frac = scaled - whole + if random.random() < frac: + whole += 1 + return max(0, whole) + +SETTINGS_ACTIONS = [ + ("Move Left", "move_left"), + ("Move Right", "move_right"), + ("Move Down", "move_down"), + ("Jump", "jump"), + ("Heal", "heal"), + ("Attack", "attack"), + ("Ranged", "ranged"), + ("Dash", "dash"), + ("Shatterstep", "shatterstep"), + ("Tether", "tether"), + ("Reality Break", "reality_break"), + ("Toggle Shade", "toggle_shade"), + ("Inventory", "inventory"), + ("Interact/Trade", "interact"), + ("Pause", "pause"), + ("Debug 1", "debug_1"), + ("Debug 2", "debug_2"), + ("Debug 3", "debug_3"), +] + + +# ---------------------------- +# Paths + JSON loading helpers +# ---------------------------- +BASE_DIR = Path(__file__).resolve().parent + +DATA_ROOT = BASE_DIR / "data" +CONFIG_DIR = DATA_ROOT / "config" +DROPS_DIR = DATA_ROOT / "drops" +ITEMS_DIR = DATA_ROOT / "items" +ENEMIES_DIR = DATA_ROOT / "enemies" +LEVELS_DIR = DATA_ROOT / "levels" # legacy (still supported) +ROOMS_DIR = DATA_ROOT / "world" / "rooms" +UI_DIR = DATA_ROOT / "ui" +UI_ICONS_SLOTS_DIR = UI_DIR / "icons" / "slots" +OBJECTS_DIR = DATA_ROOT / "sprites" / "objects" + + + + +def load_json(path: Path) -> dict: + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +def find_file_anywhere(filename: str, roots: list[Path]) -> Path | None: + """Try typical locations first, then fallback to rglob search inside the given roots.""" + for r in roots: + p = r / filename + if p.exists(): + return p + for r in roots: + if r.exists(): + hits = list(r.rglob(filename)) + if hits: + return hits[0] + return None + + +def load_json_safe(filename: str, roots: list[Path], default: dict) -> dict: + p = find_file_anywhere(filename, roots) + if not p: + print(f"[JSON missing] {filename} (using defaults)") + return deepcopy(default) + try: + return load_json(p) + except Exception as e: + print(f"[JSON load failed] {p} -> {e} (using defaults)") + return deepcopy(default) + +# ---------------------------- +# Game config (numbers & colors) +# ---------------------------- +GAME_CONFIG_DEFAULT = { + "window": {"width": 800, "height": 450, "caption": "Light_and_Dark Prototype", "fps": 60}, + "colors": {}, + "loot_colors": {}, + "physics": {}, + "resources": {}, + "healing": {}, + "boss_shockwave": {}, + "assets": {"player_sprite": "Player_01.png", "player_sprite_shade": "Player_02.png"}, + "debug": {"drop_debug_print": True} +} + +GAME_CONFIG = load_json_safe("game_config.json", [CONFIG_DIR, DATA_ROOT], GAME_CONFIG_DEFAULT) + +# --- Colors config (separate file to declutter code) --- +COLOR_CFG = load_json_safe("colors.json", [CONFIG_DIR, DATA_ROOT], {}) + +# --- Physics config (separate file to declutter code) --- +PHYS_CFG = load_json_safe("physics.json", [CONFIG_DIR, DATA_ROOT], {}) + +# --- UI layout config (separate file to declutter UI numbers) --- +UI_CFG = load_json_safe("ui_layout.json", [CONFIG_DIR, DATA_ROOT], {}) + +# --- Additional tuning configs --- +RANGED_CFG = load_json_safe("ranged.json", [CONFIG_DIR, DATA_ROOT], {}) +REWARD_CFG = load_json_safe("reward_room.json", [CONFIG_DIR, DATA_ROOT], {}) +NPC_CFG = load_json_safe("npc.json", [CONFIG_DIR, DATA_ROOT], {}) +ANIM_CFG = load_json_safe("animation.json", [CONFIG_DIR, DATA_ROOT], {}) +COIN_DROPS_CFG = load_json_safe("coin_drops.json", [CONFIG_DIR, DATA_ROOT], {}) + +def _cfg(path, default=None): + cur = GAME_CONFIG + for key in path: + if not isinstance(cur, dict) or key not in cur: + return default + cur = cur[key] + return cur + +def _color(path, default=(255, 255, 255)): + cur = COLOR_CFG + for key in path: + if not isinstance(cur, dict) or key not in cur: + return default + cur = cur[key] + # Expect JSON arrays like [30, 30, 30] + return tuple(int(x) for x in cur) + +def _phys(path, default=None): + cur = PHYS_CFG + for key in path: + if not isinstance(cur, dict) or key not in cur: + return default + cur = cur[key] + return cur + +def _ui(path, default=None): + cur = UI_CFG + for key in path: + if not isinstance(cur, dict) or key not in cur: + return default + cur = cur[key] + return cur + +def _ranged(path, default=None): + cur = RANGED_CFG + for key in path: + if not isinstance(cur, dict) or key not in cur: + return default + cur = cur[key] + return cur + +def _reward(path, default=None): + cur = REWARD_CFG + for key in path: + if not isinstance(cur, dict) or key not in cur: + return default + cur = cur[key] + return cur + +def _npc(path, default=None): + cur = NPC_CFG + for key in path: + if not isinstance(cur, dict) or key not in cur: + return default + cur = cur[key] + return cur + +def _anim(path, default=None): + cur = ANIM_CFG + for key in path: + if not isinstance(cur, dict) or key not in cur: + return default + cur = cur[key] + return cur + +def _coin_drop(path, default=None): + def _get_from(cfg): + cur = cfg + for key in path: + if not isinstance(cur, dict) or key not in cur: + return None + cur = cur[key] + return cur + + room_cfg = None + try: + room_cfg = ROOM.coin_drops_cfg + except Exception: + room_cfg = None + + if isinstance(room_cfg, dict): + room_val = _get_from(room_cfg) + if room_val is not None: + return room_val + + global_val = _get_from(COIN_DROPS_CFG) + if global_val is not None: + return global_val + return default + + +# --- Colors --- +BG = _color(["bg"]) +PLAYER_LIGHT = _color(["player", "light"]) +PLAYER_DARK = _color(["player", "dark"]) +PLATFORM_COLOR = _color(["platform"]) + +CHARGE_BG = _color(["bars", "charge_bg"]) +CHARGE_FILL = _color(["bars", "charge_fill"]) + +HEALTH_BG = _color(["bars", "health_bg"]) +HEALTH_FILL = _color(["bars", "health_fill"]) + +ENEMY_COLOR = _color(["enemy", "normal"]) +ENEMY_HIT_COLOR = _color(["enemy", "normal_hit"]) + +MINIBOSS_COLOR = _color(["enemy", "miniboss"]) +MINIBOSS_HIT_COLOR = _color(["enemy", "miniboss_hit"]) + +BOSS_COLOR = _color(["enemy", "boss"]) +BOSS_HIT_COLOR = _color(["enemy", "boss_hit"]) + +PLAYER_HIT_COLOR = (255, 255, 0) + +HEAL_DARK = _color(["healing", "dark"]) +HEAL_LIGHT = _color(["healing", "light"]) + +LOOT_COLORS = { + k: tuple(v) for k, v in COLOR_CFG.get("loot", {}).items() +} + + +# --- Physics Settings --- +PLAYER_SPEED = float(_phys(["player", "speed_light"], 5.0)) +PLAYER_SHADE_SPEED = float(_phys(["player", "speed_shade"], 3.5)) +JUMP_LIGHT = float(_phys(["player", "jump_light"], -12.0)) +JUMP_SHADE = float(_phys(["player", "jump_shade"], -9.0)) +GRAVITY_LIGHT = float(_phys(["player", "gravity_light"], 0.5)) +GRAVITY_SHADE = float(_phys(["player", "gravity_shade"], 0.4)) + +# Sprite offsets (tune to align art with hitbox) +PLAYER_SPRITE_OFFSET_X = int(_cfg(["player", "sprite_offset_x"], 0)) +PLAYER_SPRITE_OFFSET_Y = int(_cfg(["player", "sprite_offset_y"], 0)) +PLAYER_SPRITE_SCALE = float(_cfg(["player", "sprite_scale"], 1.3)) +PLAYER_HITBOX_W = int(_cfg(["player", "hitbox_w"], 48)) +PLAYER_HITBOX_H = int(_cfg(["player", "hitbox_h"], 72)) + +BASE_MAX_CHARGE = float(_phys(["resources", "base_max_charge"], 100.0)) +CHARGE_DRAIN_RATE = float(_phys(["resources", "charge_drain_rate"], 40.0)) +CHARGE_REFILL_RATE = float(_phys(["resources", "charge_refill_rate"], 20.0)) + +BASE_MAX_HEALTH = int(_phys(["resources", "base_max_health"], 10)) +INVINCIBILITY_DURATION = float(_phys(["resources", "invincibility_duration"], 1.0)) + +# --- Healing Settings --- +HEAL_DURATION = float(_phys(["healing", "heal_duration"], 2.0)) +HEAL_COST_FLAT = float(_phys(["healing", "heal_cost_flat"], 50.0)) +HEAL_SLOW_MULT = float(_phys(["healing", "heal_slow_mult"], 0.30)) #70% slow when healing + +# --- Boss shockwave (from physics.json) --- +BOSS_SLAM_COOLDOWN = float(_phys(["boss_shockwave", "slam_cooldown"], BOSS_SLAM_COOLDOWN)) +BOSS_TRIGGER_CLOSE = float(_phys(["boss_shockwave", "trigger_close"], BOSS_TRIGGER_CLOSE)) +BOSS_TRIGGER_FAR = float(_phys(["boss_shockwave", "trigger_far"], BOSS_TRIGGER_FAR)) +BOSS_JUMP_VEL = float(_phys(["boss_shockwave", "jump_vel"], BOSS_JUMP_VEL)) +SHOCKWAVE_SPEED = float(_phys(["boss_shockwave", "shockwave_speed"], SHOCKWAVE_SPEED)) +SHOCKWAVE_LIFETIME = float(_phys(["boss_shockwave", "shockwave_lifetime"], SHOCKWAVE_LIFETIME)) +SHOCKWAVE_HEIGHT = int(_phys(["boss_shockwave", "shockwave_height"], SHOCKWAVE_HEIGHT)) +SHOCKWAVE_DAMAGE = int(_phys(["boss_shockwave", "shockwave_damage"], SHOCKWAVE_DAMAGE)) + +# --- Loot physics (from physics.json) --- +LOOT_GRAVITY = float(_phys(["loot_physics", "gravity"], LOOT_GRAVITY)) +LOOT_TERMINAL_VEL = float(_phys(["loot_physics", "terminal_vel"], LOOT_TERMINAL_VEL)) +LOOT_BOUNCE = float(_phys(["loot_physics", "bounce"], LOOT_BOUNCE)) +LOOT_FRICTION = float(_phys(["loot_physics", "friction"], LOOT_FRICTION)) +LOOT_BOB_AMP = float(_phys(["loot_physics", "bob_amp"], LOOT_BOB_AMP)) +LOOT_BOB_SPEED = float(_phys(["loot_physics", "bob_speed"], LOOT_BOB_SPEED)) + +# --- Ranged / projectile settings (from ranged.json) --- +RANGED_CHARGE_COST = float(_ranged(["charge_cost"], RANGED_CHARGE_COST)) +RANGED_DAMAGE_MULT = float(_ranged(["damage_mult"], RANGED_DAMAGE_MULT)) +RANGED_COOLDOWN = float(_ranged(["cooldown"], RANGED_COOLDOWN)) +PROJECTILE_SPEED = float(_ranged(["projectile", "speed"], PROJECTILE_SPEED)) +PROJECTILE_LIFETIME = float(_ranged(["projectile", "lifetime"], PROJECTILE_LIFETIME)) +PROJECTILE_SIZE = tuple(_ranged(["projectile", "size"], list(PROJECTILE_SIZE))) + +# --- Reward room settings (from reward_room.json) --- +REWARD_ROOM_ID = str(_reward(["room_id"], REWARD_ROOM_ID)) +REWARD_CHEST_POS = tuple(_reward(["chest_pos"], list(REWARD_CHEST_POS))) +REWARD_ABILITY_POS = tuple(_reward(["ability_pos"], list(REWARD_ABILITY_POS))) +REWARD_CHEST_COINS = tuple(_reward(["chest_coins"], list(REWARD_CHEST_COINS))) + +# --- NPC settings (from npc.json) --- +NPC_TALK_RANGE = int(_npc(["talk_range"], NPC_TALK_RANGE)) +NPC_TRADE_COST = int(_npc(["trade_cost"], NPC_TRADE_COST)) + +# --- Player idle animation (from animation.json) --- +PLAYER_IDLE_FRAME_TIME = float(_anim(["player_idle", "frame_time"], PLAYER_IDLE_FRAME_TIME)) +PLAYER_IDLE_START_DELAY = float(_anim(["player_idle", "start_delay"], PLAYER_IDLE_START_DELAY)) +PLAYER_IDLE_BOB_AMP = int(_anim(["player_idle", "bob_amp"], PLAYER_IDLE_BOB_AMP)) +PLAYER_IDLE_BOB_SPEED = float(_anim(["player_idle", "bob_speed"], PLAYER_IDLE_BOB_SPEED)) + +# --- Coins --- +COIN_UI_SIZE = int(_ui(["coins_hud", "icon_size"], 14)) +COIN_UI_GAP = int(_ui(["coins_hud", "icon_gap"], 6)) + +pygame.init() + +# --- Window setup --- +USE_DISPLAY_SIZE = bool(_cfg(["window", "use_display_size"], True)) +WIDTH = int(_cfg(["window", "width"], 800)) +HEIGHT = int(_cfg(["window", "height"], 450)) +FPS = int(_cfg(["window", "fps"], 60)) + +if USE_DISPLAY_SIZE: + display_info = pygame.display.Info() + if display_info.current_w > 0 and display_info.current_h > 0: + WIDTH, HEIGHT = int(display_info.current_w), int(display_info.current_h) + +screen = pygame.display.set_mode((WIDTH, HEIGHT)) +pygame.display.set_caption(str(_cfg(["window", "caption"], "Shade Prototype"))) +clock = pygame.time.Clock() +font = pygame.font.SysFont(None, 22) + +def build_vignette_surface( + w: int, + h: int, + color: tuple[int, int, int], + edge_alpha: int = 230, + steps: int = 96, + radius_scale: float = 0.425, + base_alpha: int = 0, + min_alpha: float = 0.0, +) -> pygame.Surface: + # Darken edges more, keep center lighter with a smooth blend. + surf = pygame.Surface((w, h), pygame.SRCALPHA) + if base_alpha > 0: + surf.fill((*color, base_alpha)) + cx, cy = w // 2, h // 2 + max_r = int((w * w + h * h) ** 0.5 / 2) + for i in range(steps): + t = i / max(1, steps - 1) + radius = int(max_r * (1.0 - t * radius_scale)) + falloff = (1.0 - t) ** 2 + alpha = int(edge_alpha * (min_alpha + (1.0 - min_alpha) * falloff)) + if radius <= 0 or alpha <= 0: + continue + pygame.draw.circle(surf, (*color, alpha), (cx, cy), radius) + return surf + +VIGNETTE_LIGHT = build_vignette_surface(WIDTH, HEIGHT, (0, 0, 0), edge_alpha=255, radius_scale=1.0, base_alpha=180, min_alpha=0.30) +VIGNETTE_LIGHT_EDGE = build_vignette_surface(WIDTH, HEIGHT, (0, 0, 0), edge_alpha=255, radius_scale=1.0, base_alpha=0, min_alpha=0.0) +VIGNETTE_SHADE = build_vignette_surface(WIDTH, HEIGHT, (90, 30, 130), edge_alpha=120, radius_scale=1.0, base_alpha=85, min_alpha=0.05) +VIGNETTE_SHADE_EDGE = build_vignette_surface(WIDTH, HEIGHT, (90, 30, 130), edge_alpha=120, radius_scale=1.0, base_alpha=0, min_alpha=0.0) + +VIGNETTE_CLEAR_RADIUS = 150 +_VIGNETTE_HOLE_CACHE: dict[tuple[int, int], pygame.Surface] = {} + +def _get_vignette_hole(radius: int, feather: int = 40) -> pygame.Surface: + r = max(1, int(radius)) + f = max(1, int(feather)) + key = (r, f) + if key in _VIGNETTE_HOLE_CACHE: + return _VIGNETTE_HOLE_CACHE[key] + surf = pygame.Surface((r * 2, r * 2), pygame.SRCALPHA) + if feather <= 0: + pygame.draw.circle(surf, (0, 0, 0, 255), (r, r), r) + else: + steps = max(8, f) + for i in range(steps): + t = i / max(1, steps - 1) + rad = int(r * (1.0 - t)) + alpha = int(255 * (1.0 - t) ** 2) + if rad <= 0 or alpha <= 0: + continue + pygame.draw.circle(surf, (0, 0, 0, alpha), (r, r), rad) + _VIGNETTE_HOLE_CACHE[key] = surf + return surf + +def blit_vignette_with_hole(target: pygame.Surface, vignette: pygame.Surface, center: tuple[int, int], radius: int, feather: int = 40): + if vignette is None: + return + temp = vignette.copy() + hole = _get_vignette_hole(radius, feather) + hx = int(center[0] - hole.get_width() // 2) + hy = int(center[1] - hole.get_height() // 2) + temp.blit(hole, (hx, hy), special_flags=pygame.BLEND_RGBA_SUB) + target.blit(temp, (0, 0)) + +# ---------------------------- +# Item data (armor + rarities) +# ---------------------------- +RARITIES_DEFAULT = { + "tempered": {"tier": 1, "allows_abilities": False}, + "attuned": {"tier": 2, "allows_abilities": False}, + "awakened": {"tier": 3, "allows_abilities": True}, +} + +RARITIES = load_json_safe("rarities.json", [ITEMS_DIR, DATA_ROOT], RARITIES_DEFAULT) +armor_json = load_json_safe("armor.json", [ITEMS_DIR, DATA_ROOT], {"items": []}) +ARMOR_DB = armor_json.get("items", []) if isinstance(armor_json, dict) else [] +ARMOR_BY_ID = {it["id"]: it for it in ARMOR_DB if isinstance(it, dict) and "id" in it} + +necklace_json = load_json_safe("necklaces.json", [ITEMS_DIR, DATA_ROOT], {"items": []}) +NECKLACE_DB = necklace_json.get("items", []) if isinstance(necklace_json, dict) else [] +NECKLACE_BY_ID = {it["id"]: it for it in NECKLACE_DB if isinstance(it, dict) and "id" in it} + +ITEM_BY_ID = {} +ITEM_BY_ID.update(ARMOR_BY_ID) +ITEM_BY_ID.update(NECKLACE_BY_ID) + +def load_slot_icons(): + icons = {} + + def load_icon(name: str): + path = UI_ICONS_SLOTS_DIR / f"{name}.png" + if not path.exists(): + return None + try: + img = pygame.image.load(str(path)).convert_alpha() + return pygame.transform.smoothscale(img, (16, 16)) + except Exception as e: + print(f"[UI] Failed to load icon {path}: {e}") + return None + + icons["helmet"] = load_icon("helmet") + icons["chest"] = load_icon("chestplate") + icons["pants"] = load_icon("pants") + icons["necklace"] = load_icon("necklace") + icons["unknown"] = load_icon("unknown") + + return icons + +def tint_icon(surface: pygame.Surface, color: tuple[int, int, int]) -> pygame.Surface: + """ + Tint a white/gray icon to `color` while keeping alpha. + Works best if the icon is white with transparent background. + """ + if surface is None: + return None + + tinted = surface.copy().convert_alpha() + # Multiply RGB by the tint color; alpha stays. + tinted.fill(color + (255,), special_flags=pygame.BLEND_RGBA_MULT) + return tinted + +# ---------------------------- +# World-drop icons (tinted by rarity) +# ---------------------------- +DROP_SLOT_ICONS = load_slot_icons() +DROP_ICON_CACHE: dict[tuple[str, str], pygame.Surface | None] = {} + +def get_drop_icon(slot: str, rarity: str) -> pygame.Surface | None: + """ + Returns a 16x16 icon for this slot, tinted by rarity (cached). + """ + slot = slot or "unknown" + rarity = rarity or "" + + key = (slot, rarity) + if key in DROP_ICON_CACHE: + return DROP_ICON_CACHE[key] + + base = DROP_SLOT_ICONS.get(slot) or DROP_SLOT_ICONS.get("unknown") + if base is None: + DROP_ICON_CACHE[key] = None + return None + + col = LOOT_COLORS.get(rarity) + icon = tint_icon(base, col) if col else base + + DROP_ICON_CACHE[key] = icon + return icon + + +# ---------------------------- +# Set bonuses +# ---------------------------- +SET_BONUSES_DEFAULT = {} +SET_BONUSES = load_json_safe("sets.json", [ITEMS_DIR, DATA_ROOT], SET_BONUSES_DEFAULT) + + + +def build_item_instance(base_item: dict, rarity_id: str) -> dict: + if rarity_id not in RARITIES: + raise ValueError(f"Unknown rarity: {rarity_id}") + + inst = deepcopy(base_item) + inst["rarity"] = rarity_id + inst["tier"] = int(RARITIES[rarity_id].get("tier", 1)) + + stats = deepcopy(inst.get("base_stats", {})) + overrides = inst.get("rarity_overrides", {}).get(rarity_id, {}) + extra = overrides.get("extra_stats", {}) + for k, v in extra.items(): + merge_stat_value(stats, k, v) + + + inst["stats"] = stats + if RARITIES[rarity_id].get("allows_abilities", False): + inst["abilities"] = overrides.get("abilities", []) + else: + inst["abilities"] = [] + return inst + + +def build_item_instance_any(base_item: dict, rarity_id: str | None) -> dict: + # Necklaces have no rarity for now + if base_item.get("slot") == "necklace" or base_item.get("item_type") == "necklace": + inst = deepcopy(base_item) + inst["rarity"] = "" + inst["tier"] = 0 + inst["abilities"] = [] + stats = deepcopy(inst.get("base_stats", {})) + inst["stats"] = stats + return inst + if not rarity_id: + rarity_id = "tempered" + return build_item_instance(base_item, rarity_id) + + +def safe_get_item(item_id: str): + return ITEM_BY_ID.get(item_id) + + +def merge_stat_value(dst: dict, key: str, value): + """Numeric stats add, boolean stats OR, everything else overwrites.""" + if isinstance(value, bool): + dst[key] = bool(dst.get(key, False)) or value + elif isinstance(value, (int, float)): + dst[key] = float(dst.get(key, 0.0)) + float(value) + else: + dst[key] = value + +def tint_surface_keep_alpha(src: pygame.Surface, rgb: tuple[int, int, int]) -> pygame.Surface: + """Multiply-tint a sprite while preserving alpha (works great for white sprites).""" + s = src.copy() + s.fill((*rgb, 255), special_flags=pygame.BLEND_RGBA_MULT) + return s + + +# ---------------------------- +# Enemy config (types + drops) +# ---------------------------- +ENEMY_TYPES_DEFAULT = { + "enemy": { + "rect": [40, 60], + "health": 3, + "speed": 2.2, + "air_speed": 2.0, + "detection_range": 240, + "contact_damage": 1, + "drop_profile": "normal", + }, + "miniboss": { + "rect": [70, 90], + "health": 12, + "speed": 1.9, + "air_speed": 1.7, + "detection_range": 300, + "contact_damage": 2, + "drop_profile": "miniboss", + }, + "boss": { + "rect": [110, 120], + "health": 30, + "speed": 1.6, + "air_speed": 1.4, + "detection_range": 520, + "contact_damage": 3, + "drop_profile": "boss", + }, +} + +ENEMY_DROPS_DEFAULT = { + "normal": { + "rolls": 1, + "rarity_table": {"tempered": 0.10, "none": 0.90} + }, + "miniboss": { + "rolls": 2, + "rarity_table": {"tempered": 0.75, "attuned": 0.25, "none": 0.0} + }, + "boss": { + "guaranteed": [ + {"rarity": "awakened", "count": 1}, + {"rarity": "attuned", "count": 1} + ], + "rolls": 1, + "rarity_table": {"attuned": 0.50, "none": 0.50} + } +} + + +ENEMY_TYPES = load_json_safe("enemy_types.json", [ENEMIES_DIR, DATA_ROOT], ENEMY_TYPES_DEFAULT) +ENEMY_DROPS = load_json_safe("enemy_drops.json", [DROPS_DIR, DATA_ROOT], ENEMY_DROPS_DEFAULT) + + + +# ---------------------------- +# Rooms (platforms + spawns + exits) +# ---------------------------- +ROOM_DEFAULT = { + "meta": {"id": "room_01", "name": "Default Room"}, + "player_spawn": [200, 100], + "bounds": {"kill_y": 1400}, + "platforms": { + "ground": [ + [-400, 350, 900, 200], + [650, 350, 600, 200], + ], + "floating": [ + [150, 300, 120, 20], + [320, 260, 120, 20], + ], + }, + "enemies": [ + {"type": "enemy", "pos": [700, 0]}, + ], + "npcs": [], + "exits": [], +} + + +def rects_from_list(list_of_rects) -> list[pygame.Rect]: + out = [] + if not isinstance(list_of_rects, list): + return out + for r in list_of_rects: + if isinstance(r, (list, tuple)) and len(r) == 4: + out.append(pygame.Rect(int(r[0]), int(r[1]), int(r[2]), int(r[3]))) + elif isinstance(r, dict): + out.append(pygame.Rect(int(r.get("x", 0)), int(r.get("y", 0)), int(r.get("w", 0)), int(r.get("h", 0)))) + return out + + +def normalize_room_id(to_value) -> str: + """Accepts 'room_02', 'room_02.json', or a path-like string; returns filename-ish id.""" + if not to_value: + return "room_01.json" + s = str(to_value) + s = os.path.basename(s) + if not s.lower().endswith(".json"): + s = s + ".json" + return s + + +def normalize_room_json(room_json: dict) -> dict: + """Normalize editor/game room formats into the expected game format.""" + if not isinstance(room_json, dict): + return {} + + out = deepcopy(room_json) + + # platforms -> grouped dict + platforms = out.get("platforms", {}) + if isinstance(platforms, list): + grouped = {"ground": [], "floating": []} + for p in platforms: + if isinstance(p, (list, tuple)) and len(p) == 4: + grouped["ground"].append([p[0], p[1], p[2], p[3]]) + elif isinstance(p, dict): + rect = [p.get("x", 0), p.get("y", 0), p.get("w", 0), p.get("h", 0)] + group = p.get("group", "ground") + grouped.setdefault(group, []) + grouped[group].append(rect) + out["platforms"] = grouped + elif not isinstance(platforms, dict): + out["platforms"] = {"ground": [], "floating": []} + + # enemies -> ensure pos:[x,y] + enemies = out.get("enemies", []) + if isinstance(enemies, list): + norm_enemies = [] + for e in enemies: + if not isinstance(e, dict): + continue + if not (isinstance(e.get("pos"), (list, tuple)) and len(e["pos"]) == 2): + if "x" in e and "y" in e: + e = dict(e) + e["pos"] = [e.get("x", 0), e.get("y", 0)] + norm_enemies.append(e) + out["enemies"] = norm_enemies + + # npcs -> ensure pos:[x,y] + npcs = out.get("npcs", []) + if isinstance(npcs, list): + norm_npcs = [] + for n in npcs: + if not isinstance(n, dict): + continue + if not (isinstance(n.get("pos"), (list, tuple)) and len(n["pos"]) == 2): + if "x" in n and "y" in n: + n = dict(n) + n["pos"] = [n.get("x", 0), n.get("y", 0)] + norm_npcs.append(n) + out["npcs"] = norm_npcs + + # exits -> ensure rect:[x,y,w,h] + exits = out.get("exits", []) + if isinstance(exits, list): + norm_exits = [] + for ex in exits: + if not isinstance(ex, dict): + continue + if not (isinstance(ex.get("rect"), (list, tuple)) and len(ex["rect"]) == 4): + if all(k in ex for k in ("x", "y", "w", "h")): + ex = dict(ex) + ex["rect"] = [ex.get("x", 0), ex.get("y", 0), ex.get("w", 0), ex.get("h", 0)] + norm_exits.append(ex) + out["exits"] = norm_exits + + # ability doors -> ensure rect:[x,y,w,h] + ability_doors = out.get("ability_doors", []) + if isinstance(ability_doors, list): + norm_doors = [] + for d in ability_doors: + if isinstance(d, (list, tuple)) and len(d) == 4: + norm_doors.append({"rect": [d[0], d[1], d[2], d[3]]}) + elif isinstance(d, dict): + if isinstance(d.get("rect"), (list, tuple)) and len(d["rect"]) == 4: + norm_doors.append(d) + elif all(k in d for k in ("x", "y", "w", "h")): + norm_doors.append({"rect": [d.get("x", 0), d.get("y", 0), d.get("w", 0), d.get("h", 0)]}) + out["ability_doors"] = norm_doors + + return out + + +class Room: + def __init__(self, room_json: dict, room_id: str): + self.id = room_id + self.meta = room_json.get("meta", {}) if isinstance(room_json, dict) else {} + self.name = self.meta.get("name", room_id) + + self.player_spawn = room_json.get("player_spawn", [200, 100]) + if not (isinstance(self.player_spawn, (list, tuple)) and len(self.player_spawn) == 2): + self.player_spawn = [200, 100] + + self.kill_y = int((room_json.get("bounds", {}) or {}).get("kill_y", 1400)) + + platforms = room_json.get("platforms", {}) if isinstance(room_json, dict) else {} + if isinstance(platforms, list): + self.platforms_ground = rects_from_list(platforms) + self.platforms_floating = [] + elif isinstance(platforms, dict): + self.platforms_ground = rects_from_list(platforms.get("ground", [])) + self.platforms_floating = rects_from_list(platforms.get("floating", [])) + else: + self.platforms_ground = [] + self.platforms_floating = [] + self.ground_platforms = self.platforms_ground[:] + self.platforms = self.platforms_ground + self.platforms_floating + + self.enemies = room_json.get("enemies", []) if isinstance(room_json, dict) else [] + if not isinstance(self.enemies, list): + self.enemies = [] + + self.exits = room_json.get("exits", []) if isinstance(room_json, dict) else [] + if not isinstance(self.exits, list): + self.exits = [] + + self.npcs = room_json.get("npcs", []) if isinstance(room_json, dict) else [] + if not isinstance(self.npcs, list): + self.npcs = [] + + self.props = room_json.get("props", []) if isinstance(room_json, dict) else [] + if not isinstance(self.props, list): + self.props = [] + + self.entry_spawns = room_json.get("entry_spawns", {}) if isinstance(room_json, dict) else {} + if not isinstance(self.entry_spawns, dict): + self.entry_spawns = {} + + self.coin_drops_cfg = room_json.get("coin_drops", None) if isinstance(room_json, dict) else None + + self.ability_doors = room_json.get("ability_doors", []) if isinstance(room_json, dict) else [] + if not isinstance(self.ability_doors, list): + self.ability_doors = [] + + normalized_ability_doors = [] + for d in self.ability_doors: + rect = None + if isinstance(d, dict): + if isinstance(d.get("rect"), (list, tuple)) and len(d["rect"]) == 4: + rect = d["rect"] + elif all(k in d for k in ("x", "y", "w", "h")): + rect = [d.get("x", 0), d.get("y", 0), d.get("w", 0), d.get("h", 0)] + elif isinstance(d, (list, tuple)) and len(d) == 4: + rect = list(d) + if rect is None: + continue + normalized_ability_doors.append({"rect": rect}) + self.ability_doors = normalized_ability_doors + + self.exit_triggers: list[dict] = [] + for ex in self.exits: + if not isinstance(ex, dict): + continue + rr = ex.get("rect") + if not (isinstance(rr, (list, tuple)) and len(rr) == 4): + if all(k in ex for k in ("x", "y", "w", "h")): + rr = [ex.get("x", 0), ex.get("y", 0), ex.get("w", 0), ex.get("h", 0)] + else: + continue + self.exit_triggers.append({ + "rect": pygame.Rect(int(rr[0]), int(rr[1]), int(rr[2]), int(rr[3])), + "to": normalize_room_id(ex.get("to")), + "spawn": ex.get("spawn", None), + "spawn_id": ex.get("spawn_id", None), + "hint": str(ex.get("hint", "")), + "requires_boss_dead": bool(ex.get("requires_boss_dead", False)), + "requires_ranged": bool(ex.get("requires_ranged", False)), + }) + + + +class RoomManager: + def __init__(self): + self.current_room: Room | None = None + self.current_room_id: str | None = None + self.transition_cooldown = 0.0 # seconds (prevents ping-pong) + + def load_room(self, room_id: str, forced_spawn=None) -> Room: + room_id = normalize_room_id(room_id) + + room_json = load_json_safe(room_id, [ROOMS_DIR, LEVELS_DIR, DATA_ROOT, BASE_DIR], ROOM_DEFAULT) + room_json = normalize_room_json(room_json) + + room = Room(room_json, room_id) + if forced_spawn is not None and isinstance(forced_spawn, (list, tuple)) and len(forced_spawn) == 2: + room.player_spawn = [int(forced_spawn[0]), int(forced_spawn[1])] + self.current_room = room + self.current_room_id = room_id + self.transition_cooldown = 2.0 + return room + + def update(self, dt: float): + if self.transition_cooldown > 0: + self.transition_cooldown -= dt + + +# ---------------------------- +# Load player sprite +# ---------------------------- +PLAYER_SPRITE_PATH = BASE_DIR / str(_cfg(["assets", "player_sprite"], "Player_01.png")) +SHADE_SPRITE_PATH = BASE_DIR / str(_cfg(["assets", "player_sprite_shade"], "Player_02.png")) + +try: + PLAYER_SPRITE_RAW = pygame.image.load(str(PLAYER_SPRITE_PATH)).convert_alpha() +except Exception as e: + print(f"[Sprite load failed] {PLAYER_SPRITE_PATH} -> {e}") + PLAYER_SPRITE_RAW = None + +try: + SHADE_SPRITE_RAW = pygame.image.load(str(SHADE_SPRITE_PATH)).convert_alpha() +except Exception as e: + print(f"[Sprite load failed] {SHADE_SPRITE_PATH} -> {e}") + SHADE_SPRITE_RAW = None + +IDLE_SPRITE_FILES = ["Player_01_idle1.png", "Player_01_idle2.png"] +IDLE_SPRITE_RAW = [] +for name in IDLE_SPRITE_FILES: + p = BASE_DIR / name + try: + IDLE_SPRITE_RAW.append(pygame.image.load(str(p)).convert_alpha()) + except Exception as e: + print(f"[Sprite load failed] {p} -> {e}") + +# ---------------------------- +# Load UI sprites (coins) +# ---------------------------- +COINS_DIR = DATA_ROOT / "ui" / "icons" / "coins" +COIN_LIGHT_PATH = COINS_DIR / "coin_light.png" +COIN_DARK_PATH = COINS_DIR / "coin_dark.png" +ABILITIES_DIR = DATA_ROOT / "ui" / "icons" / "abilities" +SHATTER_ICON_LIGHT_PATH = ABILITIES_DIR / "Shatterstep_light.png" +SHATTER_ICON_DARK_PATH = ABILITIES_DIR / "Shatterstep_dark.png" +TETHER_ICON_LIGHT_PATH = ABILITIES_DIR / "Tether_light.png" +TETHER_ICON_DARK_PATH = ABILITIES_DIR / "Tether_dark.png" +REALITY_ICON_LIGHT_PATH = ABILITIES_DIR / "RealityBreak_light.png" +REALITY_ICON_DARK_PATH = ABILITIES_DIR / "RealityBreak_dark.png" +ABILITY_SPRITES_DIR = DATA_ROOT / "sprites" / "abilities" +TETHER_SPRITE_LIGHT_PATH = ABILITY_SPRITES_DIR / "Tether_light_ability.png" +TETHER_SPRITE_DARK_PATH = ABILITY_SPRITES_DIR / "Tether_dark_ability.png" +TETHER_EDGE_LIGHT_PATH = ABILITY_SPRITES_DIR / "Tether_light_ability_edge.png" +TETHER_EDGE_DARK_PATH = ABILITY_SPRITES_DIR / "Tether_dark_ability_edge.png" + +def _load_image_or_none(p: Path): + try: + return pygame.image.load(str(p)).convert_alpha() + except Exception as e: + print(f"[Image load failed] {p} -> {e}") + return None + +COIN_LIGHT_RAW = _load_image_or_none(COIN_LIGHT_PATH) +COIN_DARK_RAW = _load_image_or_none(COIN_DARK_PATH) +SHATTER_ICON_LIGHT_RAW = _load_image_or_none(SHATTER_ICON_LIGHT_PATH) +SHATTER_ICON_DARK_RAW = _load_image_or_none(SHATTER_ICON_DARK_PATH) +TETHER_ICON_LIGHT_RAW = _load_image_or_none(TETHER_ICON_LIGHT_PATH) +TETHER_ICON_DARK_RAW = _load_image_or_none(TETHER_ICON_DARK_PATH) +REALITY_ICON_LIGHT_RAW = _load_image_or_none(REALITY_ICON_LIGHT_PATH) +REALITY_ICON_DARK_RAW = _load_image_or_none(REALITY_ICON_DARK_PATH) +TETHER_SPRITE_LIGHT_RAW = _load_image_or_none(TETHER_SPRITE_LIGHT_PATH) +TETHER_SPRITE_DARK_RAW = _load_image_or_none(TETHER_SPRITE_DARK_PATH) +TETHER_EDGE_LIGHT_RAW = _load_image_or_none(TETHER_EDGE_LIGHT_PATH) +TETHER_EDGE_DARK_RAW = _load_image_or_none(TETHER_EDGE_DARK_PATH) + +def _scale_icon(img: pygame.Surface | None, size: int) -> pygame.Surface | None: + if img is None: + return None + return pygame.transform.smoothscale(img, (size, size)) + +def _scale_image(img: pygame.Surface | None, size: tuple[int, int]) -> pygame.Surface | None: + if img is None: + return None + return pygame.transform.smoothscale(img, (int(size[0]), int(size[1]))) + +SHATTER_ICON_LIGHT = _scale_icon(SHATTER_ICON_LIGHT_RAW, SHATTER_ICON_SIZE) +SHATTER_ICON_DARK = _scale_icon(SHATTER_ICON_DARK_RAW, SHATTER_ICON_SIZE) +TETHER_ICON_LIGHT = _scale_icon(TETHER_ICON_LIGHT_RAW, TETHER_ICON_SIZE) +TETHER_ICON_DARK = _scale_icon(TETHER_ICON_DARK_RAW, TETHER_ICON_SIZE) +REALITY_ICON_LIGHT = _scale_icon(REALITY_ICON_LIGHT_RAW, REALITY_BREAK_ICON_SIZE) +REALITY_ICON_DARK = _scale_icon(REALITY_ICON_DARK_RAW, REALITY_BREAK_ICON_SIZE) +TETHER_SPRITE_LIGHT = _scale_image(TETHER_SPRITE_LIGHT_RAW, TETHER_SPRITE_DRAW_SIZE) +TETHER_SPRITE_LIGHT_SIZE = TETHER_SPRITE_LIGHT.get_size() if TETHER_SPRITE_LIGHT else TETHER_SIZE +TETHER_SPRITE_DARK = _scale_image(TETHER_SPRITE_DARK_RAW, TETHER_SPRITE_DRAW_SIZE) +TETHER_SPRITE_DARK_SIZE = TETHER_SPRITE_DARK.get_size() if TETHER_SPRITE_DARK else TETHER_SIZE +TETHER_EDGE_LIGHT = _scale_image(TETHER_EDGE_LIGHT_RAW, TETHER_SPRITE_DRAW_SIZE) +TETHER_EDGE_DARK = _scale_image(TETHER_EDGE_DARK_RAW, TETHER_SPRITE_DRAW_SIZE) + +LAMP_PATH = OBJECTS_DIR / "lamp_1.png" +LAMP_RAW = _load_image_or_none(LAMP_PATH) + + + +# ---------------------------- +# Loot + Shockwave +# ---------------------------- +def roll_from_table(table: dict) -> str | None: + """table keys: awakened/attuned/tempered/none with probabilities that sum to ~1.""" + r = random.random() + acc = 0.0 + for key in ["awakened", "attuned", "tempered", "none"]: + p = float(table.get(key, 0.0)) if isinstance(table, dict) else 0.0 + acc += p + if r < acc: + return None if key == "none" else key + return None + + +class Shockwave: + def __init__(self, x, y, direction: int, speed: float, lifetime: float, height: int, damage: int): + self.rect = pygame.Rect(int(x), int(y), 26, int(height)) + self.dir = 1 if direction >= 0 else -1 + self.speed = float(speed) + self.t = float(lifetime) + self.damage = int(damage) + + def update(self, dt: float, platforms: list[pygame.Rect]): + self.rect.x += int(self.dir * self.speed * dt) + self.t -= dt + + # kill if it hits a wall + for p in platforms: + if self.rect.colliderect(p): + self.t = 0 + break + + @property + def alive(self) -> bool: + return self.t > 0 + + def draw(self, surface, cx, cy): + pygame.draw.rect( + surface, + (250, 240, 120), + pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height), + ) + pygame.draw.rect( + surface, + (0, 0, 0), + pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height), + 2, + ) + + +class Projectile: + def __init__(self, x, y, direction: int, damage: float): + w, h = PROJECTILE_SIZE + self.rect = pygame.Rect(int(x), int(y), int(w), int(h)) + self.dir = 1 if direction >= 0 else -1 + self.speed = float(PROJECTILE_SPEED) + self.t = float(PROJECTILE_LIFETIME) + self.damage = float(damage) + + def update(self, dt: float, platforms: list[pygame.Rect], enemies: list, doors=None): + # move + self.rect.x += int(self.dir * self.speed * dt) + self.t -= dt + + if self.t <= 0: + return + + # collide with ability doors (open on hit) + if doors: + for d in doors: + if getattr(d, "open", False): + continue + if self.rect.colliderect(d.rect): + d.open = True + self.t = 0 + return + + # collide with world + for p in platforms: + if self.rect.colliderect(p): + self.t = 0 + return + + # collide with enemies + for en in enemies: + if self.rect.colliderect(en.rect): + en.take_damage(self.damage) + self.t = 0 + return + + @property + def alive(self) -> bool: + return self.t > 0 + + def draw(self, surface, cx, cy): + # simple readable bolt + pygame.draw.rect( + surface, + (220, 240, 255), + pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height), + ) + pygame.draw.rect( + surface, + (0, 0, 0), + pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height), + 1, + ) + + +class TetherShot: + def __init__(self, x, y, direction: int, player_ref: "Player"): + w, h = TETHER_SIZE + self.dir = 1 if direction >= 0 else -1 + self.speed = float(TETHER_SPEED) + self.t = float(TETHER_LIFETIME) + self.origin = (int(x), int(y)) + self.player = player_ref + self.alive = True + self.use_light_sprite = not player_ref.shade + if self.use_light_sprite and TETHER_SPRITE_LIGHT is not None: + w, h = TETHER_SPRITE_LIGHT_SIZE + elif (not self.use_light_sprite) and TETHER_SPRITE_DARK is not None: + w, h = TETHER_SPRITE_DARK_SIZE + self.rect = pygame.Rect(0, 0, int(w), int(h)) + self.rect.center = (int(x), int(y)) + + def update(self, dt: float, platforms: list[pygame.Rect], enemies: list): + if not self.alive: + return + self.rect.x += int(self.dir * self.speed * dt) + self.t -= dt + if self.t <= 0: + self.alive = False + if self.player.tether_cd <= 0.0 and self.player.tether_pending_cd <= 0.0: + self.player.tether_cd = TETHER_COOLDOWN + return + + for p in platforms: + if self.rect.colliderect(p): + self.alive = False + if self.player.tether_cd <= 0.0 and self.player.tether_pending_cd <= 0.0: + self.player.tether_cd = TETHER_COOLDOWN + return + + for en in enemies: + if en.health <= 0: + continue + if self.rect.colliderect(en.rect): + ratio = (en.health / en.max_health) if en.max_health > 0 else 0 + if not self.player.shade: + en.bind_timer = max(en.bind_timer, TETHER_BIND_DURATION) + en.fear_timer = max(en.fear_timer, TETHER_BIND_DURATION) + en.bound_light = True + self.player.tether_pending_cd = max(self.player.tether_pending_cd, TETHER_BIND_DURATION) + else: + thresh = (1.0 / 3.0) if en.type_id == "enemy" else (0.20 if en.type_id == "miniboss" else 0.05) + if ratio <= thresh: + en.health = 0 + self.alive = False + self.player.tether_cd = TETHER_COOLDOWN + return + en.bind_timer = max(en.bind_timer, TETHER_BIND_DURATION) + en.bound_light = False + en.slow_timer = max(en.slow_timer, TETHER_BIND_DURATION) + en.slow_mult = 0.8 + self.player.tether_pending_cd = max(self.player.tether_pending_cd, TETHER_BIND_DURATION) + self.alive = False + return + + def draw(self, surface, cx, cy): + ox, oy = self.origin + if self.use_light_sprite and TETHER_SPRITE_LIGHT is not None: + img = TETHER_SPRITE_LIGHT + elif (not self.use_light_sprite) and TETHER_SPRITE_DARK is not None: + img = TETHER_SPRITE_DARK + else: + img = None + if img is not None: + if self.dir < 0: + img = pygame.transform.flip(img, True, False) + seg_w = img.get_width() + if seg_w <= 0: + return + start_x = ox + end_x = self.rect.centerx + step = seg_w if self.dir > 0 else -seg_w + x = start_x + # tile the sprite along the tether length + while (x <= end_x and self.dir > 0) or (x >= end_x and self.dir < 0): + draw_x = int(x - cx - seg_w // 2) + draw_y = int(oy - cy - img.get_height() // 2) + surface.blit(img, (draw_x, draw_y)) + x += step + else: + pygame.draw.line(surface, (180, 180, 200), (ox - cx, oy - cy), (self.rect.centerx - cx, oy - cy), 2) + pygame.draw.rect( + surface, + (200, 200, 220), + pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height), + ) + pygame.draw.rect( + surface, + (40, 40, 60), + pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height), + 1, + ) + + +class ShatterPlatform: + def __init__(self, x: int, y: int, w: int, h: int, lifetime: float): + self.rect = pygame.Rect(int(x), int(y), int(w), int(h)) + self.t = float(lifetime) + + def update(self, dt: float): + self.t -= dt + + @property + def alive(self) -> bool: + return self.t > 0 + + def draw(self, surface, cx, cy): + pygame.draw.rect( + surface, + (90, 180, 220), + pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height), + ) + pygame.draw.rect( + surface, + (20, 40, 50), + pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height), + 2, + ) + + +class ShatterAOE: + def __init__(self, rect: pygame.Rect, lifetime: float): + self.rect = rect.copy() + self.t = float(lifetime) + + def update(self, dt: float): + self.t -= dt + + @property + def alive(self) -> bool: + return self.t > 0 + + def draw(self, surface, cx, cy): + # Visual intentionally disabled; dust puffs indicate the hitbox. + return + + +class DustPuff: + def __init__(self, x: int, y: int, lifetime: float = 0.4, scale: float = 1.0): + self.x = float(x) + self.y = float(y) + self.t = float(lifetime) + self.life = float(lifetime) + self.start_r = random.uniform(8, 14) * scale + self.end_r = self.start_r + random.uniform(12, 18) * scale + self.alpha = 140 + self.vx = random.uniform(-20.0, 20.0) + self.vy = random.uniform(-12.0, -4.0) + + def update(self, dt: float): + self.t -= dt + self.x += self.vx * dt + self.y += self.vy * dt + self.vx *= 0.9 + self.vy *= 0.9 + + @property + def alive(self) -> bool: + return self.t > 0 + + def draw(self, surface, cx, cy): + if self.t <= 0: + return + p = 1.0 - (self.t / self.life) + r = int(self.start_r + (self.end_r - self.start_r) * p) + a = int(self.alpha * (1.0 - p)) + if r <= 0 or a <= 0: + return + puff = pygame.Surface((r * 2, r * 2), pygame.SRCALPHA) + pygame.draw.circle(puff, (180, 180, 180, a), (r, r), r) + surface.blit(puff, (self.x - r - cx, self.y - r - cy)) + + +class LightSource: + def __init__(self, x: int, y: int, radius: int = 180): + self.x = int(x) + self.y = int(y) + self.radius = int(radius) + self.t = random.random() * 10.0 + if LAMP_RAW is not None: + self.img = LAMP_RAW + self.rect = self.img.get_rect(midbottom=(self.x, self.y)) + else: + self.img = None + self.rect = pygame.Rect(self.x - 8, self.y - 16, 16, 16) + + def draw(self, surface, cx, cy): + # subtle pulse area + self.t += 0.03 + pulse = 0.06 + 0.04 * (0.5 + 0.5 * math.sin(self.t)) + r = int(self.radius * (0.95 + 0.1 * (0.5 + 0.5 * math.sin(self.t * 1.3)))) + overlay = pygame.Surface((r * 2, r * 2), pygame.SRCALPHA) + alpha = int(255 * pulse) + pygame.draw.circle(overlay, (255, 230, 150, alpha), (r, r), r, 2) + surface.blit(overlay, (self.x - r - cx, self.y - r - cy)) + + if self.img is not None: + surface.blit(self.img, (self.rect.x - cx, self.rect.y - cy)) + else: + pygame.draw.rect(surface, (240, 230, 120), pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height)) +def spawn_boss_shockwave(boss, shockwaves_list: list[Shockwave]): + y = boss.rect.bottom - boss.shockwave_height + x = boss.rect.centerx + shockwaves_list.append(Shockwave( + x, y, -1, + boss.shockwave_speed, boss.shockwave_lifetime, boss.shockwave_height, boss.shockwave_damage + )) + shockwaves_list.append(Shockwave( + x, y, 1, + boss.shockwave_speed, boss.shockwave_lifetime, boss.shockwave_height, boss.shockwave_damage + )) + + +class LootPickup: + def __init__(self, x, y, item_instance: dict): + self.item = item_instance + self.rarity = item_instance.get("rarity", "tempered") + + self.rect = pygame.Rect(int(x), int(y), 16, 16) + + # physics + self.vx = random.uniform(-60, 60) # px/sec initial scatter + self.vy = random.uniform(-120, -40) # small upward pop + self.on_ground = False + + + # visual bob + self.bob_t = random.random() * 10.0 # desync multiple drops + + def update(self, dt: float, platforms: list[pygame.Rect]): + # bob timer always runs (used only when grounded) + self.bob_t += dt * LOOT_BOB_SPEED + + if self.on_ground: + # ground friction for horizontal drift + self.vx *= LOOT_FRICTION + if abs(self.vx) < 5: + self.vx = 0.0 + self.rect.x += int(self.vx * dt) + return + + + # gravity + self.vy = min(LOOT_TERMINAL_VEL, self.vy + LOOT_GRAVITY * dt) + + # move + self.rect.x += int(self.vx * dt) + self.rect.y += int(self.vy * dt) + + + for p in platforms: + if self.rect.colliderect(p): + # landing on top + if self.vy > 0 and self.rect.bottom >= p.top: + self.rect.bottom = p.top + self.vy = -self.vy * LOOT_BOUNCE + if abs(self.vy) < 30: # tiny bounce threshold + self.vy = 0.0 + self.on_ground = True + # hitting underside + elif self.vy < 0 and self.rect.top <= p.bottom: + self.rect.top = p.bottom + self.vy = 0.0 + + def draw(self, surface, cx, cy): + # bob only after it landed + bob_y = int(math.sin(self.bob_t) * LOOT_BOB_AMP) if self.on_ground else 0 + + screen_pos = (int(self.rect.x - cx), int(self.rect.y - cy + bob_y)) + + # You already have a function/cached icons for rarity tint. + # Use whatever you used to draw the dropped item icon. + slot = self.item.get("slot", "unknown") + icon = get_drop_icon(slot, self.rarity) # <- your existing helper + + if icon: + surface.blit(icon, screen_pos) + else: + # fallback: colored square + col = LOOT_COLORS.get(self.rarity, (255, 255, 255)) + pygame.draw.rect(surface, col, (*screen_pos, self.rect.width, self.rect.height)) + + +class CoinPickup: + def __init__(self, x, y, kind: str): + # kind: "light" or "dark" + self.kind = kind + self.rect = pygame.Rect(int(x), int(y), 16, 16) + + # physics (same feel as loot) + self.vx = random.uniform(-80, 80) + self.vy = random.uniform(-140, -60) + self.on_ground = False + + self.bob_t = random.random() * 10.0 + + def update(self, dt: float, platforms: list[pygame.Rect]): + self.bob_t += dt * LOOT_BOB_SPEED + + if self.on_ground: + self.vx *= LOOT_FRICTION + if abs(self.vx) < 6: + self.vx = 0.0 + self.rect.x += int(self.vx * dt) + return + + + self.vy = min(LOOT_TERMINAL_VEL, self.vy + LOOT_GRAVITY * dt) + self.rect.x += int(self.vx * dt) + self.rect.y += int(self.vy * dt) + + + for p in platforms: + if self.rect.colliderect(p): + if self.vy > 0 and self.rect.bottom >= p.top: + self.rect.bottom = p.top + self.vy = -self.vy * LOOT_BOUNCE + if abs(self.vy) < 30: + self.vy = 0.0 + self.on_ground = True + elif self.vy < 0 and self.rect.top <= p.bottom: + self.rect.top = p.bottom + self.vy = 0.0 + + def draw(self, surface, cx, cy): + bob_y = int(math.sin(self.bob_t) * LOOT_BOB_AMP) if self.on_ground else 0 + x = int(self.rect.x - cx) + y = int(self.rect.y - cy + bob_y) + + img = COIN_LIGHT_RAW if self.kind == "light" else COIN_DARK_RAW + if img: + surface.blit(pygame.transform.scale(img, (16, 16)), (x, y)) + else: + # fallback square + col = (220, 220, 220) if self.kind == "light" else (80, 80, 80) + pygame.draw.rect(surface, col, (x, y, 16, 16)) + + +class AbilityDoor: + def __init__(self, rect: pygame.Rect): + self.rect = rect + self.open = False + + def draw(self, surface, cx, cy): + if self.open: + return + r = pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height) + pygame.draw.rect(surface, (120, 180, 120), r) + pygame.draw.rect(surface, (40, 80, 40), r, 2) + + +class RewardChest: + def __init__(self, x, y, opened=False, coins=(0, 0)): + self.rect = pygame.Rect(int(x), int(y), 40, 26) + self.opened = bool(opened) + self.coins = coins + + def open(self, player): + if self.opened: + return + light_amt, dark_amt = self.coins + player.coins_light += int(light_amt) + player.coins_dark += int(dark_amt) + self.opened = True + + def draw(self, surface, cx, cy): + x = int(self.rect.x - cx) + y = int(self.rect.y - cy) + base = (150, 95, 50) if not self.opened else (110, 70, 40) + lid = (190, 125, 70) if not self.opened else (130, 85, 50) + pygame.draw.rect(surface, base, (x, y + 8, self.rect.width, self.rect.height - 8)) + pygame.draw.rect(surface, lid, (x, y, self.rect.width, 10)) + pygame.draw.rect(surface, (40, 25, 12), (x, y + 8, self.rect.width, self.rect.height - 8), 2) + pygame.draw.rect(surface, (40, 25, 12), (x, y, self.rect.width, 10), 2) + + +class AbilityPickup: + def __init__(self, x, y, ability_id: str = "ranged"): + self.base_x = int(x) + self.base_y = int(y) + self.rect = pygame.Rect(int(x), int(y), 20, 20) + self.bob_t = random.random() * 10.0 + self.ability_id = str(ability_id) + + def update(self, dt: float): + self.bob_t += dt * LOOT_BOB_SPEED + bob_y = int(math.sin(self.bob_t) * LOOT_BOB_AMP) + self.rect.x = self.base_x + self.rect.y = self.base_y + bob_y + + def draw(self, surface, cx, cy): + x = int(self.rect.x - cx) + y = int(self.rect.y - cy) + pygame.draw.rect(surface, (200, 230, 255), (x, y, self.rect.width, self.rect.height)) + pygame.draw.rect(surface, (30, 40, 60), (x, y, self.rect.width, self.rect.height), 2) + pygame.draw.line(surface, (30, 40, 60), (x + 4, y + 10), (x + 16, y + 10), 2) + + +class NPC: + def __init__(self, x, y, npc_type: str = "vendor", trade_cost: int | None = None, + offers=None, greet_light: str | None = None, greet_shade: str | None = None, + greet_hint: str | None = None): + self.type = npc_type + self.trade_cost = int(trade_cost) if trade_cost is not None else None + self.offers = offers if isinstance(offers, list) else [] + self.greet_light = greet_light + self.greet_shade = greet_shade + self.greet_hint = greet_hint + self.rect = pygame.Rect(int(x), int(y), 36, 54) + + def draw(self, surface, cx, cy): + x = int(self.rect.x - cx) + y = int(self.rect.y - cy) + body = (220, 220, 190) if self.type == "vendor" else (120, 120, 120) + pygame.draw.rect(surface, body, (x, y, self.rect.width, self.rect.height)) + pygame.draw.rect(surface, (140, 140, 110), (x, y, self.rect.width, self.rect.height), 2) + + +class TradeUI: + def __init__(self, inventory: "Inventory", player, font_obj: pygame.font.Font): + self.inv = inventory + self.player = player + self.font = font_obj + self.visible = False + self.cursor = 0 + self.active_npc: NPC | None = None + self.options = [] + + def toggle(self, npc: NPC | None = None): + self.visible = not self.visible + self.cursor = 0 + self.active_npc = npc if self.visible else None + if self.visible: + self.options = self._build_options(self.active_npc) + else: + self.options = [] + + def handle_keydown(self, key): + if not self.visible: + return + if key in (key_for("ui_cancel"), key_for("interact"), key_for("inventory")): + self.visible = False + return + if not self.options: + return + if key == key_for("ui_up"): + self.cursor = (self.cursor - 1) % len(self.options) + elif key == key_for("ui_down"): + self.cursor = (self.cursor + 1) % len(self.options) + elif key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): + self._try_trade() + + def _random_item(self, slot: str, rarity: str) -> dict | None: + pool = [it for it in ARMOR_DB if isinstance(it, dict) and it.get("slot") == slot] + if not pool: + return None + base = random.choice(pool) + use_rarity = rarity if rarity in RARITIES else "tempered" + return build_item_instance(base, use_rarity) + + def _build_options(self, npc: NPC | None) -> list[dict]: + offers = npc.offers if npc and npc.offers else [] + if not offers: + npc_type = npc.type if npc else "vendor" + default_offers = _npc(["defaults", npc_type, "offers"], []) + if isinstance(default_offers, list) and default_offers: + offers = default_offers + if not offers: + offers = [ + {"type": "sell_item", "slot": "helmet", "label": "Helmet", "rarity": "tempered"}, + {"type": "sell_item", "slot": "chest", "label": "Chestplate", "rarity": "tempered"}, + {"type": "sell_item", "slot": "pants", "label": "Greaves", "rarity": "tempered"}, + ] + + options = [] + for offer in offers: + if not isinstance(offer, dict): + continue + otype = str(offer.get("type", "sell_item")) + if otype == "upgrade_attune": + required = int(offer.get("required_count", 5)) + counts = {} + names = {} + for it in self.inv.items: + if not isinstance(it, dict): + continue + if it.get("rarity") != "tempered": + continue + item_id = it.get("id") + if not item_id: + continue + counts[item_id] = counts.get(item_id, 0) + 1 + if item_id not in names: + names[item_id] = it.get("name", item_id) + for item_id, count in counts.items(): + if count < required: + continue + label = f"Upgrade {names.get(item_id, item_id)} (x{required})" + options.append({ + "type": "upgrade_attune", + "item_id": item_id, + "label": label, + "required_count": required, + "cost": offer.get("cost"), + }) + else: + opt = dict(offer) + opt["type"] = otype + options.append(opt) + + return options + + def _try_trade(self): + cost = NPC_TRADE_COST + if self.active_npc and self.active_npc.trade_cost is not None: + cost = int(self.active_npc.trade_cost) + + if self.player.coins_light < cost: + pickup_feed.push_plain("Not enough light coins") + return + if not self.options: + pickup_feed.push_plain("No offers available") + return + choice = self.options[self.cursor] + choice_type = str(choice.get("type", "sell_item")) + choice_cost = choice.get("cost") + if isinstance(choice_cost, (int, float)): + cost = int(choice_cost) + if self.player.coins_light < cost: + pickup_feed.push_plain("Not enough light coins") + return + if choice_type == "upgrade_attune": + item_id = choice.get("item_id") + required = int(choice.get("required_count", 5)) + if not item_id: + pickup_feed.push_plain("No item selected") + return + base = safe_get_item(str(item_id)) + if not base: + pickup_feed.push_plain("Unknown item") + return + count = sum(1 for it in self.inv.items if isinstance(it, dict) + and it.get("id") == item_id and it.get("rarity") == "tempered") + if count < required: + pickup_feed.push_plain(f"Need {required} tempered copies") + return + removed = required + new_items = [] + for it in self.inv.items: + if removed > 0 and isinstance(it, dict) and it.get("id") == item_id and it.get("rarity") == "tempered": + removed -= 1 + continue + new_items.append(it) + self.inv.items = new_items + self.player.coins_light -= cost + upgraded = build_item_instance(base, "attuned") + self.inv.add(upgraded) + pickup_feed.push_plain_rarity(f"Upgraded {upgraded.get('name', 'Item')} to ", "attuned") + # refresh options after upgrade + self.options = self._build_options(self.active_npc) + if self.options: + self.cursor = max(0, min(self.cursor, len(self.options) - 1)) + else: + self.cursor = 0 + elif choice_type == "sell_specific": + item_id = choice.get("item_id") + if not item_id: + pickup_feed.push_plain("No item selected") + return + base = safe_get_item(str(item_id)) + if not base: + pickup_feed.push_plain("Unknown item") + return + rarity = choice.get("rarity", None) + item = build_item_instance_any(base, rarity) + self.player.coins_light -= cost + self.inv.add(item) + pickup_feed.push_plain(f"Bought {item.get('name', 'Item')}") + self.options = self._build_options(self.active_npc) + else: + item = self._random_item(choice["slot"], choice.get("rarity", "tempered")) + if not item: + pickup_feed.push_plain("No items available") + return + self.player.coins_light -= cost + self.inv.add(item) + pickup_feed.push_plain(f"Bought {item.get('name', 'Item')}") + # refresh options in case inventory-driven offers changed + self.options = self._build_options(self.active_npc) + + def draw(self, surface: pygame.Surface): + if not self.visible: + return + + panel_w = 340 + panel_h = 180 + x = (WIDTH - panel_w) // 2 + y = (HEIGHT - panel_h) // 2 + + panel = pygame.Surface((panel_w, panel_h), pygame.SRCALPHA) + panel.fill((10, 10, 10, 220)) + surface.blit(panel, (x, y)) + pygame.draw.rect(surface, (50, 50, 50), (x, y, panel_w, panel_h), 2) + + title = self.font.render("Vendor Trade", True, (230, 230, 230)) + surface.blit(title, (x + 14, y + 10)) + cost = NPC_TRADE_COST + if self.active_npc and self.active_npc.trade_cost is not None: + cost = int(self.active_npc.trade_cost) + if self.options: + opt_cost = self.options[self.cursor].get("cost") + if isinstance(opt_cost, (int, float)): + cost = int(opt_cost) + cost_txt = self.font.render(f"Cost: {cost} light coins", True, (180, 180, 180)) + surface.blit(cost_txt, (x + 14, y + 32)) + + list_y = y + 60 + if not self.options: + none_txt = self.font.render("No offers available", True, (160, 160, 160)) + surface.blit(none_txt, (x + 22, list_y)) + return + + for i, opt in enumerate(self.options): + otype = str(opt.get("type", "sell_item")) + if otype == "upgrade_attune": + label = str(opt.get("label", "Upgrade")) + else: + rarity = str(opt.get("rarity", "")).title() + if rarity: + label = f"{opt['label']} - {rarity}" + else: + label = str(opt.get("label", "Item")) + col = (240, 240, 240) if i == self.cursor else (170, 170, 170) + line = self.font.render(label, True, col) + surface.blit(line, (x + 22, list_y + i * 28)) + + +def spawn_drops_from_enemy(enemy) -> list[LootPickup]: + drops: list[LootPickup] = [] + prof = ENEMY_DROPS.get(enemy.drop_profile, None) + + if _cfg(["debug", "drop_debug_print"], True): + print("DROP DEBUG:", + "enemy_type=", enemy.type_id, + "profile=", enemy.drop_profile, + "prof_found=", isinstance(prof, dict), + "pool=", (prof.get("pool") if isinstance(prof, dict) else None)) + + if not isinstance(prof, dict): + return drops + + pool = prof.get("pool", []) + if not isinstance(pool, list): + pool = [] + + default_pool = prof.get("default_pool", None) + if (not pool) and default_pool == "armor": + pool = list(ARMOR_BY_ID.keys()) + elif (not pool) and default_pool == "necklace": + pool = list(NECKLACE_BY_ID.keys()) + + # ---- drop placement helper (prevents overlap) ---- + spawned = 0 + + def drop_pos(): + nonlocal spawned + # base near enemy feet + base_x = enemy.rect.centerx - 8 + base_y = enemy.rect.bottom - 40 + + # scatter + gentle "row nudge" so multiple drops don't stack + x = base_x + random.randint(-36, 36) + (spawned % 6) * 12 + y = base_y + random.randint(-6, 6) + + spawned += 1 + return x, y + + # guaranteed drops + guaranteed = prof.get("guaranteed", []) + if isinstance(guaranteed, list): + for g in guaranteed: + if not isinstance(g, dict): + continue + rarity = g.get("rarity", None) + count = scale_count(int(g.get("count", 1)), ROOM_DROP_MULT) + + for _ in range(max(0, count)): + item_id = g.get("item_id", None) + pool_kind = g.get("item_pool", None) + if not item_id: + if pool_kind == "necklace": + if not NECKLACE_BY_ID: + continue + item_id = random.choice(list(NECKLACE_BY_ID.keys())) + elif pool_kind == "armor": + if not ARMOR_BY_ID: + continue + item_id = random.choice(list(ARMOR_BY_ID.keys())) + else: + if not pool: + continue + item_id = random.choice(pool) + base = safe_get_item(item_id) + if not base: + print("DROP DEBUG: missing item ->", item_id) + continue + try: + inst = build_item_instance_any(base, rarity) + except Exception: + continue + + x, y = drop_pos() + drops.append(LootPickup(x, y, inst)) + + # random rolls + rolls = scale_count(int(prof.get("rolls", 0)), ROOM_DROP_MULT) + rarity_table = prof.get("rarity_table", {}) + + for _ in range(rolls): + rarity = roll_from_table(rarity_table) + if rarity is None: + continue + if not pool: + continue + + item_id = random.choice(pool) + base = safe_get_item(item_id) + if not base: + print("DROP DEBUG: missing item ->", item_id) + continue + try: + inst = build_item_instance_any(base, rarity) + except Exception: + continue + + x, y = drop_pos() + drops.append(LootPickup(x, y, inst)) + + return drops + + + +# ---------------------------- +# Inventory +# ---------------------------- +class Inventory: + def __init__(self): + self.items: list[dict] = [] + + def add(self, item_instance: dict): + if isinstance(item_instance, dict): + self.items.append(item_instance) + + def __len__(self): + return len(self.items) + + +class InventoryUI: + def __init__(self, inventory: Inventory, player, font_obj: pygame.font.Font): + self.inv = inventory + self.player = player + self.font = font_obj + + self.visible = False + self.focus = "inv" # "inv" or "equip" + + self.cursor = 0 + self.scroll = 0 + + self.equip_slots = ["helmet", "chest", "pants", "necklace"] + self.equip_idx = 0 + + self.x = int(_ui(["inventory_ui", "panel", "x"], 20)) + self.y = int(_ui(["inventory_ui", "panel", "y"], 20)) + self.w = int(_ui(["inventory_ui", "panel", "w"], 560)) + self.h = int(_ui(["inventory_ui", "panel", "h"], 380)) + self.pad = int(_ui(["inventory_ui", "panel", "pad"], 10)) + + self.list_w = int(_ui(["inventory_ui", "list", "w"], 360)) + self.line_h = int(_ui(["inventory_ui", "list", "line_h"], 18)) + # remembers how many lines the list can show (set in draw()) + self._last_list_max_lines = 1 + + self.slot_icons = load_slot_icons() + self.slot_icon_cache = {} # (slot, rarity) -> Surface + self.icon_size = int(_ui(["inventory_ui", "icons", "size"], 16)) + self.icon_gap = int(_ui(["inventory_ui", "icons", "gap"], 6)) + + + def toggle(self): + self.visible = not self.visible + if self.visible: + self.focus = "inv" + self._clamp_cursor() + + def _clamp_cursor(self): + n = len(self.inv.items) + if n == 0: + self.cursor = 0 + self.scroll = 0 + return + + self.cursor = max(0, min(self.cursor, n - 1)) + + # scroll is NOT allowed to go to n-1; it must stop at the last page start + max_scroll = max(0, n - self._last_list_max_lines) + self.scroll = max(0, min(self.scroll, max_scroll)) + + + + def _ensure_visible(self, max_lines: int): + if self.cursor < self.scroll: + self.scroll = self.cursor + if self.cursor >= self.scroll + max_lines: + self.scroll = self.cursor - max_lines + 1 + self.scroll = max(0, min(self.scroll, max(0, len(self.inv.items) - max_lines))) + + def handle_keydown(self, key): + if not self.visible: + return + + if key == key_for("ui_cancel"): + self.toggle() + return + + if key == key_for("ui_right"): + self.focus = "equip" + return + if key == key_for("ui_left"): + self.focus = "inv" + return + + if self.focus == "inv": + if key == key_for("ui_up"): + self.cursor -= 1 + self._clamp_cursor() + self._ensure_visible(self._last_list_max_lines) + elif key == key_for("ui_down"): + self.cursor += 1 + self._clamp_cursor() + self._ensure_visible(self._last_list_max_lines) + elif key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): + self._equip_selected_swap() + else: + if key == key_for("ui_up"): + self.equip_idx = (self.equip_idx - 1) % len(self.equip_slots) + elif key == key_for("ui_down"): + self.equip_idx = (self.equip_idx + 1) % len(self.equip_slots) + + def _equip_selected_swap(self): + if len(self.inv.items) == 0: + return + + idx = self.cursor + new_item = self.inv.items[idx] + slot = new_item.get("slot") + if slot not in self.player.equipment: + return + + old_item = self.player.equipment.get(slot) + + # equip new (swap) + self.player.equip_swap(new_item) + + # remove selected from inventory + self.inv.items.pop(idx) + + # insert old at same index (true swap) + if old_item is not None: + self.inv.items.insert(idx, old_item) + + self._clamp_cursor() + + def _blit(self, surface, text, x, y, color=(230, 230, 230)): + surface.blit(self.font.render(text, True, color), (x, y)) + + def _rarity_color(self, rarity: str): + return LOOT_COLORS.get(rarity, (230, 230, 230)) + + def _get_slot_icon(self, slot: str, rarity: str | None) -> pygame.Surface | None: + slot = slot or "unknown" + rarity = rarity or "" + + key = (slot, rarity) + if key in self.slot_icon_cache: + return self.slot_icon_cache[key] + + base = self.slot_icons.get(slot) or self.slot_icons.get("unknown") + if base is None: + self.slot_icon_cache[key] = None + return None + + # tint only if we have a known rarity color + col = LOOT_COLORS.get(rarity) + icon = tint_icon(base, col) if col else base + + self.slot_icon_cache[key] = icon + return icon + + + def _get_stats(self, item: dict) -> dict: + if not item or not isinstance(item, dict): + return {} + stats = item.get("stats", {}) + return stats if isinstance(stats, dict) else {} + + def _format_stat_value(self, key: str, value): + """ + Format stats nicely: + - *_pct are stored as decimals (0.10) but displayed as 10% + - other stats displayed as ints if whole, else as 1 decimal + """ + if key.endswith("_pct"): + # 0.10 -> "10%" + if isinstance(value, (int, float)): + sign = "+" if value > 0 else "" + return f"{sign}{value * 100:.0f}%" + return str(value) + + # non-percent stats + if isinstance(value, (int, float)) and abs(value - round(value)) < 1e-9: + v = int(round(value)) + sign = "+" if v > 0 else "" + return f"{sign}{v}" + if isinstance(value, (int, float)): + sign = "+" if value > 0 else "" + return f"{sign}{value:.1f}" + return str(value) + + def _stat_display_name(self, key: str) -> str: + names = { + "health": "Max Health", + "charge": "Max Charge", + "charge_regen_pct": "Charge Regeneration", + "heal_speed_pct": "Heal Speed", + "heal_plus": "Heal Amount", + "move_speed_pct": "Move Speed", + "move_speed_light_pct": "Move Speed (Light)", + "move_speed_shade_pct": "Move Speed (Shade)", + "attack_speed_pct": "Attack Speed", + "jump_height_shade_pct": "Jump Height (Shade)", + "negate_damage_chance": "Negate Damage Chance", + "shade_double_damage_chance": "Shade Double Damage Chance", + "shade_damage_flat": "Shade Damage", + "knockback_resistance": "Knockback Resistance", + "restore_charge": "Restore Charge", + "heal_flat": "Heal On Kill", + "shade_charge_drain_reduction_pct": "Shade Charge Drain Reduction", + "negate_shade_speed_penalty": "Negate Shade Speed Penalty", + } + if key in names: + return names[key] + # Fallback: "some_stat_name" -> "Some Stat Name" + return str(key).replace("_", " ").title() + + def _ellipsize(self, text: str, max_px: int) -> str: + """Trim text so it fits max_px pixels, adding '...' if needed.""" + if self.font.size(text)[0] <= max_px: + return text + ell = "..." + ell_w = self.font.size(ell)[0] + if max_px <= ell_w: + return ell + lo, hi = 0, len(text) + while lo < hi: + mid = (lo + hi) // 2 + if self.font.size(text[:mid])[0] <= (max_px - ell_w): + lo = mid + 1 + else: + hi = mid + cut = max(0, lo - 1) + return text[:cut] + ell + + + + def _draw_stat_line(self, surface, x, y, name, value, delta=None): + display_name = self._stat_display_name(name) + value_txt = self._format_stat_value(name, value) + base_text = f"{display_name}: {value_txt}" + self._blit(surface, base_text, x, y, (230, 230, 230)) + + if delta is None: + return + + # delta formatting: if *_pct, convert to percentage points + if name.endswith("_pct"): + # delta is e.g. 0.05 -> "+5%" + delta_txt_val = f"{delta * 100:+.0f}%" + else: + # normal numeric delta + if isinstance(delta, (int, float)) and abs(delta - round(delta)) < 1e-9: + delta_txt_val = f"{int(round(delta)):+d}" + else: + delta_txt_val = f"{delta:+.1f}" + + if delta > 0: + col = (120, 255, 120) + elif delta < 0: + col = (255, 120, 120) + else: + col = (180, 180, 180) + + dtxt = f" ({delta_txt_val})" + w = self.font.size(base_text)[0] + self._blit(surface, dtxt, x + w, y, col) + + def _active_set_bonus_lines_for_item(self, item: dict) -> list[str]: + if not item or not isinstance(item, dict): + return [] + + set_id = item.get("set") + if not set_id: + return [] + + cfg = SET_BONUSES.get(set_id, {}) + if not isinstance(cfg, dict): + return [] + + required = int(cfg.get("required_pieces", 3)) + pieces = [] + for it in self.player.equipment.values(): + if not it or it.get("set") != set_id: + continue + rarity_id = it.get("rarity") + tier = it.get("tier", None) + if tier is None: + tier = RARITIES.get(rarity_id, {}).get("tier", 1) + tier = int(tier) + if tier >= 2: + pieces.append(it) + + if len(pieces) < required: + return [] + + awakened_count = sum(1 for p in pieces if int(p.get("tier", 1)) >= 3) + bonus_tier = "awakened" if awakened_count >= required else "attuned" + tier_cfg = cfg.get(bonus_tier, {}) if isinstance(cfg, dict) else {} + + effects = tier_cfg.get("effects", {}) if isinstance(tier_cfg, dict) else {} + conds = tier_cfg.get("conditional_effects", []) if isinstance(tier_cfg, dict) else [] + + lines = [] + effect_summary = "" + + if isinstance(effects, dict) and effects: + parts = [] + for k, v in effects.items(): + label = self._stat_display_name(k) + val = self._format_stat_value(k, v) + parts.append(f"{label} {val}") + effect_summary = ", ".join(parts) + + if effect_summary: + lines.append(f"Set bonus ({bonus_tier}): {effect_summary}") + else: + set_name = str(set_id).replace("_", " ").title() + lines.append(f"Set bonus ({bonus_tier}): {set_name}") + + if isinstance(conds, list) and conds: + for c in conds: + if not isinstance(c, dict): + continue + cond = c.get("condition", "condition") + eff = c.get("effects", {}) + if not isinstance(eff, dict) or not eff: + continue + parts = [] + for k, v in eff.items(): + label = self._stat_display_name(k) + val = self._format_stat_value(k, v) + parts.append(f"{label} {val}") + lines.append(f"{cond}: " + ", ".join(parts)) + + return lines + + + def draw(self, surface): + if not self.visible: + return + + panel = pygame.Rect(self.x, self.y, self.w, self.h) + pygame.draw.rect(surface, (40, 40, 40), panel) + pygame.draw.rect(surface, (150, 150, 150), panel, 2) + + room_name = "Room" + try: + room_name = ROOM.name + except Exception: + pass + + self._blit(surface, f"Inventory (I to close) — {room_name}", + self.x + self.pad, self.y + self.pad, (240, 240, 240)) + + # --- Detail areas (reserve bottom space FIRST) --- + stats_h = int(_ui(["inventory_ui", "details", "stats_h"], 90)) + hint_h = int(_ui(["inventory_ui", "details", "hint_h"], 24)) + gap = int(_ui(["inventory_ui", "details", "gap"], 8)) + + hint_y = self.y + self.h - self.pad - hint_h + stats_y = hint_y - gap - stats_h + + # --- List region (everything between header and stats) --- + header_h = int(_ui(["inventory_ui", "list", "header_h"], 40)) + list_top = self.y + header_h + list_h = (stats_y - gap) - list_top + list_h = max(40, list_h) # safety + + # padding inside the list box (top + bottom) + list_pad_y = int(_ui(["inventory_ui", "list", "pad_y"], 6)) + usable_h = max(1, list_h - list_pad_y * 2) + + list_max_lines = max(1, usable_h // self.line_h) + self._ensure_visible(list_max_lines) + self._last_list_max_lines = list_max_lines + + + list_rect = pygame.Rect(self.x + self.pad, list_top, self.list_w, list_h) + pygame.draw.rect(surface, (45, 45, 45), list_rect) + pygame.draw.rect(surface, (110, 110, 110), list_rect, 1) + + start = self.scroll + end = min(len(self.inv.items), start + list_max_lines) + y = list_top + list_pad_y + + # --- clip everything to the list box --- + old_clip = surface.get_clip() + surface.set_clip(list_rect) + + + if len(self.inv.items) == 0: + self._blit(surface, "(empty)", self.x + self.pad + 6, y, (160, 160, 160)) + else: + for idx in range(start, end): + it = self.inv.items[idx] + name = it.get("name", it.get("id", "")) + rarity = it.get("rarity", "?") + slot = it.get("slot", "?") + + if self.focus == "inv" and idx == self.cursor: + hl = pygame.Rect(self.x + 4, y - 2, self.list_w - 8, self.line_h) + pygame.draw.rect(surface, (60, 60, 60), hl) + + rarity_text = f"[{rarity}]" if rarity else "" + right_text = f" ({slot})" + + x0 = self.x + self.pad + 6 + pad_right = int(_ui(["inventory_ui", "list", "pad_right"], 14)) # keep away from scrollbar + max_w = self.list_w - pad_right + + prefix = f"{idx + 1:02d} " + suffix = f" {rarity_text}{right_text}" + + icon_space = self.icon_size + self.icon_gap + avail_for_name = max_w - icon_space - self.font.size(suffix)[0] + + name_fit = self._ellipsize(name, max(0, avail_for_name)) + + # --- slot icon (tinted by rarity) --- + icon = self._get_slot_icon(slot, rarity) + icon_y = y + (self.line_h - self.icon_size) // 2 + if icon: + surface.blit(icon, (x0, icon_y)) + + + text_x = x0 + self.icon_size + self.icon_gap + + # --- item name --- + self._blit(surface, name_fit + " ", text_x, y, (230, 230, 230)) + x1 = text_x + self.font.size(name_fit + " ")[0] + + x0 = self.x + self.pad + 6 + if rarity_text: + self._blit(surface, rarity_text, x1, y, self._rarity_color(rarity)) + x2 = x1 + (self.font.size(rarity_text)[0] if rarity_text else 0) + self._blit(surface, right_text, x2, y, (230, 230, 230)) + + y += self.line_h + + surface.set_clip(old_clip) + + # Equipped column + eq_x = self.x + self.list_w + self.pad + eq_y = list_top + self._blit(surface, "Equipped:", eq_x, eq_y - 18, (220, 220, 220)) + + for i, slot in enumerate(self.equip_slots): + it = self.player.equipment.get(slot) + is_sel = (self.focus == "equip" and i == self.equip_idx) + + rarity = it.get("rarity", "") if it else "" + icon = self._get_slot_icon(slot, rarity) + + if is_sel: + hl = pygame.Rect(eq_x - 6, eq_y - 2, self.w - (eq_x - self.x) - self.pad, self.line_h + 2) + pygame.draw.rect(surface, (60, 60, 60), hl) + + # draw icon + if icon: + icon_y = eq_y + (self.line_h - self.icon_size) // 2 + surface.blit(icon, (eq_x, icon_y)) + + text_x = eq_x + (self.icon_size + self.icon_gap) + + if not it: + self._blit(surface, f"{slot}: (empty)", text_x, eq_y, (170, 170, 170)) + else: + name = it.get("name", "?") + left = f"{slot}: {name} " + tag = f"[{rarity}]" if rarity else "" + self._blit(surface, left, text_x, eq_y, (230, 230, 230)) + if tag: + self._blit(surface, tag, text_x + self.font.size(left)[0], eq_y, self._rarity_color(rarity)) + + + eq_y += self.line_h + int(_ui(["inventory_ui", "equipped", "line_gap"], 4)) + + # --- Detail areas --- + stats_h = int(_ui(["inventory_ui", "details", "stats_h"], 90)) + hint_h = int(_ui(["inventory_ui", "details", "hint_h"], 24)) + gap = int(_ui(["inventory_ui", "details", "gap"], 8)) + + hint_y = self.y + self.h - self.pad - hint_h + stats_y = hint_y - gap - stats_h + + # Hint box + pygame.draw.rect( + surface, (45, 45, 45), + (self.x + self.pad, hint_y, self.w - self.pad * 2, hint_h) + ) + pygame.draw.rect( + surface, (110, 110, 110), + (self.x + self.pad, hint_y, self.w - self.pad * 2, hint_h), + 1 + ) + + # Hint text (NOW hint_y exists) + self._blit( + surface, + "UP/DOWN move ENTER equip LEFT/RIGHT switch panel", + self.x + self.pad + 6, + hint_y + 4, + (160, 160, 160), + ) + + # Stats box + pygame.draw.rect( + surface, (50, 50, 50), + (self.x + self.pad, stats_y, self.w - self.pad * 2, stats_h) + ) + pygame.draw.rect( + surface, (120, 120, 120), + (self.x + self.pad, stats_y, self.w - self.pad * 2, stats_h), + 1 + ) + + + + selected_item = None + if self.focus == "inv": + selected_item = self.inv.items[self.cursor] if len(self.inv.items) > 0 else None + selected_label = "Selected (Inventory):" + else: + slot = self.equip_slots[self.equip_idx] + selected_item = self.player.equipment.get(slot) + selected_label = f"Selected (Equipped: {slot}):" + + self._blit(surface, selected_label, self.x + self.pad + 6, stats_y + 6, (220, 220, 220)) + + + if not selected_item: + self._blit(surface, "(none)", self.x + self.pad + 6, stats_y + 24, (170, 170, 170)) + return + + + name = selected_item.get("name", selected_item.get("id", "")) + rarity = selected_item.get("rarity", "?") + self._blit(surface, f"{name} ", self.x + self.pad + 6, stats_y + 24, (230, 230, 230)) + if rarity: + self._blit(surface, f"[{rarity}]", + self.x + self.pad + 6 + self.font.size(f"{name} ")[0], + stats_y + 24, + self._rarity_color(rarity)) + + + compare_item = None + selected_slot = selected_item.get("slot") + + # Only show comparisons when browsing inventory items. + if self.focus == "inv": + if selected_slot in self.player.equipment: + compare_item = self.player.equipment.get(selected_slot) + + sel_stats = self._get_stats(selected_item) + cmp_stats = self._get_stats(compare_item) if compare_item else {} + + bonus_lines = [] + if selected_item in self.player.equipment.values(): + bonus_lines = self._active_set_bonus_lines_for_item(selected_item) + + stats_x = self.x + self.pad + 6 + y_stats = stats_y + 42 + stats_bottom = stats_y + stats_h - 8 + keys = sorted(set(sel_stats.keys()) | set(cmp_stats.keys())) + max_lines = max(1, (stats_bottom - y_stats) // 16) + if bonus_lines: + max_lines = max(1, max_lines - min(2, len(bonus_lines))) + + had_stats = False + if not keys: + self._blit(surface, "Stats: (no stats)", stats_x, y_stats, (200, 200, 200)) + else: + had_stats = True + shown = 0 + for k in keys: + v = float(sel_stats.get(k, 0)) + if compare_item: + base = float(cmp_stats.get(k, 0)) + d = v - base + self._draw_stat_line(surface, stats_x, y_stats, k, v, delta=d) + else: + self._draw_stat_line(surface, stats_x, y_stats, k, v, delta=None) + + y_stats += 16 + shown += 1 + if shown >= max_lines: + if len(keys) > max_lines: + self._blit(surface, f"... ({len(keys) - max_lines} more)", + stats_x, y_stats, (160, 160, 160)) + break + + # Set bonus indicator for equipped items + if bonus_lines: + y_bonus = y_stats if not had_stats else (y_stats + 2) + max_y = stats_bottom + for line in bonus_lines[:2]: + if y_bonus > max_y: + break + self._blit(surface, line, stats_x, y_bonus, (200, 200, 200)) + y_bonus += 16 + # --- Scroll hint + tiny scrollbar --- + total = len(self.inv.items) + + # Always show item count + hint = f"{total} items" + self._blit(surface, hint, self.x + self.pad + 6, list_top + list_h - 18, (160, 160, 160)) + + # Only show scrollbar if it’s actually needed + if total > max_lines: + # scrollbar track + track_x = self.x + self.pad + self.list_w - 8 + track_y = list_top + list_pad_y + track_h = usable_h + pygame.draw.rect(surface, (40, 40, 40), (track_x, track_y, 4, track_h)) + + # thumb size based on how many lines are visible + thumb_h = max(10, int(track_h * (max_lines / total))) + + # thumb position based on cursor (so last item => thumb at bottom) + if total <= 1: + t = 0.0 + else: + t = self.cursor / (total - 1) + + thumb_y = int(track_y + (track_h - thumb_h) * t) + pygame.draw.rect(surface, (120, 120, 120), (track_x, thumb_y, 4, thumb_h)) + + + + + + +# ---------------------------- +# Pickup feed (stacked messages that fade) +# ---------------------------- +class PickupFeed: + def __init__(self, font_obj: pygame.font.Font, max_lines: int = 6): + self.font = font_obj + self.center_font = pygame.font.SysFont(None, int(_ui(["pickup_feed", "center_font_size"], 48))) + + # MUST exist before update() is ever called + self.lines = [] # list of dicts: {"name": str, "rarity": str, "t": float} + + # values (some from JSON) + self.max_lines = int(max_lines) + self.duration = float(_ui(["pickup_feed", "duration"], 3.0)) + self.fade_time = float(_ui(["pickup_feed", "fade_time"], 0.6)) + + self.pad_x = int(_ui(["pickup_feed", "pad_x"], 10)) + self.pad_y = int(_ui(["pickup_feed", "pad_y"], 6)) + self.line_gap = int(_ui(["pickup_feed", "line_gap"], 4)) + + + def push(self, name: str, rarity: str = ""): + self.lines.insert(0, { + "kind": "pickup", + "name": str(name), + "rarity": str(rarity or ""), + "t": float(self.duration), + }) + # trim + if len(self.lines) > self.max_lines: + self.lines = self.lines[:self.max_lines] + + def push_plain(self, text: str): + self.lines.insert(0, { + "kind": "plain", + "text": str(text), + "t": float(self.duration), + }) + if len(self.lines) > self.max_lines: + self.lines = self.lines[:self.max_lines] + + def push_plain_rarity(self, text: str, rarity: str): + self.lines.insert(0, { + "kind": "plain_rarity", + "text": str(text), + "rarity": str(rarity or ""), + "t": float(self.duration), + }) + if len(self.lines) > self.max_lines: + self.lines = self.lines[:self.max_lines] + + def push_center(self, text: str, duration: float | None = None): + self.lines.insert(0, { + "kind": "center", + "text": str(text), + "t": float(self.duration if duration is None else duration), + }) + if len(self.lines) > self.max_lines: + self.lines = self.lines[:self.max_lines] + + def update(self, dt: float): + if not self.lines: + return + alive = [] + for it in self.lines: + it["t"] -= dt + if it["t"] > 0: + alive.append(it) + self.lines = alive + + def _alpha_for(self, t: float) -> int: + if t <= 0: + return 0 + if t < self.fade_time: + a = int(255 * (t / self.fade_time)) + return max(0, min(255, a)) + return 255 + + def draw(self, surface: pygame.Surface): + if not self.lines: + return + + x = int(_ui(["pickup_feed", "x"], 14)) + y0 = (HEIGHT // 2) + int(_ui(["pickup_feed", "y_offset_from_center"], -10)) + + + y = y0 + for it in self.lines: + alpha = self._alpha_for(it["t"]) + if alpha <= 0: + continue + + if it.get("kind") == "center": + text = it.get("text", "") + surf = self.center_font.render(text, True, (240, 240, 240)).convert_alpha() + surf.set_alpha(alpha) + w = surf.get_width() + h = surf.get_height() + + pad_x = self.pad_x * 2 + pad_y = self.pad_y + box_w = min(WIDTH - 80, w + pad_x * 2) + box_h = h + pad_y * 2 + box_x = (WIDTH - box_w) // 2 + box_y = int((HEIGHT - box_h) * 0.35) + + box = pygame.Surface((box_w, box_h), pygame.SRCALPHA) + box.fill((0, 0, 0, int(alpha * 0.8))) + surface.blit(box, (box_x, box_y)) + + text_x = box_x + (box_w - w) // 2 + text_y = box_y + pad_y + surface.blit(surf, (text_x, text_y)) + continue + + if it.get("kind") == "plain": + text = it.get("text", "") + surf = self.font.render(text, True, (240, 240, 240)).convert_alpha() + surf.set_alpha(alpha) + w = surf.get_width() + h = surf.get_height() + box = pygame.Surface((w + self.pad_x * 2, h + self.pad_y * 2), pygame.SRCALPHA) + box.fill((0, 0, 0, int(alpha * 0.65))) + surface.blit(box, (x, y - self.pad_y)) + surface.blit(surf, (x + self.pad_x, y)) + y += h + self.line_gap + continue + + if it.get("kind") == "plain_rarity": + text = it.get("text", "") + rarity = it.get("rarity", "") + rarity_col = LOOT_COLORS.get(rarity, (240, 240, 240)) + surf_text = self.font.render(text, True, (240, 240, 240)).convert_alpha() + surf_tag = self.font.render(str(rarity).title(), True, rarity_col).convert_alpha() + for s in (surf_text, surf_tag): + s.set_alpha(alpha) + w = surf_text.get_width() + surf_tag.get_width() + h = max(surf_text.get_height(), surf_tag.get_height()) + box = pygame.Surface((w + self.pad_x * 2, h + self.pad_y * 2), pygame.SRCALPHA) + box.fill((0, 0, 0, int(alpha * 0.65))) + surface.blit(box, (x, y - self.pad_y)) + px = x + self.pad_x + surface.blit(surf_text, (px, y)) + px += surf_text.get_width() + surface.blit(surf_tag, (px, y)) + y += h + self.line_gap + continue + + name = it["name"] + rarity = it["rarity"] + + left_text = "Picked up " + name_text = f"{name} " + tag_text = f"[{rarity}]" if rarity else "" + + # Render parts (so rarity can be colored) + surf_left = self.font.render(left_text, True, (240, 240, 240)).convert_alpha() + surf_name = self.font.render(name_text, True, (240, 240, 240)).convert_alpha() + + # rarity colored like inventory + rarity_col = LOOT_COLORS.get(rarity, (240, 240, 240)) + surf_tag = self.font.render(tag_text, True, rarity_col).convert_alpha() if tag_text else None + + for s in (surf_left, surf_name, surf_tag): + if s is not None: + s.set_alpha(alpha) + + w = surf_left.get_width() + surf_name.get_width() + (surf_tag.get_width() if surf_tag else 0) + h = max(surf_left.get_height(), surf_name.get_height(), (surf_tag.get_height() if surf_tag else 0)) + + # background box per line + box = pygame.Surface((w + self.pad_x * 2, h + self.pad_y * 2), pygame.SRCALPHA) + box.fill((0, 0, 0, int(alpha * 0.65))) + surface.blit(box, (x, y - self.pad_y)) + + # blit parts + px = x + self.pad_x + surface.blit(surf_left, (px, y)) + px += surf_left.get_width() + surface.blit(surf_name, (px, y)) + px += surf_name.get_width() + if surf_tag: + surface.blit(surf_tag, (px, y)) + + y += h + self.line_gap + + + +# ---------------------------- +# Player +# ---------------------------- +class Player: + def __init__(self, x, y, sprite_surface=None, shade_sprite_surface=None): + self.start_x = int(x) + self.start_y = int(y) + + self.rect = pygame.Rect(int(x), int(y), PLAYER_HITBOX_W, PLAYER_HITBOX_H) + self.vel_y = 0.0 + self.on_ground = False + self.shade = False + + self.charge = float(BASE_MAX_CHARGE) + self.health = int(BASE_MAX_HEALTH) + self.charge_regen_mult = 1.0 + + self.coins_light = 0 + self.coins_dark = 0 + + # --- abilities --- + self.has_ranged = False + self.ranged_cd = 0.0 + self.reward_chest_opened = False + self.has_shatterstep = True + self.shatterstep_timer = 0.0 + self.shatterstep_cd = 0.0 + self.shatterstep_platform_cd = 0.0 + self.shatterstep_landing_pending = False + self.shatterstep_landing_damage = 0.0 + self.prev_vel_y = 0.0 + self.fall_start_y = float(self.rect.bottom) + self.shatterstep_jump_start_y = 0.0 + self.shatterstep_platform_spawned = False + self.has_tether = True + self.tether_cd = 0.0 + self.tether_pending_cd = 0.0 + self.has_reality_break = True + self.reality_break_flash = 0.0 + + + self.equipment = {"helmet": None, "chest": None, "pants": None, "necklace": None} + self.bonus_max_health = 0 + self.bonus_max_charge = 0.0 + + self.bonus_move_speed_pct = 0.0 + self.bonus_charge_regen_pct = 0.0 + self.bonus_heal_speed_pct = 0.0 + self.bonus_heal_plus = 0 + + # --- combat / misc bonuses (from armor stats + set bonuses) --- + self.bonus_attack_speed_pct = 0.0 + self.bonus_jump_height_shade_pct = 1.0 + self.bonus_negate_damage_chance = 0.0 + self.bonus_shade_double_damage_chance = 0.0 + self.bonus_shade_damage_flat = 0.0 + + # --- optional base/test bonuses (ALWAYS applied in addition to gear) --- + #self.base_move_speed_shade_pct = 1.0 # <-- test value + #self.base_jump_height_shade_pct = 1.0 # <-- test value + + + self.attacking = False + self.attack_timer = 0.0 + self.attack_duration = 0.2 + self.facing_right = True + self.damage_applied = False + self.dash_cd = 0.0 + self.dash_timer = 0.0 + self.dash_dir = 1 + self.dash_hit_ids = set() + self.just_jumped = False + self.just_landed = False + self.just_air_jumped = False + self.jump_held_last = False + # --- charge regen lockout (prevents instant refill after attacking) --- + self.charge_regen_lock = 0.0 + + self.bonus_move_speed_light_pct = 0.0 + self.bonus_move_speed_shade_pct = 0.0 + + + self.invincibility_timer = 0.0 + + # --- knockback (combat feel) --- + self.knockback_vx = 0.0 + self.knockback_timer = 0.0 + + # later driven by armor/set stats (0.0 = no resistance, 0.9 = very resistant) + self.bonus_knockback_resistance = 0.0 + + self.healing = False + self.heal_progress = 0.0 + + self.sprite_raw = sprite_surface + self.shade_sprite_raw = shade_sprite_surface + self.sprite_right = None + self.sprite_left = None + self.sprite_right_shade = None + self.sprite_left_shade = None + self.has_shade_sprite = False + self.idle_raw_frames = IDLE_SPRITE_RAW + self.idle_right_frames = [] + self.idle_left_frames = [] + self.idle_right_shade_frames = [] + self.idle_left_shade_frames = [] + self.idle_frame = 0 + self.idle_anim_time = 0.0 + self.idle_bob_t = 0.0 + self.idle_hold_time = 0.0 + self.ground_contact_timer = 0.0 + self.is_idle = False + self._rebuild_sprites() + + self.recalc_bonuses() + self._clamp_resources() + + @property + def max_health(self) -> int: + return int(BASE_MAX_HEALTH + self.bonus_max_health) + + @property + def max_charge(self) -> float: + return float(BASE_MAX_CHARGE + self.bonus_max_charge) + + def heal_charge_cost(self) -> float: + return HEAL_COST_FLAT + + def heal_drain_rate(self) -> float: + dur = self.effective_heal_duration() + return HEAL_COST_FLAT / dur if dur > 0 else 999999.0 + + def effective_attack_duration(self) -> float: + # More attack_speed_pct => shorter duration + # clamp so it never becomes 0 or negative + return max(0.05, self.attack_duration / (1.0 + self.bonus_attack_speed_pct)) + + + def effective_heal_duration(self) -> float: + return max(0.2, HEAL_DURATION / (1.0 + self.bonus_heal_speed_pct)) + + + def _clamp_resources(self): + self.health = max(0, min(self.health, self.max_health)) + self.charge = max(0.0, min(self.charge, self.max_charge)) + + def combined_stats(self) -> dict: + out = {} + for it in self.equipment.values(): + if not it: + continue + for k, v in it.get("stats", {}).items(): + merge_stat_value(out, k, v) + return out + + + def recalc_bonuses(self): + stats = self.combined_stats() + set_stats = self.set_bonus_stats() + + # merge set stats into total stats + for k, v in set_stats.items(): + merge_stat_value(stats, k, v) + + self.bonus_max_health = int(stats.get("health", 0)) + self.bonus_max_charge = float(stats.get("charge", 0)) + + self.bonus_move_speed_pct = float(stats.get("move_speed_pct", 0.0)) + self.bonus_move_speed_light_pct = float(stats.get("move_speed_light_pct", 0.0)) + self.bonus_move_speed_shade_pct = float(stats.get("move_speed_shade_pct", 0.0)) + float(getattr(self, "base_move_speed_shade_pct", 0.0)) + + self.bonus_charge_regen_pct = float(stats.get("charge_regen_pct", 0.0)) + self.bonus_heal_speed_pct = float(stats.get("heal_speed_pct", 0.0)) + self.bonus_heal_plus = int(stats.get("heal_plus", 0)) + + self.bonus_attack_speed_pct = float(stats.get("attack_speed_pct", 0.0)) + self.bonus_jump_height_shade_pct = float(stats.get("jump_height_shade_pct", 0.0)) + float(getattr(self, "base_jump_height_shade_pct", 0.0)) + + # chances: keep them in [0..1] so bad json can't break stuff + self.bonus_negate_damage_chance = max(0.0, min(1.0, float(stats.get("negate_damage_chance", 0.0)))) + self.bonus_shade_double_damage_chance = max(0.0, min(1.0, float(stats.get("shade_double_damage_chance", 0.0)))) + + self.bonus_shade_damage_flat = float(stats.get("shade_damage_flat", 0.0)) + + # knockback resistance in [0..0.9] (1.0 would feel weird / cancel all force) + self.bonus_knockback_resistance = max(0.0, min(0.9, float(stats.get("knockback_resistance", 0.0)))) + + + def equip_swap(self, item_instance: dict) -> dict | None: + slot = item_instance.get("slot") + if slot not in self.equipment: + return None + prev = self.equipment.get(slot) + self.equipment[slot] = item_instance + self.recalc_bonuses() + self._clamp_resources() + return prev + + def set_bonus_stats(self) -> dict: + """ + Activate set bonus when wearing 3 pieces of the same set that are attuned-or-better (tier>=2). + If all 3 are awakened -> awakened bonus, otherwise -> attuned bonus. + """ + pieces_by_set: dict[str, list[dict]] = {} + + for it in self.equipment.values(): + if not it: + continue + set_id = it.get("set") + rarity_id = it.get("rarity") + tier = it.get("tier", None) + if tier is None: + tier = RARITIES.get(rarity_id, {}).get("tier", 1) + tier = int(tier) + if not set_id or tier < 2: + continue + pieces_by_set.setdefault(str(set_id), []).append(it) + + out: dict = {} + + for set_id, pieces in pieces_by_set.items(): + if len(pieces) < 3: + continue + + awakened_count = sum(1 for p in pieces if int(p.get("tier", 1)) >= 3) + bonus_tier = "awakened" if awakened_count >= 3 else "attuned" + + cfg = SET_BONUSES.get(set_id, {}) + tier_cfg = cfg.get(bonus_tier, {}) if isinstance(cfg, dict) else {} + effects = tier_cfg.get("effects", {}) if isinstance(tier_cfg, dict) else {} + + if isinstance(effects, dict): + for k, v in effects.items(): + merge_stat_value(out, k, v) + + return out + + + def _rebuild_sprites(self): + if self.sprite_raw is None: + self.sprite_right = None + self.sprite_left = None + self.sprite_right_shade = None + self.sprite_left_shade = None + self.idle_right_frames = [] + self.idle_left_frames = [] + self.idle_right_shade_frames = [] + self.idle_left_shade_frames = [] + self.has_shade_sprite = False + return + + scale = PLAYER_SPRITE_SCALE + w = int(self.sprite_raw.get_width() * scale) + h = int(self.sprite_raw.get_height() * scale) + scaled = pygame.transform.smoothscale(self.sprite_raw, (w, h)) + self.sprite_right = scaled + self.sprite_left = pygame.transform.flip(scaled, True, False) + + if self.shade_sprite_raw is not None: + shade = pygame.transform.smoothscale(self.shade_sprite_raw, (w, h)) + self.sprite_right_shade = shade + self.sprite_left_shade = pygame.transform.flip(shade, True, False) + self.has_shade_sprite = True + else: + shade = scaled.copy() + shade.fill((120, 120, 120, 255), special_flags=pygame.BLEND_RGBA_MULT) + self.sprite_right_shade = shade + self.sprite_left_shade = pygame.transform.flip(shade, True, False) + self.has_shade_sprite = False + + self.idle_right_frames = [] + self.idle_left_frames = [] + self.idle_right_shade_frames = [] + self.idle_left_shade_frames = [] + for raw in self.idle_raw_frames: + if raw is None: + continue + fr = pygame.transform.smoothscale(raw, (w, h)) + self.idle_right_frames.append(fr) + self.idle_left_frames.append(pygame.transform.flip(fr, True, False)) + fr_shade = fr.copy() + fr_shade.fill((120, 120, 120, 255), special_flags=pygame.BLEND_RGBA_MULT) + self.idle_right_shade_frames.append(fr_shade) + self.idle_left_shade_frames.append(pygame.transform.flip(fr_shade, True, False)) + + def _cancel_heal(self): + self.healing = False + self.heal_progress = 0.0 + + def is_dashing(self) -> bool: + return self.dash_timer > 0.0 + + def try_dash(self): + if self.dash_cd > 0.0: + return False + if self.healing: + self._cancel_heal() + if self.attacking: + self.attacking = False + self.attack_timer = 0.0 + + if self.shade: + if self.health <= DASH_SHADE_HEALTH_COST: + return False + self.health = max(0, self.health - DASH_SHADE_HEALTH_COST) + else: + if self.charge < DASH_LIGHT_CHARGE_COST: + return False + self.charge -= DASH_LIGHT_CHARGE_COST + self.charge_regen_lock = max(self.charge_regen_lock, 0.35) + + self.dash_timer = DASH_DURATION + self.dash_cd = DASH_COOLDOWN + self.dash_dir = 1 if self.facing_right else -1 + self.dash_hit_ids = set() + return True + + def is_shatterstep_active(self) -> bool: + return self.shatterstep_timer > 0.0 + + def try_shatterstep(self): + if not self.has_shatterstep: + return False + if self.shatterstep_timer > 0.0 or self.shatterstep_cd > 0.0: + return False + self.shatterstep_timer = SHATTERSTEP_DURATION + self.shatterstep_cd = 0.0 + return True + + def try_tether(self, tether_list: list): + if not self.has_tether: + return False + if self.tether_cd > 0.0 or self.tether_pending_cd > 0.0: + return False + direction = 1 if self.facing_right else -1 + tether_list.append(TetherShot(self.rect.centerx, self.rect.centery, direction, self)) + return True + + def toggle_shade(self): + if not self.shade and self.healing: + self._cancel_heal() + if not self.shade and self.charge <= 0: + return + self.shade = not self.shade + + def attack(self): + if self.healing: + return + if self.charge >= 1 and not self.attacking: + self.attacking = True + self.attack_timer = self.effective_attack_duration() + self.charge -= 1 + self.damage_applied = False + + # lock regen for a bit (tweak this number to taste) + self.charge_regen_lock = max(self.charge_regen_lock, 0.35) + + def try_ranged_attack(self, projectiles_list: list, enemies_list: list, platforms_list: list): + if not self.has_ranged: + return + if self.healing: + return + if self.ranged_cd > 0.0: + return + if self.charge < RANGED_CHARGE_COST: + return + + # ranged costs more than melee + self.charge -= RANGED_CHARGE_COST + + # 50% of melee attack damage (supports 0.5 now because enemy health is float) + melee_dmg = 2.0 if self.shade else 1.0 + dmg = melee_dmg * RANGED_DAMAGE_MULT + + # spawn from roughly chest height, slightly in front + px = self.rect.centerx + (16 if self.facing_right else -16) + py = self.rect.centery - 6 + + direction = 1 if self.facing_right else -1 + projectiles_list.append(Projectile(px, py, direction, dmg)) + + # cooldown + small regen lock so it doesn't instantly refill + self.ranged_cd = RANGED_COOLDOWN + self.charge_regen_lock = max(self.charge_regen_lock, 0.75) + + + + def get_attack_rect(self): + """Legacy single-rect hitbox (kept for older callers).""" + boxes = self.get_attack_hitboxes() + return boxes[0] if boxes else None + + def get_attack_hitboxes(self): + """Wide-at-player, narrow-outward 'inverse cone' melee hitbox.""" + if not self.attacking: + return [] + + # Near (wide) segment + w1 = 28 # wider close to player + h1 = int(self.rect.height * 0.65) + y1 = self.rect.centery - h1 // 2 + + # Far (narrow) segment (+50% reach, but slimmer) + w2 = int(round(w1 * 0.5)) # +50% reach + h2 = int(round(h1 * 0.55)) # noticeably narrower + y2 = self.rect.centery - h2 // 2 + + if self.facing_right: + x1 = self.rect.right + x2 = self.rect.right + w1 + else: + x1 = self.rect.left - w1 + x2 = self.rect.left - w1 - w2 + + near = pygame.Rect(int(x1), int(y1), int(w1), int(h1)) + far = pygame.Rect(int(x2), int(y2), int(w2), int(h2)) + return [near, far] + + + def take_damage(self, amount) -> bool: + """Apply damage if not invincible. + + Returns True if damage was actually applied (useful for knockback). + """ + if self.invincibility_timer > 0: + return False + + # chance to negate incoming damage (works in both forms) + if self.bonus_negate_damage_chance > 0.0: + if random.random() < self.bonus_negate_damage_chance: + # optional: tiny "i-frames" so rapid multi-hit doesn't spam-roll + self.invincibility_timer = 0.10 + return False + + self.health -= int(amount) + self.invincibility_timer = INVINCIBILITY_DURATION + if self.healing: + self._cancel_heal() + self._clamp_resources() + return True + + + def apply_knockback(self, source_rect: pygame.Rect, + strength_px_per_frame: float = 10.0, + duration: float = 0.18, + pop_up_vel: float = -3.5): + """Knock the player away from a hit source. + + strength_px_per_frame is tuned for your current movement code (dx is per-frame-ish). + Resistance reduces strength (0.0 = none, 0.9 = strong resistance). + """ + # Push away from the source + dir_sign = 1.0 if self.rect.centerx >= source_rect.centerx else -1.0 + + resist = max(0.0, min(0.9, float(getattr(self, "bonus_knockback_resistance", 0.0)))) + strength = strength_px_per_frame * (1.0 - resist) + + # Apply / refresh knockback + self.knockback_vx = dir_sign * strength + self.knockback_timer = max(self.knockback_timer, duration) + + # Small upward pop if grounded so it's visually readable + if self.on_ground and pop_up_vel is not None: + self.vel_y = min(self.vel_y, float(pop_up_vel)) + self.on_ground = False + + + + def respawn(self, spawn_x=None, spawn_y=None): + if spawn_x is None or spawn_y is None: + spawn_x, spawn_y = self.start_x, self.start_y + else: + self.start_x, self.start_y = int(spawn_x), int(spawn_y) + + self.rect.midbottom = (int(spawn_x), int(spawn_y)) + self.vel_y = 0.0 + self.shade = False + self.attacking = False + self.attack_timer = 0.0 + self.invincibility_timer = 0.0 + self._cancel_heal() + + self.health = self.max_health + self.charge = self.max_charge + + def _start_heal_if_possible(self, keys): + if self.shade: + return + if not key_pressed(keys, "heal"): + return + if self.healing: + return + if self.health >= self.max_health: + return + if self.charge < self.heal_charge_cost(): + return + + self.healing = True + self.heal_progress = 0.0 + self.attacking = False + self.attack_timer = 0.0 + + def _update_heal(self, keys, dt): + if not self.healing: + return + if not key_pressed(keys, "heal"): + self._cancel_heal() + return + + drain = self.heal_drain_rate() * dt + self.charge = max(0.0, self.charge - drain) + self.heal_progress += dt + + dur = self.effective_heal_duration() + + if self.charge <= 0.0 and self.heal_progress < dur: + self._cancel_heal() + return + + + dur = self.effective_heal_duration() + if self.heal_progress >= dur: + gain = 1 + self.bonus_heal_plus + self.health = min(self.max_health, self.health + gain) + self._cancel_heal() + + + def update(self, keys, platforms, dt): + prev_on_ground = self.on_ground + self.just_jumped = False + self.just_landed = False + self.just_air_jumped = False + + if self.invincibility_timer > 0: + self.invincibility_timer -= dt + + if self.charge_regen_lock > 0: + self.charge_regen_lock = max(0.0, self.charge_regen_lock - dt) + + if self.dash_cd > 0.0: + self.dash_cd = max(0.0, self.dash_cd - dt) + if self.dash_timer > 0.0: + self.dash_timer = max(0.0, self.dash_timer - dt) + + if self.ranged_cd > 0.0: + self.ranged_cd = max(0.0, self.ranged_cd - dt) + + if self.shatterstep_timer > 0.0: + self.shatterstep_timer = max(0.0, self.shatterstep_timer - dt) + if self.shatterstep_timer == 0.0 and self.shatterstep_cd <= 0.0: + self.shatterstep_cd = SHATTERSTEP_COOLDOWN + if self.shatterstep_cd > 0.0: + self.shatterstep_cd = max(0.0, self.shatterstep_cd - dt) + if self.shatterstep_platform_cd > 0.0: + self.shatterstep_platform_cd = max(0.0, self.shatterstep_platform_cd - dt) + + if self.tether_pending_cd > 0.0: + self.tether_pending_cd = max(0.0, self.tether_pending_cd - dt) + if self.tether_pending_cd == 0.0 and self.tether_cd <= 0.0: + self.tether_cd = TETHER_COOLDOWN + if self.tether_cd > 0.0: + self.tether_cd = max(0.0, self.tether_cd - dt) + if self.reality_break_flash > 0.0: + self.reality_break_flash = max(0.0, self.reality_break_flash - dt) + + if key_pressed(keys, "move_left"): + self.facing_right = False + elif key_pressed(keys, "move_right"): + self.facing_right = True + + speed = PLAYER_SHADE_SPEED if self.shade else PLAYER_SPEED + form_bonus = self.bonus_move_speed_shade_pct if self.shade else self.bonus_move_speed_light_pct + speed *= (1.0 + self.bonus_move_speed_pct + form_bonus) + + if self.healing: + speed *= HEAL_SLOW_MULT + + right = 1 if key_pressed(keys, "move_right") else 0 + left = 1 if key_pressed(keys, "move_left") else 0 + input_dx = (right - left) * speed + if self.is_dashing(): + input_dx = self.dash_dir * DASH_SPEED + # --- knockback integration --- + if self.knockback_timer > 0.0: + self.knockback_timer = max(0.0, self.knockback_timer - dt) + + # decay knockback smoothly + decay = 0.85 ** (dt * 60.0) + self.knockback_vx *= decay + + # reduce player control while being knocked back + dx = (input_dx * 0.25) + self.knockback_vx + else: + self.knockback_vx = 0.0 + dx = input_dx + + + if (not self.is_dashing()) and key_pressed(keys, "jump") and self.on_ground: + if self.shade: + # more jump height => more negative jump velocity (higher jump) + self.vel_y = JUMP_SHADE * (1.0 + self.bonus_jump_height_shade_pct) + if self.is_shatterstep_active(): + self.shatterstep_landing_pending = True + self.shatterstep_landing_damage = 1.5 * 2.0 + else: + self.vel_y = JUMP_LIGHT + if self.is_shatterstep_active(): + self.shatterstep_landing_pending = False + self.shatterstep_landing_damage = 0.0 + self.on_ground = False + self.just_jumped = True + self.just_air_jumped = False + self.shatterstep_jump_start_y = float(self.rect.bottom) + self.shatterstep_platform_spawned = False + + + gravity = GRAVITY_SHADE if self.shade else GRAVITY_LIGHT + self.vel_y += gravity + dy = self.vel_y + + # Horizontal collision + self.rect.x += int(dx) + for p in platforms: + if self.rect.colliderect(p): + if dx > 0: + self.rect.right = p.left + elif dx < 0: + self.rect.left = p.right + + # Vertical collision + self.rect.y += int(dy) + self.on_ground = False + for p in platforms: + if self.rect.colliderect(p): + if dy > 0: + self.rect.bottom = p.top + self.vel_y = 0 + self.on_ground = True + elif dy < 0: + self.rect.top = p.bottom + self.vel_y = 0 + + if prev_on_ground and (not self.on_ground): + self.fall_start_y = float(self.rect.bottom) + + if (not prev_on_ground) and self.on_ground: + self.just_landed = True + self.shatterstep_platform_spawned = False + fall_dist = float(self.rect.bottom) - float(self.fall_start_y) + if self.shade and self.is_shatterstep_active() and fall_dist >= 20.0: + self.shatterstep_landing_pending = True + self.shatterstep_landing_damage = 1.5 * 2.0 + self.prev_vel_y = dy + + if self.on_ground: + self.ground_contact_timer = 0.08 + else: + self.ground_contact_timer = max(0.0, self.ground_contact_timer - dt) + + self.is_idle = (abs(input_dx) < 0.01) and (self.ground_contact_timer > 0.0) and (not self.attacking) and (not self.healing) and (not self.is_dashing()) + if self.idle_right_frames: + if self.is_idle: + self.idle_anim_time += dt + self.idle_bob_t += dt * PLAYER_IDLE_BOB_SPEED + self.idle_hold_time += dt + if self.idle_hold_time >= PLAYER_IDLE_START_DELAY: + if self.idle_anim_time >= PLAYER_IDLE_FRAME_TIME: + self.idle_anim_time -= PLAYER_IDLE_FRAME_TIME + self.idle_frame = (self.idle_frame + 1) % len(self.idle_right_frames) + else: + self.idle_anim_time = 0.0 + self.idle_frame = 0 + self.idle_bob_t = 0.0 + self.idle_hold_time = 0.0 + + self._start_heal_if_possible(keys) + self._update_heal(keys, dt) + + if self.shade: + self.charge -= CHARGE_DRAIN_RATE * dt + if self.charge <= 0: + self.charge = 0 + self.shade = False + else: + # no regen while healing, attacking, or during post-attack lockout + if (not self.healing) and (not self.attacking) and (self.charge_regen_lock <= 0.0): + regen = CHARGE_REFILL_RATE * (1.0 + self.bonus_charge_regen_pct) * self.charge_regen_mult + self.charge = min(self.max_charge, self.charge + regen * dt) + + + if self.attacking: + self.attack_timer -= dt + if self.attack_timer <= 0: + self.attacking = False + + self._clamp_resources() + + def draw(self, surface, cx, cy): + if self.healing: + dur = self.effective_heal_duration() + t = max(0.0, min(1.0, self.heal_progress / dur)) + + col = ( + int(HEAL_DARK[0] + (HEAL_LIGHT[0] - HEAL_DARK[0]) * t), + int(HEAL_DARK[1] + (HEAL_LIGHT[1] - HEAL_DARK[1]) * t), + int(HEAL_DARK[2] + (HEAL_LIGHT[2] - HEAL_DARK[2]) * t), + ) + outline = pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height).inflate(14, 14) + pygame.draw.rect(surface, col, outline, 3) + + if self.sprite_right is None: + player_color = PLAYER_HIT_COLOR if self.invincibility_timer > 0 else (PLAYER_DARK if self.shade else PLAYER_LIGHT) + pr = pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height) + pygame.draw.rect(surface, player_color, pr) + else: + use_idle = self.is_idle and self.idle_right_frames and (self.idle_hold_time >= PLAYER_IDLE_START_DELAY) + if self.shade: + if use_idle and (not self.has_shade_sprite): + frames = self.idle_right_shade_frames if self.facing_right else self.idle_left_shade_frames + img = frames[self.idle_frame % len(frames)] + else: + img = self.sprite_right_shade if self.facing_right else self.sprite_left_shade + else: + if use_idle: + frames = self.idle_right_frames if self.facing_right else self.idle_left_frames + img = frames[self.idle_frame % len(frames)] + else: + img = self.sprite_right if self.facing_right else self.sprite_left + + if self.invincibility_timer > 0: + outline = pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height) + pygame.draw.rect(surface, PLAYER_HIT_COLOR, outline, 3) + + draw_x = self.rect.x - cx - (img.get_width() - self.rect.width) // 2 + PLAYER_SPRITE_OFFSET_X + draw_y = self.rect.y - cy - (img.get_height() - self.rect.height) + PLAYER_SPRITE_OFFSET_Y + if self.is_idle and (not self.shade or not self.has_shade_sprite): + draw_y -= int(math.sin(self.idle_bob_t) * PLAYER_IDLE_BOB_AMP) + surface.blit(img, (draw_x, draw_y)) + + # Debug: show player hitbox + pygame.draw.rect( + surface, + (0, 200, 255), + pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height), + 1, + ) + + if self.attacking: + for atk in self.get_attack_hitboxes(): + pygame.draw.rect(surface, (255, 0, 0), + pygame.Rect(atk.x - cx, atk.y - cy, atk.width, atk.height)) + + + +# ---------------------------- +# Enemy (configured via JSON) + Boss slam +# ---------------------------- +class Enemy: + def __init__(self, x, y, type_id: str): + self.type_id = type_id + cfg = ENEMY_TYPES.get(type_id, ENEMY_TYPES.get("enemy", {})) + if not isinstance(cfg, dict): + cfg = {} + + w, h = cfg.get("rect", [40, 60]) + self.rect = pygame.Rect(int(x), int(y), int(w), int(h)) + self.start_x = int(x) + self.start_y = int(y) + + self.vel_x = 0.0 + self.vel_y = 0.0 + + self.max_health = float(cfg.get("health", 3)) + self.health = float(self.max_health) + self.hit_timer = 0.0 + self.stun_timer = 0.0 + self.bind_timer = 0.0 + self.fear_timer = 0.0 + self.slow_timer = 0.0 + self.slow_mult = 1.0 + self.bound_light = True + self.reality_break_glow = False + + self.on_ground = False + self.ground_platform = None + + self.speed = float(cfg.get("speed", 2.2)) + self.air_speed = float(cfg.get("air_speed", 2.0)) + self.direction = -1 if random.random() < 0.5 else 1 + self.detection_range = float(cfg.get("detection_range", 240)) + + self.jump_strength = -12.0 + self.jump_cooldown = 0.55 + self.jump_cd_timer = 0.0 + self.jump_when_player_above = 35 + self.max_step_up = 160 + self.max_step_forward = 120 + + self.contact_damage = int(cfg.get("contact_damage", 1)) + self.drop_profile = str(cfg.get("drop_profile", "normal")) + + # Boss slam/shockwave tuning (supports per-enemy overrides) + self.slam_cooldown_base = float(BOSS_SLAM_COOLDOWN) + self.slam_trigger_close = float(BOSS_TRIGGER_CLOSE) + self.slam_trigger_far = float(BOSS_TRIGGER_FAR) + self.slam_jump_vel = float(BOSS_JUMP_VEL) + self.shockwave_speed = float(SHOCKWAVE_SPEED) + self.shockwave_lifetime = float(SHOCKWAVE_LIFETIME) + self.shockwave_height = int(SHOCKWAVE_HEIGHT) + self.shockwave_damage = int(SHOCKWAVE_DAMAGE) + + boss_cfg = cfg.get("boss_shockwave", cfg.get("slam", {})) if isinstance(cfg, dict) else {} + if isinstance(boss_cfg, dict): + self.slam_cooldown_base = float(boss_cfg.get("slam_cooldown", self.slam_cooldown_base)) + self.slam_trigger_close = float(boss_cfg.get("trigger_close", self.slam_trigger_close)) + self.slam_trigger_far = float(boss_cfg.get("trigger_far", self.slam_trigger_far)) + self.slam_jump_vel = float(boss_cfg.get("jump_vel", self.slam_jump_vel)) + self.shockwave_speed = float(boss_cfg.get("shockwave_speed", self.shockwave_speed)) + self.shockwave_lifetime = float(boss_cfg.get("shockwave_lifetime", self.shockwave_lifetime)) + self.shockwave_height = int(boss_cfg.get("shockwave_height", self.shockwave_height)) + self.shockwave_damage = int(boss_cfg.get("shockwave_damage", self.shockwave_damage)) + + # Boss slam state + self.slam_cd = 0.0 + self.slam_primed = False + self.bind_timer = 0.0 + self.fear_timer = 0.0 + self.slow_timer = 0.0 + self.slow_mult = 1.0 + self.bound_light = True + self.reality_break_glow = False + + # --- anti-jiggle helpers --- + self.turn_cooldown = 0.0 # prevents rapid flip-flop at edges/walls + self.last_x = self.rect.x + self.stuck_timer = 0.0 # (reserved) detects "want to move but can't" + + # --- aggro break drop grace (prevents snap-back) --- + self.was_chasing = False + self.last_chase_dir = self.direction + self.aggro_drop_timer = 0.0 + + # When aggro breaks near an edge: keep walking off naturally, then fall + self.stepoff_timer = 0.0 + self.stepoff_dir = self.direction + + + # --- drop commit (prevents "teleport snap" on the platform we just left) --- + self.drop_ignore_timer = 0.0 + self.drop_ignore_platform = None + + + def take_damage(self, dmg): + self.health = max(0.0, float(self.health) - float(dmg)) + self.hit_timer = 0.1 + + def apply_stun(self, duration: float): + self.stun_timer = max(self.stun_timer, float(duration)) + + def respawn(self): + self.rect.topleft = (self.start_x, self.start_y) + self.vel_x = 0.0 + self.vel_y = 0.0 + cfg = ENEMY_TYPES.get(self.type_id, ENEMY_TYPES.get("enemy", {})) + self.max_health = float(cfg.get("health", 3)) if isinstance(cfg, dict) else 3.0 + self.health = float(self.max_health) + self.direction = 1 + self.on_ground = False + self.ground_platform = None + self.jump_cd_timer = 0.0 + self.slam_cd = 0.0 + self.slam_primed = False + + self.turn_cooldown = 0.0 + self.last_x = self.rect.x + self.stuck_timer = 0.0 + + def get_support_platform(self, platforms): + """Return the platform directly under the enemy's feet (or None).""" + probe = self.rect.move(0, 1) # 1px below feet + best = None + best_top = None + for p in platforms: + if probe.colliderect(p): + if best is None or p.top < best_top: + best = p + best_top = p.top + return best + + + def ground_ahead(self, direction, lookahead): + """Check for ground ahead using the CURRENT support platform only.""" + p = self.ground_platform + if p is None: + return False + + if direction > 0: + x = self.rect.right + lookahead + return x <= p.right + else: + x = self.rect.left - lookahead + return x >= p.left + + + def _probe_ground_now(self, platforms): + """Refresh on_ground + ground_platform every frame (stable).""" + p = self.get_support_platform(platforms) + if p is None: + self.on_ground = False + self.ground_platform = None + return + + # Snap to the platform top only if we're basically landing / resting on it + if self.rect.bottom > p.top: + self.rect.bottom = p.top + self.on_ground = True + self.ground_platform = p + if self.vel_y > 0: + self.vel_y = 0.0 + + + + + def reachable_platform_ahead(self, platforms): + enemy_feet_y = self.rect.bottom + if self.direction > 0: + ahead_min_x = self.rect.right + ahead_max_x = self.rect.right + self.max_step_forward + else: + ahead_min_x = self.rect.left - self.max_step_forward + ahead_max_x = self.rect.left + + for p in platforms: + if not (enemy_feet_y - self.max_step_up <= p.top < enemy_feet_y): + continue + if p.right < ahead_min_x or p.left > ahead_max_x: + continue + if self.rect.right < p.left - 30 or self.rect.left > p.right + 30: + continue + return True + return False + + def platform_ahead_for_jump(self, platforms): + """Return a reachable platform across a small gap (no downward jumps).""" + gp = self.ground_platform + if gp is None: + return None + + max_drop = 8 # allow tiny downward variance only + best = None + best_gap = None + + for p in platforms: + if p is gp: + continue + if p.top < gp.top - self.max_step_up: + continue + if p.top > gp.top + max_drop: + continue + + if self.direction > 0: + gap = p.left - gp.right + if gap < 0 or gap > self.max_step_forward: + continue + else: + gap = gp.left - p.right + if gap < 0 or gap > self.max_step_forward: + continue + + if best_gap is None or gap < best_gap: + best = p + best_gap = gap + + return best + + def update(self, dt, platforms, ground_platforms, player, shockwaves_list: list[Shockwave]): + if self.jump_cd_timer > 0: + self.jump_cd_timer -= dt + + if self.turn_cooldown > 0: + self.turn_cooldown -= dt + + if self.aggro_drop_timer > 0.0: + self.aggro_drop_timer = max(0.0, self.aggro_drop_timer - dt) + + if self.stepoff_timer > 0.0: + self.stepoff_timer = max(0.0, self.stepoff_timer - dt) + + if self.bind_timer > 0.0: + self.bind_timer = max(0.0, self.bind_timer - dt) + if self.fear_timer > 0.0: + self.fear_timer = max(0.0, self.fear_timer - dt) + if self.slow_timer > 0.0: + self.slow_timer = max(0.0, self.slow_timer - dt) + if self.slow_timer == 0.0: + self.slow_mult = 1.0 + + + + if self.drop_ignore_timer > 0.0: + self.drop_ignore_timer = max(0.0, self.drop_ignore_timer - dt) + if self.drop_ignore_timer <= 0.0: + self.drop_ignore_platform = None + + + + # Boss slam cooldown tick + if self.type_id == "boss" and self.slam_cd > 0: + self.slam_cd -= dt + + dx = player.rect.centerx - self.rect.centerx + dy = player.rect.centery - self.rect.centery + distance = math.hypot(dx, dy) + chasing = (not player.shade) and (distance <= self.detection_range) + + feared = self.fear_timer > 0.0 + if feared: + chasing = False + self.direction = -1 if dx > 0 else 1 + + if chasing: + self.direction = 1 if dx > 0 else -1 + self.last_chase_dir = self.direction # remember chase intent + + # aggro just broke (shade OR distance) + if self.was_chasing and (not chasing): + self.aggro_drop_timer = 0.30 + + # Only do "step off" behavior if we're ALREADY over the edge at all. + if self.on_ground: + gp = self.get_support_platform(platforms) + if gp is not None: + off_edge = (self.rect.left < gp.left) or (self.rect.right > gp.right) + + if off_edge: + self.stepoff_timer = 0.25 + self.stepoff_dir = 1 if self.last_chase_dir >= 0 else -1 + + # IMPORTANT: ignore this platform only for horizontal "teleport shove" + self.drop_ignore_platform = gp + self.drop_ignore_timer = max(self.drop_ignore_timer, 0.35) + + + if self.hit_timer > 0: + self.hit_timer -= dt + + stunned = False + if self.stun_timer > 0.0: + self.stun_timer = max(0.0, self.stun_timer - dt) + stunned = True + + + # Trigger boss slam: close OR far + if (not stunned) and self.type_id == "boss" and (not player.shade): + dist = abs(distance) + should_slam = (dist <= self.slam_trigger_close) or (dist >= self.slam_trigger_far) + if self.on_ground and self.slam_cd <= 0 and should_slam and (not self.slam_primed): + self.vel_y = self.slam_jump_vel + self.on_ground = False + self.ground_platform = None + self.slam_primed = True + self.slam_cd = self.slam_cooldown_base + + # gravity + vertical move + self.vel_y += 0.5 + + # clamp fall speed to avoid insane integers if something goes wrong / long fall + if self.vel_y > 24.0: + self.vel_y = 24.0 + + prev_bottom = self.rect.bottom + prev_top = self.rect.top + self.rect.y += int(self.vel_y) + + + # safety: if enemy falls into the void, respawn them + # (prevents runaway y values and removes "fell forever" weirdness) + if self.rect.top > (ROOM.kill_y + 400): + self.respawn() + return + + + # IMPORTANT: track landing transition + was_on_ground = self.on_ground + self.on_ground = False + self.ground_platform = None + + # vertical collision resolve + # IMPORTANT: Only "land" on the platform that is actually under our feet. + if self.vel_y > 0: + gp = self.get_support_platform(platforms) + + if gp is not None: + # Only count as landing if we were above the surface last frame + if prev_bottom <= gp.top + 1 and self.rect.bottom >= gp.top: + self.rect.bottom = gp.top + self.vel_y = 0 + self.on_ground = True + self.ground_platform = gp + + # Head bonk (still use colliders, but only if coming from below) + elif self.vel_y < 0: + for p in platforms: + if not self.rect.colliderect(p): + continue + if prev_top >= p.bottom - 1: + self.rect.top = p.bottom + self.vel_y = 0 + break + + + + # --- SUPPORT CHECK (edge handling) --- + # In stepoff mode: stay grounded as long as ANY overlap with the platform remains. + # Otherwise: use your foot-probe logic (helps patrol behave nicely). + if self.on_ground and (self.ground_platform is not None): + gp = self.ground_platform + + if abs(self.rect.bottom - gp.top) <= 1: + if getattr(self, "stepoff_timer", 0.0) > 0.0: + # overlap in X between enemy and platform + overlap_w = min(self.rect.right, gp.right) - max(self.rect.left, gp.left) + + if overlap_w > 0: + # still supported -> keep glued to top + self.rect.bottom = gp.top + self.vel_y = 0.0 + else: + # fully off -> now fall + self.on_ground = False + self.ground_platform = None + # do NOT shove down; let gravity take it naturally + # self.vel_y stays as-is + else: + # Patrol: keep grounded as long as any overlap remains. + if not chasing: + overlap_w = min(self.rect.right, gp.right) - max(self.rect.left, gp.left) + if overlap_w > 0: + self.rect.bottom = gp.top + self.vel_y = 0.0 + else: + # Clamp back to the nearest edge and turn around. + if self.rect.centerx >= gp.right: + self.rect.right = gp.right + self.direction = -1 + elif self.rect.centerx <= gp.left: + self.rect.left = gp.left + self.direction = 1 + self.rect.bottom = gp.top + self.vel_y = 0.0 + # keep on_ground in patrol + self.on_ground = True + self.ground_platform = gp + else: + probe_y = self.rect.bottom + 1 + + foot_inset = max(10, int(self.rect.width * 0.35)) + left_x = self.rect.left + foot_inset + right_x = self.rect.right - foot_inset + + left_supported = gp.collidepoint(left_x, probe_y) + right_supported = gp.collidepoint(right_x, probe_y) + + if (not left_supported) and (not right_supported): + self.on_ground = False + self.ground_platform = None + # no forced downward shove here either + + + + # --- HARD ledge prevention (patrol only) --- + # Don't clamp during aggro-break grace OR while we're intentionally stepping off a platform. + if (not chasing) and (self.aggro_drop_timer <= 0.0) and (self.drop_ignore_timer <= 0.0) and self.on_ground and self.ground_platform is not None: + gp = self.ground_platform + + # How much of the enemy is still over the platform (X overlap) + overlap_w = min(self.rect.right, gp.right) - max(self.rect.left, gp.left) + overlap_w = max(0, overlap_w) + + # During "aggro just broke", allow the drop if they're already significantly off. + # Bigger threshold => drops sooner (more forgiving). + commit_overlap = self.rect.width - 1 + + + if (self.aggro_drop_timer > 0.0) and (overlap_w < self.rect.width - 1): + self.on_ground = False + self.ground_platform = None + self.vel_y = max(self.vel_y, 2.0) + self.aggro_drop_timer = 0.0 + + else: + # Normal patrol behavior: never drop, just clamp/flip at bounds. + if self.rect.left < gp.left: + self.rect.left = gp.left + self.direction = 1 + elif self.rect.right > gp.right: + self.rect.right = gp.right + self.direction = -1 + + + # if boss just landed after primed slam -> spawn shockwaves + if self.type_id == "boss" and self.on_ground and (not was_on_ground) and self.slam_primed: + self.slam_primed = False + spawn_boss_shockwave(self, shockwaves_list) + + if stunned: + self.vel_x = 0.0 + self.was_chasing = False + return + + # # --- prevent overlap jiggle with the player --- + # if self.rect.colliderect(player.rect): + # inter = self.rect.clip(player.rect) + # if inter.width > 0: + # if self.rect.centerx < player.rect.centerx: + # self.rect.x -= inter.width + # else: + # self.rect.x += inter.width + # self.vel_x = 0.0 + # # tiny cooldown so we don't instantly flip-flop due to edge logic + # self.turn_cooldown = max(self.turn_cooldown, 0.08) + + desired_speed = self.speed if self.on_ground else self.air_speed + if self.slow_timer > 0.0: + desired_speed *= self.slow_mult + if feared: + desired_speed *= TETHER_FEAR_SPEED_MULT + + # --- PATROL / CHASE INTENT --- + if chasing: + self.direction = 1 if player.rect.centerx > self.rect.centerx else -1 + elif self.stepoff_timer > 0.0: + # aggro just broke near an edge: keep walking off naturally + self.direction = self.stepoff_dir + + self.vel_x = self.direction * desired_speed + + + # --- CHASE LEDGE DROP --- + # If we're chasing and the player is below, step off the platform instead of edge-wiggling. + jump_target = None + if chasing and self.on_ground and self.ground_platform is not None: + gp = self.ground_platform + + # IMPORTANT: define this right here so it's ALWAYS available + player_below = (player.rect.top > self.rect.bottom + 6) + + # are we at the edge we're trying to walk past? + look = 6 + at_edge = ( + (self.direction > 0 and self.rect.right + look >= gp.right) or + (self.direction < 0 and self.rect.left - look <= gp.left) + ) + + # Jump small gaps if a safe platform exists on the other side. + if at_edge and (not player_below) and self.jump_cd_timer <= 0: + jump_target = self.platform_ahead_for_jump(platforms) + if jump_target is not None: + self.vel_y = self.jump_strength + self.on_ground = False + self.ground_platform = None + self.jump_cd_timer = self.jump_cooldown + + if player_below and at_edge: + # cleanly step off + fall + # ignore THIS platform for a short time so we don't "snap" back into it + self.drop_ignore_platform = gp + self.drop_ignore_timer = max(self.drop_ignore_timer, 0.35) + + self.on_ground = False + self.ground_platform = None + self.vel_y = max(self.vel_y, 2.0) + self.turn_cooldown = max(self.turn_cooldown, 0.15) + + + # --- apply horizontal movement --- + old_x = self.rect.x + self.rect.x += int(self.vel_x) + + blocked_by_wall = False + for p in platforms: + # If we're standing on this platform, don't treat it as a horizontal wall. + if self.on_ground and (self.ground_platform is p): + continue + + if (not self.on_ground) and (self.drop_ignore_timer > 0.0) and (self.drop_ignore_platform is p): + continue + + if self.rect.colliderect(p): + blocked_by_wall = True + if self.vel_x > 0: + self.rect.right = p.left + elif self.vel_x < 0: + self.rect.left = p.right + break + + + if blocked_by_wall and self.on_ground: + if chasing: + # chasing: stop and don't vibrate into the wall + self.rect.x = old_x + self.vel_x = 0.0 + else: + # patrolling: turn around and keep moving + if self.turn_cooldown <= 0.0: + self.direction *= -1 + self.turn_cooldown = 0.25 + + # back off a pixel so we don't instantly collide again + self.rect.x = old_x + int(self.direction * 2) + self.vel_x = self.direction * desired_speed + + + + # regular enemy jump helper + if self.on_ground and chasing and self.jump_cd_timer <= 0: + player_above = player.rect.centery < self.rect.centery - self.jump_when_player_above + if player_above and (blocked_by_wall or self.reachable_platform_ahead(platforms)): + self.vel_y = self.jump_strength + self.on_ground = False + self.ground_platform = None + self.jump_cd_timer = self.jump_cooldown + + # track movement for potential future stuck logic / debugging + self.last_x = self.rect.x + + # remember chase state for next frame + self.was_chasing = chasing + + # if self.hit_timer > 0: + # self.hit_timer -= dt + + + + def draw(self, surface, cx, cy): + if self.type_id == "boss": + color = BOSS_HIT_COLOR if self.hit_timer > 0 else BOSS_COLOR + elif self.type_id == "miniboss": + color = MINIBOSS_HIT_COLOR if self.hit_timer > 0 else MINIBOSS_COLOR + else: + color = ENEMY_HIT_COLOR if self.hit_timer > 0 else ENEMY_COLOR + + pygame.draw.rect( + surface, + color, + pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height) + ) + + if self.reality_break_glow: + pygame.draw.rect( + surface, + (240, 210, 90), + pygame.Rect(self.rect.x - cx - 2, self.rect.y - cy - 2, self.rect.width + 4, self.rect.height + 4), + 2, + ) + + if self.bind_timer > 0.0: + _draw_tether_chain_loop(surface, self.rect, light=self.bound_light, cx=cx, cy=cy) + + +def resolve_enemy_collisions(enemies): + # for i in range(len(enemies)): + # for j in range(i + 1, len(enemies)): + # a = enemies[i] + # b = enemies[j] + # if not a.rect.colliderect(b.rect): + # continue + # inter = a.rect.clip(b.rect) + # if inter.width == 0 or inter.height == 0: + # continue + # if inter.width < inter.height: + # if a.rect.centerx < b.rect.centerx: + # a.rect.x -= inter.width // 2 + # b.rect.x += inter.width - inter.width // 2 + # else: + # a.rect.x += inter.width // 2 + # b.rect.x -= inter.width - inter.width // 2 + # else: + # if a.rect.centery < b.rect.centery: + # a.rect.y -= inter.height // 2 + # b.rect.y += inter.height - inter.height // 2 + # a.vel_y = min(a.vel_y, 0) + # else: + # a.rect.y += inter.height // 2 + # b.rect.y -= inter.height - inter.height // 2 + # b.vel_y = min(b.vel_y, 0) + return + +# ---------------------------- +# Spawn enemies from room JSON +# ---------------------------- +def spawn_enemies_from_room(room: Room): + out = [] + for ent in room.enemies: + if not isinstance(ent, dict): + continue + t = str(ent.get("type", "enemy")) + if t == "boss" and is_boss_defeated(room.id): + continue + pos = ent.get("pos", None) + if not (isinstance(pos, (list, tuple)) and len(pos) == 2): + if "x" in ent and "y" in ent: + pos = [ent.get("x", 0), ent.get("y", 0)] + else: + continue + cfg = ENEMY_TYPES.get(t, ENEMY_TYPES.get("enemy", {})) + if not isinstance(cfg, dict): + cfg = {} + w, h = cfg.get("rect", [40, 60]) + x0 = int(pos[0] - (w / 2)) + y0 = int(pos[1] - (h / 2)) + out.append(Enemy(x0, y0, t)) + return out + + +def spawn_npcs_from_room(room: Room): + out = [] + for ent in room.npcs: + if not isinstance(ent, dict): + continue + t = str(ent.get("type", "vendor")) + pos = ent.get("pos", None) + if not (isinstance(pos, (list, tuple)) and len(pos) == 2): + if "x" in ent and "y" in ent: + pos = [ent.get("x", 0), ent.get("y", 0)] + else: + continue + trade_cost = ent.get("trade_cost", None) + if trade_cost is None: + trade_cost = _npc(["defaults", t, "trade_cost"], NPC_TRADE_COST) + offers = ent.get("offers", None) + if offers is None: + offers = _npc(["defaults", t, "offers"], []) + greet_light = ent.get("greet_light", None) + if greet_light is None: + greet_light = _npc(["defaults", t, "greet_light"], None) + greet_shade = ent.get("greet_shade", None) + if greet_shade is None: + greet_shade = _npc(["defaults", t, "greet_shade"], None) + greet_hint = ent.get("greet_hint", None) + if greet_hint is None: + greet_hint = _npc(["defaults", t, "greet_hint"], None) + + out.append(NPC(pos[0], pos[1], t, trade_cost=trade_cost, offers=offers, + greet_light=greet_light, greet_shade=greet_shade, greet_hint=greet_hint)) + return out + + +# ---------------------------- +# Coin drop helpers +# ---------------------------- +def spawn_coins(kind: str, count: int, x: int, y: int): + count = scale_count(int(count), ROOM_COIN_MULT) + for _ in range(int(count)): + ox = random.randint(-10, 10) + oy = random.randint(-6, 6) + coin_drops.append(CoinPickup(x + ox, y + oy, kind)) + +def _roll_weighted_count(weights, default_weights) -> int: + if not isinstance(weights, list) or not weights: + weights = default_weights + total = float(sum(weights)) + if total <= 0: + return 0 + r = random.random() * total + acc = 0.0 + for i, w in enumerate(weights): + acc += float(w) + if r < acc: + return i + return len(weights) - 1 + +def roll_light(kind: str, default_min: int, default_max: int) -> int: + min_v = int(_coin_drop([kind, "light", "min"], default_min)) + max_v = int(_coin_drop([kind, "light", "max"], default_max)) + if max_v < min_v: + max_v = min_v + return random.randint(min_v, max_v) + +def roll_dark(kind: str, default_weights: list[float]) -> int: + weights = _coin_drop([kind, "dark", "weights"], default_weights) + return _roll_weighted_count(weights, default_weights) + +def roll_normal_light() -> int: + return roll_light("enemy", 1, 3) + +def roll_miniboss_light() -> int: + return roll_light("miniboss", 3, 5) + +def roll_boss_light() -> int: + return roll_light("boss", 5, 10) + +def roll_normal_dark() -> int: + weights = _coin_drop(["enemy", "dark_shade", "weights"], [0.5, 0.5]) + return _roll_weighted_count(weights, [0.5, 0.5]) + +def roll_miniboss_dark() -> int: + weights = _coin_drop(["miniboss", "dark_shade", "weights"], [0.34, 0.33, 0.33]) + return _roll_weighted_count(weights, [0.34, 0.33, 0.33]) + +def roll_boss_dark() -> int: + return roll_dark("boss", [0.55, 0.25, 0.15, 0.05]) + + +# ---------------------------- +# Debug overlay +# ---------------------------- +def draw_pause_controls(surface, x, y): + lines = [ + f"{key_name(key_for('move_left'))}/{key_name(key_for('move_right'))} = move", + f"{key_name(key_for('jump'))} = jump", + f"{key_name(key_for('attack'))} = attack", + f"{key_name(key_for('ranged'))} = ranged", + f"{key_name(key_for('dash'))} = dash", + f"{key_name(key_for('shatterstep'))} = shatterstep", + f"{key_name(key_for('tether'))} = tether", + f"{key_name(key_for('reality_break'))} = reality break", + f"{key_name(key_for('toggle_shade'))} = toggle shade", + f"Hold {key_name(key_for('heal'))} = heal", + f"{key_name(key_for('inventory'))} = inventory", + f"{key_name(key_for('interact'))} = interact / trade", + f"{key_name(key_for('pause'))} = pause", + "Click Close Game to exit", + ] + for s in lines: + surface.blit(font.render(s, True, (220, 220, 220)), (x, y)) + y += 18 + + +def get_pause_close_button_rect() -> pygame.Rect: + w, h = 180, 36 + x = WIDTH // 2 - w // 2 + y = HEIGHT // 2 + 24 + return pygame.Rect(x, y, w, h) + + +def draw_speech_bubble(surface, text: str, center_x: int, bottom_y: int, hint_text: str = ""): + surf = font.render(text, True, (240, 240, 240)).convert_alpha() + pad_x = 8 + pad_y = 6 + w = surf.get_width() + pad_x * 2 + h = surf.get_height() + pad_y * 2 + + box_x = int(center_x - w // 2) + box_y = int(bottom_y - h - 10) + + bubble = pygame.Surface((w, h), pygame.SRCALPHA) + bubble.fill((20, 20, 20, 200)) + surface.blit(bubble, (box_x, box_y)) + pygame.draw.rect(surface, (10, 10, 10), (box_x, box_y, w, h), 2) + + tail = [ + (center_x - 6, box_y + h), + (center_x + 6, box_y + h), + (center_x, box_y + h + 8), + ] + pygame.draw.polygon(surface, (20, 20, 20), tail) + pygame.draw.polygon(surface, (10, 10, 10), tail, 2) + surface.blit(surf, (box_x + pad_x, box_y + pad_y)) + + if hint_text: + hint = font.render(hint_text, True, (0, 0, 0)).convert_alpha() + hint_x = int(center_x - hint.get_width() // 2) + hint_y = box_y + h + 10 + surface.blit(hint, (hint_x, hint_y)) + + +def draw_npc_house(surface, npc: NPC, cx, cy): + # Simple backdrop house behind the NPC + house_w = 140 + house_h = 90 + base_x = int(npc.rect.centerx - house_w // 2 - cx) + base_y = int(npc.rect.bottom - house_h + 6 - cy) + + # wall + pygame.draw.rect(surface, (180, 170, 150), (base_x, base_y + 30, house_w, house_h - 30)) + pygame.draw.rect(surface, (120, 110, 95), (base_x, base_y + 30, house_w, house_h - 30), 2) + + # door + door_w = 26 + door_h = 34 + door_x = base_x + house_w // 2 - door_w // 2 + door_y = base_y + house_h - door_h + pygame.draw.rect(surface, (150, 170, 195), (door_x, door_y, door_w, door_h)) + pygame.draw.rect(surface, (95, 110, 130), (door_x, door_y, door_w, door_h), 2) + + # roof (draw last so it sits on top) + roof_pts = [ + (base_x - 6, base_y + 34), + (base_x + house_w // 2, base_y - 8), + (base_x + house_w + 6, base_y + 34), + ] + pygame.draw.polygon(surface, (210, 170, 110), roof_pts) + pygame.draw.polygon(surface, (135, 100, 65), roof_pts, 2) + +def draw_ability_icon_bg(surface, x: int, y: int, size: int, shade: bool): + pad = 4 + w = size + pad * 2 + h = size + pad * 2 + if shade: + fill_col = (150, 150, 165, 120) + border_col = (120, 120, 135) + else: + fill_col = (150, 150, 165, 120) + border_col = (120, 120, 135) + rect = pygame.Rect(x - pad, y - pad, w, h) + pygame.draw.rect(surface, fill_col, rect, border_radius=6) + pygame.draw.rect(surface, border_col, rect, 1, border_radius=6) + + +def draw_shatterstep_icon(surface, player_obj: "Player"): + if not getattr(player_obj, "has_shatterstep", False): + return + icon = SHATTER_ICON_DARK if player_obj.shade else SHATTER_ICON_LIGHT + if icon is None: + return + x, y = SHATTER_ICON_POS + draw_ability_icon_bg(surface, x, y, SHATTER_ICON_SIZE, player_obj.shade) + surface.blit(icon, (x, y)) + + # Cooldown overlay (always visible while cd > 0) + if player_obj.shatterstep_cd > 0.0: + cd = max(0.0, min(1.0, player_obj.shatterstep_cd / SHATTERSTEP_COOLDOWN)) + overlay = pygame.Surface((SHATTER_ICON_SIZE, SHATTER_ICON_SIZE), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 180)) + surface.blit(overlay, (x, y)) + + progress = 1.0 - cd + h = int(SHATTER_ICON_SIZE * progress) + if h > 0: + reveal = icon.subsurface((0, SHATTER_ICON_SIZE - h, SHATTER_ICON_SIZE, h)).copy() + surface.blit(reveal, (x, y + (SHATTER_ICON_SIZE - h))) + + # Active highlight + duration fill (draw after cooldown so it stays visible) + if player_obj.is_shatterstep_active(): + pygame.draw.rect(surface, (120, 220, 255), (x - 2, y - 2, SHATTER_ICON_SIZE + 4, SHATTER_ICON_SIZE + 4), 2) + dur = max(0.0, min(1.0, player_obj.shatterstep_timer / SHATTERSTEP_DURATION)) + h = int(SHATTER_ICON_SIZE * dur) + if h > 0: + active_overlay = pygame.Surface((SHATTER_ICON_SIZE, SHATTER_ICON_SIZE), pygame.SRCALPHA) + active_overlay.fill((80, 200, 255, 160)) + surface.blit(active_overlay, (x, y)) + spent_h = SHATTER_ICON_SIZE - h + if spent_h > 0: + spent = pygame.Surface((SHATTER_ICON_SIZE, spent_h), pygame.SRCALPHA) + spent.fill((0, 0, 0, 190)) + surface.blit(spent, (x, y + h)) + + +def draw_tether_icon(surface, player_obj: "Player"): + if not getattr(player_obj, "has_tether", False): + return + icon = TETHER_ICON_DARK if player_obj.shade else TETHER_ICON_LIGHT + if icon is None: + return + x, y = TETHER_ICON_POS + draw_ability_icon_bg(surface, x, y, TETHER_ICON_SIZE, player_obj.shade) + surface.blit(icon, (x, y)) + + if player_obj.tether_pending_cd > 0.0: + pygame.draw.rect(surface, (220, 180, 120), (x - 2, y - 2, TETHER_ICON_SIZE + 4, TETHER_ICON_SIZE + 4), 2) + + remaining = player_obj.tether_cd if player_obj.tether_cd > 0.0 else player_obj.tether_pending_cd + total = TETHER_COOLDOWN if player_obj.tether_cd > 0.0 else TETHER_BIND_DURATION + if remaining > 0.0 and total > 0.0: + cd = max(0.0, min(1.0, remaining / total)) + overlay = pygame.Surface((TETHER_ICON_SIZE, TETHER_ICON_SIZE), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 180)) + surface.blit(overlay, (x, y)) + progress = 1.0 - cd + h = int(TETHER_ICON_SIZE * progress) + if h > 0: + reveal = icon.subsurface((0, TETHER_ICON_SIZE - h, TETHER_ICON_SIZE, h)).copy() + surface.blit(reveal, (x, y + (TETHER_ICON_SIZE - h))) + + +def draw_reality_break_icon(surface, player_obj: "Player"): + if not getattr(player_obj, "has_reality_break", False): + return + icon = REALITY_ICON_DARK if player_obj.shade else REALITY_ICON_LIGHT + if icon is None: + return + x, y = REALITY_BREAK_ICON_POS + draw_ability_icon_bg(surface, x, y, REALITY_BREAK_ICON_SIZE, player_obj.shade) + surface.blit(icon, (x, y)) + if player_obj.reality_break_flash > 0.0: + pygame.draw.rect( + surface, + (255, 230, 150), + (x - 2, y - 2, REALITY_BREAK_ICON_SIZE + 4, REALITY_BREAK_ICON_SIZE + 4), + 2, + ) + + +def _draw_tether_chain_loop(surface, rect: pygame.Rect, light: bool, cx: float, cy: float): + seg = TETHER_SPRITE_LIGHT if light else TETHER_SPRITE_DARK + corner = TETHER_EDGE_LIGHT if light else TETHER_EDGE_DARK + if seg is None or corner is None: + return + + seg_w, seg_h = seg.get_size() + if seg_w <= 0 or seg_h <= 0: + return + + # tight loop around enemy legs area + pad = 4 + left = rect.left - pad + right = rect.right + pad + band_h = max(int(rect.height * 0.45), seg_h * 2) + top = rect.bottom - band_h + bottom = rect.bottom + pad + + # top/bottom + x = left + while x <= right - seg_w: + surface.blit(seg, (x - cx, top - cy)) + surface.blit(seg, (x - cx, bottom - cy - seg_h)) + x += max(1, seg_w - 4) + + # left/right (rotate segment) + seg_v = pygame.transform.rotate(seg, 90) + seg_v_w, seg_v_h = seg_v.get_size() + y = top + while y <= bottom - seg_v_h: + surface.blit(seg_v, (left - cx, y - cy)) + surface.blit(seg_v, (right - cx - seg_v_w, y - cy)) + y += max(1, seg_v_h - 4) + + # corners (rotate edge) + c_tl = corner + c_tr = pygame.transform.flip(corner, True, False) + c_bl = pygame.transform.flip(corner, False, True) + c_br = pygame.transform.flip(corner, True, True) + + surface.blit(c_tl, (left - cx, top - cy)) + surface.blit(c_tr, (right - cx - c_tr.get_width(), top - cy)) + surface.blit(c_bl, (left - cx, bottom - cy - c_bl.get_height())) + surface.blit(c_br, (right - cx - c_br.get_width(), bottom - cy - c_br.get_height())) + + +def get_pause_menu_rects(): + items = ["Resume", "Settings", "Close Game"] + rects = [] + start_y = HEIGHT // 2 + 20 + for i, label in enumerate(items): + text = font.render(label, True, (240, 240, 240)) + x = WIDTH // 2 - text.get_width() // 2 + y = start_y + i * 28 + rects.append(pygame.Rect(x - 6, y - 2, text.get_width() + 12, text.get_height() + 4)) + return rects + + +def draw_pause_menu(surface, selected_idx: int, use_mouse: bool): + items = ["Resume", "Settings", "Close Game"] + rects = get_pause_menu_rects() + mouse_pos = pygame.mouse.get_pos() + hover_idx = None + for i, r in enumerate(rects): + if r.collidepoint(mouse_pos): + hover_idx = i + break + + active_idx = hover_idx if (use_mouse and hover_idx is not None) else selected_idx + + for i, label in enumerate(items): + r = rects[i] + active = (i == active_idx) + fill = (60, 60, 60) if active else (40, 40, 40) + border = (140, 140, 140) if active else (90, 90, 90) + pygame.draw.rect(surface, fill, r, border_radius=6) + pygame.draw.rect(surface, border, r, 2, border_radius=6) + col = (245, 245, 245) if active else (210, 210, 210) + text = font.render(label, True, col) + surface.blit(text, (r.x + 6, r.y + 2)) + + +def draw_settings_menu(surface, selected_idx: int, waiting_action: str | None): + panel_w = 360 + panel_h = 360 + x = 20 + y = 120 + pygame.draw.rect(surface, (15, 15, 15), (x, y, panel_w, panel_h)) + pygame.draw.rect(surface, (60, 60, 60), (x, y, panel_w, panel_h), 2) + title = font.render("Settings: Keybinds", True, (240, 240, 240)) + surface.blit(title, (x + 10, y + 10)) + + start_y = y + 40 + line_h = 18 + max_lines = (panel_h - 70) // line_h + start_idx = max(0, min(selected_idx - max_lines // 2, len(SETTINGS_ACTIONS) - max_lines)) + end_idx = min(len(SETTINGS_ACTIONS), start_idx + max_lines) + + for i in range(start_idx, end_idx): + label, action = SETTINGS_ACTIONS[i] + col = (255, 255, 255) if i == selected_idx else (200, 200, 200) + key_label = key_name(key_for(action)) if key_for(action) is not None else "-" + line = f"{label}: {key_label}" + if waiting_action == action: + line = f"{label}: " + col = (255, 220, 140) + txt = font.render(line, True, col) + surface.blit(txt, (x + 10, start_y + (i - start_idx) * line_h)) + + hint = font.render("Up/Down = select Enter = rebind Esc = back", True, (170, 170, 170)) + surface.blit(hint, (x + 10, y + panel_h - 24)) + + +def get_near_npc(player_obj: Player, npc_list: list[NPC]) -> NPC | None: + for n in npc_list: + dx = player_obj.rect.centerx - n.rect.centerx + dy = player_obj.rect.centery - n.rect.centery + if (dx * dx + dy * dy) <= (NPC_TALK_RANGE * NPC_TALK_RANGE): + return n + return None + + +# ---------------------------- +# Room switching glue +# ---------------------------- +room_manager = RoomManager() +room_manager.load_room("room_01.json") + +ROOM = room_manager.current_room +KILL_Y = ROOM.kill_y +platforms_ground = ROOM.platforms_ground +platforms_floating = ROOM.platforms_floating +ground_platforms = ROOM.ground_platforms +platforms = ROOM.platforms + + +# ---------------------------- +# Game init +# ---------------------------- +player = Player(int(ROOM.player_spawn[0]), int(ROOM.player_spawn[1]), PLAYER_SPRITE_RAW, SHADE_SPRITE_RAW) +inventory = Inventory() +inv_ui = InventoryUI(inventory, player, font) +trade_ui = TradeUI(inventory, player, font) +pickup_feed = PickupFeed(font, max_lines=int(_ui(["pickup_feed", "max_lines"], 6))) + + +enemies = spawn_enemies_from_room(ROOM) +npcs = spawn_npcs_from_room(ROOM) +ability_doors = [AbilityDoor(pygame.Rect(*map(int, d.get("rect", [0, 0, 0, 0])))) + for d in ROOM.ability_doors if isinstance(d, dict)] +loot_drops: list[LootPickup] = [] +shockwaves: list[Shockwave] = [] +coin_drops: list[CoinPickup] = [] +projectiles: list[Projectile] = [] +shatter_platforms: list[ShatterPlatform] = [] +shatter_aoes: list[ShatterAOE] = [] +tether_shots: list[TetherShot] = [] +light_sources: list[LightSource] = [] +dust_puffs: list[DustPuff] = [] +def spawn_room_props(room: Room, player_obj: "Player"): + reward_chest_obj = None + reward_ability_obj = None + lights: list[LightSource] = [] + + props = room.props + if not props and room.id == REWARD_ROOM_ID: + props = [ + {"type": "reward_chest", "pos": list(REWARD_CHEST_POS), "coins": list(REWARD_CHEST_COINS)}, + {"type": "ability_pickup", "pos": list(REWARD_ABILITY_POS), "ability": "ranged"}, + ] + + for p in props: + if not isinstance(p, dict): + continue + p_type = str(p.get("type", "")) + pos = p.get("pos", None) + if not (isinstance(pos, (list, tuple)) and len(pos) == 2): + continue + x, y = int(pos[0]), int(pos[1]) + + if p_type == "reward_chest" and reward_chest_obj is None: + coins = p.get("coins", REWARD_CHEST_COINS) + if isinstance(coins, (list, tuple)) and len(coins) == 2: + coins = (int(coins[0]), int(coins[1])) + else: + coins = REWARD_CHEST_COINS + reward_chest_obj = RewardChest(x, y, opened=player_obj.reward_chest_opened, coins=coins) + elif p_type == "ability_pickup" and reward_ability_obj is None: + ability_id = str(p.get("ability", "ranged")) + if ability_id == "ranged" and player_obj.has_ranged: + continue + reward_ability_obj = AbilityPickup(x, y, ability_id=ability_id) + elif p_type == "light_source": + radius = int(p.get("radius", 180)) + lights.append(LightSource(x, y, radius)) + + return reward_chest_obj, reward_ability_obj, lights + + +reward_chest, reward_ability, light_sources = spawn_room_props(ROOM, player) + +def grant_full_set(set_id: str, rarity: str, equip: bool = True) -> bool: + slots = ["helmet", "chest", "pants"] + by_slot = {} + for it in ARMOR_DB: + if it.get("set") == set_id and it.get("slot") in slots: + by_slot[it.get("slot")] = it + + if len(by_slot) < len(slots): + return False + + for slot in slots: + base = by_slot.get(slot) + if not base: + continue + try: + inst = build_item_instance(base, rarity) + except Exception: + continue + if equip: + old = player.equip_swap(inst) + if old: + inventory.add(old) + else: + inventory.add(inst) + return True + +def grant_five_tempered() -> dict | None: + pool = [it for it in ARMOR_DB if isinstance(it, dict) and it.get("id")] + if not pool: + return None + base = random.choice(pool) + try: + inst = build_item_instance(base, "tempered") + except Exception: + return None + for _ in range(5): + inventory.add(deepcopy(inst)) + return inst + + +def apply_room(room: Room, set_player_spawn=True): + """Updates room-dependent lists + respawns enemies. Keeps inventory/equipment.""" + global ROOM, KILL_Y, platforms_ground, platforms_floating, ground_platforms, platforms + global enemies, npcs, loot_drops, shockwaves, coin_drops, projectiles, ability_doors + global shatter_platforms, shatter_aoes, tether_shots + global light_sources + global dust_puffs + global dust_puffs + global reward_chest, reward_ability + global ROOM_DROP_MULT, ROOM_COIN_MULT + + + ROOM = room + KILL_Y = ROOM.kill_y + platforms_ground = ROOM.platforms_ground + platforms_floating = ROOM.platforms_floating + ground_platforms = ROOM.ground_platforms + platforms = ROOM.platforms + + loot_drops = [] + shockwaves = [] + coin_drops = [] + projectiles = [] + shatter_platforms = [] + shatter_aoes = [] + tether_shots = [] + light_sources = [] + dust_puffs = [] + dust_puffs = [] + ROOM_DROP_MULT = 1.0 + ROOM_COIN_MULT = 1.0 + enemies = spawn_enemies_from_room(ROOM) + npcs = spawn_npcs_from_room(ROOM) + ability_doors = [AbilityDoor(pygame.Rect(*map(int, d.get("rect", [0, 0, 0, 0])))) + for d in ROOM.ability_doors if isinstance(d, dict)] + + reward_chest = None + reward_ability = None + reward_chest, reward_ability, light_sources = spawn_room_props(ROOM, player) + + if set_player_spawn: + player.start_x = int(ROOM.player_spawn[0]) + player.start_y = int(ROOM.player_spawn[1]) + player.rect.midbottom = (player.start_x, player.start_y) + player.vel_y = 0.0 + player.on_ground = False + +def boss_alive_in_room() -> bool: + return any(getattr(en, "type_id", "") == "boss" and en.health > 0 for en in enemies) + +def try_room_transition(keys): + if room_manager.transition_cooldown > 0: + return False + if inv_ui.visible or trade_ui.visible: + return False + + for ex in ROOM.exit_triggers: + # Gate exits (e.g., boss doors) + if ex.get("requires_boss_dead", False) and boss_alive_in_room(): + continue + if ex.get("requires_ranged", False) and not player.has_ranged: + continue + + if player.rect.colliderect(ex["rect"]): + target = ex.get("to", None) + spawn = ex.get("spawn", None) + spawn_id = ex.get("spawn_id", None) + + new_room = room_manager.load_room(target) + forced_spawn = None + if spawn_id: + if spawn_id == "__player_spawn__": + forced_spawn = new_room.player_spawn + else: + pos = new_room.entry_spawns.get(spawn_id) + if isinstance(pos, (list, tuple)) and len(pos) == 2: + forced_spawn = [int(pos[0]), int(pos[1])] + else: + print(f"[Room Transition] spawn_id '{spawn_id}' not found in {new_room.id}. Available: {list(new_room.entry_spawns.keys())}") + if forced_spawn is not None: + print(f"[Room Transition] {ROOM.id} -> {new_room.id} using spawn_id '{spawn_id}' at {forced_spawn}") + if forced_spawn is None and isinstance(spawn, (list, tuple)) and len(spawn) == 2: + forced_spawn = [int(spawn[0]), int(spawn[1])] + if forced_spawn is None: + forced_spawn = [60, 200] + + new_room.player_spawn = forced_spawn + apply_room(new_room, set_player_spawn=True) + return True + + return False + + +camera_x = camera_y = 0.0 + +# Track bosses defeated per room id (runtime only) +DEFEATED_BOSSES: set[str] = set() + +# Reality Break: per-room drop multipliers (reset on room change) +ROOM_DROP_MULT = 1.0 +ROOM_COIN_MULT = 1.0 + +def is_boss_defeated(room_id: str) -> bool: + return room_id in DEFEATED_BOSSES + +def mark_boss_defeated(room_id: str): + if room_id: + DEFEATED_BOSSES.add(room_id) + + + +# ---------------------------- +# Main loop +# ---------------------------- +running = True +paused = False +pause_menu = "main" # main | settings +pause_menu_index = 0 +settings_index = 0 +settings_wait_action: str | None = None +pause_use_mouse = False +while running: + dt = clock.tick(60) / 1000.0 + if not paused: + pickup_feed.update(dt) + + + for e in pygame.event.get(): + if e.type == pygame.QUIT: + running = False + if e.type == pygame.MOUSEMOTION: + if paused and pause_menu == "main": + hover_idx = None + for idx, r in enumerate(get_pause_menu_rects()): + if r.collidepoint(e.pos): + hover_idx = idx + break + if hover_idx is not None: + pause_use_mouse = True + pause_menu_index = hover_idx + else: + pause_use_mouse = False + if e.type == pygame.MOUSEBUTTONDOWN and e.button == 1: + if paused and pause_menu == "main": + for idx, r in enumerate(get_pause_menu_rects()): + if r.collidepoint(e.pos): + pause_use_mouse = True + pause_menu_index = idx + if idx == 0: + paused = False + elif idx == 1: + pause_menu = "settings" + elif idx == 2: + running = False + break + + if e.type == pygame.KEYDOWN: + was_trade_visible = trade_ui.visible + was_inv_visible = inv_ui.visible + + if paused and pause_menu == "settings": + if settings_wait_action: + BINDINGS[settings_wait_action] = e.key + settings_wait_action = None + else: + if key_is("ui_up", e.key): + settings_index = (settings_index - 1) % len(SETTINGS_ACTIONS) + elif key_is("ui_down", e.key): + settings_index = (settings_index + 1) % len(SETTINGS_ACTIONS) + elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): + settings_wait_action = SETTINGS_ACTIONS[settings_index][1] + elif key_is("ui_cancel", e.key): + pause_menu = "main" + continue + + if paused and pause_menu == "main": + if key_is("ui_up", e.key): + pause_menu_index = (pause_menu_index - 1) % 3 + pause_use_mouse = False + elif key_is("ui_down", e.key): + pause_menu_index = (pause_menu_index + 1) % 3 + pause_use_mouse = False + elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): + if pause_menu_index == 0: + paused = False + elif pause_menu_index == 1: + pause_menu = "settings" + elif pause_menu_index == 2: + running = False + elif key_is("pause", e.key): + paused = False + continue + + if key_is("ui_cancel", e.key) and (was_inv_visible or was_trade_visible): + inv_ui.visible = False + trade_ui.visible = False + trade_ui.active_npc = None + continue + if key_is("inventory", e.key) and not trade_ui.visible: + inv_ui.toggle() + + inv_ui.handle_keydown(e.key) + trade_ui.handle_keydown(e.key) + + # Debug gear: only in room_01 + if ROOM.id == "room_01.json": + if key_is("debug_1", e.key): + if grant_full_set("dawnrunner", "attuned", equip=True): + pickup_feed.push_center("Debug: Equipped Dawnrunner (attuned)", duration=3.0) + if key_is("debug_2", e.key): + if grant_full_set("dawnrunner", "awakened", equip=True): + pickup_feed.push_center("Debug: Equipped Dawnrunner (awakened)", duration=3.0) + if key_is("debug_3", e.key): + inst = grant_five_tempered() + if inst: + pickup_feed.push_center(f"Debug: +5 {inst.get('name', 'Item')} (tempered)", duration=3.0) + + if key_is("interact", e.key) and not inv_ui.visible and not was_trade_visible: + near_npc = get_near_npc(player, npcs) + if near_npc and not player.shade: + trade_ui.toggle(near_npc) + + if key_is("pause", e.key): + if paused: + paused = False + else: + if not inv_ui.visible and not trade_ui.visible: + paused = True + pause_menu = "main" + pause_menu_index = 0 + settings_wait_action = None + pause_use_mouse = False + + if (not paused) and (not inv_ui.visible) and (not trade_ui.visible): + if key_is("toggle_shade", e.key): + player.toggle_shade() + + if key_is("attack", e.key): + player.attack() + + if key_is("ranged", e.key): + player.try_ranged_attack(projectiles, enemies, platforms) + + if key_is("dash", e.key): + player.try_dash() + + if key_is("shatterstep", e.key): + player.try_shatterstep() + + if key_is("tether", e.key): + player.try_tether(tether_shots) + + if key_is("reality_break", e.key) and player.has_reality_break: + if player.shade: + ROOM_DROP_MULT = 0.5 + ROOM_COIN_MULT = 0.5 + for en in enemies: + en.health = 0 + else: + ROOM_DROP_MULT = 2.0 + ROOM_COIN_MULT = 2.0 + for en in enemies: + en.reality_break_glow = True + player.reality_break_flash = REALITY_BREAK_FLASH_DURATION + + + keys = pygame.key.get_pressed() + if paused: + # draw last frame + pause overlay + screen.fill(BG) + + # NPC backdrop houses (behind platforms) + for npc in npcs: + if npc.type == "vendor": + draw_npc_house(screen, npc, camera_x, camera_y) + + for p in platforms: + pygame.draw.rect( + screen, PLATFORM_COLOR, + pygame.Rect(p.x - camera_x, p.y - camera_y, p.width, p.height) + ) + for sp in shatter_platforms: + sp.draw(screen, camera_x, camera_y) + for aoe in shatter_aoes: + aoe.draw(screen, camera_x, camera_y) + for dp in dust_puffs: + dp.draw(screen, camera_x, camera_y) + + for ex in ROOM.exit_triggers: + if ex.get("requires_boss_dead", False) and boss_alive_in_room(): + continue + if ex.get("requires_ranged", False) and not player.has_ranged: + continue + + rr = ex["rect"] + pygame.draw.rect( + screen, + (140, 220, 140), + pygame.Rect(rr.x - camera_x, rr.y - camera_y, rr.width, rr.height), + 2, + ) + + for drop in loot_drops: + drop.draw(screen, camera_x, camera_y) + for c in coin_drops: + c.draw(screen, camera_x, camera_y) + for sw in shockwaves: + sw.draw(screen, camera_x, camera_y) + for pr in projectiles: + pr.draw(screen, camera_x, camera_y) + for ts in tether_shots: + ts.draw(screen, camera_x, camera_y) + for ls in light_sources: + ls.draw(screen, camera_x, camera_y) + + player.draw(screen, camera_x, camera_y) + for npc in npcs: + npc.draw(screen, camera_x, camera_y) + for en in enemies: + en.draw(screen, camera_x, camera_y) + + if reward_chest: + reward_chest.draw(screen, camera_x, camera_y) + if reward_ability: + reward_ability.draw(screen, camera_x, camera_y) + + if VIGNETTE_LIGHT and VIGNETTE_SHADE: + if player.shade: + screen.blit(VIGNETTE_SHADE, (0, 0)) + screen.blit(VIGNETTE_SHADE_EDGE, (0, 0)) + else: + screen.blit(VIGNETTE_LIGHT, (0, 0)) + screen.blit(VIGNETTE_LIGHT_EDGE, (0, 0)) + + # HUD + draw_shatterstep_icon(screen, player) + draw_tether_icon(screen, player) + draw_reality_break_icon(screen, player) + pygame.draw.rect(screen, HEALTH_BG, (WIDTH - 220, 20, 200, 20)) + hp_ratio = (player.health / player.max_health) if player.max_health > 0 else 0 + pygame.draw.rect(screen, HEALTH_FILL, (WIDTH - 220, 20, int(200 * hp_ratio), 20)) + + pygame.draw.rect(screen, CHARGE_BG, (WIDTH - 220, 45, 200, 20)) + ch_ratio = (player.charge / player.max_charge) if player.max_charge > 0 else 0 + pygame.draw.rect(screen, CHARGE_FILL, (WIDTH - 220, 45, int(200 * ch_ratio), 20)) + + coin_y = int(_ui(["coins_hud", "y"], 72)) + coin_x = WIDTH - int(_ui(["coins_hud", "x_start_offset"], 220)) + if COIN_LIGHT_RAW: + icon = pygame.transform.scale(COIN_LIGHT_RAW, (COIN_UI_SIZE, COIN_UI_SIZE)) + screen.blit(icon, (coin_x, coin_y)) + screen.blit( + font.render(str(player.coins_light), True, (240, 240, 240)), + (coin_x + COIN_UI_SIZE + COIN_UI_GAP, coin_y - 1) + ) + coin_x += int(_ui(["coins_hud", "group_spacing"], 90)) + if COIN_DARK_RAW: + icon = pygame.transform.scale(COIN_DARK_RAW, (COIN_UI_SIZE, COIN_UI_SIZE)) + screen.blit(icon, (coin_x, coin_y)) + screen.blit( + font.render(str(player.coins_dark), True, (240, 240, 240)), + (coin_x + COIN_UI_SIZE + COIN_UI_GAP, coin_y - 1) + ) + + overlay = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 150)) + screen.blit(overlay, (0, 0)) + pause_text = font.render("Paused", True, (240, 240, 240)) + screen.blit(pause_text, (WIDTH // 2 - pause_text.get_width() // 2, HEIGHT // 2 - 10)) + if pause_menu == "main": + draw_pause_menu(screen, pause_menu_index, pause_use_mouse) + draw_pause_controls(screen, 20, HEIGHT // 2 + 90) + else: + draw_settings_menu(screen, settings_index, settings_wait_action) + pygame.display.flip() + continue + + # Close inventory on movement input, then allow movement + if inv_ui.visible: + move_input = ( + key_pressed(keys, "move_left") + or key_pressed(keys, "move_right") + or key_pressed(keys, "jump") + or key_pressed(keys, "move_down") + ) + if move_input: + inv_ui.toggle() + keys = pygame.key.get_pressed() + else: + # zero out input by simulating no keys pressed + keys = [0] * len(keys) + # light source regen boost + player.charge_regen_mult = 1.0 + for ls in light_sources: + px, py = player.rect.midbottom + dx = px - ls.x + dy = py - ls.y + if (dx * dx + dy * dy) <= (ls.radius * ls.radius): + player.charge_regen_mult = 2.0 + break + collision_platforms = platforms + [d.rect for d in ability_doors if not d.open] + player_collision_platforms = collision_platforms + [p.rect for p in shatter_platforms if p.alive] + player.update(keys, player_collision_platforms, dt) + + if player.is_shatterstep_active() and (not player.shade) and (not player.on_ground): + if (not player.shatterstep_platform_spawned) and player.shatterstep_platform_cd <= 0.0: + jump_v0 = abs(JUMP_LIGHT) + gravity = GRAVITY_LIGHT + jump_h = (jump_v0 * jump_v0) / (2.0 * gravity) if gravity > 0 else 0.0 + target_y = player.shatterstep_jump_start_y - (0.8 * jump_h) + if float(player.rect.bottom) <= target_y: + w, h = SHATTER_PLATFORM_SIZE + px = int(player.rect.centerx - w // 2) + py = int(player.rect.bottom + 2) + shatter_platforms.append(ShatterPlatform(px, py, w, h, SHATTER_PLATFORM_LIFETIME)) + player.shatterstep_platform_cd = SHATTER_PLATFORM_COOLDOWN + player.shatterstep_platform_spawned = True + + if player.just_landed and player.shatterstep_landing_pending: + w, h = SHATTER_AOE_SIZE + ax = int(player.rect.centerx - w // 2) + ay = int(player.rect.bottom - h) + aoe_rect = pygame.Rect(ax, ay, int(w), int(h)) + for en in enemies: + if en.health > 0 and aoe_rect.colliderect(en.rect): + en.take_damage(player.shatterstep_landing_damage) + shatter_aoes.append(ShatterAOE(aoe_rect, SHATTER_AOE_LIFETIME)) + base_y = int(player.rect.bottom) + for _ in range(9): + off_x = random.uniform(-w * 0.55, w * 0.55) + off_y = random.uniform(-8, 6) + scale = random.uniform(0.7, 1.4) + dust_puffs.append(DustPuff(int(player.rect.centerx + off_x), base_y + off_y, lifetime=0.45, scale=scale)) + player.shatterstep_landing_pending = False + player.shatterstep_landing_damage = 0.0 + + if trade_ui.visible and (player.shade or not get_near_npc(player, npcs)): + trade_ui.visible = False + trade_ui.active_npc = None + + room_manager.update(dt) + if try_room_transition(keys): + camera_x = float(player.rect.centerx - WIDTH // 2) + camera_y = float(player.rect.centery - HEIGHT // 2) + continue + + # Respawn on death/void + if player.rect.top > KILL_Y or player.health <= 0: + player.respawn(int(ROOM.player_spawn[0]), int(ROOM.player_spawn[1])) + enemies = spawn_enemies_from_room(ROOM) + npcs = spawn_npcs_from_room(ROOM) + loot_drops = [] + shockwaves = [] + coin_drops = [] + projectiles = [] + shatter_platforms = [] + shatter_aoes = [] + dust_puffs = [] + tether_shots = [] + player.shatterstep_landing_pending = False + player.shatterstep_landing_damage = 0.0 + ability_doors = [AbilityDoor(pygame.Rect(*map(int, d.get("rect", [0, 0, 0, 0])))) + for d in ROOM.ability_doors if isinstance(d, dict)] + + # Update enemies + for en in enemies: + en.update(dt, collision_platforms, ground_platforms, player, shockwaves) + + # Enemy enemies do NOT physically collide. + # (Prevents enemies from shoving each other into platforms / off ledges.) + # resolve_enemy_collisions(enemies) + + atk_boxes = player.get_attack_hitboxes() + + if atk_boxes and not player.damage_applied: + dmg = 2 if player.shade else 1 + for en in enemies: + if any(box.colliderect(en.rect) for box in atk_boxes): + en.take_damage(dmg) + player.damage_applied = True + + # Dash interactions + if player.is_dashing(): + for en in enemies: + if en.health <= 0: + continue + if player.rect.colliderect(en.rect): + eid = id(en) + if eid in player.dash_hit_ids: + continue + player.dash_hit_ids.add(eid) + if player.shade: + dmg = 4 # double shade melee damage (2 -> 4) + en.take_damage(dmg) + else: + # brief invuln on hit + stun + player.invincibility_timer = max(player.invincibility_timer, DASH_LIGHT_INVULN) + if getattr(en, "type_id", "") == "boss": + en.apply_stun(DASH_STUN_BOSS) + elif getattr(en, "type_id", "") == "miniboss": + en.apply_stun(DASH_STUN_MINIBOSS) + else: + en.apply_stun(DASH_STUN_NORMAL) + + + # Enemy contact damage (only when player is Light) + for en in enemies: + if (not player.shade) and player.rect.colliderect(en.rect): + did_hit = player.take_damage(en.contact_damage) + if did_hit: + player.apply_knockback(en.rect, strength_px_per_frame=12.0, duration=0.20, pop_up_vel=-3.5) + + + # Update projectiles + for pr in projectiles: + pr.update(dt, collision_platforms, enemies, ability_doors) + projectiles = [pr for pr in projectiles if pr.alive] + + # Update tether shots + for ts in tether_shots: + ts.update(dt, collision_platforms, enemies) + tether_shots = [ts for ts in tether_shots if ts.alive] + + + # Update shockwaves + for sw in shockwaves: + sw.update(dt, collision_platforms) + shockwaves = [sw for sw in shockwaves if sw.alive] + + # Update shatter effects + for sp in shatter_platforms: + sp.update(dt) + shatter_platforms = [sp for sp in shatter_platforms if sp.alive] + for aoe in shatter_aoes: + aoe.update(dt) + shatter_aoes = [aoe for aoe in shatter_aoes if aoe.alive] + for dp in dust_puffs: + dp.update(dt) + dust_puffs = [dp for dp in dust_puffs if dp.alive] + + # Shockwave hits player (only when player is Light) + for sw in shockwaves: + if (not player.shade) and player.rect.colliderect(sw.rect): + did_hit = player.take_damage(sw.damage) + if did_hit: + player.apply_knockback(sw.rect, strength_px_per_frame=14.0, duration=0.22, pop_up_vel=-4.5) + sw.t = 0 # one-hit + + + + # Update loot drops (gravity + landing) + for drop in loot_drops: + drop.update(dt, collision_platforms) + + for c in coin_drops: + c.update(dt, collision_platforms) + + if reward_ability: + reward_ability.update(dt) + + + # Handle enemy death -> spawn drops + alive = [] + for en in enemies: + if en.health > 0: + alive.append(en) + else: + loot_drops.extend(spawn_drops_from_enemy(en)) + if getattr(en, "type_id", "") == "boss": + mark_boss_defeated(ROOM.id) + + # --- coin drops --- + base_x = en.rect.centerx - 8 + base_y = en.rect.top - 20 + + if getattr(en, "type_id", "") == "enemy": + spawn_coins("light", roll_normal_light(), base_x, base_y) + if player.shade: + spawn_coins("dark", roll_normal_dark(), base_x, base_y) + elif getattr(en, "type_id", "") == "miniboss": + spawn_coins("light", roll_miniboss_light(), base_x, base_y) + if player.shade: + spawn_coins("dark", roll_miniboss_dark(), base_x, base_y) + elif getattr(en, "type_id", "") == "boss": + boss_light = roll_boss_light() + spawn_coins("light", boss_light, base_x, base_y) + if player.shade: + spawn_coins("dark", boss_light, base_x, base_y) + else: + # fallback for non-normal enemies (kept as previous behavior) + for _ in range(random.randint(1, 3)): + kind = "light" if random.random() < 0.5 else "dark" + coin_drops.append(CoinPickup(en.rect.centerx - 8, en.rect.top - 20, kind)) + + enemies = alive + + # Pickup loot (only when inventory closed) + if not inv_ui.visible and not trade_ui.visible: + if reward_chest and player.rect.colliderect(reward_chest.rect): + reward_chest.open(player) + player.reward_chest_opened = True + + if reward_ability and player.rect.colliderect(reward_ability.rect): + if reward_ability.ability_id == "ranged": + player.has_ranged = True + pickup_feed.push_center("Ranged Ability aquired (Press Q)", duration=5.0) + else: + pickup_feed.push_center(f"Ability acquired: {reward_ability.ability_id}", duration=5.0) + reward_ability = None + + for drop in loot_drops[:]: + if player.rect.colliderect(drop.rect): + inventory.add(drop.item) + + name = drop.item.get("name", drop.item.get("id", "Item")) + rarity = drop.item.get("rarity", "") + pickup_feed.push(name, rarity) + + loot_drops.remove(drop) + for c in coin_drops[:]: + if player.rect.colliderect(c.rect): + if c.kind == "light": + player.coins_light += 1 + else: + player.coins_dark += 1 + coin_drops.remove(c) + + + + # Camera follow + camera_x += ((player.rect.centerx - WIDTH // 2) - camera_x) * 0.1 + camera_y += ((player.rect.centery - HEIGHT // 2) - camera_y) * 0.1 + + # ---------------------------- + # Draw + # ---------------------------- + screen.fill(BG) + + # NPC backdrop houses (behind platforms) + for npc in npcs: + if npc.type == "vendor": + draw_npc_house(screen, npc, camera_x, camera_y) + + for ls in light_sources: + ls.draw(screen, camera_x, camera_y) + + for p in platforms: + pygame.draw.rect( + screen, PLATFORM_COLOR, + pygame.Rect(p.x - camera_x, p.y - camera_y, p.width, p.height) + ) + for sp in shatter_platforms: + sp.draw(screen, camera_x, camera_y) + for aoe in shatter_aoes: + aoe.draw(screen, camera_x, camera_y) + for dp in dust_puffs: + dp.draw(screen, camera_x, camera_y) + + # ability doors (ranged-locked) + for d in ability_doors: + d.draw(screen, camera_x, camera_y) + + # exits / doorways + for ex in ROOM.exit_triggers: + # hide locked exits + if ex.get("requires_boss_dead", False) and boss_alive_in_room(): + continue + if ex.get("requires_ranged", False) and not player.has_ranged: + continue + + rr = ex["rect"] + pygame.draw.rect( + screen, + (140, 220, 140), + pygame.Rect(rr.x - camera_x, rr.y - camera_y, rr.width, rr.height), + 2, + ) + + # loot + for drop in loot_drops: + drop.draw(screen, camera_x, camera_y) + + # coins + for c in coin_drops: + c.draw(screen, camera_x, camera_y) + + # reward room props + if reward_chest: + reward_chest.draw(screen, camera_x, camera_y) + if reward_ability: + reward_ability.draw(screen, camera_x, camera_y) + + + # shockwaves + for sw in shockwaves: + sw.draw(screen, camera_x, camera_y) + + # projectiles + for pr in projectiles: + pr.draw(screen, camera_x, camera_y) + for ts in tether_shots: + ts.draw(screen, camera_x, camera_y) + + + # entities + for npc in npcs: + npc.draw(screen, camera_x, camera_y) + player.draw(screen, camera_x, camera_y) + for en in enemies: + en.draw(screen, camera_x, camera_y) + + # NPC greeting bubble (shown near the player) + greet_npc = get_near_npc(player, npcs) + if greet_npc: + if player.shade: + shade_text = greet_npc.greet_shade or "Stay away from me, evil!" + draw_speech_bubble( + screen, + shade_text, + int(greet_npc.rect.centerx - camera_x), + int(greet_npc.rect.top - camera_y), + ) + else: + light_text = greet_npc.greet_light or "Greetings, little light!" + hint_text = greet_npc.greet_hint or "(Press F to interact)" + draw_speech_bubble( + screen, + light_text, + int(greet_npc.rect.centerx - camera_x), + int(greet_npc.rect.top - camera_y), + hint_text + ) + + # Boss health bar (if boss exists) + boss = next((en for en in enemies if getattr(en, "type_id", "") == "boss"), None) + if boss is not None: + bar_w = 360 + bar_h = 18 + x = WIDTH // 2 - bar_w // 2 + y = 10 + + pygame.draw.rect(screen, (25, 25, 25), (x, y, bar_w, bar_h)) + pygame.draw.rect(screen, (120, 120, 120), (x, y, bar_w, bar_h), 2) + + ratio = (boss.health / boss.max_health) if boss.max_health > 0 else 0 + pygame.draw.rect(screen, BOSS_COLOR, (x, y, int(bar_w * ratio), bar_h)) + + txt = font.render(f"{boss.health}/{boss.max_health}", True, (240, 240, 240)) + screen.blit(txt, (x + bar_w + 10, y - 1)) + + if VIGNETTE_LIGHT and VIGNETTE_SHADE: + if player.shade: + screen.blit(VIGNETTE_SHADE, (0, 0)) + screen.blit(VIGNETTE_SHADE_EDGE, (0, 0)) + else: + screen.blit(VIGNETTE_LIGHT, (0, 0)) + screen.blit(VIGNETTE_LIGHT_EDGE, (0, 0)) + + draw_shatterstep_icon(screen, player) + draw_tether_icon(screen, player) + draw_reality_break_icon(screen, player) + + # Player health + pygame.draw.rect(screen, HEALTH_BG, (WIDTH - 220, 20, 200, 20)) + hp_ratio = (player.health / player.max_health) if player.max_health > 0 else 0 + pygame.draw.rect(screen, HEALTH_FILL, (WIDTH - 220, 20, int(200 * hp_ratio), 20)) + + # Player charge + pygame.draw.rect(screen, CHARGE_BG, (WIDTH - 220, 45, 200, 20)) + ch_ratio = (player.charge / player.max_charge) if player.max_charge > 0 else 0 + pygame.draw.rect(screen, CHARGE_FILL, (WIDTH - 220, 45, int(200 * ch_ratio), 20)) + + # --- Coins HUD (icons + counts) --- + coin_y = int(_ui(["coins_hud", "y"], 72)) + coin_x = WIDTH - int(_ui(["coins_hud", "x_start_offset"], 220)) + + if COIN_LIGHT_RAW: + icon = pygame.transform.scale(COIN_LIGHT_RAW, (COIN_UI_SIZE, COIN_UI_SIZE)) + screen.blit(icon, (coin_x, coin_y)) + screen.blit( + font.render(str(player.coins_light), True, (240, 240, 240)), + (coin_x + COIN_UI_SIZE + COIN_UI_GAP, coin_y - 1) + ) + + # spacing between light + dark + coin_x += int(_ui(["coins_hud", "group_spacing"], 90)) + + if COIN_DARK_RAW: + icon = pygame.transform.scale(COIN_DARK_RAW, (COIN_UI_SIZE, COIN_UI_SIZE)) + screen.blit(icon, (coin_x, coin_y)) + screen.blit( + font.render(str(player.coins_dark), True, (240, 240, 240)), + (coin_x + COIN_UI_SIZE + COIN_UI_GAP, coin_y - 1) + ) + + + + inv_ui.draw(screen) + trade_ui.draw(screen) + pickup_feed.draw(screen) + + + pygame.display.flip() + +pygame.quit()