From a063a61a137fe1709c838e26ad5087ecdddfa962 Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:01:47 +0400 Subject: [PATCH 01/15] refactor: simplify keybind config boundaries --- src/crimson/input_codes.py | 95 ++------ src/crimson/local_input.py | 117 ++++------ .../components/perk_prompt_controller.py | 16 +- src/crimson/modes/tutorial_mode.py | 14 +- src/crimson/screens/panels/controls.py | 202 +++++++++++----- src/crimson/screens/panels/controls_labels.py | 69 ++++-- src/crimson/ui/text_input.py | 15 +- src/grim/config.py | 220 ++++++++++++------ tests/conftest.py | 4 +- tests/grim/test_grim_config.py | 51 +++- tests/input/test_input_codes.py | 26 +-- tests/input/test_local_input.py | 188 +++++++-------- tests/modes/test_perk_prompt_controller.py | 20 +- tests/ui/test_controls_labels.py | 36 ++- 14 files changed, 587 insertions(+), 486 deletions(-) diff --git a/src/crimson/input_codes.py b/src/crimson/input_codes.py index 6e1ef373c..296f55bec 100644 --- a/src/crimson/input_codes.py +++ b/src/crimson/input_codes.py @@ -4,7 +4,6 @@ import msgspec -from grim.config import CrimsonConfig, default_player_keybind_block from grim.raylib_api import rl INPUT_CODE_UNBOUND = 0x17E @@ -234,7 +233,7 @@ def _axis_value_from_code(key_code: int, *, player_index: int) -> float: return 0.0 -def input_axis_value_for_player(key_code: int, *, player_index: int) -> float: +def input_axis_value(key_code: int, *, player_index: int = 0) -> float: return _axis_value_from_code(int(key_code), player_index=int(player_index)) @@ -372,20 +371,12 @@ def input_code_name(key_code: int) -> str: return f"KEY_{key_code:04X}" -def input_code_is_down(key_code: int) -> bool: - return input_code_is_down_for_player(int(key_code), player_index=0) - - -def input_code_is_pressed(key_code: int) -> bool: - return input_code_is_pressed_for_player(int(key_code), player_index=0) - - -def input_code_is_down_for_player(key_code: int, *, player_index: int) -> bool: +def input_code_is_down(key_code: int, *, player_index: int = 0) -> bool: down = _digital_down_for_player(int(key_code), player_index=int(player_index)) return _PRESSED_STATE.mark_down(player_index=int(player_index), key_code=int(key_code), is_down=down) -def input_code_is_pressed_for_player(key_code: int, *, player_index: int) -> bool: +def input_code_is_pressed(key_code: int, *, player_index: int = 0) -> bool: code = int(key_code) player_idx = int(player_index) if code == 0x109: @@ -440,69 +431,25 @@ def capture_first_pressed_input_code( value = float(rl.get_gamepad_axis_movement(gamepad, axis)) if abs(value) >= float(axis_threshold): return int(code) - return None -def _parse_keybinds_blob(blob: bytes | bytearray | None) -> tuple[int, ...]: - if blob is None: - return () - if not isinstance(blob, (bytes, bytearray)): - return () - if len(blob) != 0x80: - return () - out: list[int] = [] - for offset in range(0, 0x80, 4): - out.append(int.from_bytes(blob[offset : offset + 4], "little")) - return tuple(out) - - -def config_keybinds(config: CrimsonConfig | None) -> tuple[int, ...]: - if config is None: - return () - values: list[int] = [] - for player_index in range(2): - values.extend(int(value) for value in config.controls.player(player_index).keybinds) - return tuple(values) - - -def config_keybinds_for_player(config: CrimsonConfig | None, *, player_index: int) -> tuple[int, ...]: - if config is None: - return () - return tuple(int(value) for value in config.controls.player(player_index).keybinds) - - -def player_fire_keybind(config: CrimsonConfig | None, *, player_index: int) -> int: - idx = max(0, min(3, int(player_index))) - keybinds = config_keybinds_for_player(config, player_index=idx) - if len(keybinds) >= 5: - return int(keybinds[4]) - return int(default_player_keybind_block(idx)[4]) - - -def player_move_fire_keybinds(config: CrimsonConfig | None, *, player_index: int) -> tuple[int, int, int, int, int]: - idx = max(0, min(3, int(player_index))) - keybinds = config_keybinds_for_player(config, player_index=idx) - if len(keybinds) >= 5: - return player_move_fire_binds(keybinds, 0) - defaults = tuple(int(value) for value in default_player_keybind_block(idx)) - return int(defaults[0]), int(defaults[1]), int(defaults[2]), int(defaults[3]), int(defaults[4]) - - -def _input_primary_any_down(config: CrimsonConfig | None, *, player_count: int) -> bool: - if input_code_is_down_for_player(0x100, player_index=0): +def _input_primary_any_down(*, fire_codes: Sequence[int], player_count: int) -> bool: + if input_code_is_down(0x100, player_index=0): return True count = max(1, min(4, int(player_count))) + if len(fire_codes) < count: + raise ValueError(f"fire_codes must provide at least {count} entries, got {len(fire_codes)}") for player_index in range(count): - fire_key = player_fire_keybind(config, player_index=player_index) - if input_code_is_down_for_player(fire_key, player_index=player_index): + fire_key = int(fire_codes[player_index]) + if input_code_is_down(fire_key, player_index=player_index): return True return False -def input_primary_is_down(config: CrimsonConfig | None, *, player_count: int) -> bool: - down = _input_primary_any_down(config, player_count=player_count) +def input_primary_is_down(*, fire_codes: Sequence[int], player_count: int) -> bool: + down = _input_primary_any_down(fire_codes=fire_codes, player_count=player_count) _PRESSED_STATE.mark_down( player_index=_PRIMARY_EDGE_SENTINEL_PLAYER, key_code=_PRIMARY_EDGE_SENTINEL_KEY, @@ -511,26 +458,10 @@ def input_primary_is_down(config: CrimsonConfig | None, *, player_count: int) -> return bool(down) -def input_primary_just_pressed(config: CrimsonConfig | None, *, player_count: int) -> bool: - down = _input_primary_any_down(config, player_count=player_count) +def input_primary_just_pressed(*, fire_codes: Sequence[int], player_count: int) -> bool: + down = _input_primary_any_down(fire_codes=fire_codes, player_count=player_count) return _PRESSED_STATE.is_pressed( player_index=_PRIMARY_EDGE_SENTINEL_PLAYER, key_code=_PRIMARY_EDGE_SENTINEL_KEY, is_down=down, ) - - -def player_move_fire_binds(keybinds: Sequence[int], player_index: int) -> tuple[int, int, int, int, int]: - """Return (up, down, left, right, fire) key codes for a player. - - The classic config packs keybind blocks in 0x10-int strides; the first five entries - are used by `ui_render_keybind_help` (Up/Down/Left/Right/Fire). - """ - - base = int(player_index) * 0x10 - values = [INPUT_CODE_UNBOUND, INPUT_CODE_UNBOUND, INPUT_CODE_UNBOUND, INPUT_CODE_UNBOUND, INPUT_CODE_UNBOUND] - for idx in range(5): - src = base + idx - if 0 <= src < len(keybinds): - values[idx] = int(keybinds[src]) - return values[0], values[1], values[2], values[3], values[4] diff --git a/src/crimson/local_input.py b/src/crimson/local_input.py index 05e1439c9..6318af3c3 100644 --- a/src/crimson/local_input.py +++ b/src/crimson/local_input.py @@ -6,17 +6,16 @@ import msgspec -from grim.config import CrimsonConfig, default_player_keybind_block +from grim.config import CrimsonConfig from grim.geom import Vec2 from grim.raylib_api import rl from .aim_constants import _AIM_JOYSTICK_TURN_RATE, _AIM_KEYBOARD_TURN_RATE from .aim_schemes import AimScheme from .input_codes import ( - config_keybinds_for_player, - input_axis_value_for_player, - input_code_is_down_for_player, - input_code_is_pressed_for_player, + input_axis_value, + input_code_is_down, + input_code_is_pressed, ) from .movement_controls import MovementControlType from .screens.panels.controls_labels import controls_method_values @@ -35,18 +34,6 @@ _COMPUTER_AIM_TRACK_GAIN = 6.0 _COMPUTER_AUTO_FIRE_DISTANCE = 128.0 -_MOVE_SLOT_UP = 0 -_MOVE_SLOT_DOWN = 1 -_MOVE_SLOT_LEFT = 2 -_MOVE_SLOT_RIGHT = 3 -_FIRE_SLOT = 4 -_AIM_LEFT_SLOT = 7 -_AIM_RIGHT_SLOT = 8 -_AIM_AXIS_Y_SLOT = 9 -_AIM_AXIS_X_SLOT = 10 -_MOVE_AXIS_Y_SLOT = 11 -_MOVE_AXIS_X_SLOT = 12 - _ALT_MOVE_KEY_UP = 0xC8 _ALT_MOVE_KEY_DOWN = 0xD0 _ALT_MOVE_KEY_LEFT = 0xCB @@ -119,19 +106,11 @@ def _resolve_static_move_vector( return move -def _load_player_bind_block(config: CrimsonConfig | None, *, player_index: int) -> tuple[int, ...]: - binds = config_keybinds_for_player(config, player_index=int(player_index)) - if len(binds) >= 16: - return tuple(int(v) for v in binds[:16]) - return tuple(int(v) for v in default_player_keybind_block(int(player_index))) - - -def _config_player_count(config: CrimsonConfig | None) -> int: - value = config.gameplay.player_count if config is not None else 1 - return max(1, value) +def _config_player_count(config: CrimsonConfig) -> int: + return max(1, int(config.gameplay.player_count)) -def _single_player_alt_keys_enabled(config: CrimsonConfig | None, *, player_index: int) -> bool: +def _single_player_alt_keys_enabled(config: CrimsonConfig, *, player_index: int) -> bool: return int(player_index) == 0 and _config_player_count(config) == 1 @@ -139,26 +118,26 @@ def _key_down_with_single_player_alt( primary_key: int, *, alt_key: int, - config: CrimsonConfig | None, + config: CrimsonConfig, player_index: int, ) -> bool: - if input_code_is_down_for_player(primary_key, player_index=int(player_index)): + if input_code_is_down(primary_key, player_index=int(player_index)): return True if _single_player_alt_keys_enabled(config, player_index=int(player_index)): - return input_code_is_down_for_player(int(alt_key), player_index=int(player_index)) + return input_code_is_down(int(alt_key), player_index=int(player_index)) return False def _aim_pov_left_active(*, player_index: int, preserve_bugs: bool) -> bool: # Native `input_aim_pov_left_active` always reads joystick POV index 0. pov_index = 0 if preserve_bugs else int(player_index) - return input_code_is_down_for_player(_AIM_POV_LEFT_CODE, player_index=pov_index) + return input_code_is_down(_AIM_POV_LEFT_CODE, player_index=pov_index) def _aim_pov_right_active(*, player_index: int, preserve_bugs: bool) -> bool: # Native `input_aim_pov_right_active` always reads joystick POV index 0. pov_index = 0 if preserve_bugs else int(player_index) - return input_code_is_down_for_player(_AIM_POV_RIGHT_CODE, player_index=pov_index) + return input_code_is_down(_AIM_POV_RIGHT_CODE, player_index=pov_index) def clear_input_edges(inputs: Sequence[PlayerInput]) -> list[PlayerInput]: @@ -268,15 +247,7 @@ def _state_for_player(self, player_index: int, *, player: PlayerState | None = N return state @staticmethod - def _reload_key(config: CrimsonConfig | None) -> int: - if config is None: - return 0x102 - return config.controls.reload_key - - @staticmethod - def _safe_controls_modes(config: CrimsonConfig | None, *, player_index: int) -> tuple[AimScheme, MovementControlType]: - if config is None: - return AimScheme.MOUSE, MovementControlType.STATIC + def _safe_controls_modes(config: CrimsonConfig, *, player_index: int) -> tuple[AimScheme, MovementControlType]: aim_scheme, move_mode = controls_method_values(config.controls, player_index=int(player_index)) return aim_scheme, move_mode @@ -285,7 +256,7 @@ def build_player_input( *, player_index: int, player: PlayerState, - config: CrimsonConfig | None, + config: CrimsonConfig, mouse_screen: Vec2, mouse_world: Vec2, screen_center: Vec2, @@ -294,21 +265,21 @@ def build_player_input( ) -> PlayerInput: idx = max(0, min(3, int(player_index))) state = self._state_for_player(idx, player=player) - binds = _load_player_bind_block(config, player_index=idx) + binds = config.controls.player(idx) aim_scheme, move_mode_type = self._safe_controls_modes(config, player_index=idx) - reload_key = self._reload_key(config) - - up_key = int(binds[_MOVE_SLOT_UP]) - down_key = int(binds[_MOVE_SLOT_DOWN]) - left_key = int(binds[_MOVE_SLOT_LEFT]) - right_key = int(binds[_MOVE_SLOT_RIGHT]) - fire_key = int(binds[_FIRE_SLOT]) - aim_left_key = int(binds[_AIM_LEFT_SLOT]) - aim_right_key = int(binds[_AIM_RIGHT_SLOT]) - aim_axis_y = int(binds[_AIM_AXIS_Y_SLOT]) - aim_axis_x = int(binds[_AIM_AXIS_X_SLOT]) - move_axis_y = int(binds[_MOVE_AXIS_Y_SLOT]) - move_axis_x = int(binds[_MOVE_AXIS_X_SLOT]) + reload_key = config.controls.reload_code + + up_key = int(binds.move_forward_code) + down_key = int(binds.move_backward_code) + left_key = int(binds.turn_left_code) + right_key = int(binds.turn_right_code) + fire_key = int(binds.fire_code) + aim_left_key = int(binds.aim_left_code) + aim_right_key = int(binds.aim_right_code) + aim_axis_y = int(binds.aim_vertical_axis_code) + aim_axis_x = int(binds.aim_horizontal_axis_code) + move_axis_y = int(binds.move_vertical_axis_code) + move_axis_x = int(binds.move_horizontal_axis_code) move_vec = Vec2() move_forward_pressed: bool | None = None @@ -377,11 +348,11 @@ def build_player_input( float(move_backward_pressed) - float(move_forward_pressed), ) elif move_mode_type is MovementControlType.DUAL_ACTION_PAD: - axis_y = -input_axis_value_for_player(move_axis_y, player_index=idx) - axis_x = -input_axis_value_for_player(move_axis_x, player_index=idx) + axis_y = -input_axis_value(move_axis_y, player_index=idx) + axis_x = -input_axis_value(move_axis_x, player_index=idx) move_vec = Vec2(_clamp_unit(axis_x), _clamp_unit(axis_y)) elif move_mode_type is MovementControlType.MOUSE_POINT_CLICK: - move_to_cursor_pressed = input_code_is_down_for_player(reload_key, player_index=idx) + move_to_cursor_pressed = input_code_is_down(reload_key, player_index=idx) if move_to_cursor_pressed: state.move_target = mouse_world if float(state.move_target.x) >= 0.0 and float(state.move_target.y) >= 0.0: @@ -426,10 +397,10 @@ def build_player_input( ) else: move_vec = Vec2( - float(input_code_is_down_for_player(right_key, player_index=idx)) - - float(input_code_is_down_for_player(left_key, player_index=idx)), - float(input_code_is_down_for_player(down_key, player_index=idx)) - - float(input_code_is_down_for_player(up_key, player_index=idx)), + float(input_code_is_down(right_key, player_index=idx)) + - float(input_code_is_down(left_key, player_index=idx)), + float(input_code_is_down(down_key, player_index=idx)) + - float(input_code_is_down(up_key, player_index=idx)), ) heading = float(state.aim_heading) @@ -444,9 +415,9 @@ def build_player_input( heading = delta.to_heading() elif aim_scheme is AimScheme.KEYBOARD: if move_mode_type in {MovementControlType.RELATIVE, MovementControlType.STATIC}: - if input_code_is_down_for_player(aim_right_key, player_index=idx): + if input_code_is_down(aim_right_key, player_index=idx): heading = float(heading + float(dt) * _AIM_KEYBOARD_TURN_RATE) - if input_code_is_down_for_player(aim_left_key, player_index=idx): + if input_code_is_down(aim_left_key, player_index=idx): heading = float(heading - float(dt) * _AIM_KEYBOARD_TURN_RATE) aim = _aim_point_from_heading(player.pos, heading) elif aim_scheme is AimScheme.MOUSE_RELATIVE: @@ -455,8 +426,8 @@ def build_player_input( heading = rel.to_heading() aim = _aim_point_from_heading(player.pos, heading) elif aim_scheme is AimScheme.DUAL_ACTION_PAD: - axis_y = input_axis_value_for_player(aim_axis_y, player_index=idx) - axis_x = input_axis_value_for_player(aim_axis_x, player_index=idx) + axis_y = input_axis_value(aim_axis_y, player_index=idx) + axis_x = input_axis_value(aim_axis_x, player_index=idx) axis_vec = Vec2(axis_x, axis_y) mag_sq = axis_vec.length_sq() if mag_sq > 1e-9: @@ -505,12 +476,12 @@ def build_player_input( heading = delta.to_heading() state.aim_heading = float(heading) - fire_down = input_code_is_down_for_player(fire_key, player_index=idx) - fire_pressed = input_code_is_pressed_for_player(fire_key, player_index=idx) + fire_down = input_code_is_down(fire_key, player_index=idx) + fire_pressed = input_code_is_pressed(fire_key, player_index=idx) if aim_scheme is AimScheme.COMPUTER and computer_auto_fire: fire_down = True - reload_pressed = input_code_is_pressed_for_player(reload_key, player_index=idx) - reload_down = input_code_is_down_for_player(reload_key, player_index=idx) + reload_pressed = input_code_is_pressed(reload_key, player_index=idx) + reload_down = input_code_is_down(reload_key, player_index=idx) return PlayerInput( move=move_vec, @@ -532,7 +503,7 @@ def build_frame_inputs( self, *, players: Sequence[PlayerState], - config: CrimsonConfig | None, + config: CrimsonConfig, mouse_screen: Vec2, screen_to_world: Callable[[Vec2], Vec2], dt: float, diff --git a/src/crimson/modes/components/perk_prompt_controller.py b/src/crimson/modes/components/perk_prompt_controller.py index f3de297ba..4f9687551 100644 --- a/src/crimson/modes/components/perk_prompt_controller.py +++ b/src/crimson/modes/components/perk_prompt_controller.py @@ -7,10 +7,9 @@ from grim.math import clamp from ...input_codes import ( - input_code_is_down_for_player, - input_code_is_pressed_for_player, + input_code_is_down, + input_code_is_pressed, input_primary_just_pressed, - player_fire_keybind, ) from .perk_menu_controller import PerkMenuUiContext from .perk_prompt_ui import PERK_PROMPT_MAX_TIMER_MS, PerkPromptUi @@ -106,10 +105,11 @@ def draw( ) def _prompt_open_requested(self, *, config: CrimsonConfig, player_count: int) -> bool: - fire_key = player_fire_keybind(config, player_index=0) - pick_key = config.controls.pick_perk_key - if input_code_is_pressed_for_player(pick_key, player_index=0) and ( - not input_code_is_down_for_player(fire_key, player_index=0) + fire_key = int(config.controls.player(0).fire_code) + pick_key = config.controls.pick_perk_code + if input_code_is_pressed(pick_key, player_index=0) and ( + not input_code_is_down(fire_key, player_index=0) ): return True - return self.hover and input_primary_just_pressed(config, player_count=player_count) + fire_codes = tuple(int(config.controls.player(idx).fire_code) for idx in range(4)) + return self.hover and input_primary_just_pressed(fire_codes=fire_codes, player_count=player_count) diff --git a/src/crimson/modes/tutorial_mode.py b/src/crimson/modes/tutorial_mode.py index f5b4638b0..948919018 100644 --- a/src/crimson/modes/tutorial_mode.py +++ b/src/crimson/modes/tutorial_mode.py @@ -11,7 +11,7 @@ from grim.view import ViewContext from ..game_modes import GameMode -from ..input_codes import input_code_is_down, input_code_is_pressed, player_move_fire_keybinds +from ..input_codes import input_code_is_down, input_code_is_pressed from ..perks.selection import perk_selection_prepared_choices from ..replay import ReplayHeader, ReplayRecorder, ReplayStatusSnapshot from ..replay.checkpoints import DEFAULT_CHECKPOINT_SAMPLE_RATE @@ -185,10 +185,12 @@ def _handle_input(self) -> None: return def _build_input(self) -> PlayerInput: - up_key, down_key, left_key, right_key, fire_key = player_move_fire_keybinds( - self.config, - player_index=0, - ) + controls = self.config.controls.player(0) + up_key = int(controls.move_forward_code) + down_key = int(controls.move_backward_code) + left_key = int(controls.turn_left_code) + right_key = int(controls.turn_right_code) + fire_key = int(controls.fire_code) move = Vec2( float(input_code_is_down(right_key)) - float(input_code_is_down(left_key)), @@ -200,7 +202,7 @@ def _build_input(self) -> PlayerInput: fire_down = input_code_is_down(fire_key) fire_pressed = input_code_is_pressed(fire_key) - reload_key = self.config.controls.reload_key + reload_key = self.config.controls.reload_code reload_pressed = input_code_is_pressed(reload_key) return PlayerInput( diff --git a/src/crimson/screens/panels/controls.py b/src/crimson/screens/panels/controls.py index edc78e047..c0e0c58e2 100644 --- a/src/crimson/screens/panels/controls.py +++ b/src/crimson/screens/panels/controls.py @@ -4,8 +4,8 @@ from grim.assets import RuntimeResources, TextureId from grim.config import ( - KEYBIND_UNBOUND_CODE, - default_player_keybind_block, + CrimsonPlayerControls, + default_crimson_cfg, ) from grim.fonts.small import SmallFontData, draw_small_text, measure_small_text_width from grim.geom import Rect, Vec2 @@ -25,11 +25,10 @@ ) from .base import PANEL_TIMELINE_END_MS, PANEL_TIMELINE_START_MS, PanelMenuView from .controls_labels import ( - PICK_PERK_BIND_SLOT, - RELOAD_BIND_SLOT, + BindingId, controls_aim_method_dropdown_ids, controls_method_values, - controls_rebind_slot_plan, + controls_rebind_plan, input_configure_for_label, input_scheme_label, ) @@ -49,7 +48,94 @@ CONTROLS_REBIND_HOVER_COLOR = rl.Color(200, 230, 250, 230) CONTROLS_REBIND_ACTIVE_COLOR = rl.Color(255, 228, 170, 255) -_AXIS_REBIND_SLOTS = frozenset((9, 10, 11, 12)) +_AXIS_REBIND_BINDINGS = frozenset( + ( + BindingId.AIM_VERTICAL_AXIS_CODE, + BindingId.AIM_HORIZONTAL_AXIS_CODE, + BindingId.MOVE_VERTICAL_AXIS_CODE, + BindingId.MOVE_HORIZONTAL_AXIS_CODE, + ), +) + + +def _binding_code(player: CrimsonPlayerControls, binding_id: BindingId, *, controls) -> int: + if binding_id is BindingId.MOVE_FORWARD_CODE: + return int(player.move_forward_code) + if binding_id is BindingId.MOVE_BACKWARD_CODE: + return int(player.move_backward_code) + if binding_id is BindingId.TURN_LEFT_CODE: + return int(player.turn_left_code) + if binding_id is BindingId.TURN_RIGHT_CODE: + return int(player.turn_right_code) + if binding_id is BindingId.FIRE_CODE: + return int(player.fire_code) + if binding_id is BindingId.AIM_LEFT_CODE: + return int(player.aim_left_code) + if binding_id is BindingId.AIM_RIGHT_CODE: + return int(player.aim_right_code) + if binding_id is BindingId.AIM_VERTICAL_AXIS_CODE: + return int(player.aim_vertical_axis_code) + if binding_id is BindingId.AIM_HORIZONTAL_AXIS_CODE: + return int(player.aim_horizontal_axis_code) + if binding_id is BindingId.MOVE_VERTICAL_AXIS_CODE: + return int(player.move_vertical_axis_code) + if binding_id is BindingId.MOVE_HORIZONTAL_AXIS_CODE: + return int(player.move_horizontal_axis_code) + if binding_id is BindingId.PICK_PERK_CODE: + return int(controls.pick_perk_code) + if binding_id is BindingId.RELOAD_CODE: + return int(controls.reload_code) + raise ValueError(f"unsupported binding id: {binding_id!r}") + + +def _set_binding_code(player: CrimsonPlayerControls, binding_id: BindingId, value: int, *, controls) -> None: + code = int(value) + if binding_id is BindingId.MOVE_FORWARD_CODE: + player.move_forward_code = code + return + if binding_id is BindingId.MOVE_BACKWARD_CODE: + player.move_backward_code = code + return + if binding_id is BindingId.TURN_LEFT_CODE: + player.turn_left_code = code + return + if binding_id is BindingId.TURN_RIGHT_CODE: + player.turn_right_code = code + return + if binding_id is BindingId.FIRE_CODE: + player.fire_code = code + return + if binding_id is BindingId.AIM_LEFT_CODE: + player.aim_left_code = code + return + if binding_id is BindingId.AIM_RIGHT_CODE: + player.aim_right_code = code + return + if binding_id is BindingId.AIM_VERTICAL_AXIS_CODE: + player.aim_vertical_axis_code = code + return + if binding_id is BindingId.AIM_HORIZONTAL_AXIS_CODE: + player.aim_horizontal_axis_code = code + return + if binding_id is BindingId.MOVE_VERTICAL_AXIS_CODE: + player.move_vertical_axis_code = code + return + if binding_id is BindingId.MOVE_HORIZONTAL_AXIS_CODE: + player.move_horizontal_axis_code = code + return + if binding_id is BindingId.PICK_PERK_CODE: + controls.pick_perk_code = code + return + if binding_id is BindingId.RELOAD_CODE: + controls.reload_code = code + return + raise ValueError(f"unsupported binding id: {binding_id!r}") + + +def _default_binding_code(player_index: int, binding_id: BindingId) -> int: + controls = default_crimson_cfg().controls + player = controls.player(player_index) + return _binding_code(player, binding_id, controls=controls) def _controls_left_panel_pos_x(screen_width: float) -> float: @@ -106,7 +192,7 @@ class _ControlsDropdownLayout(DropdownLayoutBase, frozen=True): class _RebindRowLayout(msgspec.Struct, frozen=True): label: str - slot: int + binding_id: BindingId row_y: float value_pos: Vec2 value_rect: Rect @@ -126,7 +212,7 @@ def __init__(self, state: GameState) -> None: self._aim_method_open = False self._player_profile_open = False self._dirty = False - self._rebind_slot: int | None = None + self._rebind_binding_id: BindingId | None = None self._rebind_player_index: int | None = None self._rebind_skip_frames = 0 @@ -185,15 +271,15 @@ def _current_player_index(self) -> int: return max(0, min(3, int(self._config_player) - 1)) def _rebind_active(self) -> bool: - return self._rebind_slot is not None and self._rebind_player_index is not None + return self._rebind_binding_id is not None and self._rebind_player_index is not None def _clear_rebind_capture(self) -> None: - self._rebind_slot = None + self._rebind_binding_id = None self._rebind_player_index = None self._rebind_skip_frames = 0 - def _start_rebind_capture(self, *, slot: int, player_index: int) -> None: - self._rebind_slot = int(slot) + def _start_rebind_capture(self, *, binding_id: BindingId, player_index: int) -> None: + self._rebind_binding_id = binding_id self._rebind_player_index = max(0, min(3, int(player_index))) self._move_method_open = False self._aim_method_open = False @@ -202,44 +288,32 @@ def _start_rebind_capture(self, *, slot: int, player_index: int) -> None: self._rebind_skip_frames = 1 @staticmethod - def _slot_is_axis(slot: int) -> bool: - return int(slot) in _AXIS_REBIND_SLOTS + def _binding_is_axis(binding_id: BindingId) -> bool: + return binding_id in _AXIS_REBIND_BINDINGS @staticmethod - def _capture_prompt_for_slot(slot: int) -> str: - if ControlsMenuView._slot_is_axis(int(slot)): + def _capture_prompt_for_binding(binding_id: BindingId) -> str: + if ControlsMenuView._binding_is_axis(binding_id): return "" return "" - def _slot_default_key(self, *, player_index: int, slot: int) -> int: - slot_idx = int(slot) - if slot_idx == PICK_PERK_BIND_SLOT: - return 0x101 - if slot_idx == RELOAD_BIND_SLOT: - return 0x102 - defaults = default_player_keybind_block(int(player_index)) - if 0 <= slot_idx < len(defaults): - return int(defaults[slot_idx]) - return int(KEYBIND_UNBOUND_CODE) - - def _slot_key(self, *, player_index: int, slot: int) -> int: - slot_idx = int(slot) - if slot_idx == PICK_PERK_BIND_SLOT: - return self.state.config.controls.pick_perk_key - if slot_idx == RELOAD_BIND_SLOT: - return self.state.config.controls.reload_key - return self.state.config.controls.player(player_index).keybind(slot_idx) - - def _set_slot_key(self, *, player_index: int, slot: int, code: int) -> None: - slot_idx = int(slot) - value = int(code) - if slot_idx == PICK_PERK_BIND_SLOT: - self.state.config.controls.pick_perk_key = value - return - if slot_idx == RELOAD_BIND_SLOT: - self.state.config.controls.reload_key = value - return - self.state.config.controls.player(player_index).set_keybind(slot_idx, value) + def _binding_default_code(self, *, player_index: int, binding_id: BindingId) -> int: + return _default_binding_code(player_index, binding_id) + + def _binding_code(self, *, player_index: int, binding_id: BindingId) -> int: + return _binding_code( + self.state.config.controls.player(player_index), + binding_id, + controls=self.state.config.controls, + ) + + def _set_binding_code(self, *, player_index: int, binding_id: BindingId, code: int) -> None: + _set_binding_code( + self.state.config.controls.player(player_index), + binding_id, + int(code), + controls=self.state.config.controls, + ) def _left_panel_top_left(self, panel_scale: float) -> Vec2: panel_w = MENU_PANEL_WIDTH * panel_scale @@ -335,13 +409,13 @@ def _rebind_sections( player_index: int, aim_scheme: AimScheme, move_mode: MovementControlType, - ) -> tuple[tuple[str, tuple[tuple[str, int], ...]], ...]: - aim_rows, move_rows, misc_rows = controls_rebind_slot_plan( + ) -> tuple[tuple[str, tuple[tuple[str, BindingId], ...]], ...]: + aim_rows, move_rows, misc_rows = controls_rebind_plan( aim_scheme=aim_scheme, move_mode=move_mode, player_index=player_index, ) - sections: list[tuple[str, tuple[tuple[str, int], ...]]] = [("Aiming", aim_rows), ("Moving", move_rows)] + sections: list[tuple[str, tuple[tuple[str, BindingId], ...]]] = [("Aiming", aim_rows), ("Moving", move_rows)] if misc_rows: sections.append(("Misc", misc_rows)) return tuple(sections) @@ -352,15 +426,15 @@ def _collect_rebind_rows( right_top_left: Vec2, panel_scale: float, player_index: int, - sections: tuple[tuple[str, tuple[tuple[str, int], ...]], ...], + sections: tuple[tuple[str, tuple[tuple[str, BindingId], ...]], ...], font: SmallFontData, ) -> tuple[_RebindRowLayout, ...]: rows: list[_RebindRowLayout] = [] y = right_top_left.y + 64.0 * panel_scale for _section_title, section_rows in sections: row_y = y + 18.0 * panel_scale - for label, slot in section_rows: - key_code = int(self._slot_key(player_index=player_index, slot=slot)) + for label, binding_id in section_rows: + key_code = int(self._binding_code(player_index=player_index, binding_id=binding_id)) value_text = input_code_name(key_code) value_pos = Vec2(right_top_left.x + 180.0 * panel_scale, row_y) value_w = max(60.0 * panel_scale, measure_small_text_width(font, value_text)) @@ -372,7 +446,7 @@ def _collect_rebind_rows( rows.append( _RebindRowLayout( label=str(label), - slot=int(slot), + binding_id=binding_id, row_y=float(row_y), value_pos=value_pos, value_rect=value_rect, @@ -395,7 +469,7 @@ def _update_rebind_capture(self, *, right_top_left: Vec2, panel_scale: float, fo ) if self._rebind_active(): - active_slot = int(self._rebind_slot or 0) + active_binding_id = self._rebind_binding_id or BindingId.FIRE_CODE active_player = int(self._rebind_player_index or 0) if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE) or rl.is_mouse_button_pressed( rl.MouseButton.MOUSE_BUTTON_RIGHT, @@ -404,17 +478,17 @@ def _update_rebind_capture(self, *, right_top_left: Vec2, panel_scale: float, fo return True if rl.is_key_pressed(rl.KeyboardKey.KEY_BACKSPACE): - self._set_slot_key( + self._set_binding_code( player_index=active_player, - slot=active_slot, - code=self._slot_default_key(player_index=active_player, slot=active_slot), + binding_id=active_binding_id, + code=self._binding_default_code(player_index=active_player, binding_id=active_binding_id), ) self._dirty = True self._clear_rebind_capture() return True if rl.is_key_pressed(rl.KeyboardKey.KEY_DELETE): - self._set_slot_key(player_index=active_player, slot=active_slot, code=INPUT_CODE_UNBOUND) + self._set_binding_code(player_index=active_player, binding_id=active_binding_id, code=INPUT_CODE_UNBOUND) self._dirty = True self._clear_rebind_capture() return True @@ -423,7 +497,7 @@ def _update_rebind_capture(self, *, right_top_left: Vec2, panel_scale: float, fo self._rebind_skip_frames = max(0, int(self._rebind_skip_frames) - 1) return True - axis_only = self._slot_is_axis(active_slot) + axis_only = self._binding_is_axis(active_binding_id) captured = capture_first_pressed_input_code( player_index=active_player, include_keyboard=not axis_only, @@ -433,7 +507,7 @@ def _update_rebind_capture(self, *, right_top_left: Vec2, panel_scale: float, fo axis_threshold=0.5, ) if captured is not None: - self._set_slot_key(player_index=active_player, slot=active_slot, code=int(captured)) + self._set_binding_code(player_index=active_player, binding_id=active_binding_id, code=int(captured)) self._dirty = True self._clear_rebind_capture() return True @@ -446,7 +520,7 @@ def _update_rebind_capture(self, *, right_top_left: Vec2, panel_scale: float, fo mouse = Vec2.from_xy(rl.get_mouse_position()) for row in rows: if row.value_rect.contains(mouse): - self._start_rebind_capture(slot=row.slot, player_index=player_idx) + self._start_rebind_capture(binding_id=row.binding_id, player_index=player_idx) return True return False @@ -831,15 +905,15 @@ def _draw_section_heading(title: str, *, y: float) -> None: for _ in section_rows: row = next(row_iter) label = row.label - slot = int(row.slot) - active_row = rebind_active and int(self._rebind_slot or -1) == slot and int( + binding_id = row.binding_id + active_row = rebind_active and self._rebind_binding_id is binding_id and int( self._rebind_player_index or -1, ) == player_idx hovered_row = (not rebind_active) and (not dropdown_blocked) and row.value_rect.contains(mouse) value_text = ( - self._capture_prompt_for_slot(slot) + self._capture_prompt_for_binding(binding_id) if active_row - else input_code_name(self._slot_key(player_index=player_idx, slot=slot)) + else input_code_name(self._binding_code(player_index=player_idx, binding_id=binding_id)) ) value_pos = row.value_pos diff --git a/src/crimson/screens/panels/controls_labels.py b/src/crimson/screens/panels/controls_labels.py index d68ecaa68..5fa8da4c1 100644 --- a/src/crimson/screens/panels/controls_labels.py +++ b/src/crimson/screens/panels/controls_labels.py @@ -1,12 +1,27 @@ from __future__ import annotations +from enum import Enum, auto + from grim.config import CrimsonControlsConfig from ...aim_schemes import AimScheme from ...movement_controls import MovementControlType -PICK_PERK_BIND_SLOT = -1 -RELOAD_BIND_SLOT = -2 + +class BindingId(Enum): + MOVE_FORWARD_CODE = auto() + MOVE_BACKWARD_CODE = auto() + TURN_LEFT_CODE = auto() + TURN_RIGHT_CODE = auto() + FIRE_CODE = auto() + AIM_LEFT_CODE = auto() + AIM_RIGHT_CODE = auto() + AIM_VERTICAL_AXIS_CODE = auto() + AIM_HORIZONTAL_AXIS_CODE = auto() + MOVE_VERTICAL_AXIS_CODE = auto() + MOVE_HORIZONTAL_AXIS_CODE = auto() + PICK_PERK_CODE = auto() + RELOAD_CODE = auto() def input_configure_for_label(config_id: AimScheme) -> str: @@ -65,57 +80,61 @@ def controls_aim_method_dropdown_ids(current_aim_scheme: AimScheme) -> tuple[Aim return tuple(ids) -def controls_rebind_slot_plan( +def controls_rebind_plan( *, aim_scheme: AimScheme, move_mode: MovementControlType, player_index: int, -) -> tuple[tuple[tuple[str, int], ...], tuple[tuple[str, int], ...], tuple[tuple[str, int], ...]]: +) -> tuple[ + tuple[tuple[str, BindingId], ...], + tuple[tuple[str, BindingId], ...], + tuple[tuple[str, BindingId], ...], +]: """Return (aim_rows, move_rows, misc_rows) for `controls_menu_update`.""" - aim_rows: list[tuple[str, int]] = [] - move_rows: list[tuple[str, int]] = [] - misc_rows: list[tuple[str, int]] = [] + aim_rows: list[tuple[str, BindingId]] = [] + move_rows: list[tuple[str, BindingId]] = [] + misc_rows: list[tuple[str, BindingId]] = [] if aim_scheme is AimScheme.KEYBOARD: - aim_rows.append(("Torso left:", 7)) - aim_rows.append(("Torso right:", 8)) + aim_rows.append(("Torso left:", BindingId.AIM_LEFT_CODE)) + aim_rows.append(("Torso right:", BindingId.AIM_RIGHT_CODE)) elif aim_scheme is AimScheme.DUAL_ACTION_PAD: - aim_rows.append(("Aim Up/Down Axis:", 9)) - aim_rows.append(("Aim Left/Right Axis:", 10)) - aim_rows.append(("Fire:", 4)) + aim_rows.append(("Aim Up/Down Axis:", BindingId.AIM_VERTICAL_AXIS_CODE)) + aim_rows.append(("Aim Left/Right Axis:", BindingId.AIM_HORIZONTAL_AXIS_CODE)) + aim_rows.append(("Fire:", BindingId.FIRE_CODE)) if move_mode is MovementControlType.STATIC: move_rows.extend( ( - ("Move Up:", 0), - ("Move Down:", 1), - ("Move Left:", 2), - ("Move Right:", 3), + ("Move Up:", BindingId.MOVE_FORWARD_CODE), + ("Move Down:", BindingId.MOVE_BACKWARD_CODE), + ("Move Left:", BindingId.TURN_LEFT_CODE), + ("Move Right:", BindingId.TURN_RIGHT_CODE), ), ) elif move_mode is MovementControlType.RELATIVE: move_rows.extend( ( - ("Forward:", 0), - ("Backwards:", 1), - ("Turn left:", 2), - ("Turn right:", 3), + ("Forward:", BindingId.MOVE_FORWARD_CODE), + ("Backwards:", BindingId.MOVE_BACKWARD_CODE), + ("Turn left:", BindingId.TURN_LEFT_CODE), + ("Turn right:", BindingId.TURN_RIGHT_CODE), ), ) elif move_mode is MovementControlType.DUAL_ACTION_PAD: move_rows.extend( ( - ("Up/Down Axis:", 11), - ("Left/Right Axis:", 12), + ("Up/Down Axis:", BindingId.MOVE_VERTICAL_AXIS_CODE), + ("Left/Right Axis:", BindingId.MOVE_HORIZONTAL_AXIS_CODE), ), ) elif move_mode is MovementControlType.MOUSE_POINT_CLICK: - move_rows.append(("Move to cursor:", RELOAD_BIND_SLOT)) + move_rows.append(("Move to cursor:", BindingId.RELOAD_CODE)) if int(player_index) == 0: - misc_rows.append(("Level Up:", PICK_PERK_BIND_SLOT)) + misc_rows.append(("Level Up:", BindingId.PICK_PERK_CODE)) if move_mode is not MovementControlType.MOUSE_POINT_CLICK: - misc_rows.append(("Reload:", RELOAD_BIND_SLOT)) + misc_rows.append(("Reload:", BindingId.RELOAD_CODE)) return tuple(aim_rows), tuple(move_rows), tuple(misc_rows) diff --git a/src/crimson/ui/text_input.py b/src/crimson/ui/text_input.py index 5109c87ef..d79d34891 100644 --- a/src/crimson/ui/text_input.py +++ b/src/crimson/ui/text_input.py @@ -7,7 +7,7 @@ from grim.raylib_api import rl from grim.sfx_map import SfxId -from ..input_codes import INPUT_CODE_UNBOUND, config_keybinds_for_player, input_code_is_down_for_player +from ..input_codes import INPUT_CODE_UNBOUND, input_code_is_down from ..rng_caller_static import RngCallerStatic _CONTROL_BIND_SLOTS = 5 @@ -79,15 +79,20 @@ def update_name_entry_text( def gameplay_controls_held(config: CrimsonConfig) -> bool: player_count = max(1, min(4, config.gameplay.player_count)) for player_index in range(player_count): - binds = config_keybinds_for_player(config, player_index=player_index) - for code in binds[:_CONTROL_BIND_SLOTS]: + for code in ( + int(config.controls.player(player_index).move_forward_code), + int(config.controls.player(player_index).move_backward_code), + int(config.controls.player(player_index).turn_left_code), + int(config.controls.player(player_index).turn_right_code), + int(config.controls.player(player_index).fire_code), + )[:_CONTROL_BIND_SLOTS]: key_code = int(code) if key_code == INPUT_CODE_UNBOUND: continue - if input_code_is_down_for_player(key_code, player_index=player_index): + if input_code_is_down(key_code, player_index=player_index): return True for code in _SINGLE_PLAYER_ALT_MOVE_CODES: - if input_code_is_down_for_player(int(code), player_index=0): + if input_code_is_down(int(code), player_index=0): return True return False diff --git a/src/grim/config.py b/src/grim/config.py index e070e3d98..4d9668ed1 100644 --- a/src/grim/config.py +++ b/src/grim/config.py @@ -3,7 +3,7 @@ from collections.abc import Sequence from enum import IntEnum from pathlib import Path -from typing import TypeAlias, cast +from typing import Any import msgspec from construct import Array, Byte, Bytes, Float32l, Int32sl, Struct @@ -20,7 +20,6 @@ SAVED_NAME_SLOT_COUNT = 8 SAVED_NAME_ENTRY_SIZE = 0x1B SAVED_NAMES_BLOB_SIZE = SAVED_NAME_SLOT_COUNT * SAVED_NAME_ENTRY_SIZE -KEYBINDS_BLOB_SIZE = 0x80 UNKNOWN_248_SIZE = 0x1F8 PLAYER_BIND_BLOCK_DWORDS = 0x10 PLAYER_BIND_BLOCK_SIZE = PLAYER_BIND_BLOCK_DWORDS * 4 @@ -31,27 +30,24 @@ EXT_DIRECTION_ARROW_OFF = 1 EXT_DIRECTION_ARROW_ON = 2 KEYBIND_UNBOUND_CODE = 0x17E - -PlayerKeybindBlock: TypeAlias = tuple[ - int, - int, - int, - int, - int, - int, - int, - int, - int, - int, - int, - int, - int, - int, - int, - int, -] - -PLAYER_BIND_BLOCK_STRUCT = Array(PLAYER_BIND_BLOCK_DWORDS, Int32sl) +RESERVED_KEYBIND_SLOT_COUNT = 2 +PADDING_KEYBIND_SLOT_COUNT = 3 + +PLAYER_BIND_BLOCK_STRUCT = Struct( + "move_forward" / Int32sl, + "move_backward" / Int32sl, + "turn_left" / Int32sl, + "turn_right" / Int32sl, + "fire" / Int32sl, + "reserved_keys" / Array(RESERVED_KEYBIND_SLOT_COUNT, Int32sl), + "aim_left" / Int32sl, + "aim_right" / Int32sl, + "axis_aim_y" / Int32sl, + "axis_aim_x" / Int32sl, + "axis_move_y" / Int32sl, + "axis_move_x" / Int32sl, + "padding" / Array(PADDING_KEYBIND_SLOT_COUNT, Int32sl), +) CRIMSON_CFG_STRUCT = Struct( "sound_disable" / Byte, @@ -293,23 +289,24 @@ def saved_name_labels(self) -> tuple[str, ...]: class CrimsonPlayerControls(msgspec.Struct): movement: MovementControlType aim_scheme: AimScheme - keybinds: PlayerKeybindBlock show_direction_arrow: bool - - def keybind(self, slot_index: int) -> int: - return int(self.keybinds[_slot_index(slot_index)]) - - def set_keybind(self, slot_index: int, value: int) -> None: - slot = _slot_index(slot_index) - block = list(self.keybinds) - block[slot] = int(value) - self.keybinds = _player_keybind_block(block) + move_forward_code: int + move_backward_code: int + turn_left_code: int + turn_right_code: int + fire_code: int + aim_left_code: int + aim_right_code: int + aim_vertical_axis_code: int + aim_horizontal_axis_code: int + move_vertical_axis_code: int + move_horizontal_axis_code: int class CrimsonControlsConfig(msgspec.Struct): players: tuple[CrimsonPlayerControls, CrimsonPlayerControls, CrimsonPlayerControls, CrimsonPlayerControls] - pick_perk_key: int - reload_key: int + pick_perk_code: int + reload_code: int def player(self, player_index: int) -> CrimsonPlayerControls: return self.players[_player_index(player_index)] @@ -334,13 +331,6 @@ def _player_index(player_index: int) -> int: return idx -def _slot_index(slot_index: int) -> int: - idx = int(slot_index) - if idx < 0 or idx >= PLAYER_BIND_BLOCK_DWORDS: - raise IndexError(f"keybind slot must be in 0..{PLAYER_BIND_BLOCK_DWORDS - 1}, got {idx}") - return idx - - def _require_range(value: int, *, minimum: int, maximum: int, field: str) -> int: if value < minimum or value > maximum: raise ValueError(f"{field} must be in {minimum}..{maximum}, got {value}") @@ -354,29 +344,114 @@ def _block_uninitialized(values: Sequence[int]) -> bool: return True -def _player_keybind_block(values: Sequence[int]) -> PlayerKeybindBlock: +def _player_bind_values(values: Sequence[int]) -> tuple[int, ...]: if len(values) != PLAYER_BIND_BLOCK_DWORDS: raise ValueError(f"keybind block must have {PLAYER_BIND_BLOCK_DWORDS} entries, got {len(values)}") - return cast(PlayerKeybindBlock, tuple(int(value) for value in values)) + return ( + int(values[0]), + int(values[1]), + int(values[2]), + int(values[3]), + int(values[4]), + int(values[5]), + int(values[6]), + int(values[7]), + int(values[8]), + int(values[9]), + int(values[10]), + int(values[11]), + int(values[12]), + int(values[13]), + int(values[14]), + int(values[15]), + ) + + +def _raw_player_bind_values(raw_block: dict[str, Any]) -> tuple[int, ...]: + return _player_bind_values( + ( + int(raw_block["move_forward"]), + int(raw_block["move_backward"]), + int(raw_block["turn_left"]), + int(raw_block["turn_right"]), + int(raw_block["fire"]), + int(raw_block["reserved_keys"][0]), + int(raw_block["reserved_keys"][1]), + int(raw_block["aim_left"]), + int(raw_block["aim_right"]), + int(raw_block["axis_aim_y"]), + int(raw_block["axis_aim_x"]), + int(raw_block["axis_move_y"]), + int(raw_block["axis_move_x"]), + int(raw_block["padding"][0]), + int(raw_block["padding"][1]), + int(raw_block["padding"][2]), + ), + ) -def _decode_player_bind_block(raw: dict, *, player_index: int) -> PlayerKeybindBlock: +def _player_controls_from_bind_values( + values: Sequence[int], + *, + movement: MovementControlType, + aim_scheme: AimScheme, + show_direction_arrow: bool, +) -> CrimsonPlayerControls: + bind_values = _player_bind_values(values) + return CrimsonPlayerControls( + movement=movement, + aim_scheme=aim_scheme, + show_direction_arrow=show_direction_arrow, + move_forward_code=int(bind_values[0]), + move_backward_code=int(bind_values[1]), + turn_left_code=int(bind_values[2]), + turn_right_code=int(bind_values[3]), + fire_code=int(bind_values[4]), + aim_left_code=int(bind_values[7]), + aim_right_code=int(bind_values[8]), + aim_vertical_axis_code=int(bind_values[9]), + aim_horizontal_axis_code=int(bind_values[10]), + move_vertical_axis_code=int(bind_values[11]), + move_horizontal_axis_code=int(bind_values[12]), + ) + + +def _encode_player_bind_block(player: CrimsonPlayerControls, *, player_index: int) -> dict[str, object]: + defaults = _default_player_bind_values(player_index) + return { + "move_forward": int(player.move_forward_code), + "move_backward": int(player.move_backward_code), + "turn_left": int(player.turn_left_code), + "turn_right": int(player.turn_right_code), + "fire": int(player.fire_code), + "reserved_keys": [int(defaults[5]), int(defaults[6])], + "aim_left": int(player.aim_left_code), + "aim_right": int(player.aim_right_code), + "axis_aim_y": int(player.aim_vertical_axis_code), + "axis_aim_x": int(player.aim_horizontal_axis_code), + "axis_move_y": int(player.move_vertical_axis_code), + "axis_move_x": int(player.move_horizontal_axis_code), + "padding": [int(defaults[13]), int(defaults[14]), int(defaults[15])], + } + + +def _decode_player_bind_values(raw: dict[str, Any], *, player_index: int) -> tuple[int, ...]: idx = _player_index(player_index) if idx < 2: - block = tuple(int(value) for value in raw["keybinds_p1_p2"][idx]) + values = _raw_player_bind_values(raw["keybinds_p1_p2"][idx]) else: - block = tuple(int(value) for value in raw["extended_keybinds_p3_p4"][idx - 2]) - if _block_uninitialized(block): - return _default_player_bind_block(idx) - return _player_keybind_block(block) + values = _raw_player_bind_values(raw["extended_keybinds_p3_p4"][idx - 2]) + if _block_uninitialized(values): + return _default_player_bind_values(idx) + return values -def _encode_primary_keybinds(players: Sequence[CrimsonPlayerControls]) -> list[list[int]]: - return [[int(value) for value in players[idx].keybinds] for idx in range(2)] +def _encode_primary_keybinds(players: Sequence[CrimsonPlayerControls]) -> list[dict[str, object]]: + return [_encode_player_bind_block(players[idx], player_index=idx) for idx in range(2)] -def _encode_extended_keybinds(players: Sequence[CrimsonPlayerControls]) -> list[list[int]]: - return [[int(value) for value in players[idx].keybinds] for idx in range(2, 4)] +def _encode_extended_keybinds(players: Sequence[CrimsonPlayerControls]) -> list[dict[str, object]]: + return [_encode_player_bind_block(players[idx], player_index=idx) for idx in range(2, 4)] def _decode_direction_arrow(raw: dict, *, player_index: int) -> bool: @@ -458,21 +533,28 @@ def _saved_name_order_values() -> tuple[int, ...]: return tuple(range(SAVED_NAME_SLOT_COUNT)) -def _default_player_bind_block(player_index: int) -> PlayerKeybindBlock: +def _default_player_bind_values(player_index: int) -> tuple[int, ...]: idx = _player_index(player_index) - return cast(PlayerKeybindBlock, _DEFAULT_PLAYER_BIND_BLOCKS[idx]) - - -def default_player_keybind_block(player_index: int) -> tuple[int, ...]: - return _default_player_bind_block(player_index) + return _player_bind_values(_DEFAULT_PLAYER_BIND_BLOCKS[idx]) def _default_player_controls(player_index: int) -> CrimsonPlayerControls: + values = _default_player_bind_values(player_index) return CrimsonPlayerControls( movement=MovementControlType.STATIC, aim_scheme=AimScheme.MOUSE, - keybinds=_default_player_bind_block(player_index), show_direction_arrow=True, + move_forward_code=int(values[0]), + move_backward_code=int(values[1]), + turn_left_code=int(values[2]), + turn_right_code=int(values[3]), + fire_code=int(values[4]), + aim_left_code=int(values[7]), + aim_right_code=int(values[8]), + aim_vertical_axis_code=int(values[9]), + aim_horizontal_axis_code=int(values[10]), + move_vertical_axis_code=int(values[11]), + move_horizontal_axis_code=int(values[12]), ) @@ -522,8 +604,8 @@ def default_crimson_cfg(path: Path = Path("")) -> CrimsonConfig: _default_player_controls(2), _default_player_controls(3), ), - pick_perk_key=0x101, - reload_key=0x102, + pick_perk_code=0x101, + reload_code=0x102, ), ) @@ -546,10 +628,10 @@ def decode_crimson_cfg(path: Path, blob: bytes) -> CrimsonConfig: detail_preset = _require_range(detail_preset, minimum=1, maximum=5, field="detail_preset") players = tuple( - CrimsonPlayerControls( + _player_controls_from_bind_values( + _decode_player_bind_values(raw, player_index=idx), movement=_decode_movement(raw["player_mode_flags"][idx]), aim_scheme=_decode_aim_scheme(raw["aim_schemes"][idx]), - keybinds=_decode_player_bind_block(raw, player_index=idx), show_direction_arrow=_decode_direction_arrow(raw, player_index=idx), ) for idx in range(4) @@ -607,8 +689,8 @@ def decode_crimson_cfg(path: Path, blob: bytes) -> CrimsonConfig: ), controls=CrimsonControlsConfig( players=players, # type: ignore[arg-type] - pick_perk_key=int(raw["keybind_pick_perk"]), - reload_key=int(raw["keybind_reload"]), + pick_perk_code=int(raw["keybind_pick_perk"]), + reload_code=int(raw["keybind_reload"]), ), ) @@ -685,8 +767,8 @@ def encode_crimson_cfg(config: CrimsonConfig) -> bytes: field="detail_preset", ) data["mouse_sensitivity"] = float(config.display.mouse_sensitivity) - data["keybind_pick_perk"] = int(config.controls.pick_perk_key) - data["keybind_reload"] = int(config.controls.reload_key) + data["keybind_pick_perk"] = int(config.controls.pick_perk_code) + data["keybind_reload"] = int(config.controls.reload_code) return CRIMSON_CFG_STRUCT.build(data) diff --git a/tests/conftest.py b/tests/conftest.py index 5ca392327..1a51208dc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -65,9 +65,9 @@ def _apply_config_updates(cfg: "CrimsonConfig", updates: Mapping[str, object]) - case "music_volume": cfg.audio.music_volume = _as_float(value) case "keybind_pick_perk": - cfg.controls.pick_perk_key = _as_int(value) + cfg.controls.pick_perk_code = _as_int(value) case "keybind_reload": - cfg.controls.reload_key = _as_int(value) + cfg.controls.reload_code = _as_int(value) case "player_name": cfg.profile.set_player_name_input(str(value)) case "selected_saved_name_slot": diff --git a/tests/grim/test_grim_config.py b/tests/grim/test_grim_config.py index 3dd0ef600..b1151e6e2 100644 --- a/tests/grim/test_grim_config.py +++ b/tests/grim/test_grim_config.py @@ -34,31 +34,62 @@ def test_crimson_cfg_backfills_zero_keybinds(tmp_path: Path) -> None: cfg = grim_config.default_crimson_cfg() data = grim_config.CRIMSON_CFG_STRUCT.parse(grim_config.encode_crimson_cfg(cfg)) data["keybinds_p1_p2"] = [ - [0] * grim_config.PLAYER_BIND_BLOCK_DWORDS, - [0] * grim_config.PLAYER_BIND_BLOCK_DWORDS, + { + "move_forward": 0, + "move_backward": 0, + "turn_left": 0, + "turn_right": 0, + "fire": 0, + "reserved_keys": [0, 0], + "aim_left": 0, + "aim_right": 0, + "axis_aim_y": 0, + "axis_aim_x": 0, + "axis_move_y": 0, + "axis_move_x": 0, + "padding": [0, 0, 0], + }, + { + "move_forward": 0, + "move_backward": 0, + "turn_left": 0, + "turn_right": 0, + "fire": 0, + "reserved_keys": [0, 0], + "aim_left": 0, + "aim_right": 0, + "axis_aim_y": 0, + "axis_aim_x": 0, + "axis_move_y": 0, + "axis_move_x": 0, + "padding": [0, 0, 0], + }, ] path = tmp_path / grim_config.CRIMSON_CFG_NAME path.write_bytes(grim_config.CRIMSON_CFG_STRUCT.build(data)) loaded = grim_config.ensure_crimson_cfg(tmp_path) - assert loaded.controls.player(0).keybinds == grim_config.default_player_keybind_block(0) - assert loaded.controls.player(1).keybinds == grim_config.default_player_keybind_block(1) + defaults = grim_config.default_crimson_cfg(Path("")).controls + assert loaded.controls.player(0) == defaults.player(0) + assert loaded.controls.player(1) == defaults.player(1) def test_player_keybind_roundtrip_for_extended_players_uses_reserved_gap_extension() -> None: cfg = grim_config.default_crimson_cfg(Path("")) - cfg.controls.player(2).set_keybind(4, 0x120) - cfg.controls.player(3).set_keybind(0, 0x11F) + cfg.controls.player(2).fire_code = 0x120 + cfg.controls.player(3).move_forward_code = 0x11F blob = grim_config.encode_crimson_cfg(cfg) parsed = grim_config.CRIMSON_CFG_STRUCT.parse(blob) - assert list(parsed["extended_keybinds_p3_p4"][0])[4] == 0x120 - assert list(parsed["extended_keybinds_p3_p4"][1])[0] == 0x11F + assert int(parsed["extended_keybinds_p3_p4"][0]["fire"]) == 0x120 + assert int(parsed["extended_keybinds_p3_p4"][1]["move_forward"]) == 0x11F + assert list(parsed["extended_keybinds_p3_p4"][0]["reserved_keys"]) == [0x17E, 0x17E] + assert list(parsed["extended_keybinds_p3_p4"][0]["padding"]) == [0x17E, 0x17E, 0x17E] assert parsed["extended_reserved_gap"] == b"\x00" * len(parsed["extended_reserved_gap"]) loaded = grim_config.decode_crimson_cfg(Path(""), blob) - assert loaded.controls.player(2).keybind(4) == 0x120 - assert loaded.controls.player(3).keybind(0) == 0x11F + assert loaded.controls.player(2).fire_code == 0x120 + assert loaded.controls.player(3).move_forward_code == 0x11F def test_direction_arrow_extension_roundtrip_for_players_three_and_four() -> None: diff --git a/tests/input/test_input_codes.py b/tests/input/test_input_codes.py index 8bda6826b..1516351dc 100644 --- a/tests/input/test_input_codes.py +++ b/tests/input/test_input_codes.py @@ -2,7 +2,6 @@ import crimson.input_codes as input_codes from crimson.input_codes import INPUT_CODE_UNBOUND, input_code_name -from grim.config import default_player_keybind_block def test_input_code_name_extended_axes_match_original_labels() -> None: @@ -47,22 +46,23 @@ def test_pressed_edge_does_not_retrigger_after_unpolled_held_frame(mocker) -> No input_codes.input_begin_frame() key_down["value"] = True - assert input_codes.input_code_is_pressed_for_player(0x11, player_index=0) + assert input_codes.input_code_is_pressed(0x11, player_index=0) input_codes.input_begin_frame() # Simulate a frame where this binding is not queried at all. input_codes.input_begin_frame() - assert not input_codes.input_code_is_pressed_for_player(0x11, player_index=0) + assert not input_codes.input_code_is_pressed(0x11, player_index=0) def test_input_primary_just_pressed_latches_across_multiplayer_fire_keys(mocker) -> None: down: dict[tuple[int, int], bool] = {} + fire_codes = (0x100, 0x9D, 0x36, 0x11F) - def _fake_input_code_is_down_for_player(key_code: int, *, player_index: int) -> bool: + def _fake_input_code_is_down(key_code: int, *, player_index: int = 0) -> bool: return bool(down.get((int(player_index), int(key_code)), False)) - mocker.patch.object(input_codes, "input_code_is_down_for_player", side_effect=_fake_input_code_is_down_for_player) + mocker.patch.object(input_codes, "input_code_is_down", side_effect=_fake_input_code_is_down) mocker.patch.object(input_codes.rl, "get_mouse_wheel_move", return_value=0.0) input_codes._PRESSED_STATE.prev_down.clear() @@ -74,30 +74,24 @@ def _fake_input_code_is_down_for_player(key_code: int, *, player_index: int) -> # Player 2 fire key press opens the latch in two-player mode. input_codes.input_begin_frame() down[(1, 0x9D)] = True - assert input_codes.input_primary_just_pressed(None, player_count=2) + assert input_codes.input_primary_just_pressed(fire_codes=fire_codes, player_count=2) # Holding any primary source should not retrigger next frame. input_codes.input_begin_frame() - assert not input_codes.input_primary_just_pressed(None, player_count=2) + assert not input_codes.input_primary_just_pressed(fire_codes=fire_codes, player_count=2) # Pressing another primary source while already held still does not retrigger. input_codes.input_begin_frame() down[(0, 0x100)] = True - assert not input_codes.input_primary_just_pressed(None, player_count=2) + assert not input_codes.input_primary_just_pressed(fire_codes=fire_codes, player_count=2) # Releasing all sources clears the latch. input_codes.input_begin_frame() down[(1, 0x9D)] = False down[(0, 0x100)] = False - assert not input_codes.input_primary_just_pressed(None, player_count=2) + assert not input_codes.input_primary_just_pressed(fire_codes=fire_codes, player_count=2) # Fresh primary press edges again after full release. input_codes.input_begin_frame() down[(0, 0x100)] = True - assert input_codes.input_primary_just_pressed(None, player_count=2) - - -def test_player_move_fire_keybinds_falls_back_to_default_block() -> None: - expected = tuple(int(value) for value in default_player_keybind_block(0)[:5]) - - assert input_codes.player_move_fire_keybinds(None, player_index=0) == expected + assert input_codes.input_primary_just_pressed(fire_codes=fire_codes, player_count=2) diff --git a/tests/input/test_local_input.py b/tests/input/test_local_input.py index c447dad72..dca9f4f24 100644 --- a/tests/input/test_local_input.py +++ b/tests/input/test_local_input.py @@ -41,17 +41,56 @@ def _test_config(**updates: object) -> CrimsonConfig: def _patch_keys_down(mocker: MockerFixture, *, down_codes: set[int]) -> None: mocker.patch.object( local_input, - "input_code_is_down_for_player", + "input_code_is_down", lambda key, **_kwargs: int(key) in down_codes, ) - mocker.patch.object(local_input, "input_code_is_pressed_for_player", lambda *_args, **_kwargs: False) - mocker.patch.object(local_input, "input_axis_value_for_player", lambda *_args, **_kwargs: 0.0) + mocker.patch.object(local_input, "input_code_is_pressed", lambda *_args, **_kwargs: False) + mocker.patch.object(local_input, "input_axis_value", lambda *_args, **_kwargs: 0.0) def _patch_no_user_input(mocker: MockerFixture) -> None: - mocker.patch.object(local_input, "input_code_is_down_for_player", lambda *_args, **_kwargs: False) - mocker.patch.object(local_input, "input_code_is_pressed_for_player", lambda *_args, **_kwargs: False) - mocker.patch.object(local_input, "input_axis_value_for_player", lambda *_args, **_kwargs: 0.0) + mocker.patch.object(local_input, "input_code_is_down", lambda *_args, **_kwargs: False) + mocker.patch.object(local_input, "input_code_is_pressed", lambda *_args, **_kwargs: False) + mocker.patch.object(local_input, "input_axis_value", lambda *_args, **_kwargs: 0.0) + + +def _bind_values(values: list[int] | tuple[int, ...] | range) -> tuple[int, ...]: + block = tuple(int(v) for v in values) + if len(block) != 16: + raise ValueError(f"expected 16 keybind values, got {len(block)}") + return block + + +def _set_player_bind_values( + cfg: CrimsonConfig, + values: list[int] | tuple[int, ...] | range, + *, + player_index: int = 0, +) -> CrimsonConfig: + block = _bind_values(values) + player = cfg.controls.player(player_index) + player.move_forward_code = block[0] + player.move_backward_code = block[1] + player.turn_left_code = block[2] + player.turn_right_code = block[3] + player.fire_code = block[4] + player.aim_left_code = block[7] + player.aim_right_code = block[8] + player.aim_vertical_axis_code = block[9] + player.aim_horizontal_axis_code = block[10] + player.move_vertical_axis_code = block[11] + player.move_horizontal_axis_code = block[12] + return cfg + + +def _config_with_player_bind_values( + values: list[int] | tuple[int, ...] | range, + *, + player_index: int = 0, + player_count: int = 1, +) -> CrimsonConfig: + cfg = _test_config(player_count=player_count) + return _set_player_bind_values(cfg, values, player_index=player_index) def test_local_input_computer_aim_auto_fires_without_fire_pressed(mocker: MockerFixture) -> None: @@ -69,7 +108,7 @@ def test_local_input_computer_aim_auto_fires_without_fire_pressed(mocker: Mocker out = interpreter.build_player_input( player_index=0, player=player, - config=None, + config=_test_config(), mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -97,7 +136,7 @@ def test_local_input_computer_aim_without_target_points_away_from_center(mocker: out = interpreter.build_player_input( player_index=0, player=player, - config=None, + config=_test_config(), mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -133,7 +172,7 @@ def test_local_input_computer_target_state_tracks_player_identity_not_call_slot( interpreter.build_player_input( player_index=0, player=player1, - config=None, + config=_test_config(), mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -144,7 +183,7 @@ def test_local_input_computer_target_state_tracks_player_identity_not_call_slot( out = interpreter.build_player_input( player_index=0, player=player0, - config=None, + config=_test_config(), mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -171,11 +210,6 @@ def test_local_input_static_mode_conflict_precedence_matches_native( expected_move: Vec2, ) -> None: _patch_keys_down(mocker, down_codes=down_codes) - mocker.patch.object( - local_input, - "_load_player_bind_block", - lambda _config, *, player_index: tuple(range(16)), - ) mocker.patch.object( local_input.LocalInputInterpreter, "_safe_controls_modes", @@ -184,11 +218,12 @@ def test_local_input_static_mode_conflict_precedence_matches_native( interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(160.0, 100.0)) + config = _config_with_player_bind_values(range(16)) out = interpreter.build_player_input( player_index=0, player=player, - config=None, + config=config, mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -203,11 +238,6 @@ def test_local_input_relative_mode_single_player_uses_alt_arrow_fallback( mocker: MockerFixture, ) -> None: _patch_keys_down(mocker, down_codes={0xC8, 0xCB}) - mocker.patch.object( - local_input, - "_load_player_bind_block", - lambda _config, *, player_index: (0x17E,) * 16, - ) mocker.patch.object( local_input.LocalInputInterpreter, "_safe_controls_modes", @@ -216,11 +246,12 @@ def test_local_input_relative_mode_single_player_uses_alt_arrow_fallback( interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(160.0, 100.0)) + config = _config_with_player_bind_values((0x17E,) * 16) out = interpreter.build_player_input( player_index=0, player=player, - config=None, + config=config, mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -237,17 +268,12 @@ def test_local_input_relative_mode_multiplayer_does_not_use_alt_arrow_fallback( mocker: MockerFixture, ) -> None: _patch_keys_down(mocker, down_codes={0xC8, 0xCB}) - mocker.patch.object( - local_input, - "_load_player_bind_block", - lambda _config, *, player_index: (0x17E,) * 16, - ) mocker.patch.object( local_input.LocalInputInterpreter, "_safe_controls_modes", staticmethod(lambda _config, *, player_index: (AimScheme.MOUSE, MovementControlType.RELATIVE)), ) - config = _test_config(player_count=2) + config = _config_with_player_bind_values((0x17E,) * 16, player_count=2) interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(160.0, 100.0)) @@ -274,7 +300,7 @@ def test_local_input_reload_pressed_is_available_in_multiplayer( _patch_no_user_input(mocker) mocker.patch.object( local_input, - "input_code_is_pressed_for_player", + "input_code_is_pressed", lambda key, **_kwargs: int(key) == 0x102, ) mocker.patch.object( @@ -316,7 +342,7 @@ def test_local_input_reload_pressed_reads_per_player_input_slot( _patch_no_user_input(mocker) mocker.patch.object( local_input, - "input_code_is_pressed_for_player", + "input_code_is_pressed", lambda key, **kwargs: int(key) == 0x102 and int(kwargs.get("player_index", -1)) == 1, ) mocker.patch.object( @@ -347,20 +373,15 @@ def test_local_input_mouse_point_click_marks_move_to_cursor_press( mouse_world = Vec2(160.0, 140.0) mocker.patch.object( local_input, - "input_code_is_down_for_player", + "input_code_is_down", lambda key, **_kwargs: int(key) == 0x102, ) mocker.patch.object( local_input, - "input_code_is_pressed_for_player", + "input_code_is_pressed", lambda key, **_kwargs: int(key) == 0x102, ) - mocker.patch.object(local_input, "input_axis_value_for_player", lambda *_args, **_kwargs: 0.0) - mocker.patch.object( - local_input, - "_load_player_bind_block", - lambda _config, *, player_index: tuple(range(16)), - ) + mocker.patch.object(local_input, "input_axis_value", lambda *_args, **_kwargs: 0.0) mocker.patch.object( local_input.LocalInputInterpreter, "_safe_controls_modes", @@ -369,11 +390,12 @@ def test_local_input_mouse_point_click_marks_move_to_cursor_press( interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(160.0, 100.0)) + config = _config_with_player_bind_values(range(16)) out = interpreter.build_player_input( player_index=0, player=player, - config=None, + config=config, mouse_screen=Vec2(), mouse_world=mouse_world, screen_center=Vec2(), @@ -406,7 +428,7 @@ def test_local_input_computer_move_mode_near_center_heads_toward_target( out = interpreter.build_player_input( player_index=0, player=player, - config=None, + config=_test_config(), mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -435,7 +457,7 @@ def test_local_input_computer_move_mode_far_from_center_heads_toward_center( out = interpreter.build_player_input( player_index=0, player=player, - config=None, + config=_test_config(), mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -465,7 +487,7 @@ def test_local_input_computer_aim_scheme_forces_computer_movement( out = interpreter.build_player_input( player_index=0, player=player, - config=None, + config=_test_config(), mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -481,11 +503,6 @@ def test_local_input_joystick_aim_uses_pov_not_aim_keybinds( mocker: MockerFixture, ) -> None: _patch_keys_down(mocker, down_codes={8}) - mocker.patch.object( - local_input, - "_load_player_bind_block", - lambda _config, *, player_index: tuple(range(16)), - ) mocker.patch.object( local_input.LocalInputInterpreter, "_safe_controls_modes", @@ -494,11 +511,12 @@ def test_local_input_joystick_aim_uses_pov_not_aim_keybinds( interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(160.0, 100.0)) + config = _config_with_player_bind_values(range(16)) out = interpreter.build_player_input( player_index=0, player=player, - config=None, + config=config, mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -515,11 +533,6 @@ def test_local_input_joystick_aim_turns_with_pov_input( mocker: MockerFixture, ) -> None: _patch_keys_down(mocker, down_codes={0x134}) - mocker.patch.object( - local_input, - "_load_player_bind_block", - lambda _config, *, player_index: tuple(range(16)), - ) mocker.patch.object( local_input.LocalInputInterpreter, "_safe_controls_modes", @@ -528,11 +541,12 @@ def test_local_input_joystick_aim_turns_with_pov_input( interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(160.0, 100.0)) + config = _config_with_player_bind_values(range(16)) out = interpreter.build_player_input( player_index=0, player=player, - config=None, + config=config, mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -550,16 +564,11 @@ def test_local_input_joystick_aim_reads_player_pov_by_default( ) -> None: mocker.patch.object( local_input, - "input_code_is_down_for_player", + "input_code_is_down", lambda key, **kwargs: int(key) == 0x134 and int(kwargs.get("player_index", -1)) == 1, ) - mocker.patch.object(local_input, "input_code_is_pressed_for_player", lambda *_args, **_kwargs: False) - mocker.patch.object(local_input, "input_axis_value_for_player", lambda *_args, **_kwargs: 0.0) - mocker.patch.object( - local_input, - "_load_player_bind_block", - lambda _config, *, player_index: tuple(range(16)), - ) + mocker.patch.object(local_input, "input_code_is_pressed", lambda *_args, **_kwargs: False) + mocker.patch.object(local_input, "input_axis_value", lambda *_args, **_kwargs: 0.0) mocker.patch.object( local_input.LocalInputInterpreter, "_safe_controls_modes", @@ -568,11 +577,12 @@ def test_local_input_joystick_aim_reads_player_pov_by_default( interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=1, pos=Vec2(100.0, 100.0), aim=Vec2(160.0, 100.0)) + config = _config_with_player_bind_values(range(16), player_index=1, player_count=2) out = interpreter.build_player_input( player_index=1, player=player, - config=_test_config(player_count=2), + config=config, mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -590,16 +600,11 @@ def test_local_input_joystick_aim_preserve_bugs_uses_player1_pov_slot( ) -> None: mocker.patch.object( local_input, - "input_code_is_down_for_player", + "input_code_is_down", lambda key, **kwargs: int(key) == 0x134 and int(kwargs.get("player_index", -1)) == 0, ) - mocker.patch.object(local_input, "input_code_is_pressed_for_player", lambda *_args, **_kwargs: False) - mocker.patch.object(local_input, "input_axis_value_for_player", lambda *_args, **_kwargs: 0.0) - mocker.patch.object( - local_input, - "_load_player_bind_block", - lambda _config, *, player_index: tuple(range(16)), - ) + mocker.patch.object(local_input, "input_code_is_pressed", lambda *_args, **_kwargs: False) + mocker.patch.object(local_input, "input_axis_value", lambda *_args, **_kwargs: 0.0) mocker.patch.object( local_input.LocalInputInterpreter, "_safe_controls_modes", @@ -609,11 +614,12 @@ def test_local_input_joystick_aim_preserve_bugs_uses_player1_pov_slot( interpreter = local_input.LocalInputInterpreter() interpreter.set_preserve_bugs(True) player = PlayerState(index=1, pos=Vec2(100.0, 100.0), aim=Vec2(160.0, 100.0)) + config = _config_with_player_bind_values(range(16), player_index=1, player_count=2) out = interpreter.build_player_input( player_index=1, player=player, - config=_test_config(player_count=2), + config=config, mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -629,18 +635,13 @@ def test_local_input_joystick_aim_preserve_bugs_uses_player1_pov_slot( def test_local_input_dual_action_pad_aim_uses_native_radius_scale( mocker: MockerFixture, ) -> None: - mocker.patch.object(local_input, "input_code_is_down_for_player", lambda *_args, **_kwargs: False) - mocker.patch.object(local_input, "input_code_is_pressed_for_player", lambda *_args, **_kwargs: False) + mocker.patch.object(local_input, "input_code_is_down", lambda *_args, **_kwargs: False) + mocker.patch.object(local_input, "input_code_is_pressed", lambda *_args, **_kwargs: False) mocker.patch.object( local_input, - "input_axis_value_for_player", + "input_axis_value", lambda key, **_kwargs: 1.0 if int(key) == 10 else 0.0, ) - mocker.patch.object( - local_input, - "_load_player_bind_block", - lambda _config, *, player_index: tuple(range(16)), - ) mocker.patch.object( local_input.LocalInputInterpreter, "_safe_controls_modes", @@ -649,11 +650,12 @@ def test_local_input_dual_action_pad_aim_uses_native_radius_scale( interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(160.0, 100.0)) + config = _config_with_player_bind_values(range(16)) out = interpreter.build_player_input( player_index=0, player=player, - config=None, + config=config, mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -670,11 +672,6 @@ def test_local_input_keyboard_aim_in_static_mode_reanchors_to_heading( mocker: MockerFixture, ) -> None: _patch_no_user_input(mocker) - mocker.patch.object( - local_input, - "_load_player_bind_block", - lambda _config, *, player_index: tuple(range(16)), - ) mocker.patch.object( local_input.LocalInputInterpreter, "_safe_controls_modes", @@ -683,11 +680,12 @@ def test_local_input_keyboard_aim_in_static_mode_reanchors_to_heading( interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(180.0, 130.0), aim_heading=0.0) + config = _config_with_player_bind_values(range(16)) out = interpreter.build_player_input( player_index=0, player=player, - config=None, + config=config, mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -703,11 +701,6 @@ def test_local_input_keyboard_aim_with_non_relative_move_mode_keeps_world_aim( mocker: MockerFixture, ) -> None: _patch_no_user_input(mocker) - mocker.patch.object( - local_input, - "_load_player_bind_block", - lambda _config, *, player_index: tuple(range(16)), - ) mocker.patch.object( local_input.LocalInputInterpreter, "_safe_controls_modes", @@ -716,11 +709,12 @@ def test_local_input_keyboard_aim_with_non_relative_move_mode_keeps_world_aim( interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(180.0, 130.0), aim_heading=0.0) + config = _config_with_player_bind_values(range(16)) out = interpreter.build_player_input( player_index=0, player=player, - config=None, + config=config, mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -738,11 +732,6 @@ def test_local_input_relative_mouse_aim_centered_keeps_world_aim( mocker: MockerFixture, ) -> None: _patch_no_user_input(mocker) - mocker.patch.object( - local_input, - "_load_player_bind_block", - lambda _config, *, player_index: tuple(range(16)), - ) mocker.patch.object( local_input.LocalInputInterpreter, "_safe_controls_modes", @@ -752,11 +741,12 @@ def test_local_input_relative_mouse_aim_centered_keeps_world_aim( interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(180.0, 130.0), aim_heading=0.0) center = Vec2(320.0, 200.0) + config = _config_with_player_bind_values(range(16)) out = interpreter.build_player_input( player_index=0, player=player, - config=None, + config=config, mouse_screen=center, mouse_world=Vec2(), screen_center=center, diff --git a/tests/modes/test_perk_prompt_controller.py b/tests/modes/test_perk_prompt_controller.py index 151499ea0..61329fbb5 100644 --- a/tests/modes/test_perk_prompt_controller.py +++ b/tests/modes/test_perk_prompt_controller.py @@ -16,7 +16,7 @@ def _config(): config = default_crimson_cfg() - config.controls.pick_perk_key = 0x101 + config.controls.pick_perk_code = 0x101 config.gameplay.show_info_texts = True return config @@ -62,17 +62,12 @@ def test_prompt_open_request_from_pick_key(mocker) -> None: mocker.patch.object( perk_prompt_controller_module, - "input_code_is_pressed_for_player", + "input_code_is_pressed", return_value=True, ) mocker.patch.object( perk_prompt_controller_module, - "player_fire_keybind", - return_value=0x100, - ) - mocker.patch.object( - perk_prompt_controller_module, - "input_code_is_down_for_player", + "input_code_is_down", return_value=False, ) mocker.patch.object( @@ -98,14 +93,9 @@ def test_prompt_open_request_from_hover_click(mocker) -> None: mocker.patch.object( perk_prompt_controller_module, - "input_code_is_pressed_for_player", + "input_code_is_pressed", return_value=False, ) - mocker.patch.object( - perk_prompt_controller_module, - "player_fire_keybind", - return_value=0x100, - ) mocker.patch.object( perk_prompt_controller_module, "input_primary_just_pressed", @@ -146,7 +136,7 @@ def test_prompt_open_request_returns_false_while_menu_active(mocker) -> None: mocker.patch.object( perk_prompt_controller_module, - "input_code_is_pressed_for_player", + "input_code_is_pressed", return_value=True, ) diff --git a/tests/ui/test_controls_labels.py b/tests/ui/test_controls_labels.py index 53f21463d..b4d7861fb 100644 --- a/tests/ui/test_controls_labels.py +++ b/tests/ui/test_controls_labels.py @@ -5,12 +5,11 @@ from crimson.aim_schemes import AimScheme from crimson.movement_controls import MovementControlType from crimson.screens.panels.controls_labels import ( - PICK_PERK_BIND_SLOT, - RELOAD_BIND_SLOT, + BindingId, controls_aim_method_dropdown_ids, controls_method_labels, controls_method_values, - controls_rebind_slot_plan, + controls_rebind_plan, input_configure_for_label, input_scheme_label, ) @@ -86,23 +85,36 @@ def test_controls_aim_method_dropdown_ids_hides_computer_unless_loaded() -> None ) -def test_controls_rebind_slot_plan_keyboard_static_player1() -> None: - aim_rows, move_rows, misc_rows = controls_rebind_slot_plan( +def test_controls_rebind_plan_keyboard_static_player1() -> None: + aim_rows, move_rows, misc_rows = controls_rebind_plan( aim_scheme=AimScheme.KEYBOARD, move_mode=MovementControlType.STATIC, player_index=0, ) - assert aim_rows == (("Torso left:", 7), ("Torso right:", 8), ("Fire:", 4)) - assert move_rows == (("Move Up:", 0), ("Move Down:", 1), ("Move Left:", 2), ("Move Right:", 3)) - assert misc_rows == (("Level Up:", PICK_PERK_BIND_SLOT), ("Reload:", RELOAD_BIND_SLOT)) + assert aim_rows == ( + ("Torso left:", BindingId.AIM_LEFT_CODE), + ("Torso right:", BindingId.AIM_RIGHT_CODE), + ("Fire:", BindingId.FIRE_CODE), + ) + assert move_rows == ( + ("Move Up:", BindingId.MOVE_FORWARD_CODE), + ("Move Down:", BindingId.MOVE_BACKWARD_CODE), + ("Move Left:", BindingId.TURN_LEFT_CODE), + ("Move Right:", BindingId.TURN_RIGHT_CODE), + ) + assert misc_rows == (("Level Up:", BindingId.PICK_PERK_CODE), ("Reload:", BindingId.RELOAD_CODE)) -def test_controls_rebind_slot_plan_dualpad_mouse_cursor_player2() -> None: - aim_rows, move_rows, misc_rows = controls_rebind_slot_plan( +def test_controls_rebind_plan_dualpad_mouse_cursor_player2() -> None: + aim_rows, move_rows, misc_rows = controls_rebind_plan( aim_scheme=AimScheme.DUAL_ACTION_PAD, move_mode=MovementControlType.MOUSE_POINT_CLICK, player_index=1, ) - assert aim_rows == (("Aim Up/Down Axis:", 9), ("Aim Left/Right Axis:", 10), ("Fire:", 4)) - assert move_rows == (("Move to cursor:", RELOAD_BIND_SLOT),) + assert aim_rows == ( + ("Aim Up/Down Axis:", BindingId.AIM_VERTICAL_AXIS_CODE), + ("Aim Left/Right Axis:", BindingId.AIM_HORIZONTAL_AXIS_CODE), + ("Fire:", BindingId.FIRE_CODE), + ) + assert move_rows == (("Move to cursor:", BindingId.RELOAD_CODE),) assert misc_rows == () From d6fd74eaeab47369c4beb42bc11eeb793d793dec Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:26:45 +0400 Subject: [PATCH 02/15] refactor: remove controls menu binding indirection --- src/crimson/screens/panels/controls.py | 184 +++++------------- src/crimson/screens/panels/controls_labels.py | 70 +++---- tests/ui/test_controls_labels.py | 29 +-- 3 files changed, 96 insertions(+), 187 deletions(-) diff --git a/src/crimson/screens/panels/controls.py b/src/crimson/screens/panels/controls.py index c0e0c58e2..c2b168784 100644 --- a/src/crimson/screens/panels/controls.py +++ b/src/crimson/screens/panels/controls.py @@ -4,7 +4,6 @@ from grim.assets import RuntimeResources, TextureId from grim.config import ( - CrimsonPlayerControls, default_crimson_cfg, ) from grim.fonts.small import SmallFontData, draw_small_text, measure_small_text_width @@ -25,7 +24,7 @@ ) from .base import PANEL_TIMELINE_END_MS, PANEL_TIMELINE_START_MS, PanelMenuView from .controls_labels import ( - BindingId, + RebindRowSpec, controls_aim_method_dropdown_ids, controls_method_values, controls_rebind_plan, @@ -48,94 +47,21 @@ CONTROLS_REBIND_HOVER_COLOR = rl.Color(200, 230, 250, 230) CONTROLS_REBIND_ACTIVE_COLOR = rl.Color(255, 228, 170, 255) -_AXIS_REBIND_BINDINGS = frozenset( - ( - BindingId.AIM_VERTICAL_AXIS_CODE, - BindingId.AIM_HORIZONTAL_AXIS_CODE, - BindingId.MOVE_VERTICAL_AXIS_CODE, - BindingId.MOVE_HORIZONTAL_AXIS_CODE, - ), -) +def _row_binding_code(row: RebindRowSpec, *, player_index: int, controls) -> int: + if row.controls_field: + return int(getattr(controls, row.field_name)) + return int(getattr(controls.player(player_index), row.field_name)) + + +def _set_row_binding_code(row: RebindRowSpec, value: int, *, player_index: int, controls) -> None: + owner = controls if row.controls_field else controls.player(player_index) + setattr(owner, row.field_name, int(value)) -def _binding_code(player: CrimsonPlayerControls, binding_id: BindingId, *, controls) -> int: - if binding_id is BindingId.MOVE_FORWARD_CODE: - return int(player.move_forward_code) - if binding_id is BindingId.MOVE_BACKWARD_CODE: - return int(player.move_backward_code) - if binding_id is BindingId.TURN_LEFT_CODE: - return int(player.turn_left_code) - if binding_id is BindingId.TURN_RIGHT_CODE: - return int(player.turn_right_code) - if binding_id is BindingId.FIRE_CODE: - return int(player.fire_code) - if binding_id is BindingId.AIM_LEFT_CODE: - return int(player.aim_left_code) - if binding_id is BindingId.AIM_RIGHT_CODE: - return int(player.aim_right_code) - if binding_id is BindingId.AIM_VERTICAL_AXIS_CODE: - return int(player.aim_vertical_axis_code) - if binding_id is BindingId.AIM_HORIZONTAL_AXIS_CODE: - return int(player.aim_horizontal_axis_code) - if binding_id is BindingId.MOVE_VERTICAL_AXIS_CODE: - return int(player.move_vertical_axis_code) - if binding_id is BindingId.MOVE_HORIZONTAL_AXIS_CODE: - return int(player.move_horizontal_axis_code) - if binding_id is BindingId.PICK_PERK_CODE: - return int(controls.pick_perk_code) - if binding_id is BindingId.RELOAD_CODE: - return int(controls.reload_code) - raise ValueError(f"unsupported binding id: {binding_id!r}") - - -def _set_binding_code(player: CrimsonPlayerControls, binding_id: BindingId, value: int, *, controls) -> None: - code = int(value) - if binding_id is BindingId.MOVE_FORWARD_CODE: - player.move_forward_code = code - return - if binding_id is BindingId.MOVE_BACKWARD_CODE: - player.move_backward_code = code - return - if binding_id is BindingId.TURN_LEFT_CODE: - player.turn_left_code = code - return - if binding_id is BindingId.TURN_RIGHT_CODE: - player.turn_right_code = code - return - if binding_id is BindingId.FIRE_CODE: - player.fire_code = code - return - if binding_id is BindingId.AIM_LEFT_CODE: - player.aim_left_code = code - return - if binding_id is BindingId.AIM_RIGHT_CODE: - player.aim_right_code = code - return - if binding_id is BindingId.AIM_VERTICAL_AXIS_CODE: - player.aim_vertical_axis_code = code - return - if binding_id is BindingId.AIM_HORIZONTAL_AXIS_CODE: - player.aim_horizontal_axis_code = code - return - if binding_id is BindingId.MOVE_VERTICAL_AXIS_CODE: - player.move_vertical_axis_code = code - return - if binding_id is BindingId.MOVE_HORIZONTAL_AXIS_CODE: - player.move_horizontal_axis_code = code - return - if binding_id is BindingId.PICK_PERK_CODE: - controls.pick_perk_code = code - return - if binding_id is BindingId.RELOAD_CODE: - controls.reload_code = code - return - raise ValueError(f"unsupported binding id: {binding_id!r}") - - -def _default_binding_code(player_index: int, binding_id: BindingId) -> int: + +def _default_row_binding_code(player_index: int, row: RebindRowSpec) -> int: controls = default_crimson_cfg().controls - player = controls.player(player_index) - return _binding_code(player, binding_id, controls=controls) + return _row_binding_code(row, player_index=player_index, controls=controls) def _controls_left_panel_pos_x(screen_width: float) -> float: @@ -191,8 +117,7 @@ class _ControlsDropdownLayout(DropdownLayoutBase, frozen=True): class _RebindRowLayout(msgspec.Struct, frozen=True): - label: str - binding_id: BindingId + row: RebindRowSpec row_y: float value_pos: Vec2 value_rect: Rect @@ -212,7 +137,7 @@ def __init__(self, state: GameState) -> None: self._aim_method_open = False self._player_profile_open = False self._dirty = False - self._rebind_binding_id: BindingId | None = None + self._rebind_row: RebindRowSpec | None = None self._rebind_player_index: int | None = None self._rebind_skip_frames = 0 @@ -271,15 +196,15 @@ def _current_player_index(self) -> int: return max(0, min(3, int(self._config_player) - 1)) def _rebind_active(self) -> bool: - return self._rebind_binding_id is not None and self._rebind_player_index is not None + return self._rebind_row is not None and self._rebind_player_index is not None def _clear_rebind_capture(self) -> None: - self._rebind_binding_id = None + self._rebind_row = None self._rebind_player_index = None self._rebind_skip_frames = 0 - def _start_rebind_capture(self, *, binding_id: BindingId, player_index: int) -> None: - self._rebind_binding_id = binding_id + def _start_rebind_capture(self, *, row: RebindRowSpec, player_index: int) -> None: + self._rebind_row = row self._rebind_player_index = max(0, min(3, int(player_index))) self._move_method_open = False self._aim_method_open = False @@ -288,32 +213,19 @@ def _start_rebind_capture(self, *, binding_id: BindingId, player_index: int) -> self._rebind_skip_frames = 1 @staticmethod - def _binding_is_axis(binding_id: BindingId) -> bool: - return binding_id in _AXIS_REBIND_BINDINGS - - @staticmethod - def _capture_prompt_for_binding(binding_id: BindingId) -> str: - if ControlsMenuView._binding_is_axis(binding_id): + def _capture_prompt_for_binding(row: RebindRowSpec) -> str: + if row.axis: return "" return "" - def _binding_default_code(self, *, player_index: int, binding_id: BindingId) -> int: - return _default_binding_code(player_index, binding_id) + def _binding_default_code(self, *, player_index: int, row: RebindRowSpec) -> int: + return _default_row_binding_code(player_index, row) - def _binding_code(self, *, player_index: int, binding_id: BindingId) -> int: - return _binding_code( - self.state.config.controls.player(player_index), - binding_id, - controls=self.state.config.controls, - ) + def _binding_code(self, *, player_index: int, row: RebindRowSpec) -> int: + return _row_binding_code(row, player_index=player_index, controls=self.state.config.controls) - def _set_binding_code(self, *, player_index: int, binding_id: BindingId, code: int) -> None: - _set_binding_code( - self.state.config.controls.player(player_index), - binding_id, - int(code), - controls=self.state.config.controls, - ) + def _set_binding_code(self, *, player_index: int, row: RebindRowSpec, code: int) -> None: + _set_row_binding_code(row, int(code), player_index=player_index, controls=self.state.config.controls) def _left_panel_top_left(self, panel_scale: float) -> Vec2: panel_w = MENU_PANEL_WIDTH * panel_scale @@ -409,13 +321,13 @@ def _rebind_sections( player_index: int, aim_scheme: AimScheme, move_mode: MovementControlType, - ) -> tuple[tuple[str, tuple[tuple[str, BindingId], ...]], ...]: + ) -> tuple[tuple[str, tuple[RebindRowSpec, ...]], ...]: aim_rows, move_rows, misc_rows = controls_rebind_plan( aim_scheme=aim_scheme, move_mode=move_mode, player_index=player_index, ) - sections: list[tuple[str, tuple[tuple[str, BindingId], ...]]] = [("Aiming", aim_rows), ("Moving", move_rows)] + sections: list[tuple[str, tuple[RebindRowSpec, ...]]] = [("Aiming", aim_rows), ("Moving", move_rows)] if misc_rows: sections.append(("Misc", misc_rows)) return tuple(sections) @@ -426,15 +338,15 @@ def _collect_rebind_rows( right_top_left: Vec2, panel_scale: float, player_index: int, - sections: tuple[tuple[str, tuple[tuple[str, BindingId], ...]], ...], + sections: tuple[tuple[str, tuple[RebindRowSpec, ...]], ...], font: SmallFontData, ) -> tuple[_RebindRowLayout, ...]: rows: list[_RebindRowLayout] = [] y = right_top_left.y + 64.0 * panel_scale for _section_title, section_rows in sections: row_y = y + 18.0 * panel_scale - for label, binding_id in section_rows: - key_code = int(self._binding_code(player_index=player_index, binding_id=binding_id)) + for row in section_rows: + key_code = int(self._binding_code(player_index=player_index, row=row)) value_text = input_code_name(key_code) value_pos = Vec2(right_top_left.x + 180.0 * panel_scale, row_y) value_w = max(60.0 * panel_scale, measure_small_text_width(font, value_text)) @@ -445,8 +357,7 @@ def _collect_rebind_rows( ) rows.append( _RebindRowLayout( - label=str(label), - binding_id=binding_id, + row=row, row_y=float(row_y), value_pos=value_pos, value_rect=value_rect, @@ -469,7 +380,7 @@ def _update_rebind_capture(self, *, right_top_left: Vec2, panel_scale: float, fo ) if self._rebind_active(): - active_binding_id = self._rebind_binding_id or BindingId.FIRE_CODE + active_row = self._rebind_row or RebindRowSpec("Fire:", "fire_code") active_player = int(self._rebind_player_index or 0) if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE) or rl.is_mouse_button_pressed( rl.MouseButton.MOUSE_BUTTON_RIGHT, @@ -480,15 +391,15 @@ def _update_rebind_capture(self, *, right_top_left: Vec2, panel_scale: float, fo if rl.is_key_pressed(rl.KeyboardKey.KEY_BACKSPACE): self._set_binding_code( player_index=active_player, - binding_id=active_binding_id, - code=self._binding_default_code(player_index=active_player, binding_id=active_binding_id), + row=active_row, + code=self._binding_default_code(player_index=active_player, row=active_row), ) self._dirty = True self._clear_rebind_capture() return True if rl.is_key_pressed(rl.KeyboardKey.KEY_DELETE): - self._set_binding_code(player_index=active_player, binding_id=active_binding_id, code=INPUT_CODE_UNBOUND) + self._set_binding_code(player_index=active_player, row=active_row, code=INPUT_CODE_UNBOUND) self._dirty = True self._clear_rebind_capture() return True @@ -497,7 +408,7 @@ def _update_rebind_capture(self, *, right_top_left: Vec2, panel_scale: float, fo self._rebind_skip_frames = max(0, int(self._rebind_skip_frames) - 1) return True - axis_only = self._binding_is_axis(active_binding_id) + axis_only = active_row.axis captured = capture_first_pressed_input_code( player_index=active_player, include_keyboard=not axis_only, @@ -507,7 +418,7 @@ def _update_rebind_capture(self, *, right_top_left: Vec2, panel_scale: float, fo axis_threshold=0.5, ) if captured is not None: - self._set_binding_code(player_index=active_player, binding_id=active_binding_id, code=int(captured)) + self._set_binding_code(player_index=active_player, row=active_row, code=int(captured)) self._dirty = True self._clear_rebind_capture() return True @@ -520,7 +431,7 @@ def _update_rebind_capture(self, *, right_top_left: Vec2, panel_scale: float, fo mouse = Vec2.from_xy(rl.get_mouse_position()) for row in rows: if row.value_rect.contains(mouse): - self._start_rebind_capture(binding_id=row.binding_id, player_index=player_idx) + self._start_rebind_capture(row=row.row, player_index=player_idx) return True return False @@ -904,20 +815,23 @@ def _draw_section_heading(title: str, *, y: float) -> None: row_y = y + 18.0 * panel_scale for _ in section_rows: row = next(row_iter) - label = row.label - binding_id = row.binding_id - active_row = rebind_active and self._rebind_binding_id is binding_id and int( + active_row = rebind_active and self._rebind_row == row.row and int( self._rebind_player_index or -1, ) == player_idx hovered_row = (not rebind_active) and (not dropdown_blocked) and row.value_rect.contains(mouse) value_text = ( - self._capture_prompt_for_binding(binding_id) + self._capture_prompt_for_binding(row.row) if active_row - else input_code_name(self._binding_code(player_index=player_idx, binding_id=binding_id)) + else input_code_name(self._binding_code(player_index=player_idx, row=row.row)) ) value_pos = row.value_pos - draw_small_text(font, label, Vec2(right_top_left.x + 52.0 * panel_scale, row_y), rl.Color(255, 255, 255, 178)) + draw_small_text( + font, + row.row.label, + Vec2(right_top_left.x + 52.0 * panel_scale, row_y), + rl.Color(255, 255, 255, 178), + ) value_color = CONTROLS_REBIND_VALUE_COLOR if hovered_row: value_color = CONTROLS_REBIND_HOVER_COLOR diff --git a/src/crimson/screens/panels/controls_labels.py b/src/crimson/screens/panels/controls_labels.py index 5fa8da4c1..108083793 100644 --- a/src/crimson/screens/panels/controls_labels.py +++ b/src/crimson/screens/panels/controls_labels.py @@ -1,6 +1,6 @@ from __future__ import annotations -from enum import Enum, auto +from dataclasses import dataclass from grim.config import CrimsonControlsConfig @@ -8,20 +8,12 @@ from ...movement_controls import MovementControlType -class BindingId(Enum): - MOVE_FORWARD_CODE = auto() - MOVE_BACKWARD_CODE = auto() - TURN_LEFT_CODE = auto() - TURN_RIGHT_CODE = auto() - FIRE_CODE = auto() - AIM_LEFT_CODE = auto() - AIM_RIGHT_CODE = auto() - AIM_VERTICAL_AXIS_CODE = auto() - AIM_HORIZONTAL_AXIS_CODE = auto() - MOVE_VERTICAL_AXIS_CODE = auto() - MOVE_HORIZONTAL_AXIS_CODE = auto() - PICK_PERK_CODE = auto() - RELOAD_CODE = auto() +@dataclass(frozen=True, slots=True) +class RebindRowSpec: + label: str + field_name: str + axis: bool = False + controls_field: bool = False def input_configure_for_label(config_id: AimScheme) -> str: @@ -86,55 +78,55 @@ def controls_rebind_plan( move_mode: MovementControlType, player_index: int, ) -> tuple[ - tuple[tuple[str, BindingId], ...], - tuple[tuple[str, BindingId], ...], - tuple[tuple[str, BindingId], ...], + tuple[RebindRowSpec, ...], + tuple[RebindRowSpec, ...], + tuple[RebindRowSpec, ...], ]: """Return (aim_rows, move_rows, misc_rows) for `controls_menu_update`.""" - aim_rows: list[tuple[str, BindingId]] = [] - move_rows: list[tuple[str, BindingId]] = [] - misc_rows: list[tuple[str, BindingId]] = [] + aim_rows: list[RebindRowSpec] = [] + move_rows: list[RebindRowSpec] = [] + misc_rows: list[RebindRowSpec] = [] if aim_scheme is AimScheme.KEYBOARD: - aim_rows.append(("Torso left:", BindingId.AIM_LEFT_CODE)) - aim_rows.append(("Torso right:", BindingId.AIM_RIGHT_CODE)) + aim_rows.append(RebindRowSpec("Torso left:", "aim_left_code")) + aim_rows.append(RebindRowSpec("Torso right:", "aim_right_code")) elif aim_scheme is AimScheme.DUAL_ACTION_PAD: - aim_rows.append(("Aim Up/Down Axis:", BindingId.AIM_VERTICAL_AXIS_CODE)) - aim_rows.append(("Aim Left/Right Axis:", BindingId.AIM_HORIZONTAL_AXIS_CODE)) - aim_rows.append(("Fire:", BindingId.FIRE_CODE)) + aim_rows.append(RebindRowSpec("Aim Up/Down Axis:", "aim_vertical_axis_code", axis=True)) + aim_rows.append(RebindRowSpec("Aim Left/Right Axis:", "aim_horizontal_axis_code", axis=True)) + aim_rows.append(RebindRowSpec("Fire:", "fire_code")) if move_mode is MovementControlType.STATIC: move_rows.extend( ( - ("Move Up:", BindingId.MOVE_FORWARD_CODE), - ("Move Down:", BindingId.MOVE_BACKWARD_CODE), - ("Move Left:", BindingId.TURN_LEFT_CODE), - ("Move Right:", BindingId.TURN_RIGHT_CODE), + RebindRowSpec("Move Up:", "move_forward_code"), + RebindRowSpec("Move Down:", "move_backward_code"), + RebindRowSpec("Move Left:", "turn_left_code"), + RebindRowSpec("Move Right:", "turn_right_code"), ), ) elif move_mode is MovementControlType.RELATIVE: move_rows.extend( ( - ("Forward:", BindingId.MOVE_FORWARD_CODE), - ("Backwards:", BindingId.MOVE_BACKWARD_CODE), - ("Turn left:", BindingId.TURN_LEFT_CODE), - ("Turn right:", BindingId.TURN_RIGHT_CODE), + RebindRowSpec("Forward:", "move_forward_code"), + RebindRowSpec("Backwards:", "move_backward_code"), + RebindRowSpec("Turn left:", "turn_left_code"), + RebindRowSpec("Turn right:", "turn_right_code"), ), ) elif move_mode is MovementControlType.DUAL_ACTION_PAD: move_rows.extend( ( - ("Up/Down Axis:", BindingId.MOVE_VERTICAL_AXIS_CODE), - ("Left/Right Axis:", BindingId.MOVE_HORIZONTAL_AXIS_CODE), + RebindRowSpec("Up/Down Axis:", "move_vertical_axis_code", axis=True), + RebindRowSpec("Left/Right Axis:", "move_horizontal_axis_code", axis=True), ), ) elif move_mode is MovementControlType.MOUSE_POINT_CLICK: - move_rows.append(("Move to cursor:", BindingId.RELOAD_CODE)) + move_rows.append(RebindRowSpec("Move to cursor:", "reload_code", controls_field=True)) if int(player_index) == 0: - misc_rows.append(("Level Up:", BindingId.PICK_PERK_CODE)) + misc_rows.append(RebindRowSpec("Level Up:", "pick_perk_code", controls_field=True)) if move_mode is not MovementControlType.MOUSE_POINT_CLICK: - misc_rows.append(("Reload:", BindingId.RELOAD_CODE)) + misc_rows.append(RebindRowSpec("Reload:", "reload_code", controls_field=True)) return tuple(aim_rows), tuple(move_rows), tuple(misc_rows) diff --git a/tests/ui/test_controls_labels.py b/tests/ui/test_controls_labels.py index b4d7861fb..7ea7c357a 100644 --- a/tests/ui/test_controls_labels.py +++ b/tests/ui/test_controls_labels.py @@ -5,7 +5,7 @@ from crimson.aim_schemes import AimScheme from crimson.movement_controls import MovementControlType from crimson.screens.panels.controls_labels import ( - BindingId, + RebindRowSpec, controls_aim_method_dropdown_ids, controls_method_labels, controls_method_values, @@ -92,17 +92,20 @@ def test_controls_rebind_plan_keyboard_static_player1() -> None: player_index=0, ) assert aim_rows == ( - ("Torso left:", BindingId.AIM_LEFT_CODE), - ("Torso right:", BindingId.AIM_RIGHT_CODE), - ("Fire:", BindingId.FIRE_CODE), + RebindRowSpec("Torso left:", "aim_left_code"), + RebindRowSpec("Torso right:", "aim_right_code"), + RebindRowSpec("Fire:", "fire_code"), ) assert move_rows == ( - ("Move Up:", BindingId.MOVE_FORWARD_CODE), - ("Move Down:", BindingId.MOVE_BACKWARD_CODE), - ("Move Left:", BindingId.TURN_LEFT_CODE), - ("Move Right:", BindingId.TURN_RIGHT_CODE), + RebindRowSpec("Move Up:", "move_forward_code"), + RebindRowSpec("Move Down:", "move_backward_code"), + RebindRowSpec("Move Left:", "turn_left_code"), + RebindRowSpec("Move Right:", "turn_right_code"), + ) + assert misc_rows == ( + RebindRowSpec("Level Up:", "pick_perk_code", controls_field=True), + RebindRowSpec("Reload:", "reload_code", controls_field=True), ) - assert misc_rows == (("Level Up:", BindingId.PICK_PERK_CODE), ("Reload:", BindingId.RELOAD_CODE)) def test_controls_rebind_plan_dualpad_mouse_cursor_player2() -> None: @@ -112,9 +115,9 @@ def test_controls_rebind_plan_dualpad_mouse_cursor_player2() -> None: player_index=1, ) assert aim_rows == ( - ("Aim Up/Down Axis:", BindingId.AIM_VERTICAL_AXIS_CODE), - ("Aim Left/Right Axis:", BindingId.AIM_HORIZONTAL_AXIS_CODE), - ("Fire:", BindingId.FIRE_CODE), + RebindRowSpec("Aim Up/Down Axis:", "aim_vertical_axis_code", axis=True), + RebindRowSpec("Aim Left/Right Axis:", "aim_horizontal_axis_code", axis=True), + RebindRowSpec("Fire:", "fire_code"), ) - assert move_rows == (("Move to cursor:", BindingId.RELOAD_CODE),) + assert move_rows == (RebindRowSpec("Move to cursor:", "reload_code", controls_field=True),) assert misc_rows == () From e3acaa71295d390c85b25b0be7616c2c05c1f044 Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:32:10 +0400 Subject: [PATCH 03/15] refactor: group player control codes semantically --- src/crimson/local_input.py | 50 ++++++-------- src/crimson/modes/tutorial_mode.py | 9 +-- src/crimson/screens/panels/controls.py | 16 ++++- src/crimson/screens/panels/controls_labels.py | 29 ++++---- src/crimson/ui/text_input.py | 9 +-- src/grim/config.py | 67 ++++++++----------- tests/grim/test_grim_config.py | 4 +- tests/input/test_local_input.py | 14 ++-- tests/ui/test_controls_labels.py | 16 ++--- 9 files changed, 99 insertions(+), 115 deletions(-) diff --git a/src/crimson/local_input.py b/src/crimson/local_input.py index 6318af3c3..3c7514b94 100644 --- a/src/crimson/local_input.py +++ b/src/crimson/local_input.py @@ -269,17 +269,11 @@ def build_player_input( aim_scheme, move_mode_type = self._safe_controls_modes(config, player_index=idx) reload_key = config.controls.reload_code - up_key = int(binds.move_forward_code) - down_key = int(binds.move_backward_code) - left_key = int(binds.turn_left_code) - right_key = int(binds.turn_right_code) + move_codes = tuple(int(code) for code in binds.move_codes) fire_key = int(binds.fire_code) - aim_left_key = int(binds.aim_left_code) - aim_right_key = int(binds.aim_right_code) - aim_axis_y = int(binds.aim_vertical_axis_code) - aim_axis_x = int(binds.aim_horizontal_axis_code) - move_axis_y = int(binds.move_vertical_axis_code) - move_axis_x = int(binds.move_horizontal_axis_code) + keyboard_aim_codes = tuple(int(code) for code in binds.keyboard_aim_codes) + aim_axis_codes = tuple(int(code) for code in binds.aim_axis_codes) + move_axis_codes = tuple(int(code) for code in binds.move_axis_codes) move_vec = Vec2() move_forward_pressed: bool | None = None @@ -320,25 +314,25 @@ def build_player_input( move_vec = move_dir elif move_mode_type is MovementControlType.RELATIVE: move_forward_pressed = _key_down_with_single_player_alt( - up_key, + move_codes[0], alt_key=_ALT_MOVE_KEY_UP, config=config, player_index=idx, ) move_backward_pressed = _key_down_with_single_player_alt( - down_key, + move_codes[1], alt_key=_ALT_MOVE_KEY_DOWN, config=config, player_index=idx, ) turn_left_pressed = _key_down_with_single_player_alt( - left_key, + move_codes[2], alt_key=_ALT_MOVE_KEY_LEFT, config=config, player_index=idx, ) turn_right_pressed = _key_down_with_single_player_alt( - right_key, + move_codes[3], alt_key=_ALT_MOVE_KEY_RIGHT, config=config, player_index=idx, @@ -348,8 +342,8 @@ def build_player_input( float(move_backward_pressed) - float(move_forward_pressed), ) elif move_mode_type is MovementControlType.DUAL_ACTION_PAD: - axis_y = -input_axis_value(move_axis_y, player_index=idx) - axis_x = -input_axis_value(move_axis_x, player_index=idx) + axis_y = -input_axis_value(move_axis_codes[0], player_index=idx) + axis_x = -input_axis_value(move_axis_codes[1], player_index=idx) move_vec = Vec2(_clamp_unit(axis_x), _clamp_unit(axis_y)) elif move_mode_type is MovementControlType.MOUSE_POINT_CLICK: move_to_cursor_pressed = input_code_is_down(reload_key, player_index=idx) @@ -362,25 +356,25 @@ def build_player_input( move_vec = _dir elif move_mode_type is MovementControlType.STATIC: move_up_pressed = _key_down_with_single_player_alt( - up_key, + move_codes[0], alt_key=_ALT_MOVE_KEY_UP, config=config, player_index=idx, ) move_down_pressed = _key_down_with_single_player_alt( - down_key, + move_codes[1], alt_key=_ALT_MOVE_KEY_DOWN, config=config, player_index=idx, ) move_left_pressed = _key_down_with_single_player_alt( - left_key, + move_codes[2], alt_key=_ALT_MOVE_KEY_LEFT, config=config, player_index=idx, ) move_right_pressed = _key_down_with_single_player_alt( - right_key, + move_codes[3], alt_key=_ALT_MOVE_KEY_RIGHT, config=config, player_index=idx, @@ -397,10 +391,10 @@ def build_player_input( ) else: move_vec = Vec2( - float(input_code_is_down(right_key, player_index=idx)) - - float(input_code_is_down(left_key, player_index=idx)), - float(input_code_is_down(down_key, player_index=idx)) - - float(input_code_is_down(up_key, player_index=idx)), + float(input_code_is_down(move_codes[3], player_index=idx)) + - float(input_code_is_down(move_codes[2], player_index=idx)), + float(input_code_is_down(move_codes[1], player_index=idx)) + - float(input_code_is_down(move_codes[0], player_index=idx)), ) heading = float(state.aim_heading) @@ -415,9 +409,9 @@ def build_player_input( heading = delta.to_heading() elif aim_scheme is AimScheme.KEYBOARD: if move_mode_type in {MovementControlType.RELATIVE, MovementControlType.STATIC}: - if input_code_is_down(aim_right_key, player_index=idx): + if input_code_is_down(keyboard_aim_codes[1], player_index=idx): heading = float(heading + float(dt) * _AIM_KEYBOARD_TURN_RATE) - if input_code_is_down(aim_left_key, player_index=idx): + if input_code_is_down(keyboard_aim_codes[0], player_index=idx): heading = float(heading - float(dt) * _AIM_KEYBOARD_TURN_RATE) aim = _aim_point_from_heading(player.pos, heading) elif aim_scheme is AimScheme.MOUSE_RELATIVE: @@ -426,8 +420,8 @@ def build_player_input( heading = rel.to_heading() aim = _aim_point_from_heading(player.pos, heading) elif aim_scheme is AimScheme.DUAL_ACTION_PAD: - axis_y = input_axis_value(aim_axis_y, player_index=idx) - axis_x = input_axis_value(aim_axis_x, player_index=idx) + axis_y = input_axis_value(aim_axis_codes[0], player_index=idx) + axis_x = input_axis_value(aim_axis_codes[1], player_index=idx) axis_vec = Vec2(axis_x, axis_y) mag_sq = axis_vec.length_sq() if mag_sq > 1e-9: diff --git a/src/crimson/modes/tutorial_mode.py b/src/crimson/modes/tutorial_mode.py index 948919018..d7e6f9b6e 100644 --- a/src/crimson/modes/tutorial_mode.py +++ b/src/crimson/modes/tutorial_mode.py @@ -186,15 +186,12 @@ def _handle_input(self) -> None: def _build_input(self) -> PlayerInput: controls = self.config.controls.player(0) - up_key = int(controls.move_forward_code) - down_key = int(controls.move_backward_code) - left_key = int(controls.turn_left_code) - right_key = int(controls.turn_right_code) + move_codes = tuple(int(code) for code in controls.move_codes) fire_key = int(controls.fire_code) move = Vec2( - float(input_code_is_down(right_key)) - float(input_code_is_down(left_key)), - float(input_code_is_down(down_key)) - float(input_code_is_down(up_key)), + float(input_code_is_down(move_codes[3])) - float(input_code_is_down(move_codes[2])), + float(input_code_is_down(move_codes[1])) - float(input_code_is_down(move_codes[0])), ) mouse = self._ui_mouse_pos() diff --git a/src/crimson/screens/panels/controls.py b/src/crimson/screens/panels/controls.py index c2b168784..674ee2482 100644 --- a/src/crimson/screens/panels/controls.py +++ b/src/crimson/screens/panels/controls.py @@ -49,14 +49,24 @@ def _row_binding_code(row: RebindRowSpec, *, player_index: int, controls) -> int: + owner = controls if row.controls_field else controls.player(player_index) + value = getattr(owner, row.field_name) + if row.field_index is not None: + return int(value[row.field_index]) if row.controls_field: - return int(getattr(controls, row.field_name)) - return int(getattr(controls.player(player_index), row.field_name)) + return int(value) + return int(value) def _set_row_binding_code(row: RebindRowSpec, value: int, *, player_index: int, controls) -> None: owner = controls if row.controls_field else controls.player(player_index) - setattr(owner, row.field_name, int(value)) + code = int(value) + if row.field_index is None: + setattr(owner, row.field_name, code) + return + values = list(getattr(owner, row.field_name)) + values[row.field_index] = code + setattr(owner, row.field_name, tuple(values)) def _default_row_binding_code(player_index: int, row: RebindRowSpec) -> int: diff --git a/src/crimson/screens/panels/controls_labels.py b/src/crimson/screens/panels/controls_labels.py index 108083793..3380ff88b 100644 --- a/src/crimson/screens/panels/controls_labels.py +++ b/src/crimson/screens/panels/controls_labels.py @@ -12,6 +12,7 @@ class RebindRowSpec: label: str field_name: str + field_index: int | None = None axis: bool = False controls_field: bool = False @@ -89,36 +90,36 @@ def controls_rebind_plan( misc_rows: list[RebindRowSpec] = [] if aim_scheme is AimScheme.KEYBOARD: - aim_rows.append(RebindRowSpec("Torso left:", "aim_left_code")) - aim_rows.append(RebindRowSpec("Torso right:", "aim_right_code")) + aim_rows.append(RebindRowSpec("Torso left:", "keyboard_aim_codes", 0)) + aim_rows.append(RebindRowSpec("Torso right:", "keyboard_aim_codes", 1)) elif aim_scheme is AimScheme.DUAL_ACTION_PAD: - aim_rows.append(RebindRowSpec("Aim Up/Down Axis:", "aim_vertical_axis_code", axis=True)) - aim_rows.append(RebindRowSpec("Aim Left/Right Axis:", "aim_horizontal_axis_code", axis=True)) + aim_rows.append(RebindRowSpec("Aim Up/Down Axis:", "aim_axis_codes", 0, axis=True)) + aim_rows.append(RebindRowSpec("Aim Left/Right Axis:", "aim_axis_codes", 1, axis=True)) aim_rows.append(RebindRowSpec("Fire:", "fire_code")) if move_mode is MovementControlType.STATIC: move_rows.extend( ( - RebindRowSpec("Move Up:", "move_forward_code"), - RebindRowSpec("Move Down:", "move_backward_code"), - RebindRowSpec("Move Left:", "turn_left_code"), - RebindRowSpec("Move Right:", "turn_right_code"), + RebindRowSpec("Move Up:", "move_codes", 0), + RebindRowSpec("Move Down:", "move_codes", 1), + RebindRowSpec("Move Left:", "move_codes", 2), + RebindRowSpec("Move Right:", "move_codes", 3), ), ) elif move_mode is MovementControlType.RELATIVE: move_rows.extend( ( - RebindRowSpec("Forward:", "move_forward_code"), - RebindRowSpec("Backwards:", "move_backward_code"), - RebindRowSpec("Turn left:", "turn_left_code"), - RebindRowSpec("Turn right:", "turn_right_code"), + RebindRowSpec("Forward:", "move_codes", 0), + RebindRowSpec("Backwards:", "move_codes", 1), + RebindRowSpec("Turn left:", "move_codes", 2), + RebindRowSpec("Turn right:", "move_codes", 3), ), ) elif move_mode is MovementControlType.DUAL_ACTION_PAD: move_rows.extend( ( - RebindRowSpec("Up/Down Axis:", "move_vertical_axis_code", axis=True), - RebindRowSpec("Left/Right Axis:", "move_horizontal_axis_code", axis=True), + RebindRowSpec("Up/Down Axis:", "move_axis_codes", 0, axis=True), + RebindRowSpec("Left/Right Axis:", "move_axis_codes", 1, axis=True), ), ) elif move_mode is MovementControlType.MOUSE_POINT_CLICK: diff --git a/src/crimson/ui/text_input.py b/src/crimson/ui/text_input.py index d79d34891..a167c4b88 100644 --- a/src/crimson/ui/text_input.py +++ b/src/crimson/ui/text_input.py @@ -79,11 +79,12 @@ def update_name_entry_text( def gameplay_controls_held(config: CrimsonConfig) -> bool: player_count = max(1, min(4, config.gameplay.player_count)) for player_index in range(player_count): + move_codes = tuple(int(code) for code in config.controls.player(player_index).move_codes) for code in ( - int(config.controls.player(player_index).move_forward_code), - int(config.controls.player(player_index).move_backward_code), - int(config.controls.player(player_index).turn_left_code), - int(config.controls.player(player_index).turn_right_code), + move_codes[0], + move_codes[1], + move_codes[2], + move_codes[3], int(config.controls.player(player_index).fire_code), )[:_CONTROL_BIND_SLOTS]: key_code = int(code) diff --git a/src/grim/config.py b/src/grim/config.py index 4d9668ed1..cea9c15bc 100644 --- a/src/grim/config.py +++ b/src/grim/config.py @@ -290,17 +290,11 @@ class CrimsonPlayerControls(msgspec.Struct): movement: MovementControlType aim_scheme: AimScheme show_direction_arrow: bool - move_forward_code: int - move_backward_code: int - turn_left_code: int - turn_right_code: int + move_codes: tuple[int, int, int, int] fire_code: int - aim_left_code: int - aim_right_code: int - aim_vertical_axis_code: int - aim_horizontal_axis_code: int - move_vertical_axis_code: int - move_horizontal_axis_code: int + keyboard_aim_codes: tuple[int, int] + aim_axis_codes: tuple[int, int] + move_axis_codes: tuple[int, int] class CrimsonControlsConfig(msgspec.Struct): @@ -402,35 +396,34 @@ def _player_controls_from_bind_values( movement=movement, aim_scheme=aim_scheme, show_direction_arrow=show_direction_arrow, - move_forward_code=int(bind_values[0]), - move_backward_code=int(bind_values[1]), - turn_left_code=int(bind_values[2]), - turn_right_code=int(bind_values[3]), + move_codes=( + int(bind_values[0]), + int(bind_values[1]), + int(bind_values[2]), + int(bind_values[3]), + ), fire_code=int(bind_values[4]), - aim_left_code=int(bind_values[7]), - aim_right_code=int(bind_values[8]), - aim_vertical_axis_code=int(bind_values[9]), - aim_horizontal_axis_code=int(bind_values[10]), - move_vertical_axis_code=int(bind_values[11]), - move_horizontal_axis_code=int(bind_values[12]), + keyboard_aim_codes=(int(bind_values[7]), int(bind_values[8])), + aim_axis_codes=(int(bind_values[9]), int(bind_values[10])), + move_axis_codes=(int(bind_values[11]), int(bind_values[12])), ) def _encode_player_bind_block(player: CrimsonPlayerControls, *, player_index: int) -> dict[str, object]: defaults = _default_player_bind_values(player_index) return { - "move_forward": int(player.move_forward_code), - "move_backward": int(player.move_backward_code), - "turn_left": int(player.turn_left_code), - "turn_right": int(player.turn_right_code), + "move_forward": int(player.move_codes[0]), + "move_backward": int(player.move_codes[1]), + "turn_left": int(player.move_codes[2]), + "turn_right": int(player.move_codes[3]), "fire": int(player.fire_code), "reserved_keys": [int(defaults[5]), int(defaults[6])], - "aim_left": int(player.aim_left_code), - "aim_right": int(player.aim_right_code), - "axis_aim_y": int(player.aim_vertical_axis_code), - "axis_aim_x": int(player.aim_horizontal_axis_code), - "axis_move_y": int(player.move_vertical_axis_code), - "axis_move_x": int(player.move_horizontal_axis_code), + "aim_left": int(player.keyboard_aim_codes[0]), + "aim_right": int(player.keyboard_aim_codes[1]), + "axis_aim_y": int(player.aim_axis_codes[0]), + "axis_aim_x": int(player.aim_axis_codes[1]), + "axis_move_y": int(player.move_axis_codes[0]), + "axis_move_x": int(player.move_axis_codes[1]), "padding": [int(defaults[13]), int(defaults[14]), int(defaults[15])], } @@ -544,17 +537,11 @@ def _default_player_controls(player_index: int) -> CrimsonPlayerControls: movement=MovementControlType.STATIC, aim_scheme=AimScheme.MOUSE, show_direction_arrow=True, - move_forward_code=int(values[0]), - move_backward_code=int(values[1]), - turn_left_code=int(values[2]), - turn_right_code=int(values[3]), + move_codes=(int(values[0]), int(values[1]), int(values[2]), int(values[3])), fire_code=int(values[4]), - aim_left_code=int(values[7]), - aim_right_code=int(values[8]), - aim_vertical_axis_code=int(values[9]), - aim_horizontal_axis_code=int(values[10]), - move_vertical_axis_code=int(values[11]), - move_horizontal_axis_code=int(values[12]), + keyboard_aim_codes=(int(values[7]), int(values[8])), + aim_axis_codes=(int(values[9]), int(values[10])), + move_axis_codes=(int(values[11]), int(values[12])), ) diff --git a/tests/grim/test_grim_config.py b/tests/grim/test_grim_config.py index b1151e6e2..d110fc254 100644 --- a/tests/grim/test_grim_config.py +++ b/tests/grim/test_grim_config.py @@ -77,7 +77,7 @@ def test_crimson_cfg_backfills_zero_keybinds(tmp_path: Path) -> None: def test_player_keybind_roundtrip_for_extended_players_uses_reserved_gap_extension() -> None: cfg = grim_config.default_crimson_cfg(Path("")) cfg.controls.player(2).fire_code = 0x120 - cfg.controls.player(3).move_forward_code = 0x11F + cfg.controls.player(3).move_codes = (0x11F, 0x91, 0x8A, 0x97) blob = grim_config.encode_crimson_cfg(cfg) parsed = grim_config.CRIMSON_CFG_STRUCT.parse(blob) @@ -89,7 +89,7 @@ def test_player_keybind_roundtrip_for_extended_players_uses_reserved_gap_extensi loaded = grim_config.decode_crimson_cfg(Path(""), blob) assert loaded.controls.player(2).fire_code == 0x120 - assert loaded.controls.player(3).move_forward_code == 0x11F + assert loaded.controls.player(3).move_codes[0] == 0x11F def test_direction_arrow_extension_roundtrip_for_players_three_and_four() -> None: diff --git a/tests/input/test_local_input.py b/tests/input/test_local_input.py index dca9f4f24..b6e9eb498 100644 --- a/tests/input/test_local_input.py +++ b/tests/input/test_local_input.py @@ -69,17 +69,11 @@ def _set_player_bind_values( ) -> CrimsonConfig: block = _bind_values(values) player = cfg.controls.player(player_index) - player.move_forward_code = block[0] - player.move_backward_code = block[1] - player.turn_left_code = block[2] - player.turn_right_code = block[3] + player.move_codes = (block[0], block[1], block[2], block[3]) player.fire_code = block[4] - player.aim_left_code = block[7] - player.aim_right_code = block[8] - player.aim_vertical_axis_code = block[9] - player.aim_horizontal_axis_code = block[10] - player.move_vertical_axis_code = block[11] - player.move_horizontal_axis_code = block[12] + player.keyboard_aim_codes = (block[7], block[8]) + player.aim_axis_codes = (block[9], block[10]) + player.move_axis_codes = (block[11], block[12]) return cfg diff --git a/tests/ui/test_controls_labels.py b/tests/ui/test_controls_labels.py index 7ea7c357a..0651d3a93 100644 --- a/tests/ui/test_controls_labels.py +++ b/tests/ui/test_controls_labels.py @@ -92,15 +92,15 @@ def test_controls_rebind_plan_keyboard_static_player1() -> None: player_index=0, ) assert aim_rows == ( - RebindRowSpec("Torso left:", "aim_left_code"), - RebindRowSpec("Torso right:", "aim_right_code"), + RebindRowSpec("Torso left:", "keyboard_aim_codes", 0), + RebindRowSpec("Torso right:", "keyboard_aim_codes", 1), RebindRowSpec("Fire:", "fire_code"), ) assert move_rows == ( - RebindRowSpec("Move Up:", "move_forward_code"), - RebindRowSpec("Move Down:", "move_backward_code"), - RebindRowSpec("Move Left:", "turn_left_code"), - RebindRowSpec("Move Right:", "turn_right_code"), + RebindRowSpec("Move Up:", "move_codes", 0), + RebindRowSpec("Move Down:", "move_codes", 1), + RebindRowSpec("Move Left:", "move_codes", 2), + RebindRowSpec("Move Right:", "move_codes", 3), ) assert misc_rows == ( RebindRowSpec("Level Up:", "pick_perk_code", controls_field=True), @@ -115,8 +115,8 @@ def test_controls_rebind_plan_dualpad_mouse_cursor_player2() -> None: player_index=1, ) assert aim_rows == ( - RebindRowSpec("Aim Up/Down Axis:", "aim_vertical_axis_code", axis=True), - RebindRowSpec("Aim Left/Right Axis:", "aim_horizontal_axis_code", axis=True), + RebindRowSpec("Aim Up/Down Axis:", "aim_axis_codes", 0, axis=True), + RebindRowSpec("Aim Left/Right Axis:", "aim_axis_codes", 1, axis=True), RebindRowSpec("Fire:", "fire_code"), ) assert move_rows == (RebindRowSpec("Move to cursor:", "reload_code", controls_field=True),) From 6ef97f673373bfe732ce09efee0a930726a01307 Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:45:49 +0400 Subject: [PATCH 04/15] refactor: simplify config bind block bridge --- src/grim/config.py | 262 ++++++++++++++++++--------------------------- 1 file changed, 107 insertions(+), 155 deletions(-) diff --git a/src/grim/config.py b/src/grim/config.py index cea9c15bc..2c0b033f9 100644 --- a/src/grim/config.py +++ b/src/grim/config.py @@ -23,7 +23,6 @@ UNKNOWN_248_SIZE = 0x1F8 PLAYER_BIND_BLOCK_DWORDS = 0x10 PLAYER_BIND_BLOCK_SIZE = PLAYER_BIND_BLOCK_DWORDS * 4 -PLAYER_BIND_INPUT_DWORDS = 0x0D EXT_DIRECTION_ARROW_FLAG_COUNT = 2 EXTENDED_RESERVED_GAP_SIZE = UNKNOWN_248_SIZE - 2 * PLAYER_BIND_BLOCK_SIZE - EXT_DIRECTION_ARROW_FLAG_COUNT EXT_DIRECTION_ARROW_UNSET = 0 @@ -49,6 +48,33 @@ "padding" / Array(PADDING_KEYBIND_SLOT_COUNT, Int32sl), ) + +def _player_bind_block( + *, + move_codes: tuple[int, int, int, int], + fire_code: int, + reserved_keys: tuple[int, int], + keyboard_aim_codes: tuple[int, int], + aim_axis_codes: tuple[int, int], + move_axis_codes: tuple[int, int], + padding: tuple[int, int, int], +) -> dict[str, Any]: + return { + "move_forward": int(move_codes[0]), + "move_backward": int(move_codes[1]), + "turn_left": int(move_codes[2]), + "turn_right": int(move_codes[3]), + "fire": int(fire_code), + "reserved_keys": reserved_keys, + "aim_left": int(keyboard_aim_codes[0]), + "aim_right": int(keyboard_aim_codes[1]), + "axis_aim_y": int(aim_axis_codes[0]), + "axis_aim_x": int(aim_axis_codes[1]), + "axis_move_y": int(move_axis_codes[0]), + "axis_move_x": int(move_axis_codes[1]), + "padding": padding, + } + CRIMSON_CFG_STRUCT = Struct( "sound_disable" / Byte, "music_disable" / Byte, @@ -117,78 +143,42 @@ "keybind_reload" / Int32sl, ) -_DEFAULT_PLAYER_BIND_BLOCKS: tuple[tuple[int, ...], ...] = ( - ( - 0x11, - 0x1F, - 0x1E, - 0x20, - 0x100, - 0x17E, - 0x17E, - 0x10, - 0x12, - 0x13F, - 0x140, - 0x141, - 0x153, - 0x17E, - 0x17E, - 0x17E, +_DEFAULT_PLAYER_BIND_BLOCKS: tuple[dict[str, Any], ...] = ( + _player_bind_block( + move_codes=(0x11, 0x1F, 0x1E, 0x20), + fire_code=0x100, + reserved_keys=(0x17E, 0x17E), + keyboard_aim_codes=(0x10, 0x12), + aim_axis_codes=(0x13F, 0x140), + move_axis_codes=(0x141, 0x153), + padding=(0x17E, 0x17E, 0x17E), ), - ( - 0xC8, - 0xD0, - 0xCB, - 0xCD, - 0x9D, - 0x17E, - 0x17E, - 0xD3, - 0xD1, - 0x13F, - 0x140, - 0x141, - 0x153, - 0x17E, - 0x17E, - 0x17E, + _player_bind_block( + move_codes=(0xC8, 0xD0, 0xCB, 0xCD), + fire_code=0x9D, + reserved_keys=(0x17E, 0x17E), + keyboard_aim_codes=(0xD3, 0xD1), + aim_axis_codes=(0x13F, 0x140), + move_axis_codes=(0x141, 0x153), + padding=(0x17E, 0x17E, 0x17E), ), - ( - 0x17, - 0x25, - 0x24, - 0x26, - 0x36, - 0x17E, - 0x17E, - 0x16, - 0x18, - 0x17E, - 0x17E, - 0x17E, - 0x17E, - 0x17E, - 0x17E, - 0x17E, + _player_bind_block( + move_codes=(0x17, 0x25, 0x24, 0x26), + fire_code=0x36, + reserved_keys=(0x17E, 0x17E), + keyboard_aim_codes=(0x16, 0x18), + aim_axis_codes=(0x17E, 0x17E), + move_axis_codes=(0x17E, 0x17E), + padding=(0x17E, 0x17E, 0x17E), ), - ( - 0x131, - 0x132, - 0x133, - 0x134, - 0x11F, - 0x17E, - 0x17E, - 0x17E, - 0x17E, - 0x140, - 0x13F, - 0x153, - 0x154, - 0x17E, - 0x17E, - 0x17E, + _player_bind_block( + move_codes=(0x131, 0x132, 0x133, 0x134), + fire_code=0x11F, + reserved_keys=(0x17E, 0x17E), + keyboard_aim_codes=(0x17E, 0x17E), + aim_axis_codes=(0x140, 0x13F), + move_axis_codes=(0x153, 0x154), + padding=(0x17E, 0x17E, 0x17E), ), ) @@ -331,114 +321,84 @@ def _require_range(value: int, *, minimum: int, maximum: int, field: str) -> int return value -def _block_uninitialized(values: Sequence[int]) -> bool: - for idx in range(min(len(values), PLAYER_BIND_INPUT_DWORDS)): - if int(values[idx]) != 0: - return False +def _raw_player_bind_block_is_uninitialized(raw_block: dict[str, Any]) -> bool: + if int(raw_block["move_forward"]) != 0: + return False + if int(raw_block["move_backward"]) != 0: + return False + if int(raw_block["turn_left"]) != 0: + return False + if int(raw_block["turn_right"]) != 0: + return False + if int(raw_block["fire"]) != 0: + return False + if int(raw_block["reserved_keys"][0]) != 0 or int(raw_block["reserved_keys"][1]) != 0: + return False + if int(raw_block["aim_left"]) != 0 or int(raw_block["aim_right"]) != 0: + return False + if int(raw_block["axis_aim_y"]) != 0 or int(raw_block["axis_aim_x"]) != 0: + return False + if int(raw_block["axis_move_y"]) != 0 or int(raw_block["axis_move_x"]) != 0: + return False return True -def _player_bind_values(values: Sequence[int]) -> tuple[int, ...]: - if len(values) != PLAYER_BIND_BLOCK_DWORDS: - raise ValueError(f"keybind block must have {PLAYER_BIND_BLOCK_DWORDS} entries, got {len(values)}") - return ( - int(values[0]), - int(values[1]), - int(values[2]), - int(values[3]), - int(values[4]), - int(values[5]), - int(values[6]), - int(values[7]), - int(values[8]), - int(values[9]), - int(values[10]), - int(values[11]), - int(values[12]), - int(values[13]), - int(values[14]), - int(values[15]), - ) +def _default_player_bind_block(player_index: int) -> dict[str, Any]: + return _DEFAULT_PLAYER_BIND_BLOCKS[_player_index(player_index)] -def _raw_player_bind_values(raw_block: dict[str, Any]) -> tuple[int, ...]: - return _player_bind_values( - ( - int(raw_block["move_forward"]), - int(raw_block["move_backward"]), - int(raw_block["turn_left"]), - int(raw_block["turn_right"]), - int(raw_block["fire"]), - int(raw_block["reserved_keys"][0]), - int(raw_block["reserved_keys"][1]), - int(raw_block["aim_left"]), - int(raw_block["aim_right"]), - int(raw_block["axis_aim_y"]), - int(raw_block["axis_aim_x"]), - int(raw_block["axis_move_y"]), - int(raw_block["axis_move_x"]), - int(raw_block["padding"][0]), - int(raw_block["padding"][1]), - int(raw_block["padding"][2]), - ), - ) +def _raw_player_bind_block(raw: dict[str, Any], *, player_index: int) -> dict[str, Any]: + idx = _player_index(player_index) + if idx < 2: + return raw["keybinds_p1_p2"][idx] + return raw["extended_keybinds_p3_p4"][idx - 2] -def _player_controls_from_bind_values( - values: Sequence[int], +def _player_controls_from_raw_bind_block( + raw_block: dict[str, Any], *, + player_index: int, movement: MovementControlType, aim_scheme: AimScheme, show_direction_arrow: bool, ) -> CrimsonPlayerControls: - bind_values = _player_bind_values(values) + bind_block = _default_player_bind_block(player_index) if _raw_player_bind_block_is_uninitialized(raw_block) else raw_block return CrimsonPlayerControls( movement=movement, aim_scheme=aim_scheme, show_direction_arrow=show_direction_arrow, move_codes=( - int(bind_values[0]), - int(bind_values[1]), - int(bind_values[2]), - int(bind_values[3]), + int(bind_block["move_forward"]), + int(bind_block["move_backward"]), + int(bind_block["turn_left"]), + int(bind_block["turn_right"]), ), - fire_code=int(bind_values[4]), - keyboard_aim_codes=(int(bind_values[7]), int(bind_values[8])), - aim_axis_codes=(int(bind_values[9]), int(bind_values[10])), - move_axis_codes=(int(bind_values[11]), int(bind_values[12])), + fire_code=int(bind_block["fire"]), + keyboard_aim_codes=(int(bind_block["aim_left"]), int(bind_block["aim_right"])), + aim_axis_codes=(int(bind_block["axis_aim_y"]), int(bind_block["axis_aim_x"])), + move_axis_codes=(int(bind_block["axis_move_y"]), int(bind_block["axis_move_x"])), ) def _encode_player_bind_block(player: CrimsonPlayerControls, *, player_index: int) -> dict[str, object]: - defaults = _default_player_bind_values(player_index) + defaults = _default_player_bind_block(player_index) return { "move_forward": int(player.move_codes[0]), "move_backward": int(player.move_codes[1]), "turn_left": int(player.move_codes[2]), "turn_right": int(player.move_codes[3]), "fire": int(player.fire_code), - "reserved_keys": [int(defaults[5]), int(defaults[6])], + "reserved_keys": [int(defaults["reserved_keys"][0]), int(defaults["reserved_keys"][1])], "aim_left": int(player.keyboard_aim_codes[0]), "aim_right": int(player.keyboard_aim_codes[1]), "axis_aim_y": int(player.aim_axis_codes[0]), "axis_aim_x": int(player.aim_axis_codes[1]), "axis_move_y": int(player.move_axis_codes[0]), "axis_move_x": int(player.move_axis_codes[1]), - "padding": [int(defaults[13]), int(defaults[14]), int(defaults[15])], + "padding": [int(defaults["padding"][0]), int(defaults["padding"][1]), int(defaults["padding"][2])], } -def _decode_player_bind_values(raw: dict[str, Any], *, player_index: int) -> tuple[int, ...]: - idx = _player_index(player_index) - if idx < 2: - values = _raw_player_bind_values(raw["keybinds_p1_p2"][idx]) - else: - values = _raw_player_bind_values(raw["extended_keybinds_p3_p4"][idx - 2]) - if _block_uninitialized(values): - return _default_player_bind_values(idx) - return values - - def _encode_primary_keybinds(players: Sequence[CrimsonPlayerControls]) -> list[dict[str, object]]: return [_encode_player_bind_block(players[idx], player_index=idx) for idx in range(2)] @@ -526,22 +486,13 @@ def _saved_name_order_values() -> tuple[int, ...]: return tuple(range(SAVED_NAME_SLOT_COUNT)) -def _default_player_bind_values(player_index: int) -> tuple[int, ...]: - idx = _player_index(player_index) - return _player_bind_values(_DEFAULT_PLAYER_BIND_BLOCKS[idx]) - - def _default_player_controls(player_index: int) -> CrimsonPlayerControls: - values = _default_player_bind_values(player_index) - return CrimsonPlayerControls( + return _player_controls_from_raw_bind_block( + _default_player_bind_block(player_index), + player_index=player_index, movement=MovementControlType.STATIC, aim_scheme=AimScheme.MOUSE, show_direction_arrow=True, - move_codes=(int(values[0]), int(values[1]), int(values[2]), int(values[3])), - fire_code=int(values[4]), - keyboard_aim_codes=(int(values[7]), int(values[8])), - aim_axis_codes=(int(values[9]), int(values[10])), - move_axis_codes=(int(values[11]), int(values[12])), ) @@ -615,8 +566,9 @@ def decode_crimson_cfg(path: Path, blob: bytes) -> CrimsonConfig: detail_preset = _require_range(detail_preset, minimum=1, maximum=5, field="detail_preset") players = tuple( - _player_controls_from_bind_values( - _decode_player_bind_values(raw, player_index=idx), + _player_controls_from_raw_bind_block( + _raw_player_bind_block(raw, player_index=idx), + player_index=idx, movement=_decode_movement(raw["player_mode_flags"][idx]), aim_scheme=_decode_aim_scheme(raw["aim_schemes"][idx]), show_direction_arrow=_decode_direction_arrow(raw, player_index=idx), From dc8e5a26be1e362ceb55c3f72de99812f647416d Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:50:58 +0400 Subject: [PATCH 05/15] refactor: remove redundant keybind int casts --- src/crimson/local_input.py | 10 +++---- src/crimson/modes/tutorial_mode.py | 4 +-- src/crimson/ui/text_input.py | 10 +++---- src/grim/config.py | 48 +++++++++++++++--------------- 4 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/crimson/local_input.py b/src/crimson/local_input.py index 3c7514b94..333cbe2c6 100644 --- a/src/crimson/local_input.py +++ b/src/crimson/local_input.py @@ -269,11 +269,11 @@ def build_player_input( aim_scheme, move_mode_type = self._safe_controls_modes(config, player_index=idx) reload_key = config.controls.reload_code - move_codes = tuple(int(code) for code in binds.move_codes) - fire_key = int(binds.fire_code) - keyboard_aim_codes = tuple(int(code) for code in binds.keyboard_aim_codes) - aim_axis_codes = tuple(int(code) for code in binds.aim_axis_codes) - move_axis_codes = tuple(int(code) for code in binds.move_axis_codes) + move_codes = binds.move_codes + fire_key = binds.fire_code + keyboard_aim_codes = binds.keyboard_aim_codes + aim_axis_codes = binds.aim_axis_codes + move_axis_codes = binds.move_axis_codes move_vec = Vec2() move_forward_pressed: bool | None = None diff --git a/src/crimson/modes/tutorial_mode.py b/src/crimson/modes/tutorial_mode.py index d7e6f9b6e..ba3c5dc67 100644 --- a/src/crimson/modes/tutorial_mode.py +++ b/src/crimson/modes/tutorial_mode.py @@ -186,8 +186,8 @@ def _handle_input(self) -> None: def _build_input(self) -> PlayerInput: controls = self.config.controls.player(0) - move_codes = tuple(int(code) for code in controls.move_codes) - fire_key = int(controls.fire_code) + move_codes = controls.move_codes + fire_key = controls.fire_code move = Vec2( float(input_code_is_down(move_codes[3])) - float(input_code_is_down(move_codes[2])), diff --git a/src/crimson/ui/text_input.py b/src/crimson/ui/text_input.py index a167c4b88..7a4004ba6 100644 --- a/src/crimson/ui/text_input.py +++ b/src/crimson/ui/text_input.py @@ -79,18 +79,18 @@ def update_name_entry_text( def gameplay_controls_held(config: CrimsonConfig) -> bool: player_count = max(1, min(4, config.gameplay.player_count)) for player_index in range(player_count): - move_codes = tuple(int(code) for code in config.controls.player(player_index).move_codes) + player_controls = config.controls.player(player_index) + move_codes = player_controls.move_codes for code in ( move_codes[0], move_codes[1], move_codes[2], move_codes[3], - int(config.controls.player(player_index).fire_code), + player_controls.fire_code, )[:_CONTROL_BIND_SLOTS]: - key_code = int(code) - if key_code == INPUT_CODE_UNBOUND: + if code == INPUT_CODE_UNBOUND: continue - if input_code_is_down(key_code, player_index=player_index): + if input_code_is_down(code, player_index=player_index): return True for code in _SINGLE_PLAYER_ALT_MOVE_CODES: diff --git a/src/grim/config.py b/src/grim/config.py index 2c0b033f9..243ed789d 100644 --- a/src/grim/config.py +++ b/src/grim/config.py @@ -60,18 +60,18 @@ def _player_bind_block( padding: tuple[int, int, int], ) -> dict[str, Any]: return { - "move_forward": int(move_codes[0]), - "move_backward": int(move_codes[1]), - "turn_left": int(move_codes[2]), - "turn_right": int(move_codes[3]), - "fire": int(fire_code), + "move_forward": move_codes[0], + "move_backward": move_codes[1], + "turn_left": move_codes[2], + "turn_right": move_codes[3], + "fire": fire_code, "reserved_keys": reserved_keys, - "aim_left": int(keyboard_aim_codes[0]), - "aim_right": int(keyboard_aim_codes[1]), - "axis_aim_y": int(aim_axis_codes[0]), - "axis_aim_x": int(aim_axis_codes[1]), - "axis_move_y": int(move_axis_codes[0]), - "axis_move_x": int(move_axis_codes[1]), + "aim_left": keyboard_aim_codes[0], + "aim_right": keyboard_aim_codes[1], + "axis_aim_y": aim_axis_codes[0], + "axis_aim_x": aim_axis_codes[1], + "axis_move_y": move_axis_codes[0], + "axis_move_x": move_axis_codes[1], "padding": padding, } @@ -383,18 +383,18 @@ def _player_controls_from_raw_bind_block( def _encode_player_bind_block(player: CrimsonPlayerControls, *, player_index: int) -> dict[str, object]: defaults = _default_player_bind_block(player_index) return { - "move_forward": int(player.move_codes[0]), - "move_backward": int(player.move_codes[1]), - "turn_left": int(player.move_codes[2]), - "turn_right": int(player.move_codes[3]), - "fire": int(player.fire_code), + "move_forward": player.move_codes[0], + "move_backward": player.move_codes[1], + "turn_left": player.move_codes[2], + "turn_right": player.move_codes[3], + "fire": player.fire_code, "reserved_keys": [int(defaults["reserved_keys"][0]), int(defaults["reserved_keys"][1])], - "aim_left": int(player.keyboard_aim_codes[0]), - "aim_right": int(player.keyboard_aim_codes[1]), - "axis_aim_y": int(player.aim_axis_codes[0]), - "axis_aim_x": int(player.aim_axis_codes[1]), - "axis_move_y": int(player.move_axis_codes[0]), - "axis_move_x": int(player.move_axis_codes[1]), + "aim_left": player.keyboard_aim_codes[0], + "aim_right": player.keyboard_aim_codes[1], + "axis_aim_y": player.aim_axis_codes[0], + "axis_aim_x": player.aim_axis_codes[1], + "axis_move_y": player.move_axis_codes[0], + "axis_move_x": player.move_axis_codes[1], "padding": [int(defaults["padding"][0]), int(defaults["padding"][1]), int(defaults["padding"][2])], } @@ -706,8 +706,8 @@ def encode_crimson_cfg(config: CrimsonConfig) -> bytes: field="detail_preset", ) data["mouse_sensitivity"] = float(config.display.mouse_sensitivity) - data["keybind_pick_perk"] = int(config.controls.pick_perk_code) - data["keybind_reload"] = int(config.controls.reload_code) + data["keybind_pick_perk"] = config.controls.pick_perk_code + data["keybind_reload"] = config.controls.reload_code return CRIMSON_CFG_STRUCT.build(data) From fb27c8fb840b1224c26a3da9814cabf29599625c Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Fri, 13 Mar 2026 19:25:40 +0400 Subject: [PATCH 06/15] refactor: type default config bind blocks --- src/grim/config.py | 88 ++++++++++++++++++++++------------------------ 1 file changed, 43 insertions(+), 45 deletions(-) diff --git a/src/grim/config.py b/src/grim/config.py index 243ed789d..a890b9d1c 100644 --- a/src/grim/config.py +++ b/src/grim/config.py @@ -49,31 +49,14 @@ ) -def _player_bind_block( - *, - move_codes: tuple[int, int, int, int], - fire_code: int, - reserved_keys: tuple[int, int], - keyboard_aim_codes: tuple[int, int], - aim_axis_codes: tuple[int, int], - move_axis_codes: tuple[int, int], - padding: tuple[int, int, int], -) -> dict[str, Any]: - return { - "move_forward": move_codes[0], - "move_backward": move_codes[1], - "turn_left": move_codes[2], - "turn_right": move_codes[3], - "fire": fire_code, - "reserved_keys": reserved_keys, - "aim_left": keyboard_aim_codes[0], - "aim_right": keyboard_aim_codes[1], - "axis_aim_y": aim_axis_codes[0], - "axis_aim_x": aim_axis_codes[1], - "axis_move_y": move_axis_codes[0], - "axis_move_x": move_axis_codes[1], - "padding": padding, - } +class _DefaultPlayerBindBlock(msgspec.Struct, frozen=True): + move_codes: tuple[int, int, int, int] + fire_code: int + reserved_keys: tuple[int, int] + keyboard_aim_codes: tuple[int, int] + aim_axis_codes: tuple[int, int] + move_axis_codes: tuple[int, int] + padding: tuple[int, int, int] CRIMSON_CFG_STRUCT = Struct( "sound_disable" / Byte, @@ -143,8 +126,8 @@ def _player_bind_block( "keybind_reload" / Int32sl, ) -_DEFAULT_PLAYER_BIND_BLOCKS: tuple[dict[str, Any], ...] = ( - _player_bind_block( +_DEFAULT_PLAYER_BIND_BLOCKS: tuple[_DefaultPlayerBindBlock, ...] = ( + _DefaultPlayerBindBlock( move_codes=(0x11, 0x1F, 0x1E, 0x20), fire_code=0x100, reserved_keys=(0x17E, 0x17E), @@ -153,7 +136,7 @@ def _player_bind_block( move_axis_codes=(0x141, 0x153), padding=(0x17E, 0x17E, 0x17E), ), - _player_bind_block( + _DefaultPlayerBindBlock( move_codes=(0xC8, 0xD0, 0xCB, 0xCD), fire_code=0x9D, reserved_keys=(0x17E, 0x17E), @@ -162,7 +145,7 @@ def _player_bind_block( move_axis_codes=(0x141, 0x153), padding=(0x17E, 0x17E, 0x17E), ), - _player_bind_block( + _DefaultPlayerBindBlock( move_codes=(0x17, 0x25, 0x24, 0x26), fire_code=0x36, reserved_keys=(0x17E, 0x17E), @@ -171,7 +154,7 @@ def _player_bind_block( move_axis_codes=(0x17E, 0x17E), padding=(0x17E, 0x17E, 0x17E), ), - _player_bind_block( + _DefaultPlayerBindBlock( move_codes=(0x131, 0x132, 0x133, 0x134), fire_code=0x11F, reserved_keys=(0x17E, 0x17E), @@ -343,7 +326,7 @@ def _raw_player_bind_block_is_uninitialized(raw_block: dict[str, Any]) -> bool: return True -def _default_player_bind_block(player_index: int) -> dict[str, Any]: +def _default_player_bind_block(player_index: int) -> _DefaultPlayerBindBlock: return _DEFAULT_PLAYER_BIND_BLOCKS[_player_index(player_index)] @@ -362,21 +345,32 @@ def _player_controls_from_raw_bind_block( aim_scheme: AimScheme, show_direction_arrow: bool, ) -> CrimsonPlayerControls: - bind_block = _default_player_bind_block(player_index) if _raw_player_bind_block_is_uninitialized(raw_block) else raw_block + if _raw_player_bind_block_is_uninitialized(raw_block): + defaults = _default_player_bind_block(player_index) + return CrimsonPlayerControls( + movement=movement, + aim_scheme=aim_scheme, + show_direction_arrow=show_direction_arrow, + move_codes=defaults.move_codes, + fire_code=defaults.fire_code, + keyboard_aim_codes=defaults.keyboard_aim_codes, + aim_axis_codes=defaults.aim_axis_codes, + move_axis_codes=defaults.move_axis_codes, + ) return CrimsonPlayerControls( movement=movement, aim_scheme=aim_scheme, show_direction_arrow=show_direction_arrow, move_codes=( - int(bind_block["move_forward"]), - int(bind_block["move_backward"]), - int(bind_block["turn_left"]), - int(bind_block["turn_right"]), + int(raw_block["move_forward"]), + int(raw_block["move_backward"]), + int(raw_block["turn_left"]), + int(raw_block["turn_right"]), ), - fire_code=int(bind_block["fire"]), - keyboard_aim_codes=(int(bind_block["aim_left"]), int(bind_block["aim_right"])), - aim_axis_codes=(int(bind_block["axis_aim_y"]), int(bind_block["axis_aim_x"])), - move_axis_codes=(int(bind_block["axis_move_y"]), int(bind_block["axis_move_x"])), + fire_code=int(raw_block["fire"]), + keyboard_aim_codes=(int(raw_block["aim_left"]), int(raw_block["aim_right"])), + aim_axis_codes=(int(raw_block["axis_aim_y"]), int(raw_block["axis_aim_x"])), + move_axis_codes=(int(raw_block["axis_move_y"]), int(raw_block["axis_move_x"])), ) @@ -388,14 +382,14 @@ def _encode_player_bind_block(player: CrimsonPlayerControls, *, player_index: in "turn_left": player.move_codes[2], "turn_right": player.move_codes[3], "fire": player.fire_code, - "reserved_keys": [int(defaults["reserved_keys"][0]), int(defaults["reserved_keys"][1])], + "reserved_keys": [defaults.reserved_keys[0], defaults.reserved_keys[1]], "aim_left": player.keyboard_aim_codes[0], "aim_right": player.keyboard_aim_codes[1], "axis_aim_y": player.aim_axis_codes[0], "axis_aim_x": player.aim_axis_codes[1], "axis_move_y": player.move_axis_codes[0], "axis_move_x": player.move_axis_codes[1], - "padding": [int(defaults["padding"][0]), int(defaults["padding"][1]), int(defaults["padding"][2])], + "padding": [defaults.padding[0], defaults.padding[1], defaults.padding[2]], } @@ -487,12 +481,16 @@ def _saved_name_order_values() -> tuple[int, ...]: def _default_player_controls(player_index: int) -> CrimsonPlayerControls: - return _player_controls_from_raw_bind_block( - _default_player_bind_block(player_index), - player_index=player_index, + defaults = _default_player_bind_block(player_index) + return CrimsonPlayerControls( movement=MovementControlType.STATIC, aim_scheme=AimScheme.MOUSE, show_direction_arrow=True, + move_codes=defaults.move_codes, + fire_code=defaults.fire_code, + keyboard_aim_codes=defaults.keyboard_aim_codes, + aim_axis_codes=defaults.aim_axis_codes, + move_axis_codes=defaults.move_axis_codes, ) From e9ea4088fe5a3a43fc5d7f7e463342342b42e9f9 Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Fri, 13 Mar 2026 19:35:02 +0400 Subject: [PATCH 07/15] refactor: type parsed config bind blocks --- src/grim/config.py | 95 ++++++++++++++++++++++++++++++---------------- 1 file changed, 63 insertions(+), 32 deletions(-) diff --git a/src/grim/config.py b/src/grim/config.py index a890b9d1c..cf45cf36a 100644 --- a/src/grim/config.py +++ b/src/grim/config.py @@ -3,7 +3,7 @@ from collections.abc import Sequence from enum import IntEnum from pathlib import Path -from typing import Any +from typing import TypedDict, cast import msgspec from construct import Array, Byte, Bytes, Float32l, Int32sl, Struct @@ -49,7 +49,7 @@ ) -class _DefaultPlayerBindBlock(msgspec.Struct, frozen=True): +class _RawPlayerBindBlock(msgspec.Struct, frozen=True): move_codes: tuple[int, int, int, int] fire_code: int reserved_keys: tuple[int, int] @@ -58,6 +58,42 @@ class _DefaultPlayerBindBlock(msgspec.Struct, frozen=True): move_axis_codes: tuple[int, int] padding: tuple[int, int, int] + +class _ParsedPlayerBindBlockDict(TypedDict): + move_forward: int + move_backward: int + turn_left: int + turn_right: int + fire: int + reserved_keys: list[int] + aim_left: int + aim_right: int + axis_aim_y: int + axis_aim_x: int + axis_move_y: int + axis_move_x: int + padding: list[int] + + +def _parsed_player_bind_block(raw_block: object) -> _RawPlayerBindBlock: + block = cast(_ParsedPlayerBindBlockDict, raw_block) + reserved_keys = block["reserved_keys"] + padding = block["padding"] + return _RawPlayerBindBlock( + move_codes=( + block["move_forward"], + block["move_backward"], + block["turn_left"], + block["turn_right"], + ), + fire_code=block["fire"], + reserved_keys=(reserved_keys[0], reserved_keys[1]), + keyboard_aim_codes=(block["aim_left"], block["aim_right"]), + aim_axis_codes=(block["axis_aim_y"], block["axis_aim_x"]), + move_axis_codes=(block["axis_move_y"], block["axis_move_x"]), + padding=(padding[0], padding[1], padding[2]), + ) + CRIMSON_CFG_STRUCT = Struct( "sound_disable" / Byte, "music_disable" / Byte, @@ -126,8 +162,8 @@ class _DefaultPlayerBindBlock(msgspec.Struct, frozen=True): "keybind_reload" / Int32sl, ) -_DEFAULT_PLAYER_BIND_BLOCKS: tuple[_DefaultPlayerBindBlock, ...] = ( - _DefaultPlayerBindBlock( +_DEFAULT_PLAYER_BIND_BLOCKS: tuple[_RawPlayerBindBlock, ...] = ( + _RawPlayerBindBlock( move_codes=(0x11, 0x1F, 0x1E, 0x20), fire_code=0x100, reserved_keys=(0x17E, 0x17E), @@ -136,7 +172,7 @@ class _DefaultPlayerBindBlock(msgspec.Struct, frozen=True): move_axis_codes=(0x141, 0x153), padding=(0x17E, 0x17E, 0x17E), ), - _DefaultPlayerBindBlock( + _RawPlayerBindBlock( move_codes=(0xC8, 0xD0, 0xCB, 0xCD), fire_code=0x9D, reserved_keys=(0x17E, 0x17E), @@ -145,7 +181,7 @@ class _DefaultPlayerBindBlock(msgspec.Struct, frozen=True): move_axis_codes=(0x141, 0x153), padding=(0x17E, 0x17E, 0x17E), ), - _DefaultPlayerBindBlock( + _RawPlayerBindBlock( move_codes=(0x17, 0x25, 0x24, 0x26), fire_code=0x36, reserved_keys=(0x17E, 0x17E), @@ -154,7 +190,7 @@ class _DefaultPlayerBindBlock(msgspec.Struct, frozen=True): move_axis_codes=(0x17E, 0x17E), padding=(0x17E, 0x17E, 0x17E), ), - _DefaultPlayerBindBlock( + _RawPlayerBindBlock( move_codes=(0x131, 0x132, 0x133, 0x134), fire_code=0x11F, reserved_keys=(0x17E, 0x17E), @@ -304,41 +340,41 @@ def _require_range(value: int, *, minimum: int, maximum: int, field: str) -> int return value -def _raw_player_bind_block_is_uninitialized(raw_block: dict[str, Any]) -> bool: - if int(raw_block["move_forward"]) != 0: +def _raw_player_bind_block_is_uninitialized(raw_block: _RawPlayerBindBlock) -> bool: + if raw_block.move_codes[0] != 0: return False - if int(raw_block["move_backward"]) != 0: + if raw_block.move_codes[1] != 0: return False - if int(raw_block["turn_left"]) != 0: + if raw_block.move_codes[2] != 0: return False - if int(raw_block["turn_right"]) != 0: + if raw_block.move_codes[3] != 0: return False - if int(raw_block["fire"]) != 0: + if raw_block.fire_code != 0: return False - if int(raw_block["reserved_keys"][0]) != 0 or int(raw_block["reserved_keys"][1]) != 0: + if raw_block.reserved_keys[0] != 0 or raw_block.reserved_keys[1] != 0: return False - if int(raw_block["aim_left"]) != 0 or int(raw_block["aim_right"]) != 0: + if raw_block.keyboard_aim_codes[0] != 0 or raw_block.keyboard_aim_codes[1] != 0: return False - if int(raw_block["axis_aim_y"]) != 0 or int(raw_block["axis_aim_x"]) != 0: + if raw_block.aim_axis_codes[0] != 0 or raw_block.aim_axis_codes[1] != 0: return False - if int(raw_block["axis_move_y"]) != 0 or int(raw_block["axis_move_x"]) != 0: + if raw_block.move_axis_codes[0] != 0 or raw_block.move_axis_codes[1] != 0: return False return True -def _default_player_bind_block(player_index: int) -> _DefaultPlayerBindBlock: +def _default_player_bind_block(player_index: int) -> _RawPlayerBindBlock: return _DEFAULT_PLAYER_BIND_BLOCKS[_player_index(player_index)] -def _raw_player_bind_block(raw: dict[str, Any], *, player_index: int) -> dict[str, Any]: +def _raw_player_bind_block(raw: dict[str, object], *, player_index: int) -> _RawPlayerBindBlock: idx = _player_index(player_index) if idx < 2: - return raw["keybinds_p1_p2"][idx] - return raw["extended_keybinds_p3_p4"][idx - 2] + return _parsed_player_bind_block(cast(list[object], raw["keybinds_p1_p2"])[idx]) + return _parsed_player_bind_block(cast(list[object], raw["extended_keybinds_p3_p4"])[idx - 2]) def _player_controls_from_raw_bind_block( - raw_block: dict[str, Any], + raw_block: _RawPlayerBindBlock, *, player_index: int, movement: MovementControlType, @@ -361,16 +397,11 @@ def _player_controls_from_raw_bind_block( movement=movement, aim_scheme=aim_scheme, show_direction_arrow=show_direction_arrow, - move_codes=( - int(raw_block["move_forward"]), - int(raw_block["move_backward"]), - int(raw_block["turn_left"]), - int(raw_block["turn_right"]), - ), - fire_code=int(raw_block["fire"]), - keyboard_aim_codes=(int(raw_block["aim_left"]), int(raw_block["aim_right"])), - aim_axis_codes=(int(raw_block["axis_aim_y"]), int(raw_block["axis_aim_x"])), - move_axis_codes=(int(raw_block["axis_move_y"]), int(raw_block["axis_move_x"])), + move_codes=raw_block.move_codes, + fire_code=raw_block.fire_code, + keyboard_aim_codes=raw_block.keyboard_aim_codes, + aim_axis_codes=raw_block.aim_axis_codes, + move_axis_codes=raw_block.move_axis_codes, ) From 8940dd76f7c42904d9b03cec79c151af09586135 Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Fri, 13 Mar 2026 19:53:42 +0400 Subject: [PATCH 08/15] refactor: unpack runtime control groups once --- src/crimson/local_input.py | 44 +++++++++++++++--------------- src/crimson/modes/tutorial_mode.py | 6 ++-- src/crimson/ui/text_input.py | 10 +++---- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/crimson/local_input.py b/src/crimson/local_input.py index 333cbe2c6..decaff40a 100644 --- a/src/crimson/local_input.py +++ b/src/crimson/local_input.py @@ -269,11 +269,11 @@ def build_player_input( aim_scheme, move_mode_type = self._safe_controls_modes(config, player_index=idx) reload_key = config.controls.reload_code - move_codes = binds.move_codes + move_forward_key, move_backward_key, turn_left_key, turn_right_key = binds.move_codes fire_key = binds.fire_code - keyboard_aim_codes = binds.keyboard_aim_codes - aim_axis_codes = binds.aim_axis_codes - move_axis_codes = binds.move_axis_codes + aim_left_key, aim_right_key = binds.keyboard_aim_codes + aim_axis_y, aim_axis_x = binds.aim_axis_codes + move_axis_y, move_axis_x = binds.move_axis_codes move_vec = Vec2() move_forward_pressed: bool | None = None @@ -314,25 +314,25 @@ def build_player_input( move_vec = move_dir elif move_mode_type is MovementControlType.RELATIVE: move_forward_pressed = _key_down_with_single_player_alt( - move_codes[0], + move_forward_key, alt_key=_ALT_MOVE_KEY_UP, config=config, player_index=idx, ) move_backward_pressed = _key_down_with_single_player_alt( - move_codes[1], + move_backward_key, alt_key=_ALT_MOVE_KEY_DOWN, config=config, player_index=idx, ) turn_left_pressed = _key_down_with_single_player_alt( - move_codes[2], + turn_left_key, alt_key=_ALT_MOVE_KEY_LEFT, config=config, player_index=idx, ) turn_right_pressed = _key_down_with_single_player_alt( - move_codes[3], + turn_right_key, alt_key=_ALT_MOVE_KEY_RIGHT, config=config, player_index=idx, @@ -342,8 +342,8 @@ def build_player_input( float(move_backward_pressed) - float(move_forward_pressed), ) elif move_mode_type is MovementControlType.DUAL_ACTION_PAD: - axis_y = -input_axis_value(move_axis_codes[0], player_index=idx) - axis_x = -input_axis_value(move_axis_codes[1], player_index=idx) + axis_y = -input_axis_value(move_axis_y, player_index=idx) + axis_x = -input_axis_value(move_axis_x, player_index=idx) move_vec = Vec2(_clamp_unit(axis_x), _clamp_unit(axis_y)) elif move_mode_type is MovementControlType.MOUSE_POINT_CLICK: move_to_cursor_pressed = input_code_is_down(reload_key, player_index=idx) @@ -356,25 +356,25 @@ def build_player_input( move_vec = _dir elif move_mode_type is MovementControlType.STATIC: move_up_pressed = _key_down_with_single_player_alt( - move_codes[0], + move_forward_key, alt_key=_ALT_MOVE_KEY_UP, config=config, player_index=idx, ) move_down_pressed = _key_down_with_single_player_alt( - move_codes[1], + move_backward_key, alt_key=_ALT_MOVE_KEY_DOWN, config=config, player_index=idx, ) move_left_pressed = _key_down_with_single_player_alt( - move_codes[2], + turn_left_key, alt_key=_ALT_MOVE_KEY_LEFT, config=config, player_index=idx, ) move_right_pressed = _key_down_with_single_player_alt( - move_codes[3], + turn_right_key, alt_key=_ALT_MOVE_KEY_RIGHT, config=config, player_index=idx, @@ -391,10 +391,10 @@ def build_player_input( ) else: move_vec = Vec2( - float(input_code_is_down(move_codes[3], player_index=idx)) - - float(input_code_is_down(move_codes[2], player_index=idx)), - float(input_code_is_down(move_codes[1], player_index=idx)) - - float(input_code_is_down(move_codes[0], player_index=idx)), + float(input_code_is_down(turn_right_key, player_index=idx)) + - float(input_code_is_down(turn_left_key, player_index=idx)), + float(input_code_is_down(move_backward_key, player_index=idx)) + - float(input_code_is_down(move_forward_key, player_index=idx)), ) heading = float(state.aim_heading) @@ -409,9 +409,9 @@ def build_player_input( heading = delta.to_heading() elif aim_scheme is AimScheme.KEYBOARD: if move_mode_type in {MovementControlType.RELATIVE, MovementControlType.STATIC}: - if input_code_is_down(keyboard_aim_codes[1], player_index=idx): + if input_code_is_down(aim_right_key, player_index=idx): heading = float(heading + float(dt) * _AIM_KEYBOARD_TURN_RATE) - if input_code_is_down(keyboard_aim_codes[0], player_index=idx): + if input_code_is_down(aim_left_key, player_index=idx): heading = float(heading - float(dt) * _AIM_KEYBOARD_TURN_RATE) aim = _aim_point_from_heading(player.pos, heading) elif aim_scheme is AimScheme.MOUSE_RELATIVE: @@ -420,8 +420,8 @@ def build_player_input( heading = rel.to_heading() aim = _aim_point_from_heading(player.pos, heading) elif aim_scheme is AimScheme.DUAL_ACTION_PAD: - axis_y = input_axis_value(aim_axis_codes[0], player_index=idx) - axis_x = input_axis_value(aim_axis_codes[1], player_index=idx) + axis_y = input_axis_value(aim_axis_y, player_index=idx) + axis_x = input_axis_value(aim_axis_x, player_index=idx) axis_vec = Vec2(axis_x, axis_y) mag_sq = axis_vec.length_sq() if mag_sq > 1e-9: diff --git a/src/crimson/modes/tutorial_mode.py b/src/crimson/modes/tutorial_mode.py index ba3c5dc67..da415c76a 100644 --- a/src/crimson/modes/tutorial_mode.py +++ b/src/crimson/modes/tutorial_mode.py @@ -186,12 +186,12 @@ def _handle_input(self) -> None: def _build_input(self) -> PlayerInput: controls = self.config.controls.player(0) - move_codes = controls.move_codes + move_forward_key, move_backward_key, turn_left_key, turn_right_key = controls.move_codes fire_key = controls.fire_code move = Vec2( - float(input_code_is_down(move_codes[3])) - float(input_code_is_down(move_codes[2])), - float(input_code_is_down(move_codes[1])) - float(input_code_is_down(move_codes[0])), + float(input_code_is_down(turn_right_key)) - float(input_code_is_down(turn_left_key)), + float(input_code_is_down(move_backward_key)) - float(input_code_is_down(move_forward_key)), ) mouse = self._ui_mouse_pos() diff --git a/src/crimson/ui/text_input.py b/src/crimson/ui/text_input.py index 7a4004ba6..4be9c5c66 100644 --- a/src/crimson/ui/text_input.py +++ b/src/crimson/ui/text_input.py @@ -80,12 +80,12 @@ def gameplay_controls_held(config: CrimsonConfig) -> bool: player_count = max(1, min(4, config.gameplay.player_count)) for player_index in range(player_count): player_controls = config.controls.player(player_index) - move_codes = player_controls.move_codes + move_forward_key, move_backward_key, turn_left_key, turn_right_key = player_controls.move_codes for code in ( - move_codes[0], - move_codes[1], - move_codes[2], - move_codes[3], + move_forward_key, + move_backward_key, + turn_left_key, + turn_right_key, player_controls.fire_code, )[:_CONTROL_BIND_SLOTS]: if code == INPUT_CODE_UNBOUND: From 1513606ec4f4d219431357a8d84c830a92b2df7c Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:26:12 +0400 Subject: [PATCH 09/15] refactor: inline controls mode access --- src/crimson/local_input.py | 5 ++--- src/crimson/screens/panels/controls.py | 13 +++++++++---- src/crimson/screens/panels/controls_labels.py | 13 +++---------- tests/ui/test_controls_labels.py | 6 ++---- 4 files changed, 16 insertions(+), 21 deletions(-) diff --git a/src/crimson/local_input.py b/src/crimson/local_input.py index decaff40a..3d8e10d26 100644 --- a/src/crimson/local_input.py +++ b/src/crimson/local_input.py @@ -18,7 +18,6 @@ input_code_is_pressed, ) from .movement_controls import MovementControlType -from .screens.panels.controls_labels import controls_method_values from .sim.input import PlayerInput from .sim.state_types import PlayerState @@ -248,8 +247,8 @@ def _state_for_player(self, player_index: int, *, player: PlayerState | None = N @staticmethod def _safe_controls_modes(config: CrimsonConfig, *, player_index: int) -> tuple[AimScheme, MovementControlType]: - aim_scheme, move_mode = controls_method_values(config.controls, player_index=int(player_index)) - return aim_scheme, move_mode + player_controls = config.controls.player(int(player_index)) + return player_controls.aim_scheme, player_controls.movement def build_player_input( self, diff --git a/src/crimson/screens/panels/controls.py b/src/crimson/screens/panels/controls.py index 674ee2482..fc8ac6174 100644 --- a/src/crimson/screens/panels/controls.py +++ b/src/crimson/screens/panels/controls.py @@ -26,7 +26,6 @@ from .controls_labels import ( RebindRowSpec, controls_aim_method_dropdown_ids, - controls_method_values, controls_rebind_plan, input_configure_for_label, input_scheme_label, @@ -379,7 +378,9 @@ def _collect_rebind_rows( def _update_rebind_capture(self, *, right_top_left: Vec2, panel_scale: float, font: SmallFontData) -> bool: player_idx = self._current_player_index() - aim_scheme, move_mode = controls_method_values(self.state.config.controls, player_index=player_idx) + player_controls = self.state.config.controls.player(player_idx) + aim_scheme = player_controls.aim_scheme + move_mode = player_controls.movement sections = self._rebind_sections(player_index=player_idx, aim_scheme=aim_scheme, move_mode=move_mode) rows = self._collect_rebind_rows( right_top_left=right_top_left, @@ -534,7 +535,9 @@ def _update_dropdown( def _update_method_dropdowns(self, *, left_top_left: Vec2, panel_scale: float, font: SmallFontData) -> bool: config = self.state.config player_idx = self._current_player_index() - aim_scheme, move_mode = controls_method_values(config.controls, player_index=player_idx) + player_controls = config.controls.player(player_idx) + aim_scheme = player_controls.aim_scheme + move_mode = player_controls.movement move_mode_ids = self._move_method_ids(move_mode=move_mode) move_items = tuple(input_scheme_label(mode) for mode in move_mode_ids) aim_item_ids = controls_aim_method_dropdown_ids(aim_scheme) @@ -649,7 +652,9 @@ def _draw_contents(self) -> None: text_color_soft = rl.Color(255, 255, 255, 204) config = self.state.config player_idx = self._current_player_index() - aim_scheme, move_mode = controls_method_values(config.controls, player_index=player_idx) + player_controls = config.controls.player(player_idx) + aim_scheme = player_controls.aim_scheme + move_mode = player_controls.movement move_mode_ids = self._move_method_ids(move_mode=move_mode) move_items = tuple(input_scheme_label(mode) for mode in move_mode_ids) aim_item_ids = controls_aim_method_dropdown_ids(aim_scheme) diff --git a/src/crimson/screens/panels/controls_labels.py b/src/crimson/screens/panels/controls_labels.py index 3380ff88b..92f41486d 100644 --- a/src/crimson/screens/panels/controls_labels.py +++ b/src/crimson/screens/panels/controls_labels.py @@ -45,17 +45,10 @@ def input_scheme_label(scheme: MovementControlType) -> str: return labels.get(scheme, "Unknown") -def controls_method_values( - controls: CrimsonControlsConfig, - *, - player_index: int, -) -> tuple[AimScheme, MovementControlType]: - player = controls.player(player_index) - return player.aim_scheme, player.movement - - def controls_method_labels(controls: CrimsonControlsConfig, *, player_index: int) -> tuple[str, str]: - aim_scheme, move_mode = controls_method_values(controls, player_index=player_index) + player = controls.player(player_index) + aim_scheme = player.aim_scheme + move_mode = player.movement return input_configure_for_label(aim_scheme), input_scheme_label(move_mode) diff --git a/tests/ui/test_controls_labels.py b/tests/ui/test_controls_labels.py index 0651d3a93..2a3385e87 100644 --- a/tests/ui/test_controls_labels.py +++ b/tests/ui/test_controls_labels.py @@ -8,7 +8,6 @@ RebindRowSpec, controls_aim_method_dropdown_ids, controls_method_labels, - controls_method_values, controls_rebind_plan, input_configure_for_label, input_scheme_label, @@ -54,17 +53,16 @@ def test_controls_method_labels_reads_player_arrays() -> None: assert controls_method_labels(controls, player_index=1) == ("Joystick", "Mouse point click") assert controls_method_labels(controls, player_index=2) == ("Dual Action Pad", "Computer") assert controls_method_labels(controls, player_index=3) == ("Computer", "Relative") - assert controls_method_values(controls, player_index=1) == (AimScheme.JOYSTICK, MovementControlType.MOUSE_POINT_CLICK) def test_controls_method_labels_defaults_missing_blob() -> None: assert controls_method_labels(_controls(), player_index=0) == ("Mouse", "Static") -def test_controls_method_values_unknown_move_mode_maps_to_unknown_enum() -> None: +def test_controls_method_labels_unknown_move_mode_maps_to_unknown_enum() -> None: controls = _controls() controls.player(0).movement = MovementControlType.UNKNOWN - assert controls_method_values(controls, player_index=0) == (AimScheme.MOUSE, MovementControlType.UNKNOWN) + assert controls_method_labels(controls, player_index=0) == ("Mouse", "Unknown") def test_controls_aim_method_dropdown_ids_hides_computer_unless_loaded() -> None: From dc09885ea639bdcd662f382e795a872074c86ed3 Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:35:57 +0400 Subject: [PATCH 10/15] refactor: inline local input control modes --- src/crimson/local_input.py | 8 +- tests/input/test_local_input.py | 174 ++++++++++---------------------- 2 files changed, 58 insertions(+), 124 deletions(-) diff --git a/src/crimson/local_input.py b/src/crimson/local_input.py index 3d8e10d26..d949a6965 100644 --- a/src/crimson/local_input.py +++ b/src/crimson/local_input.py @@ -245,11 +245,6 @@ def _state_for_player(self, player_index: int, *, player: PlayerState | None = N state.aim_heading = float(player.aim_heading) return state - @staticmethod - def _safe_controls_modes(config: CrimsonConfig, *, player_index: int) -> tuple[AimScheme, MovementControlType]: - player_controls = config.controls.player(int(player_index)) - return player_controls.aim_scheme, player_controls.movement - def build_player_input( self, *, @@ -265,7 +260,8 @@ def build_player_input( idx = max(0, min(3, int(player_index))) state = self._state_for_player(idx, player=player) binds = config.controls.player(idx) - aim_scheme, move_mode_type = self._safe_controls_modes(config, player_index=idx) + aim_scheme = binds.aim_scheme + move_mode_type = binds.movement reload_key = config.controls.reload_code move_forward_key, move_backward_key, turn_left_key, turn_right_key = binds.move_codes diff --git a/tests/input/test_local_input.py b/tests/input/test_local_input.py index b6e9eb498..23aa4b62a 100644 --- a/tests/input/test_local_input.py +++ b/tests/input/test_local_input.py @@ -77,32 +77,46 @@ def _set_player_bind_values( return cfg +def _set_player_modes( + cfg: CrimsonConfig, + *, + aim_scheme: AimScheme | None = None, + move_mode: MovementControlType | None = None, + player_index: int = 0, +) -> CrimsonConfig: + player = cfg.controls.player(player_index) + if aim_scheme is not None: + player.aim_scheme = aim_scheme + if move_mode is not None: + player.movement = move_mode + return cfg + + def _config_with_player_bind_values( values: list[int] | tuple[int, ...] | range, *, player_index: int = 0, player_count: int = 1, + aim_scheme: AimScheme | None = None, + move_mode: MovementControlType | None = None, ) -> CrimsonConfig: cfg = _test_config(player_count=player_count) + _set_player_modes(cfg, aim_scheme=aim_scheme, move_mode=move_mode, player_index=player_index) return _set_player_bind_values(cfg, values, player_index=player_index) def test_local_input_computer_aim_auto_fires_without_fire_pressed(mocker: MockerFixture) -> None: _patch_no_user_input(mocker) - mocker.patch.object( - local_input.LocalInputInterpreter, - "_safe_controls_modes", - staticmethod(lambda _config, *, player_index: (AimScheme.COMPUTER, MovementControlType.STATIC)), - ) interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(512.0, 512.0), aim=Vec2(560.0, 512.0)) creatures = [_DummyCreature(pos=Vec2(612.0, 512.0), active=True, hp=20.0)] + config = _set_player_modes(_test_config(), aim_scheme=AimScheme.COMPUTER) out = interpreter.build_player_input( player_index=0, player=player, - config=_test_config(), + config=config, mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -118,19 +132,15 @@ def test_local_input_computer_aim_auto_fires_without_fire_pressed(mocker: Mocker def test_local_input_computer_aim_without_target_points_away_from_center(mocker: MockerFixture) -> None: _patch_no_user_input(mocker) - mocker.patch.object( - local_input.LocalInputInterpreter, - "_safe_controls_modes", - staticmethod(lambda _config, *, player_index: (AimScheme.COMPUTER, MovementControlType.STATIC)), - ) interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(512.0, 512.0), aim=Vec2(512.0, 512.0)) + config = _set_player_modes(_test_config(), aim_scheme=AimScheme.COMPUTER) out = interpreter.build_player_input( player_index=0, player=player, - config=_test_config(), + config=config, mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -148,15 +158,11 @@ def test_local_input_computer_target_state_tracks_player_identity_not_call_slot( mocker: MockerFixture, ) -> None: _patch_no_user_input(mocker) - mocker.patch.object( - local_input.LocalInputInterpreter, - "_safe_controls_modes", - staticmethod(lambda _config, *, player_index: (AimScheme.COMPUTER, MovementControlType.STATIC)), - ) interpreter = local_input.LocalInputInterpreter() player0 = PlayerState(index=0, pos=Vec2(0.0, 0.0), aim=Vec2(0.0, 0.0)) player1 = PlayerState(index=1, pos=Vec2(128.0, 0.0), aim=Vec2(128.0, 0.0)) + config = _set_player_modes(_test_config(), aim_scheme=AimScheme.COMPUTER) creatures = [ _DummyCreature(pos=Vec2(100.0, 0.0), active=True, hp=20.0), # nearest to player0 _DummyCreature(pos=Vec2(130.0, 0.0), active=True, hp=20.0), # nearest to player1 @@ -166,7 +172,7 @@ def test_local_input_computer_target_state_tracks_player_identity_not_call_slot( interpreter.build_player_input( player_index=0, player=player1, - config=_test_config(), + config=config, mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -177,7 +183,7 @@ def test_local_input_computer_target_state_tracks_player_identity_not_call_slot( out = interpreter.build_player_input( player_index=0, player=player0, - config=_test_config(), + config=config, mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -204,11 +210,6 @@ def test_local_input_static_mode_conflict_precedence_matches_native( expected_move: Vec2, ) -> None: _patch_keys_down(mocker, down_codes=down_codes) - mocker.patch.object( - local_input.LocalInputInterpreter, - "_safe_controls_modes", - staticmethod(lambda _config, *, player_index: (AimScheme.MOUSE, MovementControlType.STATIC)), - ) interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(160.0, 100.0)) @@ -232,15 +233,10 @@ def test_local_input_relative_mode_single_player_uses_alt_arrow_fallback( mocker: MockerFixture, ) -> None: _patch_keys_down(mocker, down_codes={0xC8, 0xCB}) - mocker.patch.object( - local_input.LocalInputInterpreter, - "_safe_controls_modes", - staticmethod(lambda _config, *, player_index: (AimScheme.MOUSE, MovementControlType.RELATIVE)), - ) interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(160.0, 100.0)) - config = _config_with_player_bind_values((0x17E,) * 16) + config = _config_with_player_bind_values((0x17E,) * 16, move_mode=MovementControlType.RELATIVE) out = interpreter.build_player_input( player_index=0, @@ -262,12 +258,7 @@ def test_local_input_relative_mode_multiplayer_does_not_use_alt_arrow_fallback( mocker: MockerFixture, ) -> None: _patch_keys_down(mocker, down_codes={0xC8, 0xCB}) - mocker.patch.object( - local_input.LocalInputInterpreter, - "_safe_controls_modes", - staticmethod(lambda _config, *, player_index: (AimScheme.MOUSE, MovementControlType.RELATIVE)), - ) - config = _config_with_player_bind_values((0x17E,) * 16, player_count=2) + config = _config_with_player_bind_values((0x17E,) * 16, player_count=2, move_mode=MovementControlType.RELATIVE) interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(160.0, 100.0)) @@ -297,11 +288,6 @@ def test_local_input_reload_pressed_is_available_in_multiplayer( "input_code_is_pressed", lambda key, **_kwargs: int(key) == 0x102, ) - mocker.patch.object( - local_input.LocalInputInterpreter, - "_safe_controls_modes", - staticmethod(lambda _config, *, player_index: (AimScheme.MOUSE, MovementControlType.STATIC)), - ) interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(160.0, 100.0)) @@ -339,11 +325,6 @@ def test_local_input_reload_pressed_reads_per_player_input_slot( "input_code_is_pressed", lambda key, **kwargs: int(key) == 0x102 and int(kwargs.get("player_index", -1)) == 1, ) - mocker.patch.object( - local_input.LocalInputInterpreter, - "_safe_controls_modes", - staticmethod(lambda _config, *, player_index: (AimScheme.MOUSE, MovementControlType.STATIC)), - ) interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=1, pos=Vec2(100.0, 100.0), aim=Vec2(160.0, 100.0)) @@ -376,15 +357,10 @@ def test_local_input_mouse_point_click_marks_move_to_cursor_press( lambda key, **_kwargs: int(key) == 0x102, ) mocker.patch.object(local_input, "input_axis_value", lambda *_args, **_kwargs: 0.0) - mocker.patch.object( - local_input.LocalInputInterpreter, - "_safe_controls_modes", - staticmethod(lambda _config, *, player_index: (AimScheme.MOUSE, MovementControlType.MOUSE_POINT_CLICK)), - ) interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(160.0, 100.0)) - config = _config_with_player_bind_values(range(16)) + config = _config_with_player_bind_values(range(16), move_mode=MovementControlType.MOUSE_POINT_CLICK) out = interpreter.build_player_input( player_index=0, @@ -409,20 +385,16 @@ def test_local_input_computer_move_mode_near_center_heads_toward_target( mocker: MockerFixture, ) -> None: _patch_no_user_input(mocker) - mocker.patch.object( - local_input.LocalInputInterpreter, - "_safe_controls_modes", - staticmethod(lambda _config, *, player_index: (AimScheme.MOUSE, MovementControlType.COMPUTER)), - ) interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(500.0, 500.0), aim=Vec2(560.0, 500.0)) creatures = [_DummyCreature(pos=Vec2(560.0, 500.0), active=True, hp=20.0)] + config = _set_player_modes(_test_config(), move_mode=MovementControlType.COMPUTER) out = interpreter.build_player_input( player_index=0, player=player, - config=_test_config(), + config=config, mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -438,20 +410,16 @@ def test_local_input_computer_move_mode_far_from_center_heads_toward_center( mocker: MockerFixture, ) -> None: _patch_no_user_input(mocker) - mocker.patch.object( - local_input.LocalInputInterpreter, - "_safe_controls_modes", - staticmethod(lambda _config, *, player_index: (AimScheme.MOUSE, MovementControlType.COMPUTER)), - ) interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(900.0, 900.0), aim=Vec2(960.0, 900.0)) creatures = [_DummyCreature(pos=Vec2(960.0, 900.0), active=True, hp=20.0)] + config = _set_player_modes(_test_config(), move_mode=MovementControlType.COMPUTER) out = interpreter.build_player_input( player_index=0, player=player, - config=_test_config(), + config=config, mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -468,20 +436,16 @@ def test_local_input_computer_aim_scheme_forces_computer_movement( mocker: MockerFixture, ) -> None: _patch_no_user_input(mocker) - mocker.patch.object( - local_input.LocalInputInterpreter, - "_safe_controls_modes", - staticmethod(lambda _config, *, player_index: (AimScheme.COMPUTER, MovementControlType.STATIC)), - ) interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(500.0, 500.0), aim=Vec2(560.0, 500.0)) creatures = [_DummyCreature(pos=Vec2(560.0, 500.0), active=True, hp=20.0)] + config = _set_player_modes(_test_config(), aim_scheme=AimScheme.COMPUTER) out = interpreter.build_player_input( player_index=0, player=player, - config=_test_config(), + config=config, mouse_screen=Vec2(), mouse_world=Vec2(), screen_center=Vec2(), @@ -497,15 +461,10 @@ def test_local_input_joystick_aim_uses_pov_not_aim_keybinds( mocker: MockerFixture, ) -> None: _patch_keys_down(mocker, down_codes={8}) - mocker.patch.object( - local_input.LocalInputInterpreter, - "_safe_controls_modes", - staticmethod(lambda _config, *, player_index: (AimScheme.JOYSTICK, MovementControlType.STATIC)), - ) interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(160.0, 100.0)) - config = _config_with_player_bind_values(range(16)) + config = _config_with_player_bind_values(range(16), aim_scheme=AimScheme.JOYSTICK) out = interpreter.build_player_input( player_index=0, @@ -527,15 +486,10 @@ def test_local_input_joystick_aim_turns_with_pov_input( mocker: MockerFixture, ) -> None: _patch_keys_down(mocker, down_codes={0x134}) - mocker.patch.object( - local_input.LocalInputInterpreter, - "_safe_controls_modes", - staticmethod(lambda _config, *, player_index: (AimScheme.JOYSTICK, MovementControlType.STATIC)), - ) interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(160.0, 100.0)) - config = _config_with_player_bind_values(range(16)) + config = _config_with_player_bind_values(range(16), aim_scheme=AimScheme.JOYSTICK) out = interpreter.build_player_input( player_index=0, @@ -563,15 +517,15 @@ def test_local_input_joystick_aim_reads_player_pov_by_default( ) mocker.patch.object(local_input, "input_code_is_pressed", lambda *_args, **_kwargs: False) mocker.patch.object(local_input, "input_axis_value", lambda *_args, **_kwargs: 0.0) - mocker.patch.object( - local_input.LocalInputInterpreter, - "_safe_controls_modes", - staticmethod(lambda _config, *, player_index: (AimScheme.JOYSTICK, MovementControlType.STATIC)), - ) interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=1, pos=Vec2(100.0, 100.0), aim=Vec2(160.0, 100.0)) - config = _config_with_player_bind_values(range(16), player_index=1, player_count=2) + config = _config_with_player_bind_values( + range(16), + player_index=1, + player_count=2, + aim_scheme=AimScheme.JOYSTICK, + ) out = interpreter.build_player_input( player_index=1, @@ -599,16 +553,16 @@ def test_local_input_joystick_aim_preserve_bugs_uses_player1_pov_slot( ) mocker.patch.object(local_input, "input_code_is_pressed", lambda *_args, **_kwargs: False) mocker.patch.object(local_input, "input_axis_value", lambda *_args, **_kwargs: 0.0) - mocker.patch.object( - local_input.LocalInputInterpreter, - "_safe_controls_modes", - staticmethod(lambda _config, *, player_index: (AimScheme.JOYSTICK, MovementControlType.STATIC)), - ) interpreter = local_input.LocalInputInterpreter() interpreter.set_preserve_bugs(True) player = PlayerState(index=1, pos=Vec2(100.0, 100.0), aim=Vec2(160.0, 100.0)) - config = _config_with_player_bind_values(range(16), player_index=1, player_count=2) + config = _config_with_player_bind_values( + range(16), + player_index=1, + player_count=2, + aim_scheme=AimScheme.JOYSTICK, + ) out = interpreter.build_player_input( player_index=1, @@ -636,15 +590,10 @@ def test_local_input_dual_action_pad_aim_uses_native_radius_scale( "input_axis_value", lambda key, **_kwargs: 1.0 if int(key) == 10 else 0.0, ) - mocker.patch.object( - local_input.LocalInputInterpreter, - "_safe_controls_modes", - staticmethod(lambda _config, *, player_index: (AimScheme.DUAL_ACTION_PAD, MovementControlType.STATIC)), - ) interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(160.0, 100.0)) - config = _config_with_player_bind_values(range(16)) + config = _config_with_player_bind_values(range(16), aim_scheme=AimScheme.DUAL_ACTION_PAD) out = interpreter.build_player_input( player_index=0, @@ -666,15 +615,10 @@ def test_local_input_keyboard_aim_in_static_mode_reanchors_to_heading( mocker: MockerFixture, ) -> None: _patch_no_user_input(mocker) - mocker.patch.object( - local_input.LocalInputInterpreter, - "_safe_controls_modes", - staticmethod(lambda _config, *, player_index: (AimScheme.KEYBOARD, MovementControlType.STATIC)), - ) interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(180.0, 130.0), aim_heading=0.0) - config = _config_with_player_bind_values(range(16)) + config = _config_with_player_bind_values(range(16), aim_scheme=AimScheme.KEYBOARD) out = interpreter.build_player_input( player_index=0, @@ -695,15 +639,14 @@ def test_local_input_keyboard_aim_with_non_relative_move_mode_keeps_world_aim( mocker: MockerFixture, ) -> None: _patch_no_user_input(mocker) - mocker.patch.object( - local_input.LocalInputInterpreter, - "_safe_controls_modes", - staticmethod(lambda _config, *, player_index: (AimScheme.KEYBOARD, MovementControlType.DUAL_ACTION_PAD)), - ) interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(180.0, 130.0), aim_heading=0.0) - config = _config_with_player_bind_values(range(16)) + config = _config_with_player_bind_values( + range(16), + aim_scheme=AimScheme.KEYBOARD, + move_mode=MovementControlType.DUAL_ACTION_PAD, + ) out = interpreter.build_player_input( player_index=0, @@ -726,16 +669,11 @@ def test_local_input_relative_mouse_aim_centered_keeps_world_aim( mocker: MockerFixture, ) -> None: _patch_no_user_input(mocker) - mocker.patch.object( - local_input.LocalInputInterpreter, - "_safe_controls_modes", - staticmethod(lambda _config, *, player_index: (AimScheme.MOUSE_RELATIVE, MovementControlType.STATIC)), - ) interpreter = local_input.LocalInputInterpreter() player = PlayerState(index=0, pos=Vec2(100.0, 100.0), aim=Vec2(180.0, 130.0), aim_heading=0.0) center = Vec2(320.0, 200.0) - config = _config_with_player_bind_values(range(16)) + config = _config_with_player_bind_values(range(16), aim_scheme=AimScheme.MOUSE_RELATIVE) out = interpreter.build_player_input( player_index=0, From 7b4ba9be9ac5051587f5738bf45c476654c60ed9 Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Fri, 13 Mar 2026 23:11:55 +0400 Subject: [PATCH 11/15] refactor: replace controls row reflection with typed targets --- docs/rewrite/index.md | 1 + docs/rewrite/keybind-flow.d2 | 138 +++++++++ docs/rewrite/keybind-flow.md | 273 ++++++++++++++++++ src/crimson/screens/panels/controls.py | 65 ++++- src/crimson/screens/panels/controls_labels.py | 52 ++-- tests/ui/test_controls_labels.py | 27 +- zensical.toml | 1 + 7 files changed, 508 insertions(+), 49 deletions(-) create mode 100644 docs/rewrite/keybind-flow.d2 create mode 100644 docs/rewrite/keybind-flow.md diff --git a/docs/rewrite/index.md b/docs/rewrite/index.md index 7ab3501c4..47e1bc7d5 100644 --- a/docs/rewrite/index.md +++ b/docs/rewrite/index.md @@ -128,6 +128,7 @@ See also: - [CDT trace format (rewrite tooling)](cdt-trace-format.md) - [Terrain (rewrite)](terrain.md) - [Perks architecture (rewrite)](perks-architecture.md) +- [Keybind flow](keybind-flow.md) - [Original bugs (and rewrite fixes)](original-bugs.md) ## Known gaps (short list) diff --git a/docs/rewrite/keybind-flow.d2 b/docs/rewrite/keybind-flow.d2 new file mode 100644 index 000000000..8d8e8ad04 --- /dev/null +++ b/docs/rewrite/keybind-flow.d2 @@ -0,0 +1,138 @@ +direction: right + +cfg_blob: { + shape: document + label: "crimson.cfg\n0x480-byte blob" +} + +wire: { + label: "Wire Boundary" + + construct: { + label: "CRIMSON_CFG_STRUCT\nConstruct layout" + } + + raw_blocks: { + label: "Raw bind blocks\nP1/P2 in original keybinds region\nP3/P4 in reserved-gap extension" + } + + decode: { + label: "decode_crimson_cfg()" + } + + encode: { + label: "encode_crimson_cfg()" + } + + defaults: { + label: "Canonical defaults\nunused bind slots\nreserved bytes\nP3/P4 extension" + } +} + +model: { + label: "Semantic Config Model" + + cfg: { + label: "CrimsonConfig" + } + + controls: { + label: "CrimsonControlsConfig\nplayers[4]\npick_perk_code\nreload_code" + } + + player_controls: { + label: "CrimsonPlayerControls\nmovement\naim_scheme\nshow_direction_arrow\nmove_codes[4]\nfire_code\nkeyboard_aim_codes[2]\naim_axis_codes[2]\nmove_axis_codes[2]" + } +} + +ui: { + label: "Controls Menu" + + labels: { + label: "controls_rebind_plan()\nchooses visible rows\nfrom aim_scheme + movement" + } + + rowspec: { + label: "RebindRowSpec\nlabel\nfield_name\nfield_index?\ncontrols_field?\naxis?" + } + + reflection: { + label: "_row_binding_code()\n_set_row_binding_code()\ngetattr/setattr" + } + + capture: { + label: "capture_first_pressed_input_code()" + } +} + +runtime: { + label: "Runtime Consumers" + + local_input: { + label: "LocalInputInterpreter\nbuild_player_input()\nbuild_frame_inputs()" + } + + perk_prompt: { + label: "PerkPromptController\nreads fire_code + pick_perk_code" + } + + tutorial: { + label: "TutorialMode\nreads move_codes + fire_code + reload_code" + } + + text_input: { + label: "text_input.gameplay_controls_held()\nreads first 5 gameplay controls" + } +} + +translator: { + label: "Input Translator" + + input_codes: { + label: "input_codes.py\nGrim input code -> raylib polling" + } + + raylib: { + label: "raylib\nkeyboard\nmouse\ngamepad\naxes" + } +} + +cfg_blob -> wire.construct: "parse/build" +wire.construct -> wire.raw_blocks: "typed raw fields" +wire.raw_blocks -> wire.decode: "read" +wire.defaults -> wire.decode: "zero-block backfill" +wire.decode -> model.cfg: "build semantic model" + +model.cfg -> model.controls +model.controls -> model.player_controls + +model.cfg -> wire.encode: "save()" +wire.defaults -> wire.encode: "canonicalize" +wire.encode -> wire.construct: "build raw dict" +wire.construct -> cfg_blob: "write blob" + +model.controls -> ui.labels: "current movement + aim scheme" +ui.labels -> ui.rowspec: "row plan" +ui.rowspec -> ui.reflection: "field_name / field_index" +model.controls -> ui.reflection: "controls or player object" +ui.reflection -> model.controls: "read/write selected field" +ui.capture -> ui.reflection: "captured Grim code" + +model.controls -> runtime.local_input: "grouped player controls" +model.controls -> runtime.perk_prompt: "fire_code / pick_perk_code" +model.controls -> runtime.tutorial: "move_codes / fire_code / reload_code" +model.controls -> runtime.text_input: "move_codes / fire_code" + +runtime.local_input -> translator.input_codes: "poll bindings" +runtime.perk_prompt -> translator.input_codes: "primary/open checks" +runtime.tutorial -> translator.input_codes: "poll bindings" +runtime.text_input -> translator.input_codes: "held checks" +ui.capture -> translator.input_codes: "capture input code" + +translator.input_codes -> translator.raylib: "query backend state" + +notes: { + label: "Why getattr/setattr feels bad\n\nIt is a tiny reflection layer used only by the controls menu.\nThe row spec stores field names as strings like \"move_codes\" or \"reload_code\".\ncontrols.py then uses getattr()/setattr() to read or update that field on either:\n- CrimsonControlsConfig, or\n- CrimsonPlayerControls\n\nThis keeps slot numbers out of the UI, but it is still stringly typed:\n- typos fail at runtime\n- refactors are less safe\n- type checkers cannot follow the field access" +} + +ui.reflection -> notes: "current weak spot" diff --git a/docs/rewrite/keybind-flow.md b/docs/rewrite/keybind-flow.md new file mode 100644 index 000000000..0b41293c5 --- /dev/null +++ b/docs/rewrite/keybind-flow.md @@ -0,0 +1,273 @@ +--- +tags: + - rewrite + - controls +--- + +# Keybind Flow + +This note describes the current keybind flow in the Python port after the +config-model cleanup. + +The important conclusions are: + +- `crimson.cfg` stores original Grim input codes, not raylib enums. +- `src/crimson/input_codes.py` is the only Grim-code to raylib translator. +- `src/grim/config.py` owns the wire-layout bridge and default/canonical write + policy. +- Runtime code reads grouped semantic controls directly. +- The controls menu owns the only remaining UI-specific rebind mapping. + +The companion structure diagram lives in `keybind-flow.d2`. + +## Code Domains + +There are four domains involved: + +1. Wire layout in `src/grim/config.py` + - fixed `crimson.cfg` blob + - original field order and offsets + +2. Semantic config model in `src/grim/config.py` + - `CrimsonControlsConfig` + - `CrimsonPlayerControls` + - grouped Grim input codes such as `move_codes`, `fire_code`, + `keyboard_aim_codes`, `aim_axis_codes`, and `move_axis_codes` + +3. Original Grim input-code domain in `src/crimson/input_codes.py` + - keyboard DIK-style codes below `0x100` + - mouse buttons at `0x100+` + - joystick buttons, POV directions, and axes in the `0x11f+` range + +4. Backend input domain in raylib + - `rl.KeyboardKey` + - `rl.MouseButton` + - `rl.GamepadButton` + - `rl.GamepadAxis` + +The config model should stay entirely in domains 1 and 2. +The backend translator should stay entirely in domains 3 and 4. + +## What Is Stored On Disk + +The wire layout is defined by `CRIMSON_CFG_STRUCT` in `src/grim/config.py`. + +For controls: + +- P1/P2 live in `keybinds_p1_p2` +- P3/P4 live in `extended_keybinds_p3_p4` +- the P3/P4 blocks are our port extension inside the original reserved gap + +Each raw per-player bind block is represented in the wire schema as: + +- `move_forward` +- `move_backward` +- `turn_left` +- `turn_right` +- `fire` +- `reserved_keys[2]` +- `aim_left` +- `aim_right` +- `axis_aim_y` +- `axis_aim_x` +- `axis_move_y` +- `axis_move_x` +- `padding[3]` + +That shape is good for the wire codec because it stays close to the original +layout and reverse-engineering field names. + +## What The Semantic Model Stores + +`decode_crimson_cfg(...)` maps each raw bind block into `CrimsonPlayerControls`. + +That semantic model stores only the bindings the port actually uses: + +- `movement` +- `aim_scheme` +- `show_direction_arrow` +- `move_codes` +- `fire_code` +- `keyboard_aim_codes` +- `aim_axis_codes` +- `move_axis_codes` + +`CrimsonControlsConfig` then adds: + +- `players[4]` +- `pick_perk_code` +- `reload_code` + +This is the current stable semantic boundary. + +It is intentionally not a generic 16-slot tuple API, but it also does not try to +invent fake higher-level meanings for all raw slots. Unused raw slots remain a +wire concern only and are written canonically from defaults. + +## How The Controls Menu Works + +The controls menu no longer works in raw slot ids or string field names. + +`controls_rebind_plan(...)` in `src/crimson/screens/panels/controls_labels.py` +returns `RebindRowSpec` values that contain: + +- a visible label +- a typed `RebindTarget` +- an optional tuple index +- an `axis` flag + +The target describes exactly what is being edited: + +- `PLAYER_MOVE_CODES` +- `PLAYER_FIRE_CODE` +- `PLAYER_KEYBOARD_AIM_CODES` +- `PLAYER_AIM_AXIS_CODES` +- `PLAYER_MOVE_AXIS_CODES` +- `GLOBAL_PICK_PERK_CODE` +- `GLOBAL_RELOAD_CODE` + +`ControlsMenuView` in `src/crimson/screens/panels/controls.py` then: + +1. builds the visible row plan from `movement` and `aim_scheme` +2. reads the selected value through an explicit `match` on `RebindTarget` +3. captures a new Grim input code via `capture_first_pressed_input_code(...)` +4. writes the updated Grim code back through the same typed target +5. calls `config.save()` on close + +Important point: + +- rebinding stores original Grim-style codes in config +- rebinding does not store raylib enums + +That is the correct boundary. + +## Runtime Input Flow + +The main gameplay consumer is `LocalInputInterpreter` in +`src/crimson/local_input.py`. + +Its input path is: + +1. read grouped per-player controls from config +2. select which codes matter for the current `movement` and `aim_scheme` +3. pass those Grim codes to `input_code_is_down(...)`, + `input_code_is_pressed(...)`, or `input_axis_value(...)` +4. let `src/crimson/input_codes.py` translate them to raylib polling +5. build `PlayerInput` + +The movement and aim schemes decide how the grouped binds are interpreted: + +- `MovementControlType.STATIC` + - uses `move_codes` as Up, Down, Left, Right + +- `MovementControlType.RELATIVE` + - uses the same `move_codes` as Forward, Backward, Turn left, Turn right + +- `MovementControlType.DUAL_ACTION_PAD` + - uses `move_axis_codes` + +- `MovementControlType.MOUSE_POINT_CLICK` + - uses global `reload_code` as the "move to cursor" trigger + +- `AimScheme.KEYBOARD` + - uses `keyboard_aim_codes` + +- `AimScheme.DUAL_ACTION_PAD` + - uses `aim_axis_codes` + +- `AimScheme.JOYSTICK` + - does not use stored keyboard aim binds + - uses hardcoded POV input codes `0x133` / `0x134` + +- `AimScheme.MOUSE` + - aims directly from mouse world position + +- `AimScheme.MOUSE_RELATIVE` + - aims from mouse delta relative to screen center + +- `AimScheme.COMPUTER` + - ignores player aim binds and computes aim internally + +This is why grouped semantic fields are a better fit than per-slot fake names +like `move_forward_code` or a generic 16-slot tuple. + +## Other Keybind Consumers + +Outside `LocalInputInterpreter`, there are only a few narrow consumers: + +- `src/crimson/modes/tutorial_mode.py` + - directly reads `move_codes`, `fire_code`, and `reload_code` + +- `src/crimson/modes/components/perk_prompt_controller.py` + - directly reads player fire codes plus `pick_perk_code` + +- `src/crimson/ui/text_input.py` + - checks the first five gameplay controls: the movement quartet plus `fire` + +These direct reads are simple and do not justify a generic config-keybind helper +layer. + +## Grim Code To Raylib Translation + +`src/crimson/input_codes.py` is the real translation layer. + +It contains: + +- keyboard DIK-style mapping via `_DIK_TO_RL_KEY` +- mouse-button mapping via `_MOUSE_CODE_TO_BUTTON` +- joystick button mapping via `_JOYS_BUTTON_CODES` +- axis mapping via `_AXIS_CODE_TO_AXIS` +- older RIM-device mappings via `_RIM_AXIS_CODES` and `_RIM_BUTTON_CODES` + +Two directions matter: + +1. Stored config code -> raylib poll + - `input_code_is_down(...)` + - `input_code_is_pressed(...)` + - `input_axis_value(...)` + +2. Raylib event -> stored config code + - `capture_first_pressed_input_code(...)` + +That file should remain the only place that knows how a stored Grim code maps +onto current backend input. + +## Defaults And Canonicalization + +There are two important config-side policies: + +1. Decode-time backfill for zeroed bind blocks + - if a raw player bind block is fully zeroed, `decode_crimson_cfg(...)` + substitutes that player's default bindings + +2. Encode-time canonicalization for wire-only fields + - unused raw bind slots are written from canonical per-player defaults + - reserved bytes are written canonically by the config bridge + +Runtime code does not have a `config is None` fallback anymore. +Gameplay code is expected to receive a real `CrimsonConfig`. + +## Current Assessment + +The structure is now: + +- storage is original Grim layout +- semantic config stores grouped Grim input codes +- the controls menu edits those grouped controls through typed UI-local targets +- runtime translation is original Grim code -> raylib +- runtime/gameplay code reads grouped controls directly + +That is the right mental model. + +The remaining complexity is mostly inherent: + +- the wire format is old and irregular +- movement and aim schemes reinterpret the same stored control groups +- the controls menu still has UI-specific row planning logic + +What should not come back is: + +- a generic 16-slot keybind API +- config-aware helper wrappers in `input_codes.py` +- stringly typed rebind field access +- a second runtime cache layer for bindings diff --git a/src/crimson/screens/panels/controls.py b/src/crimson/screens/panels/controls.py index fc8ac6174..59dc8c254 100644 --- a/src/crimson/screens/panels/controls.py +++ b/src/crimson/screens/panels/controls.py @@ -25,6 +25,7 @@ from .base import PANEL_TIMELINE_END_MS, PANEL_TIMELINE_START_MS, PanelMenuView from .controls_labels import ( RebindRowSpec, + RebindTarget, controls_aim_method_dropdown_ids, controls_rebind_plan, input_configure_for_label, @@ -48,24 +49,58 @@ def _row_binding_code(row: RebindRowSpec, *, player_index: int, controls) -> int: - owner = controls if row.controls_field else controls.player(player_index) - value = getattr(owner, row.field_name) - if row.field_index is not None: - return int(value[row.field_index]) - if row.controls_field: - return int(value) - return int(value) + player_controls = controls.player(player_index) + match row.target: + case RebindTarget.PLAYER_MOVE_CODES: + assert row.target_index is not None + return int(player_controls.move_codes[row.target_index]) + case RebindTarget.PLAYER_FIRE_CODE: + return int(player_controls.fire_code) + case RebindTarget.PLAYER_KEYBOARD_AIM_CODES: + assert row.target_index is not None + return int(player_controls.keyboard_aim_codes[row.target_index]) + case RebindTarget.PLAYER_AIM_AXIS_CODES: + assert row.target_index is not None + return int(player_controls.aim_axis_codes[row.target_index]) + case RebindTarget.PLAYER_MOVE_AXIS_CODES: + assert row.target_index is not None + return int(player_controls.move_axis_codes[row.target_index]) + case RebindTarget.GLOBAL_PICK_PERK_CODE: + return int(controls.pick_perk_code) + case RebindTarget.GLOBAL_RELOAD_CODE: + return int(controls.reload_code) def _set_row_binding_code(row: RebindRowSpec, value: int, *, player_index: int, controls) -> None: - owner = controls if row.controls_field else controls.player(player_index) + player_controls = controls.player(player_index) code = int(value) - if row.field_index is None: - setattr(owner, row.field_name, code) - return - values = list(getattr(owner, row.field_name)) - values[row.field_index] = code - setattr(owner, row.field_name, tuple(values)) + match row.target: + case RebindTarget.PLAYER_MOVE_CODES: + assert row.target_index is not None + values = list(player_controls.move_codes) + values[row.target_index] = code + player_controls.move_codes = tuple(values) + case RebindTarget.PLAYER_FIRE_CODE: + player_controls.fire_code = code + case RebindTarget.PLAYER_KEYBOARD_AIM_CODES: + assert row.target_index is not None + values = list(player_controls.keyboard_aim_codes) + values[row.target_index] = code + player_controls.keyboard_aim_codes = tuple(values) + case RebindTarget.PLAYER_AIM_AXIS_CODES: + assert row.target_index is not None + values = list(player_controls.aim_axis_codes) + values[row.target_index] = code + player_controls.aim_axis_codes = tuple(values) + case RebindTarget.PLAYER_MOVE_AXIS_CODES: + assert row.target_index is not None + values = list(player_controls.move_axis_codes) + values[row.target_index] = code + player_controls.move_axis_codes = tuple(values) + case RebindTarget.GLOBAL_PICK_PERK_CODE: + controls.pick_perk_code = code + case RebindTarget.GLOBAL_RELOAD_CODE: + controls.reload_code = code def _default_row_binding_code(player_index: int, row: RebindRowSpec) -> int: @@ -391,7 +426,7 @@ def _update_rebind_capture(self, *, right_top_left: Vec2, panel_scale: float, fo ) if self._rebind_active(): - active_row = self._rebind_row or RebindRowSpec("Fire:", "fire_code") + active_row = self._rebind_row or RebindRowSpec("Fire:", RebindTarget.PLAYER_FIRE_CODE) active_player = int(self._rebind_player_index or 0) if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE) or rl.is_mouse_button_pressed( rl.MouseButton.MOUSE_BUTTON_RIGHT, diff --git a/src/crimson/screens/panels/controls_labels.py b/src/crimson/screens/panels/controls_labels.py index 92f41486d..fef722fcd 100644 --- a/src/crimson/screens/panels/controls_labels.py +++ b/src/crimson/screens/panels/controls_labels.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from enum import Enum, auto from grim.config import CrimsonControlsConfig @@ -8,13 +9,22 @@ from ...movement_controls import MovementControlType +class RebindTarget(Enum): + PLAYER_MOVE_CODES = auto() + PLAYER_FIRE_CODE = auto() + PLAYER_KEYBOARD_AIM_CODES = auto() + PLAYER_AIM_AXIS_CODES = auto() + PLAYER_MOVE_AXIS_CODES = auto() + GLOBAL_PICK_PERK_CODE = auto() + GLOBAL_RELOAD_CODE = auto() + + @dataclass(frozen=True, slots=True) class RebindRowSpec: label: str - field_name: str - field_index: int | None = None + target: RebindTarget + target_index: int | None = None axis: bool = False - controls_field: bool = False def input_configure_for_label(config_id: AimScheme) -> str: @@ -83,44 +93,44 @@ def controls_rebind_plan( misc_rows: list[RebindRowSpec] = [] if aim_scheme is AimScheme.KEYBOARD: - aim_rows.append(RebindRowSpec("Torso left:", "keyboard_aim_codes", 0)) - aim_rows.append(RebindRowSpec("Torso right:", "keyboard_aim_codes", 1)) + aim_rows.append(RebindRowSpec("Torso left:", RebindTarget.PLAYER_KEYBOARD_AIM_CODES, 0)) + aim_rows.append(RebindRowSpec("Torso right:", RebindTarget.PLAYER_KEYBOARD_AIM_CODES, 1)) elif aim_scheme is AimScheme.DUAL_ACTION_PAD: - aim_rows.append(RebindRowSpec("Aim Up/Down Axis:", "aim_axis_codes", 0, axis=True)) - aim_rows.append(RebindRowSpec("Aim Left/Right Axis:", "aim_axis_codes", 1, axis=True)) - aim_rows.append(RebindRowSpec("Fire:", "fire_code")) + aim_rows.append(RebindRowSpec("Aim Up/Down Axis:", RebindTarget.PLAYER_AIM_AXIS_CODES, 0, axis=True)) + aim_rows.append(RebindRowSpec("Aim Left/Right Axis:", RebindTarget.PLAYER_AIM_AXIS_CODES, 1, axis=True)) + aim_rows.append(RebindRowSpec("Fire:", RebindTarget.PLAYER_FIRE_CODE)) if move_mode is MovementControlType.STATIC: move_rows.extend( ( - RebindRowSpec("Move Up:", "move_codes", 0), - RebindRowSpec("Move Down:", "move_codes", 1), - RebindRowSpec("Move Left:", "move_codes", 2), - RebindRowSpec("Move Right:", "move_codes", 3), + RebindRowSpec("Move Up:", RebindTarget.PLAYER_MOVE_CODES, 0), + RebindRowSpec("Move Down:", RebindTarget.PLAYER_MOVE_CODES, 1), + RebindRowSpec("Move Left:", RebindTarget.PLAYER_MOVE_CODES, 2), + RebindRowSpec("Move Right:", RebindTarget.PLAYER_MOVE_CODES, 3), ), ) elif move_mode is MovementControlType.RELATIVE: move_rows.extend( ( - RebindRowSpec("Forward:", "move_codes", 0), - RebindRowSpec("Backwards:", "move_codes", 1), - RebindRowSpec("Turn left:", "move_codes", 2), - RebindRowSpec("Turn right:", "move_codes", 3), + RebindRowSpec("Forward:", RebindTarget.PLAYER_MOVE_CODES, 0), + RebindRowSpec("Backwards:", RebindTarget.PLAYER_MOVE_CODES, 1), + RebindRowSpec("Turn left:", RebindTarget.PLAYER_MOVE_CODES, 2), + RebindRowSpec("Turn right:", RebindTarget.PLAYER_MOVE_CODES, 3), ), ) elif move_mode is MovementControlType.DUAL_ACTION_PAD: move_rows.extend( ( - RebindRowSpec("Up/Down Axis:", "move_axis_codes", 0, axis=True), - RebindRowSpec("Left/Right Axis:", "move_axis_codes", 1, axis=True), + RebindRowSpec("Up/Down Axis:", RebindTarget.PLAYER_MOVE_AXIS_CODES, 0, axis=True), + RebindRowSpec("Left/Right Axis:", RebindTarget.PLAYER_MOVE_AXIS_CODES, 1, axis=True), ), ) elif move_mode is MovementControlType.MOUSE_POINT_CLICK: - move_rows.append(RebindRowSpec("Move to cursor:", "reload_code", controls_field=True)) + move_rows.append(RebindRowSpec("Move to cursor:", RebindTarget.GLOBAL_RELOAD_CODE)) if int(player_index) == 0: - misc_rows.append(RebindRowSpec("Level Up:", "pick_perk_code", controls_field=True)) + misc_rows.append(RebindRowSpec("Level Up:", RebindTarget.GLOBAL_PICK_PERK_CODE)) if move_mode is not MovementControlType.MOUSE_POINT_CLICK: - misc_rows.append(RebindRowSpec("Reload:", "reload_code", controls_field=True)) + misc_rows.append(RebindRowSpec("Reload:", RebindTarget.GLOBAL_RELOAD_CODE)) return tuple(aim_rows), tuple(move_rows), tuple(misc_rows) diff --git a/tests/ui/test_controls_labels.py b/tests/ui/test_controls_labels.py index 2a3385e87..92b084cb3 100644 --- a/tests/ui/test_controls_labels.py +++ b/tests/ui/test_controls_labels.py @@ -6,6 +6,7 @@ from crimson.movement_controls import MovementControlType from crimson.screens.panels.controls_labels import ( RebindRowSpec, + RebindTarget, controls_aim_method_dropdown_ids, controls_method_labels, controls_rebind_plan, @@ -90,19 +91,19 @@ def test_controls_rebind_plan_keyboard_static_player1() -> None: player_index=0, ) assert aim_rows == ( - RebindRowSpec("Torso left:", "keyboard_aim_codes", 0), - RebindRowSpec("Torso right:", "keyboard_aim_codes", 1), - RebindRowSpec("Fire:", "fire_code"), + RebindRowSpec("Torso left:", RebindTarget.PLAYER_KEYBOARD_AIM_CODES, 0), + RebindRowSpec("Torso right:", RebindTarget.PLAYER_KEYBOARD_AIM_CODES, 1), + RebindRowSpec("Fire:", RebindTarget.PLAYER_FIRE_CODE), ) assert move_rows == ( - RebindRowSpec("Move Up:", "move_codes", 0), - RebindRowSpec("Move Down:", "move_codes", 1), - RebindRowSpec("Move Left:", "move_codes", 2), - RebindRowSpec("Move Right:", "move_codes", 3), + RebindRowSpec("Move Up:", RebindTarget.PLAYER_MOVE_CODES, 0), + RebindRowSpec("Move Down:", RebindTarget.PLAYER_MOVE_CODES, 1), + RebindRowSpec("Move Left:", RebindTarget.PLAYER_MOVE_CODES, 2), + RebindRowSpec("Move Right:", RebindTarget.PLAYER_MOVE_CODES, 3), ) assert misc_rows == ( - RebindRowSpec("Level Up:", "pick_perk_code", controls_field=True), - RebindRowSpec("Reload:", "reload_code", controls_field=True), + RebindRowSpec("Level Up:", RebindTarget.GLOBAL_PICK_PERK_CODE), + RebindRowSpec("Reload:", RebindTarget.GLOBAL_RELOAD_CODE), ) @@ -113,9 +114,9 @@ def test_controls_rebind_plan_dualpad_mouse_cursor_player2() -> None: player_index=1, ) assert aim_rows == ( - RebindRowSpec("Aim Up/Down Axis:", "aim_axis_codes", 0, axis=True), - RebindRowSpec("Aim Left/Right Axis:", "aim_axis_codes", 1, axis=True), - RebindRowSpec("Fire:", "fire_code"), + RebindRowSpec("Aim Up/Down Axis:", RebindTarget.PLAYER_AIM_AXIS_CODES, 0, axis=True), + RebindRowSpec("Aim Left/Right Axis:", RebindTarget.PLAYER_AIM_AXIS_CODES, 1, axis=True), + RebindRowSpec("Fire:", RebindTarget.PLAYER_FIRE_CODE), ) - assert move_rows == (RebindRowSpec("Move to cursor:", "reload_code", controls_field=True),) + assert move_rows == (RebindRowSpec("Move to cursor:", RebindTarget.GLOBAL_RELOAD_CODE),) assert misc_rows == () diff --git a/zensical.toml b/zensical.toml index 87f52ebcc..605fbb478 100644 --- a/zensical.toml +++ b/zensical.toml @@ -41,6 +41,7 @@ nav = [ { "Module map" = "rewrite/module-map.md" }, { "Mode systems" = "rewrite/mode-systems.md" }, { "Perks architecture" = "rewrite/perks-architecture.md" }, + { "Keybind flow" = "rewrite/keybind-flow.md" }, ]}, { "Contracts" = [ { "Overview" = "rewrite/contracts/index.md" }, From 64b7a12a60e876d79e771cf9d75088428f2d4a1e Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Fri, 13 Mar 2026 23:17:18 +0400 Subject: [PATCH 12/15] docs: remove keybind flow notes --- docs/rewrite/index.md | 1 - docs/rewrite/keybind-flow.d2 | 138 ------------------ docs/rewrite/keybind-flow.md | 273 ----------------------------------- zensical.toml | 1 - 4 files changed, 413 deletions(-) delete mode 100644 docs/rewrite/keybind-flow.d2 delete mode 100644 docs/rewrite/keybind-flow.md diff --git a/docs/rewrite/index.md b/docs/rewrite/index.md index 47e1bc7d5..7ab3501c4 100644 --- a/docs/rewrite/index.md +++ b/docs/rewrite/index.md @@ -128,7 +128,6 @@ See also: - [CDT trace format (rewrite tooling)](cdt-trace-format.md) - [Terrain (rewrite)](terrain.md) - [Perks architecture (rewrite)](perks-architecture.md) -- [Keybind flow](keybind-flow.md) - [Original bugs (and rewrite fixes)](original-bugs.md) ## Known gaps (short list) diff --git a/docs/rewrite/keybind-flow.d2 b/docs/rewrite/keybind-flow.d2 deleted file mode 100644 index 8d8e8ad04..000000000 --- a/docs/rewrite/keybind-flow.d2 +++ /dev/null @@ -1,138 +0,0 @@ -direction: right - -cfg_blob: { - shape: document - label: "crimson.cfg\n0x480-byte blob" -} - -wire: { - label: "Wire Boundary" - - construct: { - label: "CRIMSON_CFG_STRUCT\nConstruct layout" - } - - raw_blocks: { - label: "Raw bind blocks\nP1/P2 in original keybinds region\nP3/P4 in reserved-gap extension" - } - - decode: { - label: "decode_crimson_cfg()" - } - - encode: { - label: "encode_crimson_cfg()" - } - - defaults: { - label: "Canonical defaults\nunused bind slots\nreserved bytes\nP3/P4 extension" - } -} - -model: { - label: "Semantic Config Model" - - cfg: { - label: "CrimsonConfig" - } - - controls: { - label: "CrimsonControlsConfig\nplayers[4]\npick_perk_code\nreload_code" - } - - player_controls: { - label: "CrimsonPlayerControls\nmovement\naim_scheme\nshow_direction_arrow\nmove_codes[4]\nfire_code\nkeyboard_aim_codes[2]\naim_axis_codes[2]\nmove_axis_codes[2]" - } -} - -ui: { - label: "Controls Menu" - - labels: { - label: "controls_rebind_plan()\nchooses visible rows\nfrom aim_scheme + movement" - } - - rowspec: { - label: "RebindRowSpec\nlabel\nfield_name\nfield_index?\ncontrols_field?\naxis?" - } - - reflection: { - label: "_row_binding_code()\n_set_row_binding_code()\ngetattr/setattr" - } - - capture: { - label: "capture_first_pressed_input_code()" - } -} - -runtime: { - label: "Runtime Consumers" - - local_input: { - label: "LocalInputInterpreter\nbuild_player_input()\nbuild_frame_inputs()" - } - - perk_prompt: { - label: "PerkPromptController\nreads fire_code + pick_perk_code" - } - - tutorial: { - label: "TutorialMode\nreads move_codes + fire_code + reload_code" - } - - text_input: { - label: "text_input.gameplay_controls_held()\nreads first 5 gameplay controls" - } -} - -translator: { - label: "Input Translator" - - input_codes: { - label: "input_codes.py\nGrim input code -> raylib polling" - } - - raylib: { - label: "raylib\nkeyboard\nmouse\ngamepad\naxes" - } -} - -cfg_blob -> wire.construct: "parse/build" -wire.construct -> wire.raw_blocks: "typed raw fields" -wire.raw_blocks -> wire.decode: "read" -wire.defaults -> wire.decode: "zero-block backfill" -wire.decode -> model.cfg: "build semantic model" - -model.cfg -> model.controls -model.controls -> model.player_controls - -model.cfg -> wire.encode: "save()" -wire.defaults -> wire.encode: "canonicalize" -wire.encode -> wire.construct: "build raw dict" -wire.construct -> cfg_blob: "write blob" - -model.controls -> ui.labels: "current movement + aim scheme" -ui.labels -> ui.rowspec: "row plan" -ui.rowspec -> ui.reflection: "field_name / field_index" -model.controls -> ui.reflection: "controls or player object" -ui.reflection -> model.controls: "read/write selected field" -ui.capture -> ui.reflection: "captured Grim code" - -model.controls -> runtime.local_input: "grouped player controls" -model.controls -> runtime.perk_prompt: "fire_code / pick_perk_code" -model.controls -> runtime.tutorial: "move_codes / fire_code / reload_code" -model.controls -> runtime.text_input: "move_codes / fire_code" - -runtime.local_input -> translator.input_codes: "poll bindings" -runtime.perk_prompt -> translator.input_codes: "primary/open checks" -runtime.tutorial -> translator.input_codes: "poll bindings" -runtime.text_input -> translator.input_codes: "held checks" -ui.capture -> translator.input_codes: "capture input code" - -translator.input_codes -> translator.raylib: "query backend state" - -notes: { - label: "Why getattr/setattr feels bad\n\nIt is a tiny reflection layer used only by the controls menu.\nThe row spec stores field names as strings like \"move_codes\" or \"reload_code\".\ncontrols.py then uses getattr()/setattr() to read or update that field on either:\n- CrimsonControlsConfig, or\n- CrimsonPlayerControls\n\nThis keeps slot numbers out of the UI, but it is still stringly typed:\n- typos fail at runtime\n- refactors are less safe\n- type checkers cannot follow the field access" -} - -ui.reflection -> notes: "current weak spot" diff --git a/docs/rewrite/keybind-flow.md b/docs/rewrite/keybind-flow.md deleted file mode 100644 index 0b41293c5..000000000 --- a/docs/rewrite/keybind-flow.md +++ /dev/null @@ -1,273 +0,0 @@ ---- -tags: - - rewrite - - controls ---- - -# Keybind Flow - -This note describes the current keybind flow in the Python port after the -config-model cleanup. - -The important conclusions are: - -- `crimson.cfg` stores original Grim input codes, not raylib enums. -- `src/crimson/input_codes.py` is the only Grim-code to raylib translator. -- `src/grim/config.py` owns the wire-layout bridge and default/canonical write - policy. -- Runtime code reads grouped semantic controls directly. -- The controls menu owns the only remaining UI-specific rebind mapping. - -The companion structure diagram lives in `keybind-flow.d2`. - -## Code Domains - -There are four domains involved: - -1. Wire layout in `src/grim/config.py` - - fixed `crimson.cfg` blob - - original field order and offsets - -2. Semantic config model in `src/grim/config.py` - - `CrimsonControlsConfig` - - `CrimsonPlayerControls` - - grouped Grim input codes such as `move_codes`, `fire_code`, - `keyboard_aim_codes`, `aim_axis_codes`, and `move_axis_codes` - -3. Original Grim input-code domain in `src/crimson/input_codes.py` - - keyboard DIK-style codes below `0x100` - - mouse buttons at `0x100+` - - joystick buttons, POV directions, and axes in the `0x11f+` range - -4. Backend input domain in raylib - - `rl.KeyboardKey` - - `rl.MouseButton` - - `rl.GamepadButton` - - `rl.GamepadAxis` - -The config model should stay entirely in domains 1 and 2. -The backend translator should stay entirely in domains 3 and 4. - -## What Is Stored On Disk - -The wire layout is defined by `CRIMSON_CFG_STRUCT` in `src/grim/config.py`. - -For controls: - -- P1/P2 live in `keybinds_p1_p2` -- P3/P4 live in `extended_keybinds_p3_p4` -- the P3/P4 blocks are our port extension inside the original reserved gap - -Each raw per-player bind block is represented in the wire schema as: - -- `move_forward` -- `move_backward` -- `turn_left` -- `turn_right` -- `fire` -- `reserved_keys[2]` -- `aim_left` -- `aim_right` -- `axis_aim_y` -- `axis_aim_x` -- `axis_move_y` -- `axis_move_x` -- `padding[3]` - -That shape is good for the wire codec because it stays close to the original -layout and reverse-engineering field names. - -## What The Semantic Model Stores - -`decode_crimson_cfg(...)` maps each raw bind block into `CrimsonPlayerControls`. - -That semantic model stores only the bindings the port actually uses: - -- `movement` -- `aim_scheme` -- `show_direction_arrow` -- `move_codes` -- `fire_code` -- `keyboard_aim_codes` -- `aim_axis_codes` -- `move_axis_codes` - -`CrimsonControlsConfig` then adds: - -- `players[4]` -- `pick_perk_code` -- `reload_code` - -This is the current stable semantic boundary. - -It is intentionally not a generic 16-slot tuple API, but it also does not try to -invent fake higher-level meanings for all raw slots. Unused raw slots remain a -wire concern only and are written canonically from defaults. - -## How The Controls Menu Works - -The controls menu no longer works in raw slot ids or string field names. - -`controls_rebind_plan(...)` in `src/crimson/screens/panels/controls_labels.py` -returns `RebindRowSpec` values that contain: - -- a visible label -- a typed `RebindTarget` -- an optional tuple index -- an `axis` flag - -The target describes exactly what is being edited: - -- `PLAYER_MOVE_CODES` -- `PLAYER_FIRE_CODE` -- `PLAYER_KEYBOARD_AIM_CODES` -- `PLAYER_AIM_AXIS_CODES` -- `PLAYER_MOVE_AXIS_CODES` -- `GLOBAL_PICK_PERK_CODE` -- `GLOBAL_RELOAD_CODE` - -`ControlsMenuView` in `src/crimson/screens/panels/controls.py` then: - -1. builds the visible row plan from `movement` and `aim_scheme` -2. reads the selected value through an explicit `match` on `RebindTarget` -3. captures a new Grim input code via `capture_first_pressed_input_code(...)` -4. writes the updated Grim code back through the same typed target -5. calls `config.save()` on close - -Important point: - -- rebinding stores original Grim-style codes in config -- rebinding does not store raylib enums - -That is the correct boundary. - -## Runtime Input Flow - -The main gameplay consumer is `LocalInputInterpreter` in -`src/crimson/local_input.py`. - -Its input path is: - -1. read grouped per-player controls from config -2. select which codes matter for the current `movement` and `aim_scheme` -3. pass those Grim codes to `input_code_is_down(...)`, - `input_code_is_pressed(...)`, or `input_axis_value(...)` -4. let `src/crimson/input_codes.py` translate them to raylib polling -5. build `PlayerInput` - -The movement and aim schemes decide how the grouped binds are interpreted: - -- `MovementControlType.STATIC` - - uses `move_codes` as Up, Down, Left, Right - -- `MovementControlType.RELATIVE` - - uses the same `move_codes` as Forward, Backward, Turn left, Turn right - -- `MovementControlType.DUAL_ACTION_PAD` - - uses `move_axis_codes` - -- `MovementControlType.MOUSE_POINT_CLICK` - - uses global `reload_code` as the "move to cursor" trigger - -- `AimScheme.KEYBOARD` - - uses `keyboard_aim_codes` - -- `AimScheme.DUAL_ACTION_PAD` - - uses `aim_axis_codes` - -- `AimScheme.JOYSTICK` - - does not use stored keyboard aim binds - - uses hardcoded POV input codes `0x133` / `0x134` - -- `AimScheme.MOUSE` - - aims directly from mouse world position - -- `AimScheme.MOUSE_RELATIVE` - - aims from mouse delta relative to screen center - -- `AimScheme.COMPUTER` - - ignores player aim binds and computes aim internally - -This is why grouped semantic fields are a better fit than per-slot fake names -like `move_forward_code` or a generic 16-slot tuple. - -## Other Keybind Consumers - -Outside `LocalInputInterpreter`, there are only a few narrow consumers: - -- `src/crimson/modes/tutorial_mode.py` - - directly reads `move_codes`, `fire_code`, and `reload_code` - -- `src/crimson/modes/components/perk_prompt_controller.py` - - directly reads player fire codes plus `pick_perk_code` - -- `src/crimson/ui/text_input.py` - - checks the first five gameplay controls: the movement quartet plus `fire` - -These direct reads are simple and do not justify a generic config-keybind helper -layer. - -## Grim Code To Raylib Translation - -`src/crimson/input_codes.py` is the real translation layer. - -It contains: - -- keyboard DIK-style mapping via `_DIK_TO_RL_KEY` -- mouse-button mapping via `_MOUSE_CODE_TO_BUTTON` -- joystick button mapping via `_JOYS_BUTTON_CODES` -- axis mapping via `_AXIS_CODE_TO_AXIS` -- older RIM-device mappings via `_RIM_AXIS_CODES` and `_RIM_BUTTON_CODES` - -Two directions matter: - -1. Stored config code -> raylib poll - - `input_code_is_down(...)` - - `input_code_is_pressed(...)` - - `input_axis_value(...)` - -2. Raylib event -> stored config code - - `capture_first_pressed_input_code(...)` - -That file should remain the only place that knows how a stored Grim code maps -onto current backend input. - -## Defaults And Canonicalization - -There are two important config-side policies: - -1. Decode-time backfill for zeroed bind blocks - - if a raw player bind block is fully zeroed, `decode_crimson_cfg(...)` - substitutes that player's default bindings - -2. Encode-time canonicalization for wire-only fields - - unused raw bind slots are written from canonical per-player defaults - - reserved bytes are written canonically by the config bridge - -Runtime code does not have a `config is None` fallback anymore. -Gameplay code is expected to receive a real `CrimsonConfig`. - -## Current Assessment - -The structure is now: - -- storage is original Grim layout -- semantic config stores grouped Grim input codes -- the controls menu edits those grouped controls through typed UI-local targets -- runtime translation is original Grim code -> raylib -- runtime/gameplay code reads grouped controls directly - -That is the right mental model. - -The remaining complexity is mostly inherent: - -- the wire format is old and irregular -- movement and aim schemes reinterpret the same stored control groups -- the controls menu still has UI-specific row planning logic - -What should not come back is: - -- a generic 16-slot keybind API -- config-aware helper wrappers in `input_codes.py` -- stringly typed rebind field access -- a second runtime cache layer for bindings diff --git a/zensical.toml b/zensical.toml index 605fbb478..87f52ebcc 100644 --- a/zensical.toml +++ b/zensical.toml @@ -41,7 +41,6 @@ nav = [ { "Module map" = "rewrite/module-map.md" }, { "Mode systems" = "rewrite/mode-systems.md" }, { "Perks architecture" = "rewrite/perks-architecture.md" }, - { "Keybind flow" = "rewrite/keybind-flow.md" }, ]}, { "Contracts" = [ { "Overview" = "rewrite/contracts/index.md" }, From 8146c60a3f7490f0fba65286dcb417f76f9707bf Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Fri, 13 Mar 2026 23:34:31 +0400 Subject: [PATCH 13/15] refactor: collapse config bind block bridge --- src/grim/config.py | 222 ++++++++++++++++++++------------------------- 1 file changed, 97 insertions(+), 125 deletions(-) diff --git a/src/grim/config.py b/src/grim/config.py index cf45cf36a..52607b745 100644 --- a/src/grim/config.py +++ b/src/grim/config.py @@ -3,7 +3,7 @@ from collections.abc import Sequence from enum import IntEnum from pathlib import Path -from typing import TypedDict, cast +from typing import cast import msgspec from construct import Array, Byte, Bytes, Float32l, Int32sl, Struct @@ -31,6 +31,8 @@ KEYBIND_UNBOUND_CODE = 0x17E RESERVED_KEYBIND_SLOT_COUNT = 2 PADDING_KEYBIND_SLOT_COUNT = 3 +_DEFAULT_WIRE_RESERVED_KEYS = (KEYBIND_UNBOUND_CODE, KEYBIND_UNBOUND_CODE) +_DEFAULT_WIRE_PADDING = (KEYBIND_UNBOUND_CODE, KEYBIND_UNBOUND_CODE, KEYBIND_UNBOUND_CODE) PLAYER_BIND_BLOCK_STRUCT = Struct( "move_forward" / Int32sl, @@ -48,52 +50,6 @@ "padding" / Array(PADDING_KEYBIND_SLOT_COUNT, Int32sl), ) - -class _RawPlayerBindBlock(msgspec.Struct, frozen=True): - move_codes: tuple[int, int, int, int] - fire_code: int - reserved_keys: tuple[int, int] - keyboard_aim_codes: tuple[int, int] - aim_axis_codes: tuple[int, int] - move_axis_codes: tuple[int, int] - padding: tuple[int, int, int] - - -class _ParsedPlayerBindBlockDict(TypedDict): - move_forward: int - move_backward: int - turn_left: int - turn_right: int - fire: int - reserved_keys: list[int] - aim_left: int - aim_right: int - axis_aim_y: int - axis_aim_x: int - axis_move_y: int - axis_move_x: int - padding: list[int] - - -def _parsed_player_bind_block(raw_block: object) -> _RawPlayerBindBlock: - block = cast(_ParsedPlayerBindBlockDict, raw_block) - reserved_keys = block["reserved_keys"] - padding = block["padding"] - return _RawPlayerBindBlock( - move_codes=( - block["move_forward"], - block["move_backward"], - block["turn_left"], - block["turn_right"], - ), - fire_code=block["fire"], - reserved_keys=(reserved_keys[0], reserved_keys[1]), - keyboard_aim_codes=(block["aim_left"], block["aim_right"]), - aim_axis_codes=(block["axis_aim_y"], block["axis_aim_x"]), - move_axis_codes=(block["axis_move_y"], block["axis_move_x"]), - padding=(padding[0], padding[1], padding[2]), - ) - CRIMSON_CFG_STRUCT = Struct( "sound_disable" / Byte, "music_disable" / Byte, @@ -162,45 +118,6 @@ def _parsed_player_bind_block(raw_block: object) -> _RawPlayerBindBlock: "keybind_reload" / Int32sl, ) -_DEFAULT_PLAYER_BIND_BLOCKS: tuple[_RawPlayerBindBlock, ...] = ( - _RawPlayerBindBlock( - move_codes=(0x11, 0x1F, 0x1E, 0x20), - fire_code=0x100, - reserved_keys=(0x17E, 0x17E), - keyboard_aim_codes=(0x10, 0x12), - aim_axis_codes=(0x13F, 0x140), - move_axis_codes=(0x141, 0x153), - padding=(0x17E, 0x17E, 0x17E), - ), - _RawPlayerBindBlock( - move_codes=(0xC8, 0xD0, 0xCB, 0xCD), - fire_code=0x9D, - reserved_keys=(0x17E, 0x17E), - keyboard_aim_codes=(0xD3, 0xD1), - aim_axis_codes=(0x13F, 0x140), - move_axis_codes=(0x141, 0x153), - padding=(0x17E, 0x17E, 0x17E), - ), - _RawPlayerBindBlock( - move_codes=(0x17, 0x25, 0x24, 0x26), - fire_code=0x36, - reserved_keys=(0x17E, 0x17E), - keyboard_aim_codes=(0x16, 0x18), - aim_axis_codes=(0x17E, 0x17E), - move_axis_codes=(0x17E, 0x17E), - padding=(0x17E, 0x17E, 0x17E), - ), - _RawPlayerBindBlock( - move_codes=(0x131, 0x132, 0x133, 0x134), - fire_code=0x11F, - reserved_keys=(0x17E, 0x17E), - keyboard_aim_codes=(0x17E, 0x17E), - aim_axis_codes=(0x140, 0x13F), - move_axis_codes=(0x153, 0x154), - padding=(0x17E, 0x17E, 0x17E), - ), -) - _DEFAULT_PROFILE_NAME = "10tons" _DEFAULT_SAVED_NAMES: tuple[str, str, str, str, str, str, str, str] = ( "default", @@ -306,6 +223,50 @@ class CrimsonPlayerControls(msgspec.Struct): move_axis_codes: tuple[int, int] +_DEFAULT_PLAYER_CONTROL_TEMPLATES: tuple[CrimsonPlayerControls, ...] = ( + CrimsonPlayerControls( + movement=MovementControlType.STATIC, + aim_scheme=AimScheme.MOUSE, + show_direction_arrow=True, + move_codes=(0x11, 0x1F, 0x1E, 0x20), + fire_code=0x100, + keyboard_aim_codes=(0x10, 0x12), + aim_axis_codes=(0x13F, 0x140), + move_axis_codes=(0x141, 0x153), + ), + CrimsonPlayerControls( + movement=MovementControlType.STATIC, + aim_scheme=AimScheme.MOUSE, + show_direction_arrow=True, + move_codes=(0xC8, 0xD0, 0xCB, 0xCD), + fire_code=0x9D, + keyboard_aim_codes=(0xD3, 0xD1), + aim_axis_codes=(0x13F, 0x140), + move_axis_codes=(0x141, 0x153), + ), + CrimsonPlayerControls( + movement=MovementControlType.STATIC, + aim_scheme=AimScheme.MOUSE, + show_direction_arrow=True, + move_codes=(0x17, 0x25, 0x24, 0x26), + fire_code=0x36, + keyboard_aim_codes=(0x16, 0x18), + aim_axis_codes=(0x17E, 0x17E), + move_axis_codes=(0x17E, 0x17E), + ), + CrimsonPlayerControls( + movement=MovementControlType.STATIC, + aim_scheme=AimScheme.MOUSE, + show_direction_arrow=True, + move_codes=(0x131, 0x132, 0x133, 0x134), + fire_code=0x11F, + keyboard_aim_codes=(0x17E, 0x17E), + aim_axis_codes=(0x140, 0x13F), + move_axis_codes=(0x153, 0x154), + ), +) + + class CrimsonControlsConfig(msgspec.Struct): players: tuple[CrimsonPlayerControls, CrimsonPlayerControls, CrimsonPlayerControls, CrimsonPlayerControls] pick_perk_code: int @@ -340,49 +301,55 @@ def _require_range(value: int, *, minimum: int, maximum: int, field: str) -> int return value -def _raw_player_bind_block_is_uninitialized(raw_block: _RawPlayerBindBlock) -> bool: - if raw_block.move_codes[0] != 0: +def _parsed_player_bind_block(raw: dict[str, object], *, player_index: int) -> dict[str, object]: + idx = _player_index(player_index) + if idx < 2: + return cast(dict[str, object], cast(list[object], raw["keybinds_p1_p2"])[idx]) + return cast(dict[str, object], cast(list[object], raw["extended_keybinds_p3_p4"])[idx - 2]) + + +def _parsed_bind_int(raw_block: dict[str, object], field: str) -> int: + return int(cast(int, raw_block[field])) + + +def _parsed_bind_pair(raw_block: dict[str, object], field: str) -> tuple[int, int]: + values = cast(list[object], raw_block[field]) + return int(cast(int, values[0])), int(cast(int, values[1])) + + +def _parsed_player_bind_block_is_uninitialized(raw_block: dict[str, object]) -> bool: + if _parsed_bind_int(raw_block, "move_forward") != 0: return False - if raw_block.move_codes[1] != 0: + if _parsed_bind_int(raw_block, "move_backward") != 0: return False - if raw_block.move_codes[2] != 0: + if _parsed_bind_int(raw_block, "turn_left") != 0: return False - if raw_block.move_codes[3] != 0: + if _parsed_bind_int(raw_block, "turn_right") != 0: return False - if raw_block.fire_code != 0: + if _parsed_bind_int(raw_block, "fire") != 0: return False - if raw_block.reserved_keys[0] != 0 or raw_block.reserved_keys[1] != 0: + reserved_keys = _parsed_bind_pair(raw_block, "reserved_keys") + if reserved_keys[0] != 0 or reserved_keys[1] != 0: return False - if raw_block.keyboard_aim_codes[0] != 0 or raw_block.keyboard_aim_codes[1] != 0: + if _parsed_bind_int(raw_block, "aim_left") != 0 or _parsed_bind_int(raw_block, "aim_right") != 0: return False - if raw_block.aim_axis_codes[0] != 0 or raw_block.aim_axis_codes[1] != 0: + if _parsed_bind_int(raw_block, "axis_aim_y") != 0 or _parsed_bind_int(raw_block, "axis_aim_x") != 0: return False - if raw_block.move_axis_codes[0] != 0 or raw_block.move_axis_codes[1] != 0: + if _parsed_bind_int(raw_block, "axis_move_y") != 0 or _parsed_bind_int(raw_block, "axis_move_x") != 0: return False return True -def _default_player_bind_block(player_index: int) -> _RawPlayerBindBlock: - return _DEFAULT_PLAYER_BIND_BLOCKS[_player_index(player_index)] - - -def _raw_player_bind_block(raw: dict[str, object], *, player_index: int) -> _RawPlayerBindBlock: - idx = _player_index(player_index) - if idx < 2: - return _parsed_player_bind_block(cast(list[object], raw["keybinds_p1_p2"])[idx]) - return _parsed_player_bind_block(cast(list[object], raw["extended_keybinds_p3_p4"])[idx - 2]) - - -def _player_controls_from_raw_bind_block( - raw_block: _RawPlayerBindBlock, +def _player_controls_from_parsed_bind_block( + raw_block: dict[str, object], *, player_index: int, movement: MovementControlType, aim_scheme: AimScheme, show_direction_arrow: bool, ) -> CrimsonPlayerControls: - if _raw_player_bind_block_is_uninitialized(raw_block): - defaults = _default_player_bind_block(player_index) + if _parsed_player_bind_block_is_uninitialized(raw_block): + defaults = _default_player_controls(player_index) return CrimsonPlayerControls( movement=movement, aim_scheme=aim_scheme, @@ -397,30 +364,35 @@ def _player_controls_from_raw_bind_block( movement=movement, aim_scheme=aim_scheme, show_direction_arrow=show_direction_arrow, - move_codes=raw_block.move_codes, - fire_code=raw_block.fire_code, - keyboard_aim_codes=raw_block.keyboard_aim_codes, - aim_axis_codes=raw_block.aim_axis_codes, - move_axis_codes=raw_block.move_axis_codes, + move_codes=( + _parsed_bind_int(raw_block, "move_forward"), + _parsed_bind_int(raw_block, "move_backward"), + _parsed_bind_int(raw_block, "turn_left"), + _parsed_bind_int(raw_block, "turn_right"), + ), + fire_code=_parsed_bind_int(raw_block, "fire"), + keyboard_aim_codes=(_parsed_bind_int(raw_block, "aim_left"), _parsed_bind_int(raw_block, "aim_right")), + aim_axis_codes=(_parsed_bind_int(raw_block, "axis_aim_y"), _parsed_bind_int(raw_block, "axis_aim_x")), + move_axis_codes=(_parsed_bind_int(raw_block, "axis_move_y"), _parsed_bind_int(raw_block, "axis_move_x")), ) def _encode_player_bind_block(player: CrimsonPlayerControls, *, player_index: int) -> dict[str, object]: - defaults = _default_player_bind_block(player_index) + _ = player_index return { "move_forward": player.move_codes[0], "move_backward": player.move_codes[1], "turn_left": player.move_codes[2], "turn_right": player.move_codes[3], "fire": player.fire_code, - "reserved_keys": [defaults.reserved_keys[0], defaults.reserved_keys[1]], + "reserved_keys": [int(_DEFAULT_WIRE_RESERVED_KEYS[0]), int(_DEFAULT_WIRE_RESERVED_KEYS[1])], "aim_left": player.keyboard_aim_codes[0], "aim_right": player.keyboard_aim_codes[1], "axis_aim_y": player.aim_axis_codes[0], "axis_aim_x": player.aim_axis_codes[1], "axis_move_y": player.move_axis_codes[0], "axis_move_x": player.move_axis_codes[1], - "padding": [defaults.padding[0], defaults.padding[1], defaults.padding[2]], + "padding": [int(_DEFAULT_WIRE_PADDING[0]), int(_DEFAULT_WIRE_PADDING[1]), int(_DEFAULT_WIRE_PADDING[2])], } @@ -512,11 +484,11 @@ def _saved_name_order_values() -> tuple[int, ...]: def _default_player_controls(player_index: int) -> CrimsonPlayerControls: - defaults = _default_player_bind_block(player_index) + defaults = _DEFAULT_PLAYER_CONTROL_TEMPLATES[_player_index(player_index)] return CrimsonPlayerControls( - movement=MovementControlType.STATIC, - aim_scheme=AimScheme.MOUSE, - show_direction_arrow=True, + movement=defaults.movement, + aim_scheme=defaults.aim_scheme, + show_direction_arrow=defaults.show_direction_arrow, move_codes=defaults.move_codes, fire_code=defaults.fire_code, keyboard_aim_codes=defaults.keyboard_aim_codes, @@ -595,8 +567,8 @@ def decode_crimson_cfg(path: Path, blob: bytes) -> CrimsonConfig: detail_preset = _require_range(detail_preset, minimum=1, maximum=5, field="detail_preset") players = tuple( - _player_controls_from_raw_bind_block( - _raw_player_bind_block(raw, player_index=idx), + _player_controls_from_parsed_bind_block( + _parsed_player_bind_block(raw, player_index=idx), player_index=idx, movement=_decode_movement(raw["player_mode_flags"][idx]), aim_scheme=_decode_aim_scheme(raw["aim_schemes"][idx]), From 1c3fb61c8fb9bf39e7ade4f4574cffec492d0f9a Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Fri, 13 Mar 2026 23:37:28 +0400 Subject: [PATCH 14/15] refactor: simplify config zero-bind detection --- src/grim/config.py | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/src/grim/config.py b/src/grim/config.py index 52607b745..4a3fbf052 100644 --- a/src/grim/config.py +++ b/src/grim/config.py @@ -318,26 +318,22 @@ def _parsed_bind_pair(raw_block: dict[str, object], field: str) -> tuple[int, in def _parsed_player_bind_block_is_uninitialized(raw_block: dict[str, object]) -> bool: - if _parsed_bind_int(raw_block, "move_forward") != 0: - return False - if _parsed_bind_int(raw_block, "move_backward") != 0: - return False - if _parsed_bind_int(raw_block, "turn_left") != 0: - return False - if _parsed_bind_int(raw_block, "turn_right") != 0: - return False - if _parsed_bind_int(raw_block, "fire") != 0: - return False - reserved_keys = _parsed_bind_pair(raw_block, "reserved_keys") - if reserved_keys[0] != 0 or reserved_keys[1] != 0: - return False - if _parsed_bind_int(raw_block, "aim_left") != 0 or _parsed_bind_int(raw_block, "aim_right") != 0: - return False - if _parsed_bind_int(raw_block, "axis_aim_y") != 0 or _parsed_bind_int(raw_block, "axis_aim_x") != 0: - return False - if _parsed_bind_int(raw_block, "axis_move_y") != 0 or _parsed_bind_int(raw_block, "axis_move_x") != 0: - return False - return True + return not any( + ( + _parsed_bind_int(raw_block, "move_forward"), + _parsed_bind_int(raw_block, "move_backward"), + _parsed_bind_int(raw_block, "turn_left"), + _parsed_bind_int(raw_block, "turn_right"), + _parsed_bind_int(raw_block, "fire"), + *_parsed_bind_pair(raw_block, "reserved_keys"), + _parsed_bind_int(raw_block, "aim_left"), + _parsed_bind_int(raw_block, "aim_right"), + _parsed_bind_int(raw_block, "axis_aim_y"), + _parsed_bind_int(raw_block, "axis_aim_x"), + _parsed_bind_int(raw_block, "axis_move_y"), + _parsed_bind_int(raw_block, "axis_move_x"), + ), + ) def _player_controls_from_parsed_bind_block( From d4be5f1bf34748b6a7f4eacd0de3650de0f3de41 Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Fri, 13 Mar 2026 23:42:14 +0400 Subject: [PATCH 15/15] refactor: simplify config parse boundary typing --- src/grim/config.py | 61 ++++++++++++++++++++-------------------------- 1 file changed, 26 insertions(+), 35 deletions(-) diff --git a/src/grim/config.py b/src/grim/config.py index 4a3fbf052..63f4b111b 100644 --- a/src/grim/config.py +++ b/src/grim/config.py @@ -3,7 +3,7 @@ from collections.abc import Sequence from enum import IntEnum from pathlib import Path -from typing import cast +from typing import Any import msgspec from construct import Array, Byte, Bytes, Float32l, Int32sl, Struct @@ -301,43 +301,34 @@ def _require_range(value: int, *, minimum: int, maximum: int, field: str) -> int return value -def _parsed_player_bind_block(raw: dict[str, object], *, player_index: int) -> dict[str, object]: +def _parsed_player_bind_block(raw: dict[str, Any], *, player_index: int) -> dict[str, Any]: idx = _player_index(player_index) if idx < 2: - return cast(dict[str, object], cast(list[object], raw["keybinds_p1_p2"])[idx]) - return cast(dict[str, object], cast(list[object], raw["extended_keybinds_p3_p4"])[idx - 2]) + return raw["keybinds_p1_p2"][idx] + return raw["extended_keybinds_p3_p4"][idx - 2] -def _parsed_bind_int(raw_block: dict[str, object], field: str) -> int: - return int(cast(int, raw_block[field])) - - -def _parsed_bind_pair(raw_block: dict[str, object], field: str) -> tuple[int, int]: - values = cast(list[object], raw_block[field]) - return int(cast(int, values[0])), int(cast(int, values[1])) - - -def _parsed_player_bind_block_is_uninitialized(raw_block: dict[str, object]) -> bool: +def _parsed_player_bind_block_is_uninitialized(raw_block: dict[str, Any]) -> bool: return not any( ( - _parsed_bind_int(raw_block, "move_forward"), - _parsed_bind_int(raw_block, "move_backward"), - _parsed_bind_int(raw_block, "turn_left"), - _parsed_bind_int(raw_block, "turn_right"), - _parsed_bind_int(raw_block, "fire"), - *_parsed_bind_pair(raw_block, "reserved_keys"), - _parsed_bind_int(raw_block, "aim_left"), - _parsed_bind_int(raw_block, "aim_right"), - _parsed_bind_int(raw_block, "axis_aim_y"), - _parsed_bind_int(raw_block, "axis_aim_x"), - _parsed_bind_int(raw_block, "axis_move_y"), - _parsed_bind_int(raw_block, "axis_move_x"), + raw_block["move_forward"], + raw_block["move_backward"], + raw_block["turn_left"], + raw_block["turn_right"], + raw_block["fire"], + *raw_block["reserved_keys"], + raw_block["aim_left"], + raw_block["aim_right"], + raw_block["axis_aim_y"], + raw_block["axis_aim_x"], + raw_block["axis_move_y"], + raw_block["axis_move_x"], ), ) def _player_controls_from_parsed_bind_block( - raw_block: dict[str, object], + raw_block: dict[str, Any], *, player_index: int, movement: MovementControlType, @@ -361,15 +352,15 @@ def _player_controls_from_parsed_bind_block( aim_scheme=aim_scheme, show_direction_arrow=show_direction_arrow, move_codes=( - _parsed_bind_int(raw_block, "move_forward"), - _parsed_bind_int(raw_block, "move_backward"), - _parsed_bind_int(raw_block, "turn_left"), - _parsed_bind_int(raw_block, "turn_right"), + raw_block["move_forward"], + raw_block["move_backward"], + raw_block["turn_left"], + raw_block["turn_right"], ), - fire_code=_parsed_bind_int(raw_block, "fire"), - keyboard_aim_codes=(_parsed_bind_int(raw_block, "aim_left"), _parsed_bind_int(raw_block, "aim_right")), - aim_axis_codes=(_parsed_bind_int(raw_block, "axis_aim_y"), _parsed_bind_int(raw_block, "axis_aim_x")), - move_axis_codes=(_parsed_bind_int(raw_block, "axis_move_y"), _parsed_bind_int(raw_block, "axis_move_x")), + fire_code=raw_block["fire"], + keyboard_aim_codes=(raw_block["aim_left"], raw_block["aim_right"]), + aim_axis_codes=(raw_block["axis_aim_y"], raw_block["axis_aim_x"]), + move_axis_codes=(raw_block["axis_move_y"], raw_block["axis_move_x"]), )