@zakkster/lite-camera-pro 1.1.0 → 1.2.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/CHANGELOG.md CHANGED
@@ -6,6 +6,82 @@ Versioning. Version lives in three places at once -- `package.json`, the
6
6
  `VERSION` const in `src/index.js`, and the `Version:` header in `llms.txt` --
7
7
  bumped together or not at all.
8
8
 
9
+ ## [1.2.0] -- 2026-08-30
10
+
11
+ Fail-closed doors. Three reproduced ways ordinary runtime garbage permanently
12
+ broke the render are closed at the entry, not patched in the body: a NaN dt that
13
+ poisoned shake and camera forever (CP-3), a frame-time spike that diverged the
14
+ position lerp (CP-4), and garbage into seven facade entries that crashed or froze
15
+ at frame N+1 (CP-12, CP-19). The hot bodies are untouched: the only additions to
16
+ an update path are two 2-line entry doors (`update()`, `updateShake()`), so the
17
+ T6 alloc gate still holds at maxMajor 0 / maxPauseMs 4 with the shake and parallax
18
+ pools pinned by identity. No new exports; the T8 main-entry surface is unchanged
19
+ but for the VERSION value. Full policy in `decisions/0002-dt-policy.md` (repo-only).
20
+
21
+ Credit: the ROADMAP audit catalogued CP-3/CP-4/CP-12; the PRO1 qa pass surfaced
22
+ the CP-19 facade over-reads.
23
+
24
+ ### Added
25
+
26
+ - **`cam.maxDt` tunable (default 0.1s).** `update()` clamps a finite dt above this
27
+ ceiling before integrating so a frame-time spike cannot diverge the position
28
+ lerp; a dt exactly == maxDt passes untouched. A plain field beside `lerpSpeed`,
29
+ not a per-frame-validated input (H-C).
30
+ - **Fail-closed doors** on `setMode`, `setState`, `setZoom`, `zoomAt`,
31
+ `trackMultiple`, `setTargetCount` (facade) and `registerPreset` (shake
32
+ registry). Setters validate at the call so a defect fails loud there instead of
33
+ as a raw crash on the next frame. Shake profiles now finiteness-check every
34
+ numeric (decay/freq/maxOffset/maxAngle/dirX/dirY, not just trauma/intensity) in
35
+ the cold entry; the `profile.dirX || 0` NaN-laundering is removed.
36
+ - **Five error codes** (house style, `.code` on a named Error): `ERR_CAMERA_MODE`,
37
+ `ERR_CAMERA_STATE`, `ERR_CAMERA_ZOOM`, `ERR_CAMERA_TARGETS`, `ERR_SHAKE_PRESET`.
38
+ Documented in `llms.txt`; a metadata drift guard asserts every code greppable in
39
+ `src/` is documented and vice versa (both directions, fail closed).
40
+ - **dt policy decision record.** `decisions/0002-dt-policy.md` adopts reject
41
+ (Policy A) + clamp (Policy B) and records the rejection of an exponential-damping
42
+ rewrite (Policy C) by measurement. Repo-only; not shipped in the tarball.
43
+
44
+ ### Changed
45
+
46
+ - **Measured door cost in the subpath weights** (esm, unminified, gzip -9):
47
+ `./shake` 2.82 -> 3.01 KB gz (the addShake full-profile guard + the preset
48
+ registry doors), `./sequence` 5.94 -> 6.01 KB gz (drags the preset registry),
49
+ `.` 21.70 -> 23.19 KB gz (all doors + their JSDoc). `./parallax`, `./bounds`,
50
+ `./multi`, `./follow` unchanged. The `./shake` budget gate (16384 B) holds at
51
+ 3082 B.
52
+
53
+ ### Fixed
54
+
55
+ - **CP-3 -- a NaN dt no longer poisons the shake engine forever.** Before:
56
+ `updateShake(state, NaN)` drove `time`/`trauma` to NaN, the `trauma <= 0` test
57
+ never fired, and `computeShake` emitted NaN every later frame; after one poison
58
+ frame plus 10k good frames the slot was still active with a NaN offset. Now the
59
+ reject door makes that frame a no-op and the slot decays to `active === false`,
60
+ `offsetX === 0`. The same poison via a profile (a NaN `decay`) is closed by the
61
+ addShake full-profile finiteness check.
62
+ - **CP-4 -- a dt spike no longer diverges the position lerp.** Before: 40 frames
63
+ of `dt = 0.5` with `BoundsType.NONE` blew `pos` past 1e6 (the explicit
64
+ integrator is unstable for `lerpSpeed * dt > 2`). Now `update()` clamps dt to
65
+ `maxDt`, bounding `lerpSpeed * dt <= 0.5` at defaults; pos stays in the world
66
+ envelope. The exponential-damping alternative was rejected: measured against the
67
+ linear lerp at `lerpSpeed = 5` over 600 frames it drifts 15.884 px at dt = 1/60
68
+ and 32.981 px at dt = 1/30 -- four to five orders of magnitude above the f32
69
+ position-storage noise (~1.19e-4 px at a 1000 px offset), a visible change to how
70
+ valid frames feel (`decisions/0002-dt-policy.md`).
71
+ - **CP-12 -- garbage into the facade fails loud, not at frame N+1.** Before:
72
+ `setMode(99)` left the strategy lookup undefined and the next `update()` threw a
73
+ raw un-coded TypeError; `setState({ zoom: 0 })` skipped the setZoom clamp and set
74
+ `visibleW` to Infinity; `setState({ zoom: NaN })` emitted `scale(NaN)` (a black
75
+ screen, no error); `shakePreset(undefined)` threw a raw TypeError from
76
+ `name.toLowerCase()`; `setZoom(NaN)` set zoom to NaN. Now each rejects at its door
77
+ (`ERR_CAMERA_MODE` / `ERR_CAMERA_STATE` / `ERR_CAMERA_ZOOM`) or is a documented
78
+ no-op (`shakePreset` unknown name), and `setState({ zoom: 0 })` clamps to 0.25.
79
+ - **CP-19 -- multi-target over-reads are unreachable.** Before: `setTargetCount(64)`
80
+ on 2 targets, or `trackMultiple` with a garbage entry, crashed `updateMultiTarget`
81
+ at frame N+1 reading `.x` on undefined. Now `trackMultiple` validates the array and
82
+ every entry at call time and `setTargetCount` bounds the count to the array length,
83
+ both throwing `ERR_CAMERA_TARGETS`; the facade over-read is unreachable.
84
+
9
85
  ## [1.1.0] -- 2026-08-26
