feat新增光栅卡和镭射卡
@ -38,18 +38,18 @@ export default {
|
|||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 引入圆体字体 */
|
/* 圆体 JDLTYuanTiJian.ttf 在部分 Android WebView 上报 OTS/cmap 解析失败,暂不 @font-face 加载,避免控制台告警与渲染异常 */
|
||||||
@font-face {
|
|
||||||
font-family: 'yt';
|
|
||||||
src: url('/static/fonts/经典圆体简.ttf') format('truetype');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 全局字体设置 */
|
/* 全局字体设置 */
|
||||||
body {
|
body {
|
||||||
font-family: 'yt', sans-serif;
|
font-family:
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'PingFang SC',
|
||||||
|
'Hiragino Sans GB',
|
||||||
|
'Microsoft YaHei',
|
||||||
|
'Noto Sans SC',
|
||||||
|
sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* App 容器 */
|
/* App 容器 */
|
||||||
|
|||||||
768
frontend/composables/useLenticularStudioTilt.js
Normal file
@ -0,0 +1,768 @@
|
|||||||
|
/**
|
||||||
|
* 光栅卡工作室倾斜驱动:App 优先 imengyu-UniAndroidGyro(DCloud 插件 id=6237),否则加速度计。
|
||||||
|
*
|
||||||
|
* 与插件约定对齐(官方说明):
|
||||||
|
* - 模块:`imengyu-UniAndroidGyro-GyroModule`,`uni.requireNativePlugin`。
|
||||||
|
* - `startGyro`:首包回调仅 success/errMsg/notSupport(**无 x/y/z 角度属正常**);持续数据需 **`getGyroValue` 轮询**(插件官方示例);勿把首包当「无参数」故障。
|
||||||
|
* - `startGyroWithCallback`:部分自定义基座 + HX SDK 组合下长期收不到角度帧;本实现以 **startGyro + 轮询** 为主,WithCallback 为兜底。
|
||||||
|
* - 轮询:插件若同时提供 `getGyroValue` 与 `getGyroValueSync`,**优先异步**(与官方示例一致);部分环境下 Sync 长期无 x/y/z,易触发看门狗回退。
|
||||||
|
* - 再次开启前须 `getGyroStarted`;若已在监听则先 `stopGyro`(不可重复开启)。
|
||||||
|
* - 页面进入后立即监听前宜延时再 start(官方示例约 100ms)。
|
||||||
|
* - 插件返回的 x/y/z 为**当前各轴角度(度)**,与「进入光栅卡时采样的基准角」做差得到相对姿态;离散档位应使用**与轴向切换无关**的标量(如 max(|Δx|,|Δy|,|Δz|)),避免「谁幅度大跟谁」在临界区来回跳轴导致画面乱切。
|
||||||
|
* - 原生陀螺看门狗:按「**连续无有效角度帧**」计时(每来一帧即重置),避免固定短超时与晚首包 / `getGyroStarted`→`stopGyro`→`kick` 慢链冲突;超时仍无帧再回退加速度计。
|
||||||
|
*
|
||||||
|
* @see https://ext.dcloud.net.cn/plugin?id=6237
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NATIVE_PLUGIN_ID = 'imengyu-UniAndroidGyro-GyroModule'
|
||||||
|
|
||||||
|
/** iOS 等环境下布尔可能为字符串 `'true'` */
|
||||||
|
function isTruthyFlag(v) {
|
||||||
|
return v === true || v === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnglePayload(res) {
|
||||||
|
if (!res || typeof res !== 'object') return false
|
||||||
|
/** 插件首包常无 x/y/z;有数值(含 0)才视为角度帧 */
|
||||||
|
return [res.x, res.y, res.z].some((v) => Number.isFinite(Number(v)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无角度包时:仅明确失败才放弃原生;success 缺省不等同于失败(部分 ROM 首包字段不全) */
|
||||||
|
function isExplicitGyroHandshakeFailure(res) {
|
||||||
|
if (!res || typeof res !== 'object') return false
|
||||||
|
if (isTruthyFlag(res.notSupport)) return true
|
||||||
|
if (res.success === false || res.success === 'false') return true
|
||||||
|
if (res.success === '') return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// #ifdef APP-PLUS
|
||||||
|
function tryRequireImengyuGyro() {
|
||||||
|
try {
|
||||||
|
if (typeof uni === 'undefined' || typeof uni.requireNativePlugin !== 'function') return null
|
||||||
|
const mod = uni.requireNativePlugin(NATIVE_PLUGIN_ID)
|
||||||
|
const okPoll =
|
||||||
|
mod &&
|
||||||
|
typeof mod.startGyro === 'function' &&
|
||||||
|
typeof mod.stopGyro === 'function' &&
|
||||||
|
(typeof mod.getGyroValue === 'function' || typeof mod.getGyroValueSync === 'function')
|
||||||
|
const okCb =
|
||||||
|
mod &&
|
||||||
|
typeof mod.startGyroWithCallback === 'function' &&
|
||||||
|
typeof mod.stopGyro === 'function'
|
||||||
|
if (okPoll || okCb) {
|
||||||
|
return mod
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[useLenticularStudioTilt] requireNativePlugin failed', e)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
// #ifndef APP-PLUS
|
||||||
|
function tryRequireImengyuGyro() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
/** 基线帧数 */
|
||||||
|
const ACCEL_BASELINE_FRAMES = 10
|
||||||
|
const INSTANT_FULL_RAD = 0.32
|
||||||
|
const MAX_STEP_RAD = 0.48
|
||||||
|
const STEP_GAIN = 2.15
|
||||||
|
const SIN_PHASE_SCALE = 1.18
|
||||||
|
const BLEND_INSTANT = 0.38
|
||||||
|
|
||||||
|
/** 原生插件:x/y/z 均为角度(度);进入页面后先采若干帧算**基准角**(与官方「先 start 再轮询 getGyroValue」一致) */
|
||||||
|
const NATIVE_BASELINE_FRAMES = 12
|
||||||
|
const NATIVE_FULL_DEG = 10
|
||||||
|
|
||||||
|
/** 加速度计直连 + 离散档位:进入后多帧圆均值定「水平」基准,避免首帧抖动 */
|
||||||
|
const STUDIO_ACCEL_BASELINE_FRAMES = 12
|
||||||
|
|
||||||
|
/** 连续无有效 x/y/z 角度帧则回退加速度计(ms);覆盖 getGyroStarted→stopGyro→kick 慢链与晚首包 */
|
||||||
|
const NATIVE_GYRO_STALL_FALLBACK_MS = 14000
|
||||||
|
|
||||||
|
function deltaDeg(value, base) {
|
||||||
|
const v = Number(value)
|
||||||
|
const b = Number(base)
|
||||||
|
if (!Number.isFinite(v) || !Number.isFinite(b)) return 0
|
||||||
|
let d = v - b
|
||||||
|
while (d > 180) d -= 360
|
||||||
|
while (d < -180) d += 360
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取与基线差最大的轴,避免握持方向不同导致「陀螺仪没反应」 */
|
||||||
|
function pickDominantTiltDelta(dx, dy, dz) {
|
||||||
|
const ax = Math.abs(dx)
|
||||||
|
const ay = Math.abs(dy)
|
||||||
|
const az = Math.abs(dz)
|
||||||
|
if (ax >= ay && ax >= az) return dx
|
||||||
|
if (ay >= az) return dy
|
||||||
|
return dz
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 相对基准的最大欧拉偏差(度),左右/多轴合成时仍单调,且不会在临界区因「换轴」突变符号 */
|
||||||
|
function maxAbsTiltDeltaDeg(dx, dy, dz) {
|
||||||
|
return Math.max(Math.abs(dx), Math.abs(dy), Math.abs(dz))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据进入后采集的样本,选「抖动范围最大」的轴作为连续跟手的倾角轴(基线期手持微动时仍稳定)。
|
||||||
|
* @param {{ x: number, y: number, z: number }[]} samples
|
||||||
|
* @returns {0|1|2}
|
||||||
|
*/
|
||||||
|
function inferPrimaryTiltAxisFromSamples(samples) {
|
||||||
|
if (!samples || samples.length < 2) return 1
|
||||||
|
let bestAxis = 1
|
||||||
|
let bestSpread = -1
|
||||||
|
for (let axis = 0; axis < 3; axis++) {
|
||||||
|
const vals = samples.map((s) => [s.x, s.y, s.z][axis])
|
||||||
|
const mn = Math.min(...vals)
|
||||||
|
const mx = Math.max(...vals)
|
||||||
|
const spread = mx - mn
|
||||||
|
if (spread > bestSpread) {
|
||||||
|
bestSpread = spread
|
||||||
|
bestAxis = axis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* 几乎完全静止:竖屏常见左右倾角在 y */
|
||||||
|
if (bestSpread < 0.35) return 1
|
||||||
|
return bestAxis
|
||||||
|
}
|
||||||
|
|
||||||
|
function circularMeanRad(rads) {
|
||||||
|
if (!rads.length) return 0
|
||||||
|
let sx = 0
|
||||||
|
let sy = 0
|
||||||
|
for (const r of rads) {
|
||||||
|
sx += Math.sin(r)
|
||||||
|
sy += Math.cos(r)
|
||||||
|
}
|
||||||
|
return Math.atan2(sx / rads.length, sy / rads.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function majorityPlane(votes) {
|
||||||
|
const cnt = { xz: 0, xy: 0, yz: 0 }
|
||||||
|
for (const v of votes) cnt[v] = (cnt[v] || 0) + 1
|
||||||
|
if (cnt.xz >= cnt.xy && cnt.xz >= cnt.yz) return 'xz'
|
||||||
|
if (cnt.xy >= cnt.yz) return 'xy'
|
||||||
|
return 'yz'
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectGravityPlane(nx, ny, nz) {
|
||||||
|
const ax = Math.abs(nx)
|
||||||
|
const ay = Math.abs(ny)
|
||||||
|
const az = Math.abs(nz)
|
||||||
|
if (ay >= ax && ay >= az) return 'xz'
|
||||||
|
if (az >= ax && az >= ay) return 'xy'
|
||||||
|
return 'yz'
|
||||||
|
}
|
||||||
|
|
||||||
|
function uvForPlane(nx, ny, nz, mode) {
|
||||||
|
if (mode === 'xz') return { u: nx, v: nz }
|
||||||
|
if (mode === 'xy') return { u: nx, v: ny }
|
||||||
|
return { u: ny, v: nz }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {(x: number, y: number) => void} opts.simulate
|
||||||
|
* @param {(tiltMagDeg: number) => void} [opts.simulateFromSignedDegrees] 若提供:用相对进入时基准的**倾角标量(度)**驱动预览(由页面做离散档位等);原生/加速度计侧会喂入非负幅度为主
|
||||||
|
* @param {import('vue').Ref<string>} opts.gyroSourceLabel
|
||||||
|
* @param {boolean} [opts.useStudioAccelDirect] 光栅工作室:加速度计用重力投影直连(免原 warmup)
|
||||||
|
* @param {() => void} [opts.onTiltDriverFallback] 从原生陀螺失败/超时回退到加速度计时调用(用于重置离散档位等 UI 状态)
|
||||||
|
*/
|
||||||
|
export function useLenticularStudioTilt(opts) {
|
||||||
|
const {
|
||||||
|
simulate,
|
||||||
|
simulateFromSignedDegrees,
|
||||||
|
gyroSourceLabel,
|
||||||
|
useStudioAccelDirect = false,
|
||||||
|
onTiltDriverFallback,
|
||||||
|
} = opts
|
||||||
|
|
||||||
|
let mode = /** @type {'native'|'accel'} */ ('accel')
|
||||||
|
let gyroModule = null
|
||||||
|
let nativeStartTimer = null
|
||||||
|
let nativeWatchdogTimer = null
|
||||||
|
let gyroCbGuardTimer = null
|
||||||
|
let nativePollTimer = null
|
||||||
|
/** @type {{ x: number, y: number, z: number }[]} */
|
||||||
|
let nativeTrSamples = []
|
||||||
|
let nativeBase = { x: 0, y: 0, z: 0 }
|
||||||
|
let nativeBaselineReady = false
|
||||||
|
let nativeSmoothed = 0
|
||||||
|
/** 连续模式下与基线样本推断的主倾角轴(0=x,1=y,2=z),避免每帧在 x/y 间抢主导 */
|
||||||
|
let nativeLockedAxisIdx = 1
|
||||||
|
/** 递增以丢弃 stop 之后的原生回调 / 延时任务 */
|
||||||
|
let tiltGen = 0
|
||||||
|
/** 原生:每次收到角度帧时重置该计时器;超时仍无帧则回退加速度计 */
|
||||||
|
let scheduleNativeGyroStallFallback = /** @type {null | (() => void)} */ (null)
|
||||||
|
|
||||||
|
/** 加速度计直连:多帧采样缓冲,填满后取圆均值为基准角 */
|
||||||
|
let studioAccelBaselineRads = []
|
||||||
|
/** 加速度计直连模式下「校零」后的基准(弧度),仅与 simulateFromSignedDegrees 联用 */
|
||||||
|
let studioAccelBaseRad = null
|
||||||
|
|
||||||
|
let accelHandler = null
|
||||||
|
let accelSmoothed = 0
|
||||||
|
let rollAccum = 0
|
||||||
|
let prevCu = null
|
||||||
|
let prevCv = null
|
||||||
|
|
||||||
|
let tiltCal = {
|
||||||
|
lockedMode: null,
|
||||||
|
planeVotes: [],
|
||||||
|
sumU: 0,
|
||||||
|
sumV: 0,
|
||||||
|
count: 0,
|
||||||
|
ready: false,
|
||||||
|
bu: 1,
|
||||||
|
bv: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAccelCalibration() {
|
||||||
|
tiltCal = {
|
||||||
|
lockedMode: null,
|
||||||
|
planeVotes: [],
|
||||||
|
sumU: 0,
|
||||||
|
sumV: 0,
|
||||||
|
count: 0,
|
||||||
|
ready: false,
|
||||||
|
bu: 1,
|
||||||
|
bv: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetNativeBaseline() {
|
||||||
|
nativeTrSamples = []
|
||||||
|
nativeBase = { x: 0, y: 0, z: 0 }
|
||||||
|
nativeBaselineReady = false
|
||||||
|
nativeSmoothed = 0
|
||||||
|
nativeLockedAxisIdx = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetStudioAccelBaseline() {
|
||||||
|
studioAccelBaseRad = null
|
||||||
|
studioAccelBaselineRads = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function accelToRelativeTilt01(ax, ay, az) {
|
||||||
|
const gMag = Math.hypot(ax, ay, az)
|
||||||
|
if (gMag < 0.18) return null
|
||||||
|
const nx = ax / gMag
|
||||||
|
const ny = ay / gMag
|
||||||
|
const nz = az / gMag
|
||||||
|
|
||||||
|
if (!tiltCal.ready) {
|
||||||
|
const m = detectGravityPlane(nx, ny, nz)
|
||||||
|
if (tiltCal.planeVotes.length < 5) tiltCal.planeVotes.push(m)
|
||||||
|
if (tiltCal.planeVotes.length >= 5 && tiltCal.lockedMode == null) {
|
||||||
|
tiltCal.lockedMode = majorityPlane(tiltCal.planeVotes)
|
||||||
|
tiltCal.sumU = 0
|
||||||
|
tiltCal.sumV = 0
|
||||||
|
tiltCal.count = 0
|
||||||
|
}
|
||||||
|
if (tiltCal.lockedMode == null) return { warmup: true }
|
||||||
|
const { u, v } = uvForPlane(nx, ny, nz, tiltCal.lockedMode)
|
||||||
|
const h = Math.hypot(u, v)
|
||||||
|
if (h < 0.035) return { warmup: true }
|
||||||
|
tiltCal.sumU += u / h
|
||||||
|
tiltCal.sumV += v / h
|
||||||
|
tiltCal.count += 1
|
||||||
|
if (tiltCal.count >= ACCEL_BASELINE_FRAMES) {
|
||||||
|
const bh = Math.hypot(tiltCal.sumU, tiltCal.sumV) || 1
|
||||||
|
tiltCal.bu = tiltCal.sumU / bh
|
||||||
|
tiltCal.bv = tiltCal.sumV / bh
|
||||||
|
tiltCal.ready = true
|
||||||
|
}
|
||||||
|
return { warmup: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { u, v } = uvForPlane(nx, ny, nz, tiltCal.lockedMode)
|
||||||
|
const h = Math.hypot(u, v)
|
||||||
|
if (h < 0.028) return null
|
||||||
|
const cu = u / h
|
||||||
|
const cv = v / h
|
||||||
|
const sinD = cu * tiltCal.bv - cv * tiltCal.bu
|
||||||
|
const cosD = cu * tiltCal.bu + cv * tiltCal.bv
|
||||||
|
const deltaRad = Math.atan2(sinD, cosD)
|
||||||
|
return { warmup: false, cu, cv, deltaRad }
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopNative() {
|
||||||
|
tiltGen++
|
||||||
|
if (nativeWatchdogTimer != null) {
|
||||||
|
try {
|
||||||
|
clearTimeout(nativeWatchdogTimer)
|
||||||
|
} catch (e) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
nativeWatchdogTimer = null
|
||||||
|
}
|
||||||
|
if (gyroCbGuardTimer != null) {
|
||||||
|
try {
|
||||||
|
clearTimeout(gyroCbGuardTimer)
|
||||||
|
} catch (e) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
gyroCbGuardTimer = null
|
||||||
|
}
|
||||||
|
if (nativePollTimer != null) {
|
||||||
|
try {
|
||||||
|
clearInterval(nativePollTimer)
|
||||||
|
} catch (e) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
nativePollTimer = null
|
||||||
|
}
|
||||||
|
if (nativeStartTimer != null) {
|
||||||
|
try {
|
||||||
|
clearTimeout(nativeStartTimer)
|
||||||
|
} catch (e) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
nativeStartTimer = null
|
||||||
|
}
|
||||||
|
if (gyroModule && typeof gyroModule.stopGyro === 'function') {
|
||||||
|
try {
|
||||||
|
gyroModule.stopGyro(() => {})
|
||||||
|
} catch (e) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gyroModule = null
|
||||||
|
mode = 'accel'
|
||||||
|
scheduleNativeGyroStallFallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAccel() {
|
||||||
|
try {
|
||||||
|
if (accelHandler && typeof uni.offAccelerometerChange === 'function') {
|
||||||
|
uni.offAccelerometerChange(accelHandler)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
accelHandler = null
|
||||||
|
try {
|
||||||
|
uni.stopAccelerometer({})
|
||||||
|
} catch (e) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAccelInternal(options = {}) {
|
||||||
|
const fromNativeFallback = options.fromNativeFallback === true
|
||||||
|
mode = 'accel'
|
||||||
|
stopAccel()
|
||||||
|
if (fromNativeFallback && typeof onTiltDriverFallback === 'function') {
|
||||||
|
try {
|
||||||
|
onTiltDriverFallback()
|
||||||
|
} catch (e) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resetAccelCalibration()
|
||||||
|
accelSmoothed = 0
|
||||||
|
rollAccum = 0
|
||||||
|
prevCu = null
|
||||||
|
prevCv = null
|
||||||
|
|
||||||
|
if (useStudioAccelDirect) {
|
||||||
|
if (typeof simulateFromSignedDegrees === 'function') {
|
||||||
|
resetStudioAccelBaseline()
|
||||||
|
}
|
||||||
|
accelHandler = (res) => {
|
||||||
|
const ax = Number(res.x) || 0
|
||||||
|
const ay = Number(res.y) || 0
|
||||||
|
const az = Number(res.z) || 0
|
||||||
|
const gMag = Math.hypot(ax, ay, az)
|
||||||
|
if (gMag < 0.12) return
|
||||||
|
/* 竖屏常见握持:左右倾斜主要反映为 ax/az 与重力的关系(免 warmup,避免长期无输出) */
|
||||||
|
if (typeof simulateFromSignedDegrees === 'function') {
|
||||||
|
const tiltRad = Math.atan2(-ax, az)
|
||||||
|
if (studioAccelBaseRad == null) {
|
||||||
|
studioAccelBaselineRads.push(tiltRad)
|
||||||
|
if (studioAccelBaselineRads.length < STUDIO_ACCEL_BASELINE_FRAMES) {
|
||||||
|
simulate(0, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
studioAccelBaseRad = circularMeanRad(studioAccelBaselineRads)
|
||||||
|
studioAccelBaselineRads = []
|
||||||
|
}
|
||||||
|
let delta = tiltRad - studioAccelBaseRad
|
||||||
|
while (delta > Math.PI) delta -= 2 * Math.PI
|
||||||
|
while (delta < -Math.PI) delta += 2 * Math.PI
|
||||||
|
const signedDeg = delta * (180 / Math.PI)
|
||||||
|
simulateFromSignedDegrees(Math.abs(signedDeg))
|
||||||
|
} else {
|
||||||
|
const tiltRaw = Math.atan2(-ax, az) / (Math.PI / 5)
|
||||||
|
const tilt01 = Math.max(-1, Math.min(1, tiltRaw))
|
||||||
|
accelSmoothed += (tilt01 - accelSmoothed) * 0.58
|
||||||
|
simulate(Math.max(-1, Math.min(1, accelSmoothed)), 0)
|
||||||
|
}
|
||||||
|
gyroSourceLabel.value = 'accelerometer'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
accelHandler = (res) => {
|
||||||
|
const ax = Number(res.x) || 0
|
||||||
|
const ay = Number(res.y) || 0
|
||||||
|
const az = Number(res.z) || 0
|
||||||
|
const out = accelToRelativeTilt01(ax, ay, az)
|
||||||
|
if (out == null) return
|
||||||
|
if (out.warmup) return
|
||||||
|
const { cu, cv, deltaRad } = out
|
||||||
|
if (prevCu != null && prevCv != null) {
|
||||||
|
let step = Math.atan2(cu * prevCv - cv * prevCu, cu * prevCu + cv * prevCv)
|
||||||
|
if (step > Math.PI * 0.92) step -= 2 * Math.PI
|
||||||
|
if (step < -Math.PI * 0.92) step += 2 * Math.PI
|
||||||
|
step = Math.max(-MAX_STEP_RAD, Math.min(MAX_STEP_RAD, step))
|
||||||
|
rollAccum += step * STEP_GAIN
|
||||||
|
}
|
||||||
|
prevCu = cu
|
||||||
|
prevCv = cv
|
||||||
|
|
||||||
|
const instant = Math.max(-1, Math.min(1, deltaRad / INSTANT_FULL_RAD))
|
||||||
|
const cyclic = Math.sin(rollAccum * SIN_PHASE_SCALE)
|
||||||
|
const target = BLEND_INSTANT * instant + (1 - BLEND_INSTANT) * cyclic
|
||||||
|
|
||||||
|
accelSmoothed += (target - accelSmoothed) * 0.74
|
||||||
|
simulate(Math.max(-1, Math.min(1, accelSmoothed)), 0)
|
||||||
|
gyroSourceLabel.value = 'accelerometer'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
uni.onAccelerometerChange(accelHandler)
|
||||||
|
const applyStart = (interval) => {
|
||||||
|
uni.startAccelerometer({
|
||||||
|
interval,
|
||||||
|
success: () => {
|
||||||
|
gyroSourceLabel.value = 'accelerometer'
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
if (interval === 'game') {
|
||||||
|
applyStart('normal')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gyroSourceLabel.value = 'simulation'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
applyStart('game')
|
||||||
|
} catch (e) {
|
||||||
|
gyroSourceLabel.value = 'simulation'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNativeAngleFrame(x, y, z) {
|
||||||
|
const vx = Number(x)
|
||||||
|
const vy = Number(y)
|
||||||
|
const vz = Number(z)
|
||||||
|
if (![vx, vy, vz].some(Number.isFinite)) return
|
||||||
|
/* 有角度帧即视为陀螺仪在工作,含基线采集期,避免看门狗误判「从未产出」而中途切加速度计 */
|
||||||
|
gyroSourceLabel.value = 'gyroscope'
|
||||||
|
|
||||||
|
if (!nativeBaselineReady) {
|
||||||
|
if ([vx, vy, vz].every(Number.isFinite)) {
|
||||||
|
nativeTrSamples.push({ x: vx, y: vy, z: vz })
|
||||||
|
}
|
||||||
|
if (nativeTrSamples.length >= NATIVE_BASELINE_FRAMES) {
|
||||||
|
const n = nativeTrSamples.length
|
||||||
|
nativeBase.x = nativeTrSamples.reduce((a, b) => a + b.x, 0) / n
|
||||||
|
nativeBase.y = nativeTrSamples.reduce((a, b) => a + b.y, 0) / n
|
||||||
|
nativeBase.z = nativeTrSamples.reduce((a, b) => a + b.z, 0) / n
|
||||||
|
nativeLockedAxisIdx = inferPrimaryTiltAxisFromSamples(nativeTrSamples)
|
||||||
|
nativeBaselineReady = true
|
||||||
|
}
|
||||||
|
simulate(0, 0)
|
||||||
|
if (typeof scheduleNativeGyroStallFallback === 'function') {
|
||||||
|
scheduleNativeGyroStallFallback()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = deltaDeg(vx, nativeBase.x)
|
||||||
|
const dy = deltaDeg(vy, nativeBase.y)
|
||||||
|
const dz = deltaDeg(vz, nativeBase.z)
|
||||||
|
if (typeof simulateFromSignedDegrees === 'function') {
|
||||||
|
simulateFromSignedDegrees(maxAbsTiltDeltaDeg(dx, dy, dz))
|
||||||
|
} else {
|
||||||
|
const axisDeltas = [dx, dy, dz]
|
||||||
|
const chosen = axisDeltas[nativeLockedAxisIdx] ?? pickDominantTiltDelta(dx, dy, dz)
|
||||||
|
const tilt01 = Math.max(-1, Math.min(1, chosen / NATIVE_FULL_DEG))
|
||||||
|
/* 跟手:过低显钝;过高易抖。插件已做角度融合,此处略轻低通即可 */
|
||||||
|
nativeSmoothed += (tilt01 - nativeSmoothed) * 0.86
|
||||||
|
simulate(Math.max(-1, Math.min(1, nativeSmoothed)), 0)
|
||||||
|
}
|
||||||
|
if (typeof scheduleNativeGyroStallFallback === 'function') {
|
||||||
|
scheduleNativeGyroStallFallback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNativeInternal() {
|
||||||
|
stopNative()
|
||||||
|
stopAccel()
|
||||||
|
resetNativeBaseline()
|
||||||
|
// #ifdef APP-PLUS
|
||||||
|
gyroModule = tryRequireImengyuGyro()
|
||||||
|
if (!gyroModule) {
|
||||||
|
mode = 'accel'
|
||||||
|
startAccelInternal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mode = 'native'
|
||||||
|
const myGen = tiltGen
|
||||||
|
/** 与官方示例一致:normal / ui / game / fastest,game≈50Hz */
|
||||||
|
const startOpts = { interval: 'game' }
|
||||||
|
|
||||||
|
scheduleNativeGyroStallFallback = () => {
|
||||||
|
if (nativeWatchdogTimer != null) {
|
||||||
|
try {
|
||||||
|
clearTimeout(nativeWatchdogTimer)
|
||||||
|
} catch (e) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
nativeWatchdogTimer = null
|
||||||
|
}
|
||||||
|
nativeWatchdogTimer = setTimeout(() => {
|
||||||
|
nativeWatchdogTimer = null
|
||||||
|
if (myGen !== tiltGen) return
|
||||||
|
if (mode !== 'native') return
|
||||||
|
console.warn(
|
||||||
|
'[useLenticularStudioTilt] native gyro stalled (no angle frames for ' +
|
||||||
|
NATIVE_GYRO_STALL_FALLBACK_MS +
|
||||||
|
'ms), falling back to accelerometer'
|
||||||
|
)
|
||||||
|
stopNative()
|
||||||
|
startAccelInternal({ fromNativeFallback: true })
|
||||||
|
}, NATIVE_GYRO_STALL_FALLBACK_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function invokeStartNativeGyro() {
|
||||||
|
const mod = gyroModule
|
||||||
|
if (myGen !== tiltGen || !mod) return
|
||||||
|
|
||||||
|
function startNativePoll() {
|
||||||
|
if (nativePollTimer != null) {
|
||||||
|
try {
|
||||||
|
clearInterval(nativePollTimer)
|
||||||
|
} catch (e) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
nativePollTimer = null
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 官方示例只用异步 getGyroValue;部分基座上 getGyroValueSync 长期无 x/y/z,
|
||||||
|
* 仅在没有异步接口时才用 Sync(见插件 id=6237 说明)。
|
||||||
|
*/
|
||||||
|
const useSync =
|
||||||
|
typeof mod.getGyroValueSync === 'function' && typeof mod.getGyroValue !== 'function'
|
||||||
|
const intervalMs = useSync ? 20 : 28
|
||||||
|
const pollOnce = () => {
|
||||||
|
if (myGen !== tiltGen || !gyroModule) return
|
||||||
|
try {
|
||||||
|
if (useSync) {
|
||||||
|
const v = mod.getGyroValueSync()
|
||||||
|
if (v && hasAnglePayload(v)) {
|
||||||
|
onNativeAngleFrame(v.x, v.y, v.z)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mod.getGyroValue((v) => {
|
||||||
|
if (myGen !== tiltGen || !gyroModule || !v) return
|
||||||
|
if (hasAnglePayload(v)) {
|
||||||
|
onNativeAngleFrame(v.x, v.y, v.z)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pollOnce()
|
||||||
|
nativePollTimer = setInterval(pollOnce, intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
let kickOnce = false
|
||||||
|
const kick = () => {
|
||||||
|
if (kickOnce) return
|
||||||
|
if (myGen !== tiltGen || !gyroModule) return
|
||||||
|
kickOnce = true
|
||||||
|
if (gyroCbGuardTimer != null) {
|
||||||
|
try {
|
||||||
|
clearTimeout(gyroCbGuardTimer)
|
||||||
|
} catch (e) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
gyroCbGuardTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const usePoll =
|
||||||
|
typeof mod.startGyro === 'function' &&
|
||||||
|
(typeof mod.getGyroValue === 'function' || typeof mod.getGyroValueSync === 'function')
|
||||||
|
|
||||||
|
if (usePoll) {
|
||||||
|
try {
|
||||||
|
mod.startGyro(startOpts, (res) => {
|
||||||
|
if (myGen !== tiltGen || !gyroModule) return
|
||||||
|
/* 插件约定:首包只表示是否开启成功,不含持续角度;若回调无对象则无法进入官方要求的 getGyroValue 轮询 */
|
||||||
|
if (!res) {
|
||||||
|
console.warn(
|
||||||
|
'[useLenticularStudioTilt] startGyro callback received no argument (see plugin doc: first callback is handshake only)'
|
||||||
|
)
|
||||||
|
stopNative()
|
||||||
|
startAccelInternal({ fromNativeFallback: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isExplicitGyroHandshakeFailure(res)) {
|
||||||
|
console.warn('[useLenticularStudioTilt] startGyro handshake failed', res)
|
||||||
|
stopNative()
|
||||||
|
startAccelInternal({ fromNativeFallback: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
/* 与官方示例一致:success 为真后再轮询;iOS 上常为字符串 'true' */
|
||||||
|
if (Object.prototype.hasOwnProperty.call(res, 'success') && !isTruthyFlag(res.success)) {
|
||||||
|
console.warn('[useLenticularStudioTilt] startGyro success=false', res)
|
||||||
|
stopNative()
|
||||||
|
startAccelInternal({ fromNativeFallback: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (hasAnglePayload(res)) {
|
||||||
|
onNativeAngleFrame(res.x, res.y, res.z)
|
||||||
|
} else {
|
||||||
|
simulate(0, 0)
|
||||||
|
}
|
||||||
|
startNativePoll()
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[useLenticularStudioTilt] startGyro error', e)
|
||||||
|
stopNative()
|
||||||
|
startAccelInternal({ fromNativeFallback: true })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
mod.startGyroWithCallback(startOpts, (res) => {
|
||||||
|
if (myGen !== tiltGen || !gyroModule) return
|
||||||
|
if (!res) {
|
||||||
|
console.warn('[useLenticularStudioTilt] startGyroWithCallback: empty callback argument')
|
||||||
|
stopNative()
|
||||||
|
startAccelInternal({ fromNativeFallback: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAnglePayload(res)) {
|
||||||
|
if (isExplicitGyroHandshakeFailure(res)) {
|
||||||
|
console.warn('[useLenticularStudioTilt] native gyro handshake failed', res)
|
||||||
|
stopNative()
|
||||||
|
startAccelInternal({ fromNativeFallback: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
simulate(0, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onNativeAngleFrame(res.x, res.y, res.z)
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[useLenticularStudioTilt] startGyroWithCallback error', e)
|
||||||
|
stopNative()
|
||||||
|
startAccelInternal({ fromNativeFallback: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof mod.getGyroStarted === 'function') {
|
||||||
|
gyroCbGuardTimer = setTimeout(() => {
|
||||||
|
gyroCbGuardTimer = null
|
||||||
|
if (myGen !== tiltGen || !gyroModule || kickOnce) return
|
||||||
|
console.warn('[useLenticularStudioTilt] getGyroStarted callback timeout, starting gyro')
|
||||||
|
kick()
|
||||||
|
}, 700)
|
||||||
|
mod.getGyroStarted((r) => {
|
||||||
|
if (gyroCbGuardTimer != null) {
|
||||||
|
try {
|
||||||
|
clearTimeout(gyroCbGuardTimer)
|
||||||
|
} catch (e) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
gyroCbGuardTimer = null
|
||||||
|
}
|
||||||
|
if (myGen !== tiltGen || !gyroModule) return
|
||||||
|
const started = r && isTruthyFlag(r.started)
|
||||||
|
if (started) {
|
||||||
|
let stopDone = false
|
||||||
|
const stopTimer = setTimeout(() => {
|
||||||
|
if (stopDone || myGen !== tiltGen || !gyroModule) return
|
||||||
|
stopDone = true
|
||||||
|
console.warn('[useLenticularStudioTilt] stopGyro callback timeout, continuing')
|
||||||
|
setTimeout(kick, 80)
|
||||||
|
}, 700)
|
||||||
|
mod.stopGyro(() => {
|
||||||
|
if (stopDone || myGen !== tiltGen || !gyroModule) return
|
||||||
|
stopDone = true
|
||||||
|
try {
|
||||||
|
clearTimeout(stopTimer)
|
||||||
|
} catch (e) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
setTimeout(kick, 80)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
kick()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
kick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 官方:因 uni-app 原因,页面进入后需延时再 start(示例 100ms) */
|
||||||
|
nativeStartTimer = setTimeout(() => {
|
||||||
|
nativeStartTimer = null
|
||||||
|
if (myGen !== tiltGen || !gyroModule) return
|
||||||
|
invokeStartNativeGyro()
|
||||||
|
if (typeof scheduleNativeGyroStallFallback === 'function') {
|
||||||
|
scheduleNativeGyroStallFallback()
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
// #endif
|
||||||
|
// #ifndef APP-PLUS
|
||||||
|
mode = 'accel'
|
||||||
|
startAccelInternal()
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
// #ifdef APP-PLUS
|
||||||
|
startNativeInternal()
|
||||||
|
// #endif
|
||||||
|
// #ifndef APP-PLUS
|
||||||
|
startAccelInternal()
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
stopNative()
|
||||||
|
stopAccel()
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalibrate() {
|
||||||
|
resetAccelCalibration()
|
||||||
|
resetNativeBaseline()
|
||||||
|
resetStudioAccelBaseline()
|
||||||
|
accelSmoothed = 0
|
||||||
|
rollAccum = 0
|
||||||
|
prevCu = null
|
||||||
|
prevCv = null
|
||||||
|
simulate(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
recalibrate,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,6 +11,10 @@ const app = new Vue({
|
|||||||
app.$mount()
|
app.$mount()
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef VUE3 && H5
|
||||||
|
import './pages/castlove/create-laser-upload.css'
|
||||||
|
// #endif
|
||||||
|
|
||||||
// #ifdef VUE3
|
// #ifdef VUE3
|
||||||
import { createSSRApp } from 'vue'
|
import { createSSRApp } from 'vue'
|
||||||
import store from './store'
|
import store from './store'
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name" : "TopFans",
|
"name" : "TopFans",
|
||||||
"appid" : "__UNI__F199FF4",
|
"appid" : "__UNI__8CBE431",
|
||||||
"description" : "",
|
"description" : "",
|
||||||
"versionName" : "1.0.4",
|
"versionName" : "1.0.4",
|
||||||
"versionCode" : 105,
|
"versionCode" : 105,
|
||||||
@ -26,6 +26,9 @@
|
|||||||
"Speech" : {},
|
"Speech" : {},
|
||||||
"Push" : {}
|
"Push" : {}
|
||||||
},
|
},
|
||||||
|
"nativePlugins" : {
|
||||||
|
"imengyu-UniAndroidGyro" : {}
|
||||||
|
},
|
||||||
/* 应用发布信息 */
|
/* 应用发布信息 */
|
||||||
"distribute" : {
|
"distribute" : {
|
||||||
/* android打包配置 */
|
/* android打包配置 */
|
||||||
@ -72,37 +75,38 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"push" : {},
|
"push" : {},
|
||||||
"statics" : {}
|
"statics" : {},
|
||||||
|
"ad" : {}
|
||||||
},
|
},
|
||||||
"icons" : {
|
"icons" : {
|
||||||
"android" : {
|
"android" : {
|
||||||
"hdpi" : "unpackage/res/icons/72x72.png",
|
"hdpi" : "static/app-icons/72x72.png",
|
||||||
"xhdpi" : "unpackage/res/icons/96x96.png",
|
"xhdpi" : "static/app-icons/96x96.png",
|
||||||
"xxhdpi" : "unpackage/res/icons/144x144.png",
|
"xxhdpi" : "static/app-icons/144x144.png",
|
||||||
"xxxhdpi" : "unpackage/res/icons/192x192.png"
|
"xxxhdpi" : "static/app-icons/192x192.png"
|
||||||
},
|
},
|
||||||
"ios" : {
|
"ios" : {
|
||||||
"appstore" : "unpackage/res/icons/1024x1024.png",
|
"appstore" : "static/app-icons/1024x1024.png",
|
||||||
"ipad" : {
|
"ipad" : {
|
||||||
"app" : "unpackage/res/icons/76x76.png",
|
"app" : "static/app-icons/76x76.png",
|
||||||
"app@2x" : "unpackage/res/icons/152x152.png",
|
"app@2x" : "static/app-icons/152x152.png",
|
||||||
"notification" : "unpackage/res/icons/20x20.png",
|
"notification" : "static/app-icons/20x20.png",
|
||||||
"notification@2x" : "unpackage/res/icons/40x40.png",
|
"notification@2x" : "static/app-icons/40x40.png",
|
||||||
"proapp@2x" : "unpackage/res/icons/167x167.png",
|
"proapp@2x" : "static/app-icons/167x167.png",
|
||||||
"settings" : "unpackage/res/icons/29x29.png",
|
"settings" : "static/app-icons/29x29.png",
|
||||||
"settings@2x" : "unpackage/res/icons/58x58.png",
|
"settings@2x" : "static/app-icons/58x58.png",
|
||||||
"spotlight" : "unpackage/res/icons/40x40.png",
|
"spotlight" : "static/app-icons/40x40.png",
|
||||||
"spotlight@2x" : "unpackage/res/icons/80x80.png"
|
"spotlight@2x" : "static/app-icons/80x80.png"
|
||||||
},
|
},
|
||||||
"iphone" : {
|
"iphone" : {
|
||||||
"app@2x" : "unpackage/res/icons/120x120.png",
|
"app@2x" : "static/app-icons/120x120.png",
|
||||||
"app@3x" : "unpackage/res/icons/180x180.png",
|
"app@3x" : "static/app-icons/180x180.png",
|
||||||
"notification@2x" : "unpackage/res/icons/40x40.png",
|
"notification@2x" : "static/app-icons/40x40.png",
|
||||||
"notification@3x" : "unpackage/res/icons/60x60.png",
|
"notification@3x" : "static/app-icons/60x60.png",
|
||||||
"settings@2x" : "unpackage/res/icons/58x58.png",
|
"settings@2x" : "static/app-icons/58x58.png",
|
||||||
"settings@3x" : "unpackage/res/icons/87x87.png",
|
"settings@3x" : "static/app-icons/87x87.png",
|
||||||
"spotlight@2x" : "unpackage/res/icons/80x80.png",
|
"spotlight@2x" : "static/app-icons/80x80.png",
|
||||||
"spotlight@3x" : "unpackage/res/icons/120x120.png"
|
"spotlight@3x" : "static/app-icons/120x120.png"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,101 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>files</key>
|
||||||
|
<dict>
|
||||||
|
<key>Info.plist</key>
|
||||||
|
<data>
|
||||||
|
8XOwyvu+OiYnMWBub0JdOOoIL9c=
|
||||||
|
</data>
|
||||||
|
</dict>
|
||||||
|
<key>files2</key>
|
||||||
|
<dict/>
|
||||||
|
<key>rules</key>
|
||||||
|
<dict>
|
||||||
|
<key>^.*</key>
|
||||||
|
<true/>
|
||||||
|
<key>^.*\.lproj/</key>
|
||||||
|
<dict>
|
||||||
|
<key>optional</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1000</real>
|
||||||
|
</dict>
|
||||||
|
<key>^.*\.lproj/locversion.plist$</key>
|
||||||
|
<dict>
|
||||||
|
<key>omit</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1100</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Base\.lproj/</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1010</real>
|
||||||
|
</dict>
|
||||||
|
<key>^version.plist$</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>rules2</key>
|
||||||
|
<dict>
|
||||||
|
<key>.*\.dSYM($|/)</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>11</real>
|
||||||
|
</dict>
|
||||||
|
<key>^(.*/)?\.DS_Store$</key>
|
||||||
|
<dict>
|
||||||
|
<key>omit</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>2000</real>
|
||||||
|
</dict>
|
||||||
|
<key>^.*</key>
|
||||||
|
<true/>
|
||||||
|
<key>^.*\.lproj/</key>
|
||||||
|
<dict>
|
||||||
|
<key>optional</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1000</real>
|
||||||
|
</dict>
|
||||||
|
<key>^.*\.lproj/locversion.plist$</key>
|
||||||
|
<dict>
|
||||||
|
<key>omit</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1100</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Base\.lproj/</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1010</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Info\.plist$</key>
|
||||||
|
<dict>
|
||||||
|
<key>omit</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>20</real>
|
||||||
|
</dict>
|
||||||
|
<key>^PkgInfo$</key>
|
||||||
|
<dict>
|
||||||
|
<key>omit</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>20</real>
|
||||||
|
</dict>
|
||||||
|
<key>^embedded\.provisionprofile$</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>20</real>
|
||||||
|
</dict>
|
||||||
|
<key>^version\.plist$</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>20</real>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
30
frontend/nativePlugins/imengyu-UniAndroidGyro/package.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "imengyu-UniAndroidGyro",
|
||||||
|
"id": "imengyu-UniAndroidGyro",
|
||||||
|
"version": "1.0.5",
|
||||||
|
"description": "APP端陀螺仪数据采集",
|
||||||
|
"_dp_type": "nativeplugin",
|
||||||
|
"_dp_nativeplugin": {
|
||||||
|
"android": {
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"type": "module",
|
||||||
|
"name": "imengyu-UniAndroidGyro-GyroModule",
|
||||||
|
"class": "uni.imengyu.gyro.GyroModule"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"integrateType": "aar",
|
||||||
|
"minSdkVersion": "19"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"plugins": [{
|
||||||
|
"type": "module",
|
||||||
|
"name": "imengyu-UniAndroidGyro-GyroModule",
|
||||||
|
"class": "GyroModule"
|
||||||
|
}],
|
||||||
|
"frameworks": ["MapKit.framework"],
|
||||||
|
"integrateType": "framework",
|
||||||
|
"deploymentTarget": "9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -229,5 +229,15 @@
|
|||||||
"navigationBarBackgroundColor": "#F8F8F8",
|
"navigationBarBackgroundColor": "#F8F8F8",
|
||||||
"backgroundColor": "#F8F8F8"
|
"backgroundColor": "#F8F8F8"
|
||||||
},
|
},
|
||||||
"uniIdRouter": {}
|
"uniIdRouter": {},
|
||||||
|
"condition": {
|
||||||
|
"current": 0,
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"name": "castlove-lenticular-create",
|
||||||
|
"path": "pages/castlove/create",
|
||||||
|
"query": "name=%E5%85%89%E6%A0%85%E5%8D%A1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
117
frontend/pages/castlove/create-laser-upload.css
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* 铸爱 create 页 — 镭射卡上传区样式(独立 CSS,由 create.vue import,确保各端构建必进包)
|
||||||
|
* 根类 castlove-laser-upload-root 全局唯一,避免 scoped / 第二 style 块在部分端未生效
|
||||||
|
*/
|
||||||
|
.castlove-laser-upload-root {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
/* 略偏竖版小卡比例,接近设计稿「大卡 + 内虚线」观感 */
|
||||||
|
height: 520rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.38);
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
backdrop-filter: blur(22rpx);
|
||||||
|
-webkit-backdrop-filter: blur(22rpx);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow:
|
||||||
|
0 22rpx 56rpx rgba(40, 22, 80, 0.36),
|
||||||
|
inset 0 0 0 1rpx rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.castlove-laser-upload-root .uploaded-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
box-shadow: inset 0 0 0 1rpx rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.castlove-laser-upload-root .upload-laser-stack {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 与设计稿一致:卡内淡色「卡面 / 人像」底图(与工艺选择同款素材) */
|
||||||
|
.castlove-laser-upload-root .upload-laser-photo-bg {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.22;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: saturate(1.15) contrast(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.castlove-laser-upload-root .upload-laser-watermark {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
/* 叠在底图上:压暗边缘 + 轻微幻彩,避免只剩「灰蒙一块」 */
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
118deg,
|
||||||
|
rgba(255, 170, 220, 0.12) 0%,
|
||||||
|
transparent 40%,
|
||||||
|
rgba(170, 210, 255, 0.1) 62%,
|
||||||
|
transparent 82%,
|
||||||
|
rgba(255, 210, 190, 0.08) 100%
|
||||||
|
),
|
||||||
|
radial-gradient(ellipse 55% 75% at 50% 100%, rgba(35, 22, 70, 0.45), transparent 58%),
|
||||||
|
radial-gradient(ellipse 40% 48% at 50% 32%, rgba(100, 75, 150, 0.28), transparent 55%),
|
||||||
|
linear-gradient(185deg, rgba(255, 220, 245, 0.08) 0%, rgba(55, 35, 95, 0.25) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.castlove-laser-upload-root .upload-laser-inset {
|
||||||
|
position: absolute;
|
||||||
|
/* 收紧留白:虚线框更贴外框,接近设计稿「整卡即相框」 */
|
||||||
|
left: 14rpx;
|
||||||
|
right: 14rpx;
|
||||||
|
top: 14rpx;
|
||||||
|
bottom: 14rpx;
|
||||||
|
z-index: 2;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.castlove-laser-upload-root .upload-laser-dashed-inner {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 2rpx dashed rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 26rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
padding: 24rpx 16rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.castlove-laser-upload-root .upload-plus--laser {
|
||||||
|
font-size: 96rpx;
|
||||||
|
line-height: 0.88;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 100;
|
||||||
|
text-shadow: 0 2rpx 18rpx rgba(60, 30, 100, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.castlove-laser-upload-root .upload-text--laser {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.96);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
text-shadow: 0 2rpx 12rpx rgba(40, 20, 80, 0.4);
|
||||||
|
}
|
||||||
@ -47,17 +47,25 @@
|
|||||||
<!-- 其他工艺:单图上传 -->
|
<!-- 其他工艺:单图上传 -->
|
||||||
<view v-else class="upload-section" :class="{ 'upload-section--laser': isLaserCardCraft }">
|
<view v-else class="upload-section" :class="{ 'upload-section--laser': isLaserCardCraft }">
|
||||||
<view class="upload-box-wrap">
|
<view class="upload-box-wrap">
|
||||||
<view class="upload-box" :class="{ 'upload-box--laser': isLaserCardCraft }" @click="onSingleUploadTap">
|
<view
|
||||||
|
:class="isLaserCardCraft ? 'castlove-laser-upload-root' : 'upload-box'"
|
||||||
|
@click="onSingleUploadTap"
|
||||||
|
>
|
||||||
<image v-if="uploadedImage" class="uploaded-image" :src="uploadedImage" mode="aspectFill"></image>
|
<image v-if="uploadedImage" class="uploaded-image" :src="uploadedImage" mode="aspectFill"></image>
|
||||||
<view v-else-if="isLaserCardCraft" class="upload-laser-empty">
|
<view v-else-if="isLaserCardCraft" class="upload-laser-stack">
|
||||||
|
<image
|
||||||
|
class="upload-laser-photo-bg"
|
||||||
|
src="/static/castlove/leisheka.png"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
<view class="upload-laser-watermark" aria-hidden="true" />
|
<view class="upload-laser-watermark" aria-hidden="true" />
|
||||||
|
<view class="upload-laser-inset">
|
||||||
<view class="upload-laser-dashed-inner">
|
<view class="upload-laser-dashed-inner">
|
||||||
<view class="upload-plus-ring">
|
|
||||||
<text class="upload-plus upload-plus--laser">+</text>
|
<text class="upload-plus upload-plus--laser">+</text>
|
||||||
</view>
|
|
||||||
<text class="upload-text upload-text--laser">点击上传图片</text>
|
<text class="upload-text upload-text--laser">点击上传图片</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
<view v-else class="upload-placeholder">
|
<view v-else class="upload-placeholder">
|
||||||
<image class="upload-icon" src="/static/icon/add.png" mode="aspectFit" />
|
<image class="upload-icon" src="/static/icon/add.png" mode="aspectFit" />
|
||||||
<text class="upload-text">点击上传图片</text>
|
<text class="upload-text">点击上传图片</text>
|
||||||
@ -183,6 +191,7 @@ import { onLoad, onUnload } from '@dcloudio/uni-app';
|
|||||||
import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js';
|
import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js';
|
||||||
import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js';
|
import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js';
|
||||||
import ConfirmModal from '@/components/ConfirmModal.vue';
|
import ConfirmModal from '@/components/ConfirmModal.vue';
|
||||||
|
import './create-laser-upload.css';
|
||||||
import {
|
import {
|
||||||
buildCastloveFormSnapshot,
|
buildCastloveFormSnapshot,
|
||||||
CRAFT_LENTICULAR_CN,
|
CRAFT_LENTICULAR_CN,
|
||||||
@ -201,8 +210,8 @@ const scrollEnhanced = true;
|
|||||||
const scrollEnhanced = false;
|
const scrollEnhanced = false;
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
const isLenticularCraft = computed(() => pageName.value === CRAFT_LENTICULAR_CN);
|
const isLenticularCraft = computed(() => (pageName.value || '').trim() === CRAFT_LENTICULAR_CN);
|
||||||
const isLaserCardCraft = computed(() => pageName.value === CRAFT_LASER_CARD_CN);
|
const isLaserCardCraft = computed(() => (pageName.value || '').trim() === CRAFT_LASER_CARD_CN);
|
||||||
|
|
||||||
onUnload(() => {
|
onUnload(() => {
|
||||||
try {
|
try {
|
||||||
@ -1449,95 +1458,6 @@ onMounted(() => {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-section--laser .upload-box--laser {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 460rpx;
|
|
||||||
border-radius: 36rpx;
|
|
||||||
border: 2rpx solid rgba(255, 255, 255, 0.32);
|
|
||||||
box-shadow: 0 18rpx 56rpx rgba(60, 30, 100, 0.28);
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
backdrop-filter: blur(18rpx);
|
|
||||||
-webkit-backdrop-filter: blur(18rpx);
|
|
||||||
/* 外框实线;内层虚线框见 .upload-laser-dashed-inner */
|
|
||||||
border-style: solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-section--laser .upload-box--laser .uploaded-image {
|
|
||||||
border-radius: 32rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-laser-empty {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 28rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-laser-watermark {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 0;
|
|
||||||
opacity: 0.35;
|
|
||||||
pointer-events: none;
|
|
||||||
background:
|
|
||||||
radial-gradient(ellipse 52% 78% at 50% 100%, rgba(60, 35, 90, 0.55), transparent 58%),
|
|
||||||
radial-gradient(ellipse 42% 55% at 50% 36%, rgba(140, 110, 180, 0.45), transparent 58%),
|
|
||||||
radial-gradient(ellipse 28% 22% at 50% 16%, rgba(200, 180, 230, 0.35), transparent 70%),
|
|
||||||
linear-gradient(185deg, rgba(255, 210, 240, 0.15) 0%, rgba(80, 50, 120, 0.18) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-laser-dashed-inner {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
width: 82%;
|
|
||||||
height: 78%;
|
|
||||||
max-width: 520rpx;
|
|
||||||
max-height: 360rpx;
|
|
||||||
border: 3rpx dashed rgba(255, 255, 255, 0.92);
|
|
||||||
border-radius: 28rpx;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 20rpx;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-plus-ring {
|
|
||||||
width: 100rpx;
|
|
||||||
height: 100rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 3rpx solid rgba(255, 255, 255, 0.95);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 8rpx 28rpx rgba(40, 20, 80, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-plus--laser {
|
|
||||||
font-size: 64rpx;
|
|
||||||
line-height: 1;
|
|
||||||
color: rgba(255, 255, 255, 0.98);
|
|
||||||
font-weight: 200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-text--laser {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: rgba(255, 255, 255, 0.95);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-section--laser .upload-hint {
|
.upload-section--laser .upload-hint {
|
||||||
color: rgba(255, 255, 255, 0.88);
|
color: rgba(255, 255, 255, 0.88);
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
@ -1564,9 +1484,9 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.upload-clear-x--laser {
|
.upload-clear-x--laser {
|
||||||
color: rgba(90, 70, 120, 0.85);
|
color: #3d6fd8;
|
||||||
font-size: 36rpx;
|
font-size: 34rpx;
|
||||||
font-weight: 300;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-section--laser {
|
.form-section--laser {
|
||||||
|
|||||||
@ -8,10 +8,15 @@
|
|||||||
<text class="top-bar-title-text">光栅卡工作室</text>
|
<text class="top-bar-title-text">光栅卡工作室</text>
|
||||||
<text class="top-bar-subtitle">倾斜手机 · 重力感应预览</text>
|
<text class="top-bar-subtitle">倾斜手机 · 重力感应预览</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="top-bar-actions">
|
||||||
|
<view class="top-bar-chip" @tap.stop="onRecalibrate">
|
||||||
|
<text class="top-bar-chip-text">校零</text>
|
||||||
|
</view>
|
||||||
<view class="top-bar-btn" @tap="onMore">
|
<view class="top-bar-btn" @tap="onMore">
|
||||||
<text class="nav-glyph">⋮</text>
|
<text class="nav-glyph">⋮</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="canvas-wrap">
|
<view class="canvas-wrap">
|
||||||
<view class="canvas-bg" />
|
<view class="canvas-bg" />
|
||||||
@ -21,16 +26,9 @@
|
|||||||
:transforms="layerTransforms"
|
:transforms="layerTransforms"
|
||||||
:gyro-source="gyroSourceLabel"
|
:gyro-source="gyroSourceLabel"
|
||||||
:skip-built-in-touch="true"
|
:skip-built-in-touch="true"
|
||||||
:tilt-hint-text="tiltHintText"
|
:tilt-hint-text="''"
|
||||||
@simulate="simulate"
|
@simulate="simulate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<view class="preview-controls" @tap.stop>
|
|
||||||
<view class="recalib-chip" @tap.stop="onRecalibrate">
|
|
||||||
<text class="recalib-chip-text">校零(水平对准)</text>
|
|
||||||
</view>
|
|
||||||
<text v-if="gyroHintLine" class="preview-controls-hint">{{ gyroHintLine }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="tool-dock">
|
<view class="tool-dock">
|
||||||
@ -74,9 +72,10 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import LenticularCard from '@/components/lenticular/LenticularCard.vue'
|
import LenticularCard from '@/components/lenticular/LenticularCard.vue'
|
||||||
import { useLenticularPreview } from '@/composables/useLenticularPreview.js'
|
import { useLenticularPreview } from '@/composables/useLenticularPreview.js'
|
||||||
|
import { useLenticularStudioTilt } from '@/composables/useLenticularStudioTilt.js'
|
||||||
import {
|
import {
|
||||||
buildLenticularLayersTwo,
|
buildLenticularLayersTwo,
|
||||||
LENTICULAR_STUDIO_STORAGE_KEY,
|
LENTICULAR_STUDIO_STORAGE_KEY,
|
||||||
@ -85,129 +84,46 @@ import {
|
|||||||
const layers = ref([])
|
const layers = ref([])
|
||||||
const gyroSourceLabel = ref('simulation')
|
const gyroSourceLabel = ref('simulation')
|
||||||
|
|
||||||
let accelHandler = null
|
|
||||||
let accelSmoothed = 0
|
|
||||||
/** 平面内重力方向角增量积分,用于持续同向倾斜时周期性叠化 */
|
|
||||||
let rollAccum = 0
|
|
||||||
let prevCu = null
|
|
||||||
let prevCv = null
|
|
||||||
|
|
||||||
/** 基线帧数:过长时用户若在采样期小幅晃动,会把「水平」拉进摆动中间,相对角变小、体感发木 */
|
|
||||||
const BASELINE_FRAMES = 10
|
|
||||||
/** 瞬时相对基线角 → [-1,1] 的弧度尺度(越小越灵敏) */
|
|
||||||
const INSTANT_FULL_RAD = 0.32
|
|
||||||
/** 相邻帧转角上限(弧度),抑制噪声尖峰 */
|
|
||||||
const MAX_STEP_RAD = 0.48
|
|
||||||
/** 角增量放大:越大同向拧动积分越快、周期切换越快 */
|
|
||||||
const STEP_GAIN = 2.15
|
|
||||||
/** sin 相位倍率:越大同样积分下周期越多 */
|
|
||||||
const SIN_PHASE_SCALE = 1.18
|
|
||||||
/** 输出 = 瞬时项 + 周期项 的混合 */
|
|
||||||
const BLEND_INSTANT = 0.38
|
|
||||||
|
|
||||||
let tiltCal = {
|
|
||||||
lockedMode: null,
|
|
||||||
planeVotes: [],
|
|
||||||
sumU: 0,
|
|
||||||
sumV: 0,
|
|
||||||
count: 0,
|
|
||||||
ready: false,
|
|
||||||
bu: 1,
|
|
||||||
bv: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetTiltCalibration() {
|
|
||||||
tiltCal = {
|
|
||||||
lockedMode: null,
|
|
||||||
planeVotes: [],
|
|
||||||
sumU: 0,
|
|
||||||
sumV: 0,
|
|
||||||
count: 0,
|
|
||||||
ready: false,
|
|
||||||
bu: 1,
|
|
||||||
bv: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function majorityPlane(votes) {
|
|
||||||
const cnt = { xz: 0, xy: 0, yz: 0 }
|
|
||||||
for (const v of votes) cnt[v] = (cnt[v] || 0) + 1
|
|
||||||
if (cnt.xz >= cnt.xy && cnt.xz >= cnt.yz) return 'xz'
|
|
||||||
if (cnt.xy >= cnt.yz) return 'xy'
|
|
||||||
return 'yz'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 重力主要沿哪条设备轴:在该轴上投影小,用另两轴构成平面测「绕该轴的滚转」 */
|
|
||||||
function detectGravityPlane(nx, ny, nz) {
|
|
||||||
const ax = Math.abs(nx)
|
|
||||||
const ay = Math.abs(ny)
|
|
||||||
const az = Math.abs(nz)
|
|
||||||
if (ay >= ax && ay >= az) return 'xz'
|
|
||||||
if (az >= ax && az >= ay) return 'xy'
|
|
||||||
return 'yz'
|
|
||||||
}
|
|
||||||
|
|
||||||
function uvForPlane(nx, ny, nz, mode) {
|
|
||||||
if (mode === 'xz') return { u: nx, v: nz }
|
|
||||||
if (mode === 'xy') return { u: nx, v: ny }
|
|
||||||
return { u: ny, v: nz }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {null | { warmup: boolean, cu?: number, cv?: number, deltaRad?: number }}
|
|
||||||
*/
|
|
||||||
function accelToRelativeTilt01(ax, ay, az) {
|
|
||||||
const gMag = Math.hypot(ax, ay, az)
|
|
||||||
if (gMag < 0.18) return null
|
|
||||||
const nx = ax / gMag
|
|
||||||
const ny = ay / gMag
|
|
||||||
const nz = az / gMag
|
|
||||||
|
|
||||||
if (!tiltCal.ready) {
|
|
||||||
const m = detectGravityPlane(nx, ny, nz)
|
|
||||||
if (tiltCal.planeVotes.length < 5) tiltCal.planeVotes.push(m)
|
|
||||||
if (tiltCal.planeVotes.length >= 5 && tiltCal.lockedMode == null) {
|
|
||||||
tiltCal.lockedMode = majorityPlane(tiltCal.planeVotes)
|
|
||||||
tiltCal.sumU = 0
|
|
||||||
tiltCal.sumV = 0
|
|
||||||
tiltCal.count = 0
|
|
||||||
}
|
|
||||||
if (tiltCal.lockedMode == null) return { warmup: true }
|
|
||||||
const { u, v } = uvForPlane(nx, ny, nz, tiltCal.lockedMode)
|
|
||||||
const h = Math.hypot(u, v)
|
|
||||||
if (h < 0.035) return { warmup: true }
|
|
||||||
tiltCal.sumU += u / h
|
|
||||||
tiltCal.sumV += v / h
|
|
||||||
tiltCal.count += 1
|
|
||||||
if (tiltCal.count >= BASELINE_FRAMES) {
|
|
||||||
const bh = Math.hypot(tiltCal.sumU, tiltCal.sumV) || 1
|
|
||||||
tiltCal.bu = tiltCal.sumU / bh
|
|
||||||
tiltCal.bv = tiltCal.sumV / bh
|
|
||||||
tiltCal.ready = true
|
|
||||||
}
|
|
||||||
return { warmup: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
const { u, v } = uvForPlane(nx, ny, nz, tiltCal.lockedMode)
|
|
||||||
const h = Math.hypot(u, v)
|
|
||||||
if (h < 0.028) return null
|
|
||||||
const cu = u / h
|
|
||||||
const cv = v / h
|
|
||||||
const sinD = cu * tiltCal.bv - cv * tiltCal.bu
|
|
||||||
const cosD = cu * tiltCal.bu + cv * tiltCal.bv
|
|
||||||
const deltaRad = Math.atan2(sinD, cosD)
|
|
||||||
return { warmup: false, cu, cv, deltaRad }
|
|
||||||
}
|
|
||||||
|
|
||||||
const { physics, layerTransforms, simulate } = useLenticularPreview(layers)
|
const { physics, layerTransforms, simulate } = useLenticularPreview(layers)
|
||||||
|
|
||||||
const tiltHintText = computed(() => '缓慢扭动手机 · 「校零」回中')
|
/** 相对进入时基准的倾角标量(度),每满 15° 一档;左右倾斜等价 */
|
||||||
|
const STUDIO_DISCRETE_STEP_DEG = 15
|
||||||
|
/** 档位施密特迟滞(度),减轻在档位边界来回跳 */
|
||||||
|
const DISCRETE_STEP_HYST_DEG = 4
|
||||||
|
|
||||||
const gyroHintLine = computed(() => {
|
const discreteStableStep = ref(0)
|
||||||
if (gyroSourceLabel.value === 'accelerometer') {
|
|
||||||
return '重力计驱动 · 持续同向倾斜会反复叠化;可点「校零」'
|
/**
|
||||||
}
|
* 将相对基准的倾角标量(度,非负)映射为 LenticularEngine 的 gamma。
|
||||||
return '倾斜驱动预览中;若无效请检查权限或换真机'
|
* @param {number} tiltMagDeg 相对校零的倾角幅度(度);由 composable 已做低通时可为非负标量
|
||||||
|
*/
|
||||||
|
function simulateFromSignedDegrees(tiltMagDeg) {
|
||||||
|
const ls = layers.value || []
|
||||||
|
const n = Math.max(1, ls.length)
|
||||||
|
const absDeg = Math.abs(Number(tiltMagDeg) || 0)
|
||||||
|
const STEP = STUDIO_DISCRETE_STEP_DEG
|
||||||
|
const MARGIN = DISCRETE_STEP_HYST_DEG
|
||||||
|
let s = discreteStableStep.value
|
||||||
|
while (absDeg >= (s + 1) * STEP + MARGIN) s++
|
||||||
|
while (s > 0 && absDeg <= s * STEP - MARGIN) s--
|
||||||
|
discreteStableStep.value = s
|
||||||
|
const idx = s % n
|
||||||
|
const sens = physics.tiltSensitivity / 100
|
||||||
|
const mul = 0.44 + sens * 0.52
|
||||||
|
const u = (idx + 0.5) / n
|
||||||
|
const gPick = Math.max(-1, Math.min(1, 2 * u - 1))
|
||||||
|
const gamma = Math.max(-1, Math.min(1, gPick / mul))
|
||||||
|
simulate(gamma, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start: startTilt, stop: stopTilt, recalibrate: recalibrateTilt } = useLenticularStudioTilt({
|
||||||
|
simulate,
|
||||||
|
simulateFromSignedDegrees,
|
||||||
|
gyroSourceLabel,
|
||||||
|
useStudioAccelDirect: true,
|
||||||
|
onTiltDriverFallback: () => {
|
||||||
|
discreteStableStep.value = 0
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
@ -279,86 +195,9 @@ function resetLayer(layerId) {
|
|||||||
layers.value = copy
|
layers.value = copy
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopAccel() {
|
|
||||||
try {
|
|
||||||
if (accelHandler && typeof uni.offAccelerometerChange === 'function') {
|
|
||||||
uni.offAccelerometerChange(accelHandler)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
/* noop */
|
|
||||||
}
|
|
||||||
accelHandler = null
|
|
||||||
try {
|
|
||||||
uni.stopAccelerometer({})
|
|
||||||
} catch (e) {
|
|
||||||
/* noop */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startAccel() {
|
|
||||||
stopAccel()
|
|
||||||
resetTiltCalibration()
|
|
||||||
accelSmoothed = 0
|
|
||||||
rollAccum = 0
|
|
||||||
prevCu = null
|
|
||||||
prevCv = null
|
|
||||||
accelHandler = (res) => {
|
|
||||||
const ax = Number(res.x) || 0
|
|
||||||
const ay = Number(res.y) || 0
|
|
||||||
const az = Number(res.z) || 0
|
|
||||||
const out = accelToRelativeTilt01(ax, ay, az)
|
|
||||||
if (out == null) return
|
|
||||||
if (out.warmup) return
|
|
||||||
const { cu, cv, deltaRad } = out
|
|
||||||
if (prevCu != null && prevCv != null) {
|
|
||||||
let step = Math.atan2(cu * prevCv - cv * prevCu, cu * prevCu + cv * prevCv)
|
|
||||||
if (step > Math.PI * 0.92) step -= 2 * Math.PI
|
|
||||||
if (step < -Math.PI * 0.92) step += 2 * Math.PI
|
|
||||||
step = Math.max(-MAX_STEP_RAD, Math.min(MAX_STEP_RAD, step))
|
|
||||||
rollAccum += step * STEP_GAIN
|
|
||||||
}
|
|
||||||
prevCu = cu
|
|
||||||
prevCv = cv
|
|
||||||
|
|
||||||
const instant = Math.max(-1, Math.min(1, deltaRad / INSTANT_FULL_RAD))
|
|
||||||
const cyclic = Math.sin(rollAccum * SIN_PHASE_SCALE)
|
|
||||||
const target = BLEND_INSTANT * instant + (1 - BLEND_INSTANT) * cyclic
|
|
||||||
|
|
||||||
accelSmoothed += (target - accelSmoothed) * 0.62
|
|
||||||
simulate(Math.max(-1, Math.min(1, accelSmoothed)), 0)
|
|
||||||
gyroSourceLabel.value = 'accelerometer'
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
uni.onAccelerometerChange(accelHandler)
|
|
||||||
const applyStart = (interval) => {
|
|
||||||
uni.startAccelerometer({
|
|
||||||
interval,
|
|
||||||
success: () => {
|
|
||||||
gyroSourceLabel.value = 'accelerometer'
|
|
||||||
},
|
|
||||||
fail: () => {
|
|
||||||
if (interval === 'game') {
|
|
||||||
applyStart('normal')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
gyroSourceLabel.value = 'simulation'
|
|
||||||
uni.showToast({ title: '无法启动加速度计', icon: 'none' })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
applyStart('game')
|
|
||||||
} catch (e) {
|
|
||||||
gyroSourceLabel.value = 'simulation'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRecalibrate() {
|
function onRecalibrate() {
|
||||||
resetTiltCalibration()
|
discreteStableStep.value = 0
|
||||||
accelSmoothed = 0
|
recalibrateTilt()
|
||||||
rollAccum = 0
|
|
||||||
prevCu = null
|
|
||||||
prevCv = null
|
|
||||||
simulate(0, 0)
|
|
||||||
uni.showToast({ title: '已重置水平基准', icon: 'none' })
|
uni.showToast({ title: '已重置水平基准', icon: 'none' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -379,16 +218,17 @@ onMounted(() => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
physics.angleStability = 16
|
/* 离散换图:略降过渡混合,档位切换更利落 */
|
||||||
physics.transitionSmoothness = 62
|
physics.angleStability = 6
|
||||||
|
physics.transitionSmoothness = 12
|
||||||
physics.tiltSensitivity = 96
|
physics.tiltSensitivity = 96
|
||||||
physics.sensorDeadzoneStrength = 0
|
physics.sensorDeadzoneStrength = 0
|
||||||
startAccel()
|
startTilt()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopAccel()
|
stopTilt()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -426,6 +266,25 @@ onUnmounted(() => {
|
|||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-bar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar-chip {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(45, 52, 73, 0.75);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar-chip-text {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ddb7ff;
|
||||||
|
}
|
||||||
|
|
||||||
.top-bar-btn {
|
.top-bar-btn {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
@ -477,40 +336,6 @@ onUnmounted(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-controls {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 22;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
max-width: 92vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recalib-chip {
|
|
||||||
padding: 5px 14px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(45, 52, 73, 0.82);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recalib-chip-text {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #ddb7ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-controls-hint {
|
|
||||||
font-size: 10px;
|
|
||||||
color: rgba(207, 194, 214, 0.78);
|
|
||||||
text-align: center;
|
|
||||||
max-width: 260px;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-bg {
|
.canvas-bg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
55
frontend/scripts/generate_app_icons.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
"""One-off: generate PNGs under unpackage/res/icons for manifest.json paths."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
# 勿放 unpackage/(.gitignore),否则云端/他人拉代码后 HBuilder 仍报图标不存在
|
||||||
|
OUT = ROOT / "static" / "app-icons"
|
||||||
|
|
||||||
|
BG = (0x4A, 0x6B, 0xCF)
|
||||||
|
ACCENT = (0xFF, 0xFF, 0xFF)
|
||||||
|
|
||||||
|
SIZES = {
|
||||||
|
"72x72.png": 72,
|
||||||
|
"96x96.png": 96,
|
||||||
|
"144x144.png": 144,
|
||||||
|
"192x192.png": 192,
|
||||||
|
"1024x1024.png": 1024,
|
||||||
|
"76x76.png": 76,
|
||||||
|
"152x152.png": 152,
|
||||||
|
"20x20.png": 20,
|
||||||
|
"40x40.png": 40,
|
||||||
|
"167x167.png": 167,
|
||||||
|
"29x29.png": 29,
|
||||||
|
"58x58.png": 58,
|
||||||
|
"80x80.png": 80,
|
||||||
|
"120x120.png": 120,
|
||||||
|
"180x180.png": 180,
|
||||||
|
"60x60.png": 60,
|
||||||
|
"87x87.png": 87,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
for fname, w in SIZES.items():
|
||||||
|
h = w
|
||||||
|
im = Image.new("RGB", (w, h), BG)
|
||||||
|
draw = ImageDraw.Draw(im)
|
||||||
|
margin = max(1, w // 8)
|
||||||
|
draw.ellipse(
|
||||||
|
[margin, margin, w - margin, h - margin],
|
||||||
|
outline=ACCENT,
|
||||||
|
width=max(1, w // 32),
|
||||||
|
)
|
||||||
|
if w >= 48:
|
||||||
|
r = w // 5
|
||||||
|
cx, cy = w // 2, h // 2
|
||||||
|
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=ACCENT)
|
||||||
|
im.save(OUT / fname, format="PNG", optimize=True)
|
||||||
|
print("wrote", len(SIZES), "icons to", OUT)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
BIN
frontend/static/app-icons/1024x1024.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
frontend/static/app-icons/120x120.png
Normal file
|
After Width: | Height: | Size: 794 B |
BIN
frontend/static/app-icons/144x144.png
Normal file
|
After Width: | Height: | Size: 990 B |
BIN
frontend/static/app-icons/152x152.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
frontend/static/app-icons/167x167.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/static/app-icons/180x180.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/static/app-icons/192x192.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
frontend/static/app-icons/20x20.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
frontend/static/app-icons/29x29.png
Normal file
|
After Width: | Height: | Size: 206 B |
BIN
frontend/static/app-icons/40x40.png
Normal file
|
After Width: | Height: | Size: 237 B |
BIN
frontend/static/app-icons/58x58.png
Normal file
|
After Width: | Height: | Size: 377 B |
BIN
frontend/static/app-icons/60x60.png
Normal file
|
After Width: | Height: | Size: 386 B |
BIN
frontend/static/app-icons/72x72.png
Normal file
|
After Width: | Height: | Size: 462 B |
BIN
frontend/static/app-icons/76x76.png
Normal file
|
After Width: | Height: | Size: 497 B |
BIN
frontend/static/app-icons/80x80.png
Normal file
|
After Width: | Height: | Size: 525 B |
BIN
frontend/static/app-icons/87x87.png
Normal file
|
After Width: | Height: | Size: 607 B |
BIN
frontend/static/app-icons/96x96.png
Normal file
|
After Width: | Height: | Size: 658 B |
BIN
frontend/static/castlove/laser-bg/laser-bg-1.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
frontend/static/castlove/laser-bg/laser-bg-2.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
frontend/static/castlove/laser-bg/laser-bg-3.png
Normal file
|
After Width: | Height: | Size: 69 KiB |