Browse Source

script: added --extended-controls option;
tooncher.launch(): remap WASD to arrow keys if extended controls are
enabled

Fabian Peter Hammerle 6 years ago
parent
commit
df956e06d9
4 changed files with 142 additions and 4 deletions
  1. 10 1
      README.md
  2. 10 1
      scripts/tooncher
  3. 3 0
      setup.py
  4. 119 2
      tooncher/__init__.py

+ 10 - 1
README.md

@@ -31,10 +31,19 @@ $ tooncher [username]
 
 `tooncher --help` shows all available options.
 
+### Extended Controls
+
+To enable the use of WASD keys for walking,
+launch tooncher with the `--extended-controls` option:
+
+```{sh}
+$ tooncher --extended-controls username
+```
+
 ### Examples
 
 ```
 $ tooncher toon
-$ tooncher ceo
+$ tooncher --extended-controls ceo
 $ tooncher --cpu-limit 70 cfo
 ```

+ 10 - 1
scripts/tooncher

@@ -8,7 +8,7 @@ import yaml
 
 
 def run(username, config_path, engine_path=None, validate_ssl_certs=True,
-        cpu_limit_percent=None):
+        cpu_limit_percent=None, enable_extended_keyboard_controls=False):
 
     if os.path.exists(config_path):
         with open(config_path) as f:
@@ -32,6 +32,7 @@ def run(username, config_path, engine_path=None, validate_ssl_certs=True,
                 password=account['password'],
                 validate_ssl_certs=validate_ssl_certs,
                 cpu_limit_percent=cpu_limit_percent,
+                enable_extended_keyboard_controls=enable_extended_keyboard_controls,
             )
 
 
@@ -75,6 +76,14 @@ def _init_argparser():
         help='maximally allowed cpu usage in percent'
             + ' (requires cpulimit command, default: %(default)s)',
     )
+    argparser.add_argument(
+        '--extended-controls',
+        dest='enable_extended_keyboard_controls',
+        action='store_true',
+        help='enable extended keyboard controls'
+            + ', e.g. walk with WASD'
+            + ' (requires xlib for python, default: %(default)s)',
+    )
     return argparser
 
 

+ 3 - 0
setup.py

@@ -17,5 +17,8 @@ setup(
     classifiers = [],
     scripts = glob.glob('scripts/*'),
     install_requires = ['pyyaml'],
+    extras_require = {
+        'extended-controls': ['xlib'],
+    },
     tests_require = ['pytest'],
     )

+ 119 - 2
tooncher/__init__.py

@@ -4,8 +4,15 @@ import os
 import ssl
 import subprocess
 import sys
+import time
+import traceback
 import urllib.parse
 import urllib.request
