home · contact · privacy
Add "item" thing type differentiated from animate thing types.
[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         elif type_ == 'item':
176             symbol = 'i'
177         return symbol
178
179
180 ASCII_printable = ' !"#$%&\'\(\)*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWX'\
181                   'YZ[\\]^_\`abcdefghijklmnopqrstuvwxyz{|}~'
182
183
184 def recv_loop(plom_socket, game):
185     for msg in plom_socket.recv():
186         game.handle_input(msg)
187
188
189 class Widget:
190
191     def __init__(self, tui, start, size, check_game=[], check_tui=[]):
192         self.check_game = check_game
193         self.check_tui = check_tui
194         self.tui = tui
195         self.start = start
196         self.win = curses.newwin(1, 1, self.start[0], self.start[1])
197         self.size_def = size  # store for re-calling .size on SIGWINCH
198         self.size = size
199         self.do_update = True
200
201     @property
202     def size(self):
203         return self.win.getmaxyx()
204
205     @size.setter
206     def size(self, size):
207         """Set window size. Size be y,x tuple. If y or x None, use legal max."""
208         n_lines, n_cols = size
209         if n_lines is None:
210             n_lines = self.tui.stdscr.getmaxyx()[0] - self.start[0]
211         if n_cols is None:
212             n_cols = self.tui.stdscr.getmaxyx()[1] - self.start[1]
213         self.win.resize(n_lines, n_cols)
214
215     def __len__(self):
216         return self.win.getmaxyx()[0] * self.win.getmaxyx()[1]
217
218     def safe_write(self, foo):
219
220         def to_chars_with_attrs(part):
221             attr = curses.A_NORMAL
222             part_string = part
223             if not type(part) == str:
224                 part_string = part[0]
225                 attr = part[1]
226             if len(part_string) > 0:
227                 return [(char, attr) for char in part_string]
228             elif len(part_string) == 1:
229                 return [part]
230             return []
231
232         chars_with_attrs = []
233         if type(foo) == str or len(foo) == 2 and type(foo[1]) == int:
234             chars_with_attrs += to_chars_with_attrs(foo)
235         else:
236             for part in foo:
237                 chars_with_attrs += to_chars_with_attrs(part)
238         self.win.move(0, 0)
239         if len(chars_with_attrs) < len(self):
240             for char_with_attr in chars_with_attrs:
241                 self.win.addstr(char_with_attr[0], char_with_attr[1])
242         else:  # workaround to <https://stackoverflow.com/q/7063128>
243             cut = chars_with_attrs[:len(self) - 1]
244             last_char_with_attr = chars_with_attrs[len(self) - 1]
245             self.win.addstr(self.size[0] - 1, self.size[1] - 2,
246                             last_char_with_attr[0], last_char_with_attr[1])
247             self.win.insstr(self.size[0] - 1, self.size[1] - 2, ' ')
248             self.win.move(0, 0)
249             for char_with_attr in cut:
250                 self.win.addstr(char_with_attr[0], char_with_attr[1])
251
252     def ensure_freshness(self, do_refresh=False):
253         if not do_refresh:
254             for key in self.check_game:
255                 if self.tui.game.to_update[key]:
256                     do_refresh = True
257                     break
258         if not do_refresh:
259             for key in self.check_tui:
260                 if self.tui.to_update[key]:
261                     do_refresh = True
262                     break
263         if do_refresh:
264             self.win.erase()
265             self.draw()
266             self.win.refresh()
267
268
269 class EditWidget(Widget):
270
271     def draw(self):
272         self.safe_write((''.join(self.tui.to_send), curses.color_pair(1)))
273
274
275 class LogWidget(Widget):
276
277     def draw(self):
278         line_width = self.size[1]
279         log_lines = self.tui.game.log_text.split('\n')
280         to_join = []
281         for line in log_lines:
282             to_pad = line_width - (len(line) % line_width)
283             if to_pad == line_width:
284                 to_pad = 0
285             to_join += [line + ' '*to_pad]
286         self.safe_write((''.join(to_join), curses.color_pair(3)))
287
288
289 class MapWidget(Widget):
290
291     def draw(self):
292
293         def terrain_with_objects():
294             terrain_as_list = list(self.tui.game.world.map_.terrain[:])
295             for t in self.tui.game.world.things:
296                 pos_i = self.tui.game.world.map_.get_position_index(t.position)
297                 symbol = self.tui.game.symbol_for_type(t.type_)
298                 if symbol in {'i'} and terrain_as_list[pos_i] in {'@', 'm'}:
299                     continue
300                 terrain_as_list[pos_i] = symbol
301             return ''.join(terrain_as_list)
302
303         def pad_or_cut_x(lines):
304             line_width = self.size[1]
305             for y in range(len(lines)):
306                 line = lines[y]
307                 if line_width > len(line):
308                     to_pad = line_width - (len(line) % line_width)
309                     lines[y] = line + '0' * to_pad
310                 else:
311                     lines[y] = line[:line_width]
312
313         def pad_y(lines):
314             if len(lines) < self.size[0]:
315                 to_pad = self.size[0] - len(lines)
316                 lines += to_pad * ['0' * self.size[1]]
317
318         def lines_to_colored_chars(lines):
319             chars_with_attrs = []
320             for c in ''.join(lines):
321                 if c in {'@', 'm'}:
322                     chars_with_attrs += [(c, curses.color_pair(1))]
323                 elif c == 'i':
324                     chars_with_attrs += [(c, curses.color_pair(4))]
325                 elif c == '.':
326                     chars_with_attrs += [(c, curses.color_pair(2))]
327                 elif c in {'x', 'X', '#'}:
328                     chars_with_attrs += [(c, curses.color_pair(3))]
329                 else:
330                     chars_with_attrs += [c]
331             return chars_with_attrs
332
333         if self.tui.game.world.map_.terrain == '':
334             lines = []
335             pad_y(lines)
336             self.safe_write(''.join(lines))
337             return
338
339         terrain_with_objects = terrain_with_objects()
340         center = self.tui.game.world.player_position
341         lines = self.tui.game.world.map_.format_to_view(terrain_with_objects,
342                                                         center, self.size)
343         pad_or_cut_x(lines)
344         pad_y(lines)
345         self.safe_write(lines_to_colored_chars(lines))
346
347
348 class TurnWidget(Widget):
349
350     def draw(self):
351         self.safe_write((str(self.tui.game.world.turn), curses.color_pair(2)))
352
353
354 class TUI:
355
356     def __init__(self, plom_socket, game):
357         self.socket = plom_socket
358         self.game = game
359         self.parser = Parser(self.game)
360         self.to_update = {'edit': False}
361         curses.wrapper(self.loop)
362
363     def setup_screen(self, stdscr):
364         self.stdscr = stdscr
365         self.stdscr.refresh()  # will be called by getkey else, clearing screen
366         self.stdscr.timeout(10)
367         self.stdscr.addstr(0, 0, 'SEND:')
368         self.stdscr.addstr(2, 0, 'TURN:')
369
370     def loop(self, stdscr):
371         self.setup_screen(stdscr)
372         curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_RED)
373         curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN)
374         curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_BLUE)
375         curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW)
376         curses.curs_set(False)  # hide cursor
377         self.to_send = []
378         self.edit = EditWidget(self, (0, 6), (1, 14), check_tui = ['edit'])
379         self.turn = TurnWidget(self, (2, 6), (1, 14), ['turn'])
380         self.log = LogWidget(self, (4, 0), (None, 20), ['log'])
381         self.map_ = MapWidget(self, (0, 21), (None, None), ['map'])
382         widgets = (self.edit, self.turn, self.log, self.map_)
383         map_mode = False
384         while True:
385             for w in widgets:
386                 w.ensure_freshness()
387             for key in self.game.to_update:
388                 self.game.to_update[key] = False
389             for key in self.to_update:
390                 self.to_update[key] = False
391             try:
392                 key = self.stdscr.getkey()
393                 if key == 'KEY_RESIZE':
394                     curses.endwin()
395                     self.setup_screen(curses.initscr())
396                     for w in widgets:
397                         w.size = w.size_def
398                         w.ensure_freshness(True)
399                 elif key == '\t':  # Tabulator key.
400                     map_mode = False if map_mode else True
401                 elif map_mode:
402                     if key == 'w':
403                         self.socket.send('TASK:MOVE UPLEFT')
404                     elif key == 'e':
405                         self.socket.send('TASK:MOVE UPRIGHT')
406                     if key == 's':
407                         self.socket.send('TASK:MOVE LEFT')
408                     elif key == 'd':
409                         self.socket.send('TASK:MOVE RIGHT')
410                     if key == 'x':
411                         self.socket.send('TASK:MOVE DOWNLEFT')
412                     elif key == 'c':
413                         self.socket.send('TASK:MOVE DOWNRIGHT')
414                 else:
415                     if len(key) == 1 and key in ASCII_printable and \
416                             len(self.to_send) < len(self.edit):
417                         self.to_send += [key]
418                         self.to_update['edit'] = True
419                     elif key == 'KEY_BACKSPACE':
420                         self.to_send[:] = self.to_send[:-1]
421                         self.to_update['edit'] = True
422                     elif key == '\n':  # Return key
423                         self.socket.send(''.join(self.to_send))
424                         self.to_send[:] = []
425                         self.to_update['edit'] = True
426             except curses.error:
427                 pass
428             if self.game.do_quit:
429                 break
430
431
432 s = socket.create_connection(('127.0.0.1', 5000))
433 plom_socket = PlomSocket(s)
434 game = Game()
435 t = threading.Thread(target=recv_loop, args=(plom_socket, game))
436 t.start()
437 TUI(plom_socket, game)