From dfb738f406f9f716b9be3a3afe94802ebcb356dc Mon Sep 17 00:00:00 2001 From: Mai Date: Sat, 4 Jul 2026 08:23:39 +0800 Subject: [PATCH] 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 --- docs/plans/mobile-touch-controls.md | 229 ++++++++++++++++++++++++++++ source/document.js | 23 ++- source/input.js | 69 +++++++++ source/main.js | 6 +- 4 files changed, 324 insertions(+), 3 deletions(-) create mode 100644 docs/plans/mobile-touch-controls.md diff --git a/docs/plans/mobile-touch-controls.md b/docs/plans/mobile-touch-controls.md new file mode 100644 index 0000000..612b6d4 --- /dev/null +++ b/docs/plans/mobile-touch-controls.md @@ -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 = + '
' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '
'; + 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 行) + +在 `''` 之前插入虚拟按键样式: + +```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。 diff --git a/source/document.js b/source/document.js index d961f54..aa43f47 100644 --- a/source/document.js +++ b/source/document.js @@ -1,4 +1,7 @@ +// Touch detection +is_touch = 'ontouchstart' in window; + document.body.innerHTML += ''+ '
'+ ''+ @@ -21,4 +42,4 @@ document.body.innerHTML += '

'+ '

'+ 'code: phoboslab.org / music: no-fate.net'+ - '

'; \ No newline at end of file + '

'; diff --git a/source/input.js b/source/input.js index eb2ad52..248f2e6 100755 --- a/source/input.js +++ b/source/input.js @@ -81,3 +81,72 @@ c.onmouseup = (ev) => { keys[key_action + ev.button] = 0; }; +// ─── Mobile virtual controls ─── + +if (is_touch) { + let ctrl = document.createElement('div'); + ctrl.id = 'ctrl'; + ctrl.innerHTML = + '
'+ + ''+ + ''+ + ''+ + ''+ + '
'+ + '
'+ + '
'+ + ''+ + ''+ + ''+ + ''+ + '
'; + 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; + } + }); +} + diff --git a/source/main.js b/source/main.js index 83142f2..f30cd13 100755 --- a/source/main.js +++ b/source/main.js @@ -124,8 +124,10 @@ game_load = async () => { f.onclick = () => g.requestFullscreen(); g.onclick = () => { - g.onclick = () => c.requestPointerLock(); - g.onclick(); + if (!is_touch) { + g.onclick = () => c.requestPointerLock(); + g.onclick(); + } audio_init();