home · contact · privacy
c5d6fcde99000d5972cd9d0a4b3c93a5737ed25b
[plomrogue2-experiments] / client.py
1 #!/usr/bin/env python3
2 import urwid
3 import plom_socket_io
4 import socket
5 import threading
6 from parser import ArgError, Parser
7
8
9 class Game:
10     turn = 0
11     log_text = ''
12     map_size = (0, 0)
13     terrain_map = ''
14     things = []
15
16     class Thing:
17         def __init__(self, id_, position, symbol):
18             self.id_ = id_
19             self.position = position
20             self.symbol = symbol
21
22     def log(self, msg):
23         """Prefix msg plus newline to self.log_text."""
24         self.log_text = msg + '\n' + self.log_text
25
26     def get_thing(self, i):
27         for thing in self.things:
28             if i == thing.id_:
29                 return thing
30         t = self.Thing(i, [0,0], '?')
31         self.things += [t]
32         return t
33
34     def cmd_THING_TYPE(self, i, type_):
35         t = self.get_thing(i)
36         symbol = '?'
37         if type_ == 'human':
38             symbol = '@'
39         elif type_ == 'monster':
40             symbol = 'm'
41         t.symbol = symbol
42     cmd_THING_TYPE.argtypes = 'int:nonneg string'
43
44     def cmd_THING_POS(self, i, yx):
45         t = self.get_thing(i)
46         t.position = list(yx)
47     cmd_THING_POS.argtypes = 'int:nonneg yx_tuple:nonneg'
48
49     def cmd_THING_POS(self, i, yx):
50         t = self.get_thing(i)
51         t.position = list(yx)
52     cmd_THING_POS.argtypes = 'int:nonneg yx_tuple:nonneg'
53
54     def cmd_MAP_SIZE(self, yx):
55         """Set self.map_size to yx, redraw self.terrain_map as '?' cells."""
56         y, x = yx
57         self.map_size = (y, x)
58         self.terrain_map = ''
59         for y in range(self.map_size[0]):
60             self.terrain_map += '?' * self.map_size[1]
61     cmd_MAP_SIZE.argtypes = 'yx_tuple:nonneg'
62
63     def cmd_TURN_FINISHED(self, n):
64         """Do nothing. (This may be extended later.)"""
65         pass
66     cmd_TURN_FINISHED.argtypes = 'int:nonneg'
67
68     def cmd_NEW_TURN(self, n):
69         """Set self.turn to n, empty self.things."""
70         self.turn = n
71         self.things = []
72     cmd_NEW_TURN.argtypes = 'int:nonneg'
73
74     def cmd_TERRAIN_LINE(self, y, terrain_line):
75         width_map = self.map_size[1]
76         if y >= self.map_size[0]:
77             raise ArgError('too large row number %s' % y)
78         width_line = len(terrain_line)
79         if width_line > width_map:
80             raise ArgError('too large map line width %s' % width_line)
81         self.terrain_map = self.terrain_map[:y * width_map] + \
82                            terrain_line + self.terrain_map[(y + 1) * width_map:]
83     cmd_TERRAIN_LINE.argtypes = 'int:nonneg string'
84
85
86 class WidgetManager:
87
88     def __init__(self, socket, game):
89         """Set up all urwid widgets we want on the screen."""
90         self.game = game
91         edit_widget = self.EditToSocketWidget(socket, 'SEND: ')
92         self.map_widget = urwid.Text('', wrap='clip')
93         self.turn_widget = urwid.Text('')
94         self.log_widget = urwid.Text('')
95         map_box = urwid.Padding(self.map_widget, width=50)
96         widget_pile = urwid.Pile([edit_widget, map_box, self.turn_widget,
97                                   self.log_widget])
98         self.top = urwid.Filler(widget_pile, valign='top')
99
100     def draw_map(self):
101         """Draw map view from .game.terrain_map, .game.things."""
102         map_lines = []
103         map_size = len(self.game.terrain_map)
104         start_cut = 0
105         while start_cut < map_size:
106             limit = start_cut + self.game.map_size[1]
107             map_lines += [self.game.terrain_map[start_cut:limit]]
108             start_cut = limit
109         for t in self.game.things:
110             line_as_list = list(map_lines[t.position[0]])
111             line_as_list[t.position[1]] = t.symbol
112             map_lines[t.position[0]] = ''.join(line_as_list)
113         return "\n".join(map_lines)
114
115     def update(self):
116         """Redraw all non-edit widgets."""
117         self.turn_widget.set_text('TURN: ' + str(self.game.turn))
118         self.log_widget.set_text(self.game.log_text)
119         self.map_widget.set_text(self.draw_map())
120
121     class EditToSocketWidget(urwid.Edit):
122         """Extends urwid.Edit with socket to send input on 'enter' to."""
123
124         def __init__(self, socket, *args, **kwargs):
125             super().__init__(*args, **kwargs)
126             self.socket = socket
127
128         def keypress(self, size, key):
129             """Extend super(): on Enter, send .edit_text, and empty it."""
130             if key != 'enter':
131                 return super().keypress(size, key)
132             plom_socket_io.send(self.socket, self.edit_text)
133             self.edit_text = ''
134
135
136 class PlomRogueClient:
137
138     def __init__(self, game, socket):
139         """Build client urwid interface around socket communication.
140
141         Sets up all widgets for writing to the socket and representing data
142         from it. Sending via a WidgetManager.EditToSocket widget is
143         straightforward; polling the socket for input from the server in
144         parallel to the urwid main loop not so much:
145
146         The urwid developers warn against sharing urwid resources among
147         threads, so having a socket polling thread for writing to an urwid
148         widget while other widgets are handled in other threads would be
149         dangerous. Urwid developers recommend using urwid's watch_pipe
150         mechanism instead: using a pipe from non-urwid threads into a single
151         urwid thread. We use self.recv_loop_thread to poll the socket, therein
152         write socket.recv output to an object that is then linked to by
153         self.server_output (which is known to the urwid thread), then use the
154         pipe to urwid to trigger it pulling new data from self.server_output to
155         handle via self.handle_input. (We *could* pipe socket.recv output
156         directly, but then we get complicated buffering situations here as well
157         as in the urwid code that receives the pipe output. It's easier to just
158         tell the urwid code where it finds full new server messages to handle.)
159         """
160         self.game = game
161         self.parser = Parser(self.game)
162         self.socket = socket
163         self.widget_manager = WidgetManager(self.socket, self.game)
164         self.server_output = []
165         self.urwid_loop = urwid.MainLoop(self.widget_manager.top)
166         self.urwid_pipe_write_fd = self.urwid_loop.watch_pipe(self.
167                                                               handle_input)
168         self.recv_loop_thread = threading.Thread(target=self.recv_loop)
169
170     def handle_input(self, trigger):
171         """On input from recv_loop thread, parse and enact commands.
172
173         Serves as a receiver to urwid's watch_pipe mechanism, with trigger the
174         data that a pipe defined by watch_pipe delivers. To avoid buffering
175         trouble, we don't care for that data beyond the fact that its receival
176         triggers this function: The sender is to write the data it wants to
177         deliver into the container referenced by self.server_output, and just
178         pipe the trigger to inform us about this.
179
180         If the message delivered is 'BYE', quits Urwid. Otherwise tries to
181         parse it as a command, and enact it. In all cases but the 'BYE', calls
182         self.widget_manager.update.
183         """
184         msg = self.server_output[0]
185         if msg == 'BYE':
186             raise urwid.ExitMainLoop()
187         try:
188             command = self.parser.parse(msg)
189             if command is None:
190                 self.game.log('UNHANDLED INPUT: ' + msg)
191             else:
192                 command()
193         except ArgError as e:
194             self.game.log('ARGUMENT ERROR: ' + msg + '\n' + str(e))
195         self.widget_manager.update()
196         del self.server_output[0]
197
198     def recv_loop(self):
199         """Loop to receive messages from socket, deliver them to urwid thread.
200
201         Waits for self.server_output to become empty (this signals that the
202         input handler is finished / ready to receive new input), then writes
203         finished message from socket to self.server_output, then sends a single
204         b' ' through self.urwid_pipe_write_fd to trigger the input handler.
205         """
206         import os
207         for msg in plom_socket_io.recv(self.socket):
208             while len(self.server_output) > 0:  # Wait until self.server_output
209                 pass                            # is emptied by input handler.
210             self.server_output += [msg]
211             os.write(self.urwid_pipe_write_fd, b' ')
212
213     def run(self):
214         """Run in parallel urwid_loop and recv_loop threads."""
215         self.recv_loop_thread.start()
216         self.urwid_loop.run()
217         self.recv_loop_thread.join()
218
219
220 if __name__ == '__main__':
221     game = Game()
222     s = socket.create_connection(('127.0.0.1', 5000))
223     p = PlomRogueClient(game, s)
224     p.run()
225     s.close()