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