Compare commits

..

No commits in common. "master" and "v0.6" have entirely different histories.
master ... v0.6

66 changed files with 567 additions and 1968 deletions

109
.gitignore vendored
View File

@ -1,104 +1,5 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
brutalmaze.egg-info
build
dist
__pycache__
*.pyc

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "wiki"]
path = wiki
url = https://github.com/McSinyx/brutalmaze.wiki.git

1
MANIFEST.in Normal file
View File

@ -0,0 +1 @@
include README.rst LICENSE screenshot.png

View File

@ -1,44 +1,45 @@
Brutal Maze
===========
Brutal Maze is a thrilling shoot 'em up game with minimalist art style.
Brutal Maze is a hack and slash game with fast-paced action and a minimalist
art style.
.. image:: https://brutalmaze.rtfd.io/_images/screenshot.png
:target: https://brutalmaze.rtfd.io/recplayer.html
.. image:: https://raw.githubusercontent.com/McSinyx/brutalmaze/master/screenshot.png
The game features a trigon trapped in an infinite maze. As our hero tries
to escape, the maze's border turns into aggressive squares trying to stop per.
Your job is to help the trigon fight against those evil squares and find
a way out (if there is any). Be aware that the more get killed,
the more will show up and our hero will get weaker when wounded.
The game features a trigon trapped in an infinite maze. As our hero tries to
escape, the maze's border turns into aggressive squares trying to stop him.
Your job is to help the trigon fight against those evil squares and find a way
out (if there is any). Be aware that the more get killed, the more will show up
and our hero will get weaker when wounded.
Brutal Maze has a few notable features:
* Being highly portable.
* Auto-generated and infinite maze. [0]_
* Auto-generated and infinite maze.
* No binary data for drawing.
* Enemies with special abilities: stun, poison, camo, etc.
* Somewhat a realistic physic and logic system.
* Resizable game window in-game.
* Easily customizable via INI file format.
* Recordable in JSON (some kind of silent screencast).
* Remote control through TCP/IP socket (can be used in AI researching).
Installation
------------
Brutal Maze is written in Python and is compatible version 3.6 and above.
Brutal Maze is written in Python and is compatible with both version 2 and 3.
The installation procedure should be as simple as follows:
* Install Python and pip_. Make sure the directory for `Python scripts`_
* Install Python and `pip <https://pip.pypa.io/en/latest/>`_. Make sure the
directory for `Python scripts <https://docs.python.org/2/install/index.html#alternate-installation-the-user-scheme>`_
is in your ``$PATH``.
* Open Terminal or Command Prompt and run ``pip install --user brutalmaze``.
For more information, see Installation_ page from the documentation.
For more information, see
`Installation <https://github.com/McSinyx/brutalmaze/wiki/Installation>`_
page from Brutal Maze wiki.
After installation, you can launch the game by running the command
``brutalmaze``. Below are the default bindings, which can be configured as
shown in the next section:
``brutalmaze``. Below are default bindings:
F2
New game.
@ -46,26 +47,19 @@ F2
Toggle pause.
``m``
Toggle mute.
``a``
Left
Move left.
``d``
Right
Move right.
``w``
Up
Move up.
``s``
Down
Move down.
Left Mouse
Long-range attack.
Right Mouse
Close-range attack, also dodge from bullets.
Additionally, Brutal Maze also supports touch-friendly control. In this mode,
touches on different grid (empty, wall, enemy, hero) send different signals
(to guide the hero to either move or attack, or start new game). Albeit it is
implemented using *mouse button up* event, touch control is not a solution for
mouse-only input, but an attempt to support mobile GNU/Linux distribution such
as postmarketOS, i.e. it's meant to be played using two thumbs :-)
Configuration
-------------
@ -76,10 +70,10 @@ to configuration file only.
Settings are read in the following order:
0. Default configuration [1]_
1. System-wide configuration file [2]_
2. Local configuration file [2]_
3. Manually set configuration file [3]_
0. Default configuration [0]_
1. System-wide configuration file [1]_
2. Local configuration file [1]_
3. Manually set configuration file [2]_
4. Command-line arguments
Later-read preferences will override previous ones.
@ -87,18 +81,13 @@ Later-read preferences will override previous ones.
Remote control
--------------
If you enable the socket server [4]_, Brutal Maze will no longer accept
direct input from your mouse or keyboard, but wait for a client to connect.
The I/O format is explained in details in the `Remote Control`_ page.
If you enable the socket server [3]_, Brutal Maze will no longer accept direct
input from your mouse or keyboard, but wait for a client to connect. Details
about I/O format are explained carefully in
`Remote control <https://github.com/McSinyx/brutalmaze/wiki/Remote-control>`_
wiki page.
Game recording
--------------
Either game played by human or client script can be recorded to JSON format.
This can be enabled by setting the output directory to a non-empty string [5]_.
Recordings can be played using Brutal Maze `HTML5 record player`_.
Copying
License
-------
Brutal Maze's source code and its icon are released under GNU Affero General
@ -108,24 +97,15 @@ allow them to download the source code corresponding to the modified version
running there.
This project also uses Tango color palette and several sound effects, whose
authors and licenses are listed in the Copying_ page in our documentation.
authors and licenses are listed in
`Credits <https://github.com/McSinyx/brutalmaze/wiki/Credits>`_ wiki page.
.. [0] Broken on vanilla pygame on GNU/Linux. For workarounds, see issue
`#3 <https://git.disroot.org/McSinyx/brutalmaze/issues/3>`_.
.. [1] This can be copied to desired location by ``brutalmaze --write-config
PATH``. ``brutalmaze --write-config`` alone will print the file to stdout.
.. [2] These will be listed as fallback config in the help message
(``brutalmaze --help``). See the Configuration_ documentation for more info.
.. [3] If specified by ``brutalmaze --config PATH``.
.. [4] This can be done by either editing option *Enable* in section *Server*
in the configuration file or launching the game via ``brutalmaze --server``.
.. [5] ``brutalmaze --record-dir DIR``. Navigate to Configuration_
to see more options.
.. _pip: https://pip.pypa.io/en/latest/
.. _Python scripts: https://docs.python.org/3/install/index.html#alternate-installation-the-user-scheme
.. _Installation: https://brutalmaze.rtfd.io/install.html
.. _Remote Control: https://brutalmaze.rtfd.io/remote.html
.. _HTML5 record player: https://brutalmaze.rtfd.io/recplayer.html
.. _Copying: https://brutalmaze.rtfd.io/copying.html
.. _Configuration: https://brutalmaze.rtfd.io/config.html
.. [0] This can be copied to desired location by ``brutalmaze --write-config
PATH``. ``brutalmaze --write-config`` alone will print the file to stdout.
.. [1] These will be listed as fallback config in the help message
(``brutalmaze --help``). See `wiki <https://github.com/McSinyx/brutalmaze/wiki/Configuration>`_
for more info.
.. [2] If specified by ``brutalmaze --config PATH``.
.. [3] This can be done by either editing option *Enable* in section *Server*
in the configuration file, or launching Brutal Maze using ``brutalmaze
--server``.

View File

@ -1,3 +1 @@
"""Minimalist thrilling shoot 'em up game with minimalist art style"""
from .game import __version__
"""Brutal Maze is a minimalist hack and slash game with fast-paced action"""

