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 Map, MapGeometryHex, YX
9 from plomrogue.io import PlomSocket
10 from plomrogue.things import ThingBase
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, indent_first_line):
38 def map_cells_to_lines(map_cells):
41 map_view_chars += ['0']
44 for cell in map_cells:
46 map_view_chars += [cell, ' ']
48 map_view_chars += [cell[0], cell[1]]
51 map_view_chars += ['\n']
54 if y % 2 == int(not indent_first_line):
55 map_view_chars += ['0']
56 if y % 2 == int(not indent_first_line):
57 map_view_chars = map_view_chars[:-1]
58 map_view_chars = map_view_chars[:-1]
59 return ''.join(map_view_chars).split('\n')
61 map_lines = map_cells_to_lines(map_cells)
62 self.y_cut(map_lines, center[1].y, size.y)
63 map_width = self.size.x * 2 + 1
64 self.x_cut(map_lines, center[1].x * 2, size.x, map_width)
68 class World(WorldBase):
70 def __init__(self, *args, **kwargs):
71 """Extend original with local classes and empty default map.
73 We need the empty default map because we draw the map widget
74 on any update, even before we actually receive map data.
76 super().__init__(*args, **kwargs)
77 self.map_ = ClientMap()
79 self.player_inventory = []
81 self.pickable_items = []
83 def new_map(self, offset, size):
84 self.map_ = ClientMap(size)
89 return self.get_thing(self.player_id)
92 def cmd_LAST_PLAYER_TASK_RESULT(game, msg):
95 cmd_LAST_PLAYER_TASK_RESULT.argtypes = 'string'
98 def cmd_TURN_FINISHED(game, n):
99 """Do nothing. (This may be extended later.)"""
101 cmd_TURN_FINISHED.argtypes = 'int:nonneg'
104 def cmd_TURN(game, n):
105 """Set game.turn to n, empty game.things."""
107 game.world.things = []
108 game.world.pickable_items[:] = []
109 cmd_TURN.argtypes = 'int:nonneg'
112 def cmd_VISIBLE_MAP(game, offset, size):
113 game.world.new_map(offset, size)
114 cmd_VISIBLE_MAP.argtypes = 'yx_tuple yx_tuple:pos'
117 def cmd_VISIBLE_MAP_LINE(game, y, terrain_line):
118 game.world.map_.set_line(y, terrain_line)
119 cmd_VISIBLE_MAP_LINE.argtypes = 'int:nonneg string'
122 def cmd_GAME_STATE_COMPLETE(game):
123 game.tui.to_update['turn'] = True
124 game.tui.to_update['map'] = True
125 game.tui.to_update['inventory'] = True
128 def cmd_THING_TYPE(game, i, type_):
129 t = game.world.get_thing(i)
131 cmd_THING_TYPE.argtypes = 'int:nonneg string'
134 def cmd_THING_POS(game, i, yx):
135 t = game.world.get_thing(i)
136 t.position = YX(0,0), yx
137 cmd_THING_POS.argtypes = 'int:nonneg yx_tuple:nonneg'
140 def cmd_PLAYER_INVENTORY(game, ids):
141 game.world.player_inventory[:] = ids # TODO: test whether valid IDs
142 game.tui.to_update['inventory'] = True
143 cmd_PLAYER_INVENTORY.argtypes = 'seq:int:nonneg'
146 def cmd_PICKABLE_ITEMS(game, ids):
147 game.world.pickable_items[:] = ids
148 game.tui.to_update['pickable_items'] = True
149 cmd_PICKABLE_ITEMS.argtypes = 'seq:int:nonneg'
155 self.parser = Parser(self)
156 self.world = World(self)
157 self.map_geometry = MapGeometryHex()
158 self.thing_type = ThingBase
159 self.commands = {'LAST_PLAYER_TASK_RESULT': cmd_LAST_PLAYER_TASK_RESULT,
160 'TURN_FINISHED': cmd_TURN_FINISHED,
162 'VISIBLE_MAP_LINE': cmd_VISIBLE_MAP_LINE,
163 'PLAYER_ID': cmd_PLAYER_ID,
164 'PLAYER_INVENTORY': cmd_PLAYER_INVENTORY,
165 'GAME_STATE_COMPLETE': cmd_GAME_STATE_COMPLETE,
166 'VISIBLE_MAP': cmd_VISIBLE_MAP,
167 'PICKABLE_ITEMS': cmd_PICKABLE_ITEMS,
168 'THING_TYPE': cmd_THING_TYPE,
169 'THING_HEALTH': cmd_THING_HEALTH,
170 'THING_POS': cmd_THING_POS}
175 def get_command(self, command_name):
176 from functools import partial
177 if command_name in self.commands:
178 f = partial(self.commands[command_name], self)
179 if hasattr(self.commands[command_name], 'argtypes'):
180 f.argtypes = self.commands[command_name].argtypes
184 def get_string_options(self, string_option_type):
187 def handle_input(self, msg):
193 command, args = self.parser.parse(msg)
195 self.log('UNHANDLED INPUT: ' + msg)
198 except ArgError as e:
199 self.log('ARGUMENT ERROR: ' + msg + '\n' + str(e))
202 """Prefix msg plus newline to self.log_text."""
203 self.log_text = msg + '\n' + self.log_text
204 self.tui.to_update['log'] = True
206 def symbol_for_type(self, type_):
210 elif type_ == 'monster':
212 elif type_ == 'food':
217 ASCII_printable = ' !"#$%&\'\(\)*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWX'\
218 'YZ[\\]^_\`abcdefghijklmnopqrstuvwxyz{|}~'
221 def recv_loop(plom_socket, game, q):
222 for msg in plom_socket.recv():
228 def __init__(self, tui, start, size, check_updates=[], visible=True):
229 self.check_updates = check_updates
232 self.win = curses.newwin(1, 1, self.start.y, self.start.x)
233 self.size_def = size # store for re-calling .size on SIGWINCH
235 self.do_update = True
236 self.visible = visible
241 return YX(*self.win.getmaxyx())
244 def size(self, size):
245 """Set window size. Size be y,x tuple. If y or x None, use legal max."""
246 n_lines, n_cols = size
247 getmaxyx = YX(*self.tui.stdscr.getmaxyx())
249 n_lines = getmaxyx.y - self.start.y
251 n_cols = getmaxyx.x - self.start.x
252 self.win.resize(n_lines, n_cols)
255 getmaxyx = YX(*self.win.getmaxyx())
256 return getmaxyx.y * getmaxyx.x
258 def safe_write(self, foo):
260 def to_chars_with_attrs(part):
261 attr = curses.A_NORMAL
263 if not type(part) == str:
264 part_string = part[0]
266 return [(char, attr) for char in part_string]
268 chars_with_attrs = []
269 if type(foo) == str or (len(foo) == 2 and type(foo[1]) == int):
270 chars_with_attrs += to_chars_with_attrs(foo)
273 chars_with_attrs += to_chars_with_attrs(part)
275 if len(chars_with_attrs) < len(self):
276 for char_with_attr in chars_with_attrs:
277 self.win.addstr(char_with_attr[0], char_with_attr[1])
278 else: # workaround to <https://stackoverflow.com/q/7063128>
279 cut = chars_with_attrs[:len(self) - 1]
280 last_char_with_attr = chars_with_attrs[len(self) - 1]
281 self.win.addstr(self.size.y - 1, self.size.x - 2,
282 last_char_with_attr[0], last_char_with_attr[1])
283 self.win.insstr(self.size.y - 1, self.size.x - 2, ' ')
285 for char_with_attr in cut:
286 self.win.addstr(char_with_attr[0], char_with_attr[1])
288 def ensure_freshness(self, do_refresh=False):
292 for key in self.check_updates:
293 if key in self.tui.to_update and self.tui.to_update[key]:
301 for child in self.children:
302 did_refresh = child.ensure_freshness(do_refresh) | did_refresh
306 class EditWidget(Widget):
309 self.safe_write((''.join(self.tui.to_send), curses.color_pair(1)))
312 class TextLinesWidget(Widget):
315 lines = self.get_text_lines()
316 line_width = self.size.x
319 to_pad = line_width - (len(line) % line_width)
320 if to_pad == line_width:
322 to_join += [line + ' '*to_pad]
323 self.safe_write((''.join(to_join), curses.color_pair(3)))
326 class LogWidget(TextLinesWidget):
328 def get_text_lines(self):
329 return self.tui.game.log_text.split('\n')
332 class DescriptorWidget(TextLinesWidget):
334 def get_text_lines(self):
336 pos_i = self.tui.game.world.map_.\
337 get_position_index(self.tui.examiner_position[1])
338 terrain = self.tui.game.world.map_.terrain[pos_i]
340 for t in self.tui.game.world.things_at_pos(self.tui.examiner_position):
345 class PopUpWidget(Widget):
348 self.safe_write(self.tui.popup_text)
350 def reconfigure(self):
351 size = (1, len(self.tui.popup_text))
354 getmaxyx = YX(*self.tui.stdscr.getmaxyx())
355 offset_y = int(getmaxyx.y / 2 - size.y / 2)
356 offset_x = int(getmaxyx.x / 2 - size.x / 2)
357 self.start = YX(offset_y, offset_x)
358 self.win.mvwin(self.start.y, self.start.x)
361 class ItemsSelectorWidget(Widget):
363 def __init__(self, headline, selection, *args, **kwargs):
364 super().__init__(*args, **kwargs)
365 self.headline = headline
366 self.selection = selection
368 def ensure_freshness(self, *args, **kwargs):
369 # We only update pointer on non-empty selection so that the zero-ing
370 # of the selection at TURN_FINISHED etc. before pulling in a new
371 # state does not destroy any memory of previous item pointer positions.
372 if len(self.selection) > 0 and\
373 len(self.selection) < self.tui.item_pointer + 1 and\
374 self.tui.item_pointer > 0:
375 self.tui.item_pointer = max(0, len(self.selection) - 1)
376 self.tui.to_update[self.check_updates[0]] = True
377 super().ensure_freshness(*args, **kwargs)
380 lines = [self.headline]
382 for id_ in self.selection:
383 pointer = '*' if counter == self.tui.item_pointer else ' '
384 t = self.tui.game.world.get_thing(id_)
385 lines += ['%s %s' % (pointer, t.type_)]
387 line_width = self.size.x
390 to_pad = line_width - (len(line) % line_width)
391 if to_pad == line_width:
393 to_join += [line + ' '*to_pad]
394 self.safe_write((''.join(to_join), curses.color_pair(3)))
397 class MapWidget(Widget):
401 def annotated_terrain():
402 terrain_as_list = list(self.tui.game.world.map_.terrain[:])
403 for t in self.tui.game.world.things:
404 if t.id_ in self.tui.game.world.player_inventory:
406 pos_i = self.tui.game.world.map_.\
407 get_position_index(t.position[1])
408 symbol = self.tui.game.symbol_for_type(t.type_)
409 if terrain_as_list[pos_i][0] in {'f', '@', 'm'}:
410 old_symbol = terrain_as_list[pos_i][0]
411 if old_symbol in {'@', 'm'}:
413 terrain_as_list[pos_i] = (symbol, '+')
415 terrain_as_list[pos_i] = symbol
416 if self.tui.examiner_mode:
417 pos_i = self.tui.game.world.map_.\
418 get_position_index(self.tui.examiner_position[1])
419 terrain_as_list[pos_i] = (terrain_as_list[pos_i][0], '?')
420 return terrain_as_list
422 def pad_or_cut_x(lines):
423 line_width = self.size.x
424 for y in range(len(lines)):
426 if line_width > len(line):
427 to_pad = line_width - (len(line) % line_width)
428 lines[y] = line + '0' * to_pad
430 lines[y] = line[:line_width]
433 if len(lines) < self.size.y:
434 to_pad = self.size.y - len(lines)
435 lines += to_pad * ['0' * self.size.x]
437 def lines_to_colored_chars(lines):
438 chars_with_attrs = []
439 for c in ''.join(lines):
441 chars_with_attrs += [(c, curses.color_pair(1))]
443 chars_with_attrs += [(c, curses.color_pair(4))]
445 chars_with_attrs += [(c, curses.color_pair(2))]
446 elif c in {'x', 'X', '#'}:
447 chars_with_attrs += [(c, curses.color_pair(3))]
449 chars_with_attrs += [(c, curses.color_pair(5))]
451 chars_with_attrs += [c]
452 return chars_with_attrs
454 if self.tui.game.world.map_.terrain == '':
457 self.safe_write(''.join(lines))
460 annotated_terrain = annotated_terrain()
461 center = self.tui.game.world.player.position
462 if self.tui.examiner_mode:
463 center = self.tui.examiner_position
464 indent_first_line = not bool(self.tui.game.world.offset.y % 2)
465 lines = self.tui.game.world.map_.\
466 format_to_view(annotated_terrain, center, self.size,
470 self.safe_write(lines_to_colored_chars(lines))
473 class TurnWidget(Widget):
476 self.safe_write((str(self.tui.game.world.turn), curses.color_pair(2)))
479 class HealthWidget(Widget):
482 if hasattr(self.tui.game.world.player, 'health'):
483 self.safe_write((str(self.tui.game.world.player.health),
484 curses.color_pair(2)))
487 class TextLineWidget(Widget):
489 def __init__(self, text_line, *args, **kwargs):
490 self.text_line = text_line
491 super().__init__(*args, **kwargs)
494 self.safe_write(self.text_line)
499 def __init__(self, plom_socket, game, q):
500 self.socket = plom_socket
504 self.parser = Parser(self.game)
506 self.item_pointer = 0
507 self.examiner_position = (YX(0,0), YX(0, 0))
508 self.examiner_mode = False
509 self.popup_text = 'Hi bob'
511 self.draw_popup_if_visible = True
512 curses.wrapper(self.loop)
514 def loop(self, stdscr):
516 def setup_screen(stdscr):
518 self.stdscr.refresh() # will be called by getkey else, clearing screen
519 self.stdscr.timeout(10)
521 def switch_widgets(widget_1, widget_2):
522 widget_1.visible = False
523 widget_2.visible = True
524 trigger = widget_2.check_updates[0]
525 self.to_update[trigger] = True
527 def selectables_menu(key, widget, selectables, f):
529 switch_widgets(widget, map_widget)
531 self.item_pointer += 1
532 elif key == 'k' and self.item_pointer > 0:
533 self.item_pointer -= 1
534 elif not f(key, selectables):
536 trigger = widget.check_updates[0]
537 self.to_update[trigger] = True
539 def pickup_menu(key):
541 def f(key, selectables):
542 if key == 'p' and len(selectables) > 0:
543 id_ = selectables[self.item_pointer]
544 self.socket.send('TASK:PICKUP %s' % id_)
545 self.socket.send('GET_PICKABLE_ITEMS')
550 selectables_menu(key, pickable_items_widget,
551 self.game.world.pickable_items, f)
553 def inventory_menu(key):
555 def f(key, selectables):
556 if key == 'd' and len(selectables) > 0:
557 id_ = selectables[self.item_pointer]
558 self.socket.send('TASK:DROP %s' % id_)
559 elif key == 'e' and len(selectables) > 0:
560 id_ = selectables[self.item_pointer]
561 self.socket.send('TASK:EAT %s' % id_)
566 selectables_menu(key, inventory_widget,
567 self.game.world.player_inventory, f)
569 def move_examiner(direction):
570 start_pos = self.examiner_position
571 new_examine_pos = self.game.map_geometry.move(start_pos, direction,
572 self.game.world.map_.size)
573 if new_examine_pos[0] == (0,0):
574 self.examiner_position = new_examine_pos
575 self.to_update['map'] = True
577 def switch_to_pick_or_drop(target_widget):
578 self.item_pointer = 0
579 switch_widgets(map_widget, target_widget)
580 if self.examiner_mode:
581 self.examiner_mode = False
582 switch_widgets(descriptor_widget, log_widget)
584 def toggle_examiner_mode():
585 if self.examiner_mode:
586 self.examiner_mode = False
587 switch_widgets(descriptor_widget, log_widget)
589 self.examiner_mode = True
590 self.examiner_position = self.game.world.player.position
591 switch_widgets(log_widget, descriptor_widget)
592 self.to_update['map'] = True
595 if popup_widget.visible:
596 popup_widget.visible = False
597 for w in top_widgets:
598 w.ensure_freshness(True)
600 self.to_update['popup'] = True
601 popup_widget.visible = True
602 popup_widget.reconfigure()
603 self.draw_popup_if_visible = True
605 def try_write_keys():
606 if len(key) == 1 and key in ASCII_printable and \
607 len(self.to_send) < len(edit_line_widget):
608 self.to_send += [key]
609 self.to_update['edit'] = True
610 elif key == 'KEY_BACKSPACE':
611 self.to_send[:] = self.to_send[:-1]
612 self.to_update['edit'] = True
613 elif key == '\n': # Return key
614 self.socket.send(''.join(self.to_send))
616 self.to_update['edit'] = True
618 def try_examiner_keys():
620 move_examiner('UPLEFT')
622 move_examiner('UPRIGHT')
624 move_examiner('LEFT')
626 move_examiner('RIGHT')
628 move_examiner('DOWNLEFT')
630 move_examiner('DOWNRIGHT')
632 def try_player_move_keys():
634 self.socket.send('TASK:MOVE UPLEFT')
636 self.socket.send('TASK:MOVE UPRIGHT')
638 self.socket.send('TASK:MOVE LEFT')
640 self.socket.send('TASK:MOVE RIGHT')
642 self.socket.send('TASK:MOVE DOWNLEFT')
644 self.socket.send('TASK:MOVE DOWNRIGHT')
647 curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_RED)
648 curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN)
649 curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_BLUE)
650 curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW)
651 curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_WHITE)
653 # Basic curses initialization work.
655 curses.curs_set(False) # hide cursor
658 # With screen initialized, set up widgets with their curses windows.
659 edit_widget = TextLineWidget('SEND:', self, YX(0, 0), YX(1, 20))
660 edit_line_widget = EditWidget(self, YX(0, 6), YX(1, 14), ['edit'])
661 edit_widget.children += [edit_line_widget]
662 turn_widget = TextLineWidget('TURN:', self, YX(2, 0), YX(1, 20))
663 turn_widget.children += [TurnWidget(self, YX(2, 6), YX(1, 14), ['turn'])]
664 health_widget = TextLineWidget('HEALTH:', self, YX(3, 0), YX(1, 20))
665 health_widget.children += [HealthWidget(self, YX(3, 8), YX(1, 12), ['turn'])]
666 log_widget = LogWidget(self, YX(5, 0), YX(None, 20), ['log'])
667 descriptor_widget = DescriptorWidget(self, YX(5, 0), YX(None, 20),
669 map_widget = MapWidget(self, YX(0, 21), YX(None, None), ['map'])
670 inventory_widget = ItemsSelectorWidget('INVENTORY:',
671 self.game.world.player_inventory,
672 self, YX(0, 21), YX(None, None),
673 ['inventory'], False)
674 pickable_items_widget = ItemsSelectorWidget('PICKABLE:',
675 self.game.world.pickable_items,
680 top_widgets = [edit_widget, turn_widget, health_widget, log_widget,
681 descriptor_widget, map_widget, inventory_widget,
682 pickable_items_widget]
683 popup_widget = PopUpWidget(self, YX(0, 0), YX(1, 1), visible=False)
685 # Ensure initial window state before loop starts.
686 for w in top_widgets:
687 w.ensure_freshness(True)
688 self.socket.send('GET_GAMESTATE')
693 for w in top_widgets:
694 if w.ensure_freshness():
695 self.draw_popup_if_visible = True
696 if popup_widget.visible and self.draw_popup_if_visible:
697 popup_widget.ensure_freshness(True)
698 self.draw_popup_if_visible = False
699 for k in self.to_update.keys():
700 self.to_update[k] = False
702 # Handle input from server.
705 command = self.queue.get(block=False)
708 self.game.handle_input(command)
710 # Handle keys (and resize event read as key).
712 key = self.stdscr.getkey()
713 if key == 'KEY_RESIZE':
715 setup_screen(curses.initscr())
716 for w in top_widgets:
718 w.ensure_freshness(True)
719 elif key == '\t': # Tabulator key.
720 write_mode = False if write_mode else True
725 elif map_widget.visible:
727 toggle_examiner_mode()
729 self.socket.send('GET_PICKABLE_ITEMS')
730 switch_to_pick_or_drop(pickable_items_widget)
732 switch_to_pick_or_drop(inventory_widget)
733 elif self.examiner_mode:
736 try_player_move_keys()
737 elif pickable_items_widget.visible:
739 elif inventory_widget.visible:
744 # Quit when server recommends it.
745 if self.game.do_quit:
749 s = socket.create_connection(('127.0.0.1', 5000))
750 plom_socket = PlomSocket(s)
753 t = threading.Thread(target=recv_loop, args=(plom_socket, game, q))
755 TUI(plom_socket, game, q)