@solidrt/2d 0.0.57 → 0.0.59

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/examples/anim.tsx CHANGED
@@ -62,15 +62,15 @@ function App() {
62
62
  }, period / 2)
63
63
  setTimeout(() => {
64
64
  check(shownIndex(looped[1]!) === 1, "loop advanced to frame 1")
65
- check(looping.frame === shownIndex(looped[1]!), "anim.frame matches the sprite's shown frame")
65
+ check(looping.frame() === shownIndex(looped[1]!), "anim.frame matches the sprite's shown frame")
66
66
  }, period * 1.5)
67
67
  setTimeout(() => {
68
68
  check(shownIndex(looped[2]!) === 0, "loop wrapped back to frame 0 after a full cycle")
69
69
  // One-shot: 4 frames at 5fps end at 800ms - long since done here.
70
70
  check(shownIndex(shotSprite) === frames.length - 1, "one-shot holds its last frame")
71
- check(!shot.playing, "one-shot stopped playing")
71
+ check(!shot.playing(), "one-shot stopped playing")
72
72
  check(ended === 1, `onFinish fired once, got ${ended}`)
73
- check(looping.playing, "the looping clip is still playing")
73
+ check(looping.playing(), "the looping clip is still playing")
74
74
  console.log(failures.length === 0 ? "ANIM-OK" : `ANIM-FAIL: ${failures.join("; ")}`)
75
75
  }, period * 4.5)
76
76
 
@@ -17,7 +17,7 @@ import {
17
17
  createSpriteLayer,
18
18
  getSprite,
19
19
  grid,
20
- removeSprite,
20
+ destroySprite,
21
21
  setGroup,
22
22
  setSprite,
23
23
  } from "@solidrt/2d"
@@ -83,11 +83,11 @@ function App() {
83
83
  {
84
84
  let extra = addSprite(nodes, { x: 10, y: 10, w: 8, h: 8 })
85
85
  let slotBefore = extra._slot
86
- removeSprite(extra)
86
+ destroySprite(extra)
87
87
  let reused = addSprite(nodes, { x: -100, y: -100, w: 0, h: 0 })
88
88
  check(reused._slot === slotBefore, "removed slot recycles to the next add")
89
89
  check(getSprite(extra) === null, "removed handle is inert")
90
- removeSprite(reused)
90
+ destroySprite(reused)
91
91
  }
92
92
 
93
93
  let pixelParity = () => {
@@ -158,7 +158,7 @@ function App() {
158
158
  check(nodes.pick(150, 100)[0] !== child, "the ungrouped position no longer hits")
159
159
  let f = getSprite(child)!
160
160
  check(f.x === 50 && f.y === 0, "getSprite reads the local pose")
161
- removeSprite(child)
161
+ destroySprite(child)
162
162
  // The group node stays for the layer to dispose.
163
163
  })
164
164
  }
@@ -13,8 +13,15 @@
13
13
  // random angle always takes the short arc, and the position spring keeps
14
14
  // its velocity when a shuffle lands mid-flight - retarget as fast as you
15
15
  // like, the motion stays continuous.
16
+ //
17
+ // Tap a sprite and it LEAVES: destroySprite lets go of the handle, and the
18
+ // `exit` on its scale entry shrinks it away on an ease-in of its own while
19
+ // the sprite is a ghost - drawn, but no tap or pick reaches it - then the
20
+ // core frees it. A fresh sprite pops into the empty slot a moment later,
21
+ // entering from scale zero (`from`); no settle callback, no bookkeeping.
16
22
  import { decodeImage, render } from "@solidrt/core"
17
- import { addSprite, createAtlas, createSpriteLayer, grid, setSprite, setSpriteTransition } from "@solidrt/2d"
23
+ import { addSprite, createAtlas, createSpriteLayer, destroySprite, grid, setSprite, setSpriteTransition } from "@solidrt/2d"
24
+ import type { SpriteHandle, SpriteTransition } from "@solidrt/2d"
18
25
  import logoBytes from "./logo.png" with { type: "binary" }
19
26
 
20
27
  const COLS = 20
@@ -23,6 +30,16 @@ const COUNT = COLS * ROWS
23
30
  const W = 720
24
31
  const H = 720
25
32
  const SPRITE = 30
