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