@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/src/layer.ts ADDED
@@ -0,0 +1,788 @@
1
+ // The live sprite layer, re-founded on the spatial core: every sprite is a
2
+ // SPATIAL ARENA node (arena slot: local pose, parent, world matrix, index
3
+ // leaf, record sink - no layout, no paint, no rendertree element) whose
4
+ // Pose2D record sink writes [x, y, angle, sx, sy] into the sprite's slot of
5
+ // the pose instance buffer at the core's flush. Rendering stays one
6
+ // instanced draw into one pipeline target, composited as a single
7
+ // `<texture>` leaf; what changed is who owns the pose upstream of the
8
+ // instance buffer - the arena, so every core producer (native transitions,
9
+ // animation clips, physics) reaches sprites through `sprite.node`, and
10
+ // picking walks the core BVH instead of a JS loop.
11
+ //
12
+ // Two instance-buffer slots split ownership: slot 0 is the pose buffer,
13
+ // written ONLY by the core (one coalesced write per flush however many
14
+ // nodes moved); slot 1 is the style buffer [u0, v0, u1, v1, tint rgba],
15
+ // JS-owned and published through the zero-copy write lease. Never write
16
+ // the pose buffer from JS - the core's staging mirror is the owner and
17
+ // will overwrite. For motion only JS can compute at large populations, the
18
+ // records layer (records.ts) is the escape hatch.
19
+ //
20
+ // Sprites hold FIXED instance slots (freed slots recycle): draw order is
21
+ // slot order, so removal never shifts records and pose sinks never rebind.
22
+ // Layer space is pixels, top-left origin, y-down - the render tree's
23
+ // frame. The camera is a shared-params write (uCamera), never per-sprite.
24
+ import { getOwner, onCleanup } from "@solidrt/core"
25
+ import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
26
+ import {
27
+ beginBufferWrite,
28
+ createBuffer,
29
+ createPipelineTexture,
30
+ destroyBuffer,
31
+ destroyTexture,
32
+ endBufferWrite,
33
+ setDraw,
34
+ setTargetParams,
35
+ setTargetSize,
36
+ } from "@solidrt/core/gpu"
37
+ import type { BufferId, TextureId } from "@solidrt/core/gpu"
38
+ import * as spatial from "flux:spatial"
39
+ import type { NodeId, NodeTransition } from "flux:spatial"
40
+ import { on } from "srt:events"
41
+ import type { Frame } from "./frames.ts"
42
+ import { FULL_FRAME } from "./frames.ts"
43
+ import type { RecordLayer } from "./records.ts"
44
+ import { FRAGMENT, INSTANCE_ATTRIBUTES_SPLIT, VERTEX_SPLIT } from "./shaders.ts"
45
+
46
+ /** Floats per pose record (the core's Pose2D projection). */
47
+ export const POSE_FLOATS = 5
48
+ /** Floats per style record: [u0, v0, u1, v1, tintR, tintG, tintB, tintA]. */
49
+ export const STYLE_FLOATS = 8
50
+
51
+ const RESOLVED = Promise.resolve()
52
+
53
+ // Shared marshalling scratch (the bindings copy synchronously).
54
+ const TRANSFORM = new Float32Array(10)
55
+ const FLAT_BOUNDS = new Float32Array([-0.5, -0.5, 0, 0.5, 0.5, 0])
56
+ const RAY_ORIGIN = new Float32Array(3)
57
+ const RAY_DIR = new Float32Array([0, 0, 1])
58
+ const BOX = new Float32Array(6)
59
+
60
+ // Settle routing: the core's "spatialTransitionEnd" event carries the node
61
+ // id, so the handles with a transition DECLARED are indexed by node (only
62
+ // those can settle; adding a sprite costs nothing here) and one lazy
63
+ // subscription, started at the first declaration, routes to the handle's
64
+ // onTransitionEnd. Target-only, like the element transitions.
65
+ let declared = new Map<NodeId, Sprite | SpriteGroup>()
66
+ let subscribed = false
67
+
68
+ function declare(node: NodeId, handle: Sprite | SpriteGroup, transition: NodeTransition | string | null): void {
69
+ if (transition === null) {
70
+ declared.delete(node)
71
+ return
72
+ }
73
+ declared.set(node, handle)
74
+ if (subscribed) return
75
+ subscribed = true
76
+ on("spatialTransitionEnd", (event: { node: NodeId; component: TransitionEndEvent["component"] }) => {
77
+ let handle = declared.get(event.node)
78
+ if (!handle) return
79
+ try {
80
+ handle.onTransitionEnd?.({ component: event.component })
81
+ } catch (err) {
82
+ console.error("Error in onTransitionEnd handler:", err)
83
+ }
84
+ })
85
+ }
86
+
87
+ /**
88
+ * One sprite: a handle into its layer. Read via getSprite; write through
89
+ * setSprite so changes publish. The pointer handlers are plain assignable
90
+ * fields - they touch no GPU state (the scene-graph handler rule).
91
+ */
92
+ export type Sprite = {
93
+ /** The owning layer, null after removeSprite. */
94
+ layer: SpriteLayer | RecordLayer | null
95
+ /**
96
+ * The sprite's SPATIAL ARENA node - the citizenship handle: bind core
97
+ * producers to it or reach it through flux:spatial directly (the layer
98
+ * still owns the node's life; destroy it only via removeSprite). Null
99
+ * on a record layer's sprites.
100
+ */
101
+ node: NodeId | null
102
+ /** Instance slot: fixed for the sprite's life on the node layer, the
103
+ * shifting draw-order index on a record layer. */
104
+ _slot: number
105
+ /** Pose mirror (node layer): what setSprite composes transforms from. */
106
+ _x: number
107
+ _y: number
108
+ _w: number
109
+ _h: number
110
+ _rot: number
111
+ onPointerDown?: (event: SpritePointerEvent) => void
112
+ onPointerMove?: (event: SpritePointerEvent) => void
113
+ onPointerUp?: (event: SpritePointerEvent) => void
114
+ onPointerEnter?: (event: SpritePointerEvent) => void
115
+ onPointerLeave?: (event: SpritePointerEvent) => void
116
+ /** A declared transition (setSpriteTransition) settled naturally on
117
+ * one component; a cancel or snap never fires. */
118
+ onTransitionEnd?: (event: TransitionEndEvent) => void
119
+ }
120
+
121
+ /** The settled component of a node transition: `position` is x/y,
122
+ * `scale` w/h (a group's uniform scale). */
123
+ export type TransitionEndEvent = {
124
+ component: "position" | "rotation" | "scale"
125
+ }
126
+
127
+ /** Sprite fields, all optional at every call site: absent keys keep values. */
128
+ export type SpriteOptions = {
129
+ /** Center position in layer pixels (in the parent group's frame when the
130
+ * sprite is grouped). */
131
+ x?: number
132
+ y?: number
133
+ /** Drawn size in layer pixels. */
134
+ w?: number
135
+ h?: number
136
+ /** Atlas frame (normalized UVs); default the whole atlas. */
137
+ frame?: Frame
138
+ /** Rotation about the center, radians, clockwise (y-down space). */
139
+ rotation?: number
140
+ /** RGBA multiplier 0..1 each; default opaque white (the texture as-is). */
141
+ tint?: [number, number, number, number]
142
+ }
143
+
144
+ export type AddSpriteOptions = SpriteOptions & {
145
+ /** Mount under this group (node layer only); pose fields are then local
146
+ * to it. Reparent later with setSpriteParent. */
147
+ parent?: SpriteGroup
148
+ }
149
+
150
+ export type SpritePointerEvent = {
151
+ /** The sprite hit (the topmost at the point), constant while captured. */
152
+ sprite: Sprite
153
+ /** Pointer position in LAYER pixels (the camera mapping undone). */
154
+ x: number
155
+ y: number
156
+ pointerId: number
157
+ pointerType: string
158
+ button?: number
159
+ shiftKey: boolean
160
+ ctrlKey: boolean
161
+ altKey: boolean
162
+ metaKey: boolean
163
+ }
164
+
165
+ export type SpriteHandlers = {
166
+ onPointerDown: (event: ElementPointerEvent) => void
167
+ onPointerMove: (event: ElementPointerEvent) => void
168
+ onPointerUp: (event: ElementPointerEvent) => void
169
+ onPointerLeave: (event: ElementPointerEvent) => void
170
+ }
171
+
172
+ export type CameraUpdate = {
173
+ /** World pixel at the viewport's top-left corner. */
174
+ x?: number
175
+ y?: number
176
+ /** World-to-screen scale; 1 is pixel-for-pixel. */
177
+ zoom?: number
178
+ }
179
+
180
+ export type SpriteLayerOptions = {
181
+ /**
182
+ * Initial slot reservation; default 1024. The layer grows past it on
183
+ * demand (doubling), so this is a hint that avoids regrowth copies, not a
184
+ * limit.
185
+ */
186
+ capacity?: number
187
+ clearColor?: [number, number, number, number]
188
+ label?: string
189
+ /** Skip the owner-scoped auto-dispose (see createSpriteLayer). */
190
+ autoFree?: boolean
191
+ }
192
+
193
+ /**
194
+ * A transform group: a plain spatial arena node (position, rotation,
195
+ * uniform scale - never a sprite size) that sprites and other groups
196
+ * parent under, so a ship with turrets or a dragged stack moves as one
197
+ * subtree recomputed in native code. Groups render nothing and cannot be
198
+ * picked; sprites are always the leaves.
199
+ */
200
+ export type SpriteGroup = {
201
+ /** The owning layer, null after removeGroup. */
202
+ layer: SpriteLayer | null
203
+ /** The group's spatial arena node. */
204
+ node: NodeId
205
+ _x: number
206
+ _y: number
207
+ _rot: number
208
+ _scale: number
209
+ /** See Sprite.onTransitionEnd. */
210
+ onTransitionEnd?: (event: TransitionEndEvent) => void
211
+ }
212
+
213
+ /** Group fields, all optional: absent keys keep values. */
214
+ export type GroupOptions = {
215
+ /** Position in the parent frame (layer pixels at the root). */
216
+ x?: number
217
+ y?: number
218
+ /** Rotation, radians, clockwise (y-down space). */
219
+ rotation?: number
220
+ /** Uniform scale on the whole subtree (this one scales child sprites -
221
+ * a group is a frame, not a sprite size). */
222
+ scale?: number
223
+ /** Reparent (null = make the group a root). */
224
+ parent?: SpriteGroup | null
225
+ }
226
+
227
+ /** What both layer kinds share; the free sprite functions dispatch on it. */
228
+ export type LayerBase = {
229
+ /** The layer's output: an ordinary texture id (`<texture src>`). */
230
+ texture: TextureId
231
+ /** Element handlers wiring sprite pointer events; see handlersFor. */
232
+ handlers: SpriteHandlers
233
+ /** Live sprite count. */
234
+ readonly count: number
235
+ setSize(width: number, height: number): void
236
+ setCamera(update: CameraUpdate): void
237
+ /** Topmost sprite whose rotated rect contains the layer-pixel point. */
238
+ pick(x: number, y: number): Sprite | null
239
+ /**
240
+ * Handlers for a leaf whose LAYOUT size differs from the layer size
241
+ * (events scale by layer/layout; null layout means "already layer pixels",
242
+ * which is what the built-in leaf uses).
243
+ */
244
+ handlersFor(layout: (() => { width: number; height: number } | null) | null): SpriteHandlers
245
+ dispose(): void
246
+ _add(opts?: AddSpriteOptions): Sprite
247
+ _write(sprite: Sprite, opts: SpriteOptions): void
248
+ _read(sprite: Sprite): Required<SpriteOptions>
249
+ _remove(sprite: Sprite): void
250
+ _schedule(): void
251
+ }
252
+
253
+ export type SpriteLayer = LayerBase & {
254
+ /**
255
+ * Every sprite whose rotated rect overlaps the layer-pixel rect (the
256
+ * core BVH overlap query, exact for rotated sprites), unordered - the
257
+ * marquee query. Node layer only.
258
+ */
259
+ pickRect(x: number, y: number, w: number, h: number): Sprite[]
260
+ _groups: Set<SpriteGroup>
261
+ }
262
+
263
+ /**
264
+ * Pointer dispatch shared by both layer kinds: capture per pointer, hover
265
+ * pairing, no bubbling (the sprite list is flat). Layout null = the leaf is
266
+ * laid out at layer size, so localX/localY are layer pixels already (the
267
+ * element hit test undid every ancestor transform - never getBoundingBox
268
+ * here). Internal - layers expose the result as handlers/handlersFor.
269
+ */
270
+ export function spriteDispatch(state: {
271
+ size: () => [number, number]
272
+ camera: () => [number, number, number]
273
+ pick: (x: number, y: number) => Sprite | null
274
+ }): (layout: (() => { width: number; height: number } | null) | null) => SpriteHandlers {
275
+ let capture = new Map<number, Sprite>()
276
+ let hover = new Map<number, Sprite>()
277
+ return layout => {
278
+ let toLayer = (e: ElementPointerEvent): [number, number] => {
279
+ let x = e.localX
280
+ let y = e.localY
281
+ let l = layout?.()
282
+ let [width, height] = state.size()
283
+ if (l && l.width > 0 && l.height > 0) {
284
+ x *= width / l.width
285
+ y *= height / l.height
286
+ }
287
+ // Undo the camera: screen -> world.
288
+ let [camX, camY, camZoom] = state.camera()
289
+ return [x / camZoom + camX, y / camZoom + camY]
290
+ }
291
+ let makeEvent = (sprite: Sprite, x: number, y: number, e: ElementPointerEvent): SpritePointerEvent => ({
292
+ sprite,
293
+ x,
294
+ y,
295
+ pointerId: e.pointerId,
296
+ pointerType: e.pointerType,
297
+ button: e.button,
298
+ shiftKey: e.shiftKey,
299
+ ctrlKey: e.ctrlKey,
300
+ altKey: e.altKey,
301
+ metaKey: e.metaKey,
302
+ })
303
+ return {
304
+ onPointerDown(e) {
305
+ let [x, y] = toLayer(e)
306
+ let hit = state.pick(x, y)
307
+ if (!hit) return
308
+ capture.set(e.pointerId, hit)
309
+ hit.onPointerDown?.(makeEvent(hit, x, y, e))
310
+ },
311
+ onPointerMove(e) {
312
+ let [x, y] = toLayer(e)
313
+ let captured = capture.get(e.pointerId)
314
+ if (captured) {
315
+ if (captured.layer) captured.onPointerMove?.(makeEvent(captured, x, y, e))
316
+ return
317
+ }
318
+ let hit = state.pick(x, y)
319
+ let prev = hover.get(e.pointerId) ?? null
320
+ if (prev !== hit) {
321
+ if (prev && prev.layer) prev.onPointerLeave?.(makeEvent(prev, x, y, e))
322
+ if (hit) hit.onPointerEnter?.(makeEvent(hit, x, y, e))
323
+ if (hit) hover.set(e.pointerId, hit)
324
+ else hover.delete(e.pointerId)
325
+ }
326
+ hit?.onPointerMove?.(makeEvent(hit, x, y, e))
327
+ },
328
+ onPointerUp(e) {
329
+ let [x, y] = toLayer(e)
330
+ let captured = capture.get(e.pointerId)
331
+ if (captured) {
332
+ capture.delete(e.pointerId)
333
+ if (captured.layer) captured.onPointerUp?.(makeEvent(captured, x, y, e))
334
+ return
335
+ }
336
+ let hit = state.pick(x, y)
337
+ hit?.onPointerUp?.(makeEvent(hit, x, y, e))
338
+ },
339
+ onPointerLeave(e) {
340
+ let [x, y] = toLayer(e)
341
+ let prev = hover.get(e.pointerId)
342
+ if (prev) {
343
+ hover.delete(e.pointerId)
344
+ if (prev.layer) prev.onPointerLeave?.(makeEvent(prev, x, y, e))
345
+ }
346
+ },
347
+ }
348
+ }
349
+ }
350
+
351
+ /** Fill the shared transform scratch: xy translation, z rotation, xy scale
352
+ * (a sprite's scale is its w/h - every sprite is a scaled unit quad). */
353
+ function fillTransform(x: number, y: number, rot: number, sx: number, sy: number): void {
354
+ let half = rot / 2
355
+ TRANSFORM[0] = x
356
+ TRANSFORM[1] = y
357
+ TRANSFORM[2] = 0
358
+ TRANSFORM[3] = 0
359
+ TRANSFORM[4] = 0
360
+ TRANSFORM[5] = Math.sin(half)
361
+ TRANSFORM[6] = Math.cos(half)
362
+ TRANSFORM[7] = sx
363
+ TRANSFORM[8] = sy
364
+ TRANSFORM[9] = 1
365
+ }
366
+
367
+ /** Compose and push a node-backed sprite's local transform - through the
368
+ * node's transition declaration, so with one set (setSpriteTransition)
369
+ * the write is a target the core animates toward. */
370
+ function writeTransform(sprite: Sprite): void {
371
+ fillTransform(sprite._x, sprite._y, sprite._rot, sprite._w, sprite._h)
372
+ spatial.writeTransform(sprite.node!, TRANSFORM)
373
+ }
374
+
375
+ /**
376
+ * Create a sprite layer rendering into a `width` x `height` texture from one
377
+ * atlas texture. Disposed automatically with the owning reactive scope (opt
378
+ * out with `{ autoFree: false }`); the atlas is NOT owned - dispose it
379
+ * yourself (it commonly outlives layers).
380
+ */
381
+ export function createSpriteLayer(
382
+ width: number,
383
+ height: number,
384
+ atlas: TextureId,
385
+ opts?: SpriteLayerOptions,
386
+ ): SpriteLayer {
387
+ let capacity = opts?.capacity ?? 1024
388
+ if (!(capacity > 0 && Number.isInteger(capacity))) {
389
+ throw new Error(`createSpriteLayer: capacity must be a positive integer, got ${capacity}`)
390
+ }
391
+ let label = opts?.label ?? "sprites"
392
+ // One unit quad (triangle strip), reused by every instance.
393
+ let quad = createBuffer(new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]), {
394
+ label: `${label}-quad`,
395
+ autoFree: false,
396
+ })
397
+ let pose: BufferId = createBuffer(capacity * POSE_FLOATS * 4, { label: `${label}-pose`, autoFree: false })
398
+ let style: BufferId = createBuffer(capacity * STYLE_FLOATS * 4, { label: `${label}-style`, autoFree: false })
399
+ let texture = createPipelineTexture(
400
+ VERTEX_SPLIT,
401
+ FRAGMENT,
402
+ width,
403
+ height,
404
+ { uViewport: [width, height], uCamera: [0, 0, 1, 1] },
405
+ {
406
+ label,
407
+ topology: "triangle-strip",
408
+ vertexCount: 4,
409
+ attributes: [{ name: "aPos", format: "vec2" }],
410
+ buffer: quad,
411
+ instanceAttributes: INSTANCE_ATTRIBUTES_SPLIT,
412
+ instanceBuffers: [pose, style],
413
+ instanceCount: 0,
414
+ blend: "alpha",
415
+ textures: { uAtlas: atlas },
416
+ clearColor: opts?.clearColor ?? [0, 0, 0, 0],
417
+ autoFree: false,
418
+ },
419
+ )
420
+
421
+ // Camera state, mirrored for picking (the inverse mapping).
422
+ let camX = 0
423
+ let camY = 0
424
+ let camZoom = 1
425
+ let disposed = false
426
+ let scheduled = false
427
+ let styleDirty = false
428
+ let published = 0
429
+
430
+ // Slot allocation: freed slots recycle, the high-water mark is the
431
+ // published instance count (a freed slot's pose zeroes - zero scale
432
+ // collapses the instance - so holes draw nothing).
433
+ let highWater = 0
434
+ let freeSlots: number[] = []
435
+ let gpuCapacity = capacity
436
+ let styleData = new Float32Array(capacity * STYLE_FLOATS)
437
+ let byNode = new Map<NodeId, Sprite>()
438
+
439
+ let flush = () => {
440
+ scheduled = false
441
+ if (disposed) return
442
+ if (styleDirty) {
443
+ styleDirty = false
444
+ let out = beginBufferWrite(style)
445
+ out.set(styleData.subarray(0, highWater * STYLE_FLOATS))
446
+ endBufferWrite(style, highWater * STYLE_FLOATS * 4)
447
+ }
448
+ if (published !== highWater) {
449
+ setDraw(texture, { instanceCount: highWater })
450
+ published = highWater
451
+ }
452
+ // The core recomputes moved subtrees and publishes every dirty pose
453
+ // slot as one coalesced write per buffer.
454
+ spatial.flush()
455
+ }
456
+
457
+ // Grow both instance buffers to `next` slots: the pose sinks move in one
458
+ // retargetRecords call (the whole used range republishes at the next
459
+ // flush), the style mirror grows in JS and republishes through the lease.
460
+ // The entry holds the old buffers alive until the swap lands, so the
461
+ // destroys are safe to issue right after.
462
+ let grow = (next: number) => {
463
+ let newPose = createBuffer(next * POSE_FLOATS * 4, { label: `${label}-pose`, autoFree: false })
464
+ let newStyle = createBuffer(next * STYLE_FLOATS * 4, { label: `${label}-style`, autoFree: false })
465
+ spatial.retargetRecords(pose, newPose)
466
+ let grownStyle = new Float32Array(next * STYLE_FLOATS)
467
+ grownStyle.set(styleData)
468
+ styleData = grownStyle
469
+ setDraw(texture, { instanceBuffers: [newPose, newStyle] })
470
+ destroyBuffer(pose)
471
+ destroyBuffer(style)
472
+ pose = newPose
473
+ style = newStyle
474
+ gpuCapacity = next
475
+ styleDirty = true
476
+ }
477
+
478
+ let writeStyle = (slot: number, opts: SpriteOptions) => {
479
+ let at = slot * STYLE_FLOATS
480
+ if (opts.frame !== undefined) {
481
+ styleData[at] = opts.frame.u0
482
+ styleData[at + 1] = opts.frame.v0
483
+ styleData[at + 2] = opts.frame.u1
484
+ styleData[at + 3] = opts.frame.v1
485
+ }
486
+ if (opts.tint !== undefined) {
487
+ styleData[at + 4] = opts.tint[0]
488
+ styleData[at + 5] = opts.tint[1]
489
+ styleData[at + 6] = opts.tint[2]
490
+ styleData[at + 7] = opts.tint[3]
491
+ }
492
+ styleDirty = true
493
+ }
494
+
495
+ let dispatch = spriteDispatch({
496
+ size: () => [width, height],
497
+ camera: () => [camX, camY, camZoom],
498
+ pick: (x, y) => layer.pick(x, y),
499
+ })
500
+
501
+ let layer: SpriteLayer = {
502
+ texture,
503
+ handlers: undefined as unknown as SpriteHandlers,
504
+ get count() {
505
+ return byNode.size
506
+ },
507
+ setSize(w, h) {
508
+ if (disposed || (w === width && h === height)) return
509
+ width = w
510
+ height = h
511
+ setTargetSize(texture, w, h)
512
+ setTargetParams(texture, { uViewport: [w, h] })
513
+ },
514
+ setCamera(update) {
515
+ if (disposed) return
516
+ if (update.x !== undefined) camX = update.x
517
+ if (update.y !== undefined) camY = update.y
518
+ if (update.zoom !== undefined) {
519
+ if (!(update.zoom > 0)) throw new Error(`setCamera: zoom must be positive, got ${update.zoom}`)
520
+ camZoom = update.zoom
521
+ }
522
+ setTargetParams(texture, { uCamera: [camX, camY, camZoom, camZoom] })
523
+ },
524
+ pick(x, y) {
525
+ // The index reads as of the last core flush; run any pending batch
526
+ // first so a write followed by a pick sees the write.
527
+ if (scheduled) flush()
528
+ RAY_ORIGIN[0] = x
529
+ RAY_ORIGIN[1] = y
530
+ RAY_ORIGIN[2] = -1
531
+ let best: Sprite | null = null
532
+ for (let hit of spatial.raycast(RAY_ORIGIN, RAY_DIR)) {
533
+ // The arena is shared (a 3d scene lives in the same index): only
534
+ // this layer's nodes count. Topmost = highest slot (draw order).
535
+ let sprite = byNode.get(hit.node)
536
+ if (sprite && (best === null || sprite._slot > best._slot)) best = sprite
537
+ }
538
+ return best
539
+ },
540
+ pickRect(x, y, w, h) {
541
+ if (scheduled) flush()
542
+ BOX[0] = x
543
+ BOX[1] = y
544
+ BOX[2] = -1
545
+ BOX[3] = x + w
546
+ BOX[4] = y + h
547
+ BOX[5] = 1
548
+ let out: Sprite[] = []
549
+ for (let node of spatial.overlap(BOX)) {
550
+ let sprite = byNode.get(node)
551
+ if (sprite) out.push(sprite)
552
+ }
553
+ return out
554
+ },
555
+ handlersFor(layout) {
556
+ return dispatch(layout)
557
+ },
558
+ dispose() {
559
+ if (disposed) return
560
+ disposed = true
561
+ for (let sprite of byNode.values()) {
562
+ sprite.layer = null
563
+ declared.delete(sprite.node!)
564
+ spatial.destroyNode(sprite.node!)
565
+ }
566
+ byNode.clear()
567
+ for (let group of layer._groups) {
568
+ group.layer = null
569
+ declared.delete(group.node)
570
+ spatial.destroyNode(group.node)
571
+ }
572
+ layer._groups.clear()
573
+ // Let the core emit its final slot-zeroing writes while the pose
574
+ // buffer still exists, then free everything.
575
+ spatial.flush()
576
+ destroyTexture(texture)
577
+ destroyBuffer(pose)
578
+ destroyBuffer(style)
579
+ destroyBuffer(quad)
580
+ },
581
+ _add(opts) {
582
+ if (disposed) throw new Error("addSprite: layer is disposed")
583
+ let slot = freeSlots.pop() ?? highWater++
584
+ if (slot >= gpuCapacity) grow(gpuCapacity * 2)
585
+ let sprite: Sprite = {
586
+ layer,
587
+ node: null,
588
+ _slot: slot,
589
+ _x: opts?.x ?? 0,
590
+ _y: opts?.y ?? 0,
591
+ _w: opts?.w ?? 0,
592
+ _h: opts?.h ?? 0,
593
+ _rot: opts?.rotation ?? 0,
594
+ }
595
+ fillTransform(sprite._x, sprite._y, sprite._rot, sprite._w, sprite._h)
596
+ let node = spatial.createNode(TRANSFORM, true)
597
+ sprite.node = node
598
+ if (opts?.parent) {
599
+ if (opts.parent.layer !== layer) throw new Error("addSprite: parent group belongs to another layer")
600
+ spatial.setParent(node, opts.parent.node)
601
+ }
602
+ spatial.setBounds(node, FLAT_BOUNDS)
603
+ spatial.bindPoseRecord(node, pose, slot)
604
+ byNode.set(node, sprite)
605
+ writeStyle(slot, { frame: FULL_FRAME, tint: [1, 1, 1, 1], ...opts })
606
+ layer._schedule()
607
+ return sprite
608
+ },
609
+ _write(sprite, opts) {
610
+ let moved = false
611
+ if (opts.x !== undefined && opts.x !== sprite._x) (sprite._x = opts.x), (moved = true)
612
+ if (opts.y !== undefined && opts.y !== sprite._y) (sprite._y = opts.y), (moved = true)
613
+ if (opts.w !== undefined && opts.w !== sprite._w) (sprite._w = opts.w), (moved = true)
614
+ if (opts.h !== undefined && opts.h !== sprite._h) (sprite._h = opts.h), (moved = true)
615
+ if (opts.rotation !== undefined && opts.rotation !== sprite._rot) (sprite._rot = opts.rotation), (moved = true)
616
+ if (moved) writeTransform(sprite)
617
+ if (opts.frame !== undefined || opts.tint !== undefined) writeStyle(sprite._slot, opts)
618
+ if (moved || styleDirty) layer._schedule()
619
+ },
620
+ _read(sprite) {
621
+ let at = sprite._slot * STYLE_FLOATS
622
+ return {
623
+ x: sprite._x,
624
+ y: sprite._y,
625
+ w: sprite._w,
626
+ h: sprite._h,
627
+ frame: { u0: styleData[at]!, v0: styleData[at + 1]!, u1: styleData[at + 2]!, v1: styleData[at + 3]! },
628
+ rotation: sprite._rot,
629
+ tint: [styleData[at + 4]!, styleData[at + 5]!, styleData[at + 6]!, styleData[at + 7]!],
630
+ }
631
+ },
632
+ _remove(sprite) {
633
+ sprite.layer = null
634
+ // Destroying the node zeroes its pose slot at the next core flush
635
+ // (zero scale = nothing drawn); the slot then recycles.
636
+ byNode.delete(sprite.node!)
637
+ declared.delete(sprite.node!)
638
+ spatial.destroyNode(sprite.node!)
639
+ freeSlots.push(sprite._slot)
640
+ layer._schedule()
641
+ },
642
+ _schedule() {
643
+ if (disposed || scheduled) return
644
+ scheduled = true
645
+ RESOLVED.then(flush)
646
+ },
647
+ _groups: new Set(),
648
+ }
649
+ layer.handlers = dispatch(null)
650
+
651
+ if (opts?.autoFree !== false && getOwner()) onCleanup(() => layer.dispose())
652
+ return layer
653
+ }
654
+
655
+ /**
656
+ * Add a sprite. Its instance slot is fixed for its life: draw order is slot
657
+ * order, and a removed sprite's slot recycles to the next add - so unlike
658
+ * the record layer there is no painter's-insertion-order guarantee across
659
+ * removals. Opaque-or-transparent pixel art (the overwhelming case) never
660
+ * notices; z-ordered translucency is the sort-key backlog item. Past the
661
+ * layer's reservation both instance buffers double (pose sinks move in one
662
+ * core retarget); reserve with `capacity` to avoid the copies.
663
+ */
664
+ export function addSprite(layer: SpriteLayer | RecordLayer, opts?: AddSpriteOptions): Sprite {
665
+ return layer._add(opts)
666
+ }
667
+
668
+ /** The one write path: absent keys keep their values (the params rule). */
669
+ export function setSprite(sprite: Sprite, opts: SpriteOptions): void {
670
+ sprite.layer?._write(sprite, opts)
671
+ }
672
+
673
+ /** Read a sprite's current fields (a fresh object; mutating it does nothing). */
674
+ export function getSprite(sprite: Sprite): Required<SpriteOptions> | null {
675
+ return sprite.layer ? sprite.layer._read(sprite) : null
676
+ }
677
+
678
+ /**
679
+ * Remove a sprite. The handle goes inert (layer null); further setSprite
680
+ * calls are no-ops.
681
+ */
682
+ export function removeSprite(sprite: Sprite): void {
683
+ sprite.layer?._remove(sprite)
684
+ }
685
+
686
+ /**
687
+ * Re-parent a sprite under a group (null = back to the layer root); its
688
+ * pose fields then read in the new parent's frame, where the sprite keeps
689
+ * them (it holds its local pose, not its world pose). Node layer only.
690
+ */
691
+ export function setSpriteParent(sprite: Sprite, parent: SpriteGroup | null): void {
692
+ let layer = sprite.layer
693
+ if (!layer) return
694
+ if (sprite.node === null) throw new Error("setSpriteParent: record layers have no groups")
695
+ if (parent && parent.layer !== layer) throw new Error("setSpriteParent: group belongs to another layer")
696
+ spatial.setParent(sprite.node, parent ? parent.node : null)
697
+ layer._schedule()
698
+ }
699
+
700
+ /**
701
+ * Declare (or with null clear) how the sprite's pose writes animate: once
702
+ * set, setSprite writes are TARGETS the core animates toward - JS writes
703
+ * once per target change, the core interpolates every frame, and a
704
+ * settled sprite costs nothing. The spatial vocabulary: a spec per
705
+ * component plus `all`, where `position` is x/y, `rotation` the sprite's
706
+ * rotation (always the short arc) and `scale` its w/h; each spec is
707
+ * `{ duration, bounce? }` (a spring, the retargeting-safe default) /
708
+ * `{ duration, curve }` (a tween) / a shorthand string like
709
+ * "300ms ease-out". Clearing cancels running tracks in place (the sprite
710
+ * keeps its mid-flight pose) and later writes snap. Each natural settle
711
+ * calls the sprite's `onTransitionEnd` with the component. Node layer
712
+ * only.
713
+ */
714
+ export function setSpriteTransition(sprite: Sprite, transition: NodeTransition | string | null): void {
715
+ if (sprite.layer === null) return
716
+ if (sprite.node === null) throw new Error("setSpriteTransition: record sprites have no node transitions")
717
+ spatial.setTransition(sprite.node, transition)
718
+ declare(sprite.node, sprite, transition)
719
+ }
720
+
721
+ /** The group counterpart of setSpriteTransition (`scale` is the group's
722
+ * uniform scale). */
723
+ export function setGroupTransition(group: SpriteGroup, transition: NodeTransition | string | null): void {
724
+ if (group.layer === null) return
725
+ spatial.setTransition(group.node, transition)
726
+ declare(group.node, group, transition)
727
+ }
728
+
729
+ /** Add a transform group (see SpriteGroup). */
730
+ export function addGroup(layer: SpriteLayer, opts?: GroupOptions): SpriteGroup {
731
+ let group = {
732
+ layer,
733
+ _x: opts?.x ?? 0,
734
+ _y: opts?.y ?? 0,
735
+ _rot: opts?.rotation ?? 0,
736
+ _scale: opts?.scale ?? 1,
737
+ } as SpriteGroup
738
+ writeGroupTransform(group)
739
+ group.node = spatial.createNode(TRANSFORM, true)
740
+ if (opts?.parent) {
741
+ if (opts.parent.layer !== layer) throw new Error("addGroup: parent group belongs to another layer")
742
+ spatial.setParent(group.node, opts.parent.node)
743
+ }
744
+ layer._groups.add(group)
745
+ layer._schedule()
746
+ return group
747
+ }
748
+
749
+ function writeGroupTransform(group: SpriteGroup): void {
750
+ fillTransform(group._x, group._y, group._rot, group._scale, group._scale)
751
+ }
752
+
753
+ /** Update a group: absent keys keep values (the params rule). */
754
+ export function setGroup(group: SpriteGroup, opts: GroupOptions): void {
755
+ let layer = group.layer
756
+ if (!layer) return
757
+ let moved = false
758
+ if (opts.x !== undefined && opts.x !== group._x) (group._x = opts.x), (moved = true)
759
+ if (opts.y !== undefined && opts.y !== group._y) (group._y = opts.y), (moved = true)
760
+ if (opts.rotation !== undefined && opts.rotation !== group._rot) (group._rot = opts.rotation), (moved = true)
761
+ if (opts.scale !== undefined && opts.scale !== group._scale) (group._scale = opts.scale), (moved = true)
762
+ if (moved) {
763
+ writeGroupTransform(group)
764
+ spatial.writeTransform(group.node, TRANSFORM)
765
+ }
766
+ if (opts.parent !== undefined) {
767
+ if (opts.parent && opts.parent.layer !== layer) throw new Error("setGroup: parent group belongs to another layer")
768
+ spatial.setParent(group.node, opts.parent ? opts.parent.node : null)
769
+ moved = true
770
+ }
771
+ if (moved) layer._schedule()
772
+ }
773
+
774
+ /**
775
+ * Remove a group: its children (sprites and groups) become layer roots and
776
+ * KEEP THEIR LOCAL POSE, so they jump to root frame unless the caller
777
+ * removes or re-parents them too (a component tree unmounts children first
778
+ * and never sees this). The handle goes inert.
779
+ */
780
+ export function removeGroup(group: SpriteGroup): void {
781
+ let layer = group.layer
782
+ if (!layer) return
783
+ group.layer = null
784
+ layer._groups.delete(group)
785
+ declared.delete(group.node)
786
+ spatial.destroyNode(group.node)
787
+ layer._schedule()
788
+ }