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