@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.
- package/AGENTS.md +219 -0
- package/README.md +51 -0
- package/examples/README.md +21 -0
- package/examples/logo.png +0 -0
- package/examples/parity.tsx +204 -0
- package/examples/pick.tsx +90 -0
- package/examples/springs.tsx +69 -0
- package/examples/sprites.tsx +73 -0
- package/examples/tiles.tsx +102 -0
- package/package.json +21 -0
- package/src/atlas.ts +52 -0
- package/src/components.tsx +287 -0
- package/src/frames.ts +78 -0
- package/src/index.ts +29 -0
- package/src/layer.ts +788 -0
- package/src/pick.ts +22 -0
- package/src/records.ts +294 -0
- package/src/shaders.ts +93 -0
- package/src/tiles.ts +281 -0
|
@@ -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"
|