// The 2d camera (createCamera2d) over a world larger than the window, // attached at the layer's root: drag empty space to pan with inertia on // release, wheel or pinch anywhere to zoom about the pointer, tap empty // space to glide the view there. The sprites take part in the same // walk: a tap on one selects it (tint), a drag on one moves it - the // sprite stops its down, which claims the press, so the camera never // pans under a sprite drag. Keys switch modes - F follows the roaming // sprite through the framing (a dead zone, hard limits, a lazy vertical // damping, lookahead; the zones are drawn while it follows), S shakes // the view, R spins it about its center, Space fits the whole world. // The camera is one setCamera write per changed frame (a shared-params // write); no sprite moves for the camera's sake. The layer fills the // window through the function face (starlings' shape): the app owns the // leaf and spreads the LAYER's handlers on it; the camera // listens at the root through attach. // // Debug commands, for driving it from the control API: `camera` returns // the final camera (and parks the pose when given x/y/zoom/rotation), // `pose` the control's own values, `mode` sets { follow, spin } or runs // { fit: true } / { shake: true }, `selected` returns the selected // sprite's world position (null when none), `first` the first sprite's - // the one to aim synthetic taps and drags at. import { createEffect, createInputMap, createPointerFeed, createSignal, decodeImage, displayScale, gamepad, onFrame, render, Show, windowSize } from "@solidrt/core" import { addSprite, camera2dActions, camera2dBindings, createAtlas, createCamera2d, createSpriteLayer, feedPointer, fitOversample, grid, setSprite } from "@solidrt/2d" import type { Camera2dHandle, SpriteHandle } from "@solidrt/2d" import { registerDebug } from "sol:dev" import logoBytes from "./logo.png" with { type: "binary" } const WORLD = { width: 2400, height: 1600 } const COUNT = 300 const SPRITE = 64 const ROAMER = 96 const MAX_ZOOM = 4 // The roamer's Lissajous path: radians per second per axis, and how much // of the world it sweeps. const ROAM_RATE_X = 0.23 const ROAM_RATE_Y = 0.31 const ROAM_SPAN = 0.4 // Spin rate while R is on, radians per second. const SPIN_RATE = 0.4 // The follow's zones, fractions of the viewport: the roamer moves freely // inside the dead zone, is eased back within the hard limits, and is // never let past them. const DEAD_ZONE = { width: 0.3, height: 0.3 } const HARD_LIMITS = { width: 0.8, height: 0.7 } // A lazy vertical follow (a platformer's), tight horizontal. const FOLLOW_DAMPING = { x: 1, y: 2.5 } // Lookahead along the roamer's velocity, seconds, and its smoothing. const LOOKAHEAD = { time: 0.35, smoothing: 0.15 } // A shake: peak displacement in viewport heights, and seconds. const SHAKE_STRENGTH = 0.03 const SHAKE_SECONDS = 0.4 const ZONE_STROKE = 2 // Largest frame step, seconds: a stall eases on from here instead of // teleporting. const MAX_DT = 0.1 const TINT: [number, number, number, number] = [0.6, 0.7, 0.9, 1] const SELECTED_TINT: [number, number, number, number] = [1, 0.95, 0.5, 1] let cam!: Camera2dHandle let [following, setFollowing] = createSignal(false) let spinning = false // The roamer's world position, for the `mode` debug command. let roamerAt = { x: 0, y: 0 } let selected: SpriteHandle | null = null let first: SpriteHandle | null = null // The sprite holding a press (onPointerMove also fires on plain hover; // only the captured moves drag). let dragging: SpriteHandle | null = null function App() { let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" }) let frames = grid(atlas, 2, 2) // The window's logical size, mirrored for the layer and the camera. let win = { width: 1, height: 1 } let layer = createSpriteLayer([atlas], { capacity: COUNT + 1, label: "camera" }) // The layer's one view here: the window. The camera drives it and // listens at its root. let view = layer.createView({ width: win.width, height: win.height, clearColor: [0.05, 0.05, 0.09, 1] }) let select = (sprite: SpriteHandle | null) => { if (selected) setSprite(selected, { tint: TINT }) selected = sprite if (selected) setSprite(selected, { tint: SELECTED_TINT }) } for (let i = 0; i < COUNT; i++) { let sprite = addSprite(layer, { x: Math.random() * WORLD.width, y: Math.random() * WORLD.height, w: SPRITE, h: SPRITE, frame: frames[i % 4], rotation: Math.random() * Math.PI * 2, tint: TINT, }) // The sprite claims its press: the camera at the root never sees this // pointer again, so dragging the sprite moves it instead of the view. sprite.onPointerDown = e => { e.stopPropagation() dragging = sprite } sprite.onPointerUp = () => (dragging = null) sprite.onPointerMove = e => { if (dragging === sprite) setSprite(sprite, { x: e.x, y: e.y }) } sprite.onTap = () => select(sprite) if (first === null) first = sprite } 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] }) cam = createCamera2d(view, { world: WORLD, maxZoom: MAX_ZOOM, follow: { deadZone: DEAD_ZONE, hardLimits: HARD_LIMITS, damping: FOLLOW_DAMPING, lookahead: LOOKAHEAD }, }) // The camera's input: the view's pointer feed (fed from the view's root, // after the sprites' claims) and any pad, bound to the control's actions // (ARCHITECTURE.md: the app wires devices, the control takes the map). let pointer = createPointerFeed() feedPointer(view, pointer) let input = createInputMap(camera2dActions) input.bind(camera2dBindings({ pointer, gamepad: gamepad() })) input.drive(cam.axes) // A tap that reached the root landed on empty space (a sprite's tap // still bubbles here with e.sprite set, so the miss is the null case). view.listen({ onTap: e => { if (e.sprite) return select(null) setFollowing(false) cam.glideTo({ x: e.x, y: e.y }) }, }) createEffect( () => ({ size: windowSize(), scale: displayScale() }), ({ size, scale }) => { win.width = Math.max(1, size.width) win.height = Math.max(1, size.height) view.setSize(win.width, win.height) view.setOversample(fitOversample(scale, win.width, win.height, win.width * win.height * scale * scale)) }, ) let last = 0 onFrame(tick => { let t = tick / 1000 let dt = Math.min(MAX_DT, Math.max(0, t - last)) last = t let rx = WORLD.width / 2 + Math.sin(t * ROAM_RATE_X) * WORLD.width * ROAM_SPAN let ry = WORLD.height / 2 + Math.sin(t * ROAM_RATE_Y) * WORLD.height * ROAM_SPAN setSprite(roamer, { x: rx, y: ry, rotation: t }) roamerAt.x = rx roamerAt.y = ry if (following()) cam.follow(rx, ry) if (spinning) cam.set({ rotation: cam.pose().rotation + SPIN_RATE * dt }) cam.update(dt) }) // A zone outline centered in the window, sized by its fractions. let zone = (size: { width: number; height: number }, color: string) => ( ) return ( { if (e.key === "f") { setFollowing(!following()) if (!following()) cam.unfollow() } else if (e.key === "s") { cam.shake(SHAKE_STRENGTH, SHAKE_SECONDS) } else if (e.key === "r") { spinning = !spinning } else if (e.key === " ") { setFollowing(false) cam.unfollow() cam.fit(undefined, { glide: true }) } }} > {zone(DEAD_ZONE, "#7fd18a99")} {zone(HARD_LIMITS, "#e0704e99")} Camera 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 ) } render(() => ) registerDebug("camera", (args?: Record) => { let pose: Record = {} for (let key of ["x", "y", "zoom", "rotation"]) { if (typeof args?.[key] === "number") pose[key] = args[key] as number } if (Object.keys(pose).length > 0) cam.set(pose) return cam.camera() }) registerDebug("pose", () => cam.pose()) registerDebug("mode", (args?: Record) => { if (typeof args?.follow === "boolean") { setFollowing(args.follow) if (!args.follow) cam.unfollow() } if (typeof args?.spin === "boolean") spinning = args.spin if (args?.fit === true) cam.fit(undefined, { glide: true }) if (args?.shake === true) cam.shake(SHAKE_STRENGTH, SHAKE_SECONDS) return { following: following(), spinning, roamer: { ...roamerAt } } }) registerDebug("selected", () => (selected ? { x: selected._x, y: selected._y } : null)) registerDebug("first", () => (first ? { x: first._x, y: first._y } : null))