From 540aec0e9bf55d0452cffda4b34e1995d3724abf Mon Sep 17 00:00:00 2001 From: Christian Heller <c.heller@plomlompom.de> Date: Tue, 10 Nov 2020 00:40:35 +0100 Subject: [PATCH] Initial commit. --- config.json | 19 + plomrogue/commands.py | 95 ++++ plomrogue/errors.py | 14 + plomrogue/game.py | 203 +++++++ plomrogue/io.py | 100 ++++ plomrogue/io_tcp.py | 201 +++++++ plomrogue/io_websocket.py | 46 ++ plomrogue/mapping.py | 136 +++++ plomrogue/misc.py | 10 + plomrogue/parser.py | 128 +++++ plomrogue/tasks.py | 71 +++ plomrogue/things.py | 78 +++ requirements.txt | 2 + rogue_chat.py | 34 ++ rogue_chat_curses.py | 613 +++++++++++++++++++++ rogue_chat_nocanvas_monochrome.html | 809 ++++++++++++++++++++++++++++ 16 files changed, 2559 insertions(+) create mode 100644 config.json create mode 100644 plomrogue/commands.py create mode 100644 plomrogue/errors.py create mode 100755 plomrogue/game.py create mode 100644 plomrogue/io.py create mode 100644 plomrogue/io_tcp.py create mode 100644 plomrogue/io_websocket.py create mode 100644 plomrogue/mapping.py create mode 100644 plomrogue/misc.py create mode 100644 plomrogue/parser.py create mode 100644 plomrogue/tasks.py create mode 100644 plomrogue/things.py create mode 100644 requirements.txt create mode 100755 rogue_chat.py create mode 100755 rogue_chat_curses.py create mode 100644 rogue_chat_nocanvas_monochrome.html diff --git a/config.json b/config.json new file mode 100644 index 0000000..c594191 --- /dev/null +++ b/config.json @@ -0,0 +1,19 @@ +{ + "switch_to_chat": "t", + "switch_to_play": "p", + "switch_to_annotate": "m", + "switch_to_portal": "P", + "switch_to_study": "?", + "switch_to_edit": "m", + "flatten": "F", + "hex_move_upleft": "w", + "hex_move_upright": "e", + "hex_move_right": "d", + "hex_move_downright": "x", + "hex_move_downleft": "y", + "hex_move_left": "a", + "square_move_up": "w", + "square_move_left": "a", + "square_move_down": "s", + "square_move_right": "d" +} diff --git a/plomrogue/commands.py b/plomrogue/commands.py new file mode 100644 index 0000000..a80731d --- /dev/null +++ b/plomrogue/commands.py @@ -0,0 +1,95 @@ +from plomrogue.misc import quote +from plomrogue.errors import GameError +from plomrogue.mapping import YX, MapGeometrySquare, MapGeometryHex + + + +def cmd_ALL(game, msg, connection_id): + if not connection_id in game.sessions: + raise GameError('need to be logged in for this') + t = game.get_thing(game.sessions[connection_id], False) + game.io.send('CHAT ' + quote(t.nickname + ': ' + msg)) +cmd_ALL.argtypes = 'string' + +# TOOD split into two commands +def cmd_LOGIN(game, nick, connection_id): + for t in [t for t in game.things if t.type_ == 'player' and t.nickname == nick]: + raise GameError('name already in use') + if connection_id in game.sessions: + t_id = game.sessions[connection_id] + t = game.get_thing(t_id, False) + old_nick = t.nickname + t.nickname = nick + game.io.send('CHAT ' + quote(old_nick + ' renamed themselves to ' + nick)) + else: + t = game.thing_types['player'](game) + t.position = YX(game.map.size.y // 2, game.map.size.x // 2) + game.things += [t] # TODO refactor into Thing.__init__? + game.sessions[connection_id] = t.id_ + game.io.send('LOGIN_OK', connection_id) + t.nickname = nick + game.io.send('CHAT ' + quote(t.nickname + ' entered the map.')) + game.io.send('PLAYER_ID %s' % t.id_, connection_id) + game.changed = True +cmd_LOGIN.argtypes = 'string' + +def cmd_GET_GAMESTATE(game, connection_id): + game.send_gamestate(connection_id) +cmd_GET_GAMESTATE.argtypes = '' + +def cmd_QUERY(game, target_nick, msg, connection_id): + if not connection_id in game.sessions: + raise GameError('can only query when logged in') + 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('CHAT ' + quote(source_nick+ '->' + target_nick + ': ' + msg), c_id) + game.io.send('CHAT ' + quote(source_nick+ '->' + target_nick + ': ' + msg), connection_id) + return + raise GameError('target user offline') + raise GameError('can only query with registered nicknames') +cmd_QUERY.argtypes = 'string string' + +def cmd_PING(game, connection_id): + game.io.send('PONG', connection_id) +cmd_PING.argtypes = '' + +def cmd_TURN(game, n): + game.turn = n +cmd_TURN.argtypes = 'int:nonneg' + +def cmd_ANNOTATE(game, yx, msg, connection_id): + if msg == ' ': + if yx in game.annotations: + del game.annotations[yx] + else: + game.annotations[yx] = msg + game.changed = True +cmd_ANNOTATE.argtypes = 'yx_tuple:nonneg string' + +def cmd_PORTAL(game, yx, msg, connection_id): + if msg == ' ': + if yx in game.portals: + del game.portals[yx] + else: + game.portals[yx] = msg + game.changed = True +cmd_PORTAL.argtypes = 'yx_tuple:nonneg string' + +def cmd_GET_ANNOTATION(game, yx, connection_id): + annotation = '(none)'; + if yx in game.annotations: + annotation = game.annotations[yx] + game.io.send('ANNOTATION %s %s' % (yx, quote(annotation))) +cmd_GET_ANNOTATION.argtypes = 'yx_tuple:nonneg' + +def cmd_MAP_LINE(game, y, line): + game.map.set_line(y, line) +cmd_MAP_LINE.argtypes = 'int:nonneg string' + +def cmd_MAP(game, geometry, size): + map_geometry_class = globals()['MapGeometry' + geometry] + game.new_world(map_geometry_class(size)) +cmd_MAP.argtypes = 'string:map_geometry yx_tuple:pos' diff --git a/plomrogue/errors.py b/plomrogue/errors.py new file mode 100644 index 0000000..b98f8f6 --- /dev/null +++ b/plomrogue/errors.py @@ -0,0 +1,14 @@ +class ArgError(Exception): + pass + + +class GameError(Exception): + pass + + +class PlayError(Exception): + pass + + +class BrokenSocketConnection(Exception): + pass diff --git a/plomrogue/game.py b/plomrogue/game.py new file mode 100755 index 0000000..68bcb65 --- /dev/null +++ b/plomrogue/game.py @@ -0,0 +1,203 @@ +from plomrogue.tasks import (Task_WAIT, Task_MOVE, Task_WRITE, + Task_FLATTEN_SURROUNDINGS) +from plomrogue.errors import GameError, PlayError +from plomrogue.io import GameIO +from plomrogue.misc import quote +from plomrogue.things import Thing, ThingPlayer +from plomrogue.mapping import YX, MapGeometrySquare, Map + + + +class GameBase: + + def __init__(self): + self.turn = 0 + self.things = [] + self.map_geometry = MapGeometrySquare(YX(24, 40)) + self.commands = {} + + 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 + + def register_command(self, command): + prefix = 'cmd_' + if not command.__name__.startswith(prefix): + raise GameError('illegal command object name: %s' % command.__name__) + command_name = command.__name__[len(prefix):] + self.commands[command_name] = command + + + +import os +class Game(GameBase): + + def __init__(self, save_file, *args, **kwargs): + super().__init__(*args, **kwargs) + self.changed = True + self.io = GameIO(self, save_file) + self.tasks = {} + self.thing_type = Thing + self.thing_types = {'player': ThingPlayer} + self.sessions = {} + self.map = Map(self.map_geometry.size) + self.annotations = {} + self.portals = {} + if os.path.exists(self.io.save_file): + if not os.path.isfile(self.io.save_file): + raise GameError('save file path refers to non-file') + + def register_task(self, task): + prefix = 'Task_' + if not task.__name__.startswith(prefix): + raise GameError('illegal task object name: %s' % task.__name__) + task_name = task.__name__[len(prefix):] + self.tasks[task_name] = task + + def read_savefile(self): + if os.path.exists(self.io.save_file): + with open(self.io.save_file, 'r') as f: + lines = f.readlines() + for i in range(len(lines)): + line = lines[i] + print("FILE INPUT LINE %5s: %s" % (i, line), end='') + self.io.handle_input(line, god_mode=True) + + def get_string_options(self, string_option_type): + import string + if string_option_type == 'direction': + return self.map_geometry.get_directions() + elif string_option_type == 'char': + return [c for c in + string.digits + string.ascii_letters + string.punctuation + ' '] + elif string_option_type == 'map_geometry': + return ['Hex', 'Square'] + return None + + def get_map_geometry_shape(self): + return self.map_geometry.__class__.__name__[len('MapGeometry'):] + + 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)) + if hasattr(thing, 'nickname'): + self.io.send('THING_NAME %s %s' % (thing.id_, quote(t.nickname))) + + self.io.send('TURN ' + str(self.turn)) + for t in self.things: + send_thing(t) + self.io.send('MAP %s %s %s' % (self.get_map_geometry_shape(), + self.map_geometry.size, quote(self.map.terrain))) + for yx in self.portals: + self.io.send('PORTAL %s %s' % (yx, quote(self.portals[yx]))) + self.io.send('GAME_STATE_COMPLETE') + + def run_tick(self): + to_delete = [] + for connection_id in self.sessions: + connection_id_found = False + for server in self.io.servers: + if connection_id in server.clients: + connection_id_found = True + break + if not connection_id_found: + t = self.get_thing(self.sessions[connection_id], create_unfound=False) + if hasattr(t, 'nickname'): + self.io.send('CHAT ' + quote(t.nickname + ' left the map.')) + 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) + except PlayError as e: + for connection_id in [c_id for c_id in self.sessions + if self.sessions[c_id] == t.id_]: + self.io.send('PLAY_ERROR ' + quote(str(e)), connection_id) + if self.changed: + self.turn += 1 + self.send_gamestate() + self.changed = False + self.save() + + 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 + + def save(self): + + def write(f, msg): + f.write(msg + '\n') + + with open(self.io.save_file, 'w') as f: + # TODO: save tasks + write(f, 'TURN %s' % self.turn) + map_geometry_shape = self.get_map_geometry_shape() + write(f, 'MAP %s %s' % (map_geometry_shape, self.map_geometry.size,)) + for y, line in self.map.lines(): + write(f, 'MAP_LINE %5s %s' % (y, quote(line))) + for yx in self.annotations: + write(f, 'ANNOTATE %s %s' % (yx, quote(self.annotations[yx]))) + for yx in self.portals: + write(f, 'PORTAL %s %s' % (yx, quote(self.portals[yx]))) + + def new_world(self, map_geometry): + self.map_geometry = map_geometry + self.map = Map(self.map_geometry.size) + self.annotations = {} diff --git a/plomrogue/io.py b/plomrogue/io.py new file mode 100644 index 0000000..8e0f9bf --- /dev/null +++ b/plomrogue/io.py @@ -0,0 +1,100 @@ +import queue +import threading +import inspect + + + +class GameIO(): + + def __init__(self, game, save_file='savefile'): + from plomrogue.parser import Parser + self.parser = Parser(game) + self.game = game + self.save_file = save_file + self.servers = [] + + def loop(self, q): + """Handle commands coming through queue q, run game, send results back.""" + while True: + try: + command, connection_id = q.get(timeout=0.001) + self.handle_input(connection_id, command) + except queue.Empty: + self.game.run_tick() + + def start_loop(self): + """Start game loop, set up self.queue to communicate with it. + + The game loop works sequentially through game commands received + via self.queue from connected servers' clients.""" + self.queue = queue.Queue() + c = threading.Thread(target=self.loop, args=(self.queue,)) + c.start() + + def start_server(self, port, server_class, certfile=None, keyfile=None): + """Start server of server_class in talk with game loop. + + The server communicates with the game loop via self.queue. + """ + if 'certfile' in list(inspect.signature(server_class.__init__).parameters): + server = server_class(self.queue, port, certfile=certfile, keyfile=keyfile) + else: + server = server_class(self.queue, port) + self.servers += [server] + c = threading.Thread(target=server.serve_forever) + c.start() + + def handle_input(self, input_, connection_id=None, god_mode=False): + """Process input_ to command grammar, call command handler if found. + + Command handlers that have no connectin_i argument in their + signature will only be called if god_mode is set. + + """ + from plomrogue.errors import GameError, ArgError, PlayError + from plomrogue.misc import quote + + def answer(connection_id, msg): + if connection_id: + self.send(msg, connection_id) + else: + print(msg) + + try: + command, args = self.parser.parse(input_) + if command is None: + answer(connection_id, 'UNHANDLED_INPUT') + else: + if 'connection_id' in list(inspect.signature(command).parameters): + command(*args, connection_id=connection_id) + elif god_mode: + command(*args) + #if store and not hasattr(command, 'dont_save'): + # with open(self.game_file_name, 'a') as f: + # f.write(input_ + '\n') + except ArgError as e: + answer(connection_id, 'ARGUMENT_ERROR ' + quote(str(e))) + except PlayError as e: + answer(connection_id, 'PLAY_ERROR ' + quote(str(e))) + except GameError as e: + answer(connection_id, 'GAME_ERROR ' + quote(str(e))) + + def send(self, msg, connection_id=None): + """Send message msg to servers' client(s). + + If a specific client is identified by connection_id, only + sends msg to that one. Else, sends it to all client sessions. + + """ + if connection_id: + for server in self.servers: + if connection_id in server.clients: + client = server.clients[connection_id] + client.put(msg) + else: + for c_id in self.game.sessions: + for server in self.servers: + if c_id in server.clients: + client = server.clients[c_id] + client.put(msg) + break diff --git a/plomrogue/io_tcp.py b/plomrogue/io_tcp.py new file mode 100644 index 0000000..09b9db1 --- /dev/null +++ b/plomrogue/io_tcp.py @@ -0,0 +1,201 @@ +import socketserver + + +# Avoid "Address already in use" errors. +socketserver.TCPServer.allow_reuse_address = True + + + +from plomrogue.errors import BrokenSocketConnection +class PlomSocket: + + def __init__(self, socket): + self.socket = socket + + def send(self, message, silent_connection_break=False): + """Send via self.socket, encoded/delimited as way recv() expects. + + In detail, all \ and $ in message are escaped with prefixed \, + and an unescaped $ is appended as a message delimiter. Then, + socket.send() is called as often as necessary to ensure + message is sent fully, as socket.send() due to buffering may + not send all of it right away. + + Assuming socket is blocking, it's rather improbable that + socket.send() will be partial / return a positive value less + than the (byte) length of msg â but not entirely out of the + question. See: - <http://stackoverflow.com/q/19697218> - + <http://stackoverflow.com/q/2618736> - + <http://stackoverflow.com/q/8900474> + + This also handles a socket.send() return value of 0, which + might be possible or not (?) for blocking sockets: - + <http://stackoverflow.com/q/34919846> + + """ + escaped_message = '' + for char in message: + if char in ('\\', '$'): + escaped_message += '\\' + escaped_message += char + escaped_message += '$' + data = escaped_message.encode() + totalsent = 0 + while totalsent < len(data): + socket_broken = False + try: + sent = self.socket.send(data[totalsent:]) + socket_broken = sent == 0 + totalsent = totalsent + sent + except OSError as err: + if err.errno == 9: # "Bad file descriptor", when connection broken + socket_broken = True + else: + raise err + if socket_broken and not silent_connection_break: + raise BrokenSocketConnection + + def recv(self): + """Get full send()-prepared message from self.socket. + + In detail, socket.recv() is looped over for sequences of bytes + that can be decoded as a Unicode string delimited by an + unescaped $, with \ and $ escapable by \. If a sequence of + characters that ends in an unescaped $ cannot be decoded as + Unicode, None is returned as its representation. Stop once + socket.recv() returns nothing. + + Under the hood, the TCP stack receives packets that construct + the input payload in an internal buffer; socket.recv(BUFSIZE) + pops up to BUFSIZE bytes from that buffer, without knowledge + either about the input's segmentation into packets, or whether + the input is segmented in any other meaningful way; that's why + we do our own message segmentation with $ as a delimiter. + + """ + esc = False + data = b'' + msg = b'' + while True: + try: + data = self.socket.recv(1024) + except OSError as err: + if err.errno == 9: # "Bad file descriptor", when connection broken + raise BrokenSocketConnection + if 0 == len(data): + break + for c in data: + if esc: + msg += bytes([c]) + esc = False + elif chr(c) == '\\': + esc = True + elif chr(c) == '$': + try: + yield msg.decode() + except UnicodeDecodeError: + yield None + msg = b'' + else: + msg += bytes([c]) + + + +class PlomSocketSSL(PlomSocket): + + def __init__(self, *args, certfile, keyfile, **kwargs): + import ssl + super().__init__(*args, **kwargs) + self.send('NEED_SSL') + self.socket = ssl.wrap_socket(self.socket, server_side=True, + certfile=certfile, keyfile=keyfile) + + + +class IO_Handler(socketserver.BaseRequestHandler): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def handle(self): + """Move messages between network socket and game IO loop via queues. + + On start (a new connection from client to server), sets up a + new queue, sends it via self.server.queue_out to the game IO + loop thread, and from then on receives messages to send back + from the game IO loop via that new queue. + + At the same time, loops over socket's recv to get messages + from the outside into the game IO loop by way of + self.server.queue_out into the game IO. Ends connection once a + 'QUIT' message is received from socket, and then also calls + for a kill of its own queue. + + """ + + def send_queue_messages(plom_socket, queue_in, thread_alive): + """Send messages via socket from queue_in while thread_alive[0].""" + while thread_alive[0]: + try: + msg = queue_in.get(timeout=1) + except queue.Empty: + continue + plom_socket.send(msg, True) + + import uuid + import queue + import threading + if self.server.socket_class == PlomSocketSSL: + plom_socket = self.server.socket_class(self.request, + certfile=self.server.certfile, + keyfile=self.server.keyfile) + else: + plom_socket = self.server.socket_class(self.request) + print('CONNECTION FROM:', str(self.client_address)) + connection_id = uuid.uuid4() + queue_in = queue.Queue() + self.server.clients[connection_id] = queue_in + thread_alive = [True] + t = threading.Thread(target=send_queue_messages, + args=(plom_socket, queue_in, thread_alive)) + t.start() + for message in plom_socket.recv(): + if message is None: + plom_socket.send('BAD MESSAGE', True) + elif 'QUIT' == message: + plom_socket.send('BYE', True) + break + else: + self.server.queue_out.put((connection_id, message)) + del self.server.clients[connection_id] + thread_alive[0] = False + print('CONNECTION CLOSED FROM:', str(self.client_address)) + plom_socket.socket.close() + + + +class PlomTCPServer(socketserver.ThreadingTCPServer): + """Bind together threaded IO handling server and message queue. + + By default this only serves to localhost connections. For remote + connections, consider using PlomTCPServerSSL for more security, + which defaults to serving all connections. + + """ + + def __init__(self, queue, port, host='127.0.0.1', *args, **kwargs): + super().__init__((host, port), IO_Handler, *args, **kwargs) + self.socket_class = PlomSocket + self.queue_out = queue + self.daemon_threads = True # Else, server's threads have daemon=False. + self.clients = {} + + + +class PlomTCPServerSSL(PlomTCPServer): + + def __init__(self, *args, certfile, keyfile, **kwargs): + super().__init__(*args, host='0.0.0.0', **kwargs) + self.certfile = certfile + self.keyfile = keyfile + self.socket_class = PlomSocketSSL diff --git a/plomrogue/io_websocket.py b/plomrogue/io_websocket.py new file mode 100644 index 0000000..0a74f4e --- /dev/null +++ b/plomrogue/io_websocket.py @@ -0,0 +1,46 @@ +from SimpleWebSocketServer import SimpleWebSocketServer, WebSocket + + + +class PlomWebSocket(WebSocket): + + def handleMessage(self): + if self.data == 'QUIT': + self.sendMessage('BYE') + self.close() + else: + for connection_id in self.server.clients: + if self.server.clients[connection_id] == self: + self.server.queue.put((connection_id, self.data)) + break + + def handleConnected(self): + import uuid + print('CONNECTION FROM:', self.address) + connection_id = uuid.uuid4() + self.server.clients[connection_id] = self + + def handleClose(self): + print('CONNECTION CLOSED FROM:', self.address) + for connection_id in self.server.clients: + if self.server.clients[connection_id] == self: + del self.server.clients[connection_id] + + def put(self, msg): + self.sendMessage(msg) + + + +class PlomWebSocketServer(SimpleWebSocketServer): + + def __init__(self, queue, port, *args, **kwargs): + super().__init__('', port, PlomWebSocket) + self.queue = queue + self.clients = {} + + def serve_forever(self): + self.serveforever() + + def server_close(self): + self.close() + diff --git a/plomrogue/mapping.py b/plomrogue/mapping.py new file mode 100644 index 0000000..e0a59d8 --- /dev/null +++ b/plomrogue/mapping.py @@ -0,0 +1,136 @@ +import collections +from plomrogue.errors import ArgError + + + +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) + + + +class MapGeometry(): + + def __init__(self, size): + self.size = size + + def get_directions(self): + directions = [] + for name in dir(self): + if name[:5] == 'move_': + directions += [name[5:]] + return directions + + def get_neighbors(self, pos): + neighbors = {} + for direction in self.get_directions(): + neighbors[direction] = self.move(pos, direction) + return neighbors + + def move(self, start_pos, direction): + mover = getattr(self, 'move_' + direction) + target = mover(start_pos) + if target.y < 0 or target.x < 0 or \ + target.y >= self.size.y or target.x >= self.size.x: + return None + return target + + + +class MapGeometryWithLeftRightMoves(MapGeometry): + + def move_LEFT(self, start_pos): + return YX(start_pos.y, start_pos.x - 1) + + def move_RIGHT(self, start_pos): + return YX(start_pos.y, start_pos.x + 1) + + + +class MapGeometrySquare(MapGeometryWithLeftRightMoves): + + def move_UP(self, start_pos): + return YX(start_pos.y - 1, start_pos.x) + + def move_DOWN(self, start_pos): + return YX(start_pos.y + 1, start_pos.x) + + + +class MapGeometryHex(MapGeometryWithLeftRightMoves): + + def move_UPLEFT(self, start_pos): + start_indented = start_pos.y % 2 + if start_indented: + return YX(start_pos.y - 1, start_pos.x) + else: + return YX(start_pos.y - 1, start_pos.x - 1) + + def move_UPRIGHT(self, start_pos): + start_indented = start_pos.y % 2 + if start_indented: + return YX(start_pos.y - 1, start_pos.x + 1) + else: + return YX(start_pos.y - 1, start_pos.x) + + def move_DOWNLEFT(self, start_pos): + start_indented = start_pos.y % 2 + if start_indented: + return YX(start_pos.y + 1, start_pos.x) + else: + return YX(start_pos.y + 1, start_pos.x - 1) + + def move_DOWNRIGHT(self, start_pos): + start_indented = start_pos.y % 2 + if start_indented: + return YX(start_pos.y + 1, start_pos.x + 1) + else: + return YX(start_pos.y + 1, start_pos.x) + + + +class Map(): + + def __init__(self, map_size): + self.size = map_size + self.terrain = '.' * self.size_i + + def __getitem__(self, yx): + return self.terrain[self.get_position_index(yx)] + + def __setitem__(self, yx, c): + pos_i = self.get_position_index(yx) + if type(c) == str: + self.terrain = self.terrain[:pos_i] + c + self.terrain[pos_i + 1:] + else: + self.terrain[pos_i] = c + + @property + def size_i(self): + return self.size.y * self.size.x + + def set_line(self, y, line): + height_map = self.size.y + width_map = self.size.x + if y >= height_map: + raise ArgError('too large row number %s' % y) + width_line = len(line) + if width_line != width_map: + raise ArgError('map line width %s unequal map width %s' % (width_line, width_map)) + self.terrain = self.terrain[:y * width_map] + line +\ + self.terrain[(y + 1) * width_map:] + + def get_position_index(self, yx): + return yx.y * self.size.x + yx.x + + def lines(self): + width = self.size.x + for y in range(self.size.y): + yield (y, self.terrain[y * width:(y + 1) * width]) diff --git a/plomrogue/misc.py b/plomrogue/misc.py new file mode 100644 index 0000000..a3f7298 --- /dev/null +++ b/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/plomrogue/parser.py b/plomrogue/parser.py new file mode 100644 index 0000000..5782d69 --- /dev/null +++ b/plomrogue/parser.py @@ -0,0 +1,128 @@ +import unittest +from plomrogue.errors import ArgError +from plomrogue.mapping import YX + + +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_yx_tuple(self, yx_string, range_=None): + """Parse yx_string as yx_tuple, return result. + + The range_ argument may be 'nonneg' (non-negative, including + 0) or 'pos' (positive, excluding 0). + + """ + + def get_axis_position_from_argument(axis, token): + if len(token) < 3 or token[:2] != axis + ':' or \ + not (token[2:].isdigit() or token[2] == '-'): + raise ArgError('Non-int arg for ' + axis + ' position.') + n = int(token[2:]) + if n < 1 and range_ == 'pos': + raise ArgError('Arg for ' + axis + ' position < 1.') + elif n < 0 and range_ == 'nonneg': + raise ArgError('Arg for ' + axis + ' position < 0.') + return n + + tokens = yx_string.split(',') + if len(tokens) != 2: + raise ArgError('Wrong number of yx-tuple arguments.') + y = get_axis_position_from_argument('Y', tokens[0]) + x = get_axis_position_from_argument('X', tokens[1]) + return YX(y, x) + + 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 == 'int:nonneg': + if not arg.isdigit(): + raise ArgError('Argument must be non-negative integer.') + args += [int(arg)] + elif tmpl == 'yx_tuple:nonneg': + args += [self.parse_yx_tuple(arg, 'nonneg')] + elif tmpl == 'yx_tuple:pos': + args += [self.parse_yx_tuple(arg, 'pos')] + elif 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/plomrogue/tasks.py b/plomrogue/tasks.py new file mode 100644 index 0000000..99bb41f --- /dev/null +++ b/plomrogue/tasks.py @@ -0,0 +1,71 @@ +from plomrogue.errors import PlayError +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): + todo = 1 + argtypes = 'string:direction' + + def get_move_target(self): + return self.thing.game.map_geometry.move(self.thing.position, + self.args[0]) + + def check(self): + test_pos = self.get_move_target() + if test_pos is None: + raise PlayError('would move out of map') + elif test_pos in [t.position for t in self.thing.game.things]: + raise PlayError('would collide with other things') + elif self.thing.game.map[test_pos] != '.': + raise PlayError('would move into illegal territory') + + def do(self): + self.thing.position = self.get_move_target() + + + +class Task_WRITE(Task): + todo = 1 + argtypes = 'string:char' + + def check(self): + pass + + def do(self): + self.thing.game.map[self.thing.position] = self.args[0] + + + +class Task_FLATTEN_SURROUNDINGS(Task): + todo = 10 + + def check(self): + pass + + def do(self): + self.thing.game.map[self.thing.position] = '.' + for yx in self.thing.game.map_geometry.get_neighbors(self.thing.position).values(): + if yx is not None: + self.thing.game.map[yx] = '.' diff --git a/plomrogue/things.py b/plomrogue/things.py new file mode 100644 index 0000000..0e4af34 --- /dev/null +++ b/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/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7d7d093 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +SimpleWebSocketServer +ws4py diff --git a/rogue_chat.py b/rogue_chat.py new file mode 100755 index 0000000..026fcdf --- /dev/null +++ b/rogue_chat.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +from plomrogue.game import Game +from plomrogue.io_websocket import PlomWebSocketServer +from plomrogue.io_tcp import PlomTCPServer +from plomrogue.commands import (cmd_ALL, cmd_LOGIN, cmd_QUERY, cmd_PING, cmd_MAP, + cmd_TURN, cmd_MAP_LINE, cmd_GET_ANNOTATION, + cmd_ANNOTATE, cmd_PORTAL, cmd_GET_GAMESTATE) +from plomrogue.tasks import (Task_WAIT, Task_MOVE, Task_WRITE, + Task_FLATTEN_SURROUNDINGS) +import sys + +if len(sys.argv) != 2: + print('wrong number of arguments, expected one (save file)') + exit(1) +savefile = sys.argv[1] +game = Game(savefile) +game.register_command(cmd_PING) +game.register_command(cmd_LOGIN) +game.register_command(cmd_QUERY) +game.register_command(cmd_TURN) +game.register_command(cmd_MAP) +game.register_command(cmd_MAP_LINE) +game.register_command(cmd_GET_ANNOTATION) +game.register_command(cmd_ANNOTATE) +game.register_command(cmd_PORTAL) +game.register_command(cmd_GET_GAMESTATE) +game.register_task(Task_WAIT) +game.register_task(Task_MOVE) +game.register_task(Task_WRITE) +game.register_task(Task_FLATTEN_SURROUNDINGS) +game.read_savefile() +game.io.start_loop() +game.io.start_server(8000, PlomWebSocketServer) +game.io.start_server(5000, PlomTCPServer) diff --git a/rogue_chat_curses.py b/rogue_chat_curses.py new file mode 100755 index 0000000..a6d1ca7 --- /dev/null +++ b/rogue_chat_curses.py @@ -0,0 +1,613 @@ +#!/usr/bin/env python3 +import curses +import queue +import threading +from plomrogue.game import GameBase +from plomrogue.parser import Parser +from plomrogue.mapping import YX, MapGeometrySquare, MapGeometryHex +from plomrogue.things import ThingBase +from plomrogue.misc import quote +from plomrogue.errors import BrokenSocketConnection + +from ws4py.client import WebSocketBaseClient +class WebSocketClient(WebSocketBaseClient): + + def __init__(self, recv_handler, *args, **kwargs): + super().__init__(*args, **kwargs) + self.recv_handler = recv_handler + self.connect() + + def received_message(self, message): + if message.is_text: + message = str(message) + self.recv_handler(message) + + @property + def plom_closed(self): + return self.client_terminated + +from plomrogue.io_tcp import PlomSocket +class PlomSocketClient(PlomSocket): + + def __init__(self, recv_handler, url): + import socket + self.recv_handler = recv_handler + host, port = url.split(':') + super().__init__(socket.create_connection((host, port))) + + def close(self): + self.socket.close() + + def run(self): + import ssl + try: + for msg in self.recv(): + if msg == 'NEED_SSL': + self.socket = ssl.wrap_socket(self.socket) + continue + self.recv_handler(msg) + except BrokenSocketConnection: + pass # we assume socket will be known as dead by now + +def cmd_TURN(game, n): + game.turn = n + game.things = [] + game.portals = {} + game.turn_complete = False +cmd_TURN.argtypes = 'int:nonneg' + +def cmd_LOGIN_OK(game): + game.tui.switch_mode('post_login_wait') + game.tui.send('GET_GAMESTATE') + game.tui.log_msg('@ welcome') +cmd_LOGIN_OK.argtypes = '' + +def cmd_CHAT(game, msg): + game.tui.log_msg('# ' + msg) + game.tui.do_refresh = True +cmd_CHAT.argtypes = 'string' + +def cmd_PLAYER_ID(game, player_id): + game.player_id = player_id +cmd_PLAYER_ID.argtypes = 'int:nonneg' + +def cmd_THING_POS(game, thing_id, position): + t = game.get_thing(thing_id, True) + t.position = position +cmd_THING_POS.argtypes = 'int:nonneg yx_tuple:nonneg' + +def cmd_THING_NAME(game, thing_id, name): + t = game.get_thing(thing_id, True) + t.name = name +cmd_THING_NAME.argtypes = 'int:nonneg string' + +def cmd_MAP(game, geometry, size, content): + map_geometry_class = globals()['MapGeometry' + geometry] + game.map_geometry = map_geometry_class(size) + game.map_content = content + if type(game.map_geometry) == MapGeometrySquare: + game.tui.movement_keys = { + game.tui.keys['square_move_up']: 'UP', + game.tui.keys['square_move_left']: 'LEFT', + game.tui.keys['square_move_down']: 'DOWN', + game.tui.keys['square_move_right']: 'RIGHT', + } + elif type(game.map_geometry) == MapGeometryHex: + game.tui.movement_keys = { + game.tui.keys['hex_move_upleft']: 'UPLEFT', + game.tui.keys['hex_move_upright']: 'UPRIGHT', + game.tui.keys['hex_move_right']: 'RIGHT', + game.tui.keys['hex_move_downright']: 'DOWNRIGHT', + game.tui.keys['hex_move_downleft']: 'DOWNLEFT', + game.tui.keys['hex_move_left']: 'LEFT', + } +cmd_MAP.argtypes = 'string:map_geometry yx_tuple:pos string' + +def cmd_GAME_STATE_COMPLETE(game): + game.info_db = {} + if game.tui.mode.name == 'post_login_wait': + game.tui.switch_mode('play') + game.tui.help() + if game.tui.mode.shows_info: + game.tui.query_info() + player = game.get_thing(game.player_id, False) + if player.position in game.portals: + game.tui.teleport_target_host = game.portals[player.position] + game.tui.switch_mode('teleport') + game.turn_complete = True + game.tui.do_refresh = True +cmd_GAME_STATE_COMPLETE.argtypes = '' + +def cmd_PORTAL(game, position, msg): + game.portals[position] = msg +cmd_PORTAL.argtypes = 'yx_tuple:nonneg string' + +def cmd_PLAY_ERROR(game, msg): + game.tui.flash() + game.tui.do_refresh = True +cmd_PLAY_ERROR.argtypes = 'string' + +def cmd_GAME_ERROR(game, msg): + game.tui.log_msg('? game error: ' + msg) + game.tui.do_refresh = True +cmd_GAME_ERROR.argtypes = 'string' + +def cmd_ARGUMENT_ERROR(game, msg): + game.tui.log_msg('? syntax error: ' + msg) + game.tui.do_refresh = True +cmd_ARGUMENT_ERROR.argtypes = 'string' + +def cmd_ANNOTATION(game, position, msg): + game.info_db[position] = msg + if game.tui.mode.shows_info: + game.tui.do_refresh = True +cmd_ANNOTATION.argtypes = 'yx_tuple:nonneg string' + +def cmd_PONG(game): + pass +cmd_PONG.argtypes = '' + +class Game(GameBase): + thing_type = ThingBase + turn_complete = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.register_command(cmd_LOGIN_OK) + self.register_command(cmd_PONG) + self.register_command(cmd_CHAT) + self.register_command(cmd_PLAYER_ID) + self.register_command(cmd_TURN) + self.register_command(cmd_THING_POS) + self.register_command(cmd_THING_NAME) + self.register_command(cmd_MAP) + self.register_command(cmd_PORTAL) + self.register_command(cmd_ANNOTATION) + self.register_command(cmd_GAME_STATE_COMPLETE) + self.register_command(cmd_ARGUMENT_ERROR) + self.register_command(cmd_GAME_ERROR) + self.register_command(cmd_PLAY_ERROR) + self.map_content = '' + self.player_id = -1 + self.info_db = {} + self.portals = {} + + def get_string_options(self, string_option_type): + if string_option_type == 'map_geometry': + return ['Hex', 'Square'] + return None + + def get_command(self, command_name): + from functools import partial + f = partial(self.commands[command_name], self) + f.argtypes = self.commands[command_name].argtypes + return f + +class TUI: + + class Mode: + + def __init__(self, name, has_input_prompt=False, shows_info=False, + is_intro = False): + self.name = name + self.has_input_prompt = has_input_prompt + self.shows_info = shows_info + self.is_intro = is_intro + + def __init__(self, host): + import os + import json + self.host = host + self.mode_play = self.Mode('play') + self.mode_study = self.Mode('study', shows_info=True) + self.mode_edit = self.Mode('edit') + self.mode_annotate = self.Mode('annotate', has_input_prompt=True, shows_info=True) + self.mode_portal = self.Mode('portal', has_input_prompt=True, shows_info=True) + self.mode_chat = self.Mode('chat', has_input_prompt=True) + self.mode_waiting_for_server = self.Mode('waiting_for_server', is_intro=True) + self.mode_login = self.Mode('login', has_input_prompt=True, is_intro=True) + self.mode_post_login_wait = self.Mode('post_login_wait', is_intro=True) + self.mode_teleport = self.Mode('teleport', has_input_prompt=True) + self.game = Game() + self.game.tui = self + self.parser = Parser(self.game) + self.log = [] + self.do_refresh = True + self.queue = queue.Queue() + self.login_name = None + self.switch_mode('waiting_for_server') + self.keys = { + 'switch_to_chat': 't', + 'switch_to_play': 'p', + 'switch_to_annotate': 'm', + 'switch_to_portal': 'P', + 'switch_to_study': '?', + 'switch_to_edit': 'm', + 'flatten': 'F', + 'hex_move_upleft': 'w', + 'hex_move_upright': 'e', + 'hex_move_right': 'd', + 'hex_move_downright': 'x', + 'hex_move_downleft': 'y', + 'hex_move_left': 'a', + 'square_move_up': 'w', + 'square_move_left': 'a', + 'square_move_down': 's', + 'square_move_right': 'd', + } + if os.path.isfile('config.json'): + with open('config.json', 'r') as f: + keys_conf = json.loads(f.read()) + for k in keys_conf: + self.keys[k] = keys_conf[k] + curses.wrapper(self.loop) + + def flash(self): + curses.flash() + + def send(self, msg): + try: + if hasattr(self.socket, 'plom_closed') and self.socket.plom_closed: + raise BrokenSocketConnection + self.socket.send(msg) + except (BrokenPipeError, BrokenSocketConnection): + self.log_msg('@ server disconnected :(') + self.do_refresh = True + + def log_msg(self, msg): + self.log += [msg] + if len(self.log) > 100: + self.log = self.log[-100:] + + def query_info(self): + self.send('GET_ANNOTATION ' + str(self.explorer)) + + def switch_mode(self, mode_name, keep_position = False): + self.mode = getattr(self, 'mode_' + mode_name) + if self.mode.shows_info and not keep_position: + player = self.game.get_thing(self.game.player_id, False) + self.explorer = YX(player.position.y, player.position.x) + if self.mode.name == 'waiting_for_server': + self.log_msg('@ waiting for server â¦') + elif self.mode.name == 'login': + if self.login_name: + self.send('LOGIN ' + quote(self.login_name)) + else: + self.log_msg('@ enter username') + elif self.mode.name == 'teleport': + self.log_msg("@ May teleport to %s" % (self.teleport_target_host)), + self.log_msg("@ Enter 'YES!' to enthusiastically affirm."); + elif self.mode.name == 'annotate' and self.explorer in self.game.info_db: + info = self.game.info_db[self.explorer] + if info != '(none)': + self.input_ = info + elif self.mode.name == 'portal' and self.explorer in self.game.portals: + self.input_ = self.game.portals[self.explorer] + + def help(self): + self.log_msg("HELP:"); + self.log_msg("chat mode commands:"); + self.log_msg(" /nick NAME - re-name yourself to NAME"); + self.log_msg(" /msg USER TEXT - send TEXT to USER"); + self.log_msg(" /help - show this help"); + self.log_msg(" /%s or /play - switch to play mode" % self.keys['switch_to_play']); + self.log_msg(" /%s or /study - switch to study mode" % self.keys['switch_to_study']); + self.log_msg("commands common to study and play mode:"); + self.log_msg(" %s - move" % ','.join(self.movement_keys)); + self.log_msg(" %s - switch to chat mode" % self.keys['switch_to_chat']); + self.log_msg("commands specific to play mode:"); + self.log_msg(" %s - write following ASCII character" % self.keys['switch_to_edit']); + self.log_msg(" %s - flatten surroundings" % self.keys['flatten']); + self.log_msg(" %s - switch to study mode" % self.keys['switch_to_study']); + self.log_msg("commands specific to study mode:"); + self.log_msg(" %s - annotate terrain" % self.keys['switch_to_annotate']); + self.log_msg(" %s - switch to play mode" % self.keys['switch_to_play']); + + def loop(self, stdscr): + import time + import datetime + + def safe_addstr(y, x, line): + if y < self.size.y - 1 or x + len(line) < self.size.x: + stdscr.addstr(y, x, line) + else: # workaround to <https://stackoverflow.com/q/7063128> + cut_i = self.size.x - x - 1 + cut = line[:cut_i] + last_char = line[cut_i] + stdscr.addstr(y, self.size.x - 2, last_char) + stdscr.insstr(y, self.size.x - 2, ' ') + stdscr.addstr(y, x, cut) + + def connect(): + + def handle_recv(msg): + if msg == 'BYE': + self.socket.close() + else: + self.queue.put(msg) + + socket_client_class = PlomSocketClient + if self.host.startswith('ws://') or self.host.startswith('wss://'): + socket_client_class = WebSocketClient + while True: + try: + self.socket = socket_client_class(handle_recv, self.host) + self.socket_thread = threading.Thread(target=self.socket.run) + self.socket_thread.start() + self.switch_mode('login') + return + except ConnectionRefusedError: + self.log_msg('@ server connect failure, trying again â¦') + draw_screen() + stdscr.refresh() + time.sleep(1) + + def reconnect(): + self.send('QUIT') + time.sleep(0.1) # FIXME necessitated by some some strange SSL race + # conditions with ws4py, find out what exactly + self.switch_mode('waiting_for_server') + connect() + + def handle_input(msg): + command, args = self.parser.parse(msg) + command(*args) + + def msg_into_lines_of_width(msg, width): + chunk = '' + lines = [] + x = 0 + for i in range(len(msg)): + if x >= width or msg[i] == "\n": + lines += [chunk] + chunk = '' + x = 0 + if msg[i] != "\n": + chunk += msg[i] + x += 1 + lines += [chunk] + return lines + + def reset_screen_size(): + self.size = YX(*stdscr.getmaxyx()) + self.size = self.size - YX(self.size.y % 4, 0) + self.size = self.size - YX(0, self.size.x % 4) + self.window_width = int(self.size.x / 2) + + def recalc_input_lines(): + if not self.mode.has_input_prompt: + self.input_lines = [] + else: + self.input_lines = msg_into_lines_of_width(input_prompt + self.input_, + self.window_width) + + def move_explorer(direction): + target = self.game.map_geometry.move(self.explorer, direction) + if target: + self.explorer = target + self.query_info() + else: + self.flash() + + def draw_history(): + lines = [] + for line in self.log: + lines += msg_into_lines_of_width(line, self.window_width) + lines.reverse() + height_header = 2 + max_y = self.size.y - len(self.input_lines) + for i in range(len(lines)): + if (i >= max_y - height_header): + break + safe_addstr(max_y - i - 1, self.window_width, lines[i]) + + def draw_info(): + if not self.game.turn_complete: + return + pos_i = self.explorer.y * self.game.map_geometry.size.x + self.explorer.x + info = 'TERRAIN: %s\n' % self.game.map_content[pos_i] + for t in self.game.things: + if t.position == self.explorer: + info += 'PLAYER @: %s\n' % t.name + if self.explorer in self.game.portals: + info += 'PORTAL: ' + self.game.portals[self.explorer] + '\n' + else: + info += 'PORTAL: (none)\n' + if self.explorer in self.game.info_db: + info += 'ANNOTATION: ' + self.game.info_db[self.explorer] + else: + info += 'ANNOTATION: waiting â¦' + lines = msg_into_lines_of_width(info, self.window_width) + height_header = 2 + for i in range(len(lines)): + y = height_header + i + if y >= self.size.y - len(self.input_lines): + break + safe_addstr(y, self.window_width, lines[i]) + + def draw_input(): + y = self.size.y - len(self.input_lines) + for i in range(len(self.input_lines)): + safe_addstr(y, self.window_width, self.input_lines[i]) + y += 1 + + def draw_turn(): + if not self.game.turn_complete: + return + safe_addstr(0, self.window_width, 'TURN: ' + str(self.game.turn)) + + def draw_mode(): + safe_addstr(1, self.window_width, 'MODE: ' + self.mode.name) + + def draw_map(): + if not self.game.turn_complete: + return + map_lines_split = [] + for y in range(self.game.map_geometry.size.y): + start = self.game.map_geometry.size.x * y + end = start + self.game.map_geometry.size.x + map_lines_split += [list(self.game.map_content[start:end])] + for t in self.game.things: + map_lines_split[t.position.y][t.position.x] = '@' + if self.mode.shows_info: + map_lines_split[self.explorer.y][self.explorer.x] = '?' + map_lines = [] + if type(self.game.map_geometry) == MapGeometryHex: + indent = 0 + for line in map_lines_split: + map_lines += [indent*' ' + ' '.join(line)] + indent = 0 if indent else 1 + else: + for line in map_lines_split: + map_lines += [' '.join(line)] + window_center = YX(int(self.size.y / 2), + int(self.window_width / 2)) + player = self.game.get_thing(self.game.player_id, False) + center = player.position + if self.mode.shows_info: + center = self.explorer + center = YX(center.y, center.x * 2) + offset = center - window_center + if type(self.game.map_geometry) == MapGeometryHex and offset.y % 2: + offset += YX(0, 1) + term_y = max(0, -offset.y) + term_x = max(0, -offset.x) + map_y = max(0, offset.y) + map_x = max(0, offset.x) + while (term_y < self.size.y and map_y < self.game.map_geometry.size.y): + to_draw = map_lines[map_y][map_x:self.window_width + offset.x] + safe_addstr(term_y, term_x, to_draw) + term_y += 1 + map_y += 1 + + def draw_screen(): + stdscr.clear() + recalc_input_lines() + if self.mode.has_input_prompt: + draw_input() + if self.mode.shows_info: + draw_info() + else: + draw_history() + draw_mode() + if not self.mode.is_intro: + draw_turn() + draw_map() + + curses.curs_set(False) # hide cursor + curses.use_default_colors(); + stdscr.timeout(10) + reset_screen_size() + self.explorer = YX(0, 0) + self.input_ = '' + input_prompt = '> ' + connect() + last_ping = datetime.datetime.now() + interval = datetime.timedelta(seconds=30) + while True: + now = datetime.datetime.now() + if now - last_ping > interval: + self.send('PING') + last_ping = now + if self.do_refresh: + draw_screen() + self.do_refresh = False + while True: + try: + msg = self.queue.get(block=False) + handle_input(msg) + except queue.Empty: + break + try: + key = stdscr.getkey() + self.do_refresh = True + except curses.error: + continue + if key == 'KEY_RESIZE': + reset_screen_size() + elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE': + self.input_ = self.input_[:-1] + elif self.mode.has_input_prompt and key != '\n': # Return key + self.input_ += key + max_length = self.window_width * self.size.y - len(input_prompt) - 1 + if len(self.input_) > max_length: + self.input_ = self.input_[:max_length] + elif self.mode == self.mode_login and key == '\n': + self.login_name = self.input_ + self.send('LOGIN ' + quote(self.input_)) + self.input_ = "" + elif self.mode == self.mode_chat and key == '\n': + if self.input_[0] == '/': + if self.input_ in {'/' + self.keys['switch_to_play'], '/play'}: + self.switch_mode('play') + elif self.input_ in {'/' + self.keys['switch_to_study'], '/study'}: + self.switch_mode('study') + elif self.input_ == '/help': + self.help() + elif self.input_ == '/reconnect': + reconnect() + elif self.input_.startswith('/nick'): + tokens = self.input_.split(maxsplit=1) + if len(tokens) == 2: + self.send('LOGIN ' + quote(tokens[1])) + else: + self.log_msg('? need login name') + elif self.input_.startswith('/msg'): + tokens = self.input_.split(maxsplit=2) + if len(tokens) == 3: + self.send('QUERY %s %s' % (quote(tokens[1]), + quote(tokens[2]))) + else: + self.log_msg('? need message target and message') + else: + self.log_msg('? unknown command') + else: + self.send('ALL ' + quote(self.input_)) + self.input_ = "" + elif self.mode == self.mode_annotate and key == '\n': + if self.input_ == '': + self.input_ = ' ' + self.send('ANNOTATE %s %s' % (self.explorer, quote(self.input_))) + self.input_ = "" + self.switch_mode('study', keep_position=True) + elif self.mode == self.mode_portal and key == '\n': + if self.input_ == '': + self.input_ = ' ' + self.send('PORTAL %s %s' % (self.explorer, quote(self.input_))) + self.input_ = "" + self.switch_mode('study', keep_position=True) + elif self.mode == self.mode_teleport and key == '\n': + if self.input_ == 'YES!': + self.host = self.teleport_target_host + reconnect() + else: + self.log_msg('@ teleport aborted') + self.switch_mode('play') + self.input_ = '' + elif self.mode == self.mode_study: + if key == self.keys['switch_to_chat']: + self.switch_mode('chat') + elif key == self.keys['switch_to_play']: + self.switch_mode('play') + elif key == self.keys['switch_to_annotate']: + self.switch_mode('annotate', keep_position=True) + elif key == self.keys['switch_to_portal']: + self.switch_mode('portal', keep_position=True) + elif key in self.movement_keys: + move_explorer(self.movement_keys[key]) + elif self.mode == self.mode_play: + if key == self.keys['switch_to_chat']: + self.switch_mode('chat') + elif key == self.keys['switch_to_study']: + self.switch_mode('study') + if key == self.keys['switch_to_edit']: + self.switch_mode('edit') + elif key == self.keys['flatten']: + self.send('TASK:FLATTEN_SURROUNDINGS') + elif key in self.movement_keys: + self.send('TASK:MOVE ' + self.movement_keys[key]) + elif self.mode == self.mode_edit: + self.send('TASK:WRITE ' + key) + self.switch_mode('play') + +TUI('localhost:5000') diff --git a/rogue_chat_nocanvas_monochrome.html b/rogue_chat_nocanvas_monochrome.html new file mode 100644 index 0000000..ab3ce34 --- /dev/null +++ b/rogue_chat_nocanvas_monochrome.html @@ -0,0 +1,809 @@ +<!DOCTYPE html> +<html><head> +<style> +</style> +</head><body> +<div> +terminal rows: <input id="n_rows" type="number" step=4 min=8 value=24 /> +terminal columns: <input id="n_cols" type="number" step=4 min=20 value=80 /> +</div> +<pre id="terminal" style="display: inline-block;"></pre> +<textarea id="input" style="opacity: 0; width: 0px;"></textarea> +<div> +keys (see <a href="https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values">here</a> for non-obvious available values):<br /> +move up (square grid): <input id="key_square_move_up" type="text" value="w" /> (hint: ArrowUp)<br /> +move left (square grid): <input id="key_square_move_left" type="text" value="a" /> (hint: ArrowLeft)<br /> +move down (square grid): <input id="key_square_move_down" type="text" value="s" /> (hint: ArrowDown)<br /> +move right (square grid): <input id="key_square_move_right" type="text" value="d" /> (hint: ArrowRight)<br /> +move up-left (hex grid): <input id="key_hex_move_upleft" type="text" value="w" /><br /> +move up-right (hex grid): <input id="key_hex_move_upright" type="text" value="e" /><br /> +move right (hex grid): <input id="key_hex_move_right" type="text" value="d" /><br /> +move down-right (hex grid): <input id="key_hex_move_downright" type="text" value="x" /><br /> +move down-left (hex grid): <input id="key_hex_move_downleft" type="text" value="y" /><br /> +move left (hex grid): <input id="key_hex_move_left" type="text" value="a" /><br /> +flatten surroundings: <input id="key_flatten" type="text" value="F" /><br /> +switch to chat mode: <input id="key_switch_to_chat" type="text" value="t" /><br /> +switch to play mode: <input id="key_switch_to_play" type="text" value="p" /><br /> +switch to study mode: <input id="key_switch_to_study" type="text" value="?" /><br /> +edit terrain (from play mode): <input id="key_switch_to_edit" type="text" value="m" /><br /> +annotate terrain (from study mode): <input id="key_switch_to_annotate" type="text" value="m" /><br /> +annotate portal (from study mode): <input id="key_switch_to_portal" type="text" value="P" /><br /> +</div> +<script> +"use strict"; +let websocket_location = "ws://localhost:8000"; + +let rows_selector = document.getElementById("n_rows"); +let cols_selector = document.getElementById("n_cols"); +let key_selectors = document.querySelectorAll('[id^="key_"]'); + +function restore_selector_value(selector) { + let stored_selection = window.localStorage.getItem(selector.id); + if (stored_selection) { + selector.value = stored_selection; + } +} +restore_selector_value(rows_selector); +restore_selector_value(cols_selector); +for (let key_selector of key_selectors) { + restore_selector_value(key_selector); +} + +let terminal = { + foreground: 'white', + background: 'black', + initialize: function() { + this.rows = rows_selector.value; + this.cols = cols_selector.value; + this.pre_el = document.getElementById("terminal"); + this.pre_el.style.color = this.foreground; + this.pre_el.style.backgroundColor = this.background; + this.content = []; + let line = [] + for (let y = 0, x = 0; y <= this.rows; x++) { + if (x == this.cols) { + x = 0; + y += 1; + this.content.push(line); + line = []; + if (y == this.rows) { + break; + } + } + line.push(' '); + } + }, + blink_screen: function() { + this.pre_el.style.color = this.background; + this.pre_el.style.backgroundColor = this.foreground; + setTimeout(() => { + this.pre_el.style.color = this.foreground; + this.pre_el.style.backgroundColor = this.background; + }, 100); + }, + refresh: function() { + let pre_string = ''; + for (let y = 0; y < this.rows; y++) { + let line = this.content[y].join(''); + pre_string += line + '\n'; + } + this.pre_el.textContent = pre_string; + }, + write: function(start_y, start_x, msg) { + for (let x = start_x, i = 0; x < this.cols && i < msg.length; x++, i++) { + this.content[start_y][x] = msg[i]; + } + }, + drawBox: function(start_y, start_x, height, width) { + let end_y = start_y + height; + let end_x = start_x + width; + for (let y = start_y, x = start_x; y < this.rows; x++) { + if (x == end_x) { + x = start_x; + y += 1; + if (y == end_y) { + break; + } + }; + this.content[y][x] = ' '; + } + }, +} +terminal.initialize(); + +let parser = { + tokenize: function(str) { + let token_ends = []; + 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) { + token_ends.push(i); + tokens.push(token); + token = ''; + } + } else { + token += c; + } + } + if (token.length > 0) { + tokens.push(token); + } + let token_starts = []; + for (let i = 0; i < token_ends.length; i++) { + token_starts.push(token_ends[i] - tokens[i].length); + }; + return [tokens, token_starts]; + }, + parse_yx: function(position_string) { + let coordinate_strings = position_string.split(',') + let position = [0, 0]; + position[0] = parseInt(coordinate_strings[0].slice(2)); + position[1] = parseInt(coordinate_strings[1].slice(2)); + return position; + }, +} + +class Thing { + constructor(yx) { + this.position = yx; + } +} + +let server = { + init: function(url) { + this.url = url; + this.websocket = new WebSocket(this.url); + this.websocket.onopen = function(event) { + window.setInterval(function() { server.send(['PING']) }, 30000); + tui.log_msg("@ server connected! :)"); + tui.switch_mode(mode_login); + }; + this.websocket.onclose = function(event) { + tui.log_msg("@ server disconnected :("); + tui.log_msg("@ hint: try the '/reconnect' command"); + }; + this.websocket.onmessage = this.handle_event; + }, + reconnect: function() { + this.reconnect_to(this.url); + }, + reconnect_to: function(url) { + this.websocket.close(); + this.init(url); + }, + send: function(tokens) { + this.websocket.send(unparser.untokenize(tokens)); + }, + handle_event: function(event) { + let tokens = parser.tokenize(event.data)[0]; + if (tokens[0] === 'TURN') { + game.turn_complete = false; + game.things = {}; + game.portals = {}; + game.turn = parseInt(tokens[1]); + } else if (tokens[0] === 'THING_POS') { + game.get_thing(tokens[1], true).position = parser.parse_yx(tokens[2]); + } else if (tokens[0] === 'THING_NAME') { + game.get_thing(tokens[1], true).name_ = tokens[2]; + } else if (tokens[0] === 'MAP') { + game.map_geometry = tokens[1]; + tui.init_keys(); + game.map_size = parser.parse_yx(tokens[2]); + game.map = tokens[3] + } else if (tokens[0] === 'GAME_STATE_COMPLETE') { + game.turn_complete = true; + explorer.empty_info_db(); + if (tui.mode == mode_post_login_wait) { + tui.switch_mode(mode_play); + tui.log_help(); + } else if (tui.mode == mode_study) { + explorer.query_info(); + } + let t = game.get_thing(game.player_id); + if (t.position in game.portals) { + tui.teleport_target = game.portals[t.position]; + tui.switch_mode(mode_teleport); + return; + } + tui.full_refresh(); + } else if (tokens[0] === 'CHAT') { + tui.log_msg('# ' + tokens[1], 1); + } else if (tokens[0] === 'PLAYER_ID') { + game.player_id = parseInt(tokens[1]); + } else if (tokens[0] === 'LOGIN_OK') { + this.send(['GET_GAMESTATE']); + tui.switch_mode(mode_post_login_wait); + } else if (tokens[0] === 'PORTAL') { + let position = parser.parse_yx(tokens[1]); + game.portals[position] = tokens[2]; + } else if (tokens[0] === 'ANNOTATION') { + let position = parser.parse_yx(tokens[1]); + explorer.update_info_db(position, tokens[2]); + } else if (tokens[0] === 'UNHANDLED_INPUT') { + tui.log_msg('? unknown command'); + } else if (tokens[0] === 'PLAY_ERROR') { + terminal.blink_screen(); + } 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 if (tokens[0] === 'PONG') { + console.log('PONG'); + } else { + tui.log_msg('? unhandled input: ' + event.data); + } + } +} + +let unparser = { + quote: function(str) { + let quoted = ['"']; + for (let i = 0; i < str.length; i++) { + let c = str[i]; + if (['"', '\\'].includes(c)) { + quoted.push('\\'); + }; + quoted.push(c); + } + quoted.push('"'); + return quoted.join(''); + }, + to_yx: function(yx_coordinate) { + return "Y:" + yx_coordinate[0] + ",X:" + yx_coordinate[1]; + }, + untokenize: function(tokens) { + let quoted_tokens = []; + for (let token of tokens) { + quoted_tokens.push(this.quote(token)); + } + return quoted_tokens.join(" "); + } +} + +class Mode { + constructor(name, has_input_prompt=false, shows_info=false, is_intro=false) { + this.name = name; + this.has_input_prompt = has_input_prompt; + this.shows_info= shows_info; + this.is_intro = is_intro; + } +} +let mode_waiting_for_server = new Mode('waiting_for_server', false, false, true); +let mode_login = new Mode('login', true, false, true); +let mode_post_login_wait = new Mode('waiting for game world', false, false, true); +let mode_chat = new Mode('chat / write messages to players', true, false); +let mode_annotate = new Mode('add message to map tile', true, true); +let mode_play = new Mode('play / move around', false, false); +let mode_study = new Mode('check map tiles for messages', false, true); +let mode_edit = new Mode('write ASCII char to map tile', false, false); +let mode_teleport = new Mode('teleport away?', true); +let mode_portal = new Mode('add portal to map tile', true, true); + +let tui = { + mode: mode_waiting_for_server, + log: [], + input_prompt: '> ', + input_lines: [], + window_width: terminal.cols / 2, + height_turn_line: 1, + height_mode_line: 1, + height_input: 1, + init: function() { + this.inputEl = document.getElementById("input"); + this.inputEl.focus(); + this.recalc_input_lines(); + this.height_header = this.height_turn_line + this.height_mode_line; + this.log_msg("@ waiting for server connection ..."); + this.init_keys(); + }, + init_keys: function() { + this.keys = {}; + for (let key_selector of key_selectors) { + this.keys[key_selector.id.slice(4)] = key_selector.value; + } + if (game.map_geometry == 'Square') { + this.movement_keys = { + [this.keys.square_move_up]: 'UP', + [this.keys.square_move_left]: 'LEFT', + [this.keys.square_move_down]: 'DOWN', + [this.keys.square_move_right]: 'RIGHT' + }; + } else if (game.map_geometry == 'Hex') { + this.movement_keys = { + [this.keys.hex_move_upleft]: 'UPLEFT', + [this.keys.hex_move_upright]: 'UPRIGHT', + [this.keys.hex_move_right]: 'RIGHT', + [this.keys.hex_move_downright]: 'DOWNRIGHT', + [this.keys.hex_move_downleft]: 'DOWNLEFT', + [this.keys.hex_move_left]: 'LEFT' + }; + }; + }, + switch_mode: function(mode, keep_pos=false) { + if (mode == mode_study && !keep_pos && game.player_id in game.things) { + explorer.position = game.things[game.player_id].position; + } + this.mode = mode; + this.empty_input(); + if (mode == mode_annotate && explorer.position in explorer.info_db) { + let info = explorer.info_db[explorer.position]; + if (info != "(none)") { + this.inputEl.value = info; + this.recalc_input_lines(); + } + } + if (mode == mode_login) { + if (this.login_name) { + server.send(['LOGIN', this.login_name]); + } else { + this.log_msg("? need login name"); + } + } else if (mode == mode_portal && explorer.position in game.portals) { + let portal = game.portals[explorer.position] + this.inputEl.value = portal; + this.recalc_input_lines(); + } else if (mode == mode_teleport) { + tui.log_msg("@ May teleport to: " + tui.teleport_target); + tui.log_msg("@ Enter 'YES!' to entusiastically affirm."); + } + this.full_refresh(); + }, + empty_input: function(str) { + this.inputEl.value = ""; + if (this.mode.has_input_prompt) { + this.recalc_input_lines(); + } else { + this.height_input = 0; + } + }, + recalc_input_lines: function() { + this.input_lines = this.msg_into_lines_of_width(this.input_prompt + this.inputEl.value, this.window_width); + this.height_input = this.input_lines.length; + }, + msg_into_lines_of_width: function(msg, width) { + let chunk = ""; + let lines = []; + for (let i = 0, x = 0; i < msg.length; i++, x++) { + if (x >= width || msg[i] == "\n") { + lines.push(chunk); + chunk = ""; + x = 0; + }; + if (msg[i] != "\n") { + chunk += msg[i]; + } + } + lines.push(chunk); + return lines; + }, + log_msg: function(msg) { + this.log.push(msg); + while (this.log.length > 100) { + this.log.shift(); + }; + this.full_refresh(); + }, + log_help: function() { + let movement_keys_desc = Object.keys(this.movement_keys).join(','); + this.log_msg("HELP:"); + this.log_msg("chat mode commands:"); + this.log_msg(" /nick NAME - re-name yourself to NAME"); + this.log_msg(" /msg USER TEXT - send TEXT to USER"); + this.log_msg(" /help - show this help"); + this.log_msg(" /" + this.keys.switch_to_play + " or /play - switch to play mode"); + this.log_msg(" /" + this.keys.switch_to_study + " or /study - switch to study mode"); + this.log_msg("commands common to study and play mode:"); + this.log_msg(" " + movement_keys_desc + " - move"); + this.log_msg(" " + this.keys.switch_to_chat + " - switch to chat mode"); + this.log_msg("commands specific to play mode:"); + this.log_msg(" " + this.keys.switch_to_edit + " - write following ASCII character"); + this.log_msg(" " + this.keys.flatten + " - flatten surroundings"); + this.log_msg(" " + this.keys.switch_to_study + " - switch to study mode"); + this.log_msg("commands specific to study mode:"); + this.log_msg(" " + this.keys.switch_to_annotate + " - annotate terrain"); + this.log_msg(" " + this.keys.switch_to_play + " - switch to play mode"); + }, + draw_map: function() { + let map_lines_split = []; + let line = []; + for (let i = 0, j = 0; i < game.map.length; i++, j++) { + if (j == game.map_size[1]) { + map_lines_split.push(line); + line = []; + j = 0; + }; + line.push(game.map[i]); + }; + map_lines_split.push(line); + for (const thing_id in game.things) { + let t = game.things[thing_id]; + map_lines_split[t.position[0]][t.position[1]] = '@'; + }; + if (tui.mode.shows_info) { + map_lines_split[explorer.position[0]][explorer.position[1]] = '?'; + } + let map_lines = [] + if (game.map_geometry == 'Square') { + for (let line_split of map_lines_split) { + map_lines.push(line_split.join(' ')); + }; + } else if (game.map_geometry == 'Hex') { + let indent = 0 + for (let line_split of map_lines_split) { + map_lines.push(' '.repeat(indent) + line_split.join(' ')); + if (indent == 0) { + indent = 1; + } else { + indent = 0; + }; + }; + } + let window_center = [terminal.rows / 2, this.window_width / 2]; + let player = game.things[game.player_id]; + let center_position = [player.position[0], player.position[1]]; + if (tui.mode.shows_info) { + center_position = [explorer.position[0], explorer.position[1]]; + } + center_position[1] = center_position[1] * 2; + let offset = [center_position[0] - window_center[0], + center_position[1] - window_center[1]] + if (game.map_geometry == 'Hex' && offset[0] % 2) { + offset[1] += 1; + }; + let term_y = Math.max(0, -offset[0]); + let term_x = Math.max(0, -offset[1]); + let map_y = Math.max(0, offset[0]); + let map_x = Math.max(0, offset[1]); + for (; term_y < terminal.rows && map_y < game.map_size[0]; term_y++, map_y++) { + let to_draw = map_lines[map_y].slice(map_x, this.window_width + offset[1]); + terminal.write(term_y, term_x, to_draw); + } + }, + draw_mode_line: function() { + terminal.write(0, this.window_width, 'MODE: ' + this.mode.name); + }, + draw_turn_line: function(n) { + terminal.write(1, this.window_width, 'TURN: ' + game.turn); + }, + draw_history: function() { + let log_display_lines = []; + for (let line of this.log) { + log_display_lines = log_display_lines.concat(this.msg_into_lines_of_width(line, this.window_width)); + }; + for (let y = terminal.rows - 1 - this.height_input, + i = log_display_lines.length - 1; + y >= this.height_header && i >= 0; + y--, i--) { + terminal.write(y, this.window_width, log_display_lines[i]); + } + }, + draw_info: function() { + let lines = this.msg_into_lines_of_width(explorer.get_info(), this.window_width); + for (let y = this.height_header, i = 0; y < terminal.rows && i < lines.length; y++, i++) { + terminal.write(y, this.window_width, lines[i]); + } + }, + draw_input: function() { + if (this.mode.has_input_prompt) { + for (let y = terminal.rows - this.height_input, i = 0; i < this.input_lines.length; y++, i++) { + terminal.write(y, this.window_width, this.input_lines[i]); + } + } + }, + full_refresh: function() { + terminal.drawBox(0, 0, terminal.rows, terminal.cols); + if (this.mode.is_intro) { + this.draw_history(); + this.draw_input(); + } else { + if (game.turn_complete) { + this.draw_map(); + this.draw_turn_line(); + } + this.draw_mode_line(); + if (this.mode.shows_info) { + this.draw_info(); + } else { + this.draw_history(); + } + this.draw_input(); + } + terminal.refresh(); + } +} + +let game = { + init: function() { + this.things = {}; + this.turn = -1; + this.map = ""; + this.map_size = [0,0]; + this.player_id = -1; + this.portals = {}; + }, + get_thing: function(id_, create_if_not_found=false) { + if (id_ in game.things) { + return game.things[id_]; + } else if (create_if_not_found) { + let t = new Thing([0,0]); + game.things[id_] = t; + return t; + }; + }, + move: function(start_position, direction) { + let target = [start_position[0], start_position[1]]; + if (direction == 'LEFT') { + target[1] -= 1; + } else if (direction == 'RIGHT') { + target[1] += 1; + } else if (game.map_geometry == 'Square') { + if (direction == 'UP') { + target[0] -= 1; + } else if (direction == 'DOWN') { + target[0] += 1; + }; + } else if (game.map_geometry == 'Hex') { + let start_indented = start_position[0] % 2; + if (direction == 'UPLEFT') { + target[0] -= 1; + if (!start_indented) { + target[1] -= 1; + } + } else if (direction == 'UPRIGHT') { + target[0] -= 1; + if (start_indented) { + target[1] += 1; + } + } else if (direction == 'DOWNLEFT') { + target[0] += 1; + if (!start_indented) { + target[1] -= 1; + } + } else if (direction == 'DOWNRIGHT') { + target[0] += 1; + if (start_indented) { + target[1] += 1; + } + }; + }; + if (target[0] < 0 || target[1] < 0 || + target[0] >= this.map_size[0] || target[1] >= this.map_size[1]) { + return null; + }; + return target; + } +} + +game.init(); +tui.init(); +tui.full_refresh(); +server.init(websocket_location); + +let explorer = { + position: [0,0], + info_db: {}, + move: function(direction) { + let target = game.move(this.position, direction); + if (target) { + this.position = target + this.query_info(); + tui.full_refresh(); + } else { + terminal.blink_screen(); + }; + }, + update_info_db: function(yx, str) { + this.info_db[yx] = str; + if (tui.mode == mode_study) { + tui.full_refresh(); + } + }, + empty_info_db: function() { + this.info_db = {}; + if (tui.mode == mode_study) { + tui.full_refresh(); + } + }, + query_info: function() { + server.send(["GET_ANNOTATION", unparser.to_yx(explorer.position)]); + }, + get_info: function() { + let info = ""; + let position_i = this.position[0] * game.map_size[1] + this.position[1]; + info += "TERRAIN: " + game.map[position_i] + "\n"; + for (let t_id in game.things) { + let t = game.things[t_id]; + if (t.position[0] == this.position[0] && t.position[1] == this.position[1]) { + info += "PLAYER @"; + if (t.name_) { + info += ": " + t.name_; + } + info += "\n"; + } + } + if (this.position in game.portals) { + info += "PORTAL: " + game.portals[this.position] + "\n"; + } + if (this.position in this.info_db) { + info += "ANNOTATIONS: " + this.info_db[this.position]; + } else { + info += 'waiting â¦'; + } + return info; + }, + annotate: function(msg) { + if (msg.length == 0) { + msg = " "; // triggers annotation deletion + } + server.send(["ANNOTATE", unparser.to_yx(explorer.position), msg]); + }, + set_portal: function(msg) { + if (msg.length == 0) { + msg = " "; // triggers portal deletion + } + server.send(["PORTAL", unparser.to_yx(explorer.position), msg]); + } +} + +tui.inputEl.addEventListener('input', (event) => { + if (tui.mode.has_input_prompt) { + let max_length = tui.window_width * terminal.rows - tui.input_prompt.length; + if (tui.inputEl.value.length > max_length) { + tui.inputEl.value = tui.inputEl.value.slice(0, max_length); + }; + tui.recalc_input_lines(); + tui.full_refresh(); + } else if (tui.mode == mode_edit && tui.inputEl.value.length > 0) { + server.send(["TASK:WRITE", tui.inputEl.value[0]]); + tui.switch_mode(mode_play); + } else if (tui.mode == mode_teleport) { + if (['Y', 'y'].includes(tui.inputEl.value[0])) { + server.reconnect_to(tui.teleport_target); + } else { + tui.log_msg("@ teleportation aborted"); + tui.switch_mode(mode_play); + } + } +}, false); +tui.inputEl.addEventListener('keydown', (event) => { + if (event.key == 'Enter') { + event.preventDefault(); + } + if (tui.mode == mode_login && event.key == 'Enter') { + tui.login_name = tui.inputEl.value; + server.send(['LOGIN', tui.inputEl.value]); + tui.empty_input(); + } else if (tui.mode == mode_portal && event.key == 'Enter') { + explorer.set_portal(tui.inputEl.value); + tui.switch_mode(mode_study, true); + } else if (tui.mode == mode_annotate && event.key == 'Enter') { + explorer.annotate(tui.inputEl.value); + tui.switch_mode(mode_study, true); + } else if (tui.mode == mode_teleport && event.key == 'Enter') { + if (tui.inputEl.value == 'YES!') { + server.reconnect_to(tui.teleport_target); + } else { + tui.log_msg('@ teleport aborted'); + tui.switch_mode(mode_play); + }; + } else if (tui.mode == mode_chat && event.key == 'Enter') { + let [tokens, token_starts] = parser.tokenize(tui.inputEl.value); + if (tokens.length > 0 && tokens[0].length > 0) { + if (tui.inputEl.value[0][0] == '/') { + if (tokens[0].slice(1) == 'play' || tokens[0][1] == tui.keys.switch_to_play) { + tui.switch_mode(mode_play); + } else if (tokens[0].slice(1) == 'study' || tokens[0][1] == tui.keys.switch_to_study) { + tui.switch_mode(mode_study); + } else if (tokens[0].slice(1) == 'help') { + tui.log_help(); + } else if (tokens[0].slice(1) == 'nick') { + if (tokens.length > 1) { + server.send(['LOGIN', tokens[1]]); + } else { + tui.log_msg('? need login name'); + } + } else if (tokens[0].slice(1) == 'msg') { + if (tokens.length > 2) { + let msg = tui.inputEl.value.slice(token_starts[2]); + server.send(['QUERY', tokens[1], msg]); + } else { + tui.log_msg('? need message target and message'); + } + } else if (tokens[0].slice(1) == 'reconnect') { + if (tokens.length > 1) { + server.reconnect_to(tokens[1]); + } else { + server.reconnect(); + } + } else { + tui.log_msg('? unknown command'); + } + } else { + server.send(['ALL', tui.inputEl.value]); + } + } else if (tui.inputEl.valuelength > 0) { + server.send(['ALL', tui.inputEl.value]); + } + tui.empty_input(); + tui.full_refresh(); + } else if (tui.mode == mode_play) { + if (event.key === tui.keys.switch_to_chat) { + event.preventDefault(); + tui.switch_mode(mode_chat); + } else if (event.key === tui.keys.switch_to_edit) { + event.preventDefault(); + tui.switch_mode(mode_edit); + } else if (event.key === tui.keys.switch_to_study) { + tui.switch_mode(mode_study); + } else if (event.key === tui.keys.flatten) { + server.send(["TASK:FLATTEN_SURROUNDINGS"]); + } else if (event.key in tui.movement_keys) { + server.send(['TASK:MOVE', tui.movement_keys[event.key]]); + }; + } else if (tui.mode == mode_study) { + if (event.key === tui.keys.switch_to_chat) { + event.preventDefault(); + tui.switch_mode(mode_chat); + } else if (event.key == tui.keys.switch_to_play) { + tui.switch_mode(mode_play); + } else if (event.key === tui.keys.switch_to_portal) { + event.preventDefault(); + tui.switch_mode(mode_portal); + } else if (event.key in tui.movement_keys) { + explorer.move(tui.movement_keys[event.key]); + } else if (event.key === tui.keys.switch_to_annotate) { + event.preventDefault(); + tui.switch_mode(mode_annotate); + }; + } +}, false); + +rows_selector.addEventListener('input', function() { + if (rows_selector.value % 4 != 0) { + return; + } + window.localStorage.setItem(rows_selector.id, rows_selector.value); + terminal.initialize(); + tui.full_refresh(); +}, false); +cols_selector.addEventListener('input', function() { + if (cols_selector.value % 4 != 0) { + return; + } + window.localStorage.setItem(cols_selector.id, cols_selector.value); + terminal.initialize(); + tui.window_width = terminal.cols / 2, + tui.full_refresh(); +}, false); +for (let key_selector of key_selectors) { + key_selector.addEventListener('input', function() { + window.localStorage.setItem(key_selector.id, key_selector.value); + tui.init_keys(); + }, false); +} +window.setInterval(function() { + if (!(['input', 'n_cols', 'n_rows'].includes(document.activeElement.id) + || document.activeElement.id.startsWith('key_'))) { + tui.inputEl.focus(); + } +}, 100); +</script> +</body></html> -- 2.30.2