@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.
- package/LICENSE +21 -0
- package/README.md +692 -0
- package/llms.txt +115 -0
- package/package.json +69 -0
- package/src/BoundsSystem.js +220 -0
- package/src/CameraSequence.js +513 -0
- package/src/CinematicCameraPro.js +894 -0
- package/src/DebugHUD.js +290 -0
- package/src/FollowMode.js +179 -0
- package/src/MultiTarget.js +125 -0
- package/src/ParallaxManager.js +199 -0
- package/src/ShakeEngine.js +286 -0
- package/src/ShakePresets.js +177 -0
- package/src/index.d.ts +228 -0
- package/src/index.js +33 -0
|
@@ -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;
|