about summary refs log tree commit diff homepage
diff options
context:
space:
mode:
authorNguyễn Gia Phong <vn.mcsinyx@gmail.com>2018-08-03 22:50:59 +0700
committerNguyễn Gia Phong <vn.mcsinyx@gmail.com>2018-08-03 22:50:59 +0700
commit834fa33ec0e750061e4ebba1f9525b16ccb43dc9 (patch)
treed61184bda1a333b02e784845853a9b38bf45515f
parenteb23230acba2715ceae45457db7bff7a911c1c43 (diff)
downloadbrutalmaze-834fa33ec0e750061e4ebba1f9525b16ccb43dc9.tar.gz
Implement game recording
And fix various bugs on game data export. Somehow they remain
undiscovered the last 5 months.
-rw-r--r--brutalmaze/characters.py15
-rw-r--r--brutalmaze/constants.py2
-rw-r--r--brutalmaze/maze.py74
-rw-r--r--brutalmaze/misc.py27
-rw-r--r--brutalmaze/settings.ini10
5 files changed, 109 insertions, 19 deletions
diff --git a/brutalmaze/characters.py b/brutalmaze/characters.py
index e3e6db2..51a1eb7 100644
--- a/brutalmaze/characters.py
+++ b/brutalmaze/characters.py
@@ -286,15 +286,17 @@ class Enemy:
         x, y = self.get_pos()
         return atan2(y - self.maze.y, x - self.maze.x)
 
-
     def get_color(self):
         """Return current color of the enemy."""
         return TANGO[self.color][int(self.wound)]
 
+    def isunnoticeable(self):
+        """Return whether the enemy can be noticed."""
+        return not self.awake or self.wound >= ENEMY_HP
 
     def draw(self):
         """Draw the enemy."""
-        if not self.awake: return
+        if self.isunnoticeable(): return
         radius = self.maze.distance / SQRT2
         square = regpoly(4, radius, self.angle, *self.get_pos())
         fill_aapolygon(self.maze.surface, square, self.get_color())
@@ -344,10 +346,11 @@ class Chameleon(Enemy):
         if Enemy.wake(self) is True:
             self.visible = 1000.0 / ENEMY_SPEED
 
-    def draw(self):
-        """Draw the Chameleon."""
-        if not self.awake or self.visible > 0 or self.spin_queue:
-            Enemy.draw(self)
+    def isunnoticeable(self):
+        """Return whether the enemy can be noticed."""
+        return (Enemy.isunnoticeable(self)
+                or self.visible <= 0 and not self.spin_queue
+                and self.maze.next_move <= 0)
 
     def update(self):
         """Update the Chameleon."""
diff --git a/brutalmaze/constants.py b/brutalmaze/constants.py
index 82e76c9..7d7ba00 100644
--- a/brutalmaze/constants.py
+++ b/brutalmaze/constants.py
@@ -84,3 +84,5 @@ HERO_HP = 5
 MIN_BEAT = 526
 BG_COLOR = TANGO['Aluminium'][-1]
 FG_COLOR = TANGO['Aluminium'][0]
+
+JSON_SEPARATORS = ',', ':'
diff --git a/brutalmaze/maze.py b/brutalmaze/maze.py
index 6a5f139..28dc912 100644
--- a/brutalmaze/maze.py
+++ b/brutalmaze/maze.py
@@ -20,7 +20,9 @@
 __doc__ = 'Brutal Maze module for the maze class'
 
 from collections import defaultdict, deque
+import json
 from math import pi, log
+from os import path
 from random import choice, sample, uniform
 
 import pygame
