paperlab 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +57 -28
  2. package/dist/{FxPostPass-M66QMVVC.js → FxPostPass-ACFSHY3S.js} +24 -6
  3. package/dist/FxPostPass-ACFSHY3S.js.map +1 -0
  4. package/dist/chunk-BIFUJCUA.js +34 -0
  5. package/dist/chunk-BIFUJCUA.js.map +1 -0
  6. package/dist/{chunk-NTDVOOBA.js → chunk-LUKNPQ3S.js} +385 -165
  7. package/dist/chunk-LUKNPQ3S.js.map +1 -0
  8. package/dist/{chunk-GB6BMHC3.js → chunk-S74YZAXE.js} +7 -19
  9. package/dist/chunk-S74YZAXE.js.map +1 -0
  10. package/dist/{damageContract-DPF1YFLZ.d.cts → damageContract-9RC4egJg.d.cts} +31 -3
  11. package/dist/{damageContract-DPF1YFLZ.d.ts → damageContract-9RC4egJg.d.ts} +31 -3
  12. package/dist/fx.cjs +336 -107
  13. package/dist/fx.cjs.map +1 -1
  14. package/dist/fx.d.cts +113 -89
  15. package/dist/fx.d.ts +113 -89
  16. package/dist/fx.js +293 -63
  17. package/dist/fx.js.map +1 -1
  18. package/dist/index.cjs +422 -193
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +13 -5
  21. package/dist/index.d.ts +13 -5
  22. package/dist/index.js +21 -8
  23. package/dist/index.js.map +1 -1
  24. package/dist/{slots-C5PqY0Q5.d.cts → slots-DbJQfwVd.d.cts} +13 -1
  25. package/dist/{slots-C5PqY0Q5.d.ts → slots-DbJQfwVd.d.ts} +13 -1
  26. package/dist/stage.cjs +417 -201
  27. package/dist/stage.cjs.map +1 -1
  28. package/dist/stage.d.cts +1 -1
  29. package/dist/stage.d.ts +1 -1
  30. package/dist/stage.js +12 -12
  31. package/dist/stage.js.map +1 -1
  32. package/package.json +5 -5
  33. package/dist/FxPostPass-M66QMVVC.js.map +0 -1
  34. package/dist/chunk-7TLIWPGM.js +0 -36
  35. package/dist/chunk-7TLIWPGM.js.map +0 -1
  36. package/dist/chunk-GB6BMHC3.js.map +0 -1
  37. package/dist/chunk-NTDVOOBA.js.map +0 -1
package/dist/fx.js CHANGED
@@ -31,11 +31,11 @@ import {
31
31
  luminance,
32
32
  srgbToLinear,
33
33
  timesPaperWhite
34
- } from "./chunk-GB6BMHC3.js";
34
+ } from "./chunk-S74YZAXE.js";
35
35
  import {
36
36
  DAMAGE_CHANNELS,
37
37
  DAMAGE_LOOK_DEFAULTS
38
- } from "./chunk-7TLIWPGM.js";
38
+ } from "./chunk-BIFUJCUA.js";
39
39
 
40
40
  // src/fx/particles.ts
