feat新增光栅卡和镭射卡

This commit is contained in:
liulong 2026-05-14 22:18:25 +08:00 committed by zerosaturation
parent d6b0397230
commit a90f7155c6
40 changed files with 1901 additions and 975 deletions

View File

@ -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 容器 */

View File

@ -0,0 +1,768 @@
/**
* 光栅卡工作室倾斜驱动App 优先 imengyu-UniAndroidGyroDCloud 插件 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 / fastestgame≈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,
}
}

View File

@ -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'

View File

@ -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"
} }
} }
} }

View File

@ -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>

View 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"
}
}
}

View File

@ -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"
}
]
}
} }

View 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);
}

View File

@ -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 {

File diff suppressed because it is too large Load Diff

View File

@ -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;

View 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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB