841 lines
27 KiB
JavaScript
841 lines
27 KiB
JavaScript
/**
|
||
* 光栅卡倾斜驱动(铸爱预览共用)
|
||
*
|
||
* 多路径混合(按优先级):
|
||
* - 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<string>} 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 }
|
||
}
|