diff --git a/README.md b/README.md index 930abc2..ac2615a 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,10 @@ Optional arguments: Use CPR synchronization to prevent video buffering + - `--enable-controller, --ec` + + Enable game controller support + - `--disable-audio, --da` Disable audio entirely @@ -94,7 +98,7 @@ Not all terminals will actually offer a pleasant experience. The main criteria a In this case, it might be better to use greyscale colors using `--force-gameboy` or `--color-mode=1`. - **Support for UTF-8 and good rendering of unicode block elements** - More specifically the following characters `▄█▀`. + More specifically the following characters `▄ █ ▀`. Changing the code page might be necessary on windows, using `chcp 65001`. Also, the alignement might be off (e.g small spaces between pixels) This is not always well supported. @@ -138,24 +142,28 @@ Terminal size The emulator uses a single character on screen to display two vertically aligned pixels, like so `▄▀`. The gameboy being 160 pixels wide over 144 pixels high, you'll need your terminal to be at least 160 characters wide over 72 characters high to display the entire screen. Setting the terminal to full screen is usually enough but you might want to tweak the character size, typically using the `ctrl - / ctrl +` or `ctrl wheel` shortcuts. -Keyboard input --------------- +Keyboard, game controller and file inputs +----------------------------------------- -The key bindings are not configurable at the moment: +Keyboard controls are enabled by default, while game controller controls have to be enabled using `--enable-controller` or `--ec`. The key bindings are not configurable at the moment: -| Buttons | Keyboard | -|------------|----------------------| -| Directions | Arrows | -| A | F / V / Space | -| B | D / C / Alt | -| Start | Right Ctrl / Enter | -| Select | Right Shift / Delete | +| Buttons | Keyboard | Controller | +|------------|----------------------|-----------------------| +| Directions | Arrows | Left hat / Left stick | +| A | F / V / Space | Button 0 / Button 3 | +| B | D / C / Alt | Button 1 / Button 2 | +| Start | Right Ctrl / Enter | Button 7 | +| Select | Right Shift / Delete | Button 6 | Key releases, which are usually mandatory to play games, cannot be detected through `stdin`. It is then required to access the window system to get access to the key presses. There are a couple of problems with that: - It can be hard to detect the window corresponding to the terminal. With X11, the best solution is to look for the current focused window. For other systems, the fallback solution is to use global hotkeys. -- It only works through SSH for clients with X servers using `ssh -X`, meaning it requires Requires and MacOS users to run an X server. Moreover, it's a bad idea to connect with `-X` to an untrusted server. +- It only works through SSH for clients with X servers using `ssh -X`, meaning it requires Windows and MacOS users to run an X server. Moreover, it's a bad idea to connect with `-X` to an untrusted server. + +- Additional permissions might be required to access the window system, especially on MacOS (see [this guide](https://inputs.readthedocs.io/en/latest/user/install.html#mac-permissions)) + +It is also possible to use a bizhawk BK2 input file to play tool-assisted speedruns using the `--input-file` (or `-i`) option. Motivation diff --git a/gambaterm/audio.py b/gambaterm/audio.py index 963d6c6..e4ef107 100644 --- a/gambaterm/audio.py +++ b/gambaterm/audio.py @@ -5,8 +5,7 @@ import sounddevice import numpy as np -GB_FPS = 59.727500569606 -GB_TICKS_IN_FRAME = 35112 +from .constants import GB_FPS, GB_TICKS_IN_FRAME class AudioOut: diff --git a/gambaterm/constants.py b/gambaterm/constants.py new file mode 100644 index 0000000..d6bff78 --- /dev/null +++ b/gambaterm/constants.py @@ -0,0 +1,17 @@ +from enum import IntEnum + +GB_WIDTH = 160 +GB_HEIGHT = 144 +GB_FPS = 59.727500569606 +GB_TICKS_IN_FRAME = 35112 + + +class GBInput(IntEnum): + A = 0x01 + B = 0x02 + SELECT = 0x04 + START = 0x08 + RIGHT = 0x10 + LEFT = 0x20 + UP = 0x40 + DOWN = 0x80 diff --git a/gambaterm/controller_input.py b/gambaterm/controller_input.py new file mode 100644 index 0000000..6db5e2c --- /dev/null +++ b/gambaterm/controller_input.py @@ -0,0 +1,92 @@ +import os +from contextlib import contextmanager + +from .constants import GBInput + + +def get_controller_mapping(): + return { + # Directions + "A1-": GBInput.UP, + "H1-": GBInput.UP, + "A1+": GBInput.DOWN, + "H1+": GBInput.DOWN, + "A0-": GBInput.LEFT, + "H0-": GBInput.LEFT, + "A0+": GBInput.RIGHT, + "H0+": GBInput.RIGHT, + # A button + "B0": GBInput.A, + "B3": GBInput.A, + # B button + "B1": GBInput.B, + "B2": GBInput.B, + # Start button + "B7": GBInput.START, + # Select button + "B6": GBInput.SELECT, + } + + +@contextmanager +def pygame_button_pressed_context(deadzone=0.4): + os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "1" + import pygame + + pygame.init() + pygame.joystick.init() + joystick = None + + def get_pressed(): + nonlocal joystick + pygame.event.get() + if pygame.joystick.get_count() == 0: + joystick = None + return {} + if joystick is None: + joystick = pygame.joystick.Joystick(0) + pressed = { + f"B{x}" for x in range(joystick.get_numbuttons()) if joystick.get_button(x) + } + if joystick.get_numhats() >= 1: + if joystick.get_hat(0)[0] > 0: + pressed.add(f"H0+") + if joystick.get_hat(0)[0] < 0: + pressed.add(f"H0-") + if joystick.get_hat(0)[1] < 0: + pressed.add(f"H1+") + if joystick.get_hat(0)[1] > 0: + pressed.add(f"H1-") + if joystick.get_numaxes() >= 2: + if joystick.get_axis(0) > deadzone: + pressed.add(f"A0+") + if joystick.get_axis(0) < -deadzone: + pressed.add(f"A0-") + if joystick.get_axis(1) > deadzone: + pressed.add(f"A1+") + if joystick.get_axis(1) < -deadzone: + pressed.add(f"A1-") + return pressed + + yield get_pressed + + +@contextmanager +def gb_input_from_controller_context(): + controller_mapping = get_controller_mapping() + + def get_gb_input(): + value = 0 + for keysym in joystick_get_pressed(): + value |= controller_mapping.get(keysym, 0) + return value + + with pygame_button_pressed_context() as joystick_get_pressed: + yield get_gb_input + + +@contextmanager +def combine_gb_input_from_controller_context(context): + with context as getter1: + with gb_input_from_controller_context() as getter2: + yield lambda: getter1() | getter2() diff --git a/gambaterm/file_input.py b/gambaterm/file_input.py index a21cc80..1041da3 100644 --- a/gambaterm/file_input.py +++ b/gambaterm/file_input.py @@ -1,18 +1,8 @@ -from enum import IntEnum from io import TextIOWrapper from contextlib import contextmanager from zipfile import ZipFile, BadZipFile - -class GBInput(IntEnum): - A = 0x01 - B = 0x02 - SELECT = 0x04 - START = 0x08 - RIGHT = 0x10 - LEFT = 0x20 - UP = 0x40 - DOWN = 0x80 +from .constants import GBInput INPUTS = [ diff --git a/gambaterm/main.py b/gambaterm/main.py index 4aaf849..e6d511a 100644 --- a/gambaterm/main.py +++ b/gambaterm/main.py @@ -11,6 +11,7 @@ from .colors import detect_local_color_mode from .file_input import gb_input_from_file_context from .keyboard_input import gb_input_from_keyboard_context +from .controller_input import combine_gb_input_from_controller_context def add_base_arguments(parser): @@ -60,6 +61,12 @@ def add_base_arguments(parser): action="store_true", help="Use CPR synchronization to prevent video buffering", ) + parser.add_argument( + "--enable-controller", + "--ec", + action="store_true", + help="Enable game controller support", + ) return parser @@ -86,26 +93,35 @@ def main(args=None): else: gb_input_context = gb_input_from_keyboard_context() save_directory = None + if args.enable_controller: + gb_input_context = combine_gb_input_from_controller_context( + gb_input_context + ) if args.color_mode == 0: raise RuntimeError("No color mode seems to be supported") - player = no_audio if args.disable_audio else audio_player - with player(args.speed_factor) as audio_out: - with create_app_session() as app_session: - with app_session.input.raw_mode(): - try: - # Detect color mode - if args.color_mode is None: - args.color_mode = detect_local_color_mode(app_session) - - # Prepare alternate screen - app_session.output.enter_alternate_screen() - app_session.output.erase_screen() - app_session.output.hide_cursor() - app_session.output.flush() - - with gb_input_context as get_gb_input: + # Enter terminal raw mode + with create_app_session() as app_session: + with app_session.input.raw_mode(): + try: + + # Detect color mode + if args.color_mode is None: + args.color_mode = detect_local_color_mode(app_session) + + # Prepare alternate screen + app_session.output.enter_alternate_screen() + app_session.output.erase_screen() + app_session.output.hide_cursor() + app_session.output.flush() + + # Enter input and audio contexts + with gb_input_context as get_gb_input: + player = no_audio if args.disable_audio else audio_player + with player(args.speed_factor) as audio_out: + + # Run the emulator return_code = run( args.romfile, get_gb_input, @@ -119,19 +135,25 @@ def main(args=None): save_directory=save_directory, force_gameboy=args.force_gameboy, ) - except (KeyboardInterrupt, EOFError): - pass - else: - exit(return_code) - finally: - # Wait for a possible CPR - time.sleep(0.1) - # Clear alternate screen - app_session.input.read_keys() - app_session.output.erase_screen() - app_session.output.quit_alternate_screen() - app_session.output.show_cursor() - app_session.output.flush() + + # Deal with ctrl+c and ctrl+d exceptions + except (KeyboardInterrupt, EOFError): + pass + + # Exit with return code + else: + exit(return_code) + + # Restore terminal to its initial state + finally: + # Wait for a possible CPR + time.sleep(0.1) + # Clear alternate screen + app_session.input.read_keys() + app_session.output.erase_screen() + app_session.output.quit_alternate_screen() + app_session.output.show_cursor() + app_session.output.flush() if __name__ == "__main__": diff --git a/gambaterm/run.py b/gambaterm/run.py index df134bd..30fec19 100755 --- a/gambaterm/run.py +++ b/gambaterm/run.py @@ -10,12 +10,7 @@ from .libgambatte import GB from .termblit import blit - -# Gameboy constants -GB_WIDTH = 160 -GB_HEIGHT = 144 -GB_FPS = 59.727500569606 -GB_TICKS_IN_FRAME = 35112 +from .constants import GB_WIDTH, GB_HEIGHT, GB_FPS, GB_TICKS_IN_FRAME @contextlib.contextmanager diff --git a/setup.py b/setup.py index 7d60ab0..529eb06 100644 --- a/setup.py +++ b/setup.py @@ -61,6 +61,7 @@ def __fspath__(self): "samplerate", "xlib; sys_platform == 'linux'", "pynput; sys_platform != 'linux'", + "pygame>=1.9.5", ], python_requires=">=3.6", entry_points={