@solidrt/2d 0.0.55 → 0.0.57

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.
@@ -12,10 +12,52 @@ copy one and adapt it.
12
12
  one texture may be) baked into lazily-allocated chunks, flown over by a
13
13
  ship-style camera (fixed screen pivot, the world panning and ROTATING
14
14
  under it via the `<TileLayer>` camera prop - transform writes, never a
15
- re-bake), and a timer editing tiles to show that a `setTile` batch
16
- re-bakes only the chunks it touches.
17
- - `pick.tsx` - sprite pointer events through the component face: exact
18
- rotated-rect hit testing topmost-first, pointer capture (drag a sprite and
19
- the events keep naming it), click-vs-drag slop, and removal through a
20
- signal so `<For>` unmounts the `<Sprite>` and the layer compacts its draw
21
- order.
15
+ re-bake), seeded by ONE `setTiles` rect write over a Uint16Array of
16
+ frame indices (the layer's `frames` table; -1 where the world is
17
+ empty, and those chunks never allocate), and a timer editing tiles to
18
+ show that a `setTile` batch re-bakes only the chunks it touches.
19
+ - `anim.tsx` - frame animation with `createAnimation`: three sprites share
20
+ one looping clip (one clock, stepped by wall time independent of display
21
+ rate) and a one-shot clip holds its last frame and fires `onEnd`.
22
+ Self-asserting - watch the logs for ANIM-OK.
23
+ - `camera.tsx` - the 2d camera (`createCamera2d`) over a world larger than
24
+ the window, driving the layer's one view from an input map the app
25
+ binds (`camera2dBindings` over the view's pointer feed, bridged from
26
+ the view's root with `feedPointer`, and any pad): drag empty space to
27
+ pan with inertia, wheel/pinch to zoom about the pointer, tap empty
28
+ space to glide there (the view's `onTap` with `e.sprite` null), tap a
29
+ sprite to select it, drag a sprite to move it (the sprite stops its
30
+ down, so the camera never pans under it), F to follow a roaming sprite
31
+ through a dead zone, R to spin the view, Space to fit the world. The
32
+ VIEW's `handlers` spread onto its leaf; `update(dt)` from `onFrame` is
33
+ the only per-frame call. Debug commands `camera`, `mode`, `selected`
34
+ and `first` drive it headless.
35
+ - `pick.tsx` - the event model through the component face: exact
36
+ rotated-rect hit testing topmost-first, `onTap` (the dispatch's own
37
+ click, no slop bookkeeping), a claimed press dragging a sprite under a
38
+ `<Camera2d>` that pans empty space and zooms on wheel, the layer's own
39
+ `onTap` seeing misses with `e.sprite` null (and `tapCount` for double
40
+ taps), and removal through a signal so `<For>` unmounts the `<Sprite>`
41
+ and the layer recycles the slot. A corner `<View2d>` inset shows the
42
+ same sprites again under a `<Camera2d>` of its own (drag or wheel on
43
+ the inset moves only the inset's camera; a sprite tapped or dragged
44
+ there responds as in the main view). The `state` debug command reads
45
+ both cameras and the sprite positions back.
46
+ - `views.tsx` - layer views (`layer.createView`, the only way a layer
47
+ shows): the world rendered twice from ONE set of sprites - the
48
+ window-filling main view under a `createCamera2d` and a corner minimap
49
+ showing the whole world at the fit zoom, a stroked rect outlining the
50
+ main view rect through `map.project`. A tap on the minimap glides the main camera there (the
51
+ view's root listener, `e.x`/`e.y` in world pixels), and a tap on a
52
+ sprite in the minimap selects it as in the main view (the same sprite
53
+ handlers, the walk ending at the view). Debug commands `state`,
54
+ `camera`, `selected` and `first` drive it headless.
55
+ - `split-screen.tsx` - one `<SpriteLayer output={false}>` (sprites, no
56
+ leaf of its own) shown through two `<View2d>` panes filling the window
57
+ side by side, each with a `<Camera2d>` of its own following one of two
58
+ roaming sprites through a dead zone, so the two poses differ every
59
+ frame; wheel zooms a pane alone while its follow keeps tracking, a tap
60
+ on a sprite in either pane tints it in both (one sprite, two views).
61
+ Each pane's map binds `gamepad.next()`: the first pad to press any
62
+ button drives pane 0, the next pane 1. The `cameras` debug command
63
+ reads both poses back.
@@ -0,0 +1,84 @@
1
+ // Frame animation via createAnimation: a row of sprites sharing one looping
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
4
+ // that holds its last frame and fires onFinish. Self-asserting: samples the
5
+ // sprites' frames against the wall clock and logs ANIM-OK / ANIM-FAIL.
6
+ //
7
+ // The atlas is the core logo sliced 2x2 by grid(): a 4-frame "clip". Real
8
+ // art would slice a sheet the same way and pass a consecutive slice of the
9
+ // frames array per animation.
10
+ import { decodeImage, render } from "@solidrt/core"
11
+ import { addSprite, createAnimation, createAtlas, createSpriteLayer, getSprite, grid } from "@solidrt/2d"
12
+ import type { Frame, SpriteHandle } from "@solidrt/2d"
13
+ import logoBytes from "./logo.png" with { type: "binary" }
14
+
15
+ const W = 520
16
+ const H = 200
17
+ const SPRITE = 96
18
+ // Slow enough that timer jitter cannot blur a sampled frame boundary: the
19
+ // checks sample mid-frame, half a period from either edge.
20
+ const LOOP_FPS = 2
21
+ const SHOT_FPS = 5
22
+
23
+ function App() {
24
+ let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
25
+ let frames = grid(atlas, 2, 2)
26
+ let layer = createSpriteLayer(atlas.texture, { label: "anim" })
27
+ let view = layer.createView({ width: W, height: H, clearColor: [0.05, 0.05, 0.09, 1] })
28
+
29
+ // Three sprites share the looping clip - one clock steps all of them.
30
+ let looping = createAnimation(frames, LOOP_FPS)
31
+ let looped: SpriteHandle[] = []
32
+ for (let i = 0; i < 3; i++) {
33
+ let sprite = addSprite(layer, { x: 80 + i * 120, y: H / 2, w: SPRITE, h: SPRITE })
34
+ looping.add(sprite)
35
+ looped.push(sprite)
36
+ }
37
+
38
+ // The one-shot: plays through once, holds the last frame, fires onFinish.
39
+ let shot = createAnimation(frames, SHOT_FPS, { loop: false })
40
+ let ended = 0
41
+ shot.onFinish = () => ended++
42
+ let shotSprite = addSprite(layer, { x: 440, y: H / 2, w: SPRITE, h: SPRITE })
43
+ shot.add(shotSprite)
44
+
45
+ let failures: string[] = []
46
+ function check(ok: boolean, what: string) {
47
+ if (!ok) failures.push(what)
48
+ }
49
+ function indexOf(frame: Frame): number {
50
+ return frames.findIndex(f => Math.abs(f.u0 - frame.u0) < 1e-6 && Math.abs(f.v0 - frame.v0) < 1e-6)
51
+ }
52
+ function shownIndex(sprite: SpriteHandle): number {
53
+ return indexOf(getSprite(sprite)!.frame)
54
+ }
55
+
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.
59
+ let period = 1000 / LOOP_FPS
60
+ setTimeout(() => {
61
+ check(shownIndex(looped[0]!) === 0, "loop shows frame 0 in its first period")
62
+ }, period / 2)
63
+ setTimeout(() => {
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")
66
+ }, period * 1.5)
67
+ setTimeout(() => {
68
+ check(shownIndex(looped[2]!) === 0, "loop wrapped back to frame 0 after a full cycle")
69
+ // One-shot: 4 frames at 5fps end at 800ms - long since done here.
70
+ check(shownIndex(shotSprite) === frames.length - 1, "one-shot holds its last frame")
71
+ check(!shot.playing, "one-shot stopped playing")
72
+ check(ended === 1, `onFinish fired once, got ${ended}`)
73
+ check(looping.playing, "the looping clip is still playing")
74
+ console.log(failures.length === 0 ? "ANIM-OK" : `ANIM-FAIL: ${failures.join("; ")}`)
75
+ }, period * 4.5)
76
+
77
+ return (
78
+ <window alignItems="center" justifyContent="center">
79
+ <texture src={view.texture} width={W} height={H} />
80
+ </window>
81
+ )
82
+ }
83
+
84
+ render(() => <App />)
@@ -0,0 +1,202 @@
1
+ // Camera-mapping probe: the live guard that the three spellings of the
2
+ // camera mapping agree - projectCamera (camera.ts), the vertex stages'
3
+ // uCameraRot (shaders.ts), and pointer dispatch's unprojectCamera
4
+ // (layer.ts). The headless check (checks/camera-check.ts) ties
5
+ // projectCamera to the <view>-prop oracle but cannot see the shader; this
6
+ // probe closes that gap on a real client. Self-asserting: watch the logs
7
+ // for CAMERA lines ending in CAMERA-OK. Run it after touching ANY
8
+ // rotation in the package (the touch-one-rotation-touch-all rule in
9
+ // AGENTS.md).
10
+ //
11
+ // What it checks, all under one rotated + pivoted camera:
12
+ // 1. The shader implements projectCamera: a probe sprite samples opaque
13
+ // at its PROJECTED pixel and background at the unrotated position.
14
+ // 2. VERTEX (records) and VERTEX_SPLIT (nodes) agree: pixel parity
15
+ // between the two layer kinds.
16
+ // 3. Pointer dispatch round-trips: a pointer at the projected screen
17
+ // point hits the sprite and reports its world coordinates.
18
+ // 4. Tile chunks bake with the pinned identity camera rotation.
19
+ import { decodeImage, onFrame, render } from "@solidrt/core"
20
+ import { readTexture } from "@solidrt/core/gpu"
21
+ import {
22
+ addSprite,
23
+ createAtlas,
24
+ createRecordLayer,
25
+ createSpriteLayer,
26
+ createTileLayer,
27
+ FULL_FRAME,
28
+ grid,
29
+ projectCamera,
30
+ } from "@solidrt/2d"
31
+ import type { CameraUpdate, SpriteHandle, SpritePointerEvent } from "@solidrt/2d"
32
+ import logoBytes from "./logo.png" with { type: "binary" }
33
+
34
+ const W = 360
35
+ const H = 360
36
+ const N = 40
37
+ const SEED = 0xcafe
38
+
39
+ // Chosen so the probe sprite at world (320, 320) projects on-screen
40
+ // (~185, ~208) while its unrotated position (20, 20) maps back to a world
41
+ // point far outside the population's reach - both sample points are clean.
42
+ const CAM: CameraUpdate = { x: 300, y: 300, zoom: 1, rotation: 0.6, pivotX: 180, pivotY: 180 }
43
+
44
+ function rng(seed: number): () => number {
45
+ let a = seed >>> 0
46
+ return () => {
47
+ a = (a + 0x6d2b79f5) >>> 0
48
+ let t = a
49
+ t = Math.imul(t ^ (t >>> 15), t | 1)
50
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
51
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
52
+ }
53
+ }
54
+
55
+ function App() {
56
+ let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
57
+ let frames = grid(atlas, 2, 2)
58
+ let nodes = createSpriteLayer(atlas.texture, { label: "cam-nodes" })
59
+ let records = createRecordLayer(atlas.texture, { label: "cam-records" })
60
+ let nodeView = nodes.createView({ width: W, height: H, label: "cam-nodes" })
61
+ let recordView = records.createView({ width: W, height: H, label: "cam-records" })
62
+ nodeView.setCamera(CAM)
63
+ recordView.setCamera(CAM)
64
+
65
+ // The same deterministic population on both layers, kept inside world
66
+ // 20..100 so its reach (~145 with rotation) stays clear of both sample
67
+ // points.
68
+ let fields = (r: () => number) => ({
69
+ x: 20 + r() * 80,
70
+ y: 20 + r() * 80,
71
+ w: 16 + r() * 48,
72
+ h: 16 + r() * 48,
73
+ rotation: r() * Math.PI * 2,
74
+ frame: frames[Math.floor(r() * 4) % 4]!,
75
+ tint: [0.5 + r() * 0.5, 0.5 + r() * 0.5, 0.5 + r() * 0.5, 1] as [number, number, number, number],
76
+ })
77
+ let r1 = rng(SEED)
78
+ for (let i = 0; i < N; i++) addSprite(nodes, fields(r1))
79
+ let r2 = rng(SEED)
80
+ for (let i = 0; i < N; i++) addSprite(records, fields(r2))
81
+
82
+ // The probe sprite: the FULL logo frame (its central 10% is fully
83
+ // opaque, so the projected center samples opaque), isolated from the
84
+ // population.
85
+ let probe: SpriteHandle = addSprite(nodes, { x: 320, y: 320, w: 40, h: 40, frame: FULL_FRAME })
86
+ addSprite(records, { x: 320, y: 320, w: 40, h: 40, frame: FULL_FRAME })
87
+
88
+ // A small tile layer: the bake path with the pinned identity rotation.
89
+ // The checked tile (2, 2) gets the full frame (opaque center texel).
90
+ let tiles = createTileLayer(8, 8, 16, 16, atlas.texture, { label: "cam-tiles" })
91
+ for (let i = 0; i < 8; i++) tiles.setTile(i, i, i === 2 ? FULL_FRAME : frames[i % 4]!)
92
+
93
+ let failures: string[] = []
94
+ let check = (ok: boolean, what: string) => {
95
+ if (!ok) failures.push(what)
96
+ console.log(`CAMERA ${ok ? "ok" : "FAIL"}: ${what}`)
97
+ }
98
+
99
+ let alphaAt = (img: { width: number; height: number; data: Uint8Array | Uint8ClampedArray }, sx: number, sy: number, n: number) => {
100
+ // The target is oversampled n times the layer size; sample the texel
101
+ // at the scaled screen point.
102
+ let px = Math.min(img.width - 1, Math.max(0, Math.round(sx * n)))
103
+ let py = Math.min(img.height - 1, Math.max(0, Math.round(sy * n)))
104
+ return img.data[(py * img.width + px) * 4 + 3]!
105
+ }
106
+
107
+ let shaderCheck = () => {
108
+ let img = readTexture(nodeView.texture)
109
+ let n = nodeView.oversample
110
+ let [sx, sy] = projectCamera(CAM, 320, 320)
111
+ check(sx > 8 && sx < W - 8 && sy > 8 && sy < H - 8, `projected probe on screen (${sx.toFixed(1)}, ${sy.toFixed(1)})`)
112
+ check(alphaAt(img, sx, sy, n) > 0, "probe center opaque at its PROJECTED pixel")
113
+ // Where a pre-rotation shader would draw it: (world - cam) * zoom.
114
+ let ux = (320 - CAM.x!) * CAM.zoom!
115
+ let uy = (320 - CAM.y!) * CAM.zoom!
116
+ check(Math.hypot(ux - sx, uy - sy) > 40, "rotated and unrotated positions are far apart")
117
+ check(alphaAt(img, ux, uy, n) === 0, "background at the UNROTATED position")
118
+ let [cx, cy] = projectCamera(CAM, CAM.x!, CAM.y!)
119
+ check(Math.abs(cx - CAM.pivotX!) < 1e-9 && Math.abs(cy - CAM.pivotY!) < 1e-9, "camera world point projects onto the pivot")
120
+ }
121
+
122
+ let pixelParity = () => {
123
+ let a = readTexture(nodeView.texture)
124
+ let b = readTexture(recordView.texture)
125
+ if (a.width !== b.width || a.height !== b.height) {
126
+ check(false, `sizes differ: ${a.width}x${a.height} vs ${b.width}x${b.height}`)
127
+ return
128
+ }
129
+ let off = 0
130
+ for (let i = 0; i < a.data.length; i += 4) {
131
+ if (
132
+ Math.abs(a.data[i]! - b.data[i]!) > 8 ||
133
+ Math.abs(a.data[i + 1]! - b.data[i + 1]!) > 8 ||
134
+ Math.abs(a.data[i + 2]! - b.data[i + 2]!) > 8 ||
135
+ Math.abs(a.data[i + 3]! - b.data[i + 3]!) > 8
136
+ ) {
137
+ off++
138
+ }
139
+ }
140
+ let total = a.data.length / 4
141
+ let pct = (off / total) * 100
142
+ check(pct < 0.5, `node/record pixel parity under rotated camera (${off}/${total} off, ${pct.toFixed(3)}%)`)
143
+ }
144
+
145
+ let pointerRoundTrip = () => {
146
+ let got: SpritePointerEvent | null = null
147
+ probe.onPointerDown = e => (got = e)
148
+ let [sx, sy] = projectCamera(CAM, 320, 320)
149
+ // Synthesize the element event the built-in leaf would deliver (layout
150
+ // null: localX/localY are layer pixels already).
151
+ nodeView.handlers.onPointerDown({
152
+ localX: sx,
153
+ localY: sy,
154
+ pointerId: 1,
155
+ pointerType: "mouse",
156
+ button: 0,
157
+ shiftKey: false,
158
+ ctrlKey: false,
159
+ altKey: false,
160
+ metaKey: false,
161
+ } as never)
162
+ // TS narrows `got` to null (the write is inside the handler call above).
163
+ let hit = got as SpritePointerEvent | null
164
+ check(hit !== null && hit.sprite === probe, "pointer at the projected pixel hits the probe")
165
+ check(hit !== null && Math.abs(hit.x - 320) < 1e-6 && Math.abs(hit.y - 320) < 1e-6, `pointer event carries world coords (${hit ? `${hit.x.toFixed(3)}, ${hit.y.toFixed(3)}` : "none"})`)
166
+ check(nodes.pick(320, 320)[0] === probe, "pick in world space still hits the probe")
167
+ }
168
+
169
+ let tileBake = () => {
170
+ let chunk = tiles.chunks[0]
171
+ if (!chunk) {
172
+ check(false, "tile chunk allocated")
173
+ return
174
+ }
175
+ let img = readTexture(chunk.texture)
176
+ let n = tiles.oversample
177
+ // Tile (2, 2) center at world (40, 40), chunk origin (0, 0).
178
+ check(alphaAt(img, 40, 40, n) > 0, "tile bakes opaque with the pinned identity camera rotation")
179
+ check(alphaAt(img, 40, 8, n) === 0, "empty cell stays clear")
180
+ }
181
+
182
+ let frame = 0
183
+ onFrame(() => {
184
+ frame++
185
+ if (frame === 6) {
186
+ shaderCheck()
187
+ pixelParity()
188
+ pointerRoundTrip()
189
+ tileBake()
190
+ console.log(failures.length === 0 ? "CAMERA-OK" : `CAMERA-FAIL: ${failures.join("; ")}`)
191
+ }
192
+ })
193
+
194
+ return (
195
+ <window flexDirection="row" alignItems="center" justifyContent="center" gap={8}>
196
+ <texture src={nodeView.texture} width={W} height={H} />
197
+ <texture src={recordView.texture} width={W} height={H} />
198
+ </window>
199
+ )
200
+ }
201
+
202
+ render(() => <App />)
@@ -0,0 +1,196 @@
1
+ // The 2d camera (createCamera2d) over a world larger than the window,
2
+ // attached at the layer's root: drag empty space to pan with inertia on
3
+ // release, wheel or pinch anywhere to zoom about the pointer, tap empty
4
+ // space to glide the view there. The sprites take part in the same
5
+ // walk: a tap on one selects it (tint), a drag on one moves it - the
6
+ // sprite stops its down, which claims the press, so the camera never
7
+ // pans under a sprite drag. Keys switch modes - F follows the roaming
8
+ // sprite through a dead zone with damping, R spins the view about its
9
+ // center, Space fits the whole world. The camera is one setCamera write
10
+ // per changed frame (a shared-params write); no sprite moves for the
11
+ // camera's sake. The layer fills the window through the function face
12
+ // (starlings' shape): the app owns the <texture> leaf and spreads the
13
+ // LAYER's handlers on it; the camera listens at the root through attach.
14
+ //
15
+ // Debug commands, for driving it from the control API: `camera` returns
16
+ // the pose (and parks it when given x/y/zoom/rotation), `mode` sets
17
+ // { follow, spin } or runs { fit: true }, `selected` returns the selected
18
+ // sprite's world position (null when none), `first` the first sprite's -
19
+ // the one to aim synthetic taps and drags at.
20
+ import { createEffect, createInputMap, createPointerFeed, decodeImage, displayScale, gamepad, onFrame, render, windowSize } from "@solidrt/core"
21
+ import { addSprite, camera2dActions, camera2dBindings, createAtlas, createCamera2d, createSpriteLayer, feedPointer, fitOversample, grid, setSprite } from "@solidrt/2d"
22
+ import type { Camera2dHandle, SpriteHandle } from "@solidrt/2d"
23
+ import { registerDebug } from "srt:dev"
24
+ import logoBytes from "./logo.png" with { type: "binary" }
25
+
26
+ const WORLD = { width: 2400, height: 1600 }
27
+ const COUNT = 300
28
+ const SPRITE = 64
29
+ const ROAMER = 96
30
+ const MAX_ZOOM = 4
31
+ // The roamer's Lissajous path: radians per second per axis, and how much
32
+ // of the world it sweeps.
33
+ const ROAM_RATE_X = 0.23
34
+ const ROAM_RATE_Y = 0.31
35
+ const ROAM_SPAN = 0.4
36
+ // Spin rate while R is on, radians per second.
37
+ const SPIN_RATE = 0.4
38
+ // The follow dead zone, fractions of the viewport.
39
+ const DEAD_ZONE = { width: 0.3, height: 0.3 }
40
+ // Largest frame step, seconds: a stall eases on from here instead of
41
+ // teleporting.
42
+ const MAX_DT = 0.1
43
+ const TINT: [number, number, number, number] = [0.6, 0.7, 0.9, 1]
44
+ const SELECTED_TINT: [number, number, number, number] = [1, 0.95, 0.5, 1]
45
+
46
+ let cam!: Camera2dHandle
47
+ let following = false
48
+ let spinning = false
49
+ // The roamer's world position, for the `mode` debug command.
50
+ let roamerAt = { x: 0, y: 0 }
51
+ let selected: SpriteHandle | null = null
52
+ let first: SpriteHandle | null = null
53
+ // The sprite holding a press (onPointerMove also fires on plain hover;
54
+ // only the captured moves drag).
55
+ let dragging: SpriteHandle | null = null
56
+
57
+ function App() {
58
+ let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
59
+ let frames = grid(atlas, 2, 2)
60
+ // The window's logical size, mirrored for the layer and the camera.
61
+ let win = { width: 1, height: 1 }
62
+ let layer = createSpriteLayer(atlas.texture, { capacity: COUNT + 1, label: "camera" })
63
+ // The layer's one view here: the window. The camera drives it and
64
+ // listens at its root.
65
+ let view = layer.createView({ width: win.width, height: win.height, clearColor: [0.05, 0.05, 0.09, 1] })
66
+ let select = (sprite: SpriteHandle | null) => {
67
+ if (selected) setSprite(selected, { tint: TINT })
68
+ selected = sprite
69
+ if (selected) setSprite(selected, { tint: SELECTED_TINT })
70
+ }
71
+ for (let i = 0; i < COUNT; i++) {
72
+ let sprite = addSprite(layer, {
73
+ x: Math.random() * WORLD.width,
74
+ y: Math.random() * WORLD.height,
75
+ w: SPRITE,
76
+ h: SPRITE,
77
+ frame: frames[i % 4],
78
+ rotation: Math.random() * Math.PI * 2,
79
+ tint: TINT,
80
+ })
81
+ // The sprite claims its press: the camera at the root never sees this
82
+ // pointer again, so dragging the sprite moves it instead of the view.
83
+ sprite.onPointerDown = e => {
84
+ e.stopPropagation()
85
+ dragging = sprite
86
+ }
87
+ sprite.onPointerUp = () => (dragging = null)
88
+ sprite.onPointerMove = e => {
89
+ if (dragging === sprite) setSprite(sprite, { x: e.x, y: e.y })
90
+ }
91
+ sprite.onTap = () => select(sprite)
92
+ if (first === null) first = sprite
93
+ }
94
+ let roamer = addSprite(layer, { x: WORLD.width / 2, y: WORLD.height / 2, w: ROAMER, h: ROAMER, frame: frames[0], tint: [1, 0.8, 0.3, 1] })
95
+
96
+ cam = createCamera2d(view, {
97
+ viewport: () => win,
98
+ world: WORLD,
99
+ maxZoom: MAX_ZOOM,
100
+ deadZone: DEAD_ZONE,
101
+ })
102
+ // The camera's input: the view's pointer feed (fed from the view's root,
103
+ // after the sprites' claims) and any pad, bound to the control's actions
104
+ // (ARCHITECTURE.md: the app wires devices, the control takes the map).
105
+ let pointer = createPointerFeed()
106
+ feedPointer(view, pointer)
107
+ let input = createInputMap(camera2dActions)
108
+ input.bind(camera2dBindings({ pointer, gamepad: gamepad() }))
109
+ input.drive(cam.axes)
110
+ // A tap that reached the root landed on empty space (a sprite's tap
111
+ // still bubbles here with e.sprite set, so the miss is the null case).
112
+ view.listen({
113
+ onTap: e => {
114
+ if (e.sprite) return
115
+ select(null)
116
+ following = false
117
+ cam.glideTo(e.x, e.y)
118
+ },
119
+ })
120
+
121
+ createEffect(
122
+ () => ({ size: windowSize(), scale: displayScale() }),
123
+ ({ size, scale }) => {
124
+ win.width = Math.max(1, size.width)
125
+ win.height = Math.max(1, size.height)
126
+ view.setSize(win.width, win.height)
127
+ view.setOversample(fitOversample(scale, win.width, win.height, win.width * win.height * scale * scale))
128
+ },
129
+ )
130
+
131
+ let last = 0
132
+ onFrame(tick => {
133
+ let t = tick / 1000
134
+ let dt = Math.min(MAX_DT, Math.max(0, t - last))
135
+ last = t
136
+ let rx = WORLD.width / 2 + Math.sin(t * ROAM_RATE_X) * WORLD.width * ROAM_SPAN
137
+ let ry = WORLD.height / 2 + Math.sin(t * ROAM_RATE_Y) * WORLD.height * ROAM_SPAN
138
+ setSprite(roamer, { x: rx, y: ry, rotation: t })
139
+ roamerAt.x = rx
140
+ roamerAt.y = ry
141
+ if (following) cam.follow(rx, ry)
142
+ if (spinning) cam.set({ rotation: cam.camera().rotation! + SPIN_RATE * dt })
143
+ cam.update(dt)
144
+ })
145
+
146
+ return (
147
+ <window
148
+ onKeyDown={e => {
149
+ if (e.key === "f") {
150
+ following = !following
151
+ if (!following) cam.unfollow()
152
+ } else if (e.key === "r") {
153
+ spinning = !spinning
154
+ } else if (e.key === " ") {
155
+ following = false
156
+ cam.unfollow()
157
+ cam.fit(undefined, { glide: true })
158
+ }
159
+ }}
160
+ >
161
+ <texture src={view.texture} position="absolute" left={0} top={0} width={windowSize().width} height={windowSize().height} {...view.handlers} />
162
+ <view pointerEvents="none" gap={6} padding={20}>
163
+ <text color="#eef4ff" fontSize={24} fontWeight={700}>
164
+ Camera
165
+ </text>
166
+ <text color="#a9bcd6" fontSize={15}>
167
+ drag empty space to pan (flick for inertia) - wheel or pinch to zoom - tap empty space to glide there - tap a sprite to select, drag it to move - F follow - R spin - Space fit
168
+ </text>
169
+ </view>
170
+ </window>
171
+ )
172
+ }
173
+
174
+ render(() => <App />)
175
+
176
+ registerDebug("camera", (args?: Record<string, unknown>) => {
177
+ let pose: Record<string, number> = {}
178
+ for (let key of ["x", "y", "zoom", "rotation"]) {
179
+ if (typeof args?.[key] === "number") pose[key] = args[key] as number
180
+ }
181
+ if (Object.keys(pose).length > 0) cam.set(pose)
182
+ return cam.camera()
183
+ })
184
+
185
+ registerDebug("mode", (args?: Record<string, unknown>) => {
186
+ if (typeof args?.follow === "boolean") {
187
+ following = args.follow
188
+ if (!following) cam.unfollow()
189
+ }
190
+ if (typeof args?.spin === "boolean") spinning = args.spin
191
+ if (args?.fit === true) cam.fit(undefined, { glide: true })
192
+ return { following, spinning, roamer: { ...roamerAt } }
193
+ })
194
+
195
+ registerDebug("selected", () => (selected ? { x: selected._x, y: selected._y } : null))
196
+ registerDebug("first", () => (first ? { x: first._x, y: first._y } : null))
@@ -7,7 +7,7 @@
7
7
  // picking at random points, exercises pickRect, groups and slot recycling,
8
8
  // and benches addSprite plus a move-everything frame on both layers. Watch
9
9
  // the logs for PARITY/BENCH lines ending in PARITY-OK.
10
- import { onFrame, render } from "@solidrt/core"
10
+ import { decodeImage, onFrame, render } from "@solidrt/core"
11
11
  import { readTexture } from "@solidrt/core/gpu"
12
12
  import {
13
13
  addGroup,
@@ -42,10 +42,12 @@ function rng(seed: number): () => number {
42
42
  }
43
43
 
44
44
  function App() {
45
- let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
46
- let frames = grid(2, 2, { width: atlas.width, height: atlas.height })
47
- let nodes = createSpriteLayer(W, H, atlas.texture, { capacity: 64, clearColor: [0.05, 0.05, 0.09, 1], label: "nodes" })
48
- let records = createRecordLayer(W, H, atlas.texture, { capacity: 64, clearColor: [0.05, 0.05, 0.09, 1], label: "records" })
45
+ let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
46
+ let frames = grid(atlas, 2, 2)
47
+ let nodes = createSpriteLayer(atlas.texture, { capacity: 64, label: "nodes" })
48
+ let records = createRecordLayer(atlas.texture, { capacity: 64, label: "records" })
49
+ let nodeView = nodes.createView({ width: W, height: H, clearColor: [0.05, 0.05, 0.09, 1], label: "nodes" })
50
+ let recordView = records.createView({ width: W, height: H, clearColor: [0.05, 0.05, 0.09, 1], label: "records" })
49
51
 
50
52
  let fields = (r: () => number) => ({
51
53
  x: 20 + r() * (W - 40),
@@ -89,8 +91,8 @@ function App() {
89
91
  }
90
92
 
91
93
  let pixelParity = () => {
92
- let a = readTexture(nodes.texture)
93
- let b = readTexture(records.texture)
94
+ let a = readTexture(nodeView.texture)
95
+ let b = readTexture(recordView.texture)
94
96
  if (a.width !== b.width || a.height !== b.height) {
95
97
  check(false, `sizes differ: ${a.width}x${a.height} vs ${b.width}x${b.height}`)
96
98
  return
@@ -119,8 +121,8 @@ function App() {
119
121
  for (let i = 0; i < 200; i++) {
120
122
  let x = r() * W
121
123
  let y = r() * H
122
- let a = nodes.pick(x, y)
123
- let b = records.pick(x, y)
124
+ let a = nodes.pick(x, y)[0] ?? null
125
+ let b = records.pick(x, y)[0] ?? null
124
126
  let ai = a ? byNode.get(a) : null
125
127
  let bi = b ? byRec.get(b) : null
126
128
  if (ai !== bi) mismatches++
@@ -152,8 +154,8 @@ function App() {
152
154
  let child = addSprite(nodes, { parent: g, x: 50, y: 0, w: 10, h: 10 })
153
155
  setGroup(g, { rotation: Math.PI / 2 })
154
156
  queueMicrotask(() => {
155
- check(nodes.pick(100, 150) === child, "group rotation carries the child sprite")
156
- check(nodes.pick(150, 100) !== child, "the ungrouped position no longer hits")
157
+ check(nodes.pick(100, 150)[0] === child, "group rotation carries the child sprite")
158
+ check(nodes.pick(150, 100)[0] !== child, "the ungrouped position no longer hits")
157
159
  let f = getSprite(child)!
158
160
  check(f.x === 50 && f.y === 0, "getSprite reads the local pose")
159
161
  removeSprite(child)
@@ -195,8 +197,8 @@ function App() {
195
197
 
196
198
  return (
197
199
  <window flexDirection="row" alignItems="center" justifyContent="center" gap={8}>
198
- <texture src={nodes.texture} width={W} height={H} />
199
- <texture src={records.texture} width={W} height={H} />
200
+ <texture src={nodeView.texture} width={W} height={H} />
201
+ <texture src={recordView.texture} width={W} height={H} />
200
202
  </window>
201
203
  )
202
204
  }