5 from plomrogue.parser import ArgError, Parser
6 from plomrogue.commands import cmd_PLAYER_ID, cmd_THING_HEALTH
7 from plomrogue.game import Game, WorldBase
8 from plomrogue.mapping import MapHex, YX
9 from plomrogue.io import PlomSocket
10 from plomrogue.things import ThingBase
15 class ClientMap(MapHex):
17 def y_cut(self, map_lines, center_y, view_height):
18 map_height = len(map_lines)
19 if map_height > view_height and center_y > view_height / 2:
20 if center_y > map_height - view_height / 2:
21 map_lines[:] = map_lines[map_height - view_height:]
23 start = center_y - int(view_height / 2) - 1
24 map_lines[:] = map_lines[start:start + view_height]
26 def x_cut(self, map_lines, center_x, view_width, map_width):
27 if map_width > view_width and center_x > view_width / 2:
28 if center_x > map_width - view_width / 2:
29 cut_start = map_width - view_width
32 cut_start = center_x - int(view_width / 2)
33 cut_end = cut_start + view_width
34 map_lines[:] = [line[cut_start:cut_end] for line in map_lines]
36 def format_to_view(self, map_cells, center, size):
38 def map_cells_to_lines(map_cells):
39 map_view_chars = ['0']
42 for cell in map_cells:
44 map_view_chars += [cell, ' ']
46 map_view_chars += [cell[0], cell[1]]
49 map_view_chars += ['\n']
53 map_view_chars += ['0']
55 map_view_chars = map_view_chars[:-1]
56 map_view_chars = map_view_chars[:-1]
57 return ''.join(map_view_chars).split('\n')
59 map_lines = map_cells_to_lines(map_cells)
60 if len(map_lines) % 2 == 0:
61 map_lines = map_lines[1:]
63 for i in range(len(map_lines)):
64 map_lines[i] = '0' + map_lines[i]
65 self.y_cut(map_lines, center.y, size.y)
66 map_width = self.size.x * 2 + 1
67 self.x_cut(map_lines, center.x * 2, size.x, map_width)
71 class World(WorldBase):
73 def __init__(self, *args, **kwargs):
74 """Extend original with local classes and empty default map.
76 We need the empty default map because we draw the map widget
77 on any update, even before we actually receive map data.
79 super().__init__(*args, **kwargs)
80 self.map_ = ClientMap()
82 self.player_inventory = []
84 self.pickable_items = []
86 def new_map(self, offset, size):
87 self.map_ = ClientMap(size)
92 return self.get_thing(self.player_id)
95 def cmd_LAST_PLAYER_TASK_RESULT(game, msg):
98 cmd_LAST_PLAYER_TASK_RESULT.argtypes = 'string'
101 def cmd_TURN_FINISHED(game, n):
102 """Do nothing. (This may be extended later.)"""
104 cmd_TURN_FINISHED.argtypes = 'int:nonneg'
107 def cmd_TURN(game, n):
108 """Set game.turn to n, empty game.things."""
110 game.world.things = []
111 game.world.pickable_items[:] = []
112 cmd_TURN.argtypes = 'int:nonneg'
115 def cmd_VISIBLE_MAP(game, offset, size):
116 game.world.new_map(offset, size)
117 cmd_VISIBLE_MAP.argtypes = 'yx_tuple yx_tuple:pos'
120 def cmd_VISIBLE_MAP_LINE(game, y, terrain_line):
121 game.world.map_.set_line(y, terrain_line)
122 cmd_VISIBLE_MAP_LINE.argtypes = 'int:nonneg string'
125 def cmd_GAME_STATE_COMPLETE(game):
126 game.tui.to_update['turn'] = True
127 game.tui.to_update['map'] = True
128 game.tui.to_update['inventory'] = True
131 def cmd_THING_TYPE(game, i, type_):
132 t = game.world.get_thing(i)
134 cmd_THING_TYPE.argtypes = 'int:nonneg string'
137 def cmd_THING_POS(game, i, yx):
138 t = game.world.get_thing(i)
140 cmd_THING_POS.argtypes = 'int:nonneg yx_tuple:nonneg'
143 def cmd_PLAYER_INVENTORY(game, ids):
144 game.world.player_inventory[:] = ids # TODO: test whether valid IDs
145 game.tui.to_update['inventory'] = True
146 cmd_PLAYER_INVENTORY.argtypes = 'seq:int:nonneg'
149 def cmd_PICKABLE_ITEMS(game, ids):
150 game.world.pickable_items[:] = ids
151 game.tui.to_update['pickable_items'] = True
152 cmd_PICKABLE_ITEMS.argtypes = 'seq:int:nonneg'
158 self.parser = Parser(self)
159 self.world = World(self)
160 self.thing_type = ThingBase
161 self.commands = {'LAST_PLAYER_TASK_RESULT': cmd_LAST_PLAYER_TASK_RESULT,
162 'TURN_FINISHED': cmd_TURN_FINISHED,
164 'VISIBLE_MAP_LINE': cmd_VISIBLE_MAP_LINE,
165 'PLAYER_ID': cmd_PLAYER_ID,
166 'PLAYER_INVENTORY': cmd_PLAYER_INVENTORY,
167 'GAME_STATE_COMPLETE': cmd_GAME_STATE_COMPLETE,
168 'VISIBLE_MAP': cmd_VISIBLE_MAP,
169 'PICKABLE_ITEMS': cmd_PICKABLE_ITEMS,
170 'THING_TYPE': cmd_THING_TYPE,
171 'THING_HEALTH': cmd_THING_HEALTH,
172 'THING_POS': cmd_THING_POS}
177 def get_command(self, command_name):
178 from functools import partial
179 if command_name in self.commands:
180 f = partial(self.commands[command_name], self)
181 if hasattr(self.commands[command_name], 'argtypes'):
182 f.argtypes = self.commands[command_name].argtypes
186 def get_string_options(self, string_option_type):
189 def handle_input(self, msg):
195 command, args = self.parser.parse(msg)
197 self.log('UNHANDLED INPUT: ' + msg)
200 except ArgError as e:
201 self.log('ARGUMENT ERROR: ' + msg + '\n' + str(e))
204 """Prefix msg plus newline to self.log_text."""
205 self.log_text = msg + '\n' + self.log_text
206 with open('log', 'w') as f:
207 f.write(self.log_text)
208 self.tui.to_update['log'] = True
210 def symbol_for_type(self, type_):
214 elif type_ == 'monster':
216 elif type_ == 'food':
221 ASCII_printable = ' !"#$%&\'\(\)*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWX'\
222 'YZ[\\]^_\`abcdefghijklmnopqrstuvwxyz{|}~'
225 def recv_loop(plom_socket, game, q):
226 for msg in plom_socket.recv():
232 def __init__(self, tui, start, size, check_updates=[], visible=True):
233 self.check_updates = check_updates
236 self.win = curses.newwin(1, 1, self.start.y, self.start.x)
237 self.size_def = size # store for re-calling .size on SIGWINCH
239 self.do_update = True
240 self.visible = visible
245 return YX(*self.win.getmaxyx())
248 def size(self, size):
249 """Set window size. Size be y,x tuple. If y or x None, use legal max."""
250 n_lines, n_cols = size
251 getmaxyx = YX(*self.tui.stdscr.getmaxyx())
253 n_lines = getmaxyx.y - self.start.y
255 n_cols = getmaxyx.x - self.start.x
256 self.win.resize(n_lines, n_cols)
259 getmaxyx = YX(*self.win.getmaxyx())
260 return getmaxyx.y * getmaxyx.x
262 def safe_write(self, foo):
264 def to_chars_with_attrs(part):
265 attr = curses.A_NORMAL
267 if not type(part) == str:
268 part_string = part[0]
270 return [(char, attr) for char in part_string]
272 chars_with_attrs = []
273 if type(foo) == str or (len(foo) == 2 and type(foo[1]) == int):
274 chars_with_attrs += to_chars_with_attrs(foo)
277 chars_with_attrs += to_chars_with_attrs(part)
279 if len(chars_with_attrs) < len(self):
280 for char_with_attr in chars_with_attrs:
281 self.win.addstr(char_with_attr[0], char_with_attr[1])
282 else: # workaround to <https://stackoverflow.com/q/7063128>
283 cut = chars_with_attrs[:len(self) - 1]
284 last_char_with_attr = chars_with_attrs[len(self) - 1]
285 self.win.addstr(self.size.y - 1, self.size.x - 2,
286 last_char_with_attr[0], last_char_with_attr[1])
287 self.win.insstr(self.size.y - 1, self.size.x - 2, ' ')
289 for char_with_attr in cut:
290 self.win.addstr(char_with_attr[0], char_with_attr[1])
292 def ensure_freshness(self, do_refresh=False):
296 for key in self.check_updates:
297 if key in self.tui.to_update and self.tui.to_update[key]:
305 for child in self.children:
306 did_refresh = child.ensure_freshness(do_refresh) | did_refresh
310 class EditWidget(Widget):
313 self.safe_write((''.join(self.tui.to_send), curses.color_pair(1)))
316 class TextLinesWidget(Widget):
319 lines = self.get_text_lines()
320 line_width = self.size.x
323 to_pad = line_width - (len(line) % line_width)
324 if to_pad == line_width:
326 to_join += [line + ' '*to_pad]
327 self.safe_write((''.join(to_join), curses.color_pair(3)))
330 class LogWidget(TextLinesWidget):
332 def get_text_lines(self):
333 return self.tui.game.log_text.split('\n')
336 class DescriptorWidget(TextLinesWidget):
338 def get_text_lines(self):
340 pos_i = self.tui.game.world.map_.\
341 get_position_index(self.tui.examiner_position)
342 terrain = self.tui.game.world.map_.terrain[pos_i]
344 for t in self.tui.game.world.things_at_pos(self.tui.examiner_position):
349 class PopUpWidget(Widget):
352 self.safe_write(self.tui.popup_text)
354 def reconfigure(self):
355 size = (1, len(self.tui.popup_text))
358 getmaxyx = YX(*self.tui.stdscr.getmaxyx())
359 offset_y = int(getmaxyx.y / 2 - size.y / 2)
360 offset_x = int(getmaxyx.x / 2 - size.x / 2)
361 self.start = YX(offset_y, offset_x)
362 self.win.mvwin(self.start.y, self.start.x)
365 class ItemsSelectorWidget(Widget):
367 def __init__(self, headline, selection, *args, **kwargs):
368 super().__init__(*args, **kwargs)
369 self.headline = headline
370 self.selection = selection
372 def ensure_freshness(self, *args, **kwargs):
373 # We only update pointer on non-empty selection so that the zero-ing
374 # of the selection at TURN_FINISHED etc. before pulling in a new
375 # state does not destroy any memory of previous item pointer positions.
376 if len(self.selection) > 0 and\
377 len(self.selection) < self.tui.item_pointer + 1 and\
378 self.tui.item_pointer > 0:
379 self.tui.item_pointer = max(0, len(self.selection) - 1)
380 self.tui.to_update[self.check_updates[0]] = True
381 super().ensure_freshness(*args, **kwargs)
384 lines = [self.headline]
386 for id_ in self.selection:
387 pointer = '*' if counter == self.tui.item_pointer else ' '
388 t = self.tui.game.world.get_thing(id_)
389 lines += ['%s %s' % (pointer, t.type_)]
391 line_width = self.size.x
394 to_pad = line_width - (len(line) % line_width)
395 if to_pad == line_width:
397 to_join += [line + ' '*to_pad]
398 self.safe_write((''.join(to_join), curses.color_pair(3)))
401 class MapWidget(Widget):
405 def annotated_terrain():
406 terrain_as_list = list(self.tui.game.world.map_.terrain[:])
407 for t in self.tui.game.world.things:
408 if t.id_ in self.tui.game.world.player_inventory:
410 pos_i = self.tui.game.world.map_.\
411 get_position_index(t.position)
412 symbol = self.tui.game.symbol_for_type(t.type_)
413 if terrain_as_list[pos_i][0] in {'f', '@', 'm'}:
414 old_symbol = terrain_as_list[pos_i][0]
415 if old_symbol in {'@', 'm'}:
417 terrain_as_list[pos_i] = (symbol, '+')
419 terrain_as_list[pos_i] = symbol
420 if self.tui.examiner_mode:
421 pos_i = self.tui.game.world.map_.\
422 get_position_index(self.tui.examiner_position)
423 terrain_as_list[pos_i] = (terrain_as_list[pos_i][0], '?')
424 return terrain_as_list
426 def pad_or_cut_x(lines):
427 line_width = self.size.x
428 for y in range(len(lines)):
430 if line_width > len(line):
431 to_pad = line_width - (len(line) % line_width)
432 lines[y] = line + '0' * to_pad
434 lines[y] = line[:line_width]
437 if len(lines) < self.size.y:
438 to_pad = self.size.y - len(lines)
439 lines += to_pad * ['0' * self.size.x]
441 def lines_to_colored_chars(lines):
442 chars_with_attrs = []
443 for c in ''.join(lines):
445 chars_with_attrs += [(c, curses.color_pair(1))]
447 chars_with_attrs += [(c, curses.color_pair(4))]
449 chars_with_attrs += [(c, curses.color_pair(2))]
450 elif c in {'x', 'X', '#'}:
451 chars_with_attrs += [(c, curses.color_pair(3))]
453 chars_with_attrs += [(c, curses.color_pair(5))]
455 chars_with_attrs += [c]
456 return chars_with_attrs
458 if self.tui.game.world.map_.terrain == '':
461 self.safe_write(''.join(lines))
464 annotated_terrain = annotated_terrain()
465 center = self.tui.game.world.player.position
466 if self.tui.examiner_mode:
467 center = self.tui.examiner_position
468 lines = self.tui.game.world.map_.\
469 format_to_view(annotated_terrain, center, self.size)
472 self.safe_write(lines_to_colored_chars(lines))
475 class TurnWidget(Widget):
478 self.safe_write((str(self.tui.game.world.turn), curses.color_pair(2)))
481 class HealthWidget(Widget):
484 if hasattr(self.tui.game.world.player, 'health'):
485 self.safe_write((str(self.tui.game.world.player.health),
486 curses.color_pair(2)))
489 class TextLineWidget(Widget):
491 def __init__(self, text_line, *args, **kwargs):
492 self.text_line = text_line
493 super().__init__(*args, **kwargs)
496 self.safe_write(self.text_line)
501 def __init__(self, plom_socket, game, q):
502 self.socket = plom_socket
506 self.parser = Parser(self.game)
508 self.item_pointer = 0
509 self.examiner_position = (YX(0,0), YX(0, 0))
510 self.examiner_mode = False
511 self.popup_text = 'Hi bob'
513 self.draw_popup_if_visible = True
514 curses.wrapper(self.loop)
516 def loop(self, stdscr):
518 def setup_screen(stdscr):
520 self.stdscr.refresh() # will be called by getkey else, clearing screen
521 self.stdscr.timeout(10)
523 def switch_widgets(widget_1, widget_2):
524 widget_1.visible = False
525 widget_2.visible = True
526 trigger = widget_2.check_updates[0]
527 self.to_update[trigger] = True
529 def selectables_menu(key, widget, selectables, f):
531 switch_widgets(widget, map_widget)
533 self.item_pointer += 1
534 elif key == 'k' and self.item_pointer > 0:
535 self.item_pointer -= 1
536 elif not f(key, selectables):
538 trigger = widget.check_updates[0]
539 self.to_update[trigger] = True
541 def pickup_menu(key):
543 def f(key, selectables):
544 if key == 'p' and len(selectables) > 0:
545 id_ = selectables[self.item_pointer]
546 self.socket.send('TASK:PICKUP %s' % id_)
547 self.socket.send('GET_PICKABLE_ITEMS')
552 selectables_menu(key, pickable_items_widget,
553 self.game.world.pickable_items, f)
555 def inventory_menu(key):
557 def f(key, selectables):
558 if key == 'd' and len(selectables) > 0:
559 id_ = selectables[self.item_pointer]
560 self.socket.send('TASK:DROP %s' % id_)
561 elif key == 'e' and len(selectables) > 0:
562 id_ = selectables[self.item_pointer]
563 self.socket.send('TASK:EAT %s' % id_)
568 selectables_menu(key, inventory_widget,
569 self.game.world.player_inventory, f)
571 def move_examiner(direction):
572 start_pos = self.examiner_position
573 new_examine_pos = self.game.world.map_.move(start_pos, direction)
575 self.examiner_position = new_examine_pos
576 self.to_update['map'] = True
578 def switch_to_pick_or_drop(target_widget):
579 self.item_pointer = 0
580 switch_widgets(map_widget, target_widget)
581 if self.examiner_mode:
582 self.examiner_mode = False
583 switch_widgets(descriptor_widget, log_widget)
585 def toggle_examiner_mode():
586 if self.examiner_mode:
587 self.examiner_mode = False
588 switch_widgets(descriptor_widget, log_widget)
590 self.examiner_mode = True
591 self.examiner_position = self.game.world.player.position
592 switch_widgets(log_widget, descriptor_widget)
593 self.to_update['map'] = True
596 if popup_widget.visible:
597 popup_widget.visible = False
598 for w in top_widgets:
599 w.ensure_freshness(True)
601 self.to_update['popup'] = True
602 popup_widget.visible = True
603 popup_widget.reconfigure()
604 self.draw_popup_if_visible = True
606 def try_write_keys():
607 if len(key) == 1 and key in ASCII_printable and \
608 len(self.to_send) < len(edit_line_widget):
609 self.to_send += [key]
610 self.to_update['edit'] = True
611 elif key == 'KEY_BACKSPACE':
612 self.to_send[:] = self.to_send[:-1]
613 self.to_update['edit'] = True
614 elif key == '\n': # Return key
615 self.socket.send(''.join(self.to_send))
617 self.to_update['edit'] = True
619 def try_examiner_keys():
621 move_examiner('UPLEFT')
623 move_examiner('UPRIGHT')
625 move_examiner('LEFT')
627 move_examiner('RIGHT')
629 move_examiner('DOWNLEFT')
631 move_examiner('DOWNRIGHT')
633 def try_player_move_keys():
635 self.socket.send('TASK:MOVE UPLEFT')
637 self.socket.send('TASK:MOVE UPRIGHT')
639 self.socket.send('TASK:MOVE LEFT')
641 self.socket.send('TASK:MOVE RIGHT')
643 self.socket.send('TASK:MOVE DOWNLEFT')
645 self.socket.send('TASK:MOVE DOWNRIGHT')
648 curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_RED)
649 curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN)
650 curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_BLUE)
651 curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW)
652 curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_WHITE)
654 # Basic curses initialization work.
656 curses.curs_set(False) # hide cursor
659 # With screen initialized, set up widgets with their curses windows.
660 edit_widget = TextLineWidget('SEND:', self, YX(0, 0), YX(1, 20))
661 edit_line_widget = EditWidget(self, YX(0, 6), YX(1, 14), ['edit'])
662 edit_widget.children += [edit_line_widget]
663 turn_widget = TextLineWidget('TURN:', self, YX(2, 0), YX(1, 20))
664 turn_widget.children += [TurnWidget(self, YX(2, 6), YX(1, 14), ['turn'])]
665 health_widget = TextLineWidget('HEALTH:', self, YX(3, 0), YX(1, 20))
666 health_widget.children += [HealthWidget(self, YX(3, 8), YX(1, 12), ['turn'])]
667 log_widget = LogWidget(self, YX(5, 0), YX(None, 20), ['log'])
668 descriptor_widget = DescriptorWidget(self, YX(5, 0), YX(None, 20),
670 map_widget = MapWidget(self, YX(0, 21), YX(None, None), ['map'])
671 inventory_widget = ItemsSelectorWidget('INVENTORY:',
672 self.game.world.player_inventory,
673 self, YX(0, 21), YX(None, None),
674 ['inventory'], False)
675 pickable_items_widget = ItemsSelectorWidget('PICKABLE:',
676 self.game.world.pickable_items,
681 top_widgets = [edit_widget, turn_widget, health_widget, log_widget,
682 descriptor_widget, map_widget, inventory_widget,
683 pickable_items_widget]
684 popup_widget = PopUpWidget(self, YX(0, 0), YX(1, 1), visible=False)
686 # Ensure initial window state before loop starts.
687 for w in top_widgets:
688 w.ensure_freshness(True)
689 self.socket.send('GET_GAMESTATE')
694 for w in top_widgets:
695 if w.ensure_freshness():
696 self.draw_popup_if_visible = True
697 if popup_widget.visible and self.draw_popup_if_visible:
698 popup_widget.ensure_freshness(True)
699 self.draw_popup_if_visible = False
700 for k in self.to_update.keys():
701 self.to_update[k] = False
703 # Handle input from server.
706 command = self.queue.get(block=False)
709 self.game.handle_input(command)
711 # Handle keys (and resize event read as key).
713 key = self.stdscr.getkey()
714 if key == 'KEY_RESIZE':
716 setup_screen(curses.initscr())
717 for w in top_widgets:
719 w.ensure_freshness(True)
720 elif key == '\t': # Tabulator key.
721 write_mode = False if write_mode else True
726 elif map_widget.visible:
728 toggle_examiner_mode()
730 self.socket.send('GET_PICKABLE_ITEMS')
731 switch_to_pick_or_drop(pickable_items_widget)
733 switch_to_pick_or_drop(inventory_widget)
734 elif self.examiner_mode:
737 try_player_move_keys()
738 elif pickable_items_widget.visible:
740 elif inventory_widget.visible:
745 # Quit when server recommends it.
746 if self.game.do_quit:
750 s = socket.create_connection(('127.0.0.1', 5000))
751 plom_socket = PlomSocket(s)
754 t = threading.Thread(target=recv_loop, args=(plom_socket, game, q))
756 TUI(plom_socket, game, q)