@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
package/src/DebugHUD.js
ADDED
|
@@ -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;
|