41
41
  var particlePresets = {
@@ -47,7 +47,7 @@ var particlePresets = {
47
47
  * caught the bug in the version before this one. That version said "the hot
48
48
  * end is now ~5.6, well over the bloom threshold" — and 5.6 was an ABSOLUTE
49
49
  * luminance, while paper white under `window` is 1.6. So the hottest spark
50
- * in the frame was **3.5× paper, under the spec's own 4× floor**, and its
50
+ * in the frame was **3.5× paper, under the 4× floor**, and its
51
51
  * own comment said otherwise. A number that cannot be compared to anything
52
52
  * is a number nobody can check.
53
53
  *
@@ -67,12 +67,12 @@ var particlePresets = {
67
67
  drag: 0.8,
68
68
  jitter: 1.5,
69
69
  windCatch: 0.8,
70
- // 0.3-1 mm of core (§5). A sheet is one world unit across, 210 mm, so
70
+ // 0.3-1 mm of core. A sheet is one world unit across, 210 mm, so
71
71
  // 0.0035 is 0.7 mm. It was 0.005 — a full millimetre wide before the
72
72
  // streak stretched it, which is where "wide bars" started.
73
73
  size: [35e-4, 12e-4],
74
74
  color: [
75
- // 6× paper white: inside §4.3's 4–8 band, so it blooms.
75
+ // 6× paper white: inside the 4–8× band, so it blooms.
76
76
  emitHex("#FFC271", 6),
77
77
  // 0.7× — UNDER the bloom threshold on purpose, so a dying spark stops
78
78
  // glowing rather than merely fading.
@@ -90,7 +90,13 @@ var particlePresets = {
90
90
  * simulation, on purpose; see the fx plan.
91
91
  */
92
92
  smoke: {
93
- life: [1.8, 3.5],
93
+ // Shorter, and smaller below: these are the thread beside the
94
+ // simulator's smoke, and the simulator's is gone within a second or two
95
+ // of the last flame. Left to live three and a half seconds and grow to
96
+ // seven centimetres, the last few of them overlapped into a flat wash
97
+ // over the hole with nothing left to move them — the one moment of a
98
+ // burn where the smoke looked wrong.
99
+ life: [1.4, 2.6],
94
100
  speed: [0.05, 0.15],
95
101
  direction: [0, 1, 0],
96
102
  spread: 0.3,
@@ -98,13 +104,13 @@ var particlePresets = {
98
104
  drag: 0.9,
99
105
  jitter: 0.3,
100
106
  windCatch: 1,
101
- size: [0.05, 0.35],
107
+ size: [0.05, 0.18],
102
108
  // #6B6560 grey-brown, linear, and thin — the background stays clear.
103
109
  color: [
104
110
  [0.147, 0.13, 0.117],
105
111
  [0.2, 0.19, 0.18]
106
112
  ],
107
- alpha: [0.08, 0],
113
+ alpha: [0.07, 0],
108
114
  spin: 0.4,
109
115
  blend: "normal",
110
116
  shape: "soft",
@@ -116,7 +122,9 @@ var particlePresets = {
116
122
  * burn that proves paper was there.
117
123
  */
118
124
  ash: {
119
- life: [2.5, 5],
125
+ // A second or two: ash that hung in the air for five read as grey
126
+ // confetti over the hole, dust or stars rather than something falling.
127
+ life: [1.2, 2.6],
120
128
  // Thrown UP by the heat it came off, before it is heavier than the air.
121
129
  // It used to leave at almost nothing (0.02–0.1) with a lift of −0.35, so
122
130
  // every flake began falling the moment it was born — straight through the
@@ -124,17 +132,21 @@ var particlePresets = {
124
132
  speed: [0.14, 0.3],
125
133
  direction: [0, 1, 0],
126
134
  spread: 0.55,
127
- lift: -0.22,
135
+ // Heavier than the air once the throw is spent: most flakes tumble down,
136
+ // only the lightest ride the heat up a little first.
137
+ lift: -0.42,
128
138
  drag: 1.6,
129
139
  jitter: 1.2,
130
140
  windCatch: 1,
131
- // 5–10 mm across (§8.3). A sheet is 210 mm, so 0.03 is 6.3 mm. They were
141
+ // 5–10 mm across. A sheet is 210 mm, so 0.03 is 6.3 mm. They were
132
142
  // 3.8 mm and there were far too many of them: a shower of specks rather
133
143
  // than a few flakes you would notice.
134
144
  size: [0.03, 0.026],
145
+ // Charred paper, near black: under a bright key the old value read as
146
+ // pale grey against the black stage, which is the confetti.
135
147
  color: [
136
- [0.07, 0.065, 0.06],
137
- [0.12, 0.115, 0.11]
148
+ [0.04, 0.037, 0.034],
149
+ [0.07, 0.066, 0.062]
138
150
  ],
139
151
  alpha: [0.95, 0],
140
152
  spin: 4,
@@ -358,13 +370,14 @@ var ParticlePool = class {
358
370
 
359
371
  // src/fx/fire.ts
360
372
  var fireEmitterDefaults = {
361
- embers: 0.35,
362
- smoke: 0,
363
- ash: 0.12,
373
+ embers: 0.39,
374
+ smoke: 0.135,
375
+ ash: 0.36,
364
376
  seed: 7,
365
377
  caps: {}
366
378
  };
367
379
  var PER_UPDATE = 24;
380
+ var CRACKLE_SPARKS = 0.06;
368
381
  var FireEmitter = class {
369
382
  field;
370
383
  pool;
@@ -401,7 +414,10 @@ var FireEmitter = class {
401
414
  heat /= front;
402
415
  const struggle = 0.35 + 1.6 * Math.max(0, 0.7 - heat) + air * 0.8;
403
416
  const oxygen = 1 + air * 1.5;
404
- this.emberDebt = Math.min(this.emberDebt + front * o.embers * oxygen * dt, PER_UPDATE);
417
+ this.emberDebt = Math.min(
418
+ this.emberDebt + front * o.embers * oxygen * dt + field.lastStats.charred * o.embers * CRACKLE_SPARKS,
419
+ PER_UPDATE
420
+ );
405
421
  this.smokeDebt = Math.min(this.smokeDebt + front * o.smoke * struggle * dt, PER_UPDATE);
406
422
  while (this.emberDebt >= 1) {
407
423
  this.emberDebt -= 1;
@@ -533,7 +549,7 @@ void main() {
533
549
  // It used to be flat-topped across (smoothstep from 0.35, so the middle
534
550
  // seventy per cent of the width was all at full brightness) and to keep a
535
551
  // quarter of that brightness all the way to the end of the quad. Both
536
- // together drew a wide bar with a squared-off end, which is what the review
552
+ // together drew a wide bar with a squared-off end, which is what a visual review
537
553
  // saw up close. A spark is a point of light smeared by its own motion: it
538
554
  // is brightest on its centre line and it runs out.
539
555
  float across = abs(vQuad.x);
@@ -653,14 +669,14 @@ void main() {
653
669
  float edge = 0.62 + 0.3 * flNoise(vec2(a * 1.6 + vSeed * 31.0, vSeed * 7.0));
654
670
  float mask = 1.0 - smoothstep(edge - 0.06, edge, r);
655
671
  if (mask <= 0.0) discard;
656
- // Char in the middle, pale brittle ash at the edge (\xA78.3) \u2014 in patches
672
+ // Char in the middle, pale brittle ash at the edge \u2014 in patches
657
673
  // along it, not all the way round: a flake is a few pixels across, and a
658
674
  // continuous pale rim round a dark middle drew it as a hollow ring.
659
675
  float rim = smoothstep(edge - 0.32, edge - 0.04, r);
660
676
  float patches = smoothstep(0.45, 0.75, flNoise(vec2(a * 2.2 + vSeed * 17.0, vSeed * 13.0)));
661
677
  float grain = flNoise(vQuad * 3.0 + vSeed * 23.0);
662
678
  vec3 charC = vec3(0.05, 0.043, 0.038) * (0.7 + 0.6 * grain);
663
- // #A49E9D, the spec's own sample of ash (\xA78.3), in linear.
679
+ // #A49E9D, sampled from the reference ash, in linear.
664
680
  vec3 ashC = vec3(0.372, 0.344, 0.340);
665
681
  // Pale ash with char under it, not char with a pale rim.
666
682
  //
@@ -757,7 +773,7 @@ function FxParticles({ pool }) {
757
773
  import { Suspense, lazy } from "react";
758
774
  import { jsx as jsx2 } from "react/jsx-runtime";
759
775
  var Pass = lazy(
760
- () => import("./FxPostPass-M66QMVVC.js").then((m) => ({ default: m.FxPostPass })).catch((error) => {
776
+ () => import("./FxPostPass-ACFSHY3S.js").then((m) => ({ default: m.FxPostPass })).catch((error) => {
761
777
  warnOnce(error);
762
778
  return { default: (() => null) };
763
779
  })
@@ -767,7 +783,7 @@ function warnOnce(cause) {
767
783
  if (warned) return;
768
784
  warned = true;
769
785
  console.warn(
770
- "[paperlab/fx] Rendering without FxPost \u2014 fire will not bloom.\nIt needs two optional peers:\n npm i @react-three/postprocessing postprocessing\nLeave <FxPost> out to go without it deliberately and silence this.",
786
+ "[paperlab/fx] Rendering without FxPost, so fire will not bloom.\nIt needs two optional peers:\n npm i @react-three/postprocessing postprocessing\nLeave <FxPost> out to go without it deliberately and silence this.",
771
787
  ...cause === void 0 ? [] : [cause]
772
788
  );
773
789
  }
@@ -818,7 +834,7 @@ var Afterglow = class {
818
834
  }
819
835
  /**
820
836
  * Advance by `dt`, after the field has stepped. `blow` is the breath on the
821
- * sheet, 0..1: it flares what is left (§10.6, "on smoulder: beads flare,
837
+ * sheet, 0..1: it flares what is left ("on smoulder: beads flare,
822
838
  * then fade").
823
839
  */
824
840
  step(dt, blow = 0) {
@@ -1742,10 +1758,17 @@ void main() {
1742
1758
  float rootMask = smoothstep(mix(0.4, 0.02, uRootReach), mix(0.6, 0.12, uRootReach), fuel) * (1.0 - smoothstep(0.25, 0.6, t));
1743
1759
  fire += uRootColor * rootMask * uRootAmount * uPaperWhite * uGlow * 0.6;
1744
1760
 
1745
- // Smoke: warm grey-brown (a cool grey over cream reads lavender), lit by
1746
- // the fire under it.
1761
+ // Smoke: grey-brown, warm (a cool grey over cream reads lavender), and LIT
1762
+ // BY THE FIRE UNDER IT rather than by nothing.
1763
+ //
1764
+ // The base used to be bright enough to read on its own \u2014 beige \u2014 which is
1765
+ // right while flames are lighting it and wrong the moment they stop: the
1766
+ // last of the smoke hung over a black stage as flat beige slabs, the same
1767
+ // colour everywhere, which reads as a filled shape rather than as smoke.
1768
+ // Dim at the base and warm where the fire is: smoke by
1769
+ // the flames looks as it did, smoke after them goes to a dim grey.
1747
1770
  float smokeAlpha = 1.0 - exp(-smoke * uSmokeDensity);
1748
- vec3 smokeColor = vec3(0.16, 0.13, 0.10) + vec3(0.22, 0.09, 0.02) * smoothstep(0.15, 0.9, t);
1771
+ vec3 smokeColor = vec3(0.075, 0.068, 0.062) + vec3(0.3, 0.14, 0.045) * smoothstep(0.12, 0.9, t);
1749
1772
  float alpha = smokeAlpha + fireAlpha - smokeAlpha * fireAlpha;
1750
1773
  // No square edge: the domain fades out before its borders.
1751
1774
  float edge = smoothstep(0.0, 0.06, vUv.x) * smoothstep(1.0, 0.94, vUv.x) * smoothstep(0.0, 0.03, vUv.y) * smoothstep(1.0, 0.9, vUv.y);
@@ -2131,7 +2154,7 @@ var fireFluidDefaults = {
2131
2154
  smoke: 0.7,
2132
2155
  // Was 5 — a full unit a second, 210 mm/s, of gas pushed sideways out of the
2133
2156
  // rim. Against a buoyancy of 1.7 units/s² that is what rolled the flames
2134
- // into mushroom caps: the grey spirals over the text that the review called
2157
+ // into mushroom caps: the grey spirals over the text that a visual review called
2135
2158
  // the most synthetic thing in the frame. A flame leaves paper going UP.
2136
2159
  radialImpulse: 0.8,
2137
2160
  // In world units a second now (see `solverUniforms`): 1.4 is ~0.3 m/s, the
@@ -2164,7 +2187,7 @@ var fireFluidDefaults = {
2164
2187
  // reached the text (~150 mm), 1.6 ~100 mm, 2.1 short licks (~60 mm);
2165
2188
  // Hero.png's tallest is ~70 mm.
2166
2189
  cooling: 1.8,
2167
- // Was 1.4, which made a thick grey column, then 0.5. Noor's direction is
2190
+ // Was 1.4, which made a thick grey column, then 0.5. The direction is
2168
2191
  // light smoke, and with MacCormack advection keeping the smoke's fine
2169
2192
  // structure it needs less of it to read: at 0.5 it veiled the upper sheet.
2170
2193
  smokeProduction: 0.25,
@@ -2544,55 +2567,261 @@ function FxFireFluid({
2544
2567
  import * as THREE6 from "three";
2545
2568
  import { useFrame as useFrame5 } from "@react-three/fiber";
2546
2569
  import { useRef as useRef4 } from "react";
2547
- import { Fragment as Fragment3, jsx as jsx6, jsxs as jsxs2 } from "react/jsx-runtime";
2548
- function FxFireLight({ field, locate, gain = FIRE_LIGHT_GAIN }) {
2549
- const front = useRef4(null);
2550
- const through = useRef4(null);
2551
- const anchors = useRef4([]);
2552
- useFrame5(({ camera }) => {
2553
- const a = front.current;
2554
- const b = through.current;
2555
- if (!a || !b) return;
2556
- const n = flameAnchors(field, locate, 32, anchors.current);
2557
- if (n === 0) {
2558
- a.intensity = 0;
2559
- b.intensity = 0;
2560
- return;
2561
- }
2570
+
2571
+ // src/fx/fireState.ts
2572
+ var FLAMES = 32;
2573
+ var FIRE_CLUSTERS = 4;
2574
+ var CLUSTER_REACH = 25 / 210;
2575
+ var FireState = class {
2576
+ constructor(field, locate) {
2577
+ this.field = field;
2578
+ this.locate = locate;
2579
+ }
2580
+ field;
2581
+ locate;
2582
+ /** The flames it was taken over. Only the first {@link count} are current. */
2583
+ anchors = [];
2584
+ count = 0;
2585
+ /** Where the fire is, as a whole: its flames' mean. Zero with none. */
2586
+ x = 0;
2587
+ y = 0;
2588
+ z = 0;
2589
+ /** Its flames' mean height. */
2590
+ height = 0;
2591
+ /** Its flames' mean puff. */
2592
+ flicker = 0;
2593
+ /** Where it is gathered. Only the first {@link clusterCount} are current. */
2594
+ clusters = [];
2595
+ clusterCount = 0;
2596
+ frame = Number.NaN;
2597
+ order = [];
2598
+ root = new Float64Array(FIRE_CLUSTERS * 3);
2599
+ weight = new Float64Array(FIRE_CLUSTERS);
2600
+ /**
2601
+ * Bring it up to date for `frame` — any number that is the same for every
2602
+ * reader in one frame and different in the next; R3F's `clock.elapsedTime`
2603
+ * is the one in hand. Free the second time it is asked in a frame.
2604
+ */
2605
+ update(frame) {
2606
+ if (frame === this.frame) return this;
2607
+ this.frame = frame;
2608
+ const { field, anchors } = this;
2609
+ const n = flameAnchors(field, this.locate, FLAMES, anchors);
2610
+ this.count = n;
2562
2611
  let x = 0;
2563
2612
  let y = 0;
2564
2613
  let z = 0;
2565
2614
  let h = 0;
2566
2615
  let flicker = 0;
2567
2616
  for (let i = 0; i < n; i++) {
2568
- const f = anchors.current[i];
2617
+ const f = anchors[i];
2569
2618
  x += f.x;
2570
2619
  y += f.y;
2571
2620
  z += f.z;
2572
2621
  h += f.height;
2573
2622
  flicker += flamePuff(f.seed, field.time);
2574
2623
  }
2575
- x /= n;
2576
- y /= n;
2577
- z /= n;
2578
- h /= n;
2579
- flicker /= n;
2580
- const toCamera = new THREE6.Vector3(camera.position.x - x, 0, camera.position.z - z);
2581
- toCamera.normalize().multiplyScalar(0.035);
2582
- const lift = h * 0.5;
2583
- a.position.set(x + toCamera.x, y + lift, z + toCamera.z);
2584
- b.position.set(x - toCamera.x, y + lift, z - toCamera.z);
2585
- const level = field.lastStats.front * gain * flicker;
2586
- a.intensity = level;
2587
- b.intensity = level * 0.35;
2624
+ if (n > 0) {
2625
+ x /= n;
2626
+ y /= n;
2627
+ z /= n;
2628
+ h /= n;
2629
+ flicker /= n;
2630
+ }
2631
+ this.x = x;
2632
+ this.y = y;
2633
+ this.z = z;
2634
+ this.height = h;
2635
+ this.flicker = flicker;
2636
+ this.gather();
2637
+ return this;
2638
+ }
2639
+ /**
2640
+ * Group the flames into at most {@link FIRE_CLUSTERS} clusters.
2641
+ *
2642
+ * Tallest first, so each cluster is named by its tallest tongue and stands
2643
+ * where that one stands: a flame joins the nearest cluster within
2644
+ * {@link CLUSTER_REACH} of it, or starts one while there is room, or joins
2645
+ * the nearest once there is not. Pure arithmetic over the flames, so the
2646
+ * same burn gathers the same way every time.
2647
+ */
2648
+ gather() {
2649
+ const anchors = this.anchors;
2650
+ const clusters = this.clusters;
2651
+ const order = this.order;
2652
+ const root = this.root;
2653
+ const weight = this.weight;
2654
+ const n = this.count;
2655
+ order.length = 0;
2656
+ for (let i = 0; i < n; i++) order.push(i);
2657
+ order.sort((i, j) => anchors[j].height - anchors[i].height || i - j);
2658
+ let k = 0;
2659
+ for (const i of order) {
2660
+ const f = anchors[i];
2661
+ let best = -1;
2662
+ let near = Number.POSITIVE_INFINITY;
2663
+ for (let c = 0; c < k; c++) {
2664
+ const d = Math.hypot(f.x - root[c * 3], f.y - root[c * 3 + 1], f.z - root[c * 3 + 2]);
2665
+ if (d < near) {
2666
+ near = d;
2667
+ best = c;
2668
+ }
2669
+ }
2670
+ if (best < 0 || near > CLUSTER_REACH && k < FIRE_CLUSTERS) {
2671
+ best = k++;
2672
+ root[best * 3] = f.x;
2673
+ root[best * 3 + 1] = f.y;
2674
+ root[best * 3 + 2] = f.z;
2675
+ weight[best] = 0;
2676
+ let cluster2 = clusters[best];
2677
+ if (!cluster2) {
2678
+ cluster2 = { x: 0, y: 0, z: 0, height: 0, tallest: 0, flicker: 0, heat: 0, count: 0 };
2679
+ clusters[best] = cluster2;
2680
+ }
2681
+ cluster2.x = 0;
2682
+ cluster2.y = 0;
2683
+ cluster2.z = 0;
2684
+ cluster2.height = 0;
2685
+ cluster2.tallest = f.height;
2686
+ cluster2.flicker = 0;
2687
+ cluster2.heat = 0;
2688
+ cluster2.count = 0;
2689
+ }
2690
+ const cluster = clusters[best];
2691
+ const w = f.height;
2692
+ cluster.x += f.x * w;
2693
+ cluster.y += f.y * w;
2694
+ cluster.z += f.z * w;
2695
+ weight[best] = weight[best] + w;
2696
+ cluster.height += f.height;
2697
+ cluster.flicker += flamePuff(f.seed, this.field.time);
2698
+ cluster.heat += f.heat;
2699
+ cluster.count++;
2700
+ }
2701
+ for (let c = 0; c < k; c++) {
2702
+ const cluster = clusters[c];
2703
+ const w = weight[c];
2704
+ if (w > 0) {
2705
+ cluster.x /= w;
2706
+ cluster.y /= w;
2707
+ cluster.z /= w;
2708
+ } else {
2709
+ cluster.x = root[c * 3];
2710
+ cluster.y = root[c * 3 + 1];
2711
+ cluster.z = root[c * 3 + 2];
2712
+ }
2713
+ cluster.height /= cluster.count;
2714
+ cluster.flicker /= cluster.count;
2715
+ cluster.heat /= cluster.count;
2716
+ }
2717
+ this.clusterCount = k;
2718
+ }
2719
+ };
2720
+ var shared = /* @__PURE__ */ new WeakMap();
2721
+ function fireStateOf(field, locate) {
2722
+ let byLocator = shared.get(field);
2723
+ if (!byLocator) {
2724
+ byLocator = /* @__PURE__ */ new WeakMap();
2725
+ shared.set(field, byLocator);
2726
+ }
2727
+ let state = byLocator.get(locate);
2728
+ if (!state) {
2729
+ state = new FireState(field, locate);
2730
+ byLocator.set(locate, state);
2731
+ }
2732
+ return state;
2733
+ }
2734
+
2735
+ // src/fx/FxFireLight.tsx
2736
+ import { Fragment as Fragment3, jsx as jsx6, jsxs as jsxs2 } from "react/jsx-runtime";
2737
+ function FxFireLight({ field, locate, gain = FIRE_LIGHT_GAIN, shadows = false }) {
2738
+ const front = useRef4([]);
2739
+ const through = useRef4(null);
2740
+ const spot = useRef4(null);
2741
+ useFrame5(({ camera, clock }) => {
2742
+ const b = through.current;
2743
+ if (!b) return;
2744
+ const fire = fireStateOf(field, locate).update(clock.elapsedTime);
2745
+ const level = field.lastStats.front * gain;
2746
+ let total = 0;
2747
+ let brightest = -1;
2748
+ let most = 0;
2749
+ for (let c = 0; c < fire.clusterCount; c++) {
2750
+ const cluster = fire.clusters[c];
2751
+ const share = cluster.count * cluster.height;
2752
+ total += share;
2753
+ if (share > most) {
2754
+ most = share;
2755
+ brightest = c;
2756
+ }
2757
+ }
2758
+ const s = shadows ? spot.current : null;
2759
+ if (s) s.intensity = 0;
2760
+ for (let i = 0; i < FIRE_CLUSTERS; i++) {
2761
+ const light = front.current[i];
2762
+ if (!light) continue;
2763
+ const cluster = i < fire.clusterCount ? fire.clusters[i] : null;
2764
+ if (!cluster || !(total > 0)) {
2765
+ light.intensity = 0;
2766
+ continue;
2767
+ }
2768
+ toward.set(camera.position.x - cluster.x, 0, camera.position.z - cluster.z).normalize().multiplyScalar(0.035);
2769
+ light.position.set(cluster.x + toward.x, cluster.y + cluster.height * 0.5, cluster.z + toward.z);
2770
+ light.intensity = level * cluster.flicker * (cluster.count * cluster.height / total);
2771
+ light.color.copy(COOLING).lerp(FIRE_COLOR, Math.min(1, Math.max(0, cluster.heat)));
2772
+ if (s && i === brightest) {
2773
+ s.position.copy(light.position);
2774
+ s.target.position.set(cluster.x, cluster.y, cluster.z);
2775
+ s.target.updateMatrixWorld();
2776
+ s.intensity = light.intensity;
2777
+ s.color.copy(light.color);
2778
+ light.intensity = 0;
2779
+ }
2780
+ }
2781
+ if (fire.count === 0) {
2782
+ b.intensity = 0;
2783
+ return;
2784
+ }
2785
+ toward.set(camera.position.x - fire.x, 0, camera.position.z - fire.z).normalize().multiplyScalar(0.035);
2786
+ b.position.set(fire.x - toward.x, fire.y + fire.height * 0.5, fire.z - toward.z);
2787
+ b.intensity = level * fire.flicker * 0.35;
2588
2788
  });
2589
2789
  return /* @__PURE__ */ jsxs2(Fragment3, { children: [
2590
- /* @__PURE__ */ jsx6("pointLight", { ref: front, color: FIRE_COLOR, intensity: 0, decay: 0.9, distance: 0 }),
2790
+ Array.from({ length: FIRE_CLUSTERS }, (_, i) => /* @__PURE__ */ jsx6(
2791
+ "pointLight",
2792
+ {
2793
+ ref: (light) => {
2794
+ front.current[i] = light;
2795
+ },
2796
+ color: FIRE_COLOR,
2797
+ intensity: 0,
2798
+ decay: 0.9,
2799
+ distance: 0
2800
+ },
2801
+ i
2802
+ )),
2803
+ shadows && /* @__PURE__ */ jsx6(
2804
+ "spotLight",
2805
+ {
2806
+ ref: spot,
2807
+ color: FIRE_COLOR,
2808
+ intensity: 0,
2809
+ decay: 0.9,
2810
+ distance: 0,
2811
+ angle: 1.3,
2812
+ penumbra: 0.8,
2813
+ castShadow: true,
2814
+ "shadow-mapSize": [512, 512],
2815
+ "shadow-bias": -4e-4
2816
+ }
2817
+ ),
2591
2818
  /* @__PURE__ */ jsx6("pointLight", { ref: through, color: FIRE_COLOR, intensity: 0, decay: 0.9, distance: 0 })
2592
2819
  ] });
2593
2820
  }
2821
+ var toward = new THREE6.Vector3();
2594
2822
  var FIRE_COLOR = new THREE6.Color("#ffa24c");
2595
- var FIRE_LIGHT_GAIN = 42;
2823
+ var COOLING = new THREE6.Color("#ff8a33");
2824
+ var FIRE_LIGHT_GAIN = 26;
2596
2825
 
2597
2826
  // src/fx/FxMatchFlame.tsx
2598
2827
  import * as THREE7 from "three";
@@ -2736,7 +2965,7 @@ var FxAudio = class {
2736
2965
  constructor(options) {
2737
2966
  const { quality = "auto", context, volume = 0.8 } = options;
2738
2967
  this.quality = typeof quality === "string" ? fxQualityFor(quality) : quality;
2739
- if (!context) throw new Error("FxAudio needs a context \u2014 see createAudioContext()");
2968
+ if (!context) throw new Error("FxAudio needs a context. See createAudioContext()");
2740
2969
  this.ctx = context;
2741
2970
  this.master = this.ctx.createGain();
2742
2971
  this.master.gain.value = volume;
@@ -2996,18 +3225,18 @@ var FireSound = class {
2996
3225
  }
2997
3226
  /**
2998
3227
  * A match struck: the scratch of the head across the box, then the hiss of
2999
- * it flaring as the head burns off (spec §10.2). Two bursts of the shared
3228
+ * it flaring as the head burns off. Two bursts of the shared
3000
3229
  * noise, one bright and short, one breathier and longer.
3001
3230
  */
3002
3231
  strike() {
3003
3232
  this.burst("fire-strike", 0.9, { type: "bandpass", frequency: 3200, q: 1.1 }, 4e-3, 0.07, 0.8);
3004
3233
  this.burst("fire-flare", 0.8, { type: "highpass", frequency: 1800, q: 0.7 }, 0.03, 0.45, 0.45, 0.05);
3005
3234
  }
3006
- /** Blown out: a soft, low breath of noise (spec §10.6). */
3235
+ /** Blown out: a soft, low breath of noise. */
3007
3236
  puff() {
3008
3237
  this.burst("fire-puff", 0.6, { type: "lowpass", frequency: 520, q: 0.6 }, 0.02, 0.28, 0.55);
3009
3238
  }
3010
- /** One ember popping in the air — a tiny click on the frame it flashes (spec §8.1). */
3239
+ /** One ember popping in the air — a tiny click on the frame it flashes. */
3011
3240
  pop() {
3012
3241
  const f = 3e3 + this.next() * 3e3;
3013
3242
  this.burst(
@@ -3148,6 +3377,7 @@ export {
3148
3377
  FIELD_SIZE,
3149
3378
  FIRE_GLOW,
3150
3379
  FIRE_HEAT_SCALE,
3380
+ FIRE_LIGHT_GAIN,
3151
3381
  FIRE_SOOT_SCALE,
3152
3382
  FIRE_ZONES,
3153
3383
  FIXED_DT,