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