topfans/frontend/pages/castlove/lenticular-studio.vue
2026-05-15 23:06:51 +08:00

483 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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="''"
@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 } = useLenticularPreview(layers)
/** 相对进入时基准的倾角标量(度),每满 15° 一档;左右倾斜等价 */
const STUDIO_DISCRETE_STEP_DEG = 15
/** 档位施密特迟滞(度),减轻在档位边界来回跳 */
const DISCRETE_STEP_HYST_DEG = 4
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() {
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 || '')
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
},
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
}
function onRecalibrate() {
discreteStableStep.value = 0
recalibrateTilt()
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 || '')
} catch (e) {
console.error('[lenticular-studio] load payload', e)
uni.showToast({ title: '数据无效', icon: 'none' })
setTimeout(() => goBack(), 1600)
return
}
nextTick(() => {
/* 离散换图:略降过渡混合,档位切换更利落 */
physics.angleStability = 6
physics.transitionSmoothness = 12
physics.tiltSensitivity = 96
physics.sensorDeadzoneStrength = 0
startTilt()
})
})
onUnmounted(() => {
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;
}
.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>