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]}; 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{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({x:300+this.rand()*(W-600),y:250+this.rand()*(H-500)})); for(let k=0;k.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>>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()} 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 // 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.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=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=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(xW-r-10||yH-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)} 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=currentthis.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;j80||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=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)} 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),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)&&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==='徘徊'&&!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(d20){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.agec.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.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()c.remainingBiomass>0));if(this.tick%24===0)this.updateResources();const temp=this.temp(),length=this.animals.length; for(let i=0;i{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=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(); } 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(dsum+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)} } }