LifeSimulator/js/simulation.js

643 lines
21 KiB
JavaScript

/**
* 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();
}
}
}