优化握力环训练页:内联视频舞台并修复小程序兼容与完成浮层布局。
将示范视频从自定义组件移至页面主体,避免 mp 端 vOn/ref 崩溃;奖励特效改为 prop 信号驱动,完成弹层移出 page 容器修复右侧裁切,并修正后台音频配置与 CLI 热重载监听。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
|
||||
import { ref, watch, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
|
||||
|
||||
// ============================================================
|
||||
// 类型定义
|
||||
@@ -18,7 +18,8 @@ import { ref, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
|
||||
type CrushItemType = 'egg' | 'walnut' | 'can' | 'balloon'
|
||||
type ParticleShape = 'circle' | 'square' | 'triangle' | 'rect'
|
||||
|
||||
interface Particle {
|
||||
// 碎片(主体飞溅物)
|
||||
interface Debris {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
@@ -29,7 +30,49 @@ interface Particle {
|
||||
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 {
|
||||
@@ -38,19 +81,26 @@ interface Shockwave {
|
||||
radius: number
|
||||
maxRadius: number
|
||||
color: string
|
||||
width: number
|
||||
life: number
|
||||
decay: number
|
||||
}
|
||||
|
||||
interface CrushItemConfig {
|
||||
colors: string[]
|
||||
colors: string[] // 碎片主体色
|
||||
sparkColors: string[] // 火花/高光色(偏亮)
|
||||
shapes: ParticleShape[]
|
||||
particles: number
|
||||
debrisCount: number
|
||||
sparkCount: number
|
||||
sparkleCount: number
|
||||
speedMin: number
|
||||
speedMax: number
|
||||
sizeMin: number
|
||||
sizeMax: number
|
||||
gravity: number
|
||||
flashColor: string // 中心爆闪核心色
|
||||
shockwaveColor: string
|
||||
confetti?: boolean // balloon: 碎片改为彩纸飘落
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -59,55 +109,76 @@ interface CrushItemConfig {
|
||||
|
||||
const CRUSH_ITEMS: Record<CrushItemType, CrushItemConfig> = {
|
||||
egg: {
|
||||
colors: ['#FFEB3B', '#FFF59D', '#FFFFFF', '#FFC107'],
|
||||
colors: ['#FFEB3B', '#FFF59D', '#FFFFFF', '#FFD54F', '#FFC107'],
|
||||
sparkColors: ['#FFFFFF', '#FFF9C4', '#FFEE58'],
|
||||
shapes: ['circle', 'circle', 'circle'],
|
||||
particles: 18,
|
||||
debrisCount: 20,
|
||||
sparkCount: 16,
|
||||
sparkleCount: 5,
|
||||
speedMin: 3,
|
||||
speedMax: 5.5,
|
||||
speedMax: 6,
|
||||
sizeMin: 4,
|
||||
sizeMax: 9,
|
||||
gravity: 0.32,
|
||||
shockwaveColor: 'rgba(255, 235, 59, 0.4)',
|
||||
sizeMax: 10,
|
||||
gravity: 0.34,
|
||||
flashColor: '#FFFDE7',
|
||||
shockwaveColor: 'rgba(255, 224, 130, 0.85)',
|
||||
},
|
||||
walnut: {
|
||||
colors: ['#5D4037', '#795548', '#8D6E63', '#3E2723'],
|
||||
colors: ['#5D4037', '#795548', '#8D6E63', '#A1887F', '#3E2723'],
|
||||
sparkColors: ['#FFCC80', '#FFB74D', '#FFE0B2'],
|
||||
shapes: ['square', 'triangle', 'rect'],
|
||||
particles: 20,
|
||||
speedMin: 2.5,
|
||||
speedMax: 4.5,
|
||||
debrisCount: 22,
|
||||
sparkCount: 18,
|
||||
sparkleCount: 5,
|
||||
speedMin: 2.8,
|
||||
speedMax: 5,
|
||||
sizeMin: 4,
|
||||
sizeMax: 8,
|
||||
gravity: 0.4,
|
||||
shockwaveColor: 'rgba(141, 110, 99, 0.5)',
|
||||
sizeMax: 9,
|
||||
gravity: 0.42,
|
||||
flashColor: '#FFE0B2',
|
||||
shockwaveColor: 'rgba(255, 167, 38, 0.8)',
|
||||
},
|
||||
can: {
|
||||
colors: ['#90A4AE', '#CFD8DC', '#B0BEC5', '#78909C', '#ECEFF1'],
|
||||
shapes: ['rect', 'rect', 'triangle'],
|
||||
particles: 22,
|
||||
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: 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)',
|
||||
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,
|
||||
LIFE_DECAY: 0.02,
|
||||
SHOCKWAVE_DECAY: 0.04,
|
||||
SPARK_RESISTANCE: 0.92,
|
||||
DEBRIS_DECAY: 0.018,
|
||||
SPARK_DECAY: 0.035,
|
||||
SPARKLE_DECAY: 0.045,
|
||||
FLASH_DECAY: 0.11,
|
||||
SHOCKWAVE_DECAY: 0.045,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -122,11 +193,27 @@ const dpr = ref<number>(1)
|
||||
|
||||
const rafId = ref<number | null>(null)
|
||||
const renderRunning = ref<boolean>(false)
|
||||
const particles = ref<Particle[]>([])
|
||||
|
||||
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()
|
||||
})
|
||||
@@ -191,46 +278,100 @@ function triggerCrush(itemType: CrushItemType) {
|
||||
|
||||
const centerX = canvasWidth.value / 2
|
||||
const centerY = canvasHeight.value / 2
|
||||
const maxR = Math.min(canvasWidth.value, canvasHeight.value)
|
||||
|
||||
// 1. 生成双层冲击波
|
||||
shockwaves.value.push({
|
||||
// 1. 中心爆闪
|
||||
flashList.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,
|
||||
radius: maxR * 0.12,
|
||||
maxRadius: maxR * 0.62,
|
||||
color: item.flashColor,
|
||||
life: 1.0,
|
||||
decay: PHYSICS.FLASH_DECAY,
|
||||
})
|
||||
|
||||
// 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
|
||||
// 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)]
|
||||
|
||||
particles.value.push({
|
||||
// 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: Math.sin(angle) * speed,
|
||||
vy: vy0,
|
||||
size,
|
||||
color,
|
||||
shape,
|
||||
rotation: Math.random() * Math.PI * 2,
|
||||
rotationSpeed: (Math.random() - 0.5) * 0.3,
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -238,7 +379,7 @@ function triggerCrush(itemType: CrushItemType) {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 渲染循环(使用 requestAnimationFrame,更准确高效)
|
||||
// 渲染循环
|
||||
// ============================================================
|
||||
|
||||
function ensureRenderLoop() {
|
||||
@@ -253,40 +394,74 @@ function renderLoop() {
|
||||
return
|
||||
}
|
||||
|
||||
// 更新粒子物理
|
||||
for (let i = particles.value.length - 1; i >= 0; i--) {
|
||||
const p = particles.value[i]
|
||||
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 -= 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)
|
||||
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.15
|
||||
sw.life -= PHYSICS.SHOCKWAVE_DECAY
|
||||
if (sw.life <= 0) {
|
||||
shockwaves.value.splice(i, 1)
|
||||
}
|
||||
sw.radius += (sw.maxRadius - sw.radius) * 0.16
|
||||
sw.life -= sw.decay
|
||||
if (sw.life <= 0) shockwaves.value.splice(i, 1)
|
||||
}
|
||||
|
||||
draw()
|
||||
|
||||
// 决定是否继续
|
||||
if (particles.value.length > 0 || shockwaves.value.length > 0) {
|
||||
// canvas 2d 模式下使用 canvas.requestAnimationFrame
|
||||
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
|
||||
@@ -303,35 +478,89 @@ function draw() {
|
||||
const w = canvasWidth.value
|
||||
const h = canvasHeight.value
|
||||
|
||||
// 清空画布(透明)
|
||||
c.clearRect(0, 0, w, h)
|
||||
|
||||
// 1. 绘制冲击波(底层)
|
||||
// —— 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, sw.life)
|
||||
c.lineWidth = 3 * sw.life
|
||||
c.strokeStyle = applyAlphaToRgba(sw.color, Math.min(1, sw.life))
|
||||
c.lineWidth = sw.width * 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)
|
||||
// —— 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 drawParticleShape(c: any, p: Particle, size: number) {
|
||||
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)
|
||||
@@ -339,15 +568,13 @@ function drawParticleShape(c: any, p: Particle, size: number) {
|
||||
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.fillRect(-size * 1.6, -size * 0.5, size * 3.2, size)
|
||||
c.restore()
|
||||
break
|
||||
|
||||
case 'triangle':
|
||||
c.save()
|
||||
c.translate(p.x, p.y)
|
||||
@@ -363,6 +590,27 @@ function drawParticleShape(c: any, p: Particle, size: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// 四角星(尖锐十字星),用于高光闪烁
|
||||
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()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具函数
|
||||
// ============================================================
|
||||
@@ -398,13 +646,13 @@ function cleanup() {
|
||||
rafId.value = null
|
||||
}
|
||||
renderRunning.value = false
|
||||
particles.value = []
|
||||
debrisList.value = []
|
||||
sparkList.value = []
|
||||
sparkleList.value = []
|
||||
flashList.value = []
|
||||
shockwaves.value = []
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
triggerCrush,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
<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">{{ hintText || '点击开始' }}</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
|
||||
hintText?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
hintText: '点击开始',
|
||||
})
|
||||
|
||||
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: linear-gradient(180deg, #f0fdfa 0%, #f8fafc 55%, #f1f5f9 100%);
|
||||
}
|
||||
|
||||
/* 环境氛围 - 中心放射光晕 */
|
||||
.ambient-glow {
|
||||
position: absolute;
|
||||
width: 1200rpx;
|
||||
height: 1200rpx;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle,
|
||||
rgba(204, 251, 241, 0.9) 0%,
|
||||
rgba(240, 253, 250, 0.5) 30%,
|
||||
rgba(241, 245, 249, 0.2) 55%,
|
||||
transparent 72%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 握力环容器 - 主视觉
|
||||
* ============================================================ */
|
||||
.ring-container {
|
||||
position: relative;
|
||||
width: 560rpx;
|
||||
height: 560rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 外发光 - 只在播放时显示(节省 GPU) */
|
||||
.ring-aura {
|
||||
position: absolute;
|
||||
width: 620rpx;
|
||||
height: 620rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle,
|
||||
rgba(20, 184, 166, 0.4) 0%,
|
||||
rgba(45, 212, 191, 0.15) 35%,
|
||||
rgba(20, 184, 166, 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: 460rpx;
|
||||
height: 460rpx;
|
||||
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,
|
||||
#5eead4 0%,
|
||||
#2dd4bf 45%,
|
||||
#14b8a6 100%);
|
||||
box-shadow:
|
||||
0 20rpx 56rpx rgba(20, 184, 166, 0.35),
|
||||
0 10rpx 28rpx rgba(45, 212, 191, 0.25),
|
||||
inset 0 -18rpx 36rpx rgba(15, 118, 110, 0.3),
|
||||
inset 0 18rpx 36rpx rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
/* 顶部高光 - 模拟材质感 */
|
||||
.ring-highlight {
|
||||
position: absolute;
|
||||
width: 360rpx;
|
||||
height: 100rpx;
|
||||
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: 250rpx;
|
||||
height: 250rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f0fdfa 100%);
|
||||
box-shadow:
|
||||
inset 0 8rpx 20rpx rgba(15, 118, 110, 0.25),
|
||||
inset 0 -2rpx 8rpx rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
/* 播放时的挤压动画(只影响 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: 96rpx;
|
||||
height: 96rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 12rpx 32rpx rgba(20, 184, 166, 0.28),
|
||||
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: 32rpx solid #0f766e;
|
||||
border-top: 22rpx solid transparent;
|
||||
border-bottom: 22rpx solid transparent;
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
@keyframes hint-bounce {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 26rpx;
|
||||
color: #0f766e;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3rpx;
|
||||
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: 80rpx;
|
||||
color: #0f766e;
|
||||
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: #14b8a6;
|
||||
font-weight: 700;
|
||||
letter-spacing: 6rpx;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -12,15 +12,56 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 握力环画布区域 ===== -->
|
||||
<!-- ===== 握力环画布区域(直接内联,避免原生 video 嵌自定义组件的 mp 兼容问题) ===== -->
|
||||
<view class="stage-canvas-wrapper">
|
||||
<GripRingCanvas
|
||||
ref="gripCanvasRef"
|
||||
:bpm="currentBpm"
|
||||
:is-playing="isPlaying"
|
||||
:hint-text="centerHintText"
|
||||
@toggle-play="onTogglePlay"
|
||||
/>
|
||||
<!-- 环境氛围:中心放射光晕 -->
|
||||
<view class="ambient-glow" />
|
||||
|
||||
<!-- 捏碎特效画布(prop 信号驱动) -->
|
||||
<CrushCanvas :crush-signal="crushSignal" />
|
||||
|
||||
<!-- 握力环容器 -->
|
||||
<view class="ring-container" :class="{ playing: isPlaying }">
|
||||
<!-- 节拍光环 - 跟随 BPM 精确缩放 -->
|
||||
<view class="beat-halo" />
|
||||
|
||||
<!-- 视频示范主体(圆形) -->
|
||||
<view class="video-ring">
|
||||
<video
|
||||
id="gripDemoVideo"
|
||||
class="demo-video"
|
||||
:src="TUTORIAL_VIDEO_URL"
|
||||
:poster="TUTORIAL_POSTER_URL"
|
||||
muted
|
||||
loop
|
||||
:controls="false"
|
||||
:show-center-play-btn="false"
|
||||
:show-play-btn="false"
|
||||
:show-fullscreen-btn="false"
|
||||
:show-progress="false"
|
||||
:enable-progress-gesture="false"
|
||||
object-fit="cover"
|
||||
/>
|
||||
<view class="video-ring-border" />
|
||||
</view>
|
||||
|
||||
<!-- 中心提示(仅未开始时显示) -->
|
||||
<view v-if="!isPlaying" class="center-hint">
|
||||
<view class="hint-icon">
|
||||
<view class="play-triangle" />
|
||||
</view>
|
||||
<text class="hint-text">{{ centerHintText }}</text>
|
||||
</view>
|
||||
|
||||
<!-- BPM 角标(播放中) -->
|
||||
<view v-else class="bpm-chip">
|
||||
<text class="bpm-value">{{ currentBpm }}</text>
|
||||
<text class="bpm-label">BPM</text>
|
||||
</view>
|
||||
|
||||
<!-- 点击层(捕获点击切换训练状态) -->
|
||||
<view class="tap-overlay" @click="onTogglePlay" />
|
||||
</view>
|
||||
|
||||
<view class="bgm-toggle" @click="toggleBgm">
|
||||
<text class="bgm-icon">{{ bgmEnabled ? '🎵' : '🔇' }}</text>
|
||||
@@ -46,7 +87,7 @@
|
||||
</view>
|
||||
<view class="stat-chip">
|
||||
<text class="stat-chip-label">糖分</text>
|
||||
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? `${totalSugar.toFixed(1)}g` : '—' }}</text>
|
||||
<text class="stat-chip-value" :class="{ muted: !hasStats }">{{ hasStats ? `≈${totalSugar.toFixed(1)}g` : '—' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -99,9 +140,10 @@
|
||||
<text class="end-link" @click="onEndTraining">结束训练 ›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 奖励弹窗 ===== -->
|
||||
<view v-if="showRewardPopup" class="reward-popup-mask" @click="closeRewardPopup">
|
||||
<!-- ===== 奖励弹窗 ===== -->
|
||||
<view v-if="showRewardPopup" class="reward-popup-mask" @click="closeRewardPopup">
|
||||
<view class="reward-popup" @click.stop>
|
||||
<text class="reward-emoji">{{ currentReward.emoji }}</text>
|
||||
<text class="reward-title">{{ currentReward.title }}</text>
|
||||
@@ -111,46 +153,6 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 视频教学入口 ===== -->
|
||||
<view class="tutorial-card" @click="openTutorial">
|
||||
<view class="tutorial-icon">
|
||||
<view class="tutorial-play-triangle" />
|
||||
</view>
|
||||
<view class="tutorial-info">
|
||||
<text class="tutorial-title">观看动作示范</text>
|
||||
<text class="tutorial-sub">视频教学 · 学会正确握法</text>
|
||||
</view>
|
||||
<text class="tutorial-arrow">›</text>
|
||||
</view>
|
||||
|
||||
<!-- ===== 视频教学弹层 ===== -->
|
||||
<view v-if="showTutorial" class="tutorial-mask" @click="closeTutorial">
|
||||
<view class="tutorial-modal" @click.stop>
|
||||
<view class="tutorial-header">
|
||||
<text class="tutorial-modal-title">动作示范</text>
|
||||
<view class="tutorial-close" @click="closeTutorial">
|
||||
<text class="tutorial-close-icon">×</text>
|
||||
</view>
|
||||
</view>
|
||||
<video
|
||||
class="tutorial-video"
|
||||
:src="TUTORIAL_VIDEO_URL"
|
||||
controls
|
||||
autoplay
|
||||
loop
|
||||
object-fit="contain"
|
||||
show-center-play-btn
|
||||
/>
|
||||
<view class="tutorial-tips">
|
||||
<text class="tutorial-tips-title">关键要领</text>
|
||||
<view v-for="(tip, i) in TIPS" :key="i" class="tutorial-tip-item">
|
||||
<view class="tip-dot" />
|
||||
<text class="tip-content">{{ tip }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 训练完成(浮层) ===== -->
|
||||
<view v-if="showSummary" class="summary-mask" @click="dismissSummary">
|
||||
<view class="completion-card" @click.stop>
|
||||
@@ -175,15 +177,15 @@
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-icon">🔥</text>
|
||||
<text class="stat-value">{{ totalSugar.toFixed(1) }}g</text>
|
||||
<text class="stat-label">消耗糖分</text>
|
||||
<text class="stat-value">≈{{ totalSugar.toFixed(1) }}g</text>
|
||||
<text class="stat-label">消耗糖分 (估算)</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 食物对照 -->
|
||||
<view v-if="sugarComparisons.length > 0" class="food-comparison-card">
|
||||
<view class="food-comparison-header">
|
||||
<text class="food-comparison-title">相当于消耗了</text>
|
||||
<text class="food-comparison-title">大约相当于</text>
|
||||
<text class="food-comparison-hint">这些食物的糖分 🍭</text>
|
||||
</view>
|
||||
|
||||
@@ -212,14 +214,13 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { onShow, onHide, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import { useMetronome } from '../hooks/useMetronome'
|
||||
import GripRingCanvas from './components/grip-ring-canvas.vue'
|
||||
import CrushCanvas from './components/crush-canvas.vue'
|
||||
|
||||
type PresetId = 'light' | 'medium' | 'heavy'
|
||||
interface GripPreset {
|
||||
@@ -241,10 +242,10 @@ const PRESET_ICONS: Record<PresetId, string> = {
|
||||
heavy: '🔥',
|
||||
}
|
||||
|
||||
const TIPS = ['握紧停 1 秒', '完全放松', '左右手交替'] as const
|
||||
|
||||
// 视频教学
|
||||
// 画布指导视频(跟随训练状态播放)
|
||||
const TUTORIAL_VIDEO_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260528/20260528173911bf6f93386.mp4'
|
||||
// 视频封面(未开始/暂停时显示的静态首帧)
|
||||
const TUTORIAL_POSTER_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260529/202605291030231aed12802.jpg'
|
||||
|
||||
interface TargetOption {
|
||||
minutes: 0 | 5 | 10 | 15
|
||||
@@ -324,10 +325,10 @@ const FOOD_SUGAR_TABLE = [
|
||||
|
||||
const currentPreset = ref<PresetId>('medium')
|
||||
const targetMinutes = ref<0 | 5 | 10 | 15>(0) // 训练目标(0=自由)
|
||||
const gripCanvasRef = ref<any>(null)
|
||||
// 捏碎特效信号(prop 驱动, 替代跨组件 ref 调用; nonce 保证连续同类型也能触发 watch)
|
||||
const crushSignal = ref<{ type: 'egg' | 'walnut' | 'can' | 'balloon'; nonce: number } | null>(null)
|
||||
const showRewardPopup = ref(false)
|
||||
const showSummary = ref(false)
|
||||
const showTutorial = ref(false) // 视频教学弹层
|
||||
const currentReward = ref<RewardConfig>(REWARDS[0])
|
||||
const crushAudioCtx = ref<UniApp.InnerAudioContext | null>(null)
|
||||
const bgmAudioCtx = ref<UniApp.InnerAudioContext | null>(null)
|
||||
@@ -355,6 +356,18 @@ const metronome = useMetronome({
|
||||
})
|
||||
const { isPlaying } = metronome
|
||||
|
||||
// 示范视频跟随训练状态: 开始训练才播放, 暂停/未开始则暂停
|
||||
// 视频已内联在页面主体(非自定义组件内), page 级 createVideoContext 即可定位
|
||||
watch(isPlaying, (playing) => {
|
||||
const videoCtx = uni.createVideoContext('gripDemoVideo')
|
||||
if (!videoCtx) return
|
||||
if (playing) {
|
||||
videoCtx.play()
|
||||
} else {
|
||||
videoCtx.pause()
|
||||
}
|
||||
})
|
||||
|
||||
const intervalMs = computed(() => 60000 / currentBpm.value)
|
||||
const hasStats = computed(() => isPlaying.value || totalReps.value > 0)
|
||||
const isPaused = computed(() => !isPlaying.value && totalReps.value > 0 && !showSummary.value)
|
||||
@@ -379,7 +392,7 @@ const statusText = computed(() => {
|
||||
// 分享文案
|
||||
const shareTitle = computed(() => {
|
||||
if (totalReps.value === 0) return '握力环训练 · 一起练起来'
|
||||
return `刚刚完成 ${totalReps.value} 次握力训练,消耗 ${totalSugar.value.toFixed(1)}g 糖分 💪`
|
||||
return `刚刚完成 ${totalReps.value} 次握力训练,约消耗 ${totalSugar.value.toFixed(1)}g 糖分 💪`
|
||||
})
|
||||
|
||||
|
||||
@@ -451,9 +464,10 @@ function triggerReward() {
|
||||
const reward = REWARDS[rewardIndex.value]
|
||||
currentReward.value = reward
|
||||
|
||||
// 立即触发粒子特效和音效
|
||||
gripCanvasRef.value?.triggerCrushEffect(reward.itemType)
|
||||
// 立即触发粒子特效 + 音效 + 震动
|
||||
crushSignal.value = { type: reward.itemType, nonce: Date.now() }
|
||||
playCrushSound(reward.sound)
|
||||
triggerHaptic()
|
||||
|
||||
// 递进到下一个奖励等级
|
||||
rewardIndex.value++
|
||||
@@ -475,6 +489,21 @@ function closeRewardPopup() {
|
||||
showRewardPopup.value = false
|
||||
}
|
||||
|
||||
// 奖励震动反馈: "砰" + 余震两段,配合爆裂特效
|
||||
function triggerHaptic() {
|
||||
const buzz = (type: 'heavy' | 'medium' | 'light') => {
|
||||
// type 仅微信高版本支持,fail 时降级为无参短震
|
||||
uni.vibrateShort({
|
||||
type,
|
||||
fail: () => {
|
||||
try { uni.vibrateShort({}) } catch (_) {}
|
||||
},
|
||||
} as any)
|
||||
}
|
||||
buzz('heavy')
|
||||
setTimeout(() => buzz('medium'), 120)
|
||||
}
|
||||
|
||||
function playCrushSound(sound: string) {
|
||||
// 如果没有音效URL,跳过播放
|
||||
if (!sound) return
|
||||
@@ -587,20 +616,6 @@ function dismissSummary() {
|
||||
showSummary.value = false
|
||||
}
|
||||
|
||||
// 打开视频教学(避免与 BGM 音轨冲突)
|
||||
function openTutorial() {
|
||||
stopBgm()
|
||||
showTutorial.value = true
|
||||
}
|
||||
|
||||
function closeTutorial() {
|
||||
showTutorial.value = false
|
||||
// 训练中且 BGM 开关开着 → 恢复 BGM
|
||||
if (isPlaying.value && bgmEnabled.value) {
|
||||
startBgm()
|
||||
}
|
||||
}
|
||||
|
||||
function onContinue() {
|
||||
showSummary.value = false
|
||||
startTrainingInternal()
|
||||
@@ -837,6 +852,212 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
border-radius: 0 0 40rpx 40rpx;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(180deg, #f0fdfa 0%, #f8fafc 55%, #f1f5f9 100%);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 握力环舞台(由 grip-ring-canvas 组件内联而来)
|
||||
* ============================================================ */
|
||||
.ambient-glow {
|
||||
position: absolute;
|
||||
width: 1200rpx;
|
||||
height: 1200rpx;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle,
|
||||
rgba(204, 251, 241, 0.9) 0%,
|
||||
rgba(240, 253, 250, 0.5) 30%,
|
||||
rgba(241, 245, 249, 0.2) 55%,
|
||||
transparent 72%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ring-container {
|
||||
position: relative;
|
||||
width: 560rpx;
|
||||
height: 560rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.beat-halo {
|
||||
position: absolute;
|
||||
width: 560rpx;
|
||||
height: 560rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle,
|
||||
rgba(20, 184, 166, 0.45) 0%,
|
||||
rgba(45, 212, 191, 0.18) 38%,
|
||||
rgba(20, 184, 166, 0) 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.ring-container.playing .beat-halo {
|
||||
opacity: 1;
|
||||
animation: beat-pulse calc(var(--beat-duration, 750ms) * 2) ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes beat-pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
transform: scale(0.86);
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
.video-ring {
|
||||
position: relative;
|
||||
width: 460rpx;
|
||||
height: 460rpx;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 20rpx 56rpx rgba(20, 184, 166, 0.35),
|
||||
0 10rpx 28rpx rgba(45, 212, 191, 0.22);
|
||||
will-change: transform;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ring-container.playing .video-ring {
|
||||
animation: video-squeeze calc(var(--beat-duration, 750ms) * 2) ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes video-squeeze {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.demo-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: #e2f5f1;
|
||||
}
|
||||
|
||||
.video-ring-border {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
box-shadow:
|
||||
inset 0 0 0 4rpx rgba(255, 255, 255, 0.6),
|
||||
inset 0 8rpx 24rpx rgba(15, 118, 110, 0.25);
|
||||
}
|
||||
|
||||
.center-hint {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
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: 116rpx;
|
||||
height: 116rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
backdrop-filter: blur(6rpx);
|
||||
border: 3rpx solid rgba(255, 255, 255, 0.85);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 12rpx 32rpx rgba(15, 23, 42, 0.3);
|
||||
animation: hint-bounce 2s ease-in-out infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.play-triangle {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 34rpx solid #ffffff;
|
||||
border-top: 23rpx solid transparent;
|
||||
border-bottom: 23rpx solid transparent;
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
@keyframes hint-bounce {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 26rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3rpx;
|
||||
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.bpm-chip {
|
||||
position: absolute;
|
||||
bottom: 24rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6rpx;
|
||||
padding: 8rpx 22rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(15, 118, 110, 0.78);
|
||||
box-shadow: 0 6rpx 18rpx rgba(15, 118, 110, 0.35);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.bpm-value {
|
||||
font-size: 36rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
letter-spacing: -1rpx;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bpm-label {
|
||||
font-size: 20rpx;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-weight: 700;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.tap-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -1189,193 +1410,6 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 视频教学入口卡片
|
||||
* ============================================================ */
|
||||
.tutorial-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 18rpx 24rpx 18rpx 18rpx;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f0fdfa 100%);
|
||||
border-radius: 24rpx;
|
||||
border: 2rpx solid rgba(20, 184, 166, 0.18);
|
||||
box-shadow:
|
||||
0 4rpx 16rpx rgba(15, 23, 42, 0.04),
|
||||
0 1rpx 0 rgba(255, 255, 255, 0.9) inset;
|
||||
transition: transform 0.15s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
background: linear-gradient(135deg, #f0fdfa 0%, #ecfeff 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.tutorial-icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(140deg, $brand 0%, $brand-deep 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4rpx 12rpx rgba(20, 184, 166, 0.35);
|
||||
}
|
||||
|
||||
.tutorial-play-triangle {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 22rpx solid #fff;
|
||||
border-top: 14rpx solid transparent;
|
||||
border-bottom: 14rpx solid transparent;
|
||||
margin-left: 6rpx;
|
||||
}
|
||||
|
||||
.tutorial-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tutorial-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.tutorial-sub {
|
||||
font-size: 22rpx;
|
||||
color: $text-3;
|
||||
letter-spacing: 0.5rpx;
|
||||
}
|
||||
|
||||
.tutorial-arrow {
|
||||
font-size: 36rpx;
|
||||
color: #cbd5e1;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 视频教学弹层
|
||||
* ============================================================ */
|
||||
.tutorial-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
backdrop-filter: blur(8rpx);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 950;
|
||||
padding: 0 24rpx;
|
||||
animation: fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
.tutorial-modal {
|
||||
width: 100%;
|
||||
max-width: 700rpx;
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: tutorial-pop 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes tutorial-pop {
|
||||
from {
|
||||
transform: scale(0.92);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tutorial-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 28rpx 16rpx;
|
||||
}
|
||||
|
||||
.tutorial-modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.tutorial-close {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f1f5f9;
|
||||
|
||||
&:active {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
.tutorial-close-icon {
|
||||
font-size: 40rpx;
|
||||
color: $text-2;
|
||||
line-height: 1;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.tutorial-video {
|
||||
width: 100%;
|
||||
// 视频源 720×720 方形, 容器同比贴合避免左右黑边
|
||||
// modal max-width = 700rpx, 这里也用 700rpx 完美 1:1
|
||||
height: 700rpx;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.tutorial-tips {
|
||||
padding: 24rpx 28rpx 32rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.tutorial-tips-title {
|
||||
font-size: 24rpx;
|
||||
color: $text-3;
|
||||
letter-spacing: 1rpx;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.tutorial-tip-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.tip-dot {
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 50%;
|
||||
background: $brand;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tip-content {
|
||||
font-size: 26rpx;
|
||||
color: $text-2;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 完成浮层
|
||||
* ============================================================ */
|
||||
|
||||
Reference in New Issue
Block a user