用前置摄像头 + VisionKit/ROI 管线驱动开合计数,复用跟练舞台与连击粒子,并加固结束收尾避免末段卡死无响应。 Co-authored-by: Cursor <cursoragent@cursor.com>
1723 lines
46 KiB
Vue
1723 lines
46 KiB
Vue
<template>
|
||
<view class="page">
|
||
<!-- 自定义导航:对齐微信小程序胶囊 -->
|
||
<view class="nav" :style="navStyle">
|
||
<view class="nav-row" :style="navRowStyle">
|
||
<view class="nav-back" @click="onBack">
|
||
<text class="nav-back-icon">‹</text>
|
||
</view>
|
||
<view class="nav-center">
|
||
<text class="nav-title">握力环训练 2.0</text>
|
||
<view class="nav-status">
|
||
<view class="nav-dot" :class="trackingQuality" />
|
||
<text class="nav-status-text">{{ trackingLabel }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="nav-capsule-spacer" :style="{ width: capsuleSpacerPx + 'px' }" />
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 实时指标 -->
|
||
<view class="metrics-wrap">
|
||
<view class="metrics">
|
||
<view class="metric">
|
||
<text class="metric-value">{{ validReps }}</text>
|
||
<text class="metric-label">有效握紧</text>
|
||
</view>
|
||
<view class="metric">
|
||
<text class="metric-value">{{ accuracyText }}</text>
|
||
<text class="metric-label">准确率</text>
|
||
</view>
|
||
<view class="metric metric--combo">
|
||
<text class="metric-value metric-value--combo">连击 x{{ combo }}</text>
|
||
<text class="metric-label">当前连击</text>
|
||
</view>
|
||
<view class="metric">
|
||
<text class="metric-value">{{ elapsedText }}</text>
|
||
<text class="metric-label">训练时间</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 中间画布:相机/动画 + 反馈层 -->
|
||
<view class="stage" :class="'cap-' + captureState">
|
||
<!-- #ifdef MP-WEIXIN -->
|
||
<camera
|
||
v-if="cameraReady"
|
||
id="gripCamera"
|
||
class="stage-camera"
|
||
device-position="front"
|
||
flash="off"
|
||
frame-size="small"
|
||
@initdone="onCameraInitDone"
|
||
@error="onCameraError"
|
||
/>
|
||
<!-- #endif -->
|
||
|
||
<!-- 动画模式:示范图跟练 + 粒子特效(识别仍走相机帧) -->
|
||
<view v-if="cameraReady && stageMode === 'anim'" class="stage-anim">
|
||
<view class="ambient-glow" />
|
||
<CrushCanvas v-if="crushEnabled" :crush-signal="crushSignal" />
|
||
<view class="ring-container" :class="['sq-' + energyLevel, { idle: demoIdle }]">
|
||
<view class="beat-halo" />
|
||
<view class="video-ring">
|
||
<image
|
||
class="demo-video"
|
||
:src="TUTORIAL_POSTER_URL"
|
||
mode="aspectFill"
|
||
/>
|
||
<view class="video-ring-border" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="!cameraReady || cameraError" class="stage-fallback">
|
||
<text class="stage-fallback-text">{{ sessionFinished ? '本轮训练已完成' : cameraHint }}</text>
|
||
<view
|
||
v-if="sessionFinished"
|
||
class="stage-fallback-btn"
|
||
@click="onRestart"
|
||
>
|
||
<text class="stage-fallback-btn-text">再练一次</text>
|
||
</view>
|
||
<view v-else-if="!cameraReady && !sessionPlaying" class="stage-fallback-btn" @click="startCameraSession">
|
||
<text class="stage-fallback-btn-text">开启摄像头训练</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="cameraReady && stageMode === 'camera'" class="stage-wash" />
|
||
|
||
<!-- 画布四角捕捉状态框 -->
|
||
<view v-if="cameraReady && !sessionFinished" class="capture-frame">
|
||
<view class="corner corner--tl" />
|
||
<view class="corner corner--tr" />
|
||
<view class="corner corner--bl" />
|
||
<view class="corner corner--br" />
|
||
</view>
|
||
|
||
<!-- 捕捉状态标签 -->
|
||
<view v-if="cameraReady && !sessionFinished" class="capture-pill">
|
||
<view class="capture-dot" />
|
||
<text class="capture-pill-text">{{ captureLabel }}</text>
|
||
</view>
|
||
|
||
<!-- 动画/实景切换 -->
|
||
<view v-if="cameraReady && !sessionFinished" class="mode-toggle" @click="toggleStageMode">
|
||
<text class="mode-toggle-text">{{ stageMode === 'anim' ? '看实景' : '看动画' }}</text>
|
||
</view>
|
||
|
||
<view v-if="!sessionFinished" class="stage-hud">
|
||
<text class="hit-title" :class="{ active: showHitFeedback }">{{ headlineText }}</text>
|
||
|
||
<view v-if="combo >= 2" :key="comboKey" class="combo-float">
|
||
<view class="combo-pill">
|
||
<text class="combo-pill-text">连击 x{{ combo }}</text>
|
||
<view class="combo-drain">
|
||
<view class="combo-drain-fill" />
|
||
</view>
|
||
</view>
|
||
<view class="speed-line speed-line--1" />
|
||
<view class="speed-line speed-line--2" />
|
||
<view class="speed-line speed-line--3" />
|
||
</view>
|
||
|
||
<view v-if="combo >= 3" class="combo-ribbon">
|
||
<text class="combo-ribbon-line">连击 x{{ combo }}!</text>
|
||
<text class="combo-ribbon-sub">太棒了!</text>
|
||
</view>
|
||
|
||
<view v-if="showComboBreak" class="combo-break">
|
||
<text class="combo-break-text">连击中断</text>
|
||
</view>
|
||
|
||
<view v-if="showReward" class="reward-wrap">
|
||
<view class="reward-glow" />
|
||
<text class="reward-burst">+{{ lastReward }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 庆祝层:横幅 + 撒花 -->
|
||
<view v-if="celebrateVisible" :key="'c' + celebrateKey" class="celebrate" :class="celebrateKind">
|
||
<view class="celebrate-banner">
|
||
<text class="celebrate-banner-text">{{ celebrateText }}</text>
|
||
</view>
|
||
<view v-for="n in 6" :key="n" class="confetti" :class="'confetti-' + n" />
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 底部目标与操作:始终可点,结束用强制收尾 -->
|
||
<view class="footer">
|
||
<view class="goal-row">
|
||
<text class="goal-text">任务进度 {{ validReps }}/{{ targetReps }}</text>
|
||
<text class="remain-text">{{ sessionFinished ? '已完成' : `剩余 ${remainText}` }}</text>
|
||
</view>
|
||
<view class="goal-track">
|
||
<view class="goal-fill" :style="{ width: goalProgress * 100 + '%' }" />
|
||
</view>
|
||
<view class="actions">
|
||
<view
|
||
v-if="!sessionFinished"
|
||
class="action action--pause"
|
||
@click.stop="onTogglePause"
|
||
>
|
||
<text class="action-icon">{{ sessionPlaying && !sessionPaused ? '❚❚' : '▶' }}</text>
|
||
<text class="action-text">{{ !sessionPlaying ? '开始' : sessionPaused ? '继续' : '暂停' }}</text>
|
||
</view>
|
||
<view
|
||
v-if="!sessionFinished"
|
||
class="action action--end"
|
||
@click.stop="onEndTraining"
|
||
>
|
||
<text class="action-icon">■</text>
|
||
<text class="action-text">结束训练</text>
|
||
</view>
|
||
<view
|
||
v-if="sessionFinished"
|
||
class="action action--pause"
|
||
@click.stop="onRestart"
|
||
>
|
||
<text class="action-text">再练一次</text>
|
||
</view>
|
||
<view
|
||
v-if="sessionFinished"
|
||
class="action action--end"
|
||
@click.stop="onBack"
|
||
>
|
||
<text class="action-text">返回</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
|
||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||
import CrushCanvas from './components/crush-canvas.vue'
|
||
import { createGesturePipeline } from '../motion/gesture-pipeline'
|
||
import type { GesturePhase, GestureSource, TrackingQuality } from '../motion/types'
|
||
|
||
// 与旧握力环页共用封面(跟练动画主体;训练中不叠播网络视频,避免与 camera 抢原生线程)
|
||
const TUTORIAL_POSTER_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260529/202605291030231aed12802.jpg'
|
||
|
||
const TARGET_REPS = 30
|
||
const TARGET_SECONDS = 300
|
||
|
||
const statusBarPx = ref(20)
|
||
const navRowPx = ref(44)
|
||
const capsuleSpacerPx = ref(96)
|
||
|
||
const cameraReady = ref(false)
|
||
const cameraInited = ref(false)
|
||
const cameraError = ref('')
|
||
const sessionPlaying = ref(false)
|
||
const sessionPaused = ref(false)
|
||
const sessionFinished = ref(false)
|
||
const sessionEnding = ref(false)
|
||
|
||
const trackingQuality = ref<TrackingQuality>('lost')
|
||
const gesturePhase = ref<GesturePhase>('UNTRACKED')
|
||
const energyLevel = ref(0)
|
||
const pipelineSource = ref<GestureSource>('none')
|
||
const frameFps = ref(0)
|
||
|
||
const validReps = ref(0)
|
||
const attemptReps = ref(0)
|
||
const combo = ref(0)
|
||
const bestCombo = ref(0)
|
||
const elapsedSeconds = ref(0)
|
||
const lastReward = ref(1)
|
||
const showReward = ref(false)
|
||
const showHitFeedback = ref(false)
|
||
|
||
/** 画布模式:默认动画演示,可切实景 */
|
||
const stageMode = ref<'anim' | 'camera'>('anim')
|
||
|
||
/** 连击:时限内完成下一次计连击,超时或脱离画面则中断 */
|
||
const COMBO_WINDOW_MS = 6000
|
||
const comboKey = ref(0)
|
||
const showComboBreak = ref(false)
|
||
|
||
/** 庆祝层(横幅 + 撒花) */
|
||
const celebrateVisible = ref(false)
|
||
const celebrateKind = ref<'combo' | 'progress' | 'finish'>('combo')
|
||
const celebrateText = ref('')
|
||
const celebrateKey = ref(0)
|
||
|
||
/** 捏碎粒子:连击达标才触发,随进度换款,末段减负 */
|
||
const crushEnabled = ref(false)
|
||
const crushSignal = ref<{ type: 'egg' | 'walnut' | 'can' | 'balloon'; nonce: number; intensity?: number } | null>(null)
|
||
type CrushType = 'egg' | 'walnut' | 'can' | 'balloon'
|
||
let lastCrushTs = 0
|
||
|
||
const targetReps = TARGET_REPS
|
||
|
||
let timerId: ReturnType<typeof setInterval> | null = null
|
||
let rewardTimer: ReturnType<typeof setTimeout> | null = null
|
||
let comboBreakTimer: ReturnType<typeof setTimeout> | null = null
|
||
let comboBreakHideTimer: ReturnType<typeof setTimeout> | null = null
|
||
let celebrateTimer: ReturnType<typeof setTimeout> | null = null
|
||
let teardownTimer: ReturnType<typeof setTimeout> | null = null
|
||
let lastRepTs = 0
|
||
let startingPipeline = false
|
||
let endModalShown = false
|
||
let isLeaving = false
|
||
let cleanedUp = false
|
||
|
||
const pipeline = createGesturePipeline({
|
||
onObservation(obs) {
|
||
if (sessionEnding.value || sessionFinished.value || !sessionPlaying.value) return
|
||
if (gesturePhase.value !== obs.phase) {
|
||
gesturePhase.value = obs.phase
|
||
if (obs.phase === 'UNTRACKED' && combo.value > 0) breakCombo()
|
||
}
|
||
// 能量环降频:只在档位变化时更新
|
||
const nextLevel = Math.min(4, Math.round(obs.compression * 4))
|
||
if (energyLevel.value !== nextLevel) energyLevel.value = nextLevel
|
||
if (trackingQuality.value !== obs.trackingQuality) {
|
||
trackingQuality.value = obs.trackingQuality
|
||
}
|
||
// 源/fps 很少变,降低写入
|
||
if (pipelineSource.value !== obs.source) {
|
||
pipelineSource.value = obs.source
|
||
}
|
||
const hit = obs.phase === 'CLOSED_CONFIRMED' || obs.phase === 'CLOSING'
|
||
if (showHitFeedback.value !== hit) showHitFeedback.value = hit
|
||
},
|
||
onRepCompleted() {
|
||
onRepCompleted()
|
||
},
|
||
onWarning(message) {
|
||
uni.showToast({ title: message.slice(0, 40), icon: 'none', duration: 2200 })
|
||
},
|
||
onError(message) {
|
||
cameraError.value = message
|
||
trackingQuality.value = 'lost'
|
||
uni.showToast({ title: message, icon: 'none', duration: 2500 })
|
||
},
|
||
})
|
||
|
||
const navStyle = computed(() => ({
|
||
paddingTop: `${statusBarPx.value}px`,
|
||
}))
|
||
|
||
const navRowStyle = computed(() => ({
|
||
height: `${navRowPx.value}px`,
|
||
}))
|
||
|
||
const trackingLabel = computed(() => {
|
||
if (!cameraReady.value) return '待开启'
|
||
if (cameraError.value) return '识别不可用'
|
||
if (sessionPaused.value) return '已暂停'
|
||
if (trackingQuality.value === 'lost') return '追踪丢失'
|
||
if (trackingQuality.value === 'weak') return '识别较弱'
|
||
const src = pipelineSource.value === 'roi' ? 'ROI' : ''
|
||
return src ? `识别中 · ${src}` : '识别中'
|
||
})
|
||
|
||
const accuracyText = computed(() => {
|
||
if (attemptReps.value <= 0) return '—'
|
||
return `${Math.round((validReps.value / attemptReps.value) * 100)}%`
|
||
})
|
||
|
||
const elapsedText = computed(() => formatClock(elapsedSeconds.value))
|
||
const remainText = computed(() => formatClock(Math.max(0, TARGET_SECONDS - elapsedSeconds.value)))
|
||
const goalProgress = computed(() => Math.min(1, validReps.value / targetReps))
|
||
|
||
const headlineText = computed(() => {
|
||
switch (gesturePhase.value) {
|
||
case 'OPEN_READY':
|
||
return '准备握紧'
|
||
case 'CLOSING':
|
||
return '用力握紧'
|
||
case 'CLOSED_CONFIRMED':
|
||
return '保持住'
|
||
case 'REOPENING':
|
||
return '慢慢松开'
|
||
default:
|
||
return '把手放入画面'
|
||
}
|
||
})
|
||
|
||
/** 画布四周捕捉状态:lost / seek / open / grip / hold / release */
|
||
const captureState = computed(() => {
|
||
if (sessionPaused.value) return 'seek'
|
||
switch (gesturePhase.value) {
|
||
case 'OPEN_READY':
|
||
return 'open'
|
||
case 'CLOSING':
|
||
return 'grip'
|
||
case 'CLOSED_CONFIRMED':
|
||
return 'hold'
|
||
case 'REOPENING':
|
||
return 'release'
|
||
default:
|
||
return trackingQuality.value === 'lost' ? 'lost' : 'seek'
|
||
}
|
||
})
|
||
|
||
const captureLabel = computed(() => {
|
||
switch (captureState.value) {
|
||
case 'open':
|
||
return '已捕捉到手'
|
||
case 'grip':
|
||
return '握紧中'
|
||
case 'hold':
|
||
return '保持中'
|
||
case 'release':
|
||
return '松开中'
|
||
case 'seek':
|
||
return '正在捕捉…'
|
||
default:
|
||
return '脱离画面'
|
||
}
|
||
})
|
||
|
||
const cameraHint = computed(() => {
|
||
if (cameraError.value) return cameraError.value
|
||
if (!cameraReady.value) return '开启前置摄像头后开始训练(裸手开合试验)'
|
||
return '正在准备相机…'
|
||
})
|
||
|
||
function toggleStageMode() {
|
||
stageMode.value = stageMode.value === 'anim' ? 'camera' : 'anim'
|
||
}
|
||
|
||
/** 未捕捉到手时,演示动画自动循环示范动作 */
|
||
const demoIdle = computed(() => captureState.value === 'seek' || captureState.value === 'lost')
|
||
|
||
function formatClock(total: number) {
|
||
const m = Math.floor(total / 60)
|
||
const s = total % 60
|
||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||
}
|
||
|
||
function initNavMetrics() {
|
||
try {
|
||
const sys = uni.getSystemInfoSync()
|
||
statusBarPx.value = Number(sys.statusBarHeight) || 20
|
||
// #ifdef MP-WEIXIN
|
||
const menu = typeof uni.getMenuButtonBoundingClientRect === 'function'
|
||
? uni.getMenuButtonBoundingClientRect()
|
||
: null
|
||
if (menu && menu.height) {
|
||
navRowPx.value = menu.height + (menu.top - statusBarPx.value) * 2
|
||
capsuleSpacerPx.value = Math.max(88, sys.windowWidth - menu.left + 8)
|
||
}
|
||
// #endif
|
||
} catch (_) {}
|
||
}
|
||
|
||
function requestCameraPermission(): Promise<boolean> {
|
||
// #ifdef MP-WEIXIN
|
||
return new Promise((resolve) => {
|
||
// @ts-ignore
|
||
const wxApi = typeof wx !== 'undefined' ? wx : null
|
||
if (!wxApi?.authorize) {
|
||
resolve(true)
|
||
return
|
||
}
|
||
wxApi.authorize({
|
||
scope: 'scope.camera',
|
||
success: () => resolve(true),
|
||
fail: () => {
|
||
uni.showModal({
|
||
title: '需要摄像头权限',
|
||
content: '握力环 2.0 需要前置摄像头做视觉识别(试验),请在设置中开启。',
|
||
confirmText: '去设置',
|
||
success: (res) => {
|
||
if (res.confirm) uni.openSetting({})
|
||
resolve(false)
|
||
},
|
||
})
|
||
},
|
||
})
|
||
})
|
||
// #endif
|
||
// #ifndef MP-WEIXIN
|
||
return Promise.resolve(false)
|
||
// #endif
|
||
}
|
||
|
||
async function startCameraSession() {
|
||
const ok = await requestCameraPermission()
|
||
if (!ok) {
|
||
cameraError.value = '未获得摄像头权限'
|
||
return
|
||
}
|
||
cameraError.value = ''
|
||
cameraInited.value = false
|
||
cameraReady.value = true
|
||
trackingQuality.value = 'weak'
|
||
await nextTick()
|
||
// initdone 回调里再启动 pipeline;若迟迟未到则兜底
|
||
setTimeout(() => {
|
||
if (
|
||
cameraReady.value
|
||
&& !pipeline.isActive()
|
||
&& !startingPipeline
|
||
&& !sessionFinished.value
|
||
&& !sessionEnding.value
|
||
) {
|
||
beginSession()
|
||
}
|
||
}, 600)
|
||
}
|
||
|
||
function onCameraInitDone() {
|
||
cameraInited.value = true
|
||
if (sessionFinished.value || sessionEnding.value) return
|
||
if (!sessionPlaying.value || !pipeline.isActive()) {
|
||
beginSession()
|
||
}
|
||
}
|
||
|
||
async function beginSession() {
|
||
if (startingPipeline || sessionEnding.value || sessionFinished.value) return
|
||
sessionFinished.value = false
|
||
endModalShown = false
|
||
sessionPlaying.value = true
|
||
sessionPaused.value = false
|
||
startTimer()
|
||
startingPipeline = true
|
||
try {
|
||
const ok = await pipeline.start()
|
||
// 达目标/结束过程中若 start 仍在飞,立刻停掉,避免“结束后又活过来”
|
||
if (!ok || sessionEnding.value || sessionFinished.value) {
|
||
pipeline.stop()
|
||
sessionPlaying.value = false
|
||
stopTimer()
|
||
return
|
||
}
|
||
try {
|
||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||
} catch (_) {}
|
||
} finally {
|
||
startingPipeline = false
|
||
}
|
||
}
|
||
|
||
function startTimer() {
|
||
if (timerId) return
|
||
timerId = setInterval(() => {
|
||
if (sessionPaused.value || sessionEnding.value || sessionFinished.value) return
|
||
elapsedSeconds.value += 1
|
||
if (elapsedSeconds.value >= TARGET_SECONDS) {
|
||
setTimeout(() => finishSession('time'), 0)
|
||
}
|
||
}, 1000)
|
||
}
|
||
|
||
function stopTimer() {
|
||
if (timerId) {
|
||
clearInterval(timerId)
|
||
timerId = null
|
||
}
|
||
}
|
||
|
||
/** 连击中断:超时未完成下一次,或手脱离画面 */
|
||
function breakCombo() {
|
||
if (comboBreakTimer) {
|
||
clearTimeout(comboBreakTimer)
|
||
comboBreakTimer = null
|
||
}
|
||
if (combo.value >= 2) {
|
||
showComboBreak.value = true
|
||
if (comboBreakHideTimer) clearTimeout(comboBreakHideTimer)
|
||
comboBreakHideTimer = setTimeout(() => {
|
||
showComboBreak.value = false
|
||
}, 1200)
|
||
}
|
||
combo.value = 0
|
||
}
|
||
|
||
function armComboWindow() {
|
||
if (comboBreakTimer) clearTimeout(comboBreakTimer)
|
||
comboBreakTimer = setTimeout(() => {
|
||
comboBreakTimer = null
|
||
if (sessionPlaying.value && !sessionFinished.value) breakCombo()
|
||
}, COMBO_WINDOW_MS)
|
||
}
|
||
|
||
function celebrate(kind: 'combo' | 'progress' | 'finish', text: string, durationMs = 1400) {
|
||
if (sessionEnding.value || sessionFinished.value) return
|
||
celebrateKind.value = kind
|
||
celebrateText.value = text
|
||
celebrateKey.value += 1
|
||
celebrateVisible.value = true
|
||
if (celebrateTimer) clearTimeout(celebrateTimer)
|
||
celebrateTimer = setTimeout(() => {
|
||
celebrateVisible.value = false
|
||
}, durationMs)
|
||
}
|
||
|
||
/**
|
||
* 粒子策略:
|
||
* - 普通次数:不播(减卡顿)
|
||
* - 连击 ≥3 且命中里程碑才播
|
||
* - 前段 egg → 中段 walnut/can → 高潮 balloon → 末段停播/极简
|
||
*/
|
||
function pickCrushEffect(reps: number, currentCombo: number, target: number): { type: CrushType; intensity: number } | null {
|
||
if (reps >= target - 2) return null // 最后 2 次全力保流畅
|
||
if (currentCombo < 3) return null
|
||
|
||
const isMilestone =
|
||
currentCombo === 3
|
||
|| currentCombo === 5
|
||
|| currentCombo === 8
|
||
|| currentCombo === 10
|
||
|| currentCombo === 15
|
||
|| currentCombo === 20
|
||
|| (currentCombo >= 12 && currentCombo % 5 === 0)
|
||
if (!isMilestone) return null
|
||
|
||
// 两次粒子至少隔 900ms,避免连发叠帧
|
||
const now = Date.now()
|
||
if (now - lastCrushTs < 900) return null
|
||
|
||
const progress = reps / target
|
||
if (progress < 0.35) {
|
||
return { type: 'egg', intensity: 0.75 }
|
||
}
|
||
if (progress < 0.6) {
|
||
return { type: currentCombo >= 8 ? 'can' : 'walnut', intensity: 0.9 }
|
||
}
|
||
if (progress < 0.8) {
|
||
// 高潮:高连击用气球彩纸,否则易拉罐
|
||
return { type: currentCombo >= 10 ? 'balloon' : 'can', intensity: 1 }
|
||
}
|
||
// 接近尾声:只保留轻量 egg,且要求更高连击
|
||
if (currentCombo >= 10 && currentCombo % 5 === 0) {
|
||
return { type: 'egg', intensity: 0.55 }
|
||
}
|
||
return null
|
||
}
|
||
|
||
function triggerCrushIfNeeded(reps: number, currentCombo: number) {
|
||
const picked = pickCrushEffect(reps, currentCombo, targetReps)
|
||
if (!picked) return
|
||
lastCrushTs = Date.now()
|
||
if (!crushEnabled.value) crushEnabled.value = true
|
||
crushSignal.value = {
|
||
type: picked.type,
|
||
intensity: picked.intensity,
|
||
nonce: Date.now(),
|
||
}
|
||
}
|
||
|
||
function onRepCompleted() {
|
||
if (sessionEnding.value || sessionFinished.value || !sessionPlaying.value) return
|
||
|
||
const now = Date.now()
|
||
attemptReps.value += 1
|
||
validReps.value += 1
|
||
|
||
if (lastRepTs && now - lastRepTs <= COMBO_WINDOW_MS) {
|
||
combo.value += 1
|
||
} else {
|
||
combo.value = 1
|
||
}
|
||
lastRepTs = now
|
||
comboKey.value += 1
|
||
showComboBreak.value = false
|
||
armComboWindow()
|
||
|
||
bestCombo.value = Math.max(bestCombo.value, combo.value)
|
||
lastReward.value = 1
|
||
showReward.value = true
|
||
showHitFeedback.value = true
|
||
|
||
// 粒子:连击里程碑 + 进度换款,非每次都播
|
||
triggerCrushIfNeeded(validReps.value, combo.value)
|
||
|
||
if (validReps.value >= targetReps - 2) {
|
||
pipeline.setNearGoal(true)
|
||
}
|
||
|
||
if (rewardTimer) clearTimeout(rewardTimer)
|
||
rewardTimer = setTimeout(() => {
|
||
showReward.value = false
|
||
}, 480)
|
||
|
||
if (validReps.value >= targetReps) {
|
||
setTimeout(() => finishSession('goal'), 0)
|
||
return
|
||
}
|
||
|
||
if (combo.value >= 5 && combo.value % 5 === 0) {
|
||
celebrate('combo', `连击 x${combo.value}!太棒了!`)
|
||
} else if (validReps.value === Math.ceil(targetReps / 2)) {
|
||
celebrate('progress', `已完成一半,继续加油!`)
|
||
}
|
||
}
|
||
|
||
function onTogglePause() {
|
||
if (isLeaving) return
|
||
if (sessionFinished.value) {
|
||
onRestart()
|
||
return
|
||
}
|
||
if (!sessionPlaying.value) {
|
||
startCameraSession()
|
||
return
|
||
}
|
||
sessionPaused.value = !sessionPaused.value
|
||
try {
|
||
pipeline.setPaused(sessionPaused.value)
|
||
} catch (_) {}
|
||
if (sessionPaused.value) trackingQuality.value = 'weak'
|
||
}
|
||
|
||
function onEndTraining() {
|
||
if (isLeaving) return
|
||
// 强制结束也要跳出当前事件栈,避免与帧回调打架
|
||
setTimeout(() => finishSession('manual'), 0)
|
||
}
|
||
|
||
function clearFeedbackTimers() {
|
||
if (rewardTimer) {
|
||
clearTimeout(rewardTimer)
|
||
rewardTimer = null
|
||
}
|
||
if (comboBreakTimer) {
|
||
clearTimeout(comboBreakTimer)
|
||
comboBreakTimer = null
|
||
}
|
||
if (comboBreakHideTimer) {
|
||
clearTimeout(comboBreakHideTimer)
|
||
comboBreakHideTimer = null
|
||
}
|
||
if (celebrateTimer) {
|
||
clearTimeout(celebrateTimer)
|
||
celebrateTimer = null
|
||
}
|
||
if (teardownTimer) {
|
||
clearTimeout(teardownTimer)
|
||
teardownTimer = null
|
||
}
|
||
}
|
||
|
||
function finishSession(reason: 'goal' | 'time' | 'manual') {
|
||
if (sessionFinished.value && endModalShown) return
|
||
if (sessionEnding.value && sessionFinished.value) return
|
||
|
||
sessionEnding.value = true
|
||
sessionPlaying.value = false
|
||
sessionPaused.value = false
|
||
stopTimer()
|
||
clearFeedbackTimers()
|
||
celebrateVisible.value = false
|
||
showReward.value = false
|
||
showHitFeedback.value = false
|
||
showComboBreak.value = false
|
||
crushSignal.value = null
|
||
celebrateVisible.value = false
|
||
showReward.value = false
|
||
showHitFeedback.value = false
|
||
showComboBreak.value = false
|
||
lastRepTs = 0
|
||
lastCrushTs = 0
|
||
gesturePhase.value = 'UNTRACKED'
|
||
energyLevel.value = 0
|
||
pipelineSource.value = 'none'
|
||
trackingQuality.value = 'lost'
|
||
|
||
// 1) 先停识别(仍保留 camera 节点,避开原生销毁死锁)
|
||
try {
|
||
pipeline.setNearGoal(false)
|
||
pipeline.stop()
|
||
} catch (_) {}
|
||
|
||
sessionFinished.value = true
|
||
sessionEnding.value = false
|
||
|
||
const title = reason === 'goal' ? '目标完成' : reason === 'time' ? '时间到' : '训练结束'
|
||
const content = `有效握紧 ${validReps.value} 次 · 准确率 ${accuracyText.value} · 最高连击 x${bestCombo.value}`
|
||
|
||
// 2) 延后拆相机 + 弹窗,确保已离开帧回调栈
|
||
teardownTimer = setTimeout(() => {
|
||
teardownTimer = null
|
||
cameraReady.value = false
|
||
cameraInited.value = false
|
||
try {
|
||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||
} catch (_) {}
|
||
|
||
if (endModalShown) return
|
||
endModalShown = true
|
||
uni.showModal({
|
||
title,
|
||
content,
|
||
confirmText: '再练一次',
|
||
cancelText: '返回',
|
||
success: (res) => {
|
||
endModalShown = false
|
||
if (res.confirm) onRestart()
|
||
else onBack()
|
||
},
|
||
fail: () => {
|
||
endModalShown = false
|
||
},
|
||
})
|
||
}, 120)
|
||
}
|
||
|
||
function onRestart() {
|
||
if (isLeaving) return
|
||
endModalShown = false
|
||
sessionEnding.value = false
|
||
sessionFinished.value = false
|
||
validReps.value = 0
|
||
attemptReps.value = 0
|
||
combo.value = 0
|
||
bestCombo.value = 0
|
||
elapsedSeconds.value = 0
|
||
energyLevel.value = 0
|
||
gesturePhase.value = 'UNTRACKED'
|
||
showReward.value = false
|
||
showHitFeedback.value = false
|
||
showComboBreak.value = false
|
||
celebrateVisible.value = false
|
||
crushSignal.value = null
|
||
crushEnabled.value = false
|
||
lastCrushTs = 0
|
||
lastRepTs = 0
|
||
cameraError.value = ''
|
||
stageMode.value = 'anim'
|
||
try {
|
||
pipeline.setNearGoal(false)
|
||
} catch (_) {}
|
||
startCameraSession()
|
||
}
|
||
|
||
function leavePage() {
|
||
if (isLeaving) return
|
||
isLeaving = true
|
||
// 先停识别,但不要在跳转前强拆页面;完整清理交给 onUnload
|
||
stopTimer()
|
||
pipeline.stop()
|
||
try {
|
||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||
} catch (_) {}
|
||
sessionPlaying.value = false
|
||
sessionPaused.value = false
|
||
|
||
uni.navigateBack({
|
||
fail: () => {
|
||
// 无上一页时回首页,避免 redirectTo 子包页与销毁竞态导致 pageId 错乱
|
||
uni.reLaunch({
|
||
url: '/pages/index/index',
|
||
fail: () => {
|
||
isLeaving = false
|
||
},
|
||
})
|
||
},
|
||
})
|
||
}
|
||
|
||
function onBack() {
|
||
if (isLeaving) return
|
||
if (sessionPlaying.value && validReps.value > 0 && !sessionFinished.value) {
|
||
uni.showModal({
|
||
title: '退出训练?',
|
||
content: '当前为视觉识别试验进度,不会写入旧版握力环页。',
|
||
success: (res) => {
|
||
if (res.confirm) leavePage()
|
||
},
|
||
})
|
||
return
|
||
}
|
||
leavePage()
|
||
}
|
||
|
||
function onCameraError(e: any) {
|
||
cameraError.value = e?.detail?.errMsg || '摄像头不可用'
|
||
cameraReady.value = false
|
||
trackingQuality.value = 'lost'
|
||
pipeline.stop()
|
||
}
|
||
|
||
function cleanup() {
|
||
if (cleanedUp) return
|
||
cleanedUp = true
|
||
sessionEnding.value = true
|
||
stopTimer()
|
||
pipeline.stop()
|
||
cameraReady.value = false
|
||
clearFeedbackTimers()
|
||
try {
|
||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||
} catch (_) {}
|
||
sessionPlaying.value = false
|
||
sessionPaused.value = false
|
||
sessionEnding.value = false
|
||
}
|
||
|
||
onLoad(() => {
|
||
initNavMetrics()
|
||
isLeaving = false
|
||
cleanedUp = false
|
||
})
|
||
|
||
// 仅在真正离开页面时清理;onHide 会在弹窗/短暂切后台时误伤会话
|
||
onUnload(cleanup)
|
||
onBeforeUnmount(cleanup)
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
$bg: #f8fafc;
|
||
$card: #ffffff;
|
||
$text: #0f172a;
|
||
$muted: #64748b;
|
||
$brand: #14b8a6;
|
||
$brand-deep: #0f766e;
|
||
$green: #22b85a;
|
||
$orange: #ff6b21;
|
||
$danger: #e83e3e;
|
||
|
||
.page {
|
||
min-height: 100vh;
|
||
background: linear-gradient(180deg, #f0fdfa 0%, $bg 28%, $bg 100%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.nav {
|
||
background: #ffffff;
|
||
}
|
||
|
||
.nav-row {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 12rpx 0 8rpx;
|
||
}
|
||
|
||
.nav-back {
|
||
position: relative;
|
||
z-index: 2;
|
||
width: 72rpx;
|
||
height: 72rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.nav-back-icon {
|
||
font-size: 48rpx;
|
||
line-height: 1;
|
||
color: #181818;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.nav-center {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
top: 0;
|
||
bottom: 0;
|
||
z-index: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.nav-title {
|
||
font-size: 32rpx;
|
||
font-weight: 600;
|
||
color: #181818;
|
||
}
|
||
|
||
.nav-status {
|
||
margin-top: 4rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
}
|
||
|
||
.nav-dot {
|
||
width: 12rpx;
|
||
height: 12rpx;
|
||
border-radius: 50%;
|
||
background: #94a3b8;
|
||
|
||
&.good {
|
||
background: #07c160;
|
||
}
|
||
&.weak {
|
||
background: #f59e0b;
|
||
}
|
||
&.lost {
|
||
background: #dc2626;
|
||
}
|
||
}
|
||
|
||
.nav-status-text {
|
||
font-size: 22rpx;
|
||
color: #888888;
|
||
}
|
||
|
||
.nav-capsule-spacer {
|
||
position: relative;
|
||
z-index: 2;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.metrics-wrap {
|
||
padding: 16rpx 32rpx 12rpx;
|
||
}
|
||
|
||
.metrics {
|
||
background: $card;
|
||
border-radius: 24rpx;
|
||
box-shadow: 0 8rpx 28rpx rgba(15, 23, 42, 0.08);
|
||
display: flex;
|
||
align-items: center;
|
||
min-height: 120rpx;
|
||
padding: 20rpx 12rpx;
|
||
}
|
||
|
||
.metric {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
}
|
||
|
||
.metric-value {
|
||
font-size: 36rpx;
|
||
font-weight: 800;
|
||
color: $text;
|
||
line-height: 1.1;
|
||
}
|
||
|
||
.metric-value--combo {
|
||
font-size: 28rpx;
|
||
color: $orange;
|
||
}
|
||
|
||
.metric-label {
|
||
font-size: 20rpx;
|
||
color: $muted;
|
||
}
|
||
|
||
.stage {
|
||
position: relative;
|
||
flex: 1;
|
||
margin: 8rpx 0 0;
|
||
min-height: 720rpx;
|
||
background: #f3faf9;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.stage-camera,
|
||
.stage-fallback {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.stage-fallback {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 24rpx;
|
||
background: linear-gradient(180deg, #e8faf8 0%, #f7fbfa 100%);
|
||
}
|
||
|
||
.stage-fallback-text {
|
||
font-size: 26rpx;
|
||
color: $muted;
|
||
}
|
||
|
||
.stage-fallback-btn {
|
||
padding: 18rpx 36rpx;
|
||
border-radius: 999rpx;
|
||
background: $brand;
|
||
}
|
||
|
||
.stage-fallback-btn-text {
|
||
color: #fff;
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.stage-wash {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 10;
|
||
pointer-events: none;
|
||
background: linear-gradient(
|
||
180deg,
|
||
rgba(248, 250, 252, 0.88) 0%,
|
||
rgba(240, 253, 250, 0.28) 22%,
|
||
rgba(255, 255, 255, 0.12) 55%,
|
||
rgba(248, 250, 252, 0.9) 100%
|
||
);
|
||
}
|
||
|
||
.stage-hud {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 30;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.hit-title {
|
||
position: absolute;
|
||
top: 30rpx;
|
||
left: 0;
|
||
right: 0;
|
||
text-align: center;
|
||
font-size: 56rpx;
|
||
font-weight: 900;
|
||
letter-spacing: 4rpx;
|
||
color: $brand-deep;
|
||
text-shadow:
|
||
0 0 8rpx #ffffff,
|
||
0 3rpx 0 rgba(255, 255, 255, 0.9),
|
||
0 8rpx 20rpx rgba(15, 118, 110, 0.25);
|
||
|
||
&.active {
|
||
color: #17b357;
|
||
text-shadow:
|
||
0 0 8rpx #ffffff,
|
||
0 3rpx 0 rgba(255, 255, 255, 0.9),
|
||
0 8rpx 22rpx rgba(23, 179, 87, 0.35);
|
||
}
|
||
}
|
||
|
||
/* ===== 悬浮连击胶囊(仿视频:橙色渐变 + 速度线) ===== */
|
||
.combo-float {
|
||
position: absolute;
|
||
top: 118rpx;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
display: flex;
|
||
align-items: center;
|
||
animation: combo-pop 300ms cubic-bezier(0.18, 0.89, 0.32, 1.4);
|
||
}
|
||
|
||
@keyframes combo-pop {
|
||
0% {
|
||
transform: translateX(-50%) scale(0.6);
|
||
opacity: 0;
|
||
}
|
||
100% {
|
||
transform: translateX(-50%) scale(1);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
.combo-pill {
|
||
padding: 10rpx 30rpx 12rpx;
|
||
border-radius: 999rpx;
|
||
background: linear-gradient(95deg, #ff5a1f 0%, #ff9e2c 70%, #ffc93c 100%);
|
||
box-shadow: 0 10rpx 26rpx rgba(255, 199, 44, 0.45), 0 4rpx 10rpx rgba(255, 90, 31, 0.35);
|
||
}
|
||
|
||
.combo-pill-text {
|
||
color: #fff;
|
||
font-size: 30rpx;
|
||
font-weight: 900;
|
||
font-style: italic;
|
||
text-shadow: 0 2rpx 4rpx rgba(200, 60, 0, 0.35);
|
||
}
|
||
|
||
/* 连击时限条:时限内完成下一次才算连击 */
|
||
.combo-drain {
|
||
margin-top: 6rpx;
|
||
height: 5rpx;
|
||
border-radius: 999rpx;
|
||
background: rgba(255, 255, 255, 0.35);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.combo-drain-fill {
|
||
height: 100%;
|
||
border-radius: 999rpx;
|
||
background: #ffffff;
|
||
animation: combo-drain 6s linear forwards;
|
||
}
|
||
|
||
@keyframes combo-drain {
|
||
from {
|
||
width: 100%;
|
||
}
|
||
to {
|
||
width: 0%;
|
||
}
|
||
}
|
||
|
||
/* 速度线:胶囊右侧的冲刺短线 */
|
||
.speed-line {
|
||
height: 8rpx;
|
||
border-radius: 999rpx;
|
||
background: linear-gradient(90deg, #ffc93c 0%, rgba(255, 201, 60, 0) 100%);
|
||
margin-left: 12rpx;
|
||
}
|
||
|
||
.speed-line--1 {
|
||
width: 44rpx;
|
||
transform: translateY(-12rpx);
|
||
}
|
||
.speed-line--2 {
|
||
width: 64rpx;
|
||
margin-left: -52rpx;
|
||
transform: translateY(4rpx);
|
||
}
|
||
.speed-line--3 {
|
||
width: 36rpx;
|
||
transform: translateY(18rpx);
|
||
margin-left: -48rpx;
|
||
}
|
||
|
||
/* ===== 左上角飘带(仿视频:绿色斜标) ===== */
|
||
.combo-ribbon {
|
||
position: absolute;
|
||
left: -14rpx;
|
||
top: 40rpx;
|
||
padding: 14rpx 34rpx 14rpx 26rpx;
|
||
background: linear-gradient(120deg, #17b357 0%, #35d97b 100%);
|
||
border-radius: 10rpx 18rpx 18rpx 10rpx;
|
||
transform: rotate(-8deg);
|
||
box-shadow: 0 10rpx 24rpx rgba(23, 179, 87, 0.35);
|
||
animation: ribbon-in 380ms cubic-bezier(0.18, 0.89, 0.32, 1.3);
|
||
}
|
||
|
||
@keyframes ribbon-in {
|
||
0% {
|
||
transform: rotate(-8deg) translateX(-120rpx);
|
||
opacity: 0;
|
||
}
|
||
100% {
|
||
transform: rotate(-8deg) translateX(0);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
.combo-ribbon-line {
|
||
display: block;
|
||
color: #fff;
|
||
font-size: 30rpx;
|
||
font-weight: 900;
|
||
font-style: italic;
|
||
text-shadow: 0 2rpx 4rpx rgba(9, 92, 45, 0.4);
|
||
}
|
||
|
||
.combo-ribbon-sub {
|
||
display: block;
|
||
margin-top: 2rpx;
|
||
color: #eafff2;
|
||
font-size: 22rpx;
|
||
font-weight: 800;
|
||
font-style: italic;
|
||
}
|
||
|
||
.combo-break {
|
||
position: absolute;
|
||
top: 118rpx;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
padding: 8rpx 24rpx;
|
||
border-radius: 999rpx;
|
||
background: rgba(100, 116, 139, 0.85);
|
||
}
|
||
|
||
.combo-break-text {
|
||
color: #fff;
|
||
font-size: 24rpx;
|
||
font-weight: 700;
|
||
}
|
||
|
||
/* ===== +N 爆发(仿视频:橙色星芒) ===== */
|
||
.reward-wrap {
|
||
position: absolute;
|
||
right: 24rpx;
|
||
top: 22%;
|
||
width: 220rpx;
|
||
height: 220rpx;
|
||
animation: reward-pop 640ms cubic-bezier(0.18, 0.89, 0.32, 1.35);
|
||
}
|
||
|
||
@keyframes reward-pop {
|
||
0% {
|
||
transform: scale(0.4) rotate(-8deg);
|
||
opacity: 0;
|
||
}
|
||
45% {
|
||
transform: scale(1.12) rotate(2deg);
|
||
opacity: 1;
|
||
}
|
||
100% {
|
||
transform: scale(1) rotate(0deg);
|
||
}
|
||
}
|
||
|
||
.reward-glow {
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: 50%;
|
||
background: radial-gradient(
|
||
circle,
|
||
rgba(255, 236, 170, 0.95) 0%,
|
||
rgba(255, 196, 66, 0.55) 42%,
|
||
rgba(255, 170, 40, 0) 70%
|
||
);
|
||
}
|
||
|
||
.reward-burst {
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 50%;
|
||
transform: translate(-50%, -50%) rotate(-6deg);
|
||
font-size: 84rpx;
|
||
font-weight: 900;
|
||
font-style: italic;
|
||
color: #ff8a1e;
|
||
text-shadow: 0 0 6rpx #fff7e0, 0 6rpx 18rpx rgba(255, 138, 30, 0.5);
|
||
}
|
||
|
||
/* ===== 动画演示层(纯代码绘制,默认显示,可切实景) ===== */
|
||
.stage-anim {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 8;
|
||
background: linear-gradient(180deg, #e8f4fb 0%, #f0fbf7 55%, #e9f7f3 100%);
|
||
overflow: hidden;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.ambient-glow {
|
||
position: absolute;
|
||
width: 1100rpx;
|
||
height: 1100rpx;
|
||
left: 50%;
|
||
top: 50%;
|
||
transform: translate(-50%, -50%);
|
||
border-radius: 50%;
|
||
background: radial-gradient(
|
||
circle,
|
||
rgba(204, 251, 241, 0.9) 0%,
|
||
rgba(240, 253, 250, 0.5) 32%,
|
||
transparent 70%
|
||
);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* ===== 移植自旧握力环页的圆形视频舞台(手势驱动) ===== */
|
||
.ring-container {
|
||
position: relative;
|
||
width: 640rpx;
|
||
height: 640rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1;
|
||
}
|
||
|
||
/* 节拍光晕:随握力实时收缩变亮 */
|
||
.beat-halo {
|
||
position: absolute;
|
||
width: 640rpx;
|
||
height: 640rpx;
|
||
border-radius: 50%;
|
||
background: radial-gradient(
|
||
circle,
|
||
rgba(20, 184, 166, 0.45) 0%,
|
||
rgba(45, 212, 191, 0.18) 38%,
|
||
rgba(20, 184, 166, 0) 70%
|
||
);
|
||
opacity: 0.35;
|
||
transform: scale(1);
|
||
transition: transform 140ms ease-out, opacity 140ms linear;
|
||
will-change: transform, opacity;
|
||
}
|
||
|
||
/* 圆形示范视频主体 */
|
||
.video-ring {
|
||
position: relative;
|
||
width: 560rpx;
|
||
height: 560rpx;
|
||
border-radius: 50%;
|
||
overflow: hidden;
|
||
box-shadow:
|
||
0 20rpx 56rpx rgba(20, 184, 166, 0.35),
|
||
0 10rpx 28rpx rgba(45, 212, 191, 0.22);
|
||
transform: scale(1);
|
||
transition: transform 140ms ease-out;
|
||
will-change: transform;
|
||
z-index: 1;
|
||
}
|
||
|
||
.demo-video {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 50%;
|
||
background: #e2f5f1;
|
||
}
|
||
|
||
.video-ring-border {
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: 50%;
|
||
pointer-events: none;
|
||
box-shadow:
|
||
inset 0 0 0 4rpx rgba(255, 255, 255, 0.6),
|
||
inset 0 8rpx 24rpx rgba(15, 118, 110, 0.25);
|
||
transition: box-shadow 200ms linear;
|
||
}
|
||
|
||
/* ---- 握力驱动:环随压缩收缩、光晕增强 ---- */
|
||
.sq-1 .video-ring { transform: scale(0.985); }
|
||
.sq-2 .video-ring { transform: scale(0.965); }
|
||
.sq-3 .video-ring { transform: scale(0.945); }
|
||
.sq-4 .video-ring { transform: scale(0.92); }
|
||
|
||
.sq-1 .beat-halo { opacity: 0.5; transform: scale(0.97); }
|
||
.sq-2 .beat-halo { opacity: 0.65; transform: scale(0.94); }
|
||
.sq-3 .beat-halo { opacity: 0.8; transform: scale(0.9); }
|
||
.sq-4 .beat-halo {
|
||
opacity: 1;
|
||
transform: scale(0.86);
|
||
background: radial-gradient(
|
||
circle,
|
||
rgba(34, 184, 90, 0.5) 0%,
|
||
rgba(74, 222, 128, 0.22) 38%,
|
||
rgba(34, 184, 90, 0) 70%
|
||
);
|
||
}
|
||
|
||
.sq-4 .video-ring-border {
|
||
box-shadow:
|
||
inset 0 0 0 6rpx rgba(74, 222, 128, 0.85),
|
||
inset 0 8rpx 24rpx rgba(15, 118, 110, 0.25);
|
||
}
|
||
|
||
/* ---- 未捕捉到手:舞台自动循环示范(沿用旧页 beat-pulse 节奏) ---- */
|
||
.idle .beat-halo {
|
||
opacity: 1;
|
||
animation: beat-pulse 1900ms ease-in-out infinite;
|
||
}
|
||
|
||
.idle .video-ring {
|
||
animation: video-squeeze 1900ms ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes beat-pulse {
|
||
0%, 100% {
|
||
transform: scale(1);
|
||
opacity: 0.4;
|
||
}
|
||
50% {
|
||
transform: scale(0.86);
|
||
opacity: 0.95;
|
||
}
|
||
}
|
||
|
||
@keyframes video-squeeze {
|
||
0%, 100% {
|
||
transform: scale(1);
|
||
}
|
||
50% {
|
||
transform: scale(0.95);
|
||
}
|
||
}
|
||
|
||
/* ===== 画布四角捕捉框 + 状态 ===== */
|
||
.capture-frame {
|
||
position: absolute;
|
||
inset: 20rpx;
|
||
z-index: 20;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.corner {
|
||
position: absolute;
|
||
width: 56rpx;
|
||
height: 56rpx;
|
||
border: 8rpx solid #94a3b8;
|
||
transition: border-color 200ms linear;
|
||
}
|
||
|
||
.corner--tl {
|
||
left: 0;
|
||
top: 0;
|
||
border-right: none;
|
||
border-bottom: none;
|
||
border-top-left-radius: 20rpx;
|
||
}
|
||
.corner--tr {
|
||
right: 0;
|
||
top: 0;
|
||
border-left: none;
|
||
border-bottom: none;
|
||
border-top-right-radius: 20rpx;
|
||
}
|
||
.corner--bl {
|
||
left: 0;
|
||
bottom: 0;
|
||
border-right: none;
|
||
border-top: none;
|
||
border-bottom-left-radius: 20rpx;
|
||
}
|
||
.corner--br {
|
||
right: 0;
|
||
bottom: 0;
|
||
border-left: none;
|
||
border-top: none;
|
||
border-bottom-right-radius: 20rpx;
|
||
}
|
||
|
||
/* 捕捉状态驱动四角颜色 */
|
||
.cap-lost .corner {
|
||
border-color: #dc2626;
|
||
}
|
||
.cap-seek .corner {
|
||
border-color: #94a3b8;
|
||
}
|
||
.cap-open .corner {
|
||
border-color: $brand;
|
||
}
|
||
.cap-grip .corner,
|
||
.cap-hold .corner {
|
||
border-color: $green;
|
||
}
|
||
.cap-release .corner {
|
||
border-color: #f59e0b;
|
||
}
|
||
|
||
.capture-pill {
|
||
position: absolute;
|
||
left: 50%;
|
||
bottom: 36rpx;
|
||
transform: translateX(-50%);
|
||
z-index: 25;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
padding: 12rpx 30rpx;
|
||
border-radius: 999rpx;
|
||
background: rgba(15, 23, 42, 0.55);
|
||
}
|
||
|
||
.capture-dot {
|
||
width: 16rpx;
|
||
height: 16rpx;
|
||
border-radius: 50%;
|
||
background: #94a3b8;
|
||
}
|
||
|
||
.cap-lost .capture-dot {
|
||
background: #dc2626;
|
||
}
|
||
.cap-open .capture-dot {
|
||
background: #25d9d0;
|
||
}
|
||
.cap-grip .capture-dot,
|
||
.cap-hold .capture-dot {
|
||
background: #22b85a;
|
||
}
|
||
.cap-release .capture-dot {
|
||
background: #f59e0b;
|
||
}
|
||
|
||
.capture-pill-text {
|
||
color: #fff;
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.mode-toggle {
|
||
position: absolute;
|
||
right: 16rpx;
|
||
top: 12rpx;
|
||
z-index: 45;
|
||
padding: 10rpx 22rpx;
|
||
border-radius: 999rpx;
|
||
background: rgba(15, 23, 42, 0.45);
|
||
}
|
||
|
||
.mode-toggle-text {
|
||
color: #fff;
|
||
font-size: 22rpx;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* ===== 庆祝层:横幅 + 撒花 ===== */
|
||
.celebrate {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 50;
|
||
pointer-events: none;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.celebrate-banner {
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 38%;
|
||
transform: translateX(-50%);
|
||
padding: 20rpx 48rpx;
|
||
border-radius: 999rpx;
|
||
background: linear-gradient(90deg, #ff6a2d 0%, #ffb52d 100%);
|
||
box-shadow: 0 12rpx 36rpx rgba(255, 107, 33, 0.4);
|
||
animation: banner-in 420ms cubic-bezier(0.18, 0.89, 0.32, 1.28);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.celebrate.finish .celebrate-banner {
|
||
background: linear-gradient(90deg, #16a34a 0%, #4ade80 100%);
|
||
box-shadow: 0 12rpx 36rpx rgba(22, 163, 74, 0.4);
|
||
}
|
||
|
||
@keyframes banner-in {
|
||
0% {
|
||
transform: translateX(-50%) scale(0.5);
|
||
opacity: 0;
|
||
}
|
||
100% {
|
||
transform: translateX(-50%) scale(1);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
.celebrate-banner-text {
|
||
color: #fff;
|
||
font-size: 40rpx;
|
||
font-weight: 900;
|
||
}
|
||
|
||
.confetti {
|
||
position: absolute;
|
||
top: -30rpx;
|
||
width: 18rpx;
|
||
height: 30rpx;
|
||
border-radius: 4rpx;
|
||
opacity: 0;
|
||
animation: confetti-fall 1.6s ease-in forwards;
|
||
}
|
||
|
||
@keyframes confetti-fall {
|
||
0% {
|
||
transform: translateY(0) rotate(0deg);
|
||
opacity: 1;
|
||
}
|
||
100% {
|
||
transform: translateY(120vh) rotate(540deg);
|
||
opacity: 0.6;
|
||
}
|
||
}
|
||
|
||
/* 14 片彩纸:错开位置 / 颜色 / 延迟 */
|
||
.confetti-1 { left: 6%; background: #ff6a2d; animation-delay: 0ms; }
|
||
.confetti-2 { left: 13%; background: #22b85a; animation-delay: 120ms; }
|
||
.confetti-3 { left: 20%; background: #25d9d0; animation-delay: 60ms; }
|
||
.confetti-4 { left: 27%; background: #ffb52d; animation-delay: 200ms; }
|
||
.confetti-5 { left: 34%; background: #f472b6; animation-delay: 30ms; }
|
||
.confetti-6 { left: 41%; background: #22b85a; animation-delay: 160ms; }
|
||
.confetti-7 { left: 48%; background: #ff6a2d; animation-delay: 90ms; }
|
||
.confetti-8 { left: 55%; background: #25d9d0; animation-delay: 240ms; }
|
||
.confetti-9 { left: 62%; background: #ffb52d; animation-delay: 50ms; }
|
||
.confetti-10 { left: 69%; background: #f472b6; animation-delay: 180ms; }
|
||
.confetti-11 { left: 76%; background: #22b85a; animation-delay: 110ms; }
|
||
.confetti-12 { left: 83%; background: #ff6a2d; animation-delay: 260ms; }
|
||
.confetti-13 { left: 90%; background: #25d9d0; animation-delay: 140ms; }
|
||
.confetti-14 { left: 96%; background: #ffb52d; animation-delay: 20ms; }
|
||
|
||
.footer {
|
||
position: relative;
|
||
z-index: 100;
|
||
background: #fff;
|
||
padding: 24rpx 36rpx calc(24rpx + env(safe-area-inset-bottom));
|
||
}
|
||
|
||
.goal-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.goal-text {
|
||
font-size: 28rpx;
|
||
font-weight: 800;
|
||
color: $text;
|
||
}
|
||
|
||
.remain-text {
|
||
font-size: 26rpx;
|
||
font-weight: 700;
|
||
color: $brand-deep;
|
||
}
|
||
|
||
.goal-track {
|
||
margin-top: 16rpx;
|
||
height: 14rpx;
|
||
border-radius: 999rpx;
|
||
background: #e2e8f0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.goal-fill {
|
||
height: 100%;
|
||
border-radius: 999rpx;
|
||
background: linear-gradient(90deg, #42c7c6 0%, #54c9c2 100%);
|
||
}
|
||
|
||
.actions {
|
||
margin-top: 28rpx;
|
||
display: flex;
|
||
gap: 20rpx;
|
||
}
|
||
|
||
.action {
|
||
flex: 1;
|
||
height: 96rpx;
|
||
border-radius: 20rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.action--pause {
|
||
background: #f0fdfa;
|
||
border: 2rpx solid $brand;
|
||
}
|
||
|
||
.action--end {
|
||
background: #fef2f2;
|
||
border: 2rpx solid #f28b8b;
|
||
}
|
||
|
||
.action-icon {
|
||
font-size: 24rpx;
|
||
}
|
||
|
||
.action--pause .action-icon,
|
||
.action--pause .action-text {
|
||
color: $brand-deep;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.action--end .action-icon,
|
||
.action--end .action-text {
|
||
color: $danger;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.action-text {
|
||
font-size: 30rpx;
|
||
}
|
||
</style>
|