home · contact · privacy
Client-wise, add inventory capability and pop-up example widget.
[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
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
13
14 class Map(MapBase):
15
16     def y_cut(self, map_lines, center_y, view_height):
17         map_height = len(map_lines)
18         if map_height > view_height and center_y > view_height / 2:
19             if center_y > map_height - view_height / 2:
20                 map_lines[:] = map_lines[map_height - view_height:]
21             else:
22                 start = center_y - int(view_height / 2) - 1
23                 map_lines[:] = map_lines[start:start + view_height]
24
25     def x_cut(self, map_lines, center_x, view_width, map_width):
26         if map_width > view_width and center_x > view_width / 2:
27             if center_x > map_width - view_width / 2:
28                 cut_start = map_width - view_width
29                 cut_end = None
30             else:
31                 cut_start = center_x - int(view_width / 2)
32                 cut_end = cut_start + view_width
33             map_lines[:] = [line[cut_start:cut_end] for line in map_lines]
34
35     def format_to_view(self, map_string, center, size):
36
37         def map_string_to_lines(map_string):
38             map_view_chars = ['0']
39             x = 0
40             y = 0
41             for c in map_string:
42                 map_view_chars += [c, ' ']
43                 x += 1
44                 if x == self.size[1]:
45                     map_view_chars += ['\n']
46                     x = 0
47                     y += 1
48                     if y % 2 == 0:
49                         map_view_chars += ['0']
50             if y % 2 == 0:
51                 map_view_chars = map_view_chars[:-1]
52             map_view_chars = map_view_chars[:-1]
53             return ''.join(map_view_chars).split('\n')
54
55         map_lines = map_string_to_lines(map_string)
56         self.y_cut(map_lines, center[0], size[0])
57         map_width = self.size[1] * 2 + 1
58         self.x_cut(map_lines, center[1] * 2, size[1], map_width)
59         return map_lines
60
61
62 class World(WorldBase):
63
64     def __init__(self, *args, **kwargs):
65         """Extend original with local classes and empty default map.
66
67         We need the empty default map because we draw the map widget
68         on any update, even before we actually receive map data.
69         """
70         super().__init__(*args, **kwargs)
71         self.map_ = Map()
72         self.player_position = (0, 0)
73         self.player_inventory = []
74
75     def new_map(self, yx):
76         self.map_ = Map(yx)
77
78
79 def cmd_LAST_PLAYER_TASK_RESULT(game, msg):
80     if msg != "success":
81         game.log(msg)
82 cmd_LAST_PLAYER_TASK_RESULT.argtypes = 'string'
83
84 def cmd_TURN_FINISHED(game, n):
85     """Do nothing. (This may be extended later.)"""
86     pass
87 cmd_TURN_FINISHED.argtypes = 'int:nonneg'
88
89 def cmd_TURN(game, n):
90     """Set game.turn to n, empty game.things."""
91     game.world.turn = n
92     game.world.things = []
93     game.to_update['turn'] = False
94     game.to_update['map'] = False
95 cmd_TURN.argtypes = 'int:nonneg'
96
97 def cmd_VISIBLE_MAP_LINE(game, y, terrain_line):
98     game.world.map_.set_line(y, terrain_line)
99 cmd_VISIBLE_MAP_LINE.argtypes = 'int:nonneg string'
100
101 def cmd_PLAYER_POS(game, yx):
102     game.world.player_position = yx
103 cmd_PLAYER_POS.argtypes = 'yx_tuple:pos'
104
105 def cmd_GAME_STATE_COMPLETE(game):
106     game.to_update['turn'] = True
107     game.to_update['map'] = True
108
109 def cmd_THING_TYPE(game, i, type_):
110     t = game.world.get_thing(i)
111     t.type_ = type_
112 cmd_THING_TYPE.argtypes = 'int:nonneg string'
113
114 def cmd_PLAYER_INVENTORY(game, ids):
115     game.world.player_inventory = [ids]  # TODO: test whether valid IDs
116 cmd_PLAYER_INVENTORY.argtypes = 'seq:int:nonneg'
117
118
119 class Game:
120
121     def __init__(self):
122         self.parser = Parser(self)
123         self.world = World(self)
124         self.thing_type = ThingBase
125         self.commands = {'LAST_PLAYER_TASK_RESULT': cmd_LAST_PLAYER_TASK_RESULT,
126                          'TURN_FINISHED': cmd_TURN_FINISHED,
127                          'TURN': cmd_TURN,
128                          'VISIBLE_MAP_LINE': cmd_VISIBLE_MAP_LINE,
129                          'PLAYER_POS': cmd_PLAYER_POS,
130                          'GAME_STATE_COMPLETE': cmd_GAME_STATE_COMPLETE,
131                          'MAP': cmd_MAP,
132                          'THING_TYPE': cmd_THING_TYPE,
133                          'THING_POS': cmd_THING_POS}
134         self.log_text = ''
135         self.to_update = {
136             'log': True,
137             'map': True,
138             'turn': True,
139             }
140         self.do_quit = False
141
142     def get_command(self, command_name):
143         from functools import partial
144         if command_name in self.commands:
145             f = partial(self.commands[command_name], self)
146             if hasattr(self.commands[command_name], 'argtypes'):
147                 f.argtypes = self.commands[command_name].argtypes
148             return f
149         return None
150
151     def get_string_options(self, string_option_type):
152         return None
153
154     def handle_input(self, msg):
155         if msg == 'BYE':
156             self.do_quit = True
157             return
158         try:
159             command, args = self.parser.parse(msg)
160             if command is None:
161                 self.log('UNHANDLED INPUT: ' + msg)
162                 self.to_update['log'] = True
163             else:
164                 command(*args)
165         except ArgError as e:
166             self.log('ARGUMENT ERROR: ' + msg + '\n' + str(e))
167             self.to_update['log'] = True
168
169     def log(self, msg):
170         """Prefix msg plus newline to self.log_text."""
171         self.log_text = msg + '\n' + self.log_text
172         self.to_update['log'] = True
173
174     def symbol_for_type(self, type_):
175         symbol = '?'
176         if type_ == 'human':
177             symbol = '@'
178         elif type_ == 'monster':
179             symbol = 'm'
180         elif type_ == 'item':
181             symbol = 'i'
182         return symbol
183
184
185 ASCII_printable = ' !"#$%&\'\(\)*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWX'\
186                   'YZ[\\]^_\`abcdefghijklmnopqrstuvwxyz{|}~'
187
188
189 def recv_loop(plom_socket, game):
190     for msg in plom_socket.recv():
191         game.handle_input(msg)
192
193
194 class Widget:
195
196     def __init__(self, tui, start, size, check_game=[], check_tui=[]):
197         self.check_game = check_game
198         self.check_tui = check_tui
199         self.tui = tui
200         self.start = start
201         self.win = curses.newwin(1, 1, self.start[0], self.start[1])
202         self.size_def = size  # store for re-calling .size on SIGWINCH
203         self.size = size
204         self.do_update = True
205         self.visible = True
206
207     @property
208     def size(self):
209         return self.win.getmaxyx()
210
211     @size.setter
212     def size(self, size):
213         """Set window size. Size be y,x tuple. If y or x None, use legal max."""
214         n_lines, n_cols = size
215         if n_lines is None:
216             n_lines = self.tui.stdscr.getmaxyx()[0] - self.start[0]
217         if n_cols is None:
218             n_cols = self.tui.stdscr.getmaxyx()[1] - self.start[1]
219         self.win.resize(n_lines, n_cols)
220
221     def __len__(self):
222         return self.win.getmaxyx()[0] * self.win.getmaxyx()[1]
223
224     def safe_write(self, foo):
225
226         def to_chars_with_attrs(part):
227             attr = curses.A_NORMAL
228             part_string = part
229             if not type(part) == str:
230                 part_string = part[0]
231                 attr = part[1]
232             if len(part_string) > 0:
233                 return [(char, attr) for char in part_string]
234             elif len(part_string) == 1:
235                 return [part]
236             return []
237
238         chars_with_attrs = []
239         if type(foo) == str or len(foo) == 2 and type(foo[1]) == int:
240             chars_with_attrs += to_chars_with_attrs(foo)
241         else:
242             for part in foo:
243                 chars_with_attrs += to_chars_with_attrs(part)
244         self.win.move(0, 0)
245         if len(chars_with_attrs) < len(self):
246             for char_with_attr in chars_with_attrs:
247                 self.win.addstr(char_with_attr[0], char_with_attr[1])
248         else:  # workaround to <https://stackoverflow.com/q/7063128>
249             cut = chars_with_attrs[:len(self) - 1]
250             last_char_with_attr = chars_with_attrs[len(self) - 1]
251             self.win.addstr(self.size[0] - 1, self.size[1] - 2,
252                             last_char_with_attr[0], last_char_with_attr[1])
253             self.win.insstr(self.size[0] - 1, self.size[1] - 2, ' ')
254             self.win.move(0, 0)
255             for char_with_attr in cut:
256                 self.win.addstr(char_with_attr[0], char_with_attr[1])
257
258     def ensure_freshness(self, do_refresh=False):
259         if not self.visible:
260             return
261         if not do_refresh:
262             for key in self.check_game:
263                 if key in self.tui.game.to_update and self.tui.game.to_update[key]:
264                     do_refresh = True
265                     break
266         if not do_refresh:
267             for key in self.check_tui:
268                 if key in self.tui.to_update and self.tui.to_update[key]:
269                     do_refresh = True
270                     break
271         if do_refresh:
272             self.win.erase()
273             self.draw()
274             self.win.refresh()
275
276
277 class EditWidget(Widget):
278
279     def draw(self):
280         self.safe_write((''.join(self.tui.to_send), curses.color_pair(1)))
281
282
283 class LogWidget(Widget):
284
285     def draw(self):
286         line_width = self.size[1]
287         log_lines = self.tui.game.log_text.split('\n')
288         to_join = []
289         for line in log_lines:
290             to_pad = line_width - (len(line) % line_width)
291             if to_pad == line_width:
292                 to_pad = 0
293             to_join += [line + ' '*to_pad]
294         self.safe_write((''.join(to_join), curses.color_pair(3)))
295
296
297 class PopUpWidget(Widget):
298
299     def draw(self):
300         self.safe_write(self.tui.popup_text)
301
302     def reconfigure(self):
303         self.visible = True
304         size = (1, len(self.tui.popup_text))
305         self.size = size
306         self.size_def = size
307         offset_y = int((self.tui.stdscr.getmaxyx()[0] / 2) - (size[0] / 2))
308         offset_x = int((self.tui.stdscr.getmaxyx()[1] / 2) - (size[1] / 2))
309         self.start = (offset_y, offset_x)
310         self.win.mvwin(self.start[0], self.start[1])
311         self.ensure_freshness(True)
312
313
314
315 class MapWidget(Widget):
316
317     def draw(self):
318
319         def terrain_with_objects():
320             terrain_as_list = list(self.tui.game.world.map_.terrain[:])
321             for t in self.tui.game.world.things:
322                 pos_i = self.tui.game.world.map_.get_position_index(t.position)
323                 symbol = self.tui.game.symbol_for_type(t.type_)
324                 if symbol in {'i'} and terrain_as_list[pos_i] in {'@', 'm'}:
325                     continue
326                 terrain_as_list[pos_i] = symbol
327             return ''.join(terrain_as_list)
328
329         def pad_or_cut_x(lines):
330             line_width = self.size[1]
331             for y in range(len(lines)):
332                 line = lines[y]
333                 if line_width > len(line):
334                     to_pad = line_width - (len(line) % line_width)
335                     lines[y] = line + '0' * to_pad
336                 else:
337                     lines[y] = line[:line_width]
338
339         def pad_y(lines):
340             if len(lines) < self.size[0]:
341                 to_pad = self.size[0] - len(lines)
342                 lines += to_pad * ['0' * self.size[1]]
343
344         def lines_to_colored_chars(lines):
345             chars_with_attrs = []
346             for c in ''.join(lines):
347                 if c in {'@', 'm'}:
348                     chars_with_attrs += [(c, curses.color_pair(1))]
349                 elif c == 'i':
350                     chars_with_attrs += [(c, curses.color_pair(4))]
351                 elif c == '.':
352                     chars_with_attrs += [(c, curses.color_pair(2))]
353                 elif c in {'x', 'X', '#'}:
354                     chars_with_attrs += [(c, curses.color_pair(3))]
355                 else:
356                     chars_with_attrs += [c]
357             return chars_with_attrs
358
359         if self.tui.game.world.map_.terrain == '':
360             lines = []
361             pad_y(lines)
362             self.safe_write(''.join(lines))
363             return
364
365         terrain_with_objects = terrain_with_objects()
366         center = self.tui.game.world.player_position
367         lines = self.tui.game.world.map_.format_to_view(terrain_with_objects,
368                                                         center, self.size)
369         pad_or_cut_x(lines)
370         pad_y(lines)
371         self.safe_write(lines_to_colored_chars(lines))
372
373
374 class TurnWidget(Widget):
375
376     def draw(self):
377         self.safe_write((str(self.tui.game.world.turn), curses.color_pair(2)))
378
379
380 class TUI:
381
382     def __init__(self, plom_socket, game):
383         self.socket = plom_socket
384         self.game = game
385         self.parser = Parser(self.game)
386         self.to_update = {'edit': False}
387         curses.wrapper(self.loop)
388
389     def draw_screen(self):
390         self.stdscr.addstr(0, 0, 'SEND:')
391         self.stdscr.addstr(2, 0, 'TURN:')
392
393     def setup_screen(self, stdscr):
394         self.stdscr = stdscr
395         self.stdscr.refresh()  # will be called by getkey else, clearing screen
396         self.stdscr.timeout(10)
397         self.draw_screen()
398
399     def loop(self, stdscr):
400         self.setup_screen(stdscr)
401         curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_RED)
402         curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN)
403         curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_BLUE)
404         curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW)
405         curses.curs_set(False)  # hide cursor
406         self.to_send = []
407         self.edit = EditWidget(self, (0, 6), (1, 14), check_tui = ['edit'])
408         self.turn = TurnWidget(self, (2, 6), (1, 14), ['turn'])
409         self.log = LogWidget(self, (4, 0), (None, 20), ['log'])
410         self.map_ = MapWidget(self, (0, 21), (None, None), ['map'])
411         self.popup = PopUpWidget(self, (0, 0), (1, 1), ['popup'])
412         self.popup.visible = False
413         self.popup_text = 'Hi bob'
414         widgets = (self.edit, self.turn, self.log, self.map_, self.popup)
415         map_mode = False
416         while True:
417             for w in widgets:
418                 w.ensure_freshness()
419             for key in self.game.to_update:
420                 self.game.to_update[key] = False
421             for key in self.to_update:
422                 self.to_update[key] = False
423             try:
424                 key = self.stdscr.getkey()
425                 if key == 'KEY_RESIZE':
426                     curses.endwin()
427                     self.setup_screen(curses.initscr())
428                     for w in widgets:
429                         w.size = w.size_def
430                         w.ensure_freshness(True)
431                 elif key == '\t':  # Tabulator key.
432                     map_mode = False if map_mode else True
433                 elif map_mode:
434                     if key == 'w':
435                         self.socket.send('TASK:MOVE UPLEFT')
436                     elif key == 'e':
437                         self.socket.send('TASK:MOVE UPRIGHT')
438                     if key == 's':
439                         self.socket.send('TASK:MOVE LEFT')
440                     elif key == 'd':
441                         self.socket.send('TASK:MOVE RIGHT')
442                     if key == 'x':
443                         self.socket.send('TASK:MOVE DOWNLEFT')
444                     elif key == 'c':
445                         self.socket.send('TASK:MOVE DOWNRIGHT')
446                     elif key == 't':
447                         if not self.popup.visible:
448                             self.to_update['popup'] = True
449                             self.popup.visible = True
450                             self.popup.reconfigure()
451                         else:
452                             self.popup.visible = False
453                             self.stdscr.erase()    # we'll call refresh here so
454                             self.stdscr.refresh()  # getkey doesn't, erasing screen
455                             self.draw_screen()
456                             for w in widgets:
457                                 w.ensure_freshness(True)
458                 else:
459                     if len(key) == 1 and key in ASCII_printable and \
460                             len(self.to_send) < len(self.edit):
461                         self.to_send += [key]
462                         self.to_update['edit'] = True
463                     elif key == 'KEY_BACKSPACE':
464                         self.to_send[:] = self.to_send[:-1]
465                         self.to_update['edit'] = True
466                     elif key == '\n':  # Return key
467                         self.socket.send(''.join(self.to_send))
468                         self.to_send[:] = []
469                         self.to_update['edit'] = True
470             except curses.error:
471                 pass
472             if self.game.do_quit:
473                 break
474
475
476 s = socket.create_connection(('127.0.0.1', 5000))
477 plom_socket = PlomSocket(s)
478 game = Game()
479 t = threading.Thread(target=recv_loop, args=(plom_socket, game))
480 t.start()
481 TUI(plom_socket, game)