@solidrt/core 0.0.63 → 0.0.65
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 +92 -9
- package/README.md +1 -1
- package/agents/performance.md +7 -2
- package/docs/index.md +9 -0
- package/package.json +7 -5
- package/src/camera-control.ts +9 -2
- package/src/core.ts +6 -1
- package/src/double-tap.ts +2 -2
- package/src/gamepad.ts +32 -1
- package/src/gpu.ts +25 -10
- package/src/image.ts +10 -2
- package/src/index.ts +8 -4
- package/src/input-gamepad-device.ts +38 -14
- package/src/input-gamepad.ts +6 -4
- package/src/input-keyboard.ts +11 -2
- package/src/input-map.ts +12 -1
- package/src/input-pointer.ts +3 -3
- package/src/input-processors.ts +65 -25
- package/src/pan.ts +4 -3
- package/src/renderer.ts +1 -1
- package/src/runtime-modules.d.ts +68 -1
- package/src/swipe.ts +6 -6
- package/src/textures.ts +333 -0
- package/src/transform.ts +16 -7
- package/src/types.d.ts +66 -12
- package/src/velocity.ts +113 -50
- package/src/window.ts +41 -3
package/AGENTS.md
CHANGED
|
@@ -111,7 +111,7 @@ tsconfig.json - the two load-bearing lines are jsx + jsxImportSource:
|
|
|
111
111
|
```
|
|
112
112
|
|
|
113
113
|
Peer deps @solidjs/signals and @solidjs/universal must match (currently
|
|
114
|
-
2.0.0-rc.
|
|
114
|
+
2.0.0-rc.13); bun resolves them from peerDependencies. On a version bump,
|
|
115
115
|
`bun install` only warns ("incorrect peer dependency") and keeps the old
|
|
116
116
|
ones, and `bun update solid-js @solidjs/signals @solidjs/universal` does
|
|
117
117
|
not read the pin either: it adds all three to package.json at registry
|
|
@@ -316,6 +316,24 @@ that reads exactly like Solid fallout.
|
|
|
316
316
|
nothing focused, the window alone), so `<window onKeyDown>` is the
|
|
317
317
|
app-global shortcut point; `stopPropagation()` ends the walk. `focusable`
|
|
318
318
|
declares focus-navigation candidacy (enumerate via getFocusables()).
|
|
319
|
+
Every pointer, wheel and key event carries `timeStamp` (ms): when the
|
|
320
|
+
input happened. Time anything about input with it (a press length, the
|
|
321
|
+
gap between two taps, a speed from two positions), never with
|
|
322
|
+
`performance.now()` in the handler: that is when the handler ran, and
|
|
323
|
+
it does not follow the dev clock, `srt render` or a test. The stamp is
|
|
324
|
+
the input's own time, not its delivery's: a move carries the time the
|
|
325
|
+
pointer was at the position it reports, however late the frame that
|
|
326
|
+
delivers it; a down, up, wheel or key the time the platform recorded.
|
|
327
|
+
Compare stamps only with each other.
|
|
328
|
+
A pointer move also carries `predicted` (false on every other event).
|
|
329
|
+
Touch arrives in batches, and a batch can miss the frame it belongs to;
|
|
330
|
+
the runtime then bridges the gap with one predicted step so a drag does
|
|
331
|
+
not stall and jump, and that move has `predicted: true`. DRAW with it,
|
|
332
|
+
like any move; do NOT MEASURE with it: leave it out of a velocity, a
|
|
333
|
+
path or a stroke you keep. If the finger had in fact stopped, the next
|
|
334
|
+
move reports where it really is with the time it got there, which is
|
|
335
|
+
EARLIER than the predicted move's `timeStamp` - the one case where
|
|
336
|
+
stamps go back. The recognizers below already do this.
|
|
319
337
|
|
|
320
338
|
- Gesture recognizers, shared by every package: `createPan` (single-pointer
|
|
321
339
|
drag, axis-aware slop, per-event dx/dy) and `createTransform` (merged
|
|
@@ -332,11 +350,16 @@ that reads exactly like Solid fallout.
|
|
|
332
350
|
and two-finger translation different meanings - dx/dy alone cannot tell
|
|
333
351
|
them apart).
|
|
334
352
|
Both ends carry the lift velocity (`onPanEnd(velocity)`,
|
|
335
|
-
`onTransformEnd(velocity)`: parent-frame px/s from `createVelocityTracker
|
|
353
|
+
`onTransformEnd(velocity)`: parent-frame px/s from `createVelocityTracker`
|
|
354
|
+
(`push(x, y, e.timeStamp)`, `velocity(up.timeStamp)`: it reads no clock;
|
|
355
|
+
push real positions only, `if (!e.predicted)`),
|
|
336
356
|
a least-squares fit over the last 100 ms of positions, never the last
|
|
337
357
|
two samples - the final sample before a lift is often stationary and
|
|
338
|
-
frame batching makes the last delta a frame old;
|
|
339
|
-
|
|
358
|
+
frame batching makes the last delta a frame old; a pause over 40 ms
|
|
359
|
+
cuts the history, so only the motion after it counts; zero after a
|
|
360
|
+
50 ms rest, when that motion covers under 8 px (a finger leaving the
|
|
361
|
+
panel shifts a pixel or two, which is not a fling), or under
|
|
362
|
+
`FLING_MIN_VELOCITY`), the fling fact for whoever animates
|
|
340
363
|
on. The discrete recognizers: `createSwipe({ directions?, onSwipe(direction,
|
|
341
364
|
velocity), onSwipeMove?, onSwipeEnd? })` (a pan classified at the lift:
|
|
342
365
|
24 px of travel at 300 px/s within 30 degrees of an axis, direction by
|
|
@@ -432,7 +455,12 @@ that reads exactly like Solid fallout.
|
|
|
432
455
|
`chord(...sources)` are the interactions (Unity's Hold/Tap/MultiTap):
|
|
433
456
|
button sources made from button sources, so a charged attack is
|
|
434
457
|
`hold(pad.button("west"))` on one action next to the plain press on
|
|
435
|
-
another; a tap reads pressed for one task, enough for onPress.
|
|
458
|
+
another; a tap reads pressed for one task, enough for onPress. `tap`
|
|
459
|
+
and `doubleTap` time a press with the source's own `changedAt()` (the
|
|
460
|
+
`timeStamp` of the event behind each edge; keyboard keys and pad
|
|
461
|
+
buttons carry it), so a custom button source has to provide it to sit
|
|
462
|
+
under one, and they throw on one that does not (a pointer pulse, a
|
|
463
|
+
`hold`). A key lost to a blur is no tap.
|
|
436
464
|
Consumers with the axes contract (`createAxes({ rotate: "vec2", zoom:
|
|
437
465
|
"axis" }, { onNudge, onBegin, onEnd })`, what every camera control
|
|
438
466
|
exposes as `.axes`) are connected by name with `input.drive(control.axes,
|
|
@@ -479,8 +507,8 @@ that reads exactly like Solid fallout.
|
|
|
479
507
|
forward], forward = -y), `rise` axis (up). The runtime-free
|
|
480
508
|
half (`createInputMap`, `createAxes`, `keyboard`, the processors) is
|
|
481
509
|
importable as `@solidrt/core/input` for headless checks;
|
|
482
|
-
|
|
483
|
-
|
|
510
|
+
tests/input-map.test.ts runs it on the bare flux binary, and
|
|
511
|
+
tests/input-gamepad.test.ts drives the gamepad device and the join
|
|
484
512
|
order over a signal of pad snapshots (input-gamepad-device.ts is
|
|
485
513
|
runtime-free; input-gamepad.ts hands it gamepads()). The pointer
|
|
486
514
|
feed's discrete pulses (`doubleTap`, `longPress`) bound to an AXIS
|
|
@@ -494,7 +522,7 @@ that reads exactly like Solid fallout.
|
|
|
494
522
|
on, and the shot blender (`createShotBlend`: several cameras, a
|
|
495
523
|
priority, a timed blend, over the packages' `createShots`) - is
|
|
496
524
|
`@solidrt/core/camera-control`, pure, pinned by
|
|
497
|
-
|
|
525
|
+
tests/camera-control.test.ts; okf/design/camera-controls.md is the
|
|
498
526
|
pipeline the 2d and 3d controls run over it.
|
|
499
527
|
|
|
500
528
|
- Reactivity is SolidJS 2.0 (`@solidjs/signals`), NOT Solid 1.x. `createSignal`
|
|
@@ -581,7 +609,14 @@ that reads exactly like Solid fallout.
|
|
|
581
609
|
apply, return it so the loop runs only while the condition holds); `rate` is the
|
|
582
610
|
display's nominal refresh rate in Hz, which a fixed-timestep loop needs
|
|
583
611
|
(see @solidrt/cli agents/debugging.md on stepping); `requestAnimationFrame`
|
|
584
|
-
exists as a web-standard one-shot but is not the preferred driver.
|
|
612
|
+
exists as a web-standard one-shot but is not the preferred driver.
|
|
613
|
+
`onFrame(fn, { demand: false })` is the same hook without the standing
|
|
614
|
+
frame request: it runs on every frame the runtime delivers and the app
|
|
615
|
+
presents only when the body writes, for a loop that follows time but
|
|
616
|
+
seldom changes the picture. `frameTime()` is the current frame's `tick`
|
|
617
|
+
read from anywhere (not reactive, requests nothing): the app's own
|
|
618
|
+
clock for code with no tick in hand, where `performance.now()` would
|
|
619
|
+
ignore the dev clock, `srt render` and tests. A JS
|
|
585
620
|
tween loop or an animation library pushing interpolated values through
|
|
586
621
|
signals is the single most expensive mistake available here - read
|
|
587
622
|
agents/performance.md before writing either.
|
|
@@ -612,6 +647,54 @@ that reads exactly like Solid fallout.
|
|
|
612
647
|
transparent pixels for CPU work, and `encodeImage` converts back to
|
|
613
648
|
straight for the file.
|
|
614
649
|
|
|
650
|
+
- Compressed textures, for anything that holds many or large textures (a
|
|
651
|
+
3D model, a tile set): ship ONE KTX2 file per image and let each device
|
|
652
|
+
turn it into the block format its GPU samples, a quarter of the memory
|
|
653
|
+
of the decoded image. `encodeTexture(decodeImage(bytes), { codec, srgb,
|
|
654
|
+
mipmap })` makes the file at bake time (slow, never at load);
|
|
655
|
+
`transcodeTexture(ktx2)` -> `createTexture(t.data, t.width, t.height,
|
|
656
|
+
{ format: t.format, mipmap: t.mipmap })` loads it, picking the
|
|
657
|
+
device's format by itself. Start a transcode per texture and await
|
|
658
|
+
them together: the runtime runs four at a time. Such a texture is
|
|
659
|
+
sample-only (no upload into it, no readback, no generated mips: the
|
|
660
|
+
chain comes from the file). Block compression is lossy and damages
|
|
661
|
+
sharp art and text, so it is not for UI images. The runtime-free half
|
|
662
|
+
is `@solidrt/core/textures`, importable under bun by a bake script or
|
|
663
|
+
an extension's pure modules: `isKtx2(bytes)` tells such a file from a
|
|
664
|
+
PNG or JPEG, and `textureSettings(packageJson)` reads how an app
|
|
665
|
+
wants its textures compressed, declared under `solidrt.textures` in
|
|
666
|
+
its package.json, so every bake, whoever wrote it, honors the same
|
|
667
|
+
settings. Three levels, the narrower one winning: `compress` for the
|
|
668
|
+
app (on unless it says otherwise), `compress` / `codec` / `quality`
|
|
669
|
+
per kind of texture, and `files`, a list of entries that each name
|
|
670
|
+
files by glob pattern (`match`, a path from the project root) and set
|
|
671
|
+
the same three for them, a later entry winning over an earlier one.
|
|
672
|
+
The kinds say what the texels ARE, not what draws them:
|
|
673
|
+
`color` (sRGB color the eye sees), `normal` (directions), `data`
|
|
674
|
+
(other linear values that tolerate a small error: a mask, a
|
|
675
|
+
roughness). An extension maps its own uses onto them. Values that
|
|
676
|
+
must come back exact (a lookup table, a distance field) are none of
|
|
677
|
+
the three and are not block compressed: an entry with
|
|
678
|
+
`"compress": false` keeps them as their file. A bake asks per
|
|
679
|
+
texture, and checks the patterns before it encodes anything, passing
|
|
680
|
+
the runtime's matcher and scan in (the entry itself has no runtime):
|
|
681
|
+
|
|
682
|
+
```ts
|
|
683
|
+
import { glob } from "flux:fs"
|
|
684
|
+
import { matchesGlob } from "flux:path"
|
|
685
|
+
import { textureSettings, textureSettingFor, unmatchedTextureFiles } from "@solidrt/core/textures"
|
|
686
|
+
|
|
687
|
+
let settings = textureSettings(await file("package.json").json())
|
|
688
|
+
let unmatched = await unmatchedTextureFiles(settings, (pattern) => glob(pattern, { cwd: "." }))
|
|
689
|
+
if (unmatched.length > 0) throw new Error("No file matches " + unmatched.join(", "))
|
|
690
|
+
let setting = textureSettingFor(settings, "color", { file: "assets/tiles/ground.png" }, matchesGlob)
|
|
691
|
+
if (setting.compress) bytes = await encodeTexture(decodeImage(bytes), { codec: setting.codec, quality: setting.quality, srgb: true, mipmap: true })
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
A texture embedded in another file (an image inside a .glb) is
|
|
695
|
+
`{ file, name }`, and a pattern picks it as `<file>#<name>`;
|
|
696
|
+
`unmatchedTextureNames` reports the names a file does not have.
|
|
697
|
+
|
|
615
698
|
## Minimal app, core primitives only (verified to render)
|
|
616
699
|
|
|
617
700
|
```tsx
|
package/README.md
CHANGED
package/agents/performance.md
CHANGED
|
@@ -92,7 +92,12 @@ Rules, in order of leverage:
|
|
|
92
92
|
while a condition holds is an effect returning it:
|
|
93
93
|
`createEffect(() => active(), on => { if (!on) return; return onFrame(...) })`
|
|
94
94
|
- the apply phase has no owner, so onFrame registers nothing there and
|
|
95
|
-
the returned cleanup is the handle the effect disposes.
|
|
95
|
+
the returned cleanup is the handle the effect disposes. A loop that
|
|
96
|
+
follows time but changes the picture only now and then (a clip stepping
|
|
97
|
+
at 8 fps, a clock face) registers with `onFrame(fn, { demand: false })`:
|
|
98
|
+
it is called on every frame the runtime runs, requests none itself, and
|
|
99
|
+
the app presents only when the body writes something. `frameTime()`
|
|
100
|
+
reads the current frame's tick with no callback at all.
|
|
96
101
|
5. repaintBoundary works like Flutter's: transforms and opacity on the
|
|
97
102
|
boundary node itself (or any ancestor) are hoisted out of the cache and
|
|
98
103
|
applied at composite time, so animating x/y/scale/rotate/opacity of a
|
|
@@ -144,7 +149,7 @@ Rules, in order of leverage:
|
|
|
144
149
|
anything needing what is behind it does not - and is split from content
|
|
145
150
|
invalidation, so a params-only change re-runs the pass against the cached
|
|
146
151
|
snapshot instead of re-rasterizing. A window shader's output is invisible
|
|
147
|
-
to get_snapshot and every other MCP tool; `
|
|
152
|
+
to get_snapshot and every other MCP tool; `bun run srt render` is the only
|
|
148
153
|
way to see it (see @solidrt/cli AGENTS.md).
|
|
149
154
|
8. `flux:wasm` runs a pure interpreter (wasmi, no JIT), so temper browser
|
|
150
155
|
expectations - but do not write it off for compute. A genuinely numeric
|
package/docs/index.md
CHANGED
|
@@ -98,6 +98,15 @@ bubble the same way, starting at the focused node - or at the window root
|
|
|
98
98
|
when nothing is focused, so `onKeyDown` on the window is where app-global
|
|
99
99
|
shortcuts live.
|
|
100
100
|
|
|
101
|
+
Every input event carries `timeStamp`, in milliseconds: when the input
|
|
102
|
+
happened, not when the handler ran. Time a press, the gap between two taps
|
|
103
|
+
or a speed with it. A pointer move also carries `predicted`. Touch arrives
|
|
104
|
+
in batches, and when one is late the runtime bridges the frame with a
|
|
105
|
+
predicted position so a drag does not stall; that move has `predicted:
|
|
106
|
+
true`. Draw with it, but leave it out of anything you measure or keep, such
|
|
107
|
+
as a velocity or a stroke: if the finger had stopped, the next move reports
|
|
108
|
+
where it really is, with an earlier `timeStamp`.
|
|
109
|
+
|
|
101
110
|
Text entry goes to the focused node's `onTextInput`. Focusing a field never
|
|
102
111
|
raises the on-screen keyboard by itself - a tap on the field (or an explicit
|
|
103
112
|
`startTextInput()`) does, and never while a physical keyboard is attached.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.65",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"funding": "https://github.com/sponsors/wellawaretech",
|
|
6
6
|
"author": "Antoine van Wel",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"./microphone": "./src/microphone.ts",
|
|
20
20
|
"./speech-recognition": "./src/speech-recognition.ts",
|
|
21
21
|
"./text-input": "./src/text-input.ts",
|
|
22
|
+
"./textures": "./src/textures.ts",
|
|
22
23
|
"./video": "./src/video.ts",
|
|
23
24
|
"./jsx-runtime": "./jsx-runtime.d.ts",
|
|
24
25
|
"./jsx-runtime-dev": "./jsx-runtime.d.ts"
|
|
@@ -32,11 +33,12 @@
|
|
|
32
33
|
"AGENTS.md"
|
|
33
34
|
],
|
|
34
35
|
"devDependencies": {
|
|
35
|
-
"@solidrt/flux-types": "0.0.
|
|
36
|
+
"@solidrt/flux-types": "0.0.65",
|
|
37
|
+
"@solidrt/test": "0.0.65"
|
|
36
38
|
},
|
|
37
39
|
"peerDependencies": {
|
|
38
|
-
"@solidjs/signals": "2.0.0-rc.
|
|
39
|
-
"@solidjs/universal": "2.0.0-rc.
|
|
40
|
-
"solid-js": "2.0.0-rc.
|
|
40
|
+
"@solidjs/signals": "2.0.0-rc.13",
|
|
41
|
+
"@solidjs/universal": "2.0.0-rc.13",
|
|
42
|
+
"solid-js": "2.0.0-rc.13"
|
|
41
43
|
}
|
|
42
44
|
}
|
package/src/camera-control.ts
CHANGED
|
@@ -308,7 +308,8 @@ export const BLEND_SECONDS = 0.5
|
|
|
308
308
|
|
|
309
309
|
/** A shot's recording target: what its control drives. `camera()` is
|
|
310
310
|
* the shot's latest full camera (the control's partial updates merged
|
|
311
|
-
* over the initial one
|
|
311
|
+
* over the initial one; an undefined field is absent and keeps its
|
|
312
|
+
* value, as the real target's setCamera treats it). */
|
|
312
313
|
export type ShotTarget<C> = { setCamera(update: Partial<C>): void; camera(): C }
|
|
313
314
|
|
|
314
315
|
export type ShotBlendOptions = {
|
|
@@ -379,7 +380,13 @@ export function createShotBlend<C extends object>(push: (camera: C) => void, mix
|
|
|
379
380
|
shots.set(name, s)
|
|
380
381
|
return {
|
|
381
382
|
setCamera(update) {
|
|
382
|
-
|
|
383
|
+
// A component's props arrive as explicit undefineds for the
|
|
384
|
+
// unset ones; copying those would blank a field the mix then
|
|
385
|
+
// reads (NaN, or a missing vector).
|
|
386
|
+
for (let key of Object.keys(update) as (keyof C)[]) {
|
|
387
|
+
let value = update[key]
|
|
388
|
+
if (value !== undefined) s.latest[key] = value as C[keyof C]
|
|
389
|
+
}
|
|
383
390
|
if (s === live && from === null) pushNow()
|
|
384
391
|
},
|
|
385
392
|
camera: () => ({ ...s.latest }),
|
package/src/core.ts
CHANGED
|
@@ -38,9 +38,12 @@ function syncInterest(nodeId: number): void {
|
|
|
38
38
|
/**
|
|
39
39
|
* A pointer fact with no per-node fields: window coordinates plus pointer
|
|
40
40
|
* identity and modifiers. `target` is the deepest node under the pointer
|
|
41
|
-
* (0 when nothing is hit).
|
|
41
|
+
* (0 when nothing is hit). `timeStamp` and `predicted` are
|
|
42
|
+
* `PointerEvent`'s.
|
|
42
43
|
*/
|
|
43
44
|
export interface GlobalPointerEvent {
|
|
45
|
+
timeStamp: number
|
|
46
|
+
predicted: boolean
|
|
44
47
|
clientX: number
|
|
45
48
|
clientY: number
|
|
46
49
|
target: number
|
|
@@ -70,6 +73,8 @@ export function onPointerMove(fn: (e: GlobalPointerEvent) => void): () => void {
|
|
|
70
73
|
if (globalMoveSubs.size === 1) {
|
|
71
74
|
globalMoveUnsub = on("pointerMove", (raw: any) => {
|
|
72
75
|
let e: GlobalPointerEvent = {
|
|
76
|
+
timeStamp: raw.timeStamp,
|
|
77
|
+
predicted: raw.predicted,
|
|
73
78
|
clientX: raw.clientX,
|
|
74
79
|
clientY: raw.clientY,
|
|
75
80
|
target: raw.target,
|
package/src/double-tap.ts
CHANGED
|
@@ -63,7 +63,7 @@ export function createTapSequence(options: TapSequenceOptions) {
|
|
|
63
63
|
}
|
|
64
64
|
return {
|
|
65
65
|
down(e: PointerEvent) {
|
|
66
|
-
let now =
|
|
66
|
+
let now = e.timeStamp
|
|
67
67
|
if (first) {
|
|
68
68
|
if (first.upAt === null) {
|
|
69
69
|
// A second finger while the first is down: neither is a tap.
|
|
@@ -88,7 +88,7 @@ export function createTapSequence(options: TapSequenceOptions) {
|
|
|
88
88
|
},
|
|
89
89
|
up(e: PointerEvent) {
|
|
90
90
|
if (!first || first.id !== e.pointerId || first.upAt !== null) return
|
|
91
|
-
let now =
|
|
91
|
+
let now = e.timeStamp
|
|
92
92
|
if (now - first.downAt > TAP_MAX_MS) {
|
|
93
93
|
settle(false)
|
|
94
94
|
return
|
package/src/gamepad.ts
CHANGED
|
@@ -43,6 +43,33 @@ export interface GamepadState {
|
|
|
43
43
|
|
|
44
44
|
let gamepadsAccessor: (() => (GamepadState | null)[]) | undefined
|
|
45
45
|
|
|
46
|
+
// When each pad button last changed: per slot, button name to the
|
|
47
|
+
// `timeStamp` of the "gamepads" event that pressed or released it. Kept
|
|
48
|
+
// here, where every snapshot passes once, because a reader of the
|
|
49
|
+
// accessor sees only the latest one.
|
|
50
|
+
let buttonTimes: Map<string, number>[] = []
|
|
51
|
+
let lastPads: (GamepadState | null)[] = []
|
|
52
|
+
|
|
53
|
+
function recordButtonTimes(pads: (GamepadState | null)[], at: number): void {
|
|
54
|
+
for (let slot = 0; slot < Math.max(pads.length, lastPads.length); slot++) {
|
|
55
|
+
let before = lastPads[slot]?.buttons ?? []
|
|
56
|
+
let after = pads[slot]?.buttons ?? []
|
|
57
|
+
let times = (buttonTimes[slot] ??= new Map())
|
|
58
|
+
for (let name of after) if (!before.includes(name)) times.set(name, at)
|
|
59
|
+
for (let name of before) if (!after.includes(name)) times.set(name, at)
|
|
60
|
+
}
|
|
61
|
+
lastPads = pads
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** When the button `name` of the pad in `slot` was last pressed or
|
|
65
|
+
* released, as the `timeStamp` (ms, the clock of `PointerEvent.timeStamp`)
|
|
66
|
+
* of the pad snapshot that carried the change; null when it never
|
|
67
|
+
* changed. Needs `gamepads()` to have been read: the snapshots are
|
|
68
|
+
* watched from its first read on. */
|
|
69
|
+
export function gamepadButtonChangedAt(slot: number, name: string): number | null {
|
|
70
|
+
return buttonTimes[slot]?.get(name) ?? null
|
|
71
|
+
}
|
|
72
|
+
|
|
46
73
|
/**
|
|
47
74
|
* Connected gamepads as a reactive accessor. Slots are stable web-style: a
|
|
48
75
|
* pad keeps its index for its whole connection, disconnecting leaves a null
|
|
@@ -56,7 +83,11 @@ export function gamepads(): (GamepadState | null)[] {
|
|
|
56
83
|
// and the first read is usually a tracked scope (see environment.ts).
|
|
57
84
|
runWithOwner(null, () => {
|
|
58
85
|
let [pads, setPads] = createSignal<(GamepadState | null)[]>([])
|
|
59
|
-
on("gamepads", (e: { pads?: (GamepadState | null)[] }) =>
|
|
86
|
+
on("gamepads", (e: { pads?: (GamepadState | null)[]; timeStamp: number }) => {
|
|
87
|
+
let next = e.pads ?? []
|
|
88
|
+
recordButtonTimes(next, e.timeStamp)
|
|
89
|
+
setPads(next)
|
|
90
|
+
})
|
|
60
91
|
gamepadsAccessor = pads
|
|
61
92
|
})
|
|
62
93
|
}
|
package/src/gpu.ts
CHANGED
|
@@ -80,13 +80,16 @@ export type { FilterMode, WrapMode, TextureBinding, TextureBindings } from "flux
|
|
|
80
80
|
// createCubeTexture, createMutableTexture), fixed for the id's lifetime like
|
|
81
81
|
// the sampler state. "rgba8" (default), "rgba8-srgb" (decodes to linear
|
|
82
82
|
// light on sample), "r8", the float data-texture formats "r32f"/"rgba32f"
|
|
83
|
-
// (Float32Array payload, nearest/texelFetch sampling only, no readback)
|
|
84
|
-
//
|
|
85
|
-
// filterable)
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
// "
|
|
89
|
-
//
|
|
83
|
+
// (Float32Array payload, nearest/texelFetch sampling only, no readback), the
|
|
84
|
+
// HDR image format "rgba16f" (Float32Array payload packed to half float,
|
|
85
|
+
// filterable) and the integer data-texture format "rgba32ui" (Uint32Array
|
|
86
|
+
// payload, a uvec4 per texel through a usampler2D, nearest/texelFetch only)
|
|
87
|
+
// and the block-compressed formats "etc2-rgba8"/"etc2-rgba8-srgb" (GLES 3.0
|
|
88
|
+
// core, every device) and "bc7-rgba8"/"bc7-rgba8-srgb" (desktops, behind
|
|
89
|
+
// limits.bc7Textures): raw 4x4 blocks, the full mip chain when mipmap is
|
|
90
|
+
// set, create-once and sample-only - see TextureFormat in flux:gpu for each
|
|
91
|
+
// format's contract. Draw targets (createDrawTarget, createCubeDrawTarget)
|
|
92
|
+
// take the renderable subset: "rgba8", "rgba8-srgb", "rgba16f".
|
|
90
93
|
export type TextureFormatOptions = { format?: gpu.TextureFormat }
|
|
91
94
|
export type { TextureFormat } from "flux:gpu"
|
|
92
95
|
|
|
@@ -245,7 +248,7 @@ export { captureSnapshot, readTexture } from "flux:gpu"
|
|
|
245
248
|
* scope.
|
|
246
249
|
*/
|
|
247
250
|
export function createTexture(
|
|
248
|
-
data: Uint8Array | Float32Array,
|
|
251
|
+
data: Uint8Array | Float32Array | Uint32Array,
|
|
249
252
|
width: number,
|
|
250
253
|
height: number,
|
|
251
254
|
opts?: CreateOptions & SamplerOptions & TextureFormatOptions,
|
|
@@ -279,7 +282,7 @@ export function createTexture(
|
|
|
279
282
|
* reactive owner, `{ autoFree: false }` or `destroyTexture` otherwise).
|
|
280
283
|
*/
|
|
281
284
|
export function createCubeTexture(
|
|
282
|
-
faces: (Uint8Array | Float32Array)[] | (Uint8Array | Float32Array)[][],
|
|
285
|
+
faces: (Uint8Array | Float32Array | Uint32Array)[] | (Uint8Array | Float32Array | Uint32Array)[][],
|
|
283
286
|
size: number,
|
|
284
287
|
opts?: CreateOptions & SamplerOptions & TextureFormatOptions,
|
|
285
288
|
): gpu.TextureId {
|
|
@@ -300,7 +303,7 @@ export function createCubeTexture(
|
|
|
300
303
|
* flux:gpu) yourself.
|
|
301
304
|
*/
|
|
302
305
|
export function createMutableTexture(
|
|
303
|
-
data: Uint8Array | Float32Array,
|
|
306
|
+
data: Uint8Array | Float32Array | Uint32Array,
|
|
304
307
|
width: number,
|
|
305
308
|
height: number,
|
|
306
309
|
opts?: CreateOptions & SamplerOptions & TextureFormatOptions,
|
|
@@ -757,4 +760,16 @@ export function beginBufferWrite(id: gpu.BufferId): Float32Array {
|
|
|
757
760
|
*/
|
|
758
761
|
export function writeBuffer(id: gpu.BufferId, data: ArrayBuffer | ArrayBufferView, byteOffset?: number): void {
|
|
759
762
|
gpu.writeBuffer(id, toUint8(data), byteOffset)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Hands an ordered instance buffer's full record set to the engine - the
|
|
767
|
+
* write-once form: the engine keeps the one CPU copy and sorts,
|
|
768
|
+
* republishes and count-dials from it on its own; the caller keeps
|
|
769
|
+
* nothing and partial rewrites are off the table. Hand off before the
|
|
770
|
+
* entry attaches over the buffer. See `transferRecords` in flux:gpu for
|
|
771
|
+
* the full contract.
|
|
772
|
+
*/
|
|
773
|
+
export function transferRecords(id: gpu.BufferId, records: ArrayBuffer | ArrayBufferView): void {
|
|
774
|
+
gpu.transferRecords(id, toUint8(records))
|
|
760
775
|
}
|
package/src/image.ts
CHANGED
|
@@ -6,10 +6,18 @@
|
|
|
6
6
|
|
|
7
7
|
import { createMemo, onCleanup } from "@solidjs/signals"
|
|
8
8
|
import { decodeImage, type DecodedImage } from "flux:image"
|
|
9
|
+
import type { TextureCodec } from "flux:image"
|
|
9
10
|
import { createTexture, destroyTexture, type TextureId } from "./gpu"
|
|
11
|
+
import type { TextureCodec as SettingsCodec } from "./textures"
|
|
10
12
|
|
|
11
|
-
export { decodeImage, encodeImage } from "flux:image"
|
|
12
|
-
export type { DecodedImage } from "flux:image"
|
|
13
|
+
export { decodeImage, encodeImage, encodeTexture, transcodeTexture } from "flux:image"
|
|
14
|
+
export type { DecodedImage, TextureCodec, TranscodedTexture } from "flux:image"
|
|
15
|
+
|
|
16
|
+
// The codecs the texture settings name (./textures, which takes no type
|
|
17
|
+
// from the runtime) are the encoder's: a codec added to one side alone
|
|
18
|
+
// stops this line from compiling.
|
|
19
|
+
type SameType<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false
|
|
20
|
+
const SETTINGS_NAME_THE_ENCODERS_CODECS: SameType<SettingsCodec, TextureCodec> = true
|
|
13
21
|
|
|
14
22
|
export type ImageSource = string | Uint8Array
|
|
15
23
|
|
package/src/index.ts
CHANGED
|
@@ -3,8 +3,8 @@ export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables,
|
|
|
3
3
|
export type { BoundingBox, GlobalPointerEvent, TextLine } from "./core"
|
|
4
4
|
export { parseColor, mixColors, withAlpha, brightness, createLinearGradient, createRadialGradient } from "./color"
|
|
5
5
|
export type { Gradient, GradientStop } from "./color"
|
|
6
|
-
export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, onLink, onSuspend, onQuit, exit, background, registerProtocolHandler } from "./window"
|
|
7
|
-
export type { BackEvent, LifecycleHandler } from "./window"
|
|
6
|
+
export { onFrame, frameTime, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, onLink, onSuspend, onQuit, exit, background, registerProtocolHandler } from "./window"
|
|
7
|
+
export type { BackEvent, FrameOptions, LifecycleHandler } from "./window"
|
|
8
8
|
export { windowSize, safeArea, displayScale, windowFocused, keyboardHeight, lockPointer, pointerLocked } from "./window"
|
|
9
9
|
export { env } from "./environment"
|
|
10
10
|
export type { InputDevices, SystemTheme, Orientation, Visibility, Launch } from "./environment"
|
|
@@ -14,10 +14,12 @@ export { capabilities } from "./capabilities"
|
|
|
14
14
|
export type { Capabilities, WindowSizeClass } from "./capabilities"
|
|
15
15
|
export { createCubeTexture, createTexture } from "./gpu"
|
|
16
16
|
export type { TextureId } from "./gpu"
|
|
17
|
-
export { createImage, decodeImage, encodeImage } from "./image"
|
|
17
|
+
export { createImage, decodeImage, encodeImage, encodeTexture, transcodeTexture } from "./image"
|
|
18
18
|
export { createCursor } from "./cursor"
|
|
19
19
|
export type { CursorFrame, CursorId, CursorName, CursorOptions } from "./cursor"
|
|
20
|
-
export type { DecodedImage, ImageSource } from "./image"
|
|
20
|
+
export type { DecodedImage, ImageSource, TextureCodec, TranscodedTexture } from "./image"
|
|
21
|
+
export { isKtx2, textureSettings, textureSettingFor, unmatchedTextureFiles, unmatchedTextureNames, DEFAULT_TEXTURE_SETTINGS } from "./textures"
|
|
22
|
+
export type { GlobMatcher, GlobScan, TextureFileRule, TextureKind, TextureSetting, TextureSettings, TextureSource } from "./textures"
|
|
21
23
|
export { parseSvg, svg } from "./svg"
|
|
22
24
|
export { Logo } from "./logo"
|
|
23
25
|
export type { LogoProps } from "./logo"
|
|
@@ -67,6 +69,8 @@ export type {
|
|
|
67
69
|
TextEvent,
|
|
68
70
|
TextInputHints,
|
|
69
71
|
PaintProps,
|
|
72
|
+
ShadowProps,
|
|
73
|
+
FilterProps,
|
|
70
74
|
BlendMode,
|
|
71
75
|
DrawStyle,
|
|
72
76
|
StrokeCap,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// A gamepad as input sources, over any
|
|
1
|
+
// A gamepad as input sources, over any reader of pad snapshots: sticks
|
|
2
2
|
// and the dpad as vec2, triggers and shoulders as axes, any button as a
|
|
3
|
-
// button, any raw axis by name. Runtime-free (the
|
|
3
|
+
// button, any raw axis by name. Runtime-free (the reader is injected,
|
|
4
4
|
// the public `gamepad` in input-gamepad.ts hands in core's gamepads()),
|
|
5
|
-
// so the join logic below runs headless in the
|
|
5
|
+
// so the join logic below runs headless in the tests.
|
|
6
6
|
//
|
|
7
7
|
// Every read is reactive when the accessor is (gamepads() is a signal),
|
|
8
8
|
// so a stick moved while a control's frame loop is off wakes it. Sticks
|
|
@@ -33,7 +33,14 @@ const LISTEN_THRESHOLD = 0.5
|
|
|
33
33
|
// saved binding restores onto the device the app hands in.
|
|
34
34
|
const DEVICE = "gamepad"
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
/** What a gamepad device reads: the pad snapshots by slot (reactive),
|
|
37
|
+
* and when a slot's button last changed (the `timeStamp` of the snapshot
|
|
38
|
+
* that pressed or released it, null when it never did), which is what a
|
|
39
|
+
* button source reports as its `changedAt`. */
|
|
40
|
+
export interface PadsReader {
|
|
41
|
+
pads(): (GamepadState | null)[]
|
|
42
|
+
buttonChangedAt(slot: number, name: string): number | null
|
|
43
|
+
}
|
|
37
44
|
|
|
38
45
|
export interface GamepadDevice extends InputDevice {
|
|
39
46
|
readonly name: "gamepad"
|
|
@@ -74,9 +81,10 @@ const DPAD_BUTTONS = ["dpadUp", "dpadDown", "dpadLeft", "dpadRight"]
|
|
|
74
81
|
|
|
75
82
|
/**
|
|
76
83
|
* The sources over `pads()` (the pads this device reads now, reactive),
|
|
77
|
-
* with `slot()` as the device's slot
|
|
84
|
+
* with `slot()` as the device's slot, `buttonChangedAt` as the time a
|
|
85
|
+
* button of the pads it reads last changed, and `who` as the label prefix.
|
|
78
86
|
*/
|
|
79
|
-
export function createGamepadDevice(pads: () => GamepadState[], slot: () => number | undefined, who: string): GamepadDevice {
|
|
87
|
+
export function createGamepadDevice(pads: () => GamepadState[], slot: () => number | undefined, buttonChangedAt: (name: string) => number | null, who: string): GamepadDevice {
|
|
80
88
|
let sumAxis = (read: (pad: GamepadState) => number) => (): number => {
|
|
81
89
|
let sum = 0
|
|
82
90
|
for (let pad of pads()) sum += read(pad)
|
|
@@ -145,7 +153,7 @@ export function createGamepadDevice(pads: () => GamepadState[], slot: () => numb
|
|
|
145
153
|
if (typeof name !== "string" || name.length === 0) throw new Error(`gamepad.button: expected a button name, got ${String(name)}`)
|
|
146
154
|
let source = buttons.get(name)
|
|
147
155
|
if (!source) {
|
|
148
|
-
source = { kind: "button", label: `${who} ${name}`, id: `${DEVICE}:button:${name}`, device: DEVICE, rate: anyButton(name) }
|
|
156
|
+
source = { kind: "button", label: `${who} ${name}`, id: `${DEVICE}:button:${name}`, device: DEVICE, rate: anyButton(name), changedAt: () => buttonChangedAt(name) }
|
|
149
157
|
buttons.set(name, source)
|
|
150
158
|
}
|
|
151
159
|
return source
|
|
@@ -216,22 +224,34 @@ export function createGamepadDevice(pads: () => GamepadState[], slot: () => numb
|
|
|
216
224
|
}
|
|
217
225
|
|
|
218
226
|
/** One slot, or every connected pad when `slot` is undefined. */
|
|
219
|
-
export function createGamepadSlot(read:
|
|
227
|
+
export function createGamepadSlot(read: PadsReader, slot: number | undefined): GamepadDevice {
|
|
220
228
|
if (slot !== undefined && !(Number.isInteger(slot) && slot >= 0)) throw new Error(`gamepad: slot must be a non-negative integer, got ${String(slot)}`)
|
|
221
229
|
let pads = (): GamepadState[] => {
|
|
222
|
-
let all = read()
|
|
230
|
+
let all = read.pads()
|
|
223
231
|
if (slot === undefined) return all.filter((p): p is GamepadState => p !== null)
|
|
224
232
|
let pad = all[slot]
|
|
225
233
|
return pad ? [pad] : []
|
|
226
234
|
}
|
|
227
|
-
|
|
235
|
+
// Every pad: the latest change on any slot, as the button reads pressed
|
|
236
|
+
// while any of them holds it.
|
|
237
|
+
let changedAt = (name: string): number | null => {
|
|
238
|
+
if (slot !== undefined) return read.buttonChangedAt(slot, name)
|
|
239
|
+
let latest: number | null = null
|
|
240
|
+
let count = untrack(read.pads).length
|
|
241
|
+
for (let i = 0; i < count; i++) {
|
|
242
|
+
let at = read.buttonChangedAt(i, name)
|
|
243
|
+
if (at !== null && (latest === null || at > latest)) latest = at
|
|
244
|
+
}
|
|
245
|
+
return latest
|
|
246
|
+
}
|
|
247
|
+
return createGamepadDevice(pads, () => slot, changedAt, slot === undefined ? "gamepad" : `gamepad ${slot}`)
|
|
228
248
|
}
|
|
229
249
|
|
|
230
250
|
// Slots held by joining devices, shared so a pad joins once.
|
|
231
251
|
let claimed = new Set<number>()
|
|
232
252
|
|
|
233
253
|
/** A device that claims the next unclaimed pad to press any button. */
|
|
234
|
-
export function createGamepadJoin(read:
|
|
254
|
+
export function createGamepadJoin(read: PadsReader): GamepadDevice {
|
|
235
255
|
// ownedWrite: the claim lands inside an effect, the release in a cleanup.
|
|
236
256
|
let [slot, setSlot] = createSignal<number | undefined>(undefined, { ownedWrite: true })
|
|
237
257
|
let mine: number | undefined
|
|
@@ -239,7 +259,7 @@ export function createGamepadJoin(read: PadsAccessor): GamepadDevice {
|
|
|
239
259
|
// the device only when there is none to tie it to.
|
|
240
260
|
let dispose = createRoot(dispose => {
|
|
241
261
|
createEffect(
|
|
242
|
-
() => read(),
|
|
262
|
+
() => read.pads(),
|
|
243
263
|
pads => {
|
|
244
264
|
if (mine !== undefined) return
|
|
245
265
|
for (let i = 0; i < pads.length; i++) {
|
|
@@ -263,8 +283,12 @@ export function createGamepadJoin(read: PadsAccessor): GamepadDevice {
|
|
|
263
283
|
let pads = (): GamepadState[] => {
|
|
264
284
|
let s = slot()
|
|
265
285
|
if (s === undefined) return []
|
|
266
|
-
let pad = read()[s]
|
|
286
|
+
let pad = read.pads()[s]
|
|
267
287
|
return pad ? [pad] : []
|
|
268
288
|
}
|
|
269
|
-
|
|
289
|
+
let changedAt = (name: string): number | null => {
|
|
290
|
+
let s = untrack(slot)
|
|
291
|
+
return s === undefined ? null : read.buttonChangedAt(s, name)
|
|
292
|
+
}
|
|
293
|
+
return createGamepadDevice(pads, slot, changedAt, "gamepad (joined)")
|
|
270
294
|
}
|
package/src/input-gamepad.ts
CHANGED
|
@@ -6,14 +6,16 @@
|
|
|
6
6
|
// split screen that seats players in pick-up order. The sources and the
|
|
7
7
|
// join logic live in input-gamepad-device.ts, runtime-free.
|
|
8
8
|
|
|
9
|
-
import { gamepads } from "./gamepad"
|
|
9
|
+
import { gamepadButtonChangedAt, gamepads } from "./gamepad"
|
|
10
10
|
import { createGamepadJoin, createGamepadSlot } from "./input-gamepad-device"
|
|
11
|
-
import type { GamepadDevice } from "./input-gamepad-device"
|
|
11
|
+
import type { GamepadDevice, PadsReader } from "./input-gamepad-device"
|
|
12
12
|
|
|
13
13
|
export type { GamepadDevice } from "./input-gamepad-device"
|
|
14
14
|
|
|
15
|
+
let reader: PadsReader = { pads: gamepads, buttonChangedAt: gamepadButtonChangedAt }
|
|
16
|
+
|
|
15
17
|
export function gamepad(slot?: number): GamepadDevice {
|
|
16
|
-
return createGamepadSlot(
|
|
18
|
+
return createGamepadSlot(reader, slot)
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/**
|
|
@@ -21,4 +23,4 @@ export function gamepad(slot?: number): GamepadDevice {
|
|
|
21
23
|
* any button, then that pad for good (`slot` resolves, reactively). The
|
|
22
24
|
* claim is released when the creating scope is disposed.
|
|
23
25
|
*/
|
|
24
|
-
gamepad.next = (): GamepadDevice => createGamepadJoin(
|
|
26
|
+
gamepad.next = (): GamepadDevice => createGamepadJoin(reader)
|