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