minimojs 1.0.0-alpha.4 → 1.0.0-alpha.41

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.
Files changed (71) hide show
  1. package/README.md +58 -20
  2. package/dist/internal/AnimationSystem.js +345 -0
  3. package/dist/internal/AssetSystem.d.ts +6 -0
  4. package/dist/internal/AssetSystem.js +281 -0
  5. package/dist/internal/BackgroundSystem.d.ts +1 -0
  6. package/dist/internal/BackgroundSystem.js +26 -0
  7. package/dist/internal/CanvasSystem.d.ts +1 -0
  8. package/dist/internal/CanvasSystem.js +51 -0
  9. package/dist/internal/DesignNamingCheck.d.ts +1 -0
  10. package/dist/internal/DesignNamingCheck.js +7 -0
  11. package/dist/internal/ExplosionSystem.d.ts +1 -0
  12. package/dist/internal/ExplosionSystem.js +540 -0
  13. package/dist/internal/InputSystem.d.ts +1 -0
  14. package/dist/internal/InputSystem.js +292 -0
  15. package/dist/internal/LoopSystem.d.ts +1 -0
  16. package/dist/internal/LoopSystem.js +61 -0
  17. package/dist/internal/PhysicsSystem.d.ts +1 -0
  18. package/dist/internal/PhysicsSystem.js +174 -0
  19. package/dist/internal/RenderSystem.d.ts +1 -0
  20. package/dist/internal/RenderSystem.js +1021 -0
  21. package/dist/internal/SoundSystem.d.ts +39 -0
  22. package/dist/internal/SoundSystem.js +292 -0
  23. package/dist/internal/SpriteSystem.d.ts +1 -0
  24. package/dist/internal/SpriteSystem.js +32 -0
  25. package/dist/internal/TextSystem.d.ts +1 -0
  26. package/dist/internal/TextSystem.js +16 -0
  27. package/dist/internal/TimerSystem.d.ts +1 -0
  28. package/dist/internal/TimerSystem.js +43 -0
  29. package/dist/internal/TrailSystem.d.ts +1 -0
  30. package/dist/internal/TrailSystem.js +116 -0
  31. package/dist/internal/TransitionSystem.d.ts +1 -0
  32. package/dist/internal/TransitionSystem.js +74 -0
  33. package/dist/internal/arcade-racer/ArcadeRacerCollisionSystem.d.ts +1 -0
  34. package/dist/internal/arcade-racer/ArcadeRacerCollisionSystem.js +198 -0
  35. package/dist/internal/arcade-racer/ArcadeRacerLaneSystem.d.ts +1 -0
  36. package/dist/internal/arcade-racer/ArcadeRacerLaneSystem.js +120 -0
  37. package/dist/internal/arcade-racer/ArcadeRacerRenderSystem.d.ts +1 -0
  38. package/dist/internal/arcade-racer/ArcadeRacerRenderSystem.js +599 -0
  39. package/dist/internal/arcade-racer/ArcadeRacerRoadSprite.d.ts +1 -0
  40. package/dist/internal/arcade-racer/ArcadeRacerRoadSprite.js +13 -0
  41. package/dist/internal/arcade-racer/ArcadeRacerTrackSystem.d.ts +1 -0
  42. package/dist/internal/arcade-racer/ArcadeRacerTrackSystem.js +447 -0
  43. package/dist/minimo-ambient.d.ts +299 -0
  44. package/dist/minimo-ambient.js +1445 -0
  45. package/dist/minimo-arcaderacer.d.ts +1431 -0
  46. package/dist/minimo-arcaderacer.js +2060 -0
  47. package/dist/minimo-cards.d.ts +359 -0
  48. package/dist/minimo-cards.js +819 -0
  49. package/dist/minimo-dice.d.ts +206 -0
  50. package/dist/minimo-dice.js +719 -0
  51. package/dist/minimo-fire.d.ts +282 -0
  52. package/dist/minimo-fire.js +930 -0
  53. package/dist/minimo-format.d.ts +632 -0
  54. package/dist/minimo-format.js +669 -0
  55. package/dist/minimo-leaderboard.d.ts +199 -0
  56. package/dist/minimo-leaderboard.js +566 -0
  57. package/dist/minimo-multiplayer.d.ts +387 -0
  58. package/dist/minimo-multiplayer.js +1323 -0
  59. package/dist/minimo.d.ts +1690 -233
  60. package/dist/minimo.js +2587 -892
  61. package/package.json +10 -3
  62. package/dist/animations.js +0 -30
  63. package/dist/audio.js +0 -17
  64. package/dist/game.js +0 -1105
  65. package/dist/input.js +0 -185
  66. package/dist/internal-types.js +0 -4
  67. package/dist/physics.js +0 -10
  68. package/dist/render.js +0 -75
  69. package/dist/sprite.js +0 -149
  70. package/dist/timers.js +0 -23
  71. /package/dist/{pointer-info.js → internal/AnimationSystem.d.ts} +0 -0
