/** * 光栅卡倾斜驱动(铸爱预览共用) * * 多路径混合(按优先级): * - App Android:Native.js → SensorManager(加速度计低通滤波 → 重力方向 → 绝对倾角) * - App iOS:Native.js → CMMotionManager.deviceMotion.attitude(系统级传感器融合) * - H5:DeviceOrientationEvent(Chrome/Safari/Firefox 通用) * - 小程序:降级为模拟(无陀螺仪 API) * * ⚠️ uni-app 内置 uni.startGyroscope / uni.onGyroscopeChange 在 Android/iOS * 原生层未实现桥接——文档里写着但底层没接通。实测所有 3.x 版本 App 打包后调用均 * 静默失败(无报错、无日志、无回调)。官方跳过陀螺仪是因为 iOS CMMotionManager * 和 Android SensorManager 在生命周期、权限模型、回调线程上差异太大。 * 结论:App 端必须用 Native.js 或原生插件接入。 * * 平台接入要点: * - Android:BODY_SENSORS 权限(Android 12+ 强制,仅 ACCESS_FINE_LOCATION 不够) * - iOS:NSMotionUsageDescription(Info.plist,否则 CMMotionManager 直接失败) * - 不能在 onLoad 阶段调用传感器——须等 plus.ready 后 * - 务必检查传感器硬件:getDefaultSensor(TYPE_GYROSCOPE) 返回 null = 不支持 * * DeviceOrientationEvent: * - gamma: 左右倾斜 -90°~+90°(平放=0) * - beta: 前后倾斜 -180°~+180°(竖直=0) * - iOS 13+ Safari 需 requestPermission(用户手势触发) * * 关键设计: * 1. 跳变拒绝:abs(raw-prev) > 15° → clamp,掐掉位移加速度伪影 * 2. 快通道 α=0.25:约 0.2s 到 63%,离散切换更跟手 * 3. 慢通道 α=0.08 + 冷启动基线=0:自适应用户"休息位" * 4. dx/dy = fast - slow → 上层 2D 挡位映射 * 5. 跳过前 5 帧预热帧(传感器初始化噪声) */ import { createOneEuroFilter, nowMs } from '@/composables/useOneEuroFilter.js' const JUMP_REJECT_DEG = 15 const SKIP_WARMUP_FRAMES = 5 const FAST_ALPHA = 0.25 const SLOW_ALPHA = 0.08 /** * @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 tiltGen = 0 // 跳变拒绝:上一帧 raw,NaN 表示"无条件接受第一帧" let prevRawX = NaN, prevRawY = NaN // 预热跳过计数器 let warmupSkip = 0 // 快通道:平滑跟踪(α=0.25 跟手 + 抑制噪声) let fastX = NaN, fastY = NaN // 慢通道:缓慢追踪"休息位"(α=0.08 自动重居中,冷启动基线=0) let slowX = NaN, slowY = NaN // 日志节流 let lastLogSecond = 0 // ==================================================================== // Native.js 路径状态 // ==================================================================== // #ifdef APP-PLUS let nativeCleanup = null // () => void let nativeStartTimer = null // setTimeout ID // #endif // ==================================================================== // DeviceOrientation 路径状态 // ==================================================================== let orientationHandler = null // ——— 通用复位 ——— function resetState() { prevRawX = NaN; prevRawY = NaN warmupSkip = 0 fastX = NaN; fastY = NaN slowX = NaN; slowY = NaN } // ——— 跳变拒绝 ——— /** * 单帧 raw 变化超过 JUMP_REJECT_DEG 则 clamp,掐掉位移加速度伪影。 * 真实倾斜不太可能超过 ±15°/帧,大跳变必定是设备平移加速度的伪信号。 */ 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:让第1帧 dx 立即有值(设备已倾斜时不会白板) slowX = 0; slowY = 0 return } slowX += (rawX - slowX) * SLOW_ALPHA slowY += (rawY - slowY) * SLOW_ALPHA } // ——— 统一输出 ——— function applyDelta(dx, dy) { // 节流日志(每秒最多一条) if (Math.floor(Date.now() / 1000) !== lastLogSecond) { lastLogSecond = Math.floor(Date.now() / 1000) console.log('[StudioTilt] dx:', dx.toFixed(1), 'dy:', dy.toFixed(1), 'src:', gyroSourceLabel.value) } if (typeof simulateFromSignedDegrees === 'function') { simulateFromSignedDegrees(dx, dy) } else { simulate(0, 0) } } // ==================================================================== // Native.js 倾斜帧入口(Android & iOS 共用) // 参数:(rollDeg, pitchDeg) — 绝对倾角,单位为度 // rollDeg: 左右倾斜(>0 右侧低),映射到 dx // pitchDeg: 前后倾斜(>0 后端低),映射到 dy // ==================================================================== function handleNativeTiltFrame(rollDeg, pitchDeg) { if (warmupSkip < SKIP_WARMUP_FRAMES) { warmupSkip++ return } const { cx, cy } = clampJump(rollDeg, pitchDeg) updateFast(cx, cy) updateSlow(cx, cy) const dx = fastX - slowX const dy = fastY - slowY applyDelta(dx, dy) } // ==================================================================== // 路径 A-1: Native.js Android // 用加速度计 + 低通滤波提取重力方向 → 绝对 roll/pitch(度) // 避免直接积分陀螺仪(TYPE_GYROSCOPE 输出角速度 rad/s,积分漂移严重) // ==================================================================== // #ifdef APP-PLUS // ================================================================ // 插件路径 (Android):imengyu-UniAndroidGyro-GyroModule // 插件内部完成 Java float[] → JS number 转换,绕开 Native.js 限制 // ================================================================ const NATIVE_PLUGIN_ID = 'imengyu-UniAndroidGyro-GyroModule' const POLL_INTERVAL_MS = 33 const RESTART_DELAY_MS = 600 const STARTGYRO_MAX_RETRIES = 3 const STARTGYRO_BASE_RETRY_MS = 400 let gyroModule = null let gyroPollGen = 0 let pollTimer = null let startTimer = null function tryRequireGyroModule() { try { if (typeof uni !== 'undefined' && typeof uni.requireNativePlugin === 'function') { 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 } function stopPoll() { if (pollTimer != null) { try { clearInterval(pollTimer) } catch (_) {} pollTimer = null } if (startTimer != null) { try { clearTimeout(startTimer) } catch (_) {} startTimer = null } } function handleGyroValue(res) { const rawX = Number(res.y || 0) // roll → dx(左右) const rawY = Number(res.x || 0) // pitch → dy(前后) 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 applyDelta(dx, dy) } function pollOnce(myGen) { if (myGen !== gyroPollGen || !gyroModule) return try { gyroModule.getGyroValue((res) => { if (myGen !== gyroPollGen || !gyroModule || !res) return gyroSourceLabel.value = 'gyroscope' handleGyroValue(res) }) } catch (_) {} } function tryStartGyro(myGen, retry) { if (myGen !== gyroPollGen || !gyroModule) return if (retry > 0) { console.log('[useLenticularStudioTilt] startGyro retry #', retry) } gyroModule.startGyro({ interval: 'ui' }, (res) => { if (myGen !== gyroPollGen || !gyroModule) return if (!res || !res.success) { 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) onNativeFallback() return } console.log('[useLenticularStudioTilt] plugin gyro ok, polling') gyroSourceLabel.value = 'gyroscope' warmupSkip = 0 pollOnce(myGen) pollTimer = setInterval(() => pollOnce(myGen), POLL_INTERVAL_MS) }) } function stopGyroPlugin() { gyroPollGen++ stopPoll() if (gyroModule && typeof gyroModule.stopGyro === 'function') { try { gyroModule.stopGyro(() => {}) } catch (_) {} } gyroModule = null } // ================================================================ // 原生传感器 (iOS / Android 备用) // ================================================================ /** * @param {(rollDeg: number, pitchDeg: number) => void} onFrame * @returns {{ cleanup: () => void } | null} */ function createAndroidTiltSensor(onFrame) { try { if (typeof plus === 'undefined' || !plus.android) { console.warn('[useLenticularStudioTilt] plus.android not available') return null } const Context = plus.android.importClass('android.content.Context') const SensorManager = plus.android.importClass('android.hardware.SensorManager') const Sensor = plus.android.importClass('android.hardware.Sensor') const activity = plus.android.runtimeMainActivity() const sm = activity.getSystemService(Context.SENSOR_SERVICE) // 先检查陀螺仪硬件是否存在(作为诊断日志,实际用加速度计) const gyroSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE) if (!gyroSensor) { console.warn('[useLenticularStudioTilt] device has no gyroscope hardware — ' + 'using accelerometer gravity fallback') } else { console.log('[useLenticularStudioTilt] gyroscope hardware detected; using accelerometer gravity for absolute tilt') } // 检查加速度计 const gravitySensor = sm.getDefaultSensor(Sensor.TYPE_GRAVITY) const accelSensorRaw = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) console.log('[useLenticularStudioTilt] Android sensor support: ' + 'gyro=' + (gyroSensor ? 'YES' : 'NO') + ', ' + 'gravity=' + (gravitySensor ? 'YES' : 'NO') + ', ' + 'accel=' + (accelSensorRaw ? 'YES' : 'NO')) // TYPE_GRAVITY 直接输出重力分量,优先使用;不支持则用 TYPE_ACCELEROMETER + LPF let accelSensor = gravitySensor const useGravitySensor = !!accelSensor if (!accelSensor) { accelSensor = accelSensorRaw console.log('[useLenticularStudioTilt] TYPE_GRAVITY unavailable, using TYPE_ACCELEROMETER + LPF') } else { console.log('[useLenticularStudioTilt] using TYPE_GRAVITY sensor (no LPF needed)') } if (!accelSensor) { console.warn('[useLenticularStudioTilt] no accelerometer available — tilt NOT supported on this device') return null } // 低通滤波状态(仅 TYPE_ACCELEROMETER 需要) const LPF_ALPHA = 0.85 let gx = 0, gy = 0, gz = 0 let gravityReady = useGravitySensor // TYPE_GRAVITY 不需要 LPF let sensorCallbackCount = 0 let ArrayReflect = null const listener = plus.android.implements('android.hardware.SensorEventListener', { onSensorChanged: function(event) { sensorCallbackCount++ try { // ——— 诊断:探明 event 对象的实际结构 ——— if (sensorCallbackCount <= 3) { // 枚举 event 上的所有 key let keys = [] try { for (let k in event) { try { keys.push(k + ':' + typeof event[k]) } catch (_) { keys.push(k + ':err') } } } catch (_) {} console.log('[useLenticularStudioTilt] onSensorChanged #' + sensorCallbackCount + ' typeof=' + typeof event + ' keys=[' + (keys.length ? keys.join(', ') : '(none)') + ']') // 尝试 plusGetAttribute 方式(项目既有模式) try { const v = event.plusGetAttribute('values') console.log('[useLenticularStudioTilt] plusGetAttribute("values"):', typeof v, v) } catch (e1) { console.log('[useLenticularStudioTilt] plusGetAttribute("values") threw:', e1) } // 尝试 plus.android.getAttribute try { const v = plus.android.getAttribute(event, 'values') console.log('[useLenticularStudioTilt] getAttribute(event,"values"):', typeof v, v) } catch (e2) { console.log('[useLenticularStudioTilt] getAttribute(event,"values") threw:', e2) } } // ——— 获取 float[] 数据 ——— // 已验证: // ✅ plusGetAttribute('values') → Java float[] // ✅ Array.get(arr, i) → Java Float 对象(之前 r0 type=object 是成功的) // ❌ Array.getFloat(arr, i) → null // ❌ Number(Float) → 0 // ❌ Float.floatValue 不是 JS function // 取 float[] 数据 — 内联调用,不经过函数传参(Native.js 传参丢引用) let v0, v1, v2 let rawValues = null try { rawValues = event.plusGetAttribute('values') } catch (_) {} if (!rawValues) { try { rawValues = plus.android.getAttribute(event, 'values') } catch (_) {} } if (!rawValues) return if (!ArrayReflect || typeof ArrayReflect.get !== "function") { try { ArrayReflect = plus.android.importClass("java.lang.reflect.Array") } catch (_) {} } // 内联 Array.get()(已验证可用),拿 Float → invoke floatValue 拆箱 if (sensorCallbackCount <= 2) { console.log('[useLenticularStudioTilt] pre-get: ArrayReflect=' + typeof ArrayReflect + ' hasGet=' + (ArrayReflect && typeof ArrayReflect.get === 'function') + ' rv=' + typeof rawValues + ' rvNull=' + (rawValues == null)) } const f0 = ArrayReflect ? ArrayReflect.get(rawValues, 0) : null const f1 = ArrayReflect ? ArrayReflect.get(rawValues, 1) : null const f2 = ArrayReflect ? ArrayReflect.get(rawValues, 2) : null if (sensorCallbackCount <= 2) { console.log('[useLenticularStudioTilt] Array.get results: f0=' + typeof f0 + ' null=' + (f0 == null) + ' f1 null=' + (f1 == null) + ' f2 null=' + (f2 == null)) } function unboxFloat(f) { if (f == null) return NaN try { const v = plus.android.invoke(f, 'floatValue') if (typeof v === 'number') return v } catch (_) {} try { const s = plus.android.invoke(f, 'toString') if (s) return parseFloat(String(s)) } catch (_) {} return NaN } v0 = unboxFloat(f0) v1 = unboxFloat(f1) v2 = unboxFloat(f2) if (typeof v0 !== 'number' || isNaN(v0)) { if (sensorCallbackCount <= 3) { console.warn('[useLenticularStudioTilt] onSensorChanged: failed to read values #' + sensorCallbackCount) } return } // 第一次拿到值时打印 if (sensorCallbackCount <= 5) { console.log('[useLenticularStudioTilt] sensor #' + sensorCallbackCount + ': [' + v0.toFixed(3) + ', ' + v1.toFixed(3) + ', ' + v2.toFixed(3) + ']') } let rx, ry, rz if (useGravitySensor) { // TYPE_GRAVITY 直接输出重力分量 rx = v0 ry = v1 rz = v2 } else { // TYPE_ACCELEROMETER:低通滤波提取重力分量 gx = LPF_ALPHA * gx + (1 - LPF_ALPHA) * v0 gy = LPF_ALPHA * gy + (1 - LPF_ALPHA) * v1 gz = LPF_ALPHA * gz + (1 - LPF_ALPHA) * v2 if (!gravityReady) { // 等重力滤波器收敛后再输出 const mag = Math.sqrt(gx * gx + gy * gy + gz * gz) if (mag > 8 && mag < 12) gravityReady = true return } rx = gx; ry = gy; rz = gz } // 重力方向 → roll/pitch(度),坐标系对齐 DeviceOrientation // roll: 绕 z 轴旋转(左右倾斜) // pitch: 绕 x 轴旋转(前后倾斜) const roll = Math.atan2(rx, rz) * (180 / Math.PI) const pitch = Math.atan2(ry, Math.sqrt(rx * rx + rz * rz)) * (180 / Math.PI) onFrame(roll, pitch) } catch (e) { if (sensorCallbackCount <= 3) { console.warn('[useLenticularStudioTilt] onSensorChanged error:', e, JSON.stringify(e)) } } }, onAccuracyChanged: function(sensor, accuracy) {} }) // SENSOR_DELAY_GAME = 20,000μs (50Hz) — 响应快,够跟手 sm.registerListener(listener, accelSensor, SensorManager.SENSOR_DELAY_GAME) return { cleanup: function() { sm.unregisterListener(listener) } } } catch (e) { console.warn('[useLenticularStudioTilt] Android tilt sensor init failed:', e) return null } } // ==================================================================== // 路径 A-2: Native.js iOS // CMMotionManager.deviceMotion.attitude → roll/pitch(度) // 系统级传感器融合,无漂移,比手动积分陀螺仪可靠得多 // ==================================================================== /** * @param {(rollDeg: number, pitchDeg: number) => void} onFrame * @returns {{ cleanup: () => void } | null} */ function createIOSTiltSensor(onFrame) { try { if (typeof plus === 'undefined' || !plus.ios) { console.warn('[useLenticularStudioTilt] plus.ios not available') return null } const CMMotionManager = plus.ios.importClass('CMMotionManager') const NSOperationQueue = plus.ios.importClass('NSOperationQueue') const mm = CMMotionManager.alloc().init() if (!mm || !mm.isDeviceMotionAvailable || !mm.isDeviceMotionAvailable()) { console.warn('[useLenticularStudioTilt] CMMotionManager deviceMotion NOT available — tilt NOT supported on this device') if (mm) { mm.release() } return null } // 检查各传感器硬件可用性(诊断用) const gyroOk = mm.isGyroAvailable && mm.isGyroAvailable() const accelOk = mm.isAccelerometerAvailable && mm.isAccelerometerAvailable() const magnetoOk = mm.isMagnetometerAvailable && mm.isMagnetometerAvailable() console.log('[useLenticularStudioTilt] iOS sensor support: ' + 'deviceMotion=YES, ' + 'gyro=' + (gyroOk ? 'YES' : 'NO') + ', ' + 'accel=' + (accelOk ? 'YES' : 'NO') + ', ' + 'magnetometer=' + (magnetoOk ? 'YES' : 'NO')) if (!gyroOk) { console.warn('[useLenticularStudioTilt] iOS gyro unavailable; deviceMotion will fall back to accel+compass fusion') } // 30Hz 采样(适合倾斜交互,低于游戏需求但省电) mm.setDeviceMotionUpdateInterval(1.0 / 30.0) const queue = NSOperationQueue.mainQueue() // 用 block 接收 motion 数据 // CMDeviceMotionHandler = void (^)(CMDeviceMotion * _Nullable motion, NSError * _Nullable error) const handler = plus.ios.implements('CMDeviceMotionHandler', { invoke: function(motion, error) { try { if (!motion || error) return const attitude = motion.attitude() if (!attitude) return // attitude.roll / pitch 是弧度,转度 const roll = attitude.roll() * (180 / Math.PI) const pitch = attitude.pitch() * (180 / Math.PI) onFrame(roll, pitch) } catch (_) {} } }) mm.startDeviceMotionUpdatesToQueueWithHandler(queue, handler) return { cleanup: function() { mm.stopDeviceMotionUpdates() mm.release() } } } catch (e) { console.warn('[useLenticularStudioTilt] iOS tilt sensor init failed:', e) return null } } /** * 等待 plus 环境就绪后执行回调。 * 在 Vue mounted 阶段调用时 plus 通常已就绪,但加一层防护。 * @param {(ready: boolean) => void} cb */ function onPlusReady(cb) { try { if (typeof plus !== 'undefined') { cb(true) return } // 尚未就绪:挂载 plusready 事件 function handler() { document.removeEventListener('plusready', handler) cb(true) } document.addEventListener('plusready', handler, false) } catch (_) { cb(false) } } function startNativeApp(myGen) { onPlusReady(function(ready) { if (!ready) { console.warn('[useLenticularStudioTilt] plus not ready') onNativeFallback() return } // Android:uni.onAccelerometerChange(uni 原生桥接,不走 WebView 事件, // 也不走 Native.js float[] 读取,uni 内部处理数据转换,直接返 JS number)。 if (plus.os && plus.os.name === 'Android') { uni.startAccelerometer({ interval: 'game' }) let accelWarmup = 0 // ——— Android-accel 路径专用:One-Euro Filter(速度自适应低通)——— // 必须在闭包内新建,不能放在 handleNativeTiltFrame // (handleNativeTiltFrame 被 iOS CMMotionManager 共用) const oneEuroRoll = createOneEuroFilter({ mincutoff: 0.2, beta: 0.05, dcutoff: 1.0 }) const oneEuroPitch = createOneEuroFilter({ mincutoff: 0.2, beta: 0.05, dcutoff: 1.0 }) let accelCb = function(res) { if (myGen !== tiltGen) return // 跳过前几帧初始化噪声 if (accelWarmup < SKIP_WARMUP_FRAMES) { accelWarmup++; return } const rollRaw = Math.atan2(res.x, res.z) * (180 / Math.PI) const pitchRaw = Math.atan2(res.y, Math.sqrt(res.x*res.x + res.z*res.z)) * (180 / Math.PI) // One-Euro 平滑(解决静止抖动 + 减小动态延迟) const t = nowMs() const roll = oneEuroRoll.filter(rollRaw, t) const pitch = oneEuroPitch.filter(pitchRaw, t) gyroSourceLabel.value = 'accelerometer' handleNativeTiltFrame(roll, pitch) } uni.onAccelerometerChange(accelCb) nativeCleanup = function() { uni.offAccelerometerChange(accelCb) try { uni.stopAccelerometer() } catch (_) {} } console.log('[useLenticularStudioTilt] uni.onAccelerometerChange started with One-Euro Filter') return } // iOS:CMMotionManager.attitude if (plus.os && plus.os.name === 'iOS') { const result = createIOSTiltSensor(function(roll, pitch) { if (myGen !== tiltGen) return gyroSourceLabel.value = 'native-ios' handleNativeTiltFrame(roll, pitch) }) if (result) { nativeCleanup = result.cleanup console.log('[useLenticularStudioTilt] Native.js iOS tilt started') return } } // Native.js 不可用 → 降级 console.warn('[useLenticularStudioTilt] Native.js tilt init failed for ' + (plus.os ? plus.os.name : 'unknown') + ' — falling back') onNativeFallback() }) } function stopNativeApp() { stopGyroPlugin() if (nativeStartTimer != null) { try { clearTimeout(nativeStartTimer) } catch (_) {} nativeStartTimer = null } if (nativeCleanup) { try { nativeCleanup() } catch (_) {} nativeCleanup = null } } // #endif /** * Native.js 路径降级回调:转到 DeviceOrientation 或模拟 */ function onNativeFallback() { // #ifdef APP-PLUS nativeCleanup = null // #endif // 尝试 DeviceOrientationEvent console.log('[useLenticularStudioTilt] Native.js unavailable, trying DeviceOrientationEvent...') if (startOrientation()) return // 全部不可用 → 模拟 console.warn('[useLenticularStudioTilt] all tilt paths exhausted — falling back to simulation') gyroSourceLabel.value = 'simulation' simulate(0, 0) if (typeof onTiltDriverFallback === 'function') { try { onTiltDriverFallback() } catch (_) {} } } // ==================================================================== // 路径 B: DeviceOrientationEvent (H5 / iOS App WKWebView 降级) // ==================================================================== function handleDeviceOrientation(e) { if (e.gamma == null) return // gamma: 左右倾斜(roll),beta: 前后倾斜(pitch) const rawX = e.gamma || 0 // roll → dx(左右) const rawY = e.beta || 0 // pitch → dy(前后) 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 applyDelta(dx, dy) } function startOrientationListener() { gyroSourceLabel.value = 'deviceorientation' warmupSkip = 0 orientationHandler = handleDeviceOrientation window.addEventListener('deviceorientation', orientationHandler, true) console.log('[useLenticularStudioTilt] deviceorientation listener started') } function stopOrientationListener() { if (orientationHandler) { window.removeEventListener('deviceorientation', orientationHandler, true) orientationHandler = null } } /** * 尝试启动 DeviceOrientationEvent。 * @returns {boolean} true=已启动或正在请求权限;false=不可用 */ function startOrientation() { if (typeof DeviceOrientationEvent === 'undefined') { console.warn('[useLenticularStudioTilt] DeviceOrientationEvent not supported') return false } // iOS 13+ Safari 需要用户手势触发 requestPermission if (typeof DeviceOrientationEvent.requestPermission === 'function') { gyroSourceLabel.value = 'deviceorientation-requesting' DeviceOrientationEvent.requestPermission() .then(state => { if (state === 'granted') { startOrientationListener() } else { console.warn('[useLenticularStudioTilt] DeviceOrientation permission denied') gyroSourceLabel.value = 'simulation' simulate(0, 0) if (typeof onTiltDriverFallback === 'function') { try { onTiltDriverFallback() } catch (_) {} } } }) .catch(err => { console.warn('[useLenticularStudioTilt] DeviceOrientation permission error:', err) gyroSourceLabel.value = 'simulation' simulate(0, 0) if (typeof onTiltDriverFallback === 'function') { try { onTiltDriverFallback() } catch (_) {} } }) return true } // Android / 旧 iOS:直接启动 listener startOrientationListener() return true } // ==================================================================== // 公开 API // ==================================================================== /** * 启动传感器。优先级: * 1. Native.js (App Android/iOS) — 直接桥接原生 API,最可靠 * 2. DeviceOrientationEvent (H5 / iOS App WKWebView) * 3. 降级模拟 */ function start() { console.log('[useLenticularStudioTilt] start()') stop() tiltGen++ resetState() lastLogSecond = 0 // #ifdef APP-PLUS // App 端:优先 Native.js startNativeApp(tiltGen) return // #endif // #ifndef APP-PLUS // H5 / 小程序:尝试 DeviceOrientationEvent if (startOrientation()) return // 全部不可用 → 模拟 gyroSourceLabel.value = 'simulation' simulate(0, 0) if (typeof onTiltDriverFallback === 'function') { try { onTiltDriverFallback() } catch (_) {} } // #endif } function stop() { console.log('[useLenticularStudioTilt] stop()') tiltGen++ // #ifdef APP-PLUS stopNativeApp() // #endif stopOrientationListener() gyroSourceLabel.value = 'simulation' resetState() } function recalibrate() { resetState() simulate(0, 0) } return { start, stop, recalibrate } }