diff --git a/ECOLOGY-MODEL.md b/ECOLOGY-MODEL.md new file mode 100644 index 0000000..6425089 --- /dev/null +++ b/ECOLOGY-MODEL.md @@ -0,0 +1,108 @@ +# ECOSPHERE v41 — 生態モデル仕様 + +## 1. 単位と timestep + +- time: day +- `dt = 1/24 day` +- distance: m +- animal wet mass: kg +- animal energy reserve: kJ +- producer / zooplankton: kg biomass m^-2 + +## 2. 表現 + +- terrestrial/aquatic producer: `ResourceField` grid +- zooplankton: cohort biomass grid +- macro consumers: individual agents +- food web: fixed sparse consumer-resource graph + +水深/鉛直層、人為的攪乱、自動移入、自動種分化は実装しない。 + +## 3. 生産者 + +水域の最大増殖率: + +`muMax(T) = 0.81 * exp(0.0631*T) day^-1` + +実増殖率: + +`mu = muMax(T) * min(fN,fP) * fLight` + +freshwater / marine で Bissinger式を温度上限として使い、光・N・P で制限する。dissolved oxygen はこの式へ直接掛けない。 + +陸上 producer は fallback として `rMax=0.03 day^-1`, `K=0.5 kg m^-2` と moisture / light / temperature limitation を使う。 + +## 4. 摂食 + +固定 interaction edge は resource type ごとに + +- preference weight +- assimilation efficiency +- attack baseline +- handling-time baseline +- functional response type + +を持つ。 + +植物由来 assimilation = 0.45、動物由来 = 0.85。 + +Holling response: + +`Fij = aij * Pij * Nj^q / (1 + sum(aik * Pik * hik * Nk^q))` + +既定 edge は Type II (`q=1`)。関数は Type III (`q=2`) にも対応するが、v41 は runtime API で edge を差し替えない。 + +Rall et al. 2012 の all-data slopes を attack / handling の mass-temperature scaling に使う。 + +- attack: consumer `+0.47`, resource `+0.15`, activation energy `+0.44 eV` +- handling: consumer `-0.48`, resource `+0.34`, activation energy `-0.27 eV` +- local mass-ratio residualsと handling-temperature residualも基準点へ正規化して適用 + +捕食イベントは `p = 1-exp(-rate*dt)` でサンプリングする。 + +## 5. 代謝・死亡・繁殖 + +basal metabolism は `B0 * M^0.75` を基本にする。ectotherm は Arrhenius 型温度補正、endotherm は別 normalization。 + +死亡: +- predation +- starvation +- lifespan +- background hazard +- thermal stress + +個体は `structuralMassKg` と reserve energy (kJ) を分離して保持する。juvenile の structural growth は余剰 reserve energy から支払い、adult body mass を上限とする。年齢だけで体重を自動増加させない。 + +繁殖は mature / breeding season / interval / energy threshold を満たした個体が offspring を生成し、親は死亡しない。 + +## 6. 移動 + +Hirt et al. 2017 Supplementary Table 4 の + +`v_kmh = a*M^b*(1-exp(-h*M^i))` + +を maximum speed に使う。 + +| mode | a | b | h | i | +|---|---:|---:|---:|---:| +| flying | 142.8 | 0.24 | 2.4 | -0.72 | +| running | 25.5 | 0.26 | 22.0 | -0.60 | +| swimming | 11.2 | 0.36 | 19.5 | -0.56 | + +maximum / routine / foraging / escape speed、daily movement budget、home range、dispersal を分離する。後者の behavioural multipliers は simulator calibration。 + +## 7. climate / profile + +`terrestrial / freshwater / marine` profile を分離する。通常UIでは aquatic profile を freshwater / marine で切り替える。 + +`ClimateProvider` は seasonal fallback を持つ。観測 series を扱える内部機構は残すが、v41 では外部 Worker API を公開しない。 + +## 8. 水域 + +初期 procedural water の radius に `sqrt(2)` を一度掛ける。union 面積の厳密測定・最適化はしない。 + +ユーザー編集水域は `setWater()` に渡された形状をそのまま採用する。追加ツール既定半径は 170 m。 + +## 9. provenance + +文献由来値と較正値の区分は `PARAMETER-PROVENANCE.json`、実測検査は `VALIDATION.md` を参照。 diff --git a/IMPLEMENTATION-NOTES.md b/IMPLEMENTATION-NOTES.md new file mode 100644 index 0000000..d8b1a32 --- /dev/null +++ b/IMPLEMENTATION-NOTES.md @@ -0,0 +1,65 @@ +# 実装記録 — v41 + +## 目的 + +v38 の描画・空間探索・水域/岩編集・Web Worker を維持しつつ、生態学コアを実単位ベースへ置換する。ユーザー指定により水深と人為的攪乱は実装しない。初期水域は厳密面積探索を使わず概ね2倍とし、ユーザー編集水域には倍率を掛けない。 + +v41 では追加要求により、実験用 API と詳細診断出力を削除した。 + +## v41 で削除したもの + +- Worker: `set-interactions` +- Worker: `set-climate` +- Worker: `run-batch` +- batch progress/result/error 出力 +- `benchmark.mjs` +- runtime interaction graph 置換機能 +- `consumptionFlux` +- `assimilationFlux` +- `respirationLoss` +- `trophicFlux` +- `populationByGroup` +- `biomassByGroup` +- `meanBodyMass` +- `occupiedArea` +- `resourceTurnover` +- これらのためだけに存在した production/consumption counters + +`worker.js` の state message は Canvas/UI の描画に必要な内部通信なので残す。`stats()` は `time / temp / population / plant / species / generation` のみ返す。 + +## 生態ロジック + +- `DT = 1/24 day` +- producer: grid biomass field +- zooplankton: grid cohort biomass +- consumers: individual agents +- interaction graph: `ecology/feeding.js` の固定 sparse graph +- feeding: Holling denominator + Rall allometric/temperature scaling +- predation event: continuous rate → `1-exp(-rate*dt)` +- metabolism: `M^0.75` + ectotherm Arrhenius response +- reproduction: parent survives birth +- mortality: background / starvation / thermal / predation / lifespan +- movement: Hirt maximum-speed fit + behaviour-specific speed + daily budget + home range + dispersal +- growth: structural mass と reserve energy を分離し、juvenile growth は余剰 energy から支払う + +## v41 監査中の修正 + +追加監査で、陸上 producer の light field が既に shade を含むのに growth 側でも shade を掛けていた二重適用を発見し、growth 側では light を一度だけ使うよう修正した。 + +v40 では aquatic producer growth に dissolved oxygen multiplier を掛けていたが、元仕様の式は + +`mu = muMax(T) * min(fN, fP) * fLight` + +であり DO は phytoplankton growth の直接因子として指定されていなかった。v41 では DO multiplier を producer growth から削除した。 + +また、年齢だけで `biomass()` を決めていた処理を廃止し、`structuralMassKg` を個体状態として保持するよう変更した。成長は reserve floor を超えた energy だけを使用し、成長コストは provenance に較正値として明記した。 + +## 水域 + +初期 procedural water の半径だけ `sqrt(2)` 倍する。重なりのため union 面積は厳密2倍にはならない。面積を計測して二分探索するコードはない。 + +ユーザー編集では `setWater()` が渡された円群を clone してそのまま採用する。既定追加半径は 170 m のまま。 + +## 文献値と較正値 + +Hirt / Rall / Bissinger 等から直接採用した係数と、simulator calibration を `PARAMETER-PROVENANCE.json` で区別する。routine movement、home range、life history、resource K、zooplankton cohort 係数などは普遍的な実測定数とは主張しない。 diff --git a/PARAMETER-PROVENANCE.json b/PARAMETER-PROVENANCE.json new file mode 100644 index 0000000..3932d9d --- /dev/null +++ b/PARAMETER-PROVENANCE.json @@ -0,0 +1,192 @@ +{ + "schemaVersion": 1, + "modelVersion": "41.0.0", + "parameters": { + "timeStepDays": { + "value": 0.041666666666666664, + "unit": "day", + "sourceType": "implementation-plan", + "note": "1/24 day" + }, + "phytoplankton.muMax": { + "equation": "0.81 * exp(0.0631 * temperatureC)", + "unit": "day^-1", + "environment": [ + "freshwater", + "marine" + ], + "source": "Bissinger et al. 2008; updated Eppley temperature-growth relation", + "sourceType": "primary-study" + }, + "assimilation.plant": { + "value": 0.45, + "unit": "fraction", + "sourceType": "published-food-web-model" + }, + "assimilation.animal": { + "value": 0.85, + "unit": "fraction", + "sourceType": "published-food-web-model" + }, + "metabolism.massExponent": { + "value": 0.75, + "unit": "dimensionless", + "source": "Gillooly et al. metabolic scaling framework", + "sourceType": "primary-study-framework" + }, + "metabolism.ectotherm.activationEnergy": { + "value": 0.65, + "unit": "eV", + "sourceType": "model-calibration", + "note": "Arrhenius temperature response normalization used by the simulator" + }, + "metabolism.B0.ectotherm": { + "value": 42, + "unit": "kJ day^-1 at 1 kg", + "sourceType": "model-calibration" + }, + "metabolism.B0.endotherm": { + "value": 310, + "unit": "kJ day^-1 at 1 kg", + "sourceType": "model-calibration" + }, + "producer.terrestrial.rMax": { + "value": 0.03, + "unit": "day^-1", + "sourceType": "implementation-plan-fallback" + }, + "producer.terrestrial.K": { + "value": 0.5, + "unit": "kg m^-2", + "sourceType": "implementation-plan-fallback" + }, + "producer.freshwater.K": { + "value": 0.18, + "unit": "kg m^-2", + "sourceType": "model-calibration" + }, + "producer.marine.K": { + "value": 0.14, + "unit": "kg m^-2", + "sourceType": "model-calibration" + }, + "predatorPreyRatio.terrestrial": { + "value": 35, + "unit": "predator/prey mass ratio", + "source": "Brose et al. relationship used as structural basis", + "sourceType": "model-calibration" + }, + "predatorPreyRatio.freshwater": { + "value": 80, + "unit": "predator/prey mass ratio", + "source": "Brose et al. freshwater tendency used as structural basis", + "sourceType": "model-calibration" + }, + "predatorPreyRatio.marine": { + "value": 55, + "unit": "predator/prey mass ratio", + "source": "Brose et al. relationship used as structural basis", + "sourceType": "model-calibration" + }, + "feeding.rall.attack": { + "activationEnergyEv": 0.44, + "consumerMassExponent": 0.47, + "resourceMassExponent": 0.15, + "source": "Rall et al. 2012, all-data only-slopes model", + "sourceType": "primary-study" + }, + "feeding.rall.handling": { + "activationEnergyEv": -0.27, + "consumerMassExponent": -0.48, + "resourceMassExponent": 0.34, + "source": "Rall et al. 2012, all-data only-slopes model", + "sourceType": "primary-study" + }, + "feeding.rall.localResiduals": { + "attackMassRatio": "-1.81 + 0.37*ln(R) - 0.017*ln(R)^2", + "handlingMassRatio": "1.93 - 0.48*ln(R) + 0.026*ln(R)^2", + "handlingTemperature": "0.51 - 0.055*T + 0.0013*T^2", + "source": "Rall et al. 2012 residual analysis", + "sourceType": "primary-study", + "note": "normalized relative to 20 C and each profile's preferred predator/prey ratio" + }, + "feeding.edgeBaselines": { + "sourceType": "model-calibration", + "note": "default attackRate and handlingTimeDays are simulator baselines; Rall scaling is applied at runtime" + }, + "movement.maximumSpeed.running": { + "a": 25.5, + "b": 0.26, + "h": 22.0, + "i": -0.6, + "equation": "v_kmh = a*M^b*(1-exp(-h*M^i))", + "source": "Hirt et al. 2017 Supplementary Table 4", + "sourceType": "primary-study" + }, + "movement.maximumSpeed.flying": { + "a": 142.8, + "b": 0.24, + "h": 2.4, + "i": -0.72, + "equation": "v_kmh = a*M^b*(1-exp(-h*M^i))", + "source": "Hirt et al. 2017 Supplementary Table 4", + "sourceType": "primary-study" + }, + "movement.maximumSpeed.swimming": { + "a": 11.2, + "b": 0.36, + "h": 19.5, + "i": -0.56, + "equation": "v_kmh = a*M^b*(1-exp(-h*M^i))", + "source": "Hirt et al. 2017 Supplementary Table 4", + "sourceType": "primary-study" + }, + "movement.behaviourFractions": { + "sourceType": "model-calibration", + "note": "routine/foraging/escape fractions, home-range multipliers and dispersal scale are simulator behaviour parameters; they are not claimed as Hirt coefficients" + }, + "zooplankton.cohort": { + "sourceType": "model-calibration", + "note": "cohort ingestion/mortality coefficients are provisional simulator calibration values" + }, + "energy.plant": { + "value": 18000, + "unit": "kJ kg^-1", + "sourceType": "model-calibration/reference-value" + }, + "energy.animal": { + "value": 7000, + "unit": "kJ kg^-1", + "sourceType": "model-calibration/reference-value" + }, + "energy.reserveCapacity": { + "value": 1200, + "unit": "kJ kg^-1 body mass", + "sourceType": "model-calibration" + }, + "growth.energyCost": { + "value": 6000, + "unit": "kJ kg^-1 structural growth", + "sourceType": "model-calibration", + "note": "juvenile structural mass gain is limited by surplus reserve energy; adult mass remains the genome target" + }, + "water.initialRadiusMultiplier": { + "value": 1.4142135623730951, + "unit": "radius ratio", + "sourceType": "user-requirement", + "note": "sqrt(2); gives roughly twice initial water area without measuring or optimizing union area" + }, + "water.userEditMultiplier": { + "value": 1, + "unit": "radius ratio", + "sourceType": "user-requirement", + "note": "user-edited geometry is applied as supplied" + }, + "water.addToolDefaultRadiusM": { + "value": 170, + "unit": "m", + "sourceType": "compatibility", + "note": "same default as v38" + } + } +} diff --git a/README-JA.txt b/README-JA.txt new file mode 100644 index 0000000..a0b0c7e --- /dev/null +++ b/README-JA.txt @@ -0,0 +1,42 @@ +食物連鎖シミュレータ v41 + +起動: + python3 -m http.server 8000 + http://localhost:8000/ + +Web Worker と JavaScript module を使うため、index.html の直接起動ではなく HTTP 経由を推奨します。 + +主な仕様: +- day / m / kg / kJ の実単位ベース +- producer は grid field、zooplankton は cohort、動物は individual agent +- 単一 diet を廃止し、固定 consumer-resource interaction graph を使用 +- Holling II を標準とし、handling time と Rall mass/temperature scaling を実計算へ接続 +- 植物由来同化 0.45、動物由来同化 0.85 +- 親が生存する出生イベント +- Hirt型 maximum speed、routine / foraging / escape speed、daily movement budget、home range、dispersal +- freshwater / marine profile と seasonal climate fallback +- 自動移入・自動種分化なし +- 水深なし、人為的攪乱なし +- 初期水域のみ radius × sqrt(2) で概ね旧版の2倍。厳密面積最適化なし +- ユーザー編集水域は入力形状をそのまま適用 + +削除済み: +- 実験用 Worker API(set-interactions / set-climate / run-batch) +- batch 実行ユーティリティ +- consumer×resource flux、assimilation、respiration、occupied area 等の詳細診断出力 + +UI内部で必要な最小状態(時刻、気温、個体数、植物量、種一覧、世代)は描画のため残しています。 + +検証: + node test/ecology.test.mjs + +詳細: +- ECOLOGY-MODEL.md +- IMPLEMENTATION-NOTES.md +- PARAMETER-PROVENANCE.json +- VALIDATION.md + +最終監査で追加修正: +- 陸上 producer の shade 二重適用を除去 +- 動物の構造体重を energy-limited growth 化(年齢だけの自動成長を廃止) +- 初期配置専用処理を seedInitialPopulation() に限定し、実行中の導入 API は持たない diff --git a/VALIDATION.md b/VALIDATION.md new file mode 100644 index 0000000..837337b --- /dev/null +++ b/VALIDATION.md @@ -0,0 +1,88 @@ +# v41 最終監査記録 + +検証日: 2026-09-29 + +## 結論 + +API/詳細診断出力の削除後に再監査した。監査中に、(1) 陸上 producer の shade 二重適用、(2) animal body mass が energy budget と独立して年齢だけで増える不整合を発見し、修正後に全テストを再実行した。 + +このファイルに PASS と書くのは実際に再実行した項目だけとする。 + +## 自動テスト + +`node test/ecology.test.mjs` — PASS + +対象: +- `dt = 1/24 day` +- Bissinger 10/20/30°C golden values +- aquatic producer = `muMax * min(N,P) * light` +- terrestrial light limitation の一重適用 +- assimilation 0.45 / 0.85 +- continuous hazard の timestep invariance +- handling time の Holling denominator 接続 +- World predation path の local multi-resource saturation +- Rall mass / temperature scaling +- Hirt running / flying / swimming coefficients +- producer field / zooplankton cohort +- parent-surviving reproduction +- energy-limited structural growth +- daily movement budget +- spontaneous immigration/speciation absence +- deterministic repeatability +- 初期水域の概ね2倍化 +- user-edited water geometry preservation +- water depth / human disturbance absence +- exact water-area optimizer absence +- experiment API / detailed diagnostic output absence + +## 初期水域面積 + +production code は半径へ `sqrt(2)` を一度掛けるだけで、面積測定・二分探索は行わない。テスト時のみ v38 と wet-cell union を比較した。 + +| seed | v41 / v38 wet-cell area | +|---:|---:| +| 481516 | 1.9972 | +| 1 | 1.9730 | +| 123456789 | 1.9617 | +| 4294967295 | 1.8248 | + +ユーザー編集水域は入力 geometry と完全一致する回帰テストを PASS。 + +## 365日 headless + +seed 481516、修正後コード: +- elapsed: 7.56 s +- final population: 134 +- max agents observed: 416 +- surviving species: 6 +- producer biomass: 2,364,186.82 kg +- zooplankton biomass: 0 kg +- NaN/Infinity: none +- negative resource biomass: none +- 18,000-agent limit: not reached + +## 730日 headless + +seed 481516、修正後コード: +- elapsed: 17.40 s +- final population: 264 +- surviving species: 5 +- producer biomass: 2,493,088.04 kg +- zooplankton biomass: 0 kg +- NaN/Infinity: none +- negative resource biomass: none +- 18,000-agent limit: not reached + +## 工学検査 + +- 全 `.js/.mjs`: `node --check` PASS +- local relative import reference: PASS +- JSON parse: PASS +- removed experiment API strings: production executable code になし +- removed detailed diagnostic fields: production executable code になし + +## 未解決の較正事項 + +標準パラメータでは zooplankton cohort が長期的に 0 へ到達する。これは NaN/負値等の実装破綻ではないが、淡水/海洋の長期較正としては未解決。cohort ingestion/mortality と consumer pressure の較正が必要。 + +また、life-history、resource K、routine movement/home range、energy reserve/growth cost 等には model-calibration 値が残る。文献から直接採用した係数と較正値は `PARAMETER-PROVENANCE.json` で区別する。 diff --git a/app.js b/app.js new file mode 100644 index 0000000..eda9681 --- /dev/null +++ b/app.js @@ -0,0 +1,40 @@ +import {habitat} from './water.js'; +import {drawScene} from './scene.js?v=41'; +import {lineageOrder,drawLineage} from './lineage.js'; +import {paintTerrain} from './terrain.js?v=41'; +import {createTreeSprites} from './tree-sprites.js'; +import {formatElapsed as elapsed} from './time.js'; +import {roleLabel} from './ecology/feeding.js'; +import {pinchCamera} from './gestures.js'; +import {W,H,GW,GH,CELL,traits} from './engine.js?v=41'; +const $=id=>document.getElementById(id),worker=new Worker('./worker.js?v=41',{type:'module'}),canvas=$('world'),ctx=canvas.getContext('2d'),chart=$('chart'),cc=chart.getContext('2d'),terrain=document.createElement('canvas');terrain.width=GW;terrain.height=GH;const tc=terrain.getContext('2d');let state=null,tool='observe',waterMode='add',water=[],speed=1,lastSpeed=1,selected=null,layer='plants',zoom=1,panX=0,panY=0,drag=null,view={},lastPaint=0,tab='overview',hover=null; +const treeSprites=createTreeSprites(()=>document.createElement('canvas')); +const names={bodySize:'成体体重 (kg)',moveSpeed:'日常移動速度 (m/day)',staminaCapacity:'スタミナ係数',staminaRecovery:'回復係数',visionRange:'視野範囲 (m)',perceptionAbility:'知覚能力',preferredTemperature:'適温 (°C)',temperatureTolerance:'温度許容幅 (°C)',waterAffinity:'水域適応',offspringSize:'出生時体重比',maturityAge:'成熟齢 (day)',lifespan:'最大寿命 (day)',sociability:'群居性',fear:'警戒性',aggression:'攻撃性'}; +const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +function toast(t){$('toast').textContent=t;$('toast').style.opacity=1;clearTimeout(toast.timer);toast.timer=setTimeout(()=>$('toast').style.opacity=0,2500)} +function setTab(t){tab=t;document.querySelectorAll('[data-tab]').forEach(b=>{const a=b.dataset.tab===t;b.classList.toggle('active',a);b.setAttribute('aria-selected',a)});document.querySelectorAll('.tab-panel').forEach(p=>p.hidden=p.id!==t);if(state)renderDetails()} +document.querySelectorAll('[data-tab]').forEach(b=>b.onclick=()=>setTab(b.dataset.tab)); +function options(){const el=$('toolOptions');if(tool==='observe')el.replaceChildren();if(tool==='water'||tool==='rocks'){el.innerHTML=`${tool==='rocks'?'岩は移動を遮ります':'滑らかな岸線の水域を編集'}`;el.querySelectorAll('button').forEach(b=>{b.classList.toggle('active',b.dataset.water===waterMode);b.onclick=()=>{waterMode=b.dataset.water;options()}})}} +document.querySelectorAll('[data-tool]').forEach(b=>b.onclick=()=>{tool=b.dataset.tool;document.querySelectorAll('[data-tool]').forEach(x=>x.classList.toggle('active',x===b));options();if(state?.statMode&&tool!=='observe')toast('フィールド編集は通常の表示速度で行ってください')}); +function setSpeed(v){speed=v;if(v)lastSpeed=v;worker.postMessage({type:'speed',speed:v,stat:v===1000});$('pause').textContent=v?'Ⅱ':'▶';$('pause').setAttribute('aria-label',v?'一時停止':'再開');$('runStatus').textContent=v?'シミュレーション実行中':'一時停止中';document.querySelectorAll('[data-speed]').forEach(b=>b.classList.toggle('active',+b.dataset.speed===v))} +$('pause').onclick=()=>setSpeed(speed?0:lastSpeed);document.querySelectorAll('[data-speed]').forEach(b=>b.onclick=()=>setSpeed(+b.dataset.speed));$('layer').onchange=e=>{layer=e.target.value;updateTerrain();if(state)render()};$('aquaticProfile').onchange=e=>worker.postMessage({type:'load-profile',profile:e.target.value});$('helpBtn').onclick=()=>$('help').showModal();$('closeHelp').onclick=()=>$('help').close();$('help').onclick=e=>{if(e.target===$('help')&&e.offsetX<0)$('help').close()}; +function resize(){const r=canvas.getBoundingClientRect(),d=Math.min(devicePixelRatio||1,1.5);canvas.width=r.width*d;canvas.height=r.height*d;view={width:r.width,height:r.height,d,base:Math.min(r.width/W,r.height/H)};const c=chart.getBoundingClientRect();chart.width=c.width*d;chart.height=c.height*d;if(state)drawChart()} +new ResizeObserver(resize).observe($('field'));window.addEventListener('resize',resize);function transform(){const s=view.base*zoom;return {s,x:(view.width-W*s)/2+panX,y:(view.height-H*s)/2+panY}}function point(e){const r=canvas.getBoundingClientRect(),t=transform();return {x:(e.clientX-r.left-t.x)/t.s,y:(e.clientY-r.top-t.y)/t.s,px:e.clientX-r.left,py:e.clientY-r.top}} +function zoomBy(f){zoom=Math.max(1,Math.min(32,zoom*f));if(zoom===1)panX=panY=0} $('toggleUI').onclick=()=>{const hidden=$('field').classList.toggle('ui-hidden'),button=$('toggleUI');button.textContent=hidden?'UI 表示':'UI 非表示';button.setAttribute('aria-pressed',String(hidden));button.setAttribute('aria-label',hidden?'操作UIを表示':'操作UIを非表示')};canvas.addEventListener('wheel',e=>{e.preventDefault();const p=point(e),old=transform();zoomBy(e.deltaY<0?1.12:1/1.12);const t=transform();panX+=p.px-(p.x*t.s+t.x);panY+=p.py-(p.y*t.s+t.y);if(zoom===1)panX=panY=0},{passive:false}); +const shapeTool=()=>tool==='water'||tool==='rocks'; +const contacts=new Map();let pinch=null; +canvas.onpointerdown=e=>{if(!state||state.statMode)return;canvas.setPointerCapture(e.pointerId);contacts.set(e.pointerId,{x:e.clientX,y:e.clientY});if(contacts.size===2){const [a,b]=[...contacts.values()],mid=point({clientX:(a.x+b.x)/2,clientY:(a.y+b.y)/2});pinch={distance:Math.max(1,Math.hypot(a.x-b.x,a.y-b.y)),zoom,anchorX:mid.x,anchorY:mid.y};drag=null;return}if(contacts.size>2)return;const p=point(e);drag={...p,start:p,moved:false};if(shapeTool()){water=state[tool].map(w=>({...w}));const hits=water.map((w,i)=>({i,d:Math.hypot(w.x-p.x,w.y-p.y),w})).filter(v=>v.dMath.abs(a.d-a.w.r)-Math.abs(b.d-b.w.r));if(waterMode==='delete'){if(hits.length){water.splice(hits[0].i,1);sendShapes()}drag=null}else if(waterMode==='add'){if(water.length>=80){toast('各地形は最大80円です');drag=null;return}water.push({x:Math.max(0,Math.min(W,p.x)),y:Math.max(0,Math.min(H,p.y)),r:tool==='water'?170:90,seed:tool==='water'?(p.x*.017+p.y*.011)%6.28:undefined});drag.water=water.length-1;drag.resize=true}else if(hits.length){drag.water=hits[0].i;drag.resize=Math.abs(hits[0].d-hits[0].w.r)<25;drag.origin={...hits[0].w}}}}; +canvas.onpointermove=e=>{if(contacts.has(e.pointerId))contacts.set(e.pointerId,{x:e.clientX,y:e.clientY});if(pinch&&contacts.size>=2){const [a,b]=[...contacts.values()],camera=pinchCamera(pinch,a,b,canvas.getBoundingClientRect(),view,W,H);({zoom,panX,panY}=camera);return}const p=point(e);hover=p;if(!drag)return;if(Math.hypot(p.px-drag.start.px,p.py-drag.start.py)>4)drag.moved=true;if(tool==='observe'){panX+=p.px-drag.px;panY+=p.py-drag.py}else if(shapeTool()&&drag.water!==undefined){const w=water[drag.water];if(drag.resize)w.r=Math.max(tool==='water'?20:80,Math.min(tool==='water'?800:420,Math.hypot(p.x-w.x,p.y-w.y)));else{w.x=Math.max(0,Math.min(W,drag.origin.x+p.x-drag.start.x));w.y=Math.max(0,Math.min(H,drag.origin.y+p.y-drag.start.y))}}drag.px=p.px;drag.py=p.py}; +function sendShapes(){worker.postMessage({type:tool,[tool]:water});} +canvas.onpointerup=e=>{contacts.delete(e.pointerId);if(pinch){if(contacts.size<2)pinch=null;drag=null;return}if(!drag)return;const p=point(e);if(tool==='observe'&&!drag.moved){let best=null,dist=25/transform().s;for(let i=0;i{contacts.delete(e.pointerId);if(contacts.size<2)pinch=null;drag=null}; +function updateTerrain(){if(state)paintTerrain(tc,state,layer)} +function render(redrawChart=true){if(!state)return;const s=state.stats;$('date').textContent=`経過 ${elapsed(s.time)}`;$('temperature').textContent=s.temp.toFixed(1)+' °C';$('population').innerHTML=s.population.toLocaleString()+'個体';$('speciesCount').innerHTML=s.species.length+'種';$('plantMass').innerHTML=(s.plant/1000).toFixed(1)+'t';$('generation').innerHTML=s.generation+'世代';$('actual').textContent=state.actual.toFixed(2)+' 日/秒';$('statOverlay').hidden=!state.statMode;$('mapLegend').innerHTML=layer==='trophic'?'● 一次消費者 ● 二次消費者 ● 高次消費者 ':layer==='habitat'?'● 陸棲 ● 両棲 ● 水棲 ':'岩倒木死骸';$('events').innerHTML=state.events.slice(0,10).map(x=>`
${esc(x.text)}
`).join('');renderDetails();if(redrawChart)drawChart();if(state.limited&&speed){setSpeed(0);toast('個体数が計算上限に達したため停止しました')}} +function renderDetails(){if(tab==='individual'){const a=state.selected;if(a){const sp=state.stats.species.find(s=>s.id===a.sid),phys=sp.physiology==='endotherm'?'内温性':'変温性';$('individual').innerHTML=`
${esc(sp.name)}#${a.id}

