refactor: move world concepts into engine
This commit is contained in:
parent
eeedcb8e48
commit
2d15c7ab8d
52 changed files with 3298 additions and 1558 deletions
38
tests/actors.test.ts
Normal file
38
tests/actors.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { Actor, type Actor as RuntimeActor, type ActorDefinition } from "../engine/scene/Actor"
|
||||
|
||||
type World = { updates: string[] }
|
||||
|
||||
const numberDefinition: ActorDefinition<{ value: number }, World> = {
|
||||
prototype: { groups: [], radius: 1, minY: 0, maxY: 1 },
|
||||
update: (state, _dt, world) => {
|
||||
state.value++
|
||||
world.updates.push(String(state.value))
|
||||
},
|
||||
transform: (state) => ({ x: state.value, y: 0, z: 0, heading: 0, scale: 1 }),
|
||||
}
|
||||
|
||||
const textDefinition: ActorDefinition<{ value: string }, World> = {
|
||||
prototype: { groups: [], radius: 1, minY: 0, maxY: 1 },
|
||||
update: (state, _dt, world) => {
|
||||
state.value += "!"
|
||||
world.updates.push(state.value)
|
||||
},
|
||||
transform: (state) => ({ x: state.value.length, y: 0, z: 0, heading: 0, scale: 1 }),
|
||||
}
|
||||
|
||||
test("one actor collection safely erases unrelated state types", () => {
|
||||
const actors: RuntimeActor<World>[] = [
|
||||
Actor.create(numberDefinition, { value: 1 }),
|
||||
Actor.create(textDefinition, { value: "a" }),
|
||||
]
|
||||
const world: World = { updates: [] }
|
||||
|
||||
for (const actor of actors) {
|
||||
Actor.update(actor, 1, world)
|
||||
}
|
||||
|
||||
expect(world.updates).toEqual(["2", "a!"])
|
||||
expect(Actor.transform(actors[0]).x).toBe(2)
|
||||
expect(Actor.transform(actors[1]).x).toBe(2)
|
||||
})
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
// The engine is the reusable, content-agnostic layer: it must import nothing from
|
||||
// game (content) or app (browser glue). game -> engine and app -> game -> engine are
|
||||
|
|
@ -9,7 +9,7 @@ import { join } from "node:path"
|
|||
function tsFiles(dir: string): string[] {
|
||||
const out: string[] = []
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name)
|
||||
const p = path.join(dir, name)
|
||||
if (statSync(p).isDirectory()) {
|
||||
out.push(...tsFiles(p))
|
||||
} else if (p.endsWith(".ts")) {
|
||||
|
|
@ -19,14 +19,42 @@ function tsFiles(dir: string): string[] {
|
|||
return out
|
||||
}
|
||||
|
||||
function repositoryRoot(): string {
|
||||
const parent = new URL("..", import.meta.url).pathname
|
||||
return existsSync(path.join(parent, "app/renderer.ts")) ? parent : path.join(parent, "..")
|
||||
}
|
||||
|
||||
function importsLayer(source: string, layers: string): boolean {
|
||||
return new RegExp(`(?:from\\s+|import\\s*(?:\\(\\s*)?)["'][^"']*/(?:${layers})/`).test(source)
|
||||
}
|
||||
|
||||
test("engine imports nothing from game or app", () => {
|
||||
const engineDir = new URL("../engine", import.meta.url).pathname
|
||||
const offenders = tsFiles(engineDir).filter((f) => /from\s+["'][^"']*\/(?:game|app)\//.test(readFileSync(f, "utf8")))
|
||||
const engineDir = path.join(repositoryRoot(), "engine")
|
||||
const offenders = tsFiles(engineDir).filter((file) => importsLayer(readFileSync(file, "utf8"), "game|app"))
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
||||
test("game imports nothing from app", () => {
|
||||
const gameDir = new URL("../game", import.meta.url).pathname
|
||||
const offenders = tsFiles(gameDir).filter((f) => /from\s+["'][^"']*\/app\//.test(readFileSync(f, "utf8")))
|
||||
const gameDir = path.join(repositoryRoot(), "game")
|
||||
const offenders = tsFiles(gameDir).filter((file) => importsLayer(readFileSync(file, "utf8"), "app"))
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
||||
test("render driver and worker know no game content", () => {
|
||||
const root = repositoryRoot()
|
||||
const files = [path.join(root, "app/renderer.ts"), path.join(root, "app/render-worker.ts")]
|
||||
const offenders = files.filter((file) => importsLayer(readFileSync(file, "utf8"), "game"))
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
||||
test("game exports no closed content-kind registries", () => {
|
||||
const gameDir = path.join(repositoryRoot(), "game")
|
||||
const offenders = tsFiles(gameDir).filter((file) => {
|
||||
const source = readFileSync(file, "utf8")
|
||||
return (
|
||||
/\b(?:export\s+)?(?:type|enum)\s+\w+Kind\b/.test(source) ||
|
||||
/\b(?:const|let|var)\s+\w*_(?:KINDS|REGISTRY)\b/.test(source)
|
||||
)
|
||||
})
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
|
|
|||
72
tests/level.test.ts
Normal file
72
tests/level.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import type { Texture } from "../engine/render/Texture"
|
||||
import { buildLevel } from "../game/level"
|
||||
import type { Textures } from "../game/textures"
|
||||
|
||||
const texture: Texture = { width: 1, height: 1, data: new Uint32Array([0xFFFFFFFF]) }
|
||||
const textures: Textures = {
|
||||
floor: texture,
|
||||
grass: texture,
|
||||
bark: texture,
|
||||
birch: texture,
|
||||
leaf: texture,
|
||||
needle: texture,
|
||||
rock: texture,
|
||||
flower: texture,
|
||||
wall: texture,
|
||||
crate: texture,
|
||||
npc: texture,
|
||||
frog: texture,
|
||||
bee: texture,
|
||||
robin: texture,
|
||||
skybox: texture,
|
||||
}
|
||||
|
||||
test("game data compiles into a clone-safe engine render scene", () => {
|
||||
const level = buildLevel(textures)
|
||||
expect(level.actors).toHaveLength(60)
|
||||
expect(Object.isFrozen(level.actors)).toBe(true)
|
||||
expect(Object.isFrozen(level.render)).toBe(true)
|
||||
expect(Object.isFrozen(level.render.prototypes)).toBe(true)
|
||||
expect(level.render.prototypes).toHaveLength(3)
|
||||
expect(level.render.maxInstances).toBe(level.actors.length)
|
||||
expect(() => structuredClone(level.render)).not.toThrow()
|
||||
})
|
||||
|
||||
test("actor prototype bounds cover local geometry", () => {
|
||||
const level = buildLevel(textures)
|
||||
for (const prototype of level.render.prototypes) {
|
||||
let covered = true
|
||||
for (const group of prototype.groups) {
|
||||
const vertices = group.mesh.verts
|
||||
for (let i = 0; i < vertices.length; i += 5) {
|
||||
covered &&=
|
||||
Math.abs(vertices[i]) <= prototype.radius + 1e-6 &&
|
||||
vertices[i + 1] >= prototype.minY - 1e-6 &&
|
||||
vertices[i + 1] <= prototype.maxY + 1e-6 &&
|
||||
Math.abs(vertices[i + 2]) <= prototype.radius + 1e-6
|
||||
}
|
||||
}
|
||||
expect(covered).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("chunk bounds cover geometry in every LOD", () => {
|
||||
const level = buildLevel(textures)
|
||||
for (const chunk of level.render.chunks) {
|
||||
let covered = true
|
||||
for (const group of [...chunk.near, ...chunk.far]) {
|
||||
const vertices = group.mesh.verts
|
||||
for (let i = 0; i < vertices.length; i += 5) {
|
||||
covered &&=
|
||||
vertices[i] >= chunk.minX &&
|
||||
vertices[i] <= chunk.maxX &&
|
||||
vertices[i + 1] >= chunk.minY &&
|
||||
vertices[i + 1] <= chunk.maxY &&
|
||||
vertices[i + 2] >= chunk.minZ &&
|
||||
vertices[i + 2] <= chunk.maxZ
|
||||
}
|
||||
}
|
||||
expect(covered).toBe(true)
|
||||
}
|
||||
})
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { MOB_KINDS, Mob } from "../game/actors/Mob"
|
||||
|
||||
// The mob SAB packs a kind as its index in MOB_KINDS; the main thread and every
|
||||
// render worker must agree on that order. Freeze it here: appending a kind is fine,
|
||||
// but reordering or removing an existing one silently corrupts which mesh/texture a
|
||||
// worker draws.
|
||||
test("MOB_KINDS order is frozen (mob SAB ids)", () => {
|
||||
expect(MOB_KINDS).toEqual(["frog", "bee", "robin"])
|
||||
})
|
||||
|
||||
test("every kind resolves to a complete definition", () => {
|
||||
for (const kind of MOB_KINDS) {
|
||||
const d = Mob.def(kind)
|
||||
expect(d.name).toBe(kind)
|
||||
expect(typeof d.build).toBe("function")
|
||||
expect(typeof d.update).toBe("function")
|
||||
expect(d.boundingRadius).toBeGreaterThan(0)
|
||||
expect(d.bodyHeight).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
38
tests/render-protocol.test.ts
Normal file
38
tests/render-protocol.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { RenderProtocol } from "../engine/render/RenderProtocol"
|
||||
import type { RenderInstance } from "../engine/render/RenderScene"
|
||||
import type { Camera } from "../engine/scene/Camera"
|
||||
|
||||
test("render camera protocol owns its complete shared layout", () => {
|
||||
const input: Camera = { position: { x: 1, y: 2, z: 3 }, yaw: 4, pitch: 5, fov: 6 }
|
||||
const data = new Float64Array(RenderProtocol.CAMERA_LENGTH)
|
||||
|
||||
RenderProtocol.writeCamera(data, input, 7)
|
||||
const output = RenderProtocol.readCamera(data)
|
||||
|
||||
expect(output).toEqual({ camera: input, time: 7 })
|
||||
expect(RenderProtocol.VIEW_PROJECTION_LENGTH).toBe(16)
|
||||
})
|
||||
|
||||
test("render instance protocol round-trips scene-local prototype indexes", () => {
|
||||
const input: RenderInstance[] = [
|
||||
{ prototype: 2, x: 1.25, y: -2, z: 3.5, heading: 0.75, scale: 1.5 },
|
||||
{ prototype: 0, x: -4, y: 5.25, z: 6, heading: -0.5, scale: 0.625 },
|
||||
]
|
||||
const ids = new Int32Array(2)
|
||||
const transforms = new Float32Array(2 * RenderProtocol.TRANSFORM_FLOATS)
|
||||
const output: RenderInstance[] = []
|
||||
|
||||
const count = RenderProtocol.writeInstances(ids, transforms, input)
|
||||
RenderProtocol.readInstances(ids, transforms, count, output)
|
||||
|
||||
expect(output).toHaveLength(input.length)
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
expect(output[i].prototype).toBe(input[i].prototype)
|
||||
expect(output[i].x).toBeCloseTo(input[i].x)
|
||||
expect(output[i].y).toBeCloseTo(input[i].y)
|
||||
expect(output[i].z).toBeCloseTo(input[i].z)
|
||||
expect(output[i].heading).toBeCloseTo(input[i].heading)
|
||||
expect(output[i].scale).toBeCloseTo(input[i].scale)
|
||||
}
|
||||
})
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { Tree, TREE_KINDS } from "../game/actors/Tree"
|
||||
|
||||
// The chunk baker (level.ts) accumulates geometry into a mesh per material key and
|
||||
// only draws keys listed in MAT_ORDER. A tree species that declares a trunk/foliage
|
||||
// material outside that palette would bake geometry that is silently never drawn.
|
||||
// Freeze the palette here so a typo'd or unregistered material key fails a test.
|
||||
const MATERIAL_KEYS = new Set(["grass", "rock", "bark", "birch", "leaf", "needle", "flower"])
|
||||
|
||||
test("every tree species maps to known chunk materials", () => {
|
||||
for (const kind of TREE_KINDS) {
|
||||
const s = Tree.species(kind)
|
||||
expect(s.kind).toBe(kind)
|
||||
expect(typeof s.build).toBe("function")
|
||||
expect(MATERIAL_KEYS.has(s.trunk)).toBe(true)
|
||||
expect(MATERIAL_KEYS.has(s.foliage)).toBe(true)
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue