@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
|
@@ -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
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
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
|
+
// walk. Down, move, up and wheel dispatch on the topmost hit sprite,
|
|
4
|
+
// bubble through its enclosing groups and end at the layer's listeners;
|
|
5
|
+
// over empty space the walk is the layer alone (`sprite` null). Claiming
|
|
6
|
+
// is stopPropagation: a handler that stops an event keeps every later
|
|
7
|
+
// target, the layer included, from seeing it, and the DOWN decides the
|
|
8
|
+
// press - a pointer whose down never reached the layer stays claimed by
|
|
9
|
+
// the sprite chain for its move, up and tap too, so a sprite that drags
|
|
10
|
+
// itself stops its down once and a camera attached at the root never
|
|
11
|
+
// pans. Capture is per pointer to the press target, the layer included: a
|
|
12
|
+
// drag from empty space keeps delivering to the layer as it crosses
|
|
13
|
+
// sprites, exactly as a drag from a sprite keeps naming that sprite.
|
|
14
|
+
// Enter/leave pair on the sprite alone. Taps are synthesized here (DOM's
|
|
15
|
+
// click, Unity's IPointerClickHandler): a press that releases on the same
|
|
16
|
+
// target within the slop, the only pointer down for its whole press,
|
|
17
|
+
// counted up for repeats (DOM's detail, Unity's clickCount).
|
|
18
|
+
//
|
|
19
|
+
// Pure BY DESIGN (types and camera.ts, nothing with GPU imports) so
|
|
20
|
+
// checks/dispatch-check.ts drives it headless with a fake pick.
|
|
21
|
+
|
|
22
|
+
import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
|
|
23
|
+
import { unprojectCamera } from "./camera.ts"
|
|
24
|
+
import type { CameraUpdate } from "./camera.ts"
|
|
25
|
+
import type { LayerPointerListener, Sprite, SpriteGroup, SpriteHandlers, SpritePointerEvent } from "./layer.ts"
|
|
26
|
+
import type { ViewHandle } from "./views.ts"
|
|
27
|
+
|
|
28
|
+
// Finger travel from the down point, in window pixels, past which a press
|
|
29
|
+
// is a drag and never a tap. Core's pan and transform recognizers engage
|
|
30
|
+
// at the same 8, so a press is never both a tap and a pan.
|
|
31
|
+
const TAP_SLOP = 8
|
|
32
|
+
// Successive taps closer in time than this, on the same target and within
|
|
33
|
+
// TAP_REPEAT_SLOP of each other, count up (Unity's 0.3 s clickCount
|
|
34
|
+
// window, Android's double-tap timeout).
|
|
35
|
+
const TAP_INTERVAL_MS = 300
|
|
36
|
+
// Window pixels a repeat tap may land from the previous one and still
|
|
37
|
+
// count: a thumb repeats within about this (Hammer.js uses 10, Android
|
|
38
|
+
// 100 dp); 20 tolerates touch without a mouse double-click spanning
|
|
39
|
+
// neighbouring sprites.
|
|
40
|
+
const TAP_REPEAT_SLOP = 20
|
|
41
|
+
|
|
42
|
+
export type DispatchDeps = {
|
|
43
|
+
size: () => [number, number]
|
|
44
|
+
camera: () => CameraUpdate
|
|
45
|
+
pick: (x: number, y: number) => Sprite[]
|
|
46
|
+
/** The view: the walk's root and the listeners' currentTarget. */
|
|
47
|
+
root: ViewHandle
|
|
48
|
+
/** The root's listeners, in registration order. */
|
|
49
|
+
listeners: Set<LayerPointerListener>
|
|
50
|
+
/** The clock for tap repeats (default performance.now; checks inject). */
|
|
51
|
+
now?: () => number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type HandlerName = "onPointerDown" | "onPointerMove" | "onPointerUp" | "onWheel" | "onTap"
|
|
55
|
+
|
|
56
|
+
// One runtime event object serves every view (SpritePointerEvent for the
|
|
57
|
+
// sprite chain, LayerPointerEvent at the root, plus the wheel and tap
|
|
58
|
+
// fields); the public types narrow it per handler.
|
|
59
|
+
type InternalEvent = {
|
|
60
|
+
sprite: Sprite | null
|
|
61
|
+
currentTarget: Sprite | SpriteGroup | ViewHandle
|
|
62
|
+
x: number
|
|
63
|
+
y: number
|
|
64
|
+
pointerId: number
|
|
65
|
+
pointerType: string
|
|
66
|
+
button?: number
|
|
67
|
+
shiftKey: boolean
|
|
68
|
+
ctrlKey: boolean
|
|
69
|
+
altKey: boolean
|
|
70
|
+
metaKey: boolean
|
|
71
|
+
native: ElementPointerEvent
|
|
72
|
+
deltaX?: number
|
|
73
|
+
deltaY?: number
|
|
74
|
+
tapCount?: number
|
|
75
|
+
_stopped: boolean
|
|
76
|
+
stopPropagation(): void
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A pointer between its down and up: what it pressed (null = the layer),
|
|
80
|
+
// whether the down reached the root, the down point for the slop, and
|
|
81
|
+
// whether it has been the only pointer down for its whole press.
|
|
82
|
+
type Press = {
|
|
83
|
+
target: Sprite | null
|
|
84
|
+
claimed: boolean
|
|
85
|
+
cx: number
|
|
86
|
+
cy: number
|
|
87
|
+
moved: boolean
|
|
88
|
+
alone: boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function spriteDispatch(deps: DispatchDeps): (layout: (() => { width: number; height: number }) | null) => SpriteHandlers {
|
|
92
|
+
let now = deps.now ?? (() => performance.now())
|
|
93
|
+
let presses = new Map<number, Press>()
|
|
94
|
+
let hover = new Map<number, Sprite>()
|
|
95
|
+
let lastTap: { time: number; cx: number; cy: number; target: Sprite | null; count: number } | null = null
|
|
96
|
+
|
|
97
|
+
// Bubble from the sprite through its groups; then, when the press lets
|
|
98
|
+
// it, the root's listeners (every one of them - the root is the last
|
|
99
|
+
// stop, there is nothing left to claim). Returns whether the root ran.
|
|
100
|
+
let walk = (name: HandlerName, event: InternalEvent, toRoot: boolean): boolean => {
|
|
101
|
+
for (let n: Sprite | SpriteGroup | null = event.sprite; n !== null && !event._stopped; n = n._parent) {
|
|
102
|
+
// An inert handle (removed mid-press) has no handlers to run.
|
|
103
|
+
if (n.layer === null) continue
|
|
104
|
+
let handler = n[name] as ((event: InternalEvent) => void) | undefined
|
|
105
|
+
if (handler) {
|
|
106
|
+
event.currentTarget = n
|
|
107
|
+
handler(event)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (event._stopped || !toRoot) return false
|
|
111
|
+
event.currentTarget = deps.root
|
|
112
|
+
for (let listener of deps.listeners) {
|
|
113
|
+
let handler = listener[name] as ((event: InternalEvent) => void) | undefined
|
|
114
|
+
if (handler) handler(event)
|
|
115
|
+
}
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return layout => {
|
|
120
|
+
let toLayer = (e: ElementPointerEvent): [number, number] => {
|
|
121
|
+
let x = e.localX
|
|
122
|
+
let y = e.localY
|
|
123
|
+
let l = layout?.()
|
|
124
|
+
let [width, height] = deps.size()
|
|
125
|
+
if (l && l.width > 0 && l.height > 0) {
|
|
126
|
+
x *= width / l.width
|
|
127
|
+
y *= height / l.height
|
|
128
|
+
}
|
|
129
|
+
// Undo the camera: screen -> world.
|
|
130
|
+
return unprojectCamera(deps.camera(), x, y)
|
|
131
|
+
}
|
|
132
|
+
let makeEvent = (sprite: Sprite | null, x: number, y: number, e: ElementPointerEvent): InternalEvent => {
|
|
133
|
+
let event: InternalEvent = {
|
|
134
|
+
sprite,
|
|
135
|
+
currentTarget: sprite ?? deps.root,
|
|
136
|
+
x,
|
|
137
|
+
y,
|
|
138
|
+
pointerId: e.pointerId,
|
|
139
|
+
pointerType: e.pointerType,
|
|
140
|
+
button: e.button,
|
|
141
|
+
shiftKey: e.shiftKey,
|
|
142
|
+
ctrlKey: e.ctrlKey,
|
|
143
|
+
altKey: e.altKey,
|
|
144
|
+
metaKey: e.metaKey,
|
|
145
|
+
native: e,
|
|
146
|
+
_stopped: false,
|
|
147
|
+
stopPropagation() {
|
|
148
|
+
event._stopped = true
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
return event
|
|
152
|
+
}
|
|
153
|
+
let topmost = (x: number, y: number): Sprite | null => deps.pick(x, y)[0] ?? null
|
|
154
|
+
// Enter/leave go to one sprite directly, outside the walk: the event
|
|
155
|
+
// in its sprite view.
|
|
156
|
+
let hoverEvent = (sprite: Sprite, x: number, y: number, e: ElementPointerEvent): SpritePointerEvent =>
|
|
157
|
+
makeEvent(sprite, x, y, e) as unknown as SpritePointerEvent
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
onPointerDown(e) {
|
|
161
|
+
let [x, y] = toLayer(e)
|
|
162
|
+
let hit = topmost(x, y)
|
|
163
|
+
// A second pointer landing ends "alone" for every press in flight.
|
|
164
|
+
for (let p of presses.values()) p.alone = false
|
|
165
|
+
let press: Press = { target: hit, claimed: false, cx: e.clientX, cy: e.clientY, moved: false, alone: presses.size === 0 }
|
|
166
|
+
presses.set(e.pointerId, press)
|
|
167
|
+
press.claimed = !walk("onPointerDown", makeEvent(hit, x, y, e), true)
|
|
168
|
+
},
|
|
169
|
+
onPointerMove(e) {
|
|
170
|
+
let [x, y] = toLayer(e)
|
|
171
|
+
let press = presses.get(e.pointerId)
|
|
172
|
+
if (press) {
|
|
173
|
+
if (!press.moved && Math.hypot(e.clientX - press.cx, e.clientY - press.cy) >= TAP_SLOP) press.moved = true
|
|
174
|
+
walk("onPointerMove", makeEvent(press.target, x, y, e), !press.claimed)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
let hit = topmost(x, y)
|
|
178
|
+
let prev = hover.get(e.pointerId) ?? null
|
|
179
|
+
if (prev !== hit) {
|
|
180
|
+
if (prev && prev.layer) prev.onPointerLeave?.(hoverEvent(prev, x, y, e))
|
|
181
|
+
if (hit) hit.onPointerEnter?.(hoverEvent(hit, x, y, e))
|
|
182
|
+
if (hit) hover.set(e.pointerId, hit)
|
|
183
|
+
else hover.delete(e.pointerId)
|
|
184
|
+
}
|
|
185
|
+
walk("onPointerMove", makeEvent(hit, x, y, e), true)
|
|
186
|
+
},
|
|
187
|
+
onPointerUp(e) {
|
|
188
|
+
let [x, y] = toLayer(e)
|
|
189
|
+
let press = presses.get(e.pointerId)
|
|
190
|
+
if (!press) {
|
|
191
|
+
// A press this layer never saw go down: deliver to what is
|
|
192
|
+
// under it, as the element model does.
|
|
193
|
+
walk("onPointerUp", makeEvent(topmost(x, y), x, y, e), true)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
presses.delete(e.pointerId)
|
|
197
|
+
walk("onPointerUp", makeEvent(press.target, x, y, e), !press.claimed)
|
|
198
|
+
// The tap rule: no travel past the slop, alone for the whole
|
|
199
|
+
// press, released over the target it pressed (empty space for the
|
|
200
|
+
// layer itself).
|
|
201
|
+
if (press.moved || !press.alone || topmost(x, y) !== press.target) return
|
|
202
|
+
let time = now()
|
|
203
|
+
let repeat =
|
|
204
|
+
lastTap !== null &&
|
|
205
|
+
time - lastTap.time <= TAP_INTERVAL_MS &&
|
|
206
|
+
lastTap.target === press.target &&
|
|
207
|
+
Math.hypot(e.clientX - lastTap.cx, e.clientY - lastTap.cy) <= TAP_REPEAT_SLOP
|
|
208
|
+
let count = repeat ? lastTap!.count + 1 : 1
|
|
209
|
+
lastTap = { time, cx: e.clientX, cy: e.clientY, target: press.target, count }
|
|
210
|
+
let tap = makeEvent(press.target, x, y, e)
|
|
211
|
+
tap.tapCount = count
|
|
212
|
+
walk("onTap", tap, !press.claimed)
|
|
213
|
+
},
|
|
214
|
+
onWheel(e) {
|
|
215
|
+
let [x, y] = toLayer(e)
|
|
216
|
+
let event = makeEvent(topmost(x, y), x, y, e)
|
|
217
|
+
event.deltaX = e.deltaX
|
|
218
|
+
event.deltaY = e.deltaY
|
|
219
|
+
walk("onWheel", event, true)
|
|
220
|
+
},
|
|
221
|
+
onPointerLeave(e) {
|
|
222
|
+
let [x, y] = toLayer(e)
|
|
223
|
+
let prev = hover.get(e.pointerId)
|
|
224
|
+
if (prev) {
|
|
225
|
+
hover.delete(e.pointerId)
|
|
226
|
+
if (prev.layer) prev.onPointerLeave?.(hoverEvent(prev, x, y, e))
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
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
|
}
|