This commit is contained in:
33333-33333 2026-09-29 23:45:12 +09:00
commit f5fc9b76ce
73 changed files with 32628 additions and 376 deletions

103
engine.js
View file

@ -1,18 +1,18 @@
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';
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';
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]};
@ -47,7 +47,7 @@ export class World{
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}
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}
// 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
@ -131,9 +131,13 @@ export class World{
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))}
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}
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}
}
@ -143,8 +147,8 @@ export class World{
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
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
}
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;
@ -168,31 +172,50 @@ export class World{
}
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)}
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)}
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))}
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)}
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}}}
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}}}
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)}
if(a.action==='徘徊'&&!a.roamTarget)a.roamTarget=this.chooseRoamTarget(a)
this.updateFlocking(a,near);
}
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;
}
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 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;
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)}
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)}
if(!children.length)return;a.energy=Math.max(0,a.energy-perChild*children.length);a.lastBirth=this.time;this.animals.push(...children);
}
updateResources(){
@ -219,7 +242,7 @@ export class World{
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;
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;
}
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;
@ -230,15 +253,15 @@ export class World{
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 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))}
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();