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