@solidrt/2d 0.0.64 → 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 CHANGED
@@ -110,7 +110,7 @@ being shared with e.g. a 3d scene costs nothing in JS.
110
110
 
111
111
  ### Spatial queries
112
112
 
113
- Spatial queries (`checks/collision-check.tsx` pins the contract): the
113
+ Spatial queries (`tests/collision.test.tsx` pins the contract): the
114
114
  3d scene's trio one dimension down, Godot's PhysicsDirectSpaceState2D
115
115
  and CharacterBody2D in their names. `layer.raycast(x, y, dx, dy,
116
116
  opts?)` is every shown sprite the ray strikes, nearest first, with
@@ -350,7 +350,7 @@ the nearest view (the `<SpriteLayer>`'s own or the enclosing
350
350
  axes, frames only while `active()`. A view's feed normalizes a drag by
351
351
  the leaf's own box, so a leaf under a designSize fit pans and zooms
352
352
  correctly. camera2d.ts imports `@solidrt/core/input` and
353
- `@solidrt/core/camera-control` only, so checks/camera2d-check.ts pins
353
+ `@solidrt/core/camera-control` only, so tests/camera2d.test.ts pins
354
354
  the clamp, anchoring, glides, the framing, the lanes, damped bounds,
355
355
  inertia and the axes headless; examples/camera.tsx (function face) and
356
356
  examples/pick.tsx (`<Camera2d>`) are the live guards. The shared
@@ -468,13 +468,16 @@ re-runs an effect per sprite per frame for nothing.
468
468
  ### Frame animation
469
469
 
470
470
  Frame animation (animation.ts): `createAnimation(frames, fps, { loop })`
471
- is a clip with a shared wall-clock timer stepping every attached sprite
471
+ is a clip with a shared clock on the app's frame time (it freezes with
472
+ the dev clock and plays at its own speed under `srt render`) stepping
473
+ every attached sprite
472
474
  (`anim.add(sprite)` / `remove`; `play`/`pause`; `frame()`/`playing()`
473
475
  read the clock, methods like the 3d mixer's `playing()`; `loop: false`
474
476
  holds the last frame and fires `onFinish`, the mixer's name for it in
475
- @solidrt/3d). One timer per playing clip, one setSprite
476
- per sprite per STEP - an 8fps cycle is 8 publishes a second regardless of
477
- display rate, and a paused clip costs nothing. A sprite belongs to at
477
+ @solidrt/3d). One no-demand frame callback per playing clip, one
478
+ setSprite per sprite per STEP - an 8fps cycle is 8 publishes and 8
479
+ presents a second regardless of display rate, and a paused clip costs
480
+ nothing. A sprite belongs to at
478
481
  most one animation (add detaches the previous); removed sprites prune
479
482
  lazily on the next step. Works on both layer kinds; with `<Sprite>`,
480
483
  attach via `ref` and leave the `frame` prop off - the clip owns that
@@ -485,7 +488,7 @@ keeps, so other props stay reactive).
485
488
 
486
489
  frames.ts, pick.ts, camera.ts, camera-motion.ts, dispatch.ts,
487
490
  oversample-math.ts and tiles-math.ts (the tile grid's chunk and slot
488
- math, checks/tiles-check.ts) are pure (no GPU imports) BY DESIGN so they
491
+ math, tests/tiles.test.ts) are pure (no GPU imports) BY DESIGN so they
489
492
  can be checked headless; keep them that way.
490
493
 
491
494
  ## The baked tile layer (tiles.ts)
@@ -625,6 +628,9 @@ is never both a tap and a pan), was the only pointer down for its whole
625
628
  press (a pinch never taps), with `tapCount` counting repeats within 300
626
629
  ms and 20 px on the same target (a double tap is `tapCount === 2`).
627
630
  Wheel walks like a move with `deltaX/deltaY`. Every event carries
