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