322 lines
13 KiB
Python
Executable File
322 lines
13 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()
|
|
|
|
# Launch BiTFiT
|
|
else:
|
|
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()
|
|
os.chdir(pathlib.Path("../.."))
|
|
self.display.set_screen(dimensions=self.configuration.get("display", "dimensions"))
|
|
|
|
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()
|
|
|
|
# LocalWords: kmsdrm fbcon fb KMS FAQUsingSDL bitfit
|