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 brutalmaze.egg-info
__pycache__/ build
*.py[cod] dist
*$py.class __pycache__
*.pyc
# 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/

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
=========== ===========
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 .. image:: https://raw.githubusercontent.com/McSinyx/brutalmaze/master/screenshot.png
:target: https://brutalmaze.rtfd.io/recplayer.html
The game features a trigon trapped in an infinite maze. As our hero tries The game features a trigon trapped in an infinite maze. As our hero tries to
to escape, the maze's border turns into aggressive squares trying to stop per. 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 Your job is to help the trigon fight against those evil squares and find a way
a way out (if there is any). Be aware that the more get killed, out (if there is any). Be aware that the more get killed, the more will show up
the more will show up and our hero will get weaker when wounded. and our hero will get weaker when wounded.
Brutal Maze has a few notable features: Brutal Maze has a few notable features:
* Being highly portable. * Being highly portable.
* Auto-generated and infinite maze. [0]_ * Auto-generated and infinite maze.
* No binary data for drawing. * No binary data for drawing.
* Enemies with special abilities: stun, poison, camo, etc. * Enemies with special abilities: stun, poison, camo, etc.
* Somewhat a realistic physic and logic system. * Somewhat a realistic physic and logic system.
* Resizable game window in-game. * Resizable game window in-game.
* Easily customizable via INI file format. * 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). * Remote control through TCP/IP socket (can be used in AI researching).
Installation 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: 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``. is in your ``$PATH``.
* Open Terminal or Command Prompt and run ``pip install --user brutalmaze``. * 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 After installation, you can launch the game by running the command
``brutalmaze``. Below are the default bindings, which can be configured as ``brutalmaze``. Below are default bindings:
shown in the next section:
F2 F2
New game. New game.
@ -46,26 +47,19 @@ F2
Toggle pause. Toggle pause.
``m`` ``m``
Toggle mute. Toggle mute.
``a`` Left
Move left. Move left.
``d`` Right
Move right. Move right.
``w`` Up
Move up. Move up.
``s`` Down
Move down. Move down.
Left Mouse Left Mouse
Long-range attack. Long-range attack.
Right Mouse Right Mouse
Close-range attack, also dodge from bullets. 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 Configuration
------------- -------------
@ -76,10 +70,10 @@ to configuration file only.
Settings are read in the following order: Settings are read in the following order:
0. Default configuration [1]_ 0. Default configuration [0]_
1. System-wide configuration file [2]_ 1. System-wide configuration file [1]_
2. Local configuration file [2]_ 2. Local configuration file [1]_
3. Manually set configuration file [3]_ 3. Manually set configuration file [2]_
4. Command-line arguments 4. Command-line arguments
Later-read preferences will override previous ones. Later-read preferences will override previous ones.
@ -87,18 +81,13 @@ Later-read preferences will override previous ones.
Remote control Remote control
-------------- --------------
If you enable the socket server [4]_, Brutal Maze will no longer accept If you enable the socket server [3]_, Brutal Maze will no longer accept direct
direct input from your mouse or keyboard, but wait for a client to connect. input from your mouse or keyboard, but wait for a client to connect. Details
The I/O format is explained in details in the `Remote Control`_ page. about I/O format are explained carefully in
`Remote control <https://github.com/McSinyx/brutalmaze/wiki/Remote-control>`_
wiki page.
Game recording License
--------------
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
------- -------
Brutal Maze's source code and its icon are released under GNU Affero General 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. running there.
This project also uses Tango color palette and several sound effects, whose 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 .. [0] This can be copied to desired location by ``brutalmaze --write-config
`#3 <https://git.disroot.org/McSinyx/brutalmaze/issues/3>`_. PATH``. ``brutalmaze --write-config`` alone will print the file to stdout.
.. [1] This can be copied to desired location by ``brutalmaze --write-config .. [1] These will be listed as fallback config in the help message
PATH``. ``brutalmaze --write-config`` alone will print the file to stdout. (``brutalmaze --help``). See `wiki <https://github.com/McSinyx/brutalmaze/wiki/Configuration>`_
.. [2] These will be listed as fallback config in the help message for more info.
(``brutalmaze --help``). See the Configuration_ documentation for more info. .. [2] If specified by ``brutalmaze --config PATH``.
.. [3] If specified by ``brutalmaze --config PATH``. .. [3] This can be done by either editing option *Enable* in section *Server*
.. [4] This can be done by either editing option *Enable* in section *Server* in the configuration file, or launching Brutal Maze using ``brutalmaze
in the configuration file or launching the game via ``brutalmaze --server``. --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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
# game.py - main module, starts game and main loop # -*- coding: utf-8 -*-
# Copyright (C) 2017-2020 Nguyễn Gia Phong # main.py - main module, starts game and main loop
# Copyright (C) 2017, 2018 Nguyễn Gia Phong
# #
# This file is part of Brutal Maze. # This file is part of Brutal Maze.
# #
@ -16,28 +17,29 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with Brutal Maze. If not, see <https://www.gnu.org/licenses/>.
__version__ = '0.9.4' __version__ = '0.6.5'
import re import re
from argparse import ArgumentParser, FileType, RawTextHelpFormatter from argparse import ArgumentParser, FileType, RawTextHelpFormatter
from configparser import ConfigParser from collections import deque
from contextlib import redirect_stdout try: # Python 3
from io import StringIO from configparser import ConfigParser
from math import atan2, pi, radians except ImportError: # Python 2
from os.path import join as pathjoin, pathsep from ConfigParser import ConfigParser
from socket import SO_REUSEADDR, SOL_SOCKET, socket 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 sys import stdout
from threading import Thread from threading import Thread
with redirect_stdout(StringIO()): import pygame import pygame
from appdirs import AppDirs from pygame import KEYDOWN, QUIT, VIDEORESIZE
from palace import Context, Device, free, use_context
from pygame import KEYDOWN, MOUSEBUTTONUP, QUIT, VIDEORESIZE
from pygame.time import Clock, get_ticks 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 .maze import Maze
from .misc import deg, join, play, sign from .misc import deg, round2, sign
class ConfigReader: class ConfigReader:
@ -68,9 +70,6 @@ class ConfigReader:
self.max_fps = self.config.getint('Graphics', 'Maximum FPS') self.max_fps = self.config.getint('Graphics', 'Maximum FPS')
self.muted = self.config.getboolean('Sound', 'Muted') self.muted = self.config.getboolean('Sound', 'Muted')
self.musicvol = self.config.getfloat('Sound', 'Music volume') 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.server = self.config.getboolean('Server', 'Enable')
self.host = self.config.get('Server', 'Host') self.host = self.config.get('Server', 'Host')
self.port = self.config.getint('Server', 'Port') self.port = self.config.getint('Server', 'Port')
@ -97,21 +96,26 @@ class ConfigReader:
def read_args(self, arguments): def read_args(self, arguments):
"""Read and parse a ArgumentParser.Namespace.""" """Read and parse a ArgumentParser.Namespace."""
for option in ('size', 'max_fps', 'muted', 'musicvol', for option in ('size', 'max_fps', 'muted', 'musicvol',
'touch', 'export_dir', 'export_rate', 'server', 'server', 'host', 'port', 'timeout', 'headless'):
'host', 'port', 'timeout', 'headless'):
value = getattr(arguments, option) value = getattr(arguments, option)
if value is not None: setattr(self, option, value) if value is not None: setattr(self, option, value)
class Game: class Game:
"""Object handling main loop and IO.""" """Object handling main loop and IO."""
def __init__(self, config: ConfigReader): def __init__(self, config):
pygame.mixer.pre_init(frequency=44100)
pygame.init() pygame.init()
self.headless = config.headless and config.server self.headless = config.headless and config.server
if not self.headless: pygame.display.set_icon(ICON) if config.muted or self.headless:
self.actx = None if self.headless else Context(Device()) pygame.mixer.quit()
self._mute = config.muted 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: if config.server:
self.server = socket() self.server = socket()
self.server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) self.server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
@ -124,87 +128,83 @@ class Game:
else: else:
self.server = self.sockinp = None 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.musicvol = config.musicvol
self.touch = config.touch
self.key, self.mouse = config.key, config.mouse self.key, self.mouse = config.key, config.mouse
self.maze = Maze(config.max_fps, config.size, config.headless, self.maze = Maze(config.max_fps, config.size, config.headless)
config.export_dir, 1000 / config.export_rate)
self.hero = self.maze.hero self.hero = self.maze.hero
self.clock, self.paused = Clock(), False self.clock, self.paused = Clock(), False
def __enter__(self): def __enter__(self): return 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 __exit__(self, exc_type, exc_value, traceback): def expos(self, x, y):
if self.server is not None: self.server.close() """Return position of the given coordinates in rounded percent."""
if not self.hero.dead: self.maze.dump_records() cx = (x+self.maze.x-self.maze.centerx) / self.maze.distance * 100
if self.actx is not None: cy = (y+self.maze.y-self.maze.centery) / self.maze.distance * 100
free(SFX) return round2(cx), round2(cy)
self._source.stop()
self.actx.update()
use_context(None)
self.actx.destroy()
self.actx.device.close()
pygame.quit()
@property def export(self):
def mute(self): """Export maze data to a bytes object."""
"""Mute state.""" maze, hero, = self.maze, self.hero
return getattr(self, '_mute', 1) 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 walls = [[1 if maze.map[x][y] == WALL else 0 for x in maze.rangex]
def mute(self, value): for y in maze.rangey] if maze.next_move <= 0 else []
"""Mute state.""" ne = nb = 0
self._mute = int(bool(value))
self.actx.listener.gain = not self._mute
def export_txt(self): for enemy in maze.enemies:
"""Export maze data to string.""" if not enemy.awake and walls:
export = self.maze.update_export(forced=True) walls[enemy.y-maze.rangey[0]][enemy.x-maze.rangex[0]] = WALL
return '{} {} {} {}\n{}{}{}{}'.format( continue
len(export['m']), len(export['e']), len(export['b']), export['s'], # Check Chameleons
''.join(row + '\n' for row in export['m']), join(export['h']), elif getattr(enemy, 'visible', 1) <= 0 and maze.next_move <= 0:
''.join(map(join, export['e'])), ''.join(map(join, export['b']))) 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): def update(self):
"""Draw and handle meta events on Pygame window. """Draw and handle meta events on Pygame window.
Return False if QUIT event is captured, True otherwise. Return False if QUIT event is captured, True otherwise.
""" """
events = pygame.event.get() events = pygame.fastevent.get()
for event in events: for event in events:
if event.type == QUIT: if event.type == QUIT:
return False return False
elif event.type == VIDEORESIZE: elif event.type == VIDEORESIZE:
self.maze.resize((event.w, event.h)) self.maze.resize((event.w, event.h))
elif event.type == KEYDOWN: elif event.type == KEYDOWN and not self.server:
if event.key == self.key['mute']: if event.key == self.key['new']:
self.mute ^= 1 self.maze.reinit()
elif not self.server: elif event.key == self.key['pause'] and not self.hero.dead:
if event.key == self.key['new']: self.paused ^= True
self.maze.reinit() elif event.key == self.key['mute']:
elif event.key == self.key['pause'] and not self.hero.dead: if pygame.mixer.get_init() is None:
self.paused ^= True pygame.mixer.init(frequency=44100)
elif event.type == MOUSEBUTTONUP and self.touch: pygame.mixer.music.load(MUSIC)
# We're careless about which mouse button is clicked. pygame.mixer.music.set_volume(self.musicvol)
maze = self.maze pygame.mixer.music.play(-1)
if self.hero.dead: else:
maze.reinit() pygame.mixer.quit()
else:
x, y = pygame.mouse.get_pos()
maze.destx, maze.desty = maze.get_grid(x, y)
if maze.set_step(maze.isdisplayed):
maze.target = maze.get_target(x, y)
self.hero.firing = not maze.target.retired
if maze.stepx == maze.stepy == 0:
maze.destx = maze.desty = MIDDLE
# Compare current FPS with the average of the last 10 frames # Compare current FPS with the average of the last 10 frames
new_fps = self.clock.get_fps() new_fps = self.clock.get_fps()
@ -215,38 +215,31 @@ class Game:
if not self.paused: self.maze.update(self.fps) if not self.paused: self.maze.update(self.fps)
if not self.headless: self.maze.draw() if not self.headless: self.maze.draw()
self.clock.tick(self.fps) self.clock.tick(self.fps)
self.actx.update()
return True return True
def move(self, x=0, y=0): def move(self, x, y):
"""Command the hero to move faster in the given direction.""" """Command the hero to move faster in the given direction."""
maze = self.maze x, y = -x, -y # or move the maze in the reverse direction
velocity = maze.distance * HERO_SPEED / self.fps velocity = self.maze.distance * HERO_SPEED / self.fps
accel = velocity * HERO_SPEED / self.fps accel = velocity * HERO_SPEED / self.fps
if x == y == 0: if self.maze.next_move > 0 or not x:
maze.set_step() self.maze.vx -= sign(self.maze.vx) * accel
x, y = maze.stepx, maze.stepy 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: 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: if self.maze.next_move > 0 or not y:
maze.vx -= sign(maze.vx) * accel self.maze.vy -= sign(self.maze.vy) * accel
if abs(maze.vx) < accel * 2: maze.vx = 0.0 if abs(self.maze.vy) < accel * 2: self.maze.vy = 0.0
elif x * maze.vx < 0: elif y * self.maze.vy < 0:
maze.vx += x * 2 * accel self.maze.vy += y * 2 * accel
else: else:
maze.vx += x * accel self.maze.vy += y * accel
if abs(maze.vx) > velocity: maze.vx = x * velocity if abs(self.maze.vy) > velocity: self.maze.vy = y * velocity
if maze.next_move > 0 or not y:
maze.vy -= sign(maze.vy) * accel
if abs(maze.vy) < accel * 2: maze.vy = 0.0
elif y * maze.vy < 0:
maze.vy += y * 2 * accel
else:
maze.vy += y * accel
if abs(maze.vy) > velocity: maze.vy = y * velocity
def control(self, x, y, angle, firing, slashing): def control(self, x, y, angle, firing, slashing):
"""Control how the hero move and attack.""" """Control how the hero move and attack."""
@ -271,23 +264,20 @@ class Game:
if self.hero.dead: if self.hero.dead:
connection.send('0000000'.encode()) connection.send('0000000'.encode())
break break
data = self.export_txt().encode() data = self.export()
alpha = deg(self.hero.angle)
connection.send('{:07}'.format(len(data)).encode()) connection.send('{:07}'.format(len(data)).encode())
connection.send(data) connection.send(data)
try: try:
buf = connection.recv(7) buf = connection.recv(7)
except: # noqa except: # client is closed or timed out
break # client is closed or timed out break
if not buf: break if not buf: break
try: try:
move, angle, attack = map(int, buf.decode().split()) move, angle, attack = map(int, buf.decode().split())
except ValueError: # invalid input except ValueError: # invalid input
break break
y, x = (i - 1 for i in divmod(move, 3)) y, x = (i - 1 for i in divmod(move, 3))
# Time is the essence. self.sockinp = x, y, radians(angle), attack & 1, attack >> 1
angle = self.hero.angle if angle == alpha else radians(angle)
self.sockinp = x, y, angle, attack & 1, attack >> 1
clock.tick(self.fps) clock.tick(self.fps)
self.sockinp = 0, 0, -pi * 3 / 4, 0, 0 self.sockinp = 0, 0, -pi * 3 / 4, 0, 0
new_time = get_ticks() new_time = get_ticks()
@ -296,37 +286,32 @@ class Game:
connection.close() connection.close()
if not self.hero.dead: self.maze.lose() 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): def user_control(self):
"""Handle direct control from user's mouse and keyboard.""" """Handle direct control from user's mouse and keyboard."""
if self.hero.dead: return if not self.hero.dead:
keys = pygame.key.get_pressed() keys = pygame.key.get_pressed()
buttons = pygame.mouse.get_pressed() right = keys[self.key['right']] - keys[self.key['left']]
down = keys[self.key['down']] - keys[self.key['up']]
right = keys[self.key['right']] - keys[self.key['left']] # Follow the mouse cursor
down = keys[self.key['down']] - keys[self.key['up']] x, y = pygame.mouse.get_pos()
x, y = pygame.mouse.get_pos() angle = atan2(y - self.hero.y, x - self.hero.x)
angle = atan2(y - self.hero.y, x - self.hero.x)
try: buttons = pygame.mouse.get_pressed()
firing = keys[self.key['shot']] try:
except KeyError: firing = keys[self.key['shot']]
firing = buttons[self.mouse['shot']] except KeyError:
try: firing = buttons[self.mouse['shot']]
slashing = keys[self.key['slash']] try:
except KeyError: slashing = keys[self.key['slash']]
slashing = buttons[self.mouse['slash']] except KeyError:
self.control(right, down, angle, firing, slashing) 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(): def main():
@ -335,7 +320,7 @@ def main():
dirs = AppDirs(appname='brutalmaze', appauthor=False, multipath=True) dirs = AppDirs(appname='brutalmaze', appauthor=False, multipath=True)
parents = dirs.site_config_dir.split(pathsep) parents = dirs.site_config_dir.split(pathsep)
parents.append(dirs.user_config_dir) 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 = ConfigReader(filenames)
config.parse() config.parse()
@ -366,20 +351,6 @@ def main():
parser.add_argument( parser.add_argument(
'--music-volume', type=float, metavar='VOL', dest='musicvol', '--music-volume', type=float, metavar='VOL', dest='musicvol',
help='between 0.0 and 1.0 (fallback: {})'.format(config.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( parser.add_argument(
'--server', action='store_true', default=None, '--server', action='store_true', default=None,
help='enable server (fallback: {})'.format(config.server)) help='enable server (fallback: {})'.format(config.server))
@ -420,11 +391,5 @@ def main():
socket_thread.daemon = True # make it disposable socket_thread.daemon = True # make it disposable
socket_thread.start() socket_thread.start()
while game.update(): game.control(*game.sockinp) while game.update(): game.control(*game.sockinp)
elif config.touch:
while game.update(): game.touch_control()
else: else:
while game.update(): game.user_control() 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 # 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. # This file is part of Brutal Maze.
# #
@ -18,24 +19,40 @@
__doc__ = 'Brutal Maze module for the maze class' __doc__ = 'Brutal Maze module for the maze class'
import json from collections import deque
from collections import defaultdict, deque from math import pi, log
from math import log, pi from random import choice, getrandbits, uniform
from os import path
from random import choice, sample
import pygame import pygame
from .characters import Hero, new_enemy from .characters import Hero, new_enemy
from .constants import (ADJACENTS, ATTACK_SPEED, BG_COLOR, from .constants import (
BULLET_LIFETIME, CELL_NODES, CELL_WIDTH, COLORS, EMPTY, WALL, HERO, ROAD_WIDTH, MAZE_SIZE, MIDDLE, INIT_SCORE, ENEMIES,
EMPTY, ENEMIES, ENEMY, ENEMY_HP, FG_COLOR, HERO, MINW, MAXW, SQRT2, SFX_SPAWN, SFX_SLASH_ENEMY, SFX_LOSE, ADJACENT_GRIDS,
HERO_HP, HERO_SPEED, INIT_SCORE, JSON_SEPARATORS, BG_COLOR, FG_COLOR, CELL_WIDTH, LAST_ROW, HERO_HP, ENEMY_HP, ATTACK_SPEED,
MAX_WOUND, MAZE_SIZE, MIDDLE, ROAD_WIDTH, HERO_SPEED, BULLET_LIFETIME)
SFX_LOSE, SFX_MISSED, SFX_SLASH_ENEMY, SFX_SPAWN, from .misc import round2, sign, regpoly, fill_aapolygon, play
SQRT2, TANGO_VALUES, WALL, WALL_WIDTH) from .weapons import Bullet
from .misc import around, deg, fill_aapolygon, json_rec, play, regpoly, sign
from .weapons import LockOn
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: class Maze:
@ -53,103 +70,50 @@ class Maze:
map (deque of deque): map of grids representing objects on the 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) vx, vy (float): velocity of the maze movement (in pixels per frame)
rotatex, rotatey (int): grids rotated 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 enemies (list of Enemy): alive enemies
hero (Hero): the hero 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) 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) next_slashfx (float): time until next slash effect of the hero (in ms)
slashd (float): minimum distance for slashes to be effective slashd (float): minimum distance for slashes to be effective
export (list of defaultdict): records of game states sfx_slash (pygame.mixer.Sound): sound effect of slashed enemy
export_dir (str): directory containing records of game states sfx_lose (pygame.mixer.Sound): sound effect to be played when you lose
export_rate (float): milliseconds per snapshot
next_export (float): time until next snapshot (in ms)
""" """
def __init__(self, fps, size, headless, export_dir, export_rate): def __init__(self, fps, size, headless):
self.fps = fps self.fps = fps
self.w, self.h = size self.w, self.h = size
if headless: if headless:
self.surface = None self.surface = None
else: else:
self.surface = pygame.display.set_mode(size, pygame.RESIZABLE) 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.distance = (self.w * self.h / 416) ** 0.5
self.x, self.y = self.w // 2, self.h // 2 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) w, h = (int(i/self.distance/2 + 1) for i in size)
self.rangex = list(range(MIDDLE - w, MIDDLE + w + 1)) self.rangex = list(range(MIDDLE - w, MIDDLE + w + 1))
self.rangey = list(range(MIDDLE - h, MIDDLE + h + 1)) self.rangey = list(range(MIDDLE - h, MIDDLE + h + 1))
self.score = INIT_SCORE 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.vx = self.vy = 0.0
self.rotatex = self.rotatey = 0 self.rotatex = self.rotatey = 0
self.bullets, self.enemies = [], [] self.bullets, self.enemies = [], []
self.enemy_weights = {color: MINW for color in ENEMIES}
self.add_enemy() self.add_enemy()
self.hero = Hero(self.surface, fps, size) self.hero = Hero(self.surface, fps, size)
self.target = LockOn(MIDDLE, MIDDLE, retired=True) self.map[MIDDLE][MIDDLE] = HERO
self.next_move = self.glitch = self.next_slashfx = 0.0 self.next_move = self.next_slashfx = 0.0
self.slashd = self.hero.R + self.distance/SQRT2 self.slashd = self.hero.R + self.distance/SQRT2
self.sfx_spawn = SFX_SPAWN self.sfx_spawn = SFX_SPAWN
self.sfx_slash = SFX_SLASH_ENEMY self.sfx_slash = SFX_SLASH_ENEMY
self.sfx_lose = SFX_LOSE 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): def add_enemy(self):
"""Add enough enemies.""" """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 walls = [(i, j) for i in self.rangex for j in self.rangey
if self.map[i][j] == WALL] if self.map[i][j] == WALL]
plums = [e for e in self.enemies if e.color == 'Plum' and e.awake] 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) num = log(self.score, INIT_SCORE)
while walls and len(self.enemies) < num: while walls and len(self.enemies) < num:
x, y = choice(walls) 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 continue
enemy = new_enemy(self, x, y) enemy = new_enemy(self, x, y)
self.enemies.append(enemy) 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): def get_pos(self, x, y):
"""Return coordinate of the center of the grid (x, y).""" """Return coordinate of the center of the grid (x, y)."""
return (self.centerx + (x - MIDDLE)*self.distance, return (self.centerx + (x - MIDDLE)*self.distance,
self.centery + (y - 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): def get_score(self):
"""Return the current score.""" """Return the current score."""
return int(self.score - INIT_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): def draw(self):
"""Draw the maze.""" """Draw the maze."""
self.surface.fill(BG_COLOR) self.surface.fill(BG_COLOR)
@ -201,7 +148,7 @@ class Maze:
if self.map[i][j] != WALL: continue if self.map[i][j] != WALL: continue
x, y = self.get_pos(i, j) x, y = self.get_pos(i, j)
square = regpoly(4, self.distance / SQRT2, pi / 4, x, y) 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() for enemy in self.enemies: enemy.draw()
if not self.hero.dead: self.hero.draw() if not self.hero.dead: self.hero.draw()
@ -216,46 +163,47 @@ class Maze:
x = int((self.centerx-self.x) * 2 / self.distance) x = int((self.centerx-self.x) * 2 / self.distance)
y = int((self.centery-self.y) * 2 / self.distance) y = int((self.centery-self.y) * 2 / self.distance)
if x == y == 0: return if x == y == 0: return
for enemy in self.enemies: for enemy in self.enemies: self.map[enemy.x][enemy.y] = EMPTY
if self.map[enemy.x][enemy.y] == ENEMY:
self.map[enemy.x][enemy.y] = EMPTY
self.map[MIDDLE][MIDDLE] = EMPTY self.map[MIDDLE][MIDDLE] = EMPTY
self.centerx -= x * self.distance if x:
self.map.rotate(x) self.centerx -= x * self.distance
self.rotatex += x self.map.rotate(x)
self.centery -= y * self.distance self.rotatex += x
for d in self.map: d.rotate(y) if y:
self.rotatey += y self.centery -= y * self.distance
for d in self.map: d.rotate(y)
self.rotatey += y
self.map[MIDDLE][MIDDLE] = HERO 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 # Respawn the enemies that fall off the display
killist = []
for i, enemy in enumerate(self.enemies): for i, enemy in enumerate(self.enemies):
enemy.place(x, y) 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 self.score += enemy.wound
enemy.die() enemy.die()
killist.append(i)
for i in reversed(killist): self.enemies.pop(i)
self.add_enemy() self.add_enemy()
# LockOn target is not yet updated.
if isinstance(self.target, LockOn):
self.target.place(x, y, self.isdisplayed)
# Regenerate the maze # Regenerate the maze
if abs(self.rotatex) == CELL_WIDTH: if abs(self.rotatex) == CELL_WIDTH:
self.rotatex = 0 self.rotatex = 0
for i in range(CELL_WIDTH): self.map[i].rotate(-self.rotatey) for _ in range(CELL_WIDTH): self.map.pop()
for i in range(MAZE_SIZE): self.new_cell(0, i) self.map.extend(new_column())
for i in range(CELL_WIDTH): self.map[i].rotate(self.rotatey) for i in range(-CELL_WIDTH, 0):
self.map[i].rotate(self.rotatey)
if abs(self.rotatey) == CELL_WIDTH: if abs(self.rotatey) == CELL_WIDTH:
self.rotatey = 0 self.rotatey = 0
self.map.rotate(-self.rotatex) for i in range(MAZE_SIZE):
for i in range(MAZE_SIZE): self.new_cell(i, 0) b, c = getrandbits(1), (i-1)*CELL_WIDTH + self.rotatex
self.map.rotate(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): def get_distance(self, x, y):
"""Return the distance from the center of the maze to the point """Return the distance from the center of the maze to the point
@ -265,84 +213,83 @@ class Maze:
def hit_hero(self, wound, color): def hit_hero(self, wound, color):
"""Handle the hero when he loses HP.""" """Handle the hero when he loses HP."""
if color == 'Orange': fx = (uniform(0, sum(self.enemy_weights.values()))
# If called by close-range attack, this is FPS-dependant, although < self.enemy_weights[color])
# in playable FPS (24 to infinity), the difference within 2%. if (color == 'Butter' or color == 'ScarletRed') and fx:
self.hero.next_heal = abs(self.hero.next_heal * (1 - wound)) self.hero.wound += wound * 2.5
elif choice(ENEMIES) == color: elif color == 'Orange' and fx:
self.hero.next_heal = -1.0 # what doesn't kill you heals you self.hero.next_heal = max(self.hero.next_heal, 0) + wound*1000
if color == 'Butter' or color == 'ScarletRed': elif color == 'SkyBlue' and fx:
wound *= ENEMY_HP self.next_move = max(self.next_move, 0) + wound*1000
elif color == 'Chocolate': else:
self.hero.highness += wound self.hero.wound += wound
wound = 0 if self.enemy_weights[color] + wound < MAXW:
elif color == 'SkyBlue': self.enemy_weights[color] += wound
self.next_move = max(self.next_move, 0) + wound*1000 if self.hero.wound > HERO_HP and not self.hero.dead: self.lose()
wound = 0
if wound and sum(self.hero.wounds) < MAX_WOUND:
self.hero.wounds[-1] += wound
def slash(self): def slash(self):
"""Handle close-range attacks.""" """Handle close-range attacks."""
for enemy in self.enemies: enemy.slash() for enemy in self.enemies: enemy.slash()
if not self.hero.spin_queue: return if not self.hero.spin_queue: return
for enemy in filter(lambda e: e.awake, self.enemies): killist = []
d = self.slashd - enemy.distance for i, enemy in enumerate(self.enemies):
d = self.slashd - enemy.get_distance()
if d > 0: if d > 0:
wound = d * SQRT2 / self.distance wound = d * SQRT2 / self.distance
if self.next_slashfx <= 0: 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 self.next_slashfx = ATTACK_SPEED
enemy.hit(wound / self.hero.spin_speed) enemy.hit(wound / self.hero.spin_speed)
if enemy.wound >= ENEMY_HP: if enemy.wound >= ENEMY_HP:
self.score += enemy.wound self.score += enemy.wound
enemy.die() enemy.die()
killist.append(i)
for i in reversed(killist): self.enemies.pop(i)
self.add_enemy() self.add_enemy()
def track_bullets(self): def track_bullets(self):
"""Handle the bullets.""" """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 = [] 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) and self.hero.next_strike > self.hero.spin_queue / self.fps)
for i, bullet in enumerate(self.bullets): for i, bullet in enumerate(self.bullets):
wound = bullet.fall_time / BULLET_LIFETIME wound = bullet.fall_time / BULLET_LIFETIME
bullet.update(self.fps, self.distance) bullet.update(self.fps, self.distance)
gridx, gridy = self.get_grid(bullet.x, bullet.y) if wound < 0:
if wound <= 0 or not self.isdisplayed(gridx, gridy):
fallen.append(i) fallen.append(i)
elif bullet.color == 'Aluminium': elif bullet.color == 'Aluminium':
active_enemies = [e for e in self.enemies if e.awake] x = MIDDLE + round2((bullet.x-self.x) / self.distance)
if self.map[gridx][gridy] == WALL and self.next_move <= 0: y = MIDDLE + round2((bullet.y-self.y) / self.distance)
if self.map[x][y] == WALL and self.next_move <= 0:
fallen.append(i) 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 continue
for enemy in active_enemies: for j, enemy in enumerate(self.enemies):
if bullet.get_distance(*enemy.pos) < self.distance: if not enemy.awake: continue
x, y = enemy.get_pos()
if bullet.get_distance(x, y) < self.distance:
enemy.hit(wound) enemy.hit(wound)
if enemy.wound >= ENEMY_HP: if enemy.wound >= ENEMY_HP:
self.score += enemy.wound self.score += enemy.wound
enemy.die() enemy.die()
self.add_enemy() self.enemies.pop(j)
play(bullet.sfx_hit, gridx, gridy, wound) play(bullet.sfx_hit, wound, bullet.angle)
fallen.append(i) fallen.append(i)
break break
elif bullet.get_distance(self.x, self.y) < self.distance: elif bullet.get_distance(self.x, self.y) < self.distance:
if block: if block:
self.hero.next_strike = (abs(self.hero.spin_queue/self.fps) self.hero.next_strike = (abs(self.hero.spin_queue/self.fps)
+ ATTACK_SPEED) + ATTACK_SPEED)
play(SFX_MISSED, gain=wound) play(bullet.sfx_missed, wound, bullet.angle + pi)
else: else:
self.hit_hero(wound, bullet.color) self.hit_hero(wound, bullet.color)
play(bullet.sfx_hit, gain=wound) play(bullet.sfx_hit, wound, bullet.angle + pi)
fallen.append(i) fallen.append(i)
for i in reversed(fallen): self.bullets.pop(i) for i in reversed(fallen): self.bullets.pop(i)
@ -360,75 +307,32 @@ class Maze:
return 0.0 return 0.0
for enemy in self.enemies: for enemy in self.enemies:
x, y = self.get_pos(enemy.x, enemy.y) 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 0.0
return vx or vy 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): def update(self, fps):
"""Update the maze.""" """Update the maze."""
self.fps = fps self.fps = fps
self.vx = self.is_valid_move(vx=self.vx) dx = self.is_valid_move(vx=self.vx)
self.centerx += self.vx self.centerx += dx
self.vy = self.is_valid_move(vy=self.vy) dy = self.is_valid_move(vy=self.vy)
self.centery += self.vy self.centery += dy
self.next_move -= 1000 / fps self.next_move -= 1000.0 / self.fps
self.glitch -= 1000 / fps self.next_slashfx -= 1000.0 / self.fps
self.next_slashfx -= 1000 / fps
self.next_export -= 1000 / fps
self.rotate() 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 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() for enemy in self.enemies: enemy.update()
self.track_bullets()
if not self.hero.dead: if not self.hero.dead:
self.hero.update(fps) self.hero.update(fps)
self.slash() self.slash()
if self.hero.wound >= HERO_HP: self.lose() self.track_bullets()
self.update_export()
def resize(self, size): def resize(self, size):
"""Resize the maze.""" """Resize the maze."""
@ -447,84 +351,30 @@ class Maze:
self.rangey = list(range(MIDDLE - h, MIDDLE + h + 1)) self.rangey = list(range(MIDDLE - h, MIDDLE + h + 1))
self.slashd = self.hero.R + self.distance/SQRT2 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): def isfast(self):
"""Return if the hero is moving faster than HERO_SPEED.""" """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 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): def lose(self):
"""Handle loses.""" """Handle loses."""
self.hero.dead = True self.hero.dead = True
self.hero.wound = HERO_HP
self.hero.slashing = self.hero.firing = False self.hero.slashing = self.hero.firing = False
self.destx = self.desty = MIDDLE
self.stepx = self.stepy = 0
self.vx = self.vy = 0.0 self.vx = self.vy = 0.0
play(SFX_LOSE) play(self.sfx_lose)
self.dump_records()
def reinit(self): def reinit(self):
"""Open new game.""" """Open new game."""
self.centerx, self.centery = self.w / 2, self.h / 2 self.centerx, self.centery = self.w / 2.0, self.h / 2.0
self.score, self.export = INIT_SCORE, [] 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.vx = self.vy = 0.0
self.rotatex = self.rotatey = 0 self.rotatex = self.rotatey = 0
self.bullets, self.enemies = [], [] self.bullets, self.enemies = [], []
self.enemy_weights = {color: MINW for color in ENEMIES}
self.add_enemy() self.add_enemy()
self.next_move = self.next_slashfx = self.hero.next_strike = 0.0 self.next_move = self.next_slashfx = 0.0
self.target = LockOn(MIDDLE, MIDDLE, retired=True) self.hero.next_heal = self.hero.next_strike = 0
self.hero.next_heal = -1.0
self.hero.highness = 0.0
self.hero.slashing = self.hero.firing = self.hero.dead = False self.hero.slashing = self.hero.firing = self.hero.dead = False
self.hero.spin_queue = self.hero.wound = 0.0 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 # 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. # This file is part of Brutal Maze.
# #
@ -18,17 +19,16 @@
__doc__ = 'Brutal Maze module for miscellaneous functions' __doc__ = 'Brutal Maze module for miscellaneous functions'
from datetime import datetime from math import degrees, cos, sin, pi
from itertools import chain from random import uniform
from math import cos, degrees, pi, sin
from os import path
from random import shuffle
import pygame import pygame
from palace import Buffer, Source from pygame.gfxdraw import filled_polygon, aapolygon
from pygame.gfxdraw import aapolygon, filled_polygon
from .constants import ADJACENTS, CORNERS, MIDDLE
def round2(number):
"""Round a number to an int."""
return int(round(number))
def randsign(): def randsign():
@ -37,9 +37,9 @@ def randsign():
def regpoly(n, R, r, x, y): def regpoly(n, R, r, x, y):
"""Return pointlist of a regular n-gon with circumradius of R, """Return the pointlist of the regular polygon with n sides,
center point I(x, y) and corner A that angle of vector IA is r circumradius of R, the center point I(x, y) and one point A make the
(in radians). vector IA with angle r (in radians).
""" """
r %= pi * 2 r %= pi * 2
angles = [r + pi*2*side/n for side in range(n)] 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): 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) aapolygon(surface, points, color)
filled_polygon(surface, points, color) filled_polygon(surface, points, color)
@ -58,43 +58,44 @@ def sign(n):
def deg(x): def deg(x):
"""Convert angle x from radians to degrees, """Convert angle x from radians to degrees casted to a nonnegative
casted to a nonnegative integer. 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'): def cosin(x):
"""Return a string which is the concatenation of string """Return the sum of cosine and sine of x (measured in radians)."""
representations of objects in the iterable, separated by sep. 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): def play(sound, volume=1.0, angle=None):
"""Return grids around the given one in random order.""" """Play a pygame.mixer.Sound at the given volume."""
a = [(x + i, y + j) for i, j in ADJACENTS] if pygame.mixer.get_init() is None: return
shuffle(a) if pygame.mixer.find_channel() is None:
c = [(x + i, y + j) for i, j in CORNERS] pygame.mixer.set_num_channels(pygame.mixer.get_num_channels() + 1)
shuffle(c)
return chain(a, c)
channel = sound.play()
def json_rec(directory): if angle is None:
"""Return path to JSON file to be created inside the given directory channel.set_volume(volume)
based on current time local to timezone in ISO 8601 format. else:
""" delta = cos(angle)
return path.join( volumes = [volume * (1-delta), volume * (1+delta)]
directory, '{}.json'.format(datetime.now().isoformat()[:19])) for i, v in enumerate(volumes):
if v > 1:
volumes[i - 1] += v - 1
def play(sound: str, x: float = MIDDLE, y: float = MIDDLE, volumes[i] = 1.0
gain: float = 1.0) -> Source: sound.set_volume(1.0)
"""Play a sound at the given position.""" channel.set_volume(*volumes)
source = Buffer(sound).play()
source.spatialize = True
source.position = x, -y, 0
source.gain = gain
return source

