From 034f7994318bed23a6cc8b6f67f934193fddfab6 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 10 Mar 2026 01:22:47 +0000 Subject: [PATCH] feat: multi-tile trees with treesnstone tileset and trunk bridging Replace single-tile tree rendering with 6 multi-tile tree variants using the treesnstone.png tileset. Each tree is 3 tiles wide with canopy tiles on the decorations layer (above characters) and ground/shadow tiles on the trunkDecorations layer (below characters). Add trunk bridge tiles (tile 41) behind transparent canopy areas to connect canopy to ground visually, fixing floating canopy appearance. Co-Authored-By: Claude Opus 4.6 --- client/public/assets/lpc/treesnstone.png | Bin 0 -> 15967 bytes client/src/scenes/BootScene.ts | 3 +- client/src/scenes/GameScene.ts | 76 +------ server/src/map/__tests__/mapGenerator.test.ts | 12 +- server/src/map/mapGenerator.ts | 188 ++++++++++++++++-- shared/src/constants.ts | 2 +- 6 files changed, 185 insertions(+), 96 deletions(-) create mode 100644 client/public/assets/lpc/treesnstone.png diff --git a/client/public/assets/lpc/treesnstone.png b/client/public/assets/lpc/treesnstone.png new file mode 100644 index 0000000000000000000000000000000000000000..d034e142da096b07d1c72ff9be57cf48e3e786a3 GIT binary patch literal 15967 zcmX||WmHt}7w?}*8ipD|%D|vg8U_?mN}l$s$#rBoy(##N+*w%O06+2DV%gWb6Kckv~sxrCT75>*&?DS+LxSH$fpif;|V*N^5 z$#KDEfb*t>_e$zl&yA+ggN>r`O6SeWkz?ZK^xkqYNuHdt;V?_-=&(CcNjiD8JmYL& z%1r4vB#ks->DN1@&wXyl=+Z}27#w(5@#b5@nL;nkrvHjBUi6eSEPTGHrUe`QqT9!0 z6SNBJol!XZQ$lyOc~dkts)^O3i&M#uD{#4@Y|n~a>9}hmPPENXCayZxj~;1oiO!sN0I zVjNj^78SS6X`=e=0j4h+mk+EOTV_aF>X0+!X;)oJuu@*)S@ZPb8Q~ z9B&(1`3KFcB2(C*#f3rRWgDlTsmWX7Y&;E=)H%}4-?x5u{@dZ3WDnJWSW5vR0+%x8 zFO{qa;ER+QSYpRSde|xM5y`Ahg-RjQA z>S|;Gh!c-%_KNTL~en!P<~J+y)#YGG&rPbMs}SNaG1UJmqg9Fo6`X4W~1wC7&qU8 zQ+{D3+FTWkpu_Jg!?XbseejOA(!dP4G3L;!l7@gnq%VuS{DmmT=v#9q0n} zDWEwDOnIOq^{&}t1h{=A`bKtq!$~IDIzxJ=Gem`1LO$~SOqI-pT#p_v-=a&&)lr=mOqbu|uVzl2qJT~+@L zn1%#6lXHEl(5P%SE9&c%NQJEV40OPj<)Fda5(S!9UXQVY@gl}p|$+sujN~w?(a=mHyTZ{x>8bRipX~I zTpv9Vd^_{dM1OcW=XKDnR&isUh%1>4AalwNWJ<2&p-TRk2zfU0&iSTThwOG-vj;j5 z4py67wwWuZ^4AN)k-i$}Fjo&8+giX(P4(>>7sgZh+4f#H;6Y}E^Lw)8t8Q^F6m@fWcDg(H=j|CR8zdR`H5*$a96kmYZ^1-NyGYb< zr{0b;J@-iQ=HAARZ%9hyGYaX9$IASXjt;NFD(gB}q(zobgZt!R*;)|5gCZq%J+OPn zUOp5?HNG@St#_&O!->gb_P|QYlH;C~asFsQs^8s^=d?fTyn935vyr=nop6lfs|*3m z=z5{5ax!sA^EIPkw6e%()at{dbB3BVA{2X@?Ygh@yB0*oqR1z+24@(qkh;=fYp%Y2 zQci8J9Apmk;Q-5K%}izNm;Tv*CQ;D~)Fzo?zdWDQtmM3HW`BKCXJ|aJ5NgbB!c8qw zUkWCLL^)461cy}`976vM7qN9lOR2v+dU*@cvHl2aY%D(bL9i6<)(w4?^1uqy$!b!c z-8QPaqg#?0_|;NA`Q(*7(zYezsoZ2rbqBRq&s7(?O0u8)vac*480yD?;{qe{c~?q@ z4MyuFXRq{2&bSMCUUqoI7-S#+?eEI;>T%fY&UGH5X{93B*7VpLWnN%aC%yUu;+flK z-uG}q7FYq~**vLR5CH51D&(iQ!x#2YS(WOPO8+XP#El}z^~5_p|9<`y;1G4q5+;*_qe|yKf$n9 z%^f41UPZlZw9Q><5mD|nu$-24by*5iGV=?%9jS70zNmQ0wnAP=aHp*w8Hv2cTfeBK z;d9u;YByWa@yzmxI17E+Ig)JQ_WJ%4H+7sKfj0%E(AzMqsTCE>jwW3-f^#RNS>or~ z=_BU13L?xMzs<2|5o;||CprQ@^Api-RKuX@j(a1m+55ChoeMW}_L#mqrMRx{IZ>Qf z-aTsl(69F!B0$&F2=gN^gK@CvsH#h*N7@U~g33ok?8^w7C<`WI2xMKHDl<2}YgaI9?Ye>)EZNS%8e}S|4SAQ~iH|ca zeV-Zq82?TntmLtboX6e`9LrBdh;ItbB^@v>^owZ13%?+ThN8to`CyN&slB+nJs6-r$9L0o6Ack95cN8UbGMBqFf z#?f_~wKSbn$l5G%%AT2{AlX6(Ir_tRr})J6e7^_W`kPq+3$kbF_udbVu8k=(L;0E zFX-LR_$pU2j+W)*w$CS)(y`Zyp9Xtg(&TdKZ5&uyAqDjaH=Rz`b0051r+$9e-P>KdwyJ<;r~z0vURFvsW`)#um8ED{ORM0!56iwF+%R^1sP|Ha z$WAh-yf$Efg-@5@KDQg`#czmm+_2FL&gni0jFNUv|x(Vt)7(C&t8O?6$YHPXA#(y`i9&P zgrLt$QKJR~)2FV=b~N%Y7ZJ06q3o8>`ndFSOFChGF0`J>6BUp>)0C48s-V>qA6S%xs~T>*;1lF);j?%Ho2`qa!FD2nTaaQ1uCi|4@6ATvc=&$eeMK_WvhX6+ zvL$$#dXq~eA$>)usv&#y`U8lp63Yn3+0YfYO##P$DGAT}w_K+<`gTU0Hk5O>k7TS zA9Sl7kIoMgKWF@_41cuPJ(f7ctzR{D}1|<&0G4H1{ z5b%zuKi8YolBKjb(Q>~CG>7yJJV)Q<;Ab?mZ1~UOvegQ!<=^;rC4emsq|)-^W9zG1 zt(?u+FThzKrT+6=FnxoLSxdZ_C}0|by(k<+0=68?xwJ30D?T~E8=kNGkQxq)*k%&3 zShn)v$qar=&k#ea#RmddOFvqdD=obxpY$2~R*nIaI^=H7w~&0#yaXp-d&va7YT4UH zH{6A!2-{rs$TbFO4TW)9GQ`_}>;HLM-v_Dms zGX9q{{8P^jNx>KN9r>1SbhGxU4asoe?Ck&QCNX+d#_?oEm2TVZuW<-Ana zHg4(v({8tp%Bz9jJJ~@ps)atwxSRXNd3Byp$MkUaT>TpdGTt!q7lmK$-=mj?p0T29 z-IPfHJYRH=P+rDel0JJ{o)(P2Ha6}3Wn0Q z$p$$TG;0r?j7>DJ?3yeYW~wMO`2ocpZ3y6qrr?=KEv&mmT4zw`yLCoG=f$*#9=A%KPo!%n}t zvYHxmhrpB(>^+(^owN5uv*xilY3i`NqDZ0d>RUG-D70NAlY#Xn{cTS&27asFmPV%g zWtp?4qLBKNDDD6H-4lI~vX?!SYpJQl*ej1oLP<&OMyUurD8!p?awhqtLP{f(0z47j z?W1GiXz;Ttb#p*`>eoQHt}dmL3S0rH@nwASn%IKxa22AO+~>9u4ZCdC()lo{H$B$g zboxz`hU&v&_CADq?EaFP!Sq>gu^@IJ@#&K-*Gq2yQNx&opwNyrqgMqvDOKnV;z42= zwCjsAxsn->Bv}TY6OTS>o~2;xMxzHhpSqVxt2`ixi_f$xmr=iwOSLJb-v14)NWZLT zZ%G*IU$@%d$zm_c&vo{qane*k!bjdhUXNL7Ks*SV6A1!{r6v0oQ>dRBcO^;;7qcp9 zbZ^kFxa7lr>^?iyC>42BJXZ9+5czy8>x!DhUetDE8syzjUgv0u;CG{eBoo=vYaDqY z7(tMf++g~r18Oi>Dx@Emb7r2>Yx2Tm|Aq!5v4eHzmg?CQ7oZ`ZKP-m8H!Q%=T!w}2 z?WCtRygb3VNwGI$6n7arHX61gHDA3oK-tcH!AGJ(k&=S_n1V>jTRFEZZqF`De@RXN zBe6TzG&;2sUuUtG{}!rwWd0+?Mhe%Nsf?$-TfXG2$ZJ)AQ<=49P#~jWXaeE zx3)yw#BZYgP?S9GqWOeKyP;G2=B&ur7tK@})TyG|di(?zK|xOO_CZI{*#2oq=v6Ir zv#{eqA$M+kIYW7l`};Gr5&4QdY@y>^^371G6I z$DT!#CWeGQNPvrQ&uknFC@pGxVHR%ppt2FYoee*WSJ|eL~KY zN5wa9YN+v!qSoo(z=wD}{|zqKe?diL2m1_bzZL^I(ZG>Zc%{g8S|;OPNLl^Q0(H}e zlEVXmOL0a2MbPPP%~sa5BM6VozTWG8Jy%A?R+>p?RGWCTVPgHTd!(d0LMt$)U6Xcx zQg{5}UwkmQ$)Ve>xe)R|)Wqmd&WO?bK1R7)ImhO80*{w8XY>!?TW`d+X%!v~q=~F* z{;A1bEc2vKy_7j#g>Wzr;?mIbWOhzb@_(Ftwyk*nvR4vIXo6(UxxLlgFmJ-6jpcM- zJ$%`ausYHsPKfl;P{xFF1FSFipE5c!y%m;tV^(qB%P^XT$SIKSuayLmS1%!o{;hY~ zhQ;7H0xR^NfE%LUlewl905E74NxnsPTwx5g|NO_KPA~^POlg&sLIvd|S$3P}{V5yb zj?K3lkc0MY&mA4$`c7Q*Wa_u zpNvvMF+&cmi#!aAXE5l9@b(~@qRDImC(o;~S1lmnh5p?@&PHnW* zYq>wA-~6yjJNEIZSqXIpvf${6lZuRO@sXY@q?Fvm^bN~{8`i$&{dSQGxfVT7uFDT; zpS(b|{><>;Y9j}4D}+#S6xm2evGO9jp4v>^TpAA6w#vp&=5z*zy|*`mxY|(9EKFhl zZZ^S2O#hAPE$dQN4JBoMY?<>dU*ry+8N2;!-2CJ${$^iqXi~uKfZ|mtK**G#B2%i# z;ltq~Pi65Iu$M2xJ_Oatb!62V7KLu23F;x!#1J76DQ-0+$&kFCeZw0s|bD11UML+R^ zTCVBZg%7`+-xpcLmS>Q?7wXkRv4&#RWV%D~fsdxzGOh}>(ZuY38SK;8Mixx{+TFHuDYIYnua~RWUfRvzkEk3dsh6Z{SbCk!m3K$wLH|5b7;*ssG;DN66LaP{@<;1 z{-P?`=+%=!M#>9+ksdXJSqa9FhgGm&e@ocmwsGh8VcdmSQ$QD)q`GbWZ{hI2{@--v z>Atz!@72GUuI7@~v+pX=Jt_MZt&Bam+NBNqH)7>=z<_Obv_m@-(e#GAE`=wV9r^!( z)odXtWmFsk@@l8GNS8XW7m>@{t{k!$TxRsm?58Oe1&74A-n*ReIOp~;GNWIZG56gQ zI(Cj0#iOuo<4+Vh3L1^yq zohvukO%Q1rGT7EW?s-aqt%Gk+F(s)`3Lg!@tEcjY)y-WLYCiIlIdpFw z6+ZO*In=n0my59ZP|J^PzCvYHP3@@#qq6=JZjH;eaLvBuTl0$iIR}(p$)@e#{NRTq zg0e4HS9~qxBiuo>df)}anERZ3?3&xKMvsAVrWf>)Pgmom63=K;%1~Kg?y+}d@wJE! zno21_QS*y5E`d_Wwi2c0B@j$5nf2dYhG7QhmffFx{o%}Q;I885>dbTUEUlbUOvehN zBjObtYlx`^`-J`o(QB7X-OwQuPMi-DdhiQlwQ;E-?m-)I2P6)Fqq>)AlDNjJ#BZRr z!M&*u9YHNhmOuHv zaM~4QzIO~(ND*E%|Aq7d#-Y|`Cn7wA6pPA$H=^nU>|D)K8ZT~N%^y?smgRd;Fw1Yf z+18o=bz@y{`*+@ozK)J&g<*wSj%Vr+CF9WTU?17rg9VEhrW}~3YuAUWwO7V0s=L6( z!J1-)dAG-`FCS=@Wu15ifrhUrJq@TzRcAM=bSQF(-0y7~d)N4Dct_k=?tsTkAK`&S z{>k5fb}>&&j%!@w_P@+pX>e!c%2*ClqEpI;=M!R$`aVgOyOu6-{{f99E!pFI)!AvY zf!~sXGoRb_~TsKji?mmy@y>@P!ZI$sK_L+JczOQ!iS8?Rw4Be(!JkyMlj(9+27 zNM6-@hJ?nSDj8lA$C(M-d~Cq-#-3l@;tl(h_~pe_+Ea0DPn^<07DH8xw0=lX8gC!O zDmNgno3VTt@z*cwkzC=^l!S^bF)32f`0x2K+}k51wZrmCC&*(muw23nOieVhNlx@1 zL$Dj1IfT+;donE1z{&B(INo3(LGvVGq{=~jSTXq**5~#+CMjWN#`^;~JqLmmxl~m$ zkw5##j@Bdz#kJHulsb{ho%12dLWnQP*EViUT$zMe6^F%-$Ne35nwQyCipc!|H59a4 zqQ9DU2~G(a)Z37Y%rCv??d8Bcj?VPnxGQ*=cP56pCCrm{X~fwZ)K$^KWkDeV%vak@ z*)3*^Ei&XjOa7Uf$=o6F2C=2l1A$*TJ*|_GgqUoVT+1}+y8Cy9sIGi(WX$A>->Z0R zIlD%_8K>DgOx{LktJH@RnEsdhdGF|UlU*A%J-*suR)E!e1*OrOm^3E3ZUPH^vM)FH zJV%wnL8E1Cp3AnS(B{{?h-5Wl+_}OPTkUgj|@B;d4%OD9kweHp80U*ItZx z*OPN(mY3{!|F(>oZ%Sp8r-M=7se3(IQ?T)u35<8-7q0pj{G+Bn8L}o}8*V9lw9|mr z{A4PN+FZg|kzq~D)uW$^(>b3H%nJuj@uu^e@VGxr{@}|;yCB!jD>9zJ&p35za`N<+ zGP!52cdg}(B+WSXz|_s2v(+;Ji)4zL$G$BYCUC~Nyvh^+GEf{YyD+Yto+)3F`HS4E zuOmYe(J`Bq$EGnjb*kJKLjsk0rc7s}e=&nWVMn~BYb(>ZJ3i9^N1+R&8OK}I*LVC* zs5Wa`CC>wY*e)E|E1l%jUj>A3kEPF8Fglb3X-{7MfPBOqkz|3X4_RIb9-f%_0lvv% zyC9hmdq;6(|Mmn0Cp=+mty__?r8k6|(Lk9F*Zz>)wZaISq>;J2GYYNQR1H^Jt+Dly zxoAn=Yl_=%7OHBpVTNq^Rk7Qt_nI_xhy7mpT5ZoHAB`(p34KzG+NXt2c8xFu?Yh{2 zg?z%lb{;OB2Z!anzq-7aE~#?!t(jmoVY2MI?#(F!a-!#Y{coshq^0JTKSf69e^}yZ zI~KW{*{e4N$o+MY_)gOEvt_F8wy(96`3RjI((uxAPTlK%UvV0cIEPQnfze*nrEn&r(Kd~bFyLevjjpX z_+5anWTGbPDhnKUy_J@sbffcUrmlKPJOFn79Zs(L;m&3Qw}hWl0KmomKNjG;|FI2( z6V8?3HeyFL7kr_p$7)?Z%pq76S;lbQHH#LE0wNrLqv?D(6zeED43rWjxlfK)4n`!N z6?y6FiwF|$bc^YTe2EraTv?3o$4+!^jwODO1w+OM){_xq+$kC1miE2ulaBwohSVx7 zmEi;t0KzOSzq(>pKiJ|#S)|Sz`I0< zin*`}Ofq3W!Y-d{VpvV{l+-%%{y0L*`4Rr?M0-K$f6xE`xZW_CVrM~&dBIa82l;Gr zwg6$7Qip?pC|CE7F-!c$`z{Fqv)I**EUv00;jdb9u}=T!69cOzF#GW%%S6fBEiZl=-Qk3krvY=!fpP}|vu2kC!i zobBYDy|0H{;@+@d4Ah8%_R30OY=G8e|0M--%FNxwoqwohG_>^IbH`qnQBLWvu-R9I z>n8Q>-oqXyE3t@1yMg*Q4iZh?WjRTqR90n`vjQk28u`k-)AH^yo$DZ6;-n*k$m=h% zj-n!;$q4^8%s{R(tRCRpmO2EXZV9v`mvGp>L;r4~{gt-a3QP>+CZwF3Ca!GTz5awO znVFzcJdQ`#cnQ5K*ijQ@b{GrAN*V_E9h65a(Sii><5c@xG|)59qj=5h(IhAk`izYe zB@HQRhpK-ILzD2f4{T-5_3#(>XA3-Bebb@5_nls2-rrW6ZN-HssP>`8rGQ29ay0oi zYgy1gp4rB~vZvP&%9Ahb=xw3DLvWLpXn`(?dL)IobxAE_%7Sn$WgQ{#({q+%cEsrB z(mco1)Y9#(3ZQIwz`Zb#1UW9hfJ&swkVtXjVf7k=11d@>+(4a-7!&vv63WoXv{oRf zb1UsTkIm9}^%3#=N#-(0Fv6^8_WOE8_pvNAs5kH3yf4OA!l+`PT> zdV}FX)oTPG)&WHfinPZJHN|e()X9&T8}c6wu_bheLY1Uc+j849$`PMWUoAYUPC9J zmtu*qfLNDeH^hP3f?s~v@MC#eioTEn?cSbcLfU`G?vpc8Hyo`DIJ!MjML9Tg365GL! z{fDc&(~JN2&t~YjGX{S+r$8D-EFT*E@~6Lqb?X#2>DQ^yrGjp{&6aR7yvB!gR+%kI-~sm4o6ELOzpJqy z&LW4Peyq+v{;>wWpmo!u^nyYoR+Cx<#bqjj1j*weDh@ejxesqYkBKGZ8O_MZ<#aP@kn_N~8?2Du!{JHc*^Bo@SJ0-QRrhNVO3KYu?OP#RuAF+Nb^9`_ zV@uq(*5evoecxiDFpgmLbn0f>WWz8eD}3*K4S(27fnjUheTkOCW|nWxczB2$%UuGc zr8~CoR9PjXrlDkV3-Q?H+jREq;IoS5%s2ompB$Wei)mO#{p!s}cr5&LoL zex!C+xb*QmGY7d~W^ zRxaq>^V0bZf6^iPLgHkUu%7cJ^Oe8g(|liYB=N%xL7(1y?F^08mcXY$zG*5zi_JQZt?WK_4gkRXJwc1*)%?Q{N{Yf^$>h&w6+mVqsezGY(OIqjiupuQVlw3S) zH6h%)T6>3dU?zMNp$w5Rue>iNf@Q|KZ&JX;=0+rMZet#ZTpk*Kh5uq!MxBN*Ug%_C zT`qTC4=0~9Y7YL}d;a&w>SIy~>5Lo&rC!7m0OElYFMpyXwx~WJz z;+guU7eI|v_+5ozQ%Xg4)FuRR4RAn-l@%uI8k;n^SuW|lD0d7)4H51^@^aFZFtZ;e zt>$76A{H;wuucROW&k2H5#;v(Yxq)W^6W>7A(Z#uK*3VN6WfsN^J_^Rjln8}9KVTQl4_!4O~;ZYSbwKt<(s_cF}uc53-`L!4jwDhY$qz=ou^D0}?Qr`%B z(i;@JB(8&*$@@tIuxDq6b4xx&hRpz&-1Vo^M6}4VWVU~}=@_Vba#;UJt-DDf#JBEG z92?}M?8Q}bwG)V@WNrvC-?8ojA>ln-l4vT1B}S5o*!2eyJ2IdC6`#1kI=A>7>eoW3 zM4mKEaxKWKnta_#3<$chmbEu*;f5g)3EB3F=ie?P->F)TymhD|ch8H?NSqSjxzF4I7>K-2s(_upk=GjO5YrK7(jmxDEFFGJPb`MkW zbp6XC+p3>DApkBDiD(P7MTn>&Ve)h%7ZnnUk#9Q}&^h=>(M{hb<5Kyk##V9U_$gI$ zJ0x|iw?dC3?aGmGUsmDQzF7KKSVGNM{m3hm2r{=9SoI`Yml||L>6K+!5LxuAR zrEQjo{Glr(-uC^B;B9e7ZU|Hjl-@vob$mt!ty%mx*CYEKdLPBNH`uS#6~r85WUpg$ z-xuHh(yDa&gGu>Oe3*Vw!m`Z|j>S5h6TJhG96dF#-Suazun`KJH^=2vO79GoOTI(U z-2hlt!a#+pi&bJ>iK}vh6k`x)TnbLeAp(5L{p{s@62y)6Fw|PU;9`ptfpe;XN~~`IcAOWRK^BQDBv*NiO(%M z6E3h8>6az>bowutN;-{r{|s**<)$ag;kI1FeBk=1HH@3#}~d&zX?+RaVaArE=eJ zkJus+G=!(lq8~QcLa>sD<|rNKIdb6TJH|Eez>{K{Az)z8Xr2>5$t}Bmxs&pE+Lp!7 zN1PPoKe_C*i$C-JNST}}=NauR*pfyDTpnJN}+HCiJbatV{GVRTcDY9T)9guTaW? z4?>ACN44%eR{5HWMs6=pe(7c+z+%)rs)(;3(-12zEQ&4C8K4I(B{G5Npl5wIU49tg zryMs8*SR+-fR%U?^L;VY#kiHSpo7&ck$j}%pl_2@n;5i}5wuAcxK{ZEf&n7sz$srb z5$W}ZfZsb_4P_R21og0@Y!~7K9M>kOfb^WbvM$7n{zQ+~^6dNrMeM<=S^mS>^vJ6p z`DhkojN)bq`4x}*dmj_mvZ(4mT?pYepkHWWa!{ppyd#qdeD)NiSiHY7%}R03`Med5 zJN%Cm9mH$p82UEhcz#TPmJs1hja8;!Vh4Bq59If$rTJ9^Ll4agW~yRbt3}rCxP}6& zJ|0@gj{$7Vz2r>iBjNNvE-et~!edYg<&if9gW}L!m^H#S7ax*nN4H<`n9k&Td2RU-H`xo@&fssr{%K3{OmFY}f!_!m zc;&G>KcwYbZ-;*GB8@{hIdu3{N2fv@X+W6agi7Ry-iu`KW>bq3x7oS3$$g!Vi3L>n zJOBgL-+M^>>-28A!5eYDR51V*@?TB~%I|JZa#nV$VDi`h_H3R}n{QW(Z>G&wQx*u> ztXF)}!}ru#%?X>(*CgVMM@~l|G9sj`BT*+ZAQ%zm_71fKyPHvG;0Ff*og6KpL)@AV z_tL5>kP=g!8if;11~pFMih^La%dME)YpKf}Z|?RPp^@}1l)N14fADKHEPEh!cWA2SNA3ooKCtg!?d8Q`D-~nu#ucbcxg4GUb*5QW zmEJ+W_8bc{H4(-4LJSkMW|zFU%uMYCcUN6sH+W- zaZZz4w20jTHLD2@HV{EkAcTlQ}X>7{kJ1-5Imx8eno z$$iQMHOV!Mjbp@33RQPO-O$SBW|3^p^~Y8eAE8#QqjWPiM!3#KO+xLA0IHKnzC72_ z8u3aj&{SJY#eV~~Da*3;xJ24w>AMWgck#X&FvE{i)jG^Nv$@j;2Ok5TQMlnWoE48h zVwf%%Yhv_(}gBcw=jdk8AP|ifZ#db7;p3aeFANeloE@gh2m?D#xrn37Qh=DT;M!%<* zB{yrKdM6&mSQxFK{-WWng-zy2GK`%a|=_ULUT~DB2b+1`G`~IsDfMauSsMNv(JGLb43?3*3LH>f}}}I7&@>+ zzcqzXUUMFNp2bERRl|o0;B(IC4UJ~&9=<-lBu!Wl_a{;ps`8^Pl}MIWHC&s%@=R%! zfWd+5N&)`q1`hsdykWrrJ4)S_H%k-)YcqB!)FZ-KS%;5U?Qcm_ss4|ub{ppDrBKt} zCh;BCES(-it#cU16DZIVrriHc;-z=#Wu>yNZ@FiLr%&X~FbfC9HT=mAbru}{#eLLt zS^D4kLD0^I@G0S7>TEiZG&9A0M&SN*8tdD>N7^;RD>NPR&jf5v*(;GIBJpWWL=Nr0 zWILqM2yVKan4`0NmUPZKIOqA&rRTIGe&^cCR&r*WR06^DIE<-R~l0i@uGk_jKMPz~B#)RBWa|U<}0Pzok&pRy@ zKBuP(c63r@@GXz+6akb9ihu(2P!zhg$QX#S5VsB+VFX$8YAMVpHJG)uy%EP(yB+^N znDjSTG*y^b$FZG`KYfEgGL7Wdg#vPgidjJzrVqWd3i^IJ{_#m<3W$##uV4B;cxx!h z7;Fr-_18|g5sHrzl4-V?|n68jFBJUA@7bAj_sgYqzqootVx4{8UNP+6mfga z&TLDCWGYaVhD~ryi??&g5U{`KRdn z=2pT0*7f}7&qmZ2ssnRMFOa11jjA$YZ*RSNI)DZZjP88p|7ZYJ+}SN}H{GJr%EQ~` zF&Q%h^CLHnV*K4t9}t8fk*{-yBXcK*x#}9L6aIEnvJ2{qn7CL(&zGZgUOv!%HUEbfA zP{*L{9v!t0#Ve~EF8D7VU%rFD-RHdbt?xsWG6j&J=~xuugB~_SA6S7n`KUY0hZ@9tDLyd0Kw})(P&-(IQ`B?**Oo0rJ!!Vq^Qq( zCL;|Qw(kq={co1$j(cEFjPgbvj1Z^7Q_Xxh3HriGb>a0-cOxO#-pjp;I^Vf3)(YA( z7e$DbFhaiJMXMEn#!y{Aw@phM)2Bpk4g*+~NN1|I)8CTnF>Zt%1e?w}^p98H8$twF z5zmD@ubYd4HK4a?ss8cF%1Dza$Mf4GP(oIwa$er_-mb3dthwQumB__!=ntX?4)6K` z-;x{P8lJLVOK#PyyVSS-YLFF(lMIy!`cSOy`K5}NCvwkeuGxUsc-;d!9xqYQ$CG}x zM!Hh^Q~}Q5SEd!`A(4j+z$z6W13ZTqhQ#g6`)NY z(6=1icyv;7w#9ukzI7u;Dik3?20pvk-U(V-_nkWXu9l8J888Y8e$PMKmK{oP!FC9M z*SA_}bohBT;uX~@K3X#YjL$S>l(X3gr10{oXd+z`X{zLGWeFd2(D*RWzV94$oaBiF z?iLVwFJ*PF=oD-YCg{?2PNwkl1E|r5d;`QiaSYJ+mfy* zckH3b3Z+*wo~hGvUjYWXrXYSlE{g#-pkskmFa~TEi&)&odFbrDnb%a9!A?meK;X8{ zwi!dXl{^=K#g_1o^~B(js~m%kyQw6G!OrQB8p{bi^AsN>*p9M;&yObvVO)+(u{==h zBpqC;Sk;^dgVj2#p?wE1ZI$F)=Wpm5OYbjxb8*6*`28CX$iQqbjaswzX$8la1>^bQ zq1x%P-DjEK1F<$&_Syk(w(13aS57q($>H;xedFZRP>^|a=McgGL2b9Ph3N+dMP-E& zIEow7t3s(V?e{t>_0b%Ls=J}tZ4YlDVuH53C(!^L9e7+DHJ|GpjZo$pFWuS^2O%~4 zXT44JdFRDyT*8V2VYkbVrBW)I4?xrp%J%ev=I!T zR%n5enTaOOq21~?ZgTx@6V_S$4*$SeZoUs8xNdS7KFk|}Yz50ZH=i|i#H2~FPG ze@^?DrPW*#+{}M}J!-WPwOq&gE!O7}r8zUWs{phctvzZtp#V(UCAq$IZGCoDbdpWD z4lc0nd-c4Qorq)&?v7>e%@dmr#m~R=XXk;=j$k}o17IKa%RH5PR?Q^T`|d0{F^8af zA!u)r-Kv5E0adb)Ut&*#C;r@YyA6!5thtVBD8|$#AP5V8dE8`j*PhpLAbKvkY>ErW z5#;+%~1n$Jb<=PP!g-Pb#+Vk;O&;l4Cr3KG)Q7N#VKc)n+wM?t)B{(Q2Ko{zf zg(B{*#;$$1_z2VGp~auc1bglK*A;Whd7$n{&^Nls+I*(9-c8*9v$AQ9t%=m6-IgVl^MQNS|7w=8*+~R(sd?3R*RGX^kg`PE68lU>@Nd3YxHt+ywHu zSne8dXhDUWmwg+yyE6)bI@duohiIXa=+%P*%?l9$Fq%`5j~*D!314*V5`%%t2ON&g zw7}nPQ?ZKu|9TasgzUP6ttrkZf+(Vx;==k`59|vdmVME5j>Ug3>iU;&#(2CvW#Pq2 zBEvZk2V5#h&79!qO9q`gSs=6QTW`VJ`>fg2OCWs606nn1f^l0g4cJ3xT*w4RjAPWO zZU3YbT^LHqnNp#HC`rmOsl;z`FpXY}FFqW{o1IHkrR)QW0Q4tua_2v!~+INdptU~?|d-}|K literal 0 HcmV?d00001 diff --git a/client/src/scenes/BootScene.ts b/client/src/scenes/BootScene.ts index 1863653..770224a 100644 --- a/client/src/scenes/BootScene.ts +++ b/client/src/scenes/BootScene.ts @@ -13,8 +13,7 @@ export class BootScene extends Phaser.Scene { this.load.image('lpc_grass', 'assets/lpc/grass.png'); this.load.image('lpc_watergrass', 'assets/lpc/watergrass.png'); this.load.image('lpc_dirt', 'assets/lpc/dirt.png'); - this.load.image('lpc_treetop', 'assets/lpc/treetop.png'); - this.load.image('lpc_trunk', 'assets/lpc/trunk.png'); + this.load.image('lpc_treesnstone', 'assets/lpc/treesnstone.png'); } create(): void { diff --git a/client/src/scenes/GameScene.ts b/client/src/scenes/GameScene.ts index 27a1c68..9354dc1 100644 --- a/client/src/scenes/GameScene.ts +++ b/client/src/scenes/GameScene.ts @@ -435,11 +435,9 @@ export class GameScene extends Phaser.Scene { const grassData: number[][] = []; // base: solid grass everywhere const waterData: number[][] = []; // watergrass transitions + solid water const dirtData: number[][] = []; // dirt transitions + solid dirt - const moundData: number[][] = []; // grass mound at tree bases - const trunkData: number[][] = []; // tree trunks - const decoData: number[][] = []; // tree canopy decorations + const trunkData: number[][] = []; // tree shade + stone tiles (below characters) + const decoData: number[][] = []; // tree canopy decorations (above characters) - // First pass: build terrain layers + trunk/canopy for (let y = 0; y < worldHeight; y++) { const grassRow: number[] = []; const waterRow: number[] = []; @@ -469,11 +467,11 @@ export class GameScene extends Phaser.Scene { dirtRow.push(-1); } - // Trunk decorations + // Trunk/ground decorations (shade + stones) const trunk = trunkDecorations[y * worldWidth + x]; trunkRow.push(trunk >= 0 ? trunk : -1); - // Canopy decorations + // Canopy decorations (tree body) const deco = decorations[y * worldWidth + x]; decoRow.push(deco >= 0 ? deco : -1); } @@ -485,37 +483,6 @@ export class GameScene extends Phaser.Scene { decoData.push(decoRow); } - // Second pass: generate grass mound at tree bases from trunk positions. - // Trunk center-bottom tiles (7=type1, 10=type2) mark the tree anchor. - // Place grass.png 3×3 island tiles (rows 2-4) centered at and below the anchor. - // grass island: row2=[6,7,8] row3=[9,10,11] row4=[12,13,14] - const MOUND_OFFSETS = [ - // Top row of mound at same level as trunk bottom - { dx: -1, dy: 0, tile: 6 }, { dx: 0, dy: 0, tile: 7 }, { dx: 1, dy: 0, tile: 8 }, - // Bottom row below trunk - { dx: -1, dy: 1, tile: 12 }, { dx: 0, dy: 1, tile: 13 }, { dx: 1, dy: 1, tile: 14 }, - ]; - - for (let y = 0; y < worldHeight; y++) { - const moundRow: number[] = new Array(worldWidth).fill(-1); - moundData.push(moundRow); - } - - for (let y = 0; y < worldHeight; y++) { - for (let x = 0; x < worldWidth; x++) { - const trunk = trunkDecorations[y * worldWidth + x]; - // Check for trunk center-bottom (tile 7 for type 1, tile 10 for type 2) - if (trunk === 7 || trunk === 10) { - for (const { dx, dy, tile } of MOUND_OFFSETS) { - const mx = x + dx, my = y + dy; - if (mx >= 0 && mx < worldWidth && my >= 0 && my < worldHeight) { - moundData[my][mx] = tile; - } - } - } - } - } - // Helper to create a scaled tilemap layer const createLayer = ( data: number[][], @@ -542,13 +509,14 @@ export class GameScene extends Phaser.Scene { createLayer(grassData, 'grass', 'lpc_grass', -10); createLayer(waterData, 'water', 'lpc_watergrass', -9); createLayer(dirtData, 'dirt', 'lpc_dirt', -8); - createLayer(moundData, 'mound', 'lpc_grass', -7); - // Tree layers — in front of characters so NPCs walk "behind" trees. + // Tree shade + stone tiles — behind characters + createLayer(trunkData, 'ground_deco', 'lpc_treesnstone', -7); + + // Tree canopy — in front of characters so NPCs walk "behind" trees. // Characters use depth = y * TILE_SIZE (0 to worldHeight * TILE_SIZE). const aboveCharDepth = worldHeight * TILE_SIZE + 1; - createLayer(trunkData, 'trunk', 'lpc_trunk', aboveCharDepth); - createLayer(decoData, 'trees', 'lpc_treetop', aboveCharDepth + 1); + createLayer(decoData, 'trees', 'lpc_treesnstone', aboveCharDepth); // Points of interest markers const graphics = this.add.graphics(); @@ -562,32 +530,6 @@ export class GameScene extends Phaser.Scene { TILE_SIZE - 16, ); } - - // Stone deposit markers - const stoneGraphics = this.add.graphics(); - stoneGraphics.setDepth(-6); - for (let y = 0; y < worldHeight; y++) { - for (let x = 0; x < worldWidth; x++) { - if (terrain[y * worldWidth + x] === Terrain.STONE) { - // Gray stone tile with slight variation - stoneGraphics.fillStyle(0x888888, 0.8); - stoneGraphics.fillRect( - x * TILE_SIZE, - y * TILE_SIZE, - TILE_SIZE, - TILE_SIZE, - ); - // Darker border for definition - stoneGraphics.lineStyle(1, 0x666666, 0.6); - stoneGraphics.strokeRect( - x * TILE_SIZE + 1, - y * TILE_SIZE + 1, - TILE_SIZE - 2, - TILE_SIZE - 2, - ); - } - } - } } private async spawnEntities(entities: EntityState[]): Promise { diff --git a/server/src/map/__tests__/mapGenerator.test.ts b/server/src/map/__tests__/mapGenerator.test.ts index 5b0b74e..52072ea 100644 --- a/server/src/map/__tests__/mapGenerator.test.ts +++ b/server/src/map/__tests__/mapGenerator.test.ts @@ -65,14 +65,12 @@ describe('generateMap — resource generation', () => { expect(trunkCount).toBeGreaterThan(0); }); - it('tree trunk tiles are marked as obstacles', () => { + it('tree log resource tiles are marked as obstacles', () => { const map = generateMap(32, 32, 42); - for (let y = 0; y < 32; y++) { - for (let x = 0; x < 32; x++) { - if (map.trunkDecorations[y * 32 + x] !== -1) { - expect(map.obstacles.has(`${x},${y}`)).toBe(true); - } - } + const logResources = map.resourceTiles.filter(r => r.resourceType === 'log'); + expect(logResources.length).toBeGreaterThan(0); + for (const r of logResources) { + expect(map.obstacles.has(`${r.x},${r.y}`)).toBe(true); } }); diff --git a/server/src/map/mapGenerator.ts b/server/src/map/mapGenerator.ts index bc53d56..2bdcd26 100644 --- a/server/src/map/mapGenerator.ts +++ b/server/src/map/mapGenerator.ts @@ -25,6 +25,125 @@ function randRange(rng: () => number, min: number, max: number): number { return min + Math.floor(rng() * (max - min + 1)); } +// --- Multi-tile tree variant definitions --- +// Tile indices are 0-based for treesnstone.png (8 cols × 7 rows, 32px tiles). +// Offsets are relative to anchor (center of trunk row, which is the obstacle tile). +// canopy = decorations layer (above characters), ground = trunkDecorations layer (below characters). + +interface TreeTileOffset { + dx: number; + dy: number; + tile: number; +} + +interface TreeVariant { + canopy: TreeTileOffset[]; + ground: TreeTileOffset[]; + topOffset: number; // negative: highest canopy row relative to anchor + bottomOffset: number; // positive: lowest shade row relative to anchor +} + +const TRUNK_TILE = 41; + +const TREE_VARIANTS: TreeVariant[] = [ + // Tree 1: 5 tall (3 canopy rows + 2 shade rows) + { + canopy: [ + { dx: -1, dy: -2, tile: 9 }, { dx: 0, dy: -2, tile: 10 }, { dx: 1, dy: -2, tile: 11 }, + { dx: -1, dy: -1, tile: 12 }, { dx: 0, dy: -1, tile: 13 }, { dx: 1, dy: -1, tile: 14 }, + { dx: -1, dy: 0, tile: 15 }, { dx: 0, dy: 0, tile: 16 }, { dx: 1, dy: 0, tile: 17 }, + ], + ground: [ + { dx: 0, dy: -1, tile: TRUNK_TILE }, { dx: 0, dy: 0, tile: TRUNK_TILE }, + { dx: -1, dy: 1, tile: 48 }, { dx: 0, dy: 1, tile: 49 }, { dx: 1, dy: 1, tile: 50 }, + { dx: -1, dy: 2, tile: 51 }, { dx: 0, dy: 2, tile: 52 }, { dx: 1, dy: 2, tile: 53 }, + ], + topOffset: -2, + bottomOffset: 2, + }, + // Tree 2: 5 tall + { + canopy: [ + { dx: -1, dy: -2, tile: 18 }, { dx: 0, dy: -2, tile: 19 }, { dx: 1, dy: -2, tile: 20 }, + { dx: -1, dy: -1, tile: 21 }, { dx: 0, dy: -1, tile: 22 }, { dx: 1, dy: -1, tile: 23 }, + { dx: -1, dy: 0, tile: 24 }, { dx: 0, dy: 0, tile: 25 }, { dx: 1, dy: 0, tile: 26 }, + ], + ground: [ + { dx: 0, dy: -1, tile: TRUNK_TILE }, { dx: 0, dy: 0, tile: TRUNK_TILE }, + { dx: -1, dy: 1, tile: 48 }, { dx: 0, dy: 1, tile: 49 }, { dx: 1, dy: 1, tile: 50 }, + { dx: -1, dy: 2, tile: 51 }, { dx: 0, dy: 2, tile: 52 }, { dx: 1, dy: 2, tile: 53 }, + ], + topOffset: -2, + bottomOffset: 2, + }, + // Tree 3: 6 tall (extra canopy row at top, same tiles as Tree 1 shifted up) + { + canopy: [ + { dx: -1, dy: -3, tile: 9 }, { dx: 0, dy: -3, tile: 10 }, { dx: 1, dy: -3, tile: 11 }, + { dx: -1, dy: -2, tile: 12 }, { dx: 0, dy: -2, tile: 13 }, { dx: 1, dy: -2, tile: 14 }, + { dx: -1, dy: -1, tile: 15 }, { dx: 0, dy: -1, tile: 16 }, { dx: 1, dy: -1, tile: 17 }, + ], + ground: [ + { dx: 0, dy: -2, tile: TRUNK_TILE }, { dx: 0, dy: -1, tile: TRUNK_TILE }, { dx: 0, dy: 0, tile: TRUNK_TILE }, + { dx: -1, dy: 1, tile: 48 }, { dx: 0, dy: 1, tile: 49 }, { dx: 1, dy: 1, tile: 50 }, + { dx: -1, dy: 2, tile: 51 }, { dx: 0, dy: 2, tile: 52 }, { dx: 1, dy: 2, tile: 53 }, + ], + topOffset: -3, + bottomOffset: 2, + }, + // Tree 4: 5 tall + { + canopy: [ + { dx: -1, dy: -2, tile: 0 }, { dx: 0, dy: -2, tile: 1 }, { dx: 1, dy: -2, tile: 2 }, + { dx: -1, dy: -1, tile: 3 }, { dx: 0, dy: -1, tile: 4 }, { dx: 1, dy: -1, tile: 5 }, + { dx: -1, dy: 0, tile: 6 }, { dx: 0, dy: 0, tile: 7 }, { dx: 1, dy: 0, tile: 8 }, + ], + ground: [ + { dx: 0, dy: -1, tile: TRUNK_TILE }, { dx: 0, dy: 0, tile: TRUNK_TILE }, + { dx: -1, dy: 1, tile: 42 }, { dx: 0, dy: 1, tile: 43 }, { dx: 1, dy: 1, tile: 44 }, + { dx: -1, dy: 2, tile: 45 }, { dx: 0, dy: 2, tile: 46 }, { dx: 1, dy: 2, tile: 47 }, + ], + topOffset: -2, + bottomOffset: 2, + }, + // Tree 5: 6 tall (4 canopy rows) + { + canopy: [ + { dx: -1, dy: -3, tile: 27 }, { dx: 0, dy: -3, tile: 28 }, { dx: 1, dy: -3, tile: 29 }, + { dx: -1, dy: -2, tile: 30 }, { dx: 0, dy: -2, tile: 31 }, { dx: 1, dy: -2, tile: 32 }, + { dx: -1, dy: -1, tile: 33 }, { dx: 0, dy: -1, tile: 34 }, { dx: 1, dy: -1, tile: 35 }, + { dx: -1, dy: 0, tile: 36 }, { dx: 0, dy: 0, tile: 37 }, { dx: 1, dy: 0, tile: 38 }, + ], + ground: [ + { dx: 0, dy: -1, tile: TRUNK_TILE }, { dx: 0, dy: 0, tile: TRUNK_TILE }, + { dx: -1, dy: 1, tile: 42 }, { dx: 0, dy: 1, tile: 43 }, { dx: 1, dy: 1, tile: 44 }, + { dx: -1, dy: 2, tile: 45 }, { dx: 0, dy: 2, tile: 46 }, { dx: 1, dy: 2, tile: 47 }, + ], + topOffset: -3, + bottomOffset: 2, + }, + // Tree 6: 7 tall (5 canopy rows, tallest variant) + { + canopy: [ + { dx: -1, dy: -4, tile: 0 }, { dx: 0, dy: -4, tile: 1 }, { dx: 1, dy: -4, tile: 2 }, + { dx: -1, dy: -3, tile: 3 }, { dx: 0, dy: -3, tile: 4 }, { dx: 1, dy: -3, tile: 5 }, + { dx: -1, dy: -2, tile: 6 }, { dx: 0, dy: -2, tile: 7 }, { dx: 1, dy: -2, tile: 8 }, + { dx: -1, dy: -1, tile: 33 }, { dx: 0, dy: -1, tile: 34 }, { dx: 1, dy: -1, tile: 35 }, + { dx: -1, dy: 0, tile: 36 }, { dx: 0, dy: 0, tile: 37 }, { dx: 1, dy: 0, tile: 38 }, + ], + ground: [ + { dx: 0, dy: -1, tile: TRUNK_TILE }, { dx: 0, dy: 0, tile: TRUNK_TILE }, + { dx: -1, dy: 1, tile: 42 }, { dx: 0, dy: 1, tile: 43 }, { dx: 1, dy: 1, tile: 44 }, + { dx: -1, dy: 2, tile: 45 }, { dx: 0, dy: 2, tile: 46 }, { dx: 1, dy: 2, tile: 47 }, + ], + topOffset: -4, + bottomOffset: 2, + }, +]; + +const LARGE_STONE_TILE = 39; +const SMALL_STONE_TILE = 40; + export function generateMap( width = WORLD_WIDTH, height = WORLD_HEIGHT, @@ -116,37 +235,68 @@ export function generateMap( const key = `${x},${y}`; if (!reserved.has(key) && !obstacles.has(key) && terrain[y * width + x] === Terrain.GRASS) { terrain[y * width + x] = Terrain.STONE; + trunkDecorations[y * width + x] = rng() < 0.5 ? LARGE_STONE_TILE : SMALL_STONE_TILE; resourceTiles.push({ x, y, resourceType: 'stone' }); } } } - // --- Trees --- - const treeClusterCount = randRange(rng, 8, 14); + // --- Trees (multi-tile, 3 tiles wide) --- + const usedTiles = new Set(); + const treeClusterCount = randRange(rng, 5, 10); for (let c = 0; c < treeClusterCount; c++) { - const cx = randRange(rng, 3, width - 4); - const cy = randRange(rng, 3, height - 4); - const treeCount = randRange(rng, 2, 5); + const cx = randRange(rng, 6, width - 7); + const cy = randRange(rng, 6, height - 5); + const treeCount = randRange(rng, 1, 3); for (let t = 0; t < treeCount; t++) { - const x = cx + randRange(rng, -2, 2); - const y = cy + randRange(rng, -2, 2); - if (x < 1 || x >= width - 1 || y < 1 || y >= height - 1) continue; - const key = `${x},${y}`; - if (reserved.has(key) || obstacles.has(key) || terrain[y * width + x] !== Terrain.GRASS) continue; + const x = cx + randRange(rng, -4, 4); + const y = cy + randRange(rng, -3, 3); + const variant = TREE_VARIANTS[randRange(rng, 0, TREE_VARIANTS.length - 1)]; - // Trunk at (x, y) — obstacle and resource tile - const trunkTile = randRange(rng, 0, 5); - trunkDecorations[y * width + x] = trunkTile; - obstacles.add(key); + // Check bounds + if (x - 1 < 0 || x + 1 >= width) continue; + if (y + variant.topOffset < 0 || y + variant.bottomOffset >= height) continue; - // Canopy at (x, y-1) — visual only - if (y > 0) { - const canopyTile = randRange(rng, 0, 5); - decorations[(y - 1) * width + x] = canopyTile; + // Check clearance: 3-wide footprint from top to bottom + let blocked = false; + for (let dy = variant.topOffset; dy <= variant.bottomOffset && !blocked; dy++) { + for (let dx = -1; dx <= 1 && !blocked; dx++) { + const tx = x + dx; + const ty = y + dy; + const key = `${tx},${ty}`; + if (reserved.has(key) || obstacles.has(key) || usedTiles.has(key)) { + blocked = true; + } + if (terrain[ty * width + tx] !== Terrain.GRASS) { + blocked = true; + } + } + } + if (blocked) continue; + + // Place canopy tiles (decorations layer, above characters) + for (const { dx, dy, tile } of variant.canopy) { + const tx = x + dx; + const ty = y + dy; + if (tx >= 0 && tx < width && ty >= 0 && ty < height) { + decorations[ty * width + tx] = tile; + usedTiles.add(`${tx},${ty}`); + } } - // Mark trunk tile as log resource + // Place ground/shade tiles (trunkDecorations layer, below characters) + for (const { dx, dy, tile } of variant.ground) { + const tx = x + dx; + const ty = y + dy; + if (tx >= 0 && tx < width && ty >= 0 && ty < height) { + trunkDecorations[ty * width + tx] = tile; + usedTiles.add(`${tx},${ty}`); + } + } + + // Mark anchor as obstacle and log resource + obstacles.add(`${x},${y}`); resourceTiles.push({ x, y, resourceType: 'log' }); } } diff --git a/shared/src/constants.ts b/shared/src/constants.ts index d120996..7622442 100644 --- a/shared/src/constants.ts +++ b/shared/src/constants.ts @@ -98,4 +98,4 @@ export type Terrain = (typeof Terrain)[keyof typeof Terrain]; export const TILESET_TILE_SIZE = 32; export const LPC_COLS = 3; // terrain tilesets are 3 cols wide (96px / 32px) export const TILESET_SCALE = TILE_SIZE / TILESET_TILE_SIZE; // 1.5 -export const TREETOP_COLS = 6; // treetop.png is 6 cols wide (192px / 32px) +export const TREE_TILESET_COLS = 8; // treesnstone.png is 8 cols wide (256px / 32px)