@kernel.chat/kbot 3.95.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.
- package/dist/agent.js +30 -0
- package/dist/coordinator.d.ts +132 -0
- package/dist/coordinator.js +682 -0
- package/dist/tools/audio-engine.d.ts +76 -0
- package/dist/tools/audio-engine.js +583 -24
- package/dist/tools/index.js +6 -0
- package/dist/tools/sprite-engine.d.ts +18 -0
- package/dist/tools/sprite-engine.js +435 -1
- package/dist/tools/stream-chat-ai.d.ts +56 -0
- package/dist/tools/stream-chat-ai.js +625 -0
- package/dist/tools/stream-commands.d.ts +91 -0
- package/dist/tools/stream-commands.js +911 -0
- package/dist/tools/stream-overlay.d.ts +53 -0
- package/dist/tools/stream-overlay.js +494 -0
- package/dist/tools/stream-renderer.js +676 -77
- package/dist/tools/stream-vod.d.ts +60 -0
- package/dist/tools/stream-vod.js +449 -0
- package/dist/tools/stream-weather.d.ts +79 -0
- package/dist/tools/stream-weather.js +811 -0
- package/dist/tools/tile-world.d.ts +6 -0
- package/dist/tools/tile-world.js +3 -3
- package/package.json +1 -1
|
@@ -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
|