Walking Sim

top-down RPG-style walking with the full pirate-captain sprite

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 <canvas> 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 &copy; Esri &mdash; Source: Esri, Maxar, Earthstar Geographics, " "and the GIS User Community" ) ESRI_ATTR_NATGEO = ( "Tiles &copy; Esri &mdash; 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 <img> slot. The rAF loop hot-swaps

.src on every frame tick to play whichever animation is current.

SPRITE_DIVICON_HTML = ( f'<img class="ws-sprite-frame" ' f'src="{SPRITES["idle"]["south"][0]}" ' f'style="width:{SPRITE_SIZE}px; height:{SPRITE_SIZE}px; ' f'display:block; image-rendering:pixelated; pointer-events:none;">' )

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-<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*

Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs: