@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.
@@ -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
- }