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