home · contact · privacy
f91a0ddc48f80f5992c77cd5beaeb46c1cd3959a
[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         edit_map = urwid.AttrMap(edit_widget, 'foo')
107         turn_map = urwid.AttrMap(self.turn_widget, 'bar')
108         log_map = urwid.AttrMap(self.log_widget, 'baz')
109         widget_pile = urwid.Pile([edit_map,
110                                   urwid.Divider(),
111                                   turn_map,
112                                   urwid.Divider(),
113                                   log_map])
114         widget_columns = urwid.Columns([(20, widget_pile), self.map_widget],
115                                        dividechars=1)
116         self.top = urwid.Filler(widget_columns, valign='top')
117         self.palette = [('foo', 'white', 'dark red'),
118                         ('bar', 'white', 'dark blue'),
119                         ('baz', 'white', 'dark green')]
120
121     def draw_map(self):
122         """Draw map view from .game.map_.terrain, .game.things."""
123         terrain_as_list = list(self.game.world.map_.terrain[:])
124         for t in self.game.world.things:
125             pos_i = self.game.world.map_.get_position_index(t.position)
126             terrain_as_list[pos_i] = self.game.symbol_for_type(t.type_)
127         text = self.game.world.map_.list_terrain_to_lines(terrain_as_list)
128         new_map_text = []
129         for char in text:
130             if char == '.':
131                 new_map_text += [('foo', char)]
132             elif char in {'x', 'X', '#'}:
133                 new_map_text += [('bar', char)]
134             elif char in {'@', 'm'}:
135                 new_map_text += [('baz', char)]
136             else:
137                 new_map_text += [char]
138         return new_map_text
139
140     def update(self):
141         """Redraw all non-edit widgets."""
142         self.turn_widget.set_text('TURN: ' + str(self.game.world.turn))
143         self.log_widget.set_text(self.game.log_text)
144         self.map_widget.set_text(self.draw_map())
145
146     class EditToSocketWidget(urwid.Edit):
147         """Extends urwid.Edit with socket to send input on 'enter' to."""
148
149         def __init__(self, socket, *args, **kwargs):
150             super().__init__(*args, **kwargs)
151             self.socket = socket
152
153         def keypress(self, size, key):
154             """Extend super(): on Enter, send .edit_text, and empty it."""
155             if key != 'enter':
156                 return super().keypress(size, key)
157             plom_socket_io.send(self.socket, self.edit_text)
158             self.edit_text = ''
159
160
161 class PlomRogueClient:
162
163     def __init__(self, game, socket):
164         """Build client urwid interface around socket communication.
165
166         Sets up all widgets for writing to the socket and representing data
167         from it. Sending via a WidgetManager.EditToSocket widget is
168         straightforward; polling the socket for input from the server in
169         parallel to the urwid main loop not so much:
170
171         The urwid developers warn against sharing urwid resources among
172         threads, so having a socket polling thread for writing to an urwid
173         widget while other widgets are handled in other threads would be
174         dangerous. Urwid developers recommend using urwid's watch_pipe
175         mechanism instead: using a pipe from non-urwid threads into a single
176         urwid thread. We use self.recv_loop_thread to poll the socket, therein
177         write socket.recv output to an object that is then linked to by
178         self.server_output (which is known to the urwid thread), then use the
179         pipe to urwid to trigger it pulling new data from self.server_output to
180         handle via self.handle_input. (We *could* pipe socket.recv output
181         directly, but then we get complicated buffering situations here as well
182         as in the urwid code that receives the pipe output. It's easier to just
183         tell the urwid code where it finds full new server messages to handle.)
184         """
185         self.game = game
186         self.parser = Parser(self.game)
187         self.socket = socket
188         self.widget_manager = WidgetManager(self.socket, self.game)
189         self.server_output = []
190         self.urwid_loop = urwid.MainLoop(self.widget_manager.top,
191                                          self.widget_manager.palette)
192         self.urwid_pipe_write_fd = self.urwid_loop.watch_pipe(self.
193                                                               handle_input)
194         self.recv_loop_thread = threading.Thread(target=self.recv_loop)
195
196     def handle_input(self, trigger):
197         """On input from recv_loop thread, parse and enact commands.
198
199         Serves as a receiver to urwid's watch_pipe mechanism, with trigger the
200         data that a pipe defined by watch_pipe delivers. To avoid buffering
201         trouble, we don't care for that data beyond the fact that its receival
202         triggers this function: The sender is to write the data it wants to
203         deliver into the container referenced by self.server_output, and just
204         pipe the trigger to inform us about this.
205
206         If the message delivered is 'BYE', quits Urwid. Otherwise tries to
207         parse it as a command, and enact it. In all cases but the 'BYE', calls
208         self.widget_manager.update.
209         """
210         msg = self.server_output[0]
211         if msg == 'BYE':
212             raise urwid.ExitMainLoop()
213         try:
214             command = self.parser.parse(msg)
215             if command is None:
216                 self.game.log('UNHANDLED INPUT: ' + msg)
217             else:
218                 command()
219         except ArgError as e:
220             self.game.log('ARGUMENT ERROR: ' + msg + '\n' + str(e))
221         self.widget_manager.update()
222         del self.server_output[0]
223
224     def recv_loop(self):
225         """Loop to receive messages from socket, deliver them to urwid thread.
226
227         Waits for self.server_output to become empty (this signals that the
228         input handler is finished / ready to receive new input), then writes
229         finished message from socket to self.server_output, then sends a single
230         b' ' through self.urwid_pipe_write_fd to trigger the input handler.
231         """
232         import os
233         for msg in plom_socket_io.recv(self.socket):
234             while len(self.server_output) > 0:  # Wait until self.server_output
235                 pass                            # is emptied by input handler.
236             self.server_output += [msg]
237             os.write(self.urwid_pipe_write_fd, b' ')
238
239     def run(self):
240         """Run in parallel urwid_loop and recv_loop threads."""
241         self.recv_loop_thread.start()
242         self.urwid_loop.run()
243         self.recv_loop_thread.join()
244
245
246 if __name__ == '__main__':
247     game = Game()
248     s = socket.create_connection(('127.0.0.1', 5000))
249     p = PlomRogueClient(game, s)
250     p.run()
251     s.close()