From 01204dfa2f3e5bbbc443d2ea5634db4e3ee1f33b Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Tue, 29 Sep 2026 19:10:03 +0900 Subject: [PATCH] second --- ECOLOGY-MODEL.md | 108 ++++++++++++++++ IMPLEMENTATION-NOTES.md | 65 ++++++++++ PARAMETER-PROVENANCE.json | 192 +++++++++++++++++++++++++++++ README-JA.txt | 42 +++++++ VALIDATION.md | 88 +++++++++++++ app.js | 40 ++++++ body-shapes.js | 18 +++ diet-label.js | 2 + ecology/climate.js | 13 ++ ecology/demography.js | 40 ++++++ ecology/feeding.js | 73 +++++++++++ ecology/metabolism.js | 23 ++++ ecology/movement.js | 37 ++++++ ecology/profiles.js | 11 ++ ecology/resources.js | 32 +++++ ecology/schema.js | 3 + ecology/units.js | 7 ++ engine.js | 253 ++++++++++++++++++++++++++++++++++++++ gestures.js | 5 + index.html | 12 ++ lineage.js | 30 +++++ model.js | 12 ++ obstacles.js | 110 +++++++++++++++++ package.json | 1 + research.html | 13 ++ scene.js | 47 +++++++ style.css | 49 ++++++++ terrain.js | 3 + test/ecology.test.mjs | 107 ++++++++++++++++ time.js | 5 + tree-sprites.js | 12 ++ water.js | 22 ++++ worker.js | 21 ++++ world-size.js | 2 + 34 files changed, 1498 insertions(+) create mode 100644 ECOLOGY-MODEL.md create mode 100644 IMPLEMENTATION-NOTES.md create mode 100644 PARAMETER-PROVENANCE.json create mode 100644 README-JA.txt create mode 100644 VALIDATION.md create mode 100644 app.js create mode 100644 body-shapes.js create mode 100644 diet-label.js create mode 100644 ecology/climate.js create mode 100644 ecology/demography.js create mode 100644 ecology/feeding.js create mode 100644 ecology/metabolism.js create mode 100644 ecology/movement.js create mode 100644 ecology/profiles.js create mode 100644 ecology/resources.js create mode 100644 ecology/schema.js create mode 100644 ecology/units.js create mode 100644 engine.js create mode 100644 gestures.js create mode 100644 index.html create mode 100644 lineage.js create mode 100644 model.js create mode 100644 obstacles.js create mode 100644 package.json create mode 100644 research.html create mode 100644 scene.js create mode 100644 style.css create mode 100644 terrain.js create mode 100644 test/ecology.test.mjs create mode 100644 time.js create mode 100644 tree-sprites.js create mode 100644 water.js create mode 100644 worker.js create mode 100644 world-size.js 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;