@solidrt/2d 0.0.71 → 0.0.73

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.
@@ -1,4 +1,4 @@
1
- import { createEffect, merge, onFrame, untrack } from "@solidrt/core"
1
+ import { callRef, createEffect, merge, onFrame, onLayout, untrack } from "@solidrt/core"
2
2
  import type { InputMap, VoidComponent } from "@solidrt/core"
3
3
  import { createCamera2d } from "../camera2d.ts"
4
4
  import type { Camera2d as Camera2dHandle, Camera2dAxes, Camera2dOptions } from "../camera2d.ts"
@@ -33,9 +33,10 @@ const MAX_CAMERA_DT = 0.1
33
33
  * the follow's zones, the offset lane or a rate follows its prop, and a
34
34
  * bounds, pivot or offset change re-clamps and pushes the pose at once -
35
35
  * the `<OrbitCamera>` rule. The view's `size()` is the viewport (live: a
36
- * fill layer's box, a setSize, a `<View2d>` resize). Frames run only
37
- * while the camera moves (`active()`), so a resting camera leaves the
38
- * app demand-driven idle.
36
+ * fill layer's box, a setSize, a `<View2d>` resize), re-read after every
37
+ * layout so a resting camera's pivot follows a resize too. Frames run
38
+ * only while the camera moves (`active()`), so a resting camera leaves
39
+ * the app demand-driven idle.
39
40
  */
40
41
  export let Camera2d: VoidComponent<Camera2dProps> = props => {
41
42
  let target = useSpriteLayer().viewport
@@ -61,6 +62,14 @@ export let Camera2d: VoidComponent<Camera2dProps> = props => {
61
62
  })
62
63
  },
63
64
  )
65
+ // The viewport follows layout: a fill layer's first box replaces its
66
+ // creation size, a <View2d> or window resize changes it, and a camera at
67
+ // rest runs no frame to notice, so its pivot (a viewport fraction) would
68
+ // sit where the old size put it until it next moved. A child mounts after
69
+ // its layer, so this handler runs after the one that sizes the view;
70
+ // update(0) re-reads the viewport and pushes only when it changed.
71
+ // Untracked: the control reads its options through the props getters.
72
+ onLayout(() => untrack(() => cam.update(0)))
64
73
  // A bounds, pivot or offset prop change re-clamps and pushes the pose
65
74
  // at once (set({}) applies the clamps and the lanes). Deferred: the
66
75
  // creation already clamped and pushed the initial pose. Untracked: the
@@ -72,6 +81,6 @@ export let Camera2d: VoidComponent<Camera2dProps> = props => {
72
81
  () => untrack(() => cam.set({})),
73
82
  { defer: true },
74
83
  )
75
- untrack(() => props.ref)?.(cam)
84
+ callRef(() => props.ref, cam)
76
85
  return null
77
86
  }
