home · contact · privacy
Optimize ncurses client, only update map on VISIBLE_MAP_COMPLETE.
[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 MapSquare(game_common.Map):
11
12     def list_terrain_to_lines(self, terrain_as_list):
13         terrain = ''.join(terrain_as_list)
14         map_lines = []
15         start_cut = 0
16         while start_cut < len(terrain):
17             limit = start_cut + self.size[1]
18             map_lines += [terrain[start_cut:limit]]
19             start_cut = limit
20         return map_lines
21
22
23 class MapHex(game_common.Map):
24
25     def list_terrain_to_lines(self, terrain_as_list):
26         new_terrain_list = [' ']
27         x = 0
28         y = 0
29         for c in terrain_as_list:
30             new_terrain_list += [c, ' ']
31             x += 1
32             if x == self.size[1]:
33                 new_terrain_list += ['\n']
34                 x = 0
35                 y += 1
36                 if y % 2 == 0:
37                     new_terrain_list += [' ']
38         return ''.join(new_terrain_list).split('\n')
39
40
41 map_manager = game_common.MapManager(globals())
42
43
44 class World(game_common.World):
45
46     def __init__(self, game, *args, **kwargs):
47         """Extend original with local classes and empty default map.
48
49         We need the empty default map because we draw the map widget
50         on any update, even before we actually receive map data.
51         """
52         super().__init__(*args, **kwargs)
53         self.game = game
54         self.map_ = self.game.map_manager.get_map_class('Hex')()
55
56
57 class Game(game_common.CommonCommandsMixin):
58
59     def __init__(self, tui):
60         self.tui = tui
61         self.map_manager = map_manager
62         self.parser = Parser(self)
63         self.world = World(self)
64         self.log_text = ''
65
66     def log(self, msg):
67         """Prefix msg plus newline to self.log_text."""
68         self.log_text = msg + '\n' + self.log_text
69
70     def symbol_for_type(self, type_):
71         symbol = '?'
72         if type_ == 'human':
73             symbol = '@'
74         elif type_ == 'monster':
75             symbol = 'm'
76         return symbol
77
78     def cmd_LAST_PLAYER_TASK_RESULT(self, msg):
79         if msg != "success":
80             self.log(msg)
81             self.tui.log.do_update = True
82     cmd_LAST_PLAYER_TASK_RESULT.argtypes = 'string'
83
84     def cmd_TURN_FINISHED(self, n):
85         """Do nothing. (This may be extended later.)"""
86         pass
87     cmd_TURN_FINISHED.argtypes = 'int:nonneg'
88
89     def cmd_NEW_TURN(self, n):
90         """Set self.turn to n, empty self.things."""
91         self.world.turn = n
92         self.tui.turn.do_update = True
93         self.world.things = []
94     cmd_NEW_TURN.argtypes = 'int:nonneg'
95
96     def cmd_VISIBLE_MAP_LINE(self, y, terrain_line):
97         self.world.map_.set_line(y, terrain_line)
98     cmd_VISIBLE_MAP_LINE.argtypes = 'int:nonneg string'
99
100     def cmd_VISIBLE_MAP_COMPLETE(self):
101         self.tui.map_.do_update = True
102
103
104 ASCII_printable = ' !"#$%&\'\(\)*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWX'\
105                   'YZ[\\]^_\`abcdefghijklmnopqrstuvwxyz{|}~'
106
107
108 def recv_loop(server_output):
109     for msg in plom_socket_io.recv(s):
110         while len(server_output) > 0:
111             pass
112         server_output += [msg]
113
114
115 class Widget:
116
117     def __init__(self, tui, start, size):
118         self.tui = tui
119         self.start = start
120         self.win = curses.newwin(1, 1, self.start[0], self.start[1])
121         self.size_def = size  # store for re-calling .size on SIGWINCH
122         self.size = size
123         self.do_update = True
124
125     @property
126     def size(self):
127         return self.win.getmaxyx()
128
129     @size.setter
130     def size(self, size):
131         """Set window size. Size be y,x tuple. If y or x None, use legal max."""
132         n_lines, n_cols = size
133         if n_lines is None:
134             n_lines = self.tui.stdscr.getmaxyx()[0] - self.start[0]
135         if n_cols is None:
136             n_cols = self.tui.stdscr.getmaxyx()[1] - self.start[1]
137         self.win.resize(n_lines, n_cols)
138
139     def __len__(self):
140         return self.win.getmaxyx()[0] * self.win.getmaxyx()[1]
141
142     def safe_write(self, foo):
143
144         def to_chars_with_attrs(part):
145             attr = curses.A_NORMAL
146             part_string = part
147             if not type(part) == str:
148                 part_string = part[0]
149                 attr = part[1]
150             if len(part_string) > 0:
151                 return [(char, attr) for char in part_string]
152             elif len(part_string) == 1:
153                 return [part]
154             return []
155
156         chars_with_attrs = []
157         if type(foo) == str or len(foo) == 2 and type(foo[1]) == int:
158             chars_with_attrs += to_chars_with_attrs(foo)
159         else:
160             for part in foo:
161                 chars_with_attrs += to_chars_with_attrs(part)
162         self.win.move(0, 0)
163         if len(chars_with_attrs) < len(self):
164             for char_with_attr in chars_with_attrs:
165                 self.win.addstr(char_with_attr[0], char_with_attr[1])
166         else:  # workaround to <https://stackoverflow.com/q/7063128>
167             cut = chars_with_attrs[:len(self) - 1]
168             last_char_with_attr = chars_with_attrs[len(self) - 1]
169             self.win.addstr(self.size[0] - 1, self.size[1] - 2,
170                             last_char_with_attr[0], last_char_with_attr[1])
171             self.win.insstr(self.size[0] - 1, self.size[1] - 2, ' ')
172             self.win.move(0, 0)
173             for char_with_attr in cut:
174                 self.win.addstr(char_with_attr[0], char_with_attr[1])
175
176     def draw_and_refresh(self):
177         self.win.erase()
178         self.draw()
179         self.win.refresh()
180
181
182 class EditWidget(Widget):
183
184     def draw(self):
185         self.safe_write((''.join(self.tui.to_send), curses.color_pair(1)))
186
187
188 class LogWidget(Widget):
189
190     def draw(self):
191         line_width = self.size[1]
192         log_lines = self.tui.game.log_text.split('\n')
193         to_join = []
194         for line in log_lines:
195             to_pad = line_width - (len(line) % line_width)
196             if to_pad == line_width:
197                 to_pad = 0
198             to_join += [line + ' '*to_pad]
199         self.safe_write((''.join(to_join), curses.color_pair(3)))
200
201
202 class MapWidget(Widget):
203
204     def draw(self):
205         to_join = []
206         if len(self.tui.game.world.map_.terrain) > 0:
207             terrain_as_list = list(self.tui.game.world.map_.terrain[:])
208             for t in self.tui.game.world.things:
209                 pos_i = self.tui.game.world.map_.get_position_index(t.position)
210                 terrain_as_list[pos_i] = self.tui.game.symbol_for_type(t.type_)
211             lines = self.tui.game.world.map_.list_terrain_to_lines(terrain_as_list)
212             line_width = self.size[1]
213             for line in lines:
214                 if line_width > len(line):
215                     to_pad = line_width - (len(line) % line_width)
216                     to_join += [line + '0' * to_pad]
217                 else:
218                     to_join += [line[:line_width]]
219         if len(to_join) < self.size[0]:
220             to_pad = self.size[0] - len(to_join)
221             to_join += to_pad * ['0' * self.size[1]]
222         text = ''.join(to_join)
223         text_as_list = []
224         for c in text:
225             if c in {'@', 'm'}:
226                 text_as_list += [(c, curses.color_pair(1))]
227             elif c == '.':
228                 text_as_list += [(c, curses.color_pair(2))]
229             elif c in {'x', 'X', '#'}:
230                 text_as_list += [(c, curses.color_pair(3))]
231             else:
232                 text_as_list += [c]
233         self.safe_write(text_as_list)
234
235
236 class TurnWidget(Widget):
237
238     def draw(self):
239         self.safe_write((str(self.tui.game.world.turn), curses.color_pair(2)))
240
241
242 class TUI:
243
244     def __init__(self, server_output):
245         self.server_output = server_output
246         self.game = Game(self)
247         self.parser = Parser(self.game)
248         self.do_update = True
249         curses.wrapper(self.loop)
250
251     def setup_screen(self, stdscr):
252         self.stdscr = stdscr
253         self.stdscr.refresh()  # will be called by getkey else, clearing screen
254         self.stdscr.timeout(1)
255         self.stdscr.addstr(0, 0, 'SEND:')
256         self.stdscr.addstr(2, 0, 'TURN:')
257
258     def loop(self, stdscr):
259         self.setup_screen(stdscr)
260         curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_RED)
261         curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN)
262         curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_BLUE)
263         curses.curs_set(False)  # hide cursor
264         self.to_send = []
265         self.edit = EditWidget(self, (0, 6), (1, 14))
266         self.turn = TurnWidget(self, (2, 6), (1, 14))
267         self.log = LogWidget(self, (4, 0), (None, 20))
268         self.map_ = MapWidget(self, (0, 21), (None, None))
269         widgets = (self.edit, self.turn, self.log, self.map_)
270         while True:
271             for w in widgets:
272                 if w.do_update:
273                     w.draw_and_refresh()
274                     w.do_update = False
275             try:
276                 key = self.stdscr.getkey()
277                 if len(key) == 1 and key in ASCII_printable and \
278                         len(self.to_send) < len(self.edit):
279                     self.to_send += [key]
280                     self.edit.do_update = True
281                 elif key == 'KEY_BACKSPACE':
282                     self.to_send[:] = self.to_send[:-1]
283                     self.edit.do_update = True
284                 elif key == '\n':
285                     plom_socket_io.send(s, ''.join(self.to_send))
286                     self.to_send[:] = []
287                     self.edit.do_update = True
288                 elif key == 'KEY_RESIZE':
289                     curses.endwin()
290                     self.setup_screen(curses.initscr())
291                     for w in widgets:
292                         w.size = w.size_def
293                         w.do_update = True
294             except curses.error:
295                 pass
296             if len(self.server_output) > 0:
297                 do_quit = self.handle_input(self.server_output[0])
298                 if do_quit:
299                     break
300                 self.server_output[:] = []
301                 self.do_update = True
302
303     def handle_input(self, msg):
304         if msg == 'BYE':
305             return True
306         try:
307             command = self.parser.parse(msg)
308             if command is None:
309                 self.game.log('UNHANDLED INPUT: ' + msg)
310                 self.log.do_update = True
311             else:
312                 command()
313         except ArgError as e:
314                 self.game.log('ARGUMENT ERROR: ' + msg + '\n' + str(e))
315                 self.log.do_update = True
316         return False
317
318
319 server_output = []
320 s = socket.create_connection(('127.0.0.1', 5000))
321 t = threading.Thread(target=recv_loop, args=(server_output,))
322 t.start()
323 TUI(server_output)