home · contact · privacy
df5d7e360147a074e2f5754ed9e0ece91e514502
[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)*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_LINE(self, y, terrain_line):
57         width_map = self.map_size[1]
58         if y >= self.map_size[0]:
59             raise ArgError('too large row number %s' % y)
60         width_line = len(terrain_line)
61         if width_line > width_map:
62             raise ArgError('too large map line width %s' % width_line)
63         self.terrain_map = self.terrain_map[:y * width_map] + \
64                            terrain_line + self.terrain_map[(y + 1) * width_map:]
65     cmd_TERRAIN_LINE.argtypes = 'int:nonneg 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         map_lines = []
85         map_size = len(self.game.terrain_map)
86         start_cut = 0
87         while start_cut < map_size:
88             limit = start_cut + self.game.map_size[1]
89             map_lines += [self.game.terrain_map[start_cut:limit]]
90             start_cut = limit
91         for t in self.game.things:
92             line_as_list = list(map_lines[t.position[0]])
93             line_as_list[t.position[1]] = t.symbol
94             map_lines[t.position[0]] = ''.join(line_as_list)
95         return "\n".join(map_lines)
96
97     def update(self):
98         """Redraw all non-edit widgets."""
99         self.turn_widget.set_text('TURN: ' + str(self.game.turn))
100         self.log_widget.set_text(self.game.log_text)
101         self.map_widget.set_text(self.draw_map())
102
103     class EditToSocketWidget(urwid.Edit):
104         """Extends urwid.Edit with socket to send input on 'enter' to."""
105
106         def __init__(self, socket, *args, **kwargs):
107             super().__init__(*args, **kwargs)
108             self.socket = socket
109
110         def keypress(self, size, key):
111             """Extend super(): on Enter, send .edit_text, and empty it."""
112             if key != 'enter':
113                 return super().keypress(size, key)
114             plom_socket_io.send(self.socket, self.edit_text)
115             self.edit_text = ''
116
117
118 class PlomRogueClient:
119
120     def __init__(self, game, socket):
121         """Build client urwid interface around socket communication.
122
123         Sets up all widgets for writing to the socket and representing data
124         from it. Sending via a WidgetManager.EditToSocket widget is
125         straightforward; polling the socket for input from the server in
126         parallel to the urwid main loop not so much:
127
128         The urwid developers warn against sharing urwid resources among
129         threads, so having a socket polling thread for writing to an urwid
130         widget while other widgets are handled in other threads would be
131         dangerous. Urwid developers recommend using urwid's watch_pipe
132         mechanism instead: using a pipe from non-urwid threads into a single
133         urwid thread. We use self.recv_loop_thread to poll the socket, therein
134         write socket.recv output to an object that is then linked to by
135         self.server_output (which is known to the urwid thread), then use the
136         pipe to urwid to trigger it pulling new data from self.server_output to
137         handle via self.handle_input. (We *could* pipe socket.recv output
138         directly, but then we get complicated buffering situations here as well
139         as in the urwid code that receives the pipe output. It's easier to just
140         tell the urwid code where it finds full new server messages to handle.)
141         """
142         self.game = game
143         self.parser = Parser(self.game)
144         self.socket = socket
145         self.widget_manager = WidgetManager(self.socket, self.game)
146         self.server_output = []
147         self.urwid_loop = urwid.MainLoop(self.widget_manager.top)
148         self.urwid_pipe_write_fd = self.urwid_loop.watch_pipe(self.
149                                                               handle_input)
150         self.recv_loop_thread = threading.Thread(target=self.recv_loop)
151
152     def handle_input(self, trigger):
153         """On input from recv_loop thread, parse and enact commands.
154
155         Serves as a receiver to urwid's watch_pipe mechanism, with trigger the
156         data that a pipe defined by watch_pipe delivers. To avoid buffering
157         trouble, we don't care for that data beyond the fact that its receival
158         triggers this function: The sender is to write the data it wants to
159         deliver into the container referenced by self.server_output, and just
160         pipe the trigger to inform us about this.
161
162         If the message delivered is 'BYE', quits Urwid. Otherwise tries to
163         parse it as a command, and enact it. In all cases but the 'BYE', calls
164         self.widget_manager.update.
165         """
166         msg = self.server_output[0]
167         if msg == 'BYE':
168             raise urwid.ExitMainLoop()
169         try:
170             command = self.parser.parse(msg)
171             if command is None:
172                 self.game.log('UNHANDLED INPUT: ' + msg)
173             else:
174                 command()
175         except ArgError as e:
176             self.game.log('ARGUMENT ERROR: ' + msg + '\n' + str(e))
177         self.widget_manager.update()
178         del self.server_output[0]
179
180     def recv_loop(self):
181         """Loop to receive messages from socket, deliver them to urwid thread.
182
183         Waits for self.server_output to become empty (this signals that the
184         input handler is finished / ready to receive new input), then writes
185         finished message from socket to self.server_output, then sends a single
186         b' ' through self.urwid_pipe_write_fd to trigger the input handler.
187         """
188         import os
189         for msg in plom_socket_io.recv(self.socket):
190             while len(self.server_output) > 0:  # Wait until self.server_output
191                 pass                            # is emptied by input handler.
192             self.server_output += [msg]
193             os.write(self.urwid_pipe_write_fd, b' ')
194
195     def run(self):
196         """Run in parallel urwid_loop and recv_loop threads."""
197         self.recv_loop_thread.start()
198         self.urwid_loop.run()
199         self.recv_loop_thread.join()
200
201
202 if __name__ == '__main__':
203     game = Game()
204     s = socket.create_connection(('127.0.0.1', 5000))
205     p = PlomRogueClient(game, s)
206     p.run()
207     s.close()