ibitfit/bitfit/Bitfit.py

1392 lines
55 KiB
Python

# -*- coding: utf-8 -*-
import sys
import random
import subprocess
import shutil
import tempfile
from random import randint, randrange, choice
from time import time
from operator import itemgetter
import pygame
from pygame import Surface, PixelArray, Rect, Color
from pygame.draw import polygon
from pygame.font import Font
from pygame.mixer import Sound
from pygame.locals import QUIT, SRCALPHA
import gpiozero # type: ignore[import-untyped]
from gpiozero.pins.mock import MockFactory # type: ignore[import-untyped]
from lib.pgfw.pgfw.Game import Game
from lib.pgfw.pgfw.GameChild import GameChild
from lib.pgfw.pgfw.Sprite import Sprite, BlinkingSprite, RainbowSprite
from lib.pgfw.pgfw.Vector import Vector
from lib.pgfw.pgfw.Animation import Animation
from lib.pgfw.pgfw.Audio import SoundEffect
from lib.pgfw.pgfw.extension import render_box
from .land.Land import Land
# Import GPIO library if available
try:
import RPi.GPIO as GPIO
except ImportError:
pass
# The GPIO pins corresponding to hard-wired controller buttons and card dispenser
PIN_BUTTON_LEFT: int = 18
PIN_BUTTON_RIGHT: int = 16
DISPENSER_PIN: str = "BOARD37"
# Palette to randomly select from each frame to create a flashing effect
flash_colors = (255, 255, 180), (180, 255, 255), (255, 180, 255), (255, 220, 160), (160, 255, 220), (220, 160, 255)
class Bitfit(Game):
def __init__(self, suppress_gpio_init=False, rotate=False):
"""
Initialize super class, GPIO input, background, and activate the title screen.
"""
# Initialize super
Game.__init__(self)
# Add type declarations for custom config entries
self.get_configuration().type_declarations.add_chart({
"display": {
"bool": "rotate",
"path": "prompt-image"
},
"input": {
"int": ["title-hold", "initials-hold", "initials-idle"]
},
"audio": {
"int": "title-fade",
"float": ["counter-volume", "spin-volume", "win-volume", "lose-volume"],
"path": [
"title", "title-advance", "triangles", "hit", "miss", "end", "noise", "counter", "spin", "win",
"lose"
],
},
"land": {
"int": ["gradient", "height", "x-step"],
"float": ["altitude-ratio", "spacing-factor", "velocity-ratio", "fade-speed"]
},
"prize": {
"bool": "enabled",
"path": ["banner", "meter", "win", "lose"],
"int": "length"
},
})
# 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
}
# Rotate the display if requested
if rotate:
self.configuration.set("display", "rotate", True)
self.rotated = False
if self.get_configuration("display", "rotate"):
self.display.rotate()
self.rotated = True
# Initialize GPIO input and callbacks if GPIO library is loaded
if not suppress_gpio_init:
self.initialize_gpio()
self.velocity = Vector(0, 0)
# Create an intermediate surface to draw the triangles to and create a trail effect
self.trail_effect = Surface(self.get_display_surface().get_size(), pygame.SRCALPHA)
# Create a black background
self.background = Surface(self.get_display_surface().get_size())
self.background.fill((0, 0, 0))
# Initialize the flash color effect
self.flash_color = flash_colors[0]
# Alpha filter
self.alpha_filter = Surface(self.get_display_surface().get_size(), pygame.SRCALPHA)
self.alpha_filter.fill(pygame.Color(0, 0, 0, 80))
# Create game objects
self.title = Title(self)
self.sieve = Sieve(self)
self.triangles = Triangles(self)
self.acid = Acid(self)
self.static = Static(self)
self.land = Land(self)
# Start the title screen
self.title.activate()
# Subscribe to framework commands
self.subscribe(self.respond)
# Register dispenser test function as an animation, so it can be triggered on a delay
self.register(self.print_dispenser_status)
def initialize_gpio(self):
"""
Set pin numbering mode to GPIO, initialize all buttons to input pullup.
"""
# The low level library will only be available on Raspberry Pi devices
if "RPi.GPIO" in sys.modules:
# Use GPIO 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, and turn it off. If the pin fails to load, assume
# the code is not running on Raspberry Pi and use mock pins.
try:
self.dispenser = gpiozero.DigitalOutputDevice(pin=DISPENSER_PIN, initial_value=False)
except gpiozero.BadPinFactory:
# Use mock pins
gpiozero.Device.pin_factory = MockFactory()
self.dispenser = gpiozero.DigitalOutputDevice(pin=DISPENSER_PIN, initial_value=False)
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. "
f"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)
def orient(self, geometry):
"""
Orient the passed pgfw.Vector, pygame.Rect, or pygame.Surface so it is rotated if necessary.
@param geometry A pgfw.Vector or pygame.Rect to rotate
@return Either a new pgfw.Vector or pygame.Rect depending on which was passed
"""
if not self.rotated:
return geometry
else:
if isinstance(geometry, Vector):
return self.rotated_point(geometry)
elif isinstance(geometry, pygame.Rect):
return self.rotated_rect(geometry)
elif isinstance(geometry, pygame.Surface):
return pygame.transform.rotate(geometry, 90)
def rotated_point(self, point):
"""
Return a new pgfw.Vector with the X and Y values of a pgfw.Vector rotated 90 degrees.
@param point pgfw.Vector to rotate
@return rotated pgfw.Vector
"""
return Vector(self.get_display_surface().get_height() - point.x, point.y)
def rotated_rect(self, rect):
"""
Return a new pygame.Rect rotated 90 degrees.
@param rect pygame.Rect to rotate
@return rotated pygame.Rect
"""
rotated = pygame.Rect(0, 0, 0, 0)
rotated.x = rect.y
rotated.y = self.get_display_surface().get_height() - rect.x + rect.w
rotated.w = rect.h
rotated.h = rect.w
return rotated
def end(self, event):
"""
Extend the parent end method to try adding a permanent quit feature in case there is a Raspbian Lite systemd
auto start service running
"""
if event.type == QUIT or self.delegate.compare(event, "quit"):
if self.confirming_quit or not self.get_configuration("input", "confirm-quit"):
# If SHIFT is pressed, try permanently stopping the systemd service to get a console back in case the
# program is running on Raspbian Lite
if pygame.key.get_mods() & pygame.KMOD_SHIFT:
try:
subprocess.run(["sudo", "systemctl", "stop", "ibitfit"])
print("Killing with permanent stop sent to systemd ibitfit.service")
except Exception:
print("No BiTFiT system service detected, so permanent quit either failed or was unnecessary")
super().end(event)
def respond(self, event: pygame.event.Event):
"""
Run automatically by the framework when a command is posted. If the select button is pressed, send a signal to
the dispenser pin if the designated combination of buttons are also pressed (A, B, and L).
@param event Pygame event object associated with the posted command
"""
# Check for the combination of buttons that trigger a dispense test when the select key is pressed
if self.delegate.compare(event, "select"):
if self.input.is_command_active("a") \
and self.input.is_command_active("b") \
and self.input.is_command_active("l"):
# Send a signal to a Raspberry Pi dispenser pin
self.send_dispense_signal()
def send_dispense_signal(self):
"""
Send one signal lasting 500ms to the dispenser pin by blinking the signal once.
"""
message = f"Dispenser set from {bool(self.dispenser.value)}"
self.dispenser.blink(on_time=0.5, n=1)
print(f"{message} to {bool(self.dispenser.value)} for 0.5 seconds")
self.play(self.print_dispenser_status, delay=1200, play_once=True)
self.suppress_input_temporarily()
def print_dispenser_status(self):
print(f"Dispenser state is {bool(self.dispenser.value)}")
def update(self):
super().update()
# Choose a new random color from a palette to create the flash effect
self.flash_color = choice(flash_colors)
# Test if the level is being played
if self.triangles.active:
# Draw grid effect
self.land.update()
# Draw triangles onto the trail effect surface, update position
self.triangles.update()
if not self.title.active:
# Draw bottom layer background
self.get_display_surface().blit(self.background, (0, 0))
# Draw static behind objects on title screen
if self.title.active:
self.static.update()
# Draw the triangles to the screen, using the intermediate trail effect surface
self.get_display_surface().blit(self.trail_effect, (0, 0))
self.title.update()
# Draw the sieve
self.sieve.update()
if not self.title.active:
# Draw the static
self.static.update()
class Title(GameChild):
def __init__(self, parent):
GameChild.__init__(self, parent)
self.display_surface = self.get_display_surface()
self.delegate = self.parent.delegate
bg_color = 200, 168, 122
self.background = surface = Surface(self.display_surface.get_size())
tile = Surface((2, 2))
tile.fill(bg_color)
for y in range(0, surface.get_height(), 2):
for x in range(0, surface.get_width(), 2):
surface.blit(self.get_game().orient(tile), (x, y))
self.scoreboard = Scoreboard(self)
self.music = Sound(self.get_resource("audio", "title"))
self.advance = Sound(self.get_resource("audio", "title-advance"))
self.prompt_surface = pygame.image.load(self.get_configuration("display", "prompt-image")).convert_alpha()
self.prompt_surface = self.get_game().orient(
pygame.transform.smoothscale(
self.prompt_surface, (440, 440 * 0.6666)
))
# Load and position a banner at the bottom of the screen, prompting the player to play for a chicken nugget
if self.get_configuration("prize", "enabled"):
frame = self.get_game().orient(pygame.image.load(self.get_resource("prize", "banner")).convert_alpha())
if not self.get_game().rotated:
new_w = self.get_display_surface().get_width() + 100
frame = pygame.transform.smoothscale(frame, (new_w, frame.get_height() * new_w / frame.get_width()))
self.banner = RainbowSprite(self, frame)
self.banner.location.midbottom = self.get_display_surface().get_rect().move(0, -15).midbottom
else:
new_h = self.get_display_surface().get_height() + 100
frame = pygame.transform.smoothscale(frame, (frame.get_width() * new_h / frame.get_height(), new_h))
self.banner = RainbowSprite(self, frame)
self.banner.location.midright = self.get_display_surface().get_rect().move(-15, 0).midright
# Respond to input
self.subscribe(self.respond)
def respond(self, event: pygame.event.Event):
if self.active:
self.idle_time = 0
if not self.music.get_num_channels():
self.music.play(-1, 0, 1000)
self.get_game().static.noise.fadeout(1000)
if self.delegate.compare(event, "advance"):
self.holding_button = True
def activate(self):
self.active = True
self.holding_button = False
self.holding_button_elapsed = 0
self.idle_time = 0
self.music.play(-1)
self.get_game().static.activate()
self.get_game().static.full()
self.get_game().static.noise.stop()
self.get_game().sieve.activate()
self.get_game().triangles.activate(music=False)
def deactivate(self):
self.active = False
self.music.fadeout(500)
def update(self):
if self.active:
if self.holding_button_elapsed > self.get_configuration("input", "title-hold"):
self.deactivate()
self.parent.triangles.reset()
while self.parent.triangles:
self.parent.triangles.pop()
self.parent.triangles.activate()
self.parent.sieve.activate()
self.parent.static.reset()
self.parent.static.activate()
self.advance.play()
self.get_game().trail_effect.fill(Color(0, 0, 0, 0))
self.get_display_surface().blit(self.get_game().background, (0, 0))
else:
if self.idle_time > self.get_configuration("audio", "title-fade"):
self.music.fadeout(5000)
if not self.get_game().static.noise.get_num_channels():
self.get_game().static.noise.set_volume(0.25)
self.get_game().static.noise.play(-1, 0, 5000)
else:
self.idle_time += self.get_game().time_filter.get_last_frame_duration()
if self.holding_button:
self.holding_button_elapsed += self.get_game().time_filter.get_last_frame_duration()
self.scoreboard.update()
logo = Sprite(self)
logo.clear_frames()
elapsed = self.holding_button_elapsed / self.get_configuration("input", "title-hold")
if elapsed:
size = self.get_game().orient(
pygame.Rect(
0, 0, max(0, 440 - int(elapsed * 440)), max(0, 440 * 0.6666 - int(elapsed * 440 * 0.6666))
))
logo.add_frame(pygame.transform.scale(self.prompt_surface, size.size))
else:
logo.add_frame(self.prompt_surface)
logo.location.center = self.get_display_surface().get_rect().move(0, 5).center
logo.update()
if self.get_configuration("prize", "enabled"):
self.banner.update()
class Strip(Sprite):
LEFT, RIGHT = range(2)
def __init__(self, parent, interval):
Sprite.__init__(self, parent, interval)
self.deactivate()
self.display_surface = self.get_display_surface()
self.delegate = self.get_game().delegate
if not self.get_game().rotated:
self.hshifts = Shift(self, 1, "shift-2"), Shift(self, -1, "shift-2")
else:
self.hshifts = Shift(self, -1, "shift-2"), Shift(self, 1, "shift-2")
self.add_frames()
def deactivate(self):
self.active = False
def reset(self):
for shift in self.hshifts:
shift.reset()
def add_frames(self):
pass
def activate(self):
self.active = True
def update(self):
if self.active:
self.hshifts[self.LEFT].active = self.get_game().input.is_command_active("left")
self.hshifts[self.RIGHT].active = self.get_game().input.is_command_active("right")
for shift in self.hshifts:
shift.update()
if shift.time:
if not self.get_game().rotated:
self.move(shift.get_change())
else:
self.move(dy=shift.get_change())
Sprite.update(self)
class Shift(GameChild):
def __init__(self, parent, direction, nodeset):
GameChild.__init__(self, parent)
self.direction = direction
self.reset()
self.timer = self.get_game().time_filter
self.nodeset = self.get_game().interpolator.get_nodeset(nodeset)
def reset(self):
self.active = False
self.time = 0
def update(self):
least, greatest = self.nodeset[0].x, self.nodeset[-1].x
if self.active and self.time < greatest:
self.time = min(self.time + self.timer.get_last_frame_duration(), greatest)
elif not self.active and self.time > least:
self.time = max(self.time - self.timer.get_last_frame_duration(), least)
def get_change(self):
return self.nodeset.get_y(self.time) * self.direction
class Scoreboard(GameChild):
BACKGROUND = 255, 255, 255
FOREGROUND = 27, 27, 27
NEW = 27, 27, 27
SPACING = 45
MARGIN = 0
BLINK_INTERVAL = 400
PADDING = 0
BORDER = 1
SCORE_COUNT = 11
SIZES = [32, 28, 24, 22, 22, 20, 20, 20, 18, 18, 18]
def __init__(self, parent):
GameChild.__init__(self, parent)
self.display_surface = self.get_display_surface()
self.scores_path = self.get_resource("score", "path")
self.most_recent_score = None
self.set_scores()
self.load()
def set_scores(self):
self.scores = []
try:
with open(self.scores_path, "r") as fp:
for line in fp:
fields = line.split()
self.scores.append((float(fields[0]), int(fields[1]), fields[2]))
fp.close()
except Exception:
print("Warning: error while reading scores file. Ignoring for now.")
self.scores = sorted(self.scores, key=itemgetter(0))
self.scores = sorted(self.scores, key=itemgetter(1), reverse=True)
def load(self):
self.sprites = sprites = []
font_path = self.get_resource("display", "scoreboard-font-path")
blink = False
for ii, score in enumerate(self.get_scores()[:len(self.SIZES)]):
font = Font(font_path, self.SIZES[ii])
sprites.append((Sprite(self, self.BLINK_INTERVAL), Sprite(self, self.BLINK_INTERVAL)))
score_text = str(score[1])
color = self.BACKGROUND \
if (self.most_recent_score and not blink and score[1:] == self.most_recent_score) \
else self.FOREGROUND
score_plate = font.render(score_text, False, color, self.BACKGROUND)
rect = score_plate.get_rect()
surface = Surface(rect.inflate((2, 2)).size)
surface.fill(self.FOREGROUND)
rect.center = surface.get_rect().center
surface.blit(score_plate, rect)
width = 80
sprites[ii][1].add_frame(self.get_game().orient(
render_box(font, score_text, True, color, self.BACKGROUND, (0, 0, 0), padding=self.PADDING, width=width)
))
sprites[ii][0].add_frame(self.get_game().orient(
render_box(font, score[2], True, color, self.BACKGROUND, (0, 0, 0), padding=self.PADDING, width=width)
))
if self.most_recent_score and not blink and score[1:] == self.most_recent_score:
sprites[ii][1].add_frame(self.get_game().orient(
render_box(
font, score_text, True, self.NEW, self.BACKGROUND, (0, 0, 0), padding=self.PADDING, width=width
)))
sprites[ii][0].add_frame(self.get_game().orient(
render_box(
font, score[2], True, self.NEW, self.BACKGROUND, (0, 0, 0), padding=self.PADDING, width=width
)))
blink = True
if not self.get_game().rotated:
sprites[ii][0].location.left = self.MARGIN
sprites[ii][1].location.right = self.get_display_surface().get_rect().right - self.MARGIN
y = self.get_display_surface().get_rect().centery + self.SPACING * (ii - len(self.SIZES) / 2)
if (ii < 5):
y -= 75
else:
y += 30
for sprite in sprites[ii]:
sprite.location.centery = y
else:
sprites[ii][0].location.bottom = self.get_display_surface().get_height() - self.MARGIN
sprites[ii][1].location.top = self.MARGIN
x = self.get_display_surface().get_rect().centerx + self.SPACING * (ii - len(self.SIZES) / 2)
if (ii < 5):
x -= 75
else:
x += 30
for sprite in sprites[ii]:
sprite.location.centerx = x
def get_scores(self):
return self.scores
def write(self, initials: str, attempts: int = 0):
"""
Write the player's score to a file. To protect against corruption, copy the file to a temporary location before
writing to it. Append the latest score to the temporary file, and read the file to check for corruption. If no
exceptions are raised, overwrite the original file with the temporary file.
If an exception occurs, re-run the function with the `attempts` parameter incremented. Once attempts reaches 4,
no more attempts will be made, and the score will be discarded.
@param initials Player's initials to be saved with the score
@param attempts Number of recursive tries that have happened so far. The function sets this internally.
"""
# Get the most recent score, and set the fields which will be stored
score = int(round(self.get_game().triangles.score))
fields = str(time()), str(score), initials
if attempts < 4:
try:
entry = fields[0] + " " + fields[1] + " " + fields[2] + "\n"
# The temporary file will not be deleted until the context is exited
with tempfile.NamedTemporaryFile(delete_on_close=False) as temp:
# Close immediately, so the scores file can be copied to this path
temp.close()
# Copy scores file to a temporary path
shutil.copyfile(self.scores_path, temp.name)
# Append score to the temporary file
with open(temp.name, "a") as fp:
fp.write(entry)
# Read the temp file data to check for corruption
with open(temp.name, "r") as fp:
for line in fp:
written = line.split()
_, score, _ = float(written[0]), int(written[1]), written[2]
# Overwrite the original file with the temp file
shutil.copyfile(temp.name, self.scores_path)
except Exception:
# Retry the write if something went wrong
print("Warning: unknown error received while writing scores. Retrying.")
self.write(initials, attempts + 1)
return
# Save the score to RAM, and re-draw the scoreboard
self.most_recent_score = score, initials
self.scores.append((float(fields[0]), int(fields[1]), fields[2]))
self.scores = sorted(self.scores, key=itemgetter(0))
self.scores = sorted(self.scores, key=itemgetter(1), reverse=True)
self.load()
def update(self):
for pair in self.sprites:
for sprite in pair:
sprite.update()
class Sieve(Strip):
UP, DOWN = range(2)
def __init__(self, parent):
Strip.__init__(self, parent, 400)
self.delegate = self.get_game().delegate
self.electric = Electric(self)
if not self.get_game().rotated:
self.location.left = 0
self.add_location(offset=(self.location.w, 0))
else:
self.location.bottom = self.get_display_surface().get_height()
self.add_location(offset=(0, -self.location.h))
def add_frames(self):
bar_locations = []
self.bar_rects = bar_rects = []
x = 0
sh = 30
nodeset = self.get_game().interpolator.get_nodeset("scale")
self.bar_w = bar_w = 3
self.gaps = gaps = []
while x < nodeset[-1].x:
bar_locations.append(x)
bar_rects.append(Rect(x, 0, bar_w, sh))
gaps.append(nodeset.get_y(x, natural=True))
x += gaps[-1]
surface = Surface((x, sh))
transparent_color = (255, 0, 255)
surface.fill(transparent_color)
surface.set_colorkey(transparent_color)
frames = surface, surface.copy()
# colors = (0, 255, 0), (153, 0, 204)
colors = (255, 255, 255), (255, 255, 255)
for x in bar_locations:
bar_rects.append(Rect(x + surface.get_width(), 0, bar_w, sh))
for ii, frame in enumerate(frames):
frame.fill(colors[ii], (x, 0, bar_w, sh))
frame.fill(colors[ii - 1], (x + 1, 1, 1, sh - 2))
if self.get_game().rotated:
for ii, rect in enumerate(bar_rects):
bar_rects[ii] = self.get_game().orient(rect)
bar_rects[ii].move_ip(0, -6)
for frame in frames:
self.add_frame(self.get_game().orient(frame))
def reset(self):
Strip.reset(self)
if not self.get_game().rotated:
self.location.centerx = self.display_surface.get_rect().centerx
self.locations[1].centerx = self.location.centerx + self.location.w
else:
self.location.centery = self.display_surface.get_rect().centery
self.locations[1].centery = self.location.centery - self.location.h
def update(self):
if self.active:
if not self.get_game().rotated:
if self.location.right < 0:
self.move(self.location.w)
if self.locations[1].left > self.display_surface.get_width():
self.move(-self.location.w)
for location in self.locations:
location.bottom = self.parent.acid.get_top()
self.electric.location.centery = self.location.centery + 13
else:
if self.location.top > self.display_surface.get_height():
self.move(dy=-self.location.h)
if self.locations[1].bottom < 0:
self.move(dy=self.location.h)
for location in self.locations:
location.right = self.parent.acid.get_top()
self.electric.location.centerx = self.location.centerx + 13
self.electric.update()
for rect in self.bar_rects:
if not self.get_game().rotated:
rect.centery = self.location.centery
else:
rect.centerx = self.location.centerx
Strip.update(self)
class Electric(Sprite):
def __init__(self, parent):
Sprite.__init__(self, parent)
self.display_surface = self.get_display_surface()
self.add_frames()
def add_frames(self):
if not self.get_game().rotated:
surface = Surface((self.display_surface.get_width(), self.parent.location.h - 10))
else:
surface = Surface((self.display_surface.get_height(), self.parent.location.w - 10))
frames = surface, surface.copy()
# colors = (255, 255, 0), (100, 89, 213)
# colors = (180, 152, 111), (180, 152, 111)
colors = (255, 255, 255), (255, 255, 255)
pixel_arrays = PixelArray(frames[0]), PixelArray(frames[1])
for x in range(len(pixel_arrays[0])):
for y in range(len(pixel_arrays[0][0])):
pixel_arrays[0][x][y] = colors[(y + x) // 5 % 2]
pixel_arrays[1][x][y] = colors[(y + x + 1) // 5 % 2]
for pixels in pixel_arrays:
del pixels
for frame in frames:
self.add_frame(self.get_game().orient(frame))
class Triangles(GameChild, list):
def __init__(self, parent):
GameChild.__init__(self, parent)
self.hue = 0
self.music = Sound(self.get_resource("audio", "triangles"))
self.deactivate()
self.display_surface = self.get_game().trail_effect
self.delegate = self.get_game().delegate
self.booster = Shift(self, 1, "boost")
self.hit = Sound(self.get_resource("audio", "hit"))
self.miss = Sound(self.get_resource("audio", "miss"))
self.reset()
self.subscribe(self.respond)
def deactivate(self):
self.active = False
self.music.fadeout(500)
def reset(self):
list.__init__(self, [])
self.streak = 0
self.score = 0
self.booster.reset()
def populate(self):
if not self:
self.append(Triangle(self))
if not self.get_game().rotated:
self[-1].location.bottom = 0
else:
self[-1].location.right = 0
self.set_next_gap()
if not self.get_game().rotated:
while self[-1].location.top > -self.display_surface.get_height():
self.append(Triangle(self))
self[-1].location.bottom = self[-2].location.top - self.next_gap
self.set_next_gap()
else:
while self[-1].location.left > -self.display_surface.get_width():
self.append(Triangle(self))
self[-1].location.right = self[-2].location.left - self.next_gap
self.set_next_gap()
def set_next_gap(self):
self.next_gap = randint(500, 800)
def respond(self, event):
if self.active:
compare = self.delegate.compare
if compare(event, "down") or compare(event, "down", True):
self.booster.active = not event.cancel
def get_boost(self):
return self.booster.get_change()
def activate(self, music=True):
self.active = True
if music:
self.music.play(-1, 0, 500)
def update(self):
if self.active:
self.populate()
self.booster.update()
if self[0].location.collidelist(self.parent.sieve.locations) != -1:
sieve = self.parent.sieve
removed = False
if self[0].location.colliderect(sieve.electric.location):
if not self.get_game().title.active:
self.parent.acid.increase()
self.streak += 1
self.score += self.streak ** .8 + self.parent.acid.get_volume() * 5 + self[0].count
self.remove(self[0])
self.hit.play()
removed = True
else:
for br in sieve.bar_rects:
for tr in self[0].collision_rects:
tr_offset = (self[0].location.left, 0) if not self.get_game().rotated else \
(0, self[0].location.bottom - self.get_display_surface().get_height())
br_offset = (sieve.location.left, 0) if not self.get_game().rotated else \
(0, sieve.location.bottom - self.get_display_surface().get_height())
if tr.move(tr_offset).colliderect(br.move(br_offset)):
if not self.get_game().title.active:
self.parent.static.increase()
self.streak = 0
self.remove(self[0])
self.miss.play()
removed = True
break
if removed:
self.get_display_surface().blit(self.get_game().alpha_filter, (0, 0), None, pygame.BLEND_RGBA_SUB)
for triangle in self:
triangle.update()
class Triangle(Sprite):
def __init__(self, parent):
Sprite.__init__(self, parent, 100)
mark = randint(112, 328)
sieve = self.parent.parent.sieve
gaps = sieve.gaps
start = randrange(0, len(gaps))
widths = [gaps[start]]
while sum(widths) < mark:
widths.append(gaps[(start + len(widths)) % len(gaps)])
surface = Surface((sum(widths), 20))
surface.set_colorkey((0, 0, 0))
height = surface.get_height()
margin = 26
self.collision_rects = collision_rects = []
for ii, lightness in enumerate(range(30, 110, 10)):
color = pygame.Color(0, 0, 0)
color.hsla = parent.hue, 100, lightness, 100
# opposite_color = pygame.Color(0, 0, 0)
# opposite_color.hsla = (parent.hue + 180) % 360, 100, lightness, 100
x = 0
surface = surface.copy()
for width in widths:
x += sieve.bar_w
points = ((x + margin // 2, height - 2),
(x + width - margin // 2 - 1, height - 2),
(x + width / 2.0, 1))
polygon(surface, color, points)
if ii == 0:
if not self.get_game().rotated:
collision_rects.append(Rect(points[0], (width - margin - 1, 1)))
else:
collision_rects.append(
Rect(height - 2 - 1,
self.get_display_surface().get_height() - x - width + margin // 2 + 1,
1,
width - margin - 1))
# points = ((x + margin // 2 + (width * .1), height - 2 - 2),
# (x + width - margin // 2 - 1 - (width * .1), height - 2 - 2),
# (x + width / 2.0, 1 + 5))
# polygon(surface, opposite_color, points)
x += width - sieve.bar_w
self.add_frame(self.get_game().orient(surface))
next_hue = parent.hue
while abs(next_hue - parent.hue) < 60:
next_hue = random.randint(0, 359)
parent.hue = next_hue
if not self.get_game().rotated:
self.location.centerx = self.get_display_surface().get_rect().centerx
else:
self.location.centery = self.get_display_surface().get_rect().centery
self.count = len(widths)
def update(self):
step = 9.5 * self.get_game().acid.get_volume() + 3.8 + self.parent.get_boost()
if not self.get_game().rotated:
self.move(dy=step)
else:
self.move(dx=step)
for rect in self.collision_rects:
if not self.get_game().rotated:
rect.bottom = self.location.bottom
else:
rect.right = self.location.right
Sprite.update(self)
class Acid(GameChild):
def __init__(self, parent):
GameChild.__init__(self, parent)
self.display_surface = self.get_display_surface()
self.level_r = 80, 320
self.nodeset = self.get_game().interpolator.get_nodeset("volume")
self.reset()
def reset(self):
self.substance = 0
def get_top(self):
if not self.get_game().rotated:
return self.display_surface.get_height() - self.get_level()
else:
return self.display_surface.get_width() - self.get_level()
def get_level(self):
return self.get_volume() * (self.level_r[1] - self.level_r[0]) + self.level_r[0]
def get_volume(self):
return self.nodeset.get_y(self.substance)
def increase(self):
self.substance += 1
class Static(Sprite):
def __init__(self, parent):
Sprite.__init__(self, parent, 120)
self.advance_automatically = 0
self.noise = Sound(self.get_resource("audio", "noise"))
self.end = Sound(self.get_resource("audio", "end"))
self.deactivate()
self.delegate = self.get_game().delegate
self.increaser = Shift(self, 1, "intensity")
self.total = Total(self)
self.initials = Initials(self)
self.prize_meter = PrizeMeter(self)
self.spinner = Spinner(self)
self.reset()
self.add_frames()
def deactivate(self):
self.active = False
self.end.fadeout(500)
def reset(self):
self.advance_automatically = 0
self.complete = False
self.intensity = 0
self.noise.set_volume(0)
self.increaser.reset()
self.spinner.reset()
def add_frames(self):
surface = Surface(self.get_display_surface().get_size())
frames = surface, surface.copy(), surface.copy(), surface.copy()
tiles = []
for _ in range(32):
tiles.append(Surface((16, 16)))
pixel_arrays = []
for tile in tiles:
pixel_arrays.append(PixelArray(tile))
colors = (0, 0, 0), (64, 64, 64), (128, 128, 128), (196, 196, 196), (255, 255, 255)
for x in range(len(pixel_arrays[0])):
for y in range(len(pixel_arrays[0][0])):
for pixels in pixel_arrays:
pixels[x][y] = choice(colors)
for pixels in pixel_arrays:
del pixels
del pixel_arrays
for frame in frames:
for y in range(0, frame.get_height(), tiles[0].get_height()):
for x in range(0, frame.get_width(), tiles[0].get_width()):
frame.blit(choice(tiles), (x, y))
self.add_frame(frame)
def finish(self, text="---", wipe=False):
if wipe:
self.parent.title.scoreboard.most_recent_score = None
self.parent.title.scoreboard.write(text)
self.total.deactivate()
self.deactivate()
self.reset()
self.parent.acid.reset()
self.parent.triangles.reset()
self.parent.sieve.reset()
self.parent.title.activate()
def increase(self):
self.intensity += self.increaser.get_change()
if self.intensity > 1:
self.intensity = 1
self.increaser.time += 12000
if self.increaser.time >= self.increaser.nodeset[-1].x + 5000:
self.increaser.time = self.increaser.nodeset[-1].x + 5000
def full(self):
self.intensity = 1
def activate(self):
self.active = True
self.noise.play(-1)
def update(self):
if self.active:
if not self.get_game().title.active:
if not self.complete and self.intensity >= .65:
self.complete = True
self.parent.sieve.deactivate()
self.parent.triangles.deactivate()
self.set_alpha(255)
self.noise.fadeout(6000)
self.end.play(-1, 0, 4000)
self.total.load()
elif not self.complete:
self.set_alpha(min(150, int(self.intensity * 1.15 * 255)))
if self.intensity > 0:
self.intensity *= .998
self.increaser.update()
self.noise.set_volume(self.intensity)
if self.total.active:
if self.advance_automatically > 3000:
self.advance_automatically = 0
if self.get_game().triangles.score > \
self.get_game().title.scoreboard.get_scores()[Scoreboard.SCORE_COUNT - 1][1]:
self.total.deactivate()
self.initials.activate()
self.get_game().suppress_input_temporarily()
else:
self.finish(wipe=True)
elif ((not self.get_configuration("prize", "enabled") and self.total.counter_finished)
or (self.get_configuration("prize", "enabled") and self.spinner.finished)):
self.advance_automatically += self.get_game().time_filter.get_last_frame_duration()
if self.intensity > .1:
Sprite.update(self)
self.total.update()
if self.total.active and self.get_configuration("prize", "enabled"):
self.prize_meter.update()
if self.total.counter_finished:
self.spinner.update()
self.initials.update()
class PrizeMeter(GameChild):
def __init__(self, parent: GameChild):
GameChild.__init__(self, parent)
if not self.get_game().rotated:
width = self.get_display_surface().get_width() - 92
height = 0.0625 * width
else:
height = self.get_display_surface().get_height() - 92
width = 0.0625 * height
self.meter_surface = pygame.image.load(self.get_resource("prize", "meter")).convert_alpha()
self.meter_surface = pygame.transform.smoothscale(self.meter_surface, (width, height))
self.percent_filled = 0
@property
def rect(self) -> pygame.Rect:
if hasattr(self, "sprite"):
return self.sprite.location
else:
return self.meter_surface.get_rect()
def update(self):
if self.get_configuration("prize", "enabled"):
frame = self.meter_surface.copy()
fill_rect = frame.get_rect()
self.percent_filled = min(self.parent.total.counter / 300, 300)
if not self.get_game().rotated:
width_filled = int(self.percent_filled * fill_rect.width)
fill_rect.width = width_filled
else:
height_filled = int(self.percent_filled * fill_rect.height)
fill_rect.height = height_filled
fill_rect.bottom = frame.get_height()
frame.fill(self.get_game().flash_color, fill_rect, pygame.BLEND_RGB_ADD)
self.sprite = Sprite(self)
self.sprite.add_frame(frame)
if not self.get_game().rotated:
self.sprite.location.center = self.get_display_surface().get_rect().move(0, 45).center
else:
self.sprite.location.center = self.get_display_surface().get_rect().move(45, 0).center
self.sprite.update()
class Spinner(Animation):
def __init__(self, parent):
Animation.__init__(self, parent)
# Load result sprites
self.party = Sprite(self)
self.party.load_from_path(self.get_resource("prize", "win"), transparency=True, ppa=True)
self.heartbreak = Sprite(self)
self.heartbreak.load_from_path(self.get_resource("prize", "lose"), transparency=True, ppa=True)
for sprite in self.party, self.heartbreak:
if self.get_game().rotated:
for ii, frame in enumerate(sprite.frames):
sprite.frames[ii] = self.get_game().orient(frame)
self.party.location.center = self.heartbreak.location.center = \
self.get_display_surface().get_rect().move(0, 200).center
# SFX
self.spin_sfx = SoundEffect(
self,
self.get_resource("audio", "spin"),
volume=self.get_configuration("audio", "spin-volume"),
loops=-1)
self.win_sfx = SoundEffect(
self,
self.get_resource("audio", "win"),
volume=self.get_configuration("audio", "win-volume"))
self.lose_sfx = SoundEffect(
self,
self.get_resource("audio", "lose"),
volume=self.get_configuration("audio", "lose-volume"))
# Register animations
self.register(self.spin, interval=60)
self.register(self.stop)
# Initialize members
self.reset()
def reset(self):
self.halt()
self.spin_sfx.stop()
self.finished = False
self.win = False
self.dispensed = True
self.roll = 0.0
def start(self):
self.dispensed = False
self.play(self.spin)
self.spin_sfx.play()
def spin(self):
if not self.finished:
self.roll = random.random()
if not self.is_playing(self.stop, include_delay=True):
self.play(self.stop, play_once=True, delay=self.get_configuration("prize", "length"))
def stop(self):
self.spin_sfx.stop()
self.halt()
# Final roll
self.roll = random.random()
self.win = self.roll <= self.parent.prize_meter.percent_filled
print(self.win, self.roll, self.parent.prize_meter.percent_filled)
# Dispense nugget
if self.win:
self.win_sfx.play()
if not self.dispensed:
self.get_game().send_dispense_signal()
print("Dispensing chicken nugget")
else:
self.lose_sfx.play()
# Finalize
self.dispensed = True
self.finished = True
def update(self):
Animation.update(self)
if self.finished or self.is_playing(self.spin):
# Create marker with different color each frame to create flash effect
marker = Sprite(self)
frame = pygame.Surface((25, 25), SRCALPHA)
if not self.get_game().rotated:
pygame.draw.polygon(frame, self.get_game().flash_color, ((0, 24), (12, 0), (24, 24)))
else:
pygame.draw.polygon(frame, self.get_game().flash_color, ((24, 24), (0, 12), (24, 0)))
marker.add_frame(frame)
if not self.get_game().rotated:
center_x = int(self.roll * self.parent.prize_meter.rect.w)
marker.location.midtop = self.parent.prize_meter.rect.move(center_x, 32).bottomleft
else:
center_y = int(self.roll * self.parent.prize_meter.rect.h)
marker.location.midleft = self.parent.prize_meter.rect.move(32, -center_y).bottomright
marker.update()
# Draw the result emoji if spin is finished
if self.finished:
emoji = self.party if self.win else self.heartbreak
if not self.get_game().rotated:
emoji.location.midtop = marker.location.move(0, 38).midtop
else:
emoji.location.midleft = marker.location.move(38, 0).midleft
emoji.update()
class Initials(GameChild):
LETTER_SIZE = 24
FOREGROUND = 27, 27, 27
BACKGROUND = 255, 255, 255
PADDING = 10
ARROW_MARGIN = 20
ARROW_HEIGHT = 10
def __init__(self, parent):
GameChild.__init__(self, parent)
self.left_last_pressed = 0
self.button_prompt = BlinkingSprite(self, 500)
font = pygame.font.Font(self.get_resource("terminus/Terminus.ttf"), 32)
self.button_prompt.add_frame(self.get_game().orient(
font.render("PRESS START FOR NEXT LETTER", True, pygame.Color(0, 0, 0), pygame.Color(255, 255, 255))))
if not self.get_game().rotated:
self.button_prompt.location.midbottom = self.get_display_surface().get_rect().move(0, -200).midbottom
else:
self.button_prompt.location.midright = self.get_display_surface().get_rect().move(-200, 0).midright
self.reset()
self.deactivate()
self.font = Font(self.get_resource("display", "initials-font"), self.LETTER_SIZE)
self.text = "---"
self.subscribe(self.respond)
def reset(self):
self.idle_time = 0
self.index = 0
self.holding_button = False
self.holding_button_elapsed = 0
def deactivate(self):
self.active = False
def respond(self, event):
if self.active:
self.idle_time = 0
compare = self.get_game().delegate.compare
if compare(event, "advance"):
self.holding_button = True
self.holding_button_elapsed = self.get_configuration("input", "initials-hold")
if compare(event, "left") or compare(event, "right"):
if compare(event, "left"):
increment = -1
elif compare(event, "right"):
increment = 1
letter = self.text[self.index]
if letter == '-':
letter = 'A' if increment == 1 else 'Z'
else:
letter = chr(ord(letter) + increment)
if ord(letter) == 91 or ord(letter) == 64:
letter = '-'
replacement = ""
for ii in range(len(self.text)):
if ii == self.index:
replacement += letter
else:
replacement += self.text[ii]
self.text = replacement
def activate(self):
self.active = True
self.idle_time = 0
def submit(self):
self.deactivate()
self.parent.finish(self.text)
self.reset()
def update(self):
if self.active:
if self.idle_time > self.get_configuration("input", "initials-idle"):
self.submit()
else:
self.idle_time += self.get_game().time_filter.get_last_frame_duration()
ds = self.get_display_surface()
self.button_prompt.update()
if self.holding_button:
self.holding_button_elapsed += self.get_game().time_filter.get_last_frame_duration()
if self.holding_button_elapsed > self.get_configuration("input", "initials-hold"):
self.index += 1
if self.index == len(self.text):
self.submit()
else:
self.holding_button = False
self.holding_button_elapsed = 0
self.get_game().suppress_input_temporarily(length=300)
for ii, letter in enumerate(self.text):
box = self.get_game().orient(render_box(
self.font, letter, False, self.FOREGROUND, self.BACKGROUND, self.FOREGROUND, padding=self.PADDING))
rect = box.get_rect()
if not self.get_game().rotated:
rect.centery = ds.get_rect().centery
rect.centerx = ii * ds.get_width() / 3 + ds.get_width() / 6
else:
rect.centerx = ds.get_rect().centerx
rect.centery = (len(self.text) - 1 - ii) * ds.get_height() / 3 + ds.get_height() / 6
ds.blit(box, rect)
if ii == self.index:
hold_offset = self.holding_button_elapsed / self.get_configuration("input", "initials-hold") * 10
if not self.get_game().rotated:
x = rect.left - self.ARROW_MARGIN
left_points = ((x, rect.top), (x, rect.bottom), (x - self.ARROW_HEIGHT, rect.centery))
x = rect.right + self.ARROW_MARGIN + hold_offset
right_points = ((x, rect.top), (x, rect.bottom), (x + self.ARROW_HEIGHT, rect.centery))
else:
y = rect.top - self.ARROW_MARGIN - hold_offset
left_points = ((rect.left, y), (rect.right, y), (rect.centerx, y - self.ARROW_HEIGHT))
y = rect.bottom + self.ARROW_MARGIN
right_points = ((rect.left, y), (rect.right, y), (rect.centerx, y + self.ARROW_HEIGHT))
pygame.draw.polygon(ds, pygame.Color(0, 0, 0), left_points)
pygame.draw.polygon(ds, pygame.Color(0, 0, 0), right_points)
class Total(Animation):
def __init__(self, parent):
Animation.__init__(self, parent, 68)
self.counter = 0
self.font = Font(self.get_resource("display", "score-font-path"), 92)
self.counter_sfx = SoundEffect(
self,
self.get_resource("audio", "counter"),
volume=self.get_configuration("audio", "counter-volume"),
loops=-1)
self.register(self.count_up)
self.deactivate()
def deactivate(self):
self.active = False
self.counter_sfx.stop()
def get_score_surface(self, score: int) -> pygame.Surface:
text = ""
for ch in str(int(round(score))):
text += ch + " "
surface = Surface((self.get_display_surface().get_width(), 100), SRCALPHA)
tr = surface.get_rect()
rendering = self.font.render(text, True, self.get_game().flash_color)
rect = rendering.get_rect()
rect.center = tr.centerx, tr.centery + 2
surface.blit(rendering, rect)
return self.get_game().orient(surface)
def load(self):
self.counter = 0
self.play(self.count_up)
self.counter_sfx.play()
self.active = True
def count_up(self):
self.counter += 2
if self.counter >= self.get_game().triangles.score:
self.counter = self.get_game().triangles.score
if self.get_configuration("prize", "enabled"):
self.parent.spinner.start()
self.halt(self.count_up)
self.counter_sfx.stop()
@property
def counter_finished(self) -> bool:
return self.counter >= self.get_game().triangles.score
def update(self):
if self.active:
Animation.update(self)
sprite = Sprite(self)
sprite.add_frame(self.get_score_surface(self.counter))
if not self.get_configuration("prize", "enabled"):
sprite.location.center = self.get_display_surface().get_rect().center
elif not self.get_game().rotated:
sprite.location.center = self.get_display_surface().get_rect().move(0, -108).center
else:
sprite.location.center = self.get_display_surface().get_rect().move(-108, 0).center
sprite.update()