home · contact · privacy
Add FOV optimization ideas. Toggle Hex indentation.
[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 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 = urwid.Text('', wrap='clip')
104         self.turn_widget = urwid.Text('')
105         self.log_widget = urwid.Text('')
106         map_box = urwid.Padding(self.map_widget, width=50)
107         widget_pile = urwid.Pile([edit_widget, map_box, self.turn_widget,
108                                   self.log_widget])
109         self.top = urwid.Filler(widget_pile, valign='top')
110
111     def draw_map(self):
112         """Draw map view from .game.map_.terrain, .game.things."""
113         terrain_as_list = list(self.game.world.map_.terrain[:])
114         for t in self.game.world.things:
115             pos_i = self.game.world.map_.get_position_index(t.position)
116             terrain_as_list[pos_i] = self.game.symbol_for_type(t.type_)
117         return self.game.world.map_.list_terrain_to_lines(terrain_as_list)
118
119     def update(self):
120         """Redraw all non-edit widgets."""
121         self.turn_widget.set_text('TURN: ' + str(self.game.world.turn))
122         self.log_widget.set_text(self.game.log_text)
123         self.map_widget.set_text(self.draw_map())
124
125     class EditToSocketWidget(urwid.Edit):
126         """Extends urwid.Edit with socket to send input on 'enter' to."""
127
128         def __init__(self, socket, *args, **kwargs):
129             super().__init__(*args, **kwargs)
130             self.socket = socket
131
132         def keypress(self, size, key):
133             """Extend super(): on Enter, send .edit_text, and empty it."""
134             if key != 'enter':
135                 return super().keypress(size, key)
136             plom_socket_io.send(self.socket, self.edit_text)
137             self.edit_text = ''
138
139
140 class PlomRogueClient:
141
142     def __init__(self, game, socket):
143         """Build client urwid interface around socket communication.
144
145         Sets up all widgets for writing to the socket and representing data
146         from it. Sending via a WidgetManager.EditToSocket widget is
147         straightforward; polling the socket for input from the server in
148         parallel to the urwid main loop not so much:
149
150         The urwid developers warn against sharing urwid resources among
151         threads, so having a socket polling thread for writing to an urwid
152         widget while other widgets are handled in other threads would be
153         dangerous. Urwid developers recommend using urwid's watch_pipe
154         mechanism instead: using a pipe from non-urwid threads into a single
155         urwid thread. We use self.recv_loop_thread to poll the socket, therein
156         write socket.recv output to an object that is then linked to by
157         self.server_output (which is known to the urwid thread), then use the
158         pipe to urwid to trigger it pulling new data from self.server_output to
159         handle via self.handle_input. (We *could* pipe socket.recv output
160         directly, but then we get complicated buffering situations here as well
161         as in the urwid code that receives the pipe output. It's easier to just
162         tell the urwid code where it finds full new server messages to handle.)
163         """
164         self.game = game
165         self.parser = Parser(self.game)
166         self.socket = socket
167         self.widget_manager = WidgetManager(self.socket, self.game)
168         self.server_output = []
169         self.urwid_loop = urwid.MainLoop(self.widget_manager.top)
170         self.urwid_pipe_write_fd = self.urwid_loop.watch_pipe(self.
171                                                               handle_input)
172         self.recv_loop_thread = threading.Thread(target=self.recv_loop)
173
174     def handle_input(self, trigger):
175         """On input from recv_loop thread, parse and enact commands.
176
177         Serves as a receiver to urwid's watch_pipe mechanism, with trigger the
178         data that a pipe defined by watch_pipe delivers. To avoid buffering
179         trouble, we don't care for that data beyond the fact that its receival
180         triggers this function: The sender is to write the data it wants to
181         deliver into the container referenced by self.server_output, and just
182         pipe the trigger to inform us about this.
183
184         If the message delivered is 'BYE', quits Urwid. Otherwise tries to
185         parse it as a command, and enact it. In all cases but the 'BYE', calls
186         self.widget_manager.update.
187         """
188         msg = self.server_output[0]
189         if msg == 'BYE':
190             raise urwid.ExitMainLoop()
191         try:
192             command = self.parser.parse(msg)
193             if command is None:
194                 self.game.log('UNHANDLED INPUT: ' + msg)
195             else:
196                 command()
197         except ArgError as e:
198             self.game.log('ARGUMENT ERROR: ' + msg + '\n' + str(e))
199         self.widget_manager.update()
200         del self.server_output[0]
201
202     def recv_loop(self):
203         """Loop to receive messages from socket, deliver them to urwid thread.
204
205         Waits for self.server_output to become empty (this signals that the
206         input handler is finished / ready to receive new input), then writes
207         finished message from socket to self.server_output, then sends a single
208         b' ' through self.urwid_pipe_write_fd to trigger the input handler.
209         """
210         import os
211         for msg in plom_socket_io.recv(self.socket):
212             while len(self.server_output) > 0:  # Wait until self.server_output
213                 pass                            # is emptied by input handler.
214             self.server_output += [msg]
215             os.write(self.urwid_pipe_write_fd, b' ')
216
217     def run(self):
218         """Run in parallel urwid_loop and recv_loop threads."""
219         self.recv_loop_thread.start()
220         self.urwid_loop.run()
221         self.recv_loop_thread.join()
222
223
224 if __name__ == '__main__':
225     game = Game()
226     s = socket.create_connection(('127.0.0.1', 5000))
227     p = PlomRogueClient(game, s)
228     p.run()
229     s.close()