from __future__ import annotations import configparser from pathlib import Path DEFAULT_KEYBINDINGS: dict[str, list[str]] = { "move_up": ["w", "ArrowUp"], "move_down": ["s", "ArrowDown"], "move_left": ["a", "ArrowLeft"], "move_right": ["d", "ArrowRight"], "leap": ["space"], "swim_down": ["Control"], "block": ["b"], "activate_right_hand": ["mouse_left"], "activate_left_hand": ["mouse_right"], "activate_right_hand_2": ["shift+mouse_left"], "activate_left_hand_2": ["shift+mouse_right"], "select_ability_1": ["1"], "select_ability_2": ["2"], "select_ability_3": ["3"], "select_ability_4": ["4"], "select_ability_5": ["5"], "select_ability_6": ["6"], "select_ability_7": ["7"], "select_ability_8": ["8"], "select_ability_9": ["9"], "select_ability_0": ["0"], "activate_selected_ability": ["e"], "pickup": ["f"], "pause": ["Escape"], "toggle_character_card": ["c"], "cycle_character_tab": ["Tab"], "toggle_dev_mode": ["F1"], "dev_cycle_spawn": ["F2"], "dev_spawn_object": ["F3"], "dev_spawn_npc_menu": ["F4"], } # Human-friendly config names for keys that don't have a printable representation. _KEY_ALIASES = {"space": " "} def _normalize(key: str) -> str: return _KEY_ALIASES.get(key, key) class KeyBindings: """Action -> list of keys, loaded from an editable .conf file (arrays, so several keys can share one action). Falls back to DEFAULT_KEYBINDINGS for anything the file omits. """ def __init__(self, bindings: dict[str, list[str]]): self.bindings = bindings self._action_by_key: dict[str, str] = {} self._reindex() def _reindex(self) -> None: self._action_by_key = {} for action, keys in self.bindings.items(): for key in keys: self._action_by_key[_normalize(key)] = action @classmethod def load(cls, path: Path) -> "KeyBindings": bindings = {action: list(keys) for action, keys in DEFAULT_KEYBINDINGS.items()} if path.exists(): parser = configparser.ConfigParser() parser.read(path) for section in parser.sections(): for action, raw in parser.items(section): keys = [k.strip() for k in raw.split(",") if k.strip()] if keys: bindings[action] = keys return cls(bindings) def action_for_key(self, key: str) -> str | None: return self._action_by_key.get(key) def keys_for_action(self, action: str) -> list[str]: return self.bindings.get(action, []) def rebind(self, action: str, key: str, index: int = 0) -> None: """Replaces the key at `index` in `action`'s bound-key list (default: its primary key) with `key` - the "configure controls" menu's rebind action (engine/controls_menu.py). Steals `key` away from any other action it was already bound to first, so two actions can never end up racing for the same input. """ key = _normalize(key) for other_action, keys in self.bindings.items(): if other_action != action and key in keys: keys.remove(key) keys = self.bindings.setdefault(action, []) if index < len(keys): keys[index] = key else: keys.append(key) self._reindex() def save(self, path: Path) -> None: parser = configparser.ConfigParser() parser.add_section("actions") for action, keys in self.bindings.items(): parser.set("actions", action, ", ".join(keys)) path.parent.mkdir(parents=True, exist_ok=True) with path.open("w") as handle: parser.write(handle)