topfans/frontend/composables/useLenticularStudioTilt.js
2026-06-03 22:19:22 +08:00

361 lines
11 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.

/**
* 光栅卡倾斜驱动(铸爱预览共用)
*
* 使用 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 }
}