@solidrt/2d 0.0.56 → 0.0.58

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.
@@ -0,0 +1,27 @@
1
+ // The auto oversample apply shared by <SpriteLayer> and <TileLayer>: the
2
+ // measured inputs (window texel budget, device limits) around the pure
3
+ // decision in oversample-math.ts.
4
+ import { displayScale, windowSize } from "@solidrt/core"
5
+ import { limits } from "@solidrt/core/gpu"
6
+ import { pickOversample } from "../oversample-math.ts"
7
+
8
+ // The window's device pixel count: the texel budget an auto-picked
9
+ // oversample target stays within (see fitOversample).
10
+ function windowTexels(): number {
11
+ let win = windowSize()
12
+ let scale = displayScale()
13
+ return win.width * scale * (win.height * scale)
14
+ }
15
+
16
+ // The auto-pick apply: the decision itself (cap, shrink hysteresis,
17
+ // validation) is pure and lives in oversample-math.ts.
18
+ export function applyOversample(
19
+ layer: { readonly oversample: number; setOversample(n: number): void },
20
+ scale: number,
21
+ targetW: number,
22
+ targetH: number,
23
+ max: number | undefined,
24
+ ): void {
25
+ let n = pickOversample(layer.oversample, scale, targetW, targetH, windowTexels(), limits.maxTextureSize, max)
26
+ if (n !== null) layer.setOversample(n)
27
+ }
@@ -0,0 +1,82 @@
1
+ import { createEffect, merge, onFrame, untrack } from "@solidrt/core"
2
+ import type { InputMap, VoidComponent } from "@solidrt/core"
3
+ import { createCamera2d } from "../camera2d.ts"
4
+ import type { Camera2d as Camera2dHandle, Camera2dAxes, Camera2dOptions } from "../camera2d.ts"
5
+ import { useSpriteLayer } from "./context.ts"
6
+
7
+ export type Camera2dProps = Omit<Camera2dOptions, "viewport"> & {
8
+ /** Viewport in layer pixels; defaults to the driven view's own size
9
+ * (live: a fill layer's box, a setSize, a `<View2d>` resize). */
10
+ viewport?: () => { width: number; height: number }
11
+ /** The input map driving the control (InputMap.drive): its `pan`,
12
+ * `zoom` and `roll` actions, or the ones `actions` names. Live: a new
13
+ * map reconnects. Without one the control moves only through `ref`. */
14
+ input?: InputMap<any>
15
+ /** Action names per axis when the map's differ (null skips an axis). */
16
+ actions?: Partial<Record<keyof Camera2dAxes, string | null>>
17
+ ref?: (camera: Camera2dHandle) => void
18
+ }
19
+
20
+ // Cap on the camera loop's per-frame dt in seconds, so the first tick
21
+ // after a suspended stretch (a resumed app, a reload) cannot leap a glide.
22
+ const MAX_CAMERA_DT = 0.1
23
+
24
+ /**
25
+ * createCamera2d as a SpriteLayer child: drives the nearest view's camera
26
+ * - the `<SpriteLayer>`'s own, or inside a `<View2d>` that view
27
+ * (useSpriteLayer's `viewport`) - and takes its input from the map in
28
+ * `input`, nothing else (ARCHITECTURE.md: no device wiring in a
29
+ * component; the app binds the view's pointer feed, a pad or anything
30
+ * else to the map). Under a `<SpriteLayer output={false}>` there is no
31
+ * view to drive: put the `<Camera2d>` inside one of its `<View2d>`
32
+ * children. The pose props (x, y, zoom, rotation) are initial values:
33
+ * change the pose at runtime through `ref`'s set/glideTo/fit/follow and
34
+ * the verbs. Every other prop is live, forwarded to the control as a
35
+ * getter and read where it applies, so a world, a zoom range, a pivot,
36
+ * a dead zone or a rate follows its prop, and a bounds or pivot change
37
+ * re-clamps and pushes the pose at once - the `<OrbitCamera>` rule.
38
+ * `viewport` defaults to the view's own size. Frames run only while the
39
+ * camera moves (`active()`), so a resting camera leaves the app
40
+ * demand-driven idle.
41
+ */
42
+ export let Camera2d: VoidComponent<Camera2dProps> = props => {
43
+ let target = useSpriteLayer().viewport
44
+ // Through merge, not a spread: a props object hands out getters, and
45
+ // merge keeps them, so the control reads each option live where it
46
+ // applies.
47
+ let options: Camera2dOptions = merge(props, {
48
+ get viewport() {
49
+ return props.viewport ?? (() => ({ width: target.width, height: target.height }))
50
+ },
51
+ })
52
+ let cam = untrack(() => createCamera2d(target, options))
53
+ createEffect(
54
+ () => props.input,
55
+ input => (input ? input.drive(cam.axes, untrack(() => props.actions)) : undefined),
56
+ )
57
+ createEffect(
58
+ () => cam.active(),
59
+ on => {
60
+ if (!on) return
61
+ let last: number | null = null
62
+ return onFrame(tick => {
63
+ let now = tick / 1000
64
+ let dt = last === null ? 0 : Math.min(now - last, MAX_CAMERA_DT)
65
+ last = now
66
+ cam.update(dt)
67
+ })
68
+ },
69
+ )
70
+ // A bounds or pivot prop change re-clamps and pushes the pose at once
71
+ // (set({}) applies the clamps). Deferred: the creation already clamped
72
+ // and pushed the initial pose. Untracked: the control reads the options
73
+ // through the getters while it applies them, and the apply wants the
74
+ // values of that moment (the compute above is what tracks them).
75
+ createEffect(
76
+ () => [options.world, options.minZoom, options.maxZoom, options.pivot],
77
+ () => untrack(() => cam.set({})),
78
+ { defer: true },
79
+ )
80
+ untrack(() => props.ref)?.(cam)
81
+ return null
82
+ }
@@ -0,0 +1,48 @@
1
+ import { createContext, useContext } from "@solidrt/core"
2
+ import type { PointerFeed } from "@solidrt/core"
3
+ import type { SpriteGroup, SpriteLayer as LayerHandle } from "../layer.ts"
4
+ import type { ViewHandle } from "../views.ts"
5
+
6
+ export let LayerContext = createContext<LayerHandle>()
7
+ export let GroupContext = createContext<SpriteGroup | null>(null)
8
+
9
+ // The nearest view: the <SpriteLayer>'s own, or the enclosing <View2d>'s;
10
+ // null under a leafless layer (output={false}) until a <View2d>
11
+ // provides one. Null-default on purpose, like GroupContext.
12
+ export let ViewportContext = createContext<ViewHandle | null>(null)
13
+ // The nearest view's pointer feed (its owner's `pointer` prop), null when
14
+ // none was handed in.
15
+ export let PointerContext = createContext<PointerFeed | null>(null)
16
+
17
+ type LayerCtx = { layer: LayerHandle; parent: SpriteGroup | null; readonly viewport: ViewHandle; pointer: PointerFeed | null }
18
+
19
+ /**
20
+ * The enclosing layer, parent group, view and pointer feed - the
21
+ * imperative escape hatch inside a component subtree (throws outside a
22
+ * `<SpriteLayer>`), the same shape as @solidrt/3d's useScene. `parent` is
23
+ * the enclosing `<Group>`'s handle (null at the layer root); pass it on
24
+ * to addSprite/addGroup so imperative sprites mount where the JSX sits.
25
+ * `viewport` is the nearest view: the `<SpriteLayer>`'s own, or inside a
26
+ * `<View2d>` that view - what a `<Camera2d>` drives, and where a custom
27
+ * `output` leaf takes its `handlers`. Reading it under a
28
+ * `<SpriteLayer output={false}>` throws: such a layer shows only through
29
+ * its `<View2d>` children, so controls and leaves go inside one.
30
+ * `pointer` is that view's feed (the owner's `pointer` prop), or null.
31
+ */
32
+ export function useSpriteLayer(): LayerCtx {
33
+ let layer = useContext(LayerContext)
34
+ let parent = useContext(GroupContext)
35
+ let viewport = useContext(ViewportContext)
36
+ let pointer = useContext(PointerContext)
37
+ return {
38
+ layer,
39
+ parent,
40
+ pointer,
41
+ get viewport() {
42
+ if (viewport === null) {
43
+ throw new Error("useSpriteLayer: no view here - a <SpriteLayer output={false}> shows only through its <View2d> children; put camera controls and leaves inside one")
44
+ }
45
+ return viewport
46
+ },
47
+ }
48
+ }
@@ -0,0 +1,68 @@
1
+ import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
+ import type { ParentComponent } from "@solidrt/core"
3
+ import { addGroup, removeGroup, setGroup, setGroupTransition } from "../layer.ts"
4
+ import type { SpriteGroup, SpritePointerEvent, SpriteTapEvent, SpriteWheelEvent, TransitionEndEvent } from "../layer.ts"
5
+ import type { NodeTransition } from "flux:spatial"
6
+ import { GroupContext, LayerContext } from "./context.ts"
7
+
8
+ export type GroupProps = {
9
+ /** Position in the parent frame (layer pixels at the root). */
10
+ x?: number
11
+ y?: number
12
+ /** Rotation, radians, clockwise. */
13
+ rotation?: number
14
+ /** Uniform scale on the whole subtree (child sprites scale with it). */
15
+ scale?: number
16
+ /** Show or hide the whole subtree (default true); see
17
+ * GroupOptions.visible. */
18
+ visible?: boolean
19
+ /** Bubbled from a hit child sprite (see SpritePointerProps); a group
20
+ * never receives enter/leave. */
21
+ onPointerDown?: (event: SpritePointerEvent) => void
22
+ onPointerMove?: (event: SpritePointerEvent) => void
23
+ onPointerUp?: (event: SpritePointerEvent) => void
24
+ onWheel?: (event: SpriteWheelEvent) => void
25
+ onTap?: (event: SpriteTapEvent) => void
26
+ /** How pose-prop changes animate (see setGroupTransition); the mount
27
+ * pose always snaps. */
28
+ transition?: NodeTransition | string | null
29
+ /** A declared transition settled on one component. */
30
+ onTransitionEnd?: (event: TransitionEndEvent) => void
31
+ ref?: (group: SpriteGroup) => void
32
+ }
33
+
34
+ /**
35
+ * A transform group: `<Sprite>` (and nested `<Group>`) children mount under
36
+ * its spatial arena node, so their pose props read in the group's frame and
37
+ * moving the group moves the subtree in one native recompute - a ship with
38
+ * turrets is one `<Group>` with the hull and turret sprites inside. Renders
39
+ * nothing itself.
40
+ */
41
+ export let Group: ParentComponent<GroupProps> = props => {
42
+ let layer = useContext(LayerContext)
43
+ let parent = useContext(GroupContext)
44
+ let group = untrack(() => addGroup(layer, { parent }))
45
+ createEffect(
46
+ () => [props.x, props.y, props.rotation, props.scale, props.visible] as const,
47
+ ([x, y, rotation, scale, visible]) => setGroup(group, { x, y, rotation, scale, visible: visible !== false }),
48
+ )
49
+ // After the pose effect, so the mount pose snaps before writes animate.
50
+ createEffect(
51
+ () => props.transition,
52
+ transition => setGroupTransition(group, transition ?? null),
53
+ )
54
+ createEffect(
55
+ () => [props.onPointerDown, props.onPointerMove, props.onPointerUp, props.onWheel, props.onTap, props.onTransitionEnd] as const,
56
+ ([down, move, up, wheel, tap, end]) => {
57
+ group.onPointerDown = down
58
+ group.onPointerMove = move
59
+ group.onPointerUp = up
60
+ group.onWheel = wheel
61
+ group.onTap = tap
62
+ group.onTransitionEnd = end
63
+ },
64
+ )
65
+ untrack(() => props.ref)?.(group)
66
+ onCleanup(() => removeGroup(group))
67
+ return <GroupContext value={group}>{props.children}</GroupContext>
68
+ }
@@ -0,0 +1,28 @@
1
+ // The Solid face: PascalCase components over context, syncing the retained
2
+ // layer (layer.ts) - no new intrinsic elements, no renderer changes. Props
3
+ // follow the Solid 2.0 model (reactive values, no destructuring); effects
4
+ // write into the retained records and the runtime's dirty flush renders.
5
+ // Anything moving at frame rate can bypass the declarative layer: grab the
6
+ // sprite with `ref` and call setSprite from onFrame - signals carry
7
+ // structure and slow state, per-frame motion goes straight to the layer.
8
+ // The same split, with the same reasoning, as @solidrt/3d's components.
9
+ //
10
+ // One file per component, with the shared pieces beside them: context.ts
11
+ // (the layer, group and viewport contexts and useSpriteLayer) and
12
+ // auto-oversample.ts (the measured auto-pick the leaves apply). This index is the package's
13
+ // face: index.ts at the root re-exports it, nothing outside imports the
14
+ // files directly.
15
+
16
+ export { useSpriteLayer } from "./context.ts"
17
+ export { SpriteLayer } from "./sprite-layer.tsx"
18
+ export type { LayerPointerProps, SpriteLayerProps } from "./sprite-layer.tsx"
19
+ export { Group } from "./group.tsx"
20
+ export type { GroupProps } from "./group.tsx"
21
+ export { Sprite } from "./sprite.tsx"
22
+ export type { SpritePointerProps, SpriteProps } from "./sprite.tsx"
23
+ export { Camera2d } from "./camera2d.tsx"
24
+ export type { Camera2dProps } from "./camera2d.tsx"
25
+ export { View2d } from "./view2d.tsx"
26
+ export type { View2dProps } from "./view2d.tsx"
27
+ export { TileLayer } from "./tile-layer.tsx"
28
+ export type { TileCamera, TileLayerProps } from "./tile-layer.tsx"
@@ -0,0 +1,312 @@
1
+ import { createEffect, displayScale, getBoundingBoxViewport, getLayoutBox, onCleanup, onLayout, untrack } from "@solidrt/core"
2
+ import type { Element, ParentComponent, PointerFeed, TextureId } from "@solidrt/core"
3
+ import { createSpriteLayer } from "../layer.ts"
4
+ import { feedPointer } from "../views.ts"
5
+ import type { LayerPointerEvent, LayerTapEvent, LayerWheelEvent, SpriteLayer as LayerHandle } from "../layer.ts"
6
+ import type { CameraUpdate } from "../camera.ts"
7
+ import type { ViewHandle } from "../views.ts"
8
+ import { LayerContext, PointerContext, ViewportContext } from "./context.ts"
9
+ import { applyOversample } from "./auto-oversample.ts"
10
+
11
+ /**
12
+ * A view's own pointer events, the root of the walk: every event arrives
13
+ * after the hit sprite and its Groups (`event.sprite` set) or as the only
14
+ * stop over empty space (`event.sprite` null), unless a handler stopped
15
+ * it. Deselect on a miss, start a marquee, drive a camera - the
16
+ * `<Camera2d>` child listens at the same root.
17
+ */
18
+ export type LayerPointerProps = {
19
+ onPointerDown?: (event: LayerPointerEvent) => void
20
+ onPointerMove?: (event: LayerPointerEvent) => void
21
+ onPointerUp?: (event: LayerPointerEvent) => void
22
+ onWheel?: (event: LayerWheelEvent) => void
23
+ onTap?: (event: LayerTapEvent) => void
24
+ }
25
+
26
+ export type SpriteLayerProps = LayerPointerProps & {
27
+ /**
28
+ * View pixels of the layer's own view - give both, or neither. Omitted,
29
+ * the view FILLS: the built-in leaf is laid out at 100% of its parent's
30
+ * box (give it a sized parent, as on the web) and the view sizes to that
31
+ * box, so view pixels are the leaf's own coordinates and sprites place
32
+ * in element units - the auto oversample still supplies display density
33
+ * on top. Fill or fixed is decided at mount, and matches @solidrt/3d's
34
+ * `<Scene>` one dimension down. A function `output` needs explicit
35
+ * sizes (the view cannot follow a leaf it does not own); the leaf's own
36
+ * width/height are then layout, so render size and display size
37
+ * separate.
38
+ */
39
+ width?: number
40
+ height?: number
41
+ /** The atlas texture every sprite samples (create with createAtlas). */
42
+ atlas: TextureId
43
+ /** Initial record reservation (grows on demand); default 1024. */
44
+ capacity?: number
45
+ /** The own view's clear color. */
46
+ clearColor?: [number, number, number, number]
47
+ /** Pan/zoom/rotate the own view over the world (in-shader); a
48
+ * shared-params write, never per-sprite. A `<Camera2d>` child writes
49
+ * the same state - use one form, not both. */
50
+ camera?: CameraUpdate
51
+ /**
52
+ * Layer tint, [r, g, b, a] in 0..1, multiplied over every sprite's own
53
+ * tint in every view (day/night, a dimmed parallax plane, a fade-in).
54
+ * One shared uniform write per view, cheap to animate - unlike
55
+ * TileLayer's, which re-bakes.
56
+ */
57
+ tint?: [number, number, number, number]
58
+ /**
59
+ * Target texels per view pixel. Absent, the component picks it every
60
+ * layout from the built-in leaf's on-screen size (display scale, any
61
+ * designSize fit, layout scaling), so the view resamples properly at any
62
+ * scale; with a function `output` there is no built-in leaf, so set it
63
+ * yourself.
64
+ */
65
+ oversample?: number
66
+ /**
67
+ * Cap on the auto-picked oversample (integer >= 1): the view stays
68
+ * adaptive up to it, never above - the lever that bounds target memory on
69
+ * high-scale displays without giving up adaptivity. Ignored when
70
+ * `oversample` is set (explicit already opts out of the pick).
71
+ */
72
+ maxOversample?: number
73
+ /**
74
+ * Draw sprites in key order instead of slot order, produced by core at
75
+ * every publish: `"y"` orders by world y (back-to-front for a
76
+ * perspective scene, zero JS per frame even under native transitions),
77
+ * `"renderOrder"` by the per-sprite `renderOrder` field (explicit layering:
78
+ * raise-on-drag, click-to-front). Creation-fixed (see
79
+ * SpriteLayerOptions.orderBy).
80
+ */
81
+ orderBy?: "y" | "renderOrder"
82
+ label?: string
83
+ /** The layer: sprites, tint, pick, createView. */
84
+ ref?: (layer: LayerHandle) => void
85
+ /** The layer's own view (texture, camera, handlers) for imperative use
86
+ * outside the tree; inside, `useSpriteLayer().viewport` is the same.
87
+ * Not called with `output={false}` - there is no own view. */
88
+ viewRef?: (view: ViewHandle) => void
89
+ /**
90
+ * The own view's leaf. Absent: a built-in `<texture>` leaf carrying the
91
+ * view's handlers. A function: compose the leaf yourself - called once
92
+ * (untracked) with the view's texture id, its return renders in place
93
+ * of the built-in leaf; sprite pointer events then need the view's
94
+ * handlers on your leaf: `<texture src={texture}
95
+ * {...useSpriteLayer().viewport.handlers} />`. `false`: NO view of its
96
+ * own - the layer shows only through its `<View2d>` children, each a
97
+ * full view with its own camera (split-screen: two side by side, a
98
+ * game with its minimap and nothing else). The view props (width,
99
+ * height, camera, clearColor, oversample, maxOversample, events,
100
+ * viewRef, the pointer props) then have nothing to apply to and throw.
101
+ */
102
+ output?: ((texture: TextureId) => Element) | false
103
+ /**
104
+ * Pointer events (default on): the built-in leaf carries the view's
105
+ * handlers, so sprites, groups, the view's own handlers and a
106
+ * `<Camera2d>` child receive input. `false` detaches them - the leaf
107
+ * then costs no pointer routing at all.
108
+ */
109
+ events?: boolean
110
+ /**
111
+ * The pointer feed of the layer's own view (createPointerFeed), fed
112
+ * from the view's root - the events the sprites let through: a sprite
113
+ * that claims its press keeps a camera bound to the feed out of that
114
+ * drag - so an input map over it drives the `<Camera2d>` child; inside,
115
+ * `useSpriteLayer().pointer` is this feed. Fixed at creation.
116
+ */
117
+ pointer?: PointerFeed
118
+ }
119
+
120
+ // Creation size of a fill-mode view: the first onLayout replaces it
121
+ // before the first paint, so it only has to be a valid target size.
122
+ const FILL_INITIAL_SIZE = 1
123
+
124
+ // The props that configure the layer's own view - meaningless, hence
125
+ // rejected, on a layer without one (output={false}).
126
+ const VIEW_PROPS = [
127
+ "width",
128
+ "height",
129
+ "camera",
130
+ "clearColor",
131
+ "oversample",
132
+ "maxOversample",
133
+ "events",
134
+ "pointer",
135
+ "viewRef",
136
+ "onPointerDown",
137
+ "onPointerMove",
138
+ "onPointerUp",
139
+ "onWheel",
140
+ "onTap",
141
+ ] as const
142
+
143
+ /**
144
+ * Owns a sprite layer and, unless `output={false}`, its own view,
145
+ * composited as an ordinary `<texture>` leaf so the output takes layout,
146
+ * transforms, blendMode, and pointer events like any element - or hand
147
+ * `output` the texture id and compose it yourself. A layout component:
148
+ * it cannot sit inside a d-* subtree; `output` with a `<d-texture>` is
149
+ * the detached form. Children (`<Sprite>`) render nothing themselves -
150
+ * they populate the retained layer through context; `<View2d>` children
151
+ * are further views of the same sprites.
152
+ */
153
+ export let SpriteLayer: ParentComponent<SpriteLayerProps> = props => {
154
+ let layer = untrack(() =>
155
+ createSpriteLayer(props.atlas, {
156
+ capacity: props.capacity,
157
+ tint: props.tint,
158
+ orderBy: props.orderBy,
159
+ label: props.label,
160
+ }),
161
+ )
162
+ createEffect(
163
+ () => props.tint,
164
+ tint => {
165
+ if (tint !== undefined) layer.setTint(tint)
166
+ },
167
+ )
168
+ untrack(() => props.ref)?.(layer)
169
+ let output = untrack(() => props.output)
170
+ if (output === false) {
171
+ untrack(() => {
172
+ for (let name of VIEW_PROPS) {
173
+ if (props[name] !== undefined) throw new Error(`SpriteLayer: output={false} has no view of its own - ${name} belongs on a <View2d> child`)
174
+ }
175
+ })
176
+ return (
177
+ <LayerContext value={layer}>
178
+ <ViewportContext value={null}>{props.children}</ViewportContext>
179
+ </LayerContext>
180
+ )
181
+ }
182
+ // Fill vs fixed view is decided at mount, like `output`: both width
183
+ // and height (fixed view pixels) or neither (fill).
184
+ let fill = untrack(() => {
185
+ if ((props.width === undefined) !== (props.height === undefined)) {
186
+ throw new Error("SpriteLayer: width and height come together - give both (fixed view) or neither (fill)")
187
+ }
188
+ if (props.width === undefined && output) {
189
+ throw new Error("SpriteLayer: fill needs the built-in leaf - with output, give width and height")
190
+ }
191
+ return props.width === undefined
192
+ })
193
+ // The layer's own view: the initial props seed it, the effects follow
194
+ // changes only (`defer`).
195
+ let view = untrack(() =>
196
+ layer.createView({
197
+ width: props.width ?? FILL_INITIAL_SIZE,
198
+ height: props.height ?? FILL_INITIAL_SIZE,
199
+ camera: props.camera,
200
+ oversample: props.oversample,
201
+ clearColor: props.clearColor,
202
+ label: props.label,
203
+ }),
204
+ )
205
+ createEffect(
206
+ () => [props.width, props.height] as const,
207
+ ([w, h]) => {
208
+ if ((w === undefined) !== (h === undefined) || (w === undefined) !== fill) {
209
+ throw new Error("SpriteLayer: fill/fixed is mount-fixed - width/height cannot appear or disappear")
210
+ }
211
+ if (!fill) view.setSize(w!, h!)
212
+ },
213
+ { defer: true },
214
+ )
215
+ createEffect(
216
+ () => props.camera,
217
+ camera => {
218
+ if (camera) view.setCamera(camera)
219
+ },
220
+ { defer: true },
221
+ )
222
+ createEffect(
223
+ () => props.oversample,
224
+ n => {
225
+ if (n !== undefined) view.setOversample(n)
226
+ },
227
+ { defer: true },
228
+ )
229
+ untrack(() => props.viewRef)?.(view)
230
+ let pointer = untrack(() => props.pointer) ?? null
231
+ if (pointer) onCleanup(feedPointer(view, pointer))
232
+ // The view's own handlers at the root of the walk; the props are read
233
+ // per event, so a handler prop may change without re-registering.
234
+ onCleanup(
235
+ view.listen({
236
+ onPointerDown: e => props.onPointerDown?.(e),
237
+ onPointerMove: e => props.onPointerMove?.(e),
238
+ onPointerUp: e => props.onPointerUp?.(e),
239
+ onWheel: e => props.onWheel?.(e),
240
+ onTap: e => props.onTap?.(e),
241
+ }),
242
+ )
243
+ let events = untrack(() => props.events) !== false
244
+ let leaf: { id: number } | undefined
245
+ // The view's pixel size: the props in fixed mode, the built-in leaf's
246
+ // laid-out box in fill mode (getLayoutBox, the untransformed read, so a
247
+ // designSize fit or ancestor transform never enters the world space -
248
+ // display density rides on the oversample pick below). Null before the
249
+ // first layout.
250
+ let viewSize = (): { width: number; height: number } | null => {
251
+ if (!fill) return { width: props.width!, height: props.height! }
252
+ let box = leaf && getLayoutBox(leaf)
253
+ if (!box || box.width <= 0 || box.height <= 0) return null
254
+ return { width: Math.max(1, Math.round(box.width)), height: Math.max(1, Math.round(box.height)) }
255
+ }
256
+ // Fill: the view follows the leaf's box. Registered before the pick, so
257
+ // one onLayout applies size then oversample in order; setSize no-ops
258
+ // when nothing changed.
259
+ if (fill) {
260
+ onLayout(() => {
261
+ let size = viewSize()
262
+ if (size) view.setSize(size.width, size.height)
263
+ })
264
+ }
265
+ // Auto oversample: the built-in leaf's window box, in device pixels, per
266
+ // view pixel. Picked after every layout, and again when the display scale
267
+ // changes: the first layout runs before the resize event that reports the
268
+ // scale, and a scale change alone lays nothing out. pick runs as an
269
+ // onLayout handler and as an effect apply, both untracked scopes, so its
270
+ // prop reads are wrapped in an explicit untrack.
271
+ let pick = () =>
272
+ untrack(() => {
273
+ if (!leaf || props.oversample !== undefined) return
274
+ let box = getBoundingBoxViewport(leaf)
275
+ let size = viewSize()
276
+ if (!box || !size) return
277
+ let scale = displayScale() * Math.max(box.width / size.width, box.height / size.height)
278
+ applyOversample(view, scale, size.width, size.height, props.maxOversample)
279
+ })
280
+ onLayout(pick)
281
+ createEffect(() => [displayScale(), props.maxOversample], pick)
282
+ // Sprite events on the built-in leaf: at view size the plain handlers,
283
+ // in fill mode scaled from the laid-out box (the box can be fractional,
284
+ // the view size never is).
285
+ let viewHandlers = fill
286
+ ? view.handlersFor(() => (leaf && getLayoutBox(leaf)) || { width: 0, height: 0 })
287
+ : view.handlers
288
+ return (
289
+ <LayerContext value={layer}>
290
+ <ViewportContext value={view}>
291
+ <PointerContext value={pointer}>
292
+ {output ? (
293
+ untrack(() => output(view.texture))
294
+ ) : (
295
+ <texture
296
+ ref={(n: { id: number }) => (leaf = n)}
297
+ src={view.texture}
298
+ width={fill ? "100%" : props.width}
299
+ height={fill ? "100%" : props.height}
300
+ onPointerDown={events ? viewHandlers.onPointerDown : undefined}
301
+ onPointerMove={events ? viewHandlers.onPointerMove : undefined}
302
+ onPointerUp={events ? viewHandlers.onPointerUp : undefined}
303
+ onPointerLeave={events ? viewHandlers.onPointerLeave : undefined}
304
+ onWheel={events ? viewHandlers.onWheel : undefined}
305
+ />
306
+ )}
307
+ {props.children}
308
+ </PointerContext>
309
+ </ViewportContext>
310
+ </LayerContext>
311
+ )
312
+ }
@@ -0,0 +1,72 @@
1
+ import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
+ import type { VoidComponent } from "@solidrt/core"
3
+ import { addSprite, removeSprite, setSprite, setSpriteTransition } from "../layer.ts"
4
+ import type { Sprite as SpriteHandle, SpriteOptions, SpritePointerEvent, SpriteTapEvent, SpriteWheelEvent, TransitionEndEvent } from "../layer.ts"
5
+ import type { NodeTransition } from "flux:spatial"
6
+ import { GroupContext, LayerContext } from "./context.ts"
7
+
8
+ /**
9
+ * Sprite pointer events, the element vocabulary one tree deeper: the
10
+ * topmost hit sprite receives the event, down/move/up/wheel/tap bubble to
11
+ * enclosing Groups and end at the `<SpriteLayer>` (stopPropagation stops
12
+ * the walk; stopping a down claims the whole press), enter/leave pair on
13
+ * the sprite alone. Events flow while the element showing the layer
14
+ * carries layer.handlers - the built-in `<SpriteLayer>` leaf does (opt
15
+ * out with events={false}); an `output` leaf spreads them itself.
16
+ */
17
+ export type SpritePointerProps = {
18
+ onPointerDown?: (event: SpritePointerEvent) => void
19
+ onPointerMove?: (event: SpritePointerEvent) => void
20
+ onPointerUp?: (event: SpritePointerEvent) => void
21
+ /** Sprites only: a Group never receives enter/leave. */
22
+ onPointerEnter?: (event: SpritePointerEvent) => void
23
+ onPointerLeave?: (event: SpritePointerEvent) => void
24
+ onWheel?: (event: SpriteWheelEvent) => void
25
+ /** A press released on the sprite without dragging; `tapCount` counts
26
+ * repeats (2 = double tap). */
27
+ onTap?: (event: SpriteTapEvent) => void
28
+ }
29
+
30
+ export type SpriteProps = SpriteOptions &
31
+ SpritePointerProps & {
32
+ /** How pose-prop changes animate (see setSpriteTransition); the mount
33
+ * pose always snaps. */
34
+ transition?: NodeTransition | string | null
35
+ /** A declared transition settled on one component. */
36
+ onTransitionEnd?: (event: TransitionEndEvent) => void
37
+ ref?: (sprite: SpriteHandle) => void
38
+ }
39
+
40
+ /** One sprite: a frame drawn at a position (in the enclosing `<Group>`'s
41
+ * frame when there is one). */
42
+ export let Sprite: VoidComponent<SpriteProps> = props => {
43
+ let layer = useContext(LayerContext)
44
+ let parent = useContext(GroupContext)
45
+ let sprite = untrack(() => addSprite(layer, { parent }))
46
+ createEffect(
47
+ () => [props.x, props.y, props.w, props.h, props.frame, props.flipX, props.flipY, props.rotation, props.tint, props.renderOrder, props.visible] as const,
48
+ ([x, y, w, h, frame, flipX, flipY, rotation, tint, renderOrder, visible]) =>
49
+ setSprite(sprite, { x, y, w, h, frame, flipX, flipY, rotation, tint, renderOrder, visible: visible !== false }),
50
+ )
51
+ // After the pose effect, so the mount pose snaps before writes animate.
52
+ createEffect(
53
+ () => props.transition,
54
+ transition => setSpriteTransition(sprite, transition ?? null),
55
+ )
56
+ createEffect(
57
+ () => [props.onPointerDown, props.onPointerMove, props.onPointerUp, props.onPointerEnter, props.onPointerLeave, props.onWheel, props.onTap, props.onTransitionEnd] as const,
58
+ ([down, move, up, enter, leave, wheel, tap, end]) => {
59
+ sprite.onPointerDown = down
60
+ sprite.onPointerMove = move
61
+ sprite.onPointerUp = up
62
+ sprite.onPointerEnter = enter
63
+ sprite.onPointerLeave = leave
64
+ sprite.onWheel = wheel
65
+ sprite.onTap = tap
66
+ sprite.onTransitionEnd = end
67
+ },
68
+ )
69
+ untrack(() => props.ref)?.(sprite)
70
+ onCleanup(() => removeSprite(sprite))
71
+ return null
72
+ }