From 8166f2665aa46beb842202821fdc2cfff4a01954 Mon Sep 17 00:00:00 2001 From: Nguyễn Gia Phong Date: Thu, 5 Aug 2021 15:36:54 +0700 Subject: Retire pkg_resources --- brutalmaze/__init__.py | 2 +- brutalmaze/__main__.py | 435 +++++++++++++++++++++++++++++++++++++++++ brutalmaze/characters.py | 17 +- brutalmaze/constants.py | 23 +-- brutalmaze/game.py | 430 ---------------------------------------- brutalmaze/maze.py | 21 +- brutalmaze/misc.py | 12 +- brutalmaze/soundfx/__init__.py | 1 + brutalmaze/weapons.py | 10 +- pyproject.toml | 4 +- 10 files changed, 471 insertions(+), 484 deletions(-) create mode 100644 brutalmaze/__main__.py delete mode 100644 brutalmaze/game.py create mode 100644 brutalmaze/soundfx/__init__.py diff --git a/brutalmaze/__init__.py b/brutalmaze/__init__.py index ce4296a..1eca2a1 100644 --- a/brutalmaze/__init__.py +++ b/brutalmaze/__init__.py @@ -1,3 +1,3 @@ """Minimalist thrilling shoot 'em up game""" -from .game import __version__ +from .__main__ import __version__ diff --git a/brutalmaze/__main__.py b/brutalmaze/__main__.py new file mode 100644 index 0000000..a9a0e98 --- /dev/null +++ b/brutalmaze/__main__.py @@ -0,0 +1,435 @@ +# Game initialization and main loop +# Copyright (C) 2017-2021 Nguyễn Gia Phong +# +# This file is part of Brutal Maze. +# +# Brutal Maze is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Brutal Maze is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Brutal Maze. If not, see . + +__version__ = '1.0.0' + +import re +from argparse import ArgumentParser, FileType, RawTextHelpFormatter +from configparser import ConfigParser +from contextlib import redirect_stdout +from importlib.resources import open_binary, path, read_text +from io import StringIO +from math import atan2, pi, radians +from os.path import join as pathjoin, pathsep +from socket import SO_REUSEADDR, SOL_SOCKET, socket +from sys import stdout +from threading import Thread + +with redirect_stdout(StringIO()): import pygame +from appdirs import AppDirs +from palace import Context, Device, free, use_context +from pygame import KEYDOWN, MOUSEBUTTONUP, QUIT, VIDEORESIZE +from pygame.time import Clock, get_ticks + +from .constants import HERO_SPEED, MIDDLE, SFX +from .maze import Maze +from .misc import deg, join, play, sign + + +class ConfigReader: + """Object reading and processing INI configuration file for + Brutal Maze. + """ + CONTROL_ALIASES = (('New game', 'new'), ('Toggle pause', 'pause'), + ('Toggle mute', 'mute'), + ('Move left', 'left'), ('Move right', 'right'), + ('Move up', 'up'), ('Move down', 'down'), + ('Long-range attack', 'shot'), + ('Close-range attack', 'slash')) + WEIRD_MOUSE_ERR = '{}: Mouse is not a suitable control' + INVALID_CONTROL_ERR = '{}: {} is not recognized as a valid control key' + + def __init__(self, filenames): + self.config = ConfigParser() + # Default configuration + with path('brutalmaze', 'settings.ini') as settings: + self.config.read(settings) + self.config.read(filenames) + + # Fallback to None when attribute is missing + def __getattr__(self, name): return None + + def parse(self): + """Parse configurations.""" + self.size = (self.config.getint('Graphics', 'Screen width'), + self.config.getint('Graphics', 'Screen height')) + self.max_fps = self.config.getint('Graphics', 'Maximum FPS') + self.muted = self.config.getboolean('Sound', 'Muted') + self.musicvol = self.config.getfloat('Sound', 'Music volume') + self.touch = self.config.getboolean('Control', 'Touch') + self.export_dir = self.config.get('Record', 'Directory') + self.export_rate = self.config.getint('Record', 'Frequency') + self.server = self.config.getboolean('Server', 'Enable') + self.host = self.config.get('Server', 'Host') + self.port = self.config.getint('Server', 'Port') + self.timeout = self.config.getfloat('Server', 'Timeout') + self.headless = self.config.getboolean('Server', 'Headless') + + if self.server: return + 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', 'max_fps', 'muted', 'musicvol', + 'touch', 'export_dir', 'export_rate', 'server', + 'host', 'port', 'timeout', 'headless'): + value = getattr(arguments, option) + if value is not None: setattr(self, option, value) + + +class Game: + """Object handling main loop and IO.""" + def __init__(self, config: ConfigReader): + pygame.init() + self.headless = config.headless and config.server + if not self.headless: + with open_binary('brutalmaze', 'icon.png') as icon: + pygame.display.set_icon(pygame.image.load(icon)) + self.actx = None if self.headless else Context(Device()) + self._mute = config.muted + + if config.server: + self.server = socket() + self.server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) + self.server.bind((config.host, config.port)) + self.server.listen(1) + print('Socket server is listening on {}:{}'.format(config.host, + config.port)) + self.timeout = config.timeout + self.sockinp = 0, 0, -pi * 3 / 4, 0, 0 # freeze and point to NW + else: + self.server = self.sockinp = None + + self.max_fps, self.fps = config.max_fps, config.max_fps + self.musicvol = config.musicvol + self.touch = config.touch + self.key, self.mouse = config.key, config.mouse + self.maze = Maze(config.max_fps, config.size, config.headless, + config.export_dir, 1000 / config.export_rate) + self.hero = self.maze.hero + self.clock, self.paused = Clock(), False + + def __enter__(self): + if self.actx is not None: + use_context(self.actx) + self.actx.listener.position = MIDDLE, -MIDDLE, 0 + self.actx.listener.gain = not self._mute + self._source = play('noise.ogg') + self._source.looping = True + return self + + def __exit__(self, exc_type, exc_value, traceback): + if self.server is not None: self.server.close() + if not self.hero.dead: self.maze.dump_records() + if self.actx is not None: + free(SFX) + self._source.stop() + self.actx.update() + use_context(None) + self.actx.destroy() + self.actx.device.close() + pygame.quit() + + @property + def mute(self): + """Mute state.""" + return getattr(self, '_mute', 1) + + @mute.setter + def mute(self, value): + """Mute state.""" + self._mute = int(bool(value)) + self.actx.listener.gain = not self._mute + + def export_txt(self): + """Export maze data to string.""" + export = self.maze.update_export(forced=True) + return '{} {} {} {}\n{}{}{}{}'.format( + len(export['m']), len(export['e']), len(export['b']), export['s'], + ''.join(row + '\n' for row in export['m']), join(export['h']), + ''.join(map(join, export['e'])), ''.join(map(join, export['b']))) + + def update(self): + """Draw and handle meta events on Pygame window. + + Return False if QUIT event is captured, True otherwise. + """ + events = pygame.event.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['mute']: + self.mute ^= 1 + elif not self.server: + if event.key == self.key['new']: + self.maze.reinit() + elif event.key == self.key['pause'] and not self.hero.dead: + self.paused ^= True + elif event.type == MOUSEBUTTONUP and self.touch: + # We're careless about which mouse button is clicked. + maze = self.maze + if self.hero.dead: + maze.reinit() + else: + x, y = pygame.mouse.get_pos() + maze.destx, maze.desty = maze.get_grid(x, y) + if maze.set_step(maze.isdisplayed): + maze.target = maze.get_target(x, y) + self.hero.firing = not maze.target.retired + if maze.stepx == maze.stepy == 0: + maze.destx = maze.desty = MIDDLE + + # Compare current FPS with the average of the last 10 frames + new_fps = self.clock.get_fps() + if new_fps < self.fps: + self.fps -= 1 + elif self.fps < self.max_fps and not self.paused: + self.fps += 5 + if not self.paused: self.maze.update(self.fps) + if not self.headless: self.maze.draw() + self.clock.tick(self.fps) + self.actx.update() + return True + + def move(self, x=0, y=0): + """Command the hero to move faster in the given direction.""" + maze = self.maze + velocity = maze.distance * HERO_SPEED / self.fps + accel = velocity * HERO_SPEED / self.fps + + if x == y == 0: + maze.set_step() + x, y = maze.stepx, maze.stepy + else: + x, y = -x, -y # or move the maze in the reverse direction + + if maze.next_move > 0 or not x: + maze.vx -= sign(maze.vx) * accel + if abs(maze.vx) < accel * 2: maze.vx = 0.0 + elif x * maze.vx < 0: + maze.vx += x * 2 * accel + else: + maze.vx += x * accel + if abs(maze.vx) > velocity: maze.vx = x * velocity + + if maze.next_move > 0 or not y: + maze.vy -= sign(maze.vy) * accel + if abs(maze.vy) < accel * 2: maze.vy = 0.0 + elif y * maze.vy < 0: + maze.vy += y * 2 * accel + else: + maze.vy += y * accel + if abs(maze.vy) > velocity: maze.vy = y * velocity + + 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): + """Handle remote control though socket server. + + This function is supposed to be run in a Thread. + """ + clock = Clock() + while True: + connection, address = self.server.accept() + connection.settimeout(self.timeout) + time = get_ticks() + print('[{}] Connected to {}:{}'.format(time, *address)) + self.maze.reinit() + while True: + if self.hero.dead: + connection.send('0000000'.encode()) + break + data = self.export_txt().encode() + alpha = deg(self.hero.angle) + connection.send('{:07}'.format(len(data)).encode()) + connection.send(data) + try: + buf = connection.recv(7) + except: # noqa + break # client is closed or timed out + if not buf: break + try: + move, angle, attack = map(int, buf.decode().split()) + except ValueError: # invalid input + break + y, x = (i - 1 for i in divmod(move, 3)) + # Time is the essence. + angle = self.hero.angle if angle == alpha else radians(angle) + self.sockinp = x, y, angle, attack & 1, attack >> 1 + clock.tick(self.fps) + self.sockinp = 0, 0, -pi * 3 / 4, 0, 0 + new_time = get_ticks() + print('[{0}] {3}:{4} scored {1} points in {2}ms'.format( + new_time, self.maze.get_score(), new_time - time, *address)) + connection.close() + if not self.hero.dead: self.maze.lose() + + def touch_control(self): + """Handle touch control.""" + maze, hero = self.maze, self.hero + if maze.target.retired: hero.firing = False + if hero.firing: + x, y = maze.get_pos(maze.target.x, maze.target.y) + else: + x, y = pygame.mouse.get_pos() + hero.update_angle(atan2(y - hero.y, x - hero.x)) + self.move() + + def user_control(self): + """Handle direct control from user's mouse and keyboard.""" + if self.hero.dead: return + keys = pygame.key.get_pressed() + buttons = pygame.mouse.get_pressed() + + right = keys[self.key['right']] - keys[self.key['left']] + down = keys[self.key['down']] - keys[self.key['up']] + x, y = pygame.mouse.get_pos() + angle = atan2(y - self.hero.y, x - self.hero.x) + + try: + firing = keys[self.key['shot']] + except KeyError: + firing = buttons[self.mouse['shot']] + try: + slashing = keys[self.key['slash']] + except KeyError: + slashing = buttons[self.mouse['slash']] + self.control(right, down, angle, firing, slashing) + + +def main(): + """Start game and main loop.""" + # Read configuration file + dirs = AppDirs(appname='brutalmaze', appauthor=False, multipath=True) + parents = dirs.site_config_dir.split(pathsep) + parents.append(dirs.user_config_dir) + filenames = [pathjoin(parent, 'settings.ini') for parent in parents] + config = ConfigReader(filenames) + config.parse() + + # Parse command-line arguments + parser = ArgumentParser(usage='%(prog)s [options]', + formatter_class=RawTextHelpFormatter) + parser.add_argument('-v', '--version', action='version', + version='Brutal Maze {}'.format(__version__)) + parser.add_argument( + '--write-config', nargs='?', const=stdout, type=FileType('w'), + metavar='PATH', dest='defaultcfg', + help='write default config and exit, if PATH not specified use stdout') + parser.add_argument( + '-c', '--config', metavar='PATH', + help='location of the configuration file (fallback: {})'.format( + pathsep.join(filenames))) + parser.add_argument( + '-s', '--size', type=int, nargs=2, metavar=('X', 'Y'), + help='the desired screen size (fallback: {}x{})'.format(*config.size)) + parser.add_argument( + '-f', '--max-fps', type=int, metavar='FPS', + help='the desired maximum FPS (fallback: {})'.format(config.max_fps)) + parser.add_argument( + '--mute', '-m', action='store_true', default=None, dest='muted', + help='mute all sounds (fallback: {})'.format(config.muted)) + parser.add_argument('--unmute', action='store_false', dest='muted', + help='unmute sound') + parser.add_argument( + '--music-volume', type=float, metavar='VOL', dest='musicvol', + help='between 0.0 and 1.0 (fallback: {})'.format(config.musicvol)) + parser.add_argument( + '--touch', action='store_true', default=None, + help='enable touch-friendly control (fallback: {})'.format( + config.touch)) + parser.add_argument('--no-touch', action='store_false', dest='touch', + help='disable touch-friendly control') + parser.add_argument( + '--record-dir', metavar='DIR', dest='export_dir', + help='directory to write game records (fallback: {})'.format( + config.export_dir or '*disabled*')) + parser.add_argument( + '--record-rate', metavar='SPF', dest='export_rate', + help='snapshots of game state per second (fallback: {})'.format( + config.export_rate)) + parser.add_argument( + '--server', action='store_true', default=None, + help='enable server (fallback: {})'.format(config.server)) + parser.add_argument('--no-server', action='store_false', dest='server', + help='disable server') + parser.add_argument( + '--host', help='host to bind server to (fallback: {})'.format( + config.host)) + parser.add_argument( + '--port', type=int, + help='port for server to listen on (fallback: {})'.format(config.port)) + parser.add_argument( + '-t', '--timeout', type=float, + help='socket operations timeout in seconds (fallback: {})'.format( + config.timeout)) + parser.add_argument( + '--head', action='store_false', default=None, dest='headless', + help='run server with graphics and sound (fallback: {})'.format( + not config.headless)) + parser.add_argument('--headless', action='store_true', + help='run server without graphics or sound') + args = parser.parse_args() + if args.defaultcfg is not None: + args.defaultcfg.write(read_text('brutalmaze', 'settings.ini')) + args.defaultcfg.close() + exit() + + # Manipulate config + if args.config: + config.config.read(args.config) + config.parse() + config.read_args(args) + + # Main loop + with Game(config) as game: + if config.server: + socket_thread = Thread(target=game.remote_control) + socket_thread.daemon = True # make it disposable + socket_thread.start() + while game.update(): game.control(*game.sockinp) + elif config.touch: + while game.update(): game.touch_control() + else: + while game.update(): game.user_control() + + +# Allow launching the game via invoking ``python -m brutalmaze'' +if __name__ == '__main__': main() diff --git a/brutalmaze/characters.py b/brutalmaze/characters.py index 1c37be9..12eebb5 100644 --- a/brutalmaze/characters.py +++ b/brutalmaze/characters.py @@ -1,5 +1,5 @@ -# characters.py - module for hero and enemy classes -# Copyright (C) 2017-2020 Nguyễn Gia Phong +# Hero and enemy classes +# Copyright (C) 2017-2021 Nguyễn Gia Phong # # This file is part of Brutal Maze. # @@ -23,10 +23,9 @@ from math import atan2, gcd, pi, sin from random import choice, randrange, shuffle from sys import modules -from .constants import (ADJACENTS, AROUND_HERO, ATTACK_SPEED, EMPTY, - ENEMIES, ENEMY, ENEMY_HP, ENEMY_SPEED, FIRANGE, - HEAL_SPEED, HERO_HP, MIDDLE, MIN_BEAT, SFX_HEART, - SFX_SLASH_HERO, SFX_SPAWN, SQRT2, TANGO, WALL) +from .constants import (ADJACENTS, AROUND_HERO, ATTACK_SPEED, EMPTY, ENEMIES, + ENEMY, ENEMY_HP, ENEMY_SPEED, FIRANGE, HEAL_SPEED, + HERO_HP, MIDDLE, MIN_BEAT, SQRT2, TANGO, WALL) from .misc import fill_aapolygon, play, randsign, regpoly, sign from .weapons import Bullet @@ -84,7 +83,7 @@ class Hero: if self.wound < 0: self.wound = 0.0 self.wounds.append(0.0) if self.next_beat <= 0: - play(SFX_HEART) + play('heart.ogg') self.next_beat = MIN_BEAT*(2 - self.wound/HERO_HP) else: self.next_beat -= 1000 / fps @@ -221,7 +220,7 @@ class Enemy: if self.maze.map[srcx+i//w][srcy+i//u] == WALL: return False self.awake = True self.maze.map[self.x][self.y] = ENEMY - play(SFX_SPAWN, self.x, self.y) + play('spawn.ogg', self.x, self.y) return True def fire(self): @@ -311,7 +310,7 @@ class Enemy: if not self.spin_queue and not self.fire() and not self.move(): self.spin_queue = randsign() * self.spin_speed if not self.maze.hero.dead: - play(SFX_SLASH_HERO, self.x, self.y, self.get_slash()) + play('slash-hero.ogg', self.x, self.y, self.get_slash()) if round(self.spin_queue) != 0: self.angle += sign(self.spin_queue) * pi / 2 / self.spin_speed self.spin_queue -= sign(self.spin_queue) diff --git a/brutalmaze/constants.py b/brutalmaze/constants.py index 396cc2b..69ea94d 100644 --- a/brutalmaze/constants.py +++ b/brutalmaze/constants.py @@ -1,5 +1,5 @@ -# constants.py - module for shared constants -# Copyright (C) 2017-2020 Nguyễn Gia Phong +# Shared constants +# Copyright (C) 2017-2021 Nguyễn Gia Phong # # This file is part of Brutal Maze. # @@ -20,23 +20,8 @@ __doc__ = 'Brutal Maze module for shared constants' from string import ascii_lowercase -import pygame -from pkg_resources import resource_filename as pkg_file - -SETTINGS = pkg_file('brutalmaze', 'settings.ini') -ICON = pygame.image.load(pkg_file('brutalmaze', 'icon.png')) - -SFX_NOISE = pkg_file('brutalmaze', 'soundfx/noise.ogg') -SFX_SPAWN = pkg_file('brutalmaze', 'soundfx/spawn.ogg') -SFX_SLASH_ENEMY = pkg_file('brutalmaze', 'soundfx/slash-enemy.ogg') -SFX_SLASH_HERO = pkg_file('brutalmaze', 'soundfx/slash-hero.ogg') -SFX_SHOT_ENEMY = pkg_file('brutalmaze', 'soundfx/shot-enemy.ogg') -SFX_SHOT_HERO = pkg_file('brutalmaze', 'soundfx/shot-hero.ogg') -SFX_MISSED = pkg_file('brutalmaze', 'soundfx/missed.ogg') -SFX_HEART = pkg_file('brutalmaze', 'soundfx/heart.ogg') -SFX_LOSE = pkg_file('brutalmaze', 'soundfx/lose.ogg') -SFX = (SFX_NOISE, SFX_SPAWN, SFX_SLASH_ENEMY, SFX_SLASH_HERO, - SFX_SHOT_ENEMY, SFX_SHOT_HERO, SFX_MISSED, SFX_HEART, SFX_LOSE) +SFX = ('noise.ogg', 'spawn.ogg', 'missed.ogg', 'heart.ogg', 'lose.ogg', + 'slash-enemy.ogg', 'slash-hero.ogg', 'shot-enemy.ogg', 'shot-hero.ogg') SQRT2 = 2 ** 0.5 INIT_SCORE = 2 diff --git a/brutalmaze/game.py b/brutalmaze/game.py deleted file mode 100644 index 8b11228..0000000 --- a/brutalmaze/game.py +++ /dev/null @@ -1,430 +0,0 @@ -# game.py - main module, starts game and main loop -# Copyright (C) 2017-2020 Nguyễn Gia Phong -# -# This file is part of Brutal Maze. -# -# Brutal Maze is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# Brutal Maze is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with Brutal Maze. If not, see . - -__version__ = '0.9.4' - -import re -from argparse import ArgumentParser, FileType, RawTextHelpFormatter -from configparser import ConfigParser -from contextlib import redirect_stdout -from io import StringIO -from math import atan2, pi, radians -from os.path import join as pathjoin, pathsep -from socket import SO_REUSEADDR, SOL_SOCKET, socket -from sys import stdout -from threading import Thread - -with redirect_stdout(StringIO()): import pygame -from appdirs import AppDirs -from palace import Context, Device, free, use_context -from pygame import KEYDOWN, MOUSEBUTTONUP, QUIT, VIDEORESIZE -from pygame.time import Clock, get_ticks - -from .constants import HERO_SPEED, ICON, MIDDLE, SETTINGS, SFX, SFX_NOISE -from .maze import Maze -from .misc import deg, join, play, sign - - -class ConfigReader: - """Object reading and processing INI configuration file for - Brutal Maze. - """ - CONTROL_ALIASES = (('New game', 'new'), ('Toggle pause', 'pause'), - ('Toggle mute', 'mute'), - ('Move left', 'left'), ('Move right', 'right'), - ('Move up', 'up'), ('Move down', 'down'), - ('Long-range attack', 'shot'), - ('Close-range attack', 'slash')) - WEIRD_MOUSE_ERR = '{}: Mouse is not a suitable control' - INVALID_CONTROL_ERR = '{}: {} is not recognized as a valid control key' - - def __init__(self, filenames): - self.config = ConfigParser() - self.config.read(SETTINGS) # default configuration - self.config.read(filenames) - - # Fallback to None when attribute is missing - def __getattr__(self, name): return None - - def parse(self): - """Parse configurations.""" - self.size = (self.config.getint('Graphics', 'Screen width'), - self.config.getint('Graphics', 'Screen height')) - self.max_fps = self.config.getint('Graphics', 'Maximum FPS') - self.muted = self.config.getboolean('Sound', 'Muted') - self.musicvol = self.config.getfloat('Sound', 'Music volume') - self.touch = self.config.getboolean('Control', 'Touch') - self.export_dir = self.config.get('Record', 'Directory') - self.export_rate = self.config.getint('Record', 'Frequency') - self.server = self.config.getboolean('Server', 'Enable') - self.host = self.config.get('Server', 'Host') - self.port = self.config.getint('Server', 'Port') - self.timeout = self.config.getfloat('Server', 'Timeout') - self.headless = self.config.getboolean('Server', 'Headless') - - if self.server: return - 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', 'max_fps', 'muted', 'musicvol', - 'touch', 'export_dir', 'export_rate', 'server', - 'host', 'port', 'timeout', 'headless'): - value = getattr(arguments, option) - if value is not None: setattr(self, option, value) - - -class Game: - """Object handling main loop and IO.""" - def __init__(self, config: ConfigReader): - pygame.init() - self.headless = config.headless and config.server - if not self.headless: pygame.display.set_icon(ICON) - self.actx = None if self.headless else Context(Device()) - self._mute = config.muted - - if config.server: - self.server = socket() - self.server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) - self.server.bind((config.host, config.port)) - self.server.listen(1) - print('Socket server is listening on {}:{}'.format(config.host, - config.port)) - self.timeout = config.timeout - self.sockinp = 0, 0, -pi * 3 / 4, 0, 0 # freeze and point to NW - else: - self.server = self.sockinp = None - - self.max_fps, self.fps = config.max_fps, config.max_fps - self.musicvol = config.musicvol - self.touch = config.touch - self.key, self.mouse = config.key, config.mouse - self.maze = Maze(config.max_fps, config.size, config.headless, - config.export_dir, 1000 / config.export_rate) - self.hero = self.maze.hero - self.clock, self.paused = Clock(), False - - def __enter__(self): - if self.actx is not None: - use_context(self.actx) - self.actx.listener.position = MIDDLE, -MIDDLE, 0 - self.actx.listener.gain = not self._mute - self._source = play(SFX_NOISE) - self._source.looping = True - return self - - def __exit__(self, exc_type, exc_value, traceback): - if self.server is not None: self.server.close() - if not self.hero.dead: self.maze.dump_records() - if self.actx is not None: - free(SFX) - self._source.stop() - self.actx.update() - use_context(None) - self.actx.destroy() - self.actx.device.close() - pygame.quit() - - @property - def mute(self): - """Mute state.""" - return getattr(self, '_mute', 1) - - @mute.setter - def mute(self, value): - """Mute state.""" - self._mute = int(bool(value)) - self.actx.listener.gain = not self._mute - - def export_txt(self): - """Export maze data to string.""" - export = self.maze.update_export(forced=True) - return '{} {} {} {}\n{}{}{}{}'.format( - len(export['m']), len(export['e']), len(export['b']), export['s'], - ''.join(row + '\n' for row in export['m']), join(export['h']), - ''.join(map(join, export['e'])), ''.join(map(join, export['b']))) - - def update(self): - """Draw and handle meta events on Pygame window. - - Return False if QUIT event is captured, True otherwise. - """ - events = pygame.event.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['mute']: - self.mute ^= 1 - elif not self.server: - if event.key == self.key['new']: - self.maze.reinit() - elif event.key == self.key['pause'] and not self.hero.dead: - self.paused ^= True - elif event.type == MOUSEBUTTONUP and self.touch: - # We're careless about which mouse button is clicked. - maze = self.maze - if self.hero.dead: - maze.reinit() - else: - x, y = pygame.mouse.get_pos() - maze.destx, maze.desty = maze.get_grid(x, y) - if maze.set_step(maze.isdisplayed): - maze.target = maze.get_target(x, y) - self.hero.firing = not maze.target.retired - if maze.stepx == maze.stepy == 0: - maze.destx = maze.desty = MIDDLE - - # Compare current FPS with the average of the last 10 frames - new_fps = self.clock.get_fps() - if new_fps < self.fps: - self.fps -= 1 - elif self.fps < self.max_fps and not self.paused: - self.fps += 5 - if not self.paused: self.maze.update(self.fps) - if not self.headless: self.maze.draw() - self.clock.tick(self.fps) - self.actx.update() - return True - - def move(self, x=0, y=0): - """Command the hero to move faster in the given direction.""" - maze = self.maze - velocity = maze.distance * HERO_SPEED / self.fps - accel = velocity * HERO_SPEED / self.fps - - if x == y == 0: - maze.set_step() - x, y = maze.stepx, maze.stepy - else: - x, y = -x, -y # or move the maze in the reverse direction - - if maze.next_move > 0 or not x: - maze.vx -= sign(maze.vx) * accel - if abs(maze.vx) < accel * 2: maze.vx = 0.0 - elif x * maze.vx < 0: - maze.vx += x * 2 * accel - else: - maze.vx += x * accel - if abs(maze.vx) > velocity: maze.vx = x * velocity - - if maze.next_move > 0 or not y: - maze.vy -= sign(maze.vy) * accel - if abs(maze.vy) < accel * 2: maze.vy = 0.0 - elif y * maze.vy < 0: - maze.vy += y * 2 * accel - else: - maze.vy += y * accel - if abs(maze.vy) > velocity: maze.vy = y * velocity - - 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): - """Handle remote control though socket server. - - This function is supposed to be run in a Thread. - """ - clock = Clock() - while True: - connection, address = self.server.accept() - connection.settimeout(self.timeout) - time = get_ticks() - print('[{}] Connected to {}:{}'.format(time, *address)) - self.maze.reinit() - while True: - if self.hero.dead: - connection.send('0000000'.encode()) - break - data = self.export_txt().encode() - alpha = deg(self.hero.angle) - connection.send('{:07}'.format(len(data)).encode()) - connection.send(data) - try: - buf = connection.recv(7) - except: # noqa - break # client is closed or timed out - if not buf: break - try: - move, angle, attack = map(int, buf.decode().split()) - except ValueError: # invalid input - break - y, x = (i - 1 for i in divmod(move, 3)) - # Time is the essence. - angle = self.hero.angle if angle == alpha else radians(angle) - self.sockinp = x, y, angle, attack & 1, attack >> 1 - clock.tick(self.fps) - self.sockinp = 0, 0, -pi * 3 / 4, 0, 0 - new_time = get_ticks() - print('[{0}] {3}:{4} scored {1} points in {2}ms'.format( - new_time, self.maze.get_score(), new_time - time, *address)) - connection.close() - if not self.hero.dead: self.maze.lose() - - def touch_control(self): - """Handle touch control.""" - maze, hero = self.maze, self.hero - if maze.target.retired: hero.firing = False - if hero.firing: - x, y = maze.get_pos(maze.target.x, maze.target.y) - else: - x, y = pygame.mouse.get_pos() - hero.update_angle(atan2(y - hero.y, x - hero.x)) - self.move() - - def user_control(self): - """Handle direct control from user's mouse and keyboard.""" - if self.hero.dead: return - keys = pygame.key.get_pressed() - buttons = pygame.mouse.get_pressed() - - right = keys[self.key['right']] - keys[self.key['left']] - down = keys[self.key['down']] - keys[self.key['up']] - x, y = pygame.mouse.get_pos() - angle = atan2(y - self.hero.y, x - self.hero.x) - - try: - firing = keys[self.key['shot']] - except KeyError: - firing = buttons[self.mouse['shot']] - try: - slashing = keys[self.key['slash']] - except KeyError: - slashing = buttons[self.mouse['slash']] - self.control(right, down, angle, firing, slashing) - - -def main(): - """Start game and main loop.""" - # Read configuration file - dirs = AppDirs(appname='brutalmaze', appauthor=False, multipath=True) - parents = dirs.site_config_dir.split(pathsep) - parents.append(dirs.user_config_dir) - filenames = [pathjoin(parent, 'settings.ini') for parent in parents] - config = ConfigReader(filenames) - config.parse() - - # Parse command-line arguments - parser = ArgumentParser(usage='%(prog)s [options]', - formatter_class=RawTextHelpFormatter) - parser.add_argument('-v', '--version', action='version', - version='Brutal Maze {}'.format(__version__)) - parser.add_argument( - '--write-config', nargs='?', const=stdout, type=FileType('w'), - metavar='PATH', dest='defaultcfg', - help='write default config and exit, if PATH not specified use stdout') - parser.add_argument( - '-c', '--config', metavar='PATH', - help='location of the configuration file (fallback: {})'.format( - pathsep.join(filenames))) - parser.add_argument( - '-s', '--size', type=int, nargs=2, metavar=('X', 'Y'), - help='the desired screen size (fallback: {}x{})'.format(*config.size)) - parser.add_argument( - '-f', '--max-fps', type=int, metavar='FPS', - help='the desired maximum FPS (fallback: {})'.format(config.max_fps)) - parser.add_argument( - '--mute', '-m', action='store_true', default=None, dest='muted', - help='mute all sounds (fallback: {})'.format(config.muted)) - parser.add_argument('--unmute', action='store_false', dest='muted', - help='unmute sound') - parser.add_argument( - '--music-volume', type=float, metavar='VOL', dest='musicvol', - help='between 0.0 and 1.0 (fallback: {})'.format(config.musicvol)) - parser.add_argument( - '--touch', action='store_true', default=None, - help='enable touch-friendly control (fallback: {})'.format( - config.touch)) - parser.add_argument('--no-touch', action='store_false', dest='touch', - help='disable touch-friendly control') - parser.add_argument( - '--record-dir', metavar='DIR', dest='export_dir', - help='directory to write game records (fallback: {})'.format( - config.export_dir or '*disabled*')) - parser.add_argument( - '--record-rate', metavar='SPF', dest='export_rate', - help='snapshots of game state per second (fallback: {})'.format( - config.export_rate)) - parser.add_argument( - '--server', action='store_true', default=None, - help='enable server (fallback: {})'.format(config.server)) - parser.add_argument('--no-server', action='store_false', dest='server', - help='disable server') - parser.add_argument( - '--host', help='host to bind server to (fallback: {})'.format( - config.host)) - parser.add_argument( - '--port', type=int, - help='port for server to listen on (fallback: {})'.format(config.port)) - parser.add_argument( - '-t', '--timeout', type=float, - help='socket operations timeout in seconds (fallback: {})'.format( - config.timeout)) - parser.add_argument( - '--head', action='store_false', default=None, dest='headless', - help='run server with graphics and sound (fallback: {})'.format( - not config.headless)) - parser.add_argument('--headless', action='store_true', - help='run server without graphics or sound') - args = parser.parse_args() - if args.defaultcfg is not None: - with open(SETTINGS) as settings: args.defaultcfg.write(settings.read()) - args.defaultcfg.close() - exit() - - # Manipulate config - if args.config: - config.config.read(args.config) - config.parse() - config.read_args(args) - - # Main loop - with Game(config) as game: - if config.server: - socket_thread = Thread(target=game.remote_control) - socket_thread.daemon = True # make it disposable - socket_thread.start() - while game.update(): game.control(*game.sockinp) - elif config.touch: - while game.update(): game.touch_control() - else: - while game.update(): game.user_control() - - -# Allow launching the game via invoking ``python -m brutalmaze.game'' -if __name__ == '__main__': main() diff --git a/brutalmaze/maze.py b/brutalmaze/maze.py index d64e77f..a31fb86 100644 --- a/brutalmaze/maze.py +++ b/brutalmaze/maze.py @@ -1,5 +1,5 @@ -# maze.py - module for the maze class -# Copyright (C) 2017-2020 Nguyễn Gia Phong +# Maze class +# Copyright (C) 2017-2021 Nguyễn Gia Phong # # This file is part of Brutal Maze. # @@ -31,9 +31,8 @@ from .constants import (ADJACENTS, ATTACK_SPEED, BG_COLOR, BULLET_LIFETIME, CELL_NODES, CELL_WIDTH, COLORS, EMPTY, ENEMIES, ENEMY, ENEMY_HP, FG_COLOR, HERO, HERO_HP, HERO_SPEED, INIT_SCORE, JSON_SEPARATORS, - MAX_WOUND, MAZE_SIZE, MIDDLE, ROAD_WIDTH, - SFX_LOSE, SFX_MISSED, SFX_SLASH_ENEMY, SFX_SPAWN, - SQRT2, TANGO_VALUES, WALL, WALL_WIDTH) + MAX_WOUND, MAZE_SIZE, MIDDLE, ROAD_WIDTH, SQRT2, + TANGO_VALUES, WALL, WALL_WIDTH) from .misc import around, deg, fill_aapolygon, json_rec, play, regpoly, sign from .weapons import LockOn @@ -97,10 +96,6 @@ class Maze: self.next_move = self.glitch = self.next_slashfx = 0.0 self.slashd = self.hero.R + self.distance/SQRT2 - self.sfx_spawn = SFX_SPAWN - self.sfx_slash = SFX_SLASH_ENEMY - self.sfx_lose = SFX_LOSE - def new_cell(self, x, y): """Draw on the map a newly created cell whose coordinates are given. @@ -291,7 +286,7 @@ class Maze: if d > 0: wound = d * SQRT2 / self.distance if self.next_slashfx <= 0: - play(SFX_SLASH_ENEMY, enemy.x, enemy.y, wound) + play('slash-enemy.ogg', enemy.x, enemy.y, wound) self.next_slashfx = ATTACK_SPEED enemy.hit(wound / self.hero.spin_speed) if enemy.wound >= ENEMY_HP: @@ -321,7 +316,7 @@ class Maze: enemy = new_enemy(self, gridx, gridy) enemy.awake = True self.map[gridx][gridy] = ENEMY - play(SFX_SPAWN, enemy.x, enemy.y) + play('spawn.ogg', enemy.x, enemy.y) enemy.hit(wound) self.enemies.append(enemy) continue @@ -339,7 +334,7 @@ class Maze: if block: self.hero.next_strike = (abs(self.hero.spin_queue/self.fps) + ATTACK_SPEED) - play(SFX_MISSED, gain=wound) + play('missed.ogg', gain=wound) else: self.hit_hero(wound, bullet.color) play(bullet.sfx_hit, gain=wound) @@ -507,7 +502,7 @@ class Maze: self.destx = self.desty = MIDDLE self.stepx = self.stepy = 0 self.vx = self.vy = 0.0 - play(SFX_LOSE) + play('lose.ogg') self.dump_records() def reinit(self): diff --git a/brutalmaze/misc.py b/brutalmaze/misc.py index db1be00..94537db 100644 --- a/brutalmaze/misc.py +++ b/brutalmaze/misc.py @@ -1,5 +1,5 @@ -# misc.py - module for miscellaneous functions -# Copyright (C) 2017-2020 Nguyễn Gia Phong +# Miscellaneous functions +# Copyright (C) 2017-2021 Nguyễn Gia Phong # # This file is part of Brutal Maze. # @@ -19,6 +19,7 @@ __doc__ = 'Brutal Maze module for miscellaneous functions' from datetime import datetime +from importlib.resources import path as resource from itertools import chain from math import cos, degrees, pi, sin from os import path @@ -86,14 +87,15 @@ def json_rec(directory): """Return path to JSON file to be created inside the given directory based on current time local to timezone in ISO 8601 format. """ - return path.join( - directory, '{}.json'.format(datetime.now().isoformat()[:19])) + return path.join(directory, + '{}.json'.format(datetime.now().isoformat()[:19])) def play(sound: str, x: float = MIDDLE, y: float = MIDDLE, gain: float = 1.0) -> Source: """Play a sound at the given position.""" - source = Buffer(sound).play() + with resource('brutalmaze.soundfx', sound) as file: + source = Buffer(str(file)).play() source.spatialize = True source.position = x, -y, 0 source.gain = gain diff --git a/brutalmaze/soundfx/__init__.py b/brutalmaze/soundfx/__init__.py new file mode 100644 index 0000000..ddc4efe --- /dev/null +++ b/brutalmaze/soundfx/__init__.py @@ -0,0 +1 @@ +# This module intentional left blank to aid importlib.resources diff --git a/brutalmaze/weapons.py b/brutalmaze/weapons.py index e42f127..75ccb67 100644 --- a/brutalmaze/weapons.py +++ b/brutalmaze/weapons.py @@ -1,4 +1,4 @@ -# characters.py - module for weapon classes +# Weapon classes # Copyright (C) 2017-2020 Nguyễn Gia Phong # # This file is part of Brutal Maze. @@ -20,8 +20,7 @@ __doc__ = 'Brutal Maze module for weapon classes' from math import cos, sin -from .constants import (BG_COLOR, BULLET_LIFETIME, BULLET_SPEED, - ENEMY_HP, SFX_SHOT_ENEMY, SFX_SHOT_HERO, TANGO) +from .constants import BG_COLOR, BULLET_LIFETIME, BULLET_SPEED, ENEMY_HP, TANGO from .misc import fill_aapolygon, regpoly @@ -41,9 +40,9 @@ class Bullet: self.x, self.y, self.angle, self.color = x, y, angle, color self.fall_time = BULLET_LIFETIME if color == 'Aluminium': - self.sfx_hit = SFX_SHOT_ENEMY + self.sfx_hit = 'shot-enemy.ogg' else: - self.sfx_hit = SFX_SHOT_HERO + self.sfx_hit = 'shot-hero.ogg' def update(self, fps, distance): """Update the bullet.""" @@ -77,6 +76,7 @@ class Bullet: class LockOn: """Lock-on device to assist hero's aiming. + This is used as a mutable object to represent a grid of wall. Attributes: diff --git a/pyproject.toml b/pyproject.toml index 7f80517..05d26cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ module = 'brutalmaze' author = 'Nguyễn Gia Phong' author-email = 'mcsinyx@disroot.org' home-page = 'https://sr.ht/~cnx/brutalmaze' -requires = ['appdirs', 'palace', 'pygame>=2', 'setuptools'] +requires = ['appdirs', 'palace', 'pygame>=2'] description-file = 'README.rst' classifiers = [ 'Development Status :: 5 - Production/Stable', @@ -29,4 +29,4 @@ license = 'AGPLv3+' Documentation = 'https://brutalmaze.rtfd.io' [tool.flit.entrypoints.console_scripts] -brutalmaze = 'brutalmaze.game:main' +brutalmaze = 'brutalmaze.__main__:main' -- cgit 1.4.1