home · contact · privacy
In client, mark terrain cells that carry multiple objects with +.
[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_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_ = Map()
76         self.player_inventory = []
77         self.player_id = 0
78         self.pickable_items = []
79
80     def new_map(self, yx):
81         self.map_ = Map(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 draw(self):
351
352         def annotated_terrain():
353             terrain_as_list = list(self.tui.game.world.map_.terrain[:])
354             for t in self.tui.game.world.things:
355                 pos_i = self.tui.game.world.map_.get_position_index(t.position)
356                 symbol = self.tui.game.symbol_for_type(t.type_)
357                 if terrain_as_list[pos_i][0] in {'i', '@', 'm'}:
358                     old_symbol = terrain_as_list[pos_i][0]
359                     if old_symbol in {'@', 'm'}:
360                         symbol = old_symbol
361                     terrain_as_list[pos_i] = (symbol, '+')
362                 else:
363                     terrain_as_list[pos_i] = symbol
364             return terrain_as_list
365
366         def pad_or_cut_x(lines):
367             line_width = self.size[1]
368             for y in range(len(lines)):
369                 line = lines[y]
370                 if line_width > len(line):
371                     to_pad = line_width - (len(line) % line_width)
372                     lines[y] = line + '0' * to_pad
373                 else:
374                     lines[y] = line[:line_width]
375
376         def pad_y(lines):
377             if len(lines) < self.size[0]:
378                 to_pad = self.size[0] - len(lines)
379                 lines += to_pad * ['0' * self.size[1]]
380
381         def lines_to_colored_chars(lines):
382             chars_with_attrs = []
383             for c in ''.join(lines):
384                 if c in {'@', 'm'}:
385                     chars_with_attrs += [(c, curses.color_pair(1))]
386                 elif c == 'i':
387                     chars_with_attrs += [(c, curses.color_pair(4))]
388                 elif c == '.':
389                     chars_with_attrs += [(c, curses.color_pair(2))]
390                 elif c in {'x', 'X', '#'}:
391                     chars_with_attrs += [(c, curses.color_pair(3))]
392                 else:
393                     chars_with_attrs += [c]
394             return chars_with_attrs
395
396         if self.tui.game.world.map_.terrain == '':
397             lines = []
398             pad_y(lines)
399             self.safe_write(''.join(lines))
400             return
401
402         annotated_terrain = annotated_terrain()
403         center = self.tui.game.world.player.position
404         lines = self.tui.game.world.map_.format_to_view(annotated_terrain,
405                                                         center, self.size)
406         pad_or_cut_x(lines)
407         pad_y(lines)
408         self.safe_write(lines_to_colored_chars(lines))
409
410
411 class TurnWidget(Widget):
412
413     def draw(self):
414         self.safe_write((str(self.tui.game.world.turn), curses.color_pair(2)))
415
416
417 class TextLineWidget(Widget):
418
419     def __init__(self, text_line, *args, **kwargs):
420         self.text_line = text_line
421         super().__init__(*args, **kwargs)
422
423     def draw(self):
424         self.safe_write(self.text_line)
425
426
427 class TUI:
428
429     def __init__(self, plom_socket, game, q):
430         self.socket = plom_socket
431         self.game = game
432         self.game.tui = self
433         self.queue = q
434         self.parser = Parser(self.game)
435         self.to_update = {}
436         self.item_pointer = 0
437         curses.wrapper(self.loop)
438
439     def loop(self, stdscr):
440
441         def setup_screen(stdscr):
442             self.stdscr = stdscr
443             self.stdscr.refresh()  # will be called by getkey else, clearing screen
444             self.stdscr.timeout(10)
445
446         def switch_widgets(widget_1, widget_2):
447             widget_1.visible = False
448             widget_2.visible = True
449             trigger = widget_2.check_updates[0]
450             self.to_update[trigger] = True
451
452         def pick_or_drop_menu(action_key, widget, selectables, task,
453                               bonus_command=None):
454             if len(selectables) < self.item_pointer + 1 and\
455                self.item_pointer > 0:
456                 self.item_pointer = len(selectables) - 1
457             if key == 'c':
458                 switch_widgets(widget, map_widget)
459             elif key == 'j':
460                 self.item_pointer += 1
461             elif key == 'k' and self.item_pointer > 0:
462                 self.item_pointer -= 1
463             elif key == action_key and len(selectables) > 0:
464                 id_ = selectables[self.item_pointer]
465                 self.socket.send('TASK:%s %s' % (task, id_))
466                 if bonus_command:
467                     self.socket.send(bonus_command)
468                 if self.item_pointer > 0:
469                     self.item_pointer -= 1
470             else:
471                 return
472             trigger = widget.check_updates[0]
473             self.to_update[trigger] = True
474
475         setup_screen(stdscr)
476         curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_RED)
477         curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN)
478         curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_BLUE)
479         curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW)
480         curses.curs_set(False)  # hide cursor
481         self.to_send = []
482         edit_widget = TextLineWidget('SEND:', self, (0, 0), (1, 20))
483         edit_line_widget = EditWidget(self, (0, 6), (1, 14), ['edit'])
484         edit_widget.children += [edit_line_widget]
485         turn_widget = TextLineWidget('TURN:', self, (2, 0), (1, 20))
486         turn_widget.children += [TurnWidget(self, (2, 6), (1, 14), ['turn'])]
487         log_widget = LogWidget(self, (4, 0), (None, 20), ['log'])
488         map_widget = MapWidget(self, (0, 21), (None, None), ['map'])
489         inventory_widget = InventoryWidget(self, (0, 21), (None, None),
490                                            ['inventory'], False)
491         pickable_items_widget = PickableItemsWidget(self, (0, 21), (None, None),
492                                                     ['pickable_items'], False)
493         top_widgets = [edit_widget, turn_widget, log_widget, map_widget,
494                        inventory_widget, pickable_items_widget]
495         popup_widget = PopUpWidget(self, (0, 0), (1, 1), visible=False)
496         self.popup_text = 'Hi bob'
497         write_mode = True
498         for w in top_widgets:
499             w.ensure_freshness(True)
500         draw_popup_if_visible = True
501         while True:
502             for w in top_widgets:
503                 did_refresh = w.ensure_freshness()
504                 draw_popup_if_visible = did_refresh | draw_popup_if_visible
505             if popup_widget.visible and draw_popup_if_visible:
506                 popup_widget.ensure_freshness(True)
507                 draw_popup_if_visible = False
508             for k in self.to_update.keys():
509                 self.to_update[k] = False
510             while True:
511                 try:
512                     command = self.queue.get(block=False)
513                 except queue.Empty:
514                     break
515                 self.game.handle_input(command)
516             try:
517                 key = self.stdscr.getkey()
518                 if key == 'KEY_RESIZE':
519                     curses.endwin()
520                     setup_screen(curses.initscr())
521                     for w in top_widgets:
522                         w.size = w.size_def
523                         w.ensure_freshness(True)
524                 elif key == '\t':  # Tabulator key.
525                     write_mode = False if write_mode else True
526                 elif write_mode:
527                     if len(key) == 1 and key in ASCII_printable and \
528                             len(self.to_send) < len(edit_line_widget):
529                         self.to_send += [key]
530                         self.to_update['edit'] = True
531                     elif key == 'KEY_BACKSPACE':
532                         self.to_send[:] = self.to_send[:-1]
533                         self.to_update['edit'] = True
534                     elif key == '\n':  # Return key
535                         self.socket.send(''.join(self.to_send))
536                         self.to_send[:] = []
537                         self.to_update['edit'] = True
538                 elif key == 't':
539                     if not popup_widget.visible:
540                         self.to_update['popup'] = True
541                         popup_widget.visible = True
542                         popup_widget.reconfigure()
543                         draw_popup_if_visible = True
544                     else:
545                         popup_widget.visible = False
546                         for w in top_widgets:
547                             w.ensure_freshness(True)
548                 elif map_widget.visible:
549                     if key == 'w':
550                         self.socket.send('TASK:MOVE UPLEFT')
551                     elif key == 'e':
552                         self.socket.send('TASK:MOVE UPRIGHT')
553                     if key == 's':
554                         self.socket.send('TASK:MOVE LEFT')
555                     elif key == 'd':
556                         self.socket.send('TASK:MOVE RIGHT')
557                     if key == 'x':
558                         self.socket.send('TASK:MOVE DOWNLEFT')
559                     elif key == 'c':
560                         self.socket.send('TASK:MOVE DOWNRIGHT')
561                     elif key == 'p':
562                         self.socket.send('GET_PICKABLE_ITEMS')
563                         self.item_pointer = 0
564                         switch_widgets(map_widget, pickable_items_widget)
565                     elif key == 'i':
566                         self.item_pointer = 0
567                         switch_widgets(map_widget, inventory_widget)
568                 elif pickable_items_widget.visible:
569                     pick_or_drop_menu('p', pickable_items_widget,
570                                       self.game.world.pickable_items,
571                                       'PICKUP', 'GET_PICKABLE_ITEMS')
572                 elif inventory_widget.visible:
573                     pick_or_drop_menu('d', inventory_widget,
574                                       self.game.world.player_inventory,
575                                       'DROP')
576             except curses.error:
577                 pass
578             if self.game.do_quit:
579                 break
580
581
582 s = socket.create_connection(('127.0.0.1', 5000))
583 plom_socket = PlomSocket(s)
584 game = Game()
585 q = queue.Queue()
586 t = threading.Thread(target=recv_loop, args=(plom_socket, game, q))
587 t.start()
588 TUI(plom_socket, game, q)