View File

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
# characters.py - module for hero and enemy classes
# Copyright (C) 2017-2020 Nguyễn Gia Phong
# Copyright (C) 2017, 2018 Nguyễn Gia Phong
#
# This file is part of Brutal Maze.
#
@ -18,16 +19,15 @@
__doc__ = 'Brutal Maze module for hero and enemy classes'
from collections import deque
from math import atan2, gcd, pi, sin
from math import atan, atan2, sin, pi
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 .misc import fill_aapolygon, play, randsign, regpoly, sign
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)
from .misc import sign, cosin, randsign, regpoly, fill_aapolygon, choices, play
from .weapons import Bullet
@ -40,17 +40,16 @@ class Hero:
angle (float): angle of the direction the hero pointing (in radians)
color (tuple of pygame.Color): colors of the hero on different HPs
R (int): circumradius of the regular triangle representing the hero
next_heal (float): minimum wound in ATTACK_SPEED allowing healing again
next_heal (float): ms until the hero gains back healing ability
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 indicating if the hero's 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
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
spin_speed (float): speed of spinning (in frames per slash)
spin_queue (float): frames left to finish spinning
wound (float): amount of wound
wounds (deque of float): wounds in time of an attack (ATTACK_SPEED)
sfx_heart (pygame.mixer.Sound): heart beat sound effect
"""
def __init__(self, surface, fps, maze_size):
self.surface = surface
@ -59,13 +58,12 @@ class Hero:
self.angle, self.color = -pi * 3 / 4, TANGO['Aluminium']
self.R = (w * h / sin(pi*2/3) / 624) ** 0.5
self.next_heal = -1.0
self.next_beat = self.next_strike = 0.0
self.highness = 0.0
self.next_heal = self.next_beat = self.next_strike = 0.0
self.slashing = self.firing = self.dead = False
self.spin_speed = fps / HERO_HP
self.spin_queue = self.wound = 0.0
self.wounds = deque([0.0])
self.sfx_heart = SFX_HEART
def update(self, fps):
"""Update the hero."""
@ -75,22 +73,19 @@ class Hero:
old_speed = self.spin_speed
self.spin_speed = fps / (HERO_HP-self.wound**0.5)
self.spin_queue *= self.spin_speed / old_speed
if len(self.wounds) > fps * ATTACK_SPEED / 1000: self.wounds.popleft()
if sum(self.wounds) < self.next_heal: self.next_heal = -1.0
self.wound += self.wounds[-1]
if self.next_heal < 0:
if self.next_heal <= 0:
self.wound -= HEAL_SPEED / self.spin_speed / HERO_HP
if self.wound < 0: self.wound = 0.0
self.wounds.append(0.0)
else:
self.next_heal -= 1000.0 / fps
if self.next_beat <= 0:
play(SFX_HEART)
play(self.sfx_heart)
self.next_beat = MIN_BEAT*(2 - self.wound/HERO_HP)
else:
self.next_beat -= 1000 / fps
self.next_strike -= 1000 / fps
self.next_beat -= 1000.0 / fps
self.next_strike -= 1000.0 / fps
full_spin = pi * 2 / self.sides
full_spin = pi * 2 / self.get_sides()
if self.slashing and self.next_strike <= 0:
self.next_strike = ATTACK_SPEED
self.spin_queue = randsign() * self.spin_speed
@ -101,45 +96,29 @@ class Hero:
else:
self.spin_queue = 0.0
@property
def sides(self):
"""Number of sides the hero has. While the hero is generally
a trigon, Agent Orange may turn him into a square.
def get_sides(self):
"""Return the number of sides the hero has. While the hero is
generally a trigon, Agent Orange may turn him into a square.
"""
return 3 if self.next_heal < 0 else 4
return 3 if self.next_heal <= 0 else 4
def update_angle(self, angle):
"""Turn to the given angle if the hero is not busy slashing."""
if round(self.spin_queue) != 0: return
delta = (angle - self.angle + pi) % (pi * 2) - pi
unit = pi * 2 / self.sides / self.spin_speed
unit = pi * 2 / self.get_sides() / self.spin_speed
if abs(delta) < unit:
self.angle, self.spin_queue = angle, 0.0
else:
self.spin_queue = delta / unit
@property
def shots(self):
"""List of Bullet the hero has just shot."""
if not self.firing or self.slashing or self.next_strike > 0: return []
self.next_strike = ATTACK_SPEED
if not randrange(int(self.highness + 1)):
return [Bullet(self.surface, self.x, self.y,
self.angle, 'Aluminium')]
self.highness -= 1.0
n = self.sides
corners = {randrange(n) for _ in range(n)}
angles = (self.angle + pi*2*corner/n for corner in corners)
return [Bullet(self.surface, self.x, self.y, angle, 'Aluminium')
for angle in angles]
def get_color(self):
"""Return current color of the hero."""
return self.color[int(self.wound)]
def draw(self):
"""Draw the hero."""
trigon = regpoly(self.sides, self.R, self.angle, self.x, self.y)
trigon = regpoly(self.get_sides(), self.R, self.angle, self.x, self.y)
fill_aapolygon(self.surface, trigon, self.get_color())
def resize(self, maze_size):
@ -157,51 +136,47 @@ 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
alive (bool): flag indicating if the enemy is alive
awake (bool): flag indicating if the enemy is active
awake (bool): flag indicates 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
spin_speed (float): speed of spinning (in frames per slash)
spin_queue (float): frames left to finish spinning
wound (float): amount of wound
sfx_slash (pygame.mixer.Sound): sound effect of slashed hero
"""
def __init__(self, maze, x, y, color):
self.maze = maze
self.x, self.y = x, y
self.maze.map[x][y] = ENEMY
self.angle, self.color = pi / 4, color
self.alive, self.awake = True, False
self.awake = False
self.next_strike = 0.0
self.move_speed = self.maze.fps / ENEMY_SPEED
self.offsetx = self.offsety = 0
self.spin_speed = self.maze.fps / ENEMY_HP
self.spin_queue = self.wound = 0.0
@property
def pos(self):
"""Coordinates (in pixels) of the center of the enemy."""
self.sfx_slash = SFX_SLASH_HERO
def get_pos(self):
"""Return coordinate of the center of the enemy."""
x, y = self.maze.get_pos(self.x, self.y)
step = self.maze.distance * ENEMY_SPEED / self.maze.fps
return x + self.offsetx*step, y + self.offsety*step
@property
def distance(self):
"""Distance from the center of the enemy
to the center of the maze.
def get_distance(self):
"""Return the distance from the center of the enemy to
the center of the maze.
"""
return self.maze.get_distance(*self.pos)
return self.maze.get_distance(*self.get_pos())
def place(self, x=0, y=0):
"""Move the enemy by (x, y) (in grids)."""
self.x += x
self.y += y
if self.awake: self.maze.map[self.x][self.y] = ENEMY
@property
def spawn_volume(self):
"""Volumn of spawning sound effect."""
return 1 - self.distance / self.maze.get_distance(0, 0) / 2
self.maze.map[self.x][self.y] = ENEMY
def wake(self):
"""Wake the enemy up if it can see the hero.
@ -210,24 +185,30 @@ class Enemy:
has just woken it, False otherwise.
"""
if self.awake: return None
srcx, destx = self.x, MIDDLE
if abs(destx - srcx) != 1: srcx += sign(destx - srcx) or 1
srcy, desty = self.y, MIDDLE
if abs(desty - srcy) != 1: srcy += sign(desty - srcy) or 1
m, n = destx - srcx, desty - srcy
lcm = abs(m * n // gcd(m, n))
w, u = lcm // m, lcm // n
for i in range(lcm):
if self.maze.map[srcx+i//w][srcy+i//u] == WALL: return False
startx = starty = MIDDLE
stopx, stopy, distance = self.x, self.y, self.maze.distance
if startx > stopx: startx, stopx = stopx, startx
if starty > stopy: starty, stopy = stopy, starty
dx = (self.x-MIDDLE)*distance + self.maze.centerx - self.maze.x
dy = (self.y-MIDDLE)*distance + self.maze.centery - self.maze.y
mind = cosin(abs(atan(dy / dx)) if dx else 0) * distance
def get_distance(x, y): return abs(dy*x - dx*y) / (dy**2 + dx**2)**0.5
for i in range(startx, stopx + 1):
for j in range(starty, stopy + 1):
if self.maze.map[i][j] != WALL: continue
x, y = self.maze.get_pos(i, j)
if get_distance(x - self.maze.x, y - self.maze.y) <= mind:
return False
self.awake = True
self.maze.map[self.x][self.y] = ENEMY
play(SFX_SPAWN, self.x, self.y)
play(self.maze.sfx_spawn,
1 - self.get_distance()/self.maze.get_distance(0, 0)/2,
self.get_angle() + pi)
return True
def fire(self):
"""Return True if the enemy has just fired, False otherwise."""
if self.maze.hero.dead: return False
x, y = self.pos
x, y = self.get_pos()
if (self.maze.get_distance(x, y) > FIRANGE*self.maze.distance
or self.next_strike > 0
or (self.x, self.y) in AROUND_HERO or self.offsetx or self.offsety
@ -251,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(ADJACENTS))
if self.maze.hero.dead: directions = choice(ADJACENTS),
directions.append(choice(ADJACENT_GRIDS))
if self.maze.hero.dead: directions = choice(ADJACENT_GRIDS),
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))
@ -264,42 +245,34 @@ class Enemy:
def get_slash(self):
"""Return the enemy's close-range damage."""
wound = (self.maze.slashd - self.distance) / self.maze.hero.R
wound = (self.maze.slashd - self.get_distance()) / self.maze.hero.R
return wound if wound > 0 else 0.0
def slash(self):
"""Return the enemy's close-range damage per frame."""
wound = self.get_slash() / self.spin_speed
if self.spin_queue and wound: self.maze.hit_hero(wound, self.color)
if self.spin_queue: self.maze.hit_hero(wound, self.color)
return wound
def get_angle(self):
def get_angle(self, reversed=False):
"""Return the angle of the vector whose initial point is
the center of the screen and terminal point is the center of
the enemy.
"""
x, y = self.pos
x, y = self.get_pos()
return atan2(y - self.maze.y, x - self.maze.x)
def get_color(self):
"""Return current color of the enemy."""
return TANGO[self.color][int(self.wound)]
return TANGO[self.color][int(self.wound)] if self.awake else FG_COLOR
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):
"""Draw the enemy."""
if self.isunnoticeable(): return
radius = self.maze.distance / SQRT2
square = regpoly(4, radius, self.angle, *self.pos)
if self.maze.next_move > 0 and not self.awake: return
radius = self.maze.distance/SQRT2 - self.awake*2
square = regpoly(4, radius, self.angle, *self.get_pos())
fill_aapolygon(self.maze.surface, square, self.get_color())
def update(self):
@ -307,11 +280,11 @@ class Enemy:
if self.awake:
self.spin_speed, tmp = self.maze.fps / ENEMY_HP, self.spin_speed
self.spin_queue *= self.spin_speed / tmp
self.next_strike -= 1000 / self.maze.fps
self.next_strike -= 1000.0 / self.maze.fps
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(self.sfx_slash, self.get_slash(), self.get_angle())
if round(self.spin_queue) != 0:
self.angle += sign(self.spin_queue) * pi / 2 / self.spin_speed
self.spin_queue -= sign(self.spin_queue)
@ -322,22 +295,14 @@ 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."""
self.maze.map[self.x][self.y] = EMPTY if self.wake else WALL
self.alive = False
if self.awake:
self.maze.map[self.x][self.y] = EMPTY
if self.maze.enemy_weights[self.color] > MINW + 1.5:
self.maze.enemy_weights[self.color] -= 1.5
else:
self.maze.map[self.x][self.y] = WALL
class Chameleon(Enemy):
@ -347,39 +312,34 @@ class Chameleon(Enemy):
visible (float): time until the Chameleon is visible (in ms)
"""
def __init__(self, maze, x, y):
super().__init__(maze, x, y, 'Chameleon')
Enemy.__init__(self, maze, x, y, 'Chameleon')
self.visible = 0.0
def wake(self):
"""Wake the Chameleon up if it can see the hero."""
if super().wake() is True:
self.visible = 1000 / ENEMY_SPEED
if Enemy.wake(self) is True:
self.visible = 1000.0 / ENEMY_SPEED
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 (super().isunnoticeable(x, y)
or self.visible <= 0 and not self.spin_queue
and self.maze.next_move <= 0)
def draw(self):
"""Draw the Chameleon."""
if not self.awake or self.visible > 0 or self.spin_queue:
Enemy.draw(self)
def update(self):
"""Update the Chameleon."""
super().update()
if self.awake: self.visible -= 1000 / self.maze.fps
Enemy.update(self)
if self.awake: self.visible -= 1000.0 / self.maze.fps
def hit(self, wound):
"""Handle the Chameleon when it's attacked."""
self.visible = 1000.0 / ENEMY_SPEED
super().hit(wound)
Enemy.hit(self, wound)
class Plum(Enemy):
"""Object representing an enemy of Plum."""
def __init__(self, maze, x, y):
super().__init__(maze, x, y, 'Plum')
Enemy.__init__(self, maze, x, y, 'Plum')
def clone(self, other):
"""Turn the other enemy into a clone of this Plum and return
@ -396,24 +356,24 @@ class Plum(Enemy):
class ScarletRed(Enemy):
"""Object representing an enemy of Scarlet Red."""
def __init__(self, maze, x, y):
super().__init__(maze, x, y, 'ScarletRed')
Enemy.__init__(self, maze, x, y, 'ScarletRed')
def fire(self):
"""Scarlet Red doesn't shoot."""
return False
def move(self):
return super().move(self, ENEMY_SPEED * SQRT2)
return Enemy.move(self, ENEMY_SPEED * SQRT2)
def slash(self):
"""Handle the Scarlet Red's close-range attack."""
self.wound -= super().slash()
self.wound -= Enemy.slash(self)
if self.wound < 0: self.wound = 0.0
def new_enemy(maze, x, y):
"""Return an enemy of a random type in the grid (x, y)."""
color = choice(ENEMIES)
color = choices(maze.enemy_weights)
try:
return getattr(modules[__name__], color)(maze, x, y)
except AttributeError:

