hayao 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/API.md CHANGED
@@ -3,19 +3,23 @@
3
3
  > Auto-generated by `npm run api`. Everything importable from `@hayao`.
4
4
  > Grep here for the real surface — never guess an API from memory.
5
5
 
6
- **717 exports.**
6
+ **775 exports.**
7
7
 
8
- ## classs (59)
8
+ ## classs (68)
9
9
 
10
10
  - `AmbientField`
11
11
  - `AnimationPlayer`
12
12
  - `AudioBus`
13
13
  - `BitmapText`
14
+ - `Blend1D`
15
+ - `Blend2D`
16
+ - `Bone2D`
14
17
  - `BroadcastChannelTransport`
15
18
  - `Camera2D`
16
19
  - `CameraController`
17
20
  - `Canvas2DRenderer`
18
21
  - `CinematicPlayer`
22
+ - `ClipPlayer`
19
23
  - `Clock`
20
24
  - `Coroutines`
21
25
  - `DepthSort`
@@ -24,11 +28,13 @@
24
28
  - `Fsm`
25
29
  - `GamepadSource`
26
30
  - `HeadlessRenderer`
31
+ - `IkTarget`
27
32
  - `InputBuffer`
28
33
  - `InputRecorder`
29
34
  - `InputState`
30
35
  - `IsoPrism`
31
36
  - `KeyboardSource`
37
+ - `LightLayer`
32
38
  - `LocalStorageAdapter`
33
39
  - `LockstepSession`
34
40
  - `LoopbackHub`
@@ -44,6 +50,7 @@
44
50
  - `PixelBuffer`
45
51
  - `PlayerInput`
46
52
  - `PointerSource`
53
+ - `PointLight`
47
54
  - `RingBuffer`
48
55
  - `Rng`
49
56
  - `RollbackSession`
@@ -54,6 +61,8 @@
54
61
  - `Shaker`
55
62
  - `Shell`
56
63
  - `Signal`
64
+ - `Skeleton`
65
+ - `SkeletonDebug`
57
66
  - `SnapshotRing`
58
67
  - `SpatialHash`
59
68
  - `Sprite`
@@ -67,7 +76,7 @@
67
76
  - `WebGL2Renderer`
68
77
  - `World`
69
78
 
70
- ## functions (310)
79
+ ## functions (330)
71
80
 
72
81
  - `abilitiesOf`: (world: WorldGraphDef, taken: readonly string[]) => Set<string>
73
82
  - `addBody`: (rw: RigidWorld, def: RigidBodyDef) => number
@@ -77,6 +86,7 @@
77
86
  - `albumTrack`: (id: string) => AlbumTrack | undefined
78
87
  - `all`: (...waits: Wait[]) => Wait
79
88
  - `analyzePlaytest`: (def: GameDefinition<Record<string, unknown>>, session: PlaytestSession, opts?: AnalyzeOptions) => PlaytestReport
89
+ - `applyChannel`: (node: Node, channel: ClipChannel, value: number) => void
80
90
  - `applyImpulse`: (rw: RigidWorld, id: number, ix: number, iy: number, px?: number | undefined, py?: number | undefined) => void
81
91
  - `applyReverb`: (buf: StereoBuffer, opts?: ReverbOptions) => void
82
92
  - `applyTransform`: (m: Transform, p: Vec2) => Vec2
@@ -93,10 +103,12 @@
93
103
  - `availableUpgrades`: (defs: readonly UpgradeDef[], owned: readonly string[]) => UpgradeDef[]
94
104
  - `bandBalance`: (sig: Samples, sampleRate?: number) => BandBalance
95
105
  - `bfs`: <N>(start: N, neighbours: Neighbours<N>, key: NodeKey<N>, opts?: { goal?: ((n: N) => boolean) | undefined; maxNodes?: number | undefined; }) => BfsResult<N>
106
+ - `blendIssues`: (samples: BlendSample[], dims?: 2 | 1) => string[]
96
107
  - `blobPath`: (rng: Rng, radius: number, wobble?: number, lobes?: number) => string
