Something went wrong. Try again.
HEXWORK - A Lantern in the Deep: 3D ASCII terminal roguelike RPG in Rust.
Entirely designed and built by Muse Spark 1.3 to evaluate if I like the model.
Something went wrong. Try again.
19 kB · 439 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440//! Pure key/mouse → [`Input`](crate::Input) mapping (docs/04 §4.6, docs/01 §6–7).//!//! No terminal I/O here: [`map_key`] and [`map_mouse`] are total pure//! functions over crossterm types (constructing `KeyCode` values needs no//! TTY, so they are unit-tested exhaustively). The crossterm `Event` bridge//! lives in [`crate::backend`](super::backend) (`map_event`).//!//! Design trace://! - docs/04 §4.6: full keyboard play is mandatory; **every mouse action has//! a key twin**; the keymap lives in one TOML file and is remappable//! in-game. This module distinguishes the shipped asset ([`KEYMAP_PATH`])//! from the runtime basename ([`KEYMAP_FILE_NAME`]) and pins the embedded//! defaults ([`default_keymap_toml`]).//! - docs/04 §4.2: holding `Shift` advances exactly one tick (roguelike step//! mode) — `Shift` + any movement key yields [`Input::PauseStep`].//! - docs/01 §6: `x` = look mode, `Tab` = chase-orbit, `M` = top-tactical.//! - docs/01 §7 (kindness): full keyboard + optional mouse, never consequence.//!//! # Key table (shipped defaults)//!//! | Keys | `Input` | Notes |//! |---|---|---|//! | Arrows, `WASD`, `hjkl` | `Up/Down/Left/Right` | case-insensitive |//! | `Q` / `y`, `E`/`e` / `u`, `Z`/`z` / `b`, `C` / `n` | diagonals | QEZC + vi-keys `yubn`; see collisions below |//! | `Shift` + any movement above | `PauseStep` | single-step one tick; directionless by design |//! | `x` | `Look` | look mode |//! | `i` | `Inventory` | |//! | `c` | `Contracts` | notice-board heartbeat (docs/01 §2) |//! | `m` | `Map` | top-tactical planner (docs/05 §5.1) |//! | `p` | `Chapel` | mnemonic: *pray*. `h` is taken by movement-left, so `p` it is |//! | `Tab` / `Shift+Tab` | `CameraToggle` | perspective mode toggle |//! | `<` / `>` | `TiltDown` / `TiltUp` | keyboard tilt |//! | `PageUp` / `PageDown`, `Home`, `0` | slice, recenter, look reset | optional remaps |//! | `Space`, `.` | `Wait` | pass one tick |//! | `Esc`, `q` | `Quit` | |//! | `Enter` | `Confirm` | contextual interaction/menu confirmation |//! | `g` | `Pickup` | pick up a loose item at the player's cell (Phase 12 T.2) |//! | `-` | `Drop` | drop the selected carried item (Phase 12 T.2) |//! | everything else | `Unknown` | reserved for parent shortcuts / menus |//!//! # Lowercase-wins collisions (documented, tested)//!//! Two lowercase letters are claimed by actions, so their QEZC diagonal//! partners live on the uppercase key only://! - `q` → `Quit` wins; `Q` → `UpLeft` (the QEZC up-left diagonal).//! - `c` → `Contracts` wins; `C` → `DownRight` (the QEZC down-right diagonal).//! - `E`/`e` → `UpRight` and `Z`/`z` → `DownLeft` are free in both cases.//! - vi-diagonals `yubn` work in both cases (no collisions).//!//! # Every mouse action has a key twin//!//! | Mouse | Maps to | Key twin |//! |---|---|---|//! | left drag | `MouseDrag(column,row)` | look directional keys / tilt |//! | left press / release | `MouseDown(column,row)` / `MouseUp` | look mode |//! | other buttons / hover / scroll | `Unknown` | no game effect |//!//! The backend uses [`map_mouse_event`] to preserve coordinates. App owns//! drag state; dragging never toggles camera mode. PageUp/PageDown select slices.
use crate::Input;use crossterm::event::{KeyCode, KeyModifiers, MouseEventKind};
/// Repository path of the shipped keymap asset embedded at compile time.////// This is documentation for the source asset only; runtime lookup must join/// [`KEYMAP_FILE_NAME`] to the selected data root and must not use this path.pub const KEYMAP_PATH: &str = "assets/data/keymap.toml";
/// Runtime keymap basename, joined to the selected data root by the caller.pub const KEYMAP_FILE_NAME: &str = "keymap.toml";
/// The shipped default keymap as TOML text.////// This pins the defaults compiled into the game; the file at/// [`KEYMAP_PATH`] starts life as a copy of this text. Kept as `&'static str`/// (not parsed here) so this crate stays dependency-free beyond crossterm —/// parsing/validation belongs to the parent-side loader.pub fn default_keymap_toml() -> &'static str { crate::remap::DEFAULT_KEYMAP_TOML}
/// Map a key press to normalized [`Input`].////// `code` + `modifiers` come straight from crossterm's `KeyEvent`; release/// filtering happens in `backend::map_event`, so this function is total over/// presses and repeats.////// Modifier policy:/// - `Ctrl` / `Alt` / `Super` / `Hyper` chords → [`Input::Unknown`]/// (reserved for parent-side shortcuts; never steal them here)./// - `Shift` + any movement key (cardinal or diagonal, arrows or letters) →/// [`Input::PauseStep`]: "advance exactly one tick" (docs/04 §4.2). The/// variant is intentionally directionless — step mode ticks the sim once/// with whatever intent is already queued./// - `Shift` + non-movement keys is ignored (e.g. `X` still looks, `BackTab`/// still toggles the camera).////// Case policy: letters match case-insensitively, except `q`/`Q` and `c`/`C`/// (see "Lowercase-wins collisions" above) — and except `Enter`, below.////// `Enter` maps to [`Input::Confirm`], a consequence-free contextual request./// The parent controller decides whether it accepts a selected menu entry,/// uses a transition/service, turns in a contract, or attacks a nearby hostile.pub fn map_key(code: KeyCode, modifiers: KeyModifiers) -> Input { if modifiers.intersects( KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER | KeyModifiers::HYPER, ) { return Input::Unknown; } let shift = modifiers.contains(KeyModifiers::SHIFT);
// Movement layer (cardinals + diagonals). Shift short-circuits to step mode. let dir = match code { KeyCode::Up => Some(Input::Up), KeyCode::Down => Some(Input::Down), KeyCode::Left => Some(Input::Left), KeyCode::Right => Some(Input::Right), KeyCode::Char('w') | KeyCode::Char('W') => Some(Input::Up), KeyCode::Char('s') | KeyCode::Char('S') => Some(Input::Down), KeyCode::Char('a') | KeyCode::Char('A') => Some(Input::Left), KeyCode::Char('d') | KeyCode::Char('D') => Some(Input::Right), KeyCode::Char('h') | KeyCode::Char('H') => Some(Input::Left), KeyCode::Char('j') | KeyCode::Char('J') => Some(Input::Down), KeyCode::Char('k') | KeyCode::Char('K') => Some(Input::Up), KeyCode::Char('l') | KeyCode::Char('L') => Some(Input::Right), // Diagonals: QEZC + vi-keys yubn. Q/C uppercase-only (q=Quit, c=Contracts). KeyCode::Char('Q') | KeyCode::Char('y') | KeyCode::Char('Y') => Some(Input::UpLeft), KeyCode::Char('e') | KeyCode::Char('E') | KeyCode::Char('u') | KeyCode::Char('U') => { Some(Input::UpRight) } KeyCode::Char('z') | KeyCode::Char('Z') | KeyCode::Char('b') | KeyCode::Char('B') => { Some(Input::DownLeft) } KeyCode::Char('C') | KeyCode::Char('n') | KeyCode::Char('N') => Some(Input::DownRight), _ => None, }; if let Some(d) = dir { return if shift { Input::PauseStep } else { d }; }
// Action layer (shift-insensitive). match code { KeyCode::Esc => Input::Quit, KeyCode::Char('q') => Input::Quit, KeyCode::Char('x') | KeyCode::Char('X') => Input::Look, KeyCode::Char('i') | KeyCode::Char('I') => Input::Inventory, KeyCode::Char('c') => Input::Contracts, KeyCode::Char('m') | KeyCode::Char('M') => Input::Map, KeyCode::Char('p') | KeyCode::Char('P') => Input::Chapel, KeyCode::Tab | KeyCode::BackTab => Input::CameraToggle, KeyCode::Char('<') => Input::TiltDown, KeyCode::Char('>') => Input::TiltUp, KeyCode::PageUp => Input::SliceUp, KeyCode::PageDown => Input::SliceDown, KeyCode::Home => Input::Recenter, KeyCode::Char('0') => Input::LookReset, KeyCode::Char(' ') | KeyCode::Char('.') => Input::Wait, KeyCode::Enter => Input::Confirm, // Transfer verbs (Phase 12 T.2): the action layer is shift-insensitive, // so `G` also picks up; `-` drops the selected carried item. KeyCode::Char('g') | KeyCode::Char('G') => Input::Pickup, KeyCode::Char('-') => Input::Drop, // Chargen + menu digits: number keys select in menus; Play treats // them as no-ops (Unknown), so picks never leak into movement. KeyCode::Char('1') => Input::ChargenPick(0), KeyCode::Char('2') => Input::ChargenPick(1), KeyCode::Char('3') => Input::ChargenPick(2), KeyCode::Char('4') => Input::ChargenPick(3), KeyCode::Char('5') => Input::ChargenPick(4), KeyCode::Char('6') => Input::ChargenPick(5), KeyCode::Char('7') | KeyCode::Char('8') | KeyCode::Char('9') => Input::Unknown, _ => Input::Unknown, }}
/// Compatibility adapter without coordinates. Never invents an anchor or toggles./// Use [`map_mouse_event`] for the real terminal event path.pub fn map_mouse(_kind: MouseEventKind, _modifiers: KeyModifiers) -> Input { Input::Unknown}
/// Preserve zero-based left-button coordinates; App owns drag state and mode rules.pub fn map_mouse_event(event: crossterm::event::MouseEvent) -> Input { use crossterm::event::MouseButton; match event.kind { MouseEventKind::Down(MouseButton::Left) => Input::MouseDown(event.column, event.row), MouseEventKind::Drag(MouseButton::Left) => Input::MouseDrag(event.column, event.row), MouseEventKind::Up(MouseButton::Left) => Input::MouseUp, _ => Input::Unknown, }}
#[cfg(test)]mod tests { use super::*; use crossterm::event::MouseButton;
const NO_MOD: KeyModifiers = KeyModifiers::NONE; const SHIFT: KeyModifiers = KeyModifiers::SHIFT;
#[test] fn arrows_map_to_cardinals() { assert_eq!(map_key(KeyCode::Up, NO_MOD), Input::Up); assert_eq!(map_key(KeyCode::Down, NO_MOD), Input::Down); assert_eq!(map_key(KeyCode::Left, NO_MOD), Input::Left); assert_eq!(map_key(KeyCode::Right, NO_MOD), Input::Right); }
#[test] fn wasd_maps_case_insensitively() { assert_eq!(map_key(KeyCode::Char('w'), NO_MOD), Input::Up); assert_eq!(map_key(KeyCode::Char('s'), NO_MOD), Input::Down); assert_eq!(map_key(KeyCode::Char('a'), NO_MOD), Input::Left); assert_eq!(map_key(KeyCode::Char('d'), NO_MOD), Input::Right); assert_eq!(map_key(KeyCode::Char('W'), NO_MOD), Input::Up); assert_eq!(map_key(KeyCode::Char('S'), NO_MOD), Input::Down); assert_eq!(map_key(KeyCode::Char('A'), NO_MOD), Input::Left); assert_eq!(map_key(KeyCode::Char('D'), NO_MOD), Input::Right); }
#[test] fn hjkl_maps_to_cardinals() { assert_eq!(map_key(KeyCode::Char('h'), NO_MOD), Input::Left); assert_eq!(map_key(KeyCode::Char('j'), NO_MOD), Input::Down); assert_eq!(map_key(KeyCode::Char('k'), NO_MOD), Input::Up); assert_eq!(map_key(KeyCode::Char('l'), NO_MOD), Input::Right); assert_eq!(map_key(KeyCode::Char('H'), NO_MOD), Input::Left); assert_eq!(map_key(KeyCode::Char('K'), NO_MOD), Input::Up); }
#[test] fn qezc_diagonals_with_documented_collisions() { // Uppercase-only partners (lowercase claimed by Quit/Contracts). assert_eq!(map_key(KeyCode::Char('Q'), NO_MOD), Input::UpLeft); assert_eq!(map_key(KeyCode::Char('C'), NO_MOD), Input::DownRight); // Free in both cases. assert_eq!(map_key(KeyCode::Char('e'), NO_MOD), Input::UpRight); assert_eq!(map_key(KeyCode::Char('E'), NO_MOD), Input::UpRight); assert_eq!(map_key(KeyCode::Char('z'), NO_MOD), Input::DownLeft); assert_eq!(map_key(KeyCode::Char('Z'), NO_MOD), Input::DownLeft); }
#[test] fn vi_diagonals_yubn() { assert_eq!(map_key(KeyCode::Char('y'), NO_MOD), Input::UpLeft); assert_eq!(map_key(KeyCode::Char('u'), NO_MOD), Input::UpRight); assert_eq!(map_key(KeyCode::Char('b'), NO_MOD), Input::DownLeft); assert_eq!(map_key(KeyCode::Char('n'), NO_MOD), Input::DownRight); assert_eq!(map_key(KeyCode::Char('Y'), NO_MOD), Input::UpLeft); assert_eq!(map_key(KeyCode::Char('U'), NO_MOD), Input::UpRight); assert_eq!(map_key(KeyCode::Char('B'), NO_MOD), Input::DownLeft); assert_eq!(map_key(KeyCode::Char('N'), NO_MOD), Input::DownRight); }
#[test] fn quit_keys_with_q_collision_documented() { assert_eq!(map_key(KeyCode::Esc, NO_MOD), Input::Quit); assert_eq!(map_key(KeyCode::Char('q'), NO_MOD), Input::Quit); // Uppercase Q is the up-left diagonal, NOT quit (tested collision). assert_ne!(map_key(KeyCode::Char('Q'), NO_MOD), Input::Quit); }
#[test] fn action_keys_look_inventory_contracts_map_chapel() { assert_eq!(map_key(KeyCode::Char('x'), NO_MOD), Input::Look); assert_eq!(map_key(KeyCode::Char('i'), NO_MOD), Input::Inventory); assert_eq!(map_key(KeyCode::Char('c'), NO_MOD), Input::Contracts); assert_eq!(map_key(KeyCode::Char('m'), NO_MOD), Input::Map); assert_eq!(map_key(KeyCode::Char('p'), NO_MOD), Input::Chapel); // Lenient uppercase (except the two documented collisions). assert_eq!(map_key(KeyCode::Char('X'), NO_MOD), Input::Look); assert_eq!(map_key(KeyCode::Char('P'), NO_MOD), Input::Chapel); assert_ne!(map_key(KeyCode::Char('C'), NO_MOD), Input::Contracts); }
#[test] fn tab_toggles_camera() { assert_eq!(map_key(KeyCode::Tab, NO_MOD), Input::CameraToggle); assert_eq!(map_key(KeyCode::BackTab, NO_MOD), Input::CameraToggle); }
#[test] fn wait_keys() { assert_eq!(map_key(KeyCode::Char(' '), NO_MOD), Input::Wait); assert_eq!(map_key(KeyCode::Char('.'), NO_MOD), Input::Wait); }
#[test] fn toggle_walk_default_binding_is_t() { let keymap = crate::remap::parse_keymap(default_keymap_toml()).unwrap(); assert_eq!(keymap.map_key(KeyCode::Char('t'), NO_MOD), Input::ToggleWalk); // Action layer is shift-insensitive, so uppercase T toggles too. assert_eq!(keymap.map_key(KeyCode::Char('T'), NO_MOD), Input::ToggleWalk); }
#[test] fn shift_plus_movement_steps_one_tick() { assert_eq!(map_key(KeyCode::Up, SHIFT), Input::PauseStep); assert_eq!(map_key(KeyCode::Left, SHIFT), Input::PauseStep); assert_eq!(map_key(KeyCode::Char('w'), SHIFT), Input::PauseStep); assert_eq!(map_key(KeyCode::Char('k'), SHIFT), Input::PauseStep); assert_eq!(map_key(KeyCode::Char('y'), SHIFT), Input::PauseStep); assert_eq!(map_key(KeyCode::Char('Q'), SHIFT), Input::PauseStep); // Shift does NOT alter action keys. assert_eq!(map_key(KeyCode::Char('x'), SHIFT), Input::Look); assert_eq!(map_key(KeyCode::Tab, SHIFT), Input::CameraToggle); }
#[test] fn enter_is_contextual_confirm() { // Confirm itself has no sim consequence; the parent resolves context. assert_eq!(map_key(KeyCode::Enter, NO_MOD), Input::Confirm); }
#[test] fn unbound_and_modified_keys_are_unknown() { assert_eq!(map_key(KeyCode::F(1), NO_MOD), Input::Unknown); assert_eq!(map_key(KeyCode::End, NO_MOD), Input::Unknown); assert_eq!(map_key(KeyCode::Delete, NO_MOD), Input::Unknown); assert_eq!(map_key(KeyCode::Char('v'), NO_MOD), Input::Unknown); // Ctrl/Alt chords are reserved for parent shortcuts, never stolen here. assert_eq!( map_key(KeyCode::Char('c'), KeyModifiers::CONTROL), Input::Unknown ); assert_eq!( map_key(KeyCode::Char('x'), KeyModifiers::ALT), Input::Unknown ); }
#[test] fn coordinate_free_mouse_never_toggles() { for button in [MouseButton::Left, MouseButton::Right, MouseButton::Middle] { assert_eq!( map_mouse(MouseEventKind::Drag(button), NO_MOD), Input::Unknown ); } }
#[test] fn approved_camera_keys_match_loaded_map() { let map = crate::remap::parse_keymap(default_keymap_toml()).unwrap(); for (code, expected) in [ (KeyCode::Char('<'), Input::TiltDown), (KeyCode::Char('>'), Input::TiltUp), (KeyCode::PageUp, Input::SliceUp), (KeyCode::PageDown, Input::SliceDown), (KeyCode::Home, Input::Recenter), (KeyCode::Char('0'), Input::LookReset), ] { for modifiers in [NO_MOD, SHIFT] { assert_eq!(map_key(code, modifiers), expected); assert_eq!(map.map_key(code, modifiers), expected); } } }
#[test] fn mouse_click_scroll_hover_reserved_unknown() { assert_eq!( map_mouse(MouseEventKind::Down(MouseButton::Left), NO_MOD), Input::Unknown ); assert_eq!( map_mouse(MouseEventKind::Up(MouseButton::Left), NO_MOD), Input::Unknown ); assert_eq!(map_mouse(MouseEventKind::Moved, NO_MOD), Input::Unknown); assert_eq!( map_mouse(MouseEventKind::ScrollDown, NO_MOD), Input::Unknown ); assert_eq!(map_mouse(MouseEventKind::ScrollUp, NO_MOD), Input::Unknown); assert_eq!( map_mouse(MouseEventKind::ScrollLeft, NO_MOD), Input::Unknown ); assert_eq!( map_mouse(MouseEventKind::ScrollRight, NO_MOD), Input::Unknown ); }
#[test] fn keymap_path_is_single_canonical() { assert_eq!(KEYMAP_PATH, "assets/data/keymap.toml"); assert_eq!(KEYMAP_FILE_NAME, "keymap.toml"); }
#[test] fn default_toml_pins_every_binding() { let t = default_keymap_toml(); // Sections. for section in ["[move]", "[step]", "[action]", "[mouse]"] { assert!(t.contains(section), "missing {section}"); } // Movement + diagonals. for key in [ "up", "down", "left", "right", "up_left", "up_right", "down_left", "down_right", "pause_step", "look", "inventory", "contracts", "map_tactical", "chapel", "camera_toggle", "wait", "quit", "confirm", "drag_orbit", "pickup", "drop", ] { assert!(t.contains(key), "missing {key}"); } // Representative bound symbols. for sym in [ "\"Tab\"", "\"Esc\"", "\"Space\"", "\".\"", "\"Enter\"", "\"g\"", "\"-\"", ] { assert!(t.contains(sym), "missing {sym}"); } }}