import { Color } from "../engine/render/Color" import type { CloudLayer, SkyConfig } from "../engine/render/Sky" import { STRIDE, type Mesh } from "../engine/scene/Mesh" import { Boulder } from "../engine/scene/Boulder" import { Bush } from "../engine/scene/Bush" import { Flower, type FlowerColor } from "../engine/scene/Flower" import { Terrain } from "../engine/scene/Terrain" import { Tree } from "../engine/scene/Tree" type Corner = [number, number, number] /** Axis-aligned solid. Blocks the player horizontally while their feet are * below `top`; if `standable`, its `top` also counts as ground to land on. */ export type Aabb = { minX: number maxX: number minZ: number maxZ: number top: number standable: boolean } /** One spatial cell of the outdoor world: its terrain patch + the trees/boulders * standing in it, split by texture, plus an axis-aligned bounding box (tight to * the actual geometry, so overhanging canopies aren't clipped). The renderer * frustum-tests the box and skips the whole cell when it is off-screen -- this * is what keeps a big, dense world affordable. Empty cells are never created. */ export type Chunk = { minX: number minY: number minZ: number maxX: number maxY: number maxZ: number grass: Mesh bark: Mesh leaf: Mesh needle: Mesh rock: Mesh /** Flowers (color-atlas texture); drawn double-sided, so kept separate. */ 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. */ barkFar: Mesh leafFar: Mesh needleFar: Mesh rockFar: Mesh } /** The playground: a flat-floored room dropped into the center of a big open * landscape. The room (floor/walls/crate) is small and always drawn; the * outdoor world is split into `chunks` that are frustum-culled per frame. */ export type Level = { floor: Mesh walls: Mesh crate: Mesh chunks: Chunk[] colliders: Aabb[] npcPosition: { x: number; y: number; z: number } terrain: Terrain sky: SkyConfig } const ARENA = 12 const WALL_HEIGHT = 4 /** How deep the perimeter walls are. Thick enough to read as solid walls (and to * give the doorway real jambs); their outer faces sit flush with the room edge, * so they eat into the interior, not the terrain. */ const WALL_THICKNESS = 1.5 const CRATE = { x: -2, z: -2, half: 1, height: 1 } /** Z-bias lifting the stone floor above the terrain skirt that laps under the * room edge (see `buildLevel`). Big enough to beat depth precision, too small * to see. */ const FLOOR_LIFT = 0.02 /** The world around the room: a flat clearing the size of the room (`inner`), * rolling hills beyond, ramping into very high peaks at the `outer` rim ~20x * the room across. Tune freely -- crank `peakHeight` for taller mountains, * `outer` for a bigger world. */ const TERRAIN: Terrain = { inner: ARENA, outer: ARENA * 20, blend: 12, amplitude: 5, frequency: 0.14, peakHeight: 90, peakFrequency: 0.05, 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 /** 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 /** Bushes + flowers: ground detail, kept to the nearer band since they're small * and fog/size hides them far out. Flowers roll white/red/yellow. */ const BUSH_COUNT = 140 const BUSH_SEED = 0xB554 const BUSH_REACH = 0.35 const FLOWER_COUNT = 340 const FLOWER_SEED = 0xF10E const FLOWER_REACH = 0.3 const FLOWER_COLORS: FlowerColor[] = ["white", "red", "yellow"] /** Spatial partition of the world for frustum culling: `CHUNK_GRID` x * `CHUNK_GRID` square cells over [-outer, outer]. Smaller cells cull tighter * (less drawn off-screen) but cost more per-cell tests + bounds; this is the * granularity knob. `TERRAIN_SUBDIV` is the terrain quads per cell edge, so the * world's terrain resolution is `CHUNK_GRID * TERRAIN_SUBDIV`. `GROUND_UV` sets * texture tiles/unit. */ const CHUNK_GRID = 12 const TERRAIN_SUBDIV = 5 const GROUND_UV = 0.25 /** The two cloud styles; swap which one the sky uses in `buildLevel`. * `basicCumulus` is cheap flat puffs; `fancyCumulus` is the pricier * heightfield-shaded, domain-warped version with faked volume. */ export const basicCumulus: CloudLayer = { kind: "basic", color: Color.rgb(248, 250, 255), coverage: 0.5, scale: 0.9, speed: 0.5, edge: 0.005, } export const fancyCumulus: CloudLayer = { kind: "fancy", color: Color.rgb(250, 251, 255), coverage: 0.5, scale: 0.6, speed: 0.5, edge: 0.02, warp: 0.4, relief: 7, } export function buildLevel(): Level { // Flat room floor, lifted a hair above the terrain's clearing (y 0). The // outdoor grid's cells straddle the room boundary and lap under the floor's // edges; this small z-bias keeps the flat stone floor winning the depth test // there instead of z-fighting the grass. The step is invisible at the doorway. const floor = mesh() const fy = FLOOR_LIFT quad(floor, [-ARENA, fy, -ARENA], [ARENA, fy, -ARENA], [ARENA, fy, ARENA], [-ARENA, fy, ARENA], 12, 12) const walls = mesh() const h = WALL_HEIGHT const t = WALL_THICKNESS // Three thick perimeter walls, outer faces flush with the room edge; the north // (-Z) side is left open onto the world. No ceiling, so the sky shows above. slab(walls, -ARENA, ARENA, ARENA - t, ARENA, 0, h, 0.5) // south (+Z) slab(walls, ARENA - t, ARENA, -ARENA, ARENA - t, 0, h, 0.5) // east (+X) slab(walls, -ARENA, -ARENA + t, -ARENA, ARENA - t, 0, h, 0.5) // west (-X) // Crate on the flat room floor. const crate = mesh() box(crate, CRATE.x, CRATE.z, CRATE.half, 0, CRATE.height) const colliders: Aabb[] = [ wall(-ARENA, ARENA, ARENA - t, ARENA), wall(ARENA - t, ARENA, -ARENA, ARENA - t), wall(-ARENA, -ARENA + t, -ARENA, ARENA - t), { minX: CRATE.x - CRATE.half, maxX: CRATE.x + CRATE.half, minZ: CRATE.z - CRATE.half, maxZ: CRATE.z + CRATE.half, top: CRATE.height, standable: true, }, ] const sky: SkyConfig = { zenith: Color.rgb(58, 108, 196), horizon: Color.rgb(178, 198, 226), sun: Color.rgb(255, 246, 214), sunDir: { x: 0.3, y: 0.5, z: -0.8 }, sunSize: 0.04, clouds: basicCumulus, } const npcPosition = { x: 2, y: 0, z: -1 } // Place the props (also pushes their colliders), then bake everything into // frustum-cullable spatial chunks. const trees = placeTrees(colliders) const boulders = placeBoulders(colliders) const bushes = placeBushes() const flowers = placeFlowers() const chunks = buildChunks(trees, boulders, bushes, flowers) return { floor, walls, crate, chunks, colliders, npcPosition, terrain: TERRAIN, sky } } /** Bake the terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` set of spatial * chunks. Each prop lands in the cell holding its base; the cell's bounds are * grown to the real geometry so overhanging canopies never get culled early. * Bushes share the oak leaf mesh; flowers get their own (double-sided) mesh. */ function buildChunks(trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers: Flower[]): Chunk[] { const cell = (TERRAIN.outer * 2) / CHUNK_GRID const chunks: Chunk[] = [] for (let ci = 0; ci < CHUNK_GRID; ci++) { const x0 = -TERRAIN.outer + ci * cell const x1 = x0 + cell for (let cj = 0; cj < CHUNK_GRID; cj++) { const z0 = -TERRAIN.outer + cj * cell const z1 = z0 + cell const grass = mesh() const bark = mesh() const leaf = mesh() const needle = mesh() const rock = mesh() const flowerMesh = mesh() const barkFar = 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") } } for (const boulder of boulders) { if (inCell(boulder.position, x0, z0, x1, z1)) { Boulder.build(boulder, rock) Boulder.build(boulder, rockFar, "impostor") } } // Bushes share the near leaf mesh; they just drop out past lodDistance. for (const bush of bushes) { if (inCell(bush.position, x0, z0, x1, z1)) { Bush.build(bush, leaf) } } for (const flower of flowers) { if (inCell(flower.position, x0, z0, x1, z1)) { Flower.build(flower, flowerMesh) } } const b = bounds([grass, bark, leaf, needle, rock, flowerMesh]) if (b === null) { continue } chunks.push({ ...b, grass, bark, leaf, needle, rock, flowers: flowerMesh, barkFar, leafFar, needleFar, rockFar }) } } return chunks } function inCell(p: { x: number; z: number }, x0: number, z0: number, x1: number, z1: number): boolean { return p.x >= x0 && p.x < x1 && p.z >= z0 && p.z < z1 } /** Tight AABB over several meshes' vertices, or null if they are all empty. */ function bounds(meshes: Mesh[]): Pick | null { let minX = Infinity let minY = Infinity let minZ = Infinity let maxX = -Infinity let maxY = -Infinity let maxZ = -Infinity for (const m of meshes) { const verts = m.verts for (let i = 0; i < verts.length; i += STRIDE) { const x = verts[i] const y = verts[i + 1] const z = verts[i + 2] minX = Math.min(minX, x) minY = Math.min(minY, y) minZ = Math.min(minZ, z) maxX = Math.max(maxX, x) maxY = Math.max(maxY, y) maxZ = Math.max(maxZ, z) } } return maxX < minX ? null : { minX, minY, minZ, maxX, maxY, maxZ } } /** 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 placeTrees(colliders: Aabb[]): Tree[] { const rand = mulberry(TREE_SEED) const maxDist = TERRAIN.outer * TREE_REACH const trees: Tree[] = [] for (let guard = 0; trees.length < 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 } trees.push({ kind, position, growth, seed: (rand() * 0xFFFFFFFF) | 0 }) // 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 }) } } return trees } /** 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 placeBoulders(colliders: Aabb[]): Boulder[] { const rand = mulberry(BOULDER_SEED) const maxDist = TERRAIN.outer * BOULDER_REACH const boulders: Boulder[] = [] for (let guard = 0; boulders.length < 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 } boulders.push({ position, radius, seed: (rand() * 0xFFFFFFFF) | 0 }) 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 }) } } return boulders } /** Scatter bushes on the grass near the play area (no colliders -- walk through). */ function placeBushes(): Bush[] { const rand = mulberry(BUSH_SEED) const maxDist = TERRAIN.outer * BUSH_REACH const bushes: Bush[] = [] for (let guard = 0; bushes.length < BUSH_COUNT && guard < BUSH_COUNT * 20; guard++) { const angle = rand() * Math.PI * 2 const dist = ARENA + 3 + rand() * (maxDist - ARENA - 3) 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 } bushes.push({ position: { x, y: Terrain.height(TERRAIN, x, z), z }, size: 0.8 + rand() * 1, seed: (rand() * 0xFFFFFFFF) | 0 }) } return bushes } /** Scatter small flowers on the grass near the play area, colors rolled. */ function placeFlowers(): Flower[] { const rand = mulberry(FLOWER_SEED) const maxDist = TERRAIN.outer * FLOWER_REACH const flowers: Flower[] = [] for (let guard = 0; flowers.length < FLOWER_COUNT && guard < FLOWER_COUNT * 20; guard++) { const angle = rand() * Math.PI * 2 const dist = ARENA + 2 + rand() * (maxDist - ARENA - 2) const x = Math.cos(angle) * dist const z = Math.sin(angle) * dist if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 1) { continue } const color = FLOWER_COLORS[(rand() * FLOWER_COLORS.length) | 0] flowers.push({ position: { x, y: Terrain.height(TERRAIN, x, z), z }, color, size: 0.28 + rand() * 0.22, seed: (rand() * 0xFFFFFFFF) | 0 }) } return flowers } /** 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 { return { verts: [], indices: [] } } /** A perimeter wall collider: blocks from the sides, and `standable` so you can * jump up and land on its top (given enough JUMP_SPEED to clear WALL_HEIGHT). */ function wall(minX: number, maxX: number, minZ: number, maxZ: number): Aabb { return { minX, maxX, minZ, maxZ, top: WALL_HEIGHT, standable: true } } /** One flat quad (two tris). Corners run a (uv 0,0) -> b (us,0) -> c (us,vs) -> * d (0,vs); `us`/`vs` set how many texture tiles span it. No subdivision is * needed -- texturing is perspective-correct, so a single quad looks right at * any size. */ function quad(m: Mesh, a: Corner, b: Corner, c: Corner, d: Corner, us: number, vs: number): void { const base = m.verts.length / STRIDE m.verts.push(a[0], a[1], a[2], 0, 0, b[0], b[1], b[2], us, 0, c[0], c[1], c[2], us, vs, d[0], d[1], d[2], 0, vs) m.indices.push(base, base + 1, base + 2, base, base + 2, base + 3) } /** An axis-aligned box from (x0,z0)-(x1,z1), y0..y1: four sides + top, no bottom * (never seen from below). `tpu` = texture tiles per world unit, so every face * tiles at the same density whatever its size. Used for the thick walls. */ function slab(m: Mesh, x0: number, x1: number, z0: number, z1: number, y0: number, y1: number, tpu: number): void { const dx = (x1 - x0) * tpu const dz = (z1 - z0) * tpu const dy = (y1 - y0) * tpu quad(m, [x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1], dx, dz) quad(m, [x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], dx, dy) quad(m, [x1, y0, z1], [x0, y0, z1], [x0, y1, z1], [x1, y1, z1], dx, dy) quad(m, [x0, y0, z1], [x0, y0, z0], [x0, y1, z0], [x0, y1, z1], dz, dy) quad(m, [x1, y0, z0], [x1, y0, z1], [x1, y1, z1], [x1, y1, z0], dz, dy) } /** A box centered at (cx, cz), rising `height` units from `base`: top face plus * four sides, one uv tile per face. No bottom (never seen). */ function box(m: Mesh, cx: number, cz: number, half: number, base: number, height: number): void { const x0 = cx - half const x1 = cx + half const z0 = cz - half const z1 = cz + half const y0 = base const y1 = base + height quad(m, [x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1], 1, 1) quad(m, [x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], 1, 1) quad(m, [x1, y0, z1], [x0, y0, z1], [x0, y1, z1], [x1, y1, z1], 1, 1) quad(m, [x1, y0, z0], [x1, y0, z1], [x1, y1, z1], [x1, y1, z0], 1, 1) quad(m, [x0, y0, z1], [x0, y0, z0], [x0, y1, z0], [x0, y1, z1], 1, 1) }