@solidrt/2d 0.0.56 → 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 CHANGED
@@ -17,8 +17,16 @@ moved subtrees in Rust, and picking walks the core BVH.
17
17
  `addGroup`/`setGroup`/`setSpriteParent`/`setSpriteTransition`/
18
18
  `setGroupTransition` - plain objects, no signals, usable without
19
19
  components), the records layer (records.ts: `createRecordLayer` - the
20
- raw escape hatch, below), and the component face (components.tsx:
21
- `SpriteLayer`/`Sprite`/`Group`/`Camera2d` over context).
20
+ raw escape hatch, below), and the component face (components/:
21
+ `SpriteLayer`/`Sprite`/`Group`/`View2d`/`Camera2d` over context).
22
+ - A layer holds sprites and renders NOTHING by itself: it shows through
23
+ its VIEWS (`layer.createView({ width, height, camera?, oversample?,
24
+ clearColor?, label? })`, `<View2d>`, or the `<SpriteLayer>`'s own leaf,
25
+ which is one), each a draw target with a camera, a size and pointer
26
+ dispatch of its own - a Unity scene renders only through Cameras, a
27
+ Godot World2D only through Viewports, and there is no privileged first
28
+ one. The window view, the minimap, two split-screen panes are the same
29
+ `ViewHandle`. Details under Views below.
22
30
  - Node layer ownership split, two instance-buffer slots on one pipeline:
23
31
  slot 0 is the POSE buffer `[x, y, angle, sx, sy]` written ONLY by the
