From 9aa0ec6a3ff90b9558d0ec5c252e99312185d9f8 Mon Sep 17 00:00:00 2001 From: Errilaz Date: Tue, 4 Aug 2026 13:53:59 +0200 Subject: [PATCH] feat: trees --- AGENTS.md | 33 ++++++-- app/assets.ts | 13 ++- app/level.ts | 62 ++++++++++++++- app/main.ts | 3 + app/player.ts | 2 +- assets/bark.png | Bin 0 -> 8831 bytes assets/leaf.png | Bin 0 -> 8320 bytes assets/needle.png | Bin 0 -> 8873 bytes engine/scene/Tree.ts | 181 ++++++++++++++++++++++++++++++++++++++++++ scripts/gen-assets.ts | 27 +++++++ 10 files changed, 311 insertions(+), 10 deletions(-) create mode 100644 assets/bark.png create mode 100644 assets/leaf.png create mode 100644 assets/needle.png create mode 100644 engine/scene/Tree.ts diff --git a/AGENTS.md b/AGENTS.md index c59a067..2445c19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,9 @@ rules live in `.agents/rules/*.md`. `Mesh` (indexed tris), `Sprite` (Y-axis billboard), `Terrain` (procedural heightfield around the room: flat clearing in the center, rolling hills, tall edge peaks. `Terrain.ground` builds the outdoor mesh with a hole for the room; - `Terrain.height` is the shared ground-height sampler for the player). + `Terrain.height` is the shared ground-height sampler for the player), `Tree` + (procedural low-poly oak/spruce geometry, sapling..full via a `growth` knob; + `Tree.build` appends into shared trunk + foliage meshes). - `app/` — browser glue. - `main.ts` — game loop, input, preset switching, canvas blit, FPS meter. - `assets.ts` — load `/assets/*.png` → `Texture` (zero-copy; ImageData bytes @@ -64,8 +66,10 @@ rules live in `.agents/rules/*.md`. - `level.ts` — builds the playground: a flat stone-floored room (three thick walls via `slab`, north side open) in the center of a big grassy `Terrain` world (~20x across). - Per-texture meshes incl. the outdoor grass `ground`, `Aabb` colliders, NPC - position, `Terrain` config + `GROUND_DIVISIONS`/`GROUND_UV`, sky/cloud config. + Per-texture meshes incl. the outdoor grass `ground` and a scattered forest + (`scatterTrees` → `trunks`/`oakFoliage`/`spruceFoliage`, `TREE_COUNT`/`_SEED`/ + `_REACH`), `Aabb` colliders (incl. grown-tree trunks), NPC position, `Terrain` + config + `GROUND_DIVISIONS`/`GROUND_UV`, sky/cloud config. The stone floor is lifted by `FLOOR_LIFT` (a z-bias) so it stays clean over the terrain skirt that laps under the room edges. Room surfaces are single flat quads -- no subdivision needed since texturing is perspective-correct; ground @@ -79,8 +83,9 @@ rules live in `.agents/rules/*.md`. `#fps` meter div (styled inline). - `scripts/gen-assets.ts` — procedurally draws the placeholder textures and writes PNGs (hand-rolled encoder via `node:zlib`). Run via `bun run assets`. -- `assets/` — generated `floor/grass/wall/crate/npc` PNGs (`floor` = room stone, - `grass` = outdoor ground). Swap for real art anytime; +- `assets/` — generated `floor/grass/bark/leaf/needle/wall/crate/npc` PNGs + (`floor` = room stone, `grass` = outdoor ground, `bark`/`leaf`/`needle` = tree + trunk/oak/spruce). Swap for real art anytime; filenames are the contract. - `server/` — Bun server stub. `shared/` — isomorphic slot. @@ -88,7 +93,8 @@ rules live in `.agents/rules/*.md`. `Player.update` → build `Camera` → `Camera.viewProjection` → `Sky.render` (fills color + resets depth, replaces a clear) → -`Rasterizer.draw` ground, floor, walls, crate (one call per texture) → +`Rasterizer.draw` ground, floor, walls, crate, tree trunks, oak foliage, spruce +foliage (one call per texture) → `Sprite.billboard(npc)` drawn via `Rasterizer.draw` → `Framebuffer.quantize` → `present` (integer-scale, letterboxed blit; `imageSmoothingEnabled` follows `upscaleFilter`). @@ -131,6 +137,21 @@ Both are exported presets in `app/level.ts`; the active one is set in branching in the cloud shader. Cost scales with sky resolution — fine at `standard`, heavy at `clean` (mitigate: fewer fbm octaves or half-res sky). +## Trees (`engine/scene/Tree.ts`) + +Procedural low-poly geometry, faceted flat-shaded like everything else. Two +`kind`s carry the species read purely by silhouette: +- **`oak`** — short tapered trunk, a couple of branches, a broad cluster of + lumpy canopy `blob`s (wider than tall, bushy). +- **`spruce`** — tall thin trunk under stacked narrowing `cone` tiers pointing + to a tip (taller than wide, conical). + +`growth` (0..1) runs **sapling → full grown**: it scales height/girth and adds +canopy blobs (oak) / tiers (spruce); `seed` gives each tree its own wobble. +`Tree.build` appends into caller meshes so a forest batches into 3 draw calls +(one bark trunk mesh, oak-leaf and spruce-needle foliage meshes). `app/level.ts` +`scatterTrees` seeds the forest; add a species by extending the union + a builder. + ## Controls WASD move · **Shift** run (speed ×`RUN_MULTIPLIER` in `app/player.ts`) · mouse diff --git a/app/assets.ts b/app/assets.ts index ca5c580..dbea7eb 100644 --- a/app/assets.ts +++ b/app/assets.ts @@ -1,13 +1,19 @@ import type { Texture } from "../engine/render/Texture" +import barkUrl from "../assets/bark.png" import crateUrl from "../assets/crate.png" import floorUrl from "../assets/floor.png" import grassUrl from "../assets/grass.png" +import leafUrl from "../assets/leaf.png" +import needleUrl from "../assets/needle.png" import npcUrl from "../assets/npc.png" import wallUrl from "../assets/wall.png" export type Textures = { floor: Texture grass: Texture + bark: Texture + leaf: Texture + needle: Texture wall: Texture crate: Texture npc: Texture @@ -15,14 +21,17 @@ export type Textures = { /** Load every game texture up front. Call once before starting the loop. */ export async function loadTextures(): Promise { - const [floor, grass, wall, crate, npc] = await Promise.all([ + const [floor, grass, bark, leaf, needle, wall, crate, npc] = await Promise.all([ loadTexture(floorUrl), loadTexture(grassUrl), + loadTexture(barkUrl), + loadTexture(leafUrl), + loadTexture(needleUrl), loadTexture(wallUrl), loadTexture(crateUrl), loadTexture(npcUrl), ]) - return { floor, grass, wall, crate, npc } + return { floor, grass, bark, leaf, needle, wall, crate, npc } } function loadTexture(url: string): Promise { diff --git a/app/level.ts b/app/level.ts index b917471..f0debd0 100644 --- a/app/level.ts +++ b/app/level.ts @@ -2,6 +2,7 @@ import { Color } from "../engine/render/Color" import type { CloudLayer, SkyConfig } from "../engine/render/Sky" import type { Mesh } from "../engine/scene/Mesh" import { Terrain } from "../engine/scene/Terrain" +import { Tree } from "../engine/scene/Tree" type Corner = [number, number, number] @@ -24,6 +25,12 @@ export type Level = { walls: Mesh crate: Mesh ground: Mesh + /** All tree trunks + branches (bark texture). */ + trunks: Mesh + /** Oak canopies (leaf texture) and spruce foliage (needle texture), split so + * each takes its own texture in one draw call. */ + oakFoliage: Mesh + spruceFoliage: Mesh colliders: Aabb[] npcPosition: { x: number; y: number; z: number } terrain: Terrain @@ -57,6 +64,13 @@ const TERRAIN: Terrain = { peakStart: 0.45, } +/** Forest: how many trees to scatter on the grass, and the seed for their + * placement/kind/growth. Trees ring the room out to `TREE_REACH` of the world; + * each rolls oak-or-spruce and a growth 0..1 (sapling .. full grown). */ +const TREE_COUNT = 500 +const TREE_SEED = 0x5EED +const TREE_REACH = 0.6 + /** Outdoor ground mesh resolution. A fixed grid over the whole world, so cell * size (and cost) is set here, not by the world's size: bigger `outer` gives * chunkier terrain, not more triangles. `GROUND_UV` sets texture tiles/unit. */ @@ -136,7 +150,53 @@ export function buildLevel(): Level { const npcPosition = { x: 2, y: 0, z: -1 } - return { floor, walls, crate, ground, colliders, npcPosition, terrain: TERRAIN, sky } + // Scatter a forest on the grass; appends into the tree meshes + trunk colliders. + const trunks = mesh() + const oakFoliage = mesh() + const spruceFoliage = mesh() + scatterTrees(trunks, oakFoliage, spruceFoliage, colliders) + + return { floor, walls, crate, ground, trunks, oakFoliage, spruceFoliage, colliders, npcPosition, terrain: TERRAIN, sky } +} + +/** Place `TREE_COUNT` trees around the room on walkable grass: each sits on the + * terrain, rolls oak/spruce and a growth stage, and (once past sapling size) + * drops a trunk collider so you can't walk through it. */ +function scatterTrees(trunks: Mesh, oakFoliage: Mesh, spruceFoliage: Mesh, colliders: Aabb[]): void { + const rand = mulberry(TREE_SEED) + const maxDist = TERRAIN.outer * TREE_REACH + let placed = 0 + for (let guard = 0; placed < TREE_COUNT && guard < TREE_COUNT * 20; guard++) { + const angle = rand() * Math.PI * 2 + const dist = ARENA + 5 + rand() * (maxDist - ARENA - 5) + const x = Math.cos(angle) * dist + const z = Math.sin(angle) * dist + // Stay out of the room clearing and its flat rim. + if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 3) { + continue + } + const kind = rand() < 0.5 ? "oak" : "spruce" + const growth = 0.08 + rand() * 0.92 + const position = { x, y: Terrain.height(TERRAIN, x, z), z } + Tree.build({ kind, position, growth, seed: (rand() * 0xFFFFFFFF) | 0 }, trunks, kind === "oak" ? oakFoliage : spruceFoliage) + // Saplings are passable; grown trunks block. Square footprint, non-standable. + if (growth > 0.35) { + const r = growth * (kind === "oak" ? 0.3 : 0.2) + 0.15 + colliders.push({ minX: x - r, maxX: x + r, minZ: z - r, maxZ: z + r, top: position.y + 3, standable: false }) + } + placed++ + } +} + +/** Deterministic 0..1 generator (mulberry32) for tree placement. */ +function mulberry(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6D2B79F5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t ^= t + Math.imul(t ^ (t >>> 7), 61 | t) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } } function mesh(): Mesh { diff --git a/app/main.ts b/app/main.ts index fef890b..8072d20 100644 --- a/app/main.ts +++ b/app/main.ts @@ -114,6 +114,9 @@ async function main(): Promise { Rasterizer.draw(fb, level.floor, textures.floor, viewProj, config) Rasterizer.draw(fb, level.walls, textures.wall, viewProj, config) Rasterizer.draw(fb, level.crate, textures.crate, viewProj, config) + Rasterizer.draw(fb, level.trunks, textures.bark, viewProj, config) + Rasterizer.draw(fb, level.oakFoliage, textures.leaf, viewProj, config) + Rasterizer.draw(fb, level.spruceFoliage, textures.needle, viewProj, config) Rasterizer.draw(fb, Sprite.billboard(npc, camera), npc.texture, viewProj, config) Framebuffer.quantize(fb, config) present() diff --git a/app/player.ts b/app/player.ts index 5ba2a64..32a6130 100644 --- a/app/player.ts +++ b/app/player.ts @@ -20,7 +20,7 @@ const SPEED = 6 * solid even at big multipliers. */ const RUN_MULTIPLIER = 2 const GRAVITY = 22 -const JUMP_SPEED = 20 +const JUMP_SPEED = 14 const NPC_RADIUS = 0.5 export namespace Player { diff --git a/assets/bark.png b/assets/bark.png new file mode 100644 index 0000000000000000000000000000000000000000..33b2b65413f265f88a6eefa3ac6d4711f8487116 GIT binary patch literal 8831 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.MyST$hi zk~80bX}LKA<;69j=D5#j1r2Z*fweQbCYUmaFKaq=TkxuTz*fOGG64MWz}o>A+8|RO zObCDv1CeMji>6D?4kYhr$n&u50G9|(79|#K?;RX}R$C}!6)rQ* z(ep&#;BZ}5P>x*FImS5Gp3hy@M^Zv;HnB!Q=lcEu;uK~9RdM>LK9i0PjaFsSOR~5 zCS%DFEX@%bX|+~{FyAILmrevmzy-h^w1D#i7Udb>J!nr*geJ5kr)F(x)0 z8%ugE{bqJzpE(UT|aTX2~6KL*DX;I5y53QT5p$w)YG7bYX7Egc!hP9#`Ibet$ zV|Wi586m>?vu2vaNWYapMWEh=sIC77hY?U}fzNxwuHQEq|MS6n~02v4mu=R0~(wpMx z)HjD3l-1yg z9}w4rlGcySxaN7(7EwJfMYXSoTI4jr3br&zFZj3U?V(#_!bhNg16rCJ43W=i;R1{i zje*YBW@yeUGDX`0G|zJz^7zgt>#=Zr4)wKzS>iTKCEX{=j*^22ahOb-$teB}$_z$P za@cGrO?VD18^@>?Vash>G+smqg29X6;_{26A|(M$ZARQwheZXl085+s9UVdTbw8n! zp|$aXxGqeTC?AB8+GRB$1gCV!0#Xm^3~)l2=+g{4o+l|gU8nujVc#z#Ae^q)u`R;O zbFh?`?D-1hDks+HmNaOIF}R+cm&LgVzhRfDY6O(xVw&Z`g=MmZ5NIQC6BEI`p> zbkl8#;qeo3%7alB9zW3{iU=WU)oFG&C4Q(f^UKO*V)y}5q!n^aXL!U#uu8kLc2n5a z9w44zB``0FOqpMxSHwuu$ZUi0p_LI(~Mq1N> zcm@s(X-;W)w2aJJTFccdMkk2ej=429dOR$y9ow*@-8mfskz$>NNMV&z2Gk#sm9Mw& zvrio`AS?za;?m~j!xLHRGy$Z9ewC9kq>%OASvYE>V5$<$f(S@zO+9tMa3)jrWnmj{kF5 zxIL5|poV0pzk@4eDp@5p$dWd8S}~-xd$w4ZNI|hF1vp6l-GUe`2L+9W4$BjSOJh%u zGTZuCLL;p7s`hH&{3t$_i}*Q)ifnf?Z{y&IuG=tkGkm3FIX-cf6TiL#pd9214O4bq z3Tit9eh$&nzF#XX0tC_G=O9KfK8O~k=yU`Rcv6n^b4l`6M45PY=##$JZetyC7SKIt z@va=%H}uK+r;`oRUN^)Ithzlx$Olq2{TBTfs}MA|@c)t-_oiY!Y^M>}%@#l827 zwl~??rKRF05CH_%v|ls#$3~~FX_Z6(kfR2Xtz-eKw?q|n$TXTsPZ|H7`i(AYa?Exr zMoV#L){e7YO``!`vVhfCWHT)lFeg(Pk0GW2JGcU1%JwOl0!_vS@Q_9T3KkKpiPPf2 z;NXBEDMLivhY|tlZGHAK0VKv~2qfAiFX&8GH?Jqf*cBjJf1Nns;eKa&V2~SiM(bn^ zCMqE`UBJLSo*JFhEbF)M0|EiKG4^6vg7N?;$-L7ed<<+gHrJddp^_P3xrHi%f$; z^PH^vIVBp@u^SZJZH&z-5by=QJYlHgLDE8OxEZ43-;-8|4WfhT>U8bpr5P2d z5oJw!Sb)NtKX5)Br|yuN1t%EgoJa^D$;;v)9=xB?dJHU$n#XG-<49vnRtO79ljUY? zcN8A`ei)*Z120DUQzi&3EO|bfU~>)RCqu|0GL9ETcaTX;uttHA)G z2^yotNmYK4^dtI+Ao%!6W?iNz2;TxU(_7YvuInK9j~Zh_Gc`N!kGzt_$C*trFrtky zLf^(6W>tKRUOhFBNX5RD9KKwHmsEZ@PgnQ z*)iQh0|#7p;RCSP#1Sg_v`qnO(ymxMgxt9Oe7C^B)(4|z?G~327hyLxYqBAdQg=Y7 z>+6lKTVeSzObOlT_uP~yP^0`VWl{0FQ8zT0vH;j%ksKtNf@w6FK}Vo`7@ROb*?&J{ z<-m%{3<}QJ4dw~pPCt>s)S-2k{YzHZS<=qq;qJ2P9uY)V^PFOW&aZ>AVoUqfdl5l! z0hk_0OiLuSkjjj_d0K4MemZUDEJ^)Ms}9`<(Q~W1xymIb2E$$xL|=E;z`fmp)r7_(WSdUBC%t ziDafU(BQ~xmMvJ8!s-U>;Pq$5i3Za~B|S!E>Nb(XW18 zAguTtc1p{yj>wXf(i*@ddK?-h!8Oqr>?~+quAlm~>Wdx$=;%_Y)T!k+?M#g1xCYNj zM(V>!_VkzwuyqB&Xrj>*tKtRIb^|M{hu1WEqQwa48X=h-*wh#pC@xlCSee5#b=zMH znlmsVLjUuj1>2ysM0tdvRc(Uvbq62^AjA9N>WtkXV&DOv>(Ty>c4n1X_y~;YFiDzx z^B7psy`k&9(wQEIjA?3e6i2=niG65M{ zB@`Av5ai|ek+@ost0uIEgVO`)nELdc=#myE+wl$6?0ti80o~VLnnzARjyi{vkn*TZ zSQ~44WFafAv{$qOF9jz#E4Xh7HFV{mCLdxT-{}aDD*lA#-&H3aLmq=`_4;-gj~*X+_yYQr6!YK=qM@6_zhE) zRT;{J+I1gLvz><7={}66GtnjJ)zmKBfv&{5D;R^pf#T!y-CuKrftx_%5b0Zof}425 zE(t6<3hW)@PijjnuX z!}aVX(Q3d4kkV4K?CYdP$7z2`8>KyLvoRlMoWRJ5k8IvRg0tEyTS!6`UCOF$;h9a8s&?(#Rp(qUy5Voub31GCO|MvKf6V;%BQ|g?u+e)(Yn)11@5QVQ zt>mrmfp6V6GnRgHo%KKTt$8zJ&4sj$eOtE1$&~e9^sRb4Vg2WQYs{srXEAHlqY3kO zN31dxx5j+RvfJA&|3RnKP9$yor>YHI&s*(8!kVYjRz4cD>Qur8ALp(Ak#D6#G3!2= zvF3c*hCV6iTKT=5y6({Rf>n;jt^0Jw3J1HaaG*=;te;BS#+Nl4xm&P-Yrc)Ys9OJJ z-x>=k>t0G*Z7yNi_u4J><~r-So3-TM*P54leVx^gCS`nncc*3EZnurUR&B%MiVfY& z+t4ldh;M$yDu?4%nTT6{SEqHaWVCpUFHgm6!}k>%xE$Evv%DVJ_uH-eRK|usD_HSR z)CQIV89(wx(F*&!tUi~tz6&{h9|!O{{Z^aRk0)*5qrm*_9p>+dSl>C{2A%}gy^zs0 zYtu;^{=8s=SKRe*?E3F2w&mXqt+VfPPS3TI3ETK<&6p7dT;adgSQ<;oguq&e9 z7|x0UuKG53J+Q{9r1f9%ZRB=AP8eP(Sp8&D zuR(9$L*L4WV;1a;m`B7hF8fZq^{iyAd?;?4|5euo^`+lxR-KK@IrZZyt4<~?_4jp_ z*t*6_lQ9|AIGMKM;iv_BBZ54U@4w&+vH_1h5gYraZ2s;J8~iwL)#-!{-z?bZKT6hf zCTpc5Q8}S8leYd31IxVKW{ndm%kS;7@JSIugg+ztd%2#fb<*dta&|c#Sfwu+xUtM z>pq*Yt$#M;(aQ0d4c^RK`EY#I0i;8Icb66b{DCm(fI+~udMsfhpBME^gz7U%y;nP) z5CSl$G9B0J@gJ)ed=Sz5T)#P=5%l4M-1{BY|B%MNNxJHW}{!1tY;}B`0)(k^_|bj2w)H7$#3$%F`Kg9rJOZp(|YE| zyU#ch+Vrxf=#Y;aUsP@ITHx}N#ZE*8XRb5$kCI>xsz$yniYrJPj2-!^XoFYs7VL|N z1NeONMn-8|pH5k@tJ8uVoz^^^mMsJUe&YK$o}lqvp1r{*dFzD{UtcS)^xn%^X)0#* z`J_IK10WGC?5mv{0wk&4n_uv2=!S5^YpO;0fI12>wd4d3$gJc{)8pBuJr_J4 zzU8)e2n85Ea5YfG1}^)`Bgzzu!=o&KX!M@VSz|t}D6l{rG5krvg542o%%p7SSzgW{ z1-K^gC#^R8ToLT4&3zYhLIi{r?CaF^F^mQCo4lsXL7b$*@E4TTE*XtM5aQ5h1!)xY z42FY*!rm@jtM_bHM&k&gF0HccE0qVY<;{b2iJD5zWL#(fmd#Ts7o9U1D7r}$N z&)-0lXn_!%5DEU`S3B%aFOF88|tc)WtTu8fHvhFh(r3eniaIQ;klUg{Nnm=?c zFCM@MQUjv}f6#HsS1HfF+b%BXyYE|K%Nn5vC$Lr=kw4I>sEd$ilR}hNKbCSe{ef@I z8)=op?0X$5Nt6tjIlg6$%F*D{K)Dao7Y}rap;dy;5t$$QjUiU5MY~)^1 zc}MPI04YStWYMY`%f5Idrey!2Zppv}$qXL|poToX2?Bn|!Sti(B6XUy;KB7!1J?Weil5TlxSb z;0$s(G@KMb3QG)4#q_#xs7q@o9*k-Ypcw^83CC!V$1|lJq(Q!84@$QApLO%dmCQTs zR@zT}^yLUjAy7sWAdmSUtd6)rkf6``n`dM#|0C~Ve`M&Uf7Y$OkhJPd+{PZ2l_J0v z_9j|HUkY$OC8N<0IBEU&6&a3m$Z>Jb>9i9~Ul*IX0sV|Y*j z5Ig)RdCm{}J3Fj`(GN>fkRXBAz#b#XXAJZAbcjdb7=KSh`AwMu9sEu0=QWY{_ePxL z{;sU=BAR;7Wo`J&qELZT_&hS1b+883h8`ipXpRj}Dw4+imvh#8J}WfvJaVUK9!B$k zK~+N!$K?QODIzJqr%N)J4vq+Bx3#Op@f?yI&M6&^sqB&S^b#9BqTVO{J zVS93zg;%HJGM)%iQZb4M_nyzKDoxPhQX(!kFHJ`ES<-=&!C3?ajvD$nFDEolrdOd4 z5*w#eQj3-2aisx0!}=e}=8-eNok&s!Au{s2XE7snM+=J##c1U?u>YV-UD=jD8Zs2^ zO*clZr!)~Uq9lEPGSzBJbGGUCx((A>FXwFR+mdxJrfuXl;>u}(;{K>LXVVUSuQ~J~ z%X`jcq#5`t-bG-hxV&UflFB|BIn|na7!KQ$HcaduZtI^-8@v%XQaK(I z8pu;v9O5b-?vi03qdXn6f$M<`Vn|V(h+6qr%r?HLX^}(;&PlwwM!hwKEmFa;rolM!L`8yHVd1L{DidWe;xdB6q%1Z2n~yr|xt zl-TKVAF?Cg(E=EcBltm>6$C&q)!Bp=yWxk5j3)(9k{X(vP6;JK!MTjc3F%8zFdih3 z2KDKrPzDcygueSZMXz);s(u6qfC8L^*ny`&11gr1jU9atbJqW%FMDt>QN{s?E^;0O zgqBw-3!kN~A~3=-1MByk&FaE9V&v$q|(hO?k(lK#@Kc6LU(Z6TKHzl`_~C_|ON? zjZr?)5Q6U3HR{MJlL?6^7C7)>pj_6B;Ale7#?kyHb31A@Vu@KEJv{S1I0(Ig#MTUP zcb9eF%1GKmj*R7CctlyxQdS6MEvQ8aqicCp81w^9C4C@cwcRTPMW6h~@erfO00Pp$ za2&y?2&J^?H>cN$-q`(;en@(ni40Si^KPiX6p0*!lz7HqC$8EAO{6r>8g-q{3O!td zszEh?LqN{>Golj+WjFybgA&A%I5*f4(Qn}lL{(TC=|L#5JkS>K4^kTk0C`de2heO` zJl_MM^a7w%-5^n+1W^rOUQ`mW~9iE?u$EmC5^DAmuKd1`bM*pXjVw6CL5Uv5quA9x8N5pA*LI$NXq>(`^AQi*%!w=zMypC*TfsE6_ z-n(`zDZ@3+I^rTnZfdNB;fq<7&~RX~@#CrpbmUG!JVL7BB-AEI0C7A>DF(HTYi{U4 zvsOIV<)%-U135u$^ioEFK|mp|apc%{W$6GqPV%0XJS@#)&mB*BNs4{$26iG38;Sn0G=l45AWWb-4ARQ=8{*Degg7;Bn!*>cccry?J znCBC5P(XfDg6T3zC(%mK0sJ7^TO7i4d3%Q<4(pR<>N<~5Hrq6UqtZwR%%_x(z!4O| zH#maDsZw0bNmv1AM2*<{aYd?<(z*VlvZ6}bBFMC-alhmocsWo?(Ogj(c#zhVc$^>y z)TWYFIuH}f@+{VFl`qESw8y9{=IOkLlgMivqaBxpq~`AHAC@&?!8n{%{GdxSe!fc@ zfdF1}4XO@?0efn3;b5oQQy>|R%z-na2|0_SU~ZHn>XHZpVThCHqa|wdN&QXrlF+)9 z*Zu+XL&smOS;Ha|>6B6iiS989gJ?(rW&%K;{H8o=hh`=%bp~uPp7;1X5A?k@9?xV{ zVn74mW4er}3Y~^i_+Kb>DYpzifHdU^BXJ&NixX&}i4uPUT_(uV?+dPpY0^|(^Th!e z&1MLwAO%4M1KrX4B~`1TtNGQL1MN8c0XlFZ-|?uwJjgpBi1%qs$Q36;14RcUXy@i; zMs*pq;2@xl^99d2j|F_6OV(Bg(=6mEbd z;Rzgp;~+JlO*KNI)1ntAqpDJabfT?|9a56O&`x6q&UL{`s!;<`UjLPxz4|{*EgW*u z0ONwIncA?>6t}KL_1>2VS;rN>rAvfU{QVd7X;Ar=Tw3tn= zHZpL7%ITCtimJ_>tkMM7SC7ZlI#UJcPasn?0Bt+^f*^rFI0WNxJhOyw49{K!DMhb> zvzR~NSa0LAs`k{V#jpOSsip<-gM@@I)IeMf1bIXc3)XppEo&r%h&qvFx=e2XOf~YR zd;1kK%eYO?M}eHcrX=639ZP7q3Q6(|d15>%0@5H^9E`AGey{d=B5I@Gl+>)T--r|F zTLLl8hz= zXiDhU)jZrUsUZS>3^^?#xR-Dag+r%BnlE4VHYN* zZf$(R?Aq~pQ59hkDaeY*F;Ioj*nI-BVWUFJ+r5$%%5WB?mb?Xxir#EOyh6ZGjc^UB zjzh&_iEOi_6@OQ5q2=FL4sE#YsF5{|--4|j5=<$FE;S!!9DVSQ5<@vNb@UV~)oIwJN zi~~q1h>+5%89-~WiQzCjDA8jsA>&bsL?4tuu3T5>IuI3qAeO>PhF}6QI0u_u15vsFu2}eXLZc~)})_&dL zsJc8@A9Ro^p;443QZW=}3_tj7chrSVl$=rlg$Ej05ASjGg}p!4uEQR0woxgAUPDNs%;aE0and;f zcvC*d9+Wj(WVFuy9qlmV_OW{JGI^B|*L?URz`7x7Nu)z#r9z^i>)^3e>QX2Ivxx`5N0=Ony;i?DD?)zuk^No>bIq z@I@|+XNg|#Jx7$NH_!v4tE}yI+iKK%Gy+wF%~m4A?h~G;YDgYm%d4zuul^g(21JAk zkcGs?!8n?!jkdX&RPXOpL!5J9~J5!va#%waC)|g;1 z)9^UK#Y1!T#jJ9O787xV6T&?{rbVbVdV_x)A*t48Z! zOeDqsr5!lm_uQQ~qJn5nf48W#faQrQaA%PWJyC!-gQ*YK0iD{qBbOhzLz;*t(hx^5 z@IYdVZ)Q?*0PQ)`ZVp{gJ_)|Zwl@#zF)Yo%f`~J&0tvuP?dd{B<18+weFU8yNLqQq z9Xw<~R1ySI^3}EO@Z~`_&(o`zlcZ6QU2h zs%`wUp$8Ws${0#1!6Ezr+@{LML|6_+A9e7UVdkfrSo$xZopD!sbJ&7=ls ztCPDHNV{t&d&=!C!ZV@85Hd;-gc2a_645Xnh>EZosxoyZ ztvrMY2w-DDrz&P$i^-5dGrJpZW(R~2MWjNwi9#CiOiE~%7$i(3WhBuSFoqYnMm|5;%-NG$x`_f<#GC zY#l--Iy!;&R-1C1ZE<8OFr`~V6Nr?U>n~)iJn0Tplfy(4QAKKS^bXC>s9eWzXH#-I zJJygd-$!XOG@+s}HKJrP(I%yJc40B=5PLYLG;^aiC)U&pgwqvr4)TEqFdk-Sk!i^9 zQXq5)CG%+=iGjp098J!`a0JjqJV}n;c5n}KodF21AuBdVm*4!QtTy96-flhnhfXyB?)?F=v%yF`ob10?Af!_y`htRu6tPB{``A&kS( zC(|myJi-$q;o&DxK%N3s-iHYJJOQC4rfLm;#?hOiRHfwiJ*O=+6@DBjW%&;JhAKdn zp^wmZ+4a16M4uD@8s+0Lku_!%IyOA zQk6(+37%aMr59<;gQkj|FYH8jc(6S>8Px;|Xe=NPoWSDx&gPsz+UpiZ8gS^D=;H)9 z0dXO6`MRVZ#dr-vce$-@P{Q~LqGMME(L(+bK@CXH=9CUN6Ljgs>Ar|c79%elq%(=^ z`fh6zDydxLG|0dK!x6dk^ucV~Z|xD(lM2IxVKLZDo<_oCDN$k6oL>l7jQB!n%-QYsNo3@Csp z3#Z)C64P2!pE(4HbG6Smmvm>9Ipi>t&`1lpjG#) zB!k&HM|2^%g-q1lydyo(L0||{s4#U{az3dc4|ekGu{%TL(dPUJC&kGTqJrmvlF)I@ zx9)RpYl*bk{72o5mv0wc!}8KiU5I|DDwzQQb#hNz)oP`l}kRj*1*QV$*3<(cPYi_V7;!b%^#6*Nt7KWWfkKnLAzN5kE?W`qU zWpdmlc@EoiJXZHBZEu(3No~#qZ}`wTLc2|^<2yPk0|Fo+q6`A~0wXN$BchF^lcRIv z2Z^j2iqoxqzdi0|C%#W)IP}CKaR_=|B>AxHj>|qQsh=QycnvI3d8+H&M8&NCdO=ZU z^i4oOgmnGGlI{T8@VKnQ6d;ibp&PUy$TMydtfMNuo7g0w(ZO&a z9*QK;0Ws0Ho+440N=e2tqWAX&DH#Xk{rmYbsm6llYay~7%hc_oeD4OoHlqGr_Cr~Eg4`vI9EuzS0khzl89EyHkmW&3*I@)?9 zrc}_FotFIH4P65VkPbu_)k(GBHF?eVQKa+}Aj`d%Qe_|oSUaa)L15845y3|dGZn1LRc8jd+f+)k6?*MO69Zvs_*6tdm>sq zb48S7s1{Z4k<9zun2WU0Au9GHo>i4Xz@K)PHW@>4CTE+G$hz5zyUgZYv!s#Vne6Hr zVr`PLjek85;yupeC^yjDJKFWbusG8mo?%mDEd)b;@gutT?{r4F$=)L6lCwTr{-`TO zsQHZ8-JqpY1cU=wKzLs;U7(K5wa)kQ9>Z7aIw#JU_3^hhKM!=NV}1uwnv64GObY;g9H`{j9gFe zZg${MBDx)T(moSaPF%3fP8*H!;qU}!paD>Zq0ze4=Am;0+hXb;X5GPOO^t4|H|VRz zYx}Ep1163bc*)FWHm!4~%uiTkG1n!4X${aRZYfBrmV|K@Ct(W<*LD zj}&IHlu|}*po11%_avW4YgdS23Th86pc^4}xf9_!OQ;hk>D3dd8j0P_$_W_G{vzL( z()_(H&S9e#@&#ecCw1=dV9Y!sN%X_INrZK1N_T-cFOwp?N2${N02h6+HH@rN8m-wO zNMJ$?VbVsU_%us=ns?nB)1Q;h!8lcsdmLE*N8E(vHdh&);fU~79o?t&)ZIx&3Xo38 z*W6hp2w8W#wobFLJH)9JQcBxuFWrsPbcj?tN<8}-3U$q0000< KMNUMnLSTYy+m2`e literal 0 HcmV?d00001 diff --git a/assets/needle.png b/assets/needle.png new file mode 100644 index 0000000000000000000000000000000000000000..a6341d36a4a6be9e92a68e09dd8a50819407da69 GIT binary patch literal 8873 zcmV;aB39jrP)# z+lJq;!fw}c)0X9zZEJj(SaCP9%K6ahPh$)JZ*9RJ3rqI`tA7|<<#uTKRmW=2vHq>z z$5!0!S^aHnmFtn!9%3sU_bnTTR#HU(tKP->e_^|8nNeuv%YjwzN0yzq_sNbz%M6;9Uv~6w?K!dXY2Qjm zee=Fdt$a4n`ieU}%g;Mj+V5Lot7pNV3(GIMmP)sLD{l>~u+_Evylt87rd7^{=6{}B z?J4=+SAM5s^)C}Eo<)|Mw5@tOvfR35`J=XF#!W4~jR2#3J#MXG6TJErI zwYx-{Z#+*dyW6tDqGy$>p%s@sEAB^DIUHJcr=`ydeqUH&zo)Op)5N@&nU%NumX3WZ z?)9wtG}7Ov*S-~xdgi@P^}eP3NC#EgiYz;CnfGyKg^QjQPa>@}^p zjNBnTB#NNM{nW~rLn|K+tb9AL^69|Rs;9A44u)1Z?pbls zv&=ZO(q5$K@or{TeTXf8+%fOl%$na;*8FFwb(gP)mg2^9yKT$#LaXkNta#e9>OpMY z)6|OFy`Q(y)j|l{(n)0Lg>RdE&oZk(hm}9{iP% zgV4OMGs|o>t@fHI1Z&rcHC`qZbHe+wZ{_QOmCpy(_&U`=6Q$L&*ea((^S{ikazC=dN!ObH ztStPtwp4l$=++AJu5E-3+YCI*^qLm_xwh(SZ25h+nELD3yyuyf4k9CJ^3%57JTqxp z{-mSS@V?Bf@r6+AoBwHUrMtfJym%g&cQv)*MbFAN1AXzngueeavvl0F#@ocIMdQ_jq9nEABncea(gYyteRFkO%#FUEAqRopJ#VMt3Jn;UIscKF0FDovdkj1 z+HGQu%Zd5lW>&q5t@&eVg^RA$-zS#Zh#R&!^=)(ES><|Y>3(48C=h@&zpu>uJhkwB zZNaCd)!xTeyNj)G+*Ll8F8W#|o5_wt^Im7>f0+po>vt2YUdF-&Ks0~WRhqFO?`|dx z;WPNm>SJuxvytWZIyyk_ZD#(Ta|d!eE&V;YosN8Mlqez(0?60yW8sog_pWdGovtD#MShY)nuqqcrYkZnojtCjIh1$O=M~#I%Czji8>GiyK z?PhG%%aIi>yINTAb!pA7OI4)cb)l3iUPgL72joA^1&sOKjukH=9XRQkpLMKpJ<(xQ z?ndT6%^Zf@4^$zD!s=~orSr&2N0C+bhN>Vbz$KjoR)0;bcp6#tdSv;njy9K@w-utn z2ZsZcj$G0b0<&p8Na1xmRi65{Gt2L`t$Gt%Zm;c5{Cr^5n~~LSV#_W=MN2MeTW-H& zrR~USSFu8sbOX&xH<1Nj7W%u&#mLG>1FN2lwD|0_rBvl`Gt1Cbtc#JrxX$L^$5#J1 z*5}rrC+2;asdU!Q#=?Qxd2IQlttiS&n@XWn?x<~zo3UkPp%%E=^K3KrRgV0RbE{p) zmOt%iql8=e1cYz@1S*HV03~5rf1H^AKC}Em$0{d7^FGh?dk$wa^4$&HCW?Z_<3#0% z@C4;OUbov-l;l?Mkg3(L#scW(=cN^oT}f=*PE>|VyOG|fwB1*gq1<|JQ&lcF3=ziR zRqlpX+U;8goVlF{-{3ct#PU_&Qh-iw(Q;&AtEX~PI_c|FNe6C_(DXh{MJ}kfjpwQP z?{h0%L{{92EVpi3e(J!IN&rw11+@=}Dv5IOlz;mwf4>M0he>IwJPxh-`%*y5CizUz znyN#U0XOQmV^sz3d1~JE)Eb{AI<)fMKnI*%gjU$=>AgwS?2O9dK$nYz+fYHu+XI1Z zb`sjgq+uz-xq3KK*`oXbI@w{%8h1bQ`F>)#n1S$@{GbT=?4bLC-Z znSN8ZmE8@MUofo9PE&-ZewA416hInVW;?X>B2elS);()}Sqjzt-{;zJcHB~`f%c?{ z|8*u3L^_bWAb;Vo>qyP*#JtC;BDFAco1_eq>x~Df^3-zMZOb2ZlnwwPkwHrPZ*zTy zG|KTvYa<;*0H-ZWWs;_qb|QgXVcxOae#`RXj%Aij+Z_1%9-csH+-!ORz}!|#ivV<} z3dPHwkUl#Ng&~AIr^|tP-=?a|qwW7nTJk5o%Iyz zpndH_tUN9rN21fD2*k{jS-;(-(y>w8KQHJ{gJR-srQn@2$LGlAme(5mMnu}eNTd(={T6}EcwhJKbF2I2_{`>l>nln+!Xd>l#za`MPz@(T`MIO$pSab&@dg@jM@Z7DSW-{KD= zu|#f^np{5W>-Wf1ldAb~Tj`L3tCPR5KDc^z9g6fwH_y7_*>C|$CBVjEG(JwOapl-vW*rKF zx!uxfr25NdbBngP0gG3ye;nx4tM9S-PYWv@M?z@oHlfK4^Lh?|n+Irl4?wGQ7KtBF z&q)(1j^z2>SZjynv0eyO`F3da`?0tPWe~mrOC$mcdtDKr0uuVmRQQqNCZWBRE63LA z=ZU^5HzVB)AMZiY@D7m#PqGl8dEWM|hqvNMtnJ&udx2X=G1VHZfv?+K2m4MXa{UKsho9gpO zV#~Xq>HGY4S5*M%UA`OYa^Wc{C?XO#zttANu^~1|$glx!3L-*T4D@0`6=r#+Unj&>&?Y&{XV@N=)HIyUPr2a z$s6??fw>-u4wLUNEy@S~g37Q!YPt}6ClZ_D=2T;owiYW)xJ|72acRxBl?C4x3So(< zFB1Wq`1;F4HyVChspLUa!uORG4!V{*Z7aG+NALHUxB{9({= zRe3TJZ{fx_+n%g5$|5OEWY&%o$866cMGUE$qNH()+QXkfL+ScuMI((^!(!OdYd07SJj2UO?-C$C4! zQ>3r}Y1R>&M6p%K%?n42u%rlG*c}FnLVmw30>nY8wjcEcX!)cgyAD7`bKqI<8H6|G znTvxrkW#`2jN0>&ij|7rR246?Y=b4nt{j zayY#X#PZPD5R&vLa1PKx-%8g5F)@^n8C6%slk6StvsQ1RSp)yFiagd_UsJy#@xC>=cdf}v}at-5EMC=p6 zp%i5bS5?H~cqD2GaVV_15-YibmT&>0l36!ZvQTuWL2?dKLPSua$i@2SiItzYb!UQ- zo78N{)Vqn5&fxaw{+Ys>&%vct>09?4d5T-Y2SU5D?=1P?yW0QkyMs7^yi$B|QwLNgBKGT1Ly$o1zOC_H$SYb z@;tQQ^Fkn4dmcMZa@@7_sHw87(u-Mo>9|5})D~rDqug@sFj18JieA32T_)~wkGdji zxL52ig^NoahY?cxB!_8^tZ5=dR(=?m_d8zyL|mL4Mg28yrrIoSE2ht@`!vC(-m#DuwUlDefSu~yfeLl4O zq$5)kXBfYLgAk!xwX}xcMs=}M%u~ud`OaEMM?g4uom=fK5%);}F4)rLGuOqzK0^Jm zDUiEx))grc7%e+asvbFMOuprIT3Rf?rusTTSByk#D6s$+yrR6*S2`8vJsFr_4<;v` z0h|FEj-e?Nz0TT zIsuhhJB_V$8>viE4*B~OC{Boh)GUyN%D@24uVeH7n(JQ_9Qqq%2YCSz@)E#-d=O$A z-G*)oBZJcFJb*|*B}RGPbN=J$Powo>B5$AD;BR4pm4}f9?+al`_+u^J;D4Suo9u0( zH4q8NW%U9M`l?QwNcX+AumLefDOKgUojCpS6VOTFEZBDI4lD(c!PX)Nc~d$oET-{! zqBfLJ9RUN=dWluzAvcg9EVKMU*Gb<~cQEk(-^L9^1}!(`I1#qrm%?NrwG((kBoYOn zZ6>t5BH;~8vi^DOQtO4T&PWXmoMbl7t%Ky1laV&^+oW#4`3>9b`j(yq0zx8Aa{cR6 zt`Np3G?c#}G#umq5R0pm`w*1I>r_>Og*Be0*7)L_o6>n-3xHB` zP-(n_?T!;utFCR#8t%kVU6jv@o;<|du;tMDWvnI(W(R3TT5XPfRS+1ZfcLPgjj|4^ z0-xjEOa*+{@uVDPE8d=L?s+JG0?Qn?8D0XP$V@^RtVAp+hvUstoCVajc?9#C_OlT5w@A)MmQzdA6j09$YI+yhS*^p5gv$AhQ_JDhfdB&~r^xHw{XTSF0Iu=o(w85=7bO$kKqUzNURdyTAyR^1Y5rI#l;!MPjV!;@ z7CThGf6k0S-`&b-ti}f6FMvCXoPl}eTq77Fx`I^nALr)(?d~?V$AF z-$-Uaku;%da6=$E4GbcLG=eN?z~XxBZ1Al}3#Mc#oj91Yk=2421*o=@wm~J&24fR@K;UD~%vAls!7R zu&?0BH9pY`Dp^|{H)WD^42!mI88R|JU6nRVZfCb*CnK-{l(;fW+=R84ZHiiwS_m7+vPx-{#*Pz33 zNApC088{>vz7+^We^n=ZLCQ50ohm}91YG%^w8Q(u@n()mb42txKe*O8sh|iTHwa(C zO_KKLXPteBEqGhHX|XFna3tBuho_gm5Sp;o=5dl@Ux367GIfSwLHW~E*5{uf_SZsb zp*GsEB8tPKmXY+Y!<(r|U$RYl)gjaUaxA5<^78|@R;W=Wcdy zXq%z$fb1$!EoafcwdxWf5FeSLKW1W;94Mj+1O`*!=NtmJ!vUh(p|XIiM9WQI2Y__d za0R7z?%C$tSK2_c=`_(kjkj=%;-N8`VNE&L4h%v5x0T`24x30B7pGebVEm7H$(WI2&oxSL-b%yut{511>ZpRW{V0U`zc4)I};*j6+ZKwP*ui-8<*q5xR)ucfHzuPLzb^V)d_SXkGw zn;)XNxf40x|9+|ttrqdsF)$WgKJ2T~0BY(8FT1)GWN_tdq;e+T;N2Oo+%nNc*(Jg- zadZe*bfOaZ2*_;cGN0qor(mGt(^ULI9^o>uGV_Pmu2YGaB_@A`^k<%o{{0DmaZkUO11_;sN^5yyd^USJmuC>MO!tXIV@b77LGa(9a zkwOeHA>jYIJf*>*NRhwR${q%OG*`8fwE)H2vNqQ3)5vp4cr5Pho`}~j8B8+ zm4U9Y%AXGXxF@A4HHg2DmS!W!U#w}mKM*1S3_UIJaXC~7A}qwIUnZ`-^|I6lO2K`d z%2Px+BCV0N8f|+;=T8)-YydJNvva$vFb;wrnYdh;J zg-8qB=jQuL7CIQi0EW`?xUY^9htHv)FNgqu9!800!TM6=HQcyV4y^WhEUh2@vlie$C;&D>jz*~T_#5*Tz zUC-{YEeD4UbMwE3tU-9qA9Mndq4HkedHoNuY<0j>^`0?UhN`|!tZ>s){%acTZr~cO zjQTR@b?at^NP`q_yfOFGK@kEUI)9M*4zU1$G~|qcSqH)crZU2hE7fOwJL0m0RlyT?%fSR15Pkc#FEM>3iAl-YG&T`%&A8v&JH`m2@VI&LCGW~-m9`M23pw@VKLCtaQ&8w}&Qgj2n)@9Xdadh~r=p zNI1y3Wy?`#A_|^=gv0CxoTwT^9xgO(HNLn-E=a^{*;O@S%m!`2L1y}a<>QV@vKlHE ziR^jsi0F|ehEpqV@Vu)J7G?d0avE9i&%*TtE+T9FYc1qdZ*b4q%A27l)&(;Zquj$) z9=U<&bw_26jo~+Nlgt|7)NDka1Pl(7=6*-M2o$$RHDOjj@3-q39M!jxZ~*1k{JPSZ z4L)H&D|{z(6#CH34gcy3!bGWFaGZ_66Kz)y^3z=O7-gHnBiHEa%+2-IjuHVxiD~T9 zfdmpyixB#%{})5GnCKr?_TB6qAE?gmw5!{Iu4=%F4(z`rk)Z0& zozn22rPftNV8eXqM$d^QtaE@58ciC&BXmHRGFu(j@VFStVImR$V*rhkD};wpGGEAA zu6hDJK$A$t9s^f&^N_2F8>-Mez(HT_e$5;mL?U9YJkBkJ8A(3!p@bbwkbEKcr3ODT zu{{vLASJ;F)Sw%KPeetD6ouYVM>aLzBSUe3Hhs^H+%0^m!{3s+o8P^0hNzt9c~6$O zY%!XsJAF4Me?3*?;R(PfbudSfoBLzZ>tP@xMLyr_M2Ae;arm^@IS39GbqYSvk|VWA z0Riyk&{Etu0ElY?A=0cc<7vlTl`eX4ocu43UHW>~6dNn8BF+9_j-uo^Z9bgPR*D?D znSPoi{G9p@fZ{%Z2`nxkY4luXORVeEG>WBcC)$6_E1|t zO>|2z#L6N58slp{H)H(36JwE^$$yN6zQ3L`l5M@khvM!!hnd{uJ=IV>?#s+z^MD-y zNduP6(@240voZ2qBlkO@O69L_Y z$Ce_$^IKgRn*1R?9AOP%A-~n_fu|q20SX2a7+lh`Les9~rijjo12snCAs#d(NCg_8 z8f`yHguJrcw_7?qHXyOXnBBdjt%|O#uJD5qIDRi%>n3r7(R6x-ZZeb&mM`5!`{IDB%-7hd^PFyf3{;=8mDzR5mV4egJF@Y8if5x#=m=;%zJoTR?NbXsPq#yjz~= z8d&`%k;6iV82YSf?=!f&BSpVv_+WFGtAG+LjEcaaNV)L@iCWk=OU^B(EYi`XOT}U=pb@(3x>58bi8T2E@Qf|Y(v~w$PrEWbG{k*4 zlzxHVa9IE<6J&Icc`kuN5I}BsEJc|@K;Q+U=5U8(hh2}Yamk}|kv7MhO6)L?%QHgf z&f!71(VS362r_h@Xkv_TCY7+b=r|GP%oQ^3#xpw_qd^uQyKWEVlMTa4_0UD!)FD#7 z$XA~!l)JvnRImn-rKZiY>jo`Jm-~UU+9`#HT^T);bUoy?*V5r4u83|uM9XK6mTR(P(sIuLJh~3A6eMMv>T=XkRUjhZ0;HW- z;kFyQg>A|WQO3=A!e|o zX-N8iEu_2v9uO90NGedWKzugDZIT8WeSb<^Rk@$)GldJ6k${cOkniNSnA#y&<2Hk- zfmgt@45XN*TCe*8KUg0r0`X~na$bd6^pl2^BN&6Sg?NEMdcJ_ibk7qyLwzAF5|x{! z#2_s(Kgpzs17oq449h{)=wLG$Y|7)l@quD1C%_h1#Yoc(|s z@D1h@P>@*0@4t-Nykz=HG#cFc=6;Xt_&DJNhHK+Qu4rprmdImQCbF>08YR! z{9~;NZHNpWy>4p9wZ-&3G<4aL7Y8}gqq8Tj2MNmaAd`%aRo6Z5!^79_lb?D&`5zA; zWypCjNJi>atjeL`iNm2x4|*8F1EyYEzD!dr`^xUXHfEmw4=@r*8n@pbSc=o;HmJ|E zj_96f2A>FEzMf}sF)x4K5+F&4s%Q9+XxKmz@w{-4)N#|V=m?%Ga8H<`>NI+H+R@E4 z&c||f=+@`~?TepJr}KC&WbC>pV#Gm7j{tzXk<3@ full grown: it + * scales height and girth and adds canopy blobs (oak) / tiers (spruce). `seed` + * drives the per-tree random wobble so a forest doesn't look cloned. */ +export type Tree = { + kind: "oak" | "spruce" + /** Trunk base, sitting on the ground. */ + position: Vec3 + growth: number + seed: number +} + +/** + * Low-poly tree geometry, in the same faceted flat-shaded style as the rest of + * the world. Two silhouettes carry the species read: + * oak -- short tapered trunk, a couple of branches, a broad cluster of + * rounded canopy blobs (bushy, wider than tall). + * spruce -- tall thin trunk under stacked cones that narrow to a point + * (tiered, taller than wide). + * `build` appends into caller-owned meshes so a whole forest batches into a few + * draw calls: all trunks share one bark mesh, foliage splits oak vs spruce so + * each can carry its own leaf/needle texture. + */ +export namespace Tree { + /** Append one tree into the shared `trunk` (bark) mesh and the `foliage` mesh + * for its kind (oak leaf vs spruce needle). */ + export function build(tree: Tree, trunk: Mesh, foliage: Mesh): void { + const rand = rng(tree.seed) + if (tree.kind === "oak") { + oak(tree.position, tree.growth, rand, trunk, foliage) + } else { + spruce(tree.position, tree.growth, rand, trunk, foliage) + } + } + + function oak(base: Vec3, g: number, rand: () => number, trunk: Mesh, leaves: Mesh): void { + const h = lerp(0.8, 7, g) + const rTrunk = lerp(0.04, 0.32, g) + const forkY = base.y + h * 0.5 + limb(trunk, base, { x: base.x, y: forkY, z: base.z }, rTrunk, rTrunk * 0.6, 5) + + const blobR = h * 0.3 + const spread = h * 0.32 + const canopyY = base.y + h * 0.72 + // Central blob plus, as it grows, a couple offset ones -> broad bushy crown. + const blobs = 1 + Math.round(g * 2) + for (let i = 0; i < blobs; i++) { + const angle = rand() * TAU + const rad = i === 0 ? 0 : spread * (0.5 + rand() * 0.5) + const center = { + x: base.x + Math.cos(angle) * rad, + y: canopyY + (rand() - 0.4) * spread, + z: base.z + Math.sin(angle) * rad, + } + blob(leaves, center, blobR * (0.7 + rand() * 0.4), rand) + } + // Grown oaks throw out a few branches, each tipped with a leaf tuft. + if (g > 0.55) { + const branches = 2 + Math.round(rand()) + for (let i = 0; i < branches; i++) { + const angle = rand() * TAU + const dir = Vec3.normalize({ x: Math.cos(angle), y: 1.2, z: Math.sin(angle) }) + const start = { x: base.x, y: base.y + h * 0.42, z: base.z } + const end = Vec3.add(start, Vec3.scale(dir, h * 0.3)) + limb(trunk, start, end, rTrunk * 0.4, rTrunk * 0.2, 4) + blob(leaves, end, blobR * 0.6, rand) + } + } + } + + function spruce(base: Vec3, g: number, rand: () => number, trunk: Mesh, needles: Mesh): void { + const h = lerp(0.6, 9, g) + const rTrunk = lerp(0.03, 0.2, g) + limb(trunk, base, { x: base.x, y: base.y + h, z: base.z }, rTrunk, rTrunk * 0.25, 5) + + // Stacked cones: widest low, shrinking to a point up top -> conical tiers. + const tiers = 2 + Math.round(g * 3) + const bottom = base.y + h * 0.1 + const span = h * 0.9 + for (let i = 0; i < tiers; i++) { + const t = i / tiers + const y = bottom + t * span * 0.82 + const radius = lerp(h * 0.3, h * 0.05, t) * (0.9 + rand() * 0.2) + const coneH = (span / tiers) * 1.9 + cone(needles, { x: base.x, y, z: base.z }, coneH, radius, 6) + } + } + + /** A tapered tube between two points (trunk or branch), `sides`-gonal. */ + function limb(mesh: Mesh, a: Vec3, b: Vec3, ra: number, rb: number, sides: number): void { + const axis = Vec3.normalize(Vec3.sub(b, a)) + const [u, v] = basis(axis) + const len = Vec3.length(Vec3.sub(b, a)) + const start = mesh.vertices.length + for (let i = 0; i <= sides; i++) { + const angle = (i / sides) * TAU + const dir = Vec3.add(Vec3.scale(u, Math.cos(angle)), Vec3.scale(v, Math.sin(angle))) + const s = i / sides + mesh.vertices.push({ pos: Vec3.add(a, Vec3.scale(dir, ra)), uv: { x: s * 1.5, y: 0 } }) + mesh.vertices.push({ pos: Vec3.add(b, Vec3.scale(dir, rb)), uv: { x: s * 1.5, y: len * 0.5 } }) + } + for (let i = 0; i < sides; i++) { + const p = start + i * 2 + mesh.indices.push(p, p + 2, p + 3, p, p + 3, p + 1) + } + } + + /** A cone standing on a base ring, apex `height` above it (one spruce tier). */ + function cone(mesh: Mesh, base: Vec3, height: number, radius: number, sides: number): void { + const start = mesh.vertices.length + mesh.vertices.push({ pos: { x: base.x, y: base.y + height, z: base.z }, uv: { x: 0.5, y: 0 } }) + for (let i = 0; i <= sides; i++) { + const angle = (i / sides) * TAU + mesh.vertices.push({ + pos: { x: base.x + Math.cos(angle) * radius, y: base.y, z: base.z + Math.sin(angle) * radius }, + uv: { x: (i / sides) * 2, y: 1 }, + }) + } + for (let i = 0; i < sides; i++) { + mesh.indices.push(start, start + 1 + i, start + 2 + i) + } + } + + /** A lumpy low-poly sphere (one oak canopy blob). Per-ring radius wobble keeps + * it organic without cracking the longitude seam. */ + function blob(mesh: Mesh, center: Vec3, radius: number, rand: () => number): void { + const seg = 5 + const rings = 3 + const start = mesh.vertices.length + for (let r = 0; r <= rings; r++) { + const phi = (r / rings) * Math.PI + const cy = Math.cos(phi) + const cr = Math.sin(phi) + const scale = radius * (0.85 + rand() * 0.3) + for (let s = 0; s <= seg; s++) { + const theta = (s / seg) * TAU + mesh.vertices.push({ + pos: { + x: center.x + cr * Math.cos(theta) * scale, + y: center.y + cy * scale, + z: center.z + cr * Math.sin(theta) * scale, + }, + uv: { x: (s / seg) * 2, y: (r / rings) * 2 }, + }) + } + } + const row = seg + 1 + for (let r = 0; r < rings; r++) { + for (let s = 0; s < seg; s++) { + const p = start + r * row + s + mesh.indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row) + } + } + } + + /** Two unit vectors spanning the plane perpendicular to `axis`. */ + function basis(axis: Vec3): [Vec3, Vec3] { + const ref = Math.abs(axis.y) < 0.99 ? { x: 0, y: 1, z: 0 } : { x: 1, y: 0, z: 0 } + const u = Vec3.normalize(Vec3.cross(ref, axis)) + return [u, Vec3.cross(axis, u)] + } + + function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t + } + + /** Deterministic 0..1 generator (mulberry32) seeded per tree. */ + function rng(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6D2B79F5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t ^= t + Math.imul(t ^ (t >>> 7), 61 | t) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } + } +} diff --git a/scripts/gen-assets.ts b/scripts/gen-assets.ts index 09c3230..d97e484 100644 --- a/scripts/gen-assets.ts +++ b/scripts/gen-assets.ts @@ -118,6 +118,30 @@ const grass: Shade = (x, y) => { return [56 + n * 0.7 + patch * 0.5, 116 + n + blade + patch, 50 + n * 0.5 + patch * 0.4, 255] } +// Tree bark: brown with vertical grain and the odd dark crack. +const bark: Shade = (x, y) => { + const grain = Math.sin(x * 0.6 + noise(0, y) * 2) * 10 + const n = noise(x, y) * 14 + const crack = noise(Math.floor(x / 6), y) < -0.45 ? -24 : 0 + return [92 + n + grain + crack, 66 + n * 0.8 + grain * 0.6 + crack, 44 + n * 0.6 + crack, 255] +} + +// Oak leaves: mid green, blotchy clumps with brighter highlights. +const leaf: Shade = (x, y) => { + const n = noise(x, y) * 26 + const clump = noise(Math.floor(x / 5), Math.floor(y / 5)) * 22 + const hi = noise(x, y) > 0.6 ? 22 : 0 + return [58 + n * 0.5 + clump * 0.4, 120 + n + clump + hi, 42 + n * 0.4 + clump * 0.3, 255] +} + +// Spruce needles: darker, cooler blue-green, finer streaky grain. +const needle: Shade = (x, y) => { + const n = noise(x, y) * 18 + const streak = noise(x, y * 2) * 10 + const clump = noise(Math.floor(x / 6), Math.floor(y / 6)) * 12 + return [40 + n * 0.4 + clump * 0.3, 84 + n + streak + clump, 58 + n * 0.5 + clump * 0.4, 255] +} + const wall: Shade = (x, y) => { const row = Math.floor(y / 16) const bx = (x + (row % 2) * 16) % 32 @@ -172,6 +196,9 @@ const npc: Shade = (x, y) => { const assets: Array<[string, number, number, Shade]> = [ ["floor", 64, 64, floor], ["grass", 64, 64, grass], + ["bark", 64, 64, bark], + ["leaf", 64, 64, leaf], + ["needle", 64, 64, needle], ["wall", 64, 64, wall], ["crate", 64, 64, crate], ["npc", 48, 64, npc],