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
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[0], size[0])
66 map_width = self.size[1] * 2 + 1
67 self.x_cut(map_lines, center[1] * 2, size[1], 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[0], self.start[1])
237 self.size_def = size # store for re-calling .size on SIGWINCH
239 self.do_update = True
240 self.visible = visible
245 return 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
252 n_lines = self.tui.stdscr.getmaxyx()[0] - self.start[0]
254 n_cols = self.tui.stdscr.getmaxyx()[1] - self.start[1]
255 self.win.resize(n_lines, n_cols)
258 return self.win.getmaxyx()[0] * self.win.getmaxyx()[1]
260 def safe_write(self, foo):
262 def to_chars_with_attrs(part):
263 attr = curses.A_NORMAL
265 if not type(part) == str:
266 part_string = part[0]
268 return [(char, attr) for char in part_string]
270 chars_with_attrs = []
271 if type(foo) == str or (len(foo) == 2 and type(foo[1]) == int):
272 chars_with_attrs += to_chars_with_attrs(foo)
275 chars_with_attrs += to_chars_with_attrs(part)
277 if len(chars_with_attrs) < len(self):
278 for char_with_attr in chars_with_attrs:
279 self.win.addstr(char_with_attr[0], char_with_attr[1])
280 else: # workaround to <https://stackoverflow.com/q/7063128>
281 cut = chars_with_attrs[:len(self) - 1]
282 last_char_with_attr = chars_with_attrs[len(self) - 1]
283 self.win.addstr(self.size[0] - 1, self.size[1] - 2,
284 last_char_with_attr[0], last_char_with_attr[1])
285 self.win.insstr(self.size[0] - 1, self.size[1] - 2, ' ')
287 for char_with_attr in cut:
288 self.win.addstr(char_with_attr[0], char_with_attr[1])
290 def ensure_freshness(self, do_refresh=False):
294 for key in self.check_updates:
295 if key in self.tui.to_update and self.tui.to_update[key]:
303 for child in self.children:
304 did_refresh = child.ensure_freshness(do_refresh) | did_refresh
308 class EditWidget(Widget):
311 self.safe_write((''.join(self.tui.to_send), curses.color_pair(1)))
314 class TextLinesWidget(Widget):
317 lines = self.get_text_lines()
318 line_width = self.size[1]
321 to_pad = line_width - (len(line) % line_width)
322 if to_pad == line_width:
324 to_join += [line + ' '*to_pad]
325 self.safe_write((''.join(to_join), curses.color_pair(3)))
328 class LogWidget(TextLinesWidget):
330 def get_text_lines(self):
331 return self.tui.game.log_text.split('\n')
334 class DescriptorWidget(TextLinesWidget):
336 def get_text_lines(self):
338 pos_i = self.tui.game.world.map_.\
339 get_position_index(self.tui.examiner_position)
340 terrain = self.tui.game.world.map_.terrain[pos_i]
342 for t in self.tui.game.world.things_at_pos(self.tui.examiner_position):
347 class PopUpWidget(Widget):
350 self.safe_write(self.tui.popup_text)
352 def reconfigure(self):
353 size = (1, len(self.tui.popup_text))
356 offset_y = int((self.tui.stdscr.getmaxyx()[0] / 2) - (size[0] / 2))
357 offset_x = int((self.tui.stdscr.getmaxyx()[1] / 2) - (size[1] / 2))
358 self.start = (offset_y, offset_x)
359 self.win.mvwin(self.start[0], self.start[1])
362 class ItemsSelectorWidget(Widget):
364 def __init__(self, headline, selection, *args, **kwargs):
365 super().__init__(*args, **kwargs)
366 self.headline = headline
367 self.selection = selection
369 def ensure_freshness(self, *args, **kwargs):
370 # We only update pointer on non-empty selection so that the zero-ing
371 # of the selection at TURN_FINISHED etc. before pulling in a new
372 # state does not destroy any memory of previous item pointer positions.
373 if len(self.selection) > 0 and\
374 len(self.selection) < self.tui.item_pointer + 1 and\
375 self.tui.item_pointer > 0:
376 self.tui.item_pointer = max(0, len(self.selection) - 1)
377 self.tui.to_update[self.check_updates[0]] = True
378 super().ensure_freshness(*args, **kwargs)
381 lines = [self.headline]
383 for id_ in self.selection:
384 pointer = '*' if counter == self.tui.item_pointer else ' '
385 t = self.tui.game.world.get_thing(id_)
386 lines += ['%s %s' % (pointer, t.type_)]
388 line_width = self.size[1]
391 to_pad = line_width - (len(line) % line_width)
392 if to_pad == line_width:
394 to_join += [line + ' '*to_pad]
395 self.safe_write((''.join(to_join), curses.color_pair(3)))
398 class MapWidget(Widget):
402 def annotated_terrain():
403 terrain_as_list = list(self.tui.game.world.map_.terrain[:])
404 for t in self.tui.game.world.things:
405 if t.id_ in self.tui.game.world.player_inventory:
407 pos_i = self.tui.game.world.map_.\
408 get_position_index(t.position)
409 symbol = self.tui.game.symbol_for_type(t.type_)
410 if terrain_as_list[pos_i][0] in {'f', '@', 'm'}:
411 old_symbol = terrain_as_list[pos_i][0]
412 if old_symbol in {'@', 'm'}:
414 terrain_as_list[pos_i] = (symbol, '+')
416 terrain_as_list[pos_i] = symbol
417 if self.tui.examiner_mode:
418 pos_i = self.tui.game.world.map_.\
419 get_position_index(self.tui.examiner_position)
420 terrain_as_list[pos_i] = (terrain_as_list[pos_i][0], '?')
421 return terrain_as_list
423 def pad_or_cut_x(lines):
424 line_width = self.size[1]
425 for y in range(len(lines)):
427 if line_width > len(line):
428 to_pad = line_width - (len(line) % line_width)
429 lines[y] = line + '0' * to_pad
431 lines[y] = line[:line_width]
434 if len(lines) < self.size[0]:
435 to_pad = self.size[0] - len(lines)
436 lines += to_pad * ['0' * self.size[1]]
438 def lines_to_colored_chars(lines):
439 chars_with_attrs = []
440 for c in ''.join(lines):
442 chars_with_attrs += [(c, curses.color_pair(1))]
444 chars_with_attrs += [(c, curses.color_pair(4))]
446 chars_with_attrs += [(c, curses.color_pair(2))]
447 elif c in {'x', 'X', '#'}:
448 chars_with_attrs += [(c, curses.color_pair(3))]
450 chars_with_attrs += [(c, curses.color_pair(5))]
452 chars_with_attrs += [c]
453 return chars_with_attrs
455 if self.tui.game.world.map_.terrain == '':
458 self.safe_write(''.join(lines))
461 annotated_terrain = annotated_terrain()
462 center = self.tui.game.world.player.position
463 if self.tui.examiner_mode:
464 center = self.tui.examiner_position
465 lines = self.tui.game.world.map_.\
466 format_to_view(annotated_terrain, center, self.size)
469 self.safe_write(lines_to_colored_chars(lines))
472 class TurnWidget(Widget):
475 self.safe_write((str(self.tui.game.world.turn), curses.color_pair(2)))
478 class HealthWidget(Widget):
481 if hasattr(self.tui.game.world.player, 'health'):
482 self.safe_write((str(self.tui.game.world.player.health),
483 curses.color_pair(2)))
486 class TextLineWidget(Widget):
488 def __init__(self, text_line, *args, **kwargs):
489 self.text_line = text_line
490 super().__init__(*args, **kwargs)
493 self.safe_write(self.text_line)
498 def __init__(self, plom_socket, game, q):
499 self.socket = plom_socket
503 self.parser = Parser(self.game)
505 self.item_pointer = 0
506 self.examiner_position = ((0,0), (0, 0))
507 self.examiner_mode = False
508 self.popup_text = 'Hi bob'
510 self.draw_popup_if_visible = True
511 curses.wrapper(self.loop)
513 def loop(self, stdscr):
515 def setup_screen(stdscr):
517 self.stdscr.refresh() # will be called by getkey else, clearing screen
518 self.stdscr.timeout(10)
520 def switch_widgets(widget_1, widget_2):
521 widget_1.visible = False
522 widget_2.visible = True
523 trigger = widget_2.check_updates[0]
524 self.to_update[trigger] = True
526 def selectables_menu(key, widget, selectables, f):
528 switch_widgets(widget, map_widget)
530 self.item_pointer += 1
531 elif key == 'k' and self.item_pointer > 0:
532 self.item_pointer -= 1
533 elif not f(key, selectables):
535 trigger = widget.check_updates[0]
536 self.to_update[trigger] = True
538 def pickup_menu(key):
540 def f(key, selectables):
541 if key == 'p' and len(selectables) > 0:
542 id_ = selectables[self.item_pointer]
543 self.socket.send('TASK:PICKUP %s' % id_)
544 self.socket.send('GET_PICKABLE_ITEMS')
549 selectables_menu(key, pickable_items_widget,
550 self.game.world.pickable_items, f)
552 def inventory_menu(key):
554 def f(key, selectables):
555 if key == 'd' and len(selectables) > 0:
556 id_ = selectables[self.item_pointer]
557 self.socket.send('TASK:DROP %s' % id_)
558 elif key == 'e' and len(selectables) > 0:
559 id_ = selectables[self.item_pointer]
560 self.socket.send('TASK:EAT %s' % id_)
565 selectables_menu(key, inventory_widget,
566 self.game.world.player_inventory, f)
568 def move_examiner(direction):
569 start_pos = self.examiner_position
570 new_examine_pos = self.game.world.map_.move(start_pos, direction)
572 self.examiner_position = new_examine_pos
573 self.to_update['map'] = True
575 def switch_to_pick_or_drop(target_widget):
576 self.item_pointer = 0
577 switch_widgets(map_widget, target_widget)
578 if self.examiner_mode:
579 self.examiner_mode = False
580 switch_widgets(descriptor_widget, log_widget)
582 def toggle_examiner_mode():
583 if self.examiner_mode:
584 self.examiner_mode = False
585 switch_widgets(descriptor_widget, log_widget)
587 self.examiner_mode = True
588 self.examiner_position = self.game.world.player.position
589 switch_widgets(log_widget, descriptor_widget)
590 self.to_update['map'] = True
593 if popup_widget.visible:
594 popup_widget.visible = False
595 for w in top_widgets:
596 w.ensure_freshness(True)
598 self.to_update['popup'] = True
599 popup_widget.visible = True
600 popup_widget.reconfigure()
601 self.draw_popup_if_visible = True
603 def try_write_keys():
604 if len(key) == 1 and key in ASCII_printable and \
605 len(self.to_send) < len(edit_line_widget):
606 self.to_send += [key]
607 self.to_update['edit'] = True
608 elif key == 'KEY_BACKSPACE':
609 self.to_send[:] = self.to_send[:-1]
610 self.to_update['edit'] = True
611 elif key == '\n': # Return key
612 self.socket.send(''.join(self.to_send))
614 self.to_update['edit'] = True
616 def try_examiner_keys():
618 move_examiner('UPLEFT')
620 move_examiner('UPRIGHT')
622 move_examiner('LEFT')
624 move_examiner('RIGHT')
626 move_examiner('DOWNLEFT')
628 move_examiner('DOWNRIGHT')
630 def try_player_move_keys():
632 self.socket.send('TASK:MOVE UPLEFT')
634 self.socket.send('TASK:MOVE UPRIGHT')
636 self.socket.send('TASK:MOVE LEFT')
638 self.socket.send('TASK:MOVE RIGHT')
640 self.socket.send('TASK:MOVE DOWNLEFT')
642 self.socket.send('TASK:MOVE DOWNRIGHT')
645 curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_RED)
646 curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN)
647 curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_BLUE)
648 curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW)
649 curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_WHITE)
651 # Basic curses initialization work.
653 curses.curs_set(False) # hide cursor
656 # With screen initialized, set up widgets with their curses windows.
657 edit_widget = TextLineWidget('SEND:', self, (0, 0), (1, 20))
658 edit_line_widget = EditWidget(self, (0, 6), (1, 14), ['edit'])
659 edit_widget.children += [edit_line_widget]
660 turn_widget = TextLineWidget('TURN:', self, (2, 0), (1, 20))
661 turn_widget.children += [TurnWidget(self, (2, 6), (1, 14), ['turn'])]
662 health_widget = TextLineWidget('HEALTH:', self, (3, 0), (1, 20))
663 health_widget.children += [HealthWidget(self, (3, 8), (1, 12), ['turn'])]
664 log_widget = LogWidget(self, (5, 0), (None, 20), ['log'])
665 descriptor_widget = DescriptorWidget(self, (5, 0), (None, 20),
667 map_widget = MapWidget(self, (0, 21), (None, None), ['map'])
668 inventory_widget = ItemsSelectorWidget('INVENTORY:',
669 self.game.world.player_inventory,
670 self, (0, 21), (None,
671 None), ['inventory'],
673 pickable_items_widget = ItemsSelectorWidget('PICKABLE:',
674 self.game.world.pickable_items,
679 top_widgets = [edit_widget, turn_widget, health_widget, log_widget,
680 descriptor_widget, map_widget, inventory_widget,
681 pickable_items_widget]
682 popup_widget = PopUpWidget(self, (0, 0), (1, 1), visible=False)
684 # Ensure initial window state before loop starts.
685 for w in top_widgets:
686 w.ensure_freshness(True)
687 self.socket.send('GET_GAMESTATE')
692 for w in top_widgets:
693 if w.ensure_freshness():
694 self.draw_popup_if_visible = True
695 if popup_widget.visible and self.draw_popup_if_visible:
696 popup_widget.ensure_freshness(True)
697 self.draw_popup_if_visible = False
698 for k in self.to_update.keys():
699 self.to_update[k] = False
701 # Handle input from server.
704 command = self.queue.get(block=False)
707 self.game.handle_input(command)
709 # Handle keys (and resize event read as key).
711 key = self.stdscr.getkey()
712 if key == 'KEY_RESIZE':
714 setup_screen(curses.initscr())
715 for w in top_widgets:
717 w.ensure_freshness(True)
718 elif key == '\t': # Tabulator key.
719 write_mode = False if write_mode else True
724 elif map_widget.visible:
726 toggle_examiner_mode()
728 self.socket.send('GET_PICKABLE_ITEMS')
729 switch_to_pick_or_drop(pickable_items_widget)
731 switch_to_pick_or_drop(inventory_widget)
732 elif self.examiner_mode:
735 try_player_move_keys()
736 elif pickable_items_widget.visible:
738 elif inventory_widget.visible:
743 # Quit when server recommends it.
744 if self.game.do_quit:
748 s = socket.create_connection(('127.0.0.1', 5000))
749 plom_socket = PlomSocket(s)
752 t = threading.Thread(target=recv_loop, args=(plom_socket, game, q))
754 TUI(plom_socket, game, q)