feat: boulders

This commit is contained in:
Dan Finch 2026-08-04 14:02:12 +02:00
parent 9aa0ec6a3f
commit 680e08aadc
7 changed files with 169 additions and 11 deletions

View file

@ -58,7 +58,9 @@ rules live in `.agents/rules/*.md`.
edge peaks. `Terrain.ground` builds the outdoor mesh with a hole for the room; 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` `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 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. - `app/` — browser glue.
- `main.ts` — game loop, input, preset switching, canvas blit, FPS meter. - `main.ts` — game loop, input, preset switching, canvas blit, FPS meter.
- `assets.ts` — load `/assets/*.png``Texture` (zero-copy; ImageData bytes - `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 - `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` walls via `slab`, north side open) in the center of a big grassy `Terrain`
world (~20x across). 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`/ (`scatterTrees``trunks`/`oakFoliage`/`spruceFoliage`, `TREE_COUNT`/`_SEED`/
`_REACH`), `Aabb` colliders (incl. grown-tree trunks), NPC position, `Terrain` `_REACH`) and `boulders` (`scatterBoulders`, `BOULDER_COUNT`/`_SEED`/`_REACH`),
config + `GROUND_DIVISIONS`/`GROUND_UV`, sky/cloud config. `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 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 terrain skirt that laps under the room edges. Room surfaces are single flat
quads -- no subdivision needed since texturing is perspective-correct; ground 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). `#fps` meter div (styled inline).
- `scripts/gen-assets.ts` — procedurally draws the placeholder textures and - `scripts/gen-assets.ts` — procedurally draws the placeholder textures and
writes PNGs (hand-rolled encoder via `node:zlib`). Run via `bun run assets`. 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 (`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. filenames are the contract.
- `server/` — Bun server stub. `shared/` — isomorphic slot. - `server/` — Bun server stub. `shared/` — isomorphic slot.
@ -93,8 +96,8 @@ rules live in `.agents/rules/*.md`.
`Player.update` → build `Camera``Camera.viewProjection` `Player.update` → build `Camera``Camera.viewProjection`
`Sky.render` (fills color + resets depth, replaces a clear) → `Sky.render` (fills color + resets depth, replaces a clear) →
`Rasterizer.draw` ground, floor, walls, crate, tree trunks, oak foliage, spruce `Rasterizer.draw` ground, floor, walls, crate, boulders, tree trunks, oak
foliage (one call per texture) → foliage, spruce foliage (one call per texture) →
`Sprite.billboard(npc)` drawn via `Rasterizer.draw` `Sprite.billboard(npc)` drawn via `Rasterizer.draw`
`Framebuffer.quantize``present` (integer-scale, letterboxed blit; `Framebuffer.quantize``present` (integer-scale, letterboxed blit;
`imageSmoothingEnabled` follows `upscaleFilter`). `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` (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. `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 ## Controls
WASD move · **Shift** run (speed ×`RUN_MULTIPLIER` in `app/player.ts`) · mouse WASD move · **Shift** run (speed ×`RUN_MULTIPLIER` in `app/player.ts`) · mouse

View file

@ -6,6 +6,7 @@ import grassUrl from "../assets/grass.png"
import leafUrl from "../assets/leaf.png" import leafUrl from "../assets/leaf.png"
import needleUrl from "../assets/needle.png" import needleUrl from "../assets/needle.png"
import npcUrl from "../assets/npc.png" import npcUrl from "../assets/npc.png"
import rockUrl from "../assets/rock.png"
import wallUrl from "../assets/wall.png" import wallUrl from "../assets/wall.png"
export type Textures = { export type Textures = {
@ -14,6 +15,7 @@ export type Textures = {
bark: Texture bark: Texture
leaf: Texture leaf: Texture
needle: Texture needle: Texture
rock: Texture
wall: Texture wall: Texture
crate: Texture crate: Texture
npc: Texture npc: Texture
@ -21,17 +23,18 @@ export type Textures = {
/** Load every game texture up front. Call once before starting the loop. */ /** Load every game texture up front. Call once before starting the loop. */
export async function loadTextures(): Promise<Textures> { export async function loadTextures(): Promise<Textures> {
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(floorUrl),
loadTexture(grassUrl), loadTexture(grassUrl),
loadTexture(barkUrl), loadTexture(barkUrl),
loadTexture(leafUrl), loadTexture(leafUrl),
loadTexture(needleUrl), loadTexture(needleUrl),
loadTexture(rockUrl),
loadTexture(wallUrl), loadTexture(wallUrl),
loadTexture(crateUrl), loadTexture(crateUrl),
loadTexture(npcUrl), 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<Texture> { function loadTexture(url: string): Promise<Texture> {

View file

@ -1,6 +1,7 @@
import { Color } from "../engine/render/Color" import { Color } from "../engine/render/Color"
import type { CloudLayer, SkyConfig } from "../engine/render/Sky" import type { CloudLayer, SkyConfig } from "../engine/render/Sky"
import type { Mesh } from "../engine/scene/Mesh" import type { Mesh } from "../engine/scene/Mesh"
import { Boulder } from "../engine/scene/Boulder"
import { Terrain } from "../engine/scene/Terrain" import { Terrain } from "../engine/scene/Terrain"
import { Tree } from "../engine/scene/Tree" import { Tree } from "../engine/scene/Tree"
@ -31,6 +32,8 @@ export type Level = {
* each takes its own texture in one draw call. */ * each takes its own texture in one draw call. */
oakFoliage: Mesh oakFoliage: Mesh
spruceFoliage: Mesh spruceFoliage: Mesh
/** Scattered boulders (rock texture). */
boulders: Mesh
colliders: Aabb[] colliders: Aabb[]
npcPosition: { x: number; y: number; z: number } npcPosition: { x: number; y: number; z: number }
terrain: Terrain terrain: Terrain
@ -71,6 +74,12 @@ const TREE_COUNT = 500
const TREE_SEED = 0x5EED const TREE_SEED = 0x5EED
const TREE_REACH = 0.6 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 /** 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 * 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. */ * chunkier terrain, not more triangles. `GROUND_UV` sets texture tiles/unit. */
@ -156,7 +165,11 @@ export function buildLevel(): Level {
const spruceFoliage = mesh() const spruceFoliage = mesh()
scatterTrees(trunks, oakFoliage, spruceFoliage, colliders) 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 /** 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. */ /** Deterministic 0..1 generator (mulberry32) for tree placement. */
function mulberry(seed: number): () => number { function mulberry(seed: number): () => number {
let a = seed >>> 0 let a = seed >>> 0

View file

@ -114,6 +114,7 @@ async function main(): Promise<void> {
Rasterizer.draw(fb, level.floor, textures.floor, viewProj, config) Rasterizer.draw(fb, level.floor, textures.floor, viewProj, config)
Rasterizer.draw(fb, level.walls, textures.wall, viewProj, config) Rasterizer.draw(fb, level.walls, textures.wall, viewProj, config)
Rasterizer.draw(fb, level.crate, textures.crate, 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.trunks, textures.bark, viewProj, config)
Rasterizer.draw(fb, level.oakFoliage, textures.leaf, viewProj, config) Rasterizer.draw(fb, level.oakFoliage, textures.leaf, viewProj, config)
Rasterizer.draw(fb, level.spruceFoliage, textures.needle, viewProj, config) Rasterizer.draw(fb, level.spruceFoliage, textures.needle, viewProj, config)

BIN
assets/rock.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

96
engine/scene/Boulder.ts Normal file
View file

@ -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
}
}
}

View file

@ -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] 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 wall: Shade = (x, y) => {
const row = Math.floor(y / 16) const row = Math.floor(y / 16)
const bx = (x + (row % 2) * 16) % 32 const bx = (x + (row % 2) * 16) % 32
@ -199,6 +208,7 @@ const assets: Array<[string, number, number, Shade]> = [
["bark", 64, 64, bark], ["bark", 64, 64, bark],
["leaf", 64, 64, leaf], ["leaf", 64, 64, leaf],
["needle", 64, 64, needle], ["needle", 64, 64, needle],
["rock", 64, 64, rock],
["wall", 64, 64, wall], ["wall", 64, 64, wall],
["crate", 64, 64, crate], ["crate", 64, 64, crate],
["npc", 48, 64, npc], ["npc", 48, 64, npc],