24
32
  core (each sprite node's Pose2D record sink; one coalesced buffer write
@@ -37,9 +45,34 @@ moved subtrees in Rust, and picking walks the core BVH.
37
45
  - Picking is the core index: `pick` raycasts [x, y, -1] along +z (exact
38
46
  rotated-rect via the node's local box) and returns ALL hits topmost
39
47
  first (highest slot) - the all-hits shape of @solidrt/3d's pick, so
40
- `pick(x, y)[0]` is the topmost; `pickRect` is the BVH overlap query
41
- (exact for rotated sprites, the marquee), unordered. Both filter to the
42
- layer's own nodes - the arena is shared with e.g. a 3d scene.
48
+ `pick(x, y)[0]` is the topmost; `pickRect` is `overlap` over an
49
+ unrotated rect, sprites only (exact for rotated sprites, the marquee),
50
+ unordered. Every query passes the core a filter with the layer's root
51
+ node (sprites and groups without a parent hang off it), so the arena
52
+ being shared with e.g. a 3d scene costs nothing in JS.
53
+ - Spatial queries (`checks/collision-check.tsx` pins the contract): the
54
+ 3d scene's trio one dimension down, Godot's PhysicsDirectSpaceState2D
55
+ and CharacterBody2D in their names. `layer.raycast(x, y, dx, dy,
56
+ opts?)` is every shown sprite the ray strikes, nearest first, with
57
+ distance, edge point and edge normal; `layer.overlap(volume, opts?)`
58
+ every sprite a volume touches with its deepest contact `{ sprite,
59
+ point, normal, depth }`, unordered; `layer.sweep(volume, dx, dy,
60
+ opts?)` every sprite the moving volume first touches `{ sprite, time,
61
+ point, normal }`, earliest first; `layer.moveAndSlide(volume, dx, dy,
62
+ opts?)` the character mover over them, one core call per body per
63
+ frame (the depenetration, slide and floor-snap loop runs in the spatial
64
+ core, the same one @solidrt/3d's mover uses), returning `{ motion,
65
+ floor, wall, ceiling, hits }` with `up` defaulting to [0, -1] (y-down).
66
+ A `Volume` is a `Circle` `{ x, y, radius }`, a `Capsule` `{ ax, ay,
67
+ bx, by, radius }` or a `Rect` `{ x, y, width, height, rotation? }`;
68
+ `QueryOptions.sprites` is an include-list. Results name sprites, never
69
+ node ids, and every query runs the pending batch first, like pick.
70
+ The trap behind the design: a sprite is a COLUMN in the index
71
+ (`SPRITE_DEPTH` half-depth along z), not a flat quad. Against a flat
72
+ triangle the core's contact for a circle whose center lies over the
73
+ sprite is the plane normal (z) with depth = radius; against a column
74
+ every volume at z = 0 meets only side faces, so contacts, sweeps and
75
+ the mover stay in the plane with no 2d narrowphase of their own.
43
76
  - Groups (`addGroup`/`<Group>`) are plain arena nodes (x, y, rotation,
44
77
  UNIFORM scale - a group is a frame, never a sprite size; sprite w/h
45
78
  lives in the sprite node's scale, which is why sprites cannot parent
@@ -83,68 +116,139 @@ moved subtrees in Rust, and picking walks the core BVH.
83
116
  - Layer space is pixels, top-left origin, y-down - the render tree's frame.
84
117
  The pipeline's clip space is y-down too (core gpu.ts pixel contract), so
85
118
  the vertex stage carries NO flip anywhere. Do not add one.
86
- - The camera (`setCamera`/the `camera` prop) is ONE `CameraUpdate` type
87
- across both layers: offset, zoom, and rotation about a pivot (camera.ts
88
- documents the semantics and the heading-upward convention,
89
- `rotation = -h - pi/2`). On the sprite layer it is a shared-params
90
- write (`uCamera` + `uCameraRot`, the rotation in-shader), one call
91
- however many sprites exist; `projectCamera`/`unprojectCamera` export
92
- the world <-> screen mapping as pure functions, and pointer dispatch
93
- undoes the camera with the latter, so events arrive in world (layer)
94
- pixels. Picking itself works in world space and never sees the camera.
95
- `layer.camera()` reads the camera back (every field present - the
96
- `CameraState` snapshot, @solidrt/3d's scene.camera()) and
97
- `layer.project(x, y)` / `layer.unproject(x, y)` are the two pure
119
+ - The camera (`view.setCamera`/the `camera` prop) is ONE `CameraUpdate`
120
+ type across both layer kinds' views: offset, zoom, and rotation about a
121
+ pivot (camera.ts documents the semantics and the heading-upward
122
+ convention, `rotation = -h - pi/2`). It is a shared-params write on
123
+ the view's target (`uCamera` + `uCameraRot`, the rotation in-shader),
124
+ one call however many sprites exist; `projectCamera`/`unprojectCamera`
125
+ export the world <-> screen mapping as pure functions, and pointer
126
+ dispatch undoes the view's camera with the latter, so events arrive in
127
+ world (layer) pixels. Picking itself works in world space and never
128
+ sees a camera. `view.camera()` reads the camera back (every field
129
+ present - the `CameraState` snapshot, @solidrt/3d's scene.camera())
130
+ and `view.project(x, y)` / `view.unproject(x, y)` are the two pure
98
131
  functions over it.
99
- - Camera control: `createCamera2d(layer | layers, { viewport: () =>
132
+ - Camera control: `createCamera2d(view | views, { viewport: () =>
100
133
  ({ width, height }), world?: { width, height }, min/maxZoom?, pivot?,
101
- deadZone?: { width, height }, zoomSpeed?, followSpeed?, inertia?,
102
- x?, y?, zoom?, rotation? })` - Godot's Camera2D and Three's
103
- MapControls in one control over the shared CameraUpdate: drag to pan
104
- with inertia on release, wheel and pinch zoom about the pointer, eased
105
- `glideTo`/`fit`, `follow(x, y)` through a dead zone with damping,
106
- rotation about the pivot, and world bounds that CONTAIN the view (an
107
- axis whose view is wider than the world centers; limits ignore
108
- rotation, Godot's rule). The pose is "world point at the pivot", the
109
- pivot a viewport fraction defaulting to the center, so `camera().x/y`
110
- is the view center and glideTo/follow land there (without a `world`,
111
- the default pose puts world 0,0 at the pivot: a fill layer that wants
112
- world = screen at rest takes `pivot: { x: 0, y: 0 }`). The first
113
- argument is anything with the layers' `setCamera` (a sprite layer, a
114
- record layer, both at once, or a signal setter feeding `<TileLayer
115
- camera>`). Input runs on core's `createTransform` recognizer like the
116
- 3d orbit camera (arena arbitration, slop swallowed, one delta per
117
- FRAME - a synthetic drag under a frozen clock pans nothing until frames
118
- step). Two ways in: `cam.attach(layer)` listens at the layer's root, so
119
- the camera sees exactly the presses the sprites let through (a sprite
120
- that stops its down keeps the camera out of that drag; a wheel
121
- anywhere zooms) and pushes the pose synchronously on input; the raw
122
- `cam.handlers` spread onto a leaf without a dispatch (a tile world on
123
- its own). Call `cam.update(dt)` from a frame loop (it pushes one
124
- setCamera per driven layer when the pose changed and reports that),
125
- gated on the reactive `cam.active()` (true while a glide, fling, fit
126
- or follow needs frames, false at rest), read `cam.camera()` for
134
+ deadZone?: { width, height }, panSpeed?, zoomSpeed?, rollSpeed?,
135
+ followSpeed?, inertia?, x?, y?, zoom?, rotation? })` - Godot's Camera2D
136
+ and Three's MapControls in one control over the shared CameraUpdate:
137
+ pan with inertia on release, zoom about a point, eased `glideTo`/`fit`,
138
+ `follow(x, y)` through a dead zone with damping, roll about the pivot,
139
+ and world bounds that CONTAIN the view (an axis whose view is wider
140
+ than the world centers - so at the fit zoom a pan is a no-op, there is
141
+ nothing to pan; limits ignore rotation, Godot's rule). The pose is
142
+ "world point at the pivot", the pivot a viewport fraction defaulting to
143
+ the center, so `camera().x/y` is the view center and glideTo/follow
144
+ land there (without a `world`, the default pose puts world 0,0 at the
145
+ pivot: a fill layer that wants world = screen at rest takes `pivot: {
146
+ x: 0, y: 0 }`). The first argument is anything with a view's
147
+ `setCamera` (a view of a sprite layer, of a record layer, several at
148
+ once - one camera over a scene's layers - or a signal setter feeding
149
+ `<TileLayer camera>`).
150
+ Input, the rule (ARCHITECTURE.md): the control consumes a device-free
151
+ abstraction and never handles events itself. Its `axes` (core's
152
+ createAxes contract) are `pan` (vec2: a delta is finger travel in
153
+ viewport heights, applied as content travel - the world follows the
154
+ finger; a rate slides at one viewport height per second), `zoom` (axis,
155
+ octaves, positive in: a delta bracketed by a gesture - a pinch - applies
156
+ at once about its focal point, an unbracketed one - a wheel notch -
157
+ retargets an eased glide, which is how the control tells a finger from
158
+ an impulse; notches compound on the pending target) and `roll` (axis,
159
+ turns); a pan gesture's begin stops any glide (a finger landing on a
160
+ gliding view holds it) and its end flings with the drag's velocity. The
161
+ verbs, each pushing the pose at once: `panBy(dx, dy)` screen pixels,
162
+ `zoomAt(sx, sy, factor, { glide? })`, `rollBy(angle)`, `set(pose)`,
163
+ `glideTo`, `fit`, `follow`/`unfollow`, `interrupt`, `release`. An input
164
+ map (core AGENTS.md) drives the axes by name and the APP binds devices
165
+ to it: the view's pointer feed (`createPointerFeed()`, handed to
166
+ `<SpriteLayer pointer>` / `<View2d pointer>`, or bridged with
167
+ `feedPointer(view, feed)` for an imperative view - fed from the view's
168
+ ROOT, so a sprite that claims its press keeps the camera out of that
169
+ drag and a wheel anywhere zooms), a pad, the keyboard. Nothing binds
170
+ by default; `camera2dBindings({ pointer, gamepad?, keyboard? })` is the
171
+ standard set (drag and two fingers pan, pinch and wheel zoom, twist
172
+ rolls; the left stick and the arrows scroll the VIEW - bound through
173
+ invert(), since keys and sticks move the camera where a drag moves the
174
+ content - the triggers zoom, the right stick's x rolls) and
175
+ `camera2dActions` the declarations:
176
+
177
+ ```tsx
178
+ let pointer = createPointerFeed()
179
+ feedPointer(view, pointer)
180
+ let input = createInputMap(camera2dActions)
181
+ input.bind(camera2dBindings({ pointer, gamepad: gamepad() }))
182
+ input.drive(cam.axes)
183
+ ```
184
+
185
+ Call `cam.update(dt)` from a frame loop (it advances glides, follow,
186
+ inertia and the axis rates, pushes one setCamera per driven view when
187
+ the pose changed and reports that), gated on the reactive
188
+ `cam.active()` (true while a glide, fling, fit, follow or pan gesture
189
+ needs frames, or a rate drives; false at rest), read `cam.camera()` for
127
190
  projectCamera. The camera has NO tap of its own: taps are the
128
191
  dispatch's (`onTap` on the root with `e.sprite` null is "tap on empty
129
- space"). `<Camera2d>` inside `<SpriteLayer>` is all of that wired
130
- through context, the 3d `<OrbitCamera>` shape: options read at mount,
131
- `viewport` defaulting to the layer's size, frames only while
132
- `active()`. Anchors and deltas arrive in the leaf's own frame (core's
133
- recognizers measure in the node's frames), so a leaf under a
134
- designSize fit pans and zooms correctly. The motion is
135
- camera-motion.ts, pure; checks/camera2d-check.ts pins the clamp,
136
- anchoring, glides, follow and inertia headless, examples/camera.tsx
137
- (function face, attach) and examples/pick.tsx (`<Camera2d>`) are the
138
- live guards. Not yet, all additive: two-finger twist rotation,
139
- rotation glides, a contain origin other than center, live option
140
- props on `<Camera2d>` (the motion reads them once; remount for new
141
- bounds).
192
+ space"). `<Camera2d input={input}>` inside `<SpriteLayer>` is all of
193
+ that wired through context, the 3d `<OrbitCamera>` shape: options read
194
+ at mount, driving the nearest view (the `<SpriteLayer>`'s own or the
195
+ enclosing `<View2d>`), `viewport` defaulting to that view's size, the
196
+ map's `pan`/`zoom`/`roll` actions (or the names in `actions`) driving
197
+ the axes, frames only while `active()`. A view's feed normalizes a drag
198
+ by the leaf's own box, so a leaf under a designSize fit pans and zooms
199
+ correctly. camera2d.ts imports `@solidrt/core/input` only, so
200
+ checks/camera2d-check.ts pins the clamp, anchoring, glides, follow,
201
+ inertia and the axes headless; examples/camera.tsx (function face) and
202
+ examples/pick.tsx (`<Camera2d>`) are the live guards. The shared
203
+ vocabulary with @solidrt/3d, one kind and unit per word: `pan` vec2,
204
+ `zoom` axis (octaves), `roll` axis (turns) here; `rotate`, `look`,
205
+ `move`, `rise` there. The pose options are initial values; every other
206
+ option (world, zoom range, pivot, dead zone, the speeds, inertia) is
207
+ read where it applies, never snapshotted, so `<Camera2d>` props are
208
+ live - forwarded as getters, a bounds or pivot change re-clamping and
209
+ pushing the pose at once through `set({})` - and a function-face
210
+ caller mutates the options object it passed (the `<OrbitCamera>` rule,
211
+ pinned in the check's live-options case). Not yet, all additive:
212
+ rotation glides, a contain origin other than center.
142
213
  - Layer tint (`setTint(rgba)`/the `tint` option and prop, both layer
143
214
  kinds): one `uTint` shared-params write multiplied over every sprite's
144
215
  own tint - day/night, a dimmed parallax plane, a fade-in. Cheap to
145
216
  animate (no record touches), unlike TileLayer's, which re-renders
146
217
  resident chunks. The same contract as TileLayer.setTint, so one signal
147
218
  drives a whole scene across layer kinds.
219
+ - Views (`layer.createView({ width, height, camera?, oversample?,
220
+ clearColor?, label? })`, both layer kinds): the ONLY output a layer
221
+ has - the window-filling main view, a minimap, a radar strip, a zoomed
222
+ inset, two split-screen panes - @solidrt/3d's scene.createView one
223
+ dimension down, as Unity renders a scene only through Cameras and
224
+ Godot a World2D only through Viewports. Each view is one draw target
225
+ holding one entry over the layer's pipeline and instance buffers
226
+ (views.ts): no sprite is mirrored, no record is written twice, and key
227
+ order (`orderBy`) comes along, since the core gathers the buffers
228
+ themselves at publish - ONE live entry declares the order (the first
229
+ view's; when that view is disposed the next live view re-adds its
230
+ entry ordered), every other entry reads them sorted. Growth, the
231
+ instance count and the layer tint fan out to every view; the camera
232
+ and the viewport are the view's own params, so a view costs no
233
+ per-frame JS beyond its camera writes. The `ViewHandle` is the
234
+ viewport contract (texture, width/height/setSize,
235
+ oversample/setOversample, setCamera/camera/project/unproject,
236
+ listen/handlers/handlersFor, dispose - views also die with the layer);
237
+ the sprites stay the layer's (`addSprite`, `pick`, `setTint`,
238
+ `createView`, `dispose`). Pointer events on a view leaf run the
239
+ dispatch with the VIEW's camera undone over the layer's pick, the view
240
+ as the root of the walk: a sprite under a minimap gets its ordinary
241
+ handlers, `view.listen` is the last stop (a tap there with `e.x`/`e.y`
242
+ in world pixels is "glide the main camera here"), and
243
+ a camera bound to a feed `feedPointer(view, feed)` bridges drives any
244
+ view. `<View2d>` is the
245
+ component form; the `<SpriteLayer>`'s built-in leaf is a view too, and
246
+ `<SpriteLayer output={false}>` has none, showing only through its
247
+ `<View2d>` children (examples/split-screen.tsx). examples/views.tsx is
248
+ the live guard. Not yet, all additive: tile-layer views, a `layers`
249
+ bitmask (a markers-only minimap), `into` tiling, per-view tint, and a
250
+ viewport over SEVERAL layers under one camera (their scene-level
251
+ camera) - okf/backlog/2d-layer-views-additive.md.
148
252
  - Retargeted motion is NATIVE: `setSpriteTransition(sprite, { position:
149
253
  { duration: 700, bounce: 0.3 }, ... })` (or the `transition` prop) makes
150
254
  setSprite writes TARGETS the core animates toward - position/scale
@@ -217,6 +321,29 @@ empty chunks cost nothing). Tiles are data, not
217
321
  children: there is no `<Tile>` component on purpose - write cells through
218
322
  `ref` with `setTile`.
219
323
 
324
+ Bulk writes: `setTiles(col, row, cols, rows, cells)` writes a rect at
325
+ once - `cells` row-major, `cols * rows` long, either frames (null clears)
326
+ or, with a `frames` table given at creation (`createTileLayer(..., {
327
+ frames })`, the `<TileLayer frames>` prop: a tileset, `grid()`'s array,
328
+ up to 65534 entries), indices into it (-1 clears; a Uint16Array holds -1
329
+ as 0xffff, the same bits) - Unity's SetTilesBlock, Phaser's putTilesAt
330
+ over the map's tileset. One locate and one dirty mark per chunk the rect
331
+ touches, the per-cell loop inside the layer, the same flush; a chunk the
332
+ rect only clears never allocates, so a whole-world write of a sparse
333
+ world allocates exactly the chunks the per-cell seed would. A generated
334
+ or worker-built world is a Uint16Array of indices, transferable, never an
335
+ array of frame objects; a fill is `new Uint16Array(n).fill(i)`. `tint`
336
+ in the options applies to every cell set. Every entry is validated before
337
+ the first write, so a bad one changes nothing; the table is copied at
338
+ creation. The rect form is the shape for worker transfer and chunk
339
+ re-fill, not a large speedup: the per-cell floor is the record write
340
+ itself, so a 16k-cell solid rect re-writes in ~14 ms against ~25 ms as a
341
+ setTile loop and a sparse rect is at parity with the loop, while a first
342
+ seed is dominated by chunk allocation at 1-2 ms per chunk
343
+ (probes/2d-tiles-bulk-bench.tsx). probes/2d-tiles-bulk-probe.tsx holds
344
+ the three seeds (setTile, index rect, frame rect) to the same cells,
345
+ chunks and bakes.
346
+
220
347
  Tinting, two levels: `setTile(col, row, frame, { tint })` writes the
221
348
  cell's record tint (same `[r, g, b, a]` 0..1 semantics as a sprite's
222
349
  tint; absent keys keep their values, a cell set from empty starts at
@@ -232,37 +359,50 @@ on approach, evict) - okf/backlog/2d-baked-layers.md.
232
359
 
233
360
  | Component | Props |
234
361
  |---|---|
235
- | `SpriteLayer` | width?, height? (layer pixels - both, or neither = FILL: the leaf lays out at 100% of its sized parent and the layer follows its box, so layer pixels are the leaf's own coordinates; mount-fixed, `output` requires explicit sizes, matching `<Scene>` in @solidrt/3d), atlas (TextureId), capacity?, clearColor?, camera?, tint? ([r,g,b,a] 0..1, over the whole layer), oversample?, maxOversample?, orderBy?, label?, ref?, output?, events?, onPointer{Down,Move,Up}?, onWheel?, onTap? (the root of the walk: `event.sprite` is the hit sprite or null over empty space) |
362
+ | `SpriteLayer` | atlas (TextureId), capacity?, tint? ([r,g,b,a] 0..1, over the whole layer in every view), orderBy?, label?, ref?(layer) - the layer; plus its OWN VIEW, unless `output={false}`: width?, height? (view pixels - both, or neither = FILL: the leaf lays out at 100% of its sized parent and the view follows its box, so view pixels are the leaf's own coordinates; mount-fixed, a function `output` requires explicit sizes, matching `<Scene>` in @solidrt/3d), clearColor?, camera?, oversample?, maxOversample?, viewRef?(view), output? (a function composes the leaf from the texture id; `false` = no own view, the layer shows only through `<View2d>` children and the view props throw), events?, pointer? (a createPointerFeed fed from the view's root, what a map over this view binds; `useSpriteLayer().pointer` inside), onPointer{Down,Move,Up}?, onWheel?, onTap? (the view's root: `event.sprite` is the hit sprite or null over empty space) |
236
363
  | `Sprite` | x, y (center; local to the enclosing `<Group>`), w, h, frame?, rotation? (radians, clockwise), tint? ([r,g,b,a] 0..1), visible?, transition?, onPointer{Down,Move,Up,Enter,Leave}?, onWheel?, onTap?, ref? |
237
364
  | `Group` | x?, y?, rotation?, scale? (uniform, scales the subtree), visible? (the whole subtree), transition?, onPointer{Down,Move,Up}?, onWheel?, onTap? (bubbled from hit child sprites), ref? |
238
- | `Camera2d` | createCamera2d's options minus `viewport` (world?, min/maxZoom?, pivot?, deadZone?, zoomSpeed?, followSpeed?, inertia?, x?, y?, zoom?, rotation?), viewport? (`() => { width, height }`, default: the layer's size), ref? - a `<SpriteLayer>` child driving its camera from the layer's root; read at mount |
239
- | `TileLayer` | cols, rows, tileW, tileH, atlas (TextureId), chunkClearColor?, filter?, chunkTiles?, tint? ([r,g,b,a] 0..1, over the whole layer), oversample?, maxOversample?, camera? (TileCamera: x, y, zoom, rotation, pivotX, pivotY), label?, ref? |
365
+ | `Camera2d` | createCamera2d's options minus `viewport` (world?, min/maxZoom?, pivot?, deadZone?, panSpeed?, zoomSpeed?, rollSpeed?, followSpeed?, inertia?, x?, y?, zoom?, rotation?), viewport? (`() => { width, height }`, default: the driven viewport's size), input? (the input map driving its `pan`/`zoom`/`roll` axes; live), actions? (action names per axis when the map's differ), ref? - a `<SpriteLayer>` child driving the nearest view's camera (the `<SpriteLayer>`'s own, or inside a `<View2d>` that view) from the map, nothing else; read at mount; throws under `output={false}` outside a `<View2d>` |
366
+ | `View2d` | a `<SpriteLayer>` child: one more view of the layer from a camera of its own (layer.createView as a component): width, height (view pixels, live; fixed-size only for now), camera? (partial CameraUpdate on the view's camera, live; the same state a `<Camera2d>` child writes), oversample?, maxOversample? (the auto-pick, as SpriteLayer's), clearColor?, label? (createView's, fixed), ref?(view), output?(texture) (else a built-in `<texture>` leaf at the view size carrying the view's handlers), events?, pointer? (this view's feed, fed from its root), onPointer{Down,Move,Up}?, onWheel?, onTap? (the view's root: `event.sprite` null over empty space); a `<Camera2d>` child drives the VIEW from its map (inside, `useSpriteLayer()` reports the view as `viewport` and the feed as `pointer`); `<Sprite>`/`<Group>` children mount to the layer as outside |
367
+ | `TileLayer` | cols, rows, tileW, tileH, atlas (TextureId), frames? (the tileset `setTiles` indices name), chunkClearColor?, filter?, chunkTiles?, tint? ([r,g,b,a] 0..1, over the whole layer), oversample?, maxOversample?, camera? (TileCamera: x, y, zoom, rotation, pivotX, pivotY), label?, ref? |
240
368
 
241
- `SpriteLayer` owns the layer and renders the built-in `<texture>` leaf
242
- carrying the layer's pointer handlers (opt out with `events={false}`; compose
243
- yourself with `output`, then spread `useSpriteLayer().layer.handlers` onto
244
- your leaf). `useSpriteLayer()` returns `{ layer, parent }` - the same shape
245
- as `useScene()` in `@solidrt/3d` - where `parent` is the enclosing
246
- `<Group>`'s handle (null at the layer root), so imperative
247
- `addSprite(layer, { parent })` mounts where the JSX sits. `Sprite` renders
369
+ `SpriteLayer` owns the layer and its own view, rendered as the built-in
370
+ `<texture>` leaf carrying the view's pointer handlers (opt out with
371
+ `events={false}`; compose yourself with `output`, then spread
372
+ `useSpriteLayer().viewport.handlers` onto your leaf; `output={false}` for
373
+ no own view at all). `useSpriteLayer()` returns `{ layer, parent,
374
+ viewport, pointer }` - the same shape as `useScene()` in `@solidrt/3d` -
375
+ where `parent` is the enclosing `<Group>`'s handle (null at the layer
376
+ root), so imperative `addSprite(layer, { parent })` mounts where the JSX
377
+ sits, `viewport` is the nearest view (the `<SpriteLayer>`'s own, or
378
+ inside a `<View2d>` that view), what a `<Camera2d>` drives, and
379
+ `pointer` that view's feed (the owner's `pointer` prop, null without
380
+ one); read under `output={false}` outside a `<View2d>` `viewport` throws. A FILL-mode
381
+ view is 1x1 until the first layout: `ref` fires at mount, before it, so
382
+ `viewport.width`/`height` read 1 there. Sprites added and positioned from
383
+ `ref` (the pool, the opening scene) are fine - the first layout resizes
384
+ the view before the first paint - but anything that needs the real size
385
+ (centering on the view) waits for `onLayout`, or works in a `designSize`
386
+ that is known up front. `Sprite` renders
248
387
  nothing - it allocates a record through context and syncs props into it.
249
388
  `GroupContext` is `createContext<SpriteGroup | null>(null)` on purpose: an
250
389
  optional parent needs a non-undefined default, since Solid 2 throws on a
251
390
  resolved `undefined` even when one was passed as the default.
252
391
 
253
392
  Pointer events (dispatch.ts, both layer kinds): the DOM event model one
254
- tree deeper, with the LAYER as the root of the walk. Exact rotated-rect
393
+ tree deeper, with the VIEW as the root of the walk. Exact rotated-rect
255
394
  containment, topmost sprite first; down/move/up/wheel dispatch on the hit
256
- sprite, bubble through its enclosing groups and END AT THE LAYER's
257
- listeners (`layer.listen({...})`, the `<SpriteLayer onPointer*/onWheel/
258
- onTap>` props) - over empty space the walk is the layer alone, with
259
- `event.sprite` null. `event.sprite` stays the hit, `currentTarget` the
260
- handle whose handler runs (the layer at the end), `stopPropagation()`
261
- stops the walk, and a stopped DOWN claims the whole press: that pointer's
262
- move, up and tap never reach the layer either (the chain still bubbles).
395
+ sprite, bubble through its enclosing groups and END AT THE VIEW's
396
+ listeners (`view.listen({...})`, the `<SpriteLayer>`/`<View2d>`
397
+ `onPointer*/onWheel/onTap` props) - over empty space the walk is the view
398
+ alone, with `event.sprite` null. `event.sprite` stays the hit,
399
+ `currentTarget` the handle whose handler runs (the view at the end),
400
+ `stopPropagation()` stops the walk, and a stopped DOWN claims the whole
401
+ press: that pointer's move, up and tap never reach the view either (the
402
+ chain still bubbles).
263
403
  That one rule is how a sprite drags itself under a `<Camera2d>` without
264
404
  the view panning: stop the down, own the captured moves. Capture is per
265
- pointerId to the press target, the layer included (a drag from empty
405
+ pointerId to the press target, the view included (a drag from empty
266
406
  space keeps delivering to the root as it crosses sprites; a drag from a
267
407
  sprite keeps naming it with live coordinates). Enter/leave fire on the
268
408
  sprite alone - a group never receives them - while the root sees every
@@ -290,7 +430,12 @@ hover, wheel and tap rules headless.
290
430
  live app-long. Dispose atlases yourself (or let the reactive owner do it -
291
431
  createAtlas registers with the owning scope like every core texture).
292
432
  - `capacity` is a reservation, not a limit, on both layer kinds; reserve
293
- realistically to skip the growth copies. On the records layer, do not
433
+ realistically to skip the growth copies. The pool idiom that fits a
434
+ game: reserve `capacity`, add every sprite at mount with `visible:
435
+ false`, then show/hide - a hidden sprite keeps its slot, so draw order
436
+ stays stable and a spawn is one setSprite, never an add. Keep a free
437
+ list of hidden handles; scanning the pool for one is the cost that
438
+ shows up first at a few hundred entities. On the records layer, do not
294
439
  cache `layer.records` across addSprite - growth replaces the array and
295
440
  a hoisted reference becomes a dead copy whose writes publish nothing.
296
441
  `layer.withRecords(fn)` is the hoist-proof read; a bare `layer.records`
@@ -307,10 +452,18 @@ hover, wheel and tap rules headless.
307
452
  mid-flight pose (what is on screen). Clearing the transition (null) keeps the
308
453
  mid-flight pose on the node while the mirror still holds the old
309
454
  target: the next setSprite write snaps to whatever it says.
310
- - Node layer picking reads the index as of the last core flush; `pick`/
311
- `pickRect` run the layer's pending batch first, so write-then-pick in
312
- one tick is coherent. Producers moving nodes between flushes are one
313
- frame stale to picking, like every query.
455
+ - Node layer picking reads the index as of the last core flush; every
456
+ query runs the layer's pending batch first, so write-then-query in one
457
+ tick is coherent. Producers moving nodes between flushes are one frame
458
+ stale to picking, like every query.
459
+ - The queries are per-body answers - a hit test, a marquee, a blast
460
+ radius, one mover call per character - not a collision broadphase. A
461
+ bullets-vs-crowd test at frame rate is hundreds of core queries per
462
+ frame, and it is simulation logic anyway, which lives in plain
463
+ TypeScript with no layer in sight (core's "keep the simulation out of
464
+ the renderer"): the app's own arrays, a uniform grid over them,
465
+ runnable headless. Tile-layer collision (tiles are baked records, not
466
+ nodes) is a solid-cell grid query of its own, not the sprite index.
314
467
  - Records layer: record order is draw order: `removeSprite` shifts every
315
468
  later sprite down one slot (copyWithin + index fixup, O(later
316
469
  sprites)). Its flush publishes the WHOLE live prefix, not a dirty
@@ -340,14 +493,24 @@ hover, wheel and tap rules headless.
340
493
  slot recycles. Do not read style truth from the buffer; getSprite reads
341
494
  the JS mirror.
342
495
  - `createImage` is the wrong loader for pixel-art atlases: it never forwards
343
- sampler options, so it is always `filter: "linear"`. `createAtlas` decodes
344
- bytes and passes `filter: "nearest"` through - use it, or `decodeImage` +
345
- `createTexture` directly.
496
+ sampler options, so it is always `filter: "linear"`. `createAtlas` takes
497
+ decoded pixels (`createAtlas(decodeImage(bytes), { filter: "nearest" })`,
498
+ or pixels built in code) and passes the full sampler through (`filter`,
499
+ `wrap`, `mipmap`, `anisotropy`); the record it returns is what `grid` and
500
+ `namedFrames` slice.
501
+ - Frames that share an edge do not bleed: the fragment stage clamps every
502
+ sample into its frame, so a sprite at a fractional position (a scrolling
503
+ backdrop, an easing camera) never paints a line of the neighbouring cell,
504
+ and an edge-to-edge sheet needs no gutter and no shaved rect table. The
505
+ one case left is a `mipmap` atlas, whose mip texels average across cell
506
+ edges before sampling: pass `inset` to `grid`/`namedFrames` (2^k texels
507
+ keeps mip level k clean, losing that border) or pack a gutter as
508
+ `spacing`, until extrusion lands (okf/backlog/2d-atlas-extrude.md).
346
509
  - Tint multiplies the sampled texel (`texture * tint`) and the pipeline
347
510
  blends with `blend: "alpha"` in record order, the premultiplied composite.
348
511
  The atlas is premultiplied because `decodeImage` premultiplies by default;
349
512
  an atlas uploaded from straight-alpha pixels (`decodeImage(bytes, { alpha:
350
- "straight" })` + `createTexture`) draws color under transparent texels
513
+ "straight" })` into `createAtlas`) draws color under transparent texels
351
514
  as opaque - the classic "keyed-out backdrop becomes a wash" symptom.
352
515
  - Every rotation must agree on direction (clockwise, y-down):
353
516
  `pointInSprite` in pick.ts with the vertex stage's `iRot`, and
package/README.md CHANGED
@@ -7,12 +7,12 @@ texture element.
7
7
  *Status: experimental. Expect API churn.*
8
8
 
9
9
  ```tsx
10
- import { render } from "@solidrt/core"
10
+ import { decodeImage, render } from "@solidrt/core"
11
11
  import { createAtlas, grid, Sprite, SpriteLayer } from "@solidrt/2d"
12
12
  import sheet from "./sheet.png" with { type: "binary" }
13
13
 
14
- let atlas = createAtlas(sheet, { filter: "nearest" })
15
- let frames = grid(4, 4, { width: atlas.width, height: atlas.height })
14
+ let atlas = createAtlas(decodeImage(sheet), { filter: "nearest" })
15
+ let frames = grid(atlas, 4, 4)
16
16
 
17
17
  render(() => (
18
18
  <window>
@@ -12,27 +12,52 @@ copy one and adapt it.
12
12
  one texture may be) baked into lazily-allocated chunks, flown over by a
13
13
  ship-style camera (fixed screen pivot, the world panning and ROTATING
14
14
  under it via the `<TileLayer>` camera prop - transform writes, never a
15
- re-bake), and a timer editing tiles to show that a `setTile` batch
16
- re-bakes only the chunks it touches.
15
+ re-bake), seeded by ONE `setTiles` rect write over a Uint16Array of
16
+ frame indices (the layer's `frames` table; -1 where the world is
17
+ empty, and those chunks never allocate), and a timer editing tiles to
18
+ show that a `setTile` batch re-bakes only the chunks it touches.
17
19
  - `anim.tsx` - frame animation with `createAnimation`: three sprites share
18
20
  one looping clip (one clock, stepped by wall time independent of display
19
21
  rate) and a one-shot clip holds its last frame and fires `onEnd`.
20
22
  Self-asserting - watch the logs for ANIM-OK.
21
23
  - `camera.tsx` - the 2d camera (`createCamera2d`) over a world larger than
22
- the window, attached at the layer's root (`cam.attach(layer)`): drag
23
- empty space to pan with inertia, wheel/pinch to zoom about the pointer,
24
- tap empty space to glide there (the layer's `onTap` with `e.sprite`
25
- null), tap a sprite to select it, drag a sprite to move it (the sprite
26
- stops its down, so the camera never pans under it), F to follow a
27
- roaming sprite through a dead zone, R to spin the view, Space to fit the
28
- world. The LAYER's `handlers` spread onto its leaf; `update(dt)` from
29
- `onFrame` is the only per-frame call. Debug commands `camera`, `mode`,
30
- `selected` and `first` drive it headless.
24
+ the window, driving the layer's one view from an input map the app
25
+ binds (`camera2dBindings` over the view's pointer feed, bridged from
26
+ the view's root with `feedPointer`, and any pad): drag empty space to
27
+ pan with inertia, wheel/pinch to zoom about the pointer, tap empty
28
+ space to glide there (the view's `onTap` with `e.sprite` null), tap a
29
+ sprite to select it, drag a sprite to move it (the sprite stops its
30
+ down, so the camera never pans under it), F to follow a roaming sprite
31
+ through a dead zone, R to spin the view, Space to fit the world. The
32
+ VIEW's `handlers` spread onto its leaf; `update(dt)` from `onFrame` is
33
+ the only per-frame call. Debug commands `camera`, `mode`, `selected`
34
+ and `first` drive it headless.
31
35
  - `pick.tsx` - the event model through the component face: exact
32
36
  rotated-rect hit testing topmost-first, `onTap` (the dispatch's own
33
37
  click, no slop bookkeeping), a claimed press dragging a sprite under a
34
38
  `<Camera2d>` that pans empty space and zooms on wheel, the layer's own
35
39
  `onTap` seeing misses with `e.sprite` null (and `tapCount` for double
36
40
  taps), and removal through a signal so `<For>` unmounts the `<Sprite>`
37
- and the layer recycles the slot. The `state` debug command reads the
38
- camera and sprite positions back.
41
+ and the layer recycles the slot. A corner `<View2d>` inset shows the
42
+ same sprites again under a `<Camera2d>` of its own (drag or wheel on
43
+ the inset moves only the inset's camera; a sprite tapped or dragged
44
+ there responds as in the main view). The `state` debug command reads
45
+ both cameras and the sprite positions back.
46
+ - `views.tsx` - layer views (`layer.createView`, the only way a layer
47
+ shows): the world rendered twice from ONE set of sprites - the
48
+ window-filling main view under a `createCamera2d` and a corner minimap
49
+ showing the whole world at the fit zoom, a stroked rect outlining the
50
+ main view rect through `map.project`. A tap on the minimap glides the main camera there (the
51
+ view's root listener, `e.x`/`e.y` in world pixels), and a tap on a
52
+ sprite in the minimap selects it as in the main view (the same sprite
53
+ handlers, the walk ending at the view). Debug commands `state`,
54
+ `camera`, `selected` and `first` drive it headless.
55
+ - `split-screen.tsx` - one `<SpriteLayer output={false}>` (sprites, no
56
+ leaf of its own) shown through two `<View2d>` panes filling the window
57
+ side by side, each with a `<Camera2d>` of its own following one of two
58
+ roaming sprites through a dead zone, so the two poses differ every
59
+ frame; wheel zooms a pane alone while its follow keeps tracking, a tap
60
+ on a sprite in either pane tints it in both (one sprite, two views).
61
+ Each pane's map binds `gamepad.next()`: the first pad to press any
62
+ button drives pane 0, the next pane 1. The `cameras` debug command
63
+ reads both poses back.
package/examples/anim.tsx CHANGED
@@ -7,7 +7,7 @@
7
7
  // The atlas is the core logo sliced 2x2 by grid(): a 4-frame "clip". Real
8
8
  // art would slice a sheet the same way and pass a consecutive slice of the
9
9
  // frames array per animation.
10
- import { render } from "@solidrt/core"
10
+ import { decodeImage, render } from "@solidrt/core"
11
11
  import { addSprite, createAnimation, createAtlas, createSpriteLayer, getSprite, grid } from "@solidrt/2d"
12
12
  import type { Frame, SpriteHandle } from "@solidrt/2d"
13
13
  import logoBytes from "./logo.png" with { type: "binary" }
@@ -21,12 +21,10 @@ const LOOP_FPS = 2
21
21
  const SHOT_FPS = 5
22
22
 
23
23
  function App() {
24
- let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
25
- let frames = grid(2, 2, { width: atlas.width, height: atlas.height })
26
- let layer = createSpriteLayer(W, H, atlas.texture, {
27
- clearColor: [0.05, 0.05, 0.09, 1],
28
- label: "anim",
29
- })
24
+ let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
25
+ let frames = grid(atlas, 2, 2)
26
+ let layer = createSpriteLayer(atlas.texture, { label: "anim" })
27
+ let view = layer.createView({ width: W, height: H, clearColor: [0.05, 0.05, 0.09, 1] })
30
28
 
31
29
  // Three sprites share the looping clip - one clock steps all of them.
32
30
  let looping = createAnimation(frames, LOOP_FPS)
@@ -78,7 +76,7 @@ function App() {
78
76
 
79
77
  return (
80
78
  <window alignItems="center" justifyContent="center">
81
- <texture src={layer.texture} width={W} height={H} />
79
+ <texture src={view.texture} width={W} height={H} />
82
80
  </window>
83
81
  )
84
82
  }
@@ -16,7 +16,7 @@
16
16
  // 3. Pointer dispatch round-trips: a pointer at the projected screen
17
17
  // point hits the sprite and reports its world coordinates.
18
18
  // 4. Tile chunks bake with the pinned identity camera rotation.
19
- import { onFrame, render } from "@solidrt/core"
19
+ import { decodeImage, onFrame, render } from "@solidrt/core"
20
20
  import { readTexture } from "@solidrt/core/gpu"
21
21
  import {
22
22
  addSprite,
@@ -53,12 +53,14 @@ function rng(seed: number): () => number {
53
53
  }
54
54
 
55
55
  function App() {
56
- let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
57
- let frames = grid(2, 2, { width: atlas.width, height: atlas.height })
58
- let nodes = createSpriteLayer(W, H, atlas.texture, { label: "cam-nodes" })
59
- let records = createRecordLayer(W, H, atlas.texture, { label: "cam-records" })
60
- nodes.setCamera(CAM)
61
- records.setCamera(CAM)
56
+ let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
57
+ let frames = grid(atlas, 2, 2)
58
+ let nodes = createSpriteLayer(atlas.texture, { label: "cam-nodes" })
59
+ let records = createRecordLayer(atlas.texture, { label: "cam-records" })
60
+ let nodeView = nodes.createView({ width: W, height: H, label: "cam-nodes" })
61
+ let recordView = records.createView({ width: W, height: H, label: "cam-records" })
62
+ nodeView.setCamera(CAM)
63
+ recordView.setCamera(CAM)
62
64
 
63
65
  // The same deterministic population on both layers, kept inside world
64
66
  // 20..100 so its reach (~145 with rotation) stays clear of both sample
@@ -103,8 +105,8 @@ function App() {
103
105
  }
104
106
 
105
107
  let shaderCheck = () => {
106
- let img = readTexture(nodes.texture)
107
- let n = nodes.oversample
108
+ let img = readTexture(nodeView.texture)
109
+ let n = nodeView.oversample
108
110
  let [sx, sy] = projectCamera(CAM, 320, 320)
109
111
  check(sx > 8 && sx < W - 8 && sy > 8 && sy < H - 8, `projected probe on screen (${sx.toFixed(1)}, ${sy.toFixed(1)})`)
110
112
  check(alphaAt(img, sx, sy, n) > 0, "probe center opaque at its PROJECTED pixel")
@@ -118,8 +120,8 @@ function App() {
118
120
  }
119
121
 
120
122
  let pixelParity = () => {
121
- let a = readTexture(nodes.texture)
122
- let b = readTexture(records.texture)
123
+ let a = readTexture(nodeView.texture)
124
+ let b = readTexture(recordView.texture)
123
125
  if (a.width !== b.width || a.height !== b.height) {
124
126
  check(false, `sizes differ: ${a.width}x${a.height} vs ${b.width}x${b.height}`)
125
127
  return
@@ -146,7 +148,7 @@ function App() {
146
148
  let [sx, sy] = projectCamera(CAM, 320, 320)
147
149
  // Synthesize the element event the built-in leaf would deliver (layout
148
150
  // null: localX/localY are layer pixels already).
149
- nodes.handlers.onPointerDown({
151
+ nodeView.handlers.onPointerDown({
150
152
  localX: sx,
151
153
  localY: sy,
152
154
  pointerId: 1,
@@ -191,8 +193,8 @@ function App() {
191
193
 
192
194
  return (
193
195
  <window flexDirection="row" alignItems="center" justifyContent="center" gap={8}>
194
- <texture src={nodes.texture} width={W} height={H} />
195
- <texture src={records.texture} width={W} height={H} />
196
+ <texture src={nodeView.texture} width={W} height={H} />
197
+ <texture src={recordView.texture} width={W} height={H} />
196
198
  </window>
197
199
  )
198
200
  }