home · contact · privacy
Refactor.
[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         size = (1, len(self.tui.popup_text))
326         self.size = size
327         self.size_def = size
328         offset_y = int((self.tui.stdscr.getmaxyx()[0] / 2) - (size[0] / 2))
329         offset_x = int((self.tui.stdscr.getmaxyx()[1] / 2) - (size[1] / 2))
330         self.start = (offset_y, offset_x)
331         self.win.mvwin(self.start[0], self.start[1])
332
333
334 class ItemsSelectorWidget(Widget):
335
336     def draw_item_selector(self, title, selection):
337         lines = [title]
338         counter = 0
339         for id_ in selection:
340             pointer = '*' if counter == self.tui.item_pointer else ' '
341             t = self.tui.game.world.get_thing(id_)
342             lines += ['%s %s' % (pointer, t.type_)]
343             counter += 1
344         line_width = self.size[1]
345         to_join = []
346         for line in lines:
347             to_pad = line_width - (len(line) % line_width)
348             if to_pad == line_width:
349                 to_pad = 0
350             to_join += [line + ' '*to_pad]
351         self.safe_write((''.join(to_join), curses.color_pair(3)))
352
353
354 class InventoryWidget(ItemsSelectorWidget):
355
356     def draw(self):
357         self.draw_item_selector('INVENTORY:',
358                                 self.tui.game.world.player_inventory)
359
360 class PickableItemsWidget(ItemsSelectorWidget):
361
362     def draw(self):
363         self.draw_item_selector('PICKABLE:',
364                                 self.tui.game.world.pickable_items)
365
366
367 class MapWidget(Widget):
368
369     def draw(self):
370
371         def annotated_terrain():
372             terrain_as_list = list(self.tui.game.world.map_.terrain[:])
373             for t in self.tui.game.world.things:
374                 pos_i = self.tui.game.world.map_.get_position_index(t.position)
375                 symbol = self.tui.game.symbol_for_type(t.type_)
376                 if terrain_as_list[pos_i][0] in {'i', '@', 'm'}:
377                     old_symbol = terrain_as_list[pos_i][0]
378                     if old_symbol in {'@', 'm'}:
379                         symbol = old_symbol
380                     terrain_as_list[pos_i] = (symbol, '+')
381                 else:
382                     terrain_as_list[pos_i] = symbol
383             if self.tui.examiner_mode:
384                 pos_i = self.tui.game.world.map_.\
385                         get_position_index(self.tui.examiner_position)
386                 terrain_as_list[pos_i] = (terrain_as_list[pos_i][0], '?')
387             return terrain_as_list
388
389         def pad_or_cut_x(lines):
390             line_width = self.size[1]
391             for y in range(len(lines)):
392                 line = lines[y]
393                 if line_width > len(line):
394                     to_pad = line_width - (len(line) % line_width)
395                     lines[y] = line + '0' * to_pad
396                 else:
397                     lines[y] = line[:line_width]
398
399         def pad_y(lines):
400             if len(lines) < self.size[0]:
401                 to_pad = self.size[0] - len(lines)
402                 lines += to_pad * ['0' * self.size[1]]
403
404         def lines_to_colored_chars(lines):
405             chars_with_attrs = []
406             for c in ''.join(lines):
407                 if c in {'@', 'm'}:
408                     chars_with_attrs += [(c, curses.color_pair(1))]
409                 elif c == 'i':
410                     chars_with_attrs += [(c, curses.color_pair(4))]
411                 elif c == '.':
412                     chars_with_attrs += [(c, curses.color_pair(2))]
413                 elif c in {'x', 'X', '#'}:
414                     chars_with_attrs += [(c, curses.color_pair(3))]
415                 else:
416                     chars_with_attrs += [c]
417             return chars_with_attrs
418
419         if self.tui.game.world.map_.terrain == '':
420             lines = []
421             pad_y(lines)
422             self.safe_write(''.join(lines))
423             return
424
425         annotated_terrain = annotated_terrain()
426         center = self.tui.game.world.player.position
427         if self.tui.examiner_mode:
428             center = self.tui.examiner_position
429         lines = self.tui.game.world.map_.format_to_view(annotated_terrain,
430                                                         center, self.size)
431         pad_or_cut_x(lines)
432         pad_y(lines)
433         self.safe_write(lines_to_colored_chars(lines))
434
435
436 class TurnWidget(Widget):
437
438     def draw(self):
439         self.safe_write((str(self.tui.game.world.turn), curses.color_pair(2)))
440
441
442 class TextLineWidget(Widget):
443
444     def __init__(self, text_line, *args, **kwargs):
445         self.text_line = text_line
446         super().__init__(*args, **kwargs)
447
448     def draw(self):
449         self.safe_write(self.text_line)
450
451
452 class TUI:
453
454     def __init__(self, plom_socket, game, q):
455         self.socket = plom_socket
456         self.game = game
457         self.game.tui = self
458         self.queue = q
459         self.parser = Parser(self.game)
460         self.to_update = {}
461         self.item_pointer = 0
462         self.examiner_position = (0, 0)
463         self.examiner_mode = False
464         self.popup_text = 'Hi bob'
465         self.to_send = []
466         self.draw_popup_if_visible = True
467         curses.wrapper(self.loop)
468
469     def loop(self, stdscr):
470
471         def setup_screen(stdscr):
472             self.stdscr = stdscr
473             self.stdscr.refresh()  # will be called by getkey else, clearing screen
474             self.stdscr.timeout(10)
475
476         def switch_widgets(widget_1, widget_2):
477             widget_1.visible = False
478             widget_2.visible = True
479             trigger = widget_2.check_updates[0]
480             self.to_update[trigger] = True
481
482         def pick_or_drop_menu(action_key, widget, selectables, task,
483                               bonus_command=None):
484             if len(selectables) < self.item_pointer + 1 and\
485                self.item_pointer > 0:
486                 self.item_pointer = len(selectables) - 1
487             if key == 'c':
488                 switch_widgets(widget, map_widget)
489             elif key == 'j':
490                 self.item_pointer += 1
491             elif key == 'k' and self.item_pointer > 0:
492                 self.item_pointer -= 1
493             elif key == action_key and len(selectables) > 0:
494                 id_ = selectables[self.item_pointer]
495                 self.socket.send('TASK:%s %s' % (task, id_))
496                 if bonus_command:
497                     self.socket.send(bonus_command)
498                 if self.item_pointer > 0:
499                     self.item_pointer -= 1
500             else:
501                 return
502             trigger = widget.check_updates[0]
503             self.to_update[trigger] = True
504
505         def move_examiner(direction):
506             start_pos = self.examiner_position
507             new_examine_pos = self.game.world.map_.move(start_pos, direction)
508             if new_examine_pos:
509                 self.examiner_position = new_examine_pos
510             self.to_update['map'] = True
511             self.to_update['descriptor'] = True
512
513         def switch_to_pick_or_drop(target_widget):
514             self.item_pointer = 0
515             switch_widgets(map_widget, target_widget)
516             if self.examiner_mode:
517                 self.examiner_mode = False
518                 switch_widgets(descriptor_widget, log_widget)
519
520         def toggle_examiner_mode():
521             if self.examiner_mode:
522                 self.examiner_mode = False
523                 switch_widgets(descriptor_widget, log_widget)
524             else:
525                 self.examiner_mode = True
526                 self.examiner_position = self.game.world.player.position
527                 switch_widgets(log_widget, descriptor_widget)
528             self.to_update['map'] = True
529
530         def toggle_popup():
531             if popup_widget.visible:
532                 popup_widget.visible = False
533                 for w in top_widgets:
534                     w.ensure_freshness(True)
535             else:
536                 self.to_update['popup'] = True
537                 popup_widget.visible = True
538                 popup_widget.reconfigure()
539                 self.draw_popup_if_visible = True
540
541         def try_write_keys():
542             if len(key) == 1 and key in ASCII_printable and \
543                     len(self.to_send) < len(edit_line_widget):
544                 self.to_send += [key]
545                 self.to_update['edit'] = True
546             elif key == 'KEY_BACKSPACE':
547                 self.to_send[:] = self.to_send[:-1]
548                 self.to_update['edit'] = True
549             elif key == '\n':  # Return key
550                 self.socket.send(''.join(self.to_send))
551                 self.to_send[:] = []
552                 self.to_update['edit'] = True
553
554         def try_examiner_keys():
555             if key == 'w':
556                 move_examiner('UPLEFT')
557             elif key == 'e':
558                 move_examiner('UPRIGHT')
559             elif key == 's':
560                 move_examiner('LEFT')
561             elif key == 'd':
562                 move_examiner('RIGHT')
563             elif key == 'x':
564                 move_examiner('DOWNLEFT')
565             elif key == 'c':
566                 move_examiner('DOWNRIGHT')
567
568         def try_player_move_keys():
569             if key == 'w':
570                 self.socket.send('TASK:MOVE UPLEFT')
571             elif key == 'e':
572                 self.socket.send('TASK:MOVE UPRIGHT')
573             elif key == 's':
574                 self.socket.send('TASK:MOVE LEFT')
575             elif key == 'd':
576                 self.socket.send('TASK:MOVE RIGHT')
577             elif key == 'x':
578                 self.socket.send('TASK:MOVE DOWNLEFT')
579             elif key == 'c':
580                 self.socket.send('TASK:MOVE DOWNRIGHT')
581
582         def init_colors():
583             curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_RED)
584             curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN)
585             curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_BLUE)
586             curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW)
587
588         setup_screen(stdscr)
589         curses.curs_set(False)  # hide cursor
590         init_colors()
591         edit_widget = TextLineWidget('SEND:', self, (0, 0), (1, 20))
592         edit_line_widget = EditWidget(self, (0, 6), (1, 14), ['edit'])
593         edit_widget.children += [edit_line_widget]
594         turn_widget = TextLineWidget('TURN:', self, (2, 0), (1, 20))
595         turn_widget.children += [TurnWidget(self, (2, 6), (1, 14), ['turn'])]
596         log_widget = LogWidget(self, (4, 0), (None, 20), ['log'])
597         descriptor_widget = DescriptorWidget(self, (4, 0), (None, 20),
598                                              ['descriptor'], False)
599         map_widget = MapWidget(self, (0, 21), (None, None), ['map'])
600         inventory_widget = InventoryWidget(self, (0, 21), (None, None),
601                                            ['inventory'], False)
602         pickable_items_widget = PickableItemsWidget(self, (0, 21), (None, None),
603                                                     ['pickable_items'], False)
604         top_widgets = [edit_widget, turn_widget, log_widget,
605                        descriptor_widget, map_widget, inventory_widget,
606                        pickable_items_widget]
607         popup_widget = PopUpWidget(self, (0, 0), (1, 1), visible=False)
608         write_mode = False
609         for w in top_widgets:
610             w.ensure_freshness(True)
611         while True:
612
613             # Draw screen.
614             for w in top_widgets:
615                 if w.ensure_freshness():
616                     self.draw_popup_if_visible = True
617             if popup_widget.visible and self.draw_popup_if_visible:
618                 popup_widget.ensure_freshness(True)
619                 self.draw_popup_if_visible = False
620             for k in self.to_update.keys():
621                 self.to_update[k] = False
622
623             # Handle input from server.
624             while True:
625                 try:
626                     command = self.queue.get(block=False)
627                 except queue.Empty:
628                     break
629                 self.game.handle_input(command)
630
631             # Handle keys (and resize event read as key).
632             try:
633                 key = self.stdscr.getkey()
634                 if key == 'KEY_RESIZE':
635                     curses.endwin()
636                     setup_screen(curses.initscr())
637                     for w in top_widgets:
638                         w.size = w.size_def
639                         w.ensure_freshness(True)
640                 elif key == '\t':  # Tabulator key.
641                     write_mode = False if write_mode else True
642                 elif write_mode:
643                     try_write_keys()
644                 elif key == 't':
645                     toggle_popup()
646                 elif map_widget.visible:
647                     if key == '?':
648                         toggle_examiner_mode()
649                     elif key == 'p':
650                         self.socket.send('GET_PICKABLE_ITEMS')
651                         switch_to_pick_or_drop(pickable_items_widget)
652                     elif key == 'i':
653                         switch_to_pick_or_drop(inventory_widget)
654                     elif self.examiner_mode:
655                         try_examiner_keys()
656                     else:
657                         try_player_move_keys()
658                 elif pickable_items_widget.visible:
659                     pick_or_drop_menu('p', pickable_items_widget,
660                                       self.game.world.pickable_items,
661                                       'PICKUP', 'GET_PICKABLE_ITEMS')
662                 elif inventory_widget.visible:
663                     pick_or_drop_menu('d', inventory_widget,
664                                       self.game.world.player_inventory,
665                                       'DROP')
666             except curses.error:
667                 pass
668
669             # Quit when server recommends it.
670             if self.game.do_quit:
671                 break
672
673
674 s = socket.create_connection(('127.0.0.1', 5000))
675 plom_socket = PlomSocket(s)
676 game = Game()
677 q = queue.Queue()
678 t = threading.Thread(target=recv_loop, args=(plom_socket, game, q))
679 t.start()
680 TUI(plom_socket, game, q)