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