@solidrt/2d 0.0.56 → 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 +248 -85
- package/README.md +3 -3
- package/examples/README.md +38 -13
- package/examples/anim.tsx +6 -8
- package/examples/camera-probe.tsx +16 -14
- package/examples/camera.tsx +21 -15
- package/examples/parity.tsx +11 -9
- package/examples/pick.tsx +43 -7
- package/examples/split-screen.tsx +111 -0
- package/examples/springs.tsx +6 -9
- package/examples/sprites.tsx +9 -12
- package/examples/tiles.tsx +24 -9
- package/examples/views.tsx +177 -0
- package/package.json +2 -2
- package/src/atlas.ts +35 -33
- package/src/camera.ts +26 -1
- package/src/camera2d.ts +692 -119
- package/src/components/auto-oversample.ts +27 -0
- package/src/components/camera2d.tsx +82 -0
- package/src/components/context.ts +48 -0
- package/src/components/group.tsx +68 -0
- package/src/components/index.ts +28 -0
- package/src/components/sprite-layer.tsx +312 -0
- package/src/components/sprite.tsx +72 -0
- package/src/components/tile-layer.tsx +160 -0
- package/src/components/view2d.tsx +151 -0
- package/src/dispatch.ts +7 -7
- package/src/frames.ts +63 -14
- package/src/index.ts +27 -11
- package/src/input.ts +50 -0
- package/src/layer.ts +384 -245
- package/src/oversample-math.ts +1 -1
- package/src/records.ts +33 -138
- package/src/shaders.ts +65 -3
- package/src/tiles.ts +195 -42
- package/src/views.ts +294 -0
- package/src/camera-motion.ts +0 -532
- package/src/components.tsx +0 -611
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createEffect, createSignal, displayScale, For, getBoundingBoxViewport, onLayout, untrack } from "@solidrt/core"
|
|
2
|
+
import type { TextureId, VoidComponent } from "@solidrt/core"
|
|
3
|
+
import type { FilterMode } from "@solidrt/core/gpu"
|
|
4
|
+
import type { CameraUpdate } from "../camera.ts"
|
|
5
|
+
import type { Frame } from "../frames.ts"
|
|
6
|
+
import { tileWorldScale } from "../oversample-math.ts"
|
|
7
|
+
import { createTileLayer } from "../tiles.ts"
|
|
8
|
+
import type { TileChunk, TileLayer as TileLayerHandle } from "../tiles.ts"
|
|
9
|
+
import { applyOversample } from "./auto-oversample.ts"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The tile layer's camera - the same CameraUpdate vocabulary as the sprite
|
|
13
|
+
* layer (see camera.ts for the pivot/rotation semantics and the
|
|
14
|
+
* heading-upward convention), so one signal drives a whole rotating scene
|
|
15
|
+
* across both layers. Here the camera is a transform on the composited
|
|
16
|
+
* world view, never a re-bake; projectCamera/unprojectCamera are the same
|
|
17
|
+
* mapping as plain functions.
|
|
18
|
+
*/
|
|
19
|
+
export type TileCamera = CameraUpdate
|
|
20
|
+
|
|
21
|
+
export type TileLayerProps = {
|
|
22
|
+
/** Grid shape and tile pixel size - creation-fixed (recreate to resize). */
|
|
23
|
+
cols: number
|
|
24
|
+
rows: number
|
|
25
|
+
tileW: number
|
|
26
|
+
tileH: number
|
|
27
|
+
/** The atlas texture every tile samples (create with createAtlas). */
|
|
28
|
+
atlas: TextureId
|
|
29
|
+
/** The frames table `setTiles`' index cells name (a tileset, `grid()`'s
|
|
30
|
+
* array); see TileLayerOptions. Fixed at creation. */
|
|
31
|
+
frames?: Frame[]
|
|
32
|
+
/** Per-chunk clear color (the name says the scope: never-written regions
|
|
33
|
+
* have no chunk and render nothing, so a full-bleed ground color belongs
|
|
34
|
+
* on the container behind the layer). */
|
|
35
|
+
chunkClearColor?: [number, number, number, number]
|
|
36
|
+
/** Sampler filter for the baked chunk textures at composite time; default
|
|
37
|
+
* "linear" (hard pixels belong to the atlas sampler, see TileLayerOptions). */
|
|
38
|
+
filter?: FilterMode
|
|
39
|
+
/**
|
|
40
|
+
* Target texels per world pixel in the baked chunks. Absent, the component
|
|
41
|
+
* picks it every layout from the world view's on-screen size (display
|
|
42
|
+
* scale, camera zoom, any designSize fit), so tiles resample properly at
|
|
43
|
+
* any scale.
|
|
44
|
+
*/
|
|
45
|
+
oversample?: number
|
|
46
|
+
/**
|
|
47
|
+
* Cap on the auto-picked oversample (integer >= 1): tiles stay adaptive
|
|
48
|
+
* up to it, never above. A tile world's texture memory is resident chunks
|
|
49
|
+
* x n squared, so this is the lever that bounds it on high-scale displays
|
|
50
|
+
* without giving up adaptivity. Ignored when `oversample` is set.
|
|
51
|
+
*/
|
|
52
|
+
maxOversample?: number
|
|
53
|
+
/** Chunk edge in tiles (default ~512px worth); see TileLayerOptions. */
|
|
54
|
+
chunkTiles?: number
|
|
55
|
+
/**
|
|
56
|
+
* Layer tint, [r, g, b, a] in 0..1, multiplied over every cell's own
|
|
57
|
+
* tint (day/night, a dimmed parallax plane). A change re-renders every
|
|
58
|
+
* resident chunk GPU-side (no record uploads) - drive it from slow
|
|
59
|
+
* state, not per frame.
|
|
60
|
+
*/
|
|
61
|
+
tint?: [number, number, number, number]
|
|
62
|
+
/**
|
|
63
|
+
* Pan/zoom/rotate over the world - a transform on the composited world
|
|
64
|
+
* view, never a re-bake. The world view is WORLD sized; put the layer
|
|
65
|
+
* inside a clipping container (`overflow="clip"`) sized to the viewport.
|
|
66
|
+
*/
|
|
67
|
+
camera?: TileCamera
|
|
68
|
+
label?: string
|
|
69
|
+
ref?: (layer: TileLayerHandle) => void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Owns a baked tile layer (createTileLayer) and composites its chunks as
|
|
74
|
+
* `d-texture` leaves at their world rects inside a `<view>` carrying the
|
|
75
|
+
* camera transform - a handful of quads however many tiles exist. A layout
|
|
76
|
+
* component: the world view is laid out, so it cannot sit inside a d-*
|
|
77
|
+
* subtree. Tiles
|
|
78
|
+
* are data, not children: write them through `ref` with `setTile` and
|
|
79
|
+
* `setTiles` - there is no `<Tile>` component on purpose (a component per
|
|
80
|
+
* tile would re-introduce the per-element cost the bake removes).
|
|
81
|
+
*/
|
|
82
|
+
export let TileLayer: VoidComponent<TileLayerProps> = props => {
|
|
83
|
+
let layer = untrack(() =>
|
|
84
|
+
createTileLayer(props.cols, props.rows, props.tileW, props.tileH, props.atlas, {
|
|
85
|
+
chunkClearColor: props.chunkClearColor,
|
|
86
|
+
filter: props.filter,
|
|
87
|
+
chunkTiles: props.chunkTiles,
|
|
88
|
+
tint: props.tint,
|
|
89
|
+
frames: props.frames,
|
|
90
|
+
label: props.label,
|
|
91
|
+
}),
|
|
92
|
+
)
|
|
93
|
+
untrack(() => props.ref)?.(layer)
|
|
94
|
+
createEffect(
|
|
95
|
+
() => props.oversample,
|
|
96
|
+
n => {
|
|
97
|
+
if (n !== undefined) layer.setOversample(n)
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
createEffect(
|
|
101
|
+
() => props.tint,
|
|
102
|
+
tint => {
|
|
103
|
+
if (tint !== undefined) layer.setTint(tint)
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
// Auto oversample: the world view's window box, in device pixels, per
|
|
107
|
+
// world pixel. Rotation is not a resolution factor - texels per world
|
|
108
|
+
// pixel do not change as the camera turns - but the measured box is the
|
|
109
|
+
// AABB of the ROTATED view, which swells by up to sqrt(2) and under an
|
|
110
|
+
// animated rotation would sweep the scale across an integer boundary,
|
|
111
|
+
// re-baking every resident chunk on each flip. So the swell is divided
|
|
112
|
+
// back out for the known camera rotation, leaving displayScale, the
|
|
113
|
+
// ancestor fit and the camera zoom. The divide-out's basis is the view's
|
|
114
|
+
// laid-out box, which flexShrink 0 below pins to layer.width x
|
|
115
|
+
// layer.height: a flex container would otherwise compress the box (the
|
|
116
|
+
// chunks are detached and draw at world coordinates either way) and the
|
|
117
|
+
// measurement would mix layout compression into the scale. pick runs
|
|
118
|
+
// untracked (onLayout handler / effect apply), so its prop reads are
|
|
119
|
+
// wrapped explicitly.
|
|
120
|
+
let world: { id: number } | undefined
|
|
121
|
+
let pick = () =>
|
|
122
|
+
untrack(() => {
|
|
123
|
+
if (!world || props.oversample !== undefined) return
|
|
124
|
+
let box = getBoundingBoxViewport(world)
|
|
125
|
+
if (!box) return
|
|
126
|
+
let r = props.camera?.rotation ?? 0
|
|
127
|
+
let scale = displayScale() * tileWorldScale(box.width, box.height, layer.width, layer.height, r)
|
|
128
|
+
applyOversample(layer, scale, layer.chunkW, layer.chunkH, props.maxOversample)
|
|
129
|
+
})
|
|
130
|
+
onLayout(pick)
|
|
131
|
+
createEffect(() => [displayScale(), props.maxOversample], pick)
|
|
132
|
+
// Chunk allocations arrive through the layer's hook; the signal carries a
|
|
133
|
+
// fresh array so <For> sees the growth.
|
|
134
|
+
let [chunks, setChunks] = createSignal<TileChunk[]>(layer.chunks.slice())
|
|
135
|
+
layer.onChunk = () => setChunks(layer.chunks.slice())
|
|
136
|
+
// World -> screen: p maps to pivot + R(rotation) * zoom * (p - camera) -
|
|
137
|
+
// projectCamera (camera.ts) spelled with element transforms as origin at
|
|
138
|
+
// the camera point, rotate + scale there, then translate the camera point
|
|
139
|
+
// onto the pivot. checks/camera-check.ts holds the two spellings together.
|
|
140
|
+
let camX = () => props.camera?.x ?? 0
|
|
141
|
+
let camY = () => props.camera?.y ?? 0
|
|
142
|
+
return (
|
|
143
|
+
<view
|
|
144
|
+
ref={(n: { id: number }) => (world = n)}
|
|
145
|
+
width={layer.width}
|
|
146
|
+
height={layer.height}
|
|
147
|
+
flexShrink={0}
|
|
148
|
+
originX={camX()}
|
|
149
|
+
originY={camY()}
|
|
150
|
+
rotate={props.camera?.rotation ?? 0}
|
|
151
|
+
scale={props.camera?.zoom ?? 1}
|
|
152
|
+
x={(props.camera?.pivotX ?? 0) - camX()}
|
|
153
|
+
y={(props.camera?.pivotY ?? 0) - camY()}
|
|
154
|
+
>
|
|
155
|
+
<For each={chunks()}>
|
|
156
|
+
{chunk => <d-texture src={chunk.texture} x={chunk.x} y={chunk.y} w={chunk.width} h={chunk.height} />}
|
|
157
|
+
</For>
|
|
158
|
+
</view>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { createEffect, displayScale, getBoundingBoxViewport, onCleanup, onLayout, untrack, useContext } from "@solidrt/core"
|
|
2
|
+
import type { Element, ParentComponent, PointerFeed, TextureId } from "@solidrt/core"
|
|
3
|
+
import type { CameraUpdate } from "../camera.ts"
|
|
4
|
+
import { feedPointer } from "../views.ts"
|
|
5
|
+
import type { ViewHandle, ViewOptions } from "../views.ts"
|
|
6
|
+
import { LayerContext, PointerContext, ViewportContext } from "./context.ts"
|
|
7
|
+
import type { LayerPointerProps } from "./sprite-layer.tsx"
|
|
8
|
+
import { applyOversample } from "./auto-oversample.ts"
|
|
9
|
+
|
|
10
|
+
export type View2dProps = LayerPointerProps &
|
|
11
|
+
Pick<ViewOptions, "clearColor" | "label"> & {
|
|
12
|
+
/** View pixels, live (view.setSize). Fixed only, for now: a fill mode
|
|
13
|
+
* like SpriteLayer's is a later, additive step. */
|
|
14
|
+
width: number
|
|
15
|
+
height: number
|
|
16
|
+
/** Partial camera update on the view's own camera (view.setCamera):
|
|
17
|
+
* absent keys keep their values. Live. A `<Camera2d>` child writes the
|
|
18
|
+
* same state - use one form, not both. */
|
|
19
|
+
camera?: CameraUpdate
|
|
20
|
+
/**
|
|
21
|
+
* Target texels per view pixel. Absent, the component picks it every
|
|
22
|
+
* layout from the built-in leaf's on-screen size, as `<SpriteLayer>`
|
|
23
|
+
* does; with `output` there is no built-in leaf, so set it yourself.
|
|
24
|
+
*/
|
|
25
|
+
oversample?: number
|
|
26
|
+
/** Cap on the auto-picked oversample (see SpriteLayerProps). */
|
|
27
|
+
maxOversample?: number
|
|
28
|
+
ref?: (view: ViewHandle) => void
|
|
29
|
+
/**
|
|
30
|
+
* Compose the leaf yourself: called once (untracked) with view.texture,
|
|
31
|
+
* and its return renders in place of the built-in `<texture>` leaf.
|
|
32
|
+
* Sprite pointer events through the view then need its handlers on
|
|
33
|
+
* your leaf: `<texture src={texture} {...view.handlers} />` (the view
|
|
34
|
+
* from `ref`).
|
|
35
|
+
*/
|
|
36
|
+
output?: (texture: TextureId) => Element
|
|
37
|
+
/** Pointer events (default on): the built-in leaf carries the view's
|
|
38
|
+
* handlers. `false` detaches them. */
|
|
39
|
+
events?: boolean
|
|
40
|
+
/** The pointer feed of this view (createPointerFeed), fed from the
|
|
41
|
+
* view's root - the events the sprites let through - so a map bound
|
|
42
|
+
* to it drives the `<Camera2d>` inside this view and no other; inside,
|
|
43
|
+
* `useSpriteLayer().pointer` is this feed. Fixed at creation. */
|
|
44
|
+
pointer?: PointerFeed
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A view of the enclosing layer from a camera of its own (layer.createView
|
|
49
|
+
* as a component): the same sprites, one more target and one entry, so
|
|
50
|
+
* it costs no per-frame JS. The `<SpriteLayer>`'s own leaf is one of
|
|
51
|
+
* these too; a `<SpriteLayer output={false}>` shows ONLY through its
|
|
52
|
+
* `<View2d>` children (split-screen: two side by side). Composites as an
|
|
53
|
+
* ordinary `<texture>` leaf at the view size, or through `output`. A
|
|
54
|
+
* `<Camera2d>`
|
|
55
|
+
* child drives the VIEW's camera from the map it is given, and the view's
|
|
56
|
+
* `pointer` feed carries the view's gestures to that map: inside,
|
|
57
|
+
* `useSpriteLayer()` reports the view as `viewport` and the feed as
|
|
58
|
+
* `pointer`. `<Sprite>` and
|
|
59
|
+
* `<Group>` children mount to the layer as they would outside - a view
|
|
60
|
+
* mirrors the layer's sprites, it has none of its own. Sprites under the
|
|
61
|
+
* view's leaf get their ordinary pointer handlers, with the view's camera
|
|
62
|
+
* undone; the view's own `onPointer*`/`onTap` are the last stop of that
|
|
63
|
+
* walk (`event.sprite` null over empty space).
|
|
64
|
+
*/
|
|
65
|
+
export let View2d: ParentComponent<View2dProps> = props => {
|
|
66
|
+
let layer = useContext(LayerContext)
|
|
67
|
+
// The initial props seed createView, so `ref` hands out a configured
|
|
68
|
+
// view and the first frame draws it whole; the effects follow changes
|
|
69
|
+
// only (`defer`).
|
|
70
|
+
let view = untrack(() =>
|
|
71
|
+
layer.createView({
|
|
72
|
+
width: props.width,
|
|
73
|
+
height: props.height,
|
|
74
|
+
camera: props.camera,
|
|
75
|
+
oversample: props.oversample,
|
|
76
|
+
clearColor: props.clearColor,
|
|
77
|
+
label: props.label,
|
|
78
|
+
}),
|
|
79
|
+
)
|
|
80
|
+
createEffect(
|
|
81
|
+
() => [props.width, props.height] as const,
|
|
82
|
+
([w, h]) => view.setSize(w, h),
|
|
83
|
+
{ defer: true },
|
|
84
|
+
)
|
|
85
|
+
createEffect(
|
|
86
|
+
() => props.camera,
|
|
87
|
+
camera => {
|
|
88
|
+
if (camera) view.setCamera(camera)
|
|
89
|
+
},
|
|
90
|
+
{ defer: true },
|
|
91
|
+
)
|
|
92
|
+
createEffect(
|
|
93
|
+
() => props.oversample,
|
|
94
|
+
n => {
|
|
95
|
+
if (n !== undefined) view.setOversample(n)
|
|
96
|
+
},
|
|
97
|
+
{ defer: true },
|
|
98
|
+
)
|
|
99
|
+
untrack(() => props.ref)?.(view)
|
|
100
|
+
onCleanup(() => view.dispose())
|
|
101
|
+
let pointer = untrack(() => props.pointer) ?? null
|
|
102
|
+
if (pointer) onCleanup(feedPointer(view, pointer))
|
|
103
|
+
// The view's own handlers at the root of the walk; the props are read
|
|
104
|
+
// per event, so a handler prop may change without re-registering.
|
|
105
|
+
onCleanup(
|
|
106
|
+
view.listen({
|
|
107
|
+
onPointerDown: e => props.onPointerDown?.(e),
|
|
108
|
+
onPointerMove: e => props.onPointerMove?.(e),
|
|
109
|
+
onPointerUp: e => props.onPointerUp?.(e),
|
|
110
|
+
onWheel: e => props.onWheel?.(e),
|
|
111
|
+
onTap: e => props.onTap?.(e),
|
|
112
|
+
}),
|
|
113
|
+
)
|
|
114
|
+
let output = untrack(() => props.output)
|
|
115
|
+
let events = untrack(() => props.events) !== false
|
|
116
|
+
let leaf: { id: number } | undefined
|
|
117
|
+
// Auto oversample from the built-in leaf's window box, in device pixels,
|
|
118
|
+
// per view pixel: the same pick as SpriteLayer's fixed mode.
|
|
119
|
+
let pick = () =>
|
|
120
|
+
untrack(() => {
|
|
121
|
+
if (!leaf || props.oversample !== undefined) return
|
|
122
|
+
let box = getBoundingBoxViewport(leaf)
|
|
123
|
+
if (!box) return
|
|
124
|
+
let scale = displayScale() * Math.max(box.width / props.width, box.height / props.height)
|
|
125
|
+
applyOversample(view, scale, props.width, props.height, props.maxOversample)
|
|
126
|
+
})
|
|
127
|
+
onLayout(pick)
|
|
128
|
+
createEffect(() => [displayScale(), props.maxOversample], pick)
|
|
129
|
+
return (
|
|
130
|
+
<ViewportContext value={view}>
|
|
131
|
+
<PointerContext value={pointer}>
|
|
132
|
+
{output ? (
|
|
133
|
+
untrack(() => output(view.texture))
|
|
134
|
+
) : (
|
|
135
|
+
<texture
|
|
136
|
+
ref={(n: { id: number }) => (leaf = n)}
|
|
137
|
+
src={view.texture}
|
|
138
|
+
width={props.width}
|
|
139
|
+
height={props.height}
|
|
140
|
+
onPointerDown={events ? view.handlers.onPointerDown : undefined}
|
|
141
|
+
onPointerMove={events ? view.handlers.onPointerMove : undefined}
|
|
142
|
+
onPointerUp={events ? view.handlers.onPointerUp : undefined}
|
|
143
|
+
onPointerLeave={events ? view.handlers.onPointerLeave : undefined}
|
|
144
|
+
onWheel={events ? view.handlers.onWheel : undefined}
|
|
145
|
+
/>
|
|
146
|
+
)}
|
|
147
|
+
{props.children}
|
|
148
|
+
</PointerContext>
|
|
149
|
+
</ViewportContext>
|
|
150
|
+
)
|
|
151
|
+
}
|
package/src/dispatch.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Pointer dispatch behind
|
|
2
|
-
// element event model one tree deeper, with the
|
|
1
|
+
// Pointer dispatch behind view.handlers, shared by both layer kinds: the
|
|
2
|
+
// element event model one tree deeper, with the VIEW as the root of the
|
|
3
3
|
// walk. Down, move, up and wheel dispatch on the topmost hit sprite,
|
|
4
4
|
// bubble through its enclosing groups and end at the layer's listeners;
|
|
5
5
|
// over empty space the walk is the layer alone (`sprite` null). Claiming
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
|
|
23
23
|
import { unprojectCamera } from "./camera.ts"
|
|
24
24
|
import type { CameraUpdate } from "./camera.ts"
|
|
25
|
-
import type { LayerPointerListener, Sprite, SpriteGroup, SpriteHandlers,
|
|
26
|
-
import type {
|
|
25
|
+
import type { LayerPointerListener, Sprite, SpriteGroup, SpriteHandlers, SpritePointerEvent } from "./layer.ts"
|
|
26
|
+
import type { ViewHandle } from "./views.ts"
|
|
27
27
|
|
|
28
28
|
// Finger travel from the down point, in window pixels, past which a press
|
|
29
29
|
// is a drag and never a tap. Core's pan and transform recognizers engage
|
|
@@ -43,8 +43,8 @@ export type DispatchDeps = {
|
|
|
43
43
|
size: () => [number, number]
|
|
44
44
|
camera: () => CameraUpdate
|
|
45
45
|
pick: (x: number, y: number) => Sprite[]
|
|
46
|
-
/** The
|
|
47
|
-
root:
|
|
46
|
+
/** The view: the walk's root and the listeners' currentTarget. */
|
|
47
|
+
root: ViewHandle
|
|
48
48
|
/** The root's listeners, in registration order. */
|
|
49
49
|
listeners: Set<LayerPointerListener>
|
|
50
50
|
/** The clock for tap repeats (default performance.now; checks inject). */
|
|
@@ -58,7 +58,7 @@ type HandlerName = "onPointerDown" | "onPointerMove" | "onPointerUp" | "onWheel"
|
|
|
58
58
|
// fields); the public types narrow it per handler.
|
|
59
59
|
type InternalEvent = {
|
|
60
60
|
sprite: Sprite | null
|
|
61
|
-
currentTarget: Sprite | SpriteGroup |
|
|
61
|
+
currentTarget: Sprite | SpriteGroup | ViewHandle
|
|
62
62
|
x: number
|
|
63
63
|
y: number
|
|
64
64
|
pointerId: number
|
package/src/frames.ts
CHANGED
|
@@ -31,10 +31,14 @@ export function writeFrame(
|
|
|
31
31
|
data[at + 3] = flipY ? v0 : v1
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* What the slicers need of an atlas: its pixel size, the space every rect
|
|
36
|
+
* and gap below is measured in. An `Atlas` fits as it is; a texture from
|
|
37
|
+
* anywhere else (a render target, a camera) passes `{ width, height }`.
|
|
38
|
+
*/
|
|
39
|
+
export type AtlasSize = { width: number; height: number }
|
|
40
|
+
|
|
34
41
|
export type GridOptions = {
|
|
35
|
-
/** Pixel size of the atlas the pixel-space options below refer to. */
|
|
36
|
-
width: number
|
|
37
|
-
height: number
|
|
38
42
|
/** Cell size in pixels; defaults to width/cols x height/rows. */
|
|
39
43
|
cellW?: number
|
|
40
44
|
cellH?: number
|
|
@@ -43,6 +47,29 @@ export type GridOptions = {
|
|
|
43
47
|
/** Pixel offset of the first cell from the top-left corner; default 0. */
|
|
44
48
|
marginX?: number
|
|
45
49
|
marginY?: number
|
|
50
|
+
/**
|
|
51
|
+
* Texels shaved off every side of each frame; default 0. Only a
|
|
52
|
+
* mipmapped atlas needs it: 2^k texels keep mip level k inside the cell
|
|
53
|
+
* (the layer shaders already clamp level-0 samples into the frame), at
|
|
54
|
+
* the cost of that border of every sprite. The stopgap until extrusion
|
|
55
|
+
* (okf/backlog/2d-atlas-extrude.md).
|
|
56
|
+
*/
|
|
57
|
+
inset?: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type NamedFramesOptions = {
|
|
61
|
+
/** As GridOptions.inset: texels shaved off every side of each rect. */
|
|
62
|
+
inset?: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A pixel rect to UVs, shrunk by `inset` on every side; `what` names the throw. */
|
|
66
|
+
function frameOf(atlas: AtlasSize, x: number, y: number, w: number, h: number, inset: number, what: string): Frame {
|
|
67
|
+
if (!(inset >= 0)) throw new Error(`${what}: inset must be non-negative, got ${inset}`)
|
|
68
|
+
let iw = w - inset * 2
|
|
69
|
+
let ih = h - inset * 2
|
|
70
|
+
if (!(iw > 0 && ih > 0)) throw new Error(`${what}: inset ${inset} leaves no frame of a ${w} x ${h} rect`)
|
|
71
|
+
let { width, height } = atlas
|
|
72
|
+
return { u0: (x + inset) / width, v0: (y + inset) / height, u1: (x + inset + iw) / width, v1: (y + inset + ih) / height }
|
|
46
73
|
}
|
|
47
74
|
|
|
48
75
|
/**
|
|
@@ -50,17 +77,23 @@ export type GridOptions = {
|
|
|
50
77
|
* top to bottom) - the layout every sheet packer and pixel-art tool emits.
|
|
51
78
|
* Frames are returned in cell order, so `frames[row * cols + col]` addresses
|
|
52
79
|
* a cell and an animation is a slice of consecutive indices.
|
|
80
|
+
*
|
|
81
|
+
* Cells that touch do not bleed at mip level 0: the layer shaders clamp
|
|
82
|
+
* every sample into its frame (shaders.ts). A mip chain is different - its
|
|
83
|
+
* texels straddle cell edges before sampling - so a mipmapped sheet packs
|
|
84
|
+
* a gutter (`spacing`) or shaves its frames (`inset`); see namedFrames.
|
|
53
85
|
*/
|
|
54
|
-
export function grid(cols: number, rows: number, opts
|
|
86
|
+
export function grid(atlas: AtlasSize, cols: number, rows: number, opts?: GridOptions): Frame[] {
|
|
55
87
|
if (!(cols > 0 && rows > 0 && Number.isInteger(cols) && Number.isInteger(rows))) {
|
|
56
88
|
throw new Error(`grid: cols and rows must be positive integers, got ${cols} x ${rows}`)
|
|
57
89
|
}
|
|
58
|
-
let { width, height
|
|
90
|
+
let { width, height } = atlas
|
|
59
91
|
if (!(width > 0 && height > 0)) {
|
|
60
92
|
throw new Error(`grid: atlas size must be positive, got ${width} x ${height}`)
|
|
61
93
|
}
|
|
62
|
-
let
|
|
63
|
-
let
|
|
94
|
+
let { spacing = 0, marginX = 0, marginY = 0, inset = 0 } = opts ?? {}
|
|
95
|
+
let cellW = opts?.cellW ?? (width - marginX * 2 - spacing * (cols - 1)) / cols
|
|
96
|
+
let cellH = opts?.cellH ?? (height - marginY * 2 - spacing * (rows - 1)) / rows
|
|
64
97
|
if (!(cellW > 0 && cellH > 0)) {
|
|
65
98
|
throw new Error(`grid: derived cell size ${cellW} x ${cellH} is not positive`)
|
|
66
99
|
}
|
|
@@ -69,7 +102,7 @@ export function grid(cols: number, rows: number, opts: GridOptions): Frame[] {
|
|
|
69
102
|
for (let col = 0; col < cols; col++) {
|
|
70
103
|
let x = marginX + col * (cellW + spacing)
|
|
71
104
|
let y = marginY + row * (cellH + spacing)
|
|
72
|
-
frames.push(
|
|
105
|
+
frames.push(frameOf(atlas, x, y, cellW, cellH, inset, "grid"))
|
|
73
106
|
}
|
|
74
107
|
}
|
|
75
108
|
return frames
|
|
@@ -78,21 +111,37 @@ export function grid(cols: number, rows: number, opts: GridOptions): Frame[] {
|
|
|
78
111
|
/**
|
|
79
112
|
* Name frames from a pixel-rect map: `{ hero: [x, y, w, h], ... }` in atlas
|
|
80
113
|
* pixels to `{ hero: Frame, ... }`. The named counterpart of `grid` for
|
|
81
|
-
* hand-packed or tool-exported sheets.
|
|
114
|
+
* hand-packed or tool-exported sheets. The key union comes from the table
|
|
115
|
+
* you pass: an object literal (or a built table with `satisfies Record<...>`
|
|
116
|
+
* on the literal) gives a record whose every name is checked; a table
|
|
117
|
+
* typed `Record<string, ...>` gives back an index signature, so every name
|
|
118
|
+
* compiles, a typo included, and no `!` is needed on a lookup - the
|
|
119
|
+
* scaffold's tsconfig does not set noUncheckedIndexedAccess.
|
|
120
|
+
*
|
|
121
|
+
* The oldest atlas trap - frames addressed as whole-pixel rects that share
|
|
122
|
+
* an edge bleed into each other, a sprite drifting by fractions of a pixel
|
|
123
|
+
* flashing a one-texel line of the cell next door - is handled in the
|
|
124
|
+
* layer shaders, which clamp every sample into its frame. What they cannot
|
|
125
|
+
* handle is a mip chain, whose texels average across cell edges before
|
|
126
|
+
* sampling: a mipmapped sheet passes `inset` (2^k texels keeps level k
|
|
127
|
+
* clean, losing that border) or packs a gutter, until extrusion lands
|
|
128
|
+
* (okf/backlog/2d-atlas-extrude.md).
|
|
82
129
|
*/
|
|
83
130
|
export function namedFrames<K extends string>(
|
|
84
|
-
|
|
85
|
-
atlasH: number,
|
|
131
|
+
atlas: AtlasSize,
|
|
86
132
|
rects: Record<K, [number, number, number, number]>,
|
|
133
|
+
opts?: NamedFramesOptions,
|
|
87
134
|
): Record<K, Frame> {
|
|
88
|
-
|
|
89
|
-
|
|
135
|
+
let { width, height } = atlas
|
|
136
|
+
if (!(width > 0 && height > 0)) {
|
|
137
|
+
throw new Error(`namedFrames: atlas size must be positive, got ${width} x ${height}`)
|
|
90
138
|
}
|
|
139
|
+
let inset = opts?.inset ?? 0
|
|
91
140
|
let out = {} as Record<K, Frame>
|
|
92
141
|
for (let name in rects) {
|
|
93
142
|
let [x, y, w, h] = rects[name]
|
|
94
143
|
if (!(w > 0 && h > 0)) throw new Error(`namedFrames: frame '${name}' has non-positive size ${w} x ${h}`)
|
|
95
|
-
out[name] =
|
|
144
|
+
out[name] = frameOf(atlas, x, y, w, h, inset, `namedFrames: frame '${name}'`)
|
|
96
145
|
}
|
|
97
146
|
return out
|
|
98
147
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
// @solidrt/2d - an instanced sprite layer above @solidrt/core/gpu.
|
|
2
|
-
// One atlas, N quads in one draw
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// One atlas, N quads in one draw per VIEW: a layer holds the sprites and
|
|
3
|
+
// shows only through its views (layer.createView / <View2d>, each a target
|
|
4
|
+
// with a camera of its own - a Unity scene renders only through Cameras,
|
|
5
|
+
// a Godot World2D only through Viewports). The live layer
|
|
6
|
+
// (createSpriteLayer/addSprite) backs every sprite with a SPATIAL ARENA
|
|
7
|
+
// node whose Pose2D record sink writes the pose instance buffer at the
|
|
8
|
+
// core flush, so core producers reach sprites and picking walks the core
|
|
9
|
+
// BVH; style stays a JS-written second instance buffer. The records layer (createRecordLayer)
|
|
7
10
|
// is the raw escape hatch for motion only JS can compute: 13 JS-owned
|
|
8
11
|
// floats per sprite published through the zero-copy write lease. The baked
|
|
9
12
|
// tile layer (createTileLayer/TileLayer) is the static sibling: a tile
|
|
10
13
|
// world rendered once into textures and composited as a few quads,
|
|
11
14
|
// re-baked on change. Two faces throughout: the imperative core (usable
|
|
12
15
|
// without Solid components) and the components (SpriteLayer/Sprite/Group/
|
|
13
|
-
// TileLayer) on top. See AGENTS.md for the model and the traps.
|
|
16
|
+
// View2d/TileLayer) on top. See AGENTS.md for the model and the traps.
|
|
14
17
|
|
|
15
18
|
export { addGroup, addSprite, createSpriteLayer, getSprite, removeGroup, removeSprite, setGroup, setGroupTransition, setSprite, setSpriteParent, setSpriteTransition, worldPosition, POSE_FLOATS, STYLE_FLOATS } from "./layer.ts"
|
|
16
19
|
export { createRecordLayer, FLOATS_PER_SPRITE } from "./records.ts"
|
|
@@ -19,11 +22,21 @@ export { pointInSprite } from "./pick.ts"
|
|
|
19
22
|
export { projectCamera, unprojectCamera } from "./camera.ts"
|
|
20
23
|
export type { CameraState, CameraUpdate } from "./camera.ts"
|
|
21
24
|
export { createCamera2d } from "./camera2d.ts"
|
|
22
|
-
export type { Camera2d as Camera2dHandle, Camera2dOptions } from "./camera2d.ts"
|
|
23
|
-
export
|
|
25
|
+
export type { Camera2d as Camera2dHandle, Camera2dAxes, Camera2dOptions, Camera2dPose, Camera2dTarget, Rect2d } from "./camera2d.ts"
|
|
26
|
+
export { camera2dActions, camera2dBindings } from "./input.ts"
|
|
27
|
+
export type { CameraDevices } from "./input.ts"
|
|
24
28
|
export type {
|
|
25
29
|
AddSpriteOptions,
|
|
30
|
+
Capsule,
|
|
31
|
+
Circle,
|
|
26
32
|
GroupOptions,
|
|
33
|
+
Impact,
|
|
34
|
+
MoveOptions,
|
|
35
|
+
MoveResult,
|
|
36
|
+
Overlap,
|
|
37
|
+
QueryOptions,
|
|
38
|
+
RayHit,
|
|
39
|
+
Rect,
|
|
27
40
|
LayerPointerEvent,
|
|
28
41
|
LayerPointerListener,
|
|
29
42
|
LayerTapEvent,
|
|
@@ -38,17 +51,20 @@ export type {
|
|
|
38
51
|
SpriteTapEvent,
|
|
39
52
|
SpriteWheelEvent,
|
|
40
53
|
TransitionEndEvent,
|
|
54
|
+
Volume,
|
|
41
55
|
} from "./layer.ts"
|
|
56
|
+
export { feedPointer } from "./views.ts"
|
|
57
|
+
export type { ViewHandle, ViewOptions } from "./views.ts"
|
|
42
58
|
export { createTileLayer } from "./tiles.ts"
|
|
43
59
|
export type { TileChunk, TileLayer as TileLayerHandle, TileLayerOptions } from "./tiles.ts"
|
|
44
60
|
|
|
45
61
|
export type { NodeTransition, NodeTransitionSpec } from "flux:spatial"
|
|
46
62
|
export { grid, namedFrames, FULL_FRAME } from "./frames.ts"
|
|
47
|
-
export type { Frame, GridOptions } from "./frames.ts"
|
|
63
|
+
export type { AtlasSize, Frame, GridOptions, NamedFramesOptions } from "./frames.ts"
|
|
48
64
|
export { createAnimation } from "./animation.ts"
|
|
49
65
|
export type { AnimationOptions, SpriteAnimation } from "./animation.ts"
|
|
50
66
|
export { fitOversample } from "./oversample.ts"
|
|
51
67
|
export { createAtlas } from "./atlas.ts"
|
|
52
68
|
export type { Atlas, AtlasOptions } from "./atlas.ts"
|
|
53
|
-
export { Camera2d, Group, Sprite, SpriteLayer, TileLayer, useSpriteLayer } from "./components.
|
|
54
|
-
export type { Camera2dProps, GroupProps, LayerPointerProps, SpriteLayerProps, SpritePointerProps, SpriteProps, TileCamera, TileLayerProps } from "./components.
|
|
69
|
+
export { Camera2d, Group, Sprite, SpriteLayer, TileLayer, View2d, useSpriteLayer } from "./components/index.ts"
|
|
70
|
+
export type { Camera2dProps, GroupProps, LayerPointerProps, SpriteLayerProps, SpritePointerProps, SpriteProps, TileCamera, TileLayerProps, View2dProps } from "./components/index.ts"
|
package/src/input.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The standard input wiring for the 2d camera: the action declarations it
|
|
2
|
+
// consumes (its axes, by the shared vocabulary) and a preset that binds
|
|
3
|
+
// the devices an app hands in - plain data the app could write by hand,
|
|
4
|
+
// applied with `input.bind(camera2dBindings({ pointer, gamepad }))` and
|
|
5
|
+
// adjusted afterwards with unbind; nothing binds unless the app says so
|
|
6
|
+
// (ARCHITECTURE.md). `pointer` is the feed of the view showing the layer
|
|
7
|
+
// (`createPointerFeed()`, handed to `<SpriteLayer pointer>` or
|
|
8
|
+
// `<View2d pointer>`, or bridged with feedPointer for an imperative view),
|
|
9
|
+
// `gamepad` a core gamepad device, `keyboard` the core keyboard device.
|
|
10
|
+
//
|
|
11
|
+
// Keys and sticks move the CAMERA where a drag moves the content (the
|
|
12
|
+
// arrow right scrolls the view right, as every map does), so those
|
|
13
|
+
// bindings go through invert() and `pan` keeps its one meaning.
|
|
14
|
+
|
|
15
|
+
import { invert } from "@solidrt/core/input"
|
|
16
|
+
import type { Binding, GamepadDevice, KeyboardDevice, PointerFeed } from "@solidrt/core"
|
|
17
|
+
|
|
18
|
+
export type CameraDevices = {
|
|
19
|
+
pointer?: PointerFeed
|
|
20
|
+
gamepad?: GamepadDevice
|
|
21
|
+
keyboard?: KeyboardDevice
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The 2d camera's actions: `pan`, `zoom`, `roll`. */
|
|
25
|
+
export let camera2dActions = { pan: "vec2", zoom: "axis", roll: "axis" } as const
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The 2d camera's standard bindings: one finger or a mouse drag pans and
|
|
29
|
+
* so do two fingers, a pinch and the wheel zoom about the pointer, a twist
|
|
30
|
+
* rolls; the left stick scrolls, the triggers zoom, the right stick's x
|
|
31
|
+
* rolls; the arrow keys scroll and minus/equals zoom.
|
|
32
|
+
*/
|
|
33
|
+
export function camera2dBindings(devices: CameraDevices): Binding[] {
|
|
34
|
+
let out: Binding[] = []
|
|
35
|
+
let { pointer, gamepad, keyboard } = devices
|
|
36
|
+
if (pointer) {
|
|
37
|
+
out.push(
|
|
38
|
+
{ action: "pan", source: pointer.drag },
|
|
39
|
+
{ action: "pan", source: pointer.pan },
|
|
40
|
+
{ action: "zoom", source: pointer.pinch },
|
|
41
|
+
{ action: "zoom", source: pointer.wheel },
|
|
42
|
+
{ action: "roll", source: pointer.twist },
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
if (gamepad) {
|
|
46
|
+
out.push({ action: "pan", source: invert(gamepad.leftStick) }, { action: "zoom", source: gamepad.triggers }, { action: "roll", source: invert(gamepad.axis("rightX")) })
|
|
47
|
+
}
|
|
48
|
+
if (keyboard) out.push({ action: "pan", source: invert(keyboard.arrows) }, { action: "zoom", source: keyboard.axis("Minus", "Equal") })
|
|
49
|
+
return out
|
|
50
|
+
}
|