feat: robins

This commit is contained in:
Dan Finch 2026-08-07 13:37:30 +02:00
parent a4490f7a20
commit bd88d3cbd6
12 changed files with 159 additions and 54 deletions

View file

@ -10,6 +10,7 @@ import grassUrl from "../assets/grass.png"
import leafUrl from "../assets/leaf.png"
import needleUrl from "../assets/needle.png"
import npcUrl from "../assets/npc.png"
import robinUrl from "../assets/robin.png"
import rockUrl from "../assets/rock.png"
import wallUrl from "../assets/wall.png"
@ -27,11 +28,12 @@ export type Textures = {
npc: Texture
frog: Texture
bee: Texture
robin: Texture
}
/** Load every game texture up front. Call once before starting the loop. */
export async function loadTextures(): Promise<Textures> {
const [floor, grass, bark, birch, leaf, needle, rock, flower, wall, crate, npc, frog, bee] = await Promise.all([
const [floor, grass, bark, birch, leaf, needle, rock, flower, wall, crate, npc, frog, bee, robin] = await Promise.all([
loadTexture(floorUrl),
loadTexture(grassUrl),
loadTexture(barkUrl),
@ -45,8 +47,9 @@ export async function loadTextures(): Promise<Textures> {
loadTexture(npcUrl),
loadTexture(frogUrl),
loadTexture(beeUrl),
loadTexture(robinUrl),
])
return { floor, grass, bark, birch, leaf, needle, rock, flower, wall, crate, npc, frog, bee }
return { floor, grass, bark, birch, leaf, needle, rock, flower, wall, crate, npc, frog, bee, robin }
}
function loadTexture(url: string): Promise<Texture> {

View file

@ -125,6 +125,7 @@ const FLOWER_COLORS: FlowerColor[] = ["white", "red", "yellow"]
* frame (frustum-culled), not baked into the static chunks. */
const FROG_COUNT = 40
const BEE_COUNT = 30
const ROBIN_COUNT = 30
const MOB_SEED = 0x30B
const MOB_REACH = 0.5
@ -407,14 +408,14 @@ function placeFlowers(): Flower[] {
return flowers
}
/** Scatter frogs + bees across the grass (like the boulders), each at its home
* anchor with a random heading and size. No colliders here -- mobs move, so their
* block/stand-on AABBs are rebuilt per frame in `main`. */
/** Scatter frogs, bees + robins across the grass (like the boulders), each at its
* home anchor with a random heading and size. No colliders here -- mobs move, so
* their block/stand-on AABBs are rebuilt per frame in `main`. */
function placeMobs(): Mob[] {
const rand = mulberry(MOB_SEED)
const maxDist = TERRAIN.outer * MOB_REACH
const mobs: Mob[] = []
const total = FROG_COUNT + BEE_COUNT
const total = FROG_COUNT + BEE_COUNT + ROBIN_COUNT
for (let guard = 0; mobs.length < total && guard < total * 20; guard++) {
const angle = rand() * Math.PI * 2
const dist = ARENA + 3 + rand() * (maxDist - ARENA - 3)
@ -423,9 +424,10 @@ function placeMobs(): Mob[] {
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 2) {
continue
}
const kind: MobKind = mobs.length < FROG_COUNT ? "frog" : "bee"
const n = mobs.length
const kind: MobKind = n < FROG_COUNT ? "frog" : n < FROG_COUNT + BEE_COUNT ? "bee" : "robin"
const y = Terrain.height(TERRAIN, x, z)
const scale = kind === "frog" ? 0.5 + rand() * 0.35 : 0.5 + rand() * 0.3
const scale = kind === "frog" ? 0.5 + rand() * 0.35 : kind === "robin" ? 0.4 + rand() * 0.25 : 0.5 + rand() * 0.3
mobs.push({
kind,
home: { x, y, z },
@ -437,8 +439,10 @@ function placeMobs(): Mob[] {
vz: 0,
vy: 0,
timer: rand() * 1.5,
phase: rand() * 10,
grounded: true,
// Bees hover (never grounded) and use phase for the bob; frogs/robins start
// resting on the ground.
phase: kind === "bee" ? rand() * 10 : 0,
grounded: kind !== "bee",
})
}
return mobs

View file

@ -9,7 +9,8 @@ import { EYE_HEIGHT, Player } from "./player"
import { createRenderer } from "./renderer"
import { chunkFar, visibleChunks, visibleMobs, type Scene } from "./renderScene"
const FOV = Math.PI / 3
const FOV_DEGREES = 75
const FOV = (FOV_DEGREES * Math.PI) / 180
/** 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. */
@ -51,15 +52,17 @@ async function main(): Promise<void> {
// supplies each mob's per-frame transform).
const frogMesh: Mesh = { verts: [], indices: [] }
const beeMesh: Mesh = { verts: [], indices: [] }
const robinMesh: Mesh = { verts: [], indices: [] }
Mob.build("frog", frogMesh)
Mob.build("bee", beeMesh)
Mob.build("robin", robinMesh)
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 },
mobMesh: { frog: frogMesh, bee: beeMesh, robin: robinMesh },
mobCount: level.mobs.length,
sky: level.sky,
textures,
@ -321,9 +324,10 @@ function runBench(
/** 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. */
* baked. Only mobs on the ground are `standable` (hop onto a resting frog/perched
* robin); bees and airborne birds still block but never make a 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) {
@ -339,7 +343,7 @@ function rebuildMobColliders(level: Level, playerPos: Vec3, staticCount: number)
minZ: m.position.z - half,
maxZ: m.position.z + half,
top: m.position.y + Mob.bodyHeight(m.kind) * m.scale,
standable: m.kind === "frog",
standable: m.kind !== "bee" && m.grounded,
})
}
}

View file

@ -1,6 +1,6 @@
import type { Framebuffer } from "../engine/render/Framebuffer"
import type { RenderConfig } from "../engine/render/RenderConfig"
import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "./renderScene"
import { renderBand, MOB_FLOATS, MOB_KINDS, type MobDraw, type Scene } from "./renderScene"
/** One-time setup: shared framebuffer + control/param buffers, the (cloned)
* scene, this worker's row band, and its index into the per-worker times array. */
@ -61,7 +61,7 @@ ctx.addEventListener("message", (e) => {
const mobDraws: MobDraw[] = []
for (let i = 0; i < mobCount; i++) {
const o = i * MOB_FLOATS
mobDraws.push({ kind: mob[o] === 1 ? "bee" : "frog", x: mob[o + 1], y: mob[o + 2], z: mob[o + 3], heading: mob[o + 4], scale: mob[o + 5] })
mobDraws.push({ kind: MOB_KINDS[mob[o]] ?? "frog", x: mob[o + 1], y: mob[o + 2], z: mob[o + 3], heading: mob[o + 4], scale: mob[o + 5] })
}
renderBand(fb, scene, camera, vp, visible, mobDraws, config, skyStep, cam[6], band[0], band[1])
times[index] = performance.now() - t0

View file

@ -24,7 +24,7 @@ export type Scene = {
npc: { position: Vec3; size: Vec2 }
/** Canonical local-space mob meshes, one per kind, built once + shared by every
* instance (each instance differs only by its per-frame model matrix). */
mobMesh: { frog: Mesh; bee: Mesh }
mobMesh: Record<MobKind, Mesh>
/** How many mobs the sim has -- sizes the worker's shared transform buffer. */
mobCount: number
sky: SkyConfig
@ -36,7 +36,11 @@ export type Scene = {
* `renderBand` (single-thread) or packed into the shared `mobState` buffer and
* rebuilt in each worker. `MOB_FLOATS` is that packed layout's stride. */
export type MobDraw = { kind: MobKind; x: number; y: number; z: number; heading: number; scale: number }
export const MOB_FLOATS = 6 // kind(0/1), x, y, z, heading, scale
export const MOB_FLOATS = 6 // kind index, x, y, z, heading, scale
/** Canonical kind order -- the index packed into the shared `mobState` buffer
* (main packs `indexOf`, each worker reads it back). Keep frog/bee first so the
* existing indices don't shift. */
export const MOB_KINDS: MobKind[] = ["frog", "bee", "robin"]
/** Chunk indices whose bounding box is inside the view frustum. Computed once on
* the main thread and shared with every worker (so they don't each re-cull). */
@ -122,10 +126,8 @@ export function renderBand(
// own model matrix (viewProj x model). Drawn double-sided (cull off) -- they're
// small and few, so the winding-correct backface cull isn't worth the fuss.
for (const m of mobDraws) {
const mesh = m.kind === "bee" ? scene.mobMesh.bee : scene.mobMesh.frog
const texture = m.kind === "bee" ? tx.bee : tx.frog
const mvp = Mat4.multiply(viewProj, Mat4.compose(m.x, m.y, m.z, m.heading, m.scale))
Rasterizer.draw(fb, mesh, texture, mvp, config, false, y0, y1)
Rasterizer.draw(fb, scene.mobMesh[m.kind], tx[m.kind], mvp, config, false, y0, y1)
}
Framebuffer.quantize(fb, config, y0, y1)
}

View file

@ -2,7 +2,7 @@ import { Framebuffer } from "../engine/render/Framebuffer"
import type { RenderConfig } from "../engine/render/RenderConfig"
import type { Mat4 } from "../engine/math/Mat4"
import type { Camera } from "../engine/scene/Camera"
import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "./renderScene"
import { renderBand, MOB_FLOATS, MOB_KINDS, type MobDraw, type Scene } from "./renderScene"
/** Sky is drawn at 1/SKY_STEP resolution; band splits align to it. */
const SKY_STEP = 2
@ -150,7 +150,7 @@ export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers
for (let i = 0; i < mobCount; i++) {
const d = mobDraws[i]
const o = i * MOB_FLOATS
mob[o] = d.kind === "bee" ? 1 : 0
mob[o] = MOB_KINDS.indexOf(d.kind)
mob[o + 1] = d.x
mob[o + 2] = d.y
mob[o + 3] = d.z