@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.
- package/AGENTS.md +246 -69
- package/README.md +33 -18
- package/examples/README.md +29 -0
- package/examples/anim.tsx +1 -1
- package/examples/atlases.tsx +151 -0
- package/examples/camera-probe.tsx +7 -7
- package/examples/camera.tsx +2 -3
- package/examples/markers.tsx +185 -0
- package/examples/parity.tsx +2 -2
- package/examples/pick.tsx +1 -1
- package/examples/split-screen.tsx +1 -1
- package/examples/springs.tsx +1 -1
- package/examples/sprites.tsx +1 -1
- package/examples/text.tsx +143 -0
- package/examples/tiles.tsx +1 -1
- package/examples/views.tsx +3 -3
- package/package.json +3 -3
- package/src/atlas.ts +75 -12
- package/src/camera2d.ts +47 -34
- package/src/collision.ts +20 -0
- package/src/components/camera2d.tsx +6 -13
- package/src/components/group.tsx +5 -10
- package/src/components/index.ts +3 -1
- package/src/components/sprite-layer.tsx +47 -14
- package/src/components/sprite.tsx +15 -7
- package/src/components/text2d.tsx +77 -0
- package/src/components/tile-layer.tsx +11 -5
- package/src/dispatch.ts +3 -2
- package/src/extrude.ts +156 -0
- package/src/font.ts +207 -0
- package/src/frames.ts +65 -55
- package/src/index.ts +37 -18
- package/src/layer.ts +267 -45
- package/src/pack.ts +70 -0
- package/src/pick.ts +25 -1
- package/src/records.ts +286 -121
- package/src/shaders.ts +224 -77
- package/src/shots.ts +23 -9
- package/src/text-layout.ts +163 -0
- package/src/text.ts +240 -0
- package/src/tiles.ts +111 -71
- package/src/views.ts +20 -13
package/examples/README.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
59
|
-
let records = createRecordLayer(atlas
|
|
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:
|
|
86
|
-
addSprite(records, { x: 320, y: 320, w: 40, h: 40, 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
|
|
91
|
-
for (let i = 0; i < 8; i++) tiles.setTile(i, i, i === 2 ?
|
|
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) => {
|
package/examples/camera.tsx
CHANGED
|
@@ -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
|
|
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
|
+
})
|
package/examples/parity.tsx
CHANGED
|
@@ -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
|
|
48
|
-
let records = createRecordLayer(atlas
|
|
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
|
@@ -73,7 +73,7 @@ function App() {
|
|
|
73
73
|
}
|
|
74
74
|
return (
|
|
75
75
|
<window>
|
|
76
|
-
<SpriteLayer
|
|
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)}
|
package/examples/springs.tsx
CHANGED
|
@@ -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
|
|
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)
|
package/examples/sprites.tsx
CHANGED
|
@@ -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
|
|
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
|
+
})
|