${roleLabel(sp.trophicRole)} · ${phys} · ${habitat(a.g.waterAffinity)}

${a.action} · 第${a.generation}世代 · 年齢 ${elapsed(a.age)}

現在体重 ${a.mass.toFixed(2)} kg · 隠匿 ${(a.concealment*100).toFixed(0)}%

個体形質

生態単位系
${Object.keys(traits).map(k=>`
${names[k]}${k==='waterAffinity'?habitat(a.g[k]):a.g[k].toFixed(2)}
`).join('')}`}else if(selected)$('individual').innerHTML='
⌖

観察個体は死亡しました

別の個体をクリックして観察を続けられます。

'}} +function drawChart(){ + if(!state)return;const species=state.stats.species,d=1,h=Math.max(220,species.length*31+42),w=Math.max(1,chart.getBoundingClientRect().width); + chart.style.height=h+'px';if(chart.width!==Math.round(w*d)||chart.height!==Math.round(h*d)){chart.width=Math.round(w*d);chart.height=Math.round(h*d)} + const {ordered,first,last,byId}=drawLineage(cc,{width:w,height:h,species,hist:state.history,scale:d,layer}); + $('chartStart').textContent=elapsed(first);$('chartEnd').textContent=elapsed(last);$('chartData').innerHTML=ordered.map(({s})=>`
  • ${esc(s.name)}:${s.count}個体。${byId.has(s.parent)?'親系統 '+esc(byId.get(s.parent).name):s.originType==='外来'?'外来種':'初期種'}
  • `).join(''); +} +function paint(now){requestAnimationFrame(paint);if(!state||!view.width)return;const frameGap=state.statMode?900:state.animalIds.length>4000?50:state.animalIds.length>1500?33:16;if(now-lastPaint{if(data.type==='notice'){toast(data.text);return}try{state=state?{...state,...data}:data;if(!drag)water=state[tool==='rocks'?'rocks':'water'];if(data.moisture||layer!=='moisture'&&data.plants)updateTerrain();if(data.stats){render(!!data.history);$('runStatus').textContent=speed?'シミュレーション実行中':'一時停止中'}else $('actual').textContent=state.actual.toFixed(2)+' 日/秒'}catch(e){console.error('状態表示エラー',e);$('runStatus').textContent='表示エラー';$('loading').textContent='表示エラー:再読み込みしてください';toast('表示エラー:再読み込みしてください')}finally{worker.postMessage({type:'ack'})}};worker.onerror=e=>{setSpeed(0);$('runStatus').textContent='計算エラー';$('loading').textContent='計算エラー:再読み込みしてください';toast('計算エラー:再読み込みしてください');console.error(e)};resize();requestAnimationFrame(paint); diff --git a/body-shapes.js b/body-shapes.js new file mode 100644 index 0000000..eca4377 --- /dev/null +++ b/body-shapes.js @@ -0,0 +1,18 @@ +// Four readable silhouettes. Each mostly fills the circular movement footprint. +export function bodyPath(ctx,r,morph){ + ctx.beginPath(); + switch(morph&3){ + case 1: // tapered + ctx.moveTo(r*1.13,0);ctx.bezierCurveTo(r*.7,-r*.72,-r*.25,-r*1.04,-r*.97,-r*.48); + ctx.quadraticCurveTo(-r*1.18,0,-r*.97,r*.48);ctx.bezierCurveTo(-r*.25,r*1.04,r*.7,r*.72,r*1.13,0);break; + case 2: // rounded diamond + ctx.moveTo(r*1.12,0);ctx.quadraticCurveTo(r*.66,-r*.73,0,-r*1.06);ctx.quadraticCurveTo(-r*.65,-r*.8,-r*1.1,0); + ctx.quadraticCurveTo(-r*.65,r*.8,0,r*1.06);ctx.quadraticCurveTo(r*.66,r*.73,r*1.12,0);break; + case 3: // broad front, narrow rear + ctx.moveTo(r*1.1,0);ctx.bezierCurveTo(r*.91,-r*.75,r*.24,-r*1.13,-r*.31,-r*.89); + ctx.bezierCurveTo(-r*.83,-r*.72,-r*1.12,-r*.31,-r*1.12,0);ctx.bezierCurveTo(-r*1.12,r*.31,-r*.83,r*.72,-r*.31,r*.89); + ctx.bezierCurveTo(r*.24,r*1.13,r*.91,r*.75,r*1.1,0);break; + default:ctx.ellipse(0,0,r*1.12,r*.96,0,0,Math.PI*2); + } + ctx.closePath(); +} diff --git a/diet-label.js b/diet-label.js new file mode 100644 index 0000000..a8ae5f2 --- /dev/null +++ b/diet-label.js @@ -0,0 +1,2 @@ +export function dietLabel(value){return value<1/3?'草食':value<2/3?'雑食':'肉食'} +export function dietChoice(value){return value<1/3?'0':value<2/3?'0.5':'1'} diff --git a/ecology/climate.js b/ecology/climate.js new file mode 100644 index 0000000..f8dcfe1 --- /dev/null +++ b/ecology/climate.js @@ -0,0 +1,13 @@ +import {clamp} from './units.js'; + +export class ClimateProvider{ + constructor({meanTemperatureC=15,amplitudeC=10,phaseDay=205}={}){this.meanTemperatureC=meanTemperatureC;this.amplitudeC=amplitudeC;this.phaseDay=phaseDay;this.series=new Map} + setSeries(variable,values){const clean=Array.from(values??[],Number);if(!clean.length||clean.some(v=>!Number.isFinite(v)))throw new TypeError(`${variable} values must contain finite numbers`);this.series.set(variable,clean)} + hasSeries(variable){return this.series.has(variable)} + setDailyTemperature(values){this.setSeries('temperatureC',values)} + clearObserved(variable=null){if(variable)this.series.delete(variable);else this.series.clear()} + value(variable,day,fallback=null){const values=this.series.get(variable);if(values){const i=clamp(Math.floor(day),0,values.length-1);return values[i]}return typeof fallback==='function'?fallback(day):fallback} + temperatureC(day,{aquatic=false}={}){if(aquatic&&this.series.has('surfaceTemperatureC'))return this.value('surfaceTemperatureC',day);return this.value('temperatureC',day,()=>this.meanTemperatureC+this.amplitudeC*Math.cos(2*Math.PI*(day-this.phaseDay)/365))} + precipitationMm(day){return this.value('precipitationMm',day,0)} + soilMoisture(day,fallback){return this.value('soilMoisture',day,fallback)} +} diff --git a/ecology/demography.js b/ecology/demography.js new file mode 100644 index 0000000..0827906 --- /dev/null +++ b/ecology/demography.js @@ -0,0 +1,40 @@ +import {annualSurvivalToDailyHazard,rateToProbability,clamp} from './units.js'; + +// Life-history fallbacks are simulator calibration values, not universal biological constants. +export const ROLE_DEMOGRAPHY = Object.freeze({ + 'primary-consumer': {annualSurvival:.38,offspringPerEvent:1,reproductionIntervalDays:180}, + 'secondary-consumer': {annualSurvival:.58,offspringPerEvent:1,reproductionIntervalDays:240}, + 'tertiary-consumer': {annualSurvival:.70,offspringPerEvent:1,reproductionIntervalDays:365}, + detritivore: {annualSurvival:.45,offspringPerEvent:1,reproductionIntervalDays:210}, +}); +export function backgroundHazardPerDay(role){return annualSurvivalToDailyHazard((ROLE_DEMOGRAPHY[role]||ROLE_DEMOGRAPHY['primary-consumer']).annualSurvival)} +export function mortalityOccurs(hazardPerDay,dtDays,random){return random()=60&&doy<=180; + if(role==='secondary-consumer')return (doy>=60&&doy<=165)||(doy>=245&&doy<=300); + if(role==='detritivore')return (doy>=35&&doy<=175)||(doy>=220&&doy<=315); + return (doy>=45&&doy<=170)||(doy>=225&&doy<=305); +} +export function offspringEnergyShare(role){return role==='tertiary-consumer'?.18:.13} +export function reproductionThreshold(role){return role==='tertiary-consumer'?.82:role==='secondary-consumer'?.8:.78} +export const energyFraction = (energy,maxEnergy)=>clamp(maxEnergy>0?energy/maxEnergy:0,0,1); + + +// Structural growth is paid from assimilated energy. These are simulator +// calibration values rather than universal life-history constants. +export const GROWTH_ENERGY_COST_KJ_PER_KG = 6000; +export const RESERVE_FLOOR_FRACTION_FOR_GROWTH = 0.50; +export function initialStructuralMassKg(adultMassKg,offspringSize,ageDays,maturityAgeDays){ + const adult=Math.max(1e-6,adultMassKg),birth=adult*clamp(offspringSize,0.01,1),progress=clamp(ageDays/Math.max(1e-6,maturityAgeDays),0,1); + return birth+(adult-birth)*progress; +} +export function energyLimitedGrowth({structuralMassKg,adultMassKg,energyKJ,reserveCapacityKJPerKg=1200,ageDays,maturityAgeDays,dtDays}){ + const mass=Math.max(1e-6,structuralMassKg),adult=Math.max(mass,adultMassKg),remaining=Math.max(0,adult-mass); + if(!remaining)return {structuralMassKg:mass,energyKJ,gainKg:0}; + const reserveFloor=mass*reserveCapacityKJPerKg*RESERVE_FLOOR_FRACTION_FOR_GROWTH,available=Math.max(0,energyKJ-reserveFloor); + if(!available)return {structuralMassKg:mass,energyKJ,gainKg:0}; + const daysToMaturity=Math.max(1,maturityAgeDays-ageDays),potential=remaining*Math.min(1,dtDays/daysToMaturity),affordable=available/GROWTH_ENERGY_COST_KJ_PER_KG,gain=Math.min(remaining,potential,affordable); + return {structuralMassKg:mass+gain,energyKJ:energyKJ-gain*GROWTH_ENERGY_COST_KJ_PER_KG,gainKg:gain}; +} diff --git a/ecology/feeding.js b/ecology/feeding.js new file mode 100644 index 0000000..970b8db --- /dev/null +++ b/ecology/feeding.js @@ -0,0 +1,73 @@ +import {clamp,rateToProbability} from './units.js'; + +export const TROPHIC_ROLES = Object.freeze(['primary-consumer','secondary-consumer','tertiary-consumer','detritivore']); +export const RESOURCE_ROLES = Object.freeze(['producer','zooplankton','primary-consumer','secondary-consumer','tertiary-consumer','detritus']); +export const roleCode = role => Math.max(0,TROPHIC_ROLES.indexOf(role)); +export const roleLabel = role => ({'primary-consumer':'一次消費者','secondary-consumer':'二次消費者','tertiary-consumer':'高次消費者','detritivore':'腐食者'}[role]||role); + +const DEFAULT_EDGES = Object.freeze({ + 'primary-consumer': Object.freeze({ + producer:Object.freeze({preferenceWeight:1,assimilationEfficiency:.45,functionalResponse:2,attackRate:2.2,handlingTimeDays:.05}), + zooplankton:Object.freeze({preferenceWeight:.42,assimilationEfficiency:.85,functionalResponse:2,attackRate:1.25,handlingTimeDays:.08}), + }), + 'secondary-consumer': Object.freeze({ + producer:Object.freeze({preferenceWeight:.12,assimilationEfficiency:.45,functionalResponse:2,attackRate:.55,handlingTimeDays:.08}), + zooplankton:Object.freeze({preferenceWeight:.32,assimilationEfficiency:.85,functionalResponse:2,attackRate:1.05,handlingTimeDays:.08}), + 'primary-consumer':Object.freeze({preferenceWeight:1,assimilationEfficiency:.85,functionalResponse:2,attackRate:1.25,handlingTimeDays:.12}), + detritus:Object.freeze({preferenceWeight:.3,assimilationEfficiency:.85,functionalResponse:2,attackRate:.8,handlingTimeDays:.1}), + }), + 'tertiary-consumer': Object.freeze({ + 'primary-consumer':Object.freeze({preferenceWeight:.45,assimilationEfficiency:.85,functionalResponse:2,attackRate:.8,handlingTimeDays:.16}), + 'secondary-consumer':Object.freeze({preferenceWeight:1,assimilationEfficiency:.85,functionalResponse:2,attackRate:1.05,handlingTimeDays:.18}), + detritus:Object.freeze({preferenceWeight:.2,assimilationEfficiency:.85,functionalResponse:2,attackRate:.55,handlingTimeDays:.12}), + }), + detritivore: Object.freeze({ + detritus:Object.freeze({preferenceWeight:1,assimilationEfficiency:.85,functionalResponse:2,attackRate:1.4,handlingTimeDays:.08}), + producer:Object.freeze({preferenceWeight:.08,assimilationEfficiency:.45,functionalResponse:2,attackRate:.45,handlingTimeDays:.1}), + }), +}); + +export function interactionEdge(consumer,resource){return DEFAULT_EDGES[consumer]?.[resource] || null} + +export function bodyMassPreference(predatorMassKg,preyMassKg,profile,edge=null){ + const preferred=edge?.preferredPredatorPreyMassRatio??profile.feeding.preferredPredatorPreyMassRatio,sigma=edge?.massRatioSigma??profile.feeding.massRatioSigma,ratio=Math.max(1e-8,predatorMassKg/Math.max(1e-8,preyMassKg)),mu=Math.log(preferred); + return Math.exp(-((Math.log(ratio)-mu)**2)/(2*sigma*sigma)); +} + +// Rall et al. (2012), all-data 'only slopes' model: +// attack: Ea=+0.44 eV, consumer exponent +0.47, resource exponent +0.15 +// handling: Ea=-0.27 eV, consumer exponent -0.48, resource exponent +0.34. +// Their local residual analysis adds the nonlinear ln(predator:prey ratio) terms below +// and a quadratic temperature residual for handling time. Edge values remain transparent +// baseline calibrations at 20 C, 1 kg consumer, and the profile's preferred mass ratio. +const BOLTZMANN_EV_K=8.617333262e-5,REFERENCE_T_K=293.15; +const arrheniusFactor=(activationEnergyEv,temperatureC)=>{const t=Math.max(180,temperatureC+273.15);return Math.exp(clamp(activationEnergyEv*(t-REFERENCE_T_K)/(BOLTZMANN_EV_K*t*REFERENCE_T_K),-5,5))}; +export function rallFeedingParameters(edge,consumerMassKg,resourceMassKg,temperatureC,profile){ + if(!edge)return {attackRate:0,handlingTimeDays:Infinity,preference:0}; + const mc=Math.max(consumerMassKg,1e-8),mr=Math.max(resourceMassKg,1e-10),preferredRatio=Math.max(1e-8,edge.preferredPredatorPreyMassRatio??profile.feeding.preferredPredatorPreyMassRatio),ratio=mc/mr,lr=Math.log(ratio),lr0=Math.log(preferredRatio),referenceConsumerKg=1,referenceResourceKg=1/preferredRatio; + const attackResidual=x=>-1.81+.37*x-.017*x*x; + const handlingMassResidual=x=>1.93-.48*x+.026*x*x; + const handlingTempResidual=t=>.51-.055*t+.0013*t*t; + const globalAttackMass=Math.pow(mc/referenceConsumerKg,.47)*Math.pow(mr/referenceResourceKg,.15),globalHandlingMass=Math.pow(mc/referenceConsumerKg,-.48)*Math.pow(mr/referenceResourceKg,.34); + const attackRatioResidual=Math.exp(clamp(attackResidual(lr)-attackResidual(lr0),-3,3)),handlingRatioResidual=Math.exp(clamp(handlingMassResidual(lr)-handlingMassResidual(lr0),-3,3)); + const attackTemp=arrheniusFactor(.44,temperatureC),handlingTemp=arrheniusFactor(-.27,temperatureC),handlingTempResidualFactor=Math.exp(clamp(handlingTempResidual(temperatureC)-handlingTempResidual(20),-2,2)); + const attackScale=clamp(globalAttackMass*attackRatioResidual*attackTemp,1e-3,1e3),handlingScale=clamp(globalHandlingMass*handlingRatioResidual*handlingTemp*handlingTempResidualFactor,1e-3,1e3),preference=bodyMassPreference(mc,mr,profile,edge); + return {attackRate:edge.attackRate*attackScale,handlingTimeDays:Math.max(1e-5,edge.handlingTimeDays*handlingScale),preference}; +} + +export function functionalResponseRatePerDay(params,resourceDensity,denominatorTerms=[],q=1){ + const n=Math.max(0,resourceDensity),power=Math.pow(n,q),numerator=params.attackRate*params.preference*power; + let denominator=1; + for(const term of denominatorTerms){const tq=term.q??1,tn=Math.max(0,term.resourceDensity);denominator+=term.attackRate*term.preference*term.handlingTimeDays*Math.pow(tn,tq)} + return numerator/Math.max(1e-12,denominator); +} + +export function producerIntakeKgPerDay(massKg,producerKgPerM2,edge,temperatureC=20,profile={feeding:{preferredPredatorPreyMassRatio:35,massRatioSigma:1.05}}){ + if(!edge)return 0; + const resourceMassEquivalent=Math.max(1e-5,producerKgPerM2*.01),params=rallFeedingParameters(edge,massKg,resourceMassEquivalent,temperatureC,profile),q=edge.functionalResponse===3?2:1; + const raw=functionalResponseRatePerDay(params,producerKgPerM2,[{...params,resourceDensity:producerKgPerM2,q}],q); + const maxIntake=Math.max(.002,.16*Math.pow(Math.max(massKg,.001),.78))*edge.preferenceWeight; + return Math.min(maxIntake,raw*Math.max(.03,Math.pow(massKg,.58))); +} + +export function attackProbabilityPerStep(ratePerDay,dtDays){return rateToProbability(ratePerDay,dtDays)} diff --git a/ecology/metabolism.js b/ecology/metabolism.js new file mode 100644 index 0000000..7c7e430 --- /dev/null +++ b/ecology/metabolism.js @@ -0,0 +1,23 @@ +import {KELVIN_OFFSET, clamp} from './units.js'; + +const B0 = Object.freeze({ectotherm: 42, endotherm: 310}); // kJ d^-1 at 1 kg, simulator calibration +const ACTIVATION_EV = 0.65; +const BOLTZMANN_EV = 8.617333262e-5; + +export function ectothermTemperatureMultiplier(temperatureC,referenceC=20){ + const t=clamp(temperatureC+KELVIN_OFFSET,180,350),ref=referenceC+KELVIN_OFFSET; + return Math.exp((-ACTIVATION_EV/BOLTZMANN_EV)*(1/t-1/ref)); +} +export function basalMetabolismKJPerDay(massKg,temperatureC,physiology='ectotherm'){ + const mass=Math.max(1e-4,massKg),temp=physiology==='ectotherm'?ectothermTemperatureMultiplier(temperatureC):1; + return B0[physiology] * Math.pow(mass,.75) * temp; +} +export function starvationHazardPerDay(energyFraction){ + if(energyFraction>=.18)return 0; + return 0.018 + 0.34*Math.pow((.18-Math.max(0,energyFraction))/.18,2); +} +export function thermalHazardPerDay(temperatureC,preferredC,toleranceC,physiology='ectotherm'){ + const excess=Math.max(0,Math.abs(temperatureC-preferredC)-Math.max(1,toleranceC)); + if(!excess)return 0; + return (physiology==='endotherm'?.00025:.0008)*excess*excess; +} diff --git a/ecology/movement.js b/ecology/movement.js new file mode 100644 index 0000000..dcfac7a --- /dev/null +++ b/ecology/movement.js @@ -0,0 +1,37 @@ +const MODE = Object.freeze({ + // Hirt et al. time-dependent maximum-speed form: + // v = a * M^b * (1 - exp(-h * M^i)), v in km/h, M in kg. + // Flying, running and swimming coefficients are the published Supplementary + // Table 4 fits for the time-dependent maximum-speed model. + running:{a:25.5,b:.26,h:22,i:-.60,routineFraction:.34,homeRangeFactor:1,calibration:'Hirt-2017'}, + swimming:{a:11.2,b:.36,h:19.5,i:-.56,routineFraction:.30,homeRangeFactor:1.25,calibration:'Hirt-2017'}, + flying:{a:142.8,b:.24,h:2.4,i:-.72,routineFraction:.42,homeRangeFactor:1.8,calibration:'Hirt-2017'}, +}); +export const locomotionModes=Object.freeze(Object.keys(MODE)); +export const normalizeLocomotionMode=mode=>MODE[mode]?mode:'running'; +export const locomotionCalibration=mode=>({...MODE[normalizeLocomotionMode(mode)]}); +export function maximumSpeedMPerDay(massKg,mode='running'){ + const c=MODE[normalizeLocomotionMode(mode)],m=Math.max(massKg,1e-6),kmh=c.a*Math.pow(m,c.b)*(1-Math.exp(-c.h*Math.pow(m,c.i))); + return Math.max(1,kmh*24000); // km/h -> m/day +} +export const maximumSpeedMultiplier=massKg=>maximumSpeedMPerDay(massKg,'running')/maximumSpeedMPerDay(3,'running'); +export function routineTravelSpeedMPerDay(massKg,dailyMovementBudgetM,mode='running'){ + const c=MODE[normalizeLocomotionMode(mode)],budgetSpeed=Math.max(0,dailyMovementBudgetM)*c.routineFraction; + return Math.min(maximumSpeedMPerDay(massKg,mode)*.22,Math.max(1,budgetSpeed)); +} +export function foragingSpeedMPerDay(massKg,dailyMovementBudgetM,mode='running'){ + return Math.min(maximumSpeedMPerDay(massKg,mode)*.42,routineTravelSpeedMPerDay(massKg,dailyMovementBudgetM,mode)*1.35); +} +export function escapeSpeedMPerDay(massKg,dailyMovementBudgetM,mode='running'){ + return Math.min(maximumSpeedMPerDay(massKg,mode),routineTravelSpeedMPerDay(massKg,dailyMovementBudgetM,mode)*2.4); +} +export function dailyMovementBudgetM(massKg,geneBudgetM,mode='running'){ + const c=MODE[normalizeLocomotionMode(mode)]; + return Math.max(25,geneBudgetM)*(0.75+0.25*Math.pow(Math.max(massKg,.01)/3,.08))*(.9+.1*c.homeRangeFactor); +} +export function homeRangeRadiusM(massKg,mode='running'){ + const c=MODE[normalizeLocomotionMode(mode)];return Math.min(1500,Math.max(90,170*Math.pow(Math.max(massKg,.01),.22)*c.homeRangeFactor)); +} +export function sampleDispersalDistanceM(random,massKg,mode='running'){ + const scale=homeRangeRadiusM(massKg,mode)*.55;return Math.min(scale*3,-Math.log(Math.max(1e-9,1-random()))*scale); +} diff --git a/ecology/profiles.js b/ecology/profiles.js new file mode 100644 index 0000000..df79ff0 --- /dev/null +++ b/ecology/profiles.js @@ -0,0 +1,11 @@ +export const TERRESTRIAL = Object.freeze({ + id:'terrestrial',type:'terrestrial',producer:{rMaxPerDay:.03,carryingCapacityKgPerM2:.5},feeding:{preferredPredatorPreyMassRatio:35,massRatioSigma:1.05},environment:{light:.82,nutrientN:.78,nutrientP:.78,dissolvedOxygen:1},movement:{homeRangeMultiplier:1} +}); +export const FRESHWATER = Object.freeze({ + id:'freshwater',type:'freshwater',producer:{carryingCapacityKgPerM2:.18},feeding:{preferredPredatorPreyMassRatio:80,massRatioSigma:1.12},environment:{light:.72,nutrientN:.58,nutrientP:.52,dissolvedOxygen:.82},movement:{homeRangeMultiplier:1.15} +}); +export const MARINE = Object.freeze({ + id:'marine',type:'marine',producer:{carryingCapacityKgPerM2:.14},feeding:{preferredPredatorPreyMassRatio:55,massRatioSigma:1.08},environment:{light:.76,nutrientN:.54,nutrientP:.48,dissolvedOxygen:.9},movement:{homeRangeMultiplier:1.35} +}); +export const PROFILES=Object.freeze({terrestrial:TERRESTRIAL,freshwater:FRESHWATER,marine:MARINE}); +export function getProfile(id){return PROFILES[id]||null} diff --git a/ecology/resources.js b/ecology/resources.js new file mode 100644 index 0000000..0174638 --- /dev/null +++ b/ecology/resources.js @@ -0,0 +1,32 @@ +import {clamp} from './units.js'; + +export class ResourceField{ + constructor({id,role,cellCount,energyDensityKJPerKg,representation='field'}){this.id=id;this.role=role;this.energyDensityKJPerKg=energyDensityKJPerKg;this.representation=representation;this.biomass=new Float32Array(cellCount)} + consume(index,amountKg,cellAreaM2){const available=this.biomass[index]*cellAreaM2,taken=Math.min(Math.max(0,amountKg),available);this.biomass[index]=Math.max(0,this.biomass[index]-taken/cellAreaM2);return taken} +} + +export const phytoplanktonMuMax = temperatureC => 0.81 * Math.exp(0.0631 * temperatureC); + +export function producerCapacity(profile,{moisture=1,shade=0,blocked=false}={}){ + if(blocked)return 0; + if(profile.type==='terrestrial')return profile.producer.carryingCapacityKgPerM2 * clamp(0.35 + 0.65*moisture,0,1) * (1 - 0.18*clamp(shade,0,1)); + return profile.producer.carryingCapacityKgPerM2; +} + +export function producerGrowthPerDay(profile,temperatureC,{moisture=1,light,nutrientN,nutrientP}={}){ + if(profile.type==='terrestrial'){ + const fT=Math.exp(-0.5*((temperatureC-20)/13)**2),fW=clamp(moisture,0,1),fL=clamp(light ?? profile.environment.light,0,1); + return profile.producer.rMaxPerDay * fT * fW * fL; + } + const fI=clamp(light ?? profile.environment.light,0,1),fN=clamp(nutrientN ?? profile.environment.nutrientN,0,1),fP=clamp(nutrientP ?? profile.environment.nutrientP,0,1); + return phytoplanktonMuMax(temperatureC) * Math.min(fN,fP) * fI; +} + +export function updateProducerBiomass(biomass,capacity,growthPerDay,consumedKgPerM2,dtDays){ + if(capacity<=0)return 0;const growth=growthPerDay*biomass*(1-biomass/capacity);return clamp(biomass + growth*dtDays - Math.max(0,consumedKgPerM2),0,capacity*1.05); +} + +export function updateZooplanktonCohort(producerKgPerM2,zooplanktonKgPerM2,dtDays=1){ + const z=Math.max(0,zooplanktonKgPerM2),p=Math.max(0,producerKgPerM2),ingestion=Math.min(p,.9*z*p/(.035+p))*dtDays,assimilated=ingestion*.45,mortality=.10*z*dtDays; + return {producerConsumedKgPerM2:ingestion,nextZooplanktonKgPerM2:Math.max(0,z+assimilated-mortality),productionKgPerM2:Math.max(0,assimilated-mortality)}; +} diff --git a/ecology/schema.js b/ecology/schema.js new file mode 100644 index 0000000..c9ac9b9 --- /dev/null +++ b/ecology/schema.js @@ -0,0 +1,3 @@ +import {PROFILES} from './profiles.js'; + +export function validateProfileId(id){if(!PROFILES[id])throw new RangeError(`unknown environment profile: ${id}`);return id} diff --git a/ecology/units.js b/ecology/units.js new file mode 100644 index 0000000..3b17e4e --- /dev/null +++ b/ecology/units.js @@ -0,0 +1,7 @@ +export const HOURS_PER_DAY = 24; +export const KELVIN_OFFSET = 273.15; +export const DEFAULT_TIME_STEP_DAYS = 1 / HOURS_PER_DAY; +export const CELL_AREA_M2 = cellSizeM => cellSizeM * cellSizeM; +export const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); +export const rateToProbability = (ratePerDay, dtDays) => 1 - Math.exp(-Math.max(0, ratePerDay) * dtDays); +export const annualSurvivalToDailyHazard = annualSurvival => -Math.log(clamp(annualSurvival, 1e-6, 1)) / 365; diff --git a/engine.js b/engine.js new file mode 100644 index 0000000..a846717 --- /dev/null +++ b/engine.js @@ -0,0 +1,253 @@ +import {RockField} from './obstacles.js?v=30'; +import {waterDistance as shorelineDistance,inWater,habitat} from './water.js?v=30'; +import {W,H,GW,GH,CELL} from './world-size.js'; +import {sizeSpeedBoost,staminaLimit,recoveryRate,locomotionCost,sprintCost,thermalFitness,metabolismKJPerDay,decayRate} from './model.js'; +import {ClimateProvider} from './ecology/climate.js'; +import {TERRESTRIAL,getProfile} from './ecology/profiles.js'; +import {ResourceField,producerCapacity,producerGrowthPerDay,updateProducerBiomass,updateZooplanktonCohort} from './ecology/resources.js'; +import {interactionEdge,bodyMassPreference,producerIntakeKgPerDay,rallFeedingParameters,functionalResponseRatePerDay,attackProbabilityPerStep,roleCode} from './ecology/feeding.js'; +import {backgroundHazardPerDay,reproductionConfig,inBreedingSeason,offspringEnergyShare,reproductionThreshold,energyFraction,initialStructuralMassKg,energyLimitedGrowth} from './ecology/demography.js'; +import {starvationHazardPerDay,thermalHazardPerDay} from './ecology/metabolism.js'; +import {rateToProbability} from './ecology/units.js'; +import {routineTravelSpeedMPerDay,foragingSpeedMPerDay,escapeSpeedMPerDay,maximumSpeedMPerDay,dailyMovementBudgetM,homeRangeRadiusM,sampleDispersalDistanceM} from './ecology/movement.js'; +import {validateProfileId} from './ecology/schema.js'; +export {sizeSpeedBoost} from './model.js'; +export {W,H,GW,GH,CELL} from './world-size.js'; +export const DT=1/24; +export const CANOPY_BLOCK_MASS=8; +export const traits={bodySize:[.02,100],moveSpeed:[50,5000],staminaCapacity:[5,100],staminaRecovery:[.1,7],visionRange:[10,500],perceptionAbility:[.2,2.5],preferredTemperature:[-15,45],temperatureTolerance:[3,30],waterAffinity:[0,1],offspringSize:[.05,.5],maturityAge:[15,365],lifespan:[120,3650],sociability:[0,1],fear:[.05,2],aggression:[0,1]}; +const base={bodySize:3,moveSpeed:900,staminaCapacity:30,staminaRecovery:1.6,visionRange:115,perceptionAbility:1,preferredTemperature:18,temperatureTolerance:13,waterAffinity:.25,offspringSize:.18,maturityAge:90,lifespan:900,sociability:.6,fear:1,aggression:.2}; +export const defaultGenome={...base}; +export const INITIAL_SPECIES=16; +const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)); +export function descendantColor(parentColor,id){ + const match=/hsl\(([\d.]+)\s+([\d.]+)%\s+([\d.]+)%\)/.exec(parentColor||''); + if(!match)return parentColor||`hsl(${id*137.5%360} 55% 70%)`; + const hue=(+match[1]+(id*37%23)-11+360)%360,sat=clamp(+match[2]+(id*7%9)-4,48,78),light=clamp(+match[3]+(id*11%9)-4,52,77); + return `hsl(${hue} ${sat}% ${light}%)`; +} +export class Hash { + constructor(size=100){this.size=size;this.nx=Math.ceil(W/size)+1;this.ny=Math.ceil(H/size)+1;this.buckets=Array.from({length:this.nx*this.ny},()=>[]);this.used=[]} + rebuild(list){for(const i of this.used)this.buckets[i].length=0;this.used.length=0;for(const a of list){if(a.dead)continue;const i=Math.floor(a.x/this.size)*this.ny+Math.floor(a.y/this.size),b=this.buckets[i];if(!b.length)this.used.push(i);b.push(a)}} + near(x,y,r,out=[]){out.length=0;const s=this.size,minX=Math.max(0,Math.floor((x-r)/s)),maxX=Math.min(this.nx-1,Math.floor((x+r)/s)),minY=Math.max(0,Math.floor((y-r)/s)),maxY=Math.min(this.ny-1,Math.floor((y+r)/s)),rr=r*r;for(let i=minX;i<=maxX;i++)for(let j=minY;j<=maxY;j++)for(const a of this.buckets[i*this.ny+j])if((a.x-x)**2+(a.y-y)**2{if(out){out.x=x;out.y=y;return out}return {x,y}}; +export class World{ + constructor(seed=481516){ + this.steeringOutput={x:0,y:0};this.collisionOutput={x:0,y:0};this.habitatOutput={x:0,y:0};this.genomeRecords=new WeakMap();this.distanceCache=new WeakMap();this.seed=seed>>>0;this.time=0;this.tick=0;this.nextId=1;this.nextSpeciesId=0;this.animals=[];this.carcasses=[];this.species=[];this.events=[];this.history=[];this.hash=new Hash;this.cHash=new Hash;this.nearAnimals=[];this.nearCarcasses=[];this.climate=new ClimateProvider();this.aquaticProfile=getProfile('freshwater');this.resourceFields=[new ResourceField({id:'producer',role:'producer',cellCount:GW*GH,energyDensityKJPerKg:18000}),new ResourceField({id:'zooplankton',role:'zooplankton',cellCount:GW*GH,energyDensityKJPerKg:7000,representation:'cohort'})];this.plants=this.resourceFields[0].biomass;this.zooplankton=this.resourceFields[1].biomass;this.moisture=new Float32Array(GW*GH);this.shade=new Float32Array(GW*GH);this.light=new Float32Array(GW*GH);this.nutrientN=new Float32Array(GW*GH);this.nutrientP=new Float32Array(GW*GH);this.dissolvedOxygen=new Float32Array(GW*GH);this.wet=new Uint8Array(GW*GH);this.trees=[];this.fallen=[];this.nextTreeId=1;this.treeField=new RockField();this.rocks=[];this.rockField=new RockField();this.rockMask=new Uint8Array(GW*GH);this.water=this.generateWater(seed);this.generateRocks(seed);this.generateTrees(seed);this.rebuildEnvironment(); + for(let i=0;i({x:300+this.rand()*(W-600),y:250+this.rand()*(H-500)})); + for(let k=0;k.55?'ectotherm':(k%3===0?'endotherm':'ectotherm');this.addSpecies(null,g,`hsl(${(k*137.508+30)%360} 64% 68%)`,null,{trophicRole:role,physiology,locomotionMode:g.waterAffinity>.55?'swimming':'running'});const total=role==='tertiary-consumer'?14:role==='secondary-consumer'?20:28+Math.floor(this.rand()*10);for(let j=0;j<2;j++){const hub=hubs[(k+j*5)%hubs.length],count=Math.floor(total/2)+(j>>0;t=Math.imul(t^t>>>15,t|1);t^=t+Math.imul(t^t>>>7,t|61);return((t^t>>>14)>>>0)/4294967296} + randomGenome(){const r=(lo,hi)=>lo+(hi-lo)*this.rand(),mass=Math.exp(r(Math.log(.15),Math.log(24))),life=Math.pow(mass/3,.25);return {bodySize:mass,moveSpeed:r(350,1800),staminaCapacity:r(16,55),staminaRecovery:r(.8,2.8),visionRange:r(65,180),perceptionAbility:r(.55,1.65),preferredTemperature:r(9,26),temperatureTolerance:r(9,21),waterAffinity:r(0,1),offspringSize:r(.12,.28),maturityAge:clamp(80*life*r(.75,1.4),...traits.maturityAge),lifespan:clamp(900*life*r(.8,1.35),...traits.lifespan),sociability:r(0,1),fear:r(.35,1.35),aggression:r(.1,.9)}} + temp(){return this.climate.temperatureC(this.time)} + setAquaticProfile(id){this.aquaticProfile=getProfile(validateProfileId(id));this.rebuildEnvironment()} + edge(consumerRole,resourceRole){return interactionEdge(consumerRole,resourceRole)} + log(text){this.events.unshift({time:this.time,text});if(this.events.length>80)this.events.pop()} + addSpecies(name,g,color,parent=null,meta={}){const id=this.nextSpeciesId++,ancestor=this.species[parent];name='種 '+String(id+1).padStart(3,'0');this.species[id]={id,name,g:Object.freeze({...g}),color:ancestor?descendantColor(ancestor.color,id):color,morph:ancestor?ancestor.morph:id%4,parent,origin:this.time,pending:0,trophicRole:meta.trophicRole||ancestor?.trophicRole||'primary-consumer',physiology:meta.physiology||ancestor?.physiology||'ectotherm',locomotionMode:meta.locomotionMode||ancestor?.locomotionMode||(g.waterAffinity>.55?'swimming':'running')};return id} +// Separate terrain RNG keeps terrain generation reproducible without consuming animal RNG. + generateWater(seed){ + // Keep generation cheap: radii are scaled by sqrt(2), which gives roughly twice the + // area for isolated circles. Overlap makes the union only approximate, intentionally. + let value=(seed^0x397b4c63)>>>0;const random=()=>{let t=value=(value+0x6D2B79F5)>>>0;t=Math.imul(t^t>>>15,t|1);t^=t+Math.imul(t^t>>>7,t|61);return((t^t>>>14)>>>0)/4294967296}; + const areaScale=Math.SQRT2,edge=Math.floor(random()*4),radius=(320+random()*110)*areaScale,along=.14+random()*.72; + const start=edge===0?{x:radius*.42,y:H*along}:edge===1?{x:W-radius*.42,y:H*along}:edge===2?{x:W*along,y:radius*.42}:{x:W*along,y:H-radius*.42}; + const destination=edge===0?{x:W-300,y:H*(.14+random()*.72)}:edge===1?{x:300,y:H*(.14+random()*.72)}:edge===2?{x:W*(.14+random()*.72),y:H-300}:{x:W*(.14+random()*.72),y:300}; + const water=[{...start,r:radius,seed:random()*Math.PI*2}]; + for(let i=1;i<7;i++){const last=water.at(-1),r=(290+random()*170)*areaScale,heading=Math.atan2(destination.y-last.y,destination.x-last.x)+(random()-.5)*1.25,step=(last.r+r)*(.43+random()*.12),x=clamp(last.x+Math.cos(heading)*step,r*.42,W-r*.42),y=clamp(last.y+Math.sin(heading)*step,r*.42,H-r*.42);water.push({x,y,r,seed:random()*Math.PI*2})} + for(const index of [2,4]){const parent=water[index],before=water[index-1],r=(270+random()*140)*areaScale,heading=Math.atan2(parent.y-before.y,parent.x-before.x)+(random()<.5?-1:1)*(1.05+random()*.65),step=(parent.r+r)*(.42+random()*.13);water.push({x:clamp(parent.x+Math.cos(heading)*step,r*.42,W-r*.42),y:clamp(parent.y+Math.sin(heading)*step,r*.42,H-r*.42),r,seed:random()*Math.PI*2})} + return water; + } + generateRocks(seed){ + let value=(seed^0x51f15e)>>>0;const random=()=>{value=(value+0x6D2B79F5)>>>0;let t=value;t=Math.imul(t^t>>>15,t|1);t^=t+Math.imul(t^t>>>7,t|61);return ((t^t>>>14)>>>0)/4294967296}; + const circles=[]; + for(let attempt=0;attempt<1200&&circles.length<30;attempt++){ + const r=80+random()*38,x=90+r+random()*(W-180-2*r),y=90+r+random()*(H-180-2*r); + if(circles.some(c=>(c.x-x)**2+(c.y-y)**2<(c.r+r+45)**2))continue; + circles.push({x,y,r}); + } + this.rocks=circles;this.rockField=new RockField(circles); + } + generateTrees(seed){ + let value=(seed^0x76b17d)>>>0;const random=()=>{value=(Math.imul(value,1664525)+1013904223)>>>0;return value/4294967296};const trees=[]; + for(let attempt=0;attempt<6000&&trees.length<256;attempt++){const x=70+random()*(W-140),y=70+random()*(H-140),r=34+random()*35,trunk=4+r*.055,d=this.waterDistance(x,y),moisture=.12+.88*Math.exp(-Math.max(0,d)/210);if(d<12||!this.rockField.free(x,y,trunk+4)||random()>.07+.93*moisture||trees.some(t=>(t.x-x)**2+(t.y-y)**2<35**2))continue;trees.push(this.createTree(x,y,r,trunk,.58+random()*.22,Math.floor(random()*6),random))} + this.trees=trees;this.rebuildTreeObstacles(); + } + createTree(x,y,r,trunk,cover,variant,random=()=>this.rand()){ + const lifespan=650+random()*550,age=random()*lifespan,matureR=r,growth=Math.min(1,.12+age/220),radius=matureR*growth,maxLeaves=radius*radius*.05; + return {id:this.nextTreeId++,kind:'circle',x,y,r:radius,matureR,trunk:trunk*growth,matureTrunk:trunk,cover,variant,age,lifespan,maxLeaves,leaves:maxLeaves,dead:false}; + } + trunks(){return this.trees.map(t=>({x:t.x,y:t.y,r:t.trunk,steer:false}))} + fieldFor(a){return this.biomass(a)>=CANOPY_BLOCK_MASS?this.largeField:this.rockField} + rebuildLargeField(){ + this.largeField=new RockField([...this.rocks,...this.trunks(),...this.trees.map(t=>({x:t.x,y:t.y,r:this.canopyCollisionRadius(t),treeId:t.id}))],this.fallen); + for(const a of this.animals){if(a.dead||this.biomass(a)=CANOPY_BLOCK_MASS||this.rockField.free(a.x,a.y,this.radius(a)))continue;const p=this.rockField.findFree(a.x,a.y,this.radius(a));if(p){const h=this.findHabitat(a,p.x,p.y);if(h){a.x=h.x;a.y=h.y}}} + } + updateObstacleMask(){for(let j=0;j=r2)continue;const n=j*GW+i;this.shade[n]=Math.max(this.shade[n],health*(1-d2/r2)); + } + } + } + concealmentAt(x,y){let cover=0,field=this.treeField;if(!field.cells)return 0;const ix=Math.floor(x/field.cellSize),iy=Math.floor(y/field.cellSize);if(ix<0||iy<0||ix>=field.nx||iy>=field.ny)return cover;for(const id of field.cells[ix*field.ny+iy]){const t=field.shapes[id],r=this.canopyRadius(t),d2=(t.x-x)**2+(t.y-y)**2;if(d2=t.lifespan||t.leaves<=0)t.dead=true; + if(t.dead){this.fallen.push({x:t.x,y:t.y,w:t.r*1.55,h:t.trunk*2.3,angle:this.rand()*Math.PI*2,expires:this.time+120});changed=true} + } + if(changed)this.trees=this.trees.filter(t=>!t.dead); + const alive=this.fallen.filter(f=>f.expires>this.time);if(alive.length!==this.fallen.length){this.fallen=alive;changed=true} + const density=Math.max(0,1-this.trees.length/620),newTrees=[]; + if(density&&this.trees.length<520)for(const parent of this.trees){ + if(parent.age<120||parent.leaves/parent.maxLeaves<.05||this.rand()>=.0045*density*(.3+.7*this.moisture[this.index(parent.x,parent.y)]))continue; + const angle=this.rand()*Math.PI*2,d=55+this.rand()*105,x=parent.x+Math.cos(angle)*d,y=parent.y+Math.sin(angle)*d,r=34+this.rand()*35,trunk=4+r*.055; + if(xW-r-10||yH-r-10||this.waterDistance(x,y)<12||!this.rockField.free(x,y,trunk+4)||this.trees.some(t=>(t.x-x)**2+(t.y-y)**2<35**2)||newTrees.some(t=>(t.x-x)**2+(t.y-y)**2<35**2))continue; + if(this.animals.some(a=>!a.dead&&(a.x-x)**2+(a.y-y)**2<((this.biomass(a)>=CANOPY_BLOCK_MASS?r*.88:trunk)+this.radius(a)+2)**2))continue; + const child=this.createTree(x,y,r,trunk,parent.cover,this.rand()*6|0);child.age=0;child.r=child.matureR*.12;child.trunk=child.matureTrunk*.12;child.maxLeaves=child.r*child.r*.05;child.leaves=child.maxLeaves*.55;newTrees.push(child); + if(this.trees.length+newTrees.length>=520)break; + } + if(newTrees.length){this.trees.push(...newTrees);changed=true} + if(changed){this.rebuildTreeObstacles();this.updateObstacleMask()}else this.rebuildLargeField();this.updateShade(); + } + detectionProbability(a,b,d=Math.hypot(b.x-a.x,b.y-a.y)){const visible=clamp(Math.sqrt(this.biomass(b))*a.g.perceptionAbility*35/(d+15)*(1+Math.hypot(b.vx,b.vy)/80),.015,1);return visible*(1-this.concealmentAt(b.x,b.y))} + staminaCapacity(a){return staminaLimit(this.biomass(a),a.g.staminaCapacity)} + waterDistance(x,y){return shorelineDistance(this.water,x,y)} + waterAllowed(a,x,y){const h=a.habitat||habitat(a.g.waterAffinity);return h==='両棲'||(h==='水棲')===inWater(this.water,x,y)} + findHabitat(a,x,y){if(this.waterAllowed(a,x,y)&&this.fieldFor(a).free(x,y,this.radius(a)))return {x,y};for(let radius=10;radius<=540;radius+=12)for(let j=0;j<16;j++){const t=j*Math.PI/8+radius*.037,px=clamp(x+Math.cos(t)*radius,5,W-5),py=clamp(y+Math.sin(t)*radius,5,H-5);if(this.waterAllowed(a,px,py)&&this.fieldFor(a).free(px,py,this.radius(a)))return {x:px,y:py}}if((a.habitat||habitat(a.g.waterAffinity))==='水棲')for(const w of this.water){if(this.fieldFor(a).free(w.x,w.y,this.radius(a)))return {x:w.x,y:w.y}}return null} + moveHabitat(a,x,y,tx,ty,out=null){if(!this.waterAllowed(a,x,y)){const p=this.findHabitat(a,x,y);return outputPosition(out,p?p.x:x,p?p.y:y)}let allowed=this.waterAllowed(a,tx,ty),firstBlocked=1;if(allowed&&(tx-x)**2+(ty-y)**2>100)for(let j=1;j<8;j++){const q=j/8;if(!this.waterAllowed(a,x+(tx-x)*q,y+(ty-y)*q)){allowed=false;firstBlocked=q;break}}if(allowed)return outputPosition(out,tx,ty);let lo=0,hi=firstBlocked;for(let i=0;i<9;i++){const mid=(lo+hi)/2;if(this.waterAllowed(a,x+(tx-x)*mid,y+(ty-y)*mid))lo=mid;else hi=mid}return outputPosition(out,x+(tx-x)*Math.max(0,lo-.006),y+(ty-y)*Math.max(0,lo-.006))} + rebuildEnvironment(){ + this.updateShade();for(let j=0;j80||water.some(w=>![w.x,w.y,w.r].every(Number.isFinite)||w.r<20||w.r>800||w.x<0||w.x>W||w.y<0||w.y>H))return false; + const previous=this.water;this.water=water.map(w=>({...w}));const moves=[]; + for(const a of this.animals){if(a.dead||this.waterAllowed(a,a.x,a.y))continue;const p=this.findHabitat(a,a.x,a.y);if(!p){this.water=previous;return false}moves.push([a,p])} + for(const [a,p]of moves){a.x=p.x;a.y=p.y}this.rebuildEnvironment();return true; + } + setRocks(circles){ + if(!Array.isArray(circles)||circles.length>80||circles.some(c=>![c.x,c.y,c.r].every(Number.isFinite)||c.r<80||c.r>420||c.x<0||c.x>W||c.y<0||c.y>H))return false; + const field=new RockField([...circles,...this.trunks()],this.fallen),large=new RockField([...circles,...this.trunks(),...this.trees.map(t=>({x:t.x,y:t.y,r:this.canopyCollisionRadius(t),treeId:t.id}))],this.fallen),moves=[]; + for(const a of this.animals){if(a.dead)continue;const p=(this.biomass(a)>=CANOPY_BLOCK_MASS?large:field).findFree(a.x,a.y,this.radius(a));if(!p||!this.waterAllowed(a,p.x,p.y))return false;moves.push([a,p])} + this.rocks=circles.map(c=>({...c}));this.rockField=field;this.largeField=large;for(const [a,p]of moves){a.x=p.x;a.y=p.y}this.rebuildEnvironment();return true; + } + clearPath(x,y,tx,ty,a=null){const field=a?this.fieldFor(a):this.rockField;if(!field.shapes.length)return true;const dx=tx-x,dy=ty-y,len=dx*dx+dy*dy;for(const c of field.candidates(x,y,tx,ty,0)){if(c.kind==='rect'){let lo=0,hi=1;const co=Math.cos(c.angle||0),si=Math.sin(c.angle||0),lx=co*(x-c.x)+si*(y-c.y),ly=-si*(x-c.x)+co*(y-c.y),vx=co*dx+si*dy,vy=-si*dx+co*dy,bounds=[[-vx,lx+c.w/2],[vx,c.w/2-lx],[-vy,ly+c.h/2],[vy,c.h/2-ly]];for(const [p,q] of bounds){if(p===0){if(q<0){lo=2;break}}else if(p<0)lo=Math.max(lo,q/p);else hi=Math.min(hi,q/p)}if(lo<=hi)return false}else{const t=len?clamp(((c.x-x)*dx+(c.y-y)*dy)/len,0,1):0;if((x+t*dx-c.x)**2+(y+t*dy-c.y)**2=48){row={cache:new WeakMap(),count:0};this.distanceCache.set(a,row)}row.cache.set(b,value);row.count++}return value; + } + rawGeneticDistance(a,b){let sum=0;for(const k of traitKeys){const [lo,hi]=traits[k],d=(a[k]-b[k])/(hi-lo);sum+=d*d}return Math.sqrt(sum/traitKeys.length)} + geneticSimilarity(a,b){return Math.exp(-Math.pow(this.geneticDistance(a,b)/.065,2)*3)} + shouldPauseAtFood(a,t){if(!t||t.dead||a.forageUntil>this.time)return false;const distance=Math.hypot(t.x-a.x,t.y-a.y);if(t.resourceRole==='zooplankton'||t.plant)return distance=CANOPY_BLOCK_MASS?this.canopyCollisionRadius(t):t.trunk)+this.radius(a)+2;if(t.id<0)return distance.035&&((a.habitat||habitat(a.g.waterAffinity))==='両棲'||b.habitat&&a.habitat===b.habitat||this.waterAllowed(a,b.x,b.y))} + kill(a,cause){if(a.dead)return;a.dead=true;this.carcasses.push({id:-a.id,x:a.x,y:a.y,remainingBiomass:this.biomass(a),originalBodySize:this.biomass(a),decay:.015,cause,resourceRole:'detritus'}); } + decide(a){ + const g=a.g,near=this.hash.near(a.x,a.y,g.visionRange,this.nearAnimals),hunger=clamp(1-a.energy/this.maxEnergy(a),0,1),role=this.roleOf(a);let danger=null,food=null,dangerScore=0,bestFood=0; + for(const b of near){if(b===a||b.dead)continue;const d=Math.hypot(b.x-a.x,b.y-a.y),visible=clamp(Math.sqrt(this.biomass(b))*g.perceptionAbility*35/(d+15)*(1+Math.hypot(b.vx,b.vy)/1200),.015,1),roll=this.rand();if(roll>visible*(1-this.concealmentAt(b.x,b.y)))continue;if(this.canHunt(b,a)){const sc=(1-d/g.visionRange)*(this.species[b.sid]?.g.aggression??.3);if(sc>dangerScore){dangerScore=sc;danger=b}}if(this.canHunt(a,b)){const edge=this.preyEdge(a,b),pref=bodyMassPreference(this.biomass(a),this.biomass(b),this.profileAt(a.x,a.y),edge),sc=edge.preferenceWeight*pref*(1-d/g.visionRange);if(sc>bestFood){food=b;bestFood=sc}}} + const detritusEdge=this.edge(role,'detritus');if(detritusEdge)for(const c of this.cHash.near(a.x,a.y,g.visionRange,this.nearCarcasses)){if(c.remainingBiomass<=0)continue;const sc=detritusEdge.preferenceWeight*(1-Math.hypot(c.x-a.x,c.y-a.y)/g.visionRange);if(sc>bestFood&&this.waterAllowed(a,c.x,c.y)){food=c;bestFood=sc}} + const producerEdge=this.edge(role,'producer');if(producerEdge){for(const tree of this.treeField.candidates(a.x,a.y,a.x,a.y,g.visionRange)){if(tree.dead||tree.leaves<=0)continue;const d=Math.hypot(tree.x-a.x,tree.y-a.y),r=this.canopyRadius(tree),sc=producerEdge.preferenceWeight*.55*(tree.leaves/tree.maxLeaves)*(1-Math.max(0,d-r)/g.visionRange*.7);if(sc>bestFood&&this.waterAllowed(a,tree.x,tree.y)){food=tree;bestFood=sc}}for(let i=0;i<9;i++){const angle=this.rand()*6.28,r=i?this.rand()*g.visionRange:0,x=clamp(a.x+Math.cos(angle)*r,0,W-1),y=clamp(a.y+Math.sin(angle)*r,0,H-1),idx=this.index(x,y),cap=Math.max(.001,this.capacity(idx)),sc=producerEdge.preferenceWeight*(this.plants[idx]/cap)*(1-r/g.visionRange*.6);if(sc>.06&&sc>bestFood&&this.waterAllowed(a,x,y)){food={x,y,plant:true};bestFood=sc}}} + const zooplanktonEdge=this.edge(role,'zooplankton');if(zooplanktonEdge&&inWater(this.water,a.x,a.y)){for(let i=0;i<7;i++){const angle=this.rand()*6.28,r=i?this.rand()*g.visionRange:0,x=clamp(a.x+Math.cos(angle)*r,0,W-1),y=clamp(a.y+Math.sin(angle)*r,0,H-1),idx=this.index(x,y),sc=zooplanktonEdge.preferenceWeight*Math.min(1,this.zooplankton[idx]/.02)*(1-r/g.visionRange*.6);if(sc>.05&&sc>bestFood&&this.waterAllowed(a,x,y)){food={x,y,resourceRole:'zooplankton'};bestFood=sc}}} + let winning=dangerScore*g.fear*2.5;a.action='逃走';a.target=danger;const feeding=hunger*(.35+bestFood)*1.7;if(food&&feeding>winning){winning=feeding;a.action='摂餌';a.target=food}const resting=Math.pow(1-a.stamina/this.staminaCapacity(a),2)*1.1*(food&&hunger>.16?.35:1);if(resting>winning){winning=resting;a.action='休息';a.target=null}if(.13>winning){a.action='徘徊';a.target=null} + if(a.action==='徘徊'&&hunger>.5&&bestFood<.04&&!a.dispersalTarget&&this.rand()<.025){const mode=this.species[a.sid]?.locomotionMode||'running',distance=sampleDispersalDistanceM(()=>this.rand(),this.biomass(a),mode),angle=this.rand()*Math.PI*2;a.dispersalTarget={x:clamp(a.x+Math.cos(angle)*distance,5,W-5),y:clamp(a.y+Math.sin(angle)*distance,5,H-5)}} + a.flockX=0;a.flockY=0;let count=0;for(const b of near){if(b===a||b.dead)continue;const dx=b.x-a.x,dy=b.y-a.y,d2=dx*dx+dy*dy,space=this.radius(a)+this.radius(b);if(d2<(space*2)**2){const d=Math.hypot(dx,dy);a.flockX-=dx/(d*d+1)*space*5;a.flockY-=dy/(d*d+1)*space*5}else if(b.sid===a.sid&&d2<85**2){a.flockX+=(dx*.007+b.vx*.012)*g.sociability;a.flockY+=(dy*.007+b.vy*.012)*g.sociability;count++}}if(count){a.flockX/=Math.sqrt(count);a.flockY/=Math.sqrt(count)} + } + grow(a){ + const result=energyLimitedGrowth({structuralMassKg:this.biomass(a),adultMassKg:a.g.bodySize,energyKJ:a.energy,ageDays:a.age,maturityAgeDays:a.g.maturityAge,dtDays:DT}); + a.structuralMassKg=result.structuralMassKg;a.energy=result.energyKJ;return result.gainKg; + } + reproduce(a){ + const role=this.roleOf(a),cfg=reproductionConfig(role);if(a.agethis.rand(),this.biomass(a),mode)*.15),child=this.make(a.g,a.sid,a.x+Math.cos(angle)*d,a.y+Math.sin(angle)*d,0,perChild,[a.id]);if(!child)continue;child.generation=a.generation+1;children.push(child)} + if(!children.length)return;a.energy=Math.max(0,a.energy-perChild*children.length);a.lastBirth=this.time;this.animals.push(...children); + } + updateResources(){ + const landTemp=this.climate.temperatureC(this.time),waterTemp=this.climate.temperatureC(this.time,{aquatic:true}); + for(let i=0;ic.remainingBiomass>.003);this.updateTrees(); + } + applyMortality(a,temp){if(a.dead)return;const maxE=this.maxEnergy(a),ef=energyFraction(a.energy,maxE),thermal=thermalHazardPerDay(temp,a.g.preferredTemperature,a.g.temperatureTolerance,this.physiologyOf(a)),starve=starvationHazardPerDay(ef),background=backgroundHazardPerDay(this.roleOf(a)),total=thermal+starve+background;if(!total||this.rand()>=rateToProbability(total,DT))return;let pick=this.rand()*total;if((pick-=thermal)<0)this.kill(a,'温度ストレス');else if((pick-=starve)<0)this.kill(a,'飢餓');else this.kill(a,'背景死亡')} + + predationRateForTarget(a,target,temp){ + const radius=Math.max(20,a.g.visionRange),areaHa=Math.max(.01,Math.PI*radius*radius/10000),near=this.hash.near(a.x,a.y,radius,[]),groups=new Map; + for(const b of near){if(b===a||b.dead||!this.canHunt(a,b))continue;const role=this.roleOf(b),g=groups.get(role)||{count:0,mass:0};g.count++;g.mass+=this.biomass(b);groups.set(role,g)} + const terms=[];let chosen=null;for(const [resourceRole,g] of groups){const edge=this.edge(this.roleOf(a),resourceRole);if(!edge)continue;const avgMass=g.mass/g.count,params=rallFeedingParameters(edge,this.biomass(a),avgMass,temp,this.profileAt(a.x,a.y)),q=edge.functionalResponse===3?2:1,term={...params,resourceDensity:g.count/areaHa,q,count:g.count,resourceRole};terms.push(term);if(resourceRole===this.roleOf(target))chosen=term} + if(!chosen)return {rate:0,handlingTimeDays:0};const total=functionalResponseRatePerDay(chosen,chosen.resourceDensity,terms,chosen.q);return {rate:total/Math.max(1,chosen.count),handlingTimeDays:chosen.handlingTimeDays}; + } + feed(a,t,m,speed,staminaMax){ + const role=this.roleOf(a),profile=this.profileAt(a.x,a.y),temp=this.climate.temperatureC(this.time,{aquatic:this.wet[this.index(a.x,a.y)]===1}),producerEdge=this.edge(role,'producer'); + if(producerEdge){ + const idx=this.index(a.x,a.y),intake=producerIntakeKgPerDay(m,this.plants[idx],producerEdge,temp,profile)*DT,bite=this.resourceFields[0].consume(idx,intake,CELL*CELL),gain=bite*this.resourceFields[0].energyDensityKJPerKg*producerEdge.assimilationEfficiency;a.energy+=gain; + if(t?.maxLeaves&&!t.dead&&Math.hypot(a.x-t.x,a.y-t.y)=this.radius(a)+targetRadius+3||!this.clearPath(a.x,a.y,t.x,t.y,a))return; + if(t.id<0){const edge=this.edge(role,'detritus');if(!edge)return;const amount=Math.min(t.remainingBiomass,.045*Math.pow(Math.max(m,.001),.78)*DT);t.remainingBiomass-=amount;if(t.remainingBiomass<=0)this.emptyCarcasses=true;const gain=amount*7000*edge.assimilationEfficiency;a.energy+=gain;return} + if(!this.canHunt(a,t))return;const relative=(speed-Math.hypot(t.vx,t.vy))/Math.max(1,speed)*.25+(a.stamina/staminaMax-t.stamina/this.staminaCapacity(t))*.18,fr=this.predationRateForTarget(a,t,temp),rate=fr.rate*clamp(.75+relative,.15,1.7),probability=attackProbabilityPerStep(rate,DT);a.stamina=Math.max(0,a.stamina-2); + if(this.rand()c.remainingBiomass>0));if(this.tick%24===0)this.updateResources();const temp=this.temp(),length=this.animals.length; + for(let i=0;ihomeRange){dx=homeDx;dy=homeDy}else{a.heading+=(this.rand()-.5)*.18;dx=Math.cos(a.heading)*40;dy=Math.sin(a.heading)*40}} + let norm=Math.hypot(dx,dy)||1;dx=dx/norm+(a.flockX||0)*.18;dy=dy/norm+(a.flockY||0)*.18;if(field.shapes.length){const v=field.steer(a.x,a.y,dx,dy,this.radius(a),a.id%2?1:-1,t?.maxLeaves?t.id:null,this.steeringOutput);dx=v.x;dy=v.y}norm=Math.hypot(dx,dy)||1; + const sprint=a.action==='逃走'||(a.action==='摂餌'&&t&&!t.plant&&!t.maxLeaves&&t.id>0),budget=dailyMovementBudgetM(m,g.moveSpeed,mode),remaining=Math.max(0,budget-a.movedToday),modeSpeed=a.action==='逃走'?escapeSpeedMPerDay(m,budget,mode):a.action==='摂餌'?foragingSpeedMPerDay(m,budget,mode):routineTravelSpeedMPerDay(m,budget,mode),maxSpeed=maximumSpeedMPerDay(m,mode);speed=Math.min(maxSpeed,modeSpeed)*(.35+.65*a.stamina/staminaMax)/(1+stress*.45)*(.35+.65*fitness);if(a.habitat==='両棲')speed*=.72;if(a.action==='摂餌'&&t?.id>0&&!t?.maxLeaves)speed=Math.max(speed,Math.min(maxSpeed,Math.hypot(t.vx,t.vy)*1.12));if(a.action==='摂餌'&&this.shouldPauseAtFood(a,t))speed=0;speed=Math.min(speed,remaining/DT); + a.vx=dx/norm*speed;a.vy=dy/norm*speed;const oldX=a.x,oldY=a.y;if(field.shapes.length){const p=field.move(a.x,a.y,a.vx*DT,a.vy*DT,this.radius(a),this.collisionOutput),h=this.moveHabitat(a,a.x,a.y,p.x,p.y,this.habitatOutput);if((h.x!==p.x||h.y!==p.y)&&!field.free(h.x,h.y,this.radius(a))){const safe=this.findHabitat(a,h.x,h.y);if(safe){h.x=safe.x;h.y=safe.y}}a.vx=(h.x-a.x)/DT;a.vy=(h.y-a.y)/DT;a.x=h.x;a.y=h.y}else{const h=this.moveHabitat(a,a.x,a.y,clamp(a.x+a.vx*DT,3,W-3),clamp(a.y+a.vy*DT,3,H-3),this.habitatOutput);a.vx=(h.x-a.x)/DT;a.vy=(h.y-a.y)/DT;a.x=h.x;a.y=h.y}a.movedToday+=Math.hypot(a.x-oldX,a.y-oldY);if(a.x<=3||a.x>=W-3||a.y<=3||a.y>=H-3)a.heading+=Math.PI*.7;a.stamina=clamp(a.stamina+DT*(sprint?-sprintCost(m,speed):recover*.15)/(1+stress),0,staminaMax) + }else{a.vx=a.vy=0;a.stamina=Math.min(staminaMax,a.stamina+recover*DT/(1+stress))} + const metabolism=metabolismKJPerDay(m,localTemp,this.physiologyOf(a))*a.ability,resp=DT*metabolism*(a.action==='休息'?.78:1),moveCost=DT*locomotionCost(m,speed);a.energy-=resp+moveCost;if(a.action==='摂餌')this.feed(a,t,m,speed,staminaMax);if(a.energy>0)this.grow(a);a.energy=Math.min(a.energy,this.maxEnergy(a));if(a.energy<=0)this.kill(a,'飢餓');else if(a.age>g.lifespan)this.kill(a,'寿命');else{this.applyMortality(a,localTemp);if(!a.dead)this.reproduce(a)} + } + if(this.emptyCarcasses){this.carcasses=this.carcasses.filter(c=>c.remainingBiomass>0);this.emptyCarcasses=false}if(this.tick%24===0)this.animals=this.animals.filter(a=>!a.dead);if(this.tick%168===0)this.sample(); + } + seedInitialPopulation(sid,n,x,y,spread=45){const s=this.species[sid];if(!s)return 0;if(habitat(s.g.waterAffinity)==='水棲'&&!inWater(this.water,x,y)){let nearest=this.water[0],distance=Infinity;for(const w of this.water){const d=(w.x-x)**2+(w.y-y)**2;if(dsum+v,0)*CELL*CELL;this.history.push({time:this.time,counts,plant});if(this.history.length>520)this.history.shift()} + stats(){ + const alive=this.animals.filter(a=>!a.dead),counts=[];for(const a of alive)counts[a.sid]=(counts[a.sid]||0)+1; + const producerBiomassKg=this.plants.reduce((sum,v)=>sum+v,0)*CELL*CELL; + return {time:this.time,temp:this.temp(),population:alive.length,plant:producerBiomassKg,species:this.species.filter(Boolean).map(s=>({...s,count:counts[s.id]||0,roleCode:roleCode(s.trophicRole)})),generation:alive.reduce((m,a)=>Math.max(m,a.generation),0)} + } +} diff --git a/gestures.js b/gestures.js new file mode 100644 index 0000000..cab0749 --- /dev/null +++ b/gestures.js @@ -0,0 +1,5 @@ +// Keep the world point under the gesture midpoint while fingers move or zoom. +export function pinchCamera(start,a,b,rect,view,worldWidth,worldHeight){ + const zoom=Math.max(1,Math.min(32,start.zoom*Math.hypot(a.x-b.x,a.y-b.y)/start.distance)),s=zoom*view.base; + return {zoom,panX:(a.x+b.x)/2-rect.left-start.anchorX*s-(view.width-worldWidth*s)/2,panY:(a.y+b.y)/2-rect.top-start.anchorY*s-(view.height-worldHeight*s)/2}; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..aa8ea25 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ +ECOSPHERE | 食物連鎖シミュレータ +
    ◉ ECOSPHERE
    経過 0000日 00:00— °C
    準備中
    +
    +
    +
    +
    準備中
    岩 · 倒木 · 死骸
    ━━━ 100 m
    実効 — 日/秒
    +
    +

    個体数と種

    線の太さ=個体数 · 色=表示レイヤー
      0000日 00:00現在
      +
      +

      操作と生態系

      操作

      生物をクリックすると行動と形質を確認できます。水域・岩は追加、移動、拡大縮小、削除ができます。水域は淡水/海洋プロファイルを切り替えられます。

      生態系

      時間は day、距離は m、動物体重は kg、エネルギーは kJ です。生産者は格子上の biomass、動物は個体として扱います。食性の単一値ではなく、一次・二次・高次消費者と資源の相互作用エッジで摂食を決めます。出生後も親は生存し、加速された突然変異・自動種分化・自動移入は行いません。

      今回除外した機能

      水深レイヤーと人為的攪乱(漁獲、農薬、富栄養化、生息地消失、温暖化イベント等)は実装していません。初期水域は旧版と同じ地形系列を基準に、格子面積がおよそ2倍になるよう拡張しています。

      表示と計算

      標準の生態刻みは 1/24 day です。式、単位、出典、検証条件はモデルの根拠をご覧ください。

      diff --git a/lineage.js b/lineage.js new file mode 100644 index 0000000..dbac315 --- /dev/null +++ b/lineage.js @@ -0,0 +1,30 @@ +// Living species occupy stable depth-first lanes; extinct ancestors are reparented by World.sample(). +export function lineageOrder(species){ + const byId=new Map(species.map(s=>[s.id,s])),ordered=[],seen=new Set(); + const visit=(s,depth)=>{if(seen.has(s.id))return;seen.add(s.id);ordered.push({s,depth});species.filter(c=>c.parent===s.id).sort((a,b)=>a.origin-b.origin||a.id-b.id).forEach(c=>visit(c,depth+1))}; + species.filter(s=>!byId.has(s.parent)).sort((a,b)=>a.id-b.id).forEach(s=>visit(s,0)); + for(const s of species)visit(s,0); + return ordered; +} +export function lineageColor(s,layer='plants'){ + if(layer==='trophic')return s.trophicRole==='primary-consumer'?'#a7d46d':s.trophicRole==='secondary-consumer'?'#e9c976':s.trophicRole==='tertiary-consumer'?'#d88979':'#aa8bc7'; + if(layer==='habitat')return s.g.waterAffinity<1/3?'#d5bc88':s.g.waterAffinity<2/3?'#86c0a1':'#78b8da'; + return s.color; +} +export function drawLineage(ctx,{width:w,height:h,species,hist,scale=1,layer='plants'}){ + const ordered=lineageOrder(species),byId=new Map(species.map(s=>[s.id,s])); + ctx.setTransform(scale,0,0,scale,0,0);ctx.clearRect(0,0,w,h); + const compact=w<500,left=compact?66:115,right=w-(compact?37:57),first=hist[0]?.time??0,last=hist.at(-1)?.time??first,span=Math.max(1,last-first),x=time=>left+Math.max(0,Math.min(1,(time-first)/span))*(right-left); + ctx.fillStyle='#899c94';ctx.font='12px system-ui';ctx.textAlign='right';ctx.fillText('種',left-12,20);ctx.textAlign='left';ctx.fillText('現在',right+8,20); + for(const [i,{s}] of ordered.entries()){ + const y=36+i*31;ctx.strokeStyle='#2c3c3c';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(left,y);ctx.lineTo(right,y);ctx.stroke(); + ctx.fillStyle=lineageColor(s,layer);ctx.textAlign='right';ctx.font='12px system-ui';ctx.fillText(compact?s.name.replace('種 ','#'):s.name,left-8,y+4);ctx.textAlign='left';ctx.fillStyle='#dce7df';ctx.fillText(s.count.toLocaleString(),right+8,y+4); + } + const lanes=new Map(ordered.map((v,i)=>[v.s.id,36+i*31])); + for(const {s} of ordered){if(!lanes.has(s.parent)||s.origin>last)continue;const cx=x(Math.max(first,s.origin)),py=lanes.get(s.parent),cy=lanes.get(s.id),birthCount=hist.find(v=>v.time>=s.origin)?.counts[s.id]||s.count,width=Math.max(1.5,Math.min(16,1+Math.sqrt(birthCount)*.78));ctx.strokeStyle=lineageColor(s,layer);ctx.globalAlpha=s.origingene*Math.max(.2,mass/3); +export const recoveryRate=(mass,gene)=>gene*Math.pow(Math.max(.05,mass/3),.75); +// Returns kJ/day when speed is m/day. +export const locomotionCost=(mass,speed)=>.018*Math.pow(Math.max(mass,.001),.684)*Math.max(0,speed); +export const sprintCost=(mass,speed)=>.034*Math.pow(Math.max(mass,.001),.684)*Math.max(0,speed); +export const thermalFitness=(temperature,preferred,tolerance)=>Math.exp(-0.5*(((temperature-preferred)/Math.max(1,tolerance))**2)); +export const metabolismKJPerDay=basalMetabolismKJPerDay; +export const decayRate=temperature=>.018*Math.pow(1.88,(temperature-20)/10); diff --git a/obstacles.js b/obstacles.js new file mode 100644 index 0000000..187df3d --- /dev/null +++ b/obstacles.js @@ -0,0 +1,110 @@ +// Continuous circles and rotated fallen-log rectangles. +// A uniform spatial index limits swept collision checks to nearby obstacles. +import {W as WIDTH,H as HEIGHT} from './world-size.js'; +const SIZE=120,EPS=.0001; +const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)); +const local=(c,x,y)=>{const co=Math.cos(c.angle||0),si=Math.sin(c.angle||0),dx=x-c.x,dy=y-c.y;return {x:co*dx+si*dy,y:-si*dx+co*dy}}; +const world=(c,x,y)=>{const co=Math.cos(c.angle||0),si=Math.sin(c.angle||0);return {x:c.x+co*x-si*y,y:c.y+si*x+co*y}}; +const rectOverlap=(c,x,y,r)=>{const q=local(c,x,y),dx=Math.max(Math.abs(q.x)-c.w/2,0),dy=Math.max(Math.abs(q.y)-c.h/2,0);return dx*dx+dy*dy<(r-EPS)**2||(dx===0&&dy===0)}; +function roundedRectHit(c,x,y,dx,dy,r){ + const p=local(c,x,y),co=Math.cos(c.angle||0),si=Math.sin(c.angle||0),vx=co*dx+si*dy,vy=-si*dx+co*dy,hx=c.w/2,hy=c.h/2; + let first=Infinity,nx=0,ny=0; + const check=(t,a,b)=>{if(t>=0&&t=0||disc<0)continue;const t=(-b-Math.sqrt(disc))/len,xx=p.x+vx*t,yy=p.y+vy*t; + if(sx*(xx-cx)<-EPS||sy*(yy-cy)<-EPS)continue; + const n=Math.hypot(xx-cx,yy-cy)||1;check(t,(xx-cx)/n,(yy-cy)/n); + } + return first<=1?{t:first,x:co*nx-si*ny,y:si*nx+co*ny}:null; +} +const outputPosition=(out,x,y)=>{if(out){out.x=x;out.y=y;return out}return {x,y}}; +export class RockField { + constructor(circles=[],rects=[],preserveReferences=false){ + this.cellSize=SIZE; + this.nx=Math.ceil(WIDTH/SIZE);this.ny=Math.ceil(HEIGHT/SIZE); + this.cells=Array.from({length:this.nx*this.ny},()=>[]); + this.circles=preserveReferences?circles:circles.map(c=>({...c}));this.rects=rects.map(r=>({...r})); + this.shapes=[...this.circles.map(c=>preserveReferences?c:{...c,kind:'circle'}),...this.rects.map(r=>({...r,kind:'rect'}))]; + this.marks=new Uint32Array(this.shapes.length);this.stamp=0; + for(let id=0;id>>0;if(!this.stamp){this.marks.fill(0);this.stamp=1} + for(let x=Math.max(0,Math.floor((Math.min(x0,x1)-r)/SIZE));x<=Math.min(this.nx-1,Math.floor((Math.max(x0,x1)+r)/SIZE));x++)for(let y=Math.max(0,Math.floor((Math.min(y0,y1)-r)/SIZE));y<=Math.min(this.ny-1,Math.floor((Math.max(y0,y1)+r)/SIZE));y++)for(const id of this.cells[x*this.ny+y])if(this.marks[id]!==this.stamp){this.marks[id]=this.stamp;out.push(this.shapes[id])} + return out; + } + free(x,y,r){ + if(xWIDTH-r||yHEIGHT-r)return false; + for(const c of this.candidates(x,y,x,y,r)){ + if(c.kind==='rect'){if(rectOverlap(c,x,y,r))return false} + else if((x-c.x)**2+(y-c.y)**2<(c.r+r-EPS)**2)return false; + } + return true; + } + freePoint(x,y){ + if(x<0||x>WIDTH||y<0||y>HEIGHT)return false; + const ix=Math.min(this.nx-1,Math.floor(x/SIZE)),iy=Math.min(this.ny-1,Math.floor(y/SIZE)); + for(const id of this.cells[ix*this.ny+iy]){ + const c=this.shapes[id]; + if(c.kind==='rect'){if(rectOverlap(c,x,y,0))return false} + else if((x-c.x)**2+(y-c.y)**2<(c.r-EPS)**2)return false; + } + return true; + } + findFree(x,y,r){ + if(this.free(x,y,r))return {x,y};let px=clamp(x,r+EPS,WIDTH-r-EPS),py=clamp(y,r+EPS,HEIGHT-r-EPS); + for(let pass=0;pass<16;pass++){ + for(const c of this.candidates(px,py,px,py,r)){ + if(c.kind==='rect'){ + const q=local(c,px,py),left=-c.w/2-r-EPS,right=c.w/2+r+EPS,top=-c.h/2-r-EPS,bottom=c.h/2+r+EPS; + if(q.x>left&&q.xtop&&q.ya.d-b.d),p=world(c,choices[0].x,choices[0].y);px=clamp(p.x,r+EPS,WIDTH-r-EPS);py=clamp(p.y,r+EPS,HEIGHT-r-EPS)} + }else{const dx=px-c.x,dy=py-c.y,d=Math.hypot(dx,dy),limit=c.r+r+EPS;if(d{const d=(qx-x)**2+(qy-y)**2;if(d=0||disc<0)continue; + const t=(-b-Math.sqrt(disc))/len;if(t>=-EPS&&t0&&distanceモデル仕様と根拠 | ECOSPHERE
      ◉ ECOSPHERE観測画面へ
      MODEL · v41

      実単位ベースの食物網モデル

      +

      v41 は v38 の描画・空間探索・水域編集・Web Worker を維持し、生態学コアを day / m / kg / kJ へ置換した版です。生産者は field、動物プランクトンは cohort、大型消費者は individual agent として扱います。

      +

      実装範囲

      水深・鉛直層は扱いません。漁獲、農薬、富栄養化、生息地消失などの人為的攪乱も実装していません。自動移入と自動種分化も停止しています。

      +

      単位

      量実装
      時間day、標準 dt=1/24 day
      距離m、世界 4800 × 3200 m
      動物体重kg wet mass
      動物エネルギーkJ
      producer / zooplanktonkg biomass m-2
      +

      生産者・cohort

      植物プランクトン最大増殖率
      μmax(T)=0.81 exp(0.0631T) day^-1

      10 / 20 / 30°C の golden test は約 1.52 / 2.86 / 5.38 day-1。水域では光・窒素・リン制限を掛け、zooplankton は個体を大量生成せず格子上の cohort biomass として更新します。DO は phytoplankton growth の式へ直接掛けません。

      +

      食物網・Holling応答

      単一の diet は廃止し、consumer–resource interaction edge を摂食関係のソースにします。植物資源の標準同化効率は 0.45、動物資源は 0.85 です。

      Fij = aij Pij Nj^q / (1 + Σ aik Pik hik Nk^q)

      q=1 を Holling II、q=2 を Holling III とし、handling time は実際に複数資源の分母へ入ります。個体捕食は p=1-exp(-rate×dt) に変換します。

      +

      Rall et al. (2012) の mass / temperature scaling を attack / handling に接続しています。all-data slopes は attack が consumer mass +0.47、resource mass +0.15、activation energy +0.44 eV、handling が consumer mass -0.48、resource mass +0.34、activation energy -0.27 eV です。捕食サイズ選好は profile 別 predator:prey mass ratio を中心とする対数正規関数です。

      +

      代謝

      B(M,T)=B0 M^(3/4) × temperature response

      ectotherm は Arrhenius 型温度補正を使い、endotherm とは normalization を分離します。旧版の肉食個体だけを一律 0.55 倍する代謝補正は使いません。

      +

      移動

      maximum / routine / foraging / escape speed、daily movement budget、home range、dispersal を分離しました。最大速度は Hirt et al. (2017) Supplementary Table 4 の式 v=aM^b(1-exp(-hM^i)) を用います。

      modeabhi
      flying142.80.242.4-0.72
      running25.50.2622.0-0.60
      swimming11.20.3619.5-0.56
      +

      出生・死亡

      親を死亡させる divide() は廃止しました。成熟、繁殖季節、繁殖間隔、energy reserve を満たすと offspring が出生し、親は生存します。死亡要因は捕食、飢餓、寿命、背景死亡、温度ストレスとして処理します。v41 では研究用の詳細集計出力は持ちません。

      +

      初期水域

      初期 procedural water の各半径へ √2 を一度掛け、旧版より概ね2倍の面積にします。水域同士が重なるため厳密な2倍にはしません。wet-cell 面積を測って二分探索する処理はありません。ユーザーが編集した水域は受け取った形状をそのまま適用し、水域追加ツールの既定半径も v38 と同じ 170 m です。

      +

      環境 forcing

      ClimateProvider は seasonal fallback と観測 series を扱える内部機構を持ちますが、v41 では実験用 Worker API を公開しません。通常UIで切り替えられる外部入力は freshwater / marine profile のみです。

      出力

      研究用 batch API と詳細 flux 出力は削除しました。Worker が送るのは描画・個体観察・グラフ表示に必要な内部状態だけです。

      +

      参照と較正

      1. Bissinger et al. (2008) / Eppley: phytoplankton temperature-growth upper envelope。
      2. Gillooly et al.: mass/temperature metabolic scaling。
      3. Rall et al. (2012): attack rate / handling time の mass・temperature scaling。
      4. Brose et al.: predator–prey body-size relationship。
      5. Hirt et al. (2017): locomotion mode 別 maximum speed model。

      文献から一意に決まらない edge baseline、B0、life-history、zooplankton cohort 係数等は simulator calibration です。区分は PARAMETER-PROVENANCE.json に記録しています。

      観測画面に戻る

      diff --git a/scene.js b/scene.js new file mode 100644 index 0000000..2758c92 --- /dev/null +++ b/scene.js @@ -0,0 +1,47 @@ +import {waterPath,shoreRadius,withinWater} from './water.js'; +import {W,H} from './engine.js'; +import {bodyPath} from './body-shapes.js'; +const BIN=128,NY=Math.ceil(H/BIN),EMPTY=[]; +let indexedTrees=null,treeBins=null; +let indexedWater=null,coastline=null; +function outerCoast(waters,editing=false){if(!editing&&indexedWater===waters)return coastline;if(!editing)indexedWater=waters;const lines=[];for(const [i,w] of waters.entries()){let px=0,py=0;for(let k=0;k<=72;k++){const angle=k*Math.PI/36,r=shoreRadius(w,angle),x=w.x+Math.cos(angle)*r,y=w.y+Math.sin(angle)*r;if(k&& !waters.some((other,j)=>j!==i&&withinWater(other,(px+x)/2,(py+y)/2)))lines.push([px,py,x,y]);px=x;py=y}}if(!editing)coastline=lines;return lines} +function nearbyTrees(trees,x,y){ + if(indexedTrees!==trees){indexedTrees=trees;treeBins=new Map();for(const t of trees){const r=t.r*(.32+.68*Math.sqrt(Math.max(0,t.leaves/t.maxLeaves)))+16; + for(let i=Math.max(0,Math.floor((t.x-r)/BIN));i<=Math.floor((t.x+r)/BIN);i++)for(let j=Math.max(0,Math.floor((t.y-r)/BIN));j<=Math.floor((t.y+r)/BIN);j++){ + const key=i*NY+j,b=treeBins.get(key);if(b)b.push(t);else treeBins.set(key,[t]); + } + }}return treeBins.get(Math.floor(x/BIN)*NY+Math.floor(y/BIN))||EMPTY; +} + +export function drawScene(ctx,{state,view,zoom=1,panX=0,panY=0,terrain,tool='observe',drag=null,water=[],treeSprites,selected=null,hover=null,waterMode='add',layer='plants'}){ +const shapeTool=()=>tool==='water'||tool==='rocks'; +const d=view.d,scale=view.base*zoom,t={s:scale,x:(view.width-W*scale)/2+panX,y:(view.height-H*scale)/2+panY};ctx.setTransform(d,0,0,d,0,0);ctx.fillStyle='#172723';ctx.fillRect(0,0,view.width,view.height);ctx.translate(t.x,t.y);ctx.scale(t.s,t.s);ctx.imageSmoothingEnabled=true;ctx.drawImage(terrain,0,0,W,H); + const waters=tool==='water'&&drag?.water!==undefined?water:state.water;ctx.save();ctx.beginPath();ctx.rect(0,0,W,H);ctx.clip();ctx.beginPath();for(const w of waters)waterPath(ctx,w,true);ctx.fillStyle='#315b608c';ctx.fill();ctx.beginPath();for(const [x,y,xx,yy] of outerCoast(waters,tool==='water'&&drag?.water!==undefined)){ctx.moveTo(x,y);ctx.lineTo(xx,yy)}ctx.strokeStyle='#a7baa063';ctx.lineWidth=8;ctx.stroke();const rocks=tool==='rocks'&&drag?.water!==undefined?water:state.rocks; + for(const rock of rocks){ctx.fillStyle='#6c7472';ctx.strokeStyle='#a1aaa0';ctx.lineWidth=1/t.s;ctx.beginPath();ctx.arc(rock.x,rock.y,rock.r,0,Math.PI*2);ctx.fill();ctx.stroke();ctx.fillStyle='#ffffff0b';ctx.beginPath();ctx.arc(rock.x-rock.r*.16,rock.y-rock.r*.18,rock.r*.64,0,Math.PI*2);ctx.fill()} + if(shapeTool()){ctx.lineWidth=1/t.s;ctx.setLineDash([5/t.s,5/t.s]);for(const w of tool==='rocks'?rocks:waters){ctx.strokeStyle='#bfded3aa';if(tool==='water')waterPath(ctx,w);else{ctx.beginPath();ctx.arc(w.x,w.y,w.r,0,Math.PI*2)}ctx.stroke();if(waterMode==='edit'){ctx.fillStyle='#d5e697';ctx.fillRect(w.x-3/t.s,w.y-3/t.s,6/t.s,6/t.s)}}ctx.setLineDash([])} + + for(const log of state.fallen||[]){ctx.save();ctx.translate(log.x,log.y);ctx.rotate(log.angle||0);const w=log.w,h=log.h;ctx.fillStyle='#17201880';ctx.fillRect(-w/2+3,-h/2+4,w,h);const bark=ctx.createLinearGradient(0,-h/2,0,h/2);bark.addColorStop(0,'#a48655');bark.addColorStop(.22,'#705331');bark.addColorStop(.65,'#64462a');bark.addColorStop(1,'#33291e');ctx.fillStyle=bark;ctx.strokeStyle='#291f18';ctx.lineWidth=Math.min(h/2,Math.max(.7,1/t.s));ctx.fillRect(-w/2,-h/2,w,h);const edge=ctx.lineWidth;ctx.strokeRect(-w/2+edge/2,-h/2+edge/2,w-edge,h-edge);for(let j=0;j<7;j++){const y=-h*.36+j*h*.12,shift=Math.sin(log.x*.13+log.y*.17+j*3.1)*h*.035;ctx.strokeStyle=j%2?'#b4945b80':'#2d261e95';ctx.lineWidth=Math.max(.45,.7/t.s);ctx.beginPath();ctx.moveTo(-w*.46,y);ctx.bezierCurveTo(-w*.2,y+shift,w*.17,y-shift,w*.45,y+shift*.5);ctx.stroke()}for(const end of [-1,1]){const x=end*(w/2-h*.17);ctx.fillStyle=end<0?'#a98c5e':'#92734c';ctx.beginPath();ctx.ellipse(x,0,h*.12,h*.42,0,0,Math.PI*2);ctx.fill();ctx.strokeStyle='#52402b';for(let j=0;j<3;j++){ctx.beginPath();ctx.ellipse(x,0,h*(.04+j*.023),h*(.13+j*.1),0,0,Math.PI*2);ctx.stroke()}}ctx.restore()} + for(const tree of state.trees){const radius=tree.r*(.32+.68*Math.sqrt(Math.max(0,tree.leaves/tree.maxLeaves)));ctx.globalAlpha=.5+.5*tree.leaves/tree.maxLeaves;ctx.drawImage(treeSprites[tree.variant],tree.x-radius,tree.y-radius,radius*2,radius*2);ctx.globalAlpha=1;ctx.fillStyle='#57442b';ctx.strokeStyle='#ad9768';ctx.lineWidth=.8/t.s;ctx.beginPath();ctx.arc(tree.x,tree.y,tree.trunk,0,Math.PI*2);ctx.fill();ctx.stroke()} + + ctx.fillStyle='#958670aa';for(const c of state.carcasses){const radius=2.3*Math.sqrt(Math.max(0,c.remainingBiomass));if(radius*t.s<2){const dot=Math.max(radius*1.5,1.5/t.s);ctx.fillRect(c.x-dot/2,c.y-dot/2,dot,dot)}else{ctx.beginPath();ctx.arc(c.x,c.y,radius,0,6.29);ctx.fill()}} + const species=new Map(state.stats.species.map(s=>[s.id,s])),left=-t.x/t.s,right=(view.width-t.x)/t.s,top=-t.y/t.s,bottom=(view.height-t.y)/t.s,noCull=left<=0&&top<=0&&right>=W&&bottom>=H; + const packed=state.animalData,count=packed?state.animalIds.length:state.animals.length; + for(let i=0;iright||ay+extentbottom)continue} + const sp=species.get(sid),col=layer==='trophic'?(role<.5?'#a7d46d':role<1.5?'#e9c976':role<2.5?'#d88979':'#aa8bc7'):layer==='habitat'?(affinity<1/3?'#d5bc88':affinity<2/3?'#86c0a1':'#78b8da'):sp?.color||'#ddd',morph=sp?.morph||0,angle=Math.atan2(vy,vx),r=Math.max(ar,1.9/t.s); + if(r*t.s<2){let covered=false;for(const tree of nearbyTrees(state.trees,ax,ay)){const radius=tree.r*(.32+.68*Math.sqrt(Math.max(0,tree.leaves/tree.maxLeaves)));if((ax-tree.x)**2+(ay-tree.y)**21.7||ar>7){ctx.strokeStyle=col;ctx.lineWidth=1/t.s;ctx.beginPath();ctx.moveTo(-ar,0);ctx.lineTo(-ar*1.5,0);ctx.stroke()}ctx.restore(); + for(const tree of nearbyTrees(state.trees,ax,ay)){ + const radius=tree.r*(.32+.68*Math.sqrt(Math.max(0,tree.leaves/tree.maxLeaves))); + if((ax-tree.x)**2+(ay-tree.y)**2>(radius+r*1.13)**2)continue; + ctx.save();ctx.translate(ax,ay);ctx.rotate(angle);bodyPath(ctx,r,morph);ctx.clip();ctx.rotate(-angle);ctx.translate(-ax,-ay); + ctx.globalAlpha=.35+.4*tree.leaves/tree.maxLeaves;ctx.drawImage(treeSprites[tree.variant],tree.x-radius,tree.y-radius,radius*2,radius*2);ctx.restore(); + } + if(id===selected){ctx.strokeStyle='#f4f3cc';ctx.lineWidth=1.3/t.s;ctx.beginPath();ctx.arc(ax,ay,ar+6/t.s,0,6.29);ctx.stroke()} + } + if(tool==='introduce'&&hover){ctx.strokeStyle='#d5e697';ctx.lineWidth=1/t.s;ctx.beginPath();ctx.arc(hover.x,hover.y,45,0,6.29);ctx.stroke()}ctx.restore();return t; +} diff --git a/style.css b/style.css new file mode 100644 index 0000000..5384c78 --- /dev/null +++ b/style.css @@ -0,0 +1,49 @@ +:root{color-scheme:dark;--bg:#101b1e;--panel:#152327;--line:#2b3a3c;--text:#e8eeea;--muted:#98aaa6;--accent:#d5e697}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,"Noto Sans JP",system-ui,sans-serif;font-size:14px}button,select,input{font:inherit}button,select{color:inherit;background:transparent;border:1px solid var(--line);border-radius:5px;cursor:pointer}button{min-height:36px;padding:7px 12px;transition:background .15s}button:hover{background:#32413d}button:focus-visible,select:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:3px}button.active{background:#d5e697;color:#1b2b23;border-color:#d5e697}header{height:76px;padding:0 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{display:flex;align-items:center;gap:12px;color:var(--text);text-decoration:none;font-size:20px;letter-spacing:3px;font-weight:650}.brandmark{color:var(--accent);font-size:32px}.brand small{letter-spacing:1px;font-size:12px;font-weight:400;color:var(--muted);margin-left:12px}.header-right{display:flex;align-items:center;gap:20px}.live{font-size:12px;color:#b8cebc}.live:before{content:'';display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-right:9px}.icon-button{border-radius:50%;width:30px;min-height:30px;padding:0}main{display:grid;grid-template-columns:minmax(0,1fr) 326px;max-width:1900px;margin:auto}.workspace{padding:26px 28px 24px;min-width:0}.workspace-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px;gap:16px}.eyebrow{color:var(--accent);font-size:11px;letter-spacing:2px}h1{font-size:21px;font-weight:450;letter-spacing:.05em;margin:9px 0 0}h2{font-size:14px;font-weight:550;margin:0}h3{font-size:16px}.date{display:flex;flex-direction:column;gap:7px;text-align:right;white-space:nowrap}.date strong{font-weight:500}.date span{color:var(--muted);font-size:13px}.toolbar{display:flex;justify-content:space-between;gap:12px;align-items:center}.tools{display:flex;gap:6px}.tools button{font-size:16px}.tools button span{font-size:14px;margin-left:5px}.layer-label{display:flex;gap:10px;align-items:center;color:var(--muted);font-size:12px}select{padding:7px 24px 7px 10px;background:#18272a;font-size:13px}.tool-options{min-height:48px;display:flex;align-items:center;flex-wrap:wrap;gap:8px;color:var(--muted);font-size:12px}.tool-options button{min-height:28px;padding:3px 8px;font-size:12px}.tool-options input{width:60px;background:#18272a;color:var(--text);border:1px solid var(--line);padding:5px;border-radius:4px}.field{position:relative;height:clamp(370px,52vh,660px);background:#1e3029;border:1px solid #415346;border-radius:7px;overflow:hidden;touch-action:none}#world{display:block;width:100%;height:100%;cursor:crosshair}.map-controls{position:absolute;right:14px;top:16px;display:flex;flex-direction:column;gap:5px}.map-controls button{background:#122422b8;width:33px;min-height:32px;padding:0;font-size:21px;border-color:#5e756b}.legend{position:absolute;bottom:16px;left:18px;display:flex;gap:15px;font-size:11px;pointer-events:none;background:#14251cc9;border-radius:4px;padding:8px 10px}.legend i{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:6px}.scale{position:absolute;right:18px;bottom:18px;font-size:11px;color:#c2d0be}.transport{display:flex;align-items:center;gap:13px;padding:14px 0 21px}.pause{color:var(--accent);font-size:17px;min-width:39px}.speed-buttons{display:flex;gap:5px}.speed-buttons button{border:0;color:var(--muted);font-size:13px;min-height:30px;padding:6px 11px}.speed-buttons button.active{color:#142217}.speed-buttons .fast{border-left:1px solid var(--line);margin-left:3px;border-radius:0;padding-left:15px}.actual{margin-left:auto;font-size:12px;color:var(--muted)}.actual strong{color:var(--accent);margin-left:7px;font-weight:500}.timeline{border-top:1px solid var(--line);padding-top:20px}.section-heading{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:18px}.section-heading>span{font-size:11px;color:var(--muted)}#chart{width:100%;height:132px;display:block}.chart-axis{display:flex;justify-content:space-between;color:var(--muted);font-size:11px;padding-left:26px}aside{border-left:1px solid var(--line);padding:0 22px;display:flex;flex-direction:column;background:var(--panel);min-width:0}.side-tabs{display:flex;gap:18px;border-bottom:1px solid var(--line);margin-bottom:27px;padding-top:21px}.side-tabs button{flex:1;padding:15px 0;border:0;border-radius:0;color:var(--muted);white-space:nowrap}.side-tabs button.active{background:none;color:var(--accent);border-bottom:2px solid var(--accent)}.metrics{display:grid;grid-template-columns:1fr 1fr;gap:22px 12px;padding:4px 0 23px;border-bottom:1px solid var(--line)}.metrics>div>span{color:var(--muted);font-size:12px;display:block;margin-bottom:8px}.metrics strong{font-size:29px;font-weight:400;font-variant-numeric:tabular-nums}.metrics small{font-size:11px;color:var(--muted);margin-left:6px}.species-title{margin-top:25px}.species-row{margin-bottom:18px}.species-row .row{display:flex;align-items:center;justify-content:space-between;font-size:13px;margin-bottom:8px}.species-name{display:flex;align-items:center;gap:9px}.species-name i{width:8px;height:8px;border-radius:50%}.species-row b{font-weight:450;font-variant-numeric:tabular-nums}.bar{height:3px;background:#293b3a;border-radius:3px;margin-left:17px}.bar span{display:block;height:100%;border-radius:3px}#events{max-height:185px;overflow:auto}.event{display:grid;grid-template-columns:44px 1fr;gap:10px;font-size:12px;padding:0 0 13px;line-height:1.6}.event time{color:var(--muted);font-size:11px}.tab-panel{padding-bottom:20px}.empty{text-align:center;padding:60px 6px}.empty>span{font-size:36px;color:var(--accent)}.empty h2{margin-top:22px}.empty p{font-size:14px;line-height:1.9;color:var(--muted)}.gene-row{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #ffffff08;font-size:12px}.gene-row span{color:var(--muted)}.detail-title{display:flex;justify-content:space-between;align-items:center;margin-bottom:18px}.detail-title strong{font-size:18px}.meter{margin:16px 0}.meter label{display:flex;justify-content:space-between;color:var(--muted);font-size:12px;margin-bottom:7px}.meter progress{width:100%;height:7px;accent-color:var(--accent)}.lineage-node{margin:14px 0;padding:10px 12px;border-left:2px solid var(--accent);background:#ffffff03;font-size:13px}.lineage-node small{display:block;margin-top:7px;color:var(--muted);line-height:1.7}.note{color:var(--muted);font-size:12px;line-height:1.9}#statOverlay{position:absolute;inset:0;background:#12211ee8;align-items:center;justify-content:center;flex-direction:column;gap:20px;text-align:center;padding:30px}#statOverlay:not([hidden]){display:flex}#statOverlay span{color:var(--accent);letter-spacing:4px}#statOverlay strong{font-size:20px;font-weight:400}#statOverlay small{color:var(--muted)}#toast{position:absolute;bottom:65px;left:50%;transform:translateX(-50%);background:#13211eed;border:1px solid var(--accent);padding:10px 16px;border-radius:5px;opacity:0;pointer-events:none;white-space:nowrap}dialog{max-width:620px;width:calc(100% - 32px);max-height:85vh;overflow:auto;background:#18282b;border:1px solid #536550;border-radius:12px;color:var(--text);padding:34px;line-height:1.9}dialog::backdrop{background:#08110fc9;backdrop-filter:blur(5px)}dialog h2{font-size:22px;margin:12px 0}dialog p{font-size:14px;color:#becbc6}.close{float:right;border:0;font-size:22px} [hidden]{display:none!important}@media(min-width:1550px){main{grid-template-columns:minmax(0,1fr) 360px}.workspace{padding:32px 40px}aside{padding:0 28px}}@media(max-width:1000px){main{grid-template-columns:minmax(0,1fr) 280px}.workspace{padding:22px 18px}aside{padding:0 17px}h1{font-size:17px}.brand small{display:none}.layer-label{font-size:0}.tools button{padding:6px 9px}.tools button span{font-size:12px}.fast{font-size:11px!important}.actual{font-size:11px}}@media(max-width:760px){header{height:62px;padding:0 17px}.brand{font-size:17px}.live{display:none}main{display:block}.workspace{padding:21px 14px}.workspace-head{margin-bottom:20px}h1{font-size:15px;line-height:1.7}.eyebrow{font-size:9px}.date{font-size:12px}.field{height:440px}.toolbar{gap:5px}.tools{gap:4px}.tools button{padding:6px}.layer-label select{max-width:91px;font-size:12px;padding-right:4px}.transport{gap:5px}.speed-buttons{gap:0}.speed-buttons button{padding:6px 9px}.actual{margin-left:auto}.legend{gap:9px;font-size:10px;left:9px;bottom:12px}.scale{right:9px;bottom:18px}aside{border-left:0;border-top:1px solid var(--line);padding:0 22px}.metrics{grid-template-columns:repeat(4,1fr)}.metrics strong{font-size:23px}.metrics small{display:block;margin:3px 0 0}#events{max-height:160px}.side-tabs{padding-top:2px}#chart{height:120px}}@media(prefers-reduced-motion:reduce){*{transition:none!important}} + +#genomeFields{margin:22px 0;display:grid;grid-template-columns:1fr 1fr;gap:12px 20px}#genomeFields label{font-size:13px;color:var(--muted);display:flex;flex-direction:column;gap:4px}#genomeDialog input{max-width:100%;background:#101e20;border:1px solid #415450;border-radius:4px;color:var(--text);padding:7px;font:inherit}#applyGenome{background:var(--accent);color:#15251c;width:100%} + +/* Additional terrain tool remains reachable on narrow displays. */ +@media(max-width:1100px){.toolbar{flex-wrap:wrap}.tools{flex-wrap:wrap}.layer-label{margin-left:auto}} + +#speciesList{max-height:350px;overflow:auto;padding-right:6px}dialog a{color:var(--accent)}.legend{flex-wrap:wrap;max-width:75%}@media(max-width:760px){.legend{max-width:78%;gap:6px;font-size:10px}#speciesList{max-height:300px}} + + +.chart-wrap{overflow:hidden;max-width:100%}#chart{display:block;width:100%;min-width:0;height:520px}.chart-axis{padding-left:112px;padding-right:54px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:760px){#chart{height:520px}.chart-axis{padding-left:0;padding-right:0}} + +.chart-wrap{max-height:620px;overflow-y:auto;overflow-x:hidden} + +/* Narrow portrait screens: controls remain tappable without horizontal page scrolling. */ +@media(max-width:600px){ + body{overflow-x:hidden} + header{padding:0 12px}.brand{font-size:15px;letter-spacing:1.5px;gap:6px}.brandmark{font-size:25px} + .workspace{padding:14px 10px}.workspace-head{align-items:flex-start;gap:8px}.workspace-head h1{font-size:16px;margin-top:5px}.date{font-size:11px}.date span{font-size:11px} + .toolbar{align-items:stretch}.tools{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));width:100%;gap:6px}.tools button{min-width:0;min-height:44px;font-size:15px;text-align:center}.tools button span{font-size:12px} + .layer-label{font-size:12px;margin:3px 0 0 auto}.layer-label select{max-width:none;min-height:42px} + .tool-options{padding:5px 0;min-height:42px}.tool-options button,.tool-options select{min-height:42px}.tool-options input{min-height:42px} + .field{height:min(58svh,510px);min-height:330px}.map-controls{right:8px;top:8px}.map-controls button{width:44px;min-height:44px}.legend{bottom:8px;left:8px;max-width:72%;font-size:9px;padding:6px}.scale{right:8px;bottom:11px;font-size:10px} + .transport{display:grid;grid-template-columns:44px minmax(0,1fr);gap:7px;padding:10px 0 14px}.pause{min-height:44px}.speed-buttons{display:flex;overflow-x:auto;gap:4px;min-width:0;scrollbar-width:thin}.speed-buttons button{flex:none;min-height:44px;padding:8px 10px}.speed-buttons .fast{padding-left:10px;margin-left:0}.actual{grid-column:2;text-align:right;margin:0} + .timeline{padding-top:15px}.section-heading{gap:6px}.section-heading>span{font-size:10px}.chart-wrap{max-height:430px}aside{padding:0 12px}.side-tabs button{min-height:44px}.metrics{grid-template-columns:repeat(2,minmax(0,1fr));gap:16px} + #toast{width:max-content;max-width:calc(100% - 24px);white-space:normal;text-align:center}dialog{padding:22px 18px}#genomeFields{grid-template-columns:1fr} +} + +.loading{position:absolute;inset:0;z-index:5;display:grid;place-items:center;background:#1e3029;color:#d5e697;font-size:18px;letter-spacing:.12em}.loading[hidden]{display:none!important} + +/* Compact, grouped editing tools; the field remains the visual focus. */ +.tools{display:inline-flex;flex-wrap:nowrap;gap:0;width:auto;border:1px solid var(--line);border-radius:7px;overflow:hidden;flex:none} +.tools button,.tools button span{font-size:13px} +.tools button{display:inline-flex;align-items:center;justify-content:center;gap:4px;min-height:32px;padding:4px 9px;border:0;border-radius:0;white-space:nowrap} +.tools button+button{border-left:1px solid var(--line)} +.tools button span{margin:0} +@media(max-width:600px){.toolbar{align-items:center}.tools{display:inline-flex;grid-template-columns:none;width:auto;gap:0}.tools button{min-width:0;min-height:36px;padding:5px 6px;font-size:12px}.tools button span{font-size:11px}.layer-label{margin:0 0 0 auto}.layer-label select{min-height:36px}} + +/* v37: field controls leave the simulation as the main surface. */ +header{height:58px;padding:0 20px}.brand{font-size:17px;letter-spacing:2px}.brandmark{font-size:24px}.header-right{gap:14px}.header-right .date{flex-direction:row;gap:12px;align-items:center;font-size:12px}.header-right .date span{font-size:12px} +main{max-width:none}.workspace{padding:10px 12px 18px}.field{height:clamp(520px,76svh,1100px);border-radius:8px}.field-toolbar{position:absolute;z-index:3;left:12px;top:12px;right:65px;display:flex;align-items:flex-start;gap:8px;flex-wrap:wrap;pointer-events:none}.field-toolbar>*{pointer-events:auto}.field-toolbar .toolbar{display:flex;gap:8px;flex:0 1 auto;padding:5px;background:#10221ddd;border:1px solid #607a64;border-radius:8px;backdrop-filter:blur(8px)}.field-toolbar .tool-options{min-height:0;max-width:min(100%,530px);padding:4px 7px;background:#10221ddd;border:1px solid #607a64;border-radius:8px;backdrop-filter:blur(8px)}.field-toolbar .tool-options:empty{display:none}.field-toolbar .tool-options span{line-height:1.3}.field-toolbar .tools{border-color:#597160}.field-toolbar .layer-label{margin:0}.field-toolbar .layer-label select{min-height:32px}.field>.transport{position:absolute;z-index:3;left:12px;bottom:12px;max-width:calc(100% - 24px);padding:5px 8px;margin:0;border:1px solid #607a64;border-radius:8px;background:#10221de6;backdrop-filter:blur(8px)}.field>.transport .actual{margin-left:4px}.field .legend{bottom:64px}.field .scale{bottom:24px;right:16px}.map-controls{top:12px;right:12px}.timeline{margin-top:14px}.header-right .live{white-space:nowrap} +#genomeFields{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px 16px}#genomeDialog{max-width:540px}#genomeDialog input,#genomeDialog select{width:100%;max-width:160px;min-width:0;min-height:34px}.field-toolbar button,.field>.transport button{touch-action:manipulation} +@media(max-width:760px){header{height:54px;padding:0 11px}.header-right .date{gap:4px;flex-direction:column;align-items:flex-end;font-size:11px}.header-right .date span{font-size:10px}.workspace{padding:7px}.field{height:min(80svh,850px);min-height:480px}.field-toolbar{top:7px;left:7px;right:56px;gap:5px}.field-toolbar .toolbar{flex-wrap:wrap;padding:3px}.field-toolbar .tools button{min-height:34px}.field-toolbar .layer-label{margin:0}.field-toolbar .layer-label select{min-height:34px}.field-toolbar .tool-options{padding:3px 5px;max-width:100%}.field>.transport{left:6px;bottom:6px;max-width:calc(100% - 12px);display:flex;gap:4px;flex-wrap:wrap;padding:3px 5px}.field>.transport .speed-buttons{max-width:calc(100vw - 95px)}.field>.transport .speed-buttons button{min-height:34px;padding:5px 7px}.field>.transport .pause{min-height:34px}.field>.transport .actual{flex-basis:100%;text-align:right}.field .legend{bottom:83px;left:8px}.field .scale{bottom:91px;right:8px}.map-controls{top:7px;right:7px}.map-controls button{width:42px;min-height:42px}#genomeFields{grid-template-columns:repeat(2,minmax(0,1fr))}#genomeDialog input,#genomeDialog select{max-width:130px}} +@media(max-width:430px){.field-toolbar .tools button{padding:4px 5px;font-size:12px}.field-toolbar .tools button span{font-size:11px}.field-toolbar .tool-options span{display:none}.field-toolbar .tool-options button{min-height:33px}.field-toolbar .tool-options input{min-height:33px}.header-right .live{display:none}} + +/* v38: a single control keeps the field clear when observing. */ +.ui-toggle{position:absolute;z-index:4;top:10px;right:10px;min-height:28px;padding:3px 7px;border:1px solid #607a64;border-radius:6px;background:#10221de6;backdrop-filter:blur(8px);color:var(--text);font-size:11px;white-space:nowrap}.field.ui-hidden .field-toolbar,.field.ui-hidden .transport,.field.ui-hidden .legend,.field.ui-hidden .scale{display:none} +.field-toolbar .tools button{min-height:28px;padding:3px 6px;font-size:12px}.field-toolbar .tools button span{font-size:11px}.field-toolbar .layer-label select{min-height:28px;padding:4px 18px 4px 7px;font-size:11px}.field-toolbar .tool-options button,.field-toolbar .tool-options input{min-height:28px;font-size:11px;padding:3px 6px}.field>.transport .pause{min-height:28px;min-width:29px;padding:2px 5px;font-size:14px}.field>.transport .speed-buttons button{min-height:28px;padding:4px 6px;font-size:11px}.field>.transport{gap:5px}.field>.transport .actual{font-size:10px} +@media(max-width:760px){.ui-toggle{top:7px;right:7px;min-height:30px}.field-toolbar .tools button{min-height:30px;padding:3px 5px}.field-toolbar .layer-label select{min-height:30px}.field-toolbar .tool-options button,.field-toolbar .tool-options input{min-height:30px}.field>.transport .pause,.field>.transport .speed-buttons button{min-height:30px;padding:3px 6px}} diff --git a/terrain.js b/terrain.js new file mode 100644 index 0000000..410562f --- /dev/null +++ b/terrain.js @@ -0,0 +1,3 @@ +import {GW,GH} from './engine.js'; +const stops=[[0,105,89,59],[.32,103,113,61],[.68,72,108,48],[1,41,80,40]]; +export function paintTerrain(tc,state,layer){const im=tc.createImageData(GW,GH);for(let i=0;istops[k+1][0])k++;const lo=stops[k],hi=stops[k+1],f=(p-lo[0])/(hi[0]-lo[0]);r=lo[1]+(hi[1]-lo[1])*f;g=lo[2]+(hi[2]-lo[2])*f;b=lo[3]+(hi[3]-lo[3])*f}const j=i*4;im.data[j]=r;im.data[j+1]=g;im.data[j+2]=b;im.data[j+3]=255}tc.putImageData(im,0,0)} diff --git a/test/ecology.test.mjs b/test/ecology.test.mjs new file mode 100644 index 0000000..010a4d8 --- /dev/null +++ b/test/ecology.test.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {performance} from 'node:perf_hooks'; +import {World,DT,H,GW,GH,CELL,INITIAL_SPECIES} from '../engine.js'; +import {inWater} from '../water.js'; +import {phytoplanktonMuMax,producerGrowthPerDay,updateZooplanktonCohort} from '../ecology/resources.js'; +import {interactionEdge,rallFeedingParameters,functionalResponseRatePerDay} from '../ecology/feeding.js'; +import {rateToProbability} from '../ecology/units.js'; +import {reproductionConfig,energyLimitedGrowth,initialStructuralMassKg} from '../ecology/demography.js'; +import {maximumSpeedMPerDay,routineTravelSpeedMPerDay,homeRangeRadiusM,sampleDispersalDistanceM,dailyMovementBudgetM,locomotionCalibration} from '../ecology/movement.js'; +import {FRESHWATER,TERRESTRIAL} from '../ecology/profiles.js'; +import {ClimateProvider} from '../ecology/climate.js'; + +const approx=(actual,expected,tol,msg)=>assert.ok(Math.abs(actual-expected)<=tol,`${msg}: ${actual} vs ${expected}`); +const sumField=(field)=>field.reduce((sum,v)=>sum+v,0)*CELL*CELL; + +// Published phytoplankton upper envelope and aquatic limitation structure. +approx(phytoplanktonMuMax(10),1.52,.015,'muMax 10C'); +approx(phytoplanktonMuMax(20),2.86,.02,'muMax 20C'); +approx(phytoplanktonMuMax(30),5.38,.03,'muMax 30C'); +approx(producerGrowthPerDay(FRESHWATER,20,{light:.5,nutrientN:.8,nutrientP:.4,dissolvedOxygen:0}),phytoplanktonMuMax(20)*.4*.5,1e-12,'aquatic producer limitation'); +approx(producerGrowthPerDay(TERRESTRIAL,20,{moisture:1,light:.4}),.03*.4,1e-12,'terrestrial light limitation is applied once'); + +// Static food-web edges: no runtime interaction API, but the ecological graph remains explicit. +assert.equal(interactionEdge('primary-consumer','producer').assimilationEfficiency,.45); +assert.equal(interactionEdge('secondary-consumer','primary-consumer').assimilationEfficiency,.85); +assert.equal(interactionEdge('primary-consumer','secondary-consumer'),null); + +// Continuous rates must compose independently of timestep. +const daily=rateToProbability(.7,1),hourly=rateToProbability(.7,DT),combined=1-Math.pow(1-hourly,24); +approx(combined,daily,1e-12,'hourly rate composition'); + +// Handling time is truly connected to Holling denominator; Rall allometric/temperature scaling is live. +{ + const edge=interactionEdge('secondary-consumer','primary-consumer'); + const p=rallFeedingParameters(edge,5,.1,20,FRESHWATER),pHot=rallFeedingParameters(edge,5,.1,30,FRESHWATER),term={...p,resourceDensity:2,q:1}; + const rate=functionalResponseRatePerDay(p,2,[term],1),slower=functionalResponseRatePerDay({...p,handlingTimeDays:p.handlingTimeDays*3},2,[{...term,handlingTimeDays:p.handlingTimeDays*3}],1); + const competitor={...p,resourceDensity:4,q:1},withCompetition=functionalResponseRatePerDay(p,2,[term,competitor],1); + assert.ok(rate>slower,'handling time is not connected to Holling denominator'); + assert.ok(withCompetitionp.attackRate,'Rall temperature scaling does not increase attack rate'); + assert.ok(pHot.handlingTimeDayss?.trophicRole==='secondary-consumer').g,w.species.find(s=>s?.trophicRole==='secondary-consumer').id,1000,1000,200),p1=w.make(w.species.find(s=>s?.trophicRole==='primary-consumer').g,w.species.find(s=>s?.trophicRole==='primary-consumer').id,1010,1000,100),p2=w.make(w.species.find(s=>s?.trophicRole==='primary-consumer').g,w.species.find(s=>s?.trophicRole==='primary-consumer').id,1020,1000,100);for(const a of [predator,p1,p2]){a.habitat='陸棲';a.dead=false}predator.structuralMassKg=3.5;p1.structuralMassKg=.1;p2.structuralMassKg=.1;w.animals=[predator,p1];w.hash.rebuild(w.animals);const one=w.predationRateForTarget(predator,p1,20).rate;w.animals.push(p2);w.hash.rebuild(w.animals);const two=w.predationRateForTarget(predator,p1,20).rate;assert.ok(one>0&&two>0&&twoMath.max(a,Math.min(b,v)); +function baseWater(seed){let value=(seed^0x397b4c63)>>>0;const random=()=>{let t=value=(value+0x6D2B79F5)>>>0;t=Math.imul(t^t>>>15,t|1);t^=t+Math.imul(t^t>>>7,t|61);return((t^t>>>14)>>>0)/4294967296};const edge=Math.floor(random()*4),radius=320+random()*110,along=.14+random()*.72;const start=edge===0?{x:radius*.42,y:H*along}:edge===1?{x:W-radius*.42,y:H*along}:edge===2?{x:W*along,y:radius*.42}:{x:W*along,y:H-radius*.42};const destination=edge===0?{x:W-300,y:H*(.14+random()*.72)}:edge===1?{x:300,y:H*(.14+random()*.72)}:edge===2?{x:W*(.14+random()*.72),y:H-300}:{x:W*(.14+random()*.72),y:300};const water=[{...start,r:radius,seed:random()*Math.PI*2}];for(let i=1;i<7;i++){const last=water.at(-1),r=290+random()*170,heading=Math.atan2(destination.y-last.y,destination.x-last.x)+(random()-.5)*1.25,step=(last.r+r)*(.43+random()*.12);water.push({x:clamp(last.x+Math.cos(heading)*step,r*.42,W-r*.42),y:clamp(last.y+Math.sin(heading)*step,r*.42,H-r*.42),r,seed:random()*Math.PI*2})}for(const index of [2,4]){const parent=water[index],before=water[index-1],r=270+random()*140,heading=Math.atan2(parent.y-before.y,parent.x-before.x)+(random()<.5?-1:1)*(1.05+random()*.65),step=(parent.r+r)*(.42+random()*.13);water.push({x:clamp(parent.x+Math.cos(heading)*step,r*.42,W-r*.42),y:clamp(parent.y+Math.sin(heading)*step,r*.42,H-r*.42),r,seed:random()*Math.PI*2})}return water} +function wetCells(water){let n=0;for(let j=0;ja+b,0),ratio=now/base;ratios.push(ratio);assert.ok(ratio>1.7&&ratio<2.3,`rough water area ratio ${ratio}`)} +{const w=new World(77),edited=w.water.map((v,i)=>({...v,r:i===0?v.r*.98:v.r}));assert.equal(w.setWater(edited),true);assert.deepEqual(w.water,edited,'user water geometry was altered')} + +// Hirt maximum-speed coefficients and separate routine/home-range/dispersal paths. +assert.ok(maximumSpeedMPerDay(3,'running')>routineTravelSpeedMPerDay(3,900,'running')); +assert.ok(maximumSpeedMPerDay(3,'flying')>maximumSpeedMPerDay(3,'running')); +assert.deepEqual((({a,b,h,i})=>({a,b,h,i}))(locomotionCalibration('flying')),{a:142.8,b:.24,h:2.4,i:-.72}); +assert.deepEqual((({a,b,h,i})=>({a,b,h,i}))(locomotionCalibration('running')),{a:25.5,b:.26,h:22,i:-.60}); +assert.deepEqual((({a,b,h,i})=>({a,b,h,i}))(locomotionCalibration('swimming')),{a:11.2,b:.36,h:19.5,i:-.56}); +assert.ok(homeRangeRadiusM(10,'swimming')>homeRangeRadiusM(10,'running')); +assert.ok(sampleDispersalDistanceM(()=>.5,3,'running')>0); + +// Cohort resource path is active. +{const z=updateZooplanktonCohort(.1,.02,1);assert.ok(z.producerConsumedKgPerM2>0);assert.notEqual(z.nextZooplanktonKgPerM2,.02)} + +// Reproduction must not kill the parent. +{const w=new World(481516),parent=w.animals.find(a=>w.roleOf(a)==='primary-consumer');w.time=100;parent.age=parent.g.maturityAge+1;parent.energy=w.maxEnergy(parent);parent.lastBirth=w.time-reproductionConfig('primary-consumer').reproductionIntervalDays-1;const before=w.animals.length;w.reproduce(parent);assert.equal(parent.dead,false);assert.ok(w.animals.length>before)} + +// Juvenile structural growth is energy-limited rather than age-only. +{const birth=initialStructuralMassKg(10,.2,0,100);approx(birth,2,1e-12,'birth structural mass');const rich=energyLimitedGrowth({structuralMassKg:birth,adultMassKg:10,energyKJ:5000,ageDays:10,maturityAgeDays:100,dtDays:1}),poor=energyLimitedGrowth({structuralMassKg:birth,adultMassKg:10,energyKJ:500,ageDays:10,maturityAgeDays:100,dtDays:1});assert.ok(rich.gainKg>0,'surplus energy did not produce structural growth');assert.ok(rich.energyKJ<5000,'growth did not consume energy');assert.equal(poor.gainKg,0,'growth occurred below reserve floor')} + +// Profile switching remains an internal UI operation. Climate series support is internal, not a World/Worker experiment API. +{const w=new World(7);assert.throws(()=>w.setAquaticProfile('bogus'));const c=new ClimateProvider();c.setSeries('surfaceTemperatureC',[12,13,14]);c.setSeries('soilMoisture',[.2,.4,.6]);c.setSeries('nutrientN',[.3,.4,.5]);assert.equal(c.temperatureC(1,{aquatic:true}),13);assert.equal(c.soilMoisture(1,.9),.4);assert.equal(c.value('nutrientN',1),.4)} + +// Ordinary stepping: finite state, no spontaneous immigration/speciation, daily movement budget enforced. +const bench=new World(481516),startSpeciesId=bench.nextSpeciesId,start=performance.now(); +for(let i=0;i<30*24;i++)bench.step(); +const elapsedMs=performance.now()-start,stats=bench.stats(),zooMass=sumField(bench.zooplankton); +assert.equal(startSpeciesId,INITIAL_SPECIES);assert.equal(bench.nextSpeciesId,INITIAL_SPECIES); +assert.ok(Number.isFinite(stats.plant)&&stats.plant>=0);assert.ok(Number.isFinite(zooMass)&&zooMass>=0); +assert.ok(bench.plants.every(Number.isFinite));assert.ok(bench.zooplankton.every(Number.isFinite)); +assert.ok(bench.animals.every(a=>[a.x,a.y,a.energy,a.age,a.structuralMassKg,a.movedToday].every(Number.isFinite))); +for(const a of bench.animals){if(a.dead)continue;const mode=bench.species[a.sid]?.locomotionMode||'running',budget=dailyMovementBudgetM(bench.biomass(a),a.g.moveSpeed,mode);assert.ok(a.movedToday<=budget+1e-6,`daily movement budget exceeded: ${a.movedToday} > ${budget}`)} +assert.equal(bench.resourceFields[0].representation,'field');assert.equal(bench.resourceFields[1].representation,'cohort'); +assert.deepEqual(Object.keys(stats).sort(),['generation','plant','population','species','temp','time'].sort(),'detailed diagnostic output leaked into UI stats'); + +// Requested omissions and removed experiment API/output stay absent. +const codeFiles=['../engine.js','../worker.js','../ecology/profiles.js','../ecology/resources.js','../ecology/metabolism.js','../ecology/feeding.js','../ecology/demography.js','../ecology/movement.js','../ecology/schema.js']; +const code=(await Promise.all(codeFiles.map(f=>readFile(new URL(f,import.meta.url),'utf8')))).join('\n'); +assert.ok(!/DisturbanceEvent|schedule-disturbance|pesticide|harvest|habitat-loss|nutrient-pulse/.test(code),'human disturbance code remains'); +assert.ok(!/waterDepth|depthLayer|mixed-layer|deepLayer/.test(code),'water-depth code remains'); +assert.ok(!/doubleWaterArea|target=Math\.min\(GW\*GH,base\*2\)|for\(let iter=0;iter<14/.test(code),'exact water-area optimizer remains'); +assert.ok(!/nearest=1400;for\(const b of this\.animals\)/.test(code),'omniscient global prey scan remains'); +assert.ok(!/set-interactions|set-climate|run-batch|batch-result|batch-progress|validateBatchRequest|setInteractions|setClimate|introduce\(/.test(code),'removed experiment API remains'); +assert.ok(!/consumptionFlux|assimilationFlux|respirationLoss|resourceTurnover|occupiedArea|meanBodyMass|biomassByGroup|populationByGroup|trophicFlux/.test(code),'removed detailed output remains'); + +// Deterministic repeatability after removing diagnostics. +const a=new World(20260929),b=new World(20260929);for(let i=0;i<7*24;i++){a.step();b.step()}assert.deepEqual(a.stats(),b.stats());assert.equal(sumField(a.zooplankton),sumField(b.zooplankton)); + +console.log(JSON.stringify({ok:true,dtDays:DT,waterAreaRatios:ratios,benchmark30DaysMs:+elapsedMs.toFixed(1),benchmark30DaysPopulation:stats.population,benchmark30DaysSpecies:stats.species.length,producerBiomassKg:+stats.plant.toFixed(2),zooplanktonBiomassKg:+zooMass.toFixed(2)},null,2)); diff --git a/time.js b/time.js new file mode 100644 index 0000000..23e1977 --- /dev/null +++ b/time.js @@ -0,0 +1,5 @@ +// Simulation time is measured in days. Display day + hour/minute without implying wall-clock time. +export function formatElapsed(days){ + const totalMinutes=Math.max(0,Math.floor(days*24*60+1e-8)),d=Math.floor(totalMinutes/1440),h=Math.floor(totalMinutes%1440/60),m=totalMinutes%60; + return `${String(d).padStart(4,'0')}日 ${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`; +} diff --git a/tree-sprites.js b/tree-sprites.js new file mode 100644 index 0000000..8ad4dc6 --- /dev/null +++ b/tree-sprites.js @@ -0,0 +1,12 @@ +// Top-down canopies are functional world markers. Sprites are rasterized once per palette variant. +export function createTreeSprites(createCanvas){ + return Array.from({length:6},(_,variant)=>{const c=createCanvas(192,192),g=c.getContext('2d');c.width=c.height=192; + const leaf=['#254a29','#345b2d','#416634','#507339','#5d7c40']; + g.fillStyle='#071e1740';g.beginPath();g.ellipse(99,103,82,77,0,0,Math.PI*2);g.fill(); + g.strokeStyle='#65553a';g.lineWidth=4; + for(let j=0;j<9;j++){const angle=j*Math.PI*2/9+variant*.38,reach=43+(j%3)*9;g.beginPath();g.moveTo(96,96);g.lineTo(96+Math.cos(angle)*reach,96+Math.sin(angle)*reach);g.stroke()} + for(let ring=0;ring<2;ring++){const count=ring?8:13;for(let j=0;j=limit*limit)continue; + const v=Math.hypot(dx,dy)-shoreRadius(w,Math.atan2(dy,dx));if(v(w.r*1.18)**2)return false;const r=shoreRadius(w,Math.atan2(dy,dx));return d2{ + if(d.type==='ack'){awaitingAck=false;return} + if(d.type==='speed'){speed=d.speed;statMode=d.stat;debt=0;last=performance.now();forceSnapshot=true} + if(d.type==='water'){const ok=world.setWater(d.water);if(ok){world.log('水域を編集');waterDirty=true;moistureDirty=true}postMessage({type:'notice',text:ok?'水域を更新しました':'生物の退避先がないため、水域の変更を取り消しました'});forceSnapshot=true;send()} + if(d.type==='rocks'){const ok=world.setRocks(d.rocks);if(ok){world.log('岩を編集');rocksDirty=true}postMessage({type:'notice',text:ok?'岩を更新しました':'安全な退避先がないため、岩の変更を取り消しました'});forceSnapshot=true;send()} + if(d.type==='load-profile'){try{world.setAquaticProfile(d.profile);moistureDirty=true;forceSnapshot=true;send()}catch(error){postMessage({type:'notice',text:error.message})}} + if(d.type==='select'){selected=d.id;forceSnapshot=true;send()} +}; + + +function send(){ + if(awaitingAck)return;const now=performance.now(),summary=forceSnapshot||now-lastSummary>850,field=!statMode&&(forceSnapshot||Math.floor(world.tick/20)!==lastFieldEpoch),a=world.animals.find(a=>a.id===selected&&!a.dead);const packet={type:'state',actual,limited,statMode};const alive=statMode?[]:world.animals.filter(a=>!a.dead),data=new Float32Array(alive.length*7),ids=new Uint32Array(alive.length),sids=new Uint32Array(alive.length); + for(let i=0;i({x,y,r,trunk,leaves,maxLeaves,variant}));packet.fallen=world.fallen;packet.plants=world.plants;if(moistureDirty){packet.moisture=world.moisture;moistureDirty=false}lastFieldEpoch=Math.floor(world.tick/20)}if(statMode)packet.carcasses=[];awaitingAck=true;postMessage(packet,[data.buffer,ids.buffer,sids.buffer]);forceSnapshot=false;lastSend=now; +} +function loop(){const now=performance.now(),elapsed=Math.min(.2,(now-last)/1000);last=now;if(speed){debt=Math.min(debt+elapsed*speed,DT*300);const start=performance.now();while(debt>=DT&&performance.now()-start<22){world.step();debt-=DT;steps++;if(world.animals.length>18000){speed=0;limited=true;world.log('計算保護:18,000個体を超えたため一時停止');break}}}if(now-lastMeasure>1000){actual=steps*DT/((now-lastMeasure)/1000);steps=0;lastMeasure=now}if(now-lastSend>(statMode?900:120))send();setTimeout(loop,4)}send();loop(); diff --git a/world-size.js b/world-size.js new file mode 100644 index 0000000..0a301e8 --- /dev/null +++ b/world-size.js @@ -0,0 +1,2 @@ +// Shared continuous-world bounds and the lower-resolution plant field. +export const W=4800,H=3200,GW=192,GH=128,CELL=W/GW;