10
86
 
11
87
  Subpath exports. A consumer who needs only screen shake now imports
@@ -118,5 +194,6 @@ modes, multi-target auto-framing, an 8-slot simplex-noise shake engine with
118
194
  presets, fluent timeline sequences, a 16-layer parallax manager, per-edge
119
195
  bounds, a debug HUD, and zero-alloc coordinate conversion.
120
196
 
197
+ [1.2.0]: https://github.com/PeshoVurtoleta/lite-camera-pro/releases/tag/v1.2.0
121
198
  [1.0.1]: https://github.com/PeshoVurtoleta/lite-camera-pro/releases/tag/v1.0.1
122
199
  [1.0.0]: https://github.com/PeshoVurtoleta/lite-camera-pro/releases/tag/v1.0.0
package/README.md CHANGED
@@ -21,7 +21,7 @@ npm install @zakkster/lite-camera-pro
21
21
  ```
22
22
 
23
23
  Need only screen shake? Import the `./shake` subpath and pull just the engine +
24
- presets (2.82 KB gz -- esm, unminified, gzip -9):
24
+ presets (3.01 KB gz -- esm, unminified, gzip -9):
25
25
 
26
26
  ```js
27
27
  import { createShakeState, addShake, updateShake, computeShake, getPreset } from '@zakkster/lite-camera-pro/shake';
package/llms.txt CHANGED
@@ -6,7 +6,7 @@
6
6
  > with presets, fluent timeline sequences, a 16-layer parallax manager, per-edge
7
7
  > bounds, a debug HUD, and zero-alloc coordinate conversion.
8
8
 
9
- Version: 1.1.0
9
+ Version: 1.2.0
10
10
 
11
11
  ESM only. `import` from `@zakkster/lite-camera-pro`. Default export is the
12
12
  CinematicCameraPro class. All hot-path APIs avoid allocation; setup-time calls
@@ -112,19 +112,58 @@ Enums: FollowMode, FOLLOW_STRATEGIES, BoundsType, WrapMode
112
112
  ## Subpath exports (import only what you use; gz, esm, unminified, gzip -9)
113
113
  Beside the "." main entry, six subsystem subpaths + "./package.json". Same runtime
114
114
  identities as the main entry (one engine, no fork). Per-subpath gz weight:
115
- ./shake 2.82 KB createShakeState, addShake, addTraumaSimple, updateShake, computeShake, clearShakes + presets/getPreset/registerPreset/listPresets
115
+ ./shake 3.01 KB createShakeState, addShake, addTraumaSimple, updateShake, computeShake, clearShakes + presets/getPreset/registerPreset/listPresets
116
116
  ./parallax 0.89 KB createParallaxState, addParallaxLayer, removeParallaxLayer, updateParallax, getLayerScroll, applyParallaxLayer, WrapMode
117
117
  ./bounds 1.13 KB createBoundsState, setBoundsAll, setBoundsEdges, setBoundsRect, clearBoundsRect, applyBounds, BoundsType
118
118
  ./multi 0.89 KB createMultiTargetState, updateMultiTarget
119
119
  ./follow 0.71 KB FollowMode, FOLLOW_STRATEGIES
120
- ./sequence 5.94 KB createCameraSequence, panTo, dramaticZoom, bossReveal, timedShake (drags lite-timeline + lite-ease by design -- the only subpath that does)
121
- . 21.70 KB full class + functional layer
120
+ ./sequence 6.01 KB createCameraSequence, panTo, dramaticZoom, bossReveal, timedShake (drags lite-timeline + lite-ease by design -- the only subpath that does)
121
+ . 23.19 KB full class + functional layer
122
122
  Self-reference example (shake only):
123
123
  import { createShakeState, addShake, updateShake, computeShake, getPreset } from '@zakkster/lite-camera-pro/shake';
124
124
 
125
125
  ## Dependencies (all @zakkster, first-party)
126
126
  @zakkster/lite-camera (base CinematicCamera), lite-ease, lite-lerp, lite-noise, lite-timeline.
127
127
 
