@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.
@@ -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
+ }
@@ -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: GridOptions): Frame[] {
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, spacing = 0, marginX = 0, marginY = 0 } = opts
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 cellW = opts.cellW ?? (width - marginX * 2 - spacing * (cols - 1)) / cols
63
- let cellH = opts.cellH ?? (height - marginY * 2 - spacing * (rows - 1)) / rows
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({ u0: x / width, v0: y / height, u1: (x + cellW) / width, v1: (y + cellH) / height })
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
- atlasW: number,
85
- atlasH: number,
131
+ atlas: AtlasSize,
86
132
  rects: Record<K, [number, number, number, number]>,
133
+ opts?: NamedFramesOptions,
87
134
  ): Record<K, Frame> {
88
- if (!(atlasW > 0 && atlasH > 0)) {
89
- throw new Error(`namedFrames: atlas size must be positive, got ${atlasW} x ${atlasH}`)
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] = { u0: x / atlasW, v0: y / atlasH, u1: (x + w) / atlasW, v1: (y + h) / atlasH }
144
+ out[name] = frameOf(atlas, x, y, w, h, inset, `namedFrames: frame '${name}'`)
96
145
  }
97
146
  return out
98
147
  }