paperlab 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,811 @@
1
+ import {
2
+ DAMAGE_CHANNELS
3
+ } from "./chunk-7TLIWPGM.js";
4
+
5
+ // src/fx/field.ts
6
+ var CHAR = DAMAGE_CHANNELS.char;
7
+ var SATURATION = DAMAGE_CHANNELS.saturation;
8
+ var HEAT = DAMAGE_CHANNELS.heat;
9
+ var PRESENCE = DAMAGE_CHANNELS.presence;
10
+ var FIELD_SIZE = 64;
11
+ var FIXED_DT = 1 / 120;
12
+ var MAX_STEPS_PER_FRAME = 8;
13
+ var REST = 1e-3;
14
+ var IGNITION = 0.35;
15
+ var STABLE = 0.4;
16
+ var DEFAULTS = {
17
+ fibre: 0,
18
+ anisotropy: 3,
19
+ heatDiffusion: 58e-5,
20
+ wicking: 45e-4,
21
+ charRate: 2.7,
22
+ consumeRate: 6,
23
+ combustion: 2.4,
24
+ cooling: 0.18,
25
+ wetResistance: 2.6,
26
+ drying: 0.015,
27
+ grain: 0.34,
28
+ seed: 1
29
+ };
30
+ function diffusionTensor(rate, anisotropy, fibre) {
31
+ const along = rate * 2 * anisotropy / (1 + anisotropy);
32
+ const across = rate * 2 / (1 + anisotropy);
33
+ const c = Math.cos(fibre);
34
+ const s = Math.sin(fibre);
35
+ return {
36
+ xx: along * c * c + across * s * s,
37
+ yy: along * s * s + across * c * c,
38
+ xy: (along - across) * c * s
39
+ };
40
+ }
41
+ function stencil(rate, anisotropy, fibre, dt = FIXED_DT) {
42
+ const d = diffusionTensor(rate, anisotropy, fibre);
43
+ const cross = Math.min(Math.abs(d.xy), d.xx, d.yy);
44
+ const scale = dt * (FIELD_SIZE - 1) * (FIELD_SIZE - 1);
45
+ let ax = (d.xx - cross) * scale;
46
+ let ay = (d.yy - cross) * scale;
47
+ let ad = cross * scale;
48
+ const total = ax + ay + ad;
49
+ const clamped = total > STABLE;
50
+ if (clamped) {
51
+ const k = STABLE / total;
52
+ ax *= k;
53
+ ay *= k;
54
+ ad *= k;
55
+ }
56
+ return { ax, ay, ad, diagonal: d.xy >= 0 ? 1 : -1, clamped };
57
+ }
58
+ function hash(x, y, seed) {
59
+ let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263) ^ Math.imul(seed, 1274126177);
60
+ h = Math.imul(h ^ h >>> 13, 1274126177);
61
+ return ((h ^ h >>> 16) >>> 0) / 4294967296;
62
+ }
63
+ var EMPTY = () => ({ x0: 1, y0: 1, x1: 0, y1: 0 });
64
+ var DamageField = class {
65
+ size = FIELD_SIZE;
66
+ /** RGBA per texel in float, row-major from v = 0. The simulation's own state. */
67
+ data;
68
+ /** The same, at 8 bits, for upload. Kept in step with `data` over what changed. */
69
+ pixels;
70
+ /**
71
+ * How ragged the sheet DRAWS this field's edges, 0..1 — see
72
+ * `DamageSource.detail`. Not a simulation option, because it changes
73
+ * nothing the field computes: set it from `fxQualityFor(tier).detail`, and
74
+ * again whenever the tier moves.
75
+ */
76
+ detail = 1;
77
+ revision = 0;
78
+ next;
79
+ /** Per-texel ignition threshold, fixed for the life of the sheet. */
80
+ tinder;
81
+ o;
82
+ heat;
83
+ water;
84
+ accumulator = 0;
85
+ /** Fixed steps run since the field was made — its own clock. See {@link time}. */
86
+ steps = 0;
87
+ /** Cells that might change on the next step. Everything outside is at rest. */
88
+ active = EMPTY();
89
+ /** Cells written since the last pack into `pixels`. */
90
+ touched = EMPTY();
91
+ visited = 0;
92
+ /** Running totals, so the stats never have to walk the whole grid. */
93
+ present;
94
+ wetTotal = 0;
95
+ stats = {
96
+ front: 0,
97
+ charred: 0,
98
+ consumed: 0,
99
+ wetted: 0,
100
+ saturation: 0,
101
+ remaining: 1
102
+ };
103
+ /**
104
+ * Texels whose presence reached zero during the last `step`, in the first
105
+ * {@link consumedCount} slots — where ash leaves from.
106
+ *
107
+ * WHERE, not just how many. The stats were enough for the sound, which
108
+ * wants a level; an emitter wants a place. Fixed buffers the size of the
109
+ * grid and written in place: a burning sheet must not allocate a list a
110
+ * step, and a texel is consumed once, so a step can never fill it twice.
111
+ */
112
+ consumedCells = new Int32Array(FIELD_SIZE * FIELD_SIZE);
113
+ /** The burn front as of the last step that ran, in the first {@link frontCount} slots — where embers and smoke leave from. */
114
+ frontCells = new Int32Array(FIELD_SIZE * FIELD_SIZE);
115
+ consumedLength = 0;
116
+ frontLength = 0;
117
+ constructor(options = {}) {
118
+ this.o = { ...DEFAULTS, ...options };
119
+ const n = FIELD_SIZE * FIELD_SIZE;
120
+ this.data = new Float32Array(n * 4);
121
+ this.next = new Float32Array(n * 4);
122
+ this.pixels = new Uint8Array(n * 4);
123
+ this.tinder = new Float32Array(n);
124
+ this.present = n;
125
+ for (let i = 0; i < n; i++) {
126
+ this.data[i * 4 + PRESENCE] = 1;
127
+ this.pixels[i * 4 + PRESENCE] = 255;
128
+ const x = i % FIELD_SIZE;
129
+ const y = i / FIELD_SIZE | 0;
130
+ const coarse = hash(x >> 2, y >> 2, this.o.seed);
131
+ const fine = hash(x, y, this.o.seed * 7 + 11);
132
+ this.tinder[i] = 1 + this.o.grain * (coarse * 0.65 + fine * 0.35 - 0.5);
133
+ }
134
+ this.next.set(this.data);
135
+ this.heat = stencil(this.o.heatDiffusion, this.o.anisotropy, this.o.fibre);
136
+ this.water = stencil(this.o.wicking, this.o.anisotropy, this.o.fibre);
137
+ }
138
+ /** Bumped whenever `pixels` changes. */
139
+ get version() {
140
+ return this.revision;
141
+ }
142
+ /**
143
+ * Simulated seconds this field has burned for — whole fixed steps, never
144
+ * wall time.
145
+ *
146
+ * What anything DRAWN from the field animates on: the ember line's beads
147
+ * flicker and crawl, flames puff, and all of it has to be the same at the
148
+ * same moment of the same burn, or a replay flickers differently from the
149
+ * original and a capture can never be taken twice. Stops while the field
150
+ * sleeps, which is right — a sheet at rest has nothing hot left to move.
151
+ */
152
+ get time() {
153
+ return this.steps * FIXED_DT;
154
+ }
155
+ /** Nothing is happening to this sheet, and stepping it costs nothing. */
156
+ get asleep() {
157
+ return this.active.x0 > this.active.x1;
158
+ }
159
+ /**
160
+ * Cells the last `step` visited.
161
+ *
162
+ * The cost of the field, stated in the one unit that means the same thing on
163
+ * every machine. Milliseconds would make a test of it a test of whoever ran
164
+ * it — which is how the hands harness spent weeks passing on one laptop.
165
+ */
166
+ get cellsVisited() {
167
+ return this.visited;
168
+ }
169
+ /** What the last `step` produced. */
170
+ get lastStats() {
171
+ return this.stats;
172
+ }
173
+ /** How many of {@link consumedCells} the last `step` wrote. Always `lastStats.consumed`. */
174
+ get consumedCount() {
175
+ return this.consumedLength;
176
+ }
177
+ /**
178
+ * How many of {@link frontCells} are current. `lastStats.front` times the
179
+ * texel count — and held, like it, across a frame too short to step.
180
+ */
181
+ get frontCount() {
182
+ return this.frontLength;
183
+ }
184
+ /** Texel index for a UV, clamped to the sheet. */
185
+ at(u, v) {
186
+ const x = Math.min(FIELD_SIZE - 1, Math.max(0, Math.round(u * (FIELD_SIZE - 1))));
187
+ const y = Math.min(FIELD_SIZE - 1, Math.max(0, Math.round(v * (FIELD_SIZE - 1))));
188
+ return y * FIELD_SIZE + x;
189
+ }
190
+ /** The four channels at a UV, for anything that needs to ask a question of a point. */
191
+ sample(u, v) {
192
+ const i = this.at(u, v) * 4;
193
+ return [this.data[i], this.data[i + 1], this.data[i + 2], this.data[i + 3]];
194
+ }
195
+ /**
196
+ * Add to one channel in a soft disc.
197
+ *
198
+ * Every paint operation is this with a different channel, which is the
199
+ * point of having one primitive: `ignite` and `wet` are not two systems
200
+ * that happen to look alike, they are the same write.
201
+ *
202
+ * `plateau` is the fraction of the radius that takes the full amount
203
+ * before the falloff starts. Zero for anything added, so the deposit has a
204
+ * soft peak — a flame held near paper does not deposit a stamped disc of
205
+ * heat, and a too-perfect edge is the first thing that reads as fake.
206
+ * Raised for anything removed, because a pure smoothstep never quite
207
+ * reaches zero even at its centre, and "almost all the way through" is not
208
+ * a hole.
209
+ */
210
+ paint(channel, u, v, radius, amount, plateau = 0) {
211
+ if (radius <= 0 || amount === 0) return;
212
+ const last = FIELD_SIZE - 1;
213
+ const cx = u * last;
214
+ const cy = v * last;
215
+ const r = radius * last;
216
+ const box = {
217
+ x0: Math.max(0, Math.floor(cx - r)),
218
+ y0: Math.max(0, Math.floor(cy - r)),
219
+ x1: Math.min(last, Math.ceil(cx + r)),
220
+ y1: Math.min(last, Math.ceil(cy + r))
221
+ };
222
+ if (box.x0 > box.x1 || box.y0 > box.y1) return;
223
+ const r2 = r * r;
224
+ const { data, next } = this;
225
+ for (let y = box.y0; y <= box.y1; y++) {
226
+ for (let x = box.x0; x <= box.x1; x++) {
227
+ const dx = x - cx;
228
+ const dy = y - cy;
229
+ const d2 = dx * dx + dy * dy;
230
+ if (d2 > r2) continue;
231
+ const t = plateau >= 1 ? 1 : Math.min(1, (1 - Math.sqrt(d2) / r) / (1 - plateau));
232
+ const falloff = t * t * (3 - 2 * t);
233
+ const i = (y * FIELD_SIZE + x) * 4 + channel;
234
+ const before = data[i];
235
+ const after = Math.min(1, Math.max(0, before + amount * falloff));
236
+ if (after === before) continue;
237
+ data[i] = after;
238
+ next[i] = after;
239
+ if (channel === PRESENCE) this.present += after - before;
240
+ if (channel === SATURATION) this.wetTotal += after - before;
241
+ }
242
+ }
243
+ grow(this.active, box);
244
+ grow(this.touched, box);
245
+ this.pack();
246
+ }
247
+ /** Hold a flame near the sheet. Heat, not char — the burning is the field's job. */
248
+ ignite(u, v, radius = 0.06, amount = 1) {
249
+ this.paint(HEAT, u, v, radius, amount);
250
+ }
251
+ /** Wet the sheet. Saturation wicks along the fibre from wherever it lands. */
252
+ wet(u, v, radius = 0.08, amount = 0.9) {
253
+ this.paint(SATURATION, u, v, radius, amount);
254
+ }
255
+ /**
256
+ * Take the paper away along a path: a tear, a cut, a punched hole.
257
+ *
258
+ * Presence only ever decreases. A sheet does not grow back, and a paint op
259
+ * that could raise it would make every burn reversible by accident.
260
+ */
261
+ cut(u0, v0, u1, v1, width = 0.02) {
262
+ const steps = Math.max(1, Math.ceil(Math.hypot(u1 - u0, v1 - v0) * FIELD_SIZE));
263
+ for (let s = 0; s <= steps; s++) {
264
+ const t = s / steps;
265
+ this.paint(PRESENCE, u0 + (u1 - u0) * t, v0 + (v1 - v0) * t, width, -1, 0.5);
266
+ }
267
+ }
268
+ /** Punch a hole. The middle of the sheet, which a fixed-topology mesh cannot do. */
269
+ punch(u, v, radius = 0.04) {
270
+ this.paint(PRESENCE, u, v, radius, -1, 0.5);
271
+ }
272
+ /**
273
+ * Advance the field by a frame's worth of real time.
274
+ *
275
+ * Fixed steps from an accumulator, so the same simulated second is the same
276
+ * fire at any frame rate. A sheet nothing is happening to returns at once
277
+ * and visits no cells at all — which is the condition for keeping this on
278
+ * the main thread, and the thing the first version could not do: an
279
+ * untouched sheet cost exactly what a burning one did.
280
+ */
281
+ step(delta) {
282
+ this.visited = 0;
283
+ this.consumedLength = 0;
284
+ if (delta <= 0) {
285
+ this.stats = { ...this.stats, charred: 0, consumed: 0, wetted: 0 };
286
+ return this.stats;
287
+ }
288
+ if (this.asleep) {
289
+ this.accumulator = 0;
290
+ this.frontLength = 0;
291
+ this.stats = { ...this.stats, front: 0, charred: 0, consumed: 0, wetted: 0 };
292
+ return this.stats;
293
+ }
294
+ this.accumulator = Math.min(this.accumulator + delta, FIXED_DT * MAX_STEPS_PER_FRAME);
295
+ let charred = 0;
296
+ let consumed = 0;
297
+ let wetted = 0;
298
+ let front = 0;
299
+ let stepped = false;
300
+ while (this.accumulator >= FIXED_DT && !this.asleep) {
301
+ this.accumulator -= FIXED_DT;
302
+ this.steps++;
303
+ stepped = true;
304
+ const done = this.substep();
305
+ charred += done.charred;
306
+ consumed += done.consumed;
307
+ wetted += done.wetted;
308
+ front = done.front;
309
+ }
310
+ this.pack();
311
+ const n = FIELD_SIZE * FIELD_SIZE;
312
+ this.stats = {
313
+ // A frame shorter than one fixed step runs none — one frame in six at
314
+ // 144 Hz — and nothing about the fire changed on it. Reporting 0 there
315
+ // would drop the burn's sound to silence mid-burn, six times a second.
316
+ front: stepped ? front / n : this.stats.front,
317
+ charred,
318
+ consumed,
319
+ wetted,
320
+ saturation: this.wetTotal / n,
321
+ remaining: this.present / n
322
+ };
323
+ return this.stats;
324
+ }
325
+ /**
326
+ * One fixed step of diffusion and reaction, over the active region only.
327
+ *
328
+ * The region is the box around every cell that could change, grown by one
329
+ * cell because diffusion reaches exactly one neighbour per step. Cells
330
+ * outside it are at rest and read-only here — their neighbours may read
331
+ * them, and nothing writes them.
332
+ *
333
+ * Written into `next` and copied back over the same region, rather than
334
+ * swapping the two buffers. A swap needs both buffers to agree everywhere
335
+ * the step did not write, and a region that SHRINKS breaks that: a cell the
336
+ * last step wrote into one buffer still holds an older value in the other.
337
+ * Copying the region back keeps them identical outside it, for the cost of
338
+ * the region itself — which is the cost that matters, since it is zero on
339
+ * a sheet at rest rather than a whole grid every step.
340
+ */
341
+ substep() {
342
+ const { data, next, tinder, o } = this;
343
+ const heat = this.heat;
344
+ const water = this.water;
345
+ const size = FIELD_SIZE;
346
+ const last = size - 1;
347
+ const dt = FIXED_DT;
348
+ const region = {
349
+ x0: Math.max(0, this.active.x0 - 1),
350
+ y0: Math.max(0, this.active.y0 - 1),
351
+ x1: Math.min(last, this.active.x1 + 1),
352
+ y1: Math.min(last, this.active.y1 + 1)
353
+ };
354
+ const awake = EMPTY();
355
+ let charred = 0;
356
+ let consumed = 0;
357
+ let wetted = 0;
358
+ let front = 0;
359
+ const { frontCells, consumedCells } = this;
360
+ const hd = heat.diagonal;
361
+ const wd = water.diagonal;
362
+ for (let y = region.y0; y <= region.y1; y++) {
363
+ const hasN = y < region.y1;
364
+ const hasS = y > region.y0;
365
+ for (let x = region.x0; x <= region.x1; x++) {
366
+ this.visited++;
367
+ const i = y * size + x;
368
+ const b = i * 4;
369
+ const presence = data[b + PRESENCE];
370
+ if (presence <= 0) {
371
+ this.wetTotal -= data[b + SATURATION];
372
+ next[b + HEAT] = 0;
373
+ next[b + SATURATION] = 0;
374
+ next[b + CHAR] = data[b + CHAR];
375
+ next[b + PRESENCE] = 0;
376
+ continue;
377
+ }
378
+ const hasE = x < region.x1;
379
+ const hasW = x > region.x0;
380
+ const e = hasE ? i + 1 : i;
381
+ const w = hasW ? i - 1 : i;
382
+ const n = hasN ? i + size : i;
383
+ const s = hasS ? i - size : i;
384
+ const hd1 = hd > 0 ? hasN && hasE ? i + size + 1 : i : hasN && hasW ? i + size - 1 : i;
385
+ const hd2 = hd > 0 ? hasS && hasW ? i - size - 1 : i : hasS && hasE ? i - size + 1 : i;
386
+ const wd1 = wd > 0 ? hasN && hasE ? i + size + 1 : i : hasN && hasW ? i + size - 1 : i;
387
+ const wd2 = wd > 0 ? hasS && hasW ? i - size - 1 : i : hasS && hasE ? i - size + 1 : i;
388
+ const me = e * 4;
389
+ const mw = w * 4;
390
+ const mn = n * 4;
391
+ const ms = s * 4;
392
+ const pe = Math.min(presence, data[me + PRESENCE]);
393
+ const pw = Math.min(presence, data[mw + PRESENCE]);
394
+ const pn = Math.min(presence, data[mn + PRESENCE]);
395
+ const ps = Math.min(presence, data[ms + PRESENCE]);
396
+ const h0 = data[b + HEAT];
397
+ const h1 = hd1 * 4;
398
+ const h2 = hd2 * 4;
399
+ let h = h0 + heat.ax * ((data[me + HEAT] - h0) * pe + (data[mw + HEAT] - h0) * pw) + heat.ay * ((data[mn + HEAT] - h0) * pn + (data[ms + HEAT] - h0) * ps) + heat.ad * ((data[h1 + HEAT] - h0) * Math.min(presence, data[h1 + PRESENCE]) + (data[h2 + HEAT] - h0) * Math.min(presence, data[h2 + PRESENCE]));
400
+ h -= h * o.cooling * dt;
401
+ const sat = data[b + SATURATION];
402
+ const w1 = wd1 * 4;
403
+ const w2 = wd2 * 4;
404
+ let g = sat + water.ax * ((data[me + SATURATION] - sat) * pe + (data[mw + SATURATION] - sat) * pw) + water.ay * ((data[mn + SATURATION] - sat) * pn + (data[ms + SATURATION] - sat) * ps) + water.ad * ((data[w1 + SATURATION] - sat) * Math.min(presence, data[w1 + PRESENCE]) + (data[w2 + SATURATION] - sat) * Math.min(presence, data[w2 + PRESENCE]));
405
+ if (g > 0 && h > 0) {
406
+ const boiled = Math.min(g, h * o.wetResistance * dt);
407
+ g -= boiled;
408
+ h -= boiled / o.wetResistance;
409
+ }
410
+ g -= g * o.drying * dt;
411
+ const char = data[b + CHAR];
412
+ let c = char;
413
+ const threshold = tinder[i] * IGNITION;
414
+ if (h > threshold && g < 0.25) {
415
+ const made = Math.min(1 - c, o.charRate * (h - threshold) * dt);
416
+ if (made > 0) {
417
+ c += made;
418
+ h += made * o.combustion;
419
+ if (char < 0.5 && c >= 0.5) charred++;
420
+ }
421
+ }
422
+ let p = presence;
423
+ if (c > 0.85) {
424
+ p = Math.max(0, p - o.consumeRate * (c - 0.85) * dt);
425
+ if (p <= 0) {
426
+ consumed++;
427
+ consumedCells[this.consumedLength++] = i;
428
+ }
429
+ }
430
+ h = Math.min(1, Math.max(0, h));
431
+ g = Math.min(1, Math.max(0, g));
432
+ c = Math.min(1, c);
433
+ if (g > sat + 1e-6) wetted++;
434
+ if (p > 0.15 && c > 0.08 && c < 0.92 && h > 0.1) frontCells[front++] = i;
435
+ next[b + CHAR] = c;
436
+ next[b + SATURATION] = g;
437
+ next[b + HEAT] = h;
438
+ next[b + PRESENCE] = p;
439
+ this.present += p - presence;
440
+ this.wetTotal += g - sat;
441
+ if (h > REST || g > REST || c > 0.85 && p > 0) {
442
+ if (x < awake.x0) awake.x0 = x;
443
+ if (x > awake.x1) awake.x1 = x;
444
+ if (y < awake.y0) awake.y0 = y;
445
+ if (y > awake.y1) awake.y1 = y;
446
+ }
447
+ }
448
+ }
449
+ for (let y = region.y0; y <= region.y1; y++) {
450
+ const from = (y * size + region.x0) * 4;
451
+ const to = (y * size + region.x1 + 1) * 4;
452
+ data.set(next.subarray(from, to), from);
453
+ }
454
+ grow(this.touched, region);
455
+ this.active = awake;
456
+ this.frontLength = front;
457
+ return { charred, consumed, wetted, front };
458
+ }
459
+ /** Quantise everything touched since the last pack, and mark it uploadable. */
460
+ pack() {
461
+ const t = this.touched;
462
+ if (t.x0 > t.x1) return;
463
+ const { data, pixels } = this;
464
+ for (let y = t.y0; y <= t.y1; y++) {
465
+ const from = (y * FIELD_SIZE + t.x0) * 4;
466
+ const to = (y * FIELD_SIZE + t.x1 + 1) * 4;
467
+ for (let k = from; k < to; k++) pixels[k] = Math.round(data[k] * 255);
468
+ }
469
+ this.touched = EMPTY();
470
+ this.revision++;
471
+ }
472
+ };
473
+ function grow(into, box) {
474
+ if (box.x0 > box.x1) return;
475
+ if (into.x0 > into.x1) {
476
+ into.x0 = box.x0;
477
+ into.y0 = box.y0;
478
+ into.x1 = box.x1;
479
+ into.y1 = box.y1;
480
+ return;
481
+ }
482
+ if (box.x0 < into.x0) into.x0 = box.x0;
483
+ if (box.y0 < into.y0) into.y0 = box.y0;
484
+ if (box.x1 > into.x1) into.x1 = box.x1;
485
+ if (box.y1 > into.y1) into.y1 = box.y1;
486
+ }
487
+
488
+ // src/fx/emission.ts
489
+ var LUMA = [0.2126, 0.7152, 0.0722];
490
+ var PAPER_WHITE = 1.6;
491
+ var FX_BLOOM_THRESHOLD = PAPER_WHITE * 2.25;
492
+ var FIRE_GLOW = [4, 8];
493
+ function srgbToLinear(c) {
494
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
495
+ }
496
+ function luminance(c) {
497
+ return c[0] * LUMA[0] + c[1] * LUMA[1] + c[2] * LUMA[2];
498
+ }
499
+ function timesPaperWhite(c) {
500
+ return luminance(c) / PAPER_WHITE;
501
+ }
502
+ function emit(srgb, times) {
503
+ const linear = [
504
+ srgbToLinear(srgb[0]),
505
+ srgbToLinear(srgb[1]),
506
+ srgbToLinear(srgb[2])
507
+ ];
508
+ const lum = luminance(linear);
509
+ if (lum <= 1e-6) return [0, 0, 0];
510
+ const gain = times * PAPER_WHITE / lum;
511
+ return [linear[0] * gain, linear[1] * gain, linear[2] * gain];
512
+ }
513
+ function emitHex(hex, times) {
514
+ const n = Number.parseInt(hex.replace("#", ""), 16);
515
+ return emit([(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255], times);
516
+ }
517
+ var FIRE_HEAT_SCALE = 0.65;
518
+ var FIRE_SOOT_SCALE = 3;
519
+ var FX_BLOOM = 0.55;
520
+ var FIRE_CONTRAST = 0.9;
521
+ var FIRE_OPACITY = 5;
522
+ var FIRE_THIN = 0.55;
523
+ var FIRE_ZONES = {
524
+ root: { color: "#3b6bff", amount: 0, reach: 0.5 },
525
+ // glow 6 (was 4.6): once the core became the hottest few percent of a
526
+ // flame instead of its whole root, 4.6 no longer cleared the bloom
527
+ // threshold — bloom's share of the peak frame fell to 0.48%, under the
528
+ // budget. Brighter keeps the same small area: 1.47% bloom, near-white 0.8%.
529
+ // Starting it lower (from 0.45) bloomed 6.4% but put near-white back at 4.7%.
530
+ core: { color: "#fff7d4", glow: 6, from: 0.55 },
531
+ // Saturated, because the tone curve takes saturation away from anything
532
+ // above paper white: #ffdd7c at this glow came out pale yellow (s ~0.4),
533
+ // and measured 7.5% yellow against Flame_base.png's 29%.
534
+ body: { color: "#ffcf3a", glow: 1.3 },
535
+ // to: 0.4, measured against Flame_base.png with bloom off (bloom's halo
536
+ // counts as flame in any diff): near-white / pale / yellow / orange came out
537
+ // 0.7 / 21 / 34 / 44% against the reference's 0.9 / 17 / 29 / 43. At 0.32
538
+ // the body took half the flame and it was 51% yellow; at 0.45, 64% orange.
539
+ tip: { color: "#ff9e2c", glow: 0.9, from: 0.08, to: 0.4, softness: 0.15, tearing: 0.35 }
540
+ };
541
+ function fireZones(input) {
542
+ return {
543
+ root: { ...FIRE_ZONES.root, ...input?.root },
544
+ core: { ...FIRE_ZONES.core, ...input?.core },
545
+ body: { ...FIRE_ZONES.body, ...input?.body },
546
+ tip: { ...FIRE_ZONES.tip, ...input?.tip }
547
+ };
548
+ }
549
+ function hexToLinear(hex) {
550
+ const n = Number.parseInt(hex.replace("#", ""), 16);
551
+ return [
552
+ srgbToLinear((n >> 16 & 255) / 255),
553
+ srgbToLinear((n >> 8 & 255) / 255),
554
+ srgbToLinear((n & 255) / 255)
555
+ ];
556
+ }
557
+
558
+ // src/fx/flames.ts
559
+ var FLAME_HEIGHT = [10 / 210, 40 / 210];
560
+ var FLAME_HEAT = 0.22;
561
+ var IRREGULAR = 0.4;
562
+ var MM = 63 / 210;
563
+ var scratchA = { x: 0, y: 0, z: 0 };
564
+ function flameAnchors(field, locate, max, out, time = field.time) {
565
+ if (max <= 0 || field.frontCount === 0) return 0;
566
+ const size = field.size;
567
+ const last = size - 1;
568
+ const data = field.data;
569
+ const rim = [];
570
+ let cx = 0;
571
+ let cy = 0;
572
+ for (let y = 1; y < last; y++) {
573
+ for (let x = 1; x < last; x++) {
574
+ const cell = y * size + x;
575
+ if (data[cell * 4 + HEAT] < FLAME_HEAT || data[cell * 4 + PRESENCE] < 0.5) continue;
576
+ if (data[(cell - 1) * 4 + PRESENCE] >= 0.5 && data[(cell + 1) * 4 + PRESENCE] >= 0.5 && data[(cell - size) * 4 + PRESENCE] >= 0.5 && data[(cell + size) * 4 + PRESENCE] >= 0.5) {
577
+ continue;
578
+ }
579
+ rim.push(cell);
580
+ cx += x;
581
+ cy += y;
582
+ }
583
+ }
584
+ if (rim.length === 0) return 0;
585
+ cx /= rim.length;
586
+ cy /= rim.length;
587
+ const angle = (cell) => Math.atan2((cell / size | 0) - cy, cell % size - cx);
588
+ rim.sort((a, b) => angle(a) - angle(b) || a - b);
589
+ const picks = [];
590
+ for (const cell of rim) {
591
+ const seed = hash2(cell, 1);
592
+ const a = angle(cell);
593
+ const cluster = clamp01(
594
+ 0.62 * noise2(Math.cos(a) * 1.6 + time * 0.42, Math.sin(a) * 1.6 - time * 0.17) + 0.38 * noise2(Math.cos(a) * 4.1 - time * 0.73 + 7.3, Math.sin(a) * 4.1 + time * 0.31) - 0.12
595
+ );
596
+ const epoch = Math.floor(time * 1.4 + seed * 10);
597
+ if (hash2(cell, epoch + 7) > 0.38 + 0.55 * cluster) continue;
598
+ const breath = noise1(time * 0.9 + seed * 31);
599
+ if (breath < 0.16) continue;
600
+ const life = 0.3 + 0.7 * smoothstep(0.16, 0.5, breath);
601
+ picks.push({ cell, seed, cluster, life, along: (hash2(cell, 5) - 0.5) * 3, scale: 1 });
602
+ if (cluster > 0.5) {
603
+ const extra = cluster > 0.72 ? 2 : 1;
604
+ for (let k = 0; k < extra; k++) {
605
+ const side = hash2(cell, 11 + k);
606
+ picks.push({
607
+ cell,
608
+ seed: hash2(cell, 21 + k),
609
+ cluster,
610
+ life: life * (0.6 + 0.4 * side),
611
+ along: (side < 0.5 ? -1 : 1) * (1.5 + 3 * hash2(cell, 31 + k)),
612
+ scale: 0.4 + 0.45 * hash2(cell, 41 + k)
613
+ });
614
+ }
615
+ }
616
+ }
617
+ picks.sort((p, q) => q.cluster * 0.7 + q.seed * 0.3 - (p.cluster * 0.7 + p.seed * 0.3) || p.cell - q.cell);
618
+ const count = Math.min(max, picks.length);
619
+ let n = 0;
620
+ for (let i = 0; i < count; i++) {
621
+ const pick = picks[i];
622
+ const cell = pick.cell;
623
+ const x = cell % size;
624
+ const y = cell / size | 0;
625
+ const gx = data[(cell + 1) * 4 + PRESENCE] - data[(cell - 1) * 4 + PRESENCE];
626
+ const gy = data[(cell + size) * 4 + PRESENCE] - data[(cell - size) * 4 + PRESENCE];
627
+ const gl = Math.hypot(gx, gy) || 1;
628
+ const shift = pick.along * MM / last;
629
+ const u = x / last + -gy / gl * shift;
630
+ const v = y / last + gx / gl * shift;
631
+ const at = locate(u, v);
632
+ if (!at) continue;
633
+ const rx = at.x;
634
+ const ry = at.y;
635
+ const rz = at.z;
636
+ const toward = locate(u + gx / gl / last, v + gy / gl / last);
637
+ let upper = 0;
638
+ scratchA.x = 0;
639
+ scratchA.y = 0;
640
+ scratchA.z = 0;
641
+ if (toward) {
642
+ const l = Math.hypot(toward.x - rx, toward.y - ry, toward.z - rz) || 1;
643
+ scratchA.x = (toward.x - rx) / l;
644
+ scratchA.y = (toward.y - ry) / l;
645
+ scratchA.z = (toward.z - rz) / l;
646
+ upper = scratchA.y;
647
+ }
648
+ const heat = data[cell * 4 + HEAT];
649
+ const hot = Math.sqrt(Math.min(1, (heat - FLAME_HEAT) / 0.35));
650
+ const rim2 = 0.35 + 0.65 * (0.5 + 0.5 * upper);
651
+ const base = (FLAME_HEIGHT[0] + (FLAME_HEIGHT[1] - FLAME_HEIGHT[0]) * hot) * rim2;
652
+ const height = base * (0.2 + 1.3 * pick.cluster ** 1.4) * (0.6 + 0.8 * pick.seed) * pick.life * pick.scale;
653
+ let anchor = out[n];
654
+ if (!anchor) {
655
+ anchor = { x: 0, y: 0, z: 0, height: 0, width: 0, seed: 0, heat: 0, upper: 0, nx: 0, ny: 0, nz: 0 };
656
+ out[n] = anchor;
657
+ }
658
+ anchor.nx = scratchA.x;
659
+ anchor.ny = scratchA.y;
660
+ anchor.nz = scratchA.z;
661
+ anchor.x = rx;
662
+ anchor.y = ry;
663
+ anchor.z = rz;
664
+ anchor.height = Math.min(FLAME_HEIGHT[1], height);
665
+ anchor.width = anchor.height * (0.32 + 0.3 * hash2(cell, 3));
666
+ anchor.seed = pick.seed;
667
+ anchor.heat = hot;
668
+ anchor.upper = upper;
669
+ n++;
670
+ }
671
+ if (n >= 3 && variation(out, n) < IRREGULAR) {
672
+ for (let i = 0; i < n; i++) {
673
+ const a = out[i];
674
+ a.height = Math.min(FLAME_HEIGHT[1], a.height * (0.3 + 1.4 * hash2(Math.floor(a.seed * 1e6), 9)));
675
+ a.width = a.height * (0.32 + 0.3 * hash2(Math.floor(a.seed * 1e6), 3));
676
+ }
677
+ }
678
+ return n;
679
+ }
680
+ function variation(anchors, n) {
681
+ let sum = 0;
682
+ for (let i = 0; i < n; i++) sum += anchors[i].height;
683
+ const mean = sum / n;
684
+ if (!(mean > 0)) return 0;
685
+ let sq = 0;
686
+ for (let i = 0; i < n; i++) sq += (anchors[i].height - mean) ** 2;
687
+ return Math.sqrt(sq / n) / mean;
688
+ }
689
+ function clamp01(x) {
690
+ return x < 0 ? 0 : x > 1 ? 1 : x;
691
+ }
692
+ function smoothstep(a, b, x) {
693
+ const t = clamp01((x - a) / (b - a));
694
+ return t * t * (3 - 2 * t);
695
+ }
696
+ function hash2(a, b) {
697
+ let h = Math.imul(a ^ Math.imul(b, 668265261), 2654435761) >>> 0;
698
+ h ^= h >>> 15;
699
+ h = Math.imul(h, 2246822519) >>> 0;
700
+ h ^= h >>> 13;
701
+ return (h >>> 0) / 4294967296;
702
+ }
703
+ function noise2(x, y) {
704
+ const ix = Math.floor(x);
705
+ const iy = Math.floor(y);
706
+ let fx = x - ix;
707
+ let fy = y - iy;
708
+ fx = fx * fx * (3 - 2 * fx);
709
+ fy = fy * fy * (3 - 2 * fy);
710
+ const h = (i, j) => hash2(i * 73856093, j * 19349663 + 7);
711
+ const a = h(ix, iy) + (h(ix + 1, iy) - h(ix, iy)) * fx;
712
+ const b = h(ix, iy + 1) + (h(ix + 1, iy + 1) - h(ix, iy + 1)) * fx;
713
+ return a + (b - a) * fy;
714
+ }
715
+ function flamePuff(seed, time) {
716
+ return 0.72 + 0.28 * noise1(time * 12.5 + seed * 37) + 0.12 * (noise1(time * 23 + seed * 11) - 0.5);
717
+ }
718
+ function hash1(n) {
719
+ const s = Math.sin(n) * 43758.5453;
720
+ return s - Math.floor(s);
721
+ }
722
+ function noise1(x) {
723
+ const i = Math.floor(x);
724
+ let f = x - i;
725
+ f = f * f * (3 - 2 * f);
726
+ return hash1(i) + (hash1(i + 1) - hash1(i)) * f;
727
+ }
728
+
729
+ // src/fx/quality.ts
730
+ var fxQualityNames = ["auto", "low", "medium", "high"];
731
+ var fxQualityTiers = {
732
+ /** A desktop GPU, or a phone that has measured its way up here. */
733
+ high: {
734
+ particles: 2e3,
735
+ voices: 16,
736
+ detail: 1,
737
+ bloomScale: 1,
738
+ flames: 64,
739
+ caps: { ember: 200, smoke: 200, ash: 120 },
740
+ haze: 2,
741
+ fluid: { velocity: [192, 256], dye: [384, 512], iterations: 36 }
742
+ },
743
+ /** The default worth aiming at: a recent phone, or an integrated laptop GPU. */
744
+ medium: {
745
+ particles: 900,
746
+ voices: 10,
747
+ detail: 1,
748
+ bloomScale: 1,
749
+ flames: 36,
750
+ caps: { ember: 80, smoke: 80, ash: 60 },
751
+ haze: 0,
752
+ fluid: { velocity: [144, 192], dye: [288, 384], iterations: 30 }
753
+ },
754
+ /**
755
+ * A throttled phone with the camera and the tracker already running, which
756
+ * is the realistic case rather than the pessimistic one. The fire is the
757
+ * same fire; the shower is thinner, fewer crackles overlap, and its edge is
758
+ * the grid's own.
759
+ */
760
+ low: {
761
+ particles: 350,
762
+ voices: 6,
763
+ detail: 0,
764
+ bloomScale: 0.5,
765
+ flames: 16,
766
+ caps: { ember: 30, smoke: 30, ash: 20 },
767
+ haze: 0,
768
+ fluid: { velocity: [96, 128], dye: [192, 256], iterations: 20 }
769
+ }
770
+ };
771
+ var FX_INITIAL_TIER = "medium";
772
+ var FX_TIER_ORDER = ["low", "medium", "high"];
773
+ function fxQualityFor(name) {
774
+ return fxQualityTiers[name === "auto" ? FX_INITIAL_TIER : name];
775
+ }
776
+
777
+ export {
778
+ CHAR,
779
+ SATURATION,
780
+ HEAT,
781
+ PRESENCE,
782
+ FIELD_SIZE,
783
+ FIXED_DT,
784
+ DamageField,
785
+ PAPER_WHITE,
786
+ FX_BLOOM_THRESHOLD,
787
+ FIRE_GLOW,
788
+ srgbToLinear,
789
+ luminance,
790
+ timesPaperWhite,
791
+ emit,
792
+ emitHex,
793
+ FIRE_HEAT_SCALE,
794
+ FIRE_SOOT_SCALE,
795
+ FX_BLOOM,
796
+ FIRE_CONTRAST,
797
+ FIRE_OPACITY,
798
+ FIRE_THIN,
799
+ FIRE_ZONES,
800
+ fireZones,
801
+ hexToLinear,
802
+ FLAME_HEIGHT,
803
+ flameAnchors,
804
+ flamePuff,
805
+ fxQualityNames,
806
+ fxQualityTiers,
807
+ FX_INITIAL_TIER,
808
+ FX_TIER_ORDER,
809
+ fxQualityFor
810
+ };
811
+ //# sourceMappingURL=chunk-GB6BMHC3.js.map