597 lines
15 KiB
Vue
597 lines
15 KiB
Vue
<template>
|
||
<view class="physics-page">
|
||
<view class="top-bar">
|
||
<view class="top-bar-btn" @tap="goBack">
|
||
<text class="nav-glyph">←</text>
|
||
</view>
|
||
<view class="top-bar-title">
|
||
<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" />
|
||
<LenticularCard
|
||
class="preview-stack"
|
||
:layers="layers"
|
||
:transforms="layerTransforms"
|
||
:gyro-source="gyroSourceLabel"
|
||
:skip-built-in-touch="true"
|
||
:tilt-hint-text="''"
|
||
:shimmer-mid-opacity="0.16"
|
||
@simulate="simulate"
|
||
/>
|
||
</view>
|
||
|
||
<view class="tool-dock">
|
||
<view class="dock-rim" />
|
||
<view class="layer-strip">
|
||
<view class="layer-strip-header">
|
||
<text class="layer-strip-title">图层素材</text>
|
||
<text class="layer-strip-hint">点击替换 · 长按重置</text>
|
||
</view>
|
||
<view class="layer-slots">
|
||
<view
|
||
v-for="layer in layers"
|
||
:key="layer.id"
|
||
class="layer-slot"
|
||
:class="{ 'layer-slot--filled': !!layer.src }"
|
||
@tap="pickImage(layer.id)"
|
||
@longpress="resetLayer(layer.id)"
|
||
>
|
||
<image v-if="layer.src" class="layer-slot-img" :src="layer.src" mode="aspectFill" />
|
||
<view v-else class="layer-slot-placeholder" :style="placeholderStyle(layer)">
|
||
<text class="layer-slot-icon">+</text>
|
||
</view>
|
||
<view class="layer-slot-meta">
|
||
<text class="layer-slot-name">{{ layer.label }}</text>
|
||
<text class="layer-slot-tag">{{ layer.src ? '已上传' : '点击上传' }}</text>
|
||
</view>
|
||
<view v-if="layer.src" class="layer-slot-badge" @tap.stop="resetLayer(layer.id)">
|
||
<text class="layer-slot-badge-icon">×</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
export default {
|
||
inheritAttrs: false,
|
||
};
|
||
</script>
|
||
|
||
<script setup>
|
||
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,
|
||
} from '@/utils/castloveMintForm.js'
|
||
|
||
const layers = ref([])
|
||
const gyroSourceLabel = ref('simulation')
|
||
|
||
const { physics, layerTransforms, simulate, relax, snapSimulatedTilt } = useLenticularPreview(layers)
|
||
|
||
/** 相对进入时基准的倾角标量(度),每满一档换一层;越大需掰得越狠才换图,体感更慢 */
|
||
const STUDIO_DISCRETE_STEP_DEG = 13
|
||
/** 档位施密特迟滞(度),减轻在档位边界来回跳;换向经过水平附近时略加大更稳 */
|
||
const DISCRETE_STEP_HYST_DEG = 5
|
||
/**
|
||
* 倾角幅度一阶低通的时间常数(ms),对称跟随:进档/退档节奏一致,避免「抬手慢、压手快」的忽快忽慢。
|
||
* dt 按实际回调间隔估算,与陀螺轮询/加速度计频率解耦。
|
||
*/
|
||
const STUDIO_TILT_MAG_TAU_MS = 150
|
||
/** 平滑后倾角幅度最大爬升速率(度/秒),抑制某一侧翻转时 raw 陡增导致的瞬间跳档 */
|
||
const STUDIO_TILT_MAG_MAX_RISE_DPS = 82
|
||
/** 回落可略快,减档仍跟手 */
|
||
const STUDIO_TILT_MAG_MAX_FALL_DPS = 168
|
||
/**
|
||
* 有符号倾角相对水平远离的速率阈值(度/秒);超过则认为该帧在「快速掰离水平」,
|
||
* 再收紧爬升限幅(典型:从下往上翻时 |atan2| 一侧梯度更大)。
|
||
*/
|
||
const STUDIO_TILT_AWAY_FROM_LEVEL_DPS = 72
|
||
/** 触发「快速远离水平」时,爬升限幅乘数(越小越匀) */
|
||
const STUDIO_TILT_RISE_TIGHTEN = 0.42
|
||
/** 进入页后延迟开启倾斜监听(ms),避免首帧/基线采集/大图解码时画面跟着抖 */
|
||
const STUDIO_TILT_START_DELAY_MS = 560
|
||
|
||
const discreteStableStep = ref(0)
|
||
/** 与离散档位同步平滑的倾角幅度(度) */
|
||
let studioTiltMagEma = 0
|
||
/** 上次幅度采样时间戳,用于按真实 dt 做指数平滑 */
|
||
let lastStudioTiltMagSampleAt = 0
|
||
/** 上一帧有符号倾角(度),仅加速度计路径传入;用于检测快速远离水平 */
|
||
let lastStudioSignedDegHint = null
|
||
|
||
function resetStudioTiltMagFilter() {
|
||
studioTiltMagEma = 0
|
||
lastStudioTiltMagSampleAt = 0
|
||
lastStudioSignedDegHint = null
|
||
}
|
||
|
||
/**
|
||
* 将相对基准的倾角标量(度,非负)映射为 LenticularEngine 的 gamma。
|
||
* @param {number} tiltMagDeg 相对校零的倾角幅度(度,非负)
|
||
* @param {number} [signedDegHint] 有符号倾角(度),仅加速度计路径传入;用于识别快速远离水平的一侧并收紧爬升
|
||
*/
|
||
function simulateFromSignedDegrees(tiltMagDeg, signedDegHint) {
|
||
const ls = layers.value || []
|
||
const n = Math.max(1, ls.length)
|
||
const raw = Math.abs(Number(tiltMagDeg) || 0)
|
||
const now = Date.now()
|
||
let dt = 33
|
||
if (lastStudioTiltMagSampleAt > 0) {
|
||
dt = now - lastStudioTiltMagSampleAt
|
||
}
|
||
lastStudioTiltMagSampleAt = now
|
||
dt = Math.max(16, Math.min(100, dt))
|
||
const alpha = 1 - Math.exp(-dt / STUDIO_TILT_MAG_TAU_MS)
|
||
let nextEma = studioTiltMagEma + (raw - studioTiltMagEma) * alpha
|
||
|
||
const sec = dt * 0.001
|
||
let maxRise = STUDIO_TILT_MAG_MAX_RISE_DPS * sec
|
||
const maxFall = STUDIO_TILT_MAG_MAX_FALL_DPS * sec
|
||
|
||
if (Number.isFinite(signedDegHint)) {
|
||
if (lastStudioSignedDegHint != null && sec > 1e-6) {
|
||
const dAbsSignedDt =
|
||
(Math.abs(signedDegHint) - Math.abs(lastStudioSignedDegHint)) / sec
|
||
if (dAbsSignedDt > STUDIO_TILT_AWAY_FROM_LEVEL_DPS) {
|
||
maxRise *= STUDIO_TILT_RISE_TIGHTEN
|
||
}
|
||
}
|
||
lastStudioSignedDegHint = signedDegHint
|
||
} else {
|
||
lastStudioSignedDegHint = null
|
||
}
|
||
|
||
let dMag = nextEma - studioTiltMagEma
|
||
if (dMag > 0) {
|
||
dMag = Math.min(dMag, maxRise)
|
||
} else {
|
||
dMag = Math.max(dMag, -maxFall)
|
||
}
|
||
studioTiltMagEma += dMag
|
||
|
||
const absDeg = studioTiltMagEma
|
||
const STEP = STUDIO_DISCRETE_STEP_DEG
|
||
const MARGIN = DISCRETE_STEP_HYST_DEG
|
||
const stepBefore = discreteStableStep.value
|
||
let s = stepBefore
|
||
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)
|
||
/* 不在此 snap:保留引擎内 displayGamma 渐近(angleStability / transitionSmoothness),
|
||
* 切换档位时 u 连续扫过叠化带,才能看到一张渐隐、另一张渐显。 */
|
||
}
|
||
|
||
const { start: startTilt, stop: stopTilt, recalibrate: recalibrateTilt } = useLenticularStudioTilt({
|
||
simulate,
|
||
simulateFromSignedDegrees,
|
||
gyroSourceLabel,
|
||
useStudioAccelDirect: true,
|
||
onTiltDriverFallback: () => {
|
||
discreteStableStep.value = 0
|
||
resetStudioTiltMagFilter()
|
||
},
|
||
})
|
||
|
||
/** 首屏与换素材后:档位与 EMA 归零,并写入与 0° 档位一致的 gamma(传感器未开时保持静止) */
|
||
function lockStudioPreviewStill() {
|
||
discreteStableStep.value = 0
|
||
resetStudioTiltMagFilter()
|
||
simulateFromSignedDegrees(0)
|
||
snapSimulatedTilt({ resetLayerSmoothing: true })
|
||
}
|
||
|
||
let entryTiltTimer = null
|
||
|
||
function goBack() {
|
||
uni.navigateBack({
|
||
delta: 1,
|
||
fail: () => {},
|
||
})
|
||
}
|
||
|
||
function onMore() {
|
||
uni.showActionSheet({
|
||
itemList: ['恢复为进入工作室时的图片'],
|
||
success: (res) => {
|
||
if (res.tapIndex === 0) {
|
||
const raw = uni.getStorageSync(LENTICULAR_STUDIO_STORAGE_KEY)
|
||
if (!raw) return
|
||
try {
|
||
const p = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||
layers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || '')
|
||
discreteStableStep.value = 0
|
||
resetStudioTiltMagFilter()
|
||
nextTick(() => relax(0.86))
|
||
uni.showToast({ title: '已恢复', icon: 'none' })
|
||
} catch (e) {
|
||
console.error(e)
|
||
}
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
function placeholderStyle(layer) {
|
||
if (layer.background) {
|
||
return { background: layer.background }
|
||
}
|
||
return { background: 'rgba(6,14,32,0.55)' }
|
||
}
|
||
|
||
function pickImage(layerId) {
|
||
uni.chooseImage({
|
||
count: 1,
|
||
sizeType: ['compressed'],
|
||
sourceType: ['album', 'camera'],
|
||
success: (res) => {
|
||
const path = res.tempFilePaths && res.tempFilePaths[0]
|
||
if (!path) return
|
||
const idx = layers.value.findIndex((l) => l.id === layerId)
|
||
if (idx < 0) return
|
||
const cur = layers.value[idx]
|
||
const next = { ...cur, src: path, background: undefined }
|
||
if (layerId === 'base') next.dots = undefined
|
||
const copy = [...layers.value]
|
||
copy[idx] = next
|
||
layers.value = copy
|
||
nextTick(() => relax(0.86))
|
||
},
|
||
fail: (err) => {
|
||
const msg = (err && err.errMsg) || ''
|
||
if (!msg.includes('cancel')) {
|
||
uni.showToast({ title: '选择图片失败', icon: 'none' })
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
function resetLayer(layerId) {
|
||
const fresh = buildLenticularLayersTwo('', '')
|
||
const defLayer = fresh.find((l) => l.id === layerId)
|
||
const idx = layers.value.findIndex((l) => l.id === layerId)
|
||
if (idx < 0 || !defLayer) return
|
||
const copy = [...layers.value]
|
||
copy[idx] = { ...defLayer }
|
||
layers.value = copy
|
||
nextTick(() => relax(0.86))
|
||
}
|
||
|
||
function onRecalibrate() {
|
||
discreteStableStep.value = 0
|
||
resetStudioTiltMagFilter()
|
||
recalibrateTilt()
|
||
nextTick(() => {
|
||
simulateFromSignedDegrees(0)
|
||
snapSimulatedTilt({ resetLayerSmoothing: true })
|
||
})
|
||
uni.showToast({ title: '已重置水平基准', icon: 'none' })
|
||
}
|
||
|
||
onMounted(() => {
|
||
try {
|
||
const raw = uni.getStorageSync(LENTICULAR_STUDIO_STORAGE_KEY)
|
||
if (!raw) {
|
||
uni.showToast({ title: '缺少素材数据,请返回上一步', icon: 'none' })
|
||
setTimeout(() => goBack(), 1600)
|
||
return
|
||
}
|
||
const p = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||
layers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || '')
|
||
/* 光栅感:略抬残影/ghost;叠化带宽略加宽,配合非 snap 的 gamma 渐近,换图时渐隐渐显更明显 */
|
||
Object.assign(physics, {
|
||
/* 略提高:让 smoothedSensor → displayGamma 在档位间多走几帧,叠化更明显 */
|
||
angleStability: 52,
|
||
transitionSmoothness: 40,
|
||
tiltSensitivity: 96,
|
||
sensorDeadzoneStrength: 0,
|
||
parallaxDepth: 18,
|
||
lenticularAnchorFloor: 0.1,
|
||
lenticularNonDominantResidualMin: 0.092,
|
||
lenticularPrevLayerGhostMin: 0.098,
|
||
lenticularBlendBaseScale: 1.1,
|
||
})
|
||
} catch (e) {
|
||
console.error('[lenticular-studio] load payload', e)
|
||
uni.showToast({ title: '数据无效', icon: 'none' })
|
||
setTimeout(() => goBack(), 1600)
|
||
return
|
||
}
|
||
nextTick(() => {
|
||
lockStudioPreviewStill()
|
||
if (entryTiltTimer != null) {
|
||
clearTimeout(entryTiltTimer)
|
||
entryTiltTimer = null
|
||
}
|
||
entryTiltTimer = setTimeout(() => {
|
||
entryTiltTimer = null
|
||
startTilt()
|
||
}, STUDIO_TILT_START_DELAY_MS)
|
||
})
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (entryTiltTimer != null) {
|
||
clearTimeout(entryTiltTimer)
|
||
entryTiltTimer = null
|
||
}
|
||
stopTilt()
|
||
})
|
||
</script>
|
||
|
||
<style>
|
||
.nav-glyph {
|
||
font-size: 22px;
|
||
line-height: 1;
|
||
font-weight: 600;
|
||
color: inherit;
|
||
}
|
||
</style>
|
||
|
||
<style scoped>
|
||
.physics-page {
|
||
width: 100vw;
|
||
height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #0b1326;
|
||
color: #dae2fd;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.top-bar {
|
||
position: relative;
|
||
z-index: 50;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 0 16px;
|
||
height: 56px;
|
||
padding-top: calc(env(safe-area-inset-top) + 4px);
|
||
background: rgba(11, 19, 38, 0.88);
|
||
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;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #ddb7ff;
|
||
}
|
||
|
||
.top-bar-title {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
.top-bar-title-text {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #dae2fd;
|
||
}
|
||
|
||
.top-bar-subtitle {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.16em;
|
||
color: #4cd7f6;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.canvas-wrap {
|
||
flex: 1;
|
||
min-height: 0;
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 8px 10px 4px;
|
||
z-index: 10;
|
||
}
|
||
|
||
.preview-stack {
|
||
position: relative;
|
||
z-index: 1;
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
contain: layout paint;
|
||
transform: translateZ(0);
|
||
}
|
||
|
||
.canvas-bg {
|
||
position: absolute;
|
||
inset: 0;
|
||
background:
|
||
radial-gradient(ellipse at 50% 35%, rgba(132, 43, 210, 0.18), transparent 55%),
|
||
radial-gradient(ellipse at 50% 85%, rgba(76, 215, 246, 0.1), transparent 60%);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.tool-dock {
|
||
position: relative;
|
||
z-index: 20;
|
||
margin: 0 10px calc(10px + env(safe-area-inset-bottom));
|
||
max-width: 460px;
|
||
align-self: center;
|
||
width: calc(100% - 20px);
|
||
background: rgba(34, 42, 61, 0.88);
|
||
border: 1px solid rgba(77, 67, 84, 0.45);
|
||
border-radius: 20px;
|
||
padding: 12px 14px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.4);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.dock-rim {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 1px;
|
||
background: linear-gradient(to right, transparent, rgba(221, 183, 255, 0.45), transparent);
|
||
}
|
||
|
||
.layer-strip {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.layer-strip-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 0 2px;
|
||
}
|
||
|
||
.layer-strip-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #dae2fd;
|
||
}
|
||
|
||
.layer-strip-hint {
|
||
font-size: 11px;
|
||
color: rgba(207, 194, 214, 0.6);
|
||
}
|
||
|
||
.layer-slots {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.layer-slot {
|
||
flex: 1;
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
background: rgba(6, 14, 32, 0.55);
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
.layer-slot--filled {
|
||
border-color: rgba(76, 215, 246, 0.5);
|
||
box-shadow: 0 0 14px rgba(76, 215, 246, 0.15);
|
||
}
|
||
|
||
.layer-slot-img {
|
||
width: 100%;
|
||
height: 56px;
|
||
display: block;
|
||
}
|
||
|
||
.layer-slot-placeholder {
|
||
width: 100%;
|
||
height: 56px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background-size: cover;
|
||
background-position: center;
|
||
}
|
||
|
||
.layer-slot-icon {
|
||
font-size: 22px;
|
||
color: rgba(221, 183, 255, 0.85);
|
||
}
|
||
|
||
.layer-slot-meta {
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 6px 8px 8px;
|
||
gap: 2px;
|
||
background: rgba(6, 14, 32, 0.55);
|
||
}
|
||
|
||
.layer-slot-name {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #dae2fd;
|
||
}
|
||
|
||
.layer-slot-tag {
|
||
font-size: 10px;
|
||
color: rgba(207, 194, 214, 0.7);
|
||
}
|
||
|
||
.layer-slot--filled .layer-slot-tag {
|
||
color: #4cd7f6;
|
||
}
|
||
|
||
.layer-slot-badge {
|
||
position: absolute;
|
||
top: 4px;
|
||
right: 4px;
|
||
width: 22px;
|
||
height: 22px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 50%;
|
||
background: rgba(0, 0, 0, 0.55);
|
||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||
}
|
||
|
||
.layer-slot-badge-icon {
|
||
font-size: 14px;
|
||
color: #dae2fd;
|
||
}
|
||
</style>
|