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

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 is a hack and slash game with fast-paced action and a minimalist
art style.
Brutal Maze is a thrilling shoot 'em up game with minimalist 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
escape, the maze's border turns into aggressive squares trying to stop him.
Your job is to help the trigon fight against those evil squares and find a way
out (if there is any). Be aware that the more get killed, the more will show up
and our hero will get weaker when wounded.
The game features a trigon trapped in an infinite maze. As our hero tries
to escape, the maze's border turns into aggressive squares trying to stop per.
Your job is to help the trigon fight against those evil squares and find
a way out (if there is any). Be aware that the more get killed,
the more will show up and our hero will get weaker when wounded.
Brutal Maze has a few notable feautures:
Brutal Maze has a few notable features:
* Being highly portable.
* Auto-generated and infinite maze.
* Auto-generated and infinite maze. [0]_
* No binary data for drawing.
* Enemies with special abilities: stun, poison, camo, etc.
* Somewhat a realistic physic and logic system.
* Resizable game window in-game.
* Easily customizable via INI file format.
* Recordable in JSON (some kind of silent screencast).
* Remote control through TCP/IP socket (can be used in AI researching).
Installation
------------
Brutal Maze is written in Python and is compatible with both version 2 and 3.
The installation procedure should be as simply as follow:
Brutal Maze is written in Python and is compatible version 3.6 and above.
The installation procedure should be as simple as follows:
* Install Python and `pip <https://pip.pypa.io/en/latest/>`_. Make sure the
directory for `Python scripts <https://docs.python.org/2/install/index.html#alternate-installation-the-user-scheme>`_
* Install Python and pip_. Make sure the directory for `Python scripts`_
is in your ``$PATH``.
* 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>`_
from Brutal Maze wiki.
For more information, see Installation_ page from the documentation.
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
-------------
Brutal Maze supports both configuration file and command-line options.
Apparently one can change settings for graphics and control in the config file
and set graphics options using in CLI. These settings are read in the following
order:
Apparently, while settings for graphics, sound and socket server can be set
either in the config file or using CLI, keyboard and mouse bindings are limited
to configuration file only.
0. Default configuration [0]_
1. System-wide configuration file [1]_
2. Local configuration file [1]_
3. Manually set configuration file [2]_
Settings are read in the following order:
0. Default configuration [1]_
1. System-wide configuration file [2]_
2. Local configuration file [2]_
3. Manually set configuration file [3]_
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
PATH``. ``brutalmaze --write-config`` alone will print the file to stdout.
.. [1] These will be listed as fallback config in the help message
(``brutalmaze --help``). See `wiki <https://github.com/McSinyx/brutalmaze/wiki/Configuration>`_
for more info.
.. [2] If specified by ``brutalmaze --config PATH``.
Remote control
--------------
If you enable the socket server [4]_, Brutal Maze will no longer accept
direct input from your mouse or keyboard, but wait for a client to connect.
The I/O format is explained in details in the `Remote Control`_ page.
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
# Copyright (C) 2017, 2018 Nguyễn Gia Phong
# Copyright (C) 2017-2020 Nguyễn Gia Phong
#
# This file is part of Brutal Maze.
#
@ -19,15 +18,16 @@
__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 sys import modules
import pygame
from pygame.time import get_ticks
from .constants import *
from .misc import sign, cosin, randsign, regpoly, fill_aapolygon, choices, play
from .constants import (ADJACENTS, AROUND_HERO, ATTACK_SPEED, EMPTY,
ENEMIES, ENEMY, ENEMY_HP, ENEMY_SPEED, FIRANGE,
HEAL_SPEED, HERO_HP, MIDDLE, MIN_BEAT, SFX_HEART,
SFX_SLASH_HERO, SFX_SPAWN, SQRT2, TANGO, WALL)
from .misc import fill_aapolygon, play, randsign, regpoly, sign
from .weapons import Bullet
@ -40,67 +40,111 @@ class Hero:
angle (float): angle of the direction the hero pointing (in radians)
color (tuple of pygame.Color): colors of the hero on different HPs
R (int): circumradius of the regular triangle representing the hero
next_heal (int): the tick that the hero gains back healing ability
next_beat (int): the tick to play next heart beat
next_strike (int): the tick that the hero can do the next attack
slashing (bool): flag indicates if the hero is doing close-range attack
firing (bool): flag indicates if the hero is doing long-range attack
dead (bool): flag indicates if the hero is dead
next_heal (float): minimum wound in ATTACK_SPEED allowing healing again
next_beat (float): time until next heart beat (in ms)
next_strike (float): time until the hero can do the next attack (in ms)
highness (float): likelihood that the hero shoots toward other angles
slashing (bool): flag indicating if the hero's doing close-range attack
firing (bool): flag indicating if the hero is doing long-range attack
dead (bool): flag indicating if the hero is dead
spin_speed (float): speed of spinning (in frames per slash)
spin_queue (float): frames left to finish spinning
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
w, h = self.surface.get_width(), self.surface.get_height()
w, h = maze_size
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.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.spin_speed = fps / HERO_HP
self.spin_queue = self.wound = 0.0
self.sfx_heart = SFX_HEART
self.wounds = deque([0.0])
def update(self, fps):
"""Update the hero."""
if self.dead:
self.spin_queue = 0.0
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_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
if self.wound < 0: self.wound = 0.0
if time > self.next_beat:
play(self.sfx_heart)
self.next_beat = time + MIN_BEAT*(2 - self.wound/HERO_HP)
self.wounds.append(0.0)
if self.next_beat <= 0:
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:
self.next_strike = time + ATTACK_SPEED
full_spin = pi * 2 / self.sides
if self.slashing and self.next_strike <= 0:
self.next_strike = ATTACK_SPEED
self.spin_queue = randsign() * self.spin_speed
if abs(self.spin_queue) > 0.5:
self.angle += sign(self.spin_queue) * pi / 2 / self.spin_speed
self.angle -= sign(self.spin_queue) * full_spin
if round(self.spin_queue) != 0:
self.angle += sign(self.spin_queue) * full_spin / self.spin_speed
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):
"""Turn to the given angle if the hero is not busy slashing."""
if abs(self.spin_queue) <= 0.5:
self.spin_queue = 0.0
self.angle = angle
if round(self.spin_queue) != 0: return
delta = (angle - self.angle + pi) % (pi * 2) - pi
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):
"""Draw the hero."""
trigon = regpoly(3, self.R, self.angle, self.x, self.y)
fill_aapolygon(self.surface, trigon, self.color[int(self.wound)])
trigon = regpoly(self.sides, self.R, self.angle, self.x, self.y)
fill_aapolygon(self.surface, trigon, self.get_color())
def resize(self):
def resize(self, maze_size):
"""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.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)
angle (float): angle of the direction the enemy pointing (in radians)
color (str): enemy's color name
awake (bool): flag indicates if the enemy is active
next_strike (int): the tick that the enemy can do the next attack
alive (bool): flag indicating if the enemy is alive
awake (bool): flag indicating if the enemy is active
next_strike (float): time until the enemy's next action (in ms)
move_speed (float): speed of movement (in frames per grid)
offsetx, offsety (integer): steps moved from the center of the grid
spin_speed (float): speed of spinning (in frames per slash)
spin_queue (float): frames left to finish spinning
wound (float): amount of wound
sfx_slash (pygame.mixer.Sound): sound effect of slashed hero
"""
def __init__(self, maze, x, y, color):
self.maze = maze
self.x, self.y = x, y
self.maze.map[x][y] = ENEMY
self.angle, self.color = pi / 4, color
self.awake = False
self.next_strike = 0
self.alive, self.awake = True, False
self.next_strike = 0.0
self.move_speed = self.maze.fps / ENEMY_SPEED
self.offsetx = self.offsety = 0
self.spin_speed = self.maze.fps / ENEMY_HP
self.spin_queue = self.wound = 0.0
self.sfx_slash = SFX_SLASH_HERO
def get_pos(self):
"""Return coordinate of the center of the enemy."""
@property
def pos(self):
"""Coordinates (in pixels) of the center of the enemy."""
x, y = self.maze.get_pos(self.x, self.y)
step = self.maze.distance * ENEMY_SPEED / self.maze.fps
return x + self.offsetx*step, y + self.offsety*step
def get_distance(self):
"""Return the distance from the center of the enemy to
the center of the maze.
@property
def distance(self):
"""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):
"""Move the enemy by (x, y) (in grids)."""
self.x += x
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):
"""Wake the enemy up if it can see the hero.
@ -162,36 +210,30 @@ class Enemy:
has just woken it, False otherwise.
"""
if self.awake: return None
startx = starty = MIDDLE
stopx, stopy, distance = self.x, self.y, self.maze.distance
if startx > stopx: startx, stopx = stopx, startx
if starty > stopy: starty, stopy = stopy, starty
dx = (self.x-MIDDLE)*distance + self.maze.centerx - self.maze.x
dy = (self.y-MIDDLE)*distance + self.maze.centery - self.maze.y
mind = cosin(abs(atan(dy / dx)) if dx else 0) * distance
def get_distance(x, y): return abs(dy*x - dx*y) / (dy**2 + dx**2)**0.5
for i in range(startx, stopx + 1):
for j in range(starty, stopy + 1):
if self.maze.map[i][j] != WALL: continue
x, y = self.maze.get_pos(i, j)
if get_distance(x - self.maze.x, y - self.maze.y) <= mind:
return False
srcx, destx = self.x, MIDDLE
if abs(destx - srcx) != 1: srcx += sign(destx - srcx) or 1
srcy, desty = self.y, MIDDLE
if abs(desty - srcy) != 1: srcy += sign(desty - srcy) or 1
m, n = destx - srcx, desty - srcy
lcm = abs(m * n // gcd(m, n))
w, u = lcm // m, lcm // n
for i in range(lcm):
if self.maze.map[srcx+i//w][srcy+i//u] == WALL: return False
self.awake = True
play(self.maze.sfx_spawn,
1 - self.get_distance()/self.maze.get_distance(0, 0)/2,
self.get_angle() + pi)
self.maze.map[self.x][self.y] = ENEMY
play(SFX_SPAWN, self.x, self.y)
return True
def fire(self):
"""Return True if the enemy has just fired, False otherwise."""
if self.maze.hero.dead: return False
x, y = self.get_pos()
x, y = self.pos
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 randrange((self.maze.hero.slashing+self.maze.isfast()+1) * 3)):
return False
self.next_strike = get_ticks() + ATTACK_SPEED
self.next_strike = ATTACK_SPEED
self.maze.bullets.append(
Bullet(self.maze.surface, x, y, self.get_angle() + pi, self.color))
return True
@ -204,13 +246,13 @@ class Enemy:
if self.offsety:
self.offsety -= sign(self.offsety)
return True
if get_ticks() < self.next_strike: return False
if self.next_strike > 0: return False
self.move_speed = self.maze.fps / speed
directions = [(sign(MIDDLE - self.x), 0), (0, sign(MIDDLE - self.y))]
shuffle(directions)
directions.append(choice(ADJACENT_GRIDS))
if self.maze.hero.dead: directions = choice(ADJACENT_GRIDS),
directions.append(choice(ADJACENTS))
if self.maze.hero.dead: directions = choice(ADJACENTS),
for x, y in directions:
if (x or y) and self.maze.map[self.x + x][self.y + y] == EMPTY:
self.offsetx = round(x * (1 - self.move_speed))
@ -222,34 +264,42 @@ class Enemy:
def get_slash(self):
"""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
def slash(self):
"""Return the enemy's close-range damage per frame."""
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
def get_angle(self, reversed=False):
def get_angle(self):
"""Return the angle of the vector whose initial point is
the center of the screen and terminal point is the center of
the enemy.
"""
x, y = self.get_pos()
x, y = self.pos
return atan2(y - self.maze.y, x - self.maze.x)
def get_color(self):
"""Return current color of the enemy."""
return TANGO[self.color][int(self.wound)] 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):
"""Draw the enemy."""
if get_ticks() < self.maze.next_move and not self.awake: return
radious = self.maze.distance/SQRT2 - self.awake*2
square = regpoly(4, radious, self.angle, *self.get_pos())
if self.isunnoticeable(): return
radius = self.maze.distance / SQRT2
square = regpoly(4, radius, self.angle, *self.pos)
fill_aapolygon(self.maze.surface, square, self.get_color())
def update(self):
@ -257,11 +307,12 @@ class Enemy:
if self.awake:
self.spin_speed, tmp = self.maze.fps / ENEMY_HP, self.spin_speed
self.spin_queue *= self.spin_speed / tmp
self.next_strike -= 1000 / self.maze.fps
if not self.spin_queue and not self.fire() and not self.move():
self.spin_queue = randsign() * self.spin_speed
if not self.maze.hero.dead:
play(self.sfx_slash, self.get_slash(), self.get_angle())
if abs(self.spin_queue) > 0.5:
play(SFX_SLASH_HERO, self.x, self.y, self.get_slash())
if round(self.spin_queue) != 0:
self.angle += sign(self.spin_queue) * pi / 2 / self.spin_speed
self.spin_queue -= sign(self.spin_queue)
else:
@ -271,46 +322,64 @@ class Enemy:
"""Handle the enemy when it's attacked."""
self.wound += wound
@property
def retired(self):
"""Provide compatibility with LockOn object."""
try:
return self._retired
except AttributeError:
return self.wound >= ENEMY_HP
@retired.setter
def retired(self, value):
self._retired = value
def die(self):
"""Handle the enemy's death."""
if self.awake:
self.maze.map[self.x][self.y] = EMPTY
if self.maze.enemy_weights[self.color] > MINW + 1.5:
self.maze.enemy_weights[self.color] -= 1.5
else:
self.maze.map[self.x][self.y] = WALL
self.maze.map[self.x][self.y] = EMPTY if self.wake else WALL
self.alive = False
class Chameleon(Enemy):
"""Object representing an enemy of Chameleon.
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):
Enemy.__init__(self, maze, x, y, 'Chameleon')
self.visible = 0
super().__init__(maze, x, y, 'Chameleon')
self.visible = 0.0
def wake(self):
"""Wake the Chameleon up if it can see the hero."""
if Enemy.wake(self) is True:
self.visible = get_ticks() + 1000//ENEMY_SPEED
if super().wake() is True:
self.visible = 1000 / ENEMY_SPEED
def draw(self):
"""Draw the Chameleon."""
if not self.awake or get_ticks() < self.visible or self.spin_queue:
Enemy.draw(self)
def isunnoticeable(self, x=None, y=None):
"""Return whether the enemy can be noticed.
Only search within column x and row y if these coordinates
are provided.
"""
return (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):
"""Handle the Chameleon when it's attacked."""
self.visible = get_ticks() + 1000//ENEMY_SPEED
Enemy.hit(self, wound)
self.visible = 1000.0 / ENEMY_SPEED
super().hit(wound)
class Plum(Enemy):
"""Object representing an enemy of Plum."""
def __init__(self, maze, x, y):
Enemy.__init__(self, maze, x, y, 'Plum')
super().__init__(maze, x, y, 'Plum')
def clone(self, other):
"""Turn the other enemy into a clone of this Plum and return
@ -327,24 +396,24 @@ class Plum(Enemy):
class ScarletRed(Enemy):
"""Object representing an enemy of Scarlet Red."""
def __init__(self, maze, x, y):
Enemy.__init__(self, maze, x, y, 'ScarletRed')
super().__init__(maze, x, y, 'ScarletRed')
def fire(self):
"""Scarlet Red doesn't shoot."""
return False
def move(self):
return Enemy.move(self, ENEMY_SPEED * SQRT2)
return super().move(self, ENEMY_SPEED * SQRT2)
def slash(self):
"""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
def new_enemy(maze, x, y):
"""Return an enemy of a random type in the grid (x, y)."""
color = choices(maze.enemy_weights)
color = choice(ENEMIES)
try:
return getattr(modules[__name__], color)(maze, x, y)
except AttributeError:

View File

@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
# 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.
#
@ -21,44 +20,44 @@ __doc__ = 'Brutal Maze module for shared constants'
from string import ascii_lowercase
from pkg_resources import resource_filename as pkg_file
import pygame
from pygame.mixer import Sound
from pkg_resources import resource_filename as pkg_file
SETTINGS = pkg_file('brutalmaze', 'settings.ini')
ICON = pygame.image.load(pkg_file('brutalmaze', 'icon.png'))
MUSIC = pkg_file('brutalmaze', 'soundfx/music.ogg')
mixer = pygame.mixer.get_init()
if mixer is None: pygame.mixer.init(frequency=44100)
SFX_SPAWN = Sound(pkg_file('brutalmaze', 'soundfx/spawn.ogg'))
SFX_SLASH_ENEMY = Sound(pkg_file('brutalmaze', 'soundfx/slash-enemy.ogg'))
SFX_SLASH_HERO = Sound(pkg_file('brutalmaze', 'soundfx/slash-hero.ogg'))
SFX_SHOT_ENEMY = Sound(pkg_file('brutalmaze', 'soundfx/shot-enemy.ogg'))
SFX_SHOT_HERO = Sound(pkg_file('brutalmaze', 'soundfx/shot-hero.ogg'))
SFX_MISSED = Sound(pkg_file('brutalmaze', 'soundfx/missed.ogg'))
SFX_HEART = Sound(pkg_file('brutalmaze', 'soundfx/heart.ogg'))
SFX_LOSE = Sound(pkg_file('brutalmaze', 'soundfx/lose.ogg'))
if mixer is None: pygame.mixer.quit()
SFX_NOISE = pkg_file('brutalmaze', 'soundfx/noise.ogg')
SFX_SPAWN = pkg_file('brutalmaze', 'soundfx/spawn.ogg')
SFX_SLASH_ENEMY = pkg_file('brutalmaze', 'soundfx/slash-enemy.ogg')
SFX_SLASH_HERO = pkg_file('brutalmaze', 'soundfx/slash-hero.ogg')
SFX_SHOT_ENEMY = pkg_file('brutalmaze', 'soundfx/shot-enemy.ogg')
SFX_SHOT_HERO = pkg_file('brutalmaze', 'soundfx/shot-hero.ogg')
SFX_MISSED = pkg_file('brutalmaze', 'soundfx/missed.ogg')
SFX_HEART = pkg_file('brutalmaze', 'soundfx/heart.ogg')
SFX_LOSE = pkg_file('brutalmaze', 'soundfx/lose.ogg')
SFX = (SFX_NOISE, SFX_SPAWN, SFX_SLASH_ENEMY, SFX_SLASH_HERO,
SFX_SHOT_ENEMY, SFX_SHOT_HERO, SFX_MISSED, SFX_HEART, SFX_LOSE)
SQRT2 = 2 ** 0.5
INIT_SCORE = 5**0.5/2 + 0.5 # golden mean
MAZE_SIZE = 10
ROAD_WIDTH = 5 # grids
CELL_WIDTH = ROAD_WIDTH * 2 # grids
MIDDLE = (MAZE_SIZE + MAZE_SIZE%2 - 1)*ROAD_WIDTH + ROAD_WIDTH//2
LAST_ROW = (MAZE_SIZE-1) * ROAD_WIDTH * 2
INIT_SCORE = 2
ROAD_WIDTH = 3 # grids
WALL_WIDTH = 4 # grids
CELL_WIDTH = WALL_WIDTH + ROAD_WIDTH*2 # grids
CELL_NODES = ROAD_WIDTH, ROAD_WIDTH + WALL_WIDTH, 0
MAZE_SIZE = 10 # cells
MIDDLE = MAZE_SIZE // 2 * CELL_WIDTH
HEAL_SPEED = 1 # HP/s
HERO_SPEED = 5 # grid/s
ENEMY_SPEED = 6 # grid/s
ENEMY_SPEED = 6 # grid/s
BULLET_SPEED = 15 # grid/s
ATTACK_SPEED = 333 # ms/strike
ATTACK_SPEED = 333.333 # ms/strike
MAX_WOUND = 1 # per attack turn
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)
ADJACENT_GRIDS = (1, 0), (0, 1), (-1, 0), (0, -1)
AROUND_HERO = set((MIDDLE + x, MIDDLE + y) for x, y in
ADJACENT_GRIDS + ((1, 1), (-1, 1), (-1, -1), (1, -1)))
ADJACENTS = (1, 0), (0, 1), (-1, 0), (0, -1)
CORNERS = (1, 1), (-1, 1), (-1, -1), (1, -1)
AROUND_HERO = set((MIDDLE + x, MIDDLE + y) for x, y in ADJACENTS + CORNERS)
TANGO = {'Butter': ((252, 233, 79), (237, 212, 0), (196, 160, 0)),
'Orange': ((252, 175, 62), (245, 121, 0), (206, 92, 0)),
@ -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)),
'Aluminium': ((238, 238, 236), (211, 215, 207), (186, 189, 182),
(136, 138, 133), (85, 87, 83), (46, 52, 54))}
TANGO_VALUES = list(TANGO.values())
ENEMIES = ['Butter', 'Orange', 'Chocolate', 'Chameleon',
'SkyBlue', 'Plum', 'ScarletRed']
COLOR_CODE = ascii_lowercase + '0'
COLORS = {c: COLOR_CODE[i] for i, c in enumerate(
color for code in ENEMIES + ['Aluminium'] for color in TANGO[code])}
MINW, MAXW = 24, 36
ENEMY_HP = 3
HERO_HP = 5
MIN_BEAT = 526
MIN_BEAT = 420
BG_COLOR = TANGO['Aluminium'][-1]
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
# Copyright (C) 2017, 2018 Nguyễn Gia Phong
# Copyright (C) 2017-2020 Nguyễn Gia Phong
#
# This file is part of Brutal Maze.
#
@ -19,38 +18,24 @@
__doc__ = 'Brutal Maze module for the maze class'
from collections import deque
from math import pi, log
from random import choice, getrandbits, uniform
import json
from collections import defaultdict, deque
from math import log, pi
from os import path
from random import choice, sample
import pygame
from pygame import RESIZABLE
from pygame.time import get_ticks
from .characters import Hero, new_enemy
from .constants import *
from .misc import round2, sign, regpoly, fill_aapolygon, play
from .weapons import Bullet
def new_cell(bit, upper=True):
"""Return a half of a cell of the maze based on the given bit."""
if bit: return deque([WALL]*ROAD_WIDTH + [EMPTY]*ROAD_WIDTH)
if upper: return deque([WALL] * (ROAD_WIDTH<<1))
return deque([EMPTY] * (ROAD_WIDTH<<1))
def new_column():
"""Return a newly generated column of the maze."""
column = deque()
upper, lower = deque(), deque()
for _ in range(MAZE_SIZE):
b = getrandbits(1)
upper.extend(new_cell(b))
lower.extend(new_cell(b, False))
for _ in range(ROAD_WIDTH): column.append(upper.__copy__())
for _ in range(ROAD_WIDTH): column.append(lower.__copy__())
return column
from .constants import (ADJACENTS, ATTACK_SPEED, BG_COLOR,
BULLET_LIFETIME, CELL_NODES, CELL_WIDTH, COLORS,
EMPTY, ENEMIES, ENEMY, ENEMY_HP, FG_COLOR, HERO,
HERO_HP, HERO_SPEED, INIT_SCORE, JSON_SEPARATORS,
MAX_WOUND, MAZE_SIZE, MIDDLE, ROAD_WIDTH,
SFX_LOSE, SFX_MISSED, SFX_SLASH_ENEMY, SFX_SPAWN,
SQRT2, TANGO_VALUES, WALL, WALL_WIDTH)
from .misc import around, deg, fill_aapolygon, json_rec, play, regpoly, sign
from .weapons import LockOn
class Maze:
@ -68,49 +53,103 @@ class Maze:
map (deque of deque): map of grids representing objects on the maze
vx, vy (float): velocity of the maze movement (in pixels per frame)
rotatex, rotatey (int): grids rotated
bullets (list of Bullet): flying bullets
enemy_weights (dict): probabilities of enemies to be created
bullets (list of .weapons.Bullet): flying bullets
enemies (list of Enemy): alive enemies
hero (Hero): the hero
next_move (int): the tick that the hero gets mobilized
next_slashfx (int): the tick to play next slash effect of the hero
destx, desty (int): the grid the hero is moving to
stepx, stepy (int): direction the maze is moving
target (Enemy or LockOn): target to automatically aim at
next_move (float): time until the hero gets mobilized (in ms)
glitch (float): time that the maze remain flashing colors (in ms)
next_slashfx (float): time until next slash effect of the hero (in ms)
slashd (float): minimum distance for slashes to be effective
sfx_slash (pygame.mixer.Sound): sound effect of slashed enemy
sfx_lose (pygame.mixer.Sound): sound effect to be played when you lose
export (list of defaultdict): records of game states
export_dir (str): directory containing records of game states
export_rate (float): milliseconds per snapshot
next_export (float): time until next snapshot (in ms)
"""
def __init__(self, fps, size, scrtype, headless=False):
def __init__(self, fps, size, headless, export_dir, export_rate):
self.fps = fps
if not headless:
self.w, self.h = size
self.scrtype = scrtype
self.surface = pygame.display.set_mode(size, self.scrtype)
self.w, self.h = size
if headless:
self.surface = None
else:
self.surface = pygame.display.set_mode(size, pygame.RESIZABLE)
self.export_dir = path.abspath(export_dir) if export_dir else ''
self.next_export = self.export_rate = export_rate
self.export = []
self.distance = (self.w * self.h / 416) ** 0.5
self.x, self.y = self.w // 2, self.h // 2
self.centerx, self.centery = self.w / 2.0, self.h / 2.0
w, h = (int(i/self.distance/2 + 2) for i in size)
self.centerx, self.centery = self.w / 2, self.h / 2
w, h = (int(i/self.distance/2 + 1) for i in size)
self.rangex = list(range(MIDDLE - w, MIDDLE + w + 1))
self.rangey = list(range(MIDDLE - h, MIDDLE + h + 1))
self.score = INIT_SCORE
self.new_map()
self.map = deque()
for _ in range(MAZE_SIZE): self.map.extend(new_column())
self.vx = self.vy = 0.0
self.rotatex = self.rotatey = 0
self.bullets, self.enemies = [], []
self.enemy_weights = {color: MINW for color in ENEMIES}
self.add_enemy()
self.hero = Hero(self.surface, fps)
self.map[MIDDLE][MIDDLE] = HERO
self.next_move = self.next_slashfx = 0
self.hero = Hero(self.surface, fps, size)
self.target = LockOn(MIDDLE, MIDDLE, retired=True)
self.next_move = self.glitch = self.next_slashfx = 0.0
self.slashd = self.hero.R + self.distance/SQRT2
self.sfx_spawn = SFX_SPAWN
self.sfx_slash = SFX_SLASH_ENEMY
self.sfx_lose = SFX_LOSE
def new_cell(self, x, y):
"""Draw on the map a newly created cell
whose coordinates are given.
"""
def draw_bit(bit, dx=0, dy=0):
startx, starty = x + CELL_NODES[dx], y + CELL_NODES[dy]
height = ROAD_WIDTH if dy else WALL_WIDTH
for i in range(ROAD_WIDTH if dx else WALL_WIDTH):
for j in range(height): self.map[startx + i][starty + j] = bit
x, y = x * CELL_WIDTH, y * CELL_WIDTH
draw_bit(WALL)
walls = set(sample(ADJACENTS, 2))
walls.add(choice(ADJACENTS))
for i, j in ADJACENTS:
draw_bit((WALL if (i, j) in walls else EMPTY), i, j)
def isdisplayed(self, x, y):
"""Return True if the grid (x, y) is in the displayable part
of the map, False otherwise.
"""
return (self.rangex[0] <= x <= self.rangex[-1]
and self.rangey[0] <= y <= self.rangey[-1])
def new_map(self):
"""Generate a new map."""
self.map = deque(deque(EMPTY for _ in range(MAZE_SIZE * CELL_WIDTH))
for _ in range(MAZE_SIZE * CELL_WIDTH))
for x in range(MAZE_SIZE):
for y in range(MAZE_SIZE): self.new_cell(x, y)
# Regenerate if the hero is trapped. This can only reach
# maximum recursion depth is there's a flaw with the system's entropy.
room, visited = [(MIDDLE, MIDDLE)], set()
while room:
bit = room.pop()
if bit not in visited:
if not self.isdisplayed(*bit): break
visited.add(bit)
for x, y in around(*bit):
if self.map[x][y] == EMPTY: room.append((x, y))
else:
self.new_map()
self.map[MIDDLE][MIDDLE] = HERO
self.destx = self.desty = MIDDLE
self.stepx = self.stepy = 0
def add_enemy(self):
"""Add enough enemies."""
self.enemies = [e for e in self.enemies if e.alive]
walls = [(i, j) for i in self.rangex for j in self.rangey
if self.map[i][j] == WALL]
plums = [e for e in self.enemies if e.color == 'Plum' and e.awake]
@ -118,34 +157,51 @@ class Maze:
num = log(self.score, INIT_SCORE)
while walls and len(self.enemies) < num:
x, y = choice(walls)
if all(self.map[x + a][y + b] == WALL for a, b in ADJACENT_GRIDS):
if all(self.map[x + a][y + b] == WALL for a, b in ADJACENTS):
continue
enemy = new_enemy(self, x, y)
self.enemies.append(enemy)
if plum is None or not plum.clone(enemy):
walls.remove((x, y))
else:
self.map[x][y] = WALL
if plum is None or not plum.clone(enemy): walls.remove((x, y))
def get_pos(self, x, y):
"""Return coordinate of the center of the grid (x, y)."""
return (self.centerx + (x - MIDDLE)*self.distance,
self.centery + (y - MIDDLE)*self.distance)
def get_grid(self, x, y):
"""Return the grid containing the point (x, y)."""
return (MIDDLE + round((x-self.centerx) / self.distance),
MIDDLE + round((y-self.centery) / self.distance))
def get_target(self, x, y):
"""Return shooting target the grid containing the point (x, y).
If the grid is the hero, return a retired target.
"""
gridx, gridy = self.get_grid(x, y)
if gridx == gridy == MIDDLE: return LockOn(gridx, gridy, True)
for enemy in self.enemies:
if not enemy.isunnoticeable(gridx, gridy): return enemy
return LockOn(gridx, gridy)
def get_score(self):
"""Return the current score."""
return int(self.score - INIT_SCORE)
def get_color(self):
"""Return color of a grid."""
return choice(TANGO_VALUES)[0] if self.glitch > 0 else FG_COLOR
def draw(self):
"""Draw the maze."""
self.surface.fill(BG_COLOR)
if get_ticks() >= self.next_move:
if self.next_move <= 0:
for i in self.rangex:
for j in self.rangey:
if self.map[i][j] != WALL: continue
x, y = self.get_pos(i, j)
square = regpoly(4, self.distance / SQRT2, pi / 4, x, y)
fill_aapolygon(self.surface, square, FG_COLOR)
fill_aapolygon(self.surface, square, self.get_color())
for enemy in self.enemies: enemy.draw()
if not self.hero.dead: self.hero.draw()
@ -160,47 +216,46 @@ class Maze:
x = int((self.centerx-self.x) * 2 / self.distance)
y = int((self.centery-self.y) * 2 / self.distance)
if x == y == 0: return
for enemy in self.enemies: 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
if x:
self.centerx -= x * self.distance
self.map.rotate(x)
self.rotatex += x
if y:
self.centery -= y * self.distance
for d in self.map: d.rotate(y)
self.rotatey += y
self.centerx -= x * self.distance
self.map.rotate(x)
self.rotatex += x
self.centery -= y * self.distance
for d in self.map: d.rotate(y)
self.rotatey += y
self.map[MIDDLE][MIDDLE] = HERO
if self.map[self.destx][self.desty] != HERO:
self.destx += x
self.desty += y
self.stepx = self.stepy = 0
# Respawn the enemies that fall off the display
killist = []
for i, enemy in enumerate(self.enemies):
enemy.place(x, y)
if enemy.x not in self.rangex or enemy.y not in self.rangey:
if not self.isdisplayed(enemy.x, enemy.y):
self.score += enemy.wound
enemy.die()
killist.append(i)
for i in reversed(killist): self.enemies.pop(i)
self.add_enemy()
# LockOn target is not yet updated.
if isinstance(self.target, LockOn):
self.target.place(x, y, self.isdisplayed)
# Regenerate the maze
if abs(self.rotatex) == CELL_WIDTH:
self.rotatex = 0
for _ in range(CELL_WIDTH): self.map.pop()
self.map.extend(new_column())
for i in range(-CELL_WIDTH, 0):
self.map[i].rotate(self.rotatey)
for i in range(CELL_WIDTH): self.map[i].rotate(-self.rotatey)
for i in range(MAZE_SIZE): self.new_cell(0, i)
for i in range(CELL_WIDTH): self.map[i].rotate(self.rotatey)
if abs(self.rotatey) == CELL_WIDTH:
self.rotatey = 0
for i in range(MAZE_SIZE):
b, c = getrandbits(1), (i-1)*CELL_WIDTH + self.rotatex
for j, grid in enumerate(new_cell(b)):
for k in range(ROAD_WIDTH):
self.map[c + k][LAST_ROW + j] = grid
c += ROAD_WIDTH
for j, grid in enumerate(new_cell(b, False)):
for k in range(ROAD_WIDTH):
self.map[c + k][LAST_ROW + j] = grid
self.map.rotate(-self.rotatex)
for i in range(MAZE_SIZE): self.new_cell(i, 0)
self.map.rotate(self.rotatex)
def get_distance(self, x, y):
"""Return the distance from the center of the maze to the point
@ -210,78 +265,84 @@ class Maze:
def hit_hero(self, wound, color):
"""Handle the hero when he loses HP."""
fx = (uniform(0, sum(self.enemy_weights.values()))
< self.enemy_weights[color])
time = get_ticks()
if (color == 'Butter' or color == 'ScarletRed') and fx:
self.hero.wound += wound * 2.5
elif color == 'Orange' and fx:
self.hero.next_heal = max(self.hero.next_heal, time) + wound*1000
elif color == 'SkyBlue' and fx:
self.next_move = max(self.next_move, time) + wound*1000
else:
self.hero.wound += wound
if self.enemy_weights[color] + wound < MAXW:
self.enemy_weights[color] += wound
if self.hero.wound > HERO_HP and not self.hero.dead: self.lose()
if color == 'Orange':
# If called by close-range attack, this is FPS-dependant, although
# in playable FPS (24 to infinity), the difference within 2%.
self.hero.next_heal = abs(self.hero.next_heal * (1 - wound))
elif choice(ENEMIES) == color:
self.hero.next_heal = -1.0 # what doesn't kill you heals you
if color == 'Butter' or color == 'ScarletRed':
wound *= ENEMY_HP
elif color == 'Chocolate':
self.hero.highness += wound
wound = 0
elif color == 'SkyBlue':
self.next_move = max(self.next_move, 0) + wound*1000
wound = 0
if wound and sum(self.hero.wounds) < MAX_WOUND:
self.hero.wounds[-1] += wound
def slash(self):
"""Handle close-range attacks."""
for enemy in self.enemies: enemy.slash()
if not self.hero.spin_queue: return
killist = []
for i, enemy in enumerate(self.enemies):
d = self.slashd - enemy.get_distance()
for enemy in filter(lambda e: e.awake, self.enemies):
d = self.slashd - enemy.distance
if d > 0:
wound, time = d * SQRT2 / self.distance, get_ticks()
if time >= self.next_slashfx:
play(self.sfx_slash, wound, enemy.get_angle())
self.next_slashfx = time + ATTACK_SPEED
wound = d * SQRT2 / self.distance
if self.next_slashfx <= 0:
play(SFX_SLASH_ENEMY, enemy.x, enemy.y, wound)
self.next_slashfx = ATTACK_SPEED
enemy.hit(wound / self.hero.spin_speed)
if enemy.wound >= ENEMY_HP:
self.score += enemy.wound
enemy.die()
killist.append(i)
for i in reversed(killist): self.enemies.pop(i)
self.add_enemy()
def track_bullets(self):
"""Handle the bullets."""
fallen, time = [], get_ticks()
if (self.hero.firing and not self.hero.slashing
and time >= self.hero.next_strike):
self.hero.next_strike = time + ATTACK_SPEED
self.bullets.append(Bullet(self.surface, self.x, self.y,
self.hero.angle, 'Aluminium'))
self.bullets.extend(self.hero.shots)
fallen = []
block = (self.hero.spin_queue and self.hero.next_heal < 0
and self.hero.next_strike > self.hero.spin_queue / self.fps)
for i, bullet in enumerate(self.bullets):
wound = float(bullet.fall_time-time) / BULLET_LIFETIME
wound = bullet.fall_time / BULLET_LIFETIME
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)
elif bullet.color == 'Aluminium':
x = MIDDLE + round2((bullet.x-self.x) / self.distance)
y = MIDDLE + round2((bullet.y-self.y) / self.distance)
if self.map[x][y] == WALL and time >= self.next_move:
active_enemies = [e for e in self.enemies if e.awake]
if self.map[gridx][gridy] == WALL and self.next_move <= 0:
fallen.append(i)
if not active_enemies: continue
self.glitch = wound * 1000
enemy = new_enemy(self, gridx, gridy)
enemy.awake = True
self.map[gridx][gridy] = ENEMY
play(SFX_SPAWN, enemy.x, enemy.y)
enemy.hit(wound)
self.enemies.append(enemy)
continue
for j, enemy in enumerate(self.enemies):
if not enemy.awake: continue
x, y = enemy.get_pos()
if bullet.get_distance(x, y) < self.distance:
for enemy in active_enemies:
if bullet.get_distance(*enemy.pos) < self.distance:
enemy.hit(wound)
if enemy.wound >= ENEMY_HP:
self.score += enemy.wound
enemy.die()
self.enemies.pop(j)
play(bullet.sfx_hit, wound, bullet.angle)
self.add_enemy()
play(bullet.sfx_hit, gridx, gridy, wound)
fallen.append(i)
break
elif bullet.get_distance(self.x, self.y) < self.distance:
if self.hero.spin_queue:
play(bullet.sfx_missed, wound, bullet.angle + pi)
if block:
self.hero.next_strike = (abs(self.hero.spin_queue/self.fps)
+ ATTACK_SPEED)
play(SFX_MISSED, gain=wound)
else:
self.hit_hero(wound, bullet.color)
play(bullet.sfx_hit, wound, bullet.angle + pi)
play(bullet.sfx_hit, gain=wound)
fallen.append(i)
for i in reversed(fallen): self.bullets.pop(i)
@ -299,35 +360,81 @@ class Maze:
return 0.0
for enemy in self.enemies:
x, y = self.get_pos(enemy.x, enemy.y)
if (max(abs(herox - x), abs(heroy - y)) * 2 < self.distance
and enemy.awake):
if max(abs(herox - x), abs(heroy - y)) * 2 < self.distance:
return 0.0
return vx or vy
def expos(self, x, y):
"""Return position of the given coordinates in rounded percent."""
cx = len(self.rangex)*50 + (x - self.centerx)/self.distance*100
cy = len(self.rangey)*50 + (y - self.centery)/self.distance*100
return round(cx), round(cy)
def update_export(self, forced=False):
"""Update the maze's data export and return the last record."""
if self.next_export > 0 and not forced or self.hero.dead: return
export = defaultdict(list)
export['s'] = self.get_score()
if self.next_move <= 0:
for y in self.rangey:
export['m'].append(''.join(
COLORS[self.get_color()] if self.map[x][y] == WALL else '0'
for x in self.rangex))
x, y = self.expos(self.x, self.y)
export['h'] = [
COLORS[self.hero.get_color()], x, y, deg(self.hero.angle),
int(self.hero.next_strike <= 0), int(self.hero.next_heal <= 0)]
for enemy in self.enemies:
if enemy.isunnoticeable(): continue
x, y = self.expos(*enemy.pos)
color, angle = COLORS[enemy.get_color()], deg(enemy.angle)
export['e'].append([color, x, y, angle])
for bullet in self.bullets:
x, y = self.expos(bullet.x, bullet.y)
color, angle = COLORS[bullet.get_color()], deg(bullet.angle)
if color != '0': export['b'].append([color, x, y, angle])
if self.next_export <= 0:
export['t'] = round(self.export_rate - self.next_export)
self.export.append(export)
self.next_export = self.export_rate
return export
def update(self, fps):
"""Update the maze."""
self.fps = fps
dx = self.is_valid_move(vx=self.vx)
self.centerx += dx
dy = self.is_valid_move(vy=self.vy)
self.centery += dy
self.vx = self.is_valid_move(vx=self.vx)
self.centerx += self.vx
self.vy = self.is_valid_move(vy=self.vy)
self.centery += self.vy
if dx or dy:
self.rotate()
self.next_move -= 1000 / fps
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 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()
self.track_bullets()
if not self.hero.dead:
self.hero.update(fps)
self.slash()
self.track_bullets()
if self.hero.wound >= HERO_HP: self.lose()
self.update_export()
def resize(self, size):
"""Resize the maze."""
self.w, self.h = size
self.surface = pygame.display.set_mode(size, self.scrtype)
self.hero.resize()
self.surface = pygame.display.set_mode(size, pygame.RESIZABLE)
self.hero.resize(size)
offsetx = (self.centerx-self.x) / 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.centerx = self.x + offsetx*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.rangey = list(range(MIDDLE - h, MIDDLE + h + 1))
self.slashd = self.hero.R + self.distance/SQRT2
def set_step(self, check=(lambda x, y: True)):
"""Work out next step on the shortest path to the destination.
Return whether target is impossible to reach and hero should
shoot toward it instead.
"""
if self.stepx or self.stepy and self.vx == self.vy == 0.0:
x, y = MIDDLE - self.stepx, MIDDLE - self.stepy
if self.stepx and not self.stepy:
nextx = x - self.stepx
n = self.map[x][y - 1] == EMPTY == self.map[nextx][y - 1]
s = self.map[x][y + 1] == EMPTY == self.map[nextx][y + 1]
self.stepy = n - s
elif not self.stepx and self.stepy:
nexty = y - self.stepy
w = self.map[x - 1][y] == EMPTY == self.map[x - 1][nexty]
e = self.map[x + 1][y] == EMPTY == self.map[x + 1][nexty]
self.stepx = w - e
return False
# Shoot WALL and ENEMY instead
if self.map[self.destx][self.desty] != EMPTY:
self.stepx = self.stepy = 0
return True
# Forest Fire algorithm
queue, visited = deque([(self.destx, self.desty)]), set()
while queue:
x, y = queue.pop()
if (x, y) in visited: continue
visited.add((x, y))
dx, dy = MIDDLE - x, MIDDLE - y
if dx**2 + dy**2 <= 2:
# Succeeded on finding a path
self.stepx, self.stepy = dx, dy
return False
for i, j in around(x, y):
if self.map[i][j] == EMPTY and check(i, j):
queue.appendleft((i, j))
# Failed to find way to move to target
self.stepx = self.stepy = 0
return True
def isfast(self):
"""Return if the hero is moving faster than HERO_SPEED."""
return (self.vx**2+self.vy**2)**0.5*self.fps > HERO_SPEED*self.distance
def dump_records(self):
"""Dump JSON records."""
if self.export_dir:
with open(json_rec(self.export_dir), 'w') as f:
json.dump(self.export, f, separators=JSON_SEPARATORS)
def lose(self):
"""Handle loses."""
self.hero.dead = True
self.hero.wound = HERO_HP
self.hero.slashing = self.hero.firing = False
self.destx = self.desty = MIDDLE
self.stepx = self.stepy = 0
self.vx = self.vy = 0.0
play(self.sfx_lose)
play(SFX_LOSE)
self.dump_records()
def reinit(self):
"""Open new game."""
self.centerx, self.centery = self.w / 2.0, self.h / 2.0
self.score = INIT_SCORE
self.map = deque()
for _ in range(MAZE_SIZE): self.map.extend(new_column())
self.centerx, self.centery = self.w / 2, self.h / 2
self.score, self.export = INIT_SCORE, []
self.new_map()
self.vx = self.vy = 0.0
self.rotatex = self.rotatey = 0
self.bullets, self.enemies = [], []
self.enemy_weights = {color: MINW for color in ENEMIES}
self.add_enemy()
self.next_move = self.next_slashfx = 0
self.hero.next_heal = self.hero.next_beat = self.hero.next_strike = 0
self.next_move = self.next_slashfx = self.hero.next_strike = 0.0
self.target = LockOn(MIDDLE, MIDDLE, retired=True)
self.hero.next_heal = -1.0
self.hero.highness = 0.0
self.hero.slashing = self.hero.firing = self.hero.dead = False
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
# Copyright (C) 2017, 2018 Nguyễn Gia Phong
# Copyright (C) 2017-2020 Nguyễn Gia Phong
#
# This file is part of Brutal Maze.
#
@ -19,16 +18,17 @@
__doc__ = 'Brutal Maze module for miscellaneous functions'
from math import cos, sin, pi
from random import uniform
from datetime import datetime
from itertools import chain
from math import cos, degrees, pi, sin
from os import path
from random import shuffle
import pygame
from pygame.gfxdraw import filled_polygon, aapolygon
from palace import Buffer, Source
from pygame.gfxdraw import aapolygon, filled_polygon
def round2(number):
"""Round a number to an int."""
return int(round(number))
from .constants import ADJACENTS, CORNERS, MIDDLE
def randsign():
@ -37,9 +37,9 @@ def randsign():
def regpoly(n, R, r, x, y):
"""Return the pointlist of the regular polygon with n sides,
circumradius of R, the center point I(x, y) and one point A make the
vector IA with angle r (in radians).
"""Return pointlist of a regular n-gon with circumradius of R,
center point I(x, y) and corner A that angle of vector IA is r
(in radians).
"""
r %= pi * 2
angles = [r + pi*2*side/n for side in range(n)]
@ -47,7 +47,7 @@ def regpoly(n, R, r, x, y):
def fill_aapolygon(surface, points, color):
"""Draw a filled polygon with anti aliased edges onto a surface."""
"""Draw a filled polygon with anti-aliased edges onto a surface."""
aapolygon(surface, points, color)
filled_polygon(surface, points, color)
@ -57,38 +57,44 @@ def sign(n):
return -1 if n < 0 else 1 if n else 0
def cosin(x):
"""Return the sum of cosine and sine of x (measured in radians)."""
return cos(x) + sin(x)
def choices(d):
"""Choose a random key from a dict which has values being relative
weights of the coresponding keys.
def deg(x):
"""Convert angle x from radians to degrees,
casted to a nonnegative integer.
"""
population, weights = tuple(d.keys()), tuple(d.values())
cum_weights = [weights[0]]
for weight in weights[1:]: cum_weights.append(cum_weights[-1] + weight)
num = uniform(0, cum_weights[-1])
for i, w in enumerate(cum_weights):
if num <= w: return population[i]
return round((lambda a: a if a > 0 else a + 360)(degrees(x)))
def play(sound, volume=1.0, angle=None):
"""Play a pygame.mixer.Sound at the given volume."""
if pygame.mixer.get_init() is None: return
if pygame.mixer.find_channel() is None:
pygame.mixer.set_num_channels(pygame.mixer.get_num_channels() + 1)
def join(iterable, sep=' ', end='\n'):
"""Return a string which is the concatenation of string
representations of objects in the iterable, separated by sep.
channel = sound.play()
if angle is None:
channel.set_volume(volume)
else:
delta = cos(angle)
volumes = [volume * (1-delta), volume * (1+delta)]
for i, v in enumerate(volumes):
if v > 1:
volumes[i - 1] += v - 1
volumes[i] = 1.0
sound.set_volume(1.0)
channel.set_volume(*volumes)
end is appended to the resulting string.
"""
return sep.join(map(str, iterable)) + end
def around(x, y):
"""Return grids around the given one in random order."""
a = [(x + i, y + j) for i, j in ADJACENTS]
shuffle(a)
c = [(x + i, y + j) for i, j in CORNERS]
shuffle(c)
return chain(a, c)
def 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]
Screen width: 640
Screen height: 480
# OpenGL should be supported on all machines with hardware acceleration.
OpenGL: no
# FPS should not be greater than refresh rate.
Maximum FPS: 60
[Sound]
Muted: no
# Volume must be between 0.0 and 1.0
# Volume must be between 0.0 and 1.0.
Music volume: 1.0
[Control]
# Touch-friendly control
Touch: no
# Input values should be either from Mouse1 to Mouse3 or a keyboard key
# and they are case-insensitively read.
# Aliases for special keys are listed here (without the K_ part):
@ -28,9 +20,25 @@ Music volume: 1.0
New game: F2
Toggle pause: p
Toggle mute: m
Move left: Left
Move right: Right
Move up: Up
Move down: Down
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

