home · contact · privacy
66c2bf651ea777d77f1ed48dd9f155c836287dde
[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         if not self.visible:
263             return
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         for child in self.children:
274             child.ensure_freshness(do_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 class MapWidget(Widget):
315
316     def draw(self):
317         if self.tui.view == 'map':
318             self.draw_map()
319         elif self.tui.view == 'inventory':
320             self.draw_item_selector('INVENTORY:',
321                                     self.tui.game.world.player_inventory)
322         elif self.tui.view == 'pickable_items':
323             self.draw_item_selector('PICKABLE:',
324                                     self.tui.game.world.pickable_items)
325
326     def draw_item_selector(self, title, selection):
327         lines = [title]
328         counter = 0
329         for id_ in selection:
330             pointer = '*' if counter == self.tui.item_pointer else ' '
331             t = self.tui.game.world.get_thing(id_)
332             lines += ['%s %s' % (pointer, t.type_)]
333             counter += 1
334         line_width = self.size[1]
335         to_join = []
336         for line in lines:
337             to_pad = line_width - (len(line) % line_width)
338             if to_pad == line_width:
339                 to_pad = 0
340             to_join += [line + ' '*to_pad]
341         self.safe_write((''.join(to_join), curses.color_pair(3)))
342
343     def draw_map(self):
344
345         def terrain_with_objects():
346             terrain_as_list = list(self.tui.game.world.map_.terrain[:])
347             for t in self.tui.game.world.things:
348                 pos_i = self.tui.game.world.map_.get_position_index(t.position)
349                 symbol = self.tui.game.symbol_for_type(t.type_)
350                 if symbol in {'i'} and terrain_as_list[pos_i] in {'@', 'm'}:
351                     continue
352                 terrain_as_list[pos_i] = symbol
353             return ''.join(terrain_as_list)
354
355         def pad_or_cut_x(lines):
356             line_width = self.size[1]
357             for y in range(len(lines)):
358                 line = lines[y]
359                 if line_width > len(line):
360                     to_pad = line_width - (len(line) % line_width)
361                     lines[y] = line + '0' * to_pad
362                 else:
363                     lines[y] = line[:line_width]
364
365         def pad_y(lines):
366             if len(lines) < self.size[0]:
367                 to_pad = self.size[0] - len(lines)
368                 lines += to_pad * ['0' * self.size[1]]
369
370         def lines_to_colored_chars(lines):
371             chars_with_attrs = []
372             for c in ''.join(lines):
373                 if c in {'@', 'm'}:
374                     chars_with_attrs += [(c, curses.color_pair(1))]
375                 elif c == 'i':
376                     chars_with_attrs += [(c, curses.color_pair(4))]
377                 elif c == '.':
378                     chars_with_attrs += [(c, curses.color_pair(2))]
379                 elif c in {'x', 'X', '#'}:
380                     chars_with_attrs += [(c, curses.color_pair(3))]
381                 else:
382                     chars_with_attrs += [c]
383             return chars_with_attrs
384
385         if self.tui.game.world.map_.terrain == '':
386             lines = []
387             pad_y(lines)
388             self.safe_write(''.join(lines))
389             return
390
391         terrain_with_objects = terrain_with_objects()
392         center = self.tui.game.world.player.position
393         lines = self.tui.game.world.map_.format_to_view(terrain_with_objects,
394                                                         center, self.size)
395         pad_or_cut_x(lines)
396         pad_y(lines)
397         self.safe_write(lines_to_colored_chars(lines))
398
399
400 class TurnWidget(Widget):
401
402     def draw(self):
403         self.safe_write((str(self.tui.game.world.turn), curses.color_pair(2)))
404
405
406 class TextLineWidget(Widget):
407
408     def __init__(self, text_line, *args, **kwargs):
409         self.text_line = text_line
410         super().__init__(*args, **kwargs)
411
412     def draw(self):
413         self.safe_write(self.text_line)
414
415
416 class TUI:
417
418     def __init__(self, plom_socket, game, q):
419         self.socket = plom_socket
420         self.game = game
421         self.game.tui = self
422         self.queue = q
423         self.parser = Parser(self.game)
424         self.to_update = {}
425         self.item_pointer = 0
426         self.top_widgets = []
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         popup_widget = PopUpWidget(self, (0, 0), (1, 1), ['popup'])
450         popup_widget.visible = False
451         self.popup_text = 'Hi bob'
452         self.top_widgets = [edit_widget, turn_widget, log_widget, map_widget,
453                             popup_widget]
454         write_mode = True
455         self.view = 'map'
456         for w in self.top_widgets:
457             w.ensure_freshness(True)
458         while True:
459             for w in self.top_widgets:
460                 w.ensure_freshness()
461             for k in self.to_update.keys():
462                 self.to_update[k] = False
463             while True:
464                 try:
465                     command = self.queue.get(block=False)
466                 except queue.Empty:
467                     break
468                 self.game.handle_input(command)
469             try:
470                 key = self.stdscr.getkey()
471                 if key == 'KEY_RESIZE':
472                     curses.endwin()
473                     self.setup_screen(curses.initscr())
474                     for w in self.top_widgets:
475                         w.size = w.size_def
476                         w.ensure_freshness(True)
477                 elif key == '\t':  # Tabulator key.
478                     write_mode = False if write_mode else True
479                 elif write_mode:
480                     if len(key) == 1 and key in ASCII_printable and \
481                             len(self.to_send) < len(edit_line_widget):
482                         self.to_send += [key]
483                         self.to_update['edit'] = True
484                     elif key == 'KEY_BACKSPACE':
485                         self.to_send[:] = self.to_send[:-1]
486                         self.to_update['edit'] = True
487                     elif key == '\n':  # Return key
488                         self.socket.send(''.join(self.to_send))
489                         self.to_send[:] = []
490                         self.to_update['edit'] = True
491                 elif self.view == 'map':
492                     if key == 'w':
493                         self.socket.send('TASK:MOVE UPLEFT')
494                     elif key == 'e':
495                         self.socket.send('TASK:MOVE UPRIGHT')
496                     if key == 's':
497                         self.socket.send('TASK:MOVE LEFT')
498                     elif key == 'd':
499                         self.socket.send('TASK:MOVE RIGHT')
500                     if key == 'x':
501                         self.socket.send('TASK:MOVE DOWNLEFT')
502                     elif key == 'c':
503                         self.socket.send('TASK:MOVE DOWNRIGHT')
504                     elif key == 't':
505                         if not popup_widget.visible:
506                             self.to_update['popup'] = True
507                             popup_widget.visible = True
508                             popup_widget.reconfigure()
509                         else:
510                             popup_widget.visible = False
511                             for w in self.top_widgets:
512                                 w.ensure_freshness(True)
513                     elif key == 'p':
514                         self.socket.send('GET_PICKABLE_ITEMS')
515                         self.item_pointer = 0
516                         self.view = 'pickable_items'
517                     elif key == 'i':
518                         self.item_pointer = 0
519                         self.view = 'inventory'
520                         self.to_update['map'] = True
521                 elif self.view == 'pickable_items':
522                     if key == 'c':
523                         self.view = 'map'
524                     elif key == 'j' and \
525                          len(self.game.world.pickable_items) > \
526                          self.item_pointer + 1:
527                         self.item_pointer += 1
528                     elif key == 'k' and self.item_pointer > 0:
529                         self.item_pointer -= 1
530                     elif key == 'p' and \
531                          len(self.game.world.pickable_items) > 0:
532                         id_ = self.game.world.pickable_items[self.item_pointer]
533                         self.socket.send('TASK:PICKUP %s' % id_)
534                         self.socket.send('GET_PICKABLE_ITEMS')
535                         if self.item_pointer > 0:
536                             self.item_pointer -= 1
537                     else:
538                         continue
539                     self.to_update['map'] = True
540                 elif self.view == 'inventory':
541                     if key == 'c':
542                         self.view = 'map'
543                     elif key == 'j' and \
544                          len(self.game.world.player_inventory) > \
545                          self.item_pointer + 1:
546                         self.item_pointer += 1
547                     elif key == 'k' and self.item_pointer > 0:
548                         self.item_pointer -= 1
549                     elif key == 'd' and \
550                          len(self.game.world.player_inventory) > 0:
551                         id_ = self.game.world.player_inventory[self.item_pointer]
552                         self.socket.send('TASK:DROP %s' % id_)
553                         if self.item_pointer > 0:
554                             self.item_pointer -= 1
555                     else:
556                         continue
557                     self.to_update['map'] = True
558             except curses.error:
559                 pass
560             if self.game.do_quit:
561                 break
562
563
564 s = socket.create_connection(('127.0.0.1', 5000))
565 plom_socket = PlomSocket(s)
566 game = Game()
567 q = queue.Queue()
568 t = threading.Thread(target=recv_loop, args=(plom_socket, game, q))
569 t.start()
570 TUI(plom_socket, game, q)