Spaces:
Running
Running
| // 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; | |
| } | |