@solidrt/core 0.0.59 → 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 +100 -16
- package/agents/performance.md +5 -1
- package/examples/README.md +2 -1
- package/examples/gestures.tsx +197 -0
- package/examples/layout-slide.tsx +44 -15
- package/package.json +6 -5
- package/src/arena.ts +55 -0
- package/src/audio.ts +4 -3
- package/src/camera-control.ts +428 -0
- package/src/cursor.ts +113 -0
- package/src/double-tap.ts +150 -0
- package/src/gesture.ts +32 -0
- package/src/index.ts +16 -2
- package/src/input-axes.ts +12 -7
- package/src/input-chord.ts +3 -0
- package/src/input-gamepad-device.ts +115 -6
- package/src/input-id.ts +92 -0
- package/src/input-keyboard.ts +49 -2
- package/src/input-map.ts +268 -68
- package/src/input-pointer.ts +240 -16
- package/src/input-processors.ts +214 -0
- package/src/input.ts +4 -2
- package/src/long-press.ts +137 -0
- package/src/pan.ts +23 -4
- package/src/renderer.ts +16 -6
- package/src/runtime-modules.d.ts +4 -0
- package/src/svg.ts +1 -0
- package/src/swipe.ts +130 -0
- package/src/text-input.ts +104 -46
- package/src/transform.ts +23 -3
- package/src/types.d.ts +44 -7
- package/src/velocity.ts +134 -0
- package/src/video.ts +82 -15
- package/src/window.ts +16 -9
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
// The pure half every camera control shares (okf/design/camera-controls.md):
|
|
2
|
+
// the ease, the framing of a followed point, and the lanes. Runtime-free,
|
|
3
|
+
// so the 2d and 3d controls import it and their headless checks run it
|
|
4
|
+
// on the bare flux binary.
|
|
5
|
+
//
|
|
6
|
+
// Ease: every self-driven motion (a glide, a damped wheel notch, a follow)
|
|
7
|
+
// closes the gap to its goal by the same exponential fraction per frame,
|
|
8
|
+
// 1 - e^(-rate * dt), never a fixed fraction, so it is frame-rate
|
|
9
|
+
// independent and reads the same in 2d and 3d.
|
|
10
|
+
//
|
|
11
|
+
// Framing (Cinemachine's Position Composer): a followed point is measured
|
|
12
|
+
// as an offset from the pivot in viewport fractions. Inside the dead zone
|
|
13
|
+
// the camera does not respond; in the band between the dead zone and the
|
|
14
|
+
// hard limits (the soft zone) it eases the point back toward the dead
|
|
15
|
+
// edge, damped per axis; beyond the hard limits the point is clamped
|
|
16
|
+
// inside at once, whatever the damping - the guarantee that a fast target
|
|
17
|
+
// never leaves the screen. Lookahead projects the point ahead along its
|
|
18
|
+
// velocity, the velocity estimated from successive points and smoothed,
|
|
19
|
+
// so a noisy input does not jitter the camera.
|
|
20
|
+
//
|
|
21
|
+
// Lanes: additive offsets summed on top of the pose at push time and
|
|
22
|
+
// never written into it, so pose() stays what the app set or the follow
|
|
23
|
+
// reached: a persistent screen offset (Babylon's targetScreenOffset,
|
|
24
|
+
// Godot's offset), and shakes - decaying oscillations that sum
|
|
25
|
+
// (Cinemachine Impulse, Phaser's shake). Both in viewport fractions; the
|
|
26
|
+
// control maps them to its own space at push.
|
|
27
|
+
|
|
28
|
+
import { createMemo, createSignal } from "@solidjs/signals"
|
|
29
|
+
|
|
30
|
+
export type Vec2 = [number, number]
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A control's frame-loop gate: `active()` is reactive, true while the
|
|
34
|
+
* control's own motion (`busy()`: a glide, a follow, a shake, an eased
|
|
35
|
+
* return) or any of its axis rates (`rates()`) has work. `notify()` is
|
|
36
|
+
* called at the end of every entry that may start or stop a motion. The
|
|
37
|
+
* plain flag is the truth and the signal mirrors it: a signal read
|
|
38
|
+
* between flushes reports the value before the queued writes, so a
|
|
39
|
+
* control comparing against its own signal drops the write back to
|
|
40
|
+
* false once a true is queued (a check rig that never flushes mid-run
|
|
41
|
+
* shows it; a live app, which flushes every frame, hides it).
|
|
42
|
+
*/
|
|
43
|
+
export function createActivity(busy: () => boolean, rates: () => boolean) {
|
|
44
|
+
// ownedWrite: entries run from component bodies and handlers alike.
|
|
45
|
+
let [motion, setMotion] = createSignal(false, { ownedWrite: true })
|
|
46
|
+
let on = false
|
|
47
|
+
let active = createMemo(() => motion() || rates())
|
|
48
|
+
return {
|
|
49
|
+
active,
|
|
50
|
+
notify() {
|
|
51
|
+
let now = busy()
|
|
52
|
+
if (now !== on) {
|
|
53
|
+
on = now
|
|
54
|
+
setMotion(now)
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// E-foldings per second toward a goal: high enough that a wheel notch
|
|
61
|
+
// reads as one push, low enough to look smooth.
|
|
62
|
+
export const GLIDE_EASE = 9
|
|
63
|
+
// A motion lands - snaps to its goal and stops - once this fraction of
|
|
64
|
+
// its initial gap remains: under a pixel on any pose component a glide
|
|
65
|
+
// moves through at ordinary distances.
|
|
66
|
+
export const GLIDE_EPSILON = 0.001
|
|
67
|
+
// Follow damping, e-foldings per second (Godot's default
|
|
68
|
+
// position_smoothing_speed).
|
|
69
|
+
export const FOLLOW_EASE = 5
|
|
70
|
+
// A shake's default oscillation rate, cycles per second: a hand-held
|
|
71
|
+
// judder, the range Cinemachine's 6D shake presets sit in.
|
|
72
|
+
export const SHAKE_FREQUENCY = 12
|
|
73
|
+
|
|
74
|
+
/** The fraction of the remaining gap an ease at `rate` closes over dt. */
|
|
75
|
+
export let easeStep = (rate: number, dt: number): number => 1 - Math.exp(-rate * dt)
|
|
76
|
+
|
|
77
|
+
/** A zone's size as fractions of the viewport, centred on the pivot. */
|
|
78
|
+
export type Zone = { width: number; height: number }
|
|
79
|
+
|
|
80
|
+
/** How a follow chases its point (the framing stage). Every field is
|
|
81
|
+
* read where it applies, so a live options object changes it on the
|
|
82
|
+
* next update. */
|
|
83
|
+
export type FollowOptions = {
|
|
84
|
+
/** How long the follow takes to close in, as a multiple of the built-in
|
|
85
|
+
* settle time (1, the default): 2 trails twice as lazily, 0 snaps.
|
|
86
|
+
* Per axis as `{ x, y }` (2d) or `{ x, y, z }` (3d: right, up,
|
|
87
|
+
* forward) - a platformer's tight horizontal and lazy vertical. */
|
|
88
|
+
damping?: number | { x?: number; y?: number; z?: number }
|
|
89
|
+
/** The point roams inside it without moving the camera. Default 0. */
|
|
90
|
+
deadZone?: Zone
|
|
91
|
+
/** The point is clamped inside it at once; the band between the dead
|
|
92
|
+
* zone and this is where the damping applies. Default: none (the whole
|
|
93
|
+
* plane); Cinemachine's default for a game camera is 0.8 x 0.8. */
|
|
94
|
+
hardLimits?: Zone
|
|
95
|
+
/** Frame ahead of the point by `time` seconds of its velocity, the
|
|
96
|
+
* velocity smoothed over `smoothing` seconds (default 0: raw). */
|
|
97
|
+
lookahead?: { time: number; smoothing?: number }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A per-axis correction: how far the camera must move its view so the
|
|
101
|
+
* point returns toward the pivot, in the offset's units, and whether the
|
|
102
|
+
* framing has settled (nothing left to move that a frame would show). */
|
|
103
|
+
export type Framing = { x: number; y: number; settled: boolean }
|
|
104
|
+
|
|
105
|
+
let dampingOf = (d: FollowOptions["damping"], axis: "x" | "y" | "z"): number => {
|
|
106
|
+
if (d === undefined) return 1
|
|
107
|
+
if (typeof d === "number") return d
|
|
108
|
+
return d[axis] ?? 1
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The ease rate of one follow axis, e-foldings per second (Infinity for
|
|
112
|
+
* damping 0: at once). */
|
|
113
|
+
export let followRate = (options: FollowOptions | undefined, axis: "x" | "y" | "z"): number => {
|
|
114
|
+
let d = dampingOf(options?.damping, axis)
|
|
115
|
+
return d <= 0 ? Infinity : FOLLOW_EASE / d
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// One axis of the zones: the correction for an offset `o` from the pivot
|
|
119
|
+
// given the half-sizes of the dead zone and the hard limits, eased by k
|
|
120
|
+
// (the fraction of the soft overshoot closed this frame; 1 snaps).
|
|
121
|
+
let frameAxis = (o: number, dead: number, hard: number, k: number): number => {
|
|
122
|
+
let a = Math.abs(o)
|
|
123
|
+
if (a <= dead) return 0
|
|
124
|
+
let sign = o < 0 ? -1 : 1
|
|
125
|
+
let over = Math.min(a, hard) - dead
|
|
126
|
+
let beyond = a > hard ? a - hard : 0
|
|
127
|
+
return sign * (beyond + over * k)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Frame a followed point: `offset` is the point's position relative to
|
|
132
|
+
* the pivot in viewport fractions (x right, y down), the result the
|
|
133
|
+
* amount the view must move so the point comes back inside the zones,
|
|
134
|
+
* in the same units (subtract it from the point's screen position). The
|
|
135
|
+
* hard part applies whole; the soft part closes by the axis's ease over
|
|
136
|
+
* dt. `epsilon` (viewport fractions) is the remainder under which the
|
|
137
|
+
* soft part snaps and the framing reports settled, so a resting follow
|
|
138
|
+
* writes nothing.
|
|
139
|
+
*/
|
|
140
|
+
export function frame(offset: Vec2, options: FollowOptions | undefined, dt: number, epsilon: number): Framing {
|
|
141
|
+
let dead = options?.deadZone
|
|
142
|
+
let hard = options?.hardLimits
|
|
143
|
+
let dw = (dead?.width ?? 0) / 2
|
|
144
|
+
let dh = (dead?.height ?? 0) / 2
|
|
145
|
+
let hw = hard ? Math.max(hard.width / 2, dw) : Infinity
|
|
146
|
+
let hh = hard ? Math.max(hard.height / 2, dh) : Infinity
|
|
147
|
+
let kx = easeStep(followRate(options, "x"), dt)
|
|
148
|
+
let ky = easeStep(followRate(options, "y"), dt)
|
|
149
|
+
let x = frameAxis(offset[0], dw, hw, kx)
|
|
150
|
+
let y = frameAxis(offset[1], dh, hh, ky)
|
|
151
|
+
// What the soft part would still leave after this frame: under epsilon
|
|
152
|
+
// the whole overshoot applies and the framing rests.
|
|
153
|
+
let restX = frameAxis(offset[0], dw, hw, 1)
|
|
154
|
+
let restY = frameAxis(offset[1], dh, hh, 1)
|
|
155
|
+
if (Math.hypot(restX - x, restY - y) < epsilon) return { x: restX, y: restY, settled: true }
|
|
156
|
+
return { x, y, settled: false }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Zone fractions must be within 0..1 (a zone wider than the viewport
|
|
160
|
+
* frames nothing); throws with the control's prefix. */
|
|
161
|
+
export function checkFollowOptions(prefix: string, options: FollowOptions | undefined): void {
|
|
162
|
+
if (!options) return
|
|
163
|
+
let fraction = (what: string, v: number | undefined) => {
|
|
164
|
+
if (v !== undefined && !(Number.isFinite(v) && v >= 0 && v <= 1)) throw new Error(`${prefix}: ${what} must be within 0..1, got ${v}`)
|
|
165
|
+
}
|
|
166
|
+
let nonNegative = (what: string, v: number | undefined) => {
|
|
167
|
+
if (v !== undefined && !(Number.isFinite(v) && v >= 0)) throw new Error(`${prefix}: ${what} must be a non-negative number, got ${v}`)
|
|
168
|
+
}
|
|
169
|
+
fraction("follow.deadZone.width", options.deadZone?.width)
|
|
170
|
+
fraction("follow.deadZone.height", options.deadZone?.height)
|
|
171
|
+
fraction("follow.hardLimits.width", options.hardLimits?.width)
|
|
172
|
+
fraction("follow.hardLimits.height", options.hardLimits?.height)
|
|
173
|
+
let d = options.damping
|
|
174
|
+
if (typeof d === "number") nonNegative("follow.damping", d)
|
|
175
|
+
else if (d) {
|
|
176
|
+
nonNegative("follow.damping.x", d.x)
|
|
177
|
+
nonNegative("follow.damping.y", d.y)
|
|
178
|
+
nonNegative("follow.damping.z", d.z)
|
|
179
|
+
}
|
|
180
|
+
nonNegative("follow.lookahead.time", options.lookahead?.time)
|
|
181
|
+
nonNegative("follow.lookahead.smoothing", options.lookahead?.smoothing)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The lookahead filter over a followed point of any dimension: feed it
|
|
186
|
+
* the point each frame and read the predicted point - the point plus
|
|
187
|
+
* `time` seconds of its velocity, the velocity estimated from successive
|
|
188
|
+
* points and smoothed over `smoothing` seconds (an exponential average).
|
|
189
|
+
* reset() forgets the velocity (a teleport, a new follow).
|
|
190
|
+
*/
|
|
191
|
+
export function createLookahead() {
|
|
192
|
+
let prev: number[] | null = null
|
|
193
|
+
let velocity: number[] = []
|
|
194
|
+
return {
|
|
195
|
+
predict(point: ArrayLike<number>, options: FollowOptions["lookahead"], dt: number, out: number[]): number[] {
|
|
196
|
+
let n = point.length
|
|
197
|
+
if (prev === null || prev.length !== n) {
|
|
198
|
+
prev = Array.from(point)
|
|
199
|
+
velocity = new Array(n).fill(0)
|
|
200
|
+
}
|
|
201
|
+
if (dt > 0) {
|
|
202
|
+
let smoothing = options?.smoothing ?? 0
|
|
203
|
+
let k = smoothing > 0 ? easeStep(1 / smoothing, dt) : 1
|
|
204
|
+
for (let i = 0; i < n; i++) {
|
|
205
|
+
let v = (point[i]! - prev[i]!) / dt
|
|
206
|
+
velocity[i] = velocity[i]! + (v - velocity[i]!) * k
|
|
207
|
+
prev[i] = point[i]!
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
let time = options?.time ?? 0
|
|
211
|
+
for (let i = 0; i < n; i++) out[i] = point[i]! + velocity[i]! * time
|
|
212
|
+
return out
|
|
213
|
+
},
|
|
214
|
+
reset() {
|
|
215
|
+
prev = null
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// A shake in flight: a damped oscillation along a direction, its amplitude
|
|
221
|
+
// falling linearly to zero over the duration.
|
|
222
|
+
type Shake = { strength: number; duration: number; frequency: number; dx: number; dy: number; phase: number; t: number }
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The lanes: a persistent `offset` (viewport fractions, x right, y down)
|
|
226
|
+
* and shakes summed over it. `step(dt)` advances the shakes, `total()`
|
|
227
|
+
* is what a control adds to its pose at push, `active()` whether a shake
|
|
228
|
+
* is still running (the frame-loop gate's lane half).
|
|
229
|
+
*/
|
|
230
|
+
export function createLanes() {
|
|
231
|
+
let shakes: Shake[] = []
|
|
232
|
+
let total: Vec2 = [0, 0]
|
|
233
|
+
return {
|
|
234
|
+
/** Start a shake: `strength` is its peak in viewport fractions,
|
|
235
|
+
* `duration` seconds, `frequency` cycles per second (default
|
|
236
|
+
* SHAKE_FREQUENCY). `direction` fixes its axis (a unit vector, y
|
|
237
|
+
* down); default random, so repeated hits read as a judder, not a
|
|
238
|
+
* metronome. Shakes sum. */
|
|
239
|
+
shake(strength: number, duration: number, opts?: { frequency?: number; direction?: Vec2 }) {
|
|
240
|
+
let dir = opts?.direction
|
|
241
|
+
let angle = Math.random() * 2 * Math.PI
|
|
242
|
+
let dx = dir ? dir[0] : Math.cos(angle)
|
|
243
|
+
let dy = dir ? dir[1] : Math.sin(angle)
|
|
244
|
+
let len = Math.hypot(dx, dy) || 1
|
|
245
|
+
shakes.push({ strength, duration, frequency: opts?.frequency ?? SHAKE_FREQUENCY, dx: dx / len, dy: dy / len, phase: dir ? Math.PI / 2 : Math.random() * 2 * Math.PI, t: 0 })
|
|
246
|
+
},
|
|
247
|
+
step(dt: number) {
|
|
248
|
+
if (shakes.length === 0) return
|
|
249
|
+
let kept: Shake[] = []
|
|
250
|
+
for (let s of shakes) {
|
|
251
|
+
s.t += dt
|
|
252
|
+
if (s.t < s.duration) kept.push(s)
|
|
253
|
+
}
|
|
254
|
+
shakes = kept
|
|
255
|
+
},
|
|
256
|
+
/** The summed lane offset now: `offset` plus every running shake. */
|
|
257
|
+
total(offset: Vec2 | undefined): Vec2 {
|
|
258
|
+
total[0] = offset?.[0] ?? 0
|
|
259
|
+
total[1] = offset?.[1] ?? 0
|
|
260
|
+
for (let s of shakes) {
|
|
261
|
+
let amplitude = s.strength * (1 - s.t / s.duration)
|
|
262
|
+
let wave = Math.sin(2 * Math.PI * s.frequency * s.t + s.phase) * amplitude
|
|
263
|
+
total[0] += s.dx * wave
|
|
264
|
+
total[1] += s.dy * wave
|
|
265
|
+
}
|
|
266
|
+
return total
|
|
267
|
+
},
|
|
268
|
+
active: () => shakes.length > 0,
|
|
269
|
+
clear() {
|
|
270
|
+
shakes = []
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export type Lanes = ReturnType<typeof createLanes>
|
|
276
|
+
|
|
277
|
+
/** A lane offset must be a finite pair; a shake's strength and duration
|
|
278
|
+
* finite and positive. Throws with the control's prefix. */
|
|
279
|
+
export function checkShake(prefix: string, strength: number, duration: number, opts?: { frequency?: number; direction?: Vec2 }): void {
|
|
280
|
+
if (!(Number.isFinite(strength) && strength >= 0)) throw new Error(`${prefix}: shake strength must be a non-negative number, got ${strength}`)
|
|
281
|
+
if (!(Number.isFinite(duration) && duration > 0)) throw new Error(`${prefix}: shake duration must be a positive number of seconds, got ${duration}`)
|
|
282
|
+
if (opts?.frequency !== undefined && !(Number.isFinite(opts.frequency) && opts.frequency > 0)) throw new Error(`${prefix}: shake frequency must be a positive number, got ${opts.frequency}`)
|
|
283
|
+
if (opts?.direction !== undefined && !(Array.isArray(opts.direction) && opts.direction.length === 2 && Number.isFinite(opts.direction[0]) && Number.isFinite(opts.direction[1]))) {
|
|
284
|
+
throw new Error(`${prefix}: shake direction must be [dx, dy], got ${JSON.stringify(opts.direction)}`)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function checkOffset(prefix: string, offset: Vec2 | undefined): void {
|
|
289
|
+
if (offset === undefined) return
|
|
290
|
+
if (!(Array.isArray(offset) && offset.length === 2 && Number.isFinite(offset[0]) && Number.isFinite(offset[1]))) throw new Error(`${prefix}: offset must be [x, y] viewport fractions, got ${JSON.stringify(offset)}`)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---- Shots and blends ----
|
|
294
|
+
//
|
|
295
|
+
// Cinemachine's architecture of a game camera, over the controls: several
|
|
296
|
+
// cameras (shots), each driven by its own control into a RECORDING target
|
|
297
|
+
// instead of the scene, a priority that says which is live, and a blend
|
|
298
|
+
// between the outgoing output and the new live shot over a fixed time.
|
|
299
|
+
// The blender owns the one push to the real target; a live shot's push
|
|
300
|
+
// goes straight through at rest, so a still camera costs no frames, and
|
|
301
|
+
// a blend runs on update(dt). A switch mid-blend starts from the output
|
|
302
|
+
// of that moment, so a quick back-and-forth never jumps.
|
|
303
|
+
|
|
304
|
+
// The default blend time, seconds: short enough for a game cut to read as
|
|
305
|
+
// deliberate, long enough to see (Cinemachine's default is 2 s, a
|
|
306
|
+
// cinematic pace; maps use a third of a second).
|
|
307
|
+
export const BLEND_SECONDS = 0.5
|
|
308
|
+
|
|
309
|
+
/** A shot's recording target: what its control drives. `camera()` is
|
|
310
|
+
* the shot's latest full camera (the control's partial updates merged
|
|
311
|
+
* over the initial one). */
|
|
312
|
+
export type ShotTarget<C> = { setCamera(update: Partial<C>): void; camera(): C }
|
|
313
|
+
|
|
314
|
+
export type ShotBlendOptions = {
|
|
315
|
+
/** The default blend time in seconds when the live shot changes
|
|
316
|
+
* (BLEND_SECONDS); 0 cuts. */
|
|
317
|
+
blend?: number
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The generic blender: `push` writes the output camera to the real
|
|
322
|
+
* target, `mix(a, b, t)` interpolates two full cameras (the package
|
|
323
|
+
* decides the space: zoom in log space, ortho extents, ...), `initial`
|
|
324
|
+
* is the camera every shot starts from. A shot is live when it is the
|
|
325
|
+
* enabled one with the highest priority (the most recently enabled on a
|
|
326
|
+
* tie); with none enabled nothing is pushed. The blend eases in and out
|
|
327
|
+
* (smoothstep) over its time, and lands exactly.
|
|
328
|
+
*/
|
|
329
|
+
export function createShotBlend<C extends object>(push: (camera: C) => void, mix: (a: C, b: C, t: number) => C, initial: C, options: ShotBlendOptions = {}) {
|
|
330
|
+
type Shot = { name: string; priority: number; latest: C; enabled: boolean; order: number }
|
|
331
|
+
let shots = new Map<string, Shot>()
|
|
332
|
+
let live: Shot | null = null
|
|
333
|
+
// The output as last pushed, the start of a blend when frozen.
|
|
334
|
+
let current: C = { ...initial }
|
|
335
|
+
let from: C | null = null
|
|
336
|
+
let progress = 0
|
|
337
|
+
let duration = 0
|
|
338
|
+
let order = 0
|
|
339
|
+
let activity = createActivity(() => from !== null, () => false)
|
|
340
|
+
let choose = (): Shot | null => {
|
|
341
|
+
let best: Shot | null = null
|
|
342
|
+
for (let s of shots.values()) {
|
|
343
|
+
if (!s.enabled) continue
|
|
344
|
+
if (best === null || s.priority > best.priority || (s.priority === best.priority && s.order > best.order)) best = s
|
|
345
|
+
}
|
|
346
|
+
return best
|
|
347
|
+
}
|
|
348
|
+
let smooth = (t: number) => t * t * (3 - 2 * t)
|
|
349
|
+
let pushNow = () => {
|
|
350
|
+
if (live === null) return
|
|
351
|
+
current = from === null ? { ...live.latest } : mix(from, live.latest, smooth(progress))
|
|
352
|
+
push(current)
|
|
353
|
+
}
|
|
354
|
+
let relive = (blend: number | undefined) => {
|
|
355
|
+
let seconds = blend ?? options.blend ?? BLEND_SECONDS
|
|
356
|
+
if (!(Number.isFinite(seconds) && seconds >= 0)) throw new Error(`createShotBlend: blend must be a non-negative number of seconds, got ${seconds}`)
|
|
357
|
+
let next = choose()
|
|
358
|
+
if (next === live) return
|
|
359
|
+
if (live !== null && next !== null && seconds > 0) {
|
|
360
|
+
from = { ...current }
|
|
361
|
+
progress = 0
|
|
362
|
+
duration = seconds
|
|
363
|
+
} else from = null
|
|
364
|
+
live = next
|
|
365
|
+
pushNow()
|
|
366
|
+
activity.notify()
|
|
367
|
+
}
|
|
368
|
+
let named = (name: string, what: string): Shot => {
|
|
369
|
+
let s = shots.get(name)
|
|
370
|
+
if (!s) throw new Error(`createShotBlend: ${what}("${name}"): no such shot (${[...shots.keys()].join(", ") || "none"})`)
|
|
371
|
+
return s
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
/** Declare a shot; returns the target its control drives. */
|
|
375
|
+
shot(name: string, opts?: { priority?: number }): ShotTarget<C> {
|
|
376
|
+
if (typeof name !== "string" || name.length === 0) throw new Error("createShotBlend: a shot needs a name")
|
|
377
|
+
if (shots.has(name)) throw new Error(`createShotBlend: shot "${name}" exists`)
|
|
378
|
+
let s: Shot = { name, priority: opts?.priority ?? 0, latest: { ...initial }, enabled: false, order: 0 }
|
|
379
|
+
shots.set(name, s)
|
|
380
|
+
return {
|
|
381
|
+
setCamera(update) {
|
|
382
|
+
Object.assign(s.latest, update)
|
|
383
|
+
if (s === live && from === null) pushNow()
|
|
384
|
+
},
|
|
385
|
+
camera: () => ({ ...s.latest }),
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
/** Enable a shot: it goes live when its priority wins, blending from
|
|
389
|
+
* the current output over `blend` seconds (the default otherwise). */
|
|
390
|
+
activate(name: string, opts?: { blend?: number }) {
|
|
391
|
+
let s = named(name, "activate")
|
|
392
|
+
s.enabled = true
|
|
393
|
+
s.order = ++order
|
|
394
|
+
relive(opts?.blend)
|
|
395
|
+
},
|
|
396
|
+
/** Disable a shot: the next by priority goes live, blended. */
|
|
397
|
+
deactivate(name: string, opts?: { blend?: number }) {
|
|
398
|
+
named(name, "deactivate").enabled = false
|
|
399
|
+
relive(opts?.blend)
|
|
400
|
+
},
|
|
401
|
+
/** Forget a shot (an unmounted component): disabled first, so the
|
|
402
|
+
* next by priority goes live, blended; its name is free again. */
|
|
403
|
+
remove(name: string, opts?: { blend?: number }) {
|
|
404
|
+
named(name, "remove").enabled = false
|
|
405
|
+
relive(opts?.blend)
|
|
406
|
+
shots.delete(name)
|
|
407
|
+
},
|
|
408
|
+
/** The live shot's name, or null. */
|
|
409
|
+
live: () => (live === null ? null : live.name),
|
|
410
|
+
/** The output as last pushed (a fresh object). */
|
|
411
|
+
camera: (): C => ({ ...current }),
|
|
412
|
+
/** Reactive: whether a blend is in flight - the frame-loop gate. */
|
|
413
|
+
active: activity.active,
|
|
414
|
+
/** Advance a blend over dt seconds and push; returns whether the
|
|
415
|
+
* output changed. A live shot's own pushes need no update. */
|
|
416
|
+
update(dt: number): boolean {
|
|
417
|
+
if (!Number.isFinite(dt)) throw new Error(`createShotBlend: update dt must be a finite number, got ${dt}`)
|
|
418
|
+
if (from === null || dt <= 0) return false
|
|
419
|
+
progress = Math.min(1, progress + dt / duration)
|
|
420
|
+
pushNow()
|
|
421
|
+
if (progress >= 1) from = null
|
|
422
|
+
activity.notify()
|
|
423
|
+
return true
|
|
424
|
+
},
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export type ShotBlend<C extends object> = ReturnType<typeof createShotBlend<C>>
|
package/src/cursor.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Image cursors: decode once, register with the platform, reference from any
|
|
2
|
+
// element's `cursor` prop by handle. The named shapes need no setup
|
|
3
|
+
// (`cursor="pointer"`); this is the `url()` half of CSS `cursor` through the
|
|
4
|
+
// SolidRT lens: a handle created once rather than a URL resolved per element,
|
|
5
|
+
// since the platform holds the image and the app decodes it.
|
|
6
|
+
|
|
7
|
+
import { getOwner, onCleanup } from "@solidjs/signals"
|
|
8
|
+
import { decodeImage } from "flux:image"
|
|
9
|
+
import {
|
|
10
|
+
createCursor as registerCursor,
|
|
11
|
+
dropCursor,
|
|
12
|
+
type CursorFrame as NativeFrame,
|
|
13
|
+
type CursorImage as NativeImage,
|
|
14
|
+
} from "flux:rendertree"
|
|
15
|
+
|
|
16
|
+
/** A registered image cursor (createCursor), for an element's `cursor` prop. */
|
|
17
|
+
export type CursorId = number & { readonly __cursor: unique symbol }
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The named cursors: the CSS `cursor` keywords the platform has a shape for,
|
|
21
|
+
* plus `"none"` to hide the cursor. Keywords without a platform shape
|
|
22
|
+
* (grab, zoom-in, ...) are image cursors here (createCursor).
|
|
23
|
+
*/
|
|
24
|
+
export type CursorName =
|
|
25
|
+
| "default"
|
|
26
|
+
| "pointer"
|
|
27
|
+
| "text"
|
|
28
|
+
| "wait"
|
|
29
|
+
| "progress"
|
|
30
|
+
| "crosshair"
|
|
31
|
+
| "move"
|
|
32
|
+
| "not-allowed"
|
|
33
|
+
| "ns-resize"
|
|
34
|
+
| "ew-resize"
|
|
35
|
+
| "nesw-resize"
|
|
36
|
+
| "nwse-resize"
|
|
37
|
+
| "n-resize"
|
|
38
|
+
| "e-resize"
|
|
39
|
+
| "s-resize"
|
|
40
|
+
| "w-resize"
|
|
41
|
+
| "ne-resize"
|
|
42
|
+
| "nw-resize"
|
|
43
|
+
| "se-resize"
|
|
44
|
+
| "sw-resize"
|
|
45
|
+
| "none"
|
|
46
|
+
|
|
47
|
+
/** One frame of an animated cursor (see CursorOptions.frames). */
|
|
48
|
+
export interface CursorFrame {
|
|
49
|
+
/** The frame's encoded image (png, webp, ...). Every frame decodes to the same size. */
|
|
50
|
+
image: Uint8Array
|
|
51
|
+
/** HiDPI alternates keyed by scale: `{ 2: bytes }` must decode to exactly twice the image's size. */
|
|
52
|
+
alternates?: Record<number, Uint8Array>
|
|
53
|
+
/** How long the frame shows, in milliseconds. 0 holds it, which ends a one-shot animation. */
|
|
54
|
+
duration: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CursorOptions {
|
|
58
|
+
/** The cursor's encoded image (png, webp, ...). Use `frames` instead for an animated cursor. */
|
|
59
|
+
image?: Uint8Array
|
|
60
|
+
/** HiDPI alternates of `image` keyed by scale: `{ 2: bytes }` must decode to exactly twice its size. */
|
|
61
|
+
alternates?: Record<number, Uint8Array>
|
|
62
|
+
/** Frames of an animated cursor, played in order and looped (unless a frame's duration is 0). */
|
|
63
|
+
frames?: CursorFrame[]
|
|
64
|
+
/** The click point, in the 1x image's pixels. Default: the top-left corner. */
|
|
65
|
+
hotspot?: [number, number]
|
|
66
|
+
/** Pass false to keep the cursor past the owning scope and drop it yourself. */
|
|
67
|
+
autoFree?: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Registers an image cursor and returns the handle an element's `cursor` prop
|
|
72
|
+
* takes: `<view cursor={id}>`. Synchronous, from bytes you hold (a
|
|
73
|
+
* `with { type: "binary" }` import, or a file read); the platform keeps the
|
|
74
|
+
* image, so one handle serves any number of elements. Supply a 2x alternate
|
|
75
|
+
* for HiDPI displays: without one the platform scales the 1x image. A 32x32
|
|
76
|
+
* base is the common size; keep it under 128x128, the ceiling shared across
|
|
77
|
+
* platforms. The cursor is dropped with the owning reactive scope; outside
|
|
78
|
+
* one (or with `autoFree: false`) it lives until the app ends.
|
|
79
|
+
*/
|
|
80
|
+
export function createCursor(opts: CursorOptions): CursorId {
|
|
81
|
+
if (!opts || typeof opts !== "object") throw new Error("createCursor: expected an options object")
|
|
82
|
+
let hasImage = opts.image !== undefined
|
|
83
|
+
let hasFrames = opts.frames !== undefined
|
|
84
|
+
if (hasImage === hasFrames) throw new Error("createCursor: pass either image or frames")
|
|
85
|
+
let frames: CursorFrame[] = hasFrames ? opts.frames! : [{ image: opts.image!, alternates: opts.alternates, duration: 0 }]
|
|
86
|
+
if (frames.length === 0) throw new Error("createCursor: frames is empty")
|
|
87
|
+
let native = frames.map(decodeFrame)
|
|
88
|
+
let [hotX, hotY] = opts.hotspot ?? [0, 0]
|
|
89
|
+
let id = registerCursor(native, hotX, hotY) as CursorId
|
|
90
|
+
if (opts.autoFree !== false && getOwner()) onCleanup(() => dropCursor(id))
|
|
91
|
+
return id
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Decodes a frame's image and alternates to straight-alpha RGBA8 (what the
|
|
95
|
+
// platform's cursor surfaces take) and checks each alternate against its
|
|
96
|
+
// scale, so a mismatched asset fails here with the sizes named.
|
|
97
|
+
function decodeFrame(frame: CursorFrame): NativeFrame {
|
|
98
|
+
if (!(frame.image instanceof Uint8Array)) throw new Error("createCursor: image must be a Uint8Array")
|
|
99
|
+
if (!(Number.isInteger(frame.duration) && frame.duration >= 0))
|
|
100
|
+
throw new Error(`createCursor: duration must be a whole number of milliseconds, got ${frame.duration}`)
|
|
101
|
+
let base = decodeImage(frame.image, { alpha: "straight" })
|
|
102
|
+
let images: NativeImage[] = [base]
|
|
103
|
+
for (let [key, bytes] of Object.entries(frame.alternates ?? {})) {
|
|
104
|
+
let scale = Number(key)
|
|
105
|
+
if (!(scale > 1)) throw new Error(`createCursor: alternate scale ${key} must be above 1`)
|
|
106
|
+
let alt = decodeImage(bytes, { alpha: "straight" })
|
|
107
|
+
let [w, h] = [base.width * scale, base.height * scale]
|
|
108
|
+
if (alt.width !== w || alt.height !== h)
|
|
109
|
+
throw new Error(`createCursor: the ${scale}x alternate is ${alt.width}x${alt.height}, expected ${w}x${h}`)
|
|
110
|
+
images.push(alt)
|
|
111
|
+
}
|
|
112
|
+
return { images, duration: frame.duration }
|
|
113
|
+
}
|