home · contact · privacy
Improve client.
[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 import game_common
8
9
10 class MapSquare(game_common.Map):
11
12     def list_terrain_to_lines(self, terrain_as_list):
13         terrain = ''.join(terrain_as_list)
14         map_lines = []
15         start_cut = 0
16         while start_cut < len(terrain):
17             limit = start_cut + self.size[1]
18             map_lines += [terrain[start_cut:limit]]
19             start_cut = limit
20         return "\n".join(map_lines)
21
22
23 class MapHex(game_common.Map):
24
25     def list_terrain_to_lines(self, terrain_as_list):
26         new_terrain_list = [' ']
27         x = 0
28         y = 0
29         for c in terrain_as_list:
30             new_terrain_list += [c, ' ']
31             x += 1
32             if x == self.size[1]:
33                 new_terrain_list += ['\n']
34                 x = 0
35                 y += 1
36                 if y % 2 == 0:
37                     new_terrain_list += [' ']
38         return ''.join(new_terrain_list)
39
40
41 map_manager = game_common.MapManager(globals())
42
43
44 class World(game_common.World):
45
46     def __init__(self, game, *args, **kwargs):
47         """Extend original with local classes and empty default map.
48
49         We need the empty default map because we draw the map widget
50         on any update, even before we actually receive map data.
51         """
52         super().__init__(*args, **kwargs)
53         self.game = game
54         self.map_ = self.game.map_manager.get_map_class('Hex')()
55
56
57 class Game(game_common.CommonCommandsMixin):
58
59     def __init__(self):
60         self.map_manager = map_manager
61         self.world = World(self)
62         self.log_text = ''
63
64     def log(self, msg):
65         """Prefix msg plus newline to self.log_text."""
66         self.log_text = msg + '\n' + self.log_text
67
68     def symbol_for_type(self, type_):
69         symbol = '?'
70         if type_ == 'human':
71             symbol = '@'
72         elif type_ == 'monster':
73             symbol = 'm'
74         return symbol
75
76     def cmd_LAST_PLAYER_TASK_RESULT(self, msg):
77         if msg != "success":
78             self.log_text = msg + '\n' + self.log_text
79     cmd_LAST_PLAYER_TASK_RESULT.argtypes = 'string'
80
81     def cmd_TURN_FINISHED(self, n):
82         """Do nothing. (This may be extended later.)"""
83         pass
84     cmd_TURN_FINISHED.argtypes = 'int:nonneg'
85
86     def cmd_NEW_TURN(self, n):
87         """Set self.turn to n, empty self.things."""
88         self.world.turn = n
89         self.world.things = []
90     cmd_NEW_TURN.argtypes = 'int:nonneg'
91
92     def cmd_VISIBLE_MAP_LINE(self, y, terrain_line):
93         self.world.map_.set_line(y, terrain_line)
94     cmd_VISIBLE_MAP_LINE.argtypes = 'int:nonneg string'
95
96
97 class WidgetManager:
98
99     def __init__(self, socket, game):
100         """Set up all urwid widgets we want on the screen."""
101         self.game = game
102         edit_widget = self.EditToSocketWidget(socket, 'SEND: ')
103         self.map_widget = urwid.Text('', wrap='clip')
104         self.turn_widget = urwid.Text('')
105         self.log_widget = urwid.Text('')
106
107         edit_map = urwid.AttrMap(edit_widget, 'foo')
108         turn_map = urwid.AttrMap(self.turn_widget, 'bar')
109         log_map = urwid.AttrMap(self.log_widget, 'baz')
110         widget_pile = urwid.Pile([edit_map,
111                                   urwid.Divider(),
112                                   turn_map,
113                                   urwid.Divider(),
114                                   log_map])
115         widget_columns = urwid.Columns([(20, widget_pile), self.map_widget],
116                                        dividechars=1)
117
118         self.top = urwid.Filler(widget_columns, valign='top')
119
120     def draw_map(self):
121         """Draw map view from .game.map_.terrain, .game.things."""
122         terrain_as_list = list(self.game.world.map_.terrain[:])
123         for t in self.game.world.things:
124             pos_i = self.game.world.map_.get_position_index(t.position)
125             terrain_as_list[pos_i] = self.game.symbol_for_type(t.type_)
126         return self.game.world.map_.list_terrain_to_lines(terrain_as_list)
127
128     def update(self):
129         """Redraw all non-edit widgets."""
130         self.turn_widget.set_text('TURN: ' + str(self.game.world.turn))
131         self.log_widget.set_text(self.game.log_text)
132         map_lines = self.draw_map()
133         new_map_text = []
134         for char in map_lines:
135             if char == '.':
136                 new_map_text += [('foo', char)]
137             elif char in {'x', 'X', '#'}:
138                 new_map_text += [('bar', char)]
139             elif char in {'@', 'm'}:
140                 new_map_text += [('baz', char)]
141             else:
142                 new_map_text += [char]
143         self.map_widget.set_text(new_map_text)
144
145     class EditToSocketWidget(urwid.Edit):
146         """Extends urwid.Edit with socket to send input on 'enter' to."""
147
148         def __init__(self, socket, *args, **kwargs):
149             super().__init__(*args, **kwargs)
150             self.socket = socket
151
152         def keypress(self, size, key):
153             """Extend super(): on Enter, send .edit_text, and empty it."""
154             if key != 'enter':
155                 return super().keypress(size, key)
156             plom_socket_io.send(self.socket, self.edit_text)
157             self.edit_text = ''
158
159
160 class PlomRogueClient:
161
162     def __init__(self, game, socket):
163         """Build client urwid interface around socket communication.
164
165         Sets up all widgets for writing to the socket and representing data
166         from it. Sending via a WidgetManager.EditToSocket widget is
167         straightforward; polling the socket for input from the server in
168         parallel to the urwid main loop not so much:
169
170         The urwid developers warn against sharing urwid resources among
171         threads, so having a socket polling thread for writing to an urwid
172         widget while other widgets are handled in other threads would be
173         dangerous. Urwid developers recommend using urwid's watch_pipe
174         mechanism instead: using a pipe from non-urwid threads into a single
175         urwid thread. We use self.recv_loop_thread to poll the socket, therein
176         write socket.recv output to an object that is then linked to by
177         self.server_output (which is known to the urwid thread), then use the
178         pipe to urwid to trigger it pulling new data from self.server_output to
179         handle via self.handle_input. (We *could* pipe socket.recv output
180         directly, but then we get complicated buffering situations here as well
181         as in the urwid code that receives the pipe output. It's easier to just
182         tell the urwid code where it finds full new server messages to handle.)
183         """
184         self.game = game
185         self.parser = Parser(self.game)
186         self.socket = socket
187         self.widget_manager = WidgetManager(self.socket, self.game)
188         self.server_output = []
189         palette = [('foo', 'white', 'dark red'),
190                    ('bar', 'white', 'dark blue'),
191                    ('baz', 'white', 'dark green')]
192         self.urwid_loop = urwid.MainLoop(self.widget_manager.top, palette)
193         self.urwid_pipe_write_fd = self.urwid_loop.watch_pipe(self.
194                                                               handle_input)
195         self.recv_loop_thread = threading.Thread(target=self.recv_loop)
196
197     def handle_input(self, trigger):
198         """On input from recv_loop thread, parse and enact commands.
199
200         Serves as a receiver to urwid's watch_pipe mechanism, with trigger the
201         data that a pipe defined by watch_pipe delivers. To avoid buffering
202         trouble, we don't care for that data beyond the fact that its receival
203         triggers this function: The sender is to write the data it wants to
204         deliver into the container referenced by self.server_output, and just
205         pipe the trigger to inform us about this.
206
207         If the message delivered is 'BYE', quits Urwid. Otherwise tries to
208         parse it as a command, and enact it. In all cases but the 'BYE', calls
209         self.widget_manager.update.
210         """
211         msg = self.server_output[0]
212         if msg == 'BYE':
213             raise urwid.ExitMainLoop()
214         try:
215             command = self.parser.parse(msg)
216             if command is None:
217                 self.game.log('UNHANDLED INPUT: ' + msg)
218             else:
219                 command()
220         except ArgError as e:
221             self.game.log('ARGUMENT ERROR: ' + msg + '\n' + str(e))
222         self.widget_manager.update()
223         del self.server_output[0]
224
225     def recv_loop(self):
226         """Loop to receive messages from socket, deliver them to urwid thread.
227
228         Waits for self.server_output to become empty (this signals that the
229         input handler is finished / ready to receive new input), then writes
230         finished message from socket to self.server_output, then sends a single
231         b' ' through self.urwid_pipe_write_fd to trigger the input handler.
232         """
233         import os
234         for msg in plom_socket_io.recv(self.socket):
235             while len(self.server_output) > 0:  # Wait until self.server_output
236                 pass                            # is emptied by input handler.
237             self.server_output += [msg]
238             os.write(self.urwid_pipe_write_fd, b' ')
239
240     def run(self):
241         """Run in parallel urwid_loop and recv_loop threads."""
242         self.recv_loop_thread.start()
243         self.urwid_loop.run()
244         self.recv_loop_thread.join()
245
246
247 if __name__ == '__main__':
248     game = Game()
249     s = socket.create_connection(('127.0.0.1', 5000))
250     p = PlomRogueClient(game, s)
251     p.run()
252     s.close()