@solidrt/2d 0.0.55 → 0.0.57

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/src/views.ts ADDED
@@ -0,0 +1,294 @@
1
+ // A layer's views: every rendering of a layer's world is a view - one
2
+ // draw target holding ONE entry over the layer's pipeline and instance
3
+ // buffers, with a camera and a viewport of its own - and a layer has no
4
+ // output but its views (Unity's scene renders only through Cameras,
5
+ // Godot's World2D only through Viewports; @solidrt/3d's scene.createView
6
+ // one dimension down). The window-filling main view, the minimap, the
7
+ // radar strip, the zoomed inset, two split-screen panes: all the same
8
+ // object. No sprite is mirrored, no record is written twice, and the
9
+ // core's key-order gather (the entry's instanceOrder) happens in the
10
+ // buffers at publish: ONE entry per buffer declares the order - the first
11
+ // live view's, re-homed to the next when that view goes - and every other
12
+ // entry reads the buffers already sorted. The layer fans buffer swaps
13
+ // (growth), the instance count and the tint out to every view through
14
+ // the registry below; the camera and the viewport are each view's own
15
+ // params, so a view costs no per-frame JS beyond its camera writes.
16
+ // Pointer events run the layer's dispatch (dispatch.ts) with the view's
17
+ // camera undone over the layer's pick, the view as the root of the walk:
18
+ // a sprite under a minimap gets its ordinary handlers, and the view's
19
+ // listeners are the last stop.
20
+ import { addDraw, createDrawTarget, destroyTexture, removeDraw, setDrawBuffers, setDrawRange, setTargetParams, setTargetSize } from "@solidrt/core/gpu"
21
+ import type { BufferId, BufferUpdate, DrawId, InstanceOrder, RenderPipelineId, TextureId } from "@solidrt/core/gpu"
22
+ import { applyCamera, cameraParams, checkCamera, defaultCamera, projectCamera, unprojectCamera } from "./camera.ts"
23
+ import type { CameraState, CameraUpdate } from "./camera.ts"
24
+ import { spriteDispatch } from "./dispatch.ts"
25
+ import type { PointerFeed } from "@solidrt/core"
26
+ import type { LayerPointerListener, Sprite, SpriteHandlers } from "./layer.ts"
27
+ import { checkOversample, thrashSentinel } from "./oversample.ts"
28
+
29
+ export type ViewOptions = {
30
+ /** View pixels: the viewport its camera maps the world onto. */
31
+ width: number
32
+ height: number
33
+ /** The camera to start from (a setCamera update); default the identity. */
34
+ camera?: CameraUpdate
35
+ /** Target texels per view pixel (see ViewHandle.setOversample); default 1. */
36
+ oversample?: number
37
+ clearColor?: [number, number, number, number]
38
+ label?: string
39
+ }
40
+
41
+ /**
42
+ * A view of a layer (LayerBase.createView): the viewport contract -
43
+ * texture, size, oversample, camera and pointer dispatch - over the
44
+ * layer's sprites, with a camera of its own. The sprites are the layer's:
45
+ * add, write and pick through the layer, tint through the layer (every
46
+ * view follows it). `dispose` is idempotent; views also die with their
47
+ * layer.
48
+ */
49
+ export type ViewHandle = {
50
+ /** The view's output: an ordinary texture id (`<texture src>`). */
51
+ texture: TextureId
52
+ /** Element handlers wiring the view's pointer events (sprites, groups
53
+ * and the root listeners); see handlersFor. */
54
+ handlers: SpriteHandlers
55
+ /** View pixels, as created or last set by setSize. */
56
+ readonly width: number
57
+ readonly height: number
58
+ setSize(width: number, height: number): void
59
+ /**
60
+ * Listen at the root of the event walk. Every down, move, up, wheel and
61
+ * tap arrives here after the hit sprite and its enclosing groups
62
+ * (`sprite` set) or as the walk's only stop over empty space (`sprite`
63
+ * null), unless a handler stopped it on the way. Listeners run in
64
+ * registration order and all of them run - the root is the last stop,
65
+ * there is nothing left to claim. Returns the remover. The app's own
66
+ * root handling (deselect on a miss, a marquee) and controls
67
+ * (createCamera2d's attach) meet here, which is why the root is a list
68
+ * where a sprite has plain fields.
69
+ */
70
+ listen(listener: LayerPointerListener): () => void
71
+ /** Target texels per view pixel; see setOversample. */
72
+ readonly oversample: number
73
+ /**
74
+ * Re-render at `n` target texels per view pixel (positive integer): the
75
+ * target resizes in place at its stable id; view pixels, records, camera
76
+ * and picking are untouched. Pick `n` as the ceiling of the device pixels
77
+ * one view pixel covers on screen (display scale times any designSize
78
+ * fit or layout scaling), which the components do in onLayout.
79
+ */
80
+ setOversample(n: number): void
81
+ setCamera(update: CameraUpdate): void
82
+ /** The camera as last set (a fresh object per call, every field
83
+ * present): the argument for projectCamera/unprojectCamera -
84
+ * @solidrt/3d's scene.camera(). */
85
+ camera(): CameraState
86
+ /** World (layer) pixels -> view pixels under the current camera:
87
+ * projectCamera over camera(). */
88
+ project(x: number, y: number): [number, number]
89
+ /** View pixels -> world (layer) pixels, the inverse: what pointer
90
+ * dispatch applies to every event. */
91
+ unproject(x: number, y: number): [number, number]
92
+ /**
93
+ * handlers for a leaf whose LAYOUT size differs from the view size
94
+ * (events scale by view/layout; a leaf laid out AT view size just uses
95
+ * `handlers`). `layout` is read per event, so a resize-reactive layout
96
+ * just works - @solidrt/3d's handlersFor, one dimension down.
97
+ */
98
+ handlersFor(layout: () => { width: number; height: number }): SpriteHandlers
99
+ dispose(): void
100
+ }
101
+
102
+ // What a layer hands its views: the pipeline and quad every entry draws
103
+ // with, the atlas, the key order one entry declares, and live reads of
104
+ // the state the layer fans out.
105
+ export type ViewDeps = {
106
+ label: string
107
+ pipeline: RenderPipelineId
108
+ quad: BufferId
109
+ atlas: TextureId
110
+ /** The layer's current instance binding, in the entry's own spelling
111
+ * (`instanceBuffers` on the node layer, `instanceBuffer` on records). */
112
+ buffers: () => BufferUpdate
113
+ /** The instance count as last published. */
114
+ count: () => number
115
+ tint: () => [number, number, number, number]
116
+ pick: (x: number, y: number) => Sprite[]
117
+ /** The layer's key order (`orderBy`), declared by ONE live entry. */
118
+ order?: InstanceOrder
119
+ }
120
+
121
+ /** The layer side of its views: create, and fan out what changes. */
122
+ export type Views = {
123
+ create(opts: ViewOptions): ViewHandle
124
+ /** The layer swapped its instance buffers (growth): every entry
125
+ * follows. */
126
+ setBuffers(update: BufferUpdate): void
127
+ setCount(count: number): void
128
+ setTint(tint: [number, number, number, number]): void
129
+ dispose(): void
130
+ }
131
+
132
+ type ViewRecord = { texture: TextureId; entry: DrawId; dispose(): void }
133
+
134
+ export function createViews(deps: ViewDeps): Views {
135
+ let views = new Set<ViewRecord>()
136
+ // The one entry declaring the key order (null until a view exists, or
137
+ // while the layer has no order).
138
+ let ordered: ViewRecord | null = null
139
+ let entryFor = (texture: TextureId, order: InstanceOrder | undefined): DrawId =>
140
+ addDraw(texture, deps.pipeline, null, {
141
+ buffer: deps.quad,
142
+ vertexCount: 4,
143
+ ...deps.buffers(),
144
+ instanceOrder: order,
145
+ instanceCount: deps.count(),
146
+ })
147
+ // The ordered view went: the next live view re-adds its entry with the
148
+ // order, so key order survives any one view's lifetime.
149
+ let rehome = () => {
150
+ if (deps.order === undefined) return
151
+ let next = views.values().next().value
152
+ if (!next) return
153
+ removeDraw(next.texture, next.entry)
154
+ next.entry = entryFor(next.texture, deps.order)
155
+ ordered = next
156
+ }
157
+ return {
158
+ create(opts) {
159
+ let width = opts.width
160
+ let height = opts.height
161
+ if (!(Number.isInteger(width) && width > 0 && Number.isInteger(height) && height > 0)) {
162
+ throw new Error(`createView: width and height must be positive integers, got ${width} x ${height}`)
163
+ }
164
+ let oversample = opts.oversample ?? 1
165
+ checkOversample("createView", oversample, width, height)
166
+ let label = opts.label ?? `${deps.label}-view`
167
+ let cam = defaultCamera()
168
+ if (opts.camera) {
169
+ checkCamera(opts.camera)
170
+ applyCamera(cam, opts.camera)
171
+ }
172
+ let texture = createDrawTarget(
173
+ width * oversample,
174
+ height * oversample,
175
+ { uViewport: [width, height], ...cameraParams(cam), uTint: deps.tint() },
176
+ { textures: { uAtlas: deps.atlas }, clearColor: opts.clearColor ?? [0, 0, 0, 0], label, autoFree: false },
177
+ )
178
+ let order = ordered === null ? deps.order : undefined
179
+ let entry = entryFor(texture, order)
180
+ let thrash = thrashSentinel(`view "${label}"`)
181
+ let disposed = false
182
+ let listeners = new Set<LayerPointerListener>()
183
+ let record: ViewRecord = {
184
+ texture,
185
+ entry,
186
+ dispose() {
187
+ if (disposed) return
188
+ disposed = true
189
+ listeners.clear()
190
+ views.delete(record)
191
+ // The entry dies with its target, releasing the buffers' order.
192
+ destroyTexture(texture)
193
+ if (ordered === record) {
194
+ ordered = null
195
+ rehome()
196
+ }
197
+ },
198
+ }
199
+ if (order !== undefined) ordered = record
200
+ let view: ViewHandle = {
201
+ texture,
202
+ handlers: undefined as unknown as SpriteHandlers,
203
+ get width() {
204
+ return width
205
+ },
206
+ get height() {
207
+ return height
208
+ },
209
+ setSize(w, h) {
210
+ if (disposed || (w === width && h === height)) return
211
+ checkOversample("setSize", oversample, w, h)
212
+ width = w
213
+ height = h
214
+ setTargetSize(texture, w * oversample, h * oversample)
215
+ setTargetParams(texture, { uViewport: [w, h] })
216
+ },
217
+ listen(listener) {
218
+ listeners.add(listener)
219
+ return () => {
220
+ listeners.delete(listener)
221
+ }
222
+ },
223
+ get oversample() {
224
+ return oversample
225
+ },
226
+ setOversample(n) {
227
+ if (disposed || n === oversample) return
228
+ checkOversample("setOversample", n, width, height)
229
+ thrash()
230
+ oversample = n
231
+ setTargetSize(texture, width * n, height * n)
232
+ },
233
+ setCamera(update) {
234
+ if (disposed) return
235
+ checkCamera(update)
236
+ applyCamera(cam, update)
237
+ setTargetParams(texture, cameraParams(cam))
238
+ },
239
+ camera() {
240
+ return { ...cam }
241
+ },
242
+ project(x, y) {
243
+ return projectCamera(cam, x, y)
244
+ },
245
+ unproject(x, y) {
246
+ return unprojectCamera(cam, x, y)
247
+ },
248
+ handlersFor(layout) {
249
+ return dispatch(layout)
250
+ },
251
+ dispose: record.dispose,
252
+ }
253
+ let dispatch = spriteDispatch({
254
+ size: () => [width, height],
255
+ camera: () => cam,
256
+ pick: deps.pick,
257
+ root: view,
258
+ listeners,
259
+ })
260
+ view.handlers = dispatch(null)
261
+ views.add(record)
262
+ return view
263
+ },
264
+ setBuffers(update) {
265
+ for (let v of views) setDrawBuffers(v.texture, v.entry, update)
266
+ },
267
+ setCount(count) {
268
+ for (let v of views) setDrawRange(v.texture, v.entry, { instanceCount: count })
269
+ },
270
+ setTint(tint) {
271
+ for (let v of views) setTargetParams(v.texture, { uTint: tint })
272
+ },
273
+ dispose() {
274
+ for (let v of [...views]) v.dispose()
275
+ },
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Feed a pointer feed (createPointerFeed) from a view's root: the events
281
+ * the sprites let through - a sprite that claims its press
282
+ * (stopPropagation on its down) keeps a camera bound to the feed out of
283
+ * that drag, a drag on empty space or an unclaimed sprite pans, a wheel
284
+ * anywhere zooms. Returns the detach. The components do this for their
285
+ * `pointer` prop; an imperative view calls it directly.
286
+ */
287
+ export function feedPointer(view: ViewHandle, feed: PointerFeed): () => void {
288
+ return view.listen({
289
+ onPointerDown: e => feed.handlers.onPointerDown(e.native),
290
+ onPointerMove: e => feed.handlers.onPointerMove(e.native),
291
+ onPointerUp: e => feed.handlers.onPointerUp(e.native),
292
+ onWheel: e => feed.handlers.onWheel(e.native),
293
+ })
294
+ }
@@ -1,357 +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 { createContext, createEffect, createSignal, displayScale, For, getBoundingBoxViewport, onCleanup, onLayout, untrack, useContext, windowSize } from "@solidrt/core"
10
- import type { Element, ParentComponent, TextureId, VoidComponent } from "@solidrt/core"
11
- import type { FilterMode } from "@solidrt/core/gpu"
12
- import { addGroup, addSprite, createSpriteLayer, removeGroup, removeSprite, setGroup, setGroupTransition, setSprite, setSpriteTransition } from "./layer.ts"
13
- 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
-
17
- // The window's device pixel count: the texel budget an auto-picked
18
- // oversample target stays within (see fitOversample).
19
- function windowTexels(): number {
20
- let win = windowSize()
21
- let scale = displayScale()
22
- return win.width * scale * (win.height * scale)
23
- }
24
- import { createTileLayer } from "./tiles.ts"
25
- import type { TileChunk, TileLayer as TileLayerHandle } from "./tiles.ts"
26
-
27
- let LayerContext = createContext<LayerHandle>()
28
-
29
- /**
30
- * The enclosing layer - the imperative escape hatch inside a component
31
- * subtree (throws outside a `<SpriteLayer>`).
32
- */
33
- export function useSpriteLayer(): LayerHandle {
34
- return useContext(LayerContext)
35
- }
36
-
37
- export type SpritePointerProps = {
38
- onPointerDown?: (event: SpritePointerEvent) => void
39
- onPointerMove?: (event: SpritePointerEvent) => void
40
- onPointerUp?: (event: SpritePointerEvent) => void
41
- onPointerEnter?: (event: SpritePointerEvent) => void
42
- onPointerLeave?: (event: SpritePointerEvent) => void
43
- }
44
-
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
50
- /** The atlas texture every sprite samples (create with createAtlas). */
51
- atlas: TextureId
52
- /** Initial record reservation (grows on demand); default 1024. */
53
- capacity?: number
54
- clearColor?: [number, number, number, number]
55
- /** Pan/zoom over the world; a shared-params write, never per-sprite. */
56
- camera?: CameraUpdate
57
- /**
58
- * Target texels per layer pixel. Absent, the component picks it every
59
- * layout from the built-in leaf's on-screen size (display scale, any
60
- * designSize fit, layout scaling), so the layer resamples properly at any
61
- * scale; with `output` there is no built-in leaf, so set it yourself.
62
- */
63
- oversample?: number
64
- label?: string
65
- ref?: (layer: LayerHandle) => void
66
- /**
67
- * Compose the output yourself: called once (untracked) with the layer's
68
- * texture id, and its return renders in place of the built-in `<texture>`
69
- * leaf. Sprite pointer events then need the layer's handlers on your
70
- * leaf: `<texture src={texture} {...useSpriteLayer().handlers} />`.
71
- */
72
- output?: (texture: TextureId) => Element
73
- /**
74
- * Sprite pointer events (default on): the built-in leaf carries
75
- * layer.handlers. `false` detaches them - the leaf then costs no pointer
76
- * routing at all.
77
- */
78
- events?: boolean
79
- }
80
-
81
- /**
82
- * Owns a sprite layer and composites it as an ordinary `<texture>` leaf, so
83
- * the output takes layout, transforms, blendMode, and pointer events like
84
- * any element - or hand `output` the texture id and compose it yourself.
85
- * A layout component: it cannot sit inside a d-* subtree; `output` with a
86
- * `<d-texture>` is the detached form.
87
- * Children (`<Sprite>`) render nothing themselves - they populate the
88
- * retained layer through context.
89
- */
90
- export let SpriteLayer: ParentComponent<SpriteLayerProps> = props => {
91
- let layer = untrack(() =>
92
- createSpriteLayer(props.width, props.height, props.atlas, {
93
- capacity: props.capacity,
94
- clearColor: props.clearColor,
95
- label: props.label,
96
- }),
97
- )
98
- createEffect(
99
- () => [props.width, props.height] as const,
100
- ([w, h]) => layer.setSize(w, h),
101
- )
102
- createEffect(
103
- () => props.camera,
104
- camera => {
105
- if (camera) layer.setCamera(camera)
106
- },
107
- )
108
- createEffect(
109
- () => props.oversample,
110
- n => {
111
- if (n !== undefined) layer.setOversample(n)
112
- },
113
- )
114
- untrack(() => props.ref)?.(layer)
115
- let output = untrack(() => props.output)
116
- let events = untrack(() => props.events) !== false
117
- // Auto oversample: the built-in leaf's window box, in device pixels, per
118
- // layer pixel. Picked after every layout, and again when the display scale
119
- // 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
- }
130
- onLayout(pick)
131
- createEffect(() => displayScale(), pick)
132
- return (
133
- <LayerContext value={layer}>
134
- {output ? (
135
- untrack(() => output(layer.texture))
136
- ) : (
137
- <texture
138
- ref={(n: { id: number }) => (leaf = n)}
139
- 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}
146
- />
147
- )}
148
- {props.children}
149
- </LayerContext>
150
- )
151
- }
152
-
153
- let GroupContext = createContext<SpriteGroup | null>(null)
154
-
155
- export type GroupProps = {
156
- /** Position in the parent frame (layer pixels at the root). */
157
- x?: number
158
- y?: number
159
- /** Rotation, radians, clockwise. */
160
- rotation?: number
161
- /** Uniform scale on the whole subtree (child sprites scale with it). */
162
- scale?: number
163
- /** How pose-prop changes animate (see setGroupTransition); the mount
164
- * pose always snaps. */
165
- transition?: NodeTransition | string | null
166
- /** A declared transition settled on one component. */
167
- onTransitionEnd?: (event: TransitionEndEvent) => void
168
- ref?: (group: SpriteGroup) => void
169
- }
170
-
171
- /**
172
- * A transform group: `<Sprite>` (and nested `<Group>`) children mount under
173
- * its spatial arena node, so their pose props read in the group's frame and
174
- * moving the group moves the subtree in one native recompute - a ship with
175
- * turrets is one `<Group>` with the hull and turret sprites inside. Renders
176
- * nothing itself.
177
- */
178
- export let Group: ParentComponent<GroupProps> = props => {
179
- let layer = useContext(LayerContext)
180
- let parent = useContext(GroupContext)
181
- let group = untrack(() => addGroup(layer, { parent }))
182
- createEffect(
183
- () => [props.x, props.y, props.rotation, props.scale] as const,
184
- ([x, y, rotation, scale]) => setGroup(group, { x, y, rotation, scale }),
185
- )
186
- // After the pose effect, so the mount pose snaps before writes animate.
187
- createEffect(
188
- () => props.transition,
189
- transition => setGroupTransition(group, transition ?? null),
190
- )
191
- createEffect(
192
- () => props.onTransitionEnd,
193
- end => {
194
- group.onTransitionEnd = end
195
- },
196
- )
197
- untrack(() => props.ref)?.(group)
198
- onCleanup(() => removeGroup(group))
199
- return <GroupContext value={group}>{props.children}</GroupContext>
200
- }
201
-
202
- export type SpriteProps = SpriteOptions &
203
- SpritePointerProps & {
204
- /** How pose-prop changes animate (see setSpriteTransition); the mount
205
- * pose always snaps. */
206
- transition?: NodeTransition | string | null
207
- /** A declared transition settled on one component. */
208
- onTransitionEnd?: (event: TransitionEndEvent) => void
209
- ref?: (sprite: SpriteHandle) => void
210
- }
211
-
212
- /** One sprite: a frame drawn at a position (in the enclosing `<Group>`'s
213
- * frame when there is one). */
214
- export let Sprite: VoidComponent<SpriteProps> = props => {
215
- let layer = useContext(LayerContext)
216
- let parent = useContext(GroupContext)
217
- let sprite = untrack(() => addSprite(layer, parent ? { parent } : undefined))
218
- 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 }),
221
- )
222
- // After the pose effect, so the mount pose snaps before writes animate.
223
- createEffect(
224
- () => props.transition,
225
- transition => setSpriteTransition(sprite, transition ?? null),
226
- )
227
- createEffect(
228
- () => [props.onPointerDown, props.onPointerMove, props.onPointerUp, props.onPointerEnter, props.onPointerLeave, props.onTransitionEnd] as const,
229
- ([down, move, up, enter, leave, end]) => {
230
- sprite.onPointerDown = down
231
- sprite.onPointerMove = move
232
- sprite.onPointerUp = up
233
- sprite.onPointerEnter = enter
234
- sprite.onPointerLeave = leave
235
- sprite.onTransitionEnd = end
236
- },
237
- )
238
- untrack(() => props.ref)?.(sprite)
239
- onCleanup(() => removeSprite(sprite))
240
- return null
241
- }
242
-
243
- /**
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.)
252
- */
253
- export type TileCamera = CameraUpdate & {
254
- rotation?: number
255
- pivotX?: number
256
- pivotY?: number
257
- }
258
-
259
- export type TileLayerProps = {
260
- /** Grid shape and tile pixel size - creation-fixed (recreate to resize). */
261
- cols: number
262
- rows: number
263
- tileW: number
264
- tileH: number
265
- /** The atlas texture every tile samples (create with createAtlas). */
266
- 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]
270
- /** Sampler filter for the baked chunk textures at composite time; default
271
- * "linear" (hard pixels belong to the atlas sampler, see TileLayerOptions). */
272
- filter?: FilterMode
273
- /**
274
- * Target texels per world pixel in the baked chunks. Absent, the component
275
- * picks it every layout from the world view's on-screen size (display
276
- * scale, camera zoom, any designSize fit), so tiles resample properly at
277
- * any scale.
278
- */
279
- oversample?: number
280
- /** Chunk edge in tiles (default ~512px worth); see TileLayerOptions. */
281
- chunkTiles?: number
282
- /**
283
- * Pan/zoom/rotate over the world - a transform on the composited world
284
- * view, never a re-bake. The world view is WORLD sized; put the layer
285
- * inside a clipping container (`overflow="clip"`) sized to the viewport.
286
- */
287
- camera?: TileCamera
288
- label?: string
289
- ref?: (layer: TileLayerHandle) => void
290
- }
291
-
292
- /**
293
- * Owns a baked tile layer (createTileLayer) and composites its chunks as
294
- * `d-texture` leaves at their world rects inside a `<view>` carrying the
295
- * camera transform - a handful of quads however many tiles exist. A layout
296
- * component: the world view is laid out, so it cannot sit inside a d-*
297
- * subtree. Tiles
298
- * are data, not children: write them through `ref` with `setTile` - there
299
- * is no `<Tile>` component on purpose (a component per tile would
300
- * re-introduce the per-element cost the bake removes).
301
- */
302
- export let TileLayer: VoidComponent<TileLayerProps> = props => {
303
- let layer = untrack(() =>
304
- createTileLayer(props.cols, props.rows, props.tileW, props.tileH, props.atlas, {
305
- clearColor: props.clearColor,
306
- filter: props.filter,
307
- chunkTiles: props.chunkTiles,
308
- label: props.label,
309
- }),
310
- )
311
- untrack(() => props.ref)?.(layer)
312
- createEffect(
313
- () => props.oversample,
314
- n => {
315
- if (n !== undefined) layer.setOversample(n)
316
- },
317
- )
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.
321
- 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
- }
329
- onLayout(pick)
330
- createEffect(() => displayScale(), pick)
331
- // Chunk allocations arrive through the layer's hook; the signal carries a
332
- // fresh array so <For> sees the growth.
333
- let [chunks, setChunks] = createSignal<TileChunk[]>(layer.chunks.slice())
334
- 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.
338
- let camX = () => props.camera?.x ?? 0
339
- let camY = () => props.camera?.y ?? 0
340
- return (
341
- <view
342
- ref={(n: { id: number }) => (world = n)}
343
- width={layer.width}
344
- height={layer.height}
345
- originX={camX()}
346
- originY={camY()}
347
- rotate={props.camera?.rotation ?? 0}
348
- scale={props.camera?.zoom ?? 1}
349
- x={(props.camera?.pivotX ?? 0) - camX()}
350
- y={(props.camera?.pivotY ?? 0) - camY()}
351
- >
352
- <For each={chunks()}>
353
- {chunk => <d-texture src={chunk.texture} x={chunk.x} y={chunk.y} w={chunk.width} h={chunk.height} />}
354
- </For>
355
- </view>
356
- )
357
- }