@solidrt/2d 0.0.60 → 0.0.61

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 CHANGED
@@ -216,24 +216,79 @@ functions over it.
216
216
 
217
217
  ### Camera control
218
218
 
219
+ The camera controls run one pipeline (okf/design/camera-controls.md):
220
+ a SOURCE (an input nudge, a verb, the follow) writes the pose, the
221
+ FRAMING eases a followed point into its zones, the LANES (a screen
222
+ offset, shakes) are summed on top of the pose, the CONSTRAINTS (the zoom
223
+ range, the contain clamp) apply to pose plus lanes and move the pose,
224
+ and the PUSH writes the final camera. `pose()` is the pose alone;
225
+ `camera()` the final camera as the layers received it.
226
+
219
227
  Camera control: `createCamera2d(view | views, { viewport: () =>
220
- ({ width, height }), world?: { width, height }, min/maxZoom?, pivot?,
221
- deadZone?: { width, height }, panSpeed?, zoomSpeed?, rollSpeed?,
222
- damping?, followSpeed?, inertia?, x?, y?, zoom?, rotation? })` - Godot's Camera2D
223
- and Three's MapControls in one control over the shared CameraUpdate:
224
- pan with inertia on release, zoom about a point, eased `glideTo`/`fit`,
225
- `follow(x, y)` through a dead zone with damping, roll about the pivot,
226
- and world bounds that CONTAIN the view (an axis whose view is wider
227
- than the world centers - so at the fit zoom a pan is a no-op, there is
228
- nothing to pan; limits ignore rotation, Godot's rule). The pose is
229
- "world point at the pivot", the pivot a viewport fraction defaulting to
230
- the center, so `camera().x/y` is the view center and glideTo/follow
231
- land there (without a `world`, the default pose puts world 0,0 at the
232
- pivot: a fill layer that wants world = screen at rest takes `pivot: {
233
- x: 0, y: 0 }`). The first argument is anything with a view's
234
- `setCamera` (a view of a sprite layer, of a record layer, several at
235
- once - one camera over a scene's layers - or a signal setter feeding
236
- `<TileLayer camera>`).
228
+ ({ width, height }), world?: { width, height, damping? }, min/maxZoom?,
229
+ pivot?, follow?: { damping?, deadZone?, hardLimits?, lookahead? },
230
+ offset?, panSpeed?, zoomSpeed?, rollSpeed?, damping?, inertia?, x?, y?,
231
+ zoom?, rotation? })` - Godot's Camera2D and Three's MapControls in one
232
+ control over the shared CameraUpdate: pan with inertia on release, zoom
233
+ about a point, eased `glideTo`/`fit`, `follow(x, y)` through
234
+ Cinemachine's framing, roll about the pivot, and world bounds that
235
+ CONTAIN the view (an axis whose view is wider than the world centers -
236
+ so at the fit zoom a pan is a no-op, there is nothing to pan; limits
237
+ ignore rotation, Godot's rule; a `world.damping` eases a MOTION into
238
+ the bounds instead of cutting it at the edge, Godot's limit_smoothed,
239
+ while a direct write always clamps at once). The pose is "world point
240
+ at the pivot", the pivot a viewport fraction defaulting to the center,
241
+ so `camera().x/y` is the view center and glideTo/follow land there
242
+ (without a `world`, the default pose puts world 0,0 at the pivot: a
243
+ fill layer that wants world = screen at rest takes `pivot: { x: 0, y:
244
+ 0 }`). The first argument is anything with a view's `setCamera` (a view
245
+ of a sprite layer, of a record layer, several at once - one camera over
246
+ a scene's layers - or a signal setter feeding `<TileLayer camera>`).
247
+
248
+ **Follow.** `follow(x, y)` chases a world point through the framing in
249
+ `follow` (core's camera-control, shared with the 3d orbit camera):
250
+ damping per axis (`{ x, y }`: a platformer's tight horizontal and lazy
251
+ vertical; a number for both; 0 snaps), the dead zone and hard limits as
252
+ fractions of the viewport centred on the pivot (inside the dead zone
253
+ the point roams free; in the band up to the hard limits it is eased
254
+ back, Godot's drag margins; beyond them it is clamped inside at once,
255
+ whatever the damping - the guarantee a fast target never leaves the
256
+ screen; Cinemachine's game default is 0.8 x 0.8), and lookahead by
257
+ `time` seconds of the point's velocity smoothed over `smoothing`
258
+ seconds. Call it every frame for a moving point; the control settles
259
+ once the point rests inside the zones. A pose glide yields to it, an
260
+ anchor glide (a wheel zoom) keeps running under it; `unfollow()`, or a
261
+ verb that writes x/y/zoom, ends it.
262
+
263
+ **Lanes.** `offset` is where the pose's world point is shown, in
264
+ viewport HEIGHTS from the pivot (x right, y down: `[0, -0.25]` shows it
265
+ a quarter of the height above the pivot - Godot's `offset`, Phaser's
266
+ followOffset); `shake(strength, duration, { frequency?, direction? })`
267
+ adds decaying oscillations (viewport heights of peak displacement) that
268
+ sum. Both are added on top of the pose at push and never enter
269
+ `pose()`; the bounds contain pose plus offset by moving the pose, and
270
+ clip a shake at push, so a shake at the world's edge never shows the
271
+ outside and never disturbs the pose.
272
+
273
+ **Shots.** `createShots(view | views, { blend? })` (core's blender over
274
+ the layers' CameraState, Cinemachine's virtual cameras): `shot(name, {
275
+ priority? })` returns a target a `createCamera2d` drives instead of the
276
+ view - a wide camera and a close one, each with its own control - and
277
+ `activate(name, { blend? })` makes the highest-priority enabled shot
278
+ live, blending the view from the current output over `blend` seconds
279
+ (default 0.5; 0 cuts; x/y/rotation/pivot linear, the zoom in log
280
+ space, `mixCamera2d`); `deactivate` falls back by priority. A live
281
+ shot's pushes reach the view at once; a blend needs `update(dt)` while
282
+ `active()`, the controls' rule. `live()` and `camera()` read the state.
283
+ A switch mid-blend starts from the output of that moment, so a quick
284
+ back-and-forth never jumps. The component form: `<Shots blend? ref?>`
285
+ under `<SpriteLayer>` (or `<View2d>`) with `<Shot name priority?
286
+ active? blend?>` children, each providing the view with its camera
287
+ redirected to the shot, so a `<Camera2d>` inside a `<Shot>` drives that
288
+ shot while its size and handlers stay the view's; `active` (default
289
+ true, live) withdraws and restores a shot, an unmount removes it, the
290
+ container runs the blend on frames while one is in flight.
291
+ probes/shots-2d-probe.tsx is the live guard.
237
292
 
238
293
  #### Input and bindings
239
294
 
@@ -249,10 +304,14 @@ an impulse; notches compound on the pending target, and `damping` scales
249
304
  the settle time - 0 applies a notch at once - the 3d controls' knob) and
250
305
  `roll` (axis,
251
306
  turns); a pan gesture's begin stops any glide (a finger landing on a
252
- gliding view holds it) and its end flings with the drag's velocity. The
307
+ gliding view holds it) and its end flings with the release velocity the
308
+ gesture measured (viewport heights per second from the pointer feed;
309
+ the control has no estimator of its own). The
253
310
  verbs, each pushing the pose at once: `panBy(dx, dy)` screen pixels,
254
311
  `zoomAt(sx, sy, factor, { glide? })`, `rollBy(angle)`, `set(pose)`,
255
- `glideTo`, `fit`, `follow`/`unfollow`, `interrupt`, `release`. An input
312
+ `glideTo(x, y, zoom?, rotation?)` (eased, a rotation glide included),
313
+ `fit`, `follow`/`unfollow`, `shake`, `interrupt`, `release([vx, vy])`
314
+ (screen pixels per second of content travel). An input
256
315
  map (core AGENTS.md) drives the axes by name and the APP binds devices
257
316
  to it: the view's pointer feed (`createPointerFeed()`, handed to
258
317
  `<SpriteLayer pointer>` / `<View2d pointer>`, or bridged with
@@ -275,34 +334,37 @@ input.drive(cam.axes)
275
334
  ```
