40 lines
13 KiB
JavaScript
40 lines
13 KiB
JavaScript
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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);
|