Merge branch 'mini-sport'

This commit is contained in:
2026-05-28 11:49:41 +08:00
962 changed files with 110615 additions and 647 deletions
@@ -2,8 +2,8 @@
<view v-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
<!-- 展开后的菜单卡片(从下往上动画) -->
<view v-if="expanded" class="menu-list" @click.stop>
<!-- "练一练" 暂时隐藏,后续恢复时取消注释即可
<view class="menu-item" @click="goto('/training/pages/index')">
<!-- "练一练" 暂时隐藏,后续恢复时取消注释即可 -->
<!-- <view class="menu-item" @click="goto('/training/pages/index')">
<view class="menu-icon menu-icon&#45;&#45;train">
<text class="menu-icon-text">💪</text>
</view>
@@ -12,8 +12,19 @@
<text class="menu-sub">器械跟练</text>
</view>
<view class="menu-arrow"></view>
</view> -->
<view class="menu-item" @click="goto('/training/pages/grip-ring')">
<view class="menu-icon menu-icon--grip">
<text class="menu-icon-text">🟢</text>
</view>
<view class="menu-info">
<text class="menu-title">握力环</text>
<text class="menu-sub">握力训练</text>
</view>
<view class="menu-arrow"></view>
</view>
-->
<view class="menu-item" @click="goto('/training/pages/metronome')">
<view class="menu-icon menu-icon--metro">
<view class="metro-bar metro-bar-1" />
@@ -21,7 +32,7 @@
<view class="metro-bar metro-bar-3" />
</view>
<view class="menu-info">
<text class="menu-title">节拍器</text>
<text class="menu-title">耗糖节拍器</text>
<text class="menu-sub">健走配速</text>
</view>
<view class="menu-arrow"></view>
@@ -251,6 +262,10 @@ $brand-light: #34d399;
&--train {
background: linear-gradient(140deg, $brand-light, $brand-deep);
}
&--grip {
background: linear-gradient(140deg, #86efac, #22c55e);
box-shadow: 0 2rpx 8rpx rgba(34, 197, 94, 0.36);
}
&--metro {
background: linear-gradient(140deg, #fbbf24, #d97706);
box-shadow: 0 2rpx 8rpx rgba(245, 158, 11, 0.36);
+11 -1
View File
@@ -115,10 +115,20 @@
"navigationBarTitleText": "练一练"
}
},
{
"path": "pages/grip-ring",
"style": {
"navigationBarTitleText": "握力环训练",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc",
"requiredBackgroundModes": ["audio"]
}
},
{
"path": "pages/metronome",
"style": {
"navigationBarTitleText": "节拍器",
"navigationBarTitleText": "耗糖节拍器",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc",
+64 -8
View File
@@ -8,6 +8,7 @@ export interface MetronomeOptions {
accentSrc?: string
accentEvery?: number
poolSize?: number
volume?: number // 节拍音音量 0-1
onBeat?: (beatIndex: number, isAccent: boolean) => void
}
@@ -21,11 +22,15 @@ class AudioPool {
private list: UniApp.InnerAudioContext[] = []
private cursor = 0
private warmedUp = false
private targetVolume = 1
constructor(
private src: string,
private size: number,
) {}
volume = 1,
) {
this.targetVolume = volume
}
private create() {
for (let i = 0; i < this.size; i++) {
@@ -33,14 +38,23 @@ class AudioPool {
ctx.src = this.src
ctx.obeyMuteSwitch = false
ctx.autoplay = false
ctx.volume = this.targetVolume
this.list.push(ctx)
}
}
setVolume(volume: number) {
this.targetVolume = Math.max(0, Math.min(1, volume))
this.list.forEach((ctx) => {
try {
ctx.volume = this.targetVolume
} catch (_) {}
})
}
/**
* 预热:极小音量短暂播放,让音频文件下载/解码到内存
* 预热:短暂播放,让音频文件下载/解码到内存
* 真正首次 play 不会再卡冷启动
* 注意:iOS 上 volume = 0 不一定真正解码,所以用 0.01 极小音量
*/
warmUp() {
if (this.warmedUp) return
@@ -48,12 +62,12 @@ class AudioPool {
this.list.forEach((ctx) => {
try {
ctx.volume = 0.01
ctx.volume = 0 // 静音预热,避免进入页面时响声
ctx.play()
setTimeout(() => {
try {
ctx.stop()
ctx.volume = 1
ctx.volume = this.targetVolume // 恢复目标音量
} catch (_) {}
}, 60)
} catch (_) {}
@@ -69,8 +83,11 @@ class AudioPool {
const ctx = this.list[this.cursor]
this.cursor = (this.cursor + 1) % this.list.length
try {
// 强制停止所有正在播放的音频,避免重叠
this.list.forEach(c => {
try { c.stop() } catch (_) {}
})
// iOS 上 seek(0) + play() 不一定能从头播放;stop() + play() 更稳
ctx.stop()
ctx.play()
} catch (_) {
try { ctx.play() } catch (__) {}
@@ -101,6 +118,7 @@ export function useMetronome(options: MetronomeOptions = {}) {
clickSrc = DEFAULT_CLICK_SRC,
accentSrc,
poolSize = 4,
volume = 1,
onBeat,
} = options
@@ -119,11 +137,11 @@ export function useMetronome(options: MetronomeOptions = {}) {
const ensureAudio = () => {
if (!clickPool) {
clickPool = new AudioPool(clickSrc, poolSize)
clickPool = new AudioPool(clickSrc, poolSize, volume)
clickPool.warmUp()
}
if (accentSrc && !accentPool) {
accentPool = new AudioPool(accentSrc, Math.max(2, Math.ceil(poolSize / 2)))
accentPool = new AudioPool(accentSrc, Math.max(2, Math.ceil(poolSize / 2)), volume)
accentPool.warmUp()
}
}
@@ -179,6 +197,43 @@ export function useMetronome(options: MetronomeOptions = {}) {
beatIndex.value = 0
}
/**
* 动态更新音频源(用于切换音色)
*/
const updateAudioSrc = (newClickSrc: string, newAccentSrc?: string) => {
const wasPlaying = isPlaying.value
if (wasPlaying) stop()
// 销毁旧的音频池
clickPool?.destroy()
accentPool?.destroy()
clickPool = null
accentPool = null
// 创建新的音频池(静默预热,不播放)
clickPool = new AudioPool(newClickSrc, poolSize)
if (newAccentSrc) {
accentPool = new AudioPool(newAccentSrc, Math.max(2, Math.ceil(poolSize / 2)))
}
// 播放一次预览音效(适中音量)
const previewCtx = uni.createInnerAudioContext()
previewCtx.src = newClickSrc
previewCtx.obeyMuteSwitch = false
previewCtx.volume = 0.4
try {
previewCtx.play()
setTimeout(() => {
try {
previewCtx.destroy?.()
} catch (_) {}
}, 500)
} catch (_) {}
// 恢复播放状态
if (wasPlaying) start()
}
/**
* 主动预加载(推荐在页面 onShow 时调用,让用户进页面就完成预热)
* 同时再次设置 setInnerAudioOption,防 App.vue 全局设置失效(iOS 静音键)
@@ -214,6 +269,7 @@ export function useMetronome(options: MetronomeOptions = {}) {
stop,
setBpm,
setAccentEvery,
updateAudioSrc,
preload,
}
}
@@ -1,213 +0,0 @@
import { ref, onUnmounted } from 'vue'
/**
* 节拍器后台播放专版 - 基于 BackgroundAudioManager
*
* 解决 InnerAudioContext 在 iOS 微信小程序中无法后台播放的硬限制:
* - iOS 真机切到后台/锁屏 InnerAudioContext 必被挂起,requiredBackgroundModes 无效
* - BackgroundAudioManager 是微信唯一支持 iOS 真后台/锁屏播放的音频 API
*
* 取舍:
* - BgAudio 是全局单例,一次只能播一个音频,不适合短促 click 高频重复
* - 因此提前用 ffmpeg 合成 3 个档位的"完整节拍循环"音轨(80/110/130 BPM × 2 拍)
* 每个 mp3 是 5~6 秒无缝循环,设 onEnded 重赋 src 实现永久循环
*
* 副作用:
* - 播放时锁屏/通知栏会显示带封面+暂停按钮的音乐控制条(可被用户从锁屏暂停)
* - 必须声明 requiredBackgroundModes:["audio"](已配在 manifest+pages)
* - 切档位有 200~500ms 切换延迟,但用户主动操作时可接受
*/
export type LoopId = 'slow' | 'normal' | 'brisk'
export interface LoopPreset {
id: LoopId
bpm: number
accent: number
src: string
label: string
}
/**
* 重要:微信 BackgroundAudioManager.src 只接受 http/https 网络流,不能是包内资源
* 所以必须把音频上传到 CDN(腾讯云 COS),并在小程序后台加 downloadFile 合法域名
*
* 当前线上文件(2026-05-26 上传到 cos.ap-guangzhou):
* - loop_80bpm_2.mp3 循环音轨 慢走档
* - loop_110bpm_2.mp3 循环音轨 健走档
* - loop_130bpm_2.mp3 循环音轨 快走档
*
* 历史源文件(本地 training/static/audio/*.mp3) 已删除以减小包体积,
* 如果以后要重新合成,先从 COS 下回来再用 ffmpeg 处理
*/
export const LOOP_PRESETS: readonly LoopPreset[] = [
{
id: 'slow',
bpm: 80,
accent: 2,
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105129dcbc50112.mp3',
label: '慢走 80 BPM',
},
{
id: 'normal',
bpm: 110,
accent: 2,
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051284f5b04928.mp3',
label: '健走 110 BPM',
},
{
id: 'brisk',
bpm: 130,
accent: 2,
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105128164d77281.mp3',
label: '快走 130 BPM',
},
]
/**
* 锁屏控制条封面
* 暂时留空,微信会显示默认音乐图标,等以后有正式品牌图再上传到 COS 后填入这里
* 注意:URL 必须是 https,且域名要加进小程序后台 downloadFile 合法域名
*/
const COVER_URL = ''
export function useMetronomeBg() {
const isPlaying = ref<boolean>(false)
const currentLoop = ref<LoopId | null>(null)
/* @dcloudio/types 没有 BackgroundAudioManager 类型,直接 any */
let bgm: any = null
/* 当前期望的 src,onEnded 时用它重新赋值实现循环 */
let desiredSrc = ''
/* 懒初始化 BackgroundAudioManager + 绑定事件
BgAudio 是全局单例,跨页面共享,只能在第一次需要时初始化 */
const ensureBgm = () => {
if (bgm) return bgm
const m = uni.getBackgroundAudioManager()
/* 必填 metadata,缺一会报错或不显示锁屏控制条 */
m.title = '节拍器'
m.epname = '甄养堂 · 健走'
m.singer = '健走配速'
if (COVER_URL) m.coverImgUrl = COVER_URL
m.webUrl = ''
m.onPlay(() => {
isPlaying.value = true
})
m.onPause(() => {
/* 用户从锁屏控制条点暂停,同步 UI 状态 */
isPlaying.value = false
})
m.onStop(() => {
isPlaying.value = false
currentLoop.value = null
})
/* 实现无限循环:每段 mp3 播完时立即重赋 src 再次播放
BgAudio 没有原生 loop 属性,只能用这招 */
m.onEnded(() => {
if (desiredSrc && isPlaying.value) {
try {
m.src = desiredSrc
} catch (_) {}
}
})
m.onError((err) => {
console.error('[BgAudio] error:', err)
isPlaying.value = false
uni.showToast({
title: '音频播放失败,请重试',
icon: 'none',
duration: 2000,
})
})
bgm = m
return m
}
/**
* 切到指定档位并开始播放
* 如果已经在播同一档位 → 切到 pause/play 状态
* 如果在播别的档位 → 切换 src(有 200~500ms 延迟)
*/
const playLoop = (id: LoopId) => {
const preset = LOOP_PRESETS.find((p) => p.id === id)
if (!preset) return
const m = ensureBgm()
/* 同档位再点一下 = 暂停 */
if (currentLoop.value === id && isPlaying.value) {
m.pause()
return
}
/* 切换档位或从暂停恢复 */
currentLoop.value = id
desiredSrc = preset.src
/* 重设 title 让锁屏控制条显示当前档位 */
m.title = `节拍器 · ${preset.label}`
/* 赋值 src 会自动播放(微信 API 设计如此) */
m.src = preset.src
/* isPlaying 由 onPlay 回调置 true */
}
const pause = () => {
if (bgm && isPlaying.value) {
bgm.pause()
}
}
const resume = () => {
if (bgm && !isPlaying.value && desiredSrc) {
/* 从暂停态恢复:直接 play 即可 */
try {
bgm.play()
} catch (_) {
/* 部分基础库 play() 不可用时,重赋 src */
bgm.src = desiredSrc
}
}
}
/**
* 完全停止 + 清掉锁屏控制条
* 注意 BgAudio 是全局单例,stop 会影响所有页面共享的实例
*/
const stop = () => {
if (bgm) {
try {
bgm.stop()
} catch (_) {}
}
currentLoop.value = null
desiredSrc = ''
isPlaying.value = false
}
/* hook 卸载时不主动 stop,因为用户离开节拍器页时
仍希望音乐持续(走在路上拿出手机切别的页面应该不停)
真正停止的责任在 metronome.vue 的退出按钮里 */
onUnmounted(() => {
/* 仅解绑回调? BgAudio 是全局单例,我们的回调还在,
不解会导致内存中保留无用引用,但回调里都判断了 isPlaying,
且新页面再 ensureBgm 时会覆盖回调,可接受 */
})
return {
isPlaying,
currentLoop,
playLoop,
pause,
resume,
stop,
LOOP_PRESETS,
}
}
+32
View File
@@ -0,0 +1,32 @@
/**
* TTS 语音鼓励 Hook
* 用于在捏碎特效触发时播放语音鼓励
*
* 当前状态:预留接口,待集成 MiMo TTS
* TODO: 集成 MiMo TTS SDK
*/
export function useTTS() {
/**
* 播放语音鼓励
* 随机播放:"加油"、"太棒了"、"继续"、"很好"等
*
* 实现计划:
* 1. 引入 MiMo TTS SDK
* 2. 配置语音库(鼓励短语列表)
* 3. 随机选择短语并播放
*/
const playEncouragement = () => {
// TODO: 集成 MiMo TTS
// 示例实现:
// const phrases = ['加油', '太棒了', '继续', '很好', '坚持']
// const phrase = phrases[Math.floor(Math.random() * phrases.length)]
// mimoTTS.speak(phrase)
console.log('[TTS] 播放语音鼓励(待实现 MiMo TTS 集成)')
}
return {
playEncouragement
}
}
@@ -1,7 +1,11 @@
import { ref, computed, onUnmounted } from 'vue'
import { calculateCalories, getFoodComparison } from '../utils/calorie'
import type { FoodComparison } from '../utils/calorie'
export type SessionPhase = 'idle' | 'training' | 'resting' | 'done'
export type CrushItemType = 'egg' | 'walnut' | 'can' | 'balloon'
export interface TrainingSessionConfig {
sets: number
reps: number
@@ -13,9 +17,35 @@ export interface TrainingSessionConfig {
onExitRest?: () => void
onDone?: () => void
onCountdownTick?: (remainingSec: number) => void
onCrushTrigger?: (itemType: CrushItemType) => void
}
export function useTrainingSession() {
export interface TrainingStats {
totalSets: number
totalReps: number
durationSeconds: number
calories: number
foodComparison: FoodComparison
}
// 捏碎特效配置
const CRUSH_TRIGGER_INTERVAL_MIN = 8 // 最小触发间隔
const CRUSH_TRIGGER_INTERVAL_MAX = 12 // 最大触发间隔
const CRUSH_ITEMS: CrushItemType[] = ['egg', 'walnut', 'can', 'balloon']
// 随机触发判断(平均每 8-12 次触发一次)
function shouldTriggerCrush(): boolean {
// 使用随机间隔的平均值:(8 + 12) / 2 = 10
const avgInterval = (CRUSH_TRIGGER_INTERVAL_MIN + CRUSH_TRIGGER_INTERVAL_MAX) / 2
return Math.random() < (1 / avgInterval)
}
// 随机选择物品
function randomCrushItem(): CrushItemType {
return CRUSH_ITEMS[Math.floor(Math.random() * CRUSH_ITEMS.length)]
}
export function useTrainingSession(bpm?: number) {
const phase = ref<SessionPhase>('idle')
const currentSet = ref<number>(0)
const currentRep = ref<number>(0)
@@ -24,6 +54,9 @@ export function useTrainingSession() {
let config: TrainingSessionConfig | null = null
let restTimer: ReturnType<typeof setInterval> | null = null
let crushTimers: ReturnType<typeof setTimeout>[] = [] // 存储所有捏碎特效的 setTimeout
let currentBpm = bpm ?? 80
let startTime = 0 // 训练开始时间戳
const totalReps = computed(() => config?.reps ?? 0)
const totalSets = computed(() => config?.sets ?? 0)
@@ -33,6 +66,10 @@ export function useTrainingSession() {
const isResting = computed(() => phase.value === 'resting')
const isDone = computed(() => phase.value === 'done')
const setBpm = (newBpm: number) => {
currentBpm = newBpm
}
const start = (cfg: TrainingSessionConfig) => {
config = cfg
phase.value = 'training'
@@ -40,6 +77,27 @@ export function useTrainingSession() {
currentRep.value = 0
beatCounter.value = 0
restRemainingSec.value = 0
startTime = Date.now() // 记录开始时间
}
// 获取训练统计数据
const getStats = (): TrainingStats => {
const durationSeconds = Math.floor((Date.now() - startTime) / 1000)
const actualTotalReps = (config?.sets ?? 0) * (config?.reps ?? 0)
const calories = calculateCalories({
totalReps: actualTotalReps,
bpm: currentBpm,
userWeight: 60, // 默认 60kg
})
return {
totalSets: config?.sets ?? 0,
totalReps: actualTotalReps,
durationSeconds,
calories,
foodComparison: getFoodComparison(calories),
}
}
const onBeat = () => {
@@ -52,6 +110,17 @@ export function useTrainingSession() {
currentRep.value++
config.onRepComplete?.(currentRep.value, totalReps.value)
// 随机触发捏碎特效
if (shouldTriggerCrush() && config.onCrushTrigger) {
const item = randomCrushItem()
// 延迟到动画峰值(50%)触发
const repDurationMs = (60000 / currentBpm) * beatsPerRep.value
const timer = setTimeout(() => {
config?.onCrushTrigger?.(item)
}, repDurationMs / 2)
crushTimers.push(timer)
}
if (currentRep.value >= totalReps.value) {
config.onSetComplete?.(currentSet.value, totalSets.value)
@@ -104,6 +173,10 @@ export function useTrainingSession() {
clearInterval(restTimer)
restTimer = null
}
// 清理所有待触发的捏碎特效
crushTimers.forEach(timer => clearTimeout(timer))
crushTimers = []
phase.value = 'idle'
currentSet.value = 0
currentRep.value = 0
@@ -113,6 +186,8 @@ export function useTrainingSession() {
onUnmounted(() => {
if (restTimer) clearInterval(restTimer)
crushTimers.forEach(timer => clearTimeout(timer))
crushTimers = []
})
return {
@@ -129,5 +204,7 @@ export function useTrainingSession() {
stop,
skipRest,
onBeat,
setBpm,
getStats,
}
}
@@ -0,0 +1,427 @@
<template>
<view class="crush-canvas-container">
<canvas
type="2d"
id="crush"
class="crush-canvas"
></canvas>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
// ============================================================
// 类型定义
// ============================================================
type CrushItemType = 'egg' | 'walnut' | 'can' | 'balloon'
type ParticleShape = 'circle' | 'square' | 'triangle' | 'rect'
interface Particle {
x: number
y: number
vx: number
vy: number
size: number
color: string
shape: ParticleShape
rotation: number
rotationSpeed: number
life: number
gravity: number
}
interface Shockwave {
x: number
y: number
radius: number
maxRadius: number
color: string
life: number
}
interface CrushItemConfig {
colors: string[]
shapes: ParticleShape[]
particles: number
speedMin: number
speedMax: number
sizeMin: number
sizeMax: number
gravity: number
shockwaveColor: string
}
// ============================================================
// 物品差异化配置
// ============================================================
const CRUSH_ITEMS: Record<CrushItemType, CrushItemConfig> = {
egg: {
colors: ['#FFEB3B', '#FFF59D', '#FFFFFF', '#FFC107'],
shapes: ['circle', 'circle', 'circle'],
particles: 18,
speedMin: 3,
speedMax: 5.5,
sizeMin: 4,
sizeMax: 9,
gravity: 0.32,
shockwaveColor: 'rgba(255, 235, 59, 0.4)',
},
walnut: {
colors: ['#5D4037', '#795548', '#8D6E63', '#3E2723'],
shapes: ['square', 'triangle', 'rect'],
particles: 20,
speedMin: 2.5,
speedMax: 4.5,
sizeMin: 4,
sizeMax: 8,
gravity: 0.4,
shockwaveColor: 'rgba(141, 110, 99, 0.5)',
},
can: {
colors: ['#90A4AE', '#CFD8DC', '#B0BEC5', '#78909C', '#ECEFF1'],
shapes: ['rect', 'rect', 'triangle'],
particles: 22,
speedMin: 4,
speedMax: 6.5,
sizeMin: 3,
sizeMax: 10,
gravity: 0.35,
shockwaveColor: 'rgba(176, 190, 197, 0.6)',
},
balloon: {
colors: ['#FF5252', '#FF4081', '#E040FB', '#7C4DFF', '#536DFE', '#448AFF', '#FFEB3B', '#69F0AE'],
shapes: ['circle', 'triangle'],
particles: 24,
speedMin: 4.5,
speedMax: 7,
sizeMin: 3,
sizeMax: 6,
gravity: 0.12,
shockwaveColor: 'rgba(255, 64, 129, 0.5)',
},
}
const PHYSICS = {
AIR_RESISTANCE: 0.985,
LIFE_DECAY: 0.02,
SHOCKWAVE_DECAY: 0.04,
}
// ============================================================
// 状态
// ============================================================
const canvasNode = ref<any>(null)
const ctx = ref<any>(null)
const canvasWidth = ref<number>(240)
const canvasHeight = ref<number>(240)
const dpr = ref<number>(1)
const rafId = ref<number | null>(null)
const renderRunning = ref<boolean>(false)
const particles = ref<Particle[]>([])
const shockwaves = ref<Shockwave[]>([])
const instance = getCurrentInstance()
onMounted(() => {
initCanvas()
})
onBeforeUnmount(() => {
cleanup()
})
// ============================================================
// 初始化 Canvas 2D
// ============================================================
function initCanvas() {
setTimeout(() => {
if (!instance) return
const query = uni.createSelectorQuery().in(instance)
query.select('#crush')
.fields({ node: true, size: true } as any)
.exec((res: any[]) => {
if (!res || !res[0] || !res[0].node) {
// 降级:如果获取不到 node(非微信平台),不渲染
console.warn('[crush-canvas] canvas 2d not supported on this platform')
return
}
const canvas = res[0].node
const width = res[0].width
const height = res[0].height
// 获取设备像素比
const systemInfo = uni.getSystemInfoSync()
const pixelRatio = systemInfo.pixelRatio || 1
// 设置 canvas 物理像素尺寸(高清)
canvas.width = width * pixelRatio
canvas.height = height * pixelRatio
// 获取 2D 上下文
const context = canvas.getContext('2d')
context.scale(pixelRatio, pixelRatio)
// 保存状态
canvasNode.value = canvas
ctx.value = context
canvasWidth.value = width
canvasHeight.value = height
dpr.value = pixelRatio
})
}, 200)
}
// ============================================================
// 触发捏碎特效
// ============================================================
function triggerCrush(itemType: CrushItemType) {
if (!ctx.value || !canvasNode.value) return
const item = CRUSH_ITEMS[itemType]
if (!item) return
const centerX = canvasWidth.value / 2
const centerY = canvasHeight.value / 2
// 1. 生成双层冲击波
shockwaves.value.push({
x: centerX,
y: centerY,
radius: 10,
maxRadius: Math.min(canvasWidth.value, canvasHeight.value) * 0.6,
color: item.shockwaveColor,
life: 1.0,
})
shockwaves.value.push({
x: centerX,
y: centerY,
radius: 4,
maxRadius: Math.min(canvasWidth.value, canvasHeight.value) * 0.4,
color: item.shockwaveColor,
life: 1.0,
})
// 2. 生成粒子(径向扩散)
const particleCount = item.particles
for (let i = 0; i < particleCount; i++) {
const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.6
const speed = item.speedMin + Math.random() * (item.speedMax - item.speedMin)
const size = item.sizeMin + Math.random() * (item.sizeMax - item.sizeMin)
const color = item.colors[Math.floor(Math.random() * item.colors.length)]
const shape = item.shapes[Math.floor(Math.random() * item.shapes.length)]
particles.value.push({
x: centerX,
y: centerY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
size,
color,
shape,
rotation: Math.random() * Math.PI * 2,
rotationSpeed: (Math.random() - 0.5) * 0.3,
life: 1.0,
gravity: item.gravity,
})
}
ensureRenderLoop()
}
// ============================================================
// 渲染循环(使用 requestAnimationFrame,更准确高效)
// ============================================================
function ensureRenderLoop() {
if (renderRunning.value) return
renderRunning.value = true
renderLoop()
}
function renderLoop() {
if (!canvasNode.value || !ctx.value) {
renderRunning.value = false
return
}
// 更新粒子物理
for (let i = particles.value.length - 1; i >= 0; i--) {
const p = particles.value[i]
p.vy += p.gravity
p.vx *= PHYSICS.AIR_RESISTANCE
p.vy *= PHYSICS.AIR_RESISTANCE
p.x += p.vx
p.y += p.vy
p.rotation += p.rotationSpeed
p.life -= PHYSICS.LIFE_DECAY
const outOfBounds =
p.x < -50 || p.x > canvasWidth.value + 50 ||
p.y < -50 || p.y > canvasHeight.value + 50
if (p.life <= 0 || outOfBounds) {
particles.value.splice(i, 1)
}
}
// 更新冲击波
for (let i = shockwaves.value.length - 1; i >= 0; i--) {
const sw = shockwaves.value[i]
sw.radius += (sw.maxRadius - sw.radius) * 0.15
sw.life -= PHYSICS.SHOCKWAVE_DECAY
if (sw.life <= 0) {
shockwaves.value.splice(i, 1)
}
}
draw()
// 决定是否继续
if (particles.value.length > 0 || shockwaves.value.length > 0) {
// canvas 2d 模式下使用 canvas.requestAnimationFrame
rafId.value = canvasNode.value.requestAnimationFrame(renderLoop) as unknown as number
} else {
rafId.value = null
renderRunning.value = false
}
}
// ============================================================
// 绘制
// ============================================================
function draw() {
const c = ctx.value
const w = canvasWidth.value
const h = canvasHeight.value
// 清空画布(透明)
c.clearRect(0, 0, w, h)
// 1. 绘制冲击波(底层)
shockwaves.value.forEach(sw => {
c.beginPath()
c.arc(sw.x, sw.y, sw.radius, 0, Math.PI * 2)
c.strokeStyle = applyAlphaToRgba(sw.color, sw.life)
c.lineWidth = 3 * sw.life
c.stroke()
})
// 2. 绘制粒子(上层)
particles.value.forEach(p => {
const alpha = p.life
const currentSize = p.size * (0.6 + p.life * 0.4)
c.fillStyle = hexToRgba(p.color, alpha)
drawParticleShape(c, p, currentSize)
})
}
function drawParticleShape(c: any, p: Particle, size: number) {
switch (p.shape) {
case 'circle':
c.beginPath()
c.arc(p.x, p.y, size, 0, Math.PI * 2)
c.fill()
break
case 'square':
c.save()
c.translate(p.x, p.y)
c.rotate(p.rotation)
c.fillRect(-size, -size, size * 2, size * 2)
c.restore()
break
case 'rect':
c.save()
c.translate(p.x, p.y)
c.rotate(p.rotation)
c.fillRect(-size * 1.5, -size * 0.5, size * 3, size)
c.restore()
break
case 'triangle':
c.save()
c.translate(p.x, p.y)
c.rotate(p.rotation)
c.beginPath()
c.moveTo(0, -size)
c.lineTo(size, size)
c.lineTo(-size, size)
c.closePath()
c.fill()
c.restore()
break
}
}
// ============================================================
// 工具函数
// ============================================================
function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
function applyAlphaToRgba(rgba: string, alphaMultiplier: number): string {
const match = rgba.match(/rgba?\(([^)]+)\)/)
if (!match) return rgba
const parts = match[1].split(',').map(s => s.trim())
const r = parts[0]
const g = parts[1]
const b = parts[2]
const a = parts[3] ? parseFloat(parts[3]) : 1
return `rgba(${r}, ${g}, ${b}, ${a * alphaMultiplier})`
}
// ============================================================
// 清理
// ============================================================
function cleanup() {
if (rafId.value !== null && canvasNode.value) {
try {
canvasNode.value.cancelAnimationFrame(rafId.value)
} catch (_) {}
rafId.value = null
}
renderRunning.value = false
particles.value = []
shockwaves.value = []
}
defineExpose({
triggerCrush,
})
</script>
<style scoped>
.crush-canvas-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
}
.crush-canvas {
display: block;
width: 100%;
height: 100%;
background: transparent;
}
</style>
@@ -24,9 +24,12 @@
</view>
</view>
<!-- 握力环缩放 -->
<view v-else-if="animationType === 'grip'" class="grip-ring">
<view class="grip-ring-inner">{{ icon }}</view>
<!-- 握力环缩放 + 粒子特效 -->
<view v-else-if="animationType === 'grip'" class="grip-container">
<view class="grip-ring">
<view class="grip-ring-inner">{{ icon }}</view>
</view>
<CrushCanvas ref="crushCanvasRef" />
</view>
<!-- 卷腹身体折叠 -->
@@ -38,8 +41,9 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ref, computed } from 'vue'
import type { AnimationType } from '../exercises'
import CrushCanvas from './crush-canvas.vue'
interface Props {
animationType: AnimationType
@@ -62,6 +66,17 @@ const cssVars = computed(() => {
'--anim-state': props.isPlaying ? 'running' : 'paused',
} as Record<string, string>
})
const crushCanvasRef = ref()
// 暴露捏碎特效触发方法(供外部测试)
const triggerCrushEffect = (itemType: 'egg' | 'walnut' | 'can' | 'balloon') => {
crushCanvasRef.value?.triggerCrush(itemType)
}
defineExpose({
triggerCrushEffect
})
</script>
<style lang="scss" scoped>
@@ -208,6 +223,12 @@ const cssVars = computed(() => {
}
/* ===== 握力环 ===== */
.grip-container {
position: relative;
width: 240rpx;
height: 240rpx;
}
.grip-ring {
width: 240rpx;
height: 240rpx;
@@ -0,0 +1,318 @@
<template>
<view class="grip-ring-canvas">
<!-- 环境氛围:中心放射光晕(不形成边界,真正融入) -->
<view class="ambient-glow" />
<!-- 捏碎特效画布 -->
<CrushCanvas ref="crushCanvasRef" />
<!-- 握力环容器 -->
<view class="ring-container" :class="{ playing: isPlaying }" @click="onButtonClick">
<!-- 外发光(只在播放时启用) -->
<view class="ring-aura" />
<!-- 握力环主体 -->
<view class="grip-ring">
<view class="ring-outer-circle" />
<view class="ring-highlight" />
<view class="ring-inner-circle" />
</view>
<!-- 中心提示 (仅未开始时显示) -->
<view v-if="!isPlaying" class="center-hint">
<view class="hint-icon">
<view class="play-triangle" />
</view>
<text class="hint-text">点击开始</text>
</view>
<!-- BPM 显示 (播放时显示) -->
<view v-else class="bpm-display">
<text class="bpm-value">{{ bpm }}</text>
<text class="bpm-label">BPM</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import CrushCanvas from './crush-canvas.vue'
interface Props {
bpm: number
isPlaying: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
togglePlay: []
}>()
const crushCanvasRef = ref()
const onButtonClick = () => {
emit('togglePlay')
}
// 暴露捏碎特效触发方法
const triggerCrushEffect = (itemType: 'egg' | 'walnut' | 'can' | 'balloon') => {
crushCanvasRef.value?.triggerCrush(itemType)
}
defineExpose({
triggerCrushEffect
})
</script>
<style lang="scss" scoped>
/* ============================================================
* 画布容器 - 完全融入页面,无边界
* ============================================================ */
.grip-ring-canvas {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
/* 与页面背景一致,无可见边界 */
background: #f8fafc;
}
/* 环境氛围 - 中心大放射光晕,营造空间感 */
.ambient-glow {
position: absolute;
width: 1400rpx;
height: 1400rpx;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
background:
radial-gradient(circle,
rgba(255, 255, 255, 1) 0%,
rgba(241, 245, 249, 0.6) 25%,
rgba(226, 232, 240, 0.3) 50%,
transparent 75%);
pointer-events: none;
}
/* ============================================================
* 握力环容器 - 主视觉
* ============================================================ */
.ring-container {
position: relative;
width: 640rpx;
height: 640rpx;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 1;
}
/* 外发光 - 只在播放时显示(节省 GPU) */
.ring-aura {
position: absolute;
width: 700rpx;
height: 700rpx;
border-radius: 50%;
background: radial-gradient(circle,
rgba(244, 114, 182, 0.45) 0%,
rgba(244, 114, 182, 0.15) 35%,
rgba(244, 114, 182, 0) 70%);
opacity: 0;
transition: opacity 0.3s ease;
will-change: transform, opacity;
}
.ring-container.playing .ring-aura {
opacity: 1;
animation: aura-breath 2s ease-in-out infinite;
}
@keyframes aura-breath {
0%, 100% {
transform: scale(0.95);
opacity: 0.7;
}
50% {
transform: scale(1.08);
opacity: 1;
}
}
/* ============================================================
* 握力环主体 - 大尺寸沉浸感
* ============================================================ */
.grip-ring {
position: relative;
width: 520rpx;
height: 520rpx;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
will-change: transform;
}
.ring-container:active .grip-ring {
transform: scale(0.96);
}
/* 外圈 - 粉色渐变(精致质感) */
.ring-outer-circle {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
background: linear-gradient(135deg,
#fda4af 0%,
#f472b6 50%,
#ec4899 100%);
box-shadow:
0 24rpx 64rpx rgba(236, 72, 153, 0.4),
0 12rpx 32rpx rgba(244, 114, 182, 0.3),
inset 0 -20rpx 40rpx rgba(190, 24, 93, 0.35),
inset 0 20rpx 40rpx rgba(255, 255, 255, 0.5);
}
/* 顶部高光 - 模拟材质感 */
.ring-highlight {
position: absolute;
width: 420rpx;
height: 120rpx;
top: 40rpx;
border-radius: 50%;
background: radial-gradient(ellipse,
rgba(255, 255, 255, 0.6) 0%,
rgba(255, 255, 255, 0) 70%);
pointer-events: none;
}
/* 内圈中空 */
.ring-inner-circle {
position: absolute;
width: 280rpx;
height: 280rpx;
border-radius: 50%;
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
box-shadow:
inset 0 8rpx 20rpx rgba(190, 24, 93, 0.3),
inset 0 -2rpx 8rpx rgba(255, 255, 255, 0.8);
}
/* 播放时的挤压动画(只影响 transform,GPU 加速) */
.ring-container.playing .grip-ring {
animation: squeeze-ring 1s ease-in-out infinite;
}
@keyframes squeeze-ring {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(0.93);
}
}
/* ============================================================
* 中心提示 (未开始)
* ============================================================ */
.center-hint {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
pointer-events: none;
animation: hint-fade-in 0.3s ease;
}
@keyframes hint-fade-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.hint-icon {
width: 108rpx;
height: 108rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.98);
border-radius: 50%;
box-shadow:
0 12rpx 32rpx rgba(236, 72, 153, 0.35),
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.9);
animation: hint-bounce 2s ease-in-out infinite;
will-change: transform;
}
.play-triangle {
width: 0;
height: 0;
border-left: 36rpx solid #ec4899;
border-top: 24rpx solid transparent;
border-bottom: 24rpx solid transparent;
margin-left: 10rpx;
}
@keyframes hint-bounce {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.06);
}
}
.hint-text {
font-size: 28rpx;
color: #be185d;
font-weight: 700;
letter-spacing: 4rpx;
text-shadow: 0 2rpx 8rpx rgba(255, 255, 255, 0.9);
}
/* ============================================================
* BPM 显示 (播放中)
* ============================================================ */
.bpm-display {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4rpx;
pointer-events: none;
}
.bpm-value {
font-size: 88rpx;
color: #be185d;
font-weight: 900;
line-height: 1;
letter-spacing: -2rpx;
text-shadow: 0 2rpx 8rpx rgba(255, 255, 255, 0.9);
font-variant-numeric: tabular-nums;
}
.bpm-label {
font-size: 22rpx;
color: #ec4899;
font-weight: 700;
letter-spacing: 6rpx;
margin-top: 2rpx;
}
</style>
@@ -0,0 +1,383 @@
<template>
<view class="walker-canvas-container">
<canvas
canvas-id="walker"
id="walker"
class="walker-canvas"
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchCancel"
></canvas>
</view>
</template>
<script>
export default {
props: {
isPlaying: {
type: Boolean,
default: false
},
bpm: {
type: Number,
default: 110
}
},
data() {
return {
canvasWidth: 320,
canvasHeight: 400,
ctx: null,
timer: null,
renderRunning: false,
// Interaction State (Spring Physics)
coreScale: 1,
coreTargetScale: 1,
coreVelocity: 0,
pressed: false,
// Animation Physics (Time)
time: 0,
lastFrameTime: 0,
// Metronome State
lastBeatTime: 0,
// Energy Waves
waveAmplitudeMultiplier: 1,
// Shockwaves
shockwaves: [],
// Rotation for outer ring
ringAngle: 0
};
},
mounted() {
this.initCanvas();
},
beforeUnmount() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.renderRunning = false;
},
watch: {
isPlaying(newVal) {
if (newVal) {
this.waveAmplitudeMultiplier = 1;
this.ensureRenderLoop();
}
}
},
methods: {
initCanvas() {
// Delay to ensure Flexbox layout is fully complete before measuring
setTimeout(() => {
const query = uni.createSelectorQuery().in(this);
query.select('.walker-canvas-container').boundingClientRect(data => {
if (data && data.width > 0) {
this.canvasWidth = data.width;
this.canvasHeight = data.height > 100 ? data.height : data.width * 1.2;
} else {
const sys = uni.getSystemInfoSync();
this.canvasWidth = sys.windowWidth - 30; // approximate padding
this.canvasHeight = this.canvasWidth * 1.2;
}
if (!this.ctx) {
this.ctx = uni.createCanvasContext('walker', this);
this.lastFrameTime = Date.now();
this.ensureRenderLoop();
}
}).exec();
}, 100);
},
// Expose a method to be called from parent when a beat hits
triggerBeat() {
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
// Add a shockwave
this.shockwaves.push({
radius: coreRadius,
maxRadius: coreRadius * 2.2,
opacity: 1
});
// Spike the wave amplitude
this.waveAmplitudeMultiplier = 2.5;
// Slight pulse to the core
this.coreVelocity -= 0.05;
this.ensureRenderLoop();
},
onTouchStart(e) {
const touch = e.touches[0];
if (!touch) return;
// Center
const cx = this.canvasWidth / 2;
const cy = this.canvasHeight / 2 - 30;
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
const dx = touch.x - cx;
const dy = touch.y - cy;
const dist = Math.sqrt(dx*dx + dy*dy);
// Hit box slightly larger than the visual circle
if (dist <= coreRadius + 40) {
this.pressed = true;
this.coreTargetScale = 0.9;
this.ensureRenderLoop();
}
},
onTouchMove(e) {
if (!this.pressed) return;
const touch = e.touches[0];
if (!touch) return;
const cx = this.canvasWidth / 2;
const cy = this.canvasHeight / 2 - 30;
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
const dx = touch.x - cx;
const dy = touch.y - cy;
const dist = Math.sqrt(dx*dx + dy*dy);
// Slid out of the hit box - cancel the press
if (dist > coreRadius + 40) {
this.pressed = false;
this.coreTargetScale = 1;
}
},
onTouchEnd() {
// Unconditionally reset the visual scale
this.coreTargetScale = 1;
// Only emit toggle when the press has not been cancelled by drag-out
if (this.pressed) {
this.$emit('toggle-play');
}
this.pressed = false;
},
onTouchCancel() {
// System interruption (incoming call, notification pull-down, etc.):
// reset visuals only — do NOT count this as a user toggle intent.
this.coreTargetScale = 1;
this.pressed = false;
},
ensureRenderLoop() {
if (this.renderRunning) return;
this.renderRunning = true;
this.renderLoop();
},
renderLoop() {
// Use a fixed time delta to prevent rubber-banding/jitter on unstable JS timers
const fixedDt = 0.016;
if (this.isPlaying) {
this.time += fixedDt;
} else {
this.time += fixedDt * 0.2; // slow drift when paused
}
// Spring Physics for Core Scale
const tension = 120;
const friction = 12;
const force = (this.coreTargetScale - this.coreScale) * tension;
this.coreVelocity += force * fixedDt;
this.coreVelocity *= Math.exp(-friction * fixedDt);
this.coreScale += this.coreVelocity * fixedDt;
// Decay wave amplitude back to 1
if (this.waveAmplitudeMultiplier > 1) {
this.waveAmplitudeMultiplier -= fixedDt * 3;
if (this.waveAmplitudeMultiplier < 1) this.waveAmplitudeMultiplier = 1;
} else if (!this.isPlaying && this.waveAmplitudeMultiplier > 0.1) {
this.waveAmplitudeMultiplier -= fixedDt * 2;
if (this.waveAmplitudeMultiplier < 0.1) this.waveAmplitudeMultiplier = 0.1;
}
this.draw();
this.ctx.draw(false);
// Decide whether to continue the render loop. Short-circuit on isPlaying first for perf.
const shouldContinue =
this.isPlaying ||
this.shockwaves.length > 0 ||
Math.abs(this.coreScale - this.coreTargetScale) >= 0.001 ||
Math.abs(this.coreVelocity) >= 0.001 ||
this.waveAmplitudeMultiplier > 0.11;
if (shouldContinue) {
// Polyfill requestAnimationFrame for uni-app
this.timer = setTimeout(() => {
this.renderLoop();
}, 1000 / 60);
} else {
this.timer = null;
this.renderRunning = false;
}
},
draw() {
const ctx = this.ctx;
const W = this.canvasWidth;
const H = this.canvasHeight;
// Clear entire canvas to ensure it is completely transparent and blends with the page background
ctx.clearRect(0, 0, W, H);
// Dynamic majestic sizing to fill the huge screen area
const coreRadius = Math.min(W, H) * 0.32; // Make the central circle MASSIVE
const ringRadius = coreRadius + 18;
const cx = W / 2;
const cy = H / 2 - 30; // Slightly above center
// 3. Draw Shockwaves (Ripples)
for (let i = this.shockwaves.length - 1; i >= 0; i--) {
const sw = this.shockwaves[i];
sw.radius += (sw.maxRadius - sw.radius) * 0.08;
sw.opacity -= 0.03;
if (sw.opacity <= 0) {
this.shockwaves.splice(i, 1);
continue;
}
ctx.beginPath();
ctx.arc(cx, cy, sw.radius, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(16, 185, 129, ${sw.opacity * 0.5})`;
ctx.lineWidth = 3;
ctx.stroke();
}
// 4. Draw Energy Waves (Bottom area)
this.drawWaves(ctx, W, H);
// 5. Draw Interactive BPM Core
ctx.save();
ctx.translate(cx, cy);
ctx.scale(this.coreScale, this.coreScale);
// The Core Button (Emerald Gradient)
ctx.setShadow(0, 12, 24, 'rgba(16, 185, 129, 0.3)');
const coreGrad = ctx.createLinearGradient(-coreRadius, -coreRadius, coreRadius, coreRadius);
coreGrad.addColorStop(0, '#34d399'); // Light Mint
coreGrad.addColorStop(1, '#047857'); // Deep Emerald
ctx.beginPath();
ctx.arc(0, 0, coreRadius, 0, Math.PI * 2);
ctx.fillStyle = coreGrad;
ctx.fill();
// Clear shadow for internal elements
ctx.setShadow(0, 0, 0, 'transparent');
// Inner Subtle Highlight (Glass edge)
ctx.beginPath();
ctx.arc(0, 0, coreRadius - 2, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)';
ctx.lineWidth = 2;
ctx.stroke();
// Text - BPM Number (MASSIVE font)
const fontSize = Math.floor(coreRadius * 0.7); // Dynamic font size based on radius
ctx.fillStyle = '#ffffff';
ctx.font = `bold ${fontSize}px "DIN Condensed", "Inter", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Removed shadowBlur for text to save low-end GPU performance
ctx.fillText(this.bpm.toString(), 0, -12);
// Text - 'BPM' Label
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
ctx.font = '500 16px "Inter", sans-serif';
ctx.fillText('BPM', 0, coreRadius * 0.3);
// Play/Pause Icon
ctx.fillStyle = '#ffffff';
const iconY = coreRadius * 0.55;
if (this.isPlaying) {
// Pause Icon (Two vertical bars)
ctx.fillRect(-8, iconY - 6, 5, 14);
ctx.fillRect(3, iconY - 6, 5, 14);
} else {
// Play Icon (Triangle)
ctx.beginPath();
ctx.moveTo(-5, iconY - 8);
ctx.lineTo(9, iconY);
ctx.lineTo(-5, iconY + 8);
ctx.closePath();
ctx.fill();
}
ctx.restore();
},
drawWaves(ctx, W, H) {
const baseY = H * 0.82;
const dynamicAmp = H * 0.08;
// Reduced to 2 layers to save GPU/Bridge rendering time and ensure 60fps
const waves = [
{ color: 'rgba(52, 211, 153, 0.3)', speed: 1.5, freq: 0.012, amp: dynamicAmp * 0.7, offset: 0 },
{ color: 'rgba(16, 185, 129, 0.6)', speed: 2.5, freq: 0.015, amp: dynamicAmp, offset: Math.PI }
];
const activeAmp = this.waveAmplitudeMultiplier;
const t = this.time;
waves.forEach(wave => {
ctx.beginPath();
ctx.moveTo(0, H);
ctx.lineTo(0, baseY);
// Increased step size to drastically reduce JS-to-Native bridge commands
const step = 25;
for (let x = 0; x <= W; x += step) {
// Removed edge envelope taper so waves crash cleanly into the edge of the screen
const y = baseY + Math.sin(x * wave.freq + t * wave.speed + wave.offset) * wave.amp * activeAmp;
ctx.lineTo(x, y);
}
// Add one final line to exactly W to ensure flush edge
const finalY = baseY + Math.sin(W * wave.freq + t * wave.speed + wave.offset) * wave.amp * activeAmp;
ctx.lineTo(W, finalY);
ctx.lineTo(W, H);
ctx.closePath();
ctx.fillStyle = wave.color;
ctx.fill();
});
}
}
};
</script>
<style scoped>
.walker-canvas-container {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.walker-canvas {
display: block;
width: 100%;
height: 100%;
background: transparent; /* Pure transparent to blend into page */
}
</style>
+1 -18
View File
@@ -2,13 +2,12 @@ export type AnimationType =
| 'curl'
| 'press'
| 'sidefly'
| 'grip'
| 'crunch'
export interface ExercisePreset {
id: string
name: string
equipment: '哑铃' | '握力环' | '健身环' | '徒手'
equipment: '哑铃' | '健身环' | '徒手'
icon: string
animationType: AnimationType
description: string
@@ -76,22 +75,6 @@ export const EXERCISE_PRESETS: ExercisePreset[] = [
defaultRestSec: 60,
beatsPerRep: 2,
},
{
id: 'grip-ring',
name: '握力环训练',
equipment: '握力环',
icon: '🟢',
animationType: 'grip',
description: '锻炼前臂屈肌群,改善握力',
tips: ['完全握紧停顿 1 秒', '完全张开放松', '左右手交替'],
defaultBpm: 80,
bpmMin: 60,
bpmMax: 120,
defaultSets: 4,
defaultReps: 20,
defaultRestSec: 45,
beatsPerRep: 2,
},
{
id: 'crunch',
name: '集中机(仰卧卷腹)',
File diff suppressed because it is too large Load Diff
+135 -6
View File
@@ -5,8 +5,13 @@
<text class="title">练一练</text>
<text class="subtitle">器械动作跟练</text>
</view>
<view class="header-link" @click="goMetronome">
🎵 节拍器
<view class="header-links">
<view class="header-link" @click="goGripRing">
🟢 握力环
</view>
<view class="header-link" @click="goMetronome">
🎵 节拍器
</view>
</view>
</view>
@@ -39,6 +44,7 @@
<view v-if="selected" class="exercise-detail">
<exercise-anim
ref="exerciseAnimRef"
:animation-type="selected.animationType"
:bpm="bpm"
:beats-per-rep="selected.beatsPerRep"
@@ -126,7 +132,32 @@
<view v-if="isDone" class="status-card done">
<text class="status-title">训练完成 🎉</text>
<text class="status-line"> {{ totalSets }} × {{ totalReps }} </text>
<view v-if="trainingStats" class="completion-content">
<view class="stats-grid">
<view class="stat-item">
<text class="stat-label">组数</text>
<text class="stat-value">{{ trainingStats.totalSets }}</text>
</view>
<view class="stat-item">
<text class="stat-label">次数</text>
<text class="stat-value">{{ trainingStats.totalReps }}</text>
</view>
<view class="stat-item">
<text class="stat-label">时长</text>
<text class="stat-value">{{ formatDuration(trainingStats.durationSeconds) }}</text>
</view>
</view>
<view class="calorie-card">
<text class="calorie-label">消耗卡路里</text>
<text class="calorie-value">{{ trainingStats.calories }} kcal</text>
<text class="food-comparison">
{{ trainingStats.foodComparison.message }}
</text>
</view>
</view>
<view class="action-row">
<button class="btn-primary" @click="onStart">再来一次</button>
</view>
@@ -153,10 +184,13 @@ import { EXERCISE_PRESETS, getPresetById } from './exercises'
import type { ExercisePreset } from './exercises'
import { useMetronome } from '../hooks/useMetronome'
import { useTrainingSession } from '../hooks/useTrainingSession'
import type { CrushItemType, TrainingStats } from '../hooks/useTrainingSession'
import { useVoiceCoach } from '../hooks/useVoiceCoach'
import { useTrainingBgm } from '../hooks/useTrainingBgm'
import { useTTS } from '../hooks/useTTS'
import { formatDuration } from '../utils/calorie'
const equipmentList = ['哑铃', '握力环', '健身环', '徒手'] as const
const equipmentList = ['哑铃', '健身环', '徒手'] as const
type Equipment = (typeof equipmentList)[number]
const currentEquipment = ref<Equipment>('哑铃')
@@ -189,8 +223,11 @@ watch(
const voice = useVoiceCoach()
const bgm = useTrainingBgm()
const tts = useTTS()
const session = useTrainingSession()
const exerciseAnimRef = ref()
const session = useTrainingSession(bpm.value)
const {
currentSet,
currentRep,
@@ -200,15 +237,21 @@ const {
isTraining,
isResting,
isDone,
getStats,
} = session
const trainingStats = ref<TrainingStats | null>(null)
const metronome = useMetronome({
initialBpm: bpm.value,
onBeat: () => session.onBeat(),
})
const { beatIndex, isPlaying: metronomeIsPlaying } = metronome
watch(bpm, (v) => metronome.setBpm(v))
watch(bpm, (v) => {
metronome.setBpm(v)
session.setBpm(v)
})
const onSwitchEquipment = (eq: Equipment) => {
if (isTraining.value || isResting.value) return
@@ -234,6 +277,14 @@ const adjust = (key: 'sets' | 'reps' | 'rest', delta: number) => {
if (key === 'rest') restSec.value = Math.max(0, Math.min(300, restSec.value + delta))
}
const onCrushTrigger = (item: CrushItemType) => {
// 触发粒子特效
exerciseAnimRef.value?.triggerCrushEffect(item)
// 播放语音鼓励
tts.playEncouragement()
}
const onStart = () => {
if (!selected.value) return
uni.setKeepScreenOn({ keepScreenOn: true })
@@ -267,7 +318,10 @@ const onStart = () => {
bgm.switchTo('none')
voice.speakPrompt('good-job')
uni.setKeepScreenOn({ keepScreenOn: false })
// 获取训练统计数据
trainingStats.value = getStats()
},
onCrushTrigger: onCrushTrigger,
})
bgm.switchTo('training')
@@ -289,6 +343,10 @@ const goMetronome = () => {
uni.navigateTo({ url: '/training/pages/metronome' })
}
const goGripRing = () => {
uni.navigateTo({ url: '/training/pages/grip-ring' })
}
onHide(() => {
if (isTraining.value || isResting.value) {
onStop()
@@ -330,6 +388,11 @@ onUnmounted(() => {
margin-top: 6rpx;
}
.header-links {
display: flex;
gap: 12rpx;
}
.header-link {
padding: 12rpx 24rpx;
background: #f3f4f6;
@@ -563,6 +626,7 @@ onUnmounted(() => {
}
&.done {
background: linear-gradient(135deg, #dbeafe, #ede9fe);
gap: 24rpx;
}
.status-title {
@@ -581,6 +645,71 @@ onUnmounted(() => {
}
}
.completion-content {
width: 100%;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.stats-grid {
display: flex;
gap: 16rpx;
width: 100%;
}
.stat-item {
flex: 1;
background: #f8fafc;
border-radius: 16rpx;
padding: 20rpx 12rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.stat-label {
font-size: 24rpx;
color: #64748b;
}
.stat-value {
font-size: 32rpx;
font-weight: 700;
color: #0f172a;
}
.calorie-card {
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
border-radius: 16rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
width: 100%;
}
.calorie-label {
font-size: 24rpx;
color: #065f46;
font-weight: 500;
}
.calorie-value {
font-size: 48rpx;
font-weight: 800;
color: #047857;
line-height: 1.2;
}
.food-comparison {
font-size: 24rpx;
color: #065f46;
margin-top: 4rpx;
}
.tips-card {
background: #fff;
border-radius: 20rpx;
+208 -392
View File
@@ -1,46 +1,32 @@
<template>
<view class="page" :style="rootStyle">
<!-- ===== 主舞台:节拍器圆 ===== -->
<view class="stage" @click="onCenterTap">
<view class="ring r1" :class="{ playing: isPlaying }" />
<view class="ring r2" :class="{ playing: isPlaying }" />
<view class="core" :class="{ playing: isPlaying }">
<text class="bpm-num">{{ currentBpm }}</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>
<!-- ===== 统一运动律动视区 (Canvas 律动舞台) ===== -->
<view class="stage-canvas-wrapper">
<WalkerCanvas
ref="walkerCanvasRef"
:bpm="currentBpm"
:is-playing="isPlaying"
@toggle-play="onCenterTap"
/>
</view>
<!-- ===== 跑步小人(矩形完整显示,跟白底融合) ===== -->
<view class="walker">
<view class="walker-box">
<video
id="runner-video"
class="runner-video"
:src="RUNNER_VIDEO_URL"
:muted="true"
:loop="true"
:autoplay="true"
:show-controls="false"
:show-fullscreen-btn="false"
:show-play-btn="false"
:show-center-play-btn="false"
:enable-progress-gesture="false"
:show-mute-btn="false"
:picture-in-picture-mode="[]"
object-fit="contain"
@error="onVideoError"
@loadedmetadata="onVideoLoaded"
/>
<!-- #ifdef MP-WEIXIN -->
<cover-view class="pip-mask">&nbsp;</cover-view>
<!-- #endif -->
<!-- ===== 音色选择 ===== -->
<view class="sound-selector">
<view
class="sound-option"
:class="{ active: currentSound === 'crisp' }"
@click="onSoundSelect('crisp')"
>
<text class="sound-icon"></text>
<text class="sound-label">清脆</text>
</view>
<view
class="sound-option"
:class="{ active: currentSound === 'wood' }"
@click="onSoundSelect('wood')"
>
<text class="sound-icon">🪵</text>
<text class="sound-label">木鱼</text>
</view>
</view>
@@ -50,276 +36,183 @@
v-for="p in LOOP_PRESETS"
:key="p.id"
class="preset"
:class="{ active: mode === 'preset' && currentLoop === p.id }"
:class="{ active: currentLoop === p.id }"
@click="onPresetTap(p.id)"
>
<text class="preset-icon">{{ presetIcon(p.id) }}</text>
<text class="preset-name">{{ presetName(p.id) }}</text>
<text class="preset-name">{{ p.label }}</text>
<text class="preset-bpm">{{ p.bpm }} BPM</text>
<text class="preset-desc">{{ presetDesc(p.id) }}</text>
<text class="preset-desc">{{ p.desc }}</text>
</view>
</view>
<!-- ===== 自定义节奏(高级折叠区) ===== -->
<view v-if="customExpanded" class="custom-panel">
<view class="custom-warn">
<text class="warn-dot"></text>
<text class="warn-text">前台模式 · 切后台会暂停</text>
<!-- ===== 自定义节奏区域(含标题栏+折叠内容) ===== -->
<view class="custom-section">
<!-- 标题栏(始终显示) -->
<view class="custom-header" @click="onToggleCustom">
<text class="custom-title">{{ customExpanded ? '✕ 收起自定义' : '⚙ 自定义节奏' }}</text>
</view>
<!-- BPM 微调 -->
<view class="row">
<text class="row-label">BPM</text>
<view class="bpm-stepper">
<view class="step-btn" @click="onBpmDelta(-5)">5</view>
<view class="step-btn" @click="onBpmDelta(-1)">1</view>
<view class="step-val">{{ customBpm }}</view>
<view class="step-btn" @click="onBpmDelta(1)">+1</view>
<view class="step-btn" @click="onBpmDelta(5)">+5</view>
<!-- 折叠内容(仅展开时显示) -->
<view v-if="customExpanded" class="custom-content">
<!-- BPM 微调 -->
<view class="row">
<text class="row-label">BPM</text>
<view class="bpm-stepper">
<view class="step-btn" @click="onBpmDelta(-5)">5</view>
<view class="step-btn" @click="onBpmDelta(-1)">1</view>
<view class="step-val">{{ currentBpm }}</view>
<view class="step-btn" @click="onBpmDelta(1)">+1</view>
<view class="step-btn" @click="onBpmDelta(5)">+5</view>
</view>
</view>
<!-- 拍号 -->
<view class="row">
<text class="row-label">拍号</text>
<view class="meter-tabs">
<view
v-for="n in [2, 3, 4]"
:key="n"
class="meter-tab"
:class="{ active: customAccent === n }"
@click="onAccentSelect(n)"
>{{ n }}/4</view>
</view>
</view>
</view>
<!-- 拍号 -->
<view class="row">
<text class="row-label">拍号</text>
<view class="meter-tabs">
<view
v-for="n in [2, 3, 4]"
:key="n"
class="meter-tab"
:class="{ active: customAccent === n }"
@click="onAccentSelect(n)"
>{{ n }}/4</view>
</view>
</view>
</view>
<!-- 底部行:自定义入口 + 提示 -->
<view class="bottom-row">
<text class="bottom-link" @click="onToggleCustom">
{{ customExpanded ? ' 收起自定义' : ' 自定义节奏' }}
</text>
<text class="bottom-hint" v-if="mode === 'preset'">🔒 锁屏可继续播放</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onShow, onHide } from '@dcloudio/uni-app'
import { useMetronomeBg, type LoopId } from '../hooks/useMetronomeBg'
import { useMetronome } from '../hooks/useMetronome'
import WalkerCanvas from './components/walker-canvas.vue'
/**
* 跑步动画视频(腾讯云 COS CDN)
* 注意:小程序后台需将 cos.ap-guangzhou.myqcloud.com 加入 downloadFile 合法域名,
* 否则正式发布版会被拦截(开发版默认不校验)
*/
const RUNNER_VIDEO_URL =
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260525/20260525164014ef89f8862.mp4'
type LoopId = 'slow' | 'normal' | 'brisk'
interface LoopPreset {
id: LoopId
bpm: number
label: string
desc: string
}
/** 视频原速对应的 BPM 基准, playback-rate 范围 [0.5, 2.0] */
const VIDEO_BASE_BPM = 124
const LOOP_PRESETS: readonly LoopPreset[] = [
{ id: 'slow', bpm: 110, label: '慢走', desc: '热身 · 恢复' },
{ id: 'normal', bpm: 130, label: '健走', desc: '日常 · 通勤' },
{ id: 'brisk', bpm: 150, label: '快走', desc: '提速 · 燃脂' },
]
/* ============================================================
* 双引擎设计
* ------------------------------------------------------------
* - mode='preset': 用 BgAudio 播预合成 mp3,支持后台/锁屏(走路场景)
* - mode='custom': 用 InnerAudio 实时合成,支持任意 BPM/拍号,但仅前台
* - 切模式时停掉对侧引擎,避免双声道叠加
* ============================================================ */
type Mode = 'preset' | 'custom'
const mode = ref<Mode>('preset')
const customExpanded = ref<boolean>(false)
const currentLoop = ref<LoopId | null>('normal')
const currentSound = ref<'crisp' | 'wood'>('crisp')
const walkerCanvasRef = ref<any>(null)
/* click-wood.mp3 (CDN), 木鱼质感的敲击音 */
const CLICK_WOOD_URL =
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3'
/* 节拍器音效配置 */
const SOUND_PRESETS = {
crisp: {
normal: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260527/202605271539371ebe13672.mp3',
accent: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260527/202605271539376ff628472.mp3',
},
wood: {
normal: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3',
accent: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3',
},
}
const bg = useMetronomeBg()
// 统一采用高精度引擎,彻底抛弃卡顿的后台播放引擎
const fg = useMetronome({
initialBpm: 100,
initialBpm: 130,
accentEvery: 2,
clickSrc: CLICK_WOOD_URL,
accentSrc: CLICK_WOOD_URL,
clickSrc: SOUND_PRESETS.crisp.normal,
accentSrc: SOUND_PRESETS.crisp.accent,
poolSize: 4,
})
const { LOOP_PRESETS } = bg
const currentLoop = bg.currentLoop
const customBpm = computed(() => fg.bpm.value)
const customAccent = computed(() => fg.accentEvery.value)
/* 统一对外暴露的 isPlaying / currentBpm,根据 mode 选择信号源 */
const isPlaying = computed(() =>
mode.value === 'preset' ? bg.isPlaying.value : fg.isPlaying.value,
)
const currentBpm = computed(() => {
if (mode.value === 'preset') {
const p = LOOP_PRESETS.find((p) => p.id === currentLoop.value)
return p?.bpm ?? 110
// 【完美音画同步核心】
onBeat: (index, isAccent) => {
if (walkerCanvasRef.value && walkerCanvasRef.value.triggerBeat) {
walkerCanvasRef.value.triggerBeat()
}
}
return fg.bpm.value
})
const intervalMs = computed(() => 60000 / currentBpm.value)
const isPlaying = computed(() => fg.isPlaying.value)
const currentBpm = computed(() => fg.bpm.value)
const customAccent = computed(() => fg.accentEvery.value)
const intervalMs = computed(() => 60000 / fg.bpm.value)
/* ============================================================
* 档位元数据
* ============================================================ */
const presetIcon = (id: LoopId) =>
id === 'slow' ? '🚶' : id === 'normal' ? '🏃‍♀️' : '🏃'
const presetName = (id: LoopId) =>
id === 'slow' ? '慢走' : id === 'normal' ? '健走' : '快走'
const presetDesc = (id: LoopId) =>
id === 'slow' ? '热身 · 恢复' : id === 'normal' ? '日常 · 通勤' : '提速 · 燃脂'
/* ============================================================
* 中央圆按钮:在两个模式下分别工作
* 中央圆按钮
* ============================================================ */
const onCenterTap = () => {
if (mode.value === 'preset') {
if (!currentLoop.value) {
bg.playLoop('normal')
uni.setKeepScreenOn({ keepScreenOn: true })
return
}
if (bg.isPlaying.value) {
bg.pause()
uni.setKeepScreenOn({ keepScreenOn: false })
} else {
bg.resume()
uni.setKeepScreenOn({ keepScreenOn: true })
}
if (fg.isPlaying.value) {
fg.stop()
uni.setKeepScreenOn({ keepScreenOn: false })
} else {
/* custom 模式 */
if (fg.isPlaying.value) {
fg.stop()
uni.setKeepScreenOn({ keepScreenOn: false })
} else {
fg.start()
uni.setKeepScreenOn({ keepScreenOn: true })
}
fg.start()
uni.setKeepScreenOn({ keepScreenOn: true })
}
}
/* ============================================================
* 档位卡片:点了 → 切回 preset 模式 + 停掉 custom
* 档位卡片
* ============================================================ */
const onPresetTap = (id: LoopId) => {
if (mode.value === 'custom') {
fg.stop()
mode.value = 'preset'
customExpanded.value = false
const preset = LOOP_PRESETS.find(p => p.id === id)
if (!preset) return
currentLoop.value = id
fg.setBpm(preset.bpm)
if (!fg.isPlaying.value) {
fg.start()
uni.setKeepScreenOn({ keepScreenOn: true })
}
bg.playLoop(id)
uni.setKeepScreenOn({ keepScreenOn: true })
}
/* ============================================================
* 自定义折叠区切换
* ============================================================ */
const onToggleCustom = () => {
if (!customExpanded.value) {
/* 展开:进入 custom 模式 + 停掉 preset */
if (bg.isPlaying.value || currentLoop.value) {
bg.stop()
}
mode.value = 'custom'
/* 把当前显示 BPM 带过去当起始值 */
const seed =
LOOP_PRESETS.find((p) => p.id === (currentLoop.value ?? 'normal'))?.bpm ?? 100
fg.setBpm(seed)
fg.setAccentEvery(2)
/* 立即预热 InnerAudio,避免首拍冷启动延迟 */
customExpanded.value = !customExpanded.value
if (customExpanded.value) {
fg.preload()
customExpanded.value = true
} else {
/* 收起:回到 preset,停掉 fg */
fg.stop()
mode.value = 'preset'
customExpanded.value = false
uni.setKeepScreenOn({ keepScreenOn: false })
// 收起时,如果当前 BPM 刚好匹配某个档位,高亮对应档位卡片
const matched = LOOP_PRESETS.find(p => p.bpm === fg.bpm.value)
currentLoop.value = matched ? matched.id : null
}
}
const onBpmDelta = (delta: number) => {
fg.setBpm(fg.bpm.value + delta)
currentLoop.value = null // 用户手动微调后取消档位高亮
}
const onAccentSelect = (n: number) => {
fg.setAccentEvery(n)
}
/* ============================================================
* 视频实例与 BPM 同步
* ============================================================ */
let videoCtx: UniApp.VideoContext | null = null
const playbackRate = computed(() => {
const rate = currentBpm.value / VIDEO_BASE_BPM
return Math.max(0.5, Math.min(2.0, +rate.toFixed(2)))
})
onMounted(() => {
videoCtx = uni.createVideoContext('runner-video')
setTimeout(() => videoCtx?.pause(), 50)
})
const onVideoError = (e: any) => {
console.error('[runner-video] error:', e?.detail || e)
const onSoundSelect = (sound: 'crisp' | 'wood') => {
currentSound.value = sound
const preset = SOUND_PRESETS[sound]
fg.updateAudioSrc(preset.normal, preset.accent)
}
const onVideoLoaded = (e: any) => {
console.log('[runner-video] metadata loaded:', e?.detail)
try {
videoCtx?.playbackRate?.(playbackRate.value)
} catch (_) {}
}
watch(isPlaying, (playing) => {
if (playing) {
videoCtx?.play()
} else {
videoCtx?.pause()
}
})
watch(playbackRate, (rate) => {
try {
videoCtx?.playbackRate?.(rate)
} catch (_) {}
})
const rootStyle = computed(() => ({
'--beat-duration': `${intervalMs.value}ms`,
}))
onShow(() => {
/* custom 模式回到前台时主动预加载,避免冷启动延迟 */
if (mode.value === 'custom') {
fg.preload()
}
fg.preload()
})
/**
* onHide:差异化处理
* - preset 模式: 不停,BgAudio 接管后台播放
* - custom 模式: 立即停掉,InnerAudio 后台不工作,留着会被 iOS 挂起后状态错乱
*/
onHide(() => {
if (mode.value === 'custom') {
fg.stop()
uni.setKeepScreenOn({ keepScreenOn: false })
}
fg.stop()
uni.setKeepScreenOn({ keepScreenOn: false })
})
onUnmounted(() => {
/* 页面销毁时全部清理 */
if (mode.value === 'preset' && bg.isPlaying.value) bg.stop()
if (mode.value === 'custom') fg.stop()
fg.stop()
uni.setKeepScreenOn({ keepScreenOn: false })
})
</script>
@@ -359,166 +252,70 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
}
/* ============================================================
* 节拍器主舞台
* 统一运动律动视区 (Canvas)
* ============================================================ */
.stage {
position: relative;
width: 480rpx;
height: 480rpx;
margin-top: 12rpx;
.stage-canvas-wrapper {
width: 100vw;
margin-left: -28rpx;
margin-right: -28rpx;
flex-shrink: 0; /* 防止被压缩 */
height: 600rpx; /* 固定高度,不随内容变化 */
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(16, 185, 129, 0.12);
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(16, 185, 129, 0.4);
}
100% {
transform: scale(1.18);
opacity: 0;
border-color: rgba(16, 185, 129, 0);
}
}
.core {
position: relative;
width: 380rpx;
height: 380rpx;
border-radius: 50%;
background: linear-gradient(140deg, #34d399 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.18),
inset 0 16rpx 50rpx rgba(255, 255, 255, 0.18),
0 16rpx 40rpx rgba(16, 185, 129, 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;
}
.bpm-num {
font-size: 168rpx;
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.16);
}
.bpm-label {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.7);
letter-spacing: 6rpx;
margin-top: 10rpx;
font-weight: 600;
}
.ctrl {
margin-top: 22rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
}
}
@keyframes core-beat {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.04);
}
}
/* 播放/暂停 icon */
.icon-play {
width: 0;
height: 0;
border-left: 52rpx solid #fff;
border-top: 32rpx solid transparent;
border-bottom: 32rpx solid transparent;
margin-left: 12rpx;
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.18));
}
.icon-pause {
display: flex;
gap: 16rpx;
height: 60rpx;
.bar {
width: 16rpx;
height: 100%;
background: #fff;
border-radius: 4rpx;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.18);
}
margin-bottom: 24rpx;
position: relative;
}
/* ============================================================
* 跑步小人
* 音色选择器
* ============================================================ */
.walker {
.sound-selector {
width: 100%;
height: 240rpx;
display: flex;
justify-content: center;
gap: 14rpx;
margin-bottom: 20rpx;
}
.sound-option {
flex: 1;
background: $card-bg;
border: 2rpx solid $card-border;
border-radius: $radius-card;
padding: 20rpx 16rpx;
display: flex;
align-items: center;
}
justify-content: center;
gap: 10rpx;
box-shadow: $shadow-sm;
transition: all 0.18s;
.walker-box {
position: relative;
width: 240rpx;
height: 240rpx;
background: #e8ecf2;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 4rpx 14rpx rgba(15, 23, 42, 0.06);
}
.sound-icon {
font-size: 32rpx;
}
.runner-video {
width: 100%;
height: 100%;
background: #e8ecf2;
}
.sound-label {
font-size: 26rpx;
font-weight: 600;
color: $text-2;
letter-spacing: 1rpx;
}
.pip-mask {
position: absolute;
top: 0;
right: 0;
width: 80rpx;
height: 56rpx;
background-color: #dde2eb;
border-bottom-left-radius: 18rpx;
border-top-right-radius: 24rpx;
&:active {
transform: scale(0.97);
}
&.active {
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
border-color: $brand;
box-shadow:
0 8rpx 20rpx rgba(16, 185, 129, 0.18),
inset 0 0 0 2rpx rgba(16, 185, 129, 0.4);
.sound-label {
color: $brand-deep;
}
}
}
/* ============================================================
@@ -535,19 +332,14 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
background: $card-bg;
border: 2rpx solid $card-border;
border-radius: $radius-card;
padding: 18rpx 8rpx 16rpx;
padding: 26rpx 8rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
gap: 8rpx;
box-shadow: $shadow-sm;
transition: all 0.18s;
.preset-icon {
font-size: 40rpx;
line-height: 1;
margin-bottom: 4rpx;
}
.preset-name {
font-size: 28rpx;
font-weight: 700;
@@ -591,9 +383,32 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
}
/* ============================================================
* 自定义节奏面板(仅 customExpanded 时显示)
* 自定义节奏区域(标题栏 + 折叠内容)
* ============================================================ */
.custom-panel {
.custom-section {
width: 100%;
margin-top: 12rpx;
}
.custom-header {
width: 100%;
padding: 16rpx 6rpx;
display: flex;
align-items: center;
justify-content: center;
.custom-title {
font-size: 22rpx;
color: $text-3;
letter-spacing: 0.5rpx;
&:active {
color: $text-2;
}
}
}
.custom-content {
width: 100%;
background: $card-bg;
border: 2rpx solid rgba(245, 158, 11, 0.25);
@@ -603,6 +418,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 8rpx;
}
.custom-warn {
@@ -705,7 +521,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
}
/* ============================================================
* 底部行(自定义入口 + 锁屏提示,横向排布)
* 底部行(已废弃,保留样式以防引用)
* ============================================================ */
.bottom-row {
width: 100%;
@@ -0,0 +1,276 @@
# 握力环训练音效采集清单
> 更新日期: 2026-05-28
> 用途: 为握力环训练页面添加完整音效体系
---
## 📋 总体规划
| 类型 | 数量 | 单文件大小 | 总大小预算 | 优先级 |
|------|------|-----------|-----------|--------|
| 节拍音效 | 1-3 个音色 | 5-10 KB | < 30 KB | P0 |
| 捏碎音效 | 4 个 | 20-50 KB | < 200 KB | P0 |
| 背景音乐 | 1-2 首 | 200-500 KB | < 1 MB | P1 |
| 语音鼓励 | 4-8 句 | 10-30 KB | < 200 KB | P1 |
**总计预算**: < 1.5 MB(都放 CDN,不影响小程序包体积)
---
## 🥁 一、节拍音效 (P0 - 必需)
### 用途
训练时按 BPM 节奏播放,引导用户握紧/松开。
### 推荐音色 (任选 1-3 个)
| 音色 | 推荐度 | 描述 | Pixabay 搜索词 |
|------|--------|------|---------------|
| 木鱼 | ⭐⭐⭐⭐⭐ | 温暖、东方意境,适合养生主题 | `wood block`, `wooden tap`, `muyu` |
| 心跳 | ⭐⭐⭐⭐ | 有运动感,代入感强 | `heart beat`, `heartbeat single` |
| 轻柔敲击 | ⭐⭐⭐⭐ | 不刺耳,适合长时间训练 | `soft tap`, `gentle knock` |
| 竹片声 | ⭐⭐⭐ | 清脆,有节奏感 | `bamboo tap`, `bamboo click` |
| 鼓点 | ⭐⭐⭐ | 有力量感,适合"重度"档位 | `kick drum soft`, `tom drum tap` |
### 技术要求
- **格式**: mp3
- **时长**: 50-150ms (不能太长,会重叠)
- **采样率**: 22050Hz 或 44100Hz
- **声道**: 单声道
- **大小**: < 10 KB
### 命名规范
```
grip-tick-wood.mp3 # 木鱼
grip-tick-heart.mp3 # 心跳
grip-tick-soft.mp3 # 轻柔
grip-tick-bamboo.mp3 # 竹片
```
---
## 💥 二、捏碎音效 (P0 - 必需)
### 用途
达成奖励里程碑时播放,与粒子特效同步。
### 音效清单
| 奖励 | 触发次数 | Pixabay 搜索词 | 时长 | 备注 |
|------|---------|---------------|------|------|
| 🥚 鸡蛋 | 8-12 | `egg crack`, `eggshell break`, `crack sound` | 0.5-1s | 清脆短促 |
| 🥜 核桃 | 15-20 | `nut crack`, `walnut break`, `wood snap` | 0.5-1s | 木质爆裂感 |
| 🥫 易拉罐 | 25-30 | `can crush`, `aluminum crush`, `metal crunch` | 1-2s | 金属挤压 |
| 🎈 气球 | 40-50 | `balloon pop`, `pop burst`, `balloon explosion` | 0.3-0.5s | 短促爆裂 |
### 技术要求
- **格式**: mp3
- **时长**: 0.3-2s
- **大小**: < 50 KB
- **音量**: 适中(避免突然吓到用户)
### 命名规范
```
crush-egg.mp3
crush-walnut.mp3
crush-can.mp3
crush-balloon.mp3
```
### 试听建议
下载前一定要试听,优先选择:
- ✅ 干净,无杂音
- ✅ 中等音量(峰值不爆音)
- ✅ 短促有力(不拖沓)
- ❌ 避免: 太长、太尖锐、有人声
---
## 🎵 三、背景音乐 BGM (P1 - 推荐)
### 用途
训练时持续播放,营造轻松氛围,缓解握力训练的枯燥感。
### 风格推荐
| 风格 | 描述 | Pixabay 搜索词 | 适合人群 |
|------|------|---------------|---------|
| **Lo-Fi** | 慵懒电子,流行选择 | `lofi calm`, `lofi study`, `lofi chill` | 年轻人 |
| **冥想轻音乐** | 钢琴 + 自然音,放松 | `meditation`, `calm piano`, `relaxing` | 中老年 |
| **轻爵士** | 优雅放松,有质感 | `light jazz`, `cafe jazz`, `bossa nova` | 通用 |
| **自然环境音** | 鸟叫/流水/雨声 | `nature ambient`, `forest sounds`, `rain bgm` | 喜欢自然的用户 |
| **东方禅意** | 古筝/笛子,养生 | `chinese zen`, `oriental calm`, `guqin` | 养生主题契合 |
### 推荐场景搭配
- **轻度档位 (60 BPM)**: 冥想轻音乐 (节奏舒缓)
- **中度档位 (80 BPM)**: 轻爵士 / Lo-Fi (适中)
- **重度档位 (100 BPM)**: 轻快电子 / 健身流行
### 技术要求
- **格式**: mp3
- **时长**: 30s - 2min (循环播放)
- **比特率**: 128 kbps (单声道 64 kbps 也可)
- **大小**: < 500 KB
- **循环点**: 选择起止点接近的片段,避免明显接缝
### 命名规范
```
bgm-grip-light.mp3 # 轻度档位 BGM
bgm-grip-medium.mp3 # 中度档位 BGM (可选)
bgm-grip-heavy.mp3 # 重度档位 BGM (可选)
```
或简化:
```
bgm-grip-default.mp3 # 默认 BGM (所有档位通用)
```
---
## 🎙️ 四、语音鼓励 (P1 - 推荐)
### 用途
达成奖励时播放鼓励语音,增强成就感。
### 两种方案
#### 方案 A: 小米 MiMo TTS 自动生成 (推荐)
项目已有 `useVoiceCoach.ts` + `generate-voice.mjs` 脚本支持。
**操作步骤**:
1. 申请小米 MiMo API Key: https://platform.xiaomimimo.com/
2. 配置环境变量: `export MIMO_API_KEY=sk-xxx`
3.`generate-voice.mjs` 添加握力环话术
4. 运行脚本自动生成
**话术清单**:
```javascript
const gripPrompts = [
{ id: 'grip-egg', text: '握力不错!' },
{ id: 'grip-walnut', text: '力量惊人!' },
{ id: 'grip-can', text: '太强了!' },
{ id: 'grip-balloon', text: '完美!继续保持!' },
{ id: 'grip-start', text: '开始训练!' },
{ id: 'grip-pause', text: '休息一下!' },
{ id: 'grip-encourage-1', text: '加油,再来!' },
{ id: 'grip-encourage-2', text: '坚持住!' },
]
```
**音色推荐**: `茉莉` (清爽利落,有"运动博主"感)
#### 方案 B: 手工录制
如果不想用 TTS,可以自己录制(质感更好,但费时):
- 工具: 手机录音 / Audacity
- 环境: 安静的房间,距离麦克风 15-30cm
- 后期: 去噪 + 标准化音量
---
## 🛠️ 五、采集工具推荐
### 在线裁剪
- [audiotrimmer.com](https://audiotrimmer.com) - 简单的 mp3 裁剪
- [mp3cut.net](https://mp3cut.net) - 功能更全
### 桌面工具(免费)
- **Audacity** (Mac/Windows/Linux) - 专业级,可以剪辑、降噪、标准化
- **Logic Pro** (Mac, 付费) - 更专业的音频处理
### 音量标准化
统一音量(避免有的太响有的太轻):
```bash
# 使用 ffmpeg (Mac: brew install ffmpeg)
ffmpeg -i input.mp3 -af "loudnorm=I=-16:LRA=11:TP=-1.5" output.mp3
```
或在 Audacity 里: Effect → Normalize (峰值 -3dB)
---
## ☁️ 六、上传到 CDN
### 项目使用的 COS 信息
- **域名**: `gz-1349751149.cos.ap-guangzhou.myqcloud.com`
- **目录建议**: `uploads/training/grip-ring/`
### 上传途径
1. **腾讯云控制台**: https://console.cloud.tencent.com/cos
2. **管理后台** (如果项目有上传入口)
3. **找团队 COS 管理员上传**
### 上传后获得的 URL 格式
```
https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/training/grip-ring/crush-egg.mp3
```
---
## 📝 七、最终采集清单 (一次性收齐)
### 最小可行版本 (MVP) - 4 个文件
```
☐ grip-tick-wood.mp3 # 节拍音 - 木鱼
☐ crush-egg.mp3 # 鸡蛋
☐ crush-walnut.mp3 # 核桃
☐ crush-can.mp3 # 易拉罐
☐ crush-balloon.mp3 # 气球
```
### 完整版本 - 10 个文件
```
节拍音 (3 选 1 或全部):
☐ grip-tick-wood.mp3
☐ grip-tick-heart.mp3
☐ grip-tick-soft.mp3
捏碎音效 (必需 4 个):
☐ crush-egg.mp3
☐ crush-walnut.mp3
☐ crush-can.mp3
☐ crush-balloon.mp3
BGM (1-3 首):
☐ bgm-grip-default.mp3
☐ bgm-grip-meditation.mp3 (可选)
☐ bgm-grip-lofi.mp3 (可选)
语音鼓励 (TTS 自动生成,8 个):
☐ grip-egg.mp3
☐ grip-walnut.mp3
☐ grip-can.mp3
☐ grip-balloon.mp3
☐ grip-start.mp3
☐ grip-pause.mp3
☐ grip-encourage-1.mp3
☐ grip-encourage-2.mp3
```
---
## ✅ 八、采集完成后
把所有 URL 整理给我,我会:
1. 更新 `grip-ring.vue` 中的 REWARDS 配置 (替换捏碎音效 URL)
2. 创建/更新 `useMetronome.ts` 的音色配置 (支持切换节拍音色)
3.`grip-ring.vue` 集成 `useTrainingBgm` (添加 BGM)
4.`grip-ring.vue` 集成 `useVoiceCoach` (添加语音鼓励)
5. 在 UI 上添加音色/BGM 切换选项 (可选)
预计代码改动: 50-100 行,1-2 小时工作量。
---
## 📚 参考链接
- [Pixabay 音效](https://pixabay.com/sound-effects/) - 免费可商用
- [Pixabay 音乐](https://pixabay.com/music/) - 免费可商用
- [Freesound](https://freesound.org) - 大量音效,部分需署名
- [Mixkit](https://mixkit.co/free-sound-effects/) - 免费音效库
- [Bensound](https://bensound.com) - 免费音乐(需署名)
- [Free Music Archive](https://freemusicarchive.org) - 免费音乐
**版权提示**: 务必确认音效/音乐是"免费商用"(Public Domain 或 CC0),避免侵权风险。
+115
View File
@@ -0,0 +1,115 @@
/**
* 卡路里计算工具
* 基于标准 MET (Metabolic Equivalent of Task) 公式
*/
export interface FoodItem {
name: string
emoji: string
calories: number
unit: string
}
export interface FoodComparison {
food: FoodItem
ratio: number
message: string
}
export interface CalorieCalculationConfig {
totalReps: number
bpm: number
userWeight?: number // 默认 60kg
}
// 食物卡路里数据库
export const FOOD_CALORIES: Record<string, FoodItem> = {
apple: { name: '苹果', emoji: '🍎', calories: 52, unit: '个(100g' },
egg: { name: '鸡蛋', emoji: '🥚', calories: 70, unit: '个' },
chocolate: { name: '巧克力', emoji: '🍫', calories: 54, unit: '块(10g' },
banana: { name: '香蕉', emoji: '🍌', calories: 89, unit: '根' },
rice: { name: '米饭', emoji: '🍚', calories: 116, unit: '碗(100g' },
}
/**
* 计算卡路里消耗
* 公式:卡路里 = MET × 体重(kg) × 时长(小时)
*
* @param config 计算配置
* @returns 卡路里消耗(kcal,保留一位小数)
*/
export function calculateCalories(config: CalorieCalculationConfig): number {
const MET = 3.5 // 握力环训练的标准 MET 值
const weight = config.userWeight || 60 // 默认 60kg
// 边界情况:无效输入
if (config.totalReps <= 0 || config.bpm <= 0) {
return 0
}
const secondsPerRep = 60 / config.bpm
const activeTimeHours = (config.totalReps * secondsPerRep) / 3600
const calories = MET * weight * activeTimeHours
return Math.round(calories * 10) / 10 // 保留一位小数
}
/**
* 自动匹配最接近的食物对比
*
* @param burnedCalories 消耗的卡路里
* @returns 食物对比信息
*/
export function getFoodComparison(burnedCalories: number): FoodComparison {
const foods = Object.values(FOOD_CALORIES)
// 边界情况:食物数据库为空
if (foods.length === 0) {
throw new Error('食物数据库为空')
}
// 边界情况:卡路里为 0 或负数
if (burnedCalories <= 0) {
return {
food: foods[0],
ratio: 0,
message: `暂无消耗`,
}
}
// 找到卡路里最接近的食物
const closest = foods.reduce((prev, curr) => {
const prevDiff = Math.abs(prev.calories - burnedCalories)
const currDiff = Math.abs(curr.calories - burnedCalories)
return currDiff < prevDiff ? curr : prev
})
const ratio = burnedCalories / closest.calories
const ratioRounded = Math.round(ratio * 100) / 100 // 保留两位小数
// 格式化显示:ratio < 1 时显示分数形式更直观
let displayText: string
if (ratioRounded < 1) {
displayText = `相当于 ${ratioRounded.toFixed(2)}${closest.name} ${closest.emoji}`
} else {
displayText = `相当于 ${ratioRounded.toFixed(2)} ${closest.unit}${closest.name} ${closest.emoji}`
}
return {
food: closest,
ratio: ratioRounded,
message: displayText,
}
}
/**
* 格式化时长(秒 → 分:秒)
*
* @param seconds 秒数
* @returns 格式化字符串(如 "3:45"
*/
export function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}