@solidrt/2d 0.0.67 → 0.0.68

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.
@@ -8,6 +8,15 @@ copy one and adapt it.
8
8
  frame rate, moved imperatively with `setSprite` from `onFrame` while the
9
9
  tree holds one texture leaf. Atlas from PNG bytes (`createAtlas`) sliced
10
10
  2x2 with `grid()`.
11
+ - `atlases.tsx` - several atlases in ONE layer: the logo sheet and a
12
+ tileset built in code, declared together as the layer's `atlases`, its
13
+ sprites drawing from either and interleaved by `orderBy: "y"` across
14
+ both sheets in the one draw. The tileset is uploaded with `mipmap` for
15
+ the far zoom, so it goes through `extrudeGrid` first (a 4-texel gutter
16
+ of replicated edge pixels per cell, the returned options slicing the
17
+ repacked sheet): zoom out and the cells' rims stay crisp instead of
18
+ smearing the neighbour in. Wheel/pinch zooms, drag pans. Debug
19
+ commands `zoom` and `atlases` drive it headless.
11
20
  - `tiles.tsx` - the baked tile layer: a 128x128 world (6144px - bigger than
12
21
  one texture may be) baked into lazily-allocated chunks, flown over by a
13
22
  ship-style camera (fixed screen pivot, the world panning and ROTATING
@@ -52,6 +61,15 @@ copy one and adapt it.
52
61
  sprite in the minimap selects it as in the main view (the same sprite
53
62
  handlers, the walk ending at the view). Debug commands `state`,
54
63
  `camera`, `selected` and `first` drive it headless.
64
+ - `markers.tsx` - screen-size markers and an additive glow layer: two
65
+ layers under ONE `createCamera2d` (both views as targets), the world
66
+ layer carrying a constant-size pin per sprite (`minScreenPx` =
67
+ `maxScreenPx`), a label bar that never grows past 24 px (`maxScreenPx`)
68
+ and a selection ring that never shrinks under 48 px (`minScreenPx`,
69
+ gliding to the tapped sprite through a position transition), the glow
70
+ layer the same atlas on `blend: "add"` with haloes that add up to white
71
+ where they overlap. Picking follows the drawn size. Debug commands
72
+ `zoom`, `select` and `pick` drive it headless.
55
73
  - `split-screen.tsx` - one `<SpriteLayer output={false}>` (sprites, no
56
74
  leaf of its own) shown through two `<View2d>` panes filling the window
57
75
  side by side, each with a `<Camera2d>` of its own following one of two
@@ -61,3 +79,14 @@ copy one and adapt it.
61
79
  Each pane's map binds `gamepad.next()`: the first pad to press any
62
80
  button drives pane 0, the next pane 1. The `cameras` debug command
63
81
  reads both poses back.
82
+ - `text.tsx` - world-space text: a sprite font (`createSpriteFont`, the
83
+ runtime's glyph engine behind `flux:font`) declared as one more atlas of
84
+ the layer, each sprite carrying a name below it as a text run
85
+ (`addText`: a group of glyph sprites in the one draw, anchored on its
86
+ middle, an `outline` for the msdf kind), a counter re-set every half
87
+ second (`setText` reuses the run's sprite pool) and a title at twice
88
+ the face's size through the component face: a `<Text2d>` under a
89
+ `<SpriteLayer layer={layer} output={false}>` adopting the same
90
+ imperative layer. Wheel/pinch zooms, drag pans; the labels ride the
91
+ camera with no JS per label. Debug commands `zoom` and `state` drive
92
+ it headless.
package/examples/anim.tsx CHANGED
@@ -24,7 +24,7 @@ const SHOT_FPS = 5
24
24
  function App() {
25
25
  let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
26
26
  let frames = grid(atlas, 2, 2)
27
- let layer = createSpriteLayer(atlas.texture, { label: "anim" })
27
+ let layer = createSpriteLayer([atlas], { label: "anim" })
28
28
  let view = layer.createView({ width: W, height: H, clearColor: [0.05, 0.05, 0.09, 1] })
29
29
 
30
30
  // Three sprites share the looping clip - one clock steps all of them.
@@ -0,0 +1,151 @@
1
+ // Several atlases in ONE layer, and an extruded sheet under a zooming
2
+ // camera. The layer declares two sheets - the core logo sliced 2x2, and a
3
+ // tileset built in code (four solid 32 px cells with a dark one-texel
4
+ // rim) - and its sprites draw from either, interleaved by `orderBy: "y"`
5
+ // across both sheets in the one draw: the multi-texture batch of PixiJS
6
+ // and Phaser, where Unity and Godot would break the batch on every
7
+ // texture change. The tileset is uploaded with `mipmap` for the far zoom,
8
+ // so it goes through `extrudeGrid` first: every cell gets a 4-texel gutter
9
+ // of its own edge pixels, and mip levels 0 through 2 sample clean at the
10
+ // cell edges (zoom out and the rims stay crisp; a plain sheet would smear
11
+ // each rim with the neighbouring cell's colour). The returned options
12
+ // slice the repacked sheet, so nothing downstream knows it was extruded.
13
+ //
14
+ // Wheel or pinch to zoom, drag empty space to pan.
15
+ //
16
+ // Debug commands: `zoom` ({ value, x?, y? }) parks the camera zoom (and
17
+ // the world point at its pivot) and returns the pose; `atlases` returns
18
+ // the layer's atlas list with each sheet's size and the frame each sprite
19
+ // kind draws.
20
+ import { createEffect, createInputMap, createPointerFeed, decodeImage, displayScale, onFrame, render, windowSize } from "@solidrt/core"
21
+ import type { DecodedImage } from "@solidrt/core"
22
+ import { addSprite, camera2dActions, camera2dBindings, createAtlas, createCamera2d, createSpriteLayer, extrudeGrid, feedPointer, fitOversample, grid } from "@solidrt/2d"
23
+ import type { Camera2dHandle, ViewHandle } from "@solidrt/2d"
24
+ import { registerDebug } from "sol:dev"
25
+ import logoBytes from "./logo.png" with { type: "binary" }
26
+
27
+ const WORLD = { width: 1600, height: 1000 }
28
+ const COLUMNS = 8
29
+ const ROWS = 5
30
+ const SPRITE = 96
31
+ // The tileset: four solid cells with a one-texel rim, extruded by this
32
+ // gutter (a power of two; the cell size is a multiple of it).
33
+ const CELL = 32
34
+ const CELLS = 4
35
+ const GUTTER = 4
36
+ const RIM = 1
37
+ const CELL_COLORS: [number, number, number][] = [
38
+ [230, 90, 70],
39
+ [90, 200, 120],
40
+ [80, 140, 240],
41
+ [240, 200, 80],
42
+ ]
43
+ const RIM_COLOR: [number, number, number] = [20, 20, 30]
44
+ const MIN_ZOOM = 0.05
45
+ const MAX_ZOOM = 8
46
+ // Largest frame step, seconds: a stall eases on from here.
47
+ const MAX_DT = 0.1
48
+
49
+ let cam!: Camera2dHandle
50
+ let world!: ViewHandle
51
+
52
+ /** The tileset's pixels: `CELLS` solid cells in a row, each rimmed. */
53
+ function tileset(): DecodedImage {
54
+ let width = CELLS * CELL
55
+ let data = new Uint8Array(width * CELL * 4)
56
+ for (let cell = 0; cell < CELLS; cell++) {
57
+ let color = CELL_COLORS[cell]!
58
+ for (let y = 0; y < CELL; y++) {
59
+ for (let x = 0; x < CELL; x++) {
60
+ let rim = x < RIM || y < RIM || x >= CELL - RIM || y >= CELL - RIM
61
+ let [r, g, b] = rim ? RIM_COLOR : color
62
+ let i = (y * width + cell * CELL + x) * 4
63
+ data[i] = r
64
+ data[i + 1] = g
65
+ data[i + 2] = b
66
+ data[i + 3] = 255
67
+ }
68
+ }
69
+ }
70
+ return { data, width, height: CELL }
71
+ }
72
+
73
+ function App() {
74
+ let logo = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
75
+ let logoFrames = grid(logo, 2, 2)
76
+ // The tileset, extruded for its mip chain: the repacked image goes to
77
+ // createAtlas, the returned options to grid.
78
+ let extruded = extrudeGrid(tileset(), CELLS, 1, GUTTER)
79
+ let tiles = createAtlas(extruded.image, { mipmap: true, label: "tiles-atlas" })
80
+ let tileFrames = grid(tiles, CELLS, 1, extruded.options)
81
+
82
+ let win = { width: 1, height: 1 }
83
+ // One layer over both sheets; y order interleaves them in the one draw.
84
+ let layer = createSpriteLayer([logo, tiles], { capacity: COLUMNS * ROWS, orderBy: "y", label: "atlases" })
85
+ world = layer.createView({ width: win.width, height: win.height, clearColor: [0.05, 0.05, 0.09, 1], label: "world" })
86
+
87
+ for (let i = 0; i < COLUMNS * ROWS; i++) {
88
+ let col = i % COLUMNS
89
+ let row = Math.floor(i / COLUMNS)
90
+ let x = (col + 0.5) * (WORLD.width / COLUMNS)
91
+ // Rows lean so that neighbours overlap and the y order shows.
92
+ let y = (row + 0.5) * (WORLD.height / ROWS) + (col % 2) * SPRITE * 0.4
93
+ let frame = (col + row) % 2 === 0 ? logoFrames[i % 4] : tileFrames[i % CELLS]
94
+ addSprite(layer, { x, y, w: SPRITE, h: SPRITE, frame })
95
+ }
96
+
97
+ cam = createCamera2d(world, { world: WORLD, minZoom: MIN_ZOOM, maxZoom: MAX_ZOOM })
98
+ let pointer = createPointerFeed()
99
+ feedPointer(world, pointer)
100
+ let input = createInputMap(camera2dActions)
101
+ input.bind(camera2dBindings({ pointer }))
102
+ input.drive(cam.axes)
103
+
104
+ createEffect(
105
+ () => ({ size: windowSize(), scale: displayScale() }),
106
+ ({ size, scale }) => {
107
+ win.width = Math.max(1, size.width)
108
+ win.height = Math.max(1, size.height)
109
+ world.setSize(win.width, win.height)
110
+ world.setOversample(fitOversample(scale, win.width, win.height, win.width * win.height * scale * scale))
111
+ },
112
+ )
113
+ let last = 0
114
+ onFrame(tick => {
115
+ let t = tick / 1000
116
+ let dt = Math.min(MAX_DT, Math.max(0, t - last))
117
+ last = t
118
+ cam.update(dt)
119
+ })
120
+ registerDebug("atlases", () => ({
121
+ atlases: layer.atlases.map(a => ({ texture: a.texture, width: a.width, height: a.height })),
122
+ logoFrame: logoFrames[0],
123
+ tileFrame: tileFrames[0],
124
+ extruded: { width: extruded.image.width, height: extruded.image.height, options: extruded.options },
125
+ }))
126
+
127
+ return (
128
+ <window>
129
+ <texture src={world.texture} position="absolute" left={0} top={0} width={windowSize().width} height={windowSize().height} {...world.handlers} />
130
+ <view pointerEvents="none" gap={6} padding={20}>
131
+ <text color="#eef4ff" fontSize={24} fontWeight={700}>
132
+ Atlases
133
+ </text>
134
+ <text color="#a9bcd6" fontSize={15}>
135
+ two sheets, one layer, one draw - wheel or pinch to zoom, drag to pan - the rimmed tiles come from an extruded, mipmapped sheet and stay crisp at any zoom
136
+ </text>
137
+ </view>
138
+ </window>
139
+ )
140
+ }
141
+
142
+ render(() => <App />)
143
+
144
+ registerDebug("zoom", (args?: { value?: number; x?: number; y?: number }) => {
145
+ let pose: { zoom?: number; x?: number; y?: number } = {}
146
+ if (typeof args?.value === "number") pose.zoom = args.value
147
+ if (typeof args?.x === "number") pose.x = args.x
148
+ if (typeof args?.y === "number") pose.y = args.y
149
+ cam.set(pose)
150
+ return cam.pose()
151
+ })
@@ -24,7 +24,7 @@ import {
24
24
  createRecordLayer,
25
25
  createSpriteLayer,
26
26
  createTileLayer,
27
- FULL_FRAME,
27
+ fullFrame,
28
28
  grid,
29
29
  projectCamera,
30
30
  } from "@solidrt/2d"
@@ -55,8 +55,8 @@ function rng(seed: number): () => number {
55
55
  function App() {
56
56
  let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
57
57
  let frames = grid(atlas, 2, 2)
58
- let nodes = createSpriteLayer(atlas.texture, { label: "cam-nodes" })
59
- let records = createRecordLayer(atlas.texture, { label: "cam-records" })
58
+ let nodes = createSpriteLayer([atlas], { label: "cam-nodes" })
59
+ let records = createRecordLayer([atlas], { label: "cam-records" })
60
60
  let nodeView = nodes.createView({ width: W, height: H, label: "cam-nodes" })
61
61
  let recordView = records.createView({ width: W, height: H, label: "cam-records" })
62
62
  nodeView.setCamera(CAM)
@@ -82,13 +82,13 @@ function App() {
82
82
  // The probe sprite: the FULL logo frame (its central 10% is fully
83
83
  // opaque, so the projected center samples opaque), isolated from the
84
84
  // population.
85
- let probe: SpriteHandle = addSprite(nodes, { x: 320, y: 320, w: 40, h: 40, frame: FULL_FRAME })
86
- addSprite(records, { x: 320, y: 320, w: 40, h: 40, frame: FULL_FRAME })
85
+ let probe: SpriteHandle = addSprite(nodes, { x: 320, y: 320, w: 40, h: 40, frame: fullFrame(atlas) })
86
+ addSprite(records, { x: 320, y: 320, w: 40, h: 40, frame: fullFrame(atlas) })
87
87
 
88
88
  // A small tile layer: the bake path with the pinned identity rotation.
89
89
  // The checked tile (2, 2) gets the full frame (opaque center texel).
90
- let tiles = createTileLayer(8, 8, 16, 16, atlas.texture, { label: "cam-tiles" })
91
- for (let i = 0; i < 8; i++) tiles.setTile(i, i, i === 2 ? FULL_FRAME : frames[i % 4]!)
90
+ let tiles = createTileLayer(8, 8, 16, 16, [atlas], { label: "cam-tiles" })
91
+ for (let i = 0; i < 8; i++) tiles.setTile(i, i, i === 2 ? fullFrame(atlas) : frames[i % 4]!)
92
92
 
93
93
  let failures: string[] = []
94
94
  let check = (ok: boolean, what: string) => {
@@ -73,7 +73,7 @@ function App() {
73
73
  let frames = grid(atlas, 2, 2)
74
74
  // The window's logical size, mirrored for the layer and the camera.
75
75
  let win = { width: 1, height: 1 }
76
- let layer = createSpriteLayer(atlas.texture, { capacity: COUNT + 1, label: "camera" })
76
+ let layer = createSpriteLayer([atlas], { capacity: COUNT + 1, label: "camera" })
77
77
  // The layer's one view here: the window. The camera drives it and
78
78
  // listens at its root.
79
79
  let view = layer.createView({ width: win.width, height: win.height, clearColor: [0.05, 0.05, 0.09, 1] })
@@ -108,7 +108,6 @@ function App() {
108
108
  let roamer = addSprite(layer, { x: WORLD.width / 2, y: WORLD.height / 2, w: ROAMER, h: ROAMER, frame: frames[0], tint: [1, 0.8, 0.3, 1] })
109
109
 
110
110
  cam = createCamera2d(view, {
111
- viewport: () => win,
112
111
  world: WORLD,
113
112
  maxZoom: MAX_ZOOM,
114
113
  follow: { deadZone: DEAD_ZONE, hardLimits: HARD_LIMITS, damping: FOLLOW_DAMPING, lookahead: LOOKAHEAD },
@@ -128,7 +127,7 @@ function App() {
128
127
  if (e.sprite) return
129
128
  select(null)
130
129
  setFollowing(false)
131
- cam.glideTo(e.x, e.y)
130
+ cam.glideTo({ x: e.x, y: e.y })
132
131
  },
133
132
  })
134
133
 
@@ -0,0 +1,185 @@
1
+ // Screen-size markers and an additive glow layer over one world. Two
2
+ // sprite layers share ONE camera (createCamera2d over both views keeps
3
+ // them in step; the glow view's leaf takes no pointer): the world layer
4
+ // holds the sprites, each with a PIN at its corner drawn at a constant 18
5
+ // px (minScreenPx = maxScreenPx, the gizmo: `w`/`h` carry the aspect
6
+ // alone), a LABEL bar over every fourth sprite that never grows past 24
7
+ // px however far in you zoom (maxScreenPx alone), and the selection RING
8
+ // that never shrinks under 48 px however far out (minScreenPx alone), so
9
+ // the selected sprite stays findable at the overview. The glow layer is
10
+ // the same atlas on a `blend: "add"` pipeline: half-lit haloes that
11
+ // accumulate to white where they overlap, over the transparent clear.
12
+ // Wheel or pinch to zoom, drag empty space to pan, tap a sprite to
13
+ // select it (the ring glides along), tap empty space to deselect.
14
+ // Picking follows the drawn size: `pick` takes the view's zoom, so a tap
15
+ // lands on the ring where the ring is drawn.
16
+ //
17
+ // Debug commands: `zoom` ({ value, x?, y? }) parks the camera zoom (and
18
+ // the world point at its pivot) and returns the pose; `select` ({ index })
19
+ // selects a sprite (null clears) and
20
+ // returns its world position, where the world view shows it (`at`, view
21
+ // pixels) and the ring's; `pick` ({ x, y }) returns
22
+ // what the world view hits at those VIEW pixels, as marker kinds.
23
+ import { createEffect, createInputMap, createPointerFeed, decodeImage, displayScale, onFrame, render, windowSize } from "@solidrt/core"
24
+ import { addSprite, camera2dActions, camera2dBindings, createAtlas, createCamera2d, createSpriteLayer, feedPointer, fitOversample, grid, setSprite, setSpriteTransition } from "@solidrt/2d"
25
+ import type { Camera2dHandle, SpriteHandle, ViewHandle } from "@solidrt/2d"
26
+ import { registerDebug } from "sol:dev"
27
+ import logoBytes from "./logo.png" with { type: "binary" }
28
+
29
+ const WORLD = { width: 1600, height: 1000 }
30
+ const COLUMNS = 6
31
+ const ROWS = 4
32
+ const SPRITE = 64
33
+ // The pin: a constant 18 px gizmo at the sprite's top-right corner.
34
+ const PIN_PX = 18
35
+ // The label bar over every fourth sprite: world-sized until the zoom
36
+ // would grow it past 24 px tall.
37
+ const LABEL_EVERY = 4
38
+ const LABEL = { w: 120, h: 24 }
39
+ const LABEL_MAX_PX = 24
40
+ // The selection ring: world-sized until the zoom would shrink it under
41
+ // 48 px.
42
+ const RING = 96
43
+ const RING_MIN_PX = 48
44
+ // A halo on the glow layer, wider than the sprite spacing so neighbours
45
+ // overlap, a quarter lit: where four meet they add up to white.
46
+ const HALO = 320
47
+ const HALO_TINT: [number, number, number, number] = [0.3, 0.2, 0.1, 0.25]
48
+ const MIN_ZOOM = 0.08
49
+ const MAX_ZOOM = 8
50
+ // The ring's glide to a newly selected sprite.
51
+ const RING_GLIDE = "260ms ease-out"
52
+ // Largest frame step, seconds: a stall eases on from here.
53
+ const MAX_DT = 0.1
54
+ const SPRITE_TINT: [number, number, number, number] = [0.6, 0.7, 0.9, 1]
55
+ const PIN_TINT: [number, number, number, number] = [1, 0.45, 0.35, 1]
56
+ const LABEL_TINT: [number, number, number, number] = [0.35, 0.9, 0.6, 0.9]
57
+ const RING_TINT: [number, number, number, number] = [1, 0.9, 0.4, 0.6]
58
+
59
+ type Kind = "sprite" | "pin" | "label" | "ring"
60
+
61
+ let cam!: Camera2dHandle
62
+ let world!: ViewHandle
63
+ let sprites: SpriteHandle[] = []
64
+ let kinds = new Map<SpriteHandle, { kind: Kind; index: number }>()
65
+ let ring!: SpriteHandle
66
+ let selected: number | null = null
67
+
68
+ function select(index: number | null) {
69
+ selected = index
70
+ if (index === null) {
71
+ setSprite(ring, { visible: false })
72
+ return
73
+ }
74
+ let s = sprites[index]!
75
+ setSprite(ring, { x: s._x, y: s._y, visible: true })
76
+ }
77
+
78
+ function App() {
79
+ let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
80
+ let frames = grid(atlas, 2, 2)
81
+ let win = { width: 1, height: 1 }
82
+ let layer = createSpriteLayer([atlas], { capacity: 128, label: "markers" })
83
+ let glow = createSpriteLayer([atlas], { capacity: 64, blend: "add", label: "glow" })
84
+ world = layer.createView({ width: win.width, height: win.height, clearColor: [0.05, 0.05, 0.09, 1], label: "world" })
85
+ let glowView = glow.createView({ width: win.width, height: win.height, clearColor: [0, 0, 0, 0], label: "glow" })
86
+
87
+ for (let i = 0; i < COLUMNS * ROWS; i++) {
88
+ let x = ((i % COLUMNS) + 0.5) * (WORLD.width / COLUMNS)
89
+ let y = (Math.floor(i / COLUMNS) + 0.5) * (WORLD.height / ROWS)
90
+ let sprite = addSprite(layer, { x, y, w: SPRITE, h: SPRITE, frame: frames[i % 4], tint: SPRITE_TINT })
91
+ sprite.onTap = () => select(i)
92
+ sprites.push(sprite)
93
+ kinds.set(sprite, { kind: "sprite", index: i })
94
+ // The constant-size pin at the corner: equal bounds, the aspect from
95
+ // w/h (a square), drawn over the sprite.
96
+ let pin = addSprite(layer, { x: x + SPRITE / 2, y: y - SPRITE / 2, w: 1, h: 1, frame: frames[1], tint: PIN_TINT, minScreenPx: PIN_PX, maxScreenPx: PIN_PX })
97
+ kinds.set(pin, { kind: "pin", index: i })
98
+ if (i % LABEL_EVERY === 0) {
99
+ let label = addSprite(layer, { x, y: y - SPRITE, w: LABEL.w, h: LABEL.h, frame: frames[2], tint: LABEL_TINT, maxScreenPx: LABEL_MAX_PX })
100
+ kinds.set(label, { kind: "label", index: i })
101
+ }
102
+ addSprite(glow, { x, y, w: HALO, h: HALO, frame: frames[3], tint: HALO_TINT })
103
+ }
104
+ // The ring: added last so it draws over everything, hidden until a tap.
105
+ ring = addSprite(layer, { x: 0, y: 0, w: RING, h: RING, frame: frames[0], tint: RING_TINT, minScreenPx: RING_MIN_PX, visible: false })
106
+ setSpriteTransition(ring, { position: RING_GLIDE })
107
+ kinds.set(ring, { kind: "ring", index: -1 })
108
+
109
+ // One camera, both views: the glow follows the world exactly.
110
+ cam = createCamera2d([world, glowView], { world: WORLD, minZoom: MIN_ZOOM, maxZoom: MAX_ZOOM })
111
+ let pointer = createPointerFeed()
112
+ feedPointer(world, pointer)
113
+ let input = createInputMap(camera2dActions)
114
+ input.bind(camera2dBindings({ pointer }))
115
+ input.drive(cam.axes)
116
+ world.listen({
117
+ onTap: e => {
118
+ if (e.sprite === null) select(null)
119
+ },
120
+ })
121
+
122
+ createEffect(
123
+ () => ({ size: windowSize(), scale: displayScale() }),
124
+ ({ size, scale }) => {
125
+ win.width = Math.max(1, size.width)
126
+ win.height = Math.max(1, size.height)
127
+ let oversample = fitOversample(scale, win.width, win.height, win.width * win.height * scale * scale)
128
+ for (let view of [world, glowView]) {
129
+ view.setSize(win.width, win.height)
130
+ view.setOversample(oversample)
131
+ }
132
+ },
133
+ )
134
+ let last = 0
135
+ onFrame(tick => {
136
+ let t = tick / 1000
137
+ let dt = Math.min(MAX_DT, Math.max(0, t - last))
138
+ last = t
139
+ cam.update(dt)
140
+ })
141
+
142
+ return (
143
+ <window>
144
+ <texture src={world.texture} position="absolute" left={0} top={0} width={windowSize().width} height={windowSize().height} {...world.handlers} />
145
+ {/* The glow leaf composites over the world (alpha, its transparent
146
+ clear) and takes no pointer, so every tap reaches the world. */}
147
+ <texture src={glowView.texture} position="absolute" left={0} top={0} width={windowSize().width} height={windowSize().height} pointerEvents="none" />
148
+ <view pointerEvents="none" gap={6} padding={20}>
149
+ <text color="#eef4ff" fontSize={24} fontWeight={700}>
150
+ Markers
151
+ </text>
152
+ <text color="#a9bcd6" fontSize={15}>
153
+ wheel or pinch to zoom - drag to pan - tap a sprite to ring it - pins hold 18 px, labels never pass 24 px, the ring never drops under 48 px - haloes add up
154
+ </text>
155
+ </view>
156
+ </window>
157
+ )
158
+ }
159
+
160
+ render(() => <App />)
161
+
162
+ registerDebug("zoom", (args?: { value?: number; x?: number; y?: number }) => {
163
+ let pose: { zoom?: number; x?: number; y?: number } = {}
164
+ if (typeof args?.value === "number") pose.zoom = args.value
165
+ if (typeof args?.x === "number") pose.x = args.x
166
+ if (typeof args?.y === "number") pose.y = args.y
167
+ cam.set(pose)
168
+ return cam.pose()
169
+ })
170
+
171
+ registerDebug("select", (args?: { index?: number | null }) => {
172
+ if (args && "index" in args) select(args.index ?? null)
173
+ let s = selected === null ? null : sprites[selected]!
174
+ return {
175
+ selected,
176
+ sprite: s ? { x: s._x, y: s._y, at: world.project(s._x, s._y) } : null,
177
+ ring: { x: ring._x, y: ring._y, visible: ring._visible },
178
+ }
179
+ })
180
+
181
+ registerDebug("pick", (args?: { x?: number; y?: number }) => {
182
+ let x = args?.x ?? 0
183
+ let y = args?.y ?? 0
184
+ return { zoom: world.camera().zoom, hits: world.pick(x, y).map(s => kinds.get(s) ?? null) }
185
+ })
@@ -44,8 +44,8 @@ function rng(seed: number): () => number {
44
44
  function App() {
45
45
  let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
46
46
  let frames = grid(atlas, 2, 2)
47
- let nodes = createSpriteLayer(atlas.texture, { capacity: 64, label: "nodes" })
48
- let records = createRecordLayer(atlas.texture, { capacity: 64, label: "records" })
47
+ let nodes = createSpriteLayer([atlas], { capacity: 64, label: "nodes" })
48
+ let records = createRecordLayer([atlas], { capacity: 64, label: "records" })
49
49
  let nodeView = nodes.createView({ width: W, height: H, clearColor: [0.05, 0.05, 0.09, 1], label: "nodes" })
50
50
  let recordView = records.createView({ width: W, height: H, clearColor: [0.05, 0.05, 0.09, 1], label: "records" })
51
51
 
package/examples/pick.tsx CHANGED
@@ -66,7 +66,7 @@ function App() {
66
66
  return (
67
67
  <window>
68
68
  <SpriteLayer
69
- atlas={atlas.texture}
69
+ atlases={[atlas]}
70
70
  capacity={64}
71
71
  clearColor={[0.05, 0.05, 0.09, 1]}
72
72
  pointer={pointer}
@@ -73,7 +73,7 @@ function App() {
73
73
  }
74
74
  return (
75
75
  <window>
76
- <SpriteLayer atlas={atlas.texture} capacity={COUNT + 2} output={false}>
76
+ <SpriteLayer atlases={[atlas]} capacity={COUNT + 2} output={false}>
77
77
  <view flexDirection="row" width="100%" height="100%">
78
78
  {paneView(0)}
79
79
  {paneView(1)}
@@ -44,7 +44,7 @@ const TRANSITION: SpriteTransition = {
44
44
  function App() {
45
45
  let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
46
46
  let frames = grid(atlas, 2, 2)
47
- let layer = createSpriteLayer(atlas.texture, { capacity: COUNT, label: "springs" })
47
+ let layer = createSpriteLayer([atlas], { capacity: COUNT, label: "springs" })
48
48
  let view = layer.createView({ width: W, height: H, clearColor: [0.05, 0.05, 0.09, 1] })
49
49
 
50
50
  let slotX = (slot: number) => ((slot % COLS) + 0.5) * (W / COLS)
@@ -23,7 +23,7 @@ const SPRITE = 48
23
23
  function App() {
24
24
  let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
25
25
  let frames = grid(atlas, 2, 2)
26
- let layer = createSpriteLayer(atlas.texture, { capacity: COUNT, label: "bounce" })
26
+ let layer = createSpriteLayer([atlas], { capacity: COUNT, label: "bounce" })
27
27
  let view = layer.createView({ width: W, height: H, clearColor: [0.05, 0.05, 0.09, 1] })
28
28
 
29
29
  // Simulation state lives in plain arrays; the layer holds the published
@@ -0,0 +1,143 @@
1
+ // World-space text: labels that ride the camera as glyph sprites. A sprite
2
+ // font (`createSpriteFont`, the runtime's own glyph engine behind
3
+ // `flux:font`) is one more atlas in the layer's list, and a text run
4
+ // (`addText`, `<Text2d>`) is a group of glyph sprites drawn from it in the
5
+ // same draw as the art - so the labels pan, zoom, sort and pick with the
6
+ // sprites they belong to, with no per-label JS when the camera moves.
7
+ // Each sprite carries a name below it, anchored on its middle; a counter
8
+ // at the top re-sets its text every half second (the sprite pool of the
9
+ // run is reused, glyphs re-framed in place); the title draws at twice
10
+ // the face's size through the component face, a `<Text2d>` under a
11
+ // `<SpriteLayer layer={layer} output={false}>` that adopts the same
12
+ // imperative layer (the example's own view shows it), the same run under
13
+ // props. Wheel or pinch to zoom, drag to pan.
14
+ //
15
+ // The cells are distance fields (the sprite font's default), so one atlas
16
+ // keeps every label sharp across the zoom range and the `outline` draws;
17
+ // "mask" cells would be exact at 1x and resample under zoom.
18
+ //
19
+ // Debug commands: `zoom` ({ value, x?, y? }) parks the camera and returns
20
+ // the pose; `state` returns the counter's text and box, the first label's
21
+ // box and sprite count, and the font atlas as it is now.
22
+ import { createEffect, createInputMap, createPointerFeed, decodeImage, displayScale, onFrame, render, windowSize } from "@solidrt/core"
23
+ import { addSprite, addText, camera2dActions, camera2dBindings, createAtlas, createCamera2d, createSpriteFont, createSpriteLayer, feedPointer, fitOversample, grid, setText, SpriteLayer, Text2d } from "@solidrt/2d"
24
+ import type { Camera2dHandle, SpriteFont, TextRun, ViewHandle } from "@solidrt/2d"
25
+ import { registerDebug } from "sol:dev"
26
+ import logoBytes from "./logo.png" with { type: "binary" }
27
+
28
+ const WORLD = { width: 1600, height: 1000 }
29
+ const COLUMNS = 5
30
+ const ROWS = 3
31
+ const SPRITE = 72
32
+ // The label font's size: what a run shapes at when it names none.
33
+ const LABEL_PX = 18
34
+ // The gap between a sprite's bottom and its label's top.
35
+ const LABEL_GAP = 6
36
+ // The counter re-sets its text at this interval (milliseconds).
37
+ const COUNTER_EVERY_MS = 500
38
+ const MIN_ZOOM = 0.2
39
+ const MAX_ZOOM = 6
40
+ // Largest frame step, seconds: a stall eases on from here.
41
+ const MAX_DT = 0.1
42
+ const SPRITE_TINT: [number, number, number, number] = [0.6, 0.7, 0.9, 1]
43
+ const LABEL_TINT: [number, number, number, number] = [0.93, 0.96, 1, 1]
44
+ const COUNTER_TINT: [number, number, number, number] = [1, 0.85, 0.4, 1]
45
+ const OUTLINE: { color: [number, number, number]; width: number } = { color: [0.05, 0.05, 0.09], width: 2 }
46
+ const NAMES = ["Alder", "Birch", "Cedar", "Elm", "Fir", "Hazel", "Larch", "Maple", "Oak", "Pine", "Rowan", "Spruce", "Willow", "Yew", "Aspen"]
47
+
48
+ let cam!: Camera2dHandle
49
+ let world!: ViewHandle
50
+ let font!: SpriteFont
51
+ let counter!: TextRun
52
+ let labels: TextRun[] = []
53
+ let ticks = 0
54
+
55
+ function App() {
56
+ let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
57
+ let frames = grid(atlas, 2, 2)
58
+ font = createSpriteFont({ fontFamily: "sans", fontSize: LABEL_PX, fontWeight: 600 }, { label: "labels" })
59
+ let win = { width: 1, height: 1 }
60
+ let layer = createSpriteLayer([atlas, font.atlas], { capacity: 512, label: "world", orderBy: "y" })
61
+ world = layer.createView({ width: win.width, height: win.height, clearColor: [0.05, 0.05, 0.09, 1], label: "world" })
62
+
63
+ for (let i = 0; i < COLUMNS * ROWS; i++) {
64
+ let x = ((i % COLUMNS) + 0.5) * (WORLD.width / COLUMNS)
65
+ let y = (Math.floor(i / COLUMNS) + 0.5) * (WORLD.height / ROWS)
66
+ addSprite(layer, { x, y, w: SPRITE, h: SPRITE, frame: frames[i % 4], tint: SPRITE_TINT })
67
+ // The name under the sprite, centered on it: the run's anchor is its
68
+ // middle, its top the gap below the sprite.
69
+ labels.push(addText(layer, { font, text: NAMES[i % NAMES.length]!, x, y: y + SPRITE / 2 + LABEL_GAP, anchor: "middle", tint: LABEL_TINT, outline: OUTLINE }))
70
+ }
71
+ counter = addText(layer, { font, text: "0 ticks", x: WORLD.width / 2, y: LABEL_PX, anchor: "middle", tint: COUNTER_TINT, outline: OUTLINE })
72
+
73
+ cam = createCamera2d([world], { world: WORLD, minZoom: MIN_ZOOM, maxZoom: MAX_ZOOM })
74
+ let pointer = createPointerFeed()
75
+ feedPointer(world, pointer)
76
+ let input = createInputMap(camera2dActions)
77
+ input.bind(camera2dBindings({ pointer }))
78
+ input.drive(cam.axes)
79
+
80
+ createEffect(
81
+ () => ({ size: windowSize(), scale: displayScale() }),
82
+ ({ size, scale }) => {
83
+ win.width = Math.max(1, size.width)
84
+ win.height = Math.max(1, size.height)
85
+ world.setSize(win.width, win.height)
86
+ world.setOversample(fitOversample(scale, win.width, win.height, win.width * win.height * scale * scale))
87
+ },
88
+ )
89
+ let last = 0
90
+ let nextTick = COUNTER_EVERY_MS
91
+ onFrame(tick => {
92
+ let t = tick / 1000
93
+ let dt = Math.min(MAX_DT, Math.max(0, t - last))
94
+ last = t
95
+ cam.update(dt)
96
+ if (tick >= nextTick) {
97
+ nextTick += COUNTER_EVERY_MS
98
+ ticks++
99
+ setText(counter, { text: `${ticks} ${ticks === 1 ? "tick" : "ticks"}` })
100
+ }
101
+ })
102
+
103
+ return (
104
+ <window>
105
+ <texture src={world.texture} position="absolute" left={0} top={0} width={windowSize().width} height={windowSize().height} {...world.handlers} />
106
+ {/* The title, bottom center, bigger than the face: its glyphs scale
107
+ their distance-field cells and stay sharp. The component face over
108
+ the imperative layer: no view of its own, the texture above shows
109
+ it. */}
110
+ <SpriteLayer layer={layer} output={false}>
111
+ <Text2d font={font} text="World-space text" x={WORLD.width / 2} y={WORLD.height - LABEL_PX} fontSize={LABEL_PX * 2} anchor="middle" anchorY="bottom" tint={LABEL_TINT} outline={OUTLINE} />
112
+ </SpriteLayer>
113
+ <view pointerEvents="none" gap={6} padding={20}>
114
+ <text color="#eef4ff" fontSize={24} fontWeight={700}>
115
+ Text
116
+ </text>
117
+ <text color="#a9bcd6" fontSize={15}>
118
+ wheel or pinch to zoom - drag to pan - every label is a group of glyph sprites in the one draw, no JS per label when the camera moves
119
+ </text>
120
+ </view>
121
+ </window>
122
+ )
123
+ }
124
+
125
+ render(() => <App />)
126
+
127
+ registerDebug("zoom", (args?: { value?: number; x?: number; y?: number }) => {
128
+ let pose: { zoom?: number; x?: number; y?: number } = {}
129
+ if (typeof args?.value === "number") pose.zoom = args.value
130
+ if (typeof args?.x === "number") pose.x = args.x
131
+ if (typeof args?.y === "number") pose.y = args.y
132
+ cam.set(pose)
133
+ return cam.pose()
134
+ })
135
+
136
+ registerDebug("state", () => {
137
+ let first = labels[0]!
138
+ return {
139
+ counter: { text: counter.text, width: counter.width, height: counter.height, sprites: counter.sprites.length },
140
+ label: { text: first.text, width: first.width, height: first.height, sprites: first.sprites.length, visible: first.sprites.filter(s => s._visible).length },
141
+ atlas: { width: font.atlas.width, height: font.atlas.height, size: font.size },
142
+ }
143
+ })
@@ -112,7 +112,7 @@ function App() {
112
112
  rows={ROWS}
113
113
  tileW={TILE}
114
114
  tileH={TILE}
115
- atlas={atlas.texture}
115
+ atlases={[atlas]}
116
116
  frames={frames}
117
117
  camera={camera()}
118
118
  label="tile-world"