@solidrt/2d 0.0.55 → 0.0.56

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,200 @@
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 { 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(logoBytes, { label: "logo-atlas" })
57
+ let frames = grid(2, 2, { width: atlas.width, height: atlas.height })
58
+ let nodes = createSpriteLayer(W, H, atlas.texture, { label: "cam-nodes" })
59
+ let records = createRecordLayer(W, H, atlas.texture, { label: "cam-records" })
60
+ nodes.setCamera(CAM)
61
+ records.setCamera(CAM)
62
+
63
+ // The same deterministic population on both layers, kept inside world
64
+ // 20..100 so its reach (~145 with rotation) stays clear of both sample
65
+ // points.
66
+ let fields = (r: () => number) => ({
67
+ x: 20 + r() * 80,
68
+ y: 20 + r() * 80,
69
+ w: 16 + r() * 48,
70
+ h: 16 + r() * 48,
71
+ rotation: r() * Math.PI * 2,
72
+ frame: frames[Math.floor(r() * 4) % 4]!,
73
+ tint: [0.5 + r() * 0.5, 0.5 + r() * 0.5, 0.5 + r() * 0.5, 1] as [number, number, number, number],
74
+ })
75
+ let r1 = rng(SEED)
76
+ for (let i = 0; i < N; i++) addSprite(nodes, fields(r1))
77
+ let r2 = rng(SEED)
78
+ for (let i = 0; i < N; i++) addSprite(records, fields(r2))
79
+
80
+ // The probe sprite: the FULL logo frame (its central 10% is fully
81
+ // opaque, so the projected center samples opaque), isolated from the
82
+ // population.
83
+ let probe: SpriteHandle = addSprite(nodes, { x: 320, y: 320, w: 40, h: 40, frame: FULL_FRAME })
84
+ addSprite(records, { x: 320, y: 320, w: 40, h: 40, frame: FULL_FRAME })
85
+
86
+ // A small tile layer: the bake path with the pinned identity rotation.
87
+ // The checked tile (2, 2) gets the full frame (opaque center texel).
88
+ let tiles = createTileLayer(8, 8, 16, 16, atlas.texture, { label: "cam-tiles" })
89
+ for (let i = 0; i < 8; i++) tiles.setTile(i, i, i === 2 ? FULL_FRAME : frames[i % 4]!)
90
+
91
+ let failures: string[] = []
92
+ let check = (ok: boolean, what: string) => {
93
+ if (!ok) failures.push(what)
94
+ console.log(`CAMERA ${ok ? "ok" : "FAIL"}: ${what}`)
95
+ }
96
+
97
+ let alphaAt = (img: { width: number; height: number; data: Uint8Array | Uint8ClampedArray }, sx: number, sy: number, n: number) => {
98
+ // The target is oversampled n times the layer size; sample the texel
99
+ // at the scaled screen point.
100
+ let px = Math.min(img.width - 1, Math.max(0, Math.round(sx * n)))
101
+ let py = Math.min(img.height - 1, Math.max(0, Math.round(sy * n)))
102
+ return img.data[(py * img.width + px) * 4 + 3]!
103
+ }
104
+
105
+ let shaderCheck = () => {
106
+ let img = readTexture(nodes.texture)
107
+ let n = nodes.oversample
108
+ let [sx, sy] = projectCamera(CAM, 320, 320)
109
+ check(sx > 8 && sx < W - 8 && sy > 8 && sy < H - 8, `projected probe on screen (${sx.toFixed(1)}, ${sy.toFixed(1)})`)
110
+ check(alphaAt(img, sx, sy, n) > 0, "probe center opaque at its PROJECTED pixel")
111
+ // Where a pre-rotation shader would draw it: (world - cam) * zoom.
112
+ let ux = (320 - CAM.x!) * CAM.zoom!
113
+ let uy = (320 - CAM.y!) * CAM.zoom!
114
+ check(Math.hypot(ux - sx, uy - sy) > 40, "rotated and unrotated positions are far apart")
115
+ check(alphaAt(img, ux, uy, n) === 0, "background at the UNROTATED position")
116
+ let [cx, cy] = projectCamera(CAM, CAM.x!, CAM.y!)
117
+ check(Math.abs(cx - CAM.pivotX!) < 1e-9 && Math.abs(cy - CAM.pivotY!) < 1e-9, "camera world point projects onto the pivot")
118
+ }
119
+
120
+ let pixelParity = () => {
121
+ let a = readTexture(nodes.texture)
122
+ let b = readTexture(records.texture)
123
+ if (a.width !== b.width || a.height !== b.height) {
124
+ check(false, `sizes differ: ${a.width}x${a.height} vs ${b.width}x${b.height}`)
125
+ return
126
+ }
127
+ let off = 0
128
+ for (let i = 0; i < a.data.length; i += 4) {
129
+ if (
130
+ Math.abs(a.data[i]! - b.data[i]!) > 8 ||
131
+ Math.abs(a.data[i + 1]! - b.data[i + 1]!) > 8 ||
132
+ Math.abs(a.data[i + 2]! - b.data[i + 2]!) > 8 ||
133
+ Math.abs(a.data[i + 3]! - b.data[i + 3]!) > 8
134
+ ) {
135
+ off++
136
+ }
137
+ }
138
+ let total = a.data.length / 4
139
+ let pct = (off / total) * 100
140
+ check(pct < 0.5, `node/record pixel parity under rotated camera (${off}/${total} off, ${pct.toFixed(3)}%)`)
141
+ }
142
+
143
+ let pointerRoundTrip = () => {
144
+ let got: SpritePointerEvent | null = null
145
+ probe.onPointerDown = e => (got = e)
146
+ let [sx, sy] = projectCamera(CAM, 320, 320)
147
+ // Synthesize the element event the built-in leaf would deliver (layout
148
+ // null: localX/localY are layer pixels already).
149
+ nodes.handlers.onPointerDown({
150
+ localX: sx,
151
+ localY: sy,
152
+ pointerId: 1,
153
+ pointerType: "mouse",
154
+ button: 0,
155
+ shiftKey: false,
156
+ ctrlKey: false,
157
+ altKey: false,
158
+ metaKey: false,
159
+ } as never)
160
+ // TS narrows `got` to null (the write is inside the handler call above).
161
+ let hit = got as SpritePointerEvent | null
162
+ check(hit !== null && hit.sprite === probe, "pointer at the projected pixel hits the probe")
163
+ 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"})`)
164
+ check(nodes.pick(320, 320)[0] === probe, "pick in world space still hits the probe")
165
+ }
166
+
167
+ let tileBake = () => {
168
+ let chunk = tiles.chunks[0]
169
+ if (!chunk) {
170
+ check(false, "tile chunk allocated")
171
+ return
172
+ }
173
+ let img = readTexture(chunk.texture)
174
+ let n = tiles.oversample
175
+ // Tile (2, 2) center at world (40, 40), chunk origin (0, 0).
176
+ check(alphaAt(img, 40, 40, n) > 0, "tile bakes opaque with the pinned identity camera rotation")
177
+ check(alphaAt(img, 40, 8, n) === 0, "empty cell stays clear")
178
+ }
179
+
180
+ let frame = 0
181
+ onFrame(() => {
182
+ frame++
183
+ if (frame === 6) {
184
+ shaderCheck()
185
+ pixelParity()
186
+ pointerRoundTrip()
187
+ tileBake()
188
+ console.log(failures.length === 0 ? "CAMERA-OK" : `CAMERA-FAIL: ${failures.join("; ")}`)
189
+ }
190
+ })
191
+
192
+ return (
193
+ <window flexDirection="row" alignItems="center" justifyContent="center" gap={8}>
194
+ <texture src={nodes.texture} width={W} height={H} />
195
+ <texture src={records.texture} width={W} height={H} />
196
+ </window>
197
+ )
198
+ }
199
+
200
+ render(() => <App />)
@@ -0,0 +1,190 @@
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, displayScale, onFrame, render, windowSize } from "@solidrt/core"
21
+ import { addSprite, createAtlas, createCamera2d, createSpriteLayer, 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(logoBytes, { label: "logo-atlas" })
59
+ let frames = grid(2, 2, { width: atlas.width, height: atlas.height })
60
+ // The window's logical size, mirrored for the layer and the camera.
61
+ let win = { width: 1, height: 1 }
62
+ let layer = createSpriteLayer(win.width, win.height, atlas.texture, {
63
+ capacity: COUNT + 1,
64
+ clearColor: [0.05, 0.05, 0.09, 1],
65
+ label: "camera",
66
+ })
67
+ let select = (sprite: SpriteHandle | null) => {
68
+ if (selected) setSprite(selected, { tint: TINT })
69
+ selected = sprite
70
+ if (selected) setSprite(selected, { tint: SELECTED_TINT })
71
+ }
72
+ for (let i = 0; i < COUNT; i++) {
73
+ let sprite = addSprite(layer, {
74
+ x: Math.random() * WORLD.width,
75
+ y: Math.random() * WORLD.height,
76
+ w: SPRITE,
77
+ h: SPRITE,
78
+ frame: frames[i % 4],
79
+ rotation: Math.random() * Math.PI * 2,
80
+ tint: TINT,
81
+ })
82
+ // The sprite claims its press: the camera at the root never sees this
83
+ // pointer again, so dragging the sprite moves it instead of the view.
84
+ sprite.onPointerDown = e => {
85
+ e.stopPropagation()
86
+ dragging = sprite
87
+ }
88
+ sprite.onPointerUp = () => (dragging = null)
89
+ sprite.onPointerMove = e => {
90
+ if (dragging === sprite) setSprite(sprite, { x: e.x, y: e.y })
91
+ }
92
+ sprite.onTap = () => select(sprite)
93
+ if (first === null) first = sprite
94
+ }
95
+ 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] })
96
+
97
+ cam = createCamera2d(layer, {
98
+ viewport: () => win,
99
+ world: WORLD,
100
+ maxZoom: MAX_ZOOM,
101
+ deadZone: DEAD_ZONE,
102
+ })
103
+ cam.attach(layer)
104
+ // A tap that reached the root landed on empty space (a sprite's tap
105
+ // still bubbles here with e.sprite set, so the miss is the null case).
106
+ layer.listen({
107
+ onTap: e => {
108
+ if (e.sprite) return
109
+ select(null)
110
+ following = false
111
+ cam.glideTo(e.x, e.y)
112
+ },
113
+ })
114
+
115
+ createEffect(
116
+ () => ({ size: windowSize(), scale: displayScale() }),
117
+ ({ size, scale }) => {
118
+ win.width = Math.max(1, size.width)
119
+ win.height = Math.max(1, size.height)
120
+ layer.setSize(win.width, win.height)
121
+ layer.setOversample(fitOversample(scale, win.width, win.height, win.width * win.height * scale * scale))
122
+ },
123
+ )
124
+
125
+ let last = 0
126
+ onFrame(tick => {
127
+ let t = tick / 1000
128
+ let dt = Math.min(MAX_DT, Math.max(0, t - last))
129
+ last = t
130
+ let rx = WORLD.width / 2 + Math.sin(t * ROAM_RATE_X) * WORLD.width * ROAM_SPAN
131
+ let ry = WORLD.height / 2 + Math.sin(t * ROAM_RATE_Y) * WORLD.height * ROAM_SPAN
132
+ setSprite(roamer, { x: rx, y: ry, rotation: t })
133
+ roamerAt.x = rx
134
+ roamerAt.y = ry
135
+ if (following) cam.follow(rx, ry)
136
+ if (spinning) cam.set({ rotation: cam.camera().rotation! + SPIN_RATE * dt })
137
+ cam.update(dt)
138
+ })
139
+
140
+ return (
141
+ <window
142
+ onKeyDown={e => {
143
+ if (e.key === "f") {
144
+ following = !following
145
+ if (!following) cam.unfollow()
146
+ } else if (e.key === "r") {
147
+ spinning = !spinning
148
+ } else if (e.key === " ") {
149
+ following = false
150
+ cam.unfollow()
151
+ cam.fit(undefined, { glide: true })
152
+ }
153
+ }}
154
+ >
155
+ <texture src={layer.texture} position="absolute" left={0} top={0} width={windowSize().width} height={windowSize().height} {...layer.handlers} />
156
+ <view pointerEvents="none" gap={6} padding={20}>
157
+ <text color="#eef4ff" fontSize={24} fontWeight={700}>
158
+ Camera
159
+ </text>
160
+ <text color="#a9bcd6" fontSize={15}>
161
+ 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
162
+ </text>
163
+ </view>
164
+ </window>
165
+ )
166
+ }
167
+
168
+ render(() => <App />)
169
+
170
+ registerDebug("camera", (args?: Record<string, unknown>) => {
171
+ let pose: Record<string, number> = {}
172
+ for (let key of ["x", "y", "zoom", "rotation"]) {
173
+ if (typeof args?.[key] === "number") pose[key] = args[key] as number
174
+ }
175
+ if (Object.keys(pose).length > 0) cam.set(pose)
176
+ return cam.camera()
177
+ })
178
+
179
+ registerDebug("mode", (args?: Record<string, unknown>) => {
180
+ if (typeof args?.follow === "boolean") {
181
+ following = args.follow
182
+ if (!following) cam.unfollow()
183
+ }
184
+ if (typeof args?.spin === "boolean") spinning = args.spin
185
+ if (args?.fit === true) cam.fit(undefined, { glide: true })
186
+ return { following, spinning, roamer: { ...roamerAt } }
187
+ })
188
+
189
+ registerDebug("selected", () => (selected ? { x: selected._x, y: selected._y } : null))
190
+ registerDebug("first", () => (first ? { x: first._x, y: first._y } : null))
@@ -119,8 +119,8 @@ function App() {
119
119
  for (let i = 0; i < 200; i++) {
120
120
  let x = r() * W
121
121
  let y = r() * H
122
- let a = nodes.pick(x, y)
123
- let b = records.pick(x, y)
122
+ let a = nodes.pick(x, y)[0] ?? null
123
+ let b = records.pick(x, y)[0] ?? null
124
124
  let ai = a ? byNode.get(a) : null
125
125
  let bi = b ? byRec.get(b) : null
126
126
  if (ai !== bi) mismatches++
@@ -152,8 +152,8 @@ function App() {
152
152
  let child = addSprite(nodes, { parent: g, x: 50, y: 0, w: 10, h: 10 })
153
153
  setGroup(g, { rotation: Math.PI / 2 })
154
154
  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")
155
+ check(nodes.pick(100, 150)[0] === child, "group rotation carries the child sprite")
156
+ check(nodes.pick(150, 100)[0] !== child, "the ungrouped position no longer hits")
157
157
  let f = getSprite(child)!
158
158
  check(f.x === 50 && f.y === 0, "getSprite reads the local pose")
159
159
  removeSprite(child)
package/examples/pick.tsx CHANGED
@@ -1,29 +1,36 @@
1
- // Sprite pointer events through the component face: the built-in leaf
1
+ // The layer's event model through the component face: the built-in leaf
2
2
  // carries the layer's handlers, so <Sprite onPointer*> props receive events
3
3
  // with LAYER-pixel coordinates and exact rotated-rect hit testing, topmost
4
- // sprite first. Drag a sprite to move it - the layer captures the pointer on
5
- // down, so the drag keeps delivering to the grabbed sprite even when the
6
- // pointer outruns it. A click (press and release without moving) cycles the
7
- // tint. Shift-click removes the sprite, exercising slot recycling (the
8
- // freed pose slot zeroes and waits for the next add); structure lives in a
9
- // signal, so <For> unmounts the removed <Sprite>.
4
+ // sprite first, and the walk ends at the <SpriteLayer>'s own props. Drag a
5
+ // sprite to move it - the sprite stops its down, which claims the press,
6
+ // so the <Camera2d> at the root never pans under it; drag empty space to
7
+ // pan, wheel to zoom. A tap on a sprite cycles its tint (onTap: the
8
+ // dispatch's own click, no slop bookkeeping in app code); shift-tap
9
+ // removes the sprite, exercising slot recycling (the freed pose slot zeroes
10
+ // and waits for the next add); structure lives in a signal, so <For>
11
+ // unmounts the removed <Sprite>. A tap on empty space reaches the layer
12
+ // with e.sprite null. The layer FILLS the window (no width/height): layer
13
+ // pixels are the leaf's own coordinates. The `state` debug command
14
+ // returns the camera pose and every sprite's world position.
10
15
  import { createSignal, render, For } from "@solidrt/core"
11
- import { createAtlas, grid, setSprite, Sprite, SpriteLayer } from "@solidrt/2d"
12
- import type { Frame, SpriteHandle } from "@solidrt/2d"
16
+ import { Camera2d, createAtlas, grid, setSprite, Sprite, SpriteLayer } from "@solidrt/2d"
17
+ import type { Camera2dHandle, Frame, SpriteHandle } from "@solidrt/2d"
18
+ import { registerDebug } from "srt:dev"
13
19
  import logoBytes from "./logo.png" with { type: "binary" }
14
20
 
15
- const W = 720
16
- const H = 720
17
-
18
21
  const TINTS: [number, number, number, number][] = [
19
22
  [1, 1, 1, 1],
20
23
  [1, 0.5, 0.5, 1],
21
24
  [0.5, 1, 0.6, 1],
22
25
  [0.55, 0.7, 1, 1],
23
26
  ]
27
+ const MAX_ZOOM = 4
24
28
 
25
29
  type Item = { id: number; x: number; y: number; frame: Frame }
26
30
 
31
+ let cam: Camera2dHandle | undefined
32
+ let handles = new Map<number, SpriteHandle>()
33
+
27
34
  function App() {
28
35
  let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
29
36
  let frames = grid(2, 2, { width: atlas.width, height: atlas.height })
@@ -35,42 +42,47 @@ function App() {
35
42
  ])
36
43
 
37
44
  return (
38
- <window alignItems="center" justifyContent="center">
39
- <SpriteLayer width={W} height={H} atlas={atlas.texture} capacity={64} clearColor={[0.05, 0.05, 0.09, 1]}>
45
+ <window>
46
+ <SpriteLayer
47
+ atlas={atlas.texture}
48
+ capacity={64}
49
+ clearColor={[0.05, 0.05, 0.09, 1]}
50
+ onTap={e => {
51
+ if (e.sprite === null) console.log(`tap on empty space at ${e.x.toFixed(0)}, ${e.y.toFixed(0)} (x${e.tapCount})`)
52
+ }}
53
+ >
54
+ {/* A top-left pivot keeps world (0,0) at the leaf's corner, so the
55
+ authored positions read as window coordinates until panned. */}
56
+ <Camera2d maxZoom={MAX_ZOOM} pivot={{ x: 0, y: 0 }} ref={c => (cam = c)} />
40
57
  <For each={items()}>
41
58
  {item => {
42
59
  let tintIndex = 0
43
- let down = false
44
- let moved = false
45
- let downX = 0
46
- let downY = 0
47
60
  let handle: SpriteHandle | undefined
61
+ // Whether this sprite holds a press: onPointerMove also fires
62
+ // on plain hover, and only a captured move drags.
63
+ let pressed = false
48
64
  return (
49
65
  <Sprite
50
- ref={s => (handle = s)}
66
+ ref={s => {
67
+ handle = s
68
+ handles.set(item.id, s)
69
+ }}
51
70
  x={item.x}
52
71
  y={item.y}
53
72
  w={96}
54
73
  h={96}
55
74
  frame={item.frame}
75
+ // Claim the press: the camera never sees this pointer, so
76
+ // the captured moves drag the sprite, not the view.
56
77
  onPointerDown={e => {
57
- down = true
58
- moved = false
59
- downX = e.x
60
- downY = e.y
78
+ e.stopPropagation()
79
+ pressed = true
61
80
  }}
81
+ onPointerUp={() => (pressed = false)}
62
82
  onPointerMove={e => {
63
- // onPointerMove also fires on plain hover; only a captured
64
- // move (button held since the down) drags. A few pixels of
65
- // slop keep an ordinary click from registering as a drag.
66
- if (!down) return
67
- if (!moved && Math.hypot(e.x - downX, e.y - downY) < 4) return
68
- moved = true
69
- if (handle) setSprite(handle, { x: e.x, y: e.y })
83
+ if (pressed && handle) setSprite(handle, { x: e.x, y: e.y })
70
84
  }}
71
- onPointerUp={e => {
72
- down = false
73
- if (moved) return
85
+ onTap={e => {
74
86
  if (e.shiftKey) {
75
87
  setItems(items().filter(other => other.id !== item.id))
76
88
  return
@@ -88,3 +100,8 @@ function App() {
88
100
  }
89
101
 
90
102
  render(() => <App />)
103
+
104
+ registerDebug("state", () => ({
105
+ camera: cam?.camera() ?? null,
106
+ items: [...handles].filter(([, s]) => s.layer !== null).map(([id, s]) => ({ id, x: s._x, y: s._y })),
107
+ }))
@@ -13,7 +13,7 @@
13
13
  //
14
14
  // The atlas is the core logo sliced 2x2 by grid(); a real game would slice
15
15
  // a tileset sheet the same way.
16
- import { createSignal, onFrame, render } from "@solidrt/core"
16
+ import { createSignal, onFrame, pct, render, windowSize } from "@solidrt/core"
17
17
  import { createAtlas, grid, TileLayer } from "@solidrt/2d"
18
18
  import type { TileCamera, TileLayerHandle } from "@solidrt/2d"
19
19
  import logoBytes from "./logo.png" with { type: "binary" }
@@ -21,9 +21,13 @@ import logoBytes from "./logo.png" with { type: "binary" }
21
21
  const COLS = 128
22
22
  const ROWS = 128
23
23
  const TILE = 48
24
- const VIEW = 720
25
24
  const WORLD = COLS * TILE
26
25
 
26
+ // onFrame tick gap that logs as a hitch, as a factor of the display's
27
+ // refresh period (onFrame's rate argument, so any refresh rate works):
28
+ // past 1.5 periods a frame slot was missed while this animation ran.
29
+ const HITCH_FACTOR = 1.5
30
+
27
31
  function App() {
28
32
  let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
29
33
  let frames = grid(2, 2, { width: atlas.width, height: atlas.height })
@@ -56,17 +60,22 @@ function App() {
56
60
  // near the viewport bottom. Per-frame camera motion is one signal write
57
61
  // feeding the transform - never a re-bake.
58
62
  let [camera, setCamera] = createSignal<TileCamera>({})
59
- onFrame(tick => {
63
+ let lastTick = 0
64
+ onFrame((tick, _frame, rate) => {
65
+ if (lastTick && tick - lastTick > (1000 / rate) * HITCH_FACTOR) console.log(`[hitch] ${(tick - lastTick).toFixed(1)}ms frame gap`)
66
+ lastTick = tick
60
67
  let t = tick / 6000
61
68
  let radius = 46 * TILE
69
+ // The path C + r(cos t, sin t) has tangent heading t + pi/2; the
70
+ // camera convention renders a heading h upward at rotation -h - pi/2.
71
+ let heading = t + Math.PI / 2
62
72
  setCamera({
63
73
  x: WORLD / 2 + Math.cos(t) * radius,
64
74
  y: WORLD / 2 + Math.sin(t) * radius,
65
- // Circle tangent heading, rotated so "forward" renders upward.
66
- rotation: -(t + Math.PI / 2),
75
+ rotation: -heading - Math.PI / 2,
67
76
  zoom: 0.9,
68
- pivotX: VIEW / 2,
69
- pivotY: VIEW * 0.78,
77
+ pivotX: windowSize().width / 2,
78
+ pivotY: windowSize().height * 0.78,
70
79
  })
71
80
  })
72
81
 
@@ -79,11 +88,11 @@ function App() {
79
88
  }, 500)
80
89
 
81
90
  return (
82
- <window alignItems="center" justifyContent="center">
83
- <view width={VIEW} height={VIEW} overflow="clip">
91
+ <window fullscreen>
92
+ <view width={pct(100)} height={pct(100)} overflow="clip">
84
93
  {/* Ground color: never-written regions render nothing, so the
85
94
  full-bleed backdrop is the container's, not the layer's. */}
86
- <d-rect x={0} y={0} w={VIEW} h={VIEW} color="#0d0d17" />
95
+ <d-rect x={0} y={0} w={windowSize().width} h={windowSize().height} color="#0d0d17" />
87
96
  <TileLayer
88
97
  cols={COLS}
89
98
  rows={ROWS}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/2d",
3
- "version": "0.0.55",
3
+ "version": "0.0.56",
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.4",
19
- "@solidrt/core": "0.0.55"
18
+ "@solidjs/signals": "2.0.0-rc.6",
19
+ "@solidrt/core": "0.0.56"
20
20
  }
21
21
  }