631
+ `timeStamp` and `predicted` as the element event has them (time input
632
+ with the first; a `predicted` move is one to draw with, not to measure
633
+ with - core's AGENTS.md has both), and
628
634
  `native`, the leaf's element event, for core's recognizers (`createPan`
629
635
  to drag a sprite with slop, `createTransform` under the camera); a plain
630
636
  `onPointerMove` also fires on hover, so a sprite drag gates on its own
@@ -632,7 +638,7 @@ pressed flag from down to up (`native.button` is set on down/up only).
632
638
  Root listeners all run, in registration order (the root is the last
633
639
  stop, nothing is left to claim); a record layer has no groups, so the
634
640
  chain is the sprite then the root. Event x/y are layer pixels with the
635
- camera undone. checks/dispatch-check.ts pins the walk, claiming, capture,
641
+ camera undone. tests/dispatch.test.ts pins the walk, claiming, capture,
636
642
  hover, wheel and tap rules headless.
637
643
 
638
644
  ## Traps
@@ -782,7 +788,7 @@ hover, wheel and tap rules headless.
782
788
  - Every rotation must agree on direction (clockwise, y-down):
783
789
  `pointInSprite` in pick.ts with the vertex stage's `iRot`, and
784
790
  `projectCamera` in camera.ts with `uCameraRot` and `<TileLayer>`'s view
785
- transform. The differential checks (pick-check.ts, camera-check.ts)
791
+ transform. The differential tests (tests/pick.test.ts, tests/camera.test.ts)
786
792
  guard the JS math against oracles but NOT against the shader - if you
787
793
  touch one rotation, touch all, then run examples/camera-probe.tsx (the
788
794
  live guard: shader vs projectCamera, node/record parity, the pointer
@@ -1,7 +1,7 @@
1
1
  # @solidrt/2d examples
2
2
 
3
3
  Single-concept sprite-layer patterns. Each file is a complete, runnable app
4
- (`bunx srt run <file>` from the repo root) demonstrating exactly one thing -
4
+ (`bun run srt run <file>` from the repo root) demonstrating exactly one thing -
5
5
  copy one and adapt it.
6
6
 
7
7
  - `sprites.tsx` - the layer at its natural scale: 500 sprites bouncing at
package/examples/anim.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  // Frame animation via createAnimation: a row of sprites sharing one looping
2
2
  // clip (one clock, one setSprite per sprite per STEP - an app's whole crowd
3
- // animates off a handful of timers, no onFrame loop), plus a one-shot clip
3
+ // animates off a handful of no-demand frame callbacks, presenting only on
4
+ // a step), plus a one-shot clip
4
5
  // that holds its last frame and fires onFinish. Self-asserting: samples the
5
6
  // sprites' frames against the wall clock and logs ANIM-OK / ANIM-FAIL.
6
7
  //
@@ -15,7 +16,7 @@ import logoBytes from "./logo.png" with { type: "binary" }
15
16
  const W = 520
16
17
  const H = 200
17
18
  const SPRITE = 96
18
- // Slow enough that timer jitter cannot blur a sampled frame boundary: the
19
+ // Slow enough that a late timer cannot blur a sampled frame boundary: the
19
20
  // checks sample mid-frame, half a period from either edge.
20
21
  const LOOP_FPS = 2
21
22
  const SHOT_FPS = 5
@@ -53,9 +54,9 @@ function App() {
53
54
  return indexOf(getSprite(sprite)!.frame)
54
55
  }
55
56
 
56
- // Sample mid-frame against the wall clock. At LOOP_FPS = 2 a frame is
57
- // 500ms; the clip steps within ~250ms of a boundary (tick at half a
58
- // period), so mid-frame samples have ~250ms of margin either way.
57
+ // Sample mid-frame on timers. At LOOP_FPS = 2 a frame is 500ms and the
58
+ // clip steps on the display frame a boundary falls in, so mid-frame
59
+ // samples have ~250ms of margin either way.
59
60
  let period = 1000 / LOOP_FPS
60
61
  setTimeout(() => {
61
62
  check(shownIndex(looped[0]!) === 0, "loop shows frame 0 in its first period")
@@ -1,7 +1,7 @@
1
1
  // Camera-mapping probe: the live guard that the three spellings of the
2
2
  // camera mapping agree - projectCamera (camera.ts), the vertex stages'
3
3
  // uCameraRot (shaders.ts), and pointer dispatch's unprojectCamera
4
- // (layer.ts). The headless check (checks/camera-check.ts) ties
4
+ // (layer.ts). The headless check (tests/camera.test.ts) ties
5
5
  // projectCamera to the <view>-prop oracle but cannot see the shader; this
6
6
  // probe closes that gap on a real client. Self-asserting: watch the logs
7
7
  // for CAMERA lines ending in CAMERA-OK. Run it after touching ANY
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/2d",
3
- "version": "0.0.64",
3
+ "version": "0.0.65",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -14,8 +14,11 @@
14
14
  "examples/",
15
15
  "AGENTS.md"
16
16
  ],
17
+ "devDependencies": {
18
+ "@solidrt/test": "0.0.65"
19
+ },
17
20
  "peerDependencies": {
18
- "@solidjs/signals": "2.0.0-rc.9",
19
- "@solidrt/core": "0.0.64"
21
+ "@solidjs/signals": "2.0.0-rc.13",
22
+ "@solidrt/core": "0.0.65"
20
23
  }
21
24
  }
package/src/animation.ts CHANGED
@@ -1,24 +1,21 @@
1
1
  // Sprite frame animation: a clip (frames + fps) with a shared clock that
2
- // steps every attached sprite's frame through setSprite. One timer per
3
- // playing clip and one frame write per attached sprite per STEP (not per
4
- // display frame), so an 8fps walk cycle generates 8 publishes a second and
5
- // a paused clip costs nothing - the demand-gate story unchanged. The clip
2
+ // steps every attached sprite's frame through setSprite. One frame callback
3
+ // per playing clip, registered without demand, and one frame write per
4
+ // attached sprite per STEP (not per display frame), so an 8fps walk cycle
5
+ // generates 8 publishes and 8 presents a second and a paused clip costs
6
+ // nothing - the demand-gate story unchanged. The clock is the app's frame
7
+ // time: the clip freezes and resumes with the dev tools' clock, plays at
8
+ // its own speed under `srt render`, and a step lands on the frame it is
9
+ // due in. The clip
6
10
  // does not own its sprites (destroySprite prunes lazily on the next step),
7
11
  // and a sprite belongs to at most one animation. Plain JS over setSprite,
8
12
  // so it works on both layer kinds and composes with <Sprite> via ref (leave
9
13
  // the frame prop off - the clip owns that field).
10
- import { getOwner, onCleanup } from "@solidrt/core"
14
+ import { getOwner, onCleanup, onFrame, runWithOwner } from "@solidrt/core"
11
15
  import type { Frame } from "./frames.ts"
12
16
  import type { Sprite } from "./layer.ts"
13
17
  import { setSprite } from "./layer.ts"
14
18
 
15
- // Timer period as a fraction of the clip's frame duration: the step index
16
- // is computed from the wall clock (drift-free), so the timer only decides
17
- // how late after a frame boundary the step lands - half a frame bounds
18
- // that lateness at half a frame, where a full-frame period could show a
19
- // frame almost a whole frame late.
20
- const TICK_RATIO = 0.5
21
-
22
19
  export type AnimationOptions = {
23
20
  /** Wrap around (default) or play once and hold the last frame. */
24
21
  loop?: boolean
@@ -61,7 +58,7 @@ let attachedTo = new WeakMap<Sprite, SpriteAnimation>()
61
58
  /**
62
59
  * A frame animation clip: `frames` (usually a slice of a `grid()` result)
63
60
  * played at `fps`. Sprites attach with `add`; the clip steps their `frame`
64
- * on its own wall-clock timer, independent of display rate.
61
+ * on the app's frame time, independent of display rate.
65
62
  */
66
63
  export function createAnimation(frames: Frame[], fps: number, opts?: AnimationOptions): SpriteAnimation {
67
64
  if (frames.length === 0) throw new Error("createAnimation: frames is empty")
@@ -74,24 +71,30 @@ export function createAnimation(frames: Frame[], fps: number, opts?: AnimationOp
74
71
  let playing = true
75
72
  let ended = false
76
73
  let disposed = false
77
- let timer: number | null = null
78
- let elapsedBase = 0 // ms of clip time accumulated before the running stretch
79
- let runStart = 0 // performance.now() when the clock last started
74
+ // The running clock's frame callback, null while stopped.
75
+ let stop: (() => void) | null = null
76
+ let elapsed = 0 // ms of clip time played so far
77
+ // The frame time the clip last advanced to, null until the running
78
+ // clock's first frame: that one only anchors, so a clip starts at the
79
+ // frame it was started in, whenever in it.
80
+ let lastTick: number | null = null
80
81
 
81
82
  function startClock(): void {
82
- if (timer !== null || !playing || sprites.size === 0) return
83
+ if (stop !== null || !playing || sprites.size === 0) return
83
84
  // A looping single-frame clip never changes; a one-shot still needs
84
85
  // the clock once, to end.
85
86
  if (loop && frames.length === 1) return
86
- runStart = performance.now()
87
- timer = setInterval(tick, (1000 / fps) * TICK_RATIO)
87
+ lastTick = null
88
+ // No demand: the clip follows the frames the app runs anyway and asks
89
+ // for one only by writing a step. Under no owner: the clock is the
90
+ // clip's (stopClock ends it), not the scope's that attached a sprite.
91
+ stop = runWithOwner(null, () => onFrame(tick, { demand: false }))
88
92
  }
89
93
 
90
94
  function stopClock(): void {
91
- if (timer === null) return
92
- elapsedBase += performance.now() - runStart
93
- clearInterval(timer)
94
- timer = null
95
+ if (stop === null) return
96
+ stop()
97
+ stop = null
95
98
  }
96
99
 
97
100
  function writeAll(): void {
@@ -112,8 +115,11 @@ export function createAnimation(frames: Frame[], fps: number, opts?: AnimationOp
112
115
  writeAll()
113
116
  }
114
117
 
115
- function tick(): void {
116
- let raw = Math.floor(((elapsedBase + performance.now() - runStart) * fps) / 1000)
118
+ function tick(now: number): void {
119
+ // Never backward: across a hot reload the frame time restarts.
120
+ if (lastTick !== null) elapsed += Math.max(0, now - lastTick)
121
+ lastTick = now
122
+ let raw = Math.floor((elapsed * fps) / 1000)
117
123
  if (!loop && raw >= frames.length) {
118
124
  // The last frame has shown for its full duration: hold it and stop.
119
125
  setIndex(frames.length - 1)
@@ -147,7 +153,7 @@ export function createAnimation(frames: Frame[], fps: number, opts?: AnimationOp
147
153
  if (disposed || playing) return
148
154
  if (ended) {
149
155
  ended = false
150
- elapsedBase = 0
156
+ elapsed = 0
151
157
  current = 0
152
158
  writeAll()
153
159
  }
package/src/camera.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // The 2d camera vocabulary, pure: the one camera type both layers share
2
2
  // and its world <-> screen mapping as plain functions - no GPU or GUI
3
- // imports, so the checks rig (checks/camera-check.ts) exercises this
3
+ // imports, so its test (tests/camera.test.ts) exercises this
4
4
  // module headless on the flux binary. Three consumers implement or undo
5
5
  // exactly this mapping and must agree with it: the vertex stages
6
6
  // (shaders.ts, uCamera + uCameraRot), the tile layer's composite
package/src/camera2d.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  // business - an input map over the view's pointer feed, a pad, a debug
24
24
  // command - and `camera2dBindings` in input.ts is the standard wiring
25
25
  // (drag pans, pinch and wheel zoom, twist rolls). This module imports no
26
- // GUI or runtime module, so checks/camera2d-check.ts runs it headless.
26
+ // GUI or runtime module, so tests/camera2d.test.ts runs it headless.
27
27
  //
28
28
  // Conventions, decided against Godot's Camera2D, Unity's Cinemachine and
29
29
  // Three's MapControls (plus the map/whiteboard libraries for the canvas
@@ -136,7 +136,7 @@ export let TileLayer: VoidComponent<TileLayerProps> = props => {
136
136
  // World -> screen: p maps to pivot + R(rotation) * zoom * (p - camera) -
137
137
  // projectCamera (camera.ts) spelled with element transforms as origin at
138
138
  // the camera point, rotate + scale there, then translate the camera point
139
- // onto the pivot. checks/camera-check.ts holds the two spellings together.
139
+ // onto the pivot. tests/camera.test.ts holds the two spellings together.
140
140
  let camX = () => props.camera?.x ?? 0
141
141
  let camY = () => props.camera?.y ?? 0
142
142
  return (
package/src/dispatch.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  // counted up for repeats (DOM's detail, Unity's clickCount).
18
18
  //
19
19
  // Pure BY DESIGN (types and camera.ts, nothing with GPU imports) so
20
- // checks/dispatch-check.ts drives it headless with a fake pick.
20
+ // tests/dispatch.test.ts drives it headless with a fake pick.
21
21
 
22
22
  import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
23
23
  import { unprojectCamera } from "./camera.ts"
@@ -47,8 +47,6 @@ export type DispatchDeps = {
47
47
  root: ViewHandle
48
48
  /** The root's listeners, in registration order. */
49
49
  listeners: Set<LayerPointerListener>
50
- /** The clock for tap repeats (default performance.now; checks inject). */
51
- now?: () => number
52
50
  }
53
51
 
54
52
  type HandlerName = "onPointerDown" | "onPointerMove" | "onPointerUp" | "onWheel" | "onTap"
@@ -61,6 +59,8 @@ type InternalEvent = {
61
59
  currentTarget: Sprite | SpriteGroup | ViewHandle
62
60
  x: number
63
61
  y: number
62
+ timeStamp: number
63
+ predicted: boolean
64
64
  pointerId: number
65
65
  pointerType: string
66
66
  button?: number
@@ -89,7 +89,6 @@ type Press = {
89
89
  }
90
90
 
91
91
  export function spriteDispatch(deps: DispatchDeps): (layout: (() => { width: number; height: number }) | null) => LayerHandlers {
92
- let now = deps.now ?? (() => performance.now())
93
92
  let presses = new Map<number, Press>()
94
93
  let hover = new Map<number, Sprite>()
95
94
  let lastTap: { time: number; cx: number; cy: number; target: Sprite | null; count: number } | null = null
@@ -135,6 +134,8 @@ export function spriteDispatch(deps: DispatchDeps): (layout: (() => { width: num
135
134
  currentTarget: sprite ?? deps.root,
136
135
  x,
137
136
  y,
137
+ timeStamp: e.timeStamp,
138
+ predicted: e.predicted,
138
139
  pointerId: e.pointerId,
139
140
  pointerType: e.pointerType,
140
141
  button: e.button,
@@ -199,7 +200,7 @@ export function spriteDispatch(deps: DispatchDeps): (layout: (() => { width: num
199
200
  // press, released over the target it pressed (empty space for the
200
201
  // layer itself).
201
202
  if (press.moved || !press.alone || topmost(x, y) !== press.target) return
202
- let time = now()
203
+ let time = e.timeStamp
203
204
  let repeat =
204
205
  lastTap !== null &&
205
206
  time - lastTap.time <= TAP_INTERVAL_MS &&
package/src/frames.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // Atlas frame math, pure: names and grid coordinates to normalized UV rects.
2
- // No GPU or GUI imports, so the checks rig (checks/frames-check.ts) exercises
2
+ // No GPU or GUI imports, so its test (tests/frames.test.ts) exercises
3
3
  // this module headless on the flux binary.
4
4
 
5
5
  /**
package/src/layer.ts CHANGED
@@ -265,6 +265,11 @@ export type LayerEventBase = {
265
265
  /** Pointer position in LAYER pixels (the camera mapping undone). */
266
266
  x: number
267
267
  y: number
268
+ /** When the event happened, ms: the element event's `timeStamp`. */
269
+ timeStamp: number
270
+ /** The element event's `predicted`: a move the pointer was never
271
+ * reported at. Draw with it, do not measure with it. */
272
+ predicted: boolean
268
273
  pointerId: number
269
274
  pointerType: string
270
275
  button?: number
@@ -1,5 +1,5 @@
1
1
  // The pure oversample math: arithmetic on plain numbers, no GPU or window
2
- // imports, so the headless check (checks/oversample-check.ts) drives it
2
+ // imports, so the headless check (tests/oversample.test.ts) drives it
3
3
  // directly. oversample.ts wraps it with the device limits; components/
4
4
  // adds the measured inputs (window box, display scale, camera).
5
5
 
package/src/oversample.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // instead of snapping to uneven widths). okf/backlog/2d-layer-display-scale.md.
7
7
  // The pure math lives in oversample-math.ts (checkable headless); this
8
8
  // module binds it to the device limits and adds the thrash sentinel.
9
+ import { frameTime } from "@solidrt/core"
9
10
  import { limits } from "@solidrt/core/gpu"
10
11
  import { fitOversampleWithin } from "./oversample-math.ts"
11
12
 
@@ -36,7 +37,9 @@ export function fitOversample(scale: number, targetW: number, targetH: number, b
36
37
  * healthy layer settles in a change or two (mount, then the post-resize
37
38
  * scale); reaching this count means something re-picks every frame. */
38
39
  const THRASH_CHANGES = 4
39
- /** The window those changes must land in, ms. */
40
+ /** The window those changes must land in, ms of the app's frame time: a
41
+ * warning about what happens per frame counts on the clock frames run on,
42
+ * so it means the same under `srt render` and a paused dev clock. */
40
43
  const THRASH_WINDOW_MS = 1000
41
44
 
42
45
  /**
@@ -52,7 +55,7 @@ export function thrashSentinel(what: string): () => void {
52
55
  let warned = false
53
56
  return () => {
54
57
  if (warned) return
55
- let now = performance.now()
58
+ let now = frameTime()
56
59
  stamps.push(now)
57
60
  while (stamps.length > 0 && now - stamps[0]! > THRASH_WINDOW_MS) stamps.shift()
58
61
  if (stamps.length < THRASH_CHANGES) return
package/src/pick.ts CHANGED
@@ -1,5 +1,5 @@
1
- // Picking narrowphase, pure: no GPU or GUI imports, so the checks rig
2
- // (checks/pick-check.ts) exercises this module headless on the flux binary.
1
+ // Picking narrowphase, pure: no GPU or GUI imports, so its test
2
+ // (tests/pick.test.ts) exercises this module headless on the flux binary.
3
3
  // layer.ts walks its draw order calling this per sprite (topmost first).
4
4
 
5
5
  /** Exact containment test against a rotated rect (center, size, rotation). */
package/src/tiles-math.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // The pure tile-grid math: cell and rect validation, a cell's chunk and
2
2
  // record slot, and the walk of a rect chunk by chunk - plain arithmetic
3
- // with no GPU imports, so the headless check (checks/tiles-check.ts)
3
+ // with no GPU imports, so the headless check (tests/tiles.test.ts)
4
4
  // drives it directly; tiles.ts wraps it with the chunk textures.
5
5
 
6
6
  /** Throw unless (col, row) is a cell of the cols x rows grid. */