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 0000000..33b2b65 Binary files /dev/null and b/assets/bark.png differ diff --git a/assets/leaf.png b/assets/leaf.png new file mode 100644 index 0000000..d557988 Binary files /dev/null and b/assets/leaf.png differ diff --git a/assets/needle.png b/assets/needle.png new file mode 100644 index 0000000..a6341d3 Binary files /dev/null and b/assets/needle.png differ diff --git a/engine/scene/Tree.ts b/engine/scene/Tree.ts new file mode 100644 index 0000000..7aae1dc --- /dev/null +++ b/engine/scene/Tree.ts @@ -0,0 +1,181 @@ +import { Vec3 } from "../math/Vec3" +import type { Mesh } from "./Mesh" + +const TAU = Math.PI * 2 + +/** One procedural tree instance. `growth` 0..1 runs sapling -> 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],