diff --git a/NS.py b/NS.py index 4cdbeed..1f08ca8 100644 --- a/NS.py +++ b/NS.py @@ -10,7 +10,7 @@ # # This is the main file containing all the Pygame code. -import argparse, pathlib, operator, subprocess, sys, os +import argparse, pathlib, operator, subprocess, sys, os, socket, select, time # Auto-detect GPIO library try: @@ -28,7 +28,7 @@ from time import sleep from PIL import Image import pygame -from pygame import Surface, Color, mixer +from pygame import Surface, mixer from pygame.event import clear from pygame.mixer import Sound from pygame.image import load, fromstring @@ -145,6 +145,20 @@ class NS(Game, Animation): 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 + + 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. @@ -194,6 +208,18 @@ class NS(Game, Animation): "cooldown-level-3", "first-combo-delay" ] }, + "network": + { + "int": ["port", "diagnostics-size"], + "bool": "diagnostics", + "path": "diagnostics-font", + "list": "peers" + }, + "pop-up": + { + "int": ["size", "length"], + "bool": "center" + }, "input": { "bool": ["serial", "pi"] @@ -202,11 +228,12 @@ class NS(Game, Animation): { "float": "attract-gif-alpha", "bool": ["effects", "alpha-effect-title", "qr-static"], - "path": "scores-font" + "path": "scores-font", + "int": "scores-alpha" }, "system": { - "bool": ["minimize-load-time", "enable-level-select"], + "bool": ["minimize-load-time", "enable-level-select", "optimize-title-screen"], "int": ["lives-boss-rush-mode", "lives-level-select-mode"] }, "pads": @@ -250,8 +277,7 @@ class NS(Game, Animation): gpio.initialize_gpio() # Launch a separate thread for reading the GPIO (and allowing its custom delays/sleeps). Use the daemon flag to force - # exit when the main thread is killed (by a sigterm from systemctl stop) (?). - self.gpio_kill = False + # 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() @@ -292,12 +318,11 @@ class NS(Game, Animation): 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_kill = False self.serial_data = 0 self.reset_arduino() # Launch a separate thread for reading serial data - self.serial_thread = Thread(target=self.read_serial) + self.serial_thread = Thread(target=self.read_serial, daemon=True) self.serial_thread.start() Animation.__init__(self, self) @@ -340,11 +365,32 @@ class NS(Game, Animation): # 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.reset() - self.pop_up_font = pygame.font.Font(self.get_resource(Dialogue.FONT_PATH), 12) + 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 + self.server = socket.create_server(("", self.get_configuration("network", "port"))) + self.peers = {} + self.player_count = 1 + 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): @@ -357,11 +403,11 @@ class NS(Game, Animation): """ Test all connections of GPIO input pins. """ - while not self.gpio_kill: + while True: self.gpio_data = gpio.activity() def read_serial(self): - while not self.serial_kill: + while True: name = self.get_configuration("input", "arduino-port") try: transmission = self.serial_reader.readline().strip() @@ -416,6 +462,72 @@ class NS(Game, Animation): 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" + elif self.level_select.active and self.level_select.level_index_selected is None: + status = "level select" + elif not self.boss.battle_finished: + status = f"playing {self.level_select.level_index_selected}" + elif self.boss.player_defeated: + status = "lost" + else: + status = f"complete {boss.time_elapse}" + + # Connect and send status message to each peer. If sending fails, pass and wait until the next iteration. + for peer in self.peers.values(): + try: + socket.create_connection((peer.address, peer.port)).send(str.encode(status)) + 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("complete"): + try: + peer.result = float(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: + # Improperly formatted message received + pass + else: + peer.status = message + + def count_players(self): + """ + @return count of peers playing versus against this + """ + count = 1 + for peer in self.peers.values(): + count += peer.versus + return count + def reset(self, leave_wipe_running=False): self.idle_elapsed = 0 self.suppressing_input = False @@ -468,20 +580,29 @@ class NS(Game, Animation): 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): + 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 """ - self.pop_up_text += f"{text}\n" + 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) @@ -548,28 +669,58 @@ class NS(Game, Animation): self.chemtrails.update() self.boss.update_dialogue() self.wipe.update() - # Draw the pop up text line by line if there is any - pop_up_y = 0 - for line in self.pop_up_text.split("\n"): - if line: - surface = self.pop_up_font.render(line, False, (0, 0, 0), (255, 255, 255)) - self.get_display_surface().blit(surface, (0, pop_up_y)) - pop_up_y += surface.get_height() + + # 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) + 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)) + 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 + 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"): - - # Kill serial threads - self.serial_kill = True - self.gpio_kill = True - # 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: @@ -645,6 +796,7 @@ class LevelSelect(Animation): 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.active = True @@ -728,7 +880,8 @@ class LevelSelect(Animation): 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 + 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 @@ -1093,11 +1246,10 @@ 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. - Notes - ----- + Title.draw_scores is a slow method, so the scores should only be drawn when a score is added. - * It should be directed to draw scores when a new score is added (and only then), and it will store the score surfaces and only blit - them once when activated (unless the scores are updated). This way it only blits that section of the screen once. + 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 @@ -1110,6 +1262,7 @@ class Title(Animation): @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 @@ -1145,14 +1298,17 @@ class Title(Animation): self.get_game().tony.set_frameset("static") self.get_audio().play_bgm("title") - # Blit the scores - for sprite in self.score_sprites: - sprite.update() + # Optimization for only drawing part of the title screen + if self.get_configuration("system", "optimize-title-screen"): - # 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())) + # 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 @@ -1238,9 +1394,9 @@ class Title(Animation): 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. + 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 @@ -1290,6 +1446,10 @@ class Title(Animation): 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): @@ -1330,6 +1490,11 @@ class Title(Animation): 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) @@ -2340,6 +2505,7 @@ class Boss(Animation): 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 = [] @@ -2865,7 +3031,8 @@ class Boss(Animation): 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`. + 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 """ diff --git a/config b/config index 6792f43..828b75d 100644 --- a/config +++ b/config @@ -30,6 +30,7 @@ attract-gif-alpha = 1.0 effects = True alpha-effect-title = True scores-font = BPmono.ttf +scores-alpha = 230 qr-static = False [system] @@ -38,6 +39,23 @@ minimize-load-time = no enable-level-select = yes lives-boss-rush-mode = 3 lives-level-select-mode = 1 +optimize-title-screen = no + +[network] +peers = 192.168.50.50, 192.168.50.77 +port = 8080 +delimeter = | +timeout = 10.0 +diagnostics = no +diagnostics-font = resource/BPmono.ttf +diagnostics-size = 15 + +[pop-up] +size = 22 +length = 3000 +foreground = #ffffff +background = #000000 +center = yes [boss] damage-per-hit-level-1 = 6.0 diff --git a/resource/scores b/resource/scores index e69de29..b3c51e7 100644 --- a/resource/scores +++ b/resource/scores @@ -0,0 +1 @@ +40143 0