@@ -0,0 +1,1445 @@
1
+ import { DrawSprite, Sprite } from "./minimo.js";
2
+ // Particles per million screen pixels at intensity 1.
3
+ const RAIN_DENSITY = 480;
4
+ const SNOW_DENSITY = 340;
5
+ const DRIFT_IMAGE_DENSITY = 90;
6
+ const DRIFT_DOT_DENSITY = 110;
7
+ // Lightning never strikes closer together than this, and a strike flashes at
8
+ // most twice, so flashes stay under WCAG 2.3.1's three per second.
9
+ const MIN_STRIKE_GAP = 1.25;
10
+ const SNOW_COLUMN = 4;
11
+ const SNOW_SETTLE_SPEED = 1.1;
12
+ const NUMERIC_FIELDS = [
13
+ "wind",
14
+ "gusts",
15
+ "rain",
16
+ "snow",
17
+ "fog",
18
+ "storm",
19
+ "darkness",
20
+ "tint",
21
+ "stars",
22
+ ];
23
+ const WEATHER_FIELDS = ["wind", "gusts", "rain", "snow", "fog", "storm"];
24
+ const PRESETS = {
25
+ clear: {},
26
+ drizzle: { rain: 0.25, wind: 0.1 },
27
+ rain: { rain: 0.6, wind: 0.2, gusts: 0.2 },
28
+ storm: { rain: 1, wind: 0.5, gusts: 0.6, storm: 0.6, fog: 0.2 },
29
+ snow: { snow: 0.5, wind: 0.1 },
30
+ blizzard: { snow: 1, wind: 0.8, gusts: 0.7, fog: 0.4 },
31
+ mist: { fog: 0.6, wind: 0.05 },
32
+ windy: { wind: 0.7, gusts: 0.8 },
33
+ };
34
+ /**
35
+ * A stream of leaves, petals, embers, bubbles, fireflies, or dust, created by
36
+ * {@link Ambient.addDrift}. It shares the ambient's wind and parallax.
37
+ */
38
+ export class AmbientDrift {
39
+ /** @internal */
40
+ constructor(options) {
41
+ /** @internal */ this._particles = [];
42
+ /** @internal */ this._tween = null;
43
+ const motion = options.motion;
44
+ const dot = options.dot
45
+ ? { color: options.dot.color, radius: options.dot.radius ?? 2, glow: options.dot.glow ?? 0 }
46
+ : null;
47
+ const size = typeof options.size === "number" ? [options.size, options.size] : options.size ?? [16, 32];
48
+ this._options = {
49
+ motion,
50
+ images: options.images ?? [],
51
+ dot,
52
+ amount: clamp01(options.amount ?? 0.5),
53
+ size,
54
+ speed: options.speed ?? 1,
55
+ spin: options.spin ?? (motion === "fall" ? 1 : 0),
56
+ sway: options.sway ?? 1,
57
+ wind: options.wind ?? (motion === "fall" ? 1 : 0.5),
58
+ twinkle: clamp01(options.twinkle ?? 0),
59
+ lit: options.lit ?? (dot !== null && dot.glow > 0),
60
+ fade: options.fade ?? dot !== null,
61
+ };
62
+ this._amount = this._options.amount;
63
+ }
64
+ /** How the drift moves. */
65
+ get motion() {
66
+ return this._options.motion;
67
+ }
68
+ /** Density from `0` to `1`. Setting it stops a running {@link AmbientDrift.to}. */
69
+ get amount() {
70
+ return this._amount;
71
+ }
72
+ set amount(value) {
73
+ this._finishTween();
74
+ this._amount = clamp01(value);
75
+ }
76
+ /**
77
+ * Changes the density over `durationMs` milliseconds. The promise resolves
78
+ * when the amount arrives, or when a later change takes over.
79
+ */
80
+ to(amount, durationMs) {
81
+ this._finishTween();
82
+ const target = clamp01(amount);
83
+ if (!(durationMs > 0)) {
84
+ this._amount = target;
85
+ return Promise.resolve();
86
+ }
87
+ return new Promise((resolve) => {
88
+ this._tween = { from: this._amount, to: target, elapsed: 0, duration: durationMs, resolve };
89
+ });
90
+ }
91
+ /** @internal */
92
+ _advance(dtMs) {
93
+ const tween = this._tween;
94
+ if (!tween)
95
+ return;
96
+ tween.elapsed += dtMs;
97
+ const k = Math.min(1, tween.elapsed / tween.duration);
98
+ this._amount = tween.from + (tween.to - tween.from) * smooth(k);
99
+ if (k >= 1)
100
+ this._finishTween();
101
+ }
102
+ /** @internal */
103
+ _finishTween() {
104
+ const tween = this._tween;
105
+ this._tween = null;
106
+ tween?.resolve();
107
+ }
108
+ }
109
+ /**
110
+ * Weather and atmosphere for a game: rain, snow, wind, fog, lightning,
111
+ * drifting leaves or fireflies, and the light of the hour.
112
+ *
113
+ * One shared state of intensities drives every effect, and every effect reads
114
+ * the same wind. All particles are painted into two canvas-sized surfaces, one
115
+ * behind the play field and one in front of it, so the module adds two sprites
116
+ * to the game whatever the weather.
117
+ *
118
+ * The ambient never moves a game sprite. The wind reaches the game only through
119
+ * {@link Ambient.windX}, which the game applies where it wants.
120
+ *
121
+ * Call {@link Ambient.update} once per frame. An ambient survives
122
+ * {@link Game.reset}: it puts its surfaces back on its next update and keeps
123
+ * its weather, so one ambient can serve every scene. Creating a new one in a
124
+ * scene's `onCreate` also works.
125
+ *
126
+ * @example
127
+ * ```ts
128
+ * const ambient = new Ambient(game, { surfaces: [ground], seed: 7 });
129
+ * ambient.preset("rain");
130
+ *
131
+ * // in onUpdate(dt)
132
+ * ambient.update(dt);
133
+ *
134
+ * // later: a storm rolls in over four seconds
135
+ * await ambient.to({ rain: 1, storm: 0.6, wind: 0.5 }, 4000);
136
+ * ```
137
+ */
138
+ export class Ambient {
139
+ /**
140
+ * Creates an ambient and adds its two surfaces to the game.
141
+ *
142
+ * @param game - The game to draw into.
143
+ * @param options - Layers, surfaces, shelters, seed, colors, and initial state.
144
+ */
145
+ constructor(game, options = {}) {
146
+ /**
147
+ * Called on every lightning strike, for thunder, a camera shake, or a
148
+ * gameplay reaction.
149
+ */
150
+ this.onLightning = null;
151
+ /** @internal */ this._tweens = [];
152
+ /** @internal */ this._drifts = [];
153
+ /** @internal */ this._rain = [];
154
+ /** @internal */ this._snow = [];
155
+ /** @internal */ this._fog = [];
156
+ /** @internal */ this._stars = [];
157
+ /** @internal */ this._splashes = [];
158
+ /** @internal */ this._bolts = [];
159
+ /** @internal */ this._flashes = [];
160
+ /** @internal */ this._snowCover = new Map();
161
+ /** @internal */ this._dotTextures = new Map();
162
+ /** @internal */ this._fogTexture = null;
163
+ /** @internal */ this._lines = [];
164
+ /** @internal */ this._rects = [];
165
+ /** @internal */ this._time = 0;
166
+ /** @internal */ this._windX = 0;
167
+ /** @internal */ this._nextStrike = 0;
168
+ /** @internal */ this._destroyed = false;
169
+ /** @internal */
170
+ this._spawnRain = (particle, anywhere) => {
171
+ const W = this._game.width;
172
+ const H = this._game.height;
173
+ particle.x = this._random() * (W + 80) - 40;
174
+ particle.y = anywhere ? this._random() * H : -20 - this._random() * 120;
175
+ particle.vy = (950 + this._random() * 350) * (0.55 + 0.45 * particle.p);
176
+ };
177
+ /** @internal */
178
+ this._spawnSnow = (particle, anywhere) => {
179
+ const W = this._game.width;
180
+ const H = this._game.height;
181
+ particle.x = this._random() * (W + 80) - 40;
182
+ particle.y = anywhere ? this._random() * H : -10 - this._random() * 60;
183
+ particle.vy = (45 + this._random() * 55) * (0.5 + 0.5 * particle.p);
184
+ particle.size = 1.6 + this._random() * 2.2;
185
+ particle.spin = 0.6 + this._random() * 1.2;
186
+ };
187
+ this._game = game;
188
+ const seed = options.seed;
189
+ this._random = typeof seed === "number" && Number.isFinite(seed) ? seededRandom(seed) : Math.random;
190
+ this._frontShare = clamp01(options.front ?? 0.25);
191
+ this._windStrength = options.windStrength ?? 600;
192
+ this._maxSnowDepth = options.maxSnowDepth ?? 16;
193
+ this._fogColor = options.fogColor ?? "#dde5ee";
194
+ this._rainColor = options.rainColor ?? "#c8d6ea";
195
+ this._snowColor = options.snowColor ?? "#ffffff";
196
+ this.surfaces = options.surfaces ?? [];
197
+ this.shelters = options.shelters ?? [];
198
+ this.snowSettles = options.snowSettles ?? false;
199
+ this.reducedMotion = options.reducedMotion ?? prefersReducedMotion();
200
+ let maxParticles = options.maxParticles ?? 900;
201
+ const cores = typeof navigator !== "undefined" ? navigator.hardwareConcurrency ?? 8 : 8;
202
+ if (game.isMobileDevice() && cores <= 4)
203
+ maxParticles = Math.round(maxParticles * 0.6);
204
+ this._maxParticles = maxParticles;
205
+ this._state = {
206
+ wind: 0,
207
+ gusts: 0,
208
+ rain: 0,
209
+ snow: 0,
210
+ fog: 0,
211
+ storm: 0,
212
+ darkness: 0,
213
+ tint: 0,
214
+ stars: 0,
215
+ tintColor: "#1b2a55",
216
+ };
217
+ this._tint = parseHex(this._state.tintColor);
218
+ if (options.state)
219
+ this.set(options.state);
220
+ this._gustPhases = [0, 0, 0].map(() => this._random() * Math.PI * 2);
221
+ this._nextStrike = this._strikeDelay();
222
+ this._lastScrollX = game.scrollX;
223
+ this._lastScrollY = game.scrollY;
224
+ const W = game.width;
225
+ const H = game.height;
226
+ this._back = new DrawSprite(W, H, W / 2, H / 2);
227
+ this._front = new DrawSprite(W, H, W / 2, H / 2);
228
+ for (const surface of [this._back, this._front]) {
229
+ surface.ignoreScroll = true;
230
+ surface.isStatic = true;
231
+ surface.gravityScale = 0;
232
+ }
233
+ this._back.layer = options.backLayer ?? 5;
234
+ this._front.layer = options.frontLayer ?? 900;
235
+ this._back.redraw = (ctx) => this._paintBack(ctx);
236
+ this._front.redraw = (ctx) => this._paintFront(ctx);
237
+ game.add(this._back);
238
+ game.add(this._front);
239
+ this._createFog();
240
+ this._createStars();
241
+ }
242
+ /** The current state. Read-only: change it with {@link Ambient.set}, {@link Ambient.to}, or {@link Ambient.preset}. */
243
+ get state() {
244
+ return this._state;
245
+ }
246
+ /**
247
+ * The wind at this frame, gusts included, in pixels per second. Positive
248
+ * blows right. This is the only way the weather reaches the game's sprites:
249
+ *
250
+ * ```ts
251
+ * player.vx += ambient.windX * 0.3 * dt;
252
+ * ```
253
+ */
254
+ get windX() {
255
+ return this._windX;
256
+ }
257
+ /** `true` while any {@link Ambient.to} or {@link Ambient.preset} transition is running. */
258
+ get isTransitioning() {
259
+ return this._tweens.length > 0;
260
+ }
261
+ /** Changes state fields at once. Fields left out keep their value. */
262
+ set(change) {
263
+ this._takeOver(change);
264
+ for (const field of NUMERIC_FIELDS) {
265
+ const value = change[field];
266
+ if (typeof value === "number")
267
+ this._state[field] = clampField(field, value);
268
+ }
269
+ if (typeof change.tintColor === "string") {
270
+ this._tint = parseHex(change.tintColor);
271
+ this._state.tintColor = toHex(this._tint);
272
+ }
273
+ }
274
+ /**
275
+ * Changes state fields over `durationMs` milliseconds with an ease in and
276
+ * out. Fields left out keep their value and any transition they are in.
277
+ *
278
+ * A field that is already moving starts from wherever it is, so a new call
279
+ * can interrupt an old one without a jump. The promise resolves when every
280
+ * field arrives, or when later calls have taken all of them over.
281
+ */
282
+ to(change, durationMs) {
283
+ if (!(durationMs > 0)) {
284
+ this.set(change);
285
+ return Promise.resolve();
286
+ }
287
+ this._takeOver(change);
288
+ const numbers = new Map();
289
+ for (const field of NUMERIC_FIELDS) {
290
+ const value = change[field];
291
+ if (typeof value === "number")
292
+ numbers.set(field, { from: this._state[field], to: clampField(field, value) });
293
+ }
294
+ const color = typeof change.tintColor === "string" ? { from: { ...this._tint }, to: parseHex(change.tintColor) } : null;
295
+ if (numbers.size === 0 && !color)
296
+ return Promise.resolve();
297
+ return new Promise((resolve) => {
298
+ this._tweens.push({ numbers, color, elapsed: 0, duration: durationMs, resolve });
299
+ });
300
+ }
301
+ /**
302
+ * Moves to a named weather. The preset's fields are set, and the other
303
+ * weather fields (`wind`, `gusts`, `rain`, `snow`, `fog`, `storm`) go to `0`.
304
+ * Light fields are left alone, because weather and time of day are
305
+ * independent.
306
+ *
307
+ * @param name - The preset.
308
+ * @param durationMs - Transition length in milliseconds. `0`, the default, changes at once.
309
+ */
310
+ preset(name, durationMs = 0) {
311
+ const values = PRESETS[name];
312
+ if (!values)
313
+ throw new Error(`MinimoJS ambient: unknown preset '${String(name)}'.`);
314
+ const change = {};
315
+ for (const field of WEATHER_FIELDS)
316
+ change[field] = values[field] ?? 0;
317
+ return this.to(change, durationMs);
318
+ }
319
+ /**
320
+ * Adds a stream of drifting things: leaves, petals, embers, bubbles,
321
+ * fireflies, or dust. Images must already be loaded or created.
322
+ *
323
+ * @example
324
+ * ```ts
325
+ * const leaves = ambient.addDrift({ images: ["leaf-red", "leaf-gold"], motion: "fall", amount: 0.5 });
326
+ * const fireflies = ambient.addDrift({ dot: { color: "#fff6a0", glow: 8 }, motion: "hover", twinkle: 1 });
327
+ * ```
328
+ */
329
+ addDrift(options) {
330
+ const hasImages = Array.isArray(options.images) && options.images.length > 0;
331
+ if (hasImages === Boolean(options.dot)) {
332
+ throw new Error("MinimoJS ambient: a drift needs either images or a dot, and not both.");
333
+ }
334
+ for (const key of options.images ?? []) {
335
+ if (!this._game.hasImage(key)) {
336
+ throw new Error(`MinimoJS ambient: drift image '${key}' is not loaded. Load it in onPreload or create it with game.createTexture first.`);
337
+ }
338
+ }
339
+ const drift = new AmbientDrift(options);
340
+ this._drifts.push(drift);
341
+ return drift;
342
+ }
343
+ /** Removes a drift at once. Fade it out first with `drift.to(0, ms)` for a gentle exit. */
344
+ removeDrift(drift) {
345
+ const index = this._drifts.indexOf(drift);
346
+ if (index !== -1)
347
+ this._drifts.splice(index, 1);
348
+ drift._finishTween();
349
+ }
350
+ /** Removes all settled snow at once. */
351
+ clearSnow() {
352
+ this._snowCover.clear();
353
+ }
354
+ /**
355
+ * Depth of settled snow in pixels on a surface at world X, `0` where there
356
+ * is none. Useful for footprints, or for slowing a player in deep snow.
357
+ */
358
+ snowDepthAt(surface, worldX) {
359
+ const line = this._resolveLine(surface);
360
+ const cover = this._snowCover.get(surface);
361
+ if (!line || !cover)
362
+ return 0;
363
+ const column = Math.floor((worldX - line.left) / SNOW_COLUMN);
364
+ return column >= 0 && column < cover.depth.length ? cover.depth[column] : 0;
365
+ }
366
+ /**
367
+ * Advances the weather by `dt` seconds. Call it once per frame from the
368
+ * scene's `onUpdate`. Transition promises resolve from here.
369
+ */
370
+ update(dt) {
371
+ if (this._destroyed)
372
+ return;
373
+ const game = this._game;
374
+ const sprites = game.getSprites();
375
+ if (!sprites.includes(this._back))
376
+ game.add(this._back);
377
+ if (!sprites.includes(this._front))
378
+ game.add(this._front);
379
+ const dtMs = dt * 1000;
380
+ this._time += dt;
381
+ this._advanceTweens(dtMs);
382
+ for (const drift of this._drifts)
383
+ drift._advance(dtMs);
384
+ const W = game.width;
385
+ const H = game.height;
386
+ let dsx = game.scrollX - this._lastScrollX;
387
+ let dsy = game.scrollY - this._lastScrollY;
388
+ // A jump larger than the screen is a reset or a teleport, not camera motion.
389
+ if (Math.abs(dsx) > W)
390
+ dsx = 0;
391
+ if (Math.abs(dsy) > H)
392
+ dsy = 0;
393
+ this._lastScrollX = game.scrollX;
394
+ this._lastScrollY = game.scrollY;
395
+ this._resolveWorld();
396
+ this._updateWind();
397
+ this._updateRain(dt, dsx, dsy);
398
+ this._updateSnow(dt, dsx, dsy);
399
+ for (const drift of this._drifts)
400
+ this._updateDrift(drift, dt, dsx, dsy);
401
+ this._updateFog(dt, dsx, dsy);
402
+ this._updateStars(dsx, dsy);
403
+ this._updateSplashes(dt);
404
+ this._updateSnowCover(dt);
405
+ this._updateLightning(dt);
406
+ const s = this._state;
407
+ const light = s.darkness > 0.001 || s.tint > 0.001 || s.fog > 0.001;
408
+ const hasSnowCover = this._snowCover.size > 0;
409
+ this._back.visible =
410
+ this._rain.length > 0 || this._snow.length > 0 || this._drifts.length > 0 || s.fog > 0.001 || s.stars > 0.001 || this._bolts.length > 0;
411
+ this._front.visible =
412
+ this._back.visible || light || hasSnowCover || this._splashes.length > 0 || this._flashes.length > 0;
413
+ }
414
+ /** Removes both surfaces from the game and resolves every pending transition. The ambient does nothing afterwards. */
415
+ destroy() {
416
+ if (this._destroyed)
417
+ return;
418
+ this._destroyed = true;
419
+ this._game.destroySprite(this._back);
420
+ this._game.destroySprite(this._front);
421
+ for (const tween of this._tweens)
422
+ tween.resolve();
423
+ this._tweens = [];
424
+ for (const drift of this._drifts)
425
+ drift._finishTween();
426
+ }
427
+ // ---------------------------------------------------------------------------
428
+ // State and transitions
429
+ // ---------------------------------------------------------------------------
430
+ /** @internal */
431
+ _takeOver(change) {
432
+ for (const tween of this._tweens) {
433
+ for (const field of NUMERIC_FIELDS) {
434
+ if (typeof change[field] === "number")
435
+ tween.numbers.delete(field);
436
+ }
437
+ if (typeof change.tintColor === "string")
438
+ tween.color = null;
439
+ }
440
+ this._tweens = this._tweens.filter((tween) => {
441
+ if (tween.numbers.size > 0 || tween.color)
442
+ return true;
443
+ tween.resolve();
444
+ return false;
445
+ });
446
+ }
447
+ /** @internal */
448
+ _advanceTweens(dtMs) {
449
+ if (this._tweens.length === 0)
450
+ return;
451
+ const done = [];
452
+ for (const tween of this._tweens) {
453
+ tween.elapsed += dtMs;
454
+ const k = Math.min(1, tween.elapsed / tween.duration);
455
+ const e = smooth(k);
456
+ for (const [field, range] of tween.numbers) {
457
+ this._state[field] = range.from + (range.to - range.from) * e;
458
+ }
459
+ if (tween.color) {
460
+ const { from, to } = tween.color;
461
+ this._tint = {
462
+ r: from.r + (to.r - from.r) * e,
463
+ g: from.g + (to.g - from.g) * e,
464
+ b: from.b + (to.b - from.b) * e,
465
+ };
466
+ this._state.tintColor = toHex(this._tint);
467
+ }
468
+ if (k >= 1)
469
+ done.push(tween);
470
+ }
471
+ if (done.length === 0)
472
+ return;
473
+ this._tweens = this._tweens.filter((tween) => !done.includes(tween));
474
+ for (const tween of done)
475
+ tween.resolve();
476
+ }
477
+ // ---------------------------------------------------------------------------
478
+ // World: surfaces and shelters
479
+ // ---------------------------------------------------------------------------
480
+ /** @internal */
481
+ _resolveLine(surface) {
482
+ if (surface instanceof Sprite) {
483
+ const half = surface.bodyDisplayWidth / 2;
484
+ return {
485
+ key: surface,
486
+ left: surface.bodyCenterX - half,
487
+ right: surface.bodyCenterX + half,
488
+ y: surface.bodyCenterY - surface.bodyDisplayHeight / 2,
489
+ };
490
+ }
491
+ if (!surface || !Number.isFinite(surface.left) || !Number.isFinite(surface.right) || !Number.isFinite(surface.y)) {
492
+ return null;
493
+ }
494
+ return { key: surface, left: Math.min(surface.left, surface.right), right: Math.max(surface.left, surface.right), y: surface.y };
495
+ }
496
+ /** @internal */
497
+ _resolveWorld() {
498
+ this._lines = [];
499
+ for (const surface of this.surfaces) {
500
+ const line = this._resolveLine(surface);
501
+ if (line && line.right > line.left)
502
+ this._lines.push(line);
503
+ }
504
+ this._rects = [];
505
+ for (const shelter of this.shelters) {
506
+ if (shelter instanceof Sprite) {
507
+ const hw = shelter.bodyDisplayWidth / 2;
508
+ const hh = shelter.bodyDisplayHeight / 2;
509
+ this._rects.push({
510
+ left: shelter.bodyCenterX - hw,
511
+ top: shelter.bodyCenterY - hh,
512
+ right: shelter.bodyCenterX + hw,
513
+ bottom: shelter.bodyCenterY + hh,
514
+ });
515
+ }
516
+ else if (shelter) {
517
+ this._rects.push(shelter);
518
+ }
519
+ }
520
+ // Forget settled snow on surfaces that are no longer listed.
521
+ if (this._snowCover.size > 0) {
522
+ for (const key of this._snowCover.keys()) {
523
+ if (!this._lines.some((line) => line.key === key))
524
+ this._snowCover.delete(key);
525
+ }
526
+ }
527
+ }
528
+ /** @internal */
529
+ _sheltered(wx, wy) {
530
+ for (const rect of this._rects) {
531
+ if (wx >= rect.left && wx <= rect.right && wy >= rect.top && wy <= rect.bottom)
532
+ return true;
533
+ }
534
+ return false;
535
+ }
536
+ /**
537
+ * Finds the surface a particle crossed this frame, moving from world Y
538
+ * `fromY` to `toY` at world X `wx`. Returns the world Y of the landing
539
+ * point, on top of any settled snow, or `null`.
540
+ * @internal
541
+ */
542
+ _landing(wx, fromY, toY) {
543
+ let best = null;
544
+ for (const line of this._lines) {
545
+ if (wx < line.left || wx > line.right)
546
+ continue;
547
+ const top = line.y - this._coverDepth(line, wx);
548
+ if (fromY <= top && toY >= top && (!best || top < best.y))
549
+ best = { y: top, line };
550
+ }
551
+ return best;
552
+ }
553
+ /** @internal */
554
+ _coverDepth(line, wx) {
555
+ const cover = this._snowCover.get(line.key);
556
+ if (!cover)
557
+ return 0;
558
+ const column = Math.floor((wx - line.left) / SNOW_COLUMN);
559
+ return column >= 0 && column < cover.depth.length ? cover.depth[column] : 0;
560
+ }
561
+ // ---------------------------------------------------------------------------
562
+ // Wind
563
+ // ---------------------------------------------------------------------------
564
+ /** @internal */
565
+ _updateWind() {
566
+ const t = this._time;
567
+ const [a, b, c] = this._gustPhases;
568
+ const g1 = Math.sin(t * 0.63 + a) * 0.55 + Math.sin(t * 1.71 + b) * 0.3 + Math.sin(t * 4.1 + c) * 0.15;
569
+ const g2 = Math.sin(t * 0.37 + b) * 0.6 + Math.sin(t * 2.3 + c) * 0.4;
570
+ const { wind, gusts } = this._state;
571
+ this._windX = this._windStrength * (wind * (1 + 0.85 * gusts * g1) + 0.25 * gusts * g2);
572
+ }
573
+ // ---------------------------------------------------------------------------
574
+ // Particles
575
+ // ---------------------------------------------------------------------------
576
+ /** @internal */
577
+ _budget() {
578
+ const area = (this._game.width * this._game.height) / 1000000;
579
+ const s = this._state;
580
+ let wanted = (s.rain * RAIN_DENSITY + s.snow * SNOW_DENSITY) * area;
581
+ for (const drift of this._drifts)
582
+ wanted += drift._amount * this._driftDensity(drift) * area;
583
+ const scale = wanted > this._maxParticles ? this._maxParticles / wanted : 1;
584
+ return this.reducedMotion ? scale * 0.5 : scale;
585
+ }
586
+ /** @internal */
587
+ _driftDensity(drift) {
588
+ return drift._options.dot ? DRIFT_DOT_DENSITY : DRIFT_IMAGE_DENSITY;
589
+ }
590
+ /** @internal */
591
+ _depth() {
592
+ const r = this._random();
593
+ return r < this._frontShare ? 1 + this._random() * 0.4 : 0.3 + this._random() * 0.7;
594
+ }
595
+ /** @internal */
596
+ _newParticle() {
597
+ return {
598
+ x: 0,
599
+ y: 0,
600
+ p: this._depth(),
601
+ vx: 0,
602
+ vy: 0,
603
+ size: 1,
604
+ phase: this._random() * Math.PI * 2,
605
+ spin: 0,
606
+ rotation: this._random() * 360,
607
+ image: 0,
608
+ fade: 0,
609
+ age: 0,
610
+ life: 0,
611
+ resting: 0,
612
+ wx: 0,
613
+ wy: 0,
614
+ };
615
+ }
616
+ /**
617
+ * Grows or shrinks a pool toward `target`: new particles fade in where they
618
+ * spawn, and particles beyond the target fade out, so intensity changes
619
+ * never pop.
620
+ * @internal
621
+ */
622
+ _fit(pool, target, dt, spawn) {
623
+ const count = Math.max(0, Math.round(target));
624
+ while (pool.length < count) {
625
+ const particle = this._newParticle();
626
+ spawn(particle, true);
627
+ pool.push(particle);
628
+ }
629
+ for (let i = 0; i < pool.length; i++) {
630
+ const particle = pool[i];
631
+ particle.fade = i < count ? Math.min(1, particle.fade + dt / 0.5) : particle.fade - dt / 0.6;
632
+ }
633
+ while (pool.length > count && pool[pool.length - 1].fade <= 0)
634
+ pool.pop();
635
+ }
636
+ /** @internal */
637
+ _shift(particle, dsx, dsy) {
638
+ particle.x -= dsx * particle.p;
639
+ particle.y -= dsy * particle.p;
640
+ }
641
+ /** @internal */
642
+ _wrapX(particle, margin) {
643
+ const W = this._game.width;
644
+ if (particle.x < -margin)
645
+ particle.x += W + margin * 2;
646
+ else if (particle.x > W + margin)
647
+ particle.x -= W + margin * 2;
648
+ }
649
+ /** @internal */
650
+ _updateRain(dt, dsx, dsy) {
651
+ const area = (this._game.width * this._game.height) / 1000000;
652
+ this._fit(this._rain, this._state.rain * RAIN_DENSITY * area * this._budget(), dt, this._spawnRain);
653
+ const H = this._game.height;
654
+ const { scrollX, scrollY } = this._game;
655
+ for (const drop of this._rain) {
656
+ this._shift(drop, dsx, dsy);
657
+ drop.vx = this._windX * (0.6 + 0.4 * drop.p);
658
+ const fromY = drop.y + scrollY;
659
+ drop.x += drop.vx * dt;
660
+ drop.y += drop.vy * dt;
661
+ this._wrapX(drop, 40);
662
+ const wx = drop.x + scrollX;
663
+ const wy = drop.y + scrollY;
664
+ if (drop.p >= 0.8 && drop.p <= 1.2) {
665
+ const landing = this._landing(wx, fromY, wy);
666
+ if (landing) {
667
+ if (drop.fade > 0.5 && !this._sheltered(wx, landing.y - 1))
668
+ this._addSplash(wx, landing.y, drop.p);
669
+ this._spawnRain(drop, false);
670
+ continue;
671
+ }
672
+ }
673
+ if (drop.y > H + 40 || this._sheltered(wx, wy))
674
+ this._spawnRain(drop, false);
675
+ }
676
+ }
677
+ /** @internal */
678
+ _updateSnow(dt, dsx, dsy) {
679
+ const area = (this._game.width * this._game.height) / 1000000;
680
+ this._fit(this._snow, this._state.snow * SNOW_DENSITY * area * this._budget(), dt, this._spawnSnow);
681
+ const H = this._game.height;
682
+ const { scrollX, scrollY } = this._game;
683
+ const t = this._time;
684
+ for (const flake of this._snow) {
685
+ this._shift(flake, dsx, dsy);
686
+ const sway = Math.sin(t * flake.spin + flake.phase) * 22;
687
+ flake.vx = this._windX * (0.5 + 0.5 * flake.p) * 0.9 + sway;
688
+ const fromY = flake.y + scrollY;
689
+ flake.x += flake.vx * dt;
690
+ flake.y += flake.vy * (1 + Math.abs(this._windX) / 1400) * dt;
691
+ this._wrapX(flake, 40);
692
+ const wx = flake.x + scrollX;
693
+ const wy = flake.y + scrollY;
694
+ if (flake.p >= 0.8 && flake.p <= 1.2) {
695
+ const landing = this._landing(wx, fromY, wy);
696
+ if (landing) {
697
+ if (this.snowSettles && !this._sheltered(wx, landing.y - 1))
698
+ this._settleFlake(landing.line, wx);
699
+ this._spawnSnow(flake, false);
700
+ continue;
701
+ }
702
+ }
703
+ if (flake.y > H + 20 || this._sheltered(wx, wy))
704
+ this._spawnSnow(flake, false);
705
+ }
706
+ }
707
+ /** @internal */
708
+ _spawnDrift(drift) {
709
+ return (particle, anywhere) => {
710
+ const W = this._game.width;
711
+ const H = this._game.height;
712
+ const o = drift._options;
713
+ particle.resting = 0;
714
+ particle.age = 0;
715
+ particle.x = this._random() * (W + 80) - 40;
716
+ if (o.motion === "fall")
717
+ particle.y = anywhere ? this._random() * H : -30 - this._random() * 80;
718
+ else if (o.motion === "rise" && o.fade)
719
+ particle.y = H * (anywhere ? this._random() : 0.35 + this._random() * 0.65);
720
+ else if (o.motion === "rise")
721
+ particle.y = anywhere ? this._random() * H : H + 20 + this._random() * 60;
722
+ else
723
+ particle.y = this._random() * H;
724
+ const [min, max] = o.size;
725
+ particle.size = min + this._random() * (max - min);
726
+ particle.image = Math.floor(this._random() * Math.max(1, o.images.length));
727
+ particle.spin = (this._random() < 0.5 ? -1 : 1) * (0.6 + this._random() * 0.8);
728
+ const base = o.motion === "fall" ? 55 + this._random() * 45 : o.motion === "rise" ? 40 + this._random() * 50 : 0;
729
+ particle.vy = base * o.speed * (0.5 + 0.5 * particle.p) * (o.motion === "rise" ? -1 : 1);
730
+ particle.life = (2.5 + this._random() * 3.5) / Math.max(0.2, o.speed);
731
+ particle.vx = 0;
732
+ };
733
+ }
734
+ /** @internal */
735
+ _updateDrift(drift, dt, dsx, dsy) {
736
+ const area = (this._game.width * this._game.height) / 1000000;
737
+ const spawn = this._spawnDrift(drift);
738
+ this._fit(drift._particles, drift._amount * this._driftDensity(drift) * area * this._budget(), dt, spawn);
739
+ const o = drift._options;
740
+ const W = this._game.width;
741
+ const H = this._game.height;
742
+ const { scrollX, scrollY } = this._game;
743
+ const t = this._time;
744
+ for (const particle of drift._particles) {
745
+ particle.age += dt;
746
+ if (particle.resting > 0) {
747
+ particle.resting -= dt;
748
+ particle.x = particle.wx - scrollX;
749
+ particle.y = particle.wy - scrollY;
750
+ if (particle.resting <= 0)
751
+ spawn(particle, false);
752
+ continue;
753
+ }
754
+ this._shift(particle, dsx, dsy);
755
+ const wind = this._windX * o.wind * (0.5 + 0.5 * particle.p);
756
+ const sway = Math.sin(t * 1.3 * particle.spin + particle.phase) * 38 * o.sway;
757
+ if (o.motion === "fall") {
758
+ particle.vx = wind + sway;
759
+ particle.rotation += particle.spin * 140 * o.spin * dt + Math.sin(t * 2 + particle.phase) * 40 * o.spin * dt;
760
+ }
761
+ else if (o.motion === "rise") {
762
+ particle.vx = wind + sway * 0.5;
763
+ particle.vy -= 12 * o.speed * dt;
764
+ particle.rotation += particle.spin * 60 * o.spin * dt;
765
+ }
766
+ else {
767
+ const n = t * 0.5 * particle.spin + particle.phase;
768
+ particle.vx = Math.cos(n) * 22 * o.speed + wind * 0.6;
769
+ particle.vy = Math.sin(n * 1.37) * 16 * o.speed;
770
+ particle.rotation += particle.spin * 30 * o.spin * dt;
771
+ }
772
+ const fromY = particle.y + scrollY;
773
+ particle.x += particle.vx * dt;
774
+ particle.y += particle.vy * dt;
775
+ this._wrapX(particle, 60);
776
+ const wx = particle.x + scrollX;
777
+ const wy = particle.y + scrollY;
778
+ if (o.motion === "fall") {
779
+ if (particle.p >= 0.8 && particle.p <= 1.2) {
780
+ const landing = this._landing(wx, fromY, wy);
781
+ if (landing && !this._sheltered(wx, landing.y - 1)) {
782
+ particle.wx = wx;
783
+ particle.wy = landing.y - particle.size * 0.2;
784
+ particle.resting = 2 + this._random() * 3;
785
+ particle.life = particle.resting;
786
+ continue;
787
+ }
788
+ }
789
+ if (particle.y > H + 60 || this._sheltered(wx, wy))
790
+ spawn(particle, false);
791
+ }
792
+ else if (o.motion === "rise") {
793
+ if (particle.y < -60 || (o.fade && particle.age > particle.life))
794
+ spawn(particle, false);
795
+ }
796
+ else {
797
+ if (particle.y < -40)
798
+ particle.y += H + 80;
799
+ else if (particle.y > H + 40)
800
+ particle.y -= H + 80;
801
+ }
802
+ }
803
+ }
804
+ // ---------------------------------------------------------------------------
805
+ // Splashes and settled snow
806
+ // ---------------------------------------------------------------------------
807
+ /** @internal */
808
+ _addSplash(wx, wy, p) {
809
+ if (this._splashes.length >= 90)
810
+ return;
811
+ const drops = [];
812
+ const count = this._random() < 0.5 ? 2 : 3;
813
+ for (let i = 0; i < count; i++) {
814
+ drops.push({
815
+ x: 0,
816
+ y: 0,
817
+ vx: (this._random() - 0.5) * 140 + this._windX * 0.1,
818
+ vy: -(70 + this._random() * 90),
819
+ });
820
+ }
821
+ this._splashes.push({ x: wx, y: wy, age: 0, size: p, drops });
822
+ }
823
+ /** @internal */
824
+ _updateSplashes(dt) {
825
+ for (let i = this._splashes.length - 1; i >= 0; i--) {
826
+ const splash = this._splashes[i];
827
+ splash.age += dt;
828
+ for (const drop of splash.drops) {
829
+ drop.vy += 900 * dt;
830
+ drop.x += drop.vx * dt;
831
+ drop.y += drop.vy * dt;
832
+ }
833
+ if (splash.age > 0.32)
834
+ this._splashes.splice(i, 1);
835
+ }
836
+ }
837
+ /** @internal */
838
+ _cover(line) {
839
+ const columns = Math.max(1, Math.ceil((line.right - line.left) / SNOW_COLUMN));
840
+ let cover = this._snowCover.get(line.key);
841
+ if (!cover || cover.depth.length !== columns) {
842
+ const noise = new Float32Array(columns);
843
+ let value = this._random();
844
+ for (let i = 0; i < columns; i++) {
845
+ value += (this._random() - 0.5) * 0.35;
846
+ value = Math.max(0, Math.min(1, value));
847
+ noise[i] = 0.6 + value * 0.8;
848
+ }
849
+ cover = { depth: new Float32Array(columns), noise };
850
+ this._snowCover.set(line.key, cover);
851
+ }
852
+ return cover;
853
+ }
854
+ /** @internal */
855
+ _settleFlake(line, wx) {
856
+ const cover = this._cover(line);
857
+ const column = Math.floor((wx - line.left) / SNOW_COLUMN);
858
+ for (let i = -2; i <= 2; i++) {
859
+ const c = column + i;
860
+ if (c < 0 || c >= cover.depth.length)
861
+ continue;
862
+ cover.depth[c] = Math.min(this._maxSnowDepth, cover.depth[c] + (i === 0 ? 0.25 : 0.1));
863
+ }
864
+ }
865
+ /** @internal */
866
+ _covered(line, wx) {
867
+ if (this._sheltered(wx, line.y - 2))
868
+ return true;
869
+ for (const other of this._lines) {
870
+ if (other !== line && wx >= other.left && wx <= other.right && other.y < line.y - 2)
871
+ return true;
872
+ }
873
+ return false;
874
+ }
875
+ /** @internal */
876
+ _updateSnowCover(dt) {
877
+ const s = this._state;
878
+ const snowing = this.snowSettles && s.snow > 0.02;
879
+ if (!snowing && this._snowCover.size === 0)
880
+ return;
881
+ const melt = (s.snow < 0.05 || !this.snowSettles ? 0.9 : 0) + s.rain * 4;
882
+ const viewLeft = this._game.scrollX - 120;
883
+ const viewRight = this._game.scrollX + this._game.width + 120;
884
+ for (const line of this._lines) {
885
+ if (line.right < viewLeft || line.left > viewRight)
886
+ continue;
887
+ if (!snowing && !this._snowCover.has(line.key))
888
+ continue;
889
+ const cover = this._cover(line);
890
+ const { depth, noise } = cover;
891
+ const first = Math.max(0, Math.floor((viewLeft - line.left) / SNOW_COLUMN));
892
+ const last = Math.min(depth.length - 1, Math.ceil((viewRight - line.left) / SNOW_COLUMN));
893
+ const grow = snowing ? s.snow * SNOW_SETTLE_SPEED * dt : 0;
894
+ let any = false;
895
+ for (let c = first; c <= last; c++) {
896
+ const wx = line.left + (c + 0.5) * SNOW_COLUMN;
897
+ let d = depth[c];
898
+ if (grow > 0 && !this._covered(line, wx))
899
+ d += grow * noise[c];
900
+ d -= melt * dt;
901
+ // Soften toward the neighbors so the cover reads as drifts, not columns.
902
+ const left = c > 0 ? depth[c - 1] : d;
903
+ const right = c < depth.length - 1 ? depth[c + 1] : d;
904
+ d += ((left + right) / 2 - d) * Math.min(1, dt * 3);
905
+ depth[c] = Math.max(0, Math.min(this._maxSnowDepth, d));
906
+ if (depth[c] > 0.05)
907
+ any = true;
908
+ }
909
+ if (!any && !snowing) {
910
+ let total = 0;
911
+ for (let c = 0; c < depth.length; c++)
912
+ total += depth[c];
913
+ if (total < 0.05)
914
+ this._snowCover.delete(line.key);
915
+ }
916
+ }
917
+ }
918
+ // ---------------------------------------------------------------------------
919
+ // Fog and stars
920
+ // ---------------------------------------------------------------------------
921
+ /** @internal */
922
+ _createFog() {
923
+ const W = this._game.width;
924
+ const H = this._game.height;
925
+ for (let i = 0; i < 11; i++) {
926
+ const front = i >= 7;
927
+ const p = front ? 1.1 + this._random() * 0.3 : 0.3 + this._random() * 0.6;
928
+ this._fog.push({
929
+ x: this._random() * W,
930
+ y: H * (0.25 + this._random() * 0.75),
931
+ w: (front ? 520 : 380) + this._random() * 380,
932
+ h: 140 + this._random() * 160,
933
+ p,
934
+ alpha: 0.55 + this._random() * 0.45,
935
+ speed: 6 + this._random() * 10,
936
+ front,
937
+ });
938
+ }
939
+ }
940
+ /** @internal */
941
+ _updateFog(dt, dsx, dsy) {
942
+ const W = this._game.width;
943
+ const H = this._game.height;
944
+ for (const puff of this._fog) {
945
+ puff.x -= dsx * puff.p;
946
+ puff.y -= dsy * puff.p * 0.5;
947
+ puff.x += (this._windX * 0.12 + puff.speed) * puff.p * dt;
948
+ if (puff.x < -puff.w)
949
+ puff.x += W + puff.w * 2;
950
+ else if (puff.x > W + puff.w)
951
+ puff.x -= W + puff.w * 2;
952
+ if (puff.y < -puff.h)
953
+ puff.y += H + puff.h * 2;
954
+ else if (puff.y > H + puff.h)
955
+ puff.y -= H + puff.h * 2;
956
+ }
957
+ }
958
+ /** @internal */
959
+ _createStars() {
960
+ const W = this._game.width;
961
+ const H = this._game.height;
962
+ const count = Math.round((W * H) / 9000);
963
+ for (let i = 0; i < count; i++) {
964
+ this._stars.push({
965
+ x: this._random() * W,
966
+ y: this._random() * H * 0.75,
967
+ size: this._random() < 0.7 ? 2 : 3,
968
+ phase: this._random() * Math.PI * 2,
969
+ speed: 0.8 + this._random() * 2.2,
970
+ });
971
+ }
972
+ }
973
+ /** @internal */
974
+ _updateStars(dsx, dsy) {
975
+ if (dsx === 0 && dsy === 0)
976
+ return;
977
+ const W = this._game.width;
978
+ for (const star of this._stars) {
979
+ star.x -= dsx * 0.04;
980
+ if (star.x < 0)
981
+ star.x += W;
982
+ else if (star.x > W)
983
+ star.x -= W;
984
+ }
985
+ }
986
+ // ---------------------------------------------------------------------------
987
+ // Lightning
988
+ // ---------------------------------------------------------------------------
989
+ /** @internal */
990
+ _strikeDelay() {
991
+ const storm = this._state.storm;
992
+ const mean = 8 - 7 * storm;
993
+ return MIN_STRIKE_GAP + -Math.log(1 - this._random()) * mean;
994
+ }
995
+ /** @internal */
996
+ _updateLightning(dt) {
997
+ for (let i = this._bolts.length - 1; i >= 0; i--) {
998
+ this._bolts[i].age += dt;
999
+ if (this._bolts[i].age > 0.35)
1000
+ this._bolts.splice(i, 1);
1001
+ }
1002
+ for (let i = this._flashes.length - 1; i >= 0; i--) {
1003
+ this._flashes[i].at += dt;
1004
+ if (this._flashes[i].at > 0.6)
1005
+ this._flashes.splice(i, 1);
1006
+ }
1007
+ if (this._state.storm < 0.01) {
1008
+ this._nextStrike = Math.max(this._nextStrike, MIN_STRIKE_GAP);
1009
+ return;
1010
+ }
1011
+ this._nextStrike -= dt;
1012
+ if (this._nextStrike > 0)
1013
+ return;
1014
+ this._nextStrike = this._strikeDelay();
1015
+ this._strike();
1016
+ }
1017
+ /** @internal */
1018
+ _strike() {
1019
+ const W = this._game.width;
1020
+ const H = this._game.height;
1021
+ const distance = Math.pow(this._random(), 0.8);
1022
+ const x = W * (0.1 + this._random() * 0.8);
1023
+ const reduced = this.reducedMotion;
1024
+ const peak = reduced ? 0.12 * (1 - distance * 0.5) : 0.6 * (1 - distance * 0.75);
1025
+ this._flashes.push({ at: 0, peak });
1026
+ // A second, weaker flicker for near strikes. Two flashes per strike and a
1027
+ // gap of at least MIN_STRIKE_GAP keep the rate under three per second.
1028
+ if (!reduced && distance < 0.6)
1029
+ this._flashes.push({ at: -0.13, peak: peak * 0.6 });
1030
+ if (distance < 0.8)
1031
+ this._bolts.push(this._makeBolt(x, H * (0.3 + 0.4 * (1 - distance)), 1 + 3 * (1 - distance)));
1032
+ this.onLightning?.({ distance, x });
1033
+ }
1034
+ /** @internal */
1035
+ _makeBolt(x, bottom, width) {
1036
+ const points = [x, -10];
1037
+ const branches = [];
1038
+ let px = x;
1039
+ let py = -10;
1040
+ const steps = 12 + Math.floor(this._random() * 6);
1041
+ const step = (bottom + 10) / steps;
1042
+ for (let i = 0; i < steps; i++) {
1043
+ px += (this._random() - 0.5) * 46;
1044
+ py += step * (0.7 + this._random() * 0.6);
1045
+ points.push(px, py);
1046
+ if (i > 2 && i < steps - 2 && this._random() < 0.18) {
1047
+ const branch = [px, py];
1048
+ let bx = px;
1049
+ let by = py;
1050
+ const dir = this._random() < 0.5 ? -1 : 1;
1051
+ for (let j = 0; j < 4; j++) {
1052
+ bx += dir * (8 + this._random() * 22);
1053
+ by += step * (0.4 + this._random() * 0.5);
1054
+ branch.push(bx, by);
1055
+ }
1056
+ branches.push(branch);
1057
+ }
1058
+ }
1059
+ return { points, branches, age: 0, width };
1060
+ }
1061
+ /** @internal */
1062
+ _flashAlpha() {
1063
+ let alpha = 0;
1064
+ for (const flash of this._flashes) {
1065
+ if (flash.at >= 0)
1066
+ alpha += flash.peak * Math.exp(-flash.at / 0.08);
1067
+ }
1068
+ return Math.min(0.75, alpha);
1069
+ }
1070
+ // ---------------------------------------------------------------------------
1071
+ // Painting
1072
+ // ---------------------------------------------------------------------------
1073
+ /** @internal */
1074
+ _paintBack(ctx) {
1075
+ this._paintStars(ctx);
1076
+ this._paintBolts(ctx);
1077
+ this._paintFog(ctx, false);
1078
+ this._paintRain(ctx, false);
1079
+ this._paintSnow(ctx, false);
1080
+ for (const drift of this._drifts)
1081
+ if (!drift._options.lit)
1082
+ this._paintDrift(ctx, drift, false);
1083
+ }
1084
+ /** @internal */
1085
+ _paintFront(ctx) {
1086
+ const W = this._game.width;
1087
+ const H = this._game.height;
1088
+ const s = this._state;
1089
+ this._paintRain(ctx, true);
1090
+ this._paintSnow(ctx, true);
1091
+ for (const drift of this._drifts)
1092
+ if (!drift._options.lit)
1093
+ this._paintDrift(ctx, drift, true);
1094
+ this._paintSplashes(ctx);
1095
+ this._paintSnowCover(ctx);
1096
+ this._paintFog(ctx, true);
1097
+ if (s.fog > 0.001) {
1098
+ ctx.globalAlpha = s.fog * 0.22;
1099
+ ctx.fillStyle = this._fogColor;
1100
+ ctx.fillRect(0, 0, W, H);
1101
+ }
1102
+ if (s.tint > 0.001) {
1103
+ ctx.globalAlpha = s.tint * 0.5;
1104
+ ctx.fillStyle = s.tintColor;
1105
+ ctx.fillRect(0, 0, W, H);
1106
+ }
1107
+ if (s.darkness > 0.001) {
1108
+ ctx.globalAlpha = s.darkness * 0.82;
1109
+ ctx.fillStyle = "#050a1a";
1110
+ ctx.fillRect(0, 0, W, H);
1111
+ }
1112
+ ctx.globalAlpha = 1;
1113
+ for (const drift of this._drifts) {
1114
+ if (drift._options.lit) {
1115
+ this._paintDrift(ctx, drift, false);
1116
+ this._paintDrift(ctx, drift, true);
1117
+ }
1118
+ }
1119
+ const flash = this._flashAlpha();
1120
+ if (flash > 0.001) {
1121
+ ctx.globalAlpha = flash;
1122
+ ctx.fillStyle = "#eef2ff";
1123
+ ctx.fillRect(0, 0, W, H);
1124
+ ctx.globalAlpha = 1;
1125
+ }
1126
+ }
1127
+ /** @internal */
1128
+ _visibleAt(x, y) {
1129
+ return this._rects.length === 0 || !this._sheltered(x + this._game.scrollX, y + this._game.scrollY);
1130
+ }
1131
+ /** @internal */
1132
+ _paintRain(ctx, front) {
1133
+ if (this._rain.length === 0)
1134
+ return;
1135
+ const buckets = front ? [[1, 1.41]] : [[0, 0.6], [0.6, 1]];
1136
+ const alphas = front ? [0.7] : [0.3, 0.5];
1137
+ const widths = front ? [2.2] : [1, 1.4];
1138
+ ctx.strokeStyle = this._rainColor;
1139
+ ctx.lineCap = "round";
1140
+ for (let b = 0; b < buckets.length; b++) {
1141
+ const [min, max] = buckets[b];
1142
+ ctx.globalAlpha = alphas[b];
1143
+ ctx.lineWidth = widths[b];
1144
+ ctx.beginPath();
1145
+ for (const drop of this._rain) {
1146
+ if (drop.p < min || drop.p >= max || drop.fade <= 0)
1147
+ continue;
1148
+ const k = 0.02 * drop.fade;
1149
+ ctx.moveTo(drop.x, drop.y);
1150
+ ctx.lineTo(drop.x - drop.vx * k, drop.y - drop.vy * k);
1151
+ }
1152
+ ctx.stroke();
1153
+ }
1154
+ ctx.globalAlpha = 1;
1155
+ }
1156
+ /** @internal */
1157
+ _paintSnow(ctx, front) {
1158
+ if (this._snow.length === 0)
1159
+ return;
1160
+ const buckets = front ? [[1, 1.41]] : [[0, 0.6], [0.6, 1]];
1161
+ const alphas = front ? [0.95] : [0.7, 0.9];
1162
+ ctx.fillStyle = this._snowColor;
1163
+ for (let b = 0; b < buckets.length; b++) {
1164
+ const [min, max] = buckets[b];
1165
+ ctx.globalAlpha = alphas[b];
1166
+ ctx.beginPath();
1167
+ for (const flake of this._snow) {
1168
+ if (flake.p < min || flake.p >= max || flake.fade <= 0)
1169
+ continue;
1170
+ const r = flake.size * (0.4 + 0.6 * flake.p) * flake.fade;
1171
+ ctx.moveTo(flake.x + r, flake.y);
1172
+ ctx.arc(flake.x, flake.y, r, 0, Math.PI * 2);
1173
+ }
1174
+ ctx.fill();
1175
+ }
1176
+ ctx.globalAlpha = 1;
1177
+ }
1178
+ /** @internal */
1179
+ _paintDrift(ctx, drift, front) {
1180
+ const o = drift._options;
1181
+ const t = this._time;
1182
+ const dot = o.dot ? this._dotTexture(o.dot) : null;
1183
+ const images = dot ? [] : o.images.map((key) => this._game.getImage(key));
1184
+ for (const particle of drift._particles) {
1185
+ if ((particle.p >= 1) !== front || particle.fade <= 0)
1186
+ continue;
1187
+ if (particle.resting <= 0 && o.motion !== "hover" && !this._visibleAt(particle.x, particle.y))
1188
+ continue;
1189
+ let alpha = particle.fade;
1190
+ if (o.twinkle > 0)
1191
+ alpha *= 1 - o.twinkle * 0.5 * (1 + Math.sin(t * 3 * Math.abs(particle.spin) + particle.phase));
1192
+ // A fading riser appears where it spawns, so it fades in as well as out.
1193
+ if (o.motion === "rise" && o.fade)
1194
+ alpha *= Math.max(0, Math.min(1, particle.age / 0.4, (particle.life - particle.age) / (particle.life * 0.4)));
1195
+ if (particle.resting > 0)
1196
+ alpha *= Math.min(1, particle.resting / 0.8);
1197
+ if (alpha <= 0.01)
1198
+ continue;
1199
+ ctx.globalAlpha = alpha;
1200
+ const scale = 0.55 + 0.45 * particle.p;
1201
+ if (dot) {
1202
+ const size = dot.width * scale;
1203
+ ctx.drawImage(dot, particle.x - size / 2, particle.y - size / 2, size, size);
1204
+ continue;
1205
+ }
1206
+ const image = images[particle.image % images.length];
1207
+ if (!image)
1208
+ continue;
1209
+ const longest = Math.max(image.width, image.height) || 1;
1210
+ const k = (particle.size * scale) / longest;
1211
+ const w = image.width * k;
1212
+ const h = image.height * k;
1213
+ ctx.save();
1214
+ ctx.translate(particle.x, particle.y);
1215
+ if (o.spin > 0) {
1216
+ ctx.rotate((particle.rotation * Math.PI) / 180);
1217
+ // A leaf tumbling in three dimensions narrows as it turns edge-on.
1218
+ if (particle.resting <= 0 && o.motion === "fall")
1219
+ ctx.scale(0.35 + 0.65 * Math.abs(Math.cos(t * 1.7 * particle.spin + particle.phase)), 1);
1220
+ }
1221
+ ctx.drawImage(image, -w / 2, -h / 2, w, h);
1222
+ ctx.restore();
1223
+ }
1224
+ ctx.globalAlpha = 1;
1225
+ }
1226
+ /** @internal */
1227
+ _paintSplashes(ctx) {
1228
+ if (this._splashes.length === 0)
1229
+ return;
1230
+ const { scrollX, scrollY } = this._game;
1231
+ ctx.strokeStyle = this._rainColor;
1232
+ ctx.fillStyle = this._rainColor;
1233
+ ctx.lineWidth = 1.2;
1234
+ for (const splash of this._splashes) {
1235
+ const k = splash.age / 0.32;
1236
+ const x = splash.x - scrollX;
1237
+ const y = splash.y - scrollY;
1238
+ ctx.globalAlpha = (1 - k) * 0.7;
1239
+ ctx.beginPath();
1240
+ ctx.ellipse(x, y, 2 + k * 9 * splash.size, 1 + k * 2.5, 0, 0, Math.PI * 2);
1241
+ ctx.stroke();
1242
+ ctx.beginPath();
1243
+ for (const drop of splash.drops) {
1244
+ ctx.moveTo(x + drop.x + 1.3, y + drop.y);
1245
+ ctx.arc(x + drop.x, y + drop.y, 1.3, 0, Math.PI * 2);
1246
+ }
1247
+ ctx.fill();
1248
+ }
1249
+ ctx.globalAlpha = 1;
1250
+ }
1251
+ /** @internal */
1252
+ _paintSnowCover(ctx) {
1253
+ if (this._snowCover.size === 0)
1254
+ return;
1255
+ const { scrollX, scrollY } = this._game;
1256
+ const W = this._game.width;
1257
+ ctx.fillStyle = this._snowColor;
1258
+ for (const line of this._lines) {
1259
+ const cover = this._snowCover.get(line.key);
1260
+ if (!cover)
1261
+ continue;
1262
+ const left = line.left - scrollX;
1263
+ const top = line.y - scrollY;
1264
+ if (left > W || line.right - scrollX < 0)
1265
+ continue;
1266
+ const { depth } = cover;
1267
+ const first = Math.max(0, Math.floor(-left / SNOW_COLUMN) - 1);
1268
+ const last = Math.min(depth.length - 1, Math.ceil((W - left) / SNOW_COLUMN) + 1);
1269
+ ctx.beginPath();
1270
+ ctx.moveTo(left + first * SNOW_COLUMN, top + 0.5);
1271
+ for (let c = first; c <= last; c++) {
1272
+ ctx.lineTo(left + (c + 0.5) * SNOW_COLUMN, top - depth[c]);
1273
+ }
1274
+ ctx.lineTo(Math.min(line.right - scrollX, left + (last + 1) * SNOW_COLUMN), top + 0.5);
1275
+ ctx.closePath();
1276
+ ctx.globalAlpha = 1;
1277
+ ctx.fill();
1278
+ // A faint blue edge keeps white snow readable against a pale sky.
1279
+ ctx.globalAlpha = 0.25;
1280
+ ctx.strokeStyle = "#9fb4d0";
1281
+ ctx.lineWidth = 1;
1282
+ ctx.stroke();
1283
+ }
1284
+ ctx.globalAlpha = 1;
1285
+ }
1286
+ /** @internal */
1287
+ _paintFog(ctx, front) {
1288
+ const fog = this._state.fog;
1289
+ if (fog <= 0.001)
1290
+ return;
1291
+ const texture = this._fogPuff();
1292
+ for (const puff of this._fog) {
1293
+ if (puff.front !== front)
1294
+ continue;
1295
+ ctx.globalAlpha = fog * puff.alpha * (front ? 0.45 : 0.7);
1296
+ ctx.drawImage(texture, puff.x - puff.w / 2, puff.y - puff.h / 2, puff.w, puff.h);
1297
+ }
1298
+ ctx.globalAlpha = 1;
1299
+ }
1300
+ /** @internal */
1301
+ _paintStars(ctx) {
1302
+ const s = this._state;
1303
+ const cover = Math.min(1, Math.max(s.rain, s.snow * 0.9, s.fog * 0.8) * 1.3);
1304
+ const visible = s.stars * (1 - cover);
1305
+ if (visible <= 0.001)
1306
+ return;
1307
+ ctx.fillStyle = "#ffffff";
1308
+ const t = this._time;
1309
+ const count = Math.round(this._stars.length * Math.min(1, 0.3 + visible));
1310
+ for (let i = 0; i < count; i++) {
1311
+ const star = this._stars[i];
1312
+ ctx.globalAlpha = visible * (0.55 + 0.45 * Math.sin(t * star.speed + star.phase));
1313
+ ctx.fillRect(star.x, star.y, star.size, star.size);
1314
+ }
1315
+ ctx.globalAlpha = 1;
1316
+ }
1317
+ /** @internal */
1318
+ _paintBolts(ctx) {
1319
+ if (this._bolts.length === 0)
1320
+ return;
1321
+ ctx.save();
1322
+ ctx.strokeStyle = "#f4f6ff";
1323
+ ctx.lineJoin = "round";
1324
+ ctx.shadowColor = "#b9c8ff";
1325
+ ctx.shadowBlur = 14;
1326
+ for (const bolt of this._bolts) {
1327
+ const k = bolt.age / 0.35;
1328
+ ctx.globalAlpha = Math.max(0, 1 - k * k);
1329
+ ctx.lineWidth = bolt.width;
1330
+ strokePolyline(ctx, bolt.points);
1331
+ ctx.lineWidth = bolt.width * 0.5;
1332
+ for (const branch of bolt.branches)
1333
+ strokePolyline(ctx, branch);
1334
+ }
1335
+ ctx.restore();
1336
+ }
1337
+ /** @internal */
1338
+ _dotTexture(dot) {
1339
+ const key = `${dot.color}|${dot.radius}|${dot.glow}`;
1340
+ let texture = this._dotTextures.get(key);
1341
+ if (texture)
1342
+ return texture;
1343
+ const radius = dot.radius + dot.glow;
1344
+ const size = Math.max(2, Math.ceil(radius * 2 + 2));
1345
+ texture = document.createElement("canvas");
1346
+ texture.width = size;
1347
+ texture.height = size;
1348
+ const ctx = texture.getContext("2d");
1349
+ if (ctx) {
1350
+ const c = size / 2;
1351
+ if (dot.glow > 0) {
1352
+ const gradient = ctx.createRadialGradient(c, c, 0, c, c, radius);
1353
+ const rgb = parseHex(dot.color);
1354
+ const core = dot.radius / radius;
1355
+ gradient.addColorStop(0, "#ffffff");
1356
+ gradient.addColorStop(Math.max(0.01, core * 0.5), dot.color);
1357
+ gradient.addColorStop(Math.max(0.02, core), `rgba(${rgb.r},${rgb.g},${rgb.b},0.55)`);
1358
+ gradient.addColorStop(1, `rgba(${rgb.r},${rgb.g},${rgb.b},0)`);
1359
+ ctx.fillStyle = gradient;
1360
+ ctx.fillRect(0, 0, size, size);
1361
+ }
1362
+ else {
1363
+ ctx.fillStyle = dot.color;
1364
+ ctx.beginPath();
1365
+ ctx.arc(c, c, dot.radius, 0, Math.PI * 2);
1366
+ ctx.fill();
1367
+ }
1368
+ }
1369
+ this._dotTextures.set(key, texture);
1370
+ return texture;
1371
+ }
1372
+ /** @internal */
1373
+ _fogPuff() {
1374
+ if (this._fogTexture)
1375
+ return this._fogTexture;
1376
+ const texture = document.createElement("canvas");
1377
+ texture.width = 128;
1378
+ texture.height = 128;
1379
+ const ctx = texture.getContext("2d");
1380
+ if (ctx) {
1381
+ const rgb = parseHex(this._fogColor);
1382
+ const gradient = ctx.createRadialGradient(64, 64, 0, 64, 64, 64);
1383
+ gradient.addColorStop(0, `rgba(${rgb.r},${rgb.g},${rgb.b},0.9)`);
1384
+ gradient.addColorStop(0.5, `rgba(${rgb.r},${rgb.g},${rgb.b},0.45)`);
1385
+ gradient.addColorStop(1, `rgba(${rgb.r},${rgb.g},${rgb.b},0)`);
1386
+ ctx.fillStyle = gradient;
1387
+ ctx.fillRect(0, 0, 128, 128);
1388
+ }
1389
+ this._fogTexture = texture;
1390
+ return texture;
1391
+ }
1392
+ }
1393
+ function strokePolyline(ctx, points) {
1394
+ ctx.beginPath();
1395
+ ctx.moveTo(points[0], points[1]);
1396
+ for (let i = 2; i < points.length; i += 2)
1397
+ ctx.lineTo(points[i], points[i + 1]);
1398
+ ctx.stroke();
1399
+ }
1400
+ function clamp01(value) {
1401
+ return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
1402
+ }
1403
+ function clampField(field, value) {
1404
+ if (!Number.isFinite(value))
1405
+ return 0;
1406
+ return field === "wind" ? Math.max(-1, Math.min(1, value)) : Math.max(0, Math.min(1, value));
1407
+ }
1408
+ function smooth(k) {
1409
+ return k * k * (3 - 2 * k);
1410
+ }
1411
+ function parseHex(color) {
1412
+ const hex = color.trim().replace(/^#/, "");
1413
+ const full = hex.length === 3 ? hex.split("").map((c) => c + c).join("") : hex;
1414
+ if (!/^[0-9a-fA-F]{6}$/.test(full)) {
1415
+ throw new Error(`MinimoJS ambient: '${color}' is not a hex color such as "#1b2a55".`);
1416
+ }
1417
+ return {
1418
+ r: parseInt(full.slice(0, 2), 16),
1419
+ g: parseInt(full.slice(2, 4), 16),
1420
+ b: parseInt(full.slice(4, 6), 16),
1421
+ };
1422
+ }
1423
+ function toHex(rgb) {
1424
+ const part = (v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0");
1425
+ return `#${part(rgb.r)}${part(rgb.g)}${part(rgb.b)}`;
1426
+ }
1427
+ function prefersReducedMotion() {
1428
+ try {
1429
+ return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
1430
+ }
1431
+ catch {
1432
+ return false;
1433
+ }
1434
+ }
1435
+ // mulberry32, the same generator `cards` uses for its seeded shuffle.
1436
+ function seededRandom(seed) {
1437
+ let state = Math.floor(seed) >>> 0;
1438
+ return () => {
1439
+ state = (state + 0x6d2b79f5) >>> 0;
1440
+ let t = state;
1441
+ t = Math.imul(t ^ (t >>> 15), t | 1);
1442
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
1443
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
1444
+ };
1445
+ }