meat/app/level.ts
2026-08-04 14:02:12 +02:00

294 lines
11 KiB
TypeScript

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"
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
}
/** The playground: a flat-floored room dropped into the center of a big open
* landscape. Geometry is split by texture, plus the collision solids, where the
* NPC stands, the heightfield the outdoor ground + player share, and the sky. */
export type Level = {
floor: Mesh
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
/** Scattered boulders (rock texture). */
boulders: Mesh
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
/** 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. */
const GROUND_DIVISIONS = 56
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)
// The big surrounding landscape, with a hole where the room sits.
const ground = Terrain.ground(TERRAIN, GROUND_DIVISIONS, GROUND_UV)
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 }
// 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)
// 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
* 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++
}
}
/** 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
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 { vertices: [], 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.vertices.length
m.vertices.push(
{ pos: { x: a[0], y: a[1], z: a[2] }, uv: { x: 0, y: 0 } },
{ pos: { x: b[0], y: b[1], z: b[2] }, uv: { x: us, y: 0 } },
{ pos: { x: c[0], y: c[1], z: c[2] }, uv: { x: us, y: vs } },
{ pos: { x: d[0], y: d[1], z: d[2] }, uv: { x: 0, y: 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)
}