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