Add mobile touch controls: virtual joystick, look drag, and Y-axis buttons

This commit is contained in:
Greg Shuflin
2026-03-17 15:24:03 -07:00
parent f8434db2c1
commit 7899000349
5 changed files with 427 additions and 23 deletions
Generated
+1
View File
@@ -2206,6 +2206,7 @@ dependencies = [
"env_logger",
"getrandom",
"glam",
"js-sys",
"log",
"pollster",
"rand",
+1
View File
@@ -22,6 +22,7 @@ console_error_panic_hook = "0.1"
console_log = "1"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = [
"Document",
"Window",
+338 -23
View File
@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>wgpu demo</title>
<link data-trunk rel="rust" data-wasm-opt="0" />
<style>
@@ -12,10 +13,12 @@
height: 100%;
overflow: hidden;
background: #000;
touch-action: none;
}
canvas {
width: 100% !important;
height: 100% !important;
touch-action: none;
}
#help-overlay {
@@ -118,6 +121,15 @@
transform: translate(-50%, -50%);
}
/* Hide desktop controls info on touch devices */
@media (pointer: coarse) {
.desktop-controls { display: none !important; }
}
/* Hide mobile controls info on desktop */
@media (pointer: fine) {
.mobile-controls { display: none !important; }
}
#coord-bar {
position: fixed;
bottom: 25%;
@@ -167,37 +179,136 @@
#coord-bar .coord-unit {
color: rgba(255, 255, 255, 0.45);
}
/* Touch controls */
#touch-controls {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 5;
pointer-events: none;
}
@media (pointer: coarse) {
#touch-controls { display: block; }
}
#joystick-base {
position: absolute;
width: 120px;
height: 120px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.05);
transform: translate(-50%, -50%);
display: none;
pointer-events: none;
}
#joystick-thumb {
position: absolute;
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
border: 2px solid rgba(255, 255, 255, 0.5);
transform: translate(-50%, -50%);
display: none;
pointer-events: none;
}
.y-btn {
position: absolute;
width: 56px;
height: 56px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
border: 2px solid rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 0.7);
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.y-btn.active {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.6);
}
#y-up-btn {
right: 20px;
bottom: 130px;
}
#y-down-btn {
right: 20px;
bottom: 60px;
}
/* Compact coord bar on mobile */
@media (pointer: coarse) {
#coord-bar .coord-inner {
font-size: 11px;
padding: 4px 12px;
gap: 12px;
}
}
</style>
</head>
<body>
<div id="help-overlay">
<div class="help-box">
<h2>Controls</h2>
<div class="help-row">
<span class="help-action">Move (view-relative)</span>
<span class="help-keys"><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd></span>
<div class="desktop-controls">
<div class="help-row">
<span class="help-action">Move (view-relative)</span>
<span class="help-keys"><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd></span>
</div>
<div class="help-row">
<span class="help-action">Move (axis-aligned)</span>
<span class="help-keys"><kbd>Shift</kbd>+<kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd></span>
</div>
<div class="help-row">
<span class="help-action">Up / Down (Z)</span>
<span class="help-keys"><kbd>E</kbd><kbd>Q</kbd></span>
</div>
<div class="help-row">
<span class="help-action">Look</span>
<span class="help-keys"><kbd>Mouse</kbd></span>
</div>
<div class="help-row">
<span class="help-action">Release Cursor</span>
<span class="help-keys"><kbd>Esc</kbd></span>
</div>
<div class="help-row">
<span class="help-action">Toggle Help</span>
<span class="help-keys"><kbd>H</kbd></span>
</div>
<div class="help-dismiss">Click anywhere to start &middot; Press <kbd>H</kbd> to toggle this help</div>
</div>
<div class="help-row">
<span class="help-action">Move (axis-aligned)</span>
<span class="help-keys"><kbd>Shift</kbd>+<kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd></span>
<div class="mobile-controls">
<div class="help-row">
<span class="help-action">Move (X/Z)</span>
<span class="help-keys"><kbd>Left stick</kbd></span>
</div>
<div class="help-row">
<span class="help-action">Up / Down (Y)</span>
<span class="help-keys"><kbd>Buttons</kbd></span>
</div>
<div class="help-row">
<span class="help-action">Look</span>
<span class="help-keys"><kbd>Right drag</kbd></span>
</div>
<div class="help-dismiss">Tap anywhere to start</div>
</div>
<div class="help-row">
<span class="help-action">Up / Down (Z)</span>
<span class="help-keys"><kbd>E</kbd><kbd>Q</kbd></span>
</div>
<div class="help-row">
<span class="help-action">Look</span>
<span class="help-keys"><kbd>Mouse</kbd></span>
</div>
<div class="help-row">
<span class="help-action">Release Cursor</span>
<span class="help-keys"><kbd>Esc</kbd></span>
</div>
<div class="help-row">
<span class="help-action">Toggle Help</span>
<span class="help-keys"><kbd>H</kbd></span>
</div>
<div class="help-dismiss">Click anywhere to start &middot; Press <kbd>H</kbd> to toggle this help</div>
</div>
</div>
@@ -208,6 +319,13 @@
<div id="scanner-separator"></div>
<div id="touch-controls">
<div id="joystick-base"></div>
<div id="joystick-thumb"></div>
<button id="y-up-btn" class="y-btn">&#9650;</button>
<button id="y-down-btn" class="y-btn">&#9660;</button>
</div>
<div id="coord-bar">
<div class="coord-inner">
<span><span class="coord-label">X</span><span id="coord-x">10.0</span></span>
@@ -220,5 +338,202 @@
<span id="move-mode">VIEW</span>
</div>
</div>
<script>
(function() {
// Touch state shared with WASM via window._touchState
var state = {
moveX: 0, moveZ: 0,
lookDX: 0, lookDY: 0,
yUp: false, yDown: false
};
window._touchState = state;
// Only set up touch controls on touch devices
if (!('ontouchstart' in window)) return;
var joystickBase = document.getElementById('joystick-base');
var joystickThumb = document.getElementById('joystick-thumb');
var yUpBtn = document.getElementById('y-up-btn');
var yDownBtn = document.getElementById('y-down-btn');
var JOYSTICK_MAX_RADIUS = 50;
var joystickTouchId = null;
var joystickCenterX = 0;
var joystickCenterY = 0;
var lookTouchId = null;
var lookLastX = 0;
var lookLastY = 0;
// Dismiss help overlay on first touch
var helpDismissed = false;
function showJoystick(cx, cy) {
joystickBase.style.left = cx + 'px';
joystickBase.style.top = cy + 'px';
joystickBase.style.display = 'block';
joystickThumb.style.left = cx + 'px';
joystickThumb.style.top = cy + 'px';
joystickThumb.style.display = 'block';
}
function hideJoystick() {
joystickBase.style.display = 'none';
joystickThumb.style.display = 'none';
}
function updateJoystickThumb(x, y) {
joystickThumb.style.left = x + 'px';
joystickThumb.style.top = y + 'px';
}
function isOverYButton(el, x, y) {
var r = el.getBoundingClientRect();
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
}
document.addEventListener('touchstart', function(e) {
// Dismiss help on first touch
if (!helpDismissed) {
helpDismissed = true;
var overlay = document.getElementById('help-overlay');
if (overlay) overlay.style.display = 'none';
}
for (var i = 0; i < e.changedTouches.length; i++) {
var touch = e.changedTouches[i];
var x = touch.clientX;
var y = touch.clientY;
var screenW = window.innerWidth;
// Skip if touch is on a Y button (handled separately)
if (isOverYButton(yUpBtn, x, y) || isOverYButton(yDownBtn, x, y)) {
continue;
}
if (x < screenW * 0.45) {
// Left side: joystick
if (joystickTouchId === null) {
joystickTouchId = touch.identifier;
joystickCenterX = x;
joystickCenterY = y;
showJoystick(x, y);
state.moveX = 0;
state.moveZ = 0;
}
} else {
// Right side: look
if (lookTouchId === null) {
lookTouchId = touch.identifier;
lookLastX = x;
lookLastY = y;
}
}
}
// Prevent default to avoid scrolling/zooming (but not on buttons)
if (e.target !== yUpBtn && e.target !== yDownBtn) {
e.preventDefault();
}
}, { passive: false });
document.addEventListener('touchmove', function(e) {
e.preventDefault();
for (var i = 0; i < e.changedTouches.length; i++) {
var touch = e.changedTouches[i];
if (touch.identifier === joystickTouchId) {
var dx = touch.clientX - joystickCenterX;
var dy = touch.clientY - joystickCenterY;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist > JOYSTICK_MAX_RADIUS) {
dx = dx / dist * JOYSTICK_MAX_RADIUS;
dy = dy / dist * JOYSTICK_MAX_RADIUS;
dist = JOYSTICK_MAX_RADIUS;
}
// Normalize to -1..1
state.moveX = dx / JOYSTICK_MAX_RADIUS;
state.moveZ = dy / JOYSTICK_MAX_RADIUS;
// Update visual
updateJoystickThumb(
joystickCenterX + dx,
joystickCenterY + dy
);
}
if (touch.identifier === lookTouchId) {
var ldx = touch.clientX - lookLastX;
var ldy = touch.clientY - lookLastY;
state.lookDX += ldx;
state.lookDY += ldy;
lookLastX = touch.clientX;
lookLastY = touch.clientY;
}
}
}, { passive: false });
function handleTouchEnd(e) {
for (var i = 0; i < e.changedTouches.length; i++) {
var touch = e.changedTouches[i];
if (touch.identifier === joystickTouchId) {
joystickTouchId = null;
state.moveX = 0;
state.moveZ = 0;
hideJoystick();
}
if (touch.identifier === lookTouchId) {
lookTouchId = null;
}
}
}
document.addEventListener('touchend', handleTouchEnd, { passive: false });
document.addEventListener('touchcancel', handleTouchEnd, { passive: false });
// Y buttons
yUpBtn.addEventListener('touchstart', function(e) {
e.preventDefault();
e.stopPropagation();
state.yUp = true;
yUpBtn.classList.add('active');
}, { passive: false });
yUpBtn.addEventListener('touchend', function(e) {
e.preventDefault();
state.yUp = false;
yUpBtn.classList.remove('active');
}, { passive: false });
yUpBtn.addEventListener('touchcancel', function(e) {
state.yUp = false;
yUpBtn.classList.remove('active');
}, { passive: false });
yDownBtn.addEventListener('touchstart', function(e) {
e.preventDefault();
e.stopPropagation();
state.yDown = true;
yDownBtn.classList.add('active');
}, { passive: false });
yDownBtn.addEventListener('touchend', function(e) {
e.preventDefault();
state.yDown = false;
yDownBtn.classList.remove('active');
}, { passive: false });
yDownBtn.addEventListener('touchcancel', function(e) {
state.yDown = false;
yDownBtn.classList.remove('active');
}, { passive: false });
})();
</script>
</body>
</html>
+26
View File
@@ -4,6 +4,20 @@ use winit::keyboard::KeyCode;
pub struct InputState {
pub keys_held: HashSet<KeyCode>,
pub mouse_locked: bool,
// Touch input (read from JS on WASM)
#[cfg(target_arch = "wasm32")]
pub touch_move_x: f32,
#[cfg(target_arch = "wasm32")]
pub touch_move_z: f32,
#[cfg(target_arch = "wasm32")]
pub touch_y_up: bool,
#[cfg(target_arch = "wasm32")]
pub touch_y_down: bool,
#[cfg(target_arch = "wasm32")]
pub touch_look_dx: f32,
#[cfg(target_arch = "wasm32")]
pub touch_look_dy: f32,
}
impl InputState {
@@ -11,6 +25,18 @@ impl InputState {
Self {
keys_held: HashSet::new(),
mouse_locked: false,
#[cfg(target_arch = "wasm32")]
touch_move_x: 0.0,
#[cfg(target_arch = "wasm32")]
touch_move_z: 0.0,
#[cfg(target_arch = "wasm32")]
touch_y_up: false,
#[cfg(target_arch = "wasm32")]
touch_y_down: false,
#[cfg(target_arch = "wasm32")]
touch_look_dx: 0.0,
#[cfg(target_arch = "wasm32")]
touch_look_dy: 0.0,
}
}
+61
View File
@@ -79,6 +79,7 @@ impl App {
fn update(&mut self, dt: f32, time: f32) {
let speed = 8.0 * dt;
// Keyboard input
let grid_mode = self.input.is_pressed(KeyCode::ShiftLeft)
|| self.input.is_pressed(KeyCode::ShiftRight);
@@ -127,6 +128,66 @@ impl App {
bullet.position += bullet.velocity * dt;
}
self.bullets.retain(|b| (time - b.spawn_time) < BULLET_LIFETIME);
// Touch input (WASM only)
#[cfg(target_arch = "wasm32")]
{
self.read_touch_state();
self.camera.position.x += self.input.touch_move_x * speed;
self.camera.position.z += self.input.touch_move_z * speed;
if self.input.touch_y_up {
self.camera.position.y += speed;
}
if self.input.touch_y_down {
self.camera.position.y -= speed;
}
if self.input.touch_look_dx != 0.0 || self.input.touch_look_dy != 0.0 {
self.camera
.mouse_look(self.input.touch_look_dx, self.input.touch_look_dy);
}
}
}
#[cfg(target_arch = "wasm32")]
fn read_touch_state(&mut self) {
use wasm_bindgen::JsValue;
let Some(win) = web_sys::window() else {
return;
};
let win_val: &JsValue = win.as_ref();
let Ok(ts) = js_sys::Reflect::get(win_val, &"_touchState".into()) else {
return;
};
if ts.is_undefined() || ts.is_null() {
return;
}
let get_f32 = |obj: &JsValue, key: &str| -> f32 {
js_sys::Reflect::get(obj, &key.into())
.ok()
.and_then(|v| v.as_f64())
.unwrap_or(0.0) as f32
};
let get_bool = |obj: &JsValue, key: &str| -> bool {
js_sys::Reflect::get(obj, &key.into())
.ok()
.and_then(|v| v.as_bool())
.unwrap_or(false)
};
self.input.touch_move_x = get_f32(&ts, "moveX");
self.input.touch_move_z = get_f32(&ts, "moveZ");
self.input.touch_y_up = get_bool(&ts, "yUp");
self.input.touch_y_down = get_bool(&ts, "yDown");
// Look deltas: read and reset
self.input.touch_look_dx = get_f32(&ts, "lookDX");
self.input.touch_look_dy = get_f32(&ts, "lookDY");
let _ = js_sys::Reflect::set(&ts, &"lookDX".into(), &JsValue::from(0.0));
let _ = js_sys::Reflect::set(&ts, &"lookDY".into(), &JsValue::from(0.0));
}
fn try_lock_cursor(&self, window: &Window) {