diff --git a/brutalmaze/characters.py b/brutalmaze/characters.py index 6201569..7aab40f 100644 --- a/brutalmaze/characters.py +++ b/brutalmaze/characters.py @@ -86,11 +86,12 @@ class Hero: if abs(self.spin_queue) > 0.5: self.angle += sign(self.spin_queue) * pi / 2 / self.spin_speed self.spin_queue -= sign(self.spin_queue) - else: - # Follow the mouse cursor - x, y = pygame.mouse.get_pos() - self.angle = atan2(y - self.y, x - self.x) + + def update_angle(self, angle): + """Turn to the given angle if the hero is not busy slashing.""" + if abs(self.spin_queue) <= 0.5: self.spin_queue = 0.0 + self.angle = angle def draw(self): """Draw the hero.""" @@ -238,13 +239,18 @@ class Enemy: x, y = self.get_pos() return atan2(y - self.maze.y, x - self.maze.x) + + def get_color(self): + """Return current color of the enemy.""" + return TANGO[self.color][int(self.wound)] if self.awake else FG_COLOR + + def draw(self): """Draw the enemy.""" if get_ticks() < self.maze.next_move and not self.awake: return radious = self.maze.distance/SQRT2 - self.awake*2 square = regpoly(4, radious, self.angle, *self.get_pos()) - color = TANGO[self.color][int(self.wound)] if self.awake else FG_COLOR - fill_aapolygon(self.maze.surface, square, color) + fill_aapolygon(self.maze.surface, square, self.get_color()) def update(self): """Update the enemy.""" diff --git a/brutalmaze/constants.py b/brutalmaze/constants.py index 3bd34b1..c64afa5 100644 --- a/brutalmaze/constants.py +++ b/brutalmaze/constants.py @@ -19,6 +19,8 @@ __doc__ = 'brutalmaze module for shared constants' +from string import ascii_lowercase + from pkg_resources import resource_filename as pkg_file import pygame from pygame.mixer import Sound @@ -69,6 +71,9 @@ TANGO = {'Butter': ((252, 233, 79), (237, 212, 0), (196, 160, 0)), (136, 138, 133), (85, 87, 83), (46, 52, 54))} ENEMIES = ['Butter', 'Orange', 'Chocolate', 'Chameleon', 'SkyBlue', 'Plum', 'ScarletRed'] +COLOR_CODE = ascii_lowercase + '0' +COLORS = {c: COLOR_CODE[i] for i, c in enumerate( + color for code in ENEMIES + ['Aluminium'] for color in TANGO[code])} MINW, MAXW = 24, 36 ENEMY_HP = 3 HERO_HP = 5 diff --git a/brutalmaze/main.py b/brutalmaze/main.py index 39dca3b..2300658 100644 --- a/brutalmaze/main.py +++ b/brutalmaze/main.py @@ -26,7 +26,10 @@ try: # Python 3 from configparser import ConfigParser except ImportError: # Python 2 from ConfigParser import ConfigParser +from itertools import repeat +from math import atan2, degrees from os.path import join, pathsep +from socket import socket from sys import stdout @@ -35,9 +38,9 @@ from pygame import DOUBLEBUF, KEYDOWN, OPENGL, QUIT, RESIZABLE, VIDEORESIZE from pygame.time import Clock, get_ticks from appdirs import AppDirs -from .constants import SETTINGS, ICON, MUSIC, HERO_SPEED +from .constants import SETTINGS, ICON, MUSIC, HERO_SPEED, COLORS, WALL from .maze import Maze -from .misc import sign +from .misc import round2, sign class ConfigReader: @@ -58,8 +61,33 @@ class ConfigReader: self.config.read(SETTINGS) # default configuration self.config.read(filenames) - def parse_output(self): - """Parse graphics and sound configurations.""" + # Fallback to None when attribute is missing + def __getattr__(self, name): return None + + def parse(self): + """Parse configurations.""" + self.server = self.config.getboolean('Server', 'Enable') + if self.server: + self.host = self.config.get('Server', 'Host') + self.port = self.config.getint('Server', 'Port') + self.headless = self.config.getboolean('Server', 'Headless') + else: + self.key, self.mouse = {}, {} + for cmd, alias in self.CONTROL_ALIASES: + i = self.config.get('Control', cmd) + if re.match('mouse[1-3]$', i.lower()): + if alias not in ('shot', 'slash'): + raise ValueError(self.WEIRD_MOUSE_ERR.format(cmd)) + self.mouse[alias] = int(i[-1]) - 1 + continue + if len(i) == 1: + self.key[alias] = ord(i.lower()) + continue + try: + self.key[alias] = getattr(pygame, 'K_{}'.format(i.upper())) + except AttributeError: + raise ValueError(self.INVALID_CONTROL_ERR.format(cmd, i)) + self.size = (self.config.getint('Graphics', 'Screen width'), self.config.getint('Graphics', 'Screen height')) self.opengl = self.config.getboolean('Graphics', 'OpenGL') @@ -67,24 +95,6 @@ class ConfigReader: self.muted = self.config.getboolean('Sound', 'Muted') self.musicvol = self.config.getfloat('Sound', 'Music volume') - def parse_control(self): - """Parse control configurations.""" - self.key, self.mouse = {}, {} - for cmd, alias in self.CONTROL_ALIASES: - i = self.config.get('Control', cmd) - if re.match('mouse[1-3]$', i.lower()): - if alias not in ('shot', 'slash'): - raise ValueError(self.WEIRD_MOUSE_ERR.format(cmd)) - self.mouse[alias] = int(i[-1]) - 1 - continue - if len(i) == 1: - self.key[alias] = ord(i.lower()) - continue - try: - self.key[alias] = getattr(pygame, 'K_{}'.format(i.upper())) - except AttributeError: - raise ValueError(self.INVALID_CONTROL_ERR.format(cmd, i)) - def read_args(self, arguments): """Read and parse a ArgumentParser.Namespace.""" for option in 'size', 'opengl', 'max_fps', 'muted', 'musicvol': @@ -94,29 +104,108 @@ class ConfigReader: class Game: """Object handling main loop and IO.""" - def __init__(self, size, scrtype, max_fps, muted, musicvol, key, mouse): + def __init__(self, config): pygame.mixer.pre_init(frequency=44100) pygame.init() - if muted: + if config.muted or config.headless: pygame.mixer.quit() else: pygame.mixer.music.load(MUSIC) - pygame.mixer.music.set_volume(musicvol) + pygame.mixer.music.set_volume(config.musicvol) pygame.mixer.music.play(-1) pygame.display.set_icon(ICON) - pygame.fastevent.init() + + if config.server: + self.socket = socket() + self.socket.bind((config.host, config.port)) + self.socket.listen() + else: + pygame.fastevent.init() + + self.server, self.headless = config.server, config.headless # self.fps is a float to make sure floordiv won't be used in Python 2 - self.max_fps, self.fps = max_fps, float(max_fps) - self.musicvol = musicvol - self.key, self.mouse = key, mouse - self.maze = Maze(max_fps, size, scrtype) + self.max_fps, self.fps = config.max_fps, float(config.max_fps) + self.musicvol = config.musicvol + self.key, self.mouse = config.key, config.mouse + scrtype = (config.opengl and DOUBLEBUF|OPENGL) | RESIZABLE + self.maze = Maze(config.max_fps, config.size, scrtype, config.headless) self.hero = self.maze.hero self.clock, self.paused = Clock(), False def __enter__(self): return self + def expos(self, x, y): + """Return position of the given coordinates in rounded percent.""" + cx = (x+self.maze.x-self.maze.centerx) / self.maze.distance * 100 + cy = (y+self.maze.y-self.maze.centery) / self.maze.distance * 100 + return round2(cx), round2(cy) + + def export(self): + """Export maze data to a bytes object.""" + maze, hero, tick, ne = self.maze, self.hero, get_ticks(), 0 + walls = [[1 if maze.map[x][y] == WALL else 0 for x in maze.rangex] + for y in maze.rangey] + + x, y = self.expos(maze.x, maze.y) + lines = deque(['{} {} {:.0f} {:d} {:d} {:d}'.format( + x, y, hero.wound * 100, hero.next_strike <= tick, + hero.next_heal <= tick, maze.next_move <= tick)]) + + for enemy in maze.enemies: + if not enemy.awake: + walls[enemy.y-maze.rangey[0]][enemy.x-maze.rangex[0]] = WALL + continue + elif enemy.color == 'Chameleon' and maze.next_move <= tick: + continue + x, y = self.expos(*enemy.get_pos()) + lines.append('{} {} {} {:.0f}'.format(COLORS[enemy.get_color()], + x, y, degrees(enemy.angle))) + ne += 1 + + if maze.next_move <= tick: + rows = (''.join(str(cell) for cell in row) for row in walls) + else: + rows = repeat('0' * len(maze.rangex), len(maze.rangey)) + lines.appendleft('\n'.join(rows)) + + for bullet in maze.bullets: + x, y = self.expos(bullet.x, bullet.y) + lines.append('{} {} {} {:.0f}'.format(COLORS[bullet.get_color()], + x, y, degrees(bullet.angle))) + + lines.appendleft('{} {} {}'.format(len(walls), ne, len(maze.bullets))) + return '\n'.join(lines).encode() + + def meta(self): + """Handle meta events on Pygame window. + + Return False if QUIT event is captured, True otherwise. + """ + events = pygame.fastevent.get() + for event in events: + if event.type == QUIT: + return False + elif event.type == VIDEORESIZE: + self.maze.resize((event.w, event.h)) + elif event.type == KEYDOWN and not self.server: + if event.key == self.key['new']: + self.maze.__init__(self.fps) + elif event.key == self.key['pause'] and not self.hero.dead: + self.paused ^= True + elif event.key == self.key['mute']: + if pygame.mixer.get_init() is None: + pygame.mixer.init(frequency=44100) + pygame.mixer.music.load(MUSIC) + pygame.mixer.music.set_volume(self.musicvol) + pygame.mixer.music.play(-1) + else: + pygame.mixer.quit() + if not self.headless: self.maze.draw() + return True + def move(self, x, y): """Command the hero to move faster in the given direction.""" + x, y = -x, -y # or move the maze in the reverse direction stunned = pygame.time.get_ticks() < self.maze.next_move velocity = self.maze.distance * HERO_SPEED / self.fps accel = velocity * HERO_SPEED / self.fps @@ -139,42 +228,51 @@ class Game: self.maze.vy += y * accel if abs(self.maze.vy) > velocity: self.maze.vy = y * velocity - def loop(self): - """Start and handle main loop.""" - events = pygame.fastevent.get() - for event in events: - if event.type == QUIT: - return False - elif event.type == VIDEORESIZE: - self.maze.resize((event.w, event.h)) - elif event.type == KEYDOWN: - if event.key == self.key['new']: - self.maze.__init__(self.fps) - elif event.key == self.key['pause'] and not self.hero.dead: - self.paused ^= True - elif event.key == self.key['mute']: - if pygame.mixer.get_init() is None: - pygame.mixer.init(frequency=44100) - pygame.mixer.music.load(MUSIC) - pygame.mixer.music.set_volume(self.musicvol) - pygame.mixer.music.play(-1) - else: - pygame.mixer.quit() + def control(self, x, y, angle, firing, slashing): + """Control how the hero move and attack.""" + self.move(x, y) + self.hero.update_angle(angle) + self.hero.firing = firing + self.hero.slashing = slashing + def remote_control(self, connection): + """Handle remote control though socket server. + + Return False if client disconnect, True otherwise. + """ + data = self.export() + connection.send('{:06}'.format(len(data))) + connection.send(data) + buf = connection.recv(8) + if not buf: return False + x, y, angle, attack = (int(i) for i in buf.decode().split()) + self.control(x, y, angle, attack & 1, attack >> 1) + + def user_control(self): + """Handle direct control from user's mouse and keyboard.""" if not self.hero.dead: keys = pygame.key.get_pressed() - self.move(keys[self.key['left']] - keys[self.key['right']], - keys[self.key['up']] - keys[self.key['down']]) + right = keys[self.key['right']] - keys[self.key['left']] + down = keys[self.key['down']] - keys[self.key['up']] + + # Follow the mouse cursor + x, y = pygame.mouse.get_pos() + angle = atan2(y - self.hero.y, x - self.hero.x) + buttons = pygame.mouse.get_pressed() try: - self.hero.firing = keys[self.key['shot']] + firing = keys[self.key['shot']] except KeyError: - self.hero.firing = buttons[self.mouse['shot']] + firing = buttons[self.mouse['shot']] try: - self.hero.slashing = keys[self.key['slash']] + slashing = keys[self.key['slash']] except KeyError: - self.hero.slashing = buttons[self.mouse['slash']] + slashing = buttons[self.mouse['slash']] + self.control(right, down, angle, firing, slashing) + + def update(self): + """Update fps and the maze.""" # Compare current FPS with the average of the last 10 frames new_fps = self.clock.get_fps() if new_fps < self.fps: @@ -183,7 +281,6 @@ class Game: self.fps += 5 if not self.paused: self.maze.update(self.fps) self.clock.tick(self.fps) - return True def __exit__(self, exc_type, exc_value, traceback): pygame.quit() @@ -196,7 +293,7 @@ def main(): parents.append(dirs.user_config_dir) filenames = [join(parent, 'settings.ini') for parent in parents] config = ConfigReader(filenames) - config.parse_output() + config.parse() # Parse command-line arguments parser = ArgumentParser(formatter_class=RawTextHelpFormatter) @@ -238,11 +335,15 @@ def main(): # Manipulate config if args.config: config.config.read(args.config) config.read_args(args) - config.parse_output() - config.parse_control() + config.parse() # Main loop - scrtype = (config.opengl and DOUBLEBUF|OPENGL) | RESIZABLE - with Game(config.size, scrtype, config.max_fps, config.muted, - config.musicvol, config.key, config.mouse) as game: - while game.loop(): pass + with Game(config) as game: + if config.server: + while game.meta(): + game.remote_control() + game.update() + else: + while game.meta(): + game.user_control() + game.update() diff --git a/brutalmaze/maze.py b/brutalmaze/maze.py index 66c4c81..f94af16 100644 --- a/brutalmaze/maze.py +++ b/brutalmaze/maze.py @@ -63,7 +63,7 @@ class Maze: distance (float): distance between centers of grids (in px) x, y (int): coordinates of the center of the hero (in px) centerx, centery (float): center grid's center's coordinates (in px) - rangex, rangey (range): range of the index of the grids on display + rangex, rangey (list): range of the index of the grids on display score (float): current score map (deque of deque): map of grids representing objects on the maze vx, vy (float): velocity of the maze movement (in pixels per frame) @@ -78,21 +78,22 @@ class Maze: sfx_slash (pygame.mixer.Sound): sound effect of slashed enemy sfx_lose (pygame.mixer.Sound): sound effect to be played when you lose """ - def __init__(self, fps, size=None, scrtype=None): + def __init__(self, fps, size=None, scrtype=None, headless=False): self.fps = fps - if size is not None: - self.w, self.h = size - else: - size = self.w, self.h - if scrtype is not None: self.scrtype = scrtype + if not headless: + if size is not None: + self.w, self.h = size + else: + size = self.w, self.h + if scrtype is not None: self.scrtype = scrtype + self.surface = pygame.display.set_mode(size, self.scrtype) - self.surface = pygame.display.set_mode(size, self.scrtype) self.distance = (self.w * self.h / 416) ** 0.5 self.x, self.y = self.w // 2, self.h // 2 self.centerx, self.centery = self.w / 2.0, self.h / 2.0 w, h = (int(i/self.distance/2 + 2) for i in size) - self.rangex = range(MIDDLE - w, MIDDLE + w + 1) - self.rangey = range(MIDDLE - h, MIDDLE + h + 1) + self.rangex = list(range(MIDDLE - w, MIDDLE + w + 1)) + self.rangey = list(range(MIDDLE - h, MIDDLE + h + 1)) self.score = INIT_SCORE self.map = deque() @@ -146,7 +147,7 @@ class Maze: fill_aapolygon(self.surface, square, FG_COLOR) for enemy in self.enemies: enemy.draw() - self.hero.draw() + if not self.hero.dead: self.hero.draw() bullet_radius = self.distance / 4 for bullet in self.bullets: bullet.draw(bullet_radius) pygame.display.flip() @@ -320,7 +321,6 @@ class Maze: self.hero.update(fps) self.slash() self.track_bullets() - self.draw() def resize(self, size): """Resize the maze.""" @@ -349,3 +349,4 @@ class Maze: self.hero.slashing = self.hero.firing = False self.vx = self.vy = 0.0 play(self.sfx_lose) + print('Game over. Your score: {}'.format(int(self.score - INIT_SCORE))) diff --git a/brutalmaze/settings.ini b/brutalmaze/settings.ini index 2baeedd..67357a0 100644 --- a/brutalmaze/settings.ini +++ b/brutalmaze/settings.ini @@ -1,3 +1,11 @@ +[Server] +# Enabling remote control will disable control via keyboard and mouse. +Enable: no +Host: localhost +Port: 8089 +# Disable graphics and sounds (only if socket server is enabled). +Headless: no + [Graphics] Screen width: 640 Screen height: 480 diff --git a/brutalmaze/weapons.py b/brutalmaze/weapons.py index 3979d30..be5dd57 100644 --- a/brutalmaze/weapons.py +++ b/brutalmaze/weapons.py @@ -55,14 +55,18 @@ class Bullet: self.x += s * cos(self.angle) self.y += s * sin(self.angle) + def get_color(self): + """Return current color of the enemy.""" + value = int((1-(self.fall_time-get_ticks())/BULLET_LIFETIME)*ENEMY_HP) + try: + return TANGO[self.color][value] + except IndexError: + return BG_COLOR + def draw(self, radius): """Draw the bullet.""" pentagon = regpoly(5, radius, self.angle, self.x, self.y) - value = int((1-(self.fall_time-get_ticks())/BULLET_LIFETIME)*ENEMY_HP) - try: - fill_aapolygon(self.surface, pentagon, TANGO[self.color][value]) - except IndexError: - pass + fill_aapolygon(self.surface, pentagon, self.get_color()) def place(self, x, y): """Move the bullet by (x, y) (in pixels)."""