home · contact · privacy
Add coloring to map in ncurses 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 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             lines = self.tui.game.world.map_.list_terrain_to_lines(terrain_as_list)
209             line_width = self.size[1]
210             for line in lines:
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         text = ''.join(to_join)
220         text_as_list = []
221         for c in text:
222             if c in {'@', 'm'}:
223                 text_as_list += [(c, curses.color_pair(1))]
224             elif c == '.':
225                 text_as_list += [(c, curses.color_pair(2))]
226             elif c in {'x', 'X', '#'}:
227                 text_as_list += [(c, curses.color_pair(3))]
228             else:
229                 text_as_list += [c]
230         #self.safe_write(''.join(to_join))
231         self.safe_write(text_as_list)
232
233
234 class TurnWidget(Widget):
235
236     def draw(self):
237         self.safe_write((str(self.tui.game.world.turn), curses.color_pair(2)))
238
239
240 class TUI:
241
242     def __init__(self, server_output):
243         self.server_output = server_output
244         self.game = Game()
245         self.parser = Parser(self.game)
246         curses.wrapper(self.loop)
247
248     def setup_screen(self, stdscr):
249         self.stdscr = stdscr
250         self.stdscr.refresh()  # will be called by getkey else, clearing screen
251         self.stdscr.timeout(10)
252         self.stdscr.addstr(0, 0, 'SEND:')
253         self.stdscr.addstr(2, 0, 'TURN:')
254
255     def loop(self, stdscr):
256         self.setup_screen(stdscr)
257         curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_RED)
258         curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN)
259         curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_BLUE)
260         curses.curs_set(False)  # hide cursor
261         self.to_send = []
262         edit_line = EditWidget(self, (0, 6), (1, 14))
263         turn_line = TurnWidget(self, (2, 6), (1, 14))
264         log_display = LogWidget(self, (4, 0), (None, 20))
265         map_view = MapWidget(self, (0, 21), (None, None))
266         map_view.update = True
267         widgets = [edit_line, turn_line, log_display, map_view]
268         do_update = True
269         while True:
270             if do_update:
271                 for w in widgets:
272                     w.draw_and_refresh()
273                 do_update = False
274             try:
275                 key = self.stdscr.getkey()
276                 do_update = True
277                 if len(key) == 1 and key in ASCII_printable and \
278                         len(self.to_send) < len(edit_line):
279                     self.to_send += [key]
280                 elif key == 'KEY_BACKSPACE':
281                     self.to_send[:] = self.to_send[:-1]
282                 elif key == '\n':
283                     plom_socket_io.send(s, ''.join(self.to_send))
284                     self.to_send[:] = []
285                 elif key == 'KEY_RESIZE':
286                     curses.endwin()
287                     self.setup_screen(curses.initscr())
288                     for w in widgets:
289                         w.size = w.size_def
290                 else:
291                     do_update = False
292             except curses.error:
293                 pass
294             if len(self.server_output) > 0:
295                 do_quit = self.handle_input(self.server_output[0])
296                 if do_quit:
297                     break
298                 self.server_output[:] = []
299                 do_update = True
300
301     def handle_input(self, msg):
302         if msg == 'BYE':
303             return True
304         try:
305             command = self.parser.parse(msg)
306             if command is None:
307                 self.game.log('UNHANDLED INPUT: ' + msg)
308             else:
309                 command()
310         except ArgError as e:
311                 self.game.log('ARGUMENT ERROR: ' + msg + '\n' + str(e))
312         return False
313
314
315 server_output = []
316 s = socket.create_connection(('127.0.0.1', 5000))
317 t = threading.Thread(target=recv_loop, args=(server_output,))
318 t.start()
319 TUI(server_output)