From 33bfdaf647c6736d99aadc017ee935f3301d758a Mon Sep 17 00:00:00 2001 From: Christian Heller <c.heller@plomlompom.de> Date: Sun, 25 Oct 2020 01:35:20 +0200 Subject: [PATCH] Add basic multiplayer roguelike chat example. --- new2/plomrogue/commands.py | 37 ++++++++ new2/plomrogue/errors.py | 10 ++ new2/plomrogue/game.py | 123 +++++++++++++++++++++++++ new2/plomrogue/mapping.py | 14 +++ new2/plomrogue/misc.py | 10 ++ new2/plomrogue/parser.py | 93 +++++++++++++++++++ new2/plomrogue/tasks.py | 46 ++++++++++ new2/plomrogue/things.py | 78 ++++++++++++++++ new2/rogue_chat.html | 183 +++++++++++++++++++++++++++++++++++++ new2/rogue_chat.py | 5 + 10 files changed, 599 insertions(+) create mode 100644 new2/plomrogue/commands.py create mode 100644 new2/plomrogue/errors.py create mode 100755 new2/plomrogue/game.py create mode 100644 new2/plomrogue/mapping.py create mode 100644 new2/plomrogue/misc.py create mode 100644 new2/plomrogue/parser.py create mode 100644 new2/plomrogue/tasks.py create mode 100644 new2/plomrogue/things.py create mode 100644 new2/rogue_chat.html create mode 100755 new2/rogue_chat.py diff --git a/new2/plomrogue/commands.py b/new2/plomrogue/commands.py new file mode 100644 index 0000000..a68efc2 --- /dev/null +++ b/new2/plomrogue/commands.py @@ -0,0 +1,37 @@ +from plomrogue.misc import quote + + + +def cmd_ALL(game, msg, connection_id): + if not connection_id in game.sessions: + game.io.send('LOG' + quote('need to be logged in for this'), connection_id) + return + t = game.get_thing(game.sessions[connection_id], False) + game.io.send('LOG ' + quote(t.nickname + ': ' + msg)) +cmd_ALL.argtypes = 'string' + +def cmd_LOGIN(game, nick, connection_id): + for t in [t for t in game.things if t.type_ == 'player' and t.nickname == nick]: + game.io.send('LOG ' + quote('name already in use'), connection_id) + return + t = game.thing_types['player'](game) + t.nickname = nick + game.things += [t] # TODO refactor into Thing.__init__? + game.sessions[connection_id] = t.id_ + game.io.send('LOG ' + quote('your are now: ' + nick), connection_id) +cmd_LOGIN.argtypes = 'string' + +def cmd_QUERY(game, target_nick, msg, connection_id): + if not connection_id in game.sessions: + game.io.send('LOG ' + quote('can only query when logged in'), connection_id) + t = game.get_thing(game.sessions[connection_id], False) + source_nick = t.nickname + for t in [t for t in game.things if t.type_ == 'player' and t.nickname == target_nick]: + for c_id in game.sessions: + if game.sessions[c_id] == t.id_: + game.io.send('LOG ' + quote(source_nick+ '->' + target_nick + ': ' + msg), c_id) + game.io.send('LOG ' + quote(source_nick+ '->' + target_nick + ': ' + msg), connection_id) + return + game.io.send('LOG ' + quote('target user offline?')) + game.io.send('LOG ' + quote('can only query with registered nicknames')) +cmd_QUERY.argtypes = 'string string' diff --git a/new2/plomrogue/errors.py b/new2/plomrogue/errors.py new file mode 100644 index 0000000..bc37495 --- /dev/null +++ b/new2/plomrogue/errors.py @@ -0,0 +1,10 @@ +class ArgError(Exception): + pass + + +class GameError(Exception): + pass + + +class BrokenSocketConnection(Exception): + pass diff --git a/new2/plomrogue/game.py b/new2/plomrogue/game.py new file mode 100755 index 0000000..e0c0715 --- /dev/null +++ b/new2/plomrogue/game.py @@ -0,0 +1,123 @@ +from plomrogue.tasks import Task_WAIT, Task_MOVE +from plomrogue.errors import GameError +from plomrogue.commands import cmd_ALL, cmd_LOGIN, cmd_QUERY +from plomrogue.io import GameIO +from plomrogue.misc import quote +from plomrogue.things import Thing, ThingPlayer + + + +class GameBase: + + def __init__(self): + pass + self.turn = 0 + self.things = [] + + def get_thing(self, id_, create_unfound): + # No default for create_unfound because every call to get_thing + # should be accompanied by serious consideration whether to use it. + for thing in self.things: + if id_ == thing.id_: + return thing + if create_unfound: + t = self.thing_type(self, id_) + self.things += [t] + return t + return None + + + +class Game(GameBase): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.changed = True + self.io = GameIO(self) + self.tasks = {'WAIT': Task_WAIT, + 'MOVE': Task_MOVE} + self.commands = {'QUERY': cmd_QUERY, 'ALL': cmd_ALL, 'LOGIN': cmd_LOGIN} + self.thing_type = Thing + self.thing_types = {'player': ThingPlayer} + self.sessions = {} + + def get_string_options(self, string_option_type): + if string_option_type == 'direction': + return ['UP', 'DOWN', 'LEFT', 'RIGHT'] + return None + + def send_gamestate(self, connection_id=None): + """Send out game state data relevant to clients.""" + + def send_thing(thing): + self.io.send('THING_POS %s %s' % (thing.id_, t.position)) + + self.io.send('TURN ' + str(self.turn)) + for t in self.things: + send_thing(t) + + def run_tick(self): + to_delete = [] + for connection_id in self.sessions: + if not connection_id in self.io.server.clients: + t = self.get_thing(self.sessions[connection_id], create_unfound=False) + self.things.remove(t) + to_delete += [connection_id] + for connection_id in to_delete: + del self.sessions[connection_id] + self.changed = True + for t in [t for t in self.things]: + if t in self.things: + try: + t.proceed() + except GameError as e: + for connection_id in [c_id for c_id in self.sessions + if self.sessions[c_id] == t.id_]: + self.io.send('GAME_ERROR ' + quote(str(e)), connection_id) + self.turn += 1 + if self.changed: + self.send_gamestate() + self.changed = False + + def get_command(self, command_name): + + def partial_with_attrs(f, *args, **kwargs): + from functools import partial + p = partial(f, *args, **kwargs) + p.__dict__.update(f.__dict__) + return p + + def cmd_TASK_colon(task_name, game, *args, connection_id): + if connection_id not in game.sessions: + raise GameError('Not registered as player.') + t = game.get_thing(game.sessions[connection_id], create_unfound=False) + t.set_next_task(task_name, args) + + def task_prefixed(command_name, task_prefix, task_command): + if command_name.startswith(task_prefix): + task_name = command_name[len(task_prefix):] + if task_name in self.tasks: + f = partial_with_attrs(task_command, task_name, self) + task = self.tasks[task_name] + f.argtypes = task.argtypes + return f + return None + + command = task_prefixed(command_name, 'TASK:', cmd_TASK_colon) + if command: + return command + if command_name in self.commands: + f = partial_with_attrs(self.commands[command_name], self) + return f + return None + + def new_thing_id(self): + if len(self.things) == 0: + return 0 + # DANGEROUS â if anywhere we append a thing to the list of lower + # ID than the highest-value ID, this might lead to re-using an + # already active ID. This condition /should/ not be fulfilled + # anywhere in the code, but if it does, trouble here is one of + # the more obvious indicators that it does â that's why there's + # no safeguard here against this. + return self.things[-1].id_ + 1 diff --git a/new2/plomrogue/mapping.py b/new2/plomrogue/mapping.py new file mode 100644 index 0000000..1847f69 --- /dev/null +++ b/new2/plomrogue/mapping.py @@ -0,0 +1,14 @@ +import collections + + + +class YX(collections.namedtuple('YX', ('y', 'x'))): + + def __add__(self, other): + return YX(self.y + other.y, self.x + other.x) + + def __sub__(self, other): + return YX(self.y - other.y, self.x - other.x) + + def __str__(self): + return 'Y:%s,X:%s' % (self.y, self.x) diff --git a/new2/plomrogue/misc.py b/new2/plomrogue/misc.py new file mode 100644 index 0000000..a3f7298 --- /dev/null +++ b/new2/plomrogue/misc.py @@ -0,0 +1,10 @@ +def quote(string): + """Quote & escape string so client interprets it as single token.""" + quoted = [] + quoted += ['"'] + for c in string: + if c in {'"', '\\'}: + quoted += ['\\'] + quoted += [c] + quoted += ['"'] + return ''.join(quoted) diff --git a/new2/plomrogue/parser.py b/new2/plomrogue/parser.py new file mode 100644 index 0000000..a56b5d1 --- /dev/null +++ b/new2/plomrogue/parser.py @@ -0,0 +1,93 @@ +import unittest +from plomrogue.errors import ArgError + + +class Parser: + + def __init__(self, game=None): + self.game = game + + def tokenize(self, msg): + """Parse msg string into tokens. + + Separates by ' ' and '\n', but allows whitespace in tokens quoted by + '"', and allows escaping within quoted tokens by a prefixed backslash. + """ + tokens = [] + token = '' + quoted = False + escaped = False + for c in msg: + if quoted: + if escaped: + token += c + escaped = False + elif c == '\\': + escaped = True + elif c == '"': + quoted = False + else: + token += c + elif c == '"': + quoted = True + elif c in {' ', '\n'}: + if len(token) > 0: + tokens += [token] + token = '' + else: + token += c + if len(token) > 0: + tokens += [token] + return tokens + + def parse(self, msg): + """Parse msg as call to function, return function with args tuple. + + Respects function signature defined in function's .argtypes attribute. + """ + tokens = self.tokenize(msg) + if len(tokens) == 0: + return None, () + func = self.game.get_command(tokens[0]) + argtypes = '' + if hasattr(func, 'argtypes'): + argtypes = func.argtypes + if func is None: + return None, () + if len(argtypes) == 0: + if len(tokens) > 1: + raise ArgError('Command expects no argument(s).') + return func, () + if len(tokens) == 1: + raise ArgError('Command expects argument(s).') + args_candidates = tokens[1:] + args = self.argsparse(argtypes, args_candidates) + return func, args + + def argsparse(self, signature, args_tokens): + tmpl_tokens = signature.split() + if len(tmpl_tokens) != len(args_tokens): + raise ArgError('Number of arguments (' + str(len(args_tokens)) + + ') not expected number (' + str(len(tmpl_tokens)) + + ').') + args = [] + string_string = 'string' + for i in range(len(tmpl_tokens)): + tmpl = tmpl_tokens[i] + arg = args_tokens[i] + if tmpl == string_string: + args += [arg] + elif tmpl[:len(string_string) + 1] == string_string + ':': + if not hasattr(self.game, 'get_string_options'): + raise ArgError('No string option directory.') + string_option_type = tmpl[len(string_string) + 1:] + options = self.game.get_string_options(string_option_type) + if options is None: + raise ArgError('Unknown string option type.') + if arg not in options: + msg = 'Argument #%s must be one of: %s' % (i + 1, options) + raise ArgError(msg) + args += [arg] + else: + raise ArgError('Unknown argument type: %s' % tmpl) + return args diff --git a/new2/plomrogue/tasks.py b/new2/plomrogue/tasks.py new file mode 100644 index 0000000..402dfcb --- /dev/null +++ b/new2/plomrogue/tasks.py @@ -0,0 +1,46 @@ +from plomrogue.errors import GameError +#from plomrogue.misc import quote +from plomrogue.mapping import YX + + + +class Task: + argtypes = '' + todo = 3 + + def __init__(self, thing, args=()): + self.thing = thing + self.args = args + + def check(self): + pass + + + +class Task_WAIT(Task): + todo = 1 + + def do(self): + return 'success' + + + +class Task_MOVE(Task): + argtypes = 'string:direction' + + def get_move_target(self): + moves = { + 'UP': YX(-1, 0), + 'DOWN': YX(1, 0), + 'LEFT': YX(0, -1), + 'RIGHT': YX(0, 1), + } + return self.thing.position + moves[self.args[0]] + + def check(self): + test_pos = self.get_move_target() + if test_pos.y < 0 or test_pos.x < 0 or test_pos.y >= 24 or test_pos.x >= 40: + raise GameError('would move out of map') + + def do(self): + self.thing.position = self.get_move_target() diff --git a/new2/plomrogue/things.py b/new2/plomrogue/things.py new file mode 100644 index 0000000..138a1fa --- /dev/null +++ b/new2/plomrogue/things.py @@ -0,0 +1,78 @@ +from plomrogue.errors import GameError +from plomrogue.mapping import YX + + + +class ThingBase: + type_ = '?' + + def __init__(self, game, id_=None, position=(YX(0,0))): + self.game = game + if id_ is None: + self.id_ = self.game.new_thing_id() + else: + self.id_ = id_ + self.position = position + + + +class Thing(ThingBase): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def proceed(self): + pass + + + +class ThingAnimate(Thing): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.next_tasks = [] + self.set_task('WAIT') + + def set_task(self, task_name, args=()): + task_class = self.game.tasks[task_name] + self.task = task_class(self, args) + self.task.check() # will throw GameError if necessary + + def set_next_task(self, task_name, args=()): + task_class = self.game.tasks[task_name] + self.next_tasks += [task_class(self, args)] + + def get_next_task(self): + if len(self.next_tasks) > 0: + task = self.next_tasks.pop(0) + task.check() + return task + else: + return None + + def proceed(self): + if self.task is None: + self.task = self.get_next_task() + return + + try: + self.task.check() + except GameError as e: + self.task = None + raise GameError + return + self.task.todo -= 1 + if self.task.todo <= 0: + self._last_task_result = self.task.do() + self.game.changed = True + self.task = self.get_next_task() + + + +class ThingPlayer(ThingAnimate): + type_ = 'player' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.nickname = 'undefined' + diff --git a/new2/rogue_chat.html b/new2/rogue_chat.html new file mode 100644 index 0000000..6fec908 --- /dev/null +++ b/new2/rogue_chat.html @@ -0,0 +1,183 @@ +<!DOCTYPE HTML> +<html> +<style> +canvas { border: 1px solid black; } +</style> +<body> +<canvas id="terminal" /> +<script> +"use strict"; +let websocket_location = "ws://localhost:8000" + +let terminal = { + rows: 24, + cols: 80, + charHeight: 24, + initialize: function() { + this.ctx = document.getElementById("terminal").getContext("2d"), + this.set_font(); + this.charWidth = this.ctx.measureText("M").width; + this.ctx.canvas.height = this.charHeight * this.rows; + this.ctx.canvas.width = this.charWidth * this.cols; + this.set_font(); // ctx.font gets reset to default on canvas size change, so we have to re-set our own + this.ctx.textBaseline = "top"; + }, + set_font: function(type='normal') { + this.ctx.font = type + ' ' + this.charHeight + 'px monospace'; + }, + write: function(start_y, start_x, msg, foreground_color='black') { + this.ctx.fillStyle = foreground_color; + this.ctx.fillRect(start_x*this.charWidth, start_y*this.charHeight, + this.charWidth*msg.length, this.charHeight); + if (foreground_color === 'black') { + this.ctx.fillStyle = 'white'; + } else { + this.ctx.fillStyle = 'black'; + } + this.ctx.fillText(msg, start_x*this.charWidth, start_y*this.charHeight); + }, + drawBox: function (start_y, start_x, height, width, color='white') { + this.ctx.fillStyle = color; + this.ctx.fillRect(start_x*this.charWidth, start_y*this.charHeight, + this.charWidth*width, this.charHeight*height); + } +} + +let parser = { + tokenize: function(str) { + let tokens = []; + let token = '' + let quoted = false; + let escaped = false; + for (let i = 0; i < str.length; i++) { + let c = str[i]; + if (quoted) { + if (escaped) { + token += c; + escaped = false; + } else if (c == '\\') { + escaped = true; + } else if (c == '"') { + quoted = false + } else { + token += c; + } + } else if (c == '"') { + quoted = true + } else if (c === ' ') { + if (token.length > 0) { + tokens.push(token); + token = ''; + } + } else { + token += c; + } + } + if (token.length > 0) { + tokens.push(token); + } + return tokens; + }, + parse_position(position_string) { + let coordinate_strings = position_string.split(',') + let position = [0, 0]; + position[0] = coordinate_strings[0].slice(2); + position[1] = coordinate_strings[1].slice(2); + return position; + } +} + +let tui = { + log_msg: function(msg) { + chat.history.unshift(msg); + if (chat.history.length > terminal.rows - 2) { + chat.history.pop(); + } + terminal.drawBox(1, terminal.cols / 2, terminal.rows - 2, terminal.cols); + let i = 0; + for (let line of chat.history) { + terminal.write(terminal.rows - 2 - i, terminal.cols / 2, line); + i += 1; + // if (i > terminal.rows - 3) { + // break; + // } + } + }, + draw_map: function() { + terminal.drawBox(0, 0, terminal.rows, terminal.cols / 2); + for (const t in game.things) { + terminal.write(game.things[t][0], game.things[t][1], '@'); + } + }, + draw_tick_line: function(n) { + terminal.drawBox(0, 0, terminal.rows, terminal.cols / 2); + terminal.write(0, terminal.cols / 2, 'tick: ' + game.tick); + }, + draw_input_line: function() { + terminal.drawBox(terminal.rows - 1, terminal.cols / 2, 1, terminal.cols / 2, 'black'); + terminal.write(terminal.rows - 1, terminal.cols / 2, chat.input_line); + } +} + +let game = { + things: {}, + tick: 0 +} + +let chat = { + input_line: "", + history: [] +} + +terminal.initialize() +terminal.drawBox(terminal.rows - 1, terminal.cols / 2, 1, terminal.cols, 'black'); + +document.addEventListener('keydown', (event) => { + if (chat.input_line === '') { + terminal.drawBox(terminal.rows - 1, terminal.cols / 2, 1, terminal.rows, 'black'); + } + if (event.key && event.key.length === 1) { + chat.input_line += event.key; + tui.draw_input_line(); + } else if (event.key === 'Backspace') { + chat.input_line = chat.input_line.slice(0, -1); + tui.draw_input_line(); + } else if (event.key === 'Enter') { + websocket.send(chat.input_line); + chat.input_line = '' + tui.draw_input_line(); + } else if (event.key === 'ArrowLeft') { + websocket.send('TASK:MOVE LEFT'); + } else if (event.key === 'ArrowRight') { + websocket.send('TASK:MOVE RIGHT'); + } else if (event.key === 'ArrowUp') { + websocket.send('TASK:MOVE UP'); + } else if (event.key === 'ArrowDown') { + websocket.send('TASK:MOVE DOWN'); + }; + console.log(event.key); +}, false); + +let websocket = new WebSocket(websocket_location); +websocket.onmessage = function (event) { + let tokens = parser.tokenize(event.data); + if (tokens[0] === 'TURN') { + game.things = {} + game.tick = parseInt(tokens[1]); + tui.draw_tick_line(); + } else if (tokens[0] === 'THING_POS') { + game.things[tokens[1]] = parser.parse_position(tokens[2]); + tui.draw_map(); + } else if (tokens[0] === 'LOG') { + tui.log_msg(' ' + tokens[1]); + } else if (tokens[0] === 'ARGUMENT_ERROR') { + tui.log_msg('syntax error: ' + tokens[1]); + } else if (tokens[0] === 'GAME_ERROR') { + tui.log_msg('game error: ' + tokens[1]); + } else { + tui.log_msg('unhandled input: ' + event.data); + } +} +</script> +</body> +</html> diff --git a/new2/rogue_chat.py b/new2/rogue_chat.py new file mode 100755 index 0000000..415b8c1 --- /dev/null +++ b/new2/rogue_chat.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +from plomrogue.game import Game +from plomrogue.io_websocket import PlomWebSocketServer +game = Game() +game.io.run_loop_with_server(8000, PlomWebSocketServer) -- 2.30.2