33
+ // The pop-in / shrink-out of a sprite's size, and the pause before a
34
+ // tapped-away sprite is replaced.
35
+ const POP_MS = 300
36
+ const RESPAWN_MS = 600
37
+
38
+ const TRANSITION: SpriteTransition = {
39
+ position: { duration: 700, bounce: 0.3 },
40
+ rotation: { duration: 700 },
41
+ scale: { duration: POP_MS, bounce: 0.4, from: [0, 0], exit: { value: [0, 0], curve: "ease-in", duration: POP_MS / 2 } },
42
+ }
26
43
 
27
44
  function App() {
28
45
  let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
@@ -35,12 +52,17 @@ function App() {
35
52
 
36
53
  // Sprite k sits at grid slot slots[k]; each shuffle re-deals the slots.
37
54
  let slots = Array.from({ length: COUNT }, (_, i) => i)
38
- let sprites = slots.map(slot =>
39
- addSprite(layer, { x: slotX(slot), y: slotY(slot), w: SPRITE, h: SPRITE, frame: frames[slot % 4] }),
40
- )
41
- for (let sprite of sprites) {
42
- setSpriteTransition(sprite, { position: { duration: 700, bounce: 0.3 }, rotation: { duration: 700 } })
55
+ let spawn = (k: number): SpriteHandle => {
56
+ let sprite = addSprite(layer, { x: slotX(slots[k]!), y: slotY(slots[k]!), w: SPRITE, h: SPRITE, frame: frames[k % 4] })
57
+ // Declared in the adding tick, so the `from` plays: the sprite pops in.
58
+ setSpriteTransition(sprite, TRANSITION)
59
+ sprite.onTap = () => {
60
+ destroySprite(sprite)
61
+ setTimeout(() => (sprites[k] = spawn(k)), RESPAWN_MS)
62
+ }
63
+ return sprite
43
64
  }
65
+ let sprites = slots.map((_, k) => spawn(k))
44
66
 
45
67
  let shuffle = () => {
46
68
  for (let i = slots.length - 1; i > 0; i--) {
@@ -58,7 +80,7 @@ function App() {
58
80
 
59
81
  return (
60
82
  <window alignItems="center" justifyContent="center">
61
- <texture src={view.texture} width={W} height={H} />
83
+ <texture src={view.texture} width={W} height={H} {...view.handlers} />
62
84
  </window>
63
85
  )
64
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/2d",
3
- "version": "0.0.57",
3
+ "version": "0.0.59",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -15,7 +15,7 @@
15
15
  "AGENTS.md"
16
16
  ],
17
17
  "peerDependencies": {
18
- "@solidjs/signals": "2.0.0-rc.6",
19
- "@solidrt/core": "0.0.57"
18
+ "@solidjs/signals": "2.0.0-rc.8",
19
+ "@solidrt/core": "0.0.59"
20
20
  }
21
21
  }
package/src/animation.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // playing clip and one frame write per attached sprite per STEP (not per
4
4
  // display frame), so an 8fps walk cycle generates 8 publishes a second and
5
5
  // a paused clip costs nothing - the demand-gate story unchanged. The clip
6
- // does not own its sprites (removeSprite prunes lazily on the next step),
6
+ // does not own its sprites (destroySprite prunes lazily on the next step),
7
7
  // and a sprite belongs to at most one animation. Plain JS over setSprite,
8
8
  // so it works on both layer kinds and composes with <Sprite> via ref (leave
9
9
  // the frame prop off - the clip owns that field).
@@ -28,10 +28,10 @@ export type AnimationOptions = {
28
28
 
29
29
  export type SpriteAnimation = {
30
30
  /** Current frame index into the clip. */
31
- readonly frame: number
31
+ frame(): number
32
32
  /** True while the clock advances: born playing, false after pause() and
33
33
  * after a one-shot ends. */
34
- readonly playing: boolean
34
+ playing(): boolean
35
35
  /**
36
36
  * Attach a sprite: it shows the clip's current frame immediately and
37
37
  * steps with the shared clock (every attached sprite shows the SAME
@@ -127,12 +127,8 @@ export function createAnimation(frames: Frame[], fps: number, opts?: AnimationOp
127
127
  }
128
128
 
129
129
  let handle: SpriteAnimation = {
130
- get frame() {
131
- return current
132
- },
133
- get playing() {
134
- return playing
135
- },
130
+ frame: () => current,
131
+ playing: () => playing,
136
132
  onFinish: undefined,
137
133
  add(sprite) {
138
134
  if (disposed) throw new Error("createAnimation: add on a disposed animation")
package/src/camera2d.ts CHANGED
@@ -53,7 +53,8 @@ import { projectCamera, unprojectCamera } from "./camera.ts"
53
53
  import type { CameraUpdate } from "./camera.ts"
54
54
 
55
55
  // Glide rate toward a pending zoom or pose, e-foldings per second: high
56
- // enough that a wheel notch reads as one push, low enough to look smooth.
56
+ // enough that a wheel notch reads as one push, low enough to look smooth
57
+ // (the 3d controls' rate, motion.ts there); `damping` scales a notch's.
57
58
  const GLIDE_EASE = 9
58
59
  // A glide lands when the remaining zoom gap is under this (relative) ...
59
60
  const GLIDE_EPSILON = 0.002
@@ -86,6 +87,7 @@ const ROLL_RATE = 0.5
86
87
  /** What a 2d camera drives: anything with the layers' `setCamera`. */
87
88
  export type Camera2dTarget = { setCamera(update: CameraUpdate): void }
88
89
 
90
+ /** The control's parameters: what set() takes and pose() returns filled. */
89
91
  export type Camera2dPose = {
90
92
  x?: number
91
93
  y?: number
@@ -121,6 +123,11 @@ export type Camera2dOptions = Camera2dPose & {
121
123
  panSpeed?: number
122
124
  zoomSpeed?: number
123
125
  rollSpeed?: number
126
+ /** How long an unbracketed zoom delta (a wheel notch) takes to ease in,
127
+ * as a multiple of the built-in settle time: 1 (the default) is the
128
+ * built-in ease, 2 coasts twice as long, 0 applies notches at once, as
129
+ * a pinch does. The 3d controls' knob, same meaning. */
130
+ damping?: number
124
131
  /** Multiplier over the built-in follow damping. */
125
132
  followSpeed?: number
126
133
  /** Whether a drag release keeps the view gliding (default true). */
@@ -133,6 +140,9 @@ export type Camera2d = {
133
140
  /** The pose as the layers receive it (a fresh object per call): the
134
141
  * argument for projectCamera/unprojectCamera. */
135
142
  camera(): CameraUpdate
143
+ /** Pose snapshot - the control's own four values, the shape set() takes
144
+ * (camera() adds the pivot the layers are told). */
145
+ pose(): Required<Camera2dPose>
136
146
  /** Merge a pose in (clamps apply) and push it. A snap: an x/y/zoom
137
147
  * write cancels a glide or fling in flight; rotation alone leaves them
138
148
  * running. */
@@ -222,6 +232,9 @@ function checkOptions(options: Camera2dOptions): void {
222
232
  }
223
233
  finite("pivot.x", options.pivot?.x)
224
234
  finite("pivot.y", options.pivot?.y)
235
+ if (options.damping !== undefined && !(Number.isFinite(options.damping) && options.damping >= 0)) {
236
+ throw new Error(`createCamera2d: damping must be a non-negative number, got ${options.damping}`)
237
+ }
225
238
  checkFraction("deadZone.width", options.deadZone?.width ?? 0)
226
239
  checkFraction("deadZone.height", options.deadZone?.height ?? 0)
227
240
  }
@@ -257,6 +270,7 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
257
270
  let panSpeed = () => options.panSpeed ?? 1
258
271
  let zoomExponent = () => options.zoomSpeed ?? 1
259
272
  let rollSpeed = () => options.rollSpeed ?? 1
273
+ let damping = () => options.damping ?? 1
260
274
  let followEase = () => FOLLOW_EASE * (options.followSpeed ?? 1)
261
275
  let inertia = () => options.inertia ?? true
262
276
 
@@ -443,7 +457,7 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
443
457
  let zoomAt = (sx: number, sy: number, factor: number, ease: boolean) => {
444
458
  positive("zoomAt factor", factor)
445
459
  readViewport()
446
- if (!ease) {
460
+ if (!ease || damping() <= 0) {
447
461
  interrupt()
448
462
  zoomTo(sx, sy, zoom * factor)
449
463
  return
@@ -512,6 +526,7 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
512
526
 
513
527
  return {
514
528
  camera,
529
+ pose: () => ({ x, y, zoom, rotation }),
515
530
  active,
516
531
  axes,
517
532
  set(pose) {
@@ -654,7 +669,9 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
654
669
  touch()
655
670
  }
656
671
  if (glide !== null && dt > 0) {
657
- let k = 1 - Math.exp(-GLIDE_EASE * dt)
672
+ // A wheel notch's glide coasts as `damping` says; a commanded
673
+ // glide (glideTo, fit) at the built-in rate.
674
+ let k = 1 - Math.exp(-(glide.kind === "anchor" ? GLIDE_EASE / damping() : GLIDE_EASE) * dt)
658
675
  if (glide.kind === "anchor") {
659
676
  let gap = Math.log(glide.target / zoom)
660
677
  zoom = Math.abs(gap) < GLIDE_EPSILON ? glide.target : zoom * Math.exp(gap * k)
@@ -1,8 +1,8 @@
1
1
  import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
2
  import type { ParentComponent } from "@solidrt/core"
3
- import { addGroup, removeGroup, setGroup, setGroupTransition } from "../layer.ts"
3
+ import { addGroup, destroyGroup, setGroup, setGroupTransition } from "../layer.ts"
4
4
  import type { SpriteGroup, SpritePointerEvent, SpriteTapEvent, SpriteWheelEvent, TransitionEndEvent } from "../layer.ts"
5
- import type { NodeTransition } from "flux:spatial"
5
+ import type { SpriteTransition } from "../layer.ts"
6
6
  import { GroupContext, LayerContext } from "./context.ts"
7
7
 
8
8
  export type GroupProps = {
@@ -23,9 +23,9 @@ export type GroupProps = {
23
23
  onPointerUp?: (event: SpritePointerEvent) => void
24
24
  onWheel?: (event: SpriteWheelEvent) => void
25
25
  onTap?: (event: SpriteTapEvent) => void
26
- /** How pose-prop changes animate (see setGroupTransition); the mount
27
- * pose always snaps. */
28
- transition?: NodeTransition | string | null
26
+ /** How pose-prop changes animate (see setGroupTransition). The mount
27
+ * pose snaps, unless a component's `from` animates it in from there. */
28
+ transition?: SpriteTransition | string | null
29
29
  /** A declared transition settled on one component. */
30
30
  onTransitionEnd?: (event: TransitionEndEvent) => void
31
31
  ref?: (group: SpriteGroup) => void
@@ -63,6 +63,6 @@ export let Group: ParentComponent<GroupProps> = props => {
63
63
  },
64
64
  )
65
65
  untrack(() => props.ref)?.(group)
66
- onCleanup(() => removeGroup(group))
66
+ onCleanup(() => destroyGroup(group))
67
67
  return <GroupContext value={group}>{props.children}</GroupContext>
68
68
  }
@@ -241,6 +241,14 @@ export let SpriteLayer: ParentComponent<SpriteLayerProps> = props => {
241
241
  }),
242
242
  )
243
243
  let events = untrack(() => props.events) !== false
244
+ // A feed on a built-in leaf carrying no handlers listens at a root no
245
+ // event reaches: a contradiction, so it throws (the dev validation
246
+ // policy) instead of feeding nothing.
247
+ if (pointer !== null && !events && !output) {
248
+ throw new Error(
249
+ "SpriteLayer: a pointer feed with events={false} receives nothing - the feed listens at the view's root, which only the leaf's handlers reach; keep events on, or compose the leaf with output and spread useSpriteLayer().viewport.handlers on it",
250
+ )
251
+ }
244
252
  let leaf: { id: number } | undefined
245
253
  // The view's pixel size: the props in fixed mode, the built-in leaf's
246
254
  // laid-out box in fill mode (getLayoutBox, the untransformed read, so a
@@ -1,8 +1,8 @@
1
1
  import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
2
  import type { VoidComponent } from "@solidrt/core"
3
- import { addSprite, removeSprite, setSprite, setSpriteTransition } from "../layer.ts"
3
+ import { addSprite, destroySprite, setSprite, setSpriteTransition } from "../layer.ts"
4
4
  import type { Sprite as SpriteHandle, SpriteOptions, SpritePointerEvent, SpriteTapEvent, SpriteWheelEvent, TransitionEndEvent } from "../layer.ts"
5
- import type { NodeTransition } from "flux:spatial"
5
+ import type { SpriteTransition } from "../layer.ts"
6
6
  import { GroupContext, LayerContext } from "./context.ts"
7
7
 
8
8
  /**
@@ -29,9 +29,9 @@ export type SpritePointerProps = {
29
29
 
30
30
  export type SpriteProps = SpriteOptions &
31
31
  SpritePointerProps & {
32
- /** How pose-prop changes animate (see setSpriteTransition); the mount
33
- * pose always snaps. */
34
- transition?: NodeTransition | string | null
32
+ /** How pose-prop changes animate (see setSpriteTransition). The mount
33
+ * pose snaps, unless a component's `from` animates it in from there. */
34
+ transition?: SpriteTransition | string | null
35
35
  /** A declared transition settled on one component. */
36
36
  onTransitionEnd?: (event: TransitionEndEvent) => void
37
37
  ref?: (sprite: SpriteHandle) => void
@@ -67,6 +67,6 @@ export let Sprite: VoidComponent<SpriteProps> = props => {
67
67
  },
68
68
  )
69
69
  untrack(() => props.ref)?.(sprite)
70
- onCleanup(() => removeSprite(sprite))
70
+ onCleanup(() => destroySprite(sprite))
71
71
  return null
72
72
  }
@@ -113,6 +113,14 @@ export let View2d: ParentComponent<View2dProps> = props => {
113
113
  )
114
114
  let output = untrack(() => props.output)
115
115
  let events = untrack(() => props.events) !== false
116
+ // A feed on a built-in leaf carrying no handlers listens at a root no
117
+ // event reaches: a contradiction, so it throws (the dev validation
118
+ // policy) instead of feeding nothing.
119
+ if (pointer !== null && !events && !output) {
120
+ throw new Error(
121
+ "View2d: a pointer feed with events={false} receives nothing - the feed listens at the view's root, which only the leaf's handlers reach; keep events on, or compose the leaf with output and spread view.handlers on it",
122
+ )
123
+ }
116
124
  let leaf: { id: number } | undefined
117
125
  // Auto oversample from the built-in leaf's window box, in device pixels,
118
126
  // per view pixel: the same pick as SpriteLayer's fixed mode.
package/src/dispatch.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
23
23
  import { unprojectCamera } from "./camera.ts"
24
24
  import type { CameraUpdate } from "./camera.ts"
25
- import type { LayerPointerListener, Sprite, SpriteGroup, SpriteHandlers, SpritePointerEvent } from "./layer.ts"
25
+ import type { LayerPointerListener, Sprite, SpriteGroup, LayerHandlers, SpritePointerEvent } from "./layer.ts"
26
26
  import type { ViewHandle } from "./views.ts"
27
27
 
28
28
  // Finger travel from the down point, in window pixels, past which a press
@@ -88,7 +88,7 @@ type Press = {
88
88
  alone: boolean
89
89
  }
90
90
 
91
- export function spriteDispatch(deps: DispatchDeps): (layout: (() => { width: number; height: number }) | null) => SpriteHandlers {
91
+ export function spriteDispatch(deps: DispatchDeps): (layout: (() => { width: number; height: number }) | null) => LayerHandlers {
92
92
  let now = deps.now ?? (() => performance.now())
93
93
  let presses = new Map<number, Press>()
94
94
  let hover = new Map<number, Sprite>()
package/src/index.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  // without Solid components) and the components (SpriteLayer/Sprite/Group/
16
16
  // View2d/TileLayer) on top. See AGENTS.md for the model and the traps.
17
17
 
18
- export { addGroup, addSprite, createSpriteLayer, getSprite, removeGroup, removeSprite, setGroup, setGroupTransition, setSprite, setSpriteParent, setSpriteTransition, worldPosition, POSE_FLOATS, STYLE_FLOATS } from "./layer.ts"
18
+ export { addGroup, addSprite, createSpriteLayer, getSprite, destroyGroup, destroySprite, setGroup, setGroupTransition, setSprite, setSpriteParent, setSpriteTransition, worldPosition, POSE_FLOATS, STYLE_FLOATS } from "./layer.ts"
19
19
  export { createRecordLayer, FLOATS_PER_SPRITE } from "./records.ts"
20
20
  export type { RecordLayer as RecordLayerHandle, RecordLayerOptions } from "./records.ts"
21
21
  export { pointInSprite } from "./pick.ts"
@@ -35,7 +35,7 @@ export type {
35
35
  MoveResult,
36
36
  Overlap,
37
37
  QueryOptions,
38
- RayHit,
38
+ Hit,
39
39
  Rect,
40
40
  LayerPointerEvent,
41
41
  LayerPointerListener,
@@ -43,12 +43,15 @@ export type {
43
43
  LayerWheelEvent,
44
44
  Sprite as SpriteHandle,
45
45
  SpriteGroup,
46
- SpriteHandlers,
46
+ LayerHandlers,
47
47
  SpriteLayer as SpriteLayerHandle,
48
48
  SpriteLayerOptions,
49
49
  SpriteOptions,
50
50
  SpritePointerEvent,
51
51
  SpriteTapEvent,
52
+ SpriteEndpoint,
53
+ SpriteTransition,
54
+ SpriteTransitionSpec,
52
55
  SpriteWheelEvent,
53
56
  TransitionEndEvent,
54
57
  Volume,