food_chain/app.js
2026-09-29 23:45:12 +09:00

40 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {habitat} from './water.js?v=45';
import {drawScene} from './scene.js?v=45';
import {lineageOrder,drawLineage} from './lineage.js?v=45';
import {paintTerrain} from './terrain.js?v=45';
import {createTreeSprites} from './tree-sprites.js?v=45';
import {formatElapsed as elapsed} from './time.js?v=45';
import {roleLabel} from './ecology/feeding.js?v=45';
import {pinchCamera} from './gestures.js?v=45';
import {W,H,GW,GH,CELL,traits} from './engine.js?v=45';
const $=id=>document.getElementById(id),worker=new Worker('./worker.js?v=45',{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,paintNeeded=true;
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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;paintNeeded=true;options()}})}}
document.querySelectorAll('[data-tool]').forEach(b=>b.onclick=()=>{tool=b.dataset.tool;paintNeeded=true;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();paintNeeded=true;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();paintNeeded=true}
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;paintNeeded=true} $('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);paintNeeded=true;return}const p=point(e);hover=p;paintNeeded=true;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;paintNeeded=true};
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;paintNeeded=true;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;paintNeeded=true};canvas.onpointercancel=e=>{contacts.delete(e.pointerId);if(contacts.size<2)pinch=null;drag=null;paintNeeded=true};
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||!paintNeeded)return;const frameGap=state.statMode?900:speed>=100?80:speed>=20?50:state.animalIds.length>4000?50:33;if(now-lastPaint<frameGap)return;lastPaint=now;paintNeeded=false;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;paintNeeded=true;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);