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