home · contact · privacy
Refactor map drawing code in curses 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
127     def symbol_for_type(self, type_):
128         symbol = '?'
129         if type_ == 'human':
130             symbol = '@'
131         elif type_ == 'monster':
132             symbol = 'm'
133         return symbol
134
135     def cmd_LAST_PLAYER_TASK_RESULT(self, msg):
136         if msg != "success":
137             self.log(msg)
138             self.to_update['log'] = True
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         while True:
363             for w in widgets:
364                 w.ensure_freshness()
365             for key in self.game.to_update:
366                 self.game.to_update[key] = False
367             for key in self.to_update:
368                 self.to_update[key] = False
369             try:
370                 key = self.stdscr.getkey()
371                 if len(key) == 1 and key in ASCII_printable and \
372                         len(self.to_send) < len(self.edit):
373                     self.to_send += [key]
374                     self.to_update['edit'] = True
375                 elif key == 'KEY_BACKSPACE':
376                     self.to_send[:] = self.to_send[:-1]
377                     self.to_update['edit'] = True
378                 elif key == '\n':
379                     plom_socket_io.send(self.socket, ''.join(self.to_send))
380                     self.to_send[:] = []
381                     self.to_update['edit'] = True
382                 elif key == 'KEY_RESIZE':
383                     curses.endwin()
384                     self.setup_screen(curses.initscr())
385                     for w in widgets:
386                         w.size = w.size_def
387                         w.ensure_freshness(True)
388             except curses.error:
389                 pass
390             if self.game.do_quit:
391                 break
392
393
394 s = socket.create_connection(('127.0.0.1', 5000))
395 game = Game()
396 t = threading.Thread(target=recv_loop, args=(s, game))
397 t.start()
398 TUI(s, game)