@solidrt/2d 0.0.55 → 0.0.56

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,532 @@
1
+ // The 2d camera's motion, pure: a pose (the world point at a viewport
2
+ // pivot, zoom, rotation), the contain clamp against world bounds, anchored
3
+ // zoom, the eased glides (wheel notch, glideTo, fit), follow with damping
4
+ // and a dead zone, and drag inertia. Everything createCamera2d does except
5
+ // the input glue, which lives in camera2d.ts: this module imports no GUI
6
+ // or core module, so checks/camera2d-check.ts runs it headless on flux.
7
+ //
8
+ // Conventions, decided against Godot's Camera2D, Unity's Cinemachine and
9
+ // Three's MapControls (plus the map/whiteboard libraries for the canvas
10
+ // half): the pose is "world point at the pivot" with the pivot defaulting
11
+ // to the viewport center (Godot's drag-center anchor, Cinemachine's screen
12
+ // position, a map's center); bounds CONTAIN the view and center an axis
13
+ // whose view is wider than the world (Cinemachine's confiner skeleton,
14
+ // tldraw's contain origin - Godot's left-wins is the worse behavior);
15
+ // limits ignore rotation, clamping the unrotated view rect (Godot's rule);
16
+ // every ease is exponential and frame-rate independent, zoom eased in log
17
+ // space so a long glide reads evenly.
18
+
19
+ import { projectCamera, unprojectCamera } from "./camera.ts"
20
+ import type { CameraUpdate } from "./camera.ts"
21
+
22
+ // Zoom exponent per wheel-delta unit (wheel up zooms in); the same rate as
23
+ // @solidrt/3d's orbit camera, so the two feel alike.
24
+ const WHEEL_ZOOM = 0.0015
25
+ // Glide rate toward a pending zoom or pose, e-foldings per second: high
26
+ // enough that a wheel notch reads as one push, low enough to look smooth.
27
+ const GLIDE_EASE = 9
28
+ // A glide lands when the remaining zoom gap is under this (relative) ...
29
+ const GLIDE_EPSILON = 0.002
30
+ // ... and the remaining travel is under this many screen pixels. Also the
31
+ // follow's settle threshold and, times the fling decay, the speed a fling
32
+ // stops at (the distance it would still travel).
33
+ const LAND_PX = 0.5
34
+ // Follow damping, e-foldings per second (Godot's default
35
+ // position_smoothing_speed).
36
+ const FOLLOW_EASE = 5
37
+ // Inertia after a drag release: velocity decay in e-foldings per second. A
38
+ // 1500 px/s flick travels 500 px; iOS scrolling decelerates at ~2, maps
39
+ // are snappier.
40
+ const FLING_DECAY = 3
41
+ // Release speeds under this (px/s) do not fling: a finger that stopped
42
+ // before lifting leaves the view where it is.
43
+ const FLING_MIN_SPEED = 50
44
+ // Per-update EMA weight of the drag velocity estimate: heavy on the newest
45
+ // frame so a direction change registers within a frame or two.
46
+ const VELOCITY_SMOOTH = 0.5
47
+ // Frame time assumed for the drag delta still pending at a release when the
48
+ // release arrives in the same frame as the last move.
49
+ const FALLBACK_DT = 1 / 60
50
+
51
+ /** What a 2d camera drives: anything with the layers' `setCamera`. */
52
+ export type Camera2dTarget = { setCamera(update: CameraUpdate): void }
53
+
54
+ export type Camera2dPose = {
55
+ x?: number
56
+ y?: number
57
+ zoom?: number
58
+ rotation?: number
59
+ }
60
+
61
+ export type Rect2d = { x: number; y: number; width: number; height: number }
62
+
63
+ export type Camera2dMotionOptions = Camera2dPose & {
64
+ /** Viewport size in layer pixels, read whenever the camera needs it (a
65
+ * resize re-clamps at the next update). Return zeros while unknown -
66
+ * clamping and fitting wait for a real size. */
67
+ viewport: () => { width: number; height: number }
68
+ /** World bounds (origin 0,0) the view is kept inside: an axis whose view
69
+ * is wider than the world centers. Absent = unbounded. */
70
+ world?: { width: number; height: number }
71
+ /** Zoom range. minZoom defaults to the fit zoom (whole world visible)
72
+ * when `world` is given, else to no lower bound; maxZoom to no upper
73
+ * bound. An explicit maxZoom below the fit zoom wins, the world then
74
+ * floats centered. */
75
+ minZoom?: number
76
+ maxZoom?: number
77
+ /** Where in the viewport the camera's world point sits, as fractions of
78
+ * its size; default the center. glideTo and follow land their point
79
+ * here and rotation turns about it. */
80
+ pivot?: { x: number; y: number }
81
+ /** Follow dead zone as fractions of the viewport, centered on the pivot:
82
+ * the target roams inside it without moving the camera. Default 0,0. */
83
+ deadZone?: { width: number; height: number }
84
+ /** Multiplier over the built-in wheel and pinch sensitivity. */
85
+ zoomSpeed?: number
86
+ /** Multiplier over the built-in follow damping. */
87
+ followSpeed?: number
88
+ /** Whether a drag release keeps the view gliding (default true). */
89
+ inertia?: boolean
90
+ }
91
+
92
+ export type Camera2dMotion = {
93
+ /** The pose as the layers receive it (a fresh object per call): the
94
+ * argument for projectCamera/unprojectCamera. */
95
+ camera(): CameraUpdate
96
+ /** Merge a pose in (clamps apply) and reach the layers at the next
97
+ * update(). A snap: an x/y/zoom write cancels a glide or fling in
98
+ * flight; rotation alone leaves them running. */
99
+ set(pose: Camera2dPose): void
100
+ /** Pan the content by a screen delta (a drag: positive dx slides the
101
+ * world rightward). Feeds the inertia estimate; release() flings. */
102
+ panBy(dx: number, dy: number): void
103
+ /** End of a drag: keep gliding with the drag's velocity (inertia). */
104
+ release(): void
105
+ /** Zoom by `factor` about a screen point - the world point under it
106
+ * stays under it. */
107
+ zoomAt(sx: number, sy: number, factor: number): void
108
+ /** A wheel notch at a screen point: retargets an eased, anchored zoom. */
109
+ wheel(sx: number, sy: number, deltaY: number): void
110
+ /** Ease the pose until world (x, y) sits at the pivot, at `zoom` (default
111
+ * the current zoom). Cancels a follow. */
112
+ glideTo(x: number, y: number, zoom?: number): void
113
+ /** Show a world rect (default the world) whole, centered in the
114
+ * viewport, snapping or gliding. Waits for the viewport if unknown. */
115
+ fit(rect?: Rect2d, opts?: { glide?: boolean }): void
116
+ /** Keep world (x, y) at the pivot through the dead zone and damping;
117
+ * call again whenever the target moves. Cancels a glide. */
118
+ follow(x: number, y: number): void
119
+ unfollow(): void
120
+ /** Stop a glide or fling in flight (a press landing). */
121
+ interrupt(): void
122
+ /** The world-space bounding box of what the viewport shows. */
123
+ viewRect(): Rect2d
124
+ /** Advance glides, follow and inertia, then push the pose to the
125
+ * layers if it changed. Call from onFrame with the frame's dt in
126
+ * seconds; returns whether the pose changed. */
127
+ update(dt: number): boolean
128
+ /**
129
+ * Whether update() still has work: a glide, fling or deferred fit in
130
+ * flight, a follow engaged, or a write not yet pushed. The frame-loop
131
+ * gate - run update(dt) on frames while true, and nothing while false
132
+ * (a resting camera costs no frames).
133
+ */
134
+ active(): boolean
135
+ }
136
+
137
+ type Glide = { kind: "anchor"; target: number; sx: number; sy: number; wx: number; wy: number } | { kind: "pose"; x: number; y: number; zoom: number }
138
+
139
+ let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
140
+
141
+ function positive(what: string, v: number): void {
142
+ if (!(Number.isFinite(v) && v > 0)) throw new Error(`createCamera2d: ${what} must be a positive number, got ${v}`)
143
+ }
144
+
145
+ function finite(what: string, v: number | undefined): void {
146
+ if (v !== undefined && !Number.isFinite(v)) throw new Error(`createCamera2d: ${what} must be a finite number, got ${v}`)
147
+ }
148
+
149
+ function checkPose(verb: string, pose: Camera2dPose): void {
150
+ finite(`${verb} x`, pose.x)
151
+ finite(`${verb} y`, pose.y)
152
+ finite(`${verb} rotation`, pose.rotation)
153
+ if (pose.zoom !== undefined) positive(`${verb} zoom`, pose.zoom)
154
+ }
155
+
156
+ function checkFraction(what: string, v: number): void {
157
+ if (!(Number.isFinite(v) && v >= 0 && v <= 1)) throw new Error(`createCamera2d: ${what} must be within 0..1, got ${v}`)
158
+ }
159
+
160
+ /**
161
+ * The pure motion behind createCamera2d. `onActive` reports each flip of
162
+ * active() as it happens (the reactive face in camera2d.ts feeds a signal
163
+ * from it); pure callers poll active() instead.
164
+ */
165
+ export function createCameraMotion(target: Camera2dTarget | Camera2dTarget[], options: Camera2dMotionOptions, onActive?: (active: boolean) => void): Camera2dMotion {
166
+ let targets = Array.isArray(target) ? target : [target]
167
+ if (typeof options.viewport !== "function") throw new Error("createCamera2d: viewport must be a function returning { width, height }")
168
+ let world = options.world ?? null
169
+ if (world) {
170
+ positive("world.width", world.width)
171
+ positive("world.height", world.height)
172
+ }
173
+ if (options.minZoom !== undefined) positive("minZoom", options.minZoom)
174
+ if (options.maxZoom !== undefined) positive("maxZoom", options.maxZoom)
175
+ if (options.minZoom !== undefined && options.maxZoom !== undefined && options.minZoom > options.maxZoom) {
176
+ throw new Error(`createCamera2d: minZoom ${options.minZoom} exceeds maxZoom ${options.maxZoom}`)
177
+ }
178
+ checkPose("", options)
179
+ let pivotFx = options.pivot?.x ?? 0.5
180
+ let pivotFy = options.pivot?.y ?? 0.5
181
+ finite("pivot.x", pivotFx)
182
+ finite("pivot.y", pivotFy)
183
+ let deadW = options.deadZone?.width ?? 0
184
+ let deadH = options.deadZone?.height ?? 0
185
+ checkFraction("deadZone.width", deadW)
186
+ checkFraction("deadZone.height", deadH)
187
+ let wheelZoom = WHEEL_ZOOM * (options.zoomSpeed ?? 1)
188
+ let zoomExponent = options.zoomSpeed ?? 1
189
+ let followEase = FOLLOW_EASE * (options.followSpeed ?? 1)
190
+ let inertia = options.inertia ?? true
191
+ let maxZoom = options.maxZoom ?? Infinity
192
+
193
+ let x = options.x ?? (world ? world.width / 2 : 0)
194
+ let y = options.y ?? (world ? world.height / 2 : 0)
195
+ let zoom = options.zoom ?? 1
196
+ let rotation = options.rotation ?? 0
197
+
198
+ // The viewport as last read; zeros mean unknown (nothing clamps or fits
199
+ // until a real size arrives).
200
+ let vw = 0
201
+ let vh = 0
202
+ let known = () => vw > 0 && vh > 0
203
+ let readViewport = () => {
204
+ let v = options.viewport()
205
+ if (!(Number.isFinite(v.width) && Number.isFinite(v.height) && v.width >= 0 && v.height >= 0)) {
206
+ throw new Error(`createCamera2d: viewport() must return non-negative finite width/height, got ${v.width}x${v.height}`)
207
+ }
208
+ vw = v.width
209
+ vh = v.height
210
+ }
211
+ let px = () => vw * pivotFx
212
+ let py = () => vh * pivotFy
213
+
214
+ let minZoom = () => {
215
+ let fit = world && known() ? Math.min(vw / world.width, vh / world.height) : 0
216
+ return Math.min(options.minZoom ?? fit, maxZoom)
217
+ }
218
+
219
+ // The contain clamp on an unrotated view rect (Godot's rule: limits
220
+ // ignore rotation): returns the pose's x/y, moved inside the world.
221
+ let contain = (cx: number, cy: number, z: number): [number, number] => {
222
+ if (!world || !known()) return [cx, cy]
223
+ let viewW = vw / z
224
+ let viewH = vh / z
225
+ let left = cx - px() / z
226
+ let top = cy - py() / z
227
+ left = viewW >= world.width ? (world.width - viewW) / 2 : clampNum(left, 0, world.width - viewW)
228
+ top = viewH >= world.height ? (world.height - viewH) / 2 : clampNum(top, 0, world.height - viewH)
229
+ return [left + px() / z, top + py() / z]
230
+ }
231
+ let clamp = () => {
232
+ zoom = clampNum(zoom, minZoom(), maxZoom)
233
+ ;[x, y] = contain(x, y, zoom)
234
+ }
235
+
236
+ let camera = (): CameraUpdate => ({ x, y, zoom, rotation, pivotX: px(), pivotY: py() })
237
+ // Pose x/y that put world (wx, wy) under screen (sx, sy) at zoom z:
238
+ // the inverse of projectCamera solved for the camera point.
239
+ let anchorPose = (wx: number, wy: number, sx: number, sy: number, z: number): [number, number] => {
240
+ let c = Math.cos(rotation)
241
+ let s = Math.sin(rotation)
242
+ let dx = sx - px()
243
+ let dy = sy - py()
244
+ return [wx - (dx * c + dy * s) / z, wy - (dy * c - dx * s) / z]
245
+ }
246
+ // Slide the content by a screen delta: the camera point moves the
247
+ // opposite way, the delta un-rotated into world axes.
248
+ let shift = (dx: number, dy: number) => {
249
+ let c = Math.cos(rotation)
250
+ let s = Math.sin(rotation)
251
+ x -= (dx * c + dy * s) / zoom
252
+ y -= (dy * c - dx * s) / zoom
253
+ }
254
+
255
+ let dirty = false
256
+ let glide: Glide | null = null
257
+ let fling: { vx: number; vy: number } | null = null
258
+ let followAt: { x: number; y: number } | null = null
259
+ // A follow that reached its target stops writing until something moves.
260
+ let followSettled = false
261
+ // Drag deltas since the last update, and the smoothed velocity (px/s).
262
+ let dragDx = 0
263
+ let dragDy = 0
264
+ let vx = 0
265
+ let vy = 0
266
+ let lastDt = FALLBACK_DT
267
+ // A fit that waits for the viewport (the default pose when a world is
268
+ // given, or an explicit fit() before the size is known).
269
+ let pendingFit: { rect: Rect2d | null; glide: boolean } | null = options.zoom === undefined && world ? { rect: null, glide: false } : null
270
+
271
+ let touch = () => {
272
+ dirty = true
273
+ followSettled = false
274
+ }
275
+ let active = () => dirty || glide !== null || fling !== null || followAt !== null || pendingFit !== null
276
+ // Report flips of active() to the hook; every public entry ends here.
277
+ let wasActive = false
278
+ let notify = () => {
279
+ let now = active()
280
+ if (now === wasActive) return
281
+ wasActive = now
282
+ onActive?.(now)
283
+ }
284
+ let interrupt = () => {
285
+ glide = null
286
+ fling = null
287
+ notify()
288
+ }
289
+ let apply = () => {
290
+ let update = camera()
291
+ for (let t of targets) t.setCamera(update)
292
+ }
293
+
294
+ let zoomTo = (sx: number, sy: number, next: number) => {
295
+ let [wx, wy] = unprojectCamera(camera(), sx, sy)
296
+ zoom = clampNum(next, minZoom(), maxZoom)
297
+ ;[x, y] = anchorPose(wx, wy, sx, sy, zoom)
298
+ clamp()
299
+ touch()
300
+ }
301
+ let glideTo = (tx: number, ty: number, tz?: number) => {
302
+ finite("glideTo x", tx)
303
+ finite("glideTo y", ty)
304
+ if (tz !== undefined) positive("glideTo zoom", tz)
305
+ readViewport()
306
+ fling = null
307
+ followAt = null
308
+ let z = clampNum(tz ?? zoom, minZoom(), maxZoom)
309
+ let [cx, cy] = contain(tx, ty, z)
310
+ glide = { kind: "pose", x: cx, y: cy, zoom: z }
311
+ }
312
+ let fitNow = (rect: Rect2d | null, ease: boolean) => {
313
+ let r = rect ?? (world ? { x: 0, y: 0, width: world.width, height: world.height } : null)
314
+ if (!r) throw new Error("createCamera2d: fit() needs a rect when the camera has no world")
315
+ let z = clampNum(Math.min(vw / r.width, vh / r.height), minZoom(), maxZoom)
316
+ let [nx, ny] = anchorPose(r.x + r.width / 2, r.y + r.height / 2, vw / 2, vh / 2, z)
317
+ if (ease) {
318
+ glideTo(nx, ny, z)
319
+ return
320
+ }
321
+ interrupt()
322
+ x = nx
323
+ y = ny
324
+ zoom = z
325
+ clamp()
326
+ touch()
327
+ }
328
+
329
+ readViewport()
330
+ clamp()
331
+ if (pendingFit && known()) {
332
+ fitNow(pendingFit.rect, pendingFit.glide)
333
+ pendingFit = null
334
+ }
335
+ dirty = false
336
+ apply()
337
+ notify()
338
+
339
+ return {
340
+ camera,
341
+ active,
342
+ set(pose) {
343
+ checkPose("set", pose)
344
+ readViewport()
345
+ if (pose.x !== undefined || pose.y !== undefined || pose.zoom !== undefined) interrupt()
346
+ if (pose.x !== undefined) x = pose.x
347
+ if (pose.y !== undefined) y = pose.y
348
+ if (pose.zoom !== undefined) zoom = pose.zoom
349
+ if (pose.rotation !== undefined) rotation = pose.rotation
350
+ clamp()
351
+ touch()
352
+ notify()
353
+ },
354
+ panBy(dx, dy) {
355
+ readViewport()
356
+ interrupt()
357
+ shift(dx, dy)
358
+ clamp()
359
+ dragDx += dx
360
+ dragDy += dy
361
+ touch()
362
+ notify()
363
+ },
364
+ release() {
365
+ // Fold the delta still pending from the release's own frame in with
366
+ // the last frame time, then start from rest either way.
367
+ let rvx = vx + (dragDx / lastDt - vx) * VELOCITY_SMOOTH
368
+ let rvy = vy + (dragDy / lastDt - vy) * VELOCITY_SMOOTH
369
+ dragDx = 0
370
+ dragDy = 0
371
+ vx = 0
372
+ vy = 0
373
+ if (!inertia || followAt !== null) return
374
+ if (Math.hypot(rvx, rvy) < FLING_MIN_SPEED) return
375
+ fling = { vx: rvx, vy: rvy }
376
+ notify()
377
+ },
378
+ zoomAt(sx, sy, factor) {
379
+ positive("zoomAt factor", factor)
380
+ readViewport()
381
+ interrupt()
382
+ zoomTo(sx, sy, zoom * Math.pow(factor, zoomExponent))
383
+ notify()
384
+ },
385
+ wheel(sx, sy, deltaY) {
386
+ readViewport()
387
+ fling = null
388
+ if (glide !== null && glide.kind === "pose") glide = null
389
+ // Notches compound on the pending target, so a fast scroll is one
390
+ // long push; the anchor is the world point under the pointer now.
391
+ let from = glide !== null ? glide.target : zoom
392
+ let target = clampNum(from * Math.exp(-deltaY * wheelZoom), minZoom(), maxZoom)
393
+ let [wx, wy] = unprojectCamera(camera(), sx, sy)
394
+ glide = { kind: "anchor", target, sx, sy, wx, wy }
395
+ notify()
396
+ },
397
+ glideTo(tx, ty, tz) {
398
+ glideTo(tx, ty, tz)
399
+ notify()
400
+ },
401
+ fit(rect, opts) {
402
+ readViewport()
403
+ if (!known()) {
404
+ pendingFit = { rect: rect ?? null, glide: opts?.glide ?? false }
405
+ notify()
406
+ return
407
+ }
408
+ fitNow(rect ?? null, opts?.glide ?? false)
409
+ notify()
410
+ },
411
+ follow(tx, ty) {
412
+ finite("follow x", tx)
413
+ finite("follow y", ty)
414
+ glide = null
415
+ fling = null
416
+ if (followAt === null) followAt = { x: tx, y: ty }
417
+ else {
418
+ followAt.x = tx
419
+ followAt.y = ty
420
+ }
421
+ followSettled = false
422
+ notify()
423
+ },
424
+ unfollow() {
425
+ followAt = null
426
+ notify()
427
+ },
428
+ interrupt,
429
+ viewRect() {
430
+ readViewport()
431
+ let cam = camera()
432
+ let corners = [unprojectCamera(cam, 0, 0), unprojectCamera(cam, vw, 0), unprojectCamera(cam, 0, vh), unprojectCamera(cam, vw, vh)]
433
+ let minX = Infinity
434
+ let minY = Infinity
435
+ let maxX = -Infinity
436
+ let maxY = -Infinity
437
+ for (let [cx, cy] of corners) {
438
+ minX = Math.min(minX, cx)
439
+ minY = Math.min(minY, cy)
440
+ maxX = Math.max(maxX, cx)
441
+ maxY = Math.max(maxY, cy)
442
+ }
443
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
444
+ },
445
+ update(dt) {
446
+ let pw = vw
447
+ let ph = vh
448
+ readViewport()
449
+ if (vw !== pw || vh !== ph) {
450
+ // The pivot moved with the viewport; the world point stays under
451
+ // it. A resize can also change the fit zoom, hence the re-clamp.
452
+ if (pendingFit !== null && known()) {
453
+ fitNow(pendingFit.rect, pendingFit.glide)
454
+ pendingFit = null
455
+ }
456
+ clamp()
457
+ touch()
458
+ }
459
+ if (dt > 0) {
460
+ vx += (dragDx / dt - vx) * VELOCITY_SMOOTH
461
+ vy += (dragDy / dt - vy) * VELOCITY_SMOOTH
462
+ dragDx = 0
463
+ dragDy = 0
464
+ lastDt = dt
465
+ }
466
+ if (fling !== null && dt > 0) {
467
+ shift(fling.vx * dt, fling.vy * dt)
468
+ let decay = Math.exp(-FLING_DECAY * dt)
469
+ fling.vx *= decay
470
+ fling.vy *= decay
471
+ if (Math.hypot(fling.vx, fling.vy) < LAND_PX * FLING_DECAY) fling = null
472
+ clamp()
473
+ touch()
474
+ }
475
+ if (glide !== null && dt > 0) {
476
+ let k = 1 - Math.exp(-GLIDE_EASE * dt)
477
+ if (glide.kind === "anchor") {
478
+ let gap = Math.log(glide.target / zoom)
479
+ zoom = Math.abs(gap) < GLIDE_EPSILON ? glide.target : zoom * Math.exp(gap * k)
480
+ ;[x, y] = anchorPose(glide.wx, glide.wy, glide.sx, glide.sy, zoom)
481
+ if (zoom === glide.target) glide = null
482
+ } else {
483
+ let gap = Math.log(glide.zoom / zoom)
484
+ let landed = Math.abs(gap) < GLIDE_EPSILON && Math.hypot(glide.x - x, glide.y - y) * zoom < LAND_PX
485
+ if (landed) {
486
+ x = glide.x
487
+ y = glide.y
488
+ zoom = glide.zoom
489
+ glide = null
490
+ } else {
491
+ zoom *= Math.exp(gap * k)
492
+ x += (glide.x - x) * k
493
+ y += (glide.y - y) * k
494
+ }
495
+ }
496
+ clamp()
497
+ touch()
498
+ }
499
+ if (followAt !== null && !followSettled && dt > 0 && known()) {
500
+ // How far the target sits outside the dead zone, in screen pixels;
501
+ // the camera eases by that overshoot so the target rides the zone
502
+ // edge, or snaps the last sub-pixel and settles.
503
+ let [sx, sy] = projectCamera(camera(), followAt.x, followAt.y)
504
+ let hw = (deadW * vw) / 2
505
+ let hh = (deadH * vh) / 2
506
+ let ox = sx < px() - hw ? sx - (px() - hw) : sx > px() + hw ? sx - (px() + hw) : 0
507
+ let oy = sy < py() - hh ? sy - (py() - hh) : sy > py() + hh ? sy - (py() + hh) : 0
508
+ if (Math.hypot(ox, oy) < LAND_PX) {
509
+ if (ox !== 0 || oy !== 0) {
510
+ shift(-ox, -oy)
511
+ clamp()
512
+ dirty = true
513
+ }
514
+ followSettled = true
515
+ } else {
516
+ let k = 1 - Math.exp(-followEase * dt)
517
+ shift(-ox * k, -oy * k)
518
+ clamp()
519
+ dirty = true
520
+ }
521
+ }
522
+ if (!dirty) {
523
+ notify()
524
+ return false
525
+ }
526
+ dirty = false
527
+ apply()
528
+ notify()
529
+ return true
530
+ },
531
+ }
532
+ }
package/src/camera.ts ADDED
@@ -0,0 +1,86 @@
1
+ // The 2d camera vocabulary, pure: the one camera type both layers share
2
+ // and its world <-> screen mapping as plain functions - no GPU or GUI
3
+ // imports, so the checks rig (checks/camera-check.ts) exercises this
4
+ // module headless on the flux binary. Three consumers implement or undo
5
+ // exactly this mapping and must agree with it: the vertex stages
6
+ // (shaders.ts, uCamera + uCameraRot), the tile layer's composite
7
+ // transform (the <view> props in components.tsx) and the pointer inverse
8
+ // (spriteDispatch in layer.ts, which calls unprojectCamera directly). The
9
+ // check keeps the spellings from drifting.
10
+
11
+ /**
12
+ * A camera over a 2d world - the sprite layer's `setCamera`/`camera` prop
13
+ * and the tile layer's `TileCamera` alike. The world point (`x`, `y`) is
14
+ * shown at the viewport point (`pivotX`, `pivotY`), the world scaled by
15
+ * `zoom` and rotated by `rotation` ABOUT that pivot. Rotation turns the
16
+ * world clockwise (y-down) about the pivot; to render a heading `h`
17
+ * upward use `rotation = -h - Math.PI / 2`. The pivot defaults to (0, 0),
18
+ * which makes `{ x, y, zoom }` mean "world x/y at the viewport top-left" -
19
+ * the plain scrolling camera.
20
+ *
21
+ * As a `setCamera` update, absent keys keep their values (the params
22
+ * rule); as an argument to projectCamera/unprojectCamera, absent keys
23
+ * read as the defaults (x/y/rotation 0, zoom 1, pivot (0, 0)).
24
+ */
25
+ export type CameraUpdate = {
26
+ /** World pixel shown at the pivot (the viewport top-left by default). */
27
+ x?: number
28
+ y?: number
29
+ /** World-to-screen scale; 1 is pixel-for-pixel. */
30
+ zoom?: number
31
+ /** Rotation, radians, clockwise (y-down), about the pivot. */
32
+ rotation?: number
33
+ /** Viewport point the camera's world point pins to; default (0, 0). */
34
+ pivotX?: number
35
+ pivotY?: number
36
+ }
37
+
38
+ /** A camera snapshot (a layer's `camera()`): CameraUpdate's fields, all
39
+ * present - the argument for projectCamera/unprojectCamera. */
40
+ export type CameraState = Required<CameraUpdate>
41
+
42
+ const CAMERA_FIELDS = ["x", "y", "zoom", "rotation", "pivotX", "pivotY"] as const
43
+
44
+ /**
45
+ * Validate a setCamera update (throws - the dev validation policy): every
46
+ * present field must be finite, zoom additionally positive. A NaN rotation
47
+ * or pivot would otherwise poison the uniforms and blank the layer with no
48
+ * error. Internal - both layer kinds call it at the top of setCamera.
49
+ */
50
+ export function checkCamera(update: CameraUpdate): void {
51
+ for (let key of CAMERA_FIELDS) {
52
+ let value = update[key]
53
+ if (value !== undefined && !Number.isFinite(value)) {
54
+ throw new Error(`setCamera: ${key} must be a finite number, got ${value}`)
55
+ }
56
+ }
57
+ if (update.zoom !== undefined && update.zoom <= 0) {
58
+ throw new Error(`setCamera: zoom must be positive, got ${update.zoom}`)
59
+ }
60
+ }
61
+
62
+ /**
63
+ * World -> screen under a camera:
64
+ * `pivot + R(rotation) * zoom * (world - cam)`. Screen means the layer's
65
+ * viewport pixels (the sprite layer's output, the tile world's container).
66
+ */
67
+ export function projectCamera(camera: CameraUpdate, worldX: number, worldY: number): [number, number] {
68
+ let zoom = camera.zoom ?? 1
69
+ let rotation = camera.rotation ?? 0
70
+ let c = Math.cos(rotation)
71
+ let s = Math.sin(rotation)
72
+ let dx = (worldX - (camera.x ?? 0)) * zoom
73
+ let dy = (worldY - (camera.y ?? 0)) * zoom
74
+ return [(camera.pivotX ?? 0) + dx * c - dy * s, (camera.pivotY ?? 0) + dx * s + dy * c]
75
+ }
76
+
77
+ /** Screen -> world: the exact inverse of projectCamera. */
78
+ export function unprojectCamera(camera: CameraUpdate, screenX: number, screenY: number): [number, number] {
79
+ let zoom = camera.zoom ?? 1
80
+ let rotation = camera.rotation ?? 0
81
+ let c = Math.cos(rotation)
82
+ let s = Math.sin(rotation)
83
+ let dx = screenX - (camera.pivotX ?? 0)
84
+ let dy = screenY - (camera.pivotY ?? 0)
85
+ return [(camera.x ?? 0) + (dx * c + dy * s) / zoom, (camera.y ?? 0) + (dy * c - dx * s) / zoom]
86
+ }