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