home · contact · privacy
b561d9ddbdbb380e4ea285b374026b0ecacb0379
[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
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_inventory = []
73         self.player_id = 0
74
75     def new_map(self, yx):
76         self.map_ = Map(yx)
77
78     @property
79     def player(self):
80         return self.get_thing(self.player_id)
81
82
83 def cmd_LAST_PLAYER_TASK_RESULT(game, msg):
84     if msg != "success":
85         game.log(msg)
86 cmd_LAST_PLAYER_TASK_RESULT.argtypes = 'string'
87
88 def cmd_TURN_FINISHED(game, n):
89     """Do nothing. (This may be extended later.)"""
90     pass
91 cmd_TURN_FINISHED.argtypes = 'int:nonneg'
92
93 def cmd_TURN(game, n):
94     """Set game.turn to n, empty game.things."""
95     game.world.turn = n
96     game.world.things = []
97     game.to_update['turn'] = False
98     game.to_update['map'] = False
99 cmd_TURN.argtypes = 'int:nonneg'
100
101 def cmd_VISIBLE_MAP_LINE(game, y, terrain_line):
102     game.world.map_.set_line(y, terrain_line)
103 cmd_VISIBLE_MAP_LINE.argtypes = 'int:nonneg string'
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_ID': cmd_PLAYER_ID,
130                          'PLAYER_INVENTORY': cmd_PLAYER_INVENTORY,
131                          'GAME_STATE_COMPLETE': cmd_GAME_STATE_COMPLETE,
132                          'MAP': cmd_MAP,
133                          'THING_TYPE': cmd_THING_TYPE,
134                          'THING_POS': cmd_THING_POS}
135         self.log_text = ''
136         self.to_update = {
137             'log': True,
138             'map': True,
139             'turn': True,
140             }
141         self.do_quit = False
142
143     def get_command(self, command_name):
144         from functools import partial
145         if command_name in self.commands:
146             f = partial(self.commands[command_name], self)
147             if hasattr(self.commands[command_name], 'argtypes'):
148                 f.argtypes = self.commands[command_name].argtypes
149             return f
150         return None
151
152     def get_string_options(self, string_option_type):
153         return None
154
155     def handle_input(self, msg):
156         if msg == 'BYE':
157             self.do_quit = True
158             return
159         try:
160             command, args = self.parser.parse(msg)
161             if command is None:
162                 self.log('UNHANDLED INPUT: ' + msg)
163                 self.to_update['log'] = True
164             else:
165                 command(*args)
166         except ArgError as e:
167             self.log('ARGUMENT ERROR: ' + msg + '\n' + str(e))
168             self.to_update['log'] = True
169
170     def log(self, msg):
171         """Prefix msg plus newline to self.log_text."""
172         self.log_text = msg + '\n' + self.log_text
173         self.to_update['log'] = True
174
175     def symbol_for_type(self, type_):
176         symbol = '?'
177         if type_ == 'human':
178             symbol = '@'
179         elif type_ == 'monster':
180             symbol = 'm'
181         elif type_ == 'item':
182             symbol = 'i'
183         return symbol
184
185
186 ASCII_printable = ' !"#$%&\'\(\)*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWX'\
187                   'YZ[\\]^_\`abcdefghijklmnopqrstuvwxyz{|}~'
188
189
190 def recv_loop(plom_socket, game):
191     for msg in plom_socket.recv():
192         game.handle_input(msg)
193
194
195 class Widget:
196
197     def __init__(self, tui, start, size, check_game=[], check_tui=[]):
198         self.check_game = check_game
199         self.check_tui = check_tui
200         self.tui = tui
201         self.start = start
202         self.win = curses.newwin(1, 1, self.start[0], self.start[1])
203         self.size_def = size  # store for re-calling .size on SIGWINCH
204         self.size = size
205         self.do_update = True
206         self.visible = True
207
208     @property
209     def size(self):
210         return self.win.getmaxyx()
211
212     @size.setter
213     def size(self, size):
214         """Set window size. Size be y,x tuple. If y or x None, use legal max."""
215         n_lines, n_cols = size
216         if n_lines is None:
217             n_lines = self.tui.stdscr.getmaxyx()[0] - self.start[0]
218         if n_cols is None:
219             n_cols = self.tui.stdscr.getmaxyx()[1] - self.start[1]
220         self.win.resize(n_lines, n_cols)
221
222     def __len__(self):
223         return self.win.getmaxyx()[0] * self.win.getmaxyx()[1]
224
225     def safe_write(self, foo):
226
227         def to_chars_with_attrs(part):
228             attr = curses.A_NORMAL
229             part_string = part
230             if not type(part) == str:
231                 part_string = part[0]
232                 attr = part[1]
233             if len(part_string) > 0:
234                 return [(char, attr) for char in part_string]
235             elif len(part_string) == 1:
236                 return [part]
237             return []
238
239         chars_with_attrs = []
240         if type(foo) == str or len(foo) == 2 and type(foo[1]) == int:
241             chars_with_attrs += to_chars_with_attrs(foo)
242         else:
243             for part in foo:
244                 chars_with_attrs += to_chars_with_attrs(part)
245         self.win.move(0, 0)
246         if len(chars_with_attrs) < len(self):
247             for char_with_attr in chars_with_attrs:
248                 self.win.addstr(char_with_attr[0], char_with_attr[1])
249         else:  # workaround to <https://stackoverflow.com/q/7063128>
250             cut = chars_with_attrs[:len(self) - 1]
251             last_char_with_attr = chars_with_attrs[len(self) - 1]
252             self.win.addstr(self.size[0] - 1, self.size[1] - 2,
253                             last_char_with_attr[0], last_char_with_attr[1])
254             self.win.insstr(self.size[0] - 1, self.size[1] - 2, ' ')
255             self.win.move(0, 0)
256             for char_with_attr in cut:
257                 self.win.addstr(char_with_attr[0], char_with_attr[1])
258
259     def ensure_freshness(self, do_refresh=False):
260         if not self.visible:
261             return
262         if not do_refresh:
263             for key in self.check_game:
264                 if key in self.tui.game.to_update and self.tui.game.to_update[key]:
265                     do_refresh = True
266                     break
267         if not do_refresh:
268             for key in self.check_tui:
269                 if key in self.tui.to_update and self.tui.to_update[key]:
270                     do_refresh = True
271                     break
272         if do_refresh:
273             self.win.erase()
274             self.draw()
275             self.win.refresh()
276
277
278 class EditWidget(Widget):
279
280     def draw(self):
281         self.safe_write((''.join(self.tui.to_send), curses.color_pair(1)))
282
283
284 class LogWidget(Widget):
285
286     def draw(self):
287         line_width = self.size[1]
288         log_lines = self.tui.game.log_text.split('\n')
289         to_join = []
290         for line in log_lines:
291             to_pad = line_width - (len(line) % line_width)
292             if to_pad == line_width:
293                 to_pad = 0
294             to_join += [line + ' '*to_pad]
295         self.safe_write((''.join(to_join), curses.color_pair(3)))
296
297
298 class PopUpWidget(Widget):
299
300     def draw(self):
301         self.safe_write(self.tui.popup_text)
302
303     def reconfigure(self):
304         self.visible = True
305         size = (1, len(self.tui.popup_text))
306         self.size = size
307         self.size_def = size
308         offset_y = int((self.tui.stdscr.getmaxyx()[0] / 2) - (size[0] / 2))
309         offset_x = int((self.tui.stdscr.getmaxyx()[1] / 2) - (size[1] / 2))
310         self.start = (offset_y, offset_x)
311         self.win.mvwin(self.start[0], self.start[1])
312         self.ensure_freshness(True)
313
314
315
316 class MapWidget(Widget):
317
318     def draw(self):
319         if self.tui.view == 'map':
320             self.draw_map()
321         elif self.tui.view == 'inventory':
322             self.draw_inventory()
323
324     def draw_inventory(self):
325         lines = ['INVENTORY:']
326         counter = 0
327         for id_ in self.tui.game.world.player_inventory:
328             pointer = '*' if counter == self.tui.inventory_pointer else ' '
329             t = self.tui.game.world.get_thing(id_)
330             lines += ['%s %s' % (pointer, t.type_)]
331             counter += 1
332         line_width = self.size[1]
333         to_join = []
334         for line in lines:
335             to_pad = line_width - (len(line) % line_width)
336             if to_pad == line_width:
337                 to_pad = 0
338             to_join += [line + ' '*to_pad]
339         self.safe_write((''.join(to_join), curses.color_pair(3)))
340
341     def draw_map(self):
342
343         def terrain_with_objects():
344             terrain_as_list = list(self.tui.game.world.map_.terrain[:])
345             for t in self.tui.game.world.things:
346                 pos_i = self.tui.game.world.map_.get_position_index(t.position)
347                 symbol = self.tui.game.symbol_for_type(t.type_)
348                 if symbol in {'i'} and terrain_as_list[pos_i] in {'@', 'm'}:
349                     continue
350                 terrain_as_list[pos_i] = symbol
351             return ''.join(terrain_as_list)
352
353         def pad_or_cut_x(lines):
354             line_width = self.size[1]
355             for y in range(len(lines)):
356                 line = lines[y]
357                 if line_width > len(line):
358                     to_pad = line_width - (len(line) % line_width)
359                     lines[y] = line + '0' * to_pad
360                 else:
361                     lines[y] = line[:line_width]
362
363         def pad_y(lines):
364             if len(lines) < self.size[0]:
365                 to_pad = self.size[0] - len(lines)
366                 lines += to_pad * ['0' * self.size[1]]
367
368         def lines_to_colored_chars(lines):
369             chars_with_attrs = []
370             for c in ''.join(lines):
371                 if c in {'@', 'm'}:
372                     chars_with_attrs += [(c, curses.color_pair(1))]
373                 elif c == 'i':
374                     chars_with_attrs += [(c, curses.color_pair(4))]
375                 elif c == '.':
376                     chars_with_attrs += [(c, curses.color_pair(2))]
377                 elif c in {'x', 'X', '#'}:
378                     chars_with_attrs += [(c, curses.color_pair(3))]
379                 else:
380                     chars_with_attrs += [c]
381             return chars_with_attrs
382
383         if self.tui.game.world.map_.terrain == '':
384             lines = []
385             pad_y(lines)
386             self.safe_write(''.join(lines))
387             return
388
389         terrain_with_objects = terrain_with_objects()
390         center = self.tui.game.world.player.position
391         lines = self.tui.game.world.map_.format_to_view(terrain_with_objects,
392                                                         center, self.size)
393         pad_or_cut_x(lines)
394         pad_y(lines)
395         self.safe_write(lines_to_colored_chars(lines))
396
397
398 class TurnWidget(Widget):
399
400     def draw(self):
401         self.safe_write((str(self.tui.game.world.turn), curses.color_pair(2)))
402
403
404 class TUI:
405
406     def __init__(self, plom_socket, game):
407         self.socket = plom_socket
408         self.game = game
409         self.parser = Parser(self.game)
410         self.to_update = {'edit': False}
411         self.inventory_pointer = 0
412         curses.wrapper(self.loop)
413
414     def draw_screen(self):
415         self.stdscr.addstr(0, 0, 'SEND:')
416         self.stdscr.addstr(2, 0, 'TURN:')
417
418     def setup_screen(self, stdscr):
419         self.stdscr = stdscr
420         self.stdscr.refresh()  # will be called by getkey else, clearing screen
421         self.stdscr.timeout(10)
422         self.draw_screen()
423
424     def loop(self, stdscr):
425         self.setup_screen(stdscr)
426         curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_RED)
427         curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN)
428         curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_BLUE)
429         curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW)
430         curses.curs_set(False)  # hide cursor
431         self.to_send = []
432         self.edit = EditWidget(self, (0, 6), (1, 14), check_tui = ['edit'])
433         self.turn = TurnWidget(self, (2, 6), (1, 14), ['turn'])
434         self.log = LogWidget(self, (4, 0), (None, 20), ['log'])
435         self.map_ = MapWidget(self, (0, 21), (None, None), ['map'])
436         self.popup = PopUpWidget(self, (0, 0), (1, 1), ['popup'])
437         self.popup.visible = False
438         self.popup_text = 'Hi bob'
439         widgets = (self.edit, self.turn, self.log, self.map_, self.popup)
440         write_mode = True
441         self.view = 'map'
442         while True:
443             for w in widgets:
444                 w.ensure_freshness()
445             for key in self.game.to_update:
446                 self.game.to_update[key] = False
447             for key in self.to_update:
448                 self.to_update[key] = False
449             try:
450                 key = self.stdscr.getkey()
451                 if key == 'KEY_RESIZE':
452                     curses.endwin()
453                     self.setup_screen(curses.initscr())
454                     for w in widgets:
455                         w.size = w.size_def
456                         w.ensure_freshness(True)
457                 elif key == '\t':  # Tabulator key.
458                     write_mode = False if write_mode else True
459                 elif write_mode:
460                     if len(key) == 1 and key in ASCII_printable and \
461                             len(self.to_send) < len(self.edit):
462                         self.to_send += [key]
463                         self.to_update['edit'] = True
464                     elif key == 'KEY_BACKSPACE':
465                         self.to_send[:] = self.to_send[:-1]
466                         self.to_update['edit'] = True
467                     elif key == '\n':  # Return key
468                         self.socket.send(''.join(self.to_send))
469                         self.to_send[:] = []
470                         self.to_update['edit'] = True
471                 elif self.view == 'map':
472                     if key == 'w':
473                         self.socket.send('TASK:MOVE UPLEFT')
474                     elif key == 'e':
475                         self.socket.send('TASK:MOVE UPRIGHT')
476                     if key == 's':
477                         self.socket.send('TASK:MOVE LEFT')
478                     elif key == 'd':
479                         self.socket.send('TASK:MOVE RIGHT')
480                     if key == 'x':
481                         self.socket.send('TASK:MOVE DOWNLEFT')
482                     elif key == 'c':
483                         self.socket.send('TASK:MOVE DOWNRIGHT')
484                     elif key == 't':
485                         if not self.popup.visible:
486                             self.to_update['popup'] = True
487                             self.popup.visible = True
488                             self.popup.reconfigure()
489                         else:
490                             self.popup.visible = False
491                             self.stdscr.erase()    # we'll call refresh here so
492                             self.stdscr.refresh()  # getkey doesn't, erasing screen
493                             self.draw_screen()
494                             for w in widgets:
495                                 w.ensure_freshness(True)
496                     elif key == 'p':
497                         for t in self.game.world.things:
498                             if t == self.game.world.player or \
499                                t.id_ in self.game.world.player_inventory:
500                                 continue
501                             if t.position == self.game.world.player.position:
502                                 self.socket.send('TASK:PICKUP %s' % t.id_)
503                                 break
504                     elif key == 'i':
505                         self.view = 'inventory'
506                         self.game.to_update['map'] = True
507                 elif self.view == 'inventory':
508                     if key == 'i':
509                         self.view = 'map'
510                     elif key == 'j' and \
511                          len(self.game.world.player_inventory) > \
512                          self.inventory_pointer + 1:
513                         self.inventory_pointer += 1
514                     elif key == 'k' and self.inventory_pointer > 0:
515                         self.inventory_pointer -= 1
516                     elif key == 'd' and \
517                          len(self.game.world.player_inventory) > 0:
518                         id_ = self.game.world.player_inventory[self.inventory_pointer]
519                         self.socket.send('TASK:DROP %s' % id_)
520                         if self.inventory_pointer > 0:
521                             self.inventory_pointer -= 1
522                     else:
523                         continue
524                     self.game.to_update['map'] = True
525             except curses.error:
526                 pass
527             if self.game.do_quit:
528                 break
529
530
531 s = socket.create_connection(('127.0.0.1', 5000))
532 plom_socket = PlomSocket(s)
533 game = Game()
534 t = threading.Thread(target=recv_loop, args=(plom_socket, game))
535 t.start()
536 TUI(plom_socket, game)