From 3f1d534ec2bc35c326e7be7861f777f6b339a9d7 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Tue, 14 Jul 2026 22:45:56 -0600 Subject: [PATCH] feat: add Star Control space combat game - Register Star Control in games registry as 2-player arcade game - Import and register StarControlGame scene - Add starcontrol slug mapping in GameRoomScene dispatch - Load star-control-ships.json asset data in PreloadScene - Add dynamic ship sprite sheet loading in asset manifest - Update README to reflect new game in Arcade/Console/PC category - Update game icons to include Star Control icon (frame 82) --- README.md | 2 +- assets/images/game-icons.png | Bin 312687 -> 317106 bytes assets/images/game-icons.psd | Bin 808129 -> 819094 bytes .../images/starcontrol/sc-ship-androsynth.png | Bin 0 -> 1746 bytes assets/images/starcontrol/sc-ship-arilou.png | Bin 0 -> 826 bytes .../images/starcontrol/sc-ship-chenjesu.png | Bin 0 -> 3713 bytes assets/images/starcontrol/sc-ship-chmmr.png | Bin 0 -> 5691 bytes assets/images/starcontrol/sc-ship-druuge.png | Bin 0 -> 2820 bytes .../images/starcontrol/sc-ship-earthling.png | Bin 0 -> 3841 bytes assets/images/starcontrol/sc-ship-ilwrath.png | Bin 0 -> 4098 bytes assets/images/starcontrol/sc-ship-kohrah.png | Bin 0 -> 6210 bytes .../images/starcontrol/sc-ship-melnorme.png | Bin 0 -> 9115 bytes .../images/starcontrol/sc-ship-mmrnmhrm-y.png | Bin 0 -> 1652 bytes .../images/starcontrol/sc-ship-mmrnmhrm.png | Bin 0 -> 1722 bytes assets/images/starcontrol/sc-ship-mycon.png | Bin 0 -> 4212 bytes assets/images/starcontrol/sc-ship-orz.png | Bin 0 -> 2406 bytes assets/images/starcontrol/sc-ship-pkunk.png | Bin 0 -> 3338 bytes .../images/starcontrol/sc-ship-shofixti.png | Bin 0 -> 1113 bytes .../images/starcontrol/sc-ship-slylandro.png | Bin 0 -> 3683 bytes assets/images/starcontrol/sc-ship-spathi.png | Bin 0 -> 2419 bytes assets/images/starcontrol/sc-ship-supox.png | Bin 0 -> 2625 bytes assets/images/starcontrol/sc-ship-syreen.png | Bin 0 -> 1633 bytes .../images/starcontrol/sc-ship-thraddash.png | Bin 0 -> 2186 bytes assets/images/starcontrol/sc-ship-umgah.png | Bin 0 -> 1324 bytes assets/images/starcontrol/sc-ship-urquan.png | Bin 0 -> 5419 bytes assets/images/starcontrol/sc-ship-utwig.png | Bin 0 -> 4932 bytes assets/images/starcontrol/sc-ship-vux.png | Bin 0 -> 1197 bytes assets/images/starcontrol/sc-ship-yehat.png | Bin 0 -> 1145 bytes assets/images/starcontrol/sc-ship-zoqfot.png | Bin 0 -> 3627 bytes data/star-control-ships.json | 1478 +++++++++++++++++ src/data/assetManifest.js | 11 + src/data/gamesRegistry.js | 1 + src/games/starcontrol/StarControlAI.js | 371 +++++ src/games/starcontrol/StarControlFleets.js | 123 ++ src/games/starcontrol/StarControlGame.js | 964 +++++++++++ src/games/starcontrol/StarControlLogic.js | 806 +++++++++ src/games/starcontrol/StarControlShips.js | 1071 ++++++++++++ src/main.js | 2 + src/scenes/GameRoomScene.js | 2 +- src/scenes/PreloadScene.js | 1 + tools/fetchStarControlAssets.js | 180 ++ tools/verifyStarControl.js | 730 ++++++++ 42 files changed, 5740 insertions(+), 2 deletions(-) create mode 100644 assets/images/starcontrol/sc-ship-androsynth.png create mode 100644 assets/images/starcontrol/sc-ship-arilou.png create mode 100644 assets/images/starcontrol/sc-ship-chenjesu.png create mode 100644 assets/images/starcontrol/sc-ship-chmmr.png create mode 100644 assets/images/starcontrol/sc-ship-druuge.png create mode 100644 assets/images/starcontrol/sc-ship-earthling.png create mode 100644 assets/images/starcontrol/sc-ship-ilwrath.png create mode 100644 assets/images/starcontrol/sc-ship-kohrah.png create mode 100644 assets/images/starcontrol/sc-ship-melnorme.png create mode 100644 assets/images/starcontrol/sc-ship-mmrnmhrm-y.png create mode 100644 assets/images/starcontrol/sc-ship-mmrnmhrm.png create mode 100644 assets/images/starcontrol/sc-ship-mycon.png create mode 100644 assets/images/starcontrol/sc-ship-orz.png create mode 100644 assets/images/starcontrol/sc-ship-pkunk.png create mode 100644 assets/images/starcontrol/sc-ship-shofixti.png create mode 100644 assets/images/starcontrol/sc-ship-slylandro.png create mode 100644 assets/images/starcontrol/sc-ship-spathi.png create mode 100644 assets/images/starcontrol/sc-ship-supox.png create mode 100644 assets/images/starcontrol/sc-ship-syreen.png create mode 100644 assets/images/starcontrol/sc-ship-thraddash.png create mode 100644 assets/images/starcontrol/sc-ship-umgah.png create mode 100644 assets/images/starcontrol/sc-ship-urquan.png create mode 100644 assets/images/starcontrol/sc-ship-utwig.png create mode 100644 assets/images/starcontrol/sc-ship-vux.png create mode 100644 assets/images/starcontrol/sc-ship-yehat.png create mode 100644 assets/images/starcontrol/sc-ship-zoqfot.png create mode 100644 data/star-control-ships.json create mode 100644 src/games/starcontrol/StarControlAI.js create mode 100644 src/games/starcontrol/StarControlFleets.js create mode 100644 src/games/starcontrol/StarControlGame.js create mode 100644 src/games/starcontrol/StarControlLogic.js create mode 100644 src/games/starcontrol/StarControlShips.js create mode 100644 tools/fetchStarControlAssets.js create mode 100644 tools/verifyStarControl.js diff --git a/README.md b/README.md index 41dfd47..679def1 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ menu categories: | **Casino** | 9 | Blackjack, Texas Hold 'Em, Baccarat, Pai Gow Poker, Video Poker, Craps, Roulette, Bingo, Slot Machines | | **Word** | 15 | Wordle Race, Scrabble, Boggle, Ghost, Word Ladder, Word Search, Hangman, Spelling Bee, Sudoku, Mini Crossword, Tectonic, Bookwork, Kiitos, Tri-Ominoes, Jumble | | **Logic & Puzzle** | 14 | 2048, Rush Hour, Hexsweeper, Jell-o Monsters, Shift, Mahjong Match, Jewel Quest, Zuma, Bejeweled Blitz, Mini Motorways, Dot Link, Katamino, Genius Square, Block Fighter | -| **Arcade, Console & PC** | 1 | Colorado Defense | +| **Arcade, Console & PC** | 2 | Colorado Defense, Star Control | Each game is a self-contained `Phaser.Scene` (plus its own logic/AI/data helper modules) under `src/games//`. Games are single-player against 0–7 diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png index 3aae5dd0e64ce0a66c44a2e9502195a3ac12bb16..262d8bccd8e904fce4ece372325bdd9a2f4abeab 100644 GIT binary patch delta 46710 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 ~klCO5Z)6!V8{4dYE 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 z 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 MSL*ULFW!XzA53msumAu6 delta 42242 zcmV)IK)k=QtrPEx6Ocs$#T0=@g+~E}M*@XM1GPs4eIb86;4CSs_Ti^<_swx~o}JBk z2}?#Y0=j|`1qCB7_=+eXprW8CsDKD6CXg)A1yQoFAh0=y+1Uv@`Oe%NJNH-Bv+y?o zn_zuE=lp)XvvcpAzSZ4Tb)Hjo&hz-qA>@iWqCA-s$3*C50{%7G-6<)T$e7dUc6ba< zDF#y&P}YA%PN`R}nq8wD`^5Fvo^;0L-`Xuu*)x>;er?46L7xY6JtXjmglmXzE0ciK zxTkBhaM2eo{pwrNDShp-OGZBQ!6j#Wbjg`Zg5JP4cMPfvY9a=F3Qxv-v9H7^U)j@p z{8mP=1C4-{38AHliHwRMh?vMBXGjnu20{e$hy;I7dWm0ENZ>5F^H(rhx@A#>@AmFdF>u82>b)RfLgBfL<6kDS?!k0speQJ$#*nSMU^ts zH*9v-*Mz#OJq=6e&z$%B5}7xHCs*^j+|E7o(9(sgUh4UDG;V+Lljj}s^YO8*7k+F0 z4Xb|(dDU6@*b3v)2Y&7ZgFc%TeKrB9);*oilhHUqclXFk_q+6Pc=Ts)KIk7^e|`AU z%P-HAN|n7{LX#_rTa}>DbacD(KRyC!>>P90X!bIAKvUq#*ZX zGN1Y8mKT1$;jP-UYDbqCmb)`rRZ|I9wazEKe_lS7AP9?m z7ja^BO^vRc9WPWv@PuGUWQfk3j_33-(iYPtAJy~?C=j5OCBL&A=`>^l6p3@!%PoI) z@Ah>xUB&7$9LvQAB2pCuDUf;h7%X)6yZW?vzLAxaKa zwUEqJ;N>mE>V)0e^8S03=5EU)jK?78Rv*5A_7^cp9~XVC2iQKAYi<*aF%Tgb7>FpRaOd||`* z_*mcLPrQ)*+-E=f0lkvHTM$HFZ9ELNItHWV=O|RFDWxgCEpn}L$g zp?mF9s9m}p`tYy|a`a^c?@gJ3(Dm2CEaaVXMSaIA?1jI(cjq@wKl!}&j*cs9O4{kI zcILx=hlwLpQ!Yq>7sq8~)5?*tKYsV&2X^Vvz4t~y+=yGx;sQC#_4#zW+EgdDkm**V zs@X1xbho|T8D?TdQzd}2LWX~YL^Yjxm(&-uhDP8e7lcfZ7c&$3-sE$pJwfZk^Aow5 z^or?u%9`%xX0p2qfLO{uN2!*`|3!&+xiIO?F->E%lG(b|8BS9zXM%NCk#o%_?d~V% zYaz}cMw#NkP=yYrG(qj8_*~!vDv{`(gaQ`=EctqJjKiiJnTkW4NdvZ3fFD{W15GQDHu?#e&Z8iV!YbG>v>MW%v>ki% zOzwK}dG)asYjEtLhhj>7+@&Lm26co24w|Y%7%LLTmL11XSHdlFVOpo7eda7OKS9* z#th2pDS#3wP|iLxPYa$@SJ8xO%i9!05}(3R1{2Opo{QoZ*tp3R79GP1T;W9Gs0A7j zlIv1Xait!Mqytnkc?iV}1_-zuC7nzhA9_d7{KNylw=Q1t^_+jsDODq>A|gRQ22=tJ zA{+walsM7{M)IRN65Kn{^?k5!|NNHZhadcQkJr!5u}Cmh$OTYH@Fbv%f-&h9cm;w& zeku%+|18ty$c9Uo=#M8OOplO2XC zYUDFsl!&kJcp86@DS!Z268P`|$RnhOlkd)w=aeS?s5(OcQ#F4?i(jHey}PAEbN)qF z>Q`O-#Sa`Zr~Y}rS86AnKrL3R%YO3frMJEG+KPAVkB=?=qeG6zuKvcQj~_gL(eb+D z`RHh}f2SZY_aAfSdHW>U`_D%F!=^W8+lf%uCjE@=wvd0mNBQz;feux|k>rU~Ao99p zswA`#iO(2=#~XwxO5n*H7YN2JU#RZkWMc3;_doPh*PgcJ9{{DEP9%>yrNvnKn09Hd z$UIxj^K&+J;;W|}->^7e9Cw~sw(+c>H`JCbuMG@m)pEX6wptoe_SAz8eSOJizOeEa zKmYmN_uYTH@ygSVVgEEVxb&&o+QnZz>a30g9)NM*_T{b)!5iPT>NlTbIoZO3Pu=n6 zvidbwU(M6#KVK@j=>NOdes8ZQl1t8*Q@;5xo!?`F)ickZKX&X3;Rup#KJ;hH=t@Ya zomEE$d~rrJ`SkYm{ids{`^0=9S!07)YZQjRo6B}ljbmnJ z>$DKZduLg!@1Q~@ceZ9Y^GAlqNMNi(tCX;H!)9z*x0+1CA&2JlP>Bp(w-|wQ7%yor z6}f*e6f;h-ShTnG?$AB5Wcd6ZjoiSJW|s63jaA{=J+b?mJ*W(crNbjA~|L7SX zp556ydhhY4pE9Satr^v=tth3FNc0Zi;fEi_wr$-m*PTc9yQ^zdPjue=>6kLB9iB)X z8c#YFFV}(cTiUtdr6c?S-}^+VbOgtX zhf9*DJ`}D)DAEkO?fr<&E}}KNlT7NXVJ2TCK(h~(6OTe<@nTdScoarmJv>`Bz^Q+) z0)OPZG!2gEC2gU4?=^1TA2beH-hh9~CJ+_L zeN1|gWNa&Ms=J+w_V*TRt#Pu%sYe=@s?;ll+^_^@PIbA@!8mB{1p zl4ld3OFC3Poq#{dUEYCP*d>3^oID@hy7t1TOcdMDhi6gPMprLoIvo=bN~?b9s&_?o z9jmEl>Do4Y3@z%hkauWiAxOaOf$i~->DR)DDVt(r!wppjPrn;0-ud=c45w>+T<^%1 zbabUGmmgXx+K^Pz1p<^dlk_uY_c(m_w+ZIhlR}_*j*kSyfvN)3>O6mpB=KLSADl!} z8%U5tf)_>&fz!!g8!w?WY3_Whf0 z@4x)Qb5A;WX2VAc75#rt=Y9RhTlVyxeD{YxqVwf5*n>RIJ+;JG^7ZPfK)2r)Y%?vB zq1)aQED?;GxI?SbUEWL5x9P46<*=m%WHi{xTr7{EHWTm^tTLs~Ws&^Xz#H zv#>seZ8;I^Mq_`tf4PiuKt^qYA92wE-F1+C0%lG3V^))hv8;_`sf?Uvq2aJrr>Tl5 z)yFX66V#pKtDe2|k3YI@)Ar8J-EQxO&8uINgo>MF3gHxkJ7#!?(6aO_;&lC&AN62S-Xu=PPC=`E=Qv@>E3l3vyEX$Lj#*L8B znbmbo9!?}qQ9Qm}p^zQsSYgz%8gh{av5yx#GRv`hA0>G`zlp^f=VWpQq6A2`wFMFM zav12&qI<_q0w9CP6&=uJcuHg6H%e~oMh7D!N%A=liL5>F5zwgdK^Uat=PCy2Pg$3a zrfgE;U=M#)5c-ckRi@!Br~$jWEWj3tIh3N!cxOk5%1Ijxv9?b}h$9ZUA#D?VKM!d*^G|_%DB% zQvcQ}d85J&jUZe$D3(Qm%&!9J=3zmHLi$~&Re+&6;r(&H_Q2>~D|`1dTWvTaF2*VGYl!sBG^vi7~(^OvmRvL5W;@I>3LSl&0^sp~Ce+Pb5){dN4-fp}geBu*o5-%kX3} z$TDRVRSs!o&K?(CUVqzNbY8w;|KGCxXJ7fsRiiilR;#h9$AW){c#;OeK{Vtzu`p}p^h!<$>VtJr3^#G?Mz7?oV_2@s z>y$^cJ)4u4eD>3O^v{%>t@bKtqTv$;dcl^w{Y6l_fd!1<%-?@3_poSE1;(2GEn++a^F z>BIt>!uA z3pt><5vHNNcJ3E0*>8c|fwWLK)~Rb+AL;iUbZeS|I~o8BE|_Fp~TXE9iFt8qZH~Te@J9s#Jf`4N|%az`D!>?)E92 z&{;P*UV=rbf|!X4Vx55d>WD8>6OTUerT@KEzx$RiTz*)S{=7d_QCKSr-nX3KkY;ew z!4v#&<}m0=C9P9qoQPX+N4X~*s@&lcn$w0wT^>c(w-aDVi-M>$kBcSg-ngUfB3~x} zl;+!2EHEVKbNqkA*_Zo}uGAruS>{r?2%3PHyK^9c6%CB%(xu@t^W`mloqMGi)zYAI z{beffT!+pbI(oaeqN6=EvGq;@9E$*8O6=ay6o(|M1hCa%bN2?k{_5-G@_Yz{0(+eP zvOD&vo$I^Fj~~(FiTY_%61;RKqn1BRu*!m{p>r-(C;@-ATA}P}nlm)7S&}DKs%;w5 zd)*aaN;2=ItHjJiI)p_jTgcT>=PJ;U#4aMl%WhvtkT`j}T<;idN8S;bb?8@reCLIQ zJ!!L%N{#lGI6F61W-YoUPm3)V}L5hP_q1x1j#>+#0~;_$_zr&2*C74A*GVY7|jHH zXOXh}s8V<&^C1-Db#6KalX0+hpbQTcfDnA>HS3VdrjUGXC#FuFPI^-TDZfBKrjGQu zg;;(FfvNzyN;)=lYvaBsd-;2OU)~d|R4UE||8sxE)_d;0?aMqby-WswjKo8bNif4x zxmKGp6wu(u)wZWI2??46x9>`_&*TNqkSKcoN)X0%xpZ;osqbI1*I4XrC7Rx_rH|d) zKfUQ=Uurp__MuE6b^&?mmVfuT`~LXk--E;hvh4ov?8l!!^Z665eE9n3OEuRRTHK#> z;!J-_sEn~x5S;@gaxarfKw+Gz9Zn+Pan@-9yS4&mYglGi_(iV&lT+jBd)?+$>tFm^ zu{5J&Jeg@A=x`x%ZZpXA|0gnZX_Q4b&Wets6)i^_gk5s1Y+_;`&J|vq*puT~<*$O^ z7X+5X9#GYn{`#DC@wYzU@9j56GR6E}U(*ZmtsL(Bn9pyxy9b zJ!9J0KfM0hW&7=B{ukM)OFm7<-Hu(yeXC!8Iv5FurW0^^v?NMPM9G*d@VvMBl%q>v z=Q1$LB|V)dSZilI{)Dt!#-zk!1GbIs! zncN1*WewYQl56I?U?m-1GL38*DO7qh<*y`inHTr=ne6Nnv~An^NZ`_ke8~Wn*WR`^ zjCnKWqIOy%0ueu=(GYY3riM7sn`|EM`hdzYom;D8qAbGeEFArINB|X8?z|v<*0uzH4!B8wO5)f{Z*G0h%SW3G6 zhM*-xi-1Y021A}*KM6ciQ;teR7o2#(&;D@vAF=YTJ#MEo^7Lg2N$68C#! z9!}&Pi?LWo^Z2>4Khm1rv~~GC`}_XvhPf%n-+5@|Bi~l)tEaZq+#pANQokO5K+c#7 zOC>>rGX`&%(oxya`b78$sF7!+FzSmz8z=Xdjlv1===_tl|I}0hO0+zyA7vtA3zJo}Z{%#a(Hq!L-eZFe(#tK%B=9$s2ZL!80I; z@?Xg-kCItxYc`!9KJAQ;?3Sv~+u3dbN@~~t^bb!(BB!0`jmP(ii1R?$cw)@v*Zt;~ zYV#-RUP#qkJi8-dt?jqFGc2>Ny(YQ7ChlLSbFr=eGdD1NpxgTQwr$6MR?7L*x$Af* z+m*Za{}zHxyJDJJrSkWqC>RQBn&HV?q>JrsyMD6t?t$im{`VqHuKj8#&NgU*?2{Ch zsjX%)El>cb8#s1;m^hDGboHr7&;laXLnf7?%iBqF#`6S(`b5bUu9rf7nt+4FhaEdG zXYRotz3Q76?A6xVr2VUZEn0jst$?TZbNStO-1?J+^Jmp~Jf3-?D4fQ!>}-`~@87<) z`(Fsn|1TRSdG3%D|6;YAKcBZJDjyA#aaWlpNUA4vy$yTUHnppw0yp=Ot}eP{=y3nc zT~!hsN*!cPRQ*ySbA4hl*W5*9POHT!;GCKz%&0 zLlF3HztP^`-}9XF`+M-<|9<{M{Q*wAx}&jtbU4Z=;lL;Xp_Z$a&dV}6*TfdPqT+t*SvfYeN;HD0G(b zk-1Ybe`XlbV1$5vFY<*>Joox)tY4Kx=ZdvRY6V1V0+D_B{ynuz`qxo#`D1$gYQ~{0 zoFsfKGnV)WYpDyWNQVeg9rj8I9?678+4Ux+Id%` ziJMka7YAQ4;AKewmX8on6NK3$ASGo`=G##&mZ3{Qctjid*ScU8!w7|Z6THMs5R4^a zq)W&Bs|HDbKeu-7DDcaNQu$34oB8wV)sL_F=Qf=CjXOm_G$L6QguGElOMtGKWayr`PLu1YX`WGMxU%dFP+I$7xRQ zWV;P0sa<~cldl6$?(a6dCwBK8cj@Q-J)!tJ=u-})h6c%#apy>F< z8Op8KIMahjHcXtUjBLq4rZ|Sa!CrK3EaF3_EuxD%2*<7r{7jK zY|{3B-p)DiB8oJIcO8E@z|DEr?B0hTw~jyjkPEzi&MX)6pRK4mN27IPmqN}2HKgNm z5jwl13^iv$ll#qzZS2ake51gareruS&!wx$O}CV(rZ3(4q`J-%N;wlilNS82?$$X) zr2GUmN1;GjG*BD!t!b=_p7DzZUi=q=;9Guw{n+xuXU_gmr#EnF&Pg68aoUMK$*d2D zM9J^vIKjg)o{*P-ObLQWzE|QPDwI>MV!fM z=$6+Q1SREaC{r0Yt5A*69TGaX455%=uqlzZHVtQ=8!Kzy)b#Qi%h~%XYihAz#|zSiOA9>ptb#vrjdL_;BnoC47hq1Ywx1h8cW$I#r`e!%i{ zzp!+&Uik8%iz}BNw{TjdPM|v21qcCx ziOKWTO%assDk;_>p^JF&i4~~MjpN`Z1`)ZL+`jK z(D#PNdv5>Q71!>QrsUiX;Up(-_TNT7PuWs2yQ&ApkZD@MJrv&4AMU63ktR5vTt_geO zMnzPsRYUiXw)#m!g9?>ML61*&438`cUPTlX!_ovrVtBuYBfoOwatub`4Mq}JMivAD zNCg5!xd|$x3WGu}?{xL2D!u*V`DCH;dZuFCqnhS(s#bbSuDw068-IAkqo;iEoMXzG z@|l{dzz3UaeN{ULy0CtK<7lPDE{QMnNxnc zKiaXccK(Y`(gKa2Jbv9zZ%m9PTY_8HEg9dyKxGUQ^0``4P zQ&-f*tIzxK%lB=?iUWNG@2%bbt1r_n)aw&{NB?^LO9K~AYpy*=%RgmX_&C?6KvW?@3_upAPgT{l{B6 zowmR5KbQUXo;z>zN8|MuivnLeGLre^&A0sWvE?gPz3ZII|5bo;(try-b;i3EA(x6p z=i<+QgjR_C!xq_pC0BljZ<#u+Ht5443ZGjvUPw;!@s61(=zp5Np!v}s-gMR5$~jHi zr0tEp^!ge)f!=+}3Gchls^sP~aE@VM9Z9Q01QM>xvbc*{$lbfP3G^~qj%w998UX}4 zPnRIwA{Vx(>Sk8eE#9_m-nLAepfnH1ib0N+0mETT(_+YfYa(6b)HIVRs|IB^G`wEV zut$-fjf6uF{rZ8Yc6;@`cd(zfePq}1_r|={-|!5_$db&6yhKa$c$t^IptvV##GsMU>ZKf1!x}omiMc01wwr~7LF(JIy)-|{+ z-8G1Bho{_hbv~6`SQ)iWDi&2QCD`YcfUUvzd~HjA-Lk(dyTrkM--P||Gt07^FI{x` z&7b@1r*1#-gkvu48yH#rUVuqz{KJ3v*>SU{&%WGo7>;9|0Eyc@1LHZBW1Mnz$X9YK zUHACHx**9M!|*ICNQPQSdCP^AhvRr%_C>~NSZ~CbC91YgCTiQ%%Z%-?%xEU(2&}^fBu;W{!{G&q zv20soIpx<=W-b2hdvRk9v>mp1F=O=%eWE4GeLqoRLYcgC#)fR)JCaXN4>tdx)#H4r zYKk&6FlvA9g|4pM;um~3?Z)fALp$@puH%t^1KgZ{Ymy`~;ZTTaYOLpuIP4&C=G4}J zR~CE{&xAO}4tTsCza%SO0zl%9gOFvYUbR#R5eFQ1EGk37fHsIoSCzSH%N-5sXfEi^^c@y$&P#5uS5F?SXE8 z`0m&@F1vykMCFugzxVBbS;lzkxxdoA=+(Q98{hNB?O%IKuxC1!Q&eee=Ei4q%+b}( z{Ys*|ulBb;Jx2lSbGwc=tq=b%ZF<`~&T{`*_RZyw_h47f<(n@i2Z0`PO)DNcVD0)( zvxn|?xBK51hm-a$S}vD!esbe&WpeC)HWl6eFur?d*A;$7J)WV7+Da7)d=xzy3+bYU z8b61)mjm&!s16z~lR9r`(A6Rcctn{@UX{X_OPS$t9%NG^kkZ=`n{h1UNF&OL?T~{p zFuVeSED3H@q2`95Rr0RNM6_`p7%FW={yt6l9*ad(%0=wVP;y%awE-E5l}3$!P(+}* zgC?0bkq`e@0LroPaa1wRwVyqbYgoFI<6d5_?bd2Cxoz^PHFj{pC;Xo4ewI0tymY_; z<$kL7{*z6qU&TgIX!Cn5OB|b=}Lb@Q0!` zlEAUNUMa9Np;;}L+^s>`B|DaXCJHk+ntbno^UAKiucfoDdIlK#@S3npo%lBn^!5Lf zF^}K$y=Ys|^CB1YwQ`jLPg@I(Tw7rTj5!WQ5?vj2hm3EgR(9xkW~Gk*_?<@r#!n==Mz7q)pnS{kIw~@Vt^ATt}TDQj&sg1;b@l_X)&*QQ9mjtREG_ zfMIa1bJfZj7^SpJVL^F!DecB+g+-ZabdHXqrO*dYeF|lZ!}gb!qGn15veAQG$*GXc zK~W2^iV1Q)jfBn&{8iHk7?mKClMSdAG6fB$u0b^%j2IO(hj`>NT2fX6ON1g{@(lFT$y^}>aW+#4T$$Jrai?O8-dL_jFTS) z>udqbmf;AL=Z%A{t6-}{hf&CV5!-E}V!h?|>VdS+e&&4UsH5I@ST-~G@#OgUN1tEy zN|jF$U%vMjKm9L%%Tk-PNt?7u`_D1HQYy0Jwhmw249uIc2yA~6t9wSFlh^7a5@s}e zQRf$2KBIJclsMuFII9HHmY_2t`b#$Y(i+-hfK*8UtOzMs1Hm7MI5ks3Ky2!IVZ+g+k~bkkLEVgV|Nt z_gtg&y|8I*GdRnzcumtOy_wlR+UJzZW!QejDfXF|b?5*tInOw~r!Rc;Zy(YNt$IU2 z1QxR_bAxJQ&XPvv3+vNnP>i))me);AX=G6q=Wz0|%E{YCnm*H$?Z1h~Ti3mRC#z3VC@9QZG^c$$Tkh>$z2~)cj zZE8z+ugus*13iCv8Et1A3BjunFiHBHExJ0VG)2y4*02#yILd z90s@NT=~4J=6b|h>%sXsnleWKrwq%~!7}783Gj4(FhM`VGE5C7^4t!{>fTFFJoJe~ zD!qjQ%I|&o0`3z>9{hE&Qn-SJ9k9|_hZP8*&h(M%=p+w^w412|mGX4-64;;w$TDM^ zVcRxraN-^dOT6oL_nr4O#>1X_)pGKP*O&J&t5$C&!K#IDN86(Zx^0s-X_Gc-|INm; zg3Km=xmFC86om5?ENZR6+@>m5GEWpJo4VmD15giHQ$GdcLt9+x37ZknUzV^ltC7E{ zLwju$Yj-@0P{%*mk!j|XnLqpIH!4pI(mqH@vAS={E%B_Q9s8Ay9@cBb- zMI}d^{9NL+7j?9tTrRkBX|zgm?Wl^N+=z;Q&F_~YqN}dFvT@6nEtk%jJ^Rmo?#0*nQ8UCS~C15$mX&ZmlYfw&JGa#ys~CZk)YI{ig09q zX`^Xpr@rxhj4gkxHOkzpo25(IBQ3w{PWSDXD$chv-s{D&M;vtU$mlpn0OjVl`rKuw zw>M62ZCV|nk&9!b6msGr!Kkg(QCdPUC>l_CqFiH8t0MSTnJP~7$Z9b zA=F2JFI};%0V9Rr7#T210E{fdEK9Zp$>zLemea=mV^%01dFTNt77Bl5=fJ>!*Ynx2 z*ptt#!nUqKXyrU=o9pv}$NRej-Ns3qv`L$^|7PO_Z@?Z-WKd)nJiDP6OAeZXEy)7v z{gjB;LRr%gcT$K}H$fy0RMb)^J0c2v2paMEN6oB9O(YD9fWs+^PC}?*CiMI;^yCf% zS`Wv<4_xTxPl~dFYQ72%apVbqrwyDWz#Lf#T`NN?W!<8H{+x!QX1c3BqBn?!aFD#u zpir?8@ktn0o&B;xnakyC)0zC|pZv?8FRGLZc5^(q<@~cw{cfsa|8eP`pMKXR=;ERJ zxwd5;CHRD3U3>g1$7yA6U7L!{FWox6r%oMZC5t58bgA)`V(n2!OCn$2sK~r8><2$MDb88H;`%o>Ijlv0fMX1dr2*-F@V-3w zTEHqt!O9Y>)EL+}0hS&Cc9C~1U2ynni{-3wa*2DTw71{w=|BImy{^9TF+HD}-P_lV z^_zMyI53KGF-69bg0|M?-~aR%zj{}A)h2DyCT-FtZTE~HN|&vkl!4k%1*#Q*EPDVw zfx$fMlJ>^+G1%#U6-bP2A`aF9=Z(UM&OzSb&|DRE!6aS3SI zdMe~lBN$GA%go_8h=n648{c-ewFkDP+YI z@~E*u@!gE_VK*x7ahH`ofc=VUBCs~%IUAT06f!X3|5LuzcJ;P3#yKxM z)%t~jRPVd0{km+~iovtaIw^YA=|@HG{PlzXLO6TXX$@6VTBe*kzH{K0gZoMYMl-epgBf@ zq_N=A=Ec4@D>zgg`!%CGSx!=V*O3IMO@)ejk6x+zvkhl@$Dl|`jez4PV`zESsftP(K zkMDpKZpDNM#V(>?kpNFcLWPRpR?UVpvIh7ZHxCdAB5w}8S#&?6z@_d#ZK zME&0V0w_Op>iZa7D``g^eKh*Iw`0rJZRj4$2}O0sg*B1T8K)dJ`^SvT{c-2;Sg@&o zKC+;?Dn!28^J-gj-P%WMGFgAm-E#g^4#=`jm zc@m@Zz_Fe;{$^UV@etY3zNa$fxeul)NUoiR-UbUydsfQ2Yk87%t znrfrbSH|+~^|;`~EY@Uw*f{FN_RPqC2~OBQD{#_WWsS-so%f-OV-TK}YU{}M{Mxs5 z`bnjd+1H=g@vf}S{`I+6j4yrh?7{g9W*-ea_~1XkKQCDKlVH3~eeB5xfBu7~`}faY zG0Wx|(wAYH(9&txx=CJ=!LR}-bs!Cv#`}k`YUOHp$#ZCqlHjIfB3@gKfz5q?7$>kY z$~ee~0_sJXj1L>*g%T34ZGvOiIPSy8yMT1r3(F8e0$&`2jk*WqX zR#!n1MHmEDDCH4YHeE3c6Q?tic;6D(QUaF4!XwM5jfUJDOIiU=ov2-ZNy634)pgLy z5?&5~CFiyE5=^5EfdEFTvqv`{eO^C=Vg}EaQaBry zyKOj&L`mbu^FysMnYrbEkg0W_oHDodS9Z`Bp59S2wiU7);dwP?r0`(Eo z7r1N6Ne%Ds+t`LGa?g$%(-GEf><}2Ni8D~AQZZrY&D=Xt6d8~0y!hjpA-TDc#jTt3 z_^mnyW$KY&1`F_Zcf;N!;F(QT*mOh`Uq2*`d$-1L$ow9xvm`u!mJqRQX)$T8>@2H> z(d0CQnshOUj-7q{sb_Y*{I^R9nA~q#>MTnl5E7JV$s9r*O|Y^>h}99&*L2c{ zsZ5%UEzi6{fR+J&O;E8T&0t5;hD@G^N*+Og1V{}tYta<0hFY|-t-BYhrlCdvcI?;! z;i(cCD}u{>kS?mEk5j}?VN-o{OPWzf zIGCIPiy%dxuLz;E4)Rx>g~8({}SK z8NmYURcM(zxoufk84Gsey|010^S5_$;aL1@&=S{TdgAq1kagd=%wdCwa;X_Gc-lQwDpVm!~-a&yZR#6kfy$D(clPMxG7Gqf36*^i3e z0ybC&JJ1Aw(I^t2S4SK&4}oJQbX7?vni;i1h3#lVz?isT7ck-ffcS0@uVVd@Cz|nAIGo2N+bSE(Sae0nD}>u$`mu zh${TY9f{hW5v<+XjZ{HJvRvR+_NEU1%*^P5DbqdOe5AS%3^1Oc-#ChV-7tMD`VAkpORbpd);_af1%~BC&fW6Xb=@g@q&pt!ik&-S zu+AHQKcWh9!T?kb`amzfcg2Os=5qM%jW?w-1H*jf&~Jv))i?Zo^;7J;p;hh~s- z6w8A95zW582}Rwz$y?BX_LUtBtS9{gfXr%tVjaFyyiGVCWBbhes~hQ zZ(q6i>q>QX_;Qggd@GYLGU;>@JNlA;C>UPoWPYK#)LzoQP;CTkL#=5$!WXal&bP$>1k3!Z9C>I7%mLzy2A8e%x z4nuJr>vH-rdJ!xygAFynv^9vi1pEzC-L3N=IhW{(klz;(4==&eiUf?B1XzZD$lr3< zK9a(GL2xT=>$n_bONGE1P+@|~qA9X>BJYd9z}lb!-Vxv2f_v_v-^^&6qFn0rWT$X! zWtwBz3KLMg4(p*Tfuv0ZFsw!n$|7ijv58-1craXuIDIdwI^qb{*I{HRfi40h6|o+{ zSQSRbQrNI%2l-vchP0yS$-3EpH9|}eM0|xvynOBhzrA$X$3Od{{@uO7448I!lv5TI zB>v!?8Ju1`qpO>7mlWIVQpB}NBWX%uGg~!N zj>MX%ytoQMz6PgD)A33*a6`TA;<-Ol9D;4tyPs7EHb?u8fpMB!kOP=mc z@9X#ez3G~!d%Rx6k63_z?B*V~uZ|PYM>Uy)@i=-{u0rEX8J?U6hn=rPmfwK2cJFVge#=6kxP*6*#r z7%Rgj9f!+|+3dgoC%yshh z;aCGiNq%$nEFxCUkOlD^fzNXc6M#*epIgjhb6^;{bE-~MvFV-(xLqMp7hIl<#bre2Hh2{0&r44DcWqqQ++L4BO-$8+Z^6_vu z_@hiFw_CY?lw7>wm!f}4UDH?+C(Y|bC@>*g?U3)!kv6w54g{}&(-5mpy?XASFb`{M?J;|NdOhAz#yNf zZRvo2qyfE4UjgS=P*q-y9kq`j1!gnY2lpv`PDKG2YaF zN{r(1LM>%bDJ3wlt`nkTK(R_-s-}UBPlK(*2w>w}VOU!;U|J?Bg%t9AtHF6A@KQ-S zhJ|Bl;2a&aK8F_RIJORs07SYv#1nu?x5`u7SAY{e$P{e|1k|Lw(@-HWQBXAmcpm|) z#7XLDFr^fVgXsGjV`Un8IQL97kbT z9C4=f^Q^ndViUN^KqUY7K;%6HXjyK7(b{#}kV9ZRFJE{49| z0T|^HR4Nfl`d6J9Lfjt!Uot?`Un$wSRx0g{LJ@P-7e41b=a@r&C>PRStkVsf8SE06 z!VujIfaiz?2;n)fv_2SG0is+05(8jc+F?Y!gIA~F_C6dw*>0MQ$+m4b*>;nSCwsE( znru&QvTaSaUBCIBbJqL*3-?-k-TUGWT{Swqby%zl)9n}Lxk;REFf)B+lOjL5Vqe{+ zqO>T>d5cbD&T@+00WeK;zAH&yBd+`i^qlZxI#H2oBnm#5<`gh17!JbZP{|>xct*R?7933`zPWHg_kBdD+h|%E zTRO!8(N@ql<~?g>J>+0>bV3Ac&|?@*0YmNd@05xNFFx{v>`KOxXzRpiak4_>0iD=T zb%>7TYyd_)+SN-WVWXmz7#|_YCDt~sOGuovvtViC5(yrhVPKJV-?9fX;Yt*SAQwW= zX=t4Zlf>kAk-Whk%dYVKuQ1H&I9B#m_isBnYc(C4%nL+W&O7Y(W1P53NWS`RKKDCF zA&kv$1@1bJA#()~m6@O9i@?oOLZnthI~?jx9*_#b<<&q`gCrH#`BQT;nk<6P>Fp?oTKvh+Uu2hqVxtPkls0YPe6scQyK1;4X zioYDR>fqx1#W%BF>Sx01k6XQRGN1Cjl*#0+G{7>iP3PU5nNqj5h2YouwpPHd<%vy6 zFQ8{+KHI4^ER;lkG)UJ{kPv@tF&=3RJl?#x#&5&BwEXmnhk=0*$5 zvZnDG^XaTw_V66u--~{V&^MNvKqa7jLS(_?qY3P0yMuz+&|@4fL?j2i|hF zvBbBFYFv%HZW6a1&j~|;(yj?=JG^=aMxJzi(7`+5WoWF#Aiu!arbqJ=wQ_Ou0n9ho z@RfJpphXDo@L{5n0uSlYzbE1U2>q)GHB{HgWMTt81P7h?`hveXnWOgtV_@Swl$)6T z7GFq45glEs%*6jg*uc{Rv%S1s+>fPAf71w!{F6DPr&TfOo0{W)IpB}}Xm>;t4~JS9 zfmk_z++Lyq$6~m=kxWvN$UxvE4uyGFOl!9l5#qwO3XEm0zYcS_3w9Qg7qxN=B7{{L ze-m+7SapEr@5rldF_9l-kg+i>KE#DYOQ(ci{dO|(i3p4JVKbVAq=01c*6?YDwnRKv zyh<@BaZj0JtagIVl*YDn-w-oY?}~#h<;l311*1`Yd2w{xccAU2$PO?MIiTC^*aAQr zdX6y&mi3;Hih>=4hkhrZN8Xg~F1U(C+aVZ12fB>R(^}gVXhcK`X11(>Cr#+X9A}z; zF8o!+=)!V)(ApAt&jAI~4k)mTkH`I>)-91?Dues+#7h3&LhzvnGPS$jcyKm%-D9tN z!>?ifrg>dyhjQ+t8L;~L{`V}s$A)*s~ydrs87?xT(Ou8?XkK) z+w!CwfJ1W<4`>f}I1vLxmk!SZt|={_SsU@eS2qH#yB6St>rEp;u0#+uGIYxN2;0f* zOGb8)0XY<8P-ZQkAe8(SDtv?zMU0_<|Ha2g(A@Ju6s5TZ%BOpC1-+e>tj_AqxVQ`5 zoYDpjX5e5us_@S#Vj2+BYPt|l&dt~uGlwHTfKyzELfrkSpc!-cN>}otPBv~GHjgvo zqT^s0(7ca4-eQ{8nDxsTf@D&tqBHop&bLuLI8Ltv>h|>_w8vY@&yBzM7+r#eHQ2sQ~s=5tHq4A-xAnNxMv(<^eQ>roL@klt~kW*KCCk zUNEJk^ec(2wMf-pXBcS1l^F_+FdMoC3laEMO94&vDbK*Co2W@0?=6DI7`2><;E!b7(0uU%KJ90g z@f2|G>VnHk`E6Bj)<=OryYOuwKQMH#&5XQ&nQlUE#+E6Lw2FoX9u|f|DklV~B8-_m zS=2%ihR3S`HqE87OMkxH^(bdTl|^p70cPd<;)~K025NdqvTytOU(cBC4?TNd$&5_j z2;BK?wOggdX}+&qICwvg|3M`GU;Ad5XQ=S5I3$6U!P7#)7cFU|v-l%`5E53LUNLEg z3NahpdT}0Q1qtkr5QY*Q5eIfI;gIj%_=`7)1e~x9Wuo)4LTpFZ`ccm7Nfm+@R}8yl zl*ZjYE0luX`yUvIpLY9F-S7K~@7ZQH_RaK=vF9rxdx>MPEK7KeJ%Pi`w%kav4U)?A z3}76JkG7?m3T9i6A@%_9ybWk%uFj@3Hx|=R>5D0+41!I)kxiB;poNi;z^_TQbPtL3pf2T@$mz7!TecAJZ?; z?^hcW9rB)r*e$p^hYxwp0>Y9b-7$%2zuS65((UP@=mtzCLlX+*&!qx(N{^sXA=m& z>|XU0&&2csxj*>tJ9Sbc6N%pcXxIblBa3#KYLM=5SFksx;1X>T&NF`kh92{&RzN3w zh5`R|TfyM7%Hivw)I3;1sstC`6fCMZCcN%Zlq7Urv1a^q!+k zto_8@ozlyOZ%_YZvJz09OHPN6*|| zwlQMY2@f{;ygs&HcNxoV`0qb6n9jEg{)DP;mBgMXE^-5)^( zHC=arDEMgFYC{&Vm`%kDJSu$ zibXk!1S=eiD>76V7GtbuAR!+XNaVFGiQ;hy^bZ<%eID9>GWyaA)_>{@o~C*a#sthb zBYR(WBjozcj09Zdpi$Eq=C#BK1J|$@M5#N$rt6NE^uyH`J;@8uEM(#!Z_~F+oDj#? zO_m?ax2W^QEWtM{Zn{~2?%rcf661Q9dJmK#45+`0{EXc`Tkvi5^UAR25R*>I5^RHr zJPg;+%($i)YE&~%w^1AGvPycM#cN`TSt^GO?f=zwJe_GbHh}T^@Eo910_4{TmFfsw z)#Pp^$5*vvVeW6XJPfABK2FiOUA*iY1@rC-vlDU^Zr)5vNwB;gsR{xd=W`ho-x~Zv zUfQM5Mlp_floFYvtGA+8`u8k3xO5#=5IfPuaKH;nONfWQxBji1pi+?A9Vo31Th0~^ zT=n^w{5dU4&-d~~LRCjB0*sJP{t#>)Q!+~w9$8<37H6*NXmY+%zHO>Qs1vir>G%5;;q%j)Q z(j+BD=rM(#Tj!Il?V*64a>{q9mwV1CO!X{v!c<`OH}(aCH_tP71+F&0rxqcy4l%bG zhVfG`>Qf0iTe0CiKCcxo->kbfeaKEnE#^J}DF&{~ELOIvm)^pmKhJH0*aK4k;*u%E zO9Sg?j{j29aS(xkT|tVZP?FIfR>?^1#X~|;P4D?M-L6> z4#2e=ut2+nq0Qw-_|XN2hB4SZJTxLUETv+}ztZ?F2zYX&3QhWA_Az%4 zz7sS>(_b%Q=?Le)t-OFDEZ|8t{>t(WA-Z0fQ)_e!>zGvWk zNdJK8rgh)+ayD?cACfl-L%E{Q7G=sPar?ri2N$^kjo8k7h@E+br&in37N1X81n|_m zRhf0~bo{ey8f?n`2eizle0GqI=2H=&E|2DlMq}sfe)KCzxQigy3#lzV4%uKSOviTl zTMp3(Zp5SziTXZs@f9L7ufv%KBtX)rKm5_FJRdP`Wj%itW7(2TX&-b{pG9ixg0ewg zfP;)jYt|#A^sf@4RhWfHzo`N=&zPESSPtB_K-5V+u9WViXqdgE8eQ*&xmzEzhk97{YmjrBMm zL*M-~<9fk@MGKk}1>T{J_N&>uV55_~(cT45j)AxDpfd)oK$go7t(00B7p5QwE=)N4 zNu05jTAJ@uYoFy`CiecR&HQ)6jD$1{=e%AE0xf|Tu4G*A^ZJ&6IoR)PKI|wEwgPM2 zZ6DJ5hC89(pA_mG40#R|4{OQ&9#Cj?NAN-DK3R8ZEH;>_WW!Cf{$~4R4NWBZ@hEWmxM^Mfy5K6a_aN$vBJ&O+%=Q<8I_i`V{ z6lHgAW}k-qms3FJBav|yl^!qtc1>c@mrYq1<#BJ#WnWy$gYq=D{v&jmG*Yt&&FpI7 z;UF zJ+^AwfRyrbV^7l!R3$Pc++Pr%-xAXB1ETOx3#FV9ve`E588PgA8MHCHDy3fV;E=3t z_9}X!;BO(lH&tmr(M_erd{4j+Xd^V+aUPD$G*Dzo$d@_5I&RCx?FY zgjf1fd3}m0nMY$-s68V1_SVK!w!fT>~D zt-zLSF*jf>bHYpZ)_x2If>ockO_FNyvq3YL@AYcuWbqp619E<-J2jFL>v%JR=kq2O zkb*qz&`I=Wa&p2L$&>b_F*0U}aRD%H#l9sbQ$_QT#K{q7#*>^nVwU_-awJ&agf}d; zF%+sNp&ZJ0wfAr52{=&0LBI9%^Op)-{e0UbsnlDDI!L*1{j7dvZWjoMs*aQ-?CkfB zT4RdMcHjJh;&)i`&y+jZZc;=E(trL6G?@AISRNS-v1#~^zW&5a_(*w-oT3ksr_C1g zGoBpTk0QI)#7(!|{Ub@_xtq%bS7slrXYDvVj?Q;f%=c|nIlA&vqCyMil1-UG?dVeb ze5HpRBP^9Z(@+BuY6f^-VUj++T6{#$ZmTqHH{gz-=BZG?6ZX$@T&ZkEva^x|IswiP9Oot%i`<87`7oTw*dR{+u|AUI=GD zT>{@7B+L;%6d>DjiyMK`9D6g>}SeW%NZwy%!{%7oU-O7NR8HwuSm%N%LcD!w_m?33e5$WW+uLv;zXo(3GXRwSls@c+IX`)Ok3xh&Pr zYh)9qyOH+iSC6lnZZ#`%MHzUmRmI|pDz2Ooy1VV(-71vh3kC)>i3jS(81{y$V=D>B zP1kcaqAT@V7whm<;KEUZcWP+)SKNyfapqc@U!U(KdW0O2{9q_NO)wCWArv{_F@w5M zzk!|+{s#1-4NK0sdNhls8Ww&NZ#&eVuzOMe+3`_rz&EVYzy4t$pAg{MpPfYt3^c8u zR+ccNSAD}j&wFr7BtZd;XOgPftCbx+Ky>!Y& z{{Fb6Wx5g4#xjxy2GDK!`eHR5&{b=feUCQ?xCG;*nA<*0`I3F$jMX!D_;(1YL(W?#iBne0- zwn(Kzri?c6)}P$gIs~g1dPlsj7^X%McX>Q_G7uNNT2~=(?JW=v6)t1@NBARt{EX$F zPpiQ5{F2~nU2NCF{u4LEsJ-CY$7iz70g(8KJ?duKb=v-t@hjbI{9k#1I#TPnS|~Ne z-7Bf}Jb)gS(>IRpQta)$=LKgDd)+Wb-*XsHFwFx$ zbLiTo2PnnN=rl#`=t2%1wlPiy_;$6rq`TLm81I?o_Ja4Bp_QNPk!APFDs^85sGi^w zFpLczhvkF|ME9^-F(VqO87$I&eGATqT6Dthnyt`(S$Ndf@;KFd+_=>t*vuwS)@4tD z2*_^Fc%is3BvyZnZ2}4)t8@ovVu&1VGgCb&K(2FMu+qryJJ1R-f0Y@51?8Cz2bDaS zy?bFF;EgJWw}{wd0F09ZS$$}I3tty5^>NVAb6kO!O4US@N^CmOj#P3Oef;X(O@+tm z^@a6v{e3t{QLD<_bSwiLr|%GdtX+zh9rl|}H&UV@-$(HD1)VB>WP_H2ssn-h+8I z_n8MXu|Lk7OqRZR3GtcxTkIs=%YuV#=AW3JDOYh-8?_`F_FOq2-BmqV%asa2IQ=t- z3E#<%8uH!8h)Ki_o?En?KNu@IDf`Uzr#uRVkY<&@#=1xeejRDOD}g&uug2f*0({ z5HMv2oCCevtt|kUJ8$uQ5WW2LP~>3u+EcXeKm~T)9^LI*cNY2~^@yAZ8VZ$YOuXtM z_hrUVqFHs+wq~bz&$PJ)X0-&>=3dkwSv%1?(+qE@jXM7)xujATkVmY}cXgm1u#45d zhr3rxSl@qvEq+nX@fvTrV?V)qIp>7K#agaHU;9^DJ)mC%RgK|Pj#8KoQqXVN3}Y%( z7U=|j5-sTIXN4_R?lj70)|MxU<|n~u%Qgy4ipGmAhrhs$9}Ce&xm3=f_{9gofsXB= z8KPo8aotSPu@%767fRoGc-oZg-wG%((~-+(hYK$9Ee@E)$|j<@pYy~k=)yLu>Ga6d&vw?|ZXhdPbuODQNV7y``?VAJ#R)GLXg-t42g@x&WHR+Tg5=N5qf}Ogn9W zlxxveup{Oktgce5$0aHcs{ob+ngSKc@vrN zFnXyU`_NZ)hx8&mWx*m+DS+e>Uul?7q)eUp&7wfuL~9WaQTkV$S!L3;=^j-*-X#QGM(*R54+b*9 zRH2&>L{Lo^GyzU_(4gqh46yhMbU6>C*y4YG9b!tX5q8rJw!=`2&i23Uf1Q?(lyK)m z{Ju>*-OaN0Vy-SGE+eSc(e*O(2-AVSlN#{{af+ftX#`4%!78ovR<$gEz1=OdDei@> zL;_6};$NRApGvzmsHWhn7%{e_NLijr6^VvwJb5_Xeu^dXA5j(F^RxHQ z_omO|(JA}3of`+b2W1pDW6!qWx?lJ=W5D>zqtbB$ zh~eY7Qh1ey`a2#?ENe3f`9bl1yYkjS(7XCXd>#xtIz?%agh5hMrpX4{E?IHiO8s+a zNhge7^(-#VH=p_d04Sf#F&OXu_L`vKs~-NffYo5=ONA?h74l$4WZA9}7nglN_J54I|zcZ48?-dF`sxxNFZ!)AvD)1qF)9=CZbpm z4q@4KzGu%&cPeC%Rf%gAfwMxjg+?iejwg5MWsG$me1=q^yZla6ivwi8%Vs$s=O|+nAOeTC1 z#ke~7*F0}m)_s^P*mW(*`#>~B@^71x*fZwBn!lmHLn>`u!5FkaOs3zk9tF--TiDM18s#ic~te?6qGQepk!uMJXsLWM))J}lN5L2r4~DX51w$Xyg|cZm;%<7jy$wk zwAL1X(nhMnQQPNZOK-s5=koO-#3Ji;qLy5b-(u`U^}G1itneKodK^z@uH~w?)0F$h zs$Zb0`kpt%t}b!jFntPIz!CXq+?S=_lNhDGin1FdjMoE`KcEu(BOvR#B*y4>VW;CYuBTP=9MkMjP*f;Yxp-ttw!@dAM; z=nFDwf~v9(;&;J6#FX;3+G^@J-v+@MaD(i|h1dHEoISG==}bJbK`kWt^%VirG;jo^ z(y;C+NUdZ zTWjDjG^CEAt4d-A-r-D3_j-4ifvesz&CBcW>Qi|9`(mvcXS&!unE?TUL^X0J4Ij1@ z8#>%5a0*%L$!`JjdxuW~d2uciJ{tA34+XQvO&^)Qj(hivSlm{Z~I0K=n>__==!Y` zzg6fz6xh@MwO+b^EU8OkH2Nhrt@A^8&U*e5)SLo6X#}nulUv4*dD*i#k!k+@IEGe{ z|8w?z>S6u==ZxVC=yWDn5=fxvnPsD}drf$L@A3@>@&-zSUKFldiZlOFUdWXHzK)>p z9n8FboG*p=Nr2&Stc$BNzqL z#DmeU!N^-shszBV!eI}+n!2<=4cX0X$#+L0H#m8HG!w{14di_7~ zoIY}e@oV_aw_dpU_pdm~hA0mS35^)D>y<;H>r($ujo&AIleTvd_$|o!Y~IoSYX1B~ zBn@35u8ee)_ zw&Gw%Hn(n(KT#IivH3Wfi4bJf{=#FkHS9mCYSztcVYtbd?~(H%2p^ z{`pG+Y*4r%!%))#O*Q#jN$uTH8j5vDUL@=Zm#Zu8EArV;cDGM(Po*%bjKdi%8oq>B zAmHKE)uP}qK$kVYR2e8Ah?y@eexG<<`t@6pE{3@M3w2_qbCy$thP`k&dznGz^aPfY z3k1mB>q{uHNWI;bjQ&!1uPgFKIx?G%e1?H?5{;RGG{TJMm!uPy%5Ugt6B<5I*MFMG za0OXHaul$f?9xMjg6X+zt3C9LMWP1!IsmNPupHE2@?Q%uB!Uh)q(*CV#IUn4qqY!MGo;D<^2TdKD;@d1C zqwCVNJ_h(|ws?UhvN~&OQf)tL=VOV<6NemmKO4v247liXi zO&;Dgo&AYAo^XFR_r{o+7G&$b8qFE*?RD|!I8}c*W{|OQ9`$O zk6`XWcW8e+<-?Rk1Ok^70H>zwx6SbDQfwZhcUZ5PgNaB8R-%j0c3c6r-1uGGAEZk* zyn=olbjyZukG_!5MG97G+%;I3A3X2#jeV{dj$mgV%TcLPYrHi|&hLFnJ~)g$1q@2& zObo}>h%FljPL2I2F>a%M%2+eC5@ z;7*^4kSp{hs4YaKV0uqU2P6nOt)A3Mqt&4xvgh(P?KMLUteH=kIH6{*K{!o)EO;TL zCnMw&iFn#^w#fNuFV(}C9uAqZ!x%DjMGV>6shD#5 zKA&Q|fpuAiWv@*9)9#buCrzHj;~kpD=|A2}wd?zSq|Vsat7{7p2$MW;25+N!Ty8#u zIvmKmr7eOn1f|)*a-@+hzpu?&4^v}z7IOuIKLQA|oJO&2dAURGD>UH?co1x^=@Vnf zo?PR#BBYwRSOAWc5?H@QGwtK_On+P$3k^6hpu8r1>%BT6=bY$cYV)s>KlWm)x}BFA zTlGXTPR&NA8~JT<4S}C@bRtYOy!e8oJ5h-5@tP3>fv^j$uy<;Nc7&1-QSZG!45xiT zR}|C<&z%x%m#3IQ)Qdv{>1N~__|a`BWa&koS1nX9sZzo!FM<4;;556_<=yUphTvl| zur@|;eAa9_PO#}+n?d(s(KJ<`b@}|x=s>N};EAW;M*$WVBfd0Pnb3>aG*ra7?Bg+8 zG4ro_^J0|2iWv3oJ55!qx|g4jIx}aiSTik#tZTRR`L_=p6Br8=fiA#!V_i4Go^7Jt zs`=v1ly+Ql-Bi*%Go`1~F6&(@7)vCYTzs`isu#_aVH<2%TgIcFq#8Kg8V&Y4T__IsU$Qn0Jj875H zr2FXE6N3yQEJ034+Hj-82sZtKZ9GN7M8k8`YcuQD);8C>Mbw4k$*MS8crh6r0H&ji zN-3pR;%UYkYJZRHSS?txz_LMW45?lrF{+4O!XWH+v}p)#+72avPdQjAZXJgFJO4Eu z##CkffNn+yquMcN1d}vyaa-HtK1_HHV}gvWv?24`SoQ56;H6I^NA35;N0pL z)Ev_5TI?nFdhef3lY6EN9%}V!KzPYw_o(-sjSL9{DGke(Dk7XAX^qPNy8`gBb=s*B zc#3!DvjHal14XRH3}V9N|M4beMrl7u`(A__?@0eC5((&H->@w<1O5DwXqlJV9^}Zq zIQy;DiJ=QLxvs#plRZr}8ruITvpI*o(VwZ(MGASL!r6fr*!8N5gaatNdT*5oiYO+sbX#Z!I^K0_$hn`4qzYFO4LoXyH%X z6tL5!Q0cedQOF+3|@X7rHnoKd)TPPZRxxAS=qe)nx+{u z^ls$;{SCa!|FCLm?;5&SmN~?k$J&K|K2;p~pc9FDlBAYqoC^S_-M4z1uppxS8G(Hk z8RZ*=eWA_9&vtf8P`s=705-$~--OOArVoJg^W$el{OC6{nb~y|$sL)3 z7uEB2`^(<14Elms)SAEi$y-nny~0IM=l-F!9*W1EM)pru7>M`&%{^5Ao6+{WDS{PVuBJ+bU zCIf>xuFL55JJJ1l?ZWN77| z+B54~3uIzry1*sT#%(QB6N0$li(v*|Ifx;Fv2TiIB(u*x;eExJceq+B3k|=EX zAtN=m^0+2XDhWb;U&1PAMgMl_0L`gOy`H3D&pXKr*UR;+3?niq!D?094w@;IWXC3sO(@Lg`xcS5T z++brCG@}&F7)wv(DBgWBfgWU6vg8C^p!wH~nBe{~Qti7BaT`pRKYXf(u6GpQH96Sm z7_r`NB1r;Uzt5vS%ofX^@<7Apd7DTBF44#)$J<;iGpOL*L4AdiaqG~;BQ@V&!%%?F!N);M2w(V(4Y+I@7NP}@ZB3TR5XfgP62cT!pr4yt3@~@9|LVz3rOW1KEV1lv31MClVERqgSqx0_wqcpg z;cS^pm;Un7p*D{+qLDd5d|9+ADvt4A_ugmC&?mmh`u{-47^-rdsl4<9NzTQe@d1mMJdUv#Yr}8sQ8_ei+HR}ply1@Z6J8apsmy&U;2pK)% zMn`Asg!H!HME)~SA?H0+(N zG|NZ57wzet$$$suFdX;L2zWWTj?%zI_z43_;aUzv7?&+ZeyV_I`VFfMhslh#kAA{twBsPFudcqgTz<&A}YF@pu4eYoD z9Rs(G5e?v!*Zc8eJ0XCp5LHfWM|DqRN;r{TPQGhvsIv?iz&26?8y; z&l*tN&q7>#Y6u@=*JrwDIVL3Mz|5)^YOZ}GMWNAr0|_6%Cp(!d*>Xf&M-7=|F8zyi z(7Fq3qu<-MLt;Ix*$YF&tuaVgzovCs?Pziv{ znqW$3l3-TU6`sMjMJ{k_Cgdn=>;`=y-(xD;2pDe65Rs@gDFgVEv;Me9gz!7Wxj-=l zw~MrHyNZ~spn+-i@cCv5ztx)7J+6cA`UBT>CarygYC?bS~X>e@+42wIldou_%cI_!)cWXo#f7mEOp`$?x(S?k)9lls}Psy952zJ>?k_H%mqrw4N^!8X2 ziFl|9J4dM*9DgAd7CA_VGope;(ntRh@eo=n?YJW!Q#gIM6xV3MIoLl^>Jd5xexqfWFZo{= zo$tMgB(mOTs7BXQ=XHAem_&!-4i9{oAsy1(Zq8K;B6li9nEp4e;l4ol{#u_Xyda5m zXI}&5m=(ZRvym}~ZPKrPH+nd_V%i#s4dkSgHF0l{Y%K+59nA2y=@(K7kGJHLfYL6U-z9Sikc9u z^4`;O5(@+-9hMu(pl!O;NydWqagr6AXHVjxl0`tt=Kr;KU4Kn4O<$j*2NeVr1f>`i z1e7K!QUb&Z9Pv;T=_E>M(rbbV@KX^L5jY%r38)ANm|#F^2oa=12uKYfiS!l{AwUQL z@^bV31ztQi&tB}^&dg_bc6Mg>JF^e|qmw52!}QUujSFWtp%L77am#bcFY>RaC?B`i zp%qYcUhfmCUh7VJdC<$rDv_D73Q^nn1nTHQ^? z$pjw~btot9-1Uv7t}^;cRm_{^UJO$HNoxoBoc=@Qa*SCEd>6M4QfyZH6ufu+&#BNu ze~6mTEgqU!J$$D5z-w{CwC}MFhp~Zf1;+Y~p)t68w!T-%-YnrMDeb4pihB+3hA7n| z_Ao-lUvVFNF3`3QqldiHn3)Hl)>{XbLeCDx6z%D(j$B?u1Isg9(2?g1>aofKKuPRx z$wxZjpE-Tn*jJB~Cob;)mUHf+QcOPN0=#g%@ts`PrLcvY2hTnKOZsA}(z*E5%WI)o z=rdW8`w|Xj^*M>0U5z~4=}GJW+QhwgCSGD6hs1WELJ+>keaJwSwRr4{E(xY>HhroNUKdl|m_~~=T-Ikx~ZGZA+ zRmnxt)t<*2L)}l}rGL$-{`>68!03rpTZKv!h0JRQG;W&yRgn~H81}?%ILzp8pURm+ zbL6?E*a}OjuB36c)B<Xr57Yhj)+Ajm`daQwBKVvQ39-^2wz5rTjTKFcY zw_$z%W-K)17IaMJZ)d6*2VLZ4E-g0|swtXM^rDUBlK06!uKQ$vNR|M4*;U#OVn+tc zWu`6{=j$@|D!sB+JGBBRk}@A$G}=&AYT?|jGd^XomwdC!Aj&{aIb%#rr(Aq!Qf#55 zH-XO8AY`Cn?;AGI=Z*k&zFml^J-pkDi7NM7oWPHLBX)Q9AGVNUJh)Wwu4}YqOr|Dv z=}Bx-zWNQHjHtWG(>Il7?K%&38s4Qv=ldLy!u=^8i_@fqe6FSgfc2AQrW~y)Q#-}~F-U>%6{@!aIUqk_09mxn3m^~uigYTK7INcH`Y%=M%qX3Rv;`3e^E^}D(_DHQ>+ilO5Aa`_+Nrl>4@@$ zE1vV#KaQHeIyeNRABk7F*k1DI(QXZp;j_^Jc>@b4iSu$duO*dR4&rY1bJ_Qb*9)Iz z1d^)jkGNFB})jaaCgd(+{mA zS{;^93&9VDlg>@61!Rnzn89nSp6&H|27xR7r*Hp=h-ah9zkt_%bb}z7D1dB)3VtmK z7yWoq3ryp>$NhEh(d$M*UL#5cI4f1xgolRaRt(xvUi7@O#ob1)Ud|sR*Kry5gQOBVinKfc~>2>+Jrug`#k1-|Z z3QmVhKL-XU_rcT^ETsA#iVdQuWyTZya}$Oq*5sc%Ja272k|ioQT|zW#Q9KZaJ8S0i zoM-4;2a1HOh#&aC>VXMIw=GEf@O2^yN-( zSa=;S8$u8!aQt?!dge~#2?>6YidKtoukM*6Cr<#%2P7lpluEzBCB>ZLC%-HjbgvwH zyK**b&EU%YSJ(FpthgBfyH$N&*Uj9}eg5@lNJeIkX@S+5aPRBGa<7$-%`LEQATK=Y zdk}eIA*DC->te9#R*N4+n7;dDt;plh{KOI1sJ0;TsPWJjEloLw`STCi`n|tiNJcBx zW1Lp1fwgU$YnHYeoDiRyv$SWgWlhhavLuqbASG+;KDW0;aBuO;Iz-m!o(SSUSwAN0_PngCjC zIMzWvw*P^S=p|zAguXgW9dpb)wBg@Y)f-mDd|)rw+5MwRVZ>Zm5z!SK2vKbDlAIj2OV^eZs=O0^< z1UPYM%ERs3!y1xIlePK{1M}K7h zec<4ST7hb~M^gPirmo*F7W$))(81NNEnciuQ3kr$OznS+KN zwT&M6;=k|D#?{qsu|~Jd(Y_Z#pVP0sDs3Y^4rAFNCOdiS?0shC0!`LN87FsMALW5S zt}{|?)ygz|yPo=8yLxs1@mJrk-8*yeufPL#s;8AQx~}Q%n{FyJlM0E+{!qgjc_``& zutqK&@%5!%`qysw)DH$246XbC<)zt(;;~!#SY|k3z9z($BU$MghFQE z_v)SO00Edapt))W*9%NsS%life^> zo=90zZ>__4q=ZB#O~=o9oYAd}x@tR&xb69+iZ|CV!k)=@EN+dN#)d1!H7Q|L=G(}fOxR$rmIscp0Z(gtG8=wn zG_`N!g7}}LZm}82TZ{8?A6Uj(dGv(Ac!aOts;i;s>>d1ZLPeWEUzZ`hHXr-RGQsnG z-=fD~+IAY)T%1iv_R@N1Hf?Ez2HUEHgspW{aam)x`Vc_Bp5ih@i+P=*i=4c>mc!*Wh}2kb}QiE9SQLB0uSl{*cJg zbfdgBM0e|ZpabRX?D)DV0YBoT;U;J{>t^i6Th4wh3>k&ECbM%@Gcyw-!=$q`#k3D7 zI!@h64&Z=OK*@sgyqa09o~w{yGx6Iezf$3E7S^0%AH18mJXLYZib z>)X5TLOSbqSlIcj>?KwA#G>r|PPKW7-&^ht#7zR!4uTEK(4kl;mT-vJ~ki->podx+g5QH zhr7^rsCgGPsyYetFb(cQ0N1yM1pWwFZ69Y~s~Ia5!mS6)wKPvu-ku1L^B#>u*-nei zKyIX&Hj6F)CtN+`h?kx9m&IWnLV5xGvG5jm@Exwg% z-+8v&1rv)OuWN}e*g)Y4(75Pdm=*?o>T3=gY#f;IyGlh#^l zJyw4#yDQq#Sq<+^madPPo4G)pWVt8ipHy=qXMnnC)%3R!QA%+i6@nfXN>@fs zXOtw~jx=0s2!2GM`9S_%th9W4Tr}}kDc?(ziK>@NHm7Y<*E6SM_;%Z~95gV>Rnhc! zj(A8JcrSBTzJB_6vMH~53oe&bYS9|_DEEdm7ZV&Y_kO37XIK--kDdK>3zELyX&sp1 zrPtN!uD#+Mow2R>vemJx``7DJ_Xn+yWG(V*_}sF@=yXrmW~(l7aj{&E$Ue!I!I&qy z@M*gcceMKx%6lqDYdVAk4EwAy#x$gO(iO=GH{R0~O4B(Y(0LbT*F=z-TFo-TGrvDx zcdZTsQ=Ofpl_z8`A~qp<;0kW<^5yLbv*{nR0axm{1urN@-XliQGUt58T09)zH|tO zsC&ra;c)r8oUB}fO$lFUHGOGa7Tdur)zpDn**9p3H%zIyMlfM5wZiSdh(NrQOga}| zt+1N`YVqv?fDNzDimOyt5*gO?pRW7f7%)k(XE*hygfr+_g&L@8*U-Vlr@I zJuWMKBd18g!kh8wz_f&r(P26DM1EslcWVW-vr$SCfNfEVNU^kxZVp4z4hNODSbnhJ z@1c@N*GtCyBQ08R;$&%SuF>V?9JxHMN>`T;WCe(2sO-tpe~TREiktd}6Q_Rus3rTP zf4E8nD-JJJ5J)NI(P__Y>2 z00IGR|F~si9{o%iYfrb2!z5P(q1?_Es$9UsS2!58&4AS`jD^qmXwyt1_9khqQJ`fE zY_*64XFE+MF*|o$icex2fBZ8X>~L)c{zwCS&65VUpCnkx#-sc^S(#KCX}LF)Mc3(< zQ_yfJwiUIjXv*l4nFxJ90&%z?0|Dy344~$mNW7lZQc+P#_&@E;qW*bX;{YLJvkQlQ zwADGU+lJNBKavQ!0=f@wt2mt*%gd2_LNlcpt&{+B$nGHt;$)$DEik&^Yjov4h+p2n zOa3REXz%C(7>f1?@?yx{WK4Dd+vw}0jnwAs1>z2)@m^v}a2P(B6A8fGmGTx>CmzJd zj{$S0IE+qrDpH+8B+{`!c=YtypfA zK%Ec%`bM!&_AcG2|6*Z8+PGGOtvM?v&LwLqHOM!INC0SN-wEi6@ zyguoOB6`7^wEPeOV7;nF!MU!I0_L(gr*?6c#OHRdm6}8D6}!k5gPsJfOC`3AA}nOq$3^$xFNb$}S)9;-QM)(`>EL0qh5PnEd_KW* z0(er{p(-Q#y-_!w*spDHX{EV7-VT%)rM8>cg?U5HgfEZ8w3Y9nWhtb5ib7dlb$r21o(H&hn*Ywg1xAF|a<6ibsD_v8^% zGgWLU=TqSoc%7Wqf?S1b$b|I#oCowq05x8G%Cf9~2~h^c2w`N8$!um-Ll}35ii$#g zJz-9bgw@s^b0XhJStP)P&*t?aCi`}JdH*KDizbaHCn3V*_Eyf2FMgtAh} zF9lXrI4;|3%*ZYkKR0jVJaCBnBQ{ndcViLpczeQ}^NC(X7&aOxFBz^ZQiiwB0J-$rZ$XJvsl&mhx z0~sEfEpxkG>$Cei!`5u82z$Ht7p79?6j-XL>~r&K4<_i*6ENE7Z5wVLwYuwP*s9^t zLf_ExPilv4MP(z#AQ`yFU%ag#f{-Ff{+;tx=|j6f>%Y;!Qezzrt3rIa60kK-RH-v% z8!h)Eg}rKwVBcb)9GFKTdQG&)*Dm0`<0q#?ChQTegLmc^k4w4=mDvXt! z#Wf>}08rd)xiJ~}A&vM_%MIdlbyQnl=Q!BI1kTJ=px&qRmxjmqoAI8LQF}L5?bKHt zYZptX&%Eq(66oQ2qts_Bq0*6}+2-2!)mqoU${(O=5@oUS-6HxK$V+zWCVt#|zB4O$ zDCvwvk5c#ai#r&Q5OqFnKGD#)GBvNBvA8 zFxiBU&>XZO*d9a{UysoXnZA?bU?10bM?D((+{CnPVg%>toDGq~tQAF6*4%y-!SUTIcTUt_@!}3DZl~qKyi}9}T4+bdzl7`sC1yl7|Wvf1;>!gvgm@ws6O2aDt`8Z0sB67m4czQIcGhi?@y zT^!Zc(RNCHA4g6EU>oHK3!ROtgPyEyNe*|qplz>m1DNqk2(*OVg%*kFklH_D5F8fY zoLeNE;b_9_cWQz$r1DFzGl8<$L}FX`IIE5Pf%{iQExu3oKB}?*5?EUZLfLzel23^Fgt)*8R6Wbg^?3wHnvMGk3c=~Rvkues+5X}kRGCP9qEp- zne+3i?$_yyhYNv!Wrd)0=`>)LS_j)>ynP&uVTFGQRPHnn2|AM(C$Kv!x|_gr%?zKB zDB`WSTtETKzVbkt)LvB};0Q*tK6W1rFCCqvJ`0^6Rm7)(5-%^^bWk30MWMAtDVMdY z1YVz^L@9m;s-8O@XH60R3VuV=rq&(9qpntUrLx)^0LZQvERzrK@0(wn+=z*(5w6C} zA)U01H@wRF;=4){TgU}n)}wVN2B}(TgT&&HNxy3P)k@yCE^f@7fc4FY#lTV;NUM3X zk2>LXj#OJ!Q}fG+Z!sQ8S_21U5d;dHtrSWe^6~sKzmZp)`berldm2gBfJE^fsGn4V z!T|rrOI7)ThGIHJxPz$R=desUJ8lfvn;Y-T2N?chOrsomg_GhtZ=g|n;jB$XQ#gP0 zhD{0eFLUXCTGZtwIJxpY=+QAoLcvm!UQm?(#VI4iQcmpXR6=8?vGs*nMc^T70}OU} z;s4oRLYO0m6HAGU%@)5!pwDYTYZ05k#9(|LN|`1Yg@jX@K)^$a?#_>D<}yKG>VpiU zkRe4Vu1N~A$K)=n11f`76t6$MiNUs4Q{2lKSQh@RiKoBD<}9e^>}479Ozt3gryGNW zgs&eiYgkODQD(UMmyK88)u(c5w?`Ld%n4m8A^c9Z1@4MAj7n8c80-Wp2*|su)nL9e z$*OKC3?hg#UifoE{T}OhryM!r+)!GYE5CvCHYs1LNBj$U+@O0p0(^*iojWi%ogph1 zm*i-40luc1u=IBDGN`A4^E!63$4jI8Hupz_&7ss0|CxNMw@GpB@zSnr>jjy~U*n$@ z6v1=YoY<+KF*Szv;sMyB6)`vNCQ8A5 zc9K5bWhbcqZHyHOTgSICfbZ{bGOsvftEZtAO^1euH0kJt4Wmh6 z2W|}%OlpO*8`IZB;xK?L&Fd?}7HdPwzt^6+LPSUd=GJoAKzmb}&W0XJ&wbF$?+SuF zItX)c;#3oeTdhA_0k=?fM1qt6V_Q~PUjH?Ct#fR~KGLJ|>eZ_-Tmr7riC&4lThZqj zaHYyNi#+22aYe%`h@+I2%GA{O+C*d2%owrF(r7YizKud&YxpH#Zs2ru#|xH_30`ue$I6EJJSh;$8_>?zy9 z4=cH7@!gC=h0A2Ss1}IB0=AmHv85G3)%_g61Gtg9MaaU#e5bx8-2{^iU?oKKrD0*x z4SX;+B!F13`Yl=_(vbt&V~Bo3>}(f)J_Vq9hQ$*srsGTTgN}*RJK!vO)ZH}2!Zsgj z65llkHFrbPxN#A|_~1<7aBx*AoBxe zd72GNuYes2Ot1h5_TOK_m(7KtCU;|c`oi3;ia~E(Tidjm5NmH6pUT$Sf+4D@B^0Vh zT;26n`WL5yYkLs+gZ1S#KzZZ>t6<1BpP#s+xeg&LO8lu}BxFvDlXkYtK0$zcqWm%l+Lx;CC1BH$?z`Pknxqd+fXa>ECZp;cpT6pZ@)K pH{k#5-+%W2`0v`k4&m|rO4K3!tByY&?%8dB-L|-eyYcY({{dFWNRR*k diff --git a/assets/images/game-icons.psd b/assets/images/game-icons.psd index c3d6d7b2d366053a79d879860e1ccd762d03068b..a60e230643340d32af96ade5ab2d3fe6cc4299fa 100644 GIT binary patch delta 19731 zcmeIYbyOVNw)S03;~Fe3uz?UDL?8rrg1cKF1b267+}#>a2p&R$y9Or&2=2iW++F*v zZrJiMnt)NIOo9*=k)NePLK`a|&6F#rG%@SSz} zhHL^5UOWJ}Jr(6K@eB%uqM@UsW1>NkQD{(f0D~Blgc6JN`5*jbaynk*R7}>`*9D?( zFbm4RO{eyb34%O^LNFjPm5{qIOe&@yv)hPq03Qkg(1@YL0534@l00O3J$#VYN3)(u z=xc1{pb-V5tBm&-SsVl~8qTHkbvyTuoE-VG$HqT#zZRIpw!p7(-?FqJlXpvrYG>d6 zu3(>WkJ2&E1g7BMdhtS$ZPB{jhx3`Zcz;Zh5cPvWE?u4>`4zvfRo2{vbEC#_oi4eM(oNo_-iUHIC_; zPU|4g<>DPMo-4f%l`qPhYtc{47wTjPmk1P}5RkXJi$QCIHTYTB3hUNf=>!?$Jqx3r zq7oCGQYM>RKT7VRxcj8-uqs|yfl`*%3z{m2{krQ=Ydx;$fxslPxf_+UuYko{0>%7n z<2Gz;zG2o~Q26I)W~#v3By}7GpJ|r^tu!iVs%*)2NKTE6O(?~_26DQ`HS)|na2rfr zRl0Q>!!eo5w_uN}`KcK{28J}{6KZJ6wYUeVFb*H<{>Uz++?fp-Cm1C?o!0B|F{`an z8e?&RppM3I+t-f!#sPbY)uK9t8U?venSAM_DpFGb;-(H+sLx_>4ckk|;G>%$!Z=Xx&%S{uJaGXvsrPdhzJMl--I>&em22HpOB_ZdQL2{)e`n+G7)p%muzSi? zTIt4R(j`5Ts!K#}g2_0tk2=q{eZRD`Ll>2#qfHq;4Y=Yuyl#D2qpt79F(nW`A7G8s z zA~&Saqs3}Zn$jA4LJ3!%5S8Pj#XyHfd^WkGlFDuKuJ}Bsfa#P$fv-D|noS-?M1jUl zvG9P_n~?tD^v_+MuEV+aPwq`ZYWAu379++#?P=t~Ia{OGevJuhJWs>+qG^RpK?-^( zC7@V3_l2d&m{SZ2#+g;btJ%k^gd`1dy>W5tjMS*j8}H8e*`{G~?}kv{ydGm{K&DE3 z$1^s3f}k|E+>`yL=*^qF^uGPouyB_C0jHWLxYt4yJDGuu>&{6alFEp^>~K^~64Qo}_R6 zvVT^`$ipRP`1IL4rAfkk0-wsM6&AG>%<>kOifPHrR92wMO*&#dkL(c|37E25Xr_xi zv(Liu!RP$6k{?lJc2eX|)^Qw{8E>do%oKe0qETZ*r|rkN6WDRrpw{;O(p0qgs)zXT zHgVUj_-EK;rg;mi4bgLb9Bmf4K{WFuRQKY1vowav`b2PV-D<}|0NHVdZzZt{F#F@@ zGH6W%1Vlyv`2yboz;6-2+ROT*3tcTN!2(1?I=c>?($`rm{a|!2?&ytAzipTW^3kTg(Y5i+J{j=vv3fX1P zhr63Ycw8O%kHdLZB95J56a^CK%>(sn%vUK?%}7xvLUl!gB|PsOoVR`uLt`k zJZ*`({d-H!0WDQLvh*1%?WnhWJwI>e*{+&yD=DYH%ohg5q9~}aJ7!sHf8AaN|D;)d zRh5SZLj)83+dppFI!V0IQ9HPGjAr^`h?{QdSN261x9*VGKt-qcXso#P7^5%IUX#4H zyhylGN{Wq%Bmd-QA4Qm2u%Rt^i4Q2 zNG~|xGfo&ev6q_kudGKCF_m6DSar(w^{InNIoYxWX5}q-#dpnN(lm#{q$O1B!m}|= zjkLW6p2~or%_Gf# zw1ng&_Uq(izW~}Z7(^QTe%Abycz#;`K)(viV4i#U?vY4z<#~;#C=CtYqw>V>b zPmC?IQX+B^eC27Z#HG9MV5zo$tew5Qf4%&r{_Tr;lTSLf_ZML|3?5b-|Ni12q2I$3 z7aTBZUgcZ6yB+|+Q3#eVs%dKD`WLm>lt=1jVuNnY3#d?Vs}$?U|dRt zIt!`c#H_MUBBCouItu$sIr!8~Jr%zq8@4sMWDksyw z?=aruXOvi*g|**OkMxZuJ?A4!f$WffA8^-q8;DTbW0Ieml}T?AnvU!r$Ch@;jj)no zziZn|E=ZHdRH<_NK8via8!cT~miWL8sFi|M96^FHMfs|qggR+&CdC`;XvienL{sbA zd&bLfna%T8H zV;<|CTGBt_diECGg;SNqQdBXuoXQIAHkIv0#EsiNg|Gf>&3Ybv_ATw=G?+Tgk=B#h zlf7|A>~|b&w=K8O{zmS%5Rc+ziIDQeYgxsav9b5MsMl$1cVI5UPn=;Y2nDdW5BOagGKRkXmI3ObkXS9eT?6V#o z6UMTlJ`oV3(ATz*e&wUJ&n%`^hWny+bhTQ7@sRpOKMd*>>rF_;PQBh$2x)|}b$o1B zEjZ^HzEMC+bDH9~z_!XHKOpDpD;4PXIEzr<7~9e%u9GX0AM38$qTy^&cnf>B#TKF0 z`v;t#xvGXN6Urik@NB~Ezu4pSNf($9NYOD)sgl61g;x}8RG;G6+9--osF>a>yd$bS zgrCNR#th5m8;+)-$B)mb&fR^WDLZCPY0eR;DBdLdRX|?mPHnhMEs->bc4jF8kb5#7 z-(7y^sC-bp-0hi2lOvj|Um1<+xb+M^^<6Z`?~%L@%j@iVHk-EYOWSX2?l94Bg7&Uwq*x=?sI7drnSU||l^^77y+>?|DR0uYkthu_*U~EJkFuxM zkpHs6%-N6?yD4lX+?tDq_3l=W>2^MOI*U2XU-HhU(cidwo~wDP0)p>JyK1c_P*Jxc;NLHvtc;4Jzgh3$)dAqlDDZVa@ z2fMu@_|vMzhb5!os`8E!PUmb47U{|5}s`ABr-F{p^Ut#)k(_W-PxW`Y9e3SdxD{K`S zso%@zd<%Dybgo5?hda?y8g<2U?>IFb!@T6iO>;R|rKVrD{|FV1pxLStrV12O>s_Is zuxOLHMgIQvUbv4+o4V}Ee5XdP+$P~%bWR02SqijMps4fwVdZ&xpoek-!8u&x#BA28 zm}@?9mPOAja9*tvSt`YY>Fgdq7uxV(T%Y!M< zibi2FcSQYR;QCP&!?oDCjqI6BA^Ebw^^t00m$LEr^`goSkLf%T*|__2vc!{ZR&Jq$ z%)v9EJB;qQ@f$g{jDsD)rCpINb^$jP7A-AJq;UjNCeqlTEX7z{Z6lS-aHV%7y2;m$ zW+GHLo3(Ys^O)7xsF?x`3Nm@nk}YA?j%JVWI?oG5&d)wJec2~jOXVx63}GO-mV=um zrNFqUlevav5-UQ@@PLPnJambGN@wJxUMsC)Rilf#Ibu76U;oLo$T%9R6HmOeUf=<3 z%1FQatCHos@>iXMgbC(lKHKK0ABC~0Mi=y{yorMI*4@6Fj-M5vlh_ZMF8^AEiRW$Y z&T1{adl2!S7y)U$G81d4CWpx42(T} z)}`ayVep5rn3cCQAqfejlOAgl(v-bdo>aVyrLVEiIK8m2{H5pB_mp3_VsxX#KCxcQ zvLP6NL3=SEhcnLja;sS`&FfG#mF`>S((j=C`S&uQS|- zRi~0LD=QR+_datClB(D5gfue=O7@m?VU<@dH=48e{3;N^p=!bkXCjIXB+T2F9w4!G zaZdOoO4M?cPI(YHuM6j+q(A4UDHdzG$)&ha)PG`dY%2ka=qjffX^tNCm4}g>Y%f}i zjY>PyHSr35cUd)OcpsHbsqs0<df8 z>&M&MXNr0ixep>aPDnmf6MvyyF=Az~%a^7$&(XtFCDEOx)(>!aB7}0QBz1 z`2i;m3(Roe;n={y;WF=1Y9e`9T*{-|WBQIZk9UvdOIz;VWWGbscD%JScmL>CT!7Bj z{&cljz1zFK)3(e(l3&x+LFPJsU6I-P!gg%Cx$52(jMP~aF)3a%o>*vKdiK2Wr6>(_ zrDaTJU$*%b2B?%C_G@Y+&<>*YSCFNz_h4@a=G#t{%T7SON2f`9$cwa6VCM4&$F|B3 zX*G4#Y8*xcX2sO|V?Cw!Y3L?XN4tvStlRy2H|UyldHpXAD1|%@%akd^0-Nz4hi?%C zV08+Q+TD=5S=W&3!9&e1#cy3{#s%|87$KA&F)gqbNM_EmKDr)|oUwWX30!^ThSB0Hc>uFif z!z`!UM@B@&-~1Xw**foyZo4WsY+Jl*xt=)a==?IE^o*{jW4J$CG_~5%o29 zt}@m?T8+K8LpYRL{dmscBz_e5(A0E6KQzZj822#=^K&hjsrR@Qp3=XrR-n~gF{YF- z)y<;R5ZQl&Cd~Ej{!o%K>_q^yAmZ!5o$uw<G#x|4&2e2T3H#*5VbO$Yg*JQ*A>ijM1WHTqadu~bQmlVZ<69J6fI$cz>NSST-cUEcbxxj*x!^mE@FCT&beS2|ky2kj z2=p7t=z`YN48q2-Im4MC=<`2Aar^U>52ZdT!bz*W`;?72U=l5*qLB}dtmMZ=A>y`? zglx3EychjB#tLYDk3J6Ez9I4Kr+Kn%SjNq-`n7Dw9t~yoIpWLPrY~DH?%3Rd)vMc! ztNsi%9F(cJxgDI8<#hAM4_AF#zKX_*vT-lm7mBd5_-HTKx#wpIkCcIf{nE;poM!sP zWo-RPp}z5hpEl?ytVF4;*G%N&Xy!r_bJmzGw1cEdG#0*(`(jOp`h7xwln0P7J^LVf zGV-ct0&@{#lsSWDX(LjTpBm#D|Kfwf&($iv&}y^yX16&dM_-#yyh`I@sQ_VY!<=%= zyTdy<^2wY*IfqIs&Lu+6d#Y8f+qV?$_+;=q{lk~{8Wl*m%oI(_?{HRorSh%U`Ayw4 zDjxj6ZN%Thcd>Xn7zKnIizj(6RA`tV3>G}26bZiW__TCXdVZH}2j;RGV|}|UX6CjU zM7f0H4lRUEnN2`U3Y`Uf>Hz@6$Y&n_fIy)B2LO8De=w_5!M7~pQ%UgM?XDQhM?#SD zlEy`Dw3w;bYY>bWia_QV2IXsz*_ggX@0h`rcMz4B_s^e|WUdxK=m20A9PtJC#suGQ z0CeQg7;MPlF({F{!gv6nA^*l~&4@x+WB6D90RPS{#X*!~C^E*tL7$1Z`^c>a$gM-b zHyZecf^YN+xpfSSA5;(k2b?o0a2>b}+y(9f4*&{)8h8lMf$xU^E$|3<3@?;IBZoZ( z7ywFe;{!187MMT`5Y7Woth<~zhrl9$2f3-1y|fpP3y>(|odQb$!OeuFv-5HmD1@z&VUVo4DFF^+v@4r+3`s8b9~tBVt?yoHsl=G z0N`8zQa>?uMZ}0BSr-o#wqMrlj02SJO)0yLw)06Sx7)x)a8!~Mgf{cSrU4BQt_&j1Vnx9$)bbo=c5 z>;(L9da^&`de7=`Z)0=saBr`R4hn&sfRI0t`{ljGo97L&g>mM8lcHnu+AZ9m^csu z0FrkGhOA4HQz$H7fYUs=cDnzTRH^^>=K5Ig=*-gk^m8!ufh`sTaQ)ZW*~P`!U34_; zJ}?wH>Or%fNam0Et-am3U%wXD*PbhJ;vtWMd~meiqx0Z8A@&871mk&j|J>s5 zU*AkAt`U>ox__OR5HkY&0grjW%a;N@Gi&hUBWx%JIt+@Afv4U%y12SLA3=dfKtza- zi-%8$55!%ZqorbiH~6h<=;UNjk|*NghW?4Y8|&+P$GfrDuyL?4p%>s!h6cefJ3C*x z1vvt5d5#~e`v?062m5>58`~?bT|`(IXvYBh_32X;G;lkF^V_dA@HRR)Jlx${UyVSg z#(_b>4N$cFa@->T3v#!5_V?<}-o*jV{@&hb@*5}sfgFM|sCfSnxJFkpKK*NTcW)p3 zw*Y6vhU|h{ppavL2*Oo5G&wcDyt=WyyXbKrZ3{RA2q3Q-+q%bRel4x7_Zg6^gJ0n> z2Yo5YFKX$nv!g{{1;4<)MF*p~M~q8Igt-L%?0=KYdjRrvDVg*DJxVkm1CIel)C>6u z`7QE)3;+!(_AYSezbL3(e7XlpL2tp!nwl#LqJcLyDkudFEo!dG&x}?9?0tP)U^plV zeQH|RRP`Y(MjfDY_Vsf2GDJxzL3~wRb3>XRJT^M`v4scB$=uZe3nigipBg&4yXsP- zBmG^R-AycP9IcG)EWm9$zzytxy!z_S?)tLUC@)JxGZP&h6B}~_3$-)kMvUOBPhVQP zld3Jnb*?NmY3w0P*mIWeYF3Bs=SUK`(0Wl zPGJctX%S&%brO_zVrN&ibau34=Qp&%nk(Bz#!JlQrC)H8a;Us!XXX}^ky4aV1~1cx z06-8^+4Zfvr*~j{vcJ5tef(#$yQTv5H3|tOQTAugxD~1_WpiLkf*1!HonO7rvf6HuY@IK zbY+gg%Y(FkduMv)y*4NR>+J2_qu(jMzJ4Bh_&m>Pxa3?^w4JBCxeI+nh{w*2eWd=amzPxqh%XJ(8c=Tl!Hq(@o5aMGX$;o*4 z8a^4-b7pQ89c^u`xB8}578W9yKw00=Xxl?jGg6C2`fD?NY-HJaU)%vXj7P-C$|t6! zp{?;oS6|=AfEp8M86F(@f`1O&&Q2>$gjt%3u|J__XF&sRV(48VAyE}|O%3%6Jv1Y2 zJWQZ#sDHFpY;1=&fl0j%Q5>4m8uuvDbb8plUk=_#?2f?5p*L-|$o zj12Yllm%$82nh(U5#V89d|R4XpUpZ0uwS^FS-oMzLj%*u&>tHb8d;c`n@BJK02U4w z6pD_8gVDXUyxIg#kDN^5q2Ln&9(`+l3k!2wTWc#_Q3;7_N8sr|L(kq^9Z)+2h+&xO zcCIe2E>4b)4mK7R3bHB>4*)3p#A5LwK!Ty=Xy@SQ?BoP)v$C+!B^S5}ZX|2^VSEH& zkn>yF+S*+>VLLk7JGwdQ$&v1XdKi~*1Q27Xm|NS}IXb$!I=OfUc_?1TK}sbpDJYdf zdX`o;cDA^Yh_|?YwzOb7n2y}j?zjQH6w7+Uh&CkKD;Qc6qi*} zdhtj|i{t;7R8n6_<)4d97;?2~9sAB5rIk11is~B6KF0uWom5d;8T_TGIxjs^1#k)t zy^_i&#s&2i>B*650G(%8Xh5*RrBp^0m(|y%`o+Y?1~WQ_gm^jmyIo49W==&zQ)^9H zY)r7XyNiXByN8Q|r~Q>wdc(3SOPV_xN?T)n?Tqw|4U8-t9ITw*BGnLr;d_xu(%h3= zp6g+zuPPz_TF=1T%+#FxN-9knoBMj2;4lYcb!i#-*Sh)!I@;FeD5*3kud8n`$GK+K z-Z$8rWN2liDj}z-_D1)unzEdh<)u`9sB3Kflm-ikE2t>%939NiRMghze!%d8l}|`g z;+2@1?v++%lz(mSY|Y85Zmh|#86GRPke6iTBIi-lW}$yBswk?cpan%qC4OjWd)K$` z-MxcD-9?2BV^d$-Ui zcq$;>nJ~i0i3kK66;Cf8ODet4HAStc>`Y}=~Zy>d@e}2C2hcgS_UGDhBo$ZFG zkU(EgH9Yp`G)zK<8Y*g^8OX4l8kqr-yytQ8bBnIFG+ z{zCG#`s=qQx*9UqC>)(xuz|YzT{C9T~+v4=jC%^L#?OKV#j z8&E0>ySjQBkW!iWrN6uKgP*w&^DCy?5GW2V0WI@$A)PmGH4QDytt?GIsjU6p-dg}( z2r!Bd316bzY|VsOo-?sCflE~g^cEWnmxivkj+(xSg{2;-l}(*(KdO|^0fLN}WM?}I zDK0KXHeM!BVhCRHNb2cms>!Ln(Kkg(<=3{J{$8Y1hGj;%n##(^Ff%+PM@glyf}X(} z4JCOs4J8pW0L!iG`{d|g2{G&fzzZ!&kQ0{|W}zpCqFn&Q=ubs8-WZtZtI3K8zT#mf zMhCDy50A`^e@{e8rAb_j)mxdzB>0$@QmL$KU~ForCP)Lpz$GBU#X!U8n4egkN(ZHq z#l_Z6gB}+pl~6_#BYk~c!?$9fR6?K_XrNX?F*?>@^Gmfzt-L8u!^1$pqi1boXK(N3 zW@D-?BJzsp5R65Cx3bu$0RD&<=bH+MHz z7Z*nxD@${;H`fJkfE!8cdkjH2Bo}l9CDz^51=Kw&YqQtVD5Z>!I|9(KU@D-5LY95 zlu(8gHCBDfj8O%=Q_{jO*OZI~xfKOz@4z)BLwH&mJlXbAD1$#1m4A-%jEW5Red3V> zON#JI2)$HF^^B6*>gq4>ARnIq@G!js0{na;y{?qf{e97=!s?2gVs|Gy9d% zn7`@eqS7TTDXz99_Ct`hftI42tdW(yqrH>!^(&#&t#0^Q{y7BZWT~U5qHbtuV{2;U zW{(m|V_0Q%=_f;+YkKuxTbdK>j4iZfRduzE&CK+*RSlfMwFFp@r`K22XD0bYf5=X+ zZ);041a(x9`Z));h?J_VxSWnD>E)vGU2#oKZQY0T{4ei=aymLPY_ycQIc{+&sk1RK zil|7bYUp3CDe*z5G*ma%SJyO@MZ@Ahw|y&cGE}7_rI8SQ#r^!zb9pTg>hdV1jHqfV zsjB~4-_hM15nR$zQwXy*ks!PGisL!U3kFUJBPJYfJq46der#^7_%`;fD9F|-?tA^$ z&LnSJa~UEbZWdv_mu#|f2AU|L1eL0_zav;vRyJU4rchP=6UTy}j zL7`-P&h(stOHopnHQ&K*#cwN87+i+sMJg z&B5{!CQ#b=wIKymN{SfRdrw1k8D6F*&$*t0!T>?L$@zjy$3p+Dl9rXTg8@D!P}kZ} zmn8=ZWwc$Gsz7L`^vGM5I0vc93dwjvtTUwrpqCuM0d z(O0aGA6`c(rI@O&zOI&xn5>*M9|?fv)KFJjTlJm@6w1JKw^tI9oKGKuuEZA#*5i79UixYx53hkjc>$0>MRhH$&2%+oIVeEK z50?-Z?2s<$t{Z|ig@a1T>}c{<`XMeFG7tLH!PdfD|FsGq%{hR927#i1C3eMVM{h1t zC&}fg*&Y&bXy_WcxqJBg+8Jwz2=Nf2bTV$JuUZydQetSk`}+q52Kc#|8<`qO%P3HS zQpnQsJr${w`d%KMo*phX=4O^QRz@1vVf^Gsq5M<{UZBJzf*xRNC%B-|x3aghHB}Qw z38k+;a!IM^>gnQYV*;w5qrHV59|=k*sffWPC99ST%-Pn?+{($x-pW{s93_-=YM|J< zxVXDG+1nUtqJ)xPLC?_8+{V$-O6LViC^-dQ31cz-@lx{H5>QcBkJ$wgYXXLWX-wXSgywX=TRxXfwPn{2b=&Gzzqlj0s#lW5&Y~6L;=x&5BQBQ z;0c5PG2o{daBBz<3`Br0PrwWO6b$D00RG_jzThWqKpId6l;HeH&>L{sBq;X(`}2S8 zIS9}lz^u?6z#`MbpG81Vcb8AR5MGF8xEm|>*BieOco+M>hC8ZzP7u4jhsZ7Mh+DC6 z7iLU)xD^|A^7UT`g1x?un*92u^NZgZu}v4qm=5?gcI<&$ONeXddq2CXGd_+To}P{- zhDXGWUv6Gv$NovNf*?EJ`1!59@tuESX8zKCd00q9cvu!{gieHH8XWQhlL3ChfnEK_ z8shr-Z1=aGZ}n-h(SdF*rFq_-L7~B+1y{jR8Sp2_;6+aCI?4^iP0-2gXs!SDy|KJ4 z*4M_^F4Wc0)iWS8ZtZgSd0g1*e{3LbU7Q{)w0q)8k@b-PzGQ&fnG72j&?R79Ev$IazotFZTK49R$VhR9Aa`X7A+ix90Do19^Ua z?yg@tt3C_JDk$oNB}FElBFFATDCWb7SisZW%!mE$={|z$d~fz!S7%F3MQ0nz@hS`O zPb{n(t4|N%W#t!DQjdUtxH>CJ`EV{|W+Oj#H{(8ndTXes`}==!z*+*lV!luHg}ssD zdGSKfz&Y91zx-;BQ3BW(PYw|@7e9OY|1`t$Qjlg?qPs`V^rRF23w91xQJuEklf$1g zsFP3;D~OE_VLU)kA9hS%`C`Grxig?GmJ{IVmOuYV;H8*2Kd+2Rzyj#$BXim-QiZTF zAW!!YRHuU|lPu9#QzsW?l7)DCdwl#A&m$nKXs#$Buj00Rj!bDoNan$}Sis+U=oR(| z(=LMQVix6_S-Lr!=OBHvRDb^ful&V)DGsqWq4s(zDh4^n=ND+l2dG_suP746#)dHM zfQbu86D_cLW$tSp(nJdl^!M{=T&vUI7F2R^v@_P$cA5qgmn)BuJN{l#FM^E*Vctg2 zA59|-wbb9I=SK~oq4q93Bp@idwl_)Vl>qlEZ8J+l9n0*wUGR#JSiH<^6$Ouyc?&`R ztFyjhy#Mn&_#mYZbk`E&Lcc3`{O8&}>{6 zE_b~q0iGx3bp-u1=*4yIoFfny)1VpWX=N_O&CD#GvkgAfINHdQk#V%KvoN-_u&@e6 z-Co}-jwP`%Aurbum=mBg*Zk|^0zotcI&(G_GOt9~1(mW7&%g&*1Y;(;w)XZ8UM|+= zHkI2~lg@>!vVxOllExl+v4+697(^O&1KC5sC%a%rTW&ldUA8`1bl9>_&w9Z-p10% zA)~6Ito-NZ-sSO<%3?zytSbntS)`pe_zO(MJ0e8vVoV(ScmxjH(# zBrWXiY;EuE@9l11&Kki`498~!2auP?9%Nbu2LO$~(UXfa#I3#4&5g6|`Jw)n;+!zB zEH#!?h~GK{pJ|?fPgTH2bEvt)85FP)50?-aW1FkP8y80i==oTf-vx}o&DG7-+0kCd z(Rt_S1a<$k!k;Q)AJHr#(AR%1E}S1Ba4*K9y*)i($eQ=U3DZ62bm8C(71{!Stc1Nz z{Tm!@X!`gFL3Xhc@8jv=0Y`Oo|HeZk37~Yc%~|L2H7|QZz2nxn3|fFSK7aZQpyUrq#AZG z*%$(QeeU<__QBphie#k-hL7-cc1(r<9Chpp9Ce&{r%TW+U4bsMALj~m^MkFGy&&j* zT!D@u4Iaq}>Sw|LPVy}fbi2K6)p<3G=NF54DJ`gj>Oe5$!RwGQ`hz&xm%y8Dul$fR z3IcCDJprC_1&C`yI5YQ43&CPD@O|AUsWnt|X7wP5Z>=789eFeIfQJjB(ZmF&PM{jeo zucu!?Sa_hPo0Er6Slk3^V0ir)&L!xUCwjiG?Cnf{`!Up);pyP$4-atnadB`A4vvZW zd^Op+ah!8H5Oh0Z?QNfP`z8k4tGY&qJ_h=`x)pX+f8i0ARL}}dj!wG-9lT)z=jbsC za5J49U9GugtxYxgwZmhjf&K}_^NU1AFB<5W0pmq}H_!$Vc&4F*BR%uJYP2GY#T4)J!q1e_Qbi@2iq{9k}; z`-y`Id5i>HFUs1C*Hu(ZLs^@V;8mahE0$A8PQg-v|CNI472qm<%-~=^P-L4z*_F62=PYQ-zaSpKnPMvuZLA5{M)z~+J;#?PKljbHz!ZZbW zUn4p9NtNljj-{!I<%J_?v$orO62OAhhuOTcCD3xQss9q8mb zI!0Uq4#7|Y=jR0vS8WMr6v?@vuC^bID9#OnwyM3Qtbi!1ph6~!bI@T8Ro4p_OixEk zONYNWmj|~%4xqP;GtP?S+&84#isIa_>?k)gc?ES*)x1ABS7B@e7QfCe_7=uzSDZsI zWWke>BUrEC44`<|jdWqVQM{{8l$TUib^nWZJ((^J&h{40nN{T_C4ccwY!wFzc>(h7 zC(@GbTe#$1yseSd=f8Lt>EY<;?BG&($-Ct%-c`u0fvLRKAb-&P1^q3D3rCEse-IDB@CBZSOo`gUX+V*0VDgfD$smS6_>`IXHT!)c%8f zO4~Sd_d)VapvadR666Oi_mPP7&HZ^rJ~-D7&JT)z5HBSxD8Lsao{wL6?b;>rkdS)D z5AK+67bhM?zJb{Au;8G;z_6J2-%u-*D)_`MPR0!s`4SRS-=$`K`L=vXJ_JKLJQ$f> zw1=a4Nx)x!BVZo90^A(Hw~v#8*`^t@#q|6_cL3xL==@@!@@T>6Ii)hQS$yz{$5*<6TyIQ_T&vwS@^Getto= z`32E2@YJ-7%Bx_hc(}R{IN3rQy!tB;eyu7jsjbQ@^>TI8(+vsm_YX;Um$PvN!cces zGW@+Q-k%_xX-Z3|Z;Q_iu`@RDPKXTxFP?~$&sQMSx5N7f2p5~0TPh19eZ7N2qhi7X z{e6OhV^XIsk4xJg?+S!t?e!oCC!1SZThl|V+(MElq`u9P+PoGO_sz;~gU!E1XrW4*VvclW!Yiq2z^rK_oU)sArdk4q&O#KK` z6c=D&WYu#{4G68eN>O*lyR7ggo106ooaxAr(w5pHFL&SE=_w~ZRxXZb0*ci;V1@Vd z5`eE=@X#U8kQH8I%auuOXC42&;cI8Iuan2e`A>q}@)A5eDyn{eRd@)M6!_*V@E3@2 z#T!F$upQ-GhiEG)2BMtn7!P~vtY2}wVk%0Osv_blJ}anOqOHQ(4G;5@gS{vd+tAfX z6NWUg6T|GSY||GDqyn{xhy&Z)FU;=1wCx^kjUHwbDqclXh**I0- z>KR+Z^5;PSjwM}Hc(E`%EL4RzTvD9fQk63clCKf8x?=)-eF6e)LGqc)3bH;G*3r>( z@Cl6lkeLwn9eGZ_SG@PcLy?buf3_*7aR5cWR?znj@b|D#7v+0hG(O+|QJv+9iiw$} zwWqg#V1UoaW#$(zWF|EOveGU!SGCo-S(}7WcdGwb#Q1f9;%w_AL?sd2N_3p zJ{}tp92y!DDsxhZTZ%gksyb?^7V#$Sx+5LW2B#VGh<-_AV~A8UK}S zY48S7@H8@o;x*7BtGf2?BUDxQK0G+c&(*@j-o@G3E2;V~wjmf2;A_YX>M*=gB;$s+ zkk#FX=#W5nYdZ&bHy8JesXrMv6^8fYA64C0dTfZFkFzWIh}FGx^{T2X3CFvv>iXlO zBf^4%{k%OwzM_8PD!5w&9*S+?Vly@|F*-7-?)RT;3x&TI11BLLiHBm_>deCO_QCGn zpVb{4{vF(0F&l|@Edzh{vfEcj;kR{om?Wn@SvJe>vK_`%xWJC*-Sqw-}C z(MxBf6b`qpA5ae1jF=;PL*y*WG-Xp~i-LE2f+Wg8>27N5;*XcnYRJ@|DZ|wGI5s!{|)mNK>r`~OJ63ca^fp7ZvyoHLGScs zVlgMaD)R<#?ceC5{k*ny@c3d%3~$vjY6fj_m)YA^S3s zfd^leX&Lwry0b6y0(tP2A1(p^ORF|AwiQ0lgRe!i_&;`SL(!0-EpTC8{I}G<|L6W~ zWMnhEfEVB7{x1OXuRFPsF%9s$eE5d<7QpVHf7R2C45@_&^5GlY8UN?zZsfKq04Nx~ tfLlvqlEU%$@tMiS|K9J7jH!SN^5b(8k0Besull}`Uzb++@#Axm{vTNl{lowO delta 9729 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 L@TM+EIi~*u?VMw! diff --git a/assets/images/starcontrol/sc-ship-androsynth.png b/assets/images/starcontrol/sc-ship-androsynth.png new file mode 100644 index 0000000000000000000000000000000000000000..868871695852011e0e853f03b7f479311572d924 GIT binary patch literal 1746 zcmV;@1}*uCP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw0000gP)t-s0000~RQ#x@r~s&_ zR8&*|0H~+{0Q~&?RQ&w>R8*)`0H^?{{8an^002ij#<~Ci00DGTPE!Ct=GbNc0004E zOGiWihy@);000IZNkl&`H=;Rhzvyr7#U=$W7JO7%NJ% z>oL_~vuQ;QK~{|PB9rlXkeFpfb>61V_ky(MD%}IN-u3In*F3E0)!`*zCo-%uXxEeMQNn6ny8&8H9iqWc;3mBP=0TR}P;s_sU@NcojBNAz5)TF8fCWv-T*XYp0EhTt%W z6(bzyx-n&XtfVGfZ4i*`EG{nIo1Z5r+v)(i2u9gX^(JHuDOUHy=rLy{DRUxt z>fm;{x@}oyc&WKQar|Oel4NK%x66Cw=Z}!g5nFl4e(?m-0g6aA7Sw*t*%Fr=h?-}LqSNM9!(u|Xg~BS>1KRY| zd<4)<0!|w%xA$sHv+>#!ujh>zAUS>j$<({0C6vFe=|Kj8;@r0j3(HH(%Za{R+mYJC zV>)}H-IF{NCao`v)fe%PC#d95#u?q?Z6Ae`nPt&~!o*m~gZ=2yVQLr@9c#`|J?)=a zt!n@>V2&(M$*}-ux8IarA_GrdN7=H_J_PYg1nf#UW{4_DLY1f|-O}P9NMf(#KSFws zIVnS$ofYa$ceJAw4oTm58t#o&HE)28JBdUngH%Ab&%|ZBmEU;YE6@)Kbj^LbI17?v z-D>9J%EXChFgLl5=o&OyY7r^uFU|%PKKe@*NRIb(oQ_r_j4-SP{RyUB zZxG75p+cX_Af0zpBjWQ9cYIy}*?l^XbCvc1osGFlE6+VBzjqpu-kY-vE-psi8sz8I!g1(ytpVK}^&U_CQr>Y*ME z{oIp|E*R0cYs_yDAF+7EMGPJaCw83PEEap4!WVr6KG}?=8j&?BEo)%M`JC}VoFoUO zU-AkY3XpK>n0rBmbU9CupAg4rCD~uZ)gE&ONHlgyUjaJdnPChi`k1@4>4L}*004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw0000>P)t-s00000ATR_}cnDyK z5OADKoWv}IumnV8bN~QcTwK@y08~^|m;eC$`~Wln0Q@8*{LIW~XlQhFbO1P1bhOl< z%;?bU{8Rt{0000`uq^KY0004WQchCp`uUHf%w`yEvPCIHJSZk#{>3!zdcGyAg>6TnI^ zjV>46XDn5)=A<(OZeKUrx|^Bi0#FJdMyMO>gN%WyO6ILn*cgx?IGdyZs4EjxMw_0D zbo4wg?BxPbv;+wP7(P|WynX6~iqgp#M+9%LOi=NX)5~EnuUgOgQ-HI!v{`B~XJj+8 z^%RUjmO>lK)?x~>yePUQCj=I;;V&>|csNY+eAJ*HInQ zb_48WZ88A-eZ|1KAOYRKP*V{AOA(p3f(BNrh=}b>5izt(0?@&ftEgr_>z8$17#Idd z0J=9Mpv@wIDyqBzreH3C%v(LB*}JPBA`)x7p#-2SOhimwl(Y>};XBN5uMh$1FpP-s zZjvcQmA?YaB9K8op?%I$1J70|T3dKSy#Y5n-%&)E9L%qztz}wQ*=f#CK)tAWmRXOrC;bYzystH+6Z0|EnAyA5MbHc;sb1*b8~dlW^1kwk?sEj%v+UqkkRx8-+*N# zD4>tQu?8s6KZ?evO90sK2n=A|T}4CwhW*+oOz0QJALFuMKYNVRfdBvi07*qoM6N<$ Ef;hKOPyhe` literal 0 HcmV?d00001 diff --git a/assets/images/starcontrol/sc-ship-chenjesu.png b/assets/images/starcontrol/sc-ship-chenjesu.png new file mode 100644 index 0000000000000000000000000000000000000000..4a143d0561a81d185a9066792887ce50bc2a19c9 GIT binary patch literal 3713 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 literal 0 HcmV?d00001 diff --git a/assets/images/starcontrol/sc-ship-chmmr.png b/assets/images/starcontrol/sc-ship-chmmr.png new file mode 100644 index 0000000000000000000000000000000000000000..09b9e95cfa29cc913ed11edcb1f4e8f5dfcc5434 GIT binary patch literal 5691 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 literal 0 HcmV?d00001 diff --git a/assets/images/starcontrol/sc-ship-druuge.png b/assets/images/starcontrol/sc-ship-druuge.png new file mode 100644 index 0000000000000000000000000000000000000000..4b8a429e2f26a86c5add75caa4f18211e6942974 GIT binary patch literal 2820 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 Avj6}9 literal 0 HcmV?d00001 diff --git a/assets/images/starcontrol/sc-ship-kohrah.png b/assets/images/starcontrol/sc-ship-kohrah.png new file mode 100644 index 0000000000000000000000000000000000000000..e5f2efbd9c1a7301de34df4f6cc67a1bdc828d9f GIT binary patch literal 6210 zcmV-I7`^9-P)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw0000^P)t-s0000dCnqyAGhAF; z8X6iB5)uFa07ytkR8&+zKtP!_l00DGTPE!Ct=GbNc0004EOGiWihy@);000+hNklyNtEw zbadI%|LWxqJ~!U{?=JeC8Q!keT93cFzVC25#5cyW#`|A*zWpZ|GyHV2Np14(GQR6N zZ_?X){L6cP97wBi|A6aP<`JWN8wu7}V-y<3r0@pyLW^_4kGC-rd5Vii}PcH0*z}Hx)Yem`d$0(d@tBVq*O07I>{QU`Pkv zcwq&49<%_hL6Dg3eK_6wUa&*(92uhXz3+gJU*ipb?6jXO)clg{N%DMn{opm#wcaC_ z2Dilqd@8{4h68||vR$g)(Vssg297%YDs%XsCcl5-GVm@uC=B`b>X0nxKDfAZy9pBy zU81${0Jz_8u)##rlTb9{ynTU!2e--5KcNm!GB!52({Ejv=1;lFa*R*#dWi2w2r`U- zV?U_5-k3maK#}D2{UkpmxM&II%_XCZ`DxPO1#bH-TZk*j)}O3pmGN<wHCgdV zFJ4b6z8lBf*fXN(Xm8>gKt|z$34Z%dmr3+L=ZgRKz3E$ShFM;8sudFwzn$cq?hqO@ zD$|bnO=vJ*iPb+wN@IJn=o@SpVFt`%@+vpBV#8KwRDb|pWqltpq|i%1mOq$?6`5M+ z(S0O2j{3bQksww^ICukn37${lb>GLlAD6l$NtH`N;xYKwY_@OcC5(Q2(Z77&Q*Mxt zxXzYap(yOGx}~isU*#Vu)f)J%6*TLK)dA4InqXN$3*&SjTQD+!*#gQc!SOE!tLX9K zWPBygn>R*c#Kc$!s3LlF@$7MKx0h zI^K_@1h8ajBUwDfJ#Qo-R~23+*bG}(_jI1{NDUKYi$DjMyuRD(DuqUYJ~(#?8|yy979jAG=ejvKrslsTz6HQT`(?7|)&+LVD_JC33!KJhei&rgXj#yr0xt%g$a01fzD4{3pP!ui>dOD1;OBOou3O}wxQNCoEH zt5i|Iy`&MoB0yAOs_)clU9hqSJ1MnHu9cv-PXQ|^lI+w!DSIL4j$k|tMWvM_B$k4; zFRZa-3^*TTJ~|txwR<%`>L8ng-Q=5E=PPg+b1CPSDfmjm07dwqRZm*^)kQhiN?}{m zgy{MP1kI!%xloOX+@6F-5v=_?89^D`BqS=f6cM)jxh4vBtmEs42|2_7URkk*h>7Y7 z2Sb!xka61G?m06qzhvxA(og+S}kgfMd3_(6#mtv1ce!UzI|w^-

tP{b1sYF#;X=C8J34 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 g|JOh7+C$_20I>e@~ literal 0 HcmV?d00001 diff --git a/assets/images/starcontrol/sc-ship-melnorme.png b/assets/images/starcontrol/sc-ship-melnorme.png new file mode 100644 index 0000000000000000000000000000000000000000..6fad9e40b97c0ca4888b5847c7b5ebad601150c0 GIT binary patch literal 9115 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 Z{{|^{P&Ahg|M36-002ovPDHLkV1o4rPL}`x literal 0 HcmV?d00001 diff --git a/assets/images/starcontrol/sc-ship-mmrnmhrm-y.png b/assets/images/starcontrol/sc-ship-mmrnmhrm-y.png new file mode 100644 index 0000000000000000000000000000000000000000..75eb325f257fa5d1d2e5f148911586d831aba5f8 GIT binary patch literal 1652 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 KMNUMnLSTZMo!qzp literal 0 HcmV?d00001 diff --git a/assets/images/starcontrol/sc-ship-orz.png b/assets/images/starcontrol/sc-ship-orz.png new file mode 100644 index 0000000000000000000000000000000000000000..b0ed1b2224d36d91678ed346f2742fd4725daeeb GIT binary patch literal 2406 zcmV-s37PhZP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw0000gP)t-s0000GaGV5GcmOz5 zEQGL3oWyjr)C5Fi04zYD%;*SUhyWlk(CqvG001w*kNE%q00DGTPE!Ct=GbNc0004E zOGiWihy@);000QFNklSERV)J*x{xWEH&HfH`Y_Oy7ec$qLX@cpcDYc|g||W+BtU;vXXd;w9m_G= z13%8($DHq*Z)VQ91fUTEh=V=6n~s(3;=g{VM;V{b{1Pr5J3sk>OW%OXquSWDncai8 zzLUnkdHcetSfOC zhZ;@iofyJ}o^_!=4O&qYZ$`nW3YYtx-@`b;;_(=^8|BD_o@@H)$o)$61@N}tM6aPk zeJ7;L|1M?Mtyp*?xs;HW=-F7jCNcTyH~K<DLhyNb3r|C;ExITjlt)ma{ddh$VIU;q>g;z_tZSTckI6IZSUxLis)fp ziBV-xJY7@>Tl7Y|9|~JziLVK{>UUiE1iz#-0**&)hFx6)?*exnx|MBoHH2_6B4F~m zMHr#o7$q|YF!lHxUX`%?Ui;H!@JxJQ&XpUM&q6)#-eg?o$duzupxFDNLMR;dCH$KT z3zr{)u8RWH@(F$*jrs){X{fTRpt&J>SZ}x)-%tvLyRLxAaPKqld0Hk%y}<|ySEs7s zucj~w;I}n@8eJUoUD|KGna}OlQNB=bO^IWn%S+y_S-wVx3ng|Y5X!xxuiz^z^NK$g zpubsFw&KS|_o} zoS2{SZGliS$EZ-VNS+#g41ZN%q4EdV@vE;WNo+b{$rccM zT!fRmo^#-5PqH~{0o&oygQi}DG4^eRG``?j+l{e zie<3)k$vlnys$p5vi+H?7oi#Qi`N%;TE~d_O3QiyU3zrX4L^hlTfTwGSBgaQoCVyX zFgH5ldLo0yvEhf0-Ss%`m`rg}{4dmh(dC`EEIGgr5Y2D+u&vuC_5mfI*W)}mho71B z30B&b4W%Y4vn}dHCe4UWjd%u zh~G-{V2!_~Vp*5V(yP3orHXOadH#QllPUf5KwNLKn0k^bnQhtptZi+iUmg-Y)x{VZwO`mjDa$XGm36qm3ly8At;f4)lAHYX z*UWE~HdG-W%nULgG9ilo(lebQa)G6;#Se-W@V&CluLkx=JC<|L;wNg1M@q|wttPAK z0cRF3>J7J=qIqu1&uJ7e2sY3SpT)T(CTU?Whsc(sd?dpR6S@js;qSa`VDV#unw9-6 zM!nJSgLfSkzl0Ii$OnJ6=mBSzxaVQVJt6sMU4f_=tk4a1lOTOWLFXt+Qa%c`&xGnT zxkqUcK${;CG^$xA!IUk_d-0pp9xVBi4U1k}oJ*y(zp_;in34`R8Hq9*1oDDs)Wzck z)EqYbaAA^^&Q~SJWTF#1mpb&H1 zi{ze37ra?3{6ak8cP^ubMbP}RX$d%dFMj4E4?0%XuegR@z0Sd@62zL|gYM#YCP73_EB_tug4`qrh->~~t1J90)vi}#~rsrv?}H8b`9H~juDBk}0+p#8M* YU$ep|o_VhPLjV8(07*qoM6N<$g640Nga7~l literal 0 HcmV?d00001 diff --git a/assets/images/starcontrol/sc-ship-pkunk.png b/assets/images/starcontrol/sc-ship-pkunk.png new file mode 100644 index 0000000000000000000000000000000000000000..6c51e47196157bcec78b5a1855bc54ae0873c6ab GIT binary patch literal 3338 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 UXjC)!FaQ7m07*qoM6N<$g8t@KNdN!< literal 0 HcmV?d00001 diff --git a/assets/images/starcontrol/sc-ship-shofixti.png b/assets/images/starcontrol/sc-ship-shofixti.png new file mode 100644 index 0000000000000000000000000000000000000000..609727a1ecb30b8c0438f84fcebd90292e88dc41 GIT binary patch literal 1113 zcmV-f1g86mP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw0001QP)t-s0002|cz6H+!1(-- z94Pz%0GLo*{Cs?T3>5qT1n4|GJZNZWh=_;~aGcoO{B(45prD}8?EEk=FqD*(L_|bT zP*7Z4T*So0)YR0txVWsWto;1^KuG-T?Cjv+-~a&BBqStEoWuYG`~U!i0001OhEs$9 z0004WQchCUr9tkR7l6QR)2HbAPjY!VjQpwu>b?I z+1h>o?>O6!x=BZPHoy%yJH z`G_YDW4zBsa$gx6tN0XXvys4|6GAu$m8BrS`9A*PU&mq!$?H;vFv1egbI)|hPbg`#mP4r8U#m()r0I-3c~HV|egMdfut zqjUr86wpQwo*28BR2G1MErb+WQ!dKe)x^bpwb+P2f}N<_h(uIag}M-?VnXr( zn>^Gl3SM8bsib9uZFoz-7><#)bZLJMYJKZhU2CS^HY7m_7l|=@ORDDLSj2!>CrnWR{M=uz zl2hvq5Lch>?Rc?Dr%=)nvR9zAU&RO{R_ji^>$PcvRCfrNu&7G?SJf#qh#qmu`VeELcsj4kaUnRaLA1eYvfzNS1g^t*n==poNpPWgKuS82$d7Y7#8h9)NvqZG$eg3rP;1H(TaO{_i%~HIyv;I& zbn<)-k8ed7L~R?HVA)t4x~m!)?9pWg#tF)k~h2TaIJFiat00000NkvXXu0mjfn#u-d literal 0 HcmV?d00001 diff --git a/assets/images/starcontrol/sc-ship-slylandro.png b/assets/images/starcontrol/sc-ship-slylandro.png new file mode 100644 index 0000000000000000000000000000000000000000..320e59c0f37feae21fdf07c7dec6a7ebed327717 GIT binary patch literal 3683 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 literal 0 HcmV?d00001 diff --git a/assets/images/starcontrol/sc-ship-supox.png b/assets/images/starcontrol/sc-ship-supox.png new file mode 100644 index 0000000000000000000000000000000000000000..0da9ad1c029bc200acfff174142a438629f9e70c GIT binary patch literal 2625 zcmV-H3cmG;P)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00018P)t-s00000ATR_(WB@o+ z06+i$C;$L#002|~m;eB{002|~0Q?vL0E7ViPyhfdK>Ta~{D=VjGynjc0N4Nk1XOtZ zqyYTD006WA{NMom`~dj;ko^4o0L%dV000000Pvx>N&o-=0d!JMQvg8b*k%9#00Cl4 zM??UK1szBL00~)1L_t(&-ql)*mYlW{7-T>{}*Kc3nlp4a+CK0$OOxJA`g-^s=n-#`h&d%##z4uO~`Q@>WT3Neh zvDxwQG{m5y_l{uz)_*42h^wvcFv2Y0I4GL{>-x8^e|-Pr>zfO6s4g6Va)k&s?}Hn- zTNL|VZ*B8@z@C4s`(4Z?ZJl@Ur#uKm)(-KuG5-774+ytQp&1Enm|RV9HY{u#$HD45 zk?HpJG0#i$e6(AKW4L+3j&aqt1RU`~9KB6=cm0=d-~asn?TeG2wmqUv5m3IqChvn9 zsS%$@h(9#X#*75+bsqBww*eXxu*JF`C=nLJ5T|z{vV~tqxQSvwZeVe#VKf<&tG-l#6C=|ea8$v;n0dw!wR_KGm0^T0> zx5@X)5s@kQ9hrREfH!biAumt)AqqD#l_KCHuEv1vic!d!Fimt!1^wYYvh4eag95^M z2gHqGxC7qPdkX6EU>*~ZD~2+?!>x~7xRHCV*csteP@PO;Iz)40-fIPW-5B9;N48iK zm>Wp`xU(G^lQMuQ1+epQbO{+{OpNLw>U%m1=xSaLt`QysZi=hyG{b=r@KH-97$_8c zb_N6=1lw*UzKXW{48B1I2{I3MIun{T#)a^njEfgs_JEEefg%yy?+~{R$p`t$7r4SU;C)~(GKUW&`$pk8kPOE$;XNYO(OE9wGcvp}94-RB zb3Q_R6dsZ3Eh>VNASAf808864&EP(gSw=fb%t2`5Uz(*J;6|$sXp8g0eHhRNyeW|$ zfx+%Z=iySM5ODiqniGS@AeeUv^w{BDwLusuGzV@l2XxOp0{?tKezWh}7RQyz%GC$FvzTo3(tTA~Q&p#DH;hk~{&wFp<;+K}~3f8=zw2 z5iMY2MnD1++N{pHSUunkKOcp`4$gvW4VW><9B&X~RpEVLO^72POKUoCneo}SgGgdc zvVpY8C?~5d7mOjXYn%tX(Rt!IvnIckhKimBX$*?N$rafO#HO=v@t%xNcn>`-6LSF* zz7KQ{$TGQAybw$#v{k%3Q4TaCfQ{wTg2^!O7j|(m26F7oU?=P5t*OQc?7$qtZJERo z)*#axPrMVGhzSEA5nqH}z9nT?RTpp8&tvLUcN6;G56yIh^~L2KmF5ih8(rv|)l zZfB-#!%0!#YTZ{B2_I^@F+~MzE9)Ec>yeLi}ZDp1ct`D-sZ=js6Vgtp790@w?w zn1g3CnZXBVLVHcBac#xZl*yL{y3knG<_GZNWE7YOm1D{>JRB#ljEl@1z$PVNVHJ+F z&93%hJd_fD0n1eJ39=F&1n1V&1ZbONJJ|^3-iRJ#%N^Cl-!A)hYlO)>GuU*U0&Vi$ z%nfoo26nRpoUUk36VfaQdo1eFyCyrshPA8*GXKhOlh3qJWXLX!Rwy^Z$vgv}RBfIS zZ+IG1Bt(uAxl`*SFaAHU>`lV9w)q*Rs;n2T)!Xve?#jG|3P~#hrpnTg!?fe0YO;mS zpD;pCCI=e}Ql(^~?-6Sh?mn+h#g!F}yb#3qI>ni$~qoQ>I{DH|UZrbf)k%@HGoHyc`C zw1r{Wk%>H$_j0iJz%>bQG4>|#DTUm(O^n$1IKj_3jKp=_125P7;E9_1c1wYGYSA2h zM0hXZzINV1i>_*kV%Ry4RISoq9MZ+J-hv}Yfyp&4 zwDnF>3H?vr4Sv*2JS5kp24r|f0C6p=;cH*{g9fxTt#Nw$nZ^AG@UWq*EiMH_wA`GR z9!=4QZ!By<{UbIvL4o(1wsHw?FgJ z>Dj;+LFW1*=<@O?(A%TD@LMPVblvaX3Esav{C5W6?R^H>hIoH@Lu%x50Xa=Ol8Cs~ zww@Sde+L_Wd0bwN?y`eXUg#YwCDP^QjrRwy#{V_IrGS>vcGR>vLCo7wlemElg*y|s z8^T+E;=AA>-9&g(a(I|Coi8kUg)#B jpC2u-`}t=UFE#!LBreaEQl!4$00000NkvXXu0mjfUe?xk literal 0 HcmV?d00001 diff --git a/assets/images/starcontrol/sc-ship-syreen.png b/assets/images/starcontrol/sc-ship-syreen.png new file mode 100644 index 0000000000000000000000000000000000000000..1c743af6e3e8ea2446ed441103dd4ee5337fe3bf GIT binary patch literal 1633 zcmV-n2A=teP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw0000IP)t-s0001}008_{RQ&u@ z0000~R8$5BNB;l-00DGTPE!Ct=GbNc0004EOGiWihy@);000HVNklY#mNG{2aOZ_iF!G6pZ8;S@Jc?!u z$YvvIs=#2B)jk2lISsndVFy@0E8qeNMkLxXU-6<%yOWgRVA}%j?qA@9Qx1*uP-1|Qe^~KiOjc= zqxz-h5x5q|rf6g#NV=GUDE_RguhMM)A(dJw2>2G?S|nU@x9VQf`PdSL`|grxM4cg` zgg=`GsB*uzxK)s;`FK)9mKa@4qH7-4m;;ww!d8vhn1u*Dq&mU3Kf0V!?|tCNUTH0< z_zaJbc6^^|6OE5SYtChg)}4)-wH z^H|qovdDStupqY?MQ_@bN1%r)88-{cA`Rd=?&D-FW3^b_z&eLGFxt8)@W{v~A6!Do3(3BH4laDUr5Kc;RXIp9(S$ z!;+xFqreruz^gh%2~{KG-s^r2$;u0V*r?sM2L10$2cxQ*YirZMhMLwFPu z(4I4?yu0_kZBX;dg@3B_i3~1O7V-pWu=m3E@foBam$9n` zHFk!dDf~hPMt|7rJKN#ne*ify3x)Y=JG=j?xWmOj(hd6W>H5!bw}uH{Q}kCu--yF+ f$#%H-ZA1S8WhlUpsfPnf00000NkvXXu0mjf4lDEY literal 0 HcmV?d00001 diff --git a/assets/images/starcontrol/sc-ship-thraddash.png b/assets/images/starcontrol/sc-ship-thraddash.png new file mode 100644 index 0000000000000000000000000000000000000000..28b5870e0f6e2a165be311fdfb25bb4d333398a8 GIT binary patch literal 2186 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 i@c+i42>wdhqW=v}wK^r(L93hq0000004R> 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 LNkvXXu0mjft+PNj literal 0 HcmV?d00001 diff --git a/assets/images/starcontrol/sc-ship-yehat.png b/assets/images/starcontrol/sc-ship-yehat.png new file mode 100644 index 0000000000000000000000000000000000000000..ae6a5780f1afa8c39dbfec58fc85fbd3805a6749 GIT binary patch literal 1145 zcmV-<1cv*GP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw0000aP)t-s0001}Q~><^R8&;_ z0H~+{0H{>_{HOq^{8ap?008_{Q~&?~MR2)-00001bW%=J06^y0W&i*H0b)x>L;#2d z9Y_EG1IkH6K~zY`l~qk|<2DebBhf(i6rlY93lzPpAmy_WN<8!=fF9dNp^@|l3S)C$ z2*@!%p+B{6h9CBNvsf$-;dnTj_xNUpQ5zN4gHqYVdMcJlW@Qa;M4{nb6OsDqDp7`L z;3Uqcli!*Tuz1yFuWWJNST<+wx@XWiU8ozG*69m56&VWD?aHpIM--c;r!UwyT{ov& zRGcN5g>D_tEyFaV5fD>Gb8IUbZtGz0#(I5%v)~p%*cV%hHL+%m8NthjpK0^v-8O z)fLw<^?2UBxW2H+5m$%{g^XmD&tyz{rUNq!hBX@`wwLG@S_Dff(lB-&e(y8Iis2^gJpYCk2v-(Jr#Gj6JDRr)CjmCZX@8?(9$%*5~+-ImgVreWl=mVq=s*aWP|BA zYl}=db*hs-=BCo#bEhdgYC6oN4>WzpC+^_mYz**ztjum?91A`cl;MOP+%#a}kpuLU z@m{i3FhWXs&*!QXE$k1t4H0A>)H}QnMM@3j)5oEc%~~$IzL1?**LpjHC+QPI2bzB z&vA*lFm4l+vLhGG&nPQ5zz%ctRRXpPiPcSuz~VwPws)?w@Bz^2?Ju_86;l+QJs+~n zR?%T%7W>59I2HzdCJ?pP;U zVwROKTKLPc+PH{6C9t}@t^2!umxW5aFqshoo$5eC=wS?2j7S6jc_o>=+lAgTai>@G zk$nFuEx}Y-fjSY{j^Cmw_4#fu2Z@)=1saAgyv01Pg0#@v8qBR0A1@GVGQ}lo=lwGi z`6mWEs5|`omHjDj!?ZT&6~WxT81p^QkLi4{>S_6v@E;`cKTGmIMsrwQk(Hns00000 LNkvXXu0mjf;;I%L literal 0 HcmV?d00001 diff --git a/assets/images/starcontrol/sc-ship-zoqfot.png b/assets/images/starcontrol/sc-ship-zoqfot.png new file mode 100644 index 0000000000000000000000000000000000000000..d949ff937729fab3ecc94e0cf0826d87a086ce40 GIT binary patch literal 3627 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 literal 0 HcmV?d00001 diff --git a/data/star-control-ships.json b/data/star-control-ships.json new file mode 100644 index 0000000..82d6ce4 --- /dev/null +++ b/data/star-control-ships.json @@ -0,0 +1,1478 @@ +{ + "credits": "Ship data and artwork derived from The Ur-Quan Masters (Star Control II), (c) Toys for Bob / the UQM project. Content licensed CC BY-NC-SA 2.5.", + "notes": "Stats are raw UQM numbers (sc2/src/uqm/ships//.c). Waits are 24fps frame counters with UQM semantics: WAIT=n recurs every n+1 frames. Distances marked 'display px' are multiplied by 4 into world units by the engine (DISPLAY_TO_WORLD); speeds are world units per frame. To add a ship: add an entry here; a 16-frame rotation sheet in assets/images/starcontrol/ is optional (a vector chevron is drawn when absent). New primary/special 'type' strings require a function in src/games/starcontrol/StarControlShips.js.", + "ships": { + "androsynth": { + "name": "Androsynth Guardian", + "race": "Androsynth", + "team": "hierarchy", + "pointValue": 15, + "stats": { + "maxCrew": 20, + "maxEnergy": 24, + "energyRegen": 1, + "energyWait": 8, + "maxThrust": 24, + "thrustIncrement": 3, + "thrustWait": 0, + "turnWait": 4, + "mass": 6 + }, + "primary": { + "type": "homing", + "name": "Acid Bubbles", + "energyCost": 3, + "refireWait": 0, + "damage": 2, + "speed": 32, + "lifeFrames": 200, + "hp": 3, + "trackWait": 2, + "muzzleDist": 12, + "radius": 26, + "projectileType": "bubble", + "sfxClass": "pellet" + }, + "special": { + "type": "blazer", + "name": "Blazer Form", + "energyCost": 2, + "refireWait": 0, + "params": { + "thrust": 60, + "ramDamage": 3 + }, + "sfxClass": "warp" + }, + "passives": [], + "ai": { + "preferredRange": 800, + "engageStyle": "chase", + "specialUse": "blazer", + "fleeCrewFrac": 0.15, + "gravityCaution": 0.9 + }, + "sprite": { + "sheet": "sc-ship-androsynth", + "frames": 16, + "frameWidth": 16, + "frameHeight": 21 + } + }, + "arilou": { + "name": "Arilou Skiff", + "race": "Arilou Lalee'lay", + "team": "alliance", + "pointValue": 16, + "stats": { + "maxCrew": 6, + "maxEnergy": 20, + "energyRegen": 1, + "energyWait": 6, + "maxThrust": 40, + "thrustIncrement": 40, + "thrustWait": 0, + "turnWait": 0, + "mass": 1 + }, + "primary": { + "type": "beam", + "name": "Auto-Tracking Laser", + "energyCost": 2, + "refireWait": 1, + "damage": 1, + "range": 120, + "autoAim": true, + "sfxClass": "zap" + }, + "special": { + "type": "teleport", + "name": "Hyperspace Shunt", + "energyCost": 3, + "refireWait": 2, + "sfxClass": "warp" + }, + "passives": [ + "inertialess" + ], + "ai": { + "preferredRange": 420, + "engageStyle": "chase", + "specialUse": "teleportEscape", + "fleeCrewFrac": 0.35, + "gravityCaution": 0.2 + }, + "sprite": { + "sheet": "sc-ship-arilou", + "frames": 16, + "frameWidth": 17, + "frameHeight": 14 + } + }, + "chenjesu": { + "name": "Chenjesu Broodhome", + "race": "Chenjesu", + "team": "alliance", + "pointValue": 28, + "stats": { + "maxCrew": 36, + "maxEnergy": 30, + "energyRegen": 1, + "energyWait": 4, + "maxThrust": 27, + "thrustIncrement": 3, + "thrustWait": 4, + "turnWait": 6, + "mass": 10 + }, + "primary": { + "type": "crystalShard", + "name": "Photon Crystal", + "energyCost": 5, + "refireWait": 0, + "damage": 6, + "speed": 64, + "lifeFrames": 90, + "hp": 10, + "muzzleDist": 12, + "radius": 30, + "fragment": { + "count": 8, + "speed": 64, + "lifeFrames": 10, + "damage": 2 + }, + "sfxClass": "heavy" + }, + "special": { + "type": "dogi", + "name": "D.O.G.I.", + "energyCost": 30, + "refireWait": 0, + "params": { + "speed": 32, + "hp": 3, + "drain": 10, + "maxUnits": 4, + "radius": 48 + }, + "sfxClass": "launch" + }, + "passives": [], + "ai": { + "preferredRange": 2300, + "engageStyle": "snipe", + "specialUse": "onCooldown", + "fleeCrewFrac": 0.1, + "gravityCaution": 1 + }, + "sprite": { + "sheet": "sc-ship-chenjesu", + "frames": 16, + "frameWidth": 34, + "frameHeight": 34 + } + }, + "chmmr": { + "name": "Chmmr Avatar", + "race": "Chmmr", + "team": "alliance", + "pointValue": 30, + "stats": { + "maxCrew": 42, + "maxEnergy": 42, + "energyRegen": 1, + "energyWait": 1, + "maxThrust": 35, + "thrustIncrement": 7, + "thrustWait": 5, + "turnWait": 3, + "mass": 10 + }, + "primary": { + "type": "beam", + "name": "Heavy Laser", + "energyCost": 2, + "refireWait": 0, + "damage": 2, + "range": 150, + "sfxClass": "zap" + }, + "special": { + "type": "tractor", + "name": "Tractor Beam", + "energyCost": 1, + "refireWait": 0, + "params": { + "pull": 6, + "range": 1000 + }, + "sfxClass": "warp" + }, + "passives": [ + "zapsats" + ], + "zapsats": { + "count": 3, + "hp": 10, + "zapRange": 64, + "zapWait": 2, + "orbitR": 300 + }, + "ai": { + "preferredRange": 520, + "engageStyle": "chase", + "specialUse": "tractor", + "fleeCrewFrac": 0.1, + "gravityCaution": 1 + }, + "sprite": { + "sheet": "sc-ship-chmmr", + "frames": 16, + "frameWidth": 37, + "frameHeight": 38 + } + }, + "druuge": { + "name": "Druuge Mauler", + "race": "Druuge", + "team": "neutral", + "pointValue": 17, + "stats": { + "maxCrew": 14, + "maxEnergy": 32, + "energyRegen": 1, + "energyWait": 50, + "maxThrust": 20, + "thrustIncrement": 2, + "thrustWait": 1, + "turnWait": 4, + "mass": 5 + }, + "primary": { + "type": "projectile", + "name": "Mass Driver", + "energyCost": 4, + "refireWait": 10, + "damage": 6, + "speed": 120, + "lifeFrames": 20, + "hp": 4, + "muzzleDist": 24, + "radius": 28, + "recoil": 24, + "sfxClass": "heavy" + }, + "special": { + "type": "furnace", + "name": "Crew Furnace", + "energyCost": 0, + "refireWait": 30, + "sfxClass": "explode" + }, + "passives": [], + "ai": { + "preferredRange": 2200, + "engageStyle": "snipe", + "specialUse": "furnace", + "fleeCrewFrac": 0.2, + "gravityCaution": 0.9 + }, + "sprite": { + "sheet": "sc-ship-druuge", + "frames": 16, + "frameWidth": 15, + "frameHeight": 34 + } + }, + "earthling": { + "name": "Earthling Cruiser", + "race": "Earthling", + "team": "alliance", + "pointValue": 11, + "stats": { + "maxCrew": 18, + "maxEnergy": 18, + "energyRegen": 1, + "energyWait": 8, + "maxThrust": 24, + "thrustIncrement": 3, + "thrustWait": 4, + "turnWait": 1, + "mass": 6 + }, + "primary": { + "type": "homing", + "name": "Nuclear Missile", + "energyCost": 9, + "refireWait": 10, + "damage": 4, + "speed": 40, + "lifeFrames": 60, + "hp": 1, + "trackWait": 3, + "muzzleDist": 42, + "sfxClass": "missile" + }, + "special": { + "type": "pointDefense", + "name": "Point-Defense Laser", + "energyCost": 4, + "refireWait": 9, + "params": { + "range": 100, + "damage": 1, + "maxTargets": 4 + }, + "sfxClass": "zap" + }, + "passives": [], + "ai": { + "preferredRange": 2400, + "engageStyle": "kite", + "specialUse": "pointDefenseAuto", + "fleeCrewFrac": 0.25, + "gravityCaution": 1 + }, + "sprite": { + "sheet": "sc-ship-earthling", + "frames": 16, + "frameWidth": 13, + "frameHeight": 35 + } + }, + "ilwrath": { + "name": "Ilwrath Avenger", + "race": "Ilwrath", + "team": "hierarchy", + "pointValue": 10, + "stats": { + "maxCrew": 22, + "maxEnergy": 16, + "energyRegen": 4, + "energyWait": 4, + "maxThrust": 25, + "thrustIncrement": 5, + "thrustWait": 0, + "turnWait": 2, + "mass": 7 + }, + "primary": { + "type": "projectile", + "name": "Hellfire Spout", + "energyCost": 1, + "refireWait": 0, + "damage": 1, + "speed": 25, + "lifeFrames": 8, + "hp": 1, + "jitterDeg": 7, + "addShipVelocity": true, + "muzzleDist": 29, + "projectileType": "flame", + "sfxClass": "flame" + }, + "special": { + "type": "cloak", + "name": "Cloaking Device", + "energyCost": 3, + "refireWait": 13, + "sfxClass": "warp" + }, + "passives": [], + "ai": { + "preferredRange": 500, + "engageStyle": "ambush", + "specialUse": "cloakApproach", + "fleeCrewFrac": 0.2, + "gravityCaution": 0.8 + }, + "sprite": { + "sheet": "sc-ship-ilwrath", + "frames": 16, + "frameWidth": 38, + "frameHeight": 31 + } + }, + "kohrah": { + "name": "Kohr-Ah Marauder", + "race": "Ur-Quan Kohr-Ah", + "team": "hierarchy", + "pointValue": 30, + "stats": { + "maxCrew": 42, + "maxEnergy": 42, + "energyRegen": 1, + "energyWait": 4, + "maxThrust": 30, + "thrustIncrement": 6, + "thrustWait": 6, + "turnWait": 4, + "mass": 10 + }, + "primary": { + "type": "guidedBlades", + "name": "Spinning Blades", + "energyCost": 6, + "refireWait": 6, + "damage": 4, + "speed": 64, + "lifeFrames": 64, + "hp": 10, + "muzzleDist": 9, + "radius": 30, + "sfxClass": "heavy" + }, + "special": { + "type": "fireRing", + "name": "F.R.I.E.D.", + "energyCost": 21, + "refireWait": 9, + "params": { + "range": 520, + "frames": 32, + "damage": 1, + "damageEvery": 8 + }, + "sfxClass": "explode" + }, + "passives": [], + "ai": { + "preferredRange": 1500, + "engageStyle": "chase", + "specialUse": "fireRingReactive", + "fleeCrewFrac": 0.1, + "gravityCaution": 1 + }, + "sprite": { + "sheet": "sc-ship-kohrah", + "frames": 16, + "frameWidth": 22, + "frameHeight": 38 + } + }, + "melnorme": { + "name": "Melnorme Trader", + "race": "Melnorme", + "team": "neutral", + "pointValue": 18, + "stats": { + "maxCrew": 20, + "maxEnergy": 42, + "energyRegen": 1, + "energyWait": 4, + "maxThrust": 36, + "thrustIncrement": 6, + "thrustWait": 4, + "turnWait": 4, + "mass": 7 + }, + "primary": { + "type": "chargeShot", + "name": "Pump-Up Blaster", + "energyCost": 5, + "refireWait": 1, + "speed": 180, + "lifeFrames": 10, + "damagePerLevel": 2, + "maxLevel": 4, + "levelFrames": 24, + "muzzleDist": 24, + "sfxClass": "heavy" + }, + "special": { + "type": "confuse", + "name": "Confusion Pulse", + "energyCost": 20, + "refireWait": 20, + "params": { + "speed": 120, + "lifeFrames": 20, + "confuseFrames": 96, + "hp": 200, + "muzzleDist": 16 + }, + "sfxClass": "warp" + }, + "passives": [], + "ai": { + "preferredRange": 2400, + "engageStyle": "snipe", + "specialUse": "onCooldown", + "fleeCrewFrac": 0.2, + "gravityCaution": 0.9 + }, + "sprite": { + "sheet": "sc-ship-melnorme", + "frames": 16, + "frameWidth": 31, + "frameHeight": 31 + } + }, + "mmrnmhrm": { + "name": "Mmrnmhrm X-Form", + "race": "Mmrnmhrm", + "team": "alliance", + "pointValue": 19, + "stats": { + "maxCrew": 20, + "maxEnergy": 10, + "energyRegen": 2, + "energyWait": 6, + "maxThrust": 20, + "thrustIncrement": 5, + "thrustWait": 1, + "turnWait": 2, + "mass": 3 + }, + "primary": { + "type": "beam", + "name": "Twin Auto-Lasers", + "energyCost": 1, + "refireWait": 0, + "damage": 2, + "range": 141, + "autoAim": true, + "sfxClass": "zap" + }, + "special": { + "type": "transform", + "name": "Transform", + "energyCost": 10, + "refireWait": 0, + "sfxClass": "warp" + }, + "forms": [ + { + "name": "Y-Wing", + "stats": { + "maxThrust": 50, + "thrustIncrement": 10, + "thrustWait": 0, + "turnWait": 14, + "energyRegen": 1, + "energyWait": 6 + }, + "primary": { + "type": "homing", + "name": "Twin Seekers", + "energyCost": 1, + "refireWait": 20, + "damage": 1, + "speed": 80, + "lifeFrames": 40, + "hp": 1, + "trackWait": 5, + "offsets": [ + [ + 4, + -10 + ], + [ + 4, + 10 + ] + ], + "sfxClass": "missile" + }, + "sprite": { + "sheet": "sc-ship-mmrnmhrm-y", + "frames": 16, + "frameWidth": 17, + "frameHeight": 24 + } + } + ], + "passives": [], + "ai": { + "preferredRange": 1100, + "engageStyle": "kite", + "specialUse": "transformRange", + "fleeCrewFrac": 0.2, + "gravityCaution": 0.9 + }, + "sprite": { + "sheet": "sc-ship-mmrnmhrm", + "frames": 16, + "frameWidth": 26, + "frameHeight": 18 + } + }, + "mycon": { + "name": "Mycon Podship", + "race": "Mycon", + "team": "hierarchy", + "pointValue": 21, + "stats": { + "maxCrew": 20, + "maxEnergy": 40, + "energyRegen": 1, + "energyWait": 4, + "maxThrust": 27, + "thrustIncrement": 9, + "thrustWait": 6, + "turnWait": 6, + "mass": 7 + }, + "primary": { + "type": "homing", + "name": "Plasmoid", + "energyCost": 20, + "refireWait": 5, + "damage": 10, + "speed": 32, + "lifeFrames": 143, + "hp": 1, + "trackWait": 1, + "muzzleDist": 12, + "radius": 60, + "projectileType": "plasmoid", + "sfxClass": "heavy" + }, + "special": { + "type": "regenerate", + "name": "Regenerate", + "energyCost": 40, + "refireWait": 0, + "params": { + "crew": 4 + }, + "sfxClass": "warp" + }, + "passives": [], + "ai": { + "preferredRange": 1600, + "engageStyle": "kite", + "specialUse": "regen", + "fleeCrewFrac": 0.3, + "gravityCaution": 0.9 + }, + "sprite": { + "sheet": "sc-ship-mycon", + "frames": 16, + "frameWidth": 26, + "frameHeight": 25 + } + }, + "orz": { + "name": "Orz Nemesis", + "race": "Orz", + "team": "alliance", + "pointValue": 23, + "stats": { + "maxCrew": 16, + "maxEnergy": 20, + "energyRegen": 1, + "energyWait": 6, + "maxThrust": 35, + "thrustIncrement": 5, + "thrustWait": 0, + "turnWait": 1, + "mass": 4 + }, + "primary": { + "type": "projectile", + "name": "Howitzer", + "energyCost": 6, + "refireWait": 4, + "damage": 3, + "speed": 120, + "lifeFrames": 12, + "hp": 2, + "muzzleDist": 4, + "turretArcDeg": 60, + "sfxClass": "heavy" + }, + "special": { + "type": "marines", + "name": "Space Marines", + "energyCost": 0, + "refireWait": 12, + "params": { + "speed": 24, + "hp": 3, + "maxUnits": 8, + "killEvery": 36, + "maxKills": 4 + }, + "sfxClass": "launch" + }, + "passives": [], + "ai": { + "preferredRange": 1300, + "engageStyle": "chase", + "specialUse": "marines", + "fleeCrewFrac": 0.25, + "gravityCaution": 0.9 + }, + "sprite": { + "sheet": "sc-ship-orz", + "frames": 16, + "frameWidth": 29, + "frameHeight": 23 + } + }, + "pkunk": { + "name": "Pkunk Fury", + "race": "Pkunk", + "team": "alliance", + "pointValue": 20, + "stats": { + "maxCrew": 8, + "maxEnergy": 12, + "energyRegen": 0, + "energyWait": 0, + "maxThrust": 64, + "thrustIncrement": 16, + "thrustWait": 0, + "turnWait": 0, + "mass": 1 + }, + "primary": { + "type": "projectile", + "name": "Triple Miniguns", + "energyCost": 1, + "refireWait": 0, + "damage": 1, + "speed": 96, + "lifeFrames": 5, + "hp": 1, + "anglesDeg": [ + 0, + 90, + -90 + ], + "muzzleDist": 4, + "sfxClass": "pellet" + }, + "special": { + "type": "taunt", + "name": "Cathartic Insults", + "energyCost": 0, + "refireWait": 16, + "params": { + "energy": 2 + }, + "sfxClass": "launch" + }, + "passives": [ + "resurrect" + ], + "ai": { + "preferredRange": 600, + "engageStyle": "chase", + "specialUse": "taunt", + "fleeCrewFrac": 0, + "gravityCaution": 0.5 + }, + "sprite": { + "sheet": "sc-ship-pkunk", + "frames": 16, + "frameWidth": 27, + "frameHeight": 20 + } + }, + "shofixti": { + "name": "Shofixti Scout", + "race": "Shofixti", + "team": "alliance", + "pointValue": 5, + "stats": { + "maxCrew": 6, + "maxEnergy": 4, + "energyRegen": 1, + "energyWait": 9, + "maxThrust": 35, + "thrustIncrement": 5, + "thrustWait": 0, + "turnWait": 1, + "mass": 1 + }, + "primary": { + "type": "projectile", + "name": "Dart Gun", + "energyCost": 1, + "refireWait": 3, + "damage": 1, + "speed": 96, + "lifeFrames": 10, + "hp": 1, + "muzzleDist": 15, + "sfxClass": "pellet" + }, + "special": { + "type": "glory", + "name": "Glory Device", + "energyCost": 0, + "refireWait": 0, + "params": { + "range": 180, + "maxDamage": 18 + }, + "sfxClass": "explode" + }, + "passives": [], + "ai": { + "preferredRange": 900, + "engageStyle": "chase", + "specialUse": "gloryFinale", + "fleeCrewFrac": 0, + "gravityCaution": 0.7 + }, + "sprite": { + "sheet": "sc-ship-shofixti", + "frames": 16, + "frameWidth": 9, + "frameHeight": 15 + } + }, + "slylandro": { + "name": "Slylandro Probe", + "race": "Slylandro", + "team": "neutral", + "pointValue": 17, + "stats": { + "maxCrew": 12, + "maxEnergy": 20, + "energyRegen": 0, + "energyWait": 10, + "maxThrust": 60, + "thrustIncrement": 60, + "thrustWait": 0, + "turnWait": 0, + "mass": 1 + }, + "primary": { + "type": "lightning", + "name": "Lightning Discharge", + "energyCost": 2, + "refireWait": 17, + "damage": 2, + "minRange": 48, + "maxRange": 200, + "sfxClass": "zap" + }, + "special": { + "type": "harvest", + "name": "Asteroid Harvest", + "energyCost": 0, + "refireWait": 20, + "params": { + "range": 120, + "energy": 8 + }, + "sfxClass": "launch" + }, + "passives": [ + "probeDrive" + ], + "ai": { + "preferredRange": 500, + "engageStyle": "chase", + "specialUse": "harvest", + "fleeCrewFrac": 0.2, + "gravityCaution": 0.1 + }, + "sprite": { + "sheet": "sc-ship-slylandro", + "frames": 16, + "frameWidth": 28, + "frameHeight": 45 + } + }, + "spathi": { + "name": "Spathi Eluder", + "race": "Spathi", + "team": "hierarchy", + "pointValue": 18, + "stats": { + "maxCrew": 30, + "maxEnergy": 10, + "energyRegen": 1, + "energyWait": 10, + "maxThrust": 48, + "thrustIncrement": 12, + "thrustWait": 1, + "turnWait": 1, + "mass": 5 + }, + "primary": { + "type": "projectile", + "name": "Forward Gun", + "energyCost": 2, + "refireWait": 0, + "damage": 1, + "speed": 120, + "lifeFrames": 10, + "hp": 1, + "muzzleDist": 16, + "sfxClass": "pellet" + }, + "special": { + "type": "rearHoming", + "name": "B.U.T.T. Missile", + "energyCost": 3, + "refireWait": 7, + "params": { + "speed": 32, + "damage": 2, + "lifeFrames": 30, + "hp": 1, + "trackWait": 1, + "muzzleDist": 20 + }, + "sfxClass": "missile" + }, + "passives": [], + "ai": { + "preferredRange": 2000, + "engageStyle": "kite", + "specialUse": "kiteRear", + "fleeCrewFrac": 0.5, + "gravityCaution": 0.9 + }, + "sprite": { + "sheet": "sc-ship-spathi", + "frames": 16, + "frameWidth": 28, + "frameHeight": 22 + } + }, + "supox": { + "name": "Supox Blade", + "race": "Supox", + "team": "alliance", + "pointValue": 16, + "stats": { + "maxCrew": 12, + "maxEnergy": 16, + "energyRegen": 1, + "energyWait": 4, + "maxThrust": 40, + "thrustIncrement": 8, + "thrustWait": 0, + "turnWait": 1, + "mass": 4 + }, + "primary": { + "type": "projectile", + "name": "Gob Gun", + "energyCost": 1, + "refireWait": 2, + "damage": 1, + "speed": 120, + "lifeFrames": 10, + "hp": 1, + "muzzleDist": 8, + "sfxClass": "pellet" + }, + "special": { + "type": "omniThrust", + "name": "Omni-Thrust", + "energyCost": 1, + "refireWait": 0, + "sfxClass": "launch" + }, + "passives": [], + "ai": { + "preferredRange": 1000, + "engageStyle": "chase", + "specialUse": "never", + "fleeCrewFrac": 0.25, + "gravityCaution": 0.8 + }, + "sprite": { + "sheet": "sc-ship-supox", + "frames": 16, + "frameWidth": 13, + "frameHeight": 30 + } + }, + "syreen": { + "name": "Syreen Penetrator", + "race": "Syreen", + "team": "alliance", + "pointValue": 13, + "stats": { + "maxCrew": 12, + "maxEnergy": 16, + "energyRegen": 1, + "energyWait": 6, + "maxThrust": 36, + "thrustIncrement": 9, + "thrustWait": 1, + "turnWait": 1, + "mass": 2 + }, + "primary": { + "type": "projectile", + "name": "Particle Stiletto", + "energyCost": 1, + "refireWait": 8, + "damage": 2, + "speed": 120, + "lifeFrames": 10, + "hp": 1, + "muzzleDist": 12, + "sfxClass": "pellet" + }, + "special": { + "type": "song", + "name": "Syreen Song", + "energyCost": 5, + "refireWait": 20, + "params": { + "range": 208, + "maxCrew": 8, + "fraction": 0.25 + }, + "sfxClass": "warp" + }, + "passives": [], + "ai": { + "preferredRange": 800, + "engageStyle": "chase", + "specialUse": "song", + "fleeCrewFrac": 0.3, + "gravityCaution": 0.9 + }, + "sprite": { + "sheet": "sc-ship-syreen", + "frames": 16, + "frameWidth": 17, + "frameHeight": 30 + } + }, + "thraddash": { + "name": "Thraddash Torch", + "race": "Thraddash", + "team": "hierarchy", + "pointValue": 10, + "stats": { + "maxCrew": 8, + "maxEnergy": 24, + "energyRegen": 1, + "energyWait": 6, + "maxThrust": 28, + "thrustIncrement": 7, + "thrustWait": 0, + "turnWait": 1, + "mass": 7 + }, + "primary": { + "type": "projectile", + "name": "Ion Blaster", + "energyCost": 2, + "refireWait": 12, + "damage": 1, + "speed": 120, + "lifeFrames": 15, + "hp": 2, + "muzzleDist": 12, + "sfxClass": "pellet" + }, + "special": { + "type": "afterburner", + "name": "Afterburner", + "energyCost": 1, + "refireWait": 0, + "params": { + "thrust": 64, + "thrustIncrement": 12, + "napalm": { + "lifeFrames": 48, + "damage": 2, + "radius": 60 + } + }, + "sfxClass": "flame" + }, + "passives": [], + "ai": { + "preferredRange": 700, + "engageStyle": "chase", + "specialUse": "burner", + "fleeCrewFrac": 0.25, + "gravityCaution": 0.8 + }, + "sprite": { + "sheet": "sc-ship-thraddash", + "frames": 16, + "frameWidth": 22, + "frameHeight": 22 + } + }, + "umgah": { + "name": "Umgah Drone", + "race": "Umgah", + "team": "hierarchy", + "pointValue": 7, + "stats": { + "maxCrew": 10, + "maxEnergy": 30, + "energyRegen": 30, + "energyWait": 150, + "maxThrust": 18, + "thrustIncrement": 6, + "thrustWait": 3, + "turnWait": 4, + "mass": 1 + }, + "primary": { + "type": "cone", + "name": "Antimatter Cone", + "energyCost": 0, + "refireWait": 2, + "damage": 1, + "hp": 100, + "lifeFrames": 3, + "radius": 130, + "muzzleDist": 32, + "sfxClass": "flame" + }, + "special": { + "type": "retroHop", + "name": "Retro-Propulsion", + "energyCost": 1, + "refireWait": 2, + "params": { + "dist": 160 + }, + "sfxClass": "warp" + }, + "passives": [], + "ai": { + "preferredRange": 380, + "engageStyle": "chase", + "specialUse": "retro", + "fleeCrewFrac": 0.2, + "gravityCaution": 0.7 + }, + "sprite": { + "sheet": "sc-ship-umgah", + "frames": 16, + "frameWidth": 12, + "frameHeight": 14 + } + }, + "urquan": { + "name": "Ur-Quan Dreadnought", + "race": "Ur-Quan Kzer-Za", + "team": "hierarchy", + "pointValue": 30, + "stats": { + "maxCrew": 42, + "maxEnergy": 42, + "energyRegen": 1, + "energyWait": 4, + "maxThrust": 30, + "thrustIncrement": 6, + "thrustWait": 6, + "turnWait": 4, + "mass": 10 + }, + "primary": { + "type": "projectile", + "name": "Fusion Blast", + "energyCost": 6, + "refireWait": 6, + "damage": 6, + "speed": 80, + "lifeFrames": 20, + "hp": 10, + "radius": 36, + "muzzleDist": 32, + "sfxClass": "heavy" + }, + "special": { + "type": "fighters", + "name": "Fighter Launch", + "energyCost": 8, + "refireWait": 9, + "params": { + "speed": 32, + "outFrames": 125, + "lifeFrames": 400, + "laserRange": 72, + "laserDamage": 1, + "weaponWait": 8, + "hp": 1 + }, + "sfxClass": "launch" + }, + "passives": [], + "ai": { + "preferredRange": 1400, + "engageStyle": "chase", + "specialUse": "swarm", + "fleeCrewFrac": 0.15, + "gravityCaution": 1 + }, + "sprite": { + "sheet": "sc-ship-urquan", + "frames": 16, + "frameWidth": 29, + "frameHeight": 33 + } + }, + "utwig": { + "name": "Utwig Jugger", + "race": "Utwig", + "team": "alliance", + "pointValue": 22, + "stats": { + "maxCrew": 20, + "maxEnergy": 20, + "energyRegen": 0, + "energyWait": 255, + "maxThrust": 36, + "thrustIncrement": 6, + "thrustWait": 6, + "turnWait": 1, + "mass": 8 + }, + "primary": { + "type": "projectile", + "name": "Six-Barreled Spray", + "energyCost": 0, + "refireWait": 7, + "damage": 1, + "speed": 120, + "lifeFrames": 10, + "hp": 1, + "count": 6, + "offsets": [ + [ + 5, + -18 + ], + [ + 13, + -9 + ], + [ + 17, + -4 + ], + [ + 17, + 4 + ], + [ + 13, + 9 + ], + [ + 5, + 18 + ] + ], + "anglesDeg": [ + -10, + -6, + -2, + 2, + 6, + 10 + ], + "sfxClass": "pellet" + }, + "special": { + "type": "absorbShield", + "name": "Absorption Shield", + "energyCost": 1, + "refireWait": 12, + "params": { + "frames": 14 + }, + "sfxClass": "warp" + }, + "passives": [], + "ai": { + "preferredRange": 1100, + "engageStyle": "chase", + "specialUse": "shieldReactive", + "fleeCrewFrac": 0.15, + "gravityCaution": 0.9 + }, + "sprite": { + "sheet": "sc-ship-utwig", + "frames": 16, + "frameWidth": 33, + "frameHeight": 31 + } + }, + "vux": { + "name": "VUX Intruder", + "race": "VUX", + "team": "hierarchy", + "pointValue": 12, + "stats": { + "maxCrew": 20, + "maxEnergy": 40, + "energyRegen": 1, + "energyWait": 8, + "maxThrust": 21, + "thrustIncrement": 7, + "thrustWait": 4, + "turnWait": 6, + "mass": 6 + }, + "primary": { + "type": "beam", + "name": "Gigawatt Laser", + "energyCost": 1, + "refireWait": 0, + "damage": 1, + "range": 179, + "sfxClass": "zap" + }, + "special": { + "type": "limpets", + "name": "Limpet Launcher", + "energyCost": 2, + "refireWait": 7, + "params": { + "speed": 25, + "lifeFrames": 80, + "trackWait": 0 + }, + "sfxClass": "launch" + }, + "passives": [ + "warpIn" + ], + "ai": { + "preferredRange": 650, + "engageStyle": "snipe", + "specialUse": "onCooldown", + "fleeCrewFrac": 0.2, + "gravityCaution": 0.8 + }, + "sprite": { + "sheet": "sc-ship-vux", + "frames": 16, + "frameWidth": 15, + "frameHeight": 18 + } + }, + "yehat": { + "name": "Yehat Terminator", + "race": "Yehat", + "team": "alliance", + "pointValue": 23, + "stats": { + "maxCrew": 20, + "maxEnergy": 10, + "energyRegen": 2, + "energyWait": 6, + "maxThrust": 30, + "thrustIncrement": 6, + "thrustWait": 2, + "turnWait": 2, + "mass": 3 + }, + "primary": { + "type": "projectile", + "name": "Twin Pulse Cannons", + "energyCost": 1, + "refireWait": 0, + "damage": 1, + "speed": 80, + "lifeFrames": 10, + "hp": 1, + "count": 2, + "offsets": [ + [ + 14, + -10 + ], + [ + 14, + 10 + ] + ], + "sfxClass": "pellet" + }, + "special": { + "type": "forceShield", + "name": "Force Shield", + "energyCost": 3, + "refireWait": 2, + "params": { + "frames": 10 + }, + "sfxClass": "warp" + }, + "passives": [], + "ai": { + "preferredRange": 900, + "engageStyle": "chase", + "specialUse": "shieldReactive", + "fleeCrewFrac": 0.2, + "gravityCaution": 0.9 + }, + "sprite": { + "sheet": "sc-ship-yehat", + "frames": 16, + "frameWidth": 19, + "frameHeight": 13 + } + }, + "zoqfot": { + "name": "Zoq-Fot-Pik Stinger", + "race": "Zoq-Fot-Pik", + "team": "alliance", + "pointValue": 6, + "stats": { + "maxCrew": 10, + "maxEnergy": 10, + "energyRegen": 1, + "energyWait": 4, + "maxThrust": 40, + "thrustIncrement": 10, + "thrustWait": 0, + "turnWait": 1, + "mass": 5 + }, + "primary": { + "type": "projectile", + "name": "Spray Gun", + "energyCost": 1, + "refireWait": 2, + "damage": 1, + "speed": 40, + "lifeFrames": 10, + "hp": 1, + "jitterDeg": 4, + "muzzleDist": 8, + "sfxClass": "pellet" + }, + "special": { + "type": "tongue", + "name": "The Tongue", + "energyCost": 7, + "refireWait": 6, + "params": { + "range": 35, + "damage": 12 + }, + "sfxClass": "flame" + }, + "passives": [], + "ai": { + "preferredRange": 550, + "engageStyle": "chase", + "specialUse": "tongueRange", + "fleeCrewFrac": 0.2, + "gravityCaution": 0.7 + }, + "sprite": { + "sheet": "sc-ship-zoqfot", + "frames": 16, + "frameWidth": 23, + "frameHeight": 24 + } + } + } +} diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 252030a..c7ee57e 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -130,6 +130,17 @@ export const MANIFEST = { // Optional drop-in art — spec in assets/gamedata/peggle/sprites.md. Missing // files 404 harmlessly and the game renders procedurally: circles for // pegs/ball, gradient starfield for the board background. + // Ship rotation sheets are described by each ship's `sprite` blocks in + // data/star-control-ships.json (written by tools/fetchStarControlAssets.js). + // Ships without a sheet render as procedural vector chevrons, so a missing + // file is harmless and new JSON ships need no manifest edit. + starcontrol: (scene) => { + const cfg = scene.cache.json.get('star-control-ships'); + return Object.values(cfg?.ships ?? {}) + .flatMap((s) => [s.sprite, s.projectileSprite, ...(s.forms ?? []).map((f) => f.sprite)]) + .filter((sp) => sp?.sheet && sp.frameWidth > 0 && sp.frameHeight > 0) + .map((sp) => sheet(sp.sheet, `assets/images/starcontrol/${sp.sheet}.png`, sp.frameWidth, sp.frameHeight)); + }, peggle: [ sheet('peggle-sprites', 'assets/images/peggle-sprites.png', 64, 64), // One board background per master, named by opponent id diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 12a3392..599e25b 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -109,3 +109,4 @@ registerGame({ slug: 'swdbg', name: 'Star Wars', category: 'cards', cardGame: tr registerGame({ slug: 'balatro', name: 'Balatro', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 79 }); registerGame({ slug: 'peggle', name: 'Peggle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 80 }); registerGame({ slug: 'coloradodefense', name: 'Colorado Defense', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 81 }); +registerGame({ slug: 'starcontrol', name: 'Star Control', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 2, minOpponents: 0, maxOpponents: 0, iconFrame: 82 }); diff --git a/src/games/starcontrol/StarControlAI.js b/src/games/starcontrol/StarControlAI.js new file mode 100644 index 0000000..d11d403 --- /dev/null +++ b/src/games/starcontrol/StarControlAI.js @@ -0,0 +1,371 @@ +// Real-time computer pilot for Star Control Super Melee. No Phaser +// dependency. Modeled on BlockFighterAI: skill 1-10 interpolates between +// anchor knob sets, and the pilot re-decides its inputs only every reactMs, +// so low skill reacts like a slow human rather than a lobotomized one. +// +// Layered decision, first match wins the steering vote: +// 1. survival — don't fall into the planet, dodge incoming tracked fire +// 2. range — close/hold/open distance per the ship's engageStyle +// 3. aim+fire — intercept lead with skill-scaled aim error +// 4. special — per-ship tactic script keyed by ai.specialUse in the JSON + +import { + ARENA_W, ARENA_H, TUNE, + mulberry32, tdelta, tdist, angleDelta, interceptAngle, quantizeFacing, + facingToRad, enemyOf, +} from './StarControlLogic.js'; + +export const SKILL_ANCHORS = [ + { skill: 1, reactMs: 550, aimErrDeg: 22, blunder: 0.35, lead: 0.0, gravityIQ: 0.2, dodge: 0.0 }, + { skill: 3, reactMs: 400, aimErrDeg: 14, blunder: 0.20, lead: 0.4, gravityIQ: 0.5, dodge: 0.25 }, + { skill: 5, reactMs: 280, aimErrDeg: 8, blunder: 0.10, lead: 1.0, gravityIQ: 0.8, dodge: 0.55 }, + { skill: 7, reactMs: 190, aimErrDeg: 4, blunder: 0.04, lead: 1.0, gravityIQ: 1.0, dodge: 0.8 }, + { skill: 10, reactMs: 110, aimErrDeg: 1, blunder: 0.0, lead: 1.0, gravityIQ: 1.0, dodge: 1.0 }, +]; + +export function knobsFor(skill) { + const s = Math.max(1, Math.min(10, skill)); + let lo = SKILL_ANCHORS[0]; + let hi = SKILL_ANCHORS[SKILL_ANCHORS.length - 1]; + for (const a of SKILL_ANCHORS) { + if (a.skill <= s && a.skill >= lo.skill) lo = a; + if (a.skill >= s && a.skill < hi.skill) hi = a; + } + if (lo.skill === hi.skill) return { ...lo }; + const t = (s - lo.skill) / (hi.skill - lo.skill); + const mix = {}; + for (const k of Object.keys(lo)) mix[k] = lo[k] + (hi[k] - lo[k]) * t; + mix.skill = s; + return mix; +} + +export function createAI({ skill = 5, side = 1, seed = 1 } = {}) { + return { + side, + skill, + knobs: knobsFor(skill), + rng: mulberry32((seed * 2654435761) >>> 0), + clockMs: 0, + nextDecisionMs: 0, + input: { left: false, right: false, thrust: false, fire: false, special: false }, + lastKnown: null, // last visible enemy position (cloak memory) + aimBias: 0, // current aim-error sample, re-rolled per decision + }; +} + +// --- steering helper: sets left/right toward a desired absolute heading. +function steerTo(ai, ship, heading, deadzone) { + const d = angleDelta(ship.facing, heading); + ai.input.left = d < -deadzone; + ai.input.right = d > deadzone; + return Math.abs(d); +} + +function primaryReach(def) { + const p = def.primary; + if (p.type === 'beam') return p.range * 4; + if (p.maxRange) return p.maxRange * 4 * 0.8; // hitscan bolts (lightning) + if (p.type === 'cone') return (p.muzzleDist ?? 32) * 4 + (p.radius ?? 120); + return (p.muzzleDist ?? 12) * 4 + (p.speed ?? 0) * (p.lifeFrames ?? 0); +} + +// Nearest live enemy projectile that is closing on the ship. +function incomingThreat(state, ship) { + let best = null; + let bestD = Infinity; + for (const p of state.projectiles) { + if (!p.alive || p.owner === ship.side || p.kind === 'unit') continue; + const d = tdist(ship.x, ship.y, p.x, p.y); + if (d > 1600 || d >= bestD) continue; + const closing = tdelta(p.x, ship.x, ARENA_W) * p.vx + tdelta(p.y, ship.y, ARENA_H) * p.vy; + if (closing > 0) { best = p; bestD = d; } + } + return best; +} + +// Predicts whether coasting for `frames` brings the ship dangerously close to +// the planet. +function planetDanger(state, ship, frames) { + let { x, y } = ship; + const { vx, vy } = ship; + const danger = state.planet.radius + ship.def.radius + 260; + for (let f = 0; f < frames; f += 8) { + x += vx * 8; y += vy * 8; + if (tdist(x, y, state.planet.x, state.planet.y) < danger) return true; + } + return false; +} + +// --- special-ability tactics, keyed by the ship JSON's ai.specialUse. +const SPECIAL_TACTICS = { + pointDefenseAuto(ai, state, ship) { + const range = (ship.def.special.params?.range ?? 100) * 4 * 1.15; + for (const p of state.projectiles) { + if (p.alive && p.owner !== ship.side && tdist(ship.x, ship.y, p.x, p.y) < range) return true; + } + return false; + }, + gloryFinale(ai, state, ship) { + const enemy = enemyOf(state, ship); + const range = (ship.def.special.params?.range ?? 180) * 4; + const dist = tdist(ship.x, ship.y, enemy.x, enemy.y); + // Boom when point-blank, or when nearly dead and still in blast range. + return dist < range * 0.45 || (ship.crew <= 2 && dist < range * 0.85); + }, + swarm(ai, state, ship) { + const enemy = enemyOf(state, ship); + const out = state.projectiles.filter((p) => p.alive && p.type === 'fighter' && p.owner === ship.side).length; + return ship.crew > 8 && out < 4 + && ship.energy > ship.def.special.energyCost + 10 + && tdist(ship.x, ship.y, enemy.x, enemy.y) < 4200; + }, + cloakApproach(ai, state, ship) { + const enemy = enemyOf(state, ship); + const dist = tdist(ship.x, ship.y, enemy.x, enemy.y); + if (ship.fx.cloaked) return false; // stay dark until the flame flies + return dist > 1400; + }, + kiteRear(ai, state, ship) { + const enemy = enemyOf(state, ship); + const dist = tdist(ship.x, ship.y, enemy.x, enemy.y); + if (dist > 1600) return false; + const bearing = Math.atan2(tdelta(ship.y, enemy.y, ARENA_H), tdelta(ship.x, enemy.x, ARENA_W)); + return Math.abs(angleDelta(ship.facing, bearing)) > 1.9; // enemy behind us + }, + onCooldown(ai, state, ship) { + return ship.energy >= ship.def.stats.maxEnergy * 0.85; + }, + never() { return false; }, + blazer(ai, state, ship) { + const enemy = enemyOf(state, ship); + const dist = tdist(ship.x, ship.y, enemy.x, enemy.y); + if (ship.fx.blazer) return ship.energy < 3 || dist > 3200; // toggle back off + return dist < 2600 && ship.energy > ship.def.stats.maxEnergy * 0.5; + }, + teleportEscape(ai, state, ship) { + if (incomingThreat(state, ship) && ai.rng() < 0.5) return true; + const enemy = enemyOf(state, ship); + return ship.crew <= 2 && tdist(ship.x, ship.y, enemy.x, enemy.y) < 900; + }, + tractor(ai, state, ship) { + const enemy = enemyOf(state, ship); + const d = tdist(ship.x, ship.y, enemy.x, enemy.y); + return d < 3600 && d > 500 && ship.energy > 10; + }, + furnace(ai, state, ship) { + return ship.crew > 3 && ship.energy < (ship.def.primary.energyCost ?? 4) + 2; + }, + fireRingReactive(ai, state, ship) { + if (ship.fx.friedFrames > 0) return false; + const range = (ship.def.special.params?.range ?? 520) * 0.9; + for (const p of state.projectiles) { + if (p.alive && p.owner !== ship.side && p.kind !== 'unit' + && tdist(ship.x, ship.y, p.x, p.y) < range) return true; + } + const enemy = enemyOf(state, ship); + return tdist(ship.x, ship.y, enemy.x, enemy.y) < range; + }, + transformRange(ai, state, ship) { + const enemy = enemyOf(state, ship); + const dist = tdist(ship.x, ship.y, enemy.x, enemy.y); + const inYForm = ship.fx.formIndex === 1; + if (!inYForm && dist > 3000) return true; // Y-wing to close the gap + if (inYForm && dist < 1300) return true; // X-wing for the knife fight + return false; + }, + regen(ai, state, ship) { + return ship.crew <= ship.def.stats.maxCrew - (ship.def.special.params?.crew ?? 4) + && ship.energy >= ship.def.stats.maxEnergy; + }, + marines(ai, state, ship) { + const enemy = enemyOf(state, ship); + return ship.crew > 4 && tdist(ship.x, ship.y, enemy.x, enemy.y) < 1500; + }, + taunt(ai, state, ship) { + return ship.energy <= ship.def.stats.maxEnergy - (ship.def.special.params?.energy ?? 2); + }, + harvest(ai, state, ship) { + if (ship.energy > ship.def.stats.maxEnergy - 8) return false; + const range = (ship.def.special.params?.range ?? 78) * 4 * 1.2; + return state.asteroids.some((a) => a.alive && tdist(ship.x, ship.y, a.x, a.y) < range); + }, + song(ai, state, ship) { + const enemy = enemyOf(state, ship); + return enemy.crew > 2 + && tdist(ship.x, ship.y, enemy.x, enemy.y) < (ship.def.special.params?.range ?? 208) * 4 * 0.9; + }, + burner(ai, state, ship) { + // Drive-by: light the afterburner when charging roughly at the enemy so + // the napalm trail crosses their path. + const enemy = enemyOf(state, ship); + if (ship.energy < 6) return false; + const dist = tdist(ship.x, ship.y, enemy.x, enemy.y); + const bearing = Math.atan2( + tdelta(ship.y, enemy.y, ARENA_H), + tdelta(ship.x, enemy.x, ARENA_W), + ); + return dist > 400 && dist < 2400 && Math.abs(angleDelta(ship.facing, bearing)) < 0.5; + }, + retro(ai, state, ship) { + // Hop backward only when the enemy is on the Drone's tail — it can't + // out-turn anyone, but it can out-reverse everyone. + const enemy = enemyOf(state, ship); + if (ship.energy < 2) return false; + const dist = tdist(ship.x, ship.y, enemy.x, enemy.y); + const bearing = Math.atan2( + tdelta(ship.y, enemy.y, ARENA_H), + tdelta(ship.x, enemy.x, ARENA_W), + ); + return dist < 700 && Math.abs(angleDelta(ship.facing, bearing)) > 1.9; + }, + shieldReactive(ai, state, ship) { + if (ship.fx.shieldFrames > 0 || ship.fx.absorbFrames > 0) return false; + const enemy = enemyOf(state, ship); + if (tdist(ship.x, ship.y, enemy.x, enemy.y) < 600) return true; + const threat = incomingThreat(state, ship); + return !!threat && tdist(ship.x, ship.y, threat.x, threat.y) < 700; + }, + tongueRange(ai, state, ship) { + const enemy = enemyOf(state, ship); + const reach = (ship.def.special.params?.range ?? 35) * 4 + ship.def.radius + enemy.def.radius; + return tdist(ship.x, ship.y, enemy.x, enemy.y) < reach * 1.1; + }, +}; + +// Advances the pilot by dtMs and returns the input object to feed setInput. +export function updateAI(ai, state, dtMs) { + ai.clockMs += dtMs; + if (ai.clockMs < ai.nextDecisionMs) return ai.input; + ai.nextDecisionMs = ai.clockMs + ai.knobs.reactMs; + + const ship = state.ships[ai.side]; + const enemy = enemyOf(state, ship); + const input = ai.input; + input.left = input.right = input.thrust = input.fire = input.special = false; + if (!ship?.alive || state.over) return input; + + const rng = ai.rng; + const K = ai.knobs; + ai.aimBias = ((rng() - 0.5) * 2 * K.aimErrDeg * Math.PI) / 180; + + // Cloak memory: track the last place we actually saw the enemy. + if (!enemy.fx.cloaked) ai.lastKnown = { x: enemy.x, y: enemy.y, vx: enemy.vx, vy: enemy.vy }; + const target = enemy.fx.cloaked && ai.lastKnown ? ai.lastKnown : enemy; + + const dist = tdist(ship.x, ship.y, target.x, target.y); + const bearing = Math.atan2( + tdelta(ship.y, target.y, ARENA_H), + tdelta(ship.x, target.x, ARENA_W), + ); + const hints = ship.def.ai ?? {}; + const style = hints.engageStyle ?? 'chase'; + const preferred = hints.preferredRange ?? 1200; + + // ---- 1. survival + if (K.gravityIQ > 0.05 && planetDanger(state, ship, 40 + 60 * K.gravityIQ) + && (hints.gravityCaution ?? 1) > rng() * 0.4) { + // Thrust perpendicular to the planet bearing to swing wide. + const pb = Math.atan2( + tdelta(ship.y, state.planet.y, ARENA_H), + tdelta(ship.x, state.planet.x, ARENA_W), + ); + const escape = pb + (angleDelta(pb, Math.atan2(ship.vy, ship.vx)) > 0 ? Math.PI / 2 : -Math.PI / 2); + steerTo(ai, ship, escape, ship.def.turnPerFrame * 1.5); + input.thrust = true; + maybeSpecial(ai, state, ship, hints); + return blunder(ai, input); + } + // Harvest ships (Slylandro Probe): when the battery runs low, graze the + // asteroid field instead of pressing a fight it can't shoot. + if (hints.specialUse === 'harvest' && ship.energy < ship.def.stats.maxEnergy * 0.3) { + let rock = null; + let rockD = Infinity; + for (const a of state.asteroids) { + if (!a.alive) continue; + const d = tdist(ship.x, ship.y, a.x, a.y); + if (d < rockD) { rockD = d; rock = a; } + } + if (rock) { + steerTo(ai, ship, Math.atan2( + tdelta(ship.y, rock.y, ARENA_H), + tdelta(ship.x, rock.x, ARENA_W), + ), ship.def.turnPerFrame); + input.thrust = true; + maybeSpecial(ai, state, ship, hints); + return blunder(ai, input); + } + } + const threat = K.dodge > 0 ? incomingThreat(state, ship) : null; + if (threat && rng() < K.dodge) { + const tb = Math.atan2(threat.vy, threat.vx); + steerTo(ai, ship, tb + Math.PI / 2, ship.def.turnPerFrame * 1.5); + input.thrust = true; + maybeSpecial(ai, state, ship, hints); + return blunder(ai, input); + } + + // ---- 2 + 3. range control and aim/fire + const reach = primaryReach(ship.def); + const shotSpeed = ship.def.primary.speed ?? 999; + const lead = K.lead >= 1 ? target + : { x: target.x, y: target.y, vx: (target.vx ?? 0) * K.lead, vy: (target.vy ?? 0) * K.lead }; + const aimAngle = interceptAngle(ship.x, ship.y, lead, shotSpeed) + ai.aimBias; + + let wantThrust = false; + let heading = aimAngle; + if (style === 'kite' && dist < preferred * 0.62) { + heading = bearing + Math.PI; // run away + wantThrust = true; + } else if (style === 'ambush' && ship.fx.cloaked) { + heading = bearing; // sneak straight in, guns cold + wantThrust = true; + } else if (dist > preferred) { + heading = aimAngle; // close in along the firing solution + wantThrust = dist > preferred * 0.8; + } else if (style === 'chase') { + heading = aimAngle; + wantThrust = dist > preferred * 0.45; + } + + const offAim = steerTo(ai, ship, heading, ship.def.turnPerFrame * 0.6); + input.thrust = wantThrust; + + // Fire when the (quantized) nose is on the solution and the shot can reach. + const noseErr = Math.abs(angleDelta(facingToRad(quantizeFacing(ship.facing)), aimAngle)); + const reserve = hints.specialUse === 'pointDefenseAuto' ? ship.def.special.energyCost : 0; + const canAfford = ship.energy >= ship.def.primary.energyCost + reserve; + const inRange = dist < reach + enemy.def.radius; + const holdForCloak = ship.fx.cloaked && dist > 700; // don't blow cover early + if (canAfford && inRange && !holdForCloak && noseErr < 0.38 && offAim < 1.2 + && !(enemy.fx.cloaked && dist > 900)) { + input.fire = true; + } + // Charge weapons (Melnorme pump-up): keep pumping while far or unaimed, + // release once the shot is charged and the nose is on target. + if (ship.fx.pump) { + const maxLevel = (ship.fx.formPrimary ?? ship.def.primary).maxLevel ?? 4; + const charged = ship.fx.pump.level >= Math.min(2, maxLevel); + if (!charged || !inRange || noseErr > 0.3) input.fire = true; + if (charged && inRange && noseErr <= 0.3) input.fire = false; + } + + // ---- 4. special + maybeSpecial(ai, state, ship, hints); + return blunder(ai, input); +} + +function maybeSpecial(ai, state, ship, hints) { + const tactic = SPECIAL_TACTICS[hints.specialUse] ?? SPECIAL_TACTICS.onCooldown; + if (ship.energy < ship.def.special.energyCost) return; + if (tactic(ai, state, ship)) ai.input.special = true; +} + +function blunder(ai, input) { + if (ai.rng() < ai.knobs.blunder) { + const roll = ai.rng(); + if (roll < 0.34) { input.left = !input.left; input.right = false; } else if (roll < 0.67) { + input.thrust = !input.thrust; + } else { input.fire = false; } + } + return input; +} diff --git a/src/games/starcontrol/StarControlFleets.js b/src/games/starcontrol/StarControlFleets.js new file mode 100644 index 0000000..a3c5cff --- /dev/null +++ b/src/games/starcontrol/StarControlFleets.js @@ -0,0 +1,123 @@ +// Fleet definitions, point math and AI fleet decisions for Star Control +// Super Melee. No Phaser dependency. +// +// A fleet is an ordered array of ship keys (duplicates allowed), max +// FLEET_MAX ships, compared by summed Super Melee point values. Preset lists +// may reference ships that haven't been added to the JSON yet; +// materializeFleet() drops those and reports them, so the game stays playable +// while the roster grows and verify can insist on zero missing once complete. + +export const FLEET_MAX = 14; + +export const PRESET_FLEETS = [ + { + id: 'alliance', + name: 'The Alliance of Free Stars', + blurb: 'Chmmr-led coalition of the New Alliance races.', + ships: [ + 'chmmr', 'chenjesu', 'mmrnmhrm', 'earthling', 'yehat', 'shofixti', 'pkunk', + 'arilou', 'syreen', 'utwig', 'supox', 'orz', 'zoqfot', 'thraddash', + ], + }, + { + id: 'hierarchy', + name: 'The Ur-Quan Hierarchy', + blurb: 'Battle thralls of the Path of Now and Forever.', + ships: [ + 'urquan', 'urquan', 'kohrah', 'spathi', 'ilwrath', 'ilwrath', 'vux', + 'mycon', 'umgah', 'umgah', 'androsynth', 'druuge', 'melnorme', 'slylandro', + ], + }, + { + id: 'heavy', + name: 'Heavy Metal', + blurb: 'Maximum tonnage. Slow, huge, devastating.', + ships: ['chmmr', 'urquan', 'kohrah', 'chenjesu', 'utwig', 'mycon', 'orz', 'yehat'], + }, + { + id: 'wolfpack', + name: 'Wolf Pack', + blurb: 'Fourteen cheap knives. Death by a thousand cuts.', + ships: [ + 'shofixti', 'shofixti', 'umgah', 'umgah', 'zoqfot', 'zoqfot', 'ilwrath', + 'ilwrath', 'thraddash', 'thraddash', 'vux', 'syreen', 'supox', 'earthling', + ], + }, + { + id: 'speed', + name: 'Speed Demons', + blurb: 'Nothing in the fleet flies below a scream.', + ships: [ + 'pkunk', 'pkunk', 'arilou', 'arilou', 'spathi', 'spathi', 'slylandro', + 'slylandro', 'supox', 'supox', 'shofixti', 'shofixti', 'zoqfot', 'syreen', + ], + }, +]; + +export function fleetPoints(SHIPS, keys) { + return keys.reduce((sum, k) => sum + (SHIPS[k]?.pointValue ?? 0), 0); +} + +// Filters a wishlist against the compiled roster. +export function materializeFleet(SHIPS, keys) { + const entries = []; + const missing = []; + for (const k of keys) (SHIPS[k] ? entries : missing).push(k); + return { entries: entries.slice(0, FLEET_MAX), missing, points: fleetPoints(SHIPS, entries) }; +} + +export function validateFleet(SHIPS, keys) { + if (!Array.isArray(keys) || keys.length === 0) return { ok: false, reason: 'empty fleet' }; + if (keys.length > FLEET_MAX) return { ok: false, reason: `more than ${FLEET_MAX} ships` }; + for (const k of keys) if (!SHIPS[k]) return { ok: false, reason: `unknown ship "${k}"` }; + return { ok: true }; +} + +// Seeded random fleet within a point budget ("Grab Bag"). +export function randomFleet(SHIPS, rng, pointCap = 200) { + const pool = Object.keys(SHIPS); + const keys = []; + let points = 0; + for (let guard = 0; guard < 200 && keys.length < FLEET_MAX; guard += 1) { + const k = pool[Math.floor(rng() * pool.length)]; + if (points + SHIPS[k].pointValue > pointCap) continue; + keys.push(k); + points += SHIPS[k].pointValue; + if (points >= pointCap - 4) break; + } + if (keys.length === 0) keys.push(pool[0]); + return keys; +} + +// Style-vs-style edge table for the AI's counter-pick. Rows: my style, +// columns: enemy style — positive means favored. +const STYLE_EDGE = { + chase: { chase: 0, kite: -1, snipe: 1, ambush: 1, ram: 0 }, + kite: { chase: 1, kite: 0, snipe: -1, ambush: 1, ram: 1 }, + snipe: { chase: -1, kite: 1, snipe: 0, ambush: -1, ram: 1 }, + ambush: { chase: -1, kite: -1, snipe: 1, ambush: 0, ram: 0 }, + ram: { chase: 0, kite: -1, snipe: -1, ambush: 0, ram: 0 }, +}; + +// Picks the AI's next ship from its remaining fleet, given the enemy's +// current ship (or null on the opening pick). Simple heuristic: prefer a +// style edge, spend big hulls early, save a finisher. +export function aiCounterPick(SHIPS, remainingKeys, enemyKey, rng) { + if (remainingKeys.length === 0) return null; + const enemy = enemyKey ? SHIPS[enemyKey] : null; + let best = remainingKeys[0]; + let bestScore = -Infinity; + for (const k of remainingKeys) { + const def = SHIPS[k]; + let score = def.pointValue * 0.1 + rng() * 2; + if (enemy) { + const mine = def.ai?.engageStyle ?? 'chase'; + const theirs = enemy.ai?.engageStyle ?? 'chase'; + score += (STYLE_EDGE[mine]?.[theirs] ?? 0) * 3; + // Big crew answers big crew; don't waste a scout on a dreadnought. + if (enemy.stats.maxCrew >= 30 && def.stats.maxCrew <= 8) score -= 4; + } + if (score > bestScore) { bestScore = score; best = k; } + } + return best; +} diff --git a/src/games/starcontrol/StarControlGame.js b/src/games/starcontrol/StarControlGame.js new file mode 100644 index 0000000..412f625 --- /dev/null +++ b/src/games/starcontrol/StarControlGame.js @@ -0,0 +1,964 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { api } from '../../services/api.js'; +import { applyArcadeCRTOverlay } from '../../ui/ArcadeCRTOverlay.js'; +import { + ARENA_W, ARENA_H, ZOOM, TUNE, STEP_MS, + mulberry32, wrap, tdelta, tdist, tmid, targetZoom, smoothTowards, + compileShips, createBattle, setInput, stepMatch, quantizeFacing, facingToRad, +} from './StarControlLogic.js'; +import { createAI, updateAI } from './StarControlAI.js'; +import { + FLEET_MAX, PRESET_FLEETS, materializeFleet, fleetPoints, randomFleet, aiCounterPick, +} from './StarControlFleets.js'; + +const D = { world: 0, fx: 5, hud: 30, overlay: 60 }; +const BEST_KEY = 'starcontrol-best'; +const FONT = 'm6x11, "Julius Sans One"'; + +// Side palettes: P1 cool green (Alliance flavor), P2 hot red (Hierarchy). +const SIDE_COLORS = [ + { hull: 0x74f5a1, glow: 0x2e8f57, hex: '#74f5a1' }, + { hull: 0xf4726c, glow: 0x93312d, hex: '#f4726c' }, +]; + +// sfxClass strings in the ships JSON -> loaded SFX keys. +const SFX_CLASS = { + pellet: SFX.SCIFI_PLINK, + flame: SFX.WOOSH, + missile: SFX.SCIFI_LAUNCH, + heavy: SFX.SCIFI_PLONK, + zap: SFX.LASER_ZAP, + launch: SFX.SCIFI_REVEAL, + warp: SFX.SCIFI_RISER, + explode: SFX.SCIFI_EXPLODE, +}; + +const MODES = [ + { id: 'ai', label: '1P VS COMPUTER' }, + { id: 'hotseat', label: '2P HOTSEAT' }, + { id: 'watch', label: 'WATCH AI VS AI' }, +]; + +export default class StarControlGame extends Phaser.Scene { + constructor() { super('StarControlGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'starcontrol', name: 'Star Control' }; + this.view = null; + this.match = null; + this.paused = false; + } + + create() { + const json = this.cache.json.get('star-control-ships'); + this.SHIPS = compileShips(json ?? { ships: {} }); + this.shipKeys = Object.keys(this.SHIPS); + this.credits = json?.credits ?? ''; + + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x03060e).setDepth(-10); + + this.gWorld = this.add.graphics().setDepth(D.world); + this.gFx = this.add.graphics().setDepth(D.fx); + this.hudRoot = this.add.container(0, 0).setDepth(D.hud); + this.viewRoot = null; + + // Fixed seeded starfield in world space, two parallax layers. + const rng = mulberry32(20260714); + this.stars = [[], []]; + for (let i = 0; i < 240; i += 1) { + this.stars[i % 2].push({ + x: rng() * ARENA_W, y: rng() * ARENA_H, + size: 1 + rng() * 2.2, tone: 0.35 + rng() * 0.65, + }); + } + + this.cam = { x: ARENA_W / 2, y: ARENA_H / 2, zoom: ZOOM.FAR }; + this.fxList = []; + this.lastSfxAt = {}; + this.deathHoldMs = 0; + this.deathFocus = null; + + this.keys = this.input.keyboard.addKeys( + 'W,A,S,D,Z,X,UP,DOWN,LEFT,RIGHT,COMMA,PERIOD', + ); + this.escHandler = () => this.onEscape(); + this.input.keyboard.on('keydown-ESC', this.escHandler); + + this.crt = applyArcadeCRTOverlay(this, { + accentTint: 0x74f5a1, scanlineTint: 0x2e8f57, + }); + try { + const music = this.cache.json.get('music'); + if (music?.tracks) this.music = new MusicPlayer(this, music.tracks); + } catch (_) { /* optional */ } + + this.events.once('shutdown', () => { + this.crt.destroy(); + this.input.keyboard.off('keydown-ESC', this.escHandler); + }); + + // Match configuration defaults (setup view edits these). + this.mode = 'ai'; + this.skills = [5, 5]; + this.fleetChoice = [0, 1]; // index into fleet options per side + this.customFleets = [null, null]; // custom builder results (task: setup UI) + + this.showSetup(); + } + + // ========================================================================= + // View plumbing + + clearView() { + this.viewRoot?.destroy(true); + this.viewRoot = this.add.container(0, 0).setDepth(D.overlay - 10); + this.builderRoot?.destroy(true); + this.builderRoot = null; + this.hudRoot.removeAll(true); + this.gWorld.clear(); + this.gFx.clear(); + this.shipSprites?.forEach((s) => s?.destroy()); + this.shipSprites = null; + } + + mkText(x, y, str, size, color = '#e8f4e8', origin = 0.5) { + return this.add.text(x, y, str, { + fontFamily: FONT, fontSize: `${size}px`, color, + }).setOrigin(origin); + } + + fleetOptions(side) { + const opts = PRESET_FLEETS.map((p) => ({ + id: p.id, name: p.name, blurb: p.blurb, + resolve: () => materializeFleet(this.SHIPS, p.ships).entries, + })); + opts.push({ + id: 'grab', name: 'Grab Bag', blurb: 'A random armada within a 200-point budget.', + resolve: () => randomFleet(this.SHIPS, mulberry32((Date.now() % 100000) + 7), 200), + }); + opts.push({ + id: 'custom', name: 'Custom Fleet', blurb: 'Hand-pick up to 14 ships (duplicates allowed).', + resolve: () => (this.customFleets[side]?.length + ? this.customFleets[side].slice(0, FLEET_MAX) + : materializeFleet(this.SHIPS, PRESET_FLEETS[side === 0 ? 0 : 1].ships).entries), + }); + return opts; + } + + // ========================================================================= + // SETUP view + + showSetup() { + this.view = 'setup'; + this.match = null; + this.clearView(); + const root = this.viewRoot; + const cx = GAME_WIDTH / 2; + + root.add(this.mkText(cx, 90, 'STAR CONTROL', 84, '#74f5a1')); + root.add(this.mkText(cx, 152, 'SUPER MELEE', 40, '#9adbb2')); + + // Mode row. + this.modeButtons = []; + MODES.forEach((m, i) => { + const b = this.textButton(cx + (i - 1) * 380, 240, m.label, () => { + this.mode = m.id; + this.refreshSetup(); + }); + this.modeButtons.push({ b, id: m.id }); + root.add(b); + }); + + // Fleet panels per side. + this.fleetPanels = []; + [0, 1].forEach((side) => { + const px = side === 0 ? 480 : GAME_WIDTH - 480; + const panel = this.add.container(px, 560); + const label = this.mkText(0, -250, side === 0 ? 'PLAYER 1' : 'PLAYER 2', 34, SIDE_COLORS[side].hex); + const nameT = this.mkText(0, -190, '', 38); + const blurbT = this.mkText(0, -150, '', 20, '#89a6a0'); + const listT = this.mkText(0, 40, '', 22, '#c7e6cf'); + listT.setLineSpacing(6).setAlign('center'); + const ptsT = this.mkText(0, 236, '', 26, '#e7d27b'); + const prev = this.textButton(-260, -190, '<', () => this.cycleFleet(side, -1)); + const next = this.textButton(260, -190, '>', () => this.cycleFleet(side, 1)); + const skillLabel = this.mkText(0, 290, '', 24, '#9adbb2'); + const minus = this.textButton(-90, 290, '-', () => this.bumpSkill(side, -1)); + const plus = this.textButton(90, 290, '+', () => this.bumpSkill(side, 1)); + const edit = this.textButton(0, 236, 'EDIT FLEET', () => this.showFleetBuilder(side), 22); + panel.add([label, nameT, blurbT, listT, ptsT, prev, next, skillLabel, minus, plus, edit]); + root.add(panel); + this.fleetPanels.push({ panel, nameT, blurbT, listT, ptsT, skillLabel, minus, plus, edit }); + }); + + const engage = this.textButton(cx, 900, 'ENGAGE', () => this.beginMatch(), 46); + root.add(engage); + + const menu = this.textButton(120, 60, '< MENU', () => this.scene.start('GameMenu'), 22); + root.add(menu); + + const best = Number(localStorage.getItem(BEST_KEY) ?? 0); + if (best > 0) root.add(this.mkText(cx, 960, `BEST FLEET SCORE ${best}`, 20, '#e7d27b')); + + root.add(this.mkText(cx, GAME_HEIGHT - 26, + 'Ship data & art: The Ur-Quan Masters (Star Control II) © Toys for Bob / UQM project — CC BY-NC-SA 2.5', + 16, '#5d7a72')); + + this.refreshSetup(); + } + + textButton(x, y, label, cb, size = 26) { + const t = this.add.text(x, y, label, { + fontFamily: FONT, fontSize: `${size}px`, color: '#d9f7e2', + backgroundColor: '#12241c', padding: { x: 18, y: 10 }, + }).setOrigin(0.5); + t.setInteractive({ useHandCursor: true }); + t.on('pointerover', () => t.setColor('#74f5a1')); + t.on('pointerout', () => t.setStyle({ color: t.getData('sel') ? '#0a130d' : '#d9f7e2' })); + t.on('pointerdown', () => { playSound(this, SFX.EIGHTBIT_ACTION); cb(); }); + return t; + } + + cycleFleet(side, dir) { + const n = this.fleetOptions(side).length; + this.fleetChoice[side] = ((this.fleetChoice[side] + dir) % n + n) % n; + this.refreshSetup(); + } + + bumpSkill(side, dir) { + this.skills[side] = Math.max(1, Math.min(10, this.skills[side] + dir)); + this.refreshSetup(); + } + + refreshSetup() { + if (this.view !== 'setup') return; + for (const { b, id } of this.modeButtons) { + const sel = id === this.mode; + b.setData('sel', sel); + b.setStyle({ color: sel ? '#0a130d' : '#d9f7e2', backgroundColor: sel ? '#74f5a1' : '#12241c' }); + } + [0, 1].forEach((side) => { + const opts = this.fleetOptions(side); + const opt = opts[this.fleetChoice[side]]; + const keys = opt.resolve(); + const p = this.fleetPanels[side]; + p.nameT.setText(opt.name); + p.blurbT.setText(opt.blurb); + const counts = {}; + for (const k of keys) counts[k] = (counts[k] ?? 0) + 1; + p.listT.setText(Object.entries(counts) + .map(([k, n]) => `${this.SHIPS[k].name}${n > 1 ? ` x${n}` : ''}`) + .join('\n')); + p.ptsT.setText(`${keys.length} SHIPS — ${fleetPoints(this.SHIPS, keys)} PTS`); + p.ptsT.setY(opt.id === 'custom' ? 200 : 236); + p.edit.setVisible(opt.id === 'custom'); + const isAI = this.sideIsAI(side); + p.skillLabel.setText(isAI ? `AI SKILL ${this.skills[side]}` : (side === 0 ? 'W A D + Z X' : 'ARROWS + , .')); + p.minus.setVisible(isAI); + p.plus.setVisible(isAI); + }); + } + + sideIsAI(side) { + if (this.mode === 'watch') return true; + if (this.mode === 'hotseat') return false; + return side === 1; + } + + // ---- Custom fleet builder overlay ------------------------------------- + + showFleetBuilder(side) { + this.builderRoot?.destroy(true); + this.builderList = (this.customFleets[side] ?? []).slice(); + const root = this.add.container(0, 0).setDepth(D.overlay); + this.builderRoot = root; + const cx = GAME_WIDTH / 2; + root.add(this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x02070c, 0.94) + .setInteractive()); // swallow clicks behind the overlay + root.add(this.mkText(cx, 64, `PLAYER ${side + 1} — CUSTOM FLEET`, 44, SIDE_COLORS[side].hex)); + root.add(this.mkText(cx, 112, `CLICK SHIPS TO ADD (MAX ${FLEET_MAX}, DUPLICATES ALLOWED) — CLICK A SLOT TO REMOVE`, 20, '#89a6a0')); + + // Roster grid. + const keys = this.shipKeys.slice().sort((a, b) => this.SHIPS[a].name.localeCompare(this.SHIPS[b].name)); + const cols = 5; + keys.forEach((key, i) => { + const def = this.SHIPS[key]; + const x = cx + ((i % cols) - (cols - 1) / 2) * 350; + const y = 190 + Math.floor(i / cols) * 78; + const b = this.textButton(x, y, `${def.name} · ${def.pointValue}`, () => { + if (this.builderList.length >= FLEET_MAX) return; + this.builderList.push(key); + this.refreshBuilder(side); + }, 20); + root.add(b); + }); + + this.builderSlotsRoot = this.add.container(0, 0); + root.add(this.builderSlotsRoot); + this.builderTotalT = this.mkText(cx, 900, '', 26, '#e7d27b'); + root.add(this.builderTotalT); + + root.add(this.textButton(cx - 220, 990, 'CLEAR', () => { + this.builderList = []; + this.refreshBuilder(side); + }, 26)); + root.add(this.textButton(cx, 990, 'DONE', () => { + if (this.builderList.length === 0) return; + this.customFleets[side] = this.builderList.slice(); + root.destroy(true); + this.builderRoot = null; + this.refreshSetup(); + }, 26)); + root.add(this.textButton(cx + 220, 990, 'CANCEL', () => { + root.destroy(true); + this.builderRoot = null; + }, 26)); + this.refreshBuilder(side); + } + + refreshBuilder(side) { + const cx = GAME_WIDTH / 2; + this.builderSlotsRoot.removeAll(true); + this.builderList.forEach((key, i) => { + const x = cx + ((i % 7) - 3) * 250; + const y = 800 + Math.floor(i / 7) * 52; + const chip = this.textButton(x, y, `${this.SHIPS[key].name.split(' ').pop()} ${this.SHIPS[key].pointValue}`, () => { + this.builderList.splice(i, 1); + this.refreshBuilder(side); + }, 18); + chip.setStyle({ backgroundColor: '#1c3327', color: SIDE_COLORS[side].hex }); + this.builderSlotsRoot.add(chip); + }); + this.builderTotalT.setText( + `${this.builderList.length}/${FLEET_MAX} SHIPS — ${fleetPoints(this.SHIPS, this.builderList)} PTS TOTAL`, + ); + } + + // ========================================================================= + // Match / fleet flow + + beginMatch() { + this.fleets = [0, 1].map((side) => { + const keys = this.fleetOptions(side)[this.fleetChoice[side]].resolve(); + return keys.map((key) => ({ key, crew: null, energy: null, dead: false })); + }); + if (this.fleets.some((f) => f.length === 0)) return; + this.ais = [0, 1].map((side) => (this.sideIsAI(side) + ? createAI({ skill: this.skills[side], side, seed: (Date.now() % 65521) + side }) + : null)); + this.current = [null, null]; + this.needPick = [true, true]; + this.destroyedPoints = [0, 0]; + this.carry = null; + this.matchSeed = (Date.now() % 2147483647) | 1; + this.showShipPick(); + } + + aliveFleet(side) { + return this.fleets[side].map((e, i) => ({ ...e, i })).filter((e) => !e.dead); + } + + showShipPick() { + this.view = 'shipPick'; + this.clearView(); + + // AI sides pick instantly. + [0, 1].forEach((side) => { + if (!this.needPick[side] || !this.sideIsAI(side)) return; + const remaining = this.aliveFleet(side); + const enemyIdx = this.current[1 - side]; + const enemyKey = this.needPick[1 - side] ? null : this.fleets[1 - side][enemyIdx]?.key; + const pick = aiCounterPick(this.SHIPS, remaining.map((e) => e.key), enemyKey, Math.random); + const entry = remaining.find((e) => e.key === pick); + this.current[side] = entry.i; + this.needPick[side] = false; + }); + + const humanSide = [0, 1].find((s) => this.needPick[s]); + if (humanSide === undefined) { this.startBattle(); return; } + + const root = this.viewRoot; + const cx = GAME_WIDTH / 2; + root.add(this.mkText(cx, 90, + `${this.mode === 'hotseat' ? `PLAYER ${humanSide + 1}` : 'COMMANDER'} — CHOOSE YOUR SHIP`, + 44, SIDE_COLORS[humanSide].hex)); + + const enemyIdx = this.current[1 - humanSide]; + if (enemyIdx !== null && !this.needPick[1 - humanSide]) { + const ek = this.fleets[1 - humanSide][enemyIdx].key; + root.add(this.mkText(cx, 148, `ENEMY FIELDS: ${this.SHIPS[ek].name.toUpperCase()}`, 26, SIDE_COLORS[1 - humanSide].hex)); + } + + const remaining = this.aliveFleet(humanSide); + const perRow = Math.min(remaining.length, 7); + const cardW = 240; + const cardH = 290; + remaining.forEach((entry, n) => { + const row = Math.floor(n / perRow); + const inRow = Math.min(remaining.length - row * perRow, perRow); + const x = cx + (n % perRow - (inRow - 1) / 2) * (cardW + 20); + const y = 420 + row * (cardH + 24); + root.add(this.shipCard(x, y, cardW, cardH, entry, humanSide, () => { + this.current[humanSide] = entry.i; + this.needPick[humanSide] = false; + playSound(this, SFX.EIGHTBIT_CARD); + this.showShipPick(); // next picker (hotseat) or battle + })); + }); + + root.add(this.mkText(cx, GAME_HEIGHT - 40, + `FLEET VALUE REMAINING — YOU ${fleetPoints(this.SHIPS, remaining.map((e) => e.key))} PTS / ` + + `ENEMY ${fleetPoints(this.SHIPS, this.aliveFleet(1 - humanSide).map((e) => e.key))} PTS`, + 22, '#89a6a0')); + } + + shipCard(x, y, w, h, entry, side, onPick) { + const def = this.SHIPS[entry.key]; + const c = this.add.container(x, y); + const g = this.add.graphics(); + g.fillStyle(0x0c1a14, 0.94).fillRoundedRect(-w / 2, -h / 2, w, h, 10); + g.lineStyle(2, SIDE_COLORS[side].hull, 0.7).strokeRoundedRect(-w / 2, -h / 2, w, h, 10); + c.add(g); + const icon = this.add.graphics(); + this.drawChevron(icon, 0, -62, 34, -Math.PI / 2, SIDE_COLORS[side].hull, false); + c.add(icon); + c.add(this.mkText(0, 8, def.name.replace(' ', '\n'), 24).setAlign('center')); + const crew = entry.crew ?? def.stats.maxCrew; + c.add(this.mkText(0, 74, `CREW ${crew}/${def.stats.maxCrew}`, 18, crew < def.stats.maxCrew ? '#f4a26c' : '#9adbb2')); + c.add(this.mkText(0, 102, `${def.pointValue} PTS`, 18, '#e7d27b')); + c.setSize(w, h); + c.setInteractive({ useHandCursor: true }); + c.on('pointerover', () => g.lineStyle(3, 0xffffff, 1).strokeRoundedRect(-w / 2, -h / 2, w, h, 10)); + c.on('pointerdown', onPick); + return c; + } + + startBattle() { + this.view = 'battle'; + this.clearView(); + this.paused = false; + this.deathHoldMs = 0; + this.deathFocus = null; + this.fxList = []; + + const mk = (side) => { + const entry = this.fleets[side][this.current[side]]; + return { + def: this.SHIPS[entry.key], + crew: entry.crew ?? undefined, + energy: entry.energy ?? undefined, + }; + }; + this.matchSeed = (this.matchSeed * 16807) % 2147483647; + this.match = createBattle({ + a: mk(0), b: mk(1), seed: this.matchSeed, + keep: this.carry, + }); + // Real UQM rotation sheets when present; vector chevrons otherwise. + this.shipSprites = this.match.ships.map((ship) => { + const sheets = [ship.def.sprite, ...(ship.def.forms ?? []).map((f) => f.sprite)] + .filter((sp) => sp?.sheet && this.textures.exists(sp.sheet)); + for (const sp of sheets) { + this.textures.get(sp.sheet).setFilter(Phaser.Textures.FilterMode.NEAREST); + } + if (!sheets.length) return null; + return this.add.sprite(0, 0, sheets[0].sheet, 0).setDepth(D.world + 1).setVisible(false); + }); + + // Camera opens wide on the survivor/new pair and glides in. + const mid = tmid(this.match.ships[0].x, this.match.ships[0].y, this.match.ships[1].x, this.match.ships[1].y); + this.cam = { x: mid.x, y: mid.y, zoom: ZOOM.FAR }; + this.buildHud(); + playSound(this, SFX.SCIFI_RISER); + } + + // ========================================================================= + // Battle loop + + update(time, delta) { + if (this.view !== 'battle' || !this.match) return; + if (this.paused) return; + + if (!this.match.over) { + for (const side of [0, 1]) { + if (this.ais[side]) { + setInput(this.match, side, updateAI(this.ais[side], this.match, delta)); + } else { + setInput(this.match, side, this.readKeys(side)); + } + } + } + + const events = stepMatch(this.match, delta); + for (const e of events) this.handleEvent(e); + + if (this.match.over && this.deathHoldMs > 0) { + this.deathHoldMs -= delta; + if (this.deathHoldMs <= 0) this.resolveBattleEnd(); + } + + this.updateCamera(delta); + this.renderWorld(delta); + this.updateHud(); + } + + readKeys(side) { + const k = this.keys; + // In single-player, both key clusters drive the human ship. + const p1 = { left: k.A.isDown, right: k.D.isDown, thrust: k.W.isDown, fire: k.Z.isDown, special: k.X.isDown }; + const p2 = { + left: k.LEFT.isDown, right: k.RIGHT.isDown, thrust: k.UP.isDown, + fire: k.COMMA.isDown, special: k.PERIOD.isDown, + }; + if (this.mode === 'hotseat') return side === 0 ? p1 : p2; + return { + left: p1.left || p2.left, right: p1.right || p2.right, + thrust: p1.thrust || p2.thrust, fire: p1.fire || p2.fire, special: p1.special || p2.special, + }; + } + + handleEvent(e) { + switch (e.t) { + case 'fire': + this.throttledSfx(SFX_CLASS[e.sfx] ?? SFX.SCIFI_PLINK, 90); + break; + case 'special': + this.throttledSfx(SFX_CLASS[e.sfx] ?? SFX.SCIFI_REVEAL, 120); + break; + case 'beam': + case 'pdZap': + this.fxList.push({ type: 'beam', x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2, side: e.side, ttl: 110 }); + this.throttledSfx(SFX.LASER_ZAP, 140); + break; + case 'shipHit': + this.fxList.push({ type: 'burst', x: e.x, y: e.y, r: 40 + e.dmg * 14, ttl: 300, age: 0 }); + this.throttledSfx(SFX.EIGHTBIT_EXPLODE, 120); + this.crt.pulse?.(0.4, 120); + break; + case 'shotHit': + this.fxList.push({ type: 'burst', x: e.x, y: e.y, r: 26, ttl: 200, age: 0 }); + break; + case 'shipsCollide': + this.fxList.push({ type: 'burst', x: e.x, y: e.y, r: 60, ttl: 260, age: 0 }); + this.throttledSfx(SFX.EIGHTBIT_EXPLODE_2, 200); + break; + case 'planetBounce': + this.throttledSfx(SFX.EIGHTBIT_ACTION, 200); + break; + case 'asteroidBreak': + this.fxList.push({ type: 'burst', x: e.x, y: e.y, r: 34, ttl: 220, age: 0 }); + this.throttledSfx(SFX.SCIFI_PLINK, 150); + break; + case 'glory': + this.fxList.push({ type: 'ring', x: e.x, y: e.y, r: e.range, ttl: 600, age: 0 }); + playSound(this, SFX.SCIFI_EXPLODE); + this.crt.pulse?.(1, 400); + break; + case 'cloak': + this.throttledSfx(SFX.SCIFI_RISER, 300); + break; + case 'fighterDock': + this.throttledSfx(SFX.EIGHTBIT_CARD, 200); + break; + case 'resurrect': + playSound(this, SFX.VICTORY_SHORT); + break; + case 'death': + this.fxList.push({ type: 'boom', x: e.x, y: e.y, r: 260, ttl: 1100, age: 0 }); + playSound(this, SFX.SCIFI_EXPLODE); + this.crt.pulse?.(1, 500); + this.deathFocus = { x: e.x, y: e.y }; + break; + case 'battleOver': + this.deathHoldMs = 1300; + break; + default: + break; + } + } + + throttledSfx(key, gapMs) { + if (!key) return; + const now = this.time.now; + if ((this.lastSfxAt[key] ?? -1e9) + gapMs > now) return; + this.lastSfxAt[key] = now; + playSound(this, key); + } + + resolveBattleEnd() { + const w = this.match.winner; + [0, 1].forEach((side) => { + const entry = this.fleets[side][this.current[side]]; + const ship = this.match.ships[side]; + if (w === side) { + // Winner keeps its ship, with damage/charge carried over. + entry.crew = ship.crew; + entry.energy = ship.energy; + this.destroyedPoints[side] += this.SHIPS[this.fleets[1 - side][this.current[1 - side]].key].pointValue; + this.carry = { + side, x: ship.x, y: ship.y, vx: ship.vx, vy: ship.vy, facing: ship.facing, + }; + this.needPick[side] = false; + } else { + entry.dead = true; + this.needPick[side] = true; + } + }); + if (w === 'draw') { + this.carry = null; + this.destroyedPoints[0] += this.SHIPS[this.fleets[1][this.current[1]].key].pointValue; + this.destroyedPoints[1] += this.SHIPS[this.fleets[0][this.current[0]].key].pointValue; + } + const out = [0, 1].filter((s) => this.aliveFleet(s).length === 0); + if (out.length > 0) this.showRecap(out); + else this.showShipPick(); + } + + // ========================================================================= + // Camera + rendering + + renderPos(e) { + const a = this.match.alpha ?? 1; + return { + x: wrap(e.px + tdelta(e.px, e.x, ARENA_W) * a, ARENA_W), + y: wrap(e.py + tdelta(e.py, e.y, ARENA_H) * a, ARENA_H), + }; + } + + w2s(x, y) { + return { + x: GAME_WIDTH / 2 + tdelta(this.cam.x, x, ARENA_W) * this.cam.zoom, + y: GAME_HEIGHT / 2 + tdelta(this.cam.y, y, ARENA_H) * this.cam.zoom, + }; + } + + onScreen(s, margin) { + return s.x > -margin && s.x < GAME_WIDTH + margin && s.y > -margin && s.y < GAME_HEIGHT + margin; + } + + updateCamera(delta) { + const [A, B] = this.match.ships; + let mid; + let tz; + if (this.match.over && this.deathFocus) { + mid = this.deathFocus; + tz = ZOOM.MED; + } else { + const ra = this.renderPos(A); + const rb = this.renderPos(B); + mid = tmid(ra.x, ra.y, rb.x, rb.y); + tz = targetZoom(tdist(ra.x, ra.y, rb.x, rb.y)); + } + const k = 1 - Math.exp(-delta / ZOOM.SMOOTH_MS); + this.cam.zoom = smoothTowards(this.cam.zoom, tz, delta); + this.cam.x = wrap(this.cam.x + tdelta(this.cam.x, mid.x, ARENA_W) * k, ARENA_W); + this.cam.y = wrap(this.cam.y + tdelta(this.cam.y, mid.y, ARENA_H) * k, ARENA_H); + } + + drawChevron(g, x, y, size, facing, color, thrusting) { + const c = Math.cos(facing); + const s = Math.sin(facing); + const pt = (fx, fy) => ({ x: x + (fx * c - fy * s), y: y + (fx * s + fy * c) }); + const nose = pt(size, 0); + const lw = pt(-size * 0.7, size * 0.66); + const rw = pt(-size * 0.7, -size * 0.66); + const tail = pt(-size * 0.32, 0); + g.fillStyle(color, 0.22); + g.fillTriangle(nose.x, nose.y, lw.x, lw.y, tail.x, tail.y); + g.fillTriangle(nose.x, nose.y, rw.x, rw.y, tail.x, tail.y); + g.lineStyle(Math.max(1.5, size * 0.09), color, 1); + g.beginPath(); + g.moveTo(lw.x, lw.y); g.lineTo(nose.x, nose.y); g.lineTo(rw.x, rw.y); g.lineTo(tail.x, tail.y); + g.closePath(); + g.strokePath(); + if (thrusting) { + const f1 = pt(-size * (0.9 + Math.random() * 0.5), 0); + const fl = pt(-size * 0.55, size * 0.22); + const fr = pt(-size * 0.55, -size * 0.22); + g.fillStyle(0xffc06a, 0.85); + g.fillTriangle(f1.x, f1.y, fl.x, fl.y, fr.x, fr.y); + } + } + + renderWorld(delta) { + const g = this.gWorld; + const z = this.cam.zoom; + g.clear(); + + // Starfield (two parallax layers). + for (let layer = 0; layer < 2; layer += 1) { + const par = layer === 0 ? 0.45 : 1; + const camX = wrap(this.cam.x * par, ARENA_W); + const camY = wrap(this.cam.y * par, ARENA_H); + for (const st of this.stars[layer]) { + const sx = GAME_WIDTH / 2 + tdelta(camX, st.x, ARENA_W) * z; + const sy = GAME_HEIGHT / 2 + tdelta(camY, st.y, ARENA_H) * z; + if (sx < -4 || sx > GAME_WIDTH + 4 || sy < -4 || sy > GAME_HEIGHT + 4) continue; + g.fillStyle(0xbfd9ff, st.tone * (layer === 0 ? 0.4 : 0.8)); + g.fillRect(sx, sy, Math.max(1, st.size * (layer === 0 ? 0.7 : 1)), Math.max(1, st.size)); + } + } + + // Planet. + const p = this.match.planet; + const ps = this.w2s(p.x, p.y); + if (this.onScreen(ps, p.radius * z + 40)) { + g.fillStyle(0x6a5540, 1).fillCircle(ps.x, ps.y, p.radius * z); + g.fillStyle(0x86704f, 1).fillCircle(ps.x - p.radius * z * 0.25, ps.y - p.radius * z * 0.2, p.radius * z * 0.62); + g.fillStyle(0x4c3c2e, 0.9); + g.fillCircle(ps.x + p.radius * z * 0.35, ps.y + p.radius * z * 0.3, p.radius * z * 0.2); + g.fillCircle(ps.x - p.radius * z * 0.1, ps.y + p.radius * z * 0.45, p.radius * z * 0.12); + g.lineStyle(2, 0xc79a62, 0.5).strokeCircle(ps.x, ps.y, p.radius * z); + } + + // Asteroids. + for (const ast of this.match.asteroids) { + if (!ast.alive) continue; + const rp = this.renderPos(ast); + const s = this.w2s(rp.x, rp.y); + if (!this.onScreen(s, 60)) continue; + const r = ast.radius * z; + g.lineStyle(2, 0x9a9a8c, 1); + g.fillStyle(0x55554c, 1); + g.beginPath(); + for (let i = 0; i < 7; i += 1) { + const a = ast.rot + (i / 7) * Math.PI * 2; + const rr = r * (0.75 + 0.25 * Math.sin(i * 2.7 + ast.id)); + const px2 = s.x + Math.cos(a) * rr; + const py2 = s.y + Math.sin(a) * rr; + if (i === 0) g.moveTo(px2, py2); else g.lineTo(px2, py2); + } + g.closePath(); + g.fillPath(); + g.strokePath(); + } + + // Projectiles + units. + for (const pr of this.match.projectiles) { + if (!pr.alive) continue; + const rp = this.renderPos(pr); + const s = this.w2s(rp.x, rp.y); + if (!this.onScreen(s, 80)) continue; + const col = SIDE_COLORS[pr.owner].hull; + if (pr.type === 'flame') { + const lifeFrac = Math.min(1, pr.life / 8); + g.fillStyle(0xff9040, 0.25 + 0.55 * lifeFrac); + g.fillCircle(s.x, s.y, (10 + (1 - lifeFrac) * 26) * z * 4 * 0.25); + } else if (pr.kind === 'unit') { + this.drawChevron(g, s.x, s.y, Math.max(6, pr.radius * z), pr.facing, col, false); + } else if (pr.type === 'missile' || pr.type === 'butt') { + const c = Math.cos(pr.facing); + const sn = Math.sin(pr.facing); + const len = Math.max(6, pr.radius * z * 1.8); + g.lineStyle(Math.max(2, 3 * z * 2), col, 1); + g.lineBetween(s.x - c * len, s.y - sn * len, s.x + c * len, s.y + sn * len); + g.fillStyle(0xffe9a0, 1).fillCircle(s.x - c * len, s.y - sn * len, Math.max(1.5, 2.4 * z * 2)); + } else { + g.fillStyle(col, 1).fillCircle(s.x, s.y, Math.max(2, pr.radius * z * 0.8)); + g.lineStyle(1, 0xffffff, 0.5).strokeCircle(s.x, s.y, Math.max(2, pr.radius * z * 0.8)); + } + } + + // Ships. UQM sheets: frame 0 faces up, rotating counterclockwise, so the + // frame for quantized facing q (0 = east, clockwise) is (12 - q) & 15. + for (const ship of this.match.ships) { + const spr = this.shipSprites?.[ship.side]; + if (!ship.alive || ship.fx.cloaked) { spr?.setVisible(false); continue; } + const rp = this.renderPos(ship); + const s = this.w2s(rp.x, rp.y); + if (!this.onScreen(s, 260)) { spr?.setVisible(false); continue; } + const thrusting = ship.input.thrust && !this.match.over; + const spDef = (ship.fx.formIndex ? ship.def.forms?.[ship.fx.formIndex - 1]?.sprite : null) + ?? ship.def.sprite; + if (spr && spDef?.sheet && this.textures.exists(spDef.sheet)) { + if (spr.texture.key !== spDef.sheet) spr.setTexture(spDef.sheet, 0); + const q = quantizeFacing(ship.facing); + spr.setVisible(true) + .setPosition(s.x, s.y) + .setScale(z * 4) + .setFrame((12 - q + 16) & 15); + spr.setTint(ship.fx.blazer ? 0xff8866 : 0xffffff); + if (thrusting) { + const f = ship.facing; + const back = ship.def.radius * 1.05 * z; + g.fillStyle(0xffc06a, 0.5 + Math.random() * 0.3); + g.fillCircle(s.x - Math.cos(f) * back, s.y - Math.sin(f) * back, Math.max(2, 22 * z)); + } + } else { + spr?.setVisible(false); + const size = Math.max(9, ship.def.radius * z); + this.drawChevron(g, s.x, s.y, size, ship.facing, SIDE_COLORS[ship.side].hull, thrusting); + } + // Shield/absorb auras read through both render paths. + if (ship.fx.shieldFrames > 0 || ship.fx.absorbFrames > 0) { + g.lineStyle(3, ship.fx.absorbFrames > 0 ? 0xe7d27b : 0x8ad4ff, 0.8); + g.strokeCircle(s.x, s.y, (ship.def.radius + 40) * z); + } + } + + // Transient FX. + const fg = this.gFx; + fg.clear(); + this.fxList = this.fxList.filter((fx) => { + fx.age = (fx.age ?? 0) + delta; + if (fx.age >= fx.ttl) return false; + const t = fx.age / fx.ttl; + if (fx.type === 'beam') { + const a = this.w2s(fx.x1, fx.y1); + const b = this.w2s(fx.x2, fx.y2); + fg.lineStyle(Math.max(1.5, 3 * z * 2) * (1 - t) + 1, SIDE_COLORS[fx.side ?? 0].hull, 1 - t * 0.7); + fg.lineBetween(a.x, a.y, b.x, b.y); + } else if (fx.type === 'burst') { + const s = this.w2s(fx.x, fx.y); + fg.fillStyle(0xffd27a, (1 - t) * 0.8); + fg.fillCircle(s.x, s.y, fx.r * z * (0.4 + t)); + } else if (fx.type === 'ring') { + const s = this.w2s(fx.x, fx.y); + fg.lineStyle(6 * (1 - t) + 1, 0xffe9a0, 1 - t); + fg.strokeCircle(s.x, s.y, fx.r * z * t); + fg.fillStyle(0xff9040, (1 - t) * 0.5); + fg.fillCircle(s.x, s.y, fx.r * z * t * 0.5); + } else if (fx.type === 'boom') { + const s = this.w2s(fx.x, fx.y); + for (let i = 0; i < 9; i += 1) { + const a = (i / 9) * Math.PI * 2 + fx.x; + fg.fillStyle(i % 2 ? 0xffd27a : 0xff7040, (1 - t) * 0.9); + fg.fillCircle( + s.x + Math.cos(a) * fx.r * z * t, + s.y + Math.sin(a) * fx.r * z * t, + Math.max(2, fx.r * z * 0.16 * (1 - t)), + ); + } + } + return true; + }); + } + + // ========================================================================= + // HUD + + buildHud() { + this.hudRoot.removeAll(true); + this.hudPanels = []; + this.lastHud = [null, null]; + [0, 1].forEach((side) => { + const x = side === 0 ? 30 : GAME_WIDTH - 330; + const c = this.add.container(x, 24); + const entry = this.fleets[side][this.current[side]]; + const def = this.SHIPS[entry.key]; + const bg = this.add.graphics(); + bg.fillStyle(0x081209, 0.72).fillRoundedRect(0, 0, 300, 118, 8); + bg.lineStyle(2, SIDE_COLORS[side].hull, 0.6).strokeRoundedRect(0, 0, 300, 118, 8); + c.add(bg); + const name = this.add.text(12, 8, def.name.toUpperCase(), { + fontFamily: FONT, fontSize: '20px', color: SIDE_COLORS[side].hex, + }); + c.add(name); + const bars = this.add.graphics(); + c.add(bars); + const crewT = this.add.text(288, 34, '', { fontFamily: FONT, fontSize: '16px', color: '#9adbb2' }).setOrigin(1, 0); + const enT = this.add.text(288, 64, '', { fontFamily: FONT, fontSize: '16px', color: '#f0908a' }).setOrigin(1, 0); + c.add(crewT); + c.add(enT); + const fleetT = this.add.text(12, 92, '', { fontFamily: FONT, fontSize: '15px', color: '#89a6a0' }); + c.add(fleetT); + this.hudRoot.add(c); + this.hudPanels.push({ c, bars, crewT, enT, fleetT, def }); + }); + } + + updateHud() { + [0, 1].forEach((side) => { + const ship = this.match.ships[side]; + const p = this.hudPanels[side]; + const remaining = this.aliveFleet(side).length; + const sig = `${ship.crew}|${ship.energy}|${remaining}`; + if (this.lastHud[side] === sig) return; + this.lastHud[side] = sig; + const s = ship.def.stats; + p.bars.clear(); + const bar = (y, frac, color) => { + p.bars.fillStyle(0x111d16, 1).fillRect(12, y, 210, 16); + if (frac > 0) p.bars.fillStyle(color, 1).fillRect(13, y + 1, 208 * frac, 14); + p.bars.lineStyle(1, 0x3a5a48, 1).strokeRect(12, y, 210, 16); + const ticks = Math.floor(210 / 14); + p.bars.lineStyle(1, 0x081209, 0.9); + for (let i = 1; i < ticks; i += 1) p.bars.lineBetween(12 + i * 14, y, 12 + i * 14, y + 16); + }; + bar(34, Math.max(0, ship.crew) / s.maxCrew, 0x63d97c); + bar(64, Math.max(0, ship.energy) / s.maxEnergy, 0xe2574e); + p.crewT.setText(`${Math.max(0, ship.crew)}`); + p.enT.setText(`${Math.max(0, ship.energy)}`); + p.fleetT.setText(`FLEET ${remaining} — DESTROYED ${this.destroyedPoints[side]} PTS`); + }); + } + + // ========================================================================= + // Pause / escape + + onEscape() { + if (this.view !== 'battle') return; + if (this.paused) { this.closePause(); return; } + this.paused = true; + const cx = GAME_WIDTH / 2; + this.pauseRoot = this.add.container(0, 0).setDepth(D.overlay); + const dim = this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62); + this.pauseRoot.add(dim); + this.pauseRoot.add(this.mkText(cx, 340, 'PAUSED', 60, '#74f5a1')); + this.pauseRoot.add(this.textButton(cx, 480, 'RESUME', () => this.closePause(), 32)); + this.pauseRoot.add(this.textButton(cx, 570, 'ABANDON MATCH', () => { this.closePause(); this.showSetup(); }, 32)); + this.pauseRoot.add(this.textButton(cx, 660, 'EXIT TO MENU', () => this.scene.start('GameMenu'), 32)); + } + + closePause() { + this.paused = false; + this.pauseRoot?.destroy(true); + this.pauseRoot = null; + } + + // ========================================================================= + // RECAP view + + showRecap(losers) { + this.view = 'recap'; + this.match = null; + this.clearView(); + const root = this.viewRoot; + const cx = GAME_WIDTH / 2; + + const bothOut = losers.length === 2; + const winner = bothOut ? null : 1 - losers[0]; + const title = bothOut ? 'MUTUAL ANNIHILATION' + : `${this.mode === 'hotseat' ? `PLAYER ${winner + 1}` : (winner === 0 ? 'YOU' : 'THE COMPUTER')} WIN${winner === 0 && this.mode !== 'hotseat' ? '' : 'S'} THE MELEE`; + root.add(this.mkText(cx, 200, title, 56, winner === null ? '#e7d27b' : SIDE_COLORS[winner].hex)); + + [0, 1].forEach((side) => { + const x = side === 0 ? 620 : GAME_WIDTH - 620; + root.add(this.mkText(x, 330, this.mode === 'hotseat' || side === 0 ? `PLAYER ${side + 1}` : 'COMPUTER', 30, SIDE_COLORS[side].hex)); + root.add(this.mkText(x, 390, `DESTROYED ${this.destroyedPoints[side]} PTS OF SHIPS`, 26)); + root.add(this.mkText(x, 440, `SHIPS LEFT ${this.aliveFleet(side).length}`, 22, '#89a6a0')); + }); + + // Score/history: single-player only, from the human's perspective. + if (this.mode === 'ai') { + const score = this.destroyedPoints[0]; + const best = Number(localStorage.getItem(BEST_KEY) ?? 0); + if (score > best) localStorage.setItem(BEST_KEY, String(score)); + api.post('/history/single-player', { + slug: 'starcontrol', + score, + opponentScores: [this.destroyedPoints[1]], + result: winner === 0 ? 'win' : 'loss', + }).catch(() => {}); + playSound(this, winner === 0 ? SFX.VICTORY_SHORT : SFX.SCIFI_EXPLODE); + } + + root.add(this.textButton(cx - 260, 760, 'REMATCH', () => { this.beginMatch(); }, 32)); + root.add(this.textButton(cx, 760, 'NEW FLEETS', () => this.showSetup(), 32)); + root.add(this.textButton(cx + 260, 760, 'EXIT', () => this.scene.start('GameMenu'), 32)); + } +} diff --git a/src/games/starcontrol/StarControlLogic.js b/src/games/starcontrol/StarControlLogic.js new file mode 100644 index 0000000..b746400 --- /dev/null +++ b/src/games/starcontrol/StarControlLogic.js @@ -0,0 +1,806 @@ +// Pure Super Melee simulation for Star Control. No Phaser dependency — fully +// unit-testable headlessly via tools/verifyStarControl.js. +// +// Fidelity anchors (from The Ur-Quan Masters sources, sc2/src/uqm): +// - Battle advances in fixed 24 fps frames; every ship stat expressed as a +// "wait" is a frame counter with UQM semantics: WAIT = n means the action +// recurs every n+1 frames. +// - World units are UQM world units (1 display pixel = 4 world units, +// units.h DISPLAY_TO_WORLD). Arena is UQM's LOG_SPACE: 8192 x 7680. +// - Ships have 16 discrete facings for thrust/fire/sprites; the sim keeps a +// float facing for smooth turning at the UQM rate ((PI/8)/(turnWait+1) per +// frame) and quantizes where the original quantizes. + +import { PRIMARIES, SPECIALS, PASSIVES } from './StarControlShips.js'; + +export const STEP_MS = 1000 / 24; +export const ARENA_W = 8192; +export const ARENA_H = 7680; +export const FACINGS = 16; +export const FACING_STEP = (Math.PI * 2) / FACINGS; + +export const TUNE = { + MAX_FRAMES_PER_STEP: 6, // spiral-of-death cap after tab suspend + PLANET_RADIUS: 220, // world units (UQM planet ~29 display px) + PLANET_GRAVITY: 1.6e6, // accel = G / r^2 (world units / frame^2) + PLANET_GRAVITY_MIN_R: 420, // clamp radius so slingshots stay sane + PLANET_CREW_DAMAGE: 3, // crew lost on planet impact + PLANET_DAMAGE_COOLDOWN: 24, // frames between crew-damaging planet hits + PLANET_BOUNCE: 0.65, // velocity reflection factor off the planet + PLANET_EJECT: 36, // extra outward shove on impact (units/frame) + ASTEROID_COUNT: 5, + ASTEROID_RADIUS: 60, + ASTEROID_SPEED_MIN: 8, // units/frame + ASTEROID_SPEED_MAX: 24, + ASTEROID_RESPAWN_FRAMES: 240, // ~10 s + SHIP_COLLISION_DAMAGE_SPEED: 40, // relative speed (units/frame) for 1-crew hit + SHIP_COLLISION_RESTITUTION: 0.9, + SPAWN_SEPARATION: 3200, // new ship spawns this far from survivor + DEFAULT_SHIP_RADIUS: 100, + MAX_BATTLE_FRAMES: 24 * 60 * 5, // 5 min safety valve -> draw +}; + +// --------------------------------------------------------------------------- +// Seeded RNG (same generator the other games use). +export function mulberry32(seed) { + let a = seed >>> 0; + return () => { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// --------------------------------------------------------------------------- +// Toroidal arena math. Exported for the AI, the scene renderer and verify. +export function wrap(v, size) { + return ((v % size) + size) % size; +} + +// Shortest signed delta from a to b on a wrapped axis. +export function tdelta(a, b, size) { + let d = (b - a) % size; + if (d > size / 2) d -= size; + else if (d < -size / 2) d += size; + return d; +} + +export function tdist2(ax, ay, bx, by) { + const dx = tdelta(ax, bx, ARENA_W); + const dy = tdelta(ay, by, ARENA_H); + return dx * dx + dy * dy; +} + +export function tdist(ax, ay, bx, by) { + return Math.sqrt(tdist2(ax, ay, bx, by)); +} + +// Toroidal midpoint (halfway along the short path from a to b). +export function tmid(ax, ay, bx, by) { + return { + x: wrap(ax + tdelta(ax, bx, ARENA_W) / 2, ARENA_W), + y: wrap(ay + tdelta(ay, by, ARENA_H) / 2, ARENA_H), + }; +} + +// True if the toroidal segment (x1,y1)->(x2,y2) passes within `r` of (cx,cy). +// Used for swept collision so fast shots can't tunnel through small ships. +export function segHitsCircle(x1, y1, x2, y2, cx, cy, r) { + const dx = tdelta(x1, x2, ARENA_W); + const dy = tdelta(y1, y2, ARENA_H); + const fx = tdelta(x1, cx, ARENA_W); + const fy = tdelta(y1, cy, ARENA_H); + const len2 = dx * dx + dy * dy; + let t = len2 > 0 ? (fx * dx + fy * dy) / len2 : 0; + t = Math.max(0, Math.min(1, t)); + const ex = fx - dx * t; + const ey = fy - dy * t; + return ex * ex + ey * ey < r * r; +} + +export function quantizeFacing(facing) { + return ((Math.round(facing / FACING_STEP) % FACINGS) + FACINGS) % FACINGS; +} + +export function facingToRad(facing16) { + return facing16 * FACING_STEP; +} + +// Angle needed to hit a moving target with a projectile of speed `shotSpeed` +// (world units/frame), from (sx, sy), toroidal-aware. Falls back to a direct +// bearing when no intercept exists. Used by homing shots and the AI. +export function interceptAngle(sx, sy, target, shotSpeed) { + const dx = tdelta(sx, target.x, ARENA_W); + const dy = tdelta(sy, target.y, ARENA_H); + const tvx = target.vx ?? 0; + const tvy = target.vy ?? 0; + const a = tvx * tvx + tvy * tvy - shotSpeed * shotSpeed; + const b = 2 * (dx * tvx + dy * tvy); + const c = dx * dx + dy * dy; + let t = -1; + if (Math.abs(a) < 1e-6) { + if (Math.abs(b) > 1e-6) t = -c / b; + } else { + const disc = b * b - 4 * a * c; + if (disc >= 0) { + const rt = Math.sqrt(disc); + const t1 = (-b - rt) / (2 * a); + const t2 = (-b + rt) / (2 * a); + t = t1 > 0 ? t1 : t2; + } + } + if (t <= 0 || !Number.isFinite(t)) return Math.atan2(dy, dx); + return Math.atan2(dy + tvy * t, dx + tvx * t); +} + +// Signed smallest angular difference a -> b in (-PI, PI]. +export function angleDelta(a, b) { + let d = (b - a) % (Math.PI * 2); + if (d > Math.PI) d -= Math.PI * 2; + else if (d <= -Math.PI) d += Math.PI * 2; + return d; +} + +// --------------------------------------------------------------------------- +// Ship definition compilation / validation. + +const REQUIRED_STATS = [ + 'maxCrew', 'maxEnergy', 'energyRegen', 'energyWait', 'maxThrust', + 'thrustIncrement', 'thrustWait', 'turnWait', 'mass', +]; + +export function validateShipDef(key, def) { + const fail = (why) => { throw new Error(`ship "${key}": ${why}`); }; + if (!def || typeof def !== 'object') fail('not an object'); + if (!def.name) fail('missing name'); + if (!['alliance', 'hierarchy', 'neutral'].includes(def.team)) fail(`bad team "${def.team}"`); + if (!(def.pointValue >= 0)) fail('bad pointValue'); + if (!def.stats) fail('missing stats'); + for (const s of REQUIRED_STATS) { + if (typeof def.stats[s] !== 'number' || def.stats[s] < 0) fail(`stats.${s} missing or negative`); + } + if (!def.primary || !PRIMARIES[def.primary.type]) fail(`unknown primary type "${def.primary?.type}"`); + if (!def.special || !SPECIALS[def.special.type]) fail(`unknown special type "${def.special?.type}"`); + for (const p of def.passives ?? []) { + if (!PASSIVES[p]) fail(`unknown passive "${p}"`); + } + for (const form of def.forms ?? []) { + if (form.primary && !PRIMARIES[form.primary.type]) fail(`unknown form primary "${form.primary.type}"`); + } + return true; +} + +// Alternate-form support (Mmrnmhrm X-Form): a transform special stashes the +// active form's stat/weapon overrides in ship.fx; everything else reads +// through these accessors. +export function shipStats(ship) { + return ship.fx.formStats ? { ...ship.def.stats, ...ship.fx.formStats } : ship.def.stats; +} + +export function shipPrimary(ship) { + return ship.fx.formPrimary ?? ship.def.primary; +} + +export function compileShip(key, def) { + validateShipDef(key, def); + const s = def.stats; + return { + key, + ...def, + passives: def.passives ?? [], + radius: def.radius + ?? (def.sprite?.frameWidth + ? Math.max(24, (Math.max(def.sprite.frameWidth, def.sprite.frameHeight) / 2) * 4 * 0.8) + : TUNE.DEFAULT_SHIP_RADIUS), + turnPerFrame: FACING_STEP / (s.turnWait + 1), // rad/frame; one 1/16 facing per (turnWait+1) frames + }; +} + +// json -> { key -> compiled def }. Throws with a named reason on bad entries. +export function compileShips(json) { + const out = {}; + const ships = json?.ships ?? {}; + for (const key of Object.keys(ships)) out[key] = compileShip(key, ships[key]); + return out; +} + +// --------------------------------------------------------------------------- +// Battle state. + +let nextEntityId = 1; + +function makeShip(def, side, opts = {}) { + return { + id: nextEntityId++, + kind: 'ship', + side, // 0 or 1 + def, + x: opts.x ?? 0, y: opts.y ?? 0, + px: opts.x ?? 0, py: opts.y ?? 0, // previous-frame position for render lerp + vx: opts.vx ?? 0, vy: opts.vy ?? 0, + facing: opts.facing ?? 0, // float radians + crew: opts.crew ?? def.stats.maxCrew, + energy: opts.energy ?? def.stats.maxEnergy, + alive: true, + waits: { turn: 0, thrust: 0, weapon: 0, special: 0, energy: 0 }, + input: { left: false, right: false, thrust: false, fire: false, special: false }, + // Registry-managed status effects / per-ship scratch state. + fx: {}, + // Movement overrides registries may set for one frame (blazer, omni-thrust, + // probe drive, afterburner). Cleared by the sim each frame. + moveOverride: null, + }; +} + +function spawnAsteroid(state, edge) { + const rng = state.rng; + let x; let y; + if (edge) { + if (rng() < 0.5) { x = 0; y = rng() * ARENA_H; } else { x = rng() * ARENA_W; y = 0; } + } else { + // Initial placement: anywhere not too close to the planet or the ships. + do { + x = rng() * ARENA_W; y = rng() * ARENA_H; + } while ( + tdist2(x, y, ARENA_W / 2, ARENA_H / 2) < 1400 * 1400 + || state.ships.some((sh) => tdist2(x, y, sh.x, sh.y) < 900 * 900) + ); + } + const speed = TUNE.ASTEROID_SPEED_MIN + rng() * (TUNE.ASTEROID_SPEED_MAX - TUNE.ASTEROID_SPEED_MIN); + const ang = rng() * Math.PI * 2; + state.asteroids.push({ + id: nextEntityId++, kind: 'asteroid', + x, y, px: x, py: y, + vx: Math.cos(ang) * speed, vy: Math.sin(ang) * speed, + spin: (rng() - 0.5) * 0.3, rot: rng() * Math.PI * 2, + radius: TUNE.ASTEROID_RADIUS, alive: true, + }); +} + +// Creates one 1v1 battle. `a`/`b`: { def, crew?, energy? } — crew/energy carry +// over for a surviving ship between battles. The survivor keeps its position +// and velocity via opts.keep = { side, x, y, vx, vy, facing }. +export function createBattle({ a, b, seed = 1, keep = null }) { + const rng = mulberry32(seed); + const state = { + seed, + rng, + frame: 0, + over: false, + winner: null, // null | 0 | 1 | 'draw' + ships: [], + projectiles: [], + asteroids: [], + events: [], + planet: { x: ARENA_W / 2, y: ARENA_H / 2, radius: TUNE.PLANET_RADIUS }, + accumulatorMs: 0, + }; + const mk = (spec, side, place) => { + const ship = makeShip(spec.def, side, { + ...place, crew: spec.crew, energy: spec.energy, + }); + for (const p of ship.def.passives) PASSIVES[p].init?.(state, ship); + SPECIALS[ship.def.special.type].init?.(state, ship); + PRIMARIES[ship.def.primary.type].init?.(state, ship); + return ship; + }; + // Default entry: top and bottom of the arena, facing each other (UQM style). + const placeA = { x: ARENA_W / 2, y: ARENA_H * 0.18, facing: Math.PI / 2 }; + const placeB = { x: ARENA_W / 2, y: ARENA_H * 0.82, facing: -Math.PI / 2 }; + if (keep && keep.side === 0) { + Object.assign(placeA, { x: keep.x, y: keep.y, vx: keep.vx, vy: keep.vy, facing: keep.facing }); + Object.assign(placeB, { + x: wrap(keep.x + TUNE.SPAWN_SEPARATION, ARENA_W), + y: wrap(keep.y + ARENA_H / 2, ARENA_H), + }); + } else if (keep && keep.side === 1) { + Object.assign(placeB, { x: keep.x, y: keep.y, vx: keep.vx, vy: keep.vy, facing: keep.facing }); + Object.assign(placeA, { + x: wrap(keep.x + TUNE.SPAWN_SEPARATION, ARENA_W), + y: wrap(keep.y + ARENA_H / 2, ARENA_H), + }); + } + state.ships.push(mk(a, 0, placeA)); + state.ships.push(mk(b, 1, placeB)); + for (let i = 0; i < TUNE.ASTEROID_COUNT; i += 1) spawnAsteroid(state, false); + return state; +} + +export function setInput(state, side, input) { + const ship = state.ships[side]; + if (!ship || !ship.alive) return; + Object.assign(ship.input, input); +} + +// --------------------------------------------------------------------------- +// Registry-facing sim services (the API weapon/special functions use). + +export function emit(state, event) { + state.events.push(event); +} + +export function spawnProjectile(state, owner, opts) { + const speed = opts.speed ?? 0; + const ang = opts.angle ?? owner.facing; + const p = { + id: nextEntityId++, + kind: opts.kind ?? 'shot', + type: opts.type ?? 'shot', + owner: owner.side, + x: wrap(opts.x ?? owner.x, ARENA_W), + y: wrap(opts.y ?? owner.y, ARENA_H), + vx: opts.vx ?? Math.cos(ang) * speed, + vy: opts.vy ?? Math.sin(ang) * speed, + px: 0, py: 0, + facing: ang, + damage: opts.damage ?? 1, + hp: opts.hp ?? 1, + life: opts.life ?? 24, + radius: opts.radius ?? 24, + gravity: opts.gravity ?? false, + collidesShips: opts.collidesShips ?? true, + collidesShots: opts.collidesShots ?? true, + behavior: opts.behavior ?? null, + data: opts.data ?? {}, + alive: true, + }; + p.px = p.x; p.py = p.y; + state.projectiles.push(p); + return p; +} + +// Launch offset in ship-local space: dist world units along facing + +// perpendicular offset. Returns spawn {x, y}. +export function muzzle(ship, dist, side = 0) { + const f = facingToRad(quantizeFacing(ship.facing)); + return { + x: wrap(ship.x + Math.cos(f) * dist + Math.cos(f + Math.PI / 2) * side, ARENA_W), + y: wrap(ship.y + Math.sin(f) * dist + Math.sin(f + Math.PI / 2) * side, ARENA_H), + angle: f, + }; +} + +export function spendEnergy(state, ship, amount) { + if (ship.energy < amount) return false; + ship.energy -= amount; + return true; +} + +export function gainEnergy(state, ship, amount) { + ship.energy = Math.min(ship.def.stats.maxEnergy, ship.energy + amount); +} + +// All crew loss funnels through here so shields/absorption/resurrection hook in. +export function damageShip(state, ship, dmg, kind = 'hit', source = null) { + if (!ship.alive || dmg <= 0) return 0; + const special = SPECIALS[ship.def.special.type]; + if (special.absorbDamage) { + dmg = special.absorbDamage(state, ship, dmg, kind); + if (dmg <= 0) return 0; + } + const dealt = Math.min(ship.crew, dmg); + ship.crew -= dealt; + emit(state, { t: 'shipHit', side: ship.side, x: ship.x, y: ship.y, dmg: dealt, kind }); + if (ship.crew <= 0) killShip(state, ship, kind); + return dealt; +} + +export function gainCrew(state, ship, amount) { + ship.crew = Math.min(ship.def.stats.maxCrew, ship.crew + amount); +} + +export function killShip(state, ship, kind = 'destroyed') { + if (!ship.alive) return; + ship.crew = 0; + // Passives get a chance to cancel death (Pkunk resurrection). + for (const p of ship.def.passives) { + if (PASSIVES[p].onDeath && PASSIVES[p].onDeath(state, ship)) { + emit(state, { t: 'resurrect', side: ship.side, x: ship.x, y: ship.y }); + return; + } + } + ship.alive = false; + emit(state, { t: 'death', side: ship.side, x: ship.x, y: ship.y, kind }); +} + +export function enemyOf(state, ship) { + return state.ships[1 - ship.side]; +} + +// --------------------------------------------------------------------------- +// Frame update. + +function applyThrustCapped(ship, ax, ay, maxThrust) { + // UQM rule: thrust may not push the speed past maxThrust, but external + // forces may; thrusting while faster than max keeps the higher of + // (previous speed, maxThrust). + const prevSpeed = Math.hypot(ship.vx, ship.vy); + ship.vx += ax; ship.vy += ay; + const speed = Math.hypot(ship.vx, ship.vy); + const cap = Math.max(prevSpeed, maxThrust); + if (speed > cap) { + const k = cap / speed; + ship.vx *= k; ship.vy *= k; + } +} + +function shipFrame(state, ship) { + const w = ship.waits; + for (const k of Object.keys(w)) { if (w[k] > 0) w[k] -= 1; } + + const special = SPECIALS[ship.def.special.type]; + + // Specials/passives that alter movement/state run before movement. + special.preprocess?.(state, ship); + for (const p of ship.def.passives) PASSIVES[p].preprocess?.(state, ship); + + const s = shipStats(ship); + const pdef = shipPrimary(ship); + const primary = PRIMARIES[pdef.type]; + const ov = ship.moveOverride; + const confused = (ship.fx.confusedFrames ?? 0) > 0; + if (ship.fx.confusedFrames > 0) ship.fx.confusedFrames -= 1; + + // Turning (limpets slow it: each limpet adds +1 to the effective turnWait). + if (!(ov?.noTurn)) { + let dir = 0; + if (ship.input.left) dir -= 1; + if (ship.input.right) dir += 1; + if (confused && dir !== 0 && state.rng() < 0.5) dir = -dir; + if (confused && dir === 0 && state.rng() < 0.5) dir = state.rng() < 0.5 ? -1 : 1; + if (dir !== 0) { + const limpets = ship.fx.limpets ?? 0; + const turnPerFrame = FACING_STEP / (s.turnWait + 1 + limpets); + ship.facing += dir * turnPerFrame; + } + } + + // Thrust. + const thrusting = ship.input.thrust || ov?.autoThrust; + if (!(ov?.noThrust) && thrusting && w.thrust === 0) { + w.thrust = (ov?.thrustWait ?? s.thrustWait) + 1; + const limpets = ship.fx.limpets ?? 0; + const thrustScale = 1 / (1 + limpets * 0.35); + let f = facingToRad(quantizeFacing(ship.facing)); + if (ov?.reverseThrust) f += Math.PI; + const inc = (ov?.thrustIncrement ?? s.thrustIncrement) * thrustScale; + const max = (ov?.maxThrust ?? s.maxThrust) * thrustScale; + if (ov?.setVelocity) { + ship.vx = Math.cos(f) * max; ship.vy = Math.sin(f) * max; + } else { + applyThrustCapped(ship, Math.cos(f) * inc, Math.sin(f) * inc, max); + } + emit(state, { t: 'thrust', side: ship.side, x: ship.x, y: ship.y, facing: ship.facing }); + } + + // Primary weapon. + if (ship.input.fire && w.weapon === 0 && !(ov?.noFire)) { + const cost = pdef.energyCost; + if (primary.canFire?.(state, ship) !== false && ship.energy >= cost) { + spendEnergy(state, ship, cost); + w.weapon = pdef.refireWait + 1; + primary.fire(state, ship, pdef); + emit(state, { t: 'fire', side: ship.side, weapon: pdef.type, sfx: pdef.sfxClass }); + } + } + primary.tick?.(state, ship, pdef); + + // Special. + if (ship.input.special && w.special === 0) { + const cost = ship.def.special.energyCost; + if (special.canFire?.(state, ship) !== false && ship.energy >= cost) { + if (special.activate(state, ship, ship.def.special) !== false) { + spendEnergy(state, ship, cost); + w.special = ship.def.special.refireWait + 1; + emit(state, { t: 'special', side: ship.side, special: ship.def.special.type, sfx: ship.def.special.sfxClass }); + } + } + } + special.tick?.(state, ship, ship.def.special); + + // Energy regeneration (can be negative: Androsynth blazer degeneration). + if (w.energy === 0) { + w.energy = (ship.fx.energyWaitOverride ?? s.energyWait) + 1; + const regen = ship.fx.energyRegenOverride ?? s.energyRegen; + if (regen > 0) gainEnergy(state, ship, regen); + else if (regen < 0) ship.energy = Math.max(0, ship.energy + regen); + } + + ship.moveOverride = null; +} + +function gravityFrame(state, body) { + const dx = tdelta(body.x, state.planet.x, ARENA_W); + const dy = tdelta(body.y, state.planet.y, ARENA_H); + const r2 = Math.max(dx * dx + dy * dy, TUNE.PLANET_GRAVITY_MIN_R * TUNE.PLANET_GRAVITY_MIN_R); + const a = TUNE.PLANET_GRAVITY / r2; + const r = Math.sqrt(r2); + body.vx += (dx / r) * a; + body.vy += (dy / r) * a; +} + +function integrate(body) { + body.px = body.x; body.py = body.y; + body.x = wrap(body.x + body.vx, ARENA_W); + body.y = wrap(body.y + body.vy, ARENA_H); +} + +function planetCollision(state, ship) { + const d = tdist(ship.x, ship.y, state.planet.x, state.planet.y); + const minD = state.planet.radius + ship.def.radius * 0.6; + if (d >= minD) return; + const nx = tdelta(state.planet.x, ship.x, ARENA_W) / (d || 1); + const ny = tdelta(state.planet.y, ship.y, ARENA_H) / (d || 1); + const vDotN = ship.vx * nx + ship.vy * ny; + if (vDotN < 0) { + ship.vx -= (1 + TUNE.PLANET_BOUNCE) * vDotN * nx; + ship.vy -= (1 + TUNE.PLANET_BOUNCE) * vDotN * ny; + } + ship.vx += nx * TUNE.PLANET_EJECT; + ship.vy += ny * TUNE.PLANET_EJECT; + ship.x = wrap(state.planet.x + nx * minD, ARENA_W); + ship.y = wrap(state.planet.y + ny * minD, ARENA_H); + // Repeated scrapes within the cooldown window bounce but don't re-damage, + // so a gravity-pinned ship can't be ground to death in a second. + if (!(ship.fx.planetHitFrame > state.frame - TUNE.PLANET_DAMAGE_COOLDOWN)) { + ship.fx.planetHitFrame = state.frame; + damageShip(state, ship, TUNE.PLANET_CREW_DAMAGE, 'planet'); + } + emit(state, { t: 'planetBounce', side: ship.side, x: ship.x, y: ship.y }); +} + +function shipShipCollision(state) { + const [A, B] = state.ships; + if (!A.alive || !B.alive) return; + if (A.fx.phasing || B.fx.phasing) return; + const minD = A.def.radius + B.def.radius; + // Swept: track A relative to B across the frame so fast closing speeds + // can't step through each other. + const relX1 = tdelta(B.px, A.px, ARENA_W); + const relY1 = tdelta(B.py, A.py, ARENA_H); + const relX2 = tdelta(B.x, A.x, ARENA_W); + const relY2 = tdelta(B.y, A.y, ARENA_H); + const swept = (() => { + const dx = relX2 - relX1; + const dy = relY2 - relY1; + const len2 = dx * dx + dy * dy; + let t = len2 > 0 ? -(relX1 * dx + relY1 * dy) / len2 : 0; + t = Math.max(0, Math.min(1, t)); + const ex = relX1 + dx * t; + const ey = relY1 + dy * t; + return ex * ex + ey * ey < minD * minD; + })(); + if (!swept) return; + const d2 = tdist2(A.x, A.y, B.x, B.y); + const d = Math.sqrt(d2) || 1; + const nx = tdelta(A.x, B.x, ARENA_W) / d; + const ny = tdelta(A.y, B.y, ARENA_H) / d; + const relV = (B.vx - A.vx) * nx + (B.vy - A.vy) * ny; + const mA = A.def.stats.mass || 1; + const mB = B.def.stats.mass || 1; + if (relV < 0) { + const j = (-(1 + TUNE.SHIP_COLLISION_RESTITUTION) * relV) / (1 / mA + 1 / mB); + A.vx -= (j / mA) * nx; A.vy -= (j / mA) * ny; + B.vx += (j / mB) * nx; B.vy += (j / mB) * ny; + } + // Positional separation, split by inverse mass (only when still overlapping). + const overlap = minD - d; + if (overlap > 0) { + const total = 1 / mA + 1 / mB; + A.x = wrap(A.x - nx * overlap * ((1 / mA) / total), ARENA_W); + A.y = wrap(A.y - ny * overlap * ((1 / mA) / total), ARENA_H); + B.x = wrap(B.x + nx * overlap * ((1 / mB) / total), ARENA_W); + B.y = wrap(B.y + ny * overlap * ((1 / mB) / total), ARENA_H); + } + if (Math.abs(relV) > TUNE.SHIP_COLLISION_DAMAGE_SPEED) { + // Contact-damage specials (Androsynth blazer, ZFP tongue handled as beam) + // hook in via onRam; default is 1 crew each. + const dmgA = SPECIALS[B.def.special.type].ramDamage?.(state, B) ?? 1; + const dmgB = SPECIALS[A.def.special.type].ramDamage?.(state, A) ?? 1; + damageShip(state, A, dmgA, 'collision'); + damageShip(state, B, dmgB, 'collision'); + } + emit(state, { t: 'shipsCollide', x: wrap(A.x + nx * A.def.radius, ARENA_W), y: wrap(A.y + ny * A.def.radius, ARENA_H) }); +} + +function projectileFrame(state, p) { + if (p.life > 0) p.life -= 1; + if (p.life === 0) { p.alive = false; return; } + p.behavior?.(state, p); + if (!p.alive) return; + if (p.gravity) gravityFrame(state, p); + integrate(p); + + // Planet swallows shots. + if (tdist2(p.x, p.y, state.planet.x, state.planet.y) < state.planet.radius * state.planet.radius) { + p.alive = false; + emit(state, { t: 'shotHit', x: p.x, y: p.y, kind: 'planet' }); + return; + } + + // Ships (swept over this frame's travel so fast shots can't tunnel). + if (p.collidesShips) { + for (const ship of state.ships) { + if (!ship.alive || ship.side === p.owner) continue; + if (ship.fx.phasing) continue; + const rr = ship.def.radius + p.radius; + if (segHitsCircle(p.px, p.py, p.x, p.y, ship.x, ship.y, rr)) { + if (p.data.onHitShip) { p.data.onHitShip(state, p, ship); } else { + damageShip(state, ship, p.damage, p.type); + } + // hp is durability against other shots; hitting a ship detonates the + // projectile outright unless it is explicitly piercing. + if (!p.data.piercing) p.alive = false; + emit(state, { t: 'shotHit', x: p.x, y: p.y, kind: p.type, dmg: p.damage }); + if (!p.alive) return; + } + } + } + + // Asteroids block shots. + for (const ast of state.asteroids) { + if (!ast.alive) continue; + const rr = ast.radius + p.radius; + if (segHitsCircle(p.px, p.py, p.x, p.y, ast.x, ast.y, rr)) { + destroyAsteroid(state, ast, p.owner); + p.hp -= 1; + if (p.hp <= 0) { p.alive = false; return; } + } + } +} + +export function destroyAsteroid(state, ast, bySide = null) { + if (!ast.alive) return; + ast.alive = false; + ast.respawnIn = TUNE.ASTEROID_RESPAWN_FRAMES; + emit(state, { t: 'asteroidBreak', x: ast.x, y: ast.y }); + // Slylandro harvest passive. + for (const ship of state.ships) { + if (ship.alive && ship.side === bySide) { + for (const pv of ship.def.passives) PASSIVES[pv].onAsteroidKill?.(state, ship, ast); + } + } +} + +function projectileProjectileCollisions(state) { + const ps = state.projectiles; + for (let i = 0; i < ps.length; i += 1) { + const a = ps[i]; + if (!a.alive || !a.collidesShots) continue; + for (let j = i + 1; j < ps.length; j += 1) { + const b = ps[j]; + if (!b.alive || !b.collidesShots || a.owner === b.owner) continue; + const rr = a.radius + b.radius; + if (tdist2(a.x, a.y, b.x, b.y) < rr * rr) { + a.hp -= b.damage > 0 ? Math.max(1, b.damage) : 1; + b.hp -= a.damage > 0 ? Math.max(1, a.damage) : 1; + if (a.hp <= 0) a.alive = false; + if (b.hp <= 0) b.alive = false; + emit(state, { t: 'shotHit', x: a.x, y: a.y, kind: 'clash' }); + if (!a.alive) break; + } + } + } +} + +function asteroidFrame(state, ast) { + if (!ast.alive) { + if (ast.respawnIn > 0) { + ast.respawnIn -= 1; + if (ast.respawnIn === 0) { + const idx = state.asteroids.indexOf(ast); + state.asteroids.splice(idx, 1); + spawnAsteroid(state, true); + } + } + return; + } + gravityFrame(state, ast); + ast.rot += ast.spin; + integrate(ast); + if (tdist2(ast.x, ast.y, state.planet.x, state.planet.y) + < (state.planet.radius + ast.radius) ** 2) { + destroyAsteroid(state, ast, null); + return; + } + for (const ship of state.ships) { + if (!ship.alive || ship.fx.phasing) continue; + const rr = ship.def.radius + ast.radius; + if (tdist2(ast.x, ast.y, ship.x, ship.y) < rr * rr) { + // Harmless bump: the rock shatters and nudges the ship. + ship.vx += ast.vx * 0.15; ship.vy += ast.vy * 0.15; + destroyAsteroid(state, ast, ship.side); + return; + } + } +} + +function frame(state) { + state.frame += 1; + + for (const ship of state.ships) { + if (ship.alive) shipFrame(state, ship); + } + for (const ship of state.ships) { + if (!ship.alive) continue; + if (!ship.fx.noGravity) gravityFrame(state, ship); + integrate(ship); + } + for (const ship of state.ships) { + if (ship.alive) planetCollision(state, ship); + } + shipShipCollision(state); + + for (const p of state.projectiles) { if (p.alive) projectileFrame(state, p); } + projectileProjectileCollisions(state); + for (const ast of state.asteroids.slice()) asteroidFrame(state, ast); + + state.projectiles = state.projectiles.filter((p) => p.alive); + + if (!state.over) { + const deadA = !state.ships[0].alive; + const deadB = !state.ships[1].alive; + if (deadA || deadB) { + state.over = true; + state.winner = deadA && deadB ? 'draw' : (deadA ? 1 : 0); + emit(state, { t: 'battleOver', winner: state.winner }); + } else if (state.frame >= TUNE.MAX_BATTLE_FRAMES) { + state.over = true; + state.winner = 'draw'; + emit(state, { t: 'battleOver', winner: 'draw' }); + } + } +} + +// Advances the battle by dtMs of real time in fixed 24 fps frames and returns +// the events that occurred. `state.alpha` afterwards is the interpolation +// fraction [0..1) between the previous and current frame for rendering. +export function stepMatch(state, dtMs) { + if (!state.over) { + state.accumulatorMs += dtMs; + let frames = 0; + while (state.accumulatorMs >= STEP_MS && frames < TUNE.MAX_FRAMES_PER_STEP) { + state.accumulatorMs -= STEP_MS; + frame(state); + frames += 1; + if (state.over) break; + } + if (state.accumulatorMs >= STEP_MS) state.accumulatorMs %= STEP_MS; + } + state.alpha = Math.min(state.accumulatorMs / STEP_MS, 1); + const events = state.events; + state.events = []; + return events; +} + +// --------------------------------------------------------------------------- +// Camera helpers (pure math so verify can cover them; the scene applies them). + +export const ZOOM = { + CLOSE: 1920 / 2048, // world->screen px; ~UQM view 1 + MED: 1920 / 4096, // ~UQM view 2 + FAR: 1920 / 7680, // whole-arena width view + // Separation bands (world units) across which the zoom glides. + BAND_1: [900, 1900], + BAND_2: [2300, 4200], + SMOOTH_MS: 180, +}; + +export function smoothstep(a, b, x) { + const t = Math.min(1, Math.max(0, (x - a) / (b - a))); + return t * t * (3 - 2 * t); +} + +// Ship separation -> target zoom (screen px per world unit). +export function targetZoom(dist) { + const t1 = smoothstep(ZOOM.BAND_1[0], ZOOM.BAND_1[1], dist); + const t2 = smoothstep(ZOOM.BAND_2[0], ZOOM.BAND_2[1], dist); + const z = ZOOM.CLOSE + (ZOOM.MED - ZOOM.CLOSE) * t1; + return z + (ZOOM.FAR - z) * t2; +} + +// Frame-rate independent exponential approach. +export function smoothTowards(current, target, dtMs, tauMs = ZOOM.SMOOTH_MS) { + return current + (target - current) * (1 - Math.exp(-dtMs / tauMs)); +} diff --git a/src/games/starcontrol/StarControlShips.js b/src/games/starcontrol/StarControlShips.js new file mode 100644 index 0000000..da6e088 --- /dev/null +++ b/src/games/starcontrol/StarControlShips.js @@ -0,0 +1,1071 @@ +// Weapon, special-ability and passive behavior registries for Star Control +// Super Melee. No Phaser dependency. Ship JSON entries reference these by +// string type; all numbers come from the JSON so the functions here are pure +// mechanics. Adding a new ship usually means JSON only — a new function lands +// here only for a genuinely new mechanic. +// +// Contracts: +// PRIMARIES[type] = { fire(state, ship, def), canFire?, init?, tick? } +// SPECIALS[type] = { activate(state, ship, def) -> false aborts (no cost), +// canFire?, init?, tick?, preprocess?, +// absorbDamage?(state, ship, dmg, kind) -> remaining dmg, +// ramDamage?(state, attacker) -> crew dmg dealt by contact } +// PASSIVES[name] = { init?, preprocess?, onDeath? -> true cancels death, +// onAsteroidKill? } +// Distances/speeds are UQM world units (1 display px = 4 world units); waits +// and lifetimes are 24 fps frame counts. + +import { + wrap, tdelta, tdist, tdist2, angleDelta, interceptAngle, + quantizeFacing, facingToRad, spawnProjectile, muzzle, damageShip, gainCrew, + gainEnergy, killShip, enemyOf, emit, destroyAsteroid, shipStats, + ARENA_W, ARENA_H, FACING_STEP, +} from './StarControlLogic.js'; + +// --------------------------------------------------------------------------- +// Shared helpers. + +// Steers a projectile toward a target angle at a limited turn rate and keeps +// its velocity aligned with its facing. +function steerProjectile(p, targetAngle, turnPerFrame, speed) { + const d = angleDelta(p.facing, targetAngle); + const step = Math.max(-turnPerFrame, Math.min(turnPerFrame, d)); + p.facing += step; + p.vx = Math.cos(p.facing) * speed; + p.vy = Math.sin(p.facing) * speed; +} + +function bearingTo(from, to) { + return Math.atan2(tdelta(from.y, to.y, ARENA_H), tdelta(from.x, to.x, ARENA_W)); +} + +// Ray-vs-circle: distance along the ray (from x,y toward angle) where it first +// hits the circle, or Infinity. Toroidal-aware over the short delta. +function rayCircle(x, y, angle, cx, cy, radius) { + const dx = tdelta(x, cx, ARENA_W); + const dy = tdelta(y, cy, ARENA_H); + const ux = Math.cos(angle); + const uy = Math.sin(angle); + const proj = dx * ux + dy * uy; + if (proj < 0) return Infinity; + const perp2 = dx * dx + dy * dy - proj * proj; + const r2 = radius * radius; + if (perp2 > r2) return Infinity; + return proj - Math.sqrt(r2 - perp2); +} + +// Everything a beam/point-defense zap can hit that belongs to the enemy. +function beamTargets(state, ship) { + const targets = []; + const enemy = enemyOf(state, ship); + if (enemy.alive && !enemy.fx.phasing) targets.push({ obj: enemy, ship: true }); + for (const p of state.projectiles) { + if (p.alive && p.owner !== ship.side && p.collidesShots) targets.push({ obj: p }); + } + for (const ast of state.asteroids) { + if (ast.alive) targets.push({ obj: ast, asteroid: true }); + } + return targets; +} + +function applyBeamHit(state, ship, hit, damage) { + if (hit.ship) { + damageShip(state, hit.obj, damage, 'beam'); + } else if (hit.asteroid) { + destroyAsteroid(state, hit.obj, ship.side); + } else { + hit.obj.hp -= damage; + if (hit.obj.hp <= 0) hit.obj.alive = false; + emit(state, { t: 'shotHit', x: hit.obj.x, y: hit.obj.y, kind: 'zapped' }); + } +} + +// Instant hit-scan along `angle` up to `range`; damages the first thing hit. +// Returns the hit (or null) and emits the beam event. +function hitscan(state, ship, angle, range, damage, color) { + const targets = beamTargets(state, ship); + let best = null; + let bestDist = range; + for (const t of targets) { + const r = t.obj.radius ?? t.obj.def?.radius ?? 24; + const d = rayCircle(ship.x, ship.y, angle, t.obj.x, t.obj.y, r); + if (d < bestDist) { bestDist = d; best = { ...t, dist: d }; } + } + emit(state, { + t: 'beam', side: ship.side, + x1: ship.x, y1: ship.y, + x2: wrap(ship.x + Math.cos(angle) * bestDist, ARENA_W), + y2: wrap(ship.y + Math.sin(angle) * bestDist, ARENA_H), + color: color ?? 'laser', hit: !!best, + }); + if (best) applyBeamHit(state, ship, best, damage); + return best; +} + +// Spawned combat unit (fighter / marine / DOGI / zapsat family): a projectile +// with hp and a per-frame brain. +function spawnUnit(state, ship, opts) { + return spawnProjectile(state, ship, { + kind: 'unit', + collidesShips: false, + collidesShots: true, + gravity: false, + ...opts, + }); +} + +function unitsOf(state, side, type) { + return state.projectiles.filter((p) => p.alive && p.owner === side && p.type === type); +} + +function launchHomingMissile(state, ship, def, x, y, angle) { + // UQM tracking: every (trackWait+1) frames the missile turns one full 1/16 + // facing toward the intercept course. + const trackWait = def.trackWait ?? 3; + return spawnProjectile(state, ship, { + type: def.projectileType ?? 'missile', + x, y, angle, + speed: def.speed, + damage: def.damage, hp: def.hp ?? 1, life: def.lifeFrames, + radius: def.radius ?? 28, + data: def.data ?? {}, + behavior(st, p) { + if (st.frame % (trackWait + 1) !== 0) return; + const enemy = st.ships[1 - p.owner]; + if (!enemy.alive || enemy.fx.cloaked || enemy.fx.phasing) return; + steerProjectile(p, interceptAngle(p.x, p.y, enemy, def.speed), FACING_STEP, def.speed); + }, + }); +} + +// --------------------------------------------------------------------------- +// PRIMARY weapon types. + +export const PRIMARIES = { + + // Straight-line shot(s). Params: damage, speed, lifeFrames, hp, radius?, + // count?, spreadDeg? (fan around facing), anglesDeg? (absolute per-shot + // offsets, e.g. Pkunk [0, 120, -120]), jitterDeg? (random per shot), + // offsets? ([[fwd, side], ...] display px), addShipVelocity?, recoil? + // (units/frame kick opposite the shot), muzzleDist? (display px), + // turretArcDeg? (auto-aim at the enemy within this cone — Orz turret). + projectile: { + fire(state, ship, def) { + const count = def.count ?? (def.anglesDeg ? def.anglesDeg.length : 1); + const offsets = def.offsets ?? [[def.muzzleDist ?? 12, 0]]; + const spread = ((def.spreadDeg ?? 0) * Math.PI) / 180; + for (let i = 0; i < count; i += 1) { + const [fwd, side] = offsets[i % offsets.length]; + const m = muzzle(ship, fwd * 4, side * 4); + let ang = m.angle; + if (def.anglesDeg) ang += (def.anglesDeg[i] * Math.PI) / 180; + else if (count > 1 && spread > 0) ang += spread * (i / (count - 1) - 0.5); + if (def.jitterDeg) ang += ((state.rng() - 0.5) * 2 * def.jitterDeg * Math.PI) / 180; + if (def.turretArcDeg) { + const enemy = enemyOf(state, ship); + if (enemy.alive && !enemy.fx.cloaked) { + const want = interceptAngle(m.x, m.y, enemy, def.speed); + const off = angleDelta(m.angle, want); + const arc = (def.turretArcDeg * Math.PI) / 180; + if (Math.abs(off) <= arc) ang = want; + } + } + spawnProjectile(state, ship, { + type: def.projectileType ?? 'shot', + x: m.x, y: m.y, angle: ang, + vx: Math.cos(ang) * def.speed + (def.addShipVelocity ? ship.vx : 0), + vy: Math.sin(ang) * def.speed + (def.addShipVelocity ? ship.vy : 0), + damage: def.damage, hp: def.hp ?? 1, life: def.lifeFrames, + radius: def.radius ?? 24, + }); + } + if (def.recoil) { + const f = facingToRad(quantizeFacing(ship.facing)); + ship.vx -= Math.cos(f) * def.recoil; + ship.vy -= Math.sin(f) * def.recoil; + } + }, + }, + + // Seeking shot. Extra params: trackWait (frames between course corrections). + homing: { + fire(state, ship, def) { + const offsets = def.offsets ?? [[def.muzzleDist ?? 12, 0]]; + for (const [fwd, side] of offsets) { + const m = muzzle(ship, fwd * 4, side * 4); + launchHomingMissile(state, ship, def, m.x, m.y, m.angle); + } + }, + }, + + // Instant hit-scan. Params: range (display px), damage, autoAim? (nearest + // target in range regardless of facing), color?. + beam: { + fire(state, ship, def) { + const range = def.range * 4; + if (def.autoAim) { + const enemy = enemyOf(state, ship); + let best = null; + let bestD2 = range * range; + const candidates = []; + if (enemy.alive && !enemy.fx.phasing) candidates.push(enemy); + for (const p of state.projectiles) { + if (p.alive && p.owner !== ship.side && p.collidesShots) candidates.push(p); + } + for (const obj of candidates) { + const d2 = tdist2(ship.x, ship.y, obj.x, obj.y); + if (d2 < bestD2) { bestD2 = d2; best = obj; } + } + if (!best) { + const f = facingToRad(quantizeFacing(ship.facing)); + hitscan(state, ship, f, range, def.damage, def.color); + return; + } + hitscan(state, ship, bearingTo(ship, best), range, def.damage, def.color); + return; + } + hitscan(state, ship, facingToRad(quantizeFacing(ship.facing)), range, def.damage, def.color); + }, + }, + + // Umgah antimatter cone: a stationary damage zone held in front of the ship, + // respawned every frame while firing (CONE_LIFE 1 in UQM). Destroys shots. + // Params: damage, radius, muzzleDist (display px). + cone: { + fire(state, ship, def) { + const m = muzzle(ship, (def.muzzleDist ?? 32) * 4, 0); + spawnProjectile(state, ship, { + type: 'cone', + x: m.x, y: m.y, angle: m.angle, + vx: ship.vx, vy: ship.vy, + damage: def.damage ?? 1, hp: def.hp ?? 100, life: def.lifeFrames ?? 2, + radius: def.radius ?? 120, + }); + }, + }, + + // Melnorme pump-up blaster: first press starts a charge; holding pumps it a + // level every levelFrames (spending energyCost again); releasing fires. + // Params: speed, lifeFrames, damagePerLevel, maxLevel, levelFrames. + chargeShot: { + canFire(state, ship) { return !ship.fx.pump; }, + fire(state, ship, def) { + ship.fx.pump = { level: 0, frames: 0 }; + }, + tick(state, ship, def) { + const pump = ship.fx.pump; + if (!pump) return; + if (ship.input.fire) { + pump.frames += 1; + if (pump.frames >= (def.levelFrames ?? 72) && pump.level < (def.maxLevel ?? 4) + && ship.energy >= def.energyCost) { + ship.energy -= def.energyCost; + pump.level += 1; + pump.frames = 0; + emit(state, { t: 'pump', side: ship.side, level: pump.level }); + } + return; + } + // Released: let it fly. + const m = muzzle(ship, (def.muzzleDist ?? 24) * 4, 0); + spawnProjectile(state, ship, { + type: 'pumpshot', + x: m.x, y: m.y, angle: m.angle, + speed: def.speed, + damage: (def.damagePerLevel ?? 2) * (pump.level + 1), + hp: pump.level + 1, + life: def.lifeFrames ?? 10, + radius: 20 + pump.level * 14, + }); + emit(state, { t: 'fire', side: ship.side, weapon: 'chargeShot', sfx: def.sfxClass }); + ship.fx.pump = null; + }, + }, + + // Chenjesu photon crystal: flies while the button is held; releasing the + // button shatters every live shard into a ring of fragments. + // Params: speed, lifeFrames, damage, hp, fragment{count,speed,lifeFrames,damage}. + crystalShard: { + fire(state, ship, def) { + const m = muzzle(ship, (def.muzzleDist ?? 12) * 4, 0); + spawnProjectile(state, ship, { + type: 'shard', + x: m.x, y: m.y, angle: m.angle, + speed: def.speed, + damage: def.damage, hp: def.hp ?? 10, life: def.lifeFrames, + radius: def.radius ?? 30, + }); + }, + tick(state, ship, def) { + if (ship.input.fire) return; + const frag = def.fragment ?? {}; + for (const p of state.projectiles) { + if (!p.alive || p.owner !== ship.side || p.type !== 'shard') continue; + p.alive = false; + const n = frag.count ?? 8; + for (let i = 0; i < n; i += 1) { + const a = (i / n) * Math.PI * 2; + spawnProjectile(state, ship, { + type: 'fragment', + x: p.x, y: p.y, angle: a, + speed: frag.speed ?? 64, + damage: frag.damage ?? 2, hp: 1, life: frag.lifeFrames ?? 10, + radius: 16, + }); + } + emit(state, { t: 'shotHit', x: p.x, y: p.y, kind: 'shatter' }); + } + }, + }, + + // Kohr-Ah spinning blades: launched forward, then steered toward wherever + // the Marauder points while the button is held. + guidedBlades: { + fire(state, ship, def) { + const m = muzzle(ship, (def.muzzleDist ?? 9) * 4, 0); + spawnProjectile(state, ship, { + type: 'blade', + x: m.x, y: m.y, angle: m.angle, + speed: def.speed, + damage: def.damage, hp: def.hp ?? 10, life: def.lifeFrames, + radius: def.radius ?? 30, + behavior(st, p) { + const owner = st.ships[p.owner]; + if (!owner.alive || !owner.input.fire) return; + steerProjectile(p, facingToRad(quantizeFacing(owner.facing)), FACING_STEP / 2, def.speed); + }, + }); + }, + }, + + // Slylandro lightning: a hit-scan bolt of random length. + // Params: minRange/maxRange (display px), damage. + lightning: { + fire(state, ship, def) { + const range = (def.minRange ?? 32) + state.rng() * ((def.maxRange ?? 160) - (def.minRange ?? 32)); + hitscan(state, ship, facingToRad(quantizeFacing(ship.facing)), range * 4, def.damage, 'lightning'); + }, + }, +}; + +// --------------------------------------------------------------------------- +// SPECIAL ability types. + +export const SPECIALS = { + + // Earthling Cruiser: point-defense laser. Zaps up to params.maxTargets + // enemy projectiles in range; hits the enemy ship for the same damage if it + // is the only thing in reach. Params: range (display px), damage, maxTargets. + pointDefense: { + activate(state, ship, def) { + const range = (def.params?.range ?? 100) * 4; + const damage = def.params?.damage ?? 1; + const maxTargets = def.params?.maxTargets ?? 4; + const inRange = []; + for (const p of state.projectiles) { + if (!p.alive || p.owner === ship.side || !p.collidesShots) continue; + const d = tdist(ship.x, ship.y, p.x, p.y); + if (d <= range) inRange.push({ obj: p, dist: d }); + } + inRange.sort((a, b) => a.dist - b.dist); + let zapped = 0; + for (const t of inRange.slice(0, maxTargets)) { + applyBeamHit(state, ship, t, damage); + emit(state, { t: 'pdZap', x1: ship.x, y1: ship.y, x2: t.obj.x, y2: t.obj.y, side: ship.side }); + zapped += 1; + } + if (zapped === 0) { + const enemy = enemyOf(state, ship); + if (enemy.alive && !enemy.fx.phasing + && tdist(ship.x, ship.y, enemy.x, enemy.y) <= range + enemy.def.radius) { + damageShip(state, enemy, damage, 'beam'); + emit(state, { t: 'pdZap', x1: ship.x, y1: ship.y, x2: enemy.x, y2: enemy.y, side: ship.side }); + zapped += 1; + } + } + return zapped > 0 ? true : false; // no cost when nothing in reach + }, + }, + + // Shofixti Scout: Glory Device. Self-destruct with radius falloff damage + // (UQM: MAX_DESTRUCTION * (range - d) / range + 1 within range). + // Params: range (display px), maxDamage. + glory: { + activate(state, ship, def) { + const range = (def.params?.range ?? 180) * 4; + const maxDamage = def.params?.maxDamage ?? 18; + emit(state, { t: 'glory', x: ship.x, y: ship.y, range }); + const enemy = enemyOf(state, ship); + if (enemy.alive) { + const d = tdist(ship.x, ship.y, enemy.x, enemy.y); + if (d < range) { + const dmg = Math.floor((maxDamage * (range - d)) / range) + 1; + damageShip(state, enemy, dmg, 'glory'); + } + } + for (const p of state.projectiles) { + if (p.alive && tdist(ship.x, ship.y, p.x, p.y) < range) p.alive = false; + } + for (const ast of state.asteroids) { + if (ast.alive && tdist(ship.x, ship.y, ast.x, ast.y) < range) { + destroyAsteroid(state, ast, ship.side); + } + } + killShip(state, ship, 'glory'); + return true; + }, + }, + + // Ur-Quan Dreadnought: autonomous fighters. Each launch costs 1 crew (plus + // energy); fighters fly out, harass the enemy with a short-range laser, then + // return to dock (restoring the crew member). Params: speed, lifeFrames, + // outFrames, laserRange (display px), laserDamage, weaponWait, hp. + fighters: { + canFire(state, ship) { + return ship.crew > 1; // never launch the last crew member + }, + activate(state, ship, def) { + const p = def.params ?? {}; + ship.crew -= 1; + const m = muzzle(ship, -ship.def.radius, 0); // launch from the stern + const speed = p.speed ?? 32; + spawnUnit(state, ship, { + type: 'fighter', + x: m.x, y: m.y, + angle: m.angle + Math.PI, + speed, + damage: 0, hp: p.hp ?? 1, + life: p.lifeFrames ?? 400, + radius: p.radius ?? 36, + data: { + speed, + outFrames: p.outFrames ?? 125, + laserRange: (p.laserRange ?? 72) * 4, + laserDamage: p.laserDamage ?? 1, + weaponWait: p.weaponWait ?? 8, + cooldown: 0, + age: 0, + }, + behavior: fighterBrain, + }); + return true; + }, + }, + + // Ilwrath Avenger: cloaking device. Toggles on; firing the primary (or + // re-triggering the special) drops it. + cloak: { + activate(state, ship) { + ship.fx.cloaked = !ship.fx.cloaked; + emit(state, { t: 'cloak', side: ship.side, on: ship.fx.cloaked }); + return true; + }, + tick(state, ship) { + if (ship.fx.cloaked && ship.input.fire) { + ship.fx.cloaked = false; + emit(state, { t: 'cloak', side: ship.side, on: false }); + } + }, + }, + + // Spathi Eluder: B.U.T.T. — a homing missile launched backwards. Params + // mirror the homing primary (speed, damage, lifeFrames, trackWait, hp). + rearHoming: { + activate(state, ship, def) { + const p = def.params ?? {}; + const f = facingToRad(quantizeFacing(ship.facing)) + Math.PI; + const dist = (p.muzzleDist ?? 20) * 4; + launchHomingMissile( + state, ship, + { + speed: p.speed ?? 32, damage: p.damage ?? 2, lifeFrames: p.lifeFrames ?? 30, + trackWait: p.trackWait ?? 1, hp: p.hp ?? 1, projectileType: 'butt', + }, + wrap(ship.x + Math.cos(f) * dist, ARENA_W), + wrap(ship.y + Math.sin(f) * dist, ARENA_H), + f, + ); + return true; + }, + }, + + // Androsynth Guardian: Blazer form. Toggles into a ramming comet — fast, + // constant speed, can't shoot, drains energy (blazer degeneration); reverts + // when energy empties. Params: thrust, ramDamage. + blazer: { + activate(state, ship) { + ship.fx.blazer = !ship.fx.blazer; + ship.fx.energyRegenOverride = ship.fx.blazer ? -1 : undefined; + emit(state, { t: 'blazer', side: ship.side, on: ship.fx.blazer }); + return true; + }, + preprocess(state, ship) { + if (!ship.fx.blazer) return; + if (ship.energy <= 0) { + ship.fx.blazer = false; + ship.fx.energyRegenOverride = undefined; + emit(state, { t: 'blazer', side: ship.side, on: false }); + return; + } + const p = ship.def.special.params ?? {}; + ship.moveOverride = { + ...(ship.moveOverride ?? {}), + autoThrust: true, setVelocity: true, noFire: true, + maxThrust: p.thrust ?? 60, thrustWait: 0, + }; + }, + ramDamage(state, attacker) { + return attacker.fx.blazer ? (attacker.def.special.params?.ramDamage ?? 3) : 1; + }, + }, + + // Arilou Skiff: hyperspace shunt — random relocation, keeps velocity. + teleport: { + activate(state, ship) { + for (let tries = 0; tries < 12; tries += 1) { + const x = state.rng() * ARENA_W; + const y = state.rng() * ARENA_H; + if (tdist(x, y, state.planet.x, state.planet.y) < state.planet.radius + 500) continue; + emit(state, { t: 'teleport', side: ship.side, fromX: ship.x, fromY: ship.y, toX: x, toY: y }); + ship.x = x; ship.y = y; ship.px = x; ship.py = y; + return true; + } + return false; + }, + }, + + // Chenjesu D.O.G.I.: a spawned drone that chases the enemy and bites off + // energy on contact. Params: speed, hp, drain, maxUnits, radius. + dogi: { + canFire(state, ship, def) { + return unitsOf(state, ship.side, 'dogi').length < (ship.def.special.params?.maxUnits ?? 4); + }, + activate(state, ship, def) { + const p = def.params ?? {}; + const m = muzzle(ship, -ship.def.radius, 0); + const speed = p.speed ?? 32; + spawnUnit(state, ship, { + type: 'dogi', + x: m.x, y: m.y, angle: m.angle + Math.PI, + speed, + damage: 0, hp: p.hp ?? 3, life: 100000, + radius: p.radius ?? 48, + data: { speed, drain: p.drain ?? 10, cooldown: 0 }, + behavior(st, u) { + const enemy = st.ships[1 - u.owner]; + if (!enemy.alive) return; + if (u.data.cooldown > 0) u.data.cooldown -= 1; + steerProjectile(u, bearingTo(u, enemy), FACING_STEP / 2, u.data.speed); + if (u.data.cooldown === 0 && !enemy.fx.phasing + && tdist(u.x, u.y, enemy.x, enemy.y) < enemy.def.radius + u.radius) { + enemy.energy = Math.max(0, enemy.energy - u.data.drain); + u.data.cooldown = 24; + // Bounce off after the bite. + u.facing += Math.PI; + u.vx = Math.cos(u.facing) * u.data.speed; + u.vy = Math.sin(u.facing) * u.data.speed; + emit(state, { t: 'dogiDrain', x: u.x, y: u.y, side: u.owner }); + } + }, + }); + return true; + }, + }, + + // Chmmr Avatar: tractor beam — hold to reel the enemy in. + // Params: pull (units/frame^2), range (display px). + tractor: { + activate(state, ship, def) { + const enemy = enemyOf(state, ship); + if (!enemy.alive) return false; + const p = def.params ?? {}; + const range = (p.range ?? 1000) * 4; + if (tdist(ship.x, ship.y, enemy.x, enemy.y) > range) return false; + const b = bearingTo(enemy, ship); + const pull = p.pull ?? 6; + enemy.vx += Math.cos(b) * pull; + enemy.vy += Math.sin(b) * pull; + emit(state, { + t: 'pdZap', side: ship.side, x1: ship.x, y1: ship.y, x2: enemy.x, y2: enemy.y, + }); + return true; + }, + }, + + // Druuge Mauler: crew furnace — shove a crewman in the engine, refill the + // battery completely. + furnace: { + activate(state, ship) { + if (ship.crew <= 1) return false; + if (ship.energy >= ship.def.stats.maxEnergy) return false; + ship.crew -= 1; + ship.energy = ship.def.stats.maxEnergy; + emit(state, { t: 'furnace', side: ship.side, x: ship.x, y: ship.y }); + return true; + }, + }, + + // Kohr-Ah F.R.I.E.D.: a ring of fire that burns off incoming ordnance and + // scorches a too-close enemy. Params: range (world), frames, damage, + // damageEvery. + fireRing: { + activate(state, ship, def) { + ship.fx.friedFrames = def.params?.frames ?? 32; + emit(state, { t: 'fried', side: ship.side, x: ship.x, y: ship.y, range: def.params?.range ?? 520 }); + return true; + }, + tick(state, ship, def) { + if (!(ship.fx.friedFrames > 0)) return; + ship.fx.friedFrames -= 1; + const p = def.params ?? {}; + const range = p.range ?? 520; + for (const pr of state.projectiles) { + if (pr.alive && pr.owner !== ship.side && pr.collidesShots + && tdist(ship.x, ship.y, pr.x, pr.y) < range) { + pr.alive = false; + emit(state, { t: 'shotHit', x: pr.x, y: pr.y, kind: 'fried' }); + } + } + const enemy = enemyOf(state, ship); + if (enemy.alive && ship.fx.friedFrames % (p.damageEvery ?? 8) === 0 + && tdist(ship.x, ship.y, enemy.x, enemy.y) < range + enemy.def.radius) { + damageShip(state, enemy, p.damage ?? 1, 'fried'); + } + }, + }, + + // Melnorme confusion pulse: a fat, harmless missile that scrambles the + // enemy's steering on impact. Params: speed, lifeFrames, confuseFrames. + confuse: { + activate(state, ship, def) { + const p = def.params ?? {}; + const m = muzzle(ship, (p.muzzleDist ?? 16) * 4, 0); + spawnProjectile(state, ship, { + type: 'confuse', + x: m.x, y: m.y, angle: m.angle, + speed: p.speed ?? 120, + damage: 0, hp: p.hp ?? 200, life: p.lifeFrames ?? 20, + radius: 30, + data: { + onHitShip(st, pr, target) { + target.fx.confusedFrames = p.confuseFrames ?? 96; + emit(st, { t: 'confused', side: target.side }); + }, + }, + }); + return true; + }, + }, + + // Mmrnmhrm Transform: swap between the ship's JSON `forms` (stat + primary + // overrides). Costs the battery, as in UQM. + transform: { + activate(state, ship) { + const forms = ship.def.forms ?? []; + if (forms.length === 0) return false; + const next = ship.fx.formIndex ? 0 : 1; + ship.fx.formIndex = next; + const form = next === 0 ? null : forms[next - 1]; + ship.fx.formStats = form?.stats ?? null; + ship.fx.formPrimary = form?.primary ?? null; + ship.fx.pump = null; + emit(state, { t: 'transform', side: ship.side, form: next }); + return true; + }, + }, + + // Mycon Podship: regenerate crew from the whole battery. + // Params: crew (regeneration amount). + regenerate: { + canFire(state, ship) { return ship.crew < ship.def.stats.maxCrew; }, + activate(state, ship, def) { + gainCrew(state, ship, def.params?.crew ?? 4); + emit(state, { t: 'regenerate', side: ship.side, x: ship.x, y: ship.y }); + return true; + }, + }, + + // Orz space marines: crew sent to chew through the enemy hull. + // Params: speed, hp, maxUnits, killEvery (frames), maxKills. + marines: { + canFire(state, ship, def) { + return ship.crew > 1 + && unitsOf(state, ship.side, 'marine').length < (ship.def.special.params?.maxUnits ?? 8); + }, + activate(state, ship, def) { + const p = def.params ?? {}; + ship.crew -= 1; + const m = muzzle(ship, -ship.def.radius, 0); + const speed = p.speed ?? 24; + spawnUnit(state, ship, { + type: 'marine', + x: m.x, y: m.y, angle: m.angle + Math.PI, + speed, + damage: 0, hp: p.hp ?? 3, life: 100000, + radius: p.radius ?? 30, + data: { speed, attached: false, timer: 0, kills: 0 }, + behavior(st, u) { + const enemy = st.ships[1 - u.owner]; + if (!enemy.alive) { u.alive = false; return; } + if (u.data.attached) { + u.x = enemy.x; u.y = enemy.y; u.px = enemy.px; u.py = enemy.py; + u.vx = enemy.vx; u.vy = enemy.vy; + u.collidesShots = false; // inside the hull now + u.data.timer += 1; + if (u.data.timer >= (ship.def.special.params?.killEvery ?? 36)) { + u.data.timer = 0; + damageShip(st, enemy, 1, 'marine'); + u.data.kills += 1; + if (u.data.kills >= (ship.def.special.params?.maxKills ?? 4)) u.alive = false; + } + return; + } + steerProjectile(u, bearingTo(u, enemy), FACING_STEP / 2, u.data.speed); + if (!enemy.fx.phasing && tdist(u.x, u.y, enemy.x, enemy.y) < enemy.def.radius + u.radius) { + u.data.attached = true; + emit(st, { t: 'marineBoard', side: u.owner, x: u.x, y: u.y }); + } + }, + }); + return true; + }, + }, + + // Pkunk Fury: cathartic insults restore a little energy. + // Params: energy. + taunt: { + canFire(state, ship) { return ship.energy < ship.def.stats.maxEnergy; }, + activate(state, ship, def) { + gainEnergy(state, ship, def.params?.energy ?? 2); + emit(state, { t: 'taunt', side: ship.side }); + return true; + }, + }, + + // Slylandro Probe: consume a nearby asteroid for energy. + // Params: range (display px), energy. + harvest: { + activate(state, ship, def) { + const p = def.params ?? {}; + const range = (p.range ?? 78) * 4; + for (const ast of state.asteroids) { + if (ast.alive && tdist(ship.x, ship.y, ast.x, ast.y) < range + ast.radius) { + // bySide null: the passive contact-harvest bonus must not also fire. + destroyAsteroid(state, ast, null); + gainEnergy(state, ship, p.energy ?? 8); + emit(state, { t: 'harvest', side: ship.side, x: ast.x, y: ast.y }); + return true; + } + } + return false; + }, + }, + + // Supox Blade: omni-thrust — while held, the engine burns backwards. + omniThrust: { + activate(state, ship) { + if (!ship.input.thrust) return false; // only costs energy while burning + ship.moveOverride = { ...(ship.moveOverride ?? {}), reverseThrust: true }; + return true; + }, + }, + + // Syreen Song: lure a slice of the enemy crew overboard as floating pods + // either ship can scoop up. Params: range (display px), maxCrew, fraction. + song: { + activate(state, ship, def) { + const enemy = enemyOf(state, ship); + const p = def.params ?? {}; + if (!enemy.alive || enemy.crew <= 1) return false; + if (tdist(ship.x, ship.y, enemy.x, enemy.y) > (p.range ?? 208) * 4) return false; + const n = Math.min( + p.maxCrew ?? 8, + Math.max(1, Math.floor(enemy.crew * (p.fraction ?? 0.25))), + enemy.crew - 1, + ); + enemy.crew -= n; + emit(state, { t: 'song', side: ship.side, count: n, x: enemy.x, y: enemy.y }); + for (let i = 0; i < n; i += 1) { + const a = state.rng() * Math.PI * 2; + spawnUnit(state, ship, { + type: 'crew', + x: wrap(enemy.x + Math.cos(a) * (enemy.def.radius + 140), ARENA_W), + y: wrap(enemy.y + Math.sin(a) * (enemy.def.radius + 140), ARENA_H), + angle: a, + speed: 6 + state.rng() * 8, + damage: 0, hp: 1, life: 24 * 30, + radius: 22, + data: { age: 0 }, + behavior(st, u) { + // A short grace period so the victim can't instantly re-scoop them. + u.data.age += 1; + if (u.data.age < 24) return; + for (const s of st.ships) { + if (!s.alive || s.fx.phasing) continue; + if (tdist(u.x, u.y, s.x, s.y) < s.def.radius + u.radius + 20) { + gainCrew(st, s, 1); + u.alive = false; + emit(st, { t: 'crewRescued', side: s.side, x: u.x, y: u.y }); + return; + } + } + }, + }); + } + return true; + }, + }, + + // Thraddash Torch: afterburner — rockets forward leaving burning napalm. + // Params: thrust, thrustIncrement, napalm{lifeFrames,damage,radius}. + afterburner: { + activate(state, ship, def) { + const p = def.params ?? {}; + ship.moveOverride = { + ...(ship.moveOverride ?? {}), + autoThrust: true, thrustWait: 0, + maxThrust: p.thrust ?? 64, thrustIncrement: p.thrustIncrement ?? 12, + }; + ship.fx.burnerTick = (ship.fx.burnerTick ?? 0) + 1; + if (ship.fx.burnerTick % 2 === 0) { + const n = p.napalm ?? {}; + const m = muzzle(ship, -ship.def.radius - 20, 0); + spawnProjectile(state, ship, { + type: 'napalm', + x: m.x, y: m.y, angle: m.angle, + vx: 0, vy: 0, + damage: n.damage ?? 2, hp: 1, life: n.lifeFrames ?? 48, + radius: n.radius ?? 60, + collidesShots: false, + }); + } + return true; + }, + }, + + // Umgah Drone: retro-propulsion — instant backward hop. + // Params: dist (world units per hop). + retroHop: { + activate(state, ship, def) { + const f = facingToRad(quantizeFacing(ship.facing)); + const d = def.params?.dist ?? 160; + ship.x = wrap(ship.x - Math.cos(f) * d, ARENA_W); + ship.y = wrap(ship.y - Math.sin(f) * d, ARENA_H); + emit(state, { t: 'retro', side: ship.side, x: ship.x, y: ship.y }); + return true; + }, + }, + + // Utwig Jugger: absorption shield — while raised, damage feeds the battery. + // Params: frames (shield life per press). + absorbShield: { + activate(state, ship, def) { + ship.fx.absorbFrames = def.params?.frames ?? 12; + emit(state, { t: 'shield', side: ship.side, on: true }); + return true; + }, + tick(state, ship) { + if (ship.fx.absorbFrames > 0) ship.fx.absorbFrames -= 1; + }, + absorbDamage(state, ship, dmg) { + if (!(ship.fx.absorbFrames > 0)) return dmg; + gainEnergy(state, ship, dmg); + emit(state, { t: 'absorbed', side: ship.side, amount: dmg }); + return 0; + }, + }, + + // VUX limpets: slow homing suckers that gum up the enemy's helm (each one + // permanently worsens turning/thrust). Params: speed, lifeFrames, trackWait. + limpets: { + activate(state, ship, def) { + const p = def.params ?? {}; + const f = facingToRad(quantizeFacing(ship.facing)) + Math.PI; + launchHomingMissile( + state, ship, + { + speed: p.speed ?? 25, damage: 0, lifeFrames: p.lifeFrames ?? 80, + trackWait: p.trackWait ?? 0, hp: 1, projectileType: 'limpet', + data: { + onHitShip(st, pr, target) { + target.fx.limpets = (target.fx.limpets ?? 0) + 1; + emit(st, { t: 'limpet', side: target.side, count: target.fx.limpets }); + }, + }, + }, + wrap(ship.x + Math.cos(f) * 40, ARENA_W), + wrap(ship.y + Math.sin(f) * 40, ARENA_H), + f, + ); + return true; + }, + }, + + // Yehat Terminator: force shield — total immunity while charged. + // Params: frames (shield life per press). + forceShield: { + activate(state, ship, def) { + ship.fx.shieldFrames = def.params?.frames ?? 10; + emit(state, { t: 'shield', side: ship.side, on: true }); + return true; + }, + tick(state, ship) { + if (ship.fx.shieldFrames > 0) ship.fx.shieldFrames -= 1; + }, + absorbDamage(state, ship, dmg) { + if (!(ship.fx.shieldFrames > 0)) return dmg; + emit(state, { t: 'absorbed', side: ship.side, amount: dmg }); + return 0; + }, + }, + + // Zoq-Fot-Pik tongue: a very short-range, very heavy lick. + // Params: range (display px), damage. + tongue: { + activate(state, ship, def) { + const p = def.params ?? {}; + const hit = hitscan( + state, ship, + facingToRad(quantizeFacing(ship.facing)), + (p.range ?? 35) * 4 + ship.def.radius, + p.damage ?? 12, + 'tongue', + ); + return hit ? true : false; // free lick when it misses + }, + }, +}; + +function fighterBrain(state, p) { + const d = p.data; + d.age += 1; + if (d.cooldown > 0) d.cooldown -= 1; + const mother = state.ships[p.owner]; + const enemy = state.ships[1 - p.owner]; + const returning = d.age > d.outFrames || !enemy.alive || p.life < d.outFrames; + const target = returning ? mother : enemy; + if (!target || !target.alive) return; + steerProjectile(p, bearingTo(p, target), FACING_STEP / 2, d.speed); + if (returning) { + if (mother.alive && tdist(p.x, p.y, mother.x, mother.y) < mother.def.radius + 80) { + p.alive = false; + gainCrew(state, mother, 1); + emit(state, { t: 'fighterDock', side: p.owner, x: p.x, y: p.y }); + } + return; + } + if (!enemy.fx.cloaked && !enemy.fx.phasing && d.cooldown === 0 + && tdist(p.x, p.y, enemy.x, enemy.y) < d.laserRange + enemy.def.radius) { + d.cooldown = d.weaponWait + 1; + damageShip(state, enemy, d.laserDamage, 'beam'); + emit(state, { t: 'pdZap', x1: p.x, y1: p.y, x2: enemy.x, y2: enemy.y, side: p.owner }); + } +} + +// --------------------------------------------------------------------------- +// PASSIVE abilities (always-on ship traits). + +export const PASSIVES = { + + // Pkunk Fury: fifty-fifty phoenix resurrection with full crew and battery. + resurrect: { + onDeath(state, ship) { + if (state.rng() >= 0.5) return false; + ship.crew = ship.def.stats.maxCrew; + ship.energy = ship.def.stats.maxEnergy; + return true; + }, + }, + + // Arilou Skiff: inertialess drive — stops dead without thrust, ignores + // gravity. + inertialess: { + init(state, ship) { ship.fx.noGravity = true; }, + preprocess(state, ship) { + if (!ship.input.thrust) { ship.vx = 0; ship.vy = 0; } + }, + }, + + // Slylandro Probe: gravity-immune constant-velocity drive — always moving + // at full speed along its facing. + probeDrive: { + init(state, ship) { ship.fx.noGravity = true; }, + preprocess(state, ship) { + const f = facingToRad(quantizeFacing(ship.facing)); + const v = shipStats(ship).maxThrust; + ship.vx = Math.cos(f) * v; + ship.vy = Math.sin(f) * v; + ship.moveOverride = { ...(ship.moveOverride ?? {}), noThrust: true }; + }, + onAsteroidKill(state, ship) { + gainEnergy(state, ship, ship.def.special?.params?.energy ?? 8); + }, + }, + + // Chmmr Avatar ZapSats: three orbiting satellites that zap whatever strays + // close (enemy shots first, then the enemy ship). + zapsats: { + init(state, ship) { + const cfg = ship.def.zapsats ?? {}; + const n = cfg.count ?? 3; + for (let i = 0; i < n; i += 1) { + spawnUnit(state, ship, { + type: 'zapsat', + x: ship.x, y: ship.y, angle: 0, speed: 0, + damage: 0, hp: cfg.hp ?? 10, life: 100000, + radius: 26, + data: { + orbit: (i / n) * Math.PI * 2, + orbitR: cfg.orbitR ?? ship.def.radius + 190, + zapRange: (cfg.zapRange ?? 64) * 4, + zapWait: cfg.zapWait ?? 2, + cooldown: 0, + }, + behavior(st, u) { + const mother = st.ships[u.owner]; + if (!mother.alive) { u.alive = false; return; } + u.data.orbit += 0.045; + const nx = wrap(mother.x + Math.cos(u.data.orbit) * u.data.orbitR, ARENA_W); + const ny = wrap(mother.y + Math.sin(u.data.orbit) * u.data.orbitR, ARENA_H); + u.px = u.x; u.py = u.y; + u.x = nx; u.y = ny; u.vx = 0; u.vy = 0; + if (u.data.cooldown > 0) { u.data.cooldown -= 1; return; } + for (const pr of st.projectiles) { + if (pr.alive && pr.owner !== u.owner && pr.collidesShots && pr.kind !== 'unit' + && tdist(u.x, u.y, pr.x, pr.y) < u.data.zapRange) { + pr.hp -= 1; + if (pr.hp <= 0) pr.alive = false; + u.data.cooldown = u.data.zapWait + 1; + emit(st, { t: 'pdZap', x1: u.x, y1: u.y, x2: pr.x, y2: pr.y, side: u.owner }); + return; + } + } + const enemy = st.ships[1 - u.owner]; + if (enemy.alive && !enemy.fx.phasing + && tdist(u.x, u.y, enemy.x, enemy.y) < u.data.zapRange + enemy.def.radius) { + damageShip(st, enemy, 1, 'beam'); + u.data.cooldown = u.data.zapWait + 1; + emit(st, { t: 'pdZap', x1: u.x, y1: u.y, x2: enemy.x, y2: enemy.y, side: u.owner }); + } + }, + }); + } + }, + }, + + // VUX Intruder: warps in right on top of its prey at the start of a battle. + warpIn: { + preprocess(state, ship) { + if (ship.fx.warped || state.frame > 2) { ship.fx.warped = true; return; } + ship.fx.warped = true; + const enemy = enemyOf(state, ship); + const a = state.rng() * Math.PI * 2; + const d = 900; + ship.x = wrap(enemy.x + Math.cos(a) * d, ARENA_W); + ship.y = wrap(enemy.y + Math.sin(a) * d, ARENA_H); + ship.px = ship.x; ship.py = ship.y; + ship.facing = bearingTo(ship, enemy); + emit(state, { t: 'teleport', side: ship.side, toX: ship.x, toY: ship.y }); + }, + }, +}; diff --git a/src/main.js b/src/main.js index cee50df..cfa7cec 100644 --- a/src/main.js +++ b/src/main.js @@ -90,6 +90,7 @@ import BalatroGame from './games/balatro/BalatroGame.js'; import PeggleGame from './games/peggle/PeggleGame.js'; import PeggleEditor from './games/peggle/PeggleEditor.js'; import ColoradoDefenseGame from './games/coloradodefense/ColoradoDefenseGame.js'; +import StarControlGame from './games/starcontrol/StarControlGame.js'; const config = { type: Phaser.AUTO, @@ -193,6 +194,7 @@ const config = { PeggleGame, PeggleEditor, ColoradoDefenseGame, + StarControlGame, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index 39fda0e..2a49292 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' }; + 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' }; 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 7546030..43341de 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -61,6 +61,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('balatro-artwork', 'data/balatro-artwork.json'); this.load.json('peggle-levels', 'assets/gamedata/peggle/levels.json'); this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.json'); + this.load.json('star-control-ships', 'data/star-control-ships.json'); this.load.audio('sfx-water-splash', 'assets/fx/water-splash.mp3'); this.load.audio('sfx-water-sink', 'assets/fx/water-sink.mp3'); diff --git a/tools/fetchStarControlAssets.js b/tools/fetchStarControlAssets.js new file mode 100644 index 0000000..41f8b8c --- /dev/null +++ b/tools/fetchStarControlAssets.js @@ -0,0 +1,180 @@ +// One-time asset/codegen fetcher for Star Control Super Melee. +// +// node tools/fetchStarControlAssets.js --ships # download + build ship rotation sheets +// node tools/fetchStarControlAssets.js --stats # print UQM stat blocks for manual review +// +// Downloads ship art and stats from the Ur-Quan Masters GitHub mirror +// (juj/sc2-uqm). UQM content is CC BY-NC-SA 2.5 — an attribution line ships in +// data/star-control-ships.json and on the game's setup screen. This script is +// never run by the site; it needs network access plus ImageMagick (`magick`). +// +// --ships: for each ship, the GitHub contents API lists +// sc2/content/base/ships//, the 16 `-big-000..015.png` rotation +// frames are downloaded, montaged into a 16x1 spritesheet at +// assets/images/starcontrol/sc-ship-.png, and the measured frame +// dimensions are written into that ship's `sprite` block in +// data/star-control-ships.json (only the sprite block is touched). + +import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const REPO = 'juj/sc2-uqm'; +const CONTENT = 'sc2/content/base/ships'; +const SRC = 'sc2/src/uqm/ships'; +const OUT_DIR = join(root, 'assets/images/starcontrol'); +const JSON_PATH = join(root, 'data/star-control-ships.json'); + +// slug (our JSON key) -> UQM content directory. Frame prefixes are discovered, +// not guessed (e.g. human/cruiser-big-000.png). +const CONTENT_DIRS = { + androsynth: 'androsynth', arilou: 'arilou', chenjesu: 'chenjesu', chmmr: 'chmmr', + druuge: 'druuge', earthling: 'human', ilwrath: 'ilwrath', kohrah: 'kohrah', + melnorme: 'melnorme', mmrnmhrm: 'mmrnmhrm', mycon: 'mycon', orz: 'orz', + pkunk: 'pkunk', shofixti: 'shofixti', slylandro: 'slylandro', spathi: 'spathi', + supox: 'supox', syreen: 'syreen', thraddash: 'thraddash', umgah: 'umgah', + urquan: 'urquan', utwig: 'utwig', vux: 'vux', yehat: 'yehat', zoqfot: 'zoqfotpik', +}; + +// slug -> the rotation-frame prefix that is the SHIP (several directories +// also hold 16-frame weapon animations like spathi's "butt-big-000..015"). +const SHIP_BASE = { + androsynth: 'guardian', arilou: 'skiff', chenjesu: 'broodhome', chmmr: 'avatar', + druuge: 'mauler', earthling: 'cruiser', ilwrath: 'avenger', kohrah: 'marauder', + melnorme: 'trader', mmrnmhrm: 'xform', mycon: 'podship', orz: 'nemesis', + pkunk: 'fury', shofixti: 'scout', slylandro: 'probe', spathi: 'eluder', + supox: 'blade', syreen: 'penetrator', thraddash: 'torch', umgah: 'drone', + urquan: 'dreadnought', utwig: 'jugger', vux: 'intruder', yehat: 'terminator', + zoqfot: 'stinger', +}; + +// slug -> UQM source directory for --stats. +const SRC_DIRS = { + androsynth: 'androsyn', arilou: 'arilou', chenjesu: 'chenjesu', chmmr: 'chmmr', + druuge: 'druuge', earthling: 'human', ilwrath: 'ilwrath', kohrah: 'blackurq', + melnorme: 'melnorme', mmrnmhrm: 'mmrnmhrm', mycon: 'mycon', orz: 'orz', + pkunk: 'pkunk', shofixti: 'shofixti', slylandro: 'slylandr', spathi: 'spathi', + supox: 'supox', syreen: 'syreen', thraddash: 'thradd', umgah: 'umgah', + urquan: 'urquan', utwig: 'utwig', vux: 'vux', yehat: 'yehat', zoqfot: 'zoqfot', +}; + +async function ghJson(path) { + const res = await fetch(`https://api.github.com/repos/${REPO}/contents/${path}`, { + headers: { 'User-Agent': 'fertig-classic-games-asset-fetch' }, + }); + if (!res.ok) throw new Error(`GitHub API ${res.status} for ${path}`); + return res.json(); +} + +async function download(url, dest) { + const res = await fetch(url); + if (!res.ok) throw new Error(`download ${res.status} for ${url}`); + writeFileSync(dest, Buffer.from(await res.arrayBuffer())); +} + +// Finds the rotation-frame prefix: a group of exactly 16 files named +// `-big-000.png` .. `-big-015.png` (weapon/captain art doesn't +// match this shape; e.g. human's saturn-big has 23 frames and is skipped). +function findRotationGroup(listing, preferredBase) { + const groups = {}; + for (const f of listing) { + const m = /^(.+)-big-(\d{3})\.png$/.exec(f.name); + if (!m) continue; + (groups[m[1]] ??= []).push({ n: Number(m[2]), url: f.download_url }); + } + const candidates = Object.entries(groups) + .filter(([, frames]) => frames.length === 16 + && Math.min(...frames.map((fr) => fr.n)) === 0 + && Math.max(...frames.map((fr) => fr.n)) === 15); + if (candidates.length === 0) return null; + let picked = candidates.find(([base]) => base === preferredBase); + if (!picked) { + console.warn(` preferred base "${preferredBase}" not found; candidates: ${candidates.map(([k]) => k).join(', ')}`); + [picked] = candidates; + } + const [base, frames] = picked; + frames.sort((a, b) => a.n - b.n); + return { base, frames }; +} + +async function buildSheet(slug, group, suffix = '') { + const tmp = join(tmpdir(), `sc-fetch-${slug}${suffix}`); + rmSync(tmp, { recursive: true, force: true }); + mkdirSync(tmp, { recursive: true }); + const files = []; + for (const fr of group.frames) { + const dest = join(tmp, `f-${String(fr.n).padStart(3, '0')}.png`); + await download(fr.url, dest); + files.push(dest); + } + const sheetName = `sc-ship-${slug}${suffix}`; + const out = join(OUT_DIR, `${sheetName}.png`); + execFileSync('magick', ['montage', ...files, '-tile', '16x1', '-geometry', '+0+0', '-background', 'none', out]); + const dims = execFileSync('magick', ['identify', '-format', '%w %h', files[0]]).toString().trim().split(' '); + rmSync(tmp, { recursive: true, force: true }); + return { sheet: sheetName, frames: 16, frameWidth: Number(dims[0]), frameHeight: Number(dims[1]) }; +} + +async function fetchShips() { + mkdirSync(OUT_DIR, { recursive: true }); + const json = JSON.parse(readFileSync(JSON_PATH, 'utf8')); + const results = []; + for (const [slug, dir] of Object.entries(CONTENT_DIRS)) { + if (!json.ships[slug]) { console.warn(`skip ${slug}: not in ships JSON`); continue; } + process.stdout.write(`${slug} (${dir}) ... `); + try { + const listing = await ghJson(`${CONTENT}/${dir}`); + const group = findRotationGroup(listing, SHIP_BASE[slug]); + if (!group) { console.log('NO 16-frame rotation group found'); continue; } + json.ships[slug].sprite = await buildSheet(slug, group); + console.log(`ok (${group.base}, ${json.ships[slug].sprite.frameWidth}x${json.ships[slug].sprite.frameHeight})`); + // Mmrnmhrm's alternate Y-Wing form has its own rotation frames. + if (slug === 'mmrnmhrm' && json.ships[slug].forms?.[0]) { + const yGroup = findRotationGroup(listing, 'ywing'); + if (yGroup?.base === 'ywing') { + json.ships[slug].forms[0].sprite = await buildSheet(slug, yGroup, '-y'); + console.log(` + y-form sheet (${json.ships[slug].forms[0].sprite.frameWidth}x${json.ships[slug].forms[0].sprite.frameHeight})`); + } + } + results.push(slug); + } catch (e) { + console.log(`FAILED: ${e.message}`); + } + } + writeFileSync(JSON_PATH, `${JSON.stringify(json, null, 2)}\n`); + console.log(`\n${results.length}/${Object.keys(CONTENT_DIRS).length} sheets built; sprite blocks written to data/star-control-ships.json`); +} + +const STAT_DEFINES = [ + 'MAX_CREW', 'MAX_ENERGY', 'ENERGY_REGENERATION', 'ENERGY_WAIT', 'MAX_THRUST', + 'THRUST_INCREMENT', 'THRUST_WAIT', 'TURN_WAIT', 'SHIP_MASS', + 'WEAPON_ENERGY_COST', 'WEAPON_WAIT', 'SPECIAL_ENERGY_COST', 'SPECIAL_WAIT', + 'MISSILE_SPEED', 'MISSILE_LIFE', 'MISSILE_HITS', 'MISSILE_DAMAGE', +]; + +// Prints raw #define blocks for manual review/merge — NOT written to the JSON +// (several ships encode stats in code: Chmmr zapsats, Slylandro drive, +// Mmrnmhrm forms). +async function fetchStats() { + for (const [slug, dir] of Object.entries(SRC_DIRS)) { + const url = `https://raw.githubusercontent.com/${REPO}/master/${SRC}/${dir}/${dir}.c`; + const res = await fetch(url); + if (!res.ok) { console.log(`${slug}: fetch failed ${res.status}`); continue; } + const text = await res.text(); + const out = {}; + for (const name of STAT_DEFINES) { + const m = new RegExp(`^#define\\s+${name}\\s+(.+)$`, 'm').exec(text); + if (m) out[name] = m[1].replace(/\/\*.*?\*\//g, '').trim(); + } + console.log(`\n=== ${slug} (${dir}.c)`); + console.log(JSON.stringify(out, null, 2)); + } +} + +const args = process.argv.slice(2); +if (args.includes('--stats')) await fetchStats(); +else if (args.includes('--ships')) await fetchShips(); +else console.log('usage: node tools/fetchStarControlAssets.js --ships | --stats'); diff --git a/tools/verifyStarControl.js b/tools/verifyStarControl.js new file mode 100644 index 0000000..839b00f --- /dev/null +++ b/tools/verifyStarControl.js @@ -0,0 +1,730 @@ +// Headless verification for Star Control Super Melee. +// node tools/verifyStarControl.js +// Exits non-zero on any failure. +// +// 1. Ship JSON schema: every entry compiles, every type resolves in the +// behavior registries, stats in sane bounds. +// 2. Toroidal math (wrap/tdelta/tmid) spot checks. +// 3. Determinism: same seed + same input script => identical state hash. +// 4. Fuzz invariants: energy/crew always in [0, max] under random inputs. +// 5. Physics fixtures: thrust cap, gravity pull, planet bounce + damage. +// 6. Weapon fixtures per pilot ship (hit registration + exact damage), +// special fixtures (point defense, glory falloff, fighters launch/dock, +// cloak vs homing, rear B.U.T.T.). +// 7. Smooth-zoom mapping: monotonic, anchored at the three UQM-style levels. + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { + STEP_MS, ARENA_W, ARENA_H, TUNE, ZOOM, + mulberry32, wrap, tdelta, tdist, tmid, quantizeFacing, angleDelta, + compileShips, createBattle, setInput, stepMatch, + targetZoom, smoothTowards, interceptAngle, +} from '../src/games/starcontrol/StarControlLogic.js'; +import { PRIMARIES, SPECIALS, PASSIVES } from '../src/games/starcontrol/StarControlShips.js'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const shipsJson = JSON.parse(readFileSync(join(root, 'data/star-control-ships.json'), 'utf8')); + +let failures = 0; +let passes = 0; +function check(name, cond, detail = '') { + if (cond) { passes += 1; return; } + failures += 1; + console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); +} +function section(name) { console.log(`\n== ${name}`); } + +// Steps whole 24fps frames deterministically (no accumulator drift in tests). +function stepFrames(state, n, collect = false) { + const events = []; + for (let i = 0; i < n; i += 1) { + const evs = stepMatch(state, STEP_MS); + if (collect) events.push(...evs); + if (state.over) break; + } + return events; +} + +const SHIPS = compileShips(shipsJson); +const keys = Object.keys(SHIPS); + +function battle(aKey, bKey, seed = 7) { + const st = createBattle({ a: { def: SHIPS[aKey] }, b: { def: SHIPS[bKey] }, seed }); + st.asteroids = []; // fixtures don't want random rocks in the way + return st; +} + +function place(ship, x, y, facing = 0, vx = 0, vy = 0) { + ship.x = wrap(x, ARENA_W); ship.y = wrap(y, ARENA_H); + ship.px = ship.x; ship.py = ship.y; + ship.facing = facing; ship.vx = vx; ship.vy = vy; +} + +// --------------------------------------------------------------------------- +section('1. ship JSON schema'); +check('ships JSON has entries', keys.length > 0); +check('credits line present (CC BY-NC-SA attribution)', /CC BY-NC-SA/.test(shipsJson.credits ?? '')); +for (const key of keys) { + const def = SHIPS[key]; + check(`${key}: primary type registered`, !!PRIMARIES[def.primary.type], def.primary.type); + check(`${key}: special type registered`, !!SPECIALS[def.special.type], def.special.type); + for (const p of def.passives) check(`${key}: passive registered`, !!PASSIVES[p], p); + check(`${key}: crew/energy sane`, def.stats.maxCrew >= 1 && def.stats.maxCrew <= 42 + && def.stats.maxEnergy >= 1 && def.stats.maxEnergy <= 42); + check(`${key}: point value sane`, def.pointValue >= 1 && def.pointValue <= 30); + check(`${key}: has ai hints`, !!def.ai && typeof def.ai.preferredRange === 'number'); + check(`${key}: radius derived`, def.radius > 0); + check(`${key}: turn rate derived`, def.turnPerFrame > 0); +} +check('bad ship def throws (named reason)', (() => { + try { compileShips({ ships: { junk: { name: 'X', team: 'alliance', pointValue: 1, stats: {}, primary: { type: 'nope' }, special: { type: 'nope' } } } }); return false; } + catch (e) { return /junk/.test(e.message); } +})()); + +// --------------------------------------------------------------------------- +section('2. toroidal math'); +check('wrap into range', wrap(-10, ARENA_W) === ARENA_W - 10 && wrap(ARENA_W + 5, ARENA_W) === 5); +check('tdelta short way', tdelta(100, ARENA_W - 100, ARENA_W) === -200); +check('tdelta antisymmetry', tdelta(500, 4000, ARENA_W) === -tdelta(4000, 500, ARENA_W)); +{ + const m = tmid(ARENA_W - 100, 100, 100, 100); + check('tmid crosses seam', m.x === 0 || m.x === ARENA_W, `got ${m.x}`); +} +check('angleDelta wraps', Math.abs(angleDelta(0.1, Math.PI * 2 - 0.1) + 0.2) < 1e-9); +check('quantizeFacing wraps negatives', quantizeFacing(-0.01) === 0 && quantizeFacing(Math.PI) === 8); + +// --------------------------------------------------------------------------- +section('3. determinism'); +{ + const hashOf = (st) => st.ships.map((s) => [ + s.x.toFixed(4), s.y.toFixed(4), s.vx.toFixed(4), s.vy.toFixed(4), + s.facing.toFixed(4), s.crew, s.energy, + ].join(',')).join('|') + `#${st.projectiles.length}`; + const run = () => { + const st = createBattle({ a: { def: SHIPS.earthling }, b: { def: SHIPS.spathi }, seed: 42 }); + const rng = mulberry32(99); + let acc = ''; + for (let f = 0; f < 2000 && !st.over; f += 1) { + for (const side of [0, 1]) { + setInput(st, side, { + left: rng() < 0.2, right: rng() < 0.2, thrust: rng() < 0.5, + fire: rng() < 0.3, special: rng() < 0.1, + }); + } + stepMatch(st, STEP_MS); + if (f % 100 === 0) acc += hashOf(st); + } + return acc; + }; + check('same seed + inputs => identical trace', run() === run()); +} + +// --------------------------------------------------------------------------- +section('4. fuzz invariants (energy/crew bounds, positions in-arena)'); +for (const key of keys) { + const st = createBattle({ a: { def: SHIPS[key] }, b: { def: SHIPS.urquan }, seed: 11 }); + const rng = mulberry32(1234); + let ok = true; + let detail = ''; + for (let f = 0; f < 5000 && !st.over; f += 1) { + for (const side of [0, 1]) { + setInput(st, side, { + left: rng() < 0.25, right: rng() < 0.25, thrust: rng() < 0.5, + fire: rng() < 0.35, special: rng() < 0.15, + }); + } + stepMatch(st, STEP_MS); + for (const s of st.ships) { + if (s.energy < 0 || s.energy > s.def.stats.maxEnergy) { ok = false; detail = `${s.def.key} energy ${s.energy}`; } + if (s.crew < 0 || s.crew > s.def.stats.maxCrew) { ok = false; detail = `${s.def.key} crew ${s.crew}`; } + if (s.x < 0 || s.x >= ARENA_W || s.y < 0 || s.y >= ARENA_H) { ok = false; detail = `${s.def.key} pos ${s.x},${s.y}`; } + if (!Number.isFinite(s.x + s.y + s.vx + s.vy + s.facing)) { ok = false; detail = `${s.def.key} NaN`; } + } + if (!ok) break; + } + check(`fuzz ${key} vs urquan`, ok, detail); +} + +// --------------------------------------------------------------------------- +section('5. physics fixtures'); +{ + // Thrust alone never exceeds maxThrust. + const st = battle('shofixti', 'urquan'); + const savedG = TUNE.PLANET_GRAVITY; + TUNE.PLANET_GRAVITY = 0; + const A = st.ships[0]; + place(A, 1000, 1000, 0); + setInput(st, 0, { thrust: true }); + stepFrames(st, 200); + const speed = Math.hypot(A.vx, A.vy); + check('thrust reaches maxThrust', Math.abs(speed - A.def.stats.maxThrust) < 0.6, `speed ${speed}`); + stepFrames(st, 100); + check('thrust never exceeds maxThrust', Math.hypot(A.vx, A.vy) <= A.def.stats.maxThrust + 1e-6); + TUNE.PLANET_GRAVITY = savedG; +} +{ + // Gravity pulls a coasting ship toward the planet. + const st = battle('earthling', 'urquan'); + const A = st.ships[0]; + place(A, ARENA_W / 2 - 1500, ARENA_H / 2, 0); + const d0 = tdist(A.x, A.y, ARENA_W / 2, ARENA_H / 2); + stepFrames(st, 30); + const d1 = tdist(A.x, A.y, ARENA_W / 2, ARENA_H / 2); + check('gravity pulls ship planet-ward', d1 < d0, `${d0} -> ${d1}`); +} +{ + // Planet impact costs crew (once per cooldown window) and ejects the ship. + const st = battle('urquan', 'earthling'); + const A = st.ships[0]; + place(A, ARENA_W / 2 - 600, ARENA_H / 2, 0, 60, 0); + const crew0 = A.crew; + let bounced = false; + for (let f = 0; f < 30 && !bounced; f += 1) { + bounced = stepFrames(st, 1, true).some((e) => e.t === 'planetBounce'); + } + stepFrames(st, 3); + check('planetBounce event emitted', bounced); + check('planet bounce damages crew once', A.crew === crew0 - TUNE.PLANET_CREW_DAMAGE, `crew ${A.crew}`); + const d = tdist(A.x, A.y, ARENA_W / 2, ARENA_H / 2); + check('ship ejected from planet', d > st.planet.radius, `d ${d}`); +} +{ + // Ship-ship collision: both take a knock at speed, momentum pushes apart. + const st = battle('urquan', 'earthling'); + const [A, B] = st.ships; + const savedG = TUNE.PLANET_GRAVITY; + TUNE.PLANET_GRAVITY = 0; + place(A, 3000, 1000, 0, 80, 0); + place(B, 3000 + A.def.radius + B.def.radius + 90, 1000, Math.PI, -80, 0); + const crews = [A.crew, B.crew]; + stepFrames(st, 20); + check('collision damages both ships', A.crew < crews[0] && B.crew < crews[1], `${A.crew},${B.crew}`); + check('collision separates ships', tdist(A.x, A.y, B.x, B.y) >= A.def.radius + B.def.radius - 1); + TUNE.PLANET_GRAVITY = savedG; +} + +// --------------------------------------------------------------------------- +section('6. weapon + special fixtures'); +const savedG2 = TUNE.PLANET_GRAVITY; +TUNE.PLANET_GRAVITY = 0; // keep fixtures geometric + +{ + // Every pilot primary lands on a stationary hulk dead ahead, placed inside + // that weapon's actual reach (flamethrowers are short, nukes are long). + for (const key of keys) { + const st = battle(key, key === 'urquan' ? 'earthling' : 'urquan'); + const [A, B] = st.ships; + const pd = A.def.primary; + const reach = pd.type === 'beam' + ? pd.range * 4 + : (pd.muzzleDist ?? 12) * 4 + (pd.speed ?? 0) * (pd.lifeFrames ?? 0); + const dist = Math.min(600, Math.max(220, reach * 0.6)); + place(A, 2000, 3000, 0); + place(B, 2000 + dist, 3000, Math.PI); + A.fx.warped = true; B.fx.warped = true; // suppress VUX-style warp-in repositioning + const crew0 = B.crew; + setInput(st, 0, { fire: true }); + stepFrames(st, 90); + setInput(st, 0, { fire: false }); // charge-shot weapons launch on release + stepFrames(st, 40); + check(`${key} primary hits`, B.crew < crew0, `crew still ${B.crew} at dist ${dist}`); + } +} +{ + // Exact damage: one Ur-Quan fusion bolt = 6 crew. + const st = battle('urquan', 'earthling'); + const [A, B] = st.ships; + place(A, 2000, 3000, 0); + place(B, 2900, 3000, Math.PI); + setInput(st, 0, { fire: true }); + stepFrames(st, 1); + setInput(st, 0, { fire: false }); + stepFrames(st, 40); + check('fusion bolt exact damage', B.crew === B.def.stats.maxCrew - 6, `crew ${B.crew}`); +} +{ + // Homing: nuke curves onto a target off boresight; cloak defeats tracking. + const run = (cloaked) => { + const st = battle('earthling', 'ilwrath'); + const [A, B] = st.ships; + place(A, 2000, 3000, 0); + place(B, 2000 + 1200, 3000 - 900, 0); + if (cloaked) B.fx.cloaked = true; + setInput(st, 0, { fire: true }); + stepFrames(st, 2); + setInput(st, 0, { fire: false }); + stepFrames(st, 70); + return B.crew < B.def.stats.maxCrew; + }; + check('nuke tracks an off-axis target', run(false)); + check('cloak defeats nuke tracking', !run(true)); +} +{ + // Point defense: zaps an incoming missile before impact, costs energy. + const st = battle('earthling', 'spathi'); + const [A, B] = st.ships; + place(A, 2000, 3000, 0); + place(B, 2000, 3000 + 260, -Math.PI / 2); + // Spathi B.U.T.T. fired away from us won't reach; instead have Spathi shoot at us. + place(B, 2600, 3000, Math.PI); + setInput(st, 1, { fire: true }); + stepFrames(st, 3); + setInput(st, 1, { fire: false }); + const e0 = A.energy; + let zapped = false; + for (let f = 0; f < 30 && !zapped; f += 1) { + setInput(st, 0, { special: true }); + zapped = stepMatch(st, STEP_MS).some((e) => e.t === 'pdZap'); + } + check('point defense zaps incoming fire', zapped); + check('point defense costs energy', A.energy < e0, `${e0} -> ${A.energy}`); + check('shot never landed', A.crew === A.def.stats.maxCrew); +} +{ + // Glory device: falloff damage, kills the scout, ends the battle. + const dmgAt = (dist) => { + const st = battle('shofixti', 'urquan'); + const [A, B] = st.ships; + place(A, 2000, 3000, 0); + place(B, 2000 + dist, 3000, Math.PI); + setInput(st, 0, { special: true }); + stepFrames(st, 3); + return { dmg: B.def.stats.maxCrew - B.crew, st, A }; + }; + const near = dmgAt(200); + const far = dmgAt(650); + const out = dmgAt(1000); + check('glory kills the scout', !near.A.alive); + check('glory near > far damage', near.dmg > far.dmg, `${near.dmg} vs ${far.dmg}`); + check('glory out of range = 0', out.dmg === 0, `${out.dmg}`); + const expect = Math.floor((18 * (720 - 200)) / 720) + 1; + check('glory matches UQM falloff formula', near.dmg === expect, `${near.dmg} vs ${expect}`); +} +{ + // Fighters: launch costs a crew member, they harass, then dock and return it. + const st = battle('urquan', 'earthling'); + const [A, B] = st.ships; + place(A, 2000, 3000, 0); + place(B, 2000 + 1500, 3000, Math.PI); + setInput(st, 0, { special: true }); + stepFrames(st, 1); + setInput(st, 0, { special: false }); + check('fighter launched', st.projectiles.some((p) => p.type === 'fighter')); + check('launch costs 1 crew', A.crew === A.def.stats.maxCrew - 1); + const crewB0 = B.crew; + const evs = stepFrames(st, 500, true); + check('fighter shot at the enemy', B.crew < crewB0 || evs.some((e) => e.t === 'pdZap')); + check('fighter docked home (crew restored)', A.crew === A.def.stats.maxCrew + || evs.some((e) => e.t === 'fighterDock'), `crew ${A.crew}`); +} +{ + // B.U.T.T.: rear-launched missile homes onto a pursuer behind the Eluder. + const st = battle('spathi', 'urquan'); + const [A, B] = st.ships; + place(A, 3000, 3000, 0, 20, 0); + place(B, 3000 - 700, 3000, 0, 20, 0); // chasing from behind + const crew0 = B.crew; + setInput(st, 0, { special: true }); + stepFrames(st, 1); + setInput(st, 0, { special: false }); + stepFrames(st, 40); + check('B.U.T.T. hits the pursuer', B.crew === crew0 - 2, `crew ${B.crew}`); +} +{ + // Asteroids block shots and shatter. + const st = battle('spathi', 'urquan'); + const [A] = st.ships; + place(A, 2000, 3000, 0); + place(st.ships[1], 6000, 6000, 0); + st.asteroids.push({ + id: 9999, kind: 'asteroid', x: 2400, y: 3000, px: 2400, py: 3000, + vx: 0, vy: 0, spin: 0, rot: 0, radius: TUNE.ASTEROID_RADIUS, alive: true, + }); + setInput(st, 0, { fire: true }); + const evs = stepFrames(st, 10, true); + check('asteroid shattered by shot', evs.some((e) => e.t === 'asteroidBreak')); +} +{ + // Battle-over bookkeeping: winner declared once, loser reported. + const st = battle('shofixti', 'urquan'); + const [A, B] = st.ships; + place(A, 2000, 3000, 0); + place(B, 2150, 3000, Math.PI); + setInput(st, 0, { special: true }); // glory at point blank vs 42-crew hulk: scout dies + const evs = stepFrames(st, 5, true); + check('battleOver emitted', evs.some((e) => e.t === 'battleOver')); + check('winner is the dreadnought', st.winner === 1, `winner ${st.winner}`); + check('death event for scout', evs.some((e) => e.t === 'death' && e.side === 0)); +} +// --------------------------------------------------------------------------- +section('6b. special-ability fixtures (full roster)'); +{ + // Teleport relocates the Skiff. + const st = battle('arilou', 'urquan'); + const A = st.ships[0]; + place(A, 2000, 3000, 0); + const x0 = A.x; + setInput(st, 0, { special: true }); + stepFrames(st, 2); + check('teleport relocates', tdist(A.x, A.y, x0, 3000) > 400 || A.x !== x0); +} +{ + // Yehat force shield blocks a fusion bolt outright. + const st = battle('yehat', 'urquan'); + const [A, B] = st.ships; + place(A, 2900, 3000, 0); + place(B, 2000, 3000, 0); + A.fx.shieldFrames = 200; + setInput(st, 1, { fire: true }); + stepFrames(st, 1); + setInput(st, 1, { fire: false }); + stepFrames(st, 40); + check('force shield blocks damage', A.crew === A.def.stats.maxCrew, `crew ${A.crew}`); +} +{ + // Utwig absorption shield converts damage into battery charge. + const st = battle('utwig', 'urquan'); + const [A, B] = st.ships; + place(A, 2900, 3000, 0); + place(B, 2000, 3000, 0); + A.energy = 2; + A.fx.absorbFrames = 200; + setInput(st, 1, { fire: true }); + stepFrames(st, 1); + setInput(st, 1, { fire: false }); + stepFrames(st, 40); + check('absorption shield converts damage', A.crew === A.def.stats.maxCrew && A.energy === 8, + `crew ${A.crew} energy ${A.energy}`); +} +{ + // Druuge furnace trades a crewman for a full battery. + const st = battle('druuge', 'urquan'); + const A = st.ships[0]; + A.energy = 3; + setInput(st, 0, { special: true }); + stepFrames(st, 1); + check('furnace refills battery for 1 crew', + A.crew === A.def.stats.maxCrew - 1 && A.energy === A.def.stats.maxEnergy, + `crew ${A.crew} energy ${A.energy}`); +} +{ + // Mycon regeneration: full battery -> +4 crew. + const st = battle('mycon', 'urquan'); + const A = st.ships[0]; + A.crew = 10; + setInput(st, 0, { special: true }); + stepFrames(st, 1); + check('mycon regenerates 4 crew', A.crew === 14 && A.energy <= 1, `crew ${A.crew} energy ${A.energy}`); +} +{ + // Pkunk taunt restores energy; resurrection lands in a plausible band. + const st = battle('pkunk', 'urquan'); + const A = st.ships[0]; + A.energy = 0; + setInput(st, 0, { special: true }); + stepFrames(st, 1); + check('pkunk taunt restores energy', A.energy === 2, `energy ${A.energy}`); + let rebirths = 0; + let deaths = 0; + const N = 400; + for (let i = 0; i < N; i += 1) { + const st2 = battle('pkunk', 'urquan', 500 + i); + const P = st2.ships[0]; + place(P, 2600, 3000, 0); + place(st2.ships[1], 2000, 3000, 0); + setInput(st2, 1, { fire: true }); + for (let f = 0; f < 80; f += 1) { + const evs = stepFrames(st2, 1, true); + if (evs.some((e) => e.t === 'death' && e.side === 0)) { deaths += 1; break; } + if (evs.some((e) => e.t === 'resurrect' && e.side === 0)) { deaths += 1; rebirths += 1; break; } + } + } + check('pkunk resurrection ~50% (0.40-0.60 band)', + deaths >= 100 && rebirths / deaths > 0.4 && rebirths / deaths < 0.6, + `${rebirths}/${deaths}`); +} +{ + // VUX limpets degrade the enemy's turn rate. + const st = battle('yehat', 'vux'); + const A = st.ships[0]; + A.fx.limpets = 4; + place(A, 2000, 3000, 0); + setInput(st, 0, { right: true }); + stepFrames(st, 24); + const slowed = A.facing; + const st2 = battle('yehat', 'vux'); + const A2 = st2.ships[0]; + place(A2, 2000, 3000, 0); + setInput(st2, 0, { right: true }); + stepFrames(st2, 24); + check('limpets slow turning', slowed < A2.facing * 0.6, `${slowed} vs ${A2.facing}`); +} +{ + // Orz marines board and chew crew. + const st = battle('orz', 'urquan'); + const [A, B] = st.ships; + place(A, 2000, 3000, 0); + place(B, 2600, 3000, Math.PI); + setInput(st, 0, { special: true }); + stepFrames(st, 1); + setInput(st, 0, { special: false }); + check('marine launched costs 1 crew', A.crew === A.def.stats.maxCrew - 1); + stepFrames(st, 300); + check('marines chew enemy crew', B.crew < B.def.stats.maxCrew, `crew ${B.crew}`); +} +{ + // Chenjesu DOGI drains enemy energy; shard fragments on release. + const st = battle('chenjesu', 'urquan'); + const [A, B] = st.ships; + place(A, 2000, 3000, 0); + place(B, 2900, 3000, Math.PI); + setInput(st, 0, { special: true }); + stepFrames(st, 1); + setInput(st, 0, { special: false }); + check('DOGI spawned', st.projectiles.some((p) => p.type === 'dogi')); + stepFrames(st, 200); + check('DOGI drained enemy energy', B.energy < B.def.stats.maxEnergy, `energy ${B.energy}`); + + const st2 = battle('chenjesu', 'urquan'); + place(st2.ships[0], 2000, 3000, 0); + place(st2.ships[1], 6000, 5000, Math.PI); + setInput(st2, 0, { fire: true }); + stepFrames(st2, 1); + setInput(st2, 0, { fire: false }); + stepFrames(st2, 2); + check('shard shatters into fragments on release', + st2.projectiles.filter((p) => p.type === 'fragment').length === 8 + && !st2.projectiles.some((p) => p.type === 'shard')); +} +{ + // Chmmr zapsats orbit and shoot down incoming missiles. + const st = battle('chmmr', 'earthling'); + const [A, B] = st.ships; + check('zapsats spawned at battle start', st.projectiles.filter((p) => p.type === 'zapsat').length === 3); + place(A, 2000, 3000, 0); + place(B, 3200, 3000, Math.PI); + setInput(st, 1, { fire: true }); + stepFrames(st, 2); + setInput(st, 1, { fire: false }); + const evs = stepFrames(st, 60, true); + check('zapsats defend the Avatar', A.crew === A.def.stats.maxCrew || evs.some((e) => e.t === 'pdZap')); +} +{ + // Androsynth blazer: constant-speed comet, contact does 3 crew. + const st = battle('androsynth', 'earthling'); + const [A, B] = st.ships; + place(A, 2000, 3000, 0); + place(B, 2000 + 700, 3000, Math.PI); + setInput(st, 0, { special: true }); + stepFrames(st, 1); + setInput(st, 0, { special: false }); + check('blazer engaged', A.fx.blazer === true); + const crew0 = B.crew; + stepFrames(st, 40); + check('blazer contact damage', B.crew <= crew0 - 3, `crew ${B.crew}`); +} +{ + // Kohr-Ah F.R.I.E.D. clears incoming ordnance. + const st = battle('kohrah', 'earthling'); + const [A, B] = st.ships; + place(A, 2000, 3000, 0); + place(B, 3000, 3000, Math.PI); + setInput(st, 1, { fire: true }); + stepFrames(st, 2); + setInput(st, 1, { fire: false }); + stepFrames(st, 8); + setInput(st, 0, { special: true }); + stepFrames(st, 2); + check('FRIED burns off incoming missiles', + !st.projectiles.some((p) => p.alive && p.owner === 1 && p.type === 'missile')); +} +{ + // Syreen song pulls crew out as floaters; the Penetrator can rescue them. + const st = battle('syreen', 'urquan'); + const [A, B] = st.ships; + place(A, 2000, 3000, 0); + place(B, 2600, 3000, Math.PI); + const crewB0 = B.crew; + setInput(st, 0, { special: true }); + stepFrames(st, 1); + setInput(st, 0, { special: false }); + const floaters = st.projectiles.filter((p) => p.type === 'crew').length; + check('song pulls crew overboard', floaters > 0 && B.crew < crewB0, `${floaters} floaters`); +} +{ + // Mmrnmhrm transform swaps stats and weapon. + const st = battle('mmrnmhrm', 'urquan'); + const A = st.ships[0]; + setInput(st, 0, { special: true }); + stepFrames(st, 1); + check('transform costs the battery', A.energy <= 2, `energy ${A.energy}`); + check('Y-form stats active', A.fx.formIndex === 1 && A.fx.formStats.maxThrust === 50); + check('Y-form weapon active', A.fx.formPrimary.type === 'homing'); +} +{ + // Umgah cone destroys incoming shots; retro hop jumps backward. + const st = battle('umgah', 'earthling'); + const [A, B] = st.ships; + const savedG3 = TUNE.PLANET_GRAVITY; + TUNE.PLANET_GRAVITY = 0; + place(A, 2000, 3000, 0); + place(B, 2900, 3000, Math.PI); + setInput(st, 1, { fire: true }); + stepFrames(st, 2); + setInput(st, 1, { fire: false }); + setInput(st, 0, { fire: true }); + stepFrames(st, 24); + check('cone destroys incoming missile', + !st.projectiles.some((p) => p.alive && p.owner === 1 && p.type === 'missile')); + setInput(st, 0, { fire: false }); + const x0 = A.x; + setInput(st, 0, { special: true }); + stepFrames(st, 1); + check('retro hop jumps backward', tdelta(x0, A.x, ARENA_W) < -100, `dx ${tdelta(x0, A.x, ARENA_W)}`); + TUNE.PLANET_GRAVITY = savedG3; +} +{ + // Slylandro probe drive: always at full speed; harvest refuels from rocks. + const st = battle('slylandro', 'urquan'); + const A = st.ships[0]; + stepFrames(st, 5); + check('probe always moves at max thrust', + Math.abs(Math.hypot(A.vx, A.vy) - A.def.stats.maxThrust) < 0.001); + A.energy = 0; + st.asteroids = [{ + id: 777, kind: 'asteroid', x: wrap(A.x + 200, ARENA_W), y: A.y, px: A.x, py: A.y, + vx: 0, vy: 0, spin: 0, rot: 0, radius: 60, alive: true, + }]; + setInput(st, 0, { special: true }); + stepFrames(st, 1); + check('harvest converts asteroid to energy', A.energy === 8, `energy ${A.energy}`); +} +{ + // Melnorme confusion scrambles steering flag. + const st = battle('melnorme', 'urquan'); + const [A, B] = st.ships; + place(A, 2000, 3000, 0); + place(B, 2500, 3000, Math.PI); + setInput(st, 0, { special: true }); + stepFrames(st, 1); + setInput(st, 0, { special: false }); + stepFrames(st, 20); + check('confusion pulse scrambles the enemy', (B.fx.confusedFrames ?? 0) > 0); +} +{ + // Thraddash afterburner lays damaging napalm. + const st = battle('thraddash', 'urquan'); + const [A, B] = st.ships; + place(A, 2000, 3000, 0); + setInput(st, 0, { special: true }); + stepFrames(st, 10); + const trail = st.projectiles.filter((p) => p.type === 'napalm'); + check('afterburner lays napalm trail', trail.length > 0); + if (trail.length > 0) { + place(B, trail[0].x, trail[0].y, 0); + const crew0 = B.crew; + stepFrames(st, 3); + check('napalm burns the enemy', B.crew < crew0, `crew ${B.crew}`); + } +} +TUNE.PLANET_GRAVITY = savedG2; + +// --------------------------------------------------------------------------- +section('6c. fleets'); +{ + const { PRESET_FLEETS, materializeFleet, fleetPoints, validateFleet, randomFleet, aiCounterPick, FLEET_MAX } = + await import('../src/games/starcontrol/StarControlFleets.js'); + for (const preset of PRESET_FLEETS) { + const m = materializeFleet(SHIPS, preset.ships); + check(`fleet "${preset.id}" fully materializes`, m.missing.length === 0, `missing ${m.missing.join(',')}`); + check(`fleet "${preset.id}" within ${FLEET_MAX}`, m.entries.length <= FLEET_MAX); + check(`fleet "${preset.id}" validates`, validateFleet(SHIPS, m.entries).ok); + } + const alliance = materializeFleet(SHIPS, PRESET_FLEETS[0].ships); + const hierarchy = materializeFleet(SHIPS, PRESET_FLEETS[1].ships); + check('default matchup point-balanced (within 10)', + Math.abs(alliance.points - hierarchy.points) <= 10, + `${alliance.points} vs ${hierarchy.points}`); + const rf = randomFleet(SHIPS, mulberry32(5), 200); + check('grab bag within budget', fleetPoints(SHIPS, rf) <= 200 && rf.length >= 1); + const pick = aiCounterPick(SHIPS, ['shofixti', 'chmmr'], 'urquan', mulberry32(9)); + check('counter-pick avoids scout vs dreadnought', pick === 'chmmr', pick); +} + +// --------------------------------------------------------------------------- +section('7. smooth zoom mapping'); +{ + check('close anchor at short range', Math.abs(targetZoom(0) - ZOOM.CLOSE) < 1e-9); + check('med anchor between bands', Math.abs(targetZoom((ZOOM.BAND_1[1] + ZOOM.BAND_2[0]) / 2) - ZOOM.MED) < 1e-9); + check('far anchor at long range', Math.abs(targetZoom(99999) - ZOOM.FAR) < 1e-9); + let mono = true; + let prev = Infinity; + for (let d = 0; d <= 6000; d += 25) { + const z = targetZoom(d); + if (z > prev + 1e-12) mono = false; + prev = z; + } + check('zoom monotonically non-increasing with distance', mono); + check('far viewport fits arena', 1920 / ZOOM.FAR <= ARENA_W && 1080 / ZOOM.FAR <= ARENA_H); + const z1 = smoothTowards(1, 0.25, 180); + const z2 = smoothTowards(z1, 0.25, 180); + check('smoothing approaches without overshoot', z1 > 0.25 && z2 > 0.25 && z2 < z1); +} + +// --------------------------------------------------------------------------- +section('8. AI pilots (soak + skill gradient)'); +{ + const { createAI, updateAI, knobsFor } = await import('../src/games/starcontrol/StarControlAI.js'); + + check('knobs interpolate between anchors', (() => { + const k = knobsFor(6); + return k.reactMs < knobsFor(5).reactMs && k.reactMs > knobsFor(7).reactMs; + })()); + + const playBattle = (aKey, bKey, skillA, skillB, seed) => { + const st = createBattle({ a: { def: SHIPS[aKey] }, b: { def: SHIPS[bKey] }, seed }); + const aiA = createAI({ skill: skillA, side: 0, seed: seed + 1 }); + const aiB = createAI({ skill: skillB, side: 1, seed: seed + 2 }); + let hits = 0; + while (!st.over && st.frame < 2160) { // 90 sim-seconds cap + setInput(st, 0, updateAI(aiA, st, STEP_MS)); + setInput(st, 1, updateAI(aiB, st, STEP_MS)); + for (const e of stepMatch(st, STEP_MS)) if (e.t === 'shipHit') hits += 1; + } + return { winner: st.over ? st.winner : 'timeout', hits, frames: st.frame }; + }; + + let decisive = 0; + let total = 0; + let battlesWithHits = 0; + let threw = null; + try { + for (const aKey of keys) { + for (const bKey of keys) { + const r = playBattle(aKey, bKey, 5, 5, 101 + total); + total += 1; + if (r.winner === 0 || r.winner === 1) decisive += 1; + if (r.hits > 0) battlesWithHits += 1; + } + } + } catch (e) { threw = e; } + check('AI soak throws no exceptions', !threw, threw?.stack?.split('\n')[0]); + check('nearly every AI battle produced hits (>=97%)', battlesWithHits / total >= 0.97, `${battlesWithHits}/${total}`); + check('most AI battles decisive', decisive / total >= 0.6, `${decisive}/${total}`); + + let hi = 0; + let games = 0; + for (let g = 0; g < 20; g += 1) { + const r = playBattle('earthling', 'earthling', 8, 2, 900 + g); + if (r.winner === 0) hi += 1; + if (r.winner === 0 || r.winner === 1) games += 1; + } + check('skill 8 beats skill 2 (mirror match)', games >= 10 && hi / games >= 0.65, `${hi}/${games}`); +} + +// --------------------------------------------------------------------------- +console.log(`\n${passes} checks passed, ${failures} failed`); +if (failures > 0) process.exit(1);