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>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 10:10:53 +01:00
1 parent 6f037294c0
commit af6be62805
10 files changed
+744 -152

No files matched your search

+195 -47
View File
@@ -14,6 +14,7 @@ mod settings;
mod sfx;
mod tracks;
mod sound;
mod touch;
mod ui;
mod xm;
@@ -26,6 +27,7 @@ use menu::{Menu, MenuAction, Page};
use render3d::{Cam, Overlay, R3d};
use settings::{bindable_keys, Action, Settings};
use sound::SoundBank;
use touch::{Gesture, TouchInput};
use ui::{hover_info, Tool, Ui, UiAction, TOOLS};
const SETTINGS_KEY: &str = "dc.settings";
@@ -54,6 +56,60 @@ struct Input {
rdrag: bool,
mlast: Option<(f32, f32)>, // middle button drag
minimap_drag: bool,
// touch
tlast: Option<Vec2>, // last position of a one-finger pan (device pixels)
touch_world: bool, // the current finger started on the game world
inspect: Option<(f32, f32, f64)>, // long press: show information for this point (CSS pixels)
}
fn area_tool(t: Tool) -> bool {
matches!(t, Tool::Dig | Tool::Room(_) | Tool::Sell)
}
// Use the active tool on the world at (mx, my) (CSS pixels): a click, or a tap on touch screens
#[allow(clippy::too_many_arguments)]
fn world_action(app: &mut App, ui: &mut Ui, r3d: &R3d, inp: &mut Input, mx: f32, my: f32, slap: bool, now: f64) {
if app.game.state != 0 {
return;
}
let tile = app.cam.pick_tile(&app.game.map, mx, my).map(|(x, z, _)| (x, z));
let ground = app.cam.ground_point(mx, my);
let (wx, wy) = (ground.x, ground.y);
match (ui.tool, tile) {
(Tool::Dig | Tool::Room(_) | Tool::Sell, Some(t)) => inp.ldown = Some(t),
(Tool::Hand, _) => {
let j = r3d.pick_ent(&app.game, &app.cam, mx, my, false);
if slap {
let kind = app.game.slap(j);
if kind > 0 {
ui.hand_anim = Some((kind, now));
}
} else {
app.game.pickup(j);
}
}
(Tool::Flag, Some((x, z))) => app.game.set_flag(x, z),
(Tool::Lightning, _) => app.game.lightning(wx, wy),
(Tool::Heal, _) => app.game.heal(wx, wy),
(Tool::Summon, _) => app.game.summon(wx, wy),
(Tool::Tower(k), Some((x, z))) => app.game.build_tower(k, x, z),
_ => {}
}
}
// Finish a selection rectangle at the tile under (mx, my)
fn finish_rect(app: &mut App, tool: Tool, start: (i32, i32), mx: f32, my: f32) {
let Some((ex, ey, _)) = app.cam.pick_tile(&app.game.map, mx, my) else { return };
let (sx, sy) = start;
match tool {
Tool::Dig => {
let on = !app.game.map.t[idx(sx, sy)].marked;
app.game.mark_rect(sx, sy, ex, ey, on);
}
Tool::Room(r) => app.game.build_rect(r, sx, sy, ex, ey),
Tool::Sell => app.game.sell_rect(sx, sy, ex, ey),
_ => {}
}
}
fn new_game(seed: u32, s: &Settings) -> (Game, Cam) {
@@ -229,17 +285,44 @@ async fn main() {
let (game, cam) = new_game(seed, &settings);
let mut app = App { game, cam, settings, seed, status: None };
app.game.hints = app.settings.hints;
let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, mlast: None, minimap_drag: false };
let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, mlast: None, minimap_drag: false, tlast: None, touch_world: false, inspect: None };
let mut touch = TouchInput::new();
let mut auto_touch = platform::coarse_pointer(); // automatic mode: touch interface after a touch, desktop after a mouse click
let mut autosave_t = 0.0f32;
let mut cache_t = 0.0f32;
loop {
let dt = get_frame_time().min(0.1);
let now = get_time();
let (mx, my) = mouse_position(); // CSS pixels (3D picking)
let (ux, uy) = ui::mouse(); // device pixels (interface)
let mut settings_changed = false;
// ---------------- touch or mouse: which interface is active
// (with the touch interface touches are no longer turned into mouse events)
simulate_mouse_with_touch(app.settings.touch_ui == 2);
let gestures = if app.settings.touch_ui == 2 { Vec::new() } else { touch.update(now, ui.tool == Tool::Hand && !menu.open()) };
if !touches().is_empty() {
auto_touch = true;
} else if app.settings.touch_ui != 2 && (is_mouse_button_pressed(MouseButton::Left) || is_mouse_button_pressed(MouseButton::Right)) {
auto_touch = false;
}
let touch_mode = match app.settings.touch_ui {
1 => true,
2 => false,
_ => auto_touch,
};
ui::set_touch(touch_mode);
if touch_mode {
// the pointer is the finger, or the position of this frame's gesture
let gpos = gestures.iter().rev().find_map(|g| match *g {
Gesture::Press(p) | Gesture::Tap(p) | Gesture::Release(p) | Gesture::LongPress(p) | Gesture::DoubleTap(p) => Some(p),
_ => None,
});
let p = touch.finger().or(gpos).unwrap_or(vec2(-1e5, -1e5));
ui::set_pointer(p.x, p.y);
}
let (ux, uy) = ui::mouse(); // device pixels (interface)
let (mx, my) = if touch_mode { (ux / ui::dpr(), uy / ui::dpr()) } else { mouse_position() }; // CSS pixels (3D picking)
// ---------------- savegame files: file picker result or dropped onto the window
let mut incoming: Option<Vec<u8>> = platform::take_file();
for f in get_dropped_files() {
@@ -294,7 +377,8 @@ async fn main() {
menu.finish_capture();
}
}
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, now);
let p = mouse_position();
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, p, false, now);
next_frame().await;
continue;
}
@@ -386,7 +470,7 @@ async fn main() {
move_cam(pan, 0.0, cam);
}
let edge = 6.0;
if keys.edge_scroll && mx >= 0.0 && my >= 0.0 && mx < screen_width() && my < screen_height() {
if keys.edge_scroll && !touch_mode && mx >= 0.0 && my >= 0.0 && mx < screen_width() && my < screen_height() {
if mx < edge {
move_cam(-pan, 0.0, cam);
} else if mx > screen_width() - edge {
@@ -411,7 +495,7 @@ async fn main() {
if is_key_down(key(Action::TiltDown)) {
cam.pitch -= rot * 0.6;
}
if is_mouse_button_down(MouseButton::Middle) {
if is_mouse_button_down(MouseButton::Middle) && !touch_mode {
if let Some((lx, ly)) = inp.mlast {
let inv = if keys.invert_mouse { -1.0 } else { 1.0 };
cam.yaw -= (mx - lx) * 0.008 * keys.rotate_speed * inv;
@@ -431,6 +515,22 @@ async fn main() {
cam.tx += before.x - after.x;
cam.tz += before.y - after.y;
}
// touch: two fingers move, zoom and rotate the camera
for g in &gestures {
if let Gesture::Camera { center, pan, zoom, rotate } = *g {
let d = ui::dpr();
let a = cam.ground_point((center.x - pan.x) / d, (center.y - pan.y) / d);
let b = cam.ground_point(center.x / d, center.y / d);
cam.tx -= b.x - a.x;
cam.tz -= b.y - a.y;
let before = cam.ground_point(center.x / d, center.y / d);
cam.zoom = (cam.zoom / zoom.max(0.01)).clamp(6.0, 45.0);
cam.yaw += rotate; // the scene turns with the fingers
let after = cam.ground_point(center.x / d, center.y / d);
cam.tx += before.x - after.x;
cam.tz += before.y - after.y;
}
}
// right button: drag = pan, click = drop
if is_mouse_button_pressed(MouseButton::Right) {
inp.rdown = Some((mx, my));
@@ -463,8 +563,13 @@ async fn main() {
}
}
// ---------------- left button
if is_mouse_button_pressed(MouseButton::Left) {
// ---------------- left button (touch: a finger touching the interface)
let pressed = if touch_mode { gestures.iter().any(|g| matches!(g, Gesture::Press(_))) } else { is_mouse_button_pressed(MouseButton::Left) };
if touch_mode && pressed {
inp.touch_world = playing && !over_ui;
inp.inspect = None;
}
if pressed {
if dialog {
match ui.click(ux, uy, &app.game) {
UiAction::SelectLanguage(code) => {
@@ -525,6 +630,11 @@ async fn main() {
MenuAction::CloudDelete(id) => {
cloud.delete(&id);
}
MenuAction::EditName => {
if let Some(n) = platform::prompt(&i18n::tr("saves.name"), &menu.save_name) {
menu.save_name = n.chars().filter(|c| !c.is_control()).take(40).collect();
}
}
MenuAction::Help => ui.show_help = true,
MenuAction::MainMenu => {
if app.game.state == 0 {
@@ -551,55 +661,86 @@ async fn main() {
(app.game, app.cam) = new_game(app.seed, &app.settings);
app.game.hints = app.settings.hints;
}
UiAction::OpenMenu => menu.page = Some(Page::Pause),
UiAction::CloseHelp | UiAction::SelectLanguage(_) | UiAction::Consumed => {}
UiAction::None => {
if app.game.state == 0 {
match (ui.tool, hover_tile) {
(Tool::Dig | Tool::Room(_) | Tool::Sell, Some(t)) => inp.ldown = Some(t),
(Tool::Hand, _) => {
let j = r3d.pick_ent(&app.game, &app.cam, mx, my, false);
if is_key_down(KeyCode::LeftShift) || is_key_down(KeyCode::RightShift) {
let kind = app.game.slap(j);
if kind > 0 {
ui.hand_anim = Some((kind, now));
}
} else {
app.game.pickup(j);
}
}
(Tool::Flag, Some((x, z))) => app.game.set_flag(x, z),
(Tool::Lightning, _) => app.game.lightning(wx, wy),
(Tool::Heal, _) => app.game.heal(wx, wy),
(Tool::Summon, _) => app.game.summon(wx, wy),
(Tool::Tower(k), Some((x, z))) => app.game.build_tower(k, x, z),
_ => {}
if touch_mode {
// touch: area tools start their rectangle, everything else acts on the tap
if area_tool(ui.tool) && app.game.state == 0 {
inp.ldown = hover_tile;
}
} else {
let slap = is_key_down(KeyCode::LeftShift) || is_key_down(KeyCode::RightShift);
world_action(&mut app, &mut ui, &r3d, &mut inp, mx, my, slap, now);
}
}
}
}
}
let held = if touch_mode { touch.finger().is_some() } else { is_mouse_button_down(MouseButton::Left) };
if inp.minimap_drag {
if is_mouse_button_down(MouseButton::Left) && ui.minimap_contains(ux, uy) {
if held && ui.minimap_contains(ux, uy) {
let (x, y) = ui.minimap_world(ux, uy);
app.cam.tx = x;
app.cam.tz = y;
} else if !is_mouse_button_down(MouseButton::Left) {
} else if !held {
inp.minimap_drag = false;
}
}
if is_mouse_button_released(MouseButton::Left) {
if let Some((sx, sy)) = inp.ldown.take() {
if let (true, Some((ex, ey))) = (playing, hover_tile) {
match ui.tool {
Tool::Dig => {
let on = !app.game.map.t[idx(sx, sy)].marked;
app.game.mark_rect(sx, sy, ex, ey, on);
}
Tool::Room(r) => app.game.build_rect(r, sx, sy, ex, ey),
Tool::Sell => app.game.sell_rect(sx, sy, ex, ey),
_ => {}
// ---------------- touch gestures on the game world
if touch_mode {
let d = ui::dpr();
for g in &gestures {
match *g {
Gesture::Cancel => {
inp.ldown = None;
inp.tlast = None;
}
Gesture::Tap(p) | Gesture::DoubleTap(p) if playing && !ui.over_ui(p.x, p.y) => {
if !area_tool(ui.tool) {
let slap = matches!(g, Gesture::DoubleTap(_));
world_action(&mut app, &mut ui, &r3d, &mut inp, p.x / d, p.y / d, slap, now);
}
}
Gesture::LongPress(p) if playing && inp.touch_world => {
inp.ldown = None;
if ui.tool == Tool::Hand && !app.game.hand.is_empty() {
let gp = app.cam.ground_point(p.x / d, p.y / d);
app.game.drop(gp.x, gp.y);
} else {
inp.inspect = Some((p.x / d, p.y / d, now));
}
}
Gesture::DragStart(p) if playing && inp.touch_world && inp.ldown.is_none() => inp.tlast = Some(p),
Gesture::DragMove(p) => {
if let Some(l) = inp.tlast {
let a = app.cam.ground_point(l.x / d, l.y / d);
let b = app.cam.ground_point(p.x / d, p.y / d);
app.cam.tx -= b.x - a.x;
app.cam.tz -= b.y - a.y;
inp.tlast = Some(p);
}
}
Gesture::Release(p) => {
inp.tlast = None;
if let Some(start) = inp.ldown.take() {
if playing && inp.touch_world {
finish_rect(&mut app, ui.tool, start, p.x / d, p.y / d);
}
}
}
_ => {}
}
}
if inp.inspect.map_or(false, |(_, _, t)| now - t > 6.0) {
inp.inspect = None;
}
}
if !touch_mode && is_mouse_button_released(MouseButton::Left) {
if let Some(start) = inp.ldown.take() {
if playing {
finish_rect(&mut app, ui.tool, start, mx, my);
}
}
}
@@ -643,15 +784,20 @@ async fn main() {
ui.log.push((m, now));
}
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, now);
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, (mx, my), touch_mode, now);
next_frame().await
}
}
#[allow(clippy::too_many_arguments)]
fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut Menu, cloud: &Cloud, inp: &Input, now: f64) {
let (mx, my) = mouse_position();
let (ux, uy) = ui::mouse();
// pointer: CSS pixels of the mouse or finger; touch: hover highlight and information only
// while a finger is down or for a long-pressed point
fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut Menu, cloud: &Cloud, inp: &Input, pointer: (f32, f32), touch: bool, now: f64) {
let (mx, my) = match (touch, inp.inspect) {
(true, Some((x, y, _))) if inp.ldown.is_none() => (x, y),
_ => pointer,
};
let (ux, uy) = (mx * ui::dpr(), my * ui::dpr());
let blocked = menu.open() || ui.show_help || ui.show_langsel;
let over_ui = blocked || ui.over_ui(ux, uy);
let hover_tile = app.cam.pick_tile(&app.game.map, mx, my).map(|(x, z, _)| (x, z));
@@ -681,7 +827,7 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
Some(Page::Title) | Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. })
);
if !title_screen {
let info = if over_ui {
let info = if over_ui || (touch && inp.inspect.is_none()) {
Vec::new()
} else {
let ent = r3d.pick_ent(&app.game, &app.cam, mx, my, false);
@@ -698,5 +844,7 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
if !menu.open() || ui.show_help || ui.show_langsel {
ui.draw_dialogs(&app.game, gfx);
}
ui.draw_cursor(gfx, &app.game, now, blocked);
if !touch {
ui.draw_cursor(gfx, &app.game, now, blocked);
}
}