scrapeboard/NS.py

3752 lines
162 KiB
Python

# -*- coding: utf-8 -*-
# [SCRAPEBOARD] is an arcade game in development by [@diskmem] & [@snakesandrews].
#
# It requires custom hardware to play but can be tested in keyboard mode without
# the hardware. For more information on setting up and running the game, see
# README.md, or for the game in general, visit <https://scrape.nugget.fun/>.
#
# The code, assets, and hardware are released as open source. See <https://open.shampoo.ooo/scrape/scrapeboard>.
#
# This is the main file containing all the Pygame code.
import argparse, pathlib, operator, subprocess, sys, os, socket, select, time, random, datetime
# Auto-detect GPIO library
try:
import gpio
except ImportError:
pass
from math import pi
from copy import copy
from glob import iglob
from os.path import basename, join
from threading import Thread
from time import sleep
from PIL import Image
import pygame
from pygame import Surface, mixer
from pygame.event import clear
from pygame.mixer import Sound
from pygame.image import load, fromstring
from pygame.transform import rotate, flip, scale, smoothscale
from pygame.time import get_ticks
from pygame.draw import aalines, lines
from pygame.gfxdraw import aapolygon, arc, polygon, aaellipse, ellipse, filled_ellipse, filled_circle
from pygame.locals import *
from lib.pgfw.pgfw.Game import Game
from lib.pgfw.pgfw.GameChild import GameChild
from lib.pgfw.pgfw.Sprite import Sprite, RainbowSprite, BlinkingSprite
from lib.pgfw.pgfw.Animation import Animation
from lib.pgfw.pgfw.Vector import Vector
from lib.pgfw.pgfw.extension import (
get_step, get_step_relative, get_delta, reflect_angle, get_distance,
render_box, get_hsla_color, get_hue_shifted_surface,
get_color_swapped_surface, load_frames, fill_colorkey, get_segments,
get_boxed_surface
)
from lib.pgfw.pgfw.gfx_extension import aa_filled_polygon
class NS(Game, Animation):
"""
The main game object. It initializes and updates the title screen, boss manager, platform, dialog manager, screen
wipe manager, main character, and more (see the objects initialized in __init__). It initializes and watches the
Arduino serial port and listens for and responds to keyboard input.
"""
# Class variables that can be used to represent each of the four game pads. The L stands for "light", and the directions
# indicate which pad is being identified.
LNW, LNE, LSE, LSW = range(4)
# Class variables that can be used to represent each of the six possible orientations of the board on the four pads: the
# four sides of the square and the two diagonals.
N, NE, E, NW, S, W = range(6)
FRONT_WIDTH = 156
BACK_WIDTH = 271
LENGTH = 94
FRONT = 330
STEP = .4
IDLE_TIMEOUT = 60000 * 5
CHANNEL_COUNT = 8
NO_RESET_TIMEOUT = 3000
class Score:
def __init__(self, milliseconds=None, level_index=None, date=None):
self.milliseconds = milliseconds
self.level_index = level_index
if date is None:
date = datetime.datetime.now()
self.date = date
@classmethod
def from_string(cls, line: str):
fields = line.strip().split()
milliseconds, level_index = (int(field) for field in fields[:2])
date = None
if len(fields) > 2:
date = datetime.datetime.fromisoformat(fields[2])
if level_index == -1:
level_index = None
return cls(milliseconds, level_index, date)
@classmethod
def level(cls, milliseconds: int, level_index: int):
return cls(milliseconds, level_index)
@classmethod
def full(cls, milliseconds: int):
return cls(milliseconds)
@classmethod
def blank_level(cls, level_index: int):
return cls(level_index=level_index)
@classmethod
def blank_full(cls):
return cls()
def is_full(self):
return self.level_index is None
def formatted(self):
if self.milliseconds is None:
return "-:--.-"
else:
minutes, remainder = divmod(int(self.milliseconds), 60000)
seconds, fraction = divmod(remainder, 1000)
return f"{int(minutes)}:{int(seconds):02}.{fraction // 100}"
def blank(self):
return self.milliseconds is None
def serialize(self):
if self.level_index is None:
serialized_level_index = -1
else:
serialized_level_index = self.level_index
return f"{self.milliseconds} {serialized_level_index} {datetime.datetime.isoformat(self.date, 'T')}"
def __str__(self):
return self.formatted()
def __repr__(self):
return f"<Score: {self.formatted()}, level: {self.level_index}>"
def __lt__(self, other):
if self.level_index == other.level_index:
if self.milliseconds == other.milliseconds:
return False
elif self.blank() or other.blank():
return other.blank()
else:
return self.milliseconds < other.milliseconds
else:
if self.is_full() or other.is_full():
return other.is_full()
else:
return self.level_index < other.level_index
class Peer:
"""
Scrapeboard game on the local area network. It is expected to be sending and receiving messages using socket
communication. It will be read and written to regularly in a separate thread.
"""
status = None
result = None
versus = False
level = None
seed = None
def __init__(self, address, port):
self.address = address
self.port = port
def __init__(self):
"""
Parse the command line, set config types, initialize the serial reader, subscribe to events, and initialize child objects.
"""
# Specify possible arguments and parse the command line. If the -h flag is passed, the argparse library will print a
# help message and end the program.
parser = argparse.ArgumentParser()
parser.add_argument("--minimize-load-time", action="store_true", help="Disable some graphics loading and effects generation")
parser.add_argument("--serial-port")
parser.add_argument("--audio-buffer-size", type=int, default=1024)
parser.add_argument("--list-serial-ports", action="store_true")
parser.add_argument("--no-serial", action="store_true", help="Force serial (Arduino) mode off.")
parser.add_argument(
"--pi", action="store_true",
help="Force to read input from the GPIO pins of a Raspberry Pi. Must be running on Raspberry Pi. Forces --no-serial.")
parser.add_argument("--show-config", action="store_true")
arguments = parser.parse_known_args()[0]
# Pre-initialize the mixer to use the specified buffer size in bytes. The default is set to 1024 to prevent lagging
# on the Raspberry Pi.
pygame.mixer.pre_init(44100, -16, 2, 1024)
# Pygame will be loaded in here.
Game.__init__(self)
# Add type declarations for non-string config name/value pairs that aren't in the default PGFW config dict.
self.get_configuration().type_declarations.add_chart(
{
"time":
{
"int": [
"timer-max-time", "timer-start-level-1", "timer-start-level-2", "timer-start-level-3",
"timer-addition-level-1", "timer-addition-level-2", "timer-addition-level-3", "sword-delay",
"attract-gif-length", "attract-board-length", "attract-reset-countdown", "level-select-reset-countdown",
"level-select-press-length", "ending-timeout", "lizard-hurt-length"
],
"float": "timer-warning-start"
},
"boss":
{
"float": [
"damage-per-hit-level-1", "damage-per-hit-level-2",
"damage-per-hit-level-3"
],
"int": [
"cooldown-level-1", "cooldown-level-2",
"cooldown-level-3", "first-combo-delay"
]
},
"network":
{
"int": ["port", "diagnostics-size", "join-time-limit"],
"bool": "diagnostics",
"path": "diagnostics-font",
"list": "peers"
},
"pop-up":
{
"int": ["size", "length"],
"bool": "center"
},
"input":
{
"bool": ["serial", "pi"]
},
"display":
{
"float": "attract-gif-alpha",
"bool": ["effects", "alpha-effect-title", "qr-static"],
"path": "scores-font",
"int": "scores-alpha"
},
"system":
{
"bool": ["minimize-load-time", "enable-level-select", "optimize-title-screen"],
"int": ["lives-boss-rush-mode", "lives-level-select-mode", "max-seed"],
"path": ["dnf-file", "scores-file"]
},
"pads":
{
"int": "center_y"
}
})
# If a serial port was passed on the command line, override the config file setting
if arguments.serial_port is not None:
self.get_configuration().set("input", "arduino-port", arguments.serial_port)
# Command line flag requesting minimal load time overrides config file setting
if arguments.minimize_load_time:
self.get_configuration().set("system", "minimize-load-time", True)
# Turn off effects if minimal load time is requested. Minimal load time setting overrides display effects setting.
if self.get_configuration("system", "minimize-load-time"):
self.get_configuration().set("display", "effects", False)
# Apply the no serial flag from the command line if requested
if arguments.no_serial:
self.get_configuration().set("input", "serial", False)
# Apply the pi flag from the command line if requested. This takes precedence over Arduino, so even if serial is enabled,
# force to no serial mode.
if arguments.pi:
self.get_configuration().set("input", "pi", True)
if self.get_configuration("input", "serial"):
print("Pi mode was requested, so forcing serial (Arduino) mode to off")
self.get_configuration().set("input", "serial", False)
# Print the configuration if requested on the command line
if arguments.show_config:
print(self.get_configuration())
# init Pi
if self.pi_enabled():
# Initialize GPIO interface
gpio.initialize_gpio()
# Launch a separate thread for reading the GPIO (and allowing its custom delays/sleeps). Use the daemon flag to force
# exit automatically when the main thread is killed.
self.gpio_thread = Thread(target=self.read_gpio, daemon=True)
self.gpio_thread.start()
self.gpio_data = gpio.activity()
# init Arduino
elif self.serial_enabled():
# Initialize the serial reader and launch a thread for reading from the serial port
from serial import Serial, SerialException
from serial.tools import list_ports
# If a list of serial ports was requested, print detected ports and exit.
if arguments.list_serial_ports:
if list_ports.comports():
for port in list_ports.comports():
print(f"Detected serial port: {port.device}")
else:
print("No serial ports detected")
exit()
# Open the port specified by the configuration or command line if it is found. If the specified port is not
# found, iterate through the com ports, and try to open each. If no serial port can be opened, raise an
# exception.
requested_port = self.get_configuration("input", "arduino-port")
devices = [port.device for port in list_ports.comports()]
if requested_port in devices:
self.serial_reader = Serial(requested_port, timeout=.3)
else:
if requested_port:
print(f"Could not connect with requested port {requested_port}. Searching for other ports.")
found = False
for device in devices:
try:
self.serial_reader = Serial(device, timeout=.3)
found = True
except SerialException:
print(f"Tried and failed to open connection with serial device {device}")
if not found:
raise SerialException("No usable serial port devices found. Use --no-serial for keyboard-only mode.")
print(f"Using serial device at port {self.serial_reader.port}")
self.serial_data = 0
self.reset_arduino()
# Launch a separate thread for reading serial data
self.serial_thread = Thread(target=self.read_serial, daemon=True)
self.serial_thread.start()
Animation.__init__(self, self)
# All events will pass through self.respond
self.subscribe(self.respond, KEYDOWN)
self.subscribe(self.respond, KEYUP)
self.subscribe(self.respond)
ds = self.get_display_surface()
# Child objects for managing more specific parts of the game
platform_cx = self.get_display_surface().get_width() // 2
self.platform = Platform(self, (platform_cx, self.get_configuration("pads", "center_y")))
self.tony = Tony(self)
self.logo = Logo(self)
self.title = Title(self)
self.wipe = Wipe(self)
self.dialogue = Dialogue(self)
self.chemtrails = Chemtrails(self)
self.boss = Boss(self)
self.level_select = LevelSelect(self)
self.ending = Ending(self)
# Start the score list with all blank scores
self.scores = []
blank_count = 25
for level_index in range(3):
for _ in range(blank_count):
self.scores.append(NS.Score.blank_level(level_index))
for _ in range(blank_count):
self.scores.append(NS.Score.blank_full())
self.most_recent_score = None
# Add existing scores to the list from file
path = self.get_configuration("system", "scores-file")
if os.path.exists(path):
with open(path, "rt") as score_file:
for line in score_file:
if line.strip():
self.scores.append(NS.Score.from_string(line))
# Draw the score sprites
self.title.draw_scores()
# Initialize key input buffering
self.last_press = get_ticks()
# Initialize pop-up
self.register(self.close_pop_up)
self.pop_up_font = pygame.font.Font(self.get_resource(Dialogue.FONT_PATH), self.get_configuration("pop-up", "size"))
self.pop_up_text = ""
# Initialize networking. Include self as a peer located at "localhost".
self.server = socket.create_server(("", self.get_configuration("network", "port")))
self.peers = {"localhost": NS.Peer("localhost", self.get_configuration("network", "port"))}
self.peers["localhost"].versus = True
print(f"Added peer 'localhost'")
if self.get_configuration("network", "peers"):
for peer in self.get_configuration("network", "peers"):
# Store peers in a dictionary where the key is the peer address
self.peers[peer] = NS.Peer(peer, self.get_configuration("network", "port"))
print(f"Added peer '{peer}'")
# Launch separate threads for listing and posting to peers
self.listen_thread = Thread(target=self.listen_to_peers, daemon=True)
self.listen_thread.start()
self.post_thread = Thread(target=self.post_to_peers, daemon=True)
self.post_thread.start()
self.reset()
# Clear events queue
clear()
def pi_enabled(self):
return self.get_configuration("input", "pi")
def serial_enabled(self):
return self.get_configuration("input", "serial")
def read_gpio(self):
"""
Test all connections of GPIO input pins.
"""
while True:
self.gpio_data = gpio.activity()
def read_serial(self):
while True:
name = self.get_configuration("input", "arduino-port")
try:
transmission = self.serial_reader.readline().strip()
print(transmission)
except:
print("Serial not ready... passing...")
transmission = ""
if len(transmission) == 4:
try:
self.serial_data = int(transmission, 2)
except ValueError:
print("Value error checking four digit serial transmission")
self.handle_garbage(transmission)
self.reset_arduino()
self.idle_elapsed = 0
elif len(transmission) > 0:
try:
int(transmission, 2)
except ValueError:
print("Received a non-four digit serial transmission")
self.handle_garbage(transmission)
else:
self.serial_data = 0
def handle_garbage(self, transmission):
self.serial_data = 0
print("Garbage detected: %s" % transmission)
self.serial_reader.reset_input_buffer()
def reset_arduino(self):
if self.serial_enabled():
self.serial_reader.dtr = False
self.serial_reader.reset_input_buffer()
self.serial_reader.dtr = True
def apply_serial(self):
for ii, light in enumerate(self.platform.lights):
light.pressed = bool(self.serial_data & (2 ** ii))
# reset idle timer if a light is detected as pressed in serial data
if light.pressed:
self.idle_elapsed = 0
def apply_gpio(self):
"""
Check the connection status of the GPIO pins and turn on the appropriate light objects (the pads).
"""
for light_id in self.gpio_data:
# The pressed state is set to the activity state
self.platform.lights[light_id].pressed = self.gpio_data[light_id]
# Reset idle timer if a light is detected as pressed
if self.gpio_data[light_id]:
self.idle_elapsed = 0
def post_to_peers(self):
"""
Update peers with current status every 1/2 second.
"""
while True:
# Determine this game's status
if self.title.active:
status = "title"
message = status
elif self.level_select.active and self.level_select.level_index_selected is None:
status = "level select"
message = status
elif self.level_select.active and not self.level_select.level_launched:
status = "voted"
level = self.level_select.level_index_selected
self.peers["localhost"].level = level
message = f"{status} {level} {self.peers['localhost'].seed}"
elif self.level_select.active or not self.boss.battle_finished:
status = "playing"
level = self.level_select.level_index_selected
self.peers["localhost"].level = level
message = f"{status} {level}"
elif self.boss.player_defeated:
status = "lost"
self.peers["localhost"].result = None
message = status
else:
status = "complete"
result = self.most_recent_score.milliseconds
self.peers["localhost"].result = result
message = f"{status} {result}"
self.peers["localhost"].status = status
# Connect and send status message to each peer. If sending fails, pass and wait until the next iteration.
for peer in self.peers.values():
if peer.address != "localhost":
try:
socket.create_connection((peer.address, peer.port)).send(str.encode(message))
except:
pass
# Send status every 1/2 second
time.sleep(0.5)
def listen_to_peers(self):
"""
Update peer statuses by processing incoming messages on the socket server.
"""
while True:
# Use the server to receive messages. Update peer statuses as the messages come in.
read_list, write_list, except_list = select.select([self.server], [], [], 0.5)
# When there is no read list, there are no messages to accept.
if (len(read_list) > 0):
incoming = self.server.accept()
peer = self.peers[incoming[1][0]]
# All messages are less than 64 characters
message = incoming[0].recv(64).decode()
if message.startswith("title") or message.startswith("level select"):
peer.versus = False
if message.startswith("complete"):
try:
peer.result = int(message.split()[-1])
peer.status = "complete"
except:
# Improperly formatted message received
pass
elif message.startswith("playing"):
try:
peer.level = int(message.split()[-1])
peer.status = "playing"
except:
pass
elif message.startswith("voted"):
try:
status, level, seed = message.split()
peer.level = int(level)
peer.seed = int(seed)
peer.status = status
except:
pass
else:
peer.status = message
def count_players(self):
"""
@return count of peers committed to a match with this peer
"""
count = 0
for peer in self.peers.values():
count += peer.versus
return count
def count_lobby(self):
"""
@return count of peers at the level select screen
"""
count = 0
for peer in self.peers.values():
count += peer.status == "level select" or peer.status == "voted"
return count
def reset(self, leave_wipe_running=False):
self.idle_elapsed = 0
self.suppressing_input = False
self.level_select.reset()
self.title.reset()
if not leave_wipe_running:
self.wipe.reset()
self.boss.reset()
self.chemtrails.reset()
self.platform.reset()
self.dialogue.reset()
self.ending.reset()
self.no_reset_elapsed = 0
self.title.activate()
def suppress_input(self):
self.suppressing_input = True
# self.platform.unpress()
def unsuppress_input(self):
self.suppressing_input = False
def respond(self, event):
"""
Respond to keyboard input.
___ ___
| O| P| These keyboard keys correspond to the floor pads.
|___|___| (O = top left pad, P = top right pad, L = bottom left pad, ; = bottom right pad)
| L| ;| Arrow keys can also be used.
|___|___| (UP = top left pad, RIGHT = top right pad, DOWN = bottom left pad, LEFT = bottom right pad)
The Z key is a shortcut for reset (F8 also resets).
The A key force resets the connected Arduino (or does nothing if no Arduino is connected).
"""
if not self.suppressing_input and event.type in (KEYDOWN, KEYUP):
if self.last_press <= get_ticks() - int(self.get_configuration("input", "buffer")):
pressed = True if event.type == KEYDOWN else False
lights = self.platform.lights
self.idle_elapsed = 0
if event.key in (K_UP, K_o):
lights[NS.LNW].pressed = pressed
elif event.key in (K_RIGHT, K_p):
lights[NS.LNE].pressed = pressed
elif event.key in (K_DOWN, K_SEMICOLON):
lights[NS.LSE].pressed = pressed
elif event.key in (K_LEFT, K_l):
lights[NS.LSW].pressed = pressed
elif event.key == K_z:
self.reset()
elif event.key == K_a:
self.reset_arduino()
elif event.type == KEYDOWN and event.key == K_n and pygame.key.get_mods() & (pygame.KMOD_CTRL | pygame.KMOD_SHIFT):
# Toggle visibility of network diagnostics menu
state = self.get_configuration("network", "diagnostics")
self.configuration.set("network", "diagnostics", not state)
self.pop_up(f"Network diagnostics visible: {not state}")
self.last_press = get_ticks()
else:
if self.get_delegate().compare(event, "reset-game"):
self.reset()
def pop_up(self, text, clear=False):
"""
Trigger a pop up message that displays for a certain amount of time before being closed automatically. Adds a line of
text to a variable that contains all pop up messages in case there is a previously sent message that needs to continue
being displayed.
@param text message to display
@param clear if True, delete any existing messages
"""
if not clear:
self.pop_up_text += f"{text}\n"
else:
self.pop_up_text = f"{text}\n"
self.halt(self.close_pop_up)
self.play(self.close_pop_up, play_once=True, delay=3000)
def close_pop_up(self):
"""
Close the pop up message by removing all text from the pop up text variable. This will cause the pop up to stop being
drawn each frame.
"""
self.pop_up_text = ""
def add_time_to_scores(self, milliseconds: int, level_index=None):
"""
Add a time to the list of scores. This method will build a score object, add it to the list, and write to the scores file.
It will also call on the title screen object to draw the sprites.
@param milliseconds player's time in milliseconds
@param level_index the level this time corresponds to or None for a full game
"""
if level_index is None:
score = NS.Score.full(milliseconds)
else:
score = NS.Score.level(milliseconds, level_index)
self.scores.append(score)
self.most_recent_score = score
# Write scores to file
try:
with open(self.get_configuration("system", "scores-file"), "wt") as score_file:
for score in sorted(self.scores):
if not score.blank():
score_file.write(f"{score.serialize()}\n")
except:
print("Error saving scores")
self.title.draw_scores()
def update(self):
Animation.update(self)
last_frame_duration = self.time_filter.get_last_frame_duration()
# Apply controller input to light (pad) states from either Pi or Arduino if applicable
if self.pi_enabled():
# Translate Raspberry Pi GPIO state into pad states
self.apply_gpio()
elif self.serial_enabled():
# Translate the most recent serial data, being provided by serial/serial.ino, into pad states
self.apply_serial()
# Handle auto reset of the Arduino for stablizing serial data
if self.title.active or self.ending.active or self.dialogue.active:
self.no_reset_elapsed += last_frame_duration
# If we received good input, reset the auto reset timer
if 0b11 <= self.serial_data <= 0b1100:
self.no_reset_elapsed = 0
if self.no_reset_elapsed >= self.NO_RESET_TIMEOUT:
print("auto arduino reset triggered")
self.reset_arduino()
self.no_reset_elapsed = 0
self.title.update()
self.level_select.update()
self.ending.update()
self.boss.update()
if not self.title.active:
self.platform.update()
self.chemtrails.update()
self.boss.update_dialogue()
self.wipe.update()
# Draw pop up text line by line
if self.pop_up_text:
width = 0
height = 0
for line in self.pop_up_text.split("\n"):
if line:
line_width, line_height = self.pop_up_font.size(line)
if line_width > width:
width = line_width
height += line_height
full_surface = pygame.Surface((width, height))
x = 0
y = 0
for line in self.pop_up_text.split("\n"):
if line:
surface = self.pop_up_font.render(
line, True, pygame.Color(self.get_configuration("pop-up", "foreground")),
pygame.Color(self.get_configuration("pop-up", "background")))
full_surface.blit(surface, (x, y))
y += surface.get_height()
if y > 0:
sprite = Sprite(self)
sprite.add_frame(full_surface)
sprite.set_alpha(200)
if self.get_configuration("pop-up", "center"):
sprite.location.center = self.get_display_surface().get_rect().center
sprite.update()
# Draw network diagnostics
if self.get_configuration("network", "diagnostics"):
y = self.get_display_surface().get_rect().bottom
font = pygame.font.Font(self.get_configuration("network", "diagnostics-font"),
self.get_configuration("network", "diagnostics-size"))
for peer in self.peers.values():
surface = font.render(
f"{peer.address} {peer.status} [PvP {peer.versus}, lvl {peer.level}, result {peer.result}]",
True, (255, 255, 255), (0, 0, 0))
surface.set_alpha(200)
y -= surface.get_height()
self.get_display_surface().blit(surface, (0, y))
surface = font.render(f"players: {self.count_players()} lobby: {self.count_lobby()}", True, (255, 255, 255), (0, 0, 0))
surface.set_alpha(200)
y -= surface.get_height()
self.get_display_surface().blit(surface, (0, y))
# Reset the game when idle
self.idle_elapsed += self.time_filter.get_last_frame_duration()
if self.idle_elapsed >= self.IDLE_TIMEOUT:
self.reset()
def end(self, event):
"""
Extend the parent end method to try adding a permanent quit feature in case there is a Raspbian Lite systemd autostart
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 this is running on
# Raspbian Lite
if pygame.key.get_mods() & pygame.KMOD_SHIFT:
try:
subprocess.run(["sudo", "systemctl", "stop", "scrapeboard"])
print("Killing with permanent stop sent to systemd scrapeboard service")
except:
print("No scrapeboard system service detected, so permanent quit either failed or was unnecessary")
# Call parent to complete quit
super().end(event)
class LevelSelect(Animation):
"""
Display the available levels. Initialize a platform for each level and display each platform beneath its level glowing
with a pair of pads to press to start that level. Wait for user input, then launch the level of the pair that gets
pressed by the user.
"""
def __init__(self, parent):
Animation.__init__(self, parent)
self.subscribe(self.respond, KEYDOWN)
self.register(self.timeout, self.force_launch)
y = 250
indent = 10
dsr = self.get_display_surface().get_rect()
self.platforms = [Platform(self, (0, y)), Platform(self, (0, y)), Platform(self, (0, y))]
scale = .75
for platform in self.platforms:
for ii, frame in enumerate(platform.view.frames):
scaled = pygame.transform.smoothscale(frame, (int(frame.get_width() * scale), int(frame.get_height() * scale)))
platform.view.frames[ii] = scaled
platform.view.get_current_frameset().measure_rect()
platform.view.update_location_size()
# for corner in platform.glow_masks:
# for ii, frame in enumerate(corner):
# scaled = pygame.transform.smoothscale(frame, (int(frame.get_width() * scale), int(frame.get_height() * scale)))
# corner[ii] = scaled
self.platforms[0].view.location.left = dsr.left + indent
self.platforms[1].view.location.centerx = dsr.centerx
self.platforms[2].view.location.right = dsr.right - indent
self.platforms[0].set_glowing((NS.LNW, NS.LSE))
self.platforms[1].set_glowing((NS.LNW, NS.LSW))
self.platforms[2].set_glowing((NS.LNW, NS.LNE))
preview_rect = pygame.Rect(0, 0, dsr.w / 3 - 40, 160)
self.previews = []
font = pygame.font.Font(self.get_resource(Dialogue.FONT_PATH), 18)
padding = 4
for level_index, text in enumerate(("1 KOOL", "2 ALIEN", "3 GOTH")):
self.previews.append(Sprite(self, 100))
text = font.render(text, True, (0, 0, 0))
text = pygame.transform.rotate(text, 90)
text_rect = text.get_rect()
text_rect.midleft = preview_rect.midleft
frame = self.get_game().boss.backgrounds[level_index].frames[0]
frame_rect = (preview_rect.w - text_rect.w - padding, preview_rect.h - padding * 2)
environment = pygame.transform.smoothscale(frame, frame_rect)
environment_rect = environment.get_rect()
environment_rect.midright = preview_rect.right - padding, preview_rect.centery
boss = pygame.transform.smoothscale(self.get_game().boss.level_sprite(level_index).frames[0],
environment_rect.inflate(-64, -28).size)
boss_rect = boss.get_rect()
boss_rect.center = environment_rect.center
for hue in range(0, 360, 8):
frame = pygame.Surface(preview_rect.size)
color = Color(0, 0, 0)
color.hsla = hue, 100, 75, 100
frame.fill(color)
frame.blit(text, text_rect)
frame.blit(environment, environment_rect)
frame.blit(boss, boss_rect)
self.previews[-1].add_frame(frame)
self.previews[-1].location.midbottom = self.platforms[level_index].view.location.centerx, \
self.platforms[level_index].view.location.top - 12
self.reset()
def activate(self):
self.reset()
self.active = True
for platform in self.platforms:
platform.activate()
self.start_timeout_countdown()
def deactivate(self):
self.active = False
for platform in self.platforms:
platform.deactivate()
self.level_index_selected = None
self.level_launched = False
self.launch_forced = False
self.zoom = 1.0
self.grow_sound_channel = None
for level_index in range(3):
self.platforms[level_index].view.halt(self.platforms[level_index].view.wipe_out)
self.previews[level_index].halt(self.previews[level_index].wipe_out)
self.platforms[level_index].view.reset()
self.previews[level_index].reset()
self.platforms[level_index].view.unhide()
self.previews[level_index].unhide()
self.halt()
def reset(self):
self.deactivate()
def respond(self, event):
"""
Respond to CTRL + key presses to launch a level or toggle level select mode
"""
level_index = None
if pygame.key.get_mods() & pygame.KMOD_CTRL:
if event.key in (pygame.K_1, pygame.K_2, pygame.K_3):
self.launch(event.key - pygame.K_1)
elif event.key == pygame.K_l:
level_select_enabled = not self.get_configuration("system", "enable-level-select")
self.get_configuration().set("system", "enable-level-select", level_select_enabled)
self.get_game().pop_up(f"Level select mode set to {level_select_enabled}")
def launch(self, index):
"""
Start a level through the boss object
"""
self.get_game().boss.start_level(index)
self.deactivate()
def launch_selected_index(self):
"""
Launch level index stored in the member variable
"""
self.launch(self.level_index_selected)
def start_timeout_countdown(self):
"""
Launch an animation on a delay that will reset the game after the delay. If the countdown is already active, reset the
countdown.
"""
self.halt(self.timeout)
self.play(self.timeout, delay=self.get_configuration("time", "level-select-reset-countdown"), play_once=True)
def timeout(self):
"""
Reset to the title screen
"""
self.get_game().wipe.start(self.get_game().reset, leave_wipe_running=True)
def force_launch(self):
self.launch_forced = True
def update(self):
if self.active:
Animation.update(self)
self.get_game().logo.update()
for ii, preview in enumerate(self.previews):
if ii != self.level_index_selected:
preview.update()
if self.level_index_selected is None:
for level_index, platform in enumerate(self.platforms):
if platform.get_glowing_edge() == self.get_game().platform.get_edge_pressed():
if self.get_game().platform.press_elapsed > self.get_configuration("time", "level-select-press-length"):
# This will cause a vote to be cast to peers if there are any. If there are others in the lobby,
# the game will wait for other votes to be cast or the lobby to clear. Otherwise, the level will
# launch.
self.level_index_selected = level_index
if self.grow_sound_channel is not None:
self.grow_sound_channel.stop()
self.grow_sound_channel = None
self.get_game().peers["localhost"].seed = random.randint(0, self.get_configuration("system", "max-seed"))
self.seed = self.get_game().peers['localhost'].seed
print(f"Set seed to {self.seed}")
self.play(self.force_launch, delay=self.get_configuration("network", "join-time-limit"))
# Wipe away other levels and zoom selected
for level_index in range(3):
if level_index != self.level_index_selected:
self.platforms[level_index].view.play(self.platforms[level_index].view.wipe_out)
self.previews[level_index].play(self.previews[level_index].wipe_out, interval=100)
self.get_audio().play_sfx("complete_pattern_3")
self.players_counted = 1
break
else:
if self.grow_sound_channel is None:
self.grow_sound_channel = self.get_audio().play_sfx("grow", -1, x=platform.view.location.centerx)
# Draw a growing ring around the currently pressed level
angle = self.get_game().platform.press_elapsed / \
self.get_configuration("time", "level-select-press-length") * 2 * pi
diameter = self.previews[level_index].location.height + 21
rect = pygame.Rect(0, 0, diameter, diameter)
rect.center = self.previews[level_index].location.center
offset = 0
while offset < .2:
if offset < angle:
pygame.draw.arc(self.get_display_surface(), (255, 255, 255), rect, offset, angle, 14)
offset += .01
# Check if peers are still deciding
elif not self.level_launched:
# Sync seed and wait time with players who have voted for the same level
for peer in self.get_game().peers.values():
if peer.address != "localhost" and peer.status == "voted" and peer.level == self.level_index_selected:
peer.versus = True
if self.get_game().count_players() > self.players_counted:
self.players_counted += 1
self.seed = (self.seed + peer.seed) % self.get_configuration("system", "max-seed")
self.halt(self.force_launch)
self.play(self.force_launch, delay=self.get_configuration("network", "join-time-limit"))
# Launch if time is up, the lobby is empty, or everyone present has voted
if all(peer.status != "level select" or peer.status == "voted" for peer in self.get_game().peers.values()) or \
self.launch_forced:
print(f"Seeding generator with {self.seed}")
random.seed(self.seed)
self.halt(self.force_launch)
self.get_game().pop_up("", clear=True)
self.level_launched = True
self.opponents_at_launch = [peer for peer in self.get_game().peers.values() if peer.versus]
if len(self.opponents_at_launch) > 1:
for level_index in range(3):
if level_index != self.level_index_selected:
self.platforms[level_index].view.halt()
self.previews[level_index].halt()
self.get_game().wipe.start(self.launch_selected_index)
# Update displayed wait message
else:
remaining = self.accounts[self.force_launch].delay
if remaining < self.get_configuration("network", "join-time-limit") - 500:
self.get_game().pop_up(
f"Waiting {remaining // 1000 + 1}s for players to join", clear=True)
# Second half of launch animation
elif not self.get_game().wipe.is_playing() and any(preview.is_hidden() for preview in self.previews):
# Final animation before game will launch, launch is attached to the animation and will be triggered automatically
self.get_game().wipe.start(self.launch_selected_index)
for platform in self.platforms:
platform.update()
if self.level_index_selected is not None:
preview = self.previews[self.level_index_selected]
self.zoom += 0.1
frame = pygame.transform.scale(
preview.get_current_frame(), (int(preview.location.w * self.zoom), int(preview.location.h * self.zoom)))
rect = frame.get_rect()
rect.center = preview.location.center
preview.update()
self.get_display_surface().blit(frame, rect)
# If input in the player's platform detected reset the automatic game reset countdown
if self.get_game().platform.get_pressed():
self.start_timeout_countdown()
elif self.grow_sound_channel is not None:
self.grow_sound_channel.stop()
self.grow_sound_channel = None
class Button(Sprite):
MARGIN = 2
BLANK = (200, 200, 200)
def __init__(self, parent, edge, size, border):
Sprite.__init__(self, parent)
colors = self.get_game().platform.get_color_pair_from_edge(edge)
width = size * 2 + self.MARGIN + border * 4
step = width / 2 + self.MARGIN / 2
rect_width = width / 2 - self.MARGIN / 2
rects = Rect(0, 0, rect_width, rect_width), \
Rect(step, 0, rect_width, rect_width), \
Rect(step, step, rect_width, rect_width), \
Rect(0, step, rect_width, rect_width)
if edge == NS.N:
colored = rects[0], rects[1]
elif edge == NS.NE:
colored = rects[1], rects[3]
elif edge == NS.E:
colored = rects[1], rects[2]
elif edge == NS.NW:
colored = rects[0], rects[2]
elif edge == NS.S:
colored = rects[3], rects[2]
elif edge == NS.W:
colored = rects[0], rects[3]
for lightness in range(30, 90, 5):
frame = Surface((width, width), SRCALPHA)
for topleft in (0, 0), (step, 0), (step, step), (0, step):
rect = Rect(topleft, (rect_width, rect_width))
border_color = Color(*self.BLANK)
border_color.a = 179
frame.fill(border_color, rect)
frame.fill((0, 0, 0, 0), rect.inflate(-border * 2, -border * 2))
for ii in range(2):
original_color = Color(*colors[ii])
original_color.a = 255
edited_color = Color(0, 0, 0)
edited_color.hsla = int(original_color.hsla[0]), int(original_color.hsla[1]), \
lightness, 70
frame.fill(edited_color, colored[ii])
frame.fill(original_color, colored[ii].inflate(-border * 2, -border * 2))
self.add_frame(frame)
class Meter(GameChild):
SPACING = 12
def __init__(self, parent):
GameChild.__init__(self, parent)
def setup(self, background, rect, indent, color, units, path):
self.background = background
self.rect = rect
self.icons = []
x = rect.left + indent
base = get_color_swapped_surface(
load(self.get_resource(path)).convert_alpha(),
(0, 0, 0), color)
while x <= self.rect.right - base.get_width() - self.SPACING:
icon = Sprite(self)
icon.add_frame(base)
icon.location.midleft = x, self.rect.centery
self.icons.append(icon)
x += icon.location.w + self.SPACING
self.units = units
def reset(self):
self.amount = self.units
for icon in self.icons:
icon.unhide()
def change(self, delta):
self.amount += delta
cutoff = float(self.amount) / self.units * len(self.icons)
for ii, icon in enumerate(self.icons):
if ii < cutoff:
icon.unhide()
else:
icon.hide()
def percent(self):
"""
Return amount as a percent of the full amount
"""
return self.amount / self.units
def update(self):
ds = self.get_display_surface()
ds.blit(self.background, self.rect)
for icon in self.icons:
icon.update()
class Tony(Sprite):
"""
A fullscreen-sized sprite of Tony Hawk the Birdman with animation and glow effects.
"""
def __init__(self, parent):
"""
Load board animation, create a glow effect, and load taunt sound effects.
"""
Sprite.__init__(self, parent, 100, False)
dsr = self.get_display_surface().get_rect()
self.board = Sprite(self, 100)
self.board.load_from_path(self.get_resource("newTony/TonyArms"), True)
# Create a glowing effect object by adding glow frames to a blank Sprite. It can then be applied to the main Tony Sprite frame
# using `pygame.BLEND_RGBA_SUB`. Skip this if fast load is requested.
if not self.get_configuration("system", "minimize-load-time"):
if self.get_configuration("display", "alpha-effect-title"):
self.effect = Sprite(self)
else:
self.effect = Sprite(self, 120)
for offset in range(12):
if self.get_configuration("display", "alpha-effect-title"):
w, h = dsr.w - 40, int(dsr.h * .65)
glow = Surface((w, h), SRCALPHA)
else:
w, h = dsr.w - 120, int(dsr.h * .65)
glow = Surface((w, h))
for ii, y in enumerate(range(h, 0, -8)):
hue = range(240, 200, -2)[(ii - offset) % 12]
alpha = min(100, int(round(y / float(h - 10) * 100)))
color = get_hsla_color(hue, 100, 50, alpha)
if ii == 0:
aaellipse(glow, w // 2, y, w // 2 - 4, h // 20, color)
ellipse(glow, w // 2, y, w // 2 - 4, h // 20, color)
filled_ellipse(glow, w // 2, y, w // 2 - 4, h // 20, color)
self.effect.add_frame(glow)
if self.get_configuration("display", "alpha-effect-title"):
self.effect.location.topleft = -20, int(dsr.h * .35)
else:
self.effect.location.midbottom = dsr.midbottom
self.add_frame(load(self.get_resource("Big_Tony.png")).convert_alpha())
self.load_from_path(self.get_resource("newTony/TonyShirtHead"), True)
self.add_frameset([0], name="static")
self.add_frameset(range(1, len(self.frames)), name="board")
self.taunts = []
for sfx_name in self.get_audio().sfx:
if sfx_name.startswith("TonyTauntsBend_"):
self.taunts.append(sfx_name)
self.location.centerx = dsr.centerx
self.board.location.centerx = self.location.centerx
# Add a QR code to the t-shirt
self.qr = Sprite(self)
frame = pygame.image.load(self.get_resource("qr/qr.png"))
frame = pygame.transform.smoothscale(frame, (165, 141))
self.qr.add_frame(frame)
self.qr.location.midtop = 410, 103
self.qr_text = Sprite(self, [6000, 1500, 1500, 1500, 1500])
if self.get_configuration("display", "qr-static"):
frames = load_frames(self.get_resource("qr/qr_text_static.png"), transparency=True)
else:
frames = load_frames(self.get_resource("qr/"), query="qr_text_[0-9].png", transparency=True)
for ii, frame in enumerate(frames):
frames[ii] = pygame.transform.smoothscale(frame, (165, int(165 / frame.get_width() * frame.get_height())))
self.qr_text.add_frames(frames)
self.qr_text.location.midtop = self.qr.location.midbottom
def set_frameset(self, name):
Sprite.set_frameset(self, name)
self.get_current_frameset().reset()
self.set_framerate(100)
if name == "board":
self.board.get_current_frameset().reset()
self.board.unhide()
self.board.set_framerate(100)
self.board.halt()
elif name == "static":
self.board.hide()
def shift_frame(self):
Sprite.shift_frame(self)
frameset = self.get_current_frameset()
if frameset.name == "board" and frameset.current_index == 1:
self.get_audio().play_sfx(random.choice(self.taunts))
def update(self):
"""
Apply the glow effect using an intermediate surface to blend the glow effect with the current main sprite frame. Skip the effect if
effects are off. Update title screen objects. Update the board sub-animation if it is active.
"""
# Create an intermediate surface for blending the glow with the sprite frame
if self.get_configuration("display", "alpha-effect-title"):
save = self.get_display_surface()
intermediate_surface = Surface(self.location.size, SRCALPHA)
self.display_surface = intermediate_surface
self.qr.display_surface = intermediate_surface
self.qr_text.display_surface = intermediate_surface
location_save = self.location.copy()
self.location.topleft = 0, 0
self.qr.location.centerx = self.location.centerx + 10
self.qr_text.location.midtop = self.qr.location.midbottom
# Do a regular Sprite animation update
Sprite.update(self)
self.qr.update()
self.qr_text.update()
# Blend the effect frame with the sprite frame
if not self.get_configuration("system", "minimize-load-time"):
if self.get_configuration("display", "alpha-effect-title"):
self.display_surface = save
self.location = location_save
self.effect.display_surface = intermediate_surface
self.effect.update(flags=BLEND_RGBA_SUB)
self.get_display_surface().blit(intermediate_surface, self.location.topleft)
else:
self.effect.update(flags=BLEND_RGBA_SUB)
# Update title screen objects that are drawn over this sprite
if self.get_game().title.active:
self.get_game().title.video.update()
self.get_game().platform.update()
self.get_game().chemtrails.update()
# Update the board sub-animation
frameset = self.get_current_frameset()
if frameset.name == "board":
self.board.get_current_frameset().current_index = frameset.current_index
if frameset.current_index == len(frameset.order) - 1:
self.set_framerate(3000)
else:
self.set_framerate(100)
self.board.update()
class Video(Sprite):
"""
Attract mode pop-up that rotates through GIFs.
"""
def __init__(self, parent, diameter, next_video_chance=.01):
Sprite.__init__(self, parent, 100)
self.next_video_chance = next_video_chance
pattern = join(self.get_resource("gif"), "Boarding_*.gif")
gifs = []
for path in iglob(pattern):
gifs.append(Image.open(path))
print(gifs[-1].info)
self.gif_index = 0
mask = Surface([diameter] * 2, SRCALPHA)
rect = mask.get_rect()
alpha = int(self.get_configuration("display", "attract-gif-alpha") * 255)
filled_circle(mask, rect.centerx, rect.centery, rect.centerx, (0, 0, 0, alpha))
filled_circle(mask, rect.centerx, rect.centery, rect.centerx - 2, (255, 255, 255, alpha))
self.add_frame(mask)
if not self.get_configuration("system", "minimize-load-time"):
self.play()
# preload GIF frames scaled instead of loading each frame like before
self.gif_frames_scaled = []
for gif in gifs:
self.gif_frames_scaled.append([])
for ii in range(0, gif.n_frames):
gif.seek(ii)
frame_scaled = smoothscale(
fromstring(gif.convert("RGBA").tobytes(), gif.size, "RGBA"),
(mask.get_width(), int(gif.width * gif.height / mask.get_width())))
copy = mask.copy()
rect = frame_scaled.get_rect()
rect.bottom = copy.get_rect().bottom
copy.blit(frame_scaled, rect, None, BLEND_RGBA_MIN)
self.gif_frames_scaled[-1].append(copy)
self.load_selection()
def load_selection(self):
self.clear_frames()
for frame in self.gif_frames_scaled[self.gif_index]:
self.add_frame(frame)
def shift_frame(self):
Sprite.shift_frame(self)
if random.random() < self.next_video_chance:
while True:
selection = random.choice(range(0, len(self.gif_frames_scaled)))
if selection != self.gif_index:
self.gif_index = selection
self.load_selection()
break
class Logo(Sprite):
"""
A screen-sized layer displaying the logo tile-filled. Hacked into displaying only a single frame for performance.
"""
def __init__(self, parent):
"""
Load the logo and create a glowing version by creating multiple frames, each with a glow effect blended onto it. But disable the
glow and create a static background frame as a hack.
"""
Sprite.__init__(self, parent, 60)
dsr = self.get_display_surface().get_rect()
mask = pygame.image.load(self.get_resource("Title_tile.png")).convert()
palette = (255, 255, 255), (255, 255, 128), (255, 255, 0)
thickness = 8
for offset in range(len(palette)):
tile = mask.copy()
for x in range(0, dsr.w, thickness):
tile.fill(palette[(offset + x) % len(palette)], (x, 0, thickness, dsr.h), pygame.BLEND_RGB_MIN)
self.add_frame(tile)
for y in range(0, dsr.h + self.location.h, self.location.h):
for x in range(0, dsr.w + self.location.w, self.location.w):
if x != 0 or y != 0:
self.add_location((x, y))
def update(self):
"""
"""
self.move(-1, 1)
if self.location.right < 0:
self.move(self.location.w)
if self.location.top > 0:
self.move(dy=-self.location.h)
Sprite.update(self)
class Title(Animation):
"""
Handles displaying and drawing the title screen. It draws the high scores, creates and updates an attract mode video pop-up, tracks
the player's moves and checks if they are doing the start game pattern, and updates the background logo and giant Tony sprite.
Title.draw_scores is a slow method, so the scores should only be drawn when a score is added.
If the game is configured to optimize on the title screen, the scores will only be blit when Title.activate is called. Otherwise,
they will blit every update.
"""
# Sequence of moves the player must do to start the game
UNLOCK_MOVES = NS.NW, NS.N, NS.NE, NS.S
def __init__(self, parent):
"""
Create an object for the attract mode video pop-up.
@param parent GameChild object that will connect this GameChild object to the overall tree and root Game object
"""
Animation.__init__(self, parent)
self.active = False
# Set up attract mode pop-up
self.angle = pi / 8
self.video = Video(self, 320)
self.video.location.center = 329, 182
self.register(self.show_video, self.hide_video)
self.show_video()
# Set up scores
font_path = self.get_resource(self.get_configuration("display", "scores-font"))
self.heading_font = pygame.font.Font(font_path, 22)
self.score_font = pygame.font.Font(font_path, 16)
self.score_sprites = []
def reset(self):
"""
Set the unlock progress back to zero. Start glowing the first move in the unlock pattern. Halt all animations. Unhide video.
"""
self.unlock_index = 0
self.get_game().platform.set_glowing(self.get_game().platform.get_buttons_from_edges([self.UNLOCK_MOVES[0]]))
self.halt()
self.show_video()
def activate(self):
"""
Activate platform, Tony, and player objects as well. Start playing the BGM.
"""
self.active = True
platform = self.get_game().platform
platform.activate()
platform.set_glowing(platform.get_buttons_from_edges([self.UNLOCK_MOVES[self.unlock_index]]))
self.get_game().chemtrails.activate()
self.get_game().tony.set_frameset("static")
self.get_audio().play_bgm("title")
# Optimization for only drawing part of the title screen
if self.get_configuration("system", "optimize-title-screen"):
# Blit the scores
for sprite in self.score_sprites:
sprite.update()
# Optimize by setting a clip that excludes the area where the scores are drawn
self.get_display_surface().set_clip(
(self.score_sprites[0].location.right, 0, self.score_sprites[1].location.left - self.score_sprites[0].location.right,
self.get_display_surface().get_height()))
def deactivate(self):
self.active = False
self.halt()
self.get_display_surface().set_clip(None)
def start_game(self):
"""
Turn off the title screen and either display the level select or start level one if level select is disabled. Set the
most recent time to None so the most recent high score stops blinking.
"""
self.deactivate()
self.get_game().most_recent_score = None
if self.get_configuration("system", "enable-level-select"):
self.get_game().level_select.activate()
else:
self.get_game().level_select.launch(0)
def draw_score_to_column(self, score, column, screen_pos, rank):
"""
Blit `score` onto `column`, taking positioning and rank into account
@param score Score to display in top scores
@param column Surface for displaying score
@param screen_pos absolute screen (x, y) of score topleft
@param rank rank of score
@return height of the drawn score
"""
# Parse both strings and score objects
if isinstance(score, NS.Score):
text = score.formatted()
else:
text = score
# The background is based on rank
if rank == 0:
bg = 255, 215, 0
elif rank == 1:
bg = 192, 192, 192
elif rank == 2:
bg = 205, 127, 50
else:
bg = 255, 255, 255
# Draw the score
score_surface = render_box(self.score_font, text, width=column.get_width(), background=bg)
column.blit(score_surface, (0, screen_pos[1]))
# Create a blinking indicator for the most recent score
if score == self.get_game().most_recent_score:
self.score_indicator = BlinkingSprite(self, 500)
arrow = pygame.surface.Surface([score_surface.get_height()] * 2)
arrow.set_colorkey((255, 0, 255))
arrow.fill((255, 0, 255))
if screen_pos[0] == 0:
points = 0, arrow.get_height() // 2, arrow.get_width() - 1, 0, arrow.get_width() - 1, arrow.get_height() - 1
else:
points = 0, 0, 0, arrow.get_height() - 1, arrow.get_width() - 1, arrow.get_height() // 2
pygame.gfxdraw.filled_trigon(arrow, points[0], points[1], points[2], points[3], points[4], points[5], bg)
pygame.gfxdraw.trigon(arrow, points[0], points[1], points[2], points[3], points[4], points[5], (0, 0, 0))
self.score_indicator.add_frame(arrow)
self.score_indicator.location.top = screen_pos[1]
if screen_pos[0] == 0:
self.score_indicator.location.left = score_surface.get_width() + 5
else:
self.score_indicator.location.right = screen_pos[0] - 5
# The height is used to move the draw location
return score_surface.get_height()
def draw_heading_to_column(self, text, column, y):
"""
Blit `text` on `column` in the heading style
@param text heading text to blit
@param column Surface where heading will be blit
@param y top position of text
@return height of drawn heading
"""
heading = render_box(self.heading_font, text, color=(255, 255, 255), background=(0, 0, 0), width=column.get_width())
column.blit(heading, (0, y))
return heading.get_height()
def draw_scores(self):
"""
Create two columns, one for each side of the screen. Draw as many scores as can fit along each column, in order from
best to worst, separating them evenly into categories: normal, advanced, and expert. Save the columns as sprites. Note
that this doesn't support non-level select mode anymore.
"""
ds = self.get_display_surface()
self.score_indicator = None
heading_width, heading_height = self.heading_font.size("ADVANCED")
heading_width += 10
score_height = self.score_font.size("0")[1]
column_width, column_height = heading_width, ds.get_height()
left_score_count = (column_height - heading_height * 2) // score_height
right_score_count = (column_height - heading_height) // score_height
total_score_count = left_score_count + right_score_count
per_category_count, remainder = divmod(total_score_count, 3)
left_column_sprite = Sprite(self)
left_column = pygame.surface.Surface((column_width, column_height))
left_column.fill((255, 255, 255))
x, y = 0, 0
y += self.draw_heading_to_column("NORMAL", left_column, y)
count = per_category_count
if remainder:
count += 1
remainder -= 1
for rank, score in enumerate(sorted([score for score in self.get_game().scores if score.level_index == 0])[:count]):
y += self.draw_score_to_column(score, left_column, (x, y), rank)
left_score_count -= 1
y += self.draw_heading_to_column("ADVANCED", left_column, y)
count = per_category_count
if remainder:
count += 1
remainder -= 1
right_column_sprite = Sprite(self)
right_column = pygame.surface.Surface((column_width, column_height))
right_column.fill((255, 255, 255))
column = left_column
for rank, score in enumerate(sorted([score for score in self.get_game().scores if score.level_index == 1])[:count]):
y += self.draw_score_to_column(score, column, (x, y), rank)
if left_score_count == 1:
y = 0
x = ds.get_width() - column_width
column = right_column
left_column_sprite.add_frame(left_column)
if left_score_count == 0:
right_score_count -= 1
else:
left_score_count -= 1
y += self.draw_heading_to_column("EXPERT", right_column, y)
count = per_category_count
for rank, score in enumerate(sorted([score for score in self.get_game().scores if score.level_index == 2])[:count]):
y += self.draw_score_to_column(score, right_column, (x, y), rank)
right_column_sprite.add_frame(right_column)
right_column_sprite.location.topleft = x, 0
if not self.get_configuration("system", "optimize-title-screen") and self.get_configuration("display", "scores-alpha") < 255:
alpha = self.get_configuration("display", "scores-alpha")
left_column.set_alpha(alpha)
right_column.set_alpha(alpha)
self.score_sprites = [left_column_sprite, right_column_sprite]
def show_video(self):
self.video.unhide()
self.play(self.hide_video, delay=self.get_configuration("time", "attract-gif-length"), play_once=True)
self.get_game().tony.set_frameset("static")
self.unlock_index = 0
self.get_game().platform.set_glowing(self.get_game().platform.get_buttons_from_edges([self.UNLOCK_MOVES[0]]))
def hide_video(self):
self.video.hide()
self.play(self.show_video, delay=self.get_configuration("time", "attract-board-length"), play_once=True)
self.get_game().tony.set_frameset("board")
def update(self):
"""
Scroll the background, check for button presses for the unlock pattern, handle switching between attract mode
with the GIFs active and unlocking pattern mode, and draw the screen
"""
Animation.update(self)
if self.active:
ds = self.get_display_surface()
dsr = ds.get_rect()
# Draw the background
self.get_game().logo.update()
# Advance through the unlock pattern
platform = self.get_game().platform
if not self.get_game().wipe.is_playing() and platform.get_edge_pressed() == self.UNLOCK_MOVES[self.unlock_index]:
if self.unlock_index == len(self.UNLOCK_MOVES) - 1:
platform.set_glowing([])
self.get_game().wipe.start(self.start_game)
self.get_audio().play_sfx("confirm")
else:
self.unlock_index += 1
platform.set_glowing(platform.get_buttons_from_edges([self.UNLOCK_MOVES[self.unlock_index]]))
self.get_audio().play_sfx("land_0")
self.get_game().tony.update()
# Draw the scores unless optimized out
if not self.get_configuration("system", "optimize-title-screen"):
for sprite in self.score_sprites:
sprite.update()
# Bounce the GIF around the screen
if self.video.location.right > dsr.right or self.video.location.left < dsr.left:
self.angle = reflect_angle(self.angle, 0)
if self.video.location.right > dsr.right:
self.video.move(dsr.right - self.video.location.right)
else:
self.video.move(dsr.left - self.video.location.left)
if self.video.location.bottom > dsr.bottom or self.video.location.top < dsr.top:
self.angle = reflect_angle(self.angle, pi)
if self.video.location.bottom > dsr.bottom:
self.video.move(dy=dsr.bottom - self.video.location.bottom)
else:
self.video.move(dy=dsr.top - self.video.location.top)
dx, dy = get_delta(self.angle, 5, False)
self.video.move(dx, dy)
# Hide GIFs/attract mode (or keep them hidden) if input is detected. Set a countdown that will turn
# attract mode back on if no input is detected before the countdown expires. As long as input keeps
# being detected, this block will keep running and restarting the countdown.
if platform.get_pressed():
self.video.hide()
self.get_game().tony.set_frameset("static")
self.halt()
self.play(self.show_video, delay=self.get_configuration("time", "attract-reset-countdown"), play_once=True)
# Indicate most recent score
if self.score_indicator is not None:
self.score_indicator.update()
class Dialogue(Animation):
"""
This class creates the graphics for displaying character dialog. It displays an avatar, a character name, and a box with the
dialog text in classic RPG format. It uses the Animation class to scroll the text onto the screen with a sound effect to mimic
talking.
"""
BACKGROUND = 255, 255, 255
BORDER = 0, 0, 0
TEXT_COLOR = 0, 0, 0
FONT_PATH = "rounded-mplus-1m-bold.ttf"
FONT_SIZE = 18
def __init__(self, parent):
Animation.__init__(self, parent)
ds = self.get_display_surface()
dsr = ds.get_rect()
frame = Surface((dsr.w, 72))
frame.fill(self.BORDER)
frame.fill(self.BACKGROUND, (1, 1, frame.get_width() - 2, frame.get_height() - 2))
self.text_box = Sprite(self)
self.text_box.add_frame(frame)
self.text_box.location.bottomleft = dsr.bottomleft
frame = Surface((66, 66))
frame.fill(self.BORDER)
frame.fill(self.BACKGROUND, (1, 1, frame.get_width() - 2, frame.get_height() - 2))
self.avatar_box = Sprite(self)
self.avatar_box.add_frame(frame)
self.avatar_box.location.bottomleft = self.text_box.location.topleft
frame = Surface((128, 24))
frame.fill(self.BORDER)
frame.fill(self.BACKGROUND, (1, 1, frame.get_width() - 2, frame.get_height() - 2))
self.name_box = Sprite(self)
self.name_box.add_frame(frame)
self.name_box.location.bottomleft = self.avatar_box.location.bottomright
self.speech_channel = None
self.base_message_frame = Surface((self.text_box.location.w - 10, 30 * 2))
self.base_message_frame.fill(self.BACKGROUND)
def reset(self):
self.stop_speech()
self.halt()
self.deactivate()
self.first_pressed = False
self.first_press_elapsed = 0
def stop_speech(self):
if self.speech_channel is not None:
self.speech_channel.stop()
self.speech_channel = None
def deactivate(self):
self.stop_speech()
self.active = False
def activate(self):
self.active = True
def set_avatar(self, image):
self.avatar = Sprite(self)
self.avatar.add_frame(image)
self.avatar.location.center = self.avatar_box.location.center
def set_name(self, text):
font = pygame.font.Font(self.get_resource(self.FONT_PATH), self.FONT_SIZE)
self.name = Sprite(self)
self.name.add_frame(font.render(text, True, self.TEXT_COLOR).convert_alpha())
self.name.location.midleft = self.name_box.location.left + 5, self.name_box.location.centery
def show_text(self, text):
self.full_text = text
self.text_index = 0
self.speech_channel = self.get_audio().play_sfx("talk", -1)
self.play()
def build_frame(self):
self.text_index += 2
if self.text_index >= len(self.full_text):
self.show_all()
def show_all(self):
self.stop_speech()
self.text_index = len(self.full_text)
self.halt()
def update(self):
if self.active:
Animation.update(self)
self.avatar_box.update()
self.avatar.update()
self.name_box.update()
self.name.update()
self.text_box.update()
font = pygame.font.Font(self.get_resource(self.FONT_PATH), self.FONT_SIZE)
message = Sprite(self)
lines = self.full_text[:self.text_index].split("\n")
frame = self.base_message_frame.copy()
for ii, line in enumerate(lines):
surface = font.render(line, True, self.TEXT_COLOR, self.BACKGROUND).convert()
frame.blit(surface, (0, 30 * ii))
message.add_frame(frame)
message.location.topleft = self.text_box.location.left + 9, self.text_box.location.top + 8
message.update()
class SkipPrompt(GameChild):
def __init__(self, parent, callback):
GameChild.__init__(self, parent)
self.callback = callback
self.buttons = []
self.pluses = []
top = 3
left = 3
for ii, edge in enumerate((NS.S, NS.NE, NS.W)):
self.buttons.append(Button(self, edge, AdvancePrompt.BUTTON_SIZE, AdvancePrompt.BUTTON_BORDER))
self.buttons[-1].location.topleft = left, top
if ii < 2:
self.pluses.append(Sprite(self))
self.pluses[-1].load_from_path(self.get_resource("Plus.png"), True)
self.pluses[-1].location.center = (
self.buttons[-1].location.right + AdvancePrompt.BUTTON_SPACING / 2,
self.buttons[-1].location.centery)
left += self.buttons[-1].location.width + AdvancePrompt.BUTTON_SPACING
self.text = Sprite(self)
font = pygame.font.Font(self.get_resource(Dialogue.FONT_PATH), 18)
self.text.add_frame(font.render("TO SKIP", True, (0, 0, 0)).convert_alpha())
self.text.location.midleft = (
self.buttons[2].location.right + 5,
self.buttons[2].location.centery)
self.button_sound = self.get_audio().sfx["button"]
def reset(self):
self.press_index = 0
self.press_elapsed = 0
for button in self.buttons:
button.unhide()
for plus in self.pluses:
plus.unhide()
def update(self):
platform = self.get_game().platform
if self.press_index == 0 and platform.get_edge_pressed() == NS.S:
self.press_index += 1
self.button_sound.play()
self.buttons[0].hide()
self.pluses[0].hide()
elif self.press_index == 1 and platform.get_edge_pressed() == NS.NE:
self.press_index += 1
self.button_sound.play()
self.buttons[1].hide()
self.pluses[1].hide()
elif self.press_index == 2 and platform.get_edge_pressed() == NS.W:
self.callback()
self.get_audio().play_sfx("confirm")
elif self.press_index > 0:
self.press_elapsed += self.get_game().time_filter.get_last_frame_duration()
if self.press_elapsed > 4000:
self.reset()
for button in self.buttons:
button.update()
for plus in self.pluses:
plus.update()
self.text.update()
class AdvancePrompt(GameChild):
BUTTON_SIZE = 30
BUTTON_BORDER = 3
BUTTON_SPACING = 64
def __init__(self, parent):
GameChild.__init__(self, parent)
dsr = self.get_display_surface().get_rect()
self.buttons = Button(self, NS.N, self.BUTTON_SIZE, self.BUTTON_BORDER), \
Button(self, NS.NW, self.BUTTON_SIZE, self.BUTTON_BORDER)
self.plus = Sprite(self)
self.plus.load_from_path(self.get_resource("Plus.png"), True)
dsr = self.get_display_surface().get_rect()
self.plus.location.center = dsr.centerx, dsr.centery + 70
self.buttons[1].location.center = self.plus.location.move(self.BUTTON_SPACING, 0).center
self.buttons[0].location.center = self.plus.location.move(-self.BUTTON_SPACING, 0).center
self.background_rect = Rect(
self.buttons[0].location.topleft,
(self.buttons[1].location.right - self.buttons[0].location.left, self.buttons[0].location.height))
self.background_rect.inflate_ip((10, 10))
def reset(self):
self.cancel_first_press()
for button in self.buttons:
button.unhide()
self.plus.unhide()
def cancel_first_press(self):
self.first_pressed = False
self.first_pressed_elapsed = 0
self.buttons[0].unhide()
self.plus.unhide()
def check_first_press(self):
return not self.first_pressed and self.get_game().platform.get_edge_pressed() == NS.N
def press_first(self):
self.first_pressed = True
self.buttons[0].hide()
self.plus.hide()
self.get_audio().play_sfx("button")
def check_second_press(self):
pressed = self.first_pressed and self.get_game().platform.get_edge_pressed() == NS.NW
if pressed:
self.get_audio().play_sfx("confirm")
return pressed
def update(self):
if self.first_pressed:
self.first_pressed_elapsed += self.get_game().time_filter.get_last_frame_duration()
self.get_display_surface().fill((255, 255, 255), self.background_rect)
for button in self.buttons:
button.update()
self.plus.update()
class Wipe(Animation):
"""
This class creates a blinds screen wipe effect that can be given a callback function to be called exactly when the screen is
filled with the wipe graphic. This allows the game to transition between states behind the wipe graphic to create a curtain
effect.
"""
BLIND_COUNT = 4
SPEED = 6
def __init__(self, parent):
"""
Initialize the wipe image and sound effect
@param parent PGFW game object that instantiated the wipe
"""
Animation.__init__(self, parent)
self.image = load(self.get_resource("Ink.png")).convert()
self.sound = self.get_audio().sfx["wipe"]
self.callback_kwargs = {}
def reset(self):
"""
Deactivate and stop the animation
"""
self.deactivate()
self.halt()
def deactivate(self):
self.active = False
def activate(self):
self.active = True
def start(self, callback, **kwargs):
"""
Trigger the wipe animation to begin. The given callback function will be called when the screen is filled with the
wipe graphic.
@param callback function to be called when the wipe is covering the screen
"""
self.activate()
self.up = True
self.blind_height = self.get_display_surface().get_height() / self.BLIND_COUNT
self.callback = callback
self.callback_kwargs = kwargs
self.play()
self.sound.play()
def build_frame(self):
"""
This grows and shrinks the height of the blinds that control how much of the wipe graphic is currently displayed. It
will be called automatically every frame as long as the wipe's update method is being called.
"""
if self.up:
self.blind_height -= self.SPEED
if self.blind_height <= 0:
self.up = False
self.callback(**self.callback_kwargs)
else:
self.blind_height += self.SPEED
if self.blind_height >= self.get_display_surface().get_height() / self.BLIND_COUNT:
self.halt()
self.deactivate()
self.get_game().unsuppress_input()
def update(self):
"""
Use the blind height value and screen clipping to draw the screen wipe in the state indicated by the blind height. The
screen is clipped to rects based on the blind height, and only those rects will have the wipe graphic drawn. Other screen
areas will show what is being drawn behind the screen wipe.
"""
if self.active:
Animation.update(self)
ds = self.get_display_surface()
dsr = ds.get_rect()
# Save the existing clip
existing_clip = ds.get_clip()
if existing_clip is not None:
left = existing_clip.left
width = existing_clip.width
else:
left = 0
width = dsr.w
# Draw blinds
for y in range(0, dsr.h, dsr.h // self.BLIND_COUNT):
if self.up:
ds.set_clip((left, y, width, dsr.h // self.BLIND_COUNT - self.blind_height))
else:
ds.set_clip((left, y + self.blind_height, width, dsr.h // self.BLIND_COUNT - self.blind_height))
ds.blit(self.image, (0, 0))
# Restore clip
ds.set_clip(existing_clip)
class Platform(GameChild):
"""
This class contains methods for manipulating and getting information about the platform the player is standing on,
both the real one and on-screen representation. It initializes four Light objects, one for each pad on the platform.
It can set lights to glowing, return the states of individual lights or pairs of lights, reset lights, draw the
on-screen representation, and track how long an edge has been continuously pressed.
"""
def __init__(self, parent, center):
"""
Initialize four lights, one for each pad on the platform. Initialize a Sprite for the pad graphics with one
frameset per six possible combinations of lights.
@param parent PGFW game object that initialized this object
@param center tuple that gives the (x, y) screen coordinates of this platform
"""
GameChild.__init__(self, parent)
# Create objects for tracking the individual pad lights
self.lights = [
Light(self, self.get_configuration("pads", "nw_color"), NS.LNW),
Light(self, self.get_configuration("pads", "ne_color"), NS.LNE),
Light(self, self.get_configuration("pads", "se_color"), NS.LSE),
Light(self, self.get_configuration("pads", "sw_color"), NS.LSW)
]
# Create a sprite which will display the pad
self.view = Sprite(self)
self.view.load_from_path(os.path.join(self.get_resource("pad"), "pad_0.png"), True)
self.view.add_frameset([0], name="neutral")
# For each of the six combinations of lights, create a glowing frameset
light_masks = load_frames(self.get_resource("pad_mask"), True)
for orientation_index, orientation in enumerate((NS.N, NS.E, NS.NW, NS.NE, NS.W, NS.S)):
pad_base_frame = load_frames(os.path.join(self.get_resource("pad"), f"pad_{orientation_index + 1}.png"), True)[0]
self.view.add_frame(pad_base_frame)
frameset = [len(self.view.frames) - 1]
intensity_resolution = 12
for intensity in range(1, intensity_resolution):
next_pad_frame = pad_base_frame.copy()
for light_orientation in self.get_buttons_from_edges([orientation]):
copy = light_masks[light_orientation].copy()
pixels = pygame.PixelArray(copy)
color = pygame.Color(0, 0, 0)
h, s, l, a = color.hsla
l = int(intensity / intensity_resolution * 100)
color.hsla = h, s, l, a
pixels.replace(pygame.Color(0, 0, 0), color)
del pixels
next_pad_frame.blit(copy, (0, 0), None, BLEND_RGBA_ADD)
self.view.add_frame(next_pad_frame)
frameset.append(len(self.view.frames) - 1)
self.view.add_frameset(frameset, name=str(orientation))
self.view.location.center = center
def reset(self):
"""
Deactivate this object and reset each light. Reset press elapsed tracker.
"""
self.deactivate()
self.reset_lights()
self.previously_pressed_edge = None
self.press_elapsed = 0
def reset_lights(self):
for light in self.lights:
light.reset()
def deactivate(self):
"""
This will stop the platform from being drawn and lights from updating
"""
self.active = False
def activate(self):
"""
This will cause the platform to get drawn and lights to update when this object's update method is called
"""
self.active = True
def unpress(self):
"""
Set the state of each light to unpressed
"""
for light in self.lights:
light.pressed = False
def get_pressed(self):
"""
Returns a list of light positions pressed (NS.LNW, NS.LNE, NS.LSE, NS.LSW)
"""
return [light.position for light in self.lights if light.pressed]
def get_edge_pressed(self):
"""
Gets the edge (2 light combination) currently pressed. This only returns one edge since there should only
be one able to be pressed at a time. If no edge is pressed, returns None.
@return NS.N | NS.NE | NS.E | NS.NW | NS.S | NS.W | None
"""
pressed = self.get_pressed()
if NS.LNW in pressed and NS.LNE in pressed:
return NS.N
elif NS.LNE in pressed and NS.LSW in pressed:
return NS.NE
elif NS.LNE in pressed and NS.LSE in pressed:
return NS.E
elif NS.LNW in pressed and NS.LSE in pressed:
return NS.NW
elif NS.LSE in pressed and NS.LSW in pressed:
return NS.S
elif NS.LSW in pressed and NS.LNW in pressed:
return NS.W
def get_glowing_edge(self):
"""
Return the edge currently glowing or None
@return NS.N | NS.NE | NS.E | NS.NW | NS.S | NS.W | None
"""
if self.lights[NS.LNW].glowing() and self.lights[NS.LNE].glowing():
return NS.N
elif self.lights[NS.LNE].glowing() and self.lights[NS.LSW].glowing():
return NS.NE
elif self.lights[NS.LNE].glowing() and self.lights[NS.LSE].glowing():
return NS.E
elif self.lights[NS.LNW].glowing() and self.lights[NS.LSE].glowing():
return NS.NW
elif self.lights[NS.LSE].glowing() and self.lights[NS.LSW].glowing():
return NS.S
elif self.lights[NS.LSW].glowing() and self.lights[NS.LNW].glowing():
return NS.W
def get_buttons_from_edges(self, edges):
"""
Get a list of light positions contained by a list of edges. For example, [NS.N, NS.E] would give [NS.LNW, NS.LNE, NS.LSE].
@param edges list of edges [NS.N | NS.NE | NS.E | NS.NW | NS.S | NS.W]
@return list of light positions [NS.LNW | NS.LNE | NS.LSE | NS.LSW]
"""
buttons = set()
for edge in edges:
if edge == NS.N:
buttons = buttons.union((NS.LNW, NS.LNE))
elif edge == NS.NE:
buttons = buttons.union((NS.LNE, NS.LSW))
elif edge == NS.E:
buttons = buttons.union((NS.LNE, NS.LSE))
elif edge == NS.NW:
buttons = buttons.union((NS.LNW, NS.LSE))
elif edge == NS.S:
buttons = buttons.union((NS.LSE, NS.LSW))
elif edge == NS.W:
buttons = buttons.union((NS.LSW, NS.LNW))
return list(buttons)
def get_steps_from_edge(self, edge):
"""
Get the edges that are one step away from a given edge. For example, NS.N would give (NS.NE, NS.NW) because those
are the edges that only require a pivot move of one step from NS.N.
@param edge one of NS.N, NS.NE, NS.E, NS.NW, NS.S, NS.W
@return pair of edges that are one step away
"""
if edge == NS.N:
return NS.NE, NS.NW
elif edge == NS.NE:
return NS.N, NS.E, NS.S, NS.W
elif edge == NS.E:
return NS.NE, NS.NW
elif edge == NS.NW:
return NS.N, NS.E, NS.S, NS.W
elif edge == NS.S:
return NS.NE, NS.NW
elif edge == NS.W:
return NS.NE, NS.NW
def get_right_angles_from_edge(self, edge):
"""
Get the pair of angles that are at a right angle to a given edge. For example, NS.N would return (NS.E, NW.W). For
diagonals, this returns None.
@param edge one of NS.N, NS.NE, NS.E, NS.NW, NS.S, NS.W
@return pair of edges that are at a right angle to given edge or None
"""
if edge == NS.N:
return NS.E, NS.W
elif edge == NS.NE:
return None
elif edge == NS.E:
return NS.N, NS.S
elif edge == NS.NW:
return None
elif edge == NS.S:
return NS.E, NS.W
elif edge == NS.W:
return NS.N, NS.S
def get_opposite_of_edge(self, edge):
"""
Get the edge opposite to a given edge. For example, NS.N would return NS.S. For diagonals, the opposite is the
reverse diagonal.
@param edge one of NS.N, NS.NE, NS.E, NS.NW, NS.S, NS.W
@return edge opposite to given edge, one of NS.N, NS.NE, NS.E, NS.NW, NS.S, NS.W
"""
if edge == NS.N:
return NS.S
elif edge == NS.NE:
return NS.NW
elif edge == NS.E:
return NS.W
elif edge == NS.NW:
return NS.NE
elif edge == NS.S:
return NS.N
elif edge == NS.W:
return NS.E
def get_color_pair_from_edge(self, edge):
"""
Return the pair of pygame color objects that make up a given edge
@param edge one of NS.N, NS.NE, NS.E, NS.NW, NS.S, NS.W
@return tuple of pygame color objects
"""
if edge == NS.N:
return self.lights[NS.LNW].color, self.lights[NS.LNE].color
elif edge == NS.NE:
return self.lights[NS.LNE].color, self.lights[NS.LSW].color
elif edge == NS.E:
return self.lights[NS.LNE].color, self.lights[NS.LSE].color
elif edge == NS.NW:
return self.lights[NS.LNW].color, self.lights[NS.LSE].color
elif edge == NS.S:
return self.lights[NS.LSW].color, self.lights[NS.LSE].color
elif edge == NS.W:
return self.lights[NS.LNW].color, self.lights[NS.LSW].color
def set_glowing(self, selected):
"""
Set the given light IDs to glowing and other indices to not glowing.
@param selected list of light IDs (NS.LNW, NS.LNE, NS.LSE, NS.LSW)
"""
for ii, light in enumerate(self.lights):
light.glow_index = 0
light.unglow()
if ii in selected:
light.glow()
def update(self):
"""
Update each light and draw the platform and glow effect
"""
if self.active:
for light in self.lights:
light.update()
# draw the pad based on which pads are glowing
glowing = self.get_glowing_edge()
if glowing is None:
self.view.set_frameset("neutral")
self.view.update()
else:
self.view.set_frameset(str(glowing))
self.view.update()
# track how long an edge has been pressed
if self.get_edge_pressed() is not None:
if self.get_edge_pressed() != self.previously_pressed_edge:
self.previously_pressed_edge = self.get_edge_pressed()
self.press_elapsed = 0
else:
self.press_elapsed += self.get_game().time_filter.get_last_frame_duration()
else:
self.previously_pressed_edge = None
self.press_elapsed = 0
class Light(GameChild):
"""
This class represents a pad on the platform. Typically there are four instances for a platform, one for each corner of the
platform. Each light stores its color, position on the platform, and state of glowing.
"""
TITLE_OFFSET = 0
def __init__(self, parent, color, position):
"""
Initialize a new Light object, providing color and position on the platform.
@param parent PGFW game object that instantiated this object
@param color pygame color object
@param position the light's position on the platform, one of NS.LNW, NS.LNE, NS.LSE, NS.LSW
"""
GameChild.__init__(self, parent)
self.color = Color(color)
self.color.a = 225
self.position = position
self.pressed = False
ds = self.get_display_surface()
frontleft = ds.get_width() / 2 - NS.FRONT_WIDTH / 2, NS.FRONT
backleft = ds.get_width() / 2 - NS.BACK_WIDTH / 2, NS.FRONT + NS.LENGTH
left_step = get_step_relative(frontleft, backleft, NS.STEP)
midleft = frontleft[0] + left_step[0], frontleft[1] + left_step[1]
frontmid = ds.get_width() / 2, NS.FRONT
mid = ds.get_width() / 2, NS.FRONT + NS.LENGTH * NS.STEP
backmid = ds.get_width() / 2, NS.FRONT + NS.LENGTH
frontright = ds.get_width() / 2 + NS.FRONT_WIDTH / 2, NS.FRONT
backright = ds.get_width() / 2 + NS.BACK_WIDTH / 2, NS.FRONT + NS.LENGTH
right_step = get_step_relative(frontright, backright, NS.STEP)
midright = frontright[0] + right_step[0], frontright[1] + right_step[1]
if self.position == NS.LNW:
self.points = frontleft, frontmid, mid, midleft
elif self.position == NS.LNE:
self.points = frontmid, frontright, midright, mid
elif self.position == NS.LSE:
self.points = mid, midright, backright, backmid
elif self.position == NS.LSW:
self.points = midleft, mid, backmid, backleft
def reset(self):
"""
Unhide, halt glow animation
"""
self.unglow()
def glow(self):
"""
Set the glow state to True
"""
self.is_glowing = True
def unglow(self):
"""
Set the glow state to False
"""
self.is_glowing = False
def glowing(self):
"""
Returns True if this light is glowing, False otherwise
@return True | False
"""
return self.is_glowing
def update(self):
"""
Checks the attack state to determine whether to start or stop glowing
"""
if not self.get_game().title.active and not self.get_game().level_select.active:
boss = self.get_game().boss
chemtrails = self.get_game().chemtrails
# checks the boss attack queue and chameleon queue index to see if the glow should be started now
if boss.queue and self.in_orientation(boss.queue[chemtrails.queue_index]):
self.glow()
# turns off the glow
elif not boss.queue or not self.in_orientation(boss.queue[chemtrails.queue_index]):
self.unglow()
def get_points(self):
if self.get_game().title.active:
points = []
for point in self.points:
points.append((point[0], point[1] - self.TITLE_OFFSET))
return points
else:
return self.points
def in_orientation(self, orientation):
"""
Returns True if this light is contained in the given edge
@param orientation edge to check, one of NS.N, NS.NW, NS.W, NS.NE, NS.E, NS.S
@return True | False
"""
if self.position == NS.LNW:
return orientation in (NS.N, NS.NW, NS.W)
elif self.position == NS.LNE:
return orientation in (NS.N, NS.NE, NS.E)
elif self.position == NS.LSE:
return orientation in (NS.NW, NS.E, NS.S)
elif self.position == NS.LSW:
return orientation in (NS.S, NS.NE, NS.W)
class Chemtrails(Sprite):
"""
This class stores the graphics and state of the player character. It contains sprite frames, health and life objects, and the
timer that counts down the amount of time left to perform a move.
"""
def __init__(self, parent):
"""
Load the sprite frames, one for each pad orientation. Initialize a health object, lives object, and timer. Create a sprite
for the tongue.
@param parent PGFW game object that initialized this object
"""
Sprite.__init__(self, parent, framerate=125)
for directory in sorted(iglob(join(self.get_resource("littleSlimeGoop"), "[0-9]_*/"))):
self.add_frameset(switch=True)
self.load_from_path(directory, True)
self.add_frameset(name="hurt", switch=True)
self.load_from_path("littleSlimeGoop/Hurt", True)
self.tongue = Sprite(self, 160)
self.tongue.load_from_path("littleSlimeGoop/justTongue", True)
self.set_frameset(NS.N)
self.register(self.cancel_hurt)
self.life = Life(self)
self.boys = Boys(self)
self.timer = Timer(self)
def reset(self):
"""
Reset the health, lives, and timer objects and deactivate.
"""
self.deactivate()
self.life.reset()
self.boys.reset()
self.timer.reset()
self.set_frameset(NS.N)
def deactivate(self):
self.active = False
def activate(self):
self.active = True
def challenge(self):
"""
Start an attempt against a new queue of swords to be cleared.
"""
self.timer.reset()
self.queue_index = 0
def display_hurt(self):
"""
Show hurt animation and trigger it to end after a delay
"""
self.set_frameset("hurt")
self.play(self.cancel_hurt, delay=self.get_configuration("time", "lizard-hurt-length"), play_once=True)
def cancel_hurt(self):
"""
Reset to a non-hurt frameset
"""
self.set_frameset(NS.N)
self.orient()
def attack(self):
"""
Hit the boss if this is called while the boss attack queue is active and the player is in the correct orientation.
Add time to the timer, decrease the boss's health, and play a sound effect. If the queue is finished, reset the
timer completely and trigger another boss combo.
"""
boss = self.get_game().boss
queue = boss.queue
if self.orientation == queue[self.queue_index]:
self.blem()
self.timer.add_time(self.get_configuration("time", f"timer-addition-level-{boss.level_index + 1}"))
boss.health.decrease(self.get_configuration("boss", f"damage-per-hit-level-{boss.level_index + 1}"))
self.queue_index += 1
boss.last_attack = self.orientation
boss.sword.block()
if self.queue_index == len(queue):
self.timer.reset()
if not boss.is_playing(boss.show_end_dialogue, include_delay=True):
boss.combo()
self.get_audio().play_sfx("complete_pattern_3")
else:
self.get_audio().play_sfx("land_0")
self.get_game().platform.reset_lights()
def orient(self):
"""
Place the sprite on screen based on which edge is being pressed by the player on the real mat.
"""
ds = self.get_display_surface()
edge = self.get_game().platform.get_edge_pressed()
dy = -Light.TITLE_OFFSET if self.get_game().title.active else 0
if edge is not None:
if self.get_current_frameset().name != "hurt":
self.set_frameset(edge + 1)
self.unhide()
else:
self.hide()
if edge == NS.N:
self.location.center = ds.get_width() / 2, NS.FRONT + dy - 10
self.orientation = NS.N
elif edge == NS.E:
self.location.center = ds.get_width() / 2 + NS.FRONT_WIDTH / 2 - 85, NS.FRONT + NS.LENGTH * NS.STEP - 40 + dy
self.orientation = NS.E
elif edge == NS.S:
self.location.center = ds.get_width() / 2, NS.FRONT + NS.LENGTH - NS.LENGTH * NS.STEP - 65 + dy
self.orientation = NS.S
elif edge == NS.W:
self.location.center = ds.get_width() / 2 - NS.FRONT_WIDTH / 2 + 85, NS.FRONT + NS.LENGTH * NS.STEP - 40 + dy
self.orientation = NS.W
elif edge == NS.NW:
self.location.center = ds.get_width() / 2, NS.FRONT + NS.LENGTH * NS.STEP + dy - 45
self.orientation = NS.NW
elif edge == NS.NE:
self.location.center = ds.get_width() / 2 - 5, NS.FRONT + NS.LENGTH * NS.STEP - 45 + dy
self.orientation = NS.NE
else:
self.orientation = None
def blem(self):
"""
Start the tongue animation to block the sword
"""
if self.orientation in (NS.N, NS.NE):
self.tongue.location.center = self.location.centerx + 10, self.location.top
elif self.orientation == NS.E:
self.tongue.location.center = self.location.right - 10, self.location.top + 25
elif self.orientation == NS.NW:
self.tongue.location.center = self.location.centerx, self.location.top + 27
elif self.orientation == NS.S:
self.tongue.location.center = self.location.centerx, self.location.top + 100
elif self.orientation == NS.W:
self.tongue.location.center = self.location.left + 13, self.location.top + 23
self.tongue.unhide()
self.tongue.get_current_frameset().reset()
def update(self, offset: Vector=(0, 0)):
if self.active:
self.orient()
self.location.move(offset)
# Draw tongue behind lizard if it the bottom, otherwise draw tongue in front of lizard
if self.orientation == NS.S:
self.tongue.update()
Sprite.update(self)
if self.orientation != NS.S:
self.tongue.update()
# End the tongue animation after one play
if self.tongue.get_current_frameset().current_index == len(self.tongue.get_current_frameset().order) - 1:
self.tongue.hide()
if not self.get_game().title.active and not self.get_game().level_select.active:
boss = self.get_game().boss
if boss.queue:
self.timer.tick()
self.attack()
if self.timer.amount < 0:
self.life.decrease()
if not boss.is_playing(boss.show_end_dialogue, include_delay=True):
self.timer.reset()
boss.combo()
if not boss.is_playing(boss.show_introduction_dialogue, include_delay=True):
self.timer.update()
self.life.update()
# self.boys.update()
class Timer(Meter):
def __init__(self, parent):
Meter.__init__(self, parent)
dsr = self.get_display_surface().get_rect()
background = load(self.get_resource("HUD_timer.png")).convert()
rect = background.get_rect()
rect.bottomright = dsr.right - 4, dsr.bottom - 4
self.setup(background, rect, 53, (0, 0, 255),
self.get_configuration("time", "timer-start-level-1"), "scrapeIcons/scrapeIcons_07.png")
def reset(self):
"""
Account for the differences in time per level by setting a custom amount based on the boss's level
"""
super().reset()
# The difference between level 1 and the current level is how much to remove from the timer
difference = self.get_configuration("time", "timer-start-level-1") - \
self.get_configuration("time", f"timer-start-level-{self.get_game().boss.level_index + 1}")
self.change(-difference)
def add_time(self, amount):
self.change(amount)
def tick(self):
self.change(-self.get_game().time_filter.get_last_frame_duration())
class Life(Meter):
"""
This class stores the state of the player's HP
"""
def __init__(self, parent):
"""
Initialize the Meter super class and graphics
"""
Meter.__init__(self, parent)
dsr = self.get_display_surface().get_rect()
background = load(self.get_resource("HUD_health.png")).convert()
rect = background.get_rect()
rect.bottomleft = 4, dsr.bottom - 4
self.setup(background, rect, 70, (255, 0, 0), 3, "scrapeIcons/scrapeIcons_03.png")
def decrease(self):
"""
Remove one health point. Set the current sword to attacking the chameleon. If this meter is depleted, remove a life
and trigger the boss's battle finish method.
"""
self.get_audio().play_sfx("hurt")
self.change(-1)
self.get_game().boss.sword.attack(player=True)
if self.amount <= 0:
self.amount = 0
self.parent.boys.change(-1)
self.get_game().boss.finish_battle(False)
class Boys(Meter):
def __init__(self, parent):
Meter.__init__(self, parent)
dsr = self.get_display_surface().get_rect()
background = load(self.get_resource("HUD_lives.png")).convert()
rect = background.get_rect()
rect.bottomleft = 6, dsr.bottom - 4
# The amount of lives depends on whether it's boss rush or level select mode.
if self.get_configuration("system", "enable-level-select"):
lives = self.get_configuration("system", "lives-level-select-mode")
else:
lives = self.get_configuration("system", "lives-boss-rush-mode")
self.setup(background, rect, 60, (0, 255, 0), lives, "scrapeIcons/scrapeIcons_01.png")
class BossSprite(Sprite):
"""
Overload the Sprite class to do custom animation for bosses
"""
def shift_frame(self):
"""
Customize the sprite animation to play the entrance animation only once and to loop over the last three frames
of the death animation.
"""
frameset = self.get_current_frameset()
if frameset.name == "entrance" and frameset.get_current_id() == frameset.order[-1]:
self.set_frameset("normal")
elif frameset.name == "death" and frameset.current_index in (-1, len(frameset.order) - 1):
frameset.current_index = -4
super().shift_frame()
class Boss(Animation):
"""
The Boss object also serves as the level object, and it is expected that only one of these objects is initialized.
Its drawing, animations, timings, etc will be determined by the level_index member variable. For example, if
level_index is 0, the kool man sprite will be drawn, but if level_index is 2, the spoopy sprite will be drawn.
"""
def __init__(self, parent):
"""
Load graphics for boss sprites, avatars, and backgrounds. Initialize boss health and swords objects. Register
animations that control attacks, effects, and dialog.
"""
Animation.__init__(self, parent)
self.battle_finished = False
# Set up sprites with boil, hit, and intro animations
self.boss_sprites = []
self.boss_sprite_arms = []
for path in (pathlib.Path(self.get_resource("koolAnimations")), pathlib.Path(self.get_resource("alienAnimations")),
pathlib.Path(self.get_resource("emoAnimations"))):
prefix = path.stem.split("Animations")[0]
self.boss_sprites.append(BossSprite(self, 42))
self.boss_sprites[-1].add_frameset(name="normal", switch=True)
self.boss_sprites[-1].load_from_path(path.joinpath(f"{prefix}Boil"), True)
self.boss_sprites[-1].add_frameset(name="hurt", switch=True)
self.boss_sprites[-1].load_from_path(path.joinpath(f"{prefix}Hit"), True)
self.boss_sprites[-1].add_frameset(name="death", switch=True)
self.boss_sprites[-1].load_from_path(path.joinpath(f"{prefix}Death"), True)
self.boss_sprites[-1].add_frameset(name="entrance", switch=True)
self.boss_sprites[-1].load_from_path(path.joinpath(f"{prefix}Intro"), True)
self.boss_sprites[-1].location.topleft = 207, 10
# Set the arm to its own sprite
self.boss_sprite_arms.append(Sprite(self, 60))
# Map the strings used to indicate direction in the animations directory to the IDs defined in the script
name_map = {
"U": NS.N,
"DR": NS.NE,
"R": NS.E,
"DL": NS.NW,
"D": NS.S,
"L": NS.W,
}
# Set static frames for arms, one for each of the 6 board orientations
root = path.joinpath(f"{prefix}Arms/Moving")
static_arm_frame_map = {
"UtoDR/*05.png": NS.N,
"UtoDR/*10.png": NS.NE,
"RtoDL/*05.png": NS.E,
"RtoDL/*10.png": NS.NW,
"DtoL/*05.png": NS.S,
"DtoL/*10.png": NS.W
}
orientation_frame_indices = {}
for arm_frame_path, orientation in static_arm_frame_map.items():
base = pygame.image.load(str(list(root.glob(arm_frame_path))[0]))
colorkeyed = fill_colorkey(base)
self.boss_sprite_arms[-1].add_frame(colorkeyed)
frame_index = len(self.boss_sprite_arms[-1].frames) - 1
self.boss_sprite_arms[-1].add_frameset([frame_index], name=str(orientation))
orientation_frame_indices[orientation] = frame_index
# Add sword smear animations to the alien's arm, one for each of the 30 possible combinations of 6 board orientations
for directory in path.joinpath(f"{prefix}Arms/Moving").iterdir():
if directory.is_dir():
frame_paths = list(sorted(directory.iterdir()))
# Extract board orientation IDs from the directory name
orientation_1, orientation_2 = [name_map[orientation] for orientation in directory.name.split("to")]
# Alien arm sprite frame indices for each orientation
frame_index_orientation_1 = orientation_frame_indices[orientation_1]
frame_index_orientation_2 = orientation_frame_indices[orientation_2]
# Add orientation_1 -> orientation_2 animation
frame_order = [frame_index_orientation_1]
for frame_path in frame_paths[5:9]:
self.boss_sprite_arms[-1].load_from_path(frame_path, True)
frame_order.append(len(self.boss_sprite_arms[-1].frames) - 1)
frame_order.append(frame_index_orientation_2)
self.boss_sprite_arms[-1].add_frameset(frame_order, name=f"{orientation_1}_{orientation_2}")
# Add orientation_2 -> orientation_1 animation
frame_order = [frame_index_orientation_2]
for frame_path in frame_paths[0:4]:
self.boss_sprite_arms[-1].load_from_path(frame_path, True)
frame_order.append(len(self.boss_sprite_arms[-1].frames) - 1)
frame_order.append(frame_index_orientation_1)
self.boss_sprite_arms[-1].add_frameset(frame_order, name=f"{orientation_2}_{orientation_1}")
self.boss_sprite_arms[-1].location.center = self.boss_sprites[-1].location.center
self.boss_sprite_arms[-1].hide()
# Boss sprite aliases
self.kool_man, self.alien, self.spoopy = self.boss_sprites
self.kool_man_arms, self.alien_arms, self.spoopy_arms = self.boss_sprite_arms
self.health = Health(self)
self.sword = Sword(self)
self.register(self.brandish, self.cancel_flash, self.show_introduction_dialogue, self.show_end_dialogue, self.end_dialogue,
self.end_player_damage, self.end_hit_animation, self.warning, self.enter_boss)
self.register(self.flash_player_damage, interval=100)
self.kool_man_avatar = load(self.get_resource("Kool_man_avatar.png")).convert()
self.alien_avatar = load(self.get_resource("Alien_avatar.png")).convert()
self.spoopy_avatar = load(self.get_resource("Spoopy_avatar.png")).convert()
self.advance_prompt = AdvancePrompt(self)
self.backgrounds = [Sprite(self), Sprite(self), Sprite(self)]
# Add background graphics and generate screen effects
for ii, background in enumerate(self.backgrounds):
background.add_frameset(name="normal", switch=True)
background.load_from_path(f"bg/bg00{ii + 1}.png")
# Inverted background
background.add_frameset(name="inverted", switch=True)
frame = pygame.Surface(background.frames[0].get_size())
frame.fill((255, 255, 255))
frame.blit(background.frames[0], (0, 0), None, pygame.BLEND_RGB_SUB)
background.add_frame(frame)
# Darkened background
background.add_frameset(name="charging", switch=True)
frame = background.frames[0].copy()
frame.fill((80, 80, 80), None, pygame.BLEND_RGB_SUB)
background.add_frame(frame)
# Red background
background.add_frameset(name="warning", switch=True)
frame = background.frames[0].copy()
frame.fill((0, 150, 150), None, pygame.BLEND_RGB_SUB)
background.add_frame(frame)
# Shining background
background.add_frameset(name="shining", switch=True, framerate=120)
for hue in range(0, 360, 40):
frame = background.frames[0].copy()
color = Color(0, 0, 0)
color.hsla = hue, 30, 30, 100
frame.fill(color, None, pygame.BLEND_RGB_ADD)
background.add_frame(frame)
background.set_frameset("normal")
self.countdown = Countdown(self)
def cancel_flash(self):
pass
def start_level(self, index):
self.level_index = index
self.battle_finished = False
self.player_defeated = False
self.health.reset()
self.get_game().chemtrails.timer.reset()
self.get_game().chemtrails.life.reset()
self.activate()
dialogue = self.get_game().dialogue
dialogue.deactivate()
if index == 0:
dialogue.set_avatar(self.kool_man_avatar)
dialogue.set_name("Kool Man")
self.kool_man.hide()
elif index == 1:
dialogue.set_avatar(self.alien_avatar)
dialogue.set_name("Alien")
self.alien.hide()
elif index == 2:
dialogue.set_avatar(self.spoopy_avatar)
dialogue.set_name("Spoopy")
self.spoopy.hide()
self.play(self.enter_boss, play_once=True, delay=1500)
self.get_audio().play_bgm(f"level_{index}")
self.play(self.show_introduction_dialogue, delay=3000, play_once=True)
self.get_game().platform.activate()
self.get_game().chemtrails.activate()
self.last_attack = NS.NW
self.backgrounds[self.level_index].set_frameset("normal")
self.halt(self.flash_player_damage)
def show_introduction_dialogue(self):
dialogue = self.get_game().dialogue
dialogue.activate()
if self.level_index == 0:
dialogue.show_text("You'll never be able to block my sword, you lizard slime!" +
" See if you can keep up\nwith these moves!")
elif self.level_index == 1:
dialogue.show_text("We're just warming up, slime breath! Prepare to get spun" +
" by these combos!")
elif self.level_index == 2:
dialogue.show_text("Lizard! My moves are so unpredictable you might as well" +
" give up now!")
self.play(self.end_dialogue, delay=5000, play_once=True)
def reset(self):
self.level_index = 0
self.kills = 0
self.time_elapsed = 0
self.deactivate()
self.cancel_flash()
self.halt(self.cancel_flash)
self.health.reset()
self.halt(self.brandish)
self.sword.reset()
self.advance_prompt.reset()
self.queue = None
self.brandish_complete = True
self.countdown.reset()
self.halt(self.end_dialogue)
self.halt(self.flash_player_damage)
self.halt(self.end_player_damage)
for background in self.backgrounds:
background.set_frameset("normal")
self.halt(self.end_hit_animation)
self.halt(self.warning)
def deactivate(self):
self.active = False
def activate(self):
self.active = True
def combo(self, delay=None):
if delay is None:
delay = self.get_configuration("boss", f"cooldown-level-{self.level_index + 1}")
self.queue = None
if self.get_game().serial_enabled():
self.get_game().reset_arduino()
self.play(self.brandish, delay=delay, play_once=True)
def brandish(self):
self.queue = []
platform = self.get_game().platform
choice = random.choice
if self.level_index == 0:
if self.health.amount > 90:
first = choice(platform.get_steps_from_edge(self.last_attack))
self.queue = [first]
elif self.health.amount > 70:
first = choice(platform.get_steps_from_edge(self.last_attack))
self.queue = [first, choice(platform.get_steps_from_edge(first))]
elif self.health.amount > 30:
choices = [0]
if self.last_attack in (NS.NE, NS.NW):
choices.append(1)
else:
choices.extend((2, 3))
result = choice(choices)
if result == 0:
first = choice(platform.get_steps_from_edge(self.last_attack))
second = choice(platform.get_steps_from_edge(first))
self.queue = [first, second, first, second]
elif result == 1:
first = choice(platform.get_steps_from_edge(self.last_attack))
self.queue = [first, choice(platform.get_steps_from_edge(first)),
choice(platform.get_right_angles_from_edge(first))]
elif result == 2:
first = choice(platform.get_steps_from_edge(self.last_attack))
self.queue = [first, choice(platform.get_steps_from_edge(first)),
platform.get_opposite_of_edge(first)]
elif result == 3:
first = choice(platform.get_steps_from_edge(self.last_attack))
second = choice(platform.get_steps_from_edge(first))
self.queue = [first, second,
choice(platform.get_right_angles_from_edge(second))]
else:
choices = [0, 1]
if self.last_attack in (NS.NE, NS.NW):
choices.extend((2, 3, 4))
else:
choices.append(5)
result = choice(choices)
if result == 0 or result == 1:
first = choice(platform.get_steps_from_edge(self.last_attack))
second = choice(platform.get_steps_from_edge(first))
last = second if result else platform.get_opposite_of_edge(second)
self.queue = [first, second, platform.get_opposite_of_edge(first),
last]
elif result == 2:
first = choice(platform.get_steps_from_edge(self.last_attack))
self.queue = [first, choice(platform.get_right_angles_from_edge(first)),
platform.get_opposite_of_edge(first)]
elif result == 3:
first = choice(platform.get_steps_from_edge(self.last_attack))
self.queue = [first, choice(platform.get_steps_from_edge(first)),
choice(platform.get_right_angles_from_edge(first)),
platform.get_opposite_of_edge(first)]
elif result == 4:
first = choice(platform.get_steps_from_edge(self.last_attack))
second = choice(platform.get_steps_from_edge(first))
self.queue = [first, second,
choice(platform.get_right_angles_from_edge(first)),
platform.get_opposite_of_edge(second)]
elif result == 5:
first = choice(platform.get_steps_from_edge(self.last_attack))
second = choice(platform.get_steps_from_edge(first))
self.queue = [first, second, platform.get_opposite_of_edge(first),
choice(platform.get_right_angles_from_edge(second))]
elif self.level_index == 1:
if self.health.amount > 85:
if self.last_attack in (NS.NE, NS.NW):
choices = 1, 2
else:
choices = 0,
result = choice(choices)
if result == 0:
first = choice(platform.get_steps_from_edge(self.last_attack))
self.queue = [first, platform.get_opposite_of_edge(first)]
elif result == 1:
first = choice(platform.get_steps_from_edge(self.last_attack))
self.queue = [first, choice(platform.get_right_angles_from_edge(first)),
platform.get_opposite_of_edge(first)]
elif result == 2:
first = choice(platform.get_steps_from_edge(self.last_attack))
self.queue = [first, platform.get_opposite_of_edge(first)]
elif self.health.amount > 60:
if self.last_attack in (NS.NE, NS.NW):
choices = 2, 3
else:
choices = 0, 1
result = choice(choices)
first = choice(platform.get_steps_from_edge(self.last_attack))
if result == 0:
second = choice(platform.get_steps_from_edge(first))
self.queue = [first, second, platform.get_opposite_of_edge(second)]
elif result == 1:
second = choice(platform.get_steps_from_edge(first))
self.queue = [first, second,
choice(platform.get_right_angles_from_edge(second)),
platform.get_opposite_of_edge(first)]
elif result == 2:
second = platform.get_opposite_of_edge(first)
self.queue = [first, second,
choice(platform.get_right_angles_from_edge(second))]
elif result == 3:
second = choice(platform.get_right_angles_from_edge(first))
self.queue = [first, second, platform.get_opposite_of_edge(first),
platform.get_opposite_of_edge(second)]
elif self.health.amount > 30:
result = choice(range(3))
if result == 0:
first = self.choose_new_edge((NS.N, NS.E, NS.S, NS.W))
self.queue = [first, choice(platform.get_steps_from_edge(first)),
platform.get_opposite_of_edge(first), first]
elif result == 1:
first = self.choose_new_edge((NS.NE, NS.NW))
second = choice(platform.get_steps_from_edge(first))
self.queue = [first, second, platform.get_opposite_of_edge(second),
choice(platform.get_right_angles_from_edge(second))]
elif result == 2:
first = self.choose_new_edge((NS.NE, NS.NW))
second = choice(platform.get_steps_from_edge(first))
self.queue = [first, second,
choice(platform.get_right_angles_from_edge(second)),
platform.get_opposite_of_edge(second)]
else:
result = choice(range(4))
if result == 0:
first = self.choose_new_edge((NS.NE, NS.NW))
second = platform.get_opposite_of_edge(first)
self.queue = [first, second, first, second]
elif result == 1:
first = self.choose_new_edge((NS.N, NS.E, NS.S, NS.W))
self.queue = [first, platform.get_opposite_of_edge(first), first]
elif result == 2:
first = self.choose_new_edge((NS.N, NS.E, NS.S, NS.W))
self.queue = [first, choice(platform.get_steps_from_edge(first)),
choice(platform.get_right_angles_from_edge(first)),
platform.get_opposite_of_edge(first), first]
elif result == 3:
first = self.choose_new_edge((NS.N, NS.E, NS.S, NS.W))
second = platform.get_opposite_of_edge(first)
third = choice(platform.get_right_angles_from_edge(first))
self.queue = [first, second, third, platform.get_opposite_of_edge(second),
platform.get_opposite_of_edge(third)]
elif self.level_index == 2:
if self.health.amount > 90:
length = 4
elif self.health.amount > 70:
length = 5
elif self.health.amount > 40:
length = 6
else:
length = 8
while len(self.queue) < length:
while True:
orientation = random.randint(0, 5)
if (not self.queue and orientation != self.last_attack) or \
(len(self.queue) > 0 and orientation != self.queue[-1]):
self.queue.append(orientation)
break
self.unbrandished = copy(self.queue)
self.brandish_complete = False
self.sword.reset()
self.sword.play(self.sword.brandish, play_once=True)
self.get_game().chemtrails.challenge()
self.backgrounds[self.level_index].set_frameset("charging")
# Set each boss to its normal frameset
for sprite in self.boss_sprites:
sprite.set_frameset("normal")
def choose_new_edge(self, edges):
while True:
edge = random.choice(edges)
if edge != self.last_attack:
return edge
def finish_battle(self, win):
self.battle_finished = True
self.halt(self.brandish)
self.halt(self.cancel_flash)
self.halt(self.warning)
self.halt(self.flash_player_damage)
self.halt(self.end_player_damage)
self.sword.reset()
self.queue = []
self.brandish_complete = True
if win:
self.level_sprite().set_frameset("death")
if self.get_configuration("system", "enable-level-select"):
self.get_game().add_time_to_scores(self.time_elapsed, self.level_index)
elif self.level_index == 2:
if not self.get_configuration("system", "enable-level-select"):
self.get_game().add_time_to_scores(self.time_elapsed)
self.backgrounds[self.level_index].set_frameset("shining")
else:
self.level_sprite().set_frameset("normal")
self.play(self.flash_player_damage)
self.get_game().chemtrails.set_frameset("hurt")
# Record a play to the DNF file for analytics
try:
with open(self.get_configuration("system", "dnf-file"), "at") as dnf_file:
dnf_file.write(f"{self.time_elapsed} {self.level_index} {datetime.datetime.isoformat(datetime.datetime.now(), 'T')}\n")
except:
print("Error saving DNF run to file")
self.player_defeated = not win
self.kills += not win
self.play(self.show_end_dialogue, delay=3000, play_once=True)
def show_end_dialogue(self):
dialogue = self.get_game().dialogue
dialogue.activate()
self.get_audio().play_sfx("die")
if self.level_index == 0:
if self.player_defeated:
dialogue.show_text("Maybe next time!")
else:
dialogue.show_text("Hey! Wow! Lizard!")
elif self.level_index == 1:
if self.player_defeated:
dialogue.show_text("Wiped out!")
else:
if self.get_configuration("system", "enable-level-select"):
dialogue.show_text("Ouch! Oof!")
else:
dialogue.show_text("Well done! But it's not over yet!")
elif self.level_index == 2:
if self.player_defeated:
dialogue.show_text("Just like I thought!")
else:
if self.get_configuration("system", "enable-level-select"):
dialogue.show_text("H-how? But you're only a lizard!")
else:
dialogue.show_text("H-how? But you're only a lizard! How could you" +
" manage to defeat all of us?")
if self.player_defeated:
self.countdown.activate()
else:
self.play(self.end_dialogue, delay=5000, play_once=True)
def end_dialogue(self):
self.get_game().dialogue.deactivate()
if not self.battle_finished:
self.combo(self.get_configuration("boss", "first-combo-delay"))
else:
self.get_game().wipe.start(self.transition_after_battle)
def transition_after_battle(self):
"""
Determine whether to reset to title screen, relaunch the current level, launch the next level, or activate the ending object and
call the appropriate method.
"""
# If the player is out of lives, reset the game.
if self.get_game().chemtrails.boys.amount <= 0:
self.get_game().reset(True)
# Check if the ending screen should be activated.
elif not self.player_defeated and self.get_configuration("system", "enable-level-select") or self.level_index == 2:
defeated_level_index = self.level_index
game = self.get_game()
game.boss.reset()
game.chemtrails.reset()
game.platform.reset()
game.ending.activate(defeated_level_index)
else:
# Level index to launch is the current level if the player was defeated, otherwise it's the next level. If the player wasn't
# defeated, level select mode would have launched the game ending, so the next level will only be launched when boss rush
# mode is active.
index = self.level_index + (not self.player_defeated)
self.start_level(index)
def transition_to_title(self):
self.get_game().reset(True)
def damage(self):
pass
def start_player_damage(self):
"""
Launch the flash player damage effect and queue it to end after a certain amount of time
"""
self.play(self.flash_player_damage)
self.play(self.end_player_damage, play_once=True, delay=1500)
def flash_player_damage(self):
"""
Invert the screen to indicate player has taken damage.
"""
background = self.backgrounds[self.level_index]
if background.get_current_frameset().name == "normal":
background.set_frameset("inverted")
else:
background.set_frameset("normal")
def end_player_damage(self):
"""
Halt the flash player damage animation and return the background to normal
"""
self.halt(self.flash_player_damage)
self.backgrounds[self.level_index].set_frameset("normal")
def end_hit_animation(self):
"""
Return boss's animation to normal
"""
if not self.battle_finished:
for boss in (self.kool_man, self.alien, self.spoopy):
boss.set_frameset("normal")
def warning(self):
"""
Use this method as an animation to create a warning flash of the background that flashes according to the
amount of time left in the player's timer object.
"""
time_left = self.get_game().chemtrails.timer.percent()
warning_threshold = self.get_configuration("time", "timer-warning-start")
background = self.backgrounds[self.level_index]
if time_left > warning_threshold:
background.set_frameset("normal")
self.halt(self.warning)
else:
if background.get_current_frameset().name == "normal":
background.set_frameset("warning")
self.play(self.warning, play_once=True, delay=50)
else:
background.set_frameset("normal")
self.play(self.warning, play_once=True, delay=time_left / warning_threshold * 400)
def enter_boss(self):
self.level_sprite().unhide()
self.level_sprite().get_current_frameset().reset()
self.level_sprite().set_frameset("entrance")
def level_sprite(self, level_index=None):
"""
Return the boss sprite associated with this the given level index. If level index is not given, use the value in
`self.level_index`.
@param level_index index of the level of the requested sprite
"""
if level_index is None:
level_index = self.level_index
if level_index == 0:
return self.kool_man
elif level_index == 1:
return self.alien
else:
return self.spoopy
def level_sprite_arm(self, level_index=None):
"""
Return the boss arm sprite associated with the given index. If index is not given, use the value in `self.level_index`.
@param level_index index of the level of the requested sprite
"""
if level_index is None:
level_index = self.level_index
if level_index == 0:
return self.kool_man_arms
elif level_index == 1:
return self.alien_arms
else:
return self.spoopy_arms
def update(self):
"""
Update graphics
"""
if self.active:
self.backgrounds[self.level_index].update()
dialogue = self.get_game().dialogue
# Handle the continue countdown or increase time elapsed if the continue screen
if self.countdown.active and dialogue.active and self.get_game().chemtrails.boys.amount > 0:
if self.advance_prompt.check_first_press():
self.advance_prompt.press_first()
elif self.advance_prompt.check_second_press():
self.countdown.deactivate()
if dialogue.is_playing():
dialogue.show_all()
else:
self.get_game().dialogue.deactivate()
if not self.battle_finished:
self.combo()
else:
self.get_game().wipe.start(self.transition_after_battle)
self.advance_prompt.cancel_first_press()
else:
self.time_elapsed += self.get_game().time_filter.get_last_frame_duration()
Animation.update(self)
# Update boss sprite
boss_sprite = self.level_sprite()
boss_sprite_arm = self.level_sprite_arm()
boss_sprite.update()
if self.brandish_complete:
if self.queue is not None:
# Draw ghosts of the upcoming moves fading more as the move goes futher back in the queue
boss_sprite_arm.unhide()
remaining_positions = list(reversed(self.queue[self.get_game().chemtrails.queue_index:]))
for ii, position in enumerate(remaining_positions):
alpha = int((ii + 1) / len(remaining_positions) * 255)
boss_sprite_arm.set_frameset(str(position))
boss_sprite_arm.get_current_frame().set_alpha(alpha)
boss_sprite_arm.update()
boss_sprite_arm.get_current_frame().set_alpha(255)
else:
boss_sprite_arm.update()
self.sword.update()
self.health.update()
self.countdown.update()
# Trigger the warning effect if time is running out
if self.get_game().chemtrails.life.amount > 0 and not self.is_playing(self.warning, include_delay=True) and \
self.get_game().chemtrails.timer.percent() <= self.get_configuration("time", "timer-warning-start"):
self.play(self.warning, play_once=True)
def update_dialogue(self):
if self.active:
dialogue = self.get_game().dialogue
if dialogue.active:
self.get_game().dialogue.update()
if self.countdown.active and self.get_game().chemtrails.boys.amount > 0:
self.advance_prompt.update()
class Countdown(GameChild):
def __init__(self, parent):
GameChild.__init__(self, parent)
dsr = self.get_display_surface().get_rect()
font = pygame.font.Font(self.get_resource(Dialogue.FONT_PATH), 76)
self.heading = Sprite(self)
self.heading.add_frame(font.render("CONTINUE?", True, (0, 0, 0), (255, 255, 255)).convert_alpha())
self.heading.location.midtop = dsr.centerx, 50
self.game_over = Sprite(self)
self.game_over.add_frame(font.render("GAME OVER", True, (0, 0, 0), (255, 255, 255)).convert_alpha())
self.game_over.location.center = dsr.centerx, dsr.centery - 40
self.glyphs = []
for ii in range(10):
glyph = Sprite(self)
frame = Surface((140, 140))
frame.fill((255, 255, 255))
digits = font.render("%i" % ii, True, (0, 0, 0), (255, 255, 255)).convert_alpha()
rect = digits.get_rect()
rect.center = frame.get_rect().center
frame.blit(digits, rect)
glyph.add_frame(frame)
glyph.location.center = dsr.centerx, dsr.centery - 30
self.glyphs.append(glyph)
def reset(self):
self.deactivate()
def deactivate(self):
self.active = False
def activate(self):
self.remaining = 9999
self.active = True
def end_game(self):
self.get_game().reset(True)
def update(self):
if self.active:
if self.get_game().chemtrails.boys.amount > 0:
self.heading.update()
self.glyphs[int(self.remaining / 1000)].update()
if not self.get_game().wipe.is_playing():
if self.remaining <= 0:
self.get_game().wipe.start(self.end_game)
self.remaining = 0
else:
self.remaining -= self.get_game().time_filter.get_last_frame_duration()
class Sword(Animation):
"""
This class stores the graphics for the swords that appear as hovering sprites when the boss is attacking.
"""
SHIFT = 15
SPRITE_COUNT = 6
def __init__(self, parent):
"""
Allocate and populate lists of sword animation frames. For each boss, create sword and spinning sword animation frames. For each
animation, create six color versions, one for each orientation on the platform.
"""
Animation.__init__(self, parent)
# These will be three dimensional lists: swords[boss][position][frame]
self.swords = []
self.spinning_swords = []
def rotate_sword(base, position):
"""
Rotate the sword based on the orientation of the board in this position
"""
if position == NS.N or position == NS.S:
rotated = rotate(base, 270)
elif position == NS.NW:
rotated = rotate(base, 45)
elif position == NS.NE:
rotated = rotate(base, 310)
else:
rotated = base
return rotated
def fill_sword(surface, position, colors):
"""
Blend the platform colors into the grayscale base image
"""
rect = surface.get_rect()
if position == NS.N or position == NS.S:
surface.fill(colors[0], (0, 0, rect.w / 2, rect.h), BLEND_RGBA_MIN)
surface.fill(colors[1], (rect.centerx, 0, rect.w / 2, rect.h), BLEND_RGBA_MIN)
else:
surface.fill(colors[0], (0, 0, rect.w, rect.h / 2), BLEND_RGBA_MIN)
surface.fill(colors[1], (0, rect.centery, rect.w, rect.h / 2), BLEND_RGBA_MIN)
# Open a folder of sword frames for each boss
for root in "sword/", "sword/", "sword/":
# Create a list of lists of sword frames, each list of sword frames corresponds to an orientation on the platform
self.swords.append([[], [], [], [], [], []])
self.spinning_swords.append([[], [], [], [], [], []])
base_image_paths = sorted(iglob(join(self.get_resource(root), "*.png")))
# If fast load is requested, use a single frame
if self.get_configuration("system", "minimize-load-time"):
base_image_paths = [base_image_paths[0]]
# Create a square surface that can be used to blit the rotated sword centered so each frame is the same size
size = max(*load(self.get_resource(base_image_paths[0])).get_size())
background = pygame.Surface((size, size), pygame.SRCALPHA)
# Create spinning sword effect by rotating the first base frame image, one for each platform position
for position in range(6):
base = rotate_sword(load(self.get_resource(base_image_paths[0])).convert_alpha(), position)
# Blend the appropriate colors into the base image
fill_sword(base, position, self.get_game().platform.get_color_pair_from_edge(position))
# Create a frame for each angle and store it in the list
for angle in range(0, 360, 36):
rotated = rotate(base, angle)
frame = background.copy()
rect = rotated.get_rect()
rect.center = frame.get_rect().center
frame.blit(rotated, rect)
self.spinning_swords[-1][position].append(frame)
# Create frames for each platform orientation by rotating the base frame images and blending colors over them
for frame_index, path in enumerate(base_image_paths):
base = load(self.get_resource(path)).convert_alpha()
# Iterate over all six orientation possibilities
for position in range(6):
rotated = rotate_sword(base, position)
surface = rotated.copy()
colors = self.get_game().platform.get_color_pair_from_edge(position)
color_a = Color(colors[0].r, colors[0].g, colors[0].b, 255)
color_b = Color(colors[1].r, colors[1].g, colors[1].b, 255)
# Edit lightness to create glowing effect
for color in (color_a, color_b):
h, s, l, a = color.hsla
l = 30 + int(abs((frame_index % 10) - 5) / 5 * 60)
color.hsla = h, s, l, a
fill_sword(surface, position, (color_a, color_b))
self.swords[-1][position].append(surface)
self.register(self.brandish, self.lower, self.swab)
def reset(self):
"""
Halt animations, clear sprites
"""
self.halt(self.brandish)
self.halt(self.lower)
self.halt(self.swab)
self.sprites = []
self.active_sprite_index = 0
self.attacking_player = False
def brandish(self):
"""
Get the next sword position to brandish from `self.parent`, create a sprite with a regular rotating frameset and spinning attack
frameset, place it around a rectangle in the center of the screen, and store it in a list.
"""
level_index = self.parent.level_index
position = self.parent.unbrandished.pop(0)
dsr = self.get_display_surface().get_rect()
sprite = Sprite(self)
# Add frames from storage for regular and attacking animations
for frame in self.swords[level_index][position] + self.spinning_swords[level_index][position]:
sprite.add_frame(frame)
sprite.add_frameset(list(range(len(self.swords[level_index][position]), len(sprite.frames))), name="attack")
# Add an explosion effect frameset
sprite.add_frameset(name="explode", switch=True, framerate=70)
surface = pygame.Surface((200, 200), pygame.SRCALPHA)
thickness = 6
color = pygame.Color(0, 0, 0)
for radius in range(6, 100, 4):
frame = surface.copy()
ratio = float(radius - 6) / (100 - 6)
color.hsla = 60 * (1 - ratio), 100, 50, 100
pygame.draw.circle(frame, color, (100, 100), radius, max(1, int(thickness)))
thickness -= .2
sprite.add_frame(frame)
sprite.add_frameset(list(range(0, len(self.swords[level_index][position]))), name="normal", switch=True)
# Place on screen around an invisible rectangle in the center
if position in (NS.W, NS.E):
sprite.location.centery = dsr.centery - 80
if position == NS.W:
sprite.location.centerx = dsr.centerx - 100
else:
sprite.location.centerx = dsr.centerx + 100
elif position in (NS.N, NS.S):
sprite.location.centerx = dsr.centerx
if position == NS.N:
sprite.location.centery = dsr.centery - 150
else:
sprite.location.centery = dsr.centery + 20
else:
sprite.location.center = dsr.centerx, dsr.centery - 80
self.sprites.append(sprite)
self.get_audio().play_sfx("brandish")
# Set brandish to complete on a delay
self.play(self.lower, delay=400, play_once=True)
# Brandish more swords
if len(self.parent.unbrandished) > 0:
self.play(self.brandish, delay=self.get_configuration("time", "sword-delay"), play_once=True)
# Trigger boss's sword swab animation on a delay
self.parent.level_sprite_arm().unhide()
self.parent.level_sprite_arm().set_frameset(str(position))
if len(self.parent.unbrandished) > 0:
self.play(self.swab, delay=self.get_configuration("time", "sword-delay") - 60 * 4, play_once=True, position=position)
def swab(self, position):
"""
Activate boss's sword swab animation
"""
self.parent.level_sprite_arm().set_frameset(f"{position}_{self.parent.unbrandished[0]}")
def lower(self):
"""
Set brandish to complete.
"""
if len(self.parent.unbrandished) == 0:
self.parent.brandish_complete = True
self.parent.backgrounds[self.parent.level_index].set_frameset("normal")
self.parent.level_sprite_arm().hide()
def block(self):
"""
Successfully block a sword move, setting the sprite to attacking and moving the active index.
"""
if self.sprites:
self.attack(player=False)
self.active_sprite_index += 1
def attack(self, player):
"""
Set the currently active sprite to its attacking animation.
@param player boolean that sets whether the attack is toward the player or boss
"""
center_save = self.sprites[self.active_sprite_index].location.center
self.sprites[self.active_sprite_index].set_frameset("attack")
self.sprites[self.active_sprite_index].location.center = center_save
self.attacking_player = player
def active_sprite(self):
"""
Get the sprite that is currently front of the queue (next to get hit)
"""
return self.sprites[self.active_sprite_index]
def update(self):
"""
Draw previously blocked swords and the boss's current move sword.
"""
Animation.update(self)
if self.sprites:
for ii, sprite in enumerate(self.sprites[:self.active_sprite_index + 1]):
if sprite.get_current_frameset().name == "attack" and not sprite.is_hidden():
if self.attacking_player and ii == self.active_sprite_index:
scale = 1.1
end = self.get_game().platform.view.location.center
else:
scale = 0.95
end = self.get_game().boss.alien.location.center
for frame_index in sprite.get_current_frameset().order:
width, height = sprite.frames[frame_index].get_size()
if width < 800 and height < 800:
scaled_width, scaled_height = int(width * scale), int(height * scale)
sprite.frames[frame_index] = pygame.transform.scale(sprite.frames[frame_index], (scaled_width, scaled_height))
if width >= 800 or width < 75 or height >= 800 or height < 75:
if self.attacking_player and ii == self.active_sprite_index:
sprite.hide()
self.get_game().boss.start_player_damage()
self.get_audio().play_sfx("damage", x=sprite.location.centerx)
self.get_game().chemtrails.display_hurt()
else:
center_save = sprite.location.center
sprite.set_frameset("explode")
sprite.location.center = center_save
self.get_audio().play_sfx("explode", x=sprite.location.centerx)
if self.parent.is_playing(self.parent.end_hit_animation, include_delay=True):
self.parent.halt(self.parent.end_hit_animation)
self.parent.level_sprite().set_frameset("hurt")
self.parent.play(self.parent.end_hit_animation, play_once=True, delay=500)
else:
center_save = sprite.location.center
sprite.get_current_frameset().measure_rect()
sprite.update_location_size()
sprite.location.center = center_save
elif sprite.get_current_frameset().name == "explode" and not sprite.is_hidden():
if sprite.get_current_frameset().get_current_id() == sprite.get_current_frameset().order[-1]:
sprite.hide()
sprite.update()
class Health(Meter):
"""
Track the boss's health and display the meter
"""
OFFSET = 4
def __init__(self, parent):
Meter.__init__(self, parent)
dsr = self.get_display_surface().get_rect()
self.background = load(self.get_resource("HUD_boss.png")).convert()
self.rect = self.background.get_rect()
self.rect.midtop = dsr.centerx, self.OFFSET
def setup(self):
level_index = self.get_game().boss.level_index
if level_index == 0:
icon_index = 22
elif level_index == 1:
icon_index = 17
elif level_index == 2:
icon_index = 19
Meter.setup(self, self.background, self.rect, 52, (255, 0, 255), 100, "scrapeIcons/scrapeIcons_%i.png" % icon_index)
def reset(self):
self.setup()
Meter.reset(self)
def decrease(self, damage):
self.change(-damage)
self.parent.damage()
if self.amount <= 0:
self.amount = 0
self.get_audio().play_sfx("complete_pattern_1")
self.get_audio().play_sfx("defeat")
self.get_game().boss.finish_battle(True)
else:
self.parent.play(self.parent.cancel_flash, delay=1000, play_once=True)
class Ending(Animation):
"""
Scene for the end of a successful play session. The Tony and slime bag sprites will be displayed, and Tony will say something
to the player. The player's time will be displayed as a sprite that bounces around the screen.
"""
BOSS_RUSH_TEXT = "Wow! You vanquished all the goons and skated like a pro, slime bag." + \
" You made your\nfather proud today. I love you, child."
def __init__(self, parent):
Animation.__init__(self, parent)
self.slime_bag = Chemtrails(self)
self.tony_avatar = load(self.get_resource("Introduction_tony_avatar.png")).convert()
self.time_font = pygame.font.Font(self.get_resource("rounded-mplus-1m-bold.ttf"), 64)
self.rank_font = pygame.font.Font(self.get_resource("rounded-mplus-1m-bold.ttf"), 26)
self.register(self.start, self.start_wipe)
self.register(self.append_sword, interval=1500)
self.swords = []
def reset(self):
self.deactivate()
self.halt()
self.text_index = 0
self.angle = random.choice((pi / 4, 3 * pi / 4, 5 * pi / 4, 7 * pi / 4))
self.slime_bag.reset()
def deactivate(self):
self.active = False
self.slime_bag.deactivate()
def activate(self, level_index):
self.defeated_level_index = level_index
self.active = True
self.play(self.start, delay=3000, play_once=True)
foreground = get_boxed_surface(
self.time_font.render(str(self.get_game().most_recent_score), False, (180, 150, 20), (255, 255, 255)).convert_alpha(),
background=(255, 255, 255), padding=(38, 0))
if self.rank()[0] % 100 // 10 != 1:
if self.rank()[0] % 10 == 1:
ordinal = "ST"
elif self.rank()[0] % 10 == 2:
ordinal = "ND"
elif self.rank()[0] % 10 == 3:
ordinal = "RD"
else:
ordinal = "TH"
else:
ordinal = "TH"
rank = self.rank_font.render(f"{self.rank()[0]}{ordinal}", False, (180, 150, 20), (255, 255, 255))
rank = pygame.transform.rotate(rank, 90)
rank_rect = rank.get_rect()
rank_rect.midleft = foreground.get_rect().midleft
foreground.blit(rank, rank_rect)
dsr = self.get_display_surface().get_rect()
self.text = RainbowSprite(self, foreground, 180, 200)
self.text.location.midtop = dsr.centerx, 80
self.get_game().tony.set_frameset("static")
dialogue = self.get_game().dialogue
dialogue.activate()
dialogue.set_avatar(self.tony_avatar)
dialogue.set_name("???")
dialogue.show_text("")
self.play(self.start_wipe, delay=self.get_configuration("time", "ending-timeout"), play_once=True)
self.get_audio().play_bgm("end")
self.slime_bag.activate()
self.play(self.append_sword)
def rank(self):
"""
@return the rank of the currently displaying score as a tuple: (rank, total)
"""
rank = 0
level_scores = [score for score in self.get_game().scores if score.level_index == self.defeated_level_index and not score.blank()]
for score in sorted(level_scores):
rank += 1
if score == self.get_game().most_recent_score:
break
return rank, len(level_scores)
def start(self):
dialogue = self.get_game().dialogue
if self.get_configuration("system", "enable-level-select"):
# Create a message for versus mode
if len(self.get_game().level_select.opponents_at_launch) > 1:
# Check if any peers had a faster time
rank = 1
for peer in self.get_game().level_select.opponents_at_launch:
if peer.address != "localhost" and not peer.status == "playing" and peer.result is not None and \
peer.result < self.get_game().most_recent_score.milliseconds:
rank += 1
if rank == 1:
text = (f"Congratulations on winning the battle and getting #{self.rank()[0]} out of {self.rank()[1]} overall!\n"
"Well done, slime bag. ")
else:
total = len(self.get_game().level_select.opponents_at_launch)
text = (f"You were #{rank} out of {total} in the battle, but you vanquished my goon and finished\n"
f"#{self.rank()[0]} out of {self.rank()[1]} overall! Well done, slime bag. ")
# Create a message for single-player mode
else:
text = (f"You vanquished my goon and got the #{self.rank()[0]} rank out of {self.rank()[1]} overall!\n"
"Well done, slime bag.")
if self.defeated_level_index == 2:
dialogue.set_name("Tony")
text += "You made your father proud today. I love you child."
elif self.defeated_level_index == 1:
text += "Give expert mode a try next."
else:
text += "Give advanced mode a try next."
else:
text = self.BOSS_RUSH_TEXT
dialogue.set_name("Tony")
dialogue.show_text(text)
self.text_index = 0
def end_game(self):
self.deactivate()
self.get_game().reset(True)
def start_wipe(self):
self.get_game().wipe.start(self.end_game)
def append_sword(self):
"""
Add a sword to the list based on what button is pressed. Remove swords that are out of view.
"""
edge = self.get_game().platform.get_edge_pressed()
if edge is not None:
sprite = Sprite(self)
# Add frames from Boss->Sword storage
for frame in self.get_game().boss.sword.swords[0][edge]:
sprite.add_frame(frame)
if edge == NS.W:
sprite.location.midleft = self.slime_bag.location.midleft
elif edge == NS.E:
sprite.location.midright = self.slime_bag.location.midright
else:
sprite.location.center = self.slime_bag.location.center
self.swords.append(sprite)
outgoing = []
for sword in self.swords:
if sword.location.bottom < 0:
outgoing.append(sword)
for sword in outgoing:
self.swords.remove(sword)
def update(self):
if self.active:
Animation.update(self)
dialogue = self.get_game().dialogue
wipe = self.get_game().wipe
self.get_game().logo.update()
self.get_game().tony.update()
# Draw swords shot at Tony
for sword in self.swords:
sword.move(0, -5)
sword.update()
self.slime_bag.update(offset=(0, -30))
dsr = self.get_display_surface().get_rect()
# Bounce the time sprite around the screen
if self.text.location.right > dsr.right or self.text.location.left < dsr.left:
self.angle = reflect_angle(self.angle, 0)
if self.text.location.right > dsr.right:
self.text.move(dsr.right - self.text.location.right)
else:
self.text.move(dsr.left - self.text.location.left)
if self.text.location.bottom > self.get_game().dialogue.avatar_box.location.top or self.text.location.top < dsr.top:
self.angle = reflect_angle(self.angle, pi)
if self.text.location.top < dsr.top:
self.text.move(dy=dsr.top - self.text.location.top)
else:
self.text.move(dy=self.get_game().dialogue.avatar_box.location.top - self.text.location.bottom)
dx, dy = get_delta(self.angle, 5, False)
self.text.move(dx, dy)
self.text.update()
self.get_game().dialogue.update()