@solidrt/2d 0.0.54 → 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.
@@ -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 { createContext, createEffect, createSignal, displayScale, For, getBoundingBoxViewport, onCleanup, onLayout, untrack, useContext, windowSize } from "@solidrt/core"
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, Sprite as SpriteHandle, SpriteGroup, SpriteLayer as LayerHandle, SpriteOptions, SpritePointerEvent, TransitionEndEvent } from "./layer.ts"
15
- import { fitOversample } from "./oversample.ts"
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 inside a component
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(): LayerHandle {
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
- export type SpriteLayerProps = {
46
- /** Layer pixels. With `output`, the leaf's own width/height are layout, so
47
- * render size and display size separate. */
48
- width: number
49
- height: number
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, never per-sprite. */
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
- * Sprite pointer events (default on): the built-in leaf carries
75
- * layer.handlers. `false` detaches them - the leaf then costs no pointer
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]) => layer.setSize(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. onLayout is not a
121
- // tracking scope; the reads there are plain.
122
- let leaf: { id: number } | undefined
123
- let pick = () => {
124
- if (!leaf || props.oversample !== undefined) return
125
- let box = getBoundingBoxViewport(leaf)
126
- if (!box) return
127
- let scale = displayScale() * Math.max(box.width / props.width, box.height / props.height)
128
- layer.setOversample(fitOversample(scale, props.width, props.height, windowTexels()))
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 ? layer.handlers.onPointerDown : undefined}
143
- onPointerMove={events ? layer.handlers.onPointerMove : undefined}
144
- onPointerUp={events ? layer.handlers.onPointerUp : undefined}
145
- onPointerLeave={events ? layer.handlers.onPointerLeave : undefined}
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, parent ? { parent } : undefined))
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]) => setSprite(sprite, { 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
- * The tile layer's camera: the world point (`x`, `y`) is shown at the
245
- * viewport point (`pivotX`, `pivotY`), the world scaled by `zoom` and
246
- * rotated by `rotation` (radians, clockwise) ABOUT that pivot. The pivot
247
- * defaults to (0, 0), which makes `{ x, y, zoom }` mean exactly what the
248
- * sprite layer's camera means (world at the viewport top-left) - one
249
- * signal drives both. The whole thing is a transform on the composited
250
- * world, never a re-bake. (The sprite layer's camera cannot rotate yet -
251
- * okf/backlog/2d-sprite-camera-rotation.md.)
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 type TileCamera = CameraUpdate & {
254
- rotation?: number
255
- pivotX?: number
256
- pivotY?: number
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; never-written regions render nothing, so a
268
- * full-bleed ground color belongs on the container behind the layer. */
269
- clearColor?: [number, number, number, number]
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
- clearColor: props.clearColor,
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
- // Auto oversample: the world view's window box (camera zoom and rotation
319
- // included - a rotated world's AABB over-estimates, which only rounds up),
320
- // in device pixels, per world pixel.
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
- if (!world || props.oversample !== undefined) return
324
- let box = getBoundingBoxViewport(world)
325
- if (!box) return
326
- let scale = displayScale() * Math.max(box.width / layer.width, box.height / layer.height)
327
- layer.setOversample(fitOversample(scale, layer.chunkW, layer.chunkH, windowTexels()))
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 the camera point, rotate +
337
- // scale there, then translate the camera point onto the pivot.
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}