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
- 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()
- 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()
- 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()