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