Continue modularization

The changes includes:
* Modularize argument parser
* Let peer handle FPS based on abstract timer
* Allow specifying configuration file
* Implement printing default configurations
This commit is contained in:
Nguyễn Gia Phong 2019-10-02 21:33:51 +07:00
parent aa6878e80d
commit 2503006d07
7 changed files with 197 additions and 109 deletions

View File

@ -1,4 +1,29 @@
"""Axuy is a minimalist first-person shooter."""
# package initialization
# Copyright (C) 2019 Nguyễn Gia Phong
#
# This file is part of Axuy
#
# Axuy 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.
#
# Axuy 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 Axuy. If not, see <https://www.gnu.org/licenses/>.
__doc__ = """Axuy is a minimalist peer-to-peer first-person shooter.
This package provides abstractions for writing custom front-ends and AIs.
All classes and helper functions are exposed at the package level.
Some superclasses may define abstract methods which must be overridden
in derived classes. Subclasses only document newly introduced attributes.
"""
from .misc import *
from .pico import *

32
axuy/__main__.py Normal file
View File

@ -0,0 +1,32 @@
# main loop
# Copyright (C) 2019 Nguyễn Gia Phong
#
# This file is part of Axuy
#
# Axuy 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.
#
# Axuy 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 Axuy. If not, see <https://www.gnu.org/licenses/>.
__doc__ = 'Axuy main loop'
__all__ = ['main']
from .control import CtlConfig, Control
def main():
"""Parse arguments and start main loop."""
config = CtlConfig()
config.parse()
with Control(config) as peer: peer.run()
if __name__ == '__main__': main()

View File

