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 0000000..6066398 Binary files /dev/null and b/assets/rock.png differ diff --git a/engine/scene/Boulder.ts b/engine/scene/Boulder.ts new file mode 100644 index 0000000..b11d660 --- /dev/null +++ b/engine/scene/Boulder.ts @@ -0,0 +1,96 @@ +import type { Vec3 } from "../math/Vec3" +import type { Mesh } from "./Mesh" + +const TAU = Math.PI * 2 + +/** One procedural boulder. `radius` is the overall size; `seed` drives the + * per-rock lumpiness and squash so no two look alike. It sits partly sunk into + * the ground at `position`, like a real rock. */ +export type Boulder = { + /** Resting point on the ground (the rock is centered a bit above and buried). */ + position: Vec3 + radius: number + seed: number +} + +/** + * Low-poly boulder geometry in the same faceted flat-shaded style as the rest of + * the world. A squashed, per-vertex-jittered sphere reads as an angular chunk of + * rock once flat shading gives each face its own tone. Radial jitter is kept + * seam- and pole-safe (the longitude wrap and both poles reuse one value) so the + * rock never cracks open. `build` appends into a caller-owned mesh, so a whole + * field of boulders batches into a single draw call. + */ +export namespace Boulder { + export function build(boulder: Boulder, mesh: Mesh): void { + const rand = rng(boulder.seed) + const seg = 5 + const rings = 4 + const r = boulder.radius + // Squat and slightly oval, so it reads as a rock, not a ball. + const sx = r * (0.8 + rand() * 0.5) + const sy = r * (0.55 + rand() * 0.3) + const sz = r * (0.8 + rand() * 0.5) + const cx = boulder.position.x + const cz = boulder.position.z + // Center lifted less than the half-height, so the base sinks into the ground. + const cy = boulder.position.y + sy * 0.55 + + const jitter = jitterGrid(seg, rings, rand) + const start = mesh.vertices.length + for (let ir = 0; ir <= rings; ir++) { + const phi = (ir / rings) * Math.PI + const cyv = Math.cos(phi) + const crv = Math.sin(phi) + for (let is = 0; is <= seg; is++) { + const theta = (is / seg) * TAU + const j = jitter[ir][is] + mesh.vertices.push({ + pos: { + x: cx + crv * Math.cos(theta) * sx * j, + y: cy + cyv * sy * j, + z: cz + crv * Math.sin(theta) * sz * j, + }, + uv: { x: (is / seg) * 1.5, y: (ir / rings) * 1.5 }, + }) + } + } + const row = seg + 1 + for (let ir = 0; ir < rings; ir++) { + for (let is = 0; is < seg; is++) { + const p = start + ir * row + is + mesh.indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row) + } + } + } + + /** Per-vertex radial scale in ~0.72..1.14 for a chunky, angular surface. The + * longitude seam (last column == first) and each pole row (one shared value) + * match so the mesh stays closed. */ + function jitterGrid(seg: number, rings: number, rand: () => 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],