From 377dda3db05e357fab3353ab4cfb25d318c4f73d Mon Sep 17 00:00:00 2001 From: Nguyễn Gia Phong Date: Sun, 7 Oct 2018 21:59:39 +0700 Subject: Implement touch-friendly control --- brutalmaze/characters.py | 40 ++++++++++++++----- brutalmaze/game.py | 100 ++++++++++++++++++++++++++++------------------- brutalmaze/maze.py | 45 +++++++++++++++++---- brutalmaze/settings.ini | 6 +-- brutalmaze/weapons.py | 19 +++++++++ setup.py | 2 +- 6 files changed, 151 insertions(+), 61 deletions(-) diff --git a/brutalmaze/characters.py b/brutalmaze/characters.py index 51a1eb7..ddbcc14 100644 --- a/brutalmaze/characters.py +++ b/brutalmaze/characters.py @@ -45,9 +45,9 @@ class Hero: next_beat (float): time until next heart beat (in ms) next_strike (float): time until the hero can do the next attack (in ms) highness (float): likelihood that the hero shoots toward other angles - slashing (bool): flag indicates if the hero is doing close-range attack - firing (bool): flag indicates if the hero is doing long-range attack - dead (bool): flag indicates if the hero is dead + slashing (bool): flag indicating if the hero is doing close-range attack + firing (bool): flag indicating if the hero is doing long-range attack + dead (bool): flag indicating if the hero is dead spin_speed (float): speed of spinning (in frames per slash) spin_queue (float): frames left to finish spinning wound (float): amount of wound @@ -159,7 +159,7 @@ class Enemy: x, y (int): coordinates of the center of the enemy (in grids) angle (float): angle of the direction the enemy pointing (in radians) color (str): enemy's color name - awake (bool): flag indicates if the enemy is active + awake (bool): flag indicating if the enemy is active next_strike (float): time until the enemy's next action (in ms) move_speed (float): speed of movement (in frames per grid) offsetx, offsety (integer): steps moved from the center of the grid @@ -290,8 +290,14 @@ class Enemy: """Return current color of the enemy.""" return TANGO[self.color][int(self.wound)] - def isunnoticeable(self): - """Return whether the enemy can be noticed.""" + def isunnoticeable(self, x=None, y=None): + """Return whether the enemy can be noticed. + + Only search within column x and row y if these coordinates + are provided. + """ + if x is not None and self.x != x: return True + if y is not None and self.y != y: return True return not self.awake or self.wound >= ENEMY_HP def draw(self): @@ -321,6 +327,18 @@ class Enemy: """Handle the enemy when it's attacked.""" self.wound += wound + @property + def retired(self): + """Provide compatibility with LockOn object.""" + try: + return self._retired + except AttributeError: + return self.wound >= ENEMY_HP + + @retired.setter + def retired(self, value): + self._retired = value + def die(self): """Handle the enemy's death.""" if self.awake: @@ -346,9 +364,13 @@ class Chameleon(Enemy): if Enemy.wake(self) is True: self.visible = 1000.0 / ENEMY_SPEED - def isunnoticeable(self): - """Return whether the enemy can be noticed.""" - return (Enemy.isunnoticeable(self) + def isunnoticeable(self, x=None, y=None): + """Return whether the enemy can be noticed. + + Only search within column x and row y if these coordinates + are provided. + """ + return (Enemy.isunnoticeable(self, x, y) or self.visible <= 0 and not self.spin_queue and self.maze.next_move <= 0) diff --git a/brutalmaze/game.py b/brutalmaze/game.py index 189c6bc..ca99a14 100644 --- a/brutalmaze/game.py +++ b/brutalmaze/game.py @@ -17,7 +17,7 @@ # You should have received a copy of the GNU Affero General Public License # along with Brutal Maze. If not, see . -__version__ = '0.8.21' +__version__ = '0.8.22' import re from argparse import ArgumentParser, FileType, RawTextHelpFormatter @@ -32,7 +32,7 @@ from sys import stdout from threading import Thread import pygame -from pygame import KEYDOWN, QUIT, VIDEORESIZE +from pygame import KEYDOWN, MOUSEBUTTONUP, QUIT, VIDEORESIZE from pygame.time import Clock, get_ticks from appdirs import AppDirs @@ -49,7 +49,6 @@ class ConfigReader: ('Toggle mute', 'mute'), ('Move left', 'left'), ('Move right', 'right'), ('Move up', 'up'), ('Move down', 'down'), - ('Auto move', 'autove'), ('Long-range attack', 'shot'), ('Close-range attack', 'slash')) WEIRD_MOUSE_ERR = '{}: Mouse is not a suitable control' @@ -71,6 +70,7 @@ class ConfigReader: self.muted = self.config.getboolean('Sound', 'Muted') self.musicvol = self.config.getfloat('Sound', 'Music volume') self.space = self.config.getboolean('Sound', 'Space theme') + 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') @@ -84,7 +84,7 @@ class ConfigReader: 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 ('autove', 'shot', 'slash'): + if alias not in ('shot', 'slash'): raise ValueError(self.WEIRD_MOUSE_ERR.format(cmd)) self.mouse[alias] = int(i[-1]) - 1 continue @@ -98,9 +98,9 @@ class ConfigReader: def read_args(self, arguments): """Read and parse a ArgumentParser.Namespace.""" - for option in ( - 'size', 'max_fps', 'muted', 'musicvol', 'space', 'export_dir', - 'export_rate', 'server', 'host', 'port', 'timeout', 'headless'): + for option in ('size', 'max_fps', 'muted', 'musicvol', 'space', + 'touch', 'export_dir', 'export_rate', 'server', + 'host', 'port', 'timeout', 'headless'): value = getattr(arguments, option) if value is not None: setattr(self, option, value) @@ -134,6 +134,7 @@ class Game: # self.fps is a float to make sure floordiv won't be used in Python 2 self.max_fps, self.fps = config.max_fps, float(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.0 / config.export_rate) @@ -175,6 +176,19 @@ class Game: 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() @@ -187,7 +201,7 @@ class Game: self.clock.tick(self.fps) return True - def move(self, x, y): + 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 @@ -265,41 +279,37 @@ class Game: 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 not self.hero.dead: - keys = pygame.key.get_pressed() - right = keys[self.key['right']] - keys[self.key['left']] - down = keys[self.key['down']] - keys[self.key['up']] - - buttons = pygame.mouse.get_pressed() - try: - autove = keys[self.key['autove']] - except KeyError: - autove = buttons[self.mouse['autove']] - 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']] - - # Follow the mouse cursor - x, y = pygame.mouse.get_pos() - maze = self.maze - if right or down: - maze.destx = maze.desty = MIDDLE - maze.stepx = maze.stepy = 0 - elif autove: - maze.destx, maze.desty = maze.get_grid(x, y) - maze.set_step(maze.is_displayed) - if maze.stepx == maze.stepy == 0: - maze.destx = maze.desty = MIDDLE - - angle = atan2(y - self.hero.y, x - self.hero.x) - self.control(right, down, angle, firing, slashing) + 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 __exit__(self, exc_type, exc_value, traceback): if self.server is not None: self.server.close() @@ -349,6 +359,12 @@ def main(): help='use space music background (fallback: {})'.format(config.space)) parser.add_argument('--default-music', action='store_false', dest='space', help='use default music background') + 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( @@ -397,5 +413,7 @@ def main(): 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() diff --git a/brutalmaze/maze.py b/brutalmaze/maze.py index da0de7c..b43b0e8 100644 --- a/brutalmaze/maze.py +++ b/brutalmaze/maze.py @@ -36,6 +36,7 @@ from .constants import ( BULLET_LIFETIME, JSON_SEPARATORS) from .misc import ( round2, sign, deg, around, regpoly, fill_aapolygon, play, json_rec) +from .weapons import LockOn class Maze: @@ -59,6 +60,7 @@ class Maze: hero (Hero): the hero destx, desty (int): the grid the hero is moving to stepx, stepy (int): direction the maze is moving + target (Enemy or LockOn): target to automatically aim at next_move (float): time until the hero gets mobilized (in ms) glitch (float): time that the maze remain flashing colors (in ms) next_slashfx (float): time until next slash effect of the hero (in ms) @@ -102,6 +104,7 @@ class Maze: self.map[MIDDLE][MIDDLE] = HERO self.destx = self.desty = MIDDLE self.stepx = self.stepy = 0 + self.target = LockOn(MIDDLE, MIDDLE, retired=True) self.next_move = self.glitch = self.next_slashfx = 0.0 self.slashd = self.hero.R + self.distance/SQRT2 @@ -152,6 +155,17 @@ class Maze: return (MIDDLE + round2((x-self.centerx) / self.distance), MIDDLE + round2((y-self.centery) / self.distance)) + def get_target(self, x, y): + """Return shooting target the grid containing the point (x, y). + + If the grid is the hero, return a retired target. + """ + gridx, gridy = self.get_grid(x, y) + if gridx == gridy == MIDDLE: return LockOn(gridx, gridy, True) + for enemy in self.enemies: + if not enemy.isunnoticeable(gridx, gridy): return enemy + return LockOn(gridx, gridy) + def get_score(self): """Return the current score.""" return int(self.score - INIT_SCORE) @@ -179,7 +193,7 @@ class Maze: pygame.display.set_caption( 'Brutal Maze - Score: {}'.format(self.get_score())) - def is_displayed(self, x, y): + def isdisplayed(self, x, y): """Return True if the grid (x, y) is in the displayable part of the map, False otherwise. """ @@ -212,13 +226,17 @@ class Maze: killist = [] for i, enemy in enumerate(self.enemies): enemy.place(x, y) - if not self.is_displayed(enemy.x, enemy.y): + if not self.isdisplayed(enemy.x, enemy.y): self.score += enemy.wound enemy.die() killist.append(i) for i in reversed(killist): self.enemies.pop(i) self.add_enemy() + # LockOn target is not yet updated. + if isinstance(self.target, LockOn): + self.target.place(x, y, self.isdisplayed) + # Regenerate the maze if abs(self.rotatex) == CELL_WIDTH: self.rotatex = 0 @@ -290,7 +308,7 @@ class Maze: wound = bullet.fall_time / BULLET_LIFETIME bullet.update(self.fps, self.distance) gridx, gridy = self.get_grid(bullet.x, bullet.y) - if wound <= 0 or not self.is_displayed(gridx, gridy): + if wound <= 0 or not self.isdisplayed(gridx, gridy): fallen.append(i) elif bullet.color == 'Aluminium': if self.map[gridx][gridy] == WALL and self.next_move <= 0: @@ -430,7 +448,11 @@ class Maze: self.slashd = self.hero.R + self.distance/SQRT2 def set_step(self, check=(lambda x, y: True)): - """Return direction on the shortest path to the destination.""" + """Work out next step on the shortest path to the destination. + + Return whether target is impossible to reach and hero should + shoot toward it instead. + """ if self.stepx or self.stepy and self.vx == self.vy == 0.0: x, y = MIDDLE - self.stepx, MIDDLE - self.stepy if self.stepx and not self.stepy: @@ -443,7 +465,12 @@ class Maze: w = self.map[x - 1][y] == EMPTY == self.map[x - 1][nexty] e = self.map[x + 1][y] == EMPTY == self.map[x + 1][nexty] self.stepx = w - e - return + return False + + # Shoot WALL and ENEMY instead + if self.map[self.destx][self.desty] != EMPTY: + self.stepx = self.stepy = 0 + return True # Forest Fire algorithm with step count queue = defaultdict(list, {0: [(self.destx, self.desty)]}) @@ -460,12 +487,15 @@ class Maze: dx, dy = MIDDLE - x, MIDDLE - y if dx**2 + dy**2 <= 2: self.stepx, self.stepy = dx, dy - return + return False for i, j in around(x, y): if self.map[i][j] == EMPTY and check(i, j): queue[distance + 1].append((i, j)) count += 1 - self.stepx, self.stepy = 0, 0 + + # Failed to find way to move to target + self.stepx = self.stepy = 0 + return True def isfast(self): """Return if the hero is moving faster than HERO_SPEED.""" @@ -506,6 +536,7 @@ class Maze: self.add_enemy() self.next_move = self.next_slashfx = self.hero.next_strike = 0.0 + self.target = LockOn(MIDDLE, MIDDLE, retired=True) self.hero.next_heal = -1.0 self.hero.highness = 0.0 self.hero.slashing = self.hero.firing = self.hero.dead = False diff --git a/brutalmaze/settings.ini b/brutalmaze/settings.ini index 8127edf..08e9580 100644 --- a/brutalmaze/settings.ini +++ b/brutalmaze/settings.ini @@ -12,6 +12,8 @@ Music volume: 1.0 Space theme: no [Control] +# Touch-friendly control +Touch: yes # Input values should be either from Mouse1 to Mouse3 or a keyboard key # and they are case-insensitively read. # Aliases for special keys are listed here (without the K_ part): @@ -24,10 +26,8 @@ Move left: a Move right: d Move up: w Move down: s -# Move hero using mouse -Auto move: Mouse3 Long-range attack: Mouse1 -Close-range attack: Space +Close-range attack: Mouse3 [Record] # Directory to write record of game states, leave blank to disable. diff --git a/brutalmaze/weapons.py b/brutalmaze/weapons.py index 7eae843..cd4c15f 100644 --- a/brutalmaze/weapons.py +++ b/brutalmaze/weapons.py @@ -76,3 +76,22 @@ class Bullet: def get_distance(self, x, y): """Return the from the center of the bullet to the point (x, y).""" return ((self.x-x)**2 + (self.y-y)**2)**0.5 + + +class LockOn: + """Lock-on device to assist hero's aiming. + This is used as a mutable object to represent a grid of wall. + + Attributes: + x, y (int): coordinates of the target (in grids) + destroyed (bool): flag indicating if the target is destroyed + """ + def __init__(self, x, y, retired=False): + self.x, self.y = x, y + self.retired = retired + + def place(self, x, y, isdisplayed): + """Move the target by (x, y) (in grids).""" + self.x += x + self.y += y + if not isdisplayed(self.x, self.y): self.retired = True diff --git a/setup.py b/setup.py index c016e3f..bb96a75 100755 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ with open('README.rst') as f: setup( name='brutalmaze', - version='0.8.21', + version='0.8.22', description='A minimalist TPS game with fast-paced action', long_description=long_description, url='https://github.com/McSinyx/brutalmaze', -- cgit 1.4.1