@solidrt/2d 0.0.55 → 0.0.56

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,231 @@
1
+ // Pointer dispatch behind layer.handlers, shared by both layer kinds: the
2
+ // element event model one tree deeper, with the LAYER 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, SpriteLayer, SpritePointerEvent } from "./layer.ts"
26
+ import type { RecordLayer } from "./records.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 layer: the walk's root and the listeners' currentTarget. */
47
+ root: SpriteLayer | RecordLayer
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 | SpriteLayer | RecordLayer
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/index.ts CHANGED
@@ -12,18 +12,43 @@
12
12
  // without Solid components) and the components (SpriteLayer/Sprite/Group/
13
13
  // TileLayer) on top. See AGENTS.md for the model and the traps.
14
14
 
15
- export { addGroup, addSprite, createSpriteLayer, getSprite, removeGroup, removeSprite, setGroup, setGroupTransition, setSprite, setSpriteParent, setSpriteTransition, POSE_FLOATS, STYLE_FLOATS } from "./layer.ts"
15
+ export { addGroup, addSprite, createSpriteLayer, getSprite, removeGroup, removeSprite, setGroup, setGroupTransition, setSprite, setSpriteParent, setSpriteTransition, worldPosition, POSE_FLOATS, STYLE_FLOATS } from "./layer.ts"
16
16
  export { createRecordLayer, FLOATS_PER_SPRITE } from "./records.ts"
17
- export type { RecordLayer as RecordLayerHandle } from "./records.ts"
17
+ export type { RecordLayer as RecordLayerHandle, RecordLayerOptions } from "./records.ts"
18
18
  export { pointInSprite } from "./pick.ts"
19
- export type { AddSpriteOptions, CameraUpdate, GroupOptions, Sprite as SpriteHandle, SpriteGroup, SpriteHandlers, SpriteLayer as SpriteLayerHandle, SpriteLayerOptions, SpriteOptions, SpritePointerEvent, TransitionEndEvent } from "./layer.ts"
19
+ export { projectCamera, unprojectCamera } from "./camera.ts"
20
+ export type { CameraState, CameraUpdate } from "./camera.ts"
21
+ export { createCamera2d } from "./camera2d.ts"
22
+ export type { Camera2d as Camera2dHandle, Camera2dOptions } from "./camera2d.ts"
23
+ export type { Camera2dPose, Camera2dTarget, Rect2d } from "./camera-motion.ts"
24
+ export type {
25
+ AddSpriteOptions,
26
+ GroupOptions,
27
+ LayerPointerEvent,
28
+ LayerPointerListener,
29
+ LayerTapEvent,
30
+ LayerWheelEvent,
31
+ Sprite as SpriteHandle,
32
+ SpriteGroup,
33
+ SpriteHandlers,
34
+ SpriteLayer as SpriteLayerHandle,
35
+ SpriteLayerOptions,
36
+ SpriteOptions,
37
+ SpritePointerEvent,
38
+ SpriteTapEvent,
39
+ SpriteWheelEvent,
40
+ TransitionEndEvent,
41
+ } from "./layer.ts"
20
42
  export { createTileLayer } from "./tiles.ts"
21
43
  export type { TileChunk, TileLayer as TileLayerHandle, TileLayerOptions } from "./tiles.ts"
22
44
 
23
45
  export type { NodeTransition, NodeTransitionSpec } from "flux:spatial"
24
46
  export { grid, namedFrames, FULL_FRAME } from "./frames.ts"
25
47
  export type { Frame, GridOptions } from "./frames.ts"
48
+ export { createAnimation } from "./animation.ts"
49
+ export type { AnimationOptions, SpriteAnimation } from "./animation.ts"
50
+ export { fitOversample } from "./oversample.ts"
26
51
  export { createAtlas } from "./atlas.ts"
27
52
  export type { Atlas, AtlasOptions } from "./atlas.ts"
28
- export { Group, Sprite, SpriteLayer, TileLayer, useSpriteLayer } from "./components.tsx"
29
- export type { GroupProps, SpriteLayerProps, SpritePointerProps, SpriteProps, TileCamera, TileLayerProps } from "./components.tsx"
53
+ export { Camera2d, Group, Sprite, SpriteLayer, TileLayer, useSpriteLayer } from "./components.tsx"
54
+ export type { Camera2dProps, GroupProps, LayerPointerProps, SpriteLayerProps, SpritePointerProps, SpriteProps, TileCamera, TileLayerProps } from "./components.tsx"