Early Touch Input Experiments on Android With Panda3D (WIP, looking for feedback)

Hey everyone, I’ve been experimenting with getting proper touch input working on Android with Panda3D, because right now Panda only gives you a single mouse-like touch event on mobile, and that isn’t enough for most games. It’s not some big system yet, it’s honestly just a bunch of small test scripts, each one trying out one idea at a time. So far I’ve tried single tap, double tap, long press, swipes with direction, and a virtual joystick, which also covers dragging since that’s basically how a joystick works. I skipped flicks because they were interfering with the swipes and I didn’t really see the point in having both right now. All of this is running through “ursina-for-mobile”, which is a project that makes it easier to get Panda3D or Ursina games running on Android.

I feel like I’ve reached a point where the tests are working well enough on-device that it might actually be useful to someone besides me, so I wanted to show it to the community before I go any further. I’m thinking about turning this into a small clean module once I understand what the best structure for it should be. Before that, I’d love some feedback on the overall direction, or anything you think I should change or add, or even stuff I might be overlooking. If anyone wants to see the test scripts or a video or apk, I can share that too.

( Edit ) : Scripts

  1. General TouchGestures : Detects tap, double-tap, long press, and swipe gestures.
from direct.showbase.ShowBase import ShowBase
from panda3d.core import ClockObject
from direct.showbase.DirectObject import DirectObject


# Simple single-finger gesture tester
class TouchGestures(DirectObject):
    def __init__(self, base, 
                 tap_max_time=0.20,
                 double_tap_max_gap=0.30,
                 long_press_time=0.45,
                 swipe_min_dist=0.08):

        self.base = base
        self.mouse = base.mouseWatcherNode
        self.clock = ClockObject.getGlobalClock()

        # Gesture thresholds
        self.tap_max_time = tap_max_time
        self.double_tap_max_gap = double_tap_max_gap
        self.long_press_time = long_press_time
        self.swipe_min_dist = swipe_min_dist

        # State
        self.is_down = False
        self.down_pos = None
        self.down_time = 0.0
        self.last_tap_time = 0.0
        self.single_tap_task = None

        # Listen for touch-as-mouse events
        self.accept("mouse1", self.on_down)
        self.accept("mouse1-up", self.on_up)

        # Per-frame update
        base.taskMgr.add(self.update, "touch_gesture_update")

    # Touch Down
    def on_down(self):
        if not self.mouse.hasMouse():
            return
        
        self.is_down = True
        self.down_pos = self.get_pos()
        self.down_time = self.clock.getFrameTime()

    # Touch Up
    def on_up(self):
        if not self.is_down:
            return
        
        up_time = self.clock.getFrameTime()
        duration = up_time - self.down_time
        dist = self.distance(self.get_pos(), self.down_pos)

        # TAP or DOUBLE TAP
        if duration <= self.tap_max_time and dist < 0.02:
            # self.on_tap()

            # DOUBLE TAP check
            if up_time - self.last_tap_time <= self.double_tap_max_gap:
                # Cancel previous scheduled tap
                if self.single_tap_task:
                    self.single_tap_task.remove()
                    self.single_tap_task = None
                
                self.on_double_tap()

            else:
                # Schedule a single tap AFTER waiting to confirm no second tap
                self.single_tap_task = self.base.taskMgr.doMethodLater(
                    self.double_tap_max_gap,
                    self.fire_single_tap,
                    "delayed_single_tap"
                )

            self.last_tap_time = up_time

        # SWIPE
        elif dist >= self.swipe_min_dist:
            self.on_swipe(self.down_pos, self.get_pos(), duration)

        self.is_down = False

    # Called after waiting to check for double tap
    def fire_single_tap(self, task):
        self.single_tap_task = None
        self.on_tap()
        return task.done

    # Long Press Check
    def update(self, task):
        if self.is_down:
            current_time = self.clock.getFrameTime()
            duration = current_time - self.down_time

            if duration >= self.long_press_time:
                self.on_long_press(self.down_pos)
                self.is_down = False  # avoid repeating long-press

        return task.cont

    # Utility: Position
    def get_pos(self):
        x = self.mouse.getMouseX()
        y = self.mouse.getMouseY()
        return (x, y)

    # Utility: Distance
    def distance(self, a, b):
        return ((a[0]-b[0])**2 + (a[1]-b[1])**2) ** 0.5

    # Gesture Callbacks
    # (you can override these)
    def on_tap(self):
        print("GESTURE: TAP")

    def on_double_tap(self):
        print("GESTURE: DOUBLE TAP")

    def on_long_press(self, pos):
        print("GESTURE: LONG PRESS at", pos)

    def on_swipe(self, start, end, duration):
        dx = end[0] - start[0]
        dy = end[1] - start[1]
        print(f"GESTURE: SWIPE dx={dx:.3f}, dy={dy:.3f}, time={duration:.3f}")


