@solidrt/2d 0.0.67 → 0.0.68
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 +246 -69
- package/README.md +33 -18
- package/examples/README.md +29 -0
- package/examples/anim.tsx +1 -1
- package/examples/atlases.tsx +151 -0
- package/examples/camera-probe.tsx +7 -7
- package/examples/camera.tsx +2 -3
- package/examples/markers.tsx +185 -0
- package/examples/parity.tsx +2 -2
- package/examples/pick.tsx +1 -1
- package/examples/split-screen.tsx +1 -1
- package/examples/springs.tsx +1 -1
- package/examples/sprites.tsx +1 -1
- package/examples/text.tsx +143 -0
- package/examples/tiles.tsx +1 -1
- package/examples/views.tsx +3 -3
- package/package.json +3 -3
- package/src/atlas.ts +75 -12
- package/src/camera2d.ts +47 -34
- package/src/collision.ts +20 -0
- package/src/components/camera2d.tsx +6 -13
- package/src/components/group.tsx +5 -10
- package/src/components/index.ts +3 -1
- package/src/components/sprite-layer.tsx +47 -14
- package/src/components/sprite.tsx +15 -7
- package/src/components/text2d.tsx +77 -0
- package/src/components/tile-layer.tsx +11 -5
- package/src/dispatch.ts +3 -2
- package/src/extrude.ts +156 -0
- package/src/font.ts +207 -0
- package/src/frames.ts +65 -55
- package/src/index.ts +37 -18
- package/src/layer.ts +267 -45
- package/src/pack.ts +70 -0
- package/src/pick.ts +25 -1
- package/src/records.ts +286 -121
- package/src/shaders.ts +224 -77
- package/src/shots.ts +23 -9
- package/src/text-layout.ts +163 -0
- package/src/text.ts +240 -0
- package/src/tiles.ts +111 -71
- package/src/views.ts +20 -13
package/examples/views.tsx
CHANGED
|
@@ -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
|
|
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, {
|
|
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.
|
|
3
|
+
"version": "0.0.68",
|
|
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.
|
|
18
|
+
"@solidrt/test": "0.0.68"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"@solidjs/signals": "2.0.0-rc.13",
|
|
22
|
-
"@solidrt/core": "0.0.
|
|
22
|
+
"@solidrt/core": "0.0.68"
|
|
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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
|
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
|
|
43
|
-
* as they are. Freed
|
|
44
|
-
* (opt out with
|
|
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
|
|
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
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
|
155
|
-
* (the 3d controls' pose()); no lane in it. */
|
|
156
|
-
pose():
|
|
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
|
|
176
|
-
* the
|
|
177
|
-
* `rotation`
|
|
178
|
-
*
|
|
179
|
-
|
|
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
|
|
205
|
-
* the
|
|
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
|
|
269
|
-
*
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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:
|
|
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(
|
|
655
|
-
glideTo
|
|
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) {
|
package/src/collision.ts
ADDED
|
@@ -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 =
|
|
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
|
-
*
|
|
40
|
-
* camera moves (`active()`), so a resting camera leaves the
|
|
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,
|
package/src/components/group.tsx
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
43
|
-
|
|
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
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
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
|
-
|
|
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(
|