From 05aae1b6380c5bea2d69ee001f64f93dbff2b0c1 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Thu, 24 Jan 2019 02:52:34 +0100 Subject: [PATCH 01/16] Add coloring to map in ncurses client. --- client-curses.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/client-curses.py b/client-curses.py index a79afa0..a18d8af 100755 --- a/client-curses.py +++ b/client-curses.py @@ -205,9 +205,9 @@ class MapWidget(Widget): for t in self.tui.game.world.things: pos_i = self.tui.game.world.map_.get_position_index(t.position) terrain_as_list[pos_i] = self.tui.game.symbol_for_type(t.type_) - text = self.tui.game.world.map_.list_terrain_to_lines(terrain_as_list) + lines = self.tui.game.world.map_.list_terrain_to_lines(terrain_as_list) line_width = self.size[1] - for line in text: + for line in lines: if line_width > len(line): to_pad = line_width - (len(line) % line_width) to_join += [line + '0' * to_pad] @@ -216,7 +216,19 @@ class MapWidget(Widget): if len(to_join) < self.size[0]: to_pad = self.size[0] - len(to_join) to_join += to_pad * ['0' * self.size[1]] - self.safe_write(''.join(to_join)) + text = ''.join(to_join) + text_as_list = [] + for c in text: + if c in {'@', 'm'}: + text_as_list += [(c, curses.color_pair(1))] + elif c == '.': + text_as_list += [(c, curses.color_pair(2))] + elif c in {'x', 'X', '#'}: + text_as_list += [(c, curses.color_pair(3))] + else: + text_as_list += [c] + #self.safe_write(''.join(to_join)) + self.safe_write(text_as_list) class TurnWidget(Widget): -- 2.30.2 From 9c2b6ad3844b6b0f168aa9b553d3d0b7a9d6036e Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Thu, 24 Jan 2019 04:05:08 +0100 Subject: [PATCH 02/16] Optimize ncurses client, only update map on VISIBLE_MAP_COMPLETE. --- client-curses.py | 52 ++++++++++++++++++++++++++---------------------- server_/game.py | 1 + 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/client-curses.py b/client-curses.py index a18d8af..93b190b 100755 --- a/client-curses.py +++ b/client-curses.py @@ -56,7 +56,8 @@ class World(game_common.World): class Game(game_common.CommonCommandsMixin): - def __init__(self): + def __init__(self, tui): + self.tui = tui self.map_manager = map_manager self.parser = Parser(self) self.world = World(self) @@ -77,6 +78,7 @@ class Game(game_common.CommonCommandsMixin): def cmd_LAST_PLAYER_TASK_RESULT(self, msg): if msg != "success": self.log(msg) + self.tui.log.do_update = True cmd_LAST_PLAYER_TASK_RESULT.argtypes = 'string' def cmd_TURN_FINISHED(self, n): @@ -87,6 +89,7 @@ class Game(game_common.CommonCommandsMixin): def cmd_NEW_TURN(self, n): """Set self.turn to n, empty self.things.""" self.world.turn = n + self.tui.turn.do_update = True self.world.things = [] cmd_NEW_TURN.argtypes = 'int:nonneg' @@ -94,6 +97,9 @@ class Game(game_common.CommonCommandsMixin): self.world.map_.set_line(y, terrain_line) cmd_VISIBLE_MAP_LINE.argtypes = 'int:nonneg string' + def cmd_VISIBLE_MAP_COMPLETE(self): + self.tui.map_.do_update = True + ASCII_printable = ' !"#$%&\'\(\)*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWX'\ 'YZ[\\]^_\`abcdefghijklmnopqrstuvwxyz{|}~' @@ -114,7 +120,7 @@ class Widget: self.win = curses.newwin(1, 1, self.start[0], self.start[1]) self.size_def = size # store for re-calling .size on SIGWINCH self.size = size - self.update = True + self.do_update = True @property def size(self): @@ -142,10 +148,7 @@ class Widget: part_string = part[0] attr = part[1] if len(part_string) > 0: - chars_with_attrs = [] - for char in part_string: - chars_with_attrs += [(char, attr)] - return chars_with_attrs + return [(char, attr) for char in part_string] elif len(part_string) == 1: return [part] return [] @@ -227,7 +230,6 @@ class MapWidget(Widget): text_as_list += [(c, curses.color_pair(3))] else: text_as_list += [c] - #self.safe_write(''.join(to_join)) self.safe_write(text_as_list) @@ -241,14 +243,15 @@ class TUI: def __init__(self, server_output): self.server_output = server_output - self.game = Game() + self.game = Game(self) self.parser = Parser(self.game) + self.do_update = True curses.wrapper(self.loop) def setup_screen(self, stdscr): self.stdscr = stdscr self.stdscr.refresh() # will be called by getkey else, clearing screen - self.stdscr.timeout(10) + self.stdscr.timeout(1) self.stdscr.addstr(0, 0, 'SEND:') self.stdscr.addstr(2, 0, 'TURN:') @@ -259,36 +262,35 @@ class TUI: curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_BLUE) curses.curs_set(False) # hide cursor self.to_send = [] - edit_line = EditWidget(self, (0, 6), (1, 14)) - turn_line = TurnWidget(self, (2, 6), (1, 14)) - log_display = LogWidget(self, (4, 0), (None, 20)) - map_view = MapWidget(self, (0, 21), (None, None)) - map_view.update = True - widgets = [edit_line, turn_line, log_display, map_view] - do_update = True + self.edit = EditWidget(self, (0, 6), (1, 14)) + self.turn = TurnWidget(self, (2, 6), (1, 14)) + self.log = LogWidget(self, (4, 0), (None, 20)) + self.map_ = MapWidget(self, (0, 21), (None, None)) + widgets = (self.edit, self.turn, self.log, self.map_) while True: - if do_update: - for w in widgets: + for w in widgets: + if w.do_update: w.draw_and_refresh() - do_update = False + w.do_update = False try: key = self.stdscr.getkey() - do_update = True if len(key) == 1 and key in ASCII_printable and \ - len(self.to_send) < len(edit_line): + len(self.to_send) < len(self.edit): self.to_send += [key] + self.edit.do_update = True elif key == 'KEY_BACKSPACE': self.to_send[:] = self.to_send[:-1] + self.edit.do_update = True elif key == '\n': plom_socket_io.send(s, ''.join(self.to_send)) self.to_send[:] = [] + self.edit.do_update = True elif key == 'KEY_RESIZE': curses.endwin() self.setup_screen(curses.initscr()) for w in widgets: w.size = w.size_def - else: - do_update = False + w.do_update = True except curses.error: pass if len(self.server_output) > 0: @@ -296,7 +298,7 @@ class TUI: if do_quit: break self.server_output[:] = [] - do_update = True + self.do_update = True def handle_input(self, msg): if msg == 'BYE': @@ -305,10 +307,12 @@ class TUI: command = self.parser.parse(msg) if command is None: self.game.log('UNHANDLED INPUT: ' + msg) + self.log.do_update = True else: command() except ArgError as e: self.game.log('ARGUMENT ERROR: ' + msg + '\n' + str(e)) + self.log.do_update = True return False diff --git a/server_/game.py b/server_/game.py index 5595cea..015c8cb 100644 --- a/server_/game.py +++ b/server_/game.py @@ -206,6 +206,7 @@ class Game(game_common.CommonCommandsMixin): self.io.send('THING_TYPE %s %s' % (thing.id_, thing.type_)) self.io.send('THING_POS %s %s' % (thing.id_, stringify_yx(thing.position))) + self.io.send('VISIBLE_MAP_COMPLETE') def proceed(self): """Send turn finish signal, run game world, send new world data. -- 2.30.2 From 70da7de2d179e893309491c1e8f31e0e868b7b42 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Thu, 24 Jan 2019 05:08:28 +0100 Subject: [PATCH 03/16] Improve map handling between client and server, add map scrolling. --- client-curses.py | 49 ++++++++++++++++++++++++++++++++++++++++++------ server_/game.py | 7 +++++-- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/client-curses.py b/client-curses.py index 93b190b..4981a33 100755 --- a/client-curses.py +++ b/client-curses.py @@ -9,7 +9,7 @@ import game_common class MapSquare(game_common.Map): - def list_terrain_to_lines(self, terrain_as_list): + def list_terrain_to_lines(self, terrain_as_list, center, size): terrain = ''.join(terrain_as_list) map_lines = [] start_cut = 0 @@ -17,12 +17,27 @@ class MapSquare(game_common.Map): limit = start_cut + self.size[1] map_lines += [terrain[start_cut:limit]] start_cut = limit + if len(map_lines) > size[0] and center[0] > size[0] / 2: + diff = len(map_lines) - size[0] + if center[0] > len(map_lines) - size[0] / 2: + map_lines = map_lines[diff:] + else: + start = center[0] - int(size[0] / 2) + map_lines = map_lines[start:start + size[0]] + if self.size[1] > size[1] and center[1] > size[1] / 2: + if center[1] > self.size[1] - size[1] / 2: + cut_start = self.size[1] - size[1] + cut_end = None + else: + cut_start = center[1] - int(size[1] / 2) + cut_end = cut_start + size[1] + map_lines = [line[cut_start:cut_end] for line in map_lines] return map_lines class MapHex(game_common.Map): - def list_terrain_to_lines(self, terrain_as_list): + def list_terrain_to_lines(self, terrain_as_list, center, size): new_terrain_list = [' '] x = 0 y = 0 @@ -35,7 +50,23 @@ class MapHex(game_common.Map): y += 1 if y % 2 == 0: new_terrain_list += [' '] - return ''.join(new_terrain_list).split('\n') + map_lines = ''.join(new_terrain_list).split('\n') + if len(map_lines) > size[0] and center[0] > size[0] / 2: + diff = len(map_lines) - size[0] + if center[0] > len(map_lines) - size[0] / 2: + map_lines = map_lines[diff:] + else: + start = center[0] - int(size[0] / 2) + map_lines = map_lines[start:start + size[0]] + if self.size[1]*2 > size[1] and center[1]*4 > size[1]: + if center[1]*2 > self.size[1]*2 - size[1] / 2: + cut_start = self.size[1] * 2 - size[1] + cut_end = None + else: + cut_start = center[1]*2 - int(size[1] / 2) + cut_end = cut_start + size[1] + map_lines = [line[cut_start:cut_end] for line in map_lines] + return map_lines map_manager = game_common.MapManager(globals()) @@ -52,6 +83,7 @@ class World(game_common.World): super().__init__(*args, **kwargs) self.game = game self.map_ = self.game.map_manager.get_map_class('Hex')() + self.player_position = (0, 0) class Game(game_common.CommonCommandsMixin): @@ -89,7 +121,6 @@ class Game(game_common.CommonCommandsMixin): def cmd_NEW_TURN(self, n): """Set self.turn to n, empty self.things.""" self.world.turn = n - self.tui.turn.do_update = True self.world.things = [] cmd_NEW_TURN.argtypes = 'int:nonneg' @@ -97,7 +128,12 @@ class Game(game_common.CommonCommandsMixin): self.world.map_.set_line(y, terrain_line) cmd_VISIBLE_MAP_LINE.argtypes = 'int:nonneg string' - def cmd_VISIBLE_MAP_COMPLETE(self): + def cmd_PLAYER_POS(self, yx): + self.world.player_position = yx + cmd_PLAYER_POS.argtypes = 'yx_tuple:pos' + + def cmd_GAME_STATE_COMPLETE(self): + self.tui.turn.do_update = True self.tui.map_.do_update = True @@ -208,7 +244,8 @@ class MapWidget(Widget): for t in self.tui.game.world.things: pos_i = self.tui.game.world.map_.get_position_index(t.position) terrain_as_list[pos_i] = self.tui.game.symbol_for_type(t.type_) - lines = self.tui.game.world.map_.list_terrain_to_lines(terrain_as_list) + center = self.tui.game.world.player_position + lines = self.tui.game.world.map_.list_terrain_to_lines(terrain_as_list, center, self.size) line_width = self.size[1] for line in lines: if line_width > len(line): diff --git a/server_/game.py b/server_/game.py index 015c8cb..df6a9f2 100644 --- a/server_/game.py +++ b/server_/game.py @@ -2,6 +2,7 @@ import sys sys.path.append('../') import game_common import server_.map_ +from parser import ArgError class GameError(Exception): @@ -206,7 +207,9 @@ class Game(game_common.CommonCommandsMixin): self.io.send('THING_TYPE %s %s' % (thing.id_, thing.type_)) self.io.send('THING_POS %s %s' % (thing.id_, stringify_yx(thing.position))) - self.io.send('VISIBLE_MAP_COMPLETE') + player = self.world.get_player() + self.io.send('PLAYER_POS %s' % (stringify_yx(player.position))) + self.io.send('GAME_STATE_COMPLETE') def proceed(self): """Send turn finish signal, run game world, send new world data. @@ -272,7 +275,7 @@ class Game(game_common.CommonCommandsMixin): self.proceed() def cmd_GET_GAMESTATE(self, connection_id): - """Send game state jto caller.""" + """Send game state to caller.""" self.send_gamestate(connection_id) def cmd_ECHO(self, msg, connection_id): -- 2.30.2 From 15b5b732868273b6b9e560bf99493b0dd2b06ed6 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Fri, 25 Jan 2019 00:03:48 +0100 Subject: [PATCH 04/16] Curses loop won't delay socket input processing. --- client-curses.py | 124 ++++++++++++++++++++++++++--------------------- 1 file changed, 69 insertions(+), 55 deletions(-) diff --git a/client-curses.py b/client-curses.py index 4981a33..a2441f6 100755 --- a/client-curses.py +++ b/client-curses.py @@ -88,12 +88,32 @@ class World(game_common.World): class Game(game_common.CommonCommandsMixin): - def __init__(self, tui): - self.tui = tui + def __init__(self): self.map_manager = map_manager self.parser = Parser(self) self.world = World(self) self.log_text = '' + self.to_update = { + 'log': False, + 'map': False, + 'turn': False, + } + self.do_quit = False + + def handle_input(self, msg): + if msg == 'BYE': + self.do_quit = True + return + try: + command = self.parser.parse(msg) + if command is None: + self.log('UNHANDLED INPUT: ' + msg) + self.to_update['log'] = True + else: + command() + except ArgError as e: + self.log('ARGUMENT ERROR: ' + msg + '\n' + str(e)) + self.to_update['log'] = True def log(self, msg): """Prefix msg plus newline to self.log_text.""" @@ -110,7 +130,7 @@ class Game(game_common.CommonCommandsMixin): def cmd_LAST_PLAYER_TASK_RESULT(self, msg): if msg != "success": self.log(msg) - self.tui.log.do_update = True + self.to_update['log'] = True cmd_LAST_PLAYER_TASK_RESULT.argtypes = 'string' def cmd_TURN_FINISHED(self, n): @@ -133,24 +153,24 @@ class Game(game_common.CommonCommandsMixin): cmd_PLAYER_POS.argtypes = 'yx_tuple:pos' def cmd_GAME_STATE_COMPLETE(self): - self.tui.turn.do_update = True - self.tui.map_.do_update = True + self.to_update['turn'] = True + self.to_update['map'] = True ASCII_printable = ' !"#$%&\'\(\)*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWX'\ 'YZ[\\]^_\`abcdefghijklmnopqrstuvwxyz{|}~' -def recv_loop(server_output): +def recv_loop(socket, game): for msg in plom_socket_io.recv(s): - while len(server_output) > 0: - pass - server_output += [msg] + game.handle_input(msg) class Widget: - def __init__(self, tui, start, size): + def __init__(self, tui, start, size, check_game=[], check_tui=[]): + self.check_game = check_game + self.check_tui = check_tui self.tui = tui self.start = start self.win = curses.newwin(1, 1, self.start[0], self.start[1]) @@ -209,10 +229,21 @@ class Widget: for char_with_attr in cut: self.win.addstr(char_with_attr[0], char_with_attr[1]) - def draw_and_refresh(self): - self.win.erase() - self.draw() - self.win.refresh() + def ensure_freshness(self, do_refresh=False): + if not do_refresh: + for key in self.check_game: + if self.tui.game.to_update[key]: + do_refresh = True + break + if not do_refresh: + for key in self.check_tui: + if self.tui.to_update[key]: + do_refresh = True + break + if do_refresh: + self.win.erase() + self.draw() + self.win.refresh() class EditWidget(Widget): @@ -278,17 +309,17 @@ class TurnWidget(Widget): class TUI: - def __init__(self, server_output): - self.server_output = server_output - self.game = Game(self) + def __init__(self, socket, game): + self.socket = socket + self.game = game self.parser = Parser(self.game) - self.do_update = True + self.to_update = {'edit': False} curses.wrapper(self.loop) def setup_screen(self, stdscr): self.stdscr = stdscr self.stdscr.refresh() # will be called by getkey else, clearing screen - self.stdscr.timeout(1) + self.stdscr.timeout(10) self.stdscr.addstr(0, 0, 'SEND:') self.stdscr.addstr(2, 0, 'TURN:') @@ -299,62 +330,45 @@ class TUI: curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_BLUE) curses.curs_set(False) # hide cursor self.to_send = [] - self.edit = EditWidget(self, (0, 6), (1, 14)) - self.turn = TurnWidget(self, (2, 6), (1, 14)) - self.log = LogWidget(self, (4, 0), (None, 20)) - self.map_ = MapWidget(self, (0, 21), (None, None)) + self.edit = EditWidget(self, (0, 6), (1, 14), check_tui = ['edit']) + self.turn = TurnWidget(self, (2, 6), (1, 14), ['turn']) + self.log = LogWidget(self, (4, 0), (None, 20), ['log']) + self.map_ = MapWidget(self, (0, 21), (None, None), ['map']) widgets = (self.edit, self.turn, self.log, self.map_) while True: for w in widgets: - if w.do_update: - w.draw_and_refresh() - w.do_update = False + w.ensure_freshness() + for key in self.game.to_update: + self.game.to_update[key] = False + for key in self.to_update: + self.to_update[key] = False try: key = self.stdscr.getkey() if len(key) == 1 and key in ASCII_printable and \ len(self.to_send) < len(self.edit): self.to_send += [key] - self.edit.do_update = True + self.to_update['edit'] = True elif key == 'KEY_BACKSPACE': self.to_send[:] = self.to_send[:-1] - self.edit.do_update = True + self.to_update['edit'] = True elif key == '\n': - plom_socket_io.send(s, ''.join(self.to_send)) + plom_socket_io.send(self.socket, ''.join(self.to_send)) self.to_send[:] = [] - self.edit.do_update = True + self.to_update['edit'] = True elif key == 'KEY_RESIZE': curses.endwin() self.setup_screen(curses.initscr()) for w in widgets: w.size = w.size_def - w.do_update = True + w.ensure_freshness(True) except curses.error: pass - if len(self.server_output) > 0: - do_quit = self.handle_input(self.server_output[0]) - if do_quit: - break - self.server_output[:] = [] - self.do_update = True - - def handle_input(self, msg): - if msg == 'BYE': - return True - try: - command = self.parser.parse(msg) - if command is None: - self.game.log('UNHANDLED INPUT: ' + msg) - self.log.do_update = True - else: - command() - except ArgError as e: - self.game.log('ARGUMENT ERROR: ' + msg + '\n' + str(e)) - self.log.do_update = True - return False + if self.game.do_quit: + break -server_output = [] s = socket.create_connection(('127.0.0.1', 5000)) -t = threading.Thread(target=recv_loop, args=(server_output,)) +game = Game() +t = threading.Thread(target=recv_loop, args=(s, game)) t.start() -TUI(server_output) +TUI(s, game) -- 2.30.2 From b871c0e19ee9d91c780f42e0bae35dab605e1ad1 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Fri, 25 Jan 2019 01:27:22 +0100 Subject: [PATCH 05/16] Refactor map drawing code in curses client. --- client-curses.py | 180 +++++++++++++++++++++++++++-------------------- 1 file changed, 102 insertions(+), 78 deletions(-) diff --git a/client-curses.py b/client-curses.py index a2441f6..6846fc5 100755 --- a/client-curses.py +++ b/client-curses.py @@ -7,65 +7,70 @@ from parser import ArgError, Parser import game_common -class MapSquare(game_common.Map): - - def list_terrain_to_lines(self, terrain_as_list, center, size): - terrain = ''.join(terrain_as_list) - map_lines = [] - start_cut = 0 - while start_cut < len(terrain): - limit = start_cut + self.size[1] - map_lines += [terrain[start_cut:limit]] - start_cut = limit - if len(map_lines) > size[0] and center[0] > size[0] / 2: - diff = len(map_lines) - size[0] - if center[0] > len(map_lines) - size[0] / 2: - map_lines = map_lines[diff:] +class Map(game_common.Map): + + def y_cut(self, map_lines, center_y, view_height): + map_height = len(map_lines) + if map_height > view_height and center_y > view_height / 2: + if center_y > map_height - view_height / 2: + map_lines = map_lines[map_height - view_height:] else: - start = center[0] - int(size[0] / 2) - map_lines = map_lines[start:start + size[0]] - if self.size[1] > size[1] and center[1] > size[1] / 2: - if center[1] > self.size[1] - size[1] / 2: - cut_start = self.size[1] - size[1] + start = center_y - int(view_height / 2) + map_lines[:] = map_lines[start:start + view_height] + + def x_cut(self, map_lines, center_x, view_width): + map_width = len(map_lines[0]) + if map_width > view_width and center_x > view_width / 2: + if center_x > map_width - view_width / 2: + cut_start = map_width - view_width cut_end = None else: - cut_start = center[1] - int(size[1] / 2) - cut_end = cut_start + size[1] - map_lines = [line[cut_start:cut_end] for line in map_lines] + cut_start = center_x - int(view_width / 2) + cut_end = cut_start + view_width + map_lines[:] = [line[cut_start:cut_end] for line in map_lines] + + +class MapSquare(Map): + + def format_to_view(self, map_string, center, size): + + def map_string_to_lines(map_string): + map_lines = [] + start_cut = 0 + while start_cut < len(map_string): + limit = start_cut + self.size[1] + map_lines += [map_string[start_cut:limit]] + start_cut = limit + return map_lines + + map_lines = map_string_to_lines(map_string) + self.y_cut(map_lines, center[0], size[0]) + self.x_cut(map_lines, center[1], size[1]) return map_lines -class MapHex(game_common.Map): - - def list_terrain_to_lines(self, terrain_as_list, center, size): - new_terrain_list = [' '] - x = 0 - y = 0 - for c in terrain_as_list: - new_terrain_list += [c, ' '] - x += 1 - if x == self.size[1]: - new_terrain_list += ['\n'] - x = 0 - y += 1 - if y % 2 == 0: - new_terrain_list += [' '] - map_lines = ''.join(new_terrain_list).split('\n') - if len(map_lines) > size[0] and center[0] > size[0] / 2: - diff = len(map_lines) - size[0] - if center[0] > len(map_lines) - size[0] / 2: - map_lines = map_lines[diff:] - else: - start = center[0] - int(size[0] / 2) - map_lines = map_lines[start:start + size[0]] - if self.size[1]*2 > size[1] and center[1]*4 > size[1]: - if center[1]*2 > self.size[1]*2 - size[1] / 2: - cut_start = self.size[1] * 2 - size[1] - cut_end = None - else: - cut_start = center[1]*2 - int(size[1] / 2) - cut_end = cut_start + size[1] - map_lines = [line[cut_start:cut_end] for line in map_lines] +class MapHex(Map): + + def format_to_view(self, map_string, center, size): + + def map_string_to_lines(map_string): + map_view_chars = [' '] + x = 0 + y = 0 + for c in map_string: + map_view_chars += [c, ' '] + x += 1 + if x == self.size[1]: + map_view_chars += ['\n'] + x = 0 + y += 1 + if y % 2 == 0: + map_view_chars += [' '] + return ''.join(map_view_chars).split('\n') + + map_lines = map_string_to_lines(map_string) + self.y_cut(map_lines, center[0], size[0]) + self.x_cut(map_lines, center[1] * 2, size[1]) return map_lines @@ -94,9 +99,9 @@ class Game(game_common.CommonCommandsMixin): self.world = World(self) self.log_text = '' self.to_update = { - 'log': False, - 'map': False, - 'turn': False, + 'log': True, + 'map': True, + 'turn': True, } self.do_quit = False @@ -269,36 +274,55 @@ class LogWidget(Widget): class MapWidget(Widget): def draw(self): - to_join = [] - if len(self.tui.game.world.map_.terrain) > 0: + + def terrain_with_objects(): terrain_as_list = list(self.tui.game.world.map_.terrain[:]) for t in self.tui.game.world.things: pos_i = self.tui.game.world.map_.get_position_index(t.position) terrain_as_list[pos_i] = self.tui.game.symbol_for_type(t.type_) - center = self.tui.game.world.player_position - lines = self.tui.game.world.map_.list_terrain_to_lines(terrain_as_list, center, self.size) + return ''.join(terrain_as_list) + + def pad_or_cut_x(lines): line_width = self.size[1] - for line in lines: + for y in range(len(lines)): + line = lines[y] if line_width > len(line): to_pad = line_width - (len(line) % line_width) - to_join += [line + '0' * to_pad] + lines[y] = line + '0' * to_pad else: - to_join += [line[:line_width]] - if len(to_join) < self.size[0]: - to_pad = self.size[0] - len(to_join) - to_join += to_pad * ['0' * self.size[1]] - text = ''.join(to_join) - text_as_list = [] - for c in text: - if c in {'@', 'm'}: - text_as_list += [(c, curses.color_pair(1))] - elif c == '.': - text_as_list += [(c, curses.color_pair(2))] - elif c in {'x', 'X', '#'}: - text_as_list += [(c, curses.color_pair(3))] - else: - text_as_list += [c] - self.safe_write(text_as_list) + lines[y] = line[:line_width] + + def pad_y(lines): + if len(lines) < self.size[0]: + to_pad = self.size[0] - len(lines) + lines += to_pad * ['0' * self.size[1]] + + def lines_to_colored_chars(lines): + chars_with_attrs = [] + for c in ''.join(lines): + if c in {'@', 'm'}: + chars_with_attrs += [(c, curses.color_pair(1))] + elif c == '.': + chars_with_attrs += [(c, curses.color_pair(2))] + elif c in {'x', 'X', '#'}: + chars_with_attrs += [(c, curses.color_pair(3))] + else: + chars_with_attrs += [c] + return chars_with_attrs + + if self.tui.game.world.map_.terrain == '': + lines = [] + pad_y(lines) + self.safe_write(''.join(lines)) + return + + terrain_with_objects = terrain_with_objects() + center = self.tui.game.world.player_position + lines = self.tui.game.world.map_.format_to_view(terrain_with_objects, + center, self.size) + pad_or_cut_x(lines) + pad_y(lines) + self.safe_write(lines_to_colored_chars(lines)) class TurnWidget(Widget): -- 2.30.2 From 362ee8651a250d99377806c33451ceb5b027c606 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Fri, 25 Jan 2019 01:53:12 +0100 Subject: [PATCH 06/16] Add map navigation key shortcuts. --- client-curses.py | 53 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/client-curses.py b/client-curses.py index 6846fc5..e942dd8 100755 --- a/client-curses.py +++ b/client-curses.py @@ -123,6 +123,7 @@ class Game(game_common.CommonCommandsMixin): def log(self, msg): """Prefix msg plus newline to self.log_text.""" self.log_text = msg + '\n' + self.log_text + self.to_update['log'] = True def symbol_for_type(self, type_): symbol = '?' @@ -135,7 +136,6 @@ class Game(game_common.CommonCommandsMixin): def cmd_LAST_PLAYER_TASK_RESULT(self, msg): if msg != "success": self.log(msg) - self.to_update['log'] = True cmd_LAST_PLAYER_TASK_RESULT.argtypes = 'string' def cmd_TURN_FINISHED(self, n): @@ -359,6 +359,7 @@ class TUI: self.log = LogWidget(self, (4, 0), (None, 20), ['log']) self.map_ = MapWidget(self, (0, 21), (None, None), ['map']) widgets = (self.edit, self.turn, self.log, self.map_) + map_mode = False while True: for w in widgets: w.ensure_freshness() @@ -368,23 +369,49 @@ class TUI: self.to_update[key] = False try: key = self.stdscr.getkey() - if len(key) == 1 and key in ASCII_printable and \ - len(self.to_send) < len(self.edit): - self.to_send += [key] - self.to_update['edit'] = True - elif key == 'KEY_BACKSPACE': - self.to_send[:] = self.to_send[:-1] - self.to_update['edit'] = True - elif key == '\n': - plom_socket_io.send(self.socket, ''.join(self.to_send)) - self.to_send[:] = [] - self.to_update['edit'] = True - elif key == 'KEY_RESIZE': + if key == 'KEY_RESIZE': curses.endwin() self.setup_screen(curses.initscr()) for w in widgets: w.size = w.size_def w.ensure_freshness(True) + elif key == '\t': # Tabulator key. + map_mode = False if map_mode else True + elif map_mode: + if type(self.game.world.map_) == MapSquare: + if key == 'a': + plom_socket_io.send(self.socket, 'MOVE LEFT') + elif key == 'd': + plom_socket_io.send(self.socket, 'MOVE RIGHT') + elif key == 'w': + plom_socket_io.send(self.socket, 'MOVE UP') + elif key == 's': + plom_socket_io.send(self.socket, 'MOVE DOWN') + elif type(self.game.world.map_) == MapHex: + if key == 'w': + plom_socket_io.send(self.socket, 'MOVE UPLEFT') + elif key == 'e': + plom_socket_io.send(self.socket, 'MOVE UPRIGHT') + if key == 's': + plom_socket_io.send(self.socket, 'MOVE LEFT') + elif key == 'd': + plom_socket_io.send(self.socket, 'MOVE RIGHT') + if key == 'x': + plom_socket_io.send(self.socket, 'MOVE DOWNLEFT') + elif key == 'c': + plom_socket_io.send(self.socket, 'MOVE DOWNRIGHT') + else: + if len(key) == 1 and key in ASCII_printable and \ + len(self.to_send) < len(self.edit): + self.to_send += [key] + self.to_update['edit'] = True + elif key == 'KEY_BACKSPACE': + self.to_send[:] = self.to_send[:-1] + self.to_update['edit'] = True + elif key == '\n': # Return key + plom_socket_io.send(self.socket, ''.join(self.to_send)) + self.to_send[:] = [] + self.to_update['edit'] = True except curses.error: pass if self.game.do_quit: -- 2.30.2 From 8c9d8857d363d9e3faf11eac9df2f96abab359aa Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Fri, 25 Jan 2019 02:15:46 +0100 Subject: [PATCH 07/16] Fix map scrolling bug in client. --- client-curses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client-curses.py b/client-curses.py index e942dd8..28d912d 100755 --- a/client-curses.py +++ b/client-curses.py @@ -13,7 +13,7 @@ class Map(game_common.Map): map_height = len(map_lines) if map_height > view_height and center_y > view_height / 2: if center_y > map_height - view_height / 2: - map_lines = map_lines[map_height - view_height:] + map_lines[:] = map_lines[map_height - view_height:] else: start = center_y - int(view_height / 2) map_lines[:] = map_lines[start:start + view_height] -- 2.30.2 From e4a1c6b657cc76cb8344b2633e2a469c21b3137c Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Fri, 25 Jan 2019 03:01:38 +0100 Subject: [PATCH 08/16] Fix map drawing / scrolling bugs in client. --- client-curses.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/client-curses.py b/client-curses.py index 28d912d..fa35ffd 100755 --- a/client-curses.py +++ b/client-curses.py @@ -15,11 +15,10 @@ class Map(game_common.Map): if center_y > map_height - view_height / 2: map_lines[:] = map_lines[map_height - view_height:] else: - start = center_y - int(view_height / 2) + start = center_y - int(view_height / 2) - 1 map_lines[:] = map_lines[start:start + view_height] - def x_cut(self, map_lines, center_x, view_width): - map_width = len(map_lines[0]) + def x_cut(self, map_lines, center_x, view_width, map_width): if map_width > view_width and center_x > view_width / 2: if center_x > map_width - view_width / 2: cut_start = map_width - view_width @@ -45,7 +44,7 @@ class MapSquare(Map): map_lines = map_string_to_lines(map_string) self.y_cut(map_lines, center[0], size[0]) - self.x_cut(map_lines, center[1], size[1]) + self.x_cut(map_lines, center[1], size[1], self.size[1]) return map_lines @@ -54,7 +53,7 @@ class MapHex(Map): def format_to_view(self, map_string, center, size): def map_string_to_lines(map_string): - map_view_chars = [' '] + map_view_chars = ['+'] x = 0 y = 0 for c in map_string: @@ -65,12 +64,16 @@ class MapHex(Map): x = 0 y += 1 if y % 2 == 0: - map_view_chars += [' '] + map_view_chars += ['+'] + if y % 2 == 0: + map_view_chars = map_view_chars[:-1] + map_view_chars = map_view_chars[:-1] return ''.join(map_view_chars).split('\n') map_lines = map_string_to_lines(map_string) self.y_cut(map_lines, center[0], size[0]) - self.x_cut(map_lines, center[1] * 2, size[1]) + map_width = self.size[1] * 2 + 1 + self.x_cut(map_lines, center[1] * 2, size[1], map_width) return map_lines -- 2.30.2 From 8ddccb6e5601f8df54f9cbc4c376ad76aee2fe78 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Fri, 25 Jan 2019 03:44:35 +0100 Subject: [PATCH 09/16] Fix FOV floating point bugs by using fractions.Fraction for fractions. --- server_/map_.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server_/map_.py b/server_/map_.py index 6e14c5a..291130c 100644 --- a/server_/map_.py +++ b/server_/map_.py @@ -2,6 +2,7 @@ import sys sys.path.append('../') import game_common import server_.game +import fractions class Map(game_common.Map): @@ -170,7 +171,7 @@ class MapFovHex(MapHex): self.shadow_cones += [cone] #print('DEBUG', yx) - step_size = (CIRCLE/6)/distance_to_center + step_size = fractions.Fraction(CIRCLE, 6) / distance_to_center number_steps = dir_i * distance_to_center + dir_progress left_arm = correct_arm(-(step_size/2) - step_size*number_steps) right_arm = correct_arm(left_arm - step_size) @@ -288,7 +289,7 @@ class MapFovSquare(MapSquare): self.shadow_cones += [cone] #print('DEBUG', yx) - step_size = (CIRCLE/4)/distance_to_center + step_size = fractions.Fraction(CIRCLE, 4) / distance_to_center number_steps = dir_i * distance_to_center + dir_progress left_arm = correct_arm(-(step_size/2) - step_size*number_steps) right_arm = correct_arm(left_arm - step_size) -- 2.30.2 From 3ce85894ae195febfb4ab24a0ab4faccabf931fd Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Fri, 25 Jan 2019 04:06:20 +0100 Subject: [PATCH 10/16] In client, avoid showing half-finished game states. --- client-curses.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client-curses.py b/client-curses.py index fa35ffd..ca907db 100755 --- a/client-curses.py +++ b/client-curses.py @@ -53,7 +53,7 @@ class MapHex(Map): def format_to_view(self, map_string, center, size): def map_string_to_lines(map_string): - map_view_chars = ['+'] + map_view_chars = ['0'] x = 0 y = 0 for c in map_string: @@ -64,7 +64,7 @@ class MapHex(Map): x = 0 y += 1 if y % 2 == 0: - map_view_chars += ['+'] + map_view_chars += ['0'] if y % 2 == 0: map_view_chars = map_view_chars[:-1] map_view_chars = map_view_chars[:-1] @@ -150,6 +150,8 @@ class Game(game_common.CommonCommandsMixin): """Set self.turn to n, empty self.things.""" self.world.turn = n self.world.things = [] + self.to_update['turn'] = False + self.to_update['map'] = False cmd_NEW_TURN.argtypes = 'int:nonneg' def cmd_VISIBLE_MAP_LINE(self, y, terrain_line): -- 2.30.2 From 990fe3a33887e1f44c25884d7d45c71337efd2f4 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Fri, 25 Jan 2019 05:18:56 +0100 Subject: [PATCH 11/16] Use math.isclose() to fix FOV bug instead of expensive Fraction. --- server_/map_.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/server_/map_.py b/server_/map_.py index 291130c..836e540 100644 --- a/server_/map_.py +++ b/server_/map_.py @@ -2,7 +2,7 @@ import sys sys.path.append('../') import game_common import server_.game -import fractions +import math class Map(game_common.Map): @@ -143,13 +143,15 @@ class MapFovHex(MapHex): def merge_cone(new_cone): for old_cone in self.shadow_cones: if new_cone[0] > old_cone[0] and \ - new_cone[1] <= old_cone[0]: + (new_cone[1] < old_cone[0] or + math.isclose(new_cone[1], old_cone[0])): #print('DEBUG merging to', old_cone) old_cone[0] = new_cone[0] #print('DEBUG merged cone:', old_cone) return True if new_cone[1] < old_cone[1] and \ - new_cone[0] >= old_cone[1]: + (new_cone[0] > old_cone[1] or + math.isclose(new_cone[0], old_cone[1])): #print('DEBUG merging to', old_cone) old_cone[1] = new_cone[1] #print('DEBUG merged cone:', old_cone) @@ -157,7 +159,6 @@ class MapFovHex(MapHex): return False def eval_cone(cone): - new_cone = [left_arm, right_arm] #print('DEBUG CONE', cone, '(', step_size, distance_to_center, number_steps, ')') if in_shadow_cone(cone): return @@ -171,7 +172,7 @@ class MapFovHex(MapHex): self.shadow_cones += [cone] #print('DEBUG', yx) - step_size = fractions.Fraction(CIRCLE, 6) / distance_to_center + step_size = (CIRCLE/6) / distance_to_center number_steps = dir_i * distance_to_center + dir_progress left_arm = correct_arm(-(step_size/2) - step_size*number_steps) right_arm = correct_arm(left_arm - step_size) -- 2.30.2 From 2053e9aa263eb090dea751af36e1ac8369eb3021 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Fri, 25 Jan 2019 05:27:01 +0100 Subject: [PATCH 12/16] Use math.isclose for square map FOVs too. --- server_/map_.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/server_/map_.py b/server_/map_.py index 836e540..96021be 100644 --- a/server_/map_.py +++ b/server_/map_.py @@ -262,13 +262,15 @@ class MapFovSquare(MapSquare): def merge_cone(new_cone): for old_cone in self.shadow_cones: if new_cone[0] > old_cone[0] and \ - new_cone[1] <= old_cone[0]: + (new_cone[1] < old_cone[0] or + math.isclose(new_cone[1], old_cone[0])): #print('DEBUG merging to', old_cone) old_cone[0] = new_cone[0] #print('DEBUG merged cone:', old_cone) return True if new_cone[1] < old_cone[1] and \ - new_cone[0] >= old_cone[1]: + (new_cone[0] > old_cone[1] or + math.isclose(new_cone[0], old_cone[1])): #print('DEBUG merging to', old_cone) old_cone[1] = new_cone[1] #print('DEBUG merged cone:', old_cone) @@ -290,7 +292,7 @@ class MapFovSquare(MapSquare): self.shadow_cones += [cone] #print('DEBUG', yx) - step_size = fractions.Fraction(CIRCLE, 4) / distance_to_center + step_size = (CIRCLE/4) / distance_to_center number_steps = dir_i * distance_to_center + dir_progress left_arm = correct_arm(-(step_size/2) - step_size*number_steps) right_arm = correct_arm(left_arm - step_size) -- 2.30.2 From 4542a12bf12101e60aedea9bb9df098573bf0d25 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Fri, 25 Jan 2019 20:23:50 +0100 Subject: [PATCH 13/16] Add very basic pathfinding AI. --- server_/game.py | 68 ++++++++++++++++++++++++++++++++++++++++++++----- server_/map_.py | 50 +++++++++++++++++++++++++++++++++--- 2 files changed, 108 insertions(+), 10 deletions(-) diff --git a/server_/game.py b/server_/game.py index df6a9f2..968d301 100644 --- a/server_/game.py +++ b/server_/game.py @@ -105,16 +105,70 @@ class Thing(game_common.Thing): return 'success' def decide_task(self): - #if self.position[1] > 1: - # self.set_task('move', 'LEFT') - #elif self.position[1] < 3: - # self.set_task('move', 'RIGHT') - #else: - self.set_task('wait') + visible_things = self.get_visible_things() + target = None + for t in visible_things: + if t.type_ == 'human': + target = t.position + break + if target is None: + self.set_task('wait') + return + dijkstra_map = type(self.world.map_)(self.world.map_.size) + n_max = 256 + dijkstra_map.terrain = [n_max for i in range(dijkstra_map.size_i)] + dijkstra_map[target] = 0 + shrunk = True + while shrunk: + shrunk = False + for pos in dijkstra_map: + if self.world.map_[pos] != '.': + continue + neighbors = dijkstra_map.get_neighbors(pos) + for yx in neighbors: + if yx is not None and dijkstra_map[yx] < dijkstra_map[pos] - 1: + dijkstra_map[pos] = dijkstra_map[yx] + 1 + shrunk = True + #with open('log', 'a') as f: + # f.write('---------------------------------\n') + # for y, line in dijkstra_map.lines(): + # for val in line: + # if val < 10: + # f.write(str(val)) + # elif val == 256: + # f.write('x') + # else: + # f.write('~') + # f.write('\n') + neighbors = dijkstra_map.get_neighbors(self.position) + n = n_max + dirs = dijkstra_map.get_directions() + #print('DEBUG dirs', dirs) + #print('DEBUG neighbors', neighbors) + #debug_scores = [] + #for pos in neighbors: + # if pos is None: + # debug_scores += [9000] + # else: + # debug_scores += [dijkstra_map[pos]] + #print('DEBUG debug_scores', debug_scores) + direction = None + for i_dir in range(len(neighbors)): + pos = neighbors[i_dir] + if pos is not None and dijkstra_map[pos] < n: + n = dijkstra_map[pos] + direction = dirs[i_dir] + #print('DEBUG result', direction) + if direction: + self.set_task('move', direction=direction) + #self.world.game.io.send('would move ' + direction) + else: + self.set_task('wait') + def set_task(self, task_name, *args, **kwargs): self.task = Task(self, task_name, args, kwargs) - self.task.check() + self.task.check() # will throw GameError if necessary def proceed(self, is_AI=True): """Further the thing in its tasks. diff --git a/server_/map_.py b/server_/map_.py index 96021be..2e87fc5 100644 --- a/server_/map_.py +++ b/server_/map_.py @@ -12,7 +12,10 @@ class Map(game_common.Map): def __setitem__(self, yx, c): pos_i = self.get_position_index(yx) - self.terrain = self.terrain[:pos_i] + c + self.terrain[pos_i + 1:] + if type(c) == str: + self.terrain = self.terrain[:pos_i] + c + self.terrain[pos_i + 1:] + else: + self.terrain[pos_i] = c def __iter__(self): """Iterate over YX position coordinates.""" @@ -97,9 +100,9 @@ class MapHex(Map): def move_DOWNLEFT(self, start_pos): if start_pos[0] % 2 == 1: - return [start_pos[0] + 1, start_pos[1] - 1] + return [start_pos[0] + 1, start_pos[1] - 1] else: - return [start_pos[0] + 1, start_pos[1]] + return [start_pos[0] + 1, start_pos[1]] def move_DOWNRIGHT(self, start_pos): if start_pos[0] % 2 == 1: @@ -107,6 +110,34 @@ class MapHex(Map): else: return [start_pos[0] + 1, start_pos[1] + 1] + def get_neighbors(self, pos): + # DOWNLEFT, DOWNRIGHT, LEFT, RIGHT, UPLEFT, UPRIGHT (alphabetically) + neighbors = [None, None, None, None, None, None] # e, d, c, x, s, w + if pos[1] > 0: + neighbors[2] = [pos[0], pos[1] - 1] + if pos[1] < self.size[1] - 1: + neighbors[3] = [pos[0], pos[1] + 1] + # x, c, s, d, w, e # 3->0, 2->1, 5->4, 0->5 + if pos[0] % 2 == 1: + if pos[0] > 0 and pos[1] > 0: + neighbors[4] = [pos[0] - 1, pos[1] - 1] + if pos[0] < self.size[0] - 1 and pos[1] > 0: + neighbors[0] = [pos[0] + 1, pos[1] - 1] + if pos[0] > 0: + neighbors[5] = [pos[0] - 1, pos[1]] + if pos[0] < self.size[0] - 1: + neighbors[1] = [pos[0] + 1, pos[1]] + else: + if pos[0] > 0 and pos[1] < self.size[1] - 1: + neighbors[5] = [pos[0] - 1, pos[1] + 1] + if pos[0] < self.size[0] - 1 and pos[1] < self.size[1] - 1: + neighbors[1] = [pos[0] + 1, pos[1] + 1] + if pos[0] > 0: + neighbors[4] = [pos[0] - 1, pos[1]] + if pos[0] < self.size[0] - 1: + neighbors[0] = [pos[0] + 1, pos[1]] + return neighbors + class MapFovHex(MapHex): @@ -231,6 +262,19 @@ class MapSquare(Map): def move_DOWN(self, start_pos): return [start_pos[0] + 1, start_pos[1]] + def get_neighbors(self, pos): + # DOWN, LEFT, RIGHT, UP (alphabetically) + neighbors = [None, None, None, None] + if pos[0] > 0: + neighbors[3] = [pos[0] - 1, pos[1]] + if pos[1] > 0: + neighbors[1] = [pos[0], pos[1] - 1] + if pos[0] < self.size[0] - 1: + neighbors[0] = [pos[0] + 1, pos[1]] + if pos[1] < self.size[1] - 1: + neighbors[2] = [pos[0], pos[1] + 1] + return neighbors + class MapFovSquare(MapSquare): """Just a marginally and unsatisfyingly adapted variant of MapFovHex.""" -- 2.30.2 From 95d1750224859e91b35b6917d010eb06614e72dc Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Fri, 25 Jan 2019 20:37:25 +0100 Subject: [PATCH 14/16] Fix AI getting stuck in illegal move attempt. --- server_/game.py | 44 ++++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/server_/game.py b/server_/game.py index 968d301..6c283b5 100644 --- a/server_/game.py +++ b/server_/game.py @@ -83,10 +83,12 @@ class Task: direction = self.kwargs['direction'] test_pos = self.thing.world.map_.move(self.thing.position, direction) if self.thing.world.map_[test_pos] != '.': - raise GameError('would move into illegal terrain') + raise GameError(str(self.thing.id_) + + ' would move into illegal terrain') for t in self.thing.world.things: if t.position == test_pos: - raise GameError('would move into other thing') + raise GameError(str(self.thing.id_) + + ' would move into other thing') class Thing(game_common.Thing): @@ -104,16 +106,7 @@ class Thing(game_common.Thing): self.position = self.world.map_.move(self.position, direction) return 'success' - def decide_task(self): - visible_things = self.get_visible_things() - target = None - for t in visible_things: - if t.type_ == 'human': - target = t.position - break - if target is None: - self.set_task('wait') - return + def move_towards_target(self, target): dijkstra_map = type(self.world.map_)(self.world.map_.size) n_max = 256 dijkstra_map.terrain = [n_max for i in range(dijkstra_map.size_i)] @@ -162,8 +155,21 @@ class Thing(game_common.Thing): if direction: self.set_task('move', direction=direction) #self.world.game.io.send('would move ' + direction) - else: - self.set_task('wait') + + def decide_task(self): + visible_things = self.get_visible_things() + target = None + for t in visible_things: + if t.type_ == 'human': + target = t.position + break + if target is not None: + try: + self.move_towards_target(target) + return + except GameError: + pass + self.set_task('wait') def set_task(self, task_name, *args, **kwargs): @@ -189,7 +195,10 @@ class Thing(game_common.Thing): self.task = None self.last_task_result = e if is_AI: - self.decide_task() + try: + self.decide_task() + except GameError: + self.set_task('wait') return self.task.todo -= 1 if self.task.todo <= 0: @@ -197,7 +206,10 @@ class Thing(game_common.Thing): self.last_task_result = task(*self.task.args, **self.task.kwargs) self.task = None if is_AI and self.task is None: - self.decide_task() + try: + self.decide_task() + except GameError: + self.set_task('wait') def get_stencil(self): if self._stencil is not None: -- 2.30.2 From 2476c32efd4ec85fafae42c556e9f1139f209f7e Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Fri, 25 Jan 2019 20:52:25 +0100 Subject: [PATCH 15/16] Add SWITCH_PLAYER debugging command for switching player. --- server_/game.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/server_/game.py b/server_/game.py index 6c283b5..5ba3ab2 100644 --- a/server_/game.py +++ b/server_/game.py @@ -335,6 +335,17 @@ class Game(game_common.CommonCommandsMixin): self.proceed() cmd_MOVE.argtypes = 'string' + def cmd_SWITCH_PLAYER(self): + player = self.world.get_player() + player.set_task('wait') + thing_ids = [t.id_ for t in self.world.things] + player_index = thing_ids.index(player.id_) + if player_index == len(thing_ids) - 1: + self.world.player_id = thing_ids[0] + else: + self.world.player_id = thing_ids[player_index + 1] + self.proceed() + def cmd_WAIT(self): """Set player task to 'wait', finish player turn.""" self.world.get_player().set_task('wait') -- 2.30.2 From 6237227d771fa5b4cfbcdffb8c74457e2d406bb1 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Sat, 26 Jan 2019 22:42:04 +0100 Subject: [PATCH 16/16] To command parser, add string options; use TASK syntax for task commands. --- client-curses.py | 39 ++++++++++++++------- game_common.py | 6 +--- parser.py | 46 +++++++++++++++---------- server_/game.py | 89 ++++++++++++++++++++++++++---------------------- 4 files changed, 105 insertions(+), 75 deletions(-) diff --git a/client-curses.py b/client-curses.py index ca907db..9a8178d 100755 --- a/client-curses.py +++ b/client-curses.py @@ -108,6 +108,21 @@ class Game(game_common.CommonCommandsMixin): } self.do_quit = False + def get_command_signature(self, command_name): + method_candidate = 'cmd_' + command_name + method = None + argtypes = '' + if hasattr(self, method_candidate): + method = getattr(self, method_candidate) + if hasattr(method, 'argtypes'): + argtypes = method.argtypes + return method, argtypes + + def get_string_options(self, string_option_type): + if string_option_type == 'geometry': + return self.map_manager.get_map_geometries() + return None + def handle_input(self, msg): if msg == 'BYE': self.do_quit = True @@ -120,8 +135,8 @@ class Game(game_common.CommonCommandsMixin): else: command() except ArgError as e: - self.log('ARGUMENT ERROR: ' + msg + '\n' + str(e)) - self.to_update['log'] = True + self.log('ARGUMENT ERROR: ' + msg + '\n' + str(e)) + self.to_update['log'] = True def log(self, msg): """Prefix msg plus newline to self.log_text.""" @@ -385,26 +400,26 @@ class TUI: elif map_mode: if type(self.game.world.map_) == MapSquare: if key == 'a': - plom_socket_io.send(self.socket, 'MOVE LEFT') + plom_socket_io.send(self.socket, 'TASK:MOVE LEFT') elif key == 'd': - plom_socket_io.send(self.socket, 'MOVE RIGHT') + plom_socket_io.send(self.socket, 'TASK:MOVE RIGHT') elif key == 'w': - plom_socket_io.send(self.socket, 'MOVE UP') + plom_socket_io.send(self.socket, 'TASK:MOVE UP') elif key == 's': - plom_socket_io.send(self.socket, 'MOVE DOWN') + plom_socket_io.send(self.socket, 'TASK:MOVE DOWN') elif type(self.game.world.map_) == MapHex: if key == 'w': - plom_socket_io.send(self.socket, 'MOVE UPLEFT') + plom_socket_io.send(self.socket, 'TASK:MOVE UPLEFT') elif key == 'e': - plom_socket_io.send(self.socket, 'MOVE UPRIGHT') + plom_socket_io.send(self.socket, 'TASK:MOVE UPRIGHT') if key == 's': - plom_socket_io.send(self.socket, 'MOVE LEFT') + plom_socket_io.send(self.socket, 'TASK:MOVE LEFT') elif key == 'd': - plom_socket_io.send(self.socket, 'MOVE RIGHT') + plom_socket_io.send(self.socket, 'TASK:MOVE RIGHT') if key == 'x': - plom_socket_io.send(self.socket, 'MOVE DOWNLEFT') + plom_socket_io.send(self.socket, 'TASK:MOVE DOWNLEFT') elif key == 'c': - plom_socket_io.send(self.socket, 'MOVE DOWNRIGHT') + plom_socket_io.send(self.socket, 'TASK:MOVE DOWNRIGHT') else: if len(key) == 1 and key in ASCII_printable and \ len(self.to_send) < len(self.edit): diff --git a/game_common.py b/game_common.py index 1119ce1..3c17bd1 100644 --- a/game_common.py +++ b/game_common.py @@ -80,12 +80,8 @@ class CommonCommandsMixin: def cmd_MAP(self, geometry, yx): """Create new map of grid geometry, size yx and only '?' cells.""" - legal_grids = self.map_manager.get_map_geometries() - if geometry not in legal_grids: - raise ArgError('First map argument must be one of: ' + - ', '.join(legal_grids)) self.world.new_map(geometry, yx) - cmd_MAP.argtypes = 'string yx_tuple:pos' + cmd_MAP.argtypes = 'string:geometry yx_tuple:pos' def cmd_THING_TYPE(self, i, type_): t = self.world.get_thing(i) diff --git a/parser.py b/parser.py index 38283bb..2292f88 100644 --- a/parser.py +++ b/parser.py @@ -45,27 +45,25 @@ class Parser: return tokens def parse(self, msg): - """Parse msg as call to self.game method, return method with arguments. + """Parse msg as call to method, return method with arguments. Respects method signatures defined in methods' .argtypes attributes. """ tokens = self.tokenize(msg) if len(tokens) == 0: return None - method_candidate = 'cmd_' + tokens[0] - if not hasattr(self.game, method_candidate): + method, argtypes = self.game.get_command_signature(tokens[0]) + if method is None: return None - method = getattr(self.game, method_candidate) + if len(argtypes) == 0: + if len(tokens) > 1: + raise ArgError('Command expects no argument(s).') + return method if len(tokens) == 1: - if not hasattr(method, 'argtypes'): - return method - else: - raise ArgError('Command expects argument(s).') + raise ArgError('Command expects argument(s).') args_candidates = tokens[1:] - if not hasattr(method, 'argtypes'): - raise ArgError('Command expects no argument(s).') - args, kwargs = self.argsparse(method.argtypes, args_candidates) - return partial(method, *args, **kwargs) + args = self.argsparse(argtypes, args_candidates) + return partial(method, *args) def parse_yx_tuple(self, yx_string, range_): """Parse yx_string as yx_tuple:nonneg argtype, return result. @@ -93,11 +91,12 @@ class Parser: return (y, x) def argsparse(self, signature, args_tokens): - """Parse into / return args_tokens as args/kwargs defined by signature. + """Parse into / return args_tokens as args defined by signature. Expects signature to be a ' '-delimited sequence of any of the strings 'int:nonneg', 'yx_tuple:nonneg', 'yx_tuple:pos', 'string', - 'seq:int:nonneg', defining the respective argument types. + 'seq:int:nonneg', 'string:' + an option type string accepted by + self.game.get_string_options, defining the respective argument types. """ tmpl_tokens = signature.split() if len(tmpl_tokens) != len(args_tokens): @@ -105,6 +104,7 @@ class Parser: ') 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] @@ -116,8 +116,6 @@ class Parser: args += [self.parse_yx_tuple(arg, 'nonneg')] elif tmpl == 'yx_tuple:pos': args += [self.parse_yx_tuple(arg, 'pos')] - elif tmpl == 'string': - args += [arg] elif tmpl == 'seq:int:nonneg': sub_tokens = arg.split(',') if len(sub_tokens) < 1: @@ -129,9 +127,22 @@ class Parser: 'non-negative integers.') seq += [int(tok)] args += [seq] + 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.') - return args, {} + return args class TestParser(unittest.TestCase): @@ -156,7 +167,6 @@ class TestParser(unittest.TestCase): self.assertEqual(p.parse('x'), None) def test_argsparse(self): - from functools import partial p = Parser() assertErr = partial(self.assertRaises, ArgError, p.argsparse) assertErr('', ['foo']) diff --git a/server_/game.py b/server_/game.py index 5ba3ab2..958bdc8 100644 --- a/server_/game.py +++ b/server_/game.py @@ -68,20 +68,15 @@ class World(game_common.World): class Task: - def __init__(self, thing, name, args=(), kwargs={}): + def __init__(self, thing, name, args=()): self.name = name self.thing = thing self.args = args - self.kwargs = kwargs self.todo = 3 def check(self): - if self.name == 'move': - if len(self.args) > 0: - direction = self.args[0] - else: - direction = self.kwargs['direction'] - test_pos = self.thing.world.map_.move(self.thing.position, direction) + if self.name == 'MOVE': + test_pos = self.thing.world.map_.move(self.thing.position, self.args[0]) if self.thing.world.map_[test_pos] != '.': raise GameError(str(self.thing.id_) + ' would move into illegal terrain') @@ -95,16 +90,17 @@ class Thing(game_common.Thing): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.task = Task(self, 'wait') + self.task = Task(self, 'WAIT') self.last_task_result = None self._stencil = None - def task_wait(self): + def task_WAIT(self): return 'success' - def task_move(self, direction): + def task_MOVE(self, direction): self.position = self.world.map_.move(self.position, direction) return 'success' + task_MOVE.argtypes = 'string:direction' def move_towards_target(self, target): dijkstra_map = type(self.world.map_)(self.world.map_.size) @@ -153,7 +149,7 @@ class Thing(game_common.Thing): direction = dirs[i_dir] #print('DEBUG result', direction) if direction: - self.set_task('move', direction=direction) + self.set_task('MOVE', (direction,)) #self.world.game.io.send('would move ' + direction) def decide_task(self): @@ -169,11 +165,11 @@ class Thing(game_common.Thing): return except GameError: pass - self.set_task('wait') + self.set_task('WAIT') - def set_task(self, task_name, *args, **kwargs): - self.task = Task(self, task_name, args, kwargs) + def set_task(self, task_name, args=()): + self.task = Task(self, task_name, args) self.task.check() # will throw GameError if necessary def proceed(self, is_AI=True): @@ -198,18 +194,18 @@ class Thing(game_common.Thing): try: self.decide_task() except GameError: - self.set_task('wait') + self.set_task('WAIT') return self.task.todo -= 1 if self.task.todo <= 0: task = getattr(self, 'task_' + self.task.name) - self.last_task_result = task(*self.task.args, **self.task.kwargs) + self.last_task_result = task(*self.task.args) self.task = None if is_AI and self.task is None: try: self.decide_task() except GameError: - self.set_task('wait') + self.set_task('WAIT') def get_stencil(self): if self._stencil is not None: @@ -324,20 +320,9 @@ class Game(game_common.CommonCommandsMixin): self.send_gamestate() self.pool_result = self.pool.map_async(fib, (35, 35)) - def cmd_MOVE(self, direction): - """Set player task to 'move' with direction arg, finish player turn.""" - import parser - legal_directions = self.world.map_.get_directions() - if direction not in legal_directions: - raise parser.ArgError('Move argument must be one of: ' + - ', '.join(legal_directions)) - self.world.get_player().set_task('move', direction=direction) - self.proceed() - cmd_MOVE.argtypes = 'string' - def cmd_SWITCH_PLAYER(self): player = self.world.get_player() - player.set_task('wait') + player.set_task('WAIT') thing_ids = [t.id_ for t in self.world.things] player_index = thing_ids.index(player.id_) if player_index == len(thing_ids) - 1: @@ -346,11 +331,6 @@ class Game(game_common.CommonCommandsMixin): self.world.player_id = thing_ids[player_index + 1] self.proceed() - def cmd_WAIT(self): - """Set player task to 'wait', finish player turn.""" - self.world.get_player().set_task('wait') - self.proceed() - def cmd_GET_GAMESTATE(self, connection_id): """Send game state to caller.""" self.send_gamestate(connection_id) @@ -370,9 +350,38 @@ class Game(game_common.CommonCommandsMixin): cmd_TERRAIN_LINE.argtypes = 'int:nonneg string' def cmd_GEN_WORLD(self, geometry, yx, seed): - legal_grids = self.map_manager.get_map_geometries() - if geometry not in legal_grids: - raise ArgError('First map argument must be one of: ' + - ', '.join(legal_grids)) self.world.make_new(geometry, yx, seed) - cmd_GEN_WORLD.argtypes = 'string yx_tuple:pos string' + cmd_GEN_WORLD.argtypes = 'string:geometry yx_tuple:pos string' + + def get_command_signature(self, command_name): + from functools import partial + + def cmd_TASK_colon(task_name, *args): + self.world.get_player().set_task(task_name, args) + self.proceed() + + method = None + argtypes = '' + task_prefix = 'TASK:' + if command_name[:len(task_prefix)] == task_prefix: + task_name = command_name[len(task_prefix):] + task_method_candidate = 'task_' + task_name + if hasattr(Thing, task_method_candidate): + method = partial(cmd_TASK_colon, task_name) + task_method = getattr(Thing, task_method_candidate) + if hasattr(task_method, 'argtypes'): + argtypes = task_method.argtypes + return method, argtypes + method_candidate = 'cmd_' + command_name + if hasattr(self, method_candidate): + method = getattr(self, method_candidate) + if hasattr(method, 'argtypes'): + argtypes = method.argtypes + return method, argtypes + + def get_string_options(self, string_option_type): + if string_option_type == 'geometry': + return self.map_manager.get_map_geometries() + elif string_option_type == 'direction': + return self.world.map_.get_directions() + return None -- 2.30.2