/** * 光栅卡工作室倾斜驱动:App 优先 imengyu-UniAndroidGyro(DCloud 插件 id=6237),否则加速度计。 * * 与插件约定对齐(官方说明): * - 模块:`imengyu-UniAndroidGyro-GyroModule`,`uni.requireNativePlugin`。 * - `startGyro`:首包回调仅 success/errMsg/notSupport(**无 x/y/z 角度属正常**);持续数据需 **`getGyroValue` 轮询**(插件官方示例);勿把首包当「无参数」故障。 * - `startGyroWithCallback`:部分自定义基座 + HX SDK 组合下长期收不到角度帧;本实现以 **startGyro + 轮询** 为主,WithCallback 为兜底。 * - 轮询:插件若同时提供 `getGyroValue` 与 `getGyroValueSync`,**优先异步**(与官方示例一致);部分环境下 Sync 长期无 x/y/z,易触发看门狗回退。 * - 再次开启前须 `getGyroStarted`;若已在监听则先 `stopGyro`(不可重复开启)。 * - 页面进入后立即监听前宜延时再 start(官方示例约 100ms)。 * - 插件返回的 x/y/z 为**当前各轴角度(度)**,与「进入光栅卡时采样的基准角」做差得到相对姿态;离散档位应使用**与轴向切换无关**的标量(如 max(|Δx|,|Δy|,|Δz|)),避免「谁幅度大跟谁」在临界区来回跳轴导致画面乱切。 * - 原生陀螺看门狗:按「**连续无有效角度帧**」计时(每来一帧即重置),避免固定短超时与晚首包 / `getGyroStarted`→`stopGyro`→`kick` 慢链冲突;超时仍无帧再回退加速度计。 * * @see https://ext.dcloud.net.cn/plugin?id=6237 */ const NATIVE_PLUGIN_ID = 'imengyu-UniAndroidGyro-GyroModule' /** iOS 等环境下布尔可能为字符串 `'true'` */ function isTruthyFlag(v) { return v === true || v === 'true' } function hasAnglePayload(res) { if (!res || typeof res !== 'object') return false /** 插件首包常无 x/y/z;有数值(含 0)才视为角度帧 */ return [res.x, res.y, res.z].some((v) => Number.isFinite(Number(v))) } /** 无角度包时:仅明确失败才放弃原生;success 缺省不等同于失败(部分 ROM 首包字段不全) */ function isExplicitGyroHandshakeFailure(res) { if (!res || typeof res !== 'object') return false if (isTruthyFlag(res.notSupport)) return true if (res.success === false || res.success === 'false') return true if (res.success === '') return true return false } // #ifdef APP-PLUS function tryRequireImengyuGyro() { try { if (typeof uni === 'undefined' || typeof uni.requireNativePlugin !== 'function') return null const mod = uni.requireNativePlugin(NATIVE_PLUGIN_ID) const okPoll = mod && typeof mod.startGyro === 'function' && typeof mod.stopGyro === 'function' && (typeof mod.getGyroValue === 'function' || typeof mod.getGyroValueSync === 'function') const okCb = mod && typeof mod.startGyroWithCallback === 'function' && typeof mod.stopGyro === 'function' if (okPoll || okCb) { return mod } } catch (e) { console.warn('[useLenticularStudioTilt] requireNativePlugin failed', e) } return null } // #endif // #ifndef APP-PLUS function tryRequireImengyuGyro() { return null } // #endif /** 基线帧数 */ const ACCEL_BASELINE_FRAMES = 10 const INSTANT_FULL_RAD = 0.32 const MAX_STEP_RAD = 0.48 const STEP_GAIN = 2.15 const SIN_PHASE_SCALE = 1.18 const BLEND_INSTANT = 0.38 /** 原生插件:x/y/z 均为角度(度);进入页面后先采若干帧算**基准角**(与官方「先 start 再轮询 getGyroValue」一致) */ const NATIVE_BASELINE_FRAMES = 12 const NATIVE_FULL_DEG = 10 /** 加速度计直连 + 离散档位:进入后多帧圆均值定「水平」基准,避免首帧抖动 */ const STUDIO_ACCEL_BASELINE_FRAMES = 12 /** 连续无有效 x/y/z 角度帧则回退加速度计(ms);覆盖 getGyroStarted→stopGyro→kick 慢链与晚首包 */ const NATIVE_GYRO_STALL_FALLBACK_MS = 14000 function deltaDeg(value, base) { const v = Number(value) const b = Number(base) if (!Number.isFinite(v) || !Number.isFinite(b)) return 0 let d = v - b while (d > 180) d -= 360 while (d < -180) d += 360 return d } /** 取与基线差最大的轴,避免握持方向不同导致「陀螺仪没反应」 */ function pickDominantTiltDelta(dx, dy, dz) { const ax = Math.abs(dx) const ay = Math.abs(dy) const az = Math.abs(dz) if (ax >= ay && ax >= az) return dx if (ay >= az) return dy return dz } /** 相对基准的最大欧拉偏差(度),左右/多轴合成时仍单调,且不会在临界区因「换轴」突变符号 */ function maxAbsTiltDeltaDeg(dx, dy, dz) { return Math.max(Math.abs(dx), Math.abs(dy), Math.abs(dz)) } /** * 根据进入后采集的样本,选「抖动范围最大」的轴作为连续跟手的倾角轴(基线期手持微动时仍稳定)。 * @param {{ x: number, y: number, z: number }[]} samples * @returns {0|1|2} */ function inferPrimaryTiltAxisFromSamples(samples) { if (!samples || samples.length < 2) return 1 let bestAxis = 1 let bestSpread = -1 for (let axis = 0; axis < 3; axis++) { const vals = samples.map((s) => [s.x, s.y, s.z][axis]) const mn = Math.min(...vals) const mx = Math.max(...vals) const spread = mx - mn if (spread > bestSpread) { bestSpread = spread bestAxis = axis } } /* 几乎完全静止:竖屏常见左右倾角在 y */ if (bestSpread < 0.35) return 1 return bestAxis } function circularMeanRad(rads) { if (!rads.length) return 0 let sx = 0 let sy = 0 for (const r of rads) { sx += Math.sin(r) sy += Math.cos(r) } return Math.atan2(sx / rads.length, sy / rads.length) } function majorityPlane(votes) { const cnt = { xz: 0, xy: 0, yz: 0 } for (const v of votes) cnt[v] = (cnt[v] || 0) + 1 if (cnt.xz >= cnt.xy && cnt.xz >= cnt.yz) return 'xz' if (cnt.xy >= cnt.yz) return 'xy' return 'yz' } function detectGravityPlane(nx, ny, nz) { const ax = Math.abs(nx) const ay = Math.abs(ny) const az = Math.abs(nz) if (ay >= ax && ay >= az) return 'xz' if (az >= ax && az >= ay) return 'xy' return 'yz' } function uvForPlane(nx, ny, nz, mode) { if (mode === 'xz') return { u: nx, v: nz } if (mode === 'xy') return { u: nx, v: ny } return { u: ny, v: nz } } /** * @param {object} opts * @param {(x: number, y: number) => void} opts.simulate * @param {(tiltMagDeg: number) => void} [opts.simulateFromSignedDegrees] 若提供:用相对进入时基准的**倾角标量(度)**驱动预览(由页面做离散档位等);原生/加速度计侧会喂入非负幅度为主 * @param {import('vue').Ref} opts.gyroSourceLabel * @param {boolean} [opts.useStudioAccelDirect] 光栅工作室:加速度计用重力投影直连(免原 warmup) * @param {() => void} [opts.onTiltDriverFallback] 从原生陀螺失败/超时回退到加速度计时调用(用于重置离散档位等 UI 状态) */ export function useLenticularStudioTilt(opts) { const { simulate, simulateFromSignedDegrees, gyroSourceLabel, useStudioAccelDirect = false, onTiltDriverFallback, } = opts let mode = /** @type {'native'|'accel'} */ ('accel') let gyroModule = null let nativeStartTimer = null let nativeWatchdogTimer = null let gyroCbGuardTimer = null let nativePollTimer = null /** @type {{ x: number, y: number, z: number }[]} */ let nativeTrSamples = [] let nativeBase = { x: 0, y: 0, z: 0 } let nativeBaselineReady = false let nativeSmoothed = 0 /** 连续模式下与基线样本推断的主倾角轴(0=x,1=y,2=z),避免每帧在 x/y 间抢主导 */ let nativeLockedAxisIdx = 1 /** 递增以丢弃 stop 之后的原生回调 / 延时任务 */ let tiltGen = 0 /** 原生:每次收到角度帧时重置该计时器;超时仍无帧则回退加速度计 */ let scheduleNativeGyroStallFallback = /** @type {null | (() => void)} */ (null) /** 加速度计直连:多帧采样缓冲,填满后取圆均值为基准角 */ let studioAccelBaselineRads = [] /** 加速度计直连模式下「校零」后的基准(弧度),仅与 simulateFromSignedDegrees 联用 */ let studioAccelBaseRad = null let accelHandler = null let accelSmoothed = 0 let rollAccum = 0 let prevCu = null let prevCv = null let tiltCal = { lockedMode: null, planeVotes: [], sumU: 0, sumV: 0, count: 0, ready: false, bu: 1, bv: 0, } function resetAccelCalibration() { tiltCal = { lockedMode: null, planeVotes: [], sumU: 0, sumV: 0, count: 0, ready: false, bu: 1, bv: 0, } } function resetNativeBaseline() { nativeTrSamples = [] nativeBase = { x: 0, y: 0, z: 0 } nativeBaselineReady = false nativeSmoothed = 0 nativeLockedAxisIdx = 1 } function resetStudioAccelBaseline() { studioAccelBaseRad = null studioAccelBaselineRads = [] } function accelToRelativeTilt01(ax, ay, az) { const gMag = Math.hypot(ax, ay, az) if (gMag < 0.18) return null const nx = ax / gMag const ny = ay / gMag const nz = az / gMag if (!tiltCal.ready) { const m = detectGravityPlane(nx, ny, nz) if (tiltCal.planeVotes.length < 5) tiltCal.planeVotes.push(m) if (tiltCal.planeVotes.length >= 5 && tiltCal.lockedMode == null) { tiltCal.lockedMode = majorityPlane(tiltCal.planeVotes) tiltCal.sumU = 0 tiltCal.sumV = 0 tiltCal.count = 0 } if (tiltCal.lockedMode == null) return { warmup: true } const { u, v } = uvForPlane(nx, ny, nz, tiltCal.lockedMode) const h = Math.hypot(u, v) if (h < 0.035) return { warmup: true } tiltCal.sumU += u / h tiltCal.sumV += v / h tiltCal.count += 1 if (tiltCal.count >= ACCEL_BASELINE_FRAMES) { const bh = Math.hypot(tiltCal.sumU, tiltCal.sumV) || 1 tiltCal.bu = tiltCal.sumU / bh tiltCal.bv = tiltCal.sumV / bh tiltCal.ready = true } return { warmup: true } } const { u, v } = uvForPlane(nx, ny, nz, tiltCal.lockedMode) const h = Math.hypot(u, v) if (h < 0.028) return null const cu = u / h const cv = v / h const sinD = cu * tiltCal.bv - cv * tiltCal.bu const cosD = cu * tiltCal.bu + cv * tiltCal.bv const deltaRad = Math.atan2(sinD, cosD) return { warmup: false, cu, cv, deltaRad } } function stopNative() { tiltGen++ if (nativeWatchdogTimer != null) { try { clearTimeout(nativeWatchdogTimer) } catch (e) { /* noop */ } nativeWatchdogTimer = null } if (gyroCbGuardTimer != null) { try { clearTimeout(gyroCbGuardTimer) } catch (e) { /* noop */ } gyroCbGuardTimer = null } if (nativePollTimer != null) { try { clearInterval(nativePollTimer) } catch (e) { /* noop */ } nativePollTimer = null } if (nativeStartTimer != null) { try { clearTimeout(nativeStartTimer) } catch (e) { /* noop */ } nativeStartTimer = null } if (gyroModule && typeof gyroModule.stopGyro === 'function') { try { gyroModule.stopGyro(() => {}) } catch (e) { /* noop */ } } gyroModule = null mode = 'accel' scheduleNativeGyroStallFallback = null } function stopAccel() { try { if (accelHandler && typeof uni.offAccelerometerChange === 'function') { uni.offAccelerometerChange(accelHandler) } } catch (e) { /* noop */ } accelHandler = null try { uni.stopAccelerometer({}) } catch (e) { /* noop */ } } function startAccelInternal(options = {}) { const fromNativeFallback = options.fromNativeFallback === true mode = 'accel' stopAccel() if (fromNativeFallback && typeof onTiltDriverFallback === 'function') { try { onTiltDriverFallback() } catch (e) { /* noop */ } } resetAccelCalibration() accelSmoothed = 0 rollAccum = 0 prevCu = null prevCv = null if (useStudioAccelDirect) { if (typeof simulateFromSignedDegrees === 'function') { resetStudioAccelBaseline() } accelHandler = (res) => { const ax = Number(res.x) || 0 const ay = Number(res.y) || 0 const az = Number(res.z) || 0 const gMag = Math.hypot(ax, ay, az) if (gMag < 0.12) return /* 竖屏常见握持:左右倾斜主要反映为 ax/az 与重力的关系(免 warmup,避免长期无输出) */ if (typeof simulateFromSignedDegrees === 'function') { const tiltRad = Math.atan2(-ax, az) if (studioAccelBaseRad == null) { studioAccelBaselineRads.push(tiltRad) if (studioAccelBaselineRads.length < STUDIO_ACCEL_BASELINE_FRAMES) { simulate(0, 0) return } studioAccelBaseRad = circularMeanRad(studioAccelBaselineRads) studioAccelBaselineRads = [] } let delta = tiltRad - studioAccelBaseRad while (delta > Math.PI) delta -= 2 * Math.PI while (delta < -Math.PI) delta += 2 * Math.PI const signedDeg = delta * (180 / Math.PI) simulateFromSignedDegrees(Math.abs(signedDeg)) } else { const tiltRaw = Math.atan2(-ax, az) / (Math.PI / 5) const tilt01 = Math.max(-1, Math.min(1, tiltRaw)) accelSmoothed += (tilt01 - accelSmoothed) * 0.58 simulate(Math.max(-1, Math.min(1, accelSmoothed)), 0) } gyroSourceLabel.value = 'accelerometer' } } else { accelHandler = (res) => { const ax = Number(res.x) || 0 const ay = Number(res.y) || 0 const az = Number(res.z) || 0 const out = accelToRelativeTilt01(ax, ay, az) if (out == null) return if (out.warmup) return const { cu, cv, deltaRad } = out if (prevCu != null && prevCv != null) { let step = Math.atan2(cu * prevCv - cv * prevCu, cu * prevCu + cv * prevCv) if (step > Math.PI * 0.92) step -= 2 * Math.PI if (step < -Math.PI * 0.92) step += 2 * Math.PI step = Math.max(-MAX_STEP_RAD, Math.min(MAX_STEP_RAD, step)) rollAccum += step * STEP_GAIN } prevCu = cu prevCv = cv const instant = Math.max(-1, Math.min(1, deltaRad / INSTANT_FULL_RAD)) const cyclic = Math.sin(rollAccum * SIN_PHASE_SCALE) const target = BLEND_INSTANT * instant + (1 - BLEND_INSTANT) * cyclic accelSmoothed += (target - accelSmoothed) * 0.74 simulate(Math.max(-1, Math.min(1, accelSmoothed)), 0) gyroSourceLabel.value = 'accelerometer' } } try { uni.onAccelerometerChange(accelHandler) const applyStart = (interval) => { uni.startAccelerometer({ interval, success: () => { gyroSourceLabel.value = 'accelerometer' }, fail: () => { if (interval === 'game') { applyStart('normal') return } gyroSourceLabel.value = 'simulation' }, }) } applyStart('game') } catch (e) { gyroSourceLabel.value = 'simulation' } } function onNativeAngleFrame(x, y, z) { const vx = Number(x) const vy = Number(y) const vz = Number(z) if (![vx, vy, vz].some(Number.isFinite)) return /* 有角度帧即视为陀螺仪在工作,含基线采集期,避免看门狗误判「从未产出」而中途切加速度计 */ gyroSourceLabel.value = 'gyroscope' if (!nativeBaselineReady) { if ([vx, vy, vz].every(Number.isFinite)) { nativeTrSamples.push({ x: vx, y: vy, z: vz }) } if (nativeTrSamples.length >= NATIVE_BASELINE_FRAMES) { const n = nativeTrSamples.length nativeBase.x = nativeTrSamples.reduce((a, b) => a + b.x, 0) / n nativeBase.y = nativeTrSamples.reduce((a, b) => a + b.y, 0) / n nativeBase.z = nativeTrSamples.reduce((a, b) => a + b.z, 0) / n nativeLockedAxisIdx = inferPrimaryTiltAxisFromSamples(nativeTrSamples) nativeBaselineReady = true } simulate(0, 0) if (typeof scheduleNativeGyroStallFallback === 'function') { scheduleNativeGyroStallFallback() } return } const dx = deltaDeg(vx, nativeBase.x) const dy = deltaDeg(vy, nativeBase.y) const dz = deltaDeg(vz, nativeBase.z) if (typeof simulateFromSignedDegrees === 'function') { simulateFromSignedDegrees(maxAbsTiltDeltaDeg(dx, dy, dz)) } else { const axisDeltas = [dx, dy, dz] const chosen = axisDeltas[nativeLockedAxisIdx] ?? pickDominantTiltDelta(dx, dy, dz) const tilt01 = Math.max(-1, Math.min(1, chosen / NATIVE_FULL_DEG)) /* 跟手:过低显钝;过高易抖。插件已做角度融合,此处略轻低通即可 */ nativeSmoothed += (tilt01 - nativeSmoothed) * 0.86 simulate(Math.max(-1, Math.min(1, nativeSmoothed)), 0) } if (typeof scheduleNativeGyroStallFallback === 'function') { scheduleNativeGyroStallFallback() } } function startNativeInternal() { stopNative() stopAccel() resetNativeBaseline() // #ifdef APP-PLUS gyroModule = tryRequireImengyuGyro() if (!gyroModule) { mode = 'accel' startAccelInternal() return } mode = 'native' const myGen = tiltGen /** 与官方示例一致:normal / ui / game / fastest,game≈50Hz */ const startOpts = { interval: 'game' } scheduleNativeGyroStallFallback = () => { if (nativeWatchdogTimer != null) { try { clearTimeout(nativeWatchdogTimer) } catch (e) { /* noop */ } nativeWatchdogTimer = null } nativeWatchdogTimer = setTimeout(() => { nativeWatchdogTimer = null if (myGen !== tiltGen) return if (mode !== 'native') return console.warn( '[useLenticularStudioTilt] native gyro stalled (no angle frames for ' + NATIVE_GYRO_STALL_FALLBACK_MS + 'ms), falling back to accelerometer' ) stopNative() startAccelInternal({ fromNativeFallback: true }) }, NATIVE_GYRO_STALL_FALLBACK_MS) } function invokeStartNativeGyro() { const mod = gyroModule if (myGen !== tiltGen || !mod) return function startNativePoll() { if (nativePollTimer != null) { try { clearInterval(nativePollTimer) } catch (e) { /* noop */ } nativePollTimer = null } /** * 官方示例只用异步 getGyroValue;部分基座上 getGyroValueSync 长期无 x/y/z, * 仅在没有异步接口时才用 Sync(见插件 id=6237 说明)。 */ const useSync = typeof mod.getGyroValueSync === 'function' && typeof mod.getGyroValue !== 'function' const intervalMs = useSync ? 20 : 28 const pollOnce = () => { if (myGen !== tiltGen || !gyroModule) return try { if (useSync) { const v = mod.getGyroValueSync() if (v && hasAnglePayload(v)) { onNativeAngleFrame(v.x, v.y, v.z) } return } mod.getGyroValue((v) => { if (myGen !== tiltGen || !gyroModule || !v) return if (hasAnglePayload(v)) { onNativeAngleFrame(v.x, v.y, v.z) } }) } catch (e) { /* noop */ } } pollOnce() nativePollTimer = setInterval(pollOnce, intervalMs) } let kickOnce = false const kick = () => { if (kickOnce) return if (myGen !== tiltGen || !gyroModule) return kickOnce = true if (gyroCbGuardTimer != null) { try { clearTimeout(gyroCbGuardTimer) } catch (e) { /* noop */ } gyroCbGuardTimer = null } const usePoll = typeof mod.startGyro === 'function' && (typeof mod.getGyroValue === 'function' || typeof mod.getGyroValueSync === 'function') if (usePoll) { try { mod.startGyro(startOpts, (res) => { if (myGen !== tiltGen || !gyroModule) return /* 插件约定:首包只表示是否开启成功,不含持续角度;若回调无对象则无法进入官方要求的 getGyroValue 轮询 */ if (!res) { console.warn( '[useLenticularStudioTilt] startGyro callback received no argument (see plugin doc: first callback is handshake only)' ) stopNative() startAccelInternal({ fromNativeFallback: true }) return } if (isExplicitGyroHandshakeFailure(res)) { console.warn('[useLenticularStudioTilt] startGyro handshake failed', res) stopNative() startAccelInternal({ fromNativeFallback: true }) return } /* 与官方示例一致:success 为真后再轮询;iOS 上常为字符串 'true' */ if (Object.prototype.hasOwnProperty.call(res, 'success') && !isTruthyFlag(res.success)) { console.warn('[useLenticularStudioTilt] startGyro success=false', res) stopNative() startAccelInternal({ fromNativeFallback: true }) return } if (hasAnglePayload(res)) { onNativeAngleFrame(res.x, res.y, res.z) } else { simulate(0, 0) } startNativePoll() }) } catch (e) { console.warn('[useLenticularStudioTilt] startGyro error', e) stopNative() startAccelInternal({ fromNativeFallback: true }) } return } try { mod.startGyroWithCallback(startOpts, (res) => { if (myGen !== tiltGen || !gyroModule) return if (!res) { console.warn('[useLenticularStudioTilt] startGyroWithCallback: empty callback argument') stopNative() startAccelInternal({ fromNativeFallback: true }) return } if (!hasAnglePayload(res)) { if (isExplicitGyroHandshakeFailure(res)) { console.warn('[useLenticularStudioTilt] native gyro handshake failed', res) stopNative() startAccelInternal({ fromNativeFallback: true }) return } simulate(0, 0) return } onNativeAngleFrame(res.x, res.y, res.z) }) } catch (e) { console.warn('[useLenticularStudioTilt] startGyroWithCallback error', e) stopNative() startAccelInternal({ fromNativeFallback: true }) } } if (typeof mod.getGyroStarted === 'function') { gyroCbGuardTimer = setTimeout(() => { gyroCbGuardTimer = null if (myGen !== tiltGen || !gyroModule || kickOnce) return console.warn('[useLenticularStudioTilt] getGyroStarted callback timeout, starting gyro') kick() }, 700) mod.getGyroStarted((r) => { if (gyroCbGuardTimer != null) { try { clearTimeout(gyroCbGuardTimer) } catch (e) { /* noop */ } gyroCbGuardTimer = null } if (myGen !== tiltGen || !gyroModule) return const started = r && isTruthyFlag(r.started) if (started) { let stopDone = false const stopTimer = setTimeout(() => { if (stopDone || myGen !== tiltGen || !gyroModule) return stopDone = true console.warn('[useLenticularStudioTilt] stopGyro callback timeout, continuing') setTimeout(kick, 80) }, 700) mod.stopGyro(() => { if (stopDone || myGen !== tiltGen || !gyroModule) return stopDone = true try { clearTimeout(stopTimer) } catch (e) { /* noop */ } setTimeout(kick, 80) }) } else { kick() } }) } else { kick() } } /* 官方:因 uni-app 原因,页面进入后需延时再 start(示例 100ms) */ nativeStartTimer = setTimeout(() => { nativeStartTimer = null if (myGen !== tiltGen || !gyroModule) return invokeStartNativeGyro() if (typeof scheduleNativeGyroStallFallback === 'function') { scheduleNativeGyroStallFallback() } }, 100) // #endif // #ifndef APP-PLUS mode = 'accel' startAccelInternal() // #endif } function start() { // #ifdef APP-PLUS startNativeInternal() // #endif // #ifndef APP-PLUS startAccelInternal() // #endif } function stop() { stopNative() stopAccel() } function recalibrate() { resetAccelCalibration() resetNativeBaseline() resetStudioAccelBaseline() accelSmoothed = 0 rollAccum = 0 prevCu = null prevCv = null simulate(0, 0) } return { start, stop, recalibrate, } }