home · contact · privacy
Fix map drawing / scrolling bugs in client.
[plomrogue2-experiments] / client-curses.py
1 #!/usr/bin/env python3
2 import curses
3 import plom_socket_io
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 = ['+']
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 += ['+']
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(globals())
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 handle_input(self, msg):
112         if msg == 'BYE':
113             self.do_quit = True
114             return
115         try:
116             command = self.parser.parse(msg)
117             if command is None:
118                 self.log('UNHANDLED INPUT: ' + msg)
119                 self.to_update['log'] = True
120             else:
121                 command()
122         except ArgError as e:
123                 self.log('ARGUMENT ERROR: ' + msg + '\n' + str(e))
124                 self.to_update['log'] = True
125
126     def log(self, msg):
127         """Prefix msg plus newline to self.log_text."""
128         self.log_text = msg + '\n' + self.log_text
129         self.to_update['log'] = True
130
131     def symbol_for_type(self, type_):
132         symbol = '?'
133         if type_ == 'human':
134             symbol = '@'
135         elif type_ == 'monster':
136             symbol = 'm'
137         return symbol
138
139     def cmd_LAST_PLAYER_TASK_RESULT(self, msg):
140         if msg != "success":
141             self.log(msg)
142     cmd_LAST_PLAYER_TASK_RESULT.argtypes = 'string'
143
144     def cmd_TURN_FINISHED(self, n):
145         """Do nothing. (This may be extended later.)"""
146         pass
147     cmd_TURN_FINISHED.argtypes = 'int:nonneg'
148
149     def cmd_NEW_TURN(self, n):
150         """Set self.turn to n, empty self.things."""
151         self.world.turn = n
152         self.world.things = []
153     cmd_NEW_TURN.argtypes = 'int:nonneg'
154
155     def cmd_VISIBLE_MAP_LINE(self, y, terrain_line):
156         self.world.map_.set_line(y, terrain_line)
157     cmd_VISIBLE_MAP_LINE.argtypes = 'int:nonneg string'
158
159     def cmd_PLAYER_POS(self, yx):
160         self.world.player_position = yx
161     cmd_PLAYER_POS.argtypes = 'yx_tuple:pos'
162
163     def cmd_GAME_STATE_COMPLETE(self):
164         self.to_update['turn'] = True
165         self.to_update['map'] = True
166
167
168 ASCII_printable = ' !"#$%&\'\(\)*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWX'\
169                   'YZ[\\]^_\`abcdefghijklmnopqrstuvwxyz{|}~'
170
171
172 def recv_loop(socket, game):
173     for msg in plom_socket_io.recv(s):
174         game.handle_input(msg)
175
176
177 class Widget:
178
179     def __init__(self, tui, start, size, check_game=[], check_tui=[]):
180         self.check_game = check_game
181         self.check_tui = check_tui
182         self.tui = tui
183         self.start = start
184         self.win = curses.newwin(1, 1, self.start[0], self.start[1])
185         self.size_def = size  # store for re-calling .size on SIGWINCH
186         self.size = size
187         self.do_update = True
188
189     @property
190     def size(self):
191         return self.win.getmaxyx()
192
193     @size.setter
194     def size(self, size):
195         """Set window size. Size be y,x tuple. If y or x None, use legal max."""
196         n_lines, n_cols = size
197         if n_lines is None:
198             n_lines = self.tui.stdscr.getmaxyx()[0] - self.start[0]
199         if n_cols is None:
200             n_cols = self.tui.stdscr.getmaxyx()[1] - self.start[1]
201         self.win.resize(n_lines, n_cols)
202
203     def __len__(self):
204         return self.win.getmaxyx()[0] * self.win.getmaxyx()[1]
205
206     def safe_write(self, foo):
207
208         def to_chars_with_attrs(part):
209             attr = curses.A_NORMAL
210             part_string = part
211             if not type(part) == str:
212                 part_string = part[0]
213                 attr = part[1]
214             if len(part_string) > 0:
215                 return [(char, attr) for char in part_string]
216             elif len(part_string) == 1:
217                 return [part]
218             return []
219
220         chars_with_attrs = []
221         if type(foo) == str or len(foo) == 2 and type(foo[1]) == int:
222             chars_with_attrs += to_chars_with_attrs(foo)
223         else:
224             for part in foo:
225                 chars_with_attrs += to_chars_with_attrs(part)
226         self.win.move(0, 0)
227         if len(chars_with_attrs) < len(self):
228             for char_with_attr in chars_with_attrs:
229                 self.win.addstr(char_with_attr[0], char_with_attr[1])
230         else:  # workaround to <https://stackoverflow.com/q/7063128>
231             cut = chars_with_attrs[:len(self) - 1]
232             last_char_with_attr = chars_with_attrs[len(self) - 1]
233             self.win.addstr(self.size[0] - 1, self.size[1] - 2,
234                             last_char_with_attr[0], last_char_with_attr[1])
235             self.win.insstr(self.size[0] - 1, self.size[1] - 2, ' ')
236             self.win.move(0, 0)
237             for char_with_attr in cut:
238                 self.win.addstr(char_with_attr[0], char_with_attr[1])
239
240     def ensure_freshness(self, do_refresh=False):
241         if not do_refresh:
242             for key in self.check_game:
243                 if self.tui.game.to_update[key]:
244                     do_refresh = True
245                     break
246         if not do_refresh:
247             for key in self.check_tui:
248                 if self.tui.to_update[key]:
249                     do_refresh = True
250                     break
251         if do_refresh:
252             self.win.erase()
253             self.draw()
254             self.win.refresh()
255
256
257 class EditWidget(Widget):
258
259     def draw(self):
260         self.safe_write((''.join(self.tui.to_send), curses.color_pair(1)))
261
262
263 class LogWidget(Widget):
264
265     def draw(self):
266         line_width = self.size[1]
267         log_lines = self.tui.game.log_text.split('\n')
268         to_join = []
269         for line in log_lines:
270             to_pad = line_width - (len(line) % line_width)
271             if to_pad == line_width:
272                 to_pad = 0
273             to_join += [line + ' '*to_pad]
274         self.safe_write((''.join(to_join), curses.color_pair(3)))
275
276
277 class MapWidget(Widget):
278
279     def draw(self):
280
281         def terrain_with_objects():
282             terrain_as_list = list(self.tui.game.world.map_.terrain[:])
283             for t in self.tui.game.world.things:
284                 pos_i = self.tui.game.world.map_.get_position_index(t.position)
285                 terrain_as_list[pos_i] = self.tui.game.symbol_for_type(t.type_)
286             return ''.join(terrain_as_list)
287
288         def pad_or_cut_x(lines):
289             line_width = self.size[1]
290             for y in range(len(lines)):
291                 line = lines[y]
292                 if line_width > len(line):
293                     to_pad = line_width - (len(line) % line_width)
294                     lines[y] = line + '0' * to_pad
295                 else:
296                     lines[y] = line[:line_width]
297
298         def pad_y(lines):
299             if len(lines) < self.size[0]:
300                 to_pad = self.size[0] - len(lines)
301                 lines += to_pad * ['0' * self.size[1]]
302
303         def lines_to_colored_chars(lines):
304             chars_with_attrs = []
305             for c in ''.join(lines):
306                 if c in {'@', 'm'}:
307                     chars_with_attrs += [(c, curses.color_pair(1))]
308                 elif c == '.':
309                     chars_with_attrs += [(c, curses.color_pair(2))]
310                 elif c in {'x', 'X', '#'}:
311                     chars_with_attrs += [(c, curses.color_pair(3))]
312                 else:
313                     chars_with_attrs += [c]
314             return chars_with_attrs
315
316         if self.tui.game.world.map_.terrain == '':
317             lines = []
318             pad_y(lines)
319             self.safe_write(''.join(lines))
320             return
321
322         terrain_with_objects = terrain_with_objects()
323         center = self.tui.game.world.player_position
324         lines = self.tui.game.world.map_.format_to_view(terrain_with_objects,
325                                                         center, self.size)
326         pad_or_cut_x(lines)
327         pad_y(lines)
328         self.safe_write(lines_to_colored_chars(lines))
329
330
331 class TurnWidget(Widget):
332
333     def draw(self):
334         self.safe_write((str(self.tui.game.world.turn), curses.color_pair(2)))
335
336
337 class TUI:
338
339     def __init__(self, socket, game):
340         self.socket = socket
341         self.game = game
342         self.parser = Parser(self.game)
343         self.to_update = {'edit': False}
344         curses.wrapper(self.loop)
345
346     def setup_screen(self, stdscr):
347         self.stdscr = stdscr
348         self.stdscr.refresh()  # will be called by getkey else, clearing screen
349         self.stdscr.timeout(10)
350         self.stdscr.addstr(0, 0, 'SEND:')
351         self.stdscr.addstr(2, 0, 'TURN:')
352
353     def loop(self, stdscr):
354         self.setup_screen(stdscr)
355         curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_RED)
356         curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN)
357         curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_BLUE)
358         curses.curs_set(False)  # hide cursor
359         self.to_send = []
360         self.edit = EditWidget(self, (0, 6), (1, 14), check_tui = ['edit'])
361         self.turn = TurnWidget(self, (2, 6), (1, 14), ['turn'])
362         self.log = LogWidget(self, (4, 0), (None, 20), ['log'])
363         self.map_ = MapWidget(self, (0, 21), (None, None), ['map'])
364         widgets = (self.edit, self.turn, self.log, self.map_)
365         map_mode = False
366         while True:
367             for w in widgets:
368                 w.ensure_freshness()
369             for key in self.game.to_update:
370                 self.game.to_update[key] = False
371             for key in self.to_update:
372                 self.to_update[key] = False
373             try:
374                 key = self.stdscr.getkey()
375                 if key == 'KEY_RESIZE':
376                     curses.endwin()
377                     self.setup_screen(curses.initscr())
378                     for w in widgets:
379                         w.size = w.size_def
380                         w.ensure_freshness(True)
381                 elif key == '\t':  # Tabulator key.
382                     map_mode = False if map_mode else True
383                 elif map_mode:
384                     if type(self.game.world.map_) == MapSquare:
385                         if key == 'a':
386                             plom_socket_io.send(self.socket, 'MOVE LEFT')
387                         elif key == 'd':
388                             plom_socket_io.send(self.socket, 'MOVE RIGHT')
389                         elif key == 'w':
390                             plom_socket_io.send(self.socket, 'MOVE UP')
391                         elif key == 's':
392                             plom_socket_io.send(self.socket, 'MOVE DOWN')
393                     elif type(self.game.world.map_) == MapHex:
394                         if key == 'w':
395                             plom_socket_io.send(self.socket, 'MOVE UPLEFT')
396                         elif key == 'e':
397                             plom_socket_io.send(self.socket, 'MOVE UPRIGHT')
398                         if key == 's':
399                             plom_socket_io.send(self.socket, 'MOVE LEFT')
400                         elif key == 'd':
401                             plom_socket_io.send(self.socket, 'MOVE RIGHT')
402                         if key == 'x':
403                             plom_socket_io.send(self.socket, 'MOVE DOWNLEFT')
404                         elif key == 'c':
405                             plom_socket_io.send(self.socket, 'MOVE DOWNRIGHT')
406                 else:
407                     if len(key) == 1 and key in ASCII_printable and \
408                             len(self.to_send) < len(self.edit):
409                         self.to_send += [key]
410                         self.to_update['edit'] = True
411                     elif key == 'KEY_BACKSPACE':
412                         self.to_send[:] = self.to_send[:-1]
413                         self.to_update['edit'] = True
414                     elif key == '\n':  # Return key
415                         plom_socket_io.send(self.socket, ''.join(self.to_send))
416                         self.to_send[:] = []
417                         self.to_update['edit'] = True
418             except curses.error:
419                 pass
420             if self.game.do_quit:
421                 break
422
423
424 s = socket.create_connection(('127.0.0.1', 5000))
425 game = Game()
426 t = threading.Thread(target=recv_loop, args=(s, game))
427 t.start()
428 TUI(s, game)