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