home · contact · privacy
Add Thing types and map them to symbols to display in client.
[plomrogue2-experiments] / client.py
1 #!/usr/bin/env python3
2
3 import urwid
4 import plom_socket_io
5 import socket
6 import threading
7
8
9 class ArgumentError(Exception):
10     pass
11
12
13 class UrwidSetup:
14
15     def __init__(self, socket):
16         """Build client urwid interface around socket communication.
17
18         Sets up all widgets for writing to the socket and representing data
19         from it. Sending via a self.EditToSocket widget is straightforward;
20         polling the socket for input from the server in parallel to the urwid
21         main loop not so much:
22
23         The urwid developers warn against sharing urwid resources among
24         threads, so having a socket polling thread for writing to an urwid
25         widget while other widgets are handled in other threads would be
26         dangerous. Urwid developers recommend using urwid's watch_pipe
27         mechanism instead: using a pipe from non-urwid threads into a single
28         urwid thread. We use self.recv_loop_thread to poll the socket, therein
29         write socket.recv output to an object that is then linked to by
30         self.server_output (which is known to the urwid thread), then use the
31         pipe to urwid to trigger it pulling new data from self.server_output to
32         handle via self.InputHandler. (We *could* pipe socket.recv output
33         directly, but then we get complicated buffering situations here as well
34         as in the urwid code that receives the pipe output. It's easier to just
35         tell the urwid code where it finds full new server messages to handle.)
36         """
37         self.socket = socket
38         self.main_loop = urwid.MainLoop(self.setup_widgets())
39         self.server_output = []
40         input_handler = getattr(self.InputHandler(self.reply_widget,
41                                                   self.map_widget,
42                                                   self.server_output),
43                                 'handle_input')
44         self.urwid_pipe_write_fd = self.main_loop.watch_pipe(input_handler)
45         self.recv_loop_thread = threading.Thread(target=self.recv_loop)
46
47     def setup_widgets(self):
48         """Return container widget with all widgets we want on our screen.
49
50         Sets up an urwid.Pile inside a returned urwid.Filler; top to bottom:
51         - an EditToSocketWidget, prefixing self.socket input with 'SEND: '
52         - a 50-col wide urwid.Padding container for self.map_widget, which is
53           to print clipped map representations
54         - self.reply_widget, a urwid.Text widget printing self.socket replies
55         """
56         edit_widget = self.EditToSocketWidget(self.socket, 'SEND: ')
57         self.reply_widget = self.LogWidget('')
58         self.map_widget = self.MapWidget('', wrap='clip')
59         map_box = urwid.Padding(self.map_widget, width=50)
60         widget_pile = urwid.Pile([edit_widget, map_box, self.reply_widget])
61         return urwid.Filler(widget_pile, valign='top')
62
63     class EditToSocketWidget(urwid.Edit):
64         """Extends urwid.Edit with socket to send input on 'enter' to."""
65
66         def __init__(self, socket, *args, **kwargs):
67             super().__init__(*args, **kwargs)
68             self.socket = socket
69
70         def keypress(self, size, key):
71             """Extend super(): on Enter, send .edit_text, and empty it."""
72             if key != 'enter':
73                 return super().keypress(size, key)
74             plom_socket_io.send(self.socket, self.edit_text)
75             self.edit_text = ''
76
77     class LogWidget(urwid.Text):
78         """Display client log, newest message on top."""
79
80         def add(self, text):
81             """Add text to (top of) log."""
82             self.set_text(text + '\n' + self.text)
83
84     class MapWidget(urwid.Text):
85         """Stores/updates/draws game map."""
86         map_size = (5, 5)
87         terrain_map = ' ' * 25
88         position = (0, 0)
89         things = []
90
91         class Thing:
92             def __init__(self, position, symbol):
93                 self.position = position
94                 self.symbol = symbol
95
96         def draw_map(self):
97             """Draw map view from .map_size, .terrain_map, .position."""
98             whole_map = []
99             for c in self.terrain_map:
100                 whole_map += [c]
101             for t in self.things:
102                 pos_i = t.position[0] * (self.map_size[1] + 1) + t.position[1]
103                 whole_map[pos_i] = t.symbol
104             self.set_text(''.join(whole_map))
105
106         def get_yx(self, yx_string):
107
108             def get_axis_position_from_argument(axis, token):
109                 if len(token) < 3 or token[:2] != axis + ':' or \
110                         not token[2:].isdigit():
111                     raise ArgumentError('Bad arg for ' + axis + ' position.')
112                 return int(token[2:])
113
114             tokens = yx_string.split(',')
115             if len(tokens) != 2:
116                 raise ArgumentError('wrong number of ","-separated arguments')
117             y = get_axis_position_from_argument('Y', tokens[0])
118             x = get_axis_position_from_argument('X', tokens[1])
119             return (y, x)
120
121         def update_map_size(self, size_string):
122             """Set map size, redo self.terrain_map in new size, '?'-filled."""
123             new_map_size = self.get_yx(size_string)
124             if 0 in new_map_size:
125                 raise ArgumentError('size value for either axis must be >0')
126             self.map_size = new_map_size
127             self.terrain_map = ''
128             for y in range(self.map_size[0]):
129                 self.terrain_map += '?' * self.map_size[1] + '\n'
130             self.draw_map()
131
132         def update_terrain(self, terrain_map):
133             """Update self.terrain_map. Ensure size matching self.map_size."""
134             lines = terrain_map.split('\n')
135             if len(lines) != self.map_size[0]:
136                 raise ArgumentError('wrong map height')
137             for line in lines:
138                 if len(line) != self.map_size[1]:
139                     raise ArgumentError('wrong map width')
140             self.terrain_map = terrain_map
141             self.draw_map()
142
143         def update_things(self, thing_description):
144             """Append thing of thing_description to self.things."""
145             thing_types = {'human': '@', 'monster': 'M'}
146             tokens = thing_description.split()
147             if len(tokens) != 2:
148                 raise ArgumentError('Wrong number of tokens.')
149             yx = self.get_yx(tokens[1])
150             if yx[0] >= self.map_size[0] or yx[1] >= self.map_size[1]:
151                 raise ArgumentError('Position outside of map size bounds.')
152             type_token = tokens[0]
153             prefix = 'TYPE:'
154             type_ = '?'
155             if len(type_token) <= len(prefix) or \
156                     type_token[:len(prefix)] != prefix:
157                 raise ArgumentError('Invalid type token.')
158             type_ = type_token[len(prefix):]
159             if type_ not in thing_types:
160                 raise ArgumentError('Unknown thing type.')
161             self.things += [self.Thing(yx, thing_types[type_])]
162             self.draw_map()
163
164         def clear_things(self, _):
165             self.things = []
166
167     class InputHandler:
168         """Delivers data from other thread to widget via message_container.
169
170         The class only exists to provide handle_input as a bound method, with
171         widget and message_container pre-set, as (bound) handle_input is used
172         as a callback in urwid's watch_pipe – which merely provides its
173         callback target with one parameter for a pipe to read data from an
174         urwid-external thread.
175         """
176
177         def __init__(self, log_widget, map_widget, message_container):
178             self.log_widget = log_widget
179             self.map_widget = map_widget
180             self.message_container = message_container
181
182         def handle_input(self, trigger):
183             """On input from other thread, either quit or write to widget text.
184
185             Serves as a receiver to urwid's watch_pipe mechanism, with trigger
186             the data that a pipe defined by watch_pipe delivers. To avoid
187             buffering trouble, we don't care for that data beyond the fact that
188             its receival triggers this function: The sender is to write the
189             data it wants to deliver into the container referenced by
190             self.message_container, and just pipe the trigger to inform us
191             about this.
192
193             If the message delivered is 'BYE', quits Urwid.
194             """
195
196             def mapdraw_command(prefix, func):
197                 n = len(prefix)
198                 if len(msg) > n and msg[:n] == prefix:
199                     m = getattr(self.map_widget, func)
200                     m(msg[n:])
201                     return True
202                 return False
203
204             msg = self.message_container[0]
205             if msg == 'BYE':
206                 raise urwid.ExitMainLoop()
207                 return
208             found_command = False
209             try:
210                 found_command = (
211                     mapdraw_command('NEW_TURN ', 'clear_things') or
212                     mapdraw_command('TERRAIN\n', 'update_terrain') or
213                     mapdraw_command('THING ', 'update_things') or
214                     mapdraw_command('MAP_SIZE ', 'update_map_size'))
215             except ArgumentError as e:
216                 self.log_widget.add('ARGUMENT ERROR: ' + msg + '\n' + str(e))
217             else:
218                 if not found_command:
219                     self.log_widget.add('UNHANDLED INPUT: ' + msg)
220             del self.message_container[0]
221
222     def recv_loop(self):
223         """Loop to receive messages from socket and deliver them to urwid.
224
225         Waits for self.server_output to become empty (this signals that the
226         input handler is finished / ready to receive new input), then writes
227         finished message from socket to self.server_output, then sends a single
228         b' ' through self.urwid_pipe_write_fd to trigger the input handler.
229         """
230         import os
231         for msg in plom_socket_io.recv(self.socket):
232             while len(self.server_output) > 0:
233                 pass
234             self.server_output += [msg]
235             os.write(self.urwid_pipe_write_fd, b' ')
236
237     def run(self):
238         """Run in parallel main and recv_loop thread."""
239         self.recv_loop_thread.start()
240         self.main_loop.run()
241         self.recv_loop_thread.join()
242
243
244 s = socket.create_connection(('127.0.0.1', 5000))
245 u = UrwidSetup(s)
246 u.run()
247 s.close()