refactor: move world concepts into engine

This commit is contained in:
Toad 2026-08-24 15:17:53 +02:00
parent eeedcb8e48
commit 2d15c7ab8d
52 changed files with 3298 additions and 1558 deletions

View file

@ -0,0 +1,89 @@
import type { Vec3 } from "../math/Vec3"
import { CollisionWorld, type CollisionWorld as World } from "./CollisionWorld"
export type Character = {
position: Vec3
yaw: number
velocityY: number
onGround: boolean
}
export type CharacterInput = {
forward: number
right: number
jump: boolean
run: boolean
}
export type CharacterConfig = {
radius: number
speed: number
runMultiplier: number
gravity: number
jumpSpeed: number
eyeHeight: number
}
export namespace CharacterController {
export function update(
character: Character,
input: CharacterInput,
dt: number,
world: World,
config: CharacterConfig,
): void {
if (input.jump && character.onGround) {
character.velocityY = config.jumpSpeed
character.onGround = false
}
const steps = moveSubsteps(input, dt, config)
for (let i = 0; i < steps; i++) {
moveHorizontal(character, input, dt / steps, config)
CollisionWorld.pushOut(world, character.position, config.radius)
}
character.velocityY -= config.gravity * dt
character.position.y += character.velocityY * dt
const ground = CollisionWorld.groundHeight(world, character.position)
if (character.position.y <= ground) {
character.position.y = ground
character.velocityY = 0
character.onGround = true
} else {
character.onGround = false
}
}
function moveSubsteps(
input: CharacterInput,
dt: number,
config: CharacterConfig,
): number {
const distance =
config.speed *
runFactor(input, config) *
dt *
Math.hypot(input.forward, input.right)
return Math.max(1, Math.ceil(distance / config.radius))
}
function moveHorizontal(
character: Character,
input: CharacterInput,
dt: number,
config: CharacterConfig,
): void {
const speed = config.speed * runFactor(input, config) * dt
const forwardX = Math.sin(character.yaw)
const forwardZ = -Math.cos(character.yaw)
const rightX = Math.cos(character.yaw)
const rightZ = Math.sin(character.yaw)
character.position.x +=
(forwardX * input.forward + rightX * input.right) * speed
character.position.z +=
(forwardZ * input.forward + rightZ * input.right) * speed
}
function runFactor(input: CharacterInput, config: CharacterConfig): number {
return input.run ? config.runMultiplier : 1
}
}

116
engine/world/Collider.ts Normal file
View file

@ -0,0 +1,116 @@
import type { Vec3 } from "../math/Vec3"
/** A 2.5D box: horizontal footprint plus top height. */
export type BoxCollider = {
shape: "box"
minX: number
maxX: number
minZ: number
maxZ: number
top: number
standable: boolean
}
/** A 2.5D circle: horizontal footprint plus top height. */
export type CircleCollider = {
shape: "circle"
x: number
z: number
radius: number
top: number
standable: boolean
}
/** Finite engine collision capabilities, not game-content identity. */
export type Collider = BoxCollider | CircleCollider
export namespace Collider {
export function centerX(collider: Collider): number {
return collider.shape === "circle"
? collider.x
: (collider.minX + collider.maxX) * 0.5
}
export function centerZ(collider: Collider): number {
return collider.shape === "circle"
? collider.z
: (collider.minZ + collider.maxZ) * 0.5
}
export function contains(collider: Collider, x: number, z: number): boolean {
if (collider.shape === "circle") {
const dx = x - collider.x
const dz = z - collider.z
return dx * dx + dz * dz <= collider.radius * collider.radius
}
return (
x >= collider.minX &&
x <= collider.maxX &&
z >= collider.minZ &&
z <= collider.maxZ
)
}
/** Push a horizontal player circle out of one collider. */
export function pushOut(
collider: Collider,
position: Vec3,
radius: number,
): void {
if (collider.shape === "circle") {
pushFromCircle(position, radius, collider)
return
}
pushFromBox(position, radius, collider)
}
function pushFromBox(position: Vec3, radius: number, box: BoxCollider): void {
const cx = Math.max(box.minX, Math.min(box.maxX, position.x))
const cz = Math.max(box.minZ, Math.min(box.maxZ, position.z))
const dx = position.x - cx
const dz = position.z - cz
const distanceSquared = dx * dx + dz * dz
if (distanceSquared >= radius * radius) {
return
}
if (distanceSquared > 1e-6) {
const distance = Math.sqrt(distanceSquared)
const push = (radius - distance) / distance
position.x += dx * push
position.z += dz * push
return
}
const left = position.x - box.minX
const right = box.maxX - position.x
const near = position.z - box.minZ
const far = box.maxZ - position.z
const nearest = Math.min(left, right, near, far)
if (nearest === left) {
position.x = box.minX - radius
} else if (nearest === right) {
position.x = box.maxX + radius
} else if (nearest === near) {
position.z = box.minZ - radius
} else {
position.z = box.maxZ + radius
}
}
function pushFromCircle(
position: Vec3,
radius: number,
circle: CircleCollider,
): void {
const dx = position.x - circle.x
const dz = position.z - circle.z
const reach = radius + circle.radius
const distanceSquared = dx * dx + dz * dz
if (distanceSquared >= reach * reach || distanceSquared < 1e-6) {
return
}
const distance = Math.sqrt(distanceSquared)
const push = (reach - distance) / distance
position.x += dx * push
position.z += dz * push
}
}

