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