From 6e5f56269854b9fc076c377cd41102c20ea70092 Mon Sep 17 00:00:00 2001 From: Pete Jensen Date: Sat, 22 Nov 2025 15:30:26 -0500 Subject: [PATCH 01/17] Add ecosystem food-web sandbox demo --- demo/food-web.html | 385 +++++++++++++++++++++++++++++++++++++++++++++ index.html | 1 + 2 files changed, 386 insertions(+) create mode 100644 demo/food-web.html diff --git a/demo/food-web.html b/demo/food-web.html new file mode 100644 index 0000000..0b03db5 --- /dev/null +++ b/demo/food-web.html @@ -0,0 +1,385 @@ + + +ecs-js — Ecosystem Food-Web Sandbox + +
+
+

Ecosystem Food-Web Sandbox

+

Plants, herbivores, and predators interact across a seasonal biome. Scripts orchestrate climate shocks; phases keep the causal order explicit.

+
+ + + +
+
+ + +
+
Biome
+
+ + + + + + +
+
Populations
+
+ + + + + + +
+
Display
+
+ + + + +
+
+ Time0.00 + Plants— + Herbivores— + Predators— + Events— + StatusOK +
+
+
Event log
+
+
+
Phases: climate → grow → sense → act → decay → render → scripts. Scripts inject seasonal shocks and territory beacons.
+
+ +
+ diff --git a/index.html b/index.html index c66d23e..72b1362 100644 --- a/index.html +++ b/index.html @@ -107,6 +107,7 @@

ECS-js

From 0e4afcf1ee28779ce3fdabc0b2d659fbc7d22e37 Mon Sep 17 00:00:00 2001 From: Pete Jensen Date: Sat, 22 Nov 2025 15:39:56 -0500 Subject: [PATCH 02/17] Revamp food-web demo visuals --- demo/food-web.html | 150 ++++++++++++++++++++++++++++++++------------- 1 file changed, 108 insertions(+), 42 deletions(-) diff --git a/demo/food-web.html b/demo/food-web.html index 0b03db5..386f9c2 100644 --- a/demo/food-web.html +++ b/demo/food-web.html @@ -4,31 +4,31 @@
-

Ecosystem Food-Web Sandbox

+

🌿 Ecosystem Food-Web Sandbox 🌌

Plants, herbivores, and predators interact across a seasonal biome. Scripts orchestrate climate shocks; phases keep the causal order explicit.

- - - + + +
@@ -60,12 +60,12 @@

Ecosystem Food-Web Sandbox

- Time0.00 - Plants— - Herbivores— - Predators— - Events— - StatusOK + ⏱️ Time0.00 + 🌿 Plants— + 🦌 Herbivores— + 🦊 Predators— + 📡 Events— + 🛰️ StatusOK
Event log
@@ -84,6 +84,7 @@

Ecosystem Food-Web Sandbox

withOverrides, createFrom, PHASE_SCRIPTS, + createRealtimeRafLoop, } from '../index.js'; const DEFAULT_SEED = 0xFEEDBEEF >>> 0; @@ -91,7 +92,7 @@

Ecosystem Food-Web Sandbox

// ---------- Components ---------- const Biome = defineComponent('Biome', { cols: 64, rows: 42, rainfall: 0.8, sunlight: 1.0, swing: 0.4 }); const Field = defineComponent('Field', { plants: null, carrion: null, scent: null }); -const Season = defineComponent('Season', { t: 0, temp: 0, rainPulse: 1, drought: 0, flare: 0 }); +const Season = defineComponent('Season', { t: 0, temp: 0, rainPulse: 1, drought: 0, flare: 0, cols: 64, rows: 42 }); const Position = defineComponent('Position', { x: 0, y: 0 }); const Velocity = defineComponent('Velocity', { dx: 0, dy: 0 }); const Herbivore = defineComponent('Herbivore', { energy: 32, gestate: 0, lastMeal: 0 }); @@ -101,6 +102,26 @@

Ecosystem Food-Web Sandbox

const Metrics = defineComponent('Metrics', { plants: 0, herb: 0, pred: 0, events: 0 }); const Beacon = defineTag('Beacon'); +const EMOJI = { + herb: ['🦌','🐇','🦘'], + pred: ['🦊','🐺','🐆'], + plant: '🌿', + carrion: '🦴', + drought: '🌵', + flare: '✨', + rain: '🌧️', + sun: '☀️', + beacon: '📡', + spark: ['✨','💫','❇️','🌟'], +}; + +const SKY_EMOJI = Array.from({ length: 36 }, () => ({ + x: Math.random(), + y: Math.random(), + glyph: EMOJI.spark[(Math.random() * EMOJI.spark.length) | 0], + drift: 0.004 + Math.random() * 0.006, +})); + // ---------- UI helpers ---------- const UI = { play: document.getElementById('play'), @@ -143,11 +164,12 @@

Ecosystem Food-Web Sandbox

const CV = (()=>{ const cv = document.getElementById('cv'); const ctx = cv.getContext('2d'); function fit(){ const dpr = window.devicePixelRatio||1; cv.width=cv.clientWidth*dpr; cv.height=cv.clientHeight*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); } window.addEventListener('resize', fit); fit(); return { cv, ctx }; })(); const idx = (x,y,cols,rows)=>(( (y+rows)%rows)*cols + ((x+cols)%cols)); +const pick = (arr, rand=Math.random)=>arr[(rand()*arr.length)|0]; function log(msg){ const time = new Date().toLocaleTimeString(); UI.log.textContent = `[${time}] ${msg}\n` + UI.log.textContent.slice(0, 2400); } // ---------- World construction ---------- -let world = null; let loopHandle = null; let playing = false; +let world = null; let loop = null; const Critter = defineArchetype('Critter', [Position, (p)=>({ x:p.x, y:p.y })], [Velocity, { dx:0, dy:0 }], [Trail, { ttl:8, hue:120 }]); const Grazer = withOverrides(Critter, { Herbivore: { energy:32, gestate:0, lastMeal:0 }, Trail: (_p,_w,_id,base)=>({ ...base, hue:110 }) }); @@ -178,14 +200,14 @@

Ecosystem Food-Web Sandbox

s.onTick((_world, _id, dt) => { for (const [id, season] of world.query(Season)) { if (season.drought <= 0 && world.rand() < 0.0015 * dt) { - season.drought = 8 + world.rand()*12; world.emit('shock', { msg: 'Dry spell triggered' }); + season.drought = 8 + world.rand()*12; world.emit('shock', { msg: `${EMOJI.drought} Dry spell triggered` }); const sid = world.create(); world.add(sid, Signal, { ttl:6, label:'drought', strength:1.2 }); world.add(sid, Beacon); world.add(sid, Position, { x:(world.rand()*season.cols)|0, y:(world.rand()*season.rows)|0 }); } if (season.flare <= 0 && world.rand() < 0.0012 * dt) { - season.flare = 5 + world.rand()*8; world.emit('shock', { msg: 'Solar flare (predators sluggish)' }); + season.flare = 5 + world.rand()*8; world.emit('shock', { msg: `${EMOJI.flare} Solar flare — predators sluggish` }); const sid = world.create(); world.add(sid, Signal, { ttl:4, label:'flare', strength:1.4 }); world.add(sid, Position, { x:(world.rand()*season.cols)|0, y:(world.rand()*season.rows)|0 }); @@ -209,8 +231,8 @@

Ecosystem Food-Web Sandbox

function bumpEvents(x){ for (const [id, m] of world.query(Metrics)) world.set(id, Metrics, { ...m, events: m.events + x }); } -function spawnHerb(){ const [meta, biome] = [...world.query(Biome)][0]; if (!biome) return; const id = createFrom(world, Grazer, { x:(world.rand()*biome.cols)|0, y:(world.rand()*biome.rows)|0 }); world.emit('spawn', { msg:'Herbivore born' }); return id; } -function spawnPred(){ const [meta, biome] = [...world.query(Biome)][0]; if (!biome) return; const id = createFrom(world, Hunter, { x:(world.rand()*biome.cols)|0, y:(world.rand()*biome.rows)|0 }); world.emit('spawn', { msg:'Predator born' }); return id; } +function spawnHerb(){ const [meta, biome] = [...world.query(Biome)][0]; if (!biome) return; const id = createFrom(world, Grazer, { x:(world.rand()*biome.cols)|0, y:(world.rand()*biome.rows)|0 }); world.emit('spawn', { msg:`${pick(EMOJI.herb, world.rand)} Herbivore born` }); return id; } +function spawnPred(){ const [meta, biome] = [...world.query(Biome)][0]; if (!biome) return; const id = createFrom(world, Hunter, { x:(world.rand()*biome.cols)|0, y:(world.rand()*biome.rows)|0 }); world.emit('spawn', { msg:`${pick(EMOJI.pred, world.rand)} Predator born` }); return id; } // ---------- Systems ---------- function climateSystem(world, dt){ @@ -331,55 +353,99 @@

Ecosystem Food-Web Sandbox

} } -function renderSystem(world, _dt){ +function renderSystem(world, _dt, statsView){ const { ctx, cv } = CV; ctx.clearRect(0,0,cv.width,cv.height); const [bid, biome, field, season, metrics] = world.query(Biome, Field, Season, Metrics)[Symbol.iterator]().next().value || []; - if(!biome) return; const cw = cv.width / biome.cols, ch = cv.height / biome.rows; + if(!biome) return; const cw = cv.width / biome.cols, ch = cv.height / biome.rows; const t = world.time; + + const sky = ctx.createLinearGradient(0,0,0,cv.height); + sky.addColorStop(0, '#061020'); sky.addColorStop(1, '#090c18'); + ctx.fillStyle = sky; ctx.fillRect(0,0,cv.width,cv.height); + + ctx.save(); + ctx.globalAlpha = 0.25; + ctx.font = `${Math.max(cw,ch)*0.9}px "Apple Color Emoji","Noto Color Emoji"`; + for(const puff of SKY_EMOJI){ + const px = (puff.x*cv.width + Math.sin(t*puff.drift*3)*24) % cv.width; + const py = (puff.y*cv.height + Math.cos(t*puff.drift*2)*18) % cv.height; + ctx.fillText(puff.glyph, px, py); + } + ctx.restore(); + if(UI.showHeat.checked){ for(let y=0;y22 && ((x+y)%5===0)){ + ctx.save(); ctx.globalAlpha = 0.6; ctx.font = `${Math.min(cw,ch)*0.9}px "Apple Color Emoji","Noto Color Emoji"`; ctx.fillText(EMOJI.plant, x*cw+cw*0.1, y*ch+ch*0.88); ctx.restore(); + } + if(carr>0.5 && ((x+y)%7===0)){ + ctx.save(); ctx.globalAlpha = 0.5; ctx.font = `${Math.min(cw,ch)*0.8}px "Apple Color Emoji","Noto Color Emoji"`; ctx.fillText(EMOJI.carrion, x*cw+cw*0.2, y*ch+ch*0.82); ctx.restore(); + } } } if(UI.showContours.checked){ - ctx.strokeStyle = 'rgba(255,255,255,0.04)'; for(let x=0;x<=biome.cols;x+=8){ ctx.beginPath(); ctx.moveTo(x*cw,0); ctx.lineTo(x*cw,cv.height); ctx.stroke(); } - for(let y=0;y<=biome.rows;y+=8){ ctx.beginPath(); ctx.moveTo(0,y*ch); ctx.lineTo(cv.width,y*ch); ctx.stroke(); } + ctx.strokeStyle = 'rgba(0,255,205,0.08)'; ctx.lineWidth=1; for(let x=0;x<=biome.cols;x+=8){ ctx.beginPath(); ctx.moveTo(x*cw,0); ctx.lineTo(x*cw,cv.height); ctx.stroke(); } + ctx.strokeStyle = 'rgba(120,160,255,0.08)'; for(let y=0;y<=biome.rows;y+=8){ ctx.beginPath(); ctx.moveTo(0,y*ch); ctx.lineTo(cv.width,y*ch); ctx.stroke(); } } if(UI.showSignals.checked){ - ctx.strokeStyle = 'rgba(255,118,84,0.7)'; ctx.fillStyle='rgba(255,118,84,0.4)'; for(const [id, pos, sig] of world.query(Position, Signal)){ - const r = (2+sig.ttl*3); ctx.beginPath(); ctx.arc((pos.x+0.5)*cw,(pos.y+0.5)*ch,r,0,Math.PI*2); ctx.stroke(); ctx.fill(); + const r = (2+sig.ttl*4); const hue = sig.label==='drought'?28:300; const glyph = sig.label==='drought'?EMOJI.drought:EMOJI.flare; + const grad = ctx.createRadialGradient((pos.x+0.5)*cw,(pos.y+0.5)*ch,2,(pos.x+0.5)*cw,(pos.y+0.5)*ch,r*4); + grad.addColorStop(0, `hsla(${hue},90%,70%,0.5)`); grad.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = grad; ctx.fillRect((pos.x-4)*cw, (pos.y-4)*ch, cw*9, ch*9); + ctx.strokeStyle = `hsla(${hue},100%,70%,0.8)`; ctx.lineWidth=1.5; ctx.beginPath(); ctx.arc((pos.x+0.5)*cw,(pos.y+0.5)*ch,r,0,Math.PI*2); ctx.stroke(); + ctx.font = `${Math.min(cw,ch)*1.1}px "Apple Color Emoji","Noto Color Emoji"`; ctx.fillStyle='rgba(255,255,255,0.9)'; ctx.fillText(glyph,(pos.x+0.1)*cw,(pos.y+0.9)*ch); } } if(UI.showTrails.checked){ for(const [id, pos, trail] of world.query(Position, Trail)){ - ctx.fillStyle = `hsla(${trail.hue},80%,60%,${Math.max(0, trail.ttl/12)})`; + ctx.fillStyle = `hsla(${trail.hue},90%,70%,${Math.max(0, trail.ttl/12)})`; + ctx.shadowColor = `hsla(${trail.hue},100%,70%,0.6)`; ctx.shadowBlur = 10; ctx.fillRect(pos.x*cw, pos.y*ch, cw, ch); } + ctx.shadowBlur = 0; ctx.shadowColor='transparent'; + } + ctx.font = `${Math.min(cw,ch)*0.95}px "Apple Color Emoji","Noto Color Emoji"`; + for(const [id, pos] of world.query(Position, Herbivore)){ + ctx.save(); ctx.shadowColor='rgba(120,255,180,0.65)'; ctx.shadowBlur=12; ctx.fillText(EMOJI.herb[id % EMOJI.herb.length],(pos.x+0.06)*cw,(pos.y+0.9)*ch); ctx.restore(); + } + for(const [id, pos] of world.query(Position, Predator)){ + ctx.save(); ctx.shadowColor='rgba(255,120,120,0.75)'; ctx.shadowBlur=14; ctx.fillText(EMOJI.pred[id % EMOJI.pred.length],(pos.x+0.05)*cw,(pos.y+0.92)*ch); ctx.restore(); } - ctx.fillStyle='#b1f0c9'; - for(const [id, pos] of world.query(Position, Herbivore)) ctx.fillRect(pos.x*cw+1, pos.y*ch+1, cw-2, ch-2); - ctx.fillStyle='#ff9f7f'; - for(const [id, pos] of world.query(Position, Predator)) ctx.beginPath(), ctx.arc((pos.x+0.5)*cw,(pos.y+0.5)*ch, Math.min(cw,ch)/2.2, 0, Math.PI*2), ctx.fill(); - UI.out.t.textContent = world.step.toFixed(2); + UI.out.t.textContent = world.time.toFixed(2); UI.out.plants.textContent = metrics ? metrics.plants.toFixed(0) : '—'; UI.out.herb.textContent = [...world.query(Herbivore)].length; UI.out.pred.textContent = [...world.query(Predator)].length; UI.out.evt.textContent = metrics ? metrics.events.toFixed(1) : '—'; - UI.out.status.textContent = season && season.drought>0 ? 'Drought' : 'OK'; + UI.out.status.textContent = season && season.drought>0 ? `${EMOJI.drought} Drought` : season && season.flare>0 ? `${EMOJI.flare} Aurora` : `${EMOJI.sun} Stable`; } // ---------- Loop ---------- -function tick(dt){ world.tick(dt); } +let runner = null; + +function setRunningUi(running){ UI.play.textContent = running ? '⏸ Pause 🌠' : '▶️ Play'; } + +function reset(){ + loop?.stop(); + buildWorld(); + runner = { tick: () => world.tick(+UI.dt.value) }; + loop = createRealtimeRafLoop({ world: runner, stepFrame: (_dt, stats)=>renderSystem(world, _dt, stats) }); + UI.log.textContent=''; + loop.start({ reset:true }); + setRunningUi(true); +} + +function step(){ if(!world || loop?.isRunning()) return; world.tick(+UI.dt.value); renderSystem(world,0); } -function frame(){ if(!playing) return; tick(+UI.dt.value); loopHandle = requestAnimationFrame(frame); } +function togglePlay(){ if(!loop) return; if(loop.isRunning()){ loop.stop(); setRunningUi(false); } else { loop.start(); setRunningUi(true); } } -UI.play.addEventListener('click', ()=>{ playing=!playing; UI.play.textContent = playing?'⏸ Pause':'▶ Play'; if(playing){ loopHandle=requestAnimationFrame(frame); } else cancelAnimationFrame(loopHandle); }); -UI.step.addEventListener('click', ()=>{ if(!world) return; tick(+UI.dt.value); renderSystem(world,0); }); -UI.reset.addEventListener('click', ()=>{ playing=false; UI.play.textContent='▶ Play'; if(loopHandle) cancelAnimationFrame(loopHandle); buildWorld(); renderSystem(world,0); UI.log.textContent=''; }); +UI.play.addEventListener('click', togglePlay); +UI.step.addEventListener('click', step); +UI.reset.addEventListener('click', reset); -buildWorld(); renderSystem(world,0); +syncLabels(); reset(); From bfd960f27381bbde01e29d12b3c36e4d3b3444e4 Mon Sep 17 00:00:00 2001 From: Pete Jensen Date: Sat, 22 Nov 2025 15:43:37 -0500 Subject: [PATCH 03/17] Stabilize food-web populations with rebalance system --- demo/food-web.html | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/demo/food-web.html b/demo/food-web.html index 386f9c2..3f8f3e5 100644 --- a/demo/food-web.html +++ b/demo/food-web.html @@ -46,11 +46,11 @@

🌿 Ecosystem Food-Web Sandbox 🌌

Populations
- + - + - +
Display
@@ -190,6 +190,7 @@

🌿 Ecosystem Food-Web Sandbox 🌌

.system(senseSystem, 'sense') .system(actSystem, 'act') .system(decaySystem, 'decay') + .system(balanceSystem, 'decay') .system(renderSystem, 'render') .build(); @@ -353,6 +354,26 @@

🌿 Ecosystem Food-Web Sandbox 🌌

} } +function balanceSystem(world, _dt){ + const herbCount = [...world.query(Herbivore)].length; + const predCount = [...world.query(Predator)].length; + const targetHerb = Math.max(10, Math.floor(+UI.herb.value * 0.4)); + const targetPred = Math.max(4, Math.floor(+UI.pred.value * 0.35)); + const [mid, metrics] = world.query(Metrics)[Symbol.iterator]().next().value || []; + if(metrics){ + world.set(mid, Metrics, { ...metrics, herb: herbCount, pred: predCount }); + } + + if(world.time > 0.5 && herbCount < targetHerb){ + const missing = Math.min(targetHerb - herbCount, 12); + for(let i=0;i 0.5 && predCount < targetPred && herbCount > targetHerb*0.5){ + const missing = Math.min(targetPred - predCount, 6); + for(let i=0;i🌿 Ecosystem Food-Web Sandbox 🌌 UI.out.t.textContent = world.time.toFixed(2); UI.out.plants.textContent = metrics ? metrics.plants.toFixed(0) : '—'; - UI.out.herb.textContent = [...world.query(Herbivore)].length; - UI.out.pred.textContent = [...world.query(Predator)].length; + UI.out.herb.textContent = metrics ? metrics.herb : [...world.query(Herbivore)].length; + UI.out.pred.textContent = metrics ? metrics.pred : [...world.query(Predator)].length; UI.out.evt.textContent = metrics ? metrics.events.toFixed(1) : '—'; UI.out.status.textContent = season && season.drought>0 ? `${EMOJI.drought} Drought` : season && season.flare>0 ? `${EMOJI.flare} Aurora` : `${EMOJI.sun} Stable`; } From 1ffdfda4c332ca14edb02ba089a275f3ee9cd875 Mon Sep 17 00:00:00 2001 From: Pete Jensen Date: Sat, 22 Nov 2025 15:46:25 -0500 Subject: [PATCH 04/17] Fix food-web archetype composition --- demo/food-web.html | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/demo/food-web.html b/demo/food-web.html index 3f8f3e5..7f7a63e 100644 --- a/demo/food-web.html +++ b/demo/food-web.html @@ -81,7 +81,6 @@

🌿 Ecosystem Food-Web Sandbox 🌌

defineComponent, defineTag, defineArchetype, - withOverrides, createFrom, PHASE_SCRIPTS, createRealtimeRafLoop, @@ -171,9 +170,19 @@

🌿 Ecosystem Food-Web Sandbox 🌌

// ---------- World construction ---------- let world = null; let loop = null; -const Critter = defineArchetype('Critter', [Position, (p)=>({ x:p.x, y:p.y })], [Velocity, { dx:0, dy:0 }], [Trail, { ttl:8, hue:120 }]); -const Grazer = withOverrides(Critter, { Herbivore: { energy:32, gestate:0, lastMeal:0 }, Trail: (_p,_w,_id,base)=>({ ...base, hue:110 }) }); -const Hunter = withOverrides(Critter, { Predator: { energy:46, hunger:0, target:-1 }, Trail: (_p,_w,_id,base)=>({ ...base, hue:10 }) }); +const Critter = defineArchetype('Critter', [Position, (p)=>({ x:p.x, y:p.y })], [Velocity, { dx:0, dy:0 }]); +const Grazer = defineArchetype( + 'Grazer', + Critter, + [Trail, { ttl: 8, hue: 110 }], + [Herbivore, { energy:32, gestate:0, lastMeal:0 }], +); +const Hunter = defineArchetype( + 'Hunter', + Critter, + [Trail, { ttl: 8, hue: 10 }], + [Predator, { energy:46, hunger:0, target:-1 }], +); function seedField(cols, rows, rainfall, sunlight, rand){ const n = cols*rows; const plants = new Float32Array(n); const carrion = new Float32Array(n); const scent = new Float32Array(n); From 997bd06aee08fe9544f34dd574320cbdc573df94 Mon Sep 17 00:00:00 2001 From: Pete Jensen Date: Sat, 22 Nov 2025 15:53:25 -0500 Subject: [PATCH 05/17] Restyle food-web demo with RTS board and live populations --- demo/food-web.html | 157 +++++++++++++++++++++++++++++++++------------ 1 file changed, 116 insertions(+), 41 deletions(-) diff --git a/demo/food-web.html b/demo/food-web.html index 7f7a63e..57bf616 100644 --- a/demo/food-web.html +++ b/demo/food-web.html @@ -99,6 +99,7 @@

🌿 Ecosystem Food-Web Sandbox 🌌

const Trail = defineComponent('Trail', { ttl: 12, hue: 180 }); const Signal = defineComponent('Signal', { ttl: 3, label: 'shock', strength: 1 }); const Metrics = defineComponent('Metrics', { plants: 0, herb: 0, pred: 0, events: 0 }); +const SpawnBeacon = defineComponent('SpawnBeacon', { ttl: 2, hue: 60 }); const Beacon = defineTag('Beacon'); const EMOJI = { @@ -121,6 +122,16 @@

🌿 Ecosystem Food-Web Sandbox 🌌

drift: 0.004 + Math.random() * 0.006, })); +const COLORS = { + grid: '#0f1926', + laneA: '#1b2c3d', + laneB: '#132031', + herb: '#74ffcf', + pred: '#ff8c7a', + plant: '#6ad96b', + carrion: '#d9b36a', +}; + // ---------- UI helpers ---------- const UI = { play: document.getElementById('play'), @@ -167,22 +178,34 @@

🌿 Ecosystem Food-Web Sandbox 🌌

function log(msg){ const time = new Date().toLocaleTimeString(); UI.log.textContent = `[${time}] ${msg}\n` + UI.log.textContent.slice(0, 2400); } +function worldMeta(){ + return world?.query(Biome, Field, Season, Metrics)[Symbol.iterator]().next().value || []; +} + +function updateMetrics(partial){ + const meta = worldMeta(); + if(!meta.length) return; + const [mid, , , , metrics] = meta; + world.set(mid, Metrics, { ...metrics, ...partial }); +} + +function adjustPopCounts(dHerb = 0, dPred = 0){ + const meta = worldMeta(); + if(!meta.length) return; + const [mid, , , , metrics] = meta; + world.set(mid, Metrics, { + ...metrics, + herb: Math.max(0, (metrics.herb || 0) + dHerb), + pred: Math.max(0, (metrics.pred || 0) + dPred), + }); +} + // ---------- World construction ---------- let world = null; let loop = null; const Critter = defineArchetype('Critter', [Position, (p)=>({ x:p.x, y:p.y })], [Velocity, { dx:0, dy:0 }]); -const Grazer = defineArchetype( - 'Grazer', - Critter, - [Trail, { ttl: 8, hue: 110 }], - [Herbivore, { energy:32, gestate:0, lastMeal:0 }], -); -const Hunter = defineArchetype( - 'Hunter', - Critter, - [Trail, { ttl: 8, hue: 10 }], - [Predator, { energy:46, hunger:0, target:-1 }], -); +const Grazer = defineArchetype('Grazer', Critter, [Trail, { ttl: 8, hue: 110 }]); +const Hunter = defineArchetype('Hunter', Critter, [Trail, { ttl: 8, hue: 10 }]); function seedField(cols, rows, rainfall, sunlight, rand){ const n = cols*rows; const plants = new Float32Array(n); const carrion = new Float32Array(n); const scent = new Float32Array(n); @@ -241,8 +264,38 @@

🌿 Ecosystem Food-Web Sandbox 🌌

function bumpEvents(x){ for (const [id, m] of world.query(Metrics)) world.set(id, Metrics, { ...m, events: m.events + x }); } -function spawnHerb(){ const [meta, biome] = [...world.query(Biome)][0]; if (!biome) return; const id = createFrom(world, Grazer, { x:(world.rand()*biome.cols)|0, y:(world.rand()*biome.rows)|0 }); world.emit('spawn', { msg:`${pick(EMOJI.herb, world.rand)} Herbivore born` }); return id; } -function spawnPred(){ const [meta, biome] = [...world.query(Biome)][0]; if (!biome) return; const id = createFrom(world, Hunter, { x:(world.rand()*biome.cols)|0, y:(world.rand()*biome.rows)|0 }); world.emit('spawn', { msg:`${pick(EMOJI.pred, world.rand)} Predator born` }); return id; } +function spawnHerb(){ + const [meta, biome, field] = world.query(Biome, Field)[Symbol.iterator]().next().value || []; + if (!biome) return; + const pos = { x:(world.rand()*biome.cols)|0, y:(world.rand()*biome.rows)|0 }; + const id = world.create(); + world.add(id, Position, pos); + world.add(id, Velocity, { dx:0, dy:0 }); + world.add(id, Herbivore, { energy:32, gestate:0, lastMeal:0 }); + world.add(id, Trail, { ttl: 8, hue: 120 + 40*world.rand() }); + world.emit('spawn', { msg:`${pick(EMOJI.herb, world.rand)} Herbivore deployed` }); + adjustPopCounts(1, 0); + const beacon = world.create(); + world.add(beacon, SpawnBeacon, { ttl: 1.5, hue: 110 }); + world.add(beacon, Position, pos); + return id; +} +function spawnPred(){ + const [meta, biome] = world.query(Biome)[Symbol.iterator]().next().value || []; + if (!biome) return; + const pos = { x:(world.rand()*biome.cols)|0, y:(world.rand()*biome.rows)|0 }; + const id = world.create(); + world.add(id, Position, pos); + world.add(id, Velocity, { dx:0, dy:0 }); + world.add(id, Predator, { energy:46, hunger:0, target:-1 }); + world.add(id, Trail, { ttl: 8, hue: 12 }); + world.emit('spawn', { msg:`${pick(EMOJI.pred, world.rand)} Predator deployed` }); + adjustPopCounts(0, 1); + const beacon = world.create(); + world.add(beacon, SpawnBeacon, { ttl: 1.5, hue: 20 }); + world.add(beacon, Position, pos); + return id; +} // ---------- Systems ---------- function climateSystem(world, dt){ @@ -320,7 +373,7 @@

🌿 Ecosystem Food-Web Sandbox 🌌

if(!biome) continue; const i=idx(pos.x,pos.y,biome.cols,biome.rows); let ate=false; for(const [hid, hpos] of world.query(Position, Herbivore)){ - if(hpos.x===pos.x && hpos.y===pos.y){ world.destroy(hid); field.carrion[i]+=2.5; ate=true; break; } + if(hpos.x===pos.x && hpos.y===pos.y){ world.destroy(hid); adjustPopCounts(-1,0); field.carrion[i]+=2.5; ate=true; break; } } const gain = ate? 8 : 0; world.set(id, Predator, { ...pred, energy: pred.energy + gain, hunger: pred.hunger + dt*(ate? -2:1) }); @@ -343,6 +396,7 @@

🌿 Ecosystem Food-Web Sandbox 🌌

const [bid, biome, field] = world.query(Biome, Field)[Symbol.iterator]().next().value || []; if(biome){ const i=idx(pos.x,pos.y,biome.cols,biome.rows); field.carrion[i]+=4; field.scent[i]+=2; } world.destroy(id); + adjustPopCounts(-1, 0); } else { world.set(id, Herbivore, { ...herb, energy: e, lastMeal: lm }); } @@ -353,6 +407,7 @@