276
335
 
277
336
  Call `cam.update(dt)` from a frame loop (it advances glides, follow,
278
- inertia and the axis rates, pushes one setCamera per driven view when
279
- the pose changed and reports that), gated on the reactive
280
- `cam.active()` (true while a glide, fling, fit, follow or pan gesture
281
- needs frames, or a rate drives; false at rest), read `cam.camera()` for
282
- projectCamera and `cam.pose()` for the control's own four values (the
283
- shape `set()` takes, the 3d controls' `pose()`). The camera has NO tap of its own: taps are the
284
- dispatch's (`onTap` on the root with `e.sprite` null is "tap on empty
285
- space"). `<Camera2d input={input}>` inside `<SpriteLayer>` is all of
286
- that wired through context, the 3d `<OrbitCamera>` shape: options read
287
- at mount, driving the nearest view (the `<SpriteLayer>`'s own or the
288
- enclosing `<View2d>`), `viewport` defaulting to that view's size, the
289
- map's `pan`/`zoom`/`roll` actions (or the names in `actions`) driving
290
- the axes, frames only while `active()`. A view's feed normalizes a drag
291
- by the leaf's own box, so a leaf under a designSize fit pans and zooms
292
- correctly. camera2d.ts imports `@solidrt/core/input` only, so
293
- checks/camera2d-check.ts pins the clamp, anchoring, glides, follow,
337
+ inertia, the lanes and the axis rates, pushes one setCamera per driven
338
+ view when the camera changed and reports that), gated on the reactive
339
+ `cam.active()` (true while a glide, fling, fit, follow, shake or damped
340
+ bound needs frames, or a rate drives; false at rest), read
341
+ `cam.camera()` for projectCamera and `cam.pose()` for the control's own
342
+ four values (the shape `set()` takes, the 3d controls' `pose()`). The
343
+ camera has NO tap of its own: taps are the dispatch's (`onTap` on the
344
+ root with `e.sprite` null is "tap on empty space"). `<Camera2d
345
+ input={input}>` inside `<SpriteLayer>` is all of that wired through
346
+ context, the 3d `<OrbitCamera>` shape: options read at mount, driving
347
+ the nearest view (the `<SpriteLayer>`'s own or the enclosing
348
+ `<View2d>`), `viewport` defaulting to that view's size, the map's
349
+ `pan`/`zoom`/`roll` actions (or the names in `actions`) driving the
350
+ axes, frames only while `active()`. A view's feed normalizes a drag by
351
+ the leaf's own box, so a leaf under a designSize fit pans and zooms
352
+ correctly. camera2d.ts imports `@solidrt/core/input` and
353
+ `@solidrt/core/camera-control` only, so checks/camera2d-check.ts pins
354
+ the clamp, anchoring, glides, the framing, the lanes, damped bounds,
294
355
  inertia and the axes headless; examples/camera.tsx (function face) and
