diff --git a/README.rst b/README.rst index ec4b3d6..1d68dac 100644 --- a/README.rst +++ b/README.rst @@ -1,8 +1,8 @@ Brutal Maze =========== -Brutal Maze is a hack and slash game with fast-paced action and a minimalist -art style. +Brutal Maze is a third-person shooter game with fast-paced action and a +minimalist art style. .. image:: https://raw.githubusercontent.com/McSinyx/brutalmaze/master/screenshot.png @@ -39,7 +39,8 @@ For more information, see page from Brutal Maze wiki. After installation, you can launch the game by running the command -``brutalmaze``. Below are default bindings: +``brutalmaze``. Below are the default bindings, which can be configured as +shown in the next section: F2 New game. @@ -55,9 +56,11 @@ Up Move up. Down Move down. +Right Mouse + Move the hero using mouse Left Mouse Long-range attack. -Right Mouse +Space Close-range attack, also dodge from bullets. Configuration diff --git a/brutalmaze/__init__.py b/brutalmaze/__init__.py index 19bf3a7..3e2e7a1 100644 --- a/brutalmaze/__init__.py +++ b/brutalmaze/__init__.py @@ -1 +1 @@ -"""Brutal Maze is a minimalist hack and slash game with fast-paced action""" +"""Brutal Maze is a minimalist third-person shooter with fast-paced action""" diff --git a/brutalmaze/characters.py b/brutalmaze/characters.py index e814d87..ab517c1 100644 --- a/brutalmaze/characters.py +++ b/brutalmaze/characters.py @@ -26,7 +26,7 @@ from sys import modules from .constants import ( TANGO, HERO_HP, SFX_HEART, HEAL_SPEED, MIN_BEAT, ATTACK_SPEED, ENEMY, ENEMY_SPEED, ENEMY_HP, SFX_SLASH_HERO, MIDDLE, WALL, FIRANGE, AROUND_HERO, - ADJACENT_GRIDS, EMPTY, FG_COLOR, SQRT2, MINW) + ADJACENTS, EMPTY, FG_COLOR, SQRT2, MINW) from .misc import sign, cosin, randsign, regpoly, fill_aapolygon, choices, play from .weapons import Bullet @@ -232,8 +232,8 @@ class Enemy: self.move_speed = self.maze.fps / speed directions = [(sign(MIDDLE - self.x), 0), (0, sign(MIDDLE - self.y))] shuffle(directions) - directions.append(choice(ADJACENT_GRIDS)) - if self.maze.hero.dead: directions = choice(ADJACENT_GRIDS), + directions.append(choice(ADJACENTS)) + if self.maze.hero.dead: directions = choice(ADJACENTS), for x, y in directions: if (x or y) and self.maze.map[self.x + x][self.y + y] == EMPTY: self.offsetx = round(x * (1 - self.move_speed)) diff --git a/brutalmaze/constants.py b/brutalmaze/constants.py index cb65d88..29b0241 100644 --- a/brutalmaze/constants.py +++ b/brutalmaze/constants.py @@ -56,9 +56,9 @@ ATTACK_SPEED = 333.333 # ms/strike FIRANGE = 6 # grids BULLET_LIFETIME = 1000.0 * FIRANGE / (BULLET_SPEED-HERO_SPEED) # ms EMPTY, WALL, HERO, ENEMY = range(4) -ADJACENT_GRIDS = (1, 0), (0, 1), (-1, 0), (0, -1) -AROUND_HERO = set((MIDDLE + x, MIDDLE + y) for x, y in - ADJACENT_GRIDS + ((1, 1), (-1, 1), (-1, -1), (1, -1))) +ADJACENTS = (1, 0), (0, 1), (-1, 0), (0, -1) +CORNERS = (1, 1), (-1, 1), (-1, -1), (1, -1) +AROUND_HERO = set((MIDDLE + x, MIDDLE + y) for x, y in ADJACENTS + CORNERS) TANGO = {'Butter': ((252, 233, 79), (237, 212, 0), (196, 160, 0)), 'Orange': ((252, 175, 62), (245, 121, 0), (206, 92, 0)), diff --git a/brutalmaze/game.py b/brutalmaze/game.py index f155ad9..8c30686 100644 --- a/brutalmaze/game.py +++ b/brutalmaze/game.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# main.py - main module, starts game and main loop +# game.py - main module, starts game and main loop # Copyright (C) 2017, 2018 Nguyễn Gia Phong # # This file is part of Brutal Maze. @@ -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.6.5' +__version__ = '0.7.0' import re from argparse import ArgumentParser, FileType, RawTextHelpFormatter @@ -37,7 +37,7 @@ from pygame import KEYDOWN, QUIT, VIDEORESIZE from pygame.time import Clock, get_ticks from appdirs import AppDirs -from .constants import SETTINGS, ICON, MUSIC, HERO_SPEED, COLORS, WALL +from .constants import SETTINGS, ICON, MUSIC, HERO_SPEED, COLORS, MIDDLE, WALL from .maze import Maze from .misc import deg, round2, sign @@ -50,6 +50,7 @@ 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' @@ -81,7 +82,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 ('shot', 'slash'): + if alias not in ('autove', 'shot', 'slash'): raise ValueError(self.WEIRD_MOUSE_ERR.format(cmd)) self.mouse[alias] = int(i[-1]) - 1 continue @@ -219,27 +220,33 @@ class Game: def move(self, x, y): """Command the hero to move faster in the given direction.""" - x, y = -x, -y # or move the maze in the reverse direction - velocity = self.maze.distance * HERO_SPEED / self.fps + maze = self.maze + velocity = maze.distance * HERO_SPEED / self.fps accel = velocity * HERO_SPEED / self.fps - if self.maze.next_move > 0 or not x: - self.maze.vx -= sign(self.maze.vx) * accel - if abs(self.maze.vx) < accel * 2: self.maze.vx = 0.0 - elif x * self.maze.vx < 0: - self.maze.vx += x * 2 * accel + if x == y == 0: + maze.set_step() + x, y = maze.stepx, maze.stepy else: - self.maze.vx += x * accel - if abs(self.maze.vx) > velocity: self.maze.vx = x * velocity + x, y = -x, -y # or move the maze in the reverse direction - if self.maze.next_move > 0 or not y: - self.maze.vy -= sign(self.maze.vy) * accel - if abs(self.maze.vy) < accel * 2: self.maze.vy = 0.0 - elif y * self.maze.vy < 0: - self.maze.vy += y * 2 * accel + 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: - self.maze.vy += y * accel - if abs(self.maze.vy) > velocity: self.maze.vy = y * velocity + 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.""" @@ -293,11 +300,11 @@ class Game: right = keys[self.key['right']] - keys[self.key['left']] down = keys[self.key['down']] - keys[self.key['up']] - # Follow the mouse cursor - x, y = pygame.mouse.get_pos() - angle = atan2(y - self.hero.y, x - self.hero.x) - buttons = pygame.mouse.get_pressed() + try: + autove = keys[self.key['autove']] + except KeyError: + autove = buttons[self.mouse['autove']] try: firing = keys[self.key['shot']] except KeyError: @@ -307,6 +314,21 @@ class Game: 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 = MIDDLE + round2((x-maze.centerx) / maze.distance) + maze.desty = MIDDLE + round2((y-maze.centery) / maze.distance) + maze.set_step(lambda x: maze.rangex[0] <= x <= maze.rangex[-1], + lambda y: maze.rangey[0] <= y <= maze.rangey[-1]) + 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): diff --git a/brutalmaze/maze.py b/brutalmaze/maze.py index 665d9c3..e98e421 100644 --- a/brutalmaze/maze.py +++ b/brutalmaze/maze.py @@ -19,7 +19,7 @@ __doc__ = 'Brutal Maze module for the maze class' -from collections import deque +from collections import deque, defaultdict from math import pi, log from random import choice, getrandbits, uniform @@ -28,10 +28,10 @@ import pygame from .characters import Hero, new_enemy from .constants import ( EMPTY, WALL, HERO, ROAD_WIDTH, MAZE_SIZE, MIDDLE, INIT_SCORE, ENEMIES, - MINW, MAXW, SQRT2, SFX_SPAWN, SFX_SLASH_ENEMY, SFX_LOSE, ADJACENT_GRIDS, + MINW, MAXW, SQRT2, SFX_SPAWN, SFX_SLASH_ENEMY, SFX_LOSE, ADJACENTS, BG_COLOR, FG_COLOR, CELL_WIDTH, LAST_ROW, HERO_HP, ENEMY_HP, ATTACK_SPEED, HERO_SPEED, BULLET_LIFETIME) -from .misc import round2, sign, regpoly, fill_aapolygon, play +from .misc import round2, sign, around, regpoly, fill_aapolygon, play from .weapons import Bullet @@ -74,6 +74,8 @@ class Maze: enemy_weights (dict): probabilities of enemies to be created enemies (list of Enemy): alive enemies hero (Hero): the hero + destx, desty (int): the grid the hero is moving to + stepx, stepy (int): direction the maze is moving next_move (float): time until the hero gets mobilized (in ms) next_slashfx (float): time until next slash effect of the hero (in ms) slashd (float): minimum distance for slashes to be effective @@ -105,6 +107,8 @@ class Maze: self.add_enemy() self.hero = Hero(self.surface, fps, size) self.map[MIDDLE][MIDDLE] = HERO + self.destx = self.desty = MIDDLE + self.stepx = self.stepy = 0 self.next_move = self.next_slashfx = 0.0 self.slashd = self.hero.R + self.distance/SQRT2 @@ -121,7 +125,7 @@ class Maze: num = log(self.score, INIT_SCORE) while walls and len(self.enemies) < num: x, y = choice(walls) - if all(self.map[x + a][y + b] == WALL for a, b in ADJACENT_GRIDS): + if all(self.map[x + a][y + b] == WALL for a, b in ADJACENTS): continue enemy = new_enemy(self, x, y) self.enemies.append(enemy) @@ -164,22 +168,26 @@ class Maze: y = int((self.centery-self.y) * 2 / self.distance) if x == y == 0: return for enemy in self.enemies: self.map[enemy.x][enemy.y] = EMPTY + self.map[MIDDLE][MIDDLE] = EMPTY - if x: - self.centerx -= x * self.distance - self.map.rotate(x) - self.rotatex += x - if y: - self.centery -= y * self.distance - for d in self.map: d.rotate(y) - self.rotatey += y + self.centerx -= x * self.distance + self.map.rotate(x) + self.rotatex += x + self.centery -= y * self.distance + for d in self.map: d.rotate(y) + self.rotatey += y self.map[MIDDLE][MIDDLE] = HERO + if self.map[self.destx][self.desty] != HERO: + self.destx += x + self.desty += y + self.stepx = self.stepy = 0 # Respawn the enemies that fall off the display killist = [] for i, enemy in enumerate(self.enemies): enemy.place(x, y) - if enemy.x not in self.rangex or enemy.y not in self.rangey: + if not (self.rangex[0] <= enemy.x <= self.rangex[-1] + and self.rangey[0] <= enemy.y <= self.rangey[-1]): self.score += enemy.wound enemy.die() killist.append(i) @@ -307,26 +315,25 @@ class Maze: return 0.0 for enemy in self.enemies: x, y = self.get_pos(enemy.x, enemy.y) - if (max(abs(herox - x), abs(heroy - y)) * 2 < self.distance - and enemy.awake): + if max(abs(herox - x), abs(heroy - y)) * 2 < self.distance: return 0.0 return vx or vy def update(self, fps): """Update the maze.""" self.fps = fps - dx = self.is_valid_move(vx=self.vx) - self.centerx += dx - dy = self.is_valid_move(vy=self.vy) - self.centery += dy + self.vx = self.is_valid_move(vx=self.vx) + self.centerx += self.vx + self.vy = self.is_valid_move(vy=self.vy) + self.centery += self.vy self.next_move -= 1000.0 / self.fps self.next_slashfx -= 1000.0 / self.fps self.rotate() - if dx or dy: + if self.vx or self.vy: for enemy in self.enemies: enemy.wake() - for bullet in self.bullets: bullet.place(dx, dy) + for bullet in self.bullets: bullet.place(self.vx, self.vy) for enemy in self.enemies: enemy.update() if not self.hero.dead: @@ -351,6 +358,44 @@ class Maze: self.rangey = list(range(MIDDLE - h, MIDDLE + h + 1)) self.slashd = self.hero.R + self.distance/SQRT2 + def set_step(self, xcheck=(lambda _: True), ycheck=(lambda _: True)): + """Return direction on the shortest path to the destination.""" + 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: + nextx = x - self.stepx + n = self.map[x][y - 1] == EMPTY == self.map[nextx][y - 1] + s = self.map[x][y + 1] == EMPTY == self.map[nextx][y + 1] + self.stepy = n - s + elif not self.stepx and self.stepy: + nexty = y - self.stepy + 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 + + queue = defaultdict(list, {0: [(self.destx, self.desty)]}) + visited, count, distance = set(), 1, 0 + while count: + # Hashes of small intergers are themselves so queue is sorted + if not queue[distance]: distance += 1 + x, y = queue[distance].pop() + count -= 1 + if (x, y) not in visited: + visited.add((x, y)) + else: + continue + + dx, dy = MIDDLE - x, MIDDLE - y + if dx**2 + dy**2 <= 2: + self.stepx, self.stepy = dx, dy + return + for i, j in around(x, y): + if self.map[i][j] == EMPTY and xcheck(i) and ycheck(j): + queue[distance + 1].append((i, j)) + count += 1 + self.stepx, self.stepy = 0, 0 + def isfast(self): """Return if the hero is moving faster than HERO_SPEED.""" return (self.vx**2+self.vy**2)**0.5*self.fps > HERO_SPEED*self.distance @@ -359,6 +404,8 @@ class Maze: """Handle loses.""" self.hero.dead = True self.hero.slashing = self.hero.firing = False + self.destx = self.desty = MIDDLE + self.stepx = self.stepy = 0 self.vx = self.vy = 0.0 play(self.sfx_lose) @@ -368,6 +415,8 @@ class Maze: self.score = INIT_SCORE self.map = deque() for _ in range(MAZE_SIZE): self.map.extend(new_column()) + self.destx = self.desty = MIDDLE + self.stepx = self.stepy = 0 self.vx = self.vy = 0.0 self.rotatex = self.rotatey = 0 self.bullets, self.enemies = [], [] diff --git a/brutalmaze/misc.py b/brutalmaze/misc.py index e6da56c..d26edf4 100644 --- a/brutalmaze/misc.py +++ b/brutalmaze/misc.py @@ -19,12 +19,15 @@ __doc__ = 'Brutal Maze module for miscellaneous functions' +from itertools import chain from math import degrees, cos, sin, pi -from random import uniform +from random import shuffle, uniform import pygame from pygame.gfxdraw import filled_polygon, aapolygon +from .constants import ADJACENTS, CORNERS + def round2(number): """Round a number to an int.""" @@ -69,6 +72,15 @@ def cosin(x): return cos(x) + sin(x) +def around(x, y): + """Return grids around the given one in random order.""" + a = [(x + i, y + j) for i, j in ADJACENTS] + shuffle(a) + c = [(x + i, y + j) for i, j in CORNERS] + shuffle(c) + return chain(a, c) + + def choices(d): """Choose a random key from a dict which has values being relative weights of the coresponding keys. diff --git a/brutalmaze/settings.ini b/brutalmaze/settings.ini index 0641aad..aaa6d98 100644 --- a/brutalmaze/settings.ini +++ b/brutalmaze/settings.ini @@ -22,8 +22,10 @@ Move left: Left Move right: Right Move up: Up Move down: Down +# Move hero using mouse +Auto move: Mouse3 Long-range attack: Mouse1 -Close-range attack: Mouse3 +Close-range attack: Space [Server] # Enabling remote control will disable control via keyboard and mouse. diff --git a/setup.py b/setup.py index b2b83be..5588f06 100755 --- a/setup.py +++ b/setup.py @@ -7,8 +7,8 @@ with open('README.rst') as f: setup( name='brutalmaze', - version='0.6.5', - description='A minimalist hack and slash game with fast-paced action', + version='0.7.0', + description='A minimalist TPS game with fast-paced action', long_description=long_description, url='https://github.com/McSinyx/brutalmaze', author='Nguyễn Gia Phong', @@ -25,7 +25,7 @@ setup( 'Operating System :: OS Independent', 'Programming Language :: Python', 'Topic :: Games/Entertainment :: Arcade'], - keywords='pygame action-game arcade-game maze socket-server ai-challenges', + keywords='pygame third-person-shooter arcade-game maze ai-challenges', packages=['brutalmaze'], install_requires=['appdirs', 'pygame>=1.9'], package_data={'brutalmaze': ['icon.png', 'soundfx/*.ogg', 'settings.ini']}, diff --git a/wiki b/wiki index 8f40eb7..b4169d8 160000 --- a/wiki +++ b/wiki @@ -1 +1 @@ -Subproject commit 8f40eb7b3d368076bb2b9fc4d268472af62e2886 +Subproject commit b4169d8f16a5f99b11f41e4823ca67065788cbac