hayao 0.3.0 → 0.4.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.
package/dist/index.js CHANGED
@@ -254,6 +254,32 @@ function invertTransform(m) {
254
254
  };
255
255
  }
256
256
 
257
+ // src/core/projection.ts
258
+ function iso(cfg) {
259
+ const { tileW, tileH } = cfg;
260
+ const origin = cfg.origin ? { x: cfg.origin.x, y: cfg.origin.y } : { x: 0, y: 0 };
261
+ const elevStep = cfg.elevStep ?? tileH;
262
+ const hw = tileW / 2;
263
+ const hh = tileH / 2;
264
+ return {
265
+ tileW,
266
+ tileH,
267
+ origin,
268
+ elevStep,
269
+ toScreen(gx, gy, elev = 0) {
270
+ return {
271
+ x: origin.x + (gx - gy) * hw,
272
+ y: origin.y + (gx + gy) * hh - elev * elevStep
273
+ };
274
+ },
275
+ toGrid(sx, sy) {
276
+ const dx = (sx - origin.x) / hw;
277
+ const dy = (sy - origin.y) / hh;
278
+ return { x: (dx + dy) / 2, y: (dy - dx) / 2 };
279
+ }
280
+ };
281
+ }
282
+
257
283
  // src/core/rng.ts
258
284
  function splitmix32(seed) {
259
285
  let a = seed >>> 0;
@@ -347,6 +373,21 @@ var Rng = class _Rng {
347
373
  [this.s0, this.s1, this.s2, this.s3] = state.s;
348
374
  }
349
375
  };
