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