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