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