449 lines
18 KiB
Python
449 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
# maze.py - module for the maze class
|
|
# 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/>.
|
|
|
|
__doc__ = 'Brutal Maze module for the maze class'
|
|
|
|
from collections import deque, defaultdict
|
|
from math import pi, log
|
|
from random import choice, getrandbits, uniform
|
|
|
|
import pygame
|
|
|
|
from .characters import Hero, new_enemy
|
|
from .constants import (
|
|
EMPTY, WALL, HERO, ENEMY, ROAD_WIDTH, MAZE_SIZE, MIDDLE, INIT_SCORE,
|
|
ENEMIES, MINW, MAXW, SQRT2, SFX_SPAWN, SFX_SLASH_ENEMY, SFX_LOSE,
|
|
ADJACENTS, TANGO, BG_COLOR, FG_COLOR, CELL_WIDTH, LAST_ROW,
|
|
HERO_HP, ENEMY_HP, ATTACK_SPEED, HERO_SPEED, BULLET_LIFETIME)
|
|
from .misc import round2, sign, around, regpoly, fill_aapolygon, play
|
|
from .weapons import Bullet
|
|
|
|
|
|
def new_cell(bit, upper=True):
|
|
"""Return a half of a cell of the maze based on the given bit."""
|
|
if bit: return deque([WALL]*ROAD_WIDTH + [EMPTY]*ROAD_WIDTH)
|
|
if upper: return deque([WALL] * (ROAD_WIDTH<<1))
|
|
return deque([EMPTY] * (ROAD_WIDTH<<1))
|
|
|
|
|
|
def new_column():
|
|
"""Return a newly generated column of the maze."""
|
|
column = deque()
|
|
upper, lower = deque(), deque()
|
|
for _ in range(MAZE_SIZE):
|
|
b = getrandbits(1)
|
|
upper.extend(new_cell(b))
|
|
lower.extend(new_cell(b, False))
|
|
for _ in range(ROAD_WIDTH): column.append(upper.__copy__())
|
|
for _ in range(ROAD_WIDTH): column.append(lower.__copy__())
|
|
return column
|
|
|
|
|
|
class Maze:
|
|
"""Object representing the maze, including the characters.
|
|
|
|
Attributes:
|
|
w, h (int): width and height of the display (in px)
|
|
fps (float): current frame rate
|
|
surface (pygame.Surface): the display to draw on
|
|
distance (float): distance between centers of grids (in px)
|
|
x, y (int): coordinates of the center of the hero (in px)
|
|
centerx, centery (float): center grid's center's coordinates (in px)
|
|
rangex, rangey (list): range of the index of the grids on display
|
|
score (float): current score
|
|
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
|
|
enemies (list of Enemy): alive enemies
|
|
hero (Hero): the hero
|
|
destx, desty (int): the grid the hero is moving to
|
|
stepx, stepy (int): direction the maze is moving
|
|
next_move (float): time until the hero gets mobilized (in ms)
|
|
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
|
|
"""
|
|
def __init__(self, fps, size, headless):
|
|
self.fps = fps
|
|
self.w, self.h = size
|
|
if headless:
|
|
self.surface = None
|
|
else:
|
|
self.surface = pygame.display.set_mode(size, pygame.RESIZABLE)
|
|
|
|
self.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 + 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.map = deque()
|
|
for _ in range(MAZE_SIZE): self.map.extend(new_column())
|
|
self.vx = self.vy = 0.0
|
|
self.rotatex = self.rotatey = 0
|
|
self.bullets, self.enemies = [], []
|
|
self.enemy_weights = {color: MINW for color in ENEMIES}
|
|
self.add_enemy()
|
|
self.hero = Hero(self.surface, fps, size)
|
|
self.map[MIDDLE][MIDDLE] = HERO
|
|
self.destx = self.desty = MIDDLE
|
|
self.stepx = self.stepy = 0
|
|
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 add_enemy(self):
|
|
"""Add enough enemies."""
|
|
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]
|
|
plum = choice(plums) if plums else None
|
|
num = log(self.score, INIT_SCORE)
|
|
while walls and len(self.enemies) < num:
|
|
x, y = choice(walls)
|
|
if all(self.map[x + a][y + b] == WALL for a, b in ADJACENTS):
|
|
continue
|
|
enemy = new_enemy(self, x, y)
|
|
self.enemies.append(enemy)
|
|
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 + round2((x-self.centerx) / self.distance),
|
|
MIDDLE + round2((y-self.centery) / self.distance))
|
|
|
|
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 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, self.get_color())
|
|
|
|
for enemy in self.enemies: enemy.draw()
|
|
if not self.hero.dead: self.hero.draw()
|
|
bullet_radius = self.distance / 4
|
|
for bullet in self.bullets: bullet.draw(bullet_radius)
|
|
pygame.display.flip()
|
|
pygame.display.set_caption(
|
|
'Brutal Maze - Score: {}'.format(self.get_score()))
|
|
|
|
def rotate(self):
|
|
"""Rotate the maze if needed."""
|
|
x = int((self.centerx-self.x) * 2 / self.distance)
|
|
y = int((self.centery-self.y) * 2 / self.distance)
|
|
if x == y == 0: return
|
|
for enemy in self.enemies:
|
|
if self.map[enemy.x][enemy.y] == ENEMY:
|
|
self.map[enemy.x][enemy.y] = EMPTY
|
|
|
|
self.map[MIDDLE][MIDDLE] = EMPTY
|
|
self.centerx -= x * self.distance
|
|
self.map.rotate(x)
|
|
self.rotatex += x
|
|
self.centery -= y * self.distance
|
|
for d in self.map: d.rotate(y)
|
|
self.rotatey += y
|
|
self.map[MIDDLE][MIDDLE] = HERO
|
|
if self.map[self.destx][self.desty] != HERO:
|
|
self.destx += x
|
|
self.desty += y
|
|
self.stepx = self.stepy = 0
|
|
|
|
# Respawn the enemies that fall off the display
|
|
killist = []
|
|
for i, enemy in enumerate(self.enemies):
|
|
enemy.place(x, y)
|
|
if not (self.rangex[0] <= enemy.x <= self.rangex[-1]
|
|
and self.rangey[0] <= enemy.y <= self.rangey[-1]):
|
|
self.score += enemy.wound
|
|
enemy.die()
|
|
killist.append(i)
|
|
for i in reversed(killist): self.enemies.pop(i)
|
|
self.add_enemy()
|
|
|
|
# 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)
|
|
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
|
|
|
|
def get_distance(self, x, y):
|
|
"""Return the distance from the center of the maze to the point
|
|
(x, y).
|
|
"""
|
|
return ((self.x-x)**2 + (self.y-y)**2)**0.5
|
|
|
|
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])
|
|
if (color == 'Butter' or color == 'ScarletRed') and fx:
|
|
self.hero.wound += wound * 2.5
|
|
elif color == 'Orange' and fx:
|
|
self.hero.next_heal = max(self.hero.next_heal, 0) + wound*1000
|
|
elif color == 'SkyBlue' and fx:
|
|
self.next_move = max(self.next_move, 0) + wound*1000
|
|
else:
|
|
self.hero.wound += wound
|
|
if self.enemy_weights[color] + wound < MAXW:
|
|
self.enemy_weights[color] += wound
|
|
if self.hero.wound > HERO_HP and not self.hero.dead: self.lose()
|
|
|
|
def slash(self):
|
|
"""Handle close-range attacks."""
|
|
for enemy in self.enemies: enemy.slash()
|
|
if not self.hero.spin_queue: return
|
|
killist = []
|
|
for i, enemy in enumerate(self.enemies):
|
|
d = self.slashd - enemy.get_distance()
|
|
if d > 0:
|
|
wound = d * SQRT2 / self.distance
|
|
if self.next_slashfx <= 0:
|
|
play(self.sfx_slash, wound, enemy.get_angle())
|
|
self.next_slashfx = ATTACK_SPEED
|
|
enemy.hit(wound / self.hero.spin_speed)
|
|
if enemy.wound >= ENEMY_HP:
|
|
self.score += enemy.wound
|
|
enemy.die()
|
|
killist.append(i)
|
|
for i in reversed(killist): self.enemies.pop(i)
|
|
self.add_enemy()
|
|
|
|
def track_bullets(self):
|
|
"""Handle the bullets."""
|
|
if (self.hero.firing and not self.hero.slashing
|
|
and self.hero.next_strike <= 0):
|
|
self.hero.next_strike = ATTACK_SPEED
|
|
self.bullets.append(Bullet(self.surface, self.x, self.y,
|
|
self.hero.angle, 'Aluminium'))
|
|
|
|
fallen = []
|
|
block = (self.hero.spin_queue and self.hero.next_heal <= 0
|
|
and self.hero.next_strike > self.hero.spin_queue / self.fps)
|
|
|
|
for i, bullet in enumerate(self.bullets):
|
|
wound = bullet.fall_time / BULLET_LIFETIME
|
|
bullet.update(self.fps, self.distance)
|
|
if wound < 0:
|
|
fallen.append(i)
|
|
elif bullet.color == 'Aluminium':
|
|
x, y = self.get_grid(bullet.x, bullet.y)
|
|
if self.map[x][y] == WALL and self.next_move <= 0:
|
|
self.glitch = wound * 1000
|
|
enemy = new_enemy(self, x, y)
|
|
enemy.awake = True
|
|
self.map[x][y] = ENEMY
|
|
play(self.sfx_spawn,
|
|
1 - enemy.get_distance()/self.get_distance(0, 0)/2,
|
|
enemy.get_angle() + pi)
|
|
enemy.hit(wound)
|
|
self.enemies.append(enemy)
|
|
fallen.append(i)
|
|
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:
|
|
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)
|
|
fallen.append(i)
|
|
break
|
|
elif bullet.get_distance(self.x, self.y) < self.distance:
|
|
if block:
|
|
self.hero.next_strike = (abs(self.hero.spin_queue/self.fps)
|
|
+ ATTACK_SPEED)
|
|
play(bullet.sfx_missed, wound, bullet.angle + pi)
|
|
else:
|
|
self.hit_hero(wound, bullet.color)
|
|
play(bullet.sfx_hit, wound, bullet.angle + pi)
|
|
fallen.append(i)
|
|
for i in reversed(fallen): self.bullets.pop(i)
|
|
|
|
def is_valid_move(self, vx=0.0, vy=0.0):
|
|
"""Return dx or dy if it it valid to move the maze in that
|
|
velocity, otherwise return 0.0.
|
|
"""
|
|
d = self.distance/2 + self.hero.R
|
|
herox, heroy, dx, dy = self.x - vx, self.y - vy, sign(vx), sign(vy)
|
|
for gridx in range(MIDDLE - dx - 1, MIDDLE - dx + 2):
|
|
for gridy in range(MIDDLE - dy - 1, MIDDLE - dy + 2):
|
|
x, y = self.get_pos(gridx, gridy)
|
|
if (max(abs(herox - x), abs(heroy - y)) < d
|
|
and self.map[gridx][gridy] == WALL):
|
|
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:
|
|
return 0.0
|
|
return vx or vy
|
|
|
|
def update(self, fps):
|
|
"""Update the maze."""
|
|
self.fps = fps
|
|
self.vx = self.is_valid_move(vx=self.vx)
|
|
self.centerx += self.vx
|
|
self.vy = self.is_valid_move(vy=self.vy)
|
|
self.centery += self.vy
|
|
|
|
self.next_move -= 1000.0 / fps
|
|
self.glitch -= 1000.0 / fps
|
|
self.next_slashfx -= 1000.0 / 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(self.vx, self.vy)
|
|
|
|
for enemy in self.enemies: enemy.update()
|
|
if not self.hero.dead:
|
|
self.hero.update(fps)
|
|
self.slash()
|
|
self.track_bullets()
|
|
|
|
def resize(self, size):
|
|
"""Resize the maze."""
|
|
self.w, self.h = size
|
|
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
|
|
self.distance = (self.w * self.h / 416) ** 0.5
|
|
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 + 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, xcheck=(lambda _: True), ycheck=(lambda _: True)):
|
|
"""Return direction on the shortest path to the destination."""
|
|
if self.stepx or self.stepy and self.vx == self.vy == 0.0:
|
|
x, y = MIDDLE - self.stepx, MIDDLE - self.stepy
|
|
if self.stepx and not self.stepy:
|
|
nextx = x - self.stepx
|
|
n = self.map[x][y - 1] == EMPTY == self.map[nextx][y - 1]
|
|
s = self.map[x][y + 1] == EMPTY == self.map[nextx][y + 1]
|
|
self.stepy = n - s
|
|
elif not self.stepx and self.stepy:
|
|
nexty = y - self.stepy
|
|
w = self.map[x - 1][y] == EMPTY == self.map[x - 1][nexty]
|
|
e = self.map[x + 1][y] == EMPTY == self.map[x + 1][nexty]
|
|
self.stepx = w - e
|
|
return
|
|
|
|
queue = defaultdict(list, {0: [(self.destx, self.desty)]})
|
|
visited, count, distance = set(), 1, 0
|
|
while count:
|
|
# Hashes of small intergers are themselves so queue is sorted
|
|
if not queue[distance]: distance += 1
|
|
x, y = queue[distance].pop()
|
|
count -= 1
|
|
if (x, y) not in visited:
|
|
visited.add((x, y))
|
|
else:
|
|
continue
|
|
|
|
dx, dy = MIDDLE - x, MIDDLE - y
|
|
if dx**2 + dy**2 <= 2:
|
|
self.stepx, self.stepy = dx, dy
|
|
return
|
|
for i, j in around(x, y):
|
|
if self.map[i][j] == EMPTY and xcheck(i) and ycheck(j):
|
|
queue[distance + 1].append((i, j))
|
|
count += 1
|
|
self.stepx, self.stepy = 0, 0
|
|
|
|
def isfast(self):
|
|
"""Return if the hero is moving faster than HERO_SPEED."""
|
|
return (self.vx**2+self.vy**2)**0.5*self.fps > HERO_SPEED*self.distance
|
|
|
|
def lose(self):
|
|
"""Handle loses."""
|
|
self.hero.dead = True
|
|
self.hero.slashing = self.hero.firing = False
|
|
self.destx = self.desty = MIDDLE
|
|
self.stepx = self.stepy = 0
|
|
self.vx = self.vy = 0.0
|
|
play(self.sfx_lose)
|
|
|
|
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.map[MIDDLE][MIDDLE] = HERO
|
|
self.destx = self.desty = MIDDLE
|
|
self.stepx = self.stepy = 0
|
|
self.vx = self.vy = 0.0
|
|
self.rotatex = self.rotatey = 0
|
|
self.bullets, self.enemies = [], []
|
|
self.enemy_weights = {color: MINW for color in ENEMIES}
|
|
self.add_enemy()
|
|
|
|
self.next_move = self.next_slashfx = 0.0
|
|
self.hero.next_heal = self.hero.next_strike = 0
|
|
self.hero.slashing = self.hero.firing = self.hero.dead = False
|
|
self.hero.spin_queue = self.hero.wound = 0.0
|