@solidrt/2d 0.0.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ // The sprite layer at its natural scale: hundreds of sprites bouncing at
2
+ // frame rate, driven imperatively. The component face mounts the layer; the
3
+ // motion loop grabs sprite handles via addSprite and rewrites positions with
4
+ // setSprite from onFrame - signals carry structure, per-frame motion goes
5
+ // straight to the layer (the same split as @solidrt/3d). Whatever moves, the
6
+ // tree holds ONE texture leaf; each setSprite writes the sprite's arena
7
+ // node, and the core flush publishes every moved pose as one coalesced
8
+ // instance-buffer write per frame.
9
+ //
10
+ // The atlas is a real image (core's logo) sliced 2x2 by grid(): four frames,
11
+ // each sprite drawing one quarter. An atlas from raw pixel bytes would use
12
+ // createTexture directly; this path (createAtlas) decodes PNG bytes imported
13
+ // with { type: "binary" }.
14
+ import { onFrame, render } from "@solidrt/core"
15
+ import { addSprite, createAtlas, createSpriteLayer, grid, setSprite } from "@solidrt/2d"
16
+ import logoBytes from "./logo.png" with { type: "binary" }
17
+
18
+ const COUNT = 500
19
+ const W = 720
20
+ const H = 720
21
+ const SPRITE = 48
22
+
23
+ function App() {
24
+ let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
25
+ let frames = grid(2, 2, { width: atlas.width, height: atlas.height })
26
+ let layer = createSpriteLayer(W, H, atlas.texture, {
27
+ capacity: COUNT,
28
+ clearColor: [0.05, 0.05, 0.09, 1],
29
+ label: "bounce",
30
+ })
31
+
32
+ // Simulation state lives in plain arrays; the layer holds the published
33
+ // snapshot of it.
34
+ let x = new Float32Array(COUNT)
35
+ let y = new Float32Array(COUNT)
36
+ let vx = new Float32Array(COUNT)
37
+ let vy = new Float32Array(COUNT)
38
+ let sprites = new Array(COUNT)
39
+ for (let i = 0; i < COUNT; i++) {
40
+ x[i] = SPRITE / 2 + Math.random() * (W - SPRITE)
41
+ y[i] = SPRITE / 2 + Math.random() * (H - SPRITE)
42
+ vx[i] = (Math.random() * 2 - 1) * 3
43
+ vy[i] = (Math.random() * 2 - 1) * 3
44
+ sprites[i] = addSprite(layer, {
45
+ x: x[i],
46
+ y: y[i],
47
+ w: SPRITE,
48
+ h: SPRITE,
49
+ frame: frames[i % 4],
50
+ rotation: Math.random() * Math.PI * 2,
51
+ })
52
+ }
53
+
54
+ onFrame(tick => {
55
+ for (let i = 0; i < COUNT; i++) {
56
+ let nx = x[i]! + vx[i]!
57
+ let ny = y[i]! + vy[i]!
58
+ if (nx < SPRITE / 2 || nx > W - SPRITE / 2) vx[i] = -vx[i]!
59
+ else x[i] = nx
60
+ if (ny < SPRITE / 2 || ny > H - SPRITE / 2) vy[i] = -vy[i]!
61
+ else y[i] = ny
62
+ setSprite(sprites[i]!, { x: x[i]!, y: y[i]!, rotation: tick / 1000 + i })
63
+ }
64
+ })
65
+
66
+ return (
67
+ <window alignItems="center" justifyContent="center">
68
+ <texture src={layer.texture} width={W} height={H} />
69
+ </window>
70
+ )
71
+ }
72
+
73
+ render(() => <App />)
@@ -0,0 +1,102 @@
1
+ // The baked tile layer: a world of tiles baked into lazily-allocated chunk
2
+ // textures and composited as a few quads, so scrolling and rotating cost
3
+ // transform writes and an unchanged world costs nothing per frame. The
4
+ // world here is BIGGER than one texture is allowed to be (6144px), which is
5
+ // the chunking at work; sparse regions never allocate a chunk at all.
6
+ //
7
+ // The camera flies a ship-style path: a fixed screen pivot near the bottom
8
+ // of the viewport, the world panning and ROTATING under it so the flight
9
+ // heading always points up - the <TileLayer> camera prop with rotation and
10
+ // pivot (a leaf transform, never a re-bake). A timer edits tiles while it
11
+ // runs: beacon markers along the road's center line blink, and each blink's
12
+ // batch of setTile calls re-bakes only the chunks the beacons land in.
13
+ //
14
+ // The atlas is the core logo sliced 2x2 by grid(); a real game would slice
15
+ // a tileset sheet the same way.
16
+ import { createSignal, onFrame, render } from "@solidrt/core"
17
+ import { createAtlas, grid, TileLayer } from "@solidrt/2d"
18
+ import type { TileCamera, TileLayerHandle } from "@solidrt/2d"
19
+ import logoBytes from "./logo.png" with { type: "binary" }
20
+
21
+ const COLS = 128
22
+ const ROWS = 128
23
+ const TILE = 48
24
+ const VIEW = 720
25
+ const WORLD = COLS * TILE
26
+
27
+ function App() {
28
+ let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
29
+ let frames = grid(2, 2, { width: atlas.width, height: atlas.height })
30
+
31
+ let layer!: TileLayerHandle
32
+ let seed = (l: TileLayerHandle) => {
33
+ layer = l
34
+ // A solid ring "road" around the world center: continuous under the
35
+ // flight path, empty everywhere else - the empty regions are the point,
36
+ // their chunks never allocate.
37
+ let c = COLS / 2
38
+ for (let row = 0; row < ROWS; row++) {
39
+ for (let col = 0; col < COLS; col++) {
40
+ let d = Math.hypot(col - c, row - c)
41
+ if (d > 40 && d < 52) l.setTile(col, row, frames[(col ^ row) % 4]!)
42
+ }
43
+ }
44
+ }
45
+
46
+ // Beacon cells on the road's center line every 7.5 degrees; the interval
47
+ // below blinks them between a marker frame and the road pattern.
48
+ let beacons: [number, number][] = []
49
+ for (let k = 0; k < 48; k++) {
50
+ let a = (k * Math.PI) / 24
51
+ beacons.push([Math.round(COLS / 2 + Math.cos(a) * 46), Math.round(ROWS / 2 + Math.sin(a) * 46)])
52
+ }
53
+
54
+ // The ship flies the ring: camera x/y follow the circle, rotation keeps
55
+ // the heading pointing screen-up, the pivot pins the ship's world point
56
+ // near the viewport bottom. Per-frame camera motion is one signal write
57
+ // feeding the transform - never a re-bake.
58
+ let [camera, setCamera] = createSignal<TileCamera>({})
59
+ onFrame(tick => {
60
+ let t = tick / 6000
61
+ let radius = 46 * TILE
62
+ setCamera({
63
+ x: WORLD / 2 + Math.cos(t) * radius,
64
+ y: WORLD / 2 + Math.sin(t) * radius,
65
+ // Circle tangent heading, rotated so "forward" renders upward.
66
+ rotation: -(t + Math.PI / 2),
67
+ zoom: 0.9,
68
+ pivotX: VIEW / 2,
69
+ pivotY: VIEW * 0.78,
70
+ })
71
+ })
72
+
73
+ // Blink the beacons: each 500 ms batch of setTile calls re-bakes only the
74
+ // chunks the beacons land in - live edits next to the free scrolling.
75
+ let lit = false
76
+ setInterval(() => {
77
+ lit = !lit
78
+ for (let [col, row] of beacons) layer.setTile(col, row, lit ? frames[3]! : frames[(col ^ row) % 4]!)
79
+ }, 500)
80
+
81
+ return (
82
+ <window alignItems="center" justifyContent="center">
83
+ <view width={VIEW} height={VIEW} overflow="clip">
84
+ {/* Ground color: never-written regions render nothing, so the
85
+ full-bleed backdrop is the container's, not the layer's. */}
86
+ <d-rect x={0} y={0} w={VIEW} h={VIEW} color="#0d0d17" />
87
+ <TileLayer
88
+ cols={COLS}
89
+ rows={ROWS}
90
+ tileW={TILE}
91
+ tileH={TILE}
92
+ atlas={atlas.texture}
93
+ camera={camera()}
94
+ label="tile-world"
95
+ ref={seed}
96
+ />
97
+ </view>
98
+ </window>
99
+ )
100
+ }
101
+
102
+ render(() => <App />)
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@solidrt/2d",
3
+ "version": "0.0.52",
4
+ "license": "MIT",
5
+ "funding": "https://github.com/sponsors/wellawaretech",
6
+ "author": "Antoine van Wel",
7
+ "type": "module",
8
+ "main": "src/index.ts",
9
+ "exports": {
10
+ ".": "./src/index.ts"
11
+ },
12
+ "files": [
13
+ "src/",
14
+ "examples/",
15
+ "AGENTS.md"
16
+ ],
17
+ "peerDependencies": {
18
+ "@solidjs/signals": "2.0.0-rc.1",
19
+ "@solidrt/core": "0.0.52"
20
+ }
21
+ }
package/src/atlas.ts ADDED
@@ -0,0 +1,52 @@
1
+ // Atlas creation: encoded image bytes to a GPU texture plus its frame table.
2
+ // Thin by design - the frame math lives in frames.ts (pure, checkable) and
3
+ // the texture is an ordinary core texture. createImage is NOT used here: it
4
+ // never forwards sampler options, and pixel-art atlases want
5
+ // `filter: "nearest"` (render small, display big with hard pixels).
6
+ import { createTexture, decodeImage } from "@solidrt/core"
7
+ import type { TextureId } from "@solidrt/core/gpu"
8
+ import type { Frame } from "./frames.ts"
9
+ import { grid } from "./frames.ts"
10
+ import type { GridOptions } from "./frames.ts"
11
+
12
+ export type Atlas = {
13
+ /** The atlas texture, sampled by every sprite in layers created over it. */
14
+ texture: TextureId
15
+ width: number
16
+ height: number
17
+ /** Frames in cell order when created via the grid option, else empty. */
18
+ frames: Frame[]
19
+ }
20
+
21
+ export type AtlasOptions = {
22
+ /**
23
+ * Sampling: "nearest" is the pixel-art path (hard pixels at any scale),
24
+ * "linear" (default) the photographic one. Fixed at creation, like every
25
+ * core texture.
26
+ */
27
+ filter?: "nearest" | "linear"
28
+ /** Slice a uniform sheet at creation: cols x rows of equal cells. */
29
+ grid?: { cols: number; rows: number } & Omit<GridOptions, "width" | "height">
30
+ label?: string
31
+ /** Skip the owner-scoped auto-free (the core createTexture contract). */
32
+ autoFree?: boolean
33
+ }
34
+
35
+ /**
36
+ * Decode encoded image bytes (PNG, JPEG, ...) into an atlas texture. Bytes
37
+ * come from `import sheet from "./sheet.png" with { type: "binary" }` or
38
+ * `await file("assets/sheet.png").bytes()`. Freed with the owning reactive
39
+ * scope like any core texture (opt out with `{ autoFree: false }`).
40
+ */
41
+ export function createAtlas(bytes: Uint8Array, opts?: AtlasOptions): Atlas {
42
+ let decoded = decodeImage(bytes)
43
+ let texture = createTexture(decoded.data, decoded.width, decoded.height, {
44
+ filter: opts?.filter ?? "linear",
45
+ label: opts?.label ?? "atlas",
46
+ autoFree: opts?.autoFree,
47
+ })
48
+ let frames = opts?.grid
49
+ ? grid(opts.grid.cols, opts.grid.rows, { ...opts.grid, width: decoded.width, height: decoded.height })
50
+ : []
51
+ return { texture, width: decoded.width, height: decoded.height, frames }
52
+ }
@@ -0,0 +1,287 @@
1
+ // The Solid face: PascalCase components over context, syncing the retained
2
+ // layer (layer.ts) - no new intrinsic elements, no renderer changes. Props
3
+ // follow the Solid 2.0 model (reactive values, no destructuring); effects
4
+ // write into the retained records and the runtime's dirty flush renders.
5
+ // Anything moving at frame rate can bypass the declarative layer: grab the
6
+ // sprite with `ref` and call setSprite from onFrame - signals carry
7
+ // structure and slow state, per-frame motion goes straight to the layer.
8
+ // The same split, with the same reasoning, as @solidrt/3d's components.
9
+ import { createContext, createEffect, createSignal, For, onCleanup, untrack, useContext } from "@solidrt/core"
10
+ import type { Element, ParentComponent, TextureId, VoidComponent } from "@solidrt/core"
11
+ import type { FilterMode } from "@solidrt/core/gpu"
12
+ import { addGroup, addSprite, createSpriteLayer, removeGroup, removeSprite, setGroup, setGroupTransition, setSprite, setSpriteTransition } from "./layer.ts"
13
+ import type { NodeTransition } from "flux:spatial"
14
+ import type { CameraUpdate, Sprite as SpriteHandle, SpriteGroup, SpriteLayer as LayerHandle, SpriteOptions, SpritePointerEvent, TransitionEndEvent } from "./layer.ts"
15
+ import { createTileLayer } from "./tiles.ts"
16
+ import type { TileChunk, TileLayer as TileLayerHandle } from "./tiles.ts"
17
+
18
+ let LayerContext = createContext<LayerHandle>()
19
+
20
+ /**
21
+ * The enclosing layer - the imperative escape hatch inside a component
22
+ * subtree (throws outside a `<SpriteLayer>`).
23
+ */
24
+ export function useSpriteLayer(): LayerHandle {
25
+ return useContext(LayerContext)
26
+ }
27
+
28
+ export type SpritePointerProps = {
29
+ onPointerDown?: (event: SpritePointerEvent) => void
30
+ onPointerMove?: (event: SpritePointerEvent) => void
31
+ onPointerUp?: (event: SpritePointerEvent) => void
32
+ onPointerEnter?: (event: SpritePointerEvent) => void
33
+ onPointerLeave?: (event: SpritePointerEvent) => void
34
+ }
35
+
36
+ export type SpriteLayerProps = {
37
+ /** Layer pixels. With `output`, the leaf's own width/height are layout, so
38
+ * render size and display size separate. */
39
+ width: number
40
+ height: number
41
+ /** The atlas texture every sprite samples (create with createAtlas). */
42
+ atlas: TextureId
43
+ /** Initial record reservation (grows on demand); default 1024. */
44
+ capacity?: number
45
+ clearColor?: [number, number, number, number]
46
+ /** Pan/zoom over the world; a shared-params write, never per-sprite. */
47
+ camera?: CameraUpdate
48
+ label?: string
49
+ ref?: (layer: LayerHandle) => void
50
+ /**
51
+ * Compose the output yourself: called once (untracked) with the layer's
52
+ * texture id, and its return renders in place of the built-in `<texture>`
53
+ * leaf. Sprite pointer events then need the layer's handlers on your
54
+ * leaf: `<texture src={texture} {...useSpriteLayer().handlers} />`.
55
+ */
56
+ output?: (texture: TextureId) => Element
57
+ /**
58
+ * Sprite pointer events (default on): the built-in leaf carries
59
+ * layer.handlers. `false` detaches them - the leaf then costs no pointer
60
+ * routing at all.
61
+ */
62
+ events?: boolean
63
+ }
64
+
65
+ /**
66
+ * Owns a sprite layer and composites it as an ordinary `<texture>` leaf, so
67
+ * the output takes layout, transforms, blendMode, and pointer events like
68
+ * any element - or hand `output` the texture id and compose it yourself.
69
+ * Children (`<Sprite>`) render nothing themselves - they populate the
70
+ * retained layer through context.
71
+ */
72
+ export let SpriteLayer: ParentComponent<SpriteLayerProps> = props => {
73
+ let layer = untrack(() =>
74
+ createSpriteLayer(props.width, props.height, props.atlas, {
75
+ capacity: props.capacity,
76
+ clearColor: props.clearColor,
77
+ label: props.label,
78
+ }),
79
+ )
80
+ createEffect(
81
+ () => [props.width, props.height] as const,
82
+ ([w, h]) => layer.setSize(w, h),
83
+ )
84
+ createEffect(
85
+ () => props.camera,
86
+ camera => {
87
+ if (camera) layer.setCamera(camera)
88
+ },
89
+ )
90
+ untrack(() => props.ref)?.(layer)
91
+ let output = untrack(() => props.output)
92
+ let events = untrack(() => props.events) !== false
93
+ return (
94
+ <LayerContext value={layer}>
95
+ {output ? (
96
+ untrack(() => output(layer.texture))
97
+ ) : (
98
+ <texture
99
+ src={layer.texture}
100
+ width={props.width}
101
+ height={props.height}
102
+ onPointerDown={events ? layer.handlers.onPointerDown : undefined}
103
+ onPointerMove={events ? layer.handlers.onPointerMove : undefined}
104
+ onPointerUp={events ? layer.handlers.onPointerUp : undefined}
105
+ onPointerLeave={events ? layer.handlers.onPointerLeave : undefined}
106
+ />
107
+ )}
108
+ {props.children}
109
+ </LayerContext>
110
+ )
111
+ }
112
+
113
+ let GroupContext = createContext<SpriteGroup | null>(null)
114
+
115
+ export type GroupProps = {
116
+ /** Position in the parent frame (layer pixels at the root). */
117
+ x?: number
118
+ y?: number
119
+ /** Rotation, radians, clockwise. */
120
+ rotation?: number
121
+ /** Uniform scale on the whole subtree (child sprites scale with it). */
122
+ scale?: number
123
+ /** How pose-prop changes animate (see setGroupTransition); the mount
124
+ * pose always snaps. */
125
+ transition?: NodeTransition | string | null
126
+ /** A declared transition settled on one component. */
127
+ onTransitionEnd?: (event: TransitionEndEvent) => void
128
+ ref?: (group: SpriteGroup) => void
129
+ }
130
+
131
+ /**
132
+ * A transform group: `<Sprite>` (and nested `<Group>`) children mount under
133
+ * its spatial arena node, so their pose props read in the group's frame and
134
+ * moving the group moves the subtree in one native recompute - a ship with
135
+ * turrets is one `<Group>` with the hull and turret sprites inside. Renders
136
+ * nothing itself.
137
+ */
138
+ export let Group: ParentComponent<GroupProps> = props => {
139
+ let layer = useContext(LayerContext)
140
+ let parent = useContext(GroupContext)
141
+ let group = untrack(() => addGroup(layer, { parent }))
142
+ createEffect(
143
+ () => [props.x, props.y, props.rotation, props.scale] as const,
144
+ ([x, y, rotation, scale]) => setGroup(group, { x, y, rotation, scale }),
145
+ )
146
+ // After the pose effect, so the mount pose snaps before writes animate.
147
+ createEffect(
148
+ () => props.transition,
149
+ transition => setGroupTransition(group, transition ?? null),
150
+ )
151
+ createEffect(
152
+ () => props.onTransitionEnd,
153
+ end => {
154
+ group.onTransitionEnd = end
155
+ },
156
+ )
157
+ untrack(() => props.ref)?.(group)
158
+ onCleanup(() => removeGroup(group))
159
+ return <GroupContext value={group}>{props.children}</GroupContext>
160
+ }
161
+
162
+ export type SpriteProps = SpriteOptions &
163
+ SpritePointerProps & {
164
+ /** How pose-prop changes animate (see setSpriteTransition); the mount
165
+ * pose always snaps. */
166
+ transition?: NodeTransition | string | null
167
+ /** A declared transition settled on one component. */
168
+ onTransitionEnd?: (event: TransitionEndEvent) => void
169
+ ref?: (sprite: SpriteHandle) => void
170
+ }
171
+
172
+ /** One sprite: a frame drawn at a position (in the enclosing `<Group>`'s
173
+ * frame when there is one). */
174
+ export let Sprite: VoidComponent<SpriteProps> = props => {
175
+ let layer = useContext(LayerContext)
176
+ let parent = useContext(GroupContext)
177
+ let sprite = untrack(() => addSprite(layer, parent ? { parent } : undefined))
178
+ createEffect(
179
+ () => [props.x, props.y, props.w, props.h, props.frame, props.rotation, props.tint] as const,
180
+ ([x, y, w, h, frame, rotation, tint]) => setSprite(sprite, { x, y, w, h, frame, rotation, tint }),
181
+ )
182
+ // After the pose effect, so the mount pose snaps before writes animate.
183
+ createEffect(
184
+ () => props.transition,
185
+ transition => setSpriteTransition(sprite, transition ?? null),
186
+ )
187
+ createEffect(
188
+ () => [props.onPointerDown, props.onPointerMove, props.onPointerUp, props.onPointerEnter, props.onPointerLeave, props.onTransitionEnd] as const,
189
+ ([down, move, up, enter, leave, end]) => {
190
+ sprite.onPointerDown = down
191
+ sprite.onPointerMove = move
192
+ sprite.onPointerUp = up
193
+ sprite.onPointerEnter = enter
194
+ sprite.onPointerLeave = leave
195
+ sprite.onTransitionEnd = end
196
+ },
197
+ )
198
+ untrack(() => props.ref)?.(sprite)
199
+ onCleanup(() => removeSprite(sprite))
200
+ return null
201
+ }
202
+
203
+ /**
204
+ * The tile layer's camera: the world point (`x`, `y`) is shown at the
205
+ * viewport point (`pivotX`, `pivotY`), the world scaled by `zoom` and
206
+ * rotated by `rotation` (radians, clockwise) ABOUT that pivot. The pivot
207
+ * defaults to (0, 0), which makes `{ x, y, zoom }` mean exactly what the
208
+ * sprite layer's camera means (world at the viewport top-left) - one
209
+ * signal drives both. The whole thing is a transform on the composited
210
+ * world, never a re-bake. (The sprite layer's camera cannot rotate yet -
211
+ * okf/backlog/2d-sprite-camera-rotation.md.)
212
+ */
213
+ export type TileCamera = CameraUpdate & {
214
+ rotation?: number
215
+ pivotX?: number
216
+ pivotY?: number
217
+ }
218
+
219
+ export type TileLayerProps = {
220
+ /** Grid shape and tile pixel size - creation-fixed (recreate to resize). */
221
+ cols: number
222
+ rows: number
223
+ tileW: number
224
+ tileH: number
225
+ /** The atlas texture every tile samples (create with createAtlas). */
226
+ atlas: TextureId
227
+ /** Per-chunk clear color; never-written regions render nothing, so a
228
+ * full-bleed ground color belongs on the container behind the layer. */
229
+ clearColor?: [number, number, number, number]
230
+ /** Sampler filter for the baked chunk textures; "nearest" for pixel art. */
231
+ filter?: FilterMode
232
+ /** Chunk edge in tiles (default ~512px worth); see TileLayerOptions. */
233
+ chunkTiles?: number
234
+ /**
235
+ * Pan/zoom/rotate over the world - a transform on the composited world
236
+ * view, never a re-bake. The world view is WORLD sized; put the layer
237
+ * inside a clipping container (`overflow="clip"`) sized to the viewport.
238
+ */
239
+ camera?: TileCamera
240
+ label?: string
241
+ ref?: (layer: TileLayerHandle) => void
242
+ }
243
+
244
+ /**
245
+ * Owns a baked tile layer (createTileLayer) and composites its chunks as
246
+ * `d-texture` leaves at their world rects inside a `<view>` carrying the
247
+ * camera transform - a handful of quads however many tiles exist. Tiles
248
+ * are data, not children: write them through `ref` with `setTile` - there
249
+ * is no `<Tile>` component on purpose (a component per tile would
250
+ * re-introduce the per-element cost the bake removes).
251
+ */
252
+ export let TileLayer: VoidComponent<TileLayerProps> = props => {
253
+ let layer = untrack(() =>
254
+ createTileLayer(props.cols, props.rows, props.tileW, props.tileH, props.atlas, {
255
+ clearColor: props.clearColor,
256
+ filter: props.filter,
257
+ chunkTiles: props.chunkTiles,
258
+ label: props.label,
259
+ }),
260
+ )
261
+ untrack(() => props.ref)?.(layer)
262
+ // Chunk allocations arrive through the layer's hook; the signal carries a
263
+ // fresh array so <For> sees the growth.
264
+ let [chunks, setChunks] = createSignal<TileChunk[]>(layer.chunks.slice())
265
+ layer.onChunk = () => setChunks(layer.chunks.slice())
266
+ // World -> screen: p maps to pivot + R(rotation) * zoom * (p - camera),
267
+ // spelled with element transforms as origin at the camera point, rotate +
268
+ // scale there, then translate the camera point onto the pivot.
269
+ let camX = () => props.camera?.x ?? 0
270
+ let camY = () => props.camera?.y ?? 0
271
+ return (
272
+ <view
273
+ width={layer.width}
274
+ height={layer.height}
275
+ originX={camX()}
276
+ originY={camY()}
277
+ rotate={props.camera?.rotation ?? 0}
278
+ scale={props.camera?.zoom ?? 1}
279
+ x={(props.camera?.pivotX ?? 0) - camX()}
280
+ y={(props.camera?.pivotY ?? 0) - camY()}
281
+ >
282
+ <For each={chunks()}>
283
+ {chunk => <d-texture src={chunk.texture} x={chunk.x} y={chunk.y} w={chunk.width} h={chunk.height} />}
284
+ </For>
285
+ </view>
286
+ )
287
+ }
package/src/frames.ts ADDED
@@ -0,0 +1,78 @@
1
+ // Atlas frame math, pure: names and grid coordinates to normalized UV rects.
2
+ // No GPU or GUI imports, so the checks rig (checks/frames-check.ts) exercises
3
+ // this module headless on the flux binary.
4
+
5
+ /**
6
+ * One atlas frame as normalized UVs: the rect [u0, v0] to [u1, v1] with
7
+ * top-left origin (the texture pixel contract), u right, v down.
8
+ */
9
+ export type Frame = { u0: number; v0: number; u1: number; v1: number }
10
+
11
+ export type GridOptions = {
12
+ /** Pixel size of the atlas the pixel-space options below refer to. */
13
+ width: number
14
+ height: number
15
+ /** Cell size in pixels; defaults to width/cols x height/rows. */
16
+ cellW?: number
17
+ cellH?: number
18
+ /** Pixel gap between cells (not around the edge); default 0. */
19
+ spacing?: number
20
+ /** Pixel offset of the first cell from the top-left corner; default 0. */
21
+ marginX?: number
22
+ marginY?: number
23
+ }
24
+
25
+ /**
26
+ * Slice a uniform sprite sheet into frames, row-major (left to right, then
27
+ * top to bottom) - the layout every sheet packer and pixel-art tool emits.
28
+ * Frames are returned in cell order, so `frames[row * cols + col]` addresses
29
+ * a cell and an animation is a slice of consecutive indices.
30
+ */
31
+ export function grid(cols: number, rows: number, opts: GridOptions): Frame[] {
32
+ if (!(cols > 0 && rows > 0 && Number.isInteger(cols) && Number.isInteger(rows))) {
33
+ throw new Error(`grid: cols and rows must be positive integers, got ${cols} x ${rows}`)
34
+ }
35
+ let { width, height, spacing = 0, marginX = 0, marginY = 0 } = opts
36
+ if (!(width > 0 && height > 0)) {
37
+ throw new Error(`grid: atlas size must be positive, got ${width} x ${height}`)
38
+ }
39
+ let cellW = opts.cellW ?? (width - marginX * 2 - spacing * (cols - 1)) / cols
40
+ let cellH = opts.cellH ?? (height - marginY * 2 - spacing * (rows - 1)) / rows
41
+ if (!(cellW > 0 && cellH > 0)) {
42
+ throw new Error(`grid: derived cell size ${cellW} x ${cellH} is not positive`)
43
+ }
44
+ let frames: Frame[] = []
45
+ for (let row = 0; row < rows; row++) {
46
+ for (let col = 0; col < cols; col++) {
47
+ let x = marginX + col * (cellW + spacing)
48
+ let y = marginY + row * (cellH + spacing)
49
+ frames.push({ u0: x / width, v0: y / height, u1: (x + cellW) / width, v1: (y + cellH) / height })
50
+ }
51
+ }
52
+ return frames
53
+ }
54
+
55
+ /**
56
+ * Name frames from a pixel-rect map: `{ hero: [x, y, w, h], ... }` in atlas
57
+ * pixels to `{ hero: Frame, ... }`. The named counterpart of `grid` for
58
+ * hand-packed or tool-exported sheets.
59
+ */
60
+ export function namedFrames<K extends string>(
61
+ atlasW: number,
62
+ atlasH: number,
63
+ rects: Record<K, [number, number, number, number]>,
64
+ ): Record<K, Frame> {
65
+ if (!(atlasW > 0 && atlasH > 0)) {
66
+ throw new Error(`namedFrames: atlas size must be positive, got ${atlasW} x ${atlasH}`)
67
+ }
68
+ let out = {} as Record<K, Frame>
69
+ for (let name in rects) {
70
+ let [x, y, w, h] = rects[name]
71
+ if (!(w > 0 && h > 0)) throw new Error(`namedFrames: frame '${name}' has non-positive size ${w} x ${h}`)
72
+ out[name] = { u0: x / atlasW, v0: y / atlasH, u1: (x + w) / atlasW, v1: (y + h) / atlasH }
73
+ }
74
+ return out
75
+ }
76
+
77
+ /** The whole texture as one frame (a plain image used as a sprite). */
78
+ export const FULL_FRAME: Frame = { u0: 0, v0: 0, u1: 1, v1: 1 }
package/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ // @solidrt/2d - an instanced sprite layer above @solidrt/core/gpu.
2
+ // One atlas, N quads in one draw. The live layer (createSpriteLayer/
3
+ // addSprite) backs every sprite with a SPATIAL ARENA node whose Pose2D
4
+ // record sink writes the pose instance buffer at the core flush, so core
5
+ // producers reach sprites and picking walks the core BVH; style stays a
6
+ // JS-written second instance buffer. The records layer (createRecordLayer)
7
+ // is the raw escape hatch for motion only JS can compute: 13 JS-owned
8
+ // floats per sprite published through the zero-copy write lease. The baked
9
+ // tile layer (createTileLayer/TileLayer) is the static sibling: a tile
10
+ // world rendered once into textures and composited as a few quads,
11
+ // re-baked on change. Two faces throughout: the imperative core (usable
12
+ // without Solid components) and the components (SpriteLayer/Sprite/Group/
13
+ // TileLayer) on top. See AGENTS.md for the model and the traps.
14
+
15
+ export { addGroup, addSprite, createSpriteLayer, getSprite, removeGroup, removeSprite, setGroup, setGroupTransition, setSprite, setSpriteParent, setSpriteTransition, POSE_FLOATS, STYLE_FLOATS } from "./layer.ts"
16
+ export { createRecordLayer, FLOATS_PER_SPRITE } from "./records.ts"
17
+ export type { RecordLayer as RecordLayerHandle } from "./records.ts"
18
+ export { pointInSprite } from "./pick.ts"
19
+ export type { AddSpriteOptions, CameraUpdate, GroupOptions, Sprite as SpriteHandle, SpriteGroup, SpriteHandlers, SpriteLayer as SpriteLayerHandle, SpriteLayerOptions, SpriteOptions, SpritePointerEvent, TransitionEndEvent } from "./layer.ts"
20
+ export { createTileLayer } from "./tiles.ts"
21
+ export type { TileChunk, TileLayer as TileLayerHandle, TileLayerOptions } from "./tiles.ts"
22
+
23
+ export type { NodeTransition, NodeTransitionSpec } from "flux:spatial"
24
+ export { grid, namedFrames, FULL_FRAME } from "./frames.ts"
25
+ export type { Frame, GridOptions } from "./frames.ts"
26
+ export { createAtlas } from "./atlas.ts"
27
+ export type { Atlas, AtlasOptions } from "./atlas.ts"
28
+ export { Group, Sprite, SpriteLayer, TileLayer, useSpriteLayer } from "./components.tsx"
29
+ export type { GroupProps, SpriteLayerProps, SpritePointerProps, SpriteProps, TileCamera, TileLayerProps } from "./components.tsx"