home · contact · privacy
3f31780e17332ffa74a768d47ee2416829a99201
[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 setup_screen(self, stdscr):
437         self.stdscr = stdscr
438         self.stdscr.refresh()  # will be called by getkey else, clearing screen
439         self.stdscr.timeout(10)
440
441     def switch_widgets(self, widget_1, widget_2):
442         widget_1.visible = False
443         widget_2.visible = True
444         x = widget_2.check_updates[0]
445         self.to_update[x] = True
446
447     def loop(self, stdscr):
448         self.setup_screen(stdscr)
449         curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_RED)
450         curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN)
451         curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_BLUE)
452         curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW)
453         curses.curs_set(False)  # hide cursor
454         self.to_send = []
455         edit_widget = TextLineWidget('SEND:', self, (0, 0), (1, 20))
456         edit_line_widget = EditWidget(self, (0, 6), (1, 14), ['edit'])
457         edit_widget.children += [edit_line_widget]
458         turn_widget = TextLineWidget('TURN:', self, (2, 0), (1, 20))
459         turn_widget.children += [TurnWidget(self, (2, 6), (1, 14), ['turn'])]
460         log_widget = LogWidget(self, (4, 0), (None, 20), ['log'])
461         map_widget = MapWidget(self, (0, 21), (None, None), ['map'])
462         inventory_widget = InventoryWidget(self, (0, 21), (None, None),
463                                            ['inventory'], False)
464         pickable_items_widget = PickableItemsWidget(self, (0, 21), (None, None),
465                                                     ['pickable_items'], False)
466         top_widgets = [edit_widget, turn_widget, log_widget, map_widget,
467                        inventory_widget, pickable_items_widget]
468         popup_widget = PopUpWidget(self, (0, 0), (1, 1), visible=False)
469         self.popup_text = 'Hi bob'
470         write_mode = True
471         for w in top_widgets:
472             w.ensure_freshness(True)
473         draw_popup_if_visible = True
474         while True:
475             for w in top_widgets:
476                 did_refresh = w.ensure_freshness()
477                 draw_popup_if_visible = did_refresh | draw_popup_if_visible
478             if popup_widget.visible and draw_popup_if_visible:
479                 popup_widget.ensure_freshness(True)
480                 draw_popup_if_visible = False
481             for k in self.to_update.keys():
482                 self.to_update[k] = False
483             while True:
484                 try:
485                     command = self.queue.get(block=False)
486                 except queue.Empty:
487                     break
488                 self.game.handle_input(command)
489             try:
490                 key = self.stdscr.getkey()
491                 if key == 'KEY_RESIZE':
492                     curses.endwin()
493                     self.setup_screen(curses.initscr())
494                     for w in top_widgets:
495                         w.size = w.size_def
496                         w.ensure_freshness(True)
497                 elif key == '\t':  # Tabulator key.
498                     write_mode = False if write_mode else True
499                 elif write_mode:
500                     if len(key) == 1 and key in ASCII_printable and \
501                             len(self.to_send) < len(edit_line_widget):
502                         self.to_send += [key]
503                         self.to_update['edit'] = True
504                     elif key == 'KEY_BACKSPACE':
505                         self.to_send[:] = self.to_send[:-1]
506                         self.to_update['edit'] = True
507                     elif key == '\n':  # Return key
508                         self.socket.send(''.join(self.to_send))
509                         self.to_send[:] = []
510                         self.to_update['edit'] = True
511                 elif map_widget.visible:
512                     if key == 'w':
513                         self.socket.send('TASK:MOVE UPLEFT')
514                     elif key == 'e':
515                         self.socket.send('TASK:MOVE UPRIGHT')
516                     if key == 's':
517                         self.socket.send('TASK:MOVE LEFT')
518                     elif key == 'd':
519                         self.socket.send('TASK:MOVE RIGHT')
520                     if key == 'x':
521                         self.socket.send('TASK:MOVE DOWNLEFT')
522                     elif key == 'c':
523                         self.socket.send('TASK:MOVE DOWNRIGHT')
524                     elif key == 't':
525                         if not popup_widget.visible:
526                             self.to_update['popup'] = True
527                             popup_widget.visible = True
528                             popup_widget.reconfigure()
529                             draw_popup_if_visible = True
530                         else:
531                             popup_widget.visible = False
532                             for w in top_widgets:
533                                 w.ensure_freshness(True)
534                     elif key == 'p':
535                         self.socket.send('GET_PICKABLE_ITEMS')
536                         self.item_pointer = 0
537                         self.switch_widgets(map_widget, pickable_items_widget)
538                     elif key == 'i':
539                         self.item_pointer = 0
540                         self.switch_widgets(map_widget, inventory_widget)
541                 elif pickable_items_widget.visible:
542                     if len(self.game.world.pickable_items) < self.item_pointer + 1\
543                        and self.item_pointer > 0:
544                         self.item_pointer = len(self.game.world.pickable_items) - 1
545                     while len(self.game.world.pickable_items) <= self.item_pointer:
546                         self.item_pointer -= 1
547                     if key == 'c':
548                         self.switch_widgets(pickable_items_widget, map_widget)
549                     elif key == 'j':
550                         self.item_pointer += 1
551                     elif key == 'k' and self.item_pointer > 0:
552                         self.item_pointer -= 1
553                     elif key == 'p' and \
554                          len(self.game.world.pickable_items) > 0:
555                         id_ = self.game.world.pickable_items[self.item_pointer]
556                         self.socket.send('TASK:PICKUP %s' % id_)
557                         self.socket.send('GET_PICKABLE_ITEMS')
558                         if self.item_pointer > 0:
559                             self.item_pointer -= 1
560                     else:
561                         continue
562                     self.to_update['pickable_items'] = True
563                 elif inventory_widget.visible:
564                     if len(self.game.world.player_inventory) < self.item_pointer + 1\
565                        and self.item_pointer > 0:
566                         self.item_pointer = len(self.game.world.player_inventory) - 1
567                     if key == 'c':
568                         self.switch_widgets(inventory_widget, map_widget)
569                     elif key == 'j':
570                         self.item_pointer += 1
571                     elif key == 'k' and self.item_pointer > 0:
572                         self.item_pointer -= 1
573                     elif key == 'd' and \
574                          len(self.game.world.player_inventory) > 0:
575                         id_ = self.game.world.player_inventory[self.item_pointer]
576                         self.socket.send('TASK:DROP %s' % id_)
577                         if self.item_pointer > 0:
578                             self.item_pointer -= 1
579                     else:
580                         continue
581                     self.to_update['inventory'] = True
582             except curses.error:
583                 pass
584             if self.game.do_quit:
585                 break
586
587
588 s = socket.create_connection(('127.0.0.1', 5000))
589 plom_socket = PlomSocket(s)
590 game = Game()
591 q = queue.Queue()
592 t = threading.Thread(target=recv_loop, args=(plom_socket, game, q))
593 t.start()
594 TUI(plom_socket, game, q)