@solidrt/2d 0.0.67 → 0.0.69

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.
@@ -49,7 +49,7 @@ function App() {
49
49
  let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
50
50
  let frames = grid(atlas, 2, 2)
51
51
  let win = { width: 1, height: 1 }
52
- let layer = createSpriteLayer(atlas.texture, { capacity: COUNT + 1, label: "world" })
52
+ let layer = createSpriteLayer([atlas], { capacity: COUNT + 1, label: "world" })
53
53
  // The main view: the window, under the pannable camera.
54
54
  let main = layer.createView({ width: win.width, height: win.height, clearColor: [0.05, 0.05, 0.09, 1], label: "world" })
55
55
  let select = (sprite: SpriteHandle | null) => {
@@ -83,7 +83,7 @@ function App() {
83
83
  label: "minimap",
84
84
  })
85
85
 
86
- cam = createCamera2d(main, { viewport: () => win, world: WORLD, maxZoom: MAX_ZOOM })
86
+ cam = createCamera2d(main, { world: WORLD, maxZoom: MAX_ZOOM })
87
87
  // The main view's gestures and any pad drive the camera through a map;
88
88
  // the minimap binds nothing (its tap glides through listen below).
89
89
  let pointer = createPointerFeed()
@@ -98,7 +98,7 @@ function App() {
98
98
  })
99
99
  // A tap on the minimap, sprite or not, glides the main camera there:
100
100
  // the view's dispatch undoes the MAP camera, so e.x/e.y are world pixels.
101
- map.listen({ onTap: e => cam.glideTo(e.x, e.y) })
101
+ map.listen({ onTap: e => cam.glideTo({ x: e.x, y: e.y }) })
102
102
 
103
103
  createEffect(
104
104
  () => ({ size: windowSize(), scale: displayScale() }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/2d",
3
- "version": "0.0.67",
3
+ "version": "0.0.69",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -15,10 +15,10 @@
15
15
  "AGENTS.md"
16
16
  ],
17
17
  "devDependencies": {
18
- "@solidrt/test": "0.0.67"
18
+ "@solidrt/test": "0.0.69"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "@solidjs/signals": "2.0.0-rc.13",
22
- "@solidrt/core": "0.0.67"
22
+ "@solidrt/core": "0.0.69"
23
23
  }
24
24
  }
package/src/atlas.ts CHANGED
@@ -1,31 +1,48 @@
1
1
  // Atlas creation: decoded pixels to a GPU texture plus the pixel size the
2
- // slicers measure in. Thin by design - the frame math lives in frames.ts
3
- // (pure, checkable) and the texture is an ordinary core texture. createImage
4
- // is NOT used here: it never forwards sampler options, and pixel-art atlases
2
+ // slicers measure in, and the checks a layer runs over the atlases it
3
+ // declares. Thin by design - the frame math lives in frames.ts (pure,
4
+ // checkable) and the texture is an ordinary core texture. createImage is
5
+ // NOT used here: it never forwards sampler options, and pixel-art atlases
5
6
  // want `filter: "nearest"` (render small, display big with hard pixels).
6
7
  import { createTexture } from "@solidrt/core"
7
8
  import type { DecodedImage } from "@solidrt/core"
9
+ import { limits } from "@solidrt/core/gpu"
8
10
  import type { SamplerOptions, TextureId } from "@solidrt/core/gpu"
11
+ import type { Frame } from "./frames.ts"
9
12
 
10
13
  /**
11
- * A texture with its pixel size: what `grid` and `namedFrames` slice, and
12
- * whose `texture` a layer samples. A plain record, so a texture that did not
13
- * come through `createAtlas` - a render target, a camera frame - is an atlas
14
- * as a literal: `{ texture, width, height }`.
14
+ * A texture with its pixel size: what `grid`, `namedFrames` and
15
+ * `fullFrame` slice (stamping the texture on every frame), and what a
16
+ * layer declares in its `atlases` list. A plain record, so a texture that
17
+ * did not come through `createAtlas` - a render target, a camera frame -
18
+ * is an atlas as a literal: `{ texture, width, height }`.
15
19
  */