View file

@ -0,0 +1,62 @@
import type { Vec3 } from "../math/Vec3"
import { Collider, type Collider as ColliderShape } from "./Collider"
import type { Terrain } from "./Terrain"
export type CollisionWorld = {
terrain: Terrain
staticColliders: ColliderShape[]
dynamicColliders: ColliderShape[]
}
export namespace CollisionWorld {
export function create(
terrain: Terrain,
staticColliders: ColliderShape[],
): CollisionWorld {
return { terrain, staticColliders, dynamicColliders: [] }
}
export function pushOut(
world: CollisionWorld,
position: Vec3,
radius: number,
): void {
pushFrom(world.staticColliders, position, radius)
pushFrom(world.dynamicColliders, position, radius)
}
export function groundHeight(world: CollisionWorld, position: Vec3): number {
let ground = world.terrain.heightAt(position.x, position.z)
ground = standingHeight(world.staticColliders, position, ground)
return standingHeight(world.dynamicColliders, position, ground)
}
function pushFrom(
colliders: ColliderShape[],
position: Vec3,
radius: number,
): void {
for (const collider of colliders) {
if (position.y < collider.top - 0.01) {
Collider.pushOut(collider, position, radius)
}
}
}
function standingHeight(
colliders: ColliderShape[],
position: Vec3,
initial: number,
): number {
let ground = initial
for (const collider of colliders) {
if (
collider.standable &&
Collider.contains(collider, position.x, position.z)
) {
ground = Math.max(ground, collider.top)
}
}
return ground
}
}

123
engine/world/Level.ts Normal file
View file

@ -0,0 +1,123 @@
import {
Actor,
type Actor as RuntimeActor,
} from "../scene/Actor"
import { Collider, type Collider as CollisionShape } from "./Collider"
import {
CollisionWorld,
type CollisionWorld as Collision,
} from "./CollisionWorld"
import type { Terrain } from "./Terrain"
import {
RenderScene,
type Billboard,
type RenderInstance,
type RenderPrototype,
type RenderScene as Scene,
} from "../render/RenderScene"
import type { Chunk } from "../render/Chunk"
import type { DrawGroup } from "../render/Material"
import type { SkyConfig } from "../render/Sky"
import type { Mat4 } from "../math/Mat4"
import type { Vec3 } from "../math/Vec3"
export type LevelDefinition<World> = {
terrain: Terrain
actorWorld: World
actors: readonly RuntimeActor<World>[]
staticColliders: CollisionShape[]
staticGroups: DrawGroup[]
chunks: Chunk[]
billboards: Billboard[]
sky: SkyConfig
}
/** Live engine world. Behavior-bearing actors stay here on the main thread; only
* `render` is clone-safe and sent to workers. */
export type Level<World> = {
readonly terrain: Terrain
readonly actorWorld: World
readonly actors: readonly RuntimeActor<World>[]
readonly collision: Collision
readonly render: Scene
}
const prototypeIndexes = new WeakMap<object, ReadonlyMap<object, number>>()
export namespace Level {
export function create<World>(definition: LevelDefinition<World>): Level<World> {
const actors = Object.freeze([...definition.actors])
const prototypes: RenderPrototype[] = []
const prototypeIndex = new Map<object, number>()
for (const actor of actors) {
if (!prototypeIndex.has(actor.definition)) {
prototypeIndex.set(actor.definition, prototypes.length)
prototypes.push(actor.prototype)
}
}
const level: Level<World> = {
terrain: definition.terrain,
actorWorld: definition.actorWorld,
actors,
collision: CollisionWorld.create(
definition.terrain,
definition.staticColliders,
),
render: Object.freeze({
chunks: Object.freeze([...definition.chunks]),
staticGroups: Object.freeze([...definition.staticGroups]),
billboards: Object.freeze([...definition.billboards]),
prototypes: Object.freeze(prototypes),
maxInstances: actors.length,
sky: definition.sky,
}),
}
prototypeIndexes.set(level, prototypeIndex)
return level
}
export function update<World>(level: Level<World>, dt: number): void {
for (const actor of level.actors) {
Actor.update(actor, dt, level.actorWorld)
}
}
export function visibleInstances<World>(
level: Level<World>,
viewProjection: Mat4,
): RenderInstance[] {
const prototypeIndex = prototypeIndexes.get(level)
if (prototypeIndex === undefined) {
throw new Error("level was not created by Level.create")
}
const instances: RenderInstance[] = []
for (const actor of level.actors) {
const prototype = prototypeIndex.get(actor.definition)
if (prototype !== undefined) {
instances.push({ prototype, ...Actor.transform(actor) })
}
}
return RenderScene.visibleInstances(level.render, instances, viewProjection)
}
export function refreshActorColliders<World>(
level: Level<World>,
focus: Vec3,
range: number,
): void {
const dynamic = level.collision.dynamicColliders
dynamic.length = 0
const rangeSquared = range * range
for (const actor of level.actors) {
const collider = Actor.collider(actor, level.actorWorld)
if (collider === null) {
continue
}
const dx = Collider.centerX(collider) - focus.x
const dz = Collider.centerZ(collider) - focus.z
if (dx * dx + dz * dz <= rangeSquared) {
dynamic.push(collider)
}
}
}
}

