# Walking Sim > top-down RPG-style walking with the full pirate-captain sprite --- .. llms_copy::Walking Sim ### Overview library. Uses all 4 animation types (idle / walking / running / jumping) across all 8 compass directions. ## Model * **Map stays north-up.** Bearing isn't driven. Pan-only camera-follow keeps the character at viewport center as they move; the world doesn't spin around them. * **Sprite stays at viewport center, with NO `rotationAngle` transform.** The character "turns" by selecting a different directional sprite frame. * **8-way D-pad input.** Arrow keys are direct compass directions: ArrowUp = north, ArrowRight = east, ArrowUp+ArrowRight = north-east, etc. The touch joystick is read the same way — its angle becomes a heading, its magnitude becomes a speed throttle. (This replaces the prior turn-and- throttle scheme; the joystick angle naturally maps to direction and the 8 sprite poses become reachable.) * **Animation state machine:** speed = 0 → `idle`, facing SOUTH (toward camera) 0 < speed < 0.6 * MAX_SPEED → `walking`, facing heading speed ≥ 0.6 * MAX_SPEED → `running`, facing heading Space pressed → `jumping`, plays through once then returns to active state * **Heading → direction** is snapped to the nearest 45° (one of 8 buckets). ## Controls * Arrow keys (with combinations): walk in that compass direction * Space: jump * Cmd / Ctrl + Arrow: pan the camera away from the walker * Touch joystick (auto-shown on mobile): drag = walk in that direction at a magnitude-scaled speed * Click the top-right MiniMap to toggle WALK ↔ EXPLORE mode. Each toggle is animated with a smooth `map.flyTo()` — the walker NEVER teleports. - WALK: the rAF loop drives the character (this whole file's behavior). Minimap shows broad surrounding context (zoom-5), free-pan disabled. - EXPLORE: the rAF loop sits out — no input is read, no center is pushed. The map is a normal dl2 map (pan/zoom freely). The joystick is hidden. The minimap "inverses" — it now pins on the WALKER's last position at zoom+3 (a small "return-home" preview). - Entry (WALK → EXPLORE): we snapshot the walker's position + the walking zoom, then flyTo the walker at a pulled-back zoom so the user can scout. - Exit (EXPLORE → WALK): flyTo BACK to that snapshot — the walker stays put exactly where they were (you can't use this as a teleport). The rAF's per-frame `center: pos` push is suppressed for the flyTo's duration so it can't snap-jump mid-glide. ## Tile stack * Main map: **Esri World Imagery** (satellite, 100%) with an **Esri NatGeo World Map** overlay on top at 50%. The NatGeo overlay's tiles cap at z16 (`maxZoom=16`) — it's invisible at walking zoom (18) where the satellite carries the scene, and progressively blends in as the EXPLORE flyTo pulls back to wider context. * MiniMap: **Esri World Street Map** — a flat reference map that reads well in a 160×160 thumbnail at any zoom level. ### Live demo .. exec::docs.walking-sim.example :code: false ### Source ```python # File: docs/walking-sim/example.py """ Walking Sim — top-down RPG-style walking with the full pirate-captain sprite library. Uses all 4 animation types (idle / walking / running / jumping) across all 8 compass directions. ## Model * **Map stays north-up.** Bearing isn't driven. Pan-only camera-follow keeps the character at viewport center as they move; the world doesn't spin around them. * **Sprite stays at viewport center, with NO `rotationAngle` transform.** The character "turns" by selecting a different directional sprite frame. * **8-way D-pad input.** Arrow keys are direct compass directions: ArrowUp = north, ArrowRight = east, ArrowUp+ArrowRight = north-east, etc. The touch joystick is read the same way — its angle becomes a heading, its magnitude becomes a speed throttle. (This replaces the prior turn-and- throttle scheme; the joystick angle naturally maps to direction and the 8 sprite poses become reachable.) * **Animation state machine:** speed = 0 → `idle`, facing SOUTH (toward camera) 0 < speed < 0.6 * MAX_SPEED → `walking`, facing heading speed ≥ 0.6 * MAX_SPEED → `running`, facing heading Space pressed → `jumping`, plays through once then returns to active state * **Heading → direction** is snapped to the nearest 45° (one of 8 buckets). ## Controls * Arrow keys (with combinations): walk in that compass direction * Space: jump * Cmd / Ctrl + Arrow: pan the camera away from the walker * Touch joystick (auto-shown on mobile): drag = walk in that direction at a magnitude-scaled speed * Click the top-right MiniMap to toggle WALK ↔ EXPLORE mode. Each toggle is animated with a smooth `map.flyTo()` — the walker NEVER teleports. - WALK: the rAF loop drives the character (this whole file's behavior). Minimap shows broad surrounding context (zoom-5), free-pan disabled. - EXPLORE: the rAF loop sits out — no input is read, no center is pushed. The map is a normal dl2 map (pan/zoom freely). The joystick is hidden. The minimap "inverses" — it now pins on the WALKER's last position at zoom+3 (a small "return-home" preview). - Entry (WALK → EXPLORE): we snapshot the walker's position + the walking zoom, then flyTo the walker at a pulled-back zoom so the user can scout. - Exit (EXPLORE → WALK): flyTo BACK to that snapshot — the walker stays put exactly where they were (you can't use this as a teleport). The rAF's per-frame `center: pos` push is suppressed for the flyTo's duration so it can't snap-jump mid-glide. ## Tile stack * Main map: **Esri World Imagery** (satellite, 100%) with an **Esri NatGeo World Map** overlay on top at 50%. The NatGeo overlay's tiles cap at z16 (`maxZoom=16`) — it's invisible at walking zoom (18) where the satellite carries the scene, and progressively blends in as the EXPLORE flyTo pulls back to wider context. * MiniMap: **Esri World Street Map** — a flat reference map that reads well in a 160×160 thumbnail at any zoom level. """ import flexlayout_dash as dfl import dash_leaflet2 as dl2 import dash_mantine_components as dmc from dash import Input, Output, State, callback, clientside_callback, dcc, html from dash_iconify import DashIconify from dl2_locations import SAVANNAH from dl2_shared import info_panel # Esri ArcGIS tile services. Note the {z}/{y}/{x} order (ArcGIS convention) — # Leaflet's URL templating doesn't care about order, the placeholders are pure # text substitution. ESRI_WORLD_IMAGERY = ( "https://server.arcgisonline.com/ArcGIS/rest/services/" "World_Imagery/MapServer/tile/{z}/{y}/{x}" ) ESRI_NATGEO = ( "https://server.arcgisonline.com/ArcGIS/rest/services/" "NatGeo_World_Map/MapServer/tile/{z}/{y}/{x}" ) ESRI_STREET = ( "https://server.arcgisonline.com/ArcGIS/rest/services/" "World_Street_Map/MapServer/tile/{z}/{y}/{x}" ) # USGS National Map Hydro Cached — transparent raster overlay where every water # feature (lake, river, ocean) is drawn as solid blue and land is fully # transparent. The walking-sim uses it for BOTH visual feedback (a tinted # overlay so the user sees the water) AND as the collision source — the rAF # loop fetches the same tiles via a CORS-friendly hidden and rejects # any step that lands on a non-transparent pixel. # # Cap: z16. Beyond that the server 404s, so set `maxNativeZoom=16` to make # Leaflet upscale the z16 tile at the walking zoom (z18) instead of breaking. USGS_HYDRO = ( "https://basemap.nationalmap.gov/arcgis/rest/services/" "USGSHydroCached/MapServer/tile/{z}/{y}/{x}" ) USGS_HYDRO_MAX_NATIVE_Z = 16 ESRI_ATTR_IMAGERY = ( "Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics, " "and the GIS User Community" ) ESRI_ATTR_NATGEO = ( "Tiles © Esri — National Geographic, Esri, DeLorme, NAVTEQ, " "UNEP-WCMC, USGS, NASA, ESA, METI, NRCAN, GEBCO, NOAA, iPC" ) START = SAVANNAH.center START_ZOOM = 18 # Physics — kept in deg/sec; the integrator multiplies by dt. MAX_SPEED tuned # so the character makes a brisk walking pace at zoom 18 and a clear running # pace once over 60% speed (the running-animation threshold). MIN_SPEED = 0.0 MAX_SPEED = 0.000016 ACCEL_PER_S = 0.000045 # ≈0.35 s from 0 → MAX_SPEED DECEL_PER_S = 0.000060 # quicker auto-decel when input released RUN_THRESHOLD = 0.60 # fraction of MAX_SPEED above which we use running animation JUMP_DURATION_MS = 700 # one play-through of the jumping animation SPRITE_SIZE = 72 # Sprite library layout — keep these constants in sync with # assets/sprites/pirate_captain/. Frame counts must match the actual file # counts in each direction folder. DIRECTIONS = [ "north", "north-east", "east", "south-east", "south", "south-west", "west", "north-west", ] FRAMES_PER_ANIM = {"idle": 4, "walking": 6, "running": 8, "jumping": 9} def _frames(anim: str, direction: str) -> list: n = FRAMES_PER_ANIM[anim] return [ f"/assets/sprites/pirate_captain/{anim}/{direction}/frame_{i:03d}.png" for i in range(n) ] # Build the full sprite map: SPRITES[anim][direction] -> list of frame URLs. # Sent into the rAF loop as a single JS object so frame swaps are pure lookups. SPRITES = {anim: {d: _frames(anim, d) for d in DIRECTIONS} for anim in FRAMES_PER_ANIM} # Per-animation frame interval (ms). Running is faster, idle is slower — # tuned to feel like a real walk / jog / breathing rhythm. FRAME_MS = { "idle": 240, "walking": 110, "running": 70, "jumping": JUMP_DURATION_MS // 9, } # The initial DivIcon HTML — a single slot. The rAF loop hot-swaps # `.src` on every frame tick to play whichever animation is current. SPRITE_DIVICON_HTML = ( f'' ) def _joystick_div(prefix: str): return html.Div( # `id` so a clientside callback can flip its inline display when mode changes # — we hide the joystick whenever the user clicks the minimap into EXPLORE. id=f"{prefix}-joystick", className="dl2-joystick", children=[ html.Div( className="dl2-joystick-base", id=f"{prefix}-joystick-base", children=html.Div( className="dl2-joystick-controller", id=f"{prefix}-joystick-controller", ), ), html.Div( className="dl2-joystick-hint", children=[ DashIconify(icon="mdi:gesture-tap", width=14), html.Span(" Drag to walk · tap center to stop"), ], ), ], ) # --------------------------------------------------------------------- # Pane builders for the DashFlexLayout shell. # Map gets the wide left tab; HUD + Controls share two tabs on the right. # Splitting these into functions keeps the dfl `children=` list readable # and mirrors the pattern in docs/tile-selector/example.py. # --------------------------------------------------------------------- def _map_pane(): """Walker map + touch joystick. Returns a position:relative wrapper so the joystick (absolute) anchors to the map even inside the dfl tab.""" return html.Div( className="ws-map-pane", children=[ dl2.Map( id="ws-map", center=START, zoom=START_ZOOM, bearing=0, style={"height": "100%", "width": "100%"}, children=[ # Two-layer basemap: Esri World Imagery (satellite, 100%) + # Esri NatGeo World Map overlay (50%, caps at z16). At # walking zoom (18) only the satellite is visible; NatGeo # fades in as the EXPLORE flyTo pulls back. dl2.TileLayer( id="ws-tile-satellite", url=ESRI_WORLD_IMAGERY, attribution=ESRI_ATTR_IMAGERY, opacity=1.0, ), dl2.TileLayer( id="ws-tile-natgeo", url=ESRI_NATGEO, attribution="", opacity=0.5, maxZoom=16, ), # USGS Hydro: visible tint + collision source for the rAF # loop. maxNativeZoom=16 because the server 404s past z16. dl2.TileLayer( id="ws-tile-hydro", url=USGS_HYDRO, attribution="", opacity=0.35, maxNativeZoom=USGS_HYDRO_MAX_NATIVE_Z, maxZoom=22, ), dl2.Marker( id="ws-walker", position=START, iconOptions={ "html": SPRITE_DIVICON_HTML, "className": "ws-sprite-icon", "iconSize": [SPRITE_SIZE, SPRITE_SIZE], "iconAnchor": [SPRITE_SIZE // 2, SPRITE_SIZE // 2], }, # No rotation — direction is conveyed by swapping the # IMG src to a different 8-direction frame. rotateWithMap=False, rotationAngle=0, ), dl2.Polyline( id="ws-trail", positions=[START], color="#1971c2", weight=3, opacity=0.5, dashArray="4,4", ), # Minimap doubles as the WALK ↔ EXPLORE switch. A clientside # callback flips ws-mode on every n_clicks bump. dl2.MiniMap( id="ws-minimap", position="topright", url=ESRI_STREET, width=160, height=160, zoomLevelOffset=-5, toggleDisplay=True, ), ], ), _joystick_div("ws"), ], ) def _hud_pane(): """Live state readouts: MODE / FACING / STATE / TERRAIN / BLOCKS / POSITION / throttle / viewport. All ids identical to the previous layout so existing callbacks bind unchanged.""" return html.Div( className="ws-side-pane", children=dmc.Stack( [ info_panel( "HUD", dmc.Stack( [ dmc.Group( [ dmc.Stack( [ dmc.Text("MODE", size="xs", c="dimmed"), dmc.Badge( id="ws-mode-badge", color="green", variant="light", size="lg", children="walk", ), ], gap=2, ), dmc.Stack( [ dmc.Text("FACING", size="xs", c="dimmed"), dmc.Badge( id="ws-direction", color="blue", variant="light", size="lg", children="south", ), ], gap=2, ), dmc.Stack( [ dmc.Text("STATE", size="xs", c="dimmed"), dmc.Badge( id="ws-anim", color="cyan", variant="light", size="lg", children="idle", ), ], gap=2, ), ], justify="space-between", ), dmc.Progress( id="ws-throttle-bar", value=0, color="cyan", size="sm", ), # TERRAIN: pixel sampled under the walker on the # USGS Hydro overlay. BLOCKS: count of frames the # rAF loop rejected because the step landed on water. dmc.Group( [ dmc.Stack( [ dmc.Text("TERRAIN", size="xs", c="dimmed"), dmc.Badge( id="ws-terrain", color="lime", variant="light", size="lg", children="land", ), ], gap=2, ), dmc.Stack( [ dmc.Text("BLOCKS", size="xs", c="dimmed"), dmc.Badge( id="ws-blocks", color="gray", variant="light", size="lg", children="0", ), ], gap=2, ), ], justify="flex-start", gap="md", ), dmc.Group( [ dmc.Text("POSITION", size="xs", c="dimmed"), dmc.Code( id="ws-position", children="...", style={"fontSize": "11px"}, ), ], justify="space-between", ), ], gap="sm", ), ), info_panel( "Viewport", dmc.Code( id="ws-viewport", block=True, style={"fontSize": "11px", "minHeight": "100px"}, ), ), ], gap="md", p="md", ), ) def _controls_pane(): """Keyboard cheat sheet — no dynamic state, just reference.""" return html.Div( className="ws-side-pane", children=dmc.Stack( [ info_panel( "Keyboard", dmc.Stack( [ dmc.Group( [ dmc.Kbd("←"), dmc.Kbd("→"), dmc.Kbd("↑"), dmc.Kbd("↓"), dmc.Text("8-way D-pad", size="sm"), ], gap="xs", ), dmc.Group( [ dmc.Kbd("↑"), dmc.Text("+", size="sm"), dmc.Kbd("→"), dmc.Text("= NE (any 2 = diagonal)", size="sm"), ], gap="xs", ), dmc.Group( [dmc.Kbd("Space"), dmc.Text("jump", size="sm")], gap="xs", ), dmc.Divider(), dmc.Group( [ dmc.Kbd("⌘"), dmc.Text("+", size="sm"), dmc.Kbd("←/→/↑/↓"), dmc.Text("pan camera", size="sm"), ], gap="xs", ), ], gap=6, ), ), info_panel( "Touch", dmc.Stack( [ dmc.Group( [ DashIconify(icon="mdi:gesture-tap", width=16), dmc.Text( "Drag the joystick to walk — its angle " "is the heading, its magnitude is the " "speed throttle.", size="sm", c="dimmed", ), ], gap="xs", wrap="nowrap", align="flex-start", ), ], gap=6, ), ), info_panel( "Minimap", dmc.Text( "Click the top-right minimap to toggle WALK ↔ EXPLORE. " "Each toggle is animated with a smooth flyTo — the " "walker never teleports.", size="sm", ), ), ], gap="md", p="md", ), ) # --------------------------------------------------------------------- # DashFlexLayout model — walker map left (67%), HUD + Controls tabs # share the right column (33%). # # tabEnableRenderOnDemand=False keeps every tab's DOM mounted at page # load so the rAF loop's set_props calls (HUD updates) keep landing # even when the user has switched away from the HUD tab. Same gotcha # the tile-selector page documents. # --------------------------------------------------------------------- WS_MODEL = { "global": { "tabEnableClose": False, "tabEnableFloat": False, "tabEnableRename": False, "tabSetEnableMaximize": True, "tabEnableRenderOnDemand": False, }, "layout": { "type": "row", "weight": 100, "children": [ { "type": "tabset", "weight": 67, "children": [ {"type": "tab", "name": "Walking Sim", "id": "ws-pane-map"}, ], }, { "type": "tabset", "weight": 33, "selected": 0, "children": [ {"type": "tab", "name": "HUD", "id": "ws-pane-hud"}, {"type": "tab", "name": "Controls", "id": "ws-pane-controls"}, ], }, ], }, } component = html.Div( id="ws-shell", # Initial class matches the default ws-mode='walk' so the zoom control # is hidden on first paint — no flash-of-visible-zoom before the # mode→class clientside callback fires. className="ws-flush-shell ws-mode-walk", children=[ dfl.DashFlexLayout( id="ws-flex", model=WS_MODEL, useStateForModel=True, supportsPopout=False, children=[ dfl.Tab(id="ws-pane-map", children=_map_pane()), dfl.Tab(id="ws-pane-hud", children=_hud_pane()), dfl.Tab(id="ws-pane-controls", children=_controls_pane()), ], ), dcc.Store(id="ws-tick", data=0), # 'walk' | 'explore'. Driven by minimap clicks; consumed by the rAF # loop (window._ws_mode mirrors it so the loop can read it without # re-binding). The flyTo anchor lives in window globals — see # toggle_mode_from_minimap below. dcc.Store(id="ws-mode", data="walk"), ], ) clientside_callback( f""" (mapId) => {{ // dfl mounts the tab's DOM asynchronously via React portals, so the // clientside callback can fire BEFORE the ws-map div is in the // document. The original early-return then bailed permanently — // leaving Leaflet's keyboard module enabled (arrows panned the map) // and the rAF loop / joystick wiring unrun. Poll until the div // arrives, then run the rest of setup exactly once. const runSetup = () => {{ const root = document.getElementById('ws-map'); if (!root) {{ setTimeout(runSetup, 80); return; }} if (root.dataset.wsLoopRunning) return; root.dataset.wsLoopRunning = '1'; const SPRITES = {SPRITES!r}; const FRAME_MS = {FRAME_MS!r}; const MIN_SPEED = {MIN_SPEED}; const MAX_SPEED = {MAX_SPEED}; const ACCEL_PER_S = {ACCEL_PER_S}; const DECEL_PER_S = {DECEL_PER_S}; const RUN_THRESHOLD = {RUN_THRESHOLD}; const JUMP_DURATION_MS = {JUMP_DURATION_MS}; // 0=N, 1=NE, 2=E, 3=SE, 4=S, 5=SW, 6=W, 7=NW — matches DIRECTIONS order const DIR_NAMES = ['north','north-east','east','south-east', 'south','south-west','west','north-west']; const headingToDir = (h) => {{ const idx = Math.round(((h % 360) + 360) % 360 / 45) % 8; return DIR_NAMES[idx]; }}; // Convert a (dx, dy) input vector (screen coords: +y down) to a compass // bearing in degrees (0=N, 90=E, 180=S, 270=W). const vecToHeading = (dx, dy) => {{ const rad = Math.atan2(dx, -dy); // y-down → compass return (rad * 180 / Math.PI + 360) % 360; }}; const state = {{ lat: {START[0]}, lng: {START[1]}, heading: 0, // last MOVEMENT heading (0=N) lastDir: 'south', // last direction we picked for the sprite speed: 0, trail: [[{START[0]}, {START[1]}]], keys: new Set(), lastFrame: performance.now(), lastSpriteSwap: 0, spriteIdx: 0, currentAnim: 'idle', jumpEndsAt: 0, terrain: 'land', // last-known terrain under the walker blocks: 0, // count of full-block (slide failed) frames lastTerrainPushed: 'land', lastBlocksPushed: 0, }}; // --- USGS Hydro collision sampler -------------------------------- // The Hydro tileset is a TRANSPARENT raster overlay — water = solid // blue pixel, land = alpha 0. We fetch the same tile URLs that the // visible Hydro TileLayer fetches (with `crossOrigin='anonymous'` so // we can read pixels — verified CORS-OK on basemap.nationalmap.gov), // draw them into a 256-px scratch canvas once, and keep the raw // ImageData. Per-step terrain check = one Uint8 array lookup. // // Cap is z16 (the server 404s above). We always sample at z16 even // though the map is at z18 — Leaflet's `map.project([lat,lng], 16)` // gives the global pixel coord at z16 regardless of the displayed // zoom, and 2 m/px is plenty for a coastline-style barrier. const HYDRO_URL = {USGS_HYDRO!r}; const HYDRO_Z = {USGS_HYDRO_MAX_NATIVE_Z}; const sampleCanvas = document.createElement('canvas'); sampleCanvas.width = 256; sampleCanvas.height = 256; const sampleCtx = sampleCanvas.getContext('2d', {{ willReadFrequently: true }}); const tileCache = new Map(); // "z/x/y" -> {{ ready, data, errored }} const getHydroTile = (z, x, y) => {{ const key = z + '/' + x + '/' + y; const cached = tileCache.get(key); if (cached) return cached; const entry = {{ ready: false, data: null, errored: false }}; tileCache.set(key, entry); const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => {{ sampleCtx.clearRect(0, 0, 256, 256); sampleCtx.drawImage(img, 0, 0, 256, 256); try {{ entry.data = sampleCtx.getImageData(0, 0, 256, 256).data; entry.ready = true; }} catch (e) {{ entry.errored = true; }} }}; img.onerror = () => {{ entry.errored = true; }}; img.src = HYDRO_URL.replace('{{z}}', z) .replace('{{x}}', x) .replace('{{y}}', y); return entry; }}; // Returns 'water' | 'land' | 'unknown' (tile not yet loaded). // 'unknown' = pass — we don't want to freeze the walker on first // boot waiting for a fetch; tiles cache within ~200 ms. const sampleTerrainAt = (lat, lng) => {{ const m = root.__dl2_map; if (!m) return 'unknown'; const pt = m.project([lat, lng], HYDRO_Z); const tx = Math.floor(pt.x / 256); const ty = Math.floor(pt.y / 256); const inX = Math.floor(pt.x - tx * 256); const inY = Math.floor(pt.y - ty * 256); const tile = getHydroTile(HYDRO_Z, tx, ty); if (tile.errored) return 'land'; // assume passable if tile broken if (!tile.ready || !tile.data) return 'unknown'; const alpha = tile.data[(inY * 256 + inX) * 4 + 3]; return alpha > 16 ? 'water' : 'land'; }}; // Warm up a 3x3 tile block around (lat, lng). Cheap on cache-hit; on // cache-miss it queues the fetch so by the time the walker arrives, // the pixels are ready. Called on init + when the walker crosses a // tile boundary. let lastWarmTx = null, lastWarmTy = null; const prewarm = (lat, lng) => {{ const m = root.__dl2_map; if (!m) return; const pt = m.project([lat, lng], HYDRO_Z); const tx = Math.floor(pt.x / 256); const ty = Math.floor(pt.y / 256); if (tx === lastWarmTx && ty === lastWarmTy) return; lastWarmTx = tx; lastWarmTy = ty; for (let dy = -1; dy <= 1; dy++) {{ for (let dx = -1; dx <= 1; dx++) {{ getHydroTile(HYDRO_Z, tx + dx, ty + dy); }} }} }}; // Kick off the first prewarm as soon as the map handle appears. const prewarmWhenReady = () => {{ if (root.__dl2_map) prewarm(state.lat, state.lng); else setTimeout(prewarmWhenReady, 50); }}; prewarmWhenReady(); window._dl2_joystick = window._dl2_joystick || {{ x: 0, y: 0, active: false }}; const joy = window._dl2_joystick; // Mode flag mirrored from the ws-mode dcc.Store via a tiny clientside // callback (see set_ws_mode_flag below). Default 'walk' until the toggle // hook fires. The rAF tick reads this on every frame — when it's // 'explore' the loop skips input + camera-follow so the map operates // like a normal dl2 map. window._ws_mode = window._ws_mode || 'walk'; // --- keyboard --- // Disable Leaflet's keyboard module — see flight_sim.py header for why. // tl;dr: Leaflet intercepts arrows once the map has focus and // stopPropagation()s the event, so our window listener never fires. const disableLeafletKeyboard = () => {{ const m = root.__dl2_map; if (m && m.keyboard && typeof m.keyboard.disable === 'function') {{ try {{ m.keyboard.disable(); }} catch (e) {{}} return; }} setTimeout(disableLeafletKeyboard, 80); }}; disableLeafletKeyboard(); const PAN_STEP_PX = 60; const panBy = (dx, dy) => {{ const m = root.__dl2_map; if (m && typeof m.panBy === 'function') m.panBy([dx, dy]); }}; const onDown = (e) => {{ const t = e.target; if (t && /input|textarea|select/i.test(t.tagName)) return; if (e.metaKey || e.ctrlKey) {{ if (e.key === 'ArrowLeft') {{ panBy(-PAN_STEP_PX, 0); e.preventDefault(); }} if (e.key === 'ArrowRight') {{ panBy(PAN_STEP_PX, 0); e.preventDefault(); }} if (e.key === 'ArrowUp') {{ panBy(0, -PAN_STEP_PX); e.preventDefault(); }} if (e.key === 'ArrowDown') {{ panBy(0, PAN_STEP_PX); e.preventDefault(); }} return; }} if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown',' ','Space'].includes(e.key)) {{ state.keys.add(e.key); if ((e.key === ' ' || e.key === 'Space') && state.jumpEndsAt <= performance.now()) {{ // Start a jump (cooldown enforced) state.jumpEndsAt = performance.now() + JUMP_DURATION_MS; state.currentAnim = 'jumping'; state.spriteIdx = 0; state.lastSpriteSwap = performance.now(); }} e.preventDefault(); }} }}; const onUp = (e) => state.keys.delete(e.key); window.addEventListener('keydown', onDown); window.addEventListener('keyup', onUp); // --- touch joystick --- const base = document.getElementById('ws-joystick-base'); const ctrl = document.getElementById('ws-joystick-controller'); if (base && ctrl && !base.dataset.wired) {{ base.dataset.wired = '1'; const reset = () => {{ ctrl.style.transform = 'translate(-50%, -50%)'; joy.x = 0; joy.y = 0; joy.active = false; }}; reset(); let activePtr = null; base.addEventListener('pointerdown', (e) => {{ if (activePtr !== null) return; activePtr = e.pointerId; base.setPointerCapture(e.pointerId); joy.active = true; e.preventDefault(); }}); base.addEventListener('pointermove', (e) => {{ if (e.pointerId !== activePtr) return; const r = base.getBoundingClientRect(); const radius = r.width / 2; const dx = e.clientX - (r.left + radius); const dy = e.clientY - (r.top + radius); const mag = Math.sqrt(dx*dx + dy*dy); const k = mag > radius ? radius / mag : 1; const cx = dx * k, cy = dy * k; ctrl.style.transform = `translate(calc(-50% + ${{cx}}px), calc(-50% + ${{cy}}px))`; joy.x = cx / radius; joy.y = cy / radius; }}); const onPtrUp = (e) => {{ if (e.pointerId !== activePtr) return; try {{ base.releasePointerCapture(e.pointerId); }} catch (err) {{}} activePtr = null; reset(); }}; base.addEventListener('pointerup', onPtrUp); base.addEventListener('pointercancel', onPtrUp); }} // Preload all sprite URLs so the first walking step / first running-step // / first jump doesn't show a transparent flash while the PNG decodes. for (const anim of Object.keys(SPRITES)) {{ for (const dir of Object.keys(SPRITES[anim])) {{ for (const src of SPRITES[anim][dir]) {{ const i = new Image(); i.src = src; }} }} }} const tick = (now) => {{ const dt = Math.min(0.1, (now - state.lastFrame) / 1000); state.lastFrame = now; // EXPLORE mode: bail out before reading input or pushing the camera. // The map operates as a normal dl2 map (free pan/zoom); the walker // stays put visually. We DO publish the walker's last-known position // to a window var so the page can snapshot it on a back-to-walk // toggle (and pin the minimap on it via centerFixed). if (window._ws_mode === 'explore') {{ window._ws_walker_pos = [state.lat, state.lng]; if (!document.getElementById('ws-map')) return; requestAnimationFrame(tick); return; }} // --- read input → direction vector (dx, dy) in screen coords --- const k = state.keys; let dx = 0, dy = 0; if (k.has('ArrowUp')) dy -= 1; if (k.has('ArrowDown')) dy += 1; if (k.has('ArrowLeft')) dx -= 1; if (k.has('ArrowRight')) dx += 1; if (joy.active && (Math.abs(joy.x) > 0.05 || Math.abs(joy.y) > 0.05)) {{ // Joystick takes precedence when actively engaged. (If both // keys and joystick are active we trust the joystick because // it's the "more deliberate" input on touch devices.) dx = joy.x; dy = joy.y; }} const inputMag = Math.min(1, Math.sqrt(dx*dx + dy*dy)); // --- update heading + speed --- if (inputMag > 0.05) {{ state.heading = vecToHeading(dx, dy); // Ramp speed toward target (target = inputMag * MAX_SPEED) const target = inputMag * MAX_SPEED; if (state.speed < target) {{ state.speed = Math.min(target, state.speed + ACCEL_PER_S * dt); }} else {{ state.speed = Math.max(target, state.speed - DECEL_PER_S * dt); }} }} else {{ // No input → decel to zero. state.speed = Math.max(MIN_SPEED, state.speed - DECEL_PER_S * dt); }} // --- integrate position (with Hydro-collision slide) --- // // Take the desired step, but before committing it ask the Hydro // sampler whether the destination is water. If yes, try sliding // along each axis separately — so walking diagonally INTO a // coastline glides along it instead of jamming. If even the slide // is blocked, the walker is up against a wall: we bleed speed // off faster than normal decel and tick the BLOCKS counter. if (state.speed > 0) {{ const hd = state.heading * Math.PI / 180; const stepLat = state.speed * Math.cos(hd) * dt * 60; const stepLng = state.speed * Math.sin(hd) * dt * 60; const tryMove = (dLat, dLng) => {{ const t = sampleTerrainAt(state.lat + dLat, state.lng + dLng); // 'unknown' (tile mid-load) = allow so the player isn't // frozen during initial paint; 'water' = reject. if (t === 'water') return false; state.lat += dLat; state.lng += dLng; return true; }}; let moved = tryMove(stepLat, stepLng); if (!moved) {{ // Try axis-only slides (lng-only, then lat-only). if (tryMove(0, stepLng)) moved = true; else if (tryMove(stepLat, 0)) moved = true; }} if (!moved) {{ // Truly walled in this frame. Bleed speed off ~4× faster // than normal decel so we don't grind against the wall. state.speed = Math.max(MIN_SPEED, state.speed - DECEL_PER_S * dt * 4); state.blocks += 1; }} if (state.trail.length === 0 || Math.hypot(state.lat - state.trail[state.trail.length-1][0], state.lng - state.trail[state.trail.length-1][1]) > 0.00003) {{ state.trail.push([state.lat, state.lng]); if (state.trail.length > 200) state.trail.shift(); }} }} // Refresh the cached terrain readout (used by the HUD) AND keep // the tile prewarm in sync with the walker's current tile. const here = sampleTerrainAt(state.lat, state.lng); if (here !== 'unknown') state.terrain = here; prewarm(state.lat, state.lng); // --- animation state machine --- // Priority: jumping > running > walking > idle. The jumping animation // runs once through (length is JUMP_DURATION_MS); when it ends, we // fall back to whichever movement state is active. const speedFrac = state.speed / MAX_SPEED; let nextAnim, nextDir; const jumping = now < state.jumpEndsAt; if (jumping) {{ nextAnim = 'jumping'; // Lock direction at jump-start (use heading at start, or south if idle). nextDir = state.lastDir; }} else if (speedFrac >= RUN_THRESHOLD) {{ nextAnim = 'running'; nextDir = headingToDir(state.heading); }} else if (state.speed > MAX_SPEED * 0.02) {{ nextAnim = 'walking'; nextDir = headingToDir(state.heading); }} else {{ nextAnim = 'idle'; nextDir = 'south'; // face the user when idle }} // On a state TRANSITION, reset the frame index so the new animation // starts at frame 0 (otherwise a transition mid-cycle can look jumpy). if (nextAnim !== state.currentAnim || nextDir !== state.lastDir) {{ state.currentAnim = nextAnim; state.lastDir = nextDir; state.spriteIdx = 0; state.lastSpriteSwap = now - FRAME_MS[nextAnim]; // force immediate swap }} // --- swap sprite frame on the interval --- const interval = FRAME_MS[state.currentAnim]; const frames = SPRITES[state.currentAnim][state.lastDir]; if (now - state.lastSpriteSwap >= interval) {{ state.lastSpriteSwap = now; state.spriteIdx = (state.spriteIdx + 1) % frames.length; const imgs = document.querySelectorAll('.ws-sprite-frame'); imgs.forEach((img) => {{ img.src = frames[state.spriteIdx]; }}); }} // --- push state to components (camera-follow translation only) --- const pos = [state.lat, state.lng]; window._ws_walker_pos = pos; dash_clientside.set_props('ws-walker', {{ position: pos }}); // The mode-toggle flyTo (see toggle_mode_from_minimap below) sets // `window._ws_skip_camera_until` to the timestamp the flyTo ends. // While that's in the future, skip the per-frame center push so the // glide animation isn't fought by an instant `setView(pos)` on every // tick (Map.tsx's external-center effect uses {{animate: false}}). if (!(window._ws_skip_camera_until && now < window._ws_skip_camera_until)) {{ dash_clientside.set_props('ws-map', {{ center: pos }}); // bearing stays 0 }} dash_clientside.set_props('ws-trail', {{ positions: state.trail }}); // HUD const pct = Math.round(speedFrac * 100); dash_clientside.set_props('ws-direction', {{ children: state.lastDir }}); dash_clientside.set_props('ws-anim', {{ children: state.currentAnim }}); dash_clientside.set_props('ws-throttle-bar', {{ value: pct }}); dash_clientside.set_props('ws-position', {{ children: pos[0].toFixed(5) + ', ' + pos[1].toFixed(5) }}); // Only push terrain/blocks when they CHANGE — these update much // less often than position, and Dash's set_props is not free. if (state.terrain !== state.lastTerrainPushed) {{ state.lastTerrainPushed = state.terrain; dash_clientside.set_props('ws-terrain', {{ children: state.terrain, color: state.terrain === 'water' ? 'blue' : 'lime', }}); }} if (state.blocks !== state.lastBlocksPushed) {{ state.lastBlocksPushed = state.blocks; dash_clientside.set_props('ws-blocks', {{ children: String(state.blocks), color: state.blocks > 0 ? 'red' : 'gray', }}); }} if (!document.getElementById('ws-map')) return; requestAnimationFrame(tick); }}; requestAnimationFrame(tick); }}; // end runSetup runSetup(); return window.dash_clientside.no_update; }} """, Output("ws-tick", "data"), Input("ws-map", "id"), ) @callback(Output("ws-viewport", "children"), Input("ws-map", "viewport")) def viewport(vp): if not vp: return "—" return ("center: [{:.5f}, {:.5f}]\nzoom: {}\nbearing: {}° (north-up)").format( vp["center"][0], vp["center"][1], vp["zoom"], round(vp.get("bearing") or 0), ) # --- WALK ↔ EXPLORE: minimap click is the mode switch ------------------------ # Each click on the minimap (anywhere on its tiles — the corner toggle button is # excluded by MiniMap itself) bumps ws-minimap.n_clicks. We flip ws-mode on every # bump and run a `flyTo` animation; the walker is NEVER moved. Done clientside so # the toggle feels instant and so we can talk to the Leaflet map directly via the # handle Map.tsx publishes on the root div as `__dl2_map`. clientside_callback( """ (n, currentMode) => { if (!n) return window.dash_clientside.no_update; const next = currentMode === 'walk' ? 'explore' : 'walk'; // Mirror to the window flag the rAF loop reads. Doing this here instead // of in a separate "mode -> flag" callback removes a round-trip and // avoids a one-tick gap where the loop still drives the camera mid- // transition. window._ws_mode = next; const m = document.getElementById('ws-map')?.__dl2_map; if (!m) return next; // Animation budget for both flyTos (seconds). Long enough to feel // deliberate, short enough not to annoy. Picked by feel. const FLY_DURATION = 1.5; // EXPLORE pulls the camera back by this many zoom levels for scouting. const EXPLORE_ZOOM_OUT = 4; if (next === 'explore') { // WALK -> EXPLORE: snapshot the walker's pos + the walking zoom // so the return flyTo can land exactly where we left them. Then // fly OUT to a wider vantage centered on the walker. const walkerPos = window._ws_walker_pos || [m.getCenter().lat, m.getCenter().lng]; window._ws_walk_anchor = walkerPos; window._ws_walk_zoom = m.getZoom(); const minZ = (typeof m.getMinZoom === 'function') ? m.getMinZoom() : 1; const targetZoom = Math.max(minZ, m.getZoom() - EXPLORE_ZOOM_OUT); m.flyTo(walkerPos, targetZoom, { duration: FLY_DURATION }); } else { // EXPLORE -> WALK: the walker did NOT move while we were exploring, // so fly BACK to the same anchor + walking zoom we snapshotted on // entry. Suppress the rAF's per-frame center push until the flyTo // ends, otherwise its instant `setView(pos)` snap-jumps the map // before the glide can play. The +100ms guard absorbs any easing // tail Leaflet adds beyond the nominal duration. const target = window._ws_walk_anchor || window._ws_walker_pos || [m.getCenter().lat, m.getCenter().lng]; const targetZoom = window._ws_walk_zoom || 18; window._ws_skip_camera_until = performance.now() + (FLY_DURATION * 1000) + 100; m.flyTo(target, targetZoom, { duration: FLY_DURATION }); } return next; } """, Output("ws-mode", "data"), Input("ws-minimap", "n_clicks"), State("ws-mode", "data"), prevent_initial_call=True, ) # Mirror ws-mode -> the MiniMap's `centerFixed` + `zoomLevelOffset` + the HUD # badge. WALK = broad context (track main map, zoom -5); EXPLORE = pin on the # walker, zoom +3 (a small "return-home" preview). Reading the walker position # from the window var avoids a Store round-trip — the rAF loop has been # publishing it every tick. clientside_callback( """ (mode) => { const wp = window._ws_walker_pos || null; if (mode === 'explore') { return [wp, 3, 'explore', 'orange']; } // walk: clear the fixed center so the inner map tracks the main map again. return [null, -5, 'walk', 'green']; } """, Output("ws-minimap", "centerFixed"), Output("ws-minimap", "zoomLevelOffset"), Output("ws-mode-badge", "children"), Output("ws-mode-badge", "color"), Input("ws-mode", "data"), ) # Hide the joystick wrapper while in EXPLORE mode (the user is now using the # map normally — joystick has nothing to drive). Toggling display via inline # style respects the existing `.dl2-joystick` CSS so the hover-opacity etc. # stays correct when shown again. clientside_callback( """ (mode) => ({ display: mode === 'explore' ? 'none' : '' }) """, Output("ws-joystick", "style"), Input("ws-mode", "data"), ) # Mirror ws-mode onto the flush-shell className so CSS can gate any UI that # should differ between modes (currently: the Leaflet zoom control, hidden in # WALK / shown in EXPLORE — see assets/walking_sim.css). Setting className from # the same shape that markup writes ('ws-flush-shell ws-mode-') keeps the # host class deterministic and easy to grep for. clientside_callback( """ (mode) => 'ws-flush-shell ws-mode-' + (mode || 'walk') """, Output("ws-shell", "className"), Input("ws-mode", "data"), ) # No color-scheme tile swap here: the Esri tile stack (World Imagery + NatGeo # overlay) is theme-independent. The app's light/dark toggle still affects DMC # chrome and the Leaflet UI glass styling, just not the tiles themselves. ``` :defaultExpanded: false :withExpandedButton: true --- *Source: /walking-sim*