@kernel.chat/kbot 3.94.0 → 3.97.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,811 @@
1
+ // kbot Stream Weather & Day/Night Cycle — Dynamic environment for the livestream
2
+ //
3
+ // Controls sky colors, weather particles, ambient lighting, and mood coupling.
4
+ // Imported by stream-renderer.ts. Canvas 2D rendering at 6fps.
5
+ //
6
+ // Tools: weather_set, weather_status, weather_forecast
7
+ import { registerTool } from './index.js';
8
+ // ─── Constants ────────────────────────────────────────────────────
9
+ const CYCLE_MINUTES = 24; // 24 real minutes = 24 stream hours
10
+ const MS_PER_STREAM_HOUR = (CYCLE_MINUTES * 60 * 1000) / 24;
11
+ const TRANSITION_FRAMES = 180; // 30 seconds at 6fps
12
+ const MIN_WEATHER_INTERVAL = 3600; // 10 min * 6fps
13
+ const MAX_WEATHER_INTERVAL = 10800; // 30 min * 6fps
14
+ const MAX_PARTICLES = 400;
15
+ /** Sky gradient per time-of-day phase */
16
+ const SKY_GRADIENTS = {
17
+ dawn: { top: '#1a1040', bottom: '#c85a30' },
18
+ morning: { top: '#2a4a7a', bottom: '#87b8d8' },
19
+ noon: { top: '#1a6ab0', bottom: '#a0d4f0' },
20
+ afternoon: { top: '#2a5a90', bottom: '#90c8e0' },
21
+ dusk: { top: '#2a1848', bottom: '#d06838' },
22
+ evening: { top: '#0e0e2a', bottom: '#30284a' },
23
+ night: { top: '#050510', bottom: '#0a1628' },
24
+ };
25
+ /** Ambient light base level per phase (0-1) */
26
+ const AMBIENT_LEVELS = {
27
+ dawn: 0.35,
28
+ morning: 0.65,
29
+ noon: 0.85,
30
+ afternoon: 0.75,
31
+ dusk: 0.45,
32
+ evening: 0.25,
33
+ night: 0.15,
34
+ };
35
+ /** Weather definitions */
36
+ const WEATHER_DEFS = {
37
+ clear: { skyTint: '#00000000', ambientMod: 1.0, soundKey: 'ambient_calm', particleRate: 0, particleType: 'drop' },
38
+ cloudy: { skyTint: '#40506080', ambientMod: 0.85, soundKey: 'ambient_wind', particleRate: 0, particleType: 'drop' },
39
+ overcast: { skyTint: '#506070a0', ambientMod: 0.65, soundKey: 'ambient_wind', particleRate: 0, particleType: 'drop' },
40
+ light_rain: { skyTint: '#30405060', ambientMod: 0.7, soundKey: 'rain_light', particleRate: 6, particleType: 'drop' },
41
+ heavy_rain: { skyTint: '#20304080', ambientMod: 0.5, soundKey: 'rain_heavy', particleRate: 18, particleType: 'drop' },
42
+ thunderstorm: { skyTint: '#101828c0', ambientMod: 0.3, soundKey: 'storm_thunder', particleRate: 22, particleType: 'drop' },
43
+ snow: { skyTint: '#c0d0e040', ambientMod: 0.75, soundKey: 'ambient_snow', particleRate: 8, particleType: 'flake' },
44
+ blizzard: { skyTint: '#a0b0c080', ambientMod: 0.4, soundKey: 'blizzard', particleRate: 25, particleType: 'flake' },
45
+ fog: { skyTint: '#808880a0', ambientMod: 0.55, soundKey: 'ambient_fog', particleRate: 3, particleType: 'fog' },
46
+ aurora: { skyTint: '#10203020', ambientMod: 0.2, soundKey: 'ambient_aurora', particleRate: 2, particleType: 'aurora' },
47
+ sandstorm: { skyTint: '#80602080', ambientMod: 0.45, soundKey: 'sandstorm', particleRate: 20, particleType: 'sand' },
48
+ meteor_shower: { skyTint: '#08081020', ambientMod: 0.18, soundKey: 'ambient_night', particleRate: 1, particleType: 'meteor' },
49
+ };
50
+ /** Chat activity thresholds for mood-weather coupling */
51
+ const ACTIVITY_STORM_THRESHOLD = 15; // messages per minute
52
+ const ACTIVITY_CALM_THRESHOLD = 2;
53
+ /** Star field (generated once) */
54
+ const STARS = [];
55
+ let starsGenerated = false;
56
+ function ensureStars(maxX, maxY) {
57
+ if (starsGenerated)
58
+ return;
59
+ starsGenerated = true;
60
+ for (let i = 0; i < 120; i++) {
61
+ STARS.push({
62
+ x: (((i * 97 + 31) * 7919) % 10000) / 10000 * maxX,
63
+ y: (((i * 53 + 71) * 6271) % 10000) / 10000 * maxY,
64
+ size: ((i * 13) % 3) + 1,
65
+ brightness: 0.4 + ((i * 37) % 60) / 100,
66
+ phase: (i * 0.73) % (Math.PI * 2),
67
+ });
68
+ }
69
+ }
70
+ // ─── Utility ──────────────────────────────────────────────────────
71
+ function hexToRgba(hex, alpha) {
72
+ const h = hex.startsWith('#') ? hex.slice(1) : hex;
73
+ const r = parseInt(h.slice(0, 2), 16);
74
+ const g = parseInt(h.slice(2, 4), 16);
75
+ const b = parseInt(h.slice(4, 6), 16);
76
+ return `rgba(${r},${g},${b},${alpha})`;
77
+ }
78
+ function lerp(a, b, t) {
79
+ return a + (b - a) * Math.max(0, Math.min(1, t));
80
+ }
81
+ function lerpColor(a, b, t) {
82
+ const ha = a.startsWith('#') ? a.slice(1) : a;
83
+ const hb = b.startsWith('#') ? b.slice(1) : b;
84
+ const ra = parseInt(ha.slice(0, 2), 16), ga = parseInt(ha.slice(2, 4), 16), ba = parseInt(ha.slice(4, 6), 16);
85
+ const rb = parseInt(hb.slice(0, 2), 16), gb = parseInt(hb.slice(2, 4), 16), bb = parseInt(hb.slice(4, 6), 16);
86
+ const r = Math.round(lerp(ra, rb, t));
87
+ const g = Math.round(lerp(ga, gb, t));
88
+ const bl = Math.round(lerp(ba, bb, t));
89
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${bl.toString(16).padStart(2, '0')}`;
90
+ }
91
+ function randomBetween(min, max) {
92
+ return min + Math.random() * (max - min);
93
+ }
94
+ // ─── WeatherSystem Class ──────────────────────────────────────────
95
+ export class WeatherSystem {
96
+ syncRealTime;
97
+ cycleStart; // epoch ms when cycle began
98
+ streamHour; // 0-23.999
99
+ // Current weather
100
+ current;
101
+ // Transition target
102
+ target = null;
103
+ transitionProgress = 0;
104
+ targetState = null;
105
+ // Auto weather change timer
106
+ weatherTimer;
107
+ weatherHistory = [];
108
+ // Chat activity tracking
109
+ chatActivityWindow = []; // timestamps of recent messages
110
+ chatDrivenWeather = false;
111
+ // Sun/moon angle (0-360)
112
+ celestialAngle = 0;
113
+ constructor(syncRealTime = false) {
114
+ this.syncRealTime = syncRealTime;
115
+ this.cycleStart = Date.now();
116
+ this.streamHour = syncRealTime ? new Date().getHours() + new Date().getMinutes() / 60 : 0;
117
+ this.current = this.buildWeatherState('clear');
118
+ this.weatherTimer = Math.floor(randomBetween(MIN_WEATHER_INTERVAL, MAX_WEATHER_INTERVAL));
119
+ }
120
+ // ── Tick ─────────────────────────────────────────────────────
121
+ tick(frame, chatActivity) {
122
+ // Advance time
123
+ this.updateTime();
124
+ // Track chat activity
125
+ const now = Date.now();
126
+ this.chatActivityWindow.push(now);
127
+ // Keep only last 60 seconds
128
+ this.chatActivityWindow = this.chatActivityWindow.filter(t => now - t < 60_000);
129
+ // Chat-driven weather influence
130
+ const msgsPerMin = this.chatActivityWindow.length;
131
+ if (!this.chatDrivenWeather) {
132
+ if (msgsPerMin >= ACTIVITY_STORM_THRESHOLD && this.current.type === 'clear') {
133
+ this.setWeather('thunderstorm');
134
+ this.chatDrivenWeather = true;
135
+ setTimeout(() => { this.chatDrivenWeather = false; }, 120_000);
136
+ }
137
+ else if (msgsPerMin <= ACTIVITY_CALM_THRESHOLD && this.current.type === 'thunderstorm') {
138
+ this.setWeather('clear');
139
+ this.chatDrivenWeather = true;
140
+ setTimeout(() => { this.chatDrivenWeather = false; }, 120_000);
141
+ }
142
+ }
143
+ // Weather transition blending
144
+ if (this.target && this.targetState) {
145
+ this.transitionProgress += 1 / TRANSITION_FRAMES;
146
+ if (this.transitionProgress >= 1) {
147
+ this.current = this.targetState;
148
+ this.current.intensity = 1;
149
+ this.target = null;
150
+ this.targetState = null;
151
+ this.transitionProgress = 0;
152
+ }
153
+ else {
154
+ this.current.intensity = 1 - this.transitionProgress;
155
+ }
156
+ }
157
+ // Auto weather change
158
+ this.weatherTimer--;
159
+ if (this.weatherTimer <= 0) {
160
+ this.pickRandomWeather();
161
+ this.weatherTimer = Math.floor(randomBetween(MIN_WEATHER_INTERVAL, MAX_WEATHER_INTERVAL));
162
+ }
163
+ // Update particles
164
+ this.tickParticles(frame);
165
+ // Spawn new particles
166
+ this.spawnParticles(frame);
167
+ // Lightning logic for thunderstorms
168
+ if (this.current.type === 'thunderstorm' || (this.targetState?.type === 'thunderstorm')) {
169
+ this.tickLightning();
170
+ }
171
+ // Celestial body angle
172
+ this.celestialAngle = (this.streamHour / 24) * 360;
173
+ }
174
+ // ── Render ───────────────────────────────────────────────────
175
+ render(ctx, width, height) {
176
+ // Render particles on top of the scene
177
+ this.renderParticles(ctx, width, height);
178
+ // Lightning flash overlay
179
+ if (this.current.lightningFlash > 0 || (this.targetState && this.targetState.lightningFlash > 0)) {
180
+ const flashAlpha = Math.max(this.current.lightningFlash > 0 ? this.current.lightningFlash / 4 : 0, this.targetState?.lightningFlash ? this.targetState.lightningFlash / 4 : 0);
181
+ ctx.save();
182
+ ctx.fillStyle = `rgba(255, 255, 255, ${Math.min(0.8, flashAlpha * 0.6)})`;
183
+ ctx.fillRect(0, 0, width, height);
184
+ ctx.restore();
185
+ }
186
+ // Fog overlay
187
+ if (this.current.type === 'fog' || this.target === 'fog') {
188
+ this.renderFogOverlay(ctx, width, height);
189
+ }
190
+ // Sandstorm tint overlay
191
+ if (this.current.type === 'sandstorm' || this.target === 'sandstorm') {
192
+ const alpha = this.current.type === 'sandstorm'
193
+ ? 0.15 * this.current.intensity
194
+ : 0.15 * this.transitionProgress;
195
+ ctx.save();
196
+ ctx.fillStyle = `rgba(160, 120, 60, ${alpha})`;
197
+ ctx.fillRect(0, 0, width, height);
198
+ ctx.restore();
199
+ }
200
+ }
201
+ renderSky(ctx, width, height) {
202
+ const tod = this.getTimeOfDay(), nextTod = this.getNextPhase(tod), pp = this.getPhaseProgress();
203
+ const topColor = lerpColor(SKY_GRADIENTS[tod].top, SKY_GRADIENTS[nextTod].top, pp);
204
+ const botColor = lerpColor(SKY_GRADIENTS[tod].bottom, SKY_GRADIENTS[nextTod].bottom, pp);
205
+ const grad = ctx.createLinearGradient(0, 0, 0, height);
206
+ grad.addColorStop(0, topColor);
207
+ grad.addColorStop(1, botColor);
208
+ ctx.fillStyle = grad;
209
+ ctx.fillRect(0, 0, width, height);
210
+ // Weather sky tint
211
+ const def = this.getActiveWeatherDef();
212
+ if (def.skyTint !== '#00000000') {
213
+ const tHex = def.skyTint.slice(0, 7);
214
+ const tA = parseInt(def.skyTint.slice(7, 9) || 'ff', 16) / 255;
215
+ const blA = this.target ? lerp(tA, parseInt(WEATHER_DEFS[this.target].skyTint.slice(7, 9) || 'ff', 16) / 255, this.transitionProgress) : tA;
216
+ ctx.save();
217
+ ctx.fillStyle = hexToRgba(tHex, blA * 0.6);
218
+ ctx.fillRect(0, 0, width, height);
219
+ ctx.restore();
220
+ }
221
+ const sv = this.getStarVisibility();
222
+ if (sv > 0) {
223
+ ensureStars(width, height * 0.7);
224
+ this.renderStars(ctx, width, height, sv);
225
+ }
226
+ this.renderCelestialBodies(ctx, width, height);
227
+ if (this.current.type === 'aurora' || this.target === 'aurora')
228
+ this.renderAuroraBands(ctx, width, height);
229
+ const cw = this.current.type, tw = this.target;
230
+ if (cw === 'cloudy' || cw === 'overcast' || tw === 'cloudy' || tw === 'overcast')
231
+ this.renderClouds(ctx, width, height);
232
+ }
233
+ // ── Public Getters ───────────────────────────────────────────
234
+ setWeather(type, immediate = false) {
235
+ if (immediate) {
236
+ this.current = this.buildWeatherState(type);
237
+ this.current.intensity = 1;
238
+ this.target = null;
239
+ this.targetState = null;
240
+ this.transitionProgress = 0;
241
+ }
242
+ else {
243
+ this.target = type;
244
+ this.targetState = this.buildWeatherState(type);
245
+ this.transitionProgress = 0;
246
+ }
247
+ this.weatherHistory.push({ type, at: Date.now() });
248
+ }
249
+ getTimeOfDay() {
250
+ const h = this.streamHour;
251
+ if (h >= 5 && h < 7)
252
+ return 'dawn';
253
+ if (h >= 7 && h < 11)
254
+ return 'morning';
255
+ if (h >= 11 && h < 13)
256
+ return 'noon';
257
+ if (h >= 13 && h < 17)
258
+ return 'afternoon';
259
+ if (h >= 17 && h < 19)
260
+ return 'dusk';
261
+ if (h >= 19 && h < 22)
262
+ return 'evening';
263
+ return 'night';
264
+ }
265
+ getAmbientLight() {
266
+ const tod = this.getTimeOfDay();
267
+ const nextTod = this.getNextPhase(tod);
268
+ const progress = this.getPhaseProgress();
269
+ const baseAmbient = lerp(AMBIENT_LEVELS[tod], AMBIENT_LEVELS[nextTod], progress);
270
+ // Weather modifier
271
+ const def = this.getActiveWeatherDef();
272
+ return Math.max(0, Math.min(1, baseAmbient * def.ambientMod));
273
+ }
274
+ getSkyColors() {
275
+ const tod = this.getTimeOfDay();
276
+ const nextTod = this.getNextPhase(tod);
277
+ const progress = this.getPhaseProgress();
278
+ return {
279
+ top: lerpColor(SKY_GRADIENTS[tod].top, SKY_GRADIENTS[nextTod].top, progress),
280
+ bottom: lerpColor(SKY_GRADIENTS[tod].bottom, SKY_GRADIENTS[nextTod].bottom, progress),
281
+ };
282
+ }
283
+ getWeather() {
284
+ return { ...this.current };
285
+ }
286
+ /** Get character mood suggestion based on weather */
287
+ getMoodSuggestion() {
288
+ switch (this.current.type) {
289
+ case 'clear': return 'happy';
290
+ case 'cloudy':
291
+ case 'overcast': return 'idle';
292
+ case 'light_rain': return 'thinking';
293
+ case 'heavy_rain': return 'thinking';
294
+ case 'thunderstorm': return 'excited';
295
+ case 'snow': return 'happy';
296
+ case 'blizzard': return 'error';
297
+ case 'fog': return 'dreaming';
298
+ case 'aurora': return 'dreaming';
299
+ case 'sandstorm': return 'error';
300
+ case 'meteor_shower': return 'excited';
301
+ default: return 'idle';
302
+ }
303
+ }
304
+ /** Handle chat commands like !weather rain */
305
+ handleCommand(cmd, args) {
306
+ const c = cmd.toLowerCase().trim();
307
+ const a = args.toLowerCase().trim();
308
+ if (c === '!weather' || c === 'weather') {
309
+ if (!a || a === 'status') {
310
+ const tod = this.getTimeOfDay();
311
+ const h = Math.floor(this.streamHour);
312
+ const m = Math.floor((this.streamHour % 1) * 60);
313
+ return `Weather: ${this.current.type} | Time: ${tod} (${h}:${m.toString().padStart(2, '0')}) | Ambient: ${(this.getAmbientLight() * 100).toFixed(0)}% | Sound: ${this.current.soundKey}`;
314
+ }
315
+ const requested = a.replace(/\s+/g, '_');
316
+ if (WEATHER_DEFS[requested]) {
317
+ this.setWeather(requested);
318
+ return `Weather changing to ${requested}... (30s transition)`;
319
+ }
320
+ // Fuzzy match
321
+ const types = Object.keys(WEATHER_DEFS);
322
+ const match = types.find(t => t.includes(a) || a.includes(t.replace('_', ' ')));
323
+ if (match) {
324
+ this.setWeather(match);
325
+ return `Weather changing to ${match}... (30s transition)`;
326
+ }
327
+ return `Unknown weather type "${a}". Available: ${types.join(', ')}`;
328
+ }
329
+ if (c === '!time') {
330
+ if (a === 'real' || a === 'sync') {
331
+ this.syncRealTime = true;
332
+ return 'Time synced to real local clock.';
333
+ }
334
+ if (a === 'fast' || a === 'cycle') {
335
+ this.syncRealTime = false;
336
+ this.cycleStart = Date.now();
337
+ return 'Time set to 24-minute cycle mode.';
338
+ }
339
+ const hour = parseInt(a, 10);
340
+ if (!isNaN(hour) && hour >= 0 && hour < 24) {
341
+ this.syncRealTime = false;
342
+ this.streamHour = hour;
343
+ this.cycleStart = Date.now() - (hour * MS_PER_STREAM_HOUR);
344
+ return `Time set to ${hour}:00.`;
345
+ }
346
+ return `Usage: !time <0-23|real|fast>`;
347
+ }
348
+ return '';
349
+ }
350
+ // ── Private: Time ────────────────────────────────────────────
351
+ updateTime() {
352
+ if (this.syncRealTime) {
353
+ const now = new Date();
354
+ this.streamHour = now.getHours() + now.getMinutes() / 60 + now.getSeconds() / 3600;
355
+ }
356
+ else {
357
+ const elapsed = Date.now() - this.cycleStart;
358
+ this.streamHour = (elapsed / MS_PER_STREAM_HOUR) % 24;
359
+ }
360
+ }
361
+ getNextPhase(current) {
362
+ const order = ['dawn', 'morning', 'noon', 'afternoon', 'dusk', 'evening', 'night'];
363
+ const idx = order.indexOf(current);
364
+ return order[(idx + 1) % order.length];
365
+ }
366
+ /** How far through the current phase we are (0-1) */
367
+ getPhaseProgress() {
368
+ const h = this.streamHour;
369
+ const ranges = [
370
+ ['dawn', 5, 7], ['morning', 7, 11], ['noon', 11, 13],
371
+ ['afternoon', 13, 17], ['dusk', 17, 19], ['evening', 19, 22], ['night', 22, 29],
372
+ ];
373
+ for (const [, start, end] of ranges) {
374
+ const adjustedH = h < 5 ? h + 24 : h; // wrap night past midnight
375
+ if (adjustedH >= start && adjustedH < end) {
376
+ return (adjustedH - start) / (end - start);
377
+ }
378
+ }
379
+ return 0;
380
+ }
381
+ getStarVisibility() {
382
+ const h = this.streamHour;
383
+ if (h >= 22 || h < 5)
384
+ return 1.0;
385
+ if (h >= 19 && h < 22)
386
+ return (h - 19) / 3;
387
+ if (h >= 5 && h < 7)
388
+ return 1 - (h - 5) / 2;
389
+ return 0;
390
+ }
391
+ // ── Private: Weather Logic ───────────────────────────────────
392
+ buildWeatherState(type) {
393
+ const def = WEATHER_DEFS[type];
394
+ return {
395
+ type,
396
+ intensity: 1,
397
+ particles: [],
398
+ skyTint: def.skyTint,
399
+ ambientMod: def.ambientMod,
400
+ soundKey: def.soundKey,
401
+ lightningTimer: type === 'thunderstorm' ? Math.floor(randomBetween(30, 90)) : -1,
402
+ lightningFlash: 0,
403
+ };
404
+ }
405
+ getActiveWeatherDef() {
406
+ if (this.target && this.targetState) {
407
+ const cur = WEATHER_DEFS[this.current.type];
408
+ const tgt = WEATHER_DEFS[this.target];
409
+ return {
410
+ skyTint: tgt.skyTint,
411
+ ambientMod: lerp(cur.ambientMod, tgt.ambientMod, this.transitionProgress),
412
+ soundKey: this.transitionProgress > 0.5 ? tgt.soundKey : cur.soundKey,
413
+ particleRate: Math.round(lerp(cur.particleRate, tgt.particleRate, this.transitionProgress)),
414
+ particleType: this.transitionProgress > 0.5 ? tgt.particleType : cur.particleType,
415
+ };
416
+ }
417
+ return WEATHER_DEFS[this.current.type];
418
+ }
419
+ pickRandomWeather() {
420
+ const candidates = ['clear', 'cloudy', 'overcast', 'light_rain', 'heavy_rain',
421
+ 'thunderstorm', 'snow', 'fog', 'aurora', 'meteor_shower'];
422
+ // Weight towards calmer weather, avoid repeating
423
+ const recent = this.weatherHistory.slice(-3).map(h => h.type);
424
+ const filtered = candidates.filter(c => !recent.includes(c));
425
+ const pool = filtered.length > 0 ? filtered : candidates;
426
+ // Calm bias: first 5 types are more likely
427
+ const weighted = [];
428
+ for (const w of pool) {
429
+ const calmTypes = ['clear', 'cloudy', 'light_rain', 'snow', 'fog'];
430
+ weighted.push(w);
431
+ if (calmTypes.includes(w))
432
+ weighted.push(w); // double weight for calm
433
+ }
434
+ const pick = weighted[Math.floor(Math.random() * weighted.length)];
435
+ this.setWeather(pick);
436
+ }
437
+ // ── Private: Lightning ───────────────────────────────────────
438
+ tickLightning() {
439
+ // Tick active flash
440
+ if (this.current.lightningFlash > 0) {
441
+ this.current.lightningFlash--;
442
+ }
443
+ // Countdown to next strike
444
+ if (this.current.lightningTimer > 0) {
445
+ this.current.lightningTimer--;
446
+ }
447
+ else if (this.current.lightningTimer === 0) {
448
+ this.current.lightningFlash = 3; // 0.5s flash at 6fps
449
+ this.current.lightningTimer = Math.floor(randomBetween(18, 72)); // 3-12 seconds
450
+ }
451
+ }
452
+ // ── Private: Particles ───────────────────────────────────────
453
+ spawnParticles(frame) {
454
+ const def = this.getActiveWeatherDef();
455
+ const rate = def.particleRate;
456
+ for (let i = 0; i < rate; i++) {
457
+ if (this.current.particles.length >= MAX_PARTICLES)
458
+ break;
459
+ this.current.particles.push(this.createParticle(def.particleType, frame));
460
+ }
461
+ // Also spawn into target if transitioning
462
+ if (this.targetState) {
463
+ const tgtDef = WEATHER_DEFS[this.target];
464
+ const tgtRate = Math.round(tgtDef.particleRate * this.transitionProgress);
465
+ for (let i = 0; i < tgtRate; i++) {
466
+ if (this.targetState.particles.length >= MAX_PARTICLES)
467
+ break;
468
+ this.targetState.particles.push(this.createParticle(tgtDef.particleType, frame));
469
+ }
470
+ }
471
+ }
472
+ createParticle(type, _frame) {
473
+ // Data-driven particle templates: [x, y, vx, vy, size, life, alpha, color]
474
+ const W = 1280;
475
+ const tpl = {
476
+ drop: () => ({ x: Math.random() * W, y: -10, vx: randomBetween(-1, 1), vy: randomBetween(8, 16), size: randomBetween(1, 3), life: 200, maxLife: 200, alpha: randomBetween(0.3, 0.7), color: '#6688cc', type: 'drop' }),
477
+ flake: () => ({ x: Math.random() * W, y: -10, vx: randomBetween(-2, 2), vy: randomBetween(1, 4), size: randomBetween(2, 5), life: 400, maxLife: 400, alpha: randomBetween(0.5, 0.9), color: '#e8e8f0', type: 'flake' }),
478
+ fog: () => ({ x: Math.random() * W, y: randomBetween(200, 600), vx: randomBetween(0.2, 1), vy: randomBetween(-0.3, 0.3), size: randomBetween(80, 200), life: 300, maxLife: 300, alpha: randomBetween(0.03, 0.08), color: '#c0c8d0', type: 'fog' }),
479
+ aurora: () => ({ x: randomBetween(50, 1230), y: randomBetween(40, 180), vx: randomBetween(-0.5, 0.5), vy: 0, size: randomBetween(100, 300), life: 600, maxLife: 600, alpha: randomBetween(0.05, 0.15), color: ['#40ff80', '#4080ff', '#a040ff', '#40ffc0'][Math.floor(Math.random() * 4)], type: 'aurora' }),
480
+ meteor: () => ({ x: randomBetween(0, W), y: randomBetween(-20, 100), vx: randomBetween(6, 14), vy: randomBetween(4, 10), size: randomBetween(2, 4), life: 30, maxLife: 30, alpha: 1.0, color: '#ffe0a0', type: 'meteor' }),
481
+ sand: () => ({ x: -10, y: randomBetween(100, 700), vx: randomBetween(8, 18), vy: randomBetween(-2, 2), size: randomBetween(1, 3), life: 150, maxLife: 150, alpha: randomBetween(0.3, 0.6), color: '#c09050', type: 'sand' }),
482
+ splash: () => ({ x: Math.random() * W, y: 680, vx: randomBetween(-2, 2), vy: randomBetween(-3, -1), size: 2, life: 8, maxLife: 8, alpha: 0.5, color: '#6688cc', type: 'splash' }),
483
+ };
484
+ return (tpl[type] || tpl.drop)();
485
+ }
486
+ tickParticles(_frame) {
487
+ // Update current particles
488
+ this.current.particles = this.updateParticleList(this.current.particles);
489
+ // Rain splash spawning at ground level
490
+ if (this.current.type === 'heavy_rain' || this.current.type === 'thunderstorm') {
491
+ const splashCount = this.current.type === 'thunderstorm' ? 3 : 1;
492
+ for (let i = 0; i < splashCount; i++) {
493
+ if (this.current.particles.length < MAX_PARTICLES && Math.random() < 0.3) {
494
+ this.current.particles.push(this.createParticle('splash', _frame));
495
+ }
496
+ }
497
+ }
498
+ // Update target particles during transition
499
+ if (this.targetState) {
500
+ this.targetState.particles = this.updateParticleList(this.targetState.particles);
501
+ }
502
+ }
503
+ updateParticleList(particles) {
504
+ const alive = [];
505
+ for (const p of particles) {
506
+ p.x += p.vx;
507
+ p.y += p.vy;
508
+ p.life--;
509
+ // Snow drift
510
+ if (p.type === 'flake') {
511
+ p.vx += randomBetween(-0.3, 0.3);
512
+ p.vx = Math.max(-3, Math.min(3, p.vx));
513
+ }
514
+ // Fog slow drift
515
+ if (p.type === 'fog') {
516
+ p.alpha = (p.life / p.maxLife) * 0.06;
517
+ }
518
+ // Aurora wave
519
+ if (p.type === 'aurora') {
520
+ p.y += Math.sin(p.life * 0.05) * 0.5;
521
+ }
522
+ if (p.life > 0 && p.x < 1400 && p.x > -220 && p.y < 750) {
523
+ alive.push(p);
524
+ }
525
+ }
526
+ return alive;
527
+ }
528
+ // ── Private: Rendering ───────────────────────────────────────
529
+ renderParticles(ctx, width, height) {
530
+ ctx.save();
531
+ // Render current weather particles
532
+ for (const p of this.current.particles) {
533
+ this.drawParticle(ctx, p, this.current.intensity);
534
+ }
535
+ // Render target weather particles (during transition)
536
+ if (this.targetState) {
537
+ for (const p of this.targetState.particles) {
538
+ this.drawParticle(ctx, p, this.transitionProgress);
539
+ }
540
+ }
541
+ ctx.restore();
542
+ }
543
+ drawParticle(ctx, p, intensity) {
544
+ const a = p.alpha * intensity;
545
+ if (a <= 0)
546
+ return;
547
+ switch (p.type) {
548
+ case 'drop':
549
+ ctx.strokeStyle = `rgba(100,136,204,${a})`;
550
+ ctx.lineWidth = p.size * 0.5;
551
+ ctx.beginPath();
552
+ ctx.moveTo(p.x, p.y);
553
+ ctx.lineTo(p.x - 1, p.y + p.size * 4);
554
+ ctx.stroke();
555
+ break;
556
+ case 'flake':
557
+ ctx.fillStyle = `rgba(232,232,240,${a})`;
558
+ ctx.beginPath();
559
+ ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
560
+ ctx.fill();
561
+ break;
562
+ case 'fog': {
563
+ const fg = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
564
+ fg.addColorStop(0, `rgba(192,200,208,${a})`);
565
+ fg.addColorStop(1, 'rgba(192,200,208,0)');
566
+ ctx.fillStyle = fg;
567
+ ctx.fillRect(p.x - p.size, p.y - p.size * 0.5, p.size * 2, p.size);
568
+ break;
569
+ }
570
+ case 'aurora': {
571
+ const [r, g, b] = [p.color.slice(1, 3), p.color.slice(3, 5), p.color.slice(5, 7)].map(h => parseInt(h, 16));
572
+ ctx.save();
573
+ ctx.globalCompositeOperation = 'lighter';
574
+ const ag = ctx.createLinearGradient(p.x - p.size / 2, p.y, p.x + p.size / 2, p.y);
575
+ ag.addColorStop(0, `rgba(${r},${g},${b},0)`);
576
+ ag.addColorStop(0.3, `rgba(${r},${g},${b},${a})`);
577
+ ag.addColorStop(0.7, `rgba(${r},${g},${b},${a})`);
578
+ ag.addColorStop(1, `rgba(${r},${g},${b},0)`);
579
+ ctx.fillStyle = ag;
580
+ ctx.beginPath();
581
+ const bH = 20 + Math.sin(p.life * 0.03) * 10;
582
+ ctx.moveTo(p.x - p.size / 2, p.y);
583
+ for (let i = 0; i <= 20; i++)
584
+ ctx.lineTo(p.x - p.size / 2 + (p.size / 20) * i, p.y + Math.sin(i * 0.5 + p.life * 0.04) * bH * 0.3);
585
+ for (let i = 20; i >= 0; i--)
586
+ ctx.lineTo(p.x - p.size / 2 + (p.size / 20) * i, p.y + bH + Math.sin(i * 0.5 + p.life * 0.04 + 1) * bH * 0.3);
587
+ ctx.closePath();
588
+ ctx.fill();
589
+ ctx.restore();
590
+ break;
591
+ }
592
+ case 'meteor': {
593
+ ctx.save();
594
+ ctx.globalCompositeOperation = 'lighter';
595
+ const mg = ctx.createLinearGradient(p.x, p.y, p.x - p.vx * 2, p.y - 20);
596
+ mg.addColorStop(0, `rgba(255,224,160,${a})`);
597
+ mg.addColorStop(1, 'rgba(255,200,100,0)');
598
+ ctx.strokeStyle = mg;
599
+ ctx.lineWidth = p.size;
600
+ ctx.beginPath();
601
+ ctx.moveTo(p.x, p.y);
602
+ ctx.lineTo(p.x - p.vx * 2, p.y - p.vy * 2);
603
+ ctx.stroke();
604
+ ctx.fillStyle = `rgba(255,250,230,${a})`;
605
+ ctx.beginPath();
606
+ ctx.arc(p.x, p.y, p.size * 0.8, 0, Math.PI * 2);
607
+ ctx.fill();
608
+ ctx.restore();
609
+ break;
610
+ }
611
+ case 'sand':
612
+ ctx.fillStyle = `rgba(192,144,80,${a})`;
613
+ ctx.fillRect(p.x, p.y, p.size * 2, p.size);
614
+ break;
615
+ case 'splash': {
616
+ const lr = p.life / p.maxLife;
617
+ ctx.fillStyle = `rgba(100,136,204,${a * lr})`;
618
+ ctx.beginPath();
619
+ ctx.arc(p.x, p.y, p.size * (1 + (1 - lr) * 2), 0, Math.PI * 2);
620
+ ctx.fill();
621
+ break;
622
+ }
623
+ }
624
+ }
625
+ renderStars(ctx, width, height, visibility) {
626
+ for (const star of STARS) {
627
+ if (star.x > width || star.y > height * 0.7)
628
+ continue;
629
+ const pulse = star.brightness + Math.sin(Date.now() * 0.001 + star.phase) * 0.15;
630
+ const alpha = Math.max(0.1, Math.min(1, pulse)) * visibility;
631
+ ctx.fillStyle = `rgba(255, 255, ${220 + Math.floor(star.phase * 10) % 35}, ${alpha})`;
632
+ ctx.beginPath();
633
+ ctx.arc(star.x, star.y, star.size, 0, Math.PI * 2);
634
+ ctx.fill();
635
+ }
636
+ }
637
+ renderCelestialBodies(ctx, width, height) {
638
+ const h = this.streamHour;
639
+ const arcR = Math.min(width, height) * 0.4, cx = width * 0.4, cy = height * 0.6;
640
+ // Sun (visible ~5:30-18:30)
641
+ if (h >= 5.5 && h <= 18.5) {
642
+ const angle = ((h - 6) / 12) * Math.PI;
643
+ const sx = cx + Math.cos(angle - Math.PI) * arcR * 1.2;
644
+ const sy = cy - Math.sin(angle) * arcR;
645
+ const sa = h < 6 ? (h - 5.5) * 2 : h > 18 ? (18.5 - h) * 2 : 1;
646
+ ctx.save();
647
+ ctx.globalCompositeOperation = 'lighter';
648
+ const sg = ctx.createRadialGradient(sx, sy, 0, sx, sy, 50);
649
+ sg.addColorStop(0, `rgba(255,220,100,${0.4 * sa})`);
650
+ sg.addColorStop(0.5, `rgba(255,180,60,${0.15 * sa})`);
651
+ sg.addColorStop(1, 'rgba(255,160,40,0)');
652
+ ctx.fillStyle = sg;
653
+ ctx.fillRect(sx - 50, sy - 50, 100, 100);
654
+ ctx.fillStyle = `rgba(255,230,140,${sa})`;
655
+ ctx.beginPath();
656
+ ctx.arc(sx, sy, 14, 0, Math.PI * 2);
657
+ ctx.fill();
658
+ ctx.restore();
659
+ }
660
+ // Moon (visible ~18:30-6:00)
661
+ if (h >= 18.5 || h <= 6) {
662
+ const angle = ((h - 18 + 24) % 24 / 12) * Math.PI;
663
+ const mx = cx + Math.cos(angle - Math.PI) * arcR * 1.05;
664
+ const my = cy - Math.sin(angle) * arcR * 0.88;
665
+ const ma = h > 18.5 ? Math.min(1, (h - 18.5) * 2) : h < 5.5 ? 1 : Math.min(1, (6 - h) * 2);
666
+ ctx.save();
667
+ const corona = ctx.createRadialGradient(mx, my, 12, mx, my, 40);
668
+ corona.addColorStop(0, `rgba(200,210,240,${0.12 * ma})`);
669
+ corona.addColorStop(1, 'rgba(200,210,240,0)');
670
+ ctx.fillStyle = corona;
671
+ ctx.fillRect(mx - 40, my - 40, 80, 80);
672
+ ctx.fillStyle = `rgba(200,208,224,${ma})`;
673
+ ctx.beginPath();
674
+ ctx.arc(mx, my, 16, 0, Math.PI * 2);
675
+ ctx.fill();
676
+ ctx.fillStyle = `rgba(160,168,184,${ma * 0.7})`;
677
+ for (const [dx, dy, r] of [[-4, -3, 3.5], [5, 4, 2.5], [-1, 6, 2]]) {
678
+ ctx.beginPath();
679
+ ctx.arc(mx + dx, my + dy, r, 0, Math.PI * 2);
680
+ ctx.fill();
681
+ }
682
+ ctx.restore();
683
+ }
684
+ }
685
+ renderAuroraBands(ctx, w, _h) {
686
+ const i = this.current.type === 'aurora' ? this.current.intensity : this.transitionProgress;
687
+ if (i <= 0)
688
+ return;
689
+ ctx.save();
690
+ ctx.globalCompositeOperation = 'lighter';
691
+ const ag = ctx.createLinearGradient(0, 40, 0, 200);
692
+ ag.addColorStop(0, `rgba(64,255,128,${0.02 * i})`);
693
+ ag.addColorStop(0.5, `rgba(64,128,255,${0.03 * i})`);
694
+ ag.addColorStop(1, 'rgba(160,64,255,0)');
695
+ ctx.fillStyle = ag;
696
+ ctx.fillRect(0, 40, w, 160);
697
+ ctx.restore();
698
+ }
699
+ renderClouds(ctx, width, _height) {
700
+ const isOC = this.current.type === 'overcast' || this.target === 'overcast';
701
+ const baseA = isOC ? 0.4 : 0.2;
702
+ const int = (this.target === 'cloudy' || this.target === 'overcast') ? this.transitionProgress : this.current.intensity;
703
+ const alpha = baseA * int;
704
+ const drift = (Date.now() * 0.005) % (width + 200);
705
+ ctx.save();
706
+ for (const [off, y, rx, ry] of [[0, 80, 80, 25], [300, 110, 100, 30], [600, 70, 70, 20], [150, 140, 90, 28]]) {
707
+ const cx = (drift + off) % (width + 200) - 100;
708
+ const cg = ctx.createRadialGradient(cx, y, 0, cx, y, rx);
709
+ cg.addColorStop(0, `rgba(180,190,200,${alpha})`);
710
+ cg.addColorStop(1, 'rgba(180,190,200,0)');
711
+ ctx.fillStyle = cg;
712
+ ctx.beginPath();
713
+ ctx.ellipse(cx, y, rx, ry, 0, 0, Math.PI * 2);
714
+ ctx.fill();
715
+ }
716
+ ctx.restore();
717
+ }
718
+ renderFogOverlay(ctx, width, height) {
719
+ const i = this.current.type === 'fog' ? this.current.intensity : this.transitionProgress;
720
+ ctx.save();
721
+ const fg = ctx.createLinearGradient(0, height * 0.3, 0, height);
722
+ fg.addColorStop(0, 'rgba(160,170,180,0)');
723
+ fg.addColorStop(0.5, `rgba(160,170,180,${0.08 * i})`);
724
+ fg.addColorStop(1, `rgba(160,170,180,${0.2 * i})`);
725
+ ctx.fillStyle = fg;
726
+ ctx.fillRect(0, 0, width, height);
727
+ ctx.restore();
728
+ }
729
+ }
730
+ // ─── Singleton for tool access ────────────────────────────────────
731
+ let _instance = null;
732
+ export function getWeatherSystem(syncRealTime) {
733
+ if (!_instance) {
734
+ _instance = new WeatherSystem(syncRealTime);
735
+ }
736
+ return _instance;
737
+ }
738
+ // ─── Tool Registration ────────────────────────────────────────────
739
+ export function registerStreamWeatherTools() {
740
+ registerTool({
741
+ name: 'weather_set',
742
+ description: 'Set the stream weather type. Transitions smoothly over 30 seconds unless immediate=true. Types: clear, cloudy, overcast, light_rain, heavy_rain, thunderstorm, snow, blizzard, fog, aurora, sandstorm, meteor_shower.',
743
+ parameters: {
744
+ type: { type: 'string', description: 'Weather type to set', required: true },
745
+ immediate: { type: 'boolean', description: 'Skip transition and apply instantly (default: false)' },
746
+ },
747
+ tier: 'free',
748
+ execute: async (args) => {
749
+ const type = String(args.type || '').replace(/\s+/g, '_');
750
+ if (!WEATHER_DEFS[type]) {
751
+ return `Unknown weather type "${type}". Available: ${Object.keys(WEATHER_DEFS).join(', ')}`;
752
+ }
753
+ const ws = getWeatherSystem();
754
+ ws.setWeather(type, args.immediate === true);
755
+ return `Weather ${args.immediate ? 'set to' : 'transitioning to'} ${type}.`;
756
+ },
757
+ });
758
+ registerTool({
759
+ name: 'weather_status',
760
+ description: 'Get current stream weather, time of day, ambient light level, and sky colors.',
761
+ parameters: {},
762
+ tier: 'free',
763
+ execute: async () => {
764
+ const ws = getWeatherSystem();
765
+ const weather = ws.getWeather();
766
+ const tod = ws.getTimeOfDay();
767
+ const ambient = ws.getAmbientLight();
768
+ const sky = ws.getSkyColors();
769
+ const mood = ws.getMoodSuggestion();
770
+ return [
771
+ `Weather: ${weather.type} (intensity: ${weather.intensity.toFixed(2)})`,
772
+ `Time of day: ${tod}`,
773
+ `Ambient light: ${(ambient * 100).toFixed(0)}%`,
774
+ `Sky: top=${sky.top}, bottom=${sky.bottom}`,
775
+ `Sound: ${weather.soundKey}`,
776
+ `Mood suggestion: ${mood}`,
777
+ `Active particles: ${weather.particles.length}`,
778
+ ].join('\n');
779
+ },
780
+ });
781
+ registerTool({
782
+ name: 'weather_forecast',
783
+ description: 'Get the weather forecast — shows current weather history and upcoming phase transitions.',
784
+ parameters: {},
785
+ tier: 'free',
786
+ execute: async () => {
787
+ const ws = getWeatherSystem();
788
+ const tod = ws.getTimeOfDay();
789
+ const weather = ws.getWeather();
790
+ const phases = ['dawn', 'morning', 'noon', 'afternoon', 'dusk', 'evening', 'night'];
791
+ const currentIdx = phases.indexOf(tod);
792
+ const upcoming = [];
793
+ for (let i = 1; i <= 3; i++) {
794
+ const next = phases[(currentIdx + i) % phases.length];
795
+ upcoming.push(` ${next}: ambient ${(AMBIENT_LEVELS[next] * 100).toFixed(0)}%`);
796
+ }
797
+ return [
798
+ `=== Stream Weather Forecast ===`,
799
+ `Current: ${weather.type} during ${tod}`,
800
+ `Ambient: ${(ws.getAmbientLight() * 100).toFixed(0)}%`,
801
+ ``,
802
+ `Upcoming phases:`,
803
+ ...upcoming,
804
+ ``,
805
+ `Weather changes automatically every 10-30 minutes.`,
806
+ `Chat can vote: !weather <type>`,
807
+ ].join('\n');
808
+ },
809
+ });
810
+ }
811
+ //# sourceMappingURL=stream-weather.js.map