16
20
  export type Atlas = {
17
- /** The atlas texture, sampled by every sprite in layers created over it. */
21
+ /** The atlas texture: what the frames cut from it name, and what a layer
22
+ * declaring this atlas binds. */
18
23
  texture: TextureId
19
24
  /** Pixel size, the space the slicers measure rects and gaps in. */
20
25
  width: number
21
26
  height: number
27
+ /**
28
+ * The sheet is a distance field, not colour: a multi-channel signed
29
+ * distance field in rgb (the edge is the median at 0.5) with the true
30
+ * field in alpha, `range` texels wide (what `flux:font`'s msdf atlas
31
+ * holds, see createSpriteFont). A layer declaring it decodes the field
32
+ * in its fragment stage - sharp edges at any zoom from one sheet, and
33
+ * the per-sprite outline - instead of sampling colour; the sprite's
34
+ * tint is the fill colour. Fixed at the layer's creation like the list
35
+ * itself: the fragment stage is generated per layer.
36
+ */
37
+ sdf?: { range: number }
22
38
  }
23
39
 
24
40
  /**
25
41
  * Sampling is core's `SamplerOptions`, fixed at creation like every core
26
42
  * texture: `filter` ("nearest" is the pixel-art path with hard pixels at any
27
43
  * scale, "linear" the default photographic one), `wrap`, `mipmap` (a chain
28
- * for atlases drawn far below their texel size) and `anisotropy`.
44
+ * for atlases drawn far below their texel size - extrude the sheet first,
45
+ * see extrude.ts) and `anisotropy`.
29
46
  */