121
engine/world/Terrain.ts Normal file
View file

@ -0,0 +1,121 @@
import { STRIDE, type Mesh } from "../scene/Mesh"
/** A bounded ground surface. Implementations may be procedural, sampled, or
* loaded; callers only depend on world-space height sampling. */
export type Terrain = {
minX: number
minZ: number
maxX: number
maxZ: number
heightAt: (x: number, z: number) => number
}
/** Parameters for the built-in rolling terrain generator. Values describe the
* surface only; level-specific holes and materials belong to level data. */
export type RollingTerrainConfig = {
inner: number
outer: number
blend: number
amplitude: number
frequency: number
peakHeight: number
peakFrequency: number
peakStart: number
}
export namespace Terrain {
/** Built-in square world with a flat center, rolling hills, and edge ridges. */
export function rolling(config: RollingTerrainConfig): Terrain {
return {
minX: -config.outer,
minZ: -config.outer,
maxX: config.outer,
maxZ: config.outer,
heightAt(x, z) {
const r = Math.max(Math.abs(x), Math.abs(z))
if (r <= config.inner) {
return 0
}
const rise = smoothstep(config.inner, config.inner + config.blend, r)
const hills = config.amplitude * bumps(x, z, config.frequency)
const k = Math.min(
1,
(r - config.inner) / (config.outer - config.inner),
)
const peaks =
config.peakHeight *
ridges(x, z, config.peakFrequency) *
smoothstep(config.peakStart, 1, k)
return rise * (hills + peaks)
},
}
}
export function height(terrain: Terrain, x: number, z: number): number {
return terrain.heightAt(x, z)
}
/** Append one sampled heightfield patch. `include` is level policy evaluated at
* each quad center, allowing arbitrary holes without teaching terrain what
* occupies them. */
export function patch(
terrain: Terrain,
mesh: Mesh,
x0: number,
z0: number,
x1: number,
z1: number,
cols: number,
rows: number,
uvScale: number,
include?: (x: number, z: number) => boolean,
): void {
const base = mesh.verts.length / STRIDE
const dx = (x1 - x0) / cols
const dz = (z1 - z0) / rows
const rowLength = cols + 1
for (let row = 0; row <= rows; row++) {
const z = z0 + row * dz
for (let col = 0; col <= cols; col++) {
const x = x0 + col * dx
mesh.verts.push(x, terrain.heightAt(x, z), z, x * uvScale, z * uvScale)
}
}
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const cx = x0 + (col + 0.5) * dx
const cz = z0 + (row + 0.5) * dz
if (include !== undefined && !include(cx, cz)) {
continue
}
const p = base + row * rowLength + col
mesh.indices.push(
p,
p + rowLength + 1,
p + 1,
p,
p + rowLength,
p + rowLength + 1,
)
}
}
}
function bumps(x: number, z: number, frequency: number): number {
const a = Math.sin(x * frequency) * Math.cos(z * frequency)
const b = Math.sin((x + z) * frequency * 0.5 + 1.7) * 0.5
return (a + b + 1.5) / 3
}
function ridges(x: number, z: number, frequency: number): number {
const n =
Math.sin(x * frequency + 1.3) * Math.cos(z * frequency - 0.7) * 0.7 +
Math.sin((x + z) * frequency * 0.6 + 2.5) * 0.3
return 1 - Math.abs(n)
}
function smoothstep(a: number, b: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - a) / (b - a || 1e-4)))
return t * t * (3 - 2 * t)
}
}