@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/2d",
3
- "version": "0.0.55",
3
+ "version": "0.0.57",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -15,7 +15,7 @@
15
15
  "AGENTS.md"
16
16
  ],
17
17
  "peerDependencies": {
18
- "@solidjs/signals": "2.0.0-rc.4",
19
- "@solidrt/core": "0.0.55"
18
+ "@solidjs/signals": "2.0.0-rc.6",
19
+ "@solidrt/core": "0.0.57"
20
20
  }
21
21
  }
@@ -0,0 +1,178 @@
1
+ // Sprite frame animation: a clip (frames + fps) with a shared clock that
2
+ // steps every attached sprite's frame through setSprite. One timer per
3
+ // playing clip and one frame write per attached sprite per STEP (not per
4
+ // display frame), so an 8fps walk cycle generates 8 publishes a second and
5
+ // a paused clip costs nothing - the demand-gate story unchanged. The clip
6
+ // does not own its sprites (removeSprite prunes lazily on the next step),
7
+ // and a sprite belongs to at most one animation. Plain JS over setSprite,
8
+ // so it works on both layer kinds and composes with <Sprite> via ref (leave
9
+ // the frame prop off - the clip owns that field).
10
+ import { getOwner, onCleanup } from "@solidrt/core"
11
+ import type { Frame } from "./frames.ts"
12
+ import type { Sprite } from "./layer.ts"
13
+ import { setSprite } from "./layer.ts"
14
+
15
+ // Timer period as a fraction of the clip's frame duration: the step index
16
+ // is computed from the wall clock (drift-free), so the timer only decides
17
+ // how late after a frame boundary the step lands - half a frame bounds
18
+ // that lateness at half a frame, where a full-frame period could show a
19
+ // frame almost a whole frame late.
20
+ const TICK_RATIO = 0.5
21
+
22
+ export type AnimationOptions = {
23
+ /** Wrap around (default) or play once and hold the last frame. */
24
+ loop?: boolean
25
+ /** Skip the owner-scoped auto-dispose (the core resource contract). */
26
+ autoFree?: boolean
27
+ }
28
+
29
+ export type SpriteAnimation = {
30
+ /** Current frame index into the clip. */
31
+ readonly frame: number
32
+ /** True while the clock advances: born playing, false after pause() and
33
+ * after a one-shot ends. */
34
+ readonly playing: boolean
35
+ /**
36
+ * Attach a sprite: it shows the clip's current frame immediately and
37
+ * steps with the shared clock (every attached sprite shows the SAME
38
+ * index). A sprite belongs to at most one animation - attaching detaches
39
+ * it from its previous one.
40
+ */
41
+ add(sprite: Sprite): void
42
+ /** Detach; the sprite keeps the frame it is showing. */
43
+ remove(sprite: Sprite): void
44
+ /** Resume a paused clip (phase kept), or replay a finished one-shot
45
+ * from frame 0. */
46
+ play(): void
47
+ /** Freeze the clock; attached sprites hold the current frame. */
48
+ pause(): void
49
+ /** One-shot clips (loop: false): the last frame finished its display
50
+ * time. Fires once per play-through, never on pause or dispose. */
51
+ onFinish?: () => void
52
+ /** Stop the clock and detach every sprite. Owner-scoped like a layer
53
+ * (opt out with autoFree: false). */
54
+ dispose(): void
55
+ }
56
+
57
+ // A sprite's current animation - add() moves the sprite here, so two clips
58
+ // never fight over one sprite's frame field.
59
+ let attachedTo = new WeakMap<Sprite, SpriteAnimation>()
60
+
61
+ /**
62
+ * A frame animation clip: `frames` (usually a slice of a `grid()` result)
63
+ * played at `fps`. Sprites attach with `add`; the clip steps their `frame`
64
+ * on its own wall-clock timer, independent of display rate.
65
+ */
66
+ export function createAnimation(frames: Frame[], fps: number, opts?: AnimationOptions): SpriteAnimation {
67
+ if (frames.length === 0) throw new Error("createAnimation: frames is empty")
68
+ if (!(Number.isFinite(fps) && fps > 0)) {
69
+ throw new Error(`createAnimation: fps must be positive, got ${fps}`)
70
+ }
71
+ let loop = opts?.loop !== false
72
+ let sprites = new Set<Sprite>()
73
+ let current = 0
74
+ let playing = true
75
+ let ended = false
76
+ let disposed = false
77
+ let timer: number | null = null
78
+ let elapsedBase = 0 // ms of clip time accumulated before the running stretch
79
+ let runStart = 0 // performance.now() when the clock last started
80
+
81
+ function startClock(): void {
82
+ if (timer !== null || !playing || sprites.size === 0) return
83
+ // A looping single-frame clip never changes; a one-shot still needs
84
+ // the clock once, to end.
85
+ if (loop && frames.length === 1) return
86
+ runStart = performance.now()
87
+ timer = setInterval(tick, (1000 / fps) * TICK_RATIO)
88
+ }
89
+
90
+ function stopClock(): void {
91
+ if (timer === null) return
92
+ elapsedBase += performance.now() - runStart
93
+ clearInterval(timer)
94
+ timer = null
95
+ }
96
+
97
+ function writeAll(): void {
98
+ let frame = frames[current]!
99
+ for (let sprite of sprites) {
100
+ if (sprite.layer === null) {
101
+ // Removed sprite: prune (deleting during for..of is safe on a Set).
102
+ sprites.delete(sprite)
103
+ attachedTo.delete(sprite)
104
+ } else setSprite(sprite, { frame })
105
+ }
106
+ if (sprites.size === 0) stopClock()
107
+ }
108
+
109
+ function setIndex(idx: number): void {
110
+ if (idx === current) return
111
+ current = idx
112
+ writeAll()
113
+ }
114
+
115
+ function tick(): void {
116
+ let raw = Math.floor(((elapsedBase + performance.now() - runStart) * fps) / 1000)
117
+ if (!loop && raw >= frames.length) {
118
+ // The last frame has shown for its full duration: hold it and stop.
119
+ setIndex(frames.length - 1)
120
+ stopClock()
121
+ playing = false
122
+ ended = true
123
+ handle.onFinish?.()
124
+ return
125
+ }
126
+ setIndex(loop ? raw % frames.length : Math.min(raw, frames.length - 1))
127
+ }
128
+
129
+ let handle: SpriteAnimation = {
130
+ get frame() {
131
+ return current
132
+ },
133
+ get playing() {
134
+ return playing
135
+ },
136
+ onFinish: undefined,
137
+ add(sprite) {
138
+ if (disposed) throw new Error("createAnimation: add on a disposed animation")
139
+ attachedTo.get(sprite)?.remove(sprite)
140
+ attachedTo.set(sprite, handle)
141
+ sprites.add(sprite)
142
+ setSprite(sprite, { frame: frames[current]! })
143
+ startClock()
144
+ },
145
+ remove(sprite) {
146
+ if (!sprites.delete(sprite)) return
147
+ attachedTo.delete(sprite)
148
+ if (sprites.size === 0) stopClock()
149
+ },
150
+ play() {
151
+ if (disposed || playing) return
152
+ if (ended) {
153
+ ended = false
154
+ elapsedBase = 0
155
+ current = 0
156
+ writeAll()
157
+ }
158
+ playing = true
159
+ startClock()
160
+ },
161
+ pause() {
162
+ if (!playing) return
163
+ stopClock()
164
+ playing = false
165
+ },
166
+ dispose() {
167
+ if (disposed) return
168
+ stopClock()
169
+ playing = false
170
+ disposed = true
171
+ for (let sprite of sprites) attachedTo.delete(sprite)
172
+ sprites.clear()
173
+ },
174
+ }
175
+
176
+ if (opts?.autoFree !== false && getOwner()) onCleanup(() => handle.dispose())
177
+ return handle
178
+ }
package/src/atlas.ts CHANGED
@@ -1,52 +1,54 @@
1
- // Atlas creation: encoded image bytes to a GPU texture plus its frame table.
2
- // Thin by design - the frame math lives in frames.ts (pure, checkable) and
3
- // the texture is an ordinary core texture. createImage is NOT used here: it
4
- // never forwards sampler options, and pixel-art atlases want
5
- // `filter: "nearest"` (render small, display big with hard pixels).
6
- import { createTexture, decodeImage } from "@solidrt/core"
7
- import type { TextureId } from "@solidrt/core/gpu"
8
- import type { Frame } from "./frames.ts"
9
- import { grid } from "./frames.ts"
10
- import type { GridOptions } from "./frames.ts"
1
+ // Atlas creation: decoded pixels to a GPU texture plus the pixel size the
2
+ // slicers measure in. Thin by design - the frame math lives in frames.ts
3
+ // (pure, checkable) and the texture is an ordinary core texture. createImage
4
+ // is NOT used here: it never forwards sampler options, and pixel-art atlases
5
+ // want `filter: "nearest"` (render small, display big with hard pixels).
6
+ import { createTexture } from "@solidrt/core"
7
+ import type { DecodedImage } from "@solidrt/core"
8
+ import type { SamplerOptions, TextureId } from "@solidrt/core/gpu"
11
9
 
