feat: add core simulation engine, entities, weather, UI, and audio systems
This commit is contained in:
parent
32c3d9c5bc
commit
4d81e6ccdd
8 changed files with 839 additions and 133 deletions
|
|
@ -196,6 +196,12 @@ body {
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-muted {
|
||||||
|
opacity: 0.52;
|
||||||
|
filter: grayscale(0.7);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-text {
|
.btn-text {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -761,12 +767,54 @@ input[type="range"]::-webkit-slider-thumb:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Fullscreen / Screen Saver Mode --- */
|
/* --- Fullscreen / Screen Saver Mode --- */
|
||||||
.app-layout.fullscreen-mode .top-nav,
|
.app-layout.fullscreen-mode .sidebar-panel,
|
||||||
.app-layout.fullscreen-mode .sidebar-panel {
|
.app-layout.fullscreen-mode .top-nav .brand,
|
||||||
|
.app-layout.fullscreen-mode .top-nav .header-actions {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-layout.fullscreen-mode .top-nav {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 14px !important;
|
||||||
|
left: 50% !important;
|
||||||
|
transform: translateX(-50%) !important;
|
||||||
|
z-index: 990 !important;
|
||||||
|
width: auto !important;
|
||||||
|
min-width: unset !important;
|
||||||
|
max-width: calc(100vw - 220px) !important;
|
||||||
|
background: rgba(14, 20, 28, 0.82) !important;
|
||||||
|
backdrop-filter: blur(16px) !important;
|
||||||
|
-webkit-backdrop-filter: blur(16px) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16) !important;
|
||||||
|
border-radius: 30px !important;
|
||||||
|
padding: 6px 14px !important;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45) !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout.fullscreen-mode .top-nav .stats-ribbon {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 8px !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout.fullscreen-mode .top-nav .stat-badge {
|
||||||
|
background: rgba(255, 255, 255, 0.06) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12) !important;
|
||||||
|
padding: 4px 10px !important;
|
||||||
|
border-radius: 20px !important;
|
||||||
|
font-size: 0.8rem !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
.app-layout.fullscreen-mode .main-workspace {
|
.app-layout.fullscreen-mode .main-workspace {
|
||||||
|
position: absolute !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
height: 100vh !important;
|
height: 100vh !important;
|
||||||
width: 100vw !important;
|
width: 100vw !important;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,14 @@
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||||
<span>Restart</span>
|
<span>Restart</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="btnSound" class="btn btn-secondary" title="Toggle Sound FX (Chomps, Bites, Chimes)">
|
||||||
|
<span id="soundIcon">🔊</span>
|
||||||
|
<span id="soundText">Sound</span>
|
||||||
|
</button>
|
||||||
|
<button id="btnMusic" class="btn btn-secondary" title="Toggle Elevator Music BGM">
|
||||||
|
<span id="musicIcon">🎵</span>
|
||||||
|
<span id="musicText">Music</span>
|
||||||
|
</button>
|
||||||
<button id="btnFullscreen" class="btn btn-secondary" title="Fullscreen Screen Saver Mode">
|
<button id="btnFullscreen" class="btn btn-secondary" title="Fullscreen Screen Saver Mode">
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>
|
||||||
<span>Fullscreen</span>
|
<span>Fullscreen</span>
|
||||||
|
|
|
||||||
469
js/audio.js
Normal file
469
js/audio.js
Normal file
|
|
@ -0,0 +1,469 @@
|
||||||
|
/**
|
||||||
|
* Synthesized Web Audio Sound Manager
|
||||||
|
* Features:
|
||||||
|
* 1. Retro 8-bit / MIDI procedural sound effects for sheep nibbles, wolf bites, mating chimes, and sprout bloops.
|
||||||
|
* Tuned to be soft, gentle, and non-intrusive.
|
||||||
|
* 2. Chill, slow, melodic loopable background music ("elevator music" / lo-fi bossa nova).
|
||||||
|
* 3. 100% INDEPENDENT Sound FX and Music toggles — muting sound never affects music, and vice versa.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class SoundManager {
|
||||||
|
constructor() {
|
||||||
|
this.ctx = null;
|
||||||
|
this.isMuted = false; // Controls Sound FX (chomps, bites, bloops)
|
||||||
|
this.musicEnabled = true; // Controls Elevator Music BGM independently
|
||||||
|
this.isMusicPlaying = false;
|
||||||
|
|
||||||
|
// Volume levels (soft, balanced, comfortable listening)
|
||||||
|
this.sfxVolume = 0.45;
|
||||||
|
this.musicVolume = 0.15;
|
||||||
|
|
||||||
|
this.sfxGain = null;
|
||||||
|
this.musicGain = null;
|
||||||
|
|
||||||
|
// Polyphony throttle to prevent distortion or rapid volume spikes during large flock grazing
|
||||||
|
this.lastSheepChompTime = 0;
|
||||||
|
this.lastWolfChompTime = 0;
|
||||||
|
this.lastSproutTime = 0;
|
||||||
|
|
||||||
|
// Music scheduler state
|
||||||
|
this.musicTimer = null;
|
||||||
|
this.currentStep = 0;
|
||||||
|
this.nextNoteTime = 0;
|
||||||
|
this.tempo = 66; // BPM (slow, relaxed elevator music tempo)
|
||||||
|
this.secondsPerBeat = 60.0 / this.tempo;
|
||||||
|
this.secondsPerStep = this.secondsPerBeat / 2; // Eighth note steps
|
||||||
|
|
||||||
|
// Load saved preferences
|
||||||
|
const savedMuted = localStorage.getItem('LifeSimulator_Muted');
|
||||||
|
if (savedMuted !== null) {
|
||||||
|
this.isMuted = savedMuted === 'true';
|
||||||
|
}
|
||||||
|
const savedMusic = localStorage.getItem('LifeSimulator_MusicEnabled');
|
||||||
|
if (savedMusic !== null) {
|
||||||
|
this.musicEnabled = savedMusic === 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureContext() {
|
||||||
|
if (!this.ctx) {
|
||||||
|
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (AudioCtx) {
|
||||||
|
this.ctx = new AudioCtx();
|
||||||
|
|
||||||
|
// 1. Independent SFX bus -> connects directly to speakers
|
||||||
|
this.sfxGain = this.ctx.createGain();
|
||||||
|
this.sfxGain.gain.setValueAtTime(this.isMuted ? 0 : this.sfxVolume, this.ctx.currentTime);
|
||||||
|
this.sfxGain.connect(this.ctx.destination);
|
||||||
|
|
||||||
|
// 2. Independent Music bus -> connects directly to speakers (never affected by sfx mute)
|
||||||
|
this.musicGain = this.ctx.createGain();
|
||||||
|
this.musicGain.gain.setValueAtTime(this.musicEnabled ? this.musicVolume : 0, this.ctx.currentTime);
|
||||||
|
this.musicGain.connect(this.ctx.destination);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.ctx && this.ctx.state === 'suspended') {
|
||||||
|
this.ctx.resume().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles Sound FX ON / OFF independently of Music
|
||||||
|
*/
|
||||||
|
toggleMute() {
|
||||||
|
this.ensureContext();
|
||||||
|
this.isMuted = !this.isMuted;
|
||||||
|
localStorage.setItem('LifeSimulator_Muted', this.isMuted.toString());
|
||||||
|
|
||||||
|
if (this.sfxGain && this.ctx) {
|
||||||
|
this.sfxGain.gain.setTargetAtTime(
|
||||||
|
this.isMuted ? 0 : this.sfxVolume,
|
||||||
|
this.ctx.currentTime,
|
||||||
|
0.03
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.isMuted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles Elevator Music ON / OFF independently of Sound FX
|
||||||
|
*/
|
||||||
|
toggleMusic() {
|
||||||
|
this.ensureContext();
|
||||||
|
this.musicEnabled = !this.musicEnabled;
|
||||||
|
localStorage.setItem('LifeSimulator_MusicEnabled', this.musicEnabled.toString());
|
||||||
|
|
||||||
|
if (this.musicGain && this.ctx) {
|
||||||
|
this.musicGain.gain.setTargetAtTime(
|
||||||
|
this.musicEnabled ? this.musicVolume : 0,
|
||||||
|
this.ctx.currentTime,
|
||||||
|
0.04
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.musicEnabled) {
|
||||||
|
this.startMusic();
|
||||||
|
} else {
|
||||||
|
this.stopMusic();
|
||||||
|
}
|
||||||
|
return this.musicEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 1. PROCEDURAL "ELEVATOR MUSIC" CHILL BGM (LO-FI BOSSA / MEADOW ELEVATOR)
|
||||||
|
// =========================================================================
|
||||||
|
startMusic() {
|
||||||
|
if (!this.musicEnabled) return;
|
||||||
|
this.ensureContext();
|
||||||
|
if (!this.ctx || this.isMusicPlaying) return;
|
||||||
|
|
||||||
|
this.isMusicPlaying = true;
|
||||||
|
this.currentStep = 0;
|
||||||
|
this.nextNoteTime = this.ctx.currentTime + 0.1;
|
||||||
|
this._scheduleLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
stopMusic() {
|
||||||
|
this.isMusicPlaying = false;
|
||||||
|
if (this.musicTimer) {
|
||||||
|
clearTimeout(this.musicTimer);
|
||||||
|
this.musicTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleLoop() {
|
||||||
|
if (!this.isMusicPlaying || !this.ctx) return;
|
||||||
|
|
||||||
|
// Lookahead scheduling (schedule up to 0.4s into future for gapless playback)
|
||||||
|
const scheduleAheadTime = 0.4;
|
||||||
|
while (this.nextNoteTime < this.ctx.currentTime + scheduleAheadTime) {
|
||||||
|
this._playStep(this.currentStep, this.nextNoteTime);
|
||||||
|
this.nextNoteTime += this.secondsPerStep;
|
||||||
|
this.currentStep = (this.currentStep + 1) % 64; // 8 bars * 8 eighth notes = 64 steps
|
||||||
|
}
|
||||||
|
|
||||||
|
this.musicTimer = setTimeout(() => this._scheduleLoop(), 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
_playStep(step, time) {
|
||||||
|
if (!this.musicEnabled) return;
|
||||||
|
|
||||||
|
const bar = Math.floor(step / 8);
|
||||||
|
const stepInBar = step % 8;
|
||||||
|
|
||||||
|
const chords = [
|
||||||
|
// 0: Cmaj7 (C3, G3, B3, E4)
|
||||||
|
{ root: 130.81, notes: [130.81, 196.00, 246.94, 329.63] },
|
||||||
|
// 1: Am7 (A2, E3, G3, C4)
|
||||||
|
{ root: 110.00, notes: [110.00, 164.81, 196.00, 261.63] },
|
||||||
|
// 2: Dm7 (D3, A3, C4, F4)
|
||||||
|
{ root: 146.83, notes: [146.83, 220.00, 261.63, 349.23] },
|
||||||
|
// 3: G7 (G2, D3, F3, B3)
|
||||||
|
{ root: 98.00, notes: [98.00, 146.83, 174.61, 246.94] },
|
||||||
|
// 4: Em7 (E3, B3, D4, G4)
|
||||||
|
{ root: 164.81, notes: [164.81, 246.94, 293.66, 392.00] },
|
||||||
|
// 5: A7 (A2, E3, G3, C#4)
|
||||||
|
{ root: 110.00, notes: [110.00, 164.81, 196.00, 277.18] },
|
||||||
|
// 6: Dm7 (D3, A3, C4, F4)
|
||||||
|
{ root: 146.83, notes: [146.83, 220.00, 261.63, 349.23] },
|
||||||
|
// 7: G7sus4 (G2, D3, F3, C4) -> G7 (B3)
|
||||||
|
{ root: 98.00, notes: [98.00, 146.83, 174.61, stepInBar < 4 ? 261.63 : 246.94] }
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentChord = chords[bar];
|
||||||
|
|
||||||
|
// A. Warm Rhodes / Electric Piano Chords (Gentle strum on beats 0, 3, 5 for bossa elevator vibe)
|
||||||
|
if (stepInBar === 0 || stepInBar === 3 || stepInBar === 5) {
|
||||||
|
const chordVel = stepInBar === 0 ? 0.08 : 0.055;
|
||||||
|
for (let n = 1; n < currentChord.notes.length; n++) {
|
||||||
|
this._synthRhodesKey(time + (n - 1) * 0.012, currentChord.notes[n], 0.65, chordVel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// B. Soft Walking Bass (Downbeats 0 and 4)
|
||||||
|
if (stepInBar === 0) {
|
||||||
|
this._synthBassNote(time, currentChord.root, 0.45, 0.11);
|
||||||
|
} else if (stepInBar === 4) {
|
||||||
|
this._synthBassNote(time, currentChord.notes[1] * 0.5, 0.4, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
// C. Relaxing Melodic Motifs (Soulful, slow pentatonic lines floating above)
|
||||||
|
const melodyMap = {
|
||||||
|
2: 392.00, // G4
|
||||||
|
4: 493.88, // B4
|
||||||
|
6: 523.25, // C5
|
||||||
|
8: 493.88, // B4
|
||||||
|
11: 440.00, // A4
|
||||||
|
13: 392.00, // G4
|
||||||
|
18: 349.23, // F4
|
||||||
|
20: 392.00, // G4
|
||||||
|
22: 440.00, // A4
|
||||||
|
26: 392.00, // G4
|
||||||
|
28: 293.66, // D4
|
||||||
|
34: 587.33, // D5
|
||||||
|
36: 523.25, // C5
|
||||||
|
38: 493.88, // B4
|
||||||
|
42: 440.00, // A4
|
||||||
|
44: 415.30, // G#4
|
||||||
|
46: 440.00, // A4
|
||||||
|
50: 392.00, // G4
|
||||||
|
52: 349.23, // F4
|
||||||
|
54: 329.63, // E4
|
||||||
|
58: 293.66, // D4
|
||||||
|
60: 246.94 // B3
|
||||||
|
};
|
||||||
|
|
||||||
|
if (melodyMap[step]) {
|
||||||
|
this._synthMelodyNote(time, melodyMap[step], 0.5, 0.075);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_synthRhodesKey(time, freq, duration, vel) {
|
||||||
|
if (!this.ctx || !this.musicGain) return;
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
const filter = this.ctx.createBiquadFilter();
|
||||||
|
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.setValueAtTime(freq, time);
|
||||||
|
|
||||||
|
filter.type = 'lowpass';
|
||||||
|
filter.frequency.setValueAtTime(750, time);
|
||||||
|
filter.frequency.exponentialRampToValueAtTime(320, time + duration);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(vel, time);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, time + duration);
|
||||||
|
|
||||||
|
osc.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(this.musicGain);
|
||||||
|
|
||||||
|
osc.start(time);
|
||||||
|
osc.stop(time + duration + 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
_synthBassNote(time, freq, duration, vel) {
|
||||||
|
if (!this.ctx || !this.musicGain) return;
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
const filter = this.ctx.createBiquadFilter();
|
||||||
|
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(freq, time);
|
||||||
|
|
||||||
|
filter.type = 'lowpass';
|
||||||
|
filter.frequency.setValueAtTime(240, time);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(vel, time);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, time + duration);
|
||||||
|
|
||||||
|
osc.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(this.musicGain);
|
||||||
|
|
||||||
|
osc.start(time);
|
||||||
|
osc.stop(time + duration + 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
_synthMelodyNote(time, freq, duration, vel) {
|
||||||
|
if (!this.ctx || !this.musicGain) return;
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
const filter = this.ctx.createBiquadFilter();
|
||||||
|
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(freq, time);
|
||||||
|
|
||||||
|
filter.type = 'lowpass';
|
||||||
|
filter.frequency.setValueAtTime(1200, time);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(vel, time);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, time + duration);
|
||||||
|
|
||||||
|
osc.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(this.musicGain);
|
||||||
|
|
||||||
|
osc.start(time);
|
||||||
|
osc.stop(time + duration + 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 2. PROCEDURAL SOUND EFFECTS (SOFTER, GENTLE CHOMPS, BITES, BLOOPS)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sheep Nibble / Chomp Sound
|
||||||
|
* Soft, gentle, subtle retro pasture munch: low-gain dual micro-nibble.
|
||||||
|
*/
|
||||||
|
playSheepChomp() {
|
||||||
|
if (this.isMuted) return;
|
||||||
|
this.ensureContext();
|
||||||
|
if (!this.ctx || this.ctx.state !== 'running') return;
|
||||||
|
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
if (now - this.lastSheepChompTime < 0.14) return; // Polyphony throttle
|
||||||
|
this.lastSheepChompTime = now;
|
||||||
|
|
||||||
|
// Pitch variation
|
||||||
|
const pitchMod = 0.93 + Math.random() * 0.14;
|
||||||
|
const baseFreq = 440 * pitchMod;
|
||||||
|
|
||||||
|
// Soft nibble clicks (much lower, comfortable volume level)
|
||||||
|
this._synthChompClick(now, baseFreq, 0.045, 0.065);
|
||||||
|
this._synthChompClick(now + 0.04, baseFreq * 0.82, 0.05, 0.075);
|
||||||
|
}
|
||||||
|
|
||||||
|
_synthChompClick(startTime, freq, duration, gainLevel) {
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
const filter = this.ctx.createBiquadFilter();
|
||||||
|
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.setValueAtTime(freq, startTime);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(freq * 0.5, startTime + duration);
|
||||||
|
|
||||||
|
filter.type = 'bandpass';
|
||||||
|
filter.frequency.setValueAtTime(freq * 1.5, startTime);
|
||||||
|
filter.Q.setValueAtTime(2.2, startTime);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(gainLevel, startTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
|
||||||
|
|
||||||
|
osc.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(this.sfxGain);
|
||||||
|
|
||||||
|
osc.start(startTime);
|
||||||
|
osc.stop(startTime + duration + 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wolf Predatory Bite / Chomp Sound
|
||||||
|
* Deep arcade crunch: comfortable, softened volume level.
|
||||||
|
*/
|
||||||
|
playWolfChomp() {
|
||||||
|
if (this.isMuted) return;
|
||||||
|
this.ensureContext();
|
||||||
|
if (!this.ctx || this.ctx.state !== 'running') return;
|
||||||
|
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
if (now - this.lastWolfChompTime < 0.16) return;
|
||||||
|
this.lastWolfChompTime = now;
|
||||||
|
|
||||||
|
const pitchMod = 0.90 + Math.random() * 0.20;
|
||||||
|
const startTime = now;
|
||||||
|
const duration = 0.11;
|
||||||
|
|
||||||
|
// Meat bite body
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const oscGain = this.ctx.createGain();
|
||||||
|
osc.type = 'sawtooth';
|
||||||
|
osc.frequency.setValueAtTime(230 * pitchMod, startTime);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(60 * pitchMod, startTime + duration);
|
||||||
|
|
||||||
|
// Softened body volume
|
||||||
|
oscGain.gain.setValueAtTime(0.12, startTime);
|
||||||
|
oscGain.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
|
||||||
|
|
||||||
|
const filter = this.ctx.createBiquadFilter();
|
||||||
|
filter.type = 'lowpass';
|
||||||
|
filter.frequency.setValueAtTime(650, startTime);
|
||||||
|
filter.frequency.exponentialRampToValueAtTime(160, startTime + duration);
|
||||||
|
|
||||||
|
osc.connect(filter);
|
||||||
|
filter.connect(oscGain);
|
||||||
|
oscGain.connect(this.sfxGain);
|
||||||
|
|
||||||
|
osc.start(startTime);
|
||||||
|
osc.stop(startTime + duration + 0.01);
|
||||||
|
|
||||||
|
// Soft teeth crunch snap
|
||||||
|
this._synthNoiseSnap(startTime, 0.04, 0.065);
|
||||||
|
}
|
||||||
|
|
||||||
|
_synthNoiseSnap(startTime, duration, volume) {
|
||||||
|
const bufferSize = Math.floor(this.ctx.sampleRate * duration);
|
||||||
|
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
|
||||||
|
const output = buffer.getChannelData(0);
|
||||||
|
for (let i = 0; i < bufferSize; i++) {
|
||||||
|
output[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
const whiteNoise = this.ctx.createBufferSource();
|
||||||
|
whiteNoise.buffer = buffer;
|
||||||
|
|
||||||
|
const filter = this.ctx.createBiquadFilter();
|
||||||
|
filter.type = 'highpass';
|
||||||
|
filter.frequency.setValueAtTime(1000, startTime);
|
||||||
|
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
gain.gain.setValueAtTime(volume, startTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
|
||||||
|
|
||||||
|
whiteNoise.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(this.sfxGain);
|
||||||
|
|
||||||
|
whiteNoise.start(startTime);
|
||||||
|
whiteNoise.stop(startTime + duration + 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mating Heart Chime
|
||||||
|
*/
|
||||||
|
playHeartChime() {
|
||||||
|
if (this.isMuted) return;
|
||||||
|
this.ensureContext();
|
||||||
|
if (!this.ctx || this.ctx.state !== 'running') return;
|
||||||
|
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
const playNote = (time, freq) => {
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(freq, time);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.08, time);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.14);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.sfxGain);
|
||||||
|
|
||||||
|
osc.start(time);
|
||||||
|
osc.stop(time + 0.15);
|
||||||
|
};
|
||||||
|
|
||||||
|
playNote(now, 523.25); // C5
|
||||||
|
playNote(now + 0.07, 783.99); // G5
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rain Cloud Plant Sprout Bloop
|
||||||
|
*/
|
||||||
|
playSproutChime() {
|
||||||
|
if (this.isMuted) return;
|
||||||
|
this.ensureContext();
|
||||||
|
if (!this.ctx || this.ctx.state !== 'running') return;
|
||||||
|
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
if (now - this.lastSproutTime < 0.25) return;
|
||||||
|
this.lastSproutTime = now;
|
||||||
|
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(320, now);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(640, now + 0.1);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.07, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.sfxGain);
|
||||||
|
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.13);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ export class Sheep extends Animal {
|
||||||
this.grazingTimer = 0;
|
this.grazingTimer = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
update(dt, worldWidth, worldHeight, config, plants, wolves, allSheep, particleSystem, onBabyBorn, terrain = null) {
|
update(dt, worldWidth, worldHeight, config, plants, wolves, allSheep, particleSystem, onBabyBorn, terrain = null, audio = null) {
|
||||||
if (this.isDead) return;
|
if (this.isDead) return;
|
||||||
|
|
||||||
// Deplete hunger over time using config
|
// Deplete hunger over time using config
|
||||||
|
|
@ -89,6 +89,9 @@ export class Sheep extends Animal {
|
||||||
particleSystem.emitHeart(this.x, this.y);
|
particleSystem.emitHeart(this.x, this.y);
|
||||||
particleSystem.emitHeart(nearestMate.x, nearestMate.y);
|
particleSystem.emitHeart(nearestMate.x, nearestMate.y);
|
||||||
}
|
}
|
||||||
|
if (audio) {
|
||||||
|
audio.playHeartChime();
|
||||||
|
}
|
||||||
|
|
||||||
if (onBabyBorn) {
|
if (onBabyBorn) {
|
||||||
const babyX = (this.x + nearestMate.x) / 2 + (Math.random() - 0.5) * 12;
|
const babyX = (this.x + nearestMate.x) / 2 + (Math.random() - 0.5) * 12;
|
||||||
|
|
@ -131,6 +134,11 @@ export class Sheep extends Animal {
|
||||||
bestPlant.takeBite(); // removes the node immediately
|
bestPlant.takeBite(); // removes the node immediately
|
||||||
this.recordMeal(nutrition, particleSystem);
|
this.recordMeal(nutrition, particleSystem);
|
||||||
|
|
||||||
|
// Play synthesized retro MIDI chomp sound!
|
||||||
|
if (audio) {
|
||||||
|
audio.playSheepChomp();
|
||||||
|
}
|
||||||
|
|
||||||
if (terrain) {
|
if (terrain) {
|
||||||
terrain.absorbDecalsNear(bestPlant.x, bestPlant.y, 25);
|
terrain.absorbDecalsNear(bestPlant.x, bestPlant.y, 25);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export class Wolf extends Animal {
|
||||||
return !this.isBaby && this.hunger >= threshold && this.mateCooldown <= 0;
|
return !this.isBaby && this.hunger >= threshold && this.mateCooldown <= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
update(dt, worldWidth, worldHeight, config, sheepList, allWolves, terrain, particleSystem, onBabyBorn, onSheepKilled) {
|
update(dt, worldWidth, worldHeight, config, sheepList, allWolves, terrain, particleSystem, onBabyBorn, onSheepKilled, audio = null) {
|
||||||
if (this.isDead) return;
|
if (this.isDead) return;
|
||||||
|
|
||||||
// Deplete hunger over time (gentler for growing pups)
|
// Deplete hunger over time (gentler for growing pups)
|
||||||
|
|
@ -80,6 +80,9 @@ export class Wolf extends Animal {
|
||||||
particleSystem.emitHeart(this.x, this.y);
|
particleSystem.emitHeart(this.x, this.y);
|
||||||
particleSystem.emitHeart(nearestMate.x, nearestMate.y);
|
particleSystem.emitHeart(nearestMate.x, nearestMate.y);
|
||||||
}
|
}
|
||||||
|
if (audio) {
|
||||||
|
audio.playHeartChime();
|
||||||
|
}
|
||||||
|
|
||||||
if (onBabyBorn) {
|
if (onBabyBorn) {
|
||||||
const babyX = (this.x + nearestMate.x) / 2 + (Math.random() - 0.5) * 12;
|
const babyX = (this.x + nearestMate.x) / 2 + (Math.random() - 0.5) * 12;
|
||||||
|
|
@ -113,6 +116,9 @@ export class Wolf extends Animal {
|
||||||
if (minCarcassDist <= 24) {
|
if (minCarcassDist <= 24) {
|
||||||
// Feast on carcass
|
// Feast on carcass
|
||||||
this.recordMeal(35, particleSystem);
|
this.recordMeal(35, particleSystem);
|
||||||
|
if (audio) {
|
||||||
|
audio.playWolfChomp();
|
||||||
|
}
|
||||||
nearestCarcass.decayProgress = Math.min(1.0, nearestCarcass.decayProgress + 0.18);
|
nearestCarcass.decayProgress = Math.min(1.0, nearestCarcass.decayProgress + 0.18);
|
||||||
if (particleSystem) {
|
if (particleSystem) {
|
||||||
particleSystem.emitEatingCrumbs(nearestCarcass.x, nearestCarcass.y, '#8A1010', 4);
|
particleSystem.emitEatingCrumbs(nearestCarcass.x, nearestCarcass.y, '#8A1010', 4);
|
||||||
|
|
@ -177,6 +183,11 @@ export class Wolf extends Animal {
|
||||||
const biteAngle = Math.atan2(targetSheep.y - this.y, targetSheep.x - this.x);
|
const biteAngle = Math.atan2(targetSheep.y - this.y, targetSheep.x - this.x);
|
||||||
const sheepDied = targetSheep.takeDamage(biteDamage, biteAngle, particleSystem);
|
const sheepDied = targetSheep.takeDamage(biteDamage, biteAngle, particleSystem);
|
||||||
|
|
||||||
|
// Play synthesized arcade wolf chomp / crunch!
|
||||||
|
if (audio) {
|
||||||
|
audio.playWolfChomp();
|
||||||
|
}
|
||||||
|
|
||||||
// Stamp blood on the ground where attack occurred
|
// Stamp blood on the ground where attack occurred
|
||||||
if (terrain) {
|
if (terrain) {
|
||||||
terrain.addBloodSplotch(targetSheep.x, targetSheep.y, 4 + Math.random() * 3);
|
terrain.addBloodSplotch(targetSheep.x, targetSheep.y, 4 + Math.random() * 3);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { TerrainManager } from './terrain.js';
|
||||||
import { WeatherManager } from './weather.js';
|
import { WeatherManager } from './weather.js';
|
||||||
import { ParticleSystem } from './particles.js';
|
import { ParticleSystem } from './particles.js';
|
||||||
import { SimStats } from './state.js';
|
import { SimStats } from './state.js';
|
||||||
|
import { SoundManager } from './audio.js';
|
||||||
|
|
||||||
export class Simulation {
|
export class Simulation {
|
||||||
constructor(canvas, config) {
|
constructor(canvas, config) {
|
||||||
|
|
@ -29,6 +30,7 @@ export class Simulation {
|
||||||
this.weather = new WeatherManager(this.width, this.height);
|
this.weather = new WeatherManager(this.width, this.height);
|
||||||
this.particles = new ParticleSystem();
|
this.particles = new ParticleSystem();
|
||||||
this.stats = new SimStats();
|
this.stats = new SimStats();
|
||||||
|
this.audio = new SoundManager();
|
||||||
|
|
||||||
// Entity lists
|
// Entity lists
|
||||||
this.sheep = [];
|
this.sheep = [];
|
||||||
|
|
@ -133,8 +135,17 @@ export class Simulation {
|
||||||
if (newConfig) {
|
if (newConfig) {
|
||||||
this.config = { ...this.config, ...newConfig };
|
this.config = { ...this.config, ...newConfig };
|
||||||
}
|
}
|
||||||
|
if (this.weather) {
|
||||||
|
this.weather.reset();
|
||||||
|
}
|
||||||
this.init();
|
this.init();
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
|
if (this.audio) {
|
||||||
|
this.audio.ensureContext();
|
||||||
|
if (this.audio.musicEnabled) {
|
||||||
|
this.audio.startMusic();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resize(newWidth, newHeight) {
|
resize(newWidth, newHeight) {
|
||||||
|
|
@ -263,9 +274,34 @@ export class Simulation {
|
||||||
|
|
||||||
// Cap delta time to prevent physics explosions on background tab
|
// Cap delta time to prevent physics explosions on background tab
|
||||||
const dt = Math.min(0.1, rawDt) * this.speedMultiplier;
|
const dt = Math.min(0.1, rawDt) * this.speedMultiplier;
|
||||||
|
const maxPlantCap = this.config.maxPlantCap || 90;
|
||||||
|
|
||||||
// 1. Weather Update (applies rain to revitalize plants)
|
// 1. Weather Update & Cloud Rain Germination
|
||||||
this.weather.update(dt, this.config.rainFrequency, this.plants, this.particles);
|
// 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)
|
// 1b. Continuous Mode Migration Waves (Screen Saver / Endless Ecosystem)
|
||||||
if (this.config.continuousMode) {
|
if (this.config.continuousMode) {
|
||||||
|
|
@ -296,8 +332,6 @@ export class Simulation {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Terrain & Carcass Decomposition Update
|
// 2. Terrain & Carcass Decomposition Update
|
||||||
const maxPlantCap = this.config.maxPlantCap || 90;
|
|
||||||
|
|
||||||
this.terrain.update(
|
this.terrain.update(
|
||||||
dt,
|
dt,
|
||||||
this.config.decaySpeed,
|
this.config.decaySpeed,
|
||||||
|
|
@ -371,26 +405,10 @@ export class Simulation {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3b. Meadow Rejuvenation & Rain Germination
|
// 3b. Gentle meadow baseline (only if critically low plant count)
|
||||||
// Prevents desolate wasteland: Rain rolls in and germinates fresh grass seedlings in open soil
|
if (this.plants.length < 12) {
|
||||||
if (this.weather.rainIntensity > 0.25) {
|
|
||||||
this.rainSproutTimer = (this.rainSproutTimer || 0) + dt;
|
|
||||||
if (this.rainSproutTimer >= 1.3 && this.plants.length < maxPlantCap) {
|
|
||||||
this.rainSproutTimer = 0;
|
|
||||||
const rx = 35 + Math.random() * (this.width - 70);
|
|
||||||
const ry = 35 + Math.random() * (this.height - 70);
|
|
||||||
const loc = this.findClearPlantLocation(rx, ry, 24, 80);
|
|
||||||
if (loc) {
|
|
||||||
const freshGrass = new Plant(loc.x, loc.y, 'grass', 0.35, this.width, this.height);
|
|
||||||
this.plants.push(freshGrass);
|
|
||||||
this.terrain.absorbDecalsNear(loc.x, loc.y, 25);
|
|
||||||
this.stats.recordPlantSpawn(false, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (this.plants.length < 16) {
|
|
||||||
// Gentle meadow baseline so sheep always have a living ecosystem
|
|
||||||
this.soilSproutTimer = (this.soilSproutTimer || 0) + dt;
|
this.soilSproutTimer = (this.soilSproutTimer || 0) + dt;
|
||||||
if (this.soilSproutTimer >= 3.8 && this.plants.length < maxPlantCap) {
|
if (this.soilSproutTimer >= 3.5 && this.plants.length < maxPlantCap) {
|
||||||
this.soilSproutTimer = 0;
|
this.soilSproutTimer = 0;
|
||||||
const rx = 40 + Math.random() * (this.width - 80);
|
const rx = 40 + Math.random() * (this.width - 80);
|
||||||
const ry = 40 + Math.random() * (this.height - 80);
|
const ry = 40 + Math.random() * (this.height - 80);
|
||||||
|
|
@ -420,7 +438,8 @@ export class Simulation {
|
||||||
this.sheep.push(babySheep);
|
this.sheep.push(babySheep);
|
||||||
this.stats.recordSheepBirth(false);
|
this.stats.recordSheepBirth(false);
|
||||||
},
|
},
|
||||||
this.terrain
|
this.terrain,
|
||||||
|
this.audio
|
||||||
);
|
);
|
||||||
|
|
||||||
if (s.isDead) {
|
if (s.isDead) {
|
||||||
|
|
@ -450,7 +469,8 @@ export class Simulation {
|
||||||
},
|
},
|
||||||
(killedSheep, killerWolf) => {
|
(killedSheep, killerWolf) => {
|
||||||
// Handled via sheep.isDead and sheep.takeDamage
|
// Handled via sheep.isDead and sheep.takeDamage
|
||||||
}
|
},
|
||||||
|
this.audio
|
||||||
);
|
);
|
||||||
|
|
||||||
if (w.isDead) {
|
if (w.isDead) {
|
||||||
|
|
@ -585,6 +605,9 @@ export class Simulation {
|
||||||
// 1. Draw Terrain, Soil Decals & Carcasses
|
// 1. Draw Terrain, Soil Decals & Carcasses
|
||||||
this.terrain.draw(ctx);
|
this.terrain.draw(ctx);
|
||||||
|
|
||||||
|
// 1b. Draw Localized Wet Ground Trails (soaking meadow under and behind clouds)
|
||||||
|
this.weather.drawWetGround(ctx);
|
||||||
|
|
||||||
// 2. Draw Plants & Flowers
|
// 2. Draw Plants & Flowers
|
||||||
for (let i = 0; i < this.plants.length; i++) {
|
for (let i = 0; i < this.plants.length; i++) {
|
||||||
this.plants[i].draw(ctx, this.stats.elapsedSimTime);
|
this.plants[i].draw(ctx, this.stats.elapsedSimTime);
|
||||||
|
|
|
||||||
63
js/ui.js
63
js/ui.js
|
|
@ -12,12 +12,19 @@ export class UIManager {
|
||||||
this.cacheDOMElements();
|
this.cacheDOMElements();
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
this.syncSlidersFromConfig();
|
this.syncSlidersFromConfig();
|
||||||
|
this.updateAudioButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheDOMElements() {
|
cacheDOMElements() {
|
||||||
// Primary Controls
|
// Primary Controls
|
||||||
this.btnPlayPause = document.getElementById('btnPlayPause');
|
this.btnPlayPause = document.getElementById('btnPlayPause');
|
||||||
this.btnRestart = document.getElementById('btnRestart');
|
this.btnRestart = document.getElementById('btnRestart');
|
||||||
|
this.btnSound = document.getElementById('btnSound');
|
||||||
|
this.soundIcon = document.getElementById('soundIcon');
|
||||||
|
this.soundText = document.getElementById('soundText');
|
||||||
|
this.btnMusic = document.getElementById('btnMusic');
|
||||||
|
this.musicIcon = document.getElementById('musicIcon');
|
||||||
|
this.musicText = document.getElementById('musicText');
|
||||||
this.btnFullscreen = document.getElementById('btnFullscreen');
|
this.btnFullscreen = document.getElementById('btnFullscreen');
|
||||||
this.btnExitFullscreen = document.getElementById('btnExitFullscreen');
|
this.btnExitFullscreen = document.getElementById('btnExitFullscreen');
|
||||||
this.btnSetDefaults = document.getElementById('btnSetDefaults');
|
this.btnSetDefaults = document.getElementById('btnSetDefaults');
|
||||||
|
|
@ -141,9 +148,29 @@ export class UIManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
bindEvents() {
|
bindEvents() {
|
||||||
|
// Audio gesture unlock for browser autoplay compliance
|
||||||
|
const resumeAudioOnGesture = () => {
|
||||||
|
if (this.sim && this.sim.audio) {
|
||||||
|
this.sim.audio.ensureContext();
|
||||||
|
if (this.sim.isRunning && this.sim.audio.musicEnabled && !this.sim.audio.isMusicPlaying) {
|
||||||
|
this.sim.audio.startMusic();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('pointerdown', resumeAudioOnGesture, { once: true });
|
||||||
|
window.addEventListener('keydown', resumeAudioOnGesture, { once: true });
|
||||||
|
|
||||||
// 1. Play / Pause
|
// 1. Play / Pause
|
||||||
this.btnPlayPause.addEventListener('click', () => {
|
this.btnPlayPause.addEventListener('click', () => {
|
||||||
|
if (this.sim.audio) {
|
||||||
|
this.sim.audio.ensureContext();
|
||||||
|
}
|
||||||
this.sim.isRunning = !this.sim.isRunning;
|
this.sim.isRunning = !this.sim.isRunning;
|
||||||
|
if (this.sim.isRunning && this.sim.audio && this.sim.audio.musicEnabled) {
|
||||||
|
this.sim.audio.startMusic();
|
||||||
|
} else if (!this.sim.isRunning && this.sim.audio) {
|
||||||
|
this.sim.audio.stopMusic();
|
||||||
|
}
|
||||||
this.updatePlayPauseButton();
|
this.updatePlayPauseButton();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -155,6 +182,24 @@ export class UIManager {
|
||||||
this.updatePlayPauseButton();
|
this.updatePlayPauseButton();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 2b. Sound Effects Toggle (Midi Chomps, Bites, Chimes)
|
||||||
|
if (this.btnSound) {
|
||||||
|
this.btnSound.addEventListener('click', () => {
|
||||||
|
const isMuted = this.sim.audio.toggleMute();
|
||||||
|
this.updateAudioButtons();
|
||||||
|
this.showToast(isMuted ? '🔇 Sound FX Muted' : '🔊 Sound FX Enabled');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2c. Elevator Music BGM Toggle
|
||||||
|
if (this.btnMusic) {
|
||||||
|
this.btnMusic.addEventListener('click', () => {
|
||||||
|
const isMusicOn = this.sim.audio.toggleMusic();
|
||||||
|
this.updateAudioButtons();
|
||||||
|
this.showToast(isMusicOn ? '🎵 Elevator Music Playing' : '🎶 Elevator Music Paused');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Save current settings as custom defaults
|
// 3. Save current settings as custom defaults
|
||||||
if (this.btnSetDefaults) {
|
if (this.btnSetDefaults) {
|
||||||
this.btnSetDefaults.addEventListener('click', () => {
|
this.btnSetDefaults.addEventListener('click', () => {
|
||||||
|
|
@ -308,6 +353,24 @@ export class UIManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateAudioButtons() {
|
||||||
|
if (this.sim && this.sim.audio) {
|
||||||
|
const isMuted = this.sim.audio.isMuted;
|
||||||
|
if (this.soundIcon) this.soundIcon.textContent = isMuted ? '🔇' : '🔊';
|
||||||
|
if (this.soundText) this.soundText.textContent = isMuted ? 'Muted' : 'Sound';
|
||||||
|
if (this.btnSound) {
|
||||||
|
this.btnSound.classList.toggle('btn-muted', isMuted);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMusic = this.sim.audio.musicEnabled;
|
||||||
|
if (this.musicIcon) this.musicIcon.textContent = isMusic ? '🎵' : '🎶';
|
||||||
|
if (this.musicText) this.musicText.textContent = isMusic ? 'Music' : 'Music Off';
|
||||||
|
if (this.btnMusic) {
|
||||||
|
this.btnMusic.classList.toggle('btn-muted', !isMusic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enterFullscreen() {
|
enterFullscreen() {
|
||||||
this.isFullscreen = true;
|
this.isFullscreen = true;
|
||||||
const appLayout = document.querySelector('.app-layout');
|
const appLayout = document.querySelector('.app-layout');
|
||||||
|
|
|
||||||
254
js/weather.js
254
js/weather.js
|
|
@ -1,11 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* Weather & Rain Simulation System
|
* Weather, Cloud Rain & Localized Meadow Wetness System
|
||||||
* Features procedural multi-lobed organic clouds with 3D volume shading,
|
* Features:
|
||||||
* realistic drifting ground shadows, weather cycles, raindrops, and plant hydration.
|
* 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 = [
|
const CLOUD_PRESETS = [
|
||||||
// Preset 0: Majestic Cumulus (Tall center dome with billowing shoulders, flanking wisps, and flat base)
|
// Preset 0: Majestic Cumulus
|
||||||
{
|
{
|
||||||
lobes: [
|
lobes: [
|
||||||
{ x: 0, y: -26, r: 50 }, // Central peak
|
{ x: 0, y: -26, r: 50 }, // Central peak
|
||||||
|
|
@ -24,7 +28,7 @@ const CLOUD_PRESETS = [
|
||||||
width: 250,
|
width: 250,
|
||||||
height: 105
|
height: 105
|
||||||
},
|
},
|
||||||
// Preset 1: Wide Billowing Cloud Bank (Broad rolling billows with undulating tops)
|
// Preset 1: Wide Billowing Cloud Bank
|
||||||
{
|
{
|
||||||
lobes: [
|
lobes: [
|
||||||
{ x: -38, y: -28, r: 48 },
|
{ x: -38, y: -28, r: 48 },
|
||||||
|
|
@ -43,7 +47,7 @@ const CLOUD_PRESETS = [
|
||||||
width: 320,
|
width: 320,
|
||||||
height: 115
|
height: 115
|
||||||
},
|
},
|
||||||
// Preset 2: Compact Fluffy Cotton Puff (Cute rounded cloud with soft bubbling lobes)
|
// Preset 2: Compact Fluffy Cotton Puff
|
||||||
{
|
{
|
||||||
lobes: [
|
lobes: [
|
||||||
{ x: 0, y: -20, r: 42 },
|
{ x: 0, y: -20, r: 42 },
|
||||||
|
|
@ -60,7 +64,7 @@ const CLOUD_PRESETS = [
|
||||||
width: 175,
|
width: 175,
|
||||||
height: 85
|
height: 85
|
||||||
},
|
},
|
||||||
// Preset 3: Drifting Stratocumulus (Elongated windswept cloud bank)
|
// Preset 3: Drifting Stratocumulus
|
||||||
{
|
{
|
||||||
lobes: [
|
lobes: [
|
||||||
{ x: -55, y: -18, r: 40 },
|
{ x: -55, y: -18, r: 40 },
|
||||||
|
|
@ -91,28 +95,24 @@ export class WeatherManager {
|
||||||
this.rainIntensity = 0; // 0.0 to 1.0 (smooth fade in/out)
|
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)
|
this.cloudDensity = 0.35; // 0.2 (clear day) to 1.0 (overcast storm)
|
||||||
|
|
||||||
// Procedural cloud entities
|
// Procedural cloud entities with dedicated rain particle pools
|
||||||
this.clouds = [];
|
this.clouds = [];
|
||||||
this.initClouds();
|
this.initClouds();
|
||||||
|
|
||||||
// Raindrop particles
|
// Persistent wet ground trail zones
|
||||||
this.raindrops = [];
|
this.wetZones = [];
|
||||||
this.maxDrops = 280;
|
|
||||||
this.initRaindrops();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initClouds() {
|
initClouds() {
|
||||||
this.clouds = [];
|
this.clouds = [];
|
||||||
// Number of clouds scales naturally with meadow width
|
|
||||||
const count = Math.max(5, Math.ceil(this.width / 220));
|
const count = Math.max(5, Math.ceil(this.width / 220));
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const preset = CLOUD_PRESETS[i % CLOUD_PRESETS.length];
|
const preset = CLOUD_PRESETS[i % CLOUD_PRESETS.length];
|
||||||
const scale = 0.8 + Math.random() * 0.45;
|
const scale = 0.8 + Math.random() * 0.45;
|
||||||
const speed = 14 + Math.random() * 16;
|
const speed = 14 + Math.random() * 16;
|
||||||
// Stagger across the entire width plus extra margin
|
const x = -120 + (i / count) * (this.width + 350);
|
||||||
const x = -100 + (i / count) * (this.width + 300);
|
const y = 25 + Math.random() * (this.height * 0.6);
|
||||||
const y = 30 + Math.random() * (this.height * 0.65);
|
|
||||||
|
|
||||||
this.clouds.push({
|
this.clouds.push({
|
||||||
x,
|
x,
|
||||||
|
|
@ -123,23 +123,12 @@ export class WeatherManager {
|
||||||
lobes: preset.lobes,
|
lobes: preset.lobes,
|
||||||
width: preset.width * scale,
|
width: preset.width * scale,
|
||||||
height: preset.height * scale,
|
height: preset.height * scale,
|
||||||
shadowOffsetY: 90 + Math.random() * 60,
|
shadowOffsetY: 85 + Math.random() * 55,
|
||||||
swayOffset: Math.random() * Math.PI * 2
|
swayOffset: Math.random() * Math.PI * 2,
|
||||||
});
|
isRaining: false,
|
||||||
}
|
raindrops: [],
|
||||||
}
|
sproutTimer: Math.random() * 1.5,
|
||||||
|
wetStampTimer: 0,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -149,16 +138,13 @@ export class WeatherManager {
|
||||||
this.width = newWidth;
|
this.width = newWidth;
|
||||||
this.height = newHeight;
|
this.height = newHeight;
|
||||||
|
|
||||||
this.initRaindrops();
|
|
||||||
|
|
||||||
// Ensure clouds span the new meadow width
|
|
||||||
if (this.clouds.length < Math.ceil(newWidth / 220)) {
|
if (this.clouds.length < Math.ceil(newWidth / 220)) {
|
||||||
this.initClouds();
|
this.initClouds();
|
||||||
} else {
|
} else {
|
||||||
for (let i = 0; i < this.clouds.length; i++) {
|
for (let i = 0; i < this.clouds.length; i++) {
|
||||||
const c = this.clouds[i];
|
const c = this.clouds[i];
|
||||||
if (c.y > newHeight * 0.75) {
|
if (c.y > newHeight * 0.7) {
|
||||||
c.y = 30 + Math.random() * (newHeight * 0.65);
|
c.y = 25 + Math.random() * (newHeight * 0.6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -169,25 +155,28 @@ export class WeatherManager {
|
||||||
this.timer = 15;
|
this.timer = 15;
|
||||||
this.rainIntensity = 0;
|
this.rainIntensity = 0;
|
||||||
this.cloudDensity = 0.35;
|
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() {
|
forceRain() {
|
||||||
this.state = 'raining';
|
this.state = 'raining';
|
||||||
this.timer = 15;
|
this.timer = 20;
|
||||||
this.rainIntensity = 1.0;
|
this.rainIntensity = 1.0;
|
||||||
this.cloudDensity = 1.0;
|
this.cloudDensity = 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
update(dt, rainSliderValue, plants, particleSystem) {
|
update(dt, rainSliderValue, plants, particleSystem, onWetSprout = null) {
|
||||||
// rainSliderValue is 0 - 100
|
|
||||||
// Higher slider = shorter sunny periods, longer rain duration
|
|
||||||
const sliderNorm = Math.max(0.05, rainSliderValue / 100);
|
const sliderNorm = Math.max(0.05, rainSliderValue / 100);
|
||||||
const sunnyDuration = (1 - sliderNorm) * 35 + 8; // e.g. 10 to 43 sec
|
const sunnyDuration = (1 - sliderNorm) * 35 + 8;
|
||||||
const rainDuration = sliderNorm * 18 + 7; // e.g. 8 to 25 sec
|
const rainDuration = sliderNorm * 20 + 8;
|
||||||
|
|
||||||
this.timer -= dt;
|
this.timer -= dt;
|
||||||
|
|
||||||
// Weather state machine
|
// Weather transition state machine
|
||||||
if (this.timer <= 0) {
|
if (this.timer <= 0) {
|
||||||
switch (this.state) {
|
switch (this.state) {
|
||||||
case 'sunny':
|
case 'sunny':
|
||||||
|
|
@ -214,7 +203,7 @@ export class WeatherManager {
|
||||||
this.rainIntensity = Math.min(1.0, this.rainIntensity + dt * 0.75);
|
this.rainIntensity = Math.min(1.0, this.rainIntensity + dt * 0.75);
|
||||||
this.cloudDensity = Math.min(1.0, this.cloudDensity + dt * 0.6);
|
this.cloudDensity = Math.min(1.0, this.cloudDensity + dt * 0.6);
|
||||||
} else if (this.state === 'cloudy') {
|
} else if (this.state === 'cloudy') {
|
||||||
this.rainIntensity = Math.min(0.25, this.rainIntensity + dt * 0.35);
|
this.rainIntensity = Math.min(0.2, this.rainIntensity + dt * 0.35);
|
||||||
this.cloudDensity = Math.min(0.85, this.cloudDensity + dt * 0.5);
|
this.cloudDensity = Math.min(0.85, this.cloudDensity + dt * 0.5);
|
||||||
} else if (this.state === 'clearing') {
|
} else if (this.state === 'clearing') {
|
||||||
this.rainIntensity = Math.max(0.0, this.rainIntensity - dt * 0.45);
|
this.rainIntensity = Math.max(0.0, this.rainIntensity - dt * 0.45);
|
||||||
|
|
@ -225,63 +214,150 @@ export class WeatherManager {
|
||||||
this.cloudDensity = Math.max(0.35, this.cloudDensity - dt * 0.2);
|
this.cloudDensity = Math.max(0.35, this.cloudDensity - dt * 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// When raining, revitalize all plants!
|
// Update persistent wet ground zones
|
||||||
if (this.rainIntensity > 0.1 && plants && plants.length > 0) {
|
for (let i = this.wetZones.length - 1; i >= 0; i--) {
|
||||||
const hydrationRate = 22 * this.rainIntensity; // moisture per second
|
const zone = this.wetZones[i];
|
||||||
for (let i = 0; i < plants.length; i++) {
|
zone.life -= dt;
|
||||||
plants[i].receiveRain(hydrationRate * dt);
|
zone.alpha = Math.max(0, (zone.life / zone.maxLife) * 0.35);
|
||||||
|
if (zone.life <= 0) {
|
||||||
|
this.wetZones.splice(i, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animate procedural clouds drifting across the sky
|
// Drift clouds across the sky & emit localized rain
|
||||||
const windSpeedMult = 1.0 + this.rainIntensity * 0.75;
|
const windSpeedMult = 1.0 + this.rainIntensity * 0.75;
|
||||||
|
const isCloudRainActive = this.rainIntensity > 0.05;
|
||||||
|
|
||||||
for (let i = 0; i < this.clouds.length; i++) {
|
for (let i = 0; i < this.clouds.length; i++) {
|
||||||
const cloud = this.clouds[i];
|
const cloud = this.clouds[i];
|
||||||
cloud.x += cloud.speed * windSpeedMult * dt;
|
cloud.x += cloud.speed * windSpeedMult * dt;
|
||||||
|
|
||||||
// Wrap around cleanly when fully offscreen to the right
|
// Wrap around when fully past screen right edge
|
||||||
if (cloud.x - cloud.width * 0.6 > this.width + 120) {
|
if (cloud.x - cloud.width * 0.6 > this.width + 120) {
|
||||||
cloud.x = -cloud.width * 0.6 - 60;
|
cloud.x = -cloud.width * 0.6 - 80;
|
||||||
cloud.y = 25 + Math.random() * (this.height * 0.65);
|
cloud.y = 25 + Math.random() * (this.height * 0.6);
|
||||||
cloud.scale = 0.8 + Math.random() * 0.45;
|
cloud.scale = 0.8 + Math.random() * 0.45;
|
||||||
cloud.speed = 14 + Math.random() * 16;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animate raindrops
|
// Sprout new flora behind the cloud in its freshly wet trailing wake!
|
||||||
if (this.rainIntensity > 0.05) {
|
cloud.sproutTimer = (cloud.sproutTimer || 0) + dt;
|
||||||
const activeDrops = Math.floor(this.maxDrops * this.rainIntensity);
|
if (cloud.sproutTimer >= 1.25 && onWetSprout) {
|
||||||
for (let i = 0; i < activeDrops; i++) {
|
cloud.sproutTimer = (Math.random() - 0.5) * 0.4; // Jitter timer
|
||||||
const drop = this.raindrops[i];
|
// Sprout location in the moist wake just behind the cloud
|
||||||
drop.x -= dt * 65; // realistic wind angle
|
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;
|
drop.y += drop.speed * dt;
|
||||||
|
|
||||||
if (drop.y > this.height) {
|
if (drop.y >= drop.groundY) {
|
||||||
drop.y = -drop.length;
|
// Impact splash ripple on ground
|
||||||
drop.x = Math.random() * (this.width + 160);
|
if (particleSystem && Math.random() < 0.25) {
|
||||||
|
particleSystem.emitSplash(drop.x, drop.groundY);
|
||||||
|
}
|
||||||
|
cloud.raindrops.splice(r, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ground splash ripple on impact
|
addWetZone(x, y, radius = 35) {
|
||||||
if (particleSystem && Math.random() < 0.2) {
|
// Keep wet zones bounded
|
||||||
particleSystem.emitSplash(drop.x, this.height - Math.random() * 30);
|
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) {
|
draw(ctx) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
|
|
||||||
// ==============================================================
|
// ==============================================================
|
||||||
// 1. ORGANIC CLOUD SHADOWS CAST ONTO THE MEADOW GROUND
|
// 1. ORGANIC CLOUD GROUND SHADOWS
|
||||||
// Realistic multi-lobed cloud silhouettes drifting over the grass
|
|
||||||
// ==============================================================
|
// ==============================================================
|
||||||
const shadowAlpha = 0.07 + this.cloudDensity * 0.16 + this.rainIntensity * 0.12;
|
const shadowAlpha = 0.07 + this.cloudDensity * 0.16 + this.rainIntensity * 0.12;
|
||||||
ctx.fillStyle = `rgba(10, 26, 14, ${shadowAlpha})`;
|
ctx.fillStyle = `rgba(10, 26, 14, ${shadowAlpha})`;
|
||||||
|
|
||||||
for (let i = 0; i < this.clouds.length; i++) {
|
for (let i = 0; i < this.clouds.length; i++) {
|
||||||
const cloud = this.clouds[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 sx = cloud.x + 35;
|
||||||
const sy = Math.min(this.height - 15, cloud.y + cloud.shadowOffsetY);
|
const sy = Math.min(this.height - 15, cloud.y + cloud.shadowOffsetY);
|
||||||
|
|
||||||
|
|
@ -289,7 +365,7 @@ export class WeatherManager {
|
||||||
for (let j = 0; j < cloud.lobes.length; j++) {
|
for (let j = 0; j < cloud.lobes.length; j++) {
|
||||||
const lobe = cloud.lobes[j];
|
const lobe = cloud.lobes[j];
|
||||||
const lx = sx + lobe.x * cloud.scale;
|
const lx = sx + lobe.x * cloud.scale;
|
||||||
const ly = sy + lobe.y * cloud.scale * 0.72; // perspective ground squash
|
const ly = sy + lobe.y * cloud.scale * 0.72;
|
||||||
const lr = lobe.r * cloud.scale;
|
const lr = lobe.r * cloud.scale;
|
||||||
ctx.moveTo(lx + lr, ly);
|
ctx.moveTo(lx + lr, ly);
|
||||||
ctx.arc(lx, ly, lr, 0, Math.PI * 2);
|
ctx.arc(lx, ly, lr, 0, Math.PI * 2);
|
||||||
|
|
@ -298,7 +374,7 @@ export class WeatherManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==============================================================
|
// ==============================================================
|
||||||
// 2. ATMOSPHERIC AMBIENT TINT (Darkening when overcast or stormy)
|
// 2. ATMOSPHERIC AMBIENT TINT
|
||||||
// ==============================================================
|
// ==============================================================
|
||||||
if (this.rainIntensity > 0.02 || this.cloudDensity > 0.5) {
|
if (this.rainIntensity > 0.02 || this.cloudDensity > 0.5) {
|
||||||
const darkFactor = this.rainIntensity * 0.32 + Math.max(0, this.cloudDensity - 0.5) * 0.15;
|
const darkFactor = this.rainIntensity * 0.32 + Math.max(0, this.cloudDensity - 0.5) * 0.15;
|
||||||
|
|
@ -307,34 +383,33 @@ export class WeatherManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==============================================================
|
// ==============================================================
|
||||||
// 3. DRIFTING OVERHEAD CLOUDS (SKY ATMOSPHERE LAYER)
|
// 3. DRIFTING OVERHEAD CLOUDS (3D VOLUMETRIC SKY LAYER)
|
||||||
// Fluffy cumulus with 3D volumetric shading & highlighted rims
|
|
||||||
// ==============================================================
|
// ==============================================================
|
||||||
const cloudAlpha = 0.28 + this.cloudDensity * 0.55 + this.rainIntensity * 0.15;
|
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++) {
|
for (let i = 0; i < this.clouds.length; i++) {
|
||||||
const cloud = this.clouds[i];
|
const cloud = this.clouds[i];
|
||||||
|
const isStormy = cloud.isRaining || (this.rainIntensity > 0.35);
|
||||||
|
|
||||||
// A. Darker Underbelly / Base Shading (Gives the cloud volumetric depth)
|
// A. Darker Underbelly / Base Shading
|
||||||
ctx.fillStyle = isStormy
|
ctx.fillStyle = isStormy
|
||||||
? `rgba(65, 78, 92, ${cloudAlpha * 0.85})`
|
? `rgba(62, 75, 88, ${cloudAlpha * 0.92})`
|
||||||
: `rgba(175, 190, 205, ${cloudAlpha * 0.55})`;
|
: `rgba(175, 190, 205, ${cloudAlpha * 0.55})`;
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (let j = 0; j < cloud.lobes.length; j++) {
|
for (let j = 0; j < cloud.lobes.length; j++) {
|
||||||
const lobe = cloud.lobes[j];
|
const lobe = cloud.lobes[j];
|
||||||
const lx = cloud.x + lobe.x * cloud.scale;
|
const lx = cloud.x + lobe.x * cloud.scale;
|
||||||
const ly = cloud.y + (lobe.y + 5) * cloud.scale; // subtle downward shadow offset
|
const ly = cloud.y + (lobe.y + 6) * cloud.scale;
|
||||||
const lr = lobe.r * cloud.scale;
|
const lr = lobe.r * cloud.scale;
|
||||||
ctx.moveTo(lx + lr, ly);
|
ctx.moveTo(lx + lr, ly);
|
||||||
ctx.arc(lx, ly, lr, 0, Math.PI * 2);
|
ctx.arc(lx, ly, lr, 0, Math.PI * 2);
|
||||||
}
|
}
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
// B. Main Puffy Cloud Body (Soft billowing white / slate-grey storm)
|
// B. Main Puffy Cloud Body
|
||||||
ctx.fillStyle = isStormy
|
ctx.fillStyle = isStormy
|
||||||
? `rgba(115, 130, 146, ${cloudAlpha * 0.95})`
|
? `rgba(110, 126, 142, ${cloudAlpha * 0.96})`
|
||||||
: `rgba(244, 248, 253, ${cloudAlpha * 0.88})`;
|
: `rgba(244, 248, 253, ${cloudAlpha * 0.88})`;
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
|
|
@ -356,7 +431,7 @@ export class WeatherManager {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (let j = 0; j < cloud.lobes.length; j++) {
|
for (let j = 0; j < cloud.lobes.length; j++) {
|
||||||
const lobe = cloud.lobes[j];
|
const lobe = cloud.lobes[j];
|
||||||
if (lobe.y < 0) { // Crown of the cloud receives direct sunlight
|
if (lobe.y < 0) {
|
||||||
const lx = cloud.x + lobe.x * cloud.scale;
|
const lx = cloud.x + lobe.x * cloud.scale;
|
||||||
const ly = cloud.y + (lobe.y - 3) * cloud.scale;
|
const ly = cloud.y + (lobe.y - 3) * cloud.scale;
|
||||||
const lr = Math.max(2, (lobe.r - 4) * cloud.scale);
|
const lr = Math.max(2, (lobe.r - 4) * cloud.scale);
|
||||||
|
|
@ -365,19 +440,18 @@ export class WeatherManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
|
||||||
|
|
||||||
// ==============================================================
|
// ==============================================================
|
||||||
// 4. DIAGONAL TRANSLUCENT RAINDROPS
|
// 4. CLOUD RAIN CURTAINS (Falling directly underneath this cloud!)
|
||||||
// ==============================================================
|
// ==============================================================
|
||||||
if (this.rainIntensity > 0.05) {
|
if (cloud.raindrops.length > 0) {
|
||||||
ctx.strokeStyle = `rgba(175, 215, 255, ${0.65 * this.rainIntensity})`;
|
ctx.save();
|
||||||
|
ctx.strokeStyle = `rgba(180, 220, 255, ${0.72 * this.rainIntensity})`;
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
|
|
||||||
const activeDrops = Math.floor(this.maxDrops * this.rainIntensity);
|
for (let r = 0; r < cloud.raindrops.length; r++) {
|
||||||
for (let i = 0; i < activeDrops; i++) {
|
const drop = cloud.raindrops[r];
|
||||||
const drop = this.raindrops[i];
|
|
||||||
const startX = drop.x;
|
const startX = drop.x;
|
||||||
const startY = drop.y;
|
const startY = drop.y;
|
||||||
const endX = startX - drop.length * 0.28;
|
const endX = startX - drop.length * 0.28;
|
||||||
|
|
@ -387,6 +461,8 @@ export class WeatherManager {
|
||||||
ctx.lineTo(endX, endY);
|
ctx.lineTo(endX, endY);
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue