@solidrt/2d 0.0.56 → 0.0.58
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 +560 -267
- 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
package/examples/tiles.tsx
CHANGED
|
@@ -7,15 +7,23 @@
|
|
|
7
7
|
// The camera flies a ship-style path: a fixed screen pivot near the bottom
|
|
8
8
|
// of the viewport, the world panning and ROTATING under it so the flight
|
|
9
9
|
// heading always points up - the <TileLayer> camera prop with rotation and
|
|
10
|
-
// pivot (a leaf transform, never a re-bake).
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// pivot (a leaf transform, never a re-bake). The world is seeded by ONE
|
|
11
|
+
// setTiles rect write over a Uint16Array of frame indices (-1 where the
|
|
12
|
+
// world is empty), the shape a generator or a worker produces; a timer
|
|
13
|
+
// edits tiles while it runs: beacon markers along the road's center line
|
|
14
|
+
// blink, and each blink's batch of setTile calls re-bakes only the chunks
|
|
15
|
+
// the beacons land in.
|
|
13
16
|
//
|
|
14
17
|
// The atlas is the core logo sliced 2x2 by grid(); a real game would slice
|
|
15
|
-
// a tileset sheet the same way
|
|
16
|
-
|
|
18
|
+
// a tileset sheet the same way, and the array is the layer's `frames`
|
|
19
|
+
// table the indices name.
|
|
20
|
+
//
|
|
21
|
+
// Debug commands: `state` (resident chunk count) and `cell` ({ col, row }:
|
|
22
|
+
// the frame at a cell, null when empty).
|
|
23
|
+
import { createSignal, decodeImage, onFrame, pct, render, windowSize } from "@solidrt/core"
|
|
17
24
|
import { createAtlas, grid, TileLayer } from "@solidrt/2d"
|
|
18
25
|
import type { TileCamera, TileLayerHandle } from "@solidrt/2d"
|
|
26
|
+
import { registerDebug } from "srt:dev"
|
|
19
27
|
import logoBytes from "./logo.png" with { type: "binary" }
|
|
20
28
|
|
|
21
29
|
const COLS = 128
|
|
@@ -29,23 +37,29 @@ const WORLD = COLS * TILE
|
|
|
29
37
|
const HITCH_FACTOR = 1.5
|
|
30
38
|
|
|
31
39
|
function App() {
|
|
32
|
-
let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
|
|
33
|
-
let frames = grid(
|
|
40
|
+
let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
|
|
41
|
+
let frames = grid(atlas, 2, 2)
|
|
34
42
|
|
|
35
43
|
let layer!: TileLayerHandle
|
|
36
44
|
let seed = (l: TileLayerHandle) => {
|
|
37
45
|
layer = l
|
|
38
46
|
// A solid ring "road" around the world center: continuous under the
|
|
39
47
|
// flight path, empty everywhere else - the empty regions are the point,
|
|
40
|
-
// their chunks never allocate
|
|
48
|
+
// their chunks never allocate, since a rect write allocates a chunk
|
|
49
|
+
// only for the first frame it carries there. -1 is the clear (a
|
|
50
|
+
// Uint16Array holds it as 0xffff, the same bits).
|
|
41
51
|
let c = COLS / 2
|
|
52
|
+
let cells = new Uint16Array(COLS * ROWS).fill(-1)
|
|
42
53
|
for (let row = 0; row < ROWS; row++) {
|
|
43
54
|
for (let col = 0; col < COLS; col++) {
|
|
44
55
|
let d = Math.hypot(col - c, row - c)
|
|
45
|
-
if (d > 40 && d < 52)
|
|
56
|
+
if (d > 40 && d < 52) cells[row * COLS + col] = (col ^ row) % 4
|
|
46
57
|
}
|
|
47
58
|
}
|
|
59
|
+
l.setTiles(0, 0, COLS, ROWS, cells)
|
|
48
60
|
}
|
|
61
|
+
registerDebug("state", () => ({ chunks: layer.chunks.length }))
|
|
62
|
+
registerDebug("cell", (args?: { col?: number; row?: number }) => layer.getTile(args?.col ?? 0, args?.row ?? 0))
|
|
49
63
|
|
|
50
64
|
// Beacon cells on the road's center line every 7.5 degrees; the interval
|
|
51
65
|
// below blinks them between a marker frame and the road pattern.
|
|
@@ -99,6 +113,7 @@ function App() {
|
|
|
99
113
|
tileW={TILE}
|
|
100
114
|
tileH={TILE}
|
|
101
115
|
atlas={atlas.texture}
|
|
116
|
+
frames={frames}
|
|
102
117
|
camera={camera()}
|
|
103
118
|
label="tile-world"
|
|
104
119
|
ref={seed}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Layer views (layer.createView): the world rendered twice from one set of
|
|
2
|
+
// sprites - the window-filling main view under a pannable, zoomable camera
|
|
3
|
+
// and a corner MINIMAP showing the whole world, no sprite mirrored. A view
|
|
4
|
+
// is one more target over the layer's own instance buffers: the sprites
|
|
5
|
+
// are added once, the roamer moves once, and both renderings follow at
|
|
6
|
+
// zero per-frame JS beyond each camera's writes. The minimap carries the
|
|
7
|
+
// layer's pointer dispatch under its own camera: a tap on it glides the
|
|
8
|
+
// main camera to that world point (the view's root listener, `e.x`/`e.y`
|
|
9
|
+
// in world pixels), and a tap on a sprite in the minimap selects it as it
|
|
10
|
+
// would in the main view (the same sprite handlers, the walk ending at
|
|
11
|
+
// the VIEW). A stroked rect over the minimap outlines the main camera's
|
|
12
|
+
// view rect through `map.project`.
|
|
13
|
+
//
|
|
14
|
+
// Debug commands: `state` returns both cameras and the outline rect;
|
|
15
|
+
// `camera` parks the main camera when given x/y/zoom and returns its
|
|
16
|
+
// pose; `selected` the selected sprite's world position (null when none),
|
|
17
|
+
// `first` the first sprite's - the one to aim a synthetic tap at.
|
|
18
|
+
import { createEffect, createInputMap, createPointerFeed, createSignal, decodeImage, displayScale, gamepad, onFrame, render, windowSize } from "@solidrt/core"
|
|
19
|
+
import { addSprite, camera2dActions, camera2dBindings, createAtlas, createCamera2d, createSpriteLayer, feedPointer, fitOversample, grid, setSprite } from "@solidrt/2d"
|
|
20
|
+
import type { Camera2dHandle, SpriteHandle, ViewHandle } from "@solidrt/2d"
|
|
21
|
+
import { registerDebug } from "srt:dev"
|
|
22
|
+
import logoBytes from "./logo.png" with { type: "binary" }
|
|
23
|
+
|
|
24
|
+
const WORLD = { width: 2400, height: 1600 }
|
|
25
|
+
const COUNT = 300
|
|
26
|
+
const SPRITE = 48
|
|
27
|
+
const ROAMER = 72
|
|
28
|
+
const MAX_ZOOM = 4
|
|
29
|
+
// The minimap's pixel size: the whole world fitted into it.
|
|
30
|
+
const MAP_WIDTH = 240
|
|
31
|
+
const MAP_HEIGHT = 160
|
|
32
|
+
const MAP_MARGIN = 16
|
|
33
|
+
const TINT: [number, number, number, number] = [0.75, 0.8, 0.9, 1]
|
|
34
|
+
const SELECTED_TINT: [number, number, number, number] = [1, 0.4, 0.4, 1]
|
|
35
|
+
// Roamer figure-eight: rates in rad/s and the span as a fraction of the world.
|
|
36
|
+
const ROAM_RATE_X = 0.23
|
|
37
|
+
const ROAM_RATE_Y = 0.31
|
|
38
|
+
const ROAM_SPAN = 0.4
|
|
39
|
+
// Cap on a frame's dt in seconds, so a resumed app cannot leap.
|
|
40
|
+
const MAX_DT = 0.1
|
|
41
|
+
|
|
42
|
+
let cam!: Camera2dHandle
|
|
43
|
+
let map!: ViewHandle
|
|
44
|
+
let selected: SpriteHandle | null = null
|
|
45
|
+
let first: SpriteHandle | null = null
|
|
46
|
+
let outline = { x: 0, y: 0, width: 0, height: 0 }
|
|
47
|
+
|
|
48
|
+
function App() {
|
|
49
|
+
let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
|
|
50
|
+
let frames = grid(atlas, 2, 2)
|
|
51
|
+
let win = { width: 1, height: 1 }
|
|
52
|
+
let layer = createSpriteLayer(atlas.texture, { capacity: COUNT + 1, label: "world" })
|
|
53
|
+
// The main view: the window, under the pannable camera.
|
|
54
|
+
let main = layer.createView({ width: win.width, height: win.height, clearColor: [0.05, 0.05, 0.09, 1], label: "world" })
|
|
55
|
+
let select = (sprite: SpriteHandle | null) => {
|
|
56
|
+
if (selected) setSprite(selected, { tint: TINT })
|
|
57
|
+
selected = sprite
|
|
58
|
+
if (selected) setSprite(selected, { tint: SELECTED_TINT })
|
|
59
|
+
}
|
|
60
|
+
for (let i = 0; i < COUNT; i++) {
|
|
61
|
+
let sprite = addSprite(layer, {
|
|
62
|
+
x: Math.random() * WORLD.width,
|
|
63
|
+
y: Math.random() * WORLD.height,
|
|
64
|
+
w: SPRITE,
|
|
65
|
+
h: SPRITE,
|
|
66
|
+
frame: frames[i % 4],
|
|
67
|
+
rotation: Math.random() * Math.PI * 2,
|
|
68
|
+
tint: TINT,
|
|
69
|
+
})
|
|
70
|
+
sprite.onTap = () => select(sprite)
|
|
71
|
+
if (first === null) first = sprite
|
|
72
|
+
}
|
|
73
|
+
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] })
|
|
74
|
+
|
|
75
|
+
// The minimap: the whole world at the fit zoom, world (0, 0) at the
|
|
76
|
+
// view's top-left (the default pivot), its own clear color.
|
|
77
|
+
let mapZoom = Math.min(MAP_WIDTH / WORLD.width, MAP_HEIGHT / WORLD.height)
|
|
78
|
+
map = layer.createView({
|
|
79
|
+
width: MAP_WIDTH,
|
|
80
|
+
height: MAP_HEIGHT,
|
|
81
|
+
camera: { zoom: mapZoom },
|
|
82
|
+
clearColor: [0.02, 0.02, 0.05, 0.85],
|
|
83
|
+
label: "minimap",
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
cam = createCamera2d(main, { viewport: () => win, world: WORLD, maxZoom: MAX_ZOOM })
|
|
87
|
+
// The main view's gestures and any pad drive the camera through a map;
|
|
88
|
+
// the minimap binds nothing (its tap glides through listen below).
|
|
89
|
+
let pointer = createPointerFeed()
|
|
90
|
+
feedPointer(main, pointer)
|
|
91
|
+
let input = createInputMap(camera2dActions)
|
|
92
|
+
input.bind(camera2dBindings({ pointer, gamepad: gamepad() }))
|
|
93
|
+
input.drive(cam.axes)
|
|
94
|
+
main.listen({
|
|
95
|
+
onTap: e => {
|
|
96
|
+
if (e.sprite === null) select(null)
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
// A tap on the minimap, sprite or not, glides the main camera there:
|
|
100
|
+
// the view's dispatch undoes the MAP camera, so e.x/e.y are world pixels.
|
|
101
|
+
map.listen({ onTap: e => cam.glideTo(e.x, e.y) })
|
|
102
|
+
|
|
103
|
+
createEffect(
|
|
104
|
+
() => ({ size: windowSize(), scale: displayScale() }),
|
|
105
|
+
({ size, scale }) => {
|
|
106
|
+
win.width = Math.max(1, size.width)
|
|
107
|
+
win.height = Math.max(1, size.height)
|
|
108
|
+
main.setSize(win.width, win.height)
|
|
109
|
+
let budget = win.width * win.height * scale * scale
|
|
110
|
+
main.setOversample(fitOversample(scale, win.width, win.height, budget))
|
|
111
|
+
map.setOversample(fitOversample(scale, MAP_WIDTH, MAP_HEIGHT, budget))
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
// The main view's rect on the minimap, as a signal written only when the
|
|
116
|
+
// camera moved this frame.
|
|
117
|
+
let [rect, setRect] = createSignal(outline)
|
|
118
|
+
let last = 0
|
|
119
|
+
onFrame(tick => {
|
|
120
|
+
let t = tick / 1000
|
|
121
|
+
let dt = Math.min(MAX_DT, Math.max(0, t - last))
|
|
122
|
+
last = t
|
|
123
|
+
let rx = WORLD.width / 2 + Math.sin(t * ROAM_RATE_X) * WORLD.width * ROAM_SPAN
|
|
124
|
+
let ry = WORLD.height / 2 + Math.sin(t * ROAM_RATE_Y) * WORLD.height * ROAM_SPAN
|
|
125
|
+
setSprite(roamer, { x: rx, y: ry, rotation: t })
|
|
126
|
+
cam.update(dt)
|
|
127
|
+
let view = cam.viewRect()
|
|
128
|
+
let [x0, y0] = map.project(view.x, view.y)
|
|
129
|
+
let [x1, y1] = map.project(view.x + view.width, view.y + view.height)
|
|
130
|
+
if (x0 !== outline.x || y0 !== outline.y || x1 - x0 !== outline.width || y1 - y0 !== outline.height) {
|
|
131
|
+
outline = { x: x0, y: y0, width: x1 - x0, height: y1 - y0 }
|
|
132
|
+
setRect(outline)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<window>
|
|
138
|
+
<texture src={main.texture} position="absolute" left={0} top={0} width={windowSize().width} height={windowSize().height} {...main.handlers} />
|
|
139
|
+
<view pointerEvents="none" gap={6} padding={20}>
|
|
140
|
+
<text color="#eef4ff" fontSize={24} fontWeight={700}>
|
|
141
|
+
Views
|
|
142
|
+
</text>
|
|
143
|
+
<text color="#a9bcd6" fontSize={15}>
|
|
144
|
+
drag to pan, wheel or pinch to zoom - tap the minimap to glide there - tap a sprite in either view to select it
|
|
145
|
+
</text>
|
|
146
|
+
</view>
|
|
147
|
+
<view position="absolute" right={MAP_MARGIN} bottom={MAP_MARGIN} width={MAP_WIDTH} height={MAP_HEIGHT} overflow="clip">
|
|
148
|
+
<texture src={map.texture} width={MAP_WIDTH} height={MAP_HEIGHT} {...map.handlers} />
|
|
149
|
+
<rect
|
|
150
|
+
position="absolute"
|
|
151
|
+
left={rect().x}
|
|
152
|
+
top={rect().y}
|
|
153
|
+
width={rect().width}
|
|
154
|
+
height={rect().height}
|
|
155
|
+
drawStyle="stroke"
|
|
156
|
+
strokeWidth={1}
|
|
157
|
+
color="#ffffff"
|
|
158
|
+
pointerEvents="none"
|
|
159
|
+
/>
|
|
160
|
+
</view>
|
|
161
|
+
</window>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
render(() => <App />)
|
|
166
|
+
|
|
167
|
+
registerDebug("state", () => ({ camera: cam.camera(), map: map.camera(), outline }))
|
|
168
|
+
registerDebug("camera", (args?: Record<string, unknown>) => {
|
|
169
|
+
let pose: Record<string, number> = {}
|
|
170
|
+
for (let key of ["x", "y", "zoom"]) {
|
|
171
|
+
if (typeof args?.[key] === "number") pose[key] = args[key] as number
|
|
172
|
+
}
|
|
173
|
+
if (Object.keys(pose).length > 0) cam.set(pose)
|
|
174
|
+
return cam.camera()
|
|
175
|
+
})
|
|
176
|
+
registerDebug("selected", () => (selected && selected.layer !== null ? { x: selected._x, y: selected._y } : null))
|
|
177
|
+
registerDebug("first", () => (first ? { x: first._x, y: first._y } : null))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/2d",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.58",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"funding": "https://github.com/sponsors/wellawaretech",
|
|
6
6
|
"author": "Antoine van Wel",
|
|
@@ -16,6 +16,6 @@
|
|
|
16
16
|
],
|
|
17
17
|
"peerDependencies": {
|
|
18
18
|
"@solidjs/signals": "2.0.0-rc.6",
|
|
19
|
-
"@solidrt/core": "0.0.
|
|
19
|
+
"@solidrt/core": "0.0.58"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/src/atlas.ts
CHANGED
|
@@ -1,52 +1,54 @@
|
|
|
1
|
-
// Atlas creation:
|
|
2
|
-
// Thin by design - the frame math lives in frames.ts
|
|
3
|
-
// the texture is an ordinary core texture. createImage
|
|
4
|
-
// never forwards sampler options, and pixel-art atlases
|
|
5
|
-
// `filter: "nearest"` (render small, display big with hard pixels).
|
|
6
|
-
import { createTexture
|
|
7
|
-
import type {
|
|
8
|
-
import type {
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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(
|
|
42
|
-
let
|
|
43
|
-
let texture = createTexture(
|
|
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
|
-
|
|
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
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// module headless on the flux binary. Three consumers implement or undo
|
|
5
5
|
// exactly this mapping and must agree with it: the vertex stages
|
|
6
6
|
// (shaders.ts, uCamera + uCameraRot), the tile layer's composite
|
|
7
|
-
// transform (the <view> props in components.tsx) and the pointer inverse
|
|
7
|
+
// transform (the <view> props in components/tile-layer.tsx) and the pointer inverse
|
|
8
8
|
// (spriteDispatch in layer.ts, which calls unprojectCamera directly). The
|
|
9
9
|
// check keeps the spellings from drifting.
|
|
10
10
|
|
|
@@ -84,3 +84,28 @@ export function unprojectCamera(camera: CameraUpdate, screenX: number, screenY:
|
|
|
84
84
|
let dy = screenY - (camera.pivotY ?? 0)
|
|
85
85
|
return [(camera.x ?? 0) + (dx * c + dy * s) / zoom, (camera.y ?? 0) + (dy * c - dx * s) / zoom]
|
|
86
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
|
+
}
|