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
+739 -147

No files matched your search

+2
View File
@@ -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) - Claim the **portal** → creatures: kobold, cave spider, shadow mage, stone ogre (depending on rooms)
- **Needs**: sleep, food, wages (payday); unhappy creatures leave - **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) - **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) - **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 - **Spells** (mana): lightning, heal, summon Pit Imp
- **Heroes**: knight, archer, priest, dwarf miner, paladin of light (they dig their way to the Soul Core) - **Heroes**: knight, archer, priest, dwarf miner, paladin of light (they dig their way to the Soul Core)
+17 -1
View File
@@ -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 | | Right drag, WASD, arrows, screen edge | Move the camera |
| Middle drag, Q/E, R/F | Rotate / tilt the camera | | Middle drag, Q/E, R/F | Rotate / tilt the camera |
| Mouse wheel | Zoom | | 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 | | P / Space | Pause |
| M, +/- | Sound on/off, volume | | M, +/- | Sound on/off, volume |
| G | Tile outlines on/off | | 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. 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 ## 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. 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.
+18
View File
@@ -365,3 +365,21 @@ cloud.err_rate = Zu viele Anfragen - bitte einen Moment warten.
cloud.err_full = Der Spielstand-Server ist voll. cloud.err_full = Der Spielstand-Server ist voll.
cloud.err_missing = Diesen Spielstand gibt es nicht mehr. cloud.err_missing = Diesen Spielstand gibt es nicht mehr.
cloud.err = Der Spielstand-Server meldet einen Fehler. 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
+18
View File
@@ -366,3 +366,21 @@ cloud.err_rate = Too many requests - please wait a moment.
cloud.err_full = The savegame server is full. cloud.err_full = The savegame server is full.
cloud.err_missing = This savegame no longer exists. cloud.err_missing = This savegame no longer exists.
cloud.err = The savegame server reported an error. 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
+191 -43
View File
@@ -14,6 +14,7 @@ mod settings;
mod sfx; mod sfx;
mod tracks; mod tracks;
mod sound; mod sound;
mod touch;
mod ui; mod ui;
mod xm; mod xm;
@@ -26,6 +27,7 @@ use menu::{Menu, MenuAction, Page};
use render3d::{Cam, Overlay, R3d}; use render3d::{Cam, Overlay, R3d};
use settings::{bindable_keys, Action, Settings}; use settings::{bindable_keys, Action, Settings};
use sound::SoundBank; use sound::SoundBank;
use touch::{Gesture, TouchInput};
use ui::{hover_info, Tool, Ui, UiAction, TOOLS}; use ui::{hover_info, Tool, Ui, UiAction, TOOLS};
const SETTINGS_KEY: &str = "dc.settings"; const SETTINGS_KEY: &str = "dc.settings";
@@ -54,6 +56,60 @@ struct Input {
rdrag: bool, rdrag: bool,
mlast: Option<(f32, f32)>, // middle button drag mlast: Option<(f32, f32)>, // middle button drag
minimap_drag: bool, 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) { fn new_game(seed: u32, s: &Settings) -> (Game, Cam) {
@@ -229,17 +285,44 @@ async fn main() {
let (game, cam) = new_game(seed, &settings); let (game, cam) = new_game(seed, &settings);
let mut app = App { game, cam, settings, seed, status: None }; let mut app = App { game, cam, settings, seed, status: None };
app.game.hints = app.settings.hints; 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 autosave_t = 0.0f32;
let mut cache_t = 0.0f32; let mut cache_t = 0.0f32;
loop { loop {
let dt = get_frame_time().min(0.1); let dt = get_frame_time().min(0.1);
let now = get_time(); 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; 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 // ---------------- savegame files: file picker result or dropped onto the window
let mut incoming: Option<Vec<u8>> = platform::take_file(); let mut incoming: Option<Vec<u8>> = platform::take_file();
for f in get_dropped_files() { for f in get_dropped_files() {
@@ -294,7 +377,8 @@ async fn main() {
menu.finish_capture(); 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; next_frame().await;
continue; continue;
} }
@@ -386,7 +470,7 @@ async fn main() {
move_cam(pan, 0.0, cam); move_cam(pan, 0.0, cam);
} }
let edge = 6.0; 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 { if mx < edge {
move_cam(-pan, 0.0, cam); move_cam(-pan, 0.0, cam);
} else if mx > screen_width() - edge { } else if mx > screen_width() - edge {
@@ -411,7 +495,7 @@ async fn main() {
if is_key_down(key(Action::TiltDown)) { if is_key_down(key(Action::TiltDown)) {
cam.pitch -= rot * 0.6; 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 { if let Some((lx, ly)) = inp.mlast {
let inv = if keys.invert_mouse { -1.0 } else { 1.0 }; let inv = if keys.invert_mouse { -1.0 } else { 1.0 };
cam.yaw -= (mx - lx) * 0.008 * keys.rotate_speed * inv; cam.yaw -= (mx - lx) * 0.008 * keys.rotate_speed * inv;
@@ -431,6 +515,22 @@ async fn main() {
cam.tx += before.x - after.x; cam.tx += before.x - after.x;
cam.tz += before.y - after.y; 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 // right button: drag = pan, click = drop
if is_mouse_button_pressed(MouseButton::Right) { if is_mouse_button_pressed(MouseButton::Right) {
inp.rdown = Some((mx, my)); inp.rdown = Some((mx, my));
@@ -463,8 +563,13 @@ async fn main() {
} }
} }
// ---------------- left button // ---------------- left button (touch: a finger touching the interface)
if is_mouse_button_pressed(MouseButton::Left) { 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 { if dialog {
match ui.click(ux, uy, &app.game) { match ui.click(ux, uy, &app.game) {
UiAction::SelectLanguage(code) => { UiAction::SelectLanguage(code) => {
@@ -525,6 +630,11 @@ async fn main() {
MenuAction::CloudDelete(id) => { MenuAction::CloudDelete(id) => {
cloud.delete(&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::Help => ui.show_help = true,
MenuAction::MainMenu => { MenuAction::MainMenu => {
if app.game.state == 0 { if app.game.state == 0 {
@@ -551,56 +661,87 @@ async fn main() {
(app.game, app.cam) = new_game(app.seed, &app.settings); (app.game, app.cam) = new_game(app.seed, &app.settings);
app.game.hints = app.settings.hints; app.game.hints = app.settings.hints;
} }
UiAction::OpenMenu => menu.page = Some(Page::Pause),
UiAction::CloseHelp | UiAction::SelectLanguage(_) | UiAction::Consumed => {} UiAction::CloseHelp | UiAction::SelectLanguage(_) | UiAction::Consumed => {}
UiAction::None => { UiAction::None => {
if app.game.state == 0 { if touch_mode {
match (ui.tool, hover_tile) { // touch: area tools start their rectangle, everything else acts on the tap
(Tool::Dig | Tool::Room(_) | Tool::Sell, Some(t)) => inp.ldown = Some(t), if area_tool(ui.tool) && app.game.state == 0 {
(Tool::Hand, _) => { inp.ldown = hover_tile;
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 { } else {
app.game.pickup(j); 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);
}
(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),
_ => {}
}
} }
} }
} }
} }
} }
let held = if touch_mode { touch.finger().is_some() } else { is_mouse_button_down(MouseButton::Left) };
if inp.minimap_drag { 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); let (x, y) = ui.minimap_world(ux, uy);
app.cam.tx = x; app.cam.tx = x;
app.cam.tz = y; app.cam.tz = y;
} else if !is_mouse_button_down(MouseButton::Left) { } else if !held {
inp.minimap_drag = false; inp.minimap_drag = false;
} }
} }
if is_mouse_button_released(MouseButton::Left) {
if let Some((sx, sy)) = inp.ldown.take() { // ---------------- touch gestures on the game world
if let (true, Some((ex, ey))) = (playing, hover_tile) { if touch_mode {
match ui.tool { let d = ui::dpr();
Tool::Dig => { for g in &gestures {
let on = !app.game.map.t[idx(sx, sy)].marked; match *g {
app.game.mark_rect(sx, sy, ex, ey, on); 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);
}
}
} }
Tool::Room(r) => app.game.build_rect(r, sx, sy, ex, ey),
Tool::Sell => app.game.sell_rect(sx, sy, ex, ey),
_ => {} _ => {}
} }
} }
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);
}
} }
} }
app.cam.tx = app.cam.tx.clamp(0.0, mw() as f32); app.cam.tx = app.cam.tx.clamp(0.0, mw() as f32);
@@ -643,15 +784,20 @@ async fn main() {
ui.log.push((m, now)); 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 next_frame().await
} }
} }
#[allow(clippy::too_many_arguments)] #[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) { // pointer: CSS pixels of the mouse or finger; touch: hover highlight and information only
let (mx, my) = mouse_position(); // while a finger is down or for a long-pressed point
let (ux, uy) = ui::mouse(); 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 blocked = menu.open() || ui.show_help || ui.show_langsel;
let over_ui = blocked || ui.over_ui(ux, uy); 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)); 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, .. }) Some(Page::Title) | Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. })
); );
if !title_screen { if !title_screen {
let info = if over_ui { let info = if over_ui || (touch && inp.inspect.is_none()) {
Vec::new() Vec::new()
} else { } else {
let ent = r3d.pick_ent(&app.game, &app.cam, mx, my, false); 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 { if !menu.open() || ui.show_help || ui.show_langsel {
ui.draw_dialogs(&app.game, gfx); ui.draw_dialogs(&app.game, gfx);
} }
if !touch {
ui.draw_cursor(gfx, &app.game, now, blocked); ui.draw_cursor(gfx, &app.game, now, blocked);
}
} }
+13 -1
View File
@@ -40,6 +40,7 @@ pub enum Item {
RenderScale, RenderScale,
GameSpeed, GameSpeed,
Hints, Hints,
TouchUi,
} }
fn tab_items(tab: usize) -> &'static [Item] { fn tab_items(tab: usize) -> &'static [Item] {
@@ -48,7 +49,7 @@ fn tab_items(tab: usize) -> &'static [Item] {
0 => &[Language, Volume, MusicVolume, Mute], 0 => &[Language, Volume, MusicVolume, Mute],
1 => &[RotateSpeed, ZoomSpeed, EdgeScroll, InvertMouse], 1 => &[RotateSpeed, ZoomSpeed, EdgeScroll, InvertMouse],
2 => &[Outlines, WallDetail, Lights, UiScale, RenderScale], 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::RenderScale => "set.render_scale",
Item::GameSpeed => "set.game_speed", Item::GameSpeed => "set.game_speed",
Item::Hints => "set.hints", 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::RenderScale => format!("1/{}", s.render_scale.max(1)),
Item::GameSpeed => format!("{}x", s.game_speed as i32), Item::GameSpeed => format!("{}x", s.game_speed as i32),
Item::Hints => onoff(s.hints), 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::<Vec<u8>>(), s.render_scale.clamp(1, 16), d), Item::RenderScale => s.render_scale = cycle(&(1u8..=16).collect::<Vec<u8>>(), s.render_scale.clamp(1, 16), d),
Item::GameSpeed => s.game_speed = cycle(&[1.0f32, 2.0], s.game_speed, d), Item::GameSpeed => s.game_speed = cycle(&[1.0f32, 2.0], s.game_speed, d),
Item::Hints => s.hints = !s.hints, 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), Scroll(i32),
FileSave, FileSave,
FileLoad, FileLoad,
NameField,
} }
pub enum MenuAction { pub enum MenuAction {
@@ -194,6 +203,7 @@ pub enum MenuAction {
CloudSave(Option<String>, String), // (id to overwrite, name) CloudSave(Option<String>, String), // (id to overwrite, name)
CloudLoad(String), CloudLoad(String),
CloudDelete(String), CloudDelete(String),
EditName, // the name field was tapped (touch: ask with a browser dialog)
} }
pub struct Menu { pub struct Menu {
@@ -364,6 +374,7 @@ impl Menu {
self.scroll_by(d * self.rows_visible.max(1) as i32, cloud.list.len()); self.scroll_by(d * self.rows_visible.max(1) as i32, cloud.list.len());
MenuAction::Consumed MenuAction::Consumed
} }
Some(Btn::NameField) => MenuAction::EditName,
Some(Btn::FileSave) => MenuAction::FileSave, Some(Btn::FileSave) => MenuAction::FileSave,
Some(Btn::FileLoad) => MenuAction::FileLoad, Some(Btn::FileLoad) => MenuAction::FileLoad,
} }
@@ -599,6 +610,7 @@ impl Menu {
let fw = (inner_w - (fx - inner_x) - bw - CW()).max(CW() * 8.0); 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(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); 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 blink = if (get_time() * 2.0) as i64 % 2 == 0 { "_" } else { " " };
let maxc = ((fw - CW() * 2.0) / CW()).max(1.0) as usize; let maxc = ((fw - CW() * 2.0) / CW()).max(1.0) as usize;
let shown: String = { let shown: String = {
+26
View File
@@ -14,6 +14,8 @@ mod imp {
extern "C" { extern "C" {
fn dc_storage_get(key: *const u8, key_len: u32) -> i32; fn dc_storage_get(key: *const u8, key_len: u32) -> i32;
fn dc_storage_take(dst: *mut u8); 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_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_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); 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<String> {
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) { 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) } 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<String> { pub fn storage_get(key: &str) -> Option<String> {
STORE.with(|s| s.borrow().get(key).cloned()) STORE.with(|s| s.borrow().get(key).cloned())
} }
pub fn prompt(_title: &str, _default: &str) -> Option<String> {
None
}
pub fn coarse_pointer() -> bool {
false
}
pub fn storage_set(key: &str, val: &str) { pub fn storage_set(key: &str, val: &str) {
STORE.with(|s| s.borrow_mut().insert(key.to_string(), val.to_string())); STORE.with(|s| s.borrow_mut().insert(key.to_string(), val.to_string()));
} }
+176
View File
@@ -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<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 };
}
}
+267 -102
View File
@@ -15,12 +15,26 @@ use macroquad::prelude::*;
// is the interface factor (automatic from the resolution or fixed in the settings). // is the interface factor (automatic from the resolution or fixed in the settings).
thread_local! { thread_local! {
static UI_FACTOR_SETTING: std::cell::Cell<u8> = std::cell::Cell::new(0); static UI_FACTOR_SETTING: std::cell::Cell<u8> = std::cell::Cell::new(0);
static TOUCH: std::cell::Cell<bool> = 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) { pub fn set_ui_factor(f: u8) {
UI_FACTOR_SETTING.with(|c| c.set(f)); 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 // device pixels per CSS pixel
pub fn dpr() -> f32 { pub fn dpr() -> f32 {
screen_dpi_scale() screen_dpi_scale()
@@ -33,6 +47,9 @@ pub fn sh() -> f32 {
(screen_height() * dpr()).round() (screen_height() * dpr()).round()
} }
pub fn mouse() -> (f32, f32) { pub fn mouse() -> (f32, f32) {
if touch_ui() {
return POINTER.with(|c| c.get());
}
let (x, y) = mouse_position(); let (x, y) = mouse_position();
(x * dpr(), y * dpr()) (x * dpr(), y * dpr())
} }
@@ -44,7 +61,15 @@ pub fn F() -> u32 {
if set > 0 { if set > 0 {
return set as u32; 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 // text scales: 1x, 2x and 4x the glyph size, times F
#[allow(non_snake_case)] #[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 { pub enum UiAction {
None, None,
OpenMenu,
SelectTool(Tool), SelectTool(Tool),
MinimapJump(f32, f32), MinimapJump(f32, f32),
CloseHelp, CloseHelp,
@@ -226,10 +269,14 @@ pub struct Ui {
minimap_tex: Texture2D, minimap_tex: Texture2D,
minimap_t: f64, minimap_t: f64,
minimap_rect: Rect, minimap_rect: Rect,
tool_rects: Vec<Rect>,
end_button: Rect, end_button: Rect,
lang_rects: Vec<(Rect, String)>, lang_rects: Vec<(Rect, String)>,
pub hand_anim: Option<(u8, f64)>, // 1 = backhand, 2 = punch, start time pub hand_anim: Option<(u8, f64)>, // 1 = backhand, 2 = punch, start time
// touch interface
open_cat: Option<usize>,
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<String> { fn wrap(s: &str, cols: usize) -> Vec<String> {
@@ -272,10 +319,13 @@ impl Ui {
minimap_tex: tex, minimap_tex: tex,
minimap_t: -1.0, minimap_t: -1.0,
minimap_rect: Rect::new(0.0, 0.0, 0.0, 0.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), end_button: Rect::new(0.0, 0.0, 0.0, 0.0),
lang_rects: Vec::new(), lang_rects: Vec::new(),
hand_anim: None, 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 { if self.show_help {
return UiAction::CloseHelp; return UiAction::CloseHelp;
} }
for (i, r) in self.tool_rects.iter().enumerate() { if let Some(&(_, b)) = self.touch_btns.iter().find(|(r, _)| r.contains(p)) {
if r.contains(p) { return match b {
return UiAction::SelectTool(TOOLS[i]); 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) { if self.minimap_rect.contains(p) {
let (wx, wy) = self.minimap_world(x, y); let (wx, wy) = self.minimap_world(x, y);
return UiAction::MinimapJump(wx, wy); return UiAction::MinimapJump(wx, wy);
@@ -333,61 +404,74 @@ impl Ui {
let sh = sh(); let sh = sh();
let (mx, my) = mouse(); 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; let cols = (sw / CW()).floor() as i32;
gfx.window(0.0, 0.0, cols, 3, S(), false, FRAME, BG); enum Part {
self.rects.push(Rect::new(0.0, 0.0, sw, 3.0 * CH())); T(String, Color),
let y = CH(); Bar(f32, Color),
let mut c = 2usize; // current column Gap,
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())));
} }
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) }; 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)); let mut segs: Vec<Vec<Part>> = vec![
for (label, lc, value) in segs { vec![txt(tr("hud.gold"), GOLD), Part::Gap, txt(format!("{}/{}", g.gold as i32, g.gold_cap() as i32), TXT)],
// only what still fits into the bar 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)],
if c + label.chars().count() + value.chars().count() + 2 > cols as usize - 1 { 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<Part>| -> 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; break;
} }
put(&label, lc, &mut c); placed.push((line, c));
c += 1; c += w + 2;
put(&value, if lc == DIM { DIM } else { TXT }, &mut c); }
c += 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 // ---------------- 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; let tb_rows = (tb_h / CH()).floor() as i32;
gfx.window(0.0, tb_y, cols, tb_rows, S(), false, FRAME, BG); 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.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 mut hover_tool = None;
let bw = 6.0 * CW();
let px = K(); // one pixel of the font, times the factor 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 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; 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());
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
let frame = if sel { if pulse { WHITE } else { FRAME_HI } } else { Color::new(0.35, 0.28, 0.2, 1.0) }; 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); 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 // icon: the 16 px atlas cell at 2*F, centred on the grid
let icon = 32.0 * px; let icon = 32.0 * px;
gfx.draw_cell(ti.icon, bx + (bw - icon) * 0.5, by + 4.0 * px, icon, icon, WHITE); gfx.draw_cell(icon_id, 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 }); 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 // 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 lw = gfx.measure(&label, SMALL());
let lx = bx + ((bw - lw) / (GW * SMALL()) / 2.0).floor() * GW * 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 }); 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 // ---------------- 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 (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_cols = ((map_w / CW()).ceil() as i32 + 2).max(10);
let mm_rows = (map_h / CH()).ceil() as i32 + 2; let mm_rows = (map_h / CH()).ceil() as i32 + 2;
@@ -500,7 +627,7 @@ impl Ui {
// ---------------- info panel // ---------------- info panel
if !info.is_empty() { if !info.is_empty() {
let icols = 30; let icols = 30.min(cols - 2);
let mut lines: Vec<(String, Color)> = Vec::new(); let mut lines: Vec<(String, Color)> = Vec::new();
for (s, c) in info.iter().skip(1) { for (s, c) in info.iter().skip(1) {
for l in wrap(s, icols as usize - 4) { for l in wrap(s, icols as usize - 4) {
@@ -509,7 +636,7 @@ impl Ui {
} }
let irows = lines.len() as i32 + 2; let irows = lines.len() as i32 + 2;
let ix = sw - icols as f32 * CW(); 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); 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() { for (k, (l, c)) in lines.iter().enumerate() {
gfx.text(l, ix + CW() * 2.0, iy + CH() * (k as f32 + 1.0), S(), *c); 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() { if !recent.is_empty() {
let mcols = (((sw - mm_w) / CW()) as i32 - 2).clamp(20, 70); let mcols = (((sw - mm_w) / CW()) as i32 - 2).clamp(20, 70);
let mut lines: Vec<(String, f32)> = Vec::new(); 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); 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) { for l in wrap(&trf(m.key, &m.args), mcols as usize - 4) {
lines.push((l, a)); lines.push((l, a));
@@ -539,20 +667,51 @@ impl Ui {
self.log.drain(0..50); 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 // ---------------- Tooltip
if let Some(i) = hover_tool { if let Some(i) = hover_tool {
let t = TOOLS[i]; let t = TOOLS[i];
let ti = tool_info(t); 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 mut lines: Vec<(String, Color)> = wrap(&ti.desc, tcols - 4).into_iter().map(|l| (l, TXT)).collect();
let cost = tool_cost(t, g); let cost = tool_cost(t, g);
if !cost.is_empty() { if !cost.is_empty() {
lines.push((trf("ui.cost", &[cost]), GOLD)); lines.push((trf("ui.cost", &[cost]), GOLD));
} }
if !touch_ui() {
lines.push((trf("ui.key", &[keys[i].clone()]), DIM)); lines.push((trf("ui.key", &[keys[i].clone()]), DIM));
}
let rows = lines.len() as i32 + 2; 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 anchor = if touch_ui() { sw * 0.5 } else { mx };
let ty = tb_y - rows as f32 * CH(); 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); 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() { for (k, (l, c)) in lines.iter().enumerate() {
gfx.text(l, tx + CW() * 2.0, ty + CH() * (k as f32 + 1.0), S(), *c); 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) { fn draw_help(&mut self, gfx: &Gfx) {
let keys: &[(&str, Color)] = &[ let controls: &[&str] = if touch_ui() {
("help.goal", GOLD), &["help.t_tap", "help.t_drag", "help.t_two", "help.t_long", "help.t_double", "help.t_tools"]
("", TXT), } else {
("help.k_left", TXT), &["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"]
("help.k_right", TXT), };
("help.k_slap", TXT), let mut keys: Vec<(&str, Color)> = vec![("help.goal", GOLD), ("", TXT)];
("help.k_pan", TXT), keys.extend(controls.iter().map(|k| (*k, TXT)));
("help.k_rot", TXT), keys.extend_from_slice(&[
("help.k_zoom", TXT),
("help.k_tools", TXT),
("help.k_misc", TXT),
("help.k_misc2", TXT),
("", TXT), ("", TXT),
("help.s1", TXT), ("help.s1", TXT),
("help.s2", TXT), ("help.s2", TXT),
@@ -606,9 +761,19 @@ impl Ui {
("help.font", DIM), ("help.font", DIM),
("", TXT), ("", TXT),
("help.start", FRAME_HI), ("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.min((sw() / CW()) as i32);
let cols = 74; 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 rows = lines.len() as i32 + 4;
let sw = sw(); let sw = sw();
let sh = sh(); let sh = sh();
+11
View File
@@ -102,6 +102,17 @@
bytes(ptr, pending.length).set(pending); bytes(ptr, pending.length).set(pending);
pending = null; 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) { env.dc_storage_set = function (kp, kl, vp, vl) {
try { window.localStorage.setItem(str(kp, kl), str(vp, vl)); } catch (e) { } try { window.localStorage.setItem(str(kp, kl), str(vp, vl)); } catch (e) { }
}; };