@@ -30,9 +32,10 @@ from .constants import (
     EMPTY, WALL, HERO, ENEMY, ROAD_WIDTH, WALL_WIDTH, CELL_WIDTH, CELL_NODES,
     MAZE_SIZE, MIDDLE, INIT_SCORE, ENEMIES, MINW, MAXW, SQRT2, SFX_SPAWN,
     SFX_SLASH_ENEMY, SFX_LOSE, ADJACENTS, TANGO_VALUES, BG_COLOR, FG_COLOR,
-    HERO_HP, ENEMY_HP, ATTACK_SPEED, MAX_WOUND, HERO_SPEED, BULLET_LIFETIME)
-from .misc import round2, sign, around, regpoly, fill_aapolygon, play
-from .weapons import Bullet
+    COLORS, HERO_HP, ENEMY_HP, ATTACK_SPEED, MAX_WOUND, HERO_SPEED,
+    BULLET_LIFETIME, JSON_SEPARATORS)
+from .misc import (
+    round2, sign, deg, around, regpoly, fill_aapolygon, play, json_rec)
 
 
 class Maze:
@@ -50,7 +53,7 @@ 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
+        bullets (list of .weapons.Bullet): flying bullets
         enemy_weights (dict): probabilities of enemies to be created
         enemies (list of Enemy): alive enemies
         hero (Hero): the hero
@@ -62,14 +65,21 @@ class Maze:
         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, headless):
+    def __init__(self, fps, size, headless, export_dir, export_rate):
         self.fps = fps
         self.w, self.h = size
         if headless:
             self.surface = None
         else:
             self.surface = pygame.display.set_mode(size, pygame.RESIZABLE)
+        self.export_dir = path.abspath(export_dir)
+        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
@@ -336,6 +346,46 @@ class Maze:
                 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 round2(cx), round2(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.get_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'] = round2(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
@@ -347,6 +397,7 @@ class Maze:
         self.next_move -= 1000.0 / fps
         self.glitch -= 1000.0 / fps
         self.next_slashfx -= 1000.0 / fps
+        self.next_export -= 1000.0 / fps
 
         self.rotate()
         if self.vx or self.vy or self.hero.firing or self.hero.slashing:
@@ -358,7 +409,8 @@ class Maze:
         if not self.hero.dead:
             self.hero.update(fps)
             self.slash()
-            if self.hero.wound > HERO_HP: self.lose()
+            if self.hero.wound >= HERO_HP: self.lose()
+        self.update_export()
 
     def resize(self, size):
         """Resize the maze."""
@@ -419,19 +471,27 @@ class Maze:
         """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)
+        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.score, self.export = INIT_SCORE, []
         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):
diff --git a/brutalmaze/misc.py b/brutalmaze/misc.py
index d26edf4..008d7cf 100644
--- a/brutalmaze/misc.py
+++ b/brutalmaze/misc.py
@@ -19,8 +19,10 @@
 
 __doc__ = 'Brutal Maze module for miscellaneous functions'
 
+from datetime import datetime
 from itertools import chain
 from math import degrees, cos, sin, pi
+from os import path
 from random import shuffle, uniform
 
 import pygame
@@ -40,9 +42,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)]
@@ -50,7 +52,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)
 
@@ -72,6 +74,15 @@ def cosin(x):
     return cos(x) + sin(x)
 
 
+def join(iterable, sep=' ', end='\n'):
+    """Return a string which is the concatenation of string
+    representations of objects in the iterable, separated by sep.
+
+    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]
@@ -111,3 +122,11 @@ def play(sound, volume=1.0, angle=None):
                 volumes[i] = 1.0
         sound.set_volume(1.0)
         channel.set_volume(*volumes)
+
+
+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]))
diff --git a/brutalmaze/settings.ini b/brutalmaze/settings.ini
index b426aab..75bfb72 100644
--- a/brutalmaze/settings.ini
+++ b/brutalmaze/settings.ini
@@ -6,9 +6,9 @@ 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
-# Use space music background, which sounds cold and creepy
+# Use space music background, which sounds cold and creepy.
 Space theme: no
 
 [Control]
@@ -29,6 +29,12 @@ Auto move: Mouse3
 Long-range attack: Mouse1
 Close-range attack: Space
 
+[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