🌿 Ecosystem Food-Web Sandbox 🌌

const [bid, biome, field] = world.query(Biome, Field)[Symbol.iterator]().next().value || []; if(biome){ const i=idx(pos.x,pos.y,biome.cols,biome.rows); field.carrion[i]+=6; field.scent[i]+=3; } world.destroy(id); + adjustPopCounts(0, -1); } else { world.set(id, Predator, { ...pred, energy: e, hunger: Math.max(0, pred.hunger - dt*0.5) }); } @@ -361,6 +416,10 @@

🌿 Ecosystem Food-Web Sandbox 🌌

world.mutate(id, Trail, t => { t.ttl -= dt; }); if(trail.ttl <= 0) world.remove(id, Trail); } + for(const [id, beacon] of world.query(SpawnBeacon)){ + world.mutate(id, SpawnBeacon, b => { b.ttl -= dt; }); + if(beacon.ttl <= 0) world.destroy(id); + } } function balanceSystem(world, _dt){ @@ -402,47 +461,63 @@

🌿 Ecosystem Food-Web Sandbox 🌌

} ctx.restore(); - if(UI.showHeat.checked){ - for(let y=0;y22 && ((x+y)%5===0)){ - ctx.save(); ctx.globalAlpha = 0.6; ctx.font = `${Math.min(cw,ch)*0.9}px "Apple Color Emoji","Noto Color Emoji"`; ctx.fillText(EMOJI.plant, x*cw+cw*0.1, y*ch+ch*0.88); ctx.restore(); - } - if(carr>0.5 && ((x+y)%7===0)){ - ctx.save(); ctx.globalAlpha = 0.5; ctx.font = `${Math.min(cw,ch)*0.8}px "Apple Color Emoji","Noto Color Emoji"`; ctx.fillText(EMOJI.carrion, x*cw+cw*0.2, y*ch+ch*0.82); ctx.restore(); - } + // RTS board tiles (lanes & resources) + for(let y=0;y0.02){ + ctx.fillStyle = `rgba(120,255,160,${0.35+0.4*richness})`; + ctx.fillRect(x*cw, (y+0.7)*ch, cw*richness, ch*0.25); + } + if(decay>0.02){ + ctx.fillStyle = `rgba(255,180,120,${0.25+0.35*decay})`; + ctx.fillRect(x*cw+cw*(1-decay), y*ch+ch*0.05, cw*decay, ch*0.18); } } if(UI.showContours.checked){ - ctx.strokeStyle = 'rgba(0,255,205,0.08)'; ctx.lineWidth=1; for(let x=0;x<=biome.cols;x+=8){ ctx.beginPath(); ctx.moveTo(x*cw,0); ctx.lineTo(x*cw,cv.height); ctx.stroke(); } - ctx.strokeStyle = 'rgba(120,160,255,0.08)'; for(let y=0;y<=biome.rows;y+=8){ ctx.beginPath(); ctx.moveTo(0,y*ch); ctx.lineTo(cv.width,y*ch); ctx.stroke(); } + ctx.strokeStyle = 'rgba(0,255,205,0.12)'; ctx.lineWidth=1; for(let x=0;x<=biome.cols;x+=8){ ctx.beginPath(); ctx.moveTo(x*cw,0); ctx.lineTo(x*cw,cv.height); ctx.stroke(); } + ctx.strokeStyle = 'rgba(120,160,255,0.12)'; for(let y=0;y<=biome.rows;y+=8){ ctx.beginPath(); ctx.moveTo(0,y*ch); ctx.lineTo(cv.width,y*ch); ctx.stroke(); } } - if(UI.showSignals.checked){ - for(const [id, pos, sig] of world.query(Position, Signal)){ - const r = (2+sig.ttl*4); const hue = sig.label==='drought'?28:300; const glyph = sig.label==='drought'?EMOJI.drought:EMOJI.flare; - const grad = ctx.createRadialGradient((pos.x+0.5)*cw,(pos.y+0.5)*ch,2,(pos.x+0.5)*cw,(pos.y+0.5)*ch,r*4); - grad.addColorStop(0, `hsla(${hue},90%,70%,0.5)`); grad.addColorStop(1, 'rgba(0,0,0,0)'); - ctx.fillStyle = grad; ctx.fillRect((pos.x-4)*cw, (pos.y-4)*ch, cw*9, ch*9); - ctx.strokeStyle = `hsla(${hue},100%,70%,0.8)`; ctx.lineWidth=1.5; ctx.beginPath(); ctx.arc((pos.x+0.5)*cw,(pos.y+0.5)*ch,r,0,Math.PI*2); ctx.stroke(); - ctx.font = `${Math.min(cw,ch)*1.1}px "Apple Color Emoji","Noto Color Emoji"`; ctx.fillStyle='rgba(255,255,255,0.9)'; ctx.fillText(glyph,(pos.x+0.1)*cw,(pos.y+0.9)*ch); - } + // Signals and rally beacons + for(const [id, pos, sig] of world.query(Position, Signal)){ + const r = (2+sig.ttl*4); const hue = sig.label==='drought'?28:300; const glyph = sig.label==='drought'?EMOJI.drought:EMOJI.flare; + const grad = ctx.createRadialGradient((pos.x+0.5)*cw,(pos.y+0.5)*ch,2,(pos.x+0.5)*cw,(pos.y+0.5)*ch,r*4); + grad.addColorStop(0, `hsla(${hue},90%,70%,0.5)`); grad.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = grad; ctx.fillRect((pos.x-3)*cw, (pos.y-3)*ch, cw*7, ch*7); + ctx.strokeStyle = `hsla(${hue},100%,70%,0.8)`; ctx.lineWidth=1.5; ctx.beginPath(); ctx.arc((pos.x+0.5)*cw,(pos.y+0.5)*ch,r,0,Math.PI*2); ctx.stroke(); + ctx.font = `${Math.min(cw,ch)*1.1}px "Apple Color Emoji","Noto Color Emoji"`; ctx.fillStyle='rgba(255,255,255,0.9)'; ctx.fillText(glyph,(pos.x+0.08)*cw,(pos.y+0.9)*ch); + } + for(const [id, pos, beacon] of world.query(Position, SpawnBeacon)){ + const alpha = Math.max(0, beacon.ttl/2); + ctx.strokeStyle = `hsla(${beacon.hue},95%,65%,${0.8*alpha})`; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc((pos.x+0.5)*cw,(pos.y+0.5)*ch, cw*0.55, 0, Math.PI*2); ctx.stroke(); } + // Trails and command arrows if(UI.showTrails.checked){ for(const [id, pos, trail] of world.query(Position, Trail)){ ctx.fillStyle = `hsla(${trail.hue},90%,70%,${Math.max(0, trail.ttl/12)})`; - ctx.shadowColor = `hsla(${trail.hue},100%,70%,0.6)`; ctx.shadowBlur = 10; + ctx.shadowColor = `hsla(${trail.hue},100%,70%,0.6)`; ctx.shadowBlur = 8; ctx.fillRect(pos.x*cw, pos.y*ch, cw, ch); } ctx.shadowBlur = 0; ctx.shadowColor='transparent'; } + // Units (emoji + health bars) ctx.font = `${Math.min(cw,ch)*0.95}px "Apple Color Emoji","Noto Color Emoji"`; - for(const [id, pos] of world.query(Position, Herbivore)){ + for(const [id, pos, herb] of world.query(Position, Herbivore)){ + const energyBar = Math.max(0, Math.min(1, herb.energy/64)); + ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(pos.x*cw, pos.y*ch+ch*0.05, cw, ch*0.25); + ctx.fillStyle = COLORS.herb; ctx.fillRect(pos.x*cw, pos.y*ch+ch*0.05, cw*energyBar, ch*0.25); ctx.save(); ctx.shadowColor='rgba(120,255,180,0.65)'; ctx.shadowBlur=12; ctx.fillText(EMOJI.herb[id % EMOJI.herb.length],(pos.x+0.06)*cw,(pos.y+0.9)*ch); ctx.restore(); } - for(const [id, pos] of world.query(Position, Predator)){ + for(const [id, pos, pred] of world.query(Position, Predator)){ + const energyBar = Math.max(0, Math.min(1, pred.energy/90)); + ctx.fillStyle = 'rgba(0,0,0,0.4)'; ctx.fillRect(pos.x*cw, pos.y*ch+ch*0.65, cw, ch*0.25); + ctx.fillStyle = COLORS.pred; ctx.fillRect(pos.x*cw, pos.y*ch+ch*0.65, cw*energyBar, ch*0.25); ctx.save(); ctx.shadowColor='rgba(255,120,120,0.75)'; ctx.shadowBlur=14; ctx.fillText(EMOJI.pred[id % EMOJI.pred.length],(pos.x+0.05)*cw,(pos.y+0.92)*ch); ctx.restore(); } From 62dc77a549e83c8b3fce65ce92b247a48488d4b7 Mon Sep 17 00:00:00 2001 From: Pete Jensen Date: Sat, 22 Nov 2025 16:00:40 -0500 Subject: [PATCH 06/17] Give food-web fluid vectors and circular units --- demo/food-web.html | 217 ++++++++++++++++++++++++++++++++------------- 1 file changed, 153 insertions(+), 64 deletions(-) diff --git a/demo/food-web.html b/demo/food-web.html index 57bf616..701f4ea 100644 --- a/demo/food-web.html +++ b/demo/food-web.html @@ -54,7 +54,7 @@

🌿 Ecosystem Food-Web Sandbox 🌌

Display
- + @@ -123,9 +123,8 @@

🌿 Ecosystem Food-Web Sandbox 🌌

})); const COLORS = { - grid: '#0f1926', - laneA: '#1b2c3d', - laneB: '#132031', + skyTop: '#081228', + skyBottom: '#050912', herb: '#74ffcf', pred: '#ff8c7a', plant: '#6ad96b', @@ -267,7 +266,7 @@

🌿 Ecosystem Food-Web Sandbox 🌌

function spawnHerb(){ const [meta, biome, field] = world.query(Biome, Field)[Symbol.iterator]().next().value || []; if (!biome) return; - const pos = { x:(world.rand()*biome.cols)|0, y:(world.rand()*biome.rows)|0 }; + const pos = { x: world.rand()*biome.cols, y: world.rand()*biome.rows }; const id = world.create(); world.add(id, Position, pos); world.add(id, Velocity, { dx:0, dy:0 }); @@ -283,7 +282,7 @@

🌿 Ecosystem Food-Web Sandbox 🌌

function spawnPred(){ const [meta, biome] = world.query(Biome)[Symbol.iterator]().next().value || []; if (!biome) return; - const pos = { x:(world.rand()*biome.cols)|0, y:(world.rand()*biome.rows)|0 }; + const pos = { x: world.rand()*biome.cols, y: world.rand()*biome.rows }; const id = world.create(); world.add(id, Position, pos); world.add(id, Velocity, { dx:0, dy:0 }); @@ -325,60 +324,102 @@

🌿 Ecosystem Food-Web Sandbox 🌌

} } -function chooseStep(world, x, y, role){ - const [bid, biome, field, season] = world.query(Biome, Field, Season)[Symbol.iterator]().next().value || []; - if (!biome) return [x,y]; - const { cols, rows } = biome; let best=[x,y], bestScore=-1e9; - for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++){ - const nx = (x+dx+cols)%cols, ny=(y+dy+rows)%rows; const i=idx(nx,ny,cols,rows); - const plant = field.plants[i]; const carr = field.carrion[i]; const sc = field.scent[i]; - let score = role==='herb' ? plant - 0.2*carr + (sc*0.1) : carr*0.6 + plant*0.05 + sc*0.5; - score += (world.rand()*0.25); - if(score>bestScore){ bestScore=score; best=[nx,ny]; } - } - return best; +const HERB_SPEED = 5.4; +const PRED_SPEED = 6.5; + +function sampleFieldAt(field, biome, x, y){ + const { cols, rows } = biome; + const gx = ((Math.floor(x)%cols)+cols)%cols; + const gy = ((Math.floor(y)%rows)+rows)%rows; + const i = idx(gx, gy, cols, rows); + return { plant: field.plants[i], carr: field.carrion[i], scent: field.scent[i], i }; +} + +function wrapPos(pos, biome){ + pos.x = (pos.x + biome.cols) % biome.cols; + pos.y = (pos.y + biome.rows) % biome.rows; } function senseSystem(world, dt){ + const [bid, biome, field] = world.query(Biome, Field)[Symbol.iterator]().next().value || []; + if(!biome) return; + const predators = [...world.query(Position, Predator)].map(([,p])=>p); + const herbs = [...world.query(Position, Herbivore)].map(([,p])=>p); + for(const [id, herb] of world.query(Herbivore)){ - const pos = world.get(id, Position); const [nx,ny] = chooseStep(world,pos.x,pos.y,'herb'); - world.set(id, Velocity, { dx:nx-pos.x, dy:ny-pos.y }); + const pos = world.get(id, Position); + let bestScore = -1e9; let dir = [Math.cos(world.rand()*Math.PI*2), Math.sin(world.rand()*Math.PI*2)]; + for(let k=0;k<10;k++){ + const ang = (k/10)*Math.PI*2 + world.rand()*0.2; + const rx = pos.x + Math.cos(ang)*4.5; + const ry = pos.y + Math.sin(ang)*4.5; + const s = sampleFieldAt(field, biome, rx, ry); + let fear = 0; + for(const p of predators){ const dx=p.x-pos.x, dy=p.y-pos.y; const d2=dx*dx+dy*dy; if(d2<36) fear += (36-d2)*0.12; } + const score = s.plant*0.8 - s.carr*0.35 + s.scent*0.12 - fear + world.rand()*0.6; + if(score > bestScore){ bestScore = score; dir = [Math.cos(ang), Math.sin(ang)]; } + } + const jitter = (world.rand()-0.5)*0.6; + const vx = dir[0] + jitter; + const vy = dir[1] + (world.rand()-0.5)*0.6; + const mag = Math.max(0.01, Math.hypot(vx, vy)); + world.set(id, Velocity, { dx: (vx/mag)*HERB_SPEED, dy: (vy/mag)*HERB_SPEED }); } + for(const [id, pred] of world.query(Predator)){ const pos = world.get(id, Position); - let nearest=null, dist=9e9; - for(const [hid, hpos] of world.query(Position, Herbivore)){ - const dx=hpos.x-pos.x, dy=hpos.y-pos.y; const d=dx*dx+dy*dy; if(dbestScore){ bestScore=score; best=[Math.cos(ang), Math.sin(ang)]; } + } + dir = best; } - if(nearest){ const [nx,ny]=chooseStep(world,pos.x,pos.y,'pred'); world.set(id, Velocity, { dx: Math.sign(nearest.x-pos.x)||nx-pos.x, dy: Math.sign(nearest.y-pos.y)||ny-pos.y }); } + const mag = Math.max(0.01, Math.hypot(dir[0], dir[1])); + world.set(id, Velocity, { dx: (dir[0]/mag)*PRED_SPEED, dy: (dir[1]/mag)*PRED_SPEED }); } } function actSystem(world, dt){ + const [bid, biome] = world.query(Biome)[Symbol.iterator]().next().value || []; for(const [id, pos, vel] of world.query(Position, Velocity)){ - pos.x += vel.dx; pos.y += vel.dy; const [bid, biome] = world.query(Biome)[Symbol.iterator]().next().value || []; - if(biome){ pos.x = (pos.x+biome.cols)%biome.cols; pos.y=(pos.y+biome.rows)%biome.rows; } + if(biome){ + pos.x += vel.dx * dt * 6; + pos.y += vel.dy * dt * 6; + wrapPos(pos, biome); + } } for(const [id, pos, herb] of world.query(Position, Herbivore)){ - const [bid, biome, field] = world.query(Biome, Field)[Symbol.iterator]().next().value || []; - if(!biome) continue; const i=idx(pos.x,pos.y,biome.cols,biome.rows); - const bite = Math.min(field.plants[i], 2.5); - field.plants[i]-=bite; field.scent[i]+=0.5; + const [bid2, biome2, field] = world.query(Biome, Field)[Symbol.iterator]().next().value || []; + if(!biome2) continue; const sample = sampleFieldAt(field, biome2, pos.x, pos.y); + const bite = Math.min(sample.plant, 2.5); + field.plants[sample.i]-=bite; field.scent[sample.i]+=0.5; world.set(id, Herbivore, { ...herb, energy: herb.energy + bite*0.7, lastMeal: 0, gestate: herb.gestate + dt*bite*0.3 }); - world.set(id, Trail, { ttl: 10, hue: 120 + 60*Math.random() }); + world.set(id, Trail, { ttl: 12, hue: 120 + 60*Math.random() }); if(herb.energy > 64 && herb.gestate > 6){ world.set(id, Herbivore, { ...herb, energy: herb.energy*0.5, gestate:0, lastMeal:0 }); spawnHerb(); } } for(const [id, pos, pred] of world.query(Position, Predator)){ - const [bid, biome, field] = world.query(Biome, Field)[Symbol.iterator]().next().value || []; - if(!biome) continue; const i=idx(pos.x,pos.y,biome.cols,biome.rows); + const [bid3, biome3, field] = world.query(Biome, Field)[Symbol.iterator]().next().value || []; + if(!biome3) continue; const sample = sampleFieldAt(field, biome3, pos.x, pos.y); let ate=false; for(const [hid, hpos] of world.query(Position, Herbivore)){ - if(hpos.x===pos.x && hpos.y===pos.y){ world.destroy(hid); adjustPopCounts(-1,0); field.carrion[i]+=2.5; ate=true; break; } + if(Math.hypot(hpos.x-pos.x, hpos.y-pos.y) < 0.4){ world.destroy(hid); adjustPopCounts(-1,0); field.carrion[sample.i]+=2.5; ate=true; break; } } const gain = ate? 8 : 0; world.set(id, Predator, { ...pred, energy: pred.energy + gain, hunger: pred.hunger + dt*(ate? -2:1) }); - field.scent[i]+=1.2; - world.set(id, Trail, { ttl: 10, hue: 12 }); + field.scent[sample.i]+=1.2; + world.set(id, Trail, { ttl: 12, hue: 12 }); if(pred.energy>90){ world.set(id, Predator, { ...pred, energy: pred.energy*0.6, hunger:0 }); spawnPred(); } } for(const [id, sig] of world.query(Signal)){ @@ -448,9 +489,19 @@

🌿 Ecosystem Food-Web Sandbox 🌌

if(!biome) return; const cw = cv.width / biome.cols, ch = cv.height / biome.rows; const t = world.time; const sky = ctx.createLinearGradient(0,0,0,cv.height); - sky.addColorStop(0, '#061020'); sky.addColorStop(1, '#090c18'); + sky.addColorStop(0, COLORS.skyTop); sky.addColorStop(1, COLORS.skyBottom); ctx.fillStyle = sky; ctx.fillRect(0,0,cv.width,cv.height); + // Aurora ribbons + ctx.save(); + ctx.globalCompositeOperation = 'screen'; + const aurora = ctx.createLinearGradient(0,0,cv.width,cv.height); + aurora.addColorStop(0, 'rgba(80,180,255,0.12)'); + aurora.addColorStop(1, 'rgba(180,120,255,0.14)'); + ctx.fillStyle = aurora; + ctx.beginPath(); ctx.moveTo(0,cv.height*0.25); ctx.quadraticCurveTo(cv.width*0.35, cv.height*0.15, cv.width, cv.height*0.28); ctx.lineTo(cv.width, cv.height*0.45); ctx.quadraticCurveTo(cv.width*0.4, cv.height*0.35, 0, cv.height*0.5); ctx.closePath(); ctx.fill(); + ctx.restore(); + ctx.save(); ctx.globalAlpha = 0.25; ctx.font = `${Math.max(cw,ch)*0.9}px "Apple Color Emoji","Noto Color Emoji"`; @@ -461,26 +512,51 @@

🌿 Ecosystem Food-Web Sandbox 🌌

} ctx.restore(); - // RTS board tiles (lanes & resources) - for(let y=0;y0.02){ - ctx.fillStyle = `rgba(120,255,160,${0.35+0.4*richness})`; - ctx.fillRect(x*cw, (y+0.7)*ch, cw*richness, ch*0.25); + // Biomass nebula: floating blobs for plants + carrion + if(UI.showHeat.checked){ + ctx.save(); + ctx.globalCompositeOperation='lighter'; + for(let y=0;y1){ + const px=(x+0.5)*cw, py=(y+0.5)*ch; + const r = Math.min(cw,ch) * (0.5 + Math.sqrt(plant)*0.12); + const g = ctx.createRadialGradient(px,py,0,px,py,r*1.4); + g.addColorStop(0, `rgba(90,255,150,0.28)`); + g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle=g; ctx.beginPath(); ctx.arc(px,py,r*1.2,0,Math.PI*2); ctx.fill(); + } + const carr = field.carrion[i]; + if(carr>0.6){ + const px=(x+0.5)*cw, py=(y+0.5)*ch; + const r = Math.min(cw,ch) * (0.35 + Math.sqrt(carr)*0.15); + const g = ctx.createRadialGradient(px,py,0,px,py,r*1.6); + g.addColorStop(0, 'rgba(255,190,130,0.35)'); g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle=g; ctx.beginPath(); ctx.arc(px,py,r,0,Math.PI*2); ctx.fill(); + } + } } - if(decay>0.02){ - ctx.fillStyle = `rgba(255,180,120,${0.25+0.35*decay})`; - ctx.fillRect(x*cw+cw*(1-decay), y*ch+ch*0.05, cw*decay, ch*0.18); + ctx.restore(); + // sprinkle visible sprites for biomass + ctx.save(); + ctx.font = `${Math.min(cw,ch)*0.85}px "Apple Color Emoji","Noto Color Emoji"`; + for(let y=0;y5 && world.rand()>0.45){ ctx.fillText(EMOJI.plant, x*cw, y*ch); } + if(carr>2.5 && world.rand()>0.65){ ctx.fillText(EMOJI.carrion, x*cw, y*ch); } + } } + ctx.restore(); } if(UI.showContours.checked){ - ctx.strokeStyle = 'rgba(0,255,205,0.12)'; ctx.lineWidth=1; for(let x=0;x<=biome.cols;x+=8){ ctx.beginPath(); ctx.moveTo(x*cw,0); ctx.lineTo(x*cw,cv.height); ctx.stroke(); } - ctx.strokeStyle = 'rgba(120,160,255,0.12)'; for(let y=0;y<=biome.rows;y+=8){ ctx.beginPath(); ctx.moveTo(0,y*ch); ctx.lineTo(cv.width,y*ch); ctx.stroke(); } + ctx.strokeStyle = 'rgba(90,140,255,0.16)'; ctx.lineWidth=2; + ctx.beginPath(); ctx.ellipse(cv.width*0.5, cv.height*0.45, cv.width*0.44, cv.height*0.28, 0, 0, Math.PI*2); ctx.stroke(); + ctx.beginPath(); ctx.ellipse(cv.width*0.4, cv.height*0.65, cv.width*0.36, cv.height*0.2, 0, 0, Math.PI*2); ctx.stroke(); } // Signals and rally beacons for(const [id, pos, sig] of world.query(Position, Signal)){ @@ -500,25 +576,38 @@

🌿 Ecosystem Food-Web Sandbox 🌌

// Trails and command arrows if(UI.showTrails.checked){ for(const [id, pos, trail] of world.query(Position, Trail)){ + const r = Math.min(cw,ch)*0.55; ctx.fillStyle = `hsla(${trail.hue},90%,70%,${Math.max(0, trail.ttl/12)})`; - ctx.shadowColor = `hsla(${trail.hue},100%,70%,0.6)`; ctx.shadowBlur = 8; - ctx.fillRect(pos.x*cw, pos.y*ch, cw, ch); + ctx.shadowColor = `hsla(${trail.hue},100%,70%,0.55)`; ctx.shadowBlur = 12; + ctx.beginPath(); ctx.arc((pos.x+0.2)*cw, (pos.y+0.2)*ch, r, 0, Math.PI*2); ctx.fill(); } ctx.shadowBlur = 0; ctx.shadowColor='transparent'; } - // Units (emoji + health bars) + // Units (emoji + bounding circles) ctx.font = `${Math.min(cw,ch)*0.95}px "Apple Color Emoji","Noto Color Emoji"`; for(const [id, pos, herb] of world.query(Position, Herbivore)){ - const energyBar = Math.max(0, Math.min(1, herb.energy/64)); - ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(pos.x*cw, pos.y*ch+ch*0.05, cw, ch*0.25); - ctx.fillStyle = COLORS.herb; ctx.fillRect(pos.x*cw, pos.y*ch+ch*0.05, cw*energyBar, ch*0.25); - ctx.save(); ctx.shadowColor='rgba(120,255,180,0.65)'; ctx.shadowBlur=12; ctx.fillText(EMOJI.herb[id % EMOJI.herb.length],(pos.x+0.06)*cw,(pos.y+0.9)*ch); ctx.restore(); + const energy = Math.max(0, Math.min(1, herb.energy/64)); + const r = Math.min(cw,ch)*0.58; + const px = pos.x*cw, py = pos.y*ch; + ctx.save(); + ctx.shadowColor='rgba(120,255,180,0.5)'; ctx.shadowBlur=18; + ctx.strokeStyle=`rgba(100,255,200,${0.5+0.4*energy})`; ctx.lineWidth=3; ctx.beginPath(); ctx.arc(px, py, r*0.9, 0, Math.PI*2); ctx.stroke(); + ctx.strokeStyle=`rgba(0,0,0,0.7)`; ctx.lineWidth=6; ctx.beginPath(); ctx.arc(px, py, r*0.7, -Math.PI/2, -Math.PI/2 + Math.PI*2*energy); ctx.stroke(); + ctx.fillStyle='rgba(0,0,0,0.32)'; ctx.beginPath(); ctx.arc(px, py, r*0.65, 0, Math.PI*2); ctx.fill(); + ctx.fillText(EMOJI.herb[id % EMOJI.herb.length], px - r*0.4, py + r*0.4); + ctx.restore(); } for(const [id, pos, pred] of world.query(Position, Predator)){ - const energyBar = Math.max(0, Math.min(1, pred.energy/90)); - ctx.fillStyle = 'rgba(0,0,0,0.4)'; ctx.fillRect(pos.x*cw, pos.y*ch+ch*0.65, cw, ch*0.25); - ctx.fillStyle = COLORS.pred; ctx.fillRect(pos.x*cw, pos.y*ch+ch*0.65, cw*energyBar, ch*0.25); - ctx.save(); ctx.shadowColor='rgba(255,120,120,0.75)'; ctx.shadowBlur=14; ctx.fillText(EMOJI.pred[id % EMOJI.pred.length],(pos.x+0.05)*cw,(pos.y+0.92)*ch); ctx.restore(); + const energy = Math.max(0, Math.min(1, pred.energy/90)); + const r = Math.min(cw,ch)*0.6; + const px = pos.x*cw, py = pos.y*ch; + ctx.save(); + ctx.shadowColor='rgba(255,120,120,0.65)'; ctx.shadowBlur=18; + ctx.strokeStyle=`rgba(255,140,120,${0.55+0.35*energy})`; ctx.lineWidth=3; ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI*2); ctx.stroke(); + ctx.strokeStyle='rgba(0,0,0,0.8)'; ctx.lineWidth=7; ctx.beginPath(); ctx.arc(px, py, r*0.74, -Math.PI/2, -Math.PI/2 + Math.PI*2*energy); ctx.stroke(); + ctx.fillStyle='rgba(0,0,0,0.38)'; ctx.beginPath(); ctx.arc(px, py, r*0.7, 0, Math.PI*2); ctx.fill(); + ctx.fillText(EMOJI.pred[id % EMOJI.pred.length], px - r*0.42, py + r*0.25); + ctx.restore(); } UI.out.t.textContent = world.time.toFixed(2); From 38a9d16bc31e7a09b5feac87a9f484a6f67f3e5f Mon Sep 17 00:00:00 2001 From: Pete Jensen Date: Sat, 22 Nov 2025 16:06:57 -0500 Subject: [PATCH 07/17] Reimagine food-web demo as RTS biome --- demo/food-web.html | 311 ++++++++++++++++++++++++++------------------- 1 file changed, 183 insertions(+), 128 deletions(-) diff --git a/demo/food-web.html b/demo/food-web.html index 701f4ea..d41b5d9 100644 --- a/demo/food-web.html +++ b/demo/food-web.html @@ -1,30 +1,33 @@ -ecs-js — Ecosystem Food-Web Sandbox +ecs-js — Wildlands RTS
-

🌿 Ecosystem Food-Web Sandbox 🌌

-

Plants, herbivores, and predators interact across a seasonal biome. Scripts orchestrate climate shocks; phases keep the causal order explicit.

+

🐾 Wildlands Command 🌌

+

An RTS-flavored biome: plants bloom, grazers sweep in flocks, and predators prowl with command arrows. Seasonal scripts strike at random.

@@ -34,30 +37,30 @@

🌿 Ecosystem Food-Web Sandbox 🌌

-
Biome
+
Biome tuning
- + - + - +
Populations
- + - + - +
Display
- - - - + + + +
⏱️ Time0.00 @@ -71,7 +74,7 @@

🌿 Ecosystem Food-Web Sandbox 🌌

Event log
-
Phases: climate → grow → sense → act → decay → render → scripts. Scripts inject seasonal shocks and territory beacons.
+
Phases: climate → grow → spread → sense → act → decay → render → scripts. Scripts inject seasonal shocks and rally pings.
@@ -81,21 +84,20 @@

🌿 Ecosystem Food-Web Sandbox 🌌

defineComponent, defineTag, defineArchetype, - createFrom, PHASE_SCRIPTS, createRealtimeRafLoop, } from '../index.js'; -const DEFAULT_SEED = 0xFEEDBEEF >>> 0; +const DEFAULT_SEED = 0xF00DF11D >>> 0; // ---------- Components ---------- -const Biome = defineComponent('Biome', { cols: 64, rows: 42, rainfall: 0.8, sunlight: 1.0, swing: 0.4 }); +const Biome = defineComponent('Biome', { cols: 96, rows: 60, rainfall: 0.9, sunlight: 1.1, swing: 0.45 }); const Field = defineComponent('Field', { plants: null, carrion: null, scent: null }); -const Season = defineComponent('Season', { t: 0, temp: 0, rainPulse: 1, drought: 0, flare: 0, cols: 64, rows: 42 }); +const Season = defineComponent('Season', { t: 0, temp: 0, rainPulse: 1, drought: 0, flare: 0, cols: 96, rows: 60 }); const Position = defineComponent('Position', { x: 0, y: 0 }); const Velocity = defineComponent('Velocity', { dx: 0, dy: 0 }); -const Herbivore = defineComponent('Herbivore', { energy: 32, gestate: 0, lastMeal: 0 }); -const Predator = defineComponent('Predator', { energy: 46, hunger: 0, target: -1 }); +const Herbivore = defineComponent('Herbivore', { energy: 32, gestate: 0, lastMeal: 0, heading: 0 }); +const Predator = defineComponent('Predator', { energy: 46, hunger: 0, target: -1, heading: 0 }); const Trail = defineComponent('Trail', { ttl: 12, hue: 180 }); const Signal = defineComponent('Signal', { ttl: 3, label: 'shock', strength: 1 }); const Metrics = defineComponent('Metrics', { plants: 0, herb: 0, pred: 0, events: 0 }); @@ -115,7 +117,7 @@

