Skip to content

Commit

Permalink
Add game controller support
Browse files Browse the repository at this point in the history
  • Loading branch information
vxgmichel committed Feb 11, 2021
1 parent 33d8bb1 commit a6fee25
Show file tree
Hide file tree
Showing 8 changed files with 184 additions and 60 deletions.
32 changes: 20 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions gambaterm/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions gambaterm/constants.py
Original file line number Diff line number Diff line change
@@ -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
92 changes: 92 additions & 0 deletions gambaterm/controller_input.py
Original file line number Diff line number Diff line change
@@ -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()
12 changes: 1 addition & 11 deletions gambaterm/file_input.py
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
80 changes: 51 additions & 29 deletions gambaterm/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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


Expand All @@ -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,
Expand All @@ -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__":
Expand Down
7 changes: 1 addition & 6 deletions gambaterm/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down

0 comments on commit a6fee25

Please sign in to comment.