@solidrt/2d 0.0.56 → 0.0.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +560 -267
- package/README.md +3 -3
- package/examples/README.md +38 -13
- package/examples/anim.tsx +6 -8
- package/examples/camera-probe.tsx +16 -14
- package/examples/camera.tsx +21 -15
- package/examples/parity.tsx +11 -9
- package/examples/pick.tsx +43 -7
- package/examples/split-screen.tsx +111 -0
- package/examples/springs.tsx +6 -9
- package/examples/sprites.tsx +9 -12
- package/examples/tiles.tsx +24 -9
- package/examples/views.tsx +177 -0
- package/package.json +2 -2
- package/src/atlas.ts +35 -33
- package/src/camera.ts +26 -1
- package/src/camera2d.ts +692 -119
- 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 +7 -7
- package/src/frames.ts +63 -14
- package/src/index.ts +27 -11
- package/src/input.ts +50 -0
- package/src/layer.ts +384 -245
- package/src/oversample-math.ts +1 -1
- package/src/records.ts +33 -138
- package/src/shaders.ts +65 -3
- package/src/tiles.ts +195 -42
- package/src/views.ts +294 -0
- package/src/camera-motion.ts +0 -532
- package/src/components.tsx +0 -611
package/README.md
CHANGED
|
@@ -7,12 +7,12 @@ texture element.
|
|
|
7
7
|
*Status: experimental. Expect API churn.*
|
|
8
8
|
|
|
9
9
|
```tsx
|
|
10
|
-
import { render } from "@solidrt/core"
|
|
10
|
+
import { decodeImage, render } from "@solidrt/core"
|
|
11
11
|
import { createAtlas, grid, Sprite, SpriteLayer } from "@solidrt/2d"
|
|
12
12
|
import sheet from "./sheet.png" with { type: "binary" }
|
|
13
13
|
|
|
14
|
-
let atlas = createAtlas(sheet, { filter: "nearest" })
|
|
15
|
-
let frames = grid(
|
|
14
|
+
let atlas = createAtlas(decodeImage(sheet), { filter: "nearest" })
|
|
15
|
+
let frames = grid(atlas, 4, 4)
|
|
16
16
|
|
|
17
17
|
render(() => (
|
|
18
18
|
<window>
|
package/examples/README.md
CHANGED
|
@@ -12,27 +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
|
-
|
|
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.
|
|
17
19
|
- `anim.tsx` - frame animation with `createAnimation`: three sprites share
|
|
18
20
|
one looping clip (one clock, stepped by wall time independent of display
|
|
19
21
|
rate) and a one-shot clip holds its last frame and fires `onEnd`.
|
|
20
22
|
Self-asserting - watch the logs for ANIM-OK.
|
|
21
23
|
- `camera.tsx` - the 2d camera (`createCamera2d`) over a world larger than
|
|
22
|
-
the window,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
`
|
|
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.
|
|
31
35
|
- `pick.tsx` - the event model through the component face: exact
|
|
32
36
|
rotated-rect hit testing topmost-first, `onTap` (the dispatch's own
|
|
33
37
|
click, no slop bookkeeping), a claimed press dragging a sprite under a
|
|
34
38
|
`<Camera2d>` that pans empty space and zooms on wheel, the layer's own
|
|
35
39
|
`onTap` seeing misses with `e.sprite` null (and `tapCount` for double
|
|
36
40
|
taps), and removal through a signal so `<For>` unmounts the `<Sprite>`
|
|
37
|
-
and the layer recycles the slot.
|
|
38
|
-
|
|
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.
|
package/examples/anim.tsx
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// The atlas is the core logo sliced 2x2 by grid(): a 4-frame "clip". Real
|
|
8
8
|
// art would slice a sheet the same way and pass a consecutive slice of the
|
|
9
9
|
// frames array per animation.
|
|
10
|
-
import { render } from "@solidrt/core"
|
|
10
|
+
import { decodeImage, render } from "@solidrt/core"
|
|
11
11
|
import { addSprite, createAnimation, createAtlas, createSpriteLayer, getSprite, grid } from "@solidrt/2d"
|
|
12
12
|
import type { Frame, SpriteHandle } from "@solidrt/2d"
|
|
13
13
|
import logoBytes from "./logo.png" with { type: "binary" }
|
|
@@ -21,12 +21,10 @@ const LOOP_FPS = 2
|
|
|
21
21
|
const SHOT_FPS = 5
|
|
22
22
|
|
|
23
23
|
function App() {
|
|
24
|
-
let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
|
|
25
|
-
let frames = grid(
|
|
26
|
-
let layer = createSpriteLayer(
|
|
27
|
-
|
|
28
|
-
label: "anim",
|
|
29
|
-
})
|
|
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] })
|
|
30
28
|
|
|
31
29
|
// Three sprites share the looping clip - one clock steps all of them.
|
|
32
30
|
let looping = createAnimation(frames, LOOP_FPS)
|
|
@@ -78,7 +76,7 @@ function App() {
|
|
|
78
76
|
|
|
79
77
|
return (
|
|
80
78
|
<window alignItems="center" justifyContent="center">
|
|
81
|
-
<texture src={
|
|
79
|
+
<texture src={view.texture} width={W} height={H} />
|
|
82
80
|
</window>
|
|
83
81
|
)
|
|
84
82
|
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// 3. Pointer dispatch round-trips: a pointer at the projected screen
|
|
17
17
|
// point hits the sprite and reports its world coordinates.
|
|
18
18
|
// 4. Tile chunks bake with the pinned identity camera rotation.
|
|
19
|
-
import { onFrame, render } from "@solidrt/core"
|
|
19
|
+
import { decodeImage, onFrame, render } from "@solidrt/core"
|
|
20
20
|
import { readTexture } from "@solidrt/core/gpu"
|
|
21
21
|
import {
|
|
22
22
|
addSprite,
|
|
@@ -53,12 +53,14 @@ function rng(seed: number): () => number {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
function App() {
|
|
56
|
-
let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
|
|
57
|
-
let frames = grid(
|
|
58
|
-
let nodes = createSpriteLayer(
|
|
59
|
-
let records = createRecordLayer(
|
|
60
|
-
nodes.
|
|
61
|
-
records.
|
|
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)
|
|
62
64
|
|
|
63
65
|
// The same deterministic population on both layers, kept inside world
|
|
64
66
|
// 20..100 so its reach (~145 with rotation) stays clear of both sample
|
|
@@ -103,8 +105,8 @@ function App() {
|
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
let shaderCheck = () => {
|
|
106
|
-
let img = readTexture(
|
|
107
|
-
let n =
|
|
108
|
+
let img = readTexture(nodeView.texture)
|
|
109
|
+
let n = nodeView.oversample
|
|
108
110
|
let [sx, sy] = projectCamera(CAM, 320, 320)
|
|
109
111
|
check(sx > 8 && sx < W - 8 && sy > 8 && sy < H - 8, `projected probe on screen (${sx.toFixed(1)}, ${sy.toFixed(1)})`)
|
|
110
112
|
check(alphaAt(img, sx, sy, n) > 0, "probe center opaque at its PROJECTED pixel")
|
|
@@ -118,8 +120,8 @@ function App() {
|
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
let pixelParity = () => {
|
|
121
|
-
let a = readTexture(
|
|
122
|
-
let b = readTexture(
|
|
123
|
+
let a = readTexture(nodeView.texture)
|
|
124
|
+
let b = readTexture(recordView.texture)
|
|
123
125
|
if (a.width !== b.width || a.height !== b.height) {
|
|
124
126
|
check(false, `sizes differ: ${a.width}x${a.height} vs ${b.width}x${b.height}`)
|
|
125
127
|
return
|
|
@@ -146,7 +148,7 @@ function App() {
|
|
|
146
148
|
let [sx, sy] = projectCamera(CAM, 320, 320)
|
|
147
149
|
// Synthesize the element event the built-in leaf would deliver (layout
|
|
148
150
|
// null: localX/localY are layer pixels already).
|
|
149
|
-
|
|
151
|
+
nodeView.handlers.onPointerDown({
|
|
150
152
|
localX: sx,
|
|
151
153
|
localY: sy,
|
|
152
154
|
pointerId: 1,
|
|
@@ -191,8 +193,8 @@ function App() {
|
|
|
191
193
|
|
|
192
194
|
return (
|
|
193
195
|
<window flexDirection="row" alignItems="center" justifyContent="center" gap={8}>
|
|
194
|
-
<texture src={
|
|
195
|
-
<texture src={
|
|
196
|
+
<texture src={nodeView.texture} width={W} height={H} />
|
|
197
|
+
<texture src={recordView.texture} width={W} height={H} />
|
|
196
198
|
</window>
|
|
197
199
|
)
|
|
198
200
|
}
|
package/examples/camera.tsx
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
// { follow, spin } or runs { fit: true }, `selected` returns the selected
|
|
18
18
|
// sprite's world position (null when none), `first` the first sprite's -
|
|
19
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"
|
|
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
22
|
import type { Camera2dHandle, SpriteHandle } from "@solidrt/2d"
|
|
23
23
|
import { registerDebug } from "srt:dev"
|
|
24
24
|
import logoBytes from "./logo.png" with { type: "binary" }
|
|
@@ -55,15 +55,14 @@ let first: SpriteHandle | null = null
|
|
|
55
55
|
let dragging: SpriteHandle | null = null
|
|
56
56
|
|
|
57
57
|
function App() {
|
|
58
|
-
let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
|
|
59
|
-
let frames = grid(
|
|
58
|
+
let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
|
|
59
|
+
let frames = grid(atlas, 2, 2)
|
|
60
60
|
// The window's logical size, mirrored for the layer and the camera.
|
|
61
61
|
let win = { width: 1, height: 1 }
|
|
62
|
-
let layer = createSpriteLayer(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
})
|
|
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] })
|
|
67
66
|
let select = (sprite: SpriteHandle | null) => {
|
|
68
67
|
if (selected) setSprite(selected, { tint: TINT })
|
|
69
68
|
selected = sprite
|
|
@@ -94,16 +93,23 @@ function App() {
|
|
|
94
93
|
}
|
|
95
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] })
|
|
96
95
|
|
|
97
|
-
cam = createCamera2d(
|
|
96
|
+
cam = createCamera2d(view, {
|
|
98
97
|
viewport: () => win,
|
|
99
98
|
world: WORLD,
|
|
100
99
|
maxZoom: MAX_ZOOM,
|
|
101
100
|
deadZone: DEAD_ZONE,
|
|
102
101
|
})
|
|
103
|
-
|
|
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)
|
|
104
110
|
// A tap that reached the root landed on empty space (a sprite's tap
|
|
105
111
|
// still bubbles here with e.sprite set, so the miss is the null case).
|
|
106
|
-
|
|
112
|
+
view.listen({
|
|
107
113
|
onTap: e => {
|
|
108
114
|
if (e.sprite) return
|
|
109
115
|
select(null)
|
|
@@ -117,8 +123,8 @@ function App() {
|
|
|
117
123
|
({ size, scale }) => {
|
|
118
124
|
win.width = Math.max(1, size.width)
|
|
119
125
|
win.height = Math.max(1, size.height)
|
|
120
|
-
|
|
121
|
-
|
|
126
|
+
view.setSize(win.width, win.height)
|
|
127
|
+
view.setOversample(fitOversample(scale, win.width, win.height, win.width * win.height * scale * scale))
|
|
122
128
|
},
|
|
123
129
|
)
|
|
124
130
|
|
|
@@ -152,7 +158,7 @@ function App() {
|
|
|
152
158
|
}
|
|
153
159
|
}}
|
|
154
160
|
>
|
|
155
|
-
<texture src={
|
|
161
|
+
<texture src={view.texture} position="absolute" left={0} top={0} width={windowSize().width} height={windowSize().height} {...view.handlers} />
|
|
156
162
|
<view pointerEvents="none" gap={6} padding={20}>
|
|
157
163
|
<text color="#eef4ff" fontSize={24} fontWeight={700}>
|
|
158
164
|
Camera
|
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
|
|
@@ -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
|
}
|
package/examples/pick.tsx
CHANGED
|
@@ -10,10 +10,16 @@
|
|
|
10
10
|
// and waits for the next add); structure lives in a signal, so <For>
|
|
11
11
|
// unmounts the removed <Sprite>. A tap on empty space reaches the layer
|
|
12
12
|
// with e.sprite null. The layer FILLS the window (no width/height): layer
|
|
13
|
-
// pixels are the leaf's own coordinates.
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
// pixels are the leaf's own coordinates. A corner <View2d> renders the
|
|
14
|
+
// same sprites again under a <Camera2d> of its own: drag or wheel there
|
|
15
|
+
// moves only the inset's camera, while a sprite tapped or dragged in the
|
|
16
|
+
// inset responds exactly as in the main view (the same handlers, the
|
|
17
|
+
// view's camera undone). The `state` debug command returns both camera
|
|
18
|
+
// poses and every sprite's world position; `maxZoom` sets the main
|
|
19
|
+
// <Camera2d maxZoom> prop live (a tighter range re-clamps the pose at
|
|
20
|
+
// once - the props are live, only the pose props are initial values).
|
|
21
|
+
import { createInputMap, createPointerFeed, createSignal, decodeImage, render, For } from "@solidrt/core"
|
|
22
|
+
import { Camera2d, camera2dActions, camera2dBindings, createAtlas, grid, setSprite, Sprite, SpriteLayer, View2d } from "@solidrt/2d"
|
|
17
23
|
import type { Camera2dHandle, Frame, SpriteHandle } from "@solidrt/2d"
|
|
18
24
|
import { registerDebug } from "srt:dev"
|
|
19
25
|
import logoBytes from "./logo.png" with { type: "binary" }
|
|
@@ -25,21 +31,37 @@ const TINTS: [number, number, number, number][] = [
|
|
|
25
31
|
[0.55, 0.7, 1, 1],
|
|
26
32
|
]
|
|
27
33
|
const MAX_ZOOM = 4
|
|
34
|
+
// The inset: view pixels, its margin from the window's corner, and the
|
|
35
|
+
// zoom that shows the four authored sprites whole inside it.
|
|
36
|
+
const INSET_WIDTH = 240
|
|
37
|
+
const INSET_HEIGHT = 160
|
|
38
|
+
const INSET_MARGIN = 16
|
|
39
|
+
const INSET_ZOOM = 0.25
|
|
28
40
|
|
|
29
41
|
type Item = { id: number; x: number; y: number; frame: Frame }
|
|
30
42
|
|
|
31
43
|
let cam: Camera2dHandle | undefined
|
|
44
|
+
let insetCam: Camera2dHandle | undefined
|
|
32
45
|
let handles = new Map<number, SpriteHandle>()
|
|
46
|
+
let [maxZoom, setMaxZoom] = createSignal(MAX_ZOOM)
|
|
33
47
|
|
|
34
48
|
function App() {
|
|
35
|
-
let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
|
|
36
|
-
let frames = grid(
|
|
49
|
+
let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
|
|
50
|
+
let frames = grid(atlas, 2, 2)
|
|
37
51
|
let [items, setItems] = createSignal<Item[]>([
|
|
38
52
|
{ id: 0, x: 200, y: 240, frame: frames[0]! },
|
|
39
53
|
{ id: 1, x: 420, y: 300, frame: frames[1]! },
|
|
40
54
|
{ id: 2, x: 300, y: 470, frame: frames[2]! },
|
|
41
55
|
{ id: 3, x: 520, y: 500, frame: frames[3]! },
|
|
42
56
|
])
|
|
57
|
+
// One pointer feed and map per view: the layer's own view and the inset
|
|
58
|
+
// each drive their <Camera2d> from their own gestures and no other's.
|
|
59
|
+
let pointer = createPointerFeed()
|
|
60
|
+
let input = createInputMap(camera2dActions)
|
|
61
|
+
input.bind(camera2dBindings({ pointer }))
|
|
62
|
+
let insetPointer = createPointerFeed()
|
|
63
|
+
let insetInput = createInputMap(camera2dActions)
|
|
64
|
+
insetInput.bind(camera2dBindings({ pointer: insetPointer }))
|
|
43
65
|
|
|
44
66
|
return (
|
|
45
67
|
<window>
|
|
@@ -47,13 +69,14 @@ function App() {
|
|
|
47
69
|
atlas={atlas.texture}
|
|
48
70
|
capacity={64}
|
|
49
71
|
clearColor={[0.05, 0.05, 0.09, 1]}
|
|
72
|
+
pointer={pointer}
|
|
50
73
|
onTap={e => {
|
|
51
74
|
if (e.sprite === null) console.log(`tap on empty space at ${e.x.toFixed(0)}, ${e.y.toFixed(0)} (x${e.tapCount})`)
|
|
52
75
|
}}
|
|
53
76
|
>
|
|
54
77
|
{/* A top-left pivot keeps world (0,0) at the leaf's corner, so the
|
|
55
78
|
authored positions read as window coordinates until panned. */}
|
|
56
|
-
<Camera2d maxZoom={
|
|
79
|
+
<Camera2d input={input} maxZoom={maxZoom()} pivot={{ x: 0, y: 0 }} ref={c => (cam = c)} />
|
|
57
80
|
<For each={items()}>
|
|
58
81
|
{item => {
|
|
59
82
|
let tintIndex = 0
|
|
@@ -94,6 +117,13 @@ function App() {
|
|
|
94
117
|
)
|
|
95
118
|
}}
|
|
96
119
|
</For>
|
|
120
|
+
{/* A laid-out element among the layer's children renders where it
|
|
121
|
+
sits; the <View2d> inside stays a layer child through context. */}
|
|
122
|
+
<view position="absolute" right={INSET_MARGIN} bottom={INSET_MARGIN}>
|
|
123
|
+
<View2d width={INSET_WIDTH} height={INSET_HEIGHT} clearColor={[0.12, 0.1, 0.16, 1]} label="inset" pointer={insetPointer}>
|
|
124
|
+
<Camera2d input={insetInput} zoom={INSET_ZOOM} maxZoom={MAX_ZOOM} pivot={{ x: 0, y: 0 }} ref={c => (insetCam = c)} />
|
|
125
|
+
</View2d>
|
|
126
|
+
</view>
|
|
97
127
|
</SpriteLayer>
|
|
98
128
|
</window>
|
|
99
129
|
)
|
|
@@ -101,7 +131,13 @@ function App() {
|
|
|
101
131
|
|
|
102
132
|
render(() => <App />)
|
|
103
133
|
|
|
134
|
+
registerDebug("maxZoom", (args?: { value?: number }) => {
|
|
135
|
+
if (typeof args?.value === "number") setMaxZoom(args.value)
|
|
136
|
+
return maxZoom()
|
|
137
|
+
})
|
|
138
|
+
|
|
104
139
|
registerDebug("state", () => ({
|
|
105
140
|
camera: cam?.camera() ?? null,
|
|
141
|
+
inset: insetCam?.camera() ?? null,
|
|
106
142
|
items: [...handles].filter(([, s]) => s.layer !== null).map(([id, s]) => ({ id, x: s._x, y: s._y })),
|
|
107
143
|
}))
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Split-screen: ONE layer of sprites shown through two views side by
|
|
2
|
+
// side, each a full viewport with a camera of its own - the layer has no
|
|
3
|
+
// output but its views (a Unity scene renders only through Cameras, a
|
|
4
|
+
// Godot World2D only through Viewports). The <SpriteLayer output={false}>
|
|
5
|
+
// owns the sprites and no leaf; its two <View2d> children fill the window
|
|
6
|
+
// half each and follow one roaming sprite apiece through a <Camera2d>
|
|
7
|
+
// with a dead zone, so the two poses differ every frame. Wheel inside a
|
|
8
|
+
// pane zooms that pane alone (the follow keeps tracking under the zoom),
|
|
9
|
+
// and so does the pad that joined it (press any button on it first);
|
|
10
|
+
// a tap on a sprite in either pane tints it in both, since there is only
|
|
11
|
+
// one sprite. The `cameras` debug command reads both poses back.
|
|
12
|
+
import { createInputMap, createPointerFeed, decodeImage, gamepad, onFrame, render, windowSize, For } from "@solidrt/core"
|
|
13
|
+
import { Camera2d, camera2dActions, camera2dBindings, createAtlas, grid, setSprite, Sprite, SpriteLayer, View2d } from "@solidrt/2d"
|
|
14
|
+
import type { Camera2dHandle, Frame, SpriteHandle } from "@solidrt/2d"
|
|
15
|
+
import { registerDebug } from "srt:dev"
|
|
16
|
+
import logoBytes from "./logo.png" with { type: "binary" }
|
|
17
|
+
|
|
18
|
+
const WORLD = { width: 2400, height: 1600 }
|
|
19
|
+
const COUNT = 200
|
|
20
|
+
const SPRITE = 48
|
|
21
|
+
const ROAMER = 72
|
|
22
|
+
// Roaming paths: angular rates (rad/s) and the fraction of the world
|
|
23
|
+
// each roamer sweeps.
|
|
24
|
+
const ROAM_RATE = [0.23, 0.31, 0.17, 0.29]
|
|
25
|
+
const ROAM_SPAN = 0.4
|
|
26
|
+
// The follow dead zone, as viewport fractions.
|
|
27
|
+
const DEAD_ZONE = { width: 0.2, height: 0.25 }
|
|
28
|
+
const PANE_ZOOM = 1.5
|
|
29
|
+
const MAX_ZOOM = 4
|
|
30
|
+
const TINT: [number, number, number, number] = [0.8, 0.85, 0.95, 1]
|
|
31
|
+
const HIT: [number, number, number, number] = [1, 0.5, 0.5, 1]
|
|
32
|
+
|
|
33
|
+
type Item = { id: number; x: number; y: number; frame: Frame }
|
|
34
|
+
|
|
35
|
+
let cams: (Camera2dHandle | undefined)[] = [undefined, undefined]
|
|
36
|
+
let roamers: (SpriteHandle | undefined)[] = [undefined, undefined]
|
|
37
|
+
|
|
38
|
+
function App() {
|
|
39
|
+
let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
|
|
40
|
+
let frames = grid(atlas, 2, 2)
|
|
41
|
+
let items: Item[] = []
|
|
42
|
+
for (let i = 0; i < COUNT; i++) {
|
|
43
|
+
items.push({ id: i, x: Math.random() * WORLD.width, y: Math.random() * WORLD.height, frame: frames[i % 4]! })
|
|
44
|
+
}
|
|
45
|
+
// The window halves, live through windowSize (its first read is 0x0,
|
|
46
|
+
// hence the floor of 1): the views resize with the window.
|
|
47
|
+
let pane = () => ({ width: Math.max(1, Math.floor(windowSize().width / 2)), height: Math.max(1, windowSize().height) })
|
|
48
|
+
// The roamers move at frame rate; each <Camera2d> follows its own and
|
|
49
|
+
// runs its frames itself while the follow is settling.
|
|
50
|
+
onFrame(tick => {
|
|
51
|
+
let t = tick / 1000
|
|
52
|
+
for (let i = 0; i < 2; i++) {
|
|
53
|
+
let x = WORLD.width / 2 + Math.sin(t * ROAM_RATE[i * 2]!) * WORLD.width * ROAM_SPAN
|
|
54
|
+
let y = WORLD.height / 2 + Math.cos(t * ROAM_RATE[i * 2 + 1]!) * WORLD.height * ROAM_SPAN
|
|
55
|
+
let roamer = roamers[i]
|
|
56
|
+
if (roamer) setSprite(roamer, { x, y, rotation: t })
|
|
57
|
+
cams[i]?.follow(x, y)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
// Two players, two maps: each pane's <Camera2d> takes its own pane's
|
|
61
|
+
// gestures and its own pad, and nothing crosses over. The pads seat in
|
|
62
|
+
// pick-up order: the first pad to press any button joins pane 0, the
|
|
63
|
+
// next joins pane 1 (gamepad.next(); gamepad(i) would pin the slots).
|
|
64
|
+
let paneView = (i: number) => {
|
|
65
|
+
let pointer = createPointerFeed()
|
|
66
|
+
let input = createInputMap(camera2dActions)
|
|
67
|
+
input.bind(camera2dBindings({ pointer, gamepad: gamepad.next() }))
|
|
68
|
+
return (
|
|
69
|
+
<View2d width={pane().width} height={pane().height} clearColor={i === 0 ? [0.05, 0.05, 0.09, 1] : [0.09, 0.05, 0.05, 1]} label={`pane-${i}`} pointer={pointer}>
|
|
70
|
+
<Camera2d input={input} world={WORLD} zoom={PANE_ZOOM} maxZoom={MAX_ZOOM} deadZone={DEAD_ZONE} ref={c => (cams[i] = c)} />
|
|
71
|
+
</View2d>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
return (
|
|
75
|
+
<window>
|
|
76
|
+
<SpriteLayer atlas={atlas.texture} capacity={COUNT + 2} output={false}>
|
|
77
|
+
<view flexDirection="row" width="100%" height="100%">
|
|
78
|
+
{paneView(0)}
|
|
79
|
+
{paneView(1)}
|
|
80
|
+
</view>
|
|
81
|
+
<For each={items}>
|
|
82
|
+
{item => {
|
|
83
|
+
let handle: SpriteHandle | undefined
|
|
84
|
+
let hit = false
|
|
85
|
+
return (
|
|
86
|
+
<Sprite
|
|
87
|
+
ref={s => (handle = s)}
|
|
88
|
+
x={item.x}
|
|
89
|
+
y={item.y}
|
|
90
|
+
w={SPRITE}
|
|
91
|
+
h={SPRITE}
|
|
92
|
+
frame={item.frame}
|
|
93
|
+
tint={TINT}
|
|
94
|
+
onTap={() => {
|
|
95
|
+
hit = !hit
|
|
96
|
+
if (handle) setSprite(handle, { tint: hit ? HIT : TINT })
|
|
97
|
+
}}
|
|
98
|
+
/>
|
|
99
|
+
)
|
|
100
|
+
}}
|
|
101
|
+
</For>
|
|
102
|
+
<Sprite ref={s => (roamers[0] = s)} x={WORLD.width / 2} y={WORLD.height / 2} w={ROAMER} h={ROAMER} frame={frames[0]!} tint={[1, 0.8, 0.3, 1]} />
|
|
103
|
+
<Sprite ref={s => (roamers[1] = s)} x={WORLD.width / 2} y={WORLD.height / 2} w={ROAMER} h={ROAMER} frame={frames[3]!} tint={[0.4, 0.9, 1, 1]} />
|
|
104
|
+
</SpriteLayer>
|
|
105
|
+
</window>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
render(() => <App />)
|
|
110
|
+
|
|
111
|
+
registerDebug("cameras", () => cams.map(c => c?.camera() ?? null))
|
package/examples/springs.tsx
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// random angle always takes the short arc, and the position spring keeps
|
|
14
14
|
// its velocity when a shuffle lands mid-flight - retarget as fast as you
|
|
15
15
|
// like, the motion stays continuous.
|
|
16
|
-
import { render } from "@solidrt/core"
|
|
16
|
+
import { decodeImage, render } from "@solidrt/core"
|
|
17
17
|
import { addSprite, createAtlas, createSpriteLayer, grid, setSprite, setSpriteTransition } from "@solidrt/2d"
|
|
18
18
|
import logoBytes from "./logo.png" with { type: "binary" }
|
|
19
19
|
|
|
@@ -25,13 +25,10 @@ const H = 720
|
|
|
25
25
|
const SPRITE = 30
|
|
26
26
|
|
|
27
27
|
function App() {
|
|
28
|
-
let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
|
|
29
|
-
let frames = grid(
|
|
30
|
-
let layer = createSpriteLayer(
|
|
31
|
-
|
|
32
|
-
clearColor: [0.05, 0.05, 0.09, 1],
|
|
33
|
-
label: "springs",
|
|
34
|
-
})
|
|
28
|
+
let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
|
|
29
|
+
let frames = grid(atlas, 2, 2)
|
|
30
|
+
let layer = createSpriteLayer(atlas.texture, { capacity: COUNT, label: "springs" })
|
|
31
|
+
let view = layer.createView({ width: W, height: H, clearColor: [0.05, 0.05, 0.09, 1] })
|
|
35
32
|
|
|
36
33
|
let slotX = (slot: number) => ((slot % COLS) + 0.5) * (W / COLS)
|
|
37
34
|
let slotY = (slot: number) => (Math.floor(slot / COLS) + 0.5) * (H / ROWS)
|
|
@@ -61,7 +58,7 @@ function App() {
|
|
|
61
58
|
|
|
62
59
|
return (
|
|
63
60
|
<window alignItems="center" justifyContent="center">
|
|
64
|
-
<texture src={
|
|
61
|
+
<texture src={view.texture} width={W} height={H} />
|
|
65
62
|
</window>
|
|
66
63
|
)
|
|
67
64
|
}
|
package/examples/sprites.tsx
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
// instance-buffer write per frame.
|
|
9
9
|
//
|
|
10
10
|
// The atlas is a real image (core's logo) sliced 2x2 by grid(): four frames,
|
|
11
|
-
// each sprite drawing one quarter.
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
import { onFrame, render } from "@solidrt/core"
|
|
11
|
+
// each sprite drawing one quarter. Pixels built in code go to createAtlas as
|
|
12
|
+
// they are; this path decodes PNG bytes imported with { type: "binary" }
|
|
13
|
+
// first.
|
|
14
|
+
import { decodeImage, onFrame, render } from "@solidrt/core"
|
|
15
15
|
import { addSprite, createAtlas, createSpriteLayer, grid, setSprite } from "@solidrt/2d"
|
|
16
16
|
import logoBytes from "./logo.png" with { type: "binary" }
|
|
17
17
|
|
|
@@ -21,13 +21,10 @@ const H = 720
|
|
|
21
21
|
const SPRITE = 48
|
|
22
22
|
|
|
23
23
|
function App() {
|
|
24
|
-
let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
|
|
25
|
-
let frames = grid(
|
|
26
|
-
let layer = createSpriteLayer(
|
|
27
|
-
|
|
28
|
-
clearColor: [0.05, 0.05, 0.09, 1],
|
|
29
|
-
label: "bounce",
|
|
30
|
-
})
|
|
24
|
+
let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
|
|
25
|
+
let frames = grid(atlas, 2, 2)
|
|
26
|
+
let layer = createSpriteLayer(atlas.texture, { capacity: COUNT, label: "bounce" })
|
|
27
|
+
let view = layer.createView({ width: W, height: H, clearColor: [0.05, 0.05, 0.09, 1] })
|
|
31
28
|
|
|
32
29
|
// Simulation state lives in plain arrays; the layer holds the published
|
|
33
30
|
// snapshot of it.
|
|
@@ -73,7 +70,7 @@ function App() {
|
|
|
73
70
|
|
|
74
71
|
return (
|
|
75
72
|
<window alignItems="center" justifyContent="center">
|
|
76
|
-
<texture src={
|
|
73
|
+
<texture src={view.texture} width={W} height={H} />
|
|
77
74
|
</window>
|
|
78
75
|
)
|
|
79
76
|
}
|