🌿 Ecosystem Food-Web Sandbox 🌌

spark: ['✨','💫','❇️','🌟'], }; -const SKY_EMOJI = Array.from({ length: 36 }, () => ({ +const SKY_EMOJI = Array.from({ length: 42 }, () => ({ x: Math.random(), y: Math.random(), glyph: EMOJI.spark[(Math.random() * EMOJI.spark.length) | 0], @@ -123,10 +125,10 @@

🌿 Ecosystem Food-Web Sandbox 🌌

})); const COLORS = { - skyTop: '#081228', - skyBottom: '#050912', - herb: '#74ffcf', - pred: '#ff8c7a', + skyTop: '#071225', + skyBottom: '#050910', + herb: '#7bffd9', + pred: '#ff9b82', plant: '#6ad96b', carrion: '#d9b36a', }; @@ -172,7 +174,7 @@

🌿 Ecosystem Food-Web Sandbox 🌌

const CV = (()=>{ const cv = document.getElementById('cv'); const ctx = cv.getContext('2d'); function fit(){ const dpr = window.devicePixelRatio||1; cv.width=cv.clientWidth*dpr; cv.height=cv.clientHeight*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); } window.addEventListener('resize', fit); fit(); return { cv, ctx }; })(); -const idx = (x,y,cols,rows)=>(( (y+rows)%rows)*cols + ((x+cols)%cols)); +const idx = (x,y,cols,rows)=>(((Math.floor(y+rows)%rows)*cols) + ((Math.floor(x+cols)%cols))); const pick = (arr, rand=Math.random)=>arr[(rand()*arr.length)|0]; function log(msg){ const time = new Date().toLocaleTimeString(); UI.log.textContent = `[${time}] ${msg}\n` + UI.log.textContent.slice(0, 2400); } @@ -208,16 +210,17 @@

🌿 Ecosystem Food-Web Sandbox 🌌

function seedField(cols, rows, rainfall, sunlight, rand){ const n = cols*rows; const plants = new Float32Array(n); const carrion = new Float32Array(n); const scent = new Float32Array(n); - for(let i=0;i🌿 Ecosystem Food-Web Sandbox 🌌 const sid = world.create(); world.add(sid, Signal, { ttl:6, label:'drought', strength:1.2 }); world.add(sid, Beacon); - world.add(sid, Position, { x:(world.rand()*season.cols)|0, y:(world.rand()*season.rows)|0 }); + world.add(sid, Position, { x:(world.rand()*season.cols), y:(world.rand()*season.rows) }); } if (season.flare <= 0 && world.rand() < 0.0012 * dt) { season.flare = 5 + world.rand()*8; world.emit('shock', { msg: `${EMOJI.flare} Solar flare — predators sluggish` }); const sid = world.create(); world.add(sid, Signal, { ttl:4, label:'flare', strength:1.4 }); - world.add(sid, Position, { x:(world.rand()*season.cols)|0, y:(world.rand()*season.rows)|0 }); + world.add(sid, Position, { x:(world.rand()*season.cols), y:(world.rand()*season.rows) }); } } }); }); const biome = world.create(); - const cols = 64, rows = 42; + const cols = 96, rows = 60; world.add(biome, Biome, { cols, rows, rainfall: +UI.rain.value, sunlight: +UI.sun.value, swing: +UI.swing.value }); const { plants, carrion, scent } = seedField(cols, rows, +UI.rain.value, +UI.sun.value, world.rand); world.add(biome, Field, { plants, carrion, scent }); @@ -270,12 +273,12 @@

