topfans/frontend/pages/castlove/lenticular-studio.vue

658 lines
15 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-btn" @tap="onMore">
<text class="nav-glyph"></text>
</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="tiltHintText"
@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">
<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, computed, onMounted, onUnmounted, nextTick } from 'vue'
import LenticularCard from '@/components/lenticular/LenticularCard.vue'
import { useLenticularPreview } from '@/composables/useLenticularPreview.js'
import {
buildLenticularLayersTwo,
LENTICULAR_STUDIO_STORAGE_KEY,
} from '@/utils/castloveMintForm.js'
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(() => '缓慢扭动手机 · 「校零」回中')
const gyroHintLine = computed(() => {
if (gyroSourceLabel.value === 'accelerometer') {
return '重力计驱动 · 持续同向倾斜会反复叠化;可点「校零」'
}
return '倾斜驱动预览中;若无效请检查权限或换真机'
})
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 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)
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 = 16
physics.transitionSmoothness = 62
physics.tiltSensitivity = 96
physics.sensorDeadzoneStrength = 0
startAccel()
})
})
onUnmounted(() => {
stopAccel()
})
</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-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;
}
.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;
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>