Spaces:
Running
Running
| import * as THREE from 'three'; | |
| import { OrbitControls } from './vendor/OrbitControls.js'; | |
| import { GLTFExporter } from './vendor/GLTFExporter.js'; | |
| import { GRID, WORLD, synthesize } from './world.js'; | |
| import { SPECIES, spawn, step as stepLife, bodyParts, waterAccess, probe } from './life.js'; | |
| import { Sky, climateOf } from './sky.js'; | |
| const canvas = document.getElementById('view'); | |
| // preserveDrawingBuffer keeps the last frame readable, so the view can be captured. | |
| const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true }); | |
| renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); | |
| renderer.outputColorSpace = THREE.SRGBColorSpace; | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color('#9fc4dc'); | |
| scene.fog = new THREE.Fog('#9fc4dc', WORLD * 0.7, WORLD * 2.0); | |
| const camera = new THREE.PerspectiveCamera(55, 1, 0.5, WORLD * 4); | |
| camera.position.set(WORLD * 0.45, WORLD * 0.34, WORLD * 0.45); | |
| const controls = new OrbitControls(camera, canvas); | |
| controls.enableDamping = true; | |
| controls.maxPolarAngle = Math.PI * 0.495; | |
| controls.target.set(0, 6, 0); | |
| const sky = new Sky(scene); | |
| let dayTime = 0.42; // 0..1; 0.5 is noon | |
| let dayRunning = false; | |
| const world = new THREE.Group(); | |
| scene.add(world); | |
| // Inhabitants live in their own group so a rebuild does not disturb the terrain. | |
| const fauna = new THREE.Group(); | |
| scene.add(fauna); | |
| let agents = [], waterPts = [], faunaMeshes = []; | |
| // ------------------------------------------------------------------ prop kit -- | |
| // Deliberately low-poly: these are placement stand-ins for generated assets, and a | |
| // thousand of them have to stay interactive in a browser tab. | |
| const PROPS = { | |
| tree: { trunk: [0.28, 0.35, 3.2], trunkColor: '#584028', crown: 'sphere', crownSize: 2.1, crownColor: '#3f6b34', scale: [0.7, 1.5] }, | |
| pine: { trunk: [0.22, 0.3, 3.6], trunkColor: '#4a3826', crown: 'cone', crownSize: 2.3, crownColor: '#2f5230', scale: [0.7, 1.6] }, | |
| palm: { trunk: [0.2, 0.26, 4.6], trunkColor: '#6b563a', crown: 'cone', crownSize: 2.0, crownColor: '#4a7c3a', scale: [0.8, 1.3] }, | |
| acacia: { trunk: [0.3, 0.4, 2.8], trunkColor: '#5c4a30', crown: 'disc', crownSize: 3.0, crownColor: '#6b7a3a', scale: [0.8, 1.4] }, | |
| cactus: { trunk: [0.42, 0.42, 2.6], trunkColor: '#4a7042', crown: 'none', crownSize: 0, crownColor: '#4a7042', scale: [0.6, 1.3] }, | |
| shrub: { trunk: null, trunkColor: '#000', crown: 'sphere', crownSize: 1.0, crownColor: '#55703a', scale: [0.6, 1.4] }, | |
| rock: { trunk: null, trunkColor: '#000', crown: 'rock', crownSize: 1.2, crownColor: '#7d7a72', scale: [0.5, 2.0] }, | |
| }; | |
| function crownGeometry(kind, size) { | |
| switch (kind) { | |
| case 'cone': return new THREE.ConeGeometry(size * 0.62, size * 1.9, 7); | |
| case 'disc': return new THREE.SphereGeometry(size * 0.62, 8, 5).scale(1, 0.42, 1); | |
| case 'rock': return new THREE.IcosahedronGeometry(size * 0.6, 0); | |
| case 'sphere': | |
| default: return new THREE.SphereGeometry(size * 0.55, 8, 6); | |
| } | |
| } | |
| // --------------------------------------------------------------- world build -- | |
| let current = null; | |
| function heightAt(field, gx, gy) { | |
| const x = Math.min(GRID - 1, Math.max(0, gx)); | |
| const y = Math.min(GRID - 1, Math.max(0, gy)); | |
| return field[y * GRID + x]; | |
| } | |
| function build(prompt, seed) { | |
| const t0 = performance.now(); | |
| const data = synthesize(prompt, seed); | |
| current = data; | |
| while (world.children.length) { | |
| const c = world.children.pop(); | |
| c.traverse?.(o => { o.geometry?.dispose(); o.material?.dispose?.(); }); | |
| } | |
| const { regions, masks, height, seaLevel, owner } = data; | |
| const step = WORLD / (GRID - 1); | |
| // --- terrain mesh, vertex-coloured from the same masks that shaped the height | |
| const geo = new THREE.PlaneGeometry(WORLD, WORLD, GRID - 1, GRID - 1); | |
| geo.rotateX(-Math.PI / 2); | |
| const pos = geo.attributes.position; | |
| const colors = new Float32Array(pos.count * 3); | |
| const col = new THREE.Color(); | |
| const regColors = regions.map(r => new THREE.Color(r.color)); | |
| const rockColor = new THREE.Color('#6f6b64'); | |
| const snowColor = new THREE.Color('#e8eef2'); | |
| let maxH = -Infinity; | |
| for (let i = 0; i < height.length; i++) maxH = Math.max(maxH, height[i]); | |
| for (let i = 0; i < pos.count; i++) { | |
| const gx = i % GRID, gy = Math.floor(i / GRID); | |
| const h = height[gy * GRID + gx]; | |
| pos.setY(i, h); | |
| // Color has no addScaledVector (that is Vector3), so blend the components. | |
| let cr = 0, cg = 0, cb = 0; | |
| for (let r = 0; r < regions.length; r++) { | |
| const m = masks[r][gy * GRID + gx]; | |
| if (m > 0.002) { | |
| const rc = regColors[r]; | |
| cr += rc.r * m; cg += rc.g * m; cb += rc.b * m; | |
| } | |
| } | |
| col.setRGB(cr, cg, cb); | |
| // slope from neighbouring samples -> exposed rock on steep faces | |
| const dx = heightAt(height, gx + 1, gy) - heightAt(height, gx - 1, gy); | |
| const dy = heightAt(height, gx, gy + 1) - heightAt(height, gx, gy - 1); | |
| const slope = Math.min(1, Math.sqrt(dx * dx + dy * dy) / (step * 3.4)); | |
| col.lerp(rockColor, slope * 0.75); | |
| // snow line, only on worlds tall enough to have one | |
| if (maxH > 26) { | |
| const t = Math.min(1, Math.max(0, (h - maxH * 0.72) / (maxH * 0.28))); | |
| col.lerp(snowColor, t * (1 - slope * 0.5) * 0.9); | |
| } | |
| colors[i * 3] = col.r; colors[i * 3 + 1] = col.g; colors[i * 3 + 2] = col.b; | |
| } | |
| geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); | |
| geo.computeVertexNormals(); | |
| const terrain = new THREE.Mesh( | |
| geo, | |
| new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.95, metalness: 0.0, flatShading: false }) | |
| ); | |
| terrain.name = 'terrain'; | |
| world.add(terrain); | |
| // --- water: built from the hydrology, so rivers show up as rivers rather | |
| // than a single flat sheet at sea level. | |
| const wdepth = data.water.depth, wsurf = data.water.surface; | |
| const wv = [], wc = []; | |
| const shallow = new THREE.Color('#5fa8bd'), deepC = new THREE.Color('#1d4f70'); | |
| const push = (gx, gy) => { | |
| const i = gy * GRID + gx; | |
| wv.push(-WORLD / 2 + gx * step, wsurf[i], -WORLD / 2 + gy * step); | |
| const t = Math.min(1, wdepth[i] / 6); | |
| const c = shallow.clone().lerp(deepC, t); | |
| wc.push(c.r, c.g, c.b); | |
| }; | |
| for (let y = 0; y < GRID - 1; y++) { | |
| for (let x = 0; x < GRID - 1; x++) { | |
| const quad = [[x, y], [x + 1, y], [x + 1, y + 1], [x, y + 1]]; | |
| if (!quad.every(([qx, qy]) => wdepth[qy * GRID + qx] > 0.02)) continue; | |
| push(x, y); push(x + 1, y); push(x + 1, y + 1); | |
| push(x, y); push(x + 1, y + 1); push(x, y + 1); | |
| } | |
| } | |
| if (wv.length) { | |
| const wg = new THREE.BufferGeometry(); | |
| wg.setAttribute('position', new THREE.Float32BufferAttribute(wv, 3)); | |
| wg.setAttribute('color', new THREE.Float32BufferAttribute(wc, 3)); | |
| wg.computeVertexNormals(); | |
| const water = new THREE.Mesh(wg, new THREE.MeshStandardMaterial({ | |
| vertexColors: true, transparent: true, opacity: 0.82, | |
| roughness: 0.14, metalness: 0.3, side: THREE.DoubleSide, | |
| })); | |
| water.name = 'water'; | |
| world.add(water); | |
| } | |
| // --- scatter, gated on the same rules the paper uses: region semantics, | |
| // elevation and slope, with orientation following the surface. | |
| const buckets = {}; | |
| const rng = (() => { let s = (seed * 2654435761) >>> 0 || 7; | |
| return () => { s ^= s << 13; s >>>= 0; s ^= s >> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; }; })(); | |
| const attempts = 18000; | |
| const capacity = 3200; | |
| let placed = 0; | |
| for (let a = 0; a < attempts && placed < capacity; a++) { | |
| const gx = Math.floor(rng() * GRID), gy = Math.floor(rng() * GRID); | |
| const reg = regions[owner[gy * GRID + gx]]; | |
| if (!reg.props.length) continue; | |
| const cell = gy * GRID + gx; | |
| const h = height[cell]; | |
| if (h < seaLevel + 0.35) continue; | |
| if (data.water.depth[cell] > 0.02) continue; // nothing grows mid-river | |
| const dx = heightAt(height, gx + 1, gy) - heightAt(height, gx - 1, gy); | |
| const dy = heightAt(height, gx, gy + 1) - heightAt(height, gx, gy - 1); | |
| const slope = Math.sqrt(dx * dx + dy * dy) / (step * 2); | |
| const kind = reg.props[Math.floor(rng() * reg.props.length)]; | |
| if (kind !== 'rock' && slope > 0.85) continue; // only rock clings to cliffs | |
| // Moisture gates the planting: greenery crowds the riverbanks and thins | |
| // out away from water, while cactus wants the opposite. This is the whole | |
| // point of deriving water before vegetation. | |
| const wet = data.moisture[cell]; | |
| const thirst = { tree: 0.30, pine: 0.22, palm: 0.45, acacia: 0.12, | |
| shrub: 0.15, cactus: -1, rock: -1 }[kind] ?? 0.2; | |
| if (thirst >= 0) { | |
| if (wet < thirst * 0.5) continue; | |
| if (rng() > 0.35 + wet * 0.75) continue; | |
| } else { | |
| if (kind === 'cactus' && wet > 0.45) continue; // cacti avoid the banks | |
| if (rng() > 0.55) continue; | |
| } | |
| (buckets[kind] ||= []).push({ | |
| x: -WORLD / 2 + gx * step, | |
| z: -WORLD / 2 + gy * step, | |
| y: h, | |
| s: PROPS[kind].scale[0] + rng() * (PROPS[kind].scale[1] - PROPS[kind].scale[0]), | |
| rot: rng() * Math.PI * 2, | |
| tilt: kind === 'palm' ? (rng() - 0.5) * 0.35 : 0, | |
| }); | |
| placed++; | |
| } | |
| const dummy = new THREE.Object3D(); | |
| for (const [kind, list] of Object.entries(buckets)) { | |
| const spec = PROPS[kind]; | |
| const parts = []; | |
| if (spec.trunk) { | |
| const [rt, rb, hh] = spec.trunk; | |
| parts.push({ | |
| geo: new THREE.CylinderGeometry(rt, rb, hh, 6).translate(0, hh / 2, 0), | |
| color: spec.trunkColor, | |
| lift: 0, | |
| }); | |
| } | |
| if (spec.crown !== 'none') { | |
| const lift = spec.trunk ? spec.trunk[2] * 0.92 : spec.crownSize * 0.28; | |
| parts.push({ geo: crownGeometry(spec.crown, spec.crownSize), color: spec.crownColor, lift }); | |
| } | |
| for (const part of parts) { | |
| const mesh = new THREE.InstancedMesh( | |
| part.geo, | |
| new THREE.MeshStandardMaterial({ color: part.color, roughness: 0.9, flatShading: true }), | |
| list.length | |
| ); | |
| mesh.name = `${kind}-${part.color}`; | |
| list.forEach((p, i) => { | |
| dummy.position.set(p.x, p.y + part.lift * p.s - 0.15, p.z); | |
| dummy.rotation.set(p.tilt, p.rot, 0); | |
| dummy.scale.setScalar(p.s); | |
| dummy.updateMatrix(); | |
| mesh.setMatrixAt(i, dummy.matrix); | |
| }); | |
| mesh.instanceMatrix.needsUpdate = true; | |
| world.add(mesh); | |
| } | |
| } | |
| // --- inhabitants | |
| populateWorld(data, rng); | |
| // --- climate is read off the finished world, not chosen | |
| const climate = climateOf(data); | |
| data.climate = climate; | |
| sky.setWeather(climate.precipitation); | |
| sky.setTime(dayTime); | |
| controls.target.set(0, Math.max(4, maxH * 0.25), 0); | |
| drawLayout(data); | |
| renderLegend(regions); | |
| const ms = Math.round(performance.now() - t0); | |
| document.getElementById('stats').textContent = | |
| `${regions.length} regions · ${(GRID * GRID / 1000).toFixed(0)}k vertices · ` + | |
| `${placed} plants · ${agents.length} animals · ${ms} ms`; | |
| renderCensus(); | |
| } | |
| function populateWorld(data, rng) { | |
| while (fauna.children.length) { | |
| const c = fauna.children.pop(); | |
| c.geometry?.dispose(); c.material?.dispose?.(); | |
| } | |
| faunaMeshes = []; | |
| waterPts = waterAccess(data); | |
| agents = spawn(data, rng, 300); | |
| for (const sp of SPECIES) { | |
| const mine = agents.filter(a => a.sp.id === sp.id); | |
| if (!mine.length) continue; | |
| for (const [n, part] of bodyParts(THREE, sp).entries()) { | |
| const mesh = new THREE.InstancedMesh( | |
| part.geo, | |
| new THREE.MeshStandardMaterial({ color: part.color, roughness: 0.85, flatShading: true }), | |
| mine.length | |
| ); | |
| mesh.name = n === 0 ? `fauna-${sp.id}` : `fauna-${sp.id}-${n}`; | |
| mesh.frustumCulled = false; | |
| fauna.add(mesh); | |
| faunaMeshes.push({ mesh, list: mine, sp }); | |
| } | |
| } | |
| syncFauna(); | |
| } | |
| const faunaDummy = new THREE.Object3D(); | |
| function syncFauna() { | |
| for (const { mesh, list, sp } of faunaMeshes) { | |
| list.forEach((a, i) => { | |
| faunaDummy.position.set(a.x, a.y, a.z); | |
| faunaDummy.rotation.set(0, a.rot || 0, 0); | |
| const bob = sp.flying ? 1 : 1 + Math.sin(a.phase) * 0.05; | |
| faunaDummy.scale.set(a.scale, a.scale * bob, a.scale); | |
| faunaDummy.updateMatrix(); | |
| mesh.setMatrixAt(i, faunaDummy.matrix); | |
| }); | |
| mesh.instanceMatrix.needsUpdate = true; | |
| } | |
| } | |
| function renderCensus() { | |
| const counts = new Map(); | |
| for (const a of agents) counts.set(a.sp.id, (counts.get(a.sp.id) || 0) + 1); | |
| const rows = SPECIES.filter(s => counts.get(s.id)) | |
| .map(s => `<li><i style="background:${s.color}"></i>${s.label}<b>${counts.get(s.id)}</b></li>`); | |
| document.getElementById('census').innerHTML = rows.join('') || | |
| '<li style="color:var(--dim)">nothing lives here</li>'; | |
| } | |
| // --------------------------------------------------------- semantic layout map -- | |
| function drawLayout(data) { | |
| const cv = document.getElementById('layout'); | |
| const ctx = cv.getContext('2d'); | |
| const img = ctx.createImageData(GRID, GRID); | |
| const cols = data.regions.map(r => { | |
| const c = new THREE.Color(r.color); | |
| return [c.r * 255, c.g * 255, c.b * 255]; | |
| }); | |
| for (let i = 0; i < GRID * GRID; i++) { | |
| const [r, g, b] = cols[data.owner[i]]; | |
| img.data[i * 4] = r; img.data[i * 4 + 1] = g; img.data[i * 4 + 2] = b; img.data[i * 4 + 3] = 255; | |
| } | |
| cv.width = GRID; cv.height = GRID; | |
| ctx.putImageData(img, 0, 0); | |
| } | |
| function renderLegend(regions) { | |
| document.getElementById('legend').innerHTML = regions.map(r => | |
| `<li><i style="background:${r.color}"></i>${r.label}<b>${Math.round(r.coverage * 100)}%</b></li>` | |
| ).join(''); | |
| } | |
| // ------------------------------------------------------------------- fly mode -- | |
| let fly = false; | |
| const keys = new Set(); | |
| const flyState = { yaw: 0, pitch: 0 }; | |
| addEventListener('keydown', e => { | |
| if (e.code === 'Escape') setFly(false); | |
| keys.add(e.code); | |
| }); | |
| addEventListener('keyup', e => keys.delete(e.code)); | |
| function setFly(on) { | |
| fly = on; | |
| controls.enabled = !on; | |
| document.getElementById('fly').classList.toggle('on', on); | |
| document.getElementById('hint').style.display = on ? 'block' : 'none'; | |
| if (on) { | |
| const e = new THREE.Euler().setFromQuaternion(camera.quaternion, 'YXZ'); | |
| flyState.yaw = e.y; flyState.pitch = e.x; | |
| canvas.requestPointerLock(); | |
| } else if (document.pointerLockElement) { | |
| document.exitPointerLock(); | |
| } | |
| } | |
| document.addEventListener('pointerlockchange', () => { | |
| if (!document.pointerLockElement && fly) setFly(false); | |
| }); | |
| addEventListener('mousemove', e => { | |
| if (!fly || !document.pointerLockElement) return; | |
| flyState.yaw -= e.movementX * 0.0022; | |
| flyState.pitch = Math.max(-1.5, Math.min(1.5, flyState.pitch - e.movementY * 0.0022)); | |
| }); | |
| function stepFly(dt) { | |
| const speed = (keys.has('ShiftLeft') ? 180 : 60) * dt; | |
| camera.quaternion.setFromEuler(new THREE.Euler(flyState.pitch, flyState.yaw, 0, 'YXZ')); | |
| const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion); | |
| const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion); | |
| if (keys.has('KeyW')) camera.position.addScaledVector(fwd, speed); | |
| if (keys.has('KeyS')) camera.position.addScaledVector(fwd, -speed); | |
| if (keys.has('KeyD')) camera.position.addScaledVector(right, speed); | |
| if (keys.has('KeyA')) camera.position.addScaledVector(right, -speed); | |
| if (keys.has('KeyE') || keys.has('Space')) camera.position.y += speed; | |
| if (keys.has('KeyQ')) camera.position.y -= speed; | |
| // keep the camera above the ground so flying never ends up inside a hill | |
| if (current) { | |
| const step = WORLD / (GRID - 1); | |
| const gx = Math.round((camera.position.x + WORLD / 2) / step); | |
| const gy = Math.round((camera.position.z + WORLD / 2) / step); | |
| if (gx >= 0 && gx < GRID && gy >= 0 && gy < GRID) { | |
| const floor = current.height[gy * GRID + gx] + 1.8; | |
| if (camera.position.y < floor) camera.position.y = floor; | |
| } | |
| } | |
| } | |
| // ----------------------------------------------------------------------- ui ---- | |
| const promptEl = document.getElementById('prompt'); | |
| const seedEl = document.getElementById('seed'); | |
| function generate() { | |
| const seed = parseInt(seedEl.value, 10) || 1; | |
| document.body.classList.add('busy'); | |
| // setTimeout, not rAF: rAF never fires in a hidden or non-compositing tab, which | |
| // would leave the world unbuilt until the page happens to become visible. | |
| setTimeout(() => { | |
| try { | |
| build(promptEl.value, seed); | |
| } catch (err) { | |
| console.error(err); | |
| document.getElementById('stats').textContent = `generation failed: ${err.message}`; | |
| } | |
| document.body.classList.remove('busy'); | |
| }, 0); | |
| } | |
| document.getElementById('go').onclick = generate; | |
| promptEl.addEventListener('keydown', e => { if (e.key === 'Enter') generate(); }); | |
| document.getElementById('reseed').onclick = () => { | |
| seedEl.value = Math.floor(Math.random() * 999999); | |
| generate(); | |
| }; | |
| document.getElementById('fly').onclick = () => setFly(!fly); | |
| document.querySelectorAll('#presets button').forEach(b => { | |
| b.onclick = () => { promptEl.value = b.dataset.p; generate(); }; | |
| }); | |
| document.getElementById('glb').onclick = () => { | |
| const btn = document.getElementById('glb'); | |
| btn.disabled = true; btn.textContent = 'Exporting…'; | |
| new GLTFExporter().parse(world, result => { | |
| const blob = new Blob([result], { type: 'model/gltf-binary' }); | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = 'worldforge.glb'; | |
| a.click(); | |
| URL.revokeObjectURL(a.href); | |
| btn.disabled = false; btn.textContent = 'Export GLB'; | |
| }, err => { | |
| console.error(err); | |
| btn.disabled = false; btn.textContent = 'Export GLB'; | |
| }, { binary: true }); | |
| }; | |
| // --- clock ------------------------------------------------------------------- | |
| const clockEl = document.getElementById('clock'); | |
| function showClock() { | |
| const mins = Math.round(dayTime * 24 * 60); | |
| const hh = String(Math.floor(mins / 60) % 24).padStart(2, '0'); | |
| const mm = String(mins % 60).padStart(2, '0'); | |
| document.getElementById('clockLabel').textContent = `${hh}:${mm}`; | |
| } | |
| clockEl.oninput = () => { dayTime = clockEl.value / 1000; sky.setTime(dayTime); showClock(); }; | |
| document.getElementById('play').onclick = (e) => { | |
| dayRunning = !dayRunning; | |
| e.target.classList.toggle('on', dayRunning); | |
| e.target.textContent = dayRunning ? 'Pause' : 'Run day'; | |
| }; | |
| showClock(); | |
| // --- inspect probe: click the ground, read what the model says is there -------- | |
| const ray = new THREE.Raycaster(); | |
| canvas.addEventListener('click', (e) => { | |
| if (fly || !current) return; | |
| const r = canvas.getBoundingClientRect(); | |
| ray.setFromCamera(new THREE.Vector2( | |
| ((e.clientX - r.left) / r.width) * 2 - 1, | |
| -((e.clientY - r.top) / r.height) * 2 + 1), camera); | |
| const hit = ray.intersectObject(world.getObjectByName('terrain'), false)[0]; | |
| const box = document.getElementById('probe'); | |
| if (!hit) { box.innerHTML = '<span class="dimmed">click the ground to inspect</span>'; return; } | |
| const p = probe(current, hit.point.x, hit.point.z); | |
| const near = agents.filter(a => (a.x - hit.point.x) ** 2 + (a.z - hit.point.z) ** 2 < 400); | |
| const kinds = [...new Set(near.map(a => a.sp.label))].slice(0, 3); | |
| box.innerHTML = | |
| `<b>${p.region.label}</b>` + | |
| `<span>elevation<b>${p.height.toFixed(1)} m</b></span>` + | |
| `<span>slope<b>${(p.slope * 100).toFixed(0)}%</b></span>` + | |
| `<span>moisture<b>${(p.moisture * 100).toFixed(0)}%</b></span>` + | |
| `<span>water<b>${p.waterDepth > 0.02 ? p.waterDepth.toFixed(2) + ' m' : '—'}</b></span>` + | |
| `<span>within 20 m<b>${near.length ? `${near.length} · ${kinds.join(', ')}` : 'nothing'}</b></span>`; | |
| }); | |
| // --- world spec: the model as data, not as a picture --------------------------- | |
| document.getElementById('json').onclick = () => { | |
| if (!current) return; | |
| let lake = 0, river = 0, wettest = 0; | |
| for (let i = 0; i < current.water.depth.length; i++) { | |
| const d = current.water.depth[i]; | |
| if (d > 0.02) (d > 0.5 ? lake++ : river++); | |
| wettest = Math.max(wettest, current.moisture[i]); | |
| } | |
| const cell = (WORLD / (GRID - 1)) ** 2; | |
| const census = {}; | |
| for (const a of agents) census[a.sp.id] = (census[a.sp.id] || 0) + 1; | |
| const spec = { | |
| prompt: promptEl.value, | |
| seed: parseInt(seedEl.value, 10) || 1, | |
| extent_m: WORLD, | |
| grid: GRID, | |
| regions: current.regions.map(r => ({ | |
| key: r.key, label: r.label, coverage: +r.coverage.toFixed(3), | |
| base_elevation_m: r.base, operator: r.op, | |
| })), | |
| hydrology: { | |
| sea_level_m: current.seaLevel > -900 ? current.seaLevel : null, | |
| lake_area_m2: Math.round(lake * cell), | |
| river_area_m2: Math.round(river * cell), | |
| max_moisture: +wettest.toFixed(3), | |
| }, | |
| climate: current.climate, | |
| ecology: Object.entries(census).map(([id, n]) => ({ | |
| species: id, count: n, | |
| habitat: SPECIES.find(s => s.id === id)?.habitat, | |
| })), | |
| time_of_day: +dayTime.toFixed(3), | |
| }; | |
| const blob = new Blob([JSON.stringify(spec, null, 2)], { type: 'application/json' }); | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = 'world-spec.json'; | |
| a.click(); | |
| URL.revokeObjectURL(a.href); | |
| }; | |
| document.getElementById('png').onclick = () => { | |
| const a = document.createElement('a'); | |
| a.href = document.getElementById('layout').toDataURL('image/png'); | |
| a.download = 'layout-map.png'; | |
| a.click(); | |
| }; | |
| // ---------------------------------------------------------------------- loop --- | |
| function resize() { | |
| const w = canvas.clientWidth, h = canvas.clientHeight; | |
| if (canvas.width !== w || canvas.height !== h) { | |
| renderer.setSize(w, h, false); | |
| camera.aspect = w / h; | |
| camera.updateProjectionMatrix(); | |
| } | |
| } | |
| // Advancing the world is separate from drawing it, so the simulation can be | |
| // driven a step at a time without a visible frame — a hidden tab gets no rAF, and | |
| // a world that only moves while someone is watching cannot be tested. | |
| function tick(dt) { | |
| if (current && agents.length) { | |
| stepLife(current, agents, dt, waterPts); | |
| syncFauna(); | |
| } | |
| if (dayRunning) { | |
| dayTime = (dayTime + dt / 120) % 1; // a full day in two minutes | |
| sky.setTime(dayTime); | |
| document.getElementById('clock').value = Math.round(dayTime * 1000); | |
| showClock(); | |
| } | |
| sky.stepWeather(dt, camera); | |
| } | |
| let last = performance.now(); | |
| function loop(now) { | |
| const dt = Math.min(0.05, (now - last) / 1000); | |
| last = now; | |
| resize(); | |
| if (fly) stepFly(dt); else controls.update(); | |
| tick(dt); | |
| renderer.render(scene, camera); | |
| requestAnimationFrame(loop); | |
| } | |
| // Handle for debugging and for driving the app from a console or a test harness | |
| // (a headless tab gets no rAF, so the render has to be callable directly). | |
| window.worldforge = { | |
| renderer, scene, camera, controls, | |
| build, generate, tick, | |
| frame: () => { resize(); renderer.render(scene, camera); }, | |
| get agents() { return agents; }, | |
| get world() { return current; }, | |
| }; | |
| seedEl.value = Math.floor(Math.random() * 999999); | |
| generate(); | |
| requestAnimationFrame(loop); | |