658 lines
15 KiB
Vue
658 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-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>
|