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