@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.
- package/AGENTS.md +417 -70
- package/README.md +3 -3
- package/examples/README.md +49 -7
- package/examples/anim.tsx +84 -0
- package/examples/camera-probe.tsx +202 -0
- package/examples/camera.tsx +196 -0
- package/examples/parity.tsx +15 -13
- package/examples/pick.tsx +89 -36
- package/examples/split-screen.tsx +111 -0
- package/examples/springs.tsx +6 -9
- package/examples/sprites.tsx +9 -12
- package/examples/tiles.tsx +42 -18
- package/examples/views.tsx +177 -0
- package/package.json +3 -3
- package/src/animation.ts +178 -0
- package/src/atlas.ts +35 -33
- package/src/camera.ts +111 -0
- package/src/camera2d.ts +713 -0
- package/src/components/auto-oversample.ts +27 -0
- package/src/components/camera2d.tsx +82 -0
- package/src/components/context.ts +48 -0
- package/src/components/group.tsx +68 -0
- package/src/components/index.ts +28 -0
- package/src/components/sprite-layer.tsx +312 -0
- package/src/components/sprite.tsx +72 -0
- package/src/components/tile-layer.tsx +160 -0
- package/src/components/view2d.tsx +151 -0
- package/src/dispatch.ts +231 -0
- package/src/frames.ts +63 -14
- package/src/index.ts +53 -12
- package/src/input.ts +50 -0
- package/src/layer.ts +716 -309
- package/src/oversample-math.ts +73 -0
- package/src/oversample.ts +39 -11
- package/src/records.ts +109 -109
- package/src/shaders.ts +93 -14
- package/src/tiles.ts +256 -48
- package/src/views.ts +294 -0
- package/src/components.tsx +0 -357
package/examples/README.md
CHANGED
|
@@ -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),
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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))
|
package/examples/parity.tsx
CHANGED
|
@@ -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(
|
|
47
|
-
let nodes = createSpriteLayer(
|
|
48
|
-
let records = createRecordLayer(
|
|
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(
|
|
93
|
-
let b = readTexture(
|
|
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={
|
|
199
|
-
<texture src={
|
|
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
|
}
|