# Demo app
class App(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # Create gesture system
        self.gestures = TouchGestures(self)

        # # Override handlers (optional)
        # self.gestures.on_tap = lambda: print(">>> TAP")
        # self.gestures.on_double_tap = lambda: print(">>> DOUBLE TAP")
        # self.gestures.on_long_press = lambda pos: print(">>> LONG PRESS", pos)
        # self.gestures.on_swipe = lambda a, b, t: print(">>> SWIPE", a, b, t)


# Run app
App().run()
  1. VisibleJoystick : A virtual joystick for mobile input, with visual feedback on screen.
from direct.showbase.ShowBase import ShowBase
from direct.showbase.DirectObject import DirectObject
from panda3d.core import NodePath, TransparencyAttrib, ClockObject, GeomVertexFormat, GeomVertexData, GeomVertexWriter, Geom, GeomTriangles, GeomNode
import math


def make_circle(radius=0.1, segments=32, color=(1, 1, 1, 0.4)):
    """
    Creates a solid filled circle that works in render2d.
    Returns a NodePath.
    """
    format = GeomVertexFormat.getV3()
    vdata = GeomVertexData("circle", format, Geom.UHStatic)
    vertex = GeomVertexWriter(vdata, "vertex")
    # center
    vertex.addData3(0, 0, 0)
    for i in range(segments + 1):
        a = 2 * math.pi * i / segments
        x = radius * math.cos(a)
        z = radius * math.sin(a)
        vertex.addData3(x, 0, z)
    tris = GeomTriangles(Geom.UHStatic)
    for i in range(1, segments + 1):
        tris.addVertices(0, i, i + 1)
    geom = Geom(vdata)
    geom.addPrimitive(tris)
    node = GeomNode("circle")
    node.addGeom(geom)
    np = NodePath(node)
    np.setTransparency(TransparencyAttrib.M_alpha)
    np.setColor(*color)
    np.setBillboardPointEye()   # always face screen
    np.setTwoSided(True)
    return np


class VisibleJoystick(DirectObject):
    def __init__(self, base,
                 radius=0.25,
                 deadzone=0.05,
                 screen_region=(-1, -0.2, -1, 1)):
        # screen_region = (min_x, max_x, min_y, max_y)
        # basically: where on screen joystick can activate (left side)

        self.base = base
        self.mouse = base.mouseWatcherNode
        self.clock = ClockObject.getGlobalClock()

        self.radius = radius
        self.deadzone = deadzone
        self.screen_region = screen_region

        # State
        self.active = False
        self.start_pos = (0, 0)

        # UI
        self.base_circle = make_circle(radius, segments=64, color=(1, 1, 1, 0.25))
        self.thumb_circle = make_circle(radius * 0.45, segments=64, color=(1, 1, 1, 0.6))

        self.base_circle.reparentTo(base.render2d)
        self.thumb_circle.reparentTo(base.render2d)
        self.hide()

        # Input
        self.accept("mouse1", self.on_down)
        self.accept("mouse1-up", self.on_up)

        base.taskMgr.add(self.update, "joystick_update")

    def hide(self):
        self.base_circle.hide()
        self.thumb_circle.hide()

    def show(self):
        self.base_circle.show()
        self.thumb_circle.show()

    def on_down(self):
        if not self.mouse.hasMouse():
            return

        x, y = self.get_mouse()

        # Check if press is inside joystick region
        if not (self.screen_region[0] <= x <= self.screen_region[1] and
                self.screen_region[2] <= y <= self.screen_region[3]):
            return  # ignore touches outside left side area

        self.active = True
        self.start_pos = (x, y)

        # Position UI
        self.base_circle.setPos(x, 0, y)
        self.thumb_circle.setPos(x, 0, y)
        self.show()

        self.on_start(0, 0)

    def on_up(self):
        if not self.active:
            return

        self.active = False
        self.hide()
        self.on_end()

    def update(self, task):
        if not self.active:
            return task.cont

        if not self.mouse.hasMouse():
            return task.cont

        x, y = self.get_mouse()
        sx, sy = self.start_pos

        dx = x - sx
        dy = y - sy
        dist = math.sqrt(dx * dx + dy * dy)

        # Clamp thumb to radius
        if dist > self.radius:
            scale = self.radius / dist
            dx *= scale
            dy *= scale
            dist = self.radius

        # Update thumb position
        self.thumb_circle.setPos(sx + dx, 0, sy + dy)

        # Normalize
        nx = dx / self.radius
        ny = dy / self.radius

        # Deadzone
        if dist < self.deadzone:
            nx = 0
            ny = 0

        self.on_move(nx, ny)

        return task.cont

    def get_mouse(self):
        return (self.mouse.getMouseX(), self.mouse.getMouseY())

    # Callbacks to override
    def on_start(self, x, y):
        print("JOYSTICK START", x, y)

    def on_move(self, x, y):
        print("JOYSTICK MOVE", x, y)

    def on_end(self):
        print("JOYSTICK END")


# Demo app
class App(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        self.joy = VisibleJoystick(
            self,
            radius=0.25,
            deadzone=0.05,
            screen_region=(-1, -0.2, -1, 1)  # Left 80% of screen
        )

App().run()
  1. DirectionalSwipes : Detects 8-directional swipe gestures with a configurable deadzone.
from direct.showbase.ShowBase import ShowBase
from direct.showbase.DirectObject import DirectObject
from panda3d.core import ClockObject


class DirectionalSwipes(DirectObject):
    def __init__(self, base,
                 swipe_min_dist=0.08,
                 direction_deadzone=0.30):  # how much angle tolerance

        self.base = base
        self.mouse = base.mouseWatcherNode
        self.clock = ClockObject.getGlobalClock()

        self.swipe_min_dist = swipe_min_dist
        self.direction_deadzone = direction_deadzone

        self.is_down = False
        self.down_pos = (0, 0)

        self.accept("mouse1", self.on_down)
        self.accept("mouse1-up", self.on_up)

    def on_down(self):
        if not self.mouse.hasMouse():
            return
        self.is_down = True
        self.down_pos = self.get_pos()

    def on_up(self):
        if not self.is_down:
            return
        end_pos = self.get_pos()
        dx = end_pos[0] - self.down_pos[0]
        dy = end_pos[1] - self.down_pos[1]

        dist = (dx * dx + dy * dy) ** 0.5

        if dist < self.swipe_min_dist:
            self.is_down = False
            return  # Not a swipe

        self.detect_direction(dx, dy)
        self.is_down = False

    def detect_direction(self, dx, dy):

        # Normalize vector
        length = (dx * dx + dy * dy) ** 0.5
        ndx, ndy = dx / length, dy / length

        # Pure Horizontal
        if abs(ndy) < self.direction_deadzone:
            if ndx > 0:
                self.on_swipe_right()
            else:
                self.on_swipe_left()
            return

        # Pure Vertical
        if abs(ndx) < self.direction_deadzone:
            if ndy > 0:
                self.on_swipe_up()
            else:
                self.on_swipe_down()
            return

        # Diagonals
        if ndx > 0 and ndy > 0:
            self.on_swipe_up_right()
        elif ndx < 0 and ndy > 0:
            self.on_swipe_up_left()
        elif ndx > 0 and ndy < 0:
            self.on_swipe_down_right()
        elif ndx < 0 and ndy < 0:
            self.on_swipe_down_left()

    # Position getter
    def get_pos(self):
        return (self.mouse.getMouseX(), self.mouse.getMouseY())

    # Callback hooks
    def on_swipe_left(self):
        print("SWIPE LEFT")

    def on_swipe_right(self):
        print("SWIPE RIGHT")

    def on_swipe_up(self):
        print("SWIPE UP")

    def on_swipe_down(self):
        print("SWIPE DOWN")

    def on_swipe_up_left(self):
        print("SWIPE DIAGONAL UP-LEFT")

    def on_swipe_up_right(self):
        print("SWIPE DIAGONAL UP-RIGHT")

    def on_swipe_down_left(self):
        print("SWIPE DIAGONAL DOWN-LEFT")

    def on_swipe_down_right(self):
        print("SWIPE DIAGONAL DOWN-RIGHT")


# Demo app
class App(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        self.swipes = DirectionalSwipes(self)


App().run()
2 Likes

Below is a detailed breakdown of each test script, including purpose, arguments, state, and callbacks.


:one: TouchGestures – Single-Finger

Purpose:
Detects simple touch gestures from a single pointer (mouse/touch), including:

  • TAP
  • DOUBLE TAP
  • LONG PRESS
  • SWIPE

Dependencies:

  • DirectObject from direct.showbase.DirectObject
  • ClockObject from panda3d.core

Initialization Arguments:

Arg Default Description
tap_max_time 0.2 Max duration for a single tap
double_tap_max_gap 0.3 Max gap between taps for a double tap
long_press_time 0.45 Time threshold to trigger a long press
swipe_min_dist 0.08 Minimum distance to consider a movement a swipe

State Variables:

  • is_down: Boolean, whether touch is currently active
  • down_pos: Position of last touch down
  • down_time: Timestamp of last touch down
  • last_tap_time: Timestamp of last tap (for double tap detection)
  • single_tap_task: Task scheduled for delayed tap confirmation

How it works:

  • On mouse1 down → records position and time.

  • On mouse1 up → calculates duration and distance.

    • If short and small movement → TAP (or DOUBLE TAP if previous tap was recent).
    • If movement > swipe_min_dist → SWIPE.
  • Continuous task (update) checks for LONG PRESS.

Callbacks (overrideable):

  • on_tap()
  • on_double_tap()
  • on_long_press(pos)
  • on_swipe(start, end, duration)

Example Output:

GESTURE: TAP
GESTURE: DOUBLE TAP
GESTURE: LONG PRESS at (-0.564, -0.423)
GESTURE: SWIPE dx=0.687, dy=-0.727, time=0.25

:two: VisibleJoystick – On-Screen 2D Joystick

Purpose:
Displays a visual on-screen joystick for directional input.

Dependencies:

  • DirectObject from direct.showbase.DirectObject
  • NodePath, CardMaker, TransparencyAttrib from panda3d.core
  • ClockObject from panda3d.core
  • math

Initialization Arguments:

Arg Default Description
radius 0.25 Max distance the thumb can move from base
deadzone 0.05 Minimum movement ignored
screen_region (-1, -0.2, -1, 1) Screen area where joystick is active

State Variables:

  • active: Whether joystick is currently being touched
  • start_pos: Position of touch start

How it works:

  • On mouse1 down → checks if touch is within screen_region → activates joystick.
  • Updates thumb circle position while touch is active.
  • Normalized vector output in range [-1, 1].
  • Movement inside deadzone ignored.
  • Releases joystick on mouse1 up.

Callbacks (overrideable):

  • on_start(x, y)
  • on_move(nx, ny)
  • on_end()

Example Output:

JOYSTICK START 0 0
JOYSTICK MOVE 0.56 0.82
JOYSTICK END

:three: DirectionalSwipes – Swipe Direction Detection

Purpose:
Detects swipe gestures and determines their approximate direction (horizontal, vertical, diagonal).

Dependencies:

  • DirectObject from direct.showbase.DirectObject
  • ClockObject from panda3d.core

Initialization Arguments:

Arg Default Description
swipe_min_dist 0.08 Minimum distance to count as a swipe
direction_deadzone 0.3 Maximum component of perpendicular axis to still consider a pure direction

State Variables:

  • is_down: Whether touch is currently active
  • down_pos: Position of last touch down

How it works:

  • On mouse1 down → records initial position.

  • On mouse1 up → calculates vector and distance.

    • If distance < swipe_min_dist → ignored.
    • Else → normalized vector used to detect swipe direction.
  • Swipe direction detection logic:

    • Pure horizontal → ndy < deadzone → left/right
    • Pure vertical → ndx < deadzone → up/down
    • Otherwise → diagonal classification

Callbacks (overrideable):

  • on_swipe_left(), on_swipe_right()
  • on_swipe_up(), on_swipe_down()
  • on_swipe_up_left(), on_swipe_up_right()
  • on_swipe_down_left(), on_swipe_down_right()

Example Output:

SWIPE UP
SWIPE DOWN
SWIPE LEFT
SWIPE DIAGONAL UP-RIGHT

Follow these steps to convert the scripts into an APK and run them on a device

1. Get Ursina-for-Mobile (UfM)

Clone the repository or download the ZIP:

git clone https://github.com/PaologGithub/UrsinaForMobile.git

Repo: UfM GitHub

2. Install Dependencies

  • Python 3.13
  • Protobuf:
python3.13 -m pip install protobuf===3.20.0
  • Panda3D 1.11: install the correct wheel for your OS from the Panda3D buildbot.
  • Java JDK, ADB, and BundleTool installed and in your PATH.

3. Prepare Scripts

  • Put your scripts in the src/ folder inside the UfM project.
  • In settings.toml, change the startfile path to the script you want to run.
  • Optionally, adjust other settings as needed (follow the UfM guide for details).

4. Build the App

From the project root:

python setup.py bdist_apps

This generates a .aab bundle in dist/.

5. Generate a Keystore

Create a signing key for your APK:

keytool -genkeypair -alias touch_test -keyalg RSA -keysize 2048 -validity 10000 \
  -keystore touch_test.keystore -storepass 'your_keystore_password' \
  -keypass 'your_key_password' \
  -dname "CN=Your Name, OU=YourUnit, O=YourOrg, L=YourCity, ST=YourState, C=US"

Adjust the values to your liking.

6. Convert .aab to .apks

Run BundleTool (update paths accordingly):

java -jar "Path/To/bundletool-all-1.18.2.jar" build-apks \
  --bundle "Path/To/dist/my_ursina_game-1.0.0_android.aab" \
  --output "Path/To/dist/touch_test.apks" \
  --ks "Path/To/touch_test.keystore" \
  --ks-pass pass:your_keystore_password \
  --ks-key-alias touch_test \
  --key-pass pass:your_key_password \
  --mode universal \
  --verbose

Tip: --mode universal produces a single APK for all architectures — simplest for testing.

7. Extract APKs

Example using PowerShell:

New-Item -ItemType Directory -Path "Path/To/dist/touch_test"
Rename-Item -Path "Path/To/dist/touch_test.apks" -NewName "touch_test.zip"
Expand-Archive -LiteralPath "Path/To/dist/touch_test.zip" -DestinationPath "Path/To/dist/touch_test" -Force

8. Install on Device

  1. Connect your Android device and make sure USB debugging is enabled.
  2. Start ADB and check the device:
adb start-server
adb devices
  1. Install the APK:
adb install "Path/To/dist/touch_test/universal.apk"

9. View Logs

For debugging gesture input:

adb logcat -v threadtime 'Panda3D:V' 'Python:V' 'python_stdout:V' 'python_stderr:V' 'threaded_app:V' '*:F' > panda_log.txt

This captures the output of your scripts for troubleshooting.