food_chain/engine.js

276 lines
43 KiB
JavaScript
Raw Permalink Normal View History

2026-09-29 23:45:12 +09:00
import {RockField} from './obstacles.js?v=45';
import {waterDistance as shorelineDistance,inWater,habitat} from './water.js?v=45';
import {W,H,GW,GH,CELL} from './world-size.js?v=45';
import {sizeSpeedBoost,staminaLimit,staminaRecoveryFractionPerDay,sprintStaminaFractionPerDay,locomotionCost,thermalFitness,metabolismKJPerDay,decayRate} from './model.js?v=45';
import {ClimateProvider} from './ecology/climate.js?v=45';
import {TERRESTRIAL,getProfile} from './ecology/profiles.js?v=45';
import {ResourceField,producerCapacity,producerGrowthPerDay,updateProducerBiomass,updateZooplanktonCohort} from './ecology/resources.js?v=45';
import {interactionEdge,bodyMassPreference,producerIntakeKgPerDay,fieldResourceIntakeKgPerDay,rallFeedingParameters,functionalResponseRatePerDay,attackProbabilityPerStep,roleCode} from './ecology/feeding.js?v=45';
import {backgroundHazardPerDay,reproductionConfig,inBreedingSeason,offspringEnergyShare,reproductionThreshold,energyFraction,initialStructuralMassKg,energyLimitedGrowth} from './ecology/demography.js?v=45';
import {starvationHazardPerDay,thermalHazardPerDay} from './ecology/metabolism.js?v=45';
import {rateToProbability} from './ecology/units.js?v=45';
import {routineTravelSpeedMPerDay,foragingSpeedMPerDay,escapeSpeedMPerDay,maximumSpeedMPerDay,dailyMovementBudgetM,sampleRoamingDistanceM,limitSpeedForArrival} from './ecology/movement.js?v=45';
import {validateProfileId} from './ecology/schema.js?v=45';
export {sizeSpeedBoost} from './model.js?v=45';
export {W,H,GW,GH,CELL} from './world-size.js?v=45';
2026-09-29 19:10:03 +09:00
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()}
2026-09-29 23:45:12 +09:00
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'),breedingPhaseDays:meta.breedingPhaseDays??ancestor?.breedingPhaseDays??((id*137.508)%365)};return id}
2026-09-29 19:10:03 +09:00
// 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)}
2026-09-29 23:45:12 +09:00
habitatClear(a,x,y,clearance=0){const h=a.habitat||habitat(a.g.waterAffinity);if(h==='両棲')return true;if(clearance<=0)return(h==='水棲')===inWater(this.water,x,y);const d=this.waterDistance(x,y);return h==='水棲'?d<=-clearance:d>=clearance}
waterAllowed(a,x,y){return this.habitatClear(a,x,y,0)}
habitatPathAllowed(a,x,y,tx,ty,clearance=0){const h=a.habitat||habitat(a.g.waterAffinity);if(h==='両棲')return true;const distance=Math.hypot(tx-x,ty-y),steps=Math.max(2,Math.min(10,Math.ceil(distance/24)));for(let j=1;j<=steps;j++){const q=j/steps;if(!this.habitatClear(a,x+(tx-x)*q,y+(ty-y)*q,clearance))return false}return true}
findHabitat(a,x,y,clearance=0){const field=this.fieldFor(a),r=this.radius(a),ok=(px,py)=>this.habitatClear(a,px,py,clearance)&&field.free(px,py,r);if(ok(x,y))return {x,y};for(let radius=10;radius<=720;radius+=12)for(let j=0;j<20;j++){const t=j*Math.PI/10+radius*.037,px=clamp(x+Math.cos(t)*radius,5,W-5),py=clamp(y+Math.sin(t)*radius,5,H-5);if(ok(px,py))return {x:px,y:py}}if((a.habitat||habitat(a.g.waterAffinity))==='水棲')for(const w of this.water){if(ok(w.x,w.y))return {x:w.x,y:w.y}}return null}
steerHabitat(a,x,y,dx,dy,lookahead,out=null){const h=a.habitat||habitat(a.g.waterAffinity);if(h==='両棲')return outputPosition(out,dx,dy);const n=Math.hypot(dx,dy)||1,ux=dx/n,uy=dy/n,probe=Math.max(24,Math.min(90,lookahead||48)),clearance=this.radius(a)+4,sign=h==='水棲'?-1:1,current=sign*this.waterDistance(x,y);if(current>clearance+probe+8)return outputPosition(out,dx,dy);const tx=clamp(x+ux*probe,3,W-3),ty=clamp(y+uy*probe,3,H-3);if(this.habitatPathAllowed(a,x,y,tx,ty,clearance))return outputPosition(out,dx,dy);const eps=8,score=(px,py)=>sign*this.waterDistance(px,py),gx=(score(clamp(x+eps,0,W),y)-score(clamp(x-eps,0,W),y))/(2*eps),gy=(score(x,clamp(y+eps,0,H))-score(x,clamp(y-eps,0,H)))/(2*eps),gn=Math.hypot(gx,gy);if(gn<1e-6)return outputPosition(out,-ux,-uy);const nx=gx/gn,ny=gy/gn,dot=ux*nx+uy*ny,outward=Math.min(0,dot),inwardBoost=current<clearance+18?Math.min(.55,(clearance+18-current)/36):.08,vx=ux-outward*nx+nx*inwardBoost,vy=uy-outward*ny+ny*inwardBoost,vn=Math.hypot(vx,vy)||1;return outputPosition(out,vx/vn,vy/vn)}
moveHabitat(a,x,y,tx,ty,out=null){if(!this.waterAllowed(a,x,y)){const p=this.findHabitat(a,x,y,this.radius(a)+6);return outputPosition(out,p?p.x:x,p?p.y:y)}let allowed=this.habitatPathAllowed(a,x,y,tx,ty,0),firstBlocked=1;if(!allowed){for(let j=1;j<=12;j++){const q=j/12;if(!this.waterAllowed(a,x+(tx-x)*q,y+(ty-y)*q)){firstBlocked=q;break}}}if(allowed)return outputPosition(out,tx,ty);let lo=0,hi=firstBlocked;for(let i=0;i<10;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-.01),y+(ty-y)*Math.max(0,lo-.01))}
chooseRoamTarget(a){const mode=this.species[a.sid]?.locomotionMode||'running',budget=dailyMovementBudgetM(this.biomass(a),a.g.moveSpeed,mode),baseDistance=sampleRoamingDistanceM(()=>this.rand(),budget),baseAngle=this.rand()*Math.PI*2,field=this.fieldFor(a),r=this.radius(a);for(const factor of [1,.72,.5,.34]){const distance=baseDistance*factor;for(let j=0;j<12;j++){const sign=j%2?-(j+1)/2:(j/2),angle=baseAngle+sign*Math.PI/6,x=clamp(a.x+Math.cos(angle)*distance,5,W-5),y=clamp(a.y+Math.sin(angle)*distance,5,H-5);if(!this.habitatClear(a,x,y,r+6)||!field.free(x,y,r)||!this.habitatPathAllowed(a,a.x,a.y,x,y,r+6))continue;return {x,y}}}return null}
2026-09-29 19:10:03 +09:00
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=[]){
2026-09-29 23:45:12 +09:00
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,movedToday:0,movementDay:Math.floor(this.time),roamTarget:null,turnBias:this.rand()<.5?-1:1};
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,Math.max(24,this.radius(a)+12));if(!h)return null;a.x=h.x;a.y=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
2026-09-29 19:10:03 +09:00
}
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)}
2026-09-29 23:45:12 +09:00
isDiffuseResourceTarget(t){return !!t&&(t.plant||t.resourceRole==='zooplankton')}
arrivalRadiusForTarget(a,t){if(!t)return 0;if(this.isDiffuseResourceTarget(t))return this.radius(a)+6;if(t.maxLeaves)return (this.biomass(a)>=CANOPY_BLOCK_MASS?this.canopyCollisionRadius(t):t.trunk)+this.radius(a)+2;if(t.id<0)return this.radius(a)+2.3*Math.sqrt(Math.max(0,t.remainingBiomass))+3;return this.radius(a)+this.radius(t)+3}
shouldPauseAtFood(a,t){if(!t||t.dead||a.forageUntil>this.time||this.isDiffuseResourceTarget(t))return false;const distance=Math.hypot(t.x-a.x,t.y-a.y);return distance<=this.arrivalRadiusForTarget(a,t)&&this.clearPath(a.x,a.y,t.x,t.y,a)}
2026-09-29 19:10:03 +09:00
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))}
2026-09-29 23:45:12 +09:00
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),sameHabitat=(a.habitat||habitat(a.g.waterAffinity))==='両棲'||b.habitat&&a.habitat===b.habitat||this.waterAllowed(a,b.x,b.y);return pref>.035&&sameHabitat&&this.habitatPathAllowed(a,a.x,a.y,b.x,b.y,0)}
2026-09-29 19:10:03 +09:00
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}}}
2026-09-29 23:45:12 +09:00
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)&&this.habitatPathAllowed(a,a.x,a.y,c.x,c.y,0)){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)&&this.habitatPathAllowed(a,a.x,a.y,tree.x,tree.y,0)){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)&&this.habitatPathAllowed(a,a.x,a.y,x,y,0)){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)&&this.habitatPathAllowed(a,a.x,a.y,x,y,0)){food={x,y,resourceRole:'zooplankton'};bestFood=sc}}}
2026-09-29 19:10:03 +09:00
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}
2026-09-29 23:45:12 +09:00
if(a.action==='徘徊'&&!a.roamTarget)a.roamTarget=this.chooseRoamTarget(a)
this.updateFlocking(a,near);
2026-09-29 19:10:03 +09:00
}
2026-09-29 23:45:12 +09:00
updateFlocking(a,near){
const sociability=clamp(a.g.sociability,0,1);let separationX=0,separationY=0,separationCount=0,sameCount=0,sumX=0,sumY=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),limit=space*2;
if(d2<limit*limit){const d=Math.sqrt(Math.max(d2,1e-9)),strength=clamp((limit-d)/Math.max(limit,1),0,1);separationX-=dx/d*strength;separationY-=dy/d*strength;separationCount++}
if(b.sid===a.sid&&d2<85**2){sameCount++;sumX+=b.x;sumY+=b.y}
}
if(separationCount){const n=Math.hypot(separationX,separationY)||1,mag=Math.min(1.1,Math.hypot(separationX,separationY)/Math.sqrt(separationCount));separationX=separationX/n*mag;separationY=separationY/n*mag}
// Social steering is deliberately position-based. Velocity alignment can create a
// self-sustaining tangential feedback loop (milling) around shared targets.
// Separation + weak centroid attraction gives grouping while keeping social steering position-based.
let flockX=separationX,flockY=separationY;
if(sameCount&&sociability>0){const cx=sumX/sameCount,cy=sumY/sameCount,rx=cx-a.x,ry=cy-a.y,rd=Math.hypot(rx,ry);if(rd>18){const cohesionStrength=.42*sociability*Math.min(1,(rd-18)/45);flockX+=rx/rd*cohesionStrength;flockY+=ry/rd*cohesionStrength}}
const magnitude=Math.hypot(flockX,flockY);if(magnitude>1.15){flockX=flockX/magnitude*1.15;flockY=flockY/magnitude*1.15}a.flockX=flockX;a.flockY=flockY;
}
2026-09-29 19:10:03 +09:00
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){
2026-09-29 23:45:12 +09:00
const role=this.roleOf(a),cfg=reproductionConfig(role);if(a.age<a.g.maturityAge||this.time-a.lastBirth<cfg.reproductionIntervalDays||!inBreedingSeason(this.time,role,this.species[a.sid]?.breedingPhaseDays||0)||energyFraction(a.energy,this.maxEnergy(a))<reproductionThreshold(role))return;
2026-09-29 19:10:03 +09:00
const children=[],share=offspringEnergyShare(role),perChild=Math.min(this.maxEnergy(a)*share,a.energy*share),mode=this.species[a.sid]?.locomotionMode||'running';
2026-09-29 23:45:12 +09:00
for(let j=0;j<cfg.offspringPerEvent;j++){const angle=this.rand()*Math.PI*2,d=Math.min(32,Math.max(5,this.radius(a)*2.5+this.rand()*12)),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)}
2026-09-29 19:10:03 +09:00
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'){
2026-09-29 23:45:12 +09:00
const edge=this.edge(role,'zooplankton');if(edge){const idx=this.index(a.x,a.y),density=this.zooplankton[idx],intake=fieldResourceIntakeKgPerDay(m,density,edge,'zooplankton')*DT,amount=this.resourceFields[1].consume(idx,intake,CELL*CELL),gain=amount*7000*edge.assimilationEfficiency;a.energy+=gain}return;
2026-09-29 19:10:03 +09:00
}
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++){
2026-09-29 23:45:12 +09:00
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),recoverFraction=staminaRecoveryFractionPerDay(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,t=a.target,arrival=null,grazing=false;const field=this.fieldFor(a);if(a.action!=='休息'){
const roamVector=()=>{if(!a.roamTarget)a.roamTarget=this.chooseRoamTarget(a);if(a.roamTarget){let rx=a.roamTarget.x-a.x,ry=a.roamTarget.y-a.y,rd=Math.hypot(rx,ry);if(rd<18||!this.habitatPathAllowed(a,a.x,a.y,a.roamTarget.x,a.roamTarget.y,0)){a.roamTarget=this.chooseRoamTarget(a);if(a.roamTarget){rx=a.roamTarget.x-a.x;ry=a.roamTarget.y-a.y;rd=Math.hypot(rx,ry)}}if(a.roamTarget){dx=rx;dy=ry;arrival={distance:rd,radius:12};return}}a.heading+=(this.rand()-.5)*.08;dx=Math.cos(a.heading);dy=Math.sin(a.heading)};
if(t&&!t.dead){const distance=Math.hypot(t.x-a.x,t.y-a.y),arrivalRadius=this.arrivalRadiusForTarget(a,t);if(this.isDiffuseResourceTarget(t)&&distance<=arrivalRadius+4){grazing=true;a.target=null;t=null;roamVector()}else{dx=t.x-a.x;dy=t.y-a.y;if(a.action==='逃走'){dx=-dx;dy=-dy}else arrival={distance,radius:arrivalRadius}}}
else roamVector();
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.turnBias||1,t?.maxLeaves?t.id:null,this.steeringOutput);dx=v.x;dy=v.y}const hv=this.steerHabitat(a,a.x,a.y,dx,dy,54,this.steeringOutput);dx=hv.x;dy=hv.y;norm=Math.hypot(dx,dy)||1;if((a.action==='徘徊'||grazing)&&norm>1e-9)a.heading=Math.atan2(dy,dx);
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),routineSpeed=routineTravelSpeedMPerDay(m,budget,mode),modeSpeed=a.action==='逃走'?escapeSpeedMPerDay(m,budget,mode):a.action==='摂餌'?foragingSpeedMPerDay(m,budget,mode):routineSpeed,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(grazing)speed=Math.min(speed,Math.max(45,routineSpeed*.55));if(a.action==='摂餌'&&this.shouldPauseAtFood(a,t))speed=0;if(arrival&&speed>0)speed=limitSpeedForArrival(speed,arrival.distance,arrival.radius,DT);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}const actualMove=Math.hypot(a.x-oldX,a.y-oldY);a.movedToday+=actualMove;if(speed>0&&actualMove<Math.min(1,speed*DT*.08)&&a.roamTarget){a.roamTarget=null;a.turnBias*=-1}if(a.x<=3||a.x>=W-3||a.y<=3||a.y>=H-3){a.heading+=Math.PI*.7;a.roamTarget=null}a.stamina=clamp(a.stamina+DT*staminaMax*(sprint?-sprintStaminaFractionPerDay(speed,routineSpeed):recoverFraction*.12)/(1+stress),0,staminaMax)
}else{a.vx=a.vy=0;a.stamina=Math.min(staminaMax,a.stamina+staminaMax*recoverFraction*DT/(1+stress))}
2026-09-29 19:10:03 +09:00
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)}
}
}