30
47
  export type AtlasOptions = SamplerOptions & {
31
48
  label?: string
@@ -39,9 +56,10 @@ export type AtlasOptions = SamplerOptions & {
39
56
  * encoded sheet goes through `decodeImage` first -
40
57
  * `createAtlas(decodeImage(sheet), { filter: "nearest" })`, with `sheet`
41
58
  * from `import sheet from "./sheet.png" with { type: "binary" }` or
42
- * `await file("assets/sheet.png").bytes()` - and pixels built in code pass
43
- * as they are. Freed with the owning reactive scope like any core texture
44
- * (opt out with `{ autoFree: false }`).
59
+ * `await file("assets/sheet.png").bytes()` - and pixels built in code
60
+ * (or repacked by `extrudeGrid`/`extrudeRects`) pass as they are. Freed
61
+ * with the owning reactive scope like any core texture (opt out with
62
+ * `{ autoFree: false }`).
45
63
  */
46
64
  export function createAtlas(image: DecodedImage, opts?: AtlasOptions): Atlas {
47
65
  let { data, width, height } = image
@@ -52,3 +70,48 @@ export function createAtlas(image: DecodedImage, opts?: AtlasOptions): Atlas {
52
70
  })
53
71
  return { texture, width, height }
54
72
  }
73
+
74
+ /** Whether a value is an atlas record: a non-negative integer texture id
75
+ * and a positive pixel size. */
76
+ export function isAtlas(a: unknown): a is Atlas {
77
+ let o = a as Atlas | null
78
+ return typeof o === "object" && o !== null && Number.isInteger(o.texture) && o.texture >= 0 && o.width > 0 && o.height > 0
79
+ }
80
+
81
+ /**
82
+ * Validate a layer's `atlases` list (throws - the dev validation policy):
83
+ * a non-empty array of distinct atlas records, no more than the device
84
+ * binds in one pass (`limits.maxTextureUnits`, 16 on every GLES 3.0
85
+ * device). Returns the texture -> sampler index map the layer resolves
86
+ * frames through; the index is the atlas's position in the list.
87
+ * Internal - every layer kind calls it.
88
+ */
89
+ export function checkAtlases(verb: string, atlases: readonly Atlas[]): Map<TextureId, number> {
90
+ if (!Array.isArray(atlases) || atlases.length === 0) {
91
+ throw new Error(`${verb}: atlases must be a non-empty array of atlas records (createAtlas, or { texture, width, height })`)
92
+ }
93
+ if (atlases.length > limits.maxTextureUnits) {
94
+ throw new Error(`${verb}: ${atlases.length} atlases exceed the ${limits.maxTextureUnits} sampler inputs one pass may bind (limits.maxTextureUnits); pack sheets together`)
95
+ }
96
+ let index = new Map<TextureId, number>()
97
+ atlases.forEach((atlas, i) => {
98
+ if (!isAtlas(atlas)) throw new Error(`${verb}: atlases[${i}] is not an atlas record, got ${JSON.stringify(atlas)}`)
99
+ if (index.has(atlas.texture)) throw new Error(`${verb}: atlases[${i}] declares texture ${atlas.texture} a second time`)
100
+ index.set(atlas.texture, i)
101
+ })
102
+ return index
103
+ }
104
+
105
+ /**
106
+ * The sampler index of a frame's texture in a layer's atlas list, or a
107
+ * throw naming the list: a frame from a sheet the layer did not declare
108
+ * is the wrong-sheet bug, caught at the write instead of drawn from the
109
+ * wrong texture. Internal - the layers' frame writes go through it.
110
+ */
111
+ export function frameIndex(verb: string, index: Map<TextureId, number>, frame: Frame): number {
112
+ let i = index.get(frame.texture)
113
+ if (i === undefined) {
114
+ throw new Error(`${verb}: the frame's texture ${frame.texture} is not one of the layer's atlases (${[...index.keys()].join(", ")}); frames come from grid/namedFrames/fullFrame over a declared atlas`)
115
+ }
116
+ return i
117
+ }
package/src/camera2d.ts CHANGED
@@ -53,7 +53,9 @@
53
53
  // Pose is plain mutable state; a nudge or a verb pushes it at once (the
54
54
  // next paint carries the new camera, no frame loop needed for a drag),
55
55
  // and update(dt) advances glides, follow, inertia, the lanes and the axis
56
- // rates from the app's own onFrame. `active()` is the reactive frame-loop
56
+ // rates from the app's own onFrame (onBeforeRender when it follows
57
+ // something the frame moves, so it sees the frame's final position).
58
+ // `active()` is the reactive frame-loop
57
59
  // gate (a glide, fling, fit, follow, shake or damped bound in flight, or
58
60
  // a rate driving); a resting camera costs nothing. update() pushes the
59
61
  // pose to the layers only when it changed (one setCamera per driven
@@ -85,24 +87,22 @@ const PAN_RATE = 1
85
87
  const ZOOM_RATE = 1
86
88
  const ROLL_RATE = 0.5
87
89
 
88
- /** What a 2d camera drives: anything with the layers' `setCamera`. */
89
- export type Camera2dTarget = { setCamera(update: CameraUpdate): void }
90
+ /** What a 2d camera drives: anything with the layers' `setCamera` and a
91
+ * `size()` (the viewport in layer pixels, read whenever the camera needs
92
+ * it - a resize re-clamps at the next update; zeros while unknown, and
93
+ * clamping and fitting wait for a real size). A view is one. */
94
+ export type Camera2dTarget = { setCamera(update: CameraUpdate): void; size(): { width: number; height: number } }
90
95
 
91
- /** The control's parameters: what set() takes and pose() returns filled. */
92
- export type Camera2dPose = {
93
- x?: number
94
- y?: number
95
- zoom?: number
96
- rotation?: number
97
- }
96
+ /** The control's parameters, filled: what pose() returns (the 3d
97
+ * controls' OrbitPoseState and FirstPersonPoseState). */
98
+ export type Camera2dPoseState = { x: number; y: number; zoom: number; rotation: number }
99
+
100
+ /** The parameters, each optional: what set() and glideTo() take. */
101
+ export type Camera2dPose = Partial<Camera2dPoseState>
98
102
 
99
103
  export type Rect2d = { x: number; y: number; width: number; height: number }
100
104
 
101
105
  export type Camera2dOptions = Camera2dPose & {
102
- /** Viewport size in layer pixels, read whenever the camera needs it (a
103
- * resize re-clamps at the next update). Return zeros while unknown -
104
- * clamping and fitting wait for a real size. */
105
- viewport: () => { width: number; height: number }
106
106
  /** World bounds (origin 0,0) the view is kept inside: an axis whose view
107
107
  * is wider than the world centers. Absent = unbounded. `damping` eases
108
108
  * a MOTION (a follow, a glide, a fling) into the bounds instead of
@@ -118,7 +118,10 @@ export type Camera2dOptions = Camera2dPose & {
118
118
  maxZoom?: number
119
119
  /** Where in the viewport the camera's world point sits, as fractions of
120
120
  * its size; default the center. glideTo and follow land their point
121
- * here and rotation turns about it. */
121
+ * here and rotation and zoom turn about it: the pivot is the 3d orbit
122
+ * control's target - the point the camera turns about - which that
123
+ * control holds at the view centre and re-seats in WORLD space
124
+ * (`setPivot`), where this one places it on screen. */
122
125
  pivot?: { x: number; y: number }
123
126
  /** How follow(x, y) chases its point (the framing stage): damping per
124
127
  * axis, the dead zone and hard limits as fractions of the viewport
@@ -151,9 +154,9 @@ export type Camera2d = {
151
154
  * call, every field set): the pose plus the lanes - the argument for
152
155
  * projectCamera/unprojectCamera. */
153
156
  camera(): CameraState
154
- /** Pose snapshot - the control's own four values, the shape set() takes
155
- * (the 3d controls' pose()); no lane in it. */
156
- pose(): Required<Camera2dPose>
157
+ /** Pose snapshot - the control's own four values, filled, the shape
158
+ * set() and glideTo() take (the 3d controls' pose()); no lane in it. */
159
+ pose(): Camera2dPoseState
157
160
  /** Merge a pose in (clamps apply) and push it. A snap: an x/y/zoom
158
161
  * write drops a glide or fling in flight and ends the follow; a
159
162
  * rotation write leaves them running. */
@@ -172,11 +175,13 @@ export type Camera2d = {
172
175
  zoomAt(sx: number, sy: number, factor: number, opts?: { glide?: boolean }): void
173
176
  /** Turn the world about the pivot by radians (clockwise, y-down) and push. */
174
177
  rollBy(angle: number): void
175
- /** Ease the pose to put world (x, y) at the pivot, at `zoom` (default
176
- * the current, or a pending wheel or double-tap zoom's target) and
177
- * `rotation` (default the current), landing exactly; a fling or a
178
- * follow yields. */
179
- glideTo(x: number, y: number, zoom?: number, rotation?: number): void
178
+ /** Ease to a pose (the fields given, the rest as they are: `x`/`y` put
179
+ * that world point at the pivot, `zoom` defaults to the current or a
180
+ * pending wheel or double-tap zoom's target, `rotation` to the current;
181
+ * clamps apply) inside update(dt), landing exactly; a new glideTo
182
+ * retargets, and a fling or a follow yields. The 3d controls'
183
+ * glideTo(pose). */
184
+ glideTo(pose: Camera2dPose): void
180
185
  /** Frame a world rect (default the whole world): centered in the view
181
186
  * at the zoom that fits it, clamped. A snap unless `glide`. Waits for
182
187
  * the viewport when its size is not known yet. */
@@ -201,8 +206,10 @@ export type Camera2d = {
201
206
  viewRect(): Rect2d
202
207
  /** Advance glides, follow, inertia, the lanes and the axis rates, then
203
208
  * push the camera to the layers if it changed. Call from onFrame with
204
- * the frame's dt in seconds; returns whether the camera changed since
205
- * the previous update (nudges and verbs included). */
209
+ * the frame's dt in seconds - or from onBeforeRender when the camera
210
+ * follows something the frame moves, so it reads that frame's final
211
+ * position; returns whether the camera changed since the previous
212
+ * update (nudges and verbs included). */
206
213
  update(dt: number): boolean
207
214
  /**
208
215
  * Reactive: whether update() still has work - a glide, fling or
@@ -265,8 +272,9 @@ function checkOptions(options: Camera2dOptions): void {
265
272
  /**
266
273
  * Create a 2d camera driving `target`'s camera, where `target` is a sprite
267
274
  * or record layer's view (or several: a view and its overlay layer's
268
- * share one camera), or anything else with the layers' `setCamera`, such
269
- * as a signal setter feeding `<TileLayer camera>`. The initial pose
275
+ * share one camera, the FIRST one's `size()` the viewport), or anything
276
+ * else with the layers' `setCamera` and a `size()`, such as a signal
277
+ * setter feeding `<TileLayer camera>` beside its size. The initial pose
270
278
  * applies immediately: the world fitted when `world` is given and no
271
279
  * `zoom`. The pose options are initial values; every other option is read
272
280
  * where it applies, so a caller holding the options object (or a props
@@ -277,7 +285,11 @@ function checkOptions(options: Camera2dOptions): void {
277
285
  */
278
286
  export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], options: Camera2dOptions): Camera2d {
279
287
  let targets = Array.isArray(target) ? target : [target]
280
- if (typeof options.viewport !== "function") throw new Error("createCamera2d: viewport must be a function returning { width, height }")
288
+ for (let t of targets) {
289
+ if (!t || typeof t.setCamera !== "function" || typeof t.size !== "function") throw new Error("createCamera2d: every target needs setCamera() and size() (a view)")
290
+ }
291
+ let first = targets[0]
292
+ if (first === undefined) throw new Error("createCamera2d: at least one target")
281
293
  checkOptions(options)
282
294
  checkPose("", options)
283
295
  // Everything below the pose is read from `options` where it applies
@@ -301,15 +313,15 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
301
313
  let zoom = options.zoom ?? 1
302
314
  let rotation = options.rotation ?? 0
303
315
 
304
- // The viewport as last read; zeros mean unknown (nothing clamps or fits
305
- // until a real size arrives).
316
+ // The viewport (the first target's size) as last read; zeros mean
317
+ // unknown (nothing clamps or fits until a real size arrives).
306
318
  let vw = 0
307
319
  let vh = 0
308
320
  let known = () => vw > 0 && vh > 0
309
321
  let readViewport = () => {
310
- let v = options.viewport()
322
+ let v = first.size()
311
323
  if (!(Number.isFinite(v.width) && Number.isFinite(v.height) && v.width >= 0 && v.height >= 0)) {
312
- throw new Error(`createCamera2d: viewport() must return non-negative finite width/height, got ${v.width}x${v.height}`)
324
+ throw new Error(`createCamera2d: the target's size() must return non-negative finite width/height, got ${v.width}x${v.height}`)
313
325
  }
314
326
  vw = v.width
315
327
  vh = v.height
@@ -651,8 +663,9 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
651
663
  rollBy(angle)
652
664
  flush()
653
665
  },
654
- glideTo(tx, ty, tz, tr) {
655
- glideTo(tx, ty, tz, tr)
666
+ glideTo(pose) {
667
+ checkPose("glideTo", pose)
668
+ glideTo(pose.x ?? x, pose.y ?? y, pose.zoom, pose.rotation)
656
669
  notify()
657
670
  },
658
671
  fit(rect, opts) {
@@ -0,0 +1,20 @@
1
+ // The kinematic character move, Godot's CharacterBody2D.move_and_slide
2
+ // and Unity's Rigidbody2D mover as one pure function: no sprite, no
3
+ // velocity state. It takes a volume where the body IS and the motion it
4
+ // WANTS, and returns the motion it gets plus what it touched; a
5
+ // sprite-driven body applies the result with setSprite. The loop itself
6
+ // (depenetration, sweep, slide, floor snap) runs in the spatial core as
7
+ // layer.moveAndSlide; this is its free-function spelling, the same as
8
+ // @solidrt/3d's moveAndSlide(scene, ...).
9
+
10
+ import type { MoveOptions, MoveResult, SpriteLayer, Volume } from "./layer.ts"
11
+
12
+ /** What moveAndSlide needs of a layer. */
13
+ export type MoveLayer = Pick<SpriteLayer, "moveAndSlide">
14
+
15
+ /** Move a body by (dx, dy) through the layer's sprites, sliding along
16
+ * what it hits: layer.moveAndSlide as a function (see it for the
17
+ * contract). */
18
+ export function moveAndSlide(layer: MoveLayer, volume: Volume, dx: number, dy: number, opts: MoveOptions = {}): MoveResult {
19
+ return layer.moveAndSlide(volume, dx, dy, opts)
20
+ }
@@ -4,10 +4,7 @@ import { createCamera2d } from "../camera2d.ts"
4
4
  import type { Camera2d as Camera2dHandle, Camera2dAxes, Camera2dOptions } from "../camera2d.ts"
5
5
  import { useSpriteLayer } from "./context.ts"
6
6
 
7
- export type Camera2dProps = Omit<Camera2dOptions, "viewport"> & {
8
- /** Viewport in layer pixels; defaults to the driven view's own size
9
- * (live: a fill layer's box, a setSize, a `<View2d>` resize). */
10
- viewport?: () => { width: number; height: number }
7
+ export type Camera2dProps = Camera2dOptions & {
11
8
  /** The input map driving the control (InputMap.drive): its `pan`,
12
9
  * `zoom` and `roll` actions, or the ones `actions` names. Live: a new
13
10
  * map reconnects. Without one the control moves only through `ref`. */
@@ -35,21 +32,17 @@ const MAX_CAMERA_DT = 0.1
35
32
  * getter and read where it applies, so a world, a zoom range, a pivot,
36
33
  * the follow's zones, the offset lane or a rate follows its prop, and a
37
34
  * bounds, pivot or offset change re-clamps and pushes the pose at once -
38
- * the `<OrbitCamera>` rule.
39
- * `viewport` defaults to the view's own size. Frames run only while the
40
- * camera moves (`active()`), so a resting camera leaves the app
41
- * demand-driven idle.
35
+ * the `<OrbitCamera>` rule. The view's `size()` is the viewport (live: a
36
+ * fill layer's box, a setSize, a `<View2d>` resize). Frames run only
37
+ * while the camera moves (`active()`), so a resting camera leaves the
38
+ * app demand-driven idle.
42
39
  */
43
40
  export let Camera2d: VoidComponent<Camera2dProps> = props => {
44
41
  let target = useSpriteLayer().viewport
45
42
  // Through merge, not a spread: a props object hands out getters, and
46
43
  // merge keeps them, so the control reads each option live where it
47
44
  // applies.
48
- let options: Camera2dOptions = merge(props, {
49
- get viewport() {
50
- return props.viewport ?? (() => ({ width: target.width, height: target.height }))
51
- },
52
- })
45
+ let options: Camera2dOptions = merge(props, {})
53
46
  let cam = untrack(() => createCamera2d(target, options))
54
47
  createEffect(
55
48
  () => props.input,
@@ -1,11 +1,14 @@
1
1
  import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
2
  import type { ParentComponent } from "@solidrt/core"
3
3
  import { addGroup, destroyGroup, setGroup, setGroupTransition } from "../layer.ts"
4
- import type { SpriteGroup, SpritePointerEvent, SpriteTapEvent, SpriteWheelEvent, TransitionEndEvent } from "../layer.ts"
4
+ import type { SpriteGroup, TransitionEndEvent } from "../layer.ts"
5
5
  import type { SpriteTransition } from "../layer.ts"
6
6
  import { GroupContext, LayerContext } from "./context.ts"
7
+ import type { BubblingSpritePointerProps } from "./sprite.tsx"
7
8
 
8
- export type GroupProps = {
9
+ /** A group's props: the pose, the bubbling pointer events (from a hit
10
+ * child sprite; a group never receives enter/leave) and the transition. */
11
+ export type GroupProps = BubblingSpritePointerProps & {
9
12
  /** Position in the parent frame (layer pixels at the root). */
10
13
  x?: number
11
14
  y?: number
@@ -16,14 +19,6 @@ export type GroupProps = {
16
19
  /** Show or hide the whole subtree (default true); see
17
20
  * GroupOptions.visible. */
18
21
  visible?: boolean
19
- /** Bubbled from a hit child sprite (see SpritePointerProps); a group
20
- * never receives enter/leave. */
21
- onPointerDown?: (event: SpritePointerEvent) => void
22
- onPointerMove?: (event: SpritePointerEvent) => void
23
- onPointerUp?: (event: SpritePointerEvent) => void
24
- onPointerCancel?: (event: SpritePointerEvent) => void
25
- onWheel?: (event: SpriteWheelEvent) => void
26
- onTap?: (event: SpriteTapEvent) => void
27
22
  /** How pose-prop changes animate (see setGroupTransition). The mount
28
23
  * pose snaps, unless a component's `from` animates it in from there. */
29
24
  transition?: SpriteTransition | string | null
@@ -19,7 +19,9 @@ export type { LayerPointerProps, SpriteLayerProps } from "./sprite-layer.tsx"
19
19
  export { Group } from "./group.tsx"
20
20
  export type { GroupProps } from "./group.tsx"
21
21
  export { Sprite } from "./sprite.tsx"
22
- export type { SpritePointerProps, SpriteProps } from "./sprite.tsx"
22
+ export { Text2d } from "./text2d.tsx"
23
+ export type { Text2dProps } from "./text2d.tsx"
24
+ export type { BubblingSpritePointerProps, SpritePointerProps, SpriteProps } from "./sprite.tsx"
23
25
  export { Camera2d } from "./camera2d.tsx"
24
26
  export type { Camera2dProps } from "./camera2d.tsx"
25
27
  export { Shot, Shots } from "./shots.tsx"
@@ -1,5 +1,7 @@
1
1
  import { createEffect, displayScale, getBoundingBoxViewport, getLayoutBox, onCleanup, onLayout, untrack } from "@solidrt/core"
2
2
  import type { Element, ParentComponent, PointerFeed, TextureId } from "@solidrt/core"
3
+ import type { BlendMode } from "@solidrt/core/gpu"
4
+ import type { Atlas } from "../atlas.ts"
3
5
  import { createSpriteLayer } from "../layer.ts"
4
6
  import { feedPointer } from "../views.ts"
5
7
  import type { LayerPointerEvent, LayerTapEvent, LayerWheelEvent, SpriteLayer as LayerHandle } from "../layer.ts"
@@ -39,10 +41,27 @@ export type SpriteLayerProps = LayerPointerProps & {
39
41
  */
40
42
  width?: number
41
43
  height?: number
42
- /** The atlas texture every sprite samples (create with createAtlas). */
43
- atlas: TextureId
44
+ /** The atlases the sprites draw from (createAtlas records), bound as
45
+ * one draw; every `<Sprite frame>` comes from one of them. Fixed at
46
+ * mount. Give these (a layer of the component's own, disposed with it)
47
+ * or `layer`, not both. */
48
+ atlases?: Atlas[]
49
+ /**
50
+ * An existing layer (createSpriteLayer) to adopt instead of making one:
51
+ * the component shows and populates it - the view and the leaf are
52
+ * still its own, `<Sprite>`, `<Group>` and `<Text2d>` children mount
53
+ * into it - and leaves its lifetime to its maker, so the component
54
+ * face sits over an imperative layer. The creation props (atlases,
55
+ * capacity, blend, orderBy) describe a layer the component makes, so
56
+ * with `layer` they have nothing to apply to and throw.
57
+ */
58
+ layer?: LayerHandle
44
59
  /** Initial record reservation (grows on demand); default 1024. */
45
60
  capacity?: number
61
+ /** How the sprites blend into the layer's views (see
62
+ * SpriteLayerOptions.blend); default "alpha", "add" for glows and
63
+ * additive particles. Fixed at mount. */
64
+ blend?: BlendMode
46
65
  /** The own view's clear color. */
47
66
  clearColor?: [number, number, number, number]
48
67
  /** Pan/zoom/rotate the own view over the world (in-shader); a
@@ -129,6 +148,10 @@ export type SpriteLayerProps = LayerPointerProps & {
129
148
  // before the first paint, so it only has to be a valid target size.
130
149
  const FILL_INITIAL_SIZE = 1
131
150
 
151
+ // The props that describe a layer the component creates - meaningless,
152
+ // hence rejected, on an adopted one (`layer`), which was created already.
153
+ const CREATION_PROPS = ["atlases", "capacity", "blend", "orderBy"] as const
154
+
132
155
  // The props that configure the layer's own view - meaningless, hence
133
156
  // rejected, on a layer without one (output={false}).
134
157
  const VIEW_PROPS = [
@@ -150,25 +173,35 @@ const VIEW_PROPS = [
150
173
  ] as const
151
174
 
152
175
  /**
153
- * Owns a sprite layer and, unless `output={false}`, its own view,
154
- * composited as an ordinary `<texture>` leaf so the output takes layout,
155
- * transforms, blendMode, and pointer events like any element - or hand
156
- * `output` the texture id and compose it yourself. A layout component:
157
- * it cannot sit inside a d-* subtree; `output` with a `<d-texture>` is
158
- * the detached form. Children (`<Sprite>`) render nothing themselves -
159
- * they populate the retained layer through context; `<View2d>` children
160
- * are further views of the same sprites.
176
+ * Owns a sprite layer (over `atlases`; or adopts an existing one through
177
+ * `layer`, owning only what follows) and, unless `output={false}`, its
178
+ * own view, composited as an ordinary `<texture>` leaf so the output
179
+ * takes layout, transforms, blendMode, and pointer events like any
180
+ * element - or hand `output` the texture id and compose it yourself. A
181
+ * layout component: it cannot sit inside a d-* subtree; `output` with a
182
+ * `<d-texture>` is the detached form. Children (`<Sprite>`) render
183
+ * nothing themselves - they populate the retained layer through context;
184
+ * `<View2d>` children are further views of the same sprites.
161
185
  */
162
186
  export let SpriteLayer: ParentComponent<SpriteLayerProps> = props => {
163
- let layer = untrack(() =>
164
- createSpriteLayer(props.atlas, {
187
+ let layer = untrack(() => {
188
+ let adopted = props.layer
189
+ if (adopted) {
190
+ for (let name of CREATION_PROPS) {
191
+ if (props[name] !== undefined) throw new Error(`SpriteLayer: layer adopts an existing layer - ${name} was fixed when it was created`)
192
+ }
193
+ return adopted
194
+ }
195
+ if (props.atlases === undefined) throw new Error("SpriteLayer: give atlases (a layer of its own) or layer (an existing one)")
196
+ return createSpriteLayer(props.atlases, {
165
197
  capacity: props.capacity,
198
+ blend: props.blend,
166
199
  tint: props.tint,
167
200
  orderBy: props.orderBy,
168
201
  stagger: props.stagger,
169
202
  label: props.label,
170
- }),
171
- )
203
+ })
204
+ })
172
205
  createEffect(
173
206
  () => props.tint,
174
207
  tint => {
@@ -14,14 +14,22 @@ import { GroupContext, LayerContext } from "./context.ts"
14
14
  * carries layer.handlers - the built-in `<SpriteLayer>` leaf does (opt
15
15
  * out with events={false}); an `output` leaf spreads them itself.
16
16
  */
17
- export type SpritePointerProps = {
17
+ export type SpritePointerProps = BubblingSpritePointerProps & {
18
+ /** Sprites only: a Group never receives enter/leave. */
19
+ onPointerEnter?: (event: SpritePointerEvent) => void
20
+ onPointerLeave?: (event: SpritePointerEvent) => void
21
+ }
22
+
23
+ /** The pointer props a `<Group>` takes as well: the events that bubble
24
+ * from the hit sprite up through the Groups above it, without the hover
25
+ * pair (@solidrt/3d's BubblingNodePointerProps). */
26
+ export type BubblingSpritePointerProps = {
18
27
  onPointerDown?: (event: SpritePointerEvent) => void
19
28
  onPointerMove?: (event: SpritePointerEvent) => void
20
29
  onPointerUp?: (event: SpritePointerEvent) => void
30
+ /** The pointer's other end: the system took it away (see core's
31
+ * onPointerCancel); bubbles like up, and the press it ends never taps. */
21
32
  onPointerCancel?: (event: SpritePointerEvent) => void
22
- /** Sprites only: a Group never receives enter/leave. */
23
- onPointerEnter?: (event: SpritePointerEvent) => void
24
- onPointerLeave?: (event: SpritePointerEvent) => void
25
33
  onWheel?: (event: SpriteWheelEvent) => void
26
34
  /** A press released on the sprite without dragging; `tapCount` counts
27
35
  * repeats (2 = double tap). */
@@ -45,9 +53,9 @@ export let Sprite: VoidComponent<SpriteProps> = props => {
45
53
  let parent = useContext(GroupContext)
46
54
  let sprite = untrack(() => addSprite(layer, { parent }))
47
55
  createEffect(
48
- () => [props.x, props.y, props.w, props.h, props.frame, props.flipX, props.flipY, props.rotation, props.tint, props.renderOrder, props.visible] as const,
49
- ([x, y, w, h, frame, flipX, flipY, rotation, tint, renderOrder, visible]) =>
50
- setSprite(sprite, { x, y, w, h, frame, flipX, flipY, rotation, tint, renderOrder, visible: visible !== false }),
56
+ () => [props.x, props.y, props.w, props.h, props.frame, props.flipX, props.flipY, props.rotation, props.tint, props.minScreenPx, props.maxScreenPx, props.renderOrder, props.visible] as const,
57
+ ([x, y, w, h, frame, flipX, flipY, rotation, tint, minScreenPx, maxScreenPx, renderOrder, visible]) =>
58
+ setSprite(sprite, { x, y, w, h, frame, flipX, flipY, rotation, tint, minScreenPx, maxScreenPx, renderOrder, visible: visible !== false }),
51
59
  )
52
60
  // After the pose effect, so the mount pose snaps before writes animate.
53
61
  createEffect(