376
+ var __noiseBits = new DataView(new ArrayBuffer(8));
377
+ function hashNoise(...values) {
378
+ let h = 2654435769 >>> 0;
379
+ for (const v of values) {
380
+ __noiseBits.setFloat64(0, v);
381
+ h = Math.imul(h ^ __noiseBits.getUint32(0), 2246822507) >>> 0;
382
+ h = (h ^ h >>> 13) >>> 0;
383
+ h = Math.imul(h ^ __noiseBits.getUint32(4), 3266489909) >>> 0;
384
+ h = (h ^ h >>> 16) >>> 0;
385
+ }
386
+ h = Math.imul(h ^ h >>> 16, 2246822507) >>> 0;
387
+ h = Math.imul(h ^ h >>> 13, 3266489909) >>> 0;
388
+ h = (h ^ h >>> 16) >>> 0;
389
+ return h / 4294967296;
390
+ }
350
391
  function hashString(str) {
351
392
  let h = 2166136261 >>> 0;
352
393
  for (let i = 0; i < str.length; i++) {
@@ -540,6 +581,24 @@ var Node = class {
540
581
  * particles, tweened positions) never pollutes the canonical, verifiable state.
541
582
  */
542
583
  cosmetic = false;
584
+ /**
585
+ * Pause behaviour for this subtree (see PauseMode). Serialized only when
586
+ * non-default, so existing trees keep their pinned hashes.
587
+ */
588
+ pauseMode = "inherit";
589
+ /**
590
+ * Screen-space overlay: this subtree ignores the camera — its transforms
591
+ * compose from IDENTITY (design-space coordinates) — and every command it
592
+ * emits is tagged `layer: 1` so HUD/overlay content always paints above the
593
+ * world, whatever the z values. Serialized only when true.
594
+ */
595
+ screenSpace = false;
596
+ /**
597
+ * Optional local anchor: when set, rotation/scale pivot around this point in
598
+ * local space instead of the node origin (the local transform gains a
599
+ * trailing translation of −pivot). Serialized only when set.
600
+ */
601
+ pivot;
543
602
  parent = null;
544
603
  children = [];
545
604
  world = null;
@@ -562,6 +621,21 @@ var Node = class {
562
621
  this.z = config.z ?? 0;
563
622
  this.visible = config.visible ?? true;
564
623
  }
624
+ // ── Convenience accessors ─────────────────────────────────────
625
+ /** Shorthand for `pos.x` — reads and writes the same vector. */
626
+ get x() {
627
+ return this.pos.x;
628
+ }
629
+ set x(v) {
630
+ this.pos.x = v;
631
+ }
632
+ /** Shorthand for `pos.y` — reads and writes the same vector. */
633
+ get y() {
634
+ return this.pos.y;
635
+ }
636
+ set y(v) {
637
+ this.pos.y = v;
638
+ }
565
639
  // ── Tree structure ─────────────────────────────────────────────
566
640
  addChild(child) {
567
641
  child.parent = this;
@@ -580,6 +654,18 @@ var Node = class {
580
654
  free() {
581
655
  this.world?.requestFree(this);
582
656
  }
657
+ /**
658
+ * Immediately exit + detach ALL children. Unlike `free()` this is NOT
659
+ * deferred — the children are gone when the call returns (safe here because
660
+ * it iterates a snapshot of the array). Use it to rebuild a container's
661
+ * contents wholesale; prefer `free()` for removals during an update.
662
+ */
663
+ clearChildren() {
664
+ for (const c of this.children.slice()) {
665
+ c.exitTree();
666
+ this.removeChild(c);
667
+ }
668
+ }
583
669
  /** Depth-first find by name. */
584
670
  find(name) {
585
671
  if (this.name === name) return this;
@@ -589,6 +675,15 @@ var Node = class {
589
675
  }
590
676
  return null;
591
677
  }
678
+ /** Depth-first find of the first node (self included) that is an instance of `ctor` — typed. */
679
+ findOfType(ctor) {
680
+ if (this instanceof ctor) return this;
681
+ for (const c of this.children) {
682
+ const r = c.findOfType(ctor);
683
+ if (r) return r;
684
+ }
685
+ return null;
686
+ }
592
687
  /** All descendants (and self) of a given type tag. */
593
688
  query(type, out = []) {
594
689
  if (this.type === type) out.push(this);
@@ -623,13 +718,25 @@ var Node = class {
623
718
  }
624
719
  for (const c of this.children) c.enterTree(world);
625
720
  }
626
- updateTree(dt) {
721
+ /**
722
+ * Advance this subtree. `dt` is the (time-scaled) step delta; `unscaledDt`
723
+ * and `paused` come from the world, and `parentMode` is the effective pause
724
+ * mode flowing down the tree. The walk always descends — a paused node just
725
+ * skips its callbacks — so an `'always'` descendant (pause menu, transition)
726
+ * keeps running inside a paused parent.
727
+ */
728
+ updateTree(dt, unscaledDt = dt, parentMode = "pausable", paused = false) {
627
729
  if (this._freed) return;
628
- const ctx = this.world;
629
- for (const b of this.behaviors) b.update?.(this, dt, ctx);
630
- this.onUpdate?.(this, dt, ctx);
631
- this.onProcess(dt);
632
- for (const c of this.children.slice()) c.updateTree(dt);
730
+ const mode = this.pauseMode === "inherit" ? parentMode : this.pauseMode;
731
+ const running = mode === "always" || mode === "pausable" && !paused;
732
+ if (running) {
733
+ const ctx = this.world;
734
+ const useDt = mode === "always" ? unscaledDt : dt;
735
+ for (const b of this.behaviors) b.update?.(this, useDt, ctx);
736
+ this.onUpdate?.(this, useDt, ctx);
737
+ this.onProcess(useDt);
738
+ }
739
+ for (const c of this.children.slice()) c.updateTree(dt, unscaledDt, mode, paused);
633
740
  }
634
741
  exitTree() {
635
742
  for (const b of this.behaviors) b.exit?.(this);
@@ -642,7 +749,8 @@ var Node = class {
642
749
  }
643
750
  // ── Transforms ────────────────────────────────────────────────
644
751
  localTransform() {
645
- return makeTransform(this.pos, this.rotation, this.scale);
752
+ const t = makeTransform(this.pos, this.rotation, this.scale);
753
+ return this.pivot ? composeTransform(t, makeTransform({ x: -this.pivot.x, y: -this.pivot.y }, 0, { x: 1, y: 1 })) : t;
646
754
  }
647
755
  worldTransform() {
648
756
  const local = this.localTransform();
@@ -652,9 +760,16 @@ var Node = class {
652
760
  /** Walk the subtree, appending draw commands with computed world transforms. */
653
761
  collectDraw(out, parentWorld = IDENTITY) {
654
762
  if (!this.visible) return;
655
- const world = composeTransform(parentWorld, this.localTransform());
763
+ const world = composeTransform(this.screenSpace ? IDENTITY : parentWorld, this.localTransform());
764
+ const start = out.length;
656
765
  this.draw(out, world);
657
766
  for (const c of this.children) c.collectDraw(out, world);
767
+ if (this.screenSpace) {
768
+ for (let i = start; i < out.length; i++) {
769
+ const cmd = out[i];
770
+ if ((cmd.layer ?? 0) < 1) cmd.layer = 1;
771
+ }
772
+ }
658
773
  }
659
774
  /** Subclasses emit their own commands here. Base draws nothing. */
660
775
  draw(_out, _world) {
@@ -676,7 +791,13 @@ var Node = class {
676
791
  scale: { ...this.scale },
677
792
  z: this.z,
678
793
  visible: this.visible,
679
- props: this.serializeProps(),
794
+ // New base fields only when non-default, so pinned hashes survive.
795
+ ...this.pivot ? { pivot: { ...this.pivot } } : {},
796
+ props: {
797
+ ...this.serializeProps(),
798
+ ...this.pauseMode !== "inherit" ? { pauseMode: this.pauseMode } : {},
799
+ ...this.screenSpace ? { screenSpace: true } : {}
800
+ },
680
801
  children: this.children.filter((c) => !c.cosmetic).map((c) => c.serialize())
681
802
  };
682
803
  }
@@ -690,6 +811,17 @@ var Node = class {
690
811
  };
691
812
 
692
813
  // src/scene/nodes.ts
814
+ function diamondPoints(w, h) {
815
+ return [0, -h / 2, w / 2, 0, 0, h / 2, -w / 2, 0];
816
+ }
817
+ function regularPolyPoints(sides, r, rotation = 0) {
818
+ const pts = [];
819
+ for (let i = 0; i < sides; i++) {
820
+ const a = rotation - TAU / 4 + TAU * i / sides;
821
+ pts.push(dcos(a) * r, dsin(a) * r);
822
+ }
823
+ return pts;
824
+ }
693
825
  var Sprite = class extends Node {
694
826
  type = "Sprite";
695
827
  shape;
@@ -704,7 +836,8 @@ var Sprite = class extends Node {
704
836
  opacity: config.opacity,
705
837
  round: config.round,
706
838
  gradient: config.gradient,
707
- shadow: config.shadow
839
+ shadow: config.shadow,
840
+ lineDash: config.lineDash
708
841
  };
709
842
  }
710
843
  draw(out, world) {
@@ -719,6 +852,12 @@ var Sprite = class extends Node {
719
852
  case "circle":
720
853
  out.push({ kind: "circle", cx: 0, cy: 0, radius: this.shape.radius, ...base });
721
854
  break;
855
+ case "ellipse":
856
+ out.push({ kind: "ellipse", cx: 0, cy: 0, rx: this.shape.rx, ry: this.shape.ry, ...base });
857
+ break;
858
+ case "arc":
859
+ out.push({ kind: "arc", cx: 0, cy: 0, radius: this.shape.radius, start: this.shape.start, end: this.shape.end, sector: this.shape.sector, ...base });
860
+ break;
722
861
  case "poly":
723
862
  out.push({ kind: "poly", points: this.shape.points, closed: this.shape.closed ?? true, ...base });
724
863
  break;
@@ -728,6 +867,12 @@ var Sprite = class extends Node {
728
867
  case "glyph":
729
868
  out.push({ kind: "text", text: this.shape.char, x: 0, y: 0, size: this.shape.size, align: "center", ...base });
730
869
  break;
870
+ case "diamond":
871
+ out.push({ kind: "poly", points: diamondPoints(this.shape.w, this.shape.h), closed: true, ...base });
872
+ break;
873
+ case "regularPoly":
874
+ out.push({ kind: "poly", points: regularPolyPoints(this.shape.sides, this.shape.r, this.shape.rotation ?? 0), closed: true, ...base });
875
+ break;
731
876
  }
732
877
  }
733
878
  serializeProps() {
@@ -848,6 +993,85 @@ var Timer = class extends Node {
848
993
  }
849
994
  };
850
995
 
996
+ // src/scene/iso.ts
997
+ function shadeHex(hex, f) {
998
+ let h = hex.replace("#", "");
999
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
1000
+ if (h.length !== 6) return hex;
1001
+ const ch = (i) => {
1002
+ const v = Math.round(Math.min(255, Math.max(0, parseInt(h.slice(i, i + 2), 16) * f)));
1003
+ return v.toString(16).padStart(2, "0");
1004
+ };
1005
+ return `#${ch(0)}${ch(2)}${ch(4)}`;
1006
+ }
1007
+ var IsoPrism = class extends Node {
1008
+ type = "IsoPrism";
1009
+ tileW;
1010
+ tileH;
1011
+ height;
1012
+ fill;
1013
+ topPaint;
1014
+ leftPaint;
1015
+ rightPaint;
1016
+ stroke;
1017
+ strokeWidth;
1018
+ opacity;
1019
+ constructor(config) {
1020
+ super(config);
1021
+ this.tileW = config.tileW;
1022
+ this.tileH = config.tileH;
1023
+ this.height = config.height;
1024
+ this.fill = config.fill;
1025
+ this.topPaint = config.top;
1026
+ this.leftPaint = config.left;
1027
+ this.rightPaint = config.right;
1028
+ this.stroke = config.stroke;
1029
+ this.strokeWidth = config.strokeWidth;
1030
+ this.opacity = config.opacity;
1031
+ }
1032
+ draw(out, world) {
1033
+ const w = this.tileW;
1034
+ const h = this.tileH;
1035
+ const H = this.height;
1036
+ const seam = { stroke: this.stroke, strokeWidth: this.strokeWidth, opacity: this.opacity };
1037
+ const top = this.topPaint ?? { fill: this.fill };
1038
+ const left = this.leftPaint ?? { fill: shadeHex(this.fill, 0.65) };
1039
+ const right = this.rightPaint ?? { fill: shadeHex(this.fill, 0.85) };
1040
+ const base = { transform: world, z: this.z };
1041
+ if (H > 0) {
1042
+ out.push({ kind: "poly", closed: true, points: [-w / 2, 0, 0, h / 2, 0, h / 2 - H, -w / 2, -H], ...seam, ...left, ...base });
1043
+ out.push({ kind: "poly", closed: true, points: [0, h / 2, w / 2, 0, w / 2, -H, 0, h / 2 - H], ...seam, ...right, ...base });
1044
+ }
1045
+ const tp = diamondPoints(w, h).map((v, i) => i % 2 === 1 ? v - H : v);
1046
+ out.push({ kind: "poly", closed: true, points: tp, ...seam, ...top, ...base });
1047
+ }
1048
+ serializeProps() {
1049
+ return { tileW: this.tileW, tileH: this.tileH, height: this.height, fill: this.fill };
1050
+ }
1051
+ applyProps(props) {
1052
+ if (typeof props.tileW === "number") this.tileW = props.tileW;
1053
+ if (typeof props.tileH === "number") this.tileH = props.tileH;
1054
+ if (typeof props.height === "number") this.height = props.height;
1055
+ if (typeof props.fill === "string") this.fill = props.fill;
1056
+ }
1057
+ };
1058
+ var DepthSort = class extends Node {
1059
+ type = "DepthSort";
1060
+ key;
1061
+ depthScale;
1062
+ /** DepthSort is a pure ordering container — its z assignments are view-only. */
1063
+ cosmetic = true;
1064
+ constructor(config) {
1065
+ super(config);
1066
+ this.key = config.key;
1067
+ this.depthScale = config.depthScale ?? 1;
1068
+ }
1069
+ collectDraw(out, parentWorld) {
1070
+ for (const c of this.children) c.z = this.key(c) * this.depthScale;
1071
+ super.collectDraw(out, parentWorld);
1072
+ }
1073
+ };
1074
+
851
1075
  // src/scene/cameraController.ts
852
1076
  var CameraController = class extends Node {
853
1077
  type = "CameraController";
@@ -1111,6 +1335,36 @@ var Particles = class extends Node {
1111
1335
  gravity = 0;
1112
1336
  drag = 0;
1113
1337
  shrink = true;
1338
+ // ── Continuous emission ─────────────────────────────────────────
1339
+ // A steady stream (torch smoke, thruster exhaust) on top of the one-shot
1340
+ // burst(): a fractional accumulator over sim dt spawns whole particles
1341
+ // through the same private-Rng burst path, so it stays deterministic and
1342
+ // cosmetic by the same construction.
1343
+ emitRate = 0;
1344
+ emitAcc = 0;
1345
+ emitStyle = null;
1346
+ emitOrigin = { x: 0, y: 0 };
1347
+ emit(rateOrName, styleOrPayload) {
1348
+ if (typeof rateOrName === "string") {
1349
+ super.emit(rateOrName, styleOrPayload);
1350
+ return;
1351
+ }
1352
+ this.emitRate = rateOrName;
1353
+ this.emitAcc = 0;
1354
+ const style = styleOrPayload;
1355
+ if (style) {
1356
+ const { at: at2, ...rest3 } = style;
1357
+ this.emitStyle = rest3;
1358
+ this.emitOrigin = at2 ? { x: at2.x, y: at2.y } : { x: 0, y: 0 };
1359
+ } else if (!this.emitStyle) {
1360
+ this.emitStyle = PARTICLE_PRESETS.burst();
1361
+ }
1362
+ }
1363
+ /** Stop continuous emission. Live particles finish their lives naturally. */
1364
+ stopEmit() {
1365
+ this.emitRate = 0;
1366
+ this.emitAcc = 0;
1367
+ }
1114
1368
  onProcess(dt) {
1115
1369
  const drag = Math.max(0, 1 - this.drag * dt);
1116
1370
  let write = 0;
@@ -1125,6 +1379,14 @@ var Particles = class extends Node {
1125
1379
  this.pool[write++] = p;
1126
1380
  }
1127
1381
  this.pool.length = write;
1382
+ if (this.emitRate > 0 && this.emitStyle) {
1383
+ this.emitAcc += this.emitRate * dt;
1384
+ const spawn = Math.floor(this.emitAcc);
1385
+ if (spawn > 0) {
1386
+ this.emitAcc -= spawn;
1387
+ this.burst(spawn, this.emitOrigin, this.emitStyle);
1388
+ }
1389
+ }
1128
1390
  }
1129
1391
  draw(out, world) {
1130
1392
  for (const p of this.pool) {
@@ -1371,6 +1633,204 @@ var FLOAT_PRESETS = {
1371
1633
  label: (color = "#3d3323") => ({ color, size: 18, weight: 600, rise: 40, gravity: 0, life: 1.2, jitter: 0, fade: 0.5 })
1372
1634
  };
1373
1635
 
1636
+ // src/scene/verletChain.ts
1637
+ var VerletChain = class extends Node {
1638
+ type = "VerletChain";
1639
+ gravity;
1640
+ damping;
1641
+ iterations;
1642
+ pinTail;
1643
+ stroke;
1644
+ strokeWidth;
1645
+ taper;
1646
+ /**
1647
+ * Where the tail is pinned when `pinTail` is true, in NODE-LOCAL space
1648
+ * (same space as `points`). null leaves the tail free even with pinTail set.
1649
+ */
1650
+ tailTarget = null;
1651
+ segments;
1652
+ /** Rest length of one segment. */
1653
+ segmentLength;
1654
+ // World-space simulation state (flat arrays: allocation-free hot path).
1655
+ px;
1656
+ py;
1657
+ prevx;
1658
+ prevy;
1659
+ /** Node-local mirror of the sim points, updated after each step. */
1660
+ local;
1661
+ initialized = false;
1662
+ // Pending impulse (world px/s), applied to free points on the next step.
1663
+ impX = 0;
1664
+ impY = 0;
1665
+ constructor(config) {
1666
+ super(config);
1667
+ this.cosmetic = true;
1668
+ this.segments = Math.max(1, Math.floor(config.segments));
1669
+ this.segmentLength = config.length / this.segments;
1670
+ this.gravity = config.gravity ? { ...config.gravity } : { x: 0, y: 600 };
1671
+ this.damping = config.damping ?? 0.985;
1672
+ this.iterations = Math.max(1, config.iterations ?? 3);
1673
+ this.pinTail = config.pinTail ?? false;
1674
+ this.stroke = config.stroke ?? "#333";
1675
+ this.strokeWidth = config.strokeWidth ?? 3;
1676
+ this.taper = config.taper ?? false;
1677
+ const n5 = this.segments + 1;
1678
+ this.px = new Float64Array(n5);
1679
+ this.py = new Float64Array(n5);
1680
+ this.prevx = new Float64Array(n5);
1681
+ this.prevy = new Float64Array(n5);
1682
+ this.local = [];
1683
+ for (let i = 0; i < n5; i++) this.local.push({ x: 0, y: i * this.segmentLength });
1684
+ }
1685
+ /**
1686
+ * Chain points in NODE-LOCAL space, head first (`points[0]` ≈ {0,0}).
1687
+ * The Vec2 objects are reused across steps — read, don't retain copies-by-ref.
1688
+ */
1689
+ get points() {
1690
+ return this.local;
1691
+ }
1692
+ /**
1693
+ * Direction of segment i (0..segments-1) in node-local space, radians
1694
+ * (0 = +x, y-down screen convention). Map child sprites to segments with
1695
+ * `sprite.pos = chain.points[i]; sprite.rotation = chain.segmentAngle(i)`.
1696
+ */
1697
+ segmentAngle(i) {
1698
+ const a = this.local[i];
1699
+ const b = this.local[i + 1];
1700
+ return datan2(b.y - a.y, b.x - a.x);
1701
+ }
1702
+ /** Flick the chain: add a velocity (world px/s) to every free point next step. */
1703
+ impulse(v) {
1704
+ this.impX += v.x;
1705
+ this.impY += v.y;
1706
+ }
1707
+ onProcess(dt) {
1708
+ const wt = this.worldTransform();
1709
+ const headX = wt.e;
1710
+ const headY = wt.f;
1711
+ const n5 = this.segments + 1;
1712
+ const px = this.px;
1713
+ const py = this.py;
1714
+ const prevx = this.prevx;
1715
+ const prevy = this.prevy;
1716
+ if (!this.initialized) {
1717
+ for (let i = 0; i < n5; i++) {
1718
+ px[i] = headX;
1719
+ py[i] = headY + i * this.segmentLength;
1720
+ prevx[i] = px[i];
1721
+ prevy[i] = py[i];
1722
+ }
1723
+ this.initialized = true;
1724
+ }
1725
+ const tailPinned = this.pinTail && this.tailTarget !== null;
1726
+ let tailX = 0;
1727
+ let tailY = 0;
1728
+ if (tailPinned) {
1729
+ const t = this.tailTarget;
1730
+ tailX = wt.a * t.x + wt.c * t.y + wt.e;
1731
+ tailY = wt.b * t.x + wt.d * t.y + wt.f;
1732
+ }
1733
+ const gdt2x = this.gravity.x * dt * dt;
1734
+ const gdt2y = this.gravity.y * dt * dt;
1735
+ const impDx = this.impX * dt;
1736
+ const impDy = this.impY * dt;
1737
+ const damp = this.damping;
1738
+ const last = n5 - 1;
1739
+ for (let i = 1; i < n5; i++) {
1740
+ if (tailPinned && i === last) continue;
1741
+ const x = px[i];
1742
+ const y = py[i];
1743
+ px[i] = x + (x - prevx[i]) * damp + gdt2x + impDx;
1744
+ py[i] = y + (y - prevy[i]) * damp + gdt2y + impDy;
1745
+ prevx[i] = x;
1746
+ prevy[i] = y;
1747
+ }
1748
+ this.impX = 0;
1749
+ this.impY = 0;
1750
+ const rest3 = this.segmentLength;
1751
+ for (let k = 0; k < this.iterations; k++) {
1752
+ px[0] = headX;
1753
+ py[0] = headY;
1754
+ if (tailPinned) {
1755
+ px[last] = tailX;
1756
+ py[last] = tailY;
1757
+ }
1758
+ for (let i = 0; i < last; i++) {
1759
+ const dx = px[i + 1] - px[i];
1760
+ const dy = py[i + 1] - py[i];
1761
+ const d = Math.sqrt(dx * dx + dy * dy);
1762
+ if (d === 0) continue;
1763
+ const diff = (d - rest3) / d;
1764
+ const aFixed = i === 0;
1765
+ const bFixed = tailPinned && i + 1 === last;
1766
+ if (aFixed && bFixed) continue;
1767
+ const wA = aFixed ? 0 : bFixed ? 1 : 0.5;
1768
+ const wB = bFixed ? 0 : aFixed ? 1 : 0.5;
1769
+ px[i] += dx * diff * wA;
1770
+ py[i] += dy * diff * wA;
1771
+ px[i + 1] -= dx * diff * wB;
1772
+ py[i + 1] -= dy * diff * wB;
1773
+ }
1774
+ }
1775
+ px[0] = headX;
1776
+ py[0] = headY;
1777
+ if (tailPinned) {
1778
+ px[last] = tailX;
1779
+ py[last] = tailY;
1780
+ }
1781
+ const inv = invertTransform(wt);
1782
+ for (let i = 0; i < n5; i++) {
1783
+ const p = this.local[i];
1784
+ const wx = px[i];
1785
+ const wy = py[i];
1786
+ p.x = inv.a * wx + inv.c * wy + inv.e;
1787
+ p.y = inv.b * wx + inv.d * wy + inv.f;
1788
+ }
1789
+ }
1790
+ /**
1791
+ * Stroked open poly of the chain. `points` are node-local and `world` is the
1792
+ * self-inclusive world transform handed to draw(), so commands emit with it
1793
+ * directly. With `taper`, each segment is its own 2-point poly at a linearly
1794
+ * decreasing width (segments are few, so this stays cheap); otherwise one poly.
1795
+ */
1796
+ draw(out, world) {
1797
+ const n5 = this.local.length;
1798
+ if (n5 < 2) return;
1799
+ if (this.taper) {
1800
+ for (let i = 0; i < n5 - 1; i++) {
1801
+ const a = this.local[i];
1802
+ const b = this.local[i + 1];
1803
+ const t = 1 - i / (n5 - 1);
1804
+ out.push({
1805
+ kind: "poly",
1806
+ points: [a.x, a.y, b.x, b.y],
1807
+ closed: false,
1808
+ stroke: this.stroke,
1809
+ strokeWidth: Math.max(0.5, this.strokeWidth * t),
1810
+ round: true,
1811
+ transform: world,
1812
+ z: this.z,
1813
+ transient: true
1814
+ });
1815
+ }
1816
+ return;
1817
+ }
1818
+ const pts = [];
1819
+ for (const p of this.local) pts.push(p.x, p.y);
1820
+ out.push({
1821
+ kind: "poly",
1822
+ points: pts,
1823
+ closed: false,
1824
+ stroke: this.stroke,
1825
+ strokeWidth: this.strokeWidth,
1826
+ round: true,
1827
+ transform: world,
1828
+ z: this.z,
1829
+ transient: true
1830
+ });
1831
+ }
1832
+ };
1833
+
1374
1834
  // src/scene/registry.ts
1375
1835
  var registry = /* @__PURE__ */ new Map();
1376
1836
  function registerNode(type, factory) {
@@ -1392,6 +1852,9 @@ function deserializeNode(data) {
1392
1852
  node.scale = { ...data.scale };
1393
1853
  node.z = data.z;
1394
1854
  node.visible = data.visible;
1855
+ if (data.pivot) node.pivot = { ...data.pivot };
1856
+ if (data.props.pauseMode === "always" || data.props.pauseMode === "stopped") node.pauseMode = data.props.pauseMode;
1857
+ if (data.props.screenSpace === true) node.screenSpace = true;
1395
1858
  node.applyProps(data.props);
1396
1859
  for (const child of data.children) node.addChild(deserializeNode(child));
1397
1860
  return node;
@@ -1401,20 +1864,72 @@ function deserializeNode(data) {
1401
1864
  var InputState = class {
1402
1865
  down = /* @__PURE__ */ new Set();
1403
1866
  prev = /* @__PURE__ */ new Set();
1404
- /** Analog axes (e.g. from gamepad or pointer), sampled per step. */
1867
+ /**
1868
+ * Analog axes (e.g. from pointer or gamepad), sampled per step. By default
1869
+ * these are LIVE host samples — NOT hashed or logged (see PointerSource).
1870
+ * Axes passed to `beginFrame`'s second argument are the exception: they are
1871
+ * *logged* (enter `getState()` → hash, and the input log), so quantized analog
1872
+ * aim replays bit-exactly. Both live and logged values read back via `axis()`.
1873
+ */
1405
1874
  axes = /* @__PURE__ */ new Map();
1406
- /** Engine: set which actions are down this step (from live input or replay). */
1407
- beginFrame(actionsDown) {
1875
+ /** The subset of axes that are part of the deterministic log + hash this frame. */
1876
+ logged = /* @__PURE__ */ new Map();
1877
+ /**
1878
+ * Action names some source has declared it can produce (null until the first
1879
+ * declareActions call). Headless worlds with no sources never declare, so
1880
+ * they never warn — the typo guard only arms once a host wires real input.
1881
+ */
1882
+ declared = null;
1883
+ /** Unknown names already warned about (warn ONCE per name). */
1884
+ warned = /* @__PURE__ */ new Set();
1885
+ /**
1886
+ * Register action names a source (or input map) can produce. The browser
1887
+ * driver declares the keyboard map's actions and the pointer's `mouse.*`
1888
+ * buttons; custom sources should declare theirs too. Once at least one
1889
+ * declaration has happened, querying an undeclared action name via
1890
+ * `isDown`/`justPressed`/`justReleased` logs a console.warn ONCE per name —
1891
+ * catching `isDown('jmup')` typos that would otherwise fail silently.
1892
+ */
1893
+ declareActions(names) {
1894
+ this.declared ??= /* @__PURE__ */ new Set();
1895
+ for (const n5 of names) this.declared.add(n5);
1896
+ }
1897
+ /** Warn-once guard for querying an action no source has declared. O(1). */
1898
+ checkDeclared(action) {
1899
+ if (this.declared === null || this.declared.has(action) || this.warned.has(action)) return;
1900
+ this.warned.add(action);
1901
+ console.warn(
1902
+ `hayao: input action "${action}" is not declared by any input source \u2014 declared actions: ${[...this.declared].sort().join(", ") || "(none)"}`
1903
+ );
1904
+ }
1905
+ /**
1906
+ * Engine: set which actions are down this step (from live input or replay).
1907
+ * `axes` (optional) are QUANTIZED analog values that become part of the log
1908
+ * and hash for this frame — use `snapAxis`/`quantizeAngle` at the host edge so
1909
+ * replay and lockstep netplay reproduce analog aim exactly.
1910
+ */
1911
+ beginFrame(actionsDown, axes) {
1408
1912
  this.prev = new Set(this.down);
1409
1913
  this.down = new Set(actionsDown);
1914
+ if (this.declared !== null) for (const a of this.down) this.declared.add(a);
1915
+ this.logged.clear();
1916
+ if (axes) {
1917
+ for (const k in axes) {
1918
+ this.logged.set(k, axes[k]);
1919
+ this.axes.set(k, axes[k]);
1920
+ }
1921
+ }
1410
1922
  }
1411
1923
  isDown(action) {
1924
+ this.checkDeclared(action);
1412
1925
  return this.down.has(action);
1413
1926
  }
1414
1927
  justPressed(action) {
1928
+ this.checkDeclared(action);
1415
1929
  return this.down.has(action) && !this.prev.has(action);
1416
1930
  }
1417
1931
  justReleased(action) {
1932
+ this.checkDeclared(action);
1418
1933
  return !this.down.has(action) && this.prev.has(action);
1419
1934
  }
1420
1935
  axis(name) {
@@ -1425,13 +1940,33 @@ var InputState = class {
1425
1940
  return [...this.down].sort();
1426
1941
  }
1427
1942
  getState() {
1428
- return { down: [...this.down].sort(), prev: [...this.prev].sort() };
1943
+ const s = {
1944
+ down: [...this.down].sort(),
1945
+ prev: [...this.prev].sort()
1946
+ };
1947
+ if (this.logged.size > 0) s.axes = [...this.logged].sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
1948
+ return s;
1429
1949
  }
1430
1950
  setState(s) {
1431
1951
  this.down = new Set(s.down);
1432
1952
  this.prev = new Set(s.prev);
1953
+ this.logged.clear();
1954
+ if (s.axes) for (const [k, v] of s.axes) {
1955
+ this.logged.set(k, v);
1956
+ this.axes.set(k, v);
1957
+ }
1433
1958
  }
1434
1959
  };
1960
+ function snapAxis(value, buckets, min = -1, max = 1) {
1961
+ if (buckets < 2 || max === min) return value;
1962
+ const t = Math.min(1, Math.max(0, (value - min) / (max - min)));
1963
+ const b = Math.round(t * (buckets - 1));
1964
+ return min + b / (buckets - 1) * (max - min);
1965
+ }
1966
+ function quantizeAngle(radians, buckets) {
1967
+ const step = Math.PI * 2 / buckets;
1968
+ return Math.round(radians / step) * step;
1969
+ }
1435
1970
  function keysToActions(map, keysDown) {
1436
1971
  const actions = [];
1437
1972
  for (const action in map) {
@@ -1441,19 +1976,32 @@ function keysToActions(map, keysDown) {
1441
1976
  }
1442
1977
  var InputRecorder = class {
1443
1978
  frames = [];
1444
- record(actionsDown) {
1979
+ axes = [];
1980
+ anyAxes = false;
1981
+ record(actionsDown, axes) {
1445
1982
  this.frames.push(actionsDown.slice().sort());
1983
+ if (axes && Object.keys(axes).length > 0) {
1984
+ this.axes.push({ ...axes });
1985
+ this.anyAxes = true;
1986
+ } else {
1987
+ this.axes.push(void 0);
1988
+ }
1446
1989
  }
1447
1990
  get length() {
1448
1991
  return this.frames.length;
1449
1992
  }
1450
1993
  toLog() {
1451
- return { frames: this.frames.map((f) => f.slice()) };
1994
+ const log = { frames: this.frames.map((f) => f.slice()) };
1995
+ if (this.anyAxes) log.axes = this.axes.map((a) => a ? { ...a } : void 0);
1996
+ return log;
1452
1997
  }
1453
1998
  };
1454
1999
  function frameActions(log, i) {
1455
2000
  return log.frames[i] ?? [];
1456
2001
  }
2002
+ function frameAxes(log, i) {
2003
+ return log.axes?.[i];
2004
+ }
1457
2005
  var DEFAULT_INPUT_MAP = {
1458
2006
  up: ["ArrowUp", "KeyW"],
1459
2007
  down: ["ArrowDown", "KeyS"],
@@ -1472,6 +2020,8 @@ var KeyboardSource = class {
1472
2020
  keysDown = /* @__PURE__ */ new Set();
1473
2021
  /** Virtual action taps (from DOM buttons etc.) pending consumption by a step. */
1474
2022
  pressed = /* @__PURE__ */ new Set();
2023
+ /** Sustained virtual actions (held on-screen controls) — persist until released. */
2024
+ held = /* @__PURE__ */ new Set();
1475
2025
  map;
1476
2026
  target;
1477
2027
  onDown;
@@ -1492,12 +2042,18 @@ var KeyboardSource = class {
1492
2042
  }
1493
2043
  /** The actions currently held down, as a stable sorted array. */
1494
2044
  currentActions() {
1495
- const acts = keysToActions(this.map, this.keysDown);
1496
- if (this.pressed.size === 0) return acts;
2045
+ const codes = this.held.size === 0 ? this.keysDown : /* @__PURE__ */ new Set([...this.keysDown, ...this.held]);
2046
+ const acts = keysToActions(this.map, codes);
2047
+ if (this.pressed.size === 0 && this.held.size === 0) return acts;
1497
2048
  const merged = new Set(acts);
1498
2049
  for (const a of this.pressed) merged.add(a);
2050
+ for (const a of this.held) merged.add(a);
1499
2051
  return [...merged].sort();
1500
2052
  }
2053
+ /** The action names this source's map can produce (for InputState.declareActions). */
2054
+ actionNames() {
2055
+ return Object.keys(this.map);
2056
+ }
1501
2057
  /**
1502
2058
  * Virtually tap an action (DOM button, touch control). The tap is held until
1503
2059
  * at least one fixed step has sampled it — the driver calls clearPressed()
@@ -1511,6 +2067,27 @@ var KeyboardSource = class {
1511
2067
  clearPressed() {
1512
2068
  this.pressed.clear();
1513
2069
  }
2070
+ /**
2071
+ * Sustain an action for as long as an on-screen control is held. Unlike
2072
+ * `press()` (an edge-shaped tap cleared after one step), a held action models
2073
+ * STATE: it stays in `currentActions()` every step until `setHeld(action,
2074
+ * false)` / `releaseHeld(action)`. This is what a virtual joystick or
2075
+ * hold-to-fire button wants — set it on drag-start, clear it on release, no
2076
+ * re-press-per-frame. Held actions still flow through the same deterministic
2077
+ * action set as keys.
2078
+ */
2079
+ setHeld(action, on = true) {
2080
+ if (on) this.held.add(action);
2081
+ else this.held.delete(action);
2082
+ }
2083
+ /** Release a sustained action (sugar for `setHeld(action, false)`). */
2084
+ releaseHeld(action) {
2085
+ this.held.delete(action);
2086
+ }
2087
+ /** Drop all sustained actions (e.g. on blur / control teardown). */
2088
+ clearHeld() {
2089
+ this.held.clear();
2090
+ }
1514
2091
  setMap(map) {
1515
2092
  this.map = map;
1516
2093
  }
@@ -1520,35 +2097,74 @@ var KeyboardSource = class {
1520
2097
  globalThis.removeEventListener?.("blur", this.onBlur);
1521
2098
  }
1522
2099
  };
2100
+ var MOUSE_ACTIONS = ["mouse.left", "mouse.right", "mouse.middle"];
1523
2101
  var PointerSource = class {
1524
2102
  clientX = 0;
1525
2103
  clientY = 0;
1526
2104
  isDown = false;
2105
+ rightDown = false;
2106
+ middleDown = false;
1527
2107
  seen = false;
2108
+ /** Live touches by pointerId (a pressed finger/button); excludes hover-only mouse. */
2109
+ touches = /* @__PURE__ */ new Map();
1528
2110
  target;
1529
2111
  el;
2112
+ keyboard;
1530
2113
  onMove;
1531
2114
  onDown;
1532
2115
  onUp;
1533
- constructor(target) {
2116
+ onCtx;
2117
+ constructor(target, opts = {}) {
1534
2118
  this.target = target;
1535
2119
  this.el = target.element;
2120
+ this.keyboard = opts.keyboard;
2121
+ const readButtons = (e) => {
2122
+ if (typeof e.buttons !== "number") return false;
2123
+ this.isDown = (e.buttons & 1) !== 0;
2124
+ this.rightDown = (e.buttons & 2) !== 0;
2125
+ this.middleDown = (e.buttons & 4) !== 0;
2126
+ return true;
2127
+ };
1536
2128
  this.onMove = (e) => {
1537
2129
  this.clientX = e.clientX;
1538
2130
  this.clientY = e.clientY;
1539
2131
  this.seen = true;
2132
+ if (typeof e.buttons === "number" && e.buttons !== 0) readButtons(e);
2133
+ const t = this.touches.get(e.pointerId);
2134
+ if (t) {
2135
+ t.clientX = e.clientX;
2136
+ t.clientY = e.clientY;
2137
+ }
1540
2138
  };
1541
2139
  this.onDown = (e) => {
1542
2140
  this.clientX = e.clientX;
1543
2141
  this.clientY = e.clientY;
1544
- this.isDown = true;
1545
2142
  this.seen = true;
2143
+ if (!readButtons(e)) {
2144
+ if (e.button === 2) this.rightDown = true;
2145
+ else if (e.button === 1) this.middleDown = true;
2146
+ else this.isDown = true;
2147
+ }
2148
+ this.touches.set(e.pointerId, { clientX: e.clientX, clientY: e.clientY });
1546
2149
  };
1547
- this.onUp = () => {
1548
- this.isDown = false;
2150
+ this.onUp = (e) => {
2151
+ if (!readButtons(e)) {
2152
+ this.isDown = false;
2153
+ if (e.button === 2) this.rightDown = false;
2154
+ else if (e.button === 1) this.middleDown = false;
2155
+ else {
2156
+ this.rightDown = false;
2157
+ this.middleDown = false;
2158
+ }
2159
+ }
2160
+ this.touches.delete(e?.pointerId);
1549
2161
  };
2162
+ this.onCtx = (e) => e.preventDefault();
1550
2163
  this.el?.addEventListener("pointermove", this.onMove);
1551
2164
  this.el?.addEventListener("pointerdown", this.onDown);
2165
+ this.el?.addEventListener("pointerup", this.onUp);
2166
+ this.el?.addEventListener("pointercancel", this.onUp);
2167
+ if (!opts.contextMenu) this.el?.addEventListener("contextmenu", this.onCtx);
1552
2168
  globalThis.addEventListener?.("pointerup", this.onUp);
1553
2169
  globalThis.addEventListener?.("pointercancel", this.onUp);
1554
2170
  }
@@ -1557,9 +2173,28 @@ var PointerSource = class {
1557
2173
  const p = this.target.toDesign?.(this.clientX, this.clientY) ?? { x: 0, y: 0 };
1558
2174
  return { x: p.x, y: p.y, down: this.isDown, active: this.seen };
1559
2175
  }
2176
+ /**
2177
+ * Every live (pressed) touch in DESIGN space, ordered by ascending id so the
2178
+ * array is stable to iterate. Empty when nothing is pressed. This is the
2179
+ * multitouch channel — a dual-stick game reads `left = touches[0]`, etc., or
2180
+ * partitions by screen half.
2181
+ */
2182
+ readAll() {
2183
+ const ids = [...this.touches.keys()].sort((a, b) => a - b);
2184
+ return ids.map((id) => {
2185
+ const t = this.touches.get(id);
2186
+ const p = this.target.toDesign?.(t.clientX, t.clientY) ?? { x: 0, y: 0 };
2187
+ return { x: p.x, y: p.y, down: true, active: true, id };
2188
+ });
2189
+ }
1560
2190
  /**
1561
2191
  * Write the current sample into an InputState's axes as pointer.x / pointer.y /
1562
- * pointer.down. Call once per step (the browser driver does this before advance).
2192
+ * pointer.down, plus pointer.right / pointer.middle (0/1) for the secondary
2193
+ * buttons. Call once per step (the browser driver does this before advance).
2194
+ *
2195
+ * When a keyboard is wired (PointerSourceOptions.keyboard), buttons are ALSO
2196
+ * held as actions `mouse.left` / `mouse.right` / `mouse.middle` so they flow
2197
+ * through the same deterministic actionsDown log as keys.
1563
2198
  */
1564
2199
  sample(input) {
1565
2200
  const r = this.read();
@@ -1567,15 +2202,110 @@ var PointerSource = class {
1567
2202
  input.axes.set("pointer.x", q(r.x));
1568
2203
  input.axes.set("pointer.y", q(r.y));
1569
2204
  input.axes.set("pointer.down", r.down ? 1 : 0);
2205
+ input.axes.set("pointer.right", this.rightDown ? 1 : 0);
2206
+ input.axes.set("pointer.middle", this.middleDown ? 1 : 0);
2207
+ if (this.keyboard) {
2208
+ input.declareActions(MOUSE_ACTIONS);
2209
+ this.keyboard.setHeld("mouse.left", this.isDown);
2210
+ this.keyboard.setHeld("mouse.right", this.rightDown);
2211
+ this.keyboard.setHeld("mouse.middle", this.middleDown);
2212
+ }
1570
2213
  }
1571
2214
  dispose() {
2215
+ if (this.keyboard) for (const a of MOUSE_ACTIONS) this.keyboard.releaseHeld(a);
1572
2216
  this.el?.removeEventListener("pointermove", this.onMove);
1573
2217
  this.el?.removeEventListener("pointerdown", this.onDown);
2218
+ this.el?.removeEventListener("pointerup", this.onUp);
2219
+ this.el?.removeEventListener("pointercancel", this.onUp);
2220
+ this.el?.removeEventListener("contextmenu", this.onCtx);
1574
2221
  globalThis.removeEventListener?.("pointerup", this.onUp);
1575
2222
  globalThis.removeEventListener?.("pointercancel", this.onUp);
1576
2223
  }
1577
2224
  };
1578
2225
 
2226
+ // src/input/gamepad.ts
2227
+ var GamepadSource = class {
2228
+ index;
2229
+ stickBuckets;
2230
+ triggerBuckets;
2231
+ deadzone;
2232
+ buttonMap;
2233
+ keyboard;
2234
+ /** Track which buttons were pressed last frame to detect edge transitions. */
2235
+ prevPressed = /* @__PURE__ */ new Map();
2236
+ constructor(opts = {}) {
2237
+ this.index = opts.index ?? 0;
2238
+ this.stickBuckets = opts.stickBuckets ?? 128;
2239
+ this.triggerBuckets = opts.triggerBuckets ?? 64;
2240
+ this.deadzone = opts.deadzone ?? 0.12;
2241
+ this.buttonMap = opts.buttonMap ?? DEFAULT_GAMEPAD_MAP;
2242
+ this.keyboard = opts.keyboard;
2243
+ }
2244
+ /**
2245
+ * Write the current gamepad state into InputState axes (quantized). Call once
2246
+ * per fixed step, before world.advance() — the browser driver does this via
2247
+ * the `sources` option in RunOptions. See also: runBrowser, GameHandle.
2248
+ */
2249
+ sample(input) {
2250
+ const pad2 = navigator.getGamepads?.()[this.index] ?? null;
2251
+ if (!pad2) return;
2252
+ const applyDeadzone = (x, y) => {
2253
+ const mag = dhypot(x, y);
2254
+ if (mag < this.deadzone) return { x: 0, y: 0 };
2255
+ const scale = (mag - this.deadzone) / (1 - this.deadzone);
2256
+ const clampedMag = Math.min(scale, 1);
2257
+ return { x: x / mag * clampedMag, y: y / mag * clampedMag };
2258
+ };
2259
+ const ls = applyDeadzone(pad2.axes[0] ?? 0, pad2.axes[1] ?? 0);
2260
+ const rs = applyDeadzone(pad2.axes[2] ?? 0, pad2.axes[3] ?? 0);
2261
+ const qs = (v) => snapAxis(v, this.stickBuckets, -1, 1);
2262
+ const qt = (v) => snapAxis(v, this.triggerBuckets, 0, 1);
2263
+ input.axes.set("gamepad.lx", qs(ls.x));
2264
+ input.axes.set("gamepad.ly", qs(ls.y));
2265
+ input.axes.set("gamepad.rx", qs(rs.x));
2266
+ input.axes.set("gamepad.ry", qs(rs.y));
2267
+ input.axes.set("gamepad.lt", qt(pad2.buttons[6]?.value ?? 0));
2268
+ input.axes.set("gamepad.rt", qt(pad2.buttons[7]?.value ?? 0));
2269
+ const du = pad2.buttons[12]?.pressed ? 1 : 0;
2270
+ const dd = pad2.buttons[13]?.pressed ? 1 : 0;
2271
+ const dl = pad2.buttons[14]?.pressed ? 1 : 0;
2272
+ const dr = pad2.buttons[15]?.pressed ? 1 : 0;
2273
+ input.axes.set("gamepad.dpad.x", dr - dl);
2274
+ input.axes.set("gamepad.dpad.y", dd - du);
2275
+ if (this.keyboard) {
2276
+ for (const [btnStr, action] of Object.entries(this.buttonMap)) {
2277
+ const idx = Number(btnStr);
2278
+ const pressed = pad2.buttons[idx]?.pressed ?? false;
2279
+ this.keyboard.setHeld(action, pressed);
2280
+ this.prevPressed.set(idx, pressed);
2281
+ }
2282
+ }
2283
+ }
2284
+ /**
2285
+ * Release all held actions and clean up. Call when removing the source or
2286
+ * switching gamepads.
2287
+ */
2288
+ dispose() {
2289
+ if (this.keyboard) {
2290
+ for (const action of Object.values(this.buttonMap)) {
2291
+ this.keyboard.releaseHeld(action);
2292
+ }
2293
+ }
2294
+ this.prevPressed.clear();
2295
+ }
2296
+ };
2297
+ var DEFAULT_GAMEPAD_MAP = {
2298
+ 0: "confirm",
2299
+ 1: "cancel",
2300
+ 2: "action2",
2301
+ 3: "action",
2302
+ 9: "confirm",
2303
+ 12: "up",
2304
+ 13: "down",
2305
+ 14: "left",
2306
+ 15: "right"
2307
+ };
2308
+
1579
2309
  // src/physics/tilemap.ts
1580
2310
  var TILE = {
1581
2311
  EMPTY: 0,
@@ -2903,7 +3633,7 @@ function rayCastRigid(rw, x0, y0, x1, y1, mask = 65535) {
2903
3633
 
2904
3634
  // src/render/commands.ts
2905
3635
  function sortCommands(cmds) {
2906
- return cmds.map((c, i) => [c, i]).sort((a, b) => a[0].z - b[0].z || a[1] - b[1]).map(([c]) => c);
3636
+ return cmds.map((c, i) => [c, i]).sort((a, b) => (a[0].layer ?? 0) - (b[0].layer ?? 0) || a[0].z - b[0].z || a[1] - b[1]).map(([c]) => c);
2907
3637
  }
2908
3638
 
2909
3639
  // src/render/paint.ts
@@ -2946,6 +3676,10 @@ function shapeBBox(c) {
2946
3676
  return { x: c.x, y: c.y, w: c.w, h: c.h };
2947
3677
  case "circle":
2948
3678
  return { x: c.cx - c.radius, y: c.cy - c.radius, w: c.radius * 2, h: c.radius * 2 };
3679
+ case "ellipse":
3680
+ return { x: c.cx - c.rx, y: c.cy - c.ry, w: c.rx * 2, h: c.ry * 2 };
3681
+ case "arc":
3682
+ return { x: c.cx - c.radius, y: c.cy - c.radius, w: c.radius * 2, h: c.radius * 2 };
2949
3683
  case "poly": {
2950
3684
  if (c.points.length < 2) return null;
2951
3685
  let minX = c.points[0];
@@ -2964,6 +3698,39 @@ function shapeBBox(c) {
2964
3698
  return null;
2965
3699
  }
2966
3700
  }
3701
+ function invalidCommandReason(c) {
3702
+ switch (c.kind) {
3703
+ case "rect":
3704
+ if (!Number.isFinite(c.x) || !Number.isFinite(c.y) || !Number.isFinite(c.w) || !Number.isFinite(c.h))
3705
+ return "non-finite x/y/w/h";
3706
+ return null;
3707
+ case "circle":
3708
+ if (!Number.isFinite(c.cx) || !Number.isFinite(c.cy)) return "non-finite center";
3709
+ if (!Number.isFinite(c.radius)) return "non-finite radius";
3710
+ if (c.radius < 0) return "negative radius";
3711
+ return null;
3712
+ case "ellipse":
3713
+ if (!Number.isFinite(c.cx) || !Number.isFinite(c.cy)) return "non-finite center";
3714
+ if (!Number.isFinite(c.rx) || !Number.isFinite(c.ry)) return "non-finite rx/ry";
3715
+ if (c.rx < 0 || c.ry < 0) return "negative rx/ry";
3716
+ return null;
3717
+ case "arc":
3718
+ if (!Number.isFinite(c.cx) || !Number.isFinite(c.cy)) return "non-finite center";
3719
+ if (!Number.isFinite(c.radius)) return "non-finite radius";
3720
+ if (c.radius < 0) return "negative radius";
3721
+ if (!Number.isFinite(c.start) || !Number.isFinite(c.end)) return "non-finite start/end";
3722
+ return null;
3723
+ default:
3724
+ return null;
3725
+ }
3726
+ }
3727
+ var warnedCommands = /* @__PURE__ */ new Set();
3728
+ function warnCommandOnce(kind, reason, detail) {
3729
+ const key = `${kind}:${reason}`;
3730
+ if (warnedCommands.has(key)) return;
3731
+ warnedCommands.add(key);
3732
+ console.warn(`hayao/render: skipped '${kind}' command \u2014 ${reason}`, detail);
3733
+ }
2967
3734
  function canvasGradient(ctx, g, bbox) {
2968
3735
  const px = (u) => bbox.x + u * bbox.w;
2969
3736
  const py = (v) => bbox.y + v * bbox.h;
@@ -2988,6 +3755,16 @@ function clientToDesign(rect, width, height, clientX, clientY) {
2988
3755
  y: (clientY - rect.top - offsetY) / scale
2989
3756
  };
2990
3757
  }
3758
+ function fitViewport(rect, width, height) {
3759
+ const scale = Math.min(rect.width / width, rect.height / height) || 1;
3760
+ return {
3761
+ x: (rect.width - width * scale) / 2,
3762
+ y: (rect.height - height * scale) / 2,
3763
+ width: width * scale,
3764
+ height: height * scale,
3765
+ scale
3766
+ };
3767
+ }
2991
3768
 
2992
3769
  // src/render/svgString.ts
2993
3770
  var n2 = (v) => Number.isInteger(v) ? String(v) : (Math.round(v * 1e3) / 1e3).toString();
@@ -3001,6 +3778,7 @@ function paintAttrs(p, fillOverride, filterId) {
3001
3778
  parts.push(`stroke="${p.stroke}"`);
3002
3779
  parts.push(`stroke-width="${n2(p.strokeWidth ?? 1)}"`);
3003
3780
  if (p.round) parts.push('stroke-linejoin="round" stroke-linecap="round"');
3781
+ if (p.lineDash && p.lineDash.length) parts.push(`stroke-dasharray="${p.lineDash.map(n2).join(" ")}"`);
3004
3782
  }
3005
3783
  if (p.opacity !== void 0 && p.opacity !== 1) parts.push(`opacity="${n2(p.opacity)}"`);
3006
3784
  if (filterId) parts.push(`filter="url(#${filterId})"`);
@@ -3009,6 +3787,24 @@ function paintAttrs(p, fillOverride, filterId) {
3009
3787
  function escapeText(s) {
3010
3788
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
3011
3789
  }
3790
+ function arcPathData(c) {
3791
+ const r = c.radius;
3792
+ const x0 = c.cx + dcos(c.start) * r;
3793
+ const y0 = c.cy + dsin(c.start) * r;
3794
+ const raw = c.end - c.start;
3795
+ let sweep = (raw % TAU + TAU) % TAU;
3796
+ if (sweep === 0 && raw !== 0) sweep = TAU;
3797
+ if (sweep >= TAU - 1e-9) {
3798
+ const xh = c.cx + dcos(c.start + Math.PI) * r;
3799
+ const yh = c.cy + dsin(c.start + Math.PI) * r;
3800
+ return `M ${n2(x0)} ${n2(y0)} A ${n2(r)} ${n2(r)} 0 1 1 ${n2(xh)} ${n2(yh)} A ${n2(r)} ${n2(r)} 0 1 1 ${n2(x0)} ${n2(y0)} Z`;
3801
+ }
3802
+ const x1 = c.cx + dcos(c.start + sweep) * r;
3803
+ const y1 = c.cy + dsin(c.start + sweep) * r;
3804
+ const large = sweep > Math.PI ? 1 : 0;
3805
+ const a = `A ${n2(r)} ${n2(r)} 0 ${large} 1 ${n2(x1)} ${n2(y1)}`;
3806
+ return c.sector ? `M ${n2(c.cx)} ${n2(c.cy)} L ${n2(x0)} ${n2(y0)} ${a} Z` : `M ${n2(x0)} ${n2(y0)} ${a}`;
3807
+ }
3012
3808
  function commandToSVG(c, defs, idBase) {
3013
3809
  const tf = `transform="${matrix(c.transform)}"`;
3014
3810
  let fillOverride;
@@ -3028,6 +3824,10 @@ function commandToSVG(c, defs, idBase) {
3028
3824
  return `<rect x="${n2(c.x)}" y="${n2(c.y)}" width="${n2(c.w)}" height="${n2(c.h)}"${c.r ? ` rx="${n2(c.r)}"` : ""} ${tf} ${paint}/>`;
3029
3825
  case "circle":
3030
3826
  return `<circle cx="${n2(c.cx)}" cy="${n2(c.cy)}" r="${n2(c.radius)}" ${tf} ${paint}/>`;
3827
+ case "ellipse":
3828
+ return `<ellipse cx="${n2(c.cx)}" cy="${n2(c.cy)}" rx="${n2(c.rx)}" ry="${n2(c.ry)}" ${tf} ${paint}/>`;
3829
+ case "arc":
3830
+ return `<path d="${arcPathData(c)}" ${tf} ${paint}/>`;
3031
3831
  case "poly": {
3032
3832
  const pts = [];
3033
3833
  for (let i = 0; i < c.points.length; i += 2) pts.push(`${n2(c.points[i])},${n2(c.points[i + 1])}`);
@@ -3054,7 +3854,14 @@ function commandToSVG(c, defs, idBase) {
3054
3854
  }
3055
3855
  function commandsToSVGInner(commands, idPrefix = "h") {
3056
3856
  const defs = [];
3057
- const body = sortCommands(commands).map((c, i) => commandToSVG(c, defs, `${idPrefix}${i}`)).join("");
3857
+ const body = sortCommands(commands).map((c, i) => {
3858
+ const bad = invalidCommandReason(c);
3859
+ if (bad) {
3860
+ warnCommandOnce(c.kind, bad, c);
3861
+ return "";
3862
+ }
3863
+ return commandToSVG(c, defs, `${idPrefix}${i}`);
3864
+ }).join("");
3058
3865
  return (defs.length ? `<defs>${defs.join("")}</defs>` : "") + body;
3059
3866
  }
3060
3867
  function renderToSVGString(commands, width, height, background = "#ffffff") {
@@ -3107,11 +3914,152 @@ var SvgRenderer = class {
3107
3914
  toDesign(clientX, clientY) {
3108
3915
  return clientToDesign(this.svg.getBoundingClientRect(), this.width, this.height, clientX, clientY);
3109
3916
  }
3917
+ viewport() {
3918
+ return fitViewport(this.svg.getBoundingClientRect(), this.width, this.height);
3919
+ }
3110
3920
  dispose() {
3111
3921
  this.svg.remove();
3112
3922
  }
3113
3923
  };
3114
3924
 
3925
+ // src/render/canvas2d-core.ts
3926
+ function fillFor(ctx, c) {
3927
+ const p = c;
3928
+ if (p.gradient) {
3929
+ const bbox = shapeBBox(c);
3930
+ if (bbox && bbox.w > 0 && bbox.h > 0) return canvasGradient(ctx, p.gradient, bbox);
3931
+ const stops = p.gradient.stops;
3932
+ return stops.length ? stops[stops.length - 1].color : p.fill;
3933
+ }
3934
+ return p.fill;
3935
+ }
3936
+ function applyStroke(ctx, c) {
3937
+ const fill = fillFor(ctx, c);
3938
+ if (fill && fill !== "none") {
3939
+ ctx.fillStyle = fill;
3940
+ ctx.fill();
3941
+ }
3942
+ if (c.stroke) {
3943
+ ctx.strokeStyle = c.stroke;
3944
+ ctx.lineWidth = c.strokeWidth ?? 1;
3945
+ if (c.round) {
3946
+ ctx.lineJoin = "round";
3947
+ ctx.lineCap = "round";
3948
+ }
3949
+ ctx.stroke();
3950
+ }
3951
+ }
3952
+ function roundRectPath(ctx, x, y, w, h, r) {
3953
+ const rr = Math.min(r, w / 2, h / 2);
3954
+ ctx.moveTo(x + rr, y);
3955
+ ctx.arcTo(x + w, y, x + w, y + h, rr);
3956
+ ctx.arcTo(x + w, y + h, x, y + h, rr);
3957
+ ctx.arcTo(x, y + h, x, y, rr);
3958
+ ctx.arcTo(x, y, x + w, y, rr);
3959
+ ctx.closePath();
3960
+ }
3961
+ function paintCommand(ctx, c) {
3962
+ switch (c.kind) {
3963
+ case "rect":
3964
+ ctx.beginPath();
3965
+ if (c.r) roundRectPath(ctx, c.x, c.y, c.w, c.h, c.r);
3966
+ else ctx.rect(c.x, c.y, c.w, c.h);
3967
+ applyStroke(ctx, c);
3968
+ break;
3969
+ case "circle":
3970
+ ctx.beginPath();
3971
+ ctx.arc(c.cx, c.cy, c.radius, 0, Math.PI * 2);
3972
+ applyStroke(ctx, c);
3973
+ break;
3974
+ case "ellipse":
3975
+ ctx.beginPath();
3976
+ ctx.ellipse(c.cx, c.cy, c.rx, c.ry, 0, 0, Math.PI * 2);
3977
+ applyStroke(ctx, c);
3978
+ break;
3979
+ case "arc":
3980
+ ctx.beginPath();
3981
+ if (c.sector) ctx.moveTo(c.cx, c.cy);
3982
+ ctx.arc(c.cx, c.cy, c.radius, c.start, c.end);
3983
+ if (c.sector) ctx.closePath();
3984
+ applyStroke(ctx, c);
3985
+ break;
3986
+ case "poly":
3987
+ ctx.beginPath();
3988
+ for (let i = 0; i < c.points.length; i += 2) {
3989
+ if (i === 0) ctx.moveTo(c.points[i], c.points[i + 1]);
3990
+ else ctx.lineTo(c.points[i], c.points[i + 1]);
3991
+ }
3992
+ if (c.closed) ctx.closePath();
3993
+ applyStroke(ctx, c);
3994
+ break;
3995
+ case "path": {
3996
+ const p = new Path2D(c.d);
3997
+ const fill = fillFor(ctx, c);
3998
+ if (fill && fill !== "none") {
3999
+ ctx.fillStyle = fill;
4000
+ ctx.fill(p);
4001
+ }
4002
+ if (c.stroke) {
4003
+ ctx.strokeStyle = c.stroke;
4004
+ ctx.lineWidth = c.strokeWidth ?? 1;
4005
+ if (c.round) {
4006
+ ctx.lineJoin = "round";
4007
+ ctx.lineCap = "round";
4008
+ }
4009
+ ctx.stroke(p);
4010
+ }
4011
+ break;
4012
+ }
4013
+ case "text":
4014
+ ctx.font = `${c.weight ?? 400} ${c.size}px ${c.font ?? "sans-serif"}`;
4015
+ ctx.textAlign = c.align ?? "left";
4016
+ ctx.textBaseline = "middle";
4017
+ if (c.stroke) {
4018
+ ctx.strokeStyle = c.stroke;
4019
+ ctx.lineWidth = c.strokeWidth ?? 1;
4020
+ ctx.lineJoin = "round";
4021
+ ctx.lineCap = "round";
4022
+ ctx.strokeText(c.text, c.x, c.y);
4023
+ }
4024
+ ctx.fillStyle = c.fill ?? "#000";
4025
+ ctx.fillText(c.text, c.x, c.y);
4026
+ break;
4027
+ case "image":
4028
+ break;
4029
+ }
4030
+ }
4031
+ function drawToCanvas2D(ctx, commands, width, height, background, scale) {
4032
+ ctx.setTransform(scale, 0, 0, scale, 0, 0);
4033
+ ctx.fillStyle = background;
4034
+ ctx.fillRect(0, 0, width, height);
4035
+ for (const c of sortCommands(commands)) {
4036
+ const bad = invalidCommandReason(c);
4037
+ if (bad) {
4038
+ warnCommandOnce(c.kind, bad, c);
4039
+ continue;
4040
+ }
4041
+ ctx.save();
4042
+ try {
4043
+ const t = c.transform;
4044
+ ctx.transform(t.a, t.b, t.c, t.d, t.e, t.f);
4045
+ ctx.globalAlpha = c.opacity ?? 1;
4046
+ const sh = c.shadow;
4047
+ if (sh) {
4048
+ ctx.shadowColor = sh.color;
4049
+ ctx.shadowBlur = sh.blur;
4050
+ ctx.shadowOffsetX = sh.dx ?? 0;
4051
+ ctx.shadowOffsetY = sh.dy ?? 0;
4052
+ }
4053
+ const dash = c.lineDash;
4054
+ if (dash && dash.length) ctx.setLineDash(dash);
4055
+ paintCommand(ctx, c);
4056
+ } catch (err) {
4057
+ warnCommandOnce(c.kind, "paint threw", err);
4058
+ }
4059
+ ctx.restore();
4060
+ }
4061
+ }
4062
+
3115
4063
  // src/render/canvas.ts
3116
4064
  var Canvas2DRenderer = class {
3117
4065
  width;
@@ -3144,120 +4092,7 @@ var Canvas2DRenderer = class {
3144
4092
  this.canvas.height = Math.round(this.height * this.dpr);
3145
4093
  }
3146
4094
  draw(commands) {
3147
- const ctx = this.ctx;
3148
- ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
3149
- ctx.fillStyle = this.background;
3150
- ctx.fillRect(0, 0, this.width, this.height);
3151
- for (const c of sortCommands(commands)) {
3152
- ctx.save();
3153
- const t = c.transform;
3154
- ctx.transform(t.a, t.b, t.c, t.d, t.e, t.f);
3155
- ctx.globalAlpha = c.opacity ?? 1;
3156
- const sh = c.shadow;
3157
- if (sh) {
3158
- ctx.shadowColor = sh.color;
3159
- ctx.shadowBlur = sh.blur;
3160
- ctx.shadowOffsetX = sh.dx ?? 0;
3161
- ctx.shadowOffsetY = sh.dy ?? 0;
3162
- }
3163
- this.paint(ctx, c);
3164
- ctx.restore();
3165
- }
3166
- }
3167
- /** Resolve a fill: a gradient (mapped to the shape's bbox) or a solid color. */
3168
- fillFor(ctx, c) {
3169
- const p = c;
3170
- if (p.gradient) {
3171
- const bbox = shapeBBox(c);
3172
- if (bbox && bbox.w > 0 && bbox.h > 0) return canvasGradient(ctx, p.gradient, bbox);
3173
- const stops = p.gradient.stops;
3174
- return stops.length ? stops[stops.length - 1].color : p.fill;
3175
- }
3176
- return p.fill;
3177
- }
3178
- stroke(ctx, c) {
3179
- const fill = this.fillFor(ctx, c);
3180
- if (fill && fill !== "none") {
3181
- ctx.fillStyle = fill;
3182
- ctx.fill();
3183
- }
3184
- if (c.stroke) {
3185
- ctx.strokeStyle = c.stroke;
3186
- ctx.lineWidth = c.strokeWidth ?? 1;
3187
- if (c.round) {
3188
- ctx.lineJoin = "round";
3189
- ctx.lineCap = "round";
3190
- }
3191
- ctx.stroke();
3192
- }
3193
- }
3194
- paint(ctx, c) {
3195
- switch (c.kind) {
3196
- case "rect":
3197
- ctx.beginPath();
3198
- if (c.r) this.roundRect(ctx, c.x, c.y, c.w, c.h, c.r);
3199
- else ctx.rect(c.x, c.y, c.w, c.h);
3200
- this.stroke(ctx, c);
3201
- break;
3202
- case "circle":
3203
- ctx.beginPath();
3204
- ctx.arc(c.cx, c.cy, c.radius, 0, Math.PI * 2);
3205
- this.stroke(ctx, c);
3206
- break;
3207
- case "poly":
3208
- ctx.beginPath();
3209
- for (let i = 0; i < c.points.length; i += 2) {
3210
- if (i === 0) ctx.moveTo(c.points[i], c.points[i + 1]);
3211
- else ctx.lineTo(c.points[i], c.points[i + 1]);
3212
- }
3213
- if (c.closed) ctx.closePath();
3214
- this.stroke(ctx, c);
3215
- break;
3216
- case "path":
3217
- {
3218
- const p = new Path2D(c.d);
3219
- const fill = this.fillFor(ctx, c);
3220
- if (fill && fill !== "none") {
3221
- ctx.fillStyle = fill;
3222
- ctx.fill(p);
3223
- }
3224
- if (c.stroke) {
3225
- ctx.strokeStyle = c.stroke;
3226
- ctx.lineWidth = c.strokeWidth ?? 1;
3227
- if (c.round) {
3228
- ctx.lineJoin = "round";
3229
- ctx.lineCap = "round";
3230
- }
3231
- ctx.stroke(p);
3232
- }
3233
- }
3234
- break;
3235
- case "text":
3236
- ctx.font = `${c.weight ?? 400} ${c.size}px ${c.font ?? "sans-serif"}`;
3237
- ctx.textAlign = c.align ?? "left";
3238
- ctx.textBaseline = "middle";
3239
- if (c.stroke) {
3240
- ctx.strokeStyle = c.stroke;
3241
- ctx.lineWidth = c.strokeWidth ?? 1;
3242
- ctx.lineJoin = "round";
3243
- ctx.lineCap = "round";
3244
- ctx.strokeText(c.text, c.x, c.y);
3245
- }
3246
- ctx.fillStyle = c.fill ?? "#000";
3247
- ctx.fillText(c.text, c.x, c.y);
3248
- break;
3249
- case "image":
3250
- break;
3251
- }
3252
- }
3253
- roundRect(ctx, x, y, w, h, r) {
3254
- const rr = Math.min(r, w / 2, h / 2);
3255
- ctx.moveTo(x + rr, y);
3256
- ctx.arcTo(x + w, y, x + w, y + h, rr);
3257
- ctx.arcTo(x + w, y + h, x, y + h, rr);
3258
- ctx.arcTo(x, y + h, x, y, rr);
3259
- ctx.arcTo(x, y, x + w, y, rr);
3260
- ctx.closePath();
4095
+ drawToCanvas2D(this.ctx, commands, this.width, this.height, this.background, this.dpr);
3261
4096
  }
3262
4097
  get element() {
3263
4098
  return this.canvas;
@@ -3266,11 +4101,517 @@ var Canvas2DRenderer = class {
3266
4101
  toDesign(clientX, clientY) {
3267
4102
  return clientToDesign(this.canvas.getBoundingClientRect(), this.width, this.height, clientX, clientY);
3268
4103
  }
4104
+ viewport() {
4105
+ return fitViewport(this.canvas.getBoundingClientRect(), this.width, this.height);
4106
+ }
3269
4107
  dispose() {
3270
4108
  this.canvas.remove();
3271
4109
  }
3272
4110
  };
3273
4111
 
4112
+ // src/render/webgl.ts
4113
+ var VERT_SRC = (
4114
+ /* glsl */
4115
+ `#version 300 es
4116
+ out vec2 v_uv;
4117
+ void main() {
4118
+ const vec2 CORNERS[4] = vec2[](
4119
+ vec2(-1.0, -1.0), vec2(1.0, -1.0),
4120
+ vec2(-1.0, 1.0), vec2(1.0, 1.0)
4121
+ );
4122
+ const vec2 UVS[4] = vec2[](
4123
+ vec2(0.0, 1.0), vec2(1.0, 1.0),
4124
+ vec2(0.0, 0.0), vec2(1.0, 0.0)
4125
+ );
4126
+ v_uv = UVS[gl_VertexID];
4127
+ gl_Position = vec4(CORNERS[gl_VertexID], 0.0, 1.0);
4128
+ }`
4129
+ );
4130
+ var PASSTHROUGH_FRAG = (
4131
+ /* glsl */
4132
+ `#version 300 es
4133
+ precision mediump float;
4134
+ in vec2 v_uv;
4135
+ uniform sampler2D u_scene;
4136
+ uniform sampler2D u_prev;
4137
+ out vec4 fragColor;
4138
+ void main() { fragColor = texture(u_prev, v_uv); }`
4139
+ );
4140
+ var WEBGL_EFFECTS = {
4141
+ /** Passthrough — no effect. */
4142
+ passthrough: PASSTHROUGH_FRAG,
4143
+ /** Pixelate: blockSize in pixels (default 4). setUniform('u_block', N) */
4144
+ pixelate: (
4145
+ /* glsl */
4146
+ `#version 300 es
4147
+ precision mediump float;
4148
+ in vec2 v_uv;
4149
+ uniform sampler2D u_prev;
4150
+ uniform vec2 u_resolution;
4151
+ uniform float u_block;
4152
+ out vec4 fragColor;
4153
+ void main() {
4154
+ float block = max(1.0, u_block > 0.0 ? u_block : 4.0);
4155
+ vec2 px = floor(v_uv * u_resolution / block) * block / u_resolution;
4156
+ fragColor = texture(u_prev, px);
4157
+ }`
4158
+ ),
4159
+ /** Vignette: darkens edges. setUniform('u_vignette', 0.0..1.0, default 0.4) */
4160
+ vignette: (
4161
+ /* glsl */
4162
+ `#version 300 es
4163
+ precision mediump float;
4164
+ in vec2 v_uv;
4165
+ uniform sampler2D u_prev;
4166
+ uniform float u_vignette;
4167
+ out vec4 fragColor;
4168
+ void main() {
4169
+ vec4 col = texture(u_prev, v_uv);
4170
+ vec2 uv = v_uv - 0.5;
4171
+ float d = dot(uv, uv);
4172
+ float strength = u_vignette > 0.0 ? u_vignette : 0.4;
4173
+ fragColor = col * (1.0 - d * strength * 4.0);
4174
+ }`
4175
+ ),
4176
+ /** CRT scanlines + slight barrel distortion. u_time drives the scan roll. */
4177
+ crt: (
4178
+ /* glsl */
4179
+ `#version 300 es
4180
+ precision mediump float;
4181
+ in vec2 v_uv;
4182
+ uniform sampler2D u_prev;
4183
+ uniform vec2 u_resolution;
4184
+ uniform float u_time;
4185
+ uniform float u_crt_lines; // scanline density, default 600
4186
+ uniform float u_crt_warp; // barrel strength, default 0.08
4187
+ out vec4 fragColor;
4188
+ void main() {
4189
+ float lines = u_crt_lines > 0.0 ? u_crt_lines : 600.0;
4190
+ float warp = u_crt_warp > 0.0 ? u_crt_warp : 0.08;
4191
+ // barrel distortion
4192
+ vec2 uv = v_uv - 0.5;
4193
+ float r2 = dot(uv, uv);
4194
+ uv *= 1.0 + warp * r2;
4195
+ uv += 0.5;
4196
+ if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {
4197
+ fragColor = vec4(0.0, 0.0, 0.0, 1.0);
4198
+ return;
4199
+ }
4200
+ vec4 col = texture(u_prev, uv);
4201
+ // rolling scanlines
4202
+ float scan = sin((uv.y * lines + u_time * 40.0) * 3.14159) * 0.5 + 0.5;
4203
+ col.rgb *= 0.75 + 0.25 * scan;
4204
+ // slight green phosphor tint
4205
+ col.rgb = mix(col.rgb, col.rgb * vec3(0.9, 1.05, 0.85), 0.3);
4206
+ fragColor = col;
4207
+ }`
4208
+ ),
4209
+ /** Chromatic aberration / RGB fringe. setUniform('u_aberration', 0..0.01, default 0.004) */
4210
+ chromaticAberration: (
4211
+ /* glsl */
4212
+ `#version 300 es
4213
+ precision mediump float;
4214
+ in vec2 v_uv;
4215
+ uniform sampler2D u_prev;
4216
+ uniform float u_aberration;
4217
+ out vec4 fragColor;
4218
+ void main() {
4219
+ float amt = u_aberration > 0.0 ? u_aberration : 0.004;
4220
+ vec2 dir = v_uv - 0.5;
4221
+ float r = texture(u_prev, v_uv + dir * amt * 2.0).r;
4222
+ float g = texture(u_prev, v_uv).g;
4223
+ float b = texture(u_prev, v_uv - dir * amt * 2.0).b;
4224
+ fragColor = vec4(r, g, b, 1.0);
4225
+ }`
4226
+ ),
4227
+ /** Palette-shift / hue rotation. u_hue_shift in radians, driven by u_time for animation. */
4228
+ hueRotate: (
4229
+ /* glsl */
4230
+ `#version 300 es
4231
+ precision mediump float;
4232
+ in vec2 v_uv;
4233
+ uniform sampler2D u_prev;
4234
+ uniform float u_hue_shift; // radians; if 0 uses u_time
4235
+ uniform float u_time;
4236
+ out vec4 fragColor;
4237
+ void main() {
4238
+ float angle = u_hue_shift != 0.0 ? u_hue_shift : u_time * 0.5;
4239
+ vec4 col = texture(u_prev, v_uv);
4240
+ // Hue rotate via Rodrigues in YIQ-ish space
4241
+ float c = cos(angle), s = sin(angle);
4242
+ mat3 m = mat3(
4243
+ 0.299+0.701*c+0.168*s, 0.587-0.587*c+0.330*s, 0.114-0.114*c-0.497*s,
4244
+ 0.299-0.299*c-0.328*s, 0.587+0.413*c+0.035*s, 0.114-0.114*c+0.292*s,
4245
+ 0.299-0.300*c+1.250*s, 0.587-0.588*c-1.050*s, 0.114+0.886*c-0.203*s
4246
+ );
4247
+ fragColor = vec4(m * col.rgb, col.a);
4248
+ }`
4249
+ ),
4250
+ /** Wave distortion (heat haze, underwater). setUniform('u_wave_amp', default 0.003) */
4251
+ wave: (
4252
+ /* glsl */
4253
+ `#version 300 es
4254
+ precision mediump float;
4255
+ in vec2 v_uv;
4256
+ uniform sampler2D u_prev;
4257
+ uniform float u_time;
4258
+ uniform float u_wave_amp;
4259
+ uniform float u_wave_freq;
4260
+ out vec4 fragColor;
4261
+ void main() {
4262
+ float amp = u_wave_amp > 0.0 ? u_wave_amp : 0.003;
4263
+ float freq = u_wave_freq > 0.0 ? u_wave_freq : 8.0;
4264
+ vec2 uv = v_uv;
4265
+ uv.x += sin(uv.y * freq + u_time * 2.5) * amp;
4266
+ uv.y += cos(uv.x * freq + u_time * 2.5) * amp;
4267
+ fragColor = texture(u_prev, clamp(uv, 0.0, 1.0));
4268
+ }`
4269
+ ),
4270
+ // ── Multi-pass bloom building blocks ───────────────────────────────────────
4271
+ // Use with setPipeline([brightpass, hblur, vblur, bloomComposite]).
4272
+ /** Bloom pass 1/4: extract bright pixels above threshold (default 0.6). */
4273
+ bloomBrightpass: (
4274
+ /* glsl */
4275
+ `#version 300 es
4276
+ precision mediump float;
4277
+ in vec2 v_uv;
4278
+ uniform sampler2D u_prev;
4279
+ uniform float u_bloom_threshold;
4280
+ out vec4 fragColor;
4281
+ void main() {
4282
+ vec4 col = texture(u_prev, v_uv);
4283
+ float lum = dot(col.rgb, vec3(0.2126, 0.7152, 0.0722));
4284
+ float thresh = u_bloom_threshold > 0.0 ? u_bloom_threshold : 0.55;
4285
+ float weight = smoothstep(thresh - 0.05, thresh + 0.05, lum);
4286
+ fragColor = col * weight;
4287
+ }`
4288
+ ),
4289
+ /** Bloom pass 2/4: horizontal gaussian blur (13-tap). */
4290
+ bloomHBlur: (
4291
+ /* glsl */
4292
+ `#version 300 es
4293
+ precision mediump float;
4294
+ in vec2 v_uv;
4295
+ uniform sampler2D u_prev;
4296
+ uniform vec2 u_resolution;
4297
+ uniform float u_bloom_spread;
4298
+ out vec4 fragColor;
4299
+ void main() {
4300
+ float spread = u_bloom_spread > 0.0 ? u_bloom_spread : 1.0;
4301
+ vec2 texel = vec2(spread / u_resolution.x, 0.0);
4302
+ const float W[7] = float[](0.0625, 0.125, 0.1875, 0.25, 0.1875, 0.125, 0.0625);
4303
+ vec4 sum = vec4(0.0);
4304
+ for (int i = 0; i < 7; i++)
4305
+ sum += texture(u_prev, clamp(v_uv + float(i-3) * texel, 0.0, 1.0)) * W[i];
4306
+ fragColor = sum;
4307
+ }`
4308
+ ),
4309
+ /** Bloom pass 3/4: vertical gaussian blur (13-tap). */
4310
+ bloomVBlur: (
4311
+ /* glsl */
4312
+ `#version 300 es
4313
+ precision mediump float;
4314
+ in vec2 v_uv;
4315
+ uniform sampler2D u_prev;
4316
+ uniform vec2 u_resolution;
4317
+ uniform float u_bloom_spread;
4318
+ out vec4 fragColor;
4319
+ void main() {
4320
+ float spread = u_bloom_spread > 0.0 ? u_bloom_spread : 1.0;
4321
+ vec2 texel = vec2(0.0, spread / u_resolution.y);
4322
+ const float W[7] = float[](0.0625, 0.125, 0.1875, 0.25, 0.1875, 0.125, 0.0625);
4323
+ vec4 sum = vec4(0.0);
4324
+ for (int i = 0; i < 7; i++)
4325
+ sum += texture(u_prev, clamp(v_uv + float(i-3) * texel, 0.0, 1.0)) * W[i];
4326
+ fragColor = sum;
4327
+ }`
4328
+ ),
4329
+ /** Bloom pass 4/4: composite original + blurred bloom. u_bloom_intensity controls strength. */
4330
+ bloomComposite: (
4331
+ /* glsl */
4332
+ `#version 300 es
4333
+ precision mediump float;
4334
+ in vec2 v_uv;
4335
+ uniform sampler2D u_scene;
4336
+ uniform sampler2D u_prev;
4337
+ uniform float u_bloom_intensity;
4338
+ out vec4 fragColor;
4339
+ void main() {
4340
+ vec4 scene = texture(u_scene, v_uv);
4341
+ vec4 bloom = texture(u_prev, v_uv);
4342
+ float intensity = u_bloom_intensity > 0.0 ? u_bloom_intensity : 0.8;
4343
+ fragColor = vec4(scene.rgb + bloom.rgb * intensity, scene.a);
4344
+ }`
4345
+ )
4346
+ };
4347
+ var BLOOM_PIPELINE = [
4348
+ { shader: WEBGL_EFFECTS.bloomBrightpass },
4349
+ { shader: WEBGL_EFFECTS.bloomHBlur },
4350
+ { shader: WEBGL_EFFECTS.bloomVBlur },
4351
+ { shader: WEBGL_EFFECTS.bloomComposite }
4352
+ ];
4353
+ function compileShader(gl, type, src) {
4354
+ const sh = gl.createShader(type);
4355
+ if (!sh) throw new Error("hayao/webgl: createShader failed");
4356
+ gl.shaderSource(sh, src);
4357
+ gl.compileShader(sh);
4358
+ if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
4359
+ const log = gl.getShaderInfoLog(sh);
4360
+ gl.deleteShader(sh);
4361
+ throw new Error(`hayao/webgl: shader compile error
4362
+ ${log}`);
4363
+ }
4364
+ return sh;
4365
+ }
4366
+ function buildProgram(gl, fragSrc) {
4367
+ const vert = compileShader(gl, gl.VERTEX_SHADER, VERT_SRC);
4368
+ const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSrc);
4369
+ const prog = gl.createProgram();
4370
+ if (!prog) throw new Error("hayao/webgl: createProgram failed");
4371
+ gl.attachShader(prog, vert);
4372
+ gl.attachShader(prog, frag);
4373
+ gl.linkProgram(prog);
4374
+ gl.deleteShader(vert);
4375
+ gl.deleteShader(frag);
4376
+ if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
4377
+ const log = gl.getProgramInfoLog(prog);
4378
+ gl.deleteProgram(prog);
4379
+ throw new Error(`hayao/webgl: program link error
4380
+ ${log}`);
4381
+ }
4382
+ return prog;
4383
+ }
4384
+ function applyUniform(gl, prog, name, value) {
4385
+ const loc = gl.getUniformLocation(prog, name);
4386
+ if (loc === null) return;
4387
+ if (typeof value === "number") {
4388
+ gl.uniform1f(loc, value);
4389
+ } else if (value.length === 2) {
4390
+ gl.uniform2f(loc, value[0], value[1]);
4391
+ } else if (value.length === 3) {
4392
+ gl.uniform3f(loc, value[0], value[1], value[2]);
4393
+ } else {
4394
+ gl.uniform4f(loc, value[0], value[1], value[2], value[3]);
4395
+ }
4396
+ }
4397
+ function makeFboSlot(gl, w, h) {
4398
+ const tex = gl.createTexture();
4399
+ gl.bindTexture(gl.TEXTURE_2D, tex);
4400
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
4401
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
4402
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
4403
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
4404
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
4405
+ const fbo = gl.createFramebuffer();
4406
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
4407
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
4408
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
4409
+ return { fbo, tex };
4410
+ }
4411
+ var WebGL2Renderer = class {
4412
+ width;
4413
+ height;
4414
+ background;
4415
+ glCanvas;
4416
+ gl;
4417
+ offscreen;
4418
+ ctx2d;
4419
+ dpr = 1;
4420
+ /** Original rasterized scene texture. */
4421
+ sceneTex;
4422
+ /** Intermediate pass framebuffers (one per non-final pass). */
4423
+ fbos = [];
4424
+ /** Compiled programs, one per pass. */
4425
+ programs = [];
4426
+ /** Per-pass uniform maps (merged with global uniforms). */
4427
+ passUniforms = [];
4428
+ /** Empty VAO for gl_VertexID-only draws. */
4429
+ vao;
4430
+ /** Frames drawn, driving u_time at a nominal 60 fps (cosmetic, but deterministic). */
4431
+ framesDrawn = 0;
4432
+ /** Global custom uniforms applied to every pass. */
4433
+ uniforms = /* @__PURE__ */ new Map();
4434
+ constructor(config) {
4435
+ this.width = config.width;
4436
+ this.height = config.height;
4437
+ this.background = config.background ?? "#ffffff";
4438
+ this.glCanvas = document.createElement("canvas");
4439
+ this.glCanvas.style.width = "100%";
4440
+ this.glCanvas.style.height = "100%";
4441
+ this.glCanvas.style.objectFit = "contain";
4442
+ this.glCanvas.style.display = "block";
4443
+ const gl = this.glCanvas.getContext("webgl2", { alpha: false, antialias: false, preserveDrawingBuffer: false });
4444
+ if (!gl) throw new Error("hayao/webgl: WebGL2 unavailable \u2014 fall back to Canvas2DRenderer");
4445
+ this.gl = gl;
4446
+ this.offscreen = document.createElement("canvas");
4447
+ const ctx2d = this.offscreen.getContext("2d");
4448
+ if (!ctx2d) throw new Error("hayao/webgl: Canvas2D unavailable for rasterization");
4449
+ this.ctx2d = ctx2d;
4450
+ this.resize();
4451
+ const vao = gl.createVertexArray();
4452
+ if (!vao) throw new Error("hayao/webgl: createVertexArray failed");
4453
+ this.vao = vao;
4454
+ const tex = gl.createTexture();
4455
+ if (!tex) throw new Error("hayao/webgl: createTexture failed");
4456
+ this.sceneTex = tex;
4457
+ gl.bindTexture(gl.TEXTURE_2D, tex);
4458
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
4459
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
4460
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
4461
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
4462
+ this.buildPipeline([{ shader: config.postProcess ?? PASSTHROUGH_FRAG }]);
4463
+ }
4464
+ mount(parent) {
4465
+ parent.appendChild(this.glCanvas);
4466
+ this.resize();
4467
+ }
4468
+ resize() {
4469
+ this.dpr = Math.min(3, globalThis.devicePixelRatio || 1);
4470
+ const w = Math.round(this.width * this.dpr);
4471
+ const h = Math.round(this.height * this.dpr);
4472
+ this.glCanvas.width = w;
4473
+ this.glCanvas.height = h;
4474
+ this.offscreen.width = w;
4475
+ this.offscreen.height = h;
4476
+ this.gl.viewport(0, 0, w, h);
4477
+ for (const slot of this.fbos) {
4478
+ this.gl.bindTexture(this.gl.TEXTURE_2D, slot.tex);
4479
+ this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, w, h, 0, this.gl.RGBA, this.gl.UNSIGNED_BYTE, null);
4480
+ }
4481
+ }
4482
+ buildPipeline(passes) {
4483
+ const gl = this.gl;
4484
+ const w = this.glCanvas.width || Math.round(this.width * this.dpr);
4485
+ const h = this.glCanvas.height || Math.round(this.height * this.dpr);
4486
+ for (const p of this.programs) gl.deleteProgram(p);
4487
+ for (const s of this.fbos) {
4488
+ gl.deleteFramebuffer(s.fbo);
4489
+ gl.deleteTexture(s.tex);
4490
+ }
4491
+ this.programs = [];
4492
+ this.fbos = [];
4493
+ this.passUniforms = [];
4494
+ for (const pass of passes) {
4495
+ this.programs.push(buildProgram(gl, pass.shader));
4496
+ this.passUniforms.push(pass.uniforms ?? {});
4497
+ }
4498
+ for (let i = 0; i < passes.length - 1; i++) {
4499
+ this.fbos.push(makeFboSlot(gl, w, h));
4500
+ }
4501
+ }
4502
+ draw(commands) {
4503
+ const gl = this.gl;
4504
+ const n5 = this.programs.length;
4505
+ const t = this.framesDrawn++ / 60;
4506
+ const w = this.glCanvas.width;
4507
+ const h = this.glCanvas.height;
4508
+ drawToCanvas2D(this.ctx2d, commands, this.width, this.height, this.background, this.dpr);
4509
+ gl.activeTexture(gl.TEXTURE0);
4510
+ gl.bindTexture(gl.TEXTURE_2D, this.sceneTex);
4511
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.offscreen);
4512
+ gl.bindVertexArray(this.vao);
4513
+ for (let i = 0; i < n5; i++) {
4514
+ const prog = this.programs[i];
4515
+ const isLast = i === n5 - 1;
4516
+ if (isLast) {
4517
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
4518
+ gl.viewport(0, 0, w, h);
4519
+ } else {
4520
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbos[i].fbo);
4521
+ gl.viewport(0, 0, w, h);
4522
+ }
4523
+ gl.useProgram(prog);
4524
+ gl.uniform1i(gl.getUniformLocation(prog, "u_scene"), 0);
4525
+ gl.activeTexture(gl.TEXTURE1);
4526
+ if (i === 0) {
4527
+ gl.bindTexture(gl.TEXTURE_2D, this.sceneTex);
4528
+ } else {
4529
+ gl.bindTexture(gl.TEXTURE_2D, this.fbos[i - 1].tex);
4530
+ }
4531
+ gl.uniform1i(gl.getUniformLocation(prog, "u_prev"), 1);
4532
+ gl.activeTexture(gl.TEXTURE0);
4533
+ gl.uniform1f(gl.getUniformLocation(prog, "u_time"), t);
4534
+ gl.uniform2f(gl.getUniformLocation(prog, "u_resolution"), w, h);
4535
+ for (const [name, val] of this.uniforms) applyUniform(gl, prog, name, val);
4536
+ for (const [name, val] of Object.entries(this.passUniforms[i])) applyUniform(gl, prog, name, val);
4537
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
4538
+ }
4539
+ }
4540
+ // ── Public control API ────────────────────────────────────────────────────
4541
+ /**
4542
+ * Set a custom uniform applied to every pass. Useful for knob-driven effect
4543
+ * parameters (intensity, scale, colour) without recompiling the shader.
4544
+ * The value is set on every draw() until cleared or overridden.
4545
+ *
4546
+ * ```ts
4547
+ * renderer.setUniform('u_vignette', 0.5);
4548
+ * renderer.setUniform('u_bloom_intensity', [1.0, 0.8]);
4549
+ * ```
4550
+ */
4551
+ setUniform(name, value) {
4552
+ this.uniforms.set(name, value);
4553
+ return this;
4554
+ }
4555
+ /** Remove a custom uniform. */
4556
+ clearUniform(name) {
4557
+ this.uniforms.delete(name);
4558
+ return this;
4559
+ }
4560
+ /**
4561
+ * Replace the post-processing pipeline. Accepts an array of passes; each
4562
+ * pass reads `u_prev` (previous output) and `u_scene` (original frame).
4563
+ * Throws on shader compile error — previous pipeline stays active.
4564
+ *
4565
+ * For single-pass effects, prefer `setPostProcess(shader)`. For the standard
4566
+ * bloom setup, use `setPipeline(BLOOM_PIPELINE)`.
4567
+ *
4568
+ * ```ts
4569
+ * renderer.setPipeline([
4570
+ * { shader: WEBGL_EFFECTS.bloomBrightpass },
4571
+ * { shader: WEBGL_EFFECTS.bloomHBlur },
4572
+ * { shader: WEBGL_EFFECTS.bloomVBlur },
4573
+ * { shader: WEBGL_EFFECTS.bloomComposite },
4574
+ * ]);
4575
+ * renderer.setUniform('u_bloom_intensity', 1.0);
4576
+ * ```
4577
+ */
4578
+ setPipeline(passes) {
4579
+ this.buildPipeline(passes.length ? passes : [{ shader: PASSTHROUGH_FRAG }]);
4580
+ return this;
4581
+ }
4582
+ /**
4583
+ * Convenience: set a single-pass post-processing shader.
4584
+ * Throws on compile error — previous effect stays active.
4585
+ */
4586
+ setPostProcess(fragmentShader) {
4587
+ return this.setPipeline([{ shader: fragmentShader }]);
4588
+ }
4589
+ /** Restore passthrough (no post-processing). */
4590
+ clearPostProcess() {
4591
+ return this.setPostProcess(PASSTHROUGH_FRAG);
4592
+ }
4593
+ get element() {
4594
+ return this.glCanvas;
4595
+ }
4596
+ toDesign(clientX, clientY) {
4597
+ return clientToDesign(this.glCanvas.getBoundingClientRect(), this.width, this.height, clientX, clientY);
4598
+ }
4599
+ viewport() {
4600
+ return fitViewport(this.glCanvas.getBoundingClientRect(), this.width, this.height);
4601
+ }
4602
+ dispose() {
4603
+ const gl = this.gl;
4604
+ gl.deleteTexture(this.sceneTex);
4605
+ for (const p of this.programs) gl.deleteProgram(p);
4606
+ for (const s of this.fbos) {
4607
+ gl.deleteFramebuffer(s.fbo);
4608
+ gl.deleteTexture(s.tex);
4609
+ }
4610
+ gl.deleteVertexArray(this.vao);
4611
+ this.glCanvas.remove();
4612
+ }
4613
+ };
4614
+
3274
4615
  // src/render/headless.ts
3275
4616
  var HeadlessRenderer = class {
3276
4617
  width;
@@ -4541,6 +5882,7 @@ function resolve(s) {
4541
5882
  pitchJumpTime: s.pitchJumpTime ?? 0,
4542
5883
  vibrato: s.vibrato ?? 0,
4543
5884
  vibratoFreq: s.vibratoFreq ?? 6,
5885
+ repeat: Math.max(0, s.repeat ?? 0),
4544
5886
  detune: s.detune ?? 0,
4545
5887
  sub: Math.max(0, s.sub ?? 0),
4546
5888
  noise: s.noise ?? 0,
@@ -4601,9 +5943,10 @@ function renderSound(spec, opts = {}) {
4601
5943
  let env = adsr(t, r);
4602
5944
  if (r.punch > 0) env *= 1 + r.punch * Math.max(0, 1 - t / Math.max(1e-4, r.attack + r.decay));
4603
5945
  if (r.tremolo > 0) env *= 1 - r.tremolo * 0.5 * (1 - dsin(TAU * r.tremoloFreq * t));
4604
- const prog = body > 0 ? t / body : 0;
5946
+ const pt = r.repeat > 0 ? t - Math.floor(t / r.repeat) * r.repeat : t;
5947
+ const prog = body > 0 ? pt / body : 0;
4605
5948
  let f = r.freq * semis(r.slide * prog + r.slideAccel * prog * prog);
4606
- if (r.pitchJump !== 0 && t >= r.pitchJumpTime) f *= semis(r.pitchJump);
5949
+ if (r.pitchJump !== 0 && pt >= r.pitchJumpTime) f *= semis(r.pitchJump);
4607
5950
  if (r.vibrato > 0) f *= semis(r.vibrato * dsin(TAU * r.vibratoFreq * t));
4608
5951
  if (r.fm > 0) f += r.fm * dsin(TAU * r.fmFreq * t);
4609
5952
  if (f < 0) f = 0;
@@ -5052,33 +6395,56 @@ var INSTRUMENTS = {
5052
6395
  // src/audio/audio.ts
5053
6396
  var DEFAULT_VOLUMES = { master: 0.7, music: 0.6, sfx: 0.8, muted: false };
5054
6397
  var AudioBus = class {
5055
- ctx = null;
6398
+ _ctx = null;
5056
6399
  master = null;
5057
6400
  musicGain = null;
5058
6401
  sfxGain = null;
5059
6402
  vol = { ...DEFAULT_VOLUMES };
5060
6403
  padOn = false;
6404
+ /**
6405
+ * The live AudioContext (null until `start()` unlocks audio). External
6406
+ * synths being ported in (bundled ZzFX/ZzFXM, hand-rolled Web Audio) should
6407
+ * create their sources ON this context — never their own — so they inherit
6408
+ * the autoplay unlock the user gesture already paid for.
6409
+ */
6410
+ get ctx() {
6411
+ return this._ctx;
6412
+ }
6413
+ /**
6414
+ * The SFX mix bus (null until `start()`). Connect external one-shot sources
6415
+ * here instead of `ctx.destination` so master/sfx volume and mute apply.
6416
+ */
6417
+ get sfxBus() {
6418
+ return this.sfxGain;
6419
+ }
6420
+ /**
6421
+ * The music mix bus (null until `start()`). Connect external music sources
6422
+ * (e.g. a ZzFXM-rendered buffer) here so master/music volume and mute apply.
6423
+ */
6424
+ get musicBus() {
6425
+ return this.musicGain;
6426
+ }
5061
6427
  get available() {
5062
6428
  return typeof globalThis.AudioContext !== "undefined" || "webkitAudioContext" in globalThis;
5063
6429
  }
5064
6430
  get started() {
5065
- return !!this.ctx;
6431
+ return !!this._ctx;
5066
6432
  }
5067
6433
  /** Must be called from a user gesture (browser autoplay policy). */
5068
6434
  start() {
5069
- if (this.ctx) {
5070
- void this.ctx.resume();
6435
+ if (this._ctx) {
6436
+ void this._ctx.resume();
5071
6437
  return;
5072
6438
  }
5073
6439
  if (!this.available) return;
5074
6440
  const Ctx = globalThis.AudioContext || globalThis.webkitAudioContext;
5075
- this.ctx = new Ctx();
5076
- this.master = this.ctx.createGain();
5077
- this.musicGain = this.ctx.createGain();
5078
- this.sfxGain = this.ctx.createGain();
6441
+ this._ctx = new Ctx();
6442
+ this.master = this._ctx.createGain();
6443
+ this.musicGain = this._ctx.createGain();
6444
+ this.sfxGain = this._ctx.createGain();
5079
6445
  this.musicGain.connect(this.master);
5080
6446
  this.sfxGain.connect(this.master);
5081
- this.master.connect(this.ctx.destination);
6447
+ this.master.connect(this._ctx.destination);
5082
6448
  this.applyVolumes();
5083
6449
  }
5084
6450
  setVolumes(v) {
@@ -5089,27 +6455,27 @@ var AudioBus = class {
5089
6455
  return { ...this.vol };
5090
6456
  }
5091
6457
  applyVolumes() {
5092
- if (!this.ctx || !this.master || !this.musicGain || !this.sfxGain) return;
5093
- const t = this.ctx.currentTime;
6458
+ if (!this._ctx || !this.master || !this.musicGain || !this.sfxGain) return;
6459
+ const t = this._ctx.currentTime;
5094
6460
  this.master.gain.setTargetAtTime(this.vol.muted ? 0 : this.vol.master, t, 0.04);
5095
6461
  this.musicGain.gain.setTargetAtTime(this.vol.music * 0.5, t, 0.04);
5096
6462
  this.sfxGain.gain.setTargetAtTime(this.vol.sfx, t, 0.04);
5097
6463
  }
5098
6464
  /** Play a single tone (no-op if audio unstarted). */
5099
6465
  tone(spec) {
5100
- if (!this.ctx || !this.sfxGain) return;
6466
+ if (!this._ctx || !this.sfxGain) return;
5101
6467
  const { freq, duration, type = "sine", gain = 0.2, delay = 0, pan } = spec;
5102
- const t = this.ctx.currentTime + delay;
5103
- const osc = this.ctx.createOscillator();
6468
+ const t = this._ctx.currentTime + delay;
6469
+ const osc = this._ctx.createOscillator();
5104
6470
  osc.type = type;
5105
6471
  osc.frequency.value = freq;
5106
- const g = this.ctx.createGain();
6472
+ const g = this._ctx.createGain();
5107
6473
  g.gain.setValueAtTime(0, t);
5108
6474
  g.gain.linearRampToValueAtTime(gain, t + 8e-3);
5109
6475
  g.gain.exponentialRampToValueAtTime(1e-4, t + duration);
5110
6476
  osc.connect(g);
5111
- if (pan !== void 0 && typeof this.ctx.createStereoPanner === "function") {
5112
- const p = this.ctx.createStereoPanner();
6477
+ if (pan !== void 0 && typeof this._ctx.createStereoPanner === "function") {
6478
+ const p = this._ctx.createStereoPanner();
5113
6479
  p.pan.value = Math.max(-1, Math.min(1, pan));
5114
6480
  g.connect(p);
5115
6481
  p.connect(this.sfxGain);
@@ -5139,24 +6505,24 @@ var AudioBus = class {
5139
6505
  * No-op without an AudioContext.
5140
6506
  */
5141
6507
  playSpec(spec, opts = {}) {
5142
- if (!this.ctx || !this.sfxGain) return;
5143
- const sig = renderSound(spec, { sampleRate: this.ctx.sampleRate });
5144
- const buf = this.ctx.createBuffer(1, sig.length, this.ctx.sampleRate);
6508
+ if (!this._ctx || !this.sfxGain) return;
6509
+ const sig = renderSound(spec, { sampleRate: this._ctx.sampleRate });
6510
+ const buf = this._ctx.createBuffer(1, sig.length, this._ctx.sampleRate);
5145
6511
  buf.copyToChannel(new Float32Array(sig), 0);
5146
- const src = this.ctx.createBufferSource();
6512
+ const src = this._ctx.createBufferSource();
5147
6513
  src.buffer = buf;
5148
- const g = this.ctx.createGain();
6514
+ const g = this._ctx.createGain();
5149
6515
  g.gain.value = opts.gain ?? 1;
5150
6516
  src.connect(g);
5151
- if (opts.pan !== void 0 && typeof this.ctx.createStereoPanner === "function") {
5152
- const p = this.ctx.createStereoPanner();
6517
+ if (opts.pan !== void 0 && typeof this._ctx.createStereoPanner === "function") {
6518
+ const p = this._ctx.createStereoPanner();
5153
6519
  p.pan.value = Math.max(-1, Math.min(1, opts.pan));
5154
6520
  g.connect(p);
5155
6521
  p.connect(this.sfxGain);
5156
6522
  } else {
5157
6523
  g.connect(this.sfxGain);
5158
6524
  }
5159
- src.start(this.ctx.currentTime + (opts.when ?? 0));
6525
+ src.start(this._ctx.currentTime + (opts.when ?? 0));
5160
6526
  }
5161
6527
  /**
5162
6528
  * Render a Song offline and play it on the music bus. Returns a stop handle.
@@ -5164,13 +6530,13 @@ var AudioBus = class {
5164
6530
  * an AudioContext.
5165
6531
  */
5166
6532
  playSong(song, opts = {}) {
5167
- if (!this.ctx || !this.musicGain) return () => {
6533
+ if (!this._ctx || !this.musicGain) return () => {
5168
6534
  };
5169
- const mix2 = renderSong(song, { sampleRate: this.ctx.sampleRate });
5170
- const buf = this.ctx.createBuffer(2, mix2.left.length, this.ctx.sampleRate);
6535
+ const mix2 = renderSong(song, { sampleRate: this._ctx.sampleRate });
6536
+ const buf = this._ctx.createBuffer(2, mix2.left.length, this._ctx.sampleRate);
5171
6537
  buf.copyToChannel(new Float32Array(mix2.left), 0);
5172
6538
  buf.copyToChannel(new Float32Array(mix2.right), 1);
5173
- const src = this.ctx.createBufferSource();
6539
+ const src = this._ctx.createBufferSource();
5174
6540
  src.buffer = buf;
5175
6541
  if (opts.loop) {
5176
6542
  src.loop = true;
@@ -5201,26 +6567,26 @@ var AudioBus = class {
5201
6567
  }
5202
6568
  /** Start a soft evolving ambient pad on the music bus. */
5203
6569
  startAmbient(root = 110, voices = [1, 1.5, 2, 2.5]) {
5204
- if (!this.ctx || !this.musicGain || this.padOn) return;
6570
+ if (!this._ctx || !this.musicGain || this.padOn) return;
5205
6571
  this.padOn = true;
5206
- const filter = this.ctx.createBiquadFilter();
6572
+ const filter = this._ctx.createBiquadFilter();
5207
6573
  filter.type = "lowpass";
5208
6574
  filter.frequency.value = 900;
5209
6575
  filter.connect(this.musicGain);
5210
- const bus = this.ctx.createGain();
6576
+ const bus = this._ctx.createGain();
5211
6577
  bus.gain.value = 0;
5212
6578
  bus.connect(filter);
5213
6579
  for (const mult of voices) {
5214
- const osc = this.ctx.createOscillator();
6580
+ const osc = this._ctx.createOscillator();
5215
6581
  osc.type = "triangle";
5216
6582
  osc.frequency.value = root * mult;
5217
- const g = this.ctx.createGain();
6583
+ const g = this._ctx.createGain();
5218
6584
  g.gain.value = 0.1 / voices.length;
5219
6585
  osc.connect(g);
5220
6586
  g.connect(bus);
5221
6587
  osc.start();
5222
6588
  }
5223
- bus.gain.setTargetAtTime(0.9, this.ctx.currentTime, 3);
6589
+ bus.gain.setTargetAtTime(0.9, this._ctx.currentTime, 3);
5224
6590
  }
5225
6591
  };
5226
6592
  var audio = new AudioBus();
@@ -5944,7 +7310,7 @@ function melancholicPiano() {
5944
7310
  }
5945
7311
  function epicOrchestral() {
5946
7312
  const key = noteToMidi("D3");
5947
- const all = voiceLead(progression(key, "minor", ["i", "VI", "III", "VII", "VI", "VII", "i", "i"]));
7313
+ const all2 = voiceLead(progression(key, "minor", ["i", "VI", "III", "VII", "VI", "VII", "i", "i"]));
5948
7314
  const scale = scaleMidis(noteToMidi("D4"), "minor", 3);
5949
7315
  const deg = (i) => scale[(i % scale.length + scale.length) % scale.length];
5950
7316
  const strings = {
@@ -5952,7 +7318,7 @@ function epicOrchestral() {
5952
7318
  instrument: { ...INSTRUMENTS.strings, volume: 0.24 },
5953
7319
  gain: 0.7,
5954
7320
  pan: -0.15,
5955
- patterns: all.map((c) => arp([c[0], c[1], c[2], c[0] + 12], 8, 0.5, 0.75)),
7321
+ patterns: all2.map((c) => arp([c[0], c[1], c[2], c[0] + 12], 8, 0.5, 0.75)),
5956
7322
  sequence: [0, 1, 2, 3, 4, 5, 6, 7]
5957
7323
  };
5958
7324
  const themeBars = [
@@ -5980,7 +7346,7 @@ function epicOrchestral() {
5980
7346
  instrument: { ...INSTRUMENTS.choir, volume: 0.18 },
5981
7347
  gain: 0.5,
5982
7348
  pan: 0.05,
5983
- patterns: all.map((c) => [n3([c[2] + 12, c[0] + 24], 4, 0.6)]),
7349
+ patterns: all2.map((c) => [n3([c[2] + 12, c[0] + 24], 4, 0.6)]),
5984
7350
  sequence: [0, 1, 2, 3, 4, 5, 6, 7]
5985
7351
  };
5986
7352
  const timp = {
@@ -5996,7 +7362,7 @@ function epicOrchestral() {
5996
7362
  gain: 0.32,
5997
7363
  pan: 0.25,
5998
7364
  // sparkles in only from bar 3, with the theme
5999
- patterns: [restBar, ...all.map((c) => [rest(1), n3(c[2] + 24, 1, 0.55), rest(1), n3(c[1] + 24, 1, 0.5)])],
7365
+ patterns: [restBar, ...all2.map((c) => [rest(1), n3(c[2] + 24, 1, 0.55), rest(1), n3(c[1] + 24, 1, 0.5)])],
6000
7366
  sequence: [0, 0, 3, 4, 5, 6, 7, 8]
6001
7367
  };
6002
7368
  return { bpm: 88, tracks: [timp, strings, brass, choir, glock], humanize: 0.14, velBrightness: 0.5, reverb: { wet: 0.32, roomSize: 0.9, damp: 0.4 }, master: { lowCut: 0.4, presence: 0.32, air: 0.14 } };
@@ -6051,6 +7417,83 @@ function genre(id) {
6051
7417
  return GENRES.find((g) => g.id === id);
6052
7418
  }
6053
7419
 
7420
+ // src/audio/zzfx.ts
7421
+ var SHAPES = ["sine", "triangle", "saw", "square", "noise"];
7422
+ var warned = /* @__PURE__ */ new Set();
7423
+ function warnOnce(param, msg) {
7424
+ if (warned.has(param)) return;
7425
+ warned.add(param);
7426
+ console.warn(`[hayao] specFromZzfx: ${param} ${msg}`);
7427
+ }
7428
+ function resetZzfxWarnings() {
7429
+ warned.clear();
7430
+ }
7431
+ function specFromZzfx(p) {
7432
+ const volume = p[0] ?? 1;
7433
+ const randomness = p[1] ?? 0.05;
7434
+ const frequency = p[2] ?? 220;
7435
+ const attack = p[3] ?? 0;
7436
+ const sustain = p[4] ?? 0;
7437
+ const release = p[5] ?? 0.1;
7438
+ const shape = p[6] ?? 0;
7439
+ const shapeCurve = p[7] ?? 1;
7440
+ const slide = p[8] ?? 0;
7441
+ const deltaSlide = p[9] ?? 0;
7442
+ const pitchJump = p[10] ?? 0;
7443
+ const pitchJumpTime = p[11] ?? 0;
7444
+ const repeatTime = p[12] ?? 0;
7445
+ const noise = p[13] ?? 0;
7446
+ const modulation = p[14] ?? 0;
7447
+ const bitCrush = p[15] ?? 0;
7448
+ const delay = p[16] ?? 0;
7449
+ const sustainVolume = p[17] ?? 1;
7450
+ const decay = p[18] ?? 0;
7451
+ const tremolo = p[19] ?? 0;
7452
+ const filter = p[20] ?? 0;
7453
+ const body = attack + decay + sustain + release;
7454
+ const spec = {
7455
+ freq: frequency,
7456
+ volume,
7457
+ attack,
7458
+ decay,
7459
+ sustain,
7460
+ release,
7461
+ sustainLevel: sustainVolume
7462
+ };
7463
+ const shapeIdx = Math.max(0, Math.min(SHAPES.length - 1, Math.round(shape)));
7464
+ spec.wave = SHAPES[shapeIdx];
7465
+ if (Math.round(shape) === 3) warnOnce("shape=3 (tan)", "has no hayao wave; approximated as a hard square.");
7466
+ if (shapeCurve !== 1) spec.shapeCurve = shapeCurve;
7467
+ if (randomness !== 0) warnOnce("randomness", "is dropped: hayao specs are deterministic (same spec \u2192 same samples).");
7468
+ if (slide !== 0) spec.slide = slide * body;
7469
+ if (deltaSlide !== 0) spec.slideAccel = deltaSlide * body * body / 2;
7470
+ if (pitchJump !== 0) {
7471
+ const target = frequency + pitchJump;
7472
+ if (target > 0 && frequency > 0) {
7473
+ spec.pitchJump = 12 * dlog2(target / frequency);
7474
+ spec.pitchJumpTime = pitchJumpTime;
7475
+ } else {
7476
+ warnOnce("pitchJump", "would drive frequency to \u2264 0 Hz; dropped.");
7477
+ }
7478
+ }
7479
+ if (repeatTime > 0) spec.repeat = repeatTime;
7480
+ if (noise !== 0) spec.noise = Math.max(0, Math.min(1, noise));
7481
+ if (modulation !== 0) {
7482
+ warnOnce("modulation", "is approximated as vibrato (pitch LFO at |modulation| Hz).");
7483
+ spec.vibrato = 0.6;
7484
+ spec.vibratoFreq = Math.abs(modulation);
7485
+ }
7486
+ if (bitCrush > 0) spec.bitCrush = Math.max(1, Math.round(bitCrush * 100));
7487
+ if (tremolo !== 0) spec.tremolo = Math.max(0, Math.min(1, tremolo));
7488
+ if (delay > 0) {
7489
+ spec.delay = delay;
7490
+ spec.delayFeedback = 0.5;
7491
+ }
7492
+ if (filter > 0) spec.lowpass = filter;
7493
+ else if (filter < 0) spec.highpass = -filter;
7494
+ return spec;
7495
+ }
7496
+
6054
7497
  // src/audio/album.ts
6055
7498
  var n4 = (pitch, beats, vel = 1) => ({ pitch, beats, vel });
6056
7499
  var rest2 = (beats) => ({ pitch: null, beats });
@@ -6635,6 +8078,163 @@ var Shell = class {
6635
8078
  }
6636
8079
  };
6637
8080
 
8081
+ // src/ui/touch.ts
8082
+ var STYLE_ID2 = "hayao-touch-style";
8083
+ var CSS2 = `
8084
+ .hy-touch{position:absolute;inset:0;z-index:40;pointer-events:none;touch-action:none;-webkit-user-select:none;user-select:none}
8085
+ .hy-stick{position:absolute;bottom:6%;width:120px;height:120px;border-radius:50%;pointer-events:auto;background:rgba(20,16,10,.16);border:2px solid rgba(250,244,230,.35);box-shadow:inset 0 0 20px rgba(0,0,0,.12)}
8086
+ .hy-stick.left{left:5%}
8087
+ .hy-stick.right{right:5%}
8088
+ .hy-knob{position:absolute;left:50%;top:50%;width:52px;height:52px;margin:-26px 0 0 -26px;border-radius:50%;background:rgba(250,244,230,.6);border:2px solid rgba(20,16,10,.25);transition:transform .04s linear}
8089
+ .hy-btns{position:absolute;right:5%;bottom:26%;display:flex;flex-direction:column-reverse;gap:12px;pointer-events:none}
8090
+ .hy-btn{pointer-events:auto;width:66px;height:66px;border-radius:50%;font:600 15px/1 var(--hy-serif,Georgia,serif);color:#fbf6ea;background:var(--hy-accent,#a11d3a);border:2px solid rgba(250,244,230,.4);box-shadow:0 4px 12px rgba(30,20,10,.3);display:grid;place-items:center;text-transform:capitalize}
8091
+ .hy-btn:active,.hy-btn.on{transform:translateY(2px);filter:brightness(1.15)}
8092
+ `;
8093
+ function ensureStyle2() {
8094
+ if (typeof document === "undefined" || document.getElementById(STYLE_ID2)) return;
8095
+ const s = document.createElement("style");
8096
+ s.id = STYLE_ID2;
8097
+ s.textContent = CSS2;
8098
+ document.head.appendChild(s);
8099
+ }
8100
+ function normDirs(s) {
8101
+ if (!s.dirs) return void 0;
8102
+ return Array.isArray(s.dirs) ? { up: s.dirs[0], down: s.dirs[1], left: s.dirs[2], right: s.dirs[3] } : s.dirs;
8103
+ }
8104
+ var TouchControls = class {
8105
+ handle;
8106
+ root;
8107
+ disposers = [];
8108
+ /** Every action this control set can hold — released together on dispose/reset. */
8109
+ owned = /* @__PURE__ */ new Set();
8110
+ constructor(handle, layout) {
8111
+ this.handle = handle;
8112
+ ensureStyle2();
8113
+ const coarse = typeof matchMedia !== "undefined" && matchMedia("(pointer: coarse)").matches;
8114
+ const mount = handle.canvas?.parentElement ?? void 0;
8115
+ this.root = document.createElement("div");
8116
+ this.root.className = "hy-touch";
8117
+ if (mount && (layout.touchOnly === false || coarse)) mount.appendChild(this.root);
8118
+ const left = normStick(layout.left);
8119
+ const right = normStick(layout.right);
8120
+ if (left) this.addStick("left", left);
8121
+ if (right) this.addStick("right", right);
8122
+ if (layout.buttons?.length) this.addButtons(layout.buttons);
8123
+ }
8124
+ addStick(side, spec) {
8125
+ const base = document.createElement("div");
8126
+ base.className = `hy-stick ${side}`;
8127
+ const knob2 = document.createElement("div");
8128
+ knob2.className = "hy-knob";
8129
+ base.appendChild(knob2);
8130
+ this.root.appendChild(base);
8131
+ const dirs = normDirs(spec);
8132
+ const prefix = spec.prefix;
8133
+ const buckets = spec.buckets ?? 32;
8134
+ const dead = spec.deadzone ?? 0.28;
8135
+ let activeId = null;
8136
+ const setAxes = (nx, ny) => {
8137
+ if (!prefix) return;
8138
+ const axes = this.handle.world.input.axes;
8139
+ axes.set(`${prefix}x`, snapAxis(nx, buckets));
8140
+ axes.set(`${prefix}y`, snapAxis(ny, buckets));
8141
+ axes.set(`${prefix}angle`, nx === 0 && ny === 0 ? 0 : quantizeAngle(datan2(ny, nx), buckets));
8142
+ };
8143
+ const release = () => {
8144
+ if (dirs) for (const a of [dirs.up, dirs.down, dirs.left, dirs.right]) this.hold(a, false);
8145
+ setAxes(0, 0);
8146
+ knob2.style.transform = "translate(0,0)";
8147
+ activeId = null;
8148
+ };
8149
+ const apply = (clientX, clientY) => {
8150
+ const r = base.getBoundingClientRect();
8151
+ const rad = r.width / 2;
8152
+ let dx = (clientX - (r.left + rad)) / rad;
8153
+ let dy = (clientY - (r.top + rad)) / rad;
8154
+ const m = dhypot(dx, dy);
8155
+ if (m > 1) {
8156
+ dx /= m;
8157
+ dy /= m;
8158
+ }
8159
+ knob2.style.transform = `translate(${dx * rad * 0.6}px,${dy * rad * 0.6}px)`;
8160
+ const mag = dhypot(dx, dy);
8161
+ if (dirs) {
8162
+ this.hold(dirs.left, dx < -dead);
8163
+ this.hold(dirs.right, dx > dead);
8164
+ this.hold(dirs.up, dy < -dead);
8165
+ this.hold(dirs.down, dy > dead);
8166
+ }
8167
+ setAxes(mag < dead ? 0 : dx, mag < dead ? 0 : dy);
8168
+ };
8169
+ const onDown = (e) => {
8170
+ if (activeId !== null) return;
8171
+ activeId = e.pointerId;
8172
+ base.setPointerCapture?.(e.pointerId);
8173
+ apply(e.clientX, e.clientY);
8174
+ e.preventDefault();
8175
+ };
8176
+ const onMove = (e) => {
8177
+ if (e.pointerId === activeId) apply(e.clientX, e.clientY);
8178
+ };
8179
+ const onUp = (e) => {
8180
+ if (e.pointerId === activeId) release();
8181
+ };
8182
+ base.addEventListener("pointerdown", onDown);
8183
+ base.addEventListener("pointermove", onMove);
8184
+ base.addEventListener("pointerup", onUp);
8185
+ base.addEventListener("pointercancel", onUp);
8186
+ this.disposers.push(() => {
8187
+ release();
8188
+ base.remove();
8189
+ });
8190
+ }
8191
+ addButtons(buttons) {
8192
+ const wrap = document.createElement("div");
8193
+ wrap.className = "hy-btns";
8194
+ this.root.appendChild(wrap);
8195
+ for (const b of buttons) {
8196
+ const el = document.createElement("button");
8197
+ el.className = "hy-btn";
8198
+ el.textContent = b.label ?? b.action;
8199
+ wrap.appendChild(el);
8200
+ const hold2 = b.hold ?? true;
8201
+ const down = (e) => {
8202
+ el.classList.add("on");
8203
+ if (hold2) this.hold(b.action, true);
8204
+ else this.handle.input.press(b.action);
8205
+ el.setPointerCapture?.(e.pointerId);
8206
+ e.preventDefault();
8207
+ };
8208
+ const up = () => {
8209
+ el.classList.remove("on");
8210
+ if (hold2) this.hold(b.action, false);
8211
+ };
8212
+ el.addEventListener("pointerdown", down);
8213
+ el.addEventListener("pointerup", up);
8214
+ el.addEventListener("pointercancel", up);
8215
+ this.disposers.push(() => {
8216
+ up();
8217
+ el.remove();
8218
+ });
8219
+ }
8220
+ this.disposers.push(() => wrap.remove());
8221
+ }
8222
+ hold(action, on) {
8223
+ this.handle.input.setHeld(action, on);
8224
+ if (on) this.owned.add(action);
8225
+ }
8226
+ dispose() {
8227
+ for (const d of this.disposers.splice(0)) d();
8228
+ for (const a of this.owned) this.handle.input.setHeld(a, false);
8229
+ this.owned.clear();
8230
+ this.root.remove();
8231
+ }
8232
+ };
8233
+ function normStick(s) {
8234
+ if (!s) return void 0;
8235
+ return Array.isArray(s) ? { dirs: s } : s;
8236
+ }
8237
+
6638
8238
  // src/ui/transition.ts
6639
8239
  var BAYER4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];
6640
8240
  var smoothstep2 = (t) => t * t * (3 - 2 * t);
@@ -6862,7 +8462,7 @@ function replay(makeWorld, log) {
6862
8462
  const world = makeWorld();
6863
8463
  const hashes = [];
6864
8464
  for (let i = 0; i < log.frames.length; i++) {
6865
- world.step(frameActions(log, i));
8465
+ world.step(frameActions(log, i), frameAxes(log, i));
6866
8466
  hashes.push(world.hash());
6867
8467
  }
6868
8468
  return { finalHash: world.hash(), hashes };
@@ -6895,13 +8495,13 @@ function assertDeterministic(makeWorld, log) {
6895
8495
  }
6896
8496
  function assertSnapshotStable(makeWorld, log, warmup) {
6897
8497
  const original = makeWorld();
6898
- for (let i = 0; i < warmup; i++) original.step(frameActions(log, i));
8498
+ for (let i = 0; i < warmup; i++) original.step(frameActions(log, i), frameAxes(log, i));
6899
8499
  const snap = original.snapshot();
6900
8500
  const restored = makeWorld();
6901
8501
  restored.restore(snap);
6902
8502
  for (let i = warmup; i < log.frames.length; i++) {
6903
- original.step(frameActions(log, i));
6904
- restored.step(frameActions(log, i));
8503
+ original.step(frameActions(log, i), frameAxes(log, i));
8504
+ restored.step(frameActions(log, i), frameAxes(log, i));
6905
8505
  }
6906
8506
  const hashA = original.hash();
6907
8507
  const hashB = restored.hash();
@@ -7572,12 +9172,29 @@ var World = class {
7572
9172
  state = {};
7573
9173
  width;
7574
9174
  height;
9175
+ /**
9176
+ * Pause switch. While true, only `pauseMode: 'always'` subtrees update —
9177
+ * rendering, input sampling, and the clock keep ticking, so a pause menu
9178
+ * (an 'always' subtree) stays live. Sim state: hashed + snapshotted, but
9179
+ * only when true, so pinned hashes of unpaused games are unchanged.
9180
+ */
9181
+ paused = false;
9182
+ /**
9183
+ * Sim-time multiplier: the tree receives `clock.dt * timeScale` (slow-mo /
9184
+ * fast-forward), while `'always'` subtrees get the unscaled dt so UI keeps
9185
+ * realtime feel. The clock itself ticks normally. Hashed/snapshotted only
9186
+ * when ≠ 1.
9187
+ */
9188
+ timeScale = 1;
7575
9189
  root;
7576
9190
  activeCamera = null;
7577
9191
  seed;
7578
9192
  freeQueue = [];
7579
9193
  started = false;
7580
9194
  tuningValues;
9195
+ guardDeterminism;
9196
+ warnedClamp = false;
9197
+ warnedNondet = false;
7581
9198
  constructor(config = {}) {
7582
9199
  this.seed = config.seed ?? 1;
7583
9200
  this.rng = new Rng(this.seed);
@@ -7585,6 +9202,7 @@ var World = class {
7585
9202
  this.width = config.width ?? 1280;
7586
9203
  this.height = config.height ?? 720;
7587
9204
  this.tuningValues = { ...config.tuning ?? {} };
9205
+ this.guardDeterminism = config.guardDeterminism ?? false;
7588
9206
  this.root = new Node({ name: "root" });
7589
9207
  }
7590
9208
  /**
@@ -7597,6 +9215,18 @@ var World = class {
7597
9215
  if (v === void 0) throw new Error(`tune('${key}'): no such knob declared in the game's tuning spec`);
7598
9216
  return v;
7599
9217
  }
9218
+ /**
9219
+ * Read a shared resource by key. Throws on a missing key (listing what IS
9220
+ * available) — a typo here would otherwise silently read `undefined` into
9221
+ * the sim, exactly like an undeclared tuning knob.
9222
+ */
9223
+ resource(key) {
9224
+ if (!this.resources.has(key)) {
9225
+ const available = [...this.resources.keys()].join(", ") || "(none)";
9226
+ throw new Error(`resource('${key}'): not set. Available: ${available}`);
9227
+ }
9228
+ return this.resources.get(key);
9229
+ }
7600
9230
  get time() {
7601
9231
  return this.clock.simTimeSec;
7602
9232
  }
@@ -7622,12 +9252,51 @@ var World = class {
7622
9252
  * Advance exactly one fixed step with the given actions held down.
7623
9253
  * This is THE deterministic transition — call it from Node or the browser loop.
7624
9254
  */
7625
- step(actionsDown = []) {
9255
+ step(actionsDown = [], axes) {
9256
+ if (typeof actionsDown === "string" || typeof actionsDown[Symbol.iterator] !== "function") {
9257
+ throw new TypeError(`step(actionsDown): pass an array/iterable of action names, got ${typeof actionsDown === "string" ? `the string '${actionsDown}'` : typeof actionsDown}`);
9258
+ }
7626
9259
  this.ensureStarted();
7627
- this.input.beginFrame(actionsDown);
7628
- this.root.updateTree(this.clock.dt);
7629
- this.flushFree();
7630
- this.clock.tick();
9260
+ const unguard = this.guardDeterminism ? this.installGuard() : null;
9261
+ try {
9262
+ this.input.beginFrame(actionsDown, axes);
9263
+ this.root.updateTree(this.clock.dt * this.timeScale, this.clock.dt, "pausable", this.paused);
9264
+ this.flushFree();
9265
+ this.clock.tick();
9266
+ } finally {
9267
+ unguard?.();
9268
+ }
9269
+ }
9270
+ /**
9271
+ * Wrap `Math.random`/`Date.now` for the duration of a step so a stray call
9272
+ * inside the sim warns ONCE per world (with a stack hint) — the values still
9273
+ * flow through, so nothing breaks mid-frame. Returns the restore function;
9274
+ * properly nested re-entry (a world stepped inside another's step) is safe
9275
+ * because each install restores exactly what it replaced.
9276
+ */
9277
+ installGuard() {
9278
+ const mathObj = globalThis.Math;
9279
+ const realRandom = mathObj.random;
9280
+ const realNow = globalThis.Date.now;
9281
+ const warn = (what) => {
9282
+ if (this.warnedNondet) return;
9283
+ this.warnedNondet = true;
9284
+ const stack = new Error().stack?.split("\n").slice(2, 5).join("\n") ?? "";
9285
+ console.warn(`hayao: ${what} called during step() \u2014 nondeterministic. Use world.rng / ctx.time instead.
9286
+ ${stack}`);
9287
+ };
9288
+ mathObj.random = () => {
9289
+ warn("Math.random");
9290
+ return realRandom.call(mathObj);
9291
+ };
9292
+ globalThis.Date.now = () => {
9293
+ warn("Date.now");
9294
+ return realNow.call(globalThis.Date);
9295
+ };
9296
+ return () => {
9297
+ mathObj.random = realRandom;
9298
+ globalThis.Date.now = realNow;
9299
+ };
7631
9300
  }
7632
9301
  /**
7633
9302
  * Feed REAL elapsed ms; runs 0+ fixed steps. Returns steps run. This is the
@@ -7636,9 +9305,13 @@ var World = class {
7636
9305
  * not 72. For headless tests/harnesses that want to fast-forward an exact
7637
9306
  * number of steps, use `runSteps(n)` or `step()` — never `advance` with a big ms.
7638
9307
  */
7639
- advance(realMs, actionsDown = []) {
9308
+ advance(realMs, actionsDown = [], axes) {
9309
+ if (realMs > this.clock.maxFrameMs && !this.warnedClamp) {
9310
+ this.warnedClamp = true;
9311
+ console.warn(`hayao: advance(${realMs}) exceeds maxFrameMs (${this.clock.maxFrameMs}) and is clamped \u2014 a realtime driver must never spiral. To fast-forward an exact number of steps, use runSteps(n).`);
9312
+ }
7640
9313
  const steps = this.clock.advance(realMs);
7641
- for (let i = 0; i < steps; i++) this.step(actionsDown);
9314
+ for (let i = 0; i < steps; i++) this.step(actionsDown, axes);
7642
9315
  return steps;
7643
9316
  }
7644
9317
  /**
@@ -7646,9 +9319,16 @@ var World = class {
7646
9319
  * headless drivers (unlike `advance`, no realtime clamp). Pass `actionsFor(i)`
7647
9320
  * to script the input per step; omit it to hold nothing down.
7648
9321
  */
7649
- runSteps(n5, actionsFor) {
7650
- for (let i = 0; i < n5; i++) this.step(actionsFor ? actionsFor(i) : []);
9322
+ runSteps(n5, actionsFor, axesFor) {
9323
+ for (let i = 0; i < n5; i++) this.step(actionsFor ? actionsFor(i) : [], axesFor?.(i));
7651
9324
  }
9325
+ /**
9326
+ * Run pending frees NOW instead of at the end of the step. `free()` is
9327
+ * deferred (safe during iteration), which means freed nodes survive until
9328
+ * the step ends — during a scene swap that lets old nodes contaminate the
9329
+ * first frame of the new scene. Pattern: `oldRoot.free(); world.flushFree();
9330
+ * buildNewScene()`. Called automatically at the end of every step.
9331
+ */
7652
9332
  flushFree() {
7653
9333
  if (this.freeQueue.length === 0) return;
7654
9334
  const q = this.freeQueue;
@@ -7659,6 +9339,45 @@ var World = class {
7659
9339
  if (this.activeCamera === node) this.activeCamera = null;
7660
9340
  }
7661
9341
  }
9342
+ // ── Tree inspection ──────────────────────────────────────────
9343
+ /** Visit every live node depth-first (root first, child-index order). */
9344
+ walk(fn) {
9345
+ const visit = (n5) => {
9346
+ fn(n5);
9347
+ for (const c of n5.children) visit(c);
9348
+ };
9349
+ visit(this.root);
9350
+ }
9351
+ /** Total live nodes in the tree (root included). */
9352
+ get nodeCount() {
9353
+ let n5 = 0;
9354
+ this.walk(() => n5++);
9355
+ return n5;
9356
+ }
9357
+ /**
9358
+ * One indented line per node — `name (type) [flags] @x,y` — for quick tree
9359
+ * audits in a REPL or test failure message.
9360
+ */
9361
+ debugTree() {
9362
+ const lines = [];
9363
+ const visit = (n5, depth) => {
9364
+ const flags = [];
9365
+ if (n5.cosmetic) flags.push("cosmetic");
9366
+ if (n5.screenSpace) flags.push("screenSpace");
9367
+ if (n5.pauseMode !== "inherit") flags.push(n5.pauseMode);
9368
+ if (!n5.visible) flags.push("!visible");
9369
+ lines.push(`${" ".repeat(depth)}${n5.name} (${n5.type})${flags.length > 0 ? ` [${flags.join(" ")}]` : ""} @${n5.pos.x},${n5.pos.y}`);
9370
+ for (const c of n5.children) visit(c, depth + 1);
9371
+ };
9372
+ visit(this.root, 0);
9373
+ return lines.join("\n");
9374
+ }
9375
+ /** The active camera's world position + zoom, or null (see WorldContext.camera). */
9376
+ camera() {
9377
+ if (!this.activeCamera) return null;
9378
+ const wt = this.activeCamera.worldTransform();
9379
+ return { pos: { x: wt.e, y: wt.f }, zoom: this.activeCamera.zoom };
9380
+ }
7662
9381
  // ── Rendering ────────────────────────────────────────────────
7663
9382
  /** The view transform (inverse of the active camera), mapping world → screen. */
7664
9383
  viewTransform() {
@@ -7703,7 +9422,10 @@ var World = class {
7703
9422
  state: this.state,
7704
9423
  tree: this.root.serialize(),
7705
9424
  // Only when declared, so games without tuning keep their pinned hashes.
7706
- ...Object.keys(this.tuningValues).length > 0 ? { tuning: this.tuningValues } : {}
9425
+ ...Object.keys(this.tuningValues).length > 0 ? { tuning: this.tuningValues } : {},
9426
+ // Same guard: only when non-default, so pre-pause pinned hashes survive.
9427
+ ...this.paused ? { paused: true } : {},
9428
+ ...this.timeScale !== 1 ? { timeScale: this.timeScale } : {}
7707
9429
  });
7708
9430
  }
7709
9431
  /** Compact snapshot for undo/time-travel and saves. */
@@ -7715,7 +9437,10 @@ var World = class {
7715
9437
  input: this.input.getState(),
7716
9438
  state: structuredClone(this.state),
7717
9439
  tree: this.root.serialize(),
7718
- tuning: { ...this.tuningValues }
9440
+ tuning: { ...this.tuningValues },
9441
+ // Only when non-default, so pre-pause snapshots stay byte-identical.
9442
+ ...this.paused ? { paused: true } : {},
9443
+ ...this.timeScale !== 1 ? { timeScale: this.timeScale } : {}
7719
9444
  };
7720
9445
  }
7721
9446
  /** Restore a snapshot. Rebuilds the tree from data (behaviors are re-attached by scene code). */
@@ -7726,6 +9451,8 @@ var World = class {
7726
9451
  this.input.setState(snap.input);
7727
9452
  this.state = structuredClone(snap.state);
7728
9453
  if (snap.tuning) this.tuningValues = { ...snap.tuning };
9454
+ this.paused = snap.paused ?? false;
9455
+ this.timeScale = snap.timeScale ?? 1;
7729
9456
  resetNodeIds(1e6);
7730
9457
  this.setRoot(deserializeNode(snap.tree));
7731
9458
  this.ensureStarted();
@@ -7990,6 +9717,245 @@ function analyzePlaytest(def, session, opts = {}) {
7990
9717
  return report;
7991
9718
  }
7992
9719
 
9720
+ // src/app/browser.ts
9721
+ function splashCommands(cfg, def, width, height) {
9722
+ const bg = cfg.palette?.bg ?? "#141821";
9723
+ const fg = cfg.palette?.fg ?? (relLuminance(bg) > 0.4 ? "#14171f" : "#f4efe3");
9724
+ const title = cfg.title ?? def.title;
9725
+ const t = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
9726
+ return [
9727
+ { kind: "rect", x: 0, y: 0, w: width, h: height, fill: bg, transform: t, z: 0 },
9728
+ { kind: "text", text: title, x: width / 2, y: height / 2, size: Math.round(height * 0.06), align: "center", weight: 700, fill: fg, transform: t, z: 1 },
9729
+ { kind: "text", text: "loading\u2026", x: width / 2, y: height / 2 + height * 0.08, size: Math.round(height * 0.03), align: "center", fill: fg, opacity: 0.7, transform: t, z: 1 }
9730
+ ];
9731
+ }
9732
+ function runBrowser(def, mount, opts = {}) {
9733
+ const width = def.width ?? 1280;
9734
+ const height = def.height ?? 720;
9735
+ const background = def.background ?? "#f3ecdb";
9736
+ let world = createWorld(def, opts.world);
9737
+ const renderer = opts.renderer === "canvas" ? new Canvas2DRenderer({ width, height, background }) : opts.renderer === "webgl" ? new WebGL2Renderer({ width, height, background }) : new SvgRenderer({ width, height, background });
9738
+ mount.style.position = mount.style.position || "relative";
9739
+ renderer.mount?.(mount);
9740
+ setOverlayHost(mount);
9741
+ const input = new KeyboardSource(def.inputMap ?? {}, document);
9742
+ const pointer = new PointerSource(renderer, { keyboard: input });
9743
+ const extraSources = [...opts.sources ?? []];
9744
+ const capture = isCaptureMode();
9745
+ const declareInputs = () => world.input.declareActions(input.actionNames());
9746
+ declareInputs();
9747
+ const startAudio = () => {
9748
+ audio.start();
9749
+ const s = settings.get();
9750
+ audio.setVolumes(s);
9751
+ window.removeEventListener("pointerdown", startAudio);
9752
+ window.removeEventListener("keydown", startAudio);
9753
+ };
9754
+ window.addEventListener("pointerdown", startAudio);
9755
+ window.addEventListener("keydown", startAudio);
9756
+ const renderFrame = () => renderer.draw(world.render());
9757
+ const restart = () => {
9758
+ world = createWorld(def, opts.world);
9759
+ declareInputs();
9760
+ renderFrame();
9761
+ };
9762
+ const shell = opts.shell === false ? null : new Shell({
9763
+ title: def.title,
9764
+ onRestart: opts.onRestart ?? restart,
9765
+ onPause: (paused) => opts.onPause?.(paused)
9766
+ });
9767
+ const splashCfg = def.splash === false ? null : def.splash ?? {};
9768
+ const splash = splashCfg && !capture ? splashCommands(splashCfg, def, width, height) : null;
9769
+ let booting = !capture;
9770
+ const readyCbs = [];
9771
+ let resolveReady;
9772
+ const ready = new Promise((r) => resolveReady = r);
9773
+ const bootStart = performance.now();
9774
+ let last = performance.now();
9775
+ const finishBoot = () => {
9776
+ if (!booting) return;
9777
+ booting = false;
9778
+ last = performance.now();
9779
+ renderFrame();
9780
+ resolveReady();
9781
+ for (const cb of readyCbs.splice(0)) cb();
9782
+ };
9783
+ const perFrame = (dtMs) => {
9784
+ if (!capture && !shell?.isPaused && !opts.isHeld?.()) {
9785
+ pointer.sample(world.input);
9786
+ for (const s of extraSources) s.sample(world.input);
9787
+ const actions = input.currentActions();
9788
+ const steps = world.advance(dtMs, actions);
9789
+ if (steps > 0) {
9790
+ input.clearPressed();
9791
+ opts.onAdvance?.(world, steps, actions);
9792
+ }
9793
+ }
9794
+ renderFrame();
9795
+ };
9796
+ let raf = 0;
9797
+ let timer;
9798
+ let pendingCb = null;
9799
+ let stopped = false;
9800
+ const HIDDEN_TICK_MS = 16;
9801
+ const clearScheduled = () => {
9802
+ if (raf !== 0) {
9803
+ cancelAnimationFrame(raf);
9804
+ raf = 0;
9805
+ }
9806
+ if (timer !== void 0) {
9807
+ clearTimeout(timer);
9808
+ timer = void 0;
9809
+ }
9810
+ };
9811
+ const schedule = (cb) => {
9812
+ if (stopped) return;
9813
+ pendingCb = cb;
9814
+ if (document.hidden) {
9815
+ timer = setTimeout(() => {
9816
+ timer = void 0;
9817
+ pendingCb = null;
9818
+ cb(performance.now());
9819
+ }, HIDDEN_TICK_MS);
9820
+ } else {
9821
+ raf = requestAnimationFrame((now) => {
9822
+ raf = 0;
9823
+ pendingCb = null;
9824
+ cb(now);
9825
+ });
9826
+ }
9827
+ };
9828
+ const onVisibility = () => {
9829
+ const cb = pendingCb;
9830
+ if (cb === null) return;
9831
+ clearScheduled();
9832
+ pendingCb = null;
9833
+ schedule(cb);
9834
+ };
9835
+ document.addEventListener("visibilitychange", onVisibility);
9836
+ const loop = (now) => {
9837
+ if (booting) {
9838
+ if (splash) renderer.draw(splash);
9839
+ schedule(loop);
9840
+ return;
9841
+ }
9842
+ const dt = now - last;
9843
+ last = now;
9844
+ perFrame(dt);
9845
+ schedule(loop);
9846
+ };
9847
+ schedule(loop);
9848
+ if (booting) {
9849
+ const minDur = splashCfg?.minDurationMs ?? 0;
9850
+ const preload = def.preload ? Promise.resolve().then(() => def.preload(world)) : Promise.resolve();
9851
+ const holdSplash = new Promise((r) => setTimeout(r, Math.max(0, minDur - (performance.now() - bootStart))));
9852
+ Promise.all([preload, holdSplash]).then(finishBoot).catch((err) => {
9853
+ console.error("hayao: preload failed \u2014", err);
9854
+ finishBoot();
9855
+ });
9856
+ } else {
9857
+ resolveReady();
9858
+ }
9859
+ const handle = {
9860
+ get world() {
9861
+ return world;
9862
+ },
9863
+ renderer,
9864
+ input,
9865
+ pointer,
9866
+ canvas: renderer.element,
9867
+ toDesign: (clientX, clientY) => renderer.toDesign?.(clientX, clientY) ?? { x: clientX, y: clientY },
9868
+ viewport: () => renderer.viewport?.(),
9869
+ ready,
9870
+ onReady(cb) {
9871
+ if (booting) readyCbs.push(cb);
9872
+ else cb();
9873
+ },
9874
+ addSource(source) {
9875
+ extraSources.push(source);
9876
+ return () => {
9877
+ const i = extraSources.indexOf(source);
9878
+ if (i !== -1) extraSources.splice(i, 1);
9879
+ source.dispose?.();
9880
+ };
9881
+ },
9882
+ tick(dtMs) {
9883
+ perFrame(dtMs ?? world.clock.stepMs);
9884
+ },
9885
+ stop() {
9886
+ stopped = true;
9887
+ pendingCb = null;
9888
+ clearScheduled();
9889
+ document.removeEventListener("visibilitychange", onVisibility);
9890
+ input.dispose();
9891
+ pointer.dispose();
9892
+ for (const s of extraSources) s.dispose?.();
9893
+ extraSources.length = 0;
9894
+ shell?.dispose();
9895
+ renderer.dispose?.();
9896
+ },
9897
+ restart
9898
+ };
9899
+ if (capture) {
9900
+ installCapture({
9901
+ get world() {
9902
+ return world;
9903
+ },
9904
+ stepOnce: (actions = []) => {
9905
+ world.step(actions);
9906
+ renderFrame();
9907
+ },
9908
+ renderSVG: () => renderToSVGString(world.render(), width, height, background),
9909
+ setPaused: () => {
9910
+ }
9911
+ });
9912
+ }
9913
+ return handle;
9914
+ }
9915
+
9916
+ // src/verify/dom.ts
9917
+ function synthPointer(type, x, y, id) {
9918
+ const e = new Event(type, { bubbles: true });
9919
+ e.clientX = x;
9920
+ e.clientY = y;
9921
+ e.pointerId = id;
9922
+ e.button = 0;
9923
+ return e;
9924
+ }
9925
+ function bootDom(def, mount) {
9926
+ if (typeof document === "undefined") {
9927
+ throw new Error("verify/dom: no DOM found \u2014 set the test's environment to jsdom (// @vitest-environment jsdom).");
9928
+ }
9929
+ const owned = !mount;
9930
+ const host2 = mount ?? document.createElement("div");
9931
+ if (owned) document.body.appendChild(host2);
9932
+ const handle = runBrowser(def, host2, { shell: false, isHeld: () => true });
9933
+ const el = handle.canvas;
9934
+ return {
9935
+ handle,
9936
+ touchDown(x, y, id = 1) {
9937
+ el?.dispatchEvent(synthPointer("pointerdown", x, y, id));
9938
+ },
9939
+ touchMove(x, y, id = 1) {
9940
+ el?.dispatchEvent(synthPointer("pointermove", x, y, id));
9941
+ },
9942
+ touchUp(id = 1) {
9943
+ globalThis.dispatchEvent?.(synthPointer("pointerup", 0, 0, id));
9944
+ },
9945
+ step(n5 = 1, axes) {
9946
+ for (let i = 0; i < n5; i++) {
9947
+ handle.pointer.sample(handle.world.input);
9948
+ handle.world.step(handle.input.currentActions(), axes);
9949
+ handle.input.clearPressed();
9950
+ }
9951
+ },
9952
+ dispose() {
9953
+ handle.stop();
9954
+ if (owned) host2.remove();
9955
+ }
9956
+ };
9957
+ }
9958
+
7993
9959
  // src/logic/fsm.ts
7994
9960
  var Fsm = class {
7995
9961
  constructor(states, transitions, initial, ctx) {
@@ -8094,6 +10060,155 @@ var PhaseClock = class {
8094
10060
  }
8095
10061
  };
8096
10062
 
10063
+ // src/logic/coroutine.ts
10064
+ function sleep(seconds) {
10065
+ return { kind: "sleep", left: seconds };
10066
+ }
10067
+ function waitFor(cond) {
10068
+ return { kind: "cond", cond };
10069
+ }
10070
+ function nextStep() {
10071
+ return { kind: "next" };
10072
+ }
10073
+ function race(...waits) {
10074
+ return { kind: "race", waits };
10075
+ }
10076
+ function all(...waits) {
10077
+ return { kind: "all", waits };
10078
+ }
10079
+ var EPS2 = 1e-9;
10080
+ function advanceWait(w, dt) {
10081
+ if (isHandle(w)) return w.done ? 0 : -1;
10082
+ switch (w.kind) {
10083
+ case "sleep":
10084
+ w.left -= dt;
10085
+ return w.left <= EPS2 ? 0 : -1;
10086
+ case "cond":
10087
+ if (!w.met && w.cond()) w.met = true;
10088
+ return w.met ? 0 : -1;
10089
+ case "next":
10090
+ w.passed = true;
10091
+ return 0;
10092
+ case "race": {
10093
+ let winner = -1;
10094
+ for (let i = 0; i < w.waits.length; i++) {
10095
+ if (advanceWait(w.waits[i], dt) >= 0 && winner < 0) winner = i;
10096
+ }
10097
+ return winner;
10098
+ }
10099
+ case "all": {
10100
+ let done = true;
10101
+ for (const inner of w.waits) {
10102
+ if (!isDone(inner)) {
10103
+ if (advanceWait(inner, dt) < 0) done = false;
10104
+ }
10105
+ }
10106
+ return done ? 0 : -1;
10107
+ }
10108
+ }
10109
+ }
10110
+ function isDone(w) {
10111
+ if (isHandle(w)) return w.done;
10112
+ switch (w.kind) {
10113
+ case "sleep":
10114
+ return w.left <= EPS2;
10115
+ case "cond":
10116
+ return w.met === true;
10117
+ case "next":
10118
+ return w.passed === true;
10119
+ case "race":
10120
+ return w.waits.some(isDone);
10121
+ case "all":
10122
+ return w.waits.every(isDone);
10123
+ }
10124
+ }
10125
+ function isHandle(w) {
10126
+ return w instanceof Runner;
10127
+ }
10128
+ var Runner = class {
10129
+ name;
10130
+ done = false;
10131
+ /** null until the first resume (which runs the body to its first yield). */
10132
+ wait = null;
10133
+ gen;
10134
+ constructor(factory, name) {
10135
+ this.name = name;
10136
+ this.gen = factory();
10137
+ }
10138
+ /** Resume the generator with a value; a throw kills only this runner. */
10139
+ resume(value) {
10140
+ try {
10141
+ const it = this.gen.next(value);
10142
+ if (it.done) this.done = true;
10143
+ else this.wait = it.value;
10144
+ } catch (err) {
10145
+ this.done = true;
10146
+ console.warn(`[hayao] coroutine "${this.name}" threw and was stopped:`, err);
10147
+ }
10148
+ }
10149
+ stop() {
10150
+ if (this.done) return;
10151
+ this.done = true;
10152
+ try {
10153
+ this.gen.return(void 0);
10154
+ } catch {
10155
+ }
10156
+ }
10157
+ };
10158
+ var Coroutines = class {
10159
+ runners = [];
10160
+ /** Started since the last step boundary; adopted at the top of the next step. */
10161
+ pending = [];
10162
+ nextId = 0;
10163
+ /**
10164
+ * Register a coroutine. Its body runs (up to the first yield) on the NEXT
10165
+ * `step()` — never immediately, and never mid-step. Pass a `name` so a
10166
+ * thrown generator warns something better than "co3".
10167
+ */
10168
+ start(gen, name) {
10169
+ const r = new Runner(gen, name ?? `co${this.nextId}`);
10170
+ this.nextId++;
10171
+ this.pending.push(r);
10172
+ return r;
10173
+ }
10174
+ /** Advance every live runner by one fixed step, in insertion order. */
10175
+ step(dt) {
10176
+ if (this.pending.length > 0) {
10177
+ this.runners.push(...this.pending);
10178
+ this.pending.length = 0;
10179
+ }
10180
+ for (const r of this.runners) {
10181
+ if (r.done) continue;
10182
+ if (r.wait === null) {
10183
+ r.resume(0);
10184
+ continue;
10185
+ }
10186
+ const result = advanceWait(r.wait, dt);
10187
+ if (result >= 0) {
10188
+ r.wait = null;
10189
+ r.resume(result);
10190
+ }
10191
+ }
10192
+ let write = 0;
10193
+ for (const r of this.runners) if (!r.done) this.runners[write++] = r;
10194
+ this.runners.length = write;
10195
+ }
10196
+ /** Stop every runner (including ones not yet adopted). */
10197
+ stopAll() {
10198
+ for (const r of this.runners) r.stop();
10199
+ for (const r of this.pending) r.stop();
10200
+ this.runners.length = 0;
10201
+ this.pending.length = 0;
10202
+ }
10203
+ /** Live runner count (started-but-not-yet-stepped ones included). */
10204
+ get active() {
10205
+ let n5 = 0;
10206
+ for (const r of this.runners) if (!r.done) n5++;
10207
+ for (const r of this.pending) if (!r.done) n5++;
10208
+ return n5;
10209
+ }
10210
+ };
10211
+
8097
10212
  // src/logic/random.ts
8098
10213
  function totalWeight(weights) {
8099
10214
  let sum = 0;
@@ -10189,138 +12304,6 @@ function runBrowserNet(def, mount, opts) {
10189
12304
  };
10190
12305
  }
10191
12306
 
10192
- // src/app/browser.ts
10193
- function splashCommands(cfg, def, width, height) {
10194
- const bg = cfg.palette?.bg ?? "#141821";
10195
- const fg = cfg.palette?.fg ?? (relLuminance(bg) > 0.4 ? "#14171f" : "#f4efe3");
10196
- const title = cfg.title ?? def.title;
10197
- const t = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
10198
- return [
10199
- { kind: "rect", x: 0, y: 0, w: width, h: height, fill: bg, transform: t, z: 0 },
10200
- { kind: "text", text: title, x: width / 2, y: height / 2, size: Math.round(height * 0.06), align: "center", weight: 700, fill: fg, transform: t, z: 1 },
10201
- { kind: "text", text: "loading\u2026", x: width / 2, y: height / 2 + height * 0.08, size: Math.round(height * 0.03), align: "center", fill: fg, opacity: 0.7, transform: t, z: 1 }
10202
- ];
10203
- }
10204
- function runBrowser(def, mount, opts = {}) {
10205
- const width = def.width ?? 1280;
10206
- const height = def.height ?? 720;
10207
- const background = def.background ?? "#f3ecdb";
10208
- let world = createWorld(def, opts.world);
10209
- const renderer = opts.renderer === "canvas" ? new Canvas2DRenderer({ width, height, background }) : new SvgRenderer({ width, height, background });
10210
- mount.style.position = mount.style.position || "relative";
10211
- renderer.mount?.(mount);
10212
- setOverlayHost(mount);
10213
- const input = new KeyboardSource(def.inputMap ?? {}, document);
10214
- const pointer = new PointerSource(renderer);
10215
- const capture = isCaptureMode();
10216
- const startAudio = () => {
10217
- audio.start();
10218
- const s = settings.get();
10219
- audio.setVolumes(s);
10220
- window.removeEventListener("pointerdown", startAudio);
10221
- window.removeEventListener("keydown", startAudio);
10222
- };
10223
- window.addEventListener("pointerdown", startAudio);
10224
- window.addEventListener("keydown", startAudio);
10225
- const renderFrame = () => renderer.draw(world.render());
10226
- const restart = () => {
10227
- world = createWorld(def, opts.world);
10228
- renderFrame();
10229
- };
10230
- const shell = opts.shell === false ? null : new Shell({
10231
- title: def.title,
10232
- onRestart: opts.onRestart ?? restart,
10233
- onPause: (paused) => opts.onPause?.(paused)
10234
- });
10235
- const splashCfg = def.splash === false ? null : def.splash ?? {};
10236
- const splash = splashCfg && !capture ? splashCommands(splashCfg, def, width, height) : null;
10237
- let booting = !capture;
10238
- const readyCbs = [];
10239
- let resolveReady;
10240
- const ready = new Promise((r) => resolveReady = r);
10241
- const bootStart = performance.now();
10242
- let raf = 0;
10243
- let last = performance.now();
10244
- const finishBoot = () => {
10245
- if (!booting) return;
10246
- booting = false;
10247
- last = performance.now();
10248
- renderFrame();
10249
- resolveReady();
10250
- for (const cb of readyCbs.splice(0)) cb();
10251
- };
10252
- const loop = (now) => {
10253
- if (booting) {
10254
- if (splash) renderer.draw(splash);
10255
- raf = requestAnimationFrame(loop);
10256
- return;
10257
- }
10258
- const dt = now - last;
10259
- last = now;
10260
- if (!capture && !shell?.isPaused && !opts.isHeld?.()) {
10261
- pointer.sample(world.input);
10262
- const actions = input.currentActions();
10263
- const steps = world.advance(dt, actions);
10264
- if (steps > 0) {
10265
- input.clearPressed();
10266
- opts.onAdvance?.(world, steps, actions);
10267
- }
10268
- }
10269
- renderFrame();
10270
- raf = requestAnimationFrame(loop);
10271
- };
10272
- raf = requestAnimationFrame(loop);
10273
- if (booting) {
10274
- const minDur = splashCfg?.minDurationMs ?? 0;
10275
- const preload = def.preload ? Promise.resolve().then(() => def.preload(world)) : Promise.resolve();
10276
- const holdSplash = new Promise((r) => setTimeout(r, Math.max(0, minDur - (performance.now() - bootStart))));
10277
- Promise.all([preload, holdSplash]).then(finishBoot).catch((err) => {
10278
- console.error("hayao: preload failed \u2014", err);
10279
- finishBoot();
10280
- });
10281
- } else {
10282
- resolveReady();
10283
- }
10284
- const handle = {
10285
- get world() {
10286
- return world;
10287
- },
10288
- renderer,
10289
- input,
10290
- pointer,
10291
- canvas: renderer.element,
10292
- toDesign: (clientX, clientY) => renderer.toDesign?.(clientX, clientY) ?? { x: clientX, y: clientY },
10293
- ready,
10294
- onReady(cb) {
10295
- if (booting) readyCbs.push(cb);
10296
- else cb();
10297
- },
10298
- stop() {
10299
- cancelAnimationFrame(raf);
10300
- input.dispose();
10301
- pointer.dispose();
10302
- shell?.dispose();
10303
- renderer.dispose?.();
10304
- },
10305
- restart
10306
- };
10307
- if (capture) {
10308
- installCapture({
10309
- get world() {
10310
- return world;
10311
- },
10312
- stepOnce: (actions = []) => {
10313
- world.step(actions);
10314
- renderFrame();
10315
- },
10316
- renderSVG: () => renderToSVGString(world.render(), width, height, background),
10317
- setPaused: () => {
10318
- }
10319
- });
10320
- }
10321
- return handle;
10322
- }
10323
-
10324
12307
  // src/studio/record.ts
10325
12308
  var counter = 0;
10326
12309
  function sessionId(game, startedAt) {
@@ -10774,13 +12757,14 @@ function runStudio(baseDef, mount, opts = {}) {
10774
12757
  }
10775
12758
 
10776
12759
  // src/index.ts
10777
- var VERSION = "0.3.0";
12760
+ var VERSION = "0.4.0";
10778
12761
  export {
10779
12762
  ALBUM,
10780
12763
  AMBIENT_PRESETS,
10781
12764
  AmbientField,
10782
12765
  AnimationPlayer,
10783
12766
  AudioBus,
12767
+ BLOOM_PIPELINE,
10784
12768
  BitmapText,
10785
12769
  BroadcastChannelTransport,
10786
12770
  Camera2D,
@@ -10788,11 +12772,14 @@ export {
10788
12772
  Canvas2DRenderer,
10789
12773
  CinematicPlayer,
10790
12774
  Clock,
12775
+ Coroutines,
12776
+ DEFAULT_GAMEPAD_MAP,
10791
12777
  DEFAULT_INPUT_MAP,
10792
12778
  DEFAULT_PLATFORMER,
10793
12779
  DEFAULT_SESSION_CONFIG,
10794
12780
  DEFAULT_TILE_CHARS,
10795
12781
  DUSK,
12782
+ DepthSort,
10796
12783
  EASINGS,
10797
12784
  Edge,
10798
12785
  EventBus,
@@ -10802,12 +12789,14 @@ export {
10802
12789
  Fsm,
10803
12790
  GENRES,
10804
12791
  GENRE_PROFILES,
12792
+ GamepadSource,
10805
12793
  HeadlessRenderer,
10806
12794
  IDENTITY,
10807
12795
  INSTRUMENTS,
10808
12796
  InputBuffer,
10809
12797
  InputRecorder,
10810
12798
  InputState,
12799
+ IsoPrism,
10811
12800
  KENTO,
10812
12801
  KeyboardSource,
10813
12802
  LocalStorageAdapter,
@@ -10816,6 +12805,7 @@ export {
10816
12805
  LoopbackTransport,
10817
12806
  LootTable,
10818
12807
  MEADOW,
12808
+ MOUSE_ACTIONS,
10819
12809
  MemoryStorage,
10820
12810
  NEIGHBORS_4,
10821
12811
  NEIGHBORS_8,
@@ -10857,9 +12847,13 @@ export {
10857
12847
  Text,
10858
12848
  TextureSprite,
10859
12849
  Timer,
12850
+ TouchControls,
10860
12851
  UndoStack,
10861
12852
  VERSION,
12853
+ VerletChain,
12854
+ WEBGL_EFFECTS,
10862
12855
  WangFrame,
12856
+ WebGL2Renderer,
10863
12857
  World,
10864
12858
  abilitiesOf,
10865
12859
  addBody,
@@ -10867,6 +12861,7 @@ export {
10867
12861
  addRevoluteJoint,
10868
12862
  addTransition,
10869
12863
  albumTrack,
12864
+ all,
10870
12865
  analyzePlaytest,
10871
12866
  applyImpulse,
10872
12867
  applyReverb,
@@ -10888,6 +12883,7 @@ export {
10888
12883
  blobPath,
10889
12884
  bodyAABB,
10890
12885
  bodyContains,
12886
+ bootDom,
10891
12887
  cadenceResolves,
10892
12888
  cameraIssues,
10893
12889
  canvasGradient,
@@ -10931,6 +12927,7 @@ export {
10931
12927
  dexp,
10932
12928
  dexp2,
10933
12929
  dhypot,
12930
+ diamondPoints,
10934
12931
  diatonicChord,
10935
12932
  diffLevels,
10936
12933
  distanceGain,
@@ -10953,11 +12950,13 @@ export {
10953
12950
  fft,
10954
12951
  findSoftlocks,
10955
12952
  firstFrame,
12953
+ fitViewport,
10956
12954
  floodFill,
10957
12955
  fluxEnvelope,
10958
12956
  forgivenessIssues,
10959
12957
  fractalNoise,
10960
12958
  frameActions,
12959
+ frameAxes,
10961
12960
  gameInputMap,
10962
12961
  generateCave,
10963
12962
  generateDungeon,
@@ -10975,6 +12974,7 @@ export {
10975
12974
  gridSet,
10976
12975
  gridToTilemap,
10977
12976
  groundAt,
12977
+ hashNoise,
10978
12978
  hashString,
10979
12979
  hashValue,
10980
12980
  hexToHsl,
@@ -10988,10 +12988,12 @@ export {
10988
12988
  inputDensity,
10989
12989
  installCapture,
10990
12990
  invLerp,
12991
+ invalidCommandReason,
10991
12992
  invertTransform,
10992
12993
  isCaptureMode,
10993
12994
  isGround,
10994
12995
  isMonotonic,
12996
+ iso,
10995
12997
  joinRoom,
10996
12998
  jumpAirtime,
10997
12999
  jumpDistance,
@@ -11036,6 +13038,7 @@ export {
11036
13038
  moveRect,
11037
13039
  mutateColor,
11038
13040
  netMessage,
13041
+ nextStep,
11039
13042
  nineSlice,
11040
13043
  normalize,
11041
13044
  noteToMidi,
@@ -11064,6 +13067,8 @@ export {
11064
13067
  proveCompletable,
11065
13068
  proveFullCompletion,
11066
13069
  pump,
13070
+ quantizeAngle,
13071
+ race,
11067
13072
  rad2deg,
11068
13073
  radialGradient,
11069
13074
  rampIssues,
@@ -11077,6 +13082,7 @@ export {
11077
13082
  rectContains,
11078
13083
  rectsOverlap,
11079
13084
  registerNode,
13085
+ regularPolyPoints,
11080
13086
  regularPolygon,
11081
13087
  relLuminance,
11082
13088
  remap,
@@ -11090,6 +13096,7 @@ export {
11090
13096
  replay,
11091
13097
  replaySession,
11092
13098
  resetNodeIds,
13099
+ resetZzfxWarnings,
11093
13100
  resolveTuning,
11094
13101
  rigidStep,
11095
13102
  rleDecode,
@@ -11118,14 +13125,17 @@ export {
11118
13125
  setOverlayHost,
11119
13126
  setScreenObserver,
11120
13127
  settings,
13128
+ shadeHex,
11121
13129
  shadowDef,
11122
13130
  shapeBBox,
11123
13131
  shapeBox,
11124
13132
  showScreen,
11125
13133
  signalHash,
13134
+ sleep,
11126
13135
  smoothClosedPath,
11127
13136
  smoothOpenPath,
11128
13137
  smoothstep,
13138
+ snapAxis,
11129
13139
  softClip,
11130
13140
  softClipInPlace,
11131
13141
  solidNeighbours,
@@ -11135,6 +13145,7 @@ export {
11135
13145
  songDuration,
11136
13146
  sortCommands,
11137
13147
  spatialMix,
13148
+ specFromZzfx,
11138
13149
  spectralCentroid,
11139
13150
  spring,
11140
13151
  springStep,
@@ -11170,8 +13181,10 @@ export {
11170
13181
  vscale,
11171
13182
  vsub,
11172
13183
  wait,
13184
+ waitFor,
11173
13185
  wakeBody,
11174
13186
  wangTile,
13187
+ warnCommandOnce,
11175
13188
  weatherEnvelope,
11176
13189
  weightedIndex,
11177
13190
  weightedPick,