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 if len(map_lines) % 2 == 0:
62 map_lines = map_lines[1:]
64 for i in range(len(map_lines)):
65 map_lines[i] = '0' + map_lines[i]
66 self.y_cut(map_lines, center[1][0], size[0])
67 map_width = self.size[1] * 2 + 1
68 self.x_cut(map_lines, center[1][1] * 2, size[1], map_width)
72 class World(WorldBase):
74 def __init__(self, *args, **kwargs):
75 """Extend original with local classes and empty default map.
77 We need the empty default map because we draw the map widget
78 on any update, even before we actually receive map data.
80 super().__init__(*args, **kwargs)
81 self.maps = {(0,0): ClientMap()}
82 self.player_inventory = []
84 self.pickable_items = []
86 def new_map(self, map_pos, size):
87 self.maps[map_pos] = ClientMap(size)
91 return self.get_thing(self.player_id)
94 def cmd_LAST_PLAYER_TASK_RESULT(game, msg):
97 cmd_LAST_PLAYER_TASK_RESULT.argtypes = 'string'
100 def cmd_TURN_FINISHED(game, n):
101 """Do nothing. (This may be extended later.)"""
103 cmd_TURN_FINISHED.argtypes = 'int:nonneg'
106 def cmd_TURN(game, n):
107 """Set game.turn to n, empty game.things."""
109 game.world.things = []
110 game.world.pickable_items[:] = []
111 cmd_TURN.argtypes = 'int:nonneg'
114 def cmd_VISIBLE_MAP_LINE(game, y, terrain_line):
115 game.world.maps[(0,0)].set_line(y, terrain_line)
116 cmd_VISIBLE_MAP_LINE.argtypes = 'int:nonneg string'
119 def cmd_GAME_STATE_COMPLETE(game):
120 game.tui.to_update['turn'] = True
121 game.tui.to_update['map'] = True
122 game.tui.to_update['inventory'] = True
125 def cmd_THING_TYPE(game, i, type_):
126 t = game.world.get_thing(i)
128 cmd_THING_TYPE.argtypes = 'int:nonneg string'
131 def cmd_PLAYER_INVENTORY(game, ids):
132 game.world.player_inventory[:] = ids # TODO: test whether valid IDs
133 game.tui.to_update['inventory'] = True
134 cmd_PLAYER_INVENTORY.argtypes = 'seq:int:nonneg'
137 def cmd_PICKABLE_ITEMS(game, ids):
138 game.world.pickable_items[:] = ids
139 game.tui.to_update['pickable_items'] = True
140 cmd_PICKABLE_ITEMS.argtypes = 'seq:int:nonneg'
146 self.parser = Parser(self)
147 self.world = World(self)
148 self.thing_type = ThingBase
149 self.commands = {'LAST_PLAYER_TASK_RESULT': cmd_LAST_PLAYER_TASK_RESULT,
150 'TURN_FINISHED': cmd_TURN_FINISHED,
152 'VISIBLE_MAP_LINE': cmd_VISIBLE_MAP_LINE,
153 'PLAYER_ID': cmd_PLAYER_ID,
154 'PLAYER_INVENTORY': cmd_PLAYER_INVENTORY,
155 'GAME_STATE_COMPLETE': cmd_GAME_STATE_COMPLETE,
157 'PICKABLE_ITEMS': cmd_PICKABLE_ITEMS,
158 'THING_TYPE': cmd_THING_TYPE,
159 'THING_HEALTH': cmd_THING_HEALTH,
160 'THING_POS': cmd_THING_POS}
165 def get_command(self, command_name):
166 from functools import partial
167 if command_name in self.commands:
168 f = partial(self.commands[command_name], self)
169 if hasattr(self.commands[command_name], 'argtypes'):
170 f.argtypes = self.commands[command_name].argtypes
174 def get_string_options(self, string_option_type):
177 def handle_input(self, msg):
183 command, args = self.parser.parse(msg)
185 self.log('UNHANDLED INPUT: ' + msg)
188 except ArgError as e:
189 self.log('ARGUMENT ERROR: ' + msg + '\n' + str(e))
192 """Prefix msg plus newline to self.log_text."""
193 self.log_text = msg + '\n' + self.log_text
194 self.tui.to_update['log'] = True
196 def symbol_for_type(self, type_):
200 elif type_ == 'monster':
202 elif type_ == 'food':
207 ASCII_printable = ' !"#$%&\'\(\)*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWX'\
208 'YZ[\\]^_\`abcdefghijklmnopqrstuvwxyz{|}~'
211 def recv_loop(plom_socket, game, q):
212 for msg in plom_socket.recv():
218 def __init__(self, tui, start, size, check_updates=[], visible=True):
219 self.check_updates = check_updates
222 self.win = curses.newwin(1, 1, self.start[0], self.start[1])
223 self.size_def = size # store for re-calling .size on SIGWINCH
225 self.do_update = True
226 self.visible = visible
231 return self.win.getmaxyx()
234 def size(self, size):
235 """Set window size. Size be y,x tuple. If y or x None, use legal max."""
236 n_lines, n_cols = size
238 n_lines = self.tui.stdscr.getmaxyx()[0] - self.start[0]
240 n_cols = self.tui.stdscr.getmaxyx()[1] - self.start[1]
241 self.win.resize(n_lines, n_cols)
244 return self.win.getmaxyx()[0] * self.win.getmaxyx()[1]
246 def safe_write(self, foo):
248 def to_chars_with_attrs(part):
249 attr = curses.A_NORMAL
251 if not type(part) == str:
252 part_string = part[0]
254 return [(char, attr) for char in part_string]
256 chars_with_attrs = []
257 if type(foo) == str or (len(foo) == 2 and type(foo[1]) == int):
258 chars_with_attrs += to_chars_with_attrs(foo)
261 chars_with_attrs += to_chars_with_attrs(part)
263 if len(chars_with_attrs) < len(self):
264 for char_with_attr in chars_with_attrs:
265 self.win.addstr(char_with_attr[0], char_with_attr[1])
266 else: # workaround to <https://stackoverflow.com/q/7063128>
267 cut = chars_with_attrs[:len(self) - 1]
268 last_char_with_attr = chars_with_attrs[len(self) - 1]
269 self.win.addstr(self.size[0] - 1, self.size[1] - 2,
270 last_char_with_attr[0], last_char_with_attr[1])
271 self.win.insstr(self.size[0] - 1, self.size[1] - 2, ' ')
273 for char_with_attr in cut:
274 self.win.addstr(char_with_attr[0], char_with_attr[1])
276 def ensure_freshness(self, do_refresh=False):
280 for key in self.check_updates:
281 if key in self.tui.to_update and self.tui.to_update[key]:
289 for child in self.children:
290 did_refresh = child.ensure_freshness(do_refresh) | did_refresh
294 class EditWidget(Widget):
297 self.safe_write((''.join(self.tui.to_send), curses.color_pair(1)))
300 class TextLinesWidget(Widget):
303 lines = self.get_text_lines()
304 line_width = self.size[1]
307 to_pad = line_width - (len(line) % line_width)
308 if to_pad == line_width:
310 to_join += [line + ' '*to_pad]
311 self.safe_write((''.join(to_join), curses.color_pair(3)))
314 class LogWidget(TextLinesWidget):
316 def get_text_lines(self):
317 return self.tui.game.log_text.split('\n')
320 class DescriptorWidget(TextLinesWidget):
322 def get_text_lines(self):
324 pos_i = self.tui.game.world.maps[(0,0)].\
325 get_position_index(self.tui.examiner_position[1])
326 terrain = self.tui.game.world.maps[(0,0)].terrain[pos_i]
328 for t in self.tui.game.world.things_at_pos(self.tui.examiner_position):
333 class PopUpWidget(Widget):
336 self.safe_write(self.tui.popup_text)
338 def reconfigure(self):
339 size = (1, len(self.tui.popup_text))
342 offset_y = int((self.tui.stdscr.getmaxyx()[0] / 2) - (size[0] / 2))
343 offset_x = int((self.tui.stdscr.getmaxyx()[1] / 2) - (size[1] / 2))
344 self.start = (offset_y, offset_x)
345 self.win.mvwin(self.start[0], self.start[1])
348 class ItemsSelectorWidget(Widget):
350 def __init__(self, headline, selection, *args, **kwargs):
351 super().__init__(*args, **kwargs)
352 self.headline = headline
353 self.selection = selection
355 def ensure_freshness(self, *args, **kwargs):
356 # We only update pointer on non-empty selection so that the zero-ing
357 # of the selection at TURN_FINISHED etc. before pulling in a new
358 # state does not destroy any memory of previous item pointer positions.
359 if len(self.selection) > 0 and\
360 len(self.selection) < self.tui.item_pointer + 1 and\
361 self.tui.item_pointer > 0:
362 self.tui.item_pointer = max(0, len(self.selection) - 1)
363 self.tui.to_update[self.check_updates[0]] = True
364 super().ensure_freshness(*args, **kwargs)
367 lines = [self.headline]
369 for id_ in self.selection:
370 pointer = '*' if counter == self.tui.item_pointer else ' '
371 t = self.tui.game.world.get_thing(id_)
372 lines += ['%s %s' % (pointer, t.type_)]
374 line_width = self.size[1]
377 to_pad = line_width - (len(line) % line_width)
378 if to_pad == line_width:
380 to_join += [line + ' '*to_pad]
381 self.safe_write((''.join(to_join), curses.color_pair(3)))
384 class MapWidget(Widget):
388 def annotated_terrain():
389 terrain_as_list = list(self.tui.game.world.maps[(0,0)].terrain[:])
390 for t in self.tui.game.world.things:
391 if t.id_ in self.tui.game.world.player_inventory:
393 pos_i = self.tui.game.world.maps[(0,0)].\
394 get_position_index(t.position[1])
395 symbol = self.tui.game.symbol_for_type(t.type_)
396 if terrain_as_list[pos_i][0] in {'f', '@', 'm'}:
397 old_symbol = terrain_as_list[pos_i][0]
398 if old_symbol in {'@', 'm'}:
400 terrain_as_list[pos_i] = (symbol, '+')
402 terrain_as_list[pos_i] = symbol
403 if self.tui.examiner_mode:
404 pos_i = self.tui.game.world.maps[(0,0)].\
405 get_position_index(self.tui.examiner_position[1])
406 terrain_as_list[pos_i] = (terrain_as_list[pos_i][0], '?')
407 return terrain_as_list
409 def pad_or_cut_x(lines):
410 line_width = self.size[1]
411 for y in range(len(lines)):
413 if line_width > len(line):
414 to_pad = line_width - (len(line) % line_width)
415 lines[y] = line + '0' * to_pad
417 lines[y] = line[:line_width]
420 if len(lines) < self.size[0]:
421 to_pad = self.size[0] - len(lines)
422 lines += to_pad * ['0' * self.size[1]]
424 def lines_to_colored_chars(lines):
425 chars_with_attrs = []
426 for c in ''.join(lines):
428 chars_with_attrs += [(c, curses.color_pair(1))]
430 chars_with_attrs += [(c, curses.color_pair(4))]
432 chars_with_attrs += [(c, curses.color_pair(2))]
433 elif c in {'x', 'X', '#'}:
434 chars_with_attrs += [(c, curses.color_pair(3))]
436 chars_with_attrs += [(c, curses.color_pair(5))]
438 chars_with_attrs += [c]
439 return chars_with_attrs
441 if self.tui.game.world.maps[(0,0)].terrain == '':
444 self.safe_write(''.join(lines))
447 annotated_terrain = annotated_terrain()
448 center = self.tui.game.world.player.position
449 if self.tui.examiner_mode:
450 center = self.tui.examiner_position
451 lines = self.tui.game.world.maps[(0,0)].\
452 format_to_view(annotated_terrain, center, self.size)
455 self.safe_write(lines_to_colored_chars(lines))
458 class TurnWidget(Widget):
461 self.safe_write((str(self.tui.game.world.turn), curses.color_pair(2)))
464 class HealthWidget(Widget):
467 if hasattr(self.tui.game.world.player, 'health'):
468 self.safe_write((str(self.tui.game.world.player.health),
469 curses.color_pair(2)))
472 class TextLineWidget(Widget):
474 def __init__(self, text_line, *args, **kwargs):
475 self.text_line = text_line
476 super().__init__(*args, **kwargs)
479 self.safe_write(self.text_line)
484 def __init__(self, plom_socket, game, q):
485 self.socket = plom_socket
489 self.parser = Parser(self.game)
491 self.item_pointer = 0
492 self.examiner_position = ((0,0), (0, 0))
493 self.examiner_mode = False
494 self.popup_text = 'Hi bob'
496 self.draw_popup_if_visible = True
497 curses.wrapper(self.loop)
499 def loop(self, stdscr):
501 def setup_screen(stdscr):
503 self.stdscr.refresh() # will be called by getkey else, clearing screen
504 self.stdscr.timeout(10)
506 def switch_widgets(widget_1, widget_2):
507 widget_1.visible = False
508 widget_2.visible = True
509 trigger = widget_2.check_updates[0]
510 self.to_update[trigger] = True
512 def selectables_menu(key, widget, selectables, f):
514 switch_widgets(widget, map_widget)
516 self.item_pointer += 1
517 elif key == 'k' and self.item_pointer > 0:
518 self.item_pointer -= 1
519 elif not f(key, selectables):
521 trigger = widget.check_updates[0]
522 self.to_update[trigger] = True
524 def pickup_menu(key):
526 def f(key, selectables):
527 if key == 'p' and len(selectables) > 0:
528 id_ = selectables[self.item_pointer]
529 self.socket.send('TASK:PICKUP %s' % id_)
530 self.socket.send('GET_PICKABLE_ITEMS')
535 selectables_menu(key, pickable_items_widget,
536 self.game.world.pickable_items, f)
538 def inventory_menu(key):
540 def f(key, selectables):
541 if key == 'd' and len(selectables) > 0:
542 id_ = selectables[self.item_pointer]
543 self.socket.send('TASK:DROP %s' % id_)
544 elif key == 'e' and len(selectables) > 0:
545 id_ = selectables[self.item_pointer]
546 self.socket.send('TASK:EAT %s' % id_)
551 selectables_menu(key, inventory_widget,
552 self.game.world.player_inventory, f)
554 def move_examiner(direction):
555 start_pos = self.examiner_position
556 new_examine_pos = self.game.world.maps[(0,0)].\
557 move(start_pos[0], direction)
559 self.examiner_position[1] = new_examine_pos
560 self.to_update['map'] = True
562 def switch_to_pick_or_drop(target_widget):
563 self.item_pointer = 0
564 switch_widgets(map_widget, target_widget)
565 if self.examiner_mode:
566 self.examiner_mode = False
567 switch_widgets(descriptor_widget, log_widget)
569 def toggle_examiner_mode():
570 if self.examiner_mode:
571 self.examiner_mode = False
572 switch_widgets(descriptor_widget, log_widget)
574 self.examiner_mode = True
575 self.examiner_position = self.game.world.player.position
576 switch_widgets(log_widget, descriptor_widget)
577 self.to_update['map'] = True
580 if popup_widget.visible:
581 popup_widget.visible = False
582 for w in top_widgets:
583 w.ensure_freshness(True)
585 self.to_update['popup'] = True
586 popup_widget.visible = True
587 popup_widget.reconfigure()
588 self.draw_popup_if_visible = True
590 def try_write_keys():
591 if len(key) == 1 and key in ASCII_printable and \
592 len(self.to_send) < len(edit_line_widget):
593 self.to_send += [key]
594 self.to_update['edit'] = True
595 elif key == 'KEY_BACKSPACE':
596 self.to_send[:] = self.to_send[:-1]
597 self.to_update['edit'] = True
598 elif key == '\n': # Return key
599 self.socket.send(''.join(self.to_send))
601 self.to_update['edit'] = True
603 def try_examiner_keys():
605 move_examiner('UPLEFT')
607 move_examiner('UPRIGHT')
609 move_examiner('LEFT')
611 move_examiner('RIGHT')
613 move_examiner('DOWNLEFT')
615 move_examiner('DOWNRIGHT')
617 def try_player_move_keys():
619 self.socket.send('TASK:MOVE UPLEFT')
621 self.socket.send('TASK:MOVE UPRIGHT')
623 self.socket.send('TASK:MOVE LEFT')
625 self.socket.send('TASK:MOVE RIGHT')
627 self.socket.send('TASK:MOVE DOWNLEFT')
629 self.socket.send('TASK:MOVE DOWNRIGHT')
632 curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_RED)
633 curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN)
634 curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_BLUE)
635 curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW)
636 curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_WHITE)
638 # Basic curses initialization work.
640 curses.curs_set(False) # hide cursor
643 # With screen initialized, set up widgets with their curses windows.
644 edit_widget = TextLineWidget('SEND:', self, (0, 0), (1, 20))
645 edit_line_widget = EditWidget(self, (0, 6), (1, 14), ['edit'])
646 edit_widget.children += [edit_line_widget]
647 turn_widget = TextLineWidget('TURN:', self, (2, 0), (1, 20))
648 turn_widget.children += [TurnWidget(self, (2, 6), (1, 14), ['turn'])]
649 health_widget = TextLineWidget('HEALTH:', self, (3, 0), (1, 20))
650 health_widget.children += [HealthWidget(self, (3, 8), (1, 12), ['turn'])]
651 log_widget = LogWidget(self, (5, 0), (None, 20), ['log'])
652 descriptor_widget = DescriptorWidget(self, (5, 0), (None, 20),
654 map_widget = MapWidget(self, (0, 21), (None, None), ['map'])
655 inventory_widget = ItemsSelectorWidget('INVENTORY:',
656 self.game.world.player_inventory,
657 self, (0, 21), (None,
658 None), ['inventory'],
660 pickable_items_widget = ItemsSelectorWidget('PICKABLE:',
661 self.game.world.pickable_items,
666 top_widgets = [edit_widget, turn_widget, health_widget, log_widget,
667 descriptor_widget, map_widget, inventory_widget,
668 pickable_items_widget]
669 popup_widget = PopUpWidget(self, (0, 0), (1, 1), visible=False)
671 # Ensure initial window state before loop starts.
672 for w in top_widgets:
673 w.ensure_freshness(True)
674 self.socket.send('GET_GAMESTATE')
679 for w in top_widgets:
680 if w.ensure_freshness():
681 self.draw_popup_if_visible = True
682 if popup_widget.visible and self.draw_popup_if_visible:
683 popup_widget.ensure_freshness(True)
684 self.draw_popup_if_visible = False
685 for k in self.to_update.keys():
686 self.to_update[k] = False
688 # Handle input from server.
691 command = self.queue.get(block=False)
694 self.game.handle_input(command)
696 # Handle keys (and resize event read as key).
698 key = self.stdscr.getkey()
699 if key == 'KEY_RESIZE':
701 setup_screen(curses.initscr())
702 for w in top_widgets:
704 w.ensure_freshness(True)
705 elif key == '\t': # Tabulator key.
706 write_mode = False if write_mode else True
711 elif map_widget.visible:
713 toggle_examiner_mode()
715 self.socket.send('GET_PICKABLE_ITEMS')
716 switch_to_pick_or_drop(pickable_items_widget)
718 switch_to_pick_or_drop(inventory_widget)
719 elif self.examiner_mode:
722 try_player_move_keys()
723 elif pickable_items_widget.visible:
725 elif inventory_widget.visible:
730 # Quit when server recommends it.
731 if self.game.do_quit:
735 s = socket.create_connection(('127.0.0.1', 5000))
736 plom_socket = PlomSocket(s)
739 t = threading.Thread(target=recv_loop, args=(plom_socket, game, q))
741 TUI(plom_socket, game, q)