topfans/frontend/composables/useLenticularStudioTilt.js

841 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 光栅卡倾斜驱动(铸爱预览共用)
*
* 多路径混合(按优先级):
* - App AndroidNative.js → SensorManager加速度计低通滤波 → 重力方向 → 绝对倾角)
* - App iOSNative.js → CMMotionManager.deviceMotion.attitude系统级传感器融合
* - H5DeviceOrientationEventChrome/Safari/Firefox 通用)
* - 小程序:降级为模拟(无陀螺仪 API
*
* ⚠️ uni-app 内置 uni.startGyroscope / uni.onGyroscopeChange 在 Android/iOS
* 原生层未实现桥接——文档里写着但底层没接通。实测所有 3.x 版本 App 打包后调用均
* 静默失败(无报错、无日志、无回调)。官方跳过陀螺仪是因为 iOS CMMotionManager
* 和 Android SensorManager 在生命周期、权限模型、回调线程上差异太大。
* 结论App 端必须用 Native.js 或原生插件接入。
*
* 平台接入要点:
* - AndroidBODY_SENSORS 权限Android 12+ 强制,仅 ACCESS_FINE_LOCATION 不够)
* - iOSNSMotionUsageDescriptionInfo.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<string>} opts.gyroSourceLabel
* @param {() => void} [opts.onTiltDriverFallback]
*/
export function useLenticularStudioTilt(opts) {
const { simulate, simulateFromSignedDegrees, gyroSourceLabel, onTiltDriverFallback } = opts
// ====================================================================
// 通用状态
// ====================================================================
let tiltGen = 0
// 跳变拒绝:上一帧 rawNaN 表示"无条件接受第一帧"
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
}
// Androiduni.onAccelerometerChangeuni 原生桥接,不走 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
}
// iOSCMMotionManager.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: 左右倾斜rollbeta: 前后倾斜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 }
}