Files
xsl ae850a4ce0 feat(rhythm): 鼓手模式重构——屏幕六区鼓组 + 无鼓伴奏合拍(用户裁定重构)
- 屏幕3列×2行六区鼓组(列=声像左中右/行=音高上亮下沉),六件程序合成打击乐,三重冗余蒙眼可辨
- 无鼓伴奏BGM上跟随领奏 call&response + 加花句;3曲90/105/120 BPM解锁制,BPM实测89.99/105.00/120.01
- 引擎后向兼容扩展:onGesture补{x,y}坐标(单参回调零感知,15游戏+3引擎测试全量回归零失败)
- 判定简化:全部音符=分区点按零延迟,旧双击补偿/下滑折中退役;键盘Digit1-6+空格=家区
- 教学摸鼓认区(摸哪报哪,8s无触摸自动进阶)+重教≤2+降速DDA+归因真话律
- 保留旧作好评:跟随领奏核心/S~C评级/解锁制/判定口径/guide工程遗产
- 语音49id双性别+8key;R1+R2连续2轮4/4愿意玩P0=0裁定可交付;live直证33 Perfect dt≈0.00s
- 03号文档仅文末加取代指针;新设计24号;档案research/rhythm-drummer/;Mureka累计$1.35
2026-09-14 20:17:10 +08:00

