Implement touch-friendly control
This commit is contained in:
parent
865a3e3b71
commit
377dda3db0
|
@ -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)
|
||||
|
||||
|
|
|
@ -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,18 +279,28 @@ 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:
|
||||
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)
|
||||
|
||||
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:
|
||||
|
@ -285,20 +309,6 @@ class Game:
|
|||
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)
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue