@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,290 @@
1
+ /**
2
+ * @zakkster/lite-camera-pro — Debug HUD
3
+ *
4
+ * Full screen-space debug overlay with toggleable panels.
5
+ * Renders directly to canvas — no DOM, no HTML.
6
+ *
7
+ * Panels: position, zoom, follow mode, shake, sequence, parallax, bounds.
8
+ * Each panel can be toggled on/off individually.
9
+ *
10
+ * Zero dependencies beyond the camera instance.
11
+ */
12
+ import {BoundsType} from './BoundsSystem.js';
13
+
14
+ const LINE_H = 13;
15
+ const PAD = 8;
16
+ const FONT = '10px monospace';
17
+ const FONT_BOLD = 'bold 10px monospace';
18
+ const BG = 'rgba(0,0,0,0.6)';
19
+ const COL_YELLOW = '#fbbf24';
20
+ const COL_RED = '#ef4444';
21
+ const COL_PURPLE = '#a78bfa';
22
+ const COL_CYAN = '#22d3ee';
23
+ const COL_GREEN = '#34d399';
24
+ const COL_DIM = '#6b7280';
25
+ const COL_WHITE = '#e5e5e5';
26
+
27
+ const MODE_NAMES = ['SMOOTH', 'LOCK', 'PREDICTIVE', 'CUT', 'HYBRID'];
28
+ const BOUNDS_NAMES = ['HARD', 'SOFT', 'ELASTIC', 'NONE'];
29
+
30
+ /**
31
+ * Create debug HUD configuration.
32
+ * @returns {Object} HUD config — mutate .show to toggle panels
33
+ */
34
+ export function createDebugHUDConfig() {
35
+ return {
36
+ show: {
37
+ position: true,
38
+ zoom: true,
39
+ mode: true,
40
+ shake: true,
41
+ sequence: true,
42
+ parallax: true,
43
+ bounds: true,
44
+ deadzone: true, // world-space deadzone rect
45
+ lookahead: true, // world-space lookahead vector
46
+ },
47
+ x: 4,
48
+ y: 4,
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Draw the full debug HUD. Screen-space (call AFTER ctx.restore()).
54
+ *
55
+ * @param {CinematicCameraPro} cam
56
+ * @param {CanvasRenderingContext2D} ctx
57
+ * @param {Object} [config] HUD config from createDebugHUDConfig()
58
+ */
59
+ export function drawDebugHUD(cam, ctx, config) {
60
+ const show = config ? config.show : {
61
+ position: true,
62
+ zoom: true,
63
+ mode: true,
64
+ shake: true,
65
+ sequence: true,
66
+ parallax: true,
67
+ bounds: true
68
+ };
69
+ const ox = config ? config.x : 4;
70
+ const oy = config ? config.y : 4;
71
+ const panelW = 260;
72
+
73
+ ctx.save();
74
+ ctx.font = FONT;
75
+
76
+ // ── Pass 1: count lines to size the background ──
77
+ let lineCount = 0;
78
+
79
+ if (show.position) lineCount += 2;
80
+ if (show.zoom) lineCount += 1;
81
+ if (show.mode) lineCount += 1;
82
+
83
+ if (show.shake) {
84
+ const sh = cam._shake;
85
+ if (sh.active) {
86
+ lineCount += 1; // header
87
+ for (let i = 0; i < sh.slotCount; i++) {
88
+ if (sh.slots[i].active) lineCount++;
89
+ }
90
+ }
91
+ }
92
+
93
+ if (show.sequence && cam._seq && cam._seq.playing) lineCount += 1;
94
+
95
+ if (show.parallax && cam._parallax.activeCount > 0) {
96
+ lineCount += 1;
97
+ for (let i = 0; i < cam._parallax.layerCount; i++) {
98
+ if (cam._parallax.layers[i].active) lineCount++;
99
+ }
100
+ }
101
+
102
+ if (show.bounds) {
103
+ const b = cam._bounds;
104
+
105
+ if (b.left !== BoundsType.HARD ||
106
+ b.right !== BoundsType.HARD ||
107
+ b.top !== BoundsType.HARD ||
108
+ b.bottom !== BoundsType.HARD) {
109
+ lineCount += 1;
110
+ }
111
+ }
112
+
113
+ if (lineCount === 0) {
114
+ ctx.restore();
115
+ return;
116
+ }
117
+
118
+ // ── Background ──
119
+ ctx.fillStyle = BG;
120
+ ctx.fillRect(ox, oy, panelW, lineCount * LINE_H + PAD * 2);
121
+
122
+ // ── Pass 2: draw directly, no intermediate objects ──
123
+ let row = 0;
124
+ const textX = ox + PAD;
125
+ const baseY = oy + PAD;
126
+
127
+ if (show.position) {
128
+ ctx.fillStyle = COL_DIM;
129
+ ctx.fillText(`pos ${cam.pos[0].toFixed(1)}, ${cam.pos[1].toFixed(1)}`, textX, baseY + row * LINE_H);
130
+ row++;
131
+ ctx.fillText(`tgt ${cam.target[0].toFixed(1)}, ${cam.target[1].toFixed(1)}`, textX, baseY + row * LINE_H);
132
+ row++;
133
+ }
134
+
135
+ if (show.zoom) {
136
+ ctx.fillStyle = COL_YELLOW;
137
+ ctx.fillText(`zoom ${cam.zoom.toFixed(3)} vis ${cam.visibleW.toFixed(0)}×${cam.visibleH.toFixed(0)}`, textX, baseY + row * LINE_H);
138
+ row++;
139
+ }
140
+
141
+ if (show.mode) {
142
+ ctx.fillStyle = COL_CYAN;
143
+ ctx.fillText(`mode ${MODE_NAMES[cam.mode] || '?'}`, textX, baseY + row * LINE_H);
144
+ row++;
145
+ }
146
+
147
+ if (show.shake) {
148
+ const sh = cam._shake;
149
+ if (sh.active) {
150
+ let actSlots = 0, mxT = 0;
151
+ for (let i = 0; i < sh.slotCount; i++) {
152
+ if (sh.slots[i].active) {
153
+ actSlots++;
154
+ if (sh.slots[i].trauma > mxT) mxT = sh.slots[i].trauma;
155
+ }
156
+ }
157
+ ctx.fillStyle = COL_RED;
158
+ ctx.fillText(`shake ${actSlots} slot${actSlots > 1 ? 's' : ''} trauma=${mxT.toFixed(2)}`, textX, baseY + row * LINE_H);
159
+ row++;
160
+
161
+ for (let i = 0; i < sh.slotCount; i++) {
162
+ const s = sh.slots[i];
163
+ if (!s.active) continue;
164
+ const col = s.isDirectional ? COL_PURPLE : COL_RED;
165
+ ctx.fillStyle = col;
166
+ ctx.fillText(` \u251C t=${s.trauma.toFixed(2)} f=${s.freq} d=${s.decay.toFixed(1)}${s.isDirectional ? ' dir' : ''}`, textX, baseY + row * LINE_H);
167
+ // Trauma bar
168
+ const barX = ox + panelW - 70;
169
+ const barY = baseY + row * LINE_H - 8;
170
+ ctx.fillStyle = 'rgba(255,255,255,0.08)';
171
+ ctx.fillRect(barX, barY, 60, 7);
172
+ ctx.fillStyle = col;
173
+ ctx.fillRect(barX, barY, 60 * s.trauma, 7);
174
+ row++;
175
+ }
176
+ }
177
+ }
178
+
179
+ if (show.sequence && cam._seq && cam._seq.playing) {
180
+ const progress = cam._seq.progress;
181
+ ctx.fillStyle = COL_PURPLE;
182
+ ctx.fillText(`seq ${(progress * 100).toFixed(0)}%`, textX, baseY + row * LINE_H);
183
+ const barX = ox + panelW - 70;
184
+ const barY = baseY + row * LINE_H - 8;
185
+ ctx.fillStyle = 'rgba(255,255,255,0.08)';
186
+ ctx.fillRect(barX, barY, 60, 7);
187
+ ctx.fillStyle = COL_PURPLE;
188
+ ctx.fillRect(barX, barY, 60 * progress, 7);
189
+ row++;
190
+ }
191
+
192
+ if (show.parallax && cam._parallax.activeCount > 0) {
193
+ ctx.fillStyle = COL_GREEN;
194
+ ctx.fillText(`parallax ${cam._parallax.activeCount} layers`, textX, baseY + row * LINE_H);
195
+ row++;
196
+ for (let i = 0; i < cam._parallax.layerCount; i++) {
197
+ const l = cam._parallax.layers[i];
198
+ if (!l.active) continue;
199
+ ctx.fillStyle = COL_DIM;
200
+ ctx.fillText(` \u251C ${l.id} speed=${l.speedX.toFixed(1)}`, textX, baseY + row * LINE_H);
201
+ row++;
202
+ }
203
+ }
204
+
205
+ if (show.bounds) {
206
+ const b = cam._bounds;
207
+
208
+ if (b.left !== BoundsType.HARD ||
209
+ b.right !== BoundsType.HARD ||
210
+ b.top !== BoundsType.HARD ||
211
+ b.bottom !== BoundsType.HARD) {
212
+ ctx.fillStyle = COL_CYAN;
213
+ ctx.fillText(`bounds L:${BOUNDS_NAMES[b.left]} R:${BOUNDS_NAMES[b.right]} T:${BOUNDS_NAMES[b.top]} B:${BOUNDS_NAMES[b.bottom]}`, textX, baseY + row * LINE_H);
214
+ row++;
215
+ }
216
+ }
217
+
218
+ ctx.restore();
219
+ }
220
+
221
+ /**
222
+ * Draw world-space debug overlay (deadzone rect, lookahead vector, world bounds).
223
+ * Call INSIDE ctx.save()/apply()/restore(), in camera-transformed space.
224
+ *
225
+ * @param {CinematicCameraPro} cam
226
+ * @param {CanvasRenderingContext2D} ctx
227
+ * @param {Object} [config] HUD config
228
+ */
229
+ export function drawDebugWorld(cam, ctx, config) {
230
+ const show = config ? config.show : {deadzone: true, lookahead: true};
231
+
232
+ ctx.save();
233
+
234
+ const cx = cam.target[0] - cam.pos[0] + cam.visibleW * 0.5;
235
+ const cy = cam.target[1] - cam.pos[1] + cam.visibleH * 0.5;
236
+
237
+ // ── Deadzone rectangle ──
238
+ if (show.deadzone !== false) {
239
+ ctx.strokeStyle = 'rgba(251,191,36,0.4)';
240
+ ctx.lineWidth = 1;
241
+ ctx.strokeRect(
242
+ cx - cam.deadzoneX,
243
+ cy - cam.deadzoneY,
244
+ cam.deadzoneX * 2,
245
+ cam.deadzoneY * 2
246
+ );
247
+
248
+ // Center crosshair
249
+ ctx.beginPath();
250
+ ctx.moveTo(cx - 6, cy);
251
+ ctx.lineTo(cx + 6, cy);
252
+ ctx.moveTo(cx, cy - 6);
253
+ ctx.lineTo(cx, cy + 6);
254
+ ctx.stroke();
255
+ }
256
+
257
+ // ── Lookahead vector ──
258
+ if (show.lookahead !== false) {
259
+ const lx = cam.look[0];
260
+ const ly = cam.look[1];
261
+ const len = Math.sqrt(lx * lx + ly * ly);
262
+ if (len > 1) {
263
+ ctx.strokeStyle = COL_CYAN;
264
+ ctx.lineWidth = 2;
265
+ ctx.beginPath();
266
+ ctx.moveTo(cx, cy);
267
+ ctx.lineTo(cx + lx, cy + ly);
268
+ ctx.stroke();
269
+
270
+ // Arrowhead
271
+ const angle = Math.atan2(ly, lx);
272
+ const aLen = 6;
273
+ ctx.beginPath();
274
+ ctx.moveTo(cx + lx, cy + ly);
275
+ ctx.lineTo(cx + lx - aLen * Math.cos(angle - 0.4), cy + ly - aLen * Math.sin(angle - 0.4));
276
+ ctx.moveTo(cx + lx, cy + ly);
277
+ ctx.lineTo(cx + lx - aLen * Math.cos(angle + 0.4), cy + ly - aLen * Math.sin(angle + 0.4));
278
+ ctx.stroke();
279
+ }
280
+ }
281
+
282
+ // ── World bounds outline ──
283
+ ctx.strokeStyle = 'rgba(239,68,68,0.2)';
284
+ ctx.lineWidth = 2;
285
+ ctx.strokeRect(0, 0, cam.worldW, cam.worldH);
286
+
287
+ ctx.restore();
288
+ }
289
+
290
+ export default drawDebugHUD;
@@ -0,0 +1,179 @@
1
+ /**
2
+ * @zakkster/lite-camera-pro — Follow Mode Strategies
3
+ *
4
+ * Each mode is a pure function:
5
+ * (camera, dt, px, py, pvx, pvy) => void
6
+ *
7
+ * Mutates camera.target[] directly. No allocations.
8
+ * The camera's update() dispatches to the active strategy.
9
+ *
10
+ * Modes:
11
+ * SMOOTH — lerp + deadzone + lookahead (default, same as lite-camera)
12
+ * LOCK — snap to target, no interpolation
13
+ * PREDICTIVE — heavy velocity extrapolation, aggressive lookahead
14
+ * CUT — instant jump (for cutscene hard cuts)
15
+ * HYBRID — smooth horizontal, locked vertical (platformer standard)
16
+ */
17
+
18
+ /** @enum {number} */
19
+ export const FollowMode = {
20
+ SMOOTH: 0,
21
+ LOCK: 1,
22
+ PREDICTIVE: 2,
23
+ CUT: 3,
24
+ HYBRID: 4,
25
+ };
26
+
27
+ /**
28
+ * SMOOTH — Default. Deadzone + lookahead + lerp.
29
+ * This is the same behavior as lite-camera base.
30
+ */
31
+ function smooth(cam, dt, px, py, pvx, pvy) {
32
+ const len = Math.sqrt(pvx * pvx + pvy * pvy);
33
+ const targetLx = len > 0 ? (pvx / len) * cam.lookaheadDist : 0;
34
+ const targetLy = len > 0 ? (pvy / len) * cam.lookaheadDist : 0;
35
+
36
+ cam.look[0] += (targetLx - cam.look[0]) * cam.lookaheadSpeed * dt;
37
+ cam.look[1] += (targetLy - cam.look[1]) * cam.lookaheadSpeed * dt;
38
+
39
+ const halfVisW = cam.visibleW * 0.5;
40
+ const halfVisH = cam.visibleH * 0.5;
41
+
42
+ const desiredX = px + cam.look[0] - halfVisW;
43
+ const desiredY = py + cam.look[1] - halfVisH;
44
+
45
+ if (desiredX < cam.target[0] - cam.deadzoneX)
46
+ cam.target[0] = desiredX + cam.deadzoneX;
47
+ else if (desiredX > cam.target[0] + cam.deadzoneX)
48
+ cam.target[0] = desiredX - cam.deadzoneX;
49
+
50
+ if (desiredY < cam.target[1] - cam.deadzoneY)
51
+ cam.target[1] = desiredY + cam.deadzoneY;
52
+ else if (desiredY > cam.target[1] + cam.deadzoneY)
53
+ cam.target[1] = desiredY - cam.deadzoneY;
54
+ }
55
+
56
+ /**
57
+ * LOCK — Camera snaps instantly to center on target.
58
+ * No deadzone, no lookahead, no lerp.
59
+ * Useful for: top-down shooters, fixed-camera moments.
60
+ */
61
+ function lock(cam, dt, px, py, pvx, pvy) {
62
+ const halfVisW = cam.visibleW * 0.5;
63
+ const halfVisH = cam.visibleH * 0.5;
64
+
65
+ cam.target[0] = px - halfVisW;
66
+ cam.target[1] = py - halfVisH;
67
+
68
+ // Bypass lerp by also setting pos directly
69
+ cam.pos[0] = cam.target[0];
70
+ cam.pos[1] = cam.target[1];
71
+
72
+ // Zero out lookahead so switching modes doesn't jerk
73
+ cam.look[0] = 0;
74
+ cam.look[1] = 0;
75
+ }
76
+
77
+ /**
78
+ * PREDICTIVE — Aggressive lookahead using raw velocity extrapolation.
79
+ * Looks further ahead than SMOOTH, and scales with speed (not just direction).
80
+ * No deadzone — the camera actively chases the predicted position.
81
+ * Useful for: racing games, fast runners, bullet-hell dodge patterns.
82
+ *
83
+ * Uses cam.predictTime (seconds of extrapolation, default 0.3).
84
+ */
85
+ function predictive(cam, dt, px, py, pvx, pvy) {
86
+ const predictTime = cam.predictTime || 0.3;
87
+
88
+ // Predicted position: where the player will be in `predictTime` seconds
89
+ const predX = px + pvx * predictTime;
90
+ const predY = py + pvy * predictTime;
91
+
92
+ const halfVisW = cam.visibleW * 0.5;
93
+ const halfVisH = cam.visibleH * 0.5;
94
+
95
+ // Lerp lookahead toward predicted offset (not normalized — scales with speed)
96
+ const targetLx = predX - px;
97
+ const targetLy = predY - py;
98
+ cam.look[0] += (targetLx - cam.look[0]) * cam.lookaheadSpeed * dt;
99
+ cam.look[1] += (targetLy - cam.look[1]) * cam.lookaheadSpeed * dt;
100
+
101
+ // No deadzone: camera directly tracks predicted center
102
+ cam.target[0] = px + cam.look[0] - halfVisW;
103
+ cam.target[1] = py + cam.look[1] - halfVisH;
104
+ }
105
+
106
+ /**
107
+ * CUT — Hard cut to center on target. Identical to LOCK but designed
108
+ * to be used as a one-frame mode switch for cutscene transitions.
109
+ * After the cut, you'd typically switch to SMOOTH or LOCK.
110
+ *
111
+ * Zero lerp, zero lookahead. Resets look vector.
112
+ */
113
+ function cut(cam, dt, px, py, pvx, pvy) {
114
+ const halfVisW = cam.visibleW * 0.5;
115
+ const halfVisH = cam.visibleH * 0.5;
116
+
117
+ cam.target[0] = px - halfVisW;
118
+ cam.target[1] = py - halfVisH;
119
+ cam.pos[0] = cam.target[0];
120
+ cam.pos[1] = cam.target[1];
121
+
122
+ cam.look[0] = 0;
123
+ cam.look[1] = 0;
124
+ }
125
+
126
+ /**
127
+ * HYBRID — Smooth horizontal (with deadzone + lookahead), locked vertical.
128
+ * The platformer standard: horizontal feels cinematic, vertical is
129
+ * pixel-precise so platforms don't jitter.
130
+ *
131
+ * Uses cam.hybridVerticalSnap (default true) to control whether
132
+ * vertical uses instant snap or fast lerp.
133
+ */
134
+ function hybrid(cam, dt, px, py, pvx, pvy) {
135
+ // ── Horizontal: full smooth behavior ──
136
+ const len = Math.sqrt(pvx * pvx + pvy * pvy);
137
+ const targetLx = len > 0 ? (pvx / len) * cam.lookaheadDist : 0;
138
+ cam.look[0] += (targetLx - cam.look[0]) * cam.lookaheadSpeed * dt;
139
+
140
+ const halfVisW = cam.visibleW * 0.5;
141
+ const halfVisH = cam.visibleH * 0.5;
142
+
143
+ const desiredX = px + cam.look[0] - halfVisW;
144
+
145
+ if (desiredX < cam.target[0] - cam.deadzoneX)
146
+ cam.target[0] = desiredX + cam.deadzoneX;
147
+ else if (desiredX > cam.target[0] + cam.deadzoneX)
148
+ cam.target[0] = desiredX - cam.deadzoneX;
149
+
150
+ // ── Vertical: locked (snap or fast lerp) ──
151
+ const desiredY = py - halfVisH;
152
+
153
+ if (cam.hybridVerticalSnap !== false) {
154
+ // Instant snap
155
+ cam.target[1] = desiredY;
156
+ cam.pos[1] = desiredY;
157
+ } else {
158
+ // Fast lerp (3× normal speed)
159
+ cam.target[1] = desiredY;
160
+ cam.pos[1] += (cam.target[1] - cam.pos[1]) * cam.lerpSpeed * 3 * dt;
161
+ }
162
+
163
+ // Zero out vertical lookahead
164
+ cam.look[1] = 0;
165
+ }
166
+
167
+ /**
168
+ * Strategy lookup table. Indexed by FollowMode enum.
169
+ * @type {Function[]}
170
+ */
171
+ export const FOLLOW_STRATEGIES = [
172
+ smooth, // 0: SMOOTH
173
+ lock, // 1: LOCK
174
+ predictive, // 2: PREDICTIVE
175
+ cut, // 3: CUT
176
+ hybrid, // 4: HYBRID
177
+ ];
178
+
179
+ export default FollowMode;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @zakkster/lite-camera-pro — Multi-Target Framing
3
+ *
4
+ * Calculates camera position and zoom to keep multiple targets visible.
5
+ * Zero allocations per frame — all state is pre-allocated on the camera.
6
+ *
7
+ * Used by boss fights, co-op, cutscenes tracking multiple actors.
8
+ *
9
+ * Depends on: @zakkster/lite-lerp (clamp)
10
+ */
11
+
12
+ /**
13
+ * Compute the camera target position and zoom level that frames
14
+ * all targets with the specified padding.
15
+ *
16
+ * Mutates cam.target[], cam.zoom directly. Zero allocations.
17
+ *
18
+ * @param {CinematicCameraPro} cam The camera instance
19
+ * @param {number} dt Delta time in seconds
20
+ * @param {{x:number,y:number}[]} targets Array of target objects
21
+ * @param {number} count Number of active targets
22
+ */
23
+ export function updateMultiTarget(cam, dt, targets, count) {
24
+ if (count === 0) return;
25
+
26
+ const mt = cam._mt;
27
+
28
+ // ── 1. Compute bounding box of all targets ──
29
+ let minX = targets[0].x;
30
+ let maxX = minX;
31
+ let minY = targets[0].y;
32
+ let maxY = minY;
33
+
34
+ for (let i = 1; i < count; i++) {
35
+ const tx = targets[i].x;
36
+ const ty = targets[i].y;
37
+ if (tx < minX) minX = tx;
38
+ if (tx > maxX) maxX = tx;
39
+ if (ty < minY) minY = ty;
40
+ if (ty > maxY) maxY = ty;
41
+ }
42
+
43
+ // ── 2. Center of the bounding box ──
44
+ const centerX = (minX + maxX) * 0.5;
45
+ const centerY = (minY + maxY) * 0.5;
46
+
47
+ // ── 3. Required world-space dimensions (with padding) ──
48
+ // Minimum bbox = viewport at maxZoom, prevents near-zero division
49
+ const minBBox = 100; // minimum world-space pixels to prevent extreme zoom
50
+ const bboxW = Math.max((maxX - minX) + mt.paddingX * 2, minBBox);
51
+ const bboxH = Math.max((maxY - minY) + mt.paddingY * 2, minBBox);
52
+
53
+ // ── 4. Compute zoom to fit bbox into viewport ──
54
+ const zoomX = cam.viewW / bboxW;
55
+ const zoomY = cam.viewH / bboxH;
56
+ let desiredZoom = Math.min(zoomX, zoomY);
57
+
58
+ // Clamp to camera limits
59
+ if (desiredZoom < cam.minZoom) desiredZoom = cam.minZoom;
60
+ if (desiredZoom > cam.maxZoom) desiredZoom = cam.maxZoom;
61
+ // Multi-target specific clamps (tighter range for framing)
62
+ if (desiredZoom < mt.minZoom) desiredZoom = mt.minZoom;
63
+ if (desiredZoom > mt.maxZoom) desiredZoom = mt.maxZoom;
64
+
65
+ // ── 5. Smooth zoom toward desired ──
66
+ // Exponential damping: cam.zoom approaches desiredZoom at mt.zoomSpeed rate
67
+ const zoomLerp = 1 - Math.exp(-mt.zoomSpeed * dt);
68
+ cam.zoom += (desiredZoom - cam.zoom) * zoomLerp;
69
+
70
+ // ── 6. Update visible dimensions after zoom change ──
71
+ cam.visibleW = cam.viewW / cam.zoom;
72
+ cam.visibleH = cam.viewH / cam.zoom;
73
+ cam._maxX = cam.worldW - cam.visibleW;
74
+ cam._maxY = cam.worldH - cam.visibleH;
75
+ if (cam._maxX < 0) cam._maxX = 0;
76
+ if (cam._maxY < 0) cam._maxY = 0;
77
+
78
+ // ── 7. Camera target = center of bbox, offset by half-visible ──
79
+ const desiredX = centerX - cam.visibleW * 0.5;
80
+ const desiredY = centerY - cam.visibleH * 0.5;
81
+
82
+ // Smooth position follow
83
+ const posLerp = 1 - Math.exp(-mt.followSpeed * dt);
84
+ cam.target[0] += (desiredX - cam.target[0]) * posLerp;
85
+ cam.target[1] += (desiredY - cam.target[1]) * posLerp;
86
+
87
+ // Zero out lookahead (multi-target doesn't use it)
88
+ cam.look[0] = 0;
89
+ cam.look[1] = 0;
90
+ }
91
+
92
+ /**
93
+ * Default multi-target configuration. Allocated once on the camera.
94
+ * All fields are mutable by the developer.
95
+ *
96
+ * @returns {Object} Config object stored as cam._mt
97
+ */
98
+ export function createMultiTargetState() {
99
+ return {
100
+ /** Whether multi-target tracking is active */
101
+ active: false,
102
+
103
+ /** Array ref provided by developer (we never allocate a new one) */
104
+ targets: null,
105
+
106
+ /** Number of active targets (avoids .length access on sparse arrays) */
107
+ count: 0,
108
+
109
+ /** World-space padding around the bounding box (pixels) */
110
+ paddingX: 80,
111
+ paddingY: 80,
112
+
113
+ /** Zoom limits specific to multi-target framing */
114
+ minZoom: 0.3,
115
+ maxZoom: 2.0,
116
+
117
+ /** Zoom smoothing speed (higher = snappier). Uses exponential damping. */
118
+ zoomSpeed: 4.0,
119
+
120
+ /** Position follow speed. Uses exponential damping. */
121
+ followSpeed: 5.0,
122
+ };
123
+ }
124
+
125
+ export default updateMultiTarget;