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