WorldForge / life.js
openfree's picture
WorldForge: prompt-driven region plan, semantic layout map and composite height field
d9a31a1 verified
Raw
History Blame Contribute Delete
8.4 kB
// 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;
}