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