feat: add mobile virtual touch controls

- document.js: add is_touch detection and virtual button CSS
- input.js: create virtual D-pad + action buttons + touch drag camera
- main.js: skip Pointer Lock on touch devices
This commit is contained in:
Mai
2026-07-04 08:23:39 +08:00
parent b8cfe6ec32
commit dfb738f406
4 changed files with 324 additions and 3 deletions
+229
View File
@@ -0,0 +1,229 @@
# 手机端虚拟按键方案
> 目标:手机浏览器打开 https://foxpaw.top/q1k3/ 能直接玩
> 方案:半透明悬浮按键,覆盖在游戏界面上
---
## 一、输入映射
游戏当前输入系统(`input.js`)是 `keys[]` 数组 + `mouse_x/mouse_y`,虚拟按键只需往同一个数组里写值:
| 游戏操作 | 当前触发方式 | keys 索引 | 虚拟按键 |
|---------|-------------|----------|---------|
| 前进 | W / ↑ | keys[1] | 左区 ↑ |
| 后退 | S / ↓ | keys[3] | 左区 ↓ |
| 左移 | A / ← | keys[2] | 左区 ← |
| 右移 | D / → | keys[4] | 左区 → |
| 射击 | 鼠标左键 | keys[7] | 右下大按钮 |
| 跳跃 | 空格 / 鼠标右键 | keys[9] | 右下小按钮 |
| 切武器上 | E / 滚轮下 | keys[6] | 右区小按钮 |
| 切武器下 | Q / 滚轮上 | keys[5] | 右区小按钮 |
| 视角旋转 | 鼠标移动 | mouse_x/mouse_y | 右半屏拖拽 |
---
## 二、界面布局
```
┌──────────────────────────────────┐
│ │
│ 游戏画面 (Canvas) │
│ │
│ │
│ ┌────┐ ┌─────────┐ │
│ │ ↑ │ │ 视角 │ │
│ │← ─→│ │ 拖拽 │ │
│ │ ↓ │ │ 区域 │ │
│ └────┘ └─────────┘ │
│ │
│ ┌────┐ ┌──────┐ ┌───────┐ │
│ │攻击│ │ Q │ │ E │ │
│ │ │ │上武器│ │下武器 │ │
│ └────┘ └──────┘ └───────┘ │
│ ┌─────────┐ │
│ │ 跳跃 │ │
│ └─────────┘ │
└──────────────────────────────────┘
```
### 分区说明
| 分区 | 覆盖区域 | 操作 |
|------|---------|------|
| **左区(运动)** | 屏幕左 30%,从底部到 55% 高度 | 四个方向按钮 + 中心空白 |
| **右区(视角)** | 屏幕右 70%,从顶部到底部 | 触摸拖拽控制视角 |
| **右下角(动作)** | 屏幕底部 15%,左侧攻击 | 攻击、武器切换、跳跃 |
---
## 三、改动文件清单
### 3.1 `input.js` — 新增触摸处理 (~40 行)
在文件末尾追加:
```js
// ─── 移动端虚拟按键 ───
// 检测触摸设备
let is_touch = 'ontouchstart' in window;
// 仅在触摸设备上创建虚拟按键
if (is_touch) {
// 创建控制层
let ctrl = document.createElement('div');
ctrl.id = 'ctrl';
ctrl.innerHTML =
'<div id="ctrl-left">' +
'<button class="ctrl-btn" id="btn-up">▲</button>' +
'<button class="ctrl-btn" id="btn-left">◀</button>' +
'<button class="ctrl-btn" id="btn-right">▶</button>' +
'<button class="ctrl-btn" id="btn-down">▼</button>' +
'</div>' +
'<div id="ctrl-right">' +
'</div>' +
'<div id="ctrl-actions">' +
'<button class="ctrl-btn ctrl-shoot" id="btn-shoot">⚡</button>' +
'<button class="ctrl-btn ctrl-prev" id="btn-prev">◀◀</button>' +
'<button class="ctrl-btn ctrl-next" id="btn-next">▶▶</button>' +
'<button class="ctrl-btn ctrl-jump" id="btn-jump">▲</button>' +
'</div>';
document.getElementById('g').appendChild(ctrl);
// 阻止 touch 事件穿透到 canvas
ctrl.addEventListener('touchstart', (e) => e.preventDefault(), {passive: false});
ctrl.addEventListener('touchmove', (e) => e.preventDefault(), {passive: false});
// 左区方向键 - touchstart 按下,touchend 松开
let bindBtn = (id, keyIndex) => {
let el = document.getElementById(id);
el.addEventListener('touchstart', () => { keys[keyIndex] = 1; });
el.addEventListener('touchend', () => { keys[keyIndex] = 0; });
el.addEventListener('touchcancel', () => { keys[keyIndex] = 0; });
};
bindBtn('btn-up', 1);
bindBtn('btn-down', 3);
bindBtn('btn-left', 2);
bindBtn('btn-right', 4);
bindBtn('btn-shoot', 7);
bindBtn('btn-jump', 9);
bindBtn('btn-prev', 5);
bindBtn('btn-next', 6);
// 右区触摸拖拽 → 视角控制
let rightZone = document.getElementById('ctrl-right');
let touch_id = null, last_tx = 0, last_ty = 0;
rightZone.addEventListener('touchstart', (e) => {
let t = e.changedTouches[0];
touch_id = t.identifier;
last_tx = t.clientX;
last_ty = t.clientY;
});
rightZone.addEventListener('touchmove', (e) => {
for (let t of e.changedTouches) {
if (t.identifier === touch_id) {
mouse_x += t.clientX - last_tx;
mouse_y += t.clientY - last_ty;
last_tx = t.clientX;
last_ty = t.clientY;
}
}
});
rightZone.addEventListener('touchend', (e) => {
for (let t of e.changedTouches) {
if (t.identifier === touch_id) touch_id = null;
}
});
}
```
### 3.2 `document.js` — 新增 CSS 样式 (~25 行)
`'</style>'` 之前插入虚拟按键样式:
```js
'#ctrl{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:10}'+
'#ctrl-left{position:absolute;bottom:18%;left:2%;width:28%;height:40%;pointer-events:auto}'+
'#ctrl-right{position:absolute;top:0;right:0;width:70%;height:100%;pointer-events:auto}'+
'#ctrl-actions{position:absolute;bottom:3%;right:2%;display:flex;gap:8px;pointer-events:auto}'+
'.ctrl-btn{background:rgba(255,255,255,0.15);border:1px solid rgba(255,255,255,0.25);'+
'border-radius:12px;color:rgba(255,255,255,0.5);font-size:18px;'+
'display:flex;align-items:center;justify-content:center;'+
'user-select:none;-webkit-user-select:none;touch-action:none}'+
'.ctrl-btn:active{background:rgba(255,255,255,0.35)}'+
'#btn-up{position:absolute;top:0;left:50%;transform:translateX(-50%);width:48px;height:48px}'+
'#btn-down{position:absolute;bottom:0;left:50%;transform:translateX(-50%);width:48px;height:48px}'+
'#btn-left{position:absolute;left:0;top:50%;transform:translateY(-50%);width:48px;height:48px}'+
'#btn-right{position:absolute;right:0;top:50%;transform:translateY(-50%);width:48px;height:48px}'+
'.ctrl-shoot{width:64px;height:64px;border-radius:50%;font-size:24px}'+
'.ctrl-prev,.ctrl-next{width:40px;height:40px;font-size:14px}'+
'.ctrl-jump{width:52px;height:52px;border-radius:50%;font-size:20px}'+
```
### 3.3 `main.js` — 不请求 Pointer Lock(触摸设备跳过)
`g.onclick` 回调中,进入 Pointer Lock 之前加判断:
```js
if (!is_touch) {
g.onclick = () => c.requestPointerLock();
g.onclick();
}
```
---
## 四、处理边界情况
### 4.1 触摸与 Pointer Lock 冲突
当前启动流程:点击标题画面 → 第一次点击全屏 → 第二次点击请求 Pointer Lock。
触摸设备上不请求 Pointer Lock,改为直接进游戏。触摸操作本身的焦点是虚拟按键层,与 canvas 无关。
### 4.2 鼠标速度滑块
桌面端有鼠标灵敏度滑块,触摸端不需要(默认灵敏度即可),仍然保留显示不影响。
### 4.3 多指触控
- 方向键:单指操作,每个指对应一个按键
- 视角拖拽只用第一根触摸点(touch_id 记录 identifier
- 攻击等动作按钮可与其他操作同时进行(不同手指)
### 4.4 横竖屏
布局基于 vw/vh 百分比,横竖屏自动适配。左区 28% × 40%,右区 70% × 100%,底部动作栏固定在右下角。
---
## 五、效果预期
```
┌─────────────────────────────────────┐
│ ┌──┐ ┌──────────────────────────┐ │
│ │▲ │ │ │ │
│ │◀┼▶│ │ 游戏画面(可拖拽视角) │ │
│ │▼ │ │ │ │
│ └──┘ │ │ │
│ │ │ │
│ ┌──┐ │ ┌─┐┌─┐│ │
│ │攻│ │ │Q││E││ │
│ │击│ │ └─┘└─┘│ │
│ └──┘ │ ┌──┐ │ │
│ │ │跳│ │ │
│ │ └──┘ │ │
└─────────────────────────────────────┘
```
---
## 六、改动汇总
| 文件 | 改动量 | 说明 |
|------|-------|------|
| `source/input.js` | ~40 行 | 新增虚拟按键 DOM 创建 + 触摸事件绑定 |
| `source/document.js` | ~15 行 | 新增 CSS 样式 |
| `source/main.js` | ~3 行 | 触摸设备跳过 Pointer Lock |
总增量:约 60 行纯 JS + 15 行 CSS → 预估 gzip 后增加 < 1KB。
+22 -1
View File
@@ -1,4 +1,7 @@
// Touch detection
is_touch = 'ontouchstart' in window;
document.body.innerHTML += document.body.innerHTML +=
'<style>'+ '<style>'+
'*{font-family:sans-serif;}'+ '*{font-family:sans-serif;}'+
@@ -10,6 +13,24 @@ document.body.innerHTML +=
'#a,#h{position:absolute;bottom:3%;left:20%;right:0;font-size:3.2vw;}'+ '#a,#h{position:absolute;bottom:3%;left:20%;right:0;font-size:3.2vw;}'+
'#h{left:-20%;}'+ '#h{left:-20%;}'+
'h1{font-size:16vw;margin:0;}'+ 'h1{font-size:16vw;margin:0;}'+
(is_touch ?
'#ctrl{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:10}'+
'#ctrl-left{position:absolute;bottom:18%;left:2%;width:28%;height:40%;pointer-events:auto}'+
'#ctrl-right{position:absolute;top:0;right:0;width:70%;height:100%;pointer-events:auto}'+
'#ctrl-actions{position:absolute;bottom:3%;right:2%;display:flex;gap:8px;pointer-events:auto}'+
'.ctrl-btn{background:rgba(255,255,255,0.15);border:1px solid rgba(255,255,255,0.25);'+
'border-radius:12px;color:rgba(255,255,255,0.5);font-size:18px;'+
'display:flex;align-items:center;justify-content:center;'+
'user-select:none;-webkit-user-select:none;touch-action:none}'+
'.ctrl-btn:active{background:rgba(255,255,255,0.35)}'+
'#btn-up{position:absolute;top:0;left:50%;transform:translateX(-50%);width:48px;height:48px}'+
'#btn-down{position:absolute;bottom:0;left:50%;transform:translateX(-50%);width:48px;height:48px}'+
'#btn-left{position:absolute;left:0;top:50%;transform:translateY(-50%);width:48px;height:48px}'+
'#btn-right{position:absolute;right:0;top:50%;transform:translateY(-50%);width:48px;height:48px}'+
'.ctrl-shoot{width:64px;height:64px;border-radius:50%;font-size:24px}'+
'.ctrl-prev,.ctrl-next{width:40px;height:40px;font-size:14px}'+
'.ctrl-jump{width:52px;height:52px;border-radius:50%;font-size:20px}'
: '')+
'</style>'+ '</style>'+
'<div id="g">'+ '<div id="g">'+
'<canvas id=c width=320 height=180></canvas>'+ '<canvas id=c width=320 height=180></canvas>'+
@@ -21,4 +42,4 @@ document.body.innerHTML +=
'<p><input id="f" type="button" value="FULLSCREEN"></p>'+ '<p><input id="f" type="button" value="FULLSCREEN"></p>'+
'<p>'+ '<p>'+
'code: <a href="https://phoboslab.org">phoboslab.org</a> / music: <a href="http://no-fate.net">no-fate.net</a>'+ 'code: <a href="https://phoboslab.org">phoboslab.org</a> / music: <a href="http://no-fate.net">no-fate.net</a>'+
'</p>'; '</p>';
+69
View File
@@ -81,3 +81,72 @@ c.onmouseup = (ev) => {
keys[key_action + ev.button] = 0; keys[key_action + ev.button] = 0;
}; };
// ─── Mobile virtual controls ───
if (is_touch) {
let ctrl = document.createElement('div');
ctrl.id = 'ctrl';
ctrl.innerHTML =
'<div id="ctrl-left">'+
'<button class="ctrl-btn" id="btn-up">▲</button>'+
'<button class="ctrl-btn" id="btn-left">◀</button>'+
'<button class="ctrl-btn" id="btn-right">▶</button>'+
'<button class="ctrl-btn" id="btn-down">▼</button>'+
'</div>'+
'<div id="ctrl-right"></div>'+
'<div id="ctrl-actions">'+
'<button class="ctrl-btn ctrl-shoot" id="btn-shoot">⚡</button>'+
'<button class="ctrl-btn ctrl-prev" id="btn-prev">◀◀</button>'+
'<button class="ctrl-btn ctrl-next" id="btn-next">▶▶</button>'+
'<button class="ctrl-btn ctrl-jump" id="btn-jump">▲</button>'+
'</div>';
document.getElementById('g').appendChild(ctrl);
// Prevent touches from reaching the canvas
ctrl.addEventListener('touchstart', (e) => e.preventDefault(), {passive: false});
ctrl.addEventListener('touchmove', (e) => e.preventDefault(), {passive: false});
// Bind button press/release
let bindBtn = (id, keyIndex) => {
let el = document.getElementById(id);
el.addEventListener('touchstart', () => { keys[keyIndex] = 1; });
el.addEventListener('touchend', () => { keys[keyIndex] = 0; });
el.addEventListener('touchcancel', () => { keys[keyIndex] = 0; });
};
bindBtn('btn-up', 1);
bindBtn('btn-down', 3);
bindBtn('btn-left', 2);
bindBtn('btn-right', 4);
bindBtn('btn-shoot', 7);
bindBtn('btn-jump', 9);
bindBtn('btn-prev', 5);
bindBtn('btn-next', 6);
// Right zone touch drag → camera
let rightZone = document.getElementById('ctrl-right'),
touch_id = null,
last_tx = 0,
last_ty = 0;
rightZone.addEventListener('touchstart', (e) => {
let t = e.changedTouches[0];
touch_id = t.identifier;
last_tx = t.clientX;
last_ty = t.clientY;
});
rightZone.addEventListener('touchmove', (e) => {
for (let t of e.changedTouches) {
if (t.identifier === touch_id) {
mouse_x += t.clientX - last_tx;
mouse_y += t.clientY - last_ty;
last_tx = t.clientX;
last_ty = t.clientY;
}
}
});
rightZone.addEventListener('touchend', (e) => {
for (let t of e.changedTouches) {
if (t.identifier === touch_id) touch_id = null;
}
});
}
+4 -2
View File
@@ -124,8 +124,10 @@ game_load = async () => {
f.onclick = () => g.requestFullscreen(); f.onclick = () => g.requestFullscreen();
g.onclick = () => { g.onclick = () => {
g.onclick = () => c.requestPointerLock(); if (!is_touch) {
g.onclick(); g.onclick = () => c.requestPointerLock();
g.onclick();
}
audio_init(); audio_init();