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