View File

@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
# 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.
#
@ -21,10 +20,9 @@ __doc__ = 'Brutal Maze module for weapon classes'
from math import cos, sin
from pygame.time import get_ticks
from .constants import *
from .misc import regpoly, fill_aapolygon
from .constants import (BG_COLOR, BULLET_LIFETIME, BULLET_SPEED,
ENEMY_HP, SFX_SHOT_ENEMY, SFX_SHOT_HERO, TANGO)
from .misc import fill_aapolygon, regpoly
class Bullet:
@ -35,29 +33,28 @@ class Bullet:
x, y (int): coordinates of the center of the bullet (in pixels)
angle (float): angle of the direction the bullet pointing (in radians)
color (str): bullet's color name
fall_time (int): the tick that the bullet will fall down
sfx_hit (pygame.mixer.Sound): sound effect indicating target was hit
sfx_missed (pygame.mixer.Sound): sound effect indicating a miss shot
fall_time (int): time until the bullet fall down
sfx_hit (str): sound effect indicating target was hit
"""
def __init__(self, surface, x, y, angle, color):
self.surface = surface
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':
self.sfx_hit = SFX_SHOT_ENEMY
else:
self.sfx_hit = SFX_SHOT_HERO
self.sfx_missed = SFX_MISSED
def update(self, fps, distance):
"""Update the bullet."""
s = distance * BULLET_SPEED / fps
self.x += s * cos(self.angle)
self.y += s * sin(self.angle)
self.fall_time -= 1000 / fps
def get_color(self):
"""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:
return TANGO[self.color][value]
except IndexError:
@ -76,3 +73,22 @@ class Bullet:
def get_distance(self, x, y):
"""Return the from the center of the bullet to the point (x, y)."""
return ((self.x-x)**2 + (self.y-y)**2)**0.5
class LockOn:
"""Lock-on device to assist hero's aiming.
This is used as a mutable object to represent a grid of wall.
Attributes:
x, y (int): coordinates of the target (in grids)
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