/** * haptics.js — navigator.vibrate 震动反馈封装。 * * 预定义震型: * | 名称 | 震型 (ms) | 建议用途 | * |-----------|----------------------|------------------------| * | confirm | [20] | 点按确认 | * | error | [60,40,60] | 操作错误 | * | success | [100] | 过关/成功 | * | warning | [30,50,30,50,30] | 警告/危险逼近 | * | swipe | [10] | 滑动识别成功(最轻) | * | heartbeat | [40,100,40] | 心跳/倒计时紧张感 | * | pickup | [15] | 拾取/获得物品 | * | hit | [80] | 受击/终结性失败 | * | beat | [8] | 节拍锚点(最轻) | * | danger | [25,40,25,40,25] | 危险逼近 | * * 特性检测:'vibrate' in navigator。支持的平台正常震动; * **不支持的平台(iOS Safari 等)降级为视觉反馈**——全屏边框闪光覆盖层 * (#haptics-flash,样式在 style.css),不同震型对应不同颜色与闪烁次数: * confirm 白·1 次 / error 红·2 次 / success 绿·1 次 / warning 橙·3 次 / * swipe 白·极淡 1 次 / heartbeat 粉·2 次 / pickup 青·1 次 / hit 红·强 1 次 / * beat 白·极淡 1 次 / danger 红·快 3 次。两者都不行的环境静默 no-op。 * * 用法: * import { vibrate } from '../engine/haptics.js'; * vibrate('confirm'); // 预定义名称 * vibrate([50, 30, 50]); // 自定义震型 * * 注意:gesture.js 已内置基础反馈(滑动→swipe,tap/doubletap→confirm), * 游戏逻辑层只需在语义事件(错误/成功/警告等)处自行调用。 */ /** @type {Record} */ export const HAPTIC_PATTERNS = { confirm: [20], error: [60, 40, 60], success: [100], warning: [30, 50, 30, 50, 30], swipe: [10], heartbeat: [40, 100, 40], pickup: [15], hit: [80], beat: [8], danger: [25, 40, 25, 40, 25], }; /** 当前环境是否支持震动(iOS Safari 为 false,此时 vibrate() 走视觉降级)。 */ export const hapticsSupported = typeof navigator !== 'undefined' && 'vibrate' in navigator; /** 震型 → 视觉降级闪光参数:[颜色, 动画 class, 亮度] */ const FLASH_MAP = { confirm: ['#ffffff', 'hp-once', 0.7], error: ['#ff5252', 'hp-twice', 0.9], success: ['#69f0ae', 'hp-once', 0.9], warning: ['#ffb74d', 'hp-thrice', 0.9], swipe: ['#ffffff', 'hp-once', 0.25], heartbeat: ['#f48fb1', 'hp-twice', 0.7], pickup: ['#80deea', 'hp-once', 0.7], hit: ['#ff1744', 'hp-once', 1], beat: ['#ffffff', 'hp-once', 0.15], danger: ['#ff5252', 'hp-thrice', 0.9], }; let flashEl = null; /** 视觉降级:全屏边框闪光(覆盖层常驻 body,改 class 重触发动画)。 */ function _flash(patternName) { if (typeof document === 'undefined') return; const [color, anim, alpha] = FLASH_MAP[patternName] || FLASH_MAP.confirm; if (!flashEl) { flashEl = document.getElementById('haptics-flash'); if (!flashEl) { flashEl = document.createElement('div'); flashEl.id = 'haptics-flash'; document.body.appendChild(flashEl); } } flashEl.style.borderColor = color; flashEl.style.boxShadow = `inset 0 0 32px ${color}`; flashEl.style.setProperty('--hp-alpha', alpha); flashEl.style.opacity = ''; flashEl.className = ''; void flashEl.offsetWidth; // 强制 reflow,重触发动画 flashEl.className = anim; } /** * 触发震动;不支持 vibrate 的平台自动降级为边框闪光。 * @param {keyof typeof HAPTIC_PATTERNS | number[]} pattern 预定义名称或毫秒数组 */ export function vibrate(pattern) { const name = typeof pattern === 'string' ? pattern : null; const p = name ? HAPTIC_PATTERNS[name] : pattern; if (!p) { console.warn('[haptics] 未知震型:', pattern); return; } if (hapticsSupported) { try { navigator.vibrate(p); return; } catch (_) { // 某些平台调用即抛(极少见)→ 落入视觉降级 } } _flash(name || 'confirm'); // 自定义数组无语义名,按 confirm 闪 } export default vibrate;