@solidrt/2d 0.0.56 → 0.0.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,160 @@
1
+ import { createEffect, createSignal, displayScale, For, getBoundingBoxViewport, onLayout, untrack } from "@solidrt/core"
2
+ import type { TextureId, VoidComponent } from "@solidrt/core"
3
+ import type { FilterMode } from "@solidrt/core/gpu"
4
+ import type { CameraUpdate } from "../camera.ts"
5
+ import type { Frame } from "../frames.ts"
6
+ import { tileWorldScale } from "../oversample-math.ts"
7
+ import { createTileLayer } from "../tiles.ts"
8
+ import type { TileChunk, TileLayer as TileLayerHandle } from "../tiles.ts"
9
+ import { applyOversample } from "./auto-oversample.ts"
10
+
11
+ /**
12
+ * The tile layer's camera - the same CameraUpdate vocabulary as the sprite
13
+ * layer (see camera.ts for the pivot/rotation semantics and the
14
+ * heading-upward convention), so one signal drives a whole rotating scene
15
+ * across both layers. Here the camera is a transform on the composited
16
+ * world view, never a re-bake; projectCamera/unprojectCamera are the same
17
+ * mapping as plain functions.
18
+ */
19
+ export type TileCamera = CameraUpdate
20
+
21
+ export type TileLayerProps = {
22
+ /** Grid shape and tile pixel size - creation-fixed (recreate to resize). */
23
+ cols: number
24
+ rows: number
25
+ tileW: number
26
+ tileH: number
27
+ /** The atlas texture every tile samples (create with createAtlas). */
28
+ atlas: TextureId
29
+ /** The frames table `setTiles`' index cells name (a tileset, `grid()`'s
30
+ * array); see TileLayerOptions. Fixed at creation. */
31
+ frames?: Frame[]
32
+ /** Per-chunk clear color (the name says the scope: never-written regions
33
+ * have no chunk and render nothing, so a full-bleed ground color belongs
34
+ * on the container behind the layer). */
35
+ chunkClearColor?: [number, number, number, number]
36
+ /** Sampler filter for the baked chunk textures at composite time; default
37
+ * "linear" (hard pixels belong to the atlas sampler, see TileLayerOptions). */
38
+ filter?: FilterMode
39
+ /**
40
+ * Target texels per world pixel in the baked chunks. Absent, the component
41
+ * picks it every layout from the world view's on-screen size (display
42
+ * scale, camera zoom, any designSize fit), so tiles resample properly at
43
+ * any scale.
44
+ */
45
+ oversample?: number
46
+ /**
47
+ * Cap on the auto-picked oversample (integer >= 1): tiles stay adaptive
48
+ * up to it, never above. A tile world's texture memory is resident chunks
49
+ * x n squared, so this is the lever that bounds it on high-scale displays
50
+ * without giving up adaptivity. Ignored when `oversample` is set.
51
+ */
52
+ maxOversample?: number
53
+ /** Chunk edge in tiles (default ~512px worth); see TileLayerOptions. */
54
+ chunkTiles?: number
55
+ /**
56
+ * Layer tint, [r, g, b, a] in 0..1, multiplied over every cell's own
57
+ * tint (day/night, a dimmed parallax plane). A change re-renders every
58
+ * resident chunk GPU-side (no record uploads) - drive it from slow
59
+ * state, not per frame.
60
+ */
61
+ tint?: [number, number, number, number]
62
+ /**
63
+ * Pan/zoom/rotate over the world - a transform on the composited world
64
+ * view, never a re-bake. The world view is WORLD sized; put the layer
65
+ * inside a clipping container (`overflow="clip"`) sized to the viewport.
66
+ */
67
+ camera?: TileCamera
68
+ label?: string
69
+ ref?: (layer: TileLayerHandle) => void
70
+ }
71
+
72
+ /**
73
+ * Owns a baked tile layer (createTileLayer) and composites its chunks as
74
+ * `d-texture` leaves at their world rects inside a `<view>` carrying the
75
+ * camera transform - a handful of quads however many tiles exist. A layout
76
+ * component: the world view is laid out, so it cannot sit inside a d-*
77
+ * subtree. Tiles
78
+ * are data, not children: write them through `ref` with `setTile` and
79
+ * `setTiles` - there is no `<Tile>` component on purpose (a component per
80
+ * tile would re-introduce the per-element cost the bake removes).
81
+ */
82
+ export let TileLayer: VoidComponent<TileLayerProps> = props => {
83
+ let layer = untrack(() =>
84
+ createTileLayer(props.cols, props.rows, props.tileW, props.tileH, props.atlas, {
85
+ chunkClearColor: props.chunkClearColor,
86
+ filter: props.filter,
87
+ chunkTiles: props.chunkTiles,
88
+ tint: props.tint,
89
+ frames: props.frames,
90
+ label: props.label,
91
+ }),
92
+ )
93
+ untrack(() => props.ref)?.(layer)
94
+ createEffect(
95
+ () => props.oversample,
96
+ n => {
97
+ if (n !== undefined) layer.setOversample(n)
98
+ },
99
+ )
100
+ createEffect(
101
+ () => props.tint,
102
+ tint => {
103
+ if (tint !== undefined) layer.setTint(tint)
104
+ },
105
+ )
106
+ // Auto oversample: the world view's window box, in device pixels, per
107
+ // world pixel. Rotation is not a resolution factor - texels per world
108
+ // pixel do not change as the camera turns - but the measured box is the
109
+ // AABB of the ROTATED view, which swells by up to sqrt(2) and under an
110
+ // animated rotation would sweep the scale across an integer boundary,
111
+ // re-baking every resident chunk on each flip. So the swell is divided
112
+ // back out for the known camera rotation, leaving displayScale, the
113
+ // ancestor fit and the camera zoom. The divide-out's basis is the view's
114
+ // laid-out box, which flexShrink 0 below pins to layer.width x
115
+ // layer.height: a flex container would otherwise compress the box (the
116
+ // chunks are detached and draw at world coordinates either way) and the
117
+ // measurement would mix layout compression into the scale. pick runs
118
+ // untracked (onLayout handler / effect apply), so its prop reads are
119
+ // wrapped explicitly.
120
+ let world: { id: number } | undefined
121
+ let pick = () =>
122
+ untrack(() => {
123
+ if (!world || props.oversample !== undefined) return
124
+ let box = getBoundingBoxViewport(world)
125
+ if (!box) return
126
+ let r = props.camera?.rotation ?? 0
127
+ let scale = displayScale() * tileWorldScale(box.width, box.height, layer.width, layer.height, r)
128
+ applyOversample(layer, scale, layer.chunkW, layer.chunkH, props.maxOversample)
129
+ })
130
+ onLayout(pick)
131
+ createEffect(() => [displayScale(), props.maxOversample], pick)
132
+ // Chunk allocations arrive through the layer's hook; the signal carries a
133
+ // fresh array so <For> sees the growth.
134
+ let [chunks, setChunks] = createSignal<TileChunk[]>(layer.chunks.slice())
135
+ layer.onChunk = () => setChunks(layer.chunks.slice())
136
+ // World -> screen: p maps to pivot + R(rotation) * zoom * (p - camera) -
137
+ // projectCamera (camera.ts) spelled with element transforms as origin at
138
+ // the camera point, rotate + scale there, then translate the camera point
139
+ // onto the pivot. checks/camera-check.ts holds the two spellings together.
140
+ let camX = () => props.camera?.x ?? 0
141
+ let camY = () => props.camera?.y ?? 0
142
+ return (
143
+ <view
144
+ ref={(n: { id: number }) => (world = n)}
145
+ width={layer.width}
146
+ height={layer.height}
147
+ flexShrink={0}
148
+ originX={camX()}
149
+ originY={camY()}
150
+ rotate={props.camera?.rotation ?? 0}
151
+ scale={props.camera?.zoom ?? 1}
152
+ x={(props.camera?.pivotX ?? 0) - camX()}
153
+ y={(props.camera?.pivotY ?? 0) - camY()}
154
+ >
155
+ <For each={chunks()}>
156
+ {chunk => <d-texture src={chunk.texture} x={chunk.x} y={chunk.y} w={chunk.width} h={chunk.height} />}
157
+ </For>
158
+ </view>
159
+ )
160
+ }
@@ -0,0 +1,151 @@
1
+ import { createEffect, displayScale, getBoundingBoxViewport, onCleanup, onLayout, untrack, useContext } from "@solidrt/core"
2
+ import type { Element, ParentComponent, PointerFeed, TextureId } from "@solidrt/core"
3
+ import type { CameraUpdate } from "../camera.ts"
4
+ import { feedPointer } from "../views.ts"
5
+ import type { ViewHandle, ViewOptions } from "../views.ts"
6
+ import { LayerContext, PointerContext, ViewportContext } from "./context.ts"
7
+ import type { LayerPointerProps } from "./sprite-layer.tsx"
8
+ import { applyOversample } from "./auto-oversample.ts"
9
+
10
+ export type View2dProps = LayerPointerProps &
11
+ Pick<ViewOptions, "clearColor" | "label"> & {
12
+ /** View pixels, live (view.setSize). Fixed only, for now: a fill mode
13
+ * like SpriteLayer's is a later, additive step. */
14
+ width: number
15
+ height: number
16
+ /** Partial camera update on the view's own camera (view.setCamera):
17
+ * absent keys keep their values. Live. A `<Camera2d>` child writes the
18
+ * same state - use one form, not both. */
19
+ camera?: CameraUpdate
20
+ /**
21
+ * Target texels per view pixel. Absent, the component picks it every
22
+ * layout from the built-in leaf's on-screen size, as `<SpriteLayer>`
23
+ * does; with `output` there is no built-in leaf, so set it yourself.
24
+ */
25
+ oversample?: number
26
+ /** Cap on the auto-picked oversample (see SpriteLayerProps). */
27
+ maxOversample?: number
28
+ ref?: (view: ViewHandle) => void
29
+ /**
30
+ * Compose the leaf yourself: called once (untracked) with view.texture,
31
+ * and its return renders in place of the built-in `<texture>` leaf.
32
+ * Sprite pointer events through the view then need its handlers on
33
+ * your leaf: `<texture src={texture} {...view.handlers} />` (the view
34
+ * from `ref`).
35
+ */
36
+ output?: (texture: TextureId) => Element
37
+ /** Pointer events (default on): the built-in leaf carries the view's
38
+ * handlers. `false` detaches them. */
39
+ events?: boolean
40
+ /** The pointer feed of this view (createPointerFeed), fed from the
41
+ * view's root - the events the sprites let through - so a map bound
42
+ * to it drives the `<Camera2d>` inside this view and no other; inside,
43
+ * `useSpriteLayer().pointer` is this feed. Fixed at creation. */
44
+ pointer?: PointerFeed
45
+ }
46
+
47
+ /**
48
+ * A view of the enclosing layer from a camera of its own (layer.createView
49
+ * as a component): the same sprites, one more target and one entry, so
50
+ * it costs no per-frame JS. The `<SpriteLayer>`'s own leaf is one of
51
+ * these too; a `<SpriteLayer output={false}>` shows ONLY through its
52
+ * `<View2d>` children (split-screen: two side by side). Composites as an
53
+ * ordinary `<texture>` leaf at the view size, or through `output`. A
54
+ * `<Camera2d>`
55
+ * child drives the VIEW's camera from the map it is given, and the view's
56
+ * `pointer` feed carries the view's gestures to that map: inside,
57
+ * `useSpriteLayer()` reports the view as `viewport` and the feed as
58
+ * `pointer`. `<Sprite>` and
59
+ * `<Group>` children mount to the layer as they would outside - a view
60
+ * mirrors the layer's sprites, it has none of its own. Sprites under the
61
+ * view's leaf get their ordinary pointer handlers, with the view's camera
62
+ * undone; the view's own `onPointer*`/`onTap` are the last stop of that
63
+ * walk (`event.sprite` null over empty space).
64
+ */
65
+ export let View2d: ParentComponent<View2dProps> = props => {
66
+ let layer = useContext(LayerContext)
67
+ // The initial props seed createView, so `ref` hands out a configured
68
+ // view and the first frame draws it whole; the effects follow changes
69
+ // only (`defer`).
70
+ let view = untrack(() =>
71
+ layer.createView({
72
+ width: props.width,
73
+ height: props.height,
74
+ camera: props.camera,
75
+ oversample: props.oversample,
76
+ clearColor: props.clearColor,
77
+ label: props.label,
78
+ }),
79
+ )
80
+ createEffect(
81
+ () => [props.width, props.height] as const,
82
+ ([w, h]) => view.setSize(w, h),
83
+ { defer: true },
84
+ )
85
+ createEffect(
86
+ () => props.camera,
87
+ camera => {
88
+ if (camera) view.setCamera(camera)
89
+ },
90
+ { defer: true },
91
+ )
92
+ createEffect(
93
+ () => props.oversample,
94
+ n => {
95
+ if (n !== undefined) view.setOversample(n)
96
+ },
97
+ { defer: true },
98
+ )
99
+ untrack(() => props.ref)?.(view)
100
+ onCleanup(() => view.dispose())
101
+ let pointer = untrack(() => props.pointer) ?? null
102
+ if (pointer) onCleanup(feedPointer(view, pointer))
103
+ // The view's own handlers at the root of the walk; the props are read
104
+ // per event, so a handler prop may change without re-registering.
105
+ onCleanup(
106
+ view.listen({
107
+ onPointerDown: e => props.onPointerDown?.(e),
108
+ onPointerMove: e => props.onPointerMove?.(e),
109
+ onPointerUp: e => props.onPointerUp?.(e),
110
+ onWheel: e => props.onWheel?.(e),
111
+ onTap: e => props.onTap?.(e),
112
+ }),
113
+ )
114
+ let output = untrack(() => props.output)
115
+ let events = untrack(() => props.events) !== false
116
+ let leaf: { id: number } | undefined
117
+ // Auto oversample from the built-in leaf's window box, in device pixels,
118
+ // per view pixel: the same pick as SpriteLayer's fixed mode.
119
+ let pick = () =>
120
+ untrack(() => {
121
+ if (!leaf || props.oversample !== undefined) return
122
+ let box = getBoundingBoxViewport(leaf)
123
+ if (!box) return
124
+ let scale = displayScale() * Math.max(box.width / props.width, box.height / props.height)
125
+ applyOversample(view, scale, props.width, props.height, props.maxOversample)
126
+ })
127
+ onLayout(pick)
128
+ createEffect(() => [displayScale(), props.maxOversample], pick)
129
+ return (
130
+ <ViewportContext value={view}>
131
+ <PointerContext value={pointer}>
132
+ {output ? (
133
+ untrack(() => output(view.texture))
134
+ ) : (
135
+ <texture
136
+ ref={(n: { id: number }) => (leaf = n)}
137
+ src={view.texture}
138
+ width={props.width}
139
+ height={props.height}
140
+ onPointerDown={events ? view.handlers.onPointerDown : undefined}
141
+ onPointerMove={events ? view.handlers.onPointerMove : undefined}
142
+ onPointerUp={events ? view.handlers.onPointerUp : undefined}
143
+ onPointerLeave={events ? view.handlers.onPointerLeave : undefined}
144
+ onWheel={events ? view.handlers.onWheel : undefined}
145
+ />
146
+ )}
147
+ {props.children}
148
+ </PointerContext>
149
+ </ViewportContext>
150
+ )
151
+ }
package/src/dispatch.ts CHANGED
@@ -1,5 +1,5 @@
1
- // Pointer dispatch behind layer.handlers, shared by both layer kinds: the
2
- // element event model one tree deeper, with the LAYER as the root of the
1
+ // Pointer dispatch behind view.handlers, shared by both layer kinds: the
2
+ // element event model one tree deeper, with the VIEW as the root of the
3
3
  // walk. Down, move, up and wheel dispatch on the topmost hit sprite,
