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