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):
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[1].y, size.y)
66 map_width = self.size.x * 2 + 1
67 self.x_cut(map_lines, center[1].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)
139 t.position = YX(0,0), yx
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.map_geometry = MapGeometryHex()
161 self.thing_type = ThingBase
162 self.commands = {'LAST_PLAYER_TASK_RESULT': cmd_LAST_PLAYER_TASK_RESULT,
163 'TURN_FINISHED': cmd_TURN_FINISHED,
165 'VISIBLE_MAP_LINE': cmd_VISIBLE_MAP_LINE,
166 'PLAYER_ID': cmd_PLAYER_ID,
167 'PLAYER_INVENTORY': cmd_PLAYER_INVENTORY,
168 'GAME_STATE_COMPLETE': cmd_GAME_STATE_COMPLETE,
169 'VISIBLE_MAP': cmd_VISIBLE_MAP,
170 'PICKABLE_ITEMS': cmd_PICKABLE_ITEMS,
171 'THING_TYPE': cmd_THING_TYPE,
172 'THING_HEALTH': cmd_THING_HEALTH,
173 'THING_POS': cmd_THING_POS}
178 def get_command(self, command_name):
179 from functools import partial
180 if command_name in self.commands:
181 f = partial(self.commands[command_name], self)
182 if hasattr(self.commands[command_name], 'argtypes'):
183 f.argtypes = self.commands[command_name].argtypes
187 def get_string_options(self, string_option_type):
190 def handle_input(self, msg):
196 command, args = self.parser.parse(msg)
198 self.log('UNHANDLED INPUT: ' + msg)
201 except ArgError as e:
202 self.log('ARGUMENT ERROR: ' + msg + '\n' + str(e))
205 """Prefix msg plus newline to self.log_text."""
206 self.log_text = msg + '\n' + self.log_text
207 self.tui.to_update['log'] = True
209 def symbol_for_type(self, type_):
213 elif type_ == 'monster':
215 elif type_ == 'food':
220 ASCII_printable = ' !"#$%&\'\(\)*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWX'\
221 'YZ[\\]^_\`abcdefghijklmnopqrstuvwxyz{|}~'
224 def recv_loop(plom_socket, game, q):
225 for msg in plom_socket.recv():
231 def __init__(self, tui, start, size, check_updates=[], visible=True):
232 self.check_updates = check_updates
235 self.win = curses.newwin(1, 1, self.start.y, self.start.x)
236 self.size_def = size # store for re-calling .size on SIGWINCH
238 self.do_update = True
239 self.visible = visible
244 return YX(*self.win.getmaxyx())
247 def size(self, size):
248 """Set window size. Size be y,x tuple. If y or x None, use legal max."""
249 n_lines, n_cols = size
250 getmaxyx = YX(*self.tui.stdscr.getmaxyx())
252 n_lines = getmaxyx.y - self.start.y
254 n_cols = getmaxyx.x - self.start.x
255 self.win.resize(n_lines, n_cols)
258 getmaxyx = YX(*self.win.getmaxyx())
259 return getmaxyx.y * getmaxyx.x
261 def safe_write(self, foo):
263 def to_chars_with_attrs(part):
264 attr = curses.A_NORMAL
266 if not type(part) == str:
267 part_string = part[0]
269 return [(char, attr) for char in part_string]
271 chars_with_attrs = []
272 if type(foo) == str or (len(foo) == 2 and type(foo[1]) == int):
273 chars_with_attrs += to_chars_with_attrs(foo)
276 chars_with_attrs += to_chars_with_attrs(part)
278 if len(chars_with_attrs) < len(self):
279 for char_with_attr in chars_with_attrs:
280 self.win.addstr(char_with_attr[0], char_with_attr[1])
281 else: # workaround to <https://stackoverflow.com/q/7063128>
282 cut = chars_with_attrs[:len(self) - 1]
283 last_char_with_attr = chars_with_attrs[len(self) - 1]
284 self.win.addstr(self.size.y - 1, self.size.x - 2,
285 last_char_with_attr[0], last_char_with_attr[1])
286 self.win.insstr(self.size.y - 1, self.size.x - 2, ' ')
288 for char_with_attr in cut:
289 self.win.addstr(char_with_attr[0], char_with_attr[1])
291 def ensure_freshness(self, do_refresh=False):
295 for key in self.check_updates:
296 if key in self.tui.to_update and self.tui.to_update[key]:
304 for child in self.children:
305 did_refresh = child.ensure_freshness(do_refresh) | did_refresh
309 class EditWidget(Widget):
312 self.safe_write((''.join(self.tui.to_send), curses.color_pair(1)))
315 class TextLinesWidget(Widget):
318 lines = self.get_text_lines()
319 line_width = self.size.x
322 to_pad = line_width - (len(line) % line_width)
323 if to_pad == line_width:
325 to_join += [line + ' '*to_pad]
326 self.safe_write((''.join(to_join), curses.color_pair(3)))
329 class LogWidget(TextLinesWidget):
331 def get_text_lines(self):
332 return self.tui.game.log_text.split('\n')
335 class DescriptorWidget(TextLinesWidget):
337 def get_text_lines(self):
339 pos_i = self.tui.game.world.map_.\
340 get_position_index(self.tui.examiner_position[1])
341 terrain = self.tui.game.world.map_.terrain[pos_i]
343 for t in self.tui.game.world.things_at_pos(self.tui.examiner_position):
348 class PopUpWidget(Widget):
351 self.safe_write(self.tui.popup_text)
353 def reconfigure(self):
354 size = (1, len(self.tui.popup_text))
357 getmaxyx = YX(*self.tui.stdscr.getmaxyx())
358 offset_y = int(getmaxyx.y / 2 - size.y / 2)
359 offset_x = int(getmaxyx.x / 2 - size.x / 2)
360 self.start = YX(offset_y, offset_x)
361 self.win.mvwin(self.start.y, self.start.x)
364 class ItemsSelectorWidget(Widget):
366 def __init__(self, headline, selection, *args, **kwargs):
367 super().__init__(*args, **kwargs)
368 self.headline = headline
369 self.selection = selection
371 def ensure_freshness(self, *args, **kwargs):
372 # We only update pointer on non-empty selection so that the zero-ing
373 # of the selection at TURN_FINISHED etc. before pulling in a new
374 # state does not destroy any memory of previous item pointer positions.
375 if len(self.selection) > 0 and\
376 len(self.selection) < self.tui.item_pointer + 1 and\
377 self.tui.item_pointer > 0:
378 self.tui.item_pointer = max(0, len(self.selection) - 1)
379 self.tui.to_update[self.check_updates[0]] = True
380 super().ensure_freshness(*args, **kwargs)
383 lines = [self.headline]
385 for id_ in self.selection:
386 pointer = '*' if counter == self.tui.item_pointer else ' '
387 t = self.tui.game.world.get_thing(id_)
388 lines += ['%s %s' % (pointer, t.type_)]
390 line_width = self.size.x
393 to_pad = line_width - (len(line) % line_width)
394 if to_pad == line_width:
396 to_join += [line + ' '*to_pad]
397 self.safe_write((''.join(to_join), curses.color_pair(3)))
400 class MapWidget(Widget):
404 def annotated_terrain():
405 terrain_as_list = list(self.tui.game.world.map_.terrain[:])
406 for t in self.tui.game.world.things:
407 if t.id_ in self.tui.game.world.player_inventory:
409 pos_i = self.tui.game.world.map_.\
410 get_position_index(t.position[1])
411 symbol = self.tui.game.symbol_for_type(t.type_)
412 if terrain_as_list[pos_i][0] in {'f', '@', 'm'}:
413 old_symbol = terrain_as_list[pos_i][0]
414 if old_symbol in {'@', 'm'}:
416 terrain_as_list[pos_i] = (symbol, '+')
418 terrain_as_list[pos_i] = symbol
419 if self.tui.examiner_mode:
420 pos_i = self.tui.game.world.map_.\
421 get_position_index(self.tui.examiner_position[1])
422 terrain_as_list[pos_i] = (terrain_as_list[pos_i][0], '?')
423 return terrain_as_list
425 def pad_or_cut_x(lines):
426 line_width = self.size.x
427 for y in range(len(lines)):
429 if line_width > len(line):
430 to_pad = line_width - (len(line) % line_width)
431 lines[y] = line + '0' * to_pad
433 lines[y] = line[:line_width]
436 if len(lines) < self.size.y:
437 to_pad = self.size.y - len(lines)
438 lines += to_pad * ['0' * self.size.x]
440 def lines_to_colored_chars(lines):
441 chars_with_attrs = []
442 for c in ''.join(lines):
444 chars_with_attrs += [(c, curses.color_pair(1))]
446 chars_with_attrs += [(c, curses.color_pair(4))]
448 chars_with_attrs += [(c, curses.color_pair(2))]
449 elif c in {'x', 'X', '#'}:
450 chars_with_attrs += [(c, curses.color_pair(3))]
452 chars_with_attrs += [(c, curses.color_pair(5))]
454 chars_with_attrs += [c]
455 return chars_with_attrs
457 if self.tui.game.world.map_.terrain == '':
460 self.safe_write(''.join(lines))
463 annotated_terrain = annotated_terrain()
464 center = self.tui.game.world.player.position
465 if self.tui.examiner_mode:
466 center = self.tui.examiner_position
467 lines = self.tui.game.world.map_.\
468 format_to_view(annotated_terrain, center, self.size)
471 self.safe_write(lines_to_colored_chars(lines))
474 class TurnWidget(Widget):
477 self.safe_write((str(self.tui.game.world.turn), curses.color_pair(2)))
480 class HealthWidget(Widget):
483 if hasattr(self.tui.game.world.player, 'health'):
484 self.safe_write((str(self.tui.game.world.player.health),
485 curses.color_pair(2)))
488 class TextLineWidget(Widget):
490 def __init__(self, text_line, *args, **kwargs):
491 self.text_line = text_line
492 super().__init__(*args, **kwargs)
495 self.safe_write(self.text_line)
500 def __init__(self, plom_socket, game, q):
501 self.socket = plom_socket
505 self.parser = Parser(self.game)
507 self.item_pointer = 0
508 self.examiner_position = (YX(0,0), YX(0, 0))
509 self.examiner_mode = False
510 self.popup_text = 'Hi bob'
512 self.draw_popup_if_visible = True
513 curses.wrapper(self.loop)
515 def loop(self, stdscr):
517 def setup_screen(stdscr):
519 self.stdscr.refresh() # will be called by getkey else, clearing screen
520 self.stdscr.timeout(10)
522 def switch_widgets(widget_1, widget_2):
523 widget_1.visible = False
524 widget_2.visible = True
525 trigger = widget_2.check_updates[0]
526 self.to_update[trigger] = True
528 def selectables_menu(key, widget, selectables, f):
530 switch_widgets(widget, map_widget)
532 self.item_pointer += 1
533 elif key == 'k' and self.item_pointer > 0:
534 self.item_pointer -= 1
535 elif not f(key, selectables):
537 trigger = widget.check_updates[0]
538 self.to_update[trigger] = True
540 def pickup_menu(key):
542 def f(key, selectables):
543 if key == 'p' and len(selectables) > 0:
544 id_ = selectables[self.item_pointer]
545 self.socket.send('TASK:PICKUP %s' % id_)
546 self.socket.send('GET_PICKABLE_ITEMS')
551 selectables_menu(key, pickable_items_widget,
552 self.game.world.pickable_items, f)
554 def inventory_menu(key):
556 def f(key, selectables):
557 if key == 'd' and len(selectables) > 0:
558 id_ = selectables[self.item_pointer]
559 self.socket.send('TASK:DROP %s' % id_)
560 elif key == 'e' and len(selectables) > 0:
561 id_ = selectables[self.item_pointer]
562 self.socket.send('TASK:EAT %s' % id_)
567 selectables_menu(key, inventory_widget,
568 self.game.world.player_inventory, f)
570 def move_examiner(direction):
571 start_pos = self.examiner_position
572 new_examine_pos = self.game.map_geometry.move(start_pos, direction,
573 self.game.world.map_.size)
574 if new_examine_pos[0] == (0,0):
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)