@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.
- package/AGENTS.md +241 -57
- package/examples/README.md +22 -5
- package/examples/anim.tsx +86 -0
- package/examples/camera-probe.tsx +200 -0
- package/examples/camera.tsx +190 -0
- package/examples/parity.tsx +4 -4
- package/examples/pick.tsx +50 -33
- package/examples/tiles.tsx +19 -10
- package/package.json +3 -3
- package/src/animation.ts +178 -0
- package/src/camera-motion.ts +532 -0
- package/src/camera.ts +86 -0
- package/src/camera2d.ts +140 -0
- package/src/components.tsx +330 -76
- package/src/dispatch.ts +231 -0
- package/src/index.ts +30 -5
- package/src/layer.ts +454 -186
- package/src/oversample-math.ts +73 -0
- package/src/oversample.ts +39 -11
- package/src/records.ts +132 -27
- package/src/shaders.ts +29 -12
- package/src/tiles.ts +79 -24
|
@@ -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))
|
package/examples/parity.tsx
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
|
39
|
-
<SpriteLayer
|
|
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 =>
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}))
|
package/examples/tiles.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
66
|
-
rotation: -(t + Math.PI / 2),
|
|
75
|
+
rotation: -heading - Math.PI / 2,
|
|
67
76
|
zoom: 0.9,
|
|
68
|
-
pivotX:
|
|
69
|
-
pivotY:
|
|
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
|
|
83
|
-
<view width={
|
|
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={
|
|
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.
|
|
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.
|
|
19
|
-
"@solidrt/core": "0.0.
|
|
18
|
+
"@solidjs/signals": "2.0.0-rc.6",
|
|
19
|
+
"@solidrt/core": "0.0.56"
|
|
20
20
|
}
|
|
21
21
|
}
|