home · contact · privacy
Minor client improvements.
[plomrogue2-experiments] / new2 / rogue_chat_curses.py
1 #!/usr/bin/env python3
2 import curses
3 import socket
4 import queue
5 import threading
6 from plomrogue.io_tcp import PlomSocket
7 from plomrogue.game import GameBase
8 from plomrogue.parser import Parser
9 from plomrogue.mapping import YX
10 from plomrogue.things import ThingBase
11 from plomrogue.misc import quote
12
13 # TODO: fix screen refreshes on intermediary map results
14
15 def cmd_TURN(game, n):
16     game.turn = n
17     game.things = []
18     game.portals = {}
19 cmd_TURN.argtypes = 'int:nonneg'
20
21 def cmd_LOGIN_OK(game):
22     game.tui.switch_mode('post_login_wait')
23     game.tui.socket.send('GET_GAMESTATE')
24     game.tui.log_msg('@ welcome')
25 cmd_LOGIN_OK.argtypes = ''
26
27 def cmd_CHAT(game, msg):
28     game.tui.log_msg('# ' + msg)
29     game.tui.do_refresh = True
30 cmd_CHAT.argtypes = 'string'
31
32 def cmd_PLAYER_ID(game, player_id):
33     game.player_id = player_id
34 cmd_PLAYER_ID.argtypes = 'int:nonneg'
35
36 def cmd_THING_POS(game, thing_id, position):
37     t = game.get_thing(thing_id, True)
38     t.position = position
39 cmd_THING_POS.argtypes = 'int:nonneg yx_tuple:nonneg'
40
41 def cmd_THING_NAME(game, thing_id, name):
42     t = game.get_thing(thing_id, True)
43     t.name = name
44 cmd_THING_NAME.argtypes = 'int:nonneg string'
45
46 def cmd_MAP(game, size, content):
47     game.map_size = size
48     game.map_content = content
49 cmd_MAP.argtypes = 'yx_tuple:pos string'
50
51 def cmd_GAME_STATE_COMPLETE(game):
52     game.info_db = {}
53     if game.tui.mode.name == 'post_login_wait':
54         game.tui.switch_mode('play')
55     if game.tui.mode.shows_info:
56         game.tui.query_info()
57     game.tui.do_refresh = True
58 cmd_GAME_STATE_COMPLETE.argtypes = ''
59
60 def cmd_PORTAL(game, position, msg):
61     game.portals[position] = msg
62 cmd_PORTAL.argtypes = 'yx_tuple:nonneg string'
63
64 def cmd_PLAY_ERROR(game, msg):
65     game.tui.log_msg('imagine the screen flicker (TODO)')
66     game.tui.do_refresh = True
67 cmd_PLAY_ERROR.argtypes = 'string'
68
69 def cmd_ARGUMENT_ERROR(game, msg):
70     game.tui.log_msg('? syntax error: ' + msg)
71     game.tui.do_refresh = True
72 cmd_ARGUMENT_ERROR.argtypes = 'string'
73
74 def cmd_ANNOTATION(game, position, msg):
75     game.info_db[position] = msg
76     if game.tui.mode.shows_info:
77         game.tui.do_refresh = True
78 cmd_ANNOTATION.argtypes = 'yx_tuple:nonneg string'
79
80 def recv_loop(plom_socket, q):
81     for msg in plom_socket.recv():
82         q.put(msg)
83
84 class Game(GameBase):
85     commands = {'LOGIN_OK': cmd_LOGIN_OK,
86                 'CHAT': cmd_CHAT,
87                 'PLAYER_ID': cmd_PLAYER_ID,
88                 'TURN': cmd_TURN,
89                 'THING_POS': cmd_THING_POS,
90                 'THING_NAME': cmd_THING_NAME,
91                 'MAP': cmd_MAP,
92                 'PORTAL': cmd_PORTAL,
93                 'ANNOTATION': cmd_ANNOTATION,
94                 'GAME_STATE_COMPLETE': cmd_GAME_STATE_COMPLETE,
95                 'ARGUMENT_ERROR': cmd_ARGUMENT_ERROR,
96                 'PLAY_ERROR': cmd_PLAY_ERROR}
97     thing_type = ThingBase
98
99     def __init__(self, *args, **kwargs):
100         super().__init__(*args, **kwargs)
101         self.map_size = YX(0, 0)
102         self.map_content = ''
103         self.player_id = -1
104         self.info_db = {}
105         self.portals = {}
106
107     def get_command(self, command_name):
108         from functools import partial
109         f = partial(self.commands[command_name], self)
110         f.argtypes = self.commands[command_name].argtypes
111         return f
112
113 class TUI:
114
115     def __init__(self, socket, q, game):
116         self.game = game
117         self.game.tui = self
118         self.parser = Parser(self.game)
119         self.queue = q
120         self.socket = socket
121         self.log = []
122         self.do_refresh = True
123         curses.wrapper(self.loop)
124
125     def log_msg(self, msg):
126         self.log += [msg]
127         if len(self.log) > 100:
128             self.log = self.log[-100:]
129
130     def query_info(self):
131         self.socket.send('GET_ANNOTATION ' + str(self.explorer))
132
133     def switch_mode(self, mode_name, keep_position = False):
134         self.mode = getattr(self, 'mode_' + mode_name)
135         if self.mode.shows_info and not keep_position:
136             player = self.game.get_thing(self.game.player_id, False)
137             self.explorer = YX(player.position.y, player.position.x)
138         if self.mode.name == 'annotate' and self.explorer in self.game.info_db:
139             info = self.game.info_db[self.explorer]
140             if info != '(none)':
141                 self.input_ = info
142         elif self.mode.name == 'portal' and self.explorer in self.game.portals:
143             self.input_ = self.game.portals[self.explorer]
144
145     def loop(self, stdscr):
146
147         class Mode:
148
149             def __init__(self, name, has_input_prompt=False, shows_info=False,
150                          is_intro = False):
151                 self.name = name
152                 self.has_input_prompt = has_input_prompt
153                 self.shows_info = shows_info
154                 self.is_intro = is_intro
155
156         def handle_input(msg):
157             command, args = self.parser.parse(msg)
158             command(*args)
159
160         def msg_into_lines_of_width(msg, width):
161             chunk = ''
162             lines = []
163             x = 0
164             for i in range(len(msg)):
165                 x += 1
166                 if x >= width or msg[i] == "\n":
167                     lines += [chunk]
168                     chunk = ''
169                     x = 0
170                 if msg[i] != "\n":
171                     chunk += msg[i]
172             lines += [chunk]
173             return lines
174
175         def reset_screen_size():
176             self.size = YX(*stdscr.getmaxyx())
177             self.size = self.size - YX(0, 1) # ugly TODO ncurses bug workaround, FIXME
178             self.size = self.size - YX(self.size.y % 2, 0)
179             self.size = self.size - YX(0, self.size.x % 4)
180             self.window_width = int(self.size.x / 2)
181
182         def recalc_input_lines():
183             if not self.mode.has_input_prompt:
184                 self.input_lines = []
185             else:
186                 self.input_lines = msg_into_lines_of_width(input_prompt + self.input_,
187                                                            self.window_width)
188
189         def move_explorer(direction):
190             # TODO movement constraints
191             if direction == 'up':
192                 self.explorer += YX(-1, 0)
193             elif direction == 'left':
194                 self.explorer += YX(0, -1)
195             elif direction == 'down':
196                 self.explorer += YX(1, 0)
197             elif direction == 'right':
198                 self.explorer += YX(0, 1)
199             self.query_info()
200
201         def draw_history():
202             lines = []
203             for line in self.log:
204                 lines += msg_into_lines_of_width(line, self.window_width)
205             lines.reverse()
206             height_header = 2
207             max_y = self.size.y - len(self.input_lines)
208             for i in range(len(lines)):
209                 if (i >= max_y - height_header):
210                     break
211                 stdscr.addstr(max_y - i - 1, self.window_width, lines[i])
212
213         def draw_info():
214             if self.explorer in self.game.portals:
215                 info = 'PORTAL: ' + self.game.portals[self.explorer] + '\n'
216             else:
217                 info = 'PORTAL: (none)\n'
218             if self.explorer in self.game.info_db:
219                 info += 'ANNOTATION: ' + self.game.info_db[self.explorer]
220             else:
221                 info += 'ANNOTATION: waiting …'
222             lines = msg_into_lines_of_width(info, self.window_width)
223             height_header = 2
224             for i in range(len(lines)):
225                 y = height_header + i
226                 if y >= self.size.y - len(self.input_lines):
227                     break
228                 stdscr.addstr(y, self.window_width, lines[i])
229
230         def draw_input():
231             y = self.size.y - len(self.input_lines)
232             for i in range(len(self.input_lines)):
233                 stdscr.addstr(y, self.window_width, self.input_lines[i])
234                 y += 1
235
236         def draw_turn():
237             stdscr.addstr(0, self.window_width, 'TURN: ' + str(self.game.turn))
238
239         def draw_mode():
240             stdscr.addstr(1, self.window_width, 'MODE: ' + self.mode.name)
241
242         def draw_map():
243             map_lines_split = []
244             for y in range(self.game.map_size.y):
245                 start = self.game.map_size.x * y
246                 end = start + self.game.map_size.x
247                 map_lines_split += [list(self.game.map_content[start:end])]
248             for t in self.game.things:
249                 map_lines_split[t.position.y][t.position.x] = '@'
250             if self.mode.shows_info:
251                 map_lines_split[self.explorer.y][self.explorer.x] = '?'
252             map_lines = []
253             for line in map_lines_split:
254                 map_lines += [''.join(line)]
255             map_center = YX(int(self.game.map_size.y / 2),
256                             int(self.game.map_size.x / 2))
257             window_center = YX(int(self.size.y / 2),
258                                int(self.window_width / 2))
259             center = map_center
260             if self.mode.shows_info:
261                 center = self.explorer
262             else:
263                 player = self.game.get_thing(self.game.player_id, False)
264                 if player:
265                     center = player.position
266             offset = center - window_center
267             term_y = max(0, -offset.y)
268             term_x = max(0, -offset.x)
269             map_y = max(0, offset.y)
270             map_x = max(0, offset.x)
271             while (term_y < self.size.y and map_y < self.game.map_size.y):
272                 to_draw = map_lines[map_y][map_x:self.window_width + offset.x]
273                 stdscr.addstr(term_y, term_x, to_draw)
274                 term_y += 1
275                 map_y += 1
276
277         def draw_screen():
278             stdscr.clear()
279             recalc_input_lines()
280             if self.mode.has_input_prompt:
281                 draw_input()
282             if self.mode.shows_info:
283                 draw_info()
284             else:
285                 draw_history()
286             draw_mode()
287             if not self.mode.is_intro:
288                 draw_turn()
289                 draw_map()
290
291         self.mode_play = Mode('play')
292         self.mode_study = Mode('study', shows_info=True)
293         self.mode_edit = Mode('edit')
294         self.mode_annotate = Mode('annotate', has_input_prompt=True, shows_info=True)
295         self.mode_portal = Mode('portal', has_input_prompt=True, shows_info=True)
296         self.mode_chat = Mode('chat', has_input_prompt=True)
297         self.mode_login = Mode('login', has_input_prompt=True, is_intro=True)
298         self.mode_post_login_wait = Mode('post_login_wait', is_intro=True)
299         curses.curs_set(False)  # hide cursor
300         stdscr.timeout(10)
301         reset_screen_size()
302         self.mode = self.mode_login
303         self.explorer = YX(0, 0)
304         self.input_ = ''
305         input_prompt = '> '
306         while True:
307             if self.do_refresh:
308                 draw_screen()
309                 self.do_refresh = False
310             while True:
311                 try:
312                     msg = self.queue.get(block=False)
313                     handle_input(msg)
314                 except queue.Empty:
315                     break
316             try:
317                 key = stdscr.getkey()
318                 self.do_refresh = True
319             except curses.error:
320                 continue
321             if key == 'KEY_RESIZE':
322                 reset_screen_size()
323             elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
324                 self.input_ = self.input_[:-1]
325             elif self.mode.has_input_prompt and key != '\n':  # Return key
326                 self.input_ += key
327                 # TODO find out why - 1 is necessary here
328                 max_length = self.window_width * self.size.y - len(input_prompt) - 1
329                 if len(self.input_) > max_length:
330                     self.input_ = self.input_[:max_length]
331             elif self.mode == self.mode_login and key == '\n':
332                 self.socket.send('LOGIN ' + quote(self.input_))
333                 self.input_ = ""
334             elif self.mode == self.mode_chat and key == '\n':
335                 # TODO: query, nick, help, reconnect, unknown command
336                 if self.input_[0] == ':':
337                     if self.input_ in {':p', ':play'}:
338                         self.switch_mode('play')
339                     elif self.input_ in {':?', ':study'}:
340                         self.switch_mode('study')
341                     elif self.input_.startswith(':nick'):
342                         tokens = self.input_.split(maxsplit=1)
343                         if len(tokens) == 2:
344                             self.socket.send('LOGIN ' + quote(tokens[1]))
345                         else:
346                             self.log_msg('? need login name')
347                     elif self.input_.startswith(':msg'):
348                         tokens = self.input_.split(maxsplit=2)
349                         if len(tokens) == 3:
350                             self.socket.send('QUERY %s %s' % (quote(tokens[1]),
351                                                               quote(tokens[2])))
352                         else:
353                             self.log_msg('? need message target and message')
354                     else:
355                         self.log_msg('? unknown command')
356                 else:
357                     self.socket.send('ALL ' + quote(self.input_))
358                 self.input_ = ""
359             elif self.mode == self.mode_annotate and key == '\n':
360                 if (self.input_ == ''):
361                     self.input_ = ' '
362                 self.socket.send('ANNOTATE %s %s' % (self.explorer, quote(self.input_)))
363                 self.input_ = ""
364                 self.switch_mode('study', keep_position=True)
365             elif self.mode == self.mode_portal and key == '\n':
366                 if (self.input_ == ''):
367                     self.input_ = ' '
368                 self.socket.send('PORTAL %s %s' % (self.explorer, quote(self.input_)))
369                 self.input_ = ""
370                 self.switch_mode('study', keep_position=True)
371             elif self.mode == self.mode_study:
372                 if key == 'c':
373                     self.switch_mode('chat')
374                 elif key == 'p':
375                     self.switch_mode('play')
376                 elif key == 'A':
377                     self.switch_mode('annotate', keep_position=True)
378                 elif key == 'P':
379                     self.switch_mode('portal', keep_position=True)
380                 elif key == 'w':
381                     move_explorer('up')
382                 elif key == 'a':
383                     move_explorer('left')
384                 elif key == 's':
385                     move_explorer('down')
386                 elif key == 'd':
387                     move_explorer('right')
388             elif self.mode == self.mode_play:
389                 if key == 'c':
390                     self.switch_mode('chat')
391                 elif key == '?':
392                     self.switch_mode('study')
393                 if key == 'e':
394                     self.switch_mode('edit')
395                 elif key == 'f':
396                     self.socket.send('TASK:FLATTEN_SURROUNDINGS')
397                 elif key == 'w':
398                     self.socket.send('TASK:MOVE UP')
399                 elif key == 'a':
400                     self.socket.send('TASK:MOVE LEFT')
401                 elif key == 's':
402                     self.socket.send('TASK:MOVE DOWN')
403                 elif key == 'd':
404                     self.socket.send('TASK:MOVE RIGHT')
405             elif self.mode == self.mode_edit:
406                 self.socket.send('TASK:WRITE ' + key)
407                 self.switch_mode('play')
408
409 s = socket.create_connection(('127.0.0.1', 5000))
410 plom_socket = PlomSocket(s)
411 q = queue.Queue()
412 t = threading.Thread(target=recv_loop, args=(plom_socket, q))
413 t.start()
414 TUI(plom_socket, q, Game())