128
+ ## Fail-closed doors (1.2.0)
129
+ Ordinary runtime garbage (a NaN dt, a frame-time spike, a bad save file) can no longer
130
+ break the render at frame N+1. Setters validate at the call; the update path validates at
131
+ its entry; every unverified numeric fails loud or is a documented no-op. Hot bodies pay
132
+ nothing (the doors are two comparisons on the update path; errors build only on cold throws).
133
+
134
+ dt policy (cam.update(dt, ...) and standalone updateShake(state, dt)):
135
+ - Non-finite (NaN/+-Infinity/null) or negative dt -> documented no-op, nothing mutated.
136
+ - dt 0 and -0 -> legal no-advance frames (zero deltas everywhere).
137
+ - cam.update clamps a finite dt above cam.maxDt (default 0.1s, a plain tunable) to cam.maxDt;
138
+ a dt exactly == maxDt passes untouched. updateShake is reject-only (no maxDt; a large finite
139
+ dt is self-limiting). Set cam.maxDt directly to retune; it is not validated per frame.
140
+
141
+ Shake profiles (cam.shake / addShake): every numeric (trauma, decay, freq, maxOffset,
142
+ maxAngle, dirX, dirY) is resolved to its default then finiteness-checked in the cold entry;
143
+ a non-finite field activates NO slot (a NaN decay would otherwise leave a slot alive forever).
144
+
145
+ Error codes (all throw a house-style Error with a .code):
146
+ - ERR_CAMERA_MODE setMode(mode): mode must be an integer FollowMode in [0, 4].
147
+ - ERR_CAMERA_STATE setState(snapshot): non-null object; posX/posY both-or-neither,
148
+ targetX/targetY both-or-neither; every present numeric finite; zoom
149
+ finite then clamped (zoom 0 -> minZoom 0.25, not an error); mode an
150
+ integer FollowMode in range. Validated in full before any write
151
+ (a rejected snapshot mutates nothing). Snapshot is pose-only
152
+ (pos/target/zoom/mode) -- shake, sequences, zoom animations are not
153
+ serialized.
154
+ - ERR_CAMERA_ZOOM setZoom(level, duration): level finite; duration finite and >= 0
155
+ (0 = instant). zoomAt: anchor x/y, level, duration finite (>= 0);
156
+ a non-function ease normalizes to null (both call forms).
157
+ - ERR_CAMERA_TARGETS trackMultiple(targets): array; every entry an object with finite x/y
158
+ (validated at call time; empty array legal, count 0). setTargetCount(n):
159
+ integer in [0, targets.length].
160
+ - ERR_SHAKE_PRESET registerPreset(name, profile): non-empty string name + object profile
161
+ (setup path fails loud).
162
+ - ERR_CAMERA_DESTROYED every public method after destroy() (1.0.1).
163
+ getPreset(name): a non-string or unknown name returns null (never throws) -- the event path.
164
+ cam.shakePreset(name): an unknown or non-string name is a no-op returning this; use
165
+ listPresets() to enumerate valid names.
166
+
128
167
  ## Notes
129
168
  - Migration: CinematicCameraPro is a drop-in superset of lite-camera's CinematicCamera.
130
169
  - Zero-GC: coordinate conversion uses caller-owned out objects; visible dims are cached properties (no getVisibleArea()); shake (8) and parallax (16) slots are pre-allocated and reused.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zakkster/lite-camera-pro",
3
3
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
4
- "version": "1.1.0",
4
+ "version": "1.2.0",
5
5
  "description": "Cinematic camera system for Canvas2D games. Zero-GC, multi-target framing, noise-based shake, and timeline sequences.",
6
6
  "type": "module",
7
7
  "sideEffects": false,
