@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/AGENTS.md +417 -70
- package/README.md +3 -3
- package/examples/README.md +49 -7
- package/examples/anim.tsx +84 -0
- package/examples/camera-probe.tsx +202 -0
- package/examples/camera.tsx +196 -0
- package/examples/parity.tsx +15 -13
- package/examples/pick.tsx +89 -36
- package/examples/split-screen.tsx +111 -0
- package/examples/springs.tsx +6 -9
- package/examples/sprites.tsx +9 -12
- package/examples/tiles.tsx +42 -18
- package/examples/views.tsx +177 -0
- package/package.json +3 -3
- package/src/animation.ts +178 -0
- package/src/atlas.ts +35 -33
- package/src/camera.ts +111 -0
- package/src/camera2d.ts +713 -0
- 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 +231 -0
- package/src/frames.ts +63 -14
- package/src/index.ts +53 -12
- package/src/input.ts +50 -0
- package/src/layer.ts +716 -309
- package/src/oversample-math.ts +73 -0
- package/src/oversample.ts +39 -11
- package/src/records.ts +109 -109
- package/src/shaders.ts +93 -14
- package/src/tiles.ts +256 -48
- package/src/views.ts +294 -0
- package/src/components.tsx +0 -357
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
|
+
}
|
package/src/components.tsx
DELETED
|
@@ -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
|
-
}
|