Files
zyt/TUICallKit-Vue3/training/pages/components/crush-canvas.vue
T
longandClaude Opus 4.7 261f8315de feat(training): 握力环训练趣味化 - 捏碎特效 + 卡路里统计
## 新增功能

### 1. Canvas 粒子捏碎特效
- 新建 crush-canvas.vue 组件(透明叠加层)
- 4 种捏碎物品:鸡蛋🥚、核桃🥜、易拉罐🥫、气球🎈
- 粒子物理模拟:径向扩散 + 重力 + 空气阻力 + 生命值衰减
- 性能优化:固定 60fps 时间步长,粒子边界清理

### 2. 随机触发逻辑
- 平均每 8-12 次随机触发捏碎特效
- 在握力环压缩峰值(scale 0.65)时触发
- 随机选择 4 种物品之一
- 资源清理:训练暂停/结束时清理所有定时器

### 3. 卡路里统计与完成页面
- 标准 MET 公式计算卡路里(MET = 3.5,默认 60kg)
- 5 种食物自动匹配(苹果、鸡蛋、巧克力、香蕉、米饭)
- 完成页面显示:组数、次数、时长、卡路里、食物对比
- 样式设计:浅色清爽风格,绿色渐变卡片

### 4. 预留接口
- useTTS.ts:预留 MiMo TTS 语音鼓励接口

## 技术实现

- Canvas 粒子系统:20-30 个粒子,物理模拟流畅
- 类型安全:完整的 TypeScript 类型定义
- 性能优化:固定时间步长、边界清理、资源管理
- 响应式布局:适配不同屏幕尺寸

## 相关任务

Task: .trellis/tasks/05-27-grip-ring-gamification
PRD: prd.md
Research: research/calorie-calculation.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 16:52:41 +08:00

234 lines
5.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="crush-canvas-container">
<canvas
canvas-id="crush"
id="crush"
class="crush-canvas"
:style="{ width: canvasSizePx + 'px', height: canvasSizePx + 'px' }"
></canvas>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
// 粒子接口
interface Particle {
x: number
y: number
vx: number
vy: number
size: number
color: string
life: number
}
// 捏碎物品类型
type CrushItemType = 'egg' | 'walnut' | 'can' | 'balloon'
// 捏碎物品配置
interface CrushItemConfig {
emoji: string
color: string
particles: number
}
const CRUSH_ITEMS: Record<CrushItemType, CrushItemConfig> = {
egg: { emoji: '🥚', color: '#FFF9C4', particles: 20 },
walnut: { emoji: '🥜', color: '#8D6E63', particles: 25 },
can: { emoji: '🥫', color: '#B0BEC5', particles: 30 },
balloon: { emoji: '🎈', color: '#FF5252', particles: 15 }
}
// 物理参数常量
const PHYSICS = {
GRAVITY: 0.3, // 重力加速度
AIR_RESISTANCE: 0.98, // 空气阻力系数
LIFE_DECAY: 0.015, // 生命值衰减速度(约 67 帧 = 1.1 秒)
SPEED_MIN: 3, // 最小初速度
SPEED_MAX: 5, // 最大初速度
SIZE_MIN: 4, // 最小粒子尺寸
SIZE_MAX: 8, // 最大粒子尺寸
FPS: 60 // 目标帧率
}
// Canvas 尺寸(px
const canvasSizePx = ref<number>(240)
const ctx = ref<any>(null)
const timer = ref<number | null>(null)
const renderRunning = ref<boolean>(false)
const particles = ref<Particle[]>([])
onMounted(() => {
initCanvas()
})
onBeforeUnmount(() => {
cleanup()
})
// 初始化 Canvas
function initCanvas() {
setTimeout(() => {
const query = uni.createSelectorQuery().in(getCurrentInstance())
query.select('.crush-canvas-container').boundingClientRect(data => {
if (data && data.width > 0) {
// 获取实际渲染尺寸(px
canvasSizePx.value = data.width
}
if (!ctx.value) {
ctx.value = uni.createCanvasContext('crush', getCurrentInstance())
}
}).exec()
}, 100)
}
// 触发捏碎特效(供外部调用)
function triggerCrush(itemType: CrushItemType) {
if (!ctx.value) {
return
}
const item = CRUSH_ITEMS[itemType]
if (!item) {
return
}
// 生成粒子
const centerX = canvasSizePx.value / 2
const centerY = canvasSizePx.value / 2
const particleCount = item.particles
for (let i = 0; i < particleCount; i++) {
// 随机角度和速度(径向扩散)
const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.5
const speed = PHYSICS.SPEED_MIN + Math.random() * (PHYSICS.SPEED_MAX - PHYSICS.SPEED_MIN)
particles.value.push({
x: centerX,
y: centerY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
size: PHYSICS.SIZE_MIN + Math.random() * (PHYSICS.SIZE_MAX - PHYSICS.SIZE_MIN),
color: item.color,
life: 1.0
})
}
ensureRenderLoop()
}
// 确保渲染循环运行
function ensureRenderLoop() {
if (renderRunning.value) return
renderRunning.value = true
renderLoop()
}
// 渲染循环
function renderLoop() {
// 更新粒子物理
for (let i = particles.value.length - 1; i >= 0; i--) {
const p = particles.value[i]
// 重力
p.vy += PHYSICS.GRAVITY
// 空气阻力
p.vx *= PHYSICS.AIR_RESISTANCE
p.vy *= PHYSICS.AIR_RESISTANCE
// 位置更新
p.x += p.vx
p.y += p.vy
// 生命值衰减
p.life -= PHYSICS.LIFE_DECAY
// 移除死亡粒子或飞出画布的粒子
const outOfBounds = p.x < -50 || p.x > canvasSizePx.value + 50 ||
p.y < -50 || p.y > canvasSizePx.value + 50
if (p.life <= 0 || outOfBounds) {
particles.value.splice(i, 1)
}
}
// 绘制
draw()
ctx.value.draw(false)
// 决定是否继续渲染
if (particles.value.length > 0) {
timer.value = setTimeout(() => {
renderLoop()
}, 1000 / PHYSICS.FPS) as unknown as number
} else {
timer.value = null
renderRunning.value = false
}
}
// 绘制粒子
function draw() {
const c = ctx.value
const size = canvasSizePx.value
// 清空画布(透明)
c.clearRect(0, 0, size, size)
// 绘制所有粒子
particles.value.forEach(p => {
const alpha = p.life
const currentSize = p.size * (0.5 + p.life * 0.5)
c.beginPath()
c.arc(p.x, p.y, currentSize, 0, Math.PI * 2)
c.fillStyle = hexToRgba(p.color, alpha)
c.fill()
})
}
// 颜色转换工具
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 cleanup() {
if (timer.value) {
clearTimeout(timer.value)
timer.value = null
}
renderRunning.value = false
particles.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>