@zakkster/lite-camera-pro 1.0.1 → 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 +124 -0
- package/README.md +7 -0
- package/llms.txt +53 -1
- package/package.json +44 -5
- package/src/BoundsSystem.d.ts +54 -0
- package/src/CameraSequence.d.ts +75 -0
- package/src/CinematicCameraPro.js +169 -14
- package/src/FollowMode.d.ts +25 -0
- package/src/MultiTarget.d.ts +49 -0
- package/src/MultiTarget.js +8 -0
- package/src/ParallaxManager.d.ts +60 -0
- package/src/Shake.d.ts +88 -0
- package/src/Shake.js +7 -0
- package/src/ShakeEngine.js +43 -19
- package/src/ShakePresets.js +15 -0
- package/src/index.d.ts +64 -130
- package/src/index.js +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
609
|
+
target = targetOrX;
|
|
610
|
+
anchorX = targetOrX.x;
|
|
611
|
+
anchorY = targetOrX.y;
|
|
543
612
|
level = yOrLevel;
|
|
544
|
-
dur = levelOrDur
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
|
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 (
|
|
1027
|
+
if (hasTargetX) {
|
|
873
1028
|
this.target[0] = snapshot.targetX;
|
|
874
1029
|
this.target[1] = snapshot.targetY;
|
|
875
1030
|
}
|
|
876
|
-
if (
|
|
877
|
-
if (
|
|
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
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-camera-pro/follow -- TypeScript declarations.
|
|
3
|
+
*
|
|
4
|
+
* Follow-mode enum and the strategy dispatch table. Complete runtime surface,
|
|
5
|
+
* no `any`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CinematicCameraPro } from './index.js';
|
|
9
|
+
|
|
10
|
+
// -- Follow modes --
|
|
11
|
+
export declare const FollowMode: {
|
|
12
|
+
readonly SMOOTH: 0;
|
|
13
|
+
readonly LOCK: 1;
|
|
14
|
+
readonly PREDICTIVE: 2;
|
|
15
|
+
readonly CUT: 3;
|
|
16
|
+
readonly HYBRID: 4;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// -- Strategy dispatch table, indexed by FollowMode value --
|
|
20
|
+
export declare const FOLLOW_STRATEGIES: ReadonlyArray<
|
|
21
|
+
(cam: CinematicCameraPro, dt: number, px: number, py: number, pvx: number, pvy: number) => void
|
|
22
|
+
>;
|
|
23
|
+
|
|
24
|
+
declare const _default: typeof FollowMode;
|
|
25
|
+
export default _default;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-camera-pro/multi -- TypeScript declarations.
|
|
3
|
+
*
|
|
4
|
+
* Multi-target framing: fit several targets in view. Pure math, no
|
|
5
|
+
* dependencies. Complete runtime surface, no `any`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CinematicCameraPro } from './index.js';
|
|
9
|
+
|
|
10
|
+
// -- A 2D point (target position) --
|
|
11
|
+
export interface Vec2 {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// -- Multi-target config/state (one per camera) --
|
|
17
|
+
export interface MultiTargetState {
|
|
18
|
+
active: boolean;
|
|
19
|
+
targets: Vec2[] | null;
|
|
20
|
+
count: number;
|
|
21
|
+
paddingX: number;
|
|
22
|
+
paddingY: number;
|
|
23
|
+
minZoom: number;
|
|
24
|
+
maxZoom: number;
|
|
25
|
+
zoomSpeed: number;
|
|
26
|
+
followSpeed: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// -- Options accepted by CinematicCameraPro.trackMultiple --
|
|
30
|
+
export interface MultiTargetOptions {
|
|
31
|
+
paddingX?: number;
|
|
32
|
+
paddingY?: number;
|
|
33
|
+
padding?: number;
|
|
34
|
+
minZoom?: number;
|
|
35
|
+
maxZoom?: number;
|
|
36
|
+
zoomSpeed?: number;
|
|
37
|
+
followSpeed?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export declare function updateMultiTarget(
|
|
41
|
+
cam: CinematicCameraPro,
|
|
42
|
+
dt: number,
|
|
43
|
+
targets: Vec2[],
|
|
44
|
+
count: number,
|
|
45
|
+
): void;
|
|
46
|
+
export declare function createMultiTargetState(): MultiTargetState;
|
|
47
|
+
|
|
48
|
+
declare const _default: typeof updateMultiTarget;
|
|
49
|
+
export default _default;
|
package/src/MultiTarget.js
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-camera-pro/parallax -- TypeScript declarations.
|
|
3
|
+
*
|
|
4
|
+
* Multi-layer scroll manager. Pure math, no dependencies. Complete runtime
|
|
5
|
+
* surface, no `any`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// -- Wrap modes for layer tiling --
|
|
9
|
+
export declare const WrapMode: {
|
|
10
|
+
readonly NONE: 0;
|
|
11
|
+
readonly REPEAT_X: 1;
|
|
12
|
+
readonly REPEAT_Y: 2;
|
|
13
|
+
readonly REPEAT_BOTH: 3;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// -- A single pre-allocated parallax layer --
|
|
17
|
+
export interface ParallaxLayer {
|
|
18
|
+
active: boolean;
|
|
19
|
+
id: string;
|
|
20
|
+
speedX: number;
|
|
21
|
+
speedY: number;
|
|
22
|
+
offsetX: number;
|
|
23
|
+
offsetY: number;
|
|
24
|
+
wrap: number;
|
|
25
|
+
scrollX: number;
|
|
26
|
+
scrollY: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// -- Parallax manager state (one per camera) --
|
|
30
|
+
export interface ParallaxState {
|
|
31
|
+
layers: ParallaxLayer[];
|
|
32
|
+
layerCount: number;
|
|
33
|
+
activeCount: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// -- Options accepted by addParallaxLayer --
|
|
37
|
+
export interface ParallaxLayerOptions {
|
|
38
|
+
offsetX?: number;
|
|
39
|
+
offsetY?: number;
|
|
40
|
+
wrap?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// -- Output shape for getLayerScroll --
|
|
44
|
+
export interface ScrollOut {
|
|
45
|
+
x: number;
|
|
46
|
+
y: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export declare function createParallaxState(): ParallaxState;
|
|
50
|
+
export declare function addParallaxLayer(
|
|
51
|
+
state: ParallaxState,
|
|
52
|
+
id: string,
|
|
53
|
+
speedX: number,
|
|
54
|
+
speedY?: number,
|
|
55
|
+
opts?: ParallaxLayerOptions,
|
|
56
|
+
): ParallaxLayer | null;
|
|
57
|
+
export declare function removeParallaxLayer(state: ParallaxState, id: string): void;
|
|
58
|
+
export declare function updateParallax(state: ParallaxState, camX: number, camY: number, zoom: number): void;
|
|
59
|
+
export declare function getLayerScroll(state: ParallaxState, id: string, out: ScrollOut): ScrollOut | null;
|
|
60
|
+
export declare function applyParallaxLayer(state: ParallaxState, id: string, ctx: CanvasRenderingContext2D): boolean;
|
package/src/Shake.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-camera-pro/shake -- TypeScript declarations.
|
|
3
|
+
*
|
|
4
|
+
* The ./shake subpath barrel: the noise-based shake engine (ShakeEngine.js)
|
|
5
|
+
* plus the built-in presets and registry (ShakePresets.js). Complete runtime
|
|
6
|
+
* surface, no `any`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// -- Shake profile (input) --
|
|
10
|
+
export interface ShakeProfile {
|
|
11
|
+
/** Initial trauma [0, 1]. Undefined defaults to 0.5 in addShake. */
|
|
12
|
+
trauma?: number;
|
|
13
|
+
/** Noise sample frequency (higher = more jittery). Default 15. */
|
|
14
|
+
freq?: number;
|
|
15
|
+
/** Trauma units lost per second. Default 1. */
|
|
16
|
+
decay?: number;
|
|
17
|
+
/** Maximum pixel offset at trauma=1. Default 15. */
|
|
18
|
+
maxOffset?: number;
|
|
19
|
+
/** Maximum rotation (radians) at trauma=1. Default 0.05. */
|
|
20
|
+
maxAngle?: number;
|
|
21
|
+
/** Directional X component; (0,0) = omnidirectional. Default 0. */
|
|
22
|
+
dirX?: number;
|
|
23
|
+
/** Directional Y component. Default 0. */
|
|
24
|
+
dirY?: number;
|
|
25
|
+
/** Per-profile intensity multiplier used by addShake. Default 1. */
|
|
26
|
+
intensity?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// -- A single pre-allocated shake slot --
|
|
30
|
+
export interface ShakeSlot {
|
|
31
|
+
active: boolean;
|
|
32
|
+
isDefault: boolean;
|
|
33
|
+
trauma: number;
|
|
34
|
+
decay: number;
|
|
35
|
+
freq: number;
|
|
36
|
+
time: number;
|
|
37
|
+
maxOffset: number;
|
|
38
|
+
maxAngle: number;
|
|
39
|
+
dirX: number;
|
|
40
|
+
dirY: number;
|
|
41
|
+
isDirectional: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// -- Shake engine state (one per camera) --
|
|
45
|
+
export interface ShakeState {
|
|
46
|
+
slots: ShakeSlot[];
|
|
47
|
+
slotCount: number;
|
|
48
|
+
seedOffset: number;
|
|
49
|
+
/** Computed X offset in pixels (read by apply()). */
|
|
50
|
+
offsetX: number;
|
|
51
|
+
/** Computed Y offset in pixels. */
|
|
52
|
+
offsetY: number;
|
|
53
|
+
/** Computed rotation in radians. */
|
|
54
|
+
angle: number;
|
|
55
|
+
/** Global shake scale (0 = no shake, 1 = normal). */
|
|
56
|
+
globalScale: number;
|
|
57
|
+
/** True when any slot is active. */
|
|
58
|
+
active: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// -- Shake engine functions (ShakeEngine.js) --
|
|
62
|
+
export declare function createShakeState(seedOffset?: number): ShakeState;
|
|
63
|
+
export declare function addShake(state: ShakeState, profile: ShakeProfile, intensity?: number): void;
|
|
64
|
+
export declare function addTraumaSimple(state: ShakeState, amount: number): void;
|
|
65
|
+
export declare function updateShake(state: ShakeState, dt: number): void;
|
|
66
|
+
export declare function computeShake(state: ShakeState): void;
|
|
67
|
+
export declare function clearShakes(state: ShakeState): void;
|
|
68
|
+
|
|
69
|
+
// -- Built-in presets (ShakePresets.js) --
|
|
70
|
+
export declare const EXPLOSION: Readonly<ShakeProfile>;
|
|
71
|
+
export declare const EARTHQUAKE: Readonly<ShakeProfile>;
|
|
72
|
+
export declare const RECOIL: Readonly<ShakeProfile>;
|
|
73
|
+
export declare const IMPACT: Readonly<ShakeProfile>;
|
|
74
|
+
export declare const LANDING: Readonly<ShakeProfile>;
|
|
75
|
+
export declare const DAMAGE: Readonly<ShakeProfile>;
|
|
76
|
+
export declare const RUMBLE: Readonly<ShakeProfile>;
|
|
77
|
+
export declare const HEAVY_IMPACT: Readonly<ShakeProfile>;
|
|
78
|
+
|
|
79
|
+
// -- Preset registry (ShakePresets.js) --
|
|
80
|
+
/** Case-insensitive lookup. Unknown or non-string name returns null (1.2.0). */
|
|
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
|
+
*/
|
|
87
|
+
export declare function registerPreset(name: string, profile: ShakeProfile): void;
|
|
88
|
+
export declare function listPresets(): string[];
|
package/src/Shake.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// @zakkster/lite-camera-pro -- the ./shake subpath barrel.
|
|
2
|
+
// Re-export only; never a copy. The camera class and this subpath import the
|
|
3
|
+
// SAME module files, so createShakeState et al. have one runtime identity.
|
|
4
|
+
// ShakePresets is folded in so a shake-only consumer gets presets + getPreset
|
|
5
|
+
// from a single import.
|
|
6
|
+
export * from './ShakeEngine.js';
|
|
7
|
+
export * from './ShakePresets.js';
|
package/src/ShakeEngine.js
CHANGED
|
@@ -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
|
|
133
|
-
// the per-frame updateShake/computeShake loops gain zero new
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
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
|
-
|
|
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 =
|
|
151
|
-
slot.freq =
|
|
152
|
-
slot.maxOffset =
|
|
153
|
-
slot.maxAngle =
|
|
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
|
-
|
|
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(
|
|
164
|
-
slot.dirX =
|
|
165
|
-
slot.dirY =
|
|
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++) {
|
package/src/ShakePresets.js
CHANGED
|
@@ -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
|
|