Reclaim the GPIO pins once the launched game is quit. Return to the launcher directory by storing the launcher directory before running the game. Update installation instructions.
333 lines
14 KiB
Python
Executable File
333 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
# Launch PGFW games in the `games/` folder
|
|
|
|
import argparse
|
|
import os
|
|
import pathlib
|
|
import pygame
|
|
import sys
|
|
import lib.pgfw.pgfw as pgfw
|
|
from games.bitfit.bitfit.Bitfit import Bitfit
|
|
|
|
# Import GPIO library if available
|
|
try:
|
|
import RPi.GPIO as GPIO # type: ignore[import-untyped]
|
|
except ImportError:
|
|
pass
|
|
|
|
# Indicate whether the Raspberry Pi library is available
|
|
rpi_available = "RPi.GPIO" in sys.modules
|
|
|
|
# The GPIO pins corresponding to the controller and card dispenser
|
|
PIN_BUTTON_LEFT = 24
|
|
PIN_BUTTON_RIGHT = 23
|
|
DISPENSER_PIN = 37
|
|
|
|
|
|
class Playzing(pgfw.Game):
|
|
|
|
def __init__(self, config_overrides=None):
|
|
"""
|
|
Create logo sprite, clear screen, and subscribe to events.
|
|
"""
|
|
pgfw.Game.__init__(self)
|
|
|
|
# Member dict for tracking pin state changes. Start at 1 because high means unpressed.
|
|
self.pin_states = {
|
|
PIN_BUTTON_LEFT: 1,
|
|
PIN_BUTTON_RIGHT: 1
|
|
}
|
|
|
|
# Assign types to configuration values
|
|
self.configuration.type_declarations.add_chart({
|
|
"display":
|
|
{
|
|
"int-list": "clear",
|
|
"bool": "rotated"
|
|
},
|
|
"logo":
|
|
{
|
|
"path": ["fire", "text"],
|
|
"int": ["margin", "restart", "delay"],
|
|
"bool": "trail"
|
|
}
|
|
})
|
|
|
|
# Merge config overrides from the command line
|
|
self.configuration.merge_command_line()
|
|
|
|
# Clear screen to black
|
|
self.get_display_surface().fill(self.configuration.get("display", "clear"))
|
|
|
|
# Initialize GPIO input and callbacks if GPIO library is loaded
|
|
if rpi_available:
|
|
self.initialize_gpio()
|
|
|
|
# Subscribe to PGFW command events
|
|
self.subscribe(self.respond)
|
|
|
|
# Load sprites for logo parts
|
|
self.logo_text = pgfw.Sprite(self)
|
|
self.logo_text.load_from_path(self.get_resource(self.configuration.get("logo", "text")), True)
|
|
if self.rotated:
|
|
self.logo_text.rotate()
|
|
self.logo_fire = pgfw.Sprite(self)
|
|
self.logo_fire.load_from_path(self.get_resource(self.configuration.get("logo", "fire")), True)
|
|
if self.rotated:
|
|
self.logo_fire.rotate()
|
|
|
|
# Register logo animation functions and begin animation
|
|
self.register(self.animate_logo)
|
|
self.register(self.reveal_text, interval=40)
|
|
self.register(self.end_output_signal)
|
|
self.play(self.animate_logo, play_once=True)
|
|
|
|
# Don't trigger an any key event when modifier keys are pressed or when the designated function button on a
|
|
# joystick or gamepad is pressed.
|
|
self.input.suppress_any_key_on_mods()
|
|
self.input.register_any_press_ignore("function")
|
|
|
|
def initialize_gpio(self):
|
|
"""
|
|
Set pin numbering mode to physical board header numbering, initialize all buttons to input pullup, and
|
|
initialize the dispenser pin.
|
|
"""
|
|
# Use physical board header numbering
|
|
GPIO.setmode(GPIO.BOARD)
|
|
|
|
# Set button pins to pullup and attach to each a callback that runs on press or release
|
|
for pin in PIN_BUTTON_LEFT, PIN_BUTTON_RIGHT:
|
|
GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_UP)
|
|
GPIO.add_event_detect(pin, GPIO.BOTH, self.gpio_input)
|
|
|
|
# Set the pin that will trigger the dispenser as an output, but not sending a signal yet
|
|
GPIO.setup(DISPENSER_PIN, GPIO.OUT, initial=GPIO.LOW)
|
|
|
|
def gpio_input(self, pin):
|
|
"""
|
|
Translate GPIO input into PGFW commands, so Raspberry Pi wired controllers be used for input.
|
|
|
|
Compare the pin state to what is stored in memory. Only fire an event if there has been a change in state. A
|
|
change from high to low triggers a press event. A change from low to high triggers a press cancel event.
|
|
|
|
@param pin Raspberry Pi pin number as read by the RPi.GPIO library
|
|
"""
|
|
# Print the input state of each pin if debug is requested on the command line
|
|
if "--debug" in sys.argv:
|
|
pin_name = "left" if pin == PIN_BUTTON_LEFT else "right"
|
|
left_pin_state = GPIO.input(PIN_BUTTON_LEFT)
|
|
right_pin_state = GPIO.input(PIN_BUTTON_RIGHT)
|
|
print(f"Received {pin} ({pin_name}) input. Left state is {left_pin_state}. "
|
|
f"Right state is {right_pin_state}")
|
|
|
|
# If the saved state of the pin is the same, there hasn't been a real button press or release, so don't continue
|
|
if self.pin_states[pin] != GPIO.input(pin):
|
|
self.pin_states[pin] = GPIO.input(pin)
|
|
# A high signal means the button is released, and a low signal means it is pressed
|
|
cancel = not (GPIO.input(pin) == GPIO.LOW)
|
|
if pin == PIN_BUTTON_LEFT:
|
|
self.input.post_command("left", cancel=cancel)
|
|
elif pin == PIN_BUTTON_RIGHT:
|
|
self.input.post_command("right", cancel=cancel)
|
|
self.input.post_any_command(id=pin, cancel=cancel)
|
|
|
|
@property
|
|
def rotated(self):
|
|
return self.configuration.get("display", "rotated")
|
|
|
|
def orient(self, obj):
|
|
oriented = obj
|
|
if self.rotated:
|
|
if isinstance(obj, pygame.Rect):
|
|
oriented = pygame.Rect(obj.y, self.get_display_surface().get_height() - obj.x + obj.w, obj.h, obj.w)
|
|
return oriented
|
|
|
|
def animate_logo(self):
|
|
"""
|
|
Reset the logo animation to its beginning and start animating.
|
|
"""
|
|
# Place the logo
|
|
margin = self.configuration.get("logo", "margin")
|
|
self.logo_text.location.center = self.get_display_surface().get_rect().center
|
|
self.logo_text.move(-self.orient(self.logo_fire.location).w + margin / 2, rotated=self.rotated)
|
|
|
|
# Clear the screen
|
|
self.get_display_surface().fill(self.configuration.get("display", "clear"))
|
|
|
|
# Place the fire at the left side of the logo text
|
|
if not self.rotated:
|
|
self.logo_fire.location.midleft = self.logo_text.location.midleft
|
|
else:
|
|
self.logo_fire.location.midbottom = self.logo_text.location.midbottom
|
|
|
|
# Close the draw clip for the logo completely
|
|
self.logo_text_clip = self.logo_text.location.copy()
|
|
if not self.rotated:
|
|
self.logo_text_clip.width = 0
|
|
else:
|
|
self.logo_text_clip.height = 0
|
|
|
|
# Queue the text reveal to start
|
|
self.play(self.reveal_text, delay=self.configuration.get("logo", "delay"))
|
|
|
|
def reveal_text(self):
|
|
"""
|
|
Move the fire right, opening the logo text clip rect until it's big enough to show all the text. Queue the
|
|
animation to restart once finished.
|
|
"""
|
|
self.logo_fire.move(10, rotated=self.rotated)
|
|
halt = False
|
|
if not self.rotated:
|
|
self.logo_text_clip.width = self.logo_fire.location.left - self.logo_text.location.left
|
|
limit = self.logo_text.location.right + self.configuration.get("logo", "margin") / 2
|
|
if self.logo_fire.location.left > limit:
|
|
self.logo_fire.location.left = limit
|
|
halt = True
|
|
else:
|
|
self.logo_text_clip.height = self.logo_text.location.bottom - self.logo_fire.location.bottom
|
|
self.logo_text_clip.bottom = self.logo_text.location.bottom
|
|
limit = self.logo_text.location.top - self.configuration.get("logo", "margin") / 2
|
|
if self.logo_fire.location.bottom < limit:
|
|
self.logo_fire.location.bottom = limit
|
|
halt = True
|
|
if halt:
|
|
self.halt(self.reveal_text)
|
|
self.play(self.animate_logo, delay=self.configuration.get("logo", "restart"), play_once=True)
|
|
|
|
def respond(self, event: pygame.event.Event):
|
|
"""
|
|
Handle all posted framework commands.
|
|
|
|
If the any input event is posted, and a special combination of buttons isn't being held down, launch the game.
|
|
|
|
If the any input event is posted, and the designated function modifier is held down, send a signal to the
|
|
dispenser pin if the "action", "start", and "select" keys/buttons are held down.
|
|
|
|
If the function modifier is held down, all any input commands other than the dispenser test will be ignored.
|
|
|
|
@param event Pygame event object, expected to be sent from the framework
|
|
"""
|
|
# Any key or button press
|
|
if self.delegate.compare(event, "any"):
|
|
|
|
# Check if the special combination of buttons for testing the card dispenser is pressed. The first clause is
|
|
# to check if the designated function modifier is pressed.
|
|
if self.input.is_command_active("function"):
|
|
if self.input.is_command_active("action") \
|
|
and self.input.is_command_active("start") \
|
|
and self.input.is_command_active("select"):
|
|
# Send a signal to a Raspberry Pi dispenser pin
|
|
if rpi_available:
|
|
GPIO.output(DISPENSER_PIN, GPIO.HIGH)
|
|
self.play(self.end_output_signal, play_once=True, delay=500)
|
|
self.suppress_input_temporarily()
|
|
|
|
else:
|
|
# Release all GPIO pins and run BiTFiT until it quits
|
|
if rpi_available:
|
|
GPIO.cleanup()
|
|
launcher_directory = pathlib.Path.cwd()
|
|
os.chdir(pathlib.Path("games/bitfit"))
|
|
bitfit = Bitfit(suppress_gpio_init=False, rotate=self.rotated)
|
|
bitfit.display.set_screen(dimensions=bitfit.configuration.get("display", "dimensions"))
|
|
bitfit.run()
|
|
|
|
# Return to launcher and reclaim GPIO pins
|
|
os.chdir(launcher_directory)
|
|
self.display.set_screen(dimensions=self.configuration.get("display", "dimensions"))
|
|
if rpi_available:
|
|
self.initialize_gpio()
|
|
|
|
def end_output_signal(self):
|
|
if rpi_available:
|
|
GPIO.output(DISPENSER_PIN, GPIO.LOW)
|
|
print("End output signal")
|
|
|
|
def update(self):
|
|
# This runs the Animation class update
|
|
super().update()
|
|
|
|
# Temporary fix: this stops audio when the game is quit and prevents spawning multiple BGM when game is
|
|
# relaunched
|
|
pygame.mixer.stop()
|
|
|
|
# Erase the center of the screen if enabled
|
|
if not self.configuration.get("logo", "trail"):
|
|
erase_rect = self.logo_text.location.copy()
|
|
if not self.rotated:
|
|
erase_rect.width = self.logo_fire.location.right - self.logo_text.location.left
|
|
else:
|
|
erase_rect.height = self.logo_text.location.bottom - self.logo_fire.location.top
|
|
erase_rect.bottom = self.logo_text.location.bottom
|
|
self.get_display_surface().fill(self.configuration.get("display", "clear"), erase_rect)
|
|
|
|
# Draw the logo text clipped to the current state of the animation, then remove the clip
|
|
self.get_display_surface().set_clip(self.logo_text_clip)
|
|
self.logo_text.update()
|
|
self.get_display_surface().set_clip(None)
|
|
|
|
# Draw the fire
|
|
self.logo_fire.update()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Parse command line arguments
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument(
|
|
"--keep-working-directory", action="store_true", help="""
|
|
Keep using the current directory as the working directory. The script expects the games to be in the games/
|
|
folder, so they can be imported as Python modules. Changing the directory to anything other than the launcher
|
|
project's root folder will break it under normal circumstances, so use with care.
|
|
""")
|
|
parser.add_argument(
|
|
"--kms", action="store_true", help="""
|
|
Use SDL 2's KMS video driver. For use on systems without a windowing system (like Linux in only console mode).
|
|
See https://wiki.libsdl.org/SDL2/FAQUsingSDL#how_do_i_choose_a_specific_video_driver
|
|
""")
|
|
parser.add_argument(
|
|
"--framebuffer", action="store_true", help="""
|
|
Use SDL 1.2's framebuffer video driver. For use on older systems without a windowing system that aren't using
|
|
KMS. This won't work with SDL 2 or Pygame 2. See
|
|
https://wiki.libsdl.org/SDL2/FAQUsingSDL#how_do_i_choose_a_specific_video_driver
|
|
""")
|
|
parser.add_argument(
|
|
"--ignore-hangup", action="store_true", help="""
|
|
Ignore hangup signals. Enabling this may be necessary for running the launcher as a systemd service. See
|
|
https://stackoverflow.com/questions/57205271/how-to-display-pygame-framebuffer-using-systemd-service
|
|
""")
|
|
arguments, unknown = parser.parse_known_args()
|
|
|
|
# If not keeping the working directory, try to move into the same directory as where this program is stored. Use the
|
|
# location of the program that launched this process to determine the path.
|
|
if not arguments.keep_working_directory:
|
|
target = os.path.dirname(sys.argv[0])
|
|
if not pathlib.Path(os.getcwd()).samefile(target):
|
|
try:
|
|
os.chdir(target)
|
|
except Exception:
|
|
print("Warning: detected that the working directory is not the same as the launcher project's root"
|
|
f"directory and could not change to detected directory {target}")
|
|
|
|
if arguments.kms:
|
|
|
|
# Use the KMS video driver. This works for newer versions of Raspberry Pi with the KMS overlay enabled, SDL 2,
|
|
# and Pygame 2.
|
|
os.putenv("SDL_VIDEODRIVER", "kmsdrm")
|
|
|
|
elif arguments.framebuffer:
|
|
|
|
# Use the framebuffer display. This only works with Pygame 1.9.6 (and SDL 1.2).
|
|
os.putenv("SDL_VIDEODRIVER", "fbcon")
|
|
os.putenv("SDL_FBDEV", "/dev/fb0")
|
|
|
|
# Run the launcher
|
|
playzing = Playzing()
|
|
playzing.run()
|
|
|
|
# Release GPIO pins
|
|
if rpi_available:
|
|
GPIO.cleanup()
|
|
|
|
# LocalWords: kmsdrm fbcon fb KMS FAQUsingSDL bitfit
|