second
This commit is contained in:
parent
5f7fcb96ed
commit
01204dfa2f
34 changed files with 1498 additions and 0 deletions
108
ECOLOGY-MODEL.md
Normal file
108
ECOLOGY-MODEL.md
Normal file
|
|
@ -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` を参照。
|
||||
65
IMPLEMENTATION-NOTES.md
Normal file
65
IMPLEMENTATION-NOTES.md
Normal file
|
|
@ -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 係数などは普遍的な実測定数とは主張しない。
|
||||
192
PARAMETER-PROVENANCE.json
Normal file
192
PARAMETER-PROVENANCE.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
42
README-JA.txt
Normal file
42
README-JA.txt
Normal file
|
|
@ -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 は持たない
|
||||
88
VALIDATION.md
Normal file
88
VALIDATION.md
Normal file
|
|
@ -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` で区別する。
|
||||
40
app.js
Normal file
40
app.js
Normal file
|
|
@ -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=`<button data-water="add">追加</button><button data-water="edit">移動・変形</button><button data-water="delete">削除</button><span>${tool==='rocks'?'岩は移動を遮ります':'滑らかな岸線の水域を編集'}</span>`;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.d<v.w.r+20).sort((a,b)=>Math.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<state.animalIds.length;i++){const d=Math.hypot(state.animalData[i*7]-p.x,state.animalData[i*7+1]-p.y);if(d<dist){best=state.animalIds[i];dist=d}}selected=best;worker.postMessage({type:'select',id:selected});if(best)setTab('individual')}else if(shapeTool()&&drag.water!==undefined)sendShapes();drag=null};canvas.onpointercancel=e=>{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()+'<small>個体</small>';$('speciesCount').innerHTML=s.species.length+'<small>種</small>';$('plantMass').innerHTML=(s.plant/1000).toFixed(1)+'<small>t</small>';$('generation').innerHTML=s.generation+'<small>世代</small>';$('actual').textContent=state.actual.toFixed(2)+' 日/秒';$('statOverlay').hidden=!state.statMode;$('mapLegend').innerHTML=layer==='trophic'?'<span>● 一次消費者 <i style="background:#a7d46d"></i></span><span>● 二次消費者 <i style="background:#e9c976"></i></span><span>● 高次消費者 <i style="background:#d88979"></i></span>':layer==='habitat'?'<span>● 陸棲 <i style="background:#d5bc88"></i></span><span>● 両棲 <i style="background:#86c0a1"></i></span><span>● 水棲 <i style="background:#78b8da"></i></span>':'<span><i style="background:#6c7472"></i>岩</span><span><i style="background:#775b37;border-radius:1px;width:12px;height:5px"></i>倒木</span><span><i style="background:#958670"></i>死骸</span>';$('events').innerHTML=state.events.slice(0,10).map(x=>`<div class="event"><time>${elapsed(x.time)}</time><span>${esc(x.text)}</span></div>`).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=`<div class="detail-title"><strong style="color:${sp.color}">${esc(sp.name)}</strong><span>#${a.id}</span></div><p class="note">${roleLabel(sp.trophicRole)} · ${phys} · ${habitat(a.g.waterAffinity)}</p><p class="note">${a.action} · 第${a.generation}世代 · 年齢 ${elapsed(a.age)}</p><div class="meter"><label>エネルギー <span>${a.energy.toFixed(0)} / ${a.maxEnergy.toFixed(0)} kJ</span></label><progress value="${a.energy}" max="${a.maxEnergy}"></progress></div><div class="meter"><label>スタミナ <span>${a.stamina.toFixed(1)} / ${a.maxStamina.toFixed(1)}</span></label><progress value="${a.stamina}" max="${a.maxStamina}"></progress></div><p class="note">現在体重 ${a.mass.toFixed(2)} kg · 隠匿 ${(a.concealment*100).toFixed(0)}%</p><div class="section-heading"><h2>個体形質</h2><span>生態単位系</span></div>${Object.keys(traits).map(k=>`<div class="gene-row"><span>${names[k]}</span><b>${k==='waterAffinity'?habitat(a.g[k]):a.g[k].toFixed(2)}</b></div>`).join('')}`}else if(selected)$('individual').innerHTML='<div class="empty"><span>⌖</span><h2>観察個体は死亡しました</h2><p>別の個体をクリックして観察を続けられます。</p></div>'}}
|
||||
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})=>`<li>${esc(s.name)}:${s.count}個体。${byId.has(s.parent)?'親系統 '+esc(byId.get(s.parent).name):s.originType==='外来'?'外来種':'初期種'}</li>`).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<frameGap)return;lastPaint=now;const t=drawScene(ctx,{state,view,zoom,panX,panY,terrain,tool,drag,water,treeSprites,selected,hover,waterMode,layer});$('scale').textContent=Math.round(50/t.s)+' m';$('loading').hidden=true}
|
||||
worker.onmessage=({data})=>{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);
|
||||
18
body-shapes.js
Normal file
18
body-shapes.js
Normal file
|
|
@ -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();
|
||||
}
|
||||
2
diet-label.js
Normal file
2
diet-label.js
Normal file
|
|
@ -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'}
|
||||
13
ecology/climate.js
Normal file
13
ecology/climate.js
Normal file
|
|
@ -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)}
|
||||
}
|
||||
40
ecology/demography.js
Normal file
40
ecology/demography.js
Normal file
|
|
@ -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()<rateToProbability(hazardPerDay,dtDays)}
|
||||
export function reproductionConfig(role){return ROLE_DEMOGRAPHY[role]||ROLE_DEMOGRAPHY['primary-consumer']}
|
||||
export function inBreedingSeason(day,role){
|
||||
const doy=((day%365)+365)%365;
|
||||
if(role==='tertiary-consumer')return doy>=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};
|
||||
}
|
||||
73
ecology/feeding.js
Normal file
73
ecology/feeding.js
Normal file
|
|
@ -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)}
|
||||
23
ecology/metabolism.js
Normal file
23
ecology/metabolism.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
37
ecology/movement.js
Normal file
37
ecology/movement.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
11
ecology/profiles.js
Normal file
11
ecology/profiles.js
Normal file
|
|
@ -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}
|
||||
32
ecology/resources.js
Normal file
32
ecology/resources.js
Normal file
|
|
@ -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)};
|
||||
}
|
||||
3
ecology/schema.js
Normal file
3
ecology/schema.js
Normal file
|
|
@ -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}
|
||||
7
ecology/units.js
Normal file
7
ecology/units.js
Normal file
|
|
@ -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;
|
||||
253
engine.js
Normal file
253
engine.js
Normal file
|
|
@ -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<rr)out.push(a);return out}
|
||||
}
|
||||
const traitKeys=Object.keys(traits);
|
||||
const outputPosition=(out,x,y)=>{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<this.plants.length;i++){this.plants[i]=this.capacity(i)*(.55+this.rand()*.25);if(this.wet[i])this.zooplankton[i]=.006+this.rand()*.012}
|
||||
const hubs=Array.from({length:8},()=>({x:300+this.rand()*(W-600),y:250+this.rand()*(H-500)}));
|
||||
for(let k=0;k<INITIAL_SPECIES;k++){const g=this.randomGenome(),role=k<8?'primary-consumer':k<13?'secondary-consumer':'tertiary-consumer';if(k%4===3)g.waterAffinity=.82;else if(k%4===2)g.waterAffinity=.5;else g.waterAffinity=.18; if(role==='tertiary-consumer')g.bodySize=Math.max(4,g.bodySize);const physiology=g.waterAffinity>.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<total%2?1:0);this.seedInitialPopulation(k,count,hub.x+(this.rand()-.5)*140,hub.y+(this.rand()-.5)*140,80)}}
|
||||
this.sample();
|
||||
}
|
||||
rand(){let t=this.seed=(this.seed+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}
|
||||
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.largeField.free(a.x,a.y,this.radius(a)))continue;const p=this.largeField.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}}}
|
||||
}
|
||||
rebuildTreeObstacles(){
|
||||
this.treeField=new RockField(this.trees,[],true);
|
||||
this.rockField=new RockField([...this.rocks,...this.trunks()],this.fallen);
|
||||
this.rebuildLargeField();
|
||||
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<GH;j++)for(let i=0;i<GW;i++){const n=j*GW+i;this.rockMask[n]=this.rockField.freePoint((i+.5)*CELL,(j+.5)*CELL)?0:1;if(this.rockMask[n])this.plants[n]=0}}
|
||||
canopyRadius(t){return t.r*(.32+.68*Math.sqrt(Math.max(0,t.leaves/t.maxLeaves)))}
|
||||
canopyCollisionRadius(t){return this.canopyRadius(t)*.88}
|
||||
updateShade(){
|
||||
this.shade.fill(0);
|
||||
for(const t of this.trees){const r=this.canopyRadius(t),r2=r*r,health=t.leaves/t.maxLeaves;if(!health)continue;
|
||||
for(let j=Math.max(0,Math.floor((t.y-r)/CELL));j<=Math.min(GH-1,Math.floor((t.y+r)/CELL));j++)for(let i=Math.max(0,Math.floor((t.x-r)/CELL));i<=Math.min(GW-1,Math.floor((t.x+r)/CELL));i++){
|
||||
const d2=((i+.5)*CELL-t.x)**2+((j+.5)*CELL-t.y)**2;if(d2>=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<r*r)cover=Math.max(cover,t.cover*(t.leaves/t.maxLeaves)*(.35+.65*(1-d2/(r*r))))}return cover}
|
||||
updateTrees(){
|
||||
let changed=false;
|
||||
for(const t of this.trees){
|
||||
t.age+=1;
|
||||
if(t.r<t.matureR){const health=t.maxLeaves?Math.max(0,t.leaves/t.maxLeaves):1;t.r=Math.min(t.matureR,t.matureR*(.12+t.age/220));t.trunk=t.matureTrunk*t.r/t.matureR;t.maxLeaves=t.r*t.r*.05;t.leaves=Math.min(t.maxLeaves,t.maxLeaves*health);changed=true}
|
||||
const moisture=this.moisture[this.index(t.x,t.y)];
|
||||
t.leaves=Math.min(t.maxLeaves,t.leaves+t.maxLeaves*.003*moisture*(1-t.leaves/t.maxLeaves));
|
||||
if(t.age>=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(x<r+10||x>W-r-10||y<r+10||y>H-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;j<GH;j++)for(let i=0;i<GW;i++){const n=j*GW+i,d=this.waterDistance((i+.5)*CELL,(j+.5)*CELL),wet=d<0,profile=wet?this.aquaticProfile:TERRESTRIAL;this.wet[n]=wet?1:0;this.moisture[n]=wet?1:.12+.88*Math.exp(-Math.max(0,d)/210);this.light[n]=Math.max(.08,profile.environment.light*(1-.55*this.shade[n]));this.nutrientN[n]=profile.environment.nutrientN;this.nutrientP[n]=profile.environment.nutrientP;this.dissolvedOxygen[n]=profile.environment.dissolvedOxygen;this.rockMask[n]=this.rockField.freePoint((i+.5)*CELL,(j+.5)*CELL)?0:1;if(this.rockMask[n]){this.plants[n]=0;this.zooplankton[n]=0}else if(!wet)this.zooplankton[n]=0}
|
||||
}
|
||||
index(x,y){return clamp(Math.floor(y/CELL),0,GH-1)*GW+clamp(Math.floor(x/CELL),0,GW-1)}
|
||||
capacity(i){const profile=this.wet[i]?this.aquaticProfile:TERRESTRIAL;return producerCapacity(profile,{moisture:this.moisture[i],shade:this.shade[i],blocked:!!this.rockMask[i]})}
|
||||
biomass(a){return a.structuralMassKg ?? initialStructuralMassKg(a.g.bodySize,a.g.offspringSize,a.age,a.g.maturityAge)}
|
||||
maxEnergy(a){return this.biomass(a)*1200}
|
||||
radius(a){return 2.3*Math.sqrt(this.biomass(a))}
|
||||
make(g,sid,x,y,age=0,energy=null,parents=[]){
|
||||
const a={id:this.nextId++,sid,g:this.internGenome(g),x:clamp(x,5,W-5),y:clamp(y,5,H-5),vx:0,vy:0,age,structuralMassKg:initialStructuralMassKg(g.bodySize,g.offspringSize,age,g.maturityAge),energy:0,stamina:0,action:'徘徊',heading:this.rand()*6.28,cooldown:5+this.rand()*20,target:null,dead:false,parents,generation:0,homeX:clamp(x,5,W-5),homeY:clamp(y,5,H-5),movedToday:0,movementDay:Math.floor(this.time),dispersalTarget:null};
|
||||
if(this.rockField.circles.length){const p=this.fieldFor(a).findFree(a.x,a.y,this.radius(a));if(!p)return null;a.x=p.x;a.y=p.y}const h=this.findHabitat(a,a.x,a.y);if(!h)return null;a.x=h.x;a.y=h.y;a.homeX=h.x;a.homeY=h.y;a.habitat=habitat(a.g.waterAffinity);a.ability=this.genomeRecords.get(a.g).ability;a.adultSpeedBoost=sizeSpeedBoost(a.g.bodySize);a.adultMassPower=Math.pow(a.g.bodySize,.75);a.energy=energy??this.maxEnergy(a)*.62;a.stamina=this.staminaCapacity(a);a.lastBirth=-Infinity;return a
|
||||
}
|
||||
setWater(water){
|
||||
if(!Array.isArray(water)||!water.length||water.length>80||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<c.r*c.r)return false}}return true}
|
||||
internGenome(g){let record=this.genomeRecords.get(g);if(!record){const genome=Object.freeze({...g}),ability=1+.05*Math.min(4,(g.moveSpeed/900)**2)+.12*(g.visionRange/110)**2*g.perceptionAbility+.1*g.staminaCapacity/30;record={genome,ability};this.genomeRecords.set(g,record);this.genomeRecords.set(genome,record)}return record.genome}
|
||||
geneticDistance(a,b){
|
||||
if(a===b)return 0;let row=this.distanceCache.get(a),value=row?.cache.get(b);if(value!==undefined)return value;value=this.distanceCache.get(b)?.cache.get(a);if(value!==undefined)return value;
|
||||
value=this.rawGeneticDistance(a,b);
|
||||
// Bounded per-genome cache: long-running lineages must not retain every pair.
|
||||
if(Object.isFrozen(a)&&Object.isFrozen(b)){if(!row||row.count>=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<this.radius(a)+5;if(t.maxLeaves)return distance<(this.biomass(a)>=CANOPY_BLOCK_MASS?this.canopyCollisionRadius(t):t.trunk)+this.radius(a)+2;if(t.id<0)return distance<this.radius(a)+5;return distance<this.radius(a)+this.radius(t)+3&&this.clearPath(a.x,a.y,t.x,t.y,a)}
|
||||
roleOf(a){return this.species[a.sid]?.trophicRole||'primary-consumer'}
|
||||
physiologyOf(a){return this.species[a.sid]?.physiology||'ectotherm'}
|
||||
profileAt(x,y){return inWater(this.water,x,y)?this.aquaticProfile:TERRESTRIAL}
|
||||
preyEdge(a,b){return this.edge(this.roleOf(a),this.roleOf(b))}
|
||||
canHunt(a,b){if(a===b||a.sid===b.sid)return false;const edge=this.preyEdge(a,b);if(!edge)return false;const pref=bodyMassPreference(this.biomass(a),this.biomass(b),this.profileAt(a.x,a.y),edge);return pref>.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.age<a.g.maturityAge||this.time-a.lastBirth<cfg.reproductionIntervalDays||!inBreedingSeason(this.time,role)||energyFraction(a.energy,this.maxEnergy(a))<reproductionThreshold(role))return;
|
||||
const children=[],share=offspringEnergyShare(role),perChild=Math.min(this.maxEnergy(a)*share,a.energy*share),mode=this.species[a.sid]?.locomotionMode||'running';
|
||||
for(let j=0;j<cfg.offspringPerEvent;j++){const angle=this.rand()*Math.PI*2,d=Math.min(homeRangeRadiusM(this.biomass(a),mode)*.12,sampleDispersalDistanceM(()=>this.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;i<this.plants.length;i++){
|
||||
const cap=this.capacity(i);if(!cap){this.plants[i]=0;this.zooplankton[i]=0;continue}const profile=this.wet[i]?this.aquaticProfile:TERRESTRIAL,temp=this.wet[i]?waterTemp:landTemp,before=this.plants[i],effectiveMoisture=this.wet[i]?1:this.climate.soilMoisture(this.time,this.moisture[i]),light=this.climate.value('light',this.time,this.light[i]),nutrientN=this.climate.value('nutrientN',this.time,this.nutrientN[i]),nutrientP=this.climate.value('nutrientP',this.time,this.nutrientP[i]),dissolvedOxygen=this.climate.value('dissolvedOxygen',this.time,this.dissolvedOxygen[i]),growth=producerGrowthPerDay(profile,temp,{moisture:effectiveMoisture,shade:this.shade[i],light,nutrientN,nutrientP,dissolvedOxygen});let next=updateProducerBiomass(before,cap,growth,0,1);
|
||||
if(this.wet[i]){const z=updateZooplanktonCohort(next,this.zooplankton[i],1);next=Math.max(0,next-z.producerConsumedKgPerM2);this.zooplankton[i]=z.nextZooplanktonKgPerM2}
|
||||
this.plants[i]=next;
|
||||
}
|
||||
const decayFactor=Math.exp(-decayRate(landTemp));for(const c of this.carcasses)c.remainingBiomass*=decayFactor;this.carcasses=this.carcasses.filter(c=>c.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.canopyRadius(t)+this.radius(a)+3){const amount=Math.min(t.leaves,producerIntakeKgPerDay(m,Math.min(1,t.leaves/Math.max(1,t.maxLeaves)),producerEdge,temp,profile)*DT);t.leaves-=amount;const treeGain=amount*18000*producerEdge.assimilationEfficiency;a.energy+=treeGain;if(t.leaves<=0){t.leaves=0;t.dead=true}}
|
||||
}
|
||||
if(t?.resourceRole==='zooplankton'){
|
||||
const edge=this.edge(role,'zooplankton');if(edge){const idx=this.index(a.x,a.y),density=this.zooplankton[idx],params=rallFeedingParameters(edge,m,Math.max(1e-5,density*.002),temp,profile),q=edge.functionalResponse===3?2:1,rate=functionalResponseRatePerDay(params,density,[{...params,resourceDensity:density,q}],q),amount=this.resourceFields[1].consume(idx,Math.min(.12*Math.pow(Math.max(m,.001),.78),rate)*DT,CELL*CELL),gain=amount*7000*edge.assimilationEfficiency;a.energy+=gain}return;
|
||||
}
|
||||
if(!t||t.plant||t.maxLeaves||t.dead)return;
|
||||
const targetRadius=t.id<0?2.3*Math.sqrt(Math.max(0,t.remainingBiomass)):this.radius(t);if(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()<probability){this.kill(t,'捕食');a.target=this.carcasses[this.carcasses.length-1]}else{t.heading=Math.atan2(t.y-a.y,t.x-a.x);t.action='逃走';t.target=a}
|
||||
}
|
||||
step(){
|
||||
this.time+=DT;this.tick++;this.hash.rebuild(this.animals);if(this.tick%6===1)this.cHash.rebuild(this.carcasses.filter(c=>c.remainingBiomass>0));if(this.tick%24===0)this.updateResources();const temp=this.temp(),length=this.animals.length;
|
||||
for(let i=0;i<length;i++){
|
||||
const a=this.animals[i];if(a.dead)continue;const g=a.g,m=this.biomass(a),species=this.species[a.sid],mode=species?.locomotionMode||'running',localTemp=this.climate.temperatureC(this.time,{aquatic:inWater(this.water,a.x,a.y)}),stress=Math.max(0,Math.abs(localTemp-g.preferredTemperature)-g.temperatureTolerance*.7)/Math.max(1,g.temperatureTolerance);a.age+=DT;if(a.movementDay!==Math.floor(this.time)){a.movementDay=Math.floor(this.time);a.movedToday=0}const staminaMax=this.staminaCapacity(a),recover=recoveryRate(m,g.staminaRecovery),fitness=thermalFitness(localTemp,g.preferredTemperature,g.temperatureTolerance);a.cooldown-=DT;if((this.tick+a.id)%4===0)this.decide(a);
|
||||
let dx=0,dy=0,speed=0;const t=a.target,field=this.fieldFor(a);if(a.action!=='休息'){
|
||||
if(t&&!t.dead){dx=t.x-a.x;dy=t.y-a.y;if(a.action==='逃走'){dx=-dx;dy=-dy}}
|
||||
else if(a.dispersalTarget){dx=a.dispersalTarget.x-a.x;dy=a.dispersalTarget.y-a.y;if(Math.hypot(dx,dy)<20){a.homeX=a.dispersalTarget.x;a.homeY=a.dispersalTarget.y;a.dispersalTarget=null}}
|
||||
else{const homeRange=homeRangeRadiusM(m,mode)*(this.profileAt(a.x,a.y).movement?.homeRangeMultiplier||1),homeDx=a.homeX-a.x,homeDy=a.homeY-a.y;if(Math.hypot(homeDx,homeDy)>homeRange){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(d<distance){distance=d;nearest=w}}if(nearest){x=nearest.x;y=nearest.y}}let added=0;for(let i=0;i<n;i++){const ang=this.rand()*6.28,r=Math.sqrt(this.rand())*spread;const a=this.make(s.g,sid,x+Math.cos(ang)*r,y+Math.sin(ang)*r,s.g.maturityAge*(.7+this.rand()*.8));if(a){a.lastBirth=this.time-this.rand()*reproductionConfig(s.trophicRole).reproductionIntervalDays;this.animals.push(a);added++}}return added}
|
||||
sample(){const counts=[];for(const a of this.animals)if(!a.dead)counts[a.sid]=(counts[a.sid]||0)+1;for(const s of this.species){if(!s||counts[s.id])continue;delete this.species[s.id];for(const point of this.history)delete point.counts[s.id];this.log(`${s.name}が絶滅`)}const plant=this.plants.reduce((sum,v)=>sum+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)}
|
||||
}
|
||||
}
|
||||
5
gestures.js
Normal file
5
gestures.js
Normal file
|
|
@ -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};
|
||||
}
|
||||
12
index.html
Normal file
12
index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html><html lang="ja"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#101d20"><title>ECOSPHERE | 食物連鎖シミュレータ</title><link rel="stylesheet" href="style.css?v=41"></head><body>
|
||||
<header><a class="brand" href="./"><span class="brandmark">◉</span> ECOSPHERE</a><div class="header-right"><div class="date"><strong id="date">経過 0000日 00:00</strong><span id="temperature">— °C</span></div><span class="live" id="runStatus">準備中</span><button id="helpBtn" class="icon-button" aria-label="モデルと操作説明">?</button></div></header>
|
||||
<main><section class="workspace">
|
||||
<div class="field" id="field"><div class="field-toolbar"><div class="toolbar"><div class="tools" role="group" aria-label="編集ツール"><button class="active" data-tool="observe" aria-label="観察">⌖ <span>観察</span></button><button data-tool="water" aria-label="水域編集">≈ <span>水域</span></button><button data-tool="rocks" aria-label="岩の編集">◒ <span>岩</span></button></div><label class="layer-label">表示 <select id="layer" aria-label="表示レイヤー"><option value="plants">生産者量</option><option value="moisture">土壌水分</option><option value="trophic">栄養段階</option><option value="habitat">水域適応</option></select></label><label class="layer-label">水域 <select id="aquaticProfile" aria-label="水域環境プロファイル"><option value="freshwater">淡水</option><option value="marine">海洋</option></select></label></div>
|
||||
<div class="tool-options" id="toolOptions"></div>
|
||||
</div><div id="loading" class="loading" role="status">準備中</div><canvas id="world" aria-label="陸上と水域を含む食物網シミュレーション。水域と岩を編集できます"></canvas><button id="toggleUI" class="ui-toggle" aria-label="操作UIを非表示" aria-pressed="false">UI 非表示</button><div class="legend" id="mapLegend"><span>岩 · 倒木 · 死骸</span></div><div class="scale">━━━ <span id="scale">100 m</span></div><div id="statOverlay" hidden><span>高速統計モード</span><strong>描画を休止し、計算に集中しています</strong><small>生態計算は 1/24 day 刻みで継続します。</small></div><div class="transport"><button id="pause" class="pause" aria-label="一時停止">Ⅱ</button><div class="speed-buttons"><button data-speed="1" class="active">×1</button><button data-speed="5">×5</button><button data-speed="20">×20</button><button data-speed="100">×100</button><button data-speed="1000" class="fast">高速統計</button></div><span class="actual">実効 <strong id="actual">— 日/秒</strong></span></div>
|
||||
<div id="toast" role="status"></div></div>
|
||||
<section class="timeline"><div class="section-heading"><h2>個体数と種</h2><span>線の太さ=個体数 · 色=表示レイヤー</span></div><div class="chart-wrap"><canvas id="chart" aria-label="種別個体数の時系列グラフ"></canvas></div><ol id="chartData" class="sr-only"></ol><div class="chart-axis"><span id="chartStart">0000日 00:00</span><span id="chartEnd">現在</span></div></section>
|
||||
</section><aside><div class="side-tabs" role="tablist"><button role="tab" aria-selected="true" data-tab="overview" class="active">生態系</button><button role="tab" aria-selected="false" data-tab="individual">個体</button></div>
|
||||
<div id="overview" class="tab-panel"><div class="section-heading"><h2>生態系のいま</h2><span class="tiny">LIVE</span></div><div class="metrics"><div><span>総個体数</span><strong id="population">—<small>個体</small></strong></div><div><span>生存種</span><strong id="speciesCount">16<small>種</small></strong></div><div><span>生産者量</span><strong id="plantMass">—</strong></div><div><span>最大世代</span><strong id="generation">0<small>世代</small></strong></div></div><div class="section-heading"><h2>観測ログ</h2><span>直近の変化</span></div><div id="events"></div></div>
|
||||
<div id="individual" class="tab-panel" hidden><div class="empty"><span>⌖</span><h2>ひとつの個体を観察する</h2><p>フィールド上の個体をクリックすると、行動・エネルギー・形質を確認できます。</p></div></div></aside></main>
|
||||
<dialog id="help"><button id="closeHelp" class="close" aria-label="閉じる">×</button><h2>操作と生態系</h2><h3>操作</h3><p>生物をクリックすると行動と形質を確認できます。水域・岩は追加、移動、拡大縮小、削除ができます。水域は淡水/海洋プロファイルを切り替えられます。</p><h3>生態系</h3><p>時間は day、距離は m、動物体重は kg、エネルギーは kJ です。生産者は格子上の biomass、動物は個体として扱います。食性の単一値ではなく、一次・二次・高次消費者と資源の相互作用エッジで摂食を決めます。出生後も親は生存し、加速された突然変異・自動種分化・自動移入は行いません。</p><h3>今回除外した機能</h3><p>水深レイヤーと人為的攪乱(漁獲、農薬、富栄養化、生息地消失、温暖化イベント等)は実装していません。初期水域は旧版と同じ地形系列を基準に、格子面積がおよそ2倍になるよう拡張しています。</p><h3>表示と計算</h3><p>標準の生態刻みは 1/24 day です。式、単位、出典、検証条件は<a href="research.html" target="_blank" rel="noopener">モデルの根拠</a>をご覧ください。</p></dialog><script type="module" src="app.js?v=41"></script></body></html>
|
||||
30
lineage.js
Normal file
30
lineage.js
Normal file
|
|
@ -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.origin<first?.45:.75;ctx.lineWidth=width;ctx.lineCap='round';ctx.setLineDash(s.origin<first?[3,4]:[]);ctx.beginPath();ctx.moveTo(cx,py);ctx.lineTo(cx,cy);ctx.stroke();ctx.setLineDash([]);ctx.globalAlpha=1}
|
||||
for(const {s} of ordered){const y=lanes.get(s.id);for(let i=1;i<hist.length;i++){
|
||||
const a=hist[i-1].counts[s.id]||0,b=hist[i].counts[s.id]||0;if(!a&&!b)continue;
|
||||
const count=Math.max(a,b),width=Math.max(1.5,Math.min(28,1.5+Math.sqrt(count)*1.08));ctx.strokeStyle=lineageColor(s,layer);ctx.globalAlpha=.92;ctx.lineWidth=width;ctx.lineCap='round';ctx.beginPath();ctx.moveTo(x(a?hist[i-1].time:Math.max(hist[i-1].time,s.origin)),y);ctx.lineTo(x(hist[i].time),y);ctx.stroke();
|
||||
}if(hist.length===1&&s.count){ctx.fillStyle=lineageColor(s,layer);ctx.beginPath();ctx.arc(x(first),y,Math.max(2,Math.min(9,Math.sqrt(s.count)*.42)),0,Math.PI*2);ctx.fill()}}
|
||||
ctx.globalAlpha=1;return {ordered,first,last,byId};
|
||||
}
|
||||
12
model.js
Normal file
12
model.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import {maximumSpeedMultiplier} from './ecology/movement.js';
|
||||
import {basalMetabolismKJPerDay} from './ecology/metabolism.js';
|
||||
|
||||
export const sizeSpeedBoost=maximumSpeedMultiplier;
|
||||
export const staminaLimit=(mass,gene)=>gene*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);
|
||||
110
obstacles.js
Normal file
110
obstacles.js
Normal file
|
|
@ -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<first&&t<=1){first=t;nx=a;ny=b}};
|
||||
if(vx){for(const sign of [-1,1]){const t=(sign*(hx+r+EPS)-p.x)/vx,y=p.y+vy*t;if(sign*vx<0&&Math.abs(y)<=hy)check(t,sign,0)}}
|
||||
if(vy){for(const sign of [-1,1]){const t=(sign*(hy+r+EPS)-p.y)/vy,x=p.x+vx*t;if(sign*vy<0&&Math.abs(x)<=hx)check(t,0,sign)}}
|
||||
const len=vx*vx+vy*vy;if(len)for(const sx of [-1,1])for(const sy of [-1,1]){
|
||||
const cx=sx*hx,cy=sy*hy,px=p.x-cx,py=p.y-cy,b=px*vx+py*vy,rr=r+EPS,disc=b*b-len*(px*px+py*py-rr*rr);
|
||||
if(b>=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<this.shapes.length;id++){
|
||||
const c=this.shapes[id],co=Math.cos(c.angle||0),si=Math.sin(c.angle||0),hx=c.kind==='circle'?c.r:Math.abs(co)*c.w/2+Math.abs(si)*c.h/2,hy=c.kind==='circle'?c.r:Math.abs(si)*c.w/2+Math.abs(co)*c.h/2,x0=c.x-hx,x1=c.x+hx,y0=c.y-hy,y1=c.y+hy;
|
||||
for(let x=Math.max(0,Math.floor(x0/SIZE));x<=Math.min(this.nx-1,Math.floor(x1/SIZE));x++)for(let y=Math.max(0,Math.floor(y0/SIZE));y<=Math.min(this.ny-1,Math.floor(y1/SIZE));y++)this.cells[x*this.ny+y].push(id);
|
||||
}
|
||||
}
|
||||
candidates(x0,y0,x1,y1,r){
|
||||
const out=[];this.stamp=(this.stamp+1)>>>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(x<r||x>WIDTH-r||y<r||y>HEIGHT-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.x<right&&q.y>top&&q.y<bottom){const choices=[{d:q.x-left,x:left,y:q.y},{d:right-q.x,x:right,y:q.y},{d:q.y-top,x:q.x,y:top},{d:bottom-q.y,x:q.x,y:bottom}].sort((a,b)=>a.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<limit){px=clamp(c.x+(d?dx/d:1)*limit,r+EPS,WIDTH-r-EPS);py=clamp(c.y+(d?dy/d:0)*limit,r+EPS,HEIGHT-r-EPS)}}
|
||||
}
|
||||
if(this.free(px,py,r))return {x:px,y:py};
|
||||
}
|
||||
let best=null,dist=Infinity;const consider=(qx,qy)=>{const d=(qx-x)**2+(qy-y)**2;if(d<dist&&this.free(qx,qy,r)){best={x:qx,y:qy};dist=d}};
|
||||
for(const c of this.shapes){if(c.kind==='rect'){for(const [qx,qy] of [[-c.w/2-r-.01,0],[c.w/2+r+.01,0],[0,-c.h/2-r-.01],[0,c.h/2+r+.01]]){const p=world(c,qx,qy);consider(p.x,p.y)}}else for(let j=0;j<24;j++){const a=j*Math.PI/12;consider(c.x+Math.cos(a)*(c.r+r+.01),c.y+Math.sin(a)*(c.r+r+.01))}}
|
||||
if(!best)for(let qx=r+1;qx<WIDTH-r;qx+=60)for(let qy=r+1;qy<HEIGHT-r;qy+=60)consider(qx,qy);return best;
|
||||
}
|
||||
move(x,y,dx,dy,r,out=null){
|
||||
if(!this.free(x,y,r)){const p=this.findFree(x,y,r);if(!p)return outputPosition(out,x,y);x=p.x;y=p.y}const startX=x,startY=y;
|
||||
for(let pass=0;pass<4;pass++){
|
||||
const targetX=clamp(x+dx,r+EPS,WIDTH-r-EPS),targetY=clamp(y+dy,r+EPS,HEIGHT-r-EPS);dx=targetX-x;dy=targetY-y;const len=dx*dx+dy*dy;if(len<1e-14)break;
|
||||
let hit=null,first=1,normalX=0,normalY=0;
|
||||
for(const c of this.candidates(x,y,targetX,targetY,r)){
|
||||
if(c.kind==='rect'){
|
||||
const contact=roundedRectHit(c,x,y,dx,dy,r);
|
||||
if(contact&&contact.t<first){first=contact.t;hit=c;normalX=contact.x;normalY=contact.y}
|
||||
}else{
|
||||
const px=x-c.x,py=y-c.y,b=px*dx+py*dy,rr=c.r+r+EPS,disc=b*b-len*(px*px+py*py-rr*rr);if(b>=0||disc<0)continue;
|
||||
const t=(-b-Math.sqrt(disc))/len;if(t>=-EPS&&t<first){first=Math.max(0,t);hit=c;normalX=normalY=0}
|
||||
}
|
||||
}
|
||||
if(!hit){x=targetX;y=targetY;break}
|
||||
x+=dx*first;y+=dy*first;
|
||||
if(hit.kind==='circle'){const nx=x-hit.x,ny=y-hit.y,n=Math.hypot(nx,ny)||1;normalX=nx/n;normalY=ny/n}
|
||||
dx*=1-first;dy*=1-first;const inward=Math.min(0,dx*normalX+dy*normalY);dx-=inward*normalX;dy-=inward*normalY;
|
||||
}
|
||||
return this.free(x,y,r)?outputPosition(out,x,y):outputPosition(out,startX,startY);
|
||||
}
|
||||
steer(x,y,dx,dy,r,side,targetTreeId=null,out=null){
|
||||
const n=Math.hypot(dx,dy)||1,ux=dx/n,uy=dy/n;
|
||||
for(const c of this.candidates(x,y,x+ux*32,y+uy*32,r)){
|
||||
if(c.steer===false||(targetTreeId!==null&&c.treeId===targetTreeId))continue;
|
||||
if(c.kind==='rect'){
|
||||
const q=local(c,x,y),lx=q.x-clamp(q.x,-c.w/2,c.w/2),ly=q.y-clamp(q.y,-c.h/2,c.h/2),distance=Math.hypot(lx,ly);
|
||||
if(distance>0&&distance<r+1){const co=Math.cos(c.angle||0),si=Math.sin(c.angle||0),nx=(co*lx-si*ly)/distance,ny=(si*lx+co*ly)/distance;if(ux*nx+uy*ny<0)return outputPosition(out,-ny*side+nx*.15,nx*side+ny*.15)}
|
||||
continue;
|
||||
}
|
||||
const px=x-c.x,py=y-c.y,d=Math.hypot(px,py)||1,limit=c.r+r+(c.treeId?12:6);
|
||||
if(d<limit&&ux*px+uy*py<0){const nx=px/d,ny=py/d;return outputPosition(out,-ny*side+nx*.15,nx*side+ny*.15)}
|
||||
}
|
||||
return outputPosition(out,dx,dy);
|
||||
}
|
||||
}
|
||||
1
package.json
Normal file
1
package.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"type":"module","private":true,"name":"ecosphere-food-chain-simulator","version":"41.0.0"}
|
||||
13
research.html
Normal file
13
research.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html><html lang="ja"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>モデル仕様と根拠 | ECOSPHERE</title><link rel="stylesheet" href="style.css?v=41"><style>.research{max-width:1050px;margin:auto;padding:40px 24px 80px;font-size:16px;line-height:1.9}.research h1{font-size:28px}.research h2{font-size:21px;margin:35px 0 16px}.research p{color:#bdccc3}.research a{color:#d5e697}.research .table-wrap{overflow:auto}.research table{border-collapse:collapse;width:100%;font-size:14px;min-width:690px}.research td,.research th{border-bottom:1px solid #3b4d43;text-align:left;padding:15px;vertical-align:top}.research th{color:#d5e697}.research code{color:#e2e8c6;font-size:14px;white-space:normal}.research li{margin:12px 0}.research .formula{padding:18px;background:#203029;border-left:3px solid #a4bc72}.research small{font-size:14px;color:#a0b3a8}</style></head><body><header><a class="brand" href="./">◉ ECOSPHERE</a><a href="./" style="color:#d5e697">観測画面へ</a></header><article class="research"><span class="eyebrow">MODEL · v41</span><h1>実単位ベースの食物網モデル</h1>
|
||||
<p>v41 は v38 の描画・空間探索・水域編集・Web Worker を維持し、生態学コアを day / m / kg / kJ へ置換した版です。生産者は field、動物プランクトンは cohort、大型消費者は individual agent として扱います。</p>
|
||||
<h2>実装範囲</h2><p><strong>水深・鉛直層は扱いません。</strong>漁獲、農薬、富栄養化、生息地消失などの<strong>人為的攪乱も実装していません</strong>。自動移入と自動種分化も停止しています。</p>
|
||||
<h2>単位</h2><div class="table-wrap"><table><tr><th>量</th><th>実装</th></tr><tr><td>時間</td><td>day、標準 <code>dt=1/24 day</code></td></tr><tr><td>距離</td><td>m、世界 4800 × 3200 m</td></tr><tr><td>動物体重</td><td>kg wet mass</td></tr><tr><td>動物エネルギー</td><td>kJ</td></tr><tr><td>producer / zooplankton</td><td>kg biomass m<sup>-2</sup></td></tr></table></div>
|
||||
<h2>生産者・cohort</h2><div class="formula"><strong>植物プランクトン最大増殖率</strong><br><code>μmax(T)=0.81 exp(0.0631T) day^-1</code></div><p>10 / 20 / 30°C の golden test は約 1.52 / 2.86 / 5.38 day<sup>-1</sup>。水域では光・窒素・リン制限を掛け、zooplankton は個体を大量生成せず格子上の cohort biomass として更新します。DO は phytoplankton growth の式へ直接掛けません。</p>
|
||||
<h2>食物網・Holling応答</h2><p>単一の <code>diet</code> は廃止し、consumer–resource interaction edge を摂食関係のソースにします。植物資源の標準同化効率は 0.45、動物資源は 0.85 です。</p><div class="formula"><code>Fij = aij Pij Nj^q / (1 + Σ aik Pik hik Nk^q)</code></div><p><code>q=1</code> を Holling II、<code>q=2</code> を Holling III とし、handling time は実際に複数資源の分母へ入ります。個体捕食は <code>p=1-exp(-rate×dt)</code> に変換します。</p>
|
||||
<p>Rall et al. (2012) の mass / temperature scaling を attack / handling に接続しています。all-data slopes は attack が consumer mass <code>+0.47</code>、resource mass <code>+0.15</code>、activation energy <code>+0.44 eV</code>、handling が consumer mass <code>-0.48</code>、resource mass <code>+0.34</code>、activation energy <code>-0.27 eV</code> です。捕食サイズ選好は profile 別 predator:prey mass ratio を中心とする対数正規関数です。</p>
|
||||
<h2>代謝</h2><div class="formula"><code>B(M,T)=B0 M^(3/4) × temperature response</code></div><p>ectotherm は Arrhenius 型温度補正を使い、endotherm とは normalization を分離します。旧版の肉食個体だけを一律 0.55 倍する代謝補正は使いません。</p>
|
||||
<h2>移動</h2><p><code>maximum / routine / foraging / escape speed</code>、daily movement budget、home range、dispersal を分離しました。最大速度は Hirt et al. (2017) Supplementary Table 4 の式 <code>v=aM^b(1-exp(-hM^i))</code> を用います。</p><div class="table-wrap"><table><tr><th>mode</th><th>a</th><th>b</th><th>h</th><th>i</th></tr><tr><td>flying</td><td>142.8</td><td>0.24</td><td>2.4</td><td>-0.72</td></tr><tr><td>running</td><td>25.5</td><td>0.26</td><td>22.0</td><td>-0.60</td></tr><tr><td>swimming</td><td>11.2</td><td>0.36</td><td>19.5</td><td>-0.56</td></tr></table></div>
|
||||
<h2>出生・死亡</h2><p>親を死亡させる <code>divide()</code> は廃止しました。成熟、繁殖季節、繁殖間隔、energy reserve を満たすと offspring が出生し、親は生存します。死亡要因は捕食、飢餓、寿命、背景死亡、温度ストレスとして処理します。v41 では研究用の詳細集計出力は持ちません。</p>
|
||||
<h2>初期水域</h2><p>初期 procedural water の各半径へ <code>√2</code> を一度掛け、旧版より<strong>概ね2倍の面積</strong>にします。水域同士が重なるため厳密な2倍にはしません。wet-cell 面積を測って二分探索する処理はありません。<strong>ユーザーが編集した水域は受け取った形状をそのまま適用</strong>し、水域追加ツールの既定半径も v38 と同じ 170 m です。</p>
|
||||
<h2>環境 forcing</h2><p><code>ClimateProvider</code> は seasonal fallback と観測 series を扱える内部機構を持ちますが、v41 では実験用 Worker API を公開しません。通常UIで切り替えられる外部入力は freshwater / marine profile のみです。</p><h2>出力</h2><p>研究用 batch API と詳細 flux 出力は削除しました。Worker が送るのは描画・個体観察・グラフ表示に必要な内部状態だけです。</p>
|
||||
<h2>参照と較正</h2><ol><li>Bissinger et al. (2008) / Eppley: phytoplankton temperature-growth upper envelope。</li><li>Gillooly et al.: mass/temperature metabolic scaling。</li><li>Rall et al. (2012): attack rate / handling time の mass・temperature scaling。</li><li>Brose et al.: predator–prey body-size relationship。</li><li>Hirt et al. (2017): locomotion mode 別 maximum speed model。</li></ol><p><small>文献から一意に決まらない edge baseline、B0、life-history、zooplankton cohort 係数等は simulator calibration です。区分は <code>PARAMETER-PROVENANCE.json</code> に記録しています。</small></p><p><a href="./">観測画面に戻る</a></p></article></body></html>
|
||||
47
scene.js
Normal file
47
scene.js
Normal file
|
|
@ -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;i<count;i++){
|
||||
const a=packed?null:state.animals[i],j=i*7,id=packed?state.animalIds[i]:a.id,sid=packed?state.animalSids[i]:a.sid,ax=packed?packed[j]:a.x,ay=packed?packed[j+1]:a.y,ar=packed?packed[j+2]:a.r,vx=packed?packed[j+3]:a.vx,vy=packed?packed[j+4]:a.vy,role=packed?packed[j+5]:a.role,affinity=packed?packed[j+6]:a.affinity;
|
||||
if(!noCull){const extent=Math.max(ar*1.65+1/t.s,ar+8/t.s);if(ax+extent<left||ax-extent>right||ay+extent<top||ay-extent>bottom)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)**2<radius*radius){covered=true;break}}
|
||||
ctx.globalAlpha=covered?.66:1;ctx.fillStyle=col;const dot=Math.max(ar*1.6,3/t.s);ctx.fillRect(ax-dot/2,ay-dot/2,dot,dot);ctx.globalAlpha=1;
|
||||
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()}continue}
|
||||
ctx.save();ctx.translate(ax,ay);ctx.rotate(angle);ctx.fillStyle=col;bodyPath(ctx,r,morph);ctx.fill();
|
||||
if(zoom>1.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;
|
||||
}
|
||||
49
style.css
Normal file
49
style.css
Normal file
File diff suppressed because one or more lines are too long
3
terrain.js
Normal file
3
terrain.js
Normal file
|
|
@ -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;i<GW*GH;i++){let r,g,b;if(layer==='moisture'){const m=state.moisture[i];r=23+m*26;g=43+m*80;b=44+m*101}else{const p=Math.min(1,state.plants[i]/.5);let k=0;while(k<2&&p>stops[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)}
|
||||
107
test/ecology.test.mjs
Normal file
107
test/ecology.test.mjs
Normal file
|
|
@ -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(withCompetition<rate,'multi-resource Holling denominator is not connected');
|
||||
assert.ok(pHot.attackRate>p.attackRate,'Rall temperature scaling does not increase attack rate');
|
||||
assert.ok(pHot.handlingTimeDays<p.handlingTimeDays,'Rall temperature scaling does not reduce handling time from 20C to 30C');
|
||||
assert.notEqual(rallFeedingParameters(edge,5,.1,20,FRESHWATER).attackRate,rallFeedingParameters(edge,5,1,20,FRESHWATER).attackRate,'resource mass does not affect attack rate');
|
||||
const scaled=rallFeedingParameters(edge,10,.2,20,FRESHWATER);
|
||||
approx(scaled.attackRate/p.attackRate,Math.pow(2,.62),1e-10,'Rall joint mass scaling attack');
|
||||
approx(scaled.handlingTimeDays/p.handlingTimeDays,Math.pow(2,-.14),1e-10,'Rall joint mass scaling handling');
|
||||
}
|
||||
|
||||
// The World predation path uses the multi-resource Holling denominator, not just the standalone helper.
|
||||
{const w=new World(913);for(const a of w.animals)a.dead=true;const predator=w.make(w.species.find(s=>s?.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&&two<one,'World predation path does not apply shared local prey saturation');}
|
||||
|
||||
// Reconstruct v38 procedural water without the sqrt(2) radius scale, then check only rough doubling.
|
||||
const W=4800;
|
||||
const clamp=(v,a,b)=>Math.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;j<GH;j++)for(let i=0;i<GW;i++)if(inWater(water,(i+.5)*CELL,(j+.5)*CELL))n++;return n}
|
||||
const ratios=[];
|
||||
for(const seed of [481516,1,123456789,0xffffffff]){const base=wetCells(baseWater(seed)),world=new World(seed),now=world.wet.reduce((a,b)=>a+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));
|
||||
5
time.js
Normal file
5
time.js
Normal file
|
|
@ -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')}`;
|
||||
}
|
||||
12
tree-sprites.js
Normal file
12
tree-sprites.js
Normal file
|
|
@ -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<count;j++){const angle=j*Math.PI*2/count+variant*.38+ring*.4,dist=ring?31:58,x=96+Math.cos(angle)*dist,y=96+Math.sin(angle)*dist,r=ring?28:23+(j+variant)%6;g.fillStyle=leaf[(j+variant+ring)%leaf.length];g.beginPath();g.arc(x,y,r,0,Math.PI*2);g.fill();g.fillStyle='#a8bc7130';g.beginPath();g.ellipse(x-6,y-8,r*.55,r*.35,-.5,0,Math.PI*2);g.fill()}}
|
||||
// A small central opening keeps the exact solid trunk visible at every zoom.
|
||||
g.globalCompositeOperation='destination-out';g.beginPath();g.arc(96,96,10,0,Math.PI*2);g.fill();g.globalCompositeOperation='source-over';return c;
|
||||
});
|
||||
}
|
||||
22
water.js
Normal file
22
water.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// Smooth deterministic shoreline shared by drawing and physical habitat checks.
|
||||
export function shoreRadius(w,theta){return w.r*(1+.095*Math.sin(3*theta+(w.seed||0))+.055*Math.sin(5*theta-(w.seed||0)*1.7)+.03*Math.cos(8*theta+(w.seed||0)*.6))}
|
||||
export function waterDistance(water,x,y){
|
||||
if(!water.length)return Infinity;
|
||||
// Choose a likely nearest shore with only cheap bounds. Every other shore
|
||||
// still receives the exact test, so this changes neither terrain nor physics.
|
||||
let first=0,lower=Infinity;
|
||||
for(let i=0;i<water.length;i++){const w=water[i],bound=Math.max(Math.abs(x-w.x),Math.abs(y-w.y))-w.r*1.18;if(bound<lower){lower=bound;first=i}}
|
||||
const a=water[first],ax=x-a.x,ay=y-a.y;
|
||||
let d=Math.hypot(ax,ay)-shoreRadius(a,Math.atan2(ay,ax));
|
||||
for(let i=0;i<water.length;i++){
|
||||
if(i===first)continue;
|
||||
const w=water[i],dx=x-w.x,dy=y-w.y,limit=d+w.r*1.18;
|
||||
if(limit<=0||dx*dx+dy*dy>=limit*limit)continue;
|
||||
const v=Math.hypot(dx,dy)-shoreRadius(w,Math.atan2(dy,dx));if(v<d)d=v;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
export function withinWater(w,x,y){const dx=x-w.x,dy=y-w.y,d2=dx*dx+dy*dy;if(d2<(w.r*.82)**2)return true;if(d2>(w.r*1.18)**2)return false;const r=shoreRadius(w,Math.atan2(dy,dx));return d2<r*r}
|
||||
export function inWater(water,x,y){for(const w of water)if(withinWater(w,x,y))return true;return false}
|
||||
export function waterPath(ctx,w,append=false){const n=72;if(!append)ctx.beginPath();for(let i=0;i<=n;i++){const theta=i*2*Math.PI/n,r=shoreRadius(w,theta),x=w.x+Math.cos(theta)*r,y=w.y+Math.sin(theta)*r;if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y)}ctx.closePath()}
|
||||
export function habitat(affinity){return affinity<1/3?'陸棲':affinity<2/3?'両棲':'水棲'}
|
||||
21
worker.js
Normal file
21
worker.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import {World,DT} from './engine.js?v=41';
|
||||
import {roleCode} from './ecology/feeding.js';
|
||||
const seedBuffer=new Uint32Array(1);crypto.getRandomValues(seedBuffer);const world=new World(seedBuffer[0]);let speed=1,statMode=false,last=performance.now(),debt=0,lastSend=0,lastMeasure=last,steps=0,actual=0,selected=null,limited=false,lastFieldEpoch=-1,lastSummary=0,forceSnapshot=true,lastHistoryTime=-1,lastEvent=null,awaitingAck=false,waterDirty=true,rocksDirty=true,moistureDirty=true;
|
||||
|
||||
onmessage=({data:d})=>{
|
||||
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<alive.length;i++){const a=alive[i],j=i*7;ids[i]=a.id;sids[i]=a.sid;data[j]=a.x;data[j+1]=a.y;data[j+2]=world.radius(a);data[j+3]=a.vx;data[j+4]=a.vy;data[j+5]=roleCode(world.species[a.sid]?.trophicRole);data[j+6]=a.g.waterAffinity}packet.animalData=data;packet.animalIds=ids;packet.animalSids=sids;
|
||||
if(summary){packet.stats=world.stats();const newest=world.history.at(-1)?.time;if(forceSnapshot||newest!==lastHistoryTime){packet.history=world.history;lastHistoryTime=newest}if(forceSnapshot||world.events[0]!==lastEvent){packet.events=world.events;lastEvent=world.events[0]}packet.selected=a?{id:a.id,sid:a.sid,g:a.g,age:a.age,energy:a.energy,stamina:a.stamina,maxStamina:world.staminaCapacity(a),concealment:world.concealmentAt(a.x,a.y),action:a.action,parents:a.parents,generation:a.generation,mass:world.biomass(a),maxEnergy:world.maxEnergy(a)}:null;lastSummary=now}
|
||||
if(field){packet.carcasses=world.carcasses;if(waterDirty){packet.water=world.water;waterDirty=false}if(rocksDirty){packet.rocks=world.rocks;rocksDirty=false}packet.trees=world.trees.map(({x,y,r,trunk,leaves,maxLeaves,variant})=>({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();
|
||||
2
world-size.js
Normal file
2
world-size.js
Normal file
|
|
@ -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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue