From 103bc235a74ae9c67261db32d1ae2456cf6263be Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Tue, 18 Aug 2026 19:10:44 -0600 Subject: [PATCH] Added Wolfenstein --- assets/gamedata/wolfenstein/level-e1m1.json | 193 ++++++ assets/gamedata/wolfenstein/level-e1m2.json | 235 +++++++ assets/gamedata/wolfenstein/level-e2m1.json | 173 ++++++ assets/images/game-icons.png | Bin 357354 -> 361876 bytes assets/images/game-icons.psd | Bin 920168 -> 939890 bytes data/wolfenstein-artwork.json | 15 + data/wolfenstein-campaigns.json | 25 + data/wolfenstein-rules.json | 26 + src/data/assetManifest.js | 10 + src/data/gamesRegistry.js | 1 + src/games/wolfenstein/WolfensteinArt.js | 66 ++ src/games/wolfenstein/WolfensteinEditor.js | 378 ++++++++++++ src/games/wolfenstein/WolfensteinGame.js | 396 ++++++++++++ src/games/wolfenstein/WolfensteinLogic.js | 571 ++++++++++++++++++ src/games/wolfenstein/WolfensteinRaycaster.js | 96 +++ src/games/wolfenstein/WolfensteinRules.js | 16 + src/games/wolfenstein/WolfensteinScreens.js | 151 +++++ src/games/wolfenstein/WolfensteinView.js | 142 +++++ src/games/wolfenstein/sprites.md | 41 ++ src/main.js | 4 + src/scenes/GameRoomScene.js | 2 +- src/scenes/PreloadScene.js | 8 + tools/genWolfenstein.js | 77 +++ tools/verifyWolfenstein.js | 293 +++++++++ tools/wolfensteinMap.js | 73 +++ 25 files changed, 2991 insertions(+), 1 deletion(-) create mode 100644 assets/gamedata/wolfenstein/level-e1m1.json create mode 100644 assets/gamedata/wolfenstein/level-e1m2.json create mode 100644 assets/gamedata/wolfenstein/level-e2m1.json create mode 100644 data/wolfenstein-artwork.json create mode 100644 data/wolfenstein-campaigns.json create mode 100644 data/wolfenstein-rules.json create mode 100644 src/games/wolfenstein/WolfensteinArt.js create mode 100644 src/games/wolfenstein/WolfensteinEditor.js create mode 100644 src/games/wolfenstein/WolfensteinGame.js create mode 100644 src/games/wolfenstein/WolfensteinLogic.js create mode 100644 src/games/wolfenstein/WolfensteinRaycaster.js create mode 100644 src/games/wolfenstein/WolfensteinRules.js create mode 100644 src/games/wolfenstein/WolfensteinScreens.js create mode 100644 src/games/wolfenstein/WolfensteinView.js create mode 100644 src/games/wolfenstein/sprites.md create mode 100644 tools/genWolfenstein.js create mode 100644 tools/verifyWolfenstein.js create mode 100644 tools/wolfensteinMap.js diff --git a/assets/gamedata/wolfenstein/level-e1m1.json b/assets/gamedata/wolfenstein/level-e1m1.json new file mode 100644 index 0000000..c67ef62 --- /dev/null +++ b/assets/gamedata/wolfenstein/level-e1m1.json @@ -0,0 +1,193 @@ +{ + "version": 1, + "id": "e1m1", + "name": "First Blood", + "campaignId": "episode1", + "missionIndex": 0, + "width": 12, + "height": 10, + "cellSize": 64, + "walls": [ + [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + [ + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 1, + 1, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 1, + 1, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + ], + "playerStart": { + "x": 1.5, + "y": 1.5, + "angle": 0 + }, + "doors": [ + { + "x": 4, + "y": 3, + "orientation": "vertical" + } + ], + "enemies": [ + { + "type": "guard", + "x": 8.5, + "y": 1.5, + "facing": 180 + } + ], + "items": [ + { + "type": "ammo", + "x": 3.5, + "y": 7.5 + }, + { + "type": "health", + "x": 9.5, + "y": 7.5 + } + ], + "exit": { + "x": 10.5, + "y": 1.5, + "radius": 0.6 + }, + "briefing": [ + "Clear the outpost.", + "Find the exit." + ] +} diff --git a/assets/gamedata/wolfenstein/level-e1m2.json b/assets/gamedata/wolfenstein/level-e1m2.json new file mode 100644 index 0000000..fd81f07 --- /dev/null +++ b/assets/gamedata/wolfenstein/level-e1m2.json @@ -0,0 +1,235 @@ +{ + "version": 1, + "id": "e1m2", + "name": "The Armory", + "campaignId": "episode1", + "missionIndex": 1, + "width": 14, + "height": 11, + "cellSize": 64, + "walls": [ + [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 1, + 1, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 1, + 1, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + ], + "playerStart": { + "x": 1.5, + "y": 1.5, + "angle": 0 + }, + "doors": [ + { + "x": 5, + "y": 3, + "orientation": "vertical" + } + ], + "enemies": [ + { + "type": "guard", + "x": 10.5, + "y": 1.5, + "facing": 180 + }, + { + "type": "guard", + "x": 9.5, + "y": 8.5, + "facing": 180 + } + ], + "items": [ + { + "type": "ammo", + "x": 3.5, + "y": 6.5 + }, + { + "type": "health", + "x": 11.5, + "y": 9.5 + } + ], + "exit": { + "x": 12.5, + "y": 9.5, + "radius": 0.6 + }, + "briefing": [ + "Tougher resistance ahead.", + "The armory door only opens from this side." + ] +} diff --git a/assets/gamedata/wolfenstein/level-e2m1.json b/assets/gamedata/wolfenstein/level-e2m1.json new file mode 100644 index 0000000..5a0bbd7 --- /dev/null +++ b/assets/gamedata/wolfenstein/level-e2m1.json @@ -0,0 +1,173 @@ +{ + "version": 1, + "id": "e2m1", + "name": "Descent", + "campaignId": "episode2", + "missionIndex": 0, + "width": 10, + "height": 10, + "cellSize": 64, + "walls": [ + [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + [ + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + ], + "playerStart": { + "x": 1.5, + "y": 1.5, + "angle": 0 + }, + "doors": [ + { + "x": 4, + "y": 3, + "orientation": "vertical" + } + ], + "enemies": [ + { + "type": "guard", + "x": 3.5, + "y": 5.5, + "facing": 180 + } + ], + "items": [ + { + "type": "ammo", + "x": 6.5, + "y": 5.5 + }, + { + "type": "health", + "x": 5.5, + "y": 7.5 + } + ], + "exit": { + "x": 8.5, + "y": 1.5, + "radius": 0.6 + }, + "briefing": [ + "Underground now.", + "Watch corners." + ] +} diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png index 0f3435a39c4bdccf5239f7b4f85b37f6c03a3640..fa837430f3eb3e38c314554bc98573adef77a974 100644 GIT binary patch delta 36057 zcmV(yK&Cj3b64j$IR}Pe z$RLP_h=N&BOkltV229{X2@e4i1`za#B8nn8=RBmDfyvWpI;ZNYt{iXt_qjELNN@%i zeZ2q8p0B>{>8`Fib?-TQuYJzmYYknwX#Ro^W~TB#s&J*x&YLsmRcmc>GLDt=*;?W- zk_ccsfwO2~)OQ7&elI?>n z_$j#^ok_fSzaV9!#qyCJp0~>&+)Uz-*X_DkC#`?it~lUkwT5&(DK&y)d<-*;Z<)=V ziiRG>wM&U`rI^OC0T+Hqab&T}iDvR8gOZFBD5MzW9HGT9-d9e-N6G36x(_=D&~?(+ zUh;pT7ZH+K+SYX6t#{l~!o}~hL5Ka1>3CZ;QIXndwPn}cagt(7Eo)VQ{2JizT3l1c zp#*}>;nd_PCI-7v&ZQ9c#?i8*1*M*=Q0P7oM#sA;xv79PSUTN4N=ykS5NOP$LWE(b zDEW#3YSLE1;TS&PYRzVx%{-#ij>WCr3ub@sb|_Cd?)Vc7-HJ?1k0O^%Q!*69#%u}! zrZVLe(5G89>bYXx-i-3lxZDIHixfK)vxr09@o}`b`s{Kqlk<|VCg5MlkWWk_(l7@R zw}XJaOi6gilATg~cx?1DU;O%+4`y@O*9_2y9rJs(@Q+oqqo9-&+h zxZ(h;e1K3YO>WABU7^|VxjhIH04-9+S(q6^dSESLi%*0lIw5$XHUek$ggPtYbOtCx zwxH&d$WL`MNj%7O0~{3Gx>~|;jXncuW`Z)*P8u2o?0By|IbB_cBiONj!qy=zB!+DY z3&VJA5rqDp-i=bhy2biqkw*;PlduqELzo9SG{=UeNL?C|vMuBq6L<%AWbPXP4C}Bu zlqo2s`+TSr#J$SUOiMpqki^^QxeksN4r2qaQl{u|b5J~zl}hPVH^mvAIk8lOnC5@}{k|OV@Rbf-F<6nYin&9(IH{_<<7^z0=Uui+RgD zM$nBGo|o_c)XDRY`@=0e-ZZGxDea9=W;Kv2K`Z5L${N1DXZ##%d*_8>6$n(of5Zq_wy6U zysaw2)TP+5T7-gAI#1Vhbd0VuW%9I693EQdG9#`o+sWC|MRRXSrVGD+v-fjvtfA4n z$m8{M6-IY|<_uYemejXy?R)=c&p40Kc>TkX?Yea@s*ynWg>*Ilq9__m^=ff@ci-TD z4h)SAl`9pEf(EZMLHJ!=;i4dL^A;?U4>|0(lgL4w#TlAkhUqtr65+jyX%PAxo{Zl# zG%|k0wrz~%7`%g~jz*cMNf%GAFwP|2u6!e`X4krZ?<(}Ow8ZF7;18T|)coM=-eC~< z){rZf?J^q|I~^c!z*)N*;V2?6^3nlp?Q;DJpl0VevJQV){khrBM<@fom*@#8ZYn{tTBuXTk2la*}Zr+d2YKmGAf z#Cc7BOXj;2;lAnNl*}5_JRt>bv+Mp#GVp=^VXP>UpDg8dFl94GG(y4Ejc}E>cV46Z zdbBy((chm#$C3#8$2UV(i?n*kfw}6r%xFPyN8PhLKEF4gNmm6;<34}RsW0CD=SLq6 z_`UDp<2Y(`WbDpYjw|5vbRW1pHm~?X3f+l+9QwD#F)}-YSrkBdO0>1c!=ds4ytgTW z_hL12V^ieYlX!gNcJ%J(MP1ZONgE}<1T3<~LKH;@JapG`(*;!McloI#93dC}@<1O} zx6p8Vy6tiTAKf!|bqWvN_6#CNt;A8oQLI{2k1^*l;CcrU6!VmPbz|H1UbMHwxKy?- z{9zjIb2r`mXBOnnJY>xghvy5`MJ$Pk5*T$mUGbjyMAHl98aE`gyXbk|_SFm8u0Pf3)XCZA)(3}Sj)TZlPsM;e9$Yx zO^ciHJ6e<&rD?vq%^a34e_xKH4_l0#+s6@RddY%;;mK|S^c-RhvuK_xs0+CfX^a_` z#0@?C*rT8N?)l$)MrX$c|HM`u{H}>#UH!998nW+(fx(@JESNtJC6+L--QUVIUF$|1 zvSco;-7KDX_C>=bxhDu4^wc*rJU(Yu%gpHL$meuHT%k}Ls}K87m(c+Q6MrRQs_KTQ zdMH`-qb)3=D=MHpBq8iD>?DO3J%q)Im?ZEylFnm1kz{2Ssg}Cn7Iv1#%6kdu1<*R{ z05~Y)wd`61#!**NayTqMrL~92%+e1ful%&OB0;cOfZTO#=|Bk zE_fRk*!2KxN9MI`?b^rly*svD|E1GDc4Xhk$ni@W58z_SZKzIlQg9F2M~0lq$f2hR zm$?E38-Evjzi2Bslye0XCw3wqA47$Jh$a$9Ga67*XVCMG>U~4AmTPN~T|v#2_I=u39m<+)SBc-D?&rk+64F zg_=Ak$IF#Gqvy8l`NN!=u?uvvx*dw68iUg8|AJto>F-QkGhgbmy6Nt%TMs{S-lrPf z)_)qq)X&n(#ycfJ`1L10xa?~and;LIJ-FiylbcJjq6X{gZP~VG9=ro?-hnL3T3I(~ zB#gF+DYP^=anJ%c#c~(6tdC=0w2YFc#;xWgFrA^#kte58q=4%rK2F zMhXIsL7LGk+9*4w&*M0BHhrEr)aSQ@_r*~4cV7MJv%j9%%Zn&+y4ld()8|qt5r6C3 zN$*w4U~+24UOS!h7o$GnMVZ#q@bEDCfHbYAs?CYU^v4O8(eF&mYHzr6WOV9dmtXmt zH_daoH^xDf6{m;po^E%M4h>FTf9j{d!D@$JmHn?@w?S`fX+3kxmMw*rhDb0mld^_J zC(|nrSmvKQr=!{9RhrqZs?!r~p?~?%Mjj$8JCp-yt+kn@jAhYh&KGiy@u?)?K7(}y z{B7;?8-I|=<|?RdhZt3>#)C{ZYS;X=hrxWg#l0-TU3SJt$1c0;nLikw@I?pB3!x+G z!1Fx`3@1xABMOrdWhz6gK7033HzSA77Ie($C1mmwZi>aMR;PK@*CME019O?o*>-fAX6m9-dE(8KZRJ|NDZU5m-psid!YuD1UOg56P5fmvWS| zIfNn}Xx$G$F(%L(mr*u21R9#4QQTPMsiImJ#tS>g5SY6J3(XX*>ju2%#CPJho9@67 zM;(u~&#k906UBE|b$qJ+1LyS|@TH5cX5((CkG-qEulFW`LI<(aHI5VCB?xkj@7J=~ z9c>kqS{o+pdRy}GWq-pmo$MrrcRIGEtJ=cM=nz^>(>{WBI2`tA4SNjx>ha?6Bo_GS zuW4O-SQK_rB2r4&ugO>j*clV6p`T!43l!f*BquWTvzF%82XDXYmUX*V+^%+RTff_; z&cUwA5W!PpY_V#zbCpCDVzAz3dKumRG)8x z#^VrjZGe&cH8OqeC`CUCGuUZ2FtXE1m3)meP!EoE;4=`(K%<%4)%DHBpka4yB`097 z?D2c_$0x@o;UsM0P#g%bBVaW^30KhG9Lp6o`?RZ?oq^@&D7hGei$ZIxA%>5C z{39q9RESlBK!37C08&IOFbjFpi)h4W`U2h(?^2!_njHDvlTWYR@bfEwwbL++y^3vL zKI81MpZ@fsQ|O-Du(@a8sKv7*&`nxro-Xn}Df=FL^u$B3u&b?iI+^{*e_rv&EuTE~ z6Woe9?XhyPSU*1AyJBHmJ$){>EyJ2G(S1=`Q4&lzjDI}la2d>?Y0Ca4LS zuhpkAo^3-jpc+n_{%bocN68g+)3kQF-R|pUS-i>R4fl&`rR><+M;TMFi1{6jc0J(O z_y`mNR8`rFw6_BVPdog23NCfVrT}8RcB-nv8ET^i*lvR{Qh!A3pa4D>QgoKBRKsKpZAA!n#BCJptY*AS0o=C)AteG=t_PJ;p3y}hN*}&d zB?nm6ZT_{_GS+5aE|*GU!>NaZp5S2vGih&UV;K4IKDb&Ap_k9?0zGu2jU-*rd4Rj7 zjAyEGn3PHGuJ zlZ5o?rnHhH)ml3&kE`u`E9}1nsLA+_rw03%v_{|aldbVz92iJ0I?>y`W=(({YYEs< zG?CF9C_MiROv>yM+naYCBQqJFJemVBAk;~j+%~)On=R5v`#MMt`zq4s85p zdwa_^&m)Mw)7C-akgJhsv`-H;XTYF^`3Mn!A8w@}zP6LwZP zQTR?um7FyS65X!tVmUGEKBVYInT~4rG-jJ13Bzi{D0o}A2~(XhVpnrzHEMPa&?Hm|F zwZLj?YCHTQWx|<+|l*J$lcE?6VQ4d9&9YIHy=FhJP5SWDnNd(n!G9mgb}D z!03eH*)%wpj5Oh!o|zd8P?Ei)q@#xbTEQ^kcKH!)tQ)4w{&)RpwnO%kj(3frrA8@9 zV6K{iB?W5iC+w6W!vLp(%)|~P2G=7NO`&Zb`ARPVqVi5?*(YJYSlg#H^R*@%#>r^ab`6No@>juXsY-0JXxfyr^9uQif&l1K7 zC!Bz$))qYd@HR^Lbp-tm#F_#|x}x1&DVys@)069_dPp`6oO{6qW7aDd|6bZxzk1&E zfcw7?2D<>pINM5L5@w}EfoYir|JKk~7J!Vy$N8o4~`GF>X z`)rqk4+SVdCXyIWW-*v^42iTTg@MU9`nFBs1Ez|_ zUF}F~B251hnrxJ*NMUkX8l2gFm+KD&9e>0|h^}UY175i6XVtc*S@y!}8D47VH^EMm zm?8u6)AYFpp}K;|D9twduQ==AlDu~2^-RwgjiCZhSI6sJXRA`FsP{ba;NK2Ba9Kw$ zpEz@6OBY=E9u!97@V0f@8kKg}nW5PQz*?andcFcRJ%ws|62*KTWwk=c-9pKd>3{t_ zD9KLb%O)ySCk1>CBZK3}Br^n_N|Z%Y#vcq)CfR_-a1cvowVEx|$*)^YD^|MvA(#DRm4ZGL9*2&F?g0f}J=Ee(?lbhBJ zyO^47$gu0}B~V$eRf1)@n*5H7zOedJqobMcRdv>gVjbEQ4K=RE#MmnbaewRmJO1|W z!`qK?am6nPmXuU$Y3~~|UeGJ$0!AigD66LXLRarq{r_r5Dd`glAqOoVtM9{`>&4jE9r1tCTO}koE?s4kwDwcazhjjQJQI!3CWd zx_>Pik6uwz{W6Wf>kVzyMSpi}ee?&%p0Y%ldcfI!Ah%%QT%^@*q!Uqec@kLFCF8l_ zJTzMLLB>N_(NItgxLF~LCD>q|?KEWQd)d+v78M)FHJDY*rR=@MSA`XBMDrX1$i-ps zp)ic1cW`uU?2NDe{Khxb^fWx!o0vCuu0IeCV8WIdGs%fLv0z@8eNGfRJTgLHGEL(s z!uAG&2u2ppYNY4QZZW!M%{t=~UpQxP=ighhUtInB8&@5)^lKiEvt@8_^sF2HdK=qa z+oSWAB#HaTCr(?_(A@kpm(dahDSs?pys$l&FR*#K8^FGy-Pnu1{K?{v?s?&>W2$iL zWL9hNiNK;RAEG`9Pi%>kAF?QZ(p=*RRB}x5%S1X|V^v^0Uu6P_6dN!k>u95SBU=UA zk4TiX$HJXxi!@-MuSz~Pfsq*s(3GgN<7UAZfZ}%94Fe9E%MKSKHRq}oHGluh%dWg^ zuR8J5lh!B9YJv9>0Fm6X(Y|Wdc^oajiLG}zpV}^;=n(^$5y*$T@+f7p1d5%g3x?4Y zT}W`n%llZvh{9-D`Y=4!Ltsg=k8tzZJe;)Vd`*7%JObYubk92esKa~@JpN3%tEEF} zX>9$ps*#&3l<98h_C8dh@PGGO*?aDL%slkq)$h;bDwjB2fz^U!42YEZPp2#I^NU%< z5?ZiieyNoa>2ilpC<{!*U8(^eGn?U>sZLtQ||k?v_U z`zSt4&Olza0t4ii*i_cp!mAIXM#-v-vE! zRNgN-9CE-d?4bL$lP=dLx6`%L@AGXbmGV8Cwr;mCL?V&E(#0#s-A?z;fgv;S(c}wF3+Q?TDduwNj!Y-1JAyU zZiTmfQLQhhCXdEyDr=yw3{Y&KC1n3UG+Ti)sDhvV9hMxh6q0WZ%nAXurkS})FjY50!0`ENb7>~qrpI~I(?F9S{PDZGqo{}JX+1X2NO1bHYVYCcw#o|>f z@$BXy4AJ~lXnr<^oLIH23q3s(^zlnjDEW^zhR5haedzFyeC)YBzx&?XIp6+a_RgDs z`<2wh=+epQcz;W0n~!i*t?|(+7MWD9U)ELtA8AqqnrbCuCN}_m|Llhngg~OYE@~c0; zigriTbgh&u8MJ7D7lsm;*WPM(^^S7^yCaZwm->tS5PukIWt~%{1boriNTg>Fi!|DF zIc&$#6`N1bmyj9RVABAx8XP8d=x~R?iS;!eD#}zCpwtMA8G02m$2!Ydwpc+hKZIql~;HvgVhtO#rM*G->EPCx*(%7BrL!COJA6-4qq| z^m&)eS&RT>ssX>3fQD%hq?lBsY`;U{ikgz+UwyCTB9C9pjB40JcFeXZ> zb%&`MpQfqlt#ROqyHsR2_&pAUS+Q5KiYbmW1>Sz-XaNRTAB6=nNac6YBDvxB%6Nf-GcVfp-EcZ&p3qHqW~Si7zGO<;z6S#(4250wrCc{ zvML%6ng_w@KxuLk^FDq&&4?6~jyeLUl`7ZVq1AglpmzWp@E`N6$G&uylJ}@zrntAVG-@b8Al-5$3=3RjjOc|xJVb^#vBsIIM-CVS2;ZHtr+JD^g zM&I+zT3_Ela$<67Lm=SkzW2UISxwy@L0vQ&;ubAg-V};9oKN$lj%7lO^?yX<8R<}k z6+Q4am#5{XTv)PLQIN!2BCWltnQZpSR5rWGGz{yJhac_7-z)#$Q~>$LyH`Aa>AhRd z&Kukx`X};AU&6w|Mg<2h45N22hjgApENBpLfN(2_AXPcf`N*FT&4uCKnn-@1k)?Y<;sg>O^DX(E+p+ETbe~>9T`g8!yy7^;TYGm zHECCqGJiKPwb^F+U^`~Ta@HpD_q&*vO(M_MEL_KZ@Z?jsz59rhpMP@mN~_zUEXZZ6 zoZ{v-8my(6%*i^$fR|1)o_hK@mQ;LZ_y14-G4P}Be8A-^CXe>X@+pq_t;agDiN=MG z+=Q-WtFX;zN0H)JHlDyU8~d;%Rz=h=;MvJBJpY1-1+6|zDjrmfJba09%+-B3CK$lO zGgC-gB3S*MHwgX&=tDSMkH~2sMsR2d#^f|zcNc;=@->lqL=QOx>HF`6zGVwC z$Ip4yb$#HW`a5daax)tL36YajbiBfeDYTiZk{gRor|1&@C3g}Qmv*2i6LSWAYedYlexlIIucEVzHkl3XF z&Q31D@1i3zYGs<`843s`aLx!MrdMxGL7TprK+-V%?Kl|!0aJ>B)N1qZvkfH39TWsu z36N;B`k8xZ%76LhSI_#0BJz>;MKP!}SJ}piq!~5g5=P%n^D;R-f|Z9Kjd-qvWI=#i z@F6HyF~75(ewRghY6>!e$>yfIoau6YN-+{?dqI_SHucTC;JpN0)P5)HH$=?2hT7AElWN(2Chti zQ_B(FN?;&9gYhEUI1o+0Q+cR47X8L(BD?Lcw>|hz{hCOC&792XXb38pDOBy_E2hAp z7Hb-u!Rj=k?1r1Qcy1a51oa#=jTU?{P0lFFxZY}XgmlYX;Wp$`;{*~Zz_^39wrov| zvo@&=a(^0wi5VM+28{$cjGP^eT~-yI$`DOx1)36u$JQ-oQhX-r18x*5WlZH>A5gNF z*9lY(jf@V9Zs*qNVs%A;0;%LxAQq=kWLvdxAnRyAsUj2LWvu=V`kW?x4$6#6RhlHQ ziAj0Np8cK(TEcF)n`8t79t6CE`(&X^PHP8eYJWW*QP@E#emBRf0|xx5vK7+?(~008-&&riUBez$cg2?2 zUs4A5w_OSXSpt?9)gMNHxI}=MK3g@LpM- zIe#>jZfJ{ri?X*H%!+xlA&8Akf=duI!xNAL~kOhdm?=J5r> zag&B_eJ2)L4Wg&B|7X#h#l23uDn?>7fiuGeP;v4%px2# zFPp*v)op2BqbRJ8|ip%Cn6Z{@bKKeWa0@!W>l)+GN_CFb(p&5o*QT;b4sme+r zy#cLfN zI3ocxwTm=|d}wMB5OT2Gg-50Ao!Ypqa`cJk-O&3_#`90@(qCM2>kqzh_L&*ec^H$J z1G z{;I35->VXdqmMp%UR}%F%Q%j2w|@+kQdv$EI3|r*;Y5~Un2KfavdP<(S|;ZZ)J(Bl zx^w4X|CLWXx4AE0ELY1cORd>k-iesp^2|;33)fi9b^braQ$=b0aK+w%3Hn7uWCz@u86^UDey`8oRZ~2&?h%Zy8Ts@zSN{C_dwnWU7jUkcOyt08v__=rMvD`3!PZq~ ze9JA}Ts6PZ$d#5i5pWq$2|Ie4QJGGolr=E6Gr{$2AA!%~q;d43sAhIi+Zu3ETo7p8 zmUC&8X+3Ni8>d7*%%w7oUVk^|IG#0(2z=Rc$wJW`l5cKbuvGiuB^PpTx6AtVuYM_f z;mc>ft}p(^B{#9Tao4T~e)FH7UMf_wC%M->e9S_xcU7i2>_`o4oXMtArGvZpmYHl7 z3i;sCkzu&SMog!x(1qe}Mw=JvAO6yDXUuVj`MEm>F_bA_`-ZLHx_`QGKw=Wrfl*`x zii5Y@jTnJSR?i*|8-Q8eTXSZeRCdRMXv+MDOZPV5l z?_9oQ<#C$8f0|8YL(|%(lhfW%FzkwT%t^S5`3Ajhls6^Pd!#)PL>s0I)V~TMqjJ%20>FKp?+qx$^ zqmkH;O|{6eQ&-)tpwMlQyqnpDi4H73_y|;JWxcp%#6G1{1b+>kZBe^qWMX_0Ev<|Z zM=pvk$EQ{t^X{kL%zfHh``G8cwc|g}J@aIrL%g+jsDD{geKQ<9#V)06k2gd>S7daI zS`w3G4M9tQBxNZcj1q`v0s~&VFNJ+SN$YVsT|j@bjG25T=XN@V!*1#3B}*3FUaXos z&iU>od*us6pMPZWj4sC^aRlrXn~oq)PKpuh`Wan{(txtjiso6ZkX=3`Cwh>~^uYA4 z#_;$6+Udr#dP#Qk*fzO4R1aUY1J(2pl!m2HOL?1aM^M~$wlSWV#?VXwA+d^BVG?q~ zLc|svjY>X=k$4=IY6VN4RB?-xX?SCYleiIlqSjB_r`OeYfiWD8r@4achH#=ub>tMQxuo3ZHd77T2Q zBN+7(Ab$zt;bG?;n}>It_Rn4;7HGS7{y6t;RWEG|2b}NhPmiK?r-@WHKR_#L<31QB zH#r=yHnv6jAXvi=(`sZ=kD{z3b>94p!^v~HiPI_t?z7|wG{O|W$fQeK0$%0Y7B8M) zn5LARP9k8K7fn^?NrRL9OoA-2$7=)bIbE}Ey?^te-d7!o@716=LI+IBkWp4pU}C&l ztn<9Jp$^^S2?QD&A+ucravamy3J5487LuWgPN<9wJyXPruBgq$y6DK|aL=xXkuIUp z=fd#w+iAiXkm}f+OCMOrWv?D=dMhjMQB*XdFD;^OBQ#zAA(*2viE2}7d= zl*w_2C@~EO-B>tp{$tZKxsQMM!o71u`+w4Y{;O+mO(s)&OjwRNVl~&^HTMvAsQKHR zAa-z8mFG>n)xp$F&E$Ex0$E}F{eoLCc(mFRtJ zCOtG<*X-2$(^g?wC7IWbB?%Ww3WfBu4NH_;dN zxZsK(e8F2OWPHU!b*g_XarOaS^DaMVP8EHraSZK@W1FtAh6}T zaqcWQSIxsLv--n*F@OGwxqWw_UbWz+!Q zE)F64`IbpIKE3v3syG~WpMP;PDdSr2z%E{8`_F3d(*D*yx0Y(GCQRg(?TT`{74}ej z9X$(9Zm>Bq*$JLeaS~`BvSo-3&H1{gTrOYeU0Sx_;T01qYuse&aQ55sHk}_UG)06YUT5HhV*x(o5|N_!o|?{}RApMC^?zt?388(?Y%5CRZVc3y zU7|efam&39mpmdmg>K8LK9Zh^Pv3U;quJm6`IdiKXDZKggHwsg+qd?-s7d*;BMx&f zt~ZTgwM`5N?Y@ZcyyV!UbM%M3cRc>wBP@OETReZhStykTZ@K5OUP+X<*T=#SPv=M8 z>M-r4H8Gq_HGecmzFtv{r+H0XFFKWLsv2K@dff;+>ib$_OYmouwRtOLwOi9nE0HRt zWA%YPuOi*<4M=*aP+T$4H+VrYmuHe)r|;=a?|h*5jm_p)m?S%O(bmJYP@pW#=G7#0 z(A?UBktqU{b@f=Wd?}uNex1Fuwt8M0`lm7oMWYx^mw#zJcu^M$fOYeViUak{G3W&9 zB&HU`j{BKoV@Wh+*oIEd#v!4c%E2eFWSr@4tFK!_x!dl0zQ^c#zUR_A&prN@c`57K zX!GPjzOFf(QrC`1@3WXkiB)AJf#g62Dcu1F1<{ySfxAqJ5IIRP=zufkgzO`y;pX8Z zP{En>XnzPnD@1a=1;xp6s5@6xtFjXXQKg8g#UouZArQ!KZ4}@mpgB%KWYna&;&bEOvzy?M*)dlY znLGpj1*CHY@J<4oqG=OGcXhQ|%UVNs>e=`;UwsWQx#`*aTNOpg%w2ZG^i%i!Irz|B zH-GqIPTy(eiOR>#8MWSsr&{Vm6i||BR7yJ6{oqsgH6FC+@=6h>jwKRvv!yHwb}fAo zetJw$!sGV@F-7ZgG(l^T)gzYEcBzlTRJSMrWc(kBm(YC3O^Hz%P4#~C4NRbtu~6kJ zsCPyX@p~|kQCG?(`LS@&r8*2u+;jgkr+>fuxHXTy*=v8+`M+jc`*wZU0c#HZ>m!>} z7fdHr=hl&0y(&il-tFc$KY0ILsnd_1f0Z0p5s$cV;hnu`3rRS6Wdml-ZHLga6MQrP zS6u+c$RKWOUybsXB#v`w5Dr*kAAg$x)FG+MR${?L()2b^T=*pM7dkFJCtuAD`dc+JQ3Dd}m$h>KKw$)ows=NPa|>C=!G! zdq;;cHad!QJ`11Un{29&{Y;dEKc({O$gi*d%bOm*D3~hOp->>IQ!sB%JAX8~Y5fHH z62%n4ZV{ak9|CUKu8CxPkc@YdV^UznaTMu>=L56Q8&9FRsv~1K;p}+^?r0|@R{)cn zABAdGY{1wz630lYKxy+2xGn(K-p)fj@%!A)ASV50i@hPrh%H;c8 z4%ZHHl#f=*`7IBxeQf%&D}Mc!B>#j~T6V!!m2ZN1wKpI6Fr3DpggRbe&Nlc>E!B zwKpT+B?sbm*$rZxRz=w7sY$aD=u~Nb&T9(WN(YQcmu0&FdT!NSMtMAemZO$~chw}p zSd*7Q&sRb|aP)o^T4Bth+X0u?|GM>|uQ2Pxi|_6I&WGRezV|MxyS#J3hL3Rsg}rlv z(5nO(o94s6w1Q4O2Q#07o=U>3h&Iow<@b?q4Z%;(;ffiUxe-`-g@SkJfw<0p<;e3d z>)Th?vu7fgeJ%wae?Fc_|7l;n@4v#@TI*MoRP`9CoJq@c!|66wV5+jmDTSj!$t?*; z32OD;@yYC+53Sv@^YN$GzbU=*w_<<0`X?4W9{j|Cx2pfXm4l_DP^j zzLa%&YZV^Zj9@pLi9!+Wb#(~0&p~j?go|}=>s3spQrNU(e-OD$5dlvWf|GFxyHOVo zSYf}byVWoK=9x$D`PGjv{l4~EW1&PW5DM4XtN74&eJnk`?O{1Nk_>ZFq0g*NcBn$d ziunX?Xl5M!x&W!@^2ty3Tx7~J+=|oELr%^YqD-smgjsCBDp`U=vB4wj2soImq>4ze z6;-_t0$u#he^?_D1rA{vV>UN!*XBliJeDn;MQfAkvwPt4dZAT$OidKg+|0~eEl;|jjdR|?91zWO4mrdNqMD2*H4whuJB27=Ll3^WVH|B;F&w`*fLV(}@UG~fwQ?{jf87SE+qzN7rYub>?9fA=$Fjk? z8+VMRp1k^;U-SJ_5)%`D zO{C(BW05+j$vnbx1R+Y`a@7K6DkdIXzZR)Xf0i}pq{)9j*xKIq=&Y{J2TNsr@SO85 zeamxDwB+)f<3Y&O~miOcQ~>N-}SlAE%RUAz=X zQ!_h|PO zSKC#?@(Re$j)^V@U(6+wiLdjzu=Wps`rG84_ul_6<;B|@8&3_`Ja3a38x-F7R$gl| zJutCP2K72NJ(-{$GX|-HNs_aD1$RuldM_n`ww?D#^eIUs^_?D3UnmU2d zLbhywU#tJ&;G5q9P1CL9)J*@_bm8JRGTux6`f%c|XB$5A>n|Pl=Vq7t3S&4ve^*&h zfEet%JGc*RV%c!ZJe<#2r#r7NZr-tdzGiejc>5n` zd>&`@*-e9#>^m>ZWeY~@;<-wL)48TnD9md+u;VBJ+M;4MRiriHp2-#c6s3Z(`UW^C zv5WU)F+7~acyMPx^;xnkS}vF4n?RJM(l_mk>;J-w8-G7<;qs@P9_2vA z;>NsQ?{g14{)}DkxWzP1^eyge=Xk!o)g?6Zg;e9W8=Xz%(v(l03@Am_#T8lIyTzrK z@#@ZhLQ|%i5nrZ}89Ei+bFS{OGG->?pfPlBh(3qfXD#^VI^ z1aLml=BQ*PN9>rJ7r2^Myxo7nz$GQggdj{pbe7JFPHH=of+E|}lHh_gjp;Rn{I2F7 z-R{}2n{SvPsiE5nVr*yB$g3!g?u1s>VA8yQVf=~dthBLt`Rd=|rq|x2teSYj>vW#R zbgMO!aoB1ED0NiTG8Btu^i+YBV8Jcww%ldJBft|3TzTz%kDc@LtFC#&B^Lh*``GE< z@4n)~FMK$gNj(@;PBUf1CoEQKGe|8olnndYx|tv?H6U!*_F@b#-H!o z@`(4a*<3u4&R=rHf4?mP#s5~@(l@?k<J-U8nQA7T%1lp97CnJ**6s0s#AU^m z(^Ml@B4fNNl}ag7tyYT`6CEgOS@M-LnM`4FbNArz^IQ7f@UGUD;Yst5Z{7I7&1Zdh z>9UaUDPz61SU6xNCMem1_^R2&m3iJ`4FOY3M_!<$K0r>|hpOBdFLTa^dXj|;-}n9R z{!23DbsM)7PkPV0Qf6K~?7%~Rj`~!-Z2f-ix=mXYkF%w#E&4uQazEtftnX2vU$Au1 zLTS_1ZExH8anF5gSwMePQ19*;_#YU}-r48hxs-LS-+A?Y8xGUGo=+J(ccf*axzX>G z8w0GCmvy2V6c8zJgde)YqD+wZ)2ujc5f zpFNX3&pwTK@XkNGPx;vAUJ67%y68N0{@k`_gZ{u%s^$ExXc~@WMag)*9>=V?9agnc zI>zPkwsD4rmv59% zcmreCkkDj2nLlU_4W!3mlp^*K3Q*+%`o_D<4<8mGsjrgKWu27O? zUvKZGd-vtF@80%e|L)KKa{IwAZe%A~J9j<)&zW=a6~*2kAN&6NMDC8tbas)KB{&5p z0(W0b2XgW;OTx1#AVFW?u5c+k27j;q7DcN*$Rn1~dNI!k5d+?hCCjZAbq+V`keec)Z zH(h(l>C=-F*FC*+)Vp>25SR#$FW}p_aAC()K9~HLEU$j&vRBdl>?@;7=e$~Jn|m^g zPI6%yB{h$jLqnBrke`5m$m^!aJchn)J8Dq$&Eak!I1JH5QoYbZ{qk;$eF$>r6XJT4(1$7t7^AJ z?NNKHYVEyOs0ym~))=>{Xze{})!tGuLaf@Gnn94(me>*zgpc?4r|*Ao@AG-@d7Se& zkMlU8BFQevygpyDBrHLip3ednpR-M;AfB^-Jh{xE32!BL?SN zBbY^~=o2LNmXVX+%pt_zJbJA0NeS>EmY0_s31=8)g&OKD><+rCGh=qMRdFRb>tc&j z-0@p$k>=Uz_Jcu6D<8}BNpP7MgX$Ar4ah~_!aqN>wEVA@Yzsa!W@}vcm)duSq2XoD zWt;xXDsY9my5RSJbe4y3Ii0 z;2;Qb1VdY)*8@o%9aU+$FnjNheRNSpQzdpEytF`T9hriTk1f;y+qBQzzCl$%1_h7i z7aMKca7^;nXFIzF9+NLLHmnnM>wuQ1SG~cMU63;Qr0d!mhMLolApdaI781Z#%HAoI z@zPO_;Lz?pV}E)$l6F}s|9GV>@U%pZc_hXPBoRu^mmc)1pt?Gv!Z_F3XFFhbDI(21 zcYfdSU^M*Q-_}%sJy+%dk261=2J&(%9EE;lFvuu&(CGyb|J$A^3$kx!w{k`~7&4sg z07V{D5uw@E9^0m~@6LbVS`G9X-#0l6g%w;0MsmlJ_Rcrs7&nK)TOo(Vt%D{PecK{w zOGAdBoCW*|95shN*=51LbkKLird^WGf()9P(HCVwIH2F?o@f;s--rYJC84mf>`wwW ze>5l6m>lM|eN@@&LxP~o2<(}l{N?g>4ethcgYSo|Zl{#Q7jW+e`||Sec$xTMLK)|! zQdL8F`AisH&JfM7B~Tpc*sbC1#=`J5)LJu(+f7vK9SO39(_+(9A=)?#bn|{hGh>}8 z_gq5|Ale9&1(9TYzYtEU``V|w;U@$p!T#rLqjp-Nk;}9uEbgz5o<*?EJ1T1W?9H}# z_07~1mv#ZJ4`^zED?#0DhB$>?W>20JJ+PVYSf-4Fub0>4voBATHHs3~@QpH$%*sV5 z7x%d}PX-u_D0w^X=r4U15_%~{T;+$O#ag@o_g}BCf8mzc&E-6EX(zGPc>(WkjxW}e zLo0VIFWp#1gHdj0E`mE+*VS_z%12s4SJ>B9sdL&+esxii?)MFOg{?|a{o z9s9@-E3UC77)wPRhZYk{306$V_IhhIaF3e$@?e5Bcpd$`r&sg-E!VY3yTcOsiv)pj z;2BY)2ewUUnm$!!SKsW@&~apm(xEd_8q>%Z${Jf+8G`~gn+WVxQuzqgmd(#Vb;kkN z5{Y^Q-vj@6LSBBG?vG}ji5S26OUflfu_s@?8oq_!vCpvl3$E^7EvI_7x)~f{b0`1v z`nvDVkVDpib6_A2BeUCjnNua#DwRzO0NZd<1Ns>CzxUj6adUg3o8xobb0J1vC5;kl zESQ&JB)P^peP2$N>f!Eux4ar2reE`o;`*H`bGz5MF1)D?z+An3h?bfm-v`r{tG-GE z6YQTKVG%f^hMupHskJDMpjuz14fW(6;i2`T84?@*aK4QE~EF`oz;|ZhAyRI2|=Y=|Y+X93Bb#>heO!XIYs((MJz1Dr-?JoFwdwV~l`QEW} zc!I&mIgqol%9T}lb=aYay?ua_aPel4JJZr1-Lc%u&^>^ai6VUTq15hw$x&3TmWs+N z>B$U_=DrSR{H>>Xq$4+Y%Y7>FI&AxQWjNQVa=9gX^qIDAV?O2h?}$JaqLKz3bEf3K z#!$hu25Dj${1ZHfx-!Tc40Fxg&m_i#{LfN;;RfrjgISVlY`6b{nvRf zSOW;x1GU!Ff_(2C}G|NZ5QUeoty8E*=R9LhD_ zvy_o9#SQ=acrP;Y>GM{>ZGmlAv)|Cpk@fcbB{Hc`c6Xa!#9=g} zFO(2Cv{C!1bf&cR_>dPmA>(m*>ECw!+KKgw4Vil1fGKcVOvv%;^vG-%m6eUZ8(O{K z-^F~mvv;1k5Qqf{%b-)h%th5ba8a6~0E=Fl!JNhLg&-ueu(WvojkS*hbhqumflZa> zVmz5~faLp~Ngbe5!4em?2W>nY>SR!iAZ`M2Ztsq42#AIp)2agBG5jpRl{ zxmGRGH4>Wn;;ju#WO1Trj(<^+BUMwJt;twL*!U@O$+Lv7P9|~WG1&XPzPRiBQ)1<_ znAF{tRODn`fmt8vZ)!|L`-)6j;OQ*sU`I<;mF3yzkgroI0P#Wg?Tcb>Kfr?=Rcg&hSEf)%1v-tV(HU)Es z-AAOt>a1O*s;Dm&mfQ^~bP7~O(`C&=%Bi`eLQdyTi#o22TXOY~B<8Mb*JNW!aUqAm zh}+rOK$4EsK@gUj^>XL+%gpBULm;IBO3YaBL@mM^O$}^*Z%g<|Jf~fJedi_!P!CgU z^%Xcw>?{CP6n2`a`0mKZKPnuthY8m>H!(G=D@&baIG9_+qV|EADvPsYubiL@x^^$( z8gRS@Two&>m$tLrf(#B$R^ozexq-A#c6>0z3}w>uoW<%$@sOo97S+Pnk-5oCzj};b zO1Lp54!p&fO6w1V>6hJ8MG@F>=K9%7Y!dte>f$ExU8yE-)I+wj=GjIXIB$k^?r**# zjxf$7FX85>+X1M$f#ZmJ8^`UIZK+M3DQZk19eJKB#BcbT#)&2k?XF;ESfN zqKT4*5&Z3sRpH*{cPFsZ?D`v8=dSw58mB&ecdat-=0qbmXfY&?$=#BkZ@tj0;zbqu zc?!!;4nja$_ckUdzYTLS1%j4We=n%EFyZU+o)NUbWBl4q;q8P5YLIuf?CqiF*RK7} zJ3!wa6bvSw^|{P9?!pd$?-PSJ$&ZH=fYT$-X`SE~(>>u7&in_hzWPT4qG$O0b5+8G zX!n1%HQtmU+U0w+qAR8*kK7H}-Pv(B%X~8Rp(8^VWsAZRsKldD1p{qao2YunNtns- zNEX8JBc?|Xamr~OY&tTHber8D4V~Ua6=UU;bV?fJ41R#r%$ET(<)>x_%u&+eAL|8- z^Y|co4&qeZGJlfIgS07Zo!%HeY2W21fOR^t&Xc&wi&$W~Q{zsX68GFysHd zR}UL}i}&LfOnyajeeo?(eikuiq>mDfEZR8Bhx?&YpY7NiVX6lEPydDZwXBDY1#dYB z5MA-eAeyUV7bIBU9#rt(A$ilE7O9!#8fs30Rn-*y$T96DQTDu7B3&(mCtWk*5 zA?H)LvsCaR_gTr<`T2Qg=N>v}qn!i~yN-vn&25D_7g&iPwDrt+vcA?(i14%=2Mk2e zFd#1SfIOJ9mFCz(>pm7*F1)CJ$>hU~OqwoFaPZ|#;p`#RHd(IlOgherGl@)$6rTs5 z$s*r?1K&VdeA8^XdQrN;rLSS>ZvQe)fb9dE6R|V%<}Xo)ZW4m(rRDXtK56qizsN-6 zB@|?*=ypu_Q&Qzq+o2hA&N{bQtngZ1K`kHh_zf1?D#H%y<0hFyQDX~580 z?A;Z+`g)PAULY(-u$W>Ca?ipf$1sh}v6VgmGFzjN?^34obU@}!Ye~M4F0z~Pj^r~7dio&G+R2ns z)O}u&_{t%XXkCdS1IHfZ8lFQp#01c34d6L9MGUslM%kc*CB+kNfrj)HVTS6EE0Ml5 z2hZOp5av=VREY(y3?NK7IhR@(ld&0)mJIzAm$i-ZwGD=?Of;Fz(-1e(Got_bk;Ixh zUM>cn0|7rpbL#Alz%N&FWQ-~z(I2Dg=-5qiQ+r%Z7$Dx}DsS=ge)8_RfO?VU#87>S zKyBV=bJ3`^O16T9pwyq~;zX~)i%ubSAs=fOnXScg@;V>7ud0`tfUe#i)C$T${`li@ zp`*N;`@6p*QT6k-^LK7vg>>mlZrvR3LARJ(((p#`YO;Yii@1UU=cWI+33zWx(NWx- zhaEG{M!PhHe7bo|7<)njj#VIU^!u(e3*c3YYLc1rO&5`jK2sxgA_J=Sgq}p zvQr@~)rNG0fiY;i-a_=Vv4Ewc;k1_3CLobF)s?u-i4Gh9eDyGlxCc=ytL)Y+ShJ=MzXf-~ zXdLTc6uyT5^bSZiegC~jdD~{Q)SY!U)_}0%f01btSaAVGU+OHrp#DCPkhfN2bMF%d z;e+*}`O55A4!8%uihF~Fe}*hVV#>3P9CSG}1pfq?jjTEpw_W71WG1f`gtL@V`A}X{ z0)GEl)68BC`kAKHh>0M*h)`~)tDr~?L0AZ^7X%78(QsYA<{x+(7c$|0XfPT)E|TLh zF=^YCv8HKythdN65@6USAg7X2?I#||J7TLlU(-n9 zOOq%dsdgL&{S8K@6h*D{_K6D^d@60&K;dncbV0k&la{;7_Bxj2sD&L|#+1C!U5vhvr%}gJ!f$(}eS!(qCLkHp)RG zea3m7J{})%CE7D>f)p(cc9L_iJPB=Uf@okRukjUti&PLEmE8S~g^c|D54xfyFg#{2 z-D(Wmerl{ZQ~rpfRARNTOS@G5A2od8vH7A5fF@A0N+ov;ZTiR*fW|x!R;W$!Oc;>& zqK)-iwP=}w;p~9$4cf=Gn0XmY!Ix7jW6)V;$^+z|=@%Sk%@{<(Baj9nm~XlmA)m)l z1JQY+p>dbOjCMF61newF;jAtYnPf)`>NquQN^S%Jc zE3e9ck4=Z<3MtwZInGyH4{O&i6x4{H+W38RqTUO(JKw8s<>idVCb@D!5GYvUOKv&S zls|bLKD8u(lPx0ytUiC#Sv2tyl#%o4l|b{kC$TlH?M>{0sFbL5`q}oG!pZa``Z$$N zuCA%M=6%oL(FR131J^u(+5Cwu>jRE@YH*@(wj8LEHkbWo*yNHFLBf&5r6L|-cR!zIdFwL-m)6|g>V@t_nJ4d(TUTl) z(s#40cX&^Z^1b4h_*UyHV3G>F2^k5?hj$#Oe;odvYFVv)d+-``ql}jlFMZE|Z1t1H zZJz3TI+c$la#Ko^fu`j#^btHw-k5Hkk@eDE%4x%DHfz!31M=|{F)qn5K#2^b!tv4q z$Dq?%)1Nj=ouv1QQydI&b^Ws^3SeCX0~RH&f?KZY6*BggynbfAeT!Ks|LK-+@+4k% z8jr(A+#uPjDi?B?iQlg{3!ymiTWSUM8dp71ack7GsnG`n24?raQFMVED| zd#>PSin~5wQ8`!zM?W{QBHql%v`E&5XIDa}Y_#JZVX9gmK72S3yBouZRC9dyl>4Kc zn9r-=^R5aoR?d$dI6BI`)Qpw6xHVUIHI`wk#95})qP)0_Y+aw8vnn8loDMI$Us0Ma z=eTwH$q6iRs%DFdUr~jxwM}r_?LLL!CJy2<&J1`tBego+Ymo^#6ymNPh7dU&`wa`2 zTf6j9vEUV|u(z1;tlr|0oof(y23Ch#C7rppTd9Y3!8|>uZQ`ciHf|d2Jr^lXe@gaE@{=MI^u?8WB zDxKPH*RHYn1wY_IvIKWV#Y(=P?tWhn2*~(uCt^A;*fn1;dVH~>d9qwI@~A1$D#!D* zc9poHU=pqzUKsCXwCS0&D_QW+7G}Q)nftY+O6=}a;5jRvA9&R0VwbqD*KfC)Y@~fip(W22;#GyZ}VZzjwMqiD3}| ziO`EqwDhMT@2p>GG278wtDXsqfLUFEFTb#}1+>d>K%BKFmGR7#L-Lmd@MGLbQ&L&S zvu8rE(|oq9!a{>NmlKjuScVLH@yt5RL1{19*hahcSQ}i16qWcZIZD5G5h`JMysHD? zuEvrzJ2R#0ITj;8ZUqw>*Kf!{oQ`kW!LD^~?)?dNS93|;UfuhCiysdI!jp=q+BP(x zs1*Ar>{xp|!lczk9UX@&=595xBv}p8xRITDdxV%LZ(bR%?#0( z8wy{#iRwrNR`Z?u^2jC6w!sBqFE`;(Zlezy*&2PdV4><8 zw-NbvfYN~@jaLI`PDv@u2E#e*;Z;_Cg#rm>RysONpm4RgVg}NKj^ET^8&~_cW$6m% zAm~Xb^WNEc39HM0QO$~OR(r!UdpE3gd{Fi<(P!L3Kenf|BRM$SIPn;EtSKplT#@U_ z(+GiJ)GmEpV^gL($GXOs5|wDgs_Fgg=r@)F%Xw3dUACg$PRlP-m1c^i54}wCGkD znw(>DhR5Ay*1s+~HnuE2Y0s@!=#0&9O<%kzC0~=qS6g=Lky-E@e6{V&kG;UTf#Opi zOT82@*OC|dEx7XaQc6BzD}f)19Z2O@sdy?Xh(>H5?~Z!a`yh`UHnT>Mc{fbSBl z@fL(Si=CM8ozho795@4FsZYNGpvQ(@bNTy{g8}a_xgvqH+p;2MBj>O zh-pZ#`D{S6Oq23+!T4X|oOyd1Ox{T&@@k(rT8l-kAEm?_<&Q8##bk}Uzzsj(*v->T zHZ;TD1hpATYDZRL>73PQQt_h!ysC(ls3p!^vFVY>LObaJQGY1vr8)leyHduuln=l~ z-JHFqgqL|X5Lju#yP2Tuqk&PuXulaSi%jxKjI9{;P$ok==dt39UhRp=&b7L=^VLZY z2)LV&=>=8B1`D!dU#+ZCgsGkRvfb&D{)XYX-eAWVa*E;DduTy+7H#O6v5|6{x1{Be zI3|6W?-sNdznxEh?b;Sch-X)Lk~qN6C`g?_(;pYSA9Ntsnu*d8H)qG<^DiZAF_P6; zCto?gJ2nldrstg8GgK`%-ss*FV{i6NY1CF`8d7~do1ns4@%mp-engEhy~>MJL4!F} z(n3R0_R1MyOruElX-ZyRVP$kh=YpP!b0X`-ho|&*PNF~bxulEihD?G@<@x~;Higm+ z;S4Z2T)?_eg%iI{FGpE{1u4M zX;hy$0+IKv=6Sy}WW9#-!MxRjHCu4{1cC73*`MSnDlW1;AfCE%y+5A*m~~%$P*UNx z$R4eK{H?)hUXx0C<`G6nP+l2UD;&}JZh^HZ`m`WW1OKJakSRW7@Yif?cICg! z?93E5;7rEzC`Lj95XelsE8w4z6?mxj-Yk~Ij)wUHcRT7rsarqeiQco3l#DsECftO^ zY6EIFp1;pI)uin(g(p2Z(eXg@8E>nV31l&6d%j6STY+Ra(72mqbw1&zQ~uO{VE<&( zY2yZnkxTbq(X&*6ES6r4u!(68Og^?cvqhFUkWwTmdfhPrI0c!6J%cAF<*6(3;h#vG zO5bdl+HmsRf5k?h!AGa=Xc|d6;~a zS|6zFCBvrxH2HG9jG9C~IISM>2_sA<{RY#aIhjVz8|E7zXWN)O7dXkeG!FA3Hf8&? z6VD~ir`gO6=I}RtD1>WFWAjS>g|?{9_$CtGym0!SGL3zA7b9lh^B;rf-pm;olK0}R zPoNYt!}LqLMDrBHeCfsPdv?a=d!!&#VvDq3!zl(RyP0EzEI_jUN^YU3^uhng3)AD!3kS{4Z&Ny4CYKkAe6D$W6 z)@MMZrO&j{z-g0tl|UP0c%{KPp(Oiw6^whCJEl=@p5h|YQ1O|u|HSmAjFeQ{X#_HO zr`JJlV-8Nfv-CaK@zq{$LgPzb4^B;S|3+W!uyWnRJRy{3WR=Q*ef~vq5RJ4?2BTbO zar0^^QbYmH#oKGl9^l!lX(4=53ZIqpXc5Rt9^wf7~DnhyuHuhxo?6T#w1h@!JF>QVA+&J)`)qta#8X13U z?5~2@`jRh3Dq(}hKar5~SH@3t|4Bketu4Zot!3r%Gon8?bbm)ISF9lM&+y?_PP#zw z_TQZSxtpOqGnh(4;J5?1=Ui7nQY?w>Q4<4i6U|D7AC-KgN~Ek11* z$i(Vt9A!i~k=z>j2?+JUfiTy|@NxODJdRi5mw{)kOu#@5P2}LlrvXF z=I$2aa*@&%1L)`w}HTpU-=iVZmm{ge}?YmTecg#$N#Lu^%v$v z&8z4SN(%oNDMh}kz&_HbKqiGl)40DbW3m?^6d-2Aj`?!wt2sRdLaPvWvYS-Rs&E7D z<7xb<3xkOO^o`c3tmKc%c4zoUHM(>nu?IZm@DA8QOaxk=zeJ^Aip0+Uq+Cn)9UeT~wOD^=l5aIN`rx-Y9r@AO_5u_Q z=HL}=j);BfD7+e-j$>8-;sp{O!2a2g^uBk%WB_7g{)%tfB=jG%oxO7YPpLv`AhoIR{~y#9p-Fh zL-g3A5;{*zqCx^o+vsNg$@@=76Nbn)tqI^^pQ*Pnu+WC8RW{muEJ@iU%>!0gUae?F zCp~70k>27yGle0H3RX&;{%Kf+^9q$#GXV`{@pJ0>T{aaa9Fu76-Y4I^fs)5XsE^uqwPt1fC-2MRIqjeNa!qJZJfA0l zHXygO&BC>kY<@1NoXRa;?BW{Fl=JK_Whw<{GaA$77B-K+{omG9m9l^M z-XCl=5E14{h-Q+1!JA5JM`$zNSJgSZi$3&2xU7n~1&{1m9W`?b2yV2j&sV--1a@lb zA2v9qLmq1Ua%)a;^sD55SWQu5WjNCWdV{gY|$Zw%W z35EeP`L5nBp`jIrJ2=D{>fi|gwcK$5wRIB*#fX80-cJ+hV-xvJeiFtUBjnagh7V+R z)Y(fwNSiHG1??_0F~5D{lcBu28TAoPBZv z(%tVtL34@_|C#btk>59q`_65REduhowo5 z&uCNP7+`k$$inUi=1T^KS_y2*3mJ^wCiHkudG1bAUuZU z8-JKBz5kn>(P=uOFw8+m0-IsgZsP}&{b?Pn-(!i-GM;+qW)nAcJf^;2 zg#7KnQSt@SQxBfi?|@sX&nrXJ(y~nljg^_(AzGOEO%R$a<%vd7W#yuGx;I8^%NbQr z9mQU-3;{WJxJJ?Qiyau-oL(%7V6Bf)8`@Uq)4&ixb{>1}+BK>wc3s4rWfkI2irr8z zc)EPeb8N;Q7ORy;7MW^KmsFn&*??1+dexGz%A^IGX$PD(!Gag(*$sCa-Jld93HRcn zUlau8UQBVWjmqq$YL{esm(*lOpC0L!EvMrh~eQEK+99qqkcC^%i>_9t3}Z3&$jWOcJ~fsipQyDf z`nXE}peA;r7F0la^v zN@nVEn3?wZ&V}lfeuz7$Sb6fg2lA?0+aSp~WL%vxk}08K^G-EvB-OcfN4jZaG_%mS zLLEw1zMrSjwwW-ZEnM%yP{YSJJh+wydM;rJWLKI8rAJdy4>hmKqC6u#vI%k+~J5^mua8W2#om;SLC{4|RaJuWuVQrW< z&-;Uk2JS- zz+VL&G++Y4B{z(<$zTc52~(9*a<%v$W0C@;P$F0+HfNrd=|c>nzG}=F?GLB$%As0+ zp2JzeQ#_lJ1!h`Y>%Z^d{@ODssa^5~(kF&8qUwC*W<5YuYc-!V4w-;~Ji4qFT#Sn3 z6nM!AtU|}b5=CXNE2)a@T9vHAx+(>T27_vO`i9SR<>J&Yh}*jQ-EBryQB|)`f3LzM z_YoL>M7Vc=n$h*4fo@y2n+VMW2x_Y#0|R*^JmGw%R-pp=qMoMaNojkcYkl)7x!>eq zOGcmV0vmn`cy3N~@OF)DA8Qa!@4ZY~xqG^XG-8wDi{k7K@~%0Lbk$0ZfUKMZ@al1e z7t96APeLT;OI)0)`OzxGgH;;Omj$C&&(w02zb8s31rWBAg97lwMHzMXrxdxl2di>w zE5Cqx2m1RrOI`ob!RHsZm8b*9rY;!o7O5u#_09G#%Oh>b)daDU6p6->QbUQli-EMt zt=c-uhK=;x_I4wtijOnfwzI1TLNdzJF;d>STRrv`5EK=A9wLGx)NE#=Y#2XE zd9U=yQ3I%LL!=GyrQFgwWs2OgpW^5BDB-@CsbG=4x8jCAP+@#85}e|ioD_A>cKvVN zFjIQMdgqt%3bDlFA9o?ODZ!$oJU}VP^bg|~XOZM+*Y(K{8WrgY=ff@CJ;pT3cX`rN zd(*zB)GV7qM|QlY>JLt_CO&5x_%l2F^KcoP1$^dmV+|xI$G^rdd64M;Ds0Lpf}fiF zwmN~S{D|`Eht{~U)_Hz2$MLJel|k!)ZGo+@Xp1G)HoB_QSo=8?{rneN9OyI0{HQfd z(kDw6qDo{)ZbNDmC*k|;fsZZi2Iq<%6FX*fl76RluFFDZwHZ?}Q+I1%G@CKq7=Lf{)&7G-5X!eV zbzm)&M>p(!#WlkQXYR*Z0R7;-fz|XSut7@LM-2!_CEt|nC7xhBn?!1+Qm^}yc<>A_ zbA&I9XkyLm_3_&SdYtPoBJ(;L#Pqql+X>C>UMm)tW7Zdcb*+-O!axf2e4573CEBH@ z$cD39RP8dJ%;kHd#O9!{&s&d-wvNq7ulMOy(vH<;1P~Y}Znq&ouOjMhgVRW_z3;40 zbo7P$yC?Qfh;3yu;eCo@LWU>OX`!4)=@02-xOyEnH(+Df>#=WOO!qU`2;y>Z5gS*Zmsj02=UfVWUJyzYqThVKZsT0`m-S3e30X+}o_DvbZ>n{eT-ZalEl|?D-S@rk zyAPYJ!NJHJox32_qi@MtV zx1v_@mi9k3(DemP3WFCRFUouy>u49Iuq&$_o=T*If#Ay@R|XkO3H64|dU2`(^>4;~ z-tkocTs+aUzR9@o;E5!iaf77;Tt^wy3RtAB_TLP#K8RMJFlm~ zBV_`^6iY;Yt>Mh**HYf~-lsJDi8FfAFb(8U#J<%?WAg<2+iKfaXk{4u^RUl*R#mM! zHu61zTb=hIpkd@@I7nMmd}CtUTVp<;!Qxcc$EpoqeHK#?YkK61kKyeSk_BJiSaZ8v z*~}F@--dgI4T{JqSRpG+&N6MbD`%yYV8y)3!LJsK4a}<=L|a~!<73l}xm-IEYKDJ@ zl!`#+r1nl`1*>?83ZM3JFP2WZKDN7J8<=OZ;R|kn7|P1CirI(cRZX7;Vl$qb=E72% zV93dQ)aW{AKa}6!OA}d6y~}e8^&S@}7nJSYF~9)XXrjnKJpTaaD6R@E>ON|wW`OVJ zOox{DmX9Y=oMA#kE=bPGoeQsI_4|oQVVw;vPAz+pnSP|dSKA`v>Mx13*RFlG<~Zw+ z5fK3+xhKOOCn1D+jDZD)mS(@CbI15r^&t^l+- zeQE;t+CY{K36&~ywW_@FgIAxW`i+yZ_L3eKUGORA-et3j<p6-tqTWgU9%du=T z`t~NP7U=}Z^LHU{PQHI*m6eed4RpO8PoIasi`KfQZb7z;P(RY@OH8pJp(&`6`By5L&fH%{{cO(E+hr_TAdh~l6I-*CZ1;Ew!xm1F zUKH4}{yno`p^e)XGWJEU0*)^(mxLMpH{G+vAAr_^qw(3S7r6D^Jn51vitEqW+n=~r z-@q*i1)@$LQ!B7-RFVU895Qo|)AE&fZ$DAg>9msBa;Y*=WH46F|2fI}$(&{!Q|A`Li|P^fwM5Jg;s#HE*rmsPA)LGL*#Z1Jh7S&!#l~sUIpc3_bzDdC!^& zNU*2>D_BPKNeyRMJ}*5j3%^WaC51HexWRHPc(HP`>TOJKW#jU|Pe%>AN_HE2%jZ~k z+7$jf=69C~=J^tjr|pl2>P@tV{+z7-xO<clIN+|`c=Iq zk-7A$&!17fcROQPx7l=9@G);_J+k!-6oI+6oau_zGk`ly7d5;7v^?#jjDT`0V7W^w zB^`$%O_D|CHdwQAnOnTDt*HjNV$s?ry~4`dG7ZCi&kGQs)9blV;sE$Blg+2k2!9rhvh+(IR0$<+whp_B>CTgQZ5AFN6GgY0S-A>Q_Py z^mfF>%2I%~1~G?6-*0hu<}FKkqlYVlYkyYW_*U&39^l#3*}IkJlG2iJw1x zG)7=I=~uV=#n)9p%bk3els)LdCAS#H@vCrBbb8N-BdJRLr3qbu5RXSOyIyZfF+~=h zBvVwTVw=YAgp}QgksAXvCBC;{n4tjD;3B37CCFSO_Z8R=@7B0B8jBct zbkt;VxdlZNuNF?&0l6|lL9bCSZT)tCo?8CAVOwH5g-USFeRvRUcJw{PC?VxxYs*t{ zA0IX!SD6q_d|3Fn)3NvGxR`3UItQn4fe^SM#re;aDZ8osKWeA% zurZxAI0rL4uD+U)TO)5=Xt8KSRvA_;oERMN=H>H6N@>UG52nS9)z1ppXr-`~B|}L$ z{vT!KBud+n^1|Ua$0%VMPjYtQI7+65jLNEdb5DN>d0 z?akHe9P*gP3NWU8AE0Huh-xRzW=AZ)m(}L0GKoJ?SkP$`M=`H_0^H7zG8Kqv(98Co zgghvDak^`%N=qU`{nUb9HE5|C-wOC>f5(Sr;V_~*d3Qpz+>ZYrp@K}*E&sn(M$P4cT_)e1T`D2~n zC7(Qk@4r!KIXZArbf4}nq@L4a{&{wg5Z?=}L^M=&$`@<&F$ z`64sn95TAekbGjWB*Zz9!D3A?4-R!l1 zTX_)sXE$vm(6Yx%4mn<5MC8pAbb?N!JOenBpO$Mp*?c{7rBbCE8vN$CxxBGcSn5y1 z!6s+!_^k-e5>?dJtJbR4HZrCT}rq$U)1<_#Mq0R&ljbkD|Xv#rUz+9uj0bmw-EKUj2&ZA*oZQraW2$rePJrv z@OG52toYL*&WN`~@ElL>)Md|MNspZ(RkG0|MO&Wg zZcN8H)l%Bp6uWL}R!8+$uttICWBI!xf8^2gsx(#GLJ-`tS2e=bgmQ>}X6E9$n8n>( z#6@p2C$&Q0{ufFtH81HORv`XL_{(smzdSP;Y6~JSMAjz46vF?hIhV!%z4z@+u>Ogq zU-`I9gUgT>Mhx?M4U&*0F#a%;l>HYWf$Q%t&GPR&@Z7HH>rY+L{)WXj1}yr(+wi$< z`vYSYp9$%4r#o{pYrt-lb(_*+tF1S9vy(H|6T_XC5oGk|W3F|DI8RjVkv_n}oSawl zj2jy(?5j=oUPGCOOp17i6LT7Q5KI1e`10WU#Z~obbEqi2`4-)ZS>J0R7U^6Rr_f6( z2t`}|{dD|sGOkNCzv_&!x5Jf-?d{N$nvvO2Gv!iwv+f4FI#doAe_K#6K2QpZRO`-; zt$tL##7Jy;a!NAH`F3|M&)~ZNJ%Lb zePaOw8djU~{|Z;u+L#GL5^Y4#Tm|ZdGGfj8r=R7wf3uii_KaDrV+{NE?z(RE87+o< z-Y-}^&B?*RUP{I_Vmsc_tQJyFkvg>8M4ahxxw4m8q6VGpMim!d8ILGK+y7{!5Vu;lOkSW89j3PJnLZRy63iCZ)*@*O{8@vyAMQ!M%$-3 zIJO;Q8`RQxv~-5jUayOKErTTtHsyVJSn0*M9*$2<;GRKDc{}3naiQ}#^d>fgn9}2vlfMuqzyR&PQpe0wEw_1PRdwYX6L&n%Ehmr*5ur9 z7u5igZLgmab11_v>p$~S2^$Uxi3Ha+U1;xCvh}&m@d1KC#65L^v^0C(Z+_e9try{p zt^orwXpfF+>#NBwC~@!#kZV*Hmreo%{dfDQ1XC^>mz-tYY--v*5|%zWiX!y6?7>~V zV({&3*K@^s^D`}w0#QNDia@safBSRk{Izv+cBKA&H8pwhV&$9ac`|kjUr4->%bbsvYzdHAvHfh$;chpr zpWSs;pKag&{1dL1I^7(&=&<;I?t&Ph^Jm|_imkbLTYt~jX`BB&-Fg1++x~e~uQvKy zf17c2rZI5JWKPv7&7#Y?(V-DvQmd+>o;`aWvg6Cr(0nhEwjix~{u98(z$q0wlny(K zMs7{o$(6#Ix%JPFbGy@`zrVfneW~{P9gpV5|9#cIs{iNe+LTB7b&uoUg~#9jvBuc% z|L0)88-HFg7k4jrZ`_|&kh0_Q$2a2fKfce;_`i$)({+uNIqz2+?5KSn5`MQtH(=)t zgR+_zzRz>#$LxH@-8}hP;?FzvdC!l@8XsfU>iAw`IIS{KCPZno>HSGfnNsWG%o4i= zkJib|Ty~~Z$B>J;T7HJr2XQT*pn4&OhX1T`ehX>>N{;8=-=BYe-rDoKUoJ~uXZiTc z=D6K8KYw(a$r@S~y*hb(_RKl&KFYopwEMUx&uH&lk>7lWmMqv27ku+=)yKx>wFULZ zfroJLzp+$rL(&(6|TLp zNmqID=H$DDn%lZIMiy1K&HlaFzxL0gRW@4|Xfo6Thf=Ck8iTr5aR={xU;DlI_SLs% z4=ZQ?|8pc5xXnj0-RAGp_Vsd-uea(u*Lh#BzTGTe@%OZT?9PWPxUb2E*VkS9R~!5_EjQ8o z=GJ3u-jcV^rTqEmEB*a_c8mPK-Tn0sPx&qXeSu%<|Hd6wz&d44rAgx_$6xgy9$wOY zIW0K$#?653w)}U14WITWFFw5fP|$o^nX97y+s5wFoMn6kEqAD1Wh!~9Z&#C|G z?^=lg3uXq1C;7G=dtC(?7`g+P7&dSK>rg5)fcc-wCX!}gB|WV7cHR-z%YP;ixWXg+~E}M*@XM1GPs44ETTGa#dB^@2PX9_dB=u^qvqx z=pcx60ei35@Dmkm;Aa6o5U^kY#eyP&BE9z>S`tXlZMnV8ow?KNssGw%5<~$Lihln8 zpOt5xdvovHnLX$1z1F+-Uh92_uUx!f;kz@_`O7O@>67#4&V9vNo0^Ja)dIGbIE*F& z*g@dzD@T97h~*s=4`!mswI7Wc0tM^)hq0d4S$tv!H+j6sj8CGqp#euMc4J%5IEs~K z%;prh^f-ZWH-Voq)X$ku>otmdcV*EOBrr_zcWiVFW@Z9w!aDBm^FUtY#`{Kwur?gS zU3v`e?v)tX@D#<#g4nwyi8}uw?4)+I>q)5*924W1WqiwQ?o>4N zGOk@pge%1~jt#hQ3B{2mE+?AFmkdcVPNa}xlyig@!+2*o2_Ge^E9pM$BtX|mU;D_1 zUPymPW?5U)eYf6mO9>af!v-DpKc?et)kH;Vr`48ScgIPJEw!vw1@dcvziUZN8HW-G zHiuJFW0)N3K{=N~*c(U7(iW6@uR@{cKo}h-P;yfNYp`^>eUz9IP9V^jON9u-PE+y~ z1JtCggu^j>z}1?~IGcGysU3@3dlt;T?NEQ7aoq7I8oCvkoEbwdo2Fzah)vlP0!(Gf zDWG4sYSeSZyuBIap>eqhL>4J_C}t6dyzPT%Z}r*bUMA-yUroTjkRhL#Mx~%_{jL!CqDPpvmVUmvacGT4?E^v+4WD~_a%ep!&8GjhqpDi@fxjVmr#GU zxqQt^0drfsaoXDt!PS4h&u-eRu#E+lzPl9D?WTBQTD;7XQ(A9NtlwszyscT%MXP=m z$F~PDH<-gioxYa2;YxPKG5qA$X!T0%!GvIxFII1}HqAuS|EYzhm*cy$qkf!@ANUZ?lUXefX1&9V4I zu)8S$|IikAT8^?+Yz&wXyV0!y}io<25X;Zi`XtqXS}Q$hkEX zXL%dkx;k9o^Mc*oy@&si6>zIF7DmT(T)abubyywB6qM5aK2!?gK4oa8rJpHC;%)R? z2S*Esv4K}9Q*^jFC?3g5rF5Px*1y%c)W$=%zQl{1OhDIe#b@pD(BmMPav-gQ?mJ3<_K*NKbYZs_Voyk#CE=tc|A%lCiu^aaQL;g+3m7*y(% z_C_eP8qAfTmGU-a4eQvhFb(=d8RM+PqQsPnB%HoBNDi-pzHKFpGm>B$zl#xrr_(gA znbHDf*i{Q%xiqRFAI5*#?hXaW-lsOeY^+1Dtr5|s-AHZkhpQ<{iGVK0!X8#fIrWd@ z>K`4ta9O~4iZMIQg%>WRgiAtTk^rDs!cxB*PgN@LP%snYab(CbNE8gxtmaMC37}E7 zsWMqH-F8aJe9;DzosEqsRCNk?Rn%2ck+k-bIBoPRsF3j%gvguiIX!aaYxSpKbg$isv=BXiXE#(C^)6_bWO*`={i#; zPwT|tp>-}Z;_9-UoGn{C@0Mh`@cTDUO!i1bZ5?xWoSu#+qVAGKKYsR z8I9N99oep5|Dt~y34~urSMx85qOnY`7I*aY5B>Mx@c3}KQqd@A@H!KO-`y213IaEO z;bQra!;U+Z9K@G6L-Wfp{iabOyjL*|LZ2g3@tcN6C$8AOov|E)chJ<)DDyPw;^`H} znZ(!d^TfK4l(()u25jAl1==0Z`b|DKm3t6ziH_Lmm=IZGm?^7W11(Vplwdw z|40VjH86jIl|}NCrMwQNZ03kYD7d;2uJZQIYt&zlHb*-K26E_F8o|KC7RYLmRu4Ha zS3Q>*D+unWyW8XQd-IueRnRo<)8~Hp#rv;+^wEIddlDbVQDdXycmCtJ0zOaAfh%J3 zi!Y?mlgMFUdmN*4GU%oN%2T4PH69L?58#xh2u^>&8sx^O$+su*_@*7`+u4V@sF#v9 zN`47gWQ|2AiVk?_uH|M5sL=27(@8i&F8uj{eynMs;r8~}7Wz8#9t9{-Twx1u12$_eJDp<4~kD7A3LOi|3|;Gex0nO)DL z=zkZC*m9d>-{A__8&awnq{UMN?^-|$jHzzrYjMUdPjkaQnzhSl1^c+Yw(+s|_(yS+ z^Sa!h$Y%4HVStd0RKm46PPHM;G>L&wf#3QIu_+(3W_^mgFwBKhnAhRx2M_Gho*!e5|H#d>E=`R z;A=a;=4x{I8X%-|b~Yn3y>Nt3bhp4!q`##J&T1wY#R%g!VG|S=yp0R(dH}W~^J=zk z-Q)Sbo!hVb{Kr0UWdG>s@k<*I;9|+`s7`lMa1Yu?hMdXBp=St}y#fUre>Z!-Xe&6B za|IM9cOf4iM}>fhCK5<98c&sxJeNl6vNJ!<|`=<~#(KEDIJFNUhW^H(1~`>UCKyoeH~n+-j^{VtUff3f~u^j@V5rlx1@wbQv^ z3F;$WlxaPUjEs;ENYi?%+MH-if1Gd`{mw*pd&8ZhW78kF?8;xiVV=u`hje1*4>sed3ZY{JlM1qOglr=mym0oqga{s)!9nBuE z(#&>Mot|h5f6a$B@(^L!p&UqSt<5ZDEQ>yKzL0ZFOeYEV8LTVduj`)Q^u0_rS3zw% z#Hd;|9%Q;vyXUVx4Cc!%?qw0~($Bnq{L;Ig`GesJUwF{`5IUj`Jl~tZNU~%zqA(dz zrZU9pv-cczGjjNBLC36KLMAWLSX^kUccIx?KzxEje>^h{F9G_3=^~$8Adn+shLV77 z@d{G9A2aI*P)bn(OTdk3J%?iv0_2T|$u+|4rdZ5sb(&Y5{pHWa_jcVC#px)N`Ev(n zQ?j1Sp)SC)j?b$?(dNr@`H8g-(OAhN3%;(M0KhN`sTmZj1%$jZ=GQmFXLZ`AN6mBm zG-rx*f8YAxG~DQ(+etCOPw~dZDFVOvz_o{W+?SY6S*mJDbV(EO_)UT(zK!=fK7IUA z$J}(@H@{|U>|J#64{D_eiu;Sb3CDwqLqk+nv5P>Z&HrsH2F@ba^8~JTe-J0N`LL`q zN#M|lGY&W$@B7&47^4gS+wcE`z(UGa+$y=oe~{CCNTxKql%t%@Ar$dI>v;f*F^Rso zjIzNY(9i^p;>Kc671g>hUf4B`z`UhcWTt3cH{hg`-;UdEx&ucXbv)KRw}HY;6yI9i z@zMHso!@)F=P&#f8+Su}>>UIBeK!#lI*6UFah&)LL6B>Fzn0DJXse*q+Av|)+meqj ze;bzRWG6Aa)3H5W)fQ#OhS6%8_7Swh;jm9@*ki<3j~7R#u+T?;P3zjjqOg+^ky65b zO~x|7&X`yY{Uj4xp!hZ_Igz2CwKTUrc>7(qtlzWZcDHle`rS5l4t7_D2%Z{ai&dkY zt0bxrgY`Dk%h-;mF}8DqeR|g2Wg<4`e+UAK9_Yo*uu6jzGXmfnk3-0{5k~Hp$n>|P z6n#I;V5i-{$WAL&@-@ytJvi2Z&p;#tjb?6l*Ebu3hCQ{FoPd3@$M3oGKt*vJpB$fp zldy?HaUj5sfYkscTtRztELYU*)2?cE7M7o*H}a+z(TLCV1-vERr93k{HTt_JpI*1|r&s=Bmth$D6x+V=nX|`#{NoGHpnGz| zmfpdmmduGjH))-Dy2$&a?0fM3lMlh7uC~6JWcGdkeZ?QQe&oX+;#SUWkCltX`iY6Y zm5bWy>2tYl8P?u*ijl3>DNf8;Tj%V6Fd6|-F~7zeg8K~2bftv;3UY#W*Z)o|MM zUprViO0KAzrnSrMc3&sU;!Q4ZctBJuWyiLD%9w&hEa+&o>jB3nMxhX(s>)uZy&Wic z+TqtzaH+F41rX!4Q&kntP#Z14b{kBoxoIS}-GyQ{0f`YtySy}k#=udQef`<*xroEkwVdN+J;c7XAUOu-Q^w5nql5|1m1MZqKo~g!RQYN{l-ow;* z=&H*m-qp%QoBwZWbPNS@f9568O^zbQLI=vJA~M+|a^-0xDeIm}64Ixe(n^k0YwfT+ zuD0)3VgJQIO~!X3H8ilaHG0yIw#9#TU?92pWN**fwE=dlC17XKL`HL<@cc6{DYHxL zXx@E{%w&A>Xb!}HP$y+_+wIP8wn!uG>mfPpt4N=Zw>tr!o~hvOe=!e%$t7@k=RR-*$yuWy(e2tU zmJ_q?LyB&c>8N&3lU6HMv8hg+G7ps!FQZb1;Go%BWwiuAB;;CM$mz5MwUz_cHu4*u znY2O16OZ*Uki(9}fA2l*xCQUl)#|k}l*bza-mBkv`igUIyXD2bj{ooGg;6arx~nQJ zge9vE!t87s8~etgG16H@fI{<$9S>VJ?q4+R8U9BSE8z7k%z}3rwFA`0>5?<6yGHDZDZhrA(Wg=f4TF;Ef?(THT>$i zUwP~&mtDGefARc|M>lQW-l;GV_15n{T<* z2G)K30|{A{_;v|iWM*6R?WY38e2iA7gIqX`j^}Pk`97&_d z+U1yX4*4P0xlU;=0WjSz=c&+ooKCR1z5<5E1*{Dk2pOa7RKQk;VjcgIBZz!<1ZiDF zG9%fge@Sf4?%DNZiD5;x7>^uWJ|-@pAn4|wne1P8`MzBD`cQM1WauZc#M{HAP~Xx1 zM6BKWt;PdZ>Q6j++l<%Aj?%o~$rm&PWkj4N`NR@p&KB$787DO6ntAF$%|2k*Y=e+6D~LXadrnNH4x19ecV?7Y>KF1q+q z^~XQDT-bf>(Gqh#de6q}vk|A`pj5J`q$n$wx zdrljGf}c--sE-%;jTN=L#p@27TPzkse+*Qz2WxI=B;adH^U-x+bVBiL8k|c;n($5U z>?{T;$=+Gg(MtfWV3=^b{D?NzjnHNPoBlN0A$v*3yT;H`qm(2tS53i^0yXv%c1n?9 zfKx$cawif)8xV`8&^DiZrI!Fvc^9T40>m zpZv&&FgiL;*T;#}_z1Lo5+(9=LlcuWvHZ5&EW7~^2rbZO3FE{QPefB|3!Z*>J0<)& zf_?{LO#vfa(QdAk%?)GesrA#nNSg=G`~LUGt$$qn`)FVO^7%8DUGk07MN#@=-{9;? zb#(!FToT1ACnQElUZ`Nn{Fb?#)p5hy-geMEW>uU|;AlDdfhK?Z9G8a=1t>r#lbA?m zF_We+PT+{*<6U&|ua+J1bHDr3-7jqniL@w%!Kpa3;rivw9?MQ1PO#f1vY?P@; zVRBj;oY?`F?+*nXf8-{Ju4aS-UbySKYg^MSdtvnqFSYZVV5dn;kpcM``dmX$T|s1& zW*hxioON(XUOV%8rgxmiP=TkbF9ac?fKQJ(=WR49|yT*)AMS3!<=&l z%G!aw1^w;ms`WvqXhyddEMc&{i`V1R_OU+4?@=5ehe#RMNp&%JT>)f#YwX3#B2-E+ zZ3@`Q_=MR%f85WGS@>to2IMUXW{xI=#DFI~sgiz9s+KPnWyi!m&RMgZFWu(yx(`pl zapyofd8=D?EudHZWL2%Sv7>Cm?sczqax^@?{*--T>9%MDh6^a(a|8AHyTKuoJ`guS4U}D{HD>rV)6p zp{>5~e~xXBe(%^bmMYT^INJ~87A~5HwAzDoB8o0g0;{`ZJU5buMvFekcql6x3aSA& zD}=EG8_ctvh75f#TROs`VgtDbvx<3?y|?(Pu)>XKo=X6^I08NthEenmjg60g=F305 z@pUyljSTfA=Fgkw4}=4lv?az&a$-&_oZn?1PQ{LljuMzm(>RK-y@4Qtk%hAw>G^Y7 zjIQqP&wS{!=kDwLdsFtaU;X~Z)dwy6ipS$@85$b<(hYyPjqR@O)p<*j#J%rBA6whd z-24-l)e;3Me=b?Fs6Cf2uz9)%z`m~C*bBe#k>cg|yzu35Rk(F3t2OvUU~!iZQJ;h- zw#La1Srk8Mu5kn^Ii~n!BAu?WDlnd}G66)24H%Ynw9&kgt%B`GBud(2;ZC$g8Zg*j zB_Er>=qv?jO4QkLv)~Iral7n>0SC=xhl`P#bJdEPfB*TVS6;eLo%rb~>qBO>zbSN#2tshr4a&v_;-3{H| zhbk2Qe^x7d&wY=XhaSAf|i`PRa$5Kv3r35$TFktZO@;$Lk~!j@^a*dZ2c z^mK&a2t-k5?SNJtg4lREoXzj2r#wb)f5}ce8Lf|}cDl&M!~1GGmvRI7Vu#%*v^OvC z_G6FnNIdr;M&{j4z~A6xgc|B838I&sv&^QHo0%Lz%kVZVS-lF+ZW+cf%}<5qXJg2T z)yuok+dD}gzXXMn|7c@)oG#RR4u9VVp4&TI zgrjPWk5;kBqs0d z)EHY0z0UM(1v{q4k*3c$Hj_3Tvb0GO#k(A$@K7im-u$CqT>lE%9Z}P@QnF;wq6JDfO;mB^Wi5@eUd59@{7klaEG+wHVFf4+6fT*_r~ zz6FKe!v&2p@{-7!U&1y4urAT0q4%5|E+bpeP%4<@=v;JDRMgYwT{?FO0+gu+{9XbY zra_QmN|CYy4uvafTIOa=lkZu3`b9IZHO}Fwes&WcW{;fKd_6fqwR2=a zyu~D{Q`_$0P3?=x{KUxXn6K7&|5UcAtNvVaEOH`WHN-F`OR9B;sTv=rsp+k8;EB6c zWH|Ue4un~;SFws|jxz<`e&lEY23Q}AU|Ax8TcsuJU6aq7|1~z19Dlx|R-Yo^JZ5la z_O%8=6Ml>(aunb<+;dmY>z5H21rrLsF*JwVm`vq&)0Zw71r}L95~Bbez&Hg9A>u)! zBG8<0BDT02<5?Ar2hE4zbf7dfh4~*mo@PV}N=F?5)XJ;wQ0_MF?caaoiJ?Ed?*(Dt zo>h&9z6S>xQ`p>_LC=Vfk}4I8=)J~7mz@{|E`M)3G&uylJ}^0*nmy&PZ``;ya_U#u zkFWeq<+AU6>+^n8uOfFDiZyp25X>V~7bP$`Mr$ce^R7S%ri@bAuxmUSlA6=iZZ2ND z=tuAR*ni#fdf)SnT7Umwa&l^VV<6z^x%a+DSxwzuL0vQ&;ubGm(G-d{e23;q9m|9m z>wk&JGt!|7D|+BFu%xv*rhq9BR4L|S{(v)SyEscd$$X&BZc4?jA9zg7Ofp#XBu z2`iuf!M$70&Kukx1}5`Lf5O6|Mg<2h3Zri*hjgApENBpLnV6poIf{lIfM?a^=OcCPeFX7n1goEzP2`jtnL4 z;ShndaE$BSmb9x$nZFyH-eNO-upP5vIcpR7`(4b-CXr`r7Ovyoefo#DpK!$KPk*_2 zrPb|F7Ur^5PH}Ua4A#<2=42gWz)PnYPd)t{ODaCG=l^H^5V-uC?{fKy$)kO;e1>B| z>#>e(qH)n9H=%3!YHT;!QKa~ljVJKTrhY7qRT1?Icy?+W&%YpIVXF^QiU$=V4_{&e z^K>7M2?p@+>@?Dr2v&dR&7^Gkx_^TQ`wb_`I<;QqK6!U z^!@ij-?|l<BmKh-*nFdcfH1Yu-be^)#I-nHSZ?+e#y+*|Ct|m1#~E(ZaA8k64-M=pM3yEZZm

D61)&}MEXkTgPnI|0Ujz?5Pjwc7mq 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 delta 13107 zcmb`O2RK|`_wUar8DmT$f{=(1L<`Y7L7Ec1MopCHy(H=&M2P5|=)FY=(M69giA0SW zj1q+Cea1ccy?^4p|M!2N``qUm`Oer7vk){BIHMhk`0;a#1KxOSWR9smF^ zsAT=Cz zN`?F}5e1KfFN1Uh$T1Q&dN^HL5^9{NXRrBsEh&{Gl#TcPBD3wd zg#W=)?cU$PlevN?qxsnm^WMRLjs`|r7qrv;_DGQOb|;5W zP?{pWM<}`SS%+wL#tW}=7@ohB8M=}x?7qOjBjoF+m7`JKg5^Gobe*Z>G)k5>v_Wy) zVHHw=+r6)XkKzB0kE*lGV2~`@Fbj{V4}4>~{E{v;uZ5|_$H|W@W5W>rC3Va>lPAVf zNHl7KNG@0lbgj@66F}&whNnAn2_z2bV|5;5uiHja-Hu(dM1D#mR7oggk0UvWqSftg1zOMGjc=i!Ehn?h^b(^D%=1`vI-3X_Va&7&COSpS4vML@+eL!c z2S0yGnS5!S78UngB{iW9)5nDw@52L)r^-s@=7m(8+X@mRuLlNoxqIin zW(g$|2MHZ`s54y?jH6iu9&UC8#fA8=PiomyZ zi0DA5KHItFIVZhQSdQjXc!Djh=wq>2?Y*?`Se8r1CHMXuE8QwOICD&-Rp2w~;u$_h zq3Iiru@{ROjMU%z_y(8}8aHCse(S_$4p8RmBqiLg$dpfEtqouz8oNY1S{0f49=1`; zZZ0fDGeBxJ8Ox({0Ibn2R>&|t4JcnEIs)V|Z-?0XIRx?s-8#{)uE2ihIz471q`p

iUB8#(MaLfLgy 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 gl6~a(%8DHNg3CYNgnuL ({ type: 'audio', key: `sfx-ta-${n}`, path: `assets/fx/ta-${n}.mp3` })), (scene) => musicFrom(scene, 'hacker-music'), ], + wolfenstein: [ + { type: 'json', key: 'wolfenstein-rules', path: 'data/wolfenstein-rules.json' }, + { type: 'json', key: 'wolfenstein-campaigns', path: 'data/wolfenstein-campaigns.json' }, + (scene) => sheetsFrom(scene, 'wolfenstein-artwork', ['sheets']), + (scene) => imagesFrom(scene, 'wolfenstein-artwork'), + // Weapon/enemy sfx (fists swing/hit, pistol shot/reload/empty, guard alert/ + // melee/death, door open/close, pickup) will be added here once clips + // exist — WolfensteinGame plays no sound yet, same "add the row when the + // clip lands" approach as totalannihilation's weapon-sound block above. + ], coloradodefense: [ // arcadedark soundtrack (see services/soundtrack.js) — lazy-loaded here // so its audio only downloads once Colorado Defense is actually entered. diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index adca781..ff80055 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -120,3 +120,4 @@ registerGame({ slug: 'bloxorz', name: 'Bloxorz', category: 'logic', minPlayers: registerGame({ slug: 'gootower', name: 'Goo Tower', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 90 }); registerGame({ slug: 'excitebike', name: 'Excitebike', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 91 }); registerGame({ slug: 'mastervega', name: 'Master of Vega', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 92 }); +registerGame({ slug: 'wolfenstein', name: 'Wolfenstein 3D', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 93 }); diff --git a/src/games/wolfenstein/WolfensteinArt.js b/src/games/wolfenstein/WolfensteinArt.js new file mode 100644 index 0000000..3d3f53b --- /dev/null +++ b/src/games/wolfenstein/WolfensteinArt.js @@ -0,0 +1,66 @@ +// Procedural placeholder art — the TAArt.js "stand-in keyed by kind" idiom. +// Everything here is generated Graphics -> texture, so the game renders with +// zero art files. Swap in painted sprites via data/wolfenstein-artwork.json +// (see assetManifest.js's wolfenstein entry) — nothing in WolfensteinView.js +// needs to change when that happens; it just starts finding real textures +// instead of falling back to these. + +export const WALL_COLORS = { + 1: 0x8a8a8a, // stone + 2: 0x8a5a3a, // wood + 3: 0x3a5a8a, // blue-tile + 4: 0x5a8a3a, // green-tile + 9: 0xb08040, // door (WolfensteinLogic.DOOR_WALL_TYPE) +}; + +export function ensureSprites(scene) { + if (scene.textures.exists('wolf-guard')) return; + paintGuard(scene); + paintItem(scene, 'wolf-item-pistol', 0xd8d8d8); + paintItem(scene, 'wolf-item-ammo', 0xd4a017); + paintItem(scene, 'wolf-item-health', 0xe06c75); + paintBullet(scene); + paintMuzzleFlash(scene); +} + +function paintGuard(scene) { + const S = 128; + const g = scene.make.graphics({ x: 0, y: 0, add: false }); + g.fillStyle(0x2b3a2b, 1); + g.fillRect(S * 0.28, S * 0.15, S * 0.44, S * 0.7); + g.fillStyle(0xd8b48a, 1); + g.fillCircle(S * 0.5, S * 0.14, S * 0.13); + g.fillStyle(0x1a2a1a, 1); + g.fillRect(S * 0.18, S * 0.4, S * 0.14, S * 0.32); + g.generateTexture('wolf-guard', S, S); + g.destroy(); +} + +function paintItem(scene, key, color) { + const S = 64; + const g = scene.make.graphics({ x: 0, y: 0, add: false }); + g.fillStyle(color, 1); + g.fillRoundedRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7, 8); + g.lineStyle(3, 0x000000, 0.4); + g.strokeRoundedRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7, 8); + g.generateTexture(key, S, S); + g.destroy(); +} + +function paintBullet(scene) { + const S = 16; + const g = scene.make.graphics({ x: 0, y: 0, add: false }); + g.fillStyle(0xfff2a8, 1); + g.fillCircle(S / 2, S / 2, S / 2); + g.generateTexture('wolf-bullet', S, S); + g.destroy(); +} + +function paintMuzzleFlash(scene) { + const S = 48; + const g = scene.make.graphics({ x: 0, y: 0, add: false }); + g.fillStyle(0xfff2a8, 0.9); + g.fillCircle(S / 2, S / 2, S / 2); + g.generateTexture('wolf-muzzle', S, S); + g.destroy(); +} diff --git a/src/games/wolfenstein/WolfensteinEditor.js b/src/games/wolfenstein/WolfensteinEditor.js new file mode 100644 index 0000000..e3aa01d --- /dev/null +++ b/src/games/wolfenstein/WolfensteinEditor.js @@ -0,0 +1,378 @@ +// Wolfenstein level editor. Secret entrance: index.html?wolfenstein-editor=1 +// (see PreloadScene). Mirrors the Super Kart / Goo Tower editor workflow: +// load (existing level from assets/gamedata/wolfenstein/ or any local .json) +// → edit → Test Play → ⬇ Export Level, then hand-drop the file into +// assets/gamedata/wolfenstein/. Validation reuses buildLevelModel/validateLevel +// from WolfensteinLogic.js verbatim — the same functions the runtime loader +// and tools/genWolfenstein.js use — so the editor can never disagree with the +// game about what a legal level is. + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { buildLevelModel, validateLevel } from './WolfensteinLogic.js'; + +const BOARD_X = 70; +const BOARD_Y = 70; +const BOARD_SIZE = 900; +const GAMEDATA = 'assets/gamedata/wolfenstein'; +const FONT = '"Julius Sans One"'; + +const WALL_TOOLS = { wall1: 1, wall2: 2, wall3: 3, wall4: 4 }; +const WALL_COLORS = { 1: 0x8a8a8a, 2: 0x8a5a3a, 3: 0x3a5a8a, 4: 0x5a8a3a }; + +const TOOLS = [ + ['wall1', 'Wall: Stone'], + ['wall2', 'Wall: Wood'], + ['wall3', 'Wall: Blue'], + ['wall4', 'Wall: Green'], + ['door', 'Door'], + ['start', 'Player Start'], + ['exit', 'Exit'], + ['enemy', 'Guard'], + ['ammo', 'Ammo Pickup'], + ['health', 'Health Pickup'], + ['erase', 'Erase'], +]; + +export default class WolfensteinEditor extends Phaser.Scene { + constructor() { super('WolfensteinEditor'); } + + init(data) { this.resume = !!data?.resume; } + + create() { + this.level = this.resume ? (this.registry.get('wolfenstein-editor-state') ?? this.defaultLevel()) : this.defaultLevel(); + this.tool = 'wall1'; + this.undoStack = []; + this.result = { valid: false, issues: [], reachable: false }; + + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x101018); + this.add.rectangle(BOARD_X + BOARD_SIZE / 2, BOARD_Y + BOARD_SIZE / 2, BOARD_SIZE + 8, BOARD_SIZE + 8, 0x000000) + .setStrokeStyle(2, COLORS.accent); + this.g = this.add.graphics().setDepth(5); + + this.manifestLevels = []; + fetch('data/wolfenstein-campaigns.json').then((r) => r.json()) + .then((d) => { + this.manifestLevels = (d.campaigns ?? []).flatMap((c) => c.missions.map((m) => ({ file: m.levelFile, label: `${c.id}/${m.id}` }))); + this.refreshLoadOptions(); + }).catch(() => {}); + + this.buildToolbar(); + this.buildLoadPanel(); + this.bindPointer(); + this.bindKeys(); + this.rebuildModel(); + } + + defaultLevel() { + const W = 12, H = 10; + const walls = Array.from({ length: H }, (_, y) => Array.from({ length: W }, (_, x) => ( + x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0 + ))); + return { + version: 1, id: 'level-new', name: 'New Level', + campaignId: null, missionIndex: 0, + width: W, height: H, cellSize: 64, + walls, playerStart: { x: 1.5, y: 1.5, angle: 0 }, + doors: [], enemies: [], items: [], + exit: { x: W - 1.5, y: H - 1.5, radius: 0.6 }, + briefing: [], + }; + } + + // ── Coordinate transforms ──────────────────────────────────────────────── + + get cellPx() { return Math.min(BOARD_SIZE / this.level.width, BOARD_SIZE / this.level.height); } + toBoard(cx, cy) { const k = this.cellPx; return [BOARD_X + cx * k, BOARD_Y + cy * k]; } + toCell(px, py) { const k = this.cellPx; return [Math.floor((px - BOARD_X) / k), Math.floor((py - BOARD_Y) / k)]; } + onBoard(p) { + const w = this.cellPx * this.level.width, h = this.cellPx * this.level.height; + return p.x >= BOARD_X && p.x <= BOARD_X + w && p.y >= BOARD_Y && p.y <= BOARD_Y + h; + } + + // ── Toolbar ─────────────────────────────────────────────────────────────── + + buildToolbar() { + const tx = BOARD_X + BOARD_SIZE + 90; + const bw = 260; + this.add.text(tx + bw / 2, 34, 'WOLFENSTEIN EDITOR', { fontFamily: FONT, fontSize: '26px', color: COLORS.goldHex }).setOrigin(0.5); + + this.toolButtons = {}; + TOOLS.forEach(([id, label], i) => { + const y = 80 + i * 58; + const btn = new Button(this, tx + bw / 2, y, label, () => this.setTool(id), { width: bw, height: 48, fontSize: 18 }); + this.toolButtons[id] = btn; + }); + this.setTool(this.tool); + + let y = 80 + TOOLS.length * 58 + 30; + this.metaName = new Button(this, tx + bw / 2, y, `Name: ${this.level.name}`, () => this.renameLevel(), { width: bw, height: 48, fontSize: 16, variant: 'ghost' }); y += 58; + new Button(this, tx + bw / 2, y, 'Resize Grid', () => this.resizeLevel(), { width: bw, height: 48, fontSize: 18 }); y += 58; + new Button(this, tx + bw / 2, y, 'New Blank Level', () => this.newLevel(), { width: bw, height: 48, fontSize: 18 }); y += 66; + + this.issueText = this.add.text(tx, y, '', { + fontFamily: FONT, fontSize: '16px', color: COLORS.dangerHex, wordWrap: { width: bw + 40 }, + }); y += 100; + + new Button(this, tx + bw / 2, y, '▶ Test Play', () => this.testPlay(), { width: bw, height: 56, fontSize: 20 }); y += 66; + new Button(this, tx + bw / 2, y, '⬇ Export Level', () => this.exportLevel(), { width: bw, height: 56, fontSize: 20 }); + } + + setTool(id) { + this.tool = id; + for (const [key, btn] of Object.entries(this.toolButtons)) btn.setActive(key === id); + } + + renameLevel() { + const next = window.prompt('Level name:', this.level.name); + if (next) { this.pushUndo(); this.level.name = next; this.metaName.setLabel(`Name: ${next}`); this.rebuildModel(); } + } + + resizeLevel() { + const wStr = window.prompt('Grid width (cells):', String(this.level.width)); + if (!wStr) return; + const hStr = window.prompt('Grid height (cells):', String(this.level.height)); + if (!hStr) return; + const w = Phaser.Math.Clamp(parseInt(wStr, 10) || this.level.width, 4, 48); + const h = Phaser.Math.Clamp(parseInt(hStr, 10) || this.level.height, 4, 48); + this.pushUndo(); + const walls = Array.from({ length: h }, (_, y) => Array.from({ length: w }, (_, x) => { + const border = x === 0 || y === 0 || x === w - 1 || y === h - 1; + const old = this.level.walls[y]?.[x]; + return border ? 1 : (old ?? 0); + })); + this.level = { + ...this.level, width: w, height: h, walls, + doors: this.level.doors.filter((d) => d.x < w - 1 && d.y < h - 1), + enemies: this.level.enemies.filter((e) => e.x < w - 1 && e.y < h - 1), + items: this.level.items.filter((it) => it.x < w - 1 && it.y < h - 1), + playerStart: this.level.playerStart && this.level.playerStart.x < w - 1 && this.level.playerStart.y < h - 1 ? this.level.playerStart : null, + exit: this.level.exit && this.level.exit.x < w - 1 && this.level.exit.y < h - 1 ? this.level.exit : null, + }; + this.rebuildModel(); + } + + newLevel() { this.pushUndo(); this.level = this.defaultLevel(); this.metaName.setLabel(`Name: ${this.level.name}`); this.rebuildModel(); } + + // ── Load panel (DOM) ──────────────────────────────────────────────────── + + buildLoadPanel() { + const rx = BOARD_X + BOARD_SIZE + 90 + 260 + 40; + const rw = 420; + const input = 'background:#1e1a12; color:#f2ead8; border:1px solid #c8a84b; border-radius:6px; padding:7px 9px; font-size:15px; width:100%;'; + const el = document.createElement('div'); + el.style.cssText = `width:${rw}px; font-family:"Julius Sans One",sans-serif; color:${COLORS.textHex};`; + el.innerHTML = ` +

+
LOAD LEVEL
+ + +
+
`; + this.loadDom = this.add.dom(rx + rw / 2, 200, el).setDepth(20); + const q = (id) => el.querySelector(id); + this.loadSelect = q('#wf-load'); + this.loadWarn = q('#wf-load-warn'); + + this.loadSelect.addEventListener('change', (ev) => { + const file = ev.target.value; + if (file) this.loadFromManifest(file); + ev.target.value = ''; + }); + q('#wf-fileinput').addEventListener('change', (ev) => { + const f = ev.target.files?.[0]; + if (!f) return; + const reader = new FileReader(); + reader.onload = () => { + try { this.loadLevelData(JSON.parse(reader.result), f.name); } catch (_) { this.flashLoadWarn(`Could not parse ${f.name}`); } + }; + reader.readAsText(f); + ev.target.value = ''; + }); + } + + refreshLoadOptions() { + if (!this.loadSelect) return; + const opts = [``, + ...this.manifestLevels.map((m) => ``)].join(''); + this.loadSelect.innerHTML = opts; + } + + flashLoadWarn(msg) { + if (!this.loadWarn) return; + this.loadWarn.textContent = msg; + this.time.delayedCall(4000, () => { if (this.loadWarn) this.loadWarn.textContent = ''; }); + } + + async loadFromManifest(file) { + try { + const res = await fetch(`${GAMEDATA}/${file}`); + if (!res.ok) throw new Error('not found'); + this.loadLevelData(await res.json(), file); + } catch (_) { this.flashLoadWarn(`Could not load ${file}`); } + } + + loadLevelData(data, fileName) { + if (!data || !Array.isArray(data.walls) || !data.width || !data.height) { + this.flashLoadWarn(`${fileName}: not a valid level file`); + return; + } + this.pushUndo(); + const clone = JSON.parse(JSON.stringify(data)); + this.level = { + ...clone, + doors: clone.doors ?? [], + enemies: clone.enemies ?? [], + items: clone.items ?? [], + playerStart: clone.playerStart ?? null, + exit: clone.exit ?? null, + }; + this.metaName?.setLabel(`Name: ${this.level.name}`); + this.rebuildModel(); + this.flashLoadWarn(`Loaded ${fileName}`); + } + + // ── Input ───────────────────────────────────────────────────────────────── + + bindPointer() { + this.input.on('pointerdown', (p) => this.handleClick(p, true)); + this.input.on('pointermove', (p) => { if (p.isDown) this.handleClick(p, false); }); + } + + handleClick(p, isDown) { + if (!this.onBoard(p)) return; + const [cx, cy] = this.toCell(p.x, p.y); + if (cx < 0 || cy < 0 || cx >= this.level.width || cy >= this.level.height) return; + const paintTools = new Set(['wall1', 'wall2', 'wall3', 'wall4', 'erase']); + if (!isDown && !paintTools.has(this.tool)) return; + if (isDown) this.pushUndo(); + this.applyTool(cx, cy); + this.rebuildModel(); + } + + applyTool(x, y) { + const lvl = this.level; + if (WALL_TOOLS[this.tool]) { lvl.walls[y][x] = WALL_TOOLS[this.tool]; this.clearEntitiesAt(x, y); return; } + if (this.tool === 'erase') { lvl.walls[y][x] = 0; this.clearEntitiesAt(x, y); return; } + + lvl.walls[y][x] = 0; // every remaining tool places on floor + if (this.tool === 'door') { + const i = lvl.doors.findIndex((d) => d.x === x && d.y === y); + if (i >= 0) lvl.doors.splice(i, 1); else lvl.doors.push({ x, y, orientation: 'vertical' }); + } else if (this.tool === 'start') { + lvl.playerStart = { x: x + 0.5, y: y + 0.5, angle: 0 }; + } else if (this.tool === 'exit') { + lvl.exit = { x: x + 0.5, y: y + 0.5, radius: 0.6 }; + } else if (this.tool === 'enemy') { + const i = lvl.enemies.findIndex((e) => Math.floor(e.x) === x && Math.floor(e.y) === y); + if (i >= 0) lvl.enemies.splice(i, 1); else lvl.enemies.push({ type: 'guard', x: x + 0.5, y: y + 0.5, facing: 180 }); + } else if (this.tool === 'ammo' || this.tool === 'health') { + const i = lvl.items.findIndex((it) => Math.floor(it.x) === x && Math.floor(it.y) === y); + if (i >= 0) lvl.items.splice(i, 1); else lvl.items.push({ type: this.tool, x: x + 0.5, y: y + 0.5 }); + } + } + + clearEntitiesAt(x, y) { + const lvl = this.level; + lvl.doors = lvl.doors.filter((d) => !(d.x === x && d.y === y)); + lvl.enemies = lvl.enemies.filter((e) => !(Math.floor(e.x) === x && Math.floor(e.y) === y)); + lvl.items = lvl.items.filter((it) => !(Math.floor(it.x) === x && Math.floor(it.y) === y)); + if (lvl.playerStart && Math.floor(lvl.playerStart.x) === x && Math.floor(lvl.playerStart.y) === y) lvl.playerStart = null; + if (lvl.exit && Math.floor(lvl.exit.x) === x && Math.floor(lvl.exit.y) === y) lvl.exit = null; + } + + bindKeys() { + this.input.keyboard.on('keydown-Z', (ev) => { if (ev.ctrlKey || ev.metaKey) this.undo(); }); + } + + // ── Undo / model ────────────────────────────────────────────────────────── + + pushUndo() { + this.undoStack.push(JSON.stringify(this.level)); + if (this.undoStack.length > 60) this.undoStack.shift(); + } + undo() { + const snap = this.undoStack.pop(); + if (!snap) return; + this.level = JSON.parse(snap); + this.rebuildModel(); + } + + rebuildModel() { + try { + const model = buildLevelModel(this.level); + this.result = validateLevel(model); + } catch (err) { + this.result = { valid: false, issues: [String(err.message ?? err)], reachable: false }; + } + this.issueText?.setText(this.result.issues.length ? `⚠ ${this.result.issues.slice(0, 4).join('\n')}` : 'Level OK'); + this.draw(); + } + + // ── Drawing ─────────────────────────────────────────────────────────────── + + draw() { + const g = this.g; + g.clear(); + const k = this.cellPx; + const lvl = this.level; + + for (let y = 0; y < lvl.height; y++) { + for (let x = 0; x < lvl.width; x++) { + const wt = lvl.walls[y][x]; + const [bx, by] = this.toBoard(x, y); + g.fillStyle(wt > 0 ? (WALL_COLORS[wt] ?? 0x888888) : 0x2a2a32, 1); + g.fillRect(bx, by, k - 1, k - 1); + } + } + g.lineStyle(1, 0x000000, 0.3); + for (let x = 0; x <= lvl.width; x++) { const [bx] = this.toBoard(x, 0); g.lineBetween(bx, BOARD_Y, bx, BOARD_Y + lvl.height * k); } + for (let y = 0; y <= lvl.height; y++) { const [, by] = this.toBoard(0, y); g.lineBetween(BOARD_X, by, BOARD_X + lvl.width * k, by); } + + g.fillStyle(0xb08040, 1); + for (const d of lvl.doors) { const [bx, by] = this.toBoard(d.x, d.y); g.fillRect(bx, by, k - 1, k - 1); } + + if (lvl.playerStart) { + const [bx, by] = this.toBoard(lvl.playerStart.x, lvl.playerStart.y); + g.fillStyle(0x38b048, 1); g.fillCircle(bx, by, k * 0.35); + } + if (lvl.exit) { + const [bx, by] = this.toBoard(lvl.exit.x, lvl.exit.y); + g.fillStyle(0x68d0ff, 1); g.fillCircle(bx, by, k * 0.35); + } + g.fillStyle(0xd83030, 1); + for (const e of lvl.enemies) { const [bx, by] = this.toBoard(e.x, e.y); g.fillCircle(bx, by, k * 0.3); } + g.fillStyle(0xd4a017, 1); + for (const it of lvl.items) { + const [bx, by] = this.toBoard(it.x, it.y); + if (it.type === 'ammo') g.fillRect(bx - k * 0.15, by - k * 0.15, k * 0.3, k * 0.3); + else { g.fillStyle(0xe06c75, 1); g.fillCircle(bx, by, k * 0.2); g.fillStyle(0xd4a017, 1); } + } + } + + // ── Test play / export ─────────────────────────────────────────────────── + + testPlay() { + if (this.result.issues.length) return; + this.registry.set('wolfenstein-editor-state', JSON.parse(JSON.stringify(this.level))); + this.scene.start('WolfensteinGame', { + game: { slug: 'wolfenstein', name: 'Wolfenstein 3D' }, + testLevel: JSON.parse(JSON.stringify(this.level)), + returnToEditor: true, + }); + } + + download(name, obj) { + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([`${JSON.stringify(obj, null, 2)}\n`], { type: 'application/json' })); + a.download = name; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 5000); + } + + exportLevel() { + if (this.result.issues.length) return; + this.download(`level-${this.level.id}.json`, this.level); + } +} diff --git a/src/games/wolfenstein/WolfensteinGame.js b/src/games/wolfenstein/WolfensteinGame.js new file mode 100644 index 0000000..51b8c36 --- /dev/null +++ b/src/games/wolfenstein/WolfensteinGame.js @@ -0,0 +1,396 @@ +// Main scene — a thin coordinator, the TotalAnnihilationGame.js role: each +// frame it steps the headless sim, replays events for HUD feedback, and +// renders the resulting state. It owns no game rules itself. +// +// Pointer-lock mouse-look is the first code of its kind in this repo (no +// existing game uses requestPointerLock/movementX) — isolated here behind a +// pointerlockchange listener that doubles as the pause trigger: losing the +// lock (click away, Esc) always pauses the sim, so nothing can keep taking +// damage while the player isn't actually looking at the screen. + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { compileRules } from './WolfensteinRules.js'; +import * as Logic from './WolfensteinLogic.js'; +import WolfensteinView, { VIEW_H } from './WolfensteinView.js'; +import * as Screens from './WolfensteinScreens.js'; +import { makeCamera } from './WolfensteinRaycaster.js'; + +const SAVE_KEY = 'wolfenstein-save'; +const SAVE_SLOT_COUNT = 4; +const saveSlotKey = (i) => `wolfenstein-save-slot-${i}`; +const PROGRESS_KEY = 'wolfenstein-progress'; + +export default class WolfensteinGame extends Phaser.Scene { + constructor() { super('WolfensteinGame'); } + + init(data) { + this.gameDef = data.game ?? null; + this.testLevel = data.testLevel ?? null; + this.returnToEditor = !!data.returnToEditor; + } + + create() { + const rulesJson = this.cache.json.get('wolfenstein-rules'); + this.campaigns = this.cache.json.get('wolfenstein-campaigns') ?? { campaigns: [] }; + this.rules = compileRules(rulesJson); + + this.phase = 'menu'; + this.currentScreen = null; + this.state = null; + this.view = null; + this.meta = null; + this._locked = false; + this._mouseFireHeld = false; + this._lastAutosave = 0; + + this.keys = this.input.keyboard.addKeys('W,A,S,D,CTRL,ONE,TWO,ESC'); + + this._bindPointerLock(); + + this.hud = this._buildHud(); + this._setHudVisible(false); + + this.events.once('shutdown', () => this._teardown()); + + if (this.testLevel) { + this._beginLevel(this.testLevel, { mode: 'test' }); + } else { + this.showMainMenu(); + } + } + + // ------------------------------------------------------------- screens + + swapScreen(builder) { + this.currentScreen?.destroy(); + this.currentScreen = builder; + } + + showMainMenu() { + this.phase = 'menu'; + this._setHudVisible(false); + this.swapScreen(Screens.mainMenu(this, { + hasContinue: !!this._readLocal(SAVE_KEY), + onNewGame: () => this.showCampaignSelect(), + onContinue: () => this._loadAutosave(), + onLoad: () => this.showLoadScreen('load'), + onLeave: () => this.scene.start('GameMenu'), + })); + } + + showCampaignSelect() { + const progress = this._readProgress(); + this.swapScreen(Screens.campaignSelect(this, { + campaigns: this.campaigns.campaigns, progress, + onBack: () => this.showMainMenu(), + onPick: (campaignId) => this.showCampaignList(campaignId), + })); + } + + showCampaignList(campaignId) { + const camp = this.campaigns.campaigns.find((c) => c.id === campaignId); + const cleared = this._readProgress()[campaignId] ?? 0; + this.swapScreen(Screens.campaignList(this, camp, cleared, { + onBack: () => this.showCampaignSelect(), + onPick: (missionIndex) => this.startMission(campaignId, missionIndex), + })); + } + + showLoadScreen(mode) { + this.swapScreen(Screens.saveLoadScreen(this, { + mode, slots: this.allSaveSlotMeta(), + onBack: () => (this.phase === 'paused' ? this.swapScreen(this._pauseScreen()) : this.showMainMenu()), + onPick: (i) => { + if (mode === 'save') { this.writeSaveSlot(i); this.showLoadScreen(mode); return; } + const restored = this.loadSaveSlot(i); + if (restored) this._resumeState(restored); + }, + onDelete: (i) => { this.deleteSaveSlot(i); this.showLoadScreen(mode); }, + })); + } + + _pauseScreen() { + return Screens.pause(this, { + onResume: () => this._resumePlay(), + onSave: () => this.showLoadScreen('save'), + onLoad: () => this.showLoadScreen('load'), + onExitToEditor: this.returnToEditor ? () => this.scene.start('WolfensteinEditor', { resume: true }) : null, + onQuit: () => { this._teardownLevel(); this.showMainMenu(); }, + }); + } + + // ------------------------------------------------------------- missions + + async startMission(campaignId, missionIndex) { + const camp = this.campaigns.campaigns.find((c) => c.id === campaignId); + const mission = camp.missions[missionIndex]; + const levelJson = await this._loadLevelJson(`wolfenstein-level-${mission.id}`, `assets/gamedata/wolfenstein/${mission.levelFile}`); + this._beginLevel(levelJson, { mode: 'campaign', campaignId, missionIndex }); + } + + _loadLevelJson(key, path) { + return new Promise((resolve) => { + if (this.cache.json.has(key)) { resolve(this.cache.json.get(key)); return; } + this.load.json(key, path); + this.load.once('complete', () => resolve(this.cache.json.get(key))); + this.load.start(); + }); + } + + _beginLevel(levelJson, meta) { + const model = Logic.buildLevelModel(levelJson); + this.state = Logic.createState(model, this.rules); + this.meta = meta; + this.view?.destroy(); + this.view = new WolfensteinView(this, this.rules); + this.swapScreen(null); + this._setHudVisible(true); + this._lastAutosave = this.time.now; + this.phase = 'playing'; + this._requestLock(); + } + + _resumeState(restored) { + this.state = restored; + this.meta = { mode: 'campaign', campaignId: restored.levelMeta.campaignId, missionIndex: restored.levelMeta.missionIndex }; + this.view?.destroy(); + this.view = new WolfensteinView(this, this.rules); + this.swapScreen(null); + this._setHudVisible(true); + this._lastAutosave = this.time.now; + this.phase = 'playing'; + this._requestLock(); + } + + _resumePlay() { + this.phase = 'playing'; + this.swapScreen(null); + this._setHudVisible(true); + this._requestLock(); + } + + _teardownLevel() { + this.view?.destroy(); + this.view = null; + this.state = null; + this._setHudVisible(false); + this._exitLock(); + } + + // ------------------------------------------------------------- input + + _bindPointerLock() { + const canvas = this.sys.game.canvas; + this._canvas = canvas; + + this._onLockChange = () => { + const locked = document.pointerLockElement === canvas; + this._locked = locked; + if (!locked && this.phase === 'playing') { + this.phase = 'paused'; + this.swapScreen(this._pauseScreen()); + } + }; + document.addEventListener('pointerlockchange', this._onLockChange); + + this._onMouseMove = (e) => { + if (!this._locked || this.phase !== 'playing' || !this.state) return; + Logic.queueTurn(this.state, e.movementX * this.rules.constants.mouseSensitivity); + }; + document.addEventListener('mousemove', this._onMouseMove); + + this.input.on('pointerdown', () => { + if (this.phase !== 'playing') return; + if (!this._locked) { this._requestLock(); return; } + this._mouseFireHeld = true; + }); + this.input.on('pointerup', () => { this._mouseFireHeld = false; }); + } + + _requestLock() { this._canvas?.requestPointerLock?.(); } + _exitLock() { if (document.pointerLockElement) document.exitPointerLock?.(); } + + _pollKeys() { + if (!this.state) return; + const k = this.keys; + const forward = (k.W.isDown ? 1 : 0) - (k.S.isDown ? 1 : 0); + const strafe = (k.D.isDown ? 1 : 0) - (k.A.isDown ? 1 : 0); + Logic.setMoveIntent(this.state, forward, strafe); + Logic.setFireHeld(this.state, this._mouseFireHeld || k.CTRL.isDown); + if (Phaser.Input.Keyboard.JustDown(k.ONE)) Logic.switchWeapon(this.state, 'fists'); + if (Phaser.Input.Keyboard.JustDown(k.TWO)) Logic.switchWeapon(this.state, 'pistol'); + } + + // ------------------------------------------------------------- loop + + update(time, delta) { + if (this.phase !== 'playing' || !this.state) return; + this._pollKeys(); + const events = Logic.step(this.state, this.rules, delta, 4); + for (const ev of events) this._onSimEvent(ev); + + const p = this.state.player; + const camera = makeCamera(p.x, p.y, p.angle, this.rules.fov); + this.view.render(this.state, camera); + this._updateHud(); + + if (this.meta?.mode === 'campaign' && time - this._lastAutosave > 60000) { + this.writeSave(); + this._lastAutosave = time; + } + + if (this.state.result === 'won') this._onMissionWon(); + else if (this.state.result === 'lost') this._onMissionLost(); + } + + _onSimEvent(ev) { + if (ev.t === 'pickup') this._toast(`Picked up ${ev.itemId}`); + } + + _onMissionWon() { + this._exitLock(); + this._setHudVisible(false); + this.phase = 'won'; + let hasNext = false; + if (this.meta?.mode === 'campaign') { + const camp = this.campaigns.campaigns.find((c) => c.id === this.meta.campaignId); + this._recordMissionCleared(this.meta.campaignId, this.meta.missionIndex); + hasNext = this.meta.missionIndex + 1 < camp.missions.length; + this.clearSave(); + } + this.swapScreen(Screens.resultScreen(this, { + won: true, missionName: this.state.levelMeta.name, hasNext, + onRetry: () => this._retry(), + onNext: () => this.startMission(this.meta.campaignId, this.meta.missionIndex + 1), + onMenu: () => { this._teardownLevel(); this.showMainMenu(); }, + })); + } + + _onMissionLost() { + this._exitLock(); + this._setHudVisible(false); + this.phase = 'lost'; + if (this.meta?.mode === 'campaign') this.clearSave(); + this.swapScreen(Screens.resultScreen(this, { + won: false, missionName: this.state.levelMeta.name, hasNext: false, + onRetry: () => this._retry(), + onMenu: () => { this._teardownLevel(); this.showMainMenu(); }, + })); + } + + _retry() { + if (this.meta?.mode === 'test' && this.testLevel) { this._beginLevel(this.testLevel, { mode: 'test' }); return; } + if (this.meta?.mode === 'campaign') this.startMission(this.meta.campaignId, this.meta.missionIndex); + } + + // ------------------------------------------------------------- HUD + + _buildHud() { + const y = GAME_HEIGHT - 70; + const objs = {}; + objs.bar = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT - 70, GAME_WIDTH, 140, 0x0a0806, 0.92).setDepth(20); + objs.health = this.add.text(80, y, '', { fontFamily: '"Julius Sans One"', fontSize: '34px', color: COLORS.textHex }).setOrigin(0, 0.5).setDepth(21); + objs.weapon = this.add.text(GAME_WIDTH / 2, y, '', { fontFamily: '"Julius Sans One"', fontSize: '34px', color: COLORS.textHex }).setOrigin(0.5).setDepth(21); + objs.ammo = this.add.text(GAME_WIDTH - 80, y, '', { fontFamily: '"Julius Sans One"', fontSize: '34px', color: COLORS.textHex }).setOrigin(1, 0.5).setDepth(21); + objs.toast = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 170, '', { fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(21).setAlpha(0); + objs.crosshair = this.add.text(GAME_WIDTH / 2, VIEW_H / 2, '+', { fontSize: '38px', color: COLORS.textHex }).setOrigin(0.5).setDepth(15); + objs.lockHint = this.add.text(GAME_WIDTH / 2, 60, 'Click to aim', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0.5).setDepth(21); + return objs; + } + + _setHudVisible(visible) { + for (const o of Object.values(this.hud)) o.setVisible(visible); + if (visible) this.hud.lockHint.setVisible(!this._locked); + } + + _updateHud() { + const p = this.state.player; + this.hud.health.setText(`HP ${Math.ceil(p.health)}`); + this.hud.weapon.setText(p.weapon.toUpperCase()); + const w = this.rules.weaponById[p.weapon]; + this.hud.ammo.setText(w.kind === 'projectile' ? `AMMO ${p.ammo[p.weapon] ?? 0}` : ''); + this.hud.lockHint.setVisible(!this._locked); + } + + _toast(msg) { + this.hud.toast.setText(msg).setAlpha(1); + this.tweens.killTweensOf(this.hud.toast); + this.tweens.add({ targets: this.hud.toast, alpha: 0, delay: 900, duration: 400 }); + } + + // ------------------------------------------------------------- progress + + _readProgress() { try { return JSON.parse(window.localStorage.getItem(PROGRESS_KEY)) ?? {}; } catch { return {}; } } + _writeProgress(obj) { try { window.localStorage.setItem(PROGRESS_KEY, JSON.stringify(obj)); } catch { /* storage may be unavailable */ } } + _recordMissionCleared(campaignId, missionIndex) { + const prog = this._readProgress(); + prog[campaignId] = Math.max(prog[campaignId] ?? 0, missionIndex + 1); + this._writeProgress(prog); + } + + // ------------------------------------------------------------- save/load + + _readLocal(key) { try { return window.localStorage.getItem(key); } catch { return null; } } + + writeSave() { + try { + if (this.state && !this.state.result) window.localStorage.setItem(SAVE_KEY, Logic.serialize(this.state)); + } catch { /* storage may be unavailable */ } + } + readSave() { + try { + const raw = window.localStorage.getItem(SAVE_KEY); + return raw ? Logic.deserialize(this.rules, raw) : null; + } catch { return null; } + } + clearSave() { try { window.localStorage.removeItem(SAVE_KEY); } catch { /* ignore */ } } + _loadAutosave() { + const restored = this.readSave(); + if (restored) this._resumeState(restored); + } + + writeSaveSlot(i) { + try { + const p = this.state.player; + const meta = { + savedAt: Date.now(), + campaignId: this.meta?.campaignId ?? null, + missionIndex: this.meta?.missionIndex ?? 0, + missionName: this.state.levelMeta.name, + health: Math.ceil(p.health), ammo: p.ammo[p.weapon] ?? 0, weapon: p.weapon, + }; + window.localStorage.setItem(saveSlotKey(i), JSON.stringify({ meta, raw: Logic.serialize(this.state) })); + return true; + } catch { return false; } + } + readSaveSlotMeta(i) { + try { + const raw = window.localStorage.getItem(saveSlotKey(i)); + return raw ? (JSON.parse(raw).meta ?? null) : null; + } catch { return null; } + } + loadSaveSlot(i) { + try { + const raw = window.localStorage.getItem(saveSlotKey(i)); + if (!raw) return null; + return Logic.deserialize(this.rules, JSON.parse(raw).raw); + } catch { return null; } + } + deleteSaveSlot(i) { try { window.localStorage.removeItem(saveSlotKey(i)); } catch { /* ignore */ } } + allSaveSlotMeta() { + const out = []; + for (let i = 0; i < SAVE_SLOT_COUNT; i++) out.push(this.readSaveSlotMeta(i)); + return out; + } + hasAnySaveSlot() { return this.allSaveSlotMeta().some(Boolean); } + + // ------------------------------------------------------------- teardown + + _teardown() { + document.removeEventListener('pointerlockchange', this._onLockChange); + document.removeEventListener('mousemove', this._onMouseMove); + this._exitLock(); + this.view?.destroy(); + } +} diff --git a/src/games/wolfenstein/WolfensteinLogic.js b/src/games/wolfenstein/WolfensteinLogic.js new file mode 100644 index 0000000..f9ff292 --- /dev/null +++ b/src/games/wolfenstein/WolfensteinLogic.js @@ -0,0 +1,571 @@ +// Headless simulation core — the TALogic.js idiom. No Phaser import, so the +// whole sim is unit-testable under Node (tools/verifyWolfenstein.js). +// +// step(state, rules, deltaMs, maxSteps, onTick) drains a fixed-tick +// accumulator and returns the tick events; state.alpha is left set for the +// view's render interpolation. Bullets use SWEPT-SEGMENT collision against +// both the wall grid and live targets (stepProjectiles below) — a point +// sample at the tick's end position would let a fast pistol round tunnel +// through a thin wall or a target it crossed mid-tick. + +import { castRay, hasLineOfSight, fullMapSteps } from './WolfensteinRaycaster.js'; + +export const SAVE_VERSION = 1; + +// A door cell reads as a solid wall to the raycaster/collision while closed, +// but — unlike a real wall — must count as passable for level-reachability +// validation, since a player can always open it. +export const DOOR_WALL_TYPE = 9; + +// --------------------------------------------------------------------------- +// State construction +// --------------------------------------------------------------------------- + +export function createState(level, rules) { + const walls = level.walls.map((row) => row.slice()); + const doors = (level.doors ?? []).map((d, i) => ({ + id: i, x: d.x, y: d.y, orientation: d.orientation ?? 'vertical', open: false, timer: 0, + })); + for (const d of doors) walls[d.y][d.x] = DOOR_WALL_TYPE; + + const enemies = (level.enemies ?? []).map((e, i) => ({ + id: i, defId: e.type, x: e.x, y: e.y, angle: ((e.facing ?? 0) * Math.PI) / 180, + health: rules.enemyById[e.type].health, state: 'idle', cooldownMs: 0, dead: false, + })); + const pickups = (level.items ?? []).map((it, i) => ({ id: i, itemId: it.type, x: it.x, y: it.y, taken: false })); + + const ammo = {}; + for (const w of rules.weapons) if (w.kind === 'projectile') ammo[w.id] = w.startAmmo ?? 0; + + return { + tick: 0, accumulatorMs: 0, alpha: 0, + map: { width: level.width, height: level.height, walls }, + player: { + x: level.playerStart.x, y: level.playerStart.y, + angle: ((level.playerStart.angle ?? 0) * Math.PI) / 180, + health: rules.constants.playerMaxHealth, + weapons: ['fists', 'pistol'], weapon: 'pistol', ammo, + pendingTurn: 0, moveForward: 0, moveStrafe: 0, fireHeld: false, + weaponCooldownMs: 0, radius: rules.constants.playerRadius, dead: false, + }, + enemies, projectiles: [], pickups, doors, + exit: { ...level.exit }, + events: [], nextProjectileId: 1, result: null, + levelMeta: { + id: level.id, name: level.name, + campaignId: level.campaignId ?? null, missionIndex: level.missionIndex ?? 0, + }, + }; +} + +// --------------------------------------------------------------------------- +// Input intents — the scene calls these; tick() consumes them +// --------------------------------------------------------------------------- + +const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); + +export function setMoveIntent(state, forward, strafe) { + state.player.moveForward = clamp(forward, -1, 1); + state.player.moveStrafe = clamp(strafe, -1, 1); +} +export function queueTurn(state, deltaRad) { state.player.pendingTurn += deltaRad; } +export function setFireHeld(state, held) { state.player.fireHeld = held; } +export function switchWeapon(state, weaponId) { + if (state.player.weapons.includes(weaponId)) state.player.weapon = weaponId; +} + +// --------------------------------------------------------------------------- +// Fixed-tick step +// --------------------------------------------------------------------------- + +export function tick(state, rules) { + state.events = []; + stepPlayer(state, rules); + stepDoors(state, rules); + stepEnemyAI(state, rules); + stepProjectiles(state, rules); + stepPickups(state, rules); + checkResult(state, rules); + state.tick++; + return state.events; +} + +/** + * Drive the sim from a wall-clock delta, draining whole fixed steps. The + * accumulator lives in state so the verify script runs the identical loop + * headlessly, and state.alpha gives the view its interpolation factor. + * @param {function} [onTick] runs just before each fixed step. + */ +export function step(state, rules, deltaMs, maxSteps = 4, onTick = null) { + const out = []; + state.accumulatorMs += deltaMs; + let n = 0; + while (state.accumulatorMs >= rules.stepMs && n < maxSteps) { + state.accumulatorMs -= rules.stepMs; + if (onTick) onTick(state); + const ev = tick(state, rules); + for (let i = 0; i < ev.length; i++) out.push(ev[i]); + n++; + if (state.result) break; + } + if (n === maxSteps && state.accumulatorMs >= rules.stepMs) state.accumulatorMs = 0; + state.alpha = Math.min(1, state.accumulatorMs / rules.stepMs); + return out; +} + +// --------------------------------------------------------------------------- +// Player movement + wall collision (circle vs. grid cells, axis-slide) +// --------------------------------------------------------------------------- + +function wrapAngle(a) { + a %= Math.PI * 2; + return a < 0 ? a + Math.PI * 2 : a; +} + +function isWallCell(map, cx, cy) { + if (cx < 0 || cy < 0 || cx >= map.width || cy >= map.height) return true; + return map.walls[cy][cx] > 0; +} + +function circleHitsWall(map, x, y, radius) { + const minX = Math.floor(x - radius), maxX = Math.floor(x + radius); + const minY = Math.floor(y - radius), maxY = Math.floor(y + radius); + for (let cy = minY; cy <= maxY; cy++) { + for (let cx = minX; cx <= maxX; cx++) { + if (!isWallCell(map, cx, cy)) continue; + const nx = Math.max(cx, Math.min(x, cx + 1)); + const ny = Math.max(cy, Math.min(y, cy + 1)); + if ((x - nx) ** 2 + (y - ny) ** 2 < radius * radius) return true; + } + } + return false; +} + +function moveWithCollision(map, body, dx, dy, radius) { + if (!circleHitsWall(map, body.x + dx, body.y, radius)) body.x += dx; + if (!circleHitsWall(map, body.x, body.y + dy, radius)) body.y += dy; +} + +function stepPlayer(state, rules) { + const p = state.player; + if (p.dead) return; + + p.angle = wrapAngle(p.angle + p.pendingTurn); + p.pendingTurn = 0; + + const dirX = Math.cos(p.angle), dirY = Math.sin(p.angle); + const strafeX = -dirY, strafeY = dirX; + const speed = rules.constants.playerSpeed * rules.dt; + const mvx = (dirX * p.moveForward + strafeX * p.moveStrafe) * speed; + const mvy = (dirY * p.moveForward + strafeY * p.moveStrafe) * speed; + moveWithCollision(state.map, p, mvx, mvy, p.radius); + + if (p.weaponCooldownMs > 0) p.weaponCooldownMs -= rules.stepMs; + if (p.fireHeld && p.weaponCooldownMs <= 0) fireWeapon(state, rules); +} + +// --------------------------------------------------------------------------- +// Doors — open on proximity, auto-close once nobody is near +// --------------------------------------------------------------------------- + +function isNear(state, x, y, radius) { + const p = state.player; + if (!p.dead && Math.hypot(p.x - x, p.y - y) < radius) return true; + for (const e of state.enemies) if (!e.dead && Math.hypot(e.x - x, e.y - y) < radius) return true; + return false; +} + +function stepDoors(state, rules) { + for (const d of state.doors) { + const near = isNear(state, d.x + 0.5, d.y + 0.5, 0.9); + if (near) { + if (!d.open) { d.open = true; state.events.push({ t: 'doorOpen', x: d.x, y: d.y }); } + d.timer = rules.constants.doorAutoCloseMs; + state.map.walls[d.y][d.x] = 0; + } else if (d.open) { + d.timer -= rules.stepMs; + if (d.timer <= 0) { + d.open = false; + state.map.walls[d.y][d.x] = DOOR_WALL_TYPE; + state.events.push({ t: 'doorClose', x: d.x, y: d.y }); + } + } + } +} + +// --------------------------------------------------------------------------- +// Enemy AI — idle -> alert -> chase -> attack -> dead +// --------------------------------------------------------------------------- + +function stepEnemyAI(state, rules) { + const p = state.player; + for (const e of state.enemies) { + if (e.dead) continue; + const def = rules.enemyById[e.defId]; + if (p.dead) { e.state = 'idle'; continue; } + + const distToPlayer = Math.hypot(p.x - e.x, p.y - e.y); + const canSee = distToPlayer <= def.detectRange && hasLineOfSight(state.map, e.x, e.y, p.x, p.y); + + if (e.state === 'idle') { + if (canSee) { e.state = 'alert'; state.events.push({ t: 'enemyAlert', id: e.id }); } + continue; + } + if (canSee) e.state = 'chase'; + + e.angle = Math.atan2(p.y - e.y, p.x - e.x); + if (canSee && distToPlayer <= def.fireRange) { + e.state = 'attack'; + if (e.cooldownMs > 0) e.cooldownMs -= rules.stepMs; + if (e.cooldownMs <= 0) { + if (distToPlayer <= def.meleeRange) { + applyDamageToPlayer(state, def.meleeDamage); + e.cooldownMs = def.meleeCooldownMs; + state.events.push({ t: 'enemyMelee', id: e.id }); + } else { + spawnProjectile(state, rules, { + ownerId: `enemy:${e.id}`, x: e.x, y: e.y, angle: e.angle, weapon: 'guard-shot', + speed: def.projectileSpeed, damage: def.projectileDamage, hitRadius: def.hitRadius, + ttlSec: 3, friendly: false, + }); + e.cooldownMs = def.fireCooldownMs; + } + } + } else { + const dist = Math.max(distToPlayer, 1e-4); + const mvx = ((p.x - e.x) / dist) * def.speed * rules.dt; + const mvy = ((p.y - e.y) / dist) * def.speed * rules.dt; + moveWithCollision(state.map, e, mvx, mvy, def.radius); + } + } +} + +// --------------------------------------------------------------------------- +// Weapons +// --------------------------------------------------------------------------- + +function isInMeleeArc(px, py, pAngle, tx, ty, range, arcRad) { + const dx = tx - px, dy = ty - py; + if (Math.hypot(dx, dy) > range) return false; + const toTarget = Math.atan2(dy, dx); + let diff = toTarget - pAngle; + diff = Math.atan2(Math.sin(diff), Math.cos(diff)); + return Math.abs(diff) <= arcRad / 2; +} + +export function fireWeapon(state, rules) { + const p = state.player; + const w = rules.weaponById[p.weapon]; + if (!w) return; + + if (w.kind === 'projectile' && (p.ammo[w.id] ?? 0) <= 0) { + p.weaponCooldownMs = 150; + state.events.push({ t: 'weaponEmpty', weapon: w.id }); + return; + } + p.weaponCooldownMs = w.cooldownMs; + state.events.push({ t: 'weaponFired', weapon: w.id, x: p.x, y: p.y }); + + if (w.kind === 'melee') { + for (const e of state.enemies) { + if (e.dead) continue; + if (isInMeleeArc(p.x, p.y, p.angle, e.x, e.y, w.range, (w.arcDeg * Math.PI) / 180) + && hasLineOfSight(state.map, p.x, p.y, e.x, e.y)) { + damageEnemy(state, e, w.damage); + state.events.push({ t: 'meleeHit', id: e.id }); + break; + } + } + return; + } + + p.ammo[w.id] -= w.ammoCost ?? 1; + spawnProjectile(state, rules, { + ownerId: 'player', x: p.x, y: p.y, angle: p.angle, weapon: w.id, + speed: w.speed, damage: w.damage, hitRadius: w.hitRadius, ttlSec: w.ttlSec, friendly: true, + }); +} + +function spawnProjectile(state, rules, opts) { + if (state.projectiles.length >= rules.constants.projectileCap) return; + state.projectiles.push({ + id: state.nextProjectileId++, + ownerId: opts.ownerId, weapon: opts.weapon, friendly: opts.friendly, + x: opts.x, y: opts.y, px: opts.x, py: opts.y, + vx: Math.cos(opts.angle) * opts.speed, vy: Math.sin(opts.angle) * opts.speed, + damage: opts.damage, hitRadius: opts.hitRadius, + ttl: Math.ceil(opts.ttlSec * rules.constants.tickHz), + }); +} + +function damageEnemy(state, e, dmg) { + e.health -= dmg; + if (e.health <= 0 && !e.dead) { + e.dead = true; e.state = 'dead'; + state.events.push({ t: 'enemyDied', id: e.id }); + } +} + +function applyDamageToPlayer(state, dmg) { + const p = state.player; + if (p.dead) return; + p.health -= dmg; + if (p.health <= 0) { p.health = 0; p.dead = true; state.events.push({ t: 'playerDied' }); } +} + +/** Parametric position along AB closest to P, clamped to [0,1]. */ +function segClosestT(ax, ay, bx, by, px, py) { + const dx = bx - ax, dy = by - ay; + const len2 = dx * dx + dy * dy; + if (len2 <= 1e-9) return 0; + const t = ((px - ax) * dx + (py - ay) * dy) / len2; + return t < 0 ? 0 : t > 1 ? 1 : t; +} +/** Squared distance from P to segment AB. */ +function segDistSq(ax, ay, bx, by, px, py) { + const t = segClosestT(ax, ay, bx, by, px, py); + const cx = ax + (bx - ax) * t - px, cy = ay + (by - ay) * t - py; + return cx * cx + cy * cy; +} + +function stepProjectiles(state, rules) { + const dt = rules.dt; + const keep = []; + for (const proj of state.projectiles) { + proj.px = proj.x; proj.py = proj.y; + proj.x += proj.vx * dt; + proj.y += proj.vy * dt; + + let detonate = --proj.ttl <= 0; + let hitX = proj.x, hitY = proj.y; + + // Wall collision: DDA the tick's own travel segment rather than + // point-sampling the new position — a fast round can cross an entire + // thin wall cell within one tick. + if (!detonate) { + const segDirX = proj.x - proj.px, segDirY = proj.y - proj.py; + if (Math.hypot(segDirX, segDirY) > 1e-6) { + const hit = castRay(state.map, proj.px, proj.py, segDirX, segDirY, fullMapSteps(state.map)); + if (hit && hit.perpDist <= 1) { + detonate = true; + hitX = proj.px + segDirX * hit.perpDist; + hitY = proj.py + segDirY * hit.perpDist; + } + } + } + + // Entity collision: swept segment vs. every live hostile in its path. + if (!detonate) { + let bestT = Infinity, bestTarget = null; + const targets = proj.friendly ? state.enemies : [state.player]; + for (const t of targets) { + if (t.dead) continue; + const r = proj.hitRadius + (t.radius ?? 0.3); + if (segDistSq(proj.px, proj.py, proj.x, proj.y, t.x, t.y) > r * r) continue; + const s = segClosestT(proj.px, proj.py, proj.x, proj.y, t.x, t.y); + if (s < bestT) { bestT = s; bestTarget = t; } + } + if (bestTarget) { + detonate = true; + hitX = proj.px + (proj.x - proj.px) * bestT; + hitY = proj.py + (proj.y - proj.py) * bestT; + if (proj.friendly) damageEnemy(state, bestTarget, proj.damage); + else applyDamageToPlayer(state, proj.damage); + } + } + + if (detonate) state.events.push({ t: 'impact', x: hitX, y: hitY, weapon: proj.weapon }); + else keep.push(proj); + } + state.projectiles = keep; +} + +// --------------------------------------------------------------------------- +// Pickups + result +// --------------------------------------------------------------------------- + +function stepPickups(state, rules) { + const p = state.player; + if (p.dead) return; + for (const pk of state.pickups) { + if (pk.taken) continue; + if (Math.hypot(p.x - pk.x, p.y - pk.y) > rules.constants.pickupRadius) continue; + const item = rules.itemById[pk.itemId]; + if (!item) continue; + + if (item.kind === 'health') { + if (p.health >= rules.constants.playerMaxHealth) continue; + p.health = Math.min(rules.constants.playerMaxHealth, p.health + item.amount); + } else if (item.kind === 'ammo') { + p.ammo.pistol = Math.min(rules.weaponById.pistol.maxAmmo, (p.ammo.pistol ?? 0) + item.amount); + } else if (item.kind === 'weapon') { + if (!p.weapons.includes(item.grantsWeapon)) p.weapons.push(item.grantsWeapon); + const cap = rules.weaponById[item.grantsWeapon].maxAmmo; + p.ammo[item.grantsWeapon] = Math.min(cap, (p.ammo[item.grantsWeapon] ?? 0) + (item.ammo ?? 0)); + p.weapon = item.grantsWeapon; + } + pk.taken = true; + state.events.push({ t: 'pickup', itemId: pk.itemId, x: pk.x, y: pk.y }); + } +} + +function checkResult(state) { + if (state.result) return; + const p = state.player; + if (p.dead) { state.result = 'lost'; state.events.push({ t: 'missionLost' }); return; } + const ex = state.exit; + if (ex && Math.hypot(p.x - ex.x, p.y - ex.y) <= (ex.radius ?? 0.6)) { + state.result = 'won'; + state.events.push({ t: 'missionWon' }); + } +} + +// --------------------------------------------------------------------------- +// Level model + validation — shared by the editor, the generator, and the +// runtime loader, so none of the three can silently disagree on legality. +// --------------------------------------------------------------------------- + +export function buildLevelModel(levelJson) { + return { + version: levelJson.version ?? 1, + id: levelJson.id, name: levelJson.name, + campaignId: levelJson.campaignId ?? null, missionIndex: levelJson.missionIndex ?? 0, + width: levelJson.width, height: levelJson.height, cellSize: levelJson.cellSize ?? 64, + walls: levelJson.walls.map((row) => row.slice()), + playerStart: levelJson.playerStart ? { ...levelJson.playerStart } : null, + doors: (levelJson.doors ?? []).map((d) => ({ ...d })), + enemies: (levelJson.enemies ?? []).map((e) => ({ ...e })), + items: (levelJson.items ?? []).map((it) => ({ ...it })), + exit: levelJson.exit ? { ...levelJson.exit } : null, + briefing: (levelJson.briefing ?? []).slice(), + }; +} + +function bfsReachable(level, start, goal) { + const { width, height, walls } = level; + const blocked = (x, y) => walls[y]?.[x] > 0 && walls[y][x] !== DOOR_WALL_TYPE; + if (blocked(start.x, start.y)) return false; + const visited = Array.from({ length: height }, () => new Array(width).fill(false)); + visited[start.y][start.x] = true; + const queue = [[start.x, start.y]]; + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; + while (queue.length) { + const [cx, cy] = queue.shift(); + if (cx === goal.x && cy === goal.y) return true; + for (const [dx, dy] of dirs) { + const nx = cx + dx, ny = cy + dy; + if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue; + if (visited[ny][nx] || blocked(nx, ny)) continue; + visited[ny][nx] = true; + queue.push([nx, ny]); + } + } + return false; +} + +export function validateLevel(level) { + const issues = []; + if (!level.width || !level.height) issues.push('missing width/height'); + if (!Array.isArray(level.walls) || level.walls.length !== level.height) { + issues.push('walls grid row count does not match height'); + } else { + for (const row of level.walls) { + if (!Array.isArray(row) || row.length !== level.width) { + issues.push('walls grid column count does not match width'); + break; + } + } + } + if (!level.playerStart) issues.push('missing playerStart'); + if (!level.exit) issues.push('missing exit point'); + if (issues.length) return { valid: false, issues, reachable: false }; + + for (let x = 0; x < level.width; x++) { + if (level.walls[0][x] === 0) issues.push(`open boundary at (${x},0)`); + if (level.walls[level.height - 1][x] === 0) issues.push(`open boundary at (${x},${level.height - 1})`); + } + for (let y = 0; y < level.height; y++) { + if (level.walls[y][0] === 0) issues.push(`open boundary at (0,${y})`); + if (level.walls[y][level.width - 1] === 0) issues.push(`open boundary at (${level.width - 1},${y})`); + } + + const startCell = { x: Math.floor(level.playerStart.x), y: Math.floor(level.playerStart.y) }; + if (level.walls[startCell.y]?.[startCell.x] > 0) issues.push('playerStart sits inside a wall cell'); + const exitCell = { x: Math.floor(level.exit.x), y: Math.floor(level.exit.y) }; + if (level.walls[exitCell.y]?.[exitCell.x] > 0) issues.push('exit sits inside a wall cell'); + + for (const e of level.enemies ?? []) { + const c = { x: Math.floor(e.x), y: Math.floor(e.y) }; + if (level.walls[c.y]?.[c.x] > 0) issues.push(`enemy spawn (${e.x},${e.y}) sits inside a wall cell`); + } + for (const it of level.items ?? []) { + const c = { x: Math.floor(it.x), y: Math.floor(it.y) }; + if (level.walls[c.y]?.[c.x] > 0) issues.push(`item (${it.x},${it.y}) sits inside a wall cell`); + } + + const reachable = bfsReachable(level, startCell, exitCell); + if (!reachable) issues.push('exit is not reachable from playerStart'); + + return { valid: issues.length === 0, issues, reachable }; +} + +// --------------------------------------------------------------------------- +// Save / load +// --------------------------------------------------------------------------- + +function rleEncodeGrid(walls) { + const flat = walls.flat(); + const out = []; + let run = 1; + for (let i = 1; i <= flat.length; i++) { + if (i < flat.length && flat[i] === flat[i - 1] && run < 65535) { run++; continue; } + out.push(flat[i - 1], run); + run = 1; + } + return out; +} + +function rleDecodeGrid(rle, width, height) { + const flat = []; + for (let i = 0; i < rle.length; i += 2) { + const val = rle[i], run = rle[i + 1]; + for (let j = 0; j < run; j++) flat.push(val); + } + const walls = []; + for (let y = 0; y < height; y++) walls.push(flat.slice(y * width, (y + 1) * width)); + return walls; +} + +export function serialize(state) { + return JSON.stringify({ + v: SAVE_VERSION, + tick: state.tick, accumulatorMs: state.accumulatorMs, + map: { width: state.map.width, height: state.map.height, wallsRle: rleEncodeGrid(state.map.walls) }, + player: { ...state.player, weapons: state.player.weapons.slice(), ammo: { ...state.player.ammo } }, + enemies: state.enemies.map((e) => ({ ...e })), + projectiles: state.projectiles.map((p) => ({ ...p })), + pickups: state.pickups.map((p) => ({ ...p })), + doors: state.doors.map((d) => ({ ...d })), + exit: state.exit, result: state.result, + nextProjectileId: state.nextProjectileId, + levelMeta: state.levelMeta, + }); +} + +export function deserialize(rules, raw) { + let data; + try { data = JSON.parse(raw); } catch { return null; } + if (!data || data.v !== SAVE_VERSION) return null; + return { + tick: data.tick, accumulatorMs: data.accumulatorMs, alpha: 0, + map: { width: data.map.width, height: data.map.height, walls: rleDecodeGrid(data.map.wallsRle, data.map.width, data.map.height) }, + player: { ...data.player, ammo: { ...data.player.ammo }, weapons: data.player.weapons.slice(), pendingTurn: 0, fireHeld: false }, + enemies: data.enemies.map((e) => ({ ...e })), + projectiles: data.projectiles.map((p) => ({ ...p })), + pickups: data.pickups.map((p) => ({ ...p })), + doors: data.doors.map((d) => ({ ...d })), + exit: data.exit, events: [], result: data.result ?? null, + nextProjectileId: data.nextProjectileId, + levelMeta: data.levelMeta, + }; +} diff --git a/src/games/wolfenstein/WolfensteinRaycaster.js b/src/games/wolfenstein/WolfensteinRaycaster.js new file mode 100644 index 0000000..be820ba --- /dev/null +++ b/src/games/wolfenstein/WolfensteinRaycaster.js @@ -0,0 +1,96 @@ +// Pure-JS grid-DDA raycaster (the Lodev direction-vector derivation — camera +// direction + perpendicular "plane" vector, one ray per screen column). No +// Phaser dependency, so it's directly unit-testable under Node +// (tools/verifyWolfenstein.js). Reused by the renderer (WolfensteinView), by +// enemy line-of-sight and player-melee checks, and by the swept-segment +// bullet collision in WolfensteinLogic — one raycasting core, several callers. +// +// perpDist is not the Euclidean distance along the ray — it's the distance +// projected onto the camera's forward axis. That's what makes the wall +// renderer fisheye-free without a separate cos() correction step, and it's +// also exactly the parametric "t" of the ray equation P = origin + dir*t, +// which is what the swept-bullet and line-of-sight callers rely on (t=1 means +// "exactly at the point dir was aimed at"). + +const DEFAULT_MAX_STEPS = 256; + +/** True outside the grid too, so a ray/AABB check never has to special-case the border. */ +export function isWallCell(map, cellX, cellY) { + if (cellX < 0 || cellY < 0 || cellX >= map.width || cellY >= map.height) return true; + return map.walls[cellY][cellX] > 0; +} + +/** + * March a ray from (x,y) along direction (dirX,dirY) — need not be unit + * length; distances/`perpDist` come out scaled to that vector's own length — + * until it exits the first solid cell. Returns null if it runs off the map + * edge or exceeds maxSteps without a hit (should never happen inside a + * validated closed level). + */ +export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS) { + let mapX = Math.floor(x); + let mapY = Math.floor(y); + + const deltaDistX = dirX === 0 ? 1e9 : Math.abs(1 / dirX); + const deltaDistY = dirY === 0 ? 1e9 : Math.abs(1 / dirY); + + let stepX, sideDistX; + if (dirX < 0) { stepX = -1; sideDistX = (x - mapX) * deltaDistX; } + else { stepX = 1; sideDistX = (mapX + 1 - x) * deltaDistX; } + let stepY, sideDistY; + if (dirY < 0) { stepY = -1; sideDistY = (y - mapY) * deltaDistY; } + else { stepY = 1; sideDistY = (mapY + 1 - y) * deltaDistY; } + + let side = 0; + for (let i = 0; i < maxSteps; i++) { + if (sideDistX < sideDistY) { sideDistX += deltaDistX; mapX += stepX; side = 0; } + else { sideDistY += deltaDistY; mapY += stepY; side = 1; } + + if (mapX < 0 || mapY < 0 || mapX >= map.width || mapY >= map.height) return null; + const wallType = map.walls[mapY][mapX]; + if (wallType > 0) { + const perpDist = side === 0 ? (sideDistX - deltaDistX) : (sideDistY - deltaDistY); + let wallX = side === 0 ? (y + perpDist * dirY) : (x + perpDist * dirX); + wallX -= Math.floor(wallX); + return { perpDist: Math.max(perpDist, 1e-4), side, mapX, mapY, wallType, textureX: wallX }; + } + } + return null; +} + +/** A generous, always-sufficient step budget for a full map traversal. */ +export function fullMapSteps(map) { + return map.width + map.height + 4; +} + +export function makeCamera(x, y, angle, fov) { + const dirX = Math.cos(angle), dirY = Math.sin(angle); + const planeScale = Math.tan(fov / 2); + return { x, y, angle, dirX, dirY, planeX: -dirY * planeScale, planeY: dirX * planeScale }; +} + +/** One castRay per screen column, camera-space (fisheye-free). */ +export function castColumns(map, camera, numColumns) { + const { x, y, dirX, dirY, planeX, planeY } = camera; + const maxSteps = fullMapSteps(map); + const out = new Array(numColumns); + for (let col = 0; col < numColumns; col++) { + const cameraX = (2 * col) / numColumns - 1; + const rdx = dirX + planeX * cameraX; + const rdy = dirY + planeY * cameraX; + out[col] = castRay(map, x, y, rdx, rdy, maxSteps); + } + return out; +} + +/** + * Clear line of sight from (x0,y0) to (x1,y1)? Casts with dir = target-origin, + * so the target sits at parametric t=1 — a wall hit at perpDist >= 1 is at or + * past the target and doesn't block it. + */ +export function hasLineOfSight(map, x0, y0, x1, y1) { + const dirX = x1 - x0, dirY = y1 - y0; + if (Math.hypot(dirX, dirY) < 1e-6) return true; + const hit = castRay(map, x0, y0, dirX, dirY, fullMapSteps(map)); + return !hit || hit.perpDist >= 1 - 1e-3; +} diff --git a/src/games/wolfenstein/WolfensteinRules.js b/src/games/wolfenstein/WolfensteinRules.js new file mode 100644 index 0000000..70bfcba --- /dev/null +++ b/src/games/wolfenstein/WolfensteinRules.js @@ -0,0 +1,16 @@ +// Compiles data/wolfenstein-rules.json into the lookup-table shape the engine, +// renderer and editor all read — the TARules.js `compileRules()` idiom. + +export function compileRules(raw) { + const constants = { ...raw.constants }; + const weapons = raw.weapons.map((w) => ({ ...w })); + const weaponById = Object.fromEntries(weapons.map((w) => [w.id, w])); + const enemies = raw.enemies.map((e) => ({ ...e })); + const enemyById = Object.fromEntries(enemies.map((e) => [e.id, e])); + const items = raw.items.map((i) => ({ ...i })); + const itemById = Object.fromEntries(items.map((i) => [i.id, i])); + const stepMs = 1000 / constants.tickHz; + const dt = 1 / constants.tickHz; + const fov = (constants.fovDeg * Math.PI) / 180; + return { constants, weapons, weaponById, enemies, enemyById, items, itemById, stepMs, dt, fov }; +} diff --git a/src/games/wolfenstein/WolfensteinScreens.js b/src/games/wolfenstein/WolfensteinScreens.js new file mode 100644 index 0000000..d38f7aa --- /dev/null +++ b/src/games/wolfenstein/WolfensteinScreens.js @@ -0,0 +1,151 @@ +// Front-end screens — the TAScreens.js router pattern. Each builder returns a +// plain { destroy() } handle and the scene keeps exactly one alive +// (WolfensteinGame.swapScreen). Screens never touch the simulation: they +// collect a choice and hand it back through a callback. + +import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; + +const BG = 0x14100c; +const PANEL = 0x241c14; +const EDGE = 0x5a4a34; +const ACCENT = 0xc8843c; +const TEXT = '#f2ead8'; +const DIM = '#a89878'; +const FONT = '"Julius Sans One"'; + +function screen(scene, objs) { + return { objs, destroy() { for (const o of objs) o.destroy?.(); objs.length = 0; } }; +} + +function backdrop(scene, objs, title, subtitle) { + objs.push(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, BG, 1).setOrigin(0, 0).setDepth(900).setInteractive()); + if (title) { + objs.push(scene.add.text(GAME_WIDTH / 2, 92, title, { fontFamily: FONT, fontSize: '68px', color: TEXT }) + .setOrigin(0.5).setDepth(901)); + } + if (subtitle) { + objs.push(scene.add.text(GAME_WIDTH / 2, 158, subtitle, { fontFamily: FONT, fontSize: '22px', color: DIM }) + .setOrigin(0.5).setDepth(901)); + } +} + +function button(scene, objs, x, y, w, h, label, onClick, { enabled = true, size = 26 } = {}) { + const c = scene.add.container(x, y).setDepth(902); + const bg = scene.add.rectangle(0, 0, w, h, enabled ? PANEL : 0x1a140e, 1) + .setStrokeStyle(2, enabled ? EDGE : 0x2a2018); + const txt = scene.add.text(0, 0, label, { fontFamily: FONT, fontSize: `${size}px`, color: enabled ? TEXT : '#5a5040' }) + .setOrigin(0.5); + c.add([bg, txt]); + c.setSize(w, h); + if (enabled) { + c.setInteractive({ useHandCursor: true }); + c.on('pointerover', () => bg.setFillStyle(0x3a2c1c, 1)); + c.on('pointerout', () => bg.setFillStyle(PANEL, 1)); + c.on('pointerdown', (p, lx, ly, ev) => { ev?.stopPropagation(); onClick(); }); + } + objs.push(c); + c.label = txt; c.bg = bg; + return c; +} + +// --------------------------------------------------------------------------- +export function mainMenu(scene, { hasContinue, onNewGame, onContinue, onLoad, onLeave }) { + const objs = []; + backdrop(scene, objs, 'WOLFENSTEIN 3D', 'Placeholder art — real sprites drop in later'); + const cx = GAME_WIDTH / 2; + let y = 420; + button(scene, objs, cx, y, 460, 76, 'NEW GAME', onNewGame); y += 100; + button(scene, objs, cx, y, 460, 76, 'CONTINUE', onContinue, { enabled: hasContinue }); y += 100; + button(scene, objs, cx, y, 460, 76, 'LOAD GAME', onLoad); y += 100; + button(scene, objs, cx, y, 460, 76, 'BACK TO ARCADE', onLeave); + return screen(scene, objs); +} + +// --------------------------------------------------------------------------- +export function campaignSelect(scene, { campaigns, progress, onBack, onPick }) { + const objs = []; + backdrop(scene, objs, 'SELECT CAMPAIGN', null); + campaigns.forEach((camp, i) => { + const y = 320 + i * 160; + const cleared = progress[camp.id] ?? 0; + const total = camp.missions.length; + const sub = total ? `${cleared} of ${total} missions cleared` : 'No missions installed yet'; + const b = button(scene, objs, GAME_WIDTH / 2, y, 760, 120, camp.name, () => onPick(camp.id), { enabled: total > 0, size: 30 }); + objs.push(scene.add.text(GAME_WIDTH / 2, y + 40, sub, { fontFamily: FONT, fontSize: '18px', color: DIM }) + .setOrigin(0.5).setDepth(903)); + void b; + }); + button(scene, objs, GAME_WIDTH / 2, GAME_HEIGHT - 110, 300, 76, 'BACK', onBack); + return screen(scene, objs); +} + +// --------------------------------------------------------------------------- +export function campaignList(scene, campaign, cleared, { onBack, onPick }) { + const objs = []; + const missions = campaign.missions ?? []; + backdrop(scene, objs, campaign.name, + missions.length ? `${cleared} of ${missions.length} missions cleared` : 'No missions installed yet'); + + missions.forEach((m, i) => { + const col = i % 3, row = Math.floor(i / 3); + const x = GAME_WIDTH / 2 + (col - 1) * 500, y = 300 + row * 130; + const unlocked = i <= cleared; + const b = button(scene, objs, x, y, 460, 104, + `${String(i + 1).padStart(2, '0')} ${unlocked ? m.name : 'LOCKED'}`, + () => onPick(i), { enabled: unlocked, size: 26 }); + if (unlocked && i < cleared) b.label.setColor('#9ce6a0'); + }); + + button(scene, objs, GAME_WIDTH / 2, GAME_HEIGHT - 110, 300, 76, 'BACK', onBack); + return screen(scene, objs); +} + +// --------------------------------------------------------------------------- +// Shared save/load slot picker. mode 'save' writes into the clicked slot; +// mode 'load' loads from it (only enabled where a slot has data). +export function saveLoadScreen(scene, { mode, slots, onBack, onPick, onDelete }) { + const objs = []; + backdrop(scene, objs, mode === 'save' ? 'SAVE GAME' : 'LOAD GAME', null); + + slots.forEach((meta, i) => { + const y = 300 + i * 150; + const label = meta + ? `SLOT ${i + 1} — ${meta.missionName ?? '?'} (HP ${meta.health ?? '?'}, ${new Date(meta.savedAt).toLocaleString()})` + : `SLOT ${i + 1} — EMPTY`; + const enabled = mode === 'save' || !!meta; + button(scene, objs, GAME_WIDTH / 2 - (meta && mode === 'load' ? 90 : 0), y, meta && mode === 'load' ? 1140 : 1240, 100, + label, () => onPick(i), { enabled, size: 22 }); + if (meta && mode === 'load') { + button(scene, objs, GAME_WIDTH / 2 + 620, y, 140, 100, 'DELETE', () => onDelete(i), { size: 18 }); + } + }); + + button(scene, objs, GAME_WIDTH / 2, GAME_HEIGHT - 110, 300, 76, 'BACK', onBack); + return screen(scene, objs); +} + +// --------------------------------------------------------------------------- +export function pause(scene, { onResume, onSave, onLoad, onExitToEditor, onQuit }) { + const objs = []; + backdrop(scene, objs, 'PAUSED', null); + const cx = GAME_WIDTH / 2; + let y = 400; + button(scene, objs, cx, y, 460, 76, 'RESUME', onResume); y += 100; + button(scene, objs, cx, y, 460, 76, 'SAVE GAME', onSave); y += 100; + button(scene, objs, cx, y, 460, 76, 'LOAD GAME', onLoad); y += 100; + if (onExitToEditor) { button(scene, objs, cx, y, 460, 76, 'EXIT TO EDITOR', onExitToEditor); y += 100; } + button(scene, objs, cx, y, 460, 76, 'QUIT TO MAIN MENU', onQuit); + return screen(scene, objs); +} + +// --------------------------------------------------------------------------- +export function resultScreen(scene, { won, missionName, hasNext, onRetry, onNext, onMenu }) { + const objs = []; + backdrop(scene, objs, won ? 'MISSION COMPLETE' : 'YOU DIED', missionName); + const cx = GAME_WIDTH / 2; + let y = 460; + if (won && hasNext) { button(scene, objs, cx, y, 460, 76, 'NEXT MISSION', onNext); y += 100; } + button(scene, objs, cx, y, 460, 76, won ? 'REPLAY MISSION' : 'RETRY', onRetry); y += 100; + button(scene, objs, cx, y, 460, 76, 'MAIN MENU', onMenu); + return screen(scene, objs); +} diff --git a/src/games/wolfenstein/WolfensteinView.js b/src/games/wolfenstein/WolfensteinView.js new file mode 100644 index 0000000..8d8890b --- /dev/null +++ b/src/games/wolfenstein/WolfensteinView.js @@ -0,0 +1,142 @@ +// Rendering — the TAWorldView.js role. Owns no simulation state; each frame +// it reads WolfensteinLogic's state and turns it into pixels. +// +// Walls: drawn into a dynamic canvas texture (scene.textures.createCanvas + +// .refresh()) rather than a WebGL shader — renderer-agnostic (works under +// both Phaser CANVAS and WEBGL, unlike Super Kart's Mode 7 shader), and +// conceptually the same canvas-manipulation approach Excitebike and Super +// Kart's track pre-rasterizer already use elsewhere in this repo. Flat-shaded +// per-column rects for now; when painted wall textures land, the same +// per-column loop swaps fillRect for a drawImage source-column stretch — the +// architecture doesn't change. +// +// Sprites (enemies/pickups/in-flight bullets): pooled billboarded Phaser +// Images, positioned via the same camera-space projection math as the walls, +// occluded per-sprite (not per-pixel) against the wall depth buffer — an +// approximation, acceptable for this MVP. + +import { castColumns } from './WolfensteinRaycaster.js'; +import { WALL_COLORS, ensureSprites } from './WolfensteinArt.js'; + +export const VIEW_W = 1920; +export const VIEW_H = 940; +export const NUM_COLUMNS = 480; + +export default class WolfensteinView { + constructor(scene, rules) { + this.scene = scene; + this.rules = rules; + ensureSprites(scene); + + const key = 'wolf-3dview'; + if (scene.textures.exists(key)) scene.textures.remove(key); + this.canvasTexture = scene.textures.createCanvas(key, VIEW_W, VIEW_H); + this.ctx = this.canvasTexture.context; + this.image = scene.add.image(0, 0, key).setOrigin(0, 0).setDepth(10); + + this.colWidth = VIEW_W / NUM_COLUMNS; + this.depthBuffer = new Array(NUM_COLUMNS).fill(Infinity); + this.spritePool = new Map(); + } + + render(state, camera) { + this._drawWalls(state.map, camera); + this._drawSprites(state, camera); + } + + _drawWalls(map, camera) { + const ctx = this.ctx; + ctx.fillStyle = '#2b2b2b'; + ctx.fillRect(0, 0, VIEW_W, VIEW_H / 2); + ctx.fillStyle = '#4a4a4a'; + ctx.fillRect(0, VIEW_H / 2, VIEW_W, VIEW_H / 2); + + const cols = castColumns(map, camera, NUM_COLUMNS); + for (let i = 0; i < NUM_COLUMNS; i++) { + const hit = cols[i]; + this.depthBuffer[i] = hit ? hit.perpDist : Infinity; + if (!hit) continue; + const lineHeight = VIEW_H / hit.perpDist; + let drawStart = -lineHeight / 2 + VIEW_H / 2; if (drawStart < 0) drawStart = 0; + let drawEnd = lineHeight / 2 + VIEW_H / 2; if (drawEnd > VIEW_H) drawEnd = VIEW_H; + const base = WALL_COLORS[hit.wallType] ?? 0xaaaaaa; + ctx.fillStyle = shadeColor(base, hit); + const x = Math.floor(i * this.colWidth); + const w = Math.ceil(this.colWidth) + 1; + ctx.fillRect(x, Math.floor(drawStart), w, Math.ceil(drawEnd - drawStart)); + } + this.canvasTexture.refresh(); + } + + _drawSprites(state, camera) { + const live = new Set(); + const sprites = []; + for (const e of state.enemies) { + if (e.dead) continue; + sprites.push({ key: `enemy:${e.id}`, x: e.x, y: e.y, tex: 'wolf-guard', scale: 0.9 }); + } + for (const pk of state.pickups) { + if (pk.taken) continue; + sprites.push({ key: `pickup:${pk.id}`, x: pk.x, y: pk.y, tex: `wolf-item-${pk.itemId}`, scale: 0.45 }); + } + for (const proj of state.projectiles) { + sprites.push({ key: `proj:${proj.id}`, x: proj.x, y: proj.y, tex: 'wolf-bullet', scale: 0.18 }); + } + + const invDet = 1 / (camera.planeX * camera.dirY - camera.dirX * camera.planeY); + for (const s of sprites) { + const sx = s.x - camera.x, sy = s.y - camera.y; + s._tx = invDet * (camera.dirY * sx - camera.dirX * sy); + s._depth = invDet * (-camera.planeY * sx + camera.planeX * sy); + } + sprites.sort((a, b) => b._depth - a._depth); // back-to-front + + for (const s of sprites) { + live.add(s.key); + if (s._depth <= 0.05) { this._hideSprite(s.key); continue; } + const screenColF = (NUM_COLUMNS / 2) * (1 + s._tx / s._depth); + const col = Math.floor(screenColF); + if (col < 0 || col >= NUM_COLUMNS || s._depth > this.depthBuffer[col] + 0.15) { + this._hideSprite(s.key); continue; + } + const spriteSize = Math.abs(VIEW_H / s._depth) * s.scale; + const img = this._ensureSprite(s.key, s.tex); + img.setTexture(s.tex); + img.setPosition(screenColF * this.colWidth, VIEW_H / 2); + img.setDisplaySize(spriteSize, spriteSize); + img.setDepth(10 + Math.max(0, 100 - s._depth)); + img.setVisible(true); + } + for (const [key, img] of this.spritePool) if (!live.has(key)) img.setVisible(false); + } + + _ensureSprite(key, tex) { + let img = this.spritePool.get(key); + if (!img) { + img = this.scene.add.image(0, 0, tex).setOrigin(0.5, 0.5); + this.spritePool.set(key, img); + } + return img; + } + + _hideSprite(key) { + const img = this.spritePool.get(key); + if (img) img.setVisible(false); + } + + destroy() { + for (const img of this.spritePool.values()) img.destroy(); + this.spritePool.clear(); + this.image?.destroy(); + if (this.scene.textures.exists('wolf-3dview')) this.scene.textures.remove('wolf-3dview'); + } +} + +function shadeColor(hex, hit) { + let r = (hex >> 16) & 255, g = (hex >> 8) & 255, b = hex & 255; + const sideMul = hit.side === 1 ? 0.72 : 1; + const fog = Math.max(0.28, 1 - hit.perpDist / 14); + const m = sideMul * fog; + r = Math.round(r * m); g = Math.round(g * m); b = Math.round(b * m); + return `rgb(${r},${g},${b})`; +} diff --git a/src/games/wolfenstein/sprites.md b/src/games/wolfenstein/sprites.md new file mode 100644 index 0000000..6186ca1 --- /dev/null +++ b/src/games/wolfenstein/sprites.md @@ -0,0 +1,41 @@ +# Wolfenstein 3D — art spec + +Everything currently renders procedurally (see `WolfensteinArt.js` for the +placeholder palette/textures). Drop real files in and fill in the matching +`path` in `data/wolfenstein-artwork.json` — no code changes needed once a +path is set (see `assetManifest.js`'s `wolfenstein` entry). + +## Wall textures — `sheets.walls` + +A horizontal spritesheet, one 64×64 frame per wall type, in the order used by +`WolfensteinArt.WALL_COLORS`: frame 0 = type 1 (stone), frame 1 = type 2 +(wood), frame 2 = type 3 (blue-tile), frame 3 = type 4 (green-tile). A door +(closed) reuses frame index matching `DOOR_WALL_TYPE`'s placeholder color +(tan) — add a 5th frame if a distinct door texture is wanted. + +## Guard enemy — `sheets.guard` + +128×128 frames. Minimum for parity with the current placeholder: one idle +pose. For proper directional billboarding later: 8 facing angles × {idle, +walk (2-4 frames), shoot, die} — not required for the MVP renderer, which +only ever shows a single billboard sprite per enemy. + +## Standalone images — `artwork[]` + +- `wolfenstein-item-pistol`, `wolfenstein-item-ammo`, `wolfenstein-item-health` + — pickup icons, square, transparent background. +- `wolfenstein-bullet` — small bright sprite for a pistol round in flight. +- `wolfenstein-muzzle` — muzzle-flash flash sprite (currently unused by the + scene beyond the painted placeholder texture; wire up on `weaponFired` + events once sfx/vfx pass lands). +- `wolfenstein-title` — optional main-menu backdrop art (the menu currently + renders a flat panel with title text, matching `TAScreens.js`'s style + before `ta-background` was painted). + +## Sound effects + +None wired up yet — `WolfensteinGame._onSimEvent` has hooks for every event +(`weaponFired`, `meleeHit`, `enemyDied`, `enemyMelee`, `doorOpen`/`doorClose`, +`pickup`, `missionWon`/`missionLost`) but plays no audio. Once clips exist, +add them to `assetManifest.js`'s `wolfenstein` entry (see the commented-out +note there) and call `this.sound.play(...)` from the matching event branch. diff --git a/src/main.js b/src/main.js index ffb32a3..63b813a 100644 --- a/src/main.js +++ b/src/main.js @@ -105,6 +105,8 @@ import GooTowerEditor from './games/gootower/GooTowerEditor.js'; import ExcitebikeGame from './games/excitebike/ExcitebikeGame.js'; import MasterOfVegaGame from './games/mastervega/MasterOfVegaGame.js'; import VegaCombatSim from './games/mastervega/VegaCombatSim.js'; +import WolfensteinGame from './games/wolfenstein/WolfensteinGame.js'; +import WolfensteinEditor from './games/wolfenstein/WolfensteinEditor.js'; const config = { type: Phaser.AUTO, @@ -223,6 +225,8 @@ const config = { MasterOfVegaGame, VegaCombatSim, GooTowerEditor, + WolfensteinGame, + WolfensteinEditor, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index 1c6e38a..4e9ba63 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame' }; if (slugDispatch[this.game.slug]) { const sceneKey = slugDispatch[this.game.slug]; const startData = { diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 4a4b6cf..0efe315 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -78,6 +78,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.json'); this.load.json('star-control-ships', 'data/star-control-ships.json'); this.load.json('mastervega-artwork', 'data/mastervega-artwork.json'); + this.load.json('wolfenstein-artwork', 'data/wolfenstein-artwork.json'); this.load.audio('sfx-engine-start', 'assets/fx/engine-start.mp3'); this.load.audio('sfx-engine-heavy', 'assets/fx/engine-heavy.mp3'); @@ -245,6 +246,13 @@ export default class PreloadScene extends Phaser.Scene { return; } + // ...and the Wolfenstein level editor: ?wolfenstein-editor=1. + if (params.has('wolfenstein-editor')) { + window.history.replaceState(null, '', window.location.pathname + window.location.hash); + this.scene.start('WolfensteinEditor'); + return; + } + // Master of Vega combat simulator: ?movsim boots straight into a // fleet-vs-fleet / fleet-vs-planet skirmish testbed for VegaCombat. if (params.has('movsim')) { diff --git a/tools/genWolfenstein.js b/tools/genWolfenstein.js new file mode 100644 index 0000000..1197fe2 --- /dev/null +++ b/tools/genWolfenstein.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node +// Hand-authors the sample Wolfenstein levels as compact ASCII maps and writes +// them to assets/gamedata/wolfenstein/ — the tools/genBloxorz.js role. Every +// level is run through auditLevel() (validateLevel + BFS reachability) before +// being written; an unsolvable or malformed map fails the build rather than +// shipping silently broken. +// +// node tools/genWolfenstein.js + +import { writeFileSync, mkdirSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { parseMap, auditLevel } from './wolfensteinMap.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const OUT_DIR = join(ROOT, 'assets/gamedata/wolfenstein'); +mkdirSync(OUT_DIR, { recursive: true }); + +const LEVELS = []; +const lvl = (id, name, art, opts) => { LEVELS.push(parseMap(id, name, art, opts)); }; + +lvl('e1m1', 'First Blood', ` +############ +#S..#...E.X# +#...#......# +#...D......# +#..........# +#....##....# +#....##....# +#..A.....H.# +#..........# +############ +`, { + campaignId: 'episode1', missionIndex: 0, + briefing: ['Clear the outpost.', 'Find the exit.'], +}); + +lvl('e1m2', 'The Armory', ` +############## +#S...#....E..# +#....#.......# +#....D.......# +#....#.......# +#....#..##...# +#..A.#..##...# +#....#.......# +#....#...E...# +#....#.....HX# +############## +`, { + campaignId: 'episode1', missionIndex: 1, + briefing: ['Tougher resistance ahead.', 'The armory door only opens from this side.'], +}); + +lvl('e2m1', 'Descent', ` +########## +#S..#...X# +#...#....# +#...D....# +#........# +#..E..A..# +#........# +#....H...# +#........# +########## +`, { + campaignId: 'episode2', missionIndex: 0, + briefing: ['Underground now.', 'Watch corners.'], +}); + +for (const level of LEVELS) { + const result = auditLevel(level); + writeFileSync(join(OUT_DIR, `level-${level.id}.json`), `${JSON.stringify(level, null, 2)}\n`); + console.log(`wrote level-${level.id}.json — reachable=${result.reachable}`); +} + +console.log(`\n${LEVELS.length} level(s) generated into ${OUT_DIR}`); diff --git a/tools/verifyWolfenstein.js b/tools/verifyWolfenstein.js new file mode 100644 index 0000000..221d1da --- /dev/null +++ b/tools/verifyWolfenstein.js @@ -0,0 +1,293 @@ +#!/usr/bin/env node +// Verifies the Wolfenstein engine end to end, headlessly. +// +// node tools/verifyWolfenstein.js [--quick] +// +// WolfensteinRules/WolfensteinRaycaster/WolfensteinLogic import no Phaser. Sections 1-5 use a +// small hand-built synthetic test map so raycaster distances and swept-bullet-collision +// outcomes can be asserted against hand-computed expected values. Sections 6+ (added once the +// authoring pipeline exists) validate the actual shipped level/campaign JSON. + +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { compileRules } from '../src/games/wolfenstein/WolfensteinRules.js'; +import { castRay, hasLineOfSight, makeCamera, castColumns } from '../src/games/wolfenstein/WolfensteinRaycaster.js'; +import * as L from '../src/games/wolfenstein/WolfensteinLogic.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const QUICK = process.argv.includes('--quick'); +const rulesJson = JSON.parse(readFileSync(join(ROOT, 'data/wolfenstein-rules.json'), 'utf8')); +const rules = compileRules(rulesJson); + +let pass = 0; const failures = []; +function check(name, cond, detail) { + if (cond) { pass++; return true; } + failures.push(detail ? `${name} — ${detail}` : name); + return false; +} +function section(title) { console.log(`\n── ${title}`); } +function near(a, b, eps = 1e-3) { return Math.abs(a - b) < eps; } + +// --------------------------------------------------------------------------- +section('1. Rules integrity'); +// --------------------------------------------------------------------------- +{ + check('fists weapon defined', !!rules.weaponById.fists); + check('pistol weapon defined', !!rules.weaponById.pistol); + check('guard enemy defined', !!rules.enemyById.guard); + for (const w of rules.weapons) { + check(`${w.id} has positive damage`, w.damage > 0); + check(`${w.id} has positive cooldown`, w.cooldownMs > 0); + } + for (const e of rules.enemies) { + check(`${e.id} has positive health`, e.health > 0); + check(`${e.id} detectRange >= fireRange`, e.detectRange >= e.fireRange); + } + check('tickHz positive', rules.constants.tickHz > 0); + check('stepMs matches tickHz', near(rules.stepMs, 1000 / rules.constants.tickHz)); +} + +// --------------------------------------------------------------------------- +// A small synthetic 8x8 test map, hand-computable: +// row0: border row3: internal wall at columns 3-4 (a 2-cell-thick wall) +// rows1-6: open interior except row3's columns 3-4 +// row7: border +// --------------------------------------------------------------------------- +function makeTestMap() { + const W = 8, H = 8; + const walls = []; + for (let y = 0; y < H; y++) { + const row = []; + for (let x = 0; x < W; x++) { + const border = x === 0 || y === 0 || x === W - 1 || y === H - 1; + const internal = y === 3 && (x === 3 || x === 4); + row.push(border || internal ? 1 : 0); + } + walls.push(row); + } + return { width: W, height: H, walls }; +} + +// --------------------------------------------------------------------------- +section('2. Raycaster correctness (hand-computed distances)'); +// --------------------------------------------------------------------------- +{ + const map = makeTestMap(); + + const east = castRay(map, 1.5, 1.5, 1, 0); + check('east ray hits east boundary', east && east.mapX === 7 && east.mapY === 1, JSON.stringify(east)); + check('east ray perpDist matches hand calc', east && near(east.perpDist, 5.5), east?.perpDist); + check('east ray is an X-side hit', east && east.side === 0); + + const south = castRay(map, 1.5, 1.5, 0, 1); + check('south ray hits south boundary', south && south.mapX === 1 && south.mapY === 7, JSON.stringify(south)); + check('south ray perpDist matches hand calc', south && near(south.perpDist, 5.5), south?.perpDist); + check('south ray is a Y-side hit', south && south.side === 1); + + const internal = castRay(map, 1.5, 3.5, 1, 0); + check('ray hits internal wall, not the far boundary', internal && internal.mapX === 3 && internal.mapY === 3, JSON.stringify(internal)); + check('internal wall perpDist matches hand calc', internal && near(internal.perpDist, 1.5), internal?.perpDist); + + // No ray fired from inside a closed room should ever escape the map. + let escapes = 0; + const angleSamples = QUICK ? 16 : 180; + const points = [[1.5, 1.5], [4.5, 4.5], [2.5, 5.5]]; + for (const [px, py] of points) { + for (let i = 0; i < angleSamples; i++) { + const a = (i / angleSamples) * Math.PI * 2; + const hit = castRay(map, px, py, Math.cos(a), Math.sin(a)); + if (!hit) escapes++; + } + } + check('no ray escapes the closed test room', escapes === 0, `${escapes} escaped`); + + // castColumns / makeCamera smoke test — every column should return a hit. + const cam = makeCamera(1.5, 1.5, 0, rules.fov); + const cols = castColumns(map, cam, 64); + check('castColumns returns one entry per column', cols.length === 64); + check('every column hits a wall', cols.every((c) => c !== null)); + + // Line of sight: clear along an open row, blocked through the internal wall. + check('LOS clear along open row', hasLineOfSight(map, 1.5, 1.5, 6.5, 1.5)); + check('LOS blocked by internal wall', !hasLineOfSight(map, 1.5, 3.5, 6.5, 3.5)); +} + +// --------------------------------------------------------------------------- +section('3. Bullet swept-collision correctness'); +// --------------------------------------------------------------------------- +{ + const level = { + id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64, + walls: makeTestMap().walls, + playerStart: { x: 1.5, y: 1.5, angle: 0 }, + doors: [], enemies: [], items: [], + exit: { x: 6.5, y: 6.5, radius: 0.6 }, + }; + + // A target far enough from the projectile's start AND end position that a + // point-sample test would miss it entirely, but the swept segment between + // them passes right through it. + { + const state = L.createState(level, rules); + state.enemies.push({ id: 0, defId: 'guard', x: 5.0, y: 1.5, angle: 0, health: rules.enemyById.guard.health, state: 'idle', cooldownMs: 0, dead: false, radius: rules.enemyById.guard.radius }); + const startX = 4.3, endGuessX = 5.7; // both >0.53 from the target — a point-sample test would see a miss at either end + const vx = (endGuessX - startX) * rules.constants.tickHz; + state.projectiles.push({ id: 99, ownerId: 'player', weapon: 'pistol', friendly: true, x: startX, y: 1.5, px: startX, py: 1.5, vx, vy: 0, damage: 20, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 }); + L.tick(state, rules); + check('fast bullet whose endpoints both miss still registers via the swept segment', state.enemies[0].health < rules.enemyById.guard.health, `health=${state.enemies[0].health}`); + } + + // A bullet fast enough to fully cross a 1-cell-thick wall within one tick — + // point-sampling only the tick's end position would see open floor on the + // far side and miss the wall entirely. + { + const W = 10, H = 3; + const row = new Array(W).fill(0); + row[4] = 1; // single-cell wall + const thinWallMap = { width: W, height: H, walls: [new Array(W).fill(1), row, new Array(W).fill(1)] }; + const state = L.createState({ ...level, width: W, height: H, walls: thinWallMap.walls, playerStart: { x: 1.5, y: 1.5, angle: 0 }, exit: { x: 8.5, y: 1.5, radius: 0.6 } }, rules); + const startX = 2.0, endGuessX = 5.0; // crosses the wall at x=4..5 entirely within one tick + const vx = (endGuessX - startX) * rules.constants.tickHz; + state.projectiles.push({ id: 1, ownerId: 'player', weapon: 'pistol', friendly: true, x: startX, y: 1.5, px: startX, py: 1.5, vx, vy: 0, damage: 20, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 }); + const events = L.tick(state, rules); + const impact = events.find((e) => e.t === 'impact'); + check('fast bullet detonates on a thin wall it crosses mid-tick', !!impact, JSON.stringify(events)); + check('impact lands at the wall face, not past it', impact && impact.x < 4.5 && impact.x >= 4, impact?.x); + check('projectile is removed after the wall hit', state.projectiles.length === 0); + } + + // A clean miss must not falsely register a hit. + { + const state = L.createState(level, rules); + state.enemies.push({ id: 0, defId: 'guard', x: 5.0, y: 5.5, angle: 0, health: rules.enemyById.guard.health, state: 'idle', cooldownMs: 0, dead: false, radius: rules.enemyById.guard.radius }); + state.projectiles.push({ id: 1, ownerId: 'player', weapon: 'pistol', friendly: true, x: 1.5, y: 1.5, px: 1.5, py: 1.5, vx: 11, vy: 0, damage: 20, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 }); + L.tick(state, rules); + check('a deliberately wide shot does not falsely register a hit', state.enemies[0].health === rules.enemyById.guard.health); + check('a surviving projectile is kept, not detonated', state.projectiles.length === 1); + } +} + +// --------------------------------------------------------------------------- +section('4. Enemy AI'); +// --------------------------------------------------------------------------- +{ + const baseLevel = { + id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64, + walls: makeTestMap().walls, doors: [], items: [], + exit: { x: 6.5, y: 6.5, radius: 0.6 }, + }; + + // No line of sight (blocked by the row-3 internal wall) — should never alert. + { + const level = { ...baseLevel, playerStart: { x: 1.5, y: 3.5, angle: 0 }, enemies: [{ type: 'guard', x: 6.5, y: 3.5, facing: 180 }] }; + const state = L.createState(level, rules); + for (let i = 0; i < 300; i++) L.tick(state, rules); + check('guard with no line of sight stays idle', state.enemies[0].state === 'idle', state.enemies[0].state); + } + + // Clear line of sight within detectRange — should alert within a bounded number of ticks. + { + const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 5.5, y: 1.5, facing: 180 }] }; + const state = L.createState(level, rules); + let alerted = false; + for (let i = 0; i < 10 && !alerted; i++) { L.tick(state, rules); alerted = state.enemies[0].state !== 'idle'; } + check('guard with clear line of sight alerts promptly', alerted); + } + + // A dead guard stops acting and can't be hit/killed twice. + { + const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 1.5, facing: 180 }] }; + const state = L.createState(level, rules); + L.setFireHeld(state, true); + L.switchWeapon(state, 'pistol'); + let deaths = 0; + for (let i = 0; i < 600 && state.enemies[0].health > 0; i++) { + const events = L.tick(state, rules); + deaths += events.filter((e) => e.t === 'enemyDied').length; + } + check('guard actually dies within the simulated window', state.enemies[0].dead, `health=${state.enemies[0].health}`); + check('enemyDied fires exactly once', deaths === 1, `fired ${deaths} times`); + const healthAtDeath = state.enemies[0].health; + for (let i = 0; i < 60; i++) L.tick(state, rules); + check('dead guard takes no further actions/damage', state.enemies[0].health === healthAtDeath && state.enemies[0].state === 'dead'); + } +} + +// --------------------------------------------------------------------------- +section('5. Save/load round-trip'); +// --------------------------------------------------------------------------- +{ + const level = { + id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64, + walls: makeTestMap().walls, + playerStart: { x: 1.5, y: 1.5, angle: 0.3 }, + doors: [{ x: 2, y: 5, orientation: 'vertical' }], + enemies: [{ type: 'guard', x: 5.5, y: 5.5, facing: 90 }], + items: [{ type: 'health', x: 4.5, y: 4.5 }], + exit: { x: 6.5, y: 6.5, radius: 0.6 }, + }; + const state = L.createState(level, rules); + L.setMoveIntent(state, 1, 0.3); + for (let i = 0; i < 30; i++) L.tick(state, rules); + + const raw = L.serialize(state); + const restored = L.deserialize(rules, raw); + check('deserialize succeeds', !!restored); + check('player position round-trips', restored && near(restored.player.x, state.player.x) && near(restored.player.y, state.player.y)); + check('player health round-trips', restored && restored.player.health === state.player.health); + check('enemy state round-trips', restored && restored.enemies[0].x === state.enemies[0].x && restored.enemies[0].health === state.enemies[0].health); + check('wall grid RLE round-trips byte-identical', restored && JSON.stringify(restored.map.walls) === JSON.stringify(state.map.walls)); + check('tick count round-trips', restored && restored.tick === state.tick); + + const badVersion = JSON.stringify({ ...JSON.parse(raw), v: 999 }); + check('a save stamped with a future version is rejected, not thrown', L.deserialize(rules, badVersion) === null); + check('garbage input is rejected, not thrown', L.deserialize(rules, '{not json') === null); +} + +// --------------------------------------------------------------------------- +// Sections 6+: shipped level/campaign data (present once the authoring +// pipeline — tools/genWolfenstein.js + data/wolfenstein-campaigns.json — +// exists; skipped gracefully before then so this script is runnable from +// step 1 of the build, per the plan's phasing). +// --------------------------------------------------------------------------- +import { existsSync } from 'fs'; +const campaignPath = join(ROOT, 'data/wolfenstein-campaigns.json'); +if (existsSync(campaignPath)) { + section('6. Level solvability + campaign/editor schema parity'); + const campaigns = JSON.parse(readFileSync(campaignPath, 'utf8')); + for (const camp of campaigns.campaigns) { + let cleared = 0; + for (const m of camp.missions) { + const levelPath = join(ROOT, 'assets/gamedata/wolfenstein', m.levelFile); + const levelJson = JSON.parse(readFileSync(levelPath, 'utf8')); + const model = L.buildLevelModel(levelJson); + const result = L.validateLevel(model); + check(`${camp.id}/${m.id} (${m.levelFile}) validates`, result.valid, result.issues.join('; ')); + check(`${camp.id}/${m.id} exit is reachable from playerStart`, result.reachable); + cleared++; + } + check(`${camp.id} mission count matches its data`, cleared === camp.missions.length); + } + + section('7. Campaign progress gating (boundary values)'); + for (const camp of campaigns.campaigns) { + const n = camp.missions.length; + for (const cleared of [0, 1, n]) { + const unlocked = camp.missions.map((_, i) => i <= cleared); + const expectedUnlocked = Math.min(cleared + 1, n); + const actualUnlocked = unlocked.filter(Boolean).length; + check(`${camp.id} cleared=${cleared} unlocks exactly ${expectedUnlocked} mission(s)`, actualUnlocked === expectedUnlocked, `got ${actualUnlocked}`); + } + } +} else { + console.log('\n(skipping level/campaign sections — data/wolfenstein-campaigns.json not present yet)'); +} + +// --------------------------------------------------------------------------- +console.log(`\n${pass} checks passed, ${failures.length} failed.`); +if (failures.length) { + console.log('\nFailures:'); + for (const f of failures) console.log(` - ${f}`); + process.exit(1); +} diff --git a/tools/wolfensteinMap.js b/tools/wolfensteinMap.js new file mode 100644 index 0000000..610b5fc --- /dev/null +++ b/tools/wolfensteinMap.js @@ -0,0 +1,73 @@ +// ASCII-map parser for Wolfenstein levels — the tools/bloxorzMap.js role. +// Produces the exact level JSON schema the editor loads/saves/exports and +// the runtime loader consumes, so a level authored here and one exported +// from the in-browser editor are indistinguishable to WolfensteinLogic. +// +// Legend: +// # wall, type 1 (stone) 2 3 4 wall, type 2/3/4 (palette variety) +// . floor D door (closed by default) +// S player start (facing east / 0°) X level exit +// E guard enemy spawn (facing west) +// A ammo pickup H health pickup +// +// Every row must be the same length; the outer ring must be entirely wall/door +// (auditLevel below hard-rejects an open boundary or an unreachable exit — +// the same BFS-solvability gate tools/genBloxorz.js uses). + +import { buildLevelModel, validateLevel } from '../src/games/wolfenstein/WolfensteinLogic.js'; + +const WALL_CHARS = { '#': 1, 2: 2, 3: 3, 4: 4 }; + +export function parseMap(id, name, art, opts = {}) { + const lines = art.split('\n').map((l) => l.replace(/\r$/, '')).filter((l) => l.length > 0); + const height = lines.length; + const width = lines[0]?.length ?? 0; + for (const l of lines) { + if (l.length !== width) throw new Error(`level ${id}: inconsistent row width (expected ${width}, got ${l.length}: "${l}")`); + } + + const walls = []; + const doors = []; + const enemies = []; + const items = []; + let playerStart = null; + let exit = null; + + for (let y = 0; y < height; y++) { + const row = []; + for (let x = 0; x < width; x++) { + const ch = lines[y][x]; + if (WALL_CHARS[ch]) { row.push(WALL_CHARS[ch]); continue; } + row.push(0); + if (ch === 'D') doors.push({ x, y, orientation: 'vertical' }); + else if (ch === 'S') playerStart = { x: x + 0.5, y: y + 0.5, angle: 0 }; + else if (ch === 'X') exit = { x: x + 0.5, y: y + 0.5, radius: 0.6 }; + else if (ch === 'E') enemies.push({ type: 'guard', x: x + 0.5, y: y + 0.5, facing: 180 }); + else if (ch === 'A') items.push({ type: 'ammo', x: x + 0.5, y: y + 0.5 }); + else if (ch === 'H') items.push({ type: 'health', x: x + 0.5, y: y + 0.5 }); + // '.' and anything else falls through as plain floor. + } + walls.push(row); + } + + if (!playerStart) throw new Error(`level ${id}: missing S (player start)`); + if (!exit) throw new Error(`level ${id}: missing X (exit)`); + + return { + version: 1, id, name, + campaignId: opts.campaignId ?? null, missionIndex: opts.missionIndex ?? 0, + width, height, cellSize: 64, + walls, playerStart, doors, enemies, items, exit, + briefing: opts.briefing ?? [], + }; +} + +/** Hard-rejects an invalid or unsolvable level — the build-time gate. */ +export function auditLevel(level) { + const model = buildLevelModel(level); + const result = validateLevel(model); + if (!result.valid) { + throw new Error(`level ${level.id} failed validation:\n ${result.issues.join('\n ')}`); + } + return result; +}