home · contact · privacy
Ensure popup stays on even if background window changes.
[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
110 def cmd_THING_TYPE(game, i, type_):
111     t = game.world.get_thing(i)
112     t.type_ = type_
113 cmd_THING_TYPE.argtypes = 'int:nonneg string'
114
115 def cmd_PLAYER_INVENTORY(game, ids):
116     game.world.player_inventory = ids  # TODO: test whether valid IDs
117 cmd_PLAYER_INVENTORY.argtypes = 'seq:int:nonneg'
118
119 def cmd_PICKABLE_ITEMS(game, ids):
120     game.world.pickable_items = ids
121     game.tui.to_update['map'] = True
122 cmd_PICKABLE_ITEMS.argtypes = 'seq:int:nonneg'
123
124
125 class Game:
126
127     def __init__(self):
128         self.parser = Parser(self)
129         self.world = World(self)
130         self.thing_type = ThingBase
131         self.commands = {'LAST_PLAYER_TASK_RESULT': cmd_LAST_PLAYER_TASK_RESULT,
132                          'TURN_FINISHED': cmd_TURN_FINISHED,
133                          'TURN': cmd_TURN,
134                          'VISIBLE_MAP_LINE': cmd_VISIBLE_MAP_LINE,
135                          'PLAYER_ID': cmd_PLAYER_ID,
136                          'PLAYER_INVENTORY': cmd_PLAYER_INVENTORY,
137                          'GAME_STATE_COMPLETE': cmd_GAME_STATE_COMPLETE,
138                          'MAP': cmd_MAP,
139                          'PICKABLE_ITEMS': cmd_PICKABLE_ITEMS,
140                          'THING_TYPE': cmd_THING_TYPE,
141                          'THING_POS': cmd_THING_POS}
142         self.log_text = ''
143         self.do_quit = False
144         self.tui = None
145
146     def get_command(self, command_name):
147         from functools import partial
148         if command_name in self.commands:
149             f = partial(self.commands[command_name], self)
150             if hasattr(self.commands[command_name], 'argtypes'):
151                 f.argtypes = self.commands[command_name].argtypes
152             return f
153         return None
154
155     def get_string_options(self, string_option_type):
156         return None
157
158     def handle_input(self, msg):
159         self.log(msg)
160         if msg == 'BYE':
161             self.do_quit = True
162             return
163         try:
164             command, args = self.parser.parse(msg)
165             if command is None:
166                 self.log('UNHANDLED INPUT: ' + msg)
167             else:
168                 command(*args)
169         except ArgError as e:
170             self.log('ARGUMENT ERROR: ' + msg + '\n' + str(e))
171
172     def log(self, msg):
173         """Prefix msg plus newline to self.log_text."""
174         self.log_text = msg + '\n' + self.log_text
175         self.tui.to_update['log'] = True
176
177     def symbol_for_type(self, type_):
178         symbol = '?'
179         if type_ == 'human':
180             symbol = '@'
181         elif type_ == 'monster':
182             symbol = 'm'
183         elif type_ == 'item':
184             symbol = 'i'
185         return symbol
186
187
188 ASCII_printable = ' !"#$%&\'\(\)*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWX'\
189                   'YZ[\\]^_\`abcdefghijklmnopqrstuvwxyz{|}~'
190
191
192 def recv_loop(plom_socket, game, q):
193     for msg in plom_socket.recv():
194         q.put(msg)
195
196
197 class Widget:
198
199     def __init__(self, tui, start, size, check_updates=[]):
200         self.check_updates = check_updates
201         self.tui = tui
202         self.start = start
203         self.win = curses.newwin(1, 1, self.start[0], self.start[1])
204         self.size_def = size  # store for re-calling .size on SIGWINCH
205         self.size = size
206         self.do_update = True
207         self.visible = True
208         self.children = []
209
210     @property
211     def size(self):
212         return self.win.getmaxyx()
213
214     @size.setter
215     def size(self, size):
216         """Set window size. Size be y,x tuple. If y or x None, use legal max."""
217         n_lines, n_cols = size
218         if n_lines is None:
219             n_lines = self.tui.stdscr.getmaxyx()[0] - self.start[0]
220         if n_cols is None:
221             n_cols = self.tui.stdscr.getmaxyx()[1] - self.start[1]
222         self.win.resize(n_lines, n_cols)
223
224     def __len__(self):
225         return self.win.getmaxyx()[0] * self.win.getmaxyx()[1]
226
227     def safe_write(self, foo):
228
229         def to_chars_with_attrs(part):
230             attr = curses.A_NORMAL
231             part_string = part
232             if not type(part) == str:
233                 part_string = part[0]
234                 attr = part[1]
235             if len(part_string) > 0:
236                 return [(char, attr) for char in part_string]
237             elif len(part_string) == 1:
238                 return [part]
239             return []
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 MapWidget(Widget):
316
317     def draw(self):
318         if self.tui.view == 'map':
319             self.draw_map()
320         elif self.tui.view == 'inventory':
321             self.draw_item_selector('INVENTORY:',
322                                     self.tui.game.world.player_inventory)
323         elif self.tui.view == 'pickable_items':
324             self.draw_item_selector('PICKABLE:',
325                                     self.tui.game.world.pickable_items)
326
327     def draw_item_selector(self, title, selection):
328         lines = [title]
329         counter = 0
330         for id_ in selection:
331             pointer = '*' if counter == self.tui.item_pointer else ' '
332             t = self.tui.game.world.get_thing(id_)
333             lines += ['%s %s' % (pointer, t.type_)]
334             counter += 1
335         line_width = self.size[1]
336         to_join = []
337         for line in lines:
338             to_pad = line_width - (len(line) % line_width)
339             if to_pad == line_width:
340                 to_pad = 0
341             to_join += [line + ' '*to_pad]
342         self.safe_write((''.join(to_join), curses.color_pair(3)))
343
344     def draw_map(self):
345
346         def terrain_with_objects():
347             terrain_as_list = list(self.tui.game.world.map_.terrain[:])
348             for t in self.tui.game.world.things:
349                 pos_i = self.tui.game.world.map_.get_position_index(t.position)
350                 symbol = self.tui.game.symbol_for_type(t.type_)
351                 if symbol in {'i'} and terrain_as_list[pos_i] in {'@', 'm'}:
352                     continue
353                 terrain_as_list[pos_i] = symbol
354             return ''.join(terrain_as_list)
355
356         def pad_or_cut_x(lines):
357             line_width = self.size[1]
358             for y in range(len(lines)):
359                 line = lines[y]
360                 if line_width > len(line):
361                     to_pad = line_width - (len(line) % line_width)
362                     lines[y] = line + '0' * to_pad
363                 else:
364                     lines[y] = line[:line_width]
365
366         def pad_y(lines):
367             if len(lines) < self.size[0]:
368                 to_pad = self.size[0] - len(lines)
369                 lines += to_pad * ['0' * self.size[1]]
370
371         def lines_to_colored_chars(lines):
372             chars_with_attrs = []
373             for c in ''.join(lines):
374                 if c in {'@', 'm'}:
375                     chars_with_attrs += [(c, curses.color_pair(1))]
376                 elif c == 'i':
377                     chars_with_attrs += [(c, curses.color_pair(4))]
378                 elif c == '.':
379                     chars_with_attrs += [(c, curses.color_pair(2))]
380                 elif c in {'x', 'X', '#'}:
381                     chars_with_attrs += [(c, curses.color_pair(3))]
382                 else:
383                     chars_with_attrs += [c]
384             return chars_with_attrs
385
386         if self.tui.game.world.map_.terrain == '':
387             lines = []
388             pad_y(lines)
389             self.safe_write(''.join(lines))
390             return
391
392         terrain_with_objects = terrain_with_objects()
393         center = self.tui.game.world.player.position
394         lines = self.tui.game.world.map_.format_to_view(terrain_with_objects,
395                                                         center, self.size)
396         pad_or_cut_x(lines)
397         pad_y(lines)
398         self.safe_write(lines_to_colored_chars(lines))
399
400
401 class TurnWidget(Widget):
402
403     def draw(self):
404         self.safe_write((str(self.tui.game.world.turn), curses.color_pair(2)))
405
406
407 class TextLineWidget(Widget):
408
409     def __init__(self, text_line, *args, **kwargs):
410         self.text_line = text_line
411         super().__init__(*args, **kwargs)
412
413     def draw(self):
414         self.safe_write(self.text_line)
415
416
417 class TUI:
418
419     def __init__(self, plom_socket, game, q):
420         self.socket = plom_socket
421         self.game = game
422         self.game.tui = self
423         self.queue = q
424         self.parser = Parser(self.game)
425         self.to_update = {}
426         self.item_pointer = 0
427         curses.wrapper(self.loop)
428
429     def setup_screen(self, stdscr):
430         self.stdscr = stdscr
431         self.stdscr.refresh()  # will be called by getkey else, clearing screen
432         self.stdscr.timeout(10)
433
434     def loop(self, stdscr):
435         self.setup_screen(stdscr)
436         curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_RED)
437         curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN)
438         curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_BLUE)
439         curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW)
440         curses.curs_set(False)  # hide cursor
441         self.to_send = []
442         edit_widget = TextLineWidget('SEND:', self, (0, 0), (1, 20))
443         edit_line_widget = EditWidget(self, (0, 6), (1, 14), ['edit'])
444         edit_widget.children += [edit_line_widget]
445         turn_widget = TextLineWidget('TURN:', self, (2, 0), (1, 20))
446         turn_widget.children += [TurnWidget(self, (2, 6), (1, 14), ['turn'])]
447         log_widget = LogWidget(self, (4, 0), (None, 20), ['log'])
448         map_widget = MapWidget(self, (0, 21), (None, None), ['map'])
449         top_widgets = [edit_widget, turn_widget, log_widget, map_widget]
450         popup_widget = PopUpWidget(self, (0, 0), (1, 1))
451         popup_widget.visible = False
452         self.popup_text = 'Hi bob'
453         write_mode = True
454         self.view = 'map'
455         for w in top_widgets:
456             w.ensure_freshness(True)
457         draw_popup_if_visible = True
458         while True:
459             for w in top_widgets:
460                 did_refresh = w.ensure_freshness()
461                 draw_popup_if_visible = did_refresh | draw_popup_if_visible
462             if popup_widget.visible and draw_popup_if_visible:
463                 popup_widget.ensure_freshness(True)
464                 draw_popup_if_visible = False
465             for k in self.to_update.keys():
466                 self.to_update[k] = False
467             while True:
468                 try:
469                     command = self.queue.get(block=False)
470                 except queue.Empty:
471                     break
472                 self.game.handle_input(command)
473             try:
474                 key = self.stdscr.getkey()
475                 if key == 'KEY_RESIZE':
476                     curses.endwin()
477                     self.setup_screen(curses.initscr())
478                     for w in top_widgets:
479                         w.size = w.size_def
480                         w.ensure_freshness(True)
481                 elif key == '\t':  # Tabulator key.
482                     write_mode = False if write_mode else True
483                 elif write_mode:
484                     if len(key) == 1 and key in ASCII_printable and \
485                             len(self.to_send) < len(edit_line_widget):
486                         self.to_send += [key]
487                         self.to_update['edit'] = True
488                     elif key == 'KEY_BACKSPACE':
489                         self.to_send[:] = self.to_send[:-1]
490                         self.to_update['edit'] = True
491                     elif key == '\n':  # Return key
492                         self.socket.send(''.join(self.to_send))
493                         self.to_send[:] = []
494                         self.to_update['edit'] = True
495                 elif self.view == 'map':
496                     if key == 'w':
497                         self.socket.send('TASK:MOVE UPLEFT')
498                     elif key == 'e':
499                         self.socket.send('TASK:MOVE UPRIGHT')
500                     if key == 's':
501                         self.socket.send('TASK:MOVE LEFT')
502                     elif key == 'd':
503                         self.socket.send('TASK:MOVE RIGHT')
504                     if key == 'x':
505                         self.socket.send('TASK:MOVE DOWNLEFT')
506                     elif key == 'c':
507                         self.socket.send('TASK:MOVE DOWNRIGHT')
508                     elif key == 't':
509                         if not popup_widget.visible:
510                             self.to_update['popup'] = True
511                             popup_widget.visible = True
512                             popup_widget.reconfigure()
513                             draw_popup_if_visible = True
514                         else:
515                             popup_widget.visible = False
516                             for w in top_widgets:
517                                 w.ensure_freshness(True)
518                     elif key == 'p':
519                         self.socket.send('GET_PICKABLE_ITEMS')
520                         self.item_pointer = 0
521                         self.view = 'pickable_items'
522                     elif key == 'i':
523                         self.item_pointer = 0
524                         self.view = 'inventory'
525                         self.to_update['map'] = True
526                 elif self.view == 'pickable_items':
527                     if key == 'c':
528                         self.view = 'map'
529                     elif key == 'j' and \
530                          len(self.game.world.pickable_items) > \
531                          self.item_pointer + 1:
532                         self.item_pointer += 1
533                     elif key == 'k' and self.item_pointer > 0:
534                         self.item_pointer -= 1
535                     elif key == 'p' and \
536                          len(self.game.world.pickable_items) > 0:
537                         id_ = self.game.world.pickable_items[self.item_pointer]
538                         self.socket.send('TASK:PICKUP %s' % id_)
539                         self.socket.send('GET_PICKABLE_ITEMS')
540                         if self.item_pointer > 0:
541                             self.item_pointer -= 1
542                     else:
543                         continue
544                     self.to_update['map'] = True
545                 elif self.view == 'inventory':
546                     if key == 'c':
547                         self.view = 'map'
548                     elif key == 'j' and \
549                          len(self.game.world.player_inventory) > \
550                          self.item_pointer + 1:
551                         self.item_pointer += 1
552                     elif key == 'k' and self.item_pointer > 0:
553                         self.item_pointer -= 1
554                     elif key == 'd' and \
555                          len(self.game.world.player_inventory) > 0:
556                         id_ = self.game.world.player_inventory[self.item_pointer]
557                         self.socket.send('TASK:DROP %s' % id_)
558                         if self.item_pointer > 0:
559                             self.item_pointer -= 1
560                     else:
561                         continue
562                     self.to_update['map'] = True
563             except curses.error:
564                 pass
565             if self.game.do_quit:
566                 break
567
568
569 s = socket.create_connection(('127.0.0.1', 5000))
570 plom_socket = PlomSocket(s)
571 game = Game()
572 q = queue.Queue()
573 t = threading.Thread(target=recv_loop, args=(plom_socket, game, q))
574 t.start()
575 TUI(plom_socket, game, q)