- 替换原来两只脚印为一张跑步小人 PNG(static/training/runner.png)
- 静态 PNG + CSS 律动:
· runner-bob keyframes: 1 拍内做"下蹲→起跳→反向倾斜→落地"
四个阶段,模拟跑步身体起伏
· 上下抖动 ±14rpx, 左右轻摆 ±1.5°,scale Y 微妙压缩
· drop-shadow 让人物有立体感
- 阴影同步:runner-shadow-pulse 跟律动反向变化
(跳起时阴影变小变淡 0.2,落地时变大变浓 0.5)
- 动画 duration 绑定 --step-duration = 60000/bpm
· BPM 60 → 1000ms/次,慢悠悠
· BPM 120 → 500ms/次,正常跑
· BPM 180 → 333ms/次,紧张快跑
注意: 用户提供的文件实际是静态 PNG 不是 SVG 动图,
所以是"画面整体律动"而非真正的 walk cycle 关节运动。
Co-authored-by: Cursor <cursoragent@cursor.com>
611 lines
15 KiB
Vue
611 lines
15 KiB
Vue
<template>
|
||
<view class="page" :style="rootStyle">
|
||
<!-- ===== 主舞台:节拍器圆 ===== -->
|
||
<view class="stage" @click="togglePlay">
|
||
<view class="ring r1" :class="{ playing: isPlaying }" />
|
||
<view class="ring r2" :class="{ playing: isPlaying }" />
|
||
<view class="core" :class="{ playing: isPlaying, accent: isAccent }">
|
||
<text class="bpm-num">{{ bpm }}</text>
|
||
<text class="bpm-label">BPM</text>
|
||
<view class="ctrl">
|
||
<view v-if="isPlaying" class="icon-pause">
|
||
<view class="bar" />
|
||
<view class="bar" />
|
||
</view>
|
||
<view v-else class="icon-play" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- ===== 配速 + 走路动画卡片 ===== -->
|
||
<view class="walker-card">
|
||
<view class="walker-header">
|
||
<view class="badge" :class="badgeClass">
|
||
<view class="badge-dot" />
|
||
<text>{{ walkerTip }}</text>
|
||
</view>
|
||
<text class="status">{{ statusHint }}</text>
|
||
</view>
|
||
|
||
<view class="walker" :class="{ playing: isPlaying }">
|
||
<view class="runner-wrap">
|
||
<image
|
||
class="runner"
|
||
src="/static/training/runner.png"
|
||
mode="aspectFit"
|
||
/>
|
||
<view class="runner-shadow" />
|
||
</view>
|
||
<view class="ground" />
|
||
</view>
|
||
</view>
|
||
|
||
<!-- ===== 控制卡片:BPM + 拍号 ===== -->
|
||
<view class="control-card">
|
||
<view class="bpm-row">
|
||
<view class="step-btn" @click="adjustBpm(-10)">
|
||
<text class="step-num">−10</text>
|
||
</view>
|
||
<view class="step-btn" @click="adjustBpm(-1)">
|
||
<text class="step-num">−1</text>
|
||
</view>
|
||
<view class="step-btn" @click="adjustBpm(1)">
|
||
<text class="step-num">+1</text>
|
||
</view>
|
||
<view class="step-btn" @click="adjustBpm(10)">
|
||
<text class="step-num">+10</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="divider" />
|
||
|
||
<view class="meter-row">
|
||
<text class="meter-label">每组拍数</text>
|
||
<view class="meter-options">
|
||
<view
|
||
v-for="m in METER_OPTIONS"
|
||
:key="m.value"
|
||
class="meter-btn"
|
||
:class="{ active: accentEvery === m.value }"
|
||
@click="onMeterChange(m.value)"
|
||
>
|
||
<text>{{ m.label }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onUnmounted } from 'vue'
|
||
import { onShow, onHide } from '@dcloudio/uni-app'
|
||
import { useMetronome } from '@/hooks/useMetronome'
|
||
|
||
const METER_OPTIONS = [
|
||
{ label: '2', value: 2 },
|
||
{ label: '3', value: 3 },
|
||
{ label: '4', value: 4 },
|
||
]
|
||
|
||
const metronome = useMetronome({
|
||
initialBpm: 80,
|
||
accentEvery: 4,
|
||
clickSrc: '/static/training/audio/click.mp3',
|
||
accentSrc: '/static/training/audio/click-wood.mp3',
|
||
})
|
||
const {
|
||
bpm,
|
||
isPlaying,
|
||
beatIndex,
|
||
isAccent,
|
||
accentEvery,
|
||
intervalMs,
|
||
start,
|
||
stop,
|
||
setBpm,
|
||
setAccentEvery,
|
||
preload,
|
||
} = metronome
|
||
|
||
const adjustBpm = (delta: number) => {
|
||
setBpm(bpm.value + delta)
|
||
}
|
||
|
||
const onMeterChange = (val: number) => {
|
||
setAccentEvery(val)
|
||
}
|
||
|
||
const togglePlay = () => {
|
||
if (isPlaying.value) {
|
||
stop()
|
||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||
} else {
|
||
start()
|
||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||
}
|
||
}
|
||
|
||
const rootStyle = computed(() => ({
|
||
'--beat-duration': `${intervalMs.value}ms`,
|
||
'--step-duration': `${intervalMs.value}ms`,
|
||
}))
|
||
|
||
const statusHint = computed(() => {
|
||
if (isPlaying.value) {
|
||
const cur = ((beatIndex.value - 1) % accentEvery.value) + 1
|
||
return `第 ${cur} 拍 / 共 ${accentEvery.value} 拍`
|
||
}
|
||
return '轻点中央圆点开始'
|
||
})
|
||
|
||
const walkerTip = computed(() => {
|
||
if (bpm.value < 70) return '慢走 · 热身'
|
||
if (bpm.value < 100) return '健走 · 日常'
|
||
if (bpm.value < 130) return '快走 · 提速'
|
||
if (bpm.value < 170) return '慢跑 · 有氧'
|
||
return '快跑 · 高强'
|
||
})
|
||
|
||
const badgeClass = computed(() => {
|
||
if (bpm.value < 100) return 'badge-mild'
|
||
if (bpm.value < 130) return 'badge-mid'
|
||
if (bpm.value < 170) return 'badge-strong'
|
||
return 'badge-hot'
|
||
})
|
||
|
||
onShow(() => {
|
||
preload()
|
||
})
|
||
|
||
onHide(() => {
|
||
if (isPlaying.value) {
|
||
stop()
|
||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
/* ============================================================
|
||
* 设计 token
|
||
* ============================================================ */
|
||
$bg-grad: linear-gradient(180deg, #0a0f1c 0%, #141a2a 100%);
|
||
$card-bg: rgba(255, 255, 255, 0.035);
|
||
$card-border: rgba(255, 255, 255, 0.06);
|
||
$text-1: #f1f5f9;
|
||
$text-2: #cbd5e1;
|
||
$text-3: #64748b;
|
||
$brand: #22c55e;
|
||
$brand-deep: #15803d;
|
||
$accent: #f59e0b;
|
||
$accent-deep: #b45309;
|
||
$radius-card: 28rpx;
|
||
$radius-btn: 20rpx;
|
||
|
||
/* ============================================================
|
||
* 页面容器
|
||
* ============================================================ */
|
||
.page {
|
||
height: 100vh;
|
||
box-sizing: border-box;
|
||
padding: 30rpx 28rpx 60rpx;
|
||
background: $bg-grad;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 24rpx;
|
||
color: $text-1;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ============================================================
|
||
* 节拍器主舞台
|
||
* ============================================================ */
|
||
.stage {
|
||
position: relative;
|
||
width: 540rpx;
|
||
height: 540rpx;
|
||
margin-top: 20rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
&:active .core {
|
||
transform: scale(0.97);
|
||
}
|
||
}
|
||
|
||
.ring {
|
||
position: absolute;
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 50%;
|
||
border: 3rpx solid rgba(34, 197, 94, 0.14);
|
||
pointer-events: none;
|
||
|
||
&.playing {
|
||
animation: ring-pulse var(--beat-duration, 750ms) ease-out infinite;
|
||
}
|
||
&.r2.playing {
|
||
animation-delay: calc(var(--beat-duration, 750ms) * -0.5);
|
||
}
|
||
}
|
||
@keyframes ring-pulse {
|
||
0% {
|
||
transform: scale(0.94);
|
||
opacity: 0.85;
|
||
border-color: rgba(34, 197, 94, 0.45);
|
||
}
|
||
100% {
|
||
transform: scale(1.18);
|
||
opacity: 0;
|
||
border-color: rgba(34, 197, 94, 0);
|
||
}
|
||
}
|
||
|
||
.core {
|
||
position: relative;
|
||
width: 420rpx;
|
||
height: 420rpx;
|
||
border-radius: 50%;
|
||
background: linear-gradient(140deg, $brand 0%, $brand-deep 100%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow:
|
||
inset 0 -16rpx 50rpx rgba(0, 0, 0, 0.22),
|
||
inset 0 16rpx 50rpx rgba(255, 255, 255, 0.1),
|
||
0 20rpx 50rpx rgba(34, 197, 94, 0.32);
|
||
transition: transform 0.12s, background 0.18s, box-shadow 0.18s;
|
||
|
||
&.playing {
|
||
animation: core-beat var(--beat-duration, 750ms) ease-in-out infinite;
|
||
}
|
||
|
||
&.playing.accent {
|
||
background: linear-gradient(140deg, #fbbf24 0%, $accent-deep 100%);
|
||
box-shadow:
|
||
inset 0 -16rpx 50rpx rgba(0, 0, 0, 0.22),
|
||
inset 0 16rpx 50rpx rgba(255, 255, 255, 0.18),
|
||
0 20rpx 50rpx rgba(245, 158, 11, 0.4);
|
||
}
|
||
|
||
.bpm-num {
|
||
font-size: 180rpx;
|
||
font-weight: 800;
|
||
color: #fff;
|
||
line-height: 1;
|
||
letter-spacing: -2rpx;
|
||
font-variant-numeric: tabular-nums;
|
||
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.18);
|
||
}
|
||
|
||
.bpm-label {
|
||
font-size: 22rpx;
|
||
color: rgba(255, 255, 255, 0.65);
|
||
letter-spacing: 6rpx;
|
||
margin-top: 10rpx;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.ctrl {
|
||
margin-top: 26rpx;
|
||
height: 76rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
}
|
||
@keyframes core-beat {
|
||
0%,
|
||
100% {
|
||
transform: scale(1);
|
||
}
|
||
50% {
|
||
transform: scale(1.04);
|
||
}
|
||
}
|
||
|
||
/* 播放/暂停 icon (CSS 几何) */
|
||
.icon-play {
|
||
width: 0;
|
||
height: 0;
|
||
border-left: 60rpx solid #fff;
|
||
border-top: 38rpx solid transparent;
|
||
border-bottom: 38rpx solid transparent;
|
||
margin-left: 14rpx;
|
||
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.2));
|
||
}
|
||
.icon-pause {
|
||
display: flex;
|
||
gap: 18rpx;
|
||
height: 70rpx;
|
||
|
||
.bar {
|
||
width: 18rpx;
|
||
height: 100%;
|
||
background: #fff;
|
||
border-radius: 5rpx;
|
||
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.2);
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
* 走路动画卡片
|
||
* ============================================================ */
|
||
.walker-card {
|
||
width: 100%;
|
||
background: $card-bg;
|
||
border: 1rpx solid $card-border;
|
||
border-radius: $radius-card;
|
||
padding: 24rpx 28rpx 32rpx;
|
||
backdrop-filter: blur(20rpx);
|
||
}
|
||
|
||
.walker-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.badge {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10rpx;
|
||
padding: 8rpx 20rpx;
|
||
background: rgba(34, 197, 94, 0.12);
|
||
border: 1rpx solid rgba(34, 197, 94, 0.3);
|
||
border-radius: 999rpx;
|
||
font-size: 24rpx;
|
||
color: $brand;
|
||
font-weight: 600;
|
||
|
||
.badge-dot {
|
||
width: 12rpx;
|
||
height: 12rpx;
|
||
border-radius: 50%;
|
||
background: currentColor;
|
||
box-shadow: 0 0 8rpx currentColor;
|
||
}
|
||
|
||
&.badge-mild {
|
||
background: rgba(34, 197, 94, 0.12);
|
||
border-color: rgba(34, 197, 94, 0.3);
|
||
color: #4ade80;
|
||
}
|
||
&.badge-mid {
|
||
background: rgba(251, 191, 36, 0.12);
|
||
border-color: rgba(251, 191, 36, 0.3);
|
||
color: #fbbf24;
|
||
}
|
||
&.badge-strong {
|
||
background: rgba(249, 115, 22, 0.12);
|
||
border-color: rgba(249, 115, 22, 0.3);
|
||
color: #fb923c;
|
||
}
|
||
&.badge-hot {
|
||
background: rgba(239, 68, 68, 0.14);
|
||
border-color: rgba(239, 68, 68, 0.32);
|
||
color: #f87171;
|
||
}
|
||
}
|
||
|
||
.status {
|
||
font-size: 24rpx;
|
||
color: $text-3;
|
||
letter-spacing: 1rpx;
|
||
}
|
||
|
||
.walker {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 240rpx;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: flex-end;
|
||
}
|
||
|
||
.walker .ground {
|
||
position: absolute;
|
||
bottom: 22rpx;
|
||
left: 10%;
|
||
right: 10%;
|
||
height: 1rpx;
|
||
background: linear-gradient(
|
||
90deg,
|
||
transparent 0%,
|
||
rgba(255, 255, 255, 0.18) 30%,
|
||
rgba(255, 255, 255, 0.18) 70%,
|
||
transparent 100%
|
||
);
|
||
}
|
||
|
||
/* ============================================================
|
||
* 跑步小人(静态 PNG + CSS 律动)
|
||
* ============================================================ */
|
||
.runner-wrap {
|
||
position: absolute;
|
||
bottom: 18rpx;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 200rpx;
|
||
height: 200rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.runner {
|
||
width: 100%;
|
||
height: 100%;
|
||
transform-origin: bottom center;
|
||
/* 默认状态保持微妙的呼吸感,不至于完全静止 */
|
||
transition: filter 0.3s;
|
||
}
|
||
|
||
.runner-shadow {
|
||
position: absolute;
|
||
bottom: -8rpx;
|
||
width: 120rpx;
|
||
height: 12rpx;
|
||
background: radial-gradient(
|
||
ellipse at center,
|
||
rgba(0, 0, 0, 0.45),
|
||
transparent 70%
|
||
);
|
||
border-radius: 50%;
|
||
transform-origin: center;
|
||
}
|
||
|
||
/* 跟节拍同步:1 拍 1 次律动 */
|
||
.walker.playing .runner {
|
||
animation: runner-bob var(--step-duration, 750ms) ease-in-out infinite;
|
||
/* 高 BPM 时加速度模糊,让"跑得很快"有视觉表达 */
|
||
filter: drop-shadow(0 4rpx 12rpx rgba(0, 0, 0, 0.25));
|
||
}
|
||
|
||
.walker.playing .runner-shadow {
|
||
animation: runner-shadow-pulse var(--step-duration, 750ms) ease-in-out infinite;
|
||
}
|
||
|
||
/* 跑步律动:身体上下起伏 + 微妙左右摆动 */
|
||
@keyframes runner-bob {
|
||
0% {
|
||
transform: translateY(0) rotate(0deg) scale(1);
|
||
}
|
||
25% {
|
||
/* 第一拍着地半程,身体最低 */
|
||
transform: translateY(2rpx) rotate(-1.5deg) scale(1, 0.99);
|
||
}
|
||
50% {
|
||
/* 离地最高点,身体抬起 */
|
||
transform: translateY(-14rpx) rotate(0deg) scale(1, 1.01);
|
||
}
|
||
75% {
|
||
/* 另一脚着地半程,反向倾斜 */
|
||
transform: translateY(2rpx) rotate(1.5deg) scale(1, 0.99);
|
||
}
|
||
100% {
|
||
transform: translateY(0) rotate(0deg) scale(1);
|
||
}
|
||
}
|
||
|
||
@keyframes runner-shadow-pulse {
|
||
0%,
|
||
100% {
|
||
opacity: 0.5;
|
||
transform: scaleX(1);
|
||
}
|
||
50% {
|
||
/* 跳起时阴影变小变淡 */
|
||
opacity: 0.2;
|
||
transform: scaleX(0.7);
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
* 控制卡片(BPM + 拍号)
|
||
* ============================================================ */
|
||
.control-card {
|
||
width: 100%;
|
||
background: $card-bg;
|
||
border: 1rpx solid $card-border;
|
||
border-radius: $radius-card;
|
||
padding: 28rpx;
|
||
backdrop-filter: blur(20rpx);
|
||
}
|
||
|
||
.bpm-row {
|
||
display: flex;
|
||
gap: 14rpx;
|
||
}
|
||
|
||
.step-btn {
|
||
flex: 1;
|
||
height: 96rpx;
|
||
border-radius: $radius-btn;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border: 1rpx solid rgba(255, 255, 255, 0.08);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.15s;
|
||
|
||
&:active {
|
||
background: rgba(34, 197, 94, 0.18);
|
||
border-color: rgba(34, 197, 94, 0.5);
|
||
transform: scale(0.96);
|
||
}
|
||
|
||
.step-num {
|
||
font-size: 38rpx;
|
||
color: $text-1;
|
||
font-weight: 700;
|
||
font-variant-numeric: tabular-nums;
|
||
letter-spacing: -1rpx;
|
||
}
|
||
}
|
||
|
||
.divider {
|
||
height: 1rpx;
|
||
background: rgba(255, 255, 255, 0.06);
|
||
margin: 24rpx 0;
|
||
}
|
||
|
||
.meter-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 20rpx;
|
||
}
|
||
|
||
.meter-label {
|
||
font-size: 26rpx;
|
||
color: $text-3;
|
||
letter-spacing: 1rpx;
|
||
}
|
||
|
||
.meter-options {
|
||
display: flex;
|
||
gap: 10rpx;
|
||
background: rgba(255, 255, 255, 0.04);
|
||
padding: 6rpx;
|
||
border-radius: 999rpx;
|
||
border: 1rpx solid rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
.meter-btn {
|
||
min-width: 84rpx;
|
||
height: 60rpx;
|
||
padding: 0 20rpx;
|
||
border-radius: 999rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.18s;
|
||
|
||
text {
|
||
font-size: 30rpx;
|
||
color: $text-2;
|
||
font-weight: 600;
|
||
}
|
||
|
||
&.active {
|
||
background: $brand;
|
||
box-shadow: 0 4rpx 12rpx rgba(34, 197, 94, 0.4);
|
||
|
||
text {
|
||
color: #fff;
|
||
}
|
||
}
|
||
}
|
||
</style>
|