@solidrt/2d 0.0.55 → 0.0.56
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 +241 -57
- package/examples/README.md +22 -5
- package/examples/anim.tsx +86 -0
- package/examples/camera-probe.tsx +200 -0
- package/examples/camera.tsx +190 -0
- package/examples/parity.tsx +4 -4
- package/examples/pick.tsx +50 -33
- package/examples/tiles.tsx +19 -10
- package/package.json +3 -3
- package/src/animation.ts +178 -0
- package/src/camera-motion.ts +532 -0
- package/src/camera.ts +86 -0
- package/src/camera2d.ts +140 -0
- package/src/components.tsx +330 -76
- package/src/dispatch.ts +231 -0
- package/src/index.ts +30 -5
- package/src/layer.ts +454 -186
- package/src/oversample-math.ts +73 -0
- package/src/oversample.ts +39 -11
- package/src/records.ts +132 -27
- package/src/shaders.ts +29 -12
- package/src/tiles.ts +79 -24
package/src/components.tsx
CHANGED
|
@@ -6,13 +6,18 @@
|
|
|
6
6
|
// sprite with `ref` and call setSprite from onFrame - signals carry
|
|
7
7
|
// structure and slow state, per-frame motion goes straight to the layer.
|
|
8
8
|
// The same split, with the same reasoning, as @solidrt/3d's components.
|
|
9
|
-
import {
|
|
9
|
+
import { merge } from "@solidjs/signals"
|
|
10
|
+
import { createContext, createEffect, createSignal, displayScale, For, getBoundingBoxViewport, getLayoutBox, onCleanup, onFrame, onLayout, untrack, useContext, windowSize } from "@solidrt/core"
|
|
10
11
|
import type { Element, ParentComponent, TextureId, VoidComponent } from "@solidrt/core"
|
|
12
|
+
import { limits } from "@solidrt/core/gpu"
|
|
11
13
|
import type { FilterMode } from "@solidrt/core/gpu"
|
|
12
14
|
import { addGroup, addSprite, createSpriteLayer, removeGroup, removeSprite, setGroup, setGroupTransition, setSprite, setSpriteTransition } from "./layer.ts"
|
|
13
15
|
import type { NodeTransition } from "flux:spatial"
|
|
14
|
-
import type { CameraUpdate
|
|
15
|
-
import {
|
|
16
|
+
import type { CameraUpdate } from "./camera.ts"
|
|
17
|
+
import { createCamera2d } from "./camera2d.ts"
|
|
18
|
+
import type { Camera2d as Camera2dHandle, Camera2dOptions } from "./camera2d.ts"
|
|
19
|
+
import type { LayerPointerEvent, LayerTapEvent, LayerWheelEvent, Sprite as SpriteHandle, SpriteGroup, SpriteLayer as LayerHandle, SpriteOptions, SpritePointerEvent, SpriteTapEvent, SpriteWheelEvent, TransitionEndEvent } from "./layer.ts"
|
|
20
|
+
import { pickOversample, tileWorldScale } from "./oversample-math.ts"
|
|
16
21
|
|
|
17
22
|
// The window's device pixel count: the texel budget an auto-picked
|
|
18
23
|
// oversample target stays within (see fitOversample).
|
|
@@ -21,39 +26,103 @@ function windowTexels(): number {
|
|
|
21
26
|
let scale = displayScale()
|
|
22
27
|
return win.width * scale * (win.height * scale)
|
|
23
28
|
}
|
|
29
|
+
|
|
30
|
+
// The auto-pick apply: the decision itself (cap, shrink hysteresis,
|
|
31
|
+
// validation) is pure and lives in oversample-math.ts.
|
|
32
|
+
function applyOversample(
|
|
33
|
+
layer: { readonly oversample: number; setOversample(n: number): void },
|
|
34
|
+
scale: number,
|
|
35
|
+
targetW: number,
|
|
36
|
+
targetH: number,
|
|
37
|
+
max: number | undefined,
|
|
38
|
+
): void {
|
|
39
|
+
let n = pickOversample(layer.oversample, scale, targetW, targetH, windowTexels(), limits.maxTextureSize, max)
|
|
40
|
+
if (n !== null) layer.setOversample(n)
|
|
41
|
+
}
|
|
24
42
|
import { createTileLayer } from "./tiles.ts"
|
|
25
43
|
import type { TileChunk, TileLayer as TileLayerHandle } from "./tiles.ts"
|
|
26
44
|
|
|
27
45
|
let LayerContext = createContext<LayerHandle>()
|
|
46
|
+
let GroupContext = createContext<SpriteGroup | null>(null)
|
|
47
|
+
|
|
48
|
+
type LayerCtx = { layer: LayerHandle; parent: SpriteGroup | null }
|
|
28
49
|
|
|
29
50
|
/**
|
|
30
|
-
* The enclosing layer - the imperative escape hatch
|
|
31
|
-
* subtree (throws outside a `<SpriteLayer>`)
|
|
51
|
+
* The enclosing layer and parent group - the imperative escape hatch
|
|
52
|
+
* inside a component subtree (throws outside a `<SpriteLayer>`), the same
|
|
53
|
+
* shape as @solidrt/3d's useScene. `parent` is the enclosing `<Group>`'s
|
|
54
|
+
* handle (null at the layer root); pass it on to addSprite/addGroup so
|
|
55
|
+
* imperative sprites mount where the JSX sits.
|
|
32
56
|
*/
|
|
33
|
-
export function useSpriteLayer():
|
|
34
|
-
return useContext(LayerContext)
|
|
57
|
+
export function useSpriteLayer(): LayerCtx {
|
|
58
|
+
return { layer: useContext(LayerContext), parent: useContext(GroupContext) }
|
|
35
59
|
}
|
|
36
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Sprite pointer events, the element vocabulary one tree deeper: the
|
|
63
|
+
* topmost hit sprite receives the event, down/move/up/wheel/tap bubble to
|
|
64
|
+
* enclosing Groups and end at the `<SpriteLayer>` (stopPropagation stops
|
|
65
|
+
* the walk; stopping a down claims the whole press), enter/leave pair on
|
|
66
|
+
* the sprite alone. Events flow while the element showing the layer
|
|
67
|
+
* carries layer.handlers - the built-in `<SpriteLayer>` leaf does (opt
|
|
68
|
+
* out with events={false}); an `output` leaf spreads them itself.
|
|
69
|
+
*/
|
|
37
70
|
export type SpritePointerProps = {
|
|
38
71
|
onPointerDown?: (event: SpritePointerEvent) => void
|
|
39
72
|
onPointerMove?: (event: SpritePointerEvent) => void
|
|
40
73
|
onPointerUp?: (event: SpritePointerEvent) => void
|
|
74
|
+
/** Sprites only: a Group never receives enter/leave. */
|
|
41
75
|
onPointerEnter?: (event: SpritePointerEvent) => void
|
|
42
76
|
onPointerLeave?: (event: SpritePointerEvent) => void
|
|
77
|
+
onWheel?: (event: SpriteWheelEvent) => void
|
|
78
|
+
/** A press released on the sprite without dragging; `tapCount` counts
|
|
79
|
+
* repeats (2 = double tap). */
|
|
80
|
+
onTap?: (event: SpriteTapEvent) => void
|
|
43
81
|
}
|
|
44
82
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
83
|
+
/**
|
|
84
|
+
* The layer's own pointer events, the root of the walk: every event
|
|
85
|
+
* arrives after the hit sprite and its Groups (`event.sprite` set) or as
|
|
86
|
+
* the only stop over empty space (`event.sprite` null), unless a handler
|
|
87
|
+
* stopped it. Deselect on a miss, start a marquee, drive a camera - the
|
|
88
|
+
* `<Camera2d>` child listens at the same root.
|
|
89
|
+
*/
|
|
90
|
+
export type LayerPointerProps = {
|
|
91
|
+
onPointerDown?: (event: LayerPointerEvent) => void
|
|
92
|
+
onPointerMove?: (event: LayerPointerEvent) => void
|
|
93
|
+
onPointerUp?: (event: LayerPointerEvent) => void
|
|
94
|
+
onWheel?: (event: LayerWheelEvent) => void
|
|
95
|
+
onTap?: (event: LayerTapEvent) => void
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type SpriteLayerProps = LayerPointerProps & {
|
|
99
|
+
/**
|
|
100
|
+
* Layer pixels - give both, or neither. Omitted, the layer FILLS: the
|
|
101
|
+
* built-in leaf is laid out at 100% of its parent's box (give it a sized
|
|
102
|
+
* parent, as on the web) and the layer sizes to that box, so layer
|
|
103
|
+
* pixels are the leaf's own coordinates and sprites place in element
|
|
104
|
+
* units - the auto oversample still supplies display density on top.
|
|
105
|
+
* Fill or fixed is decided at mount, and matches @solidrt/3d's `<Scene>`
|
|
106
|
+
* one dimension down. `output` needs explicit sizes (the layer cannot
|
|
107
|
+
* follow a leaf it does not own); the leaf's own width/height are then
|
|
108
|
+
* layout, so render size and display size separate.
|
|
109
|
+
*/
|
|
110
|
+
width?: number
|
|
111
|
+
height?: number
|
|
50
112
|
/** The atlas texture every sprite samples (create with createAtlas). */
|
|
51
113
|
atlas: TextureId
|
|
52
114
|
/** Initial record reservation (grows on demand); default 1024. */
|
|
53
115
|
capacity?: number
|
|
54
116
|
clearColor?: [number, number, number, number]
|
|
55
|
-
/** Pan/zoom over the world; a shared-params write,
|
|
117
|
+
/** Pan/zoom/rotate over the world (in-shader); a shared-params write,
|
|
118
|
+
* never per-sprite. */
|
|
56
119
|
camera?: CameraUpdate
|
|
120
|
+
/**
|
|
121
|
+
* Layer tint, [r, g, b, a] in 0..1, multiplied over every sprite's own
|
|
122
|
+
* tint (day/night, a dimmed parallax plane, a fade-in). One shared
|
|
123
|
+
* uniform write, cheap to animate - unlike TileLayer's, which re-bakes.
|
|
124
|
+
*/
|
|
125
|
+
tint?: [number, number, number, number]
|
|
57
126
|
/**
|
|
58
127
|
* Target texels per layer pixel. Absent, the component picks it every
|
|
59
128
|
* layout from the built-in leaf's on-screen size (display scale, any
|
|
@@ -61,18 +130,35 @@ export type SpriteLayerProps = {
|
|
|
61
130
|
* scale; with `output` there is no built-in leaf, so set it yourself.
|
|
62
131
|
*/
|
|
63
132
|
oversample?: number
|
|
133
|
+
/**
|
|
134
|
+
* Cap on the auto-picked oversample (integer >= 1): the layer stays
|
|
135
|
+
* adaptive up to it, never above - the lever that bounds target memory on
|
|
136
|
+
* high-scale displays without giving up adaptivity. Ignored when
|
|
137
|
+
* `oversample` is set (explicit already opts out of the pick).
|
|
138
|
+
*/
|
|
139
|
+
maxOversample?: number
|
|
140
|
+
/**
|
|
141
|
+
* Draw sprites in key order instead of slot order, produced by core at
|
|
142
|
+
* every publish: `"y"` orders by world y (back-to-front for a
|
|
143
|
+
* perspective scene, zero JS per frame even under native transitions),
|
|
144
|
+
* `"renderOrder"` by the per-sprite `renderOrder` field (explicit layering:
|
|
145
|
+
* raise-on-drag, click-to-front). Creation-fixed (see
|
|
146
|
+
* SpriteLayerOptions.orderBy).
|
|
147
|
+
*/
|
|
148
|
+
orderBy?: "y" | "renderOrder"
|
|
64
149
|
label?: string
|
|
65
150
|
ref?: (layer: LayerHandle) => void
|
|
66
151
|
/**
|
|
67
152
|
* Compose the output yourself: called once (untracked) with the layer's
|
|
68
153
|
* texture id, and its return renders in place of the built-in `<texture>`
|
|
69
154
|
* leaf. Sprite pointer events then need the layer's handlers on your
|
|
70
|
-
* leaf: `<texture src={texture} {...useSpriteLayer().handlers} />`.
|
|
155
|
+
* leaf: `<texture src={texture} {...useSpriteLayer().layer.handlers} />`.
|
|
71
156
|
*/
|
|
72
157
|
output?: (texture: TextureId) => Element
|
|
73
158
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
159
|
+
* Pointer events (default on): the built-in leaf carries layer.handlers,
|
|
160
|
+
* so sprites, groups, the layer's own handlers and a `<Camera2d>` child
|
|
161
|
+
* receive input. `false` detaches them - the leaf then costs no pointer
|
|
76
162
|
* routing at all.
|
|
77
163
|
*/
|
|
78
164
|
events?: boolean
|
|
@@ -87,17 +173,39 @@ export type SpriteLayerProps = {
|
|
|
87
173
|
* Children (`<Sprite>`) render nothing themselves - they populate the
|
|
88
174
|
* retained layer through context.
|
|
89
175
|
*/
|
|
176
|
+
// Creation size of a fill-mode layer: the first onLayout replaces it
|
|
177
|
+
// before the first paint, so it only has to be a valid target size.
|
|
178
|
+
const FILL_INITIAL_SIZE = 1
|
|
179
|
+
|
|
90
180
|
export let SpriteLayer: ParentComponent<SpriteLayerProps> = props => {
|
|
181
|
+
// Fill vs fixed layer is decided at mount, like `output`: both width
|
|
182
|
+
// and height (fixed layer pixels) or neither (fill).
|
|
183
|
+
let fill = untrack(() => {
|
|
184
|
+
if ((props.width === undefined) !== (props.height === undefined)) {
|
|
185
|
+
throw new Error("SpriteLayer: width and height come together - give both (fixed layer) or neither (fill)")
|
|
186
|
+
}
|
|
187
|
+
if (props.width === undefined && props.output) {
|
|
188
|
+
throw new Error("SpriteLayer: fill needs the built-in leaf - with output, give width and height")
|
|
189
|
+
}
|
|
190
|
+
return props.width === undefined
|
|
191
|
+
})
|
|
91
192
|
let layer = untrack(() =>
|
|
92
|
-
createSpriteLayer(props.width, props.height, props.atlas, {
|
|
193
|
+
createSpriteLayer(props.width ?? FILL_INITIAL_SIZE, props.height ?? FILL_INITIAL_SIZE, props.atlas, {
|
|
93
194
|
capacity: props.capacity,
|
|
94
195
|
clearColor: props.clearColor,
|
|
196
|
+
tint: props.tint,
|
|
197
|
+
orderBy: props.orderBy,
|
|
95
198
|
label: props.label,
|
|
96
199
|
}),
|
|
97
200
|
)
|
|
98
201
|
createEffect(
|
|
99
202
|
() => [props.width, props.height] as const,
|
|
100
|
-
([w, h]) =>
|
|
203
|
+
([w, h]) => {
|
|
204
|
+
if ((w === undefined) !== (h === undefined) || (w === undefined) !== fill) {
|
|
205
|
+
throw new Error("SpriteLayer: fill/fixed is mount-fixed - width/height cannot appear or disappear")
|
|
206
|
+
}
|
|
207
|
+
if (!fill) layer.setSize(w!, h!)
|
|
208
|
+
},
|
|
101
209
|
)
|
|
102
210
|
createEffect(
|
|
103
211
|
() => props.camera,
|
|
@@ -111,24 +219,70 @@ export let SpriteLayer: ParentComponent<SpriteLayerProps> = props => {
|
|
|
111
219
|
if (n !== undefined) layer.setOversample(n)
|
|
112
220
|
},
|
|
113
221
|
)
|
|
222
|
+
createEffect(
|
|
223
|
+
() => props.tint,
|
|
224
|
+
tint => {
|
|
225
|
+
if (tint !== undefined) layer.setTint(tint)
|
|
226
|
+
},
|
|
227
|
+
)
|
|
114
228
|
untrack(() => props.ref)?.(layer)
|
|
229
|
+
// The layer's own handlers at the root of the walk; the props are read
|
|
230
|
+
// per event, so a handler prop may change without re-registering.
|
|
231
|
+
onCleanup(
|
|
232
|
+
layer.listen({
|
|
233
|
+
onPointerDown: e => props.onPointerDown?.(e),
|
|
234
|
+
onPointerMove: e => props.onPointerMove?.(e),
|
|
235
|
+
onPointerUp: e => props.onPointerUp?.(e),
|
|
236
|
+
onWheel: e => props.onWheel?.(e),
|
|
237
|
+
onTap: e => props.onTap?.(e),
|
|
238
|
+
}),
|
|
239
|
+
)
|
|
115
240
|
let output = untrack(() => props.output)
|
|
116
241
|
let events = untrack(() => props.events) !== false
|
|
242
|
+
let leaf: { id: number } | undefined
|
|
243
|
+
// The layer's pixel size: the props in fixed mode, the built-in leaf's
|
|
244
|
+
// laid-out box in fill mode (getLayoutBox, the untransformed read, so a
|
|
245
|
+
// designSize fit or ancestor transform never enters the world space -
|
|
246
|
+
// display density rides on the oversample pick below). Null before the
|
|
247
|
+
// first layout.
|
|
248
|
+
let layerSize = (): { width: number; height: number } | null => {
|
|
249
|
+
if (!fill) return { width: props.width!, height: props.height! }
|
|
250
|
+
let box = leaf && getLayoutBox(leaf)
|
|
251
|
+
if (!box || box.width <= 0 || box.height <= 0) return null
|
|
252
|
+
return { width: Math.max(1, Math.round(box.width)), height: Math.max(1, Math.round(box.height)) }
|
|
253
|
+
}
|
|
254
|
+
// Fill: the layer follows the leaf's box. Registered before the pick, so
|
|
255
|
+
// one onLayout applies size then oversample in order; setSize no-ops
|
|
256
|
+
// when nothing changed.
|
|
257
|
+
if (fill) {
|
|
258
|
+
onLayout(() => {
|
|
259
|
+
let size = layerSize()
|
|
260
|
+
if (size) layer.setSize(size.width, size.height)
|
|
261
|
+
})
|
|
262
|
+
}
|
|
117
263
|
// Auto oversample: the built-in leaf's window box, in device pixels, per
|
|
118
264
|
// layer pixel. Picked after every layout, and again when the display scale
|
|
119
265
|
// changes: the first layout runs before the resize event that reports the
|
|
120
|
-
// scale, and a scale change alone lays nothing out.
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
let pick = () =>
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
266
|
+
// scale, and a scale change alone lays nothing out. pick runs as an
|
|
267
|
+
// onLayout handler and as an effect apply, both untracked scopes, so its
|
|
268
|
+
// prop reads are wrapped in an explicit untrack.
|
|
269
|
+
let pick = () =>
|
|
270
|
+
untrack(() => {
|
|
271
|
+
if (!leaf || props.oversample !== undefined) return
|
|
272
|
+
let box = getBoundingBoxViewport(leaf)
|
|
273
|
+
let size = layerSize()
|
|
274
|
+
if (!box || !size) return
|
|
275
|
+
let scale = displayScale() * Math.max(box.width / size.width, box.height / size.height)
|
|
276
|
+
applyOversample(layer, scale, size.width, size.height, props.maxOversample)
|
|
277
|
+
})
|
|
130
278
|
onLayout(pick)
|
|
131
|
-
createEffect(() => displayScale(), pick)
|
|
279
|
+
createEffect(() => [displayScale(), props.maxOversample], pick)
|
|
280
|
+
// Sprite events on the built-in leaf: at layer size the plain handlers,
|
|
281
|
+
// in fill mode scaled from the laid-out box (the box can be fractional,
|
|
282
|
+
// the layer size never is).
|
|
283
|
+
let layerHandlers = fill
|
|
284
|
+
? layer.handlersFor(() => (leaf && getLayoutBox(leaf)) || { width: 0, height: 0 })
|
|
285
|
+
: layer.handlers
|
|
132
286
|
return (
|
|
133
287
|
<LayerContext value={layer}>
|
|
134
288
|
{output ? (
|
|
@@ -137,12 +291,13 @@ export let SpriteLayer: ParentComponent<SpriteLayerProps> = props => {
|
|
|
137
291
|
<texture
|
|
138
292
|
ref={(n: { id: number }) => (leaf = n)}
|
|
139
293
|
src={layer.texture}
|
|
140
|
-
width={props.width}
|
|
141
|
-
height={props.height}
|
|
142
|
-
onPointerDown={events ?
|
|
143
|
-
onPointerMove={events ?
|
|
144
|
-
onPointerUp={events ?
|
|
145
|
-
onPointerLeave={events ?
|
|
294
|
+
width={fill ? "100%" : props.width}
|
|
295
|
+
height={fill ? "100%" : props.height}
|
|
296
|
+
onPointerDown={events ? layerHandlers.onPointerDown : undefined}
|
|
297
|
+
onPointerMove={events ? layerHandlers.onPointerMove : undefined}
|
|
298
|
+
onPointerUp={events ? layerHandlers.onPointerUp : undefined}
|
|
299
|
+
onPointerLeave={events ? layerHandlers.onPointerLeave : undefined}
|
|
300
|
+
onWheel={events ? layerHandlers.onWheel : undefined}
|
|
146
301
|
/>
|
|
147
302
|
)}
|
|
148
303
|
{props.children}
|
|
@@ -150,8 +305,6 @@ export let SpriteLayer: ParentComponent<SpriteLayerProps> = props => {
|
|
|
150
305
|
)
|
|
151
306
|
}
|
|
152
307
|
|
|
153
|
-
let GroupContext = createContext<SpriteGroup | null>(null)
|
|
154
|
-
|
|
155
308
|
export type GroupProps = {
|
|
156
309
|
/** Position in the parent frame (layer pixels at the root). */
|
|
157
310
|
x?: number
|
|
@@ -160,6 +313,16 @@ export type GroupProps = {
|
|
|
160
313
|
rotation?: number
|
|
161
314
|
/** Uniform scale on the whole subtree (child sprites scale with it). */
|
|
162
315
|
scale?: number
|
|
316
|
+
/** Show or hide the whole subtree (default true); see
|
|
317
|
+
* GroupOptions.visible. */
|
|
318
|
+
visible?: boolean
|
|
319
|
+
/** Bubbled from a hit child sprite (see SpritePointerProps); a group
|
|
320
|
+
* never receives enter/leave. */
|
|
321
|
+
onPointerDown?: (event: SpritePointerEvent) => void
|
|
322
|
+
onPointerMove?: (event: SpritePointerEvent) => void
|
|
323
|
+
onPointerUp?: (event: SpritePointerEvent) => void
|
|
324
|
+
onWheel?: (event: SpriteWheelEvent) => void
|
|
325
|
+
onTap?: (event: SpriteTapEvent) => void
|
|
163
326
|
/** How pose-prop changes animate (see setGroupTransition); the mount
|
|
164
327
|
* pose always snaps. */
|
|
165
328
|
transition?: NodeTransition | string | null
|
|
@@ -180,8 +343,8 @@ export let Group: ParentComponent<GroupProps> = props => {
|
|
|
180
343
|
let parent = useContext(GroupContext)
|
|
181
344
|
let group = untrack(() => addGroup(layer, { parent }))
|
|
182
345
|
createEffect(
|
|
183
|
-
() => [props.x, props.y, props.rotation, props.scale] as const,
|
|
184
|
-
([x, y, rotation, scale]) => setGroup(group, { x, y, rotation, scale }),
|
|
346
|
+
() => [props.x, props.y, props.rotation, props.scale, props.visible] as const,
|
|
347
|
+
([x, y, rotation, scale, visible]) => setGroup(group, { x, y, rotation, scale, visible: visible !== false }),
|
|
185
348
|
)
|
|
186
349
|
// After the pose effect, so the mount pose snaps before writes animate.
|
|
187
350
|
createEffect(
|
|
@@ -189,8 +352,13 @@ export let Group: ParentComponent<GroupProps> = props => {
|
|
|
189
352
|
transition => setGroupTransition(group, transition ?? null),
|
|
190
353
|
)
|
|
191
354
|
createEffect(
|
|
192
|
-
() => props.onTransitionEnd,
|
|
193
|
-
end => {
|
|
355
|
+
() => [props.onPointerDown, props.onPointerMove, props.onPointerUp, props.onWheel, props.onTap, props.onTransitionEnd] as const,
|
|
356
|
+
([down, move, up, wheel, tap, end]) => {
|
|
357
|
+
group.onPointerDown = down
|
|
358
|
+
group.onPointerMove = move
|
|
359
|
+
group.onPointerUp = up
|
|
360
|
+
group.onWheel = wheel
|
|
361
|
+
group.onTap = tap
|
|
194
362
|
group.onTransitionEnd = end
|
|
195
363
|
},
|
|
196
364
|
)
|
|
@@ -214,10 +382,11 @@ export type SpriteProps = SpriteOptions &
|
|
|
214
382
|
export let Sprite: VoidComponent<SpriteProps> = props => {
|
|
215
383
|
let layer = useContext(LayerContext)
|
|
216
384
|
let parent = useContext(GroupContext)
|
|
217
|
-
let sprite = untrack(() => addSprite(layer,
|
|
385
|
+
let sprite = untrack(() => addSprite(layer, { parent }))
|
|
218
386
|
createEffect(
|
|
219
|
-
() => [props.x, props.y, props.w, props.h, props.frame, props.flipX, props.flipY, props.rotation, props.tint] as const,
|
|
220
|
-
([x, y, w, h, frame, flipX, flipY, rotation, tint
|
|
387
|
+
() => [props.x, props.y, props.w, props.h, props.frame, props.flipX, props.flipY, props.rotation, props.tint, props.renderOrder, props.visible] as const,
|
|
388
|
+
([x, y, w, h, frame, flipX, flipY, rotation, tint, renderOrder, visible]) =>
|
|
389
|
+
setSprite(sprite, { x, y, w, h, frame, flipX, flipY, rotation, tint, renderOrder, visible: visible !== false }),
|
|
221
390
|
)
|
|
222
391
|
// After the pose effect, so the mount pose snaps before writes animate.
|
|
223
392
|
createEffect(
|
|
@@ -225,13 +394,15 @@ export let Sprite: VoidComponent<SpriteProps> = props => {
|
|
|
225
394
|
transition => setSpriteTransition(sprite, transition ?? null),
|
|
226
395
|
)
|
|
227
396
|
createEffect(
|
|
228
|
-
() => [props.onPointerDown, props.onPointerMove, props.onPointerUp, props.onPointerEnter, props.onPointerLeave, props.onTransitionEnd] as const,
|
|
229
|
-
([down, move, up, enter, leave, end]) => {
|
|
397
|
+
() => [props.onPointerDown, props.onPointerMove, props.onPointerUp, props.onPointerEnter, props.onPointerLeave, props.onWheel, props.onTap, props.onTransitionEnd] as const,
|
|
398
|
+
([down, move, up, enter, leave, wheel, tap, end]) => {
|
|
230
399
|
sprite.onPointerDown = down
|
|
231
400
|
sprite.onPointerMove = move
|
|
232
401
|
sprite.onPointerUp = up
|
|
233
402
|
sprite.onPointerEnter = enter
|
|
234
403
|
sprite.onPointerLeave = leave
|
|
404
|
+
sprite.onWheel = wheel
|
|
405
|
+
sprite.onTap = tap
|
|
235
406
|
sprite.onTransitionEnd = end
|
|
236
407
|
},
|
|
237
408
|
)
|
|
@@ -240,22 +411,68 @@ export let Sprite: VoidComponent<SpriteProps> = props => {
|
|
|
240
411
|
return null
|
|
241
412
|
}
|
|
242
413
|
|
|
414
|
+
export type Camera2dProps = Omit<Camera2dOptions, "viewport"> & {
|
|
415
|
+
/** Viewport in layer pixels; defaults to the enclosing layer's own size
|
|
416
|
+
* (live: a fill layer's box, a setSize). */
|
|
417
|
+
viewport?: () => { width: number; height: number }
|
|
418
|
+
ref?: (camera: Camera2dHandle) => void
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Cap on the camera loop's per-frame dt in seconds, so the first tick
|
|
422
|
+
// after a suspended stretch (a resumed app, a reload) cannot leap a glide.
|
|
423
|
+
const MAX_CAMERA_DT = 0.1
|
|
424
|
+
|
|
243
425
|
/**
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
426
|
+
* createCamera2d as a SpriteLayer child: drives the enclosing layer's
|
|
427
|
+
* camera and takes its input from the layer's root through context - no
|
|
428
|
+
* ref plumbing, no handler spreads, no onFrame of your own. A sprite that
|
|
429
|
+
* claims its press (stopPropagation on its down) keeps the camera out of
|
|
430
|
+
* that drag; everything else pans, pinches and wheels. The options are
|
|
431
|
+
* read at mount (the motion reads them once): change the pose at runtime
|
|
432
|
+
* through `ref`'s set/glideTo/fit/follow, and remount (a keyed `<Show>`)
|
|
433
|
+
* for new bounds. `viewport` defaults to the layer's own size. Frames run
|
|
434
|
+
* only while the camera moves (`active()`), so a resting camera leaves
|
|
435
|
+
* the app demand-driven idle.
|
|
252
436
|
*/
|
|
253
|
-
export
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
437
|
+
export let Camera2d: VoidComponent<Camera2dProps> = props => {
|
|
438
|
+
let layer = useContext(LayerContext)
|
|
439
|
+
// Through merge, not a spread: a props object hands out getters, and
|
|
440
|
+
// merge keeps them (the motion reads each once at creation, viewport
|
|
441
|
+
// live).
|
|
442
|
+
let options: Camera2dOptions = merge(props, {
|
|
443
|
+
get viewport() {
|
|
444
|
+
return props.viewport ?? (() => ({ width: layer.width, height: layer.height }))
|
|
445
|
+
},
|
|
446
|
+
})
|
|
447
|
+
let cam = untrack(() => createCamera2d(layer, options))
|
|
448
|
+
onCleanup(cam.attach(layer))
|
|
449
|
+
createEffect(
|
|
450
|
+
() => cam.active(),
|
|
451
|
+
on => {
|
|
452
|
+
if (!on) return
|
|
453
|
+
let last: number | null = null
|
|
454
|
+
return onFrame(tick => {
|
|
455
|
+
let now = tick / 1000
|
|
456
|
+
let dt = last === null ? 0 : Math.min(now - last, MAX_CAMERA_DT)
|
|
457
|
+
last = now
|
|
458
|
+
cam.update(dt)
|
|
459
|
+
})
|
|
460
|
+
},
|
|
461
|
+
)
|
|
462
|
+
untrack(() => props.ref)?.(cam)
|
|
463
|
+
return null
|
|
257
464
|
}
|
|
258
465
|
|
|
466
|
+
/**
|
|
467
|
+
* The tile layer's camera - the same CameraUpdate vocabulary as the sprite
|
|
468
|
+
* layer (see camera.ts for the pivot/rotation semantics and the
|
|
469
|
+
* heading-upward convention), so one signal drives a whole rotating scene
|
|
470
|
+
* across both layers. Here the camera is a transform on the composited
|
|
471
|
+
* world view, never a re-bake; projectCamera/unprojectCamera are the same
|
|
472
|
+
* mapping as plain functions.
|
|
473
|
+
*/
|
|
474
|
+
export type TileCamera = CameraUpdate
|
|
475
|
+
|
|
259
476
|
export type TileLayerProps = {
|
|
260
477
|
/** Grid shape and tile pixel size - creation-fixed (recreate to resize). */
|
|
261
478
|
cols: number
|
|
@@ -264,9 +481,10 @@ export type TileLayerProps = {
|
|
|
264
481
|
tileH: number
|
|
265
482
|
/** The atlas texture every tile samples (create with createAtlas). */
|
|
266
483
|
atlas: TextureId
|
|
267
|
-
/** Per-chunk clear color
|
|
268
|
-
*
|
|
269
|
-
|
|
484
|
+
/** Per-chunk clear color (the name says the scope: never-written regions
|
|
485
|
+
* have no chunk and render nothing, so a full-bleed ground color belongs
|
|
486
|
+
* on the container behind the layer). */
|
|
487
|
+
chunkClearColor?: [number, number, number, number]
|
|
270
488
|
/** Sampler filter for the baked chunk textures at composite time; default
|
|
271
489
|
* "linear" (hard pixels belong to the atlas sampler, see TileLayerOptions). */
|
|
272
490
|
filter?: FilterMode
|
|
@@ -277,8 +495,22 @@ export type TileLayerProps = {
|
|
|
277
495
|
* any scale.
|
|
278
496
|
*/
|
|
279
497
|
oversample?: number
|
|
498
|
+
/**
|
|
499
|
+
* Cap on the auto-picked oversample (integer >= 1): tiles stay adaptive
|
|
500
|
+
* up to it, never above. A tile world's texture memory is resident chunks
|
|
501
|
+
* x n squared, so this is the lever that bounds it on high-scale displays
|
|
502
|
+
* without giving up adaptivity. Ignored when `oversample` is set.
|
|
503
|
+
*/
|
|
504
|
+
maxOversample?: number
|
|
280
505
|
/** Chunk edge in tiles (default ~512px worth); see TileLayerOptions. */
|
|
281
506
|
chunkTiles?: number
|
|
507
|
+
/**
|
|
508
|
+
* Layer tint, [r, g, b, a] in 0..1, multiplied over every cell's own
|
|
509
|
+
* tint (day/night, a dimmed parallax plane). A change re-renders every
|
|
510
|
+
* resident chunk GPU-side (no record uploads) - drive it from slow
|
|
511
|
+
* state, not per frame.
|
|
512
|
+
*/
|
|
513
|
+
tint?: [number, number, number, number]
|
|
282
514
|
/**
|
|
283
515
|
* Pan/zoom/rotate over the world - a transform on the composited world
|
|
284
516
|
* view, never a re-bake. The world view is WORLD sized; put the layer
|
|
@@ -302,9 +534,10 @@ export type TileLayerProps = {
|
|
|
302
534
|
export let TileLayer: VoidComponent<TileLayerProps> = props => {
|
|
303
535
|
let layer = untrack(() =>
|
|
304
536
|
createTileLayer(props.cols, props.rows, props.tileW, props.tileH, props.atlas, {
|
|
305
|
-
|
|
537
|
+
chunkClearColor: props.chunkClearColor,
|
|
306
538
|
filter: props.filter,
|
|
307
539
|
chunkTiles: props.chunkTiles,
|
|
540
|
+
tint: props.tint,
|
|
308
541
|
label: props.label,
|
|
309
542
|
}),
|
|
310
543
|
)
|
|
@@ -315,26 +548,46 @@ export let TileLayer: VoidComponent<TileLayerProps> = props => {
|
|
|
315
548
|
if (n !== undefined) layer.setOversample(n)
|
|
316
549
|
},
|
|
317
550
|
)
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
551
|
+
createEffect(
|
|
552
|
+
() => props.tint,
|
|
553
|
+
tint => {
|
|
554
|
+
if (tint !== undefined) layer.setTint(tint)
|
|
555
|
+
},
|
|
556
|
+
)
|
|
557
|
+
// Auto oversample: the world view's window box, in device pixels, per
|
|
558
|
+
// world pixel. Rotation is not a resolution factor - texels per world
|
|
559
|
+
// pixel do not change as the camera turns - but the measured box is the
|
|
560
|
+
// AABB of the ROTATED view, which swells by up to sqrt(2) and under an
|
|
561
|
+
// animated rotation would sweep the scale across an integer boundary,
|
|
562
|
+
// re-baking every resident chunk on each flip. So the swell is divided
|
|
563
|
+
// back out for the known camera rotation, leaving displayScale, the
|
|
564
|
+
// ancestor fit and the camera zoom. The divide-out's basis is the view's
|
|
565
|
+
// laid-out box, which flexShrink 0 below pins to layer.width x
|
|
566
|
+
// layer.height: a flex container would otherwise compress the box (the
|
|
567
|
+
// chunks are detached and draw at world coordinates either way) and the
|
|
568
|
+
// measurement would mix layout compression into the scale. pick runs
|
|
569
|
+
// untracked (onLayout handler / effect apply), so its prop reads are
|
|
570
|
+
// wrapped explicitly.
|
|
321
571
|
let world: { id: number } | undefined
|
|
322
|
-
let pick = () =>
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
572
|
+
let pick = () =>
|
|
573
|
+
untrack(() => {
|
|
574
|
+
if (!world || props.oversample !== undefined) return
|
|
575
|
+
let box = getBoundingBoxViewport(world)
|
|
576
|
+
if (!box) return
|
|
577
|
+
let r = props.camera?.rotation ?? 0
|
|
578
|
+
let scale = displayScale() * tileWorldScale(box.width, box.height, layer.width, layer.height, r)
|
|
579
|
+
applyOversample(layer, scale, layer.chunkW, layer.chunkH, props.maxOversample)
|
|
580
|
+
})
|
|
329
581
|
onLayout(pick)
|
|
330
|
-
createEffect(() => displayScale(), pick)
|
|
582
|
+
createEffect(() => [displayScale(), props.maxOversample], pick)
|
|
331
583
|
// Chunk allocations arrive through the layer's hook; the signal carries a
|
|
332
584
|
// fresh array so <For> sees the growth.
|
|
333
585
|
let [chunks, setChunks] = createSignal<TileChunk[]>(layer.chunks.slice())
|
|
334
586
|
layer.onChunk = () => setChunks(layer.chunks.slice())
|
|
335
|
-
// World -> screen: p maps to pivot + R(rotation) * zoom * (p - camera)
|
|
336
|
-
// spelled with element transforms as origin at
|
|
337
|
-
// scale there, then translate the camera point
|
|
587
|
+
// World -> screen: p maps to pivot + R(rotation) * zoom * (p - camera) -
|
|
588
|
+
// projectCamera (camera.ts) spelled with element transforms as origin at
|
|
589
|
+
// the camera point, rotate + scale there, then translate the camera point
|
|
590
|
+
// onto the pivot. checks/camera-check.ts holds the two spellings together.
|
|
338
591
|
let camX = () => props.camera?.x ?? 0
|
|
339
592
|
let camY = () => props.camera?.y ?? 0
|
|
340
593
|
return (
|
|
@@ -342,6 +595,7 @@ export let TileLayer: VoidComponent<TileLayerProps> = props => {
|
|
|
342
595
|
ref={(n: { id: number }) => (world = n)}
|
|
343
596
|
width={layer.width}
|
|
344
597
|
height={layer.height}
|
|
598
|
+
flexShrink={0}
|
|
345
599
|
originX={camX()}
|
|
346
600
|
originY={camY()}
|
|
347
601
|
rotate={props.camera?.rotation ?? 0}
|