🌿 Ecosystem Food-Web Sandbox 🌌

const id = world.create(); world.add(id, Position, pos); world.add(id, Velocity, { dx:0, dy:0 }); - world.add(id, Herbivore, { energy:32, gestate:0, lastMeal:0 }); - world.add(id, Trail, { ttl: 8, hue: 120 + 40*world.rand() }); + world.add(id, Herbivore, { energy:32, gestate:0, lastMeal:0, heading: world.rand()*Math.PI*2 }); + world.add(id, Trail, { ttl: 10, hue: 120 + 40*world.rand() }); world.emit('spawn', { msg:`${pick(EMOJI.herb, world.rand)} Herbivore deployed` }); adjustPopCounts(1, 0); const beacon = world.create(); - world.add(beacon, SpawnBeacon, { ttl: 1.5, hue: 110 }); + world.add(beacon, SpawnBeacon, { ttl: 1.6, hue: 110 }); world.add(beacon, Position, pos); return id; } @@ -286,12 +289,12 @@

🌿 Ecosystem Food-Web Sandbox 🌌

const id = world.create(); world.add(id, Position, pos); world.add(id, Velocity, { dx:0, dy:0 }); - world.add(id, Predator, { energy:46, hunger:0, target:-1 }); - world.add(id, Trail, { ttl: 8, hue: 12 }); + world.add(id, Predator, { energy:46, hunger:0, target:-1, heading: world.rand()*Math.PI*2 }); + world.add(id, Trail, { ttl: 10, hue: 12 }); world.emit('spawn', { msg:`${pick(EMOJI.pred, world.rand)} Predator deployed` }); adjustPopCounts(0, 1); const beacon = world.create(); - world.add(beacon, SpawnBeacon, { ttl: 1.5, hue: 20 }); + world.add(beacon, SpawnBeacon, { ttl: 1.6, hue: 20 }); world.add(beacon, Position, pos); return id; } @@ -300,10 +303,10 @@

🌿 Ecosystem Food-Web Sandbox 🌌

function climateSystem(world, dt){ for(const [id, biome, field, season] of world.query(Biome, Field, Season)){ season.t += dt; - const seasonal = 0.5 + 0.5 * Math.sin(season.t * 0.15); + const seasonal = 0.5 + 0.5 * Math.sin(season.t * 0.14); season.temp = biome.sunlight * (1 + biome.swing * (seasonal - 0.5)); season.rainPulse = biome.rainfall * (1 + biome.swing * (0.5 - seasonal)); - if (season.drought > 0) season.rainPulse *= 0.35, season.drought -= dt; + if (season.drought > 0) season.rainPulse *= 0.32, season.drought -= dt; if (season.flare > 0) season.temp *= 0.4, season.flare -= dt; } } @@ -313,10 +316,10 @@

🌿 Ecosystem Food-Web Sandbox 🌌