97
108
  - `bodyAABB`: (b: RigidBody) => Rect
98
109
  - `bodyContains`: (b: RigidBody, x: number, y: number) => boolean
99
110
  - `bootDom`: (def: GameDefinition<Record<string, unknown>>, mount?: HTMLElement | undefined) => DomHarness
111
+ - `buildSkeleton`: (root: Node) => Skeleton
100
112
  - `cadenceResolves`: (romans: string[]) => boolean
101
113
  - `cameraIssues`: (samples: readonly Vec2[], opts?: CameraOptions) => string[]
102
114
  - `canvasGradient`: (ctx: { createLinearGradient(x0: number, y0: number, x1: number, y1: number): CanvasGradient; createRadialGradient(x0: number, y0: number, r0: number, x1: nu…
@@ -108,8 +120,12 @@
108
120
  - `chordChanges`: (symbols: string[], octave?: number) => number[][]
109
121
  - `chordNotes`: (symbol: string, octave?: number) => number[]
110
122
  - `clientToDesign`: (rect: { left: number; top: number; width: number; height: number; }, width: number, height: number, clientX: number, clientY: number) => Vec2
123
+ - `clipEvents`: (def: ClipDef, prev: number, next: number) => string[]
124
+ - `clipFinished`: (def: ClipDef, elapsed: number) => boolean
125
+ - `clipIssues`: (def: ClipDef, knownTargets?: readonly string[] | undefined) => string[]
126
+ - `clipTime`: (def: ClipDef, elapsed: number) => number
111
127
  - `collide`: (a: RigidBody, b: RigidBody) => Manifold | null
112
- - `commandsToSVGInner`: (commands: DrawCommand[], idPrefix?: string) => string
128
+ - `commandsToSVGInner`: (commands: DrawCommand[], idPrefix?: string, viewW?: number, viewH?: number) => string
113
129
  - `composeCampaign`: <State, Move>(spec: CampaignSpec<State, Move>) => Campaign<State, Move>
114
130
  - `composeTransform`: (m: Transform, n: Transform) => Transform
115
131
  - `connectedComponents`: (cols: number, rows: number, passable: Passable, diagonal?: boolean) => Components
@@ -122,6 +138,7 @@
122
138
  - `createStereo`: (durationSec: number, sampleRate?: number) => StereoBuffer
123
139
  - `createWorld`: <TState extends Record<string, unknown> = Record<string, unknown>>(def: GameDefinition<TState>, opts?: number | CreateWorldOptions | undefined) => World<...>
124
140
  - `crestFactorDb`: (sig: Samples) => number
141
+ - `cullSegments`: (light: Vec2, radius: number, segs: readonly ContourSegment[]) => ContourSegment[]
125
142
  - `dashJumpDistance`: (cfg?: PlatformerConfig) => number
126
143
  - `datan`: (x: number) => number
127
144
  - `datan2`: (y: number, x: number) => number
@@ -215,6 +232,7 @@
215
232
  - `levelIssues`: (data: LevelData) => string[]
216
233
  - `levelReachable`: (data: LevelData, opts?: { passable?: Passable | undefined; diagonal?: boolean | undefined; }) => ReachReport
217
234
  - `levelToTilemap`: (data: LevelData) => TilemapData
235
+ - `lightingIssues`: (commands: DrawCommand[], avatarFill: string, avatarWorld: Vec2, opts?: LightingOptions) => string[]
218
236
  - `linearGradient`: (stops: readonly StopInput[], angleDeg?: number) => LinearGradient
219
237
  - `lineOfSight`: (map: TilemapData, ax: number, ay: number, bx: number, by: number) => boolean
220
238
  - `lintSong`: (song: Song, opts?: LintOptions) => MusicLintResult
@@ -238,6 +256,7 @@
238
256
  - `mix`: (a: string, b: string, t: number) => string
239
257
  - `mixLinear`: (a: string, b: string, t: number) => string
240
258
  - `mixMono`: (bus: StereoBuffer, sig: Samples, startSample: number, gain?: number, pan?: number) => void
259
+ - `mixPose`: (a: Pose, b: Pose, t: number, out?: Pose) => Pose
241
260
  - `moveRect`: (map: TilemapData, rect: Rect, dx: number, dy: number, opts?: MoveOpts) => MoveResult
242
261
  - `mutateColor`: (rng: Rng, hex: string, amounts?: DriftAmounts) => string
243
262
  - `netMessage`: <T extends Omit<NetMessage, "v">>(msg: T) => T & { v: number; }
@@ -245,11 +264,13 @@
245
264
  - `nineSlice`: (rect: Rect, style: NineSliceStyle, z?: number, transform?: Transform) => DrawCommand[]
246
265
  - `normalize`: (buf: StereoBuffer, target?: number) => void
247
266
  - `noteToMidi`: (name: string) => number
267
+ - `occludersFromTilemap`: (map: TilemapData) => ContourSegment[]
248
268
  - `onsetDensity`: (sig: Samples, sampleRate?: number) => number
249
269
  - `openVoicing`: (chord: number[]) => number[]
250
270
  - `packVarints`: (nums: readonly number[]) => string
251
271
  - `panFromOffset`: (dx: number, spread?: number) => number
252
272
  - `panGains`: (pan: number) => { l: number; r: number; }
273
+ - `parseLightRun`: (run: DrawCommand[]) => ParsedLightRun | null
253
274
  - `parseRich`: (markup: string, colorMap?: Record<string, string>) => RichChar[]
254
275
  - `parseSnapshot`: (text: string | null) => WorldSnapshot | null
255
276
  - `passableFromTilemap`: (map: TilemapData) => Passable
@@ -296,6 +317,7 @@
296
317
  - `replaySession`: (def: GameDefinition<Record<string, unknown>>, session: PlaytestSession, opts?: number | ReplayOptions | undefined) => World<...>
297
318
  - `resetNodeIds`: (start?: number) => void
298
319
  - `resetZzfxWarnings`: () => void
320
+ - `resolveTracks`: (def: ClipDef, skeleton: Skeleton) => BoundTrack[]
299
321
  - `resolveTuning`: (spec: TuningSpec | undefined, overrides?: TuningValues | undefined) => TuningValues
300
322
  - `rigidStep`: (rw: RigidWorld, dt: number) => ContactEvent[]
301
323
  - `rleDecode`: (input: string) => string
@@ -309,7 +331,10 @@
309
331
  - `runHeadless`: <TState extends Record<string, unknown> = Record<string, unknown>>(def: GameDefinition<TState>, inputLog?: InputLog | undefined) => HeadlessResult<TState>
310
332
  - `runStudio`: (baseDef: GameDefinition<Record<string, unknown>>, mount: HTMLElement, opts?: StudioOptions) => StudioHandle
311
333
  - `salienceIssues`: (commands: DrawCommand[], avatarFill: string, background: string, opts?: SalienceOptions) => string[]
334
+ - `sampleClip`: (def: ClipDef, elapsed: number) => SampledChannel[]
312
335
  - `sampleGradient`: (stops: readonly string[], t: number) => string
336
+ - `samplePose`: (def: ClipDef, phase: number) => Pose
337
+ - `sampleTrack`: (keys: Keyframe[], t: number) => number
313
338
  - `scaleMidis`: (tonic: number, mode?: "major" | "minor" | "harmonicMinor" | "melodicMinor" | "dorian" | "phrygian" | "lydian" | "mixolydian" | "locrian" | "majorPentatonic"…
314
339
  - `scalePitchClasses`: (tonic: number, mode?: "major" | "minor" | "harmonicMinor" | "melodicMinor" | "dorian" | "phrygian" | "lydian" | "mixolydian" | "locrian" | "majorPentatonic"…
315
340
  - `scatter`: (x: number, y: number, probability: number, seed?: number) => boolean
@@ -324,6 +349,7 @@
324
349
  - `setScreenObserver`: (cb: ((kind: "show" | "hide", title?: string | undefined) => void) | null) => void
325
350
  - `shadeHex`: (hex: string, f: number) => string
326
351
  - `shadowDef`: (s: Shadow, id: string) => string
352
+ - `shadowQuads`: (light: Vec2, radius: number, segs: readonly ContourSegment[]) => number[][]
327
353
  - `shapeBBox`: (c: DrawCommand) => BBox | null
328
354
  - `shapeBox`: (cmd: DrawCommand) => { x: number; y: number; w: number; h: number; } | null
329
355
  - `showScreen`: (spec: ScreenSpec) => ScreenHandle
@@ -336,13 +362,16 @@
336
362
  - `softClipInPlace`: (buf: StereoBuffer) => void
337
363
  - `solidNeighbours`: (g: Grid, x: number, y: number) => number
338
364
  - `solve`: <State, Move>(puzzle: Puzzle<State, Move>, options?: SolveOptions) => SolveResult<Move>
365
+ - `solveFabrik`: (root: Vec2, lengths: number[], target: Vec2, opts?: { maxIter?: number | undefined; tolerance?: number | undefined; initial?: Vec2[] | undefined; }) => Fabr…
339
366
  - `solveJoint`: (j: Joint, A: RigidBody, B: RigidBody, dt: number, scratch: JointScratch) => void
367
+ - `solveTwoBoneIK`: (root: Vec2, l0: number, l1: number, target: Vec2, bendDir?: 1 | -1) => TwoBoneResult
340
368
  - `songBeats`: (song: Song) => number
341
369
  - `songDuration`: (song: Song) => number
342
370
  - `sortCommands`: (cmds: DrawCommand[]) => DrawCommand[]
343
371
  - `spatialMix`: (dx: number, dy: number, hearing?: number, rolloff?: number) => SpatialMix
344
372
  - `specFromZzfx`: (p: readonly (number | undefined)[]) => SoundSpec
345
373
  - `spectralCentroid`: (sig: Samples, sampleRate?: number) => number
374
+ - `splitByLightLayer`: (sorted: DrawCommand[]) => LightRunSplit
346
375
  - `springStep`: (s: SpringState, target: number, omega: number, dt: number) => SpringState
347
376
  - `star`: (points: number, outer: number, inner: number, rotation?: number) => number[]
348
377
  - `steer2D`: (px: number, py: number, tx: number, ty: number, out: string[], dead?: number) => void
@@ -380,7 +409,7 @@
380
409
  - `worldPoints`: (b: RigidBody) => number[]
381
410
  - `zcr`: (sig: Samples, sampleRate?: number) => number
382
411
 
383
- ## interfaces (240)
412
+ ## interfaces (259)
384
413
 
385
414
  - `AcceptedProof`
386
415
  - `ActBounds`
@@ -402,7 +431,10 @@
402
431
  - `BfsResult`
403
432
  - `BitmapFont`
404
433
  - `BitmapTextConfig`
434
+ - `BlendSample`
435
+ - `Bone2DConfig`
405
436
  - `BotCtx`
437
+ - `BoundTrack`
406
438
  - `Breakpoint`
407
439
  - `CameraBounds`
408
440
  - `CameraConfig`
@@ -416,6 +448,9 @@
416
448
  - `Cell`
417
449
  - `CinematicStep`
418
450
  - `CircleCommand`
451
+ - `ClipDef`
452
+ - `ClipEvent`
453
+ - `ClipPlayerConfig`
419
454
  - `ClockConfig`
420
455
  - `Components`
421
456
  - `ContactEvent`
@@ -439,6 +474,7 @@
439
474
  - `Dungeon`
440
475
  - `DungeonOptions`
441
476
  - `EllipseCommand`
477
+ - `FabrikResult`
442
478
  - `FeatureDelta`
443
479
  - `FeedbackOptions`
444
480
  - `FeedbackResponse`
@@ -466,6 +502,7 @@
466
502
  - `HesitationSpan`
467
503
  - `HotContext`
468
504
  - `HSL`
505
+ - `IkTargetConfig`
469
506
  - `ImageCommand`
470
507
  - `InputLog`
471
508
  - `InputSource`
@@ -473,12 +510,16 @@
473
510
  - `IsoPrismConfig`
474
511
  - `IsoProjection`
475
512
  - `JointScratch`
513
+ - `Keyframe`
476
514
  - `KnobEvent`
477
515
  - `LayoutOptions`
478
516
  - `LevelChange`
479
517
  - `LevelData`
480
518
  - `LevelEntity`
481
519
  - `LevelSpec`
520
+ - `LightingOptions`
521
+ - `LightLayerConfig`
522
+ - `LightRunSplit`
482
523
  - `LinearGradient`
483
524
  - `LintOptions`
484
525
  - `LockstepOptions`
@@ -501,6 +542,8 @@
501
542
  - `Paint`
502
543
  - `Palette`
503
544
  - `ParallaxLayerConfig`
545
+ - `ParsedLight`
546
+ - `ParsedLightRun`
504
547
  - `ParticleStyle`
505
548
  - `PathCommand`
506
549
  - `PhaseDef`
@@ -518,6 +561,7 @@
518
561
  - `PointerReadout`
519
562
  - `PointerSourceOptions`
520
563
  - `PointerTarget`
564
+ - `PointLightConfig`
521
565
  - `PolyCommand`
522
566
  - `PostProcessPass`
523
567
  - `ProgressState`
@@ -552,6 +596,7 @@
552
596
  - `RoomHostOptions`
553
597
  - `RunOptions`
554
598
  - `SalienceOptions`
599
+ - `SampledChannel`
555
600
  - `ScreenEvent`
556
601
  - `ScreenHandle`
557
602
  - `ScreenSpec`
@@ -561,6 +606,7 @@
561
606
  - `Settings`
562
607
  - `Shadow`
563
608
  - `ShellOptions`
609
+ - `SkeletonDebugConfig`
564
610
  - `SoftlockReport`
565
611
  - `SolidRect`
566
612
  - `SolveOptions`
@@ -594,12 +640,14 @@
594
640
  - `TouchControlsLayout`
595
641
  - `TouchStick`
596
642
  - `Track`
643
+ - `TrackDef`
597
644
  - `Transform`
598
645
  - `Transition`
599
646
  - `TransitionConfig`
600
647
  - `Transport`
601
648
  - `TuningKnob`
602
649
  - `TuningSpec`
650
+ - `TwoBoneResult`
603
651
  - `UpgradeDef`
604
652
  - `Variant`
605
653
  - `VariantRef`
@@ -623,11 +671,13 @@
623
671
  - `WorldRegion`
624
672
  - `WorldSnapshot`
625
673
 
626
- ## types (46)
674
+ ## types (50)
627
675
 
628
676
  - `AxisFrame`
629
677
  - `BodyKind`
630
678
  - `BoolGrid`
679
+ - `ClipChannel`
680
+ - `ClipLoop`
631
681
  - `CompletionMode`
632
682
  - `DistanceModel`
633
683
  - `DrawCommand`
@@ -648,10 +698,12 @@
648
698
  - `NetMessage`
649
699
  - `NodeFactory`
650
700
  - `NodeKey`
701
+ - `Occluder`
651
702
  - `Passable`
652
703
  - `PauseMode`
653
704
  - `Pitch`
654
705
  - `PlayerId`
706
+ - `Pose`
655
707
  - `ProbeFrame`
656
708
  - `ProgressMove`
657
709
  - `RigidShape`
@@ -672,13 +724,14 @@
672
724
  - `WipeKind`
673
725
  - `WorldFactory`
674
726
 
675
- ## consts (61)
727
+ ## consts (67)
676
728
 
677
729
  - `ALBUM`: { title: string; subtitle: string; concept: string; tracks: AlbumTrack[]; }
678
730
  - `AMBIENT_PRESETS`: { readonly snow: (colors?: string[]) => AmbientStyle; readonly rain: (colors?: string[]) => AmbientStyle; readonly ash: (colors?: string[]) => AmbientStyle; }
679
731
  - `audio`: AudioBus
680
732
  - `BLOOM_PIPELINE`: PostProcessPass[]
681
733
  - `clamp`: (v: number, lo: number, hi: number) => number
734
+ - `CLIP_CHANNELS`: readonly ClipChannel[]
682
735
  - `DEFAULT_GAMEPAD_MAP`: GamepadMap
683
736
  - `DEFAULT_INPUT_MAP`: Readonly<Record<string, readonly string[]>>
684
737
  - `DEFAULT_PLATFORMER`: PlatformerConfig
@@ -696,8 +749,12 @@
696
749
  - `IDENTITY`: Transform
697
750
  - `INSTRUMENTS`: Record<string, Instrument>
698
751
  - `invLerp`: (a: number, b: number, v: number) => number
752
+ - `isLightCommand`: (c: DrawCommand) => boolean
699
753
  - `KENTO`: { readonly gofun: "#f7f1e2"; readonly washi: "#efe7d3"; readonly kinu: "#e4d8bd"; readonly line: "#d8cbac"; readonly kinako: "#b9a882"; readonly stone: "#6c6…
700
754
  - `knob`: { num(key: string, opts: { default: number; min?: number | undefined; max?: number | undefined; step?: number | undefined; label?: string | undefined; group?…
755
+ - `LAYER_HUD`: 1
756
+ - `LAYER_LIGHT`: 0.5
757
+ - `LAYER_WORLD`: 0
701
758
  - `lerp`: (a: number, b: number, t: number) => number
702
759
  - `lerpDamp`: (current: number, target: number, lambda: number, dt: number) => number
703
760
  - `mapHeight`: (map: TilemapData) => number
@@ -714,6 +771,7 @@
714
771
  - `PARTICLE_PRESETS`: { readonly dust: (colors?: string[]) => ParticleStyle; readonly burst: (colors?: string[]) => ParticleStyle; readonly hit: (colors?: string[]) => ParticleSty…
715
772
  - `playerAction`: (player: string, action: string) => string
716
773
  - `playerInput`: (world: World<Record<string, unknown>>, player: string) => PlayerInput
774
+ - `poseKey`: (target: string, channel: string) => string
717
775
  - `rad2deg`: (r: number) => number
718
776
  - `remap`: (v: number, a0: number, a1: number, b0: number, b1: number) => number
719
777
  - `SAMPLE_RATE`: 44100
@@ -730,7 +788,7 @@
730
788
  - `vdist`: (a: Vec2, b: Vec2) => number
731
789
  - `vdot`: (a: Vec2, b: Vec2) => number
732
790
  - `vec2`: (x?: number, y?: number) => Vec2
733
- - `VERSION`: "0.4.0"
791
+ - `VERSION`: "0.4.1"
734
792
  - `vlen`: (a: Vec2) => number
735
793
  - `vscale`: (a: Vec2, s: number) => Vec2
736
794
  - `vsub`: (a: Vec2, b: Vec2) => Vec2
@@ -342,6 +342,62 @@ broken by tree order. For any game where sprites overlap (top-down, iso), derive
342
342
  - **`diamond` / `regularPoly` shapes** — sugar over `poly` so an iso tile or hex
343
343
  reads as intent, not four re-typed corners.
344
344
 
345
+ ## The animation & lighting seam
346
+
347
+ Two subsystems landed in 0.4.1 — authored **animation** (clips, blend spaces, IK,
348
+ skeletons) and 2D **lighting** (a light layer, point lights, occluder shadows).
349
+ They look unrelated, but they ride the SAME rule, and it is the one rule that
350
+ keeps them from rotting the sim: **deterministic data lives in the sim; the
351
+ motion and the light are observer-side presentation.**
352
+
353
+ Concretely, on the LOGICAL side (`world.state`, hashed, rolled back, replayed):
354
+ the pose a game *reasons about* is an `Fsm.current` / `PhaseClock` / committed
355
+ anchors + hitboxes in `world.state`, all advanced through `dmath`. Light state
356
+ that *drives gameplay* (is the torch lit? how alarmed is the sentry?) is a value
357
+ in `world.state` too. On the OBSERVER side: `ClipPlayer`, `IkTarget`,
358
+ `SkeletonDebug`, `LightLayer`, and `PointLight` are all `cosmetic = true` by
359
+ construction — they render state that lives elsewhere and are **excluded from
360
+ `serialize()` / `hash()` / `snapshot()`**. State flows LOGICAL → view, never the
361
+ reverse.
362
+
363
+ What that forbids, in order of how often it bites:
364
+
365
+ - **Never read a visual pose back into the sim.** A gameplay anchor — a muzzle,
366
+ a hand, a foot-plant — is computed on the LOGICAL side with `dmath`, NEVER read
367
+ from a posed `bone.worldTransform()`. The clip/IK result is one frame of eased,
368
+ crossfaded, observer-only motion; sampling it into the sim couples the hash to
369
+ the animation and the replay diverges. **The committed-anchor recipe** is the
370
+ lead pattern: the sim owns the muzzle point as a `dmath` offset from the
371
+ logical body (in `world.state`); the ClipPlayer poses the visible gun to *point
372
+ at* it; a shot spawns from the committed anchor, not from the barrel sprite's
373
+ world transform. The picture follows the truth; the truth never asks the
374
+ picture where it landed.
375
+ - **Blend weights, IK reach, and light intensity never gate gameplay.** A
376
+ `Blend1D` weight, an `IkTarget.reached` flag, a `PointLight.litIntensity` after
377
+ flicker — these are view outputs. Branch gameplay on the logical parameter that
378
+ *fed* them (the `world.state` speed, the logical "is the target in range"
379
+ distance, the `world.state` lit boolean), not on the interpolated result.
380
+ - **Secondary-motion and flicker RNG is observer-seeded — never `world.rng`.**
381
+ A `PointLight`'s flicker uses a private `Rng` seeded at construction (the
382
+ Particles precedent), advanced in `onProcess`, never in `draw` (which may run
383
+ 0..n times per step). Same for any spring/wobble jitter on the rig. Pulling
384
+ those numbers from `world.rng` would race the gameplay stream and desync every
385
+ replay.
386
+
387
+ **The proof pattern to copy** is the stripped-rig hash-equality test in
388
+ `src/scene/clipPlayer.test.ts`: build a world whose logic advances every frame
389
+ *with* the full cosmetic rig, build the same world *without* it, and assert
390
+ `withRig.hash() === without.hash()` at every frame (plus `assertSnapshotStable`
391
+ with warmup landing mid-clip and mid-crossfade). If stripping the animation
392
+ changes the hash, a visual write has leaked into the sim — the test names the
393
+ frame. Lighting has the same proof: `examples/duskveil/` was relit with a
394
+ `LightLayer` + two cosmetic `PointLight`s over the existing fight, and its golden
395
+ hash is **unchanged** — the lights are pure view, so the pinned replay survives
396
+ the relight. Both sandboxes wire the seam the idiomatic way end to end
397
+ (`sandboxes/anim-lab/` for the rig, `sandboxes/light-lab/` for the lights):
398
+ canonical knob state is hashed, the whole rig/light apparatus is cosmetic and
399
+ excluded.
400
+
345
401
  ## The human-contact layer
346
402
 
347
403
  The first real playtest of the campaign found every defect in the one layer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hayao",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "An AI-first game engine: a deterministic, headless-native simulation kernel with a Godot-style scene tree, pluggable renderers (SVG/Canvas/headless), and a built-in verification harness — designed so an LLM can author, test, and prove correct a whole game without ever opening a browser.",
6
6
  "keywords": [