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:
324 if t.position == self.tui.examiner_position:
329 class PopUpWidget(Widget):
332 self.safe_write(self.tui.popup_text)
334 def reconfigure(self):
335 size = (1, len(self.tui.popup_text))
338 offset_y = int((self.tui.stdscr.getmaxyx()[0] / 2) - (size[0] / 2))
339 offset_x = int((self.tui.stdscr.getmaxyx()[1] / 2) - (size[1] / 2))
340 self.start = (offset_y, offset_x)
341 self.win.mvwin(self.start[0], self.start[1])
344 class ItemsSelectorWidget(Widget):
346 def __init__(self, headline, selection, *args, **kwargs):
347 super().__init__(*args, **kwargs)
348 self.headline = headline
349 self.selection = selection
351 def ensure_freshness(self, *args, **kwargs):
352 # We only update pointer on non-empty selection so that the zero-ing
353 # of the selection at TURN_FINISHED etc. before pulling in a new
354 # state does not destroy any memory of previous item pointer positions.
355 if len(self.selection) > 0 and\
356 len(self.selection) < self.tui.item_pointer + 1 and\
357 self.tui.item_pointer > 0:
358 self.tui.item_pointer = max(0, len(self.selection) - 1)
359 self.tui.to_update[self.check_updates[0]] = True
360 super().ensure_freshness(*args, **kwargs)
363 lines = [self.headline]
365 for id_ in self.selection:
366 pointer = '*' if counter == self.tui.item_pointer else ' '
367 t = self.tui.game.world.get_thing(id_)
368 lines += ['%s %s' % (pointer, t.type_)]
370 line_width = self.size[1]
373 to_pad = line_width - (len(line) % line_width)
374 if to_pad == line_width:
376 to_join += [line + ' '*to_pad]
377 self.safe_write((''.join(to_join), curses.color_pair(3)))
380 class MapWidget(Widget):
384 def annotated_terrain():
385 terrain_as_list = list(self.tui.game.world.map_.terrain[:])
386 for t in self.tui.game.world.things:
387 pos_i = self.tui.game.world.map_.get_position_index(t.position)
388 symbol = self.tui.game.symbol_for_type(t.type_)
389 if terrain_as_list[pos_i][0] in {'f', '@', 'm'}:
390 old_symbol = terrain_as_list[pos_i][0]
391 if old_symbol in {'@', 'm'}:
393 terrain_as_list[pos_i] = (symbol, '+')
395 terrain_as_list[pos_i] = symbol
396 if self.tui.examiner_mode:
397 pos_i = self.tui.game.world.map_.\
398 get_position_index(self.tui.examiner_position)
399 terrain_as_list[pos_i] = (terrain_as_list[pos_i][0], '?')
400 return terrain_as_list
402 def pad_or_cut_x(lines):
403 line_width = self.size[1]
404 for y in range(len(lines)):
406 if line_width > len(line):
407 to_pad = line_width - (len(line) % line_width)
408 lines[y] = line + '0' * to_pad
410 lines[y] = line[:line_width]
413 if len(lines) < self.size[0]:
414 to_pad = self.size[0] - len(lines)
415 lines += to_pad * ['0' * self.size[1]]
417 def lines_to_colored_chars(lines):
418 chars_with_attrs = []
419 for c in ''.join(lines):
421 chars_with_attrs += [(c, curses.color_pair(1))]
423 chars_with_attrs += [(c, curses.color_pair(4))]
425 chars_with_attrs += [(c, curses.color_pair(2))]
426 elif c in {'x', 'X', '#'}:
427 chars_with_attrs += [(c, curses.color_pair(3))]
429 chars_with_attrs += [(c, curses.color_pair(5))]
431 chars_with_attrs += [c]
432 return chars_with_attrs
434 if self.tui.game.world.map_.terrain == '':
437 self.safe_write(''.join(lines))
440 annotated_terrain = annotated_terrain()
441 center = self.tui.game.world.player.position
442 if self.tui.examiner_mode:
443 center = self.tui.examiner_position
444 lines = self.tui.game.world.map_.format_to_view(annotated_terrain,
448 self.safe_write(lines_to_colored_chars(lines))
451 class TurnWidget(Widget):
454 self.safe_write((str(self.tui.game.world.turn), curses.color_pair(2)))
457 class HealthWidget(Widget):
460 if hasattr(self.tui.game.world.player, 'health'):
461 self.safe_write((str(self.tui.game.world.player.health),
462 curses.color_pair(2)))
465 class TextLineWidget(Widget):
467 def __init__(self, text_line, *args, **kwargs):
468 self.text_line = text_line
469 super().__init__(*args, **kwargs)
472 self.safe_write(self.text_line)
477 def __init__(self, plom_socket, game, q):
478 self.socket = plom_socket
482 self.parser = Parser(self.game)
484 self.item_pointer = 0
485 self.examiner_position = (0, 0)
486 self.examiner_mode = False
487 self.popup_text = 'Hi bob'
489 self.draw_popup_if_visible = True
490 curses.wrapper(self.loop)
492 def loop(self, stdscr):
494 def setup_screen(stdscr):
496 self.stdscr.refresh() # will be called by getkey else, clearing screen
497 self.stdscr.timeout(10)
499 def switch_widgets(widget_1, widget_2):
500 widget_1.visible = False
501 widget_2.visible = True
502 trigger = widget_2.check_updates[0]
503 self.to_update[trigger] = True
505 def selectables_menu(key, widget, selectables, f):
507 switch_widgets(widget, map_widget)
509 self.item_pointer += 1
510 elif key == 'k' and self.item_pointer > 0:
511 self.item_pointer -= 1
512 elif not f(key, selectables):
514 trigger = widget.check_updates[0]
515 self.to_update[trigger] = True
517 def pickup_menu(key):
519 def f(key, selectables):
520 if key == 'p' and len(selectables) > 0:
521 id_ = selectables[self.item_pointer]
522 self.socket.send('TASK:PICKUP %s' % id_)
523 self.socket.send('GET_PICKABLE_ITEMS')
528 selectables_menu(key, pickable_items_widget,
529 self.game.world.pickable_items, f)
531 def inventory_menu(key):
533 def f(key, selectables):
534 if key == 'd' and len(selectables) > 0:
535 id_ = selectables[self.item_pointer]
536 self.socket.send('TASK:DROP %s' % id_)
537 elif key == 'e' and len(selectables) > 0:
538 id_ = selectables[self.item_pointer]
539 self.socket.send('TASK:EAT %s' % id_)
544 selectables_menu(key, inventory_widget,
545 self.game.world.player_inventory, f)
547 def move_examiner(direction):
548 start_pos = self.examiner_position
549 new_examine_pos = self.game.world.map_.move(start_pos, direction)
551 self.examiner_position = new_examine_pos
552 self.to_update['map'] = True
554 def switch_to_pick_or_drop(target_widget):
555 self.item_pointer = 0
556 switch_widgets(map_widget, target_widget)
557 if self.examiner_mode:
558 self.examiner_mode = False
559 switch_widgets(descriptor_widget, log_widget)
561 def toggle_examiner_mode():
562 if self.examiner_mode:
563 self.examiner_mode = False
564 switch_widgets(descriptor_widget, log_widget)
566 self.examiner_mode = True
567 self.examiner_position = self.game.world.player.position
568 switch_widgets(log_widget, descriptor_widget)
569 self.to_update['map'] = True
572 if popup_widget.visible:
573 popup_widget.visible = False
574 for w in top_widgets:
575 w.ensure_freshness(True)
577 self.to_update['popup'] = True
578 popup_widget.visible = True
579 popup_widget.reconfigure()
580 self.draw_popup_if_visible = True
582 def try_write_keys():
583 if len(key) == 1 and key in ASCII_printable and \
584 len(self.to_send) < len(edit_line_widget):
585 self.to_send += [key]
586 self.to_update['edit'] = True
587 elif key == 'KEY_BACKSPACE':
588 self.to_send[:] = self.to_send[:-1]
589 self.to_update['edit'] = True
590 elif key == '\n': # Return key
591 self.socket.send(''.join(self.to_send))
593 self.to_update['edit'] = True
595 def try_examiner_keys():
597 move_examiner('UPLEFT')
599 move_examiner('UPRIGHT')
601 move_examiner('LEFT')
603 move_examiner('RIGHT')
605 move_examiner('DOWNLEFT')
607 move_examiner('DOWNRIGHT')
609 def try_player_move_keys():
611 self.socket.send('TASK:MOVE UPLEFT')
613 self.socket.send('TASK:MOVE UPRIGHT')
615 self.socket.send('TASK:MOVE LEFT')
617 self.socket.send('TASK:MOVE RIGHT')
619 self.socket.send('TASK:MOVE DOWNLEFT')
621 self.socket.send('TASK:MOVE DOWNRIGHT')
624 curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_RED)
625 curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN)
626 curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_BLUE)
627 curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW)
628 curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_WHITE)
630 # Basic curses initialization work.
632 curses.curs_set(False) # hide cursor
635 # With screen initialized, set up widgets with their curses windows.
636 edit_widget = TextLineWidget('SEND:', self, (0, 0), (1, 20))
637 edit_line_widget = EditWidget(self, (0, 6), (1, 14), ['edit'])
638 edit_widget.children += [edit_line_widget]
639 turn_widget = TextLineWidget('TURN:', self, (2, 0), (1, 20))
640 turn_widget.children += [TurnWidget(self, (2, 6), (1, 14), ['turn'])]
641 health_widget = TextLineWidget('HEALTH:', self, (3, 0), (1, 20))
642 health_widget.children += [HealthWidget(self, (3, 8), (1, 12), ['turn'])]
643 log_widget = LogWidget(self, (5, 0), (None, 20), ['log'])
644 descriptor_widget = DescriptorWidget(self, (5, 0), (None, 20),
646 map_widget = MapWidget(self, (0, 21), (None, None), ['map'])
647 inventory_widget = ItemsSelectorWidget('INVENTORY:',
648 self.game.world.player_inventory,
649 self, (0, 21), (None,
650 None), ['inventory'],
652 pickable_items_widget = ItemsSelectorWidget('PICKABLE:',
653 self.game.world.pickable_items,
658 top_widgets = [edit_widget, turn_widget, health_widget, log_widget,
659 descriptor_widget, map_widget, inventory_widget,
660 pickable_items_widget]
661 popup_widget = PopUpWidget(self, (0, 0), (1, 1), visible=False)
663 # Ensure initial window state before loop starts.
664 for w in top_widgets:
665 w.ensure_freshness(True)
666 self.socket.send('GET_GAMESTATE')
671 for w in top_widgets:
672 if w.ensure_freshness():
673 self.draw_popup_if_visible = True
674 if popup_widget.visible and self.draw_popup_if_visible:
675 popup_widget.ensure_freshness(True)
676 self.draw_popup_if_visible = False
677 for k in self.to_update.keys():
678 self.to_update[k] = False
680 # Handle input from server.
683 command = self.queue.get(block=False)
686 self.game.handle_input(command)
688 # Handle keys (and resize event read as key).
690 key = self.stdscr.getkey()
691 if key == 'KEY_RESIZE':
693 setup_screen(curses.initscr())
694 for w in top_widgets:
696 w.ensure_freshness(True)
697 elif key == '\t': # Tabulator key.
698 write_mode = False if write_mode else True
703 elif map_widget.visible:
705 toggle_examiner_mode()
707 self.socket.send('GET_PICKABLE_ITEMS')
708 switch_to_pick_or_drop(pickable_items_widget)
710 switch_to_pick_or_drop(inventory_widget)
711 elif self.examiner_mode:
714 try_player_move_keys()
715 elif pickable_items_widget.visible:
717 elif inventory_widget.visible:
722 # Quit when server recommends it.
723 if self.game.do_quit:
727 s = socket.create_connection(('127.0.0.1', 5000))
728 plom_socket = PlomSocket(s)
731 t = threading.Thread(target=recv_loop, args=(plom_socket, game, q))
733 TUI(plom_socket, game, q)