home · contact · privacy
Add basic multiplayer roguelike chat example.
authorChristian Heller <c.heller@plomlompom.de>
Sat, 24 Oct 2020 23:35:20 +0000 (01:35 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Sat, 24 Oct 2020 23:35:20 +0000 (01:35 +0200)
new2/plomrogue/commands.py [new file with mode: 0644]
new2/plomrogue/errors.py [new file with mode: 0644]
new2/plomrogue/game.py [new file with mode: 0755]
new2/plomrogue/mapping.py [new file with mode: 0644]
new2/plomrogue/misc.py [new file with mode: 0644]
new2/plomrogue/parser.py [new file with mode: 0644]
new2/plomrogue/tasks.py [new file with mode: 0644]
new2/plomrogue/things.py [new file with mode: 0644]
new2/rogue_chat.html [new file with mode: 0644]
new2/rogue_chat.py [new file with mode: 0755]

diff --git a/new2/plomrogue/commands.py b/new2/plomrogue/commands.py
new file mode 100644 (file)
index 0000000..a68efc2
--- /dev/null
@@ -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 (file)
index 0000000..bc37495
--- /dev/null
@@ -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 (executable)
index 0000000..e0c0715
--- /dev/null
@@ -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 (file)
index 0000000..1847f69
--- /dev/null
@@ -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 (file)
index 0000000..a3f7298
--- /dev/null
@@ -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 (file)
index 0000000..a56b5d1
--- /dev/null
@@ -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 (file)
index 0000000..402dfcb
--- /dev/null
@@ -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 (file)
index 0000000..138a1fa
--- /dev/null
@@ -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 (file)
index 0000000..6fec908
--- /dev/null
@@ -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 (executable)
index 0000000..415b8c1
--- /dev/null
@@ -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)