feat新增光栅卡和镭射卡
@ -38,18 +38,18 @@ export default {
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* 引入圆体字体 */
|
||||
@font-face {
|
||||
font-family: 'yt';
|
||||
src: url('/static/fonts/经典圆体简.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
/* 圆体 JDLTYuanTiJian.ttf 在部分 Android WebView 上报 OTS/cmap 解析失败,暂不 @font-face 加载,避免控制台告警与渲染异常 */
|
||||
|
||||
/* 全局字体设置 */
|
||||
body {
|
||||
font-family: 'yt', sans-serif;
|
||||
font-family:
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'PingFang SC',
|
||||
'Hiragino Sans GB',
|
||||
'Microsoft YaHei',
|
||||
'Noto Sans SC',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
/* 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()
|
||||
// #endif
|
||||
|
||||
// #ifdef VUE3 && H5
|
||||
import './pages/castlove/create-laser-upload.css'
|
||||
// #endif
|
||||
|
||||
// #ifdef VUE3
|
||||
import { createSSRApp } from 'vue'
|
||||
import store from './store'
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name" : "TopFans",
|
||||
"appid" : "__UNI__F199FF4",
|
||||
"appid" : "__UNI__8CBE431",
|
||||
"description" : "",
|
||||
"versionName" : "1.0.4",
|
||||
"versionCode" : 105,
|
||||
@ -26,6 +26,9 @@
|
||||
"Speech" : {},
|
||||
"Push" : {}
|
||||
},
|
||||
"nativePlugins" : {
|
||||
"imengyu-UniAndroidGyro" : {}
|
||||
},
|
||||
/* 应用发布信息 */
|
||||
"distribute" : {
|
||||
/* android打包配置 */
|
||||
@ -72,37 +75,38 @@
|
||||
}
|
||||
},
|
||||
"push" : {},
|
||||
"statics" : {}
|
||||
"statics" : {},
|
||||
"ad" : {}
|
||||
},
|
||||
"icons" : {
|
||||
"android" : {
|
||||
"hdpi" : "unpackage/res/icons/72x72.png",
|
||||
"xhdpi" : "unpackage/res/icons/96x96.png",
|
||||
"xxhdpi" : "unpackage/res/icons/144x144.png",
|
||||
"xxxhdpi" : "unpackage/res/icons/192x192.png"
|
||||
"hdpi" : "static/app-icons/72x72.png",
|
||||
"xhdpi" : "static/app-icons/96x96.png",
|
||||
"xxhdpi" : "static/app-icons/144x144.png",
|
||||
"xxxhdpi" : "static/app-icons/192x192.png"
|
||||
},
|
||||
"ios" : {
|
||||
"appstore" : "unpackage/res/icons/1024x1024.png",
|
||||
"appstore" : "static/app-icons/1024x1024.png",
|
||||
"ipad" : {
|
||||
"app" : "unpackage/res/icons/76x76.png",
|
||||
"app@2x" : "unpackage/res/icons/152x152.png",
|
||||
"notification" : "unpackage/res/icons/20x20.png",
|
||||
"notification@2x" : "unpackage/res/icons/40x40.png",
|
||||
"proapp@2x" : "unpackage/res/icons/167x167.png",
|
||||
"settings" : "unpackage/res/icons/29x29.png",
|
||||
"settings@2x" : "unpackage/res/icons/58x58.png",
|
||||
"spotlight" : "unpackage/res/icons/40x40.png",
|
||||
"spotlight@2x" : "unpackage/res/icons/80x80.png"
|
||||
"app" : "static/app-icons/76x76.png",
|
||||
"app@2x" : "static/app-icons/152x152.png",
|
||||
"notification" : "static/app-icons/20x20.png",
|
||||
"notification@2x" : "static/app-icons/40x40.png",
|
||||
"proapp@2x" : "static/app-icons/167x167.png",
|
||||
"settings" : "static/app-icons/29x29.png",
|
||||
"settings@2x" : "static/app-icons/58x58.png",
|
||||
"spotlight" : "static/app-icons/40x40.png",
|
||||
"spotlight@2x" : "static/app-icons/80x80.png"
|
||||
},
|
||||
"iphone" : {
|
||||
"app@2x" : "unpackage/res/icons/120x120.png",
|
||||
"app@3x" : "unpackage/res/icons/180x180.png",
|
||||
"notification@2x" : "unpackage/res/icons/40x40.png",
|
||||
"notification@3x" : "unpackage/res/icons/60x60.png",
|
||||
"settings@2x" : "unpackage/res/icons/58x58.png",
|
||||
"settings@3x" : "unpackage/res/icons/87x87.png",
|
||||
"spotlight@2x" : "unpackage/res/icons/80x80.png",
|
||||
"spotlight@3x" : "unpackage/res/icons/120x120.png"
|
||||
"app@2x" : "static/app-icons/120x120.png",
|
||||
"app@3x" : "static/app-icons/180x180.png",
|
||||
"notification@2x" : "static/app-icons/40x40.png",
|
||||
"notification@3x" : "static/app-icons/60x60.png",
|
||||
"settings@2x" : "static/app-icons/58x58.png",
|
||||
"settings@3x" : "static/app-icons/87x87.png",
|
||||
"spotlight@2x" : "static/app-icons/80x80.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",
|
||||
"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 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>
|
||||
<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-inset">
|
||||
<view class="upload-laser-dashed-inner">
|
||||
<view class="upload-plus-ring">
|
||||
<text class="upload-plus upload-plus--laser">+</text>
|
||||
</view>
|
||||
<text class="upload-text upload-text--laser">点击上传图片</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="upload-placeholder">
|
||||
<image class="upload-icon" src="/static/icon/add.png" mode="aspectFit" />
|
||||
<text class="upload-text">点击上传图片</text>
|
||||
@ -183,6 +191,7 @@ import { onLoad, onUnload } from '@dcloudio/uni-app';
|
||||
import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js';
|
||||
import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js';
|
||||
import ConfirmModal from '@/components/ConfirmModal.vue';
|
||||
import './create-laser-upload.css';
|
||||
import {
|
||||
buildCastloveFormSnapshot,
|
||||
CRAFT_LENTICULAR_CN,
|
||||
@ -201,8 +210,8 @@ const scrollEnhanced = true;
|
||||
const scrollEnhanced = false;
|
||||
// #endif
|
||||
|
||||
const isLenticularCraft = computed(() => pageName.value === CRAFT_LENTICULAR_CN);
|
||||
const isLaserCardCraft = computed(() => pageName.value === CRAFT_LASER_CARD_CN);
|
||||
const isLenticularCraft = computed(() => (pageName.value || '').trim() === CRAFT_LENTICULAR_CN);
|
||||
const isLaserCardCraft = computed(() => (pageName.value || '').trim() === CRAFT_LASER_CARD_CN);
|
||||
|
||||
onUnload(() => {
|
||||
try {
|
||||
@ -1449,95 +1458,6 @@ onMounted(() => {
|
||||
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 {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
font-size: 22rpx;
|
||||
@ -1564,9 +1484,9 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.upload-clear-x--laser {
|
||||
color: rgba(90, 70, 120, 0.85);
|
||||
font-size: 36rpx;
|
||||
font-weight: 300;
|
||||
color: #3d6fd8;
|
||||
font-size: 34rpx;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.form-section--laser {
|
||||
|
||||
@ -8,10 +8,15 @@
|
||||
<text class="top-bar-title-text">光栅卡工作室</text>
|
||||
<text class="top-bar-subtitle">倾斜手机 · 重力感应预览</text>
|
||||
</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">
|
||||
<text class="nav-glyph">⋮</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="canvas-wrap">
|
||||
<view class="canvas-bg" />
|
||||
@ -21,16 +26,9 @@
|
||||
:transforms="layerTransforms"
|
||||
:gyro-source="gyroSourceLabel"
|
||||
:skip-built-in-touch="true"
|
||||
:tilt-hint-text="tiltHintText"
|
||||
:tilt-hint-text="''"
|
||||
@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 class="tool-dock">
|
||||
@ -74,9 +72,10 @@ export default {
|
||||
</script>
|
||||
|
||||
<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 { useLenticularPreview } from '@/composables/useLenticularPreview.js'
|
||||
import { useLenticularStudioTilt } from '@/composables/useLenticularStudioTilt.js'
|
||||
import {
|
||||
buildLenticularLayersTwo,
|
||||
LENTICULAR_STUDIO_STORAGE_KEY,
|
||||
@ -85,129 +84,46 @@ import {
|
||||
const layers = ref([])
|
||||
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 tiltHintText = computed(() => '缓慢扭动手机 · 「校零」回中')
|
||||
/** 相对进入时基准的倾角标量(度),每满 15° 一档;左右倾斜等价 */
|
||||
const STUDIO_DISCRETE_STEP_DEG = 15
|
||||
/** 档位施密特迟滞(度),减轻在档位边界来回跳 */
|
||||
const DISCRETE_STEP_HYST_DEG = 4
|
||||
|
||||
const gyroHintLine = computed(() => {
|
||||
if (gyroSourceLabel.value === 'accelerometer') {
|
||||
return '重力计驱动 · 持续同向倾斜会反复叠化;可点「校零」'
|
||||
}
|
||||
return '倾斜驱动预览中;若无效请检查权限或换真机'
|
||||
const discreteStableStep = ref(0)
|
||||
|
||||
/**
|
||||
* 将相对基准的倾角标量(度,非负)映射为 LenticularEngine 的 gamma。
|
||||
* @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() {
|
||||
@ -279,86 +195,9 @@ function resetLayer(layerId) {
|
||||
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() {
|
||||
resetTiltCalibration()
|
||||
accelSmoothed = 0
|
||||
rollAccum = 0
|
||||
prevCu = null
|
||||
prevCv = null
|
||||
simulate(0, 0)
|
||||
discreteStableStep.value = 0
|
||||
recalibrateTilt()
|
||||
uni.showToast({ title: '已重置水平基准', icon: 'none' })
|
||||
}
|
||||
|
||||
@ -379,16 +218,17 @@ onMounted(() => {
|
||||
return
|
||||
}
|
||||
nextTick(() => {
|
||||
physics.angleStability = 16
|
||||
physics.transitionSmoothness = 62
|
||||
/* 离散换图:略降过渡混合,档位切换更利落 */
|
||||
physics.angleStability = 6
|
||||
physics.transitionSmoothness = 12
|
||||
physics.tiltSensitivity = 96
|
||||
physics.sensorDeadzoneStrength = 0
|
||||
startAccel()
|
||||
startTilt()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAccel()
|
||||
stopTilt()
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -426,6 +266,25 @@ onUnmounted(() => {
|
||||
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 {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
@ -477,40 +336,6 @@ onUnmounted(() => {
|
||||
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 {
|
||||
position: absolute;
|
||||
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 |