2026-08-04 02:09:54 +02:00
|
|
|
import { Color } from "../engine/render/Color"
|
2026-08-04 03:00:58 +02:00
|
|
|
import type { CloudLayer, SkyConfig } from "../engine/render/Sky"
|
2026-08-04 19:12:09 +02:00
|
|
|
import { STRIDE, type Mesh } from "../engine/scene/Mesh"
|
2026-08-04 14:02:12 +02:00
|
|
|
import { Boulder } from "../engine/scene/Boulder"
|
2026-08-04 23:03:55 +02:00
|
|
|
import { Bush } from "../engine/scene/Bush"
|
|
|
|
|
import { Flower, type FlowerColor } from "../engine/scene/Flower"
|
2026-08-04 12:51:07 +02:00
|
|
|
import { Terrain } from "../engine/scene/Terrain"
|
2026-08-04 13:53:59 +02:00
|
|
|
import { Tree } from "../engine/scene/Tree"
|
2026-08-04 02:09:54 +02:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 15:05:02 +02:00
|
|
|
/** 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
|
2026-08-04 23:03:55 +02:00
|
|
|
/** Flowers (color-atlas texture); drawn double-sided, so kept separate. */
|
|
|
|
|
flowers: Mesh
|
2026-08-05 09:24:13 +02:00
|
|
|
/** 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
|
2026-08-04 15:05:02 +02:00
|
|
|
}
|
|
|
|
|
|
2026-08-04 12:51:07 +02:00
|
|
|
/** The playground: a flat-floored room dropped into the center of a big open
|
2026-08-04 15:05:02 +02:00
|
|
|
* landscape. The room (floor/walls/crate) is small and always drawn; the
|
|
|
|
|
* outdoor world is split into `chunks` that are frustum-culled per frame. */
|
2026-08-04 02:09:54 +02:00
|
|
|
export type Level = {
|
|
|
|
|
floor: Mesh
|
|
|
|
|
walls: Mesh
|
|
|
|
|
crate: Mesh
|
2026-08-04 15:05:02 +02:00
|
|
|
chunks: Chunk[]
|
2026-08-04 02:09:54 +02:00
|
|
|
colliders: Aabb[]
|
|
|
|
|
npcPosition: { x: number; y: number; z: number }
|
2026-08-04 12:51:07 +02:00
|
|
|
terrain: Terrain
|
2026-08-04 02:09:54 +02:00
|
|
|
sky: SkyConfig
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ARENA = 12
|
2026-08-04 02:39:20 +02:00
|
|
|
const WALL_HEIGHT = 4
|
2026-08-04 13:09:26 +02:00
|
|
|
/** 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
|
2026-08-04 12:51:07 +02:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 13:53:59 +02:00
|
|
|
/** 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
|
|
|
|
|
|
2026-08-04 14:02:12 +02:00
|
|
|
/** 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
|
|
|
|
|
|
2026-08-04 23:03:55 +02:00
|
|
|
/** 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"]
|
|
|
|
|
|
2026-08-04 15:05:02 +02:00
|
|
|
/** 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
|
2026-08-04 12:51:07 +02:00
|
|
|
const GROUND_UV = 0.25
|
2026-08-04 02:39:20 +02:00
|
|
|
|
2026-08-04 03:00:58 +02:00
|
|
|
/** 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,
|
2026-08-04 12:51:07 +02:00
|
|
|
edge: 0.005,
|
2026-08-04 03:00:58 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 02:09:54 +02:00
|
|
|
export function buildLevel(): Level {
|
2026-08-04 12:51:07 +02:00
|
|
|
// 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.
|
2026-08-04 02:09:54 +02:00
|
|
|
const floor = mesh()
|
2026-08-04 12:51:07 +02:00
|
|
|
const fy = FLOOR_LIFT
|
|
|
|
|
quad(floor, [-ARENA, fy, -ARENA], [ARENA, fy, -ARENA], [ARENA, fy, ARENA], [-ARENA, fy, ARENA], 12, 12)
|
|
|
|
|
|
2026-08-04 02:09:54 +02:00
|
|
|
const walls = mesh()
|
|
|
|
|
const h = WALL_HEIGHT
|
2026-08-04 13:09:26 +02:00
|
|
|
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)
|
2026-08-04 02:09:54 +02:00
|
|
|
|
2026-08-04 12:51:07 +02:00
|
|
|
// Crate on the flat room floor.
|
2026-08-04 02:09:54 +02:00
|
|
|
const crate = mesh()
|
2026-08-04 12:51:07 +02:00
|
|
|
box(crate, CRATE.x, CRATE.z, CRATE.half, 0, CRATE.height)
|
2026-08-04 02:09:54 +02:00
|
|
|
|
|
|
|
|
const colliders: Aabb[] = [
|
2026-08-04 13:09:26 +02:00
|
|
|
wall(-ARENA, ARENA, ARENA - t, ARENA),
|
|
|
|
|
wall(ARENA - t, ARENA, -ARENA, ARENA - t),
|
|
|
|
|
wall(-ARENA, -ARENA + t, -ARENA, ARENA - t),
|
2026-08-04 02:09:54 +02:00
|
|
|
{
|
|
|
|
|
minX: CRATE.x - CRATE.half,
|
|
|
|
|
maxX: CRATE.x + CRATE.half,
|
|
|
|
|
minZ: CRATE.z - CRATE.half,
|
|
|
|
|
maxZ: CRATE.z + CRATE.half,
|
2026-08-04 12:51:07 +02:00
|
|
|
top: CRATE.height,
|
2026-08-04 02:09:54 +02:00
|
|
|
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,
|
2026-08-04 11:39:39 +02:00
|
|
|
clouds: basicCumulus,
|
2026-08-04 02:09:54 +02:00
|
|
|
}
|
|
|
|
|
|
2026-08-04 12:51:07 +02:00
|
|
|
const npcPosition = { x: 2, y: 0, z: -1 }
|
|
|
|
|
|
2026-08-04 15:05:02 +02:00
|
|
|
// Place the props (also pushes their colliders), then bake everything into
|
|
|
|
|
// frustum-cullable spatial chunks.
|
|
|
|
|
const trees = placeTrees(colliders)
|
|
|
|
|
const boulders = placeBoulders(colliders)
|
2026-08-04 23:03:55 +02:00
|
|
|
const bushes = placeBushes()
|
|
|
|
|
const flowers = placeFlowers()
|
|
|
|
|
const chunks = buildChunks(trees, boulders, bushes, flowers)
|
2026-08-04 15:05:02 +02:00
|
|
|
|
|
|
|
|
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
|
2026-08-04 23:03:55 +02:00
|
|
|
* 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[] {
|
2026-08-04 15:05:02 +02:00
|
|
|
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()
|
2026-08-04 23:03:55 +02:00
|
|
|
const flowerMesh = mesh()
|
2026-08-05 09:24:13 +02:00
|
|
|
const barkFar = mesh()
|
|
|
|
|
const leafFar = mesh()
|
|
|
|
|
const needleFar = mesh()
|
|
|
|
|
const rockFar = mesh()
|
2026-08-04 15:05:02 +02:00
|
|
|
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)) {
|
2026-08-05 09:24:13 +02:00
|
|
|
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")
|
2026-08-04 15:05:02 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const boulder of boulders) {
|
|
|
|
|
if (inCell(boulder.position, x0, z0, x1, z1)) {
|
|
|
|
|
Boulder.build(boulder, rock)
|
2026-08-05 09:24:13 +02:00
|
|
|
Boulder.build(boulder, rockFar, "impostor")
|
2026-08-04 15:05:02 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-05 09:24:13 +02:00
|
|
|
// Bushes share the near leaf mesh; they just drop out past lodDistance.
|
2026-08-04 23:03:55 +02:00
|
|
|
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])
|
2026-08-04 15:05:02 +02:00
|
|
|
if (b === null) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-08-05 09:24:13 +02:00
|
|
|
chunks.push({ ...b, grass, bark, leaf, needle, rock, flowers: flowerMesh, barkFar, leafFar, needleFar, rockFar })
|
2026-08-04 15:05:02 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return chunks
|
|
|
|
|
}
|
2026-08-04 13:53:59 +02:00
|
|
|
|
2026-08-04 15:05:02 +02:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-04 14:02:12 +02:00
|
|
|
|
2026-08-04 15:05:02 +02:00
|
|
|
/** Tight AABB over several meshes' vertices, or null if they are all empty. */
|
|
|
|
|
function bounds(meshes: Mesh[]): Pick<Chunk, "minX" | "minY" | "minZ" | "maxX" | "maxY" | "maxZ"> | null {
|
|
|
|
|
let minX = Infinity
|
|
|
|
|
let minY = Infinity
|
|
|
|
|
let minZ = Infinity
|
|
|
|
|
let maxX = -Infinity
|
|
|
|
|
let maxY = -Infinity
|
|
|
|
|
let maxZ = -Infinity
|
|
|
|
|
for (const m of meshes) {
|
2026-08-04 19:12:09 +02:00
|
|
|
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)
|
2026-08-04 15:05:02 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return maxX < minX ? null : { minX, minY, minZ, maxX, maxY, maxZ }
|
2026-08-04 13:53:59 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 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. */
|
2026-08-04 15:05:02 +02:00
|
|
|
function placeTrees(colliders: Aabb[]): Tree[] {
|
2026-08-04 13:53:59 +02:00
|
|
|
const rand = mulberry(TREE_SEED)
|
|
|
|
|
const maxDist = TERRAIN.outer * TREE_REACH
|
2026-08-04 15:05:02 +02:00
|
|
|
const trees: Tree[] = []
|
|
|
|
|
for (let guard = 0; trees.length < TREE_COUNT && guard < TREE_COUNT * 20; guard++) {
|
2026-08-04 13:53:59 +02:00
|
|
|
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 }
|
2026-08-04 15:05:02 +02:00
|
|
|
trees.push({ kind, position, growth, seed: (rand() * 0xFFFFFFFF) | 0 })
|
2026-08-04 13:53:59 +02:00
|
|
|
// 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 })
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-04 15:05:02 +02:00
|
|
|
return trees
|
2026-08-04 13:53:59 +02:00
|
|
|
}
|
|
|
|
|
|
2026-08-04 14:02:12 +02:00
|
|
|
/** 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). */
|
2026-08-04 15:05:02 +02:00
|
|
|
function placeBoulders(colliders: Aabb[]): Boulder[] {
|
2026-08-04 14:02:12 +02:00
|
|
|
const rand = mulberry(BOULDER_SEED)
|
|
|
|
|
const maxDist = TERRAIN.outer * BOULDER_REACH
|
2026-08-04 15:05:02 +02:00
|
|
|
const boulders: Boulder[] = []
|
|
|
|
|
for (let guard = 0; boulders.length < BOULDER_COUNT && guard < BOULDER_COUNT * 20; guard++) {
|
2026-08-04 14:02:12 +02:00
|
|
|
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 }
|
2026-08-04 15:05:02 +02:00
|
|
|
boulders.push({ position, radius, seed: (rand() * 0xFFFFFFFF) | 0 })
|
2026-08-04 14:02:12 +02:00
|
|
|
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 })
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-04 15:05:02 +02:00
|
|
|
return boulders
|
2026-08-04 14:02:12 +02:00
|
|
|
}
|
|
|
|
|
|
2026-08-04 23:03:55 +02:00
|
|
|
/** 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 13:53:59 +02:00
|
|
|
/** 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
|
|
|
|
|
}
|
2026-08-04 02:09:54 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mesh(): Mesh {
|
2026-08-04 19:12:09 +02:00
|
|
|
return { verts: [], indices: [] }
|
2026-08-04 02:09:54 +02:00
|
|
|
}
|
|
|
|
|
|
2026-08-04 13:09:26 +02:00
|
|
|
/** 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). */
|
2026-08-04 02:09:54 +02:00
|
|
|
function wall(minX: number, maxX: number, minZ: number, maxZ: number): Aabb {
|
2026-08-04 13:09:26 +02:00
|
|
|
return { minX, maxX, minZ, maxZ, top: WALL_HEIGHT, standable: true }
|
2026-08-04 02:09:54 +02:00
|
|
|
}
|
|
|
|
|
|
2026-08-04 12:51:07 +02:00
|
|
|
/** 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 {
|
2026-08-04 19:12:09 +02:00
|
|
|
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)
|
2026-08-04 12:51:07 +02:00
|
|
|
m.indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
|
2026-08-04 02:39:20 +02:00
|
|
|
}
|
|
|
|
|
|
2026-08-04 13:09:26 +02:00
|
|
|
/** 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 12:51:07 +02:00
|
|
|
/** 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 {
|
2026-08-04 02:09:54 +02:00
|
|
|
const x0 = cx - half
|
|
|
|
|
const x1 = cx + half
|
|
|
|
|
const z0 = cz - half
|
|
|
|
|
const z1 = cz + half
|
2026-08-04 12:51:07 +02:00
|
|
|
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)
|
2026-08-04 02:09:54 +02:00
|
|
|
}
|