467 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* gesture.js — 6 手势识别(触摸 + 鼠标 + 键盘三通道),对齐 Android GestureEngine 行为。
*
* 手势集合:'up' | 'down' | 'left' | 'right' | 'tap' | 'doubletap'
* | 'longpress' | 'longpressend'
*
* 判定规则(与 Android 版 GestureConfig 一致):
* - 滑动:累积位移 >= swipeMinDistance(默认 64px ≈ 64dp@160dpi)
* 且平均速度 >= swipeMinVelocity(默认 400 px/s)。
* 方向取位移较大的轴(|dx|>|dy| 判左右,否则判上下),斜滑归一到最近主轴。
* - 单击/双击:位移 < tapSlop 视为点按;双击窗口 doubleTapWindow(默认 300ms)。
* **单击回调有约 300ms 延迟**(要等双击窗口关闭才能确定不是双击),
* 即时反馈类操作请绑定滑动或双击,单击只用于非即时操作。
* **例外:`{ immediateTap: true }`(opt-in,默认 false 行为完全不变)——
* 按下(touchstart/mousedown)立即回调 tap,不等双击窗口;若 300ms 内
* 第二下也成立为点按,松开时补发 'doubletap' 改判(第一次 tap 已发出,
* 是否撤回其效果由游戏层决定)。代价:每次按下都先发 tap——本次按下
* 若最终判成滑动/长按,那一下 tap 的声音已发出无法撤回(演奏类游戏
* "按下即出声"正好要这个语义,如 rhythm 单击=kick),但**判定可以
* 撤回**:本次按下一旦确定不再是点按——移动超 tapSlop 转滑动、长按
* 成立、触摸被 cancel——引擎补发 'tapcancel'(仅 immediateTap 模式;
* 若该触摸最终以滑动收尾且滑动前未曾超 slop,补在滑动事件之前)。
* 游戏层收到后应撤销该次 tap 的判定/统计(声音已出收不回)。
* 双击序曲(tap+tap+doubletap 序列)不发 tapcancel——归属撤销由游戏层
* 按双击窗口自行处理(见 rhythm:doubletap 到达时撤回窗口内的序曲 tap)。**
* - **长按**:按住不动 >= longpressMs(默认 450ms)触发 'longpress',
* 松开触发 'longpressend';长按期间不触发 tap;位移超 tapSlop 取消
* (已激活的长按会先补发 'longpressend',之后滑动照常判定)。
* 长按触发/结束同时派发 window 事件 'soundgame:longpress' /
* 'soundgame:longpressend'——audio.js 内置监听:voice 播放中长按 =
* 旁白临时 1.7 倍速(不变调),松开恢复设置语速,游戏代码零改动。
*
* 用法:
* import { attachGestures } from '../engine/gesture.js';
* const detach = attachGestures(element, (g, info) => game.onGesture(g, info));
* // detach() 可解除绑定
*
* 第二参 info(24-rhythm 坐标透传,缺省行为逐字节不变):
* { x, y } 视口像素坐标——tap 类/长按 = 按下点,滑动 = 滑动起点(出手瞄准
* 位置),键盘通道 = 虚拟指针坐标(元素中心)。需要触点位置的游戏(如 rhythm
* 分区打击)使用;单参回调完全不受影响。
*
* 基础震动反馈(haptics.js,不支持的平台自动 no-op):
* 滑动识别成功→swipe,tap→confirm,doubletap→confirm。
* 游戏逻辑层的语义反馈(error/success/warning 等)由游戏自行调用 vibrate()。
*
* 注意:绑定点按的元素的 touchstart/touchmove/touchend 会被 preventDefault
* (阻止页面滚动、双指缩放与系统合成鼠标事件),元素需应用
* style.css 里的全屏样式(touch-action: none)。
*
* 键盘通道(PC 平行输入,research/platform/02-input-parity.md §2/§3):
* - ←↑↓→ 与 WASD = 四向滑动(keydown 即发,无位移/速度判定,零延迟);
* - 空格 = 单击:作为虚拟指针喂入**同一状态机**——抬起判 tap(保留 ~300ms
* 双击窗延迟,与触屏单击同感;双击窗与触摸共享,混合设备"触屏点一下 +
* 空格点一下"也判 doubletap,语义 = 同一虚拟指针);
* - 回车 = 双击专用键:即时触发 doubletap,不等双击窗(同时作废等待中的
* 单击),确认类操作零延迟;
* - 按住空格 ≥450ms = longpress,松开 = longpressend(旁白快进 window
* 事件照发,与触摸长按完全一致)。
* 事件与内置震动与 touch/mouse 完全同构——游戏代码零改动。守卫:
* e.repeat 抑制(按住 ↑ 只发一次 up)、映射键 preventDefault(防滚动/
* 焦点激活)、表单控件焦点放行(input/textarea/select/contenteditable)、
* tracking 单飞行指针互斥(与多指忽略语义一致)、未映射键不拦截(Tab
* 等无障碍键放行)。提示文案随输入模式见 inputMode()(settings.input)。
*/
import { vibrate } from './haptics.js';
import { settings } from './settings.js';
export const DEFAULT_GESTURE_CONFIG = {
swipeMinDistance: 64, // px,累积位移阈值(≈ Android 64dp)
swipeMinVelocity: 400, // px/s,平均速度阈值(位移 / 拖动时长)
doubleTapWindow: 300, // ms,双击判定窗口(单击因此延迟 ~300ms 回调)
tapSlop: 24, // px,位移小于此值才算点按
longpressMs: 450, // ms,按住不动超过此值触发 longpress
immediateTap: false, // true = 按下即发 tap(演奏类 opt-in,见文件头注释)
};
/**
* 全局合成鼠标守卫(模块级,所有 attachGestures 实例共享)。
* 启动遮罩(audio.js showStartOverlay)挂在 body 上、不是手势 root 的
* 子元素:解锁那次触摸不经过任何手势实例,实例级 lastTouchEndTime 守卫
* 对它无效——遮罩移除后该次触摸合成的 mousedown/mouseup 落到 root 上,
* 会被鼠标回退通道误判成 tap("解锁单击"误杀开场旁白的根因)。
* 在 document 捕获阶段记录全页面最后一次 touchend/touchcancel,
* recentlyTouched() 取两者较大值,把解锁 tap 也纳入 700ms 守卫。
*/
let lastGlobalTouchEnd = 0;
// 输入模式跟随(inputMode()):最近一次键盘/触摸使用时刻,任一通道有动作
// 即切提示。模块级共享(与 lastGlobalTouchEnd 同理:遮罩等处的输入不经过
// 任何手势实例)。
let lastKeyInputAt = 0;
let lastTouchInputAt = 0;
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
const markGlobalTouchEnd = () => { lastGlobalTouchEnd = performance.now(); };
document.addEventListener('touchend', markGlobalTouchEnd, true);
document.addEventListener('touchcancel', markGlobalTouchEnd, true);
// 输入模式跟随:任何触摸开始即认为触屏在用(提示文案切触摸版)
document.addEventListener('touchstart', () => { lastTouchInputAt = performance.now(); }, true);
}
/** 长按引擎事件:audio.js 监听它实现"长按=旁白临时倍速"。 */
/**
* 长按 boost 窗口事件('soundgame:longpress'/'soundgame:longpressend')的开关,
* 由 audio.setRateBoostEnabled(false) 置 off(E3 对局豁免):豁免期快进通道本就
* 不生效,再外发事件只会造成"boost on→off 闪跳"假象(R1 live P2 仪器侧记)。
* 只抑制 window 事件(快进/探针通道);onGesture('longpress'/'longpressend')
* 游戏回调(前进/停走)不受影响。
*/
let boostEventsEnabled = true;
export function setLongpressBoostEventsEnabled(on) {
boostEventsEnabled = on !== false;
}
function _dispatchLongpress(type) {
if (!boostEventsEnabled) return;
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function'
&& typeof CustomEvent === 'function') {
window.dispatchEvent(new CustomEvent(`soundgame:${type}`));
}
}
// ---- 键盘通道常量与输入模式 ----
/** e.code → 手势方向(WASD 为方向键别名,单手党惯例,默认开启)。 */
const KEY_DIR = {
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
KeyW: 'up', KeyS: 'down', KeyA: 'left', KeyD: 'right',
};
const TAP_KEY_CODE = 'Space'; // 空格 = 单击(虚拟指针,走完整状态机)
const DOUBLE_KEY_CODE = 'Enter'; // 回车 = 双击专用键(即时,不等 300ms 窗)
/** 初始能力检测:触屏手机 true;纯桌面 false;触屏笔记本(主指针为鼠标)
* false(初始按键鼠提示,运行时跟随会纠正,见 inputMode)。 */
function isTouchLikely() {
try {
if (typeof navigator === 'undefined' || !(navigator.maxTouchPoints > 0)) return false;
if (typeof matchMedia !== 'function') return true; // 无 matchMedia 环境:有触点即按触屏处理
return matchMedia('(pointer: coarse)').matches;
} catch (_) {
return false;
}
}
/**
* 当前提示输入模式:'touch' | 'key'。
* settings.input 手动覆盖优先('touch'/'key');默认 'auto' = 跟随最近使用
* 的通道(映射键 keydown → key,任意 touchstart → touch),会话内尚无输入时
* 按 isTouchLikely() 初始判定。
* **只影响提示文案**(audio.js 启动遮罩「轻触开始」/「按空格开始」等,游戏内
* 提示由各游戏 HTML 后续接入),**不关闭任何输入通道**——touch/mouse/key
* 三通道永远并行在线,混合设备无需玩家配置。
*/
export function inputMode() {
const pref = settings.get().input;
if (pref === 'touch' || pref === 'key') return pref;
if (lastKeyInputAt === 0 && lastTouchInputAt === 0) return isTouchLikely() ? 'touch' : 'key';
return lastKeyInputAt >= lastTouchInputAt ? 'key' : 'touch';
}
export function attachGestures(element, onGesture, config = {}) {
const cfg = { ...DEFAULT_GESTURE_CONFIG, ...config };
// 单指/单指针跟踪状态
let tracking = false;
let startX = 0, startY = 0, startTime = 0;
let downX = 0, downY = 0; // 本次按下的坐标(onGesture 第二参 info 透传用)
let moved = false; // 位移是否已超 tapSlop(超过则本次不可能是点按)
// 长按状态
let longPressTimer = null;
let longPressActive = false;
// 点按状态
let lastTapTime = 0;
let pendingTapTimer = null;
let tapPending = false; // immediateTap:本次按下已发 tap 且尚未定案(可补 tapcancel)
// 触摸发生后短时间内忽略合成鼠标事件
let lastTouchEndTime = 0;
function cancelPendingTap() {
if (pendingTapTimer !== null) {
clearTimeout(pendingTapTimer);
pendingTapTimer = null;
}
}
function cancelLongPress() {
if (longPressTimer !== null) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
/** 长按已激活后收尾(松开/滑动取消):补发 longpressend。 */
function endLongPress() {
if (!longPressActive) return;
longPressActive = false;
_dispatchLongpress('longpressend');
onGesture('longpressend', { x: downX, y: downY });
}
/** immediateTap:本次按下的序曲 tap 确定作废(转滑动/长按/触摸取消)时补发 tapcancel。 */
function cancelImmediateTap() {
if (!tapPending) return;
tapPending = false;
onGesture('tapcancel', { x: downX, y: downY });
}
function handleDown(x, y) {
tracking = true;
moved = false;
startX = x;
startY = y;
downX = x;
downY = y;
startTime = performance.now();
if (cfg.immediateTap) {
// 即时模式:按下立即回调 tap,不等双击窗口关闭。
// 注意此时尚不知本次按下会判成什么——若最终是滑动/长按,
// 这声 tap 已发出不可撤回,但引擎会补 tapcancel 让游戏层撤销判定。
tapPending = true;
vibrate('confirm');
onGesture('tap', { x: downX, y: downY });
}
cancelLongPress();
longPressTimer = setTimeout(() => {
longPressTimer = null;
if (!tracking || moved) return;
// 触发长按:作废等待中的单击;immediateTap 的序曲 tap 补发 tapcancel
cancelPendingTap();
cancelImmediateTap();
longPressActive = true;
_dispatchLongpress('longpress');
onGesture('longpress', { x: downX, y: downY });
}, cfg.longpressMs);
}
function handleMove(x, y) {
if (!tracking) return;
if (Math.hypot(x - startX, y - startY) > cfg.tapSlop) {
if (!moved) cancelImmediateTap(); // 位移超 slop:本次按下确定不是点按
moved = true;
cancelLongPress(); // 滑动取消长按判定
endLongPress(); // 已激活的长按被滑动终止(之后滑动照常判定)
}
}
function handleUp(x, y) {
if (!tracking) return;
tracking = false;
cancelLongPress();
if (longPressActive) {
endLongPress();
lastTapTime = 0;
tapPending = false;
return; // 长按后不触发 tap/doubletap
}
const dx = x - startX;
const dy = y - startY;
const dist = Math.hypot(dx, dy);
const durationMs = Math.max(performance.now() - startTime, 1);
const velocity = dist / (durationMs / 1000); // px/s
if (dist >= cfg.swipeMinDistance && velocity >= cfg.swipeMinVelocity) {
// 主轴归一:位移较大的轴决定方向
cancelPendingTap(); // 滑动作废等待中的单击
cancelImmediateTap(); // 序曲 tap 定案作废(无 move 事件的快滑在此补发)
lastTapTime = 0;
let g;
if (Math.abs(dx) > Math.abs(dy)) g = dx > 0 ? 'right' : 'left';
else g = dy > 0 ? 'down' : 'up';
vibrate('swipe'); // 滑动识别成功
onGesture(g, { x: startX, y: startY });
return;
}
if (!moved && dist < cfg.tapSlop) {
tapPending = false; // 点按成立:序曲 tap 定案保留(双击序曲归属由游戏层处理)
const now = performance.now();
if (now - lastTapTime <= cfg.doubleTapWindow) {
// 双击:取消尚未触发的单击(immediateTap 模式没有挂起的单击——
// 第一下 tap 在按下时已发出,此处补发 doubletap 改判,
// 是否撤回第一次 tap 的效果由游戏层决定)
cancelPendingTap();
lastTapTime = 0;
vibrate('confirm'); // 双击
onGesture('doubletap', { x: downX, y: downY });
} else if (cfg.immediateTap) {
lastTapTime = now; // tap 已在 handleDown 发出,这里只记双击窗起点
} else {
lastTapTime = now;
cancelPendingTap();
// 等双击窗口关闭后才回调单击(与 Android 一致的 ~300ms 延迟)
pendingTapTimer = setTimeout(() => {
pendingTapTimer = null;
lastTapTime = 0;
vibrate('confirm'); // 单击
onGesture('tap', { x: downX, y: downY });
}, cfg.doubleTapWindow);
}
return;
}
cancelImmediateTap(); // 既非滑动也非点按(慢拖/回拉):序曲 tap 定案作废
}
function handleCancel() {
tracking = false;
cancelLongPress();
cancelImmediateTap(); // 触摸被系统取消:序曲 tap 定案作废
endLongPress();
}
// ---- 触摸 ----
// 全部 preventDefault:防滚动 / 防双击缩放 / 防合成鼠标事件重复触发。
// 必须 passive:false 才能 preventDefault。
function onTouchStart(e) {
if (e.touches.length > 1) { handleCancel(); return; } // 忽略多指
e.preventDefault();
const t = e.touches[0];
handleDown(t.clientX, t.clientY);
}
function onTouchMove(e) {
e.preventDefault();
if (!tracking) return;
const t = e.touches[0];
handleMove(t.clientX, t.clientY);
}
function onTouchEnd(e) {
e.preventDefault();
lastTouchEndTime = performance.now();
const t = e.changedTouches[0];
handleUp(t.clientX, t.clientY);
}
function onTouchCancel() {
lastTouchEndTime = performance.now();
handleCancel();
}
// ---- 鼠标(桌面调试)----
let mouseDown = false;
function recentlyTouched() {
// 实例级(root 上的触摸)与全局级(遮罩等页面任意处的触摸)取较晚者
return performance.now() - Math.max(lastTouchEndTime, lastGlobalTouchEnd) < 700;
}
function onMouseDown(e) {
if (recentlyTouched() || e.button !== 0) return;
mouseDown = true;
handleDown(e.clientX, e.clientY);
}
function onMouseMove(e) {
if (!mouseDown) return;
handleMove(e.clientX, e.clientY);
}
function onMouseUp(e) {
if (!mouseDown || recentlyTouched()) return;
mouseDown = false;
handleUp(e.clientX, e.clientY);
}
function onContextMenu(e) {
e.preventDefault(); // 防移动端长按弹菜单
}
// ---- 键盘(PC 平行通道,与 touch/mouse 喂同一状态机)----
// 监听挂 window(键盘事件目标是焦点元素而非手势根,游戏页焦点在 body,
// 冒泡到 window 必达);keydown 认领映射键,keyup 只处理空格(收尾虚拟
// 指针)。方向键/回车在 keydown 即时出手势,无 keyup 语义。
let keyPointer = null; // 空格按下时认领的虚拟指针坐标(元素中心,抬起复用)
/** 表单控件守卫:焦点在大厅输入框等处时放行,不吞键(打字/候选导航正常)。 */
function isFormTarget(t) {
if (!t || typeof t !== 'object') return false;
const tag = String(t.tagName || '').toUpperCase();
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable === true;
}
/** 键盘虚拟指针落点 = 元素中心。坐标只用于位移判定(tapSlop/滑动),键盘
* 路径永不 move,必然只走出 tap/doubletap/longpress 分支,不会误判滑动。 */
function keyCenter() {
if (typeof element.getBoundingClientRect === 'function') {
const r = element.getBoundingClientRect();
if (r && (r.width || r.height)) return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}
return keyPointer || { x: 0, y: 0 }; // 无 DOM 环境(harness mock):坐标无关紧要
}
function onKeyDown(e) {
if (isFormTarget(e.target)) return; // 表单焦点:放行,不 preventDefault
const dir = KEY_DIR[e.code];
const isTapKey = e.code === TAP_KEY_CODE;
const isDoubleKey = e.code === DOUBLE_KEY_CODE;
if (!dir && !isTapKey && !isDoubleKey) return; // 未映射键不拦截(Tab 等无障碍键放行;无 Esc 映射)
e.preventDefault(); // 抑制方向键/空格滚屏、回车激活焦点元素(repeat 也要防,否则按住连滚)
if (e.repeat) return; // 自动重复抑制:按住 ↑ 只发一次 up、按住空格只认领一次
lastKeyInputAt = performance.now(); // 通道标记:inputMode() 提示跟随
if (dir) {
if (tracking) return; // 单飞行指针:触摸/空格进行中忽略(与多指忽略语义一致)
cancelPendingTap(); // 与触屏滑动一致:滑动作废等待中的单击
lastTapTime = 0;
vibrate('swipe');
onGesture(dir, keyCenter()); // 即时,无位移/速度判定(零延迟)
return;
}
if (tracking) return;
if (isDoubleKey) {
// 回车 = 双击专用键:即时 doubletap(不等 300ms 双击窗),同时作废
// 等待中的单击——收尾语义与触屏双击分支完全一致(含 confirm 震动)。
cancelPendingTap();
lastTapTime = 0;
vibrate('confirm');
onGesture('doubletap', keyCenter());
return;
}
// 空格:虚拟指针按下,进同一状态机(起 450ms 长按定时器;immediateTap
// 游戏按下即发 tap——rhythm「空格=kick 演奏」零改动成立)。
keyPointer = keyCenter();
handleDown(keyPointer.x, keyPointer.y);
}
function onKeyUp(e) {
if (e.code !== TAP_KEY_CODE) return;
if (!keyPointer) return; // 这下空格未被键盘通道认领(表单内按下 / 触摸占用时按下)
const p = keyPointer;
keyPointer = null;
handleUp(p.x, p.y); // 复用收尾:长按→longpressend;快按→tap(双击窗内两下=doubletap)
}
const hasWindow = typeof window !== 'undefined' && typeof window.addEventListener === 'function';
const opts = { passive: false };
element.addEventListener('touchstart', onTouchStart, opts);
element.addEventListener('touchmove', onTouchMove, opts);
element.addEventListener('touchend', onTouchEnd, opts);
element.addEventListener('touchcancel', onTouchCancel, opts);
element.addEventListener('mousedown', onMouseDown);
element.addEventListener('mousemove', onMouseMove);
element.addEventListener('mouseup', onMouseUp);
element.addEventListener('contextmenu', onContextMenu);
if (hasWindow) {
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
}
return function detach() {
cancelPendingTap();
cancelLongPress();
endLongPress();
element.removeEventListener('touchstart', onTouchStart, opts);
element.removeEventListener('touchmove', onTouchMove, opts);
element.removeEventListener('touchend', onTouchEnd, opts);
element.removeEventListener('touchcancel', onTouchCancel, opts);
element.removeEventListener('mousedown', onMouseDown);
element.removeEventListener('mousemove', onMouseMove);
element.removeEventListener('mouseup', onMouseUp);
element.removeEventListener('contextmenu', onContextMenu);
if (hasWindow) {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
}
};
}