@ -18,9 +18,7 @@
__doc__ = 'Axuy handling of user control using GLFW'
__all__ = ['CtlConfig', 'Control']
__version__ = '0.0.7'
from argparse import ArgumentParser, RawTextHelpFormatter
from re import IGNORECASE, match
from warnings import warn
@ -52,19 +50,29 @@ class CtlConfig(DispConfig):
Zoom speed, in scroll steps per zoom range.
"""
def __init__(self):
DispConfig.__init__(self)
self.options.add_argument(
'--mouse-speed', type=float, dest='mouspeed',
help='camera rotational speed (fallback: {:.1f})'.format(
self.__mouspeed))
self.options.add_argument(
'--zoom-speed', type=float, dest='zmspeed',
help='zoom speed (fallback: {:.1f})'.format(self.zmspeed))
@property
def mouspeed(self) -> float:
"""Relative mouse speed."""
# Standard to radians per inch for a 800 DPI mouse, at FOV of 60
return self._mouspeed / 800
return self.__mouspeed / 800
@mouspeed.setter
def mouspeed(self, value):
self._mouspeed = value
self.__mouspeed = value
def parse(self):
"""Parse configurations."""
DispConfig.parse(self)
def fallback(self):
"""Parse fallback configurations."""
DispConfig.fallback(self)
self.mouspeed = self.config.getfloat('Control', 'Mouse speed')
self.zmspeed = self.config.getfloat('Control', 'Zoom speed')
self.key, self.mouse = {}, {}
@ -78,9 +86,9 @@ class CtlConfig(DispConfig):
except AttributeError:
raise ValueError(INVALID_CONTROL_ERR.format(cmd, i))
def read_args(self, arguments):
def read(self, arguments):
"""Read and parse a argparse.ArgumentParser.Namespace."""
DispConfig.read_args(self, arguments)
DispConfig.read(self, arguments)
for option in 'fov', 'mouspeed', 'zmspeed':
value = getattr(arguments, option)
if value is not None: setattr(self, option, value)
@ -163,49 +171,3 @@ class Control(Display):
if self.is_pressed(self.key['left']): right -= 1
if self.is_pressed(self.key['right']): right += 1
self.camera.update(right, upward, forward)
def main():
"""Parse arguments and start main loop."""
# Read configuration files
config = CtlConfig()
config.parse()
# Parse command-line arguments
parser = ArgumentParser(usage='%(prog)s [options]',
formatter_class=RawTextHelpFormatter)
parser.add_argument('-v', '--version', action='version',
version='Axuy {}'.format(__version__))
parser.add_argument(
'--host',
help='host to bind this peer to (fallback: {})'.format(config.host))
parser.add_argument(
'-p', '--port', type=int,
help='port to bind this peer to (fallback: {})'.format(config.port))
parser.add_argument('-s', '--seeder',
help='address of the peer that created the map')
# All these options specific for a graphical peer need to be modularized.
parser.add_argument(
'--size', type=int, nargs=2, metavar=('X', 'Y'),
help='the desired screen size (fallback: {}x{})'.format(*config.size))
parser.add_argument(
'--vsync', action='store_true', default=None,
help='enable vertical synchronization (fallback: {})'.format(
config.vsync))
parser.add_argument('--no-vsync', action='store_false', dest='vsync',
help='disable vertical synchronization')
parser.add_argument(
'--fov', type=float,
help='horizontal field of view (fallback: {:.1f})'.format(config.fov))
parser.add_argument(
'--mouse-speed', type=float, dest='mouspeed',
help='camera rotational speed (fallback: {:.1f})'.format(
config._mouspeed))
parser.add_argument(
'--zoom-speed', type=float, dest='zmspeed',
help='zoom speed (fallback: {:.1f})'.format(config.zmspeed))
args = parser.parse_args()
config.read_args(args)
with Control(config) as peer:
while peer.is_running: peer.update()

View File

@ -70,6 +70,24 @@ class DispConfig(PeerConfig):
Zoom level.
"""
def __init__(self):
PeerConfig.__init__(self)
self.options.add_argument(
'--size', type=int, nargs=2, metavar=('X', 'Y'),
help='the desired screen size (fallback: {}x{})'.format(
*self.size))
self.options.add_argument(
'--vsync', action='store_true', default=None,
help='enable vertical synchronization (fallback: {})'.format(
self.vsync))
self.options.add_argument(
'--no-vsync', action='store_false', dest='vsync',
help='disable vertical synchronization')
self.options.add_argument(
'--fov', type=float, metavar='DEGREES',
help='horizontal field of view (fallback: {:})'.format(
round(self.fov)))
@property
def fov(self) -> float:
"""Horizontal field of view in degrees."""
@ -89,17 +107,17 @@ class DispConfig(PeerConfig):
return
self.zmlvl = log2(rad)
def parse(self):
"""Parse configurations."""
PeerConfig.parse(self)
def fallback(self):
"""Parse fallback configurations."""
PeerConfig.fallback(self)
self.size = (self.config.getint('Graphics', 'Screen width'),
self.config.getint('Graphics', 'Screen height'))
self.vsync = self.config.getboolean('Graphics', 'V-sync')
self.fov = self.config.getfloat('Graphics', 'FOV')
def read_args(self, arguments):
def read(self, arguments):
"""Read and parse a argparse.ArgumentParser.Namespace."""
PeerConfig.read_args(self, arguments)
PeerConfig.read(self, arguments)
for option in 'size', 'vsync', 'fov':
value = getattr(arguments, option)
if value is not None: setattr(self, option, value)
@ -149,8 +167,6 @@ class Display(Peer):
Vertex data for final combination of the bloom effect.
fb, ping, pong : moderngl.Framebuffer
Frame buffers for bloom-effect post-processing.
last_time : float
timestamp in seconds of the previous frame.
fpses : Deque[float]
FPS during the last 5 seconds to display the average.
"""
@ -174,7 +190,6 @@ class Display(Peer):
glfw.terminate()
raise RuntimeError('Failed to create GLFW window')
self.fpses = deque()
self.last_time = glfw.get_time()
# Window's rendering and event-handling configuration
glfw.set_window_icon(self.window, 1, Image.open(abspath('icon.png')))
@ -310,16 +325,6 @@ class Display(Peer):
"""Camera visibility."""
return np.float32(3240 / (self.fov + 240))
@property
def fps(self) -> float:
"""Currently rendered frames per second."""
return self.camera.fps
@fps.setter
def fps(self, fps):
self.camera.fps = fps
self.fpses.appendleft(fps)
@property
def fpstr(self) -> str:
"""Pretty string for displaying average FPS."""
@ -327,6 +332,10 @@ class Display(Peer):
while len(self.fpses) > mean(self.fpses) * 5 > 0: self.fpses.pop()
return '{} fps'.format(round(mean(self.fpses)))
def get_time(self) -> float:
"""Return the current time."""
return glfw.get_time()
def prender(self, obj, va, col, bright):
"""Render the obj and its images in bounded 3D space."""
rotation = Matrix44.from_matrix33(obj.rot).astype(np.float32).tobytes()
@ -367,19 +376,16 @@ class Display(Peer):
self.prog['visibility'].value = visibility
self.prog['camera'].write(self.pos.tobytes())
self.prog['vp'].write(vp)
picos = list(self.picos.values())
for pico in picos:
shards = {}
for index, shard in pico.shards.items():
shard.update(self.fps, picos)
if not shard.power: continue
self.render_shard(shard)
shards[index] = shard
pico.shards = shards
for pico in self.picos.values():
for shard in pico.shards.values(): self.render_shard(shard)
if pico is not self.camera: self.render_pico(pico)
def draw(self):
"""Render and post-process."""
def update(self):
"""Update and render the map."""
# Update states
Peer.update(self)
self.fpses.appendleft(self.fps)
# Render to framebuffer
self.fb.use()
self.fb.clear()
@ -411,15 +417,6 @@ class Display(Peer):
self.edge['zoom'].value = (self.zmlvl + 1.0) / 100
self.combine.render(moderngl.TRIANGLES)
glfw.swap_buffers(self.window)
def update(self):
"""Update and render the map."""
next_time = glfw.get_time()
self.fps = 1 / (next_time-self.last_time)
self.last_time = next_time
Peer.update(self)
# Render
self.draw()
glfw.set_window_title(self.window, '{} - axuy@{}:{} ({})'.format(
self.postr, *self.addr, self.fpstr))

View File

@ -17,8 +17,8 @@
# along with Axuy. If not, see <https://www.gnu.org/licenses/>.
__doc__ = 'Axuy miscellaneous functions'
__all__ = ['abspath', 'color', 'mapidgen', 'mapgen', 'mirror', 'norm',
'normalized', 'sign', 'placeable']
__all__ = ['abspath', 'color', 'mapidgen', 'mapgen', 'neighbors', 'mirror',
'norm', 'normalized', 'sign', 'twelve', 'nine', 'placeable']
from itertools import (chain, combinations_with_replacement,
permutations, product)

View File

@ -18,13 +18,16 @@
__doc__ = 'Axuy peer'
__all__ = ['PeerConfig', 'Peer']
__version__ = '0.0.7'
from abc import ABC, abstractmethod
from argparse import ArgumentParser, FileType, RawTextHelpFormatter
from configparser import ConfigParser
from os.path import join as pathjoin, pathsep
from pickle import dumps, loads
from queue import Empty, Queue
from socket import socket, SOCK_DGRAM, SOL_SOCKET, SO_REUSEADDR
from sys import stdout
from threading import Thread
from typing import Iterator, Tuple
@ -33,6 +36,8 @@ from appdirs import AppDirs
from .misc import abspath, mapgen, mapidgen
from .pico import Pico
SETTINGS = abspath('settings.ini')
class PeerConfig:
"""Networking configurations
@ -41,6 +46,8 @@ class PeerConfig:
----------
config : ConfigParser
INI configuration file parser.
options : ArgumentParser
Command-line argument parser.
host : str
Host to bind the peer to.
port : int
@ -55,9 +62,39 @@ class PeerConfig:
parents.append(dirs.user_config_dir)
filenames = [pathjoin(parent, 'settings.ini') for parent in parents]
# Parse configuration files
self.config = ConfigParser()
self.config.read(abspath('settings.ini')) # default configuration
self.config.read(SETTINGS)
self.config.read(filenames)
self.fallback()
# Parse command-line arguments
self.options = ArgumentParser(usage='%(prog)s [options]',
formatter_class=RawTextHelpFormatter)
self.options.add_argument('-v', '--version', action='version',
version='Axuy {}'.format(__version__))
self.options.add_argument(
'--write-config', nargs='?', const=stdout, type=FileType('w'),
metavar='PATH', dest='cfgout',
help='write default config to PATH (fallback: stdout) and exit')
self.options.add_argument(
'-c', '--config', metavar='PATH',
help='location of the configuration file (fallback: {})'.format(
pathsep.join(filenames)))
self.options.add_argument(
'--host',
help='host to bind this peer to (fallback: {})'.format(self.host))
self.options.add_argument(
'-p', '--port', type=int,
help='port to bind this peer to (fallback: {})'.format(self.port))
self.options.add_argument(
'-s', '--seeder', metavar='ADDRESS',
help='address of the peer that created the map')
def fallback(self):
"""Parse fallback configurations."""
self.host = self.config.get('Peer', 'Host')
self.port = self.config.getint('Peer', 'Port')
# Fallback to None when attribute is missing
def __getattr__(self, name): return None
@ -65,24 +102,31 @@ class PeerConfig:
@property
def seeder(self) -> Tuple[str, int]:
"""Seeder address."""
return self._seed
return self.__seed
@seeder.setter
def seeder(self, value):
host, port = value.split(':')
self._seed = host, int(port)
self.__seed = host, int(port)
def parse(self):
"""Parse configurations."""
self.host = self.config.get('Peer', 'Host')
self.port = self.config.getint('Peer', 'Port')
def read_args(self, arguments):
def read(self, arguments):
"""Read and parse a argparse.ArgumentParser.Namespace."""
for option in 'host', 'port', 'seeder':
value = getattr(arguments, option)
if value is not None: setattr(self, option, value)
def parse(self):
"""Parse all configurations."""
args = self.options.parse_args()
if args.cfgout is not None:
with open(SETTINGS) as f: args.cfgout.write(f.read())
args.cfgout.close()
exit()
if args.config: # is neither None nor empty
self.config.read(args.config)
self.fallback()
self.read(args)
class Peer(ABC):
"""Axuy peer.
@ -109,8 +153,8 @@ class Peer(ABC):
Protagonist.
picos : Dict[Tuple[str, int], Pico]
All picos present in the map.
view : View
World representation and renderer.
last_time : float
Timestamp of the previous update.
"""
def __init__(self, config):
@ -130,6 +174,7 @@ class Peer(ABC):
self.space = mapgen(mapid)
self.pico = Pico(self.addr, self.space)
self.picos = {self.addr: self.pico}
self.last_time = self.get_time()
Thread(target=self.serve, args=(mapid,), daemon=True).start()
Thread(target=self.pull, daemon=True).start()
@ -152,6 +197,15 @@ class Peer(ABC):
else:
self.q.task_done()
@property
def fps(self) -> float:
"""Current loop rate."""
return self.pico.fps
@fps.setter
def fps(self, fps):
self.pico.fps = fps
def serve(self, mapid):
"""Initiate other peers."""
with socket() as server: # TCP server
@ -174,6 +228,10 @@ class Peer(ABC):
def __enter__(self): return self
@abstractmethod
def get_time(self) -> float:
"""Return the current time."""
def sync(self):
"""Synchronize states received from other peers."""
for data, addr in self.ready:
@ -193,13 +251,27 @@ class Peer(ABC):
def control(self):
"""Control the protagonist."""
@abstractmethod
def update(self):
"""Update internal states and send them to other peers."""
next_time = self.get_time()
self.fps = 1 / (next_time-self.last_time)
self.last_time = next_time
self.sync()
self.control()
picos = list(self.picos.values())
for pico in picos:
shards = {}
for index, shard in pico.shards.items():
shard.update(self.fps, picos)
if shard.power: shards[index] = shard
pico.shards = shards
self.push()
def run(self):
"""Start main loop."""
while self.is_running: self.update()
@abstractmethod
def close(self):
"""Explicitly terminate stuff in subclass

View File

@ -35,4 +35,4 @@ setup(
'moderngl', 'glfw>=1.8', 'Pillow'],
package_data={'axuy': ['map.npy', 'shaders/*',
'icon.png', 'settings.ini']},
entry_points={'console_scripts': ['axuy = axuy.control:main']})
entry_points={'console_scripts': ['axuy = axuy.__main__:main']})