home · contact · privacy
In examine cursor mode, show map cell contents in left bar.
[plomrogue2-experiments] / new / example_client.py
1 #!/usr/bin/env python3
2 import curses
3 import socket
4 import threading
5 from plomrogue.parser import ArgError, Parser
6 from plomrogue.commands import cmd_MAP, cmd_THING_POS, cmd_PLAYER_ID
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
11 import types
12 import queue
13
14
15 class ClientMap(MapHex):
16
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:]
22             else:
23                 start = center_y - int(view_height / 2) - 1
24                 map_lines[:] = map_lines[start:start + view_height]
25
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
30                 cut_end = None
31             else:
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]
35
36     def format_to_view(self, map_cells, center, size):
37
38         def map_cells_to_lines(map_cells):
39             map_view_chars = ['0']
40             x = 0
41             y = 0
42             for cell in map_cells:
43                 if type(cell) == str:
44                     map_view_chars += [cell, ' ']
45                 else:
46                     map_view_chars += [cell[0], cell[1]]
47                 x += 1
48                 if x == self.size[1]:
49                     map_view_chars += ['\n']
50                     x = 0
51                     y += 1
52                     if y % 2 == 0:
53                         map_view_chars += ['0']
54             if y % 2 == 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')
58
59         map_lines = map_cells_to_lines(map_cells)
60         self.y_cut(map_lines, center[0], size[0])
61         map_width = self.size[1] * 2 + 1
62         self.x_cut(map_lines, center[1] * 2, size[1], map_width)
63         return map_lines
64
65
66 class World(WorldBase):
67
68     def __init__(self, *args, **kwargs):
69         """Extend original with local classes and empty default map.
70
71         We need the empty default map because we draw the map widget
72         on any update, even before we actually receive map data.
73         """
74         super().__init__(*args, **kwargs)
75         self.map_ = ClientMap()
76         self.player_inventory = []
77         self.player_id = 0
78         self.pickable_items = []
79
80     def new_map(self, yx):
81         self.map_ = ClientMap(yx)
82
83     @property
84     def player(self):
85         return self.get_thing(self.player_id)
86
87
88 def cmd_LAST_PLAYER_TASK_RESULT(game, msg):
89     if msg != "success":
90         game.log(msg)
91 cmd_LAST_PLAYER_TASK_RESULT.argtypes = 'string'
92
93 def cmd_TURN_FINISHED(game, n):
94     """Do nothing. (This may be extended later.)"""
95     pass
96 cmd_TURN_FINISHED.argtypes = 'int:nonneg'
97
98 def cmd_TURN(game, n):
99     """Set game.turn to n, empty game.things."""
100     game.world.turn = n
101     game.world.things = []
102     game.world.pickable_items = []
103 cmd_TURN.argtypes = 'int:nonneg'
104
105 def cmd_VISIBLE_MAP_LINE(game, y, terrain_line):
106     game.world.map_.set_line(y, terrain_line)
107 cmd_VISIBLE_MAP_LINE.argtypes = 'int:nonneg string'
108
109 def cmd_GAME_STATE_COMPLETE(game):
110     game.tui.to_update['turn'] = True
111     game.tui.to_update['map'] = True
112     game.tui.to_update['inventory'] = True
113
114 def cmd_THING_TYPE(game, i, type_):
115     t = game.world.get_thing(i)
116     t.type_ = type_
117 cmd_THING_TYPE.argtypes = 'int:nonneg string'
118
119 def cmd_PLAYER_INVENTORY(game, ids):
120     game.world.player_inventory = ids  # TODO: test whether valid IDs
121 cmd_PLAYER_INVENTORY.argtypes = 'seq:int:nonneg'
122
123 def cmd_PICKABLE_ITEMS(game, ids):
124     game.world.pickable_items = ids
125     game.tui.to_update['pickable_items'] = True
126 cmd_PICKABLE_ITEMS.argtypes = 'seq:int:nonneg'
127
128
129 class Game:
130
131     def __init__(self):
132         self.parser = Parser(self)
133         self.world = World(self)
134         self.thing_type = ThingBase
135         self.commands = {'LAST_PLAYER_TASK_RESULT': cmd_LAST_PLAYER_TASK_RESULT,
136                          'TURN_FINISHED': cmd_TURN_FINISHED,
137                          'TURN': cmd_TURN,
138                          'VISIBLE_MAP_LINE': cmd_VISIBLE_MAP_LINE,
139                          'PLAYER_ID': cmd_PLAYER_ID,
140                          'PLAYER_INVENTORY': cmd_PLAYER_INVENTORY,
141                          'GAME_STATE_COMPLETE': cmd_GAME_STATE_COMPLETE,
142                          'MAP': cmd_MAP,
143                          'PICKABLE_ITEMS': cmd_PICKABLE_ITEMS,
144                          'THING_TYPE': cmd_THING_TYPE,
145                          'THING_POS': cmd_THING_POS}
146         self.log_text = ''
147         self.do_quit = False
148         self.tui = None
149
150     def get_command(self, command_name):
151         from functools import partial
152         if command_name in self.commands:
153             f = partial(self.commands[command_name], self)
154             if hasattr(self.commands[command_name], 'argtypes'):
155                 f.argtypes = self.commands[command_name].argtypes
156             return f
157         return None
158
159     def get_string_options(self, string_option_type):
160         return None
161
162     def handle_input(self, msg):
163         self.log(msg)
164         if msg == 'BYE':
165             self.do_quit = True
166             return
167         try:
168             command, args = self.parser.parse(msg)
169             if command is None:
170                 self.log('UNHANDLED INPUT: ' + msg)
171             else:
172                 command(*args)
173         except ArgError as e:
174             self.log('ARGUMENT ERROR: ' + msg + '\n' + str(e))
175
176     def log(self, msg):
177         """Prefix msg plus newline to self.log_text."""
178         self.log_text = msg + '\n' + self.log_text
179         self.tui.to_update['log'] = True
180
181     def symbol_for_type(self, type_):
182         symbol = '?'
183         if type_ == 'human':
184             symbol = '@'
185         elif type_ == 'monster':
186             symbol = 'm'
187         elif type_ == 'item':
188             symbol = 'i'
189         return symbol
190
191
192 ASCII_printable = ' !"#$%&\'\(\)*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWX'\
193                   'YZ[\\]^_\`abcdefghijklmnopqrstuvwxyz{|}~'
194
195
196 def recv_loop(plom_socket, game, q):
197     for msg in plom_socket.recv():
198         q.put(msg)
199
200
201 class Widget:
202
203     def __init__(self, tui, start, size, check_updates=[], visible=True):
204         self.check_updates = check_updates
205         self.tui = tui
206         self.start = start
207         self.win = curses.newwin(1, 1, self.start[0], self.start[1])
208         self.size_def = size  # store for re-calling .size on SIGWINCH
209         self.size = size
210         self.do_update = True
211         self.visible = visible
212         self.children = []
213
214     @property
215     def size(self):
216         return self.win.getmaxyx()
217
218     @size.setter
219     def size(self, size):
220         """Set window size. Size be y,x tuple. If y or x None, use legal max."""
221         n_lines, n_cols = size
222         if n_lines is None:
223             n_lines = self.tui.stdscr.getmaxyx()[0] - self.start[0]
224         if n_cols is None:
225             n_cols = self.tui.stdscr.getmaxyx()[1] - self.start[1]
226         self.win.resize(n_lines, n_cols)
227
228     def __len__(self):
229         return self.win.getmaxyx()[0] * self.win.getmaxyx()[1]
230
231     def safe_write(self, foo):
232
233         def to_chars_with_attrs(part):
234             attr = curses.A_NORMAL
235             part_string = part
236             if not type(part) == str:
237                 part_string = part[0]
238                 attr = part[1]
239             return [(char, attr) for char in part_string]
240
241         chars_with_attrs = []
242         if type(foo) == str or (len(foo) == 2 and type(foo[1]) == int):
243             chars_with_attrs += to_chars_with_attrs(foo)
244         else:
245             for part in foo:
246                 chars_with_attrs += to_chars_with_attrs(part)
247         self.win.move(0, 0)
248         if len(chars_with_attrs) < len(self):
249             for char_with_attr in chars_with_attrs:
250                 self.win.addstr(char_with_attr[0], char_with_attr[1])
251         else:  # workaround to <https://stackoverflow.com/q/7063128>
252             cut = chars_with_attrs[:len(self) - 1]
253             last_char_with_attr = chars_with_attrs[len(self) - 1]
254             self.win.addstr(self.size[0] - 1, self.size[1] - 2,
255                             last_char_with_attr[0], last_char_with_attr[1])
256             self.win.insstr(self.size[0] - 1, self.size[1] - 2, ' ')
257             self.win.move(0, 0)
258             for char_with_attr in cut:
259                 self.win.addstr(char_with_attr[0], char_with_attr[1])
260
261     def ensure_freshness(self, do_refresh=False):
262         did_refresh = False
263         if self.visible:
264             if not do_refresh:
265                 for key in self.check_updates:
266                     if key in self.tui.to_update and self.tui.to_update[key]:
267                         do_refresh = True
268                         break
269             if do_refresh:
270                 self.win.erase()
271                 self.draw()
272                 self.win.refresh()
273                 did_refresh = True
274             for child in self.children:
275                 did_refresh = child.ensure_freshness(do_refresh) | did_refresh
276         return did_refresh
277
278
279 class EditWidget(Widget):
280
281     def draw(self):
282         self.safe_write((''.join(self.tui.to_send), curses.color_pair(1)))
283
284
285 class TextLinesWidget(Widget):
286
287     def draw(self):
288         lines = self.get_text_lines()
289         line_width = self.size[1]
290         to_join = []
291         for line in lines:
292             to_pad = line_width - (len(line) % line_width)
293             if to_pad == line_width:
294                 to_pad = 0
295             to_join += [line + ' '*to_pad]
296         self.safe_write((''.join(to_join), curses.color_pair(3)))
297
298
299 class LogWidget(TextLinesWidget):
300
301     def get_text_lines(self):
302         return self.tui.game.log_text.split('\n')
303
304
305 class DescriptorWidget(TextLinesWidget):
306
307     def get_text_lines(self):
308         lines = []
309         pos_i = self.tui.game.world.map_.\
310                 get_position_index(self.tui.examiner_position)
311         terrain = self.tui.game.world.map_.terrain[pos_i]
312         lines = [terrain]
313         for t in self.tui.game.world.things:
314             if t.position == self.tui.examiner_position:
315                 lines += [t.type_]
316         return lines
317
318
319 class PopUpWidget(Widget):
320
321     def draw(self):
322         self.safe_write(self.tui.popup_text)
323
324     def reconfigure(self):
325         self.visible = True
326         size = (1, len(self.tui.popup_text))
327         self.size = size
328         self.size_def = size
329         offset_y = int((self.tui.stdscr.getmaxyx()[0] / 2) - (size[0] / 2))
330         offset_x = int((self.tui.stdscr.getmaxyx()[1] / 2) - (size[1] / 2))
331         self.start = (offset_y, offset_x)
332         self.win.mvwin(self.start[0], self.start[1])
333
334
335 class ItemsSelectorWidget(Widget):
336
337     def draw_item_selector(self, title, selection):
338         lines = [title]
339         counter = 0
340         for id_ in selection:
341             pointer = '*' if counter == self.tui.item_pointer else ' '
342             t = self.tui.game.world.get_thing(id_)
343             lines += ['%s %s' % (pointer, t.type_)]
344             counter += 1
345         line_width = self.size[1]
346         to_join = []
347         for line in lines:
348             to_pad = line_width - (len(line) % line_width)
349             if to_pad == line_width:
350                 to_pad = 0
351             to_join += [line + ' '*to_pad]
352         self.safe_write((''.join(to_join), curses.color_pair(3)))
353
354
355 class InventoryWidget(ItemsSelectorWidget):
356
357     def draw(self):
358         self.draw_item_selector('INVENTORY:',
359                                 self.tui.game.world.player_inventory)
360
361 class PickableItemsWidget(ItemsSelectorWidget):
362
363     def draw(self):
364         self.draw_item_selector('PICKABLE:',
365                                 self.tui.game.world.pickable_items)
366
367
368 class MapWidget(Widget):
369
370     def __init__(self, *args, **kwargs):
371         super().__init__(*args, **kwargs)
372         self.examine_mode = False
373
374     def draw(self):
375
376         def annotated_terrain():
377             terrain_as_list = list(self.tui.game.world.map_.terrain[:])
378             for t in self.tui.game.world.things:
379                 pos_i = self.tui.game.world.map_.get_position_index(t.position)
380                 symbol = self.tui.game.symbol_for_type(t.type_)
381                 if terrain_as_list[pos_i][0] in {'i', '@', 'm'}:
382                     old_symbol = terrain_as_list[pos_i][0]
383                     if old_symbol in {'@', 'm'}:
384                         symbol = old_symbol
385                     terrain_as_list[pos_i] = (symbol, '+')
386                 else:
387                     terrain_as_list[pos_i] = symbol
388             if self.examine_mode:
389                 pos_i = self.tui.game.world.map_.\
390                         get_position_index(self.tui.examiner_position)
391                 terrain_as_list[pos_i] = (terrain_as_list[pos_i][0], '?')
392             return terrain_as_list
393
394         def pad_or_cut_x(lines):
395             line_width = self.size[1]
396             for y in range(len(lines)):
397                 line = lines[y]
398                 if line_width > len(line):
399                     to_pad = line_width - (len(line) % line_width)
400                     lines[y] = line + '0' * to_pad
401                 else:
402                     lines[y] = line[:line_width]
403
404         def pad_y(lines):
405             if len(lines) < self.size[0]:
406                 to_pad = self.size[0] - len(lines)
407                 lines += to_pad * ['0' * self.size[1]]
408
409         def lines_to_colored_chars(lines):
410             chars_with_attrs = []
411             for c in ''.join(lines):
412                 if c in {'@', 'm'}:
413                     chars_with_attrs += [(c, curses.color_pair(1))]
414                 elif c == 'i':
415                     chars_with_attrs += [(c, curses.color_pair(4))]
416                 elif c == '.':
417                     chars_with_attrs += [(c, curses.color_pair(2))]
418                 elif c in {'x', 'X', '#'}:
419                     chars_with_attrs += [(c, curses.color_pair(3))]
420                 else:
421                     chars_with_attrs += [c]
422             return chars_with_attrs
423
424         if self.tui.game.world.map_.terrain == '':
425             lines = []
426             pad_y(lines)
427             self.safe_write(''.join(lines))
428             return
429
430         annotated_terrain = annotated_terrain()
431         center = self.tui.game.world.player.position
432         if self.examine_mode:
433             center = self.tui.examiner_position
434         lines = self.tui.game.world.map_.format_to_view(annotated_terrain,
435                                                         center, self.size)
436         pad_or_cut_x(lines)
437         pad_y(lines)
438         self.safe_write(lines_to_colored_chars(lines))
439
440
441 class TurnWidget(Widget):
442
443     def draw(self):
444         self.safe_write((str(self.tui.game.world.turn), curses.color_pair(2)))
445
446
447 class TextLineWidget(Widget):
448
449     def __init__(self, text_line, *args, **kwargs):
450         self.text_line = text_line
451         super().__init__(*args, **kwargs)
452
453     def draw(self):
454         self.safe_write(self.text_line)
455
456
457 class TUI:
458
459     def __init__(self, plom_socket, game, q):
460         self.socket = plom_socket
461         self.game = game
462         self.game.tui = self
463         self.queue = q
464         self.parser = Parser(self.game)
465         self.to_update = {}
466         self.item_pointer = 0
467         self.examiner_position = (0, 0)
468         curses.wrapper(self.loop)
469
470     def loop(self, stdscr):
471
472         def setup_screen(stdscr):
473             self.stdscr = stdscr
474             self.stdscr.refresh()  # will be called by getkey else, clearing screen
475             self.stdscr.timeout(10)
476
477         def switch_widgets(widget_1, widget_2):
478             widget_1.visible = False
479             widget_2.visible = True
480             trigger = widget_2.check_updates[0]
481             self.to_update[trigger] = True
482
483         def pick_or_drop_menu(action_key, widget, selectables, task,
484                               bonus_command=None):
485             if len(selectables) < self.item_pointer + 1 and\
486                self.item_pointer > 0:
487                 self.item_pointer = len(selectables) - 1
488             if key == 'c':
489                 switch_widgets(widget, map_widget)
490                 map_widget.examine_mode = False
491             elif key == 'j':
492                 self.item_pointer += 1
493             elif key == 'k' and self.item_pointer > 0:
494                 self.item_pointer -= 1
495             elif key == action_key and len(selectables) > 0:
496                 id_ = selectables[self.item_pointer]
497                 self.socket.send('TASK:%s %s' % (task, id_))
498                 if bonus_command:
499                     self.socket.send(bonus_command)
500                 if self.item_pointer > 0:
501                     self.item_pointer -= 1
502             else:
503                 return
504             trigger = widget.check_updates[0]
505             self.to_update[trigger] = True
506
507         def move_examiner(direction):
508             start_pos = self.examiner_position
509             new_examine_pos = self.game.world.map_.move(start_pos, direction)
510             if new_examine_pos:
511                 self.examiner_position = new_examine_pos
512             self.to_update['map'] = True
513             self.to_update['descriptor'] = True
514
515         setup_screen(stdscr)
516         curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_RED)
517         curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN)
518         curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_BLUE)
519         curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW)
520         curses.curs_set(False)  # hide cursor
521         self.to_send = []
522         edit_widget = TextLineWidget('SEND:', self, (0, 0), (1, 20))
523         edit_line_widget = EditWidget(self, (0, 6), (1, 14), ['edit'])
524         edit_widget.children += [edit_line_widget]
525         turn_widget = TextLineWidget('TURN:', self, (2, 0), (1, 20))
526         turn_widget.children += [TurnWidget(self, (2, 6), (1, 14), ['turn'])]
527         log_widget = LogWidget(self, (4, 0), (None, 20), ['log'])
528         descriptor_widget = DescriptorWidget(self, (4, 0), (None, 20),
529                                              ['descriptor'], False)
530         map_widget = MapWidget(self, (0, 21), (None, None), ['map'])
531         inventory_widget = InventoryWidget(self, (0, 21), (None, None),
532                                            ['inventory'], False)
533         pickable_items_widget = PickableItemsWidget(self, (0, 21), (None, None),
534                                                     ['pickable_items'], False)
535         top_widgets = [edit_widget, turn_widget, log_widget,
536                        descriptor_widget, map_widget, inventory_widget,
537                        pickable_items_widget]
538         popup_widget = PopUpWidget(self, (0, 0), (1, 1), visible=False)
539         self.popup_text = 'Hi bob'
540         write_mode = True
541         for w in top_widgets:
542             w.ensure_freshness(True)
543         draw_popup_if_visible = True
544         while True:
545             for w in top_widgets:
546                 did_refresh = w.ensure_freshness()
547                 draw_popup_if_visible = did_refresh | draw_popup_if_visible
548             if popup_widget.visible and draw_popup_if_visible:
549                 popup_widget.ensure_freshness(True)
550                 draw_popup_if_visible = False
551             for k in self.to_update.keys():
552                 self.to_update[k] = False
553             while True:
554                 try:
555                     command = self.queue.get(block=False)
556                 except queue.Empty:
557                     break
558                 self.game.handle_input(command)
559             try:
560                 key = self.stdscr.getkey()
561                 if key == 'KEY_RESIZE':
562                     curses.endwin()
563                     setup_screen(curses.initscr())
564                     for w in top_widgets:
565                         w.size = w.size_def
566                         w.ensure_freshness(True)
567                 elif key == '\t':  # Tabulator key.
568                     write_mode = False if write_mode else True
569                 elif write_mode:
570                     if len(key) == 1 and key in ASCII_printable and \
571                             len(self.to_send) < len(edit_line_widget):
572                         self.to_send += [key]
573                         self.to_update['edit'] = True
574                     elif key == 'KEY_BACKSPACE':
575                         self.to_send[:] = self.to_send[:-1]
576                         self.to_update['edit'] = True
577                     elif key == '\n':  # Return key
578                         self.socket.send(''.join(self.to_send))
579                         self.to_send[:] = []
580                         self.to_update['edit'] = True
581                 elif key == 't':
582                     if not popup_widget.visible:
583                         self.to_update['popup'] = True
584                         popup_widget.visible = True
585                         popup_widget.reconfigure()
586                         draw_popup_if_visible = True
587                     else:
588                         popup_widget.visible = False
589                         for w in top_widgets:
590                             w.ensure_freshness(True)
591                 elif map_widget.visible:
592                     if key == '?':
593                         map_widget.examine_mode = not map_widget.examine_mode
594                         if map_widget.examine_mode:
595                             self.examiner_position = self.game.world.\
596                                                      player.position
597                             switch_widgets(log_widget, descriptor_widget)
598                         else:
599                             switch_widgets(descriptor_widget, log_widget)
600                         self.to_update['map'] = True
601                     elif key == 'p':
602                         self.socket.send('GET_PICKABLE_ITEMS')
603                         self.item_pointer = 0
604                         switch_widgets(map_widget, pickable_items_widget)
605                     elif key == 'i':
606                         self.item_pointer = 0
607                         switch_widgets(map_widget, inventory_widget)
608                     elif map_widget.examine_mode:
609                         if key == 'w':
610                             move_examiner('UPLEFT')
611                         elif key == 'e':
612                             move_examiner('UPRIGHT')
613                         elif key == 's':
614                             move_examiner('LEFT')
615                         elif key == 'd':
616                             move_examiner('RIGHT')
617                         elif key == 'x':
618                             move_examiner('DOWNLEFT')
619                         elif key == 'c':
620                             move_examiner('DOWNRIGHT')
621                     elif key == 'w':
622                         self.socket.send('TASK:MOVE UPLEFT')
623                     elif key == 'e':
624                         self.socket.send('TASK:MOVE UPRIGHT')
625                     elif key == 's':
626                         self.socket.send('TASK:MOVE LEFT')
627                     elif key == 'd':
628                         self.socket.send('TASK:MOVE RIGHT')
629                     elif key == 'x':
630                         self.socket.send('TASK:MOVE DOWNLEFT')
631                     elif key == 'c':
632                         self.socket.send('TASK:MOVE DOWNRIGHT')
633                 elif pickable_items_widget.visible:
634                     pick_or_drop_menu('p', pickable_items_widget,
635                                       self.game.world.pickable_items,
636                                       'PICKUP', 'GET_PICKABLE_ITEMS')
637                 elif inventory_widget.visible:
638                     pick_or_drop_menu('d', inventory_widget,
639                                       self.game.world.player_inventory,
640                                       'DROP')
641             except curses.error:
642                 pass
643             if self.game.do_quit:
644                 break
645
646
647 s = socket.create_connection(('127.0.0.1', 5000))
648 plom_socket = PlomSocket(s)
649 game = Game()
650 q = queue.Queue()
651 t = threading.Thread(target=recv_loop, args=(plom_socket, game, q))
652 t.start()
653 TUI(plom_socket, game, q)