feat: robins
This commit is contained in:
parent
a4490f7a20
commit
bd88d3cbd6
12 changed files with 159 additions and 54 deletions
19
AGENTS.md
19
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.
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
20
app/level.ts
20
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
|
||||
|
|
|
|||
16
app/main.ts
16
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<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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
assets/frog.png
BIN
assets/frog.png
Binary file not shown.
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
BIN
assets/robin.png
Normal file
BIN
assets/robin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3 KiB |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
* 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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue