394 lines
13 KiB
JavaScript
394 lines
13 KiB
JavaScript
/**
|
|
* Weather & Rain Simulation System
|
|
* Features procedural multi-lobed organic clouds with 3D volume shading,
|
|
* realistic drifting ground shadows, weather cycles, raindrops, and plant hydration.
|
|
*/
|
|
|
|
const CLOUD_PRESETS = [
|
|
// Preset 0: Majestic Cumulus (Tall center dome with billowing shoulders, flanking wisps, and flat base)
|
|
{
|
|
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 (Broad rolling billows with undulating tops)
|
|
{
|
|
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 (Cute rounded cloud with soft bubbling lobes)
|
|
{
|
|
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 (Elongated windswept cloud bank)
|
|
{
|
|
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
|
|
this.clouds = [];
|
|
this.initClouds();
|
|
|
|
// Raindrop particles
|
|
this.raindrops = [];
|
|
this.maxDrops = 280;
|
|
this.initRaindrops();
|
|
}
|
|
|
|
initClouds() {
|
|
this.clouds = [];
|
|
// Number of clouds scales naturally with meadow width
|
|
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;
|
|
// Stagger across the entire width plus extra margin
|
|
const x = -100 + (i / count) * (this.width + 300);
|
|
const y = 30 + Math.random() * (this.height * 0.65);
|
|
|
|
this.clouds.push({
|
|
x,
|
|
y,
|
|
scale,
|
|
speed,
|
|
preset,
|
|
lobes: preset.lobes,
|
|
width: preset.width * scale,
|
|
height: preset.height * scale,
|
|
shadowOffsetY: 90 + Math.random() * 60,
|
|
swayOffset: Math.random() * Math.PI * 2
|
|
});
|
|
}
|
|
}
|
|
|
|
initRaindrops() {
|
|
this.raindrops = [];
|
|
const count = Math.max(220, Math.floor((this.width * this.height) / 3200));
|
|
this.maxDrops = count;
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
this.raindrops.push({
|
|
x: Math.random() * (this.width + 160),
|
|
y: Math.random() * this.height,
|
|
speed: 420 + Math.random() * 300,
|
|
length: 8 + Math.random() * 9,
|
|
});
|
|
}
|
|
}
|
|
|
|
resize(newWidth, newHeight) {
|
|
if (newWidth <= 0 || newHeight <= 0) return;
|
|
this.width = newWidth;
|
|
this.height = newHeight;
|
|
|
|
this.initRaindrops();
|
|
|
|
// Ensure clouds span the new meadow width
|
|
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.75) {
|
|
c.y = 30 + Math.random() * (newHeight * 0.65);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
reset() {
|
|
this.state = 'sunny';
|
|
this.timer = 15;
|
|
this.rainIntensity = 0;
|
|
this.cloudDensity = 0.35;
|
|
}
|
|
|
|
forceRain() {
|
|
this.state = 'raining';
|
|
this.timer = 15;
|
|
this.rainIntensity = 1.0;
|
|
this.cloudDensity = 1.0;
|
|
}
|
|
|
|
update(dt, rainSliderValue, plants, particleSystem) {
|
|
// rainSliderValue is 0 - 100
|
|
// Higher slider = shorter sunny periods, longer rain duration
|
|
const sliderNorm = Math.max(0.05, rainSliderValue / 100);
|
|
const sunnyDuration = (1 - sliderNorm) * 35 + 8; // e.g. 10 to 43 sec
|
|
const rainDuration = sliderNorm * 18 + 7; // e.g. 8 to 25 sec
|
|
|
|
this.timer -= dt;
|
|
|
|
// Weather 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.25, 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);
|
|
}
|
|
|
|
// When raining, revitalize all plants!
|
|
if (this.rainIntensity > 0.1 && plants && plants.length > 0) {
|
|
const hydrationRate = 22 * this.rainIntensity; // moisture per second
|
|
for (let i = 0; i < plants.length; i++) {
|
|
plants[i].receiveRain(hydrationRate * dt);
|
|
}
|
|
}
|
|
|
|
// Animate procedural clouds drifting across the sky
|
|
const windSpeedMult = 1.0 + this.rainIntensity * 0.75;
|
|
for (let i = 0; i < this.clouds.length; i++) {
|
|
const cloud = this.clouds[i];
|
|
cloud.x += cloud.speed * windSpeedMult * dt;
|
|
|
|
// Wrap around cleanly when fully offscreen to the right
|
|
if (cloud.x - cloud.width * 0.6 > this.width + 120) {
|
|
cloud.x = -cloud.width * 0.6 - 60;
|
|
cloud.y = 25 + Math.random() * (this.height * 0.65);
|
|
cloud.scale = 0.8 + Math.random() * 0.45;
|
|
cloud.speed = 14 + Math.random() * 16;
|
|
}
|
|
}
|
|
|
|
// Animate raindrops
|
|
if (this.rainIntensity > 0.05) {
|
|
const activeDrops = Math.floor(this.maxDrops * this.rainIntensity);
|
|
for (let i = 0; i < activeDrops; i++) {
|
|
const drop = this.raindrops[i];
|
|
drop.x -= dt * 65; // realistic wind angle
|
|
drop.y += drop.speed * dt;
|
|
|
|
if (drop.y > this.height) {
|
|
drop.y = -drop.length;
|
|
drop.x = Math.random() * (this.width + 160);
|
|
|
|
// Ground splash ripple on impact
|
|
if (particleSystem && Math.random() < 0.2) {
|
|
particleSystem.emitSplash(drop.x, this.height - Math.random() * 30);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
draw(ctx) {
|
|
ctx.save();
|
|
|
|
// ==============================================================
|
|
// 1. ORGANIC CLOUD SHADOWS CAST ONTO THE MEADOW GROUND
|
|
// Realistic multi-lobed cloud silhouettes drifting over the grass
|
|
// ==============================================================
|
|
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];
|
|
// Ground shadow projection: offset down-right from light source with subtle vertical perspective squash
|
|
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; // perspective ground squash
|
|
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 (Darkening when overcast or stormy)
|
|
// ==============================================================
|
|
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 (SKY ATMOSPHERE LAYER)
|
|
// Fluffy cumulus with 3D volumetric shading & highlighted rims
|
|
// ==============================================================
|
|
const cloudAlpha = 0.28 + this.cloudDensity * 0.55 + this.rainIntensity * 0.15;
|
|
const isStormy = this.rainIntensity > 0.35;
|
|
|
|
for (let i = 0; i < this.clouds.length; i++) {
|
|
const cloud = this.clouds[i];
|
|
|
|
// A. Darker Underbelly / Base Shading (Gives the cloud volumetric depth)
|
|
ctx.fillStyle = isStormy
|
|
? `rgba(65, 78, 92, ${cloudAlpha * 0.85})`
|
|
: `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 + 5) * cloud.scale; // subtle downward shadow offset
|
|
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 (Soft billowing white / slate-grey storm)
|
|
ctx.fillStyle = isStormy
|
|
? `rgba(115, 130, 146, ${cloudAlpha * 0.95})`
|
|
: `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) { // Crown of the cloud receives direct sunlight
|
|
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. DIAGONAL TRANSLUCENT RAINDROPS
|
|
// ==============================================================
|
|
if (this.rainIntensity > 0.05) {
|
|
ctx.strokeStyle = `rgba(175, 215, 255, ${0.65 * this.rainIntensity})`;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
|
|
const activeDrops = Math.floor(this.maxDrops * this.rainIntensity);
|
|
for (let i = 0; i < activeDrops; i++) {
|
|
const drop = this.raindrops[i];
|
|
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();
|
|
}
|
|
}
|