View File

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
# constants.py - module for shared constants
# Copyright (C) 2017-2020 Nguyễn Gia Phong
# Copyright (C) 2017, 2018 Nguyễn Gia Phong
#
# This file is part of Brutal Maze.
#
@ -20,44 +21,44 @@ __doc__ = 'Brutal Maze module for shared constants'
from string import ascii_lowercase
import pygame
from pkg_resources import resource_filename as pkg_file
import pygame
from pygame.mixer import Sound
SETTINGS = pkg_file('brutalmaze', 'settings.ini')
ICON = pygame.image.load(pkg_file('brutalmaze', 'icon.png'))
MUSIC = pkg_file('brutalmaze', 'soundfx/music.ogg')
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)
mixer = pygame.mixer.get_init()
if mixer is None: pygame.mixer.init(frequency=44100)
SFX_SPAWN = Sound(pkg_file('brutalmaze', 'soundfx/spawn.ogg'))
SFX_SLASH_ENEMY = Sound(pkg_file('brutalmaze', 'soundfx/slash-enemy.ogg'))
SFX_SLASH_HERO = Sound(pkg_file('brutalmaze', 'soundfx/slash-hero.ogg'))
SFX_SHOT_ENEMY = Sound(pkg_file('brutalmaze', 'soundfx/shot-enemy.ogg'))
SFX_SHOT_HERO = Sound(pkg_file('brutalmaze', 'soundfx/shot-hero.ogg'))
SFX_MISSED = Sound(pkg_file('brutalmaze', 'soundfx/missed.ogg'))
SFX_HEART = Sound(pkg_file('brutalmaze', 'soundfx/heart.ogg'))
SFX_LOSE = Sound(pkg_file('brutalmaze', 'soundfx/lose.ogg'))
if mixer is None: pygame.mixer.quit()
SQRT2 = 2 ** 0.5
INIT_SCORE = 2
ROAD_WIDTH = 3 # grids
WALL_WIDTH = 4 # grids
CELL_WIDTH = WALL_WIDTH + ROAD_WIDTH*2 # grids
CELL_NODES = ROAD_WIDTH, ROAD_WIDTH + WALL_WIDTH, 0
MAZE_SIZE = 10 # cells
MIDDLE = MAZE_SIZE // 2 * CELL_WIDTH
INIT_SCORE = 5**0.5/2 + 0.5 # golden mean
MAZE_SIZE = 10
ROAD_WIDTH = 5 # grids
CELL_WIDTH = ROAD_WIDTH * 2 # grids
MIDDLE = (MAZE_SIZE + MAZE_SIZE%2 - 1)*ROAD_WIDTH + ROAD_WIDTH//2
LAST_ROW = (MAZE_SIZE-1) * ROAD_WIDTH * 2
HEAL_SPEED = 1 # HP/s
HERO_SPEED = 5 # grid/s
ENEMY_SPEED = 6 # grid/s
ENEMY_SPEED = 6 # grid/s
BULLET_SPEED = 15 # grid/s
ATTACK_SPEED = 333.333 # ms/strike
MAX_WOUND = 1 # per attack turn
FIRANGE = 6 # grids
BULLET_LIFETIME = 1000 * FIRANGE / (BULLET_SPEED-HERO_SPEED) # ms
BULLET_LIFETIME = 1000.0 * FIRANGE / (BULLET_SPEED-HERO_SPEED) # ms
EMPTY, WALL, HERO, ENEMY = range(4)
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)
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)))
TANGO = {'Butter': ((252, 233, 79), (237, 212, 0), (196, 160, 0)),
'Orange': ((252, 175, 62), (245, 121, 0), (206, 92, 0)),
@ -68,16 +69,14 @@ TANGO = {'Butter': ((252, 233, 79), (237, 212, 0), (196, 160, 0)),
'ScarletRed': ((239, 41, 41), (204, 0, 0), (164, 0, 0)),
'Aluminium': ((238, 238, 236), (211, 215, 207), (186, 189, 182),
(136, 138, 133), (85, 87, 83), (46, 52, 54))}
TANGO_VALUES = list(TANGO.values())
ENEMIES = ['Butter', 'Orange', 'Chocolate', 'Chameleon',
'SkyBlue', 'Plum', 'ScarletRed']
COLOR_CODE = ascii_lowercase + '0'
COLORS = {c: COLOR_CODE[i] for i, c in enumerate(
color for code in ENEMIES + ['Aluminium'] for color in TANGO[code])}
MINW, MAXW = 24, 36
ENEMY_HP = 3
HERO_HP = 5
MIN_BEAT = 420
MIN_BEAT = 526
BG_COLOR = TANGO['Aluminium'][-1]
FG_COLOR = TANGO['Aluminium'][0]
JSON_SEPARATORS = ',', ':'

View File

@ -1,5 +1,6 @@
# game.py - main module, starts game and main loop
# Copyright (C) 2017-2020 Nguyễn Gia Phong
# -*- coding: utf-8 -*-
# main.py - main module, starts game and main loop
# Copyright (C) 2017, 2018 Nguyễn Gia Phong
#
# This file is part of Brutal Maze.
#
@ -16,28 +17,29 @@
# 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.9.4'
__version__ = '0.6.5'
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 collections import deque
try: # Python 3
from configparser import ConfigParser
except ImportError: # Python 2
from ConfigParser import ConfigParser
from math import atan2, radians, pi
from os.path import join, pathsep
from socket import socket, SOL_SOCKET, SO_REUSEADDR
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
import pygame
from pygame import KEYDOWN, QUIT, VIDEORESIZE
from pygame.time import Clock, get_ticks
from appdirs import AppDirs
from .constants import HERO_SPEED, ICON, MIDDLE, SETTINGS, SFX, SFX_NOISE
from .constants import SETTINGS, ICON, MUSIC, HERO_SPEED, COLORS, WALL
from .maze import Maze
from .misc import deg, join, play, sign
from .misc import deg, round2, sign
class ConfigReader:
@ -68,9 +70,6 @@ class ConfigReader:
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')
@ -97,21 +96,26 @@ class ConfigReader:
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'):
'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):
def __init__(self, config):
pygame.mixer.pre_init(frequency=44100)
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.muted or self.headless:
pygame.mixer.quit()
else:
pygame.mixer.music.load(MUSIC)
pygame.mixer.music.set_volume(config.musicvol)
pygame.mixer.music.play(-1)
pygame.display.set_icon(ICON)
pygame.fastevent.init()
if config.server:
self.server = socket()
self.server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
@ -124,87 +128,83 @@ class Game:
else:
self.server = self.sockinp = None
self.max_fps, self.fps = config.max_fps, config.max_fps
# 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 / config.export_rate)
self.maze = Maze(config.max_fps, config.size, config.headless)
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 __enter__(self): 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()
def expos(self, x, y):
"""Return position of the given coordinates in rounded percent."""
cx = (x+self.maze.x-self.maze.centerx) / self.maze.distance * 100
cy = (y+self.maze.y-self.maze.centery) / self.maze.distance * 100
return round2(cx), round2(cy)
@property
def mute(self):
"""Mute state."""
return getattr(self, '_mute', 1)
def export(self):
"""Export maze data to a bytes object."""
maze, hero, = self.maze, self.hero
lines = deque(['{0} {4} {5} {1} {2:d} {3:d}'.format(
COLORS[hero.get_color()], deg(self.hero.angle),
hero.next_strike <= 0, hero.next_heal <= 0,
*self.expos(maze.x, maze.y))])
@mute.setter
def mute(self, value):
"""Mute state."""
self._mute = int(bool(value))
self.actx.listener.gain = not self._mute
walls = [[1 if maze.map[x][y] == WALL else 0 for x in maze.rangex]
for y in maze.rangey] if maze.next_move <= 0 else []
ne = nb = 0
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'])))
for enemy in maze.enemies:
if not enemy.awake and walls:
walls[enemy.y-maze.rangey[0]][enemy.x-maze.rangex[0]] = WALL
continue
# Check Chameleons
elif getattr(enemy, 'visible', 1) <= 0 and maze.next_move <= 0:
continue
lines.append('{0} {2} {3} {1:.0f}'.format(
COLORS[enemy.get_color()], deg(enemy.angle),
*self.expos(*enemy.get_pos())))
ne += 1
for bullet in maze.bullets:
x, y = self.expos(bullet.x, bullet.y)
color, angle = COLORS[bullet.get_color()], deg(bullet.angle)
if color != '0':
lines.append('{} {} {} {:.0f}'.format(color, x, y, angle))
nb += 1
if walls: lines.appendleft('\n'.join(''.join(str(cell) for cell in row)
for row in walls))
lines.appendleft('{} {} {} {}'.format(len(walls), ne, nb,
maze.get_score()))
return '\n'.join(lines).encode()
def update(self):
"""Draw and handle meta events on Pygame window.
Return False if QUIT event is captured, True otherwise.
"""
events = pygame.event.get()
events = pygame.fastevent.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
elif event.type == KEYDOWN and 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.key == self.key['mute']:
if pygame.mixer.get_init() is None:
pygame.mixer.init(frequency=44100)
pygame.mixer.music.load(MUSIC)
pygame.mixer.music.set_volume(self.musicvol)
pygame.mixer.music.play(-1)
else:
pygame.mixer.quit()
# Compare current FPS with the average of the last 10 frames
new_fps = self.clock.get_fps()
@ -215,38 +215,31 @@ class Game:
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):
def move(self, x, y):
"""Command the hero to move faster in the given direction."""
maze = self.maze
velocity = maze.distance * HERO_SPEED / self.fps
x, y = -x, -y # or move the maze in the reverse direction
velocity = self.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
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
else:
x, y = -x, -y # or move the maze in the reverse direction
self.maze.vx += x * accel
if abs(self.maze.vx) > velocity: self.maze.vx = x * velocity
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
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
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
self.maze.vy += y * accel
if abs(self.maze.vy) > velocity: self.maze.vy = y * velocity
def control(self, x, y, angle, firing, slashing):
"""Control how the hero move and attack."""
@ -271,23 +264,20 @@ class Game:
if self.hero.dead:
connection.send('0000000'.encode())
break
data = self.export_txt().encode()
alpha = deg(self.hero.angle)
data = self.export()
connection.send('{:07}'.format(len(data)).encode())
connection.send(data)
try:
buf = connection.recv(7)
except: # noqa
break # client is closed or timed out
except: # client is closed or timed out
break
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
self.sockinp = x, y, radians(angle), attack & 1, attack >> 1
clock.tick(self.fps)
self.sockinp = 0, 0, -pi * 3 / 4, 0, 0
new_time = get_ticks()
@ -296,37 +286,32 @@ 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 self.hero.dead: return
keys = pygame.key.get_pressed()
buttons = pygame.mouse.get_pressed()
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']]
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)
# Follow the mouse cursor
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)
buttons = pygame.mouse.get_pressed()
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()
pygame.quit()
def main():
@ -335,7 +320,7 @@ def main():
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]
filenames = [join(parent, 'settings.ini') for parent in parents]
config = ConfigReader(filenames)
config.parse()
@ -366,20 +351,6 @@ def main():
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))
@ -420,11 +391,5 @@ 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()
# Allow launching the game via invoking ``python -m brutalmaze.game''
if __name__ == '__main__': main()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
# maze.py - module for the maze class
# Copyright (C) 2017-2020 Nguyễn Gia Phong
# Copyright (C) 2017, 2018 Nguyễn Gia Phong
#
# This file is part of Brutal Maze.
#
@ -18,24 +19,40 @@
__doc__ = 'Brutal Maze module for the maze class'
import json
from collections import defaultdict, deque
from math import log, pi
from os import path
from random import choice, sample
from collections import deque
from math import pi, log
from random import choice, getrandbits, uniform
import pygame
from .characters import Hero, new_enemy
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)
from .misc import around, deg, fill_aapolygon, json_rec, play, regpoly, sign
from .weapons import LockOn
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,
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 .weapons import Bullet
def new_cell(bit, upper=True):
"""Return a half of a cell of the maze based on the given bit."""
if bit: return deque([WALL]*ROAD_WIDTH + [EMPTY]*ROAD_WIDTH)
if upper: return deque([WALL] * (ROAD_WIDTH<<1))
return deque([EMPTY] * (ROAD_WIDTH<<1))
def new_column():
"""Return a newly generated column of the maze."""
column = deque()
upper, lower = deque(), deque()
for _ in range(MAZE_SIZE):
b = getrandbits(1)
upper.extend(new_cell(b))
lower.extend(new_cell(b, False))
for _ in range(ROAD_WIDTH): column.append(upper.__copy__())
for _ in range(ROAD_WIDTH): column.append(lower.__copy__())
return column
class Maze:
@ -53,103 +70,50 @@ class Maze:
map (deque of deque): map of grids representing objects on the maze
vx, vy (float): velocity of the maze movement (in pixels per frame)
rotatex, rotatey (int): grids rotated
bullets (list of .weapons.Bullet): flying bullets
bullets (list of Bullet): flying bullets
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
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)
slashd (float): minimum distance for slashes to be effective
export (list of defaultdict): records of game states
export_dir (str): directory containing records of game states
export_rate (float): milliseconds per snapshot
next_export (float): time until next snapshot (in ms)
sfx_slash (pygame.mixer.Sound): sound effect of slashed enemy
sfx_lose (pygame.mixer.Sound): sound effect to be played when you lose
"""
def __init__(self, fps, size, headless, export_dir, export_rate):
def __init__(self, fps, size, headless):
self.fps = fps
self.w, self.h = size
if headless:
self.surface = None
else:
self.surface = pygame.display.set_mode(size, pygame.RESIZABLE)
self.export_dir = path.abspath(export_dir) if export_dir else ''
self.next_export = self.export_rate = export_rate
self.export = []
self.distance = (self.w * self.h / 416) ** 0.5
self.x, self.y = self.w // 2, self.h // 2
self.centerx, self.centery = self.w / 2, self.h / 2
self.centerx, self.centery = self.w / 2.0, self.h / 2.0
w, h = (int(i/self.distance/2 + 1) for i in size)
self.rangex = list(range(MIDDLE - w, MIDDLE + w + 1))
self.rangey = list(range(MIDDLE - h, MIDDLE + h + 1))
self.score = INIT_SCORE
self.new_map()
self.map = deque()
for _ in range(MAZE_SIZE): self.map.extend(new_column())
self.vx = self.vy = 0.0
self.rotatex = self.rotatey = 0
self.bullets, self.enemies = [], []
self.enemy_weights = {color: MINW for color in ENEMIES}
self.add_enemy()
self.hero = Hero(self.surface, fps, size)
self.target = LockOn(MIDDLE, MIDDLE, retired=True)
self.next_move = self.glitch = self.next_slashfx = 0.0
self.map[MIDDLE][MIDDLE] = HERO
self.next_move = 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.
"""
def draw_bit(bit, dx=0, dy=0):
startx, starty = x + CELL_NODES[dx], y + CELL_NODES[dy]
height = ROAD_WIDTH if dy else WALL_WIDTH
for i in range(ROAD_WIDTH if dx else WALL_WIDTH):
for j in range(height): self.map[startx + i][starty + j] = bit
x, y = x * CELL_WIDTH, y * CELL_WIDTH
draw_bit(WALL)
walls = set(sample(ADJACENTS, 2))
walls.add(choice(ADJACENTS))
for i, j in ADJACENTS:
draw_bit((WALL if (i, j) in walls else EMPTY), i, j)
def isdisplayed(self, x, y):
"""Return True if the grid (x, y) is in the displayable part
of the map, False otherwise.
"""
return (self.rangex[0] <= x <= self.rangex[-1]
and self.rangey[0] <= y <= self.rangey[-1])
def new_map(self):
"""Generate a new map."""
self.map = deque(deque(EMPTY for _ in range(MAZE_SIZE * CELL_WIDTH))
for _ in range(MAZE_SIZE * CELL_WIDTH))
for x in range(MAZE_SIZE):
for y in range(MAZE_SIZE): self.new_cell(x, y)
# Regenerate if the hero is trapped. This can only reach
# maximum recursion depth is there's a flaw with the system's entropy.
room, visited = [(MIDDLE, MIDDLE)], set()
while room:
bit = room.pop()
if bit not in visited:
if not self.isdisplayed(*bit): break
visited.add(bit)
for x, y in around(*bit):
if self.map[x][y] == EMPTY: room.append((x, y))
else:
self.new_map()
self.map[MIDDLE][MIDDLE] = HERO
self.destx = self.desty = MIDDLE
self.stepx = self.stepy = 0
def add_enemy(self):
"""Add enough enemies."""
self.enemies = [e for e in self.enemies if e.alive]
walls = [(i, j) for i in self.rangex for j in self.rangey
if self.map[i][j] == WALL]
plums = [e for e in self.enemies if e.color == 'Plum' and e.awake]
@ -157,41 +121,24 @@ 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 ADJACENTS):
if all(self.map[x + a][y + b] == WALL for a, b in ADJACENT_GRIDS):
continue
enemy = new_enemy(self, x, y)
self.enemies.append(enemy)
if plum is None or not plum.clone(enemy): walls.remove((x, y))
if plum is None or not plum.clone(enemy):
walls.remove((x, y))
else:
self.map[x][y] = WALL
def get_pos(self, x, y):
"""Return coordinate of the center of the grid (x, y)."""
return (self.centerx + (x - MIDDLE)*self.distance,
self.centery + (y - MIDDLE)*self.distance)
def get_grid(self, x, y):
"""Return the grid containing the point (x, y)."""
return (MIDDLE + round((x-self.centerx) / self.distance),
MIDDLE + round((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)
def get_color(self):
"""Return color of a grid."""
return choice(TANGO_VALUES)[0] if self.glitch > 0 else FG_COLOR
def draw(self):
"""Draw the maze."""
self.surface.fill(BG_COLOR)
@ -201,7 +148,7 @@ class Maze:
if self.map[i][j] != WALL: continue
x, y = self.get_pos(i, j)
square = regpoly(4, self.distance / SQRT2, pi / 4, x, y)
fill_aapolygon(self.surface, square, self.get_color())
fill_aapolygon(self.surface, square, FG_COLOR)
for enemy in self.enemies: enemy.draw()
if not self.hero.dead: self.hero.draw()
@ -216,46 +163,47 @@ class Maze:
x = int((self.centerx-self.x) * 2 / self.distance)
y = int((self.centery-self.y) * 2 / self.distance)
if x == y == 0: return
for enemy in self.enemies:
if self.map[enemy.x][enemy.y] == ENEMY:
self.map[enemy.x][enemy.y] = EMPTY
for enemy in self.enemies: self.map[enemy.x][enemy.y] = EMPTY
self.map[MIDDLE][MIDDLE] = EMPTY
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
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.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 not self.isdisplayed(enemy.x, enemy.y):
if enemy.x not in self.rangex or enemy.y not in self.rangey:
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
for i in range(CELL_WIDTH): self.map[i].rotate(-self.rotatey)
for i in range(MAZE_SIZE): self.new_cell(0, i)
for i in range(CELL_WIDTH): self.map[i].rotate(self.rotatey)
for _ in range(CELL_WIDTH): self.map.pop()
self.map.extend(new_column())
for i in range(-CELL_WIDTH, 0):
self.map[i].rotate(self.rotatey)
if abs(self.rotatey) == CELL_WIDTH:
self.rotatey = 0
self.map.rotate(-self.rotatex)
for i in range(MAZE_SIZE): self.new_cell(i, 0)
self.map.rotate(self.rotatex)
for i in range(MAZE_SIZE):
b, c = getrandbits(1), (i-1)*CELL_WIDTH + self.rotatex
for j, grid in enumerate(new_cell(b)):
for k in range(ROAD_WIDTH):
self.map[c + k][LAST_ROW + j] = grid
c += ROAD_WIDTH
for j, grid in enumerate(new_cell(b, False)):
for k in range(ROAD_WIDTH):
self.map[c + k][LAST_ROW + j] = grid
def get_distance(self, x, y):
"""Return the distance from the center of the maze to the point
@ -265,84 +213,83 @@ class Maze:
def hit_hero(self, wound, color):
"""Handle the hero when he loses HP."""
if color == 'Orange':
# If called by close-range attack, this is FPS-dependant, although
# in playable FPS (24 to infinity), the difference within 2%.
self.hero.next_heal = abs(self.hero.next_heal * (1 - wound))
elif choice(ENEMIES) == color:
self.hero.next_heal = -1.0 # what doesn't kill you heals you
if color == 'Butter' or color == 'ScarletRed':
wound *= ENEMY_HP
elif color == 'Chocolate':
self.hero.highness += wound
wound = 0
elif color == 'SkyBlue':
self.next_move = max(self.next_move, 0) + wound*1000
wound = 0
if wound and sum(self.hero.wounds) < MAX_WOUND:
self.hero.wounds[-1] += wound
fx = (uniform(0, sum(self.enemy_weights.values()))
< self.enemy_weights[color])
if (color == 'Butter' or color == 'ScarletRed') and fx:
self.hero.wound += wound * 2.5
elif color == 'Orange' and fx:
self.hero.next_heal = max(self.hero.next_heal, 0) + wound*1000
elif color == 'SkyBlue' and fx:
self.next_move = max(self.next_move, 0) + wound*1000
else:
self.hero.wound += wound
if self.enemy_weights[color] + wound < MAXW:
self.enemy_weights[color] += wound
if self.hero.wound > HERO_HP and not self.hero.dead: self.lose()
def slash(self):
"""Handle close-range attacks."""
for enemy in self.enemies: enemy.slash()
if not self.hero.spin_queue: return
for enemy in filter(lambda e: e.awake, self.enemies):
d = self.slashd - enemy.distance
killist = []
for i, enemy in enumerate(self.enemies):
d = self.slashd - enemy.get_distance()
if d > 0:
wound = d * SQRT2 / self.distance
if self.next_slashfx <= 0:
play(SFX_SLASH_ENEMY, enemy.x, enemy.y, wound)
play(self.sfx_slash, wound, enemy.get_angle())
self.next_slashfx = ATTACK_SPEED
enemy.hit(wound / self.hero.spin_speed)
if enemy.wound >= ENEMY_HP:
self.score += enemy.wound
enemy.die()
killist.append(i)
for i in reversed(killist): self.enemies.pop(i)
self.add_enemy()
def track_bullets(self):
"""Handle the bullets."""
self.bullets.extend(self.hero.shots)
if (self.hero.firing and not self.hero.slashing
and self.hero.next_strike <= 0):
self.hero.next_strike = ATTACK_SPEED
self.bullets.append(Bullet(self.surface, self.x, self.y,
self.hero.angle, 'Aluminium'))
fallen = []
block = (self.hero.spin_queue and self.hero.next_heal < 0
block = (self.hero.spin_queue and self.hero.next_heal <= 0
and self.hero.next_strike > self.hero.spin_queue / self.fps)
for i, bullet in enumerate(self.bullets):
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.isdisplayed(gridx, gridy):
if wound < 0:
fallen.append(i)
elif bullet.color == 'Aluminium':
active_enemies = [e for e in self.enemies if e.awake]
if self.map[gridx][gridy] == WALL and self.next_move <= 0:
x = MIDDLE + round2((bullet.x-self.x) / self.distance)
y = MIDDLE + round2((bullet.y-self.y) / self.distance)
if self.map[x][y] == WALL and self.next_move <= 0:
fallen.append(i)
if not active_enemies: continue
self.glitch = wound * 1000
enemy = new_enemy(self, gridx, gridy)
enemy.awake = True
self.map[gridx][gridy] = ENEMY
play(SFX_SPAWN, enemy.x, enemy.y)
enemy.hit(wound)
self.enemies.append(enemy)
continue
for enemy in active_enemies:
if bullet.get_distance(*enemy.pos) < self.distance:
for j, enemy in enumerate(self.enemies):
if not enemy.awake: continue
x, y = enemy.get_pos()
if bullet.get_distance(x, y) < self.distance:
enemy.hit(wound)
if enemy.wound >= ENEMY_HP:
self.score += enemy.wound
enemy.die()
self.add_enemy()
play(bullet.sfx_hit, gridx, gridy, wound)
self.enemies.pop(j)
play(bullet.sfx_hit, wound, bullet.angle)
fallen.append(i)
break
elif bullet.get_distance(self.x, self.y) < self.distance:
if block:
self.hero.next_strike = (abs(self.hero.spin_queue/self.fps)
+ ATTACK_SPEED)
play(SFX_MISSED, gain=wound)
play(bullet.sfx_missed, wound, bullet.angle + pi)
else:
self.hit_hero(wound, bullet.color)
play(bullet.sfx_hit, gain=wound)
play(bullet.sfx_hit, wound, bullet.angle + pi)
fallen.append(i)
for i in reversed(fallen): self.bullets.pop(i)
@ -360,75 +307,32 @@ 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:
if (max(abs(herox - x), abs(heroy - y)) * 2 < self.distance
and enemy.awake):
return 0.0
return vx or vy
def expos(self, x, y):
"""Return position of the given coordinates in rounded percent."""
cx = len(self.rangex)*50 + (x - self.centerx)/self.distance*100
cy = len(self.rangey)*50 + (y - self.centery)/self.distance*100
return round(cx), round(cy)
def update_export(self, forced=False):
"""Update the maze's data export and return the last record."""
if self.next_export > 0 and not forced or self.hero.dead: return
export = defaultdict(list)
export['s'] = self.get_score()
if self.next_move <= 0:
for y in self.rangey:
export['m'].append(''.join(
COLORS[self.get_color()] if self.map[x][y] == WALL else '0'
for x in self.rangex))
x, y = self.expos(self.x, self.y)
export['h'] = [
COLORS[self.hero.get_color()], x, y, deg(self.hero.angle),
int(self.hero.next_strike <= 0), int(self.hero.next_heal <= 0)]
for enemy in self.enemies:
if enemy.isunnoticeable(): continue
x, y = self.expos(*enemy.pos)
color, angle = COLORS[enemy.get_color()], deg(enemy.angle)
export['e'].append([color, x, y, angle])
for bullet in self.bullets:
x, y = self.expos(bullet.x, bullet.y)
color, angle = COLORS[bullet.get_color()], deg(bullet.angle)
if color != '0': export['b'].append([color, x, y, angle])
if self.next_export <= 0:
export['t'] = round(self.export_rate - self.next_export)
self.export.append(export)
self.next_export = self.export_rate
return export
def update(self, fps):
"""Update the maze."""
self.fps = fps
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
dx = self.is_valid_move(vx=self.vx)
self.centerx += dx
dy = self.is_valid_move(vy=self.vy)
self.centery += dy
self.next_move -= 1000 / fps
self.glitch -= 1000 / fps
self.next_slashfx -= 1000 / fps
self.next_export -= 1000 / fps
self.next_move -= 1000.0 / self.fps
self.next_slashfx -= 1000.0 / self.fps
self.rotate()
if self.vx or self.vy or self.hero.firing or self.hero.slashing:
if dx or dy:
for enemy in self.enemies: enemy.wake()
for bullet in self.bullets: bullet.place(self.vx, self.vy)
for bullet in self.bullets: bullet.place(dx, dy)
for enemy in self.enemies: enemy.update()
self.track_bullets()
if not self.hero.dead:
self.hero.update(fps)
self.slash()
if self.hero.wound >= HERO_HP: self.lose()
self.update_export()
self.track_bullets()
def resize(self, size):
"""Resize the maze."""
@ -447,84 +351,30 @@ class Maze:
self.rangey = list(range(MIDDLE - h, MIDDLE + h + 1))
self.slashd = self.hero.R + self.distance/SQRT2
def set_step(self, check=(lambda x, y: True)):
"""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:
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 False
# Shoot WALL and ENEMY instead
if self.map[self.destx][self.desty] != EMPTY:
self.stepx = self.stepy = 0
return True
# Forest Fire algorithm
queue, visited = deque([(self.destx, self.desty)]), set()
while queue:
x, y = queue.pop()
if (x, y) in visited: continue
visited.add((x, y))
dx, dy = MIDDLE - x, MIDDLE - y
if dx**2 + dy**2 <= 2:
# Succeeded on finding a path
self.stepx, self.stepy = dx, dy
return False
for i, j in around(x, y):
if self.map[i][j] == EMPTY and check(i, j):
queue.appendleft((i, j))
# 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."""
return (self.vx**2+self.vy**2)**0.5*self.fps > HERO_SPEED*self.distance
def dump_records(self):
"""Dump JSON records."""
if self.export_dir:
with open(json_rec(self.export_dir), 'w') as f:
json.dump(self.export, f, separators=JSON_SEPARATORS)
def lose(self):
"""Handle loses."""
self.hero.dead = True
self.hero.wound = HERO_HP
self.hero.slashing = self.hero.firing = False
self.destx = self.desty = MIDDLE
self.stepx = self.stepy = 0
self.vx = self.vy = 0.0
play(SFX_LOSE)
self.dump_records()
play(self.sfx_lose)
def reinit(self):
"""Open new game."""
self.centerx, self.centery = self.w / 2, self.h / 2
self.score, self.export = INIT_SCORE, []
self.new_map()
self.centerx, self.centery = self.w / 2.0, self.h / 2.0
self.score = INIT_SCORE
self.map = deque()
for _ in range(MAZE_SIZE): self.map.extend(new_column())
self.vx = self.vy = 0.0
self.rotatex = self.rotatey = 0
self.bullets, self.enemies = [], []
self.enemy_weights = {color: MINW for color in ENEMIES}
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.next_move = self.next_slashfx = 0.0
self.hero.next_heal = self.hero.next_strike = 0
self.hero.slashing = self.hero.firing = self.hero.dead = False
self.hero.spin_queue = self.hero.wound = 0.0
self.hero.wounds = deque([0.0])

View File

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
# misc.py - module for miscellaneous functions
# Copyright (C) 2017-2020 Nguyễn Gia Phong
# Copyright (C) 2017, 2018 Nguyễn Gia Phong
#
# This file is part of Brutal Maze.
#
@ -18,17 +19,16 @@
__doc__ = 'Brutal Maze module for miscellaneous functions'
from datetime import datetime
from itertools import chain
from math import cos, degrees, pi, sin
from os import path
from random import shuffle
from math import degrees, cos, sin, pi
from random import uniform
import pygame
from palace import Buffer, Source
from pygame.gfxdraw import aapolygon, filled_polygon
from pygame.gfxdraw import filled_polygon, aapolygon
from .constants import ADJACENTS, CORNERS, MIDDLE
def round2(number):
"""Round a number to an int."""
return int(round(number))
def randsign():
@ -37,9 +37,9 @@ def randsign():
def regpoly(n, R, r, x, y):
"""Return pointlist of a regular n-gon with circumradius of R,
center point I(x, y) and corner A that angle of vector IA is r
(in radians).
"""Return the pointlist of the regular polygon with n sides,
circumradius of R, the center point I(x, y) and one point A make the
vector IA with angle r (in radians).
"""
r %= pi * 2
angles = [r + pi*2*side/n for side in range(n)]
@ -47,7 +47,7 @@ def regpoly(n, R, r, x, y):
def fill_aapolygon(surface, points, color):
"""Draw a filled polygon with anti-aliased edges onto a surface."""
"""Draw a filled polygon with anti aliased edges onto a surface."""
aapolygon(surface, points, color)
filled_polygon(surface, points, color)
@ -58,43 +58,44 @@ def sign(n):
def deg(x):
"""Convert angle x from radians to degrees,
casted to a nonnegative integer.
"""Convert angle x from radians to degrees casted to a nonnegative
integer.
"""
return round((lambda a: a if a > 0 else a + 360)(degrees(x)))
return round2((lambda a: a if a > 0 else a + 360)(degrees(x)))
def join(iterable, sep=' ', end='\n'):
"""Return a string which is the concatenation of string
representations of objects in the iterable, separated by sep.
def cosin(x):
"""Return the sum of cosine and sine of x (measured in radians)."""
return cos(x) + sin(x)
end is appended to the resulting string.
def choices(d):
"""Choose a random key from a dict which has values being relative
weights of the coresponding keys.
"""
return sep.join(map(str, iterable)) + end
population, weights = tuple(d.keys()), tuple(d.values())
cum_weights = [weights[0]]
for weight in weights[1:]: cum_weights.append(cum_weights[-1] + weight)
num = uniform(0, cum_weights[-1])
for i, w in enumerate(cum_weights):
if num <= w: return population[i]
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 play(sound, volume=1.0, angle=None):
"""Play a pygame.mixer.Sound at the given volume."""
if pygame.mixer.get_init() is None: return
if pygame.mixer.find_channel() is None:
pygame.mixer.set_num_channels(pygame.mixer.get_num_channels() + 1)
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]))
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()
source.spatialize = True
source.position = x, -y, 0
source.gain = gain
return source
channel = sound.play()
if angle is None:
channel.set_volume(volume)
else:
delta = cos(angle)
volumes = [volume * (1-delta), volume * (1+delta)]
for i, v in enumerate(volumes):
if v > 1:
volumes[i - 1] += v - 1
volumes[i] = 1.0
sound.set_volume(1.0)
channel.set_volume(*volumes)

View File

@ -6,12 +6,10 @@ Maximum FPS: 60
[Sound]
Muted: no
# Volume must be between 0.0 and 1.0.
# Volume must be between 0.0 and 1.0
Music volume: 1.0
[Control]
# Touch-friendly control
Touch: no
# 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):
@ -20,24 +18,18 @@ Touch: no
New game: F2
Toggle pause: p
Toggle mute: m
Move left: a
Move right: d
Move up: w
Move down: s
Move left: Left
Move right: Right
Move up: Up
Move down: Down
Long-range attack: Mouse1
Close-range attack: Mouse3
[Record]
# Directory to write record of game states, leave blank to disable.
Directory:
# Number of snapshots per second. This is preferably from 3 to 60.
Frequency: 30
[Server]
# Enabling remote control will disable control via keyboard and mouse.
Enable: no
Host: localhost
Port: 42069
Port: 8089
# Timeout on blocking socket operations, in seconds.
Timeout: 1.0
# Disable graphics and sound (only if socket server is enabled).

View File

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
# characters.py - module for weapon classes
# Copyright (C) 2017-2020 Nguyễn Gia Phong
# Copyright (C) 2017, 2018 Nguyễn Gia Phong
#
# This file is part of Brutal Maze.
#
@ -20,9 +21,9 @@ __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 .misc import fill_aapolygon, regpoly
from .constants import (BULLET_LIFETIME, SFX_SHOT_ENEMY, SFX_SHOT_HERO,
SFX_MISSED, BULLET_SPEED, ENEMY_HP, TANGO, BG_COLOR)
from .misc import regpoly, fill_aapolygon
class Bullet:
@ -34,7 +35,8 @@ class Bullet:
angle (float): angle of the direction the bullet pointing (in radians)
color (str): bullet's color name
fall_time (int): time until the bullet fall down
sfx_hit (str): sound effect indicating target was hit
sfx_hit (pygame.mixer.Sound): sound effect indicating target was hit
sfx_missed (pygame.mixer.Sound): sound effect indicating a miss shot
"""
def __init__(self, surface, x, y, angle, color):
self.surface = surface
@ -44,13 +46,14 @@ class Bullet:
self.sfx_hit = SFX_SHOT_ENEMY
else:
self.sfx_hit = SFX_SHOT_HERO
self.sfx_missed = SFX_MISSED
def update(self, fps, distance):
"""Update the bullet."""
s = distance * BULLET_SPEED / fps
self.x += s * cos(self.angle)
self.y += s * sin(self.angle)
self.fall_time -= 1000 / fps
self.fall_time -= 1000.0 / fps
def get_color(self):
"""Return current color of the enemy."""
@ -73,22 +76,3 @@ 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)
retired (bool): flag indicating if the target is retired
"""
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

View File

@ -9,7 +9,7 @@ namespace BrutalmazeClient
static void Main(string[] args)
{
const string host = "localhost";
const int port = 42069;
const int port = 8089;
Socket client_socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
client_socket.Connect(host, port);
Random rnd = new Random();
@ -101,4 +101,4 @@ namespace BrutalmazeClient
client_socket.Close();
}
}
}
}

View File

@ -1,72 +1,40 @@
#!/usr/bin/env python3
from contextlib import closing, suppress
from math import atan2, degrees, inf
from random import randrange, shuffle
from math import inf, atan2, degrees
from socket import socket
from random import randint
AROUND = [5, 2, 1, 0, 3, 6, 7, 8]
clientsocket = socket()
clientsocket.connect(('localhost', 8089))
while True:
length = clientsocket.recv(7).decode()
if length in ('', '0000000'): break # connection closed or game over
l = clientsocket.recv(int(length)).decode().split()
data = iter(l)
nh, ne, nb, score = (int(next(data)) for _ in range(4))
maze = [[bool(int(i)) for i in next(data)] for _ in range(nh)]
hp = (lambda c: 0 if c == 48 else 123 - c)(ord(next(data)))
hx, hy, ha = (int(next(data)) for _ in range(3))
attackable, mobility = (bool(int(next(data))) for _ in range(2))
shortest = angle = inf
for _ in range(ne):
p = (lambda c: 0 if c == 48 else 3 - (c-97)%3)(ord(next(data)))
x, y, a = (int(next(data)) for _ in range(3))
d = ((x - hx)**2 + (y - hy)**2)**0.5
if d < shortest:
shortest = d
b = degrees(atan2(y - hy, x - hx))
angle = round(b + 360 if b < 0 else b)
# calculate to dodge from bullets is a bit too much for an example
def get_moves(y, x):
"""Return tuple of encoded moves."""
return ((y - 1, x - 1), (y - 1, x), (y - 1, x + 1), # noqa
(y, x - 1), (y, x), (y, x + 1), # noqa
(y + 1, x - 1), (y + 1, x), (y + 1, x + 1)) # noqa
def is_wall(maze, y, x):
"""Return weather the cell (x, y) is wall."""
return maze[y][x] != '0'
def get_move(maze, move):
"""Return an outstanding move."""
moves, around = get_moves(len(maze) // 2, len(maze[0]) // 2), AROUND[:]
if move != 4 and not is_wall(maze, *moves[move]): return move
if move == 4:
shuffle(around)
move = 4 if ne and hp > 2 else 0
if angle == inf:
angle, attack = ha, 0
elif not attackable:
attack = 0
elif shortest < 160 or hp < 3:
move, angle, attack = 8, ha, 2
else:
idx = AROUND.index(move)
around.sort(key=lambda i: abs(abs(abs(AROUND.index(i)-idx)-4)-4))
for move in around:
idx = AROUND.index(move)
if all(not is_wall(maze, *moves[i])
for i in (move, AROUND[idx - 1], AROUND[idx - 7])):
return move
return 4
with suppress(KeyboardInterrupt), closing(socket()) as sock:
sock.connect(('localhost', 42069))
move = 4
while True:
length = sock.recv(7).decode()
# connection closed or game over
if length in ('', '0000000'): break
data = iter(sock.recv(int(length)).decode().split())
nh, ne, nb, score = (int(next(data)) for i in range(4))
maze = [list(next(data)) for i in range(nh)]
hp = (lambda c: 0 if c == 48 else 123 - c)(ord(next(data)))
hx, hy, ha = (int(next(data)) for i in range(3))
attackable, heal = (bool(int(next(data))) for i in range(2))
if nh: move = get_move(maze, move)
angle, shortest = ha, inf
for i in range(ne):
p = 3 - (ord(next(data)) - 97)%3
x, y, a = (int(next(data)) for j in range(3))
d = ((x - hx)**2 + (y - hy)**2)**0.5
if d < shortest:
shortest = d
b = degrees(atan2(y - hy, x - hx))
angle = round(b + 360 if b < 0 else b)
if hp <= 2 and heal:
move, attack = 4, 2
elif not ne:
attack = randrange(3) * (attackable and hp > 2)
elif shortest < 160:
move, angle, attack = AROUND[round(angle/45 - 0.5) - 4], ha, 2
else:
attack = 1
sock.send(f'{move} {angle} {attack}'.encode())
attack = 1
clientsocket.send('{} {} {}'.format(move, angle, attack).encode())
clientsocket.close()

View File

@ -1,20 +0,0 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

View File

@ -1,35 +0,0 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

View File

@ -1 +0,0 @@
sphinx >= 3.*

View File

@ -1,101 +0,0 @@
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL v3.0
// Copyright (C) 2018 Nguyễn Gia Phong
const PERIGON = Math.PI * 2;
const TANGO = {'a': '#fce94f', 'b': '#edd400', 'c': '#c4a000', // Butter
'd': '#fcaf3e', 'e': '#f57900', 'f': '#ce5c00', // Orange
'g': '#e9b96e', 'h': '#c17d11', 'i': '#8f5902', // Chocolate
'j': '#8ae234', 'k': '#73d216', 'l': '#4e9a06', // Chameleon
'm': '#729fcf', 'n': '#3465a4', 'o': '#204a87', // Sky Blue
'p': '#ad7fa8', 'q': '#75507b', 'r': '#5c3566', // Plum
's': '#ef2929', 't': '#cc0000', 'u': '#a40000', // Scarlet Red
'v': '#eeeeec', 'w': '#d3d7cf', 'x': '#babdb6', // Aluminium
'y': '#888a85', 'z': '#555753', '0': '#2e3436'};
var mw, mh; // maze width and height in grids
// Resize canvas to fit page.
function resizeCanvas(canvas) {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
// Draw on the given canvas a c-colored regular n-gon with circumradius of R,
// center point I(x, y) and corner A that angle of vector IA is r (in radians).
function drawPolygon(canvas, n, c, x, y, r, R) {
var ctx = canvas.getContext('2d');
ctx.beginPath();
r = r * Math.PI / 180 % PERIGON;
ctx.moveTo(x + R*Math.cos(r), y + R*Math.sin(r));
for (var i = 1; i < n; i++) {
r += PERIGON / n;
ctx.lineTo(x + R*Math.cos(r), y + R*Math.sin(r));
}
ctx.closePath();
ctx.fillStyle = TANGO[c];
ctx.fill();
}
// Draw the maze, hero, enemies and bullets of the given frame.
function drawFrame(canvas, frame) {
var cw = canvas.width, ch = canvas.height;
var maze = frame.m;
if (maze) {
mw = maze[0].length;
mh = maze.length;
}
unit = Math.min(cw / (mw + 1), ch / (mh + 1));
eR = unit / Math.sqrt(2);
hR = unit * 2 / Math.pow(27, 0.25);
bR = unit / 4;
var hero = frame.h;
var x0 = cw/2 - hero[1]/100*unit, y0 = ch/2 - hero[2]/100*unit;
canvas.getContext('2d').clearRect(0, 0, cw, ch);
if (maze)
for (var row = 0; row < mh; row++)
for (var column = 0; column < mw; column++)
if (maze[row][column] != '0') {
var x = x0 + column*unit, y = y0 + row*unit;
var ctx = canvas.getContext('2d');
ctx.fillStyle = TANGO[maze[row][column]];
ctx.fillRect(x, y, unit + 1, unit + 1);
}
if (frame.e)
for (let enemy of frame.e)
drawPolygon(canvas, 4, enemy[0], x0 + enemy[1]/100*unit,
y0 + enemy[2]/100*unit, enemy[3], eR);
drawPolygon(canvas, 4 - hero[5], hero[0], cw / 2, ch / 2, hero[3], hR);
if (frame.b)
for (let bullet of frame.b)
drawPolygon(canvas, 5, bullet[0], x0 + bullet[1]/100*unit,
y0 + bullet[2]/100*unit, bullet[3], bR);
}
// Recursive function to loop with window.setTimeout.
function playRecord(canvas, record, index) {
if (index >= record.length) {
document.title = 'Brutal Maze record player';
document.getElementById('input').style.display = '';
return;
}
frame = record[index];
document.title = `Score: ${frame.s}`;
setTimeout(function () {
drawFrame(canvas, frame);
playRecord(canvas, record, index + 1);
}, frame.t);
}
// Fetch JSON record and parse to playRecord.
function playJSON() {
fetch(document.getElementById('record').value).then(function(res) {
return res.json();
}).then(function(record) {
document.getElementById('input').style.display = 'none';
playRecord(document.getElementById('canvas'), record, 0);
}).catch(error => alert(error));
}
// @license-end

View File

@ -1,34 +0,0 @@
html, body {
width: 100%;
height: 100%;
margin: 0px;
border: 0;
overflow: hidden;
display: block;
background-color: #2e3436;
}
canvas {
left: 0px;
top: 0px;
background-color: #2e3436;
}
div {
display: flex;
justify-content: center;
}
input {
border: none;
border-radius: 4px;
background-color: #eeeeec;
color: #2e3436;
font-size: 1.25em;
padding: 0.3em;
margin: 0.2em 0.1em;
}
input[type=text] {
width: 50vw;
}

View File

@ -1,29 +0,0 @@
<!DOCTYPE html>
<html>
<meta charset='utf-8'>
<title>Brutal Maze record player</title>
<link rel='icon' type='image/png' href='_static/favicon.ico'>
<link rel='stylesheet' type='text/css' href='_static/recplayer.css'>
<script src='_static/brutalma.js'></script>
<body>
<div id='input'>
<input id='record' type='text' name='record' value='record.json'>
<input id='button' type='button' value='Play JSON record'>
</div>
<canvas id='canvas' width='640' height='480'></canvas>
<script>
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL v3.0
var canvas = document.getElementById('canvas');
resizeCanvas(canvas);
window.onresize = function() {resizeCanvas(canvas)};
document.getElementById('record').onkeypress = function (event) {
if (event.key == 'Enter') {
playJSON();
}
};
document.getElementById('button').onclick = playJSON;
// @license-end
</script>
</body>
</html>

View File

@ -1,61 +0,0 @@
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))
# -- Project information -----------------------------------------------------
project = 'Brutal Maze'
copyright = '2017-2020, Nguyễn Gia Phong' # noqa
author = 'Nguyễn Gia Phong'
# The full version, including alpha/beta/rc tags
release = '0.9.4'
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = []
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = []
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'alabaster'
html_theme_options = {'fixed_sidebar': True, 'show_relbars': True}
html_logo = 'icon.svg'
html_favicon = 'favicon.ico'
html_extra_path = ['record.json']
html_additional_pages = {'recplayer': 'recplayer.html'}
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']

View File

@ -1,129 +0,0 @@
Configuration
=============
Configuration Files
-------------------
At the time of writing, this is the default configuration file:
.. code-block:: ini
[Graphics]
Screen width: 640
Screen height: 480
# FPS should not be greater than refresh rate.
Maximum FPS: 60
[Sound]
Muted: no
# Volume must be between 0.0 and 1.0.
Music volume: 1.0
# Use space music background, which sounds cold and creepy.
Space theme: no
[Control]
# Touch-friendly control
Touch: no
# 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):
# http://www.pygame.org/docs/ref/key.html
# Key combinations are not supported.
New game: F2
Toggle pause: p
Toggle mute: m
Move left: a
Move right: d
Move up: w
Move down: s
Long-range attack: Mouse1
Close-range attack: Mouse3
[Record]
# Directory to write record of game states, leave blank to disable.
Directory:
# Number of snapshots per second. This is preferably from 3 to 60.
Frequency: 30
[Server]
# Enabling remote control will disable control via keyboard and mouse.
Enable: no
Host: localhost
Port: 42069
# Timeout on blocking socket operations, in seconds.
Timeout: 1.0
# Disable graphics and sound (only if socket server is enabled).
Headless: no
By default, Brutal Maze also then tries to read site (system-wide)
and user configuration.
Site Config File Location
^^^^^^^^^^^^^^^^^^^^^^^^^
* Apple macOS: ``/Library/Application Support/brutalmaze/settings.ini``
* Other Unix-like: ``$XDG_CONFIG_DIRS/brutalmaze/settings.ini`` or
``/etc/xdg/brutalmaze/settings.ini``
* Microsoft Windows:
* XP: ``C:\Documents and Settings\All Users\Application Data\brutalmaze\settings.ini``
* Vista: Fail! (``C:\ProgramData`` is a hidden *system* directory,
however if you use Windows Vista, please file an issue telling us
which error you receive)
* 7 and above: ``C:\ProgramData\brutalmaze\settings.ini``
User Config File Location
^^^^^^^^^^^^^^^^^^^^^^^^^
* Apple macOS: ``~/Library/Application Support/brutalmaze/settings.ini``
* Other Unix-like: ``$XDG_CONFIG_HOME/brutalmaze/settings.ini`` or
``~/.config/brutalmaze/settings.ini``
* Microsoft Windows (roaming is not supported until someone requests):
* XP: ``C:\Documents and Settings\<username>\Application Data\brutalmaze\settings.ini``
* Vista and above: ``C:\Users\<username>\AppData\Local\brutalmaze\settings.ini``
Command-Line Arguments
----------------------
.. code-block:: console
$ brutalmaze --help
usage: brutalmaze [options]
optional arguments:
-h, --help show this help message and exit
-v, --version show program's version number and exit
--write-config [PATH]
write default config and exit, if PATH not specified use stdout
-c PATH, --config PATH
location of the configuration file
-s X Y, --size X Y the desired screen size
-f FPS, --max-fps FPS
the desired maximum FPS
--mute, -m mute all sounds
--unmute unmute sound
--music-volume VOL between 0.0 and 1.0
--space-music use space music background
--default-music use default music background
--touch enable touch-friendly control
--no-touch disable touch-friendly control
--record-dir DIR directory to write game records
--record-rate SPF snapshots of game state per second
--server enable server
--no-server disable server
--host HOST host to bind server to
--port PORT port for server to listen on
-t TIMEOUT, --timeout TIMEOUT
socket operations timeout in seconds
--head run server with graphics and sound
--headless run server without graphics or sound
First, Brutal Mazes read the default settings, then it try to read site and
user config whose locations are shown above. These files are listed as fallback
of the ``--config`` option and their contents are fallback for other options
(if they are absent default values are used instead). We don't support control
configuration via CLI because that is unarguably ugly.
If ``--config`` option is set, Brutal Maze parse it before other command-line
options. Later-read preferences will override previous ones.

View File

@ -1,109 +0,0 @@
Copying
=======
This listing is our best-faith, hard-work effort at accurate attribution,
sources, and licenses for everything in Brutal Maze. If you discover
an asset/contribution that is incorrectly attributed or licensed,
please contact us immediately. We are happy to do everything we can
to fix or remove the issue.
License
-------
Brutal Maze's source code and its icon are released under GNU Affero General
Public License version 3 or later. This means if you run a modified program on
a server and let other users communicate with it there, your server must also
allow them to download the source code corresponding to the modified version
running there.
.. image:: https://www.gnu.org/graphics/agplv3-155x51.png
:target: https://www.gnu.org/licenses/agpl.html
Other creative works retain their original licenses as listed below.
Color Palette
-------------
Brutal Maze uses the Tango color palette by `the Tango desktop project`_
to draw all of its graphics. The palette is released to the Public Domain.
Sound Effects
-------------
Sound Effects Artist---Tobiasz 'unfa_' Karoń
* License: `CC BY 3.0`_
* brutalmaze/soundfx/heart.ogg (original__)
__ https://freesound.org/s/217456
Sound Effects Artist---HappyParakeet_
* License: `CC0 1.0`_
* brutalmaze/soundfx/lose.ogg (original__)
__ https://freesound.org/s/398068
Sound Effects Artist---jameswrowles_
* License: `CC0 1.0`_
* brutalmaze/soundfx/missed.ogg (original__)
__ https://freesound.org/s/380641
Sound Effects Artist---MrPork_
* License: `CC0 1.0`_
* brutalmaze/soundfx/noise.ogg (original__)
__ https://freesound.org/s/257449
Sound Effects Artist---suspensiondigital_
* License: `CC0 1.0`_
* brutalmaze/soundfx/shot-enemy.ogg (original__)
__ https://freesound.org/s/389704
Sound Effects Artist---gusgus26_
* License: `CC0 1.0`_
* brutalmaze/soundfx/shot-hero.ogg (original__)
__ https://freesound.org/s/121188
Sound Effects Artist---braqoon_
* License: `CC0 1.0`_
* brutalmaze/soundfx/slash-enemy.ogg (original__)
__ https://freesound.org/s/161098
Sound Effects Artist---Qat_
* License: `CC0 1.0`_
* brutalmaze/soundfx/slash-hero.ogg (original__)
__ https://freesound.org/s/108333
Sound Effects Artist---pepingrillin_
* License: `CC0 1.0`_
* brutalmaze/soundfx/spawn.ogg (original__)
__ https://freesound.org/s/252083
.. _CC BY 3.0: https://creativecommons.org/licenses/by/3.0/legalcode
.. _CC0 1.0: https://creativecommons.org/publicdomain/zero/1.0/legalcode
.. _CC BY-SA 3.0: https://creativecommons.org/licenses/by-sa/3.0/legalcode
.. _the Tango desktop project: http://tango-project.org/
.. _unfa: https://freesound.org/people/unfa/
.. _HappyParakeet: https://freesound.org/people/HappyParakeet/
.. _jameswrowles: https://freesound.org/people/jameswrowles/
.. _MrPork: https://freesound.org/people/MrPork/
.. _suspensiondigital: https://freesound.org/people/suspensiondigital/
.. _gusgus26: https://freesound.org/people/gusgus26/
.. _braqoon: https://freesound.org/people/braqoon/
.. _Qat: https://freesound.org/people/Qat/
.. _pepingrillin: https://freesound.org/people/pepingrillin/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,116 +0,0 @@
Gameplay
========
Brutal Maze is a fast-paced hack and slash game which aims to bring players
a frustrating, horror-like experience. It tries to mimic real-life logic in
a way that truly represents our loneliness, mortality and helplessness
in the universe.
The game features a solitary hero in a trigon shape who got lost in world of
squares. Unlucky for per, the squares have no intention to let their visitor
leave in peace. Together, they form a greater being called The Maze.
Naturally, The Maze is dynamically and infinitely generated. As our poor hero
tries to find a way out, it releases its minions (we will call them *enemies*
from here) to stop per. Since The Maze *sees it all* and *knows it all*,
it keeps creating more and more enemies for the hero to fight. It also
keeps track of which type of squares can do most damages to our trigon,
in order to send out the most effective belligerents.
Your mission is to help the hero go the furthest distance possible,
while fighting those aggressive enemies. Extra information below will
give you a better understanding of what you fight and how you fight them.
Hero
----
The hero is a regular trigon_ in Aluminium color (from `the Tango palette`_).
Perse has the ability to attack and move (both horizontally and vertically)
simultaneously. However, close- and long-range attacks can't be stricken
at the same time. When swinging per blade, our hero may also avoid getting
damages caused by per enemies' bullets.
Like heroes in other hack and slash games, the trigon can heal too, but irony,
per HP recovery rate decreases as perse gets wounded. Been warned you have,
bravery will only give you regrets.
Enemies
-------
Enemies are put into hibernation and blend into The Maze at the time of their
creation. When the hero comes across, they become awake and show their
(physical) colors. Enemy of each color has an unique power as described below:
Butter
May strike critical hits.
Orange (also known as *Agent Orange*)
May prevent the hero from healing or blocking bullets.
Poisoned hero will be drawn as a square.
Chocolate (a.k.a. *MDMA in Disguise*):
May make the hero high and shoot uncontrollably.
Still, Chocolate is good for your health (and so is MDMA).
Chameleon
Invisible, only shows itself when attacking or being attacked.
Sky Blue (a.k.a. *Lightning Sky*)
May immobilize the hero. If this happen our hero can only see the enemies,
including Chameleons, on a blank background. What a blessing in disguise!
Plum (a.k.a. *Plum Wine*)
May replicate. Very quickly.
Scarlet Red (a.k.a. *Vampire's Eye*)
Moves faster and drains hero's HP.
The possibility that an enemy attack with its special power increases when it's
able to prove its effectiveness to The Maze. In other words, the more a kind
of enemy hit the hero, the more chance they *may* use their unique abilities.
Lucky for you, squares are unitasking so they have to stop moving to perform
attacks. This slows them down a bit, however the ones which fall off the
display will respawn elsewhere in The Maze.
Attacks
-------
In this game, attack's damage is contingent on the distance between the
attacker and its target. The closer they are, the more damage is caused.
There is at least an one-third-second delay between two attacks stricken
by any character.
Long-range Attacks
^^^^^^^^^^^^^^^^^^
While projectiles are often called *bullets* in the code and the documentation,
they are more similar to stones propelled by slingshots, as they don't fly very
far (about 6 times the width of an enemy). Those fired by enemies can fly
though walls but the ones shot by the hero turn the grid into a new enemy.
A bullet is counted as hitting the target when the distance between the center
of the two object is less than the circumradius of a cell.
Close-range Attacks
^^^^^^^^^^^^^^^^^^^
It is needless to explain any further on how this kind of attack works, so we
only provide the size of the characters for you to calculate when the strike
can wound the target. To do so, the attacker must *touch* its opponents, or
simplistically, the distance between the central points of the two characters
must not be any greater than the sum of their circumradiuses. Do the
calculations yourself, a square's side is a fifth of the walls', and covers the
same area as a trigon.
Specially, hero's closed-range attacks also block opponents' bullets.
If this happens, the hero won't be able to attack in the next turn.
Manual slashing
^^^^^^^^^^^^^^^
As the hero always follow the mouse, perse perform close-range attack
while doing so. Unlike the automatic ones, there isn't any delay between
two manual slashings.
.. _trigon:
https://www.pygame.org/docs/ref/gfxdraw.html#pygame.gfxdraw.aatrigon
.. _the Tango palette:
https://en.wikipedia.org/wiki/Tango_Desktop_Project#Palette

View File

@ -1,24 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!--
This work is licensed under the Creative Commons Attribution-ShareAlike 4.0
International License. To view a copy of this license, visit
http://creativecommons.org/licenses/by-sa/4.0/ or send a letter
to Creative Commons, PO Box 1866, Mountain View, CA 94042, USA.
-->
<svg xmlns="http://www.w3.org/2000/svg"
width="576" height="576" viewBox="-288 -288 576 576">
<title>Brutal Maze icon in Scalable Vector Graphics</title>
<path fill="#2e3436"
d="M 0 288
C 126.2 288, 196.3563 288, 242.1782 242.1782
C 288 196.3563, 288 126.2, 288 0
C 288 -126.2, 288 -196.3563, 242.1782 -242.1782
C 196.3563 -288, 126.2,-288, 0 -288
C -126.2 -288, -196.3563 -288, -242.1782 -242.1782
C -288 -196.3563, -288 -126.2, -288 0
C -288 126.2, -288 196.3563, -242.1782 242.1782
C -196.3563 288, -126.2 288, 0 288
Z"/>
<polygon points="-119.1174 -119.1174, 162.7174 -43.6, -43.6 162.7174"
style="fill:#eeeeec"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

View File

@ -1,37 +0,0 @@
Overview
========
Brutal Maze is a thrilling shoot 'em up game with minimalist art style.
.. image:: images/screenshot.png
Notable features:
* Being highly portable.
* Auto-generated and infinite maze.
* No binary data for drawing.
* Enemies with special abilities: stun, poison, camo, etc.
* Somewhat a realistic physic and logic system.
* Resizable game window in-game.
* Easily customizable via INI file format.
* Recordable in JSON (some kind of silent screencast).
* Remote control through TCP/IP socket (can be used in AI researching).
Table of Contents
-----------------
.. toctree::
:maxdepth: 2
install
config
gameplay
remote
copying
Record Player
-------------
.. raw:: html
<iframe src='recplayer.html' width=640 height=480></iframe>

View File

@ -1,38 +0,0 @@
Installation
============
Brutal Maze should run on Python version 3.6 and above.
If you're using an Unix-like operating system, you're likely
to have Python installed on your computer. Otherwise, you can
download it from python.org_.
The game also uses multiple third-party libraries, which is recommended to
be installed using ``pip``. There is a detailed documentation about getting
this package manager on pypa.io_.
Install from PyPI
-----------------
For convenience reasons, every release of Brutal Maze is uploaded to the Python
Package Index. To either install or upgrade, open your terminal (on Windows:
Command Prompt or PowerShell) and run::
pip install --user --upgrade brutalmaze
This requires the `the user scheme`_ scripts directory to be
in your environmental variable ``$PATH``.
Install from Source
-------------------
If you want to tweak the game or contribute, clone the git repository::
git clone https://git.disroot.org/McSinyx/brutalmaze.git
Then install it using ``pip``, like so::
pip install --user brutalmaze/
.. _python.org: https://www.python.org/downloads/
.. _pypa.io: https://pip.pypa.io/en/latest/installing/
.. _the user scheme: https://docs.python.org/3/install/index.html#alternate-installation-the-user-scheme

File diff suppressed because one or more lines are too long

View File

@ -1,250 +0,0 @@
Remote Control
==============
Brutal Maze provides a INET (i.e. IPv4), STREAM (i.e. TCP) socket server which
can be enabled in the config file or by adding the ``--server`` CLI flag.
After binding to the given host and port, it will wait for a client to connect.
Then, in each cycle of the loop, the server will send current details of
each object (hero, walls, enemies and bullets), wait for the client to process
the data and return instruction for the hero to follow. Since there is no EOT
(End of Transfer) on a socket, messages sent and received between server
and client must must be strictly formatted as explained below.
Server Output
-------------
First, the game will export its data to a byte sequence (which in this case,
is simply a ASCII string without null-termination) of the length :math:`l`.
Before sending the data to the client, the server would send the number
:math:`l` padded to 7 digits.
Below is the meta structure of the data::
<Map height (nh)> <Number of enemies (ne)> <Number of bullets (nb)> <Score>
<nh lines describing visible part of the maze>
<One line describing the hero>
<ne lines describing ne enemies>
<nb lines describing nb bullets>
The Maze
^^^^^^^^
Visible parts of the maze with the width :math:`n_w` and the height :math:`n_h`
are exported as a byte map of :math:`n_h` lines and :math:`n_w` columns.
Any character other than 0 represents a blocking *cell*, i.e. a wall.
To avoid floating point number in later description of other objects, each
cell has the width (and height) of 100, which means the top left corner of
the top left cell has the coordinates of :math:`(0, 0)` and the bottom right
vertex of the bottom right cell has the coordinates of
:math:`(100 n_w, 100 n_h)`.
The Hero
^^^^^^^^
6 properties of the hero are exported in one line,
separated by 1 space, in the following order:
:Color:
The current HP of the hero, as shown in in the later section.
:X-coordinate:
An integer within :math:`[0, 100 n_w]`.
:Y-coordinate:
An integer within :math:`[0, 100 n_h]`.
Note that the y-axis points up-side-down instead of pointing upward.
:Angle:
The direction the hero is pointing to in degrees,
cast to an integer from 0 to 360. Same note as above
(the unit circle figure might help you understand this easier).
:Can attack:
0 for *no* and 1 for *yes*.
:Can heal:
0 for *no* and 1 for *yes*.
.. image:: images/unit-circle.png
The Enemies
^^^^^^^^^^^
Each enemy exports these properties:
:Color:
The type and the current HP of the enemy, as shown in the table below.
:X-coordinate:
An integer within :math:`[0, 100 n_w]`.
:Y-coordinate:
An integer within :math:`[0, 100 n_h]`.
:Angle:
The direction the enemy is pointing to in degrees,
cast to a nonnegative integer.
To shorten the data, each color (in the Tango palette) is encoded to a
lowercase letter. Different shades of a same color indicating different HP
of the characters.
=========== ======== ======== ======== ======== ========
HP 5 4 3 2 1
=========== ======== ======== ======== ======== ========
Butter |fce94f| |edd400| |c4a000|
Orange |fcaf3e| |f57900| |ce5c00|
Chocolate |e9b96e| |c17d11| |8f5902|
Chameleon |8ae234| |73d216| |4e9a06|
Sky Blue |729fcf| |3465a4| |204a87|
Plum |ad7f8a| |75507b| |5c3566|
Scarlet Red |ef2929| |cc0000| |a40000|
Aluminium |eeeeec| |d3d7cf| |babdb6| |888a85| |555753|
=========== ======== ======== ======== ======== ========
.. note::
If a character shows up with color ``0``, it is safe to ignore it
since it is a dead body yet to be cleaned up.
Flying bullets
^^^^^^^^^^^^^^
Bullets also export 4 properties like enemies:
:Color:
The type and potential damage of the bullet (from 0.0 to 1.0),
encoded similarly to characters', except that aluminium bullets
only have 4 colors ``v``, ``w``, ``x`` and ``0``.
:X-coordinate:
An integer within :math:`[0, 100 n_w]`.
:Y-coordinate:
An integer within :math:`[0, 100 n_h]`.
:Angle:
The bullet's flying direction in degrees,
cast to a nonnegative integer.
Example
^^^^^^^
.. image:: images/screenshot.png
Above snapshot of the game is exported as:
.. code-block:: text
19 5 3 180
00000000000000000vvvv0000
v0000000000000000vvvv0000
v0000000000000000vvvv0000
v0000000000000000vvvv0000
vvvvvvvvvvvvvvvvvvvvv0000
vvvvvvvvvvvvvvvvvvvvv000v
vvvvvvvvvvvvvvvvvvvvv000v
vvvvvvvvvvvvvvvvvvvv00000
0000000000000000000000000
0000000000000000000000000
0000000000000000000000000
v000000000000000000000000
v000000000000000000000000
v000000000000000000000000
v000vvvvvvv000vvv0vvv0000
v000vvvvvvv000vvvvvvv0000
v000vvvvvvv000vvvvvvv0000
v000vvvvvvv000vvvvvvv0000
v000000vvvv000000vvvv0000
v 1267 975 47 0 1
p 1817 1050 45
g 1550 1217 45
a 2250 1194 45
p 2050 1017 45
e 1850 950 358
x 2126 1189 361
e 1541 1020 167
v 1356 1075 49
Client Output Format
--------------------
Every loop, the server receives no more than 7 bytes in the format of
``<Movement> <Angle> <Attack>``. Again, these values need to be
specially encoded.
Movement
^^^^^^^^
This is the most awkward one. As we can all imagine, there are nine different
directions for the hero to move. Were they represented as two-dimensional
vectors, at least three characters would be needed to describe such
a simple thing, e.g. ``1 0`` for :math:`m = (1, 0)`, and in the worst-case
scenario :math:`m = (-1, -1)`, we would need five: ``-1 -1``. 40 bits are used
to carry a four-bit piece of data, freaking insane, right? So instead,
we decided to *slightly* encode it like this:
========= ==== === =====
Direction Left Nil Right
========= ==== === =====
**Up** 0 1 2
**Nil** 3 4 5
**Down** 6 7 8
========= ==== === =====
Angle
^^^^^
Direction to point to hero to, might be useful to aim or to perform
a close-range attack manually. This value should also be converted
to degrees and casted to a nonnegative integer.
Attack
^^^^^^
Attack can be either of the three values:
0. Do nothing
1. Long-range attack
2. Close-range attack
Simple, huh? Though be aware that this won't have any effect if the hero
can yet strike an attack (as described in above section about `The Hero`_).
Pseudo-Client
-------------
#. Create an INET, STREAMing socket ``sock``
#. Connect ``sock`` to the address ``host:port`` which the server is bound to
#. Receive length :math:`l` of data
#. If :math:`l > 0`, close ``sock`` and quit
#. Receive the data
#. Process the data
#. Send instruction for the hero to the server and go back to step 3
Your AI should try to not only reach the highest score possible, but also in
the smallest amount of time. For convenience purpose, the server will
log these values to stdout.
There are samples of client implementations in different languages in
the client-examples_ directory (more are coming).
.. _client-examples:
https://git.disroot.org/McSinyx/brutalmaze/src/branch/master/client-examples
.. |204a87| image:: images/204a87.png
.. |3465a4| image:: images/3465a4.png
.. |4e9a06| image:: images/4e9a06.png
.. |555753| image:: images/555753.png
.. |5c3566| image:: images/5c3566.png
.. |729fcf| image:: images/729fcf.png
.. |73d216| image:: images/73d216.png
.. |75507b| image:: images/75507b.png
.. |888a85| image:: images/888a85.png
.. |8ae234| image:: images/8ae234.png
.. |8f5902| image:: images/8f5902.png
.. |a40000| image:: images/a40000.png
.. |ad7f8a| image:: images/ad7f8a.png
.. |babdb6| image:: images/babdb6.png
.. |c17d11| image:: images/c17d11.png
.. |c4a000| image:: images/c4a000.png
.. |cc0000| image:: images/cc0000.png
.. |ce5c00| image:: images/ce5c00.png
.. |d3d7cf| image:: images/d3d7cf.png
.. |e9b96e| image:: images/e9b96e.png
.. |edd400| image:: images/edd400.png
.. |eeeeec| image:: images/eeeeec.png
.. |ef2929| image:: images/ef2929.png
.. |f57900| image:: images/f57900.png
.. |fcaf3e| image:: images/fcaf3e.png
.. |fce94f| image:: images/fce94f.png

View File

@ -1,32 +0,0 @@
[build-system]
requires = ['flit_core >=2,<3']
build-backend = 'flit_core.buildapi'
[tool.flit.metadata]
module = 'brutalmaze'
author = 'Nguyễn Gia Phong'
author-email = 'mcsinyx@disroot.org'
home-page = 'https://git.disroot.org/McSinyx/brutalmaze'
requires = ['appdirs', 'palace', 'pygame>=1.9', 'setuptools']
description-file = 'README.rst'
classifiers = [
'Development Status :: 4 - Beta',
'Environment :: MacOS X',
'Environment :: Win32 (MS Windows)',
'Environment :: X11 Applications',
'Intended Audience :: End Users/Desktop',
'License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)',
'Natural Language :: English',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 3 :: Only',
'Topic :: Games/Entertainment :: Arcade']
requires-python = '>=3.6'
keywords = 'pygame,shmup,maze,ai-challenges'
license = 'AGPLv3+'
[tool.flit.metadata.urls]
Documentation = 'https://brutalmaze.rtfd.io'
[tool.flit.entrypoints.console_scripts]
brutalmaze = 'brutalmaze.game:main'

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

2
setup.cfg Normal file
View File

@ -0,0 +1,2 @@
[bdist_wheel]
universal=1

32
setup.py Executable file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from setuptools import setup
with open('README.rst') as f:
long_description = f.read()
setup(
name='brutalmaze',
version='0.6.5',
description='A minimalist hack and slash game with fast-paced action',
long_description=long_description,
url='https://github.com/McSinyx/brutalmaze',
author='Nguyễn Gia Phong',
author_email='vn.mcsinyx@gmail.com',
license='GPLv3+',
classifiers=[
'Development Status :: 4 - Beta',
'Environment :: MacOS X',
'Environment :: Win32 (MS Windows)',
'Environment :: X11 Applications',
'Intended Audience :: End Users/Desktop',
'License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)',
'Natural Language :: English',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Topic :: Games/Entertainment :: Arcade'],
keywords='pygame action-game arcade-game maze socket-server ai-challenges',
packages=['brutalmaze'],
install_requires=['appdirs', 'pygame>=1.9'],
package_data={'brutalmaze': ['icon.png', 'soundfx/*.ogg', 'settings.ini']},
entry_points={'console_scripts': ['brutalmaze = brutalmaze.game:main']})

21
tox.ini
View File

@ -1,21 +0,0 @@
[tox]
envlist = py
minversion = 3.3
isolated_build = True
[testenv]
deps =
flake8-builtins
isort
commands =
flake8
isort . --check --diff
[flake8]
hang-closing = True
ignore = E129, E226, E228, E701, E704, W503
exclude = .git,__pycache__,.tox,__init__.py
[isort]
balanced_wrapping = True
combine_as_imports = True

1
wiki Submodule

@ -0,0 +1 @@
Subproject commit 8f40eb7b3d368076bb2b9fc4d268472af62e2886