+try:
+    import Xlib.display
+    from Xlib import X, XK
+except ImportError:
+    Xlib = False
 
 """
 official api documentation:
@@ -29,6 +36,14 @@ else:
     TOONTOWN_LIBRARY_PATH = None
     TOONTOWN_ENGINE_DEFAULT_PATH = None
 
+if Xlib:
+    EXTENDED_KEYBOARD_CONTROLS_MAPPING = {
+        XK.XK_w: XK.XK_Up,
+        XK.XK_a: XK.XK_Left,
+        XK.XK_s: XK.XK_Down,
+        XK.XK_d: XK.XK_Right,
+    }
+
 
 def start_engine(engine_path, gameserver, playcookie, **kwargs):
     env = {
@@ -121,8 +136,99 @@ def login(username=None, password=None,
         raise Exception(repr(resp_data))
 
 
+def x_find_window(parent_window, filter_callback):
+    matching = []
+    for child_window in parent_window.query_tree().children:
+        if filter_callback(child_window):
+            matching.append(child_window)
+        matching += x_find_window(child_window, filter_callback)
+    return matching
+
+
+def x_find_window_by_pid(display, pid):
+    pid_prop = display.intern_atom('_NET_WM_PID')
+
+    def filter_callback(window):
+        prop = window.get_full_property(pid_prop, X.AnyPropertyType)
+        return prop and prop.value.tolist() == [pid]
+    return x_find_window(display.screen().root, filter_callback)
+
+
+def x_grab_key(grab_window, keycode, modifiers=None):
+    if modifiers is None:
+        modifiers = X.AnyModifier
+    grab_window.grab_key(
+        keycode,
+        modifiers,
+        # owner_events
+        # https://stackoverflow.com/questions/32122360/x11-will-xgrabpointer-prevent-other-apps-from-any-mouse-event
+        # False,
+        True,
+        X.GrabModeAsync,
+        X.GrabModeAsync,
+    )
+
+
+def wait_for_engine_window(xdisplay, engine_process):
+    while engine_process.poll() is None:  # TODO add timeout
+        windows = x_find_window_by_pid(xdisplay, engine_process.pid)
+        assert len(windows) <= 1
+        if len(windows) == 1:
+            return windows[0]
+        time.sleep(2)
+    return None
+
+
+def run_extended_keyboard_controls(engine_process):
+    if not Xlib:
+        raise Exception('\n'.join([
+            'Extended keyboard controls require xlib for python to be installed.',
+            'Depending on your system run',
+            '\t$ sudo apt-get install python3-xlib',
+            'or',
+            '\t$ pip3 install --user xlib',
+        ]))
+    xdisplay = Xlib.display.Display()
+    engine_window = wait_for_engine_window(xdisplay, engine_process)
+    if not engine_window:
+        raise Exception('Could not find the game\'s window.')
+    # TODO add toggle to switch on / off
+    for keysym in EXTENDED_KEYBOARD_CONTROLS_MAPPING.keys():
+        x_grab_key(
+            engine_window,
+            xdisplay.keysym_to_keycode(keysym),
+        )
+    while engine_process.poll() is None:
+        # TODO don't block here, engine might have already been stopped
+        xevent = xdisplay.next_event()
+        # TODO investigate why some release events get lost
+        if isinstance(xevent, Xlib.protocol.event.KeyPress) \
+                or isinstance(xevent, Xlib.protocol.event.KeyRelease):
+            keysym_in = xdisplay.keycode_to_keysym(
+                xevent.detail,
+                index=0,
+            )
+            if keysym_in in EXTENDED_KEYBOARD_CONTROLS_MAPPING:
+                keysym_out = EXTENDED_KEYBOARD_CONTROLS_MAPPING[keysym_in]
+            else:
+                keysym_out = keysym_in
+            engine_window.send_event(type(xevent)(
+                window=engine_window,
+                detail=xdisplay.keysym_to_keycode(keysym_out),
+                state=0,
+                root_x=xevent.root_x,
+                root_y=xevent.root_y,
+                event_x=xevent.event_x,
+                event_y=xevent.event_y,
+                child=xevent.child,
+                root=xevent.root,
+                time=xevent.time,  # X.CurrentTime
+                same_screen=xevent.same_screen,
+            ))
+
+
 def launch(engine_path, username, password, validate_ssl_certs=True,
-           cpu_limit_percent=None):
+           cpu_limit_percent=None, enable_extended_keyboard_controls=False):
     result = login(
         username=username,
         password=password,
@@ -146,7 +252,18 @@ def launch(engine_path, username, password, validate_ssl_certs=True,
                 '--limit', str(cpu_limit_percent),
                 # '--verbose',
             ])
-        p.wait()
+        if enable_extended_keyboard_controls:
+            try:
+                run_extended_keyboard_controls(
+                    engine_process=p,
+                )
+            except Exception as e:
+                if isinstance(e, KeyboardInterrupt):
+                    raise e
+                else:
+                    traceback.print_exc()
+        if p.poll() is None:
+            p.wait()
     else:
         raise Exception(repr(result))