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 158d154..d0d26b8 100644 Binary files a/assets/frog.png and b/assets/frog.png differ diff --git a/assets/robin.png b/assets/robin.png new file mode 100644 index 0000000..220f732 Binary files /dev/null and b/assets/robin.png differ diff --git a/engine/render/RenderConfig.ts b/engine/render/RenderConfig.ts index ed147f9..6ae3df4 100644 --- a/engine/render/RenderConfig.ts +++ b/engine/render/RenderConfig.ts @@ -57,8 +57,8 @@ export type RenderConfig = { * color depth, dither, vertex snap, and filtering from crunchy PS1 to clean. */ export namespace RenderConfig { export const standard: RenderConfig = { - internalWidth: 384, - internalHeight: 216, + internalWidth: 640, + internalHeight: 360, upscaleFilter: "nearest", colorDepth: 5, dither: 1, diff --git a/engine/scene/Mob.ts b/engine/scene/Mob.ts index c76a378..db7acd4 100644 --- a/engine/scene/Mob.ts +++ b/engine/scene/Mob.ts @@ -5,23 +5,25 @@ import { STRIDE, type Mesh } from "./Mesh" const TAU = Math.PI * 2 /** A roaming creature drawn as a moving low-poly mesh (unlike the static baked - * world). Two kinds, told apart by silhouette + motion: - * frog -- squat, ground-bound, sits then springs a ballistic hop. - * bee -- small, hovers and darts through the air, wings out. + * world). Three kinds, told apart by silhouette + motion: + * frog -- squat, ground-bound, sits then springs a ballistic hop. + * bee -- small, hovers and darts through the air, wings out. + * robin -- round red-breasted bird; mostly hops like a frog, but now and then + * takes off on a short powered flight to a new perch. * * Unlike `Tree`/`Boulder` (baked once into world-space chunks), a mob's geometry - * is a **canonical local-space mesh** built once per kind (front = +Z, frog feet - * / bee body at the origin); the live `position`/`heading`/`scale` are turned + * is a **canonical local-space mesh** built once per kind (front = +Z, frog/robin + * feet / bee body at the origin); the live `position`/`heading`/`scale` are turned * into a per-frame model matrix by the renderer. All wander state lives here so * `update` is a pure stepping function of the mob + dt (deterministic via the * evolving `seed`), which keeps the sim on the main thread and cloneable-free. */ -export type MobKind = "frog" | "bee" +export type MobKind = "frog" | "bee" | "robin" export type Mob = { kind: MobKind /** Leash anchor (where it was scattered); wandering is pulled back toward it. */ home: Vec3 - /** Live feet-center (frog) / body-center (bee), advanced each frame. */ + /** Live feet-center (frog/robin) / body-center (bee), advanced each frame. */ position: Vec3 /** Facing yaw; the mesh's front is local +Z, so world dir = (sin h, 0, cos h). */ heading: number @@ -29,16 +31,17 @@ export type Mob = { scale: number /** Evolving RNG state (mutated by `update`) -- keeps the sim deterministic. */ seed: number - /** Horizontal velocity (frog: only mid-hop; bee: cruise). */ + /** Horizontal velocity (frog/robin: mid-hop or -flight; bee: cruise). */ vx: number vz: number - /** Vertical velocity (frog ballistic hop; bee stays 0, it uses a bob). */ + /** Vertical velocity (frog/robin ballistic hop/flight; bee stays 0, uses a bob). */ vy: number - /** Countdown to the next decision (frog: next hop; bee: next heading change). */ + /** Countdown to the next decision (frog/robin: next hop; bee: next heading change). */ timer: number - /** Accumulated time, for the bee's hover bob. */ + /** Per-kind scratch clock: the bee's hover-bob phase; the robin's remaining + * powered-flight cruise time (>0 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) {