295
356
  examples/pick.tsx (`<Camera2d>`) are the live guards. The shared
296
357
  vocabulary with @solidrt/3d, one kind and unit per word: `pan` vec2,
297
358
  `zoom` axis (octaves), `roll` axis (turns) here; `rotate`, `look`,
298
- `move`, `rise` there. The pose options are initial values; every other
299
- option (world, zoom range, pivot, dead zone, the speeds, inertia) is
300
- read where it applies, never snapshotted, so `<Camera2d>` props are
301
- live - forwarded as getters, a bounds or pivot change re-clamping and
302
- pushing the pose at once through `set({})` - and a function-face
303
- caller mutates the options object it passed (the `<OrbitCamera>` rule,
304
- pinned in the check's live-options case). Not yet, all additive:
305
- rotation glides, a contain origin other than center.
359
+ `move`, `rise`, `focus` there. The pose options are initial values;
360
+ every other option (world, zoom range, pivot, the follow's zones, the
361
+ offset, the speeds, inertia) is read where it applies, never
362
+ snapshotted, so `<Camera2d>` props are live - forwarded as getters, a
363
+ bounds, pivot or offset change re-clamping and pushing the pose at once
364
+ through `set({})` - and a function-face caller mutates the options
365
+ object it passed (the `<OrbitCamera>` rule, pinned in the check's
366
+ live-options case). Not yet, additive: a contain origin other than
367
+ center.
306
368
 
307
369
  ### Layer tint
308
370
 
@@ -421,9 +483,10 @@ keeps, so other props stay reactive).
421
483
 
422
484
  ### Pure pieces
423
485
 
424
- frames.ts, pick.ts, camera.ts, camera-motion.ts and dispatch.ts are pure
425
- (no GPU imports) BY DESIGN so they can be checked headless; keep them
426
- that way.
486
+ frames.ts, pick.ts, camera.ts, camera-motion.ts, dispatch.ts,
487
+ oversample-math.ts and tiles-math.ts (the tile grid's chunk and slot
488
+ math, checks/tiles-check.ts) are pure (no GPU imports) BY DESIGN so they
489
+ can be checked headless; keep them that way.
427
490
 
