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