diff --git a/AGENTS.md b/AGENTS.md index ed57f1a..3d4da6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ rules live in `.agents/rules/*.md`. edge peaks. `Terrain.patch` builds one ground patch over a rectangle -- called per chunk, aligned so patches weld crack-free, with a hole for the room; `Terrain.height` is the shared ground-height sampler for the player), `Tree` - (procedural low-poly oak/spruce geometry, sapling..full via a `growth` knob; + (procedural low-poly oak/spruce/birch geometry, sapling..full via a `growth` knob; `Tree.build` appends into shared trunk + foliage meshes), `Boulder` (procedural low-poly rock: a squashed, jittered, part-buried sphere; `Boulder.build` appends into a shared mesh), `Bush` (cluster of small leaf @@ -116,7 +116,7 @@ rules live in `.agents/rules/*.md`. scattered (`placeMobs`; `FROG_/BEE_COUNT`, `MOB_SEED`, `MOB_REACH` — mobs move, so they carry no baked colliders), then `buildChunks` bakes terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` grid of `Chunk`s (each = - per-texture meshes grass/bark/leaf/needle/rock/flowers + a tight AABB) that + per-texture meshes grass/bark/birchBark/leaf/needle/rock/flowers + a tight AABB) that `main` frustum-culls; bushes fold into the leaf mesh, flowers get their own (drawn double-sided). Trees + boulders are baked **twice** — full geometry and a low-poly impostor (`barkFar/leafFar/needleFar/rockFar`, via the builders' @@ -134,9 +134,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/bark/leaf/needle/rock/flower/wall/crate/npc/frog/bee` - PNGs (`floor` = room stone, `grass` = outdoor ground, `bark`/`leaf`/`needle` = - tree trunk/oak/spruce, `rock` = boulders, `flower` = 2x2 bloom-color atlas, +- `assets/` — generated `floor/grass/bark/birch/leaf/needle/rock/flower/wall/crate/npc/frog/bee` + PNGs (`floor` = room stone, `grass` = outdoor ground, `bark`/`birch`/`leaf`/`needle` = + brown trunk / white birch trunk / oak leaf / spruce needle, `rock` = boulders, `flower` = 2x2 bloom-color atlas, `frog`/`bee` = mob skin atlases: frog green + eye tone; bee stripe bands + head-dark + wing-pale regions). Swap for real art anytime; @@ -271,18 +271,24 @@ branching in the cloud shader. Cost scales with sky resolution — fine at ## Trees (`engine/scene/Tree.ts`) -Procedural low-poly geometry, faceted flat-shaded like everything else. Two +Procedural low-poly geometry, faceted flat-shaded like everything else. Three `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). +- **`birch`** — silver birch: tall, slender, near-straight trunk with an airy, + high, slightly drooping canopy (lean silhouette). Uses a separate **white** + `birch` bark texture (brown bark can't stand in), and shares the oak `leaf` + foliage. `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. +canopy blobs (oak/birch) / tiers (spruce); `seed` gives each tree its own wobble. +`Tree.build` appends into caller-chosen trunk + foliage meshes, so a forest still +batches into a few draw calls (brown-bark trunks, white-birch trunks, oak/birch +leaf foliage, spruce needles). `app/level.ts` `placeTrees` seeds the forest and +rolls the species; add one by extending the union + a builder (a new bark/leaf +look also needs its own texture + per-chunk mesh channel — see `birchBark`). **Boulders** (`engine/scene/Boulder.ts`) work the same way: `Boulder.build` appends a squashed, per-vertex-jittered low-poly sphere (seam/pole-safe so it diff --git a/CLAUDE.md b/CLAUDE.md index d7f68da..bd3438f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,3 +5,6 @@ @.agents/rules/forge-sync.md @.agents/rules/openspec.md @.agents/rules/quality.md + +Don't nudge the user towards branching or commiting. User is perfectly capable of handling this. + diff --git a/README.md b/README.md index d2c02ef..88e8e5d 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,7 @@ - all the knobs +Start to make cohesive game: + +- Split apart the "engine" and "game" + diff --git a/app/assets.ts b/app/assets.ts index cdd9f34..62ab9a7 100644 --- a/app/assets.ts +++ b/app/assets.ts @@ -1,6 +1,7 @@ import type { Texture } from "../engine/render/Texture" import barkUrl from "../assets/bark.png" import beeUrl from "../assets/bee.png" +import birchUrl from "../assets/birch.png" import crateUrl from "../assets/crate.png" import floorUrl from "../assets/floor.png" import flowerUrl from "../assets/flower.png" @@ -16,6 +17,7 @@ export type Textures = { floor: Texture grass: Texture bark: Texture + birch: Texture leaf: Texture needle: Texture rock: Texture @@ -29,10 +31,11 @@ export type Textures = { /** Load every game texture up front. Call once before starting the loop. */ export async function loadTextures(): Promise { - const [floor, grass, bark, leaf, needle, rock, flower, wall, crate, npc, frog, bee] = await Promise.all([ + const [floor, grass, bark, birch, leaf, needle, rock, flower, wall, crate, npc, frog, bee] = await Promise.all([ loadTexture(floorUrl), loadTexture(grassUrl), loadTexture(barkUrl), + loadTexture(birchUrl), loadTexture(leafUrl), loadTexture(needleUrl), loadTexture(rockUrl), @@ -43,7 +46,7 @@ export async function loadTextures(): Promise { loadTexture(frogUrl), loadTexture(beeUrl), ]) - return { floor, grass, bark, leaf, needle, rock, flower, wall, crate, npc, frog, bee } + return { floor, grass, bark, birch, leaf, needle, rock, flower, wall, crate, npc, frog, bee } } function loadTexture(url: string): Promise { diff --git a/app/level.ts b/app/level.ts index 7d78658..97cd1aa 100644 --- a/app/level.ts +++ b/app/level.ts @@ -35,6 +35,9 @@ export type Chunk = { maxZ: number grass: Mesh bark: Mesh + /** Birch trunks (white bark texture) -- separate mesh since a draw is one mesh + + * one texture, and silver birch bark can't share the brown oak/spruce bark. */ + birchBark: Mesh leaf: Mesh needle: Mesh rock: Mesh @@ -42,8 +45,9 @@ export type Chunk = { flowers: Mesh /** Cheap low-poly impostors of the same trees/boulders, drawn instead of the * full meshes once the chunk is past `config.lodDistance` (renderScene). Same - * textures as bark/leaf/needle/rock. Bushes/flowers have no far version. */ + * textures as bark/birch/leaf/needle/rock. Bushes/flowers have no far version. */ barkFar: Mesh + birchBarkFar: Mesh leafFar: Mesh needleFar: Mesh rockFar: Mesh @@ -231,21 +235,27 @@ function buildChunks(trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers const z1 = z0 + cell const grass = mesh() const bark = mesh() + const birchBark = mesh() const leaf = mesh() const needle = mesh() const rock = mesh() const flowerMesh = mesh() const barkFar = mesh() + const birchBarkFar = mesh() const leafFar = mesh() const needleFar = mesh() const rockFar = mesh() Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV) for (const tree of trees) { if (inCell(tree.position, x0, z0, x1, z1)) { - const foliage = tree.kind === "oak" ? leaf : needle - const foliageFar = tree.kind === "oak" ? leafFar : needleFar - Tree.build(tree, bark, foliage) - Tree.build(tree, barkFar, foliageFar, "impostor") + // Birch trunks go to their own white-bark mesh; oak + birch share the oak + // leaf foliage, spruce keeps its needles. + const trunk = tree.kind === "birch" ? birchBark : bark + const trunkFar = tree.kind === "birch" ? birchBarkFar : barkFar + const foliage = tree.kind === "spruce" ? needle : leaf + const foliageFar = tree.kind === "spruce" ? needleFar : leafFar + Tree.build(tree, trunk, foliage) + Tree.build(tree, trunkFar, foliageFar, "impostor") } } for (const boulder of boulders) { @@ -265,11 +275,11 @@ function buildChunks(trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers Flower.build(flower, flowerMesh) } } - const b = bounds([grass, bark, leaf, needle, rock, flowerMesh]) + const b = bounds([grass, bark, birchBark, leaf, needle, rock, flowerMesh]) if (b === null) { continue } - chunks.push({ ...b, grass, bark, leaf, needle, rock, flowers: flowerMesh, barkFar, leafFar, needleFar, rockFar }) + chunks.push({ ...b, grass, bark, birchBark, leaf, needle, rock, flowers: flowerMesh, barkFar, birchBarkFar, leafFar, needleFar, rockFar }) } } return chunks @@ -320,7 +330,8 @@ function placeTrees(colliders: Aabb[]): Tree[] { if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 3) { continue } - const kind = rand() < 0.5 ? "oak" : "spruce" + const roll = rand() + const kind = roll < 0.4 ? "oak" : roll < 0.72 ? "spruce" : "birch" const growth = 0.08 + rand() * 0.92 const position = { x, y: Terrain.height(TERRAIN, x, z), z } trees.push({ kind, position, growth, seed: (rand() * 0xFFFFFFFF) | 0 }) diff --git a/app/main.ts b/app/main.ts index 034bfda..ae975fa 100644 --- a/app/main.ts +++ b/app/main.ts @@ -161,8 +161,8 @@ async function main(): Promise { t += c.grass.indices.length + c.flowers.indices.length const far = chunkFar(c, cam.position, config.lodDistance) t += far - ? c.barkFar.indices.length + c.leafFar.indices.length + c.needleFar.indices.length + c.rockFar.indices.length - : c.bark.indices.length + c.leaf.indices.length + c.needle.indices.length + c.rock.indices.length + ? c.barkFar.indices.length + c.birchBarkFar.indices.length + c.leafFar.indices.length + c.needleFar.indices.length + c.rockFar.indices.length + : c.bark.indices.length + c.birchBark.indices.length + c.leaf.indices.length + c.needle.indices.length + c.rock.indices.length } return (t / 3) | 0 } diff --git a/app/renderScene.ts b/app/renderScene.ts index 3aadfcd..003816f 100644 --- a/app/renderScene.ts +++ b/app/renderScene.ts @@ -104,11 +104,13 @@ export function renderBand( if (chunkFar(c, camera.position, config.lodDistance)) { Rasterizer.draw(fb, c.rockFar, tx.rock, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.barkFar, tx.bark, viewProj, config, true, y0, y1) + Rasterizer.draw(fb, c.birchBarkFar, tx.birch, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.leafFar, tx.leaf, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.needleFar, tx.needle, viewProj, config, true, y0, y1) } else { Rasterizer.draw(fb, c.rock, tx.rock, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.bark, tx.bark, viewProj, config, true, y0, y1) + Rasterizer.draw(fb, c.birchBark, tx.birch, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.leaf, tx.leaf, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.needle, tx.needle, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.flowers, tx.flower, viewProj, config, false, y0, y1) diff --git a/assets/birch.png b/assets/birch.png new file mode 100644 index 0000000..d87601a Binary files /dev/null and b/assets/birch.png differ diff --git a/assets/frog.png b/assets/frog.png index 29139ea..158d154 100644 Binary files a/assets/frog.png and b/assets/frog.png differ diff --git a/engine/scene/Tree.ts b/engine/scene/Tree.ts index ad92662..7eab2cc 100644 --- a/engine/scene/Tree.ts +++ b/engine/scene/Tree.ts @@ -7,7 +7,7 @@ const TAU = Math.PI * 2 * 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" + kind: "oak" | "spruce" | "birch" /** Trunk base, sitting on the ground. */ position: Vec3 growth: number @@ -34,8 +34,10 @@ export namespace Tree { const rand = rng(tree.seed) if (tree.kind === "oak") { oak(tree.position, tree.growth, rand, trunk, foliage, lod) - } else { + } else if (tree.kind === "spruce") { spruce(tree.position, tree.growth, rand, trunk, foliage, lod) + } else { + birch(tree.position, tree.growth, rand, trunk, foliage, lod) } } @@ -101,6 +103,48 @@ export namespace Tree { } } + function birch(base: Vec3, g: number, rand: () => number, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void { + // Silver birch: tall, slender, near-straight white trunk under an airy, high, + // slightly drooping canopy of small leaf tufts -- a lean silhouette between the + // broad oak and the conical spruce (the white bark texture does the rest). + const h = lerp(1, 8.5, g) + const rTrunk = lerp(0.03, 0.16, g) + const canopyY = base.y + h * 0.75 + const blobR = h * 0.22 + if (lod === "impostor") { + limb(trunk, base, { x: base.x, y: base.y + h * 0.9, z: base.z }, rTrunk, rTrunk * 0.5, 3) + blob(leaves, { x: base.x, y: canopyY, z: base.z }, blobR * 1.1, rand, 4, 2) + return + } + limb(trunk, base, { x: base.x, y: base.y + h * 0.88, z: base.z }, rTrunk, rTrunk * 0.35, 5) + + const spread = h * 0.22 + // Sparse small blobs clustered high, biased downward so the crown droops. + const blobs = 2 + 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.6) * spread, + z: base.z + Math.sin(angle) * rad, + } + blob(leaves, center, blobR * (0.7 + rand() * 0.4), rand) + } + // Grown birches trail a few thin, near-horizontal drooping twigs. + if (g > 0.5) { + 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: 0.6, z: Math.sin(angle) }) + const start = { x: base.x, y: base.y + h * 0.7, z: base.z } + const end = Vec3.add(start, Vec3.scale(dir, h * 0.22)) + limb(trunk, start, end, rTrunk * 0.4, rTrunk * 0.15, 4) + blob(leaves, end, blobR * 0.55, rand) + } + } + } + /** 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)) diff --git a/scripts/gen-assets.ts b/scripts/gen-assets.ts index 92f93c1..b0353bb 100644 --- a/scripts/gen-assets.ts +++ b/scripts/gen-assets.ts @@ -126,6 +126,18 @@ const bark: Shade = (x, y) => { return [92 + n + grain + crack, 66 + n * 0.8 + grain * 0.6 + crack, 44 + n * 0.6 + crack, 255] } +// Silver birch bark: papery near-white with faint grey mottle and the dark +// horizontal lenticel dashes that make a birch read as a birch. +const birch: Shade = (x, y) => { + const n = noise(x, y) * 8 + // Dark dashes: short random runs on a few rows, wrapping the trunk horizontally. + if (y % 8 < 3 && noise(Math.floor(x / 6), Math.floor(y / 8)) > 0.3) { + return [38 + n, 38 + n, 40 + n, 255] + } + const smudge = noise(Math.floor(x / 10), Math.floor(y / 10)) < -0.4 ? -22 : 0 + return [216 + n + smudge, 218 + n + smudge, 214 + n + smudge, 255] +} + // Oak leaves: mid green, blotchy clumps with brighter highlights. const leaf: Shade = (x, y) => { const n = noise(x, y) * 26 @@ -249,6 +261,7 @@ const assets: Array<[string, number, number, Shade]> = [ ["floor", 64, 64, floor], ["grass", 64, 64, grass], ["bark", 64, 64, bark], + ["birch", 64, 64, birch], ["leaf", 64, 64, leaf], ["needle", 64, 64, needle], ["rock", 64, 64, rock],