import { callRef, createEffect, merge, onFrame, onLayout, untrack } from "@solidrt/core" import type { InputMap, VoidComponent } from "@solidrt/core" import { createCamera2d } from "../camera2d.ts" import type { Camera2d as Camera2dHandle, Camera2dAxes, Camera2dOptions } from "../camera2d.ts" import { useSpriteLayer } from "./context.ts" export type Camera2dProps = Camera2dOptions & { /** The input map driving the control (InputMap.drive): its `pan`, * `zoom` and `roll` actions, or the ones `actions` names. Live: a new * map reconnects. Without one the control moves only through `ref`. */ input?: InputMap /** Action names per axis when the map's differ (null skips an axis). */ actions?: Partial> ref?: (camera: Camera2dHandle) => void } // Cap on the camera loop's per-frame dt in seconds, so the first tick // after a suspended stretch (a resumed app, a reload) cannot leap a glide. const MAX_CAMERA_DT = 0.1 /** * createCamera2d as a SpriteLayer child: drives the nearest view's camera * - the ``'s own, or inside a `` that view * (useSpriteLayer's `viewport`) - and takes its input from the map in * `input`, nothing else (ARCHITECTURE.md: no device wiring in a * component; the app binds the view's pointer feed, a pad or anything * else to the map). Under a `` there is no * view to drive: put the `` inside one of its `` * children. The pose props (x, y, zoom, rotation) are initial values: * change the pose at runtime through `ref`'s set/glideTo/fit/follow and * the verbs. Every other prop is live, forwarded to the control as a * getter and read where it applies, so a world, a zoom range, a pivot, * the follow's zones, the offset lane or a rate follows its prop, and a * bounds, pivot or offset change re-clamps and pushes the pose at once - * the `` rule. The view's `size()` is the viewport (live: a * fill layer's box, a setSize, a `` resize), re-read after every * layout so a resting camera's pivot follows a resize too. Frames run * only while the camera moves (`active()`), so a resting camera leaves * the app demand-driven idle. */ export let Camera2d: VoidComponent = props => { let target = useSpriteLayer().viewport // Through merge, not a spread: a props object hands out getters, and // merge keeps them, so the control reads each option live where it // applies. let options: Camera2dOptions = merge(props, {}) let cam = untrack(() => createCamera2d(target, options)) createEffect( () => props.input, input => (input ? input.drive(cam.axes, untrack(() => props.actions)) : undefined), ) createEffect( () => cam.active(), on => { if (!on) return let last: number | null = null return onFrame(tick => { let now = tick / 1000 let dt = last === null ? 0 : Math.min(now - last, MAX_CAMERA_DT) last = now cam.update(dt) }) }, ) // The viewport follows layout: a fill layer's first box replaces its // creation size, a or window resize changes it, and a camera at // rest runs no frame to notice, so its pivot (a viewport fraction) would // sit where the old size put it until it next moved. A child mounts after // its layer, so this handler runs after the one that sizes the view; // update(0) re-reads the viewport and pushes only when it changed. // Untracked: the control reads its options through the props getters. onLayout(() => untrack(() => cam.update(0))) // A bounds, pivot or offset prop change re-clamps and pushes the pose // at once (set({}) applies the clamps and the lanes). Deferred: the // creation already clamped and pushed the initial pose. Untracked: the // control reads the options through the getters while it applies // them, and the apply wants the values of that moment (the compute // above is what tracks them). createEffect( () => [options.world, options.minZoom, options.maxZoom, options.pivot, options.offset], () => untrack(() => cam.set({})), { defer: true }, ) callRef(() => props.ref, cam) return null }