10
+ /**
11
+ * A texture with its pixel size: what `grid` and `namedFrames` slice, and
12
+ * whose `texture` a layer samples. A plain record, so a texture that did not
13
+ * come through `createAtlas` - a render target, a camera frame - is an atlas
14
+ * as a literal: `{ texture, width, height }`.
15
+ */
12
16
  export type Atlas = {
13
17
  /** The atlas texture, sampled by every sprite in layers created over it. */
14
18
  texture: TextureId
19
+ /** Pixel size, the space the slicers measure rects and gaps in. */
15
20
  width: number
16
21
  height: number
17
- /** Frames in cell order when created via the grid option, else empty. */
18
- frames: Frame[]
19
22
  }
20
23
 
21
- export type AtlasOptions = {
22
- /**
23
- * Sampling: "nearest" is the pixel-art path (hard pixels at any scale),
24
- * "linear" (default) the photographic one. Fixed at creation, like every
25
- * core texture.
26
- */
27
- filter?: "nearest" | "linear"
28
- /** Slice a uniform sheet at creation: cols x rows of equal cells. */
29
- grid?: { cols: number; rows: number } & Omit<GridOptions, "width" | "height">
24
+ /**
25
+ * Sampling is core's `SamplerOptions`, fixed at creation like every core
26
+ * texture: `filter` ("nearest" is the pixel-art path with hard pixels at any
27
+ * scale, "linear" the default photographic one), `wrap`, `mipmap` (a chain
28
+ * for atlases drawn far below their texel size) and `anisotropy`.
29
+ */
30
+ export type AtlasOptions = SamplerOptions & {
30
31
  label?: string
31
32
  /** Skip the owner-scoped auto-free (the core createTexture contract). */
32
33
  autoFree?: boolean
33
34
  }
34
35
 
35
36
  /**
36
- * Decode encoded image bytes (PNG, JPEG, ...) into an atlas texture. Bytes
37
- * come from `import sheet from "./sheet.png" with { type: "binary" }` or
38
- * `await file("assets/sheet.png").bytes()`. Freed with the owning reactive
39
- * scope like any core texture (opt out with `{ autoFree: false }`).
37
+ * Upload decoded pixels as an atlas texture. `image` is a core
38
+ * `DecodedImage` (`{ data, width, height }`, premultiplied RGBA8): an
39
+ * encoded sheet goes through `decodeImage` first -
40
+ * `createAtlas(decodeImage(sheet), { filter: "nearest" })`, with `sheet`
41
+ * from `import sheet from "./sheet.png" with { type: "binary" }` or
42
+ * `await file("assets/sheet.png").bytes()` - and pixels built in code pass
43
+ * as they are. Freed with the owning reactive scope like any core texture
44
+ * (opt out with `{ autoFree: false }`).
40
45
  */
41
- export function createAtlas(bytes: Uint8Array, opts?: AtlasOptions): Atlas {
42
- let decoded = decodeImage(bytes)
43
- let texture = createTexture(decoded.data, decoded.width, decoded.height, {
46
+ export function createAtlas(image: DecodedImage, opts?: AtlasOptions): Atlas {
47
+ let { data, width, height } = image
48
+ let texture = createTexture(data, width, height, {
49
+ ...opts,
44
50
  filter: opts?.filter ?? "linear",
45
51
  label: opts?.label ?? "atlas",
46
- autoFree: opts?.autoFree,
47
52
  })
48
- let frames = opts?.grid
49
- ? grid(opts.grid.cols, opts.grid.rows, { ...opts.grid, width: decoded.width, height: decoded.height })
50
- : []
51
- return { texture, width: decoded.width, height: decoded.height, frames }
53
+ return { texture, width, height }
52
54
  }
package/src/camera.ts ADDED
@@ -0,0 +1,111 @@
1
+ // The 2d camera vocabulary, pure: the one camera type both layers share
2
+ // and its world <-> screen mapping as plain functions - no GPU or GUI
3
+ // imports, so the checks rig (checks/camera-check.ts) exercises this
4
+ // module headless on the flux binary. Three consumers implement or undo
5
+ // exactly this mapping and must agree with it: the vertex stages
6
+ // (shaders.ts, uCamera + uCameraRot), the tile layer's composite
7
+ // transform (the <view> props in components/tile-layer.tsx) and the pointer inverse
8
+ // (spriteDispatch in layer.ts, which calls unprojectCamera directly). The
9
+ // check keeps the spellings from drifting.
10
+
11
+ /**
12
+ * A camera over a 2d world - the sprite layer's `setCamera`/`camera` prop
13
+ * and the tile layer's `TileCamera` alike. The world point (`x`, `y`) is
14
+ * shown at the viewport point (`pivotX`, `pivotY`), the world scaled by
15
+ * `zoom` and rotated by `rotation` ABOUT that pivot. Rotation turns the
16
+ * world clockwise (y-down) about the pivot; to render a heading `h`
17
+ * upward use `rotation = -h - Math.PI / 2`. The pivot defaults to (0, 0),
18
+ * which makes `{ x, y, zoom }` mean "world x/y at the viewport top-left" -
19
+ * the plain scrolling camera.
20
+ *
21
+ * As a `setCamera` update, absent keys keep their values (the params
22
+ * rule); as an argument to projectCamera/unprojectCamera, absent keys
23
+ * read as the defaults (x/y/rotation 0, zoom 1, pivot (0, 0)).
24
+ */
25
+ export type CameraUpdate = {
26
+ /** World pixel shown at the pivot (the viewport top-left by default). */
27
+ x?: number
28
+ y?: number
29
+ /** World-to-screen scale; 1 is pixel-for-pixel. */
30
+ zoom?: number
31
+ /** Rotation, radians, clockwise (y-down), about the pivot. */
32
+ rotation?: number
33
+ /** Viewport point the camera's world point pins to; default (0, 0). */
34
+ pivotX?: number
35
+ pivotY?: number
36
+ }
37
+
38
+ /** A camera snapshot (a layer's `camera()`): CameraUpdate's fields, all
39
+ * present - the argument for projectCamera/unprojectCamera. */
40
+ export type CameraState = Required<CameraUpdate>
41
+
42
+ const CAMERA_FIELDS = ["x", "y", "zoom", "rotation", "pivotX", "pivotY"] as const
43
+
44
+ /**
45
+ * Validate a setCamera update (throws - the dev validation policy): every
46
+ * present field must be finite, zoom additionally positive. A NaN rotation
47
+ * or pivot would otherwise poison the uniforms and blank the layer with no
48
+ * error. Internal - both layer kinds call it at the top of setCamera.
49
+ */
50
+ export function checkCamera(update: CameraUpdate): void {
51
+ for (let key of CAMERA_FIELDS) {
52
+ let value = update[key]
53
+ if (value !== undefined && !Number.isFinite(value)) {
54
+ throw new Error(`setCamera: ${key} must be a finite number, got ${value}`)
55
+ }
56
+ }
57
+ if (update.zoom !== undefined && update.zoom <= 0) {
58
+ throw new Error(`setCamera: zoom must be positive, got ${update.zoom}`)
59
+ }
60
+ }
61
+
62
+ /**
63
+ * World -> screen under a camera:
64
+ * `pivot + R(rotation) * zoom * (world - cam)`. Screen means the layer's
65
+ * viewport pixels (the sprite layer's output, the tile world's container).
66
+ */
67
+ export function projectCamera(camera: CameraUpdate, worldX: number, worldY: number): [number, number] {
68
+ let zoom = camera.zoom ?? 1
69
+ let rotation = camera.rotation ?? 0
70
+ let c = Math.cos(rotation)
71
+ let s = Math.sin(rotation)
72
+ let dx = (worldX - (camera.x ?? 0)) * zoom
73
+ let dy = (worldY - (camera.y ?? 0)) * zoom
74
+ return [(camera.pivotX ?? 0) + dx * c - dy * s, (camera.pivotY ?? 0) + dx * s + dy * c]
75
+ }
76
+
77
+ /** Screen -> world: the exact inverse of projectCamera. */
78
+ export function unprojectCamera(camera: CameraUpdate, screenX: number, screenY: number): [number, number] {
79
+ let zoom = camera.zoom ?? 1
80
+ let rotation = camera.rotation ?? 0
81
+ let c = Math.cos(rotation)
82
+ let s = Math.sin(rotation)
83
+ let dx = screenX - (camera.pivotX ?? 0)
84
+ let dy = screenY - (camera.pivotY ?? 0)
85
+ return [(camera.x ?? 0) + (dx * c + dy * s) / zoom, (camera.y ?? 0) + (dy * c - dx * s) / zoom]
86
+ }
87
+
88
+ /** A camera at the defaults: what a layer or view starts with. */
89
+ export function defaultCamera(): CameraState {
90
+ return { x: 0, y: 0, zoom: 1, rotation: 0, pivotX: 0, pivotY: 0 }
91
+ }
92
+
93
+ /** Apply a setCamera update in place: absent keys keep their values (the
94
+ * params rule). Validate with checkCamera first. */
95
+ export function applyCamera(camera: CameraState, update: CameraUpdate): void {
96
+ if (update.x !== undefined) camera.x = update.x
97
+ if (update.y !== undefined) camera.y = update.y
98
+ if (update.zoom !== undefined) camera.zoom = update.zoom
99
+ if (update.rotation !== undefined) camera.rotation = update.rotation
100
+ if (update.pivotX !== undefined) camera.pivotX = update.pivotX
101
+ if (update.pivotY !== undefined) camera.pivotY = update.pivotY
102
+ }
103
+
104
+ /** The shared params the vertex stages (shaders.ts) read for a camera:
105
+ * uCamera [x, y, zoom, zoom] and uCameraRot [cos, sin, pivotX, pivotY]. */
106
+ export function cameraParams(camera: CameraState): { uCamera: [number, number, number, number]; uCameraRot: [number, number, number, number] } {
107
+ return {
108
+ uCamera: [camera.x, camera.y, camera.zoom, camera.zoom],
109
+ uCameraRot: [Math.cos(camera.rotation), Math.sin(camera.rotation), camera.pivotX, camera.pivotY],
110
+ }
111
+ }