@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,513 @@
1
+ /**
2
+ * @zakkster/lite-camera-pro — Camera Sequence System
3
+ *
4
+ * Fluent builder for scripted camera cinematics.
5
+ * Powered by @zakkster/lite-timeline under the hood.
6
+ *
7
+ * Each sequence step becomes a timeline track that writes directly
8
+ * to the camera's position/zoom state. While a sequence is playing,
9
+ * normal follow mode is paused.
10
+ *
11
+ * Zero external deps beyond the lite-* ecosystem.
12
+ *
13
+ * Depends on: @zakkster/lite-timeline, @zakkster/lite-lerp, @zakkster/lite-ease
14
+ */
15
+
16
+ import {createTimeline} from '@zakkster/lite-timeline';
17
+ import {lerp, clamp} from '@zakkster/lite-lerp';
18
+ import {easeInOutCubic, easeOutExpo} from '@zakkster/lite-ease';
19
+ import {addShake} from './ShakeEngine.js';
20
+ import {getPreset} from './ShakePresets.js';
21
+
22
+ /**
23
+ * Create a new camera sequence.
24
+ *
25
+ * The sequence does NOT play automatically — call .play() on the camera
26
+ * or let playSequence() handle it.
27
+ *
28
+ * @param {CinematicCameraPro} cam The camera to control
29
+ * @param {Object} [options]
30
+ * @param {boolean} [options.loop=false] Loop the sequence
31
+ * @param {Function} [options.onComplete] Called when sequence finishes
32
+ * @param {number} [options.blendOutTime=0.3] Seconds to blend back to follow after sequence ends
33
+ * @returns {CameraSequence}
34
+ *
35
+ * @example
36
+ * const seq = createCameraSequence(camera)
37
+ * .moveTo(400, 200, 1200)
38
+ * .zoomTo(1.5, 800)
39
+ * .shake('explosion')
40
+ * .wait(500)
41
+ * .moveTo(800, 300, 1000);
42
+ *
43
+ * camera.playSequence(seq);
44
+ */
45
+ export function createCameraSequence(cam, options = {}) {
46
+
47
+ const {
48
+ loop = false,
49
+ onComplete = null,
50
+ blendOutTime = 0.3,
51
+ } = options;
52
+
53
+ // ── Snapshot: captured when play() is called ──
54
+ let snapX = 0;
55
+ let snapY = 0;
56
+ let snapZoom = 1;
57
+
58
+ // ── Step queue (built during chaining, consumed when play() builds timeline) ──
59
+ const steps = [];
60
+
61
+ // ── The underlying timeline (created lazily on play) ──
62
+ let timeline = null;
63
+ let isPlaying = false;
64
+ let isDestroyed = false;
65
+
66
+ // ── Internal: the "current" animated state that steps write to ──
67
+ // These are read by updateSequence() to drive the camera.
68
+ const state = {
69
+ x: 0,
70
+ y: 0,
71
+ zoom: 1,
72
+ active: false,
73
+ };
74
+
75
+ // ─────────────────────────────────────────────────────
76
+ // STEP TYPES
77
+ // ─────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * Internal: Add a step to the queue.
81
+ * @param {string} type
82
+ * @param {Object} params
83
+ * @param {string|number} [position] lite-timeline position syntax
84
+ */
85
+ function pushStep(type, params, position) {
86
+ steps.push({type, params, position});
87
+ }
88
+
89
+ /**
90
+ * Snapshot current camera state and build a fresh timeline.
91
+ * Always destroys any prior timeline so play()/seek() are safe to call
92
+ * repeatedly — each (re)build re-snaps the current camera state.
93
+ */
94
+
95
+ function buildTimeline() {
96
+ if (timeline) timeline.destroy();
97
+
98
+ // Snapshot current camera state as the starting point
99
+ snapX = cam.pos[0] + cam.visibleW * 0.5; // center X in world
100
+ snapY = cam.pos[1] + cam.visibleH * 0.5; // center Y in world
101
+ snapZoom = cam.zoom;
102
+
103
+ state.x = snapX;
104
+ state.y = snapY;
105
+ state.zoom = snapZoom;
106
+
107
+ timeline = createTimeline({
108
+ loop,
109
+ onComplete: () => {
110
+ isPlaying = false;
111
+ state.active = false;
112
+ if (onComplete) onComplete();
113
+ },
114
+ });
115
+
116
+ // Track the "from" values as we chain — each step's start
117
+ // is the previous step's end target.
118
+ let curX = snapX;
119
+ let curY = snapY;
120
+ let curZoom = snapZoom;
121
+
122
+ for (let i = 0; i < steps.length; i++) {
123
+ const step = steps[i];
124
+ const pos = step.position; // lite-timeline position string
125
+
126
+ switch (step.type) {
127
+
128
+ case 'moveTo': {
129
+ const {x, y, duration, ease} = step.params;
130
+ const fromX = curX, fromY = curY;
131
+ curX = x;
132
+ curY = y;
133
+
134
+ timeline.add({
135
+ duration,
136
+ ease: ease || easeInOutCubic,
137
+ onUpdate: (t) => {
138
+ state.x = lerp(fromX, x, t);
139
+ state.y = lerp(fromY, y, t);
140
+ },
141
+ }, pos);
142
+ break;
143
+ }
144
+
145
+ case 'zoomTo': {
146
+ const {level, duration, ease} = step.params;
147
+ const fromZ = curZoom;
148
+ curZoom = level;
149
+
150
+ timeline.add({
151
+ duration,
152
+ ease: ease || easeOutExpo,
153
+ onUpdate: (t) => {
154
+ state.zoom = lerp(fromZ, level, t);
155
+ },
156
+ }, pos);
157
+ break;
158
+ }
159
+
160
+ case 'shake': {
161
+ const {profile, intensity} = step.params;
162
+ timeline.add({
163
+ duration: 0, // instant — fires once
164
+ onComplete: () => {
165
+ addShake(cam._shake, profile, intensity);
166
+ },
167
+ }, pos);
168
+ break;
169
+ }
170
+
171
+ case 'wait': {
172
+ const {duration} = step.params;
173
+ timeline.add({duration}, pos);
174
+ break;
175
+ }
176
+
177
+ case 'callback': {
178
+ const {fn} = step.params;
179
+ timeline.add({
180
+ duration: 0,
181
+ onComplete: fn,
182
+ }, pos);
183
+ break;
184
+ }
185
+
186
+ case 'moveAndZoom': {
187
+ const {x, y, level, duration, ease} = step.params;
188
+ const fromX = curX, fromY = curY, fromZ = curZoom;
189
+ curX = x;
190
+ curY = y;
191
+ curZoom = level;
192
+
193
+ timeline.add({
194
+ duration,
195
+ ease: ease || easeInOutCubic,
196
+ onUpdate: (t) => {
197
+ state.x = lerp(fromX, x, t);
198
+ state.y = lerp(fromY, y, t);
199
+ state.zoom = lerp(fromZ, level, t);
200
+ },
201
+ }, pos);
202
+ break;
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ // ─────────────────────────────────────────────────────
209
+ // PUBLIC: Fluent builder API
210
+ // ─────────────────────────────────────────────────────
211
+
212
+ const seq = {
213
+
214
+ /**
215
+ * Move camera center to world coordinates.
216
+ *
217
+ * @param {number} x World X
218
+ * @param {number} y World Y
219
+ * @param {number} duration Duration in ms
220
+ * @param {Object} [opts]
221
+ * @param {Function} [opts.ease] Easing function
222
+ * @param {string|number} [opts.at] Timeline position
223
+ * @returns {CameraSequence} this
224
+ *
225
+ * @example
226
+ * seq.moveTo(400, 200, 1200)
227
+ * .moveTo(800, 300, 1000);
228
+ */
229
+ moveTo(x, y, duration, opts) {
230
+ const ease = opts && opts.ease || null;
231
+ const at = opts && opts.at || undefined;
232
+ pushStep('moveTo', {x, y, duration, ease}, at);
233
+ return seq;
234
+ },
235
+
236
+ /**
237
+ * Smoothly zoom to a level.
238
+ *
239
+ * @param {number} level Target zoom
240
+ * @param {number} duration Duration in ms
241
+ * @param {Object} [opts]
242
+ * @param {Function} [opts.ease]
243
+ * @param {string|number} [opts.at] Timeline position
244
+ * @returns {CameraSequence} this
245
+ */
246
+ zoomTo(level, duration, opts) {
247
+ const ease = opts && opts.ease || null;
248
+ const at = opts && opts.at || undefined;
249
+ pushStep('zoomTo', {level, duration, ease}, at);
250
+ return seq;
251
+ },
252
+
253
+ /**
254
+ * Simultaneously move and zoom. Single track, perfectly synchronized.
255
+ *
256
+ * @param {number} x World X
257
+ * @param {number} y World Y
258
+ * @param {number} level Target zoom
259
+ * @param {number} duration Duration in ms
260
+ * @param {Object} [opts]
261
+ * @returns {CameraSequence} this
262
+ *
263
+ * @example
264
+ * seq.moveAndZoom(boss.x, boss.y, 1.8, 1500, { ease: easeOutExpo });
265
+ */
266
+ moveAndZoom(x, y, level, duration, opts) {
267
+ const ease = opts && opts.ease || null;
268
+ const at = opts && opts.at || undefined;
269
+ pushStep('moveAndZoom', {x, y, level, duration, ease}, at);
270
+ return seq;
271
+ },
272
+
273
+ /**
274
+ * Trigger a shake. Fires instantly at current timeline position.
275
+ *
276
+ * @param {string|Object} profileOrName Preset name or profile object
277
+ * @param {number} [intensity=1]
278
+ * @param {Object} [opts]
279
+ * @param {string|number} [opts.at] Timeline position
280
+ * @returns {CameraSequence} this
281
+ *
282
+ * @example
283
+ * seq.shake('explosion')
284
+ * .shake({ trauma: 0.3, freq: 20, decay: 2, maxOffset: 10 });
285
+ */
286
+ shake(profileOrName, intensity, opts) {
287
+ let profile;
288
+ if (typeof profileOrName === 'string') {
289
+ profile = getPreset(profileOrName);
290
+ if (!profile) profile = {trauma: 0.5, freq: 15, decay: 1, maxOffset: 15, maxAngle: 0.05};
291
+ } else {
292
+ profile = profileOrName;
293
+ }
294
+
295
+ const int = (typeof intensity === 'number') ? intensity : 1;
296
+
297
+ // Resolve `at`: prefer opts.at, then fall back to intensity.at if the
298
+ // caller used the 2-arg form `shake(name, { at: ... })`. Guard
299
+ // against `typeof null === 'object'` and treat `at: 0` as valid.
300
+ let at;
301
+ if (opts && opts.at !== undefined) {
302
+ at = opts.at;
303
+ } else if (intensity !== null && typeof intensity === 'object' && intensity.at !== undefined) {
304
+ at = intensity.at;
305
+ }
306
+
307
+ pushStep('shake', {profile, intensity: int}, at);
308
+ return seq;
309
+ },
310
+
311
+ /**
312
+ * Pause the sequence for a duration.
313
+ *
314
+ * @param {number} duration Wait time in ms
315
+ * @param {Object} [opts]
316
+ * @param {string|number} [opts.at] Timeline position
317
+ * @returns {CameraSequence} this
318
+ */
319
+ wait(duration, opts) {
320
+ const at = opts && opts.at || undefined;
321
+ pushStep('wait', {duration}, at);
322
+ return seq;
323
+ },
324
+
325
+ /**
326
+ * Execute an arbitrary callback at this point in the sequence.
327
+ * Great for triggering game events, spawning particles, etc.
328
+ *
329
+ * @param {Function} fn Callback function
330
+ * @param {Object} [opts]
331
+ * @param {string|number} [opts.at] Timeline position
332
+ * @returns {CameraSequence} this
333
+ *
334
+ * @example
335
+ * seq.moveTo(boss.x, boss.y, 1000)
336
+ * .call(() => boss.startPhase2())
337
+ * .shake('heavy_impact');
338
+ */
339
+ call(fn, opts) {
340
+ const at = opts && opts.at || undefined;
341
+ pushStep('callback', {fn}, at);
342
+ return seq;
343
+ },
344
+
345
+ // ─────────────────────────────────────────────────
346
+ // PLAYBACK CONTROL
347
+ // ─────────────────────────────────────────────────
348
+
349
+ /** Build and start the sequence. Usually called via camera.playSequence(). */
350
+ play() {
351
+ if (isDestroyed) return seq;
352
+ // Always rebuild — each play() captures the current camera state.
353
+ buildTimeline();
354
+ state.active = true;
355
+ isPlaying = true;
356
+ timeline.play();
357
+ return seq;
358
+ },
359
+
360
+ /** Pause the sequence. */
361
+ pause() {
362
+ if (timeline) timeline.pause();
363
+ return seq;
364
+ },
365
+
366
+ /** Resume after pause. */
367
+ resume() {
368
+ if (timeline) timeline.play();
369
+ return seq;
370
+ },
371
+
372
+ /** Stop the sequence and return camera to follow mode. */
373
+ stop() {
374
+ if (timeline) timeline.reset();
375
+ isPlaying = false;
376
+ state.active = false;
377
+ return seq;
378
+ },
379
+
380
+ /** Jump to a specific time (ms). */
381
+ seek(timeMs) {
382
+ if (!timeline) buildTimeline();
383
+ if (timeline) timeline.seek(timeMs);
384
+ return seq;
385
+ },
386
+
387
+ /**
388
+ * Total sequence duration in ms. Computed from queued steps —
389
+ * does NOT build the timeline or take a camera snapshot.
390
+ * Caveat: `at`-positioned overlaps are not accounted for.
391
+ */
392
+ get duration() {
393
+ if (timeline) return timeline.duration;
394
+ let total = 0;
395
+
396
+ for (let i = 0; i < steps.length; i++) {
397
+ total += (steps[i].params && steps[i].params.duration) || 0;
398
+ }
399
+
400
+ return total;
401
+ },
402
+
403
+ /** Current progress (0–1). */
404
+ get progress() {
405
+ return timeline ? timeline.progress : 0;
406
+ },
407
+
408
+ /** Whether the sequence is currently playing. */
409
+ get playing() {
410
+ return isPlaying;
411
+ },
412
+
413
+ /** Destroy the sequence and release timeline resources. */
414
+ destroy() {
415
+ if (isDestroyed) return;
416
+ isDestroyed = true;
417
+ if (timeline) timeline.destroy();
418
+ timeline = null;
419
+ state.active = false;
420
+ isPlaying = false;
421
+ steps.length = 0;
422
+ },
423
+
424
+ // ─────────────────────────────────────────────────
425
+ // INTERNAL: accessed by camera.update()
426
+ // ─────────────────────────────────────────────────
427
+
428
+ /** @internal */
429
+ _state: state,
430
+
431
+ /** @internal */
432
+ _blendOutTime: blendOutTime,
433
+ };
434
+
435
+ return seq;
436
+ }
437
+
438
+ // ─────────────────────────────────────────────────────
439
+ // SEQUENCE PRESETS (Day 9)
440
+ // ─────────────────────────────────────────────────────
441
+
442
+ /**
443
+ * Simple pan from current position to a world point.
444
+ *
445
+ * @param {CinematicCameraPro} cam
446
+ * @param {number} x Target world X
447
+ * @param {number} y Target world Y
448
+ * @param {number} duration Duration in ms
449
+ * @param {Object} [opts]
450
+ * @returns {CameraSequence}
451
+ */
452
+ export function panTo(cam, x, y, duration, opts) {
453
+ return createCameraSequence(cam, opts).moveTo(x, y, duration, opts);
454
+ }
455
+
456
+ /**
457
+ * Dramatic zoom: move + zoom simultaneously, great for boss reveals.
458
+ *
459
+ * @param {CinematicCameraPro} cam
460
+ * @param {number} x World X to focus on
461
+ * @param {number} y World Y to focus on
462
+ * @param {number} zoom Target zoom level
463
+ * @param {number} duration Duration in ms
464
+ * @param {Object} [opts]
465
+ * @returns {CameraSequence}
466
+ */
467
+ export function dramaticZoom(cam, x, y, zoom, duration, opts) {
468
+ return createCameraSequence(cam, opts).moveAndZoom(x, y, zoom, duration, opts);
469
+ }
470
+
471
+ /**
472
+ * Boss reveal: pan to target, zoom in, shake, hold, then return.
473
+ *
474
+ * @param {CinematicCameraPro} cam
475
+ * @param {number} x Boss world X
476
+ * @param {number} y Boss world Y
477
+ * @param {number} [totalMs=3000] Total sequence duration
478
+ * @param {Object} [opts]
479
+ * @returns {CameraSequence}
480
+ */
481
+ export function bossReveal(cam, x, y, totalMs = 3000, opts) {
482
+ const panTime = totalMs * 0.35;
483
+ const holdTime = totalMs * 0.30;
484
+ const backTime = totalMs * 0.35;
485
+
486
+ // Capture current center for return
487
+ const startX = cam.pos[0] + cam.visibleW * 0.5;
488
+ const startY = cam.pos[1] + cam.visibleH * 0.5;
489
+
490
+ return createCameraSequence(cam, opts)
491
+ .moveAndZoom(x, y, 1.8, panTime)
492
+ .shake('impact')
493
+ .wait(holdTime)
494
+ .moveAndZoom(startX, startY, 1.0, backTime);
495
+ }
496
+
497
+ /**
498
+ * Screen shake sequence with timed duration. Shake fires and
499
+ * the sequence waits for it to naturally decay.
500
+ *
501
+ * @param {CinematicCameraPro} cam
502
+ * @param {string|Object} presetOrProfile
503
+ * @param {number} [holdMs=500] How long to wait after shake fires
504
+ * @param {Object} [opts]
505
+ * @returns {CameraSequence}
506
+ */
507
+ export function timedShake(cam, presetOrProfile, holdMs = 500, opts) {
508
+ return createCameraSequence(cam, opts)
509
+ .shake(presetOrProfile)
510
+ .wait(holdMs);
511
+ }
512
+
513
+ export default createCameraSequence;