/** * 光栅卡倾斜驱动(铸爱预览共用) * * 使用 imengyu-UniAndroidGyro 插件的 startGyro + getGyroValue 轮询。 * startGyroWithCallback 在当前插件版本不回调,故回退轮询方案。 * * res.x/y/z 是处理后的绝对角度,但实测噪声极大(帧间跳变 ±20°), * 真实倾斜不大可能超过 ±15°/帧,大跳变应视为位移加速度伪影被拒掉。 * * 关键设计: * 1. 跳变拒绝:abs(raw-prev) > 15° → clampprev±15°,掐掉位移伪影 * 2. 跳过前 5 帧预热帧(raw 接近 0 的假数据) * 3. 快通道 α=0.25:约 0.2s 到 63%,离散切换更跟手 * 4. 慢通道 α=0.08 + 0 冷启动:约 0.9s 到 90%,设备已倾斜时第1帧 dx 即有值 * 5. dx/dy 直接交给上层做 2D 矢量决策(弃用单轴选主,杜绝 x↔y 振荡) * 6. delta 符号滞回(按轴独立,仅非离散模式兜底用) * 7. delta 死区:abs(delta) < 0.5° 归零,抑制静止微颤 * 8. stopGyro 后等 600ms 再 startGyro,避开异步停止竞态 * 9. startGyro 失败时带退避重试(最多 3 次,间隔 400/800/1200ms) * * 参考文档:https://ext.dcloud.net.cn/plugin?id=6237 */ const NATIVE_PLUGIN_ID = 'imengyu-UniAndroidGyro-GyroModule' // #ifdef APP-PLUS function tryRequireGyroModule() { try { if (typeof uni === 'undefined' || typeof uni.requireNativePlugin !== 'function') return null const mod = uni.requireNativePlugin(NATIVE_PLUGIN_ID) if (mod && typeof mod.startGyro === 'function' && typeof mod.stopGyro === 'function') { return mod } } catch (e) { console.warn('[useLenticularStudioTilt] requireNativePlugin failed', e) } return null } // #endif // #ifndef APP-PLUS function tryRequireGyroModule() { return null } // #endif const POLL_INTERVAL_MS = 33 // 跳变拒绝阈值(度/帧):abs(raw-prev) 超过此值视为位移伪影 const JUMP_REJECT_DEG = 15 // 跳过预热帧数(startGyro 成功后前几帧 raw≈0) const SKIP_WARMUP_FRAMES = 5 // 快通道 α=0.25:约 7 帧 (0.23s) 追到 63%,离散切换更跟手 const FAST_ALPHA = 0.25 // 慢通道 α=0.08:约 ln(0.1)/ln(0.92)≈27 帧 (0.9s) 追到 90% const SLOW_ALPHA = 0.08 // delta 死区(度):绝对值小于此值输出 0 const DELTA_DEADZONE_DEG = 0.5 // delta 符号滞回:符号翻转需连续 N 帧相反符号才生效 const DELTA_SIGN_HYST_FRAMES = 4 // stopGyro → startGyro 等待时间 (ms),避开异步停止竞态 const RESTART_DELAY_MS = 600 // startGyro 失败重试配置:最大重试次数、每次间隔递增 const STARTGYRO_MAX_RETRIES = 3 const STARTGYRO_BASE_RETRY_MS = 400 /** * @param {object} opts * @param {(x: number, y: number) => void} opts.simulate * @param {(dx: number, dy: number) => void} [opts.simulateFromSignedDegrees] * @param {import('vue').Ref} opts.gyroSourceLabel * @param {() => void} [opts.onTiltDriverFallback] */ export function useLenticularStudioTilt(opts) { const { simulate, simulateFromSignedDegrees, gyroSourceLabel, onTiltDriverFallback } = opts let gyroModule = null let tiltGen = 0 let pollTimer = null let startTimer = null // 跳变拒绝:上一帧 raw,初始 NaN 表示"无条件接受第一帧" let prevRawX = NaN, prevRawY = NaN // 预热跳过计数器 let warmupSkip = 0 // 快通道:平滑跟踪(α=0.25 跟手 + 抑制噪声) let fastX = NaN, fastY = NaN // 慢通道:缓慢追踪"休息位"(α=0.08 自动重居中) let slowX = NaN, slowY = NaN // delta 符号滞回:按轴独立存储,避免跨轴符号干扰 let lastDeltaSignX = 0, lastDeltaSignY = 0 let oppositeSignStreakX = 0, oppositeSignStreakY = 0 // 日志节流 let lastLogSecond = 0 function resetState() { prevRawX = NaN; prevRawY = NaN warmupSkip = 0 fastX = NaN; fastY = NaN slowX = NaN; slowY = NaN lastDeltaSignX = 0; lastDeltaSignY = 0 oppositeSignStreakX = 0; oppositeSignStreakY = 0 } function stopPoll() { if (pollTimer != null) { try { clearInterval(pollTimer) } catch (_) {} pollTimer = null } if (startTimer != null) { try { clearTimeout(startTimer) } catch (_) {} startTimer = null } } function stopNative() { tiltGen++ stopPoll() if (gyroModule && typeof gyroModule.stopGyro === 'function') { try { gyroModule.stopGyro(() => {}) } catch (_) {} } gyroModule = null // stopGyro 是异步的,重置状态避免旧回调残留 resetState() } // ——— 快慢双通道滤波器 ——— /** * 跳变拒绝:单帧 raw 变化超过 JUMP_REJECT_DEG 则 clamp,掐掉位移加速度伪影 */ function clampJump(rawX, rawY) { let cx = rawX, cy = rawY if (Number.isFinite(prevRawX)) { if (Math.abs(rawX - prevRawX) > JUMP_REJECT_DEG) { cx = prevRawX + Math.sign(rawX - prevRawX) * JUMP_REJECT_DEG } if (Math.abs(rawY - prevRawY) > JUMP_REJECT_DEG) { cy = prevRawY + Math.sign(rawY - prevRawY) * JUMP_REJECT_DEG } } prevRawX = rawX; prevRawY = rawY return { cx, cy } } function updateFast(rawX, rawY) { if (!Number.isFinite(fastX)) { fastX = rawX; fastY = rawY; return } fastX += (rawX - fastX) * FAST_ALPHA fastY += (rawY - fastY) * FAST_ALPHA } function updateSlow(rawX, rawY) { if (!Number.isFinite(slowX)) { // 慢通道从 0 冷启动而非 rawX:让第1帧 dx 立即有值(手机已倾斜时不会白板) slowX = 0; slowY = 0 } slowX += (rawX - slowX) * SLOW_ALPHA slowY += (rawY - slowY) * SLOW_ALPHA } /** * 每帧选择绝对值更大的轴为当前主导轴(替代轴锁,直接用幅值说话) */ function pickDominantAxis(dx, dy) { return Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y' } /** * 符号滞回 + 死区:delta 仅在「连续 DELTA_SIGN_HYST_FRAMES 帧都是相反符号」后才翻转。 * 滞回状态按轴独立,避免 x↔y 切换时的跨轴符号干扰。 * @param {number} rawDelta - 原始 signedDelta(度),经过快慢通道的差值 * @param {'x'|'y'} axis - 当前主导轴 * @returns {number} 滞回 + 死区后的 signedDelta */ function filterDelta(rawDelta, axis) { // 死区 if (Math.abs(rawDelta) < DELTA_DEADZONE_DEG) { if (axis === 'x') { oppositeSignStreakX = 0; lastDeltaSignX = 0 } else { oppositeSignStreakY = 0; lastDeltaSignY = 0 } return 0 } const rawSign = rawDelta > 0 ? 1 : -1 const lastSign = axis === 'x' ? lastDeltaSignX : lastDeltaSignY if (lastSign === 0) { if (axis === 'x') { lastDeltaSignX = rawSign; oppositeSignStreakX = 0 } else { lastDeltaSignY = rawSign; oppositeSignStreakY = 0 } return rawDelta } if (rawSign === lastSign) { if (axis === 'x') oppositeSignStreakX = 0 else oppositeSignStreakY = 0 return rawDelta } // 相反符号:累积连续帧,达到阈值才翻转 if (axis === 'x') { oppositeSignStreakX++ if (oppositeSignStreakX >= DELTA_SIGN_HYST_FRAMES) { lastDeltaSignX = rawSign oppositeSignStreakX = 0 return rawDelta } } else { oppositeSignStreakY++ if (oppositeSignStreakY >= DELTA_SIGN_HYST_FRAMES) { lastDeltaSignY = rawSign oppositeSignStreakY = 0 return rawDelta } } // 还没到阈值:输出 0(抑制翻转) return 0 } function handleGyroValue(res) { const rawX = Number(res.x || 0) const rawY = Number(res.y || 0) // 跳过预热帧(startGyro 刚成功后前几帧 raw≈0) if (warmupSkip < SKIP_WARMUP_FRAMES) { warmupSkip++ return } // 跳变拒绝:掐掉位移加速度伪影 const { cx, cy } = clampJump(rawX, rawY) updateFast(cx, cy) updateSlow(cx, cy) const dx = fastX - slowX const dy = fastY - slowY // 不再选单轴——把 dx/dy 直接交给上层做 2D 幅值决策(避免 x↔y 振荡) if (Math.floor(Date.now() / 1000) !== lastLogSecond) { lastLogSecond = Math.floor(Date.now() / 1000) console.log('[StudioTilt]', { rawX: rawX.toFixed(1), rawY: rawY.toFixed(1), clampX: cx.toFixed(1), clampY: cy.toFixed(1), fastX: fastX.toFixed(1), fastY: fastY.toFixed(1), slowX: slowX.toFixed(1), slowY: slowY.toFixed(1), dx: dx.toFixed(1), dy: dy.toFixed(1), }) } // 离散模式:输出 (dx, dy) 给上层做 2D 挡位映射;无映射器时归零 if (typeof simulateFromSignedDegrees === 'function') { simulateFromSignedDegrees(dx, dy) } else { simulate(0, 0) } } function pollOnce(myGen) { if (myGen !== tiltGen || !gyroModule) return try { gyroModule.getGyroValue((res) => { if (myGen !== tiltGen || !gyroModule || !res) return gyroSourceLabel.value = 'gyroscope' handleGyroValue(res) }) } catch (_) {} } function start() { console.log('[useLenticularStudioTilt] start()') // #ifdef APP-PLUS stop() tiltGen++ resetState() lastLogSecond = 0 const myGen = tiltGen gyroModule = tryRequireGyroModule() if (!gyroModule) { console.warn('[useLenticularStudioTilt] plugin not found') gyroSourceLabel.value = 'simulation' simulate(0, 0) return } startTimer = setTimeout(() => { startTimer = null if (myGen !== tiltGen || !gyroModule) return tryStartGyro(myGen, 0) }, RESTART_DELAY_MS) // #endif // #ifndef APP-PLUS gyroSourceLabel.value = 'simulation' simulate(0, 0) // #endif } /** * 带退避重试的 startGyro。 * retry=0 首次,retry=1/2/3 每次间隔 STARTGYRO_BASE_RETRY_MS*retry ms */ function tryStartGyro(myGen, retry) { if (myGen !== tiltGen || !gyroModule) return if (retry > 0) { console.log('[useLenticularStudioTilt] startGyro retry #', retry) } gyroModule.startGyro({ interval: 'ui' }, (res) => { if (myGen !== tiltGen || !gyroModule) return if (!res || !res.success) { // 如果是「listener running」且还有重试配额,退避后再试 const errMsg = (res && res.errMsg) || '' if (retry < STARTGYRO_MAX_RETRIES && /running/i.test(errMsg)) { const delay = STARTGYRO_BASE_RETRY_MS * (retry + 1) console.warn('[useLenticularStudioTilt] startGyro busy, retry in', delay, 'ms') startTimer = setTimeout(() => { startTimer = null tryStartGyro(myGen, retry + 1) }, delay) return } console.warn('[useLenticularStudioTilt] startGyro failed:', res) gyroSourceLabel.value = 'simulation' simulate(0, 0) if (typeof onTiltDriverFallback === 'function') { try { onTiltDriverFallback() } catch (_) {} } return } console.log('[useLenticularStudioTilt] startGyro ok, polling') gyroSourceLabel.value = 'gyroscope' warmupSkip = 0 pollOnce(myGen) pollTimer = setInterval(() => pollOnce(myGen), POLL_INTERVAL_MS) }) } function stop() { console.log('[useLenticularStudioTilt] stop()') stopNative() gyroSourceLabel.value = 'simulation' } function recalibrate() { resetState() simulate(0, 0) } return { start, stop, recalibrate } }