summary refs log tree commit diff homepage
diff options
context:
space:
mode:
-rw-r--r--brutalmaze/characters.py40
-rw-r--r--brutalmaze/game.py100
-rw-r--r--brutalmaze/maze.py45
-rw-r--r--brutalmaze/settings.ini6
-rw-r--r--brutalmaze/weapons.py19
-rwxr-xr-xsetup.py2
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 <https://www.gnu.org/licenses/>.
 
-__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()
@@ -350,6 +360,12 @@ def main():
     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(
             config.export_dir or '*disabled*'))
@@ -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',