@@ -101,6 +101,13 @@ export class CinematicCameraPro extends CinematicCamera {
101
101
  // ── Hybrid mode config ──
102
102
  this.hybridVerticalSnap = true; // true = instant, false = fast lerp
103
103
 
104
+ // -- dt policy tunable (see decisions/0002-dt-policy.md) --
105
+ // update() clamps a finite dt above this ceiling before integrating, so
106
+ // a frame-time spike cannot diverge the position lerp (CP-4). Plain knob,
107
+ // not a per-frame-validated input -- writing garbage here is out of
108
+ // contract (D-f). A dt exactly == maxDt passes unclamped (H-D).
109
+ this.maxDt = 0.1; // seconds
110
+
104
111
  // ── Multi-target framing ──
105
112
  this._mt = createMultiTargetState();
106
113
 
@@ -136,6 +143,14 @@ export class CinematicCameraPro extends CinematicCamera {
136
143
  * camera.setMode(FollowMode.PREDICTIVE);
137
144
  */
138
145
  setMode(mode) {
146
+ // Fail-closed door (CP-12): an out-of-range mode makes the update()
147
+ // strategy lookup undefined and crashes at frame N+1 with a raw
148
+ // TypeError. Reject at the setter with a named error instead.
149
+ if (!Number.isInteger(mode) || mode < 0 || mode >= FOLLOW_STRATEGIES.length) {
150
+ const e = new Error("CinematicCameraPro: setMode(mode) requires an integer FollowMode in [0, " + (FOLLOW_STRATEGIES.length - 1) + "]");
151
+ e.code = "ERR_CAMERA_MODE";
152
+ throw e;
153
+ }
139
154
  this.mode = mode;
140
155
  return this;
141
156
  }
@@ -167,6 +182,25 @@ export class CinematicCameraPro extends CinematicCamera {
167
182
  * camera.trackSingle();
168
183
  */
169
184
  trackMultiple(targets, options) {
185
+ // Fail-closed door (CP-19). Validate the array and every entry at CALL
186
+ // time -- a garbage target would otherwise crash updateMultiTarget at
187
+ // frame N+1 reading .x on undefined. Live mutation of the array/entries
188
+ // after this call is out of contract (no per-frame validation, H-C).
189
+ // An empty array is legal (count 0; update skips).
190
+ if (!Array.isArray(targets)) {
191
+ const e = new Error("CinematicCameraPro: trackMultiple(targets) requires an array");
192
+ e.code = "ERR_CAMERA_TARGETS";
193
+ throw e;
194
+ }
195
+ for (let i = 0; i < targets.length; i++) {
196
+ const t = targets[i];
197
+ if (t === null || typeof t !== 'object' || !Number.isFinite(t.x) || !Number.isFinite(t.y)) {
198
+ const e = new Error("CinematicCameraPro: trackMultiple targets[" + i + "] must be an object with finite x and y");
199
+ e.code = "ERR_CAMERA_TARGETS";
200
+ throw e;
201
+ }
202
+ }
203
+
170
204
  const mt = this._mt;
171
205
  mt.active = true;
172
206
  mt.targets = targets;
@@ -209,7 +243,17 @@ export class CinematicCameraPro extends CinematicCamera {
209
243
  * @returns {CinematicCameraPro} this
210
244
  */
211
245
  setTargetCount(count) {
212
- this._mt.count = count;
246
+ // Fail-closed door (CP-19). count must be an integer in [0, targets
247
+ // length]; an over-count would make updateMultiTarget read past the
248
+ // array end and crash at frame N+1. n=0 with null targets is legal.
249
+ const mt = this._mt;
250
+ const max = mt.targets ? mt.targets.length : 0;
251
+ if (!Number.isInteger(count) || count < 0 || count > max) {
252
+ const e = new Error("CinematicCameraPro: setTargetCount(count) must be an integer in [0, " + max + "]");
253
+ e.code = "ERR_CAMERA_TARGETS";
254
+ throw e;
255
+ }
256
+ mt.count = count;
213
257
  return this;
214
258
  }
215
259
 
@@ -262,6 +306,10 @@ export class CinematicCameraPro extends CinematicCamera {
262
306
  * Built-in presets: explosion, earthquake, recoil, impact,
263
307
  * landing, damage, rumble, heavy_impact.
264
308
  *
309
+ * Fail-closed (CP-12/CP-19): an unknown name OR a non-string name is a
310
+ * documented no-op -- getPreset returns null, nothing is activated, and
311
+ * `this` is returned. Use listPresets() to enumerate valid names.
312
+ *
265
313
  * @param {string} name Preset name (case-insensitive)
266
314
  * @param {number} [intensity=1] Scale multiplier
267
315
  * @returns {CinematicCameraPro} this
@@ -491,6 +539,20 @@ export class CinematicCameraPro extends CinematicCamera {
491
539
  * camera.setZoom(2.0, 0.5, easeOutExpo);
492
540
  */
493
541
  setZoom(level, duration = 0, ease = null) {
542
+ // Fail-closed door (CP-12). Finiteness precedes the clamp: clamp(NaN)
543
+ // returns NaN, so a NaN level would sail past the clamp and poison the
544
+ // zoom (F5). A non-finite or negative duration is defective input --
545
+ // duration 0 stays instant.
546
+ if (!Number.isFinite(level)) {
547
+ const e = new Error("CinematicCameraPro: setZoom(level) requires a finite number");
548
+ e.code = "ERR_CAMERA_ZOOM";
549
+ throw e;
550
+ }
551
+ if (!Number.isFinite(duration) || duration < 0) {
552
+ const e = new Error("CinematicCameraPro: setZoom duration must be a finite number >= 0");
553
+ e.code = "ERR_CAMERA_ZOOM";
554
+ throw e;
555
+ }
494
556
  level = clamp(level, this.minZoom, this.maxZoom);
495
557
 
496
558
  if (duration <= 0) {
@@ -533,27 +595,44 @@ export class CinematicCameraPro extends CinematicCamera {
533
595
  * camera.zoomAt(boss, 1.8, 0.8, easeOutExpo);
534
596
  */
535
597
  zoomAt(targetOrX, yOrLevel, levelOrDur, duration = 0, ease = null) {
536
- let level, dur, easeFn;
598
+ // Fail-closed door (CP-12/CP-19). Resolve BOTH call forms into locals,
599
+ // validate them, and ONLY THEN write any this._ state -- a rejected call
600
+ // must mutate nothing. .x/.y are read and validated at CALL time only
601
+ // (live anchor mutation afterwards is out of contract). Finiteness
602
+ // precedes the clamp (F5). A non-function ease normalizes to null in
603
+ // both forms -- the static form gains it, closing a frame-N+1
604
+ // "this._zoomEase is not a function" crash.
605
+ let target, anchorX, anchorY, level, dur, easeFn;
537
606
 
538
607
  if (typeof targetOrX === 'object' && targetOrX !== null) {
539
608
  // zoomAt(target, level, duration, ease)
540
- this._zoomTarget = targetOrX;
541
- this._zoomAnchorX = targetOrX.x;
542
- this._zoomAnchorY = targetOrX.y;
609
+ target = targetOrX;
610
+ anchorX = targetOrX.x;
611
+ anchorY = targetOrX.y;
543
612
  level = yOrLevel;
544
- dur = levelOrDur || 0;
613
+ dur = levelOrDur !== undefined ? levelOrDur : 0;
545
614
  easeFn = duration; // shifted arg position — duration slot holds ease
546
- if (typeof easeFn !== 'function') easeFn = null;
547
615
  } else {
548
616
  // zoomAt(x, y, level, duration, ease)
549
- this._zoomTarget = null;
550
- this._zoomAnchorX = targetOrX;
551
- this._zoomAnchorY = yOrLevel;
617
+ target = null;
618
+ anchorX = targetOrX;
619
+ anchorY = yOrLevel;
552
620
  level = levelOrDur;
553
621
  dur = duration;
554
622
  easeFn = ease;
555
623
  }
624
+ if (typeof easeFn !== 'function') easeFn = null;
625
+
626
+ if (!Number.isFinite(anchorX) || !Number.isFinite(anchorY) ||
627
+ !Number.isFinite(level) || !Number.isFinite(dur) || dur < 0) {
628
+ const e = new Error("CinematicCameraPro: zoomAt requires finite anchor x/y, a finite level, and a finite duration >= 0");
629
+ e.code = "ERR_CAMERA_ZOOM";
630
+ throw e;
631
+ }
556
632
 
633
+ this._zoomTarget = target;
634
+ this._zoomAnchorX = anchorX;
635
+ this._zoomAnchorY = anchorY;
557
636
  this._hasAnchor = true;
558
637
  level = clamp(level, this.minZoom, this.maxZoom);
559
638
 
@@ -658,6 +737,15 @@ export class CinematicCameraPro extends CinematicCamera {
658
737
  /**
659
738
  * Advance the camera by one frame.
660
739
  *
740
+ * dt policy (fail closed -- see decisions/0002-dt-policy.md):
741
+ * - Non-finite (NaN/+-Infinity/null) or negative dt is REJECTED: the call
742
+ * is a documented no-op, nothing is mutated, and it returns. A poisoned
743
+ * frame is invisible (CP-3/CP-4).
744
+ * - dt === 0 and -0 are legal no-advance frames (zero deltas everywhere).
745
+ * - A finite dt above this.maxDt (default 0.1) is clamped to this.maxDt so
746
+ * a frame-time spike cannot diverge the position lerp; a dt exactly ==
747
+ * maxDt passes untouched.
748
+ *
661
749
  * @param {number} dt Delta time in seconds
662
750
  * @param {number} px Player world X
663
751
  * @param {number} py Player world Y
@@ -665,6 +753,11 @@ export class CinematicCameraPro extends CinematicCamera {
665
753
  * @param {number} [pvy=0] Player velocity Y (for lookahead)
666
754
  */
667
755
  update(dt, px, py, pvx = 0, pvy = 0) {
756
+ // Fail-closed dt door (CP-3/CP-4, D-k). Two comparisons at the very top;
757
+ // the whole body below is byte-identical to 1.1.0 (H-C: zero new
758
+ // branches on the hot path). A rejected frame mutates nothing.
759
+ if (!Number.isFinite(dt) || dt < 0) return;
760
+ if (dt > this.maxDt) dt = this.maxDt;
668
761
 
669
762
  const mt = this._mt;
670
763
  const seq = this._seq;
@@ -861,20 +954,82 @@ export class CinematicCameraPro extends CinematicCamera {
861
954
 
862
955
  /**
863
956
  * Restore camera state from a snapshot.
957
+ *
958
+ * Fail-closed contract (CP-12/CP-19 -- see decisions/0002-dt-policy.md
959
+ * siblings): the snapshot is validated in full BEFORE any field is written,
960
+ * so a rejected snapshot mutates nothing.
961
+ * - snapshot must be a non-null object.
962
+ * - posX/posY are both-or-neither; targetX/targetY are both-or-neither.
963
+ * - every present numeric must be finite (the error names the field).
964
+ * - zoom is finite-checked then clamped to minZoom..maxZoom exactly as
965
+ * setZoom does -- zoom 0 clamps to minZoom (0.25), not an error.
966
+ * - mode, if present, must be an integer FollowMode in range.
967
+ * The snapshot is pose-only (pos/target/zoom/mode); shake, sequences, and
968
+ * zoom animations are deliberately not serialized. Any violation throws
969
+ * ERR_CAMERA_STATE.
970
+ *
864
971
  * @param {Object} snapshot
865
972
  * @returns {CinematicCameraPro} this
866
973
  */
867
974
  setState(snapshot) {
868
- if (snapshot.posX !== undefined) {
975
+ if (typeof snapshot !== 'object' || snapshot === null) {
976
+ const e = new Error("CinematicCameraPro: setState requires a snapshot object");
977
+ e.code = "ERR_CAMERA_STATE";
978
+ throw e;
979
+ }
980
+
981
+ const hasPosX = snapshot.posX !== undefined;
982
+ const hasPosY = snapshot.posY !== undefined;
983
+ const hasTargetX = snapshot.targetX !== undefined;
984
+ const hasTargetY = snapshot.targetY !== undefined;
985
+ const hasZoom = snapshot.zoom !== undefined;
986
+ const hasMode = snapshot.mode !== undefined;
987
+
988
+ // Pairing rule: a lone posX would write pos[1] = undefined -> NaN (F9).
989
+ if (hasPosX !== hasPosY) {
990
+ const e = new Error("CinematicCameraPro: setState posX and posY must be provided together");
991
+ e.code = "ERR_CAMERA_STATE";
992
+ throw e;
993
+ }
994
+ if (hasTargetX !== hasTargetY) {
995
+ const e = new Error("CinematicCameraPro: setState targetX and targetY must be provided together");
996
+ e.code = "ERR_CAMERA_STATE";
997
+ throw e;
998
+ }
999
+
1000
+ // Finiteness of every present numeric (validate ALL before mutating ANY).
1001
+ if (hasPosX && (!Number.isFinite(snapshot.posX) || !Number.isFinite(snapshot.posY))) {
1002
+ const e = new Error("CinematicCameraPro: setState posX/posY must be finite numbers");
1003
+ e.code = "ERR_CAMERA_STATE";
1004
+ throw e;
1005
+ }
1006
+ if (hasTargetX && (!Number.isFinite(snapshot.targetX) || !Number.isFinite(snapshot.targetY))) {
1007
+ const e = new Error("CinematicCameraPro: setState targetX/targetY must be finite numbers");
1008
+ e.code = "ERR_CAMERA_STATE";
1009
+ throw e;
1010
+ }
1011
+ if (hasZoom && !Number.isFinite(snapshot.zoom)) {
1012
+ const e = new Error("CinematicCameraPro: setState zoom must be a finite number");
1013
+ e.code = "ERR_CAMERA_STATE";
1014
+ throw e;
1015
+ }
1016
+ if (hasMode && (!Number.isInteger(snapshot.mode) || snapshot.mode < 0 || snapshot.mode >= FOLLOW_STRATEGIES.length)) {
1017
+ const e = new Error("CinematicCameraPro: setState mode must be an integer FollowMode in [0, " + (FOLLOW_STRATEGIES.length - 1) + "]");
1018
+ e.code = "ERR_CAMERA_STATE";
1019
+ throw e;
1020
+ }
1021
+
1022
+ // All validated -> apply. zoom takes the same clamp as setZoom.
1023
+ if (hasPosX) {
869
1024
  this.pos[0] = snapshot.posX;
870
1025
  this.pos[1] = snapshot.posY;
871
1026
  }
872
- if (snapshot.targetX !== undefined) {
1027
+ if (hasTargetX) {
873
1028
  this.target[0] = snapshot.targetX;
874
1029
  this.target[1] = snapshot.targetY;
875
1030
  }
876
- if (snapshot.zoom !== undefined) this.zoom = snapshot.zoom;
877
- if (snapshot.mode !== undefined) this.mode = snapshot.mode;
1031
+ if (hasZoom) this.zoom = clamp(snapshot.zoom, this.minZoom, this.maxZoom);
1032
+ if (hasMode) this.mode = snapshot.mode;
878
1033
  this._updateBoundsForZoom();
879
1034
  return this;
880
1035
  }
@@ -15,6 +15,14 @@
15
15
  *
16
16
  * Mutates cam.target[], cam.zoom directly. Zero allocations.
17
17
  *
18
+ * Count contract (caller-owned, standalone ./multi callers included): count
19
+ * must satisfy 0 <= count <= targets.length and every targets[0..count-1] must
20
+ * be an object with finite x/y. This loop reads targets[0..count-1] without
21
+ * per-frame validation (zero-GC hot path) -- an out-of-range count or a garbage
22
+ * entry is undefined behavior here. The CinematicCameraPro facade enforces the
23
+ * contract at its trackMultiple/setTargetCount doors (ERR_CAMERA_TARGETS);
24
+ * direct callers of this function own that guarantee themselves.
25
+ *
18
26
  * @param {CinematicCameraPro} cam The camera instance
19
27
  * @param {number} dt Delta time in seconds
20
28
  * @param {{x:number,y:number}[]} targets Array of target objects
package/src/Shake.d.ts CHANGED
@@ -77,6 +77,12 @@ export declare const RUMBLE: Readonly<ShakeProfile>;
77
77
  export declare const HEAVY_IMPACT: Readonly<ShakeProfile>;
78
78
 
79
79
  // -- Preset registry (ShakePresets.js) --
80
+ /** Case-insensitive lookup. Unknown or non-string name returns null (1.2.0). */
80
81
  export declare function getPreset(name: string): Readonly<ShakeProfile> | null;
82
+ /**
83
+ * Register (or overwrite) a named preset. Setup path: fails loud.
84
+ * @throws Error with code "ERR_SHAKE_PRESET" if name is not a non-empty
85
+ * string or profile is not a non-null object (1.2.0).
86
+ */
81
87
  export declare function registerPreset(name: string, profile: ShakeProfile): void;
82
88
  export declare function listPresets(): string[];
@@ -129,16 +129,34 @@ function acquireSlot(state) {
129
129
  * @param {number} [profile.intensity=1] Scale multiplier for the profile
130
130
  */
131
131
  export function addShake(state, profile, intensity = 1) {
132
- // CP-14 + H-F (fail closed): validate trauma/intensity in this COLD entry so
133
- // the per-frame updateShake/computeShake loops gain zero new branches.
134
- // - trauma undefined -> 0.5 (documented default)
135
- // - trauma or intensity non-finite (NaN/Inf) -> activate NOTHING, return
136
- // early. The old `profile.trauma || 0.5` laundered NaN to 0.5, opening a
137
- // poison door: a single NaN shake would drive every later frame to NaN.
138
- // null is not zero; an unverified number does not get a default.
132
+ // CP-14 + CP-3 + H-F (fail closed): validate the WHOLE profile in this COLD
133
+ // entry so the per-frame updateShake/computeShake loops gain zero new
134
+ // branches. Every numeric is resolved to its documented default FIRST (the
135
+ // `!== undefined ? : default` form -- including dirX/dirY, replacing the old
136
+ // `|| 0` that laundered a NaN direction to 0), then one combined finiteness
137
+ // check activates NOTHING and returns BEFORE acquireSlot on any failure:
138
+ // - trauma undefined -> 0.5; a non-finite trauma/intensity fires nothing.
139
+ // The old `profile.trauma || 0.5` laundered NaN to 0.5, a poison door.
140
+ // - decay/freq/maxOffset/maxAngle/dirX/dirY non-finite -> reject too. A
141
+ // NaN decay would leave the slot's trauma <= 0 test false forever, so
142
+ // the slot never deactivates (CP-3 via a poisoned profile).
143
+ // - null is not zero; an unverified number does not get a default.
139
144
  // - resulting trauma <= 0 -> inert (a zero-trauma shake fires nothing).
145
+ // Valid, all-finite profiles resolve to the SAME slot values as before --
146
+ // only the ORDER of the default resolution moved (H-A).
140
147
  const rawTrauma = profile.trauma === undefined ? 0.5 : profile.trauma;
141
- if (!Number.isFinite(rawTrauma) || !Number.isFinite(intensity)) return;
148
+ const decay = profile.decay !== undefined ? profile.decay : 1.0;
149
+ const freq = profile.freq !== undefined ? profile.freq : 15;
150
+ const maxOffset = profile.maxOffset !== undefined ? profile.maxOffset : 15;
151
+ const maxAngle = profile.maxAngle !== undefined ? profile.maxAngle : 0.05;
152
+ const dirX = profile.dirX !== undefined ? profile.dirX : 0;
153
+ const dirY = profile.dirY !== undefined ? profile.dirY : 0;
154
+
155
+ if (!Number.isFinite(rawTrauma) || !Number.isFinite(intensity) ||
156
+ !Number.isFinite(decay) || !Number.isFinite(freq) ||
157
+ !Number.isFinite(maxOffset) || !Number.isFinite(maxAngle) ||
158
+ !Number.isFinite(dirX) || !Number.isFinite(dirY)) return;
159
+
142
160
  const trauma = Math.min(1, rawTrauma * intensity);
143
161
  if (trauma <= 0) return;
144
162
 
@@ -147,22 +165,20 @@ export function addShake(state, profile, intensity = 1) {
147
165
  slot.active = true;
148
166
  slot.isDefault = false;
149
167
  slot.trauma = trauma;
150
- slot.decay = profile.decay !== undefined ? profile.decay : 1.0;
151
- slot.freq = profile.freq !== undefined ? profile.freq : 15;
152
- slot.maxOffset = profile.maxOffset !== undefined ? profile.maxOffset : 15;
153
- slot.maxAngle = profile.maxAngle !== undefined ? profile.maxAngle : 0.05;
168
+ slot.decay = decay;
169
+ slot.freq = freq;
170
+ slot.maxOffset = maxOffset;
171
+ slot.maxAngle = maxAngle;
154
172
  slot.time = 0; // reset time for fresh noise sampling
155
173
 
156
- // Directional
157
- const dx = profile.dirX || 0;
158
- const dy = profile.dirY || 0;
159
- slot.isDirectional = (dx !== 0 || dy !== 0);
174
+ // Directional. dirX/dirY are already resolved + finite-checked above.
175
+ slot.isDirectional = (dirX !== 0 || dirY !== 0);
160
176
 
161
177
  if (slot.isDirectional) {
162
178
  // Normalize direction
163
- const len = Math.sqrt(dx * dx + dy * dy);
164
- slot.dirX = dx / len;
165
- slot.dirY = dy / len;
179
+ const len = Math.sqrt(dirX * dirX + dirY * dirY);
180
+ slot.dirX = dirX / len;
181
+ slot.dirY = dirY / len;
166
182
  } else {
167
183
  slot.dirX = 0;
168
184
  slot.dirY = 0;
@@ -220,6 +236,14 @@ export function addTraumaSimple(state, amount) {
220
236
  * @param {number} dt Delta time in seconds
221
237
  */
222
238
  export function updateShake(state, dt) {
239
+ // CP-3 + H-C (fail closed): a non-finite or negative dt is rejected as a
240
+ // no-op in this entry so the per-slot loop below stays branch-for-branch
241
+ // unchanged. A NaN dt would drive s.time/s.trauma to NaN, the trauma <= 0
242
+ // test would never fire, and computeShake would emit NaN forever. No maxDt
243
+ // clamp here: a large finite dt is self-limiting (trauma decays past 0, the
244
+ // slot deactivates in one step). cam.update() hands an already-clamped dt.
245
+ if (!Number.isFinite(dt) || dt < 0) return;
246
+
223
247
  let anyActive = false;
224
248
 
225
249
  for (let i = 0; i < state.slotCount; i++) {
@@ -144,10 +144,15 @@ const _registry = {
144
144
  /**
145
145
  * Get a preset by name.
146
146
  *
147
+ * Fail-closed (CP-12): a non-string name returns null (the event path -- e.g.
148
+ * cam.shakePreset(undefined) -- must not crash on name.toLowerCase()). An
149
+ * unknown string returns null too. Case-insensitive for valid strings.
150
+ *
147
151
  * @param {string} name Preset name (case-insensitive)
148
152
  * @returns {Object|null} Shake profile or null
149
153
  */
150
154
  export function getPreset(name) {
155
+ if (typeof name !== 'string') return null;
151
156
  return _registry[name.toLowerCase()] || null;
152
157
  }
153
158
 
@@ -165,6 +170,16 @@ export function getPreset(name) {
165
170
  * });
166
171
  */
167
172
  export function registerPreset(name, profile) {
173
+ // Fail-closed (CP-12, setup path fails loud): a non-string/empty name or a
174
+ // non-object profile is a defective registration -- reject it rather than
175
+ // poison the registry with a key that getPreset can never resolve or a
176
+ // profile addShake would spread into garbage.
177
+ if (typeof name !== 'string' || name === '' ||
178
+ typeof profile !== 'object' || profile === null) {
179
+ const e = new Error("lite-camera-pro: registerPreset(name, profile) requires a non-empty string name and a profile object");
180
+ e.code = "ERR_SHAKE_PRESET";
181
+ throw e;
182
+ }
168
183
  _registry[name.toLowerCase()] = Object.freeze({ ...profile });
169
184
  }
170
185
 
package/src/index.d.ts CHANGED
@@ -75,14 +75,24 @@ export declare class CinematicCameraPro {
75
75
  predictTime: number;
76
76
  hybridVerticalSnap: boolean;
77
77
 
78
+ /**
79
+ * update() clamps a finite dt above this ceiling before integrating so a
80
+ * frame-time spike cannot diverge the position lerp (default 0.1s). Plain
81
+ * tunable; not validated per frame. See update() for the full dt policy.
82
+ */
83
+ maxDt: number;
84
+
78
85
  debugConfig: DebugHUDConfig;
79
86
 
80
87
  // Follow mode
88
+ /** @throws Error `code = "ERR_CAMERA_MODE"` if mode is not an integer FollowMode in range. */
81
89
  setMode(mode: number): this;
82
90
 
83
91
  // Multi-target
92
+ /** @throws Error `code = "ERR_CAMERA_TARGETS"` if targets is not an array or any entry lacks finite x/y (validated at call time). */
84
93
  trackMultiple(targets: Vec2[], options?: MultiTargetOptions): this;
85
94
  trackSingle(): this;
95
+ /** @throws Error `code = "ERR_CAMERA_TARGETS"` if count is not an integer in [0, targets.length]. */
86
96
  setTargetCount(count: number): this;
87
97
 
88
98
  // Shake
@@ -98,7 +108,9 @@ export declare class CinematicCameraPro {
98
108
  readonly sequencePlaying: boolean;
99
109
 
100
110
  // Zoom
111
+ /** @throws Error `code = "ERR_CAMERA_ZOOM"` if level is non-finite or duration is non-finite/negative. */
101
112
  setZoom(level: number, duration?: number, ease?: (t: number) => number): this;
113
+ /** @throws Error `code = "ERR_CAMERA_ZOOM"` if anchor x/y, level, or duration is non-finite (or duration negative). A non-function ease normalizes to null. */
102
114
  zoomAt(targetOrX: number | Vec2, yOrLevel: number, levelOrDur?: number, duration?: number, ease?: (t: number) => number): this;
103
115
 
104
116
  // Coordinate conversion
@@ -117,6 +129,12 @@ export declare class CinematicCameraPro {
117
129
  clearBoundsRect(): this;
118
130
 
119
131
  // Core
132
+ /**
133
+ * Advance the camera one frame. dt policy (fail closed): a non-finite or
134
+ * negative dt is a no-op (nothing mutated); dt 0/-0 is a legal no-advance
135
+ * frame; a finite dt above maxDt is clamped to maxDt (a dt exactly == maxDt
136
+ * passes untouched).
137
+ */
120
138
  update(dt: number, px: number, py: number, pvx?: number, pvy?: number): void;
121
139
  apply(ctx: CanvasRenderingContext2D): void;
122
140
 
@@ -126,6 +144,15 @@ export declare class CinematicCameraPro {
126
144
 
127
145
  // Save / load
128
146
  getState(): { posX: number; posY: number; targetX: number; targetY: number; zoom: number; mode: number };
147
+ /**
148
+ * Restore a pose-only snapshot (pos/target/zoom/mode). Validated in full
149
+ * before any field is written -- a rejected snapshot mutates nothing.
150
+ * posX/posY and targetX/targetY are both-or-neither; every present numeric
151
+ * must be finite; zoom is clamped to minZoom..maxZoom (zoom 0 -> minZoom);
152
+ * mode must be an integer FollowMode in range. Shake, sequences, and zoom
153
+ * animations are deliberately not serialized.
154
+ * @throws Error `code = "ERR_CAMERA_STATE"` on any violation.
155
+ */
129
156
  setState(snapshot: { posX?: number; posY?: number; targetX?: number; targetY?: number; zoom?: number; mode?: number }): this;
130
157
 
131
158
  // Lifecycle
package/src/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * Zero external dependencies outside the @zakkster ecosystem.
7
7
  */
8
8
 
9
- export const VERSION = "1.1.0";
9
+ export const VERSION = "1.2.0";
10
10
 
11
11
  // -- Core --
12
12
  export { CinematicCameraPro } from './CinematicCameraPro.js';