@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/pick.tsx
CHANGED
|
@@ -1,76 +1,111 @@
|
|
|
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
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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. 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"
|
|
23
|
+
import type { Camera2dHandle, Frame, SpriteHandle } from "@solidrt/2d"
|
|
24
|
+
import { registerDebug } from "srt:dev"
|
|
13
25
|
import logoBytes from "./logo.png" with { type: "binary" }
|
|
14
26
|
|
|
15
|
-
const W = 720
|
|
16
|
-
const H = 720
|
|
17
|
-
|
|
18
27
|
const TINTS: [number, number, number, number][] = [
|
|
19
28
|
[1, 1, 1, 1],
|
|
20
29
|
[1, 0.5, 0.5, 1],
|
|
21
30
|
[0.5, 1, 0.6, 1],
|
|
22
31
|
[0.55, 0.7, 1, 1],
|
|
23
32
|
]
|
|
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
|
|
24
40
|
|
|
25
41
|
type Item = { id: number; x: number; y: number; frame: Frame }
|
|
26
42
|
|
|
43
|
+
let cam: Camera2dHandle | undefined
|
|
44
|
+
let insetCam: Camera2dHandle | undefined
|
|
45
|
+
let handles = new Map<number, SpriteHandle>()
|
|
46
|
+
let [maxZoom, setMaxZoom] = createSignal(MAX_ZOOM)
|
|
47
|
+
|
|
27
48
|
function App() {
|
|
28
|
-
let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
|
|
29
|
-
let frames = grid(
|
|
49
|
+
let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
|
|
50
|
+
let frames = grid(atlas, 2, 2)
|
|
30
51
|
let [items, setItems] = createSignal<Item[]>([
|
|
31
52
|
{ id: 0, x: 200, y: 240, frame: frames[0]! },
|
|
32
53
|
{ id: 1, x: 420, y: 300, frame: frames[1]! },
|
|
33
54
|
{ id: 2, x: 300, y: 470, frame: frames[2]! },
|
|
34
55
|
{ id: 3, x: 520, y: 500, frame: frames[3]! },
|
|
35
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 }))
|
|
36
65
|
|
|
37
66
|
return (
|
|
38
|
-
<window
|
|
39
|
-
<SpriteLayer
|
|
67
|
+
<window>
|
|
68
|
+
<SpriteLayer
|
|
69
|
+
atlas={atlas.texture}
|
|
70
|
+
capacity={64}
|
|
71
|
+
clearColor={[0.05, 0.05, 0.09, 1]}
|
|
72
|
+
pointer={pointer}
|
|
73
|
+
onTap={e => {
|
|
74
|
+
if (e.sprite === null) console.log(`tap on empty space at ${e.x.toFixed(0)}, ${e.y.toFixed(0)} (x${e.tapCount})`)
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
{/* A top-left pivot keeps world (0,0) at the leaf's corner, so the
|
|
78
|
+
authored positions read as window coordinates until panned. */}
|
|
79
|
+
<Camera2d input={input} maxZoom={maxZoom()} pivot={{ x: 0, y: 0 }} ref={c => (cam = c)} />
|
|
40
80
|
<For each={items()}>
|
|
41
81
|
{item => {
|
|
42
82
|
let tintIndex = 0
|
|
43
|
-
let down = false
|
|
44
|
-
let moved = false
|
|
45
|
-
let downX = 0
|
|
46
|
-
let downY = 0
|
|
47
83
|
let handle: SpriteHandle | undefined
|
|
84
|
+
// Whether this sprite holds a press: onPointerMove also fires
|
|
85
|
+
// on plain hover, and only a captured move drags.
|
|
86
|
+
let pressed = false
|
|
48
87
|
return (
|
|
49
88
|
<Sprite
|
|
50
|
-
ref={s =>
|
|
89
|
+
ref={s => {
|
|
90
|
+
handle = s
|
|
91
|
+
handles.set(item.id, s)
|
|
92
|
+
}}
|
|
51
93
|
x={item.x}
|
|
52
94
|
y={item.y}
|
|
53
95
|
w={96}
|
|
54
96
|
h={96}
|
|
55
97
|
frame={item.frame}
|
|
98
|
+
// Claim the press: the camera never sees this pointer, so
|
|
99
|
+
// the captured moves drag the sprite, not the view.
|
|
56
100
|
onPointerDown={e => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
downX = e.x
|
|
60
|
-
downY = e.y
|
|
101
|
+
e.stopPropagation()
|
|
102
|
+
pressed = true
|
|
61
103
|
}}
|
|
104
|
+
onPointerUp={() => (pressed = false)}
|
|
62
105
|
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 })
|
|
106
|
+
if (pressed && handle) setSprite(handle, { x: e.x, y: e.y })
|
|
70
107
|
}}
|
|
71
|
-
|
|
72
|
-
down = false
|
|
73
|
-
if (moved) return
|
|
108
|
+
onTap={e => {
|
|
74
109
|
if (e.shiftKey) {
|
|
75
110
|
setItems(items().filter(other => other.id !== item.id))
|
|
76
111
|
return
|
|
@@ -82,9 +117,27 @@ function App() {
|
|
|
82
117
|
)
|
|
83
118
|
}}
|
|
84
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>
|
|
85
127
|
</SpriteLayer>
|
|
86
128
|
</window>
|
|
87
129
|
)
|
|
88
130
|
}
|
|
89
131
|
|
|
90
132
|
render(() => <App />)
|
|
133
|
+
|
|
134
|
+
registerDebug("maxZoom", (args?: { value?: number }) => {
|
|
135
|
+
if (typeof args?.value === "number") setMaxZoom(args.value)
|
|
136
|
+
return maxZoom()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
registerDebug("state", () => ({
|
|
140
|
+
camera: cam?.camera() ?? null,
|
|
141
|
+
inset: insetCam?.camera() ?? null,
|
|
142
|
+
items: [...handles].filter(([, s]) => s.layer !== null).map(([id, s]) => ({ id, x: s._x, y: s._y })),
|
|
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
|
}
|
package/examples/tiles.tsx
CHANGED
|
@@ -7,41 +7,59 @@
|
|
|
7
7
|
// The camera flies a ship-style path: a fixed screen pivot near the bottom
|
|
8
8
|
// of the viewport, the world panning and ROTATING under it so the flight
|
|
9
9
|
// heading always points up - the <TileLayer> camera prop with rotation and
|
|
10
|
-
// pivot (a leaf transform, never a re-bake).
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// pivot (a leaf transform, never a re-bake). The world is seeded by ONE
|
|
11
|
+
// setTiles rect write over a Uint16Array of frame indices (-1 where the
|
|
12
|
+
// world is empty), the shape a generator or a worker produces; a timer
|
|
13
|
+
// edits tiles while it runs: beacon markers along the road's center line
|
|
14
|
+
// blink, and each blink's batch of setTile calls re-bakes only the chunks
|
|
15
|
+
// the beacons land in.
|
|
13
16
|
//
|
|
14
17
|
// The atlas is the core logo sliced 2x2 by grid(); a real game would slice
|
|
15
|
-
// a tileset sheet the same way
|
|
16
|
-
|
|
18
|
+
// a tileset sheet the same way, and the array is the layer's `frames`
|
|
19
|
+
// table the indices name.
|
|
20
|
+
//
|
|
21
|
+
// Debug commands: `state` (resident chunk count) and `cell` ({ col, row }:
|
|
22
|
+
// the frame at a cell, null when empty).
|
|
23
|
+
import { createSignal, decodeImage, onFrame, pct, render, windowSize } from "@solidrt/core"
|
|
17
24
|
import { createAtlas, grid, TileLayer } from "@solidrt/2d"
|
|
18
25
|
import type { TileCamera, TileLayerHandle } from "@solidrt/2d"
|
|
26
|
+
import { registerDebug } from "srt:dev"
|
|
19
27
|
import logoBytes from "./logo.png" with { type: "binary" }
|
|
20
28
|
|
|
21
29
|
const COLS = 128
|
|
22
30
|
const ROWS = 128
|
|
23
31
|
const TILE = 48
|
|
24
|
-
const VIEW = 720
|
|
25
32
|
const WORLD = COLS * TILE
|
|
26
33
|
|
|
34
|
+
// onFrame tick gap that logs as a hitch, as a factor of the display's
|
|
35
|
+
// refresh period (onFrame's rate argument, so any refresh rate works):
|
|
36
|
+
// past 1.5 periods a frame slot was missed while this animation ran.
|
|
37
|
+
const HITCH_FACTOR = 1.5
|
|
38
|
+
|
|
27
39
|
function App() {
|
|
28
|
-
let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
|
|
29
|
-
let frames = grid(
|
|
40
|
+
let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
|
|
41
|
+
let frames = grid(atlas, 2, 2)
|
|
30
42
|
|
|
31
43
|
let layer!: TileLayerHandle
|
|
32
44
|
let seed = (l: TileLayerHandle) => {
|
|
33
45
|
layer = l
|
|
34
46
|
// A solid ring "road" around the world center: continuous under the
|
|
35
47
|
// flight path, empty everywhere else - the empty regions are the point,
|
|
36
|
-
// their chunks never allocate
|
|
48
|
+
// their chunks never allocate, since a rect write allocates a chunk
|
|
49
|
+
// only for the first frame it carries there. -1 is the clear (a
|
|
50
|
+
// Uint16Array holds it as 0xffff, the same bits).
|
|
37
51
|
let c = COLS / 2
|
|
52
|
+
let cells = new Uint16Array(COLS * ROWS).fill(-1)
|
|
38
53
|
for (let row = 0; row < ROWS; row++) {
|
|
39
54
|
for (let col = 0; col < COLS; col++) {
|
|
40
55
|
let d = Math.hypot(col - c, row - c)
|
|
41
|
-
if (d > 40 && d < 52)
|
|
56
|
+
if (d > 40 && d < 52) cells[row * COLS + col] = (col ^ row) % 4
|
|
42
57
|
}
|
|
43
58
|
}
|
|
59
|
+
l.setTiles(0, 0, COLS, ROWS, cells)
|
|
44
60
|
}
|
|
61
|
+
registerDebug("state", () => ({ chunks: layer.chunks.length }))
|
|
62
|
+
registerDebug("cell", (args?: { col?: number; row?: number }) => layer.getTile(args?.col ?? 0, args?.row ?? 0))
|
|
45
63
|
|
|
46
64
|
// Beacon cells on the road's center line every 7.5 degrees; the interval
|
|
47
65
|
// below blinks them between a marker frame and the road pattern.
|
|
@@ -56,17 +74,22 @@ function App() {
|
|
|
56
74
|
// near the viewport bottom. Per-frame camera motion is one signal write
|
|
57
75
|
// feeding the transform - never a re-bake.
|
|
58
76
|
let [camera, setCamera] = createSignal<TileCamera>({})
|
|
59
|
-
|
|
77
|
+
let lastTick = 0
|
|
78
|
+
onFrame((tick, _frame, rate) => {
|
|
79
|
+
if (lastTick && tick - lastTick > (1000 / rate) * HITCH_FACTOR) console.log(`[hitch] ${(tick - lastTick).toFixed(1)}ms frame gap`)
|
|
80
|
+
lastTick = tick
|
|
60
81
|
let t = tick / 6000
|
|
61
82
|
let radius = 46 * TILE
|
|
83
|
+
// The path C + r(cos t, sin t) has tangent heading t + pi/2; the
|
|
84
|
+
// camera convention renders a heading h upward at rotation -h - pi/2.
|
|
85
|
+
let heading = t + Math.PI / 2
|
|
62
86
|
setCamera({
|
|
63
87
|
x: WORLD / 2 + Math.cos(t) * radius,
|
|
64
88
|
y: WORLD / 2 + Math.sin(t) * radius,
|
|
65
|
-
|
|
66
|
-
rotation: -(t + Math.PI / 2),
|
|
89
|
+
rotation: -heading - Math.PI / 2,
|
|
67
90
|
zoom: 0.9,
|
|
68
|
-
pivotX:
|
|
69
|
-
pivotY:
|
|
91
|
+
pivotX: windowSize().width / 2,
|
|
92
|
+
pivotY: windowSize().height * 0.78,
|
|
70
93
|
})
|
|
71
94
|
})
|
|
72
95
|
|
|
@@ -79,17 +102,18 @@ function App() {
|
|
|
79
102
|
}, 500)
|
|
80
103
|
|
|
81
104
|
return (
|
|
82
|
-
<window
|
|
83
|
-
<view width={
|
|
105
|
+
<window fullscreen>
|
|
106
|
+
<view width={pct(100)} height={pct(100)} overflow="clip">
|
|
84
107
|
{/* Ground color: never-written regions render nothing, so the
|
|
85
108
|
full-bleed backdrop is the container's, not the layer's. */}
|
|
86
|
-
<d-rect x={0} y={0} w={
|
|
109
|
+
<d-rect x={0} y={0} w={windowSize().width} h={windowSize().height} color="#0d0d17" />
|
|
87
110
|
<TileLayer
|
|
88
111
|
cols={COLS}
|
|
89
112
|
rows={ROWS}
|
|
90
113
|
tileW={TILE}
|
|
91
114
|
tileH={TILE}
|
|
92
115
|
atlas={atlas.texture}
|
|
116
|
+
frames={frames}
|
|
93
117
|
camera={camera()}
|
|
94
118
|
label="tile-world"
|
|
95
119
|
ref={seed}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Layer views (layer.createView): the world rendered twice from one set of
|
|
2
|
+
// sprites - the window-filling main view under a pannable, zoomable camera
|
|
3
|
+
// and a corner MINIMAP showing the whole world, no sprite mirrored. A view
|
|
4
|
+
// is one more target over the layer's own instance buffers: the sprites
|
|
5
|
+
// are added once, the roamer moves once, and both renderings follow at
|
|
6
|
+
// zero per-frame JS beyond each camera's writes. The minimap carries the
|
|
7
|
+
// layer's pointer dispatch under its own camera: a tap on it glides the
|
|
8
|
+
// main camera to that world point (the view's root listener, `e.x`/`e.y`
|
|
9
|
+
// in world pixels), and a tap on a sprite in the minimap selects it as it
|
|
10
|
+
// would in the main view (the same sprite handlers, the walk ending at
|
|
11
|
+
// the VIEW). A stroked rect over the minimap outlines the main camera's
|
|
12
|
+
// view rect through `map.project`.
|
|
13
|
+
//
|
|
14
|
+
// Debug commands: `state` returns both cameras and the outline rect;
|
|
15
|
+
// `camera` parks the main camera when given x/y/zoom and returns its
|
|
16
|
+
// pose; `selected` the selected sprite's world position (null when none),
|
|
17
|
+
// `first` the first sprite's - the one to aim a synthetic tap at.
|
|
18
|
+
import { createEffect, createInputMap, createPointerFeed, createSignal, decodeImage, displayScale, gamepad, onFrame, render, windowSize } from "@solidrt/core"
|
|
19
|
+
import { addSprite, camera2dActions, camera2dBindings, createAtlas, createCamera2d, createSpriteLayer, feedPointer, fitOversample, grid, setSprite } from "@solidrt/2d"
|
|
20
|
+
import type { Camera2dHandle, SpriteHandle, ViewHandle } from "@solidrt/2d"
|
|
21
|
+
import { registerDebug } from "srt:dev"
|
|
22
|
+
import logoBytes from "./logo.png" with { type: "binary" }
|
|
23
|
+
|
|
24
|
+
const WORLD = { width: 2400, height: 1600 }
|
|
25
|
+
const COUNT = 300
|
|
26
|
+
const SPRITE = 48
|
|
27
|
+
const ROAMER = 72
|
|
28
|
+
const MAX_ZOOM = 4
|
|
29
|
+
// The minimap's pixel size: the whole world fitted into it.
|
|
30
|
+
const MAP_WIDTH = 240
|
|
31
|
+
const MAP_HEIGHT = 160
|
|
32
|
+
const MAP_MARGIN = 16
|
|
33
|
+
const TINT: [number, number, number, number] = [0.75, 0.8, 0.9, 1]
|
|
34
|
+
const SELECTED_TINT: [number, number, number, number] = [1, 0.4, 0.4, 1]
|
|
35
|
+
// Roamer figure-eight: rates in rad/s and the span as a fraction of the world.
|
|
36
|
+
const ROAM_RATE_X = 0.23
|
|
37
|
+
const ROAM_RATE_Y = 0.31
|
|
38
|
+
const ROAM_SPAN = 0.4
|
|
39
|
+
// Cap on a frame's dt in seconds, so a resumed app cannot leap.
|
|
40
|
+
const MAX_DT = 0.1
|
|
41
|
+
|
|
42
|
+
let cam!: Camera2dHandle
|
|
43
|
+
let map!: ViewHandle
|
|
44
|
+
let selected: SpriteHandle | null = null
|
|
45
|
+
let first: SpriteHandle | null = null
|
|
46
|
+
let outline = { x: 0, y: 0, width: 0, height: 0 }
|
|
47
|
+
|
|
48
|
+
function App() {
|
|
49
|
+
let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
|
|
50
|
+
let frames = grid(atlas, 2, 2)
|
|
51
|
+
let win = { width: 1, height: 1 }
|
|
52
|
+
let layer = createSpriteLayer(atlas.texture, { capacity: COUNT + 1, label: "world" })
|
|
53
|
+
// The main view: the window, under the pannable camera.
|
|
54
|
+
let main = layer.createView({ width: win.width, height: win.height, clearColor: [0.05, 0.05, 0.09, 1], label: "world" })
|
|
55
|
+
let select = (sprite: SpriteHandle | null) => {
|
|
56
|
+
if (selected) setSprite(selected, { tint: TINT })
|
|
57
|
+
selected = sprite
|
|
58
|
+
if (selected) setSprite(selected, { tint: SELECTED_TINT })
|
|
59
|
+
}
|
|
60
|
+
for (let i = 0; i < COUNT; i++) {
|
|
61
|
+
let sprite = addSprite(layer, {
|
|
62
|
+
x: Math.random() * WORLD.width,
|
|
63
|
+
y: Math.random() * WORLD.height,
|
|
64
|
+
w: SPRITE,
|
|
65
|
+
h: SPRITE,
|
|
66
|
+
frame: frames[i % 4],
|
|
67
|
+
rotation: Math.random() * Math.PI * 2,
|
|
68
|
+
tint: TINT,
|
|
69
|
+
})
|
|
70
|
+
sprite.onTap = () => select(sprite)
|
|
71
|
+
if (first === null) first = sprite
|
|
72
|
+
}
|
|
73
|
+
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] })
|
|
74
|
+
|
|
75
|
+
// The minimap: the whole world at the fit zoom, world (0, 0) at the
|
|
76
|
+
// view's top-left (the default pivot), its own clear color.
|
|
77
|
+
let mapZoom = Math.min(MAP_WIDTH / WORLD.width, MAP_HEIGHT / WORLD.height)
|
|
78
|
+
map = layer.createView({
|
|
79
|
+
width: MAP_WIDTH,
|
|
80
|
+
height: MAP_HEIGHT,
|
|
81
|
+
camera: { zoom: mapZoom },
|
|
82
|
+
clearColor: [0.02, 0.02, 0.05, 0.85],
|
|
83
|
+
label: "minimap",
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
cam = createCamera2d(main, { viewport: () => win, world: WORLD, maxZoom: MAX_ZOOM })
|
|
87
|
+
// The main view's gestures and any pad drive the camera through a map;
|
|
88
|
+
// the minimap binds nothing (its tap glides through listen below).
|
|
89
|
+
let pointer = createPointerFeed()
|
|
90
|
+
feedPointer(main, pointer)
|
|
91
|
+
let input = createInputMap(camera2dActions)
|
|
92
|
+
input.bind(camera2dBindings({ pointer, gamepad: gamepad() }))
|
|
93
|
+
input.drive(cam.axes)
|
|
94
|
+
main.listen({
|
|
95
|
+
onTap: e => {
|
|
96
|
+
if (e.sprite === null) select(null)
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
// A tap on the minimap, sprite or not, glides the main camera there:
|
|
100
|
+
// the view's dispatch undoes the MAP camera, so e.x/e.y are world pixels.
|
|
101
|
+
map.listen({ onTap: e => cam.glideTo(e.x, e.y) })
|
|
102
|
+
|
|
103
|
+
createEffect(
|
|
104
|
+
() => ({ size: windowSize(), scale: displayScale() }),
|
|
105
|
+
({ size, scale }) => {
|
|
106
|
+
win.width = Math.max(1, size.width)
|
|
107
|
+
win.height = Math.max(1, size.height)
|
|
108
|
+
main.setSize(win.width, win.height)
|
|
109
|
+
let budget = win.width * win.height * scale * scale
|
|
110
|
+
main.setOversample(fitOversample(scale, win.width, win.height, budget))
|
|
111
|
+
map.setOversample(fitOversample(scale, MAP_WIDTH, MAP_HEIGHT, budget))
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
// The main view's rect on the minimap, as a signal written only when the
|
|
116
|
+
// camera moved this frame.
|
|
117
|
+
let [rect, setRect] = createSignal(outline)
|
|
118
|
+
let last = 0
|
|
119
|
+
onFrame(tick => {
|
|
120
|
+
let t = tick / 1000
|
|
121
|
+
let dt = Math.min(MAX_DT, Math.max(0, t - last))
|
|
122
|
+
last = t
|
|
123
|
+
let rx = WORLD.width / 2 + Math.sin(t * ROAM_RATE_X) * WORLD.width * ROAM_SPAN
|
|
124
|
+
let ry = WORLD.height / 2 + Math.sin(t * ROAM_RATE_Y) * WORLD.height * ROAM_SPAN
|
|
125
|
+
setSprite(roamer, { x: rx, y: ry, rotation: t })
|
|
126
|
+
cam.update(dt)
|
|
127
|
+
let view = cam.viewRect()
|
|
128
|
+
let [x0, y0] = map.project(view.x, view.y)
|
|
129
|
+
let [x1, y1] = map.project(view.x + view.width, view.y + view.height)
|
|
130
|
+
if (x0 !== outline.x || y0 !== outline.y || x1 - x0 !== outline.width || y1 - y0 !== outline.height) {
|
|
131
|
+
outline = { x: x0, y: y0, width: x1 - x0, height: y1 - y0 }
|
|
132
|
+
setRect(outline)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<window>
|
|
138
|
+
<texture src={main.texture} position="absolute" left={0} top={0} width={windowSize().width} height={windowSize().height} {...main.handlers} />
|
|
139
|
+
<view pointerEvents="none" gap={6} padding={20}>
|
|
140
|
+
<text color="#eef4ff" fontSize={24} fontWeight={700}>
|
|
141
|
+
Views
|
|
142
|
+
</text>
|
|
143
|
+
<text color="#a9bcd6" fontSize={15}>
|
|
144
|
+
drag to pan, wheel or pinch to zoom - tap the minimap to glide there - tap a sprite in either view to select it
|
|
145
|
+
</text>
|
|
146
|
+
</view>
|
|
147
|
+
<view position="absolute" right={MAP_MARGIN} bottom={MAP_MARGIN} width={MAP_WIDTH} height={MAP_HEIGHT} overflow="clip">
|
|
148
|
+
<texture src={map.texture} width={MAP_WIDTH} height={MAP_HEIGHT} {...map.handlers} />
|
|
149
|
+
<rect
|
|
150
|
+
position="absolute"
|
|
151
|
+
left={rect().x}
|
|
152
|
+
top={rect().y}
|
|
153
|
+
width={rect().width}
|
|
154
|
+
height={rect().height}
|
|
155
|
+
drawStyle="stroke"
|
|
156
|
+
strokeWidth={1}
|
|
157
|
+
color="#ffffff"
|
|
158
|
+
pointerEvents="none"
|
|
159
|
+
/>
|
|
160
|
+
</view>
|
|
161
|
+
</window>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
render(() => <App />)
|
|
166
|
+
|
|
167
|
+
registerDebug("state", () => ({ camera: cam.camera(), map: map.camera(), outline }))
|
|
168
|
+
registerDebug("camera", (args?: Record<string, unknown>) => {
|
|
169
|
+
let pose: Record<string, number> = {}
|
|
170
|
+
for (let key of ["x", "y", "zoom"]) {
|
|
171
|
+
if (typeof args?.[key] === "number") pose[key] = args[key] as number
|
|
172
|
+
}
|
|
173
|
+
if (Object.keys(pose).length > 0) cam.set(pose)
|
|
174
|
+
return cam.camera()
|
|
175
|
+
})
|
|
176
|
+
registerDebug("selected", () => (selected && selected.layer !== null ? { x: selected._x, y: selected._y } : null))
|
|
177
|
+
registerDebug("first", () => (first ? { x: first._x, y: first._y } : null))
|