@solidrt/2d 0.0.56 → 0.0.58
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 +560 -267
- package/README.md +3 -3
- package/examples/README.md +38 -13
- package/examples/anim.tsx +6 -8
- package/examples/camera-probe.tsx +16 -14
- package/examples/camera.tsx +21 -15
- package/examples/parity.tsx +11 -9
- package/examples/pick.tsx +43 -7
- package/examples/split-screen.tsx +111 -0
- package/examples/springs.tsx +6 -9
- package/examples/sprites.tsx +9 -12
- package/examples/tiles.tsx +24 -9
- package/examples/views.tsx +177 -0
- package/package.json +2 -2
- package/src/atlas.ts +35 -33
- package/src/camera.ts +26 -1
- package/src/camera2d.ts +692 -119
- package/src/components/auto-oversample.ts +27 -0
- package/src/components/camera2d.tsx +82 -0
- package/src/components/context.ts +48 -0
- package/src/components/group.tsx +68 -0
- package/src/components/index.ts +28 -0
- package/src/components/sprite-layer.tsx +312 -0
- package/src/components/sprite.tsx +72 -0
- package/src/components/tile-layer.tsx +160 -0
- package/src/components/view2d.tsx +151 -0
- package/src/dispatch.ts +7 -7
- package/src/frames.ts +63 -14
- package/src/index.ts +27 -11
- package/src/input.ts +50 -0
- package/src/layer.ts +384 -245
- package/src/oversample-math.ts +1 -1
- package/src/records.ts +33 -138
- package/src/shaders.ts +65 -3
- package/src/tiles.ts +195 -42
- package/src/views.ts +294 -0
- package/src/camera-motion.ts +0 -532
- package/src/components.tsx +0 -611
package/src/components.tsx
DELETED
|
@@ -1,611 +0,0 @@
|
|
|
1
|
-
// The Solid face: PascalCase components over context, syncing the retained
|
|
2
|
-
// layer (layer.ts) - no new intrinsic elements, no renderer changes. Props
|
|
3
|
-
// follow the Solid 2.0 model (reactive values, no destructuring); effects
|
|
4
|
-
// write into the retained records and the runtime's dirty flush renders.
|
|
5
|
-
// Anything moving at frame rate can bypass the declarative layer: grab the
|
|
6
|
-
// sprite with `ref` and call setSprite from onFrame - signals carry
|
|
7
|
-
// structure and slow state, per-frame motion goes straight to the layer.
|
|
8
|
-
// The same split, with the same reasoning, as @solidrt/3d's components.
|
|
9
|
-
import { merge } from "@solidjs/signals"
|
|
10
|
-
import { createContext, createEffect, createSignal, displayScale, For, getBoundingBoxViewport, getLayoutBox, onCleanup, onFrame, onLayout, untrack, useContext, windowSize } from "@solidrt/core"
|
|
11
|
-
import type { Element, ParentComponent, TextureId, VoidComponent } from "@solidrt/core"
|
|
12
|
-
import { limits } from "@solidrt/core/gpu"
|
|
13
|
-
import type { FilterMode } from "@solidrt/core/gpu"
|
|
14
|
-
import { addGroup, addSprite, createSpriteLayer, removeGroup, removeSprite, setGroup, setGroupTransition, setSprite, setSpriteTransition } from "./layer.ts"
|
|
15
|
-
import type { NodeTransition } from "flux:spatial"
|
|
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"
|
|
21
|
-
|
|
22
|
-
// The window's device pixel count: the texel budget an auto-picked
|
|
23
|
-
// oversample target stays within (see fitOversample).
|
|
24
|
-
function windowTexels(): number {
|
|
25
|
-
let win = windowSize()
|
|
26
|
-
let scale = displayScale()
|
|
27
|
-
return win.width * scale * (win.height * scale)
|
|
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
|
-
}
|
|
42
|
-
import { createTileLayer } from "./tiles.ts"
|
|
43
|
-
import type { TileChunk, TileLayer as TileLayerHandle } from "./tiles.ts"
|
|
44
|
-
|
|
45
|
-
let LayerContext = createContext<LayerHandle>()
|
|
46
|
-
let GroupContext = createContext<SpriteGroup | null>(null)
|
|
47
|
-
|
|
48
|
-
type LayerCtx = { layer: LayerHandle; parent: SpriteGroup | null }
|
|
49
|
-
|
|
50
|
-
/**
|
|
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.
|
|
56
|
-
*/
|
|
57
|
-
export function useSpriteLayer(): LayerCtx {
|
|
58
|
-
return { layer: useContext(LayerContext), parent: useContext(GroupContext) }
|
|
59
|
-
}
|
|
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
|
-
*/
|
|
70
|
-
export type SpritePointerProps = {
|
|
71
|
-
onPointerDown?: (event: SpritePointerEvent) => void
|
|
72
|
-
onPointerMove?: (event: SpritePointerEvent) => void
|
|
73
|
-
onPointerUp?: (event: SpritePointerEvent) => void
|
|
74
|
-
/** Sprites only: a Group never receives enter/leave. */
|
|
75
|
-
onPointerEnter?: (event: SpritePointerEvent) => void
|
|
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
|
|
81
|
-
}
|
|
82
|
-
|
|
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
|
|
112
|
-
/** The atlas texture every sprite samples (create with createAtlas). */
|
|
113
|
-
atlas: TextureId
|
|
114
|
-
/** Initial record reservation (grows on demand); default 1024. */
|
|
115
|
-
capacity?: number
|
|
116
|
-
clearColor?: [number, number, number, number]
|
|
117
|
-
/** Pan/zoom/rotate over the world (in-shader); a shared-params write,
|
|
118
|
-
* never per-sprite. */
|
|
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]
|
|
126
|
-
/**
|
|
127
|
-
* Target texels per layer pixel. Absent, the component picks it every
|
|
128
|
-
* layout from the built-in leaf's on-screen size (display scale, any
|
|
129
|
-
* designSize fit, layout scaling), so the layer resamples properly at any
|
|
130
|
-
* scale; with `output` there is no built-in leaf, so set it yourself.
|
|
131
|
-
*/
|
|
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"
|
|
149
|
-
label?: string
|
|
150
|
-
ref?: (layer: LayerHandle) => void
|
|
151
|
-
/**
|
|
152
|
-
* Compose the output yourself: called once (untracked) with the layer's
|
|
153
|
-
* texture id, and its return renders in place of the built-in `<texture>`
|
|
154
|
-
* leaf. Sprite pointer events then need the layer's handlers on your
|
|
155
|
-
* leaf: `<texture src={texture} {...useSpriteLayer().layer.handlers} />`.
|
|
156
|
-
*/
|
|
157
|
-
output?: (texture: TextureId) => Element
|
|
158
|
-
/**
|
|
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
|
|
162
|
-
* routing at all.
|
|
163
|
-
*/
|
|
164
|
-
events?: boolean
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Owns a sprite layer and composites it as an ordinary `<texture>` leaf, so
|
|
169
|
-
* the output takes layout, transforms, blendMode, and pointer events like
|
|
170
|
-
* any element - or hand `output` the texture id and compose it yourself.
|
|
171
|
-
* A layout component: it cannot sit inside a d-* subtree; `output` with a
|
|
172
|
-
* `<d-texture>` is the detached form.
|
|
173
|
-
* Children (`<Sprite>`) render nothing themselves - they populate the
|
|
174
|
-
* retained layer through context.
|
|
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
|
-
|
|
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
|
-
})
|
|
192
|
-
let layer = untrack(() =>
|
|
193
|
-
createSpriteLayer(props.width ?? FILL_INITIAL_SIZE, props.height ?? FILL_INITIAL_SIZE, props.atlas, {
|
|
194
|
-
capacity: props.capacity,
|
|
195
|
-
clearColor: props.clearColor,
|
|
196
|
-
tint: props.tint,
|
|
197
|
-
orderBy: props.orderBy,
|
|
198
|
-
label: props.label,
|
|
199
|
-
}),
|
|
200
|
-
)
|
|
201
|
-
createEffect(
|
|
202
|
-
() => [props.width, props.height] as const,
|
|
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
|
-
},
|
|
209
|
-
)
|
|
210
|
-
createEffect(
|
|
211
|
-
() => props.camera,
|
|
212
|
-
camera => {
|
|
213
|
-
if (camera) layer.setCamera(camera)
|
|
214
|
-
},
|
|
215
|
-
)
|
|
216
|
-
createEffect(
|
|
217
|
-
() => props.oversample,
|
|
218
|
-
n => {
|
|
219
|
-
if (n !== undefined) layer.setOversample(n)
|
|
220
|
-
},
|
|
221
|
-
)
|
|
222
|
-
createEffect(
|
|
223
|
-
() => props.tint,
|
|
224
|
-
tint => {
|
|
225
|
-
if (tint !== undefined) layer.setTint(tint)
|
|
226
|
-
},
|
|
227
|
-
)
|
|
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
|
-
)
|
|
240
|
-
let output = untrack(() => props.output)
|
|
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
|
-
}
|
|
263
|
-
// Auto oversample: the built-in leaf's window box, in device pixels, per
|
|
264
|
-
// layer pixel. Picked after every layout, and again when the display scale
|
|
265
|
-
// changes: the first layout runs before the resize event that reports the
|
|
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
|
-
})
|
|
278
|
-
onLayout(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
|
|
286
|
-
return (
|
|
287
|
-
<LayerContext value={layer}>
|
|
288
|
-
{output ? (
|
|
289
|
-
untrack(() => output(layer.texture))
|
|
290
|
-
) : (
|
|
291
|
-
<texture
|
|
292
|
-
ref={(n: { id: number }) => (leaf = n)}
|
|
293
|
-
src={layer.texture}
|
|
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}
|
|
301
|
-
/>
|
|
302
|
-
)}
|
|
303
|
-
{props.children}
|
|
304
|
-
</LayerContext>
|
|
305
|
-
)
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
export type GroupProps = {
|
|
309
|
-
/** Position in the parent frame (layer pixels at the root). */
|
|
310
|
-
x?: number
|
|
311
|
-
y?: number
|
|
312
|
-
/** Rotation, radians, clockwise. */
|
|
313
|
-
rotation?: number
|
|
314
|
-
/** Uniform scale on the whole subtree (child sprites scale with it). */
|
|
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
|
|
326
|
-
/** How pose-prop changes animate (see setGroupTransition); the mount
|
|
327
|
-
* pose always snaps. */
|
|
328
|
-
transition?: NodeTransition | string | null
|
|
329
|
-
/** A declared transition settled on one component. */
|
|
330
|
-
onTransitionEnd?: (event: TransitionEndEvent) => void
|
|
331
|
-
ref?: (group: SpriteGroup) => void
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* A transform group: `<Sprite>` (and nested `<Group>`) children mount under
|
|
336
|
-
* its spatial arena node, so their pose props read in the group's frame and
|
|
337
|
-
* moving the group moves the subtree in one native recompute - a ship with
|
|
338
|
-
* turrets is one `<Group>` with the hull and turret sprites inside. Renders
|
|
339
|
-
* nothing itself.
|
|
340
|
-
*/
|
|
341
|
-
export let Group: ParentComponent<GroupProps> = props => {
|
|
342
|
-
let layer = useContext(LayerContext)
|
|
343
|
-
let parent = useContext(GroupContext)
|
|
344
|
-
let group = untrack(() => addGroup(layer, { parent }))
|
|
345
|
-
createEffect(
|
|
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 }),
|
|
348
|
-
)
|
|
349
|
-
// After the pose effect, so the mount pose snaps before writes animate.
|
|
350
|
-
createEffect(
|
|
351
|
-
() => props.transition,
|
|
352
|
-
transition => setGroupTransition(group, transition ?? null),
|
|
353
|
-
)
|
|
354
|
-
createEffect(
|
|
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
|
|
362
|
-
group.onTransitionEnd = end
|
|
363
|
-
},
|
|
364
|
-
)
|
|
365
|
-
untrack(() => props.ref)?.(group)
|
|
366
|
-
onCleanup(() => removeGroup(group))
|
|
367
|
-
return <GroupContext value={group}>{props.children}</GroupContext>
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
export type SpriteProps = SpriteOptions &
|
|
371
|
-
SpritePointerProps & {
|
|
372
|
-
/** How pose-prop changes animate (see setSpriteTransition); the mount
|
|
373
|
-
* pose always snaps. */
|
|
374
|
-
transition?: NodeTransition | string | null
|
|
375
|
-
/** A declared transition settled on one component. */
|
|
376
|
-
onTransitionEnd?: (event: TransitionEndEvent) => void
|
|
377
|
-
ref?: (sprite: SpriteHandle) => void
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/** One sprite: a frame drawn at a position (in the enclosing `<Group>`'s
|
|
381
|
-
* frame when there is one). */
|
|
382
|
-
export let Sprite: VoidComponent<SpriteProps> = props => {
|
|
383
|
-
let layer = useContext(LayerContext)
|
|
384
|
-
let parent = useContext(GroupContext)
|
|
385
|
-
let sprite = untrack(() => addSprite(layer, { parent }))
|
|
386
|
-
createEffect(
|
|
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 }),
|
|
390
|
-
)
|
|
391
|
-
// After the pose effect, so the mount pose snaps before writes animate.
|
|
392
|
-
createEffect(
|
|
393
|
-
() => props.transition,
|
|
394
|
-
transition => setSpriteTransition(sprite, transition ?? null),
|
|
395
|
-
)
|
|
396
|
-
createEffect(
|
|
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]) => {
|
|
399
|
-
sprite.onPointerDown = down
|
|
400
|
-
sprite.onPointerMove = move
|
|
401
|
-
sprite.onPointerUp = up
|
|
402
|
-
sprite.onPointerEnter = enter
|
|
403
|
-
sprite.onPointerLeave = leave
|
|
404
|
-
sprite.onWheel = wheel
|
|
405
|
-
sprite.onTap = tap
|
|
406
|
-
sprite.onTransitionEnd = end
|
|
407
|
-
},
|
|
408
|
-
)
|
|
409
|
-
untrack(() => props.ref)?.(sprite)
|
|
410
|
-
onCleanup(() => removeSprite(sprite))
|
|
411
|
-
return null
|
|
412
|
-
}
|
|
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
|
-
|
|
425
|
-
/**
|
|
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.
|
|
436
|
-
*/
|
|
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
|
|
464
|
-
}
|
|
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
|
-
|
|
476
|
-
export type TileLayerProps = {
|
|
477
|
-
/** Grid shape and tile pixel size - creation-fixed (recreate to resize). */
|
|
478
|
-
cols: number
|
|
479
|
-
rows: number
|
|
480
|
-
tileW: number
|
|
481
|
-
tileH: number
|
|
482
|
-
/** The atlas texture every tile samples (create with createAtlas). */
|
|
483
|
-
atlas: TextureId
|
|
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]
|
|
488
|
-
/** Sampler filter for the baked chunk textures at composite time; default
|
|
489
|
-
* "linear" (hard pixels belong to the atlas sampler, see TileLayerOptions). */
|
|
490
|
-
filter?: FilterMode
|
|
491
|
-
/**
|
|
492
|
-
* Target texels per world pixel in the baked chunks. Absent, the component
|
|
493
|
-
* picks it every layout from the world view's on-screen size (display
|
|
494
|
-
* scale, camera zoom, any designSize fit), so tiles resample properly at
|
|
495
|
-
* any scale.
|
|
496
|
-
*/
|
|
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
|
|
505
|
-
/** Chunk edge in tiles (default ~512px worth); see TileLayerOptions. */
|
|
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]
|
|
514
|
-
/**
|
|
515
|
-
* Pan/zoom/rotate over the world - a transform on the composited world
|
|
516
|
-
* view, never a re-bake. The world view is WORLD sized; put the layer
|
|
517
|
-
* inside a clipping container (`overflow="clip"`) sized to the viewport.
|
|
518
|
-
*/
|
|
519
|
-
camera?: TileCamera
|
|
520
|
-
label?: string
|
|
521
|
-
ref?: (layer: TileLayerHandle) => void
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
/**
|
|
525
|
-
* Owns a baked tile layer (createTileLayer) and composites its chunks as
|
|
526
|
-
* `d-texture` leaves at their world rects inside a `<view>` carrying the
|
|
527
|
-
* camera transform - a handful of quads however many tiles exist. A layout
|
|
528
|
-
* component: the world view is laid out, so it cannot sit inside a d-*
|
|
529
|
-
* subtree. Tiles
|
|
530
|
-
* are data, not children: write them through `ref` with `setTile` - there
|
|
531
|
-
* is no `<Tile>` component on purpose (a component per tile would
|
|
532
|
-
* re-introduce the per-element cost the bake removes).
|
|
533
|
-
*/
|
|
534
|
-
export let TileLayer: VoidComponent<TileLayerProps> = props => {
|
|
535
|
-
let layer = untrack(() =>
|
|
536
|
-
createTileLayer(props.cols, props.rows, props.tileW, props.tileH, props.atlas, {
|
|
537
|
-
chunkClearColor: props.chunkClearColor,
|
|
538
|
-
filter: props.filter,
|
|
539
|
-
chunkTiles: props.chunkTiles,
|
|
540
|
-
tint: props.tint,
|
|
541
|
-
label: props.label,
|
|
542
|
-
}),
|
|
543
|
-
)
|
|
544
|
-
untrack(() => props.ref)?.(layer)
|
|
545
|
-
createEffect(
|
|
546
|
-
() => props.oversample,
|
|
547
|
-
n => {
|
|
548
|
-
if (n !== undefined) layer.setOversample(n)
|
|
549
|
-
},
|
|
550
|
-
)
|
|
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.
|
|
571
|
-
let world: { id: number } | undefined
|
|
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
|
-
})
|
|
581
|
-
onLayout(pick)
|
|
582
|
-
createEffect(() => [displayScale(), props.maxOversample], pick)
|
|
583
|
-
// Chunk allocations arrive through the layer's hook; the signal carries a
|
|
584
|
-
// fresh array so <For> sees the growth.
|
|
585
|
-
let [chunks, setChunks] = createSignal<TileChunk[]>(layer.chunks.slice())
|
|
586
|
-
layer.onChunk = () => setChunks(layer.chunks.slice())
|
|
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.
|
|
591
|
-
let camX = () => props.camera?.x ?? 0
|
|
592
|
-
let camY = () => props.camera?.y ?? 0
|
|
593
|
-
return (
|
|
594
|
-
<view
|
|
595
|
-
ref={(n: { id: number }) => (world = n)}
|
|
596
|
-
width={layer.width}
|
|
597
|
-
height={layer.height}
|
|
598
|
-
flexShrink={0}
|
|
599
|
-
originX={camX()}
|
|
600
|
-
originY={camY()}
|
|
601
|
-
rotate={props.camera?.rotation ?? 0}
|
|
602
|
-
scale={props.camera?.zoom ?? 1}
|
|
603
|
-
x={(props.camera?.pivotX ?? 0) - camX()}
|
|
604
|
-
y={(props.camera?.pivotY ?? 0) - camY()}
|
|
605
|
-
>
|
|
606
|
-
<For each={chunks()}>
|
|
607
|
-
{chunk => <d-texture src={chunk.texture} x={chunk.x} y={chunk.y} w={chunk.width} h={chunk.height} />}
|
|
608
|
-
</For>
|
|
609
|
-
</view>
|
|
610
|
-
)
|
|
611
|
-
}
|