@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,27 @@
|
|
|
1
|
+
// The auto oversample apply shared by <SpriteLayer> and <TileLayer>: the
|
|
2
|
+
// measured inputs (window texel budget, device limits) around the pure
|
|
3
|
+
// decision in oversample-math.ts.
|
|
4
|
+
import { displayScale, windowSize } from "@solidrt/core"
|
|
5
|
+
import { limits } from "@solidrt/core/gpu"
|
|
6
|
+
import { pickOversample } from "../oversample-math.ts"
|
|
7
|
+
|
|
8
|
+
// The window's device pixel count: the texel budget an auto-picked
|
|
9
|
+
// oversample target stays within (see fitOversample).
|
|
10
|
+
function windowTexels(): number {
|
|
11
|
+
let win = windowSize()
|
|
12
|
+
let scale = displayScale()
|
|
13
|
+
return win.width * scale * (win.height * scale)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The auto-pick apply: the decision itself (cap, shrink hysteresis,
|
|
17
|
+
// validation) is pure and lives in oversample-math.ts.
|
|
18
|
+
export function applyOversample(
|
|
19
|
+
layer: { readonly oversample: number; setOversample(n: number): void },
|
|
20
|
+
scale: number,
|
|
21
|
+
targetW: number,
|
|
22
|
+
targetH: number,
|
|
23
|
+
max: number | undefined,
|
|
24
|
+
): void {
|
|
25
|
+
let n = pickOversample(layer.oversample, scale, targetW, targetH, windowTexels(), limits.maxTextureSize, max)
|
|
26
|
+
if (n !== null) layer.setOversample(n)
|
|
27
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createEffect, merge, onFrame, untrack } from "@solidrt/core"
|
|
2
|
+
import type { InputMap, VoidComponent } from "@solidrt/core"
|
|
3
|
+
import { createCamera2d } from "../camera2d.ts"
|
|
4
|
+
import type { Camera2d as Camera2dHandle, Camera2dAxes, Camera2dOptions } from "../camera2d.ts"
|
|
5
|
+
import { useSpriteLayer } from "./context.ts"
|
|
6
|
+
|
|
7
|
+
export type Camera2dProps = Omit<Camera2dOptions, "viewport"> & {
|
|
8
|
+
/** Viewport in layer pixels; defaults to the driven view's own size
|
|
9
|
+
* (live: a fill layer's box, a setSize, a `<View2d>` resize). */
|
|
10
|
+
viewport?: () => { width: number; height: number }
|
|
11
|
+
/** The input map driving the control (InputMap.drive): its `pan`,
|
|
12
|
+
* `zoom` and `roll` actions, or the ones `actions` names. Live: a new
|
|
13
|
+
* map reconnects. Without one the control moves only through `ref`. */
|
|
14
|
+
input?: InputMap<any>
|
|
15
|
+
/** Action names per axis when the map's differ (null skips an axis). */
|
|
16
|
+
actions?: Partial<Record<keyof Camera2dAxes, string | null>>
|
|
17
|
+
ref?: (camera: Camera2dHandle) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Cap on the camera loop's per-frame dt in seconds, so the first tick
|
|
21
|
+
// after a suspended stretch (a resumed app, a reload) cannot leap a glide.
|
|
22
|
+
const MAX_CAMERA_DT = 0.1
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* createCamera2d as a SpriteLayer child: drives the nearest view's camera
|
|
26
|
+
* - the `<SpriteLayer>`'s own, or inside a `<View2d>` that view
|
|
27
|
+
* (useSpriteLayer's `viewport`) - and takes its input from the map in
|
|
28
|
+
* `input`, nothing else (ARCHITECTURE.md: no device wiring in a
|
|
29
|
+
* component; the app binds the view's pointer feed, a pad or anything
|
|
30
|
+
* else to the map). Under a `<SpriteLayer output={false}>` there is no
|
|
31
|
+
* view to drive: put the `<Camera2d>` inside one of its `<View2d>`
|
|
32
|
+
* children. The pose props (x, y, zoom, rotation) are initial values:
|
|
33
|
+
* change the pose at runtime through `ref`'s set/glideTo/fit/follow and
|
|
34
|
+
* the verbs. Every other prop is live, forwarded to the control as a
|
|
35
|
+
* getter and read where it applies, so a world, a zoom range, a pivot,
|
|
36
|
+
* a dead zone or a rate follows its prop, and a bounds or pivot change
|
|
37
|
+
* re-clamps and pushes the pose at once - the `<OrbitCamera>` rule.
|
|
38
|
+
* `viewport` defaults to the view's own size. Frames run only while the
|
|
39
|
+
* camera moves (`active()`), so a resting camera leaves the app
|
|
40
|
+
* demand-driven idle.
|
|
41
|
+
*/
|
|
42
|
+
export let Camera2d: VoidComponent<Camera2dProps> = props => {
|
|
43
|
+
let target = useSpriteLayer().viewport
|
|
44
|
+
// Through merge, not a spread: a props object hands out getters, and
|
|
45
|
+
// merge keeps them, so the control reads each option live where it
|
|
46
|
+
// applies.
|
|
47
|
+
let options: Camera2dOptions = merge(props, {
|
|
48
|
+
get viewport() {
|
|
49
|
+
return props.viewport ?? (() => ({ width: target.width, height: target.height }))
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
let cam = untrack(() => createCamera2d(target, options))
|
|
53
|
+
createEffect(
|
|
54
|
+
() => props.input,
|
|
55
|
+
input => (input ? input.drive(cam.axes, untrack(() => props.actions)) : undefined),
|
|
56
|
+
)
|
|
57
|
+
createEffect(
|
|
58
|
+
() => cam.active(),
|
|
59
|
+
on => {
|
|
60
|
+
if (!on) return
|
|
61
|
+
let last: number | null = null
|
|
62
|
+
return onFrame(tick => {
|
|
63
|
+
let now = tick / 1000
|
|
64
|
+
let dt = last === null ? 0 : Math.min(now - last, MAX_CAMERA_DT)
|
|
65
|
+
last = now
|
|
66
|
+
cam.update(dt)
|
|
67
|
+
})
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
// A bounds or pivot prop change re-clamps and pushes the pose at once
|
|
71
|
+
// (set({}) applies the clamps). Deferred: the creation already clamped
|
|
72
|
+
// and pushed the initial pose. Untracked: the control reads the options
|
|
73
|
+
// through the getters while it applies them, and the apply wants the
|
|
74
|
+
// values of that moment (the compute above is what tracks them).
|
|
75
|
+
createEffect(
|
|
76
|
+
() => [options.world, options.minZoom, options.maxZoom, options.pivot],
|
|
77
|
+
() => untrack(() => cam.set({})),
|
|
78
|
+
{ defer: true },
|
|
79
|
+
)
|
|
80
|
+
untrack(() => props.ref)?.(cam)
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createContext, useContext } from "@solidrt/core"
|
|
2
|
+
import type { PointerFeed } from "@solidrt/core"
|
|
3
|
+
import type { SpriteGroup, SpriteLayer as LayerHandle } from "../layer.ts"
|
|
4
|
+
import type { ViewHandle } from "../views.ts"
|
|
5
|
+
|
|
6
|
+
export let LayerContext = createContext<LayerHandle>()
|
|
7
|
+
export let GroupContext = createContext<SpriteGroup | null>(null)
|
|
8
|
+
|
|
9
|
+
// The nearest view: the <SpriteLayer>'s own, or the enclosing <View2d>'s;
|
|
10
|
+
// null under a leafless layer (output={false}) until a <View2d>
|
|
11
|
+
// provides one. Null-default on purpose, like GroupContext.
|
|
12
|
+
export let ViewportContext = createContext<ViewHandle | null>(null)
|
|
13
|
+
// The nearest view's pointer feed (its owner's `pointer` prop), null when
|
|
14
|
+
// none was handed in.
|
|
15
|
+
export let PointerContext = createContext<PointerFeed | null>(null)
|
|
16
|
+
|
|
17
|
+
type LayerCtx = { layer: LayerHandle; parent: SpriteGroup | null; readonly viewport: ViewHandle; pointer: PointerFeed | null }
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The enclosing layer, parent group, view and pointer feed - the
|
|
21
|
+
* imperative escape hatch inside a component subtree (throws outside a
|
|
22
|
+
* `<SpriteLayer>`), the same shape as @solidrt/3d's useScene. `parent` is
|
|
23
|
+
* the enclosing `<Group>`'s handle (null at the layer root); pass it on
|
|
24
|
+
* to addSprite/addGroup so imperative sprites mount where the JSX sits.
|
|
25
|
+
* `viewport` is the nearest view: the `<SpriteLayer>`'s own, or inside a
|
|
26
|
+
* `<View2d>` that view - what a `<Camera2d>` drives, and where a custom
|
|
27
|
+
* `output` leaf takes its `handlers`. Reading it under a
|
|
28
|
+
* `<SpriteLayer output={false}>` throws: such a layer shows only through
|
|
29
|
+
* its `<View2d>` children, so controls and leaves go inside one.
|
|
30
|
+
* `pointer` is that view's feed (the owner's `pointer` prop), or null.
|
|
31
|
+
*/
|
|
32
|
+
export function useSpriteLayer(): LayerCtx {
|
|
33
|
+
let layer = useContext(LayerContext)
|
|
34
|
+
let parent = useContext(GroupContext)
|
|
35
|
+
let viewport = useContext(ViewportContext)
|
|
36
|
+
let pointer = useContext(PointerContext)
|
|
37
|
+
return {
|
|
38
|
+
layer,
|
|
39
|
+
parent,
|
|
40
|
+
pointer,
|
|
41
|
+
get viewport() {
|
|
42
|
+
if (viewport === null) {
|
|
43
|
+
throw new Error("useSpriteLayer: no view here - a <SpriteLayer output={false}> shows only through its <View2d> children; put camera controls and leaves inside one")
|
|
44
|
+
}
|
|
45
|
+
return viewport
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
|
|
2
|
+
import type { ParentComponent } from "@solidrt/core"
|
|
3
|
+
import { addGroup, removeGroup, setGroup, setGroupTransition } from "../layer.ts"
|
|
4
|
+
import type { SpriteGroup, SpritePointerEvent, SpriteTapEvent, SpriteWheelEvent, TransitionEndEvent } from "../layer.ts"
|
|
5
|
+
import type { NodeTransition } from "flux:spatial"
|
|
6
|
+
import { GroupContext, LayerContext } from "./context.ts"
|
|
7
|
+
|
|
8
|
+
export type GroupProps = {
|
|
9
|
+
/** Position in the parent frame (layer pixels at the root). */
|
|
10
|
+
x?: number
|
|
11
|
+
y?: number
|
|
12
|
+
/** Rotation, radians, clockwise. */
|
|
13
|
+
rotation?: number
|
|
14
|
+
/** Uniform scale on the whole subtree (child sprites scale with it). */
|
|
15
|
+
scale?: number
|
|
16
|
+
/** Show or hide the whole subtree (default true); see
|
|
17
|
+
* GroupOptions.visible. */
|
|
18
|
+
visible?: boolean
|
|
19
|
+
/** Bubbled from a hit child sprite (see SpritePointerProps); a group
|
|
20
|
+
* never receives enter/leave. */
|
|
21
|
+
onPointerDown?: (event: SpritePointerEvent) => void
|
|
22
|
+
onPointerMove?: (event: SpritePointerEvent) => void
|
|
23
|
+
onPointerUp?: (event: SpritePointerEvent) => void
|
|
24
|
+
onWheel?: (event: SpriteWheelEvent) => void
|
|
25
|
+
onTap?: (event: SpriteTapEvent) => void
|
|
26
|
+
/** How pose-prop changes animate (see setGroupTransition); the mount
|
|
27
|
+
* pose always snaps. */
|
|
28
|
+
transition?: NodeTransition | string | null
|
|
29
|
+
/** A declared transition settled on one component. */
|
|
30
|
+
onTransitionEnd?: (event: TransitionEndEvent) => void
|
|
31
|
+
ref?: (group: SpriteGroup) => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A transform group: `<Sprite>` (and nested `<Group>`) children mount under
|
|
36
|
+
* its spatial arena node, so their pose props read in the group's frame and
|
|
37
|
+
* moving the group moves the subtree in one native recompute - a ship with
|
|
38
|
+
* turrets is one `<Group>` with the hull and turret sprites inside. Renders
|
|
39
|
+
* nothing itself.
|
|
40
|
+
*/
|
|
41
|
+
export let Group: ParentComponent<GroupProps> = props => {
|
|
42
|
+
let layer = useContext(LayerContext)
|
|
43
|
+
let parent = useContext(GroupContext)
|
|
44
|
+
let group = untrack(() => addGroup(layer, { parent }))
|
|
45
|
+
createEffect(
|
|
46
|
+
() => [props.x, props.y, props.rotation, props.scale, props.visible] as const,
|
|
47
|
+
([x, y, rotation, scale, visible]) => setGroup(group, { x, y, rotation, scale, visible: visible !== false }),
|
|
48
|
+
)
|
|
49
|
+
// After the pose effect, so the mount pose snaps before writes animate.
|
|
50
|
+
createEffect(
|
|
51
|
+
() => props.transition,
|
|
52
|
+
transition => setGroupTransition(group, transition ?? null),
|
|
53
|
+
)
|
|
54
|
+
createEffect(
|
|
55
|
+
() => [props.onPointerDown, props.onPointerMove, props.onPointerUp, props.onWheel, props.onTap, props.onTransitionEnd] as const,
|
|
56
|
+
([down, move, up, wheel, tap, end]) => {
|
|
57
|
+
group.onPointerDown = down
|
|
58
|
+
group.onPointerMove = move
|
|
59
|
+
group.onPointerUp = up
|
|
60
|
+
group.onWheel = wheel
|
|
61
|
+
group.onTap = tap
|
|
62
|
+
group.onTransitionEnd = end
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
untrack(() => props.ref)?.(group)
|
|
66
|
+
onCleanup(() => removeGroup(group))
|
|
67
|
+
return <GroupContext value={group}>{props.children}</GroupContext>
|
|
68
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The Solid face: PascalCase components over context, syncing the retained
|
|
2
|
+
// layer (layer.ts) - no new intrinsic elements, no renderer changes. Props
|
|
3
|
+
// follow the Solid 2.0 model (reactive values, no destructuring); effects
|
|
4
|
+
// write into the retained records and the runtime's dirty flush renders.
|
|
5
|
+
// Anything moving at frame rate can bypass the declarative layer: grab the
|
|
6
|
+
// sprite with `ref` and call setSprite from onFrame - signals carry
|
|
7
|
+
// structure and slow state, per-frame motion goes straight to the layer.
|
|
8
|
+
// The same split, with the same reasoning, as @solidrt/3d's components.
|
|
9
|
+
//
|
|
10
|
+
// One file per component, with the shared pieces beside them: context.ts
|
|
11
|
+
// (the layer, group and viewport contexts and useSpriteLayer) and
|
|
12
|
+
// auto-oversample.ts (the measured auto-pick the leaves apply). This index is the package's
|
|
13
|
+
// face: index.ts at the root re-exports it, nothing outside imports the
|
|
14
|
+
// files directly.
|
|
15
|
+
|
|
16
|
+
export { useSpriteLayer } from "./context.ts"
|
|
17
|
+
export { SpriteLayer } from "./sprite-layer.tsx"
|
|
18
|
+
export type { LayerPointerProps, SpriteLayerProps } from "./sprite-layer.tsx"
|
|
19
|
+
export { Group } from "./group.tsx"
|
|
20
|
+
export type { GroupProps } from "./group.tsx"
|
|
21
|
+
export { Sprite } from "./sprite.tsx"
|
|
22
|
+
export type { SpritePointerProps, SpriteProps } from "./sprite.tsx"
|
|
23
|
+
export { Camera2d } from "./camera2d.tsx"
|
|
24
|
+
export type { Camera2dProps } from "./camera2d.tsx"
|
|
25
|
+
export { View2d } from "./view2d.tsx"
|
|
26
|
+
export type { View2dProps } from "./view2d.tsx"
|
|
27
|
+
export { TileLayer } from "./tile-layer.tsx"
|
|
28
|
+
export type { TileCamera, TileLayerProps } from "./tile-layer.tsx"
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { createEffect, displayScale, getBoundingBoxViewport, getLayoutBox, onCleanup, onLayout, untrack } from "@solidrt/core"
|
|
2
|
+
import type { Element, ParentComponent, PointerFeed, TextureId } from "@solidrt/core"
|
|
3
|
+
import { createSpriteLayer } from "../layer.ts"
|
|
4
|
+
import { feedPointer } from "../views.ts"
|
|
5
|
+
import type { LayerPointerEvent, LayerTapEvent, LayerWheelEvent, SpriteLayer as LayerHandle } from "../layer.ts"
|
|
6
|
+
import type { CameraUpdate } from "../camera.ts"
|
|
7
|
+
import type { ViewHandle } from "../views.ts"
|
|
8
|
+
import { LayerContext, PointerContext, ViewportContext } from "./context.ts"
|
|
9
|
+
import { applyOversample } from "./auto-oversample.ts"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A view's own pointer events, the root of the walk: every event arrives
|
|
13
|
+
* after the hit sprite and its Groups (`event.sprite` set) or as the only
|
|
14
|
+
* stop over empty space (`event.sprite` null), unless a handler stopped
|
|
15
|
+
* it. Deselect on a miss, start a marquee, drive a camera - the
|
|
16
|
+
* `<Camera2d>` child listens at the same root.
|
|
17
|
+
*/
|
|
18
|
+
export type LayerPointerProps = {
|
|
19
|
+
onPointerDown?: (event: LayerPointerEvent) => void
|
|
20
|
+
onPointerMove?: (event: LayerPointerEvent) => void
|
|
21
|
+
onPointerUp?: (event: LayerPointerEvent) => void
|
|
22
|
+
onWheel?: (event: LayerWheelEvent) => void
|
|
23
|
+
onTap?: (event: LayerTapEvent) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SpriteLayerProps = LayerPointerProps & {
|
|
27
|
+
/**
|
|
28
|
+
* View pixels of the layer's own view - give both, or neither. Omitted,
|
|
29
|
+
* the view FILLS: the built-in leaf is laid out at 100% of its parent's
|
|
30
|
+
* box (give it a sized parent, as on the web) and the view sizes to that
|
|
31
|
+
* box, so view pixels are the leaf's own coordinates and sprites place
|
|
32
|
+
* in element units - the auto oversample still supplies display density
|
|
33
|
+
* on top. Fill or fixed is decided at mount, and matches @solidrt/3d's
|
|
34
|
+
* `<Scene>` one dimension down. A function `output` needs explicit
|
|
35
|
+
* sizes (the view cannot follow a leaf it does not own); the leaf's own
|
|
36
|
+
* width/height are then layout, so render size and display size
|
|
37
|
+
* separate.
|
|
38
|
+
*/
|
|
39
|
+
width?: number
|
|
40
|
+
height?: number
|
|
41
|
+
/** The atlas texture every sprite samples (create with createAtlas). */
|
|
42
|
+
atlas: TextureId
|
|
43
|
+
/** Initial record reservation (grows on demand); default 1024. */
|
|
44
|
+
capacity?: number
|
|
45
|
+
/** The own view's clear color. */
|
|
46
|
+
clearColor?: [number, number, number, number]
|
|
47
|
+
/** Pan/zoom/rotate the own view over the world (in-shader); a
|
|
48
|
+
* shared-params write, never per-sprite. A `<Camera2d>` child writes
|
|
49
|
+
* the same state - use one form, not both. */
|
|
50
|
+
camera?: CameraUpdate
|
|
51
|
+
/**
|
|
52
|
+
* Layer tint, [r, g, b, a] in 0..1, multiplied over every sprite's own
|
|
53
|
+
* tint in every view (day/night, a dimmed parallax plane, a fade-in).
|
|
54
|
+
* One shared uniform write per view, cheap to animate - unlike
|
|
55
|
+
* TileLayer's, which re-bakes.
|
|
56
|
+
*/
|
|
57
|
+
tint?: [number, number, number, number]
|
|
58
|
+
/**
|
|
59
|
+
* Target texels per view pixel. Absent, the component picks it every
|
|
60
|
+
* layout from the built-in leaf's on-screen size (display scale, any
|
|
61
|
+
* designSize fit, layout scaling), so the view resamples properly at any
|
|
62
|
+
* scale; with a function `output` there is no built-in leaf, so set it
|
|
63
|
+
* yourself.
|
|
64
|
+
*/
|
|
65
|
+
oversample?: number
|
|
66
|
+
/**
|
|
67
|
+
* Cap on the auto-picked oversample (integer >= 1): the view stays
|
|
68
|
+
* adaptive up to it, never above - the lever that bounds target memory on
|
|
69
|
+
* high-scale displays without giving up adaptivity. Ignored when
|
|
70
|
+
* `oversample` is set (explicit already opts out of the pick).
|
|
71
|
+
*/
|
|
72
|
+
maxOversample?: number
|
|
73
|
+
/**
|
|
74
|
+
* Draw sprites in key order instead of slot order, produced by core at
|
|
75
|
+
* every publish: `"y"` orders by world y (back-to-front for a
|
|
76
|
+
* perspective scene, zero JS per frame even under native transitions),
|
|
77
|
+
* `"renderOrder"` by the per-sprite `renderOrder` field (explicit layering:
|
|
78
|
+
* raise-on-drag, click-to-front). Creation-fixed (see
|
|
79
|
+
* SpriteLayerOptions.orderBy).
|
|
80
|
+
*/
|
|
81
|
+
orderBy?: "y" | "renderOrder"
|
|
82
|
+
label?: string
|
|
83
|
+
/** The layer: sprites, tint, pick, createView. */
|
|
84
|
+
ref?: (layer: LayerHandle) => void
|
|
85
|
+
/** The layer's own view (texture, camera, handlers) for imperative use
|
|
86
|
+
* outside the tree; inside, `useSpriteLayer().viewport` is the same.
|
|
87
|
+
* Not called with `output={false}` - there is no own view. */
|
|
88
|
+
viewRef?: (view: ViewHandle) => void
|
|
89
|
+
/**
|
|
90
|
+
* The own view's leaf. Absent: a built-in `<texture>` leaf carrying the
|
|
91
|
+
* view's handlers. A function: compose the leaf yourself - called once
|
|
92
|
+
* (untracked) with the view's texture id, its return renders in place
|
|
93
|
+
* of the built-in leaf; sprite pointer events then need the view's
|
|
94
|
+
* handlers on your leaf: `<texture src={texture}
|
|
95
|
+
* {...useSpriteLayer().viewport.handlers} />`. `false`: NO view of its
|
|
96
|
+
* own - the layer shows only through its `<View2d>` children, each a
|
|
97
|
+
* full view with its own camera (split-screen: two side by side, a
|
|
98
|
+
* game with its minimap and nothing else). The view props (width,
|
|
99
|
+
* height, camera, clearColor, oversample, maxOversample, events,
|
|
100
|
+
* viewRef, the pointer props) then have nothing to apply to and throw.
|
|
101
|
+
*/
|
|
102
|
+
output?: ((texture: TextureId) => Element) | false
|
|
103
|
+
/**
|
|
104
|
+
* Pointer events (default on): the built-in leaf carries the view's
|
|
105
|
+
* handlers, so sprites, groups, the view's own handlers and a
|
|
106
|
+
* `<Camera2d>` child receive input. `false` detaches them - the leaf
|
|
107
|
+
* then costs no pointer routing at all.
|
|
108
|
+
*/
|
|
109
|
+
events?: boolean
|
|
110
|
+
/**
|
|
111
|
+
* The pointer feed of the layer's own view (createPointerFeed), fed
|
|
112
|
+
* from the view's root - the events the sprites let through: a sprite
|
|
113
|
+
* that claims its press keeps a camera bound to the feed out of that
|
|
114
|
+
* drag - so an input map over it drives the `<Camera2d>` child; inside,
|
|
115
|
+
* `useSpriteLayer().pointer` is this feed. Fixed at creation.
|
|
116
|
+
*/
|
|
117
|
+
pointer?: PointerFeed
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Creation size of a fill-mode view: the first onLayout replaces it
|
|
121
|
+
// before the first paint, so it only has to be a valid target size.
|
|
122
|
+
const FILL_INITIAL_SIZE = 1
|
|
123
|
+
|
|
124
|
+
// The props that configure the layer's own view - meaningless, hence
|
|
125
|
+
// rejected, on a layer without one (output={false}).
|
|
126
|
+
const VIEW_PROPS = [
|
|
127
|
+
"width",
|
|
128
|
+
"height",
|
|
129
|
+
"camera",
|
|
130
|
+
"clearColor",
|
|
131
|
+
"oversample",
|
|
132
|
+
"maxOversample",
|
|
133
|
+
"events",
|
|
134
|
+
"pointer",
|
|
135
|
+
"viewRef",
|
|
136
|
+
"onPointerDown",
|
|
137
|
+
"onPointerMove",
|
|
138
|
+
"onPointerUp",
|
|
139
|
+
"onWheel",
|
|
140
|
+
"onTap",
|
|
141
|
+
] as const
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Owns a sprite layer and, unless `output={false}`, its own view,
|
|
145
|
+
* composited as an ordinary `<texture>` leaf so the output takes layout,
|
|
146
|
+
* transforms, blendMode, and pointer events like any element - or hand
|
|
147
|
+
* `output` the texture id and compose it yourself. A layout component:
|
|
148
|
+
* it cannot sit inside a d-* subtree; `output` with a `<d-texture>` is
|
|
149
|
+
* the detached form. Children (`<Sprite>`) render nothing themselves -
|
|
150
|
+
* they populate the retained layer through context; `<View2d>` children
|
|
151
|
+
* are further views of the same sprites.
|
|
152
|
+
*/
|
|
153
|
+
export let SpriteLayer: ParentComponent<SpriteLayerProps> = props => {
|
|
154
|
+
let layer = untrack(() =>
|
|
155
|
+
createSpriteLayer(props.atlas, {
|
|
156
|
+
capacity: props.capacity,
|
|
157
|
+
tint: props.tint,
|
|
158
|
+
orderBy: props.orderBy,
|
|
159
|
+
label: props.label,
|
|
160
|
+
}),
|
|
161
|
+
)
|
|
162
|
+
createEffect(
|
|
163
|
+
() => props.tint,
|
|
164
|
+
tint => {
|
|
165
|
+
if (tint !== undefined) layer.setTint(tint)
|
|
166
|
+
},
|
|
167
|
+
)
|
|
168
|
+
untrack(() => props.ref)?.(layer)
|
|
169
|
+
let output = untrack(() => props.output)
|
|
170
|
+
if (output === false) {
|
|
171
|
+
untrack(() => {
|
|
172
|
+
for (let name of VIEW_PROPS) {
|
|
173
|
+
if (props[name] !== undefined) throw new Error(`SpriteLayer: output={false} has no view of its own - ${name} belongs on a <View2d> child`)
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
return (
|
|
177
|
+
<LayerContext value={layer}>
|
|
178
|
+
<ViewportContext value={null}>{props.children}</ViewportContext>
|
|
179
|
+
</LayerContext>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
// Fill vs fixed view is decided at mount, like `output`: both width
|
|
183
|
+
// and height (fixed view pixels) or neither (fill).
|
|
184
|
+
let fill = untrack(() => {
|
|
185
|
+
if ((props.width === undefined) !== (props.height === undefined)) {
|
|
186
|
+
throw new Error("SpriteLayer: width and height come together - give both (fixed view) or neither (fill)")
|
|
187
|
+
}
|
|
188
|
+
if (props.width === undefined && output) {
|
|
189
|
+
throw new Error("SpriteLayer: fill needs the built-in leaf - with output, give width and height")
|
|
190
|
+
}
|
|
191
|
+
return props.width === undefined
|
|
192
|
+
})
|
|
193
|
+
// The layer's own view: the initial props seed it, the effects follow
|
|
194
|
+
// changes only (`defer`).
|
|
195
|
+
let view = untrack(() =>
|
|
196
|
+
layer.createView({
|
|
197
|
+
width: props.width ?? FILL_INITIAL_SIZE,
|
|
198
|
+
height: props.height ?? FILL_INITIAL_SIZE,
|
|
199
|
+
camera: props.camera,
|
|
200
|
+
oversample: props.oversample,
|
|
201
|
+
clearColor: props.clearColor,
|
|
202
|
+
label: props.label,
|
|
203
|
+
}),
|
|
204
|
+
)
|
|
205
|
+
createEffect(
|
|
206
|
+
() => [props.width, props.height] as const,
|
|
207
|
+
([w, h]) => {
|
|
208
|
+
if ((w === undefined) !== (h === undefined) || (w === undefined) !== fill) {
|
|
209
|
+
throw new Error("SpriteLayer: fill/fixed is mount-fixed - width/height cannot appear or disappear")
|
|
210
|
+
}
|
|
211
|
+
if (!fill) view.setSize(w!, h!)
|
|
212
|
+
},
|
|
213
|
+
{ defer: true },
|
|
214
|
+
)
|
|
215
|
+
createEffect(
|
|
216
|
+
() => props.camera,
|
|
217
|
+
camera => {
|
|
218
|
+
if (camera) view.setCamera(camera)
|
|
219
|
+
},
|
|
220
|
+
{ defer: true },
|
|
221
|
+
)
|
|
222
|
+
createEffect(
|
|
223
|
+
() => props.oversample,
|
|
224
|
+
n => {
|
|
225
|
+
if (n !== undefined) view.setOversample(n)
|
|
226
|
+
},
|
|
227
|
+
{ defer: true },
|
|
228
|
+
)
|
|
229
|
+
untrack(() => props.viewRef)?.(view)
|
|
230
|
+
let pointer = untrack(() => props.pointer) ?? null
|
|
231
|
+
if (pointer) onCleanup(feedPointer(view, pointer))
|
|
232
|
+
// The view's own handlers at the root of the walk; the props are read
|
|
233
|
+
// per event, so a handler prop may change without re-registering.
|
|
234
|
+
onCleanup(
|
|
235
|
+
view.listen({
|
|
236
|
+
onPointerDown: e => props.onPointerDown?.(e),
|
|
237
|
+
onPointerMove: e => props.onPointerMove?.(e),
|
|
238
|
+
onPointerUp: e => props.onPointerUp?.(e),
|
|
239
|
+
onWheel: e => props.onWheel?.(e),
|
|
240
|
+
onTap: e => props.onTap?.(e),
|
|
241
|
+
}),
|
|
242
|
+
)
|
|
243
|
+
let events = untrack(() => props.events) !== false
|
|
244
|
+
let leaf: { id: number } | undefined
|
|
245
|
+
// The view's pixel size: the props in fixed mode, the built-in leaf's
|
|
246
|
+
// laid-out box in fill mode (getLayoutBox, the untransformed read, so a
|
|
247
|
+
// designSize fit or ancestor transform never enters the world space -
|
|
248
|
+
// display density rides on the oversample pick below). Null before the
|
|
249
|
+
// first layout.
|
|
250
|
+
let viewSize = (): { width: number; height: number } | null => {
|
|
251
|
+
if (!fill) return { width: props.width!, height: props.height! }
|
|
252
|
+
let box = leaf && getLayoutBox(leaf)
|
|
253
|
+
if (!box || box.width <= 0 || box.height <= 0) return null
|
|
254
|
+
return { width: Math.max(1, Math.round(box.width)), height: Math.max(1, Math.round(box.height)) }
|
|
255
|
+
}
|
|
256
|
+
// Fill: the view follows the leaf's box. Registered before the pick, so
|
|
257
|
+
// one onLayout applies size then oversample in order; setSize no-ops
|
|
258
|
+
// when nothing changed.
|
|
259
|
+
if (fill) {
|
|
260
|
+
onLayout(() => {
|
|
261
|
+
let size = viewSize()
|
|
262
|
+
if (size) view.setSize(size.width, size.height)
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
// Auto oversample: the built-in leaf's window box, in device pixels, per
|
|
266
|
+
// view pixel. Picked after every layout, and again when the display scale
|
|
267
|
+
// changes: the first layout runs before the resize event that reports the
|
|
268
|
+
// scale, and a scale change alone lays nothing out. pick runs as an
|
|
269
|
+
// onLayout handler and as an effect apply, both untracked scopes, so its
|
|
270
|
+
// prop reads are wrapped in an explicit untrack.
|
|
271
|
+
let pick = () =>
|
|
272
|
+
untrack(() => {
|
|
273
|
+
if (!leaf || props.oversample !== undefined) return
|
|
274
|
+
let box = getBoundingBoxViewport(leaf)
|
|
275
|
+
let size = viewSize()
|
|
276
|
+
if (!box || !size) return
|
|
277
|
+
let scale = displayScale() * Math.max(box.width / size.width, box.height / size.height)
|
|
278
|
+
applyOversample(view, scale, size.width, size.height, props.maxOversample)
|
|
279
|
+
})
|
|
280
|
+
onLayout(pick)
|
|
281
|
+
createEffect(() => [displayScale(), props.maxOversample], pick)
|
|
282
|
+
// Sprite events on the built-in leaf: at view size the plain handlers,
|
|
283
|
+
// in fill mode scaled from the laid-out box (the box can be fractional,
|
|
284
|
+
// the view size never is).
|
|
285
|
+
let viewHandlers = fill
|
|
286
|
+
? view.handlersFor(() => (leaf && getLayoutBox(leaf)) || { width: 0, height: 0 })
|
|
287
|
+
: view.handlers
|
|
288
|
+
return (
|
|
289
|
+
<LayerContext value={layer}>
|
|
290
|
+
<ViewportContext value={view}>
|
|
291
|
+
<PointerContext value={pointer}>
|
|
292
|
+
{output ? (
|
|
293
|
+
untrack(() => output(view.texture))
|
|
294
|
+
) : (
|
|
295
|
+
<texture
|
|
296
|
+
ref={(n: { id: number }) => (leaf = n)}
|
|
297
|
+
src={view.texture}
|
|
298
|
+
width={fill ? "100%" : props.width}
|
|
299
|
+
height={fill ? "100%" : props.height}
|
|
300
|
+
onPointerDown={events ? viewHandlers.onPointerDown : undefined}
|
|
301
|
+
onPointerMove={events ? viewHandlers.onPointerMove : undefined}
|
|
302
|
+
onPointerUp={events ? viewHandlers.onPointerUp : undefined}
|
|
303
|
+
onPointerLeave={events ? viewHandlers.onPointerLeave : undefined}
|
|
304
|
+
onWheel={events ? viewHandlers.onWheel : undefined}
|
|
305
|
+
/>
|
|
306
|
+
)}
|
|
307
|
+
{props.children}
|
|
308
|
+
</PointerContext>
|
|
309
|
+
</ViewportContext>
|
|
310
|
+
</LayerContext>
|
|
311
|
+
)
|
|
312
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
|
|
2
|
+
import type { VoidComponent } from "@solidrt/core"
|
|
3
|
+
import { addSprite, removeSprite, setSprite, setSpriteTransition } from "../layer.ts"
|
|
4
|
+
import type { Sprite as SpriteHandle, SpriteOptions, SpritePointerEvent, SpriteTapEvent, SpriteWheelEvent, TransitionEndEvent } from "../layer.ts"
|
|
5
|
+
import type { NodeTransition } from "flux:spatial"
|
|
6
|
+
import { GroupContext, LayerContext } from "./context.ts"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Sprite pointer events, the element vocabulary one tree deeper: the
|
|
10
|
+
* topmost hit sprite receives the event, down/move/up/wheel/tap bubble to
|
|
11
|
+
* enclosing Groups and end at the `<SpriteLayer>` (stopPropagation stops
|
|
12
|
+
* the walk; stopping a down claims the whole press), enter/leave pair on
|
|
13
|
+
* the sprite alone. Events flow while the element showing the layer
|
|
14
|
+
* carries layer.handlers - the built-in `<SpriteLayer>` leaf does (opt
|
|
15
|
+
* out with events={false}); an `output` leaf spreads them itself.
|
|
16
|
+
*/
|
|
17
|
+
export type SpritePointerProps = {
|
|
18
|
+
onPointerDown?: (event: SpritePointerEvent) => void
|
|
19
|
+
onPointerMove?: (event: SpritePointerEvent) => void
|
|
20
|
+
onPointerUp?: (event: SpritePointerEvent) => void
|
|
21
|
+
/** Sprites only: a Group never receives enter/leave. */
|
|
22
|
+
onPointerEnter?: (event: SpritePointerEvent) => void
|
|
23
|
+
onPointerLeave?: (event: SpritePointerEvent) => void
|
|
24
|
+
onWheel?: (event: SpriteWheelEvent) => void
|
|
25
|
+
/** A press released on the sprite without dragging; `tapCount` counts
|
|
26
|
+
* repeats (2 = double tap). */
|
|
27
|
+
onTap?: (event: SpriteTapEvent) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type SpriteProps = SpriteOptions &
|
|
31
|
+
SpritePointerProps & {
|
|
32
|
+
/** How pose-prop changes animate (see setSpriteTransition); the mount
|
|
33
|
+
* pose always snaps. */
|
|
34
|
+
transition?: NodeTransition | string | null
|
|
35
|
+
/** A declared transition settled on one component. */
|
|
36
|
+
onTransitionEnd?: (event: TransitionEndEvent) => void
|
|
37
|
+
ref?: (sprite: SpriteHandle) => void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One sprite: a frame drawn at a position (in the enclosing `<Group>`'s
|
|
41
|
+
* frame when there is one). */
|
|
42
|
+
export let Sprite: VoidComponent<SpriteProps> = props => {
|
|
43
|
+
let layer = useContext(LayerContext)
|
|
44
|
+
let parent = useContext(GroupContext)
|
|
45
|
+
let sprite = untrack(() => addSprite(layer, { parent }))
|
|
46
|
+
createEffect(
|
|
47
|
+
() => [props.x, props.y, props.w, props.h, props.frame, props.flipX, props.flipY, props.rotation, props.tint, props.renderOrder, props.visible] as const,
|
|
48
|
+
([x, y, w, h, frame, flipX, flipY, rotation, tint, renderOrder, visible]) =>
|
|
49
|
+
setSprite(sprite, { x, y, w, h, frame, flipX, flipY, rotation, tint, renderOrder, visible: visible !== false }),
|
|
50
|
+
)
|
|
51
|
+
// After the pose effect, so the mount pose snaps before writes animate.
|
|
52
|
+
createEffect(
|
|
53
|
+
() => props.transition,
|
|
54
|
+
transition => setSpriteTransition(sprite, transition ?? null),
|
|
55
|
+
)
|
|
56
|
+
createEffect(
|
|
57
|
+
() => [props.onPointerDown, props.onPointerMove, props.onPointerUp, props.onPointerEnter, props.onPointerLeave, props.onWheel, props.onTap, props.onTransitionEnd] as const,
|
|
58
|
+
([down, move, up, enter, leave, wheel, tap, end]) => {
|
|
59
|
+
sprite.onPointerDown = down
|
|
60
|
+
sprite.onPointerMove = move
|
|
61
|
+
sprite.onPointerUp = up
|
|
62
|
+
sprite.onPointerEnter = enter
|
|
63
|
+
sprite.onPointerLeave = leave
|
|
64
|
+
sprite.onWheel = wheel
|
|
65
|
+
sprite.onTap = tap
|
|
66
|
+
sprite.onTransitionEnd = end
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
untrack(() => props.ref)?.(sprite)
|
|
70
|
+
onCleanup(() => removeSprite(sprite))
|
|
71
|
+
return null
|
|
72
|
+
}
|