// Layer views (layer.createView): the world rendered twice from one set of // sprites - the window-filling main view under a pannable, zoomable camera // and a corner MINIMAP showing the whole world, no sprite mirrored. A view // is one more target over the layer's own instance buffers: the sprites // are added once, the roamer moves once, and both renderings follow at // zero per-frame JS beyond each camera's writes. The minimap carries the // layer's pointer dispatch under its own camera: a tap on it glides the // main camera to that world point (the view's root listener, `e.x`/`e.y` // in world pixels), and a tap on a sprite in the minimap selects it as it // would in the main view (the same sprite handlers, the walk ending at // the VIEW). A stroked rect over the minimap outlines the main camera's // view rect through `map.project`. // // Debug commands: `state` returns both cameras and the outline rect; // `camera` parks the main camera when given x/y/zoom and returns its // pose; `selected` the selected sprite's world position (null when none), // `first` the first sprite's - the one to aim a synthetic tap at. import { createEffect, createInputMap, createPointerFeed, createSignal, decodeImage, displayScale, gamepad, onFrame, render, windowSize } from "@solidrt/core" import { addSprite, camera2dActions, camera2dBindings, createAtlas, createCamera2d, createSpriteLayer, feedPointer, fitOversample, grid, setSprite } from "@solidrt/2d" import type { Camera2dHandle, SpriteHandle, ViewHandle } 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 = 48 const ROAMER = 72 const MAX_ZOOM = 4 // The minimap's pixel size: the whole world fitted into it. const MAP_WIDTH = 240 const MAP_HEIGHT = 160 const MAP_MARGIN = 16 const TINT: [number, number, number, number] = [0.75, 0.8, 0.9, 1] const SELECTED_TINT: [number, number, number, number] = [1, 0.4, 0.4, 1] // Roamer figure-eight: rates in rad/s and the span as a fraction of the world. const ROAM_RATE_X = 0.23 const ROAM_RATE_Y = 0.31 const ROAM_SPAN = 0.4 // Cap on a frame's dt in seconds, so a resumed app cannot leap. const MAX_DT = 0.1 let cam!: Camera2dHandle let map!: ViewHandle let selected: SpriteHandle | null = null let first: SpriteHandle | null = null let outline = { x: 0, y: 0, width: 0, height: 0 } function App() { let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" }) let frames = grid(atlas, 2, 2) let win = { width: 1, height: 1 } let layer = createSpriteLayer([atlas], { capacity: COUNT + 1, label: "world" }) // The main view: the window, under the pannable camera. let main = layer.createView({ width: win.width, height: win.height, clearColor: [0.05, 0.05, 0.09, 1], label: "world" }) 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, }) 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] }) // The minimap: the whole world at the fit zoom, world (0, 0) at the // view's top-left (the default pivot), its own clear color. let mapZoom = Math.min(MAP_WIDTH / WORLD.width, MAP_HEIGHT / WORLD.height) map = layer.createView({ width: MAP_WIDTH, height: MAP_HEIGHT, camera: { zoom: mapZoom }, clearColor: [0.02, 0.02, 0.05, 0.85], label: "minimap", }) cam = createCamera2d(main, { world: WORLD, maxZoom: MAX_ZOOM }) // The main view's gestures and any pad drive the camera through a map; // the minimap binds nothing (its tap glides through listen below). let pointer = createPointerFeed() feedPointer(main, pointer) let input = createInputMap(camera2dActions) input.bind(camera2dBindings({ pointer, gamepad: gamepad() })) input.drive(cam.axes) main.listen({ onTap: e => { if (e.sprite === null) select(null) }, }) // A tap on the minimap, sprite or not, glides the main camera there: // the view's dispatch undoes the MAP camera, so e.x/e.y are world pixels. map.listen({ onTap: e => 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) main.setSize(win.width, win.height) let budget = win.width * win.height * scale * scale main.setOversample(fitOversample(scale, win.width, win.height, budget)) map.setOversample(fitOversample(scale, MAP_WIDTH, MAP_HEIGHT, budget)) }, ) // The main view's rect on the minimap, as a signal written only when the // camera moved this frame. let [rect, setRect] = createSignal(outline) 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 }) cam.update(dt) let view = cam.viewRect() let [x0, y0] = map.project(view.x, view.y) let [x1, y1] = map.project(view.x + view.width, view.y + view.height) if (x0 !== outline.x || y0 !== outline.y || x1 - x0 !== outline.width || y1 - y0 !== outline.height) { outline = { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } setRect(outline) } }) return ( Views drag to pan, wheel or pinch to zoom - tap the minimap to glide there - tap a sprite in either view to select it ) } render(() => ) registerDebug("state", () => ({ camera: cam.camera(), map: map.camera(), outline })) registerDebug("camera", (args?: Record) => { let pose: Record = {} for (let key of ["x", "y", "zoom"]) { if (typeof args?.[key] === "number") pose[key] = args[key] as number } if (Object.keys(pose).length > 0) cam.set(pose) return cam.camera() }) registerDebug("selected", () => (selected && selected.layer !== null ? { x: selected._x, y: selected._y } : null)) registerDebug("first", () => (first ? { x: first._x, y: first._y } : null))