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