View File

@ -6,12 +6,10 @@ Maximum FPS: 60
[Sound] [Sound]
Muted: no Muted: no
# Volume must be between 0.0 and 1.0. # Volume must be between 0.0 and 1.0
Music volume: 1.0 Music volume: 1.0
[Control] [Control]
# Touch-friendly control
Touch: no
# Input values should be either from Mouse1 to Mouse3 or a keyboard key # Input values should be either from Mouse1 to Mouse3 or a keyboard key
# and they are case-insensitively read. # and they are case-insensitively read.
# Aliases for special keys are listed here (without the K_ part): # Aliases for special keys are listed here (without the K_ part):
@ -20,24 +18,18 @@ Touch: no
New game: F2 New game: F2
Toggle pause: p Toggle pause: p
Toggle mute: m Toggle mute: m
Move left: a Move left: Left
Move right: d Move right: Right
Move up: w Move up: Up
Move down: s Move down: Down
Long-range attack: Mouse1 Long-range attack: Mouse1
Close-range attack: Mouse3 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] [Server]
# Enabling remote control will disable control via keyboard and mouse. # Enabling remote control will disable control via keyboard and mouse.
Enable: no Enable: no
Host: localhost Host: localhost
Port: 42069 Port: 8089
# Timeout on blocking socket operations, in seconds. # Timeout on blocking socket operations, in seconds.
Timeout: 1.0 Timeout: 1.0
# Disable graphics and sound (only if socket server is enabled). # 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 # 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. # This file is part of Brutal Maze.
# #
@ -20,9 +21,9 @@ __doc__ = 'Brutal Maze module for weapon classes'
from math import cos, sin from math import cos, sin
from .constants import (BG_COLOR, BULLET_LIFETIME, BULLET_SPEED, from .constants import (BULLET_LIFETIME, SFX_SHOT_ENEMY, SFX_SHOT_HERO,
ENEMY_HP, SFX_SHOT_ENEMY, SFX_SHOT_HERO, TANGO) SFX_MISSED, BULLET_SPEED, ENEMY_HP, TANGO, BG_COLOR)
from .misc import fill_aapolygon, regpoly from .misc import regpoly, fill_aapolygon
class Bullet: class Bullet:
@ -34,7 +35,8 @@ class Bullet:
angle (float): angle of the direction the bullet pointing (in radians) angle (float): angle of the direction the bullet pointing (in radians)
color (str): bullet's color name color (str): bullet's color name
fall_time (int): time until the bullet fall down 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): def __init__(self, surface, x, y, angle, color):
self.surface = surface self.surface = surface
@ -44,13 +46,14 @@ class Bullet:
self.sfx_hit = SFX_SHOT_ENEMY self.sfx_hit = SFX_SHOT_ENEMY
else: else:
self.sfx_hit = SFX_SHOT_HERO self.sfx_hit = SFX_SHOT_HERO
self.sfx_missed = SFX_MISSED
def update(self, fps, distance): def update(self, fps, distance):
"""Update the bullet.""" """Update the bullet."""
s = distance * BULLET_SPEED / fps s = distance * BULLET_SPEED / fps
self.x += s * cos(self.angle) self.x += s * cos(self.angle)
self.y += s * sin(self.angle) self.y += s * sin(self.angle)
self.fall_time -= 1000 / fps self.fall_time -= 1000.0 / fps
def get_color(self): def get_color(self):
"""Return current color of the enemy.""" """Return current color of the enemy."""
@ -73,22 +76,3 @@ class Bullet:
def get_distance(self, x, y): def get_distance(self, x, y):
"""Return the from the center of the bullet to the point (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 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) static void Main(string[] args)
{ {
const string host = "localhost"; const string host = "localhost";
const int port = 42069; const int port = 8089;
Socket client_socket = new Socket(SocketType.Stream, ProtocolType.Tcp); Socket client_socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
client_socket.Connect(host, port); client_socket.Connect(host, port);
Random rnd = new Random(); Random rnd = new Random();
@ -101,4 +101,4 @@ namespace BrutalmazeClient
client_socket.Close(); client_socket.Close();
} }
} }
} }

View File

@ -1,72 +1,40 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from contextlib import closing, suppress from math import inf, atan2, degrees
from math import atan2, degrees, inf
from random import randrange, shuffle
from socket import socket 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): move = 4 if ne and hp > 2 else 0
"""Return tuple of encoded moves.""" if angle == inf:
return ((y - 1, x - 1), (y - 1, x), (y - 1, x + 1), # noqa angle, attack = ha, 0
(y, x - 1), (y, x), (y, x + 1), # noqa elif not attackable:
(y + 1, x - 1), (y + 1, x), (y + 1, x + 1)) # noqa attack = 0
elif shortest < 160 or hp < 3:
move, angle, attack = 8, ha, 2
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)
else: else:
idx = AROUND.index(move) attack = 1
around.sort(key=lambda i: abs(abs(abs(AROUND.index(i)-idx)-4)-4)) clientsocket.send('{} {} {}'.format(move, angle, attack).encode())
for move in around: clientsocket.close()
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())

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