home · contact · privacy
039d8de6bc134f9835c4f70a130a3a4879fb7890
[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_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 def cmd_THING_TYPE(game, i, type_):
109     t = game.world.get_thing(i)
110     t.type_ = type_
111 cmd_THING_TYPE.argtypes = 'int:nonneg string'
112
113
114 class Game:
115
116     def __init__(self):
117         self.parser = Parser(self)
118         self.world = World(self)
119         self.thing_type = ThingBase
120         self.commands = {'LAST_PLAYER_TASK_RESULT': cmd_LAST_PLAYER_TASK_RESULT,
121                          'TURN_FINISHED': cmd_TURN_FINISHED,
122                          'TURN': cmd_TURN,
123                          'VISIBLE_MAP_LINE': cmd_VISIBLE_MAP_LINE,
124                          'PLAYER_POS': cmd_PLAYER_POS,
125                          'GAME_STATE_COMPLETE': cmd_GAME_STATE_COMPLETE,
126                          'MAP': cmd_MAP,
127                          'THING_TYPE': cmd_THING_TYPE,
128                          'THING_POS': cmd_THING_POS}
129         self.log_text = ''
130         self.to_update = {
131             'log': True,
132             'map': True,
133             'turn': True,
134             }
135         self.do_quit = False
136
137     def get_command(self, command_name):
138         from functools import partial
139         if command_name in self.commands:
140             f = partial(self.commands[command_name], self)
141             if hasattr(self.commands[command_name], 'argtypes'):
142                 f.argtypes = self.commands[command_name].argtypes
143             return f
144         return None
145
146     def get_string_options(self, string_option_type):
147         return None
148
149     def handle_input(self, msg):
150         if msg == 'BYE':
151             self.do_quit = True
152             return
153         try:
154             command, args = self.parser.parse(msg)
155             if command is None:
156                 self.log('UNHANDLED INPUT: ' + msg)
157                 self.to_update['log'] = True
158             else:
159                 command(*args)
160         except ArgError as e:
161             self.log('ARGUMENT ERROR: ' + msg + '\n' + str(e))
162             self.to_update['log'] = True
163
164     def log(self, msg):
165         """Prefix msg plus newline to self.log_text."""
166         self.log_text = msg + '\n' + self.log_text
167         self.to_update['log'] = True
168
169     def symbol_for_type(self, type_):
170         symbol = '?'
171         if type_ == 'human':
172             symbol = '@'
173         elif type_ == 'monster':
174             symbol = 'm'
175         return symbol
176
177
178 ASCII_printable = ' !"#$%&\'\(\)*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWX'\
179                   'YZ[\\]^_\`abcdefghijklmnopqrstuvwxyz{|}~'
180
181
182 def recv_loop(plom_socket, game):
183     for msg in plom_socket.recv():
184         game.handle_input(msg)
185
186
187 class Widget:
188
189     def __init__(self, tui, start, size, check_game=[], check_tui=[]):
190         self.check_game = check_game
191         self.check_tui = check_tui
192         self.tui = tui
193         self.start = start
194         self.win = curses.newwin(1, 1, self.start[0], self.start[1])
195         self.size_def = size  # store for re-calling .size on SIGWINCH
196         self.size = size
197         self.do_update = True
198
199     @property
200     def size(self):
201         return self.win.getmaxyx()
202
203     @size.setter
204     def size(self, size):
205         """Set window size. Size be y,x tuple. If y or x None, use legal max."""
206         n_lines, n_cols = size
207         if n_lines is None:
208             n_lines = self.tui.stdscr.getmaxyx()[0] - self.start[0]
209         if n_cols is None:
210             n_cols = self.tui.stdscr.getmaxyx()[1] - self.start[1]
211         self.win.resize(n_lines, n_cols)
212
213     def __len__(self):
214         return self.win.getmaxyx()[0] * self.win.getmaxyx()[1]
215
216     def safe_write(self, foo):
217
218         def to_chars_with_attrs(part):
219             attr = curses.A_NORMAL
220             part_string = part
221             if not type(part) == str:
222                 part_string = part[0]
223                 attr = part[1]
224             if len(part_string) > 0:
225                 return [(char, attr) for char in part_string]
226             elif len(part_string) == 1:
227                 return [part]
228             return []
229
230         chars_with_attrs = []
231         if type(foo) == str or len(foo) == 2 and type(foo[1]) == int:
232             chars_with_attrs += to_chars_with_attrs(foo)
233         else:
234             for part in foo:
235                 chars_with_attrs += to_chars_with_attrs(part)
236         self.win.move(0, 0)
237         if len(chars_with_attrs) < len(self):
238             for char_with_attr in chars_with_attrs:
239                 self.win.addstr(char_with_attr[0], char_with_attr[1])
240         else:  # workaround to <https://stackoverflow.com/q/7063128>
241             cut = chars_with_attrs[:len(self) - 1]
242             last_char_with_attr = chars_with_attrs[len(self) - 1]
243             self.win.addstr(self.size[0] - 1, self.size[1] - 2,
244                             last_char_with_attr[0], last_char_with_attr[1])
245             self.win.insstr(self.size[0] - 1, self.size[1] - 2, ' ')
246             self.win.move(0, 0)
247             for char_with_attr in cut:
248                 self.win.addstr(char_with_attr[0], char_with_attr[1])
249
250     def ensure_freshness(self, do_refresh=False):
251         if not do_refresh:
252             for key in self.check_game:
253                 if self.tui.game.to_update[key]:
254                     do_refresh = True
255                     break
256         if not do_refresh:
257             for key in self.check_tui:
258                 if self.tui.to_update[key]:
259                     do_refresh = True
260                     break
261         if do_refresh:
262             self.win.erase()
263             self.draw()
264             self.win.refresh()
265
266
267 class EditWidget(Widget):
268
269     def draw(self):
270         self.safe_write((''.join(self.tui.to_send), curses.color_pair(1)))
271
272
273 class LogWidget(Widget):
274
275     def draw(self):
276         line_width = self.size[1]
277         log_lines = self.tui.game.log_text.split('\n')
278         to_join = []
279         for line in log_lines:
280             to_pad = line_width - (len(line) % line_width)
281             if to_pad == line_width:
282                 to_pad = 0
283             to_join += [line + ' '*to_pad]
284         self.safe_write((''.join(to_join), curses.color_pair(3)))
285
286
287 class MapWidget(Widget):
288
289     def draw(self):
290
291         def terrain_with_objects():
292             terrain_as_list = list(self.tui.game.world.map_.terrain[:])
293             for t in self.tui.game.world.things:
294                 pos_i = self.tui.game.world.map_.get_position_index(t.position)
295                 terrain_as_list[pos_i] = self.tui.game.symbol_for_type(t.type_)
296             return ''.join(terrain_as_list)
297
298         def pad_or_cut_x(lines):
299             line_width = self.size[1]
300             for y in range(len(lines)):
301                 line = lines[y]
302                 if line_width > len(line):
303                     to_pad = line_width - (len(line) % line_width)
304                     lines[y] = line + '0' * to_pad
305                 else:
306                     lines[y] = line[:line_width]
307
308         def pad_y(lines):
309             if len(lines) < self.size[0]:
310                 to_pad = self.size[0] - len(lines)
311                 lines += to_pad * ['0' * self.size[1]]
312
313         def lines_to_colored_chars(lines):
314             chars_with_attrs = []
315             for c in ''.join(lines):
316                 if c in {'@', 'm'}:
317                     chars_with_attrs += [(c, curses.color_pair(1))]
318                 elif c == '.':
319                     chars_with_attrs += [(c, curses.color_pair(2))]
320                 elif c in {'x', 'X', '#'}:
321                     chars_with_attrs += [(c, curses.color_pair(3))]
322                 else:
323                     chars_with_attrs += [c]
324             return chars_with_attrs
325
326         if self.tui.game.world.map_.terrain == '':
327             lines = []
328             pad_y(lines)
329             self.safe_write(''.join(lines))
330             return
331
332         terrain_with_objects = terrain_with_objects()
333         center = self.tui.game.world.player_position
334         lines = self.tui.game.world.map_.format_to_view(terrain_with_objects,
335                                                         center, self.size)
336         pad_or_cut_x(lines)
337         pad_y(lines)
338         self.safe_write(lines_to_colored_chars(lines))
339
340
341 class TurnWidget(Widget):
342
343     def draw(self):
344         self.safe_write((str(self.tui.game.world.turn), curses.color_pair(2)))
345
346
347 class TUI:
348
349     def __init__(self, plom_socket, game):
350         self.socket = plom_socket
351         self.game = game
352         self.parser = Parser(self.game)
353         self.to_update = {'edit': False}
354         curses.wrapper(self.loop)
355
356     def setup_screen(self, stdscr):
357         self.stdscr = stdscr
358         self.stdscr.refresh()  # will be called by getkey else, clearing screen
359         self.stdscr.timeout(10)
360         self.stdscr.addstr(0, 0, 'SEND:')
361         self.stdscr.addstr(2, 0, 'TURN:')
362
363     def loop(self, stdscr):
364         self.setup_screen(stdscr)
365         curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_RED)
366         curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN)
367         curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_BLUE)
368         curses.curs_set(False)  # hide cursor
369         self.to_send = []
370         self.edit = EditWidget(self, (0, 6), (1, 14), check_tui = ['edit'])
371         self.turn = TurnWidget(self, (2, 6), (1, 14), ['turn'])
372         self.log = LogWidget(self, (4, 0), (None, 20), ['log'])
373         self.map_ = MapWidget(self, (0, 21), (None, None), ['map'])
374         widgets = (self.edit, self.turn, self.log, self.map_)
375         map_mode = False
376         while True:
377             for w in widgets:
378                 w.ensure_freshness()
379             for key in self.game.to_update:
380                 self.game.to_update[key] = False
381             for key in self.to_update:
382                 self.to_update[key] = False
383             try:
384                 key = self.stdscr.getkey()
385                 if key == 'KEY_RESIZE':
386                     curses.endwin()
387                     self.setup_screen(curses.initscr())
388                     for w in widgets:
389                         w.size = w.size_def
390                         w.ensure_freshness(True)
391                 elif key == '\t':  # Tabulator key.
392                     map_mode = False if map_mode else True
393                 elif map_mode:
394                     if key == 'w':
395                         self.socket.send('TASK:MOVE UPLEFT')
396                     elif key == 'e':
397                         self.socket.send('TASK:MOVE UPRIGHT')
398                     if key == 's':
399                         self.socket.send('TASK:MOVE LEFT')
400                     elif key == 'd':
401                         self.socket.send('TASK:MOVE RIGHT')
402                     if key == 'x':
403                         self.socket.send('TASK:MOVE DOWNLEFT')
404                     elif key == 'c':
405                         self.socket.send('TASK:MOVE DOWNRIGHT')
406                 else:
407                     if len(key) == 1 and key in ASCII_printable and \
408                             len(self.to_send) < len(self.edit):
409                         self.to_send += [key]
410                         self.to_update['edit'] = True
411                     elif key == 'KEY_BACKSPACE':
412                         self.to_send[:] = self.to_send[:-1]
413                         self.to_update['edit'] = True
414                     elif key == '\n':  # Return key
415                         self.socket.send(''.join(self.to_send))
416                         self.to_send[:] = []
417                         self.to_update['edit'] = True
418             except curses.error:
419                 pass
420             if self.game.do_quit:
421                 break
422
423
424 s = socket.create_connection(('127.0.0.1', 5000))
425 plom_socket = PlomSocket(s)
426 game = Game()
427 t = threading.Thread(target=recv_loop, args=(plom_socket, game))
428 t.start()
429 TUI(plom_socket, game)