home · contact · privacy
Add player move (only vertically), basic input error handling on server.
[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 = urwid.Text('')
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 MapWidget(urwid.Text):
78         """Stores/updates/draws game map."""
79         map_size = (5, 5)
80         terrain_map = ' ' * 25
81         position = (0, 0)
82
83         def draw_map(self):
84             """Draw map view from .map_size, .terrain_map, .position."""
85             whole_map = []
86             for c in self.terrain_map:
87                 whole_map += [c]
88             pos_i = self.position[0] * (self.map_size[1] + 1) + self.position[1]
89             whole_map[pos_i] = '@'
90             self.set_text(''.join(whole_map))
91
92         def get_yx(self, yx_string):
93
94             def get_axis_position_from_argument(axis, token):
95                 if len(token) < 3 or token[:2] != axis + ':' or \
96                         not token[2:].isdigit():
97                     raise ArgumentError('Bad arg for ' + axis + ' position.')
98                 return int(token[2:])
99
100             tokens = yx_string.split(',')
101             if len(tokens) != 2:
102                 raise ArgumentError('wrong number of ","-separated arguments')
103             y = get_axis_position_from_argument('Y', tokens[0])
104             x = get_axis_position_from_argument('X', tokens[1])
105             return (y, x)
106
107         def update_map_size(self, size_string):
108             """Set map size, redo self.terrain_map in new size, '?'-filled."""
109             new_map_size = self.get_yx(size_string)
110             if 0 in new_map_size:
111                 raise ArgumentError('size value for either axis must be >0')
112             self.map_size = new_map_size
113             self.terrain_map = ''
114             for y in range(self.map_size[0]):
115                 self.terrain_map += '?' * self.map_size[1] + '\n'
116             self.draw_map()
117
118         def update_terrain(self, terrain_map):
119             """Update self.terrain_map. Ensure size matching self.map_size."""
120             lines = terrain_map.split('\n')
121             if len(lines) != self.map_size[0]:
122                 raise ArgumentError('wrong map height')
123             for line in lines:
124                 if len(line) != self.map_size[1]:
125                     raise ArgumentError('wrong map width')
126             self.terrain_map = terrain_map
127             self.draw_map()
128
129         def update_position(self, position_string):
130             """Update self.position, ensure it's within map bounds."""
131
132             def get_axis_position_from_argument(axis, token):
133                 if len(token) < 3 or token[:2] != axis + ':' or \
134                         not token[2:].isdigit():
135                     raise ArgumentError('Bad arg for ' + axis + ' position.')
136                 return int(token[2:])
137
138             new_position = self.get_yx(position_string)
139             if new_position[0] >= self.map_size[0] or \
140                     new_position[1] >= self.map_size[1]:
141                 raise ArgumentError('Position outside of map size bounds.')
142             self.position = new_position
143             self.draw_map()
144
145     class InputHandler:
146         """Delivers data from other thread to widget via message_container.
147
148         The class only exists to provide handle_input as a bound method, with
149         widget and message_container pre-set, as (bound) handle_input is used
150         as a callback in urwid's watch_pipe – which merely provides its
151         callback target with one parameter for a pipe to read data from an
152         urwid-external thread.
153         """
154
155         def __init__(self, widget1, widget2, message_container):
156             self.widget1 = widget1
157             self.widget2 = widget2
158             self.message_container = message_container
159
160         def handle_input(self, trigger):
161             """On input from other thread, either quit or write to widget text.
162
163             Serves as a receiver to urwid's watch_pipe mechanism, with trigger
164             the data that a pipe defined by watch_pipe delivers. To avoid
165             buffering trouble, we don't care for that data beyond the fact that
166             its receival triggers this function: The sender is to write the
167             data it wants to deliver into the container referenced by
168             self.message_container, and just pipe the trigger to inform us
169             about this.
170
171             If the message delivered is 'BYE', quits Urwid.
172             """
173
174             def mapdraw_command(prefix, func):
175                 n = len(prefix)
176                 if len(msg) > n and msg[:n] == prefix:
177                     m = getattr(self.widget2, func)
178                     m(msg[n:])
179                     return True
180                 return False
181
182             msg = self.message_container[0]
183             if msg == 'BYE':
184                 raise urwid.ExitMainLoop()
185                 return
186             found_command = False
187             try:
188                 found_command = (
189                     mapdraw_command('TERRAIN\n', 'update_terrain') or
190                     mapdraw_command('POSITION ', 'update_position') or
191                     mapdraw_command('MAP_SIZE ', 'update_map_size'))
192             except ArgumentError as e:
193                 self.widget1.set_text('ARGUMENT ERROR: ' + msg + '\n' +
194                                       str(e))
195             else:
196                 if not found_command:
197                     self.widget1.set_text('UNHANDLED INPUT: ' + msg)
198             del self.message_container[0]
199
200     def recv_loop(self):
201         """Loop to receive messages from socket and deliver them to urwid.
202
203         Waits for self.server_output to become empty (this signals that the
204         input handler is finished / ready to receive new input), then writes
205         finished message from socket to self.server_output, then sends a single
206         b' ' through self.urwid_pipe_write_fd to trigger the input handler.
207         """
208         import os
209         for msg in plom_socket_io.recv(self.socket):
210             while len(self.server_output) > 0:
211                 pass
212             self.server_output += [msg]
213             os.write(self.urwid_pipe_write_fd, b' ')
214
215     def run(self):
216         """Run in parallel main and recv_loop thread."""
217         self.recv_loop_thread.start()
218         self.main_loop.run()
219         self.recv_loop_thread.join()
220
221
222 s = socket.create_connection(('127.0.0.1', 5000))
223 u = UrwidSetup(s)
224 u.run()
225 s.close()