506 lines
14 KiB
Vue
506 lines
14 KiB
Vue
<template>
|
||
<view class="card-container">
|
||
<view
|
||
:id="cardId"
|
||
class="card-frame"
|
||
@touchstart.stop="onFrameTouchStart"
|
||
@touchmove.stop.prevent="onFrameTouchMove"
|
||
@touchend.stop="onFrameTouchEnd"
|
||
@touchcancel.stop="onFrameTouchEnd"
|
||
>
|
||
<view class="card-body" :style="cardRotateStyle">
|
||
<view
|
||
v-for="layer in layers"
|
||
:key="layer.id"
|
||
class="card-layer"
|
||
:style="getLayerStyle(layer)"
|
||
>
|
||
<image
|
||
v-if="layer.src"
|
||
class="card-layer-img"
|
||
:src="layer.src"
|
||
mode="aspectFill"
|
||
@error="onImageError"
|
||
@load="onImageLoad"
|
||
/>
|
||
<template v-else>
|
||
<view
|
||
v-for="(dot, i) in layer.dots || []"
|
||
:key="i"
|
||
class="card-layer-dot"
|
||
:style="getDotStyle(dot)"
|
||
/>
|
||
</template>
|
||
</view>
|
||
|
||
<view class="lenticular-shimmer" :style="shimmerStyle" />
|
||
<!-- <view class="lenticular-tint" />
|
||
<view class="glass-rim" />
|
||
<view class="vignette" /> -->
|
||
</view>
|
||
|
||
<!-- <view v-if="showHint && tiltHintText" class="tilt-hint">
|
||
<text class="tilt-hint-icon">↻</text>
|
||
<text class="tilt-hint-text">{{ tiltHintText }}</text>
|
||
<text v-if="approximatePreview" class="tilt-hint-sub">叠化近似预览(倾斜或拖动体验光栅效果)</text>
|
||
</view> -->
|
||
</view>
|
||
|
||
<ManualTiltSlider
|
||
v-if="showSlider"
|
||
:value="sliderValue"
|
||
@change="onSliderChange"
|
||
@reset="onSliderReset"
|
||
/>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, getCurrentInstance, onMounted, onUnmounted, ref, watch } from 'vue'
|
||
import ManualTiltSlider from './ManualTiltSlider.vue'
|
||
|
||
const props = defineProps({
|
||
layers: { type: Array, required: true },
|
||
transforms: { type: Object, required: true },
|
||
gyroSource: { type: String, default: 'simulation' },
|
||
approximatePreview: { type: Boolean, default: true },
|
||
skipBuiltInTouch: { type: Boolean, default: false },
|
||
tiltHintText: { type: String, default: '倾斜手机预览' },
|
||
/** 条纹高光中线不透明度 0~1,略提可增强光栅「栅感」 */
|
||
shimmerMidOpacity: { type: Number, default: 0.1 },
|
||
// 新增:手动滑块接入(spec §3.3)
|
||
showSlider: { type: Boolean, default: false },
|
||
// 新增:引擎接口(由父组件从 useLenticularCraftTiltPreview / useLenticularPreview 等注入)
|
||
// Function 类型 prop 必须用 factory 返回默认函数,避免所有实例共享同一引用
|
||
simulateTilt: { type: Function, default: () => () => {} },
|
||
simulateTiltFromNormalized:{ type: Function, default: () => () => {} },
|
||
simulateTiltReset: { type: Function, default: () => () => {} },
|
||
// 新增:自动控制
|
||
autoEnabled: { type: Boolean, default: true },
|
||
// auto 周期:sin 波一个完整循环的毫秒数。10000 = 10s 一周,再慢会感觉拖沓/卡帧
|
||
autoPeriodMs: { type: Number, default: 15000 },
|
||
idleResumeMs: { type: Number, default: 1500 },
|
||
})
|
||
|
||
// 保留 @simulate 事件以兼容其他不传引擎 props 的消费者
|
||
// (他们 listen @simulate 自己驱动引擎;本组件内已用 props.simulateTilt* 直接驱动)
|
||
// 新增 @mode-change:控制模式切换时通知父组件(auto ↔ manual)
|
||
const emit = defineEmits(['simulate', 'modeChange'])
|
||
|
||
/** Vue3 + uni:createSelectorQuery().in() 必须传组件 public 实例(proxy),传 internal instance 会运行时报错导致整页白屏 */
|
||
const pageProxy = getCurrentInstance()?.proxy
|
||
|
||
const showHint = ref(true)
|
||
const cardId = `lcard-${Math.random().toString(36).slice(2, 9)}`
|
||
const cardRect = ref(null)
|
||
|
||
// 内部滑块值(auto / 触摸 / 滑块拖动 / 复位 都在这里写)
|
||
const sliderValue = ref(0.5)
|
||
|
||
function onImageError(e) {
|
||
// console.log('[LenticularCard] image error:', e)
|
||
}
|
||
|
||
function onImageLoad(e) {
|
||
// console.log('[LenticularCard] image loaded')
|
||
}
|
||
|
||
const MOTION_HIDE_PX = 0.45
|
||
|
||
/** 仅在画面因倾斜产生可见位移后隐藏提示(非陀螺仪连接即隐藏) */
|
||
function shouldHideTiltHint() {
|
||
return Object.values(props.transforms || {}).some((t) => Math.abs(t.x) > MOTION_HIDE_PX)
|
||
}
|
||
|
||
watch(
|
||
() => props.transforms,
|
||
() => {
|
||
if (shouldHideTiltHint()) showHint.value = false
|
||
},
|
||
{ deep: true }
|
||
)
|
||
|
||
const tiltVisualX = computed(() => {
|
||
let num = 0
|
||
let den = 0
|
||
for (const layer of props.layers) {
|
||
const t = props.transforms[layer.id]
|
||
if (!t) continue
|
||
const w = Math.max(0.06, t.opacity)
|
||
num += t.x * w
|
||
den += w
|
||
}
|
||
return den > 1e-6 ? num / den : 0
|
||
})
|
||
|
||
const cardRotateStyle = computed(() => {
|
||
const x = tiltVisualX.value
|
||
const rotateY = (x / 120) * 12
|
||
return {
|
||
transform: `perspective(1000px) rotateY(${rotateY}deg) rotateX(0deg)`,
|
||
}
|
||
})
|
||
|
||
const shimmerStyle = computed(() => {
|
||
const x = tiltVisualX.value
|
||
const angle = 135 + (x / 120) * 60
|
||
const a = Math.max(0, Math.min(0.35, Number(props.shimmerMidOpacity) || 0.1))
|
||
return {
|
||
background: `linear-gradient(${angle}deg, transparent 30%, rgba(221,183,255,${a}) 50%, transparent 70%)`,
|
||
}
|
||
})
|
||
|
||
function getLayerStyle(layer) {
|
||
const t = props.transforms[layer.id]
|
||
const baseOpacity = t ? t.opacity : layer.opacity
|
||
const x = t ? t.x : 0
|
||
// 不在行内样式写 mix-blend-mode:部分小程序 / WebView 对非 normal 支持差,可能引发渲染异常
|
||
return {
|
||
opacity: baseOpacity,
|
||
transform: `translate3d(${x}px, 0, 0)`,
|
||
background: layer.background || 'transparent',
|
||
}
|
||
}
|
||
|
||
function getDotStyle(dot) {
|
||
const color = dot.color || '#dae2fd'
|
||
return {
|
||
left: `${dot.x}%`,
|
||
top: `${dot.y}%`,
|
||
width: `${dot.r * 2}px`,
|
||
height: `${dot.r * 2}px`,
|
||
background: color,
|
||
opacity: dot.opacity != null ? dot.opacity : 1,
|
||
boxShadow: `0 0 ${dot.r * 3}px ${color}`,
|
||
}
|
||
}
|
||
|
||
function refreshRect() {
|
||
return new Promise((resolve) => {
|
||
try {
|
||
const query = pageProxy ? uni.createSelectorQuery().in(pageProxy) : uni.createSelectorQuery()
|
||
query
|
||
.select(`#${cardId}`)
|
||
.boundingClientRect((rect) => {
|
||
if (rect && rect.width) {
|
||
cardRect.value = {
|
||
left: rect.left,
|
||
top: rect.top,
|
||
width: rect.width,
|
||
height: rect.height,
|
||
}
|
||
}
|
||
resolve()
|
||
})
|
||
.exec()
|
||
} catch (e) {
|
||
console.warn('[LenticularCard] refreshRect failed', e)
|
||
resolve()
|
||
}
|
||
})
|
||
}
|
||
|
||
// ====================================================================
|
||
// 自动控制状态机(auto ↔ manual + idle 恢复)
|
||
// ====================================================================
|
||
|
||
const controlMode = ref('auto') // 'auto' | 'manual'
|
||
const lastAutoWriteTime = ref(0) // auto 最近一次写滑块值的时间戳(v-model 回声时间窗检测用)
|
||
let idleAccumMs = 0 // manual 模式下空闲累计时长
|
||
let autoPhase = 0 // sin 相位
|
||
let lastTickTime = null // 上一次 tick 的时间戳
|
||
let rafId = null // rAF 句柄
|
||
|
||
// uniapp 小程序运行时(app-service.js)下,performance 和 requestAnimationFrame 都不存在
|
||
// 用 setTimeout 递归调度(60fps ≈ 16ms/帧),跨平台稳
|
||
// 时间戳统一用 Date.now()(不依赖 performance)
|
||
const nextFrame = (cb) => setTimeout(cb, 16)
|
||
const cancelFrame = (id) => clearTimeout(id)
|
||
|
||
/**
|
||
* auto tick:rAF 循环的唯一调度点
|
||
* - auto 模式:按 dt 推进 sin 相位,写滑块 + 引擎
|
||
* - manual 模式:累计空闲时长,达到 idleResumeMs 时 snap 相位到当前滑块并切回 auto
|
||
*/
|
||
function autoTick(t) {
|
||
if (!props.autoEnabled) return
|
||
if (lastTickTime == null) lastTickTime = t
|
||
const dt = t - lastTickTime
|
||
lastTickTime = t
|
||
|
||
if (controlMode.value !== 'auto') {
|
||
idleAccumMs += dt
|
||
if (idleAccumMs >= props.idleResumeMs) {
|
||
const targetSin = Math.max(-1, Math.min(1, 2 * sliderValue.value - 1))
|
||
autoPhase = Math.asin(targetSin)
|
||
lastTickTime = null
|
||
idleAccumMs = 0
|
||
controlMode.value = 'auto'
|
||
emit('modeChange', 'auto')
|
||
}
|
||
return
|
||
}
|
||
|
||
idleAccumMs = 0
|
||
autoPhase += (dt / props.autoPeriodMs) * 2 * Math.PI
|
||
if (Math.abs(autoPhase) > 100 * 2 * Math.PI) {
|
||
autoPhase = autoPhase % (2 * Math.PI)
|
||
}
|
||
// 用 sin 波 + 宽摆幅(AMP=0.5)让 auto 真正触到 A=100% / B=100% 状态
|
||
// sin 在极值点速度=0,会"停留"在端点,让用户看清 A=100% / B=100%
|
||
// 配合 blendBaseScale=5 + 线性权重,端点附近叠化也很明显,不是硬切
|
||
const AUTO_SWING_AMP = 0.3 // 满摆幅,v ∈ [0, 1],覆盖完整 0/100% 状态
|
||
const sinVal = Math.sin(autoPhase)
|
||
const v = 0.5 + sinVal * AUTO_SWING_AMP
|
||
sliderValue.value = v
|
||
lastAutoWriteTime.value = Date.now()
|
||
// 走归一化版本(兼容两种消费者:
|
||
// - 传 simulateTiltFromNormalized(离散 snap):直接 snap
|
||
// - 只传 simulate(连续):simulateTiltFromNormalized 默认是 no-op,
|
||
// 这种场景下消费者需要额外传 simulate,但我们让 auto 优先尝试归一化接口)
|
||
const x = v * 2 - 1
|
||
props.simulateTiltFromNormalized?.(x, 0)
|
||
}
|
||
|
||
/** 启动 rAF 循环(幂等)。
|
||
* 时间戳统一用 Date.now(),避免 uniapp 真机 performance 缺失导致的 ReferenceError。
|
||
*/
|
||
function startAutoLoop() {
|
||
if (rafId != null) return
|
||
const tickFn = () => {
|
||
autoTick(Date.now())
|
||
rafId = nextFrame(tickFn)
|
||
}
|
||
rafId = nextFrame(tickFn)
|
||
}
|
||
|
||
/** 停止 rAF 循环 */
|
||
function stopAutoLoop() {
|
||
if (rafId != null) {
|
||
cancelFrame(rafId)
|
||
rafId = null
|
||
}
|
||
}
|
||
|
||
/** 用户接管 → 暂停 auto + 重置空闲计时器 */
|
||
function pauseAutoAndScheduleResume() {
|
||
if (controlMode.value !== 'manual') {
|
||
controlMode.value = 'manual'
|
||
emit('modeChange', 'manual')
|
||
}
|
||
idleAccumMs = 0
|
||
}
|
||
|
||
// ====================================================================
|
||
// 触摸 / 滑块 / 复位:组件内部直接调引擎函数(通过 props 注入)
|
||
// ====================================================================
|
||
|
||
function dispatch(clientX, clientY) {
|
||
const rect = cardRect.value
|
||
if (!rect || !rect.width || !rect.height) {
|
||
void refreshRect()
|
||
return
|
||
}
|
||
const cx = rect.left + rect.width / 2
|
||
const cy = rect.top + rect.height / 2
|
||
const nx = ((clientX - cx) / (rect.width / 2)) * 1.1
|
||
const ny = ((clientY - cy) / (rect.height / 2)) * 1.1
|
||
handleTouch(Math.max(-1, Math.min(1, nx)), Math.max(-1, Math.min(1, ny)))
|
||
}
|
||
|
||
function handleTouch(x, y) {
|
||
pauseAutoAndScheduleResume()
|
||
sliderValue.value = Math.max(0, Math.min(1, (x + 1) / 2))
|
||
props.simulateTiltFromNormalized?.(x, y)
|
||
// 向后兼容:emit 给其他不传引擎 props 的消费者
|
||
emit('simulate', x, y)
|
||
}
|
||
|
||
function onTouchMove(e) {
|
||
if (props.skipBuiltInTouch) return
|
||
const touch = e.touches && e.touches[0] ? e.touches[0] : e.changedTouches && e.changedTouches[0] ? e.changedTouches[0] : null
|
||
if (!touch) return
|
||
dispatch(touch.clientX, touch.clientY)
|
||
}
|
||
|
||
function onFrameTouchStart(e) {
|
||
if (props.skipBuiltInTouch) return
|
||
onTouchMove(e)
|
||
}
|
||
|
||
function onFrameTouchMove(e) {
|
||
if (props.skipBuiltInTouch) return
|
||
onTouchMove(e)
|
||
}
|
||
|
||
function onFrameTouchEnd() {
|
||
if (props.skipBuiltInTouch) return
|
||
// 不发 (0,0),由 auto / 引擎自身状态决定
|
||
}
|
||
|
||
/**
|
||
* 滑块变化:
|
||
* - auto 自己写滑块触发的 v-model/原生 change 回声(时间窗 100ms 内)→ 不暂停 auto
|
||
* - 用户拖动 → 暂停 auto + 驱动引擎
|
||
*/
|
||
function onSliderChange(v) {
|
||
sliderValue.value = v
|
||
const isAutoEcho = Date.now() - lastAutoWriteTime.value < 100
|
||
if (!isAutoEcho) {
|
||
pauseAutoAndScheduleResume()
|
||
const dx = (v - 0.5) * 90
|
||
props.simulateTilt?.(dx, 0)
|
||
}
|
||
}
|
||
|
||
/** 复位按钮:暂停 auto + 滑块回 0.5 + 引擎归中 */
|
||
function onSliderReset() {
|
||
pauseAutoAndScheduleResume()
|
||
sliderValue.value = 0.5
|
||
props.simulateTiltReset?.()
|
||
}
|
||
|
||
onMounted(() => {
|
||
setTimeout(() => {
|
||
void refreshRect()
|
||
}, 0)
|
||
if (props.autoEnabled) {
|
||
startAutoLoop()
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
stopAutoLoop()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.card-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
perspective: 1000px;
|
||
position: relative;
|
||
}
|
||
|
||
.card-frame {
|
||
position: relative;
|
||
height: 100%;
|
||
width: 100%;
|
||
overflow: visible;
|
||
}
|
||
|
||
.card-body {
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: 24px;
|
||
overflow: hidden;
|
||
transform-style: preserve-3d;
|
||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.55);
|
||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||
background-color: #060e20;
|
||
will-change: transform;
|
||
}
|
||
|
||
.card-layer {
|
||
position: absolute;
|
||
/* top: -6%;
|
||
left: -6%; */
|
||
width: 100%;
|
||
height: 100%;
|
||
will-change: transform, opacity;
|
||
background-size: cover;
|
||
background-position: center;
|
||
}
|
||
|
||
.card-layer-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.card-layer-dot {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
transform: translate(-50%, -50%);
|
||
}
|
||
|
||
.lenticular-shimmer {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.lenticular-tint {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
background: linear-gradient(
|
||
135deg,
|
||
rgba(221, 183, 255, 0.1) 0%,
|
||
transparent 35%,
|
||
transparent 65%,
|
||
rgba(76, 215, 246, 0.1) 100%
|
||
);
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.glass-rim {
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: 24px;
|
||
border-top: 1px solid rgba(221, 183, 255, 0.42);
|
||
box-shadow: inset 0 0 40px rgba(255, 255, 255, 0.05);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.vignette {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: linear-gradient(to top, rgba(6, 14, 32, 0.82), transparent 50%);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.tilt-hint {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 8px;
|
||
background: rgba(45, 52, 73, 0.6);
|
||
backdrop-filter: blur(12px);
|
||
padding: 14px 22px;
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.tilt-hint-icon {
|
||
font-size: 28px;
|
||
line-height: 1;
|
||
color: #ddb7ff;
|
||
}
|
||
|
||
.tilt-hint-text {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.08em;
|
||
color: #dae2fd;
|
||
}
|
||
|
||
.tilt-hint-sub {
|
||
font-size: 10px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.04em;
|
||
color: rgba(207, 194, 214, 0.75);
|
||
text-align: center;
|
||
max-width: 200px;
|
||
line-height: 1.35;
|
||
}
|
||
</style>
|