minimojs 1.0.0-alpha.37 → 1.0.0-alpha.39
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/minimo-dice.d.ts +181 -0
- package/dist/minimo-dice.js +665 -0
- package/dist/minimo-fire.d.ts +242 -0
- package/dist/minimo-fire.js +831 -0
- package/package.json +1 -1
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
import { DrawSprite } from "./minimo.js";
|
|
2
|
+
const KINDS = {
|
|
3
|
+
torch: { spread: 0.16, spreadY: 0.04, rise: 4.2, life: 0.62, radius: 0.42, rate: 46, smoke: 0.15, sparks: 0.25, light: true, width: 3.6, up: 3.8, down: 1.6, bodyWidth: 0.6, bodyHeight: 1.1, bodyRise: 0.5 },
|
|
4
|
+
campfire: { spread: 0.4, spreadY: 0.05, rise: 3.4, life: 0.72, radius: 0.34, rate: 80, smoke: 0.4, sparks: 0.5, light: true, width: 3.8, up: 3.8, down: 1.5, bodyWidth: 0.85, bodyHeight: 1, bodyRise: 0.45 },
|
|
5
|
+
burning: { spread: 0.45, spreadY: 0.35, rise: 2.1, life: 0.5, radius: 0.24, rate: 90, smoke: 0.3, sparks: 0.25, light: false, width: 2.4, up: 2.6, down: 1, bodyWidth: 0.9, bodyHeight: 0.9, bodyRise: 0.1 },
|
|
6
|
+
trail: { spread: 0.12, spreadY: 0.12, rise: 0.35, life: 0.45, radius: 0.55, rate: 160, smoke: 0.35, sparks: 0.3, light: false, width: 14, up: 7, down: 7, bodyWidth: 0.8, bodyHeight: 0.8, bodyRise: 0 },
|
|
7
|
+
burst: { spread: 0.42, spreadY: 0.06, rise: 3.2, life: 0.7, radius: 0.34, rate: 80, smoke: 0.5, sparks: 0.5, light: true, width: 4, up: 4, down: 1.8, bodyWidth: 0.85, bodyHeight: 1, bodyRise: 0.45 },
|
|
8
|
+
explosion: { spread: 0, spreadY: 0, rise: 0, life: 0.5, radius: 0.34, rate: 0, smoke: 0.7, sparks: 0.7, light: true, width: 3.2, up: 2.8, down: 1.7, bodyWidth: 0.8, bodyHeight: 0.8, bodyRise: 0 },
|
|
9
|
+
};
|
|
10
|
+
const PALETTES = {
|
|
11
|
+
fire: ["#fff4b8", "#ffab2e", "#e8401c"],
|
|
12
|
+
blue: ["#eef9ff", "#5cc8ff", "#2c4fff"],
|
|
13
|
+
green: ["#f2ffd2", "#8cff4a", "#14a038"],
|
|
14
|
+
purple: ["#ffe9ff", "#d27bff", "#7326ff"],
|
|
15
|
+
};
|
|
16
|
+
const DEFAULT_SMOKE = "#4a4440";
|
|
17
|
+
const FLAME = 0;
|
|
18
|
+
const SMOKE = 1;
|
|
19
|
+
const SPARK = 2;
|
|
20
|
+
/**
|
|
21
|
+
* Fire for a game: torches, campfires, burning sprites, fireballs, fire where
|
|
22
|
+
* something was destroyed, and explosions. It makes each flame and advances
|
|
23
|
+
* all of them with one {@link Fire.update} call per frame.
|
|
24
|
+
*
|
|
25
|
+
* Every flame is a sprite, a {@link Flame}, so the game places it, layers it,
|
|
26
|
+
* hides it, and destroys it as any other. Its particles are painted into its
|
|
27
|
+
* own surface, so a fire costs one sprite however many particles it has.
|
|
28
|
+
*
|
|
29
|
+
* The module draws fire; the game decides what catches fire, what it hurts,
|
|
30
|
+
* and what puts it out.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* const fire = new Fire(game, { style: "pixel" });
|
|
35
|
+
* game.onUpdate = (dt) => fire.update(dt);
|
|
36
|
+
*
|
|
37
|
+
* const torch = fire.create("torch", { x: 120, y: 200, size: 28 });
|
|
38
|
+
* fire.burst(crate.x, crate.y, { size: 48, burnMs: 4000 }); // where the crate was
|
|
39
|
+
* fire.attach(fireball, { kind: "trail", palette: "blue" });
|
|
40
|
+
* torch.extinguish(600);
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export class Fire {
|
|
44
|
+
constructor(game, options = {}) {
|
|
45
|
+
/** @internal */
|
|
46
|
+
this._particleCount = 0;
|
|
47
|
+
/** @internal */
|
|
48
|
+
this._flames = [];
|
|
49
|
+
this.game = game;
|
|
50
|
+
const { maxParticles, reducedMotion, wind, seed, ...look } = options;
|
|
51
|
+
this.look = look;
|
|
52
|
+
if (look.palette !== undefined)
|
|
53
|
+
resolvePalette(look.palette);
|
|
54
|
+
if (look.style !== undefined)
|
|
55
|
+
checkStyle(look.style);
|
|
56
|
+
this.wind = finiteOr(wind, 0);
|
|
57
|
+
this.reducedMotion = reducedMotion ?? prefersReducedMotion();
|
|
58
|
+
this._random = typeof seed === "number" && Number.isFinite(seed) ? seededRandom(seed) : Math.random;
|
|
59
|
+
let max = Math.max(0, Math.floor(finiteOr(maxParticles, 1200)));
|
|
60
|
+
const cores = typeof navigator !== "undefined" ? navigator.hardwareConcurrency ?? 8 : 8;
|
|
61
|
+
if (maxParticles === undefined && game.isMobileDevice() && cores <= 4)
|
|
62
|
+
max = Math.round(max * 0.6);
|
|
63
|
+
this._maxParticles = max;
|
|
64
|
+
}
|
|
65
|
+
/** The flames burning now, in the order they were made. */
|
|
66
|
+
get flames() {
|
|
67
|
+
return this._flames;
|
|
68
|
+
}
|
|
69
|
+
/** How many particles are alive across every flame. */
|
|
70
|
+
get particleCount() {
|
|
71
|
+
return this._particleCount;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Makes a flame, adds it to the game, and returns it. It burns until the
|
|
75
|
+
* game puts it out with {@link Flame.extinguish} or removes it.
|
|
76
|
+
* @param kind - `"torch"`, `"campfire"`, `"burning"`, or `"trail"`.
|
|
77
|
+
* @param options - Position, size, intensity, and any look over the fire's.
|
|
78
|
+
*/
|
|
79
|
+
create(kind, options = {}) {
|
|
80
|
+
checkKind(kind);
|
|
81
|
+
return this._add(new Flame(this, kind, options));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Makes a flame that follows `sprite` every frame and adds it to the game.
|
|
85
|
+
* Its particles stay where they were emitted, so a moving sprite leaves a
|
|
86
|
+
* trail behind it and a still one does not. The flame goes out by itself
|
|
87
|
+
* when the sprite is destroyed, or after `ms`.
|
|
88
|
+
* @param sprite - A sprite already added to the game.
|
|
89
|
+
* @param options - The kind, `"burning"` by default, where on the sprite, for how long, and the look.
|
|
90
|
+
*/
|
|
91
|
+
attach(sprite, options = {}) {
|
|
92
|
+
const kind = options.kind ?? "burning";
|
|
93
|
+
checkKind(kind);
|
|
94
|
+
const { kind: _kind, offsetX, offsetY, ms, ...rest } = options;
|
|
95
|
+
const size = options.size ?? (kind === "burning" ? Math.max(8, sprite.displayWidth) : kind === "trail" ? Math.max(8, sprite.displayWidth * 0.8) : 32);
|
|
96
|
+
const top = kind === "torch" || kind === "campfire" ? -sprite.displayHeight / 2 : 0;
|
|
97
|
+
const flame = new Flame(this, kind, { ...rest, size, layer: options.layer ?? sprite.layer });
|
|
98
|
+
flame._target = sprite;
|
|
99
|
+
flame._offsetX = finiteOr(offsetX, 0);
|
|
100
|
+
flame._offsetY = finiteOr(offsetY, top);
|
|
101
|
+
if (typeof ms === "number" && Number.isFinite(ms))
|
|
102
|
+
flame._expiresIn = Math.max(0, ms) / 1000;
|
|
103
|
+
flame._follow();
|
|
104
|
+
return this._add(flame);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Leaves fire where something was destroyed: a burst of flame, then flames
|
|
108
|
+
* that die down into smoke and are gone after `burnMs`.
|
|
109
|
+
* @returns A promise that resolves when the fire is out and removed.
|
|
110
|
+
*/
|
|
111
|
+
burst(x, y, options = {}) {
|
|
112
|
+
const { size, burnMs, layer, ...look } = options;
|
|
113
|
+
const flame = this._add(new Flame(this, "burst", { ...look, x, y, size: size ?? 48, layer }));
|
|
114
|
+
flame._blast(0.5);
|
|
115
|
+
flame._burnFor(Math.max(0, finiteOr(burnMs, 3000)) / 1000);
|
|
116
|
+
return flame._whenOut();
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Blows up at a point: a fireball with sparks and a cloud of smoke, which
|
|
120
|
+
* brightens the scene once and is gone in about a second and a half.
|
|
121
|
+
* @returns A promise that resolves when the explosion is over and removed.
|
|
122
|
+
*/
|
|
123
|
+
explosion(x, y, options = {}) {
|
|
124
|
+
const { size, layer, ...look } = options;
|
|
125
|
+
const flame = this._add(new Flame(this, "explosion", { ...look, x, y, size: size ?? 96, layer, intensity: 0 }));
|
|
126
|
+
flame._blast(1);
|
|
127
|
+
flame._out = true;
|
|
128
|
+
return flame._whenOut();
|
|
129
|
+
}
|
|
130
|
+
/** Removes a flame at once, with no dying down. */
|
|
131
|
+
remove(flame) {
|
|
132
|
+
this.game.destroySprite(flame);
|
|
133
|
+
this._drop(flame);
|
|
134
|
+
}
|
|
135
|
+
/** Removes every flame at once. */
|
|
136
|
+
clear() {
|
|
137
|
+
for (const flame of [...this._flames])
|
|
138
|
+
this.remove(flame);
|
|
139
|
+
}
|
|
140
|
+
/** Advances every flame. Call it once per frame from `onUpdate`. */
|
|
141
|
+
update(dt) {
|
|
142
|
+
const seconds = Math.max(0, finiteOr(dt, 0));
|
|
143
|
+
if (this._flames.length === 0)
|
|
144
|
+
return;
|
|
145
|
+
// A flame the game destroyed, such as by game.reset(), is dropped, and a
|
|
146
|
+
// flame whose sprite was destroyed goes out.
|
|
147
|
+
const alive = new Set(this.game.getSprites());
|
|
148
|
+
for (const flame of [...this._flames]) {
|
|
149
|
+
if (!alive.has(flame)) {
|
|
150
|
+
this._drop(flame);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (flame._target && !alive.has(flame._target)) {
|
|
154
|
+
flame._target = null;
|
|
155
|
+
void flame.extinguish(250);
|
|
156
|
+
}
|
|
157
|
+
flame._advance(seconds);
|
|
158
|
+
if (flame._finished())
|
|
159
|
+
this.remove(flame);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** @internal */
|
|
163
|
+
_add(flame) {
|
|
164
|
+
this._flames.push(flame);
|
|
165
|
+
this.game.add(flame);
|
|
166
|
+
return flame;
|
|
167
|
+
}
|
|
168
|
+
/** @internal */
|
|
169
|
+
_drop(flame) {
|
|
170
|
+
const index = this._flames.indexOf(flame);
|
|
171
|
+
if (index < 0)
|
|
172
|
+
return;
|
|
173
|
+
this._flames.splice(index, 1);
|
|
174
|
+
this._particleCount -= flame._particles.length;
|
|
175
|
+
flame._particles.length = 0;
|
|
176
|
+
flame._settle();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* One fire: a `DrawSprite` whose position is the base of the flame. It has
|
|
181
|
+
* everything a sprite has, layer, visibility, `ignoreScroll` for fire on the
|
|
182
|
+
* HUD, and a collision body over the flame's core, and it carries the tag
|
|
183
|
+
* `fire`. Do not rotate or scale it: its particles are placed in the world.
|
|
184
|
+
*
|
|
185
|
+
* Make flames with {@link Fire.create}, {@link Fire.attach},
|
|
186
|
+
* {@link Fire.burst}, or {@link Fire.explosion}, which also advance them.
|
|
187
|
+
*/
|
|
188
|
+
export class Flame extends DrawSprite {
|
|
189
|
+
/**
|
|
190
|
+
* Use {@link Fire.create}, {@link Fire.attach}, {@link Fire.burst}, or
|
|
191
|
+
* {@link Fire.explosion} instead, which also add and advance the flame.
|
|
192
|
+
*/
|
|
193
|
+
constructor(fire, kind, options = {}) {
|
|
194
|
+
const spec = KINDS[kind];
|
|
195
|
+
const size = Math.max(4, finiteOr(options.size, 32));
|
|
196
|
+
const width = Math.ceil(spec.width * size);
|
|
197
|
+
const height = Math.ceil((spec.up + spec.down) * size);
|
|
198
|
+
super(width, height, finiteOr(options.x, 0), finiteOr(options.y, 0));
|
|
199
|
+
/** Called once when the flame is out and removed. */
|
|
200
|
+
this.onOut = null;
|
|
201
|
+
/** @internal */
|
|
202
|
+
this._particles = [];
|
|
203
|
+
/** @internal */
|
|
204
|
+
this._target = null;
|
|
205
|
+
/** @internal */
|
|
206
|
+
this._offsetX = 0;
|
|
207
|
+
/** @internal */
|
|
208
|
+
this._offsetY = 0;
|
|
209
|
+
/** @internal Seconds left before an attached flame goes out by itself. */
|
|
210
|
+
this._expiresIn = Infinity;
|
|
211
|
+
/** @internal No more particles are emitted; the flame is removed once the last one is gone. */
|
|
212
|
+
this._out = false;
|
|
213
|
+
/** @internal */
|
|
214
|
+
this._tween = null;
|
|
215
|
+
/** @internal */
|
|
216
|
+
this._time = 0;
|
|
217
|
+
/** @internal */
|
|
218
|
+
this._flash = 0;
|
|
219
|
+
/** @internal */
|
|
220
|
+
this._emitFlames = 0;
|
|
221
|
+
/** @internal */
|
|
222
|
+
this._emitSmoke = 0;
|
|
223
|
+
/** @internal */
|
|
224
|
+
this._emitSparks = 0;
|
|
225
|
+
/** @internal */
|
|
226
|
+
this._burn = null;
|
|
227
|
+
/** @internal Going out once the intensity reaches 0. */
|
|
228
|
+
this._extinguishing = false;
|
|
229
|
+
/** @internal */
|
|
230
|
+
this._waiting = [];
|
|
231
|
+
/** @internal */
|
|
232
|
+
this._settled = false;
|
|
233
|
+
/** @internal */
|
|
234
|
+
this._heat = null;
|
|
235
|
+
/** @internal Where the flame was at the last frame, to spread a moving flame's particles along its path. */
|
|
236
|
+
this._lastX = NaN;
|
|
237
|
+
/** @internal */
|
|
238
|
+
this._lastY = NaN;
|
|
239
|
+
this._fire = fire;
|
|
240
|
+
this._spec = spec;
|
|
241
|
+
this.kind = kind;
|
|
242
|
+
this.size = size;
|
|
243
|
+
this._baseX = width / 2;
|
|
244
|
+
this._baseY = spec.up * size;
|
|
245
|
+
const look = { ...fire.look, ...definedOnly(options) };
|
|
246
|
+
this.palette = look.palette ?? "fire";
|
|
247
|
+
resolvePalette(this.palette);
|
|
248
|
+
this.style = look.style ?? "soft";
|
|
249
|
+
checkStyle(this.style);
|
|
250
|
+
this.smoke = clamp01(finiteOr(look.smoke, spec.smoke));
|
|
251
|
+
this.sparks = clamp01(finiteOr(look.sparks, spec.sparks));
|
|
252
|
+
this.smokeColor = look.smokeColor ?? DEFAULT_SMOKE;
|
|
253
|
+
this.light = options.light ?? spec.light;
|
|
254
|
+
this._intensity = clamp01(finiteOr(options.intensity, 1));
|
|
255
|
+
this.layer = finiteOr(options.layer, 0);
|
|
256
|
+
this.tags = ["fire"];
|
|
257
|
+
this.bodyWidth = spec.bodyWidth * size;
|
|
258
|
+
this.bodyHeight = spec.bodyHeight * size;
|
|
259
|
+
this.bodyOffsetY = -spec.bodyRise * size - this.getAnchorOffsetY();
|
|
260
|
+
const random = fire._random;
|
|
261
|
+
this._phases = [random() * 6.283, random() * 6.283, random() * 6.283];
|
|
262
|
+
}
|
|
263
|
+
/** How strongly it burns, from `0` to `1`. Setting it stops a change in progress. */
|
|
264
|
+
get intensity() {
|
|
265
|
+
return this._intensity;
|
|
266
|
+
}
|
|
267
|
+
set intensity(value) {
|
|
268
|
+
this._finishTween();
|
|
269
|
+
this._intensity = clamp01(finiteOr(value, this._intensity));
|
|
270
|
+
}
|
|
271
|
+
/** `true` once the flame is going out for good: it emits nothing more and is removed when its last particle is gone. */
|
|
272
|
+
get out() {
|
|
273
|
+
return this._out;
|
|
274
|
+
}
|
|
275
|
+
/** The sprite this flame follows, or `null`. */
|
|
276
|
+
get target() {
|
|
277
|
+
return this._target;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Changes the intensity over `ms` milliseconds.
|
|
281
|
+
* @returns A promise that resolves when it gets there, or when another change replaces it.
|
|
282
|
+
*/
|
|
283
|
+
to(intensity, ms) {
|
|
284
|
+
this._finishTween();
|
|
285
|
+
const target = clamp01(finiteOr(intensity, this._intensity));
|
|
286
|
+
const seconds = Math.max(0, finiteOr(ms, 0)) / 1000;
|
|
287
|
+
if (seconds === 0) {
|
|
288
|
+
this._intensity = target;
|
|
289
|
+
return Promise.resolve();
|
|
290
|
+
}
|
|
291
|
+
return new Promise((resolve) => {
|
|
292
|
+
this._tween = { from: this._intensity, to: target, seconds, elapsed: 0, resolve };
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Puts the flame out: it dies down over `ms` milliseconds into a puff of
|
|
297
|
+
* smoke, and is removed from the game when the smoke is gone.
|
|
298
|
+
* @returns A promise that resolves when the flame is out and removed.
|
|
299
|
+
*/
|
|
300
|
+
extinguish(ms = 600) {
|
|
301
|
+
const promise = this._whenOut();
|
|
302
|
+
if (this._out || this._settled)
|
|
303
|
+
return promise;
|
|
304
|
+
this._burn = null;
|
|
305
|
+
void this.to(0, ms);
|
|
306
|
+
this._extinguishing = true;
|
|
307
|
+
this._goOutWhenDown();
|
|
308
|
+
return promise;
|
|
309
|
+
}
|
|
310
|
+
getAnchorOffsetY() {
|
|
311
|
+
// The position is the flame's base, which sits low in its surface, so
|
|
312
|
+
// the surface's center is above it.
|
|
313
|
+
return this.height / 2 - this._baseY;
|
|
314
|
+
}
|
|
315
|
+
/** @internal Called by the fire once per frame. */
|
|
316
|
+
_advance(dt) {
|
|
317
|
+
this._time += dt;
|
|
318
|
+
this._advanceTween(dt);
|
|
319
|
+
this._goOutWhenDown();
|
|
320
|
+
if (this._burn) {
|
|
321
|
+
const burn = this._burn;
|
|
322
|
+
burn.elapsed = Math.min(burn.seconds, burn.elapsed + dt);
|
|
323
|
+
const u = burn.seconds > 0 ? burn.elapsed / burn.seconds : 1;
|
|
324
|
+
this._intensity = 1 - Math.pow(u, 1.6);
|
|
325
|
+
if (u >= 1) {
|
|
326
|
+
this._burn = null;
|
|
327
|
+
this._puff();
|
|
328
|
+
this._out = true;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (this._target) {
|
|
332
|
+
this._follow();
|
|
333
|
+
this._expiresIn -= dt;
|
|
334
|
+
if (this._expiresIn <= 0) {
|
|
335
|
+
this._target = null;
|
|
336
|
+
void this.extinguish(500);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
this._flash = Math.max(0, this._flash - dt);
|
|
340
|
+
if (!this._out && dt > 0)
|
|
341
|
+
this._emit(dt);
|
|
342
|
+
this._lastX = this.x;
|
|
343
|
+
this._lastY = this.y;
|
|
344
|
+
this._move(dt);
|
|
345
|
+
if (this._particles.length > 0 || this._glows())
|
|
346
|
+
this.frozen = false;
|
|
347
|
+
}
|
|
348
|
+
/** @internal */
|
|
349
|
+
_finished() {
|
|
350
|
+
return this._out && this._particles.length === 0 && this._flash <= 0;
|
|
351
|
+
}
|
|
352
|
+
/** @internal */
|
|
353
|
+
_follow() {
|
|
354
|
+
const target = this._target;
|
|
355
|
+
if (!target)
|
|
356
|
+
return;
|
|
357
|
+
this.x = target.x + this._offsetX;
|
|
358
|
+
this.y = target.y + this._offsetY;
|
|
359
|
+
this.visible = target.visible;
|
|
360
|
+
}
|
|
361
|
+
/** @internal Burns down from full intensity to out over `seconds`. */
|
|
362
|
+
_burnFor(seconds) {
|
|
363
|
+
this._burn = { seconds, elapsed: 0 };
|
|
364
|
+
}
|
|
365
|
+
/** @internal A fireball with sparks and smoke, at `strength` of an explosion's. */
|
|
366
|
+
_blast(strength) {
|
|
367
|
+
const random = this._fire._random;
|
|
368
|
+
const s = this.size;
|
|
369
|
+
const full = strength >= 1;
|
|
370
|
+
const flames = Math.round((full ? 30 : 14) * this._density());
|
|
371
|
+
for (let i = 0; i < flames; i++) {
|
|
372
|
+
const angle = random() * Math.PI * 2;
|
|
373
|
+
const speed = s * (full ? 1.6 + random() * 1.4 : 0.9 + random() * 0.9);
|
|
374
|
+
this._spawn(FLAME, this.x, this.y - s * 0.1, Math.cos(angle) * speed, Math.sin(angle) * speed * 0.8 - s * 0.4, 0.3 + random() * 0.3, s * (0.24 + random() * 0.16) * (full ? 1.2 : 1), 3.2, 0);
|
|
375
|
+
}
|
|
376
|
+
const sparks = Math.round((full ? 20 : 8) * this.sparks * this._density());
|
|
377
|
+
for (let i = 0; i < sparks; i++) {
|
|
378
|
+
const angle = random() * Math.PI * 2;
|
|
379
|
+
const speed = s * (2.6 + random() * 2.4);
|
|
380
|
+
this._spawn(SPARK, this.x, this.y, Math.cos(angle) * speed, Math.sin(angle) * speed - s, 0.4 + random() * 0.45, Math.max(1.5, s * 0.035), 1.8, 0);
|
|
381
|
+
}
|
|
382
|
+
const smoke = Math.round((full ? 10 : 5) * this.smoke * this._density());
|
|
383
|
+
for (let i = 0; i < smoke; i++) {
|
|
384
|
+
const angle = random() * Math.PI * 2;
|
|
385
|
+
const r = random() * s * 0.3;
|
|
386
|
+
const p = this._spawn(SMOKE, this.x + Math.cos(angle) * r, this.y + Math.sin(angle) * r - s * 0.2, Math.cos(angle) * s * 0.3, -s * (0.4 + random() * 0.4), 1.2 + random() * 0.6, s * (0.22 + random() * 0.1), 0.8, 0);
|
|
387
|
+
if (p)
|
|
388
|
+
p.age = -(0.08 + random() * 0.22);
|
|
389
|
+
}
|
|
390
|
+
this._flash = full ? 0.45 : 0.25;
|
|
391
|
+
this.frozen = false;
|
|
392
|
+
}
|
|
393
|
+
/** @internal Resolves when the flame is out and removed. */
|
|
394
|
+
_whenOut() {
|
|
395
|
+
if (this._settled)
|
|
396
|
+
return Promise.resolve();
|
|
397
|
+
return new Promise((resolve) => this._waiting.push(resolve));
|
|
398
|
+
}
|
|
399
|
+
/** @internal The flame is removed: answer every promise and call onOut once. */
|
|
400
|
+
_settle() {
|
|
401
|
+
if (this._settled)
|
|
402
|
+
return;
|
|
403
|
+
this._settled = true;
|
|
404
|
+
this._finishTween();
|
|
405
|
+
const waiting = this._waiting;
|
|
406
|
+
this._waiting = [];
|
|
407
|
+
for (const resolve of waiting)
|
|
408
|
+
resolve();
|
|
409
|
+
this.onOut?.();
|
|
410
|
+
}
|
|
411
|
+
redraw(ctx) {
|
|
412
|
+
if (this._particles.length === 0 && !this._glows()) {
|
|
413
|
+
this.frozen = true;
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const colors = resolvePalette(this.palette, PALETTES.fire);
|
|
417
|
+
if (this.style === "pixel")
|
|
418
|
+
this._drawPixel(ctx, colors);
|
|
419
|
+
else
|
|
420
|
+
this._drawSoft(ctx, colors);
|
|
421
|
+
}
|
|
422
|
+
// -------------------------------------------------------------------------
|
|
423
|
+
// Particles
|
|
424
|
+
// -------------------------------------------------------------------------
|
|
425
|
+
/** @internal The share of particles the device and the player's settings allow. */
|
|
426
|
+
_density() {
|
|
427
|
+
return this._fire.reducedMotion ? 0.5 : 1;
|
|
428
|
+
}
|
|
429
|
+
/** @internal */
|
|
430
|
+
_emit(dt) {
|
|
431
|
+
const spec = this._spec;
|
|
432
|
+
const random = this._fire._random;
|
|
433
|
+
const s = this.size;
|
|
434
|
+
const k = this._intensity;
|
|
435
|
+
if (k <= 0)
|
|
436
|
+
return;
|
|
437
|
+
const density = this._density();
|
|
438
|
+
this._emitFlames += spec.rate * k * density * dt;
|
|
439
|
+
this._emitSmoke += 14 * this.smoke * (0.35 + 0.65 * k) * density * dt;
|
|
440
|
+
this._emitSparks += 7 * this.sparks * k * density * dt;
|
|
441
|
+
const rise = spec.rise * s * (0.6 + 0.4 * k);
|
|
442
|
+
const radius = spec.radius * s * (0.55 + 0.45 * k);
|
|
443
|
+
// A flame that moved since the last frame emits along the way, so a fast
|
|
444
|
+
// fireball leaves a continuous trail rather than a row of dots.
|
|
445
|
+
const fromX = Number.isFinite(this._lastX) ? this._lastX : this.x;
|
|
446
|
+
const fromY = Number.isFinite(this._lastY) ? this._lastY : this.y;
|
|
447
|
+
const count = Math.floor(this._emitFlames);
|
|
448
|
+
for (let i = 0; this._emitFlames >= 1; this._emitFlames--, i++) {
|
|
449
|
+
const along = (i + 1) / count;
|
|
450
|
+
const across = random() * 2 - 1;
|
|
451
|
+
const x = fromX + (this.x - fromX) * along + across * spec.spread * s;
|
|
452
|
+
const y = fromY + (this.y - fromY) * along + (random() * 2 - 1) * spec.spreadY * s;
|
|
453
|
+
const life = spec.life * (0.75 + random() * 0.5);
|
|
454
|
+
// Flames lean in toward the middle as they rise, so they taper to a tip.
|
|
455
|
+
const vx = (-across * spec.spread * s * 0.8) / life + (random() - 0.5) * s * 0.3;
|
|
456
|
+
this._spawn(FLAME, x, y, vx, -rise * (0.8 + random() * 0.4), life, radius * (0.75 + random() * 0.4), 0, across);
|
|
457
|
+
}
|
|
458
|
+
const tip = spec.rise * s * spec.life * 0.55;
|
|
459
|
+
for (; this._emitSmoke >= 1; this._emitSmoke--) {
|
|
460
|
+
const x = this.x + (random() * 2 - 1) * spec.spread * s * 0.6;
|
|
461
|
+
this._spawn(SMOKE, x, this.y - tip, (random() - 0.5) * s * 0.25, -s * (0.7 + random() * 0.5), 1.3 + random() * 0.7, s * (0.18 + random() * 0.08), 0.3, 0);
|
|
462
|
+
}
|
|
463
|
+
for (; this._emitSparks >= 1; this._emitSparks--) {
|
|
464
|
+
const x = this.x + (random() * 2 - 1) * spec.spread * s;
|
|
465
|
+
this._spawn(SPARK, x, this.y - s * 0.2, (random() - 0.5) * s * 1.4, -rise * (1.3 + random() * 0.6), 0.5 + random() * 0.6, Math.max(1.5, s * 0.035), 0.6, 0);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/** @internal An extinguished flame goes out, in a puff of smoke, once it has died down. */
|
|
469
|
+
_goOutWhenDown() {
|
|
470
|
+
if (!this._extinguishing || this._out || this._tween)
|
|
471
|
+
return;
|
|
472
|
+
this._extinguishing = false;
|
|
473
|
+
this._puff();
|
|
474
|
+
this._out = true;
|
|
475
|
+
}
|
|
476
|
+
/** @internal */
|
|
477
|
+
_spawn(type, x, y, vx, vy, life, radius, drag, across) {
|
|
478
|
+
const fire = this._fire;
|
|
479
|
+
if (this._settled || fire._particleCount >= fire._maxParticles)
|
|
480
|
+
return null;
|
|
481
|
+
const p = { type, x, y, vx, vy, age: 0, life, radius, drag, across, phase: fire._random() * 6.283 };
|
|
482
|
+
this._particles.push(p);
|
|
483
|
+
fire._particleCount++;
|
|
484
|
+
return p;
|
|
485
|
+
}
|
|
486
|
+
/** @internal A puff of smoke where the flame was. */
|
|
487
|
+
_puff() {
|
|
488
|
+
const random = this._fire._random;
|
|
489
|
+
const s = this.size;
|
|
490
|
+
const count = Math.round(6 * this._density());
|
|
491
|
+
for (let i = 0; i < count; i++) {
|
|
492
|
+
this._spawn(SMOKE, this.x + (random() * 2 - 1) * s * 0.3, this.y - s * 0.3, (random() - 0.5) * s * 0.4, -s * (0.6 + random() * 0.5), 1 + random() * 0.6, s * (0.2 + random() * 0.1), 0.4, 0);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
/** @internal */
|
|
496
|
+
_move(dt) {
|
|
497
|
+
const fire = this._fire;
|
|
498
|
+
const particles = this._particles;
|
|
499
|
+
const wind = fire.wind;
|
|
500
|
+
const s = this.size;
|
|
501
|
+
for (let i = particles.length - 1; i >= 0; i--) {
|
|
502
|
+
const p = particles[i];
|
|
503
|
+
p.age += dt;
|
|
504
|
+
if (p.age >= p.life) {
|
|
505
|
+
particles[i] = particles[particles.length - 1];
|
|
506
|
+
particles.pop();
|
|
507
|
+
fire._particleCount--;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (p.age < 0)
|
|
511
|
+
continue;
|
|
512
|
+
const keep = Math.exp(-p.drag * dt);
|
|
513
|
+
p.vx *= keep;
|
|
514
|
+
p.vy *= keep;
|
|
515
|
+
const lean = p.type === SMOKE ? 0.45 : p.type === SPARK ? 0.35 : 0.25;
|
|
516
|
+
let vx = p.vx + wind * lean;
|
|
517
|
+
if (p.type === FLAME)
|
|
518
|
+
vx += Math.sin(this._time * 9 + p.phase) * s * 0.35;
|
|
519
|
+
if (p.type === SPARK)
|
|
520
|
+
p.vy += s * 1.2 * dt;
|
|
521
|
+
p.x += vx * dt;
|
|
522
|
+
p.y += p.vy * dt;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/** @internal */
|
|
526
|
+
_glows() {
|
|
527
|
+
return (this.light && this._intensity > 0 && !this._out) || this._flash > 0;
|
|
528
|
+
}
|
|
529
|
+
/** @internal A gentle flicker around 1: small and smooth, never a flash. */
|
|
530
|
+
_flicker() {
|
|
531
|
+
const [a, b, c] = this._phases;
|
|
532
|
+
const t = this._time;
|
|
533
|
+
const amount = this._fire.reducedMotion ? 0.5 : 1;
|
|
534
|
+
return 1 + amount * (0.07 * Math.sin(t * 11 + a) + 0.05 * Math.sin(t * 17.3 + b) + 0.04 * Math.sin(t * 6.1 + c));
|
|
535
|
+
}
|
|
536
|
+
/** @internal How bright the glow is, and how far it reaches, in pixels. */
|
|
537
|
+
_glow() {
|
|
538
|
+
const s = this.size;
|
|
539
|
+
const flicker = this._flicker();
|
|
540
|
+
let alpha = this.light && !this._out ? 0.3 * this._intensity * flicker : 0;
|
|
541
|
+
let radius = s * 1.6 * (0.8 + 0.2 * this._intensity) * flicker;
|
|
542
|
+
if (this._flash > 0) {
|
|
543
|
+
// An explosion or a burst brightens once and fades.
|
|
544
|
+
const total = this.kind === "explosion" ? 0.45 : 0.25;
|
|
545
|
+
const u = 1 - this._flash / total;
|
|
546
|
+
alpha = Math.max(alpha, 0.8 * (1 - u));
|
|
547
|
+
radius = Math.max(radius, s * (0.9 + 0.5 * u));
|
|
548
|
+
}
|
|
549
|
+
return { alpha: Math.min(1, alpha), radius };
|
|
550
|
+
}
|
|
551
|
+
/** @internal The particle's place in the surface, and how much the surface's edge fades it. */
|
|
552
|
+
_local(p) {
|
|
553
|
+
const lx = p.x - this.x + this._baseX;
|
|
554
|
+
const ly = p.y - this.y + this._baseY;
|
|
555
|
+
const band = this.size * 0.6;
|
|
556
|
+
const edge = Math.min(lx, this.width - lx, ly, this.height - ly);
|
|
557
|
+
return [lx, ly, Math.max(0, Math.min(1, edge / band))];
|
|
558
|
+
}
|
|
559
|
+
// -------------------------------------------------------------------------
|
|
560
|
+
// Soft style
|
|
561
|
+
// -------------------------------------------------------------------------
|
|
562
|
+
/** @internal */
|
|
563
|
+
_drawSoft(ctx, colors) {
|
|
564
|
+
const glow = this._glow();
|
|
565
|
+
const cx = this._baseX;
|
|
566
|
+
const cy = this._baseY - this.size * this._spec.bodyRise;
|
|
567
|
+
ctx.globalCompositeOperation = "lighter";
|
|
568
|
+
if (glow.alpha > 0.004) {
|
|
569
|
+
ctx.globalAlpha = glow.alpha;
|
|
570
|
+
ctx.drawImage(blob(colors[2], 0.9), cx - glow.radius, cy - glow.radius, glow.radius * 2, glow.radius * 2);
|
|
571
|
+
}
|
|
572
|
+
ctx.globalCompositeOperation = "source-over";
|
|
573
|
+
const smoke = blob(this.smokeColor, 0.55);
|
|
574
|
+
for (const p of this._particles) {
|
|
575
|
+
if (p.type !== SMOKE || p.age < 0)
|
|
576
|
+
continue;
|
|
577
|
+
const a = p.age / p.life;
|
|
578
|
+
const [lx, ly, fade] = this._local(p);
|
|
579
|
+
const alpha = 0.32 * Math.min(1, a / 0.2) * (1 - a) * fade;
|
|
580
|
+
if (alpha <= 0.004)
|
|
581
|
+
continue;
|
|
582
|
+
const r = p.radius * (1 + a * 2.2);
|
|
583
|
+
ctx.globalAlpha = alpha;
|
|
584
|
+
ctx.drawImage(smoke, lx - r, ly - r, r * 2, r * 2);
|
|
585
|
+
}
|
|
586
|
+
ctx.globalCompositeOperation = "lighter";
|
|
587
|
+
for (const p of this._particles) {
|
|
588
|
+
if (p.type === SMOKE || p.age < 0)
|
|
589
|
+
continue;
|
|
590
|
+
const a = p.age / p.life;
|
|
591
|
+
const [lx, ly, fade] = this._local(p);
|
|
592
|
+
if (p.type === SPARK) {
|
|
593
|
+
const alpha = (1 - a) * fade * (0.7 + 0.3 * Math.sin(p.phase + this._time * 30));
|
|
594
|
+
if (alpha <= 0.004)
|
|
595
|
+
continue;
|
|
596
|
+
const r = p.radius * 2;
|
|
597
|
+
ctx.globalAlpha = alpha;
|
|
598
|
+
ctx.drawImage(blob(a < 0.5 ? colors[0] : colors[1], 1), lx - r, ly - r, r * 2, r * 2);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
const r = p.radius * Math.pow(1 - a, 0.6);
|
|
602
|
+
if (r < 0.5)
|
|
603
|
+
continue;
|
|
604
|
+
const alpha = 0.55 * Math.pow(1 - a, 0.8) * fade;
|
|
605
|
+
// Hot at birth, cooling to the edge color: two neighbouring stops, mixed.
|
|
606
|
+
const [inner, outer, mix] = a < 0.2 ? [0, 1, a / 0.2] : [1, 2, Math.min(1, (a - 0.2) / 0.45)];
|
|
607
|
+
ctx.globalAlpha = alpha * (1 - mix);
|
|
608
|
+
ctx.drawImage(blob(colors[inner], 0.75), lx - r, ly - r, r * 2, r * 2);
|
|
609
|
+
ctx.globalAlpha = alpha * mix;
|
|
610
|
+
ctx.drawImage(blob(colors[outer], 0.75), lx - r, ly - r, r * 2, r * 2);
|
|
611
|
+
}
|
|
612
|
+
ctx.globalAlpha = 1;
|
|
613
|
+
ctx.globalCompositeOperation = "source-over";
|
|
614
|
+
}
|
|
615
|
+
// -------------------------------------------------------------------------
|
|
616
|
+
// Pixel style
|
|
617
|
+
// -------------------------------------------------------------------------
|
|
618
|
+
/** @internal */
|
|
619
|
+
_drawPixel(ctx, colors) {
|
|
620
|
+
const cell = Math.max(2, Math.round(this.size / 9));
|
|
621
|
+
const columns = Math.ceil(this.width / cell);
|
|
622
|
+
const rows = Math.ceil(this.height / cell);
|
|
623
|
+
const glow = this._glow();
|
|
624
|
+
const cx = this._baseX;
|
|
625
|
+
const cy = this._baseY - this.size * this._spec.bodyRise;
|
|
626
|
+
// The glow: two stepped discs of the edge color.
|
|
627
|
+
if (glow.alpha > 0.004) {
|
|
628
|
+
ctx.fillStyle = colors[2];
|
|
629
|
+
for (const [share, alpha] of [[1, 0.35], [0.6, 0.45]]) {
|
|
630
|
+
ctx.globalAlpha = glow.alpha * alpha;
|
|
631
|
+
pixelDisc(ctx, cx, cy, glow.radius * share, cell);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
// Smoke: squares on the grid, in three steps of transparency.
|
|
635
|
+
ctx.fillStyle = this.smokeColor;
|
|
636
|
+
for (const p of this._particles) {
|
|
637
|
+
if (p.type !== SMOKE || p.age < 0)
|
|
638
|
+
continue;
|
|
639
|
+
const a = p.age / p.life;
|
|
640
|
+
const [lx, ly, fade] = this._local(p);
|
|
641
|
+
const alpha = 0.4 * Math.min(1, a / 0.2) * (1 - a) * fade;
|
|
642
|
+
const step = Math.round(alpha * 6) / 6;
|
|
643
|
+
if (step <= 0)
|
|
644
|
+
continue;
|
|
645
|
+
const half = Math.max(1, Math.round((p.radius * (1 + a * 2.2)) / cell)) * cell;
|
|
646
|
+
ctx.globalAlpha = step;
|
|
647
|
+
ctx.fillRect(snap(lx - half, cell), snap(ly - half, cell), half * 2, half * 2);
|
|
648
|
+
}
|
|
649
|
+
ctx.globalAlpha = 1;
|
|
650
|
+
// Flames: each particle heats the cells under it, and each cell takes the
|
|
651
|
+
// palette color of its heat.
|
|
652
|
+
const size = columns * rows;
|
|
653
|
+
if (!this._heat || this._heat.length !== size)
|
|
654
|
+
this._heat = new Float32Array(size);
|
|
655
|
+
const heat = this._heat;
|
|
656
|
+
heat.fill(0);
|
|
657
|
+
let any = false;
|
|
658
|
+
for (const p of this._particles) {
|
|
659
|
+
if (p.type !== FLAME || p.age < 0)
|
|
660
|
+
continue;
|
|
661
|
+
const a = p.age / p.life;
|
|
662
|
+
const r = p.radius * Math.pow(1 - a, 0.6);
|
|
663
|
+
if (r < 0.5)
|
|
664
|
+
continue;
|
|
665
|
+
const [lx, ly, fade] = this._local(p);
|
|
666
|
+
const hot = (1 - a) * fade * 1.25;
|
|
667
|
+
const reach = r / cell;
|
|
668
|
+
const col = lx / cell;
|
|
669
|
+
const row = ly / cell;
|
|
670
|
+
const c0 = Math.max(0, Math.floor(col - reach));
|
|
671
|
+
const c1 = Math.min(columns - 1, Math.ceil(col + reach));
|
|
672
|
+
const r0 = Math.max(0, Math.floor(row - reach));
|
|
673
|
+
const r1 = Math.min(rows - 1, Math.ceil(row + reach));
|
|
674
|
+
for (let y = r0; y <= r1; y++) {
|
|
675
|
+
for (let x = c0; x <= c1; x++) {
|
|
676
|
+
const d = Math.hypot(x + 0.5 - col, y + 0.5 - row) / reach;
|
|
677
|
+
if (d < 1) {
|
|
678
|
+
heat[y * columns + x] += hot * (1 - d);
|
|
679
|
+
any = true;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (any) {
|
|
685
|
+
const bands = [[0.25, colors[2]], [0.7, colors[1]], [1.5, colors[0]]];
|
|
686
|
+
for (const [threshold, color] of bands) {
|
|
687
|
+
ctx.fillStyle = color;
|
|
688
|
+
ctx.beginPath();
|
|
689
|
+
for (let i = 0; i < size; i++) {
|
|
690
|
+
if (heat[i] >= threshold)
|
|
691
|
+
ctx.rect((i % columns) * cell, Math.floor(i / columns) * cell, cell, cell);
|
|
692
|
+
}
|
|
693
|
+
ctx.fill();
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
// Sparks: one cell each.
|
|
697
|
+
for (const p of this._particles) {
|
|
698
|
+
if (p.type !== SPARK || p.age < 0)
|
|
699
|
+
continue;
|
|
700
|
+
const a = p.age / p.life;
|
|
701
|
+
const [lx, ly, fade] = this._local(p);
|
|
702
|
+
if (fade <= 0)
|
|
703
|
+
continue;
|
|
704
|
+
ctx.fillStyle = a < 0.5 ? colors[0] : colors[1];
|
|
705
|
+
ctx.fillRect(snap(lx, cell), snap(ly, cell), cell, cell);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
// -------------------------------------------------------------------------
|
|
709
|
+
// Intensity changes
|
|
710
|
+
// -------------------------------------------------------------------------
|
|
711
|
+
/** @internal */
|
|
712
|
+
_advanceTween(dt) {
|
|
713
|
+
const tween = this._tween;
|
|
714
|
+
if (!tween)
|
|
715
|
+
return;
|
|
716
|
+
tween.elapsed = Math.min(tween.seconds, tween.elapsed + dt);
|
|
717
|
+
const u = tween.elapsed / tween.seconds;
|
|
718
|
+
this._intensity = tween.from + (tween.to - tween.from) * u;
|
|
719
|
+
if (u >= 1)
|
|
720
|
+
this._finishTween();
|
|
721
|
+
}
|
|
722
|
+
/** @internal */
|
|
723
|
+
_finishTween() {
|
|
724
|
+
const tween = this._tween;
|
|
725
|
+
if (!tween)
|
|
726
|
+
return;
|
|
727
|
+
this._tween = null;
|
|
728
|
+
this._intensity = tween.to;
|
|
729
|
+
tween.resolve();
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
// ---------------------------------------------------------------------------
|
|
733
|
+
// Helpers
|
|
734
|
+
// ---------------------------------------------------------------------------
|
|
735
|
+
const blobs = new Map();
|
|
736
|
+
/** A round, soft dot of `color`, drawn once and kept: the soft style's particle. */
|
|
737
|
+
function blob(color, core) {
|
|
738
|
+
const key = `${color}|${core}`;
|
|
739
|
+
let canvas = blobs.get(key);
|
|
740
|
+
if (!canvas) {
|
|
741
|
+
canvas = document.createElement("canvas");
|
|
742
|
+
canvas.width = 64;
|
|
743
|
+
canvas.height = 64;
|
|
744
|
+
const ctx = canvas.getContext("2d");
|
|
745
|
+
if (ctx) {
|
|
746
|
+
const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
|
|
747
|
+
gradient.addColorStop(0, color);
|
|
748
|
+
gradient.addColorStop(core * 0.5, color);
|
|
749
|
+
gradient.addColorStop(1, "rgba(0, 0, 0, 0)");
|
|
750
|
+
ctx.fillStyle = gradient;
|
|
751
|
+
ctx.globalAlpha = 1;
|
|
752
|
+
ctx.fillRect(0, 0, 64, 64);
|
|
753
|
+
// The gradient's last stop is transparent black; redraw its color into
|
|
754
|
+
// the fade so the edge does not darken.
|
|
755
|
+
ctx.globalCompositeOperation = "source-in";
|
|
756
|
+
ctx.fillStyle = color;
|
|
757
|
+
ctx.fillRect(0, 0, 64, 64);
|
|
758
|
+
}
|
|
759
|
+
blobs.set(key, canvas);
|
|
760
|
+
}
|
|
761
|
+
return canvas;
|
|
762
|
+
}
|
|
763
|
+
/** A disc made of grid cells, row by row. */
|
|
764
|
+
function pixelDisc(ctx, cx, cy, radius, cell) {
|
|
765
|
+
const rows = Math.floor(radius / cell);
|
|
766
|
+
ctx.beginPath();
|
|
767
|
+
for (let r = -rows; r <= rows; r++) {
|
|
768
|
+
const y = cy + r * cell;
|
|
769
|
+
const half = Math.sqrt(Math.max(0, radius * radius - (r * cell) ** 2));
|
|
770
|
+
const left = snap(cx - half, cell);
|
|
771
|
+
const right = snap(cx + half, cell);
|
|
772
|
+
if (right > left)
|
|
773
|
+
ctx.rect(left, snap(y, cell), right - left, cell);
|
|
774
|
+
}
|
|
775
|
+
ctx.fill();
|
|
776
|
+
}
|
|
777
|
+
function snap(value, cell) {
|
|
778
|
+
return Math.round(value / cell) * cell;
|
|
779
|
+
}
|
|
780
|
+
function checkKind(kind) {
|
|
781
|
+
if (kind !== "torch" && kind !== "campfire" && kind !== "burning" && kind !== "trail") {
|
|
782
|
+
throw new Error(`MinimoJS: There is no fire kind "${String(kind)}"; the kinds are torch, campfire, burning, and trail.`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
function checkStyle(style) {
|
|
786
|
+
if (style !== "soft" && style !== "pixel") {
|
|
787
|
+
throw new Error(`MinimoJS: There is no fire style "${String(style)}"; the styles are soft and pixel.`);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
function resolvePalette(palette, fallback) {
|
|
791
|
+
if (Array.isArray(palette) && palette.length === 3 && palette.every((c) => typeof c === "string")) {
|
|
792
|
+
return palette;
|
|
793
|
+
}
|
|
794
|
+
if (typeof palette === "string" && Object.prototype.hasOwnProperty.call(PALETTES, palette)) {
|
|
795
|
+
return PALETTES[palette];
|
|
796
|
+
}
|
|
797
|
+
if (fallback)
|
|
798
|
+
return fallback;
|
|
799
|
+
throw new Error(`MinimoJS: There is no fire palette ${JSON.stringify(palette)}; use fire, blue, green, purple, or three colors from the core to the edge.`);
|
|
800
|
+
}
|
|
801
|
+
function definedOnly(value) {
|
|
802
|
+
const out = {};
|
|
803
|
+
for (const [key, field] of Object.entries(value))
|
|
804
|
+
if (field !== undefined)
|
|
805
|
+
out[key] = field;
|
|
806
|
+
return out;
|
|
807
|
+
}
|
|
808
|
+
function clamp01(value) {
|
|
809
|
+
return Math.max(0, Math.min(1, value));
|
|
810
|
+
}
|
|
811
|
+
function finiteOr(value, fallback) {
|
|
812
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
813
|
+
}
|
|
814
|
+
function prefersReducedMotion() {
|
|
815
|
+
try {
|
|
816
|
+
return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
|
|
817
|
+
}
|
|
818
|
+
catch {
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
function seededRandom(seed) {
|
|
823
|
+
let state = Math.floor(seed) >>> 0;
|
|
824
|
+
return () => {
|
|
825
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
826
|
+
let t = state;
|
|
827
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
828
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
829
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
830
|
+
};
|
|
831
|
+
}
|