const { cols, rows } = biome; const N = cols*rows; const drain = +UI.drain.value; let totalPlants = 0; for(let i=0;i🌿 Ecosystem Food-Web Sandbox 🌌 } } -const HERB_SPEED = 5.4; -const PRED_SPEED = 6.5; +function spreadSystem(world, dt){ + const [bid, biome, field] = world.query(Biome, Field)[Symbol.iterator]().next().value || []; + if(!biome) return; + const bursts = Math.floor(260 * dt); + for(let b=0;b 2.5){ + const ang = world.rand()*Math.PI*2; + const dist = 1 + world.rand()*3.5; + const nx = x + Math.cos(ang)*dist; + const ny = y + Math.sin(ang)*dist; + const nb = sampleFieldAt(field, biome, nx, ny); + const transfer = Math.min(center.plant*0.04, 1.6); + field.plants[center.i] = Math.max(0, field.plants[center.i] - transfer*0.6); + field.plants[nb.i] = Math.min(field.plants[nb.i] + transfer, 120); + } + if(center.carr > 1.2){ + const ang = world.rand()*Math.PI*2; + const dist = 0.5 + world.rand()*2.5; + const nx = x + Math.cos(ang)*dist; + const ny = y + Math.sin(ang)*dist; + const nb = sampleFieldAt(field, biome, nx, ny); + const bleed = Math.min(center.carr*0.08, 1.8); + field.carrion[center.i] = Math.max(0, field.carrion[center.i] - bleed*0.5); + field.carrion[nb.i] = Math.min(field.carrion[nb.i] + bleed, 90); + } + } +} + +const HERB_SPEED = 5.6; +const PRED_SPEED = 6.8; function sampleFieldAt(field, biome, x, y){ const { cols, rows } = biome; @@ -343,26 +377,28 @@

🌿 Ecosystem Food-Web Sandbox 🌌

function senseSystem(world, dt){ const [bid, biome, field] = world.query(Biome, Field)[Symbol.iterator]().next().value || []; if(!biome) return; - const predators = [...world.query(Position, Predator)].map(([,p])=>p); - const herbs = [...world.query(Position, Herbivore)].map(([,p])=>p); + const predators = [...world.query(Position, Predator)].map(([id,pos])=>({ id, pos: { ...pos } })); + const herbs = [...world.query(Position, Herbivore)].map(([id,pos])=>({ id, pos: { ...pos } })); for(const [id, herb] of world.query(Herbivore)){ const pos = world.get(id, Position); let bestScore = -1e9; let dir = [Math.cos(world.rand()*Math.PI*2), Math.sin(world.rand()*Math.PI*2)]; - for(let k=0;k<10;k++){ - const ang = (k/10)*Math.PI*2 + world.rand()*0.2; - const rx = pos.x + Math.cos(ang)*4.5; - const ry = pos.y + Math.sin(ang)*4.5; + for(let k=0;k<12;k++){ + const ang = (k/12)*Math.PI*2 + world.rand()*0.25; + const rx = pos.x + Math.cos(ang)*4.6; + const ry = pos.y + Math.sin(ang)*4.6; const s = sampleFieldAt(field, biome, rx, ry); let fear = 0; - for(const p of predators){ const dx=p.x-pos.x, dy=p.y-pos.y; const d2=dx*dx+dy*dy; if(d2<36) fear += (36-d2)*0.12; } - const score = s.plant*0.8 - s.carr*0.35 + s.scent*0.12 - fear + world.rand()*0.6; + for(const p of predators){ const dx=p.pos.x-pos.x, dy=p.pos.y-pos.y; const d2=dx*dx+dy*dy; if(d2<49) fear += (49-d2)*0.12; } + const score = s.plant*0.9 - s.carr*0.4 + s.scent*0.14 - fear + world.rand()*0.8; if(score > bestScore){ bestScore = score; dir = [Math.cos(ang), Math.sin(ang)]; } } + const inertia = 0.45; const jitter = (world.rand()-0.5)*0.6; - const vx = dir[0] + jitter; - const vy = dir[1] + (world.rand()-0.5)*0.6; + const vx = dir[0] + jitter + world.get(id, Velocity).dx*inertia; + const vy = dir[1] + (world.rand()-0.5)*0.6 + world.get(id, Velocity).dy*inertia; const mag = Math.max(0.01, Math.hypot(vx, vy)); + world.set(id, Herbivore, { ...herb, heading: Math.atan2(vy, vx) }); world.set(id, Velocity, { dx: (vx/mag)*HERB_SPEED, dy: (vy/mag)*HERB_SPEED }); } @@ -370,24 +406,30 @@

🌿 Ecosystem Food-Web Sandbox 🌌

const pos = world.get(id, Position); let closest = null, d2min = 1e9; for(const hp of herbs){ - const dx = hp.x - pos.x, dy = hp.y - pos.y; const d2 = dx*dx + dy*dy; + const dx = hp.pos.x - pos.x, dy = hp.pos.y - pos.y; const d2 = dx*dx + dy*dy; if(d2 < d2min){ d2min = d2; closest = hp; } } let dir; if(closest){ - dir = [closest.x - pos.x, closest.y - pos.y]; + dir = [closest.pos.x - pos.x, closest.pos.y - pos.y]; + world.mutate(id, Predator, p => { p.target = closest.id; }); } else { let best=[Math.cos(world.rand()*Math.PI*2), Math.sin(world.rand()*Math.PI*2)]; let bestScore=-1e9; - for(let k=0;k<8;k++){ - const ang = (k/8)*Math.PI*2 + world.rand()*0.3; const rx=pos.x+Math.cos(ang)*5; const ry=pos.y+Math.sin(ang)*5; + for(let k=0;k<10;k++){ + const ang = (k/10)*Math.PI*2 + world.rand()*0.3; const rx=pos.x+Math.cos(ang)*5.5; const ry=pos.y+Math.sin(ang)*5.5; const s = sampleFieldAt(field, biome, rx, ry); - const score = s.scent*0.8 + s.carr*0.5 + world.rand()*0.3; + const score = s.scent*0.85 + s.carr*0.55 + world.rand()*0.35; if(score>bestScore){ bestScore=score; best=[Math.cos(ang), Math.sin(ang)]; } } dir = best; + world.mutate(id, Predator, p => { p.target = -1; }); } - const mag = Math.max(0.01, Math.hypot(dir[0], dir[1])); - world.set(id, Velocity, { dx: (dir[0]/mag)*PRED_SPEED, dy: (dir[1]/mag)*PRED_SPEED }); + const inertia = 0.35; + const vx = dir[0] + world.get(id, Velocity).dx*inertia; + const vy = dir[1] + world.get(id, Velocity).dy*inertia; + const mag = Math.max(0.01, Math.hypot(vx, vy)); + world.set(id, Predator, { ...pred, heading: Math.atan2(vy, vx) }); + world.set(id, Velocity, { dx: (vx/mag)*PRED_SPEED, dy: (vy/mag)*PRED_SPEED }); } } @@ -405,8 +447,8 @@

🌿 Ecosystem Food-Web Sandbox 🌌

if(!biome2) continue; const sample = sampleFieldAt(field, biome2, pos.x, pos.y); const bite = Math.min(sample.plant, 2.5); field.plants[sample.i]-=bite; field.scent[sample.i]+=0.5; - world.set(id, Herbivore, { ...herb, energy: herb.energy + bite*0.7, lastMeal: 0, gestate: herb.gestate + dt*bite*0.3 }); - world.set(id, Trail, { ttl: 12, hue: 120 + 60*Math.random() }); + world.set(id, Herbivore, { ...herb, energy: herb.energy + bite*0.7, lastMeal: 0, gestate: herb.gestate + dt*bite*0.35 }); + world.set(id, Trail, { ttl: 14, hue: 120 + 60*Math.random() }); if(herb.energy > 64 && herb.gestate > 6){ world.set(id, Herbivore, { ...herb, energy: herb.energy*0.5, gestate:0, lastMeal:0 }); spawnHerb(); } } for(const [id, pos, pred] of world.query(Position, Predator)){ @@ -419,7 +461,7 @@

🌿 Ecosystem Food-Web Sandbox 🌌

const gain = ate? 8 : 0; world.set(id, Predator, { ...pred, energy: pred.energy + gain, hunger: pred.hunger + dt*(ate? -2:1) }); field.scent[sample.i]+=1.2; - world.set(id, Trail, { ttl: 12, hue: 12 }); + world.set(id, Trail, { ttl: 14, hue: 12 }); if(pred.energy>90){ world.set(id, Predator, { ...pred, energy: pred.energy*0.6, hunger:0 }); spawnPred(); } } for(const [id, sig] of world.query(Signal)){ @@ -466,19 +508,19 @@

🌿 Ecosystem Food-Web Sandbox 🌌

function balanceSystem(world, _dt){ const herbCount = [...world.query(Herbivore)].length; const predCount = [...world.query(Predator)].length; - const targetHerb = Math.max(10, Math.floor(+UI.herb.value * 0.4)); - const targetPred = Math.max(4, Math.floor(+UI.pred.value * 0.35)); + const targetHerb = Math.max(12, Math.floor(+UI.herb.value * 0.45)); + const targetPred = Math.max(6, Math.floor(+UI.pred.value * 0.4)); const [mid, metrics] = world.query(Metrics)[Symbol.iterator]().next().value || []; if(metrics){ world.set(mid, Metrics, { ...metrics, herb: herbCount, pred: predCount }); } if(world.time > 0.5 && herbCount < targetHerb){ - const missing = Math.min(targetHerb - herbCount, 12); + const missing = Math.min(targetHerb - herbCount, 14); for(let i=0;i 0.5 && predCount < targetPred && herbCount > targetHerb*0.5){ - const missing = Math.min(targetPred - predCount, 6); + const missing = Math.min(targetPred - predCount, 7); for(let i=0;i🌿 Ecosystem Food-Web Sandbox 🌌 sky.addColorStop(0, COLORS.skyTop); sky.addColorStop(1, COLORS.skyBottom); ctx.fillStyle = sky; ctx.fillRect(0,0,cv.width,cv.height); - // Aurora ribbons ctx.save(); ctx.globalCompositeOperation = 'screen'; const aurora = ctx.createLinearGradient(0,0,cv.width,cv.height); - aurora.addColorStop(0, 'rgba(80,180,255,0.12)'); - aurora.addColorStop(1, 'rgba(180,120,255,0.14)'); + aurora.addColorStop(0, 'rgba(90,200,255,0.12)'); + aurora.addColorStop(1, 'rgba(255,140,200,0.15)'); ctx.fillStyle = aurora; - ctx.beginPath(); ctx.moveTo(0,cv.height*0.25); ctx.quadraticCurveTo(cv.width*0.35, cv.height*0.15, cv.width, cv.height*0.28); ctx.lineTo(cv.width, cv.height*0.45); ctx.quadraticCurveTo(cv.width*0.4, cv.height*0.35, 0, cv.height*0.5); ctx.closePath(); ctx.fill(); + ctx.beginPath(); ctx.moveTo(0,cv.height*0.2); ctx.quadraticCurveTo(cv.width*0.3, cv.height*0.12, cv.width*0.6, cv.height*0.24); + ctx.quadraticCurveTo(cv.width*0.8, cv.height*0.34, cv.width, cv.height*0.2); ctx.lineTo(cv.width, cv.height*0.4); + ctx.quadraticCurveTo(cv.width*0.65, cv.height*0.32, cv.width*0.35, cv.height*0.42); ctx.quadraticCurveTo(cv.width*0.12, cv.height*0.52, 0, cv.height*0.35); ctx.closePath(); ctx.fill(); ctx.restore(); ctx.save(); @@ -512,85 +555,97 @@

🌿 Ecosystem Food-Web Sandbox 🌌

} ctx.restore(); - // Biomass nebula: floating blobs for plants + carrion + // Biomass spray — random splats instead of grid tiles if(UI.showHeat.checked){ ctx.save(); ctx.globalCompositeOperation='lighter'; - for(let y=0;y1){ - const px=(x+0.5)*cw, py=(y+0.5)*ch; - const r = Math.min(cw,ch) * (0.5 + Math.sqrt(plant)*0.12); - const g = ctx.createRadialGradient(px,py,0,px,py,r*1.4); - g.addColorStop(0, `rgba(90,255,150,0.28)`); - g.addColorStop(1, 'rgba(0,0,0,0)'); - ctx.fillStyle=g; ctx.beginPath(); ctx.arc(px,py,r*1.2,0,Math.PI*2); ctx.fill(); - } - const carr = field.carrion[i]; - if(carr>0.6){ - const px=(x+0.5)*cw, py=(y+0.5)*ch; - const r = Math.min(cw,ch) * (0.35 + Math.sqrt(carr)*0.15); - const g = ctx.createRadialGradient(px,py,0,px,py,r*1.6); - g.addColorStop(0, 'rgba(255,190,130,0.35)'); g.addColorStop(1, 'rgba(0,0,0,0)'); - ctx.fillStyle=g; ctx.beginPath(); ctx.arc(px,py,r,0,Math.PI*2); ctx.fill(); - } + for(let i=0;i<360;i++){ + const x = world.rand()*biome.cols; const y = world.rand()*biome.rows; + const s = sampleFieldAt(field, biome, x, y); + if(s.plant>2){ + const px=(x+0.4*world.rand())*cw, py=(y+0.4*world.rand())*ch; + const r = Math.min(cw,ch) * (0.5 + Math.sqrt(s.plant)*0.08); + const g = ctx.createRadialGradient(px,py,0,px,py,r*1.8); + g.addColorStop(0, 'rgba(90,255,180,0.45)'); g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle=g; ctx.beginPath(); ctx.arc(px,py,r,0,Math.PI*2); ctx.fill(); + } + if(s.carr>0.4 && world.rand()>0.55){ + const px=(x+0.4*world.rand())*cw, py=(y+0.4*world.rand())*ch; + const r = Math.min(cw,ch) * (0.35 + Math.sqrt(s.carr)*0.09); + const g = ctx.createRadialGradient(px,py,0,px,py,r*1.6); + g.addColorStop(0, 'rgba(255,200,140,0.38)'); g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle=g; ctx.beginPath(); ctx.arc(px,py,r,0,Math.PI*2); ctx.fill(); } } ctx.restore(); - // sprinkle visible sprites for biomass ctx.save(); - ctx.font = `${Math.min(cw,ch)*0.85}px "Apple Color Emoji","Noto Color Emoji"`; - for(let y=0;y5 && world.rand()>0.45){ ctx.fillText(EMOJI.plant, x*cw, y*ch); } - if(carr>2.5 && world.rand()>0.65){ ctx.fillText(EMOJI.carrion, x*cw, y*ch); } - } + ctx.font = `${Math.min(cw,ch)*0.9}px "Apple Color Emoji","Noto Color Emoji"`; + for(let i=0;i<120;i++){ + const x = world.rand()*biome.cols; const y = world.rand()*biome.rows; + const s = sampleFieldAt(field, biome, x, y); + if(s.plant>6 && world.rand()>0.35) ctx.fillText(EMOJI.plant, x*cw, y*ch); + if(s.carr>2.5 && world.rand()>0.65) ctx.fillText(EMOJI.carrion, x*cw, y*ch); } ctx.restore(); } + if(UI.showContours.checked){ - ctx.strokeStyle = 'rgba(90,140,255,0.16)'; ctx.lineWidth=2; - ctx.beginPath(); ctx.ellipse(cv.width*0.5, cv.height*0.45, cv.width*0.44, cv.height*0.28, 0, 0, Math.PI*2); ctx.stroke(); - ctx.beginPath(); ctx.ellipse(cv.width*0.4, cv.height*0.65, cv.width*0.36, cv.height*0.2, 0, 0, Math.PI*2); ctx.stroke(); + ctx.strokeStyle = 'rgba(120,180,255,0.18)'; ctx.lineWidth=2; + ctx.beginPath(); ctx.ellipse(cv.width*0.52, cv.height*0.48, cv.width*0.46, cv.height*0.26, 0, 0, Math.PI*2); ctx.stroke(); + ctx.beginPath(); ctx.ellipse(cv.width*0.35, cv.height*0.65, cv.width*0.35, cv.height*0.2, 0, 0, Math.PI*2); ctx.stroke(); } + // Signals and rally beacons for(const [id, pos, sig] of world.query(Position, Signal)){ const r = (2+sig.ttl*4); const hue = sig.label==='drought'?28:300; const glyph = sig.label==='drought'?EMOJI.drought:EMOJI.flare; - const grad = ctx.createRadialGradient((pos.x+0.5)*cw,(pos.y+0.5)*ch,2,(pos.x+0.5)*cw,(pos.y+0.5)*ch,r*4); - grad.addColorStop(0, `hsla(${hue},90%,70%,0.5)`); grad.addColorStop(1, 'rgba(0,0,0,0)'); + const grad = ctx.createRadialGradient((pos.x+0.5)*cw,(pos.y+0.5)*ch,2,(pos.x+0.5)*cw,(pos.y+0.5)*ch,r*5); + grad.addColorStop(0, `hsla(${hue},90%,70%,0.55)`); grad.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = grad; ctx.fillRect((pos.x-3)*cw, (pos.y-3)*ch, cw*7, ch*7); - ctx.strokeStyle = `hsla(${hue},100%,70%,0.8)`; ctx.lineWidth=1.5; ctx.beginPath(); ctx.arc((pos.x+0.5)*cw,(pos.y+0.5)*ch,r,0,Math.PI*2); ctx.stroke(); + ctx.strokeStyle = `hsla(${hue},100%,70%,0.85)`; ctx.lineWidth=1.6; ctx.beginPath(); ctx.arc((pos.x+0.5)*cw,(pos.y+0.5)*ch,r,0,Math.PI*2); ctx.stroke(); ctx.font = `${Math.min(cw,ch)*1.1}px "Apple Color Emoji","Noto Color Emoji"`; ctx.fillStyle='rgba(255,255,255,0.9)'; ctx.fillText(glyph,(pos.x+0.08)*cw,(pos.y+0.9)*ch); } for(const [id, pos, beacon] of world.query(Position, SpawnBeacon)){ const alpha = Math.max(0, beacon.ttl/2); ctx.strokeStyle = `hsla(${beacon.hue},95%,65%,${0.8*alpha})`; ctx.lineWidth = 2; - ctx.beginPath(); ctx.arc((pos.x+0.5)*cw,(pos.y+0.5)*ch, cw*0.55, 0, Math.PI*2); ctx.stroke(); + ctx.beginPath(); ctx.arc((pos.x+0.5)*cw,(pos.y+0.5)*ch, cw*0.65, 0, Math.PI*2); ctx.stroke(); } + // Trails and command arrows if(UI.showTrails.checked){ + ctx.lineWidth = 2.5; for(const [id, pos, trail] of world.query(Position, Trail)){ - const r = Math.min(cw,ch)*0.55; - ctx.fillStyle = `hsla(${trail.hue},90%,70%,${Math.max(0, trail.ttl/12)})`; - ctx.shadowColor = `hsla(${trail.hue},100%,70%,0.55)`; ctx.shadowBlur = 12; - ctx.beginPath(); ctx.arc((pos.x+0.2)*cw, (pos.y+0.2)*ch, r, 0, Math.PI*2); ctx.fill(); + const r = Math.min(cw,ch)*0.6; + ctx.strokeStyle = `hsla(${trail.hue},90%,70%,${Math.max(0, trail.ttl/14)})`; + ctx.shadowColor = `hsla(${trail.hue},100%,70%,0.55)`; ctx.shadowBlur = 18; + ctx.beginPath(); ctx.arc((pos.x+0.2)*cw, (pos.y+0.2)*ch, r, 0, Math.PI*2); ctx.stroke(); } ctx.shadowBlur = 0; ctx.shadowColor='transparent'; } + + // Command lines to closest prey + ctx.save(); + ctx.globalAlpha = 0.6; + ctx.strokeStyle = 'rgba(255,140,120,0.55)'; + ctx.lineWidth = 1.5; + const herbPositions = new Map(); + for(const [hid, pos] of world.query(Position, Herbivore)) herbPositions.set(hid, pos); + for(const [pid, pos, pred] of world.query(Position, Predator)){ + if(pred.target !== -1 && herbPositions.has(pred.target)){ + const hpos = herbPositions.get(pred.target); + ctx.beginPath(); ctx.moveTo(pos.x*cw, pos.y*ch); ctx.lineTo(hpos.x*cw, hpos.y*ch); ctx.stroke(); + } + } + ctx.restore(); + // Units (emoji + bounding circles) ctx.font = `${Math.min(cw,ch)*0.95}px "Apple Color Emoji","Noto Color Emoji"`; for(const [id, pos, herb] of world.query(Position, Herbivore)){ const energy = Math.max(0, Math.min(1, herb.energy/64)); - const r = Math.min(cw,ch)*0.58; + const r = Math.min(cw,ch)*0.62; const px = pos.x*cw, py = pos.y*ch; ctx.save(); - ctx.shadowColor='rgba(120,255,180,0.5)'; ctx.shadowBlur=18; + ctx.shadowColor='rgba(120,255,200,0.6)'; ctx.shadowBlur=18; ctx.strokeStyle=`rgba(100,255,200,${0.5+0.4*energy})`; ctx.lineWidth=3; ctx.beginPath(); ctx.arc(px, py, r*0.9, 0, Math.PI*2); ctx.stroke(); ctx.strokeStyle=`rgba(0,0,0,0.7)`; ctx.lineWidth=6; ctx.beginPath(); ctx.arc(px, py, r*0.7, -Math.PI/2, -Math.PI/2 + Math.PI*2*energy); ctx.stroke(); ctx.fillStyle='rgba(0,0,0,0.32)'; ctx.beginPath(); ctx.arc(px, py, r*0.65, 0, Math.PI*2); ctx.fill(); @@ -599,10 +654,10 @@

🌿 Ecosystem Food-Web Sandbox 🌌

} for(const [id, pos, pred] of world.query(Position, Predator)){ const energy = Math.max(0, Math.min(1, pred.energy/90)); - const r = Math.min(cw,ch)*0.6; + const r = Math.min(cw,ch)*0.64; const px = pos.x*cw, py = pos.y*ch; ctx.save(); - ctx.shadowColor='rgba(255,120,120,0.65)'; ctx.shadowBlur=18; + ctx.shadowColor='rgba(255,140,120,0.7)'; ctx.shadowBlur=18; ctx.strokeStyle=`rgba(255,140,120,${0.55+0.35*energy})`; ctx.lineWidth=3; ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI*2); ctx.stroke(); ctx.strokeStyle='rgba(0,0,0,0.8)'; ctx.lineWidth=7; ctx.beginPath(); ctx.arc(px, py, r*0.74, -Math.PI/2, -Math.PI/2 + Math.PI*2*energy); ctx.stroke(); ctx.fillStyle='rgba(0,0,0,0.38)'; ctx.beginPath(); ctx.arc(px, py, r*0.7, 0, Math.PI*2); ctx.fill(); From 511351d094111e993ebfc19c4f6037969b84f04f Mon Sep 17 00:00:00 2001 From: Pete Jensen Date: Sat, 22 Nov 2025 16:13:40 -0500 Subject: [PATCH 08/17] Stabilize food-web plants --- demo/food-web.html | 112 +++++++++++++++++++++++++-------------------- 1 file changed, 62 insertions(+), 50 deletions(-) diff --git a/demo/food-web.html b/demo/food-web.html index d41b5d9..fc8c897 100644 --- a/demo/food-web.html +++ b/demo/food-web.html @@ -330,32 +330,44 @@

🐾 Wildlands Command 🌌

function spreadSystem(world, dt){ const [bid, biome, field] = world.query(Biome, Field)[Symbol.iterator]().next().value || []; if(!biome) return; - const bursts = Math.floor(260 * dt); - for(let b=0;b 2.5){ - const ang = world.rand()*Math.PI*2; - const dist = 1 + world.rand()*3.5; - const nx = x + Math.cos(ang)*dist; - const ny = y + Math.sin(ang)*dist; - const nb = sampleFieldAt(field, biome, nx, ny); - const transfer = Math.min(center.plant*0.04, 1.6); - field.plants[center.i] = Math.max(0, field.plants[center.i] - transfer*0.6); - field.plants[nb.i] = Math.min(field.plants[nb.i] + transfer, 120); - } - if(center.carr > 1.2){ - const ang = world.rand()*Math.PI*2; - const dist = 0.5 + world.rand()*2.5; - const nx = x + Math.cos(ang)*dist; - const ny = y + Math.sin(ang)*dist; - const nb = sampleFieldAt(field, biome, nx, ny); - const bleed = Math.min(center.carr*0.08, 1.8); - field.carrion[center.i] = Math.max(0, field.carrion[center.i] - bleed*0.5); - field.carrion[nb.i] = Math.min(field.carrion[nb.i] + bleed, 90); + const nextPlants = new Float32Array(field.plants.length); + const nextCarrion = new Float32Array(field.carrion.length); + const { cols, rows } = biome; + for(let y=0;y 10) denseNeighbors++; + plantSum += np; + carrSum += field.carrion[ni]; + } + } + + // Conway-esque birth: empty-ish tiles bloom when surrounded by lush neighbors. + let newPlant = plant; + const avg = plantSum / 8; + if(plant < 1 && denseNeighbors >= 3){ + newPlant += dt * avg * 0.4; + } else { + // Drift toward neighborhood average to keep clumps cohesive. + newPlant += dt * (avg - plant) * 0.2; + } + nextPlants[i] = Math.max(0, Math.min(180, newPlant)); + + // Carrion wafts outward gently. + const carrDiffusion = (carrSum / 8 - carr) * 0.12 * dt; + nextCarrion[i] = Math.max(0, carr + carrDiffusion); } } + field.plants.set(nextPlants); + field.carrion.set(nextCarrion); } const HERB_SPEED = 5.6; @@ -525,6 +537,11 @@

🐾 Wildlands Command 🌌

} } +function jitter(x, y){ + const s = Math.sin(x*12.9898 + y*78.233); + return s - Math.floor(s); +} + function renderSystem(world, _dt, statsView){ const { ctx, cv } = CV; ctx.clearRect(0,0,cv.width,cv.height); const [bid, biome, field, season, metrics] = world.query(Biome, Field, Season, Metrics)[Symbol.iterator]().next().value || []; @@ -555,36 +572,31 @@

🐾 Wildlands Command 🌌

} ctx.restore(); - // Biomass spray — random splats instead of grid tiles + // Biomass spray — anchor on stable tiles so plants feel planted instead of sparkly noise. if(UI.showHeat.checked){ ctx.save(); ctx.globalCompositeOperation='lighter'; - for(let i=0;i<360;i++){ - const x = world.rand()*biome.cols; const y = world.rand()*biome.rows; - const s = sampleFieldAt(field, biome, x, y); - if(s.plant>2){ - const px=(x+0.4*world.rand())*cw, py=(y+0.4*world.rand())*ch; - const r = Math.min(cw,ch) * (0.5 + Math.sqrt(s.plant)*0.08); - const g = ctx.createRadialGradient(px,py,0,px,py,r*1.8); - g.addColorStop(0, 'rgba(90,255,180,0.45)'); g.addColorStop(1, 'rgba(0,0,0,0)'); - ctx.fillStyle=g; ctx.beginPath(); ctx.arc(px,py,r,0,Math.PI*2); ctx.fill(); - } - if(s.carr>0.4 && world.rand()>0.55){ - const px=(x+0.4*world.rand())*cw, py=(y+0.4*world.rand())*ch; - const r = Math.min(cw,ch) * (0.35 + Math.sqrt(s.carr)*0.09); - const g = ctx.createRadialGradient(px,py,0,px,py,r*1.6); - g.addColorStop(0, 'rgba(255,200,140,0.38)'); g.addColorStop(1, 'rgba(0,0,0,0)'); - ctx.fillStyle=g; ctx.beginPath(); ctx.arc(px,py,r,0,Math.PI*2); ctx.fill(); - } - } - ctx.restore(); - ctx.save(); ctx.font = `${Math.min(cw,ch)*0.9}px "Apple Color Emoji","Noto Color Emoji"`; - for(let i=0;i<120;i++){ - const x = world.rand()*biome.cols; const y = world.rand()*biome.rows; - const s = sampleFieldAt(field, biome, x, y); - if(s.plant>6 && world.rand()>0.35) ctx.fillText(EMOJI.plant, x*cw, y*ch); - if(s.carr>2.5 && world.rand()>0.65) ctx.fillText(EMOJI.carrion, x*cw, y*ch); + for(let y=0;y0.4){ + const r = Math.min(cw,ch) * (0.45 + Math.sqrt(cell.plant)*0.06); + const g = ctx.createRadialGradient(px,py,0,px,py,r*1.6); + g.addColorStop(0, 'rgba(90,255,180,0.4)'); g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle=g; ctx.beginPath(); ctx.arc(px,py,r,0,Math.PI*2); ctx.fill(); + if(cell.plant>12) ctx.fillText(EMOJI.plant, px-r*0.35, py+r*0.35); + } + if(cell.carr>1.5){ + const r = Math.min(cw,ch) * (0.4 + Math.sqrt(cell.carr)*0.07); + const g = ctx.createRadialGradient(px,py,0,px,py,r*1.4); + g.addColorStop(0, 'rgba(255,200,140,0.32)'); g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle=g; ctx.beginPath(); ctx.arc(px,py,r,0,Math.PI*2); ctx.fill(); + if(cell.carr>4) ctx.fillText(EMOJI.carrion, px-r*0.28, py+r*0.32); + } + } } ctx.restore(); } From b67551491e5bdaae905ec1c4b166212bd8914b4c Mon Sep 17 00:00:00 2001 From: Pete Jensen Date: Sat, 22 Nov 2025 19:14:43 -0500 Subject: [PATCH 09/17] Render plants as emojis without background circles --- demo/food-web.html | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/demo/food-web.html b/demo/food-web.html index fc8c897..627a664 100644 --- a/demo/food-web.html +++ b/demo/food-web.html @@ -575,26 +575,29 @@

🐾 Wildlands Command 🌌

// Biomass spray — anchor on stable tiles so plants feel planted instead of sparkly noise. if(UI.showHeat.checked){ ctx.save(); - ctx.globalCompositeOperation='lighter'; - ctx.font = `${Math.min(cw,ch)*0.9}px "Apple Color Emoji","Noto Color Emoji"`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = `${Math.min(cw,ch)*0.95}px "Apple Color Emoji","Noto Color Emoji"`; for(let y=0;y0.4){ - const r = Math.min(cw,ch) * (0.45 + Math.sqrt(cell.plant)*0.06); - const g = ctx.createRadialGradient(px,py,0,px,py,r*1.6); - g.addColorStop(0, 'rgba(90,255,180,0.4)'); g.addColorStop(1, 'rgba(0,0,0,0)'); - ctx.fillStyle=g; ctx.beginPath(); ctx.arc(px,py,r,0,Math.PI*2); ctx.fill(); - if(cell.plant>12) ctx.fillText(EMOJI.plant, px-r*0.35, py+r*0.35); + if(cell.plant>0.35){ + const wobble = jitter(x, y)*0.3; + const px=(x+0.5+wobble*0.3)*cw, py=(y+0.5+wobble*0.3)*ch; + ctx.save(); + ctx.shadowColor='rgba(120,255,180,0.45)'; + ctx.shadowBlur = 10; + ctx.fillText(EMOJI.plant, px, py); + ctx.restore(); } - if(cell.carr>1.5){ - const r = Math.min(cw,ch) * (0.4 + Math.sqrt(cell.carr)*0.07); - const g = ctx.createRadialGradient(px,py,0,px,py,r*1.4); - g.addColorStop(0, 'rgba(255,200,140,0.32)'); g.addColorStop(1, 'rgba(0,0,0,0)'); - ctx.fillStyle=g; ctx.beginPath(); ctx.arc(px,py,r,0,Math.PI*2); ctx.fill(); - if(cell.carr>4) ctx.fillText(EMOJI.carrion, px-r*0.28, py+r*0.32); + if(cell.carr>2){ + const wobble = jitter(x*1.7, y*1.3)*0.25; + const px=(x+0.55+wobble*0.4)*cw, py=(y+0.55+wobble*0.4)*ch; + ctx.save(); + ctx.shadowColor='rgba(255,210,150,0.35)'; + ctx.shadowBlur = 8; + ctx.fillText(EMOJI.carrion, px, py); + ctx.restore(); } } } From ea3564aff23745ba635318e96964b0966822fcab Mon Sep 17 00:00:00 2001 From: Pete Jensen Date: Sun, 23 Nov 2025 18:20:56 -0500 Subject: [PATCH 10/17] Simplify food-web unit rendering --- demo/food-web.html | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/demo/food-web.html b/demo/food-web.html index 627a664..f7a8afa 100644 --- a/demo/food-web.html +++ b/demo/food-web.html @@ -653,31 +653,39 @@

🐾 Wildlands Command 🌌

} ctx.restore(); - // Units (emoji + bounding circles) + // Units (emoji-forward, minimal halo for perf) ctx.font = `${Math.min(cw,ch)*0.95}px "Apple Color Emoji","Noto Color Emoji"`; for(const [id, pos, herb] of world.query(Position, Herbivore)){ const energy = Math.max(0, Math.min(1, herb.energy/64)); - const r = Math.min(cw,ch)*0.62; + const r = Math.min(cw,ch)*0.55; const px = pos.x*cw, py = pos.y*ch; + const glyph = EMOJI.herb[id % EMOJI.herb.length]; ctx.save(); - ctx.shadowColor='rgba(120,255,200,0.6)'; ctx.shadowBlur=18; - ctx.strokeStyle=`rgba(100,255,200,${0.5+0.4*energy})`; ctx.lineWidth=3; ctx.beginPath(); ctx.arc(px, py, r*0.9, 0, Math.PI*2); ctx.stroke(); - ctx.strokeStyle=`rgba(0,0,0,0.7)`; ctx.lineWidth=6; ctx.beginPath(); ctx.arc(px, py, r*0.7, -Math.PI/2, -Math.PI/2 + Math.PI*2*energy); ctx.stroke(); - ctx.fillStyle='rgba(0,0,0,0.32)'; ctx.beginPath(); ctx.arc(px, py, r*0.65, 0, Math.PI*2); ctx.fill(); - ctx.fillText(EMOJI.herb[id % EMOJI.herb.length], px - r*0.4, py + r*0.4); + ctx.shadowColor='rgba(120,255,200,0.35)'; ctx.shadowBlur=8; + ctx.fillText(glyph, px - r*0.4, py + r*0.4); ctx.restore(); + + const barW = Math.min(cw,ch)*0.9, barH = Math.min(cw,ch)*0.16; + ctx.strokeStyle='rgba(10,30,35,0.5)'; ctx.lineWidth=barH; ctx.beginPath(); + ctx.moveTo(px - barW/2, py + r*0.6); ctx.lineTo(px + barW/2, py + r*0.6); ctx.stroke(); + ctx.strokeStyle='rgba(100,255,200,0.9)'; ctx.lineWidth=barH*0.9; ctx.beginPath(); + ctx.moveTo(px - barW/2, py + r*0.6); ctx.lineTo(px - barW/2 + barW*energy, py + r*0.6); ctx.stroke(); } for(const [id, pos, pred] of world.query(Position, Predator)){ const energy = Math.max(0, Math.min(1, pred.energy/90)); - const r = Math.min(cw,ch)*0.64; + const r = Math.min(cw,ch)*0.56; const px = pos.x*cw, py = pos.y*ch; + const glyph = EMOJI.pred[id % EMOJI.pred.length]; ctx.save(); - ctx.shadowColor='rgba(255,140,120,0.7)'; ctx.shadowBlur=18; - ctx.strokeStyle=`rgba(255,140,120,${0.55+0.35*energy})`; ctx.lineWidth=3; ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI*2); ctx.stroke(); - ctx.strokeStyle='rgba(0,0,0,0.8)'; ctx.lineWidth=7; ctx.beginPath(); ctx.arc(px, py, r*0.74, -Math.PI/2, -Math.PI/2 + Math.PI*2*energy); ctx.stroke(); - ctx.fillStyle='rgba(0,0,0,0.38)'; ctx.beginPath(); ctx.arc(px, py, r*0.7, 0, Math.PI*2); ctx.fill(); - ctx.fillText(EMOJI.pred[id % EMOJI.pred.length], px - r*0.42, py + r*0.25); + ctx.shadowColor='rgba(255,140,120,0.4)'; ctx.shadowBlur=8; + ctx.fillText(glyph, px - r*0.42, py + r*0.32); ctx.restore(); + + const barW = Math.min(cw,ch)*0.95, barH = Math.min(cw,ch)*0.18; + ctx.strokeStyle='rgba(30,10,10,0.5)'; ctx.lineWidth=barH; ctx.beginPath(); + ctx.moveTo(px - barW/2, py + r*0.55); ctx.lineTo(px + barW/2, py + r*0.55); ctx.stroke(); + ctx.strokeStyle='rgba(255,140,120,0.95)'; ctx.lineWidth=barH*0.9; ctx.beginPath(); + ctx.moveTo(px - barW/2, py + r*0.55); ctx.lineTo(px - barW/2 + barW*energy, py + r*0.55); ctx.stroke(); } UI.out.t.textContent = world.time.toFixed(2); From 4ecb8eec61a95fda4d9a6a5b24edb9430035bef7 Mon Sep 17 00:00:00 2001 From: Pete Jensen Date: Sun, 23 Nov 2025 18:37:16 -0500 Subject: [PATCH 11/17] Cache plant rendering layer --- demo/food-web.html | 81 ++++++++++++++++++++++++++++------------------ 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/demo/food-web.html b/demo/food-web.html index f7a8afa..8491aa1 100644 --- a/demo/food-web.html +++ b/demo/food-web.html @@ -174,6 +174,51 @@

🐾 Wildlands Command 🌌

const CV = (()=>{ const cv = document.getElementById('cv'); const ctx = cv.getContext('2d'); function fit(){ const dpr = window.devicePixelRatio||1; cv.width=cv.clientWidth*dpr; cv.height=cv.clientHeight*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); } window.addEventListener('resize', fit); fit(); return { cv, ctx }; })(); +// Cached plant/carrion layer to avoid redrawing thousands of emojis every frame. +const PlantLayer = (()=>{ + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + let lastStamp = -Infinity; let w = 0; let h = 0; + function ensureSize(width, height){ if(width!==w || height!==h){ w=canvas.width=width; h=canvas.height=height; lastStamp=-Infinity; } } + return { + draw(t, biome, field, cw, ch){ + ensureSize(CV.cv.width, CV.cv.height); + if(t - lastStamp < 0.3) return; // refresh a few times a second only + lastStamp = t; + ctx.clearRect(0,0,w,h); + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + const fontSize = `${Math.min(cw,ch)*0.95}px "Apple Color Emoji","Noto Color Emoji"`; + ctx.font = fontSize; + for(let y=0;y0.35){ + const wobble = jitter(x, y)*0.3; + const px=(x+0.5+wobble*0.3)*cw, py=(y+0.5+wobble*0.3)*ch; + ctx.save(); + ctx.shadowColor='rgba(120,255,180,0.32)'; + ctx.shadowBlur = 6; + ctx.fillText(EMOJI.plant, px, py); + ctx.restore(); + } + if(carr>2){ + const wobble = jitter(x*1.7, y*1.3)*0.25; + const px=(x+0.55+wobble*0.4)*cw, py=(y+0.55+wobble*0.4)*ch; + ctx.save(); + ctx.shadowColor='rgba(255,210,150,0.26)'; + ctx.shadowBlur = 5; + ctx.fillText(EMOJI.carrion, px, py); + ctx.restore(); + } + } + } + }, + image: canvas, + }; +})(); + const idx = (x,y,cols,rows)=>(((Math.floor(y+rows)%rows)*cols) + ((Math.floor(x+cols)%cols))); const pick = (arr, rand=Math.random)=>arr[(rand()*arr.length)|0]; @@ -572,36 +617,10 @@

