## 新增功能 ### 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>
234 lines
5.7 KiB
Vue
234 lines
5.7 KiB
Vue
<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>
|