Files
dungeoncraft/src/touch.rs
T
Richard ZinkandClaude Opus 5.5 af6be62805 Touch interface for phones and tablets; category toolbar for mouse and touch
- touch.rs: gesture recognition (tap, double tap, long press, drag, two-finger pan/pinch/rotate)
- Touch mode: Auto (touch device or first touch) / On / Off in Settings -> Game
- Larger interface factor on touch screens, top bar wraps, smaller minimap, fewer messages
- Toolbar grouped into categories with pop-up rows and a menu button, also on the desktop
- Long press shows information or drops held units; double tap with the hand slaps
- Savegame name via the browser's text dialog when the name field is tapped
- Touch help texts (EN/DE)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:10:53 +01:00

177 lines
6.9 KiB
Rust

// Touch input: turns the raw touches of macroquad into gestures.
// one finger: press, tap, double tap, long press, drag (start / move / end), release
// two fingers: camera gesture (pan by the centre, pinch = zoom, twist = rotate)
// Positions are device pixels (the unit of the interface, see ui::begin_ui).
use macroquad::prelude::*;
const LONG_PRESS: f64 = 0.5; // seconds without moving
const DOUBLE_TAP: f64 = 0.3; // max. time between two taps
const DRAG_CSS: f32 = 10.0; // movement (CSS pixels) that turns a press into a drag
#[derive(Clone, Copy, Debug)]
pub enum Gesture {
Press(Vec2),
Tap(Vec2),
DoubleTap(Vec2),
LongPress(Vec2),
DragStart(Vec2), // start position of the drag
DragMove(Vec2),
DragEnd,
Release(Vec2),
Cancel, // a second finger arrived: abort the one-finger action
Camera { center: Vec2, pan: Vec2, zoom: f32, rotate: f32 },
}
#[derive(Clone, Copy)]
enum St {
Idle,
One { id: u64, start: Vec2, pos: Vec2, t0: f64, drag: bool, long: bool },
Two { a: u64, b: u64, center: Vec2, dist: f32, angle: f32 },
Wait, // after a two-finger gesture: until all fingers are lifted
}
pub struct TouchInput {
st: St,
last_tap: Option<(Vec2, f64)>,
pending_tap: Option<(Vec2, f64)>, // single tap held back while a double tap is possible
pub seen: bool, // a touch happened since the start
}
fn ended(p: TouchPhase) -> bool {
matches!(p, TouchPhase::Ended | TouchPhase::Cancelled)
}
impl TouchInput {
pub fn new() -> TouchInput {
TouchInput { st: St::Idle, last_tap: None, pending_tap: None, seen: false }
}
// a finger is on the screen (one-finger state)
pub fn finger(&self) -> Option<Vec2> {
match self.st {
St::One { pos, .. } => Some(pos),
_ => None,
}
}
fn tap(&mut self, p: Vec2, now: f64, want_double: bool, out: &mut Vec<Gesture>) {
let near = |a: Vec2, b: Vec2| a.distance(b) < DRAG_CSS * 3.0 * crate::ui::dpr();
if want_double {
if let Some((q, t)) = self.pending_tap {
if now - t < DOUBLE_TAP && near(p, q) {
self.pending_tap = None;
out.push(Gesture::DoubleTap(p));
return;
}
out.push(Gesture::Tap(q));
}
self.pending_tap = Some((p, now));
} else {
if let Some((q, _)) = self.pending_tap.take() {
out.push(Gesture::Tap(q));
}
out.push(Gesture::Tap(p));
}
self.last_tap = Some((p, now));
}
// want_double: taps are held back briefly so that a double tap can be recognised
pub fn update(&mut self, now: f64, want_double: bool) -> Vec<Gesture> {
let mut out = Vec::new();
let ts = touches();
if !ts.is_empty() {
self.seen = true;
}
let find = |id: u64| ts.iter().find(|t| t.id == id);
let active: Vec<&Touch> = ts.iter().filter(|t| !ended(t.phase)).collect();
let drag_px = DRAG_CSS * crate::ui::dpr();
match self.st {
St::Idle => {
if active.len() >= 2 {
self.start_two(active[0], active[1]);
} else if let Some(t) = active.first() {
out.push(Gesture::Press(t.position));
self.st = St::One { id: t.id, start: t.position, pos: t.position, t0: now, drag: false, long: false };
} else if let Some(t) = ts.iter().find(|t| ended(t.phase)) {
// started and ended between two frames
out.push(Gesture::Press(t.position));
self.tap(t.position, now, want_double, &mut out);
out.push(Gesture::Release(t.position));
}
}
St::One { id, start, t0, mut drag, mut long, .. } => {
if active.len() >= 2 {
out.push(Gesture::Cancel);
self.start_two(active[0], active[1]);
} else {
match find(id) {
Some(t) if !ended(t.phase) => {
let pos = t.position;
if !drag && pos.distance(start) > drag_px {
drag = true;
out.push(Gesture::DragStart(start));
}
if drag {
out.push(Gesture::DragMove(pos));
} else if !long && now - t0 > LONG_PRESS {
long = true;
out.push(Gesture::LongPress(pos));
}
self.st = St::One { id, start, pos, t0, drag, long };
}
other => {
let pos = other.map(|t| t.position).unwrap_or(start);
if drag {
out.push(Gesture::DragEnd);
} else if !long {
self.tap(pos, now, want_double, &mut out);
}
out.push(Gesture::Release(pos));
self.st = St::Idle;
}
}
}
}
St::Two { a, b, center, dist, angle } => match (find(a).filter(|t| !ended(t.phase)), find(b).filter(|t| !ended(t.phase))) {
(Some(ta), Some(tb)) => {
let (c, d, an) = Self::geometry(ta.position, tb.position);
let mut rot = an - angle;
if rot > std::f32::consts::PI {
rot -= std::f32::consts::TAU;
} else if rot < -std::f32::consts::PI {
rot += std::f32::consts::TAU;
}
out.push(Gesture::Camera { center: c, pan: c - center, zoom: d / dist.max(1.0), rotate: rot });
self.st = St::Two { a, b, center: c, dist: d, angle: an };
}
_ => self.st = if active.is_empty() { St::Idle } else { St::Wait },
},
St::Wait => {
if active.is_empty() {
self.st = St::Idle;
}
}
}
if let Some((p, t)) = self.pending_tap {
if !want_double || now - t >= DOUBLE_TAP {
self.pending_tap = None;
out.push(Gesture::Tap(p));
}
}
out
}
fn geometry(p: Vec2, q: Vec2) -> (Vec2, f32, f32) {
let d = q - p;
((p + q) * 0.5, d.length(), d.y.atan2(d.x))
}
fn start_two(&mut self, a: &Touch, b: &Touch) {
let (center, dist, angle) = Self::geometry(a.position, b.position);
self.pending_tap = None;
self.st = St::Two { a: a.id, b: b.id, center, dist, angle };
}
}