🐾 Wildlands Command 🌌

} ctx.restore(); - // Biomass spray — anchor on stable tiles so plants feel planted instead of sparkly noise. + // Biomass spray — cached to a layer so we aren't redrawing thousands of glyphs per frame. if(UI.showHeat.checked){ - ctx.save(); - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.font = `${Math.min(cw,ch)*0.95}px "Apple Color Emoji","Noto Color Emoji"`; - for(let y=0;y0.35){ - const wobble = jitter(x, y)*0.3; - const px=(x+0.5+wobble*0.3)*cw, py=(y+0.5+wobble*0.3)*ch; - ctx.save(); - ctx.shadowColor='rgba(120,255,180,0.45)'; - ctx.shadowBlur = 10; - ctx.fillText(EMOJI.plant, px, py); - ctx.restore(); - } - if(cell.carr>2){ - const wobble = jitter(x*1.7, y*1.3)*0.25; - const px=(x+0.55+wobble*0.4)*cw, py=(y+0.55+wobble*0.4)*ch; - ctx.save(); - ctx.shadowColor='rgba(255,210,150,0.35)'; - ctx.shadowBlur = 8; - ctx.fillText(EMOJI.carrion, px, py); - ctx.restore(); - } - } - } - ctx.restore(); + PlantLayer.draw(t, biome, field, cw, ch); + ctx.drawImage(PlantLayer.image, 0, 0); } if(UI.showContours.checked){ @@ -628,11 +647,11 @@

🐾 Wildlands Command 🌌

// Trails and command arrows if(UI.showTrails.checked){ - ctx.lineWidth = 2.5; + ctx.lineWidth = 2.2; for(const [id, pos, trail] of world.query(Position, Trail)){ const r = Math.min(cw,ch)*0.6; ctx.strokeStyle = `hsla(${trail.hue},90%,70%,${Math.max(0, trail.ttl/14)})`; - ctx.shadowColor = `hsla(${trail.hue},100%,70%,0.55)`; ctx.shadowBlur = 18; + ctx.shadowColor = `hsla(${trail.hue},100%,70%,0.4)`; ctx.shadowBlur = 8; ctx.beginPath(); ctx.arc((pos.x+0.2)*cw, (pos.y+0.2)*ch, r, 0, Math.PI*2); ctx.stroke(); } ctx.shadowBlur = 0; ctx.shadowColor='transparent'; From 21f9ee95105083c2583b92364758326a2d922649 Mon Sep 17 00:00:00 2001 From: Pete Jensen Date: Sun, 23 Nov 2025 18:37:27 -0500 Subject: [PATCH 12/17] Balance food-web growth and motion --- demo/food-web.html | 88 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 68 insertions(+), 20 deletions(-) diff --git a/demo/food-web.html b/demo/food-web.html index 8491aa1..b38a5f9 100644 --- a/demo/food-web.html +++ b/demo/food-web.html @@ -92,7 +92,7 @@

🐾 Wildlands Command 🌌

// ---------- Components ---------- const Biome = defineComponent('Biome', { cols: 96, rows: 60, rainfall: 0.9, sunlight: 1.1, swing: 0.45 }); -const Field = defineComponent('Field', { plants: null, carrion: null, scent: null }); +const Field = defineComponent('Field', { plants: null, carrion: null, scent: null, fertility: null }); const Season = defineComponent('Season', { t: 0, temp: 0, rainPulse: 1, drought: 0, flare: 0, cols: 96, rows: 60 }); const Position = defineComponent('Position', { x: 0, y: 0 }); const Velocity = defineComponent('Velocity', { dx: 0, dy: 0 }); @@ -253,10 +253,50 @@

🐾 Wildlands Command 🌌

