Compare commits

...

63 Commits

Author SHA1 Message Date
Nguyễn Gia Phong 12b798ac00 Employ linters 2020-09-19 11:12:54 +07:00
Nguyễn Gia Phong d97e1a1294 Update git repo and nitpick 2020-09-08 22:47:17 +07:00
Nguyễn Gia Phong c61bd8acc7 Update documentation pointers 2020-07-24 21:52:46 +07:00
Nguyễn Gia Phong 22b0e683bf Write an overvview in docs 2020-07-24 21:14:12 +07:00
Nguyễn Gia Phong 754610a095 Render math correctly and clean up docs 2020-07-24 20:54:56 +07:00
Nguyễn Gia Phong 328f6809f8 Specify Sphinx version 2020-07-20 21:54:53 +07:00
Nguyễn Gia Phong 1b7bf9833d Use Sphinx to replace wiki 2020-07-20 21:46:31 +07:00
Nguyễn Gia Phong d3839f160e Nitpick around 2020-04-28 17:50:36 +07:00
Nguyễn Gia Phong bc8579329e Use palace.MessageHandler to collect stopped sources 2020-04-25 15:19:05 +07:00
Nguyễn Gia Phong 2813a0856f Switch audio plane to Oxy
Also clean up sources properly
2020-04-12 17:51:12 +07:00
Nguyễn Gia Phong 600c72d0d4 Use palace for positional audio rendering
This fixes GH-15.  Sources doesn't seem to be cleaned up properly though.
2020-04-12 16:35:44 +07:00
Nguyễn Gia Phong c326f93bbb Redirect pygame message and add wiki to sdist 2020-03-04 09:41:05 +07:00
Nguyễn Gia Phong b90e6c272c Switch to flit for build 2020-03-03 21:48:00 +07:00
Nguyễn Gia Phong 5e5778d814 Drop Python 2 support (resolve #13) 2020-01-21 15:18:19 +07:00
Nguyễn Gia Phong e2149b18c2 Remove debugging print and improve style 2019-10-13 17:45:44 +07:00
Nguyễn Gia Phong 7d346a219a Prevent the maze from trapping the hero 2019-10-12 21:47:22 +07:00
Nguyễn Gia Phong b70c00eb8d Unnest the queue used for path finding 2019-10-09 17:20:57 +07:00
Nguyễn Gia Phong bb3d4158ca Remove useless enemy weights and clean up 2019-10-09 12:43:22 +07:00
Nguyễn Gia Phong ef70806a48 Fix typos and optimizations 2019-10-09 11:15:55 +07:00
Nguyễn Gia Phong 622df8c361 Fix zombie enemies and heart rate 2019-07-24 13:20:31 +07:00
Nguyễn Gia Phong 4f18daa234 Make icon true squircle 2019-07-16 16:13:37 +07:00
Nguyễn Gia Phong e2562e1698 Prevent player from creating enemy when there isn't any 2019-03-18 12:34:18 +07:00
Nguyễn Gia Phong 7a0ace220c Switch back to thrilling white noise 2019-03-15 20:23:43 +07:00
Nguyễn Gia Phong be6c2fedea Update documentation related to #11 2018-10-28 15:50:13 +07:00
Nguyễn Gia Phong bad902d02e Improve coding style 2018-10-09 21:20:38 +07:00
Nguyễn Gia Phong eb088c0cf1 Update documentation on touch control 2018-10-08 22:07:30 +07:00
Nguyễn Gia Phong 377dda3db0 Implement touch-friendly control 2018-10-07 21:59:39 +07:00
Nguyễn Gia Phong 865a3e3b71 Clarify what happens behind mouse move and close #10 2018-08-28 23:05:44 +07:00
Nguyễn Gia Phong 1e7e981e81 Fix stupid ass bugs 2018-08-08 20:05:21 +07:00
Nguyễn Gia Phong 6d2f6d6ad3 Add further description on game recording 2018-08-06 21:59:00 +07:00
Nguyễn Gia Phong d6dfd43158 Add HTML5 record player and update hit-and-run client 2018-08-05 18:24:05 +07:00
Nguyễn Gia Phong ba2aaeb1f1 Update socket output 2018-08-05 18:05:07 +07:00
Nguyễn Gia Phong 834fa33ec0 Implement game recording
And fix various bugs on game data export. Somehow they remain
undiscovered the last 5 months.
2018-08-03 22:50:59 +07:00
Nguyễn Gia Phong eb23230acb Use WASD instead of arrows for a less awkward default keybindings 2018-07-25 11:26:38 +07:00
Nguyễn Gia Phong 6dc590834e Revise documentation and bump to version 0.8 2018-07-20 15:01:03 +07:00
Nguyễn Gia Phong ffe6ba9855 Chocolate gets you high 2018-07-02 11:04:46 +07:00
Nguyễn Gia Phong fc05e0ccee Prevent gang-bang and re-balance gameplay 2018-06-28 10:31:04 +07:00
Nguyễn Gia Phong 3507a52ac7 Fix enemy spawning sound position 2018-06-28 10:31:04 +07:00
Nguyễn Gia Phong e7d04930b3 Use a more neutral algorithm to generate maze (#6) 2018-06-28 10:31:04 +07:00
Nguyễn Gia Phong 654a1a2c5e Fix bug that walls out of display can still be turned into enemies 2018-05-31 22:07:07 +07:00
Nguyễn Gia Phong cbaec90dd1 Add option to switch to the original music 2018-05-22 21:15:01 +07:00
Nguyễn Gia Phong 8e6faa6d26 Fix Python 3 incompatibility 2018-05-22 20:44:22 +07:00
Nguyễn Gia Phong eace9a270b Use more relaxing background music 2018-05-22 09:54:10 +07:00
Nguyễn Gia Phong 9a896890fa Turn wall grids into enemies when they are shot 2018-05-22 09:54:08 +07:00
Nguyễn Gia Phong 9dff378b57 Allow moving hero using mouse 2018-05-20 20:48:51 +07:00
Nguyễn Gia Phong 92a41b3cff Fix broken argument parser on Windows 2018-04-04 23:35:43 +07:00
Le Minh Nghia e63a1d8dc8 Add C# client example (#9) 2018-03-26 12:20:47 +07:00
Nguyễn Gia Phong 32e29b0a38 Fix grammar and typos 2018-03-22 23:01:00 +07:00
Nguyễn Gia Phong bbc98be317 Add socket client example and fix enemy-fall-of-the-map bug 2018-03-20 14:17:17 +07:00
Nguyễn Gia Phong 2bd7352aec Remove crazy score for server testing 2018-03-19 15:30:30 +07:00
Nguyễn Gia Phong ace9586778 Fix several bugs
* Set connection timeout to avoid hanging along with the client
* Now visible Chameleons are exported in server mode
* Disable manual slashing's bullets blocking so that there won't be no delay after this type of attack that make aiming stiff
2018-03-10 18:19:12 +07:00
Nguyễn Gia Phong d7eb9071a0 Shrink socket hardcoded msg lengths to 7 2018-03-07 17:04:48 +07:00
Nguyễn Gia Phong 5fa4eac9a8 Update documentation 2018-03-07 16:41:28 +07:00
Nguyễn Gia Phong 97d4a43ec7 Drop (trivial) OpenGL support 2018-03-07 16:13:34 +07:00
Nguyễn Gia Phong b5039285d5 Retain game state after pauses 2018-03-06 21:01:27 +07:00
Nguyễn Gia Phong f7c600934e Test and fix minor bugs 2018-03-06 09:58:52 +07:00
Nguyễn Gia Phong 2bafc0c75a Add documentation for remote control 2018-03-05 23:59:02 +07:00
Nguyễn Gia Phong 3cf78b680a Add time stamps to server log and anti-cheat on bullets blocking 2018-03-02 23:57:08 +07:00
Nguyễn Gia Phong 7bd13996fb Add command-line options for socket server 2018-03-02 22:28:49 +07:00
Nguyễn Gia Phong 6f9eb44e2a Enable manual slashing by moving mouse 2018-03-01 20:58:41 +07:00
Nguyễn Gia Phong 79ae3ed383 Specify imports, fix frozen bullets on game-over and uniform object value export 2018-02-28 22:06:03 +07:00
Nguyễn Gia Phong 781b347fcb Make remote control sticky and revise headless server 2018-02-27 22:25:58 +07:00
Nguyễn Gia Phong 0cfeaf9cab Make Agent Orange more lethal and noticable 2018-02-27 20:55:23 +07:00
67 changed files with 2468 additions and 792 deletions

109
.gitignore vendored
View File

@ -1,5 +1,104 @@
brutalmaze.egg-info # Byte-compiled / optimized / DLL files
build __pycache__/
dist *.py[cod]
__pycache__ *$py.class
*.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
View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
# characters.py - module for hero and enemy classes # characters.py - module for hero and enemy classes
# Copyright (C) 2017, 2018 Nguyễn Gia Phong # Copyright (C) 2017-2020 Nguyễn Gia Phong
# #
# This file is part of Brutal Maze. # This file is part of Brutal Maze.
# #
@ -19,15 +18,16 @@
__doc__ = 'Brutal Maze module for hero and enemy classes' __doc__ = 'Brutal Maze module for hero and enemy classes'
from math import atan, atan2, sin, pi from collections import deque
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
import pygame from .constants import (ADJACENTS, AROUND_HERO, ATTACK_SPEED, EMPTY,
from pygame.time import get_ticks ENEMIES, ENEMY, ENEMY_HP, ENEMY_SPEED, FIRANGE,
HEAL_SPEED, HERO_HP, MIDDLE, MIN_BEAT, SFX_HEART,
from .constants import * SFX_SLASH_HERO, SFX_SPAWN, SQRT2, TANGO, WALL)
from .misc import sign, cosin, randsign, regpoly, fill_aapolygon, choices, play from .misc import fill_aapolygon, play, randsign, regpoly, sign
from .weapons import Bullet from .weapons import Bullet
@ -40,67 +40,111 @@ 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 (int): the tick that the hero gains back healing ability next_heal (float): minimum wound in ATTACK_SPEED allowing healing again
next_beat (int): the tick to play next heart beat next_beat (float): time until next heart beat (in ms)
next_strike (int): the tick that the hero can do the next attack next_strike (float): time until the hero can do the next attack (in ms)
slashing (bool): flag indicates if the hero is doing close-range attack highness (float): likelihood that the hero shoots toward other angles
firing (bool): flag indicates if the hero is doing long-range attack slashing (bool): flag indicating if the hero's doing close-range attack
dead (bool): flag indicates if the hero is dead firing (bool): flag indicating if the hero is doing long-range attack
dead (bool): flag indicating if the hero is dead
spin_speed (float): speed of spinning (in frames per slash) spin_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_heart (pygame.mixer.Sound): heart beat sound effect wounds (deque of float): wounds in time of an attack (ATTACK_SPEED)
""" """
def __init__(self, surface, fps): def __init__(self, surface, fps, maze_size):
self.surface = surface self.surface = surface
w, h = self.surface.get_width(), self.surface.get_height() w, h = maze_size
self.x, self.y = w >> 1, h >> 1 self.x, self.y = w >> 1, h >> 1
self.angle, self.color = pi / 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 = self.next_beat = self.next_strike = 0 self.next_heal = -1.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."""
if self.dead: if self.dead:
self.spin_queue = 0.0 self.spin_queue = 0.0
return return
old_speed, time = self.spin_speed, get_ticks() 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 time > self.next_heal:
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
if time > self.next_beat: self.wounds.append(0.0)
play(self.sfx_heart) if self.next_beat <= 0:
self.next_beat = time + MIN_BEAT*(2 - self.wound/HERO_HP) play(SFX_HEART)
self.next_beat = MIN_BEAT*(2 - self.wound/HERO_HP)
else:
self.next_beat -= 1000 / fps
self.next_strike -= 1000 / fps
if self.slashing and time >= self.next_strike: full_spin = pi * 2 / self.sides
self.next_strike = time + ATTACK_SPEED if self.slashing and self.next_strike <= 0:
self.next_strike = ATTACK_SPEED
self.spin_queue = randsign() * self.spin_speed self.spin_queue = randsign() * self.spin_speed
if abs(self.spin_queue) > 0.5: self.angle -= sign(self.spin_queue) * full_spin
self.angle += sign(self.spin_queue) * pi / 2 / self.spin_speed if round(self.spin_queue) != 0:
self.angle += sign(self.spin_queue) * full_spin / self.spin_speed
self.spin_queue -= sign(self.spin_queue) self.spin_queue -= sign(self.spin_queue)
else:
self.spin_queue = 0.0
@property
def sides(self):
"""Number of sides the hero has. While the hero is generally
a trigon, Agent Orange may turn him into a square.
"""
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 abs(self.spin_queue) <= 0.5: if round(self.spin_queue) != 0: return
self.spin_queue = 0.0 delta = (angle - self.angle + pi) % (pi * 2) - pi
self.angle = angle unit = pi * 2 / self.sides / self.spin_speed
if abs(delta) < unit:
self.angle, self.spin_queue = angle, 0.0
else:
self.spin_queue = delta / unit
@property
def shots(self):
"""List of Bullet the hero has just shot."""
if not self.firing or self.slashing or self.next_strike > 0: return []
self.next_strike = ATTACK_SPEED
if not randrange(int(self.highness + 1)):
return [Bullet(self.surface, self.x, self.y,
self.angle, 'Aluminium')]
self.highness -= 1.0
n = self.sides
corners = {randrange(n) for _ in range(n)}
angles = (self.angle + pi*2*corner/n for corner in corners)
return [Bullet(self.surface, self.x, self.y, angle, 'Aluminium')
for angle in angles]
def get_color(self):
"""Return current color of the hero."""
return self.color[int(self.wound)]
def draw(self): def draw(self):
"""Draw the hero.""" """Draw the hero."""
trigon = regpoly(3, self.R, self.angle, self.x, self.y) trigon = regpoly(self.sides, self.R, self.angle, self.x, self.y)
fill_aapolygon(self.surface, trigon, self.color[int(self.wound)]) fill_aapolygon(self.surface, trigon, self.get_color())
def resize(self): def resize(self, maze_size):
"""Resize the hero.""" """Resize the hero."""
w, h = self.surface.get_width(), self.surface.get_height() w, h = maze_size
self.x, self.y = w >> 1, h >> 1 self.x, self.y = w >> 1, h >> 1
self.R = (w * h / sin(pi*2/3) / 624) ** 0.5 self.R = (w * h / sin(pi*2/3) / 624) ** 0.5
@ -113,47 +157,51 @@ 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
awake (bool): flag indicates if the enemy is active alive (bool): flag indicating if the enemy is alive
next_strike (int): the tick that the enemy can do the next attack awake (bool): flag indicating if the enemy is active
next_strike (float): time until the enemy's next action (in ms)
move_speed (float): speed of movement (in frames per grid) 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.awake = False self.alive, self.awake = True, False
self.next_strike = 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
self.sfx_slash = SFX_SLASH_HERO @property
def pos(self):
def get_pos(self): """Coordinates (in pixels) of the center of the enemy."""
"""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
def get_distance(self): @property
"""Return the distance from the center of the enemy to def distance(self):
the center of the maze. """Distance from the center of the enemy
to the center of the maze.
""" """
return self.maze.get_distance(*self.get_pos()) return self.maze.get_distance(*self.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
self.maze.map[self.x][self.y] = ENEMY if self.awake: self.maze.map[self.x][self.y] = ENEMY
@property
def spawn_volume(self):
"""Volumn of spawning sound effect."""
return 1 - self.distance / self.maze.get_distance(0, 0) / 2
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.
@ -162,36 +210,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
startx = starty = MIDDLE srcx, destx = self.x, MIDDLE
stopx, stopy, distance = self.x, self.y, self.maze.distance if abs(destx - srcx) != 1: srcx += sign(destx - srcx) or 1
if startx > stopx: startx, stopx = stopx, startx srcy, desty = self.y, MIDDLE
if starty > stopy: starty, stopy = stopy, starty if abs(desty - srcy) != 1: srcy += sign(desty - srcy) or 1
dx = (self.x-MIDDLE)*distance + self.maze.centerx - self.maze.x m, n = destx - srcx, desty - srcy
dy = (self.y-MIDDLE)*distance + self.maze.centery - self.maze.y lcm = abs(m * n // gcd(m, n))
mind = cosin(abs(atan(dy / dx)) if dx else 0) * distance w, u = lcm // m, lcm // n
def get_distance(x, y): return abs(dy*x - dx*y) / (dy**2 + dx**2)**0.5 for i in range(lcm):
for i in range(startx, stopx + 1): if self.maze.map[srcx+i//w][srcy+i//u] == WALL: return False
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
play(self.maze.sfx_spawn, self.maze.map[self.x][self.y] = ENEMY
1 - self.get_distance()/self.maze.get_distance(0, 0)/2, play(SFX_SPAWN, self.x, self.y)
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.get_pos() x, y = self.pos
if (self.maze.get_distance(x, y) > FIRANGE*self.maze.distance if (self.maze.get_distance(x, y) > FIRANGE*self.maze.distance
or get_ticks() < self.next_strike 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
or randrange((self.maze.hero.slashing+self.maze.isfast()+1) * 3)): or randrange((self.maze.hero.slashing+self.maze.isfast()+1) * 3)):
return False return False
self.next_strike = get_ticks() + ATTACK_SPEED self.next_strike = ATTACK_SPEED
self.maze.bullets.append( self.maze.bullets.append(
Bullet(self.maze.surface, x, y, self.get_angle() + pi, self.color)) Bullet(self.maze.surface, x, y, self.get_angle() + pi, self.color))
return True return True
@ -204,13 +246,13 @@ class Enemy:
if self.offsety: if self.offsety:
self.offsety -= sign(self.offsety) self.offsety -= sign(self.offsety)
return True return True
if get_ticks() < self.next_strike: return False if self.next_strike > 0: return False
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(ADJACENT_GRIDS)) directions.append(choice(ADJACENTS))
if self.maze.hero.dead: directions = choice(ADJACENT_GRIDS), if self.maze.hero.dead: directions = choice(ADJACENTS),
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))
@ -222,34 +264,42 @@ 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.get_distance()) / self.maze.hero.R wound = (self.maze.slashd - self.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: self.maze.hit_hero(wound, self.color) if self.spin_queue and wound: self.maze.hit_hero(wound, self.color)
return wound return wound
def get_angle(self, reversed=False): def get_angle(self):
"""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.get_pos() x, y = self.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)] if self.awake else FG_COLOR return TANGO[self.color][int(self.wound)]
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 get_ticks() < self.maze.next_move and not self.awake: return if self.isunnoticeable(): return
radious = self.maze.distance/SQRT2 - self.awake*2 radius = self.maze.distance / SQRT2
square = regpoly(4, radious, self.angle, *self.get_pos()) square = regpoly(4, radius, self.angle, *self.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):
@ -257,11 +307,12 @@ 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
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(self.sfx_slash, self.get_slash(), self.get_angle()) play(SFX_SLASH_HERO, self.x, self.y, self.get_slash())
if abs(self.spin_queue) > 0.5: 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)
else: else:
@ -271,46 +322,64 @@ 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."""
if self.awake: self.maze.map[self.x][self.y] = EMPTY if self.wake else WALL
self.maze.map[self.x][self.y] = EMPTY self.alive = False
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):
"""Object representing an enemy of Chameleon. """Object representing an enemy of Chameleon.
Additional attributes: Additional attributes:
visible (int): the tick until which the Chameleon is visible visible (float): time until the Chameleon is visible (in ms)
""" """
def __init__(self, maze, x, y): def __init__(self, maze, x, y):
Enemy.__init__(self, maze, x, y, 'Chameleon') super().__init__(maze, x, y, 'Chameleon')
self.visible = 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 Enemy.wake(self) is True: if super().wake() is True:
self.visible = get_ticks() + 1000//ENEMY_SPEED self.visible = 1000 / ENEMY_SPEED
def draw(self): def isunnoticeable(self, x=None, y=None):
"""Draw the Chameleon.""" """Return whether the enemy can be noticed.
if not self.awake or get_ticks() < self.visible or self.spin_queue:
Enemy.draw(self) Only search within column x and row y if these coordinates
are provided.
"""
return (super().isunnoticeable(x, y)
or self.visible <= 0 and not self.spin_queue
and self.maze.next_move <= 0)
def update(self):
"""Update the Chameleon."""
super().update()
if self.awake: self.visible -= 1000 / 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 = get_ticks() + 1000//ENEMY_SPEED self.visible = 1000.0 / ENEMY_SPEED
Enemy.hit(self, wound) super().hit(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):
Enemy.__init__(self, maze, x, y, 'Plum') super().__init__(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
@ -327,24 +396,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):
Enemy.__init__(self, maze, x, y, 'ScarletRed') super().__init__(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 Enemy.move(self, ENEMY_SPEED * SQRT2) return super().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 -= Enemy.slash(self) self.wound -= super().slash()
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 = choices(maze.enemy_weights) color = choice(ENEMIES)
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,6 +1,5 @@
# -*- coding: utf-8 -*-
# constants.py - module for shared constants # constants.py - module for shared constants
# Copyright (C) 2017, 2018 Nguyễn Gia Phong # Copyright (C) 2017-2020 Nguyễn Gia Phong
# #
# This file is part of Brutal Maze. # This file is part of Brutal Maze.
# #
@ -21,44 +20,44 @@ __doc__ = 'Brutal Maze module for shared constants'
from string import ascii_lowercase from string import ascii_lowercase
from pkg_resources import resource_filename as pkg_file
import pygame import pygame
from pygame.mixer import Sound from pkg_resources import resource_filename as pkg_file
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')
mixer = pygame.mixer.get_init() SFX_NOISE = pkg_file('brutalmaze', 'soundfx/noise.ogg')
if mixer is None: pygame.mixer.init(frequency=44100) SFX_SPAWN = pkg_file('brutalmaze', 'soundfx/spawn.ogg')
SFX_SPAWN = Sound(pkg_file('brutalmaze', 'soundfx/spawn.ogg')) SFX_SLASH_ENEMY = pkg_file('brutalmaze', 'soundfx/slash-enemy.ogg')
SFX_SLASH_ENEMY = Sound(pkg_file('brutalmaze', 'soundfx/slash-enemy.ogg')) SFX_SLASH_HERO = pkg_file('brutalmaze', 'soundfx/slash-hero.ogg')
SFX_SLASH_HERO = Sound(pkg_file('brutalmaze', 'soundfx/slash-hero.ogg')) SFX_SHOT_ENEMY = pkg_file('brutalmaze', 'soundfx/shot-enemy.ogg')
SFX_SHOT_ENEMY = Sound(pkg_file('brutalmaze', 'soundfx/shot-enemy.ogg')) SFX_SHOT_HERO = pkg_file('brutalmaze', 'soundfx/shot-hero.ogg')
SFX_SHOT_HERO = Sound(pkg_file('brutalmaze', 'soundfx/shot-hero.ogg')) SFX_MISSED = pkg_file('brutalmaze', 'soundfx/missed.ogg')
SFX_MISSED = Sound(pkg_file('brutalmaze', 'soundfx/missed.ogg')) SFX_HEART = pkg_file('brutalmaze', 'soundfx/heart.ogg')
SFX_HEART = Sound(pkg_file('brutalmaze', 'soundfx/heart.ogg')) SFX_LOSE = pkg_file('brutalmaze', 'soundfx/lose.ogg')
SFX_LOSE = Sound(pkg_file('brutalmaze', 'soundfx/lose.ogg')) SFX = (SFX_NOISE, SFX_SPAWN, SFX_SLASH_ENEMY, SFX_SLASH_HERO,
if mixer is None: pygame.mixer.quit() SFX_SHOT_ENEMY, SFX_SHOT_HERO, SFX_MISSED, SFX_HEART, SFX_LOSE)
SQRT2 = 2 ** 0.5 SQRT2 = 2 ** 0.5
INIT_SCORE = 5**0.5/2 + 0.5 # golden mean INIT_SCORE = 2
MAZE_SIZE = 10 ROAD_WIDTH = 3 # grids
ROAD_WIDTH = 5 # grids WALL_WIDTH = 4 # grids
CELL_WIDTH = ROAD_WIDTH * 2 # grids CELL_WIDTH = WALL_WIDTH + ROAD_WIDTH*2 # grids
MIDDLE = (MAZE_SIZE + MAZE_SIZE%2 - 1)*ROAD_WIDTH + ROAD_WIDTH//2 CELL_NODES = ROAD_WIDTH, ROAD_WIDTH + WALL_WIDTH, 0
LAST_ROW = (MAZE_SIZE-1) * ROAD_WIDTH * 2 MAZE_SIZE = 10 # cells
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 # ms/strike ATTACK_SPEED = 333.333 # ms/strike
MAX_WOUND = 1 # per attack turn
FIRANGE = 6 # grids FIRANGE = 6 # grids
BULLET_LIFETIME = 1000.0 * FIRANGE / (BULLET_SPEED-HERO_SPEED) # ms BULLET_LIFETIME = 1000 * FIRANGE / (BULLET_SPEED-HERO_SPEED) # ms
EMPTY, WALL, HERO, ENEMY = range(4) EMPTY, WALL, HERO, ENEMY = range(4)
ADJACENT_GRIDS = (1, 0), (0, 1), (-1, 0), (0, -1) ADJACENTS = (1, 0), (0, 1), (-1, 0), (0, -1)
AROUND_HERO = set((MIDDLE + x, MIDDLE + y) for x, y in CORNERS = (1, 1), (-1, 1), (-1, -1), (1, -1)
ADJACENT_GRIDS + ((1, 1), (-1, 1), (-1, -1), (1, -1))) AROUND_HERO = set((MIDDLE + x, MIDDLE + y) for x, y in ADJACENTS + CORNERS)
TANGO = {'Butter': ((252, 233, 79), (237, 212, 0), (196, 160, 0)), 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)),
@ -69,14 +68,16 @@ 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 = 526 MIN_BEAT = 420
BG_COLOR = TANGO['Aluminium'][-1] BG_COLOR = TANGO['Aluminium'][-1]
FG_COLOR = TANGO['Aluminium'][0] FG_COLOR = TANGO['Aluminium'][0]
JSON_SEPARATORS = ',', ':'

430
brutalmaze/game.py Normal file
View File

@ -0,0 +1,430 @@
# game.py - main module, starts game and main loop
# Copyright (C) 2017-2020 Nguyễn Gia Phong
#
# This file is part of Brutal Maze.
#
# Brutal Maze is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Brutal Maze is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Brutal Maze. If not, see <https://www.gnu.org/licenses/>.
__version__ = '0.9.4'
import re
from argparse import ArgumentParser, FileType, RawTextHelpFormatter
from configparser import ConfigParser
from contextlib import redirect_stdout
from io import StringIO
from math import atan2, pi, radians
from os.path import join as pathjoin, pathsep
from socket import SO_REUSEADDR, SOL_SOCKET, socket
from sys import stdout
from threading import Thread
with redirect_stdout(StringIO()): import pygame
from appdirs import AppDirs
from palace import Context, Device, free, use_context
from pygame import KEYDOWN, MOUSEBUTTONUP, QUIT, VIDEORESIZE
from pygame.time import Clock, get_ticks
from .constants import HERO_SPEED, ICON, MIDDLE, SETTINGS, SFX, SFX_NOISE
from .maze import Maze
from .misc import deg, join, play, sign
class ConfigReader:
"""Object reading and processing INI configuration file for
Brutal Maze.
"""
CONTROL_ALIASES = (('New game', 'new'), ('Toggle pause', 'pause'),
('Toggle mute', 'mute'),
('Move left', 'left'), ('Move right', 'right'),
('Move up', 'up'), ('Move down', 'down'),
('Long-range attack', 'shot'),
('Close-range attack', 'slash'))
WEIRD_MOUSE_ERR = '{}: Mouse is not a suitable control'
INVALID_CONTROL_ERR = '{}: {} is not recognized as a valid control key'
def __init__(self, filenames):
self.config = ConfigParser()
self.config.read(SETTINGS) # default configuration
self.config.read(filenames)
# Fallback to None when attribute is missing
def __getattr__(self, name): return None
def parse(self):
"""Parse configurations."""
self.size = (self.config.getint('Graphics', 'Screen width'),
self.config.getint('Graphics', 'Screen height'))
self.max_fps = self.config.getint('Graphics', 'Maximum FPS')
self.muted = self.config.getboolean('Sound', 'Muted')
self.musicvol = self.config.getfloat('Sound', 'Music volume')
self.touch = self.config.getboolean('Control', 'Touch')
self.export_dir = self.config.get('Record', 'Directory')
self.export_rate = self.config.getint('Record', 'Frequency')
self.server = self.config.getboolean('Server', 'Enable')
self.host = self.config.get('Server', 'Host')
self.port = self.config.getint('Server', 'Port')
self.timeout = self.config.getfloat('Server', 'Timeout')
self.headless = self.config.getboolean('Server', 'Headless')
if self.server: return
self.key, self.mouse = {}, {}
for cmd, alias in self.CONTROL_ALIASES:
i = self.config.get('Control', cmd)
if re.match('mouse[1-3]$', i.lower()):
if alias not in ('shot', 'slash'):
raise ValueError(self.WEIRD_MOUSE_ERR.format(cmd))
self.mouse[alias] = int(i[-1]) - 1
continue
if len(i) == 1:
self.key[alias] = ord(i.lower())
continue
try:
self.key[alias] = getattr(pygame, 'K_{}'.format(i.upper()))
except AttributeError:
raise ValueError(self.INVALID_CONTROL_ERR.format(cmd, i))
def read_args(self, arguments):
"""Read and parse a ArgumentParser.Namespace."""
for option in ('size', 'max_fps', 'muted', 'musicvol',
'touch', 'export_dir', 'export_rate', 'server',
'host', 'port', 'timeout', 'headless'):
value = getattr(arguments, option)
if value is not None: setattr(self, option, value)
class Game:
"""Object handling main loop and IO."""
def __init__(self, config: ConfigReader):
pygame.init()
self.headless = config.headless and config.server
if not self.headless: pygame.display.set_icon(ICON)
self.actx = None if self.headless else Context(Device())
self._mute = config.muted
if config.server:
self.server = socket()
self.server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
self.server.bind((config.host, config.port))
self.server.listen(1)
print('Socket server is listening on {}:{}'.format(config.host,
config.port))
self.timeout = config.timeout
self.sockinp = 0, 0, -pi * 3 / 4, 0, 0 # freeze and point to NW
else:
self.server = self.sockinp = None
self.max_fps, self.fps = config.max_fps, config.max_fps
self.musicvol = config.musicvol
self.touch = config.touch
self.key, self.mouse = config.key, config.mouse
self.maze = Maze(config.max_fps, config.size, config.headless,
config.export_dir, 1000 / config.export_rate)
self.hero = self.maze.hero
self.clock, self.paused = Clock(), False
def __enter__(self):
if self.actx is not None:
use_context(self.actx)
self.actx.listener.position = MIDDLE, -MIDDLE, 0
self.actx.listener.gain = not self._mute
self._source = play(SFX_NOISE)
self._source.looping = True
return self
def __exit__(self, exc_type, exc_value, traceback):
if self.server is not None: self.server.close()
if not self.hero.dead: self.maze.dump_records()
if self.actx is not None:
free(SFX)
self._source.stop()
self.actx.update()
use_context(None)
self.actx.destroy()
self.actx.device.close()
pygame.quit()
@property
def mute(self):
"""Mute state."""
return getattr(self, '_mute', 1)
@mute.setter
def mute(self, value):
"""Mute state."""
self._mute = int(bool(value))
self.actx.listener.gain = not self._mute
def export_txt(self):
"""Export maze data to string."""
export = self.maze.update_export(forced=True)
return '{} {} {} {}\n{}{}{}{}'.format(
len(export['m']), len(export['e']), len(export['b']), export['s'],
''.join(row + '\n' for row in export['m']), join(export['h']),
''.join(map(join, export['e'])), ''.join(map(join, export['b'])))
def update(self):
"""Draw and handle meta events on Pygame window.
Return False if QUIT event is captured, True otherwise.
"""
events = pygame.event.get()
for event in events:
if event.type == QUIT:
return False
elif event.type == VIDEORESIZE:
self.maze.resize((event.w, event.h))
elif event.type == KEYDOWN:
if event.key == self.key['mute']:
self.mute ^= 1
elif not self.server:
if event.key == self.key['new']:
self.maze.reinit()
elif event.key == self.key['pause'] and not self.hero.dead:
self.paused ^= True
elif event.type == MOUSEBUTTONUP and self.touch:
# We're careless about which mouse button is clicked.
maze = self.maze
if self.hero.dead:
maze.reinit()
else:
x, y = pygame.mouse.get_pos()
maze.destx, maze.desty = maze.get_grid(x, y)
if maze.set_step(maze.isdisplayed):
maze.target = maze.get_target(x, y)
self.hero.firing = not maze.target.retired
if maze.stepx == maze.stepy == 0:
maze.destx = maze.desty = MIDDLE
# Compare current FPS with the average of the last 10 frames
new_fps = self.clock.get_fps()
if new_fps < self.fps:
self.fps -= 1
elif self.fps < self.max_fps and not self.paused:
self.fps += 5
if not self.paused: self.maze.update(self.fps)
if not self.headless: self.maze.draw()
self.clock.tick(self.fps)
self.actx.update()
return True
def move(self, x=0, y=0):
"""Command the hero to move faster in the given direction."""
maze = self.maze
velocity = maze.distance * HERO_SPEED / self.fps
accel = velocity * HERO_SPEED / self.fps
if x == y == 0:
maze.set_step()
x, y = maze.stepx, maze.stepy
else:
x, y = -x, -y # or move the maze in the reverse direction
if maze.next_move > 0 or not x:
maze.vx -= sign(maze.vx) * accel
if abs(maze.vx) < accel * 2: maze.vx = 0.0
elif x * maze.vx < 0:
maze.vx += x * 2 * accel
else:
maze.vx += x * accel
if abs(maze.vx) > velocity: maze.vx = x * velocity
if maze.next_move > 0 or not y:
maze.vy -= sign(maze.vy) * accel
if abs(maze.vy) < accel * 2: maze.vy = 0.0
elif y * maze.vy < 0:
maze.vy += y * 2 * accel
else:
maze.vy += y * accel
if abs(maze.vy) > velocity: maze.vy = y * velocity
def control(self, x, y, angle, firing, slashing):
"""Control how the hero move and attack."""
self.move(x, y)
self.hero.update_angle(angle)
self.hero.firing = firing
self.hero.slashing = slashing
def remote_control(self):
"""Handle remote control though socket server.
This function is supposed to be run in a Thread.
"""
clock = Clock()
while True:
connection, address = self.server.accept()
connection.settimeout(self.timeout)
time = get_ticks()
print('[{}] Connected to {}:{}'.format(time, *address))
self.maze.reinit()
while True:
if self.hero.dead:
connection.send('0000000'.encode())
break
data = self.export_txt().encode()
alpha = deg(self.hero.angle)
connection.send('{:07}'.format(len(data)).encode())
connection.send(data)
try:
buf = connection.recv(7)
except: # noqa
break # client is closed or timed out
if not buf: break
try:
move, angle, attack = map(int, buf.decode().split())
except ValueError: # invalid input
break
y, x = (i - 1 for i in divmod(move, 3))
# Time is the essence.
angle = self.hero.angle if angle == alpha else radians(angle)
self.sockinp = x, y, angle, attack & 1, attack >> 1
clock.tick(self.fps)
self.sockinp = 0, 0, -pi * 3 / 4, 0, 0
new_time = get_ticks()
print('[{0}] {3}:{4} scored {1} points in {2}ms'.format(
new_time, self.maze.get_score(), new_time - time, *address))
connection.close()
if not self.hero.dead: self.maze.lose()
def touch_control(self):
"""Handle touch control."""
maze, hero = self.maze, self.hero
if maze.target.retired: hero.firing = False
if hero.firing:
x, y = maze.get_pos(maze.target.x, maze.target.y)
else:
x, y = pygame.mouse.get_pos()
hero.update_angle(atan2(y - hero.y, x - hero.x))
self.move()
def user_control(self):
"""Handle direct control from user's mouse and keyboard."""
if self.hero.dead: return
keys = pygame.key.get_pressed()
buttons = pygame.mouse.get_pressed()
right = keys[self.key['right']] - keys[self.key['left']]
down = keys[self.key['down']] - keys[self.key['up']]
x, y = pygame.mouse.get_pos()
angle = atan2(y - self.hero.y, x - self.hero.x)
try:
firing = keys[self.key['shot']]
except KeyError:
firing = buttons[self.mouse['shot']]
try:
slashing = keys[self.key['slash']]
except KeyError:
slashing = buttons[self.mouse['slash']]
self.control(right, down, angle, firing, slashing)
def main():
"""Start game and main loop."""
# Read configuration file
dirs = AppDirs(appname='brutalmaze', appauthor=False, multipath=True)
parents = dirs.site_config_dir.split(pathsep)
parents.append(dirs.user_config_dir)
filenames = [pathjoin(parent, 'settings.ini') for parent in parents]
config = ConfigReader(filenames)
config.parse()
# Parse command-line arguments
parser = ArgumentParser(usage='%(prog)s [options]',
formatter_class=RawTextHelpFormatter)
parser.add_argument('-v', '--version', action='version',
version='Brutal Maze {}'.format(__version__))
parser.add_argument(
'--write-config', nargs='?', const=stdout, type=FileType('w'),
metavar='PATH', dest='defaultcfg',
help='write default config and exit, if PATH not specified use stdout')
parser.add_argument(
'-c', '--config', metavar='PATH',
help='location of the configuration file (fallback: {})'.format(
pathsep.join(filenames)))
parser.add_argument(
'-s', '--size', type=int, nargs=2, metavar=('X', 'Y'),
help='the desired screen size (fallback: {}x{})'.format(*config.size))
parser.add_argument(
'-f', '--max-fps', type=int, metavar='FPS',
help='the desired maximum FPS (fallback: {})'.format(config.max_fps))
parser.add_argument(
'--mute', '-m', action='store_true', default=None, dest='muted',
help='mute all sounds (fallback: {})'.format(config.muted))
parser.add_argument('--unmute', action='store_false', dest='muted',
help='unmute sound')
parser.add_argument(
'--music-volume', type=float, metavar='VOL', dest='musicvol',
help='between 0.0 and 1.0 (fallback: {})'.format(config.musicvol))
parser.add_argument(
'--touch', action='store_true', default=None,
help='enable touch-friendly control (fallback: {})'.format(
config.touch))
parser.add_argument('--no-touch', action='store_false', dest='touch',
help='disable touch-friendly control')
parser.add_argument(
'--record-dir', metavar='DIR', dest='export_dir',
help='directory to write game records (fallback: {})'.format(
config.export_dir or '*disabled*'))
parser.add_argument(
'--record-rate', metavar='SPF', dest='export_rate',
help='snapshots of game state per second (fallback: {})'.format(
config.export_rate))
parser.add_argument(
'--server', action='store_true', default=None,
help='enable server (fallback: {})'.format(config.server))
parser.add_argument('--no-server', action='store_false', dest='server',
help='disable server')
parser.add_argument(
'--host', help='host to bind server to (fallback: {})'.format(
config.host))
parser.add_argument(
'--port', type=int,
help='port for server to listen on (fallback: {})'.format(config.port))
parser.add_argument(
'-t', '--timeout', type=float,
help='socket operations timeout in seconds (fallback: {})'.format(
config.timeout))
parser.add_argument(
'--head', action='store_false', default=None, dest='headless',
help='run server with graphics and sound (fallback: {})'.format(
not config.headless))
parser.add_argument('--headless', action='store_true',
help='run server without graphics or sound')
args = parser.parse_args()
if args.defaultcfg is not None:
with open(SETTINGS) as settings: args.defaultcfg.write(settings.read())
args.defaultcfg.close()
exit()
# Manipulate config
if args.config:
config.config.read(args.config)
config.parse()
config.read_args(args)
# Main loop
with Game(config) as game:
if config.server:
socket_thread = Thread(target=game.remote_control)
socket_thread.daemon = True # make it disposable
socket_thread.start()
while game.update(): game.control(*game.sockinp)
elif config.touch:
while game.update(): game.touch_control()
else:
while game.update(): game.user_control()
# Allow launching the game via invoking ``python -m brutalmaze.game''
if __name__ == '__main__': main()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,360 +0,0 @@
# -*- coding: utf-8 -*-
# main.py - main module, starts game and main loop
# Copyright (C) 2017, 2018 Nguyễn Gia Phong
#
# This file is part of Brutal Maze.
#
# Brutal Maze is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Brutal Maze is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Brutal Maze. If not, see <https://www.gnu.org/licenses/>.
__version__ = '0.5.4'
import re
from argparse import ArgumentParser, FileType, RawTextHelpFormatter
from collections import deque
try: # Python 3
from configparser import ConfigParser
except ImportError: # Python 2
from ConfigParser import ConfigParser
from itertools import repeat
from math import atan2, degrees, radians
from os.path import join, pathsep
from socket import socket, SOL_SOCKET, SO_REUSEADDR
from sys import stdout
from threading import Thread
import pygame
from pygame import DOUBLEBUF, KEYDOWN, OPENGL, QUIT, RESIZABLE, VIDEORESIZE
from pygame.time import Clock, get_ticks
from appdirs import AppDirs
from .constants import SETTINGS, ICON, MUSIC, HERO_SPEED, COLORS, WALL
from .maze import Maze
from .misc import round2, sign
class ConfigReader:
"""Object reading and processing INI configuration file for
Brutal Maze.
"""
CONTROL_ALIASES = (('New game', 'new'), ('Toggle pause', 'pause'),
('Toggle mute', 'mute'),
('Move left', 'left'), ('Move right', 'right'),
('Move up', 'up'), ('Move down', 'down'),
('Long-range attack', 'shot'),
('Close-range attack', 'slash'))
WEIRD_MOUSE_ERR = '{}: Mouse is not a suitable control'
INVALID_CONTROL_ERR = '{}: {} is not recognized as a valid control key'
def __init__(self, filenames):
self.config = ConfigParser()
self.config.read(SETTINGS) # default configuration
self.config.read(filenames)
# Fallback to None when attribute is missing
def __getattr__(self, name): return None
def parse(self):
"""Parse configurations."""
self.server = self.config.getboolean('Server', 'Enable')
if self.server:
self.host = self.config.get('Server', 'Host')
self.port = self.config.getint('Server', 'Port')
self.headless = self.config.getboolean('Server', 'Headless')
else:
self.key, self.mouse = {}, {}
for cmd, alias in self.CONTROL_ALIASES:
i = self.config.get('Control', cmd)
if re.match('mouse[1-3]$', i.lower()):
if alias not in ('shot', 'slash'):
raise ValueError(self.WEIRD_MOUSE_ERR.format(cmd))
self.mouse[alias] = int(i[-1]) - 1
continue
if len(i) == 1:
self.key[alias] = ord(i.lower())
continue
try:
self.key[alias] = getattr(pygame, 'K_{}'.format(i.upper()))
except AttributeError:
raise ValueError(self.INVALID_CONTROL_ERR.format(cmd, i))
self.size = (self.config.getint('Graphics', 'Screen width'),
self.config.getint('Graphics', 'Screen height'))
self.opengl = self.config.getboolean('Graphics', 'OpenGL')
self.max_fps = self.config.getint('Graphics', 'Maximum FPS')
self.muted = self.config.getboolean('Sound', 'Muted')
self.musicvol = self.config.getfloat('Sound', 'Music volume')
def read_args(self, arguments):
"""Read and parse a ArgumentParser.Namespace."""
for option in 'size', 'opengl', 'max_fps', 'muted', 'musicvol':
value = getattr(arguments, option)
if value is not None: setattr(self, option, value)
class Game:
"""Object handling main loop and IO."""
def __init__(self, config):
pygame.mixer.pre_init(frequency=44100)
pygame.init()
if config.muted or config.headless:
pygame.mixer.quit()
else:
pygame.mixer.music.load(MUSIC)
pygame.mixer.music.set_volume(config.musicvol)
pygame.mixer.music.play(-1)
pygame.display.set_icon(ICON)
pygame.fastevent.init()
if config.server:
self.server = socket()
self.server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
self.server.bind((config.host, config.port))
self.server.listen(1)
print('Socket server is listening on {}:{}'.format(config.host,
config.port))
else:
self.server = None
self.headless = config.headless
# 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.key, self.mouse = config.key, config.mouse
scrtype = (config.opengl and DOUBLEBUF|OPENGL) | RESIZABLE
self.maze = Maze(config.max_fps, config.size, scrtype, config.headless)
self.hero = self.maze.hero
self.clock, self.paused = Clock(), False
def __enter__(self): return self
def expos(self, x, y):
"""Return position of the given coordinates in rounded percent."""
cx = (x+self.maze.x-self.maze.centerx) / self.maze.distance * 100
cy = (y+self.maze.y-self.maze.centery) / self.maze.distance * 100
return round2(cx), round2(cy)
def export(self):
"""Export maze data to a bytes object."""
maze, hero, tick = self.maze, self.hero, get_ticks()
walls = [[1 if maze.map[x][y] == WALL else 0 for x in maze.rangex]
for y in maze.rangey] if maze.next_move <= tick else []
lines, ne, nb = deque(), 0, 0
for enemy in maze.enemies:
if not enemy.awake and walls:
walls[enemy.y-maze.rangey[0]][enemy.x-maze.rangex[0]] = WALL
continue
elif enemy.color == 'Chameleon' and maze.next_move <= tick:
continue
x, y = self.expos(*enemy.get_pos())
lines.append('{} {} {} {:.0f}'.format(COLORS[enemy.get_color()],
x, y, degrees(enemy.angle)))
ne += 1
for bullet in maze.bullets:
x, y = self.expos(bullet.x, bullet.y)
color, angle = COLORS[bullet.get_color()], degrees(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))
x, y = self.expos(maze.x, maze.y)
lines.appendleft('{} {} {} {} {} {} {:.0f} {:d} {:d}'.format(
len(walls), ne, nb, maze.get_score(), x, y, hero.wound * 100,
hero.next_strike <= tick, hero.next_heal <= tick))
return '\n'.join(lines).encode()
def update(self):
"""Draw and handle meta events on Pygame window.
Return False if QUIT event is captured, True otherwise.
"""
# Compare current FPS with the average of the last 10 frames
new_fps = self.clock.get_fps()
if new_fps < self.fps:
self.fps -= 1
elif self.fps < self.max_fps and not self.paused:
self.fps += 5
if not self.paused: self.maze.update(self.fps)
self.clock.tick(self.fps)
events = pygame.fastevent.get()
for event in events:
if event.type == QUIT:
return False
elif event.type == VIDEORESIZE:
self.maze.resize((event.w, event.h))
elif event.type == KEYDOWN and not self.server:
if event.key == self.key['new']:
self.maze.reinit()
elif event.key == self.key['pause'] and not self.hero.dead:
self.paused ^= True
elif event.key == self.key['mute']:
if pygame.mixer.get_init() is None:
pygame.mixer.init(frequency=44100)
pygame.mixer.music.load(MUSIC)
pygame.mixer.music.set_volume(self.musicvol)
pygame.mixer.music.play(-1)
else:
pygame.mixer.quit()
if not self.headless: self.maze.draw()
return True
def move(self, x, y):
"""Command the hero to move faster in the given direction."""
x, y = -x, -y # or move the maze in the reverse direction
stunned = pygame.time.get_ticks() < self.maze.next_move
velocity = self.maze.distance * HERO_SPEED / self.fps
accel = velocity * HERO_SPEED / self.fps
if stunned or not x:
self.maze.vx -= sign(self.maze.vx) * accel
if abs(self.maze.vx) < accel * 2: self.maze.vx = 0.0
elif x * self.maze.vx < 0:
self.maze.vx += x * 2 * accel
else:
self.maze.vx += x * accel
if abs(self.maze.vx) > velocity: self.maze.vx = x * velocity
if stunned or not y:
self.maze.vy -= sign(self.maze.vy) * accel
if abs(self.maze.vy) < accel * 2: self.maze.vy = 0.0
elif y * self.maze.vy < 0:
self.maze.vy += y * 2 * accel
else:
self.maze.vy += y * accel
if abs(self.maze.vy) > velocity: self.maze.vy = y * velocity
def control(self, x, y, angle, firing, slashing):
"""Control how the hero move and attack."""
self.move(x, y)
self.hero.update_angle(angle)
self.hero.firing = firing
self.hero.slashing = slashing
def remote_control(self):
"""Handle remote control though socket server.
This function is supposed to be run in a Thread.
"""
while True:
connection, address = self.server.accept()
print('Connected to {}:{}'.format(*address))
self.maze.reinit()
while not self.hero.dead:
data = self.export()
connection.send('{:06}'.format(len(data)).encode())
connection.send(data)
buf = connection.recv(8)
if not buf: break
move, angle, attack = (int(i) for i in buf.decode().split())
y, x = (i - 1 for i in divmod(move, 3))
self.control(x, y, radians(angle), attack & 1, attack >> 1)
self.maze.lose()
print('{1}:{2} scored {0} points'.format(
self.maze.get_score(), *address))
connection.close()
def user_control(self):
"""Handle direct control from user's mouse and keyboard."""
if not self.hero.dead:
keys = pygame.key.get_pressed()
right = keys[self.key['right']] - keys[self.key['left']]
down = keys[self.key['down']] - keys[self.key['up']]
# Follow the mouse cursor
x, y = pygame.mouse.get_pos()
angle = atan2(y - self.hero.y, x - self.hero.x)
buttons = pygame.mouse.get_pressed()
try:
firing = keys[self.key['shot']]
except KeyError:
firing = buttons[self.mouse['shot']]
try:
slashing = keys[self.key['slash']]
except KeyError:
slashing = buttons[self.mouse['slash']]
self.control(right, down, angle, firing, slashing)
def __exit__(self, exc_type, exc_value, traceback):
if self.server is not None: self.server.close()
pygame.quit()
def main():
"""Start game and main loop."""
# Read configuration file
dirs = AppDirs(appname='brutalmaze', appauthor=False, multipath=True)
parents = dirs.site_config_dir.split(pathsep)
parents.append(dirs.user_config_dir)
filenames = [join(parent, 'settings.ini') for parent in parents]
config = ConfigReader(filenames)
config.parse()
# Parse command-line arguments
parser = ArgumentParser(formatter_class=RawTextHelpFormatter)
parser.add_argument('-v', '--version', action='version',
version='Brutal Maze {}'.format(__version__))
parser.add_argument(
'--write-config', nargs='?', const=stdout, type=FileType('w'),
metavar='PATH', dest='defaultcfg',
help='write default config and exit, if PATH not specified use stdout')
parser.add_argument(
'-c', '--config', metavar='PATH',
help='location of the configuration file (fallback: {})'.format(
pathsep.join(filenames)))
parser.add_argument(
'-s', '--size', type=int, nargs=2, metavar=('X', 'Y'),
help='the desired screen size (fallback: {}x{})'.format(*config.size))
parser.add_argument(
'--opengl', action='store_true', default=None,
help='enable OpenGL (fallback: {})'.format(config.opengl))
parser.add_argument('--no-opengl', action='store_false', dest='opengl',
help='disable OpenGL')
parser.add_argument(
'-f', '--max-fps', type=int, metavar='FPS',
help='the desired maximum FPS (fallback: {})'.format(config.max_fps))
parser.add_argument(
'--mute', '-m', action='store_true', default=None,
help='mute all sounds (fallback: {})'.format(config.muted))
parser.add_argument('--unmute', action='store_false', dest='muted',
help='unmute sound')
parser.add_argument(
'--music-volume', type=float, metavar='VOL', dest='musicvol',
help='between 0.0 and 1.0 (fallback: {})'.format(config.musicvol))
args = parser.parse_args()
if args.defaultcfg is not None:
with open(SETTINGS) as settings: args.defaultcfg.write(settings.read())
args.defaultcfg.close()
exit()
# Manipulate config
if args.config: config.config.read(args.config)
config.read_args(args)
config.parse()
# Main loop
with Game(config) as game:
if config.server:
socket_thread = Thread(target=game.remote_control)
socket_thread.daemon = True # make it disposable
socket_thread.start()
while game.update(): pass
else:
while game.update(): game.user_control()

View File

@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
# maze.py - module for the maze class # maze.py - module for the maze class
# Copyright (C) 2017, 2018 Nguyễn Gia Phong # Copyright (C) 2017-2020 Nguyễn Gia Phong
# #
# This file is part of Brutal Maze. # This file is part of Brutal Maze.
# #
@ -19,38 +18,24 @@
__doc__ = 'Brutal Maze module for the maze class' __doc__ = 'Brutal Maze module for the maze class'
from collections import deque import json
from math import pi, log from collections import defaultdict, deque
from random import choice, getrandbits, uniform from math import log, pi
from os import path
from random import choice, sample
import pygame import pygame
from pygame import RESIZABLE
from pygame.time import get_ticks
from .characters import Hero, new_enemy from .characters import Hero, new_enemy
from .constants import * from .constants import (ADJACENTS, ATTACK_SPEED, BG_COLOR,
from .misc import round2, sign, regpoly, fill_aapolygon, play BULLET_LIFETIME, CELL_NODES, CELL_WIDTH, COLORS,
from .weapons import Bullet EMPTY, ENEMIES, ENEMY, ENEMY_HP, FG_COLOR, HERO,
HERO_HP, HERO_SPEED, INIT_SCORE, JSON_SEPARATORS,
MAX_WOUND, MAZE_SIZE, MIDDLE, ROAD_WIDTH,
def new_cell(bit, upper=True): SFX_LOSE, SFX_MISSED, SFX_SLASH_ENEMY, SFX_SPAWN,
"""Return a half of a cell of the maze based on the given bit.""" SQRT2, TANGO_VALUES, WALL, WALL_WIDTH)
if bit: return deque([WALL]*ROAD_WIDTH + [EMPTY]*ROAD_WIDTH) from .misc import around, deg, fill_aapolygon, json_rec, play, regpoly, sign
if upper: return deque([WALL] * (ROAD_WIDTH<<1)) from .weapons import LockOn
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:
@ -68,49 +53,103 @@ 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 Bullet): flying bullets bullets (list of .weapons.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
next_move (int): the tick that the hero gets mobilized destx, desty (int): the grid the hero is moving to
next_slashfx (int): the tick to play next slash effect of the hero stepx, stepy (int): direction the maze is moving
target (Enemy or LockOn): target to automatically aim at
next_move (float): time until the hero gets mobilized (in ms)
glitch (float): time that the maze remain flashing colors (in ms)
next_slashfx (float): time until next slash effect of the hero (in ms)
slashd (float): minimum distance for slashes to be effective slashd (float): minimum distance for slashes to be effective
sfx_slash (pygame.mixer.Sound): sound effect of slashed enemy export (list of defaultdict): records of game states
sfx_lose (pygame.mixer.Sound): sound effect to be played when you lose export_dir (str): directory containing records of game states
export_rate (float): milliseconds per snapshot
next_export (float): time until next snapshot (in ms)
""" """
def __init__(self, fps, size, scrtype, headless=False): def __init__(self, fps, size, headless, export_dir, export_rate):
self.fps = fps self.fps = fps
if not headless: self.w, self.h = size
self.w, self.h = size if headless:
self.scrtype = scrtype self.surface = None
self.surface = pygame.display.set_mode(size, self.scrtype) else:
self.surface = pygame.display.set_mode(size, pygame.RESIZABLE)
self.export_dir = path.abspath(export_dir) if export_dir else ''
self.next_export = self.export_rate = export_rate
self.export = []
self.distance = (self.w * self.h / 416) ** 0.5 self.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.0, self.h / 2.0 self.centerx, self.centery = self.w / 2, self.h / 2
w, h = (int(i/self.distance/2 + 2) 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) self.hero = Hero(self.surface, fps, size)
self.map[MIDDLE][MIDDLE] = HERO self.target = LockOn(MIDDLE, MIDDLE, retired=True)
self.next_move = self.next_slashfx = 0 self.next_move = self.glitch = 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]
@ -118,34 +157,51 @@ 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 ADJACENT_GRIDS): if all(self.map[x + a][y + b] == WALL for a, b in ADJACENTS):
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): if plum is None or not plum.clone(enemy): walls.remove((x, y))
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)
if get_ticks() >= self.next_move: if self.next_move <= 0:
for i in self.rangex: for i in self.rangex:
for j in self.rangey: for j in self.rangey:
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, FG_COLOR) fill_aapolygon(self.surface, square, self.get_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()
@ -160,47 +216,46 @@ 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: self.map[enemy.x][enemy.y] = EMPTY for enemy in self.enemies:
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
if x: self.centerx -= x * self.distance
self.centerx -= x * self.distance self.map.rotate(x)
self.map.rotate(x) self.rotatex += x
self.rotatex += x self.centery -= y * self.distance
if y: for d in self.map: d.rotate(y)
self.centery -= y * self.distance self.rotatey += y
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 enemy.x not in self.rangex or enemy.y not in self.rangey: if not self.isdisplayed(enemy.x, enemy.y):
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 _ in range(CELL_WIDTH): self.map.pop() for i in range(CELL_WIDTH): self.map[i].rotate(-self.rotatey)
self.map.extend(new_column()) for i in range(MAZE_SIZE): self.new_cell(0, i)
for i in range(-CELL_WIDTH, 0): for i in range(CELL_WIDTH): self.map[i].rotate(self.rotatey)
self.map[i].rotate(self.rotatey)
if abs(self.rotatey) == CELL_WIDTH: if abs(self.rotatey) == CELL_WIDTH:
self.rotatey = 0 self.rotatey = 0
for i in range(MAZE_SIZE): self.map.rotate(-self.rotatex)
b, c = getrandbits(1), (i-1)*CELL_WIDTH + self.rotatex for i in range(MAZE_SIZE): self.new_cell(i, 0)
for j, grid in enumerate(new_cell(b)): self.map.rotate(self.rotatex)
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
@ -210,78 +265,84 @@ 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."""
fx = (uniform(0, sum(self.enemy_weights.values())) if color == 'Orange':
< self.enemy_weights[color]) # If called by close-range attack, this is FPS-dependant, although
time = get_ticks() # 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, time) + wound*1000 if color == 'Butter' or color == 'ScarletRed':
elif color == 'SkyBlue' and fx: wound *= ENEMY_HP
self.next_move = max(self.next_move, time) + 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
killist = [] for enemy in filter(lambda e: e.awake, self.enemies):
for i, enemy in enumerate(self.enemies): d = self.slashd - enemy.distance
d = self.slashd - enemy.get_distance()
if d > 0: if d > 0:
wound, time = d * SQRT2 / self.distance, get_ticks() wound = d * SQRT2 / self.distance
if time >= self.next_slashfx: if self.next_slashfx <= 0:
play(self.sfx_slash, wound, enemy.get_angle()) play(SFX_SLASH_ENEMY, enemy.x, enemy.y, wound)
self.next_slashfx = time + 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."""
fallen, time = [], get_ticks() self.bullets.extend(self.hero.shots)
if (self.hero.firing and not self.hero.slashing fallen = []
and time >= self.hero.next_strike): block = (self.hero.spin_queue and self.hero.next_heal < 0
self.hero.next_strike = time + ATTACK_SPEED and self.hero.next_strike > self.hero.spin_queue / self.fps)
self.bullets.append(Bullet(self.surface, self.x, self.y,
self.hero.angle, 'Aluminium'))
for i, bullet in enumerate(self.bullets): for i, bullet in enumerate(self.bullets):
wound = float(bullet.fall_time-time) / BULLET_LIFETIME wound = bullet.fall_time / BULLET_LIFETIME
bullet.update(self.fps, self.distance) bullet.update(self.fps, self.distance)
if wound < 0: gridx, gridy = self.get_grid(bullet.x, bullet.y)
if wound <= 0 or not self.isdisplayed(gridx, gridy):
fallen.append(i) fallen.append(i)
elif bullet.color == 'Aluminium': elif bullet.color == 'Aluminium':
x = MIDDLE + round2((bullet.x-self.x) / self.distance) active_enemies = [e for e in self.enemies if e.awake]
y = MIDDLE + round2((bullet.y-self.y) / self.distance) if self.map[gridx][gridy] == WALL and self.next_move <= 0:
if self.map[x][y] == WALL and time >= self.next_move:
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 j, enemy in enumerate(self.enemies): for enemy in active_enemies:
if not enemy.awake: continue if bullet.get_distance(*enemy.pos) < self.distance:
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.enemies.pop(j) self.add_enemy()
play(bullet.sfx_hit, wound, bullet.angle) play(bullet.sfx_hit, gridx, gridy, wound)
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 self.hero.spin_queue: if block:
play(bullet.sfx_missed, wound, bullet.angle + pi) self.hero.next_strike = (abs(self.hero.spin_queue/self.fps)
+ ATTACK_SPEED)
play(SFX_MISSED, gain=wound)
else: else:
self.hit_hero(wound, bullet.color) self.hit_hero(wound, bullet.color)
play(bullet.sfx_hit, wound, bullet.angle + pi) play(bullet.sfx_hit, gain=wound)
fallen.append(i) fallen.append(i)
for i in reversed(fallen): self.bullets.pop(i) for i in reversed(fallen): self.bullets.pop(i)
@ -299,35 +360,81 @@ 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
dx = self.is_valid_move(vx=self.vx) self.vx = self.is_valid_move(vx=self.vx)
self.centerx += dx self.centerx += self.vx
dy = self.is_valid_move(vy=self.vy) self.vy = self.is_valid_move(vy=self.vy)
self.centery += dy self.centery += self.vy
if dx or dy: self.next_move -= 1000 / fps
self.rotate() self.glitch -= 1000 / fps
self.next_slashfx -= 1000 / fps
self.next_export -= 1000 / fps
self.rotate()
if self.vx or self.vy or self.hero.firing or self.hero.slashing:
for enemy in self.enemies: enemy.wake() for enemy in self.enemies: enemy.wake()
for bullet in self.bullets: bullet.place(dx, dy) for bullet in self.bullets: bullet.place(self.vx, self.vy)
for enemy in self.enemies: enemy.update() 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()
self.track_bullets() if self.hero.wound >= HERO_HP: self.lose()
self.update_export()
def resize(self, size): def resize(self, size):
"""Resize the maze.""" """Resize the maze."""
self.w, self.h = size self.w, self.h = size
self.surface = pygame.display.set_mode(size, self.scrtype) self.surface = pygame.display.set_mode(size, pygame.RESIZABLE)
self.hero.resize() self.hero.resize(size)
offsetx = (self.centerx-self.x) / self.distance offsetx = (self.centerx-self.x) / self.distance
offsety = (self.centery-self.y) / self.distance offsety = (self.centery-self.y) / self.distance
@ -335,35 +442,89 @@ class Maze:
self.x, self.y = self.w // 2, self.h // 2 self.x, self.y = self.w // 2, self.h // 2
self.centerx = self.x + offsetx*self.distance self.centerx = self.x + offsetx*self.distance
self.centery = self.y + offsety*self.distance self.centery = self.y + offsety*self.distance
w, h = int(self.w/self.distance/2 + 2), int(self.h/self.distance/2 + 2) w, h = int(self.w/self.distance/2 + 1), int(self.h/self.distance/2 + 1)
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.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(self.sfx_lose) play(SFX_LOSE)
self.dump_records()
def reinit(self): def reinit(self):
"""Open new game.""" """Open new game."""
self.centerx, self.centery = self.w / 2.0, self.h / 2.0 self.centerx, self.centery = self.w / 2, self.h / 2
self.score = INIT_SCORE self.score, self.export = INIT_SCORE, []
self.map = deque() self.new_map()
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 = 0 self.next_move = self.next_slashfx = self.hero.next_strike = 0.0
self.hero.next_heal = self.hero.next_beat = self.hero.next_strike = 0 self.target = LockOn(MIDDLE, MIDDLE, retired=True)
self.hero.next_heal = -1.0
self.hero.highness = 0.0
self.hero.slashing = self.hero.firing = self.hero.dead = False 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,6 +1,5 @@
# -*- coding: utf-8 -*-
# misc.py - module for miscellaneous functions # misc.py - module for miscellaneous functions
# Copyright (C) 2017, 2018 Nguyễn Gia Phong # Copyright (C) 2017-2020 Nguyễn Gia Phong
# #
# This file is part of Brutal Maze. # This file is part of Brutal Maze.
# #
@ -19,16 +18,17 @@
__doc__ = 'Brutal Maze module for miscellaneous functions' __doc__ = 'Brutal Maze module for miscellaneous functions'
from math import cos, sin, pi from datetime import datetime
from random import uniform from itertools import chain
from math import cos, degrees, pi, sin
from os import path
from random import shuffle
import pygame import pygame
from pygame.gfxdraw import filled_polygon, aapolygon from palace import Buffer, Source
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 the pointlist of the regular polygon with n sides, """Return pointlist of a regular n-gon with circumradius of R,
circumradius of R, the center point I(x, y) and one point A make the center point I(x, y) and corner A that angle of vector IA is r
vector IA with angle r (in radians). (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)
@ -57,38 +57,44 @@ def sign(n):
return -1 if n < 0 else 1 if n else 0 return -1 if n < 0 else 1 if n else 0
def cosin(x): def deg(x):
"""Return the sum of cosine and sine of x (measured in radians).""" """Convert angle x from radians to degrees,
return cos(x) + sin(x) casted to a nonnegative integer.
def choices(d):
"""Choose a random key from a dict which has values being relative
weights of the coresponding keys.
""" """
population, weights = tuple(d.keys()), tuple(d.values()) return round((lambda a: a if a > 0 else a + 360)(degrees(x)))
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 play(sound, volume=1.0, angle=None): def join(iterable, sep=' ', end='\n'):
"""Play a pygame.mixer.Sound at the given volume.""" """Return a string which is the concatenation of string
if pygame.mixer.get_init() is None: return representations of objects in the iterable, separated by sep.
if pygame.mixer.find_channel() is None:
pygame.mixer.set_num_channels(pygame.mixer.get_num_channels() + 1)
channel = sound.play() end is appended to the resulting string.
if angle is None: """
channel.set_volume(volume) return sep.join(map(str, iterable)) + end
else:
delta = cos(angle)
volumes = [volume * (1-delta), volume * (1+delta)] def around(x, y):
for i, v in enumerate(volumes): """Return grids around the given one in random order."""
if v > 1: a = [(x + i, y + j) for i, j in ADJACENTS]
volumes[i - 1] += v - 1 shuffle(a)
volumes[i] = 1.0 c = [(x + i, y + j) for i, j in CORNERS]
sound.set_volume(1.0) shuffle(c)
channel.set_volume(*volumes) return chain(a, c)
def json_rec(directory):
"""Return path to JSON file to be created inside the given directory
based on current time local to timezone in ISO 8601 format.
"""
return path.join(
directory, '{}.json'.format(datetime.now().isoformat()[:19]))
def play(sound: str, x: float = MIDDLE, y: float = MIDDLE,
gain: float = 1.0) -> Source:
"""Play a sound at the given position."""
source = Buffer(sound).play()
source.spatialize = True
source.position = x, -y, 0
source.gain = gain
return source

View File

@ -1,25 +1,17 @@
[Server]
# Enabling remote control will disable control via keyboard and mouse.
Enable: no
Host: localhost
Port: 8089
# Disable graphics and sounds (only if socket server is enabled).
Headless: no
[Graphics] [Graphics]
Screen width: 640 Screen width: 640
Screen height: 480 Screen height: 480
# OpenGL should be supported on all machines with hardware acceleration.
OpenGL: no
# FPS should not be greater than refresh rate. # FPS should not be greater than refresh rate.
Maximum FPS: 60 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):
@ -28,9 +20,25 @@ Music volume: 1.0
New game: F2 New game: F2
Toggle pause: p Toggle pause: p
Toggle mute: m Toggle mute: m
Move left: Left Move left: a
Move right: Right Move right: d
Move up: Up Move up: w
Move down: Down Move down: s
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]
# 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

View File

@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
# characters.py - module for weapon classes # characters.py - module for weapon classes
# Copyright (C) 2017, 2018 Nguyễn Gia Phong # Copyright (C) 2017-2020 Nguyễn Gia Phong
# #
# This file is part of Brutal Maze. # This file is part of Brutal Maze.
# #
@ -21,10 +20,9 @@ __doc__ = 'Brutal Maze module for weapon classes'
from math import cos, sin from math import cos, sin
from pygame.time import get_ticks from .constants import (BG_COLOR, BULLET_LIFETIME, BULLET_SPEED,
ENEMY_HP, SFX_SHOT_ENEMY, SFX_SHOT_HERO, TANGO)
from .constants import * from .misc import fill_aapolygon, regpoly
from .misc import regpoly, fill_aapolygon
class Bullet: class Bullet:
@ -35,29 +33,28 @@ class Bullet:
x, y (int): coordinates of the center of the bullet (in pixels) x, y (int): coordinates of the center of the bullet (in pixels)
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): the tick that the bullet will fall down fall_time (int): time until the bullet fall down
sfx_hit (pygame.mixer.Sound): sound effect indicating target was hit sfx_hit (str): 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
self.x, self.y, self.angle, self.color = x, y, angle, color self.x, self.y, self.angle, self.color = x, y, angle, color
self.fall_time = get_ticks() + BULLET_LIFETIME self.fall_time = BULLET_LIFETIME
if color == 'Aluminium': if color == 'Aluminium':
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
def get_color(self): def get_color(self):
"""Return current color of the enemy.""" """Return current color of the enemy."""
value = int((1-(self.fall_time-get_ticks())/BULLET_LIFETIME)*ENEMY_HP) value = int((1 - self.fall_time/BULLET_LIFETIME) * ENEMY_HP)
try: try:
return TANGO[self.color][value] return TANGO[self.color][value]
except IndexError: except IndexError:
@ -76,3 +73,22 @@ 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

@ -0,0 +1,104 @@
using System;
using System.Text;
using System.Net.Sockets;
namespace BrutalmazeClient
{
class Program
{
static void Main(string[] args)
{
const string host = "localhost";
const int port = 42069;
Socket client_socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
client_socket.Connect(host, port);
Random rnd = new Random();
int recv, sent;
byte[] buff = new byte[1000];
byte[] query;
string[] matrix = new string[100];
const int MAGIC = 42; // For escape
string l, data, l1;
int sz, nl;
int nh, ne, nb, score;
char hC;
int hX, hY, hA, canAtk, canReg;
int prevX = 1234, prevY = 5678;
int dir = 0, deg = 0, atk = 1;
int needEsc = 0;
while (42 < 420)
{
try
{
recv = client_socket.Receive(buff, 7, 0);
}
catch (SocketException e)
{
Console.WriteLine(e.ToString());
break;
}
l = Encoding.ASCII.GetString(buff, 0, 7);
sz = Int32.Parse(l);
if (sz == 0)
break;
recv = client_socket.Receive(buff, sz, 0);
data = Encoding.ASCII.GetString(buff, 0, sz);
// Standardize Data
nl = 0;
l1 = data.Split('\n')[nl];
nh = Int32.Parse(l1.Split(' ')[0]);
ne = Int32.Parse(l1.Split(' ')[1]);
nb = Int32.Parse(l1.Split(' ')[2]);
score = Int32.Parse(l1.Split(' ')[3]);
for (int i = 0; i < nh; ++i, ++nl)
matrix[i] = data.Split('\n')[i + 1];
l1 = data.Split('\n')[++nl];
hC = Char.Parse(l1.Split(' ')[0]);
hX = Int32.Parse(l1.Split(' ')[1]);
hY = Int32.Parse(l1.Split(' ')[2]);
hA = Int32.Parse(l1.Split(' ')[3]);
canAtk = Int32.Parse(l1.Split(' ')[4]);
canReg = Int32.Parse(l1.Split(' ')[5]);
for(int i = 1; i <= ne; ++i, ++nl)
{
}
for(int i = 1; i <= nb; ++i, ++nl)
{
}
// Process
if (needEsc == 0)
{
dir = 0;
if (prevX == hX && prevY == hY)
{
int matX = hX / 100, matY = hY / 100;
if (matrix[matY - 1][matX + 2] == '0' && matrix[matY - 1][matX - 2] == '1')
{
dir = 5;
needEsc = 1;
}
if (matrix[matY - 1][matX + 2] == '1' && matrix[matY + 1][matX - 2] == '0')
{
dir = 7;
needEsc = 1;
}
}
}
else
{
needEsc = (needEsc + 1) % MAGIC;
}
deg = rnd.Next(-4, 5) * 10;
atk = rnd.Next(1, 1);
query = Encoding.ASCII.GetBytes(dir.ToString() + " " + deg.ToString() + " " + atk.ToString());
sent = client_socket.Send(query);
prevX = hX;
prevY = hY;
}
client_socket.Shutdown(SocketShutdown.Both);
client_socket.Close();
}
}
}

72
client-examples/hit-and-run.py Executable file
View File

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

20
docs/Makefile Normal file
View File

@ -0,0 +1,20 @@
# 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)

35
docs/make.bat Normal file
View File

@ -0,0 +1,35 @@
@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

1
docs/requirements.txt Normal file
View File

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

View File

@ -0,0 +1,101 @@
// @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

@ -0,0 +1,34 @@
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

@ -0,0 +1,29 @@
<!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>

61
docs/source/conf.py Normal file
View File

@ -0,0 +1,61 @@
# 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']

129
docs/source/config.rst Normal file
View File

@ -0,0 +1,129 @@
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.

109
docs/source/copying.rst Normal file
View File

@ -0,0 +1,109 @@
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/

BIN
docs/source/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

116
docs/source/gameplay.rst Normal file
View File

@ -0,0 +1,116 @@
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

24
docs/source/icon.svg Normal file
View File

@ -0,0 +1,24 @@
<?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>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

37
docs/source/index.rst Normal file
View File

@ -0,0 +1,37 @@
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>

38
docs/source/install.rst Normal file
View File

@ -0,0 +1,38 @@
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

1
docs/source/record.json Normal file

File diff suppressed because one or more lines are too long

250
docs/source/remote.rst Normal file
View File

@ -0,0 +1,250 @@
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

32
pyproject.toml Normal file
View File

@ -0,0 +1,32 @@
[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'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

View File

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

View File

@ -1,32 +0,0 @@
#!/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.5.4',
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={'gui_scripts': ['brutalmaze = brutalmaze:main']})

21
tox.ini Normal file
View File

@ -0,0 +1,21 @@
[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

@ -1 +0,0 @@
Subproject commit 34af1cf8b3e3ea8272d6793a794484a239794d50