feat: bees and frogs

This commit is contained in:
Dan Finch 2026-08-07 12:49:13 +02:00
parent 46e7070b6b
commit 16f205babe
13 changed files with 540 additions and 31 deletions

View file

@ -1,12 +1,19 @@
import { RenderConfig } from "../engine/render/RenderConfig"
import { Camera } from "../engine/scene/Camera"
import type { Mesh } from "../engine/scene/Mesh"
import { Mob } from "../engine/scene/Mob"
import type { Vec3 } from "../engine/math/Vec3"
import { loadTextures } from "./assets"
import { buildLevel } from "./level"
import { buildLevel, type Level } from "./level"
import { EYE_HEIGHT, Player } from "./player"
import { createRenderer } from "./renderer"
import { chunkFar, visibleChunks, type Scene } from "./renderScene"
import { chunkFar, visibleChunks, visibleMobs, type Scene } from "./renderScene"
const FOV = Math.PI / 3
/** How close (world units) a mob must be to the player to get a live collider.
* Mobs farther than this can't be touched this frame, so skip them -- keeps the
* per-frame collider list (and the player's collision loop) short. */
const MOB_COLLIDE_RANGE = 3
const screen = document.querySelector<HTMLCanvasElement>("#screen")!
const ctx = screen.getContext("2d")!
@ -40,12 +47,20 @@ function benchStats(a: number[]): { median: number; p95: number; max: number; me
async function main(): Promise<void> {
const textures = await loadTextures()
const level = buildLevel()
// Two canonical mob meshes, built once and shared by every instance (the sim
// supplies each mob's per-frame transform).
const frogMesh: Mesh = { verts: [], indices: [] }
const beeMesh: Mesh = { verts: [], indices: [] }
Mob.build("frog", frogMesh)
Mob.build("bee", beeMesh)
const scene: Scene = {
chunks: level.chunks,
floor: level.floor,
walls: level.walls,
crate: level.crate,
npc: { position: level.npcPosition, size: { x: 1.1, y: 1.5 } },
mobMesh: { frog: frogMesh, bee: beeMesh },
mobCount: level.mobs.length,
sky: level.sky,
textures,
}
@ -99,11 +114,13 @@ async function main(): Promise<void> {
globalThis.addEventListener("resize", layout)
if (benchMode) {
runBench(renderer, level.chunks, present, benchMode)
runBench(renderer, level, present, benchMode)
return
}
const player: Player = { position: { x: 0, y: 0, z: 8 }, yaw: 0, pitch: 0, velocityY: 0, onGround: true }
// Colliders past this index are the dynamic mob ones, rebuilt every frame.
const staticColliderCount = level.colliders.length
const keys = new Set<string>()
globalThis.addEventListener("keydown", (e) => {
keys.add(e.code)
@ -200,6 +217,10 @@ async function main(): Promise<void> {
presentMax = 0
vsyncMax = 0
}
for (const m of level.mobs) {
Mob.update(m, dt, level.terrain)
}
rebuildMobColliders(level, player.position, staticColliderCount)
Player.update(player, keys, dt, level)
const camera: Camera = {
position: { x: player.position.x, y: player.position.y + EYE_HEIGHT, z: player.position.z },
@ -209,9 +230,10 @@ async function main(): Promise<void> {
}
const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height)
const visible = visibleChunks(level.chunks, viewProj)
const mobDraws = visibleMobs(level.mobs, viewProj)
lastVisible = visible
lastCamera = camera
renderer.dispatch(camera, viewProj, visible, now / 1000)
renderer.dispatch(camera, viewProj, visible, now / 1000, mobDraws)
inFlight = true
if (renderer.done()) {
show()
@ -225,7 +247,7 @@ async function main(): Promise<void> {
* interval, then reports the distributions (exposed on `window.__BENCH__`). */
function runBench(
renderer: ReturnType<typeof createRenderer>,
chunks: Scene["chunks"],
level: Level,
present: () => void,
mode: string,
): void {
@ -281,10 +303,14 @@ function runBench(
return
}
}
for (const m of level.mobs) {
Mob.update(m, 1 / 60, level.terrain)
}
const camera = benchCamera(i / 60)
const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height)
const visible = visibleChunks(chunks, viewProj)
renderer.dispatch(camera, viewProj, visible, i / 60)
const visible = visibleChunks(level.chunks, viewProj)
const mobDraws = visibleMobs(level.mobs, viewProj)
renderer.dispatch(camera, viewProj, visible, i / 60, mobDraws)
inFlight = true
if (renderer.done() && record()) {
report()
@ -293,6 +319,31 @@ function runBench(
requestAnimationFrame(tick)
}
/** Rebuild the dynamic tail of `level.colliders`: keep the static prefix, then add
* a block/stand-on AABB for each mob near the player. Mobs move, so these can't be
* baked; frogs are `standable` (hop onto them), bees only block (no mid-air
* platform). Only mobs within `MOB_COLLIDE_RANGE` are added -- the rest can't be
* reached this frame anyway. */
function rebuildMobColliders(level: Level, playerPos: Vec3, staticCount: number): void {
level.colliders.length = staticCount
for (const m of level.mobs) {
const dx = m.position.x - playerPos.x
const dz = m.position.z - playerPos.z
if (dx * dx + dz * dz > MOB_COLLIDE_RANGE * MOB_COLLIDE_RANGE) {
continue
}
const half = Mob.boundingRadius(m.kind) * m.scale * 0.7
level.colliders.push({
minX: m.position.x - half,
maxX: m.position.x + half,
minZ: m.position.z - half,
maxZ: m.position.z + half,
top: m.position.y + Mob.bodyHeight(m.kind) * m.scale,
standable: m.kind === "frog",
})
}
}
main().catch((error) => {
console.error(error)
})