home · contact · privacy
Make keybindings configurable.
[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, MapGeometrySquare, MapGeometryHex
10 from plomrogue.things import ThingBase
11 from plomrogue.misc import quote
12
13 def cmd_TURN(game, n):
14     game.turn = n
15     game.things = []
16     game.portals = {}
17     game.turn_complete = False
18 cmd_TURN.argtypes = 'int:nonneg'
19
20 def cmd_LOGIN_OK(game):
21     game.tui.switch_mode('post_login_wait')
22     game.tui.send('GET_GAMESTATE')
23     game.tui.log_msg('@ welcome')
24 cmd_LOGIN_OK.argtypes = ''
25
26 def cmd_CHAT(game, msg):
27     game.tui.log_msg('# ' + msg)
28     game.tui.do_refresh = True
29 cmd_CHAT.argtypes = 'string'
30
31 def cmd_PLAYER_ID(game, player_id):
32     game.player_id = player_id
33 cmd_PLAYER_ID.argtypes = 'int:nonneg'
34
35 def cmd_THING_POS(game, thing_id, position):
36     t = game.get_thing(thing_id, True)
37     t.position = position
38 cmd_THING_POS.argtypes = 'int:nonneg yx_tuple:nonneg'
39
40 def cmd_THING_NAME(game, thing_id, name):
41     t = game.get_thing(thing_id, True)
42     t.name = name
43 cmd_THING_NAME.argtypes = 'int:nonneg string'
44
45 def cmd_MAP(game, geometry, size, content):
46     map_geometry_class = globals()['MapGeometry' + geometry]
47     game.map_geometry = map_geometry_class(size)
48     game.map_content = content
49     if type(game.map_geometry) == MapGeometrySquare:
50         game.tui.movement_keys = {
51             game.tui.keys['square_move_up']: 'UP',
52             game.tui.keys['square_move_left']: 'LEFT',
53             game.tui.keys['square_move_down']: 'DOWN',
54             game.tui.keys['square_move_right']: 'RIGHT',
55         }
56     elif type(game.map_geometry) == MapGeometryHex:
57         game.tui.movement_keys = {
58             game.tui.keys['hex_move_upleft']: 'UPLEFT',
59             game.tui.keys['hex_move_upright']: 'UPRIGHT',
60             game.tui.keys['hex_move_right']: 'RIGHT',
61             game.tui.keys['hex_move_downright']: 'DOWNRIGHT',
62             game.tui.keys['hex_move_downleft']: 'DOWNLEFT',
63             game.tui.keys['hex_move_left']: 'LEFT',
64         }
65 cmd_MAP.argtypes = 'string:map_geometry yx_tuple:pos string'
66
67 def cmd_GAME_STATE_COMPLETE(game):
68     game.info_db = {}
69     if game.tui.mode.name == 'post_login_wait':
70         game.tui.switch_mode('play')
71         game.tui.help()
72     if game.tui.mode.shows_info:
73         game.tui.query_info()
74     player = game.get_thing(game.player_id, False)
75     if player.position in game.portals:
76         #host, port = game.portals[player.position].split(':')
77         game.tui.teleport_target_host = game.portals[player.position]
78         game.tui.teleport_target_port = 5000
79         game.tui.switch_mode('teleport')
80     game.turn_complete = True
81     game.tui.do_refresh = True
82 cmd_GAME_STATE_COMPLETE.argtypes = ''
83
84 def cmd_PORTAL(game, position, msg):
85     game.portals[position] = msg
86 cmd_PORTAL.argtypes = 'yx_tuple:nonneg string'
87
88 def cmd_PLAY_ERROR(game, msg):
89     game.tui.flash()
90     game.tui.do_refresh = True
91 cmd_PLAY_ERROR.argtypes = 'string'
92
93 def cmd_GAME_ERROR(game, msg):
94     game.tui.log_msg('? game error: ' + msg)
95     game.tui.do_refresh = True
96 cmd_GAME_ERROR.argtypes = 'string'
97
98 def cmd_ARGUMENT_ERROR(game, msg):
99     game.tui.log_msg('? syntax error: ' + msg)
100     game.tui.do_refresh = True
101 cmd_ARGUMENT_ERROR.argtypes = 'string'
102
103 def cmd_ANNOTATION(game, position, msg):
104     game.info_db[position] = msg
105     if game.tui.mode.shows_info:
106         game.tui.do_refresh = True
107 cmd_ANNOTATION.argtypes = 'yx_tuple:nonneg string'
108
109 class Game(GameBase):
110     commands = {'LOGIN_OK': cmd_LOGIN_OK,
111                 'CHAT': cmd_CHAT,
112                 'PLAYER_ID': cmd_PLAYER_ID,
113                 'TURN': cmd_TURN,
114                 'THING_POS': cmd_THING_POS,
115                 'THING_NAME': cmd_THING_NAME,
116                 'MAP': cmd_MAP,
117                 'PORTAL': cmd_PORTAL,
118                 'ANNOTATION': cmd_ANNOTATION,
119                 'GAME_STATE_COMPLETE': cmd_GAME_STATE_COMPLETE,
120                 'ARGUMENT_ERROR': cmd_ARGUMENT_ERROR,
121                 'GAME_ERROR': cmd_GAME_ERROR,
122                 'PLAY_ERROR': cmd_PLAY_ERROR}
123     thing_type = ThingBase
124     turn_complete = False
125
126     def __init__(self, *args, **kwargs):
127         super().__init__(*args, **kwargs)
128         self.map_content = ''
129         self.player_id = -1
130         self.info_db = {}
131         self.portals = {}
132
133     def get_string_options(self, string_option_type):
134         if string_option_type == 'map_geometry':
135             return ['Hex', 'Square']
136         return None
137
138     def get_command(self, command_name):
139         from functools import partial
140         f = partial(self.commands[command_name], self)
141         f.argtypes = self.commands[command_name].argtypes
142         return f
143
144 class TUI:
145
146     class Mode:
147
148         def __init__(self, name, has_input_prompt=False, shows_info=False,
149                      is_intro = False):
150             self.name = name
151             self.has_input_prompt = has_input_prompt
152             self.shows_info = shows_info
153             self.is_intro = is_intro
154
155     def __init__(self, host, port):
156         import os
157         import json
158         self.host = host
159         self.port = port
160         self.mode_play = self.Mode('play')
161         self.mode_study = self.Mode('study', shows_info=True)
162         self.mode_edit = self.Mode('edit')
163         self.mode_annotate = self.Mode('annotate', has_input_prompt=True, shows_info=True)
164         self.mode_portal = self.Mode('portal', has_input_prompt=True, shows_info=True)
165         self.mode_chat = self.Mode('chat', has_input_prompt=True)
166         self.mode_waiting_for_server = self.Mode('waiting_for_server', is_intro=True)
167         self.mode_login = self.Mode('login', has_input_prompt=True, is_intro=True)
168         self.mode_post_login_wait = self.Mode('post_login_wait', is_intro=True)
169         self.mode_teleport = self.Mode('teleport', has_input_prompt=True)
170         self.game = Game()
171         self.game.tui = self
172         self.parser = Parser(self.game)
173         self.log = []
174         self.do_refresh = True
175         self.queue = queue.Queue()
176         self.login_name = None
177         self.switch_mode('waiting_for_server')
178         self.keys = {
179             'switch_to_chat': 'C',
180             'switch_to_play': 'P',
181             'switch_to_annotate': 'E',
182             'switch_to_portal': 'p',
183             'switch_to_study': '?',
184             'switch_to_edit': 'E',
185             'flatten': 'f',
186             'hex_move_upleft': 'w',
187             'hex_move_upright': 'e',
188             'hex_move_right': 'd',
189             'hex_move_downright': 'c',
190             'hex_move_downleft': 'x',
191             'hex_move_left': 's',
192             'square_move_up': 'w',
193             'square_move_left': 'a',
194             'square_move_down': 's',
195             'square_move_right': 'd',
196         }
197         if os.path.isfile('config.json'):
198             with open('config.json', 'r') as f:
199                 keys_conf = json.loads(f.read())
200             for k in keys_conf:
201                 self.keys[k] = keys_conf[k]
202         curses.wrapper(self.loop)
203
204     def flash(self):
205         curses.flash()
206
207     def send(self, msg):
208         try:
209             self.socket.send(msg)
210         except BrokenPipeError:
211             self.log_msg('@ server disconnected :(')
212             self.do_refresh = True
213
214     def log_msg(self, msg):
215         self.log += [msg]
216         if len(self.log) > 100:
217             self.log = self.log[-100:]
218
219     def query_info(self):
220         self.send('GET_ANNOTATION ' + str(self.explorer))
221
222     def switch_mode(self, mode_name, keep_position = False):
223         self.mode = getattr(self, 'mode_' + mode_name)
224         if self.mode.shows_info and not keep_position:
225             player = self.game.get_thing(self.game.player_id, False)
226             self.explorer = YX(player.position.y, player.position.x)
227         if self.mode.name == 'waiting_for_server':
228             self.log_msg('@ waiting for server …')
229         elif self.mode.name == 'login':
230             if self.login_name:
231                 self.send('LOGIN ' + quote(self.login_name))
232             else:
233                 self.log_msg('@ enter username')
234         elif self.mode.name == 'teleport':
235             self.log_msg("@ May teleport to %s:%s" % (self.teleport_target_host,
236                                                       self.teleport_target_port));
237             self.log_msg("@ Enter 'YES!' to enthusiastically affirm.");
238         elif self.mode.name == 'annotate' and self.explorer in self.game.info_db:
239             info = self.game.info_db[self.explorer]
240             if info != '(none)':
241                 self.input_ = info
242         elif self.mode.name == 'portal' and self.explorer in self.game.portals:
243             self.input_ = self.game.portals[self.explorer]
244
245     def help(self):
246         self.log_msg("HELP:");
247         self.log_msg("chat mode commands:");
248         self.log_msg("  /nick NAME - re-name yourself to NAME");
249         self.log_msg("  /msg USER TEXT - send TEXT to USER");
250         self.log_msg("  /help - show this help");
251         self.log_msg("  /P or /play - switch to play mode");
252         self.log_msg("  /? or /study - switch to study mode");
253         self.log_msg("commands common to study and play mode:");
254         self.log_msg("  %s - move" % ','.join(self.movement_keys));
255         self.log_msg("  %s - switch to chat mode" % self.keys['switch_to_chat']);
256         self.log_msg("commands specific to play mode:");
257         self.log_msg("  %s - write following ASCII character" % self.keys['switch_to_edit']);
258         self.log_msg("  %s - flatten surroundings" % self.keys['flatten']);
259         self.log_msg("  %s - switch to study mode" % self.keys['switch_to_study']);
260         self.log_msg("commands specific to study mode:");
261         self.log_msg("  %s - annotate terrain" % self.keys['switch_to_annotate']);
262         self.log_msg("  %s - switch to play mode" % self.keys['switch_to_play']);
263
264     def loop(self, stdscr):
265
266         def safe_addstr(y, x, line):
267             if y < self.size.y - 1 or x + len(line) < self.size.x:
268                 stdscr.addstr(y, x, line)
269             else:  # workaround to <https://stackoverflow.com/q/7063128>
270                 cut_i = self.size.x - x - 1
271                 cut = line[:cut_i]
272                 last_char = line[cut_i]
273                 stdscr.addstr(y, self.size.x - 2, last_char)
274                 stdscr.insstr(y, self.size.x - 2, ' ')
275                 stdscr.addstr(y, x, cut)
276
277         def connect():
278             import time
279
280             def recv_loop():
281                 for msg in self.socket.recv():
282                     if msg == 'BYE':
283                         break
284                     self.queue.put(msg)
285
286             while True:
287                 try:
288                     s = socket.create_connection((self.host, self.port))
289                     self.socket = PlomSocket(s)
290                     self.socket_thread = threading.Thread(target=recv_loop)
291                     self.socket_thread.start()
292                     self.switch_mode('login')
293                     return
294                 except ConnectionRefusedError:
295                     self.log_msg('@ server connect failure, trying again …')
296                     draw_screen()
297                     stdscr.refresh()
298                     time.sleep(1)
299
300         def reconnect():
301             self.send('QUIT')
302             self.switch_mode('waiting_for_server')
303             connect()
304
305         def handle_input(msg):
306             command, args = self.parser.parse(msg)
307             command(*args)
308
309         def msg_into_lines_of_width(msg, width):
310             chunk = ''
311             lines = []
312             x = 0
313             for i in range(len(msg)):
314                 if x >= width or msg[i] == "\n":
315                     lines += [chunk]
316                     chunk = ''
317                     x = 0
318                 if msg[i] != "\n":
319                     chunk += msg[i]
320                 x += 1
321             lines += [chunk]
322             return lines
323
324         def reset_screen_size():
325             self.size = YX(*stdscr.getmaxyx())
326             self.size = self.size - YX(self.size.y % 4, 0)
327             self.size = self.size - YX(0, self.size.x % 4)
328             self.window_width = int(self.size.x / 2)
329
330         def recalc_input_lines():
331             if not self.mode.has_input_prompt:
332                 self.input_lines = []
333             else:
334                 self.input_lines = msg_into_lines_of_width(input_prompt + self.input_,
335                                                            self.window_width)
336
337         def move_explorer(direction):
338             target = self.game.map_geometry.move(self.explorer, direction)
339             if target:
340                 self.explorer = target
341                 self.query_info()
342             else:
343                 self.flash()
344
345         def draw_history():
346             lines = []
347             for line in self.log:
348                 lines += msg_into_lines_of_width(line, self.window_width)
349             lines.reverse()
350             height_header = 2
351             max_y = self.size.y - len(self.input_lines)
352             for i in range(len(lines)):
353                 if (i >= max_y - height_header):
354                     break
355                 safe_addstr(max_y - i - 1, self.window_width, lines[i])
356
357         def draw_info():
358             if not self.game.turn_complete:
359                 return
360             if self.explorer in self.game.portals:
361                 info = 'PORTAL: ' + self.game.portals[self.explorer] + '\n'
362             else:
363                 info = 'PORTAL: (none)\n'
364             if self.explorer in self.game.info_db:
365                 info += 'ANNOTATION: ' + self.game.info_db[self.explorer]
366             else:
367                 info += 'ANNOTATION: waiting …'
368             lines = msg_into_lines_of_width(info, self.window_width)
369             height_header = 2
370             for i in range(len(lines)):
371                 y = height_header + i
372                 if y >= self.size.y - len(self.input_lines):
373                     break
374                 safe_addstr(y, self.window_width, lines[i])
375
376         def draw_input():
377             y = self.size.y - len(self.input_lines)
378             for i in range(len(self.input_lines)):
379                 safe_addstr(y, self.window_width, self.input_lines[i])
380                 y += 1
381
382         def draw_turn():
383             if not self.game.turn_complete:
384                 return
385             safe_addstr(0, self.window_width, 'TURN: ' + str(self.game.turn))
386
387         def draw_mode():
388             safe_addstr(1, self.window_width, 'MODE: ' + self.mode.name)
389
390         def draw_map():
391             if not self.game.turn_complete:
392                 return
393             map_lines_split = []
394             for y in range(self.game.map_geometry.size.y):
395                 start = self.game.map_geometry.size.x * y
396                 end = start + self.game.map_geometry.size.x
397                 map_lines_split += [list(self.game.map_content[start:end])]
398             for t in self.game.things:
399                 map_lines_split[t.position.y][t.position.x] = '@'
400             if self.mode.shows_info:
401                 map_lines_split[self.explorer.y][self.explorer.x] = '?'
402             map_lines = []
403             if type(self.game.map_geometry) == MapGeometryHex:
404                 indent = 0
405                 for line in map_lines_split:
406                     map_lines += [indent*' ' + ' '.join(line)]
407                     indent = 0 if indent else 1
408             else:
409                 for line in map_lines_split:
410                     map_lines += [' '.join(line)]
411             window_center = YX(int(self.size.y / 2),
412                                int(self.window_width / 2))
413             player = self.game.get_thing(self.game.player_id, False)
414             center = player.position
415             if self.mode.shows_info:
416                 center = self.explorer
417             center = YX(center.y, center.x * 2)
418             offset = center - window_center
419             if type(self.game.map_geometry) == MapGeometryHex and offset.y % 2:
420                 offset += YX(0, 1)
421             term_y = max(0, -offset.y)
422             term_x = max(0, -offset.x)
423             map_y = max(0, offset.y)
424             map_x = max(0, offset.x)
425             while (term_y < self.size.y and map_y < self.game.map_geometry.size.y):
426                 to_draw = map_lines[map_y][map_x:self.window_width + offset.x]
427                 safe_addstr(term_y, term_x, to_draw)
428                 term_y += 1
429                 map_y += 1
430
431         def draw_screen():
432             stdscr.clear()
433             recalc_input_lines()
434             if self.mode.has_input_prompt:
435                 draw_input()
436             if self.mode.shows_info:
437                 draw_info()
438             else:
439                 draw_history()
440             draw_mode()
441             if not self.mode.is_intro:
442                 draw_turn()
443                 draw_map()
444
445         curses.curs_set(False)  # hide cursor
446         stdscr.timeout(10)
447         reset_screen_size()
448         self.explorer = YX(0, 0)
449         self.input_ = ''
450         input_prompt = '> '
451         connect()
452         while True:
453             if self.do_refresh:
454                 draw_screen()
455                 self.do_refresh = False
456             while True:
457                 try:
458                     msg = self.queue.get(block=False)
459                     handle_input(msg)
460                 except queue.Empty:
461                     break
462             try:
463                 key = stdscr.getkey()
464                 self.do_refresh = True
465             except curses.error:
466                 continue
467             if key == 'KEY_RESIZE':
468                 reset_screen_size()
469             elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
470                 self.input_ = self.input_[:-1]
471             elif self.mode.has_input_prompt and key != '\n':  # Return key
472                 self.input_ += key
473                 max_length = self.window_width * self.size.y - len(input_prompt) - 1
474                 if len(self.input_) > max_length:
475                     self.input_ = self.input_[:max_length]
476             elif self.mode == self.mode_login and key == '\n':
477                 self.login_name = self.input_
478                 self.send('LOGIN ' + quote(self.input_))
479                 self.input_ = ""
480             elif self.mode == self.mode_chat and key == '\n':
481                 if self.input_[0] == '/':
482                     if self.input_ in {'/P', '/play'}:
483                         self.switch_mode('play')
484                     elif self.input_ in {'/?', '/study'}:
485                         self.switch_mode('study')
486                     elif self.input_ == '/help':
487                         self.help()
488                     elif self.input_ == '/reconnect':
489                         reconnect()
490                     elif self.input_.startswith('/nick'):
491                         tokens = self.input_.split(maxsplit=1)
492                         if len(tokens) == 2:
493                             self.send('LOGIN ' + quote(tokens[1]))
494                         else:
495                             self.log_msg('? need login name')
496                     elif self.input_.startswith('/msg'):
497                         tokens = self.input_.split(maxsplit=2)
498                         if len(tokens) == 3:
499                             self.send('QUERY %s %s' % (quote(tokens[1]),
500                                                               quote(tokens[2])))
501                         else:
502                             self.log_msg('? need message target and message')
503                     else:
504                         self.log_msg('? unknown command')
505                 else:
506                     self.send('ALL ' + quote(self.input_))
507                 self.input_ = ""
508             elif self.mode == self.mode_annotate and key == '\n':
509                 if self.input_ == '':
510                     self.input_ = ' '
511                 self.send('ANNOTATE %s %s' % (self.explorer, quote(self.input_)))
512                 self.input_ = ""
513                 self.switch_mode('study', keep_position=True)
514             elif self.mode == self.mode_portal and key == '\n':
515                 if self.input_ == '':
516                     self.input_ = ' '
517                 self.send('PORTAL %s %s' % (self.explorer, quote(self.input_)))
518                 self.input_ = ""
519                 self.switch_mode('study', keep_position=True)
520             elif self.mode == self.mode_teleport and key == '\n':
521                 if self.input_ == 'YES!':
522                     self.host = self.teleport_target_host
523                     self.port = self.teleport_target_port
524                     reconnect()
525                 else:
526                     self.log_msg('@ teleport aborted')
527                     self.switch_mode('play')
528                 self.input_ = ''
529             elif self.mode == self.mode_study:
530                 if key == self.keys['switch_to_chat']:
531                     self.switch_mode('chat')
532                 elif key == self.keys['switch_to_play']:
533                     self.switch_mode('play')
534                 elif key == self.keys['switch_to_annotate']:
535                     self.switch_mode('annotate', keep_position=True)
536                 elif key == self.keys['switch_to_portal']:
537                     self.switch_mode('portal', keep_position=True)
538                 elif key in self.movement_keys:
539                     move_explorer(self.movement_keys[key])
540             elif self.mode == self.mode_play:
541                 if key == self.keys['switch_to_chat']:
542                     self.switch_mode('chat')
543                 elif key == self.keys['switch_to_study']:
544                     self.switch_mode('study')
545                 if key == self.keys['switch_to_edit']:
546                     self.switch_mode('edit')
547                 elif key == self.keys['flatten']:
548                     self.send('TASK:FLATTEN_SURROUNDINGS')
549                 elif key in self.movement_keys:
550                     self.send('TASK:MOVE ' + self.movement_keys[key])
551             elif self.mode == self.mode_edit:
552                 self.send('TASK:WRITE ' + key)
553                 self.switch_mode('play')
554
555 TUI('127.0.0.1', 5000)