From 32ee44e0a2c5f6ca81d9ad4e872f2ef79921ee86 Mon Sep 17 00:00:00 2001 From: Darren Eberly Date: Sat, 27 Apr 2024 02:40:09 -0400 Subject: [PATCH] Initial InputManager for experimental (#2077) * Initial action manager POC * More new InputManager stuff(not finished) * More good input sweetness * Add an example * more controller stuff * New example, more dynamic on_action handling, and better controller/keyboard switching * Mouse support, serialization/parsing of InputManager * Add action handler registration * Some baseline documentation for other devs to read * initial release events * Add positive/negative axes, fixup action/axis removal, move to experimental * Move input_manager example into experimental * Typing fixes(disabled type checking for now) * more typing stuff --- arcade/application.py | 4 + arcade/experimental/input/README.md | 102 ++++ arcade/experimental/input/__init__.py | 6 + arcade/experimental/input/inputs.py | 321 ++++++++++ arcade/experimental/input/manager.py | 609 +++++++++++++++++++ arcade/experimental/input/mapping.py | 164 +++++ arcade/experimental/input_manager_example.py | 156 +++++ 7 files changed, 1362 insertions(+) create mode 100644 arcade/experimental/input/README.md create mode 100644 arcade/experimental/input/__init__.py create mode 100644 arcade/experimental/input/inputs.py create mode 100644 arcade/experimental/input/manager.py create mode 100644 arcade/experimental/input/mapping.py create mode 100644 arcade/experimental/input_manager_example.py diff --git a/arcade/application.py b/arcade/application.py index 8276a76dc..8b2048b7a 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -186,6 +186,7 @@ def __init__( style=style, ) self.register_event_type('on_update') + self.register_event_type('on_action') except pyglet.window.NoSuchConfigException: raise NoOpenGLException("Unable to create an OpenGL 3.3+ context. " "Check to make sure your system supports OpenGL 3.3 or higher.") @@ -536,6 +537,9 @@ def set_mouse_visible(self, visible: bool = True): """ super().set_mouse_visible(visible) + def on_action(self, action_name: str, state): + pass + def on_key_press(self, symbol: int, modifiers: int): """ Called once when a key gets pushed down. diff --git a/arcade/experimental/input/README.md b/arcade/experimental/input/README.md new file mode 100644 index 000000000..ba3219fe8 --- /dev/null +++ b/arcade/experimental/input/README.md @@ -0,0 +1,102 @@ +# Arcade Input Manager + +This is an overview of how to use the new Arcade `InputManager` class. + +## Key Concepts + +- Enums are used heavily. There are new enums in the global Arcade namespace. Namely: + + - arcade.Keys - Keyboard Mappings. Same as original `arcade.keys` module, but now an enum. + - arcade.MouseAxes - Mouse Axis. Contains two values of just `X` and `Y`. + - arcade.MouseButtons - Mappings to the Pyglet mouse button values + - arcade.ControllerButtons - Mappings to pyglet controller button names + - arcade.ControllerAxes - Mappings to pyglet analog controller names + - There are some more enums added within the `input.inputs` module, but they are largely internal + +- `arcade.InputManager` is the primary user-facing class. Almost all interaction between a user and Arcade will happen through this class. + +- Actions - A named action that can be taken, and have inputs mapped to it. For example, an action named "Jump" with the spacebar and the bottom controller face button mapped to it. Users can listen to a new special event for this, or subscribe callbacks to them. + +- Axes - A named axis which can be used for more "constant" input that is intended to be polled instead of event-driven. Generally this feature is derived from analog inputs such as thumbsticks or triggers. These will be explained more below. + +## A simple example + +This example creates an InputManager that is modeled to support a basic platformer game. Supporting side-to-side movement and jumping. + +```py +input_manager = arcade.InputManager() + +input_manager.new_action("Jump") +input_manager.add_action_input("Jump", arcade.Keys.SPACE) +input_manager.add_action_input("Jump", arcade.ControllerButtons.BOTTOM_FACE) + +input_manager.new_axis("Move") +input_manager.add_axis_input("Move", arcade.Keys.LEFT, scale=-1.0) +input_manager.add_axis_input("Move", arcade.Keys.RIGHT, scale=1.0) +input_manager.add_axis_input("Move", arcade.ControllerAxes.LEFT_STICK_X, scale=1.0) +``` + +The jump action here is fairly straightforward, so let's talk about the Move axis we've created here. + +First we register two keyboard keys, LEFT and RIGHT, to it, each with a different scale. When you register a keyboard key to an axis input, the scale value that you set with it will be set literally when it is triggered. + +So in this case, when we press the LEFT keyboard key, the value of our "Move" axis will be -1.0 literally, and for RIGHT it would be 1.0. This functionality is the same keyboard keys, controller buttons, and mouse buttons. + +Conversely, when you register a `ControllerAxes` input to it, in our case the X axis of the left thumbstick. The analog value of that input is polled, and the scale value is multipled to it. So for example, if the value of the input is 0.5, and we had a scale value of 0.5, then the actual value of our "Move" axis will be 0.25. + +## Using the example + +In order to make use of the input manager we setup above, we need to do things, update/poll the axis, and receive an event for the jump action. + +### Actions + +For receiving the jump action we have a few options. One is that there is a global `on_action` function which can be put onto any `arcade.Window`, `arcade.View`, or `arcade.Section`: + +```py +def on_action(self, action: str, state: arcade.ActionState): + # Do something based on action name and state +``` + +The `arcade.ActionState` is an enum which has the values `PRESSED` and `RELEASED`. + +In addition to the global event, you can also add the `on_action` callback explicitly. This means it doesn't need to be on one of the above classes to be handled, as it doesn't go through Pyglet's event system. + +```py +def on_action(self, action: str, state: arcade.ActionState): + # Do something based on action name and state + +# Set it during the constructor. Can pass a single callable here or a list of them +input_manager = arcade.InputManager(action_handlers=self.on_action) + +# Set it after creation. Can also take a single callable or a list here +input_manager.register_action_handler(self.on_action) +``` + +### Axes + +For handling the axis, we first need to make sure we tick the input manager. In the `on_update` function(or via something else that is called every update). The below should be run: + +```py +input_manager.update() +``` + +Assuming the input manager has been ticked, it will have update to values for the various axis input values, and they can be polled by simply doing the below. You can for example poll the value(which in our case is between -1.0 and 1.0) and multiply it by a speed value to get the amount that a character should move.: + +```py +# This returns a float +player.change_x = input_manager.axis("Move") * PLAYER_MOVEMENT_SPEED +``` + +A question you may ask yourself, is if I've registered inputs on the "Move" axis for both the keyboard and the controller, and the user has both devices active, which one will be used? This depends on a few factors: + +If no controller is bound to the InputManager, then the keyboard will be used. However if the `allow_keyboard` option on the InputManager is set to False, then the keyboard/mouse will never be used, even if there is no controller. The value will simply return 0. + +HOWEVER, the InputManager is fairly intelligent, and if it has both keyboard enabled, and has a controller bound, then the one which takes precedent is the last one which has been used, so for example, if the controller is the active device, and the user presses a key on the keyboard, the active device will be swapped to the keyboard. Then if the player presses a button on the controller or uses any of the inputs, the active device will be automatically swapped to the controller(analog inputs will only trigger if they are above the configured deadzone which defaults to 0.1). + +## Handling Controllers and Multiple Players + +One thing we haven't covered, is how the InputManager actually gets a controller bound to it. Currently, the InputManager does not do this on it's own, it is up to the user to provide an instance of `pyglet.input.Controller` to it. + +This means the user can setup a `ControllerManager`, and listen for `on_connect` and `on_disconnect` events for controllers. The controller can be bound during the constructor of the InputManager, or later bound/removed with the `bind_controller` and `unbind_controller` functions. + +The general idea for multiple players, is that each player would own it's own InputManager instance, but it is largely up to the user how to handle this. \ No newline at end of file diff --git a/arcade/experimental/input/__init__.py b/arcade/experimental/input/__init__.py new file mode 100644 index 000000000..58e5f3e52 --- /dev/null +++ b/arcade/experimental/input/__init__.py @@ -0,0 +1,6 @@ +# ruff: noqa: F401 +# type: ignore + +from .inputs import ControllerAxes, ControllerButtons, Keys, MouseAxes, MouseButtons +from .manager import ActionState, InputManager +from .mapping import Action, ActionMapping, Axis, AxisMapping diff --git a/arcade/experimental/input/inputs.py b/arcade/experimental/input/inputs.py new file mode 100644 index 000000000..665efe608 --- /dev/null +++ b/arcade/experimental/input/inputs.py @@ -0,0 +1,321 @@ +# type: ignore + +""" +Enums used to map different input types to their common counterparts. + +For example, keyboard keys are mapped to their Pyglet int values, as are mouse buttons. +However Controller buttons and axes are mapped to their Pyglet string values. +""" + +from __future__ import annotations + +from enum import Enum, auto +from sys import platform + + +class InputType(Enum): + KEYBOARD = 0 + MOUSE_BUTTON = 1 + MOUSE_AXIS = 2 + CONTROLLER_BUTTON = 3 + CONTROLLER_AXIS = 4 + + +class InputEnum(Enum): + pass + + +class StrEnum(str, InputEnum): + def __new__(cls, value, *args, **kwargs): + if not isinstance(value, (str, auto)): + raise TypeError( + f"Values of StrEnums must be strings: {value!r} is a {type(value)}" + ) + return super().__new__(cls, value, *args, **kwargs) + + def __str__(self): + return str(self.value) + + def _generate_next_value_(name, *_): + return name + + +class ControllerAxes(StrEnum): + LEFT_STICK_X = "leftx" + LEFT_STICK_POSITIVE_X = "leftxpositive" + LEFT_STICK_NEGATIVE_X = "leftxnegative" + LEFT_STICK_Y = "lefty" + LEFT_STICK_POSITIVE_Y = "leftypositive" + LEFT_STICK_NEGATIVE_Y = "leftynegative" + RIGHT_STICK_X = "rightx" + RIGHT_STICK_POSITIVE_X = "rightxpositive" + RIGHT_STICK_NEGATIVE_X = "rightxnegative" + RIGHT_STICK_Y = "righty" + RIGHT_STICK_POSITIVE_Y = "rightypositive" + RIGHT_STICK_NEGATIVE_Y = "rightynegative" + LEFT_TRIGGER = "lefttrigger" + RIGHT_TRIGGER = "righttrigger" + + +class ControllerButtons(StrEnum): + TOP_FACE = "y" + RIGHT_FACE = "b" + LEFT_FACE = "x" + BOTTOM_FACE = "a" + LEFT_SHOULDER = "leftshoulder" + RIGHT_SHOULDER = "rightshoulder" + START = "start" + BACK = "back" + GUIDE = "guide" + LEFT_STICK = "leftstick" + RIGHT_STICK = "rightstick" + DPAD_LEFT = "dpleft" + DPAD_RIGHT = "dpright" + DPAD_UP = "dpup" + DPAD_DOWN = "dpdown" + + +class Keys(InputEnum): + # Key modifiers + # Done in powers of two, so you can do a bit-wise 'and' to detect + # multiple modifiers. + MOD_SHIFT = 1 + MOD_CTRL = 2 + MOD_ALT = 4 + MOD_CAPSLOCK = 8 + MOD_NUMLOCK = 16 + MOD_WINDOWS = 32 + MOD_COMMAND = 64 + MOD_OPTION = 128 + MOD_SCROLLLOCK = 256 + + # Platform-specific base hotkey modifier + MOD_ACCEL = MOD_CTRL + if platform == "darwin": + MOD_ACCEL = MOD_COMMAND + + # Keys + BACKSPACE = 65288 + TAB = 65289 + LINEFEED = 65290 + CLEAR = 65291 + RETURN = 65293 + ENTER = 65293 + PAUSE = 65299 + SCROLLLOCK = 65300 + SYSREQ = 65301 + ESCAPE = 65307 + HOME = 65360 + LEFT = 65361 + UP = 65362 + RIGHT = 65363 + DOWN = 65364 + PAGEUP = 65365 + PAGEDOWN = 65366 + END = 65367 + BEGIN = 65368 + DELETE = 65535 + SELECT = 65376 + PRINT = 65377 + EXECUTE = 65378 + INSERT = 65379 + UNDO = 65381 + REDO = 65382 + MENU = 65383 + FIND = 65384 + CANCEL = 65385 + HELP = 65386 + BREAK = 65387 + MODESWITCH = 65406 + SCRIPTSWITCH = 65406 + MOTION_UP = 65362 + MOTION_RIGHT = 65363 + MOTION_DOWN = 65364 + MOTION_LEFT = 65361 + MOTION_NEXT_WORD = 1 + MOTION_PREVIOUS_WORD = 2 + MOTION_BEGINNING_OF_LINE = 3 + MOTION_END_OF_LINE = 4 + MOTION_NEXT_PAGE = 65366 + MOTION_PREVIOUS_PAGE = 65365 + MOTION_BEGINNING_OF_FILE = 5 + MOTION_END_OF_FILE = 6 + MOTION_BACKSPACE = 65288 + MOTION_DELETE = 65535 + NUMLOCK = 65407 + NUM_SPACE = 65408 + NUM_TAB = 65417 + NUM_ENTER = 65421 + NUM_F1 = 65425 + NUM_F2 = 65426 + NUM_F3 = 65427 + NUM_F4 = 65428 + NUM_HOME = 65429 + NUM_LEFT = 65430 + NUM_UP = 65431 + NUM_RIGHT = 65432 + NUM_DOWN = 65433 + NUM_PRIOR = 65434 + NUM_PAGE_UP = 65434 + NUM_NEXT = 65435 + NUM_PAGE_DOWN = 65435 + NUM_END = 65436 + NUM_BEGIN = 65437 + NUM_INSERT = 65438 + NUM_DELETE = 65439 + NUM_EQUAL = 65469 + NUM_MULTIPLY = 65450 + NUM_ADD = 65451 + NUM_SEPARATOR = 65452 + NUM_SUBTRACT = 65453 + NUM_DECIMAL = 65454 + NUM_DIVIDE = 65455 + + # Numbers on the numberpad + NUM_0 = 65456 + NUM_1 = 65457 + NUM_2 = 65458 + NUM_3 = 65459 + NUM_4 = 65460 + NUM_5 = 65461 + NUM_6 = 65462 + NUM_7 = 65463 + NUM_8 = 65464 + NUM_9 = 65465 + + F1 = 65470 + F2 = 65471 + F3 = 65472 + F4 = 65473 + F5 = 65474 + F6 = 65475 + F7 = 65476 + F8 = 65477 + F9 = 65478 + F10 = 65479 + F11 = 65480 + F12 = 65481 + F13 = 65482 + F14 = 65483 + F15 = 65484 + F16 = 65485 + F17 = 65486 + F18 = 65487 + F19 = 65488 + F20 = 65489 + F21 = 65490 + F22 = 65491 + F23 = 65492 + F24 = 65493 + LSHIFT = 65505 + RSHIFT = 65506 + LCTRL = 65507 + RCTRL = 65508 + CAPSLOCK = 65509 + LMETA = 65511 + RMETA = 65512 + LALT = 65513 + RALT = 65514 + LWINDOWS = 65515 + RWINDOWS = 65516 + LCOMMAND = 65517 + RCOMMAND = 65518 + LOPTION = 65488 + ROPTION = 65489 + SPACE = 32 + EXCLAMATION = 33 + DOUBLEQUOTE = 34 + HASH = 35 + POUND = 35 + DOLLAR = 36 + PERCENT = 37 + AMPERSAND = 38 + APOSTROPHE = 39 + PARENLEFT = 40 + PARENRIGHT = 41 + ASTERISK = 42 + PLUS = 43 + COMMA = 44 + MINUS = 45 + PERIOD = 46 + SLASH = 47 + + # Numbers on the main keyboard + KEY_0 = 48 + KEY_1 = 49 + KEY_2 = 50 + KEY_3 = 51 + KEY_4 = 52 + KEY_5 = 53 + KEY_6 = 54 + KEY_7 = 55 + KEY_8 = 56 + KEY_9 = 57 + COLON = 58 + SEMICOLON = 59 + LESS = 60 + EQUAL = 61 + GREATER = 62 + QUESTION = 63 + AT = 64 + BRACKETLEFT = 91 + BACKSLASH = 92 + BRACKETRIGHT = 93 + ASCIICIRCUM = 94 + UNDERSCORE = 95 + GRAVE = 96 + QUOTELEFT = 96 + A = 97 + B = 98 + C = 99 + D = 100 + E = 101 + F = 102 + G = 103 + H = 104 + # noinspection PyPep8 + I = 105 + J = 106 + K = 107 + L = 108 + M = 109 + N = 110 + # noinspection PyPep8 + O = 111 + P = 112 + Q = 113 + R = 114 + S = 115 + T = 116 + U = 117 + V = 118 + W = 119 + X = 120 + Y = 121 + Z = 122 + BRACELEFT = 123 + BAR = 124 + BRACERIGHT = 125 + ASCIITILDE = 126 + + +class MouseAxes(InputEnum): + X = 0 + Y = 1 + + +class MouseButtons(InputEnum): + # LEFT and MOUSE_1 are aliases of each other + LEFT = 1 << 0 + MOUSE_1 = 1 << 0 + + # MIDDLE and MOUSE_3 are aliases of each other + MIDDLE = 1 << 1 + MOUSE_3 = 1 << 1 + + # RIGHT and MOUSE_2 are aliases of each other + RIGHT = 1 << 2 + MOUSE_2 = 1 << 2 + + MOUSE_4 = 1 << 3 + MOUSE_5 = 1 << 4 diff --git a/arcade/experimental/input/manager.py b/arcade/experimental/input/manager.py new file mode 100644 index 000000000..2735fec04 --- /dev/null +++ b/arcade/experimental/input/manager.py @@ -0,0 +1,609 @@ +# type: ignore + +from __future__ import annotations + +from enum import Enum +from typing import Any, Callable, Dict, List, Optional, Set, Union + +import pyglet +from pyglet.input.base import Controller +from typing_extensions import TypedDict + +import arcade + +from . import inputs +from .inputs import InputEnum, InputType +from .mapping import ( + Action, + ActionMapping, + Axis, + AxisMapping, + RawAction, + RawAxis, + serialize_action, + serialize_axis, +) + +RawInputManager = TypedDict( + "RawInputManager", + {"actions": List[RawAction], "axes": List[RawAxis], "controller_deadzone": float}, +) + + +def _set_discard(set: Set, element: Any) -> Set: + set.discard(element) + return set + + +class ActionState(Enum): + PRESSED = 1 + RELEASED = 0 + + +class InputDevice(Enum): + KEYBOARD = 0 + CONTROLLER = 1 + + +class InputManager: + + def __init__( + self, + controller: Optional[Controller] = None, + allow_keyboard: bool = True, + action_handlers: Union[ + Callable[[str, ActionState], Any], List[Callable[[str, ActionState], Any]] + ] = [], + controller_deadzone: float = 0.1, + ): + self.actions: Dict[str, Action] = {} + self.keys_to_actions: Dict[int, Set[str]] = {} + self.controller_buttons_to_actions: Dict[str, Set[str]] = {} + self.controller_axes_to_actions: Dict[str, Set[str]] = {} + self.mouse_buttons_to_actions: Dict[int, Set[str]] = {} + self.on_action_listeners: List[Callable[[str, ActionState], Any]] = [] + self.action_subscribers: Dict[str, Set[Callable[[ActionState], Any]]] = {} + + self.axes: Dict[str, Axis] = {} + self.axes_state: Dict[str, float] = {} + self.keys_to_axes: Dict[int, Set[str]] = {} + self.controller_buttons_to_axes: Dict[str, Set[str]] = {} + self.controller_analog_to_axes: Dict[str, Set[str]] = {} + + self.window = arcade.get_window() + + if isinstance(action_handlers, list): + self.on_action_listeners.extend(action_handlers) + else: + self.on_action_listeners.append(action_handlers) + + self._allow_keyboard = allow_keyboard + if self._allow_keyboard: + self.window.push_handlers( + self.on_key_press, + self.on_key_release, + self.on_mouse_press, + self.on_mouse_release, + ) + + self.active_device = None + + if self._allow_keyboard: + self.active_device = InputDevice.KEYBOARD + + self.controller = None + self.controller_deadzone = controller_deadzone + if controller: + self.controller = controller + self.controller.open() + self.controller.push_handlers( + self.on_button_press, + self.on_button_release, + self.on_stick_motion, + self.on_dpad_motion, + self.on_trigger_motion, + ) + self.active_device = InputDevice.CONTROLLER + + def serialize(self) -> RawInputManager: + raw_actions = [] + for action in self.actions.values(): + raw_actions.append(serialize_action(action)) + raw_axes = [] + for axis in self.axes.values(): + raw_axes.append(serialize_axis(axis)) + return { + "actions": raw_actions, + "axes": raw_axes, + "controller_deadzone": self.controller_deadzone, + } + + @classmethod + def parse(cls, raw: RawInputManager) -> InputManager: + final = cls(controller_deadzone=raw["controller_deadzone"]) + + for raw_action in raw["actions"]: + name = raw_action["name"] + final.new_action(name) + for raw_mapping in raw_action["mappings"]: + raw_input = raw_mapping["input"] + input_type = inputs.InputType(raw_mapping["input_type"]) + if input_type == inputs.InputType.KEYBOARD: + input = inputs.Keys(raw_input) + elif input_type == inputs.InputType.MOUSE_BUTTON: + input = inputs.MouseButtons(raw_input) + elif input_type == inputs.InputType.MOUSE_AXIS: + input = inputs.MouseAxes(raw_input) + elif input_type == inputs.InputType.CONTROLLER_BUTTON: + input = inputs.ControllerButtons(raw_input) + elif input_type == inputs.InputType.CONTROLLER_AXIS: + input = inputs.ControllerAxes(raw_input) + else: + raise AttributeError("Tried to parse an unknown input type") + final.add_action_input( + name, + input, + raw_mapping["mod_shift"], + raw_mapping["mod_ctrl"], + raw_mapping["mod_alt"], + ) + + for raw_axis in raw["axes"]: + name = raw_axis["name"] + final.new_axis(name) + for raw_mapping in raw_axis["mappings"]: + raw_input = raw_mapping["input"] + input_type = inputs.InputType(raw_mapping["input_type"]) + if input_type == inputs.InputType.KEYBOARD: + input = inputs.Keys(raw_input) + elif input_type == inputs.InputType.MOUSE_BUTTON: + input = inputs.MouseButtons(raw_input) + elif input_type == inputs.InputType.MOUSE_AXIS: + input = inputs.MouseAxes(raw_input) + elif input_type == inputs.InputType.CONTROLLER_BUTTON: + input = inputs.ControllerButtons(raw_input) + elif input_type == inputs.InputType.CONTROLLER_AXIS: + input = inputs.ControllerAxes(raw_input) + else: + raise AttributeError("Tried to parse an unknown input type") + final.add_axis_input(name, input, raw_mapping["scale"]) + + return final + + def copy_existing(self, existing: InputManager): + self.actions = existing.actions.copy() + self.keys_to_actions = existing.keys_to_actions.copy() + self.controller_buttons_to_actions = ( + existing.controller_buttons_to_actions.copy() + ) + self.mouse_buttons_to_actions = existing.mouse_buttons_to_actions.copy() + self.axes = existing.axes.copy() + self.axes_state = existing.axes_state.copy() + self.controller_buttons_to_axes = existing.controller_buttons_to_axes.copy() + self.controller_analog_to_axes = existing.controller_analog_to_axes.copy() + self.controller_deadzone = existing.controller_deadzone + + @classmethod + def from_existing( + cls, + existing: InputManager, + controller: Optional[pyglet.input.Controller] = None, + ) -> InputManager: + new = cls( + allow_keyboard=existing.allow_keyboard, + controller=controller, + controller_deadzone=existing.controller_deadzone, + ) + new.actions = existing.actions.copy() + new.keys_to_actions = existing.keys_to_actions.copy() + new.controller_buttons_to_actions = ( + existing.controller_buttons_to_actions.copy() + ) + new.mouse_buttons_to_actions = existing.mouse_buttons_to_actions.copy() + new.axes = existing.axes.copy() + new.axes_state = existing.axes_state.copy() + new.controller_buttons_to_axes = existing.controller_buttons_to_axes.copy() + new.controller_analog_to_axes = existing.controller_analog_to_axes.copy() + + return new + + def bind_controller(self, controller: Controller): + if self.controller: + self.controller.remove_handlers() + + self.controller = controller + self.controller.open() + self.controller.push_handlers( + self.on_button_press, + self.on_button_release, + self.on_stick_motion, + self.on_dpad_motion, + self.on_trigger_motion, + ) + self.active_device = InputDevice.CONTROLLER + + def unbind_controller(self): + if not self.controller: + return + + self.controller.remove_handlers( + self.on_button_press, + self.on_button_release, + self.on_stick_motion, + self.on_dpad_motion, + self.on_trigger_motion, + ) + self.controller.close() + self.controller = None + + if self._allow_keyboard: + self.active_device = InputDevice.KEYBOARD + + @property + def allow_keyboard(self): + return self._allow_keyboard + + @allow_keyboard.setter + def allow_keyboard(self, value: bool): + if self._allow_keyboard == value: + return + + self._allow_keyboard = value + if self._allow_keyboard: + self.window.push_handlers( + self.on_key_press, + self.on_key_release, + self.on_mouse_press, + self.on_mouse_release, + ) + else: + self.window.remove_handlers(self) + + def new_action( + self, + name: str, + ): + action = Action(name) + self.actions[name] = action + + def remove_action(self, name: str): + self.clear_action_input(name) + + to_remove = self.actions.get(name, None) + if to_remove: + del self.actions[name] + + def add_action_input( + self, + action: str, + input: InputEnum, + mod_shift: bool = False, + mod_ctrl: bool = False, + mod_alt: bool = False, + ): + mapping = ActionMapping(input, mod_shift, mod_ctrl, mod_alt) + self.actions[action].add_mapping(mapping) + + if mapping._input_type == InputType.KEYBOARD: + # input is guaranteed to be an instance of Keys enum at this point + if input.value not in self.keys_to_actions: + self.keys_to_actions[input.value] = set() + self.keys_to_actions[input.value].add(action) + elif mapping._input_type == InputType.CONTROLLER_BUTTON: + if input.value not in self.controller_buttons_to_actions: + self.controller_buttons_to_actions[input.value] = set() + self.controller_buttons_to_actions[input.value].add(action) + elif mapping._input_type == InputType.MOUSE_BUTTON: + if input.value not in self.mouse_buttons_to_actions: + self.mouse_buttons_to_actions[input.value] = set() + self.mouse_buttons_to_actions[input.value].add(action) + elif mapping._input_type == InputType.CONTROLLER_AXIS: + if input.value not in self.controller_axes_to_actions: + self.controller_axes_to_actions[input.value] = set() + self.controller_axes_to_actions[input.value].add(action) + + def clear_action_input(self, action: str): + self.actions[action]._mappings = set() + + to_discard = [] + for key, value in self.keys_to_actions.items(): + new_set = _set_discard(value, action) + if len(new_set) == 0: + to_discard.append(key) + for key in to_discard: + del self.keys_to_actions[key] + + to_discard = [] + for key, value in self.controller_buttons_to_actions.items(): + new_set = _set_discard(value, action) + if len(new_set) == 0: + to_discard.append(key) + for key in to_discard: + del self.controller_buttons_to_actions[key] + + to_discard = [] + for key, value in self.controller_axes_to_actions.items(): + new_set = _set_discard(value, action) + if len(new_set) == 0: + to_discard.append(key) + for key in to_discard: + del self.controller_axes_to_actions[key] + + to_discard = [] + for key, value in self.mouse_buttons_to_actions.items(): + new_set = _set_discard(value, action) + if len(new_set) == 0: + to_discard.append(key) + for key in to_discard: + del self.mouse_buttons_to_actions[key] + + def register_action_handler( + self, + handler: Union[ + Callable[[str, ActionState], Any], List[Callable[[str, ActionState], Any]] + ], + ): + if isinstance(handler, list): + self.on_action_listeners.extend(handler) + else: + self.on_action_listeners.append(handler) + + def subscribe_to_action(self, name: str, subscriber: Callable[[ActionState], Any]): + old = self.action_subscribers.get(name, set()) + old.add(subscriber) + self.action_subscribers[name] = old + + def new_axis(self, name: str): + if name in self.axes: + raise AttributeError(f"Tried to create Axis with duplicate name: {name}") + + axis = Axis(name) + self.axes[name] = axis + self.axes_state[name] = 0.0 + + def add_axis_input(self, axis: str, input: InputEnum, scale: float = 1.0): + mapping = AxisMapping(input, scale) + self.axes[axis].add_mapping(mapping) + + if mapping._input_type == InputType.KEYBOARD: + if input.value not in self.keys_to_axes: + self.keys_to_axes[input.value] = set() + self.keys_to_axes[input.value].add(axis) + elif mapping._input_type == InputType.CONTROLLER_BUTTON: + if input.value not in self.controller_buttons_to_axes: + self.controller_buttons_to_axes[input.value] = set() + self.controller_buttons_to_axes[input.value].add(axis) + elif mapping._input_type == InputType.CONTROLLER_AXIS: + if input.value not in self.controller_analog_to_axes: + self.controller_analog_to_axes[input.value] = set() + self.controller_analog_to_axes[input.value].add(axis) + + def clear_axis_input(self, axis: str): + self.axes[axis]._mappings = set() + + to_discard = [] + for key, value in self.keys_to_axes.items(): + new_set = _set_discard(value, axis) + if len(new_set) == 0: + to_discard.append(key) + for key in to_discard: + del self.keys_to_axes[key] + + to_discard = [] + for key, value in self.controller_analog_to_axes.items(): + new_set = _set_discard(value, axis) + if len(new_set) == 0: + to_discard.append(key) + for key in to_discard: + del self.controller_analog_to_axes[key] + + to_discard = [] + for key, value in self.controller_buttons_to_axes.items(): + new_set = _set_discard(value, axis) + if len(new_set) == 0: + to_discard.append(key) + for key in to_discard: + del self.controller_buttons_to_axes[key] + + def remove_axis(self, name: str): + self.clear_axis_input(name) + + to_remove = self.axes.get(name, None) + if to_remove: + del self.axes[name] + del self.axes_state[name] + + def axis(self, name: str) -> float: + return self.axes_state[name] + + def dispatch_action(self, action: str, state: ActionState): + arcade.get_window().dispatch_event("on_action", action, state) + for listener in self.on_action_listeners: + listener(action, state) + if action in self.action_subscribers: + for subscriber in tuple(self.action_subscribers[action]): + subscriber(state) + + def on_mouse_press(self, x: int, y: int, button: int, modifiers: int) -> None: + if not self._allow_keyboard: + return + + self.active_device = InputDevice.KEYBOARD + mouse_buttons_to_actions = tuple( + self.mouse_buttons_to_actions.get(button, set()) + ) + for action_name in mouse_buttons_to_actions: + action = self.actions[action_name] + hit = True + for mapping in tuple(action._mappings): + if mapping._modifiers: + for mod in mapping._modifiers: + if not modifiers & mod: + hit = False + + if hit: + self.dispatch_action(action_name, ActionState.PRESSED) + + def on_key_press(self, key: int, modifiers) -> None: + if not self._allow_keyboard: + return + + self.active_device = InputDevice.KEYBOARD + keys_to_actions = tuple(self.keys_to_actions.get(key, set())) + for action_name in keys_to_actions: + action = self.actions[action_name] + hit = True + for mapping in tuple(action._mappings): + if mapping._modifiers: + for mod in mapping._modifiers: + if not modifiers & mod: + hit = False + + if hit: + self.dispatch_action(action_name, ActionState.PRESSED) + + def on_mouse_release(self, x: int, y: int, button: int, modifiers: int) -> None: + if not self._allow_keyboard: + return + + mouse_buttons_to_actions = tuple( + self.mouse_buttons_to_actions.get(button, set()) + ) + for action_name in mouse_buttons_to_actions: + action = self.actions[action_name] + hit = True + for mapping in tuple(action._mappings): + if mapping._modifiers: + for mod in mapping._modifiers: + if not modifiers & mod: + hit = False + + if hit: + self.dispatch_action(action_name, ActionState.RELEASED) + + def on_key_release(self, key: int, modifiers) -> None: + if not self._allow_keyboard: + return + + keys_to_actions = tuple(self.keys_to_actions.get(key, set())) + for action_name in keys_to_actions: + action = self.actions[action_name] + hit = True + for mapping in tuple(action._mappings): + if mapping._modifiers: + for mod in mapping._modifiers: + if not modifiers & mod: + hit = False + + if hit: + self.dispatch_action(action_name, ActionState.RELEASED) + + def on_button_press(self, controller: Controller, button_name: str): + self.active_device = InputDevice.CONTROLLER + buttons_to_actions = tuple( + self.controller_buttons_to_actions.get(button_name, set()) + ) + for action_name in buttons_to_actions: + self.dispatch_action(action_name, ActionState.PRESSED) + + def on_button_release(self, controller: Controller, button_name: str): + buttons_to_actions = tuple( + self.controller_buttons_to_actions.get(button_name, set()) + ) + for action_name in buttons_to_actions: + self.dispatch_action(action_name, ActionState.RELEASED) + + def on_stick_motion(self, controller, name, x_value, y_value): + if name == "leftx": + self.window.dispatch_event( + "on_stick_motion", + self.controller, + "leftxpositive" if x_value > 0 else "leftxnegative", + x_value, + y_value, + ) + elif name == "lefty": + self.window.dispatch_event( + "on_stick_motion", + self.controller, + "leftypositive" if y_value > 0 else "leftynegative", + x_value, + y_value, + ) + elif name == "rightx": + self.window.dispatch_event( + "on_stick_motion", + self.controller, + "rightxpositive" if x_value > 0 else "rightxpositive", + x_value, + y_value, + ) + elif name == "righty": + self.window.dispatch_event( + "on_stick_motion", + self.controller, + "rightypositive" if y_value > 0 else "rightynegative", + x_value, + y_value, + ) + + axes_to_actions = self.controller_axes_to_actions.get(name, set()) + + if ( + x_value > self.controller_deadzone + or x_value < -self.controller_deadzone + or y_value > self.controller_deadzone + or y_value < -self.controller_deadzone + ): + self.active_device = InputDevice.CONTROLLER + + for action_name in axes_to_actions: + self.dispatch_action(action_name, ActionState.PRESSED) + + return + + for action_name in axes_to_actions: + self.dispatch_action(action_name, ActionState.RELEASED) + + def on_dpad_motion( + self, controller: Controller, left: bool, right: bool, up: bool, down: bool + ): + self.active_device = InputDevice.CONTROLLER + + def on_trigger_motion( + self, controller: Controller, trigger_name: str, value: float + ): + self.active_device = InputDevice.CONTROLLER + + def update(self): + for name in self.axes.keys(): + self.axes_state[name] = 0 + + if self.controller and self.active_device == InputDevice.CONTROLLER: + for name, axis in self.axes.items(): + for mapping in tuple(axis._mappings): + if mapping._input_type == InputType.CONTROLLER_AXIS: + scale = mapping._scale + input = getattr(self.controller, mapping._input.value) # type: ignore + if ( + input > self.controller_deadzone + or input < -self.controller_deadzone + ): + self.axes_state[name] = input * scale + if mapping._input_type == InputType.CONTROLLER_BUTTON: + if getattr(self.controller, mapping._input.value): # type: ignore + self.axes_state[name] = mapping._scale + elif self.active_device == InputDevice.KEYBOARD and self._allow_keyboard: + for name, axis in self.axes.items(): + for mapping in tuple(axis._mappings): + if mapping._input_type == InputType.KEYBOARD: + if self.window.keyboard[mapping._input.value]: + self.axes_state[name] = mapping._scale + elif mapping._input_type == InputType.MOUSE_AXIS: + self.axes_state[name] = ( + self.window.mouse[mapping._input.name.lower()] + * mapping._scale + ) + elif mapping._input_type == InputType.MOUSE_BUTTON: + if self.window.mouse[mapping._input.value]: + self.axes_state[name] = mapping._scale diff --git a/arcade/experimental/input/mapping.py b/arcade/experimental/input/mapping.py new file mode 100644 index 000000000..2d448d2c0 --- /dev/null +++ b/arcade/experimental/input/mapping.py @@ -0,0 +1,164 @@ +# type: ignore + +from __future__ import annotations + +from typing import List, Set, Union + +from typing_extensions import TypedDict + +from arcade.experimental.input import inputs + +RawActionMapping = TypedDict( + "RawActionMapping", + { + "input_type": int, + "input": Union[str, int], + "mod_shift": bool, + "mod_ctrl": bool, + "mod_alt": bool, + }, +) +RawAxisMapping = TypedDict( + "RawAxisMapping", {"input_type": int, "input": Union[str, int], "scale": float} +) + +RawAction = TypedDict("RawAction", {"name": str, "mappings": List[RawActionMapping]}) +RawAxis = TypedDict("RawAxis", {"name": str, "mappings": List[RawAxisMapping]}) + + +class Action: + + def __init__(self, name: str) -> None: + self.name = name + self._mappings: Set[ActionMapping] = set() + + def add_mapping(self, mapping: ActionMapping) -> None: + self._mappings.add(mapping) + + def remove_mapping(self, mapping: ActionMapping) -> None: + try: + self._mappings.remove(mapping) + except KeyError: + pass + + +class Axis: + + def __init__(self, name: str) -> None: + self.name = name + self._mappings: Set[AxisMapping] = set() + + def add_mapping(self, mapping: AxisMapping) -> None: + self._mappings.add(mapping) + + def remove_mapping(self, mapping: AxisMapping) -> None: + try: + self._mappings.remove(mapping) + except KeyError: + pass + + +class Mapping: + + def __init__(self, input: inputs.InputEnum): + if isinstance(input, inputs.Keys): + self._input_type = inputs.InputType.KEYBOARD + elif isinstance(input, inputs.MouseButtons): + self._input_type = inputs.InputType.MOUSE_BUTTON + elif isinstance(input, inputs.MouseAxes): + self._input_type = inputs.InputType.MOUSE_AXIS + elif isinstance(input, inputs.ControllerButtons): + self._input_type = inputs.InputType.CONTROLLER_BUTTON + elif isinstance(input, inputs.ControllerAxes): + self._input_type = inputs.InputType.CONTROLLER_AXIS + else: + raise TypeError( + "Input specified for ActionMapping must inherit from InputEnum" + ) + + self._input = input + + +class ActionMapping(Mapping): + + def __init__( + self, + input: inputs.InputEnum, + mod_shift: bool = False, + mod_ctrl: bool = False, + mod_alt: bool = False, + ): + super().__init__(input) + self._modifiers = set() + if mod_shift: + self._modifiers.add(inputs.Keys.MOD_SHIFT.value) + if mod_ctrl: + self._modifiers.add(inputs.Keys.MOD_ACCEL.value) + if mod_alt: + self._modifiers.add(inputs.Keys.MOD_ALT.value) + self._modifiers.add(inputs.Keys.MOD_OPTION.value) + + +class AxisMapping(Mapping): + + def __init__(self, input: inputs.InputEnum, scale: float): + super().__init__(input) + self._scale = scale + + +def serialize_action(action: Action) -> RawAction: + raw_mappings: List[RawActionMapping] = [] + for mapping in action._mappings: + raw_mappings.append( + { + "input": mapping._input.value, + "input_type": mapping._input_type.value, + "mod_shift": inputs.Keys.MOD_SHIFT in mapping._modifiers, + "mod_ctrl": inputs.Keys.MOD_ACCEL in mapping._modifiers, + "mod_alt": ( + inputs.Keys.MOD_ALT in mapping._modifiers + or inputs.Keys.MOD_OPTION in mapping._modifiers + ), + } + ) + return {"name": action.name, "mappings": raw_mappings} + + +def parse_raw_axis(raw_axis: RawAxis) -> Axis: + axis = Axis(raw_axis["name"]) + for raw_mapping in raw_axis["mappings"]: + raw_input = raw_mapping["input"] + input_type = inputs.InputType(raw_mapping["input_type"]) + if input_type == inputs.InputType.KEYBOARD: + input = inputs.Keys(raw_input) + elif input_type == inputs.InputType.MOUSE_BUTTON: + input = inputs.MouseButtons(raw_input) + elif input_type == inputs.InputType.MOUSE_AXIS: + input = inputs.MouseAxes(raw_input) + elif input_type == inputs.InputType.CONTROLLER_BUTTON: + input = inputs.ControllerButtons(raw_input) + elif input_type == inputs.InputType.CONTROLLER_AXIS: + input = inputs.ControllerAxes(raw_input) + else: + raise AttributeError("Tried to parse an unknown input type") + axis.add_mapping( + AxisMapping( + input, + raw_mapping["scale"], + ) + ) + + return axis + + +def serialize_axis(axis: Axis) -> RawAxis: + raw_mappings: List[RawAxisMapping] = [] + for mapping in axis._mappings: + raw_mappings.append( + { + "input": mapping._input.value, + "input_type": mapping._input_type.value, + "scale": mapping._scale, + } + ) + return {"name": axis.name, "mappings": raw_mappings} diff --git a/arcade/experimental/input_manager_example.py b/arcade/experimental/input_manager_example.py new file mode 100644 index 000000000..a9bc6fcdd --- /dev/null +++ b/arcade/experimental/input_manager_example.py @@ -0,0 +1,156 @@ +# type: ignore + +import random +from typing import List, Optional + +import pyglet + +import arcade +from arcade.experimental import input + +WINDOW_WIDTH = 800 +WINDOW_HEIGHT = 600 + + +class Player(arcade.Sprite): + + def __init__( + self, + walls: arcade.SpriteList, + input_manager_template: input.InputManager, + controller: Optional[pyglet.input.Controller] = None, + ): + super().__init__( + ":resources:images/animated_characters/female_adventurer/femaleAdventurer_idle.png" + ) + self.center_x = random.randint(0, WINDOW_WIDTH) + self.center_y = 128 + + self.input_manager = input.InputManager( + controller=controller, action_handlers=self.on_action + ) + self.input_manager.copy_existing(input_manager_template) + + self.physics_engine = arcade.PhysicsEnginePlatformer( + self, walls=walls, gravity_constant=1 + ) + + def on_update(self, delta_time: float): + self.input_manager.update() + self.change_x = self.input_manager.axis("Move") * 5 + + self.physics_engine.update() + + def on_action(self, action: str, state: input.ActionState): + if ( + action == "Jump" + and state == input.ActionState.PRESSED + and self.physics_engine.can_jump() + ): + self.change_y = 20 + + +class Game(arcade.Window): + + def __init__(self): + super().__init__(WINDOW_WIDTH, WINDOW_HEIGHT, "Input Example") + + # This is an example of how to load an InputManager from a file. The parse function + # accepts a dictionary, so anything such as toml, yaml, or json could be used to load + # that dictionary. As long as it can handle a Python dictionary. + # + # with open("out.json", "r") as f: + # raw = json.load(f) + # self.INPUT_TEMPLATE = arcade.InputManager.parse(raw) + # self.INPUT_TEMPLATE.allow_keyboard = False + + self.INPUT_TEMPLATE = input.InputManager(allow_keyboard=False) + self.INPUT_TEMPLATE.new_action("Jump") + self.INPUT_TEMPLATE.add_action_input("Jump", input.Keys.SPACE) + self.INPUT_TEMPLATE.add_action_input( + "Jump", input.ControllerButtons.BOTTOM_FACE + ) + + self.INPUT_TEMPLATE.new_axis("Move") + self.INPUT_TEMPLATE.add_axis_input("Move", input.Keys.A, -1.0) + self.INPUT_TEMPLATE.add_axis_input("Move", input.Keys.D, 1.0) + self.INPUT_TEMPLATE.add_axis_input("Move", input.ControllerAxes.LEFT_STICK_X) + + # This is an example of how to dump an InputManager to a file. The serialize function + # returns a dictionary, so anything such as toml, yaml, or json could be used to save + # that dictionary. As long as it can handle a Python dictionary. + # + # serialized = self.INPUT_TEMPLATE.serialize() + # with open("out.json", "w") as f: + # json.dump(serialized, f) + + self.wall_list = arcade.SpriteList(use_spatial_hash=True) + + for x in range(0, 1250, 64): + wall = arcade.Sprite(":resources:images/tiles/grassMid.png", scale=0.5) + wall.center_x = x + wall.center_y = 32 + self.wall_list.append(wall) + + self.players: List[Player] = [] + + self.controller_manager = pyglet.input.ControllerManager() + self.controller_manager.set_handlers(self.on_connect, self.on_disconnect) + + controller = None + if self.controller_manager.get_controllers(): + controller = self.controller_manager.get_controllers()[0] + + self.players.append(Player(self.wall_list, self.INPUT_TEMPLATE, controller)) + + self.player_list = arcade.SpriteList() + self.player_list.append(self.players[0]) + + def on_connect(self, controller: pyglet.input.Controller): + player = Player(self.wall_list, self.INPUT_TEMPLATE, controller) + self.players.append(player) + self.player_list.append(player) + + def on_disconnect(self, controller: pyglet.input.Controller): + to_remove = None + for player in self.players: + if player.input_manager.controller == controller: + to_remove = player + break + + if to_remove: + to_remove.input_manager.unbind_controller() + to_remove.kill() + self.players.remove(to_remove) + + def on_draw(self): + self.clear() + + self.player_list.draw() + self.wall_list.draw() + + def on_key_press(self, key, modifiers): + key = input.Keys(key) + + if key == input.Keys.KEY_1: + self.players[0].input_manager.allow_keyboard = True + for index, player in enumerate(self.players): + if index != 0: + player.input_manager.allow_keyboard = False + elif key == input.Keys.KEY_2: + self.players[1].input_manager.allow_keyboard = True + for index, player in enumerate(self.players): + if index != 1: + player.input_manager.allow_keyboard = False + + def on_update(self, delta_time: float): + self.player_list.on_update(delta_time) + + +def main(): + Game() + arcade.run() + + +if __name__ == "__main__": + main()