const Grazer = defineArchetype('Grazer', Critter, [Trail, { ttl: 8, hue: 110 }]); const Hunter = defineArchetype('Hunter', Critter, [Trail, { ttl: 8, hue: 10 }]); +function smoothstep(t){ return t*t*(3-2*t); } + +function makeNoise(cols, rows, rand, stride){ + const gx = Math.ceil(cols/stride)+2; const gy = Math.ceil(rows/stride)+2; + const grid = new Float32Array(gx*gy); + for(let i=0;igrid[(iy%gy)*gx + (ix%gx)]; + const n00=g(x0,y0), n10=g(x1,y0), n01=g(x0,y1), n11=g(x1,y1); + const nx0 = n00 + (n10-n00)*sx; + const nx1 = n01 + (n11-n01)*sx; + noise[idx(x,y,cols,rows)] = nx0 + (nx1-nx0)*sy; + } + } + return noise; +} + +function makeFertility(cols, rows, rand){ + const coarse = makeNoise(cols, rows, rand, 18); + const fine = makeNoise(cols, rows, rand, 7); + const fert = new Float32Array(cols*rows); + for(let i=0;i🐾 Wildlands Command 🌌 const biome = world.create(); const cols = 96, rows = 60; world.add(biome, Biome, { cols, rows, rainfall: +UI.rain.value, sunlight: +UI.sun.value, swing: +UI.swing.value }); - const { plants, carrion, scent } = seedField(cols, rows, +UI.rain.value, +UI.sun.value, world.rand); - world.add(biome, Field, { plants, carrion, scent }); + const { plants, carrion, scent, fertility } = seedField(cols, rows, +UI.rain.value, +UI.sun.value, world.rand); + world.add(biome, Field, { plants, carrion, scent, fertility }); world.add(biome, Season, { t: 0, temp: 0, rainPulse: 1, drought: 0, flare: 0, cols, rows }); world.add(biome, Metrics, { plants:0, herb:0, pred:0, events:0 }); world.addScript(biome, 'seasonal-shocks'); @@ -361,9 +401,13 @@

🐾 Wildlands Command 🌌

const { cols, rows } = biome; const N = cols*rows; const drain = +UI.drain.value; let totalPlants = 0; for(let i=0;i🐾 Wildlands Command 🌌 const i = idx(x, y, cols, rows); const plant = field.plants[i]; const carr = field.carrion[i]; + const fert = field.fertility ? field.fertility[i] : 0.5; let denseNeighbors = 0; let plantSum = 0; let carrSum = 0; for(let dy=-1; dy<=1; dy++){ for(let dx=-1; dx<=1; dx++){ @@ -398,13 +443,16 @@

🐾 Wildlands Command 🌌

// Conway-esque birth: empty-ish tiles bloom when surrounded by lush neighbors. let newPlant = plant; const avg = plantSum / 8; - if(plant < 1 && denseNeighbors >= 3){ - newPlant += dt * avg * 0.4; + const capacity = 10 + fert*22; + if(plant < capacity*0.18 && denseNeighbors >= 3){ + newPlant += dt * avg * (0.16 + fert*0.32); } else { // Drift toward neighborhood average to keep clumps cohesive. - newPlant += dt * (avg - plant) * 0.2; + newPlant += dt * (avg - plant) * (0.12 + fert*0.18); } - nextPlants[i] = Math.max(0, Math.min(180, newPlant)); + const damp = Math.max(0, plant - capacity); + newPlant = Math.max(0, Math.min(capacity*1.6, newPlant - damp*0.08*dt)); + nextPlants[i] = newPlant; // Carrion wafts outward gently. const carrDiffusion = (carrSum / 8 - carr) * 0.12 * dt; @@ -415,8 +463,8 @@

🐾 Wildlands Command 🌌

field.carrion.set(nextCarrion); } -const HERB_SPEED = 5.6; -const PRED_SPEED = 6.8; +const HERB_SPEED = 3.4; +const PRED_SPEED = 4.1; function sampleFieldAt(field, biome, x, y){ const { cols, rows } = biome; @@ -450,10 +498,10 @@

🐾 Wildlands Command 🌌

const score = s.plant*0.9 - s.carr*0.4 + s.scent*0.14 - fear + world.rand()*0.8; if(score > bestScore){ bestScore = score; dir = [Math.cos(ang), Math.sin(ang)]; } } - const inertia = 0.45; - const jitter = (world.rand()-0.5)*0.6; + const inertia = 0.35; + const jitter = (world.rand()-0.5)*0.3; const vx = dir[0] + jitter + world.get(id, Velocity).dx*inertia; - const vy = dir[1] + (world.rand()-0.5)*0.6 + world.get(id, Velocity).dy*inertia; + const vy = dir[1] + (world.rand()-0.5)*0.3 + world.get(id, Velocity).dy*inertia; const mag = Math.max(0.01, Math.hypot(vx, vy)); world.set(id, Herbivore, { ...herb, heading: Math.atan2(vy, vx) }); world.set(id, Velocity, { dx: (vx/mag)*HERB_SPEED, dy: (vy/mag)*HERB_SPEED }); @@ -481,7 +529,7 @@

🐾 Wildlands Command 🌌

dir = best; world.mutate(id, Predator, p => { p.target = -1; }); } - const inertia = 0.35; + const inertia = 0.25; const vx = dir[0] + world.get(id, Velocity).dx*inertia; const vy = dir[1] + world.get(id, Velocity).dy*inertia; const mag = Math.max(0.01, Math.hypot(vx, vy)); @@ -494,8 +542,8 @@

🐾 Wildlands Command 🌌

const [bid, biome] = world.query(Biome)[Symbol.iterator]().next().value || []; for(const [id, pos, vel] of world.query(Position, Velocity)){ if(biome){ - pos.x += vel.dx * dt * 6; - pos.y += vel.dy * dt * 6; + pos.x += vel.dx * dt * 2.4; + pos.y += vel.dy * dt * 2.4; wrapPos(pos, biome); } } From 322ed4311fa2455620457799ae6c436a9535139e Mon Sep 17 00:00:00 2001 From: Pete Jensen Date: Sun, 23 Nov 2025 18:43:44 -0500 Subject: [PATCH 13/17] Trim biomass and lighten rendering --- demo/food-web.html | 123 +++++++++++++++++++++++++++------------------ 1 file changed, 74 insertions(+), 49 deletions(-) diff --git a/demo/food-web.html b/demo/food-web.html index b38a5f9..59b9060 100644 --- a/demo/food-web.html +++ b/demo/food-web.html @@ -40,9 +40,9 @@

🐾 Wildlands Command 🌌

Biome tuning
- + - +
@@ -58,9 +58,9 @@

🐾 Wildlands Command 🌌

Display
- + - +
⏱️ Time0.00 @@ -91,7 +91,7 @@

🐾 Wildlands Command 🌌

const DEFAULT_SEED = 0xF00DF11D >>> 0; // ---------- Components ---------- -const Biome = defineComponent('Biome', { cols: 96, rows: 60, rainfall: 0.9, sunlight: 1.1, swing: 0.45 }); +const Biome = defineComponent('Biome', { cols: 96, rows: 60, rainfall: 0.72, sunlight: 1.0, swing: 0.45 }); const Field = defineComponent('Field', { plants: null, carrion: null, scent: null, fertility: null }); const Season = defineComponent('Season', { t: 0, temp: 0, rainPulse: 1, drought: 0, flare: 0, cols: 96, rows: 60 }); const Position = defineComponent('Position', { x: 0, y: 0 }); @@ -183,7 +183,7 @@

🐾 Wildlands Command 🌌

return { draw(t, biome, field, cw, ch){ ensureSize(CV.cv.width, CV.cv.height); - if(t - lastStamp < 0.3) return; // refresh a few times a second only + if(t - lastStamp < 0.35) return; // refresh a few times a second only lastStamp = t; ctx.clearRect(0,0,w,h); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; @@ -194,21 +194,21 @@

🐾 Wildlands Command 🌌

const i = idx(x, y, biome.cols, biome.rows); const plant = field.plants[i]; const carr = field.carrion[i]; - if(plant>0.35){ + if(plant>0.55){ const wobble = jitter(x, y)*0.3; const px=(x+0.5+wobble*0.3)*cw, py=(y+0.5+wobble*0.3)*ch; ctx.save(); - ctx.shadowColor='rgba(120,255,180,0.32)'; - ctx.shadowBlur = 6; + ctx.shadowColor='rgba(120,255,180,0.28)'; + ctx.shadowBlur = 3; ctx.fillText(EMOJI.plant, px, py); ctx.restore(); } - if(carr>2){ + if(carr>2.4){ const wobble = jitter(x*1.7, y*1.3)*0.25; const px=(x+0.55+wobble*0.4)*cw, py=(y+0.55+wobble*0.4)*ch; ctx.save(); - ctx.shadowColor='rgba(255,210,150,0.26)'; - ctx.shadowBlur = 5; + ctx.shadowColor='rgba(255,210,150,0.22)'; + ctx.shadowBlur = 3; ctx.fillText(EMOJI.carrion, px, py); ctx.restore(); } @@ -250,8 +250,8 @@

🐾 Wildlands Command 🌌

let world = null; let loop = null; const Critter = defineArchetype('Critter', [Position, (p)=>({ x:p.x, y:p.y })], [Velocity, { dx:0, dy:0 }]); -const Grazer = defineArchetype('Grazer', Critter, [Trail, { ttl: 8, hue: 110 }]); -const Hunter = defineArchetype('Hunter', Critter, [Trail, { ttl: 8, hue: 10 }]); +const Grazer = defineArchetype('Grazer', Critter, [Trail, { ttl: 6, hue: 110 }]); +const Hunter = defineArchetype('Hunter', Critter, [Trail, { ttl: 6, hue: 10 }]); function smoothstep(t){ return t*t*(3-2*t); } @@ -291,10 +291,10 @@

🐾 Wildlands Command 🌌

const fertility = makeFertility(cols, rows, rand); for(let i=0;i🐾 Wildlands Command 🌌 let totalPlants = 0; for(let i=0;i🐾 Wildlands Command 🌌 // Conway-esque birth: empty-ish tiles bloom when surrounded by lush neighbors. let newPlant = plant; const avg = plantSum / 8; - const capacity = 10 + fert*22; - if(plant < capacity*0.18 && denseNeighbors >= 3){ - newPlant += dt * avg * (0.16 + fert*0.32); + const capacity = 5 + fert*12; + if(plant < capacity*0.12 && denseNeighbors >= 3){ + newPlant += dt * avg * (0.12 + fert*0.18); } else { // Drift toward neighborhood average to keep clumps cohesive. - newPlant += dt * (avg - plant) * (0.12 + fert*0.18); + newPlant += dt * (avg - plant) * (0.08 + fert*0.12); } - const damp = Math.max(0, plant - capacity); - newPlant = Math.max(0, Math.min(capacity*1.6, newPlant - damp*0.08*dt)); + const damp = Math.max(0, plant - capacity*0.9); + newPlant = Math.max(0, Math.min(capacity*1.15, newPlant - damp*0.12*dt)); nextPlants[i] = newPlant; // Carrion wafts outward gently. @@ -540,6 +541,18 @@

🐾 Wildlands Command 🌌

function actSystem(world, dt){ const [bid, biome] = world.query(Biome)[Symbol.iterator]().next().value || []; + const herbDensity = biome ? new Uint8Array(biome.cols * biome.rows) : null; + const predDensity = biome ? new Uint8Array(biome.cols * biome.rows) : null; + if(biome){ + for(const [id, pos] of world.query(Position, Herbivore)){ + const cell = idx(Math.floor(pos.x), Math.floor(pos.y), biome.cols, biome.rows); + herbDensity[cell] = Math.min(herbDensity[cell] + 1, 8); + } + for(const [id, pos] of world.query(Position, Predator)){ + const cell = idx(Math.floor(pos.x), Math.floor(pos.y), biome.cols, biome.rows); + predDensity[cell] = Math.min(predDensity[cell] + 1, 6); + } + } for(const [id, pos, vel] of world.query(Position, Velocity)){ if(biome){ pos.x += vel.dx * dt * 2.4; @@ -550,10 +563,12 @@

🐾 Wildlands Command 🌌

for(const [id, pos, herb] of world.query(Position, Herbivore)){ const [bid2, biome2, field] = world.query(Biome, Field)[Symbol.iterator]().next().value || []; if(!biome2) continue; const sample = sampleFieldAt(field, biome2, pos.x, pos.y); - const bite = Math.min(sample.plant, 2.5); + const herbCrowd = herbDensity ? herbDensity[sample.i] : 0; + const share = 1 / (1 + herbCrowd * 0.7); + const bite = Math.min(sample.plant * share, 2.2); field.plants[sample.i]-=bite; field.scent[sample.i]+=0.5; - world.set(id, Herbivore, { ...herb, energy: herb.energy + bite*0.7, lastMeal: 0, gestate: herb.gestate + dt*bite*0.35 }); - world.set(id, Trail, { ttl: 14, hue: 120 + 60*Math.random() }); + world.set(id, Herbivore, { ...herb, energy: herb.energy + bite*0.7 - herbCrowd*0.05, lastMeal: 0, gestate: herb.gestate + dt*bite*0.35 }); + world.set(id, Trail, { ttl: 9, hue: 120 + 40*Math.random() }); if(herb.energy > 64 && herb.gestate > 6){ world.set(id, Herbivore, { ...herb, energy: herb.energy*0.5, gestate:0, lastMeal:0 }); spawnHerb(); } } for(const [id, pos, pred] of world.query(Position, Predator)){ @@ -563,10 +578,11 @@

🐾 Wildlands Command 🌌

for(const [hid, hpos] of world.query(Position, Herbivore)){ if(Math.hypot(hpos.x-pos.x, hpos.y-pos.y) < 0.4){ world.destroy(hid); adjustPopCounts(-1,0); field.carrion[sample.i]+=2.5; ate=true; break; } } - const gain = ate? 8 : 0; - world.set(id, Predator, { ...pred, energy: pred.energy + gain, hunger: pred.hunger + dt*(ate? -2:1) }); + const predCrowd = predDensity ? predDensity[sample.i] : 0; + const gain = ate? 8 * (1 / (1 + predCrowd*0.5)) : 0; + world.set(id, Predator, { ...pred, energy: pred.energy + gain - predCrowd*0.08, hunger: pred.hunger + dt*(ate? -2:1) }); field.scent[sample.i]+=1.2; - world.set(id, Trail, { ttl: 14, hue: 12 }); + world.set(id, Trail, { ttl: 9, hue: 12 }); if(pred.energy>90){ world.set(id, Predator, { ...pred, energy: pred.energy*0.6, hunger:0 }); spawnPred(); } } for(const [id, sig] of world.query(Signal)){ @@ -688,26 +704,35 @@

🐾 Wildlands Command 🌌

} for(const [id, pos, beacon] of world.query(Position, SpawnBeacon)){ const alpha = Math.max(0, beacon.ttl/2); - ctx.strokeStyle = `hsla(${beacon.hue},95%,65%,${0.8*alpha})`; - ctx.lineWidth = 2; - ctx.beginPath(); ctx.arc((pos.x+0.5)*cw,(pos.y+0.5)*ch, cw*0.65, 0, Math.PI*2); ctx.stroke(); + ctx.strokeStyle = `hsla(${beacon.hue},95%,65%,${0.7*alpha})`; + ctx.lineWidth = 1.6; + ctx.beginPath(); + const cx = (pos.x+0.5)*cw, cy=(pos.y+0.5)*ch, r = cw*0.6; + ctx.moveTo(cx-r, cy); ctx.lineTo(cx, cy-r); ctx.lineTo(cx+r, cy); ctx.lineTo(cx, cy+r); ctx.closePath(); + ctx.stroke(); } // Trails and command arrows if(UI.showTrails.checked){ - ctx.lineWidth = 2.2; - for(const [id, pos, trail] of world.query(Position, Trail)){ - const r = Math.min(cw,ch)*0.6; - ctx.strokeStyle = `hsla(${trail.hue},90%,70%,${Math.max(0, trail.ttl/14)})`; - ctx.shadowColor = `hsla(${trail.hue},100%,70%,0.4)`; ctx.shadowBlur = 8; - ctx.beginPath(); ctx.arc((pos.x+0.2)*cw, (pos.y+0.2)*ch, r, 0, Math.PI*2); ctx.stroke(); + ctx.save(); + ctx.lineWidth = 1.4; + ctx.shadowBlur = 0; + for(const [id, pos, herb, vel] of world.query(Position, Herbivore, Velocity)){ + const len = Math.min(cw,ch)*0.9; const dirX = Math.cos(herb.heading), dirY = Math.sin(herb.heading); + ctx.strokeStyle = `hsla(140,90%,70%,${Math.max(0, Math.min(1, vel ? Math.hypot(vel.dx, vel.dy)/5 : 0.8))})`; + ctx.beginPath(); ctx.moveTo(pos.x*cw, pos.y*ch); ctx.lineTo(pos.x*cw + dirX*len*0.5, pos.y*ch + dirY*len*0.5); ctx.stroke(); + } + for(const [id, pos, pred, vel] of world.query(Position, Predator, Velocity)){ + const len = Math.min(cw,ch)*1.1; const dirX = Math.cos(pred.heading), dirY = Math.sin(pred.heading); + ctx.strokeStyle = `hsla(15,90%,70%,${Math.max(0, Math.min(1, vel ? Math.hypot(vel.dx, vel.dy)/5 : 0.8))})`; + ctx.beginPath(); ctx.moveTo(pos.x*cw, pos.y*ch); ctx.lineTo(pos.x*cw + dirX*len*0.55, pos.y*ch + dirY*len*0.55); ctx.stroke(); } - ctx.shadowBlur = 0; ctx.shadowColor='transparent'; + ctx.restore(); } // Command lines to closest prey ctx.save(); - ctx.globalAlpha = 0.6; + ctx.globalAlpha = 0.45; ctx.strokeStyle = 'rgba(255,140,120,0.55)'; ctx.lineWidth = 1.5; const herbPositions = new Map(); @@ -728,14 +753,14 @@

🐾 Wildlands Command 🌌

const px = pos.x*cw, py = pos.y*ch; const glyph = EMOJI.herb[id % EMOJI.herb.length]; ctx.save(); - ctx.shadowColor='rgba(120,255,200,0.35)'; ctx.shadowBlur=8; + ctx.shadowColor='rgba(120,255,200,0.25)'; ctx.shadowBlur=4; ctx.fillText(glyph, px - r*0.4, py + r*0.4); ctx.restore(); const barW = Math.min(cw,ch)*0.9, barH = Math.min(cw,ch)*0.16; ctx.strokeStyle='rgba(10,30,35,0.5)'; ctx.lineWidth=barH; ctx.beginPath(); ctx.moveTo(px - barW/2, py + r*0.6); ctx.lineTo(px + barW/2, py + r*0.6); ctx.stroke(); - ctx.strokeStyle='rgba(100,255,200,0.9)'; ctx.lineWidth=barH*0.9; ctx.beginPath(); + ctx.strokeStyle='rgba(100,255,200,0.85)'; ctx.lineWidth=barH*0.9; ctx.beginPath(); ctx.moveTo(px - barW/2, py + r*0.6); ctx.lineTo(px - barW/2 + barW*energy, py + r*0.6); ctx.stroke(); } for(const [id, pos, pred] of world.query(Position, Predator)){ @@ -744,14 +769,14 @@

🐾 Wildlands Command 🌌

const px = pos.x*cw, py = pos.y*ch; const glyph = EMOJI.pred[id % EMOJI.pred.length]; ctx.save(); - ctx.shadowColor='rgba(255,140,120,0.4)'; ctx.shadowBlur=8; + ctx.shadowColor='rgba(255,140,120,0.28)'; ctx.shadowBlur=4; ctx.fillText(glyph, px - r*0.42, py + r*0.32); ctx.restore(); const barW = Math.min(cw,ch)*0.95, barH = Math.min(cw,ch)*0.18; ctx.strokeStyle='rgba(30,10,10,0.5)'; ctx.lineWidth=barH; ctx.beginPath(); ctx.moveTo(px - barW/2, py + r*0.55); ctx.lineTo(px + barW/2, py + r*0.55); ctx.stroke(); - ctx.strokeStyle='rgba(255,140,120,0.95)'; ctx.lineWidth=barH*0.9; ctx.beginPath(); + ctx.strokeStyle='rgba(255,140,120,0.9)'; ctx.lineWidth=barH*0.9; ctx.beginPath(); ctx.moveTo(px - barW/2, py + r*0.55); ctx.lineTo(px - barW/2 + barW*energy, py + r*0.55); ctx.stroke(); } From 1f0f1c308c86e6a8594737ae2c0b2d632efd4df7 Mon Sep 17 00:00:00 2001 From: Pete Jensen Date: Sun, 23 Nov 2025 18:47:18 -0500 Subject: [PATCH 14/17] Tighten plant growth to respect dryness --- demo/food-web.html | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/demo/food-web.html b/demo/food-web.html index 59b9060..0225e89 100644 --- a/demo/food-web.html +++ b/demo/food-web.html @@ -289,12 +289,15 @@

🐾 Wildlands Command 🌌

function seedField(cols, rows, rainfall, sunlight, rand){ const n = cols*rows; const plants = new Float32Array(n); const carrion = new Float32Array(n); const scent = new Float32Array(n); const fertility = makeFertility(cols, rows, rand); + const moisture = Math.pow(Math.max(0, rainfall), 1.4); + const light = 0.35 + 0.65 * sunlight; for(let i=0;i 0.32 || rand() < seedChance) && rand() < seedChance ? 1 : 0; + const bias = 0.12 + 0.28*rand(); + plants[i] = cover ? cap * bias * seedChance : 0; } return { plants, carrion, scent, fertility }; } @@ -402,12 +405,15 @@

🐾 Wildlands Command 🌌

let totalPlants = 0; for(let i=0;i🐾 Wildlands Command 🌌 // Conway-esque birth: empty-ish tiles bloom when surrounded by lush neighbors. let newPlant = plant; const avg = plantSum / 8; - const capacity = 5 + fert*12; - if(plant < capacity*0.12 && denseNeighbors >= 3){ - newPlant += dt * avg * (0.12 + fert*0.18); + const capacity = (2 + fert*8) * (0.32 + biome.rainfall*0.5); + if(plant < capacity*0.1 && denseNeighbors >= 3){ + newPlant += dt * avg * (0.08 + fert*0.14) * Math.max(0.2, biome.rainfall*0.9); } else { // Drift toward neighborhood average to keep clumps cohesive. - newPlant += dt * (avg - plant) * (0.08 + fert*0.12); + newPlant += dt * (avg - plant) * (0.06 + fert*0.1); } - const damp = Math.max(0, plant - capacity*0.9); - newPlant = Math.max(0, Math.min(capacity*1.15, newPlant - damp*0.12*dt)); + const damp = Math.max(0, plant - capacity*0.82); + newPlant = Math.max(0, Math.min(capacity*1.05, newPlant - damp*0.14*dt)); nextPlants[i] = newPlant; // Carrion wafts outward gently. From c2337de722f4c33f51ab76452368bdb96139ee59 Mon Sep 17 00:00:00 2001 From: Pete Jensen Date: Fri, 6 Feb 2026 16:48:31 -0500 Subject: [PATCH 15/17] Add 55 tests across 6 new files covering hierarchy edge cases, query filters (Not/Changed), error paths, strict mode, and performance. Fix _bumpIndices to use stored sibling index instead of iteration position, which caused stale indices after mid-list detach. --- hierarchy.js | 5 +- tests/hierarchy.edgecases.test.mjs | 133 +++++++++++++++++ tests/hierarchy.reparent.test.mjs | 155 +++++++++++++++++++ tests/world.errors.test.mjs | 75 ++++++++++ tests/world.performance.test.mjs | 134 +++++++++++++++++ tests/world.query.test.mjs | 206 ++++++++++++++++++++++++++ tests/world.strict.edgecases.test.mjs | 53 +++++++ 7 files changed, 758 insertions(+), 3 deletions(-) create mode 100644 tests/hierarchy.edgecases.test.mjs create mode 100644 tests/hierarchy.reparent.test.mjs create mode 100644 tests/world.errors.test.mjs create mode 100644 tests/world.performance.test.mjs create mode 100644 tests/world.query.test.mjs create mode 100644 tests/world.strict.edgecases.test.mjs diff --git a/hierarchy.js b/hierarchy.js index f3abc89..e58a282 100644 --- a/hierarchy.js +++ b/hierarchy.js @@ -154,10 +154,9 @@ function _reinsertSameParent(world, child, parent, opts){ /** @private */ function _bumpIndices(world, parent, startIdx, delta){ - let i=0; for (const c of children(world, parent)){ - if (i >= startIdx){ const s = world.get(c, Sibling); world.set(c, Sibling, { index: (s.index|0) + delta }); } - i++; + const s = world.get(c, Sibling); + if ((s.index|0) >= startIdx) world.set(c, Sibling, { index: (s.index|0) + delta }); } } /** @private */ diff --git a/tests/hierarchy.edgecases.test.mjs b/tests/hierarchy.edgecases.test.mjs new file mode 100644 index 0000000..ab36bd8 --- /dev/null +++ b/tests/hierarchy.edgecases.test.mjs @@ -0,0 +1,133 @@ +import { assert, test } from './testlib.js'; +import { World } from '../core.js'; +import { + attach, detach, children, childCount, ensureParent, + Parent, Sibling, getParent, indexOf +} from '../hierarchy.js'; + +test('attach throws on self-attach', () => { + const world = new World(); + const e = world.create(); + assert.throws(() => attach(world, e, e), Error, /cannot parent to self/); +}); + +test('attach throws when parent is descendant of child (cycle)', () => { + const world = new World(); + const a = world.create(); + const b = world.create(); + const c = world.create(); + attach(world, b, a); + attach(world, c, b); + // a -> b -> c; attaching a under c would create a cycle + assert.throws(() => attach(world, a, c), Error, /cannot create a cycle/); +}); + +test('attach throws when both before and after specified', () => { + const world = new World(); + const parent = world.create(); + const x = world.create(); + const y = world.create(); + attach(world, x, parent); + attach(world, y, parent); + const child = world.create(); + assert.throws( + () => attach(world, child, parent, { before: x, after: y }), + Error, + /at most one of before\/after/ + ); +}); + +test('attach throws when before target is not child of parent', () => { + const world = new World(); + const p1 = world.create(); + const p2 = world.create(); + const x = world.create(); + attach(world, x, p2); // x belongs to p2, not p1 + ensureParent(world, p1); + const child = world.create(); + assert.throws( + () => attach(world, child, p1, { before: x }), + Error, + /before target not child of parent/ + ); +}); + +test('attach throws when after target is not child of parent', () => { + const world = new World(); + const p1 = world.create(); + const p2 = world.create(); + const x = world.create(); + attach(world, x, p2); // x belongs to p2, not p1 + ensureParent(world, p1); + const child = world.create(); + assert.throws( + () => attach(world, child, p1, { after: x }), + Error, + /after target not child of parent/ + ); +}); + +test('detach is a no-op for entity without Sibling component', () => { + const world = new World(); + const e = world.create(); + // should not throw, just return the entity + const result = detach(world, e); + assert.equal(result, e); + assert.equal(world.has(e, Sibling), false, 'entity should still lack Sibling'); +}); + +test('detach an only child empties the parent', () => { + const world = new World(); + const parent = world.create(); + const child = world.create(); + attach(world, child, parent); + assert.equal(childCount(world, parent), 1); + + detach(world, child); + assert.equal(childCount(world, parent), 0); + const p = world.get(parent, Parent); + assert.equal(p.first, 0, 'first should be 0'); + assert.equal(p.last, 0, 'last should be 0'); +}); + +test('detach with opts.remove strips Sibling component', () => { + const world = new World(); + const parent = world.create(); + const child = world.create(); + attach(world, child, parent); + assert.ok(world.has(child, Sibling)); + + detach(world, child, { remove: true }); + assert.equal(world.has(child, Sibling), false, 'Sibling should be removed'); +}); + +test('detach without opts.remove zeroes Sibling fields', () => { + const world = new World(); + const parent = world.create(); + const child = world.create(); + attach(world, child, parent); + + detach(world, child); + assert.ok(world.has(child, Sibling), 'Sibling should still be present'); + const s = world.get(child, Sibling); + assert.equal(s.parent, 0, 'parent should be zeroed'); + assert.equal(s.prev, 0, 'prev should be zeroed'); + assert.equal(s.next, 0, 'next should be zeroed'); + assert.equal(s.index, 0, 'index should be zeroed'); +}); + +test('children yields nothing for entity without Parent component', () => { + const world = new World(); + const e = world.create(); + const list = [...children(world, e)]; + assert.deepEqual(list, []); +}); + +test('children yields nothing for ensured but empty parent', () => { + const world = new World(); + const e = world.create(); + ensureParent(world, e); + assert.ok(world.has(e, Parent), 'should have Parent component'); + const list = [...children(world, e)]; + assert.deepEqual(list, []); +}); diff --git a/tests/hierarchy.reparent.test.mjs b/tests/hierarchy.reparent.test.mjs new file mode 100644 index 0000000..408e130 --- /dev/null +++ b/tests/hierarchy.reparent.test.mjs @@ -0,0 +1,155 @@ +import { assert, test } from './testlib.js'; +import { World } from '../core.js'; +import { + attach, detach, reparent, destroySubtree, + children, childCount, getParent, indexOf, + Parent, Sibling, ensureParent +} from '../hierarchy.js'; + +test('reparent moves child from one parent to another', () => { + const world = new World(); + const pA = world.create(); + const pB = world.create(); + const child = world.create(); + attach(world, child, pA); + assert.equal(getParent(world, child), pA); + assert.equal(childCount(world, pA), 1); + + reparent(world, child, pB); + assert.equal(getParent(world, child), pB); + assert.equal(childCount(world, pA), 0, 'old parent should have 0 children'); + assert.equal(childCount(world, pB), 1, 'new parent should have 1 child'); +}); + +test('reparent with before/after ordering', () => { + const world = new World(); + const pA = world.create(); + const pB = world.create(); + const x = world.create(); + const y = world.create(); + const child = world.create(); + attach(world, x, pB); + attach(world, y, pB); + attach(world, child, pA); + + reparent(world, child, pB, { before: y }); + const kids = [...children(world, pB)]; + assert.deepEqual(kids, [x, child, y]); +}); + +test('reparent to self throws via attach guard', () => { + const world = new World(); + const e = world.create(); + assert.throws(() => reparent(world, e, e), Error, /cannot parent to self/); +}); + +test('reparent creating cycle throws', () => { + const world = new World(); + const a = world.create(); + const b = world.create(); + const c = world.create(); + attach(world, b, a); + attach(world, c, b); + // a -> b -> c; reparenting a under c would be a cycle + assert.throws(() => reparent(world, a, c), Error, /cannot create a cycle/); +}); + +test('deep tree: 5-level hierarchy traversal', () => { + const world = new World(); + const root = world.create(); + const a = world.create(); + const b = world.create(); + const c = world.create(); + const d = world.create(); + const e = world.create(); + attach(world, a, root); + attach(world, b, a); + attach(world, c, b); + attach(world, d, c); + attach(world, e, d); + + assert.equal(getParent(world, a), root); + assert.equal(getParent(world, b), a); + assert.equal(getParent(world, c), b); + assert.equal(getParent(world, d), c); + assert.equal(getParent(world, e), d); + assert.equal(childCount(world, root), 1); + assert.equal(childCount(world, d), 1); +}); + +test('destroySubtree on deep tree destroys all descendants', () => { + const world = new World(); + const root = world.create(); + const a = world.create(); + const b = world.create(); + const c = world.create(); + const d = world.create(); + const e = world.create(); + attach(world, a, root); + attach(world, b, a); + attach(world, c, b); + attach(world, d, c); + attach(world, e, d); + + destroySubtree(world, root); + for (const eid of [root, a, b, c, d, e]) { + assert.equal(world.isAlive(eid), false, `entity ${eid} should be dead`); + } +}); + +test('destroySubtree on leaf destroys only that node', () => { + const world = new World(); + const parent = world.create(); + const leaf = world.create(); + const sibling = world.create(); + attach(world, leaf, parent); + attach(world, sibling, parent); + assert.equal(childCount(world, parent), 2); + + // destroySubtree calls world.destroy() which removes component stores + // but does NOT call detach(), so the parent's count is not decremented. + // Use detach + destroy for clean removal from a surviving parent. + detach(world, leaf); + destroySubtree(world, leaf); + assert.equal(world.isAlive(leaf), false, 'leaf should be dead'); + assert.ok(world.isAlive(parent), 'parent should be alive'); + assert.ok(world.isAlive(sibling), 'sibling should be alive'); + assert.equal(childCount(world, parent), 1, 'parent should have 1 child remaining'); +}); + +test('reparent preserves sibling indices at new parent', () => { + const world = new World(); + const pA = world.create(); + const pB = world.create(); + const x = world.create(); + const child = world.create(); + const y = world.create(); + const m = world.create(); + const n = world.create(); + + // pA has [x, child, y] + attach(world, x, pA); + attach(world, child, pA); + attach(world, y, pA); + // pB has [m, n] + attach(world, m, pB); + attach(world, n, pB); + + assert.equal(indexOf(world, x), 0); + assert.equal(indexOf(world, child), 1); + assert.equal(indexOf(world, y), 2); + + reparent(world, child, pB); + + // pA should now have [x, y] with contiguous indices 0, 1 + assert.deepEqual([...children(world, pA)], [x, y]); + assert.equal(childCount(world, pA), 2); + assert.equal(indexOf(world, x), 0); + assert.equal(indexOf(world, y), 1, 'y should be bumped down to index 1 after middle child removed'); + + // pB should have [m, n, child] with contiguous indices + assert.deepEqual([...children(world, pB)], [m, n, child]); + assert.equal(indexOf(world, m), 0); + assert.equal(indexOf(world, n), 1); + assert.equal(indexOf(world, child), 2); +}); diff --git a/tests/world.errors.test.mjs b/tests/world.errors.test.mjs new file mode 100644 index 0000000..1f6d600 --- /dev/null +++ b/tests/world.errors.test.mjs @@ -0,0 +1,75 @@ +import { assert, test } from './testlib.js'; +import { World, defineComponent, Component, WorldBuilder } from '../core.js'; + +const Position = defineComponent('ErrPos', { x: 0, y: 0 }); +const Validated = defineComponent('ErrVal', { x: 0 }, { + validate: (r) => r.x > 0 +}); + +test('add on dead entity throws', () => { + const world = new World(); + const eid = world.create(); + world.destroy(eid); + assert.throws(() => world.add(eid, Position, { x: 1 }), Error, /add: entity not alive/); +}); + +test('set on missing component throws', () => { + const world = new World(); + const eid = world.create(); + assert.throws(() => world.set(eid, Position, { x: 1 }), Error, /set: entity lacks component/); +}); + +test('mutate on missing component throws', () => { + const world = new World(); + const eid = world.create(); + assert.throws(() => world.mutate(eid, Position, r => { r.x = 1; }), Error, /mutate: entity lacks component/); +}); + +test('tick without scheduler throws', () => { + const world = new World(); + assert.throws(() => world.tick(1), Error, /tick: no scheduler installed/); +}); + +test('add with failing validation throws', () => { + const world = new World(); + const eid = world.create(); + assert.throws(() => world.add(eid, Validated, { x: -1 }), Error, /Validation failed/); +}); + +test('set with failing validation throws', () => { + const world = new World(); + const eid = world.create(); + world.add(eid, Validated, { x: 5 }); + assert.throws(() => world.set(eid, Validated, { x: -1 }), Error, /Validation failed/); +}); + +test('Component builder with empty name throws', () => { + assert.throws(() => Component(''), Error, /non-empty name/); +}); + +test('withSchedulerFn with non-function throws', () => { + const builder = new WorldBuilder(); + assert.throws(() => builder.withSchedulerFn('not a fn'), Error, /scheduler must be a function/); +}); + +test('setScheduler with non-function throws', () => { + const world = new World(); + assert.throws(() => world.setScheduler(42), Error, /scheduler must be a function/); +}); + +test('onStrictError with non-function non-null throws', () => { + const world = new World(); + assert.throws(() => world.onStrictError(42), Error, /handler must be a function or null/); +}); + +test('strict mode mid-tick mutation without handler is caught by tick', () => { + const world = new World({ strict: true }); + const eid = world.create(); + world.setScheduler((w) => { + w.add(eid, Position, { x: 99 }); + }); + // tick catches the scheduler error internally via logError + world.tick(1); + // The add should NOT have succeeded — strict threw, tick caught it + assert.equal(world.has(eid, Position), false, 'mutation should not apply when strict throws'); +}); diff --git a/tests/world.performance.test.mjs b/tests/world.performance.test.mjs new file mode 100644 index 0000000..fa944b3 --- /dev/null +++ b/tests/world.performance.test.mjs @@ -0,0 +1,134 @@ +import { assert, test } from './testlib.js'; +import { World, defineComponent, defineTag, Not } from '../core.js'; +import { attach, destroySubtree, children } from '../hierarchy.js'; + +const Position = defineComponent('PerfPos', { x: 0, y: 0 }); +const Velocity = defineComponent('PerfVel', { dx: 0, dy: 0 }); + +test('bulk creation: 10,000 entities', () => { + const world = new World(); + const ids = new Set(); + for (let i = 0; i < 10000; i++) { + ids.add(world.create()); + } + assert.equal(world.alive.size, 10000, 'all 10K entities should be alive'); + assert.equal(ids.size, 10000, 'all ids should be unique'); +}); + +test('bulk query: 10,000 entities with component', () => { + const world = new World(); + for (let i = 0; i < 10000; i++) { + const e = world.create(); + world.add(e, Position, { x: i, y: i * 2 }); + } + const result = world.query(Position); + assert.equal(result.count(), 10000); + + // Verify iteration works + let count = 0; + for (const [id, pos] of result) { count++; } + assert.equal(count, 10000, 'iteration should yield all 10K'); +}); + +test('query cache invalidation under churn', () => { + const world = new World(); + const entities = []; + for (let i = 0; i < 1000; i++) { + const e = world.create(); + world.add(e, Position, { x: i, y: 0 }); + entities.push(e); + } + + // Warm the cache + assert.equal(world.query(Position).count(), 1000); + + // Remove Position from first 500 + for (let i = 0; i < 500; i++) { + world.remove(entities[i], Position); + } + + // Add Position to 500 new entities + for (let i = 0; i < 500; i++) { + const e = world.create(); + world.add(e, Position, { x: 1000 + i, y: 0 }); + } + + // Re-query — cache should have been invalidated + assert.equal(world.query(Position).count(), 1000, 'query should reflect churn'); +}); + +test('SoA store: create and query 5,000 entities', () => { + const world = new World({ store: 'soa' }); + for (let i = 0; i < 5000; i++) { + const e = world.create(); + world.add(e, Position, { x: i, y: i * 3 }); + } + const result = world.query(Position); + assert.equal(result.count(), 5000); + + // Verify data integrity via iteration + let count = 0; + for (const [id, pos] of result) { + assert.ok(Number.isFinite(pos.x), 'x should be finite'); + count++; + } + assert.equal(count, 5000); +}); + +test('deferred op queue at MAX boundary (1001 destroys)', () => { + const world = new World(); + const entities = []; + for (let i = 0; i < 1001; i++) { + entities.push(world.create()); + } + assert.equal(world.alive.size, 1001); + + world.setScheduler((w) => { + for (const e of entities) w.destroy(e); + }); + + // First tick: scheduler queues 1001 destroys, flush processes MAX=1000 + world.tick(1); + assert.equal(world.alive.size, 1, 'exactly 1 entity should survive first flush'); + + // Second tick: remaining 1 destroy flushes + world.tick(1); + assert.equal(world.alive.size, 0, 'all entities should be gone after second tick'); +}); + +test('deep hierarchy: 100-level chain with destroySubtree', () => { + const world = new World(); + const root = world.create(); + let parent = root; + for (let i = 0; i < 100; i++) { + const child = world.create(); + attach(world, child, parent); + parent = child; + } + // 101 entities total (root + 100 levels) + assert.equal(world.alive.size, 101); + + destroySubtree(world, root); + assert.equal(world.alive.size, 0, 'all 101 entities should be dead'); +}); + +test('entity id reuse after bulk destroy', () => { + const world = new World(); + const entities = []; + for (let i = 0; i < 1000; i++) { + entities.push(world.create()); + } + const nextIdAfterCreate = world._nextId; + + // Destroy all + for (const e of entities) world.destroy(e); + assert.equal(world.alive.size, 0); + assert.equal(world._free.length, 1000, 'free list should have 1000 ids'); + + // Create 1000 new — should reuse from free list + for (let i = 0; i < 1000; i++) { + world.create(); + } + assert.equal(world.alive.size, 1000); + assert.equal(world._nextId, nextIdAfterCreate, '_nextId should not grow when reusing ids'); +}); diff --git a/tests/world.query.test.mjs b/tests/world.query.test.mjs new file mode 100644 index 0000000..8a0c3c5 --- /dev/null +++ b/tests/world.query.test.mjs @@ -0,0 +1,206 @@ +import { assert, test } from './testlib.js'; +import { World, defineComponent, defineTag, Not, Changed } from '../core.js'; + +const Position = defineComponent('QPos', { x: 0, y: 0 }); +const Velocity = defineComponent('QVel', { dx: 0, dy: 0 }); +const Marker = defineTag('QMarker'); +const Health = defineComponent('QHealth', { hp: 10 }); + +// ---- Not() ---- + +test('Not() excludes entities that have the negated component', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1 }); world.add(e1, Velocity, { dx: 1 }); + const e2 = world.create(); world.add(e2, Position, { x: 2 }); + const e3 = world.create(); world.add(e3, Position, { x: 3 }); world.add(e3, Marker); + + const ids = [...world.query(Position, Not(Velocity))].map(r => r[0]).sort((a, b) => a - b); + assert.deepEqual(ids, [e2, e3].sort((a, b) => a - b)); +}); + +test('Not() with all entities having negated component returns empty', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1 }); world.add(e1, Velocity, { dx: 1 }); + const e2 = world.create(); world.add(e2, Position, { x: 2 }); world.add(e2, Velocity, { dx: 2 }); + + const result = [...world.query(Position, Not(Velocity))]; + assert.equal(result.length, 0); +}); + +// ---- Changed() ---- + +test('Changed() matches only entities modified between ticks', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1, y: 0 }); + const e2 = world.create(); world.add(e2, Position, { x: 2, y: 0 }); + const e3 = world.create(); world.add(e3, Position, { x: 3, y: 0 }); + + // Tick to clear initial change marks from add() + world.setScheduler(() => {}); + world.tick(1); + + // Modify e1 and e3 BETWEEN ticks (set runs immediately, marks changed) + world.set(e1, Position, { x: 10 }); + world.set(e3, Position, { x: 30 }); + + // Now query — Changed() sees marks set between ticks + let capturedIds = []; + world.setScheduler((w) => { + capturedIds = [...w.query(Position, Changed(Position))].map(r => r[0]).sort((a, b) => a - b); + }); + world.tick(1); + assert.deepEqual(capturedIds, [e1, e3].sort((a, b) => a - b), 'only modified entities should match'); +}); + +test('Changed() returns nothing after tick clears change marks', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1, y: 0 }); + + // Tick to clear add() marks + world.setScheduler(() => {}); + world.tick(1); + + // Modify e1 in a tick + world.setScheduler((w) => { w.set(e1, Position, { x: 99 }); }); + world.tick(1); + + // After tick, change marks are cleared + const result = [...world.query(Position, Changed(Position))]; + assert.equal(result.length, 0, 'change marks should be cleared after tick'); +}); + +test('Combined Not() and Changed() filters', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1, y: 0 }); world.add(e1, Velocity, { dx: 1 }); + const e2 = world.create(); world.add(e2, Position, { x: 2, y: 0 }); + const e3 = world.create(); world.add(e3, Position, { x: 3, y: 0 }); + + // Tick to clear initial marks + world.setScheduler(() => {}); + world.tick(1); + + // Modify e1 (has Velocity) and e2 (no Velocity) between ticks + world.set(e1, Position, { x: 10 }); + world.set(e2, Position, { x: 20 }); + // e3 not modified + + let capturedIds = []; + world.setScheduler((w) => { + capturedIds = [...w.query(Position, Not(Velocity), Changed(Position))].map(r => r[0]); + }); + world.tick(1); + // Only e2: changed AND lacks Velocity + assert.deepEqual(capturedIds, [e2]); +}); + +// ---- Empty results ---- + +test('query on empty world returns empty iterable', () => { + const world = new World(); + const result = [...world.query(Position)]; + assert.equal(result.length, 0); +}); + +test('query with no matching component returns empty', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1 }); + const e2 = world.create(); world.add(e2, Position, { x: 2 }); + + const result = [...world.query(Velocity)]; + assert.equal(result.length, 0); +}); + +// ---- count() ---- + +test('count() returns accurate count with dynamic filters', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1, y: 0 }); world.add(e1, Velocity, { dx: 1 }); + const e2 = world.create(); world.add(e2, Position, { x: 2, y: 0 }); + const e3 = world.create(); world.add(e3, Position, { x: 3, y: 0 }); + + const result = world.query(Position, Not(Velocity)); + const spread = [...result].length; + const counted = result.count(); + assert.equal(counted, spread, 'count() should match iteration length'); + assert.equal(counted, 2); +}); + +test('count({cheap:true}) may overcount with Not() filter', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1, y: 0 }); world.add(e1, Velocity, { dx: 1 }); + const e2 = world.create(); world.add(e2, Position, { x: 2, y: 0 }); + const e3 = world.create(); world.add(e3, Position, { x: 3, y: 0 }); + + const result = world.query(Position, Not(Velocity)); + const cheap = result.count({ cheap: true }); + const accurate = result.count(); + // cheap returns base cached list size (all with Position = 3) + // accurate filters out those with Velocity = 2 + assert.ok(cheap >= accurate, 'cheap count should be >= accurate count'); + assert.equal(cheap, 3, 'cheap returns base list size'); + assert.equal(accurate, 2, 'accurate filters Not()'); +}); + +// ---- Cache invalidation ---- + +test('cache invalidation: adding a component updates query results', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1, y: 0 }); + const before = [...world.query(Position)].map(r => r[0]); + assert.deepEqual(before, [e1]); + + const e2 = world.create(); world.add(e2, Position, { x: 2, y: 0 }); + const after = [...world.query(Position)].map(r => r[0]).sort((a, b) => a - b); + assert.deepEqual(after, [e1, e2].sort((a, b) => a - b)); +}); + +test('cache invalidation: removing a component updates query results', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1, y: 0 }); + const e2 = world.create(); world.add(e2, Position, { x: 2, y: 0 }); + + const before = [...world.query(Position)].map(r => r[0]).sort((a, b) => a - b); + assert.deepEqual(before, [e1, e2].sort((a, b) => a - b)); + + world.remove(e1, Position); + const after = [...world.query(Position)].map(r => r[0]); + assert.deepEqual(after, [e2]); +}); + +// ---- offset/limit ---- + +test('limit=0 returns nothing', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1, y: 0 }); + const e2 = world.create(); world.add(e2, Position, { x: 2, y: 0 }); + + const result = [...world.query(Position, { limit: 0 })]; + assert.equal(result.length, 0); +}); + +test('offset greater than result count returns nothing', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1, y: 0 }); + const e2 = world.create(); world.add(e2, Position, { x: 2, y: 0 }); + + const result = [...world.query(Position, { offset: 100 })]; + assert.equal(result.length, 0); +}); + +// ---- defineQuery handle across ticks ---- + +test('defineQuery handle returns consistent results across ticks', () => { + const world = new World(); + const e1 = world.create(); world.add(e1, Position, { x: 1, y: 0 }); + const e2 = world.create(); world.add(e2, Position, { x: 2, y: 0 }); + + const qh = world.defineQuery(Position); + world.setScheduler(() => {}); + + const ids1 = [...qh()].map(r => r[0]).sort((a, b) => a - b); + world.tick(1); + const ids2 = [...qh()].map(r => r[0]).sort((a, b) => a - b); + + assert.deepEqual(ids1, [e1, e2].sort((a, b) => a - b)); + assert.deepEqual(ids2, ids1, 'handle should return same results across ticks'); +}); diff --git a/tests/world.strict.edgecases.test.mjs b/tests/world.strict.edgecases.test.mjs new file mode 100644 index 0000000..20d5dc7 --- /dev/null +++ b/tests/world.strict.edgecases.test.mjs @@ -0,0 +1,53 @@ +import { assert, test } from './testlib.js'; +import { World, defineComponent } from '../core.js'; + +const Tag = defineComponent('StrictTag', { v: 0 }); + +test('strict handler returning "defer" string queues the operation', () => { + const world = new World({ strict: true }); + const eid = world.create(); + world.onStrictError(() => 'defer'); + world.setScheduler((w) => { + w.add(eid, Tag, { v: 7 }); + }); + world.tick(1); + const rec = world.get(eid, Tag); + assert.ok(rec, 'component should be attached after deferred execution'); + assert.equal(rec.v, 7); +}); + +test('strict handler returning false ignores the operation', () => { + const world = new World({ strict: true }); + const eid = world.create(); + world.onStrictError(() => false); + world.setScheduler((w) => { + w.destroy(eid); + }); + world.tick(1); + assert.ok(world.isAlive(eid), 'false should act as ignore, keeping entity alive'); + assert.equal(world.pendingOps().length, 0, 'no commands should be queued'); +}); + +test('strict handler returning undefined falls through to throw', () => { + const world = new World({ strict: true }); + const eid = world.create(); + world.onStrictError(() => { /* returns undefined */ }); + world.setScheduler((w) => { + w.add(eid, Tag, { v: 1 }); + }); + // tick catches the re-thrown strict error + world.tick(1); + assert.equal(world.has(eid, Tag), false, 'mutation should not apply when handler returns undefined'); +}); + +test('strict handler that throws its own error still blocks mutation', () => { + const world = new World({ strict: true }); + const eid = world.create(); + world.onStrictError(() => { throw new Error('custom handler error'); }); + world.setScheduler((w) => { + w.add(eid, Tag, { v: 1 }); + }); + // tick catches the error chain + world.tick(1); + assert.equal(world.has(eid, Tag), false, 'mutation should not apply when handler throws'); +}); From 88796fa1b4d8dbf3efeba78b807f025d7767163f Mon Sep 17 00:00:00 2001 From: Pete Jensen Date: Fri, 6 Feb 2026 17:38:55 -0500 Subject: [PATCH 16/17] boids demo (early) --- demo/boids-swarm.html | 809 ++++++++++++++++++++++++++++++++++++++++++ index.html | 1 + 2 files changed, 810 insertions(+) create mode 100644 demo/boids-swarm.html diff --git a/demo/boids-swarm.html b/demo/boids-swarm.html new file mode 100644 index 0000000..17facdf --- /dev/null +++ b/demo/boids-swarm.html @@ -0,0 +1,809 @@ + + +ecs-js — Boids Swarm + +
+
+

Boids Swarm

+

Classic Reynolds flocking — separation, alignment, cohesion — with predator pursuit. SoA storage, phase scheduling, archetypes, entity scripts, Not() queries, and events.

+
+ + + +
+
+ + +
+ + Flocking rules +
+ + + + + + +
+ + Perception +
+ + + + +
+ + Populations +
+ + + + +
+ + Speed +
+ + + + + + +
+ + Predator / prey +
+ + + + + + +
+ + Display +
+ + + + +
+ +
+ Time0.00 + Prey- + Predators- + FPS- + Catches0 + Scouts0 +
+
+
Event log
+
+
+
+ Phases: sense → steer → integrate → wrap → render → scripts.
+ SoA store, archetypes, Not() queries, entity scripts, events. +
+
+ +
+ diff --git a/index.html b/index.html index 72b1362..184f9b3 100644 --- a/index.html +++ b/index.html @@ -108,6 +108,7 @@

ECS-js

  • Base demo (logs below)
  • Fishery collapse demo
  • Ecosystem food-web sandbox
  • +
  • Boids swarm simulation
  • From 860dce08799d5075787bff50c5cbfee7ea43d8b2 Mon Sep 17 00:00:00 2001 From: Pete Jensen Date: Fri, 6 Feb 2026 17:39:27 -0500 Subject: [PATCH 17/17] SoA tests, intra-tick --- tests/world.soa.intratick.test.mjs | 151 +++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 tests/world.soa.intratick.test.mjs diff --git a/tests/world.soa.intratick.test.mjs b/tests/world.soa.intratick.test.mjs new file mode 100644 index 0000000..2a786d5 --- /dev/null +++ b/tests/world.soa.intratick.test.mjs @@ -0,0 +1,151 @@ +import { assert, test } from './testlib.js'; + +import { World, defineComponent } from '../core.js'; +import { clearSystems, composeScheduler } from '../systems.js'; + +const Position = defineComponent('Position', { x: 0, y: 0 }); +const Velocity = defineComponent('Velocity', { dx: 0, dy: 0 }); + +test('SoA direct mutation is visible to later phases in the same tick', (t) => { + for (const store of ['soa', 'map']) { + clearSystems(); + const world = new World({ store }); + const e = world.create(); + world.add(e, Position, { x: 0, y: 0 }); + world.add(e, Velocity, { dx: 10, dy: 5 }); + + let posAfterMove = null; + + // Phase 1: move — directly mutate Position via the query tuple reference + function moveSystem(w, dt) { + for (const [id, pos, vel] of w.query(Position, Velocity)) { + pos.x += vel.dx * dt; + pos.y += vel.dy * dt; + } + } + + // Phase 2: read — read Position and verify it reflects the mutation + function readSystem(w, dt) { + for (const [id, pos] of w.query(Position)) { + posAfterMove = { x: pos.x, y: pos.y }; + } + } + + world.system(moveSystem, 'move'); + world.system(readSystem, 'read'); + world.setScheduler(composeScheduler('move', 'read')); + + world.tick(1); + + assert.equal(posAfterMove.x, 10, `[${store}] direct mutation should be visible in later phase`); + assert.equal(posAfterMove.y, 5, `[${store}] direct mutation should be visible in later phase`); + } + t.after(clearSystems); +}); + +test('world.set() is deferred during tick and NOT visible to later phases', (t) => { + for (const store of ['soa', 'map']) { + clearSystems(); + const world = new World({ store }); + const e = world.create(); + world.add(e, Position, { x: 0, y: 0 }); + world.add(e, Velocity, { dx: 10, dy: 5 }); + + let posAfterMove = null; + + // Phase 1: move — use world.set() (deferred in non-strict mode) + function moveSystem(w, dt) { + for (const [id, pos, vel] of w.query(Position, Velocity)) { + w.set(id, Position, { x: pos.x + vel.dx * dt, y: pos.y + vel.dy * dt }); + } + } + + // Phase 2: read — check what Position looks like + function readSystem(w, dt) { + for (const [id, pos] of w.query(Position)) { + posAfterMove = { x: pos.x, y: pos.y }; + } + } + + world.system(moveSystem, 'move'); + world.system(readSystem, 'read'); + world.setScheduler(composeScheduler('move', 'read')); + + world.tick(1); + + // world.set() was deferred, so readSystem sees OLD values + assert.equal(posAfterMove.x, 0, `[${store}] world.set() should be deferred; old value visible`); + assert.equal(posAfterMove.y, 0, `[${store}] world.set() should be deferred; old value visible`); + + // After tick completes, deferred set is flushed + const pos = world.get(e, Position); + assert.equal(pos.x, 10, `[${store}] deferred set should be applied after tick`); + assert.equal(pos.y, 5, `[${store}] deferred set should be applied after tick`); + } + t.after(clearSystems); +}); + +test('SoA view object properties write through to backing arrays', () => { + const world = new World({ store: 'soa' }); + const e = world.create(); + world.add(e, Position, { x: 1, y: 2 }); + + // Get a view, mutate it, then get another view — should reflect the mutation + const view1 = world.get(e, Position); + view1.x = 99; + + const view2 = world.get(e, Position); + assert.equal(view2.x, 99, 'SoA views should share the same backing array'); + assert.equal(view1 === view2, true, 'SoA should return the same view object for a given entity'); +}); + +test('multi-phase pipeline: steer → integrate → wrap all via direct mutation', (t) => { + clearSystems(); + const world = new World({ store: 'soa' }); + const Accel = defineComponent('Accel', { ax: 0, ay: 0 }); + const e = world.create(); + world.add(e, Position, { x: 50, y: 50 }); + world.add(e, Velocity, { dx: 0, dy: 0 }); + world.add(e, Accel, { ax: 100, ay: 0 }); + + const WIDTH = 100; + const trail = []; + + function integrateSystem(w, dt) { + for (const [id, pos, vel, acc] of w.query(Position, Velocity, Accel)) { + vel.dx += acc.ax * dt; + vel.dy += acc.ay * dt; + pos.x += vel.dx * dt; + pos.y += vel.dy * dt; + } + } + + function wrapSystem(w, dt) { + for (const [id, pos] of w.query(Position)) { + pos.x = ((pos.x % WIDTH) + WIDTH) % WIDTH; + } + } + + function recordSystem(w, dt) { + for (const [id, pos, vel] of w.query(Position, Velocity)) { + trail.push({ x: pos.x, y: pos.y, dx: vel.dx, dy: vel.dy }); + } + } + + world.system(integrateSystem, 'integrate'); + world.system(wrapSystem, 'wrap'); + world.system(recordSystem, 'record'); + world.setScheduler(composeScheduler('integrate', 'wrap', 'record')); + + // Tick 1: accel=100, dt=1 → vel becomes 100, pos becomes 50+100=150 → wrap to 50 + world.tick(1); + assert.equal(trail[0].dx, 100, 'velocity should update from acceleration'); + assert.equal(trail[0].x, 50, 'position should wrap after exceeding width'); + + // Tick 2: vel=100+100=200, pos=50+200=250 → wrap to 50 + world.tick(1); + assert.equal(trail[1].dx, 200, 'velocity accumulates across ticks'); + assert.equal(trail[1].x, 50, 'position should wrap again'); + + t.after(clearSystems); +});