minimojs 1.0.0-alpha.38 → 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.
@@ -0,0 +1,242 @@
1
+ import { DrawSprite, Game, Sprite } from "./minimo.js";
2
+ /**
3
+ * The kinds of fire {@link Fire.create} and {@link Fire.attach} make.
4
+ *
5
+ * - `"torch"`: a narrow, tall flame that glows. For torches, braziers, and
6
+ * lamps.
7
+ * - `"campfire"`: a wide flame with smoke and sparks that glows. For
8
+ * campfires, fireplaces, and burning debris.
9
+ * - `"burning"`: flames over a whole sprite, such as an enemy set alight or a
10
+ * burning house. The default of {@link Fire.attach}.
11
+ * - `"trail"`: a ball of fire that leaves flames and smoke behind it as it
12
+ * moves. For fireballs, flaming arrows, meteors, and exhausts.
13
+ */
14
+ export type FlameKind = "torch" | "campfire" | "burning" | "trail";
15
+ /** A named palette: orange fire, a blue gas flame, green poison, or purple magic. */
16
+ export type FirePaletteName = "fire" | "blue" | "green" | "purple";
17
+ /**
18
+ * The colors of a fire: a named palette, or three CSS colors for the core,
19
+ * the middle, and the edge of the flame, from hottest to coolest.
20
+ */
21
+ export type FirePalette = FirePaletteName | readonly [string, string, string];
22
+ /**
23
+ * How a fire is drawn: `"soft"`, glowing gradients that add up to light, or
24
+ * `"pixel"`, hard-edged squares in the palette's three colors, to match pixel
25
+ * art.
26
+ */
27
+ export type FireStyle = "soft" | "pixel";
28
+ /** The look of a fire. A {@link Fire} holds one for all its flames, and each flame can override any field. */
29
+ export interface FireLook {
30
+ /** Default `"fire"`. */
31
+ palette?: FirePalette;
32
+ /** Default `"soft"`. */
33
+ style?: FireStyle;
34
+ /** How much smoke rises, from `0` to `1`. Default: by kind. */
35
+ smoke?: number;
36
+ /** How many sparks fly, from `0` to `1`. Default: by kind. */
37
+ sparks?: number;
38
+ /** CSS color of the smoke. Default `"#4a4440"`. */
39
+ smokeColor?: string;
40
+ }
41
+ /** Settings of a {@link Fire}, and the look every flame starts from. */
42
+ export interface FireOptions extends FireLook {
43
+ /**
44
+ * The most particles alive at once, across every flame. Default `1200`,
45
+ * lowered on weak phones. A flame that would go over it emits fewer.
46
+ */
47
+ maxParticles?: number;
48
+ /**
49
+ * Fewer particles and a softer flicker. Default: the player's system
50
+ * `prefers-reduced-motion` setting.
51
+ */
52
+ reducedMotion?: boolean;
53
+ /** The first {@link Fire.wind}. Default `0`. */
54
+ wind?: number;
55
+ /**
56
+ * Seeds the module's own random source, so the same seed flickers the same
57
+ * way. Default: a different flicker every time. The module never calls
58
+ * `game.random()`, so drawing fire never changes a seeded game's sequence.
59
+ */
60
+ seed?: number;
61
+ }
62
+ /** Where a new flame burns, how big, and how it looks. */
63
+ export interface FlameOptions extends FireLook {
64
+ /** X position in world space, in pixels: the base of the flame. Default `0`. */
65
+ x?: number;
66
+ /** Y position in world space, in pixels: the base of the flame, where it burns from. Default `0`. */
67
+ y?: number;
68
+ /**
69
+ * About how wide the flame is, in pixels. Default `32`, or the sprite's
70
+ * width for a `"burning"` flame made by {@link Fire.attach}. Its collision
71
+ * body covers the flame's core; its drawing surface is larger, to hold its
72
+ * glow, smoke, and trail.
73
+ */
74
+ size?: number;
75
+ /** How strongly it burns, from `0` to `1`. Default `1`. */
76
+ intensity?: number;
77
+ /** Paint a soft, flickering glow around the flame. Default `true` for a torch and a campfire. */
78
+ light?: boolean;
79
+ /** The flame's sprite layer. Default `0`, or the sprite's layer for {@link Fire.attach}. */
80
+ layer?: number;
81
+ }
82
+ /** How {@link Fire.attach} keeps a flame on a sprite. */
83
+ export interface AttachOptions extends Omit<FlameOptions, "x" | "y"> {
84
+ /** Default `"burning"`. */
85
+ kind?: FlameKind;
86
+ /**
87
+ * Where the flame burns, from the sprite's position, in pixels. Default `0`;
88
+ * for a torch or a campfire, `offsetY` defaults to the sprite's top edge.
89
+ */
90
+ offsetX?: number;
91
+ offsetY?: number;
92
+ /** Put the flame out after this many milliseconds. Default: never. */
93
+ ms?: number;
94
+ }
95
+ /** How {@link Fire.burst} leaves fire where something was destroyed. */
96
+ export interface BurstOptions extends FireLook {
97
+ /** About how wide the fire is, in pixels. Default `48`. */
98
+ size?: number;
99
+ /** How long it burns before it is out, in milliseconds. Default `3000`. */
100
+ burnMs?: number;
101
+ /** Default `0`. */
102
+ layer?: number;
103
+ }
104
+ /** How {@link Fire.explosion} blows up. */
105
+ export interface ExplosionOptions extends FireLook {
106
+ /** About how wide the fireball grows, in pixels. Default `96`. */
107
+ size?: number;
108
+ /** Default `0`. */
109
+ layer?: number;
110
+ }
111
+ type InternalKind = FlameKind | "burst" | "explosion";
112
+ /**
113
+ * Fire for a game: torches, campfires, burning sprites, fireballs, fire where
114
+ * something was destroyed, and explosions. It makes each flame and advances
115
+ * all of them with one {@link Fire.update} call per frame.
116
+ *
117
+ * Every flame is a sprite, a {@link Flame}, so the game places it, layers it,
118
+ * hides it, and destroys it as any other. Its particles are painted into its
119
+ * own surface, so a fire costs one sprite however many particles it has.
120
+ *
121
+ * The module draws fire; the game decides what catches fire, what it hurts,
122
+ * and what puts it out.
123
+ *
124
+ * @example
125
+ * ```ts
126
+ * const fire = new Fire(game, { style: "pixel" });
127
+ * game.onUpdate = (dt) => fire.update(dt);
128
+ *
129
+ * const torch = fire.create("torch", { x: 120, y: 200, size: 28 });
130
+ * fire.burst(crate.x, crate.y, { size: 48, burnMs: 4000 }); // where the crate was
131
+ * fire.attach(fireball, { kind: "trail", palette: "blue" });
132
+ * torch.extinguish(600);
133
+ * ```
134
+ */
135
+ export declare class Fire {
136
+ /** The game the flames are added to. */
137
+ readonly game: Game;
138
+ /** The look every flame starts from. */
139
+ readonly look: FireLook;
140
+ /**
141
+ * Wind in pixels per second, positive to the right. Every flame, its smoke,
142
+ * and its sparks lean with it. A game with an `ambient` can copy
143
+ * `ambient.windX` here every frame.
144
+ */
145
+ wind: number;
146
+ /** Fewer particles and a softer flicker. Follows the player's system setting unless the game sets it. */
147
+ reducedMotion: boolean;
148
+ constructor(game: Game, options?: FireOptions);
149
+ /** The flames burning now, in the order they were made. */
150
+ get flames(): readonly Flame[];
151
+ /** How many particles are alive across every flame. */
152
+ get particleCount(): number;
153
+ /**
154
+ * Makes a flame, adds it to the game, and returns it. It burns until the
155
+ * game puts it out with {@link Flame.extinguish} or removes it.
156
+ * @param kind - `"torch"`, `"campfire"`, `"burning"`, or `"trail"`.
157
+ * @param options - Position, size, intensity, and any look over the fire's.
158
+ */
159
+ create(kind: FlameKind, options?: FlameOptions): Flame;
160
+ /**
161
+ * Makes a flame that follows `sprite` every frame and adds it to the game.
162
+ * Its particles stay where they were emitted, so a moving sprite leaves a
163
+ * trail behind it and a still one does not. The flame goes out by itself
164
+ * when the sprite is destroyed, or after `ms`.
165
+ * @param sprite - A sprite already added to the game.
166
+ * @param options - The kind, `"burning"` by default, where on the sprite, for how long, and the look.
167
+ */
168
+ attach(sprite: Sprite, options?: AttachOptions): Flame;
169
+ /**
170
+ * Leaves fire where something was destroyed: a burst of flame, then flames
171
+ * that die down into smoke and are gone after `burnMs`.
172
+ * @returns A promise that resolves when the fire is out and removed.
173
+ */
174
+ burst(x: number, y: number, options?: BurstOptions): Promise<void>;
175
+ /**
176
+ * Blows up at a point: a fireball with sparks and a cloud of smoke, which
177
+ * brightens the scene once and is gone in about a second and a half.
178
+ * @returns A promise that resolves when the explosion is over and removed.
179
+ */
180
+ explosion(x: number, y: number, options?: ExplosionOptions): Promise<void>;
181
+ /** Removes a flame at once, with no dying down. */
182
+ remove(flame: Flame): void;
183
+ /** Removes every flame at once. */
184
+ clear(): void;
185
+ /** Advances every flame. Call it once per frame from `onUpdate`. */
186
+ update(dt: number): void;
187
+ }
188
+ /**
189
+ * One fire: a `DrawSprite` whose position is the base of the flame. It has
190
+ * everything a sprite has, layer, visibility, `ignoreScroll` for fire on the
191
+ * HUD, and a collision body over the flame's core, and it carries the tag
192
+ * `fire`. Do not rotate or scale it: its particles are placed in the world.
193
+ *
194
+ * Make flames with {@link Fire.create}, {@link Fire.attach},
195
+ * {@link Fire.burst}, or {@link Fire.explosion}, which also advance them.
196
+ */
197
+ export declare class Flame extends DrawSprite {
198
+ /** The kind of fire. `"burst"` and `"explosion"` are the flames {@link Fire.burst} and {@link Fire.explosion} make. */
199
+ readonly kind: FlameKind | "burst" | "explosion";
200
+ /** About how wide the flame is, in pixels. */
201
+ readonly size: number;
202
+ /** The colors of this flame. */
203
+ palette: FirePalette;
204
+ /** How this flame is drawn. */
205
+ style: FireStyle;
206
+ /** How much smoke rises, from `0` to `1`. */
207
+ smoke: number;
208
+ /** How many sparks fly, from `0` to `1`. */
209
+ sparks: number;
210
+ /** CSS color of the smoke. */
211
+ smokeColor: string;
212
+ /** Paint a soft, flickering glow around the flame. */
213
+ light: boolean;
214
+ /** Called once when the flame is out and removed. */
215
+ onOut: (() => void) | null;
216
+ /**
217
+ * Use {@link Fire.create}, {@link Fire.attach}, {@link Fire.burst}, or
218
+ * {@link Fire.explosion} instead, which also add and advance the flame.
219
+ */
220
+ constructor(fire: Fire, kind: InternalKind, options?: FlameOptions);
221
+ /** How strongly it burns, from `0` to `1`. Setting it stops a change in progress. */
222
+ get intensity(): number;
223
+ set intensity(value: number);
224
+ /** `true` once the flame is going out for good: it emits nothing more and is removed when its last particle is gone. */
225
+ get out(): boolean;
226
+ /** The sprite this flame follows, or `null`. */
227
+ get target(): Sprite | null;
228
+ /**
229
+ * Changes the intensity over `ms` milliseconds.
230
+ * @returns A promise that resolves when it gets there, or when another change replaces it.
231
+ */
232
+ to(intensity: number, ms: number): Promise<void>;
233
+ /**
234
+ * Puts the flame out: it dies down over `ms` milliseconds into a puff of
235
+ * smoke, and is removed from the game when the smoke is gone.
236
+ * @returns A promise that resolves when the flame is out and removed.
237
+ */
238
+ extinguish(ms?: number): Promise<void>;
239
+ protected getAnchorOffsetY(): number;
240
+ redraw(ctx: CanvasRenderingContext2D): void;
241
+ }
242
+ export {};
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimojs",
3
- "version": "1.0.0-alpha.38",
3
+ "version": "1.0.0-alpha.39",
4
4
  "description": "MinimoJS v1 — ultra-minimal, flat, deterministic 2D web game engine. Image-first sprites with emoji as a zero-asset alternative, rAF loop, TypeScript-first, LLM-friendly.",
5
5
  "type": "module",
6
6
  "main": "dist/minimo.js",