4
4
  // bubble through its enclosing groups and end at the layer's listeners;
5
5
  // over empty space the walk is the layer alone (`sprite` null). Claiming
@@ -22,8 +22,8 @@
22
22
  import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
23
23
  import { unprojectCamera } from "./camera.ts"
24
24
  import type { CameraUpdate } from "./camera.ts"
25
- import type { LayerPointerListener, Sprite, SpriteGroup, SpriteHandlers, SpriteLayer, SpritePointerEvent } from "./layer.ts"
26
- import type { RecordLayer } from "./records.ts"
25
+ import type { LayerPointerListener, Sprite, SpriteGroup, SpriteHandlers, SpritePointerEvent } from "./layer.ts"
26
+ import type { ViewHandle } from "./views.ts"
27
27
 
28
28
  // Finger travel from the down point, in window pixels, past which a press
29
29
  // is a drag and never a tap. Core's pan and transform recognizers engage
@@ -43,8 +43,8 @@ export type DispatchDeps = {
43
43
  size: () => [number, number]
44
44
  camera: () => CameraUpdate
45
45
  pick: (x: number, y: number) => Sprite[]
46
- /** The layer: the walk's root and the listeners' currentTarget. */
47
- root: SpriteLayer | RecordLayer
46
+ /** The view: the walk's root and the listeners' currentTarget. */
47
+ root: ViewHandle
48
48
  /** The root's listeners, in registration order. */
49
49
  listeners: Set<LayerPointerListener>
50
50
  /** The clock for tap repeats (default performance.now; checks inject). */
@@ -58,7 +58,7 @@ type HandlerName = "onPointerDown" | "onPointerMove" | "onPointerUp" | "onWheel"
58
58
  // fields); the public types narrow it per handler.
59
59
  type InternalEvent = {
60
60
  sprite: Sprite | null
61
- currentTarget: Sprite | SpriteGroup | SpriteLayer | RecordLayer
61
+ currentTarget: Sprite | SpriteGroup | ViewHandle
62
62
  x: number
63
63
  y: number
64
64
  pointerId: number
package/src/frames.ts CHANGED
@@ -31,10 +31,14 @@ export function writeFrame(
31
31
  data[at + 3] = flipY ? v0 : v1
32
32
  }
33
33
 
34
+ /**
35
+ * What the slicers need of an atlas: its pixel size, the space every rect
36
+ * and gap below is measured in. An `Atlas` fits as it is; a texture from
37
+ * anywhere else (a render target, a camera) passes `{ width, height }`.
38
+ */
39
+ export type AtlasSize = { width: number; height: number }
40
+
34
41
  export type GridOptions = {
35
- /** Pixel size of the atlas the pixel-space options below refer to. */
36
- width: number
37
- height: number
38
42
  /** Cell size in pixels; defaults to width/cols x height/rows. */
39
43
  cellW?: number
40
44
  cellH?: number
@@ -43,6 +47,29 @@ export type GridOptions = {
43
47
  /** Pixel offset of the first cell from the top-left corner; default 0. */
44
48
  marginX?: number
45
49
  marginY?: number
50
+ /**
51
+ * Texels shaved off every side of each frame; default 0. Only a
52
+ * mipmapped atlas needs it: 2^k texels keep mip level k inside the cell
53
+ * (the layer shaders already clamp level-0 samples into the frame), at
54
+ * the cost of that border of every sprite. The stopgap until extrusion
55
+ * (okf/backlog/2d-atlas-extrude.md).
56
+ */
57
+ inset?: number
58
+ }
59
+
60
+ export type NamedFramesOptions = {
61
+ /** As GridOptions.inset: texels shaved off every side of each rect. */
62
+ inset?: number
63
+ }
64
+
65
+ /** A pixel rect to UVs, shrunk by `inset` on every side; `what` names the throw. */
66
+ function frameOf(atlas: AtlasSize, x: number, y: number, w: number, h: number, inset: number, what: string): Frame {
67
+ if (!(inset >= 0)) throw new Error(`${what}: inset must be non-negative, got ${inset}`)
68
+ let iw = w - inset * 2
69
+ let ih = h - inset * 2
70
+ if (!(iw > 0 && ih > 0)) throw new Error(`${what}: inset ${inset} leaves no frame of a ${w} x ${h} rect`)
71
+ let { width, height } = atlas
72
+ return { u0: (x + inset) / width, v0: (y + inset) / height, u1: (x + inset + iw) / width, v1: (y + inset + ih) / height }
46
73
  }
47
74
 
48
75
  /**
@@ -50,17 +77,23 @@ export type GridOptions = {
50
77
  * top to bottom) - the layout every sheet packer and pixel-art tool emits.
51
78
  * Frames are returned in cell order, so `frames[row * cols + col]` addresses
52
79
  * a cell and an animation is a slice of consecutive indices.
80
+ *
81
+ * Cells that touch do not bleed at mip level 0: the layer shaders clamp
82
+ * every sample into its frame (shaders.ts). A mip chain is different - its
83
+ * texels straddle cell edges before sampling - so a mipmapped sheet packs
84
+ * a gutter (`spacing`) or shaves its frames (`inset`); see namedFrames.
53
85
  */
54
- export function grid(cols: number, rows: number, opts: GridOptions): Frame[] {
86
+ export function grid(atlas: AtlasSize, cols: number, rows: number, opts?: GridOptions): Frame[] {
55
87
  if (!(cols > 0 && rows > 0 && Number.isInteger(cols) && Number.isInteger(rows))) {
56
88
  throw new Error(`grid: cols and rows must be positive integers, got ${cols} x ${rows}`)
57
89
  }
58
- let { width, height, spacing = 0, marginX = 0, marginY = 0 } = opts
90
+ let { width, height } = atlas
59
91
  if (!(width > 0 && height > 0)) {
60
92
  throw new Error(`grid: atlas size must be positive, got ${width} x ${height}`)
61
93
  }
62
- let cellW = opts.cellW ?? (width - marginX * 2 - spacing * (cols - 1)) / cols
63
- let cellH = opts.cellH ?? (height - marginY * 2 - spacing * (rows - 1)) / rows
94
+ let { spacing = 0, marginX = 0, marginY = 0, inset = 0 } = opts ?? {}
95
+ let cellW = opts?.cellW ?? (width - marginX * 2 - spacing * (cols - 1)) / cols
96
+ let cellH = opts?.cellH ?? (height - marginY * 2 - spacing * (rows - 1)) / rows
64
97
  if (!(cellW > 0 && cellH > 0)) {
65
98
  throw new Error(`grid: derived cell size ${cellW} x ${cellH} is not positive`)
66
99
  }
@@ -69,7 +102,7 @@ export function grid(cols: number, rows: number, opts: GridOptions): Frame[] {
69
102
  for (let col = 0; col < cols; col++) {
70
103
  let x = marginX + col * (cellW + spacing)
71
104
  let y = marginY + row * (cellH + spacing)
72
- frames.push({ u0: x / width, v0: y / height, u1: (x + cellW) / width, v1: (y + cellH) / height })
105
+ frames.push(frameOf(atlas, x, y, cellW, cellH, inset, "grid"))
73
106
  }
74
107
  }
75
108
  return frames
@@ -78,21 +111,37 @@ export function grid(cols: number, rows: number, opts: GridOptions): Frame[] {
78
111
  /**
79
112
  * Name frames from a pixel-rect map: `{ hero: [x, y, w, h], ... }` in atlas
80
113
  * pixels to `{ hero: Frame, ... }`. The named counterpart of `grid` for
81
- * hand-packed or tool-exported sheets.
114
+ * hand-packed or tool-exported sheets. The key union comes from the table
115
+ * you pass: an object literal (or a built table with `satisfies Record<...>`
116
+ * on the literal) gives a record whose every name is checked; a table
117
+ * typed `Record<string, ...>` gives back an index signature, so every name
118
+ * compiles, a typo included, and no `!` is needed on a lookup - the
119
+ * scaffold's tsconfig does not set noUncheckedIndexedAccess.
120
+ *
121
+ * The oldest atlas trap - frames addressed as whole-pixel rects that share
122
+ * an edge bleed into each other, a sprite drifting by fractions of a pixel
123
+ * flashing a one-texel line of the cell next door - is handled in the
124
+ * layer shaders, which clamp every sample into its frame. What they cannot
125
+ * handle is a mip chain, whose texels average across cell edges before
126
+ * sampling: a mipmapped sheet passes `inset` (2^k texels keeps level k
127
+ * clean, losing that border) or packs a gutter, until extrusion lands
128
+ * (okf/backlog/2d-atlas-extrude.md).
82
129
  */
83
130
  export function namedFrames<K extends string>(
84
- atlasW: number,
85
- atlasH: number,
131
+ atlas: AtlasSize,
86
132
  rects: Record<K, [number, number, number, number]>,
133
+ opts?: NamedFramesOptions,
87
134
  ): Record<K, Frame> {
88
- if (!(atlasW > 0 && atlasH > 0)) {
89
- throw new Error(`namedFrames: atlas size must be positive, got ${atlasW} x ${atlasH}`)
135
+ let { width, height } = atlas
136
+ if (!(width > 0 && height > 0)) {
137
+ throw new Error(`namedFrames: atlas size must be positive, got ${width} x ${height}`)
90
138
  }
139
+ let inset = opts?.inset ?? 0
91
140
  let out = {} as Record<K, Frame>
92
141
  for (let name in rects) {
93
142
  let [x, y, w, h] = rects[name]
94
143
  if (!(w > 0 && h > 0)) throw new Error(`namedFrames: frame '${name}' has non-positive size ${w} x ${h}`)
95
- out[name] = { u0: x / atlasW, v0: y / atlasH, u1: (x + w) / atlasW, v1: (y + h) / atlasH }
144
+ out[name] = frameOf(atlas, x, y, w, h, inset, `namedFrames: frame '${name}'`)
96
145
  }
97
146
  return out
98
147
  }
package/src/index.ts CHANGED
@@ -1,16 +1,19 @@
1
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)
2
+ // One atlas, N quads in one draw per VIEW: a layer holds the sprites and
3
+ // shows only through its views (layer.createView / <View2d>, each a target
4
+ // with a camera of its own - a Unity scene renders only through Cameras,
5
+ // a Godot World2D only through Viewports). The live layer
6
+ // (createSpriteLayer/addSprite) backs every sprite with a SPATIAL ARENA
7
+ // node whose Pose2D record sink writes the pose instance buffer at the
8
+ // core flush, so core producers reach sprites and picking walks the core
9
+ // BVH; style stays a JS-written second instance buffer. The records layer (createRecordLayer)
7
10
  // is the raw escape hatch for motion only JS can compute: 13 JS-owned
8
11
  // floats per sprite published through the zero-copy write lease. The baked
9
12
  // tile layer (createTileLayer/TileLayer) is the static sibling: a tile
10
13
  // world rendered once into textures and composited as a few quads,
11
14
  // re-baked on change. Two faces throughout: the imperative core (usable
12
15
  // without Solid components) and the components (SpriteLayer/Sprite/Group/
13
- // TileLayer) on top. See AGENTS.md for the model and the traps.
16
+ // View2d/TileLayer) on top. See AGENTS.md for the model and the traps.
14
17
 
15
18
  export { addGroup, addSprite, createSpriteLayer, getSprite, removeGroup, removeSprite, setGroup, setGroupTransition, setSprite, setSpriteParent, setSpriteTransition, worldPosition, POSE_FLOATS, STYLE_FLOATS } from "./layer.ts"
16
19
  export { createRecordLayer, FLOATS_PER_SPRITE } from "./records.ts"
@@ -19,11 +22,21 @@ export { pointInSprite } from "./pick.ts"
19
22
  export { projectCamera, unprojectCamera } from "./camera.ts"
20
23
  export type { CameraState, CameraUpdate } from "./camera.ts"
21
24
  export { createCamera2d } from "./camera2d.ts"
22
- export type { Camera2d as Camera2dHandle, Camera2dOptions } from "./camera2d.ts"
23
- export type { Camera2dPose, Camera2dTarget, Rect2d } from "./camera-motion.ts"
25
+ export type { Camera2d as Camera2dHandle, Camera2dAxes, Camera2dOptions, Camera2dPose, Camera2dTarget, Rect2d } from "./camera2d.ts"
26
+ export { camera2dActions, camera2dBindings } from "./input.ts"
27
+ export type { CameraDevices } from "./input.ts"
24
28
  export type {
25
29
  AddSpriteOptions,
30
+ Capsule,
31
+ Circle,
26
32
  GroupOptions,
33
+ Impact,
34
+ MoveOptions,
35
+ MoveResult,
36
+ Overlap,
37
+ QueryOptions,
38
+ RayHit,
39
+ Rect,
27
40
  LayerPointerEvent,
28
41
  LayerPointerListener,
29
42
  LayerTapEvent,
@@ -38,17 +51,20 @@ export type {
38
51
  SpriteTapEvent,
39
52
  SpriteWheelEvent,
40
53
  TransitionEndEvent,
54
+ Volume,
41
55
  } from "./layer.ts"
56
+ export { feedPointer } from "./views.ts"
57
+ export type { ViewHandle, ViewOptions } from "./views.ts"
42
58
  export { createTileLayer } from "./tiles.ts"
43
59
  export type { TileChunk, TileLayer as TileLayerHandle, TileLayerOptions } from "./tiles.ts"
44
60
 
45
61
  export type { NodeTransition, NodeTransitionSpec } from "flux:spatial"
46
62
  export { grid, namedFrames, FULL_FRAME } from "./frames.ts"
47
- export type { Frame, GridOptions } from "./frames.ts"
63
+ export type { AtlasSize, Frame, GridOptions, NamedFramesOptions } from "./frames.ts"
48
64
  export { createAnimation } from "./animation.ts"
49
65
  export type { AnimationOptions, SpriteAnimation } from "./animation.ts"
50
66
  export { fitOversample } from "./oversample.ts"
51
67
  export { createAtlas } from "./atlas.ts"
52
68
  export type { Atlas, AtlasOptions } from "./atlas.ts"
53
- export { Camera2d, Group, Sprite, SpriteLayer, TileLayer, useSpriteLayer } from "./components.tsx"
54
- export type { Camera2dProps, GroupProps, LayerPointerProps, SpriteLayerProps, SpritePointerProps, SpriteProps, TileCamera, TileLayerProps } from "./components.tsx"
69
+ export { Camera2d, Group, Sprite, SpriteLayer, TileLayer, View2d, useSpriteLayer } from "./components/index.ts"
70
+ export type { Camera2dProps, GroupProps, LayerPointerProps, SpriteLayerProps, SpritePointerProps, SpriteProps, TileCamera, TileLayerProps, View2dProps } from "./components/index.ts"
package/src/input.ts ADDED
@@ -0,0 +1,50 @@
1
+ // The standard input wiring for the 2d camera: the action declarations it
2
+ // consumes (its axes, by the shared vocabulary) and a preset that binds
3
+ // the devices an app hands in - plain data the app could write by hand,
4
+ // applied with `input.bind(camera2dBindings({ pointer, gamepad }))` and
5
+ // adjusted afterwards with unbind; nothing binds unless the app says so
6
+ // (ARCHITECTURE.md). `pointer` is the feed of the view showing the layer
7
+ // (`createPointerFeed()`, handed to `<SpriteLayer pointer>` or
8
+ // `<View2d pointer>`, or bridged with feedPointer for an imperative view),
9
+ // `gamepad` a core gamepad device, `keyboard` the core keyboard device.
10
+ //
11
+ // Keys and sticks move the CAMERA where a drag moves the content (the
12
+ // arrow right scrolls the view right, as every map does), so those
13
+ // bindings go through invert() and `pan` keeps its one meaning.
14
+
15
+ import { invert } from "@solidrt/core/input"
16
+ import type { Binding, GamepadDevice, KeyboardDevice, PointerFeed } from "@solidrt/core"
17
+
18
+ export type CameraDevices = {
19
+ pointer?: PointerFeed
20
+ gamepad?: GamepadDevice
21
+ keyboard?: KeyboardDevice
22
+ }
23
+
24
+ /** The 2d camera's actions: `pan`, `zoom`, `roll`. */
25
+ export let camera2dActions = { pan: "vec2", zoom: "axis", roll: "axis" } as const
26
+
27
+ /**
28
+ * The 2d camera's standard bindings: one finger or a mouse drag pans and
29
+ * so do two fingers, a pinch and the wheel zoom about the pointer, a twist
30
+ * rolls; the left stick scrolls, the triggers zoom, the right stick's x
31
+ * rolls; the arrow keys scroll and minus/equals zoom.
32
+ */
33
+ export function camera2dBindings(devices: CameraDevices): Binding[] {
34
+ let out: Binding[] = []
35
+ let { pointer, gamepad, keyboard } = devices
36
+ if (pointer) {
37
+ out.push(
38
+ { action: "pan", source: pointer.drag },
39
+ { action: "pan", source: pointer.pan },
40
+ { action: "zoom", source: pointer.pinch },
41
+ { action: "zoom", source: pointer.wheel },
42
+ { action: "roll", source: pointer.twist },
43
+ )
44
+ }
45
+ if (gamepad) {
46
+ out.push({ action: "pan", source: invert(gamepad.leftStick) }, { action: "zoom", source: gamepad.triggers }, { action: "roll", source: invert(gamepad.axis("rightX")) })
47
+ }
48
+ if (keyboard) out.push({ action: "pan", source: invert(keyboard.arrows) }, { action: "zoom", source: keyboard.axis("Minus", "Equal") })
49
+ return out
50
+ }