@solidrt/2d 0.0.60 → 0.0.61
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/AGENTS.md +119 -48
- package/examples/camera.tsx +60 -22
- package/examples/split-screen.tsx +1 -1
- package/package.json +3 -3
- package/src/camera2d.ts +282 -198
- package/src/components/camera2d.tsx +10 -8
- package/src/components/index.ts +2 -0
- package/src/components/shots.tsx +90 -0
- package/src/components/sprite-layer.tsx +13 -0
- package/src/index.ts +4 -2
- package/src/input.ts +5 -3
- package/src/layer.ts +4 -1
- package/src/shots.ts +50 -0
- package/src/tiles-math.ts +62 -0
- package/src/tiles.ts +47 -63
package/src/camera2d.ts
CHANGED
|
@@ -2,8 +2,19 @@
|
|
|
2
2
|
// over the layers' shared CameraUpdate - a pose (the world point at a
|
|
3
3
|
// viewport pivot, zoom, rotation), the contain clamp against world
|
|
4
4
|
// bounds, anchored zoom, eased glides (a wheel notch, glideTo, fit),
|
|
5
|
-
// follow
|
|
6
|
-
//
|
|
5
|
+
// follow through Cinemachine's framing (a dead zone, hard limits,
|
|
6
|
+
// damping per axis, lookahead), drag inertia, rotation about the pivot,
|
|
7
|
+
// and the lanes (a screen offset, shakes) - extracted so apps stop
|
|
8
|
+
// rebuilding the same hundred lines.
|
|
9
|
+
//
|
|
10
|
+
// The control runs the pipeline of okf/design/camera-controls.md: a
|
|
11
|
+
// SOURCE (an input nudge, a verb, or the follow) writes the pose; the
|
|
12
|
+
// FRAMING eases the followed point back into its zones; the LANES are
|
|
13
|
+
// summed on top of the pose; the CONSTRAINTS (the zoom range, the
|
|
14
|
+
// contain clamp) apply to pose plus the persistent offset and move the
|
|
15
|
+
// pose, and clip a shake at push without touching it; and the PUSH
|
|
16
|
+
// writes the final camera. pose() is the pose alone; camera() is the
|
|
17
|
+
// final camera as the layers received it (what projectCamera takes).
|
|
7
18
|
//
|
|
8
19
|
// The control is pure (ARCHITECTURE.md: controls through an abstraction,
|
|
9
20
|
// never direct event handling): it consumes three axes, `pan` (vec2),
|
|
@@ -16,14 +27,16 @@
|
|
|
16
27
|
//
|
|
17
28
|
// Conventions, decided against Godot's Camera2D, Unity's Cinemachine and
|
|
18
29
|
// Three's MapControls (plus the map/whiteboard libraries for the canvas
|
|
19
|
-
// half): the pose is "world point at
|
|
20
|
-
// to the viewport center (Godot's
|
|
21
|
-
// position, a map's center);
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
30
|
+
// half; okf/notes/2d-camera-conventions.md): the pose is "world point at
|
|
31
|
+
// the pivot" with the pivot defaulting to the viewport center (Godot's
|
|
32
|
+
// drag-center anchor, Cinemachine's screen position, a map's center);
|
|
33
|
+
// bounds CONTAIN the view and center an axis whose view is wider than
|
|
34
|
+
// the world (Cinemachine's confiner skeleton, tldraw's contain origin -
|
|
35
|
+
// Godot's left-wins is the worse behavior), at once for a direct write
|
|
36
|
+
// and eased when `world.damping` says so for a motion (Godot's
|
|
37
|
+
// limit_smoothed); limits ignore rotation, clamping the unrotated view
|
|
38
|
+
// rect (Godot's rule); every ease is exponential and frame-rate
|
|
39
|
+
// independent, zoom eased in log space so a long glide reads evenly.
|
|
27
40
|
//
|
|
28
41
|
// Input units: a `pan` delta is finger travel in viewport heights (the
|
|
29
42
|
// pointer feed's unit), applied as content travel - the world follows the
|
|
@@ -33,51 +46,39 @@
|
|
|
33
46
|
// unbracketed (a wheel notch) it retargets an eased glide, which is how
|
|
34
47
|
// the control tells a finger from an impulse. A `roll` delta is turns. A
|
|
35
48
|
// pan gesture's begin stops any glide (a finger landing on a gliding view
|
|
36
|
-
// holds it), its end flings with the
|
|
49
|
+
// holds it), its end flings with the release velocity the gesture
|
|
50
|
+
// measured (the pointer feed's, in viewport heights per second: one
|
|
51
|
+
// estimator for every recognizer, velocity.ts in core), decaying.
|
|
37
52
|
//
|
|
38
53
|
// Pose is plain mutable state; a nudge or a verb pushes it at once (the
|
|
39
54
|
// next paint carries the new camera, no frame loop needed for a drag),
|
|
40
|
-
// and update(dt) advances glides, follow, inertia and the axis
|
|
41
|
-
// the app's own onFrame. `active()` is the reactive frame-loop
|
|
42
|
-
// glide, fling, fit or
|
|
43
|
-
// camera costs nothing. update() pushes the
|
|
44
|
-
// it changed (one setCamera per driven
|
|
45
|
-
// however many sprites exist) and reports
|
|
46
|
-
// (labels re-projected via projectCamera)
|
|
47
|
-
// recomputing every frame.
|
|
55
|
+
// and update(dt) advances glides, follow, inertia, the lanes and the axis
|
|
56
|
+
// rates from the app's own onFrame. `active()` is the reactive frame-loop
|
|
57
|
+
// gate (a glide, fling, fit, follow, shake or damped bound in flight, or
|
|
58
|
+
// a rate driving); a resting camera costs nothing. update() pushes the
|
|
59
|
+
// pose to the layers only when it changed (one setCamera per driven
|
|
60
|
+
// layer: a shared-params write, however many sprites exist) and reports
|
|
61
|
+
// that, so per-frame dependents (labels re-projected via projectCamera)
|
|
62
|
+
// can follow the camera without recomputing every frame.
|
|
48
63
|
|
|
49
|
-
import {
|
|
64
|
+
import { untrack } from "@solidjs/signals"
|
|
50
65
|
import { createAxes } from "@solidrt/core/input"
|
|
51
66
|
import type { Axes, Vec2 } from "@solidrt/core/input"
|
|
67
|
+
import { checkFollowOptions, checkOffset, checkShake, createActivity, createLanes, createLookahead, easeStep, frame, GLIDE_EASE, GLIDE_EPSILON } from "@solidrt/core/camera-control"
|
|
68
|
+
import type { FollowOptions } from "@solidrt/core/camera-control"
|
|
52
69
|
import { projectCamera, unprojectCamera } from "./camera.ts"
|
|
53
|
-
import type { CameraUpdate } from "./camera.ts"
|
|
70
|
+
import type { CameraState, CameraUpdate } from "./camera.ts"
|
|
54
71
|
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
// A glide lands when the remaining zoom gap is under this (relative) ...
|
|
60
|
-
const GLIDE_EPSILON = 0.002
|
|
61
|
-
// ... and the remaining travel is under this many screen pixels. Also the
|
|
62
|
-
// follow's settle threshold and, times the fling decay, the speed a fling
|
|
63
|
-
// stops at (the distance it would still travel).
|
|
72
|
+
// A glide lands when the remaining travel is under this many screen
|
|
73
|
+
// pixels. Also the follow's settle threshold, the damped bound's, and,
|
|
74
|
+
// times the fling decay, the speed a fling stops at (the distance it
|
|
75
|
+
// would still travel).
|
|
64
76
|
const LAND_PX = 0.5
|
|
65
|
-
// Follow damping, e-foldings per second (Godot's default
|
|
66
|
-
// position_smoothing_speed).
|
|
67
|
-
const FOLLOW_EASE = 5
|
|
68
77
|
// Inertia after a drag release: velocity decay in e-foldings per second. A
|
|
69
78
|
// 1500 px/s flick travels 500 px; iOS scrolling decelerates at ~2, maps
|
|
70
|
-
// are snappier.
|
|
79
|
+
// are snappier. The release speed itself comes from the gesture (a
|
|
80
|
+
// rested finger reads zero there), so nothing gates it here.
|
|
71
81
|
const FLING_DECAY = 3
|
|
72
|
-
// Release speeds under this (px/s) do not fling: a finger that stopped
|
|
73
|
-
// before lifting leaves the view where it is.
|
|
74
|
-
const FLING_MIN_SPEED = 50
|
|
75
|
-
// Per-update EMA weight of the drag velocity estimate: heavy on the newest
|
|
76
|
-
// frame so a direction change registers within a frame or two.
|
|
77
|
-
const VELOCITY_SMOOTH = 0.5
|
|
78
|
-
// Frame time assumed for the drag delta still pending at a release when the
|
|
79
|
-
// release arrives in the same frame as the last move.
|
|
80
|
-
const FALLBACK_DT = 1 / 60
|
|
81
82
|
// Rate axes at full deflection, per second: viewport heights of pan,
|
|
82
83
|
// octaves of zoom (1 = the zoom doubles each second), turns of roll.
|
|
83
84
|
const PAN_RATE = 1
|
|
@@ -103,8 +104,12 @@ export type Camera2dOptions = Camera2dPose & {
|
|
|
103
104
|
* clamping and fitting wait for a real size. */
|
|
104
105
|
viewport: () => { width: number; height: number }
|
|
105
106
|
/** World bounds (origin 0,0) the view is kept inside: an axis whose view
|
|
106
|
-
* is wider than the world centers. Absent = unbounded.
|
|
107
|
-
|
|
107
|
+
* is wider than the world centers. Absent = unbounded. `damping` eases
|
|
108
|
+
* a MOTION (a follow, a glide, a fling) into the bounds instead of
|
|
109
|
+
* cutting it at the edge, as a multiple of the built-in settle time
|
|
110
|
+
* (Godot's limit_smoothed); a direct write (a drag, set) always clamps
|
|
111
|
+
* at once. Default 0: at once. */
|
|
112
|
+
world?: { width: number; height: number; damping?: number }
|
|
108
113
|
/** Zoom range. minZoom defaults to the fit zoom (whole world visible)
|
|
109
114
|
* when `world` is given, else to no lower bound; maxZoom to no upper
|
|
110
115
|
* bound. An explicit maxZoom below the fit zoom wins, the world then
|
|
@@ -115,9 +120,16 @@ export type Camera2dOptions = Camera2dPose & {
|
|
|
115
120
|
* its size; default the center. glideTo and follow land their point
|
|
116
121
|
* here and rotation turns about it. */
|
|
117
122
|
pivot?: { x: number; y: number }
|
|
118
|
-
/**
|
|
119
|
-
* the
|
|
120
|
-
|
|
123
|
+
/** How follow(x, y) chases its point (the framing stage): damping per
|
|
124
|
+
* axis, the dead zone and hard limits as fractions of the viewport
|
|
125
|
+
* centered on the pivot, lookahead. Read where it applies. */
|
|
126
|
+
follow?: FollowOptions
|
|
127
|
+
/** The offset lane: where the pose's world point is shown, in viewport
|
|
128
|
+
* HEIGHTS from the pivot (x right, y down; [0, -0.25] shows it a
|
|
129
|
+
* quarter of the height above the pivot - the look-up of a platformer).
|
|
130
|
+
* Added on top of the pose at push, never in pose(); the bounds
|
|
131
|
+
* contain pose plus offset by moving the pose. */
|
|
132
|
+
offset?: Vec2
|
|
121
133
|
/** Multipliers over the built-in pan, zoom (pinch, wheel and rate) and
|
|
122
134
|
* roll sensitivities. */
|
|
123
135
|
panSpeed?: number
|
|
@@ -128,8 +140,6 @@ export type Camera2dOptions = Camera2dPose & {
|
|
|
128
140
|
* built-in ease, 2 coasts twice as long, 0 applies notches at once, as
|
|
129
141
|
* a pinch does. The 3d controls' knob, same meaning. */
|
|
130
142
|
damping?: number
|
|
131
|
-
/** Multiplier over the built-in follow damping. */
|
|
132
|
-
followSpeed?: number
|
|
133
143
|
/** Whether a drag release keeps the view gliding (default true). */
|
|
134
144
|
inertia?: boolean
|
|
135
145
|
}
|
|
@@ -137,54 +147,68 @@ export type Camera2dOptions = Camera2dPose & {
|
|
|
137
147
|
export type Camera2dAxes = { pan: "vec2"; zoom: "axis"; roll: "axis" }
|
|
138
148
|
|
|
139
149
|
export type Camera2d = {
|
|
140
|
-
/** The
|
|
141
|
-
* argument for
|
|
142
|
-
|
|
150
|
+
/** The final camera as the layers received it (a fresh object per
|
|
151
|
+
* call, every field set): the pose plus the lanes - the argument for
|
|
152
|
+
* projectCamera/unprojectCamera. */
|
|
153
|
+
camera(): CameraState
|
|
143
154
|
/** Pose snapshot - the control's own four values, the shape set() takes
|
|
144
|
-
* (
|
|
155
|
+
* (the 3d controls' pose()); no lane in it. */
|
|
145
156
|
pose(): Required<Camera2dPose>
|
|
146
157
|
/** Merge a pose in (clamps apply) and push it. A snap: an x/y/zoom
|
|
147
|
-
* write
|
|
148
|
-
* running. */
|
|
158
|
+
* write drops a glide or fling in flight and ends the follow; a
|
|
159
|
+
* rotation write leaves them running. */
|
|
149
160
|
set(pose: Camera2dPose): void
|
|
150
161
|
/** Pan the content by a screen delta (a drag: positive dx slides the
|
|
151
|
-
* world
|
|
152
|
-
* flings. */
|
|
162
|
+
* world right, the camera point moves left), clamped, and push. */
|
|
153
163
|
panBy(dx: number, dy: number): void
|
|
154
|
-
/** End of a drag: keep gliding
|
|
155
|
-
|
|
164
|
+
/** End of a drag: keep gliding from its release velocity, screen pixels
|
|
165
|
+
* per second of content travel, decaying (nothing while following, or
|
|
166
|
+
* with `inertia` off). */
|
|
167
|
+
release(velocity: Vec2): void
|
|
156
168
|
/** Zoom by `factor` about a screen point - the world point under it
|
|
157
|
-
* stays under it
|
|
158
|
-
*
|
|
159
|
-
*
|
|
169
|
+
* stays under it (factor > 1 zooms in), clamped to the zoom range and
|
|
170
|
+
* the bounds, and push. `glide` eases there instead (a wheel notch),
|
|
171
|
+
* retargeting the pending glide so notches compound. */
|
|
160
172
|
zoomAt(sx: number, sy: number, factor: number, opts?: { glide?: boolean }): void
|
|
161
|
-
/** Turn
|
|
173
|
+
/** Turn the world about the pivot by radians (clockwise, y-down) and push. */
|
|
162
174
|
rollBy(angle: number): void
|
|
163
|
-
/** Ease the pose
|
|
164
|
-
* the current
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
175
|
+
/** Ease the pose to put world (x, y) at the pivot, at `zoom` (default
|
|
176
|
+
* the current, or a pending wheel or double-tap zoom's target) and
|
|
177
|
+
* `rotation` (default the current), landing exactly; a fling or a
|
|
178
|
+
* follow yields. */
|
|
179
|
+
glideTo(x: number, y: number, zoom?: number, rotation?: number): void
|
|
180
|
+
/** Frame a world rect (default the whole world): centered in the view
|
|
181
|
+
* at the zoom that fits it, clamped. A snap unless `glide`. Waits for
|
|
182
|
+
* the viewport when its size is not known yet. */
|
|
168
183
|
fit(rect?: Rect2d, opts?: { glide?: boolean }): void
|
|
169
|
-
/**
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
184
|
+
/** Chase a world point (the framing stage, options.follow): the pose
|
|
185
|
+
* eases toward it per axis, through the dead zone and hard limits,
|
|
186
|
+
* with lookahead. Call it every frame for a moving point; the control
|
|
187
|
+
* settles once the point rests inside the zones. A pose glide yields;
|
|
188
|
+
* an anchor glide (a wheel zoom) keeps running - zoom and follow are
|
|
189
|
+
* separate axes. */
|
|
173
190
|
follow(x: number, y: number): void
|
|
174
191
|
unfollow(): void
|
|
192
|
+
/** A shake on the lanes: `strength` is the peak displacement in
|
|
193
|
+
* viewport heights, `duration` seconds, an optional frequency (cycles
|
|
194
|
+
* per second) and direction (a unit [x, y], y down). Shakes sum and
|
|
195
|
+
* decay; the pose is untouched, and at a world edge the shake is
|
|
196
|
+
* clipped by the bounds (it never shows the outside). */
|
|
197
|
+
shake(strength: number, duration: number, opts?: { frequency?: number; direction?: Vec2 }): void
|
|
175
198
|
/** Stop a glide or fling in flight (a press landing). */
|
|
176
199
|
interrupt(): void
|
|
177
200
|
/** The world-space bounding box of what the viewport shows. */
|
|
178
201
|
viewRect(): Rect2d
|
|
179
|
-
/** Advance glides, follow, inertia and the axis rates, then
|
|
180
|
-
*
|
|
181
|
-
* dt in seconds; returns whether the
|
|
182
|
-
* update (nudges and verbs included). */
|
|
202
|
+
/** Advance glides, follow, inertia, the lanes and the axis rates, then
|
|
203
|
+
* push the camera to the layers if it changed. Call from onFrame with
|
|
204
|
+
* the frame's dt in seconds; returns whether the camera changed since
|
|
205
|
+
* the previous update (nudges and verbs included). */
|
|
183
206
|
update(dt: number): boolean
|
|
184
207
|
/**
|
|
185
208
|
* Reactive: whether update() still has work - a glide, fling or
|
|
186
|
-
* deferred fit in flight, a follow engaged
|
|
187
|
-
*
|
|
209
|
+
* deferred fit in flight, a follow engaged and not settled, a shake
|
|
210
|
+
* running, a damped bound still easing, a write not yet pushed, or an
|
|
211
|
+
* axis rate driving. The frame-loop gate: run update(dt) on frames
|
|
188
212
|
* while true, and nothing while false (a resting camera costs no frames).
|
|
189
213
|
*/
|
|
190
214
|
active(): boolean
|
|
@@ -195,7 +219,7 @@ export type Camera2d = {
|
|
|
195
219
|
axes: Axes<Camera2dAxes>
|
|
196
220
|
}
|
|
197
221
|
|
|
198
|
-
type Glide = { kind: "anchor"; target: number; sx: number; sy: number; wx: number; wy: number } | { kind: "pose"; x: number; y: number; zoom: number }
|
|
222
|
+
type Glide = { kind: "anchor"; target: number; sx: number; sy: number; wx: number; wy: number } | { kind: "pose"; x: number; y: number; zoom: number; rotation: number }
|
|
199
223
|
|
|
200
224
|
let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
|
|
201
225
|
|
|
@@ -214,16 +238,15 @@ function checkPose(verb: string, pose: Camera2dPose): void {
|
|
|
214
238
|
if (pose.zoom !== undefined) positive(`${verb} zoom`, pose.zoom)
|
|
215
239
|
}
|
|
216
240
|
|
|
217
|
-
function checkFraction(what: string, v: number): void {
|
|
218
|
-
if (!(Number.isFinite(v) && v >= 0 && v <= 1)) throw new Error(`createCamera2d: ${what} must be within 0..1, got ${v}`)
|
|
219
|
-
}
|
|
220
|
-
|
|
221
241
|
/** The non-pose options, validated at creation and again at every set()
|
|
222
242
|
* (the live-option re-clamp entry). */
|
|
223
243
|
function checkOptions(options: Camera2dOptions): void {
|
|
224
244
|
if (options.world) {
|
|
225
245
|
positive("world.width", options.world.width)
|
|
226
246
|
positive("world.height", options.world.height)
|
|
247
|
+
if (options.world.damping !== undefined && !(Number.isFinite(options.world.damping) && options.world.damping >= 0)) {
|
|
248
|
+
throw new Error(`createCamera2d: world.damping must be a non-negative number, got ${options.world.damping}`)
|
|
249
|
+
}
|
|
227
250
|
}
|
|
228
251
|
if (options.minZoom !== undefined) positive("minZoom", options.minZoom)
|
|
229
252
|
if (options.maxZoom !== undefined) positive("maxZoom", options.maxZoom)
|
|
@@ -235,8 +258,8 @@ function checkOptions(options: Camera2dOptions): void {
|
|
|
235
258
|
if (options.damping !== undefined && !(Number.isFinite(options.damping) && options.damping >= 0)) {
|
|
236
259
|
throw new Error(`createCamera2d: damping must be a non-negative number, got ${options.damping}`)
|
|
237
260
|
}
|
|
238
|
-
|
|
239
|
-
|
|
261
|
+
checkFollowOptions("createCamera2d", options.follow)
|
|
262
|
+
checkOffset("createCamera2d", options.offset)
|
|
240
263
|
}
|
|
241
264
|
|
|
242
265
|
/**
|
|
@@ -247,10 +270,10 @@ function checkOptions(options: Camera2dOptions): void {
|
|
|
247
270
|
* applies immediately: the world fitted when `world` is given and no
|
|
248
271
|
* `zoom`. The pose options are initial values; every other option is read
|
|
249
272
|
* where it applies, so a caller holding the options object (or a props
|
|
250
|
-
* object handing out getters) changes bounds, pivot,
|
|
251
|
-
* live - `set({})` re-clamps and pushes the pose under new
|
|
252
|
-
* component tree, prefer `<Camera2d>`: it drives the nearest
|
|
253
|
-
* context and takes an input map as a prop.
|
|
273
|
+
* object handing out getters) changes bounds, pivot, the follow's zones
|
|
274
|
+
* and rates live - `set({})` re-clamps and pushes the pose under new
|
|
275
|
+
* bounds. In a component tree, prefer `<Camera2d>`: it drives the nearest
|
|
276
|
+
* view through context and takes an input map as a prop.
|
|
254
277
|
*/
|
|
255
278
|
export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], options: Camera2dOptions): Camera2d {
|
|
256
279
|
let targets = Array.isArray(target) ? target : [target]
|
|
@@ -265,14 +288,12 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
|
|
|
265
288
|
let maxZoom = () => options.maxZoom ?? Infinity
|
|
266
289
|
let pivotFx = () => options.pivot?.x ?? 0.5
|
|
267
290
|
let pivotFy = () => options.pivot?.y ?? 0.5
|
|
268
|
-
let deadW = () => options.deadZone?.width ?? 0
|
|
269
|
-
let deadH = () => options.deadZone?.height ?? 0
|
|
270
291
|
let panSpeed = () => options.panSpeed ?? 1
|
|
271
292
|
let zoomExponent = () => options.zoomSpeed ?? 1
|
|
272
293
|
let rollSpeed = () => options.rollSpeed ?? 1
|
|
273
294
|
let damping = () => options.damping ?? 1
|
|
274
|
-
let followEase = () => FOLLOW_EASE * (options.followSpeed ?? 1)
|
|
275
295
|
let inertia = () => options.inertia ?? true
|
|
296
|
+
let boundsDamping = () => options.world?.damping ?? 0
|
|
276
297
|
|
|
277
298
|
let w0 = world()
|
|
278
299
|
let x = options.x ?? (w0 ? w0.width / 2 : 0)
|
|
@@ -302,8 +323,49 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
|
|
|
302
323
|
return Math.min(options.minZoom ?? fit, maxZoom())
|
|
303
324
|
}
|
|
304
325
|
|
|
326
|
+
// The pose as a camera (no lanes): what the framing and the anchors
|
|
327
|
+
// measure against.
|
|
328
|
+
let poseCamera = (): CameraState => ({ x, y, zoom, rotation, pivotX: px(), pivotY: py() })
|
|
329
|
+
// Pose x/y that put world (wx, wy) under screen (sx, sy) at zoom z:
|
|
330
|
+
// the inverse of projectCamera solved for the camera point.
|
|
331
|
+
let anchorPose = (wx: number, wy: number, sx: number, sy: number, z: number): [number, number] => {
|
|
332
|
+
let c = Math.cos(rotation)
|
|
333
|
+
let s = Math.sin(rotation)
|
|
334
|
+
let dx = sx - px()
|
|
335
|
+
let dy = sy - py()
|
|
336
|
+
return [wx - (dx * c + dy * s) / z, wy - (dy * c - dx * s) / z]
|
|
337
|
+
}
|
|
338
|
+
// ---- The lanes ----
|
|
339
|
+
let lanes = createLanes()
|
|
340
|
+
// The camera point for a pose point under a lane offset (viewport
|
|
341
|
+
// heights): the pose's world point is shown at the pivot plus the
|
|
342
|
+
// offset, so the point AT the pivot is the one that far back.
|
|
343
|
+
let shifted = (cx: number, cy: number, z: number, ox: number, oy: number): [number, number] => {
|
|
344
|
+
if (ox === 0 && oy === 0) return [cx, cy]
|
|
345
|
+
let c = Math.cos(rotation)
|
|
346
|
+
let s = Math.sin(rotation)
|
|
347
|
+
let dx = ox * vh
|
|
348
|
+
let dy = oy * vh
|
|
349
|
+
return [cx - (dx * c + dy * s) / z, cy - (dy * c - dx * s) / z]
|
|
350
|
+
}
|
|
351
|
+
// The persistent lane only: what the bounds contain (moving the pose)
|
|
352
|
+
// and what a zoom or glide lands against.
|
|
353
|
+
let withOffset = (cx: number, cy: number, z: number): [number, number] => shifted(cx, cy, z, options.offset?.[0] ?? 0, options.offset?.[1] ?? 0)
|
|
354
|
+
let steadyCamera = (): CameraState => {
|
|
355
|
+
let [fx, fy] = withOffset(x, y, zoom)
|
|
356
|
+
return { x: fx, y: fy, zoom, rotation, pivotX: px(), pivotY: py() }
|
|
357
|
+
}
|
|
358
|
+
// The final camera: the shakes on top, clipped by the bounds at push
|
|
359
|
+
// (a shake never enters the pose and never shows the outside).
|
|
360
|
+
let camera = (): CameraState => {
|
|
361
|
+
let [ox, oy] = lanes.total(options.offset)
|
|
362
|
+
let [fx, fy] = shifted(x, y, zoom, ox, oy)
|
|
363
|
+
if (lanes.active()) [fx, fy] = contain(fx, fy, zoom)
|
|
364
|
+
return { x: fx, y: fy, zoom, rotation, pivotX: px(), pivotY: py() }
|
|
365
|
+
}
|
|
366
|
+
|
|
305
367
|
// The contain clamp on an unrotated view rect (Godot's rule: limits
|
|
306
|
-
// ignore rotation): returns the
|
|
368
|
+
// ignore rotation): returns the camera point, moved inside the world.
|
|
307
369
|
let contain = (cx: number, cy: number, z: number): [number, number] => {
|
|
308
370
|
let w = world()
|
|
309
371
|
if (!w || !known()) return [cx, cy]
|
|
@@ -315,21 +377,34 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
|
|
|
315
377
|
top = viewH >= w.height ? (w.height - viewH) / 2 : clampNum(top, 0, w.height - viewH)
|
|
316
378
|
return [left + px() / z, top + py() / z]
|
|
317
379
|
}
|
|
318
|
-
|
|
380
|
+
// The constraints on pose plus offset: the zoom range, then the contain
|
|
381
|
+
// clamp of the camera point, which moves the pose by the same delta
|
|
382
|
+
// (the offset stays). At once, or eased over dt when the bounds damp a
|
|
383
|
+
// motion; leaves whether the pose is contained now in boundsSettled.
|
|
384
|
+
let boundsSettled = true
|
|
385
|
+
let clampTo = (dt: number | null) => {
|
|
319
386
|
zoom = clampNum(zoom, minZoom(), maxZoom())
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
let
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
387
|
+
let [fx, fy] = withOffset(x, y, zoom)
|
|
388
|
+
let [cx, cy] = contain(fx, fy, zoom)
|
|
389
|
+
let dx = cx - fx
|
|
390
|
+
let dy = cy - fy
|
|
391
|
+
if (dx === 0 && dy === 0) {
|
|
392
|
+
boundsSettled = true
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
let d = boundsDamping()
|
|
396
|
+
if (dt === null || d <= 0 || Math.hypot(dx, dy) * zoom < LAND_PX) {
|
|
397
|
+
x += dx
|
|
398
|
+
y += dy
|
|
399
|
+
boundsSettled = true
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
let k = easeStep(GLIDE_EASE / d, dt)
|
|
403
|
+
x += dx * k
|
|
404
|
+
y += dy * k
|
|
405
|
+
boundsSettled = false
|
|
332
406
|
}
|
|
407
|
+
let clamp = () => clampTo(null)
|
|
333
408
|
// Slide the content by a screen delta: the camera point moves the
|
|
334
409
|
// opposite way, the delta un-rotated into world axes.
|
|
335
410
|
let shift = (dx: number, dy: number) => {
|
|
@@ -340,19 +415,15 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
|
|
|
340
415
|
}
|
|
341
416
|
|
|
342
417
|
let dirty = false
|
|
343
|
-
// Whether the
|
|
418
|
+
// Whether the camera changed since the last update() (pushes included).
|
|
344
419
|
let changed = false
|
|
345
420
|
let glide: Glide | null = null
|
|
346
421
|
let fling: { vx: number; vy: number } | null = null
|
|
347
422
|
let followAt: { x: number; y: number } | null = null
|
|
348
423
|
// A follow that reached its target stops writing until something moves.
|
|
349
424
|
let followSettled = false
|
|
350
|
-
|
|
351
|
-
let
|
|
352
|
-
let dragDy = 0
|
|
353
|
-
let vx = 0
|
|
354
|
-
let vy = 0
|
|
355
|
-
let lastDt = FALLBACK_DT
|
|
425
|
+
let lookahead = createLookahead()
|
|
426
|
+
let predicted: number[] = [0, 0]
|
|
356
427
|
// A fit that waits for the viewport (the default pose when a world is
|
|
357
428
|
// given, or an explicit fit() before the size is known).
|
|
358
429
|
let pendingFit: { rect: Rect2d | null; glide: boolean } | null = options.zoom === undefined && w0 ? { rect: null, glide: false } : null
|
|
@@ -361,19 +432,11 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
|
|
|
361
432
|
dirty = true
|
|
362
433
|
followSettled = false
|
|
363
434
|
}
|
|
364
|
-
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
let
|
|
369
|
-
let busy = () => dirty || dragging || glide !== null || fling !== null || followAt !== null || pendingFit !== null
|
|
370
|
-
// The motion's half of active(), a signal every public entry refreshes
|
|
371
|
-
// (ownedWrite: entries run from component bodies and handlers alike).
|
|
372
|
-
let [motionActive, setMotionActive] = createSignal(false, { ownedWrite: true })
|
|
373
|
-
let notify = () => {
|
|
374
|
-
let now = busy()
|
|
375
|
-
if (now !== untrack(motionActive)) setMotionActive(now)
|
|
376
|
-
}
|
|
435
|
+
let busy = () => dirty || glide !== null || fling !== null || (followAt !== null && !followSettled) || pendingFit !== null || lanes.active() || !boundsSettled
|
|
436
|
+
// The frame-loop gate (core's createActivity), built once the axes
|
|
437
|
+
// exist; notified at the end of every public entry.
|
|
438
|
+
let activity!: ReturnType<typeof createActivity>
|
|
439
|
+
let notify = () => activity.notify()
|
|
377
440
|
let interrupt = () => {
|
|
378
441
|
glide = null
|
|
379
442
|
fling = null
|
|
@@ -393,22 +456,33 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
|
|
|
393
456
|
}
|
|
394
457
|
|
|
395
458
|
let zoomTo = (sx: number, sy: number, next: number) => {
|
|
396
|
-
let [wx, wy] = unprojectCamera(
|
|
459
|
+
let [wx, wy] = unprojectCamera(steadyCamera(), sx, sy)
|
|
397
460
|
zoom = clampNum(next, minZoom(), maxZoom())
|
|
398
|
-
|
|
461
|
+
let [nx, ny] = anchorPose(wx, wy, sx, sy, zoom)
|
|
462
|
+
// anchorPose places the camera point; the pose sits the offset back
|
|
463
|
+
// from it.
|
|
464
|
+
let [lx, ly] = withOffset(0, 0, zoom)
|
|
465
|
+
x = nx - lx
|
|
466
|
+
y = ny - ly
|
|
399
467
|
clamp()
|
|
400
468
|
touch()
|
|
401
469
|
}
|
|
402
|
-
let glideTo = (tx: number, ty: number, tz?: number) => {
|
|
470
|
+
let glideTo = (tx: number, ty: number, tz?: number, tr?: number) => {
|
|
403
471
|
finite("glideTo x", tx)
|
|
404
472
|
finite("glideTo y", ty)
|
|
473
|
+
finite("glideTo rotation", tr)
|
|
405
474
|
if (tz !== undefined) positive("glideTo zoom", tz)
|
|
406
475
|
readViewport()
|
|
407
476
|
fling = null
|
|
408
477
|
followAt = null
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
478
|
+
// A pending anchor glide (a wheel notch, a double tap's octave) hands
|
|
479
|
+
// its zoom over: the glide centres the point AND lands that zoom,
|
|
480
|
+
// instead of the later verb cancelling the earlier gesture.
|
|
481
|
+
let pending = glide !== null && glide.kind === "anchor" ? glide.target : zoom
|
|
482
|
+
let z = clampNum(tz ?? pending, minZoom(), maxZoom())
|
|
483
|
+
let [lx, ly] = withOffset(0, 0, z)
|
|
484
|
+
let [cx, cy] = contain(tx + lx, ty + ly, z)
|
|
485
|
+
glide = { kind: "pose", x: cx - lx, y: cy - ly, zoom: z, rotation: tr ?? rotation }
|
|
412
486
|
}
|
|
413
487
|
let fitNow = (rect: Rect2d | null, ease: boolean) => {
|
|
414
488
|
let w = world()
|
|
@@ -416,13 +490,15 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
|
|
|
416
490
|
if (!r) throw new Error("createCamera2d: fit() needs a rect when the camera has no world")
|
|
417
491
|
let z = clampNum(Math.min(vw / r.width, vh / r.height), minZoom(), maxZoom())
|
|
418
492
|
let [nx, ny] = anchorPose(r.x + r.width / 2, r.y + r.height / 2, vw / 2, vh / 2, z)
|
|
493
|
+
let [lx, ly] = withOffset(0, 0, z)
|
|
419
494
|
if (ease) {
|
|
420
|
-
glideTo(nx, ny, z)
|
|
495
|
+
glideTo(nx - lx, ny - ly, z)
|
|
421
496
|
return
|
|
422
497
|
}
|
|
423
498
|
interrupt()
|
|
424
|
-
|
|
425
|
-
|
|
499
|
+
followAt = null
|
|
500
|
+
x = nx - lx
|
|
501
|
+
y = ny - ly
|
|
426
502
|
zoom = z
|
|
427
503
|
clamp()
|
|
428
504
|
touch()
|
|
@@ -432,27 +508,12 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
|
|
|
432
508
|
interrupt()
|
|
433
509
|
shift(dx, dy)
|
|
434
510
|
clamp()
|
|
435
|
-
dragDx += dx
|
|
436
|
-
dragDy += dy
|
|
437
511
|
touch()
|
|
438
512
|
}
|
|
439
|
-
let release = () => {
|
|
440
|
-
// Fold the delta still pending from the release's own frame in with
|
|
441
|
-
// the last frame time, then start from rest either way. The fling
|
|
442
|
-
// gates on the SMOOTHED velocity too: a finger that stopped before
|
|
443
|
-
// lifting has let it decay over the still frames, and the stray delta
|
|
444
|
-
// the release frame may still carry (the resampler's last correction)
|
|
445
|
-
// must not fling the view on its own.
|
|
446
|
-
let smoothed = Math.hypot(vx, vy)
|
|
447
|
-
let rvx = vx + (dragDx / lastDt - vx) * VELOCITY_SMOOTH
|
|
448
|
-
let rvy = vy + (dragDy / lastDt - vy) * VELOCITY_SMOOTH
|
|
449
|
-
dragDx = 0
|
|
450
|
-
dragDy = 0
|
|
451
|
-
vx = 0
|
|
452
|
-
vy = 0
|
|
513
|
+
let release = (velocity: Vec2) => {
|
|
453
514
|
if (!inertia() || followAt !== null) return
|
|
454
|
-
if (
|
|
455
|
-
fling = { vx:
|
|
515
|
+
if (velocity[0] === 0 && velocity[1] === 0) return
|
|
516
|
+
fling = { vx: velocity[0], vy: velocity[1] }
|
|
456
517
|
}
|
|
457
518
|
let zoomAt = (sx: number, sy: number, factor: number, ease: boolean) => {
|
|
458
519
|
positive("zoomAt factor", factor)
|
|
@@ -468,7 +529,7 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
|
|
|
468
529
|
// long push; the anchor is the world point under the pointer now.
|
|
469
530
|
let from = glide !== null ? glide.target : zoom
|
|
470
531
|
let target = clampNum(from * factor, minZoom(), maxZoom())
|
|
471
|
-
let [wx, wy] = unprojectCamera(
|
|
532
|
+
let [wx, wy] = unprojectCamera(steadyCamera(), sx, sy)
|
|
472
533
|
glide = { kind: "anchor", target, sx, sy, wx, wy }
|
|
473
534
|
}
|
|
474
535
|
let rollBy = (angle: number) => {
|
|
@@ -481,18 +542,36 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
|
|
|
481
542
|
// gesture has none.
|
|
482
543
|
let focalPx = (focal: Vec2 | undefined): [number, number] => (focal ? [focal[0] * vw, focal[1] * vh] : [px(), py()])
|
|
483
544
|
|
|
545
|
+
// ---- The follow: the framing stage ----
|
|
546
|
+
let stepFollow = (dt: number): boolean => {
|
|
547
|
+
let point = lookahead.predict([followAt!.x, followAt!.y], options.follow?.lookahead, dt, predicted)
|
|
548
|
+
// The point's offset from the pivot in viewport fractions, measured
|
|
549
|
+
// against the POSE camera: the lanes shift the picture afterwards.
|
|
550
|
+
let [sx, sy] = projectCamera(poseCamera(), point[0]!, point[1]!)
|
|
551
|
+
let framed = frame([(sx - px()) / vw, (sy - py()) / vh], options.follow, dt, LAND_PX / vh)
|
|
552
|
+
let moved = framed.x !== 0 || framed.y !== 0
|
|
553
|
+
if (moved) {
|
|
554
|
+
shift(-framed.x * vw, -framed.y * vh)
|
|
555
|
+
clampTo(dt)
|
|
556
|
+
dirty = true
|
|
557
|
+
}
|
|
558
|
+
followSettled = framed.settled
|
|
559
|
+
return moved
|
|
560
|
+
}
|
|
561
|
+
|
|
484
562
|
let axes = createAxes<Camera2dAxes>(
|
|
485
563
|
{ pan: "vec2", zoom: "axis", roll: "axis" },
|
|
486
564
|
{
|
|
487
565
|
onBegin: name => {
|
|
488
566
|
if (name !== "pan") return
|
|
489
|
-
dragging = true
|
|
490
567
|
interrupt()
|
|
491
568
|
},
|
|
492
|
-
onEnd: name => {
|
|
569
|
+
onEnd: (name, velocity) => {
|
|
493
570
|
if (name !== "pan") return
|
|
494
|
-
|
|
495
|
-
|
|
571
|
+
// Viewport heights per second of finger travel to screen pixels
|
|
572
|
+
// per second of content travel, the pan delta's own mapping.
|
|
573
|
+
let v = (velocity as Vec2 | undefined) ?? [0, 0]
|
|
574
|
+
release([v[0] * vh * panSpeed(), v[1] * vh * panSpeed()])
|
|
496
575
|
notify()
|
|
497
576
|
},
|
|
498
577
|
onNudge: (name, delta, focal) => {
|
|
@@ -511,7 +590,8 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
|
|
|
511
590
|
},
|
|
512
591
|
},
|
|
513
592
|
)
|
|
514
|
-
|
|
593
|
+
activity = createActivity(busy, () => axes.active())
|
|
594
|
+
let active = activity.active
|
|
515
595
|
|
|
516
596
|
readViewport()
|
|
517
597
|
clamp()
|
|
@@ -535,7 +615,10 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
|
|
|
535
615
|
// change), so the options are validated here as at creation.
|
|
536
616
|
checkOptions(options)
|
|
537
617
|
readViewport()
|
|
538
|
-
if (pose.x !== undefined || pose.y !== undefined || pose.zoom !== undefined)
|
|
618
|
+
if (pose.x !== undefined || pose.y !== undefined || pose.zoom !== undefined) {
|
|
619
|
+
interrupt()
|
|
620
|
+
followAt = null
|
|
621
|
+
}
|
|
539
622
|
if (pose.x !== undefined) x = pose.x
|
|
540
623
|
if (pose.y !== undefined) y = pose.y
|
|
541
624
|
if (pose.zoom !== undefined) zoom = pose.zoom
|
|
@@ -550,8 +633,11 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
|
|
|
550
633
|
panBy(dx, dy)
|
|
551
634
|
flush()
|
|
552
635
|
},
|
|
553
|
-
release() {
|
|
554
|
-
release()
|
|
636
|
+
release(velocity) {
|
|
637
|
+
if (!Array.isArray(velocity) || velocity.length !== 2) throw new Error(`createCamera2d: release() takes [vx, vy], got ${JSON.stringify(velocity)}`)
|
|
638
|
+
finite("release vx", velocity[0])
|
|
639
|
+
finite("release vy", velocity[1])
|
|
640
|
+
release(velocity)
|
|
555
641
|
notify()
|
|
556
642
|
},
|
|
557
643
|
zoomAt(sx, sy, factor, opts) {
|
|
@@ -565,8 +651,8 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
|
|
|
565
651
|
rollBy(angle)
|
|
566
652
|
flush()
|
|
567
653
|
},
|
|
568
|
-
glideTo(tx, ty, tz) {
|
|
569
|
-
glideTo(tx, ty, tz)
|
|
654
|
+
glideTo(tx, ty, tz, tr) {
|
|
655
|
+
glideTo(tx, ty, tz, tr)
|
|
570
656
|
notify()
|
|
571
657
|
},
|
|
572
658
|
fit(rect, opts) {
|
|
@@ -588,8 +674,10 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
|
|
|
588
674
|
// a moving target must not cancel the zoom under it.
|
|
589
675
|
if (glide !== null && glide.kind === "pose") glide = null
|
|
590
676
|
fling = null
|
|
591
|
-
if (followAt === null)
|
|
592
|
-
|
|
677
|
+
if (followAt === null) {
|
|
678
|
+
followAt = { x: tx, y: ty }
|
|
679
|
+
lookahead.reset()
|
|
680
|
+
} else {
|
|
593
681
|
followAt.x = tx
|
|
594
682
|
followAt.y = ty
|
|
595
683
|
}
|
|
@@ -600,6 +688,11 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
|
|
|
600
688
|
followAt = null
|
|
601
689
|
notify()
|
|
602
690
|
},
|
|
691
|
+
shake(strength, duration, opts) {
|
|
692
|
+
checkShake("createCamera2d", strength, duration, opts)
|
|
693
|
+
lanes.shake(strength, duration, opts)
|
|
694
|
+
notify()
|
|
695
|
+
},
|
|
603
696
|
interrupt,
|
|
604
697
|
viewRect() {
|
|
605
698
|
readViewport()
|
|
@@ -653,11 +746,6 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
|
|
|
653
746
|
clamp()
|
|
654
747
|
touch()
|
|
655
748
|
}
|
|
656
|
-
vx += (dragDx / dt - vx) * VELOCITY_SMOOTH
|
|
657
|
-
vy += (dragDy / dt - vy) * VELOCITY_SMOOTH
|
|
658
|
-
dragDx = 0
|
|
659
|
-
dragDy = 0
|
|
660
|
-
lastDt = dt
|
|
661
749
|
}
|
|
662
750
|
if (fling !== null && dt > 0) {
|
|
663
751
|
shift(fling.vx * dt, fling.vy * dt)
|
|
@@ -665,57 +753,53 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
|
|
|
665
753
|
fling.vx *= decay
|
|
666
754
|
fling.vy *= decay
|
|
667
755
|
if (Math.hypot(fling.vx, fling.vy) < LAND_PX * FLING_DECAY) fling = null
|
|
668
|
-
|
|
756
|
+
clampTo(dt)
|
|
669
757
|
touch()
|
|
670
758
|
}
|
|
671
759
|
if (glide !== null && dt > 0) {
|
|
672
760
|
// A wheel notch's glide coasts as `damping` says; a commanded
|
|
673
761
|
// glide (glideTo, fit) at the built-in rate.
|
|
674
|
-
let k =
|
|
762
|
+
let k = easeStep(glide.kind === "anchor" ? GLIDE_EASE / damping() : GLIDE_EASE, dt)
|
|
675
763
|
if (glide.kind === "anchor") {
|
|
676
764
|
let gap = Math.log(glide.target / zoom)
|
|
677
765
|
zoom = Math.abs(gap) < GLIDE_EPSILON ? glide.target : zoom * Math.exp(gap * k)
|
|
678
|
-
|
|
766
|
+
let [nx, ny] = anchorPose(glide.wx, glide.wy, glide.sx, glide.sy, zoom)
|
|
767
|
+
let [lx, ly] = withOffset(0, 0, zoom)
|
|
768
|
+
x = nx - lx
|
|
769
|
+
y = ny - ly
|
|
679
770
|
if (zoom === glide.target) glide = null
|
|
680
771
|
} else {
|
|
681
772
|
let gap = Math.log(glide.zoom / zoom)
|
|
682
|
-
let
|
|
773
|
+
let turn = glide.rotation - rotation
|
|
774
|
+
let landed = Math.abs(gap) < GLIDE_EPSILON && Math.hypot(glide.x - x, glide.y - y) * zoom < LAND_PX && Math.abs(turn) * Math.hypot(vw, vh) < LAND_PX
|
|
683
775
|
if (landed) {
|
|
684
776
|
x = glide.x
|
|
685
777
|
y = glide.y
|
|
686
778
|
zoom = glide.zoom
|
|
779
|
+
rotation = glide.rotation
|
|
687
780
|
glide = null
|
|
688
781
|
} else {
|
|
689
782
|
zoom *= Math.exp(gap * k)
|
|
690
783
|
x += (glide.x - x) * k
|
|
691
784
|
y += (glide.y - y) * k
|
|
785
|
+
rotation += turn * k
|
|
692
786
|
}
|
|
693
787
|
}
|
|
694
|
-
|
|
788
|
+
clampTo(dt)
|
|
695
789
|
touch()
|
|
696
790
|
}
|
|
697
791
|
if (followAt !== null && !followSettled && dt > 0 && known()) {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
clamp()
|
|
710
|
-
dirty = true
|
|
711
|
-
}
|
|
712
|
-
followSettled = true
|
|
713
|
-
} else {
|
|
714
|
-
let k = 1 - Math.exp(-followEase() * dt)
|
|
715
|
-
shift(-ox * k, -oy * k)
|
|
716
|
-
clamp()
|
|
717
|
-
dirty = true
|
|
718
|
-
}
|
|
792
|
+
stepFollow(dt)
|
|
793
|
+
}
|
|
794
|
+
if (lanes.active() && dt > 0) {
|
|
795
|
+
lanes.step(dt)
|
|
796
|
+
dirty = true
|
|
797
|
+
}
|
|
798
|
+
if (!boundsSettled && dt > 0 && glide === null && fling === null && (followAt === null || followSettled)) {
|
|
799
|
+
// A damped bound still easing after the motion that crossed it
|
|
800
|
+
// has landed: finish the way in.
|
|
801
|
+
clampTo(dt)
|
|
802
|
+
dirty = true
|
|
719
803
|
}
|
|
720
804
|
if (dirty) {
|
|
721
805
|
dirty = false
|