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