// Inhabitants. What separates a world model from scenery is that something lives // in it and the terrain is the reason it lives where it does. // // Animals are spawned through the habitat rules in hydro.js, so their range comes // out of the same water and slope fields that shaped the vegetation. Once placed // they act on two drives — thirst and hunger — and the map answers both: water // where the drainage put it, forage where the moisture allowed it. Nobody is // scripted to walk to a particular spot. import { GRID, WORLD } from './world.js'; import { HABITAT, populate } from './hydro.js'; export const SPECIES = [ { id: 'grazer', label: 'Grazing herd', habitat: 'plain', weight: 30, scale: 1.0, herd: 7, speed: 2.4, color: '#b6a074', drinks: true, eats: 'plant' }, { id: 'browser', label: 'Forest browser', habitat: 'forest', weight: 18, scale: 1.3, herd: 3, speed: 1.8, color: '#8d7a52', drinks: true, eats: 'plant' }, { id: 'predator', label: 'Predator', habitat: 'plain', weight: 5, scale: 1.15, herd: 2, speed: 3.4, color: '#8a5b46', drinks: true, eats: 'meat' }, { id: 'wader', label: 'Wader', habitat: 'riverbank', weight: 12, scale: 0.55, herd: 5, speed: 1.6, color: '#d8d2c4', drinks: false, eats: 'fish' }, { id: 'fish', label: 'Fish', habitat: 'water', weight: 16, scale: 0.5, herd: 9, speed: 2.0, color: '#6f9fb5', drinks: false, eats: 'plant', aquatic: true }, { id: 'flyer', label: 'Cliff flyer', habitat: 'cliff', weight: 8, scale: 0.7, herd: 3, speed: 5.0, color: '#9a9a8c', drinks: false, eats: 'meat', flying: true }, { id: 'climber', label: 'Highland herd', habitat: 'highland', weight: 11, scale: 0.9, herd: 4, speed: 2.0, color: '#a8a091', drinks: true, eats: 'plant' }, ]; const idx = (x, y) => y * GRID + x; /** Sample the world at a world-space point; the shared lookup for every drive. */ export function probe(world, x, z) { const step = WORLD / (GRID - 1); const gx = Math.min(GRID - 1, Math.max(0, Math.round((x + WORLD / 2) / step))); const gy = Math.min(GRID - 1, Math.max(0, Math.round((z + WORLD / 2) / step))); const i = idx(gx, gy); const dx = world.height[idx(Math.min(GRID - 1, gx + 1), gy)] - world.height[idx(Math.max(0, gx - 1), gy)]; const dy = world.height[idx(gx, Math.min(GRID - 1, gy + 1))] - world.height[idx(gx, Math.max(0, gy - 1))]; return { gx, gy, i, height: world.height[i], slope: Math.hypot(dx, dy) / (step * 2), moisture: world.moisture[i], waterDepth: world.water.depth[i], waterSurface: world.water.surface[i], region: world.regions[world.owner[i]], }; } /** Does this species tolerate standing here? Same predicate that placed it. */ function habitable(sp, p) { const rule = HABITAT[sp.habitat] || HABITAT.plain; return p.waterDepth >= rule.water[0] && p.waterDepth <= rule.water[1] && p.slope >= rule.slope[0] && p.slope <= rule.slope[1] && p.height >= rule.height[0] && p.height <= rule.height[1] && p.moisture >= rule.moist[0] && p.moisture <= rule.moist[1]; } /** * Spawn the cast and give each individual its own drives. Herd members keep a * home point so a herd stays a herd instead of dissolving into a random walk. */ export function spawn(world, rng, budget = 150) { const raw = populate(world, SPECIES, rng, { worldSize: WORLD, budget }); const byId = new Map(SPECIES.map(s => [s.id, s])); return raw.map(a => { const sp = byId.get(a.id); return { ...a, sp, vx: 0, vz: 0, homeX: a.x, homeZ: a.z, thirst: rng() * 0.6, phase: rng() * Math.PI * 2, wanderT: rng() * 4, targetX: a.x, targetZ: a.z, }; }); } /** * One tick. Thirsty animals that drink head for the nearest water they can find; * everyone else drifts around their home range. Movement is refused where the * habitat test fails, which is what keeps grazers off cliffs and fish in rivers * without a single hand-placed boundary. */ export function step(world, agents, dt, waterPoints) { const half = WORLD / 2 - 3; for (const a of agents) { const sp = a.sp; a.thirst = Math.min(1.6, a.thirst + dt * 0.045); a.wanderT -= dt; if (sp.drinks && a.thirst > 1 && waterPoints.length) { // nearest water: the drainage decided where that is let best = null, bestD = Infinity; for (const w of waterPoints) { const d = (w.x - a.x) ** 2 + (w.z - a.z) ** 2; if (d < bestD) { bestD = d; best = w; } } if (best) { a.targetX = best.x; a.targetZ = best.z; if (bestD < 9) { a.thirst = 0; a.wanderT = 0; } } } else if (a.wanderT <= 0) { const r = sp.herd ? 9 : 16; a.targetX = a.homeX + (Math.random() - 0.5) * r * 2; a.targetZ = a.homeZ + (Math.random() - 0.5) * r * 2; a.wanderT = 3 + Math.random() * 5; } const dx = a.targetX - a.x, dz = a.targetZ - a.z; const dist = Math.hypot(dx, dz) || 1; const speed = sp.speed * (a.thirst > 1 ? 1.35 : 1); const nx = a.x + (dx / dist) * speed * dt; const nz = a.z + (dz / dist) * speed * dt; const p = probe(world, nx, nz); if (Math.abs(nx) < half && Math.abs(nz) < half && (sp.flying || habitable(sp, p))) { a.x = nx; a.z = nz; a.rot = Math.atan2(dx, dz); a.y = sp.aquatic ? p.waterSurface - 0.35 - Math.sin(a.phase) * 0.15 : sp.flying ? p.height + 6 + Math.sin(a.phase * 0.7) * 1.5 : Math.max(p.height, p.waterDepth > 0 ? p.waterSurface : p.height); } else { a.wanderT = 0; // blocked: pick somewhere else next tick } a.phase += dt * (sp.flying ? 2.2 : 3.2); } } /** * A body in a few parts. One squashed ellipsoid reads as a pebble from any * distance — it is the legs holding it off the ground and the head sticking out * in front that make the eye call it an animal, and both are cheap because every * part is one more instanced mesh sharing the same transforms. */ export function bodyParts(THREE, sp) { if (sp.flying) { const wings = new THREE.ConeGeometry(0.62, 1.9, 4); wings.rotateX(Math.PI / 2); return [{ geo: wings, color: sp.color }]; } if (sp.aquatic) { const body = new THREE.ConeGeometry(0.3, 1.4, 5); body.rotateX(-Math.PI / 2); const tail = new THREE.ConeGeometry(0.26, 0.5, 4); tail.rotateX(Math.PI / 2); tail.translate(0, 0, 0.85); return [{ geo: body, color: sp.color }, { geo: tail, color: sp.color }]; } const body = new THREE.SphereGeometry(0.55, 8, 6); body.scale(0.78, 0.74, 1.35); body.translate(0, 0.95, 0); const head = new THREE.SphereGeometry(0.27, 7, 5); head.scale(0.9, 0.9, 1.2); head.translate(0, 1.24, 0.82); // one block for all four legs: at this size separate cylinders are invisible // detail, but the gap under the body is what sells the silhouette const legs = new THREE.BoxGeometry(0.62, 0.62, 0.9); legs.translate(0, 0.33, 0); const dark = '#4a3a2c'; return [ { geo: body, color: sp.color }, { geo: head, color: sp.color }, { geo: legs, color: dark }, ]; } /** Where drinking animals can reach water — sampled once per world, not per tick. */ export function waterAccess(world, limit = 260) { const pts = []; const step = WORLD / (GRID - 1); for (let y = 1; y < GRID - 1; y += 2) { for (let x = 1; x < GRID - 1; x += 2) { const i = idx(x, y); if (world.water.depth[i] < 0.05) continue; // only the shoreline is drinkable; the middle of a lake is not a place // a land animal can stand const edge = [idx(x + 1, y), idx(x - 1, y), idx(x, y + 1), idx(x, y - 1)] .some(j => world.water.depth[j] < 0.02); if (!edge) continue; pts.push({ x: -WORLD / 2 + x * step, z: -WORLD / 2 + y * step }); if (pts.length >= limit) return pts; } } return pts; }