live试玩round1+round2修复: 引擎gesture新增tapcancel(immediateTap误判定回滚,默认关闭零行为变化)+ rhythm判定账簿/超时Miss复活/菜单1s去抖/正片下滑永不弹菜单 + space判定冷静期800ms/暂停恢复3颗缓冲/回大厅leaving收尾/hit_last在播标记 + storyline intro满播不掐/抉择25s兜底 + simon免费重听两层账 + escape教学链自动接播/90s高频零提示 + rpg擦伤零误报/tap不丢 + guide.test适配去抖期(语义不变)+ 文档同步(13号§9-§19/web-engine-api/README)。回归:storyline82/escape140/simon132/rhythm141/space155/rpg188/guide122共921断言全绿

This commit is contained in:
xsl
2026-09-01 22:00:00 +08:00
parent 624fec8a23
commit 4e9970e227
22 changed files with 3161 additions and 401 deletions
+62 -3
View File
@@ -11,6 +11,18 @@
* - 单击/双击:位移 < 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',之后滑动照常判定)。
@@ -40,8 +52,25 @@ export const DEFAULT_GESTURE_CONFIG = {
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;
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
const markGlobalTouchEnd = () => { lastGlobalTouchEnd = performance.now(); };
document.addEventListener('touchend', markGlobalTouchEnd, true);
document.addEventListener('touchcancel', markGlobalTouchEnd, true);
}
/** 长按引擎事件:audio.js 监听它实现"长按=旁白临时倍速"。 */
function _dispatchLongpress(type) {
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function'
@@ -65,6 +94,7 @@ export function attachGestures(element, onGesture, config = {}) {
// 点按状态
let lastTapTime = 0;
let pendingTapTimer = null;
let tapPending = false; // immediateTap:本次按下已发 tap 且尚未定案(可补 tapcancel)
// 触摸发生后短时间内忽略合成鼠标事件
let lastTouchEndTime = 0;
@@ -91,18 +121,34 @@ export function attachGestures(element, onGesture, config = {}) {
onGesture('longpressend');
}
/** immediateTap:本次按下的序曲 tap 确定作废(转滑动/长按/触摸取消)时补发 tapcancel。 */
function cancelImmediateTap() {
if (!tapPending) return;
tapPending = false;
onGesture('tapcancel');
}
function handleDown(x, y) {
tracking = true;
moved = false;
startX = x;
startY = y;
startTime = performance.now();
if (cfg.immediateTap) {
// 即时模式:按下立即回调 tap,不等双击窗口关闭。
// 注意此时尚不知本次按下会判成什么——若最终是滑动/长按,
// 这声 tap 已发出不可撤回,但引擎会补 tapcancel 让游戏层撤销判定。
tapPending = true;
vibrate('confirm');
onGesture('tap');
}
cancelLongPress();
longPressTimer = setTimeout(() => {
longPressTimer = null;
if (!tracking || moved) return;
// 触发长按:作废等待中的单击
// 触发长按:作废等待中的单击;immediateTap 的序曲 tap 补发 tapcancel
cancelPendingTap();
cancelImmediateTap();
longPressActive = true;
_dispatchLongpress('longpress');
onGesture('longpress');
@@ -112,6 +158,7 @@ export function attachGestures(element, onGesture, config = {}) {
function handleMove(x, y) {
if (!tracking) return;
if (Math.hypot(x - startX, y - startY) > cfg.tapSlop) {
if (!moved) cancelImmediateTap(); // 位移超 slop:本次按下确定不是点按
moved = true;
cancelLongPress(); // 滑动取消长按判定
endLongPress(); // 已激活的长按被滑动终止(之后滑动照常判定)
@@ -125,6 +172,7 @@ export function attachGestures(element, onGesture, config = {}) {
if (longPressActive) {
endLongPress();
lastTapTime = 0;
tapPending = false;
return; // 长按后不触发 tap/doubletap
}
@@ -137,6 +185,7 @@ export function attachGestures(element, onGesture, config = {}) {
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';
@@ -147,13 +196,18 @@ export function attachGestures(element, onGesture, config = {}) {
}
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');
} else if (cfg.immediateTap) {
lastTapTime = now; // tap 已在 handleDown 发出,这里只记双击窗起点
} else {
lastTapTime = now;
cancelPendingTap();
@@ -165,12 +219,16 @@ export function attachGestures(element, onGesture, config = {}) {
onGesture('tap');
}, cfg.doubleTapWindow);
}
return;
}
cancelImmediateTap(); // 既非滑动也非点按(慢拖/回拉):序曲 tap 定案作废
}
function handleCancel() {
tracking = false;
cancelLongPress();
cancelImmediateTap(); // 触摸被系统取消:序曲 tap 定案作废
endLongPress();
}
@@ -203,7 +261,8 @@ export function attachGestures(element, onGesture, config = {}) {
// ---- 鼠标(桌面调试)----
let mouseDown = false;
function recentlyTouched() {
return performance.now() - lastTouchEndTime < 700;
// 实例级(root 上的触摸)与全局级(遮罩等页面任意处的触摸)取较晚者
return performance.now() - Math.max(lastTouchEndTime, lastGlobalTouchEnd) < 700;
}
function onMouseDown(e) {
if (recentlyTouched() || e.button !== 0) return;