first commit
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
|
||||
export interface MetronomeOptions {
|
||||
initialBpm?: number
|
||||
bpmMin?: number
|
||||
bpmMax?: number
|
||||
clickSrc?: string
|
||||
accentSrc?: string
|
||||
accentEvery?: number
|
||||
poolSize?: number
|
||||
volume?: number // 节拍音音量 0-1
|
||||
silent?: boolean // 静音模式:只驱动节拍回调,不发声(如握力环只需视觉节拍)
|
||||
onBeat?: (beatIndex: number, isAccent: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 音频实例池:每个 click 用一个独立 InnerAudioContext,循环复用。
|
||||
* 解决两个问题:
|
||||
* 1. seek+play 模式在某些设备上首拍冷启动延迟(100-300ms)
|
||||
* 2. BPM 偏快时上一拍音频还没播完,下一拍 play 会被阻塞或丢失
|
||||
*/
|
||||
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++) {
|
||||
const ctx = uni.createInnerAudioContext()
|
||||
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 不会再卡冷启动
|
||||
*/
|
||||
warmUp() {
|
||||
if (this.warmedUp) return
|
||||
if (this.list.length === 0) this.create()
|
||||
|
||||
this.list.forEach((ctx) => {
|
||||
try {
|
||||
ctx.volume = 0 // 静音预热,避免进入页面时响声
|
||||
ctx.play()
|
||||
setTimeout(() => {
|
||||
try {
|
||||
ctx.stop()
|
||||
ctx.volume = this.targetVolume // 恢复目标音量
|
||||
} catch (_) {}
|
||||
}, 60)
|
||||
} catch (_) {}
|
||||
})
|
||||
this.warmedUp = true
|
||||
}
|
||||
|
||||
play() {
|
||||
if (this.list.length === 0) {
|
||||
this.create()
|
||||
this.warmUp()
|
||||
}
|
||||
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.play()
|
||||
} catch (_) {
|
||||
try { ctx.play() } catch (__) {}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.list.forEach((ctx) => {
|
||||
try {
|
||||
ctx.destroy?.()
|
||||
} catch (_) {}
|
||||
})
|
||||
this.list = []
|
||||
this.warmedUp = false
|
||||
}
|
||||
}
|
||||
|
||||
/* InnerAudio 兼容本地路径和网络 URL,这里默认走 CDN,跟 BgAudio 保持一致便于维护
|
||||
首次播放会下载 ~3KB,实测 100~300ms 完成,然后小程序会缓存,后续触发零延迟 */
|
||||
const DEFAULT_CLICK_SRC =
|
||||
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105128557ef8669.mp3'
|
||||
|
||||
export function useMetronome(options: MetronomeOptions = {}) {
|
||||
const {
|
||||
initialBpm = 80,
|
||||
bpmMin = 40,
|
||||
bpmMax = 240,
|
||||
clickSrc = DEFAULT_CLICK_SRC,
|
||||
accentSrc,
|
||||
poolSize = 4,
|
||||
volume = 1,
|
||||
silent = false,
|
||||
onBeat,
|
||||
} = options
|
||||
|
||||
const bpm = ref<number>(initialBpm)
|
||||
const isPlaying = ref<boolean>(false)
|
||||
const beatIndex = ref<number>(0)
|
||||
const isAccent = ref<boolean>(false)
|
||||
const accentEveryRef = ref<number>(options.accentEvery ?? 4)
|
||||
|
||||
const intervalMs = computed(() => 60000 / bpm.value)
|
||||
|
||||
let clickPool: AudioPool | null = null
|
||||
let accentPool: AudioPool | null = null
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let nextTickAt = 0
|
||||
|
||||
const ensureAudio = () => {
|
||||
if (silent) return
|
||||
if (!clickPool) {
|
||||
clickPool = new AudioPool(clickSrc, poolSize, volume)
|
||||
clickPool.warmUp()
|
||||
}
|
||||
if (accentSrc && !accentPool) {
|
||||
accentPool = new AudioPool(accentSrc, Math.max(2, Math.ceil(poolSize / 2)), volume)
|
||||
accentPool.warmUp()
|
||||
}
|
||||
}
|
||||
|
||||
const playSound = (accent: boolean) => {
|
||||
if (accent && accentPool) {
|
||||
accentPool.play()
|
||||
} else if (clickPool) {
|
||||
clickPool.play()
|
||||
}
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
if (!isPlaying.value) return
|
||||
|
||||
const every = Math.max(1, accentEveryRef.value)
|
||||
const accent = beatIndex.value % every === 0
|
||||
isAccent.value = accent
|
||||
|
||||
playSound(accent)
|
||||
onBeat?.(beatIndex.value, accent)
|
||||
beatIndex.value++
|
||||
|
||||
nextTickAt += intervalMs.value
|
||||
const nextDelay = Math.max(0, nextTickAt - Date.now())
|
||||
|
||||
timer = setTimeout(tick, nextDelay)
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (isPlaying.value) return
|
||||
ensureAudio()
|
||||
isPlaying.value = true
|
||||
beatIndex.value = 0
|
||||
nextTickAt = Date.now()
|
||||
tick()
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
isPlaying.value = false
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
const setBpm = (val: number) => {
|
||||
bpm.value = Math.max(bpmMin, Math.min(bpmMax, Math.round(val)))
|
||||
}
|
||||
|
||||
const setAccentEvery = (n: number) => {
|
||||
accentEveryRef.value = Math.max(1, Math.min(16, Math.round(n)))
|
||||
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 静音键)
|
||||
*/
|
||||
const preload = () => {
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
uni.setInnerAudioOption({
|
||||
obeyMuteSwitch: false,
|
||||
mixWithOther: true,
|
||||
})
|
||||
} catch (_) {}
|
||||
// #endif
|
||||
ensureAudio()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
clickPool?.destroy()
|
||||
accentPool?.destroy()
|
||||
clickPool = null
|
||||
accentPool = null
|
||||
})
|
||||
|
||||
return {
|
||||
bpm,
|
||||
isPlaying,
|
||||
beatIndex,
|
||||
isAccent,
|
||||
intervalMs,
|
||||
accentEvery: accentEveryRef,
|
||||
start,
|
||||
stop,
|
||||
setBpm,
|
||||
setAccentEvery,
|
||||
updateAudioSrc,
|
||||
preload,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
<template>
|
||||
<view class="crush-canvas-container">
|
||||
<canvas
|
||||
type="2d"
|
||||
id="crush"
|
||||
class="crush-canvas"
|
||||
></canvas>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
|
||||
|
||||
// ============================================================
|
||||
// 类型定义
|
||||
// ============================================================
|
||||
|
||||
type CrushItemType = 'egg' | 'walnut' | 'can' | 'balloon'
|
||||
type ParticleShape = 'circle' | 'square' | 'triangle' | 'rect'
|
||||
|
||||
// 碎片(主体飞溅物)
|
||||
interface Debris {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
size: number
|
||||
color: string
|
||||
shape: ParticleShape
|
||||
rotation: number
|
||||
rotationSpeed: number
|
||||
life: number
|
||||
decay: number
|
||||
gravity: number
|
||||
glow: boolean
|
||||
swing: number // 水平摇摆幅度(confetti 彩纸用),0 表示不摇摆
|
||||
age: number
|
||||
}
|
||||
|
||||
// 火花(细小高速亮点,带拖尾)
|
||||
interface Spark {
|
||||
x: number
|
||||
y: number
|
||||
px: number // 上一帧位置(拖尾起点)
|
||||
py: number
|
||||
vx: number
|
||||
vy: number
|
||||
size: number
|
||||
color: string
|
||||
life: number
|
||||
decay: number
|
||||
gravity: number
|
||||
}
|
||||
|
||||
// 闪烁星光(四角星高光)
|
||||
interface Sparkle {
|
||||
x: number
|
||||
y: number
|
||||
size: number
|
||||
rotation: number
|
||||
spin: number
|
||||
color: string
|
||||
life: number
|
||||
decay: number
|
||||
}
|
||||
|
||||
// 中心爆闪
|
||||
interface Flash {
|
||||
x: number
|
||||
y: number
|
||||
radius: number
|
||||
maxRadius: number
|
||||
color: string
|
||||
life: number
|
||||
decay: number
|
||||
}
|
||||
|
||||
interface Shockwave {
|
||||
x: number
|
||||
y: number
|
||||
radius: number
|
||||
maxRadius: number
|
||||
color: string
|
||||
width: number
|
||||
life: number
|
||||
decay: number
|
||||
}
|
||||
|
||||
interface CrushItemConfig {
|
||||
colors: string[] // 碎片主体色
|
||||
sparkColors: string[] // 火花/高光色(偏亮)
|
||||
shapes: ParticleShape[]
|
||||
debrisCount: number
|
||||
sparkCount: number
|
||||
sparkleCount: number
|
||||
speedMin: number
|
||||
speedMax: number
|
||||
sizeMin: number
|
||||
sizeMax: number
|
||||
gravity: number
|
||||
flashColor: string // 中心爆闪核心色
|
||||
shockwaveColor: string
|
||||
confetti?: boolean // balloon: 碎片改为彩纸飘落
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 物品差异化配置
|
||||
// ============================================================
|
||||
|
||||
const CRUSH_ITEMS: Record<CrushItemType, CrushItemConfig> = {
|
||||
egg: {
|
||||
colors: ['#FFEB3B', '#FFF59D', '#FFFFFF', '#FFD54F', '#FFC107'],
|
||||
sparkColors: ['#FFFFFF', '#FFF9C4', '#FFEE58'],
|
||||
shapes: ['circle', 'circle', 'circle'],
|
||||
debrisCount: 20,
|
||||
sparkCount: 16,
|
||||
sparkleCount: 5,
|
||||
speedMin: 3,
|
||||
speedMax: 6,
|
||||
sizeMin: 4,
|
||||
sizeMax: 10,
|
||||
gravity: 0.34,
|
||||
flashColor: '#FFFDE7',
|
||||
shockwaveColor: 'rgba(255, 224, 130, 0.85)',
|
||||
},
|
||||
walnut: {
|
||||
colors: ['#5D4037', '#795548', '#8D6E63', '#A1887F', '#3E2723'],
|
||||
sparkColors: ['#FFCC80', '#FFB74D', '#FFE0B2'],
|
||||
shapes: ['square', 'triangle', 'rect'],
|
||||
debrisCount: 22,
|
||||
sparkCount: 18,
|
||||
sparkleCount: 5,
|
||||
speedMin: 2.8,
|
||||
speedMax: 5,
|
||||
sizeMin: 4,
|
||||
sizeMax: 9,
|
||||
gravity: 0.42,
|
||||
flashColor: '#FFE0B2',
|
||||
shockwaveColor: 'rgba(255, 167, 38, 0.8)',
|
||||
},
|
||||
can: {
|
||||
colors: ['#90A4AE', '#CFD8DC', '#B0BEC5', '#78909C', '#ECEFF1', '#607D8B'],
|
||||
sparkColors: ['#FFFFFF', '#E1F5FE', '#B3E5FC'],
|
||||
shapes: ['rect', 'rect', 'triangle', 'square'],
|
||||
debrisCount: 24,
|
||||
sparkCount: 20,
|
||||
sparkleCount: 6,
|
||||
speedMin: 4,
|
||||
speedMax: 7,
|
||||
sizeMin: 3,
|
||||
sizeMax: 11,
|
||||
gravity: 0.36,
|
||||
flashColor: '#FFFFFF',
|
||||
shockwaveColor: 'rgba(207, 216, 220, 0.9)',
|
||||
},
|
||||
balloon: {
|
||||
colors: ['#FF5252', '#FF4081', '#E040FB', '#7C4DFF', '#536DFE', '#448AFF', '#FFEB3B', '#69F0AE', '#FF6E40'],
|
||||
sparkColors: ['#FFFFFF', '#FF80AB', '#82B1FF', '#FFFF8D'],
|
||||
shapes: ['rect', 'rect', 'square'],
|
||||
debrisCount: 28,
|
||||
sparkCount: 18,
|
||||
sparkleCount: 8,
|
||||
speedMin: 4,
|
||||
speedMax: 7.5,
|
||||
sizeMin: 4,
|
||||
sizeMax: 8,
|
||||
gravity: 0.14,
|
||||
flashColor: '#FCE4EC',
|
||||
shockwaveColor: 'rgba(255, 64, 129, 0.85)',
|
||||
confetti: true,
|
||||
},
|
||||
}
|
||||
|
||||
const PHYSICS = {
|
||||
AIR_RESISTANCE: 0.985,
|
||||
SPARK_RESISTANCE: 0.92,
|
||||
DEBRIS_DECAY: 0.018,
|
||||
SPARK_DECAY: 0.035,
|
||||
SPARKLE_DECAY: 0.045,
|
||||
FLASH_DECAY: 0.11,
|
||||
SHOCKWAVE_DECAY: 0.045,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 状态
|
||||
// ============================================================
|
||||
|
||||
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 debrisList = ref<Debris[]>([])
|
||||
const sparkList = ref<Spark[]>([])
|
||||
const sparkleList = ref<Sparkle[]>([])
|
||||
const flashList = ref<Flash[]>([])
|
||||
const shockwaves = ref<Shockwave[]>([])
|
||||
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
// 由父级 prop 信号驱动触发特效(避免跨组件 ref 调用, 小程序端更稳)
|
||||
const props = defineProps<{
|
||||
crushSignal?: { type: CrushItemType; nonce: number } | null
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => props.crushSignal,
|
||||
(sig) => {
|
||||
if (sig) triggerCrush(sig.type)
|
||||
},
|
||||
)
|
||||
|
||||
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
|
||||
const maxR = Math.min(canvasWidth.value, canvasHeight.value)
|
||||
|
||||
// 1. 中心爆闪
|
||||
flashList.value.push({
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
radius: maxR * 0.12,
|
||||
maxRadius: maxR * 0.62,
|
||||
color: item.flashColor,
|
||||
life: 1.0,
|
||||
decay: PHYSICS.FLASH_DECAY,
|
||||
})
|
||||
|
||||
// 2. 双层冲击波
|
||||
shockwaves.value.push({
|
||||
x: centerX, y: centerY,
|
||||
radius: 12, maxRadius: maxR * 0.62,
|
||||
color: item.shockwaveColor, width: 5,
|
||||
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY,
|
||||
})
|
||||
shockwaves.value.push({
|
||||
x: centerX, y: centerY,
|
||||
radius: 4, maxRadius: maxR * 0.42,
|
||||
color: item.shockwaveColor, width: 3,
|
||||
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY * 1.3,
|
||||
})
|
||||
|
||||
// 3. 碎片(主体飞溅)
|
||||
for (let i = 0; i < item.debrisCount; i++) {
|
||||
const angle = (Math.PI * 2 * i) / item.debrisCount + (Math.random() - 0.5) * 0.7
|
||||
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)]
|
||||
|
||||
// confetti(彩纸): 初速带强烈向上偏移 + 水平摇摆下落
|
||||
const confetti = !!item.confetti
|
||||
const vy0 = confetti
|
||||
? Math.sin(angle) * speed - 2.5 // 先向上窜
|
||||
: Math.sin(angle) * speed
|
||||
|
||||
debrisList.value.push({
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: vy0,
|
||||
size,
|
||||
color,
|
||||
shape,
|
||||
rotation: Math.random() * Math.PI * 2,
|
||||
rotationSpeed: (Math.random() - 0.5) * (confetti ? 0.5 : 0.3),
|
||||
life: 1.0,
|
||||
decay: PHYSICS.DEBRIS_DECAY * (confetti ? 0.7 : 1),
|
||||
gravity: item.gravity,
|
||||
glow: !confetti, // 彩纸不发光,实色碎片发光
|
||||
swing: confetti ? 0.6 + Math.random() * 1.2 : 0,
|
||||
age: Math.random() * Math.PI * 2,
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 火花(细小高速亮点,带拖尾)
|
||||
for (let i = 0; i < item.sparkCount; i++) {
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const speed = item.speedMax * (0.9 + Math.random() * 0.8)
|
||||
const color = item.sparkColors[Math.floor(Math.random() * item.sparkColors.length)]
|
||||
sparkList.value.push({
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
px: centerX,
|
||||
py: centerY,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
size: 1.5 + Math.random() * 2,
|
||||
color,
|
||||
life: 1.0,
|
||||
decay: PHYSICS.SPARK_DECAY * (0.8 + Math.random() * 0.6),
|
||||
gravity: item.gravity * 0.3,
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 闪烁星光
|
||||
for (let i = 0; i < item.sparkleCount; i++) {
|
||||
const r = maxR * (0.08 + Math.random() * 0.32)
|
||||
const a = Math.random() * Math.PI * 2
|
||||
const color = item.sparkColors[Math.floor(Math.random() * item.sparkColors.length)]
|
||||
sparkleList.value.push({
|
||||
x: centerX + Math.cos(a) * r,
|
||||
y: centerY + Math.sin(a) * r,
|
||||
size: 10 + Math.random() * 16,
|
||||
rotation: Math.random() * Math.PI,
|
||||
spin: (Math.random() - 0.5) * 0.16,
|
||||
color,
|
||||
life: 1.0 + Math.random() * 0.4, // 错峰出现
|
||||
decay: PHYSICS.SPARKLE_DECAY * (0.8 + Math.random() * 0.5),
|
||||
})
|
||||
}
|
||||
|
||||
ensureRenderLoop()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 渲染循环
|
||||
// ============================================================
|
||||
|
||||
function ensureRenderLoop() {
|
||||
if (renderRunning.value) return
|
||||
renderRunning.value = true
|
||||
renderLoop()
|
||||
}
|
||||
|
||||
function renderLoop() {
|
||||
if (!canvasNode.value || !ctx.value) {
|
||||
renderRunning.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const w = canvasWidth.value
|
||||
const h = canvasHeight.value
|
||||
|
||||
// 碎片
|
||||
for (let i = debrisList.value.length - 1; i >= 0; i--) {
|
||||
const p = debrisList.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.age += 0.2
|
||||
if (p.swing > 0) p.x += Math.sin(p.age) * p.swing // 彩纸摇摆
|
||||
p.rotation += p.rotationSpeed
|
||||
p.life -= p.decay
|
||||
if (p.life <= 0 || p.x < -60 || p.x > w + 60 || p.y > h + 60) {
|
||||
debrisList.value.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 火花
|
||||
for (let i = sparkList.value.length - 1; i >= 0; i--) {
|
||||
const s = sparkList.value[i]
|
||||
s.px = s.x
|
||||
s.py = s.y
|
||||
s.vy += s.gravity
|
||||
s.vx *= PHYSICS.SPARK_RESISTANCE
|
||||
s.vy *= PHYSICS.SPARK_RESISTANCE
|
||||
s.x += s.vx
|
||||
s.y += s.vy
|
||||
s.life -= s.decay
|
||||
if (s.life <= 0) sparkList.value.splice(i, 1)
|
||||
}
|
||||
|
||||
// 闪烁星光
|
||||
for (let i = sparkleList.value.length - 1; i >= 0; i--) {
|
||||
const sp = sparkleList.value[i]
|
||||
sp.rotation += sp.spin
|
||||
sp.life -= sp.decay
|
||||
if (sp.life <= 0) sparkleList.value.splice(i, 1)
|
||||
}
|
||||
|
||||
// 中心爆闪
|
||||
for (let i = flashList.value.length - 1; i >= 0; i--) {
|
||||
const f = flashList.value[i]
|
||||
f.radius += (f.maxRadius - f.radius) * 0.35
|
||||
f.life -= f.decay
|
||||
if (f.life <= 0) flashList.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.16
|
||||
sw.life -= sw.decay
|
||||
if (sw.life <= 0) shockwaves.value.splice(i, 1)
|
||||
}
|
||||
|
||||
draw()
|
||||
|
||||
const alive =
|
||||
debrisList.value.length > 0 ||
|
||||
sparkList.value.length > 0 ||
|
||||
sparkleList.value.length > 0 ||
|
||||
flashList.value.length > 0 ||
|
||||
shockwaves.value.length > 0
|
||||
|
||||
if (alive) {
|
||||
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. 冲击波(底层) ——
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
shockwaves.value.forEach(sw => {
|
||||
c.beginPath()
|
||||
c.arc(sw.x, sw.y, sw.radius, 0, Math.PI * 2)
|
||||
c.strokeStyle = applyAlphaToRgba(sw.color, Math.min(1, sw.life))
|
||||
c.lineWidth = sw.width * sw.life
|
||||
c.stroke()
|
||||
})
|
||||
|
||||
// —— 2. 中心爆闪(叠加发光) ——
|
||||
c.globalCompositeOperation = 'lighter'
|
||||
flashList.value.forEach(f => {
|
||||
const grd = c.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.radius)
|
||||
const a = Math.min(1, f.life)
|
||||
grd.addColorStop(0, hexToRgba(f.color, 0.95 * a))
|
||||
grd.addColorStop(0.4, hexToRgba(f.color, 0.5 * a))
|
||||
grd.addColorStop(1, hexToRgba(f.color, 0))
|
||||
c.fillStyle = grd
|
||||
c.beginPath()
|
||||
c.arc(f.x, f.y, f.radius, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
})
|
||||
|
||||
// —— 3. 碎片(实体,带辉光) ——
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
debrisList.value.forEach(p => {
|
||||
const alpha = Math.min(1, p.life)
|
||||
const currentSize = p.size * (0.65 + p.life * 0.35)
|
||||
|
||||
// 辉光: 同色低透明大圆衬底
|
||||
if (p.glow) {
|
||||
c.fillStyle = hexToRgba(p.color, alpha * 0.22)
|
||||
c.beginPath()
|
||||
c.arc(p.x, p.y, currentSize * 1.9, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
}
|
||||
|
||||
c.fillStyle = hexToRgba(p.color, alpha)
|
||||
drawDebrisShape(c, p, currentSize)
|
||||
})
|
||||
|
||||
// —— 4. 火花(拖尾 + 亮点,叠加发光) ——
|
||||
c.globalCompositeOperation = 'lighter'
|
||||
sparkList.value.forEach(s => {
|
||||
const a = Math.min(1, s.life)
|
||||
// 拖尾线
|
||||
c.strokeStyle = hexToRgba(s.color, a * 0.8)
|
||||
c.lineWidth = s.size * a
|
||||
c.lineCap = 'round'
|
||||
c.beginPath()
|
||||
c.moveTo(s.px, s.py)
|
||||
c.lineTo(s.x, s.y)
|
||||
c.stroke()
|
||||
// 头部亮点
|
||||
c.fillStyle = hexToRgba(s.color, a)
|
||||
c.beginPath()
|
||||
c.arc(s.x, s.y, s.size * a, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
})
|
||||
|
||||
// —— 5. 闪烁星光(四角星,叠加发光) ——
|
||||
sparkleList.value.forEach(sp => {
|
||||
// life 在 1→0, 用 sin 做"先亮后灭"的缩放
|
||||
const t = Math.max(0, Math.min(1, sp.life))
|
||||
const scale = Math.sin(Math.min(1, sp.life) * Math.PI) // 0→1→0
|
||||
if (scale <= 0.02) return
|
||||
const a = t
|
||||
drawStar(c, sp.x, sp.y, sp.size * scale, sp.size * scale * 0.32, sp.rotation, hexToRgba(sp.color, a))
|
||||
})
|
||||
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
}
|
||||
|
||||
function drawDebrisShape(c: any, p: Debris, 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.6, -size * 0.5, size * 3.2, 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 drawStar(c: any, cx: number, cy: number, outerR: number, innerR: number, rotation: number, fill: string) {
|
||||
const points = 4
|
||||
c.save()
|
||||
c.translate(cx, cy)
|
||||
c.rotate(rotation)
|
||||
c.beginPath()
|
||||
for (let i = 0; i < points * 2; i++) {
|
||||
const r = i % 2 === 0 ? outerR : innerR
|
||||
const a = (Math.PI * i) / points
|
||||
const x = Math.cos(a) * r
|
||||
const y = Math.sin(a) * r
|
||||
if (i === 0) c.moveTo(x, y)
|
||||
else c.lineTo(x, y)
|
||||
}
|
||||
c.closePath()
|
||||
c.fillStyle = fill
|
||||
c.fill()
|
||||
c.restore()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具函数
|
||||
// ============================================================
|
||||
|
||||
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
|
||||
debrisList.value = []
|
||||
sparkList.value = []
|
||||
sparkleList.value = []
|
||||
flashList.value = []
|
||||
shockwaves.value = []
|
||||
}
|
||||
|
||||
</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>
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,551 @@
|
||||
<template>
|
||||
<view class="page" :style="rootStyle">
|
||||
<!-- ===== 统一运动律动视区 (Canvas 律动舞台) ===== -->
|
||||
<view class="stage-canvas-wrapper">
|
||||
<WalkerCanvas
|
||||
ref="walkerCanvasRef"
|
||||
:bpm="currentBpm"
|
||||
:is-playing="isPlaying"
|
||||
@toggle-play="onCenterTap"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- ===== 音色选择 ===== -->
|
||||
<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>
|
||||
|
||||
<!-- ===== 推荐档位(3 选 1) ===== -->
|
||||
<view class="presets">
|
||||
<view
|
||||
v-for="p in LOOP_PRESETS"
|
||||
:key="p.id"
|
||||
class="preset"
|
||||
:class="{ active: currentLoop === p.id }"
|
||||
@click="onPresetTap(p.id)"
|
||||
>
|
||||
<text class="preset-name">{{ p.label }}</text>
|
||||
<text class="preset-bpm">{{ p.bpm }} BPM</text>
|
||||
<text class="preset-desc">{{ p.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 自定义节奏区域(含标题栏+折叠内容) ===== -->
|
||||
<view class="custom-section">
|
||||
<!-- 标题栏(始终显示) -->
|
||||
<view class="custom-header" @click="onToggleCustom">
|
||||
<text class="custom-title">{{ customExpanded ? '✕ 收起自定义' : '⚙ 自定义节奏' }}</text>
|
||||
</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>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onShow, onHide, onUnload } from '@dcloudio/uni-app'
|
||||
import { useMetronome } from '../hooks/useMetronome'
|
||||
import WalkerCanvas from './components/walker-canvas.vue'
|
||||
|
||||
type LoopId = 'slow' | 'normal' | 'brisk'
|
||||
interface LoopPreset {
|
||||
id: LoopId
|
||||
bpm: number
|
||||
label: string
|
||||
desc: string
|
||||
}
|
||||
|
||||
const LOOP_PRESETS: readonly LoopPreset[] = [
|
||||
{ id: 'slow', bpm: 110, label: '慢走', desc: '热身 · 恢复' },
|
||||
{ id: 'normal', bpm: 130, label: '健走', desc: '日常 · 通勤' },
|
||||
{ id: 'brisk', bpm: 150, label: '快走', desc: '提速 · 燃脂' },
|
||||
]
|
||||
|
||||
const customExpanded = ref<boolean>(false)
|
||||
const currentLoop = ref<LoopId | null>('normal')
|
||||
const currentSound = ref<'crisp' | 'wood'>('crisp')
|
||||
const walkerCanvasRef = ref<any>(null)
|
||||
|
||||
/* 节拍器音效配置 */
|
||||
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 fg = useMetronome({
|
||||
initialBpm: 130,
|
||||
accentEvery: 2,
|
||||
clickSrc: SOUND_PRESETS.crisp.normal,
|
||||
accentSrc: SOUND_PRESETS.crisp.accent,
|
||||
poolSize: 4,
|
||||
// 【完美音画同步核心】
|
||||
onBeat: (index, isAccent) => {
|
||||
if (walkerCanvasRef.value && walkerCanvasRef.value.triggerBeat) {
|
||||
walkerCanvasRef.value.triggerBeat()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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 onCenterTap = () => {
|
||||
if (fg.isPlaying.value) {
|
||||
fg.stop()
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
} else {
|
||||
fg.start()
|
||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 档位卡片
|
||||
* ============================================================ */
|
||||
const onPresetTap = (id: LoopId) => {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 自定义折叠区切换
|
||||
* ============================================================ */
|
||||
const onToggleCustom = () => {
|
||||
customExpanded.value = !customExpanded.value
|
||||
if (customExpanded.value) {
|
||||
fg.preload()
|
||||
} else {
|
||||
// 收起时,如果当前 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)
|
||||
}
|
||||
|
||||
const onSoundSelect = (sound: 'crisp' | 'wood') => {
|
||||
currentSound.value = sound
|
||||
const preset = SOUND_PRESETS[sound]
|
||||
fg.updateAudioSrc(preset.normal, preset.accent)
|
||||
}
|
||||
|
||||
const rootStyle = computed(() => ({
|
||||
'--beat-duration': `${intervalMs.value}ms`,
|
||||
}))
|
||||
|
||||
onShow(() => {
|
||||
fg.preload()
|
||||
})
|
||||
|
||||
function cleanupMetronome() {
|
||||
fg.stop()
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
}
|
||||
|
||||
onHide(cleanupMetronome)
|
||||
onUnload(cleanupMetronome)
|
||||
|
||||
onUnmounted(cleanupMetronome)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ============================================================
|
||||
* 设计 token —— 浅色清爽
|
||||
* ============================================================ */
|
||||
$bg-color: #f8fafc;
|
||||
$card-bg: #ffffff;
|
||||
$card-border: rgba(15, 23, 42, 0.06);
|
||||
$text-1: #0f172a;
|
||||
$text-2: #475569;
|
||||
$text-3: #94a3b8;
|
||||
$brand: #10b981;
|
||||
$brand-soft: #d1fae5;
|
||||
$brand-deep: #047857;
|
||||
$warn: #f59e0b;
|
||||
$warn-soft: #fef3c7;
|
||||
$radius-card: 24rpx;
|
||||
$shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
/* ============================================================
|
||||
* 页面容器
|
||||
* ============================================================ */
|
||||
.page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 30rpx 28rpx 50rpx;
|
||||
background: $bg-color;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 统一运动律动视区 (Canvas)
|
||||
* ============================================================ */
|
||||
.stage-canvas-wrapper {
|
||||
width: 100vw;
|
||||
margin-left: -28rpx;
|
||||
margin-right: -28rpx;
|
||||
flex-shrink: 0; /* 防止被压缩 */
|
||||
height: 600rpx; /* 固定高度,不随内容变化 */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 24rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 音色选择器
|
||||
* ============================================================ */
|
||||
.sound-selector {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
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;
|
||||
|
||||
.sound-icon {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.sound-label {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: $text-2;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
&: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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 三档位推荐
|
||||
* ============================================================ */
|
||||
.presets {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.preset {
|
||||
flex: 1;
|
||||
background: $card-bg;
|
||||
border: 2rpx solid $card-border;
|
||||
border-radius: $radius-card;
|
||||
padding: 26rpx 8rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
box-shadow: $shadow-sm;
|
||||
transition: all 0.18s;
|
||||
|
||||
.preset-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
.preset-bpm {
|
||||
font-size: 22rpx;
|
||||
color: $text-2;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.preset-desc {
|
||||
font-size: 18rpx;
|
||||
color: $text-3;
|
||||
letter-spacing: 1rpx;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: $brand-soft;
|
||||
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);
|
||||
|
||||
.preset-name {
|
||||
color: $brand-deep;
|
||||
}
|
||||
.preset-bpm {
|
||||
color: $brand-deep;
|
||||
}
|
||||
.preset-desc {
|
||||
color: $brand;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 自定义节奏区域(标题栏 + 折叠内容)
|
||||
* ============================================================ */
|
||||
.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);
|
||||
border-radius: $radius-card;
|
||||
box-shadow: 0 6rpx 18rpx rgba(245, 158, 11, 0.08);
|
||||
padding: 20rpx 24rpx 22rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.custom-warn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 4rpx;
|
||||
|
||||
.warn-dot {
|
||||
font-size: 18rpx;
|
||||
color: $warn;
|
||||
}
|
||||
.warn-text {
|
||||
font-size: 22rpx;
|
||||
color: #b45309;
|
||||
letter-spacing: 0.5rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
|
||||
.row-label {
|
||||
font-size: 24rpx;
|
||||
color: $text-2;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1rpx;
|
||||
flex-shrink: 0;
|
||||
width: 70rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.bpm-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
|
||||
.step-btn {
|
||||
min-width: 56rpx;
|
||||
height: 52rpx;
|
||||
line-height: 52rpx;
|
||||
text-align: center;
|
||||
font-size: 22rpx;
|
||||
color: $text-2;
|
||||
background: #f1f5f9;
|
||||
border-radius: 10rpx;
|
||||
font-weight: 600;
|
||||
padding: 0 10rpx;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&:active {
|
||||
background: #e2e8f0;
|
||||
transform: scale(0.94);
|
||||
}
|
||||
}
|
||||
.step-val {
|
||||
min-width: 84rpx;
|
||||
height: 52rpx;
|
||||
line-height: 52rpx;
|
||||
text-align: center;
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: $text-1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: $warn-soft;
|
||||
border-radius: 10rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.meter-tabs {
|
||||
display: flex;
|
||||
gap: 8rpx;
|
||||
|
||||
.meter-tab {
|
||||
height: 52rpx;
|
||||
line-height: 52rpx;
|
||||
padding: 0 18rpx;
|
||||
font-size: 22rpx;
|
||||
color: $text-2;
|
||||
background: #f1f5f9;
|
||||
border-radius: 10rpx;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1rpx;
|
||||
|
||||
&.active {
|
||||
background: $warn;
|
||||
color: #fff;
|
||||
box-shadow: 0 4rpx 10rpx rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
&:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 底部行(已废弃,保留样式以防引用)
|
||||
* ============================================================ */
|
||||
.bottom-row {
|
||||
width: 100%;
|
||||
margin-top: 6rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 6rpx;
|
||||
}
|
||||
|
||||
.bottom-link {
|
||||
font-size: 22rpx;
|
||||
color: $text-3;
|
||||
letter-spacing: 0.5rpx;
|
||||
padding: 8rpx 4rpx;
|
||||
|
||||
&:active {
|
||||
color: $text-2;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-hint {
|
||||
font-size: 22rpx;
|
||||
color: $text-3;
|
||||
letter-spacing: 0.5rpx;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,215 @@
|
||||
# 训练模块静态资源
|
||||
|
||||
本目录用于存放"练一练"功能的所有音频素材。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
training/static/
|
||||
├── audio/ # 当前为空 —— 节拍器音频已全部迁移到 COS CDN
|
||||
├── voice/ # (规划中) TTS 语音教练
|
||||
│ ├── numbers/ 数字报数 1~30
|
||||
│ └── prompts/ 开始/休息/再来 等口令
|
||||
├── bgm/ # (规划中) 训练/休息背景乐
|
||||
└── footprint.svg # 脚印图标(早期方案残留,可保留作为备用素材)
|
||||
```
|
||||
|
||||
> 节拍器音频已完全走 CDN(`gz-1349751149.cos.ap-guangzhou.myqcloud.com`),减少小程序包体积约 210 KB。`useMetronomeBg.ts` / `useMetronome.ts` 中的 URL 即源信息,更换音色只需改 hook 里的常量。
|
||||
|
||||
## 准备步骤
|
||||
|
||||
### 1. 语音素材(小米 MiMo TTS 自动生成)
|
||||
|
||||
需要 Node 18+(用内置 `fetch`)。先准备小米 MiMo API Key:
|
||||
|
||||
- 文档: https://platform.xiaomimimo.com/
|
||||
- API Key 形如 `sk-xxxxxxxx`
|
||||
|
||||
```bash
|
||||
export MIMO_API_KEY=sk-your-key-here
|
||||
|
||||
cd uniapp
|
||||
node scripts/generate-voice.mjs
|
||||
```
|
||||
|
||||
会自动调用 `mimo-v2.5-tts` 模型生成 39 个 mp3(30 个数字 + 9 个口令)到 `voice/` 目录。
|
||||
|
||||
**可选参数**:
|
||||
|
||||
```bash
|
||||
# 换音色(默认 冰糖;可选:冰糖/茉莉/苏打/白桦/Mia/Chloe/Milo/Dean)
|
||||
node scripts/generate-voice.mjs --voice 茉莉
|
||||
|
||||
# 换格式(默认 mp3,需要跟 hooks/useVoiceCoach.ts 里的 .mp3 后缀对应)
|
||||
node scripts/generate-voice.mjs --format wav
|
||||
|
||||
# 强制重新生成(默认存在则跳过)
|
||||
node scripts/generate-voice.mjs --force
|
||||
```
|
||||
|
||||
**音色推荐**(中文女声更适合健身教练):
|
||||
- `冰糖`:温柔甜美,亲和力强(默认)
|
||||
- `茉莉`:清爽利落,有"运动博主"感
|
||||
- `苏打`:年轻男声,有力量感
|
||||
- `白桦`:成熟男声,沉稳
|
||||
|
||||
### 2. 节拍器 click / 循环音轨
|
||||
|
||||
**全部托管在腾讯云 COS(2026-05-26 上传)**,本地不再保留:
|
||||
|
||||
| 用途 | 文件 | 大小 | 引擎 |
|
||||
|---|---|---|---|
|
||||
| 单拍 click(基础) | `click.mp3` | 3.4 KB | InnerAudio |
|
||||
| 单拍 click(木鱼,推荐) | `click-wood.mp3` | 4.6 KB | InnerAudio (custom 模式) |
|
||||
| 循环音轨 慢走 80 BPM | `loop_80bpm_2.mp3` | 71 KB | BgAudio (preset) |
|
||||
| 循环音轨 健走 110 BPM | `loop_110bpm_2.mp3` | 65 KB | BgAudio (preset) |
|
||||
| 循环音轨 快走 130 BPM | `loop_130bpm_2.mp3` | 66 KB | BgAudio (preset) |
|
||||
|
||||
> 微信 `BackgroundAudioManager.src` 只接受 https URL,不支持包内资源,所以必须走 CDN。
|
||||
> COS 域名已加入小程序后台 `downloadFile 合法域名`,跑步视频也走同一域名。
|
||||
|
||||
更换音色:去 [pixabay.com/sound-effects](https://pixabay.com/sound-effects/) 找新素材 → 上传到 COS → 在 `useMetronomeBg.ts` / `useMetronome.ts` 里替换 URL 即可。
|
||||
|
||||
### 3. 背景音乐
|
||||
|
||||
每首 30 秒~2 分钟即可(loop 后听不出接缝)。
|
||||
|
||||
推荐来源(免费可商用):
|
||||
- [pixabay.com/music](https://pixabay.com/music) 搜 "calm" / "meditation" / "lofi"
|
||||
- [freemusicarchive.org](https://freemusicarchive.org)
|
||||
- [bensound.com](https://bensound.com)(含署名)
|
||||
|
||||
文件大小建议 < 1MB(mp3 128kbps 单声道即可)。
|
||||
|
||||
## 在代码里被引用的位置
|
||||
|
||||
- `training/hooks/useMetronome.ts` → `DEFAULT_CLICK_SRC` (CDN: click.mp3)
|
||||
- `training/hooks/useMetronomeBg.ts` → `LOOP_PRESETS[*].src` (CDN: loop_*.mp3)
|
||||
- `training/pages/metronome.vue` → `CLICK_WOOD_URL` (CDN: click-wood.mp3, custom 模式)
|
||||
- `training/hooks/useVoiceCoach.ts` → `voice/numbers/*.mp3`、`voice/prompts/*.mp3` (规划中)
|
||||
- `training/hooks/useTrainingBgm.ts` → `bgm/*.mp3` (规划中)
|
||||
|
||||
如需修改路径,修改对应 hook 文件里的常量即可。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 微信小程序对单个文件大小有 10MB 上限,本目录所有文件加起来建议控制在 5MB 以内。
|
||||
- 小程序整包大小限制(主包 2MB / 总包 20MB),如果资源较大建议放 CDN 而非本地 static。
|
||||
- 把 `useVoiceCoach.ts` 里的 `VOICE_BASE` 改成 CDN URL 即可。
|
||||
|
||||
---
|
||||
|
||||
## 后台/锁屏播放设计备忘(未来健身模块用)
|
||||
|
||||
> 节拍器目前用 `InnerAudioContext` + `requiredBackgroundModes:["audio"]` 已能覆盖 5~10 分钟健走场景。
|
||||
> 真要做长时间训练 BGM、锁屏控制条等高级能力,参考下面的 `BackgroundAudioManager` 方案。
|
||||
|
||||
### 引擎选择对照表
|
||||
|
||||
健身模块的音频天然分两类,配两套引擎不冲突:
|
||||
|
||||
| 音频类型 | 时长 | 推荐引擎 | 理由 |
|
||||
|---|---|---|---|
|
||||
| 训练/休息 BGM | 30s~2min 循环 | **BackgroundAudioManager** | 长流、需要后台/锁屏不停 |
|
||||
| 语音教练("开始/休息/再来") | 1~3s 单句 | InnerAudioContext | 短促,前台用即可 |
|
||||
| 报数(1, 2, 3...) | 0.5~1s | InnerAudioContext + 池子 | 高频短促 |
|
||||
| 节拍器 click | 50~150ms | InnerAudioContext + 池子 | 极短,BgAudio 不接受 |
|
||||
|
||||
**关键约束:BgAudio 是全局单例,一次只能播 1 个音频**。所以让它专门播 BGM 这类"长流",其他短音用 InnerAudio 配合,互不打架。
|
||||
|
||||
### BackgroundAudioManager 限制清单(踩坑预警)
|
||||
|
||||
1. **全局单例**:整个小程序同一时刻只能播一个,多场景要协调切换
|
||||
2. **音频时长 ≥ 1 秒**:太短的 click 会被微信判定异常忽略
|
||||
3. **必填 metadata**:`title` / `coverImgUrl` / `singer` / `epname` / `webUrl` 缺一会报错
|
||||
4. **切 src 有延迟**:200~500ms 初始化抖动,频繁切换会卡顿
|
||||
5. **必须声明 `requiredBackgroundModes:["audio"]`** 才能后台播放
|
||||
6. **iOS 锁屏豁免**:声明后能锁屏继续播,无 5 分钟时长限制(vs InnerAudio 的 5min)
|
||||
7. **会显示系统控制条**:锁屏/通知栏出现带封面+暂停按钮的控制条
|
||||
|
||||
### 推荐架构(健身模块上线时)
|
||||
|
||||
```
|
||||
training/hooks/
|
||||
├── useTrainingBgm.ts → BackgroundAudioManager (后台/锁屏继续放音乐)
|
||||
├── useVoiceCoach.ts → InnerAudioContext (语音指导,前台用)
|
||||
└── useMetronome.ts → InnerAudioContext (节拍器,池子方案,保持现状)
|
||||
```
|
||||
|
||||
页面 `pages.json` 声明:
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "pages/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "练一练",
|
||||
"requiredBackgroundModes": ["audio"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
锁屏会显示 BGM 控制条,老人能直接在锁屏点暂停。训练页面通过 `bgm.onPause()` 监听同步暂停训练,体验连贯。
|
||||
|
||||
### 必备资产清单
|
||||
|
||||
到时候要准备的文件:
|
||||
|
||||
- **BGM 2 首**:`bgm/train-light.mp3`(训练)、`bgm/rest-meditation.mp3`(休息)
|
||||
- 每个 30s~1min,128kbps 单声道,30~80KB
|
||||
- 推荐来源:[pixabay.com/music](https://pixabay.com/music) 搜 "calm" / "meditation" / "lofi"
|
||||
- **锁屏封面**:`audio/cover.jpg` 200×200
|
||||
- 简洁绿色背景 + 训练 emoji 即可
|
||||
|
||||
### BackgroundAudioManager API 速查
|
||||
|
||||
```ts
|
||||
const bgm = uni.getBackgroundAudioManager()
|
||||
|
||||
/* 必填 metadata,缺一会报错 */
|
||||
bgm.title = '健走训练中'
|
||||
bgm.coverImgUrl = '/training/static/audio/cover.jpg'
|
||||
bgm.epname = '甄养堂'
|
||||
bgm.singer = '健走节拍'
|
||||
bgm.webUrl = '' // 必填,空字符串可
|
||||
|
||||
/* src 一旦赋值会自动播放 */
|
||||
bgm.src = '/training/static/audio/bgm/train-light.mp3'
|
||||
|
||||
/* 控制 */
|
||||
bgm.pause() // 暂停 (锁屏控制条仍在)
|
||||
bgm.play() // 继续
|
||||
bgm.stop() // 真停 + 隐藏控制条
|
||||
bgm.seek(30) // 跳到 30s
|
||||
|
||||
/* 事件监听 — 用户从锁屏点暂停时会触发 */
|
||||
bgm.onPause(() => { /* 同步训练 UI 状态 */ })
|
||||
bgm.onPlay(() => {})
|
||||
bgm.onStop(() => {})
|
||||
bgm.onEnded(() => { /* 不 loop 时触发,可手动接下一首 */ })
|
||||
bgm.onError((err) => { console.error(err) })
|
||||
```
|
||||
|
||||
### 切换不同场景音乐的模式
|
||||
|
||||
```ts
|
||||
function switchBgm(scene: 'training' | 'resting' | 'none') {
|
||||
if (scene === 'none') {
|
||||
bgm.stop()
|
||||
return
|
||||
}
|
||||
bgm.title = scene === 'training' ? '健走训练中' : '休息恢复中'
|
||||
bgm.src = scene === 'training'
|
||||
? '/training/static/audio/bgm/train-light.mp3'
|
||||
: '/training/static/audio/bgm/rest-meditation.mp3'
|
||||
/* 注:setSrc 会自动播放,有 200~500ms 切换延迟 */
|
||||
}
|
||||
```
|
||||
|
||||
### 节拍器要不要也升级到 BgAudio?
|
||||
|
||||
**目前不需要**。节拍器升级 BgAudio 的代价:
|
||||
- 需要预合成 3 个档位的循环 mp3(80/110/130 BPM × 2 拍)
|
||||
- 必须砍掉右下角微调按钮(预合成 mp3 改不了 BPM)
|
||||
- 切档位有 200~500ms 卡顿
|
||||
|
||||
如果未来发现"健走 30 分钟以上锁屏会停"才考虑改。当前 5~10 分钟场景 InnerAudio + `requiredBackgroundModes` 够用。
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 150">
|
||||
<g fill="#e2e8f0" fill-opacity="0.92">
|
||||
<path d="M 50,30 C 38,30 28,32 24,40 C 19,50 18,60 22,70 C 26,82 26,92 28,102 C 30,122 40,140 50,140 C 60,140 70,122 72,102 C 74,92 74,82 78,70 C 82,60 81,50 76,40 C 72,32 62,30 50,30 Z"/>
|
||||
<ellipse cx="30" cy="20" rx="6.5" ry="8" transform="rotate(-18 30 20)"/>
|
||||
<ellipse cx="43" cy="11" rx="5" ry="6.8" transform="rotate(-6 43 11)"/>
|
||||
<ellipse cx="55" cy="9" rx="4.5" ry="6.2" transform="rotate(4 55 9)"/>
|
||||
<ellipse cx="66" cy="13" rx="4" ry="5.5" transform="rotate(13 66 13)"/>
|
||||
<ellipse cx="76" cy="20" rx="3.5" ry="5" transform="rotate(22 76 20)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 741 B |
@@ -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),避免侵权风险。
|
||||
@@ -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')}`
|
||||
}
|
||||
Reference in New Issue
Block a user