From 680e08aadc85b8025b62639357a9871a952cadf3 Mon Sep 17 00:00:00 2001 From: Errilaz Date: Tue, 4 Aug 2026 14:02:12 +0200 Subject: [PATCH] feat: boulders --- AGENTS.md | 25 +++++++---- app/assets.ts | 7 ++- app/level.ts | 41 ++++++++++++++++- app/main.ts | 1 + assets/rock.png | Bin 0 -> 5437 bytes engine/scene/Boulder.ts | 96 ++++++++++++++++++++++++++++++++++++++++ scripts/gen-assets.ts | 10 +++++ 7 files changed, 169 insertions(+), 11 deletions(-) create mode 100644 assets/rock.png create mode 100644 engine/scene/Boulder.ts diff --git a/AGENTS.md b/AGENTS.md index 2445c19..8bdcb16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,9 @@ rules live in `.agents/rules/*.md`. 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), `Tree` (procedural low-poly oak/spruce geometry, sapling..full via a `growth` knob; - `Tree.build` appends into shared trunk + foliage meshes). + `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). - `app/` — browser glue. - `main.ts` — game loop, input, preset switching, canvas blit, FPS meter. - `assets.ts` — load `/assets/*.png` → `Texture` (zero-copy; ImageData bytes @@ -66,10 +68,11 @@ 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` and a scattered forest + Per-texture meshes incl. the outdoor grass `ground`, 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. + `_REACH`) and `boulders` (`scatterBoulders`, `BOULDER_COUNT`/`_SEED`/`_REACH`), + `Aabb` colliders (incl. grown-tree trunks + big boulders), 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 @@ -83,9 +86,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/wall/crate/npc` PNGs +- `assets/` — generated `floor/grass/bark/leaf/needle/rock/wall/crate/npc` PNGs (`floor` = room stone, `grass` = outdoor ground, `bark`/`leaf`/`needle` = tree - trunk/oak/spruce). Swap for real art anytime; + trunk/oak/spruce, `rock` = boulders). Swap for real art anytime; filenames are the contract. - `server/` — Bun server stub. `shared/` — isomorphic slot. @@ -93,8 +96,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, tree trunks, oak foliage, spruce -foliage (one call per texture) → +`Rasterizer.draw` ground, floor, walls, crate, boulders, 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`). @@ -152,6 +155,12 @@ canopy blobs (oak) / tiers (spruce); `seed` gives each tree its own wobble. (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. +**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 +never cracks) into one shared rock mesh, sunk partway into the ground. +`scatterBoulders` sizes them small→big (biased small) and drops colliders on the +big ones. + ## Controls WASD move · **Shift** run (speed ×`RUN_MULTIPLIER` in `app/player.ts`) · mouse diff --git a/app/assets.ts b/app/assets.ts index dbea7eb..57619be 100644 --- a/app/assets.ts +++ b/app/assets.ts @@ -6,6 +6,7 @@ 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 rockUrl from "../assets/rock.png" import wallUrl from "../assets/wall.png" export type Textures = { @@ -14,6 +15,7 @@ export type Textures = { bark: Texture leaf: Texture needle: Texture + rock: Texture wall: Texture crate: Texture npc: Texture @@ -21,17 +23,18 @@ 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, wall, crate, npc] = await Promise.all([ + const [floor, grass, bark, leaf, needle, rock, wall, crate, npc] = await Promise.all([ loadTexture(floorUrl), loadTexture(grassUrl), loadTexture(barkUrl), loadTexture(leafUrl), loadTexture(needleUrl), + loadTexture(rockUrl), loadTexture(wallUrl), loadTexture(crateUrl), loadTexture(npcUrl), ]) - return { floor, grass, bark, leaf, needle, wall, crate, npc } + return { floor, grass, bark, leaf, needle, rock, wall, crate, npc } } function loadTexture(url: string): Promise { diff --git a/app/level.ts b/app/level.ts index f0debd0..bb5b907 100644 --- a/app/level.ts +++ b/app/level.ts @@ -1,6 +1,7 @@ import { Color } from "../engine/render/Color" import type { CloudLayer, SkyConfig } from "../engine/render/Sky" import type { Mesh } from "../engine/scene/Mesh" +import { Boulder } from "../engine/scene/Boulder" import { Terrain } from "../engine/scene/Terrain" import { Tree } from "../engine/scene/Tree" @@ -31,6 +32,8 @@ export type Level = { * each takes its own texture in one draw call. */ oakFoliage: Mesh spruceFoliage: Mesh + /** Scattered boulders (rock texture). */ + boulders: Mesh colliders: Aabb[] npcPosition: { x: number; y: number; z: number } terrain: Terrain @@ -71,6 +74,12 @@ const TREE_COUNT = 500 const TREE_SEED = 0x5EED const TREE_REACH = 0.6 +/** Boulders: how many to scatter, their seed, and how far out they reach + * (fraction of the world). Sizes range small pebble .. big boulder. */ +const BOULDER_COUNT = 70 +const BOULDER_SEED = 0xB0142 +const BOULDER_REACH = 0.7 + /** 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. */ @@ -156,7 +165,11 @@ export function buildLevel(): Level { const spruceFoliage = mesh() scatterTrees(trunks, oakFoliage, spruceFoliage, colliders) - return { floor, walls, crate, ground, trunks, oakFoliage, spruceFoliage, colliders, npcPosition, terrain: TERRAIN, sky } + // Scatter boulders across the terrain; big ones get colliders. + const boulders = mesh() + scatterBoulders(boulders, colliders) + + return { floor, walls, crate, ground, trunks, oakFoliage, spruceFoliage, boulders, colliders, npcPosition, terrain: TERRAIN, sky } } /** Place `TREE_COUNT` trees around the room on walkable grass: each sits on the @@ -188,6 +201,32 @@ function scatterTrees(trunks: Mesh, oakFoliage: Mesh, spruceFoliage: Mesh, colli } } +/** Scatter `BOULDER_COUNT` boulders across the terrain, sizes biased toward + * small. Each sits on the ground; big ones drop a blocking collider so you + * can't walk through them (little rocks stay passable). */ +function scatterBoulders(boulders: Mesh, colliders: Aabb[]): void { + const rand = mulberry(BOULDER_SEED) + const maxDist = TERRAIN.outer * BOULDER_REACH + let placed = 0 + for (let guard = 0; placed < BOULDER_COUNT && guard < BOULDER_COUNT * 20; guard++) { + const angle = rand() * Math.PI * 2 + const dist = ARENA + 4 + rand() * (maxDist - ARENA - 4) + const x = Math.cos(angle) * dist + const z = Math.sin(angle) * dist + if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 2) { + continue + } + // Square the roll so most rocks are small, a few are big. + const radius = 0.35 + rand() * rand() * 2.2 + const position = { x, y: Terrain.height(TERRAIN, x, z), z } + Boulder.build({ position, radius, seed: (rand() * 0xFFFFFFFF) | 0 }, boulders) + if (radius > 0.7) { + colliders.push({ minX: x - radius, maxX: x + radius, minZ: z - radius, maxZ: z + radius, top: position.y + radius * 0.7, standable: false }) + } + placed++ + } +} + /** Deterministic 0..1 generator (mulberry32) for tree placement. */ function mulberry(seed: number): () => number { let a = seed >>> 0 diff --git a/app/main.ts b/app/main.ts index 8072d20..968f3bf 100644 --- a/app/main.ts +++ b/app/main.ts @@ -114,6 +114,7 @@ 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.boulders, textures.rock, 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) diff --git a/assets/rock.png b/assets/rock.png new file mode 100644 index 0000000000000000000000000000000000000000..606639842d7e8dd27ed7d838192486a91babf557 GIT binary patch literal 5437 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 number): number[][] { + const grid: number[][] = [] + for (let ir = 0; ir <= rings; ir++) { + const pole = ir === 0 || ir === rings + grid[ir] = [] + for (let is = 0; is <= seg; is++) { + if (is === seg || (pole && is > 0)) { + grid[ir][is] = grid[ir][0] + } else { + grid[ir][is] = 0.72 + rand() * 0.42 + } + } + } + return grid + } + + /** Deterministic 0..1 generator (mulberry32) seeded per boulder. */ + 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 d97e484..f6081fd 100644 --- a/scripts/gen-assets.ts +++ b/scripts/gen-assets.ts @@ -142,6 +142,15 @@ const needle: Shade = (x, y) => { return [40 + n * 0.4 + clump * 0.3, 84 + n + streak + clump, 58 + n * 0.5 + clump * 0.4, 255] } +// Boulder rock: cool grey, mottled patches, the odd dark crack. +const rock: Shade = (x, y) => { + const n = noise(x, y) * 20 + const patch = noise(Math.floor(x / 7), Math.floor(y / 7)) * 16 + const crack = noise(Math.floor(x / 5) + 3, Math.floor(y / 9)) < -0.5 ? -28 : 0 + const g = 118 + n + patch + crack + return [g, g + 3, g + 8, 255] +} + const wall: Shade = (x, y) => { const row = Math.floor(y / 16) const bx = (x + (row % 2) * 16) % 32 @@ -199,6 +208,7 @@ const assets: Array<[string, number, number, Shade]> = [ ["bark", 64, 64, bark], ["leaf", 64, 64, leaf], ["needle", 64, 64, needle], + ["rock", 64, 64, rock], ["wall", 64, 64, wall], ["crate", 64, 64, crate], ["npc", 48, 64, npc],