428
491
  ## The baked tile layer (tiles.ts)
429
492
 
@@ -501,10 +564,10 @@ on approach, evict) - okf/backlog/2d-baked-layers.md.
501
564
 
502
565
  | Component | Props |
503
566
  |---|---|
504
- | `SpriteLayer` | atlas (TextureId), capacity?, tint? ([r,g,b,a] 0..1, over the whole layer in every view), orderBy?, label?, ref?(layer) - the layer; plus its OWN VIEW, unless `output={false}`: width?, height? (view pixels - both, or neither = FILL: the leaf lays out at 100% of its sized parent and the view follows its box, so view pixels are the leaf's own coordinates; mount-fixed, a function `output` requires explicit sizes, matching `<Scene>` in @solidrt/3d), clearColor?, camera?, oversample?, maxOversample?, viewRef?(view), output? (a function composes the leaf from the texture id; `false` = no own view, the layer shows only through `<View2d>` children and the view props throw), events?, pointer? (a createPointerFeed fed from the view's root, what a map over this view binds; `useSpriteLayer().pointer` inside), onPointer{Down,Move,Up}?, onWheel?, onTap? (the view's root: `event.sprite` is the hit sprite or null over empty space) |
567
+ | `SpriteLayer` | atlas (TextureId), capacity?, tint? ([r,g,b,a] 0..1, over the whole layer in every view), orderBy?, stagger? (ms, the layer root's enter/exit spacing), label?, ref?(layer) - the layer; plus its OWN VIEW, unless `output={false}`: width?, height? (view pixels - both, or neither = FILL: the leaf lays out at 100% of its sized parent and the view follows its box, so view pixels are the leaf's own coordinates; mount-fixed, a function `output` requires explicit sizes, matching `<Scene>` in @solidrt/3d), clearColor?, camera?, oversample?, maxOversample?, viewRef?(view), output? (a function composes the leaf from the texture id; `false` = no own view, the layer shows only through `<View2d>` children and the view props throw), events?, pointer? (a createPointerFeed fed from the view's root, what a map over this view binds; `useSpriteLayer().pointer` inside), onPointer{Down,Move,Up}?, onWheel?, onTap? (the view's root: `event.sprite` is the hit sprite or null over empty space) |
505
568
  | `Sprite` | x, y (center; local to the enclosing `<Group>`), w, h, frame?, rotation? (radians, clockwise), tint? ([r,g,b,a] 0..1), visible?, transition?, onPointer{Down,Move,Up,Enter,Leave}?, onWheel?, onTap?, ref? |
506
569
  | `Group` | x?, y?, rotation?, scale? (uniform, scales the subtree), visible? (the whole subtree), transition?, onPointer{Down,Move,Up}?, onWheel?, onTap? (bubbled from hit child sprites), ref? |
507
- | `Camera2d` | createCamera2d's options minus `viewport` (world?, min/maxZoom?, pivot?, deadZone?, panSpeed?, zoomSpeed?, rollSpeed?, followSpeed?, inertia?, x?, y?, zoom?, rotation?), viewport? (`() => { width, height }`, default: the driven viewport's size), input? (the input map driving its `pan`/`zoom`/`roll` axes; live), actions? (action names per axis when the map's differ), ref? - a `<SpriteLayer>` child driving the nearest view's camera (the `<SpriteLayer>`'s own, or inside a `<View2d>` that view) from the map, nothing else; read at mount; throws under `output={false}` outside a `<View2d>` |
570
+ | `Camera2d` | createCamera2d's options minus `viewport` (world?, min/maxZoom?, pivot?, follow?, offset?, panSpeed?, zoomSpeed?, rollSpeed?, damping?, inertia?, x?, y?, zoom?, rotation?), viewport? (`() => { width, height }`, default: the driven viewport's size), input? (the input map driving its `pan`/`zoom`/`roll` axes; live), actions? (action names per axis when the map's differ), ref? - a `<SpriteLayer>` child driving the nearest view's camera (the `<SpriteLayer>`'s own, or inside a `<View2d>` that view) from the map, nothing else; read at mount; throws under `output={false}` outside a `<View2d>` |
508
571
  | `View2d` | a `<SpriteLayer>` child: one more view of the layer from a camera of its own (layer.createView as a component): width, height (view pixels, live; fixed-size only for now), camera? (partial CameraUpdate on the view's camera, live; the same state a `<Camera2d>` child writes), oversample?, maxOversample? (the auto-pick, as SpriteLayer's), clearColor?, label? (createView's, fixed), ref?(view), output?(texture) (else a built-in `<texture>` leaf at the view size carrying the view's handlers), events?, pointer? (this view's feed, fed from its root), onPointer{Down,Move,Up}?, onWheel?, onTap? (the view's root: `event.sprite` null over empty space); a `<Camera2d>` child drives the VIEW from its map (inside, `useSpriteLayer()` reports the view as `viewport` and the feed as `pointer`); `<Sprite>`/`<Group>` children mount to the layer as outside |
509
572
  | `TileLayer` | cols, rows, tileW, tileH, atlas (TextureId), frames? (the tileset `setTiles` indices name), chunkClearColor?, filter?, chunkTiles?, tint? ([r,g,b,a] 0..1, over the whole layer), oversample?, maxOversample?, camera? (TileCamera: x, y, zoom, rotation, pivotX, pivotY), label?, ref? |
510
573
 
@@ -593,6 +656,14 @@ hover, wheel and tap rules headless.
593
656
  edges before sampling: pass `inset` to `grid`/`namedFrames` (2^k texels
594
657
  keeps mip level k clean, losing that border) or pack a gutter as
595
658
  `spacing`, until extrusion lands (okf/backlog/2d-atlas-extrude.md).
659
+ - The colour contract: tint multiplies the atlas texel as stored -
660
+ premultiplied sRGB, no decode - and the layer tint multiplies over
661
+ that, so a tint of `[0.5, 0.5, 0.5, 1]` halves the ENCODED value (about
662
+ a fifth of the light, not half). Right for an unlit pipeline (the
663
+ texel is the author's colour and comes out as it went in), but it is
664
+ not @solidrt/3d's contract, whose color.ts documents sRGB in, linear
665
+ shading: the same tint triple is a different brightness in the two
666
+ packages, so a palette shared across them needs its own conversion.
596
667
  - Tint multiplies the sampled texel (`texture * tint`) and the pipeline
597
668
  blends with `blend: "alpha"` in record order, the premultiplied composite.
598
669
  The atlas is premultiplied because `decodeImage` premultiplies by default;
@@ -5,19 +5,22 @@
5
5
  // walk: a tap on one selects it (tint), a drag on one moves it - the
6
6
  // sprite stops its down, which claims the press, so the camera never
7
7
  // pans under a sprite drag. Keys switch modes - F follows the roaming
8
- // sprite through a dead zone with damping, R spins the view about its
9
- // center, Space fits the whole world. The camera is one setCamera write
10
- // per changed frame (a shared-params write); no sprite moves for the
11
- // camera's sake. The layer fills the window through the function face
12
- // (starlings' shape): the app owns the <texture> leaf and spreads the
13
- // LAYER's handlers on it; the camera listens at the root through attach.
8
+ // sprite through the framing (a dead zone, hard limits, a lazy vertical
9
+ // damping, lookahead; the zones are drawn while it follows), S shakes
10
+ // the view, R spins it about its center, Space fits the whole world.
11
+ // The camera is one setCamera write per changed frame (a shared-params
12
+ // write); no sprite moves for the camera's sake. The layer fills the
13
+ // window through the function face (starlings' shape): the app owns the
14
+ // <texture> leaf and spreads the LAYER's handlers on it; the camera
15
+ // listens at the root through attach.
14
16
  //
15
17
  // Debug commands, for driving it from the control API: `camera` returns
16
- // the pose (and parks it when given x/y/zoom/rotation), `mode` sets
17
- // { follow, spin } or runs { fit: true }, `selected` returns the selected
18
+ // the final camera (and parks the pose when given x/y/zoom/rotation),
19
+ // `pose` the control's own values, `mode` sets { follow, spin } or runs
20
+ // { fit: true } / { shake: true }, `selected` returns the selected
18
21
  // sprite's world position (null when none), `first` the first sprite's -
19
22
  // the one to aim synthetic taps and drags at.
20
- import { createEffect, createInputMap, createPointerFeed, decodeImage, displayScale, gamepad, onFrame, render, windowSize } from "@solidrt/core"
23
+ import { createEffect, createInputMap, createPointerFeed, createSignal, decodeImage, displayScale, gamepad, onFrame, render, Show, windowSize } from "@solidrt/core"
21
24
  import { addSprite, camera2dActions, camera2dBindings, createAtlas, createCamera2d, createSpriteLayer, feedPointer, fitOversample, grid, setSprite } from "@solidrt/2d"
22
25
  import type { Camera2dHandle, SpriteHandle } from "@solidrt/2d"
23
26
  import { registerDebug } from "srt:dev"
@@ -35,8 +38,19 @@ const ROAM_RATE_Y = 0.31
35
38
  const ROAM_SPAN = 0.4
36
39
  // Spin rate while R is on, radians per second.
37
40
  const SPIN_RATE = 0.4
38
- // The follow dead zone, fractions of the viewport.
41
+ // The follow's zones, fractions of the viewport: the roamer moves freely
42
+ // inside the dead zone, is eased back within the hard limits, and is
43
+ // never let past them.
39
44
  const DEAD_ZONE = { width: 0.3, height: 0.3 }
45
+ const HARD_LIMITS = { width: 0.8, height: 0.7 }
46
+ // A lazy vertical follow (a platformer's), tight horizontal.
47
+ const FOLLOW_DAMPING = { x: 1, y: 2.5 }
48
+ // Lookahead along the roamer's velocity, seconds, and its smoothing.
49
+ const LOOKAHEAD = { time: 0.35, smoothing: 0.15 }
50
+ // A shake: peak displacement in viewport heights, and seconds.
51
+ const SHAKE_STRENGTH = 0.03
52
+ const SHAKE_SECONDS = 0.4
53
+ const ZONE_STROKE = 2
40
54
  // Largest frame step, seconds: a stall eases on from here instead of
41
55
  // teleporting.
42
56
  const MAX_DT = 0.1
@@ -44,7 +58,7 @@ const TINT: [number, number, number, number] = [0.6, 0.7, 0.9, 1]
44
58
  const SELECTED_TINT: [number, number, number, number] = [1, 0.95, 0.5, 1]
45
59
 
46
60
  let cam!: Camera2dHandle
47
- let following = false
61
+ let [following, setFollowing] = createSignal(false)
48
62
  let spinning = false
49
63
  // The roamer's world position, for the `mode` debug command.
50
64
  let roamerAt = { x: 0, y: 0 }
@@ -97,7 +111,7 @@ function App() {
97
111
  viewport: () => win,
98
112
  world: WORLD,
99
113
  maxZoom: MAX_ZOOM,
100
- deadZone: DEAD_ZONE,
114
+ follow: { deadZone: DEAD_ZONE, hardLimits: HARD_LIMITS, damping: FOLLOW_DAMPING, lookahead: LOOKAHEAD },
101
115
  })
102
116
  // The camera's input: the view's pointer feed (fed from the view's root,
103
117
  // after the sprites' claims) and any pad, bound to the control's actions
@@ -113,7 +127,7 @@ function App() {
113
127
  onTap: e => {
114
128
  if (e.sprite) return
115
129
  select(null)
116
- following = false
130
+ setFollowing(false)
117
131
  cam.glideTo(e.x, e.y)
118
132
  },
119
133
  })
@@ -138,33 +152,54 @@ function App() {
138
152
  setSprite(roamer, { x: rx, y: ry, rotation: t })
139
153
  roamerAt.x = rx
140
154
  roamerAt.y = ry
141
- if (following) cam.follow(rx, ry)
142
- if (spinning) cam.set({ rotation: cam.camera().rotation! + SPIN_RATE * dt })
155
+ if (following()) cam.follow(rx, ry)
156
+ if (spinning) cam.set({ rotation: cam.pose().rotation + SPIN_RATE * dt })
143
157
  cam.update(dt)
144
158
  })
159
+ // A zone outline centered in the window, sized by its fractions.
160
+ let zone = (size: { width: number; height: number }, color: string) => (
161
+ <rect
162
+ position="absolute"
163
+ left={(windowSize().width * (1 - size.width)) / 2}
164
+ top={(windowSize().height * (1 - size.height)) / 2}
165
+ width={windowSize().width * size.width}
166
+ height={windowSize().height * size.height}
167
+ drawStyle="stroke"
168
+ strokeWidth={ZONE_STROKE}
169
+ color={color}
170
+ />
171
+ )
145
172
 
146
173
  return (
147
174
  <window
148
175
  onKeyDown={e => {
149
176
  if (e.key === "f") {
150
- following = !following
151
- if (!following) cam.unfollow()
177
+ setFollowing(!following())
178
+ if (!following()) cam.unfollow()
179
+ } else if (e.key === "s") {
180
+ cam.shake(SHAKE_STRENGTH, SHAKE_SECONDS)
152
181
  } else if (e.key === "r") {
153
182
  spinning = !spinning
154
183
  } else if (e.key === " ") {
155
- following = false
184
+ setFollowing(false)
156
185
  cam.unfollow()
157
186
  cam.fit(undefined, { glide: true })
158
187
  }
159
188
  }}
160
189
  >
161
190
  <texture src={view.texture} position="absolute" left={0} top={0} width={windowSize().width} height={windowSize().height} {...view.handlers} />
191
+ <Show when={following()}>
192
+ <view pointerEvents="none" position="absolute" left={0} top={0} width={windowSize().width} height={windowSize().height}>
193
+ {zone(DEAD_ZONE, "#7fd18a99")}
194
+ {zone(HARD_LIMITS, "#e0704e99")}
195
+ </view>
196
+ </Show>
162
197
  <view pointerEvents="none" gap={6} padding={20}>
163
198
  <text color="#eef4ff" fontSize={24} fontWeight={700}>
164
199
  Camera
165
200
  </text>
166
201
  <text color="#a9bcd6" fontSize={15}>
167
- drag empty space to pan (flick for inertia) - wheel or pinch to zoom - tap empty space to glide there - tap a sprite to select, drag it to move - F follow - R spin - Space fit
202
+ drag empty space to pan (flick for inertia) - wheel or pinch to zoom - tap empty space to glide there - tap a sprite to select, drag it to move - F follow (zones drawn) - S shake - R spin - Space fit
168
203
  </text>
169
204
  </view>
170
205
  </window>
@@ -182,14 +217,17 @@ registerDebug("camera", (args?: Record<string, unknown>) => {
182
217
  return cam.camera()
183
218
  })
184
219
 
220
+ registerDebug("pose", () => cam.pose())
221
+
185
222
  registerDebug("mode", (args?: Record<string, unknown>) => {
186
223
  if (typeof args?.follow === "boolean") {
187
- following = args.follow
188
- if (!following) cam.unfollow()
224
+ setFollowing(args.follow)
225
+ if (!args.follow) cam.unfollow()
189
226
  }
190
227
  if (typeof args?.spin === "boolean") spinning = args.spin
191
228
  if (args?.fit === true) cam.fit(undefined, { glide: true })
192
- return { following, spinning, roamer: { ...roamerAt } }
229
+ if (args?.shake === true) cam.shake(SHAKE_STRENGTH, SHAKE_SECONDS)
230
+ return { following: following(), spinning, roamer: { ...roamerAt } }
193
231
  })
194
232
 
195
233
  registerDebug("selected", () => (selected ? { x: selected._x, y: selected._y } : null))
@@ -67,7 +67,7 @@ function App() {
67
67
  input.bind(camera2dBindings({ pointer, gamepad: gamepad.next() }))
68
68
  return (
69
69
  <View2d width={pane().width} height={pane().height} clearColor={i === 0 ? [0.05, 0.05, 0.09, 1] : [0.09, 0.05, 0.05, 1]} label={`pane-${i}`} pointer={pointer}>
70
- <Camera2d input={input} world={WORLD} zoom={PANE_ZOOM} maxZoom={MAX_ZOOM} deadZone={DEAD_ZONE} ref={c => (cams[i] = c)} />
70
+ <Camera2d input={input} world={WORLD} zoom={PANE_ZOOM} maxZoom={MAX_ZOOM} follow={{ deadZone: DEAD_ZONE }} ref={c => (cams[i] = c)} />
71
71
  </View2d>
72
72
  )
73
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/2d",
3
- "version": "0.0.60",
3
+ "version": "0.0.61",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -15,7 +15,7 @@
15
15
  "AGENTS.md"
16
16
  ],
17
17
  "peerDependencies": {
18
- "@solidjs/signals": "2.0.0-rc.8",
19
- "@solidrt/core": "0.0.60"
18
+ "@solidjs/signals": "2.0.0-rc.9",
19
+ "@solidrt/core": "0.0.61"
20
20
  }
21
21
  }