@@ -1,6 +1,6 @@
1
- import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
1
+ import { callRef, createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
2
  import type { ParentComponent } from "@solidrt/core"
3
- import { addGroup, destroyGroup, setGroup, setGroupTransition } from "../layer.ts"
3
+ import { addGroup, destroyGroup, setGroup, setGroupTimeScale, setGroupTransition } from "../layer.ts"
4
4
  import type { SpriteGroup, TransitionEndEvent } from "../layer.ts"
5
5
  import type { SpriteTransition } from "../layer.ts"
6
6
  import { GroupContext, LayerContext } from "./context.ts"
@@ -24,6 +24,10 @@ export type GroupProps = BubblingSpritePointerProps & {
24
24
  transition?: SpriteTransition | string | null
25
25
  /** A declared transition settled on one component. */
26
26
  onTransitionEnd?: (event: TransitionEndEvent) => void
27
+ /** The rate the native motion under this group runs at (see
28
+ * setGroupTimeScale): 0 freezes the subtree, 1 is app time; the nearest
29
+ * declaring node wins. Undefined or null inherits. */
30
+ timeScale?: number | null
27
31
  ref?: (group: SpriteGroup) => void
28
32
  }
29
33
 
@@ -47,6 +51,14 @@ export let Group: ParentComponent<GroupProps> = props => {
47
51
  () => props.transition,
48
52
  transition => setGroupTransition(group, transition ?? null),
49
53
  )
54
+ // Built only when the prop is present (the compiler emits the getter
55
+ // only then), so a plain group pays no effect for it.
56
+ if ("timeScale" in props) {
57
+ createEffect(
58
+ () => props.timeScale,
59
+ scale => setGroupTimeScale(group, scale ?? null),
60
+ )
61
+ }
50
62
  createEffect(
51
63
  () => [props.onPointerDown, props.onPointerMove, props.onPointerUp, props.onPointerCancel, props.onWheel, props.onTap, props.onTransitionEnd] as const,
52
64
  ([down, move, up, cancel, wheel, tap, end]) => {
@@ -59,7 +71,7 @@ export let Group: ParentComponent<GroupProps> = props => {
59
71
  group.onTransitionEnd = end
60
72
  },
61
73
  )
62
- untrack(() => props.ref)?.(group)
74
+ callRef(() => props.ref, group)
63
75
  onCleanup(() => destroyGroup(group))
64
76
  return <GroupContext value={group}>{props.children}</GroupContext>
65
77
  }
@@ -1,4 +1,4 @@
1
- import { createContext, createEffect, onCleanup, onFrame, untrack, useContext } from "@solidrt/core"
1
+ import { callRef, createContext, createEffect, onCleanup, onFrame, untrack, useContext } from "@solidrt/core"
2
2
  import type { Element, ParentComponent } from "@solidrt/core"
3
3
  import { ViewportContext, useSpriteLayer } from "./context.ts"
4
4
  import { createShots } from "../shots.ts"
@@ -59,7 +59,7 @@ export let Shots: ParentComponent<ShotsProps> = props => {
59
59
  })
60
60
  },
61
61
  )
62
- untrack(() => props.ref)?.(shots)
62
+ callRef(() => props.ref, shots)
63
63
  return <ShotsContext value={shots}>{props.children}</ShotsContext>
64
64
  }
65
65
 
@@ -1,7 +1,8 @@
1
- import { createEffect, displayScale, getBoundingBoxViewport, getLayoutBox, onCleanup, onLayout, untrack, warnOnce } from "@solidrt/core"
1
+ import { callRef, createEffect, displayScale, getBoundingBoxViewport, getLayoutBox, onCleanup, onLayout, untrack, warnOnce } from "@solidrt/core"
2
2
  import type { Element, ParentComponent, PointerFeed, TextureId } from "@solidrt/core"
3
- import type { BlendMode } from "@solidrt/core/gpu"
3
+ import type { ShaderParams } from "@solidrt/core/gpu"
4
4
  import type { Atlas } from "../atlas.ts"
5
+ import type { Material } from "../material.ts"
5
6
  import { createSpriteLayer } from "../layer.ts"
6
7
  import { feedPointer } from "../views.ts"
7
8
  import type { LayerPointerEvent, LayerTapEvent, LayerWheelEvent, SpriteLayer as LayerHandle } from "../layer.ts"
@@ -52,16 +53,22 @@ export type SpriteLayerProps = LayerPointerProps & {
52
53
  * still its own, `<Sprite>`, `<Group>` and `<Text2d>` children mount
53
54
  * into it - and leaves its lifetime to its maker, so the component
54
55
  * face sits over an imperative layer. The creation props (atlases,
55
- * capacity, blend, orderBy) describe a layer the component makes, so
56
- * with `layer` they have nothing to apply to and throw.
56
+ * capacity, material, orderBy) describe a layer the component makes,
57
+ * so with `layer` they have nothing to apply to and throw.
57
58
  */
58
59
  layer?: LayerHandle
59
60
  /** Initial record reservation (grows on demand); default 1024. */
60
61
  capacity?: number
61
- /** How the sprites blend into the layer's views (see
62
- * SpriteLayerOptions.blend); default "alpha", "add" for glows and
63
- * additive particles. Fixed at mount. */
64
- blend?: BlendMode
62
+ /** The layer's material (see SpriteLayerOptions.material): its blend,
63
+ * its look, its per-sprite style record; default `unlit()`, `unlit({
64
+ * blend: "add" })` for glows and additive particles. Fixed at mount. */
65
+ material?: Material
66
+ /** Uniform values written to every view of the layer (layer.setParams
67
+ * as a prop, over the material's own): the material's prelude
68
+ * uniforms. Live, with merge semantics - a key that disappears keeps
69
+ * its last value; for per-frame values prefer `ref` + setParams from
70
+ * onFrame. */
71
+ params?: ShaderParams
65
72
  /** The own view's clear color. */
66
73
  clearColor?: [number, number, number, number]
67
74
  /** Pan/zoom/rotate the own view over the world (in-shader); a
@@ -106,6 +113,13 @@ export type SpriteLayerProps = LayerPointerProps & {
106
113
  * under it). Live: a change applies to the next frame's cascade.
107
114
  */
108
115
  stagger?: number
116
+ /**
117
+ * The rate the layer's native motion runs at (layer.setTimeScale as a
118
+ * prop): 0 pauses every sprite and group transition while the app's UI
119
+ * keeps moving, 1 is app time; a `<Group timeScale={1}>` inside keeps
120
+ * running. Undefined or null is app time. Live.
121
+ */
122
+ timeScale?: number | null
109
123
  label?: string
110
124
  /** The layer: sprites, tint, pick, createView. */
111
125
  ref?: (layer: LayerHandle) => void
@@ -150,7 +164,7 @@ const FILL_INITIAL_SIZE = 1
150
164
 
151
165
  // The props that describe a layer the component creates - meaningless,
152
166
  // hence rejected, on an adopted one (`layer`), which was created already.
153
- const CREATION_PROPS = ["atlases", "capacity", "blend", "orderBy"] as const
167
+ const CREATION_PROPS = ["atlases", "capacity", "material", "orderBy"] as const
154
168
 
155
169
  // The props that configure the layer's own view - meaningless, hence
156
170
  // rejected, on a layer without one (output={false}).
@@ -195,10 +209,12 @@ export let SpriteLayer: ParentComponent<SpriteLayerProps> = props => {
195
209
  if (props.atlases === undefined) throw new Error("SpriteLayer: give atlases (a layer of its own) or layer (an existing one)")
196
210
  return createSpriteLayer(props.atlases, {
197
211
  capacity: props.capacity,
198
- blend: props.blend,
212
+ material: props.material,
213
+ params: props.params,
199
214
  tint: props.tint,
200
215
  orderBy: props.orderBy,
201
216
  stagger: props.stagger,
217
+ timeScale: props.timeScale ?? undefined,
202
218
  label: props.label,
203
219
  })
204
220
  })
@@ -208,12 +224,24 @@ export let SpriteLayer: ParentComponent<SpriteLayerProps> = props => {
208
224
  if (tint !== undefined) layer.setTint(tint)
209
225
  },
210
226
  )
227
+ createEffect(
228
+ () => props.params,
229
+ params => {
230
+ if (params !== undefined) layer.setParams(params)
231
+ },
232
+ { defer: true },
233
+ )
211
234
  createEffect(
212
235
  () => props.stagger,
213
236
  ms => layer.setStagger(ms ?? null),
214
237
  { defer: true },
215
238
  )
216
- untrack(() => props.ref)?.(layer)
239
+ createEffect(
240
+ () => props.timeScale,
241
+ scale => layer.setTimeScale(scale ?? null),
242
+ { defer: true },
243
+ )
244
+ callRef(() => props.ref, layer)
217
245
  let output = untrack(() => props.output)
218
246
  if (output === false) {
219
247
  untrack(() => {
@@ -274,7 +302,11 @@ export let SpriteLayer: ParentComponent<SpriteLayerProps> = props => {
274
302
  },
275
303
  { defer: true },
276
304
  )
277
- untrack(() => props.viewRef)?.(view)
305
+ callRef(() => props.viewRef, view)
306
+ // An adopted layer outlives the component, so its own view must not: a
307
+ // layer of the component's own takes the view with it (createSpriteLayer's
308
+ // owner-scoped dispose), an adopted one frees nothing of the component's.
309
+ if (untrack(() => props.layer)) onCleanup(() => view.dispose())
278
310
  let pointer = untrack(() => props.pointer) ?? null
279
311
  if (pointer) onCleanup(feedPointer(view, pointer))
280
312
  // The view's own handlers at the root of the walk; the props are read
@@ -1,6 +1,7 @@
1
- import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
1
+ import { callRef, createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
2
  import type { VoidComponent } from "@solidrt/core"
3
- import { addSprite, destroySprite, setSprite, setSpriteTransition } from "../layer.ts"
3
+ import { addSprite, destroySprite, setSprite, setSpriteTimeScale, setSpriteTransition } from "../layer.ts"
4
+ import { setInstanceStyle } from "../records.ts"
4
5
  import type { Sprite as SpriteHandle, SpriteOptions, SpritePointerEvent, SpriteTapEvent, SpriteWheelEvent, TransitionEndEvent } from "../layer.ts"
5
6
  import type { SpriteTransition } from "../layer.ts"
6
7
  import { GroupContext, LayerContext } from "./context.ts"
@@ -43,6 +44,16 @@ export type SpriteProps = SpriteOptions &
43
44
  transition?: SpriteTransition | string | null
44
45
  /** A declared transition settled on one component. */
45
46
  onTransitionEnd?: (event: TransitionEndEvent) => void
47
+ /** The rate this sprite's native motion runs at (see
48
+ * setSpriteTimeScale): 0 freezes it, 1 is app time, overriding the
49
+ * layer's and the enclosing groups'. Undefined or null inherits. Node
50
+ * layers only. */
51
+ timeScale?: number | null
52
+ /** The sprite's style record under the layer's material (see
53
+ * setInstanceStyle): one value per component of the material's first
54
+ * instance buffer. Absent, the sprite keeps the material's
55
+ * instanceStyle. */
56
+ style?: ArrayLike<number>
46
57
  ref?: (sprite: SpriteHandle) => void
47
58
  }
48
59
 
@@ -62,6 +73,23 @@ export let Sprite: VoidComponent<SpriteProps> = props => {
62
73
  () => props.transition,
63
74
  transition => setSpriteTransition(sprite, transition ?? null),
64
75
  )
76
+ // Built only when the prop is present (the compiler emits the getter
77
+ // only then): a plain sprite pays no effect, and a record layer's
78
+ // sprite never meets the throw.
79
+ if ("timeScale" in props) {
80
+ createEffect(
81
+ () => props.timeScale,
82
+ scale => setSpriteTimeScale(sprite, scale ?? null),
83
+ )
84
+ }
85
+ if ("style" in props) {
86
+ createEffect(
87
+ () => props.style,
88
+ style => {
89
+ if (style !== undefined) setInstanceStyle(layer, sprite, style)
90
+ },
91
+ )
92
+ }
65
93
  createEffect(
66
94
  () => [props.onPointerDown, props.onPointerMove, props.onPointerUp, props.onPointerCancel, props.onPointerEnter, props.onPointerLeave, props.onWheel, props.onTap, props.onTransitionEnd] as const,
67
95
  ([down, move, up, cancel, enter, leave, wheel, tap, end]) => {
@@ -76,7 +104,7 @@ export let Sprite: VoidComponent<SpriteProps> = props => {
76
104
  sprite.onTransitionEnd = end
77
105
  },
78
106
  )
79
- untrack(() => props.ref)?.(sprite)
107
+ callRef(() => props.ref, sprite)
80
108
  onCleanup(() => destroySprite(sprite))
81
109
  return null
82
110
  }
@@ -1,4 +1,4 @@
1
- import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
1
+ import { callRef, createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
2
  import type { VoidComponent } from "@solidrt/core"
3
3
  import { setGroupTransition } from "../layer.ts"
4
4
  import type { SpritePointerEvent, SpriteTapEvent, SpriteTransition, SpriteWheelEvent, TransitionEndEvent } from "../layer.ts"
@@ -71,7 +71,7 @@ export let Text2d: VoidComponent<Text2dProps> = props => {
71
71
  run.group.onTransitionEnd = end
72
72
  },
73
73
  )
74
- untrack(() => props.ref)?.(run)
74
+ callRef(() => props.ref, run)
75
75
  onCleanup(() => destroyText(run))
76
76
  return null
77
77
  }
@@ -1,7 +1,8 @@
1
- import { createEffect, createSignal, displayScale, For, getBoundingBoxViewport, onLayout, untrack } from "@solidrt/core"
1
+ import { callRef, createEffect, createSignal, displayScale, For, getBoundingBoxViewport, onLayout, untrack } from "@solidrt/core"
2
2
  import type { VoidComponent } from "@solidrt/core"
3
- import type { BlendMode, FilterMode } from "@solidrt/core/gpu"
3
+ import type { FilterMode, ShaderParams } from "@solidrt/core/gpu"
4
4
  import type { Atlas } from "../atlas.ts"
5
+ import type { Material } from "../material.ts"
5
6
  import type { CameraUpdate } from "../camera.ts"
6
7
  import type { Frame } from "../frames.ts"
7
8
  import { tileWorldScale } from "../oversample-math.ts"
@@ -35,9 +36,14 @@ export type TileLayerProps = {
35
36
  * have no chunk and render nothing, so a full-bleed ground color belongs
36
37
  * on the container behind the layer). */
37
38
  chunkClearColor?: [number, number, number, number]
38
- /** How the cells blend into their chunk bakes (see
39
- * TileLayerOptions.blend); default "alpha". Fixed at mount. */
40
- blend?: BlendMode
39
+ /** The material the cells bake with (see TileLayerOptions.material):
40
+ * its blend, its look, its per-cell style record; default `unlit()`.
41
+ * Fixed at mount. */
42
+ material?: Material
43
+ /** Uniform values written to every resident chunk (layer.setParams as
44
+ * a prop, over the material's own). Live, merge semantics; each change
45
+ * re-bakes every resident chunk - slow state, not per frame. */
46
+ params?: ShaderParams
41
47
  /** Sampler filter for the baked chunk textures at composite time; default
42
48
  * "linear" (hard pixels belong to the atlas sampler, see TileLayerOptions). */
43
49
  filter?: FilterMode
@@ -88,7 +94,8 @@ export let TileLayer: VoidComponent<TileLayerProps> = props => {
88
94
  let layer = untrack(() =>
89
95
  createTileLayer(props.cols, props.rows, props.tileW, props.tileH, props.atlases, {
90
96
  chunkClearColor: props.chunkClearColor,
91
- blend: props.blend,
97
+ material: props.material,
98
+ params: props.params,
92
99
  filter: props.filter,
93
100
  chunkTiles: props.chunkTiles,
94
101
  tint: props.tint,
@@ -96,7 +103,7 @@ export let TileLayer: VoidComponent<TileLayerProps> = props => {
96
103
  label: props.label,
97
104
  }),
98
105
  )
99
- untrack(() => props.ref)?.(layer)
106
+ callRef(() => props.ref, layer)
100
107
  createEffect(
101
108
  () => props.oversample,
102
109
  n => {
@@ -109,6 +116,13 @@ export let TileLayer: VoidComponent<TileLayerProps> = props => {
109
116
  if (tint !== undefined) layer.setTint(tint)
110
117
  },
111
118
  )
119
+ createEffect(
120
+ () => props.params,
121
+ params => {
122
+ if (params !== undefined) layer.setParams(params)
123
+ },
124
+ { defer: true },
125
+ )
112
126
  // Auto oversample: the world view's window box, in device pixels, per
113
127
  // world pixel. Rotation is not a resolution factor - texels per world
114
128
  // pixel do not change as the camera turns - but the measured box is the
@@ -1,4 +1,4 @@
1
- import { createEffect, displayScale, getBoundingBoxViewport, onCleanup, onLayout, untrack, useContext } from "@solidrt/core"
1
+ import { callRef, createEffect, displayScale, getBoundingBoxViewport, onCleanup, onLayout, untrack, useContext } from "@solidrt/core"
2
2
  import type { Element, ParentComponent, PointerFeed, TextureId } from "@solidrt/core"
3
3
  import type { CameraUpdate } from "../camera.ts"
4
4
  import { feedPointer } from "../views.ts"
@@ -96,7 +96,7 @@ export let View2d: ParentComponent<View2dProps> = props => {
96
96
  },
97
97
  { defer: true },
98
98
  )
99
- untrack(() => props.ref)?.(view)
99
+ callRef(() => props.ref, view)
100
100
  onCleanup(() => view.dispose())
101
101
  let pointer = untrack(() => props.pointer) ?? null
102
102
  if (pointer) onCleanup(feedPointer(view, pointer))
package/src/dispatch.ts CHANGED
@@ -12,9 +12,11 @@
12
12
  // drag from empty space keeps delivering to the layer as it crosses
13
13
  // sprites, exactly as a drag from a sprite keeps naming that sprite.
14
14
  // Enter/leave pair on the sprite alone. Taps are synthesized here (DOM's
15
- // click, Unity's IPointerClickHandler): a press that releases on the same
16
- // target within the slop, the only pointer down for its whole press,
17
- // counted up for repeats (DOM's detail, Unity's clickCount).
15
+ // click, which is primary-button only; Unity's IPointerClickHandler fires
16
+ // for every button and reports it, we do not): a primary-button press
17
+ // that releases on the same target within the slop, the only pointer
18
+ // down for its whole press, counted up for repeats (DOM's detail,
19
+ // Unity's clickCount).
18
20
  //
19
21
  // Pure BY DESIGN (types and camera.ts, nothing with GPU imports) so
20
22
  // tests/dispatch.test.ts drives it headless with a fake pick.
@@ -197,9 +199,11 @@ export function spriteDispatch(deps: DispatchDeps): (layout: (() => { width: num
197
199
  }
198
200
  presses.delete(e.pointerId)
199
201
  walk("onPointerUp", makeEvent(press.target, x, y, e), !press.claimed)
200
- // The tap rule: no travel past the slop, alone for the whole
201
- // press, released over the target it pressed (empty space for the
202
- // layer itself).
202
+ // The tap rule: the primary button (DOM click; core's press and
203
+ // long-press gate the same way), no travel past the slop, alone
204
+ // for the whole press, released over the target it pressed (empty
205
+ // space for the layer itself).
206
+ if (e.button != null && e.button !== 0) return
203
207
  if (press.moved || !press.alone || topmost(x, y) !== press.target) return
204
208
  let time = e.timeStamp
205
209
  let repeat =
package/src/frames.ts CHANGED
@@ -95,7 +95,7 @@ function frameOf(atlas: Atlas, x: number, y: number, w: number, h: number): Fram
95
95
  * a cell and an animation is a slice of consecutive indices.
96
96
  *
97
97
  * Cells that touch do not bleed at mip level 0: the layer shaders clamp
98
- * every sample into its frame (shaders.ts). A mip chain is different - its
98
+ * every sample into its frame (glsl.ts). A mip chain is different - its
99
99
  * texels straddle cell edges before sampling - so a sheet going through
100
100
  * `createAtlas` with `mipmap` is extruded first (`extrudeGrid`, whose
101
101
  * returned options slice the repacked sheet).