From bd88d3cbd64ecdbfe43b925ea7e915f0cea2026f Mon Sep 17 00:00:00 2001 From: Errilaz Date: Fri, 7 Aug 2026 13:37:30 +0200 Subject: [PATCH] feat: robins --- AGENTS.md | 19 +++--- app/assets.ts | 7 ++- app/level.ts | 20 ++++--- app/main.ts | 16 +++-- app/render-worker.ts | 4 +- app/renderScene.ts | 12 ++-- app/renderer.ts | 4 +- assets/frog.png | Bin 4039 -> 3993 bytes assets/robin.png | Bin 0 -> 3033 bytes engine/render/RenderConfig.ts | 4 +- engine/scene/Mob.ts | 106 +++++++++++++++++++++++++++++----- scripts/gen-assets.ts | 21 ++++++- 12 files changed, 159 insertions(+), 54 deletions(-) create mode 100644 assets/robin.png diff --git a/AGENTS.md b/AGENTS.md index 3d4da6b..9670e26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,12 +78,13 @@ rules live in `.agents/rules/*.md`. `Boulder.build` appends into a shared mesh), `Bush` (cluster of small leaf blobs, shares the oak leaf texture/mesh), `Flower` (thin stem + colored bloom; samples a 2x2 color-atlas texture, drawn double-sided), `Mob` (a **roaming** - creature — `frog` hops the ground, `bee` hovers/darts — the engine's only - moving geometry. Unlike the baked props, a mob's low-poly mesh is built once - per kind in **local space**; `Mob.build` bakes the two canonical meshes, - `Mob.update` steps the wander AI (leashed to a home anchor, deterministic via - an evolving per-mob seed) each frame, and the live `position`/`heading`/`scale` - become a per-frame model matrix at draw time). + creature — `frog` hops the ground, `bee` hovers/darts, `robin` mostly hops but + now and then takes a short powered flight — the engine's only moving geometry. + Unlike the baked props, a mob's low-poly mesh is built once per kind in **local + space**; `Mob.build` bakes the canonical meshes, `Mob.update` steps the wander + AI (leashed to a home anchor, deterministic via an evolving per-mob seed) each + frame, and the live `position`/`heading`/`scale` become a per-frame model matrix + at draw time. New kinds extend the `MobKind` union + `MOB_KINDS` order). - `app/` — browser glue. - `main.ts` — game loop: input, sim, preset switching, per-frame culling, then the non-blocking pump (`renderer.dispatch`/`done`) + `present` (GPU/CSS @@ -134,11 +135,11 @@ rules live in `.agents/rules/*.md`. `#fps` meter div (styled inline). - `scripts/gen-assets.ts` — procedurally draws the placeholder textures and writes PNGs (hand-rolled encoder via `node:zlib`). Run via `bun run assets`. -- `assets/` — generated `floor/grass/bark/birch/leaf/needle/rock/flower/wall/crate/npc/frog/bee` +- `assets/` — generated `floor/grass/bark/birch/leaf/needle/rock/flower/wall/crate/npc/frog/bee/robin` PNGs (`floor` = room stone, `grass` = outdoor ground, `bark`/`birch`/`leaf`/`needle` = brown trunk / white birch trunk / oak leaf / spruce needle, `rock` = boulders, `flower` = 2x2 bloom-color atlas, - `frog`/`bee` = mob skin atlases: frog green + eye tone; bee stripe bands + - head-dark + wing-pale regions). + `frog`/`bee`/`robin` = mob skin atlases: frog green + eye tone; bee stripe bands + + head-dark + wing-pale; robin brown back + orange breast + dark eye/beak). Swap for real art anytime; filenames are the contract. - `server/` — Bun server stub. `shared/` — isomorphic slot. diff --git a/app/assets.ts b/app/assets.ts index 62ab9a7..9e67e17 100644 --- a/app/assets.ts +++ b/app/assets.ts @@ -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 { - 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 { 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 { diff --git a/app/level.ts b/app/level.ts index 97cd1aa..d0592a5 100644 --- a/app/level.ts +++ b/app/level.ts @@ -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 diff --git a/app/main.ts b/app/main.ts index ae975fa..b83a0c3 100644 --- a/app/main.ts +++ b/app/main.ts @@ -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 { // 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, }) } } diff --git a/app/render-worker.ts b/app/render-worker.ts index a243729..0a490db 100644 --- a/app/render-worker.ts +++ b/app/render-worker.ts @@ -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 diff --git a/app/renderScene.ts b/app/renderScene.ts index 003816f..df92903 100644 --- a/app/renderScene.ts +++ b/app/renderScene.ts @@ -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 /** 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) } diff --git a/app/renderer.ts b/app/renderer.ts index 1c80f1a..fdc2c9b 100644 --- a/app/renderer.ts +++ b/app/renderer.ts @@ -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 diff --git a/assets/frog.png b/assets/frog.png index 158d154d50d9fa9ad91d9380718cb96a4395a22a..d0d26b82638e55cfee4ceab7d6f4cbc3fddf4bd7 100644 GIT binary patch literal 3993 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 literal 4039 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 while gliding between perches). */ phase: number - /** Frog only: resting on the ground vs airborne in a hop. */ + /** Frog/robin: resting on the ground vs airborne (a hop or a flight). */ grounded: boolean } @@ -56,14 +59,27 @@ const BEE_TURN_SPAN = 1 const BEE_HOVER = 1.1 const BEE_BOB_AMP = 0.18 const BEE_BOB_FREQ = 3 +const ROBIN_LEASH = 6 +const ROBIN_REST_MIN = 0.5 +const ROBIN_REST_SPAN = 1.3 +const ROBIN_HOP_SPEED = 1.4 +const ROBIN_HOP_IMPULSE = 2.6 +/** Fraction of a robin's moves that are a flight rather than a ground hop. */ +const ROBIN_FLY_CHANCE = 0.35 +const ROBIN_FLY_SPEED = 4.5 +const ROBIN_FLY_IMPULSE = 3.5 +const ROBIN_CRUISE = 0.8 +const ROBIN_GRAVITY = 14 export namespace Mob { /** Advance one mob by `dt` seconds, sampling `terrain` for ground height. */ export function update(mob: Mob, dt: number, terrain: Terrain): void { if (mob.kind === "frog") { frog(mob, dt, terrain) - } else { + } else if (mob.kind === "bee") { bee(mob, dt, terrain) + } else { + robin(mob, dt, terrain) } } @@ -72,19 +88,21 @@ export namespace Mob { export function build(kind: MobKind, mesh: Mesh): void { if (kind === "frog") { buildFrog(mesh) - } else { + } else if (kind === "bee") { buildBee(mesh) + } else { + buildRobin(mesh) } } /** Local bounding radius (pre-scale), for building the per-frame cull AABB. */ export function boundingRadius(kind: MobKind): number { - return kind === "frog" ? 0.7 : 0.5 + return kind === "frog" ? 0.7 : kind === "robin" ? 0.45 : 0.5 } /** Local body height (pre-scale), for the top of the stand-on collider. */ export function bodyHeight(kind: MobKind): number { - return kind === "frog" ? 0.6 : 0.5 + return kind === "frog" ? 0.6 : kind === "robin" ? 0.55 : 0.5 } // --- Simulation --------------------------------------------------------- @@ -133,6 +151,49 @@ export namespace Mob { mob.position.y = ground + BEE_HOVER + Math.sin(mob.phase * BEE_BOB_FREQ) * BEE_BOB_AMP } + function robin(mob: Mob, dt: number, terrain: Terrain): void { + if (mob.grounded) { + mob.timer -= dt + mob.position.y = Terrain.height(terrain, mob.position.x, mob.position.z) + if (mob.timer > 0) { + return + } + // Decide the next move: usually a short ground hop, sometimes a longer + // powered flight -- higher + faster off the mark, then a flat glide (see + // the cruise branch below) before settling onto a new perch. + mob.heading = wanderHeading(mob, ROBIN_LEASH, 1) + const fly = nextRand(mob) < ROBIN_FLY_CHANCE + const speed = fly ? ROBIN_FLY_SPEED : ROBIN_HOP_SPEED + mob.vx = Math.sin(mob.heading) * speed + mob.vz = Math.cos(mob.heading) * speed + mob.vy = fly ? ROBIN_FLY_IMPULSE : ROBIN_HOP_IMPULSE + mob.phase = fly ? ROBIN_CRUISE : 0 + mob.grounded = false + return + } + if (mob.phase > 0) { + // In flight: bleed vertical speed toward level so it glides roughly flat + // (a bird crossing the clearing), not a lob; gravity resumes once cruise ends. + mob.phase -= dt + mob.vy += (0 - mob.vy) * Math.min(1, dt * 6) + } else { + mob.vy -= ROBIN_GRAVITY * dt + } + mob.position.x += mob.vx * dt + mob.position.y += mob.vy * dt + mob.position.z += mob.vz * dt + const ground = Terrain.height(terrain, mob.position.x, mob.position.z) + if (mob.position.y <= ground && mob.vy < 0) { + mob.position.y = ground + mob.vx = 0 + mob.vy = 0 + mob.vz = 0 + mob.phase = 0 + mob.grounded = true + mob.timer = ROBIN_REST_MIN + nextRand(mob) * ROBIN_REST_SPAN + } + } + /** A new heading: free wander when inside the leash, else biased back toward * home so the mob never drifts off into the peaks (`jitter` = the random cone * half-width in radians layered on top of the homeward bearing). */ @@ -179,6 +240,19 @@ export namespace Mob { wing(mesh, -1, 0.83, 0.99, 0, 1) } + function buildRobin(mesh: Mesh): void { + // Round European robin: plump brown body, an orange-red breast bulging on the + // front, a round brown head with two dark eyes + a small dark beak, short tail. + // UVs: robin texture is brown (left), orange breast (mid), dark eye/beak (right). + ellipsoid(mesh, 0, 0.26, 0, 0.26, 0.26, 0.3, 6, 4, 0, 0.38, 0, 1) // body (brown) + ellipsoid(mesh, 0, 0.18, 0.17, 0.22, 0.22, 0.16, 5, 4, 0.42, 0.68, 0, 1) // breast (orange) + ellipsoid(mesh, 0, 0.48, 0.14, 0.18, 0.18, 0.18, 5, 4, 0, 0.38, 0, 1) // head (brown) + ellipsoid(mesh, 0.09, 0.52, 0.26, 0.03, 0.03, 0.03, 3, 2, 0.85, 0.99, 0, 1) // eye + ellipsoid(mesh, -0.09, 0.52, 0.26, 0.03, 0.03, 0.03, 3, 2, 0.85, 0.99, 0, 1) // eye + ellipsoid(mesh, 0, 0.47, 0.35, 0.03, 0.025, 0.09, 3, 2, 0.85, 0.99, 0, 1) // beak (dark) + ellipsoid(mesh, 0, 0.26, -0.32, 0.09, 0.05, 0.16, 4, 2, 0, 0.38, 0, 1) // tail (brown) + } + /** A UV-rected ellipsoid (pole on Y), faceted like the boulders. */ function ellipsoid( mesh: Mesh, diff --git a/scripts/gen-assets.ts b/scripts/gen-assets.ts index b0353bb..c58853d 100644 --- a/scripts/gen-assets.ts +++ b/scripts/gen-assets.ts @@ -212,9 +212,9 @@ const frog: Shade = (x, y) => { const n = noise(x, y) * 10 if (x < 34) { const belly = (y / 48) * 28 - return [66 + n, 120 + belly + n, 60 + n, 255] + return [80 + n, 140 + belly + n, 80 + n, 255] } - return [26 + n, 42 + n, 30 + n, 255] + return [46 + n, 72 + n, 50 + n, 255] } // Bee atlas: yellow/black stripe bands down the left (u<0.54, banded by y so the @@ -231,6 +231,22 @@ const bee: Shade = (x, y) => { return [228 + n, 238 + n, 248 + n, 255] } +// Robin atlas: warm brown back/head/tail (left), orange-red breast (mid), a spare +// pale band, and a dark eye/beak tone (right) -- picked per body part by its UVs. +const robin: Shade = (x, y) => { + const n = noise(x, y) * 10 + if (x < 19) { + return [120 + n, 92 + n, 58 + n, 255] + } + if (x < 34) { + return [214 + n, 98 + n, 48 + n, 255] + } + if (x < 41) { + return [226 + n, 224 + n, 216 + n, 255] + } + return [34 + n, 28 + n, 26 + n, 255] +} + // 48x64, transparent background, a simple round-topped figure with eyes. const npc: Shade = (x, y) => { const dx = (x - 24) / 17 @@ -271,6 +287,7 @@ const assets: Array<[string, number, number, Shade]> = [ ["npc", 48, 64, npc], ["frog", 48, 48, frog], ["bee", 48, 48, bee], + ["robin", 48, 48, robin], ] for (const [name, w, h, shade] of assets) {