@zakkster/lite-camera-pro 1.0.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.
@@ -0,0 +1,894 @@
1
+ /**
2
+ * @zakkster/lite-camera-pro — Cinematic Camera System
3
+ *
4
+ * Day 1: Pro scaffold + zoom system
5
+ *
6
+ * Extends CinematicCamera with:
7
+ * - Smooth zoom transitions (duration + easing)
8
+ * - Zoom-at-point (anchor zoom)
9
+ * - Zoom-aware world bounds
10
+ * - Screen ↔ world coordinate conversion
11
+ * - Visible-area–corrected follow centering
12
+ *
13
+ * Depends on: @zakkster/lite-camera, @zakkster/lite-lerp, @zakkster/lite-ease
14
+ * Zero external deps. Pure math. Canvas2D only.
15
+ */
16
+
17
+ import {CinematicCamera} from '@zakkster/lite-camera';
18
+ import {lerp, clamp} from '@zakkster/lite-lerp';
19
+ import {FollowMode, FOLLOW_STRATEGIES} from './FollowMode.js';
20
+ import {updateMultiTarget, createMultiTargetState} from './MultiTarget.js';
21
+ import {
22
+ createShakeState,
23
+ addShake,
24
+ addTraumaSimple,
25
+ updateShake,
26
+ computeShake,
27
+ clearShakes as clearShakeState
28
+ } from './ShakeEngine.js';
29
+ import {getPreset} from './ShakePresets.js';
30
+ import {createCameraSequence} from './CameraSequence.js';
31
+ import {
32
+ createParallaxState,
33
+ addParallaxLayer,
34
+ removeParallaxLayer,
35
+ updateParallax,
36
+ applyParallaxLayer
37
+ } from './ParallaxManager.js';
38
+ import {
39
+ createBoundsState,
40
+ setBoundsAll,
41
+ setBoundsEdges,
42
+ setBoundsRect,
43
+ clearBoundsRect,
44
+ applyBounds,
45
+ BoundsType
46
+ } from './BoundsSystem.js';
47
+ import {createDebugHUDConfig, drawDebugHUD, drawDebugWorld} from './DebugHUD.js';
48
+
49
+ export class CinematicCameraPro extends CinematicCamera {
50
+
51
+ /**
52
+ * @param {number} viewW Viewport width (pixels)
53
+ * @param {number} viewH Viewport height (pixels)
54
+ * @param {number} worldW World width (pixels)
55
+ * @param {number} worldH World height (pixels)
56
+ * @param {number} [seed=42] RNG seed for shake
57
+ */
58
+ constructor(viewW, viewH, worldW, worldH, seed = 42) {
59
+ super(viewW, viewH, worldW, worldH, seed);
60
+
61
+ // ── Zoom state ──
62
+ this.zoom = 1.0;
63
+ this.minZoom = 0.25;
64
+ this.maxZoom = 4.0;
65
+
66
+ // ── Zoom animation ──
67
+ this._zoomFrom = 1.0;
68
+ this._zoomTo = 1.0;
69
+ this._zoomDur = 0; // seconds
70
+ this._zoomElapsed = 0;
71
+ this._zoomEase = null;
72
+
73
+ // ── Zoom anchor (for zoomAt) ──
74
+ // Static anchor coords (for zoomAt with raw coordinates)
75
+ this._zoomAnchorX = 0;
76
+ this._zoomAnchorY = 0;
77
+ // Dynamic anchor target (for zoomAt with a moving object)
78
+ this._zoomTarget = null;
79
+ this._hasAnchor = false;
80
+
81
+ // ── Cached visible dimensions (zero-alloc frustum culling) ──
82
+ this.visibleW = viewW;
83
+ this.visibleH = viewH;
84
+
85
+ // ── Follow mode ──
86
+ this.mode = FollowMode.SMOOTH;
87
+
88
+ // ── Predictive mode config ──
89
+ this.predictTime = 0.3; // seconds of velocity extrapolation
90
+
91
+ // ── Hybrid mode config ──
92
+ this.hybridVerticalSnap = true; // true = instant, false = fast lerp
93
+
94
+ // ── Multi-target framing ──
95
+ this._mt = createMultiTargetState();
96
+
97
+ // ── Advanced shake engine (replaces base RNG shake) ──
98
+ this._shake = createShakeState(seed);
99
+
100
+ // ── Active sequence (null when no sequence is playing) ──
101
+ this._seq = null;
102
+
103
+ // ── Parallax layer manager ──
104
+ this._parallax = createParallaxState();
105
+
106
+ // ── Smart bounds system ──
107
+ this._bounds = createBoundsState();
108
+
109
+ // ── Debug HUD configuration ──
110
+ this.debugConfig = createDebugHUDConfig();
111
+ }
112
+
113
+ // ─────────────────────────────────────────────────────
114
+ // FOLLOW MODE API
115
+ // ─────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Set the follow mode. Switch mid-gameplay without position jumps
119
+ * (except CUT, which jumps by design).
120
+ *
121
+ * @param {number} mode FollowMode enum value
122
+ * @returns {CinematicCameraPro} this
123
+ *
124
+ * @example
125
+ * import { FollowMode } from '@zakkster/lite-camera-pro';
126
+ * camera.setMode(FollowMode.PREDICTIVE);
127
+ */
128
+ setMode(mode) {
129
+ this.mode = mode;
130
+ return this;
131
+ }
132
+
133
+ // ─────────────────────────────────────────────────────
134
+ // MULTI-TARGET FRAMING API
135
+ // ─────────────────────────────────────────────────────
136
+
137
+ /**
138
+ * Track multiple targets. Camera auto-zooms and centers to keep
139
+ * all targets visible within the viewport.
140
+ *
141
+ * While active, the normal follow mode and manual zoom animations
142
+ * are paused — the multi-target system controls position and zoom.
143
+ *
144
+ * @param {{x:number,y:number}[]} targets Array of objects with .x/.y
145
+ * @param {Object} [options]
146
+ * @param {number} [options.paddingX=80] Horizontal padding (world px)
147
+ * @param {number} [options.paddingY=80] Vertical padding (world px)
148
+ * @param {number} [options.minZoom=0.3] Minimum zoom for framing
149
+ * @param {number} [options.maxZoom=2.0] Maximum zoom for framing
150
+ * @param {number} [options.zoomSpeed=4.0] Zoom smoothing (higher = snappier)
151
+ * @param {number} [options.followSpeed=5.0] Position smoothing
152
+ * @returns {CinematicCameraPro} this
153
+ *
154
+ * @example
155
+ * camera.trackMultiple([player1, player2], { paddingX: 100 });
156
+ * // Later, when boss dies:
157
+ * camera.trackSingle();
158
+ */
159
+ trackMultiple(targets, options) {
160
+ const mt = this._mt;
161
+ mt.active = true;
162
+ mt.targets = targets;
163
+ mt.count = targets.length;
164
+
165
+ if (options) {
166
+ if (options.paddingX !== undefined) mt.paddingX = options.paddingX;
167
+ if (options.paddingY !== undefined) mt.paddingY = options.paddingY;
168
+ if (options.padding !== undefined) {
169
+ mt.paddingX = mt.paddingY = options.padding;
170
+ }
171
+ if (options.minZoom !== undefined) mt.minZoom = options.minZoom;
172
+ if (options.maxZoom !== undefined) mt.maxZoom = options.maxZoom;
173
+ if (options.zoomSpeed !== undefined) mt.zoomSpeed = options.zoomSpeed;
174
+ if (options.followSpeed !== undefined) mt.followSpeed = options.followSpeed;
175
+ }
176
+
177
+ return this;
178
+ }
179
+
180
+ /**
181
+ * Stop multi-target tracking. Returns to normal follow mode.
182
+ * The camera smoothly transitions back because the lerp is
183
+ * still running — no jarring cut.
184
+ *
185
+ * @returns {CinematicCameraPro} this
186
+ */
187
+ trackSingle() {
188
+ this._mt.active = false;
189
+ this._mt.targets = null;
190
+ this._mt.count = 0;
191
+ return this;
192
+ }
193
+
194
+ /**
195
+ * Update the number of active targets without re-calling trackMultiple.
196
+ * Useful when targets are added/removed from a fixed-size array.
197
+ *
198
+ * @param {number} count Number of active targets in the array
199
+ * @returns {CinematicCameraPro} this
200
+ */
201
+ setTargetCount(count) {
202
+ this._mt.count = count;
203
+ return this;
204
+ }
205
+
206
+ // ─────────────────────────────────────────────────────
207
+ // SHAKE API (Pro — noise-based, layered)
208
+ // ─────────────────────────────────────────────────────
209
+
210
+ /**
211
+ * Add simple trauma. Backward-compatible with lite-camera.
212
+ * Stacks onto the first active omnidirectional shake slot,
213
+ * or creates a new one with default profile values.
214
+ *
215
+ * @param {number} amount Trauma to add [0, 1]
216
+ * @returns {CinematicCameraPro} this
217
+ *
218
+ * @example
219
+ * camera.addTrauma(0.4);
220
+ */
221
+ addTrauma(amount) {
222
+ addTraumaSimple(this._shake, amount);
223
+ return this;
224
+ }
225
+
226
+ /**
227
+ * Fire a shake impulse with a full profile. Multiple shakes
228
+ * can run simultaneously — they layer (sum) together.
229
+ *
230
+ * @param {Object} profile Shake profile
231
+ * @param {number} profile.trauma Initial trauma [0, 1]
232
+ * @param {number} [profile.freq=15] Noise frequency
233
+ * @param {number} [profile.decay=1] Trauma units lost per second
234
+ * @param {number} [profile.maxOffset=15] Maximum pixel offset
235
+ * @param {number} [profile.maxAngle=0.05] Maximum rotation (radians)
236
+ * @param {number} [profile.dirX=0] Directional X (0 = omni)
237
+ * @param {number} [profile.dirY=0] Directional Y (0 = omni)
238
+ * @param {number} [intensity=1] Scale multiplier
239
+ * @returns {CinematicCameraPro} this
240
+ *
241
+ * @example
242
+ * camera.shake({ trauma: 0.6, freq: 18, maxOffset: 20 });
243
+ */
244
+ shake(profile, intensity = 1) {
245
+ addShake(this._shake, profile, intensity);
246
+ return this;
247
+ }
248
+
249
+ /**
250
+ * Fire a named shake preset.
251
+ *
252
+ * Built-in presets: explosion, earthquake, recoil, impact,
253
+ * landing, damage, rumble, heavy_impact.
254
+ *
255
+ * @param {string} name Preset name (case-insensitive)
256
+ * @param {number} [intensity=1] Scale multiplier
257
+ * @returns {CinematicCameraPro} this
258
+ *
259
+ * @example
260
+ * camera.shakePreset('explosion');
261
+ * camera.shakePreset('recoil', 0.5); // half intensity
262
+ */
263
+ shakePreset(name, intensity = 1) {
264
+ const preset = getPreset(name);
265
+ if (preset) addShake(this._shake, preset, intensity);
266
+ return this;
267
+ }
268
+
269
+ /**
270
+ * Stop all active shakes immediately.
271
+ * @returns {CinematicCameraPro} this
272
+ */
273
+ clearShakes() {
274
+ clearShakeState(this._shake);
275
+ return this;
276
+ }
277
+
278
+ // ─────────────────────────────────────────────────────
279
+ // SEQUENCE API (Pro — cinematic timeline control)
280
+ // ─────────────────────────────────────────────────────
281
+
282
+ /**
283
+ * Create a new camera sequence bound to this camera.
284
+ *
285
+ * @param {Object} [options]
286
+ * @param {boolean} [options.loop=false]
287
+ * @param {Function} [options.onComplete]
288
+ * @param {number} [options.blendOutTime=0.3]
289
+ * @returns {CameraSequence} A fluent sequence builder
290
+ *
291
+ * @example
292
+ * const seq = camera.createSequence()
293
+ * .moveTo(boss.x, boss.y, 1200)
294
+ * .zoomTo(1.8, 800)
295
+ * .shake('explosion')
296
+ * .wait(500)
297
+ * .moveTo(player.x, player.y, 1000);
298
+ *
299
+ * camera.playSequence(seq);
300
+ */
301
+ createSequence(options) {
302
+ return createCameraSequence(this, options);
303
+ }
304
+
305
+ /**
306
+ * Play a camera sequence. While playing, the sequence takes
307
+ * full control of camera position and zoom. Normal follow
308
+ * mode is paused.
309
+ *
310
+ * The camera takes ownership of the sequence. If another sequence is
311
+ * currently attached, it is destroyed (its timeline releases the shared
312
+ * ticker reference) before the new one starts. Do NOT pass a sequence
313
+ * to playSequence again after it has been replaced — call stopSequence()
314
+ * first if you want to re-use it later.
315
+
316
+ * @param {CameraSequence} seq
317
+ * @returns {CinematicCameraPro} this
318
+ *
319
+ * @example
320
+ * camera.playSequence(seq);
321
+ */
322
+ playSequence(seq) {
323
+ // Ownership transfer: the previous sequence is destroyed so its
324
+ // timeline releases the shared ticker. See JSDoc above.
325
+ if (this._seq && this._seq !== seq) {
326
+ this._seq.destroy();
327
+ }
328
+
329
+ this._seq = seq;
330
+ seq.play();
331
+ return this;
332
+ }
333
+
334
+ /**
335
+ * Stop the current sequence and return to follow mode.
336
+ * The transition back is smooth (lerp continues from current pos).
337
+ *
338
+ * @returns {CinematicCameraPro} this
339
+ */
340
+ stopSequence() {
341
+ if (this._seq) {
342
+ this._seq.stop();
343
+ this._seq = null;
344
+ }
345
+ return this;
346
+ }
347
+
348
+ /**
349
+ * Whether a sequence is currently playing.
350
+ * @returns {boolean}
351
+ */
352
+ get sequencePlaying() {
353
+ return this._seq !== null && this._seq.playing;
354
+ }
355
+
356
+ // ─────────────────────────────────────────────────────
357
+ // PARALLAX API
358
+ // ─────────────────────────────────────────────────────
359
+
360
+ /**
361
+ * Add a parallax layer. Speed 1.0 = normal, 0.5 = background, 1.5 = foreground.
362
+ *
363
+ * @param {string} id Unique layer name
364
+ * @param {number} speedX Horizontal scroll multiplier
365
+ * @param {number} [speedY] Vertical (defaults to speedX)
366
+ * @param {Object} [opts] { offsetX, offsetY, wrap }
367
+ * @returns {CinematicCameraPro} this
368
+ *
369
+ * @example
370
+ * camera.addParallaxLayer('sky', 0.1);
371
+ * camera.addParallaxLayer('clouds', 0.3);
372
+ * camera.addParallaxLayer('trees', 0.7, 0.7, { wrap: WrapMode.REPEAT_X });
373
+ */
374
+ addParallaxLayer(id, speedX, speedY, opts) {
375
+ addParallaxLayer(this._parallax, id, speedX, speedY, opts);
376
+ return this;
377
+ }
378
+
379
+ /**
380
+ * Remove a parallax layer.
381
+ * @param {string} id Layer name
382
+ * @returns {CinematicCameraPro} this
383
+ */
384
+ removeParallaxLayer(id) {
385
+ removeParallaxLayer(this._parallax, id);
386
+ return this;
387
+ }
388
+
389
+ /**
390
+ * Apply a parallax layer's transform to a canvas context.
391
+ * Use between ctx.save()/ctx.restore() when drawing that layer.
392
+ *
393
+ * @param {string} id Layer name
394
+ * @param {CanvasRenderingContext2D} ctx
395
+ * @returns {boolean} true if layer was found
396
+ *
397
+ * @example
398
+ * ctx.save();
399
+ * camera.applyParallax('clouds', ctx);
400
+ * drawClouds(ctx);
401
+ * ctx.restore();
402
+ */
403
+ applyParallax(id, ctx) {
404
+ return applyParallaxLayer(this._parallax, id, ctx);
405
+ }
406
+
407
+ // ─────────────────────────────────────────────────────
408
+ // BOUNDS API
409
+ // ─────────────────────────────────────────────────────
410
+
411
+ /**
412
+ * Set boundary behavior for all edges.
413
+ *
414
+ * @param {number} type BoundsType enum (HARD, SOFT, ELASTIC, NONE)
415
+ * @returns {CinematicCameraPro} this
416
+ *
417
+ * @example
418
+ * import { BoundsType } from '@zakkster/lite-camera-pro';
419
+ * camera.setBoundsType(BoundsType.SOFT);
420
+ */
421
+ setBoundsType(type) {
422
+ setBoundsAll(this._bounds, type);
423
+ return this;
424
+ }
425
+
426
+ /**
427
+ * Set boundary behavior per-edge.
428
+ *
429
+ * @param {Object} config { left, right, top, bottom } — BoundsType values
430
+ * @returns {CinematicCameraPro} this
431
+ *
432
+ * @example
433
+ * camera.setBoundsEdges({
434
+ * left: BoundsType.HARD,
435
+ * right: BoundsType.SOFT,
436
+ * top: BoundsType.ELASTIC,
437
+ * bottom: BoundsType.HARD,
438
+ * });
439
+ */
440
+ setBoundsEdges(config) {
441
+ setBoundsEdges(this._bounds, config);
442
+ return this;
443
+ }
444
+
445
+ /**
446
+ * Set a custom bounds rectangle (for room transitions, arenas, etc).
447
+ *
448
+ * @param {number} x
449
+ * @param {number} y
450
+ * @param {number} w
451
+ * @param {number} h
452
+ * @returns {CinematicCameraPro} this
453
+ */
454
+ setBoundsRect(x, y, w, h) {
455
+ setBoundsRect(this._bounds, x, y, w, h);
456
+ return this;
457
+ }
458
+
459
+ /**
460
+ * Clear custom bounds, reverting to full world.
461
+ * @returns {CinematicCameraPro} this
462
+ */
463
+ clearBoundsRect() {
464
+ clearBoundsRect(this._bounds);
465
+ return this;
466
+ }
467
+
468
+ // ─────────────────────────────────────────────────────
469
+ // ZOOM API
470
+ // ─────────────────────────────────────────────────────
471
+
472
+ /**
473
+ * Smoothly transition to a zoom level.
474
+ *
475
+ * @param {number} level Target zoom (clamped to minZoom..maxZoom)
476
+ * @param {number} [duration=0] Transition time in seconds (0 = instant)
477
+ * @param {Function} [ease] Easing function from lite-ease (t => t)
478
+ * @returns {CinematicCameraPro} this
479
+ *
480
+ * @example
481
+ * camera.setZoom(2.0, 0.5, easeOutExpo);
482
+ */
483
+ setZoom(level, duration = 0, ease = null) {
484
+ level = clamp(level, this.minZoom, this.maxZoom);
485
+
486
+ if (duration <= 0) {
487
+ this.zoom = level;
488
+ this._zoomDur = 0;
489
+ this._hasAnchor = false;
490
+ this._updateBoundsForZoom();
491
+ return this;
492
+ }
493
+
494
+ this._zoomFrom = this.zoom;
495
+ this._zoomTo = level;
496
+ this._zoomDur = duration;
497
+ this._zoomElapsed = 0;
498
+ this._zoomEase = ease;
499
+ this._hasAnchor = false;
500
+ return this;
501
+ }
502
+
503
+ /**
504
+ * Zoom toward a world point or a moving target. When a target object
505
+ * is provided, the anchor updates every frame so the zoom tracks it.
506
+ *
507
+ * * Animated form (duration > 0): the follow strategy is paused for the
508
+ * duration of the transition and the camera centers on the anchor.
509
+ * Instant form (duration = 0): the anchor is held at its current screen
510
+ * position before/after the zoom (mouse-wheel-style zoom).
511
+ *
512
+ * @param {number|{x:number,y:number}} targetOrX World X, or object with .x/.y
513
+ * @param {number} yOrLevel World Y (if coordinates) or target zoom (if object)
514
+ * @param {number} [levelOrDur] Target zoom (if coordinates) or duration (if object)
515
+ * @param {number} [duration=0] Transition time in seconds
516
+ * @param {Function} [ease] Easing function
517
+ * @returns {CinematicCameraPro} this
518
+ *
519
+ * @example
520
+ * // Static point
521
+ * camera.zoomAt(400, 300, 1.8, 0.8, easeOutExpo);
522
+ * // Moving target — anchor follows the object each frame
523
+ * camera.zoomAt(boss, 1.8, 0.8, easeOutExpo);
524
+ */
525
+ zoomAt(targetOrX, yOrLevel, levelOrDur, duration = 0, ease = null) {
526
+ let level, dur, easeFn;
527
+
528
+ if (typeof targetOrX === 'object' && targetOrX !== null) {
529
+ // zoomAt(target, level, duration, ease)
530
+ this._zoomTarget = targetOrX;
531
+ this._zoomAnchorX = targetOrX.x;
532
+ this._zoomAnchorY = targetOrX.y;
533
+ level = yOrLevel;
534
+ dur = levelOrDur || 0;
535
+ easeFn = duration; // shifted arg position — duration slot holds ease
536
+ if (typeof easeFn !== 'function') easeFn = null;
537
+ } else {
538
+ // zoomAt(x, y, level, duration, ease)
539
+ this._zoomTarget = null;
540
+ this._zoomAnchorX = targetOrX;
541
+ this._zoomAnchorY = yOrLevel;
542
+ level = levelOrDur;
543
+ dur = duration;
544
+ easeFn = ease;
545
+ }
546
+
547
+ this._hasAnchor = true;
548
+ level = clamp(level, this.minZoom, this.maxZoom);
549
+
550
+ if (dur <= 0) {
551
+ const prevZoom = this.zoom;
552
+ this.zoom = level;
553
+ this._zoomDur = 0;
554
+ this._adjustForAnchor(prevZoom, this.zoom);
555
+ this._hasAnchor = false;
556
+ this._zoomTarget = null;
557
+ this._updateBoundsForZoom();
558
+ return this;
559
+ }
560
+
561
+ this._zoomFrom = this.zoom;
562
+ this._zoomTo = level;
563
+ this._zoomDur = dur;
564
+ this._zoomElapsed = 0;
565
+ this._zoomEase = easeFn;
566
+ return this;
567
+ }
568
+
569
+ // ─────────────────────────────────────────────────────
570
+ // COORDINATE CONVERSION (zero-alloc: caller provides out)
571
+ // ─────────────────────────────────────────────────────
572
+
573
+ /**
574
+ * Convert screen pixel to world coordinate. Zero-alloc.
575
+ *
576
+ * @param {number} sx Screen X
577
+ * @param {number} sy Screen Y
578
+ * @param {{x:number,y:number}} out Pre-allocated output target (mutated)
579
+ * @returns {{x:number,y:number}} The mutated out target
580
+ *
581
+ * @example
582
+ * const pt = { x: 0, y: 0 }; // allocate once
583
+ * camera.screenToWorld(mouseX, mouseY, pt);
584
+ */
585
+ screenToWorld(sx, sy, out) {
586
+ out.x = this.pos[0] + sx / this.zoom;
587
+ out.y = this.pos[1] + sy / this.zoom;
588
+ return out;
589
+ }
590
+
591
+ /**
592
+ * Convert world coordinate to screen pixel. Zero-alloc.
593
+ *
594
+ * @param {number} wx World X
595
+ * @param {number} wy World Y
596
+ * @param {{x:number,y:number}} out Pre-allocated output target (mutated)
597
+ * @returns {{x:number,y:number}} The mutated out target
598
+ */
599
+ worldToScreen(wx, wy, out) {
600
+ out.x = (wx - this.pos[0]) * this.zoom;
601
+ out.y = (wy - this.pos[1]) * this.zoom;
602
+ return out;
603
+ }
604
+
605
+ // ─────────────────────────────────────────────────────
606
+ // INTERNAL: Zoom helpers
607
+ // ─────────────────────────────────────────────────────
608
+
609
+ /** Recalculate world-edge clamp bounds and cached visible dimensions for current zoom. */
610
+ _updateBoundsForZoom() {
611
+ this.visibleW = this.viewW / this.zoom;
612
+ this.visibleH = this.viewH / this.zoom;
613
+ this._maxX = this.worldW - this.visibleW;
614
+ this._maxY = this.worldH - this.visibleH;
615
+ if (this._maxX < 0) this._maxX = 0;
616
+ if (this._maxY < 0) this._maxY = 0;
617
+ }
618
+
619
+ /**
620
+ * Adjust camera position so the anchor point stays at the same
621
+ * screen location after a zoom change.
622
+ *
623
+ * Math: screenPos = (worldPos - camPos) * zoom
624
+ * We want screenPos_old === screenPos_new
625
+ * (wx - oldCam) * oldZ = (wx - newCam) * newZ
626
+ * newCam = wx - (wx - oldCam) * oldZ / newZ
627
+ */
628
+ _adjustForAnchor(oldZoom, newZoom) {
629
+ if (!this._hasAnchor || newZoom === 0) return;
630
+
631
+ const wx = this._zoomAnchorX;
632
+ const wy = this._zoomAnchorY;
633
+ const ratio = oldZoom / newZoom;
634
+
635
+ const newX = wx - (wx - this.pos[0]) * ratio;
636
+ const newY = wy - (wy - this.pos[1]) * ratio;
637
+
638
+ this.pos[0] = newX;
639
+ this.pos[1] = newY;
640
+ this.target[0] = newX;
641
+ this.target[1] = newY;
642
+ }
643
+
644
+ // ─────────────────────────────────────────────────────
645
+ // UPDATE (overrides base — adds zoom + corrected centering)
646
+ // ─────────────────────────────────────────────────────
647
+
648
+ /**
649
+ * Advance the camera by one frame.
650
+ *
651
+ * @param {number} dt Delta time in seconds
652
+ * @param {number} px Player world X
653
+ * @param {number} py Player world Y
654
+ * @param {number} [pvx=0] Player velocity X (for lookahead)
655
+ * @param {number} [pvy=0] Player velocity Y (for lookahead)
656
+ */
657
+ update(dt, px, py, pvx = 0, pvy = 0) {
658
+
659
+ const mt = this._mt;
660
+ const seq = this._seq;
661
+
662
+ if (seq && seq._state.active) {
663
+ // ── SEQUENCE PATH ──
664
+ // Sequence controls position and zoom via timeline.
665
+ // Timeline advances itself via its own ticker (RAF).
666
+ // We just read the animated state and apply it.
667
+ const st = seq._state;
668
+
669
+ this.zoom = clamp(st.zoom, this.minZoom, this.maxZoom);
670
+ this._updateBoundsForZoom();
671
+
672
+ // Sequence stores world CENTER — convert to top-left for camera
673
+ this.target[0] = st.x - this.visibleW * 0.5;
674
+ this.target[1] = st.y - this.visibleH * 0.5;
675
+
676
+ // Zero out lookahead so returning to follow doesn't jerk
677
+ this.look[0] = 0;
678
+ this.look[1] = 0;
679
+
680
+ } else if (mt.active && mt.targets && mt.count > 0) {
681
+ // ── MULTI-TARGET PATH ──
682
+ updateMultiTarget(this, dt, mt.targets, mt.count);
683
+
684
+ // Clean up finished sequence ref
685
+ if (seq && !seq.playing) this._seq = null;
686
+
687
+ } else {
688
+ // ── SINGLE-TARGET PATH ──
689
+
690
+ // Clean up finished sequence ref
691
+ if (seq && !seq.playing) this._seq = null;
692
+
693
+ // ── 1. Advance zoom animation ──
694
+ const prevZoom = this.zoom;
695
+
696
+ if (this._zoomDur > 0) {
697
+ this._zoomElapsed += dt;
698
+
699
+ let t = clamp(this._zoomElapsed / this._zoomDur, 0, 1);
700
+ if (this._zoomEase) t = this._zoomEase(t);
701
+
702
+ this.zoom = lerp(this._zoomFrom, this._zoomTo, t);
703
+
704
+ if (this._zoomElapsed >= this._zoomDur) {
705
+ this.zoom = this._zoomTo;
706
+ this._zoomDur = 0;
707
+ }
708
+ }
709
+
710
+ // ── 2. Recalculate bounds for current zoom ──
711
+ this._updateBoundsForZoom();
712
+
713
+ // ── 3–4. Anchored zoom OR follow strategy (mutually exclusive) ──
714
+ if (this._hasAnchor) {
715
+ // Track moving anchor each frame
716
+ if (this._zoomTarget) {
717
+ this._zoomAnchorX = this._zoomTarget.x;
718
+ this._zoomAnchorY = this._zoomTarget.y;
719
+ }
720
+ // Center on the anchor — strategy is paused during the transition.
721
+ // pos lerps toward this in step 6, giving a smooth pan-and-zoom.
722
+ this.target[0] = this._zoomAnchorX - this.visibleW * 0.5;
723
+ this.target[1] = this._zoomAnchorY - this.visibleH * 0.5;
724
+
725
+ if (this._zoomDur <= 0) {
726
+ this._hasAnchor = false;
727
+ this._zoomTarget = null;
728
+ }
729
+ } else {
730
+ const strategy = FOLLOW_STRATEGIES[this.mode];
731
+ strategy(this, dt, px, py, pvx, pvy);
732
+ }
733
+ }
734
+
735
+ // ── 5. Apply boundary enforcement (all paths) ──
736
+ applyBounds(
737
+ this._bounds, this.target, this.pos,
738
+ this._maxX, this._maxY,
739
+ this.visibleW, this.visibleH, dt
740
+ );
741
+
742
+ // ── 6. Position update ──
743
+ // Sequences and multi-target each manage their own smoothing:
744
+ // - Sequence: timeline is authoritative — pos must land exactly on
745
+ // scripted beats (e.g. bossReveal must frame the boss precisely).
746
+ // - Multi-target: updateMultiTarget already lerps target by mt.followSpeed;
747
+ // a second lerp here would compound damping unpredictably.
748
+ if ((seq && seq._state.active) || (mt.active && mt.targets && mt.count > 0)) {
749
+ this.pos[0] = this.target[0];
750
+ this.pos[1] = this.target[1];
751
+ } else {
752
+ this.pos[0] += (this.target[0] - this.pos[0]) * this.lerpSpeed * dt;
753
+ this.pos[1] += (this.target[1] - this.pos[1]) * this.lerpSpeed * dt;
754
+ }
755
+ // ── 7. Parallax layer update (all paths) ──
756
+ if (this._parallax.activeCount > 0) {
757
+ updateParallax(this._parallax, this.pos[0], this.pos[1], this.zoom);
758
+ }
759
+
760
+ // ── 8. Shake update (all paths) ──
761
+ updateShake(this._shake, dt);
762
+ }
763
+
764
+ // ─────────────────────────────────────────────────────
765
+ // APPLY (overrides base — adds zoom transform)
766
+ // ─────────────────────────────────────────────────────
767
+
768
+ /**
769
+ * Apply camera transform to a canvas 2D context.
770
+ * IMPORTANT: Caller must ctx.save() before and ctx.restore() after.
771
+ *
772
+ * Transform order:
773
+ * 1. Screen-space shake offset
774
+ * 2. Move origin to screen center
775
+ * 3. Shake rotation
776
+ * 4. Scale by zoom
777
+ * 5. Move origin back (adjusted for zoom)
778
+ * 6. Translate by camera position
779
+ *
780
+ * @param {CanvasRenderingContext2D} ctx
781
+ */
782
+ apply(ctx) {
783
+ let offsetX = 0, offsetY = 0, angle = 0;
784
+
785
+ if (this._shake.active) {
786
+ computeShake(this._shake);
787
+ offsetX = this._shake.offsetX;
788
+ offsetY = this._shake.offsetY;
789
+ angle = this._shake.angle;
790
+ }
791
+
792
+ // 1. Shake offset (screen-space, unaffected by zoom)
793
+ ctx.translate(offsetX, offsetY);
794
+
795
+ // 2–5. Zoom + rotation from screen center
796
+ ctx.translate(this._halfW, this._halfH);
797
+ ctx.rotate(angle);
798
+ ctx.scale(this.zoom, this.zoom);
799
+ ctx.translate(-this._halfW / this.zoom, -this._halfH / this.zoom);
800
+
801
+ // 6. Scroll to camera world position
802
+ ctx.translate(-(this.pos[0] | 0), -(this.pos[1] | 0));
803
+ }
804
+
805
+ // ─────────────────────────────────────────────────────
806
+ // DEBUG (overrides base — adds zoom readout)
807
+ // ─────────────────────────────────────────────────────
808
+
809
+ /**
810
+ * Draw world-space debug overlay (deadzone, lookahead, world bounds).
811
+ * Call AFTER apply() so it renders in camera-transformed space.
812
+ *
813
+ * @param {CanvasRenderingContext2D} ctx
814
+ */
815
+ debug(ctx) {
816
+ drawDebugWorld(this, ctx, this.debugConfig);
817
+ }
818
+
819
+ /**
820
+ * Draw screen-space HUD (all camera state).
821
+ * Call OUTSIDE of save/apply/restore, directly on raw canvas.
822
+ *
823
+ * Toggle panels: camera.debugConfig.show.shake = false;
824
+ *
825
+ * @param {CanvasRenderingContext2D} ctx
826
+ */
827
+ debugHUD(ctx) {
828
+ drawDebugHUD(this, ctx, this.debugConfig);
829
+ }
830
+
831
+ // ─────────────────────────────────────────────────────
832
+ // SAVE / LOAD
833
+ // ─────────────────────────────────────────────────────
834
+
835
+ /**
836
+ * Get a serializable snapshot of camera state for save/load.
837
+ * @returns {Object}
838
+ */
839
+ getState() {
840
+ return {
841
+ posX: this.pos[0],
842
+ posY: this.pos[1],
843
+ targetX: this.target[0],
844
+ targetY: this.target[1],
845
+ zoom: this.zoom,
846
+ mode: this.mode,
847
+ };
848
+ }
849
+
850
+ /**
851
+ * Restore camera state from a snapshot.
852
+ * @param {Object} snapshot
853
+ * @returns {CinematicCameraPro} this
854
+ */
855
+ setState(snapshot) {
856
+ if (snapshot.posX !== undefined) {
857
+ this.pos[0] = snapshot.posX;
858
+ this.pos[1] = snapshot.posY;
859
+ }
860
+ if (snapshot.targetX !== undefined) {
861
+ this.target[0] = snapshot.targetX;
862
+ this.target[1] = snapshot.targetY;
863
+ }
864
+ if (snapshot.zoom !== undefined) this.zoom = snapshot.zoom;
865
+ if (snapshot.mode !== undefined) this.mode = snapshot.mode;
866
+ this._updateBoundsForZoom();
867
+ return this;
868
+ }
869
+
870
+ /**
871
+ * Destroy the camera. Releases sequences, clears shake state, and
872
+ * nulls all nested allocations so the GC can reclaim them. After
873
+ * destroy(), the camera is unusable — do not call any further methods.
874
+ */
875
+ destroy() {
876
+ if (this._seq) {
877
+ this._seq.destroy();
878
+ this._seq = null;
879
+ }
880
+
881
+ clearShakeState(this._shake);
882
+
883
+ // Release nested state so the slot pools / layer arrays can be GC'd
884
+ this._shake = null;
885
+ this._mt = null;
886
+ this._parallax = null;
887
+ this._bounds = null;
888
+ this.debugConfig = null;
889
+ this.pos = this.target = this.look = null;
890
+ this.rng = null;
891
+ }
892
+ }
893
+
894
+ export default CinematicCameraPro;