/** * Core Simulation Engine * Coordinates world state, entity lifecycles, predation, flora germination, * carcass decay callbacks, weather, and statistics recording. */ import { Sheep } from './entities/sheep.js'; import { Wolf } from './entities/wolf.js'; import { Plant } from './entities/plant.js'; import { TerrainManager } from './terrain.js'; import { WeatherManager } from './weather.js'; import { ParticleSystem } from './particles.js'; import { SimStats } from './state.js'; import { SoundManager } from './audio.js'; export class Simulation { constructor(canvas, config) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.config = config; this.width = canvas.width; this.height = canvas.height; this.isRunning = false; this.speedMultiplier = 1; // Subsystems this.terrain = new TerrainManager(this.width, this.height); this.weather = new WeatherManager(this.width, this.height); this.particles = new ParticleSystem(); this.stats = new SimStats(); this.audio = new SoundManager(); // Entity lists this.sheep = []; this.wolves = []; this.plants = []; // Interaction mode: 'inspect', 'sheep', 'wolf', 'plant', 'flower' this.activeTool = 'inspect'; this.selectedEntity = null; // Callbacks this.onExtinction = null; this.onStatsChange = null; this.init(); } isPlantLocationOccupied(x, y, minDistance = 24) { for (let i = 0; i < this.plants.length; i++) { const p = this.plants[i]; if (p.isDead) continue; if (Math.hypot(p.x - x, p.y - y) < minDistance) { return true; } } return false; } findClearPlantLocation(preferredX, preferredY, minDistance = 24, maxSearchRadius = 65) { // If target spot is already clear, use it directly! if (!this.isPlantLocationOccupied(preferredX, preferredY, minDistance)) { return { x: preferredX, y: preferredY }; } // Try expanding rings and multiple angles to find an unoccupied spot nearby for (let r = minDistance; r <= maxSearchRadius; r += 7) { for (let angle = 0; angle < Math.PI * 2; angle += Math.PI / 5) { const testX = Math.max(25, Math.min(this.width - 25, preferredX + Math.cos(angle) * r)); const testY = Math.max(25, Math.min(this.height - 25, preferredY + Math.sin(angle) * r)); if (!this.isPlantLocationOccupied(testX, testY, minDistance)) { return { x: testX, y: testY }; } } } return null; // Local area is saturated, avoid stacking } init() { this.sheep = []; this.wolves = []; this.plants = []; this.terrain.reset(); this.weather.reset(); this.particles.reset(); this.stats.reset(); this.continuousSheepTimer = 0; this.continuousWolfTimer = 0; // 1. Spawn Initial Plants (spaced out with no overlaps) const plantCount = Math.max(5, this.config.initialPlants || 35); for (let i = 0; i < plantCount; i++) { let attempts = 0; let loc = null; while (!loc && attempts < 35) { const x = 50 + Math.random() * (this.width - 100); const y = 50 + Math.random() * (this.height - 100); loc = this.findClearPlantLocation(x, y, 26, 30); attempts++; } if (loc) { const type = Math.random() < 0.25 ? 'flower' : 'grass'; const plant = new Plant(loc.x, loc.y, type, 0.4 + Math.random() * 0.6, this.width, this.height); this.plants.push(plant); this.stats.recordPlantSpawn(false, false); } } // 2. Spawn Initial Sheep const sheepCount = Math.max(2, this.config.initialSheep || 20); for (let i = 0; i < sheepCount; i++) { const x = 70 + Math.random() * (this.width - 140); const y = 70 + Math.random() * (this.height - 140); const sheep = new Sheep(x, y, false); this.sheep.push(sheep); this.stats.recordSheepBirth(true); } // 3. Spawn Initial Wolves const wolfCount = Math.max(1, this.config.initialWolves || 4); for (let i = 0; i < wolfCount; i++) { const x = 70 + Math.random() * (this.width - 140); const y = 70 + Math.random() * (this.height - 140); const wolf = new Wolf(x, y, false); this.wolves.push(wolf); this.stats.recordWolfBirth(true); } } restart(newConfig = null) { if (newConfig) { this.config = { ...this.config, ...newConfig }; } if (this.weather) { this.weather.reset(); } this.init(); this.isRunning = true; if (this.audio) { this.audio.ensureContext(); if (this.audio.musicEnabled) { this.audio.startMusic(); } } } resize(newWidth, newHeight) { if (newWidth <= 0 || newHeight <= 0) return; this.width = newWidth; this.height = newHeight; if (this.terrain) { this.terrain.resize(newWidth, newHeight); } if (this.weather) { this.weather.resize(newWidth, newHeight); } // Keep all animals and plants safely within updated bounds const margin = 26; for (let i = 0; i < this.sheep.length; i++) { const s = this.sheep[i]; s.x = Math.max(margin, Math.min(newWidth - margin, s.x)); s.y = Math.max(margin, Math.min(newHeight - margin, s.y)); } for (let i = 0; i < this.wolves.length; i++) { const w = this.wolves[i]; w.x = Math.max(margin, Math.min(newWidth - margin, w.x)); w.y = Math.max(margin, Math.min(newHeight - margin, w.y)); } for (let i = 0; i < this.plants.length; i++) { const p = this.plants[i]; p.x = Math.max(margin, Math.min(newWidth - margin, p.x)); p.y = Math.max(margin, Math.min(newHeight - margin, p.y)); } } spawnEntity(type, x, y) { x = Math.max(25, Math.min(this.width - 25, x)); y = Math.max(25, Math.min(this.height - 25, y)); switch (type) { case 'sheep': { const sheep = new Sheep(x, y, false); this.sheep.push(sheep); this.stats.recordSheepBirth(false); this.particles.emitGrowthSparkles(x, y, 8); break; } case 'wolf': { const wolf = new Wolf(x, y, false); this.wolves.push(wolf); this.stats.recordWolfBirth(false); this.particles.emitGrowthSparkles(x, y, 8); break; } case 'plant': { const loc = this.findClearPlantLocation(x, y, 24); if (loc) { const plant = new Plant(loc.x, loc.y, 'grass', 0.8, this.width, this.height); this.plants.push(plant); this.stats.recordPlantSpawn(false, false); this.particles.emitEatingCrumbs(loc.x, loc.y, '#4AD638', 6); } break; } case 'flower': { const loc = this.findClearPlantLocation(x, y, 24); if (loc) { const flower = new Plant(loc.x, loc.y, 'flower', 0.9, this.width, this.height); this.plants.push(flower); this.stats.recordPlantSpawn(false, false); this.particles.emitEatingCrumbs(loc.x, loc.y, flower.flowerColor, 8); } break; } } } spawnMigrantEntity(species, forcedEdge = -1) { const edge = forcedEdge >= 0 ? forcedEdge : Math.floor(Math.random() * 4); let x, y, angle; const speed = species === 'wolf' ? 1.7 : 1.4; switch (edge) { case 0: // Top edge entering down x = 60 + Math.random() * (this.width - 120); y = 26; angle = Math.PI * 0.5 + (Math.random() - 0.5) * 0.5; break; case 1: // Bottom edge entering up x = 60 + Math.random() * (this.width - 120); y = this.height - 26; angle = -Math.PI * 0.5 + (Math.random() - 0.5) * 0.5; break; case 2: // Left edge entering right x = 26; y = 60 + Math.random() * (this.height - 120); angle = (Math.random() - 0.5) * 0.5; break; case 3: // Right edge entering left default: x = this.width - 26; y = 60 + Math.random() * (this.height - 120); angle = Math.PI + (Math.random() - 0.5) * 0.5; break; } if (species === 'sheep') { const sheep = new Sheep(x, y, false); sheep.vx = Math.cos(angle) * speed; sheep.vy = Math.sin(angle) * speed; sheep.wanderAngle = angle; this.sheep.push(sheep); this.stats.recordSheepBirth(false); this.particles.emitGrowthSparkles(x, y, 6); } else { const wolf = new Wolf(x, y, false); wolf.vx = Math.cos(angle) * speed; wolf.vy = Math.sin(angle) * speed; wolf.wanderAngle = angle; this.wolves.push(wolf); this.stats.recordWolfBirth(false); this.particles.emitGrowthSparkles(x, y, 6); } } update(rawDt) { if (!this.isRunning) return; // Cap delta time to prevent physics explosions on background tab const dt = Math.min(0.1, rawDt) * this.speedMultiplier; const maxPlantCap = this.config.maxPlantCap || 90; // 1. Weather Update & Cloud Rain Germination // Rain comes directly out of floating clouds, soaking the ground and sprouting plants in its wake! this.weather.update( dt, this.config.rainFrequency, this.plants, this.particles, (wakeX, wakeY) => { // Sprout new flora behind the raining cloud on freshly wet ground! if (this.plants.length < maxPlantCap) { const loc = this.findClearPlantLocation(wakeX, wakeY, 24, 75); if (loc) { const isNearDecal = this.terrain.decals.some(d => Math.hypot(d.x - loc.x, d.y - loc.y) < 36); const isFlower = isNearDecal || Math.random() < 0.35; const plant = new Plant(loc.x, loc.y, isFlower ? 'flower' : 'grass', 0.3, this.width, this.height); this.plants.push(plant); this.terrain.absorbDecalsNear(loc.x, loc.y, 30); this.particles.emitGrowthSparkles(loc.x, loc.y, 8); this.stats.recordPlantSpawn(isFlower, false); if (this.audio) { this.audio.playSproutChime(); } } } } ); // 1b. Continuous Mode Migration Waves (Screen Saver / Endless Ecosystem) if (this.config.continuousMode) { this.continuousSheepTimer = (this.continuousSheepTimer || 0) + dt; this.continuousWolfTimer = (this.continuousWolfTimer || 0) + dt; const targetSheepInterval = Math.max(3, this.config.continuousSheepInterval || 14); const targetWolfInterval = Math.max(6, this.config.continuousWolfInterval || 32); const maxGroup = Math.max(1, this.config.continuousMaxGroupSize || 3); if (this.continuousSheepTimer >= targetSheepInterval) { this.continuousSheepTimer = (Math.random() - 0.5) * (targetSheepInterval * 0.25); const groupCount = 1 + Math.floor(Math.random() * maxGroup); const edge = Math.floor(Math.random() * 4); for (let g = 0; g < groupCount; g++) { this.spawnMigrantEntity('sheep', edge); } } if (this.continuousWolfTimer >= targetWolfInterval) { this.continuousWolfTimer = (Math.random() - 0.5) * (targetWolfInterval * 0.25); const groupCount = 1 + Math.floor(Math.random() * Math.min(2, maxGroup)); const edge = Math.floor(Math.random() * 4); for (let g = 0; g < groupCount; g++) { this.spawnMigrantEntity('wolf', edge); } } } // 2. Terrain & Carcass Decomposition Update this.terrain.update( dt, this.config.decaySpeed, this.weather.rainIntensity, (decayX, decayY) => { // Enriched soil bloom if under cap if (this.plants.length < maxPlantCap) { const fx = Math.max(25, Math.min(this.width - 25, decayX)); const fy = Math.max(25, Math.min(this.height - 25, decayY)); const loc = this.findClearPlantLocation(fx, fy, 24, 70); if (loc) { const flower = new Plant(loc.x, loc.y, 'flower', 0.35, this.width, this.height); this.plants.push(flower); // As flower grows from carcass, it absorbs the decay and blood splats! this.terrain.absorbDecalsNear(loc.x, loc.y, 35); this.stats.recordPlantSpawn(true, false); this.particles.emitEatingCrumbs(loc.x, loc.y, flower.flowerColor, 6); } } }, (bloodX, bloodY) => { // Blood spot discoloration sprout if under cap if (this.plants.length < maxPlantCap) { const bx = Math.max(25, Math.min(this.width - 25, bloodX)); const by = Math.max(25, Math.min(this.height - 25, bloodY)); const loc = this.findClearPlantLocation(bx, by, 24, 70); if (loc) { const sprout = new Plant(loc.x, loc.y, 'flower', 0.25, this.width, this.height); this.plants.push(sprout); // Absorb blood splat into the new plant! this.terrain.absorbDecalsNear(loc.x, loc.y, 32); this.stats.recordPlantSpawn(true, false); } } } ); // 3. Plants Update for (let i = this.plants.length - 1; i >= 0; i--) { const p = this.plants[i]; p.update( dt, this.plants, (seedFromX, seedFromY, targetX, targetY) => { if (this.plants.length < maxPlantCap) { this.particles.emitSeed(seedFromX, seedFromY, targetX, targetY, (landX, landY) => { if (this.plants.length < maxPlantCap) { const loc = this.findClearPlantLocation(landX, landY, 24, 65); if (loc) { const newPlant = new Plant(loc.x, loc.y, Math.random() < 0.4 ? 'flower' : 'grass', 0.2, this.width, this.height); this.plants.push(newPlant); // Cleanse soil where seed germinates this.terrain.absorbDecalsNear(loc.x, loc.y, 25); this.stats.recordPlantSpawn(false, true); } } }); } }, (witheredPlant) => { this.stats.recordPlantWithered(); }, this.width, this.height, this.config, this.terrain ); if (p.isDead) { this.plants.splice(i, 1); } } // 3b. Gentle meadow baseline (only if critically low plant count) if (this.plants.length < 12) { this.soilSproutTimer = (this.soilSproutTimer || 0) + dt; if (this.soilSproutTimer >= 3.5 && this.plants.length < maxPlantCap) { this.soilSproutTimer = 0; const rx = 40 + Math.random() * (this.width - 80); const ry = 40 + Math.random() * (this.height - 80); const loc = this.findClearPlantLocation(rx, ry, 24, 80); if (loc) { const freshGrass = new Plant(loc.x, loc.y, 'grass', 0.3, this.width, this.height); this.plants.push(freshGrass); this.stats.recordPlantSpawn(false, false); } } } // 4. Sheep Update for (let i = this.sheep.length - 1; i >= 0; i--) { const s = this.sheep[i]; s.update( dt, this.width, this.height, this.config, this.plants, this.wolves, this.sheep, this.particles, (babyX, babyY, species) => { const babySheep = new Sheep(babyX, babyY, true); this.sheep.push(babySheep); this.stats.recordSheepBirth(false); }, this.terrain, this.audio ); if (s.isDead) { // Turn into decaying carcass on the terrain! this.terrain.addCarcass(s.x, s.y, 'sheep', 20 * s.growthScale); this.stats.recordSheepDeath(s.deathCause || 'starvation'); this.sheep.splice(i, 1); } } // 5. Wolves Update for (let i = this.wolves.length - 1; i >= 0; i--) { const w = this.wolves[i]; w.update( dt, this.width, this.height, this.config, this.sheep, this.wolves, this.terrain, this.particles, (babyX, babyY, species) => { const babyWolf = new Wolf(babyX, babyY, true); this.wolves.push(babyWolf); this.stats.recordWolfBirth(false); }, (killedSheep, killerWolf) => { // Handled via sheep.isDead and sheep.takeDamage }, this.audio ); if (w.isDead) { this.terrain.addCarcass(w.x, w.y, 'wolf', 24 * w.growthScale); this.stats.recordWolfDeath('starvation'); this.wolves.splice(i, 1); } } // 5b. Physical Animal-Animal Collision & Elastic Bounce // Prevents wolves from stacking into 1 sprite, keeps sheep flock spaced, and bounces colliding animals this.resolveAnimalCollisions(); // 6. Particles Update this.particles.update(dt, this.terrain); // 7. Stats Update & Extinction Check const wasExtinct = this.stats.extinct; this.stats.update(dt, this.sheep.length, this.wolves.length, this.plants.length); if (!wasExtinct && this.stats.extinct) { if (this.onExtinction && !this.config.continuousMode) { this.onExtinction(this.stats); } } if (this.onStatsChange) { this.onStatsChange(this.stats, this.sheep.length, this.wolves.length, this.plants.length); } } resolveAnimalCollisions() { const allAnimals = [...this.wolves, ...this.sheep]; const len = allAnimals.length; for (let i = 0; i < len; i++) { const a1 = allAnimals[i]; if (a1.isDead) continue; const r1 = (a1.species === 'wolf' ? 14 : 12) * a1.growthScale; for (let j = i + 1; j < len; j++) { const a2 = allAnimals[j]; if (a2.isDead) continue; const r2 = (a2.species === 'wolf' ? 14 : 12) * a2.growthScale; const minDist = r1 + r2; let dx = a2.x - a1.x; let dy = a2.y - a1.y; let dist = Math.hypot(dx, dy); if (dist < 0.001) { // Exactly on top of each other: nudge slightly apart in random directions const angle = Math.random() * Math.PI * 2; dx = Math.cos(angle) * 0.1; dy = Math.sin(angle) * 0.1; dist = 0.1; } if (dist < minDist) { const nx = dx / dist; // Collision normal from a1 to a2 const ny = dy / dist; const overlap = minDist - dist; // 1. Positional Separation: immediately separate positions so animals do not overlap const m1 = a1.growthScale; const m2 = a2.growthScale; const totalMass = m1 + m2; const ratio1 = m2 / totalMass; const ratio2 = m1 / totalMass; a1.x -= nx * overlap * ratio1; a1.y -= ny * overlap * ratio1; a2.x += nx * overlap * ratio2; a2.y += ny * overlap * ratio2; // 2. Velocity Bounce: elastic impulse along normal const rvx = a2.vx - a1.vx; const rvy = a2.vy - a1.vy; const velAlongNormal = rvx * nx + rvy * ny; // Only bounce if they are moving towards each other if (velAlongNormal < 0) { const restitution = 0.65; // Elastic bounce factor const impulse = -(1 + restitution) * velAlongNormal; a1.vx -= nx * (impulse * ratio1); a1.vy -= ny * (impulse * ratio1); a2.vx += nx * (impulse * ratio2); a2.vy += ny * (impulse * ratio2); // Subtle lateral deflection so head-on colliding animals slip past each other const tx = -ny; const ty = nx; const lateralKick = (Math.random() - 0.5) * 0.35; a1.vx += tx * lateralKick; a1.vy += ty * lateralKick; a2.vx -= tx * lateralKick; a2.vy -= ty * lateralKick; } } } } // Keep all animals comfortably within the visible meadow bounds after bouncing const margin = 24; for (let i = 0; i < len; i++) { const a = allAnimals[i]; if (a.isDead) continue; if (a.x < margin) { a.x = margin; if (a.vx < 0) a.vx = -a.vx * 0.5 + 0.3; } else if (a.x > this.width - margin) { a.x = this.width - margin; if (a.vx > 0) a.vx = -a.vx * 0.5 - 0.3; } if (a.y < margin) { a.y = margin; if (a.vy < 0) a.vy = -a.vy * 0.5 + 0.3; } else if (a.y > this.height - margin) { a.y = this.height - margin; if (a.vy > 0) a.vy = -a.vy * 0.5 - 0.3; } } } draw() { const ctx = this.ctx; // 1. Draw Terrain, Soil Decals & Carcasses this.terrain.draw(ctx); // 1b. Draw Localized Wet Ground Trails (soaking meadow under and behind clouds) this.weather.drawWetGround(ctx); // 2. Draw Plants & Flowers for (let i = 0; i < this.plants.length; i++) { this.plants[i].draw(ctx, this.stats.elapsedSimTime); } // 3. Draw Sheep for (let i = 0; i < this.sheep.length; i++) { this.sheep[i].draw(ctx); } // 4. Draw Wolves for (let i = 0; i < this.wolves.length; i++) { this.wolves[i].draw(ctx); } // 5. Draw Particles (Blood sprays, hearts, seeds, splashes) this.particles.draw(ctx); // 6. Draw Weather Atmosphere & Rain Drops this.weather.draw(ctx); // 7. Selected Entity Inspection Circle if (this.selectedEntity && !this.selectedEntity.isDead) { ctx.save(); ctx.strokeStyle = '#FFD700'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(this.selectedEntity.x, this.selectedEntity.y, 22 * (this.selectedEntity.growthScale || 1), 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } } }