LifeSimulator/js/weather.js

470 lines
15 KiB
JavaScript

/**
* Weather, Cloud Rain & Localized Meadow Wetness System
* Features:
* 1. Procedural multi-lobed organic clouds with volumetric 3D shading.
* 2. Rain curtains that fall directly OUT OF drifting clouds instead of uniformly across the whole screen.
* 3. Localized ground wetness: soaked meadow trails behind raining clouds.
* 4. Flora germination in the wet wake of clouds: sprouts grass and flowers in freshly watered soil.
* 5. Hydration of existing plants as rain clouds drift overhead.
*/
const CLOUD_PRESETS = [
// Preset 0: Majestic Cumulus
{
lobes: [
{ x: 0, y: -26, r: 50 }, // Central peak
{ x: -44, y: -14, r: 42 }, // Left shoulder
{ x: 46, y: -16, r: 44 }, // Right shoulder
{ x: -84, y: 0, r: 32 }, // Far left puff
{ x: 88, y: -2, r: 34 }, // Far right puff
{ x: -116, y: 6, r: 22 }, // Left tail
{ x: 120, y: 5, r: 24 }, // Right tail
{ x: -22, y: -44, r: 34 }, // High crest left
{ x: 24, y: -42, r: 32 }, // High crest right
{ x: -52, y: 10, r: 28 }, // Base left
{ x: 0, y: 12, r: 32 }, // Base center
{ x: 52, y: 10, r: 28 }, // Base right
],
width: 250,
height: 105
},
// Preset 1: Wide Billowing Cloud Bank
{
lobes: [
{ x: -38, y: -28, r: 48 },
{ x: 42, y: -26, r: 46 },
{ x: -92, y: -12, r: 38 },
{ x: 98, y: -14, r: 40 },
{ x: -142, y: 2, r: 28 },
{ x: 148, y: 4, r: 30 },
{ x: 0, y: -48, r: 38 },
{ x: -72, y: 10, r: 32 },
{ x: 0, y: 14, r: 35 },
{ x: 72, y: 12, r: 32 },
{ x: -118, y: 8, r: 24 },
{ x: 122, y: 8, r: 26 }
],
width: 320,
height: 115
},
// Preset 2: Compact Fluffy Cotton Puff
{
lobes: [
{ x: 0, y: -20, r: 42 },
{ x: -38, y: -10, r: 34 },
{ x: 40, y: -8, r: 36 },
{ x: -70, y: 2, r: 24 },
{ x: 72, y: 0, r: 26 },
{ x: 12, y: -36, r: 28 },
{ x: -18, y: -34, r: 26 },
{ x: -30, y: 6, r: 25 },
{ x: 30, y: 6, r: 25 },
{ x: 0, y: 8, r: 28 }
],
width: 175,
height: 85
},
// Preset 3: Drifting Stratocumulus
{
lobes: [
{ x: -55, y: -18, r: 40 },
{ x: 12, y: -24, r: 44 },
{ x: 78, y: -16, r: 38 },
{ x: -112, y: -6, r: 30 },
{ x: 128, y: -4, r: 28 },
{ x: -162, y: 4, r: 20 },
{ x: 168, y: 6, r: 20 },
{ x: -26, y: -40, r: 30 },
{ x: 46, y: -36, r: 28 },
{ x: -72, y: 8, r: 27 },
{ x: 22, y: 10, r: 29 },
{ x: 82, y: 8, r: 27 }
],
width: 355,
height: 100
}
];
export class WeatherManager {
constructor(width, height) {
this.width = width;
this.height = height;
this.state = 'sunny'; // 'sunny', 'cloudy', 'raining', 'clearing'
this.timer = 15; // seconds until next weather transition
this.rainIntensity = 0; // 0.0 to 1.0 (smooth fade in/out)
this.cloudDensity = 0.35; // 0.2 (clear day) to 1.0 (overcast storm)
// Procedural cloud entities with dedicated rain particle pools
this.clouds = [];
this.initClouds();
// Persistent wet ground trail zones
this.wetZones = [];
}
initClouds() {
this.clouds = [];
const count = Math.max(5, Math.ceil(this.width / 220));
for (let i = 0; i < count; i++) {
const preset = CLOUD_PRESETS[i % CLOUD_PRESETS.length];
const scale = 0.8 + Math.random() * 0.45;
const speed = 14 + Math.random() * 16;
const x = -120 + (i / count) * (this.width + 350);
const y = 25 + Math.random() * (this.height * 0.6);
this.clouds.push({
x,
y,
scale,
speed,
preset,
lobes: preset.lobes,
width: preset.width * scale,
height: preset.height * scale,
shadowOffsetY: 85 + Math.random() * 55,
swayOffset: Math.random() * Math.PI * 2,
isRaining: false,
raindrops: [],
sproutTimer: Math.random() * 1.5,
wetStampTimer: 0,
});
}
}
resize(newWidth, newHeight) {
if (newWidth <= 0 || newHeight <= 0) return;
this.width = newWidth;
this.height = newHeight;
if (this.clouds.length < Math.ceil(newWidth / 220)) {
this.initClouds();
} else {
for (let i = 0; i < this.clouds.length; i++) {
const c = this.clouds[i];
if (c.y > newHeight * 0.7) {
c.y = 25 + Math.random() * (newHeight * 0.6);
}
}
}
}
reset() {
this.state = 'sunny';
this.timer = 15;
this.rainIntensity = 0;
this.cloudDensity = 0.35;
this.wetZones = [];
for (let i = 0; i < this.clouds.length; i++) {
this.clouds[i].raindrops = [];
this.clouds[i].isRaining = false;
}
}
forceRain() {
this.state = 'raining';
this.timer = 20;
this.rainIntensity = 1.0;
this.cloudDensity = 1.0;
}
update(dt, rainSliderValue, plants, particleSystem, onWetSprout = null) {
const sliderNorm = Math.max(0.05, rainSliderValue / 100);
const sunnyDuration = (1 - sliderNorm) * 35 + 8;
const rainDuration = sliderNorm * 20 + 8;
this.timer -= dt;
// Weather transition state machine
if (this.timer <= 0) {
switch (this.state) {
case 'sunny':
this.state = 'cloudy';
this.timer = 5;
break;
case 'cloudy':
this.state = 'raining';
this.timer = rainDuration;
break;
case 'raining':
this.state = 'clearing';
this.timer = 5;
break;
case 'clearing':
this.state = 'sunny';
this.timer = sunnyDuration;
break;
}
}
// Smoothly adjust rainIntensity and cloudDensity
if (this.state === 'raining') {
this.rainIntensity = Math.min(1.0, this.rainIntensity + dt * 0.75);
this.cloudDensity = Math.min(1.0, this.cloudDensity + dt * 0.6);
} else if (this.state === 'cloudy') {
this.rainIntensity = Math.min(0.2, this.rainIntensity + dt * 0.35);
this.cloudDensity = Math.min(0.85, this.cloudDensity + dt * 0.5);
} else if (this.state === 'clearing') {
this.rainIntensity = Math.max(0.0, this.rainIntensity - dt * 0.45);
this.cloudDensity = Math.max(0.45, this.cloudDensity - dt * 0.35);
} else {
// Sunny
this.rainIntensity = Math.max(0.0, this.rainIntensity - dt * 0.5);
this.cloudDensity = Math.max(0.35, this.cloudDensity - dt * 0.2);
}
// Update persistent wet ground zones
for (let i = this.wetZones.length - 1; i >= 0; i--) {
const zone = this.wetZones[i];
zone.life -= dt;
zone.alpha = Math.max(0, (zone.life / zone.maxLife) * 0.35);
if (zone.life <= 0) {
this.wetZones.splice(i, 1);
}
}
// Drift clouds across the sky & emit localized rain
const windSpeedMult = 1.0 + this.rainIntensity * 0.75;
const isCloudRainActive = this.rainIntensity > 0.05;
for (let i = 0; i < this.clouds.length; i++) {
const cloud = this.clouds[i];
cloud.x += cloud.speed * windSpeedMult * dt;
// Wrap around when fully past screen right edge
if (cloud.x - cloud.width * 0.6 > this.width + 120) {
cloud.x = -cloud.width * 0.6 - 80;
cloud.y = 25 + Math.random() * (this.height * 0.6);
cloud.scale = 0.8 + Math.random() * 0.45;
cloud.speed = 14 + Math.random() * 16;
cloud.raindrops = [];
}
// Determine if this cloud is raining:
// Active during rain periods, especially for clouds with dark underbellies
cloud.isRaining = isCloudRainActive && (i % 2 === 0 || this.rainIntensity > 0.45);
// Rain emission directly beneath cloud
if (cloud.isRaining) {
// Spawn fresh drops from cloud underside
const dropsToSpawn = Math.round(this.rainIntensity * (cloud.width / 50) * (dt * 60));
const maxCloudDrops = 75;
for (let d = 0; d < dropsToSpawn && cloud.raindrops.length < maxCloudDrops; d++) {
const spawnOffset = (Math.random() - 0.5) * (cloud.width * 0.72);
cloud.raindrops.push({
x: cloud.x + spawnOffset,
y: cloud.y + (cloud.height * 0.22) + Math.random() * 12,
speed: 460 + Math.random() * 120,
length: 8 + Math.random() * 9,
groundY: Math.min(this.height, cloud.y + cloud.shadowOffsetY + 60 + Math.random() * (this.height - cloud.y - 70))
});
}
// Leave wet ground trail directly under and behind the cloud's path
cloud.wetStampTimer = (cloud.wetStampTimer || 0) + dt;
if (cloud.wetStampTimer >= 0.18) {
cloud.wetStampTimer = 0;
const groundY = Math.min(this.height - 25, cloud.y + cloud.shadowOffsetY + (Math.random() - 0.5) * 50);
this.addWetZone(cloud.x - 15, groundY, cloud.width * 0.35);
}
// Hydrate existing plants located directly under this cloud's rain curtain
if (plants && plants.length > 0) {
const hydrationRate = 45 * this.rainIntensity;
for (let p = 0; p < plants.length; p++) {
const plant = plants[p];
if (plant.isDead) continue;
// Check if plant is within rain shaft below cloud
const dx = Math.abs(plant.x - cloud.x);
if (dx < cloud.width * 0.52 && plant.y >= cloud.y + 20) {
plant.receiveRain(hydrationRate * dt);
}
}
}
// Sprout new flora behind the cloud in its freshly wet trailing wake!
cloud.sproutTimer = (cloud.sproutTimer || 0) + dt;
if (cloud.sproutTimer >= 1.25 && onWetSprout) {
cloud.sproutTimer = (Math.random() - 0.5) * 0.4; // Jitter timer
// Sprout location in the moist wake just behind the cloud
const wakeX = cloud.x - (cloud.width * 0.45) - Math.random() * 50;
const wakeY = cloud.y + 40 + Math.random() * (this.height - cloud.y - 70);
if (wakeX >= 25 && wakeX <= this.width - 25 && wakeY >= 25 && wakeY <= this.height - 25) {
onWetSprout(wakeX, wakeY);
}
}
}
// Animate this cloud's falling raindrops
for (let r = cloud.raindrops.length - 1; r >= 0; r--) {
const drop = cloud.raindrops[r];
drop.x -= dt * 65; // Wind angle
drop.y += drop.speed * dt;
if (drop.y >= drop.groundY) {
// Impact splash ripple on ground
if (particleSystem && Math.random() < 0.25) {
particleSystem.emitSplash(drop.x, drop.groundY);
}
cloud.raindrops.splice(r, 1);
}
}
}
}
addWetZone(x, y, radius = 35) {
// Keep wet zones bounded
if (this.wetZones.length > 60) {
this.wetZones.shift();
}
this.wetZones.push({
x,
y,
radius: Math.max(22, radius),
maxLife: 16,
life: 16,
alpha: 0.35
});
}
drawWetGround(ctx) {
if (this.wetZones.length === 0) return;
ctx.save();
for (let i = 0; i < this.wetZones.length; i++) {
const wz = this.wetZones[i];
if (wz.alpha <= 0.01) continue;
// Dark lush damp grass oval
ctx.beginPath();
ctx.ellipse(wz.x, wz.y, wz.radius, wz.radius * 0.55, 0, 0, Math.PI * 2);
ctx.fillStyle = `rgba(18, 48, 16, ${wz.alpha})`;
ctx.fill();
}
ctx.restore();
}
draw(ctx) {
ctx.save();
// ==============================================================
// 1. ORGANIC CLOUD GROUND SHADOWS
// ==============================================================
const shadowAlpha = 0.07 + this.cloudDensity * 0.16 + this.rainIntensity * 0.12;
ctx.fillStyle = `rgba(10, 26, 14, ${shadowAlpha})`;
for (let i = 0; i < this.clouds.length; i++) {
const cloud = this.clouds[i];
const sx = cloud.x + 35;
const sy = Math.min(this.height - 15, cloud.y + cloud.shadowOffsetY);
ctx.beginPath();
for (let j = 0; j < cloud.lobes.length; j++) {
const lobe = cloud.lobes[j];
const lx = sx + lobe.x * cloud.scale;
const ly = sy + lobe.y * cloud.scale * 0.72;
const lr = lobe.r * cloud.scale;
ctx.moveTo(lx + lr, ly);
ctx.arc(lx, ly, lr, 0, Math.PI * 2);
}
ctx.fill();
}
// ==============================================================
// 2. ATMOSPHERIC AMBIENT TINT
// ==============================================================
if (this.rainIntensity > 0.02 || this.cloudDensity > 0.5) {
const darkFactor = this.rainIntensity * 0.32 + Math.max(0, this.cloudDensity - 0.5) * 0.15;
ctx.fillStyle = `rgba(16, 32, 44, ${darkFactor})`;
ctx.fillRect(0, 0, this.width, this.height);
}
// ==============================================================
// 3. DRIFTING OVERHEAD CLOUDS (3D VOLUMETRIC SKY LAYER)
// ==============================================================
const cloudAlpha = 0.28 + this.cloudDensity * 0.55 + this.rainIntensity * 0.15;
for (let i = 0; i < this.clouds.length; i++) {
const cloud = this.clouds[i];
const isStormy = cloud.isRaining || (this.rainIntensity > 0.35);
// A. Darker Underbelly / Base Shading
ctx.fillStyle = isStormy
? `rgba(62, 75, 88, ${cloudAlpha * 0.92})`
: `rgba(175, 190, 205, ${cloudAlpha * 0.55})`;
ctx.beginPath();
for (let j = 0; j < cloud.lobes.length; j++) {
const lobe = cloud.lobes[j];
const lx = cloud.x + lobe.x * cloud.scale;
const ly = cloud.y + (lobe.y + 6) * cloud.scale;
const lr = lobe.r * cloud.scale;
ctx.moveTo(lx + lr, ly);
ctx.arc(lx, ly, lr, 0, Math.PI * 2);
}
ctx.fill();
// B. Main Puffy Cloud Body
ctx.fillStyle = isStormy
? `rgba(110, 126, 142, ${cloudAlpha * 0.96})`
: `rgba(244, 248, 253, ${cloudAlpha * 0.88})`;
ctx.beginPath();
for (let j = 0; j < cloud.lobes.length; j++) {
const lobe = cloud.lobes[j];
const lx = cloud.x + lobe.x * cloud.scale;
const ly = cloud.y + lobe.y * cloud.scale;
const lr = lobe.r * cloud.scale;
ctx.moveTo(lx + lr, ly);
ctx.arc(lx, ly, lr, 0, Math.PI * 2);
}
ctx.fill();
// C. Sunlit Rim / Top Crown Highlights on upper lobes
ctx.fillStyle = isStormy
? `rgba(175, 192, 208, ${cloudAlpha * 0.5})`
: `rgba(255, 255, 255, ${cloudAlpha * 0.82})`;
ctx.beginPath();
for (let j = 0; j < cloud.lobes.length; j++) {
const lobe = cloud.lobes[j];
if (lobe.y < 0) {
const lx = cloud.x + lobe.x * cloud.scale;
const ly = cloud.y + (lobe.y - 3) * cloud.scale;
const lr = Math.max(2, (lobe.r - 4) * cloud.scale);
ctx.moveTo(lx + lr, ly);
ctx.arc(lx, ly, lr, 0, Math.PI * 2);
}
}
ctx.fill();
// ==============================================================
// 4. CLOUD RAIN CURTAINS (Falling directly underneath this cloud!)
// ==============================================================
if (cloud.raindrops.length > 0) {
ctx.save();
ctx.strokeStyle = `rgba(180, 220, 255, ${0.72 * this.rainIntensity})`;
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let r = 0; r < cloud.raindrops.length; r++) {
const drop = cloud.raindrops[r];
const startX = drop.x;
const startY = drop.y;
const endX = startX - drop.length * 0.28;
const endY = startY + drop.length;
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
}
ctx.stroke();
ctx.restore();
}
}
ctx.restore();
}
}