From af6be62805ac867cbd900e1480c27b3a0e36127d Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Tue, 3 Nov 2026 10:10:53 +0100 Subject: [PATCH] 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 --- DESIGN.md | 2 + README.md | 18 ++- lang/de.lang | 18 +++ lang/en.lang | 18 +++ src/main.rs | 242 +++++++++++++++++++++++++------ src/menu.rs | 14 +- src/platform.rs | 26 ++++ src/touch.rs | 176 ++++++++++++++++++++++ src/ui.rs | 371 ++++++++++++++++++++++++++++++++++------------- www/dc_plugin.js | 11 ++ 10 files changed, 744 insertions(+), 152 deletions(-) create mode 100644 src/touch.rs diff --git a/DESIGN.md b/DESIGN.md index d7afac9..9a6734b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -65,6 +65,8 @@ An original dungeon management game (genre: dungeon building & defence) that run - Claim the **portal** → creatures: kobold, cave spider, shadow mage, stone ogre (depending on rooms) - **Needs**: sleep, food, wages (payday); unhappy creatures leave - **Hand**: left click picks up own units (max. 8) and unconscious heroes on own ground; right click drops them on own ground (prisoners onto a free torture place are locked up); Shift + left click slaps: backhand for own units (15 s +50% speed, −5% HP, a little mood, wakes sleepers), fist for heroes on own ground (15 damage, 1 s stun, 0.5 s cooldown) +- **Toolbar by categories** (Dig, Hand, Rooms, Towers, Spells, Orders, Menu) for mouse and touch alike; categories with several tools open a row of tools +- **Touch interface** (Auto / On / Off; auto = touch device or first touch): larger interface factor (about 2/3 of the device pixel ratio), top bar wraps onto several lines, gestures: tap = tool, drag = rectangle or pan, two fingers = pan / pinch zoom / twist rotate, long press = info or drop, double tap with the hand = slap; savegame names via the browser's text dialog - **Pixel-art mouse pointers**: pointing monster hand with the icon of the active tool; grabbing hand / fist for the hand tool; animated backhand and punch; the active tool is highlighted in the toolbar (pulsing frame and arrow) - **Spells** (mana): lightning, heal, summon Pit Imp - **Heroes**: knight, archer, priest, dwarf miner, paladin of light (they dig their way to the Soul Core) diff --git a/README.md b/README.md index 33d877e..76e9e0e 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ The soundtrack consists of FastTracker 2 modules in `assets/music/`, generated b | Right drag, WASD, arrows, screen edge | Move the camera | | Middle drag, Q/E, R/F | Rotate / tilt the camera | | Mouse wheel | Zoom | -| 1–9, 0, X, V | Select a tool or spell | +| 1–9, 0, X, V, K, J, B, C, T | Select a tool or spell | | P / Space | Pause | | M, +/- | Sound on/off, volume | | G | Tile outlines on/off | @@ -64,6 +64,22 @@ The soundtrack consists of FastTracker 2 modules in `assets/music/`, generated b All keys except Esc and the arrow keys can be changed in Settings → Keys. +The toolbar groups the tools into categories (Dig, Hand, Rooms, Towers, Spells, Orders) plus a menu button; categories with several tools open a row above the toolbar. The keys select tools directly. + +### Touch (phones and tablets) + +The touch interface switches on automatically on touch devices or after the first touch (Settings → Game → Touch interface: Auto / On / Off). It uses a larger interface and these gestures: + +| Gesture | Action | +|---|---| +| Tap | Use the tool (hand: pick up a unit; spells, flag, towers: at that spot) | +| Drag | Dig, rooms and sell: mark a rectangle; other tools: move the map | +| Two fingers | Move the map, pinch to zoom, twist to rotate the camera | +| Long press | Information about the spot; hand: drop the units there | +| Double tap (hand) | Backhand own units / punch heroes on your ground | + +The savegame name is entered with the browser's text dialog when the name field is tapped. + ## Savegames When the game is served together with the savegame service (`server/`, started by `docker compose`), *Save Game* and *Load Game* open a list of savegames stored on the server: save under a new name (type it into the name field), overwrite, load or delete (overwrite and delete ask for a second click). The list is shared by everybody who can reach the game - protect a public installation, for example with a password on the reverse proxy. diff --git a/lang/de.lang b/lang/de.lang index 42bc9fb..dba3099 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -365,3 +365,21 @@ cloud.err_rate = Zu viele Anfragen - bitte einen Moment warten. cloud.err_full = Der Spielstand-Server ist voll. cloud.err_missing = Diesen Spielstand gibt es nicht mehr. cloud.err = Der Spielstand-Server meldet einen Fehler. + +# touch interface +set.touch_ui = Touch-Oberfläche +val.auto_state = Auto ({0}) +cat.dig = Graben +cat.hand = Hand +cat.rooms = Räume +cat.towers = Türme +cat.spells = Zauber +cat.orders = Befehle +cat.menu = Menü +help.t_tap = Tippen: Werkzeug benutzen (Hand: Einheit aufheben; Zauber, Flagge, Türme: an der Stelle) +help.t_drag = Ziehen: Graben, Räume und Verkaufen markieren ein Rechteck; sonst verschiebt es die Karte +help.t_two = Zwei Finger: Karte verschieben, zusammenziehen zum Zoomen, drehen dreht die Kamera +help.t_long = Lange drücken: Infos zur Stelle; Hand: Einheiten dort absetzen +help.t_double = Doppelt tippen mit der Hand: Rückhand für eigene, Faust für Helden auf eigenem Gebiet +help.t_tools = Werkzeugleiste: Kategorien öffnen eine Reihe Werkzeuge; die Menütaste öffnet das Pausemenü +help.start_touch = Tippen zum Starten diff --git a/lang/en.lang b/lang/en.lang index aa0191d..1bca49a 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -366,3 +366,21 @@ cloud.err_rate = Too many requests - please wait a moment. cloud.err_full = The savegame server is full. cloud.err_missing = This savegame no longer exists. cloud.err = The savegame server reported an error. + +# touch interface +set.touch_ui = Touch interface +val.auto_state = Auto ({0}) +cat.dig = Dig +cat.hand = Hand +cat.rooms = Rooms +cat.towers = Towers +cat.spells = Spells +cat.orders = Orders +cat.menu = Menu +help.t_tap = Tap: use the tool (hand: pick up a unit; spells, flag, towers: at that spot) +help.t_drag = Drag: dig, rooms and sell mark a rectangle; with other tools it moves the map +help.t_two = Two fingers: move the map, pinch to zoom, twist to rotate the camera +help.t_long = Long press: information about the spot; hand: drop the units there +help.t_double = Double tap with the hand: backhand own units, punch heroes on your ground +help.t_tools = Toolbar: categories open a row of tools; the menu button opens the pause menu +help.start_touch = Tap to start diff --git a/src/main.rs b/src/main.rs index 3ba0ebb..ea430b5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, // 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> = 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); + } } diff --git a/src/menu.rs b/src/menu.rs index 66c588a..138933b 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -40,6 +40,7 @@ pub enum Item { RenderScale, GameSpeed, Hints, + TouchUi, } fn tab_items(tab: usize) -> &'static [Item] { @@ -48,7 +49,7 @@ fn tab_items(tab: usize) -> &'static [Item] { 0 => &[Language, Volume, MusicVolume, Mute], 1 => &[RotateSpeed, ZoomSpeed, EdgeScroll, InvertMouse], 2 => &[Outlines, WallDetail, Lights, UiScale, RenderScale], - 3 => &[GameSpeed, Hints], + 3 => &[GameSpeed, Hints, TouchUi], _ => &[], } } @@ -70,6 +71,7 @@ fn item_label(it: Item) -> String { Item::RenderScale => "set.render_scale", Item::GameSpeed => "set.game_speed", Item::Hints => "set.hints", + Item::TouchUi => "set.touch_ui", }) } @@ -108,6 +110,11 @@ fn item_value(it: Item, s: &Settings) -> String { Item::RenderScale => format!("1/{}", s.render_scale.max(1)), Item::GameSpeed => format!("{}x", s.game_speed as i32), Item::Hints => onoff(s.hints), + Item::TouchUi => match s.touch_ui { + 1 => tr("val.on"), + 2 => tr("val.off"), + _ => trf("val.auto_state", &[tr(if crate::ui::touch_ui() { "val.on" } else { "val.off" })]), + }, } } @@ -142,6 +149,7 @@ fn item_change(it: Item, s: &mut Settings, d: i32) { Item::RenderScale => s.render_scale = cycle(&(1u8..=16).collect::>(), s.render_scale.clamp(1, 16), d), Item::GameSpeed => s.game_speed = cycle(&[1.0f32, 2.0], s.game_speed, d), Item::Hints => s.hints = !s.hints, + Item::TouchUi => s.touch_ui = cycle(&[0u8, 1, 2], s.touch_ui, d), } } @@ -176,6 +184,7 @@ enum Btn { Scroll(i32), FileSave, FileLoad, + NameField, } pub enum MenuAction { @@ -194,6 +203,7 @@ pub enum MenuAction { CloudSave(Option, String), // (id to overwrite, name) CloudLoad(String), CloudDelete(String), + EditName, // the name field was tapped (touch: ask with a browser dialog) } pub struct Menu { @@ -364,6 +374,7 @@ impl Menu { self.scroll_by(d * self.rows_visible.max(1) as i32, cloud.list.len()); MenuAction::Consumed } + Some(Btn::NameField) => MenuAction::EditName, Some(Btn::FileSave) => MenuAction::FileSave, Some(Btn::FileLoad) => MenuAction::FileLoad, } @@ -599,6 +610,7 @@ impl Menu { let fw = (inner_w - (fx - inner_x) - bw - CW()).max(CW() * 8.0); draw_rectangle(fx, top, fw, CH() * 2.0, Color::new(0.02, 0.01, 0.02, 1.0)); draw_rectangle_lines(fx, top, fw, CH() * 2.0, 2.0, FRAME_HI); + self.buttons.push((Rect::new(fx, top, fw, CH() * 2.0), Btn::NameField)); let blink = if (get_time() * 2.0) as i64 % 2 == 0 { "_" } else { " " }; let maxc = ((fw - CW() * 2.0) / CW()).max(1.0) as usize; let shown: String = { diff --git a/src/platform.rs b/src/platform.rs index 5c0f9b4..059bab8 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -14,6 +14,8 @@ mod imp { extern "C" { fn dc_storage_get(key: *const u8, key_len: u32) -> i32; fn dc_storage_take(dst: *mut u8); + fn dc_coarse_pointer() -> i32; + fn dc_prompt(t: *const u8, tl: u32, d: *const u8, dl: u32) -> i32; fn dc_storage_set(key: *const u8, key_len: u32, val: *const u8, val_len: u32); fn dc_cache_set(key: *const u8, key_len: u32, val: *const u8, val_len: u32); fn dc_download(name: *const u8, name_len: u32, data: *const u8, data_len: u32); @@ -55,6 +57,24 @@ mod imp { } } + // phones and tablets: the main pointer is a finger + pub fn coarse_pointer() -> bool { + unsafe { dc_coarse_pointer() != 0 } + } + + // asks for a text with the browser's dialog; None when cancelled + pub fn prompt(title: &str, default: &str) -> Option { + unsafe { + let n = dc_prompt(title.as_ptr(), title.len() as u32, default.as_ptr(), default.len() as u32); + if n < 0 { + return None; + } + let mut buf = vec![0u8; n as usize]; + dc_storage_take(buf.as_mut_ptr()); + String::from_utf8(buf).ok() + } + } + pub fn storage_set(key: &str, val: &str) { unsafe { dc_storage_set(key.as_ptr(), key.len() as u32, val.as_ptr(), val.len() as u32) } } @@ -96,6 +116,12 @@ mod imp { pub fn storage_get(key: &str) -> Option { STORE.with(|s| s.borrow().get(key).cloned()) } + pub fn prompt(_title: &str, _default: &str) -> Option { + None + } + pub fn coarse_pointer() -> bool { + false + } pub fn storage_set(key: &str, val: &str) { STORE.with(|s| s.borrow_mut().insert(key.to_string(), val.to_string())); } diff --git a/src/touch.rs b/src/touch.rs new file mode 100644 index 0000000..6e66ffc --- /dev/null +++ b/src/touch.rs @@ -0,0 +1,176 @@ +// 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 { + match self.st { + St::One { pos, .. } => Some(pos), + _ => None, + } + } + + fn tap(&mut self, p: Vec2, now: f64, want_double: bool, out: &mut Vec) { + 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 { + 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 }; + } +} diff --git a/src/ui.rs b/src/ui.rs index 54a4a4d..464186d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -15,12 +15,26 @@ use macroquad::prelude::*; // is the interface factor (automatic from the resolution or fixed in the settings). thread_local! { static UI_FACTOR_SETTING: std::cell::Cell = std::cell::Cell::new(0); + static TOUCH: std::cell::Cell = std::cell::Cell::new(false); + static POINTER: std::cell::Cell<(f32, f32)> = std::cell::Cell::new((-1e5, -1e5)); } pub fn set_ui_factor(f: u8) { UI_FACTOR_SETTING.with(|c| c.set(f)); } +// touch interface active (bigger interface, category toolbar, gestures) +pub fn set_touch(on: bool) { + TOUCH.with(|c| c.set(on)); +} +pub fn touch_ui() -> bool { + TOUCH.with(|c| c.get()) +} +// in touch mode the pointer is the finger (device pixels), set by the input code each frame +pub fn set_pointer(x: f32, y: f32) { + POINTER.with(|c| c.set((x, y))); +} + // device pixels per CSS pixel pub fn dpr() -> f32 { screen_dpi_scale() @@ -33,6 +47,9 @@ pub fn sh() -> f32 { (screen_height() * dpr()).round() } pub fn mouse() -> (f32, f32) { + if touch_ui() { + return POINTER.with(|c| c.get()); + } let (x, y) = mouse_position(); (x * dpr(), y * dpr()) } @@ -44,7 +61,15 @@ pub fn F() -> u32 { if set > 0 { return set as u32; } - ((sw() / 1280.0).min(sh() / 760.0).floor() as u32).clamp(1, 4) + let auto = ((sw() / 1280.0).min(sh() / 760.0).floor() as u32).clamp(1, 4); + if touch_ui() { + // fingers need bigger buttons: about 2/3 of the device pixel ratio, + // as long as at least 40 columns fit across the screen + let want = (dpr() * 2.0 / 3.0).round() as u32; + let fit = (sw() / (40.0 * GW * 2.0)).floor() as u32; + return auto.max(want.min(fit)).clamp(1, 4); + } + auto } // text scales: 1x, 2x and 4x the glyph size, times F #[allow(non_snake_case)] @@ -206,8 +231,26 @@ pub fn tool_cost(t: Tool, g: &Game) -> String { } } +// Touch toolbar: tool categories (indices into TOOLS); categories with several tools open a row +pub const CATS: [(&str, &[usize]); 6] = [ + ("cat.dig", &[0]), + ("cat.hand", &[1]), + ("cat.rooms", &[2, 3, 4, 5, 6, 12, 13]), + ("cat.towers", &[14, 15, 16]), + ("cat.spells", &[9, 10, 11]), + ("cat.orders", &[8, 7]), +]; + +#[derive(Clone, Copy, PartialEq, Debug)] +enum TBtn { + Cat(usize), + Tool(usize), + Menu, +} + pub enum UiAction { None, + OpenMenu, SelectTool(Tool), MinimapJump(f32, f32), CloseHelp, @@ -226,10 +269,14 @@ pub struct Ui { minimap_tex: Texture2D, minimap_t: f64, minimap_rect: Rect, - tool_rects: Vec, end_button: Rect, lang_rects: Vec<(Rect, String)>, pub hand_anim: Option<(u8, f64)>, // 1 = backhand, 2 = punch, start time + // touch interface + open_cat: Option, + tip: Option<(usize, f64)>, // tool whose description is shown, since + touch_btns: Vec<(Rect, TBtn)>, + hud_h: f32, } fn wrap(s: &str, cols: usize) -> Vec { @@ -272,10 +319,13 @@ impl Ui { minimap_tex: tex, minimap_t: -1.0, minimap_rect: Rect::new(0.0, 0.0, 0.0, 0.0), - tool_rects: Vec::new(), end_button: Rect::new(0.0, 0.0, 0.0, 0.0), lang_rects: Vec::new(), hand_anim: None, + open_cat: None, + tip: None, + touch_btns: Vec::new(), + hud_h: 0.0, } } @@ -311,11 +361,32 @@ impl Ui { if self.show_help { return UiAction::CloseHelp; } - for (i, r) in self.tool_rects.iter().enumerate() { - if r.contains(p) { - return UiAction::SelectTool(TOOLS[i]); - } + if let Some(&(_, b)) = self.touch_btns.iter().find(|(r, _)| r.contains(p)) { + return match b { + TBtn::Menu => { + self.open_cat = None; + UiAction::OpenMenu + } + TBtn::Tool(i) => { + self.open_cat = None; + self.tip = Some((i, get_time())); + UiAction::SelectTool(TOOLS[i]) + } + TBtn::Cat(c) => { + let tools = CATS[c].1; + if tools.len() == 1 { + self.open_cat = None; + self.tip = Some((tools[0], get_time())); + UiAction::SelectTool(TOOLS[tools[0]]) + } else { + self.open_cat = if self.open_cat == Some(c) { None } else { Some(c) }; + UiAction::Consumed + } + } + }; } + // a tap elsewhere closes an open category row + self.open_cat = None; if self.minimap_rect.contains(p) { let (wx, wy) = self.minimap_world(x, y); return UiAction::MinimapJump(wx, wy); @@ -333,61 +404,74 @@ impl Ui { let sh = sh(); let (mx, my) = mouse(); - // ---------------- top bar (everything on the character grid) + // ---------------- top bar: segments flow over up to three lines on narrow screens let cols = (sw / CW()).floor() as i32; - gfx.window(0.0, 0.0, cols, 3, S(), false, FRAME, BG); - self.rects.push(Rect::new(0.0, 0.0, sw, 3.0 * CH())); - let y = CH(); - let mut c = 2usize; // current column - let col_x = |c: usize| c as f32 * CW(); - let put = |s: &str, color: Color, c: &mut usize| { - gfx.text(s, col_x(*c), y, S(), color); - *c += s.chars().count(); - }; - let bar = |frac: f32, color: Color, c: &mut usize| { - ascii_bar(gfx, col_x(*c), y, 6, frac, color, S()); - *c += 6; - }; - put(&tr("hud.gold"), GOLD, &mut c); - c += 1; - put(&format!("{}/{}", g.gold as i32, g.gold_cap() as i32), TXT, &mut c); - c += 2; - put(&tr("hud.mana"), MANA, &mut c); - c += 1; - bar(g.mana / MANA_MAX, MANA, &mut c); - c += 1; - put(&format!("{:<3}", g.mana as i32), TXT, &mut c); - c += 2; - put(&tr("hud.core"), RED, &mut c); - c += 1; - bar(g.core_hp / CORE_HP, RED, &mut c); - c += 2; - put(&tr("hud.gate"), BLUE, &mut c); - c += 1; - bar(g.gate_hp / GATE_HP, BLUE, &mut c); - c += 2; - let cap = g.counts[1].min(30); - let mut segs: Vec<(String, Color, String)> = vec![ - (tr("hud.creatures"), TXT, format!("{}/{}", g.creature_count(), cap)), - (tr("hud.workers"), TXT, g.worker_count().to_string()), - ]; - let wave_col = if g.wave_timer < 30.0 { RED } else { TXT }; - segs.push((trf("hud.wave", &[(g.wave + 1).to_string()]), wave_col, mmss(g.wave_timer))); - segs.push((tr("hud.army"), TXT, (g.army_value() as i32).to_string())); - if !g.hand.is_empty() { - segs.push((tr("hud.hand"), FRAME_HI, format!("{}/8", g.hand.len()))); + enum Part { + T(String, Color), + Bar(f32, Color), + Gap, } + let txt = |s: String, c: Color| Part::T(s, c); + let cap = g.counts[1].min(30); + let wave_col = if g.wave_timer < 30.0 { RED } else { TXT }; let vol = if settings.muted { tr("hud.off") } else { format!("{}%", (settings.volume * 100.0).round() as i32) }; - segs.push(("\u{266a}".to_string(), DIM, vol)); - for (label, lc, value) in segs { - // only what still fits into the bar - if c + label.chars().count() + value.chars().count() + 2 > cols as usize - 1 { + let mut segs: Vec> = vec![ + vec![txt(tr("hud.gold"), GOLD), Part::Gap, txt(format!("{}/{}", g.gold as i32, g.gold_cap() as i32), TXT)], + vec![txt(tr("hud.mana"), MANA), Part::Gap, Part::Bar(g.mana / MANA_MAX, MANA), Part::Gap, txt(format!("{:<3}", g.mana as i32), TXT)], + vec![txt(tr("hud.core"), RED), Part::Gap, Part::Bar(g.core_hp / CORE_HP, RED)], + vec![txt(tr("hud.gate"), BLUE), Part::Gap, Part::Bar(g.gate_hp / GATE_HP, BLUE)], + vec![txt(tr("hud.creatures"), TXT), Part::Gap, txt(format!("{}/{}", g.creature_count(), cap), TXT)], + vec![txt(tr("hud.workers"), TXT), Part::Gap, txt(g.worker_count().to_string(), TXT)], + vec![txt(trf("hud.wave", &[(g.wave + 1).to_string()]), wave_col), Part::Gap, txt(mmss(g.wave_timer), TXT)], + vec![txt(tr("hud.army"), TXT), Part::Gap, txt((g.army_value() as i32).to_string(), TXT)], + ]; + if !g.hand.is_empty() { + segs.push(vec![txt(tr("hud.hand"), FRAME_HI), Part::Gap, txt(format!("{}/8", g.hand.len()), TXT)]); + } + segs.push(vec![txt("\u{266a}".to_string(), DIM), Part::Gap, txt(vol, DIM)]); + let width = |seg: &Vec| -> usize { + seg.iter().map(|p| match p { + Part::T(s, _) => s.chars().count(), + Part::Bar(..) => 6, + Part::Gap => 1, + }).sum() + }; + // place the segments: (line, column) + let mut placed: Vec<(usize, usize)> = Vec::new(); + let (mut line, mut c) = (0usize, 2usize); + for seg in &segs { + let w = width(seg); + if c > 2 && c + w > cols as usize - 1 { + line += 1; + c = 2; + } + if line >= 3 || c + w > cols as usize - 1 { break; } - put(&label, lc, &mut c); - c += 1; - put(&value, if lc == DIM { DIM } else { TXT }, &mut c); - c += 2; + placed.push((line, c)); + c += w + 2; + } + let lines = placed.iter().map(|p| p.0).max().unwrap_or(0) + 1; + let hud_rows = lines as i32 + 2; + self.hud_h = hud_rows as f32 * CH(); + gfx.window(0.0, 0.0, cols, hud_rows, S(), false, FRAME, BG); + self.rects.push(Rect::new(0.0, 0.0, sw, self.hud_h)); + for (seg, &(l, c0)) in segs.iter().zip(placed.iter()) { + let y = CH() * (1 + l) as f32; + let mut c = c0; + for p in seg { + match p { + Part::T(s, col) => { + gfx.text(s, c as f32 * CW(), y, S(), *col); + c += s.chars().count(); + } + Part::Bar(f, col) => { + ascii_bar(gfx, c as f32 * CW(), y, 6, *f, *col, S()); + c += 6; + } + Part::Gap => c += 1, + } + } } // ---------------- toolbar: buttons of 6 x 3 characters, icons and labels on the grid @@ -398,45 +482,88 @@ impl Ui { let tb_rows = (tb_h / CH()).floor() as i32; gfx.window(0.0, tb_y, cols, tb_rows, S(), false, FRAME, BG); self.rects.push(Rect::new(0.0, tb_y, sw, tb_h)); - self.tool_rects.clear(); + self.touch_btns.clear(); let mut hover_tool = None; - let bw = 6.0 * CW(); let px = K(); // one pixel of the font, times the factor - for (i, &t) in TOOLS.iter().enumerate() { - let bx = CW() + i as f32 * bw; - let by = tb_y + CH(); - if bx + bw > sw - CW() { - break; - } + let pulse = (now * 2.5) as i64 % 2 == 0; + // one toolbar button: icon, optional key, label in small text + let tool_button = |bx: f32, by: f32, bw: f32, icon_id: u32, key: &str, label: &str, sel: bool, hovered: bool| { let r = Rect::new(bx + px, by, bw - 2.0 * px, 3.0 * CH()); - self.tool_rects.push(r); - let hovered = r.contains(vec2(mx, my)); - if hovered { - hover_tool = Some(i); - } - let sel = self.tool == t; draw_rectangle(r.x, r.y, r.w, r.h, if sel { Color::new(0.42, 0.26, 0.1, 1.0) } else if hovered { Color::new(0.18, 0.13, 0.1, 1.0) } else { Color::new(0.1, 0.07, 0.07, 1.0) }); - // the active tool: pulsing frame and an arrow in the toolbar frame above it - let pulse = (now * 2.5) as i64 % 2 == 0; + // the active tool: pulsing frame let frame = if sel { if pulse { WHITE } else { FRAME_HI } } else { Color::new(0.35, 0.28, 0.2, 1.0) }; draw_rectangle_lines(r.x, r.y, r.w, r.h, if sel { 3.0 * px } else { 2.0 * px }, frame); - if sel { - gfx.glyph(0x1F, bx + CW() * 2.5, tb_y, S(), if pulse { WHITE } else { FRAME_HI }); - } - let ti = tool_info(t); // icon: the 16 px atlas cell at 2*F, centred on the grid let icon = 32.0 * px; - gfx.draw_cell(ti.icon, bx + (bw - icon) * 0.5, by + 4.0 * px, icon, icon, WHITE); - gfx.text(&keys[i], bx + 3.0 * px, by + 3.0 * px, SMALL(), if sel { FRAME_HI } else { DIM }); + gfx.draw_cell(icon_id, bx + (bw - icon) * 0.5, by + 4.0 * px, icon, icon, WHITE); + if !key.is_empty() { + gfx.text(key, bx + 3.0 * px, by + 3.0 * px, SMALL(), if sel { FRAME_HI } else { DIM }); + } // label in small text, centred on whole small-character cells - let label: String = ti.label.chars().take(11).collect(); + let maxc = ((bw - 2.0 * px) / (GW * SMALL())).floor() as usize; + let label: String = label.chars().take(maxc.min(11)).collect(); let lw = gfx.measure(&label, SMALL()); let lx = bx + ((bw - lw) / (GW * SMALL()) / 2.0).floor() * GW * SMALL(); gfx.text(&label, lx, by + 2.0 * CH() + 4.0 * px, SMALL(), if sel { FRAME_HI } else { TXT }); + r + }; + let mut popup_top = tb_y; + // categories + menu button; a category with several tools opens a row above + // (the same layout for mouse and touch; on the desktop the keys still select tools directly) + let n = CATS.len() + 1; + let bcols = (((cols - 2) as usize / n) as f32).clamp(4.0, 8.0); + let bw = bcols * CW(); + let x0 = grid_center(sw, bw * n as f32); + let by = tb_y + CH(); + let cur = TOOLS.iter().position(|&t| t == self.tool).unwrap_or(0); + for (c, (key, tools)) in CATS.iter().enumerate() { + let bx = x0 + c as f32 * bw; + let has = tools.contains(&cur); + let shown = if has { cur } else { tools[0] }; + let ti = tool_info(TOOLS[shown]); + let label = if tools.len() == 1 { ti.label.clone() } else { tr(key) }; + let hovered = !touch_ui() && Rect::new(bx + px, by, bw - 2.0 * px, 3.0 * CH()).contains(vec2(mx, my)); + // single tools show their key (desktop) and their description when hovered + let key = if tools.len() == 1 && !touch_ui() { keys[tools[0]].as_str() } else { "" }; + if hovered && tools.len() == 1 { + hover_tool = Some(tools[0]); + } + let r = tool_button(bx, by, bw, ti.icon, key, &label, has, self.open_cat == Some(c) || hovered); + if has { + gfx.glyph(0x1F, bx + (bw - CW()) * 0.5, tb_y, S(), if pulse { WHITE } else { FRAME_HI }); + } + if tools.len() > 1 { + // small arrow: this button opens a row + gfx.glyph(0x1E, r.x + r.w - GW * SMALL() - 2.0 * px, r.y + 2.0 * px, SMALL(), if self.open_cat == Some(c) { FRAME_HI } else { DIM }); + } + self.touch_btns.push((r, TBtn::Cat(c))); + } + // menu button + let bx = x0 + CATS.len() as f32 * bw; + let r = Rect::new(bx + px, by, bw - 2.0 * px, 3.0 * CH()); + let hovered = !touch_ui() && r.contains(vec2(mx, my)); + draw_rectangle(r.x, r.y, r.w, r.h, if hovered { Color::new(0.18, 0.13, 0.1, 1.0) } else { Color::new(0.1, 0.07, 0.07, 1.0) }); + draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0 * px, Color::new(0.35, 0.28, 0.2, 1.0)); + gfx.glyph(0xF0, bx + (bw - GW * TITLE()) * 0.5, by + 2.0 * px, TITLE(), FRAME_HI); + let ml = tr("cat.menu"); + let maxc = ((bw - 2.0 * px) / (GW * SMALL())).floor() as usize; + let ml: String = ml.chars().take(maxc).collect(); + gfx.text(&ml, bx + (bw - gfx.measure(&ml, SMALL())) * 0.5, by + 2.0 * CH() + 4.0 * px, SMALL(), TXT); + self.touch_btns.push((r, TBtn::Menu)); + // open category row: (category, x0, button width, columns) + let popup = self.open_cat.map(|c| (c, x0, bw, bcols)); + // touch: after choosing a tool its description is shown for a moment + if let Some((i, t)) = self.tip.filter(|_| touch_ui()) { + if now - t < 3.5 && self.open_cat.is_none() { + hover_tool = Some(i); + } else if now - t >= 3.5 { + self.tip = None; + } } // ---------------- minimap: whole device pixels per tile - let ms = ((14.0 * CW()) / mw().max(mh()) as f32).floor().max(1.0); + let mm_size = if touch_ui() { 10.0 } else { 14.0 }; + let ms = ((mm_size * CW()) / mw().max(mh()) as f32).floor().max(1.0); let (map_w, map_h) = (mw() as f32 * ms, mh() as f32 * ms); let mm_cols = ((map_w / CW()).ceil() as i32 + 2).max(10); let mm_rows = (map_h / CH()).ceil() as i32 + 2; @@ -500,7 +627,7 @@ impl Ui { // ---------------- info panel if !info.is_empty() { - let icols = 30; + let icols = 30.min(cols - 2); let mut lines: Vec<(String, Color)> = Vec::new(); for (s, c) in info.iter().skip(1) { for l in wrap(s, icols as usize - 4) { @@ -509,7 +636,7 @@ impl Ui { } let irows = lines.len() as i32 + 2; let ix = sw - icols as f32 * CW(); - let iy = 3.0 * CH(); + let iy = self.hud_h; gfx.window_titled(ix, iy, icols, irows, S(), false, FRAME, BG, &info[0].0, info[0].1); for (k, (l, c)) in lines.iter().enumerate() { gfx.text(l, ix + CW() * 2.0, iy + CH() * (k as f32 + 1.0), S(), *c); @@ -521,7 +648,8 @@ impl Ui { if !recent.is_empty() { let mcols = (((sw - mm_w) / CW()) as i32 - 2).clamp(20, 70); let mut lines: Vec<(String, f32)> = Vec::new(); - for (m, t) in recent.iter().rev().take(5).rev() { + let keep = if touch_ui() { 3 } else { 5 }; + for (m, t) in recent.iter().rev().take(keep).rev() { let a = (1.0 - ((now - t) as f32 - 11.0) / 3.0).clamp(0.2, 1.0); for l in wrap(&trf(m.key, &m.args), mcols as usize - 4) { lines.push((l, a)); @@ -539,20 +667,51 @@ impl Ui { self.log.drain(0..50); } + // the open row of tools (wraps into several rows if needed) + if let Some((c, x0, bw, bcols)) = popup { + let tools = CATS[c].1; + let per_row = (((cols - 2) as f32 / bcols).floor() as usize).max(1).min(tools.len()); + let nrows = (tools.len() + per_row - 1) / per_row; + let pw = per_row as f32 * bw + 2.0 * CW(); + let ph = (nrows as f32 * 3.0 + 2.0) * CH(); + let pcols = (pw / CW()) as i32; + let prows = (ph / CH()) as i32; + let px0 = ((x0 + c as f32 * bw + bw * 0.5 - pw * 0.5) / CW()).floor().clamp(0.0, ((sw - pw) / CW()).floor()) * CW(); + let py0 = tb_y - ph; + gfx.window_titled(px0, py0, pcols, prows, S(), false, FRAME_HI, BG, &tr(CATS[c].0), FRAME_HI); + self.rects.push(Rect::new(px0, py0, pw, ph)); + for (k, &i) in tools.iter().enumerate() { + let bx = px0 + CW() + (k % per_row) as f32 * bw; + let by = py0 + CH() + (k / per_row) as f32 * 3.0 * CH(); + let ti = tool_info(TOOLS[i]); + let hovered = !touch_ui() && Rect::new(bx + px, by, bw - 2.0 * px, 3.0 * CH()).contains(vec2(mx, my)); + if hovered { + hover_tool = Some(i); + } + let key = if touch_ui() { "" } else { keys[i].as_str() }; + let r = tool_button(bx, by, bw, ti.icon, key, &ti.label, self.tool == TOOLS[i], hovered); + self.touch_btns.push((r, TBtn::Tool(i))); + } + popup_top = py0; + } + // ---------------- Tooltip if let Some(i) = hover_tool { let t = TOOLS[i]; let ti = tool_info(t); - let tcols = 34; + let tcols = 34.min(cols as usize); let mut lines: Vec<(String, Color)> = wrap(&ti.desc, tcols - 4).into_iter().map(|l| (l, TXT)).collect(); let cost = tool_cost(t, g); if !cost.is_empty() { lines.push((trf("ui.cost", &[cost]), GOLD)); } - lines.push((trf("ui.key", &[keys[i].clone()]), DIM)); + if !touch_ui() { + lines.push((trf("ui.key", &[keys[i].clone()]), DIM)); + } let rows = lines.len() as i32 + 2; - let tx = (((mx - tcols as f32 * CW() * 0.5) / CW()).floor() * CW()).clamp(0.0, sw - tcols as f32 * CW()); - let ty = tb_y - rows as f32 * CH(); + let anchor = if touch_ui() { sw * 0.5 } else { mx }; + let tx = (((anchor - tcols as f32 * CW() * 0.5) / CW()).floor() * CW()).clamp(0.0, (sw - tcols as f32 * CW()).max(0.0)); + let ty = popup_top - rows as f32 * CH(); gfx.window_titled(tx, ty, tcols as i32, rows, S(), false, FRAME_HI, BG, &ti.name, FRAME_HI); for (k, (l, c)) in lines.iter().enumerate() { gfx.text(l, tx + CW() * 2.0, ty + CH() * (k as f32 + 1.0), S(), *c); @@ -584,18 +743,14 @@ impl Ui { } fn draw_help(&mut self, gfx: &Gfx) { - let keys: &[(&str, Color)] = &[ - ("help.goal", GOLD), - ("", TXT), - ("help.k_left", TXT), - ("help.k_right", TXT), - ("help.k_slap", TXT), - ("help.k_pan", TXT), - ("help.k_rot", TXT), - ("help.k_zoom", TXT), - ("help.k_tools", TXT), - ("help.k_misc", TXT), - ("help.k_misc2", TXT), + let controls: &[&str] = if touch_ui() { + &["help.t_tap", "help.t_drag", "help.t_two", "help.t_long", "help.t_double", "help.t_tools"] + } else { + &["help.k_left", "help.k_right", "help.k_slap", "help.k_pan", "help.k_rot", "help.k_zoom", "help.k_tools", "help.k_misc", "help.k_misc2"] + }; + let mut keys: Vec<(&str, Color)> = vec![("help.goal", GOLD), ("", TXT)]; + keys.extend(controls.iter().map(|k| (*k, TXT))); + keys.extend_from_slice(&[ ("", TXT), ("help.s1", TXT), ("help.s2", TXT), @@ -606,9 +761,19 @@ impl Ui { ("help.font", DIM), ("", TXT), ("help.start", FRAME_HI), - ]; - let lines: Vec<(String, Color)> = keys.iter().map(|(k, c)| (if k.is_empty() { String::new() } else { tr(k) }, *c)).collect(); - let cols = 74; + ]); + let cols = 74.min((sw() / CW()) as i32); + let mut lines: Vec<(String, Color)> = Vec::new(); + for (k, c) in &keys { + if k.is_empty() { + lines.push((String::new(), *c)); + } else { + let key = if *k == "help.start" && touch_ui() { "help.start_touch" } else { k }; + for l in wrap(&tr(key), cols as usize - 6) { + lines.push((l, *c)); + } + } + } let rows = lines.len() as i32 + 4; let sw = sw(); let sh = sh(); diff --git a/www/dc_plugin.js b/www/dc_plugin.js index 11c01ed..7b8badd 100644 --- a/www/dc_plugin.js +++ b/www/dc_plugin.js @@ -102,6 +102,17 @@ bytes(ptr, pending.length).set(pending); pending = null; }; + // browser text dialog (touch devices have no keyboard in the canvas); result via dc_storage_take + env.dc_prompt = function (tp, tl, dp, dl) { + var v = window.prompt(str(tp, tl), str(dp, dl)); + if (v === null || v === undefined) { pending = null; return -1; } + pending = new TextEncoder().encode(v); + return pending.length; + }; + // the device's main pointer is a finger (phones, tablets) + env.dc_coarse_pointer = function () { + try { return window.matchMedia("(pointer: coarse)").matches ? 1 : 0; } catch (e) { return 0; } + }; env.dc_storage_set = function (kp, kl, vp, vl) { try { window.localStorage.setItem(str(kp, kl), str(vp, vl)); } catch (e) { } };