5 from plomrogue.parser import ArgError, Parser
6 from plomrogue.commands import (cmd_MAP, cmd_THING_POS, cmd_PLAYER_ID,
8 from plomrogue.game import Game, WorldBase
9 from plomrogue.mapping import MapHex
10 from plomrogue.io import PlomSocket
11 from plomrogue.things import ThingBase
16 class ClientMap(MapHex):
18 def y_cut(self, map_lines, center_y, view_height):
19 map_height = len(map_lines)
20 if map_height > view_height and center_y > view_height / 2:
21 if center_y > map_height - view_height / 2:
22 map_lines[:] = map_lines[map_height - view_height:]
24 start = center_y - int(view_height / 2) - 1
25 map_lines[:] = map_lines[start:start + view_height]
27 def x_cut(self, map_lines, center_x, view_width, map_width):
28 if map_width > view_width and center_x > view_width / 2:
29 if center_x > map_width - view_width / 2:
30 cut_start = map_width - view_width
33 cut_start = center_x - int(view_width / 2)
34 cut_end = cut_start + view_width
35 map_lines[:] = [line[cut_start:cut_end] for line in map_lines]
37 def format_to_view(self, map_cells, center, size):
39 def map_cells_to_lines(map_cells):
40 map_view_chars = ['0']
43 for cell in map_cells:
45 map_view_chars += [cell, ' ']
47 map_view_chars += [cell[0], cell[1]]
50 map_view_chars += ['\n']
54 map_view_chars += ['0']
56 map_view_chars = map_view_chars[:-1]
57 map_view_chars = map_view_chars[:-1]
58 return ''.join(map_view_chars).split('\n')
60 map_lines = map_cells_to_lines(map_cells)
61 self.y_cut(map_lines, center[0], size[0])
62 map_width = self.size[1] * 2 + 1
63 self.x_cut(map_lines, center[1] * 2, size[1], map_width)
67 class World(WorldBase):
69 def __init__(self, *args, **kwargs):
70 """Extend original with local classes and empty default map.
72 We need the empty default map because we draw the map widget
73 on any update, even before we actually receive map data.
75 super().__init__(*args, **kwargs)
76 self.map_ = ClientMap()
77 self.player_inventory = []
79 self.pickable_items = []
81 def new_map(self, yx):
82 self.map_ = ClientMap(yx)
86 return self.get_thing(self.player_id)
89 def cmd_LAST_PLAYER_TASK_RESULT(game, msg):
92 cmd_LAST_PLAYER_TASK_RESULT.argtypes = 'string'
95 def cmd_TURN_FINISHED(game, n):
96 """Do nothing. (This may be extended later.)"""
98 cmd_TURN_FINISHED.argtypes = 'int:nonneg'
101 def cmd_TURN(game, n):
102 """Set game.turn to n, empty game.things."""
104 game.world.things = []
105 game.world.pickable_items[:] = []
106 cmd_TURN.argtypes = 'int:nonneg'
109 def cmd_VISIBLE_MAP_LINE(game, y, terrain_line):
110 game.world.map_.set_line(y, terrain_line)
111 cmd_VISIBLE_MAP_LINE.argtypes = 'int:nonneg string'
114 def cmd_GAME_STATE_COMPLETE(game):
115 game.tui.to_update['turn'] = True
116 game.tui.to_update['map'] = True
117 game.tui.to_update['inventory'] = True
120 def cmd_THING_TYPE(game, i, type_):
121 t = game.world.get_thing(i)
123 cmd_THING_TYPE.argtypes = 'int:nonneg string'
126 def cmd_PLAYER_INVENTORY(game, ids):
127 game.world.player_inventory[:] = ids # TODO: test whether valid IDs
128 game.tui.to_update['inventory'] = True
129 cmd_PLAYER_INVENTORY.argtypes = 'seq:int:nonneg'
132 def cmd_PICKABLE_ITEMS(game, ids):
133 game.world.pickable_items[:] = ids
134 game.tui.to_update['pickable_items'] = True
135 cmd_PICKABLE_ITEMS.argtypes = 'seq:int:nonneg'
141 self.parser = Parser(self)
142 self.world = World(self)
143 self.thing_type = ThingBase
144 self.commands = {'LAST_PLAYER_TASK_RESULT': cmd_LAST_PLAYER_TASK_RESULT,
145 'TURN_FINISHED': cmd_TURN_FINISHED,
147 'VISIBLE_MAP_LINE': cmd_VISIBLE_MAP_LINE,
148 'PLAYER_ID': cmd_PLAYER_ID,
149 'PLAYER_INVENTORY': cmd_PLAYER_INVENTORY,
150 'GAME_STATE_COMPLETE': cmd_GAME_STATE_COMPLETE,
152 'PICKABLE_ITEMS': cmd_PICKABLE_ITEMS,
153 'THING_TYPE': cmd_THING_TYPE,
154 'THING_HEALTH': cmd_THING_HEALTH,
155 'THING_POS': cmd_THING_POS}
160 def get_command(self, command_name):
161 from functools import partial
162 if command_name in self.commands:
163 f = partial(self.commands[command_name], self)
164 if hasattr(self.commands[command_name], 'argtypes'):
165 f.argtypes = self.commands[command_name].argtypes
169 def get_string_options(self, string_option_type):
172 def handle_input(self, msg):
178 command, args = self.parser.parse(msg)
180 self.log('UNHANDLED INPUT: ' + msg)
183 except ArgError as e:
184 self.log('ARGUMENT ERROR: ' + msg + '\n' + str(e))
187 """Prefix msg plus newline to self.log_text."""
188 self.log_text = msg + '\n' + self.log_text
189 self.tui.to_update['log'] = True
191 def symbol_for_type(self, type_):
195 elif type_ == 'monster':
197 elif type_ == 'food':
202 ASCII_printable = ' !"#$%&\'\(\)*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWX'\
203 'YZ[\\]^_\`abcdefghijklmnopqrstuvwxyz{|}~'
206 def recv_loop(plom_socket, game, q):
207 for msg in plom_socket.recv():
213 def __init__(self, tui, start, size, check_updates=[], visible=True):
214 self.check_updates = check_updates
217 self.win = curses.newwin(1, 1, self.start[0], self.start[1])
218 self.size_def = size # store for re-calling .size on SIGWINCH
220 self.do_update = True
221 self.visible = visible
226 return self.win.getmaxyx()
229 def size(self, size):
230 """Set window size. Size be y,x tuple. If y or x None, use legal max."""
231 n_lines, n_cols = size
233 n_lines = self.tui.stdscr.getmaxyx()[0] - self.start[0]
235 n_cols = self.tui.stdscr.getmaxyx()[1] - self.start[1]
236 self.win.resize(n_lines, n_cols)
239 return self.win.getmaxyx()[0] * self.win.getmaxyx()[1]
241 def safe_write(self, foo):
243 def to_chars_with_attrs(part):
244 attr = curses.A_NORMAL
246 if not type(part) == str:
247 part_string = part[0]
249 return [(char, attr) for char in part_string]
251 chars_with_attrs = []
252 if type(foo) == str or (len(foo) == 2 and type(foo[1]) == int):
253 chars_with_attrs += to_chars_with_attrs(foo)
256 chars_with_attrs += to_chars_with_attrs(part)
258 if len(chars_with_attrs) < len(self):
259 for char_with_attr in chars_with_attrs:
260 self.win.addstr(char_with_attr[0], char_with_attr[1])
261 else: # workaround to <https://stackoverflow.com/q/7063128>
262 cut = chars_with_attrs[:len(self) - 1]
263 last_char_with_attr = chars_with_attrs[len(self) - 1]
264 self.win.addstr(self.size[0] - 1, self.size[1] - 2,
265 last_char_with_attr[0], last_char_with_attr[1])
266 self.win.insstr(self.size[0] - 1, self.size[1] - 2, ' ')
268 for char_with_attr in cut:
269 self.win.addstr(char_with_attr[0], char_with_attr[1])
271 def ensure_freshness(self, do_refresh=False):
275 for key in self.check_updates:
276 if key in self.tui.to_update and self.tui.to_update[key]:
284 for child in self.children:
285 did_refresh = child.ensure_freshness(do_refresh) | did_refresh
289 class EditWidget(Widget):
292 self.safe_write((''.join(self.tui.to_send), curses.color_pair(1)))
295 class TextLinesWidget(Widget):
298 lines = self.get_text_lines()
299 line_width = self.size[1]
302 to_pad = line_width - (len(line) % line_width)
303 if to_pad == line_width:
305 to_join += [line + ' '*to_pad]
306 self.safe_write((''.join(to_join), curses.color_pair(3)))
309 class LogWidget(TextLinesWidget):
311 def get_text_lines(self):
312 return self.tui.game.log_text.split('\n')
315 class DescriptorWidget(TextLinesWidget):
317 def get_text_lines(self):
319 pos_i = self.tui.game.world.map_.\
320 get_position_index(self.tui.examiner_position)
321 terrain = self.tui.game.world.map_.terrain[pos_i]
323 for t in self.tui.game.world.things_at_pos(self.tui.examiner_position):
328 class PopUpWidget(Widget):
331 self.safe_write(self.tui.popup_text)
333 def reconfigure(self):
334 size = (1, len(self.tui.popup_text))
337 offset_y = int((self.tui.stdscr.getmaxyx()[0] / 2) - (size[0] / 2))
338 offset_x = int((self.tui.stdscr.getmaxyx()[1] / 2) - (size[1] / 2))
339 self.start = (offset_y, offset_x)
340 self.win.mvwin(self.start[0], self.start[1])
343 class ItemsSelectorWidget(Widget):
345 def __init__(self, headline, selection, *args, **kwargs):
346 super().__init__(*args, **kwargs)
347 self.headline = headline
348 self.selection = selection
350 def ensure_freshness(self, *args, **kwargs):
351 # We only update pointer on non-empty selection so that the zero-ing
352 # of the selection at TURN_FINISHED etc. before pulling in a new
353 # state does not destroy any memory of previous item pointer positions.
354 if len(self.selection) > 0 and\
355 len(self.selection) < self.tui.item_pointer + 1 and\
356 self.tui.item_pointer > 0:
357 self.tui.item_pointer = max(0, len(self.selection) - 1)
358 self.tui.to_update[self.check_updates[0]] = True
359 super().ensure_freshness(*args, **kwargs)
362 lines = [self.headline]
364 for id_ in self.selection:
365 pointer = '*' if counter == self.tui.item_pointer else ' '
366 t = self.tui.game.world.get_thing(id_)
367 lines += ['%s %s' % (pointer, t.type_)]
369 line_width = self.size[1]
372 to_pad = line_width - (len(line) % line_width)
373 if to_pad == line_width:
375 to_join += [line + ' '*to_pad]
376 self.safe_write((''.join(to_join), curses.color_pair(3)))
379 class MapWidget(Widget):
383 def annotated_terrain():
384 terrain_as_list = list(self.tui.game.world.map_.terrain[:])
385 for t in self.tui.game.world.things:
386 pos_i = self.tui.game.world.map_.get_position_index(t.position)
387 symbol = self.tui.game.symbol_for_type(t.type_)
388 if terrain_as_list[pos_i][0] in {'f', '@', 'm'}:
389 old_symbol = terrain_as_list[pos_i][0]
390 if old_symbol in {'@', 'm'}:
392 terrain_as_list[pos_i] = (symbol, '+')
394 terrain_as_list[pos_i] = symbol
395 if self.tui.examiner_mode:
396 pos_i = self.tui.game.world.map_.\
397 get_position_index(self.tui.examiner_position)
398 terrain_as_list[pos_i] = (terrain_as_list[pos_i][0], '?')
399 return terrain_as_list
401 def pad_or_cut_x(lines):
402 line_width = self.size[1]
403 for y in range(len(lines)):
405 if line_width > len(line):
406 to_pad = line_width - (len(line) % line_width)
407 lines[y] = line + '0' * to_pad
409 lines[y] = line[:line_width]
412 if len(lines) < self.size[0]:
413 to_pad = self.size[0] - len(lines)
414 lines += to_pad * ['0' * self.size[1]]
416 def lines_to_colored_chars(lines):
417 chars_with_attrs = []
418 for c in ''.join(lines):
420 chars_with_attrs += [(c, curses.color_pair(1))]
422 chars_with_attrs += [(c, curses.color_pair(4))]
424 chars_with_attrs += [(c, curses.color_pair(2))]
425 elif c in {'x', 'X', '#'}:
426 chars_with_attrs += [(c, curses.color_pair(3))]
428 chars_with_attrs += [(c, curses.color_pair(5))]
430 chars_with_attrs += [c]
431 return chars_with_attrs
433 if self.tui.game.world.map_.terrain == '':
436 self.safe_write(''.join(lines))
439 annotated_terrain = annotated_terrain()
440 center = self.tui.game.world.player.position
441 if self.tui.examiner_mode:
442 center = self.tui.examiner_position
443 lines = self.tui.game.world.map_.format_to_view(annotated_terrain,
447 self.safe_write(lines_to_colored_chars(lines))
450 class TurnWidget(Widget):
453 self.safe_write((str(self.tui.game.world.turn), curses.color_pair(2)))
456 class HealthWidget(Widget):
459 if hasattr(self.tui.game.world.player, 'health'):
460 self.safe_write((str(self.tui.game.world.player.health),
461 curses.color_pair(2)))
464 class TextLineWidget(Widget):
466 def __init__(self, text_line, *args, **kwargs):
467 self.text_line = text_line
468 super().__init__(*args, **kwargs)
471 self.safe_write(self.text_line)
476 def __init__(self, plom_socket, game, q):
477 self.socket = plom_socket
481 self.parser = Parser(self.game)
483 self.item_pointer = 0
484 self.examiner_position = (0, 0)
485 self.examiner_mode = False
486 self.popup_text = 'Hi bob'
488 self.draw_popup_if_visible = True
489 curses.wrapper(self.loop)
491 def loop(self, stdscr):
493 def setup_screen(stdscr):
495 self.stdscr.refresh() # will be called by getkey else, clearing screen
496 self.stdscr.timeout(10)
498 def switch_widgets(widget_1, widget_2):
499 widget_1.visible = False
500 widget_2.visible = True
501 trigger = widget_2.check_updates[0]
502 self.to_update[trigger] = True
504 def selectables_menu(key, widget, selectables, f):
506 switch_widgets(widget, map_widget)
508 self.item_pointer += 1
509 elif key == 'k' and self.item_pointer > 0:
510 self.item_pointer -= 1
511 elif not f(key, selectables):
513 trigger = widget.check_updates[0]
514 self.to_update[trigger] = True
516 def pickup_menu(key):
518 def f(key, selectables):
519 if key == 'p' and len(selectables) > 0:
520 id_ = selectables[self.item_pointer]
521 self.socket.send('TASK:PICKUP %s' % id_)
522 self.socket.send('GET_PICKABLE_ITEMS')
527 selectables_menu(key, pickable_items_widget,
528 self.game.world.pickable_items, f)
530 def inventory_menu(key):
532 def f(key, selectables):
533 if key == 'd' and len(selectables) > 0:
534 id_ = selectables[self.item_pointer]
535 self.socket.send('TASK:DROP %s' % id_)
536 elif key == 'e' and len(selectables) > 0:
537 id_ = selectables[self.item_pointer]
538 self.socket.send('TASK:EAT %s' % id_)
543 selectables_menu(key, inventory_widget,
544 self.game.world.player_inventory, f)
546 def move_examiner(direction):
547 start_pos = self.examiner_position
548 new_examine_pos = self.game.world.map_.move(start_pos, direction)
550 self.examiner_position = new_examine_pos
551 self.to_update['map'] = True
553 def switch_to_pick_or_drop(target_widget):
554 self.item_pointer = 0
555 switch_widgets(map_widget, target_widget)
556 if self.examiner_mode:
557 self.examiner_mode = False
558 switch_widgets(descriptor_widget, log_widget)
560 def toggle_examiner_mode():
561 if self.examiner_mode:
562 self.examiner_mode = False
563 switch_widgets(descriptor_widget, log_widget)
565 self.examiner_mode = True
566 self.examiner_position = self.game.world.player.position
567 switch_widgets(log_widget, descriptor_widget)
568 self.to_update['map'] = True
571 if popup_widget.visible:
572 popup_widget.visible = False
573 for w in top_widgets:
574 w.ensure_freshness(True)
576 self.to_update['popup'] = True
577 popup_widget.visible = True
578 popup_widget.reconfigure()
579 self.draw_popup_if_visible = True
581 def try_write_keys():
582 if len(key) == 1 and key in ASCII_printable and \
583 len(self.to_send) < len(edit_line_widget):
584 self.to_send += [key]
585 self.to_update['edit'] = True
586 elif key == 'KEY_BACKSPACE':
587 self.to_send[:] = self.to_send[:-1]
588 self.to_update['edit'] = True
589 elif key == '\n': # Return key
590 self.socket.send(''.join(self.to_send))
592 self.to_update['edit'] = True
594 def try_examiner_keys():
596 move_examiner('UPLEFT')
598 move_examiner('UPRIGHT')
600 move_examiner('LEFT')
602 move_examiner('RIGHT')
604 move_examiner('DOWNLEFT')
606 move_examiner('DOWNRIGHT')
608 def try_player_move_keys():
610 self.socket.send('TASK:MOVE UPLEFT')
612 self.socket.send('TASK:MOVE UPRIGHT')
614 self.socket.send('TASK:MOVE LEFT')
616 self.socket.send('TASK:MOVE RIGHT')
618 self.socket.send('TASK:MOVE DOWNLEFT')
620 self.socket.send('TASK:MOVE DOWNRIGHT')
623 curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_RED)
624 curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN)
625 curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_BLUE)
626 curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW)
627 curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_WHITE)
629 # Basic curses initialization work.
631 curses.curs_set(False) # hide cursor
634 # With screen initialized, set up widgets with their curses windows.
635 edit_widget = TextLineWidget('SEND:', self, (0, 0), (1, 20))
636 edit_line_widget = EditWidget(self, (0, 6), (1, 14), ['edit'])
637 edit_widget.children += [edit_line_widget]
638 turn_widget = TextLineWidget('TURN:', self, (2, 0), (1, 20))
639 turn_widget.children += [TurnWidget(self, (2, 6), (1, 14), ['turn'])]
640 health_widget = TextLineWidget('HEALTH:', self, (3, 0), (1, 20))
641 health_widget.children += [HealthWidget(self, (3, 8), (1, 12), ['turn'])]
642 log_widget = LogWidget(self, (5, 0), (None, 20), ['log'])
643 descriptor_widget = DescriptorWidget(self, (5, 0), (None, 20),
645 map_widget = MapWidget(self, (0, 21), (None, None), ['map'])
646 inventory_widget = ItemsSelectorWidget('INVENTORY:',
647 self.game.world.player_inventory,
648 self, (0, 21), (None,
649 None), ['inventory'],
651 pickable_items_widget = ItemsSelectorWidget('PICKABLE:',
652 self.game.world.pickable_items,
657 top_widgets = [edit_widget, turn_widget, health_widget, log_widget,
658 descriptor_widget, map_widget, inventory_widget,
659 pickable_items_widget]
660 popup_widget = PopUpWidget(self, (0, 0), (1, 1), visible=False)
662 # Ensure initial window state before loop starts.
663 for w in top_widgets:
664 w.ensure_freshness(True)
665 self.socket.send('GET_GAMESTATE')
670 for w in top_widgets:
671 if w.ensure_freshness():
672 self.draw_popup_if_visible = True
673 if popup_widget.visible and self.draw_popup_if_visible:
674 popup_widget.ensure_freshness(True)
675 self.draw_popup_if_visible = False
676 for k in self.to_update.keys():
677 self.to_update[k] = False
679 # Handle input from server.
682 command = self.queue.get(block=False)
685 self.game.handle_input(command)
687 # Handle keys (and resize event read as key).
689 key = self.stdscr.getkey()
690 if key == 'KEY_RESIZE':
692 setup_screen(curses.initscr())
693 for w in top_widgets:
695 w.ensure_freshness(True)
696 elif key == '\t': # Tabulator key.
697 write_mode = False if write_mode else True
702 elif map_widget.visible:
704 toggle_examiner_mode()
706 self.socket.send('GET_PICKABLE_ITEMS')
707 switch_to_pick_or_drop(pickable_items_widget)
709 switch_to_pick_or_drop(inventory_widget)
710 elif self.examiner_mode:
713 try_player_move_keys()
714 elif pickable_items_widget.visible:
716 elif inventory_widget.visible:
721 # Quit when server recommends it.
722 if self.game.do_quit:
726 s = socket.create_connection(('127.0.0.1', 5000))
727 plom_socket = PlomSocket(s)
730 t = threading.Thread(target=recv_loop, args=(plom_socket, game, q))
732 TUI(plom_socket, game, q)