MODEL · v41実単位ベースの食物網モデル
+v41 は v38 の描画・空間探索・水域編集・Web Worker を維持し、生態学コアを day / m / kg / kJ へ置換した版です。生産者は field、動物プランクトンは cohort、大型消費者は individual agent として扱います。
+実装範囲
水深・鉛直層は扱いません。漁獲、農薬、富栄養化、生息地消失などの人為的攪乱も実装していません。自動移入と自動種分化も停止しています。
+単位
| 量 | 実装 |
|---|
| 時間 | day、標準 dt=1/24 day |
| 距離 | m、世界 4800 × 3200 m |
| 動物体重 | kg wet mass |
| 動物エネルギー | kJ |
| producer / zooplankton | kg biomass m-2 |
+生産者・cohort
植物プランクトン最大増殖率
μmax(T)=0.81 exp(0.0631T) day^-1
10 / 20 / 30°C の golden test は約 1.52 / 2.86 / 5.38 day-1。水域では光・窒素・リン制限を掛け、zooplankton は個体を大量生成せず格子上の cohort biomass として更新します。DO は phytoplankton growth の式へ直接掛けません。
+食物網・Holling応答
単一の diet は廃止し、consumer–resource interaction edge を摂食関係のソースにします。植物資源の標準同化効率は 0.45、動物資源は 0.85 です。
Fij = aij Pij Nj^q / (1 + Σ aik Pik hik Nk^q)
q=1 を Holling II、q=2 を Holling III とし、handling time は実際に複数資源の分母へ入ります。個体捕食は p=1-exp(-rate×dt) に変換します。
+Rall et al. (2012) の mass / temperature scaling を attack / handling に接続しています。all-data slopes は attack が consumer mass +0.47、resource mass +0.15、activation energy +0.44 eV、handling が consumer mass -0.48、resource mass +0.34、activation energy -0.27 eV です。捕食サイズ選好は profile 別 predator:prey mass ratio を中心とする対数正規関数です。
+代謝
B(M,T)=B0 M^(3/4) × temperature response
ectotherm は Arrhenius 型温度補正を使い、endotherm とは normalization を分離します。旧版の肉食個体だけを一律 0.55 倍する代謝補正は使いません。
+移動
maximum / routine / foraging / escape speed、daily movement budget、home range、dispersal を分離しました。最大速度は Hirt et al. (2017) Supplementary Table 4 の式 v=aM^b(1-exp(-hM^i)) を用います。
| 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 |
+出生・死亡
親を死亡させる divide() は廃止しました。成熟、繁殖季節、繁殖間隔、energy reserve を満たすと offspring が出生し、親は生存します。死亡要因は捕食、飢餓、寿命、背景死亡、温度ストレスとして処理します。v41 では研究用の詳細集計出力は持ちません。
+初期水域
初期 procedural water の各半径へ √2 を一度掛け、旧版より概ね2倍の面積にします。水域同士が重なるため厳密な2倍にはしません。wet-cell 面積を測って二分探索する処理はありません。ユーザーが編集した水域は受け取った形状をそのまま適用し、水域追加ツールの既定半径も v38 と同じ 170 m です。
+環境 forcing
ClimateProvider は seasonal fallback と観測 series を扱える内部機構を持ちますが、v41 では実験用 Worker API を公開しません。通常UIで切り替えられる外部入力は freshwater / marine profile のみです。
出力
研究用 batch API と詳細 flux 出力は削除しました。Worker が送るのは描画・個体観察・グラフ表示に必要な内部状態だけです。
+参照と較正
- Bissinger et al. (2008) / Eppley: phytoplankton temperature-growth upper envelope。
- Gillooly et al.: mass/temperature metabolic scaling。
- Rall et al. (2012): attack rate / handling time の mass・temperature scaling。
- Brose et al.: predator–prey body-size relationship。
- Hirt et al. (2017): locomotion mode 別 maximum speed model。
文献から一意に決まらない edge baseline、B0、life-history、zooplankton cohort 係数等は simulator calibration です。区分は PARAMETER-PROVENANCE.json に記録しています。
観測画面に戻る
diff --git a/scene.js b/scene.js
new file mode 100644
index 0000000..2758c92
--- /dev/null
+++ b/scene.js
@@ -0,0 +1,47 @@
+import {waterPath,shoreRadius,withinWater} from './water.js';
+import {W,H} from './engine.js';
+import {bodyPath} from './body-shapes.js';
+const BIN=128,NY=Math.ceil(H/BIN),EMPTY=[];
+let indexedTrees=null,treeBins=null;
+let indexedWater=null,coastline=null;
+function outerCoast(waters,editing=false){if(!editing&&indexedWater===waters)return coastline;if(!editing)indexedWater=waters;const lines=[];for(const [i,w] of waters.entries()){let px=0,py=0;for(let k=0;k<=72;k++){const angle=k*Math.PI/36,r=shoreRadius(w,angle),x=w.x+Math.cos(angle)*r,y=w.y+Math.sin(angle)*r;if(k&& !waters.some((other,j)=>j!==i&&withinWater(other,(px+x)/2,(py+y)/2)))lines.push([px,py,x,y]);px=x;py=y}}if(!editing)coastline=lines;return lines}
+function nearbyTrees(trees,x,y){
+ if(indexedTrees!==trees){indexedTrees=trees;treeBins=new Map();for(const t of trees){const r=t.r*(.32+.68*Math.sqrt(Math.max(0,t.leaves/t.maxLeaves)))+16;
+ for(let i=Math.max(0,Math.floor((t.x-r)/BIN));i<=Math.floor((t.x+r)/BIN);i++)for(let j=Math.max(0,Math.floor((t.y-r)/BIN));j<=Math.floor((t.y+r)/BIN);j++){
+ const key=i*NY+j,b=treeBins.get(key);if(b)b.push(t);else treeBins.set(key,[t]);
+ }
+ }}return treeBins.get(Math.floor(x/BIN)*NY+Math.floor(y/BIN))||EMPTY;
+}
+
+export function drawScene(ctx,{state,view,zoom=1,panX=0,panY=0,terrain,tool='observe',drag=null,water=[],treeSprites,selected=null,hover=null,waterMode='add',layer='plants'}){
+const shapeTool=()=>tool==='water'||tool==='rocks';
+const d=view.d,scale=view.base*zoom,t={s:scale,x:(view.width-W*scale)/2+panX,y:(view.height-H*scale)/2+panY};ctx.setTransform(d,0,0,d,0,0);ctx.fillStyle='#172723';ctx.fillRect(0,0,view.width,view.height);ctx.translate(t.x,t.y);ctx.scale(t.s,t.s);ctx.imageSmoothingEnabled=true;ctx.drawImage(terrain,0,0,W,H);
+ const waters=tool==='water'&&drag?.water!==undefined?water:state.water;ctx.save();ctx.beginPath();ctx.rect(0,0,W,H);ctx.clip();ctx.beginPath();for(const w of waters)waterPath(ctx,w,true);ctx.fillStyle='#315b608c';ctx.fill();ctx.beginPath();for(const [x,y,xx,yy] of outerCoast(waters,tool==='water'&&drag?.water!==undefined)){ctx.moveTo(x,y);ctx.lineTo(xx,yy)}ctx.strokeStyle='#a7baa063';ctx.lineWidth=8;ctx.stroke();const rocks=tool==='rocks'&&drag?.water!==undefined?water:state.rocks;
+ for(const rock of rocks){ctx.fillStyle='#6c7472';ctx.strokeStyle='#a1aaa0';ctx.lineWidth=1/t.s;ctx.beginPath();ctx.arc(rock.x,rock.y,rock.r,0,Math.PI*2);ctx.fill();ctx.stroke();ctx.fillStyle='#ffffff0b';ctx.beginPath();ctx.arc(rock.x-rock.r*.16,rock.y-rock.r*.18,rock.r*.64,0,Math.PI*2);ctx.fill()}
+ if(shapeTool()){ctx.lineWidth=1/t.s;ctx.setLineDash([5/t.s,5/t.s]);for(const w of tool==='rocks'?rocks:waters){ctx.strokeStyle='#bfded3aa';if(tool==='water')waterPath(ctx,w);else{ctx.beginPath();ctx.arc(w.x,w.y,w.r,0,Math.PI*2)}ctx.stroke();if(waterMode==='edit'){ctx.fillStyle='#d5e697';ctx.fillRect(w.x-3/t.s,w.y-3/t.s,6/t.s,6/t.s)}}ctx.setLineDash([])}
+
+ for(const log of state.fallen||[]){ctx.save();ctx.translate(log.x,log.y);ctx.rotate(log.angle||0);const w=log.w,h=log.h;ctx.fillStyle='#17201880';ctx.fillRect(-w/2+3,-h/2+4,w,h);const bark=ctx.createLinearGradient(0,-h/2,0,h/2);bark.addColorStop(0,'#a48655');bark.addColorStop(.22,'#705331');bark.addColorStop(.65,'#64462a');bark.addColorStop(1,'#33291e');ctx.fillStyle=bark;ctx.strokeStyle='#291f18';ctx.lineWidth=Math.min(h/2,Math.max(.7,1/t.s));ctx.fillRect(-w/2,-h/2,w,h);const edge=ctx.lineWidth;ctx.strokeRect(-w/2+edge/2,-h/2+edge/2,w-edge,h-edge);for(let j=0;j<7;j++){const y=-h*.36+j*h*.12,shift=Math.sin(log.x*.13+log.y*.17+j*3.1)*h*.035;ctx.strokeStyle=j%2?'#b4945b80':'#2d261e95';ctx.lineWidth=Math.max(.45,.7/t.s);ctx.beginPath();ctx.moveTo(-w*.46,y);ctx.bezierCurveTo(-w*.2,y+shift,w*.17,y-shift,w*.45,y+shift*.5);ctx.stroke()}for(const end of [-1,1]){const x=end*(w/2-h*.17);ctx.fillStyle=end<0?'#a98c5e':'#92734c';ctx.beginPath();ctx.ellipse(x,0,h*.12,h*.42,0,0,Math.PI*2);ctx.fill();ctx.strokeStyle='#52402b';for(let j=0;j<3;j++){ctx.beginPath();ctx.ellipse(x,0,h*(.04+j*.023),h*(.13+j*.1),0,0,Math.PI*2);ctx.stroke()}}ctx.restore()}
+ for(const tree of state.trees){const radius=tree.r*(.32+.68*Math.sqrt(Math.max(0,tree.leaves/tree.maxLeaves)));ctx.globalAlpha=.5+.5*tree.leaves/tree.maxLeaves;ctx.drawImage(treeSprites[tree.variant],tree.x-radius,tree.y-radius,radius*2,radius*2);ctx.globalAlpha=1;ctx.fillStyle='#57442b';ctx.strokeStyle='#ad9768';ctx.lineWidth=.8/t.s;ctx.beginPath();ctx.arc(tree.x,tree.y,tree.trunk,0,Math.PI*2);ctx.fill();ctx.stroke()}
+
+ ctx.fillStyle='#958670aa';for(const c of state.carcasses){const radius=2.3*Math.sqrt(Math.max(0,c.remainingBiomass));if(radius*t.s<2){const dot=Math.max(radius*1.5,1.5/t.s);ctx.fillRect(c.x-dot/2,c.y-dot/2,dot,dot)}else{ctx.beginPath();ctx.arc(c.x,c.y,radius,0,6.29);ctx.fill()}}
+ const species=new Map(state.stats.species.map(s=>[s.id,s])),left=-t.x/t.s,right=(view.width-t.x)/t.s,top=-t.y/t.s,bottom=(view.height-t.y)/t.s,noCull=left<=0&&top<=0&&right>=W&&bottom>=H;
+ const packed=state.animalData,count=packed?state.animalIds.length:state.animals.length;
+ for(let i=0;i