Files
test-repo/docs/.vitepress/theme/components/appendix/canvas-intro/ParticleSystemDemo.vue
T
sanbuphy ebe2bf6109 feat: enhance demo components with consistent styling and info boxes
- Add standardized header and info box components to all demo files
- Improve visual consistency with theme colors and spacing
- Add max-height and overflow-y for better content containment
- Update package.json build script with --force flag
- Add .gitignore entries for REFACTORING files
- Fix table formatting in audio-intro.md
2026-02-14 12:14:07 +08:00

601 lines
13 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
<!--
ParticleSystemDemo.vue
Canvas 粒子系统演示组件
用途
展示 Canvas 粒子系统的实现包括粒子生成运动生命周期管理
交互功能
- 鼠标交互鼠标移动产生粒子
- 参数调整粒子数量速度大小颜色
- 效果选择不同的粒子效果
-->
<template>
<div class="particle-demo">
<div class="control-panel">
<div class="effect-selector">
<label>Particle Effect / 粒子效果</label>
<div class="button-group">
<button
v-for="effect in effects"
:key="effect.value"
:class="{ active: currentEffect === effect.value }"
@click="currentEffect = effect.value"
>
{{ effect.label }}
</button>
</div>
</div>
<div class="parameters">
<div class="param-row">
<label>Particle Count / 粒子数量: {{ maxParticles }}</label>
<input
type="range"
v-model.number="maxParticles"
min="50"
max="500"
step="50"
/>
</div>
<div class="param-row">
<label>Particle Size / 粒子大小: {{ particleSize }}</label>
<input type="range" v-model.number="particleSize" min="1" max="10" />
</div>
<div class="param-row">
<label>Speed / 速度: {{ speed }}</label>
<input
type="range"
v-model.number="speed"
min="0.5"
max="3"
step="0.1"
/>
</div>
<div class="param-row">
<label>Gravity / 重力: {{ gravity }}</label>
<input
type="range"
v-model.number="gravity"
min="0"
max="0.5"
step="0.05"
/>
</div>
</div>
<div class="stats">
<div class="stat-item">
<span class="label">Active Particles:</span>
<span class="value">{{ particles.length }}</span>
</div>
<div class="stat-item">
<span class="label">FPS:</span>
<span class="value">{{ fps }}</span>
</div>
</div>
<button class="clear-btn" @click="clearParticles">
<span class="icon">🗑</span>
Clear Particles / 清除粒子
</button>
</div>
<div class="canvas-container">
<canvas
ref="canvasRef"
width="600"
height="400"
@mousemove="handleMouseMove"
@click="handleClick"
></canvas>
</div>
<div class="code-display">
<h4>Particle System Code / 粒子系统代码</h4>
<pre><code>{{ particleCode }}</code></pre>
</div>
<div class="explanation">
<h4>Particle System Tips / 粒子系统要点</h4>
<ul>
<li>
<strong>粒子类</strong>
每个粒子是一个对象包含位置速度加速度生命周期等属性
</li>
<li>
<strong>更新循环</strong>
每帧更新所有粒子的位置和状态移除死亡的粒子
</li>
<li>
<strong>性能优化</strong>
限制粒子数量使用对象池复用粒子对象
</li>
<li>
<strong>视觉效果</strong>
使用透明度混合模式渐变等增强视觉效果
</li>
</ul>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>提示</strong>
移动鼠标或点击画布来产生粒子不同的效果有不同的交互方式
</p>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
const canvasRef = ref(null)
const currentEffect = ref('trail')
const maxParticles = ref(200)
const particleSize = ref(3)
const speed = ref(1)
const gravity = ref(0.1)
const particles = ref([])
const fps = ref(0)
let animationId = null
let lastTime = 0
let frameCount = 0
let fpsTime = 0
let mousePos = { x: 300, y: 200 }
const effects = [
{ value: 'trail', label: 'Mouse Trail / 鼠标轨迹' },
{ value: 'firework', label: 'Firework / 烟花' },
{ value: 'snow', label: 'Snowfall / 雪花' },
{ value: 'fountain', label: 'Fountain / 喷泉' }
]
const particleCode = computed(() => {
return `// 粒子系统核心代码
class Particle {
constructor(x, y) {
this.x = x
this.y = y
this.vx = (Math.random() - 0.5) * ${speed}
this.vy = (Math.random() - 0.5) * ${speed}
this.life = 1.0
this.decay = 0.01 + Math.random() * 0.02
this.size = ${particleSize}
this.color = this.randomColor()
}
update() {
this.x += this.vx
this.y += this.vy
this.vy += ${gravity} // 重力
this.life -= this.decay
}
draw(ctx) {
ctx.globalAlpha = this.life
ctx.fillStyle = this.color
ctx.beginPath()
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2)
ctx.fill()
ctx.globalAlpha = 1.0
}
isDead() {
return this.life <= 0
}
}
// 动画循环
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 更新和绘制粒子
particles = particles.filter(p => !p.isDead())
particles.forEach(p => {
p.update()
p.draw(ctx)
})
requestAnimationFrame(animate)
}`
})
const colors = [
'#e74c3c',
'#3498db',
'#2ecc71',
'#f39c12',
'#9b59b6',
'#1abc9c',
'#e91e63',
'#00bcd4'
]
class Particle {
constructor(x, y, effect) {
this.x = x
this.y = y
this.effect = effect
this.life = 1.0
this.size = particleSize.value + Math.random() * 2
this.color = colors[Math.floor(Math.random() * colors.length)]
// 根据效果类型设置不同的初始速度
switch (effect) {
case 'trail':
this.vx = (Math.random() - 0.5) * 2 * speed.value
this.vy = (Math.random() - 0.5) * 2 * speed.value
this.decay = 0.02
break
case 'firework':
const angle = Math.random() * Math.PI * 2
const velocity = Math.random() * 5 * speed.value
this.vx = Math.cos(angle) * velocity
this.vy = Math.sin(angle) * velocity
this.decay = 0.015
break
case 'snow':
this.vx = (Math.random() - 0.5) * 0.5 * speed.value
this.vy = 1 + Math.random() * 2 * speed.value
this.decay = 0.005
this.color = '#ecf0f1'
break
case 'fountain':
this.vx = (Math.random() - 0.5) * 2 * speed.value
this.vy = -3 - Math.random() * 5 * speed.value
this.decay = 0.01
break
}
}
update() {
this.x += this.vx
this.y += this.vy
if (this.effect === 'snow' || this.effect === 'fountain') {
this.vy += gravity.value
}
this.life -= this.decay
}
draw(ctx) {
ctx.globalAlpha = this.life
ctx.fillStyle = this.color
ctx.beginPath()
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2)
ctx.fill()
ctx.globalAlpha = 1.0
}
isDead() {
return this.life <= 0 || this.y > 400 || this.x < 0 || this.x > 600
}
}
const createParticles = (x, y, count) => {
for (let i = 0; i < count; i++) {
if (particles.value.length >= maxParticles.value) {
particles.value.shift()
}
particles.value.push(new Particle(x, y, currentEffect.value))
}
}
const draw = () => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
// 清除画布(使用半透明背景产生拖尾效果)
ctx.fillStyle =
currentEffect.value === 'trail'
? 'rgba(250, 250, 250, 0.2)'
: 'rgba(250, 250, 250, 1)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// 更新和绘制粒子
particles.value = particles.value.filter((p) => !p.isDead())
particles.value.forEach((p) => {
p.update()
p.draw(ctx)
})
// 持续产生粒子(雪花效果)
if (currentEffect.value === 'snow') {
createParticles(Math.random() * 600, -10, 2)
}
}
const animate = (timestamp) => {
if (!lastTime) lastTime = timestamp
const deltaTime = timestamp - lastTime
frameCount++
fpsTime += deltaTime
if (fpsTime >= 1000) {
fps.value = Math.round((frameCount * 1000) / fpsTime)
frameCount = 0
fpsTime = 0
}
lastTime = timestamp
draw()
animationId = requestAnimationFrame(animate)
}
const handleMouseMove = (e) => {
const canvas = canvasRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
mousePos.x = e.clientX - rect.left
mousePos.y = e.clientY - rect.top
// 鼠标轨迹效果
if (currentEffect.value === 'trail') {
createParticles(mousePos.x, mousePos.y, 3)
}
}
const handleClick = (e) => {
const canvas = canvasRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
// 烟花和喷泉效果在点击时产生
if (currentEffect.value === 'firework') {
createParticles(x, y, 50)
} else if (currentEffect.value === 'fountain') {
createParticles(x, y, 30)
}
}
const clearParticles = () => {
particles.value = []
}
onMounted(() => {
animationId = requestAnimationFrame(animate)
})
onUnmounted(() => {
if (animationId) {
cancelAnimationFrame(animationId)
}
})
</script>
<style scoped>
.particle-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 1.5rem;
background: var(--vp-c-bg-soft);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.control-panel {
margin-bottom: 1.5rem;
}
.effect-selector {
margin-bottom: 15px;
}
.effect-selector label {
display: block;
font-weight: 600;
margin-bottom: 8px;
color: #2c3e50;
}
.button-group {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.button-group button {
padding: 0.5rem 1rem;
border: 2px solid var(--vp-c-divider);
background: var(--vp-c-bg);
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.25s ease;
}
.button-group button:hover {
border-color: var(--vp-c-brand);
background: var(--vp-c-bg-soft);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.button-group button.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-brand);
color: white;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
}
.parameters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 15px;
}
.param-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.param-row label {
font-size: 13px;
font-weight: 500;
color: #555;
}
.param-row input[type='range'] {
width: 100%;
}
.stats {
display: flex;
gap: 20px;
margin-bottom: 15px;
padding: 12px;
background: white;
border-radius: 6px;
}
.stat-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.stat-item .label {
font-weight: 600;
color: #555;
}
.stat-item .value {
font-family: 'Courier New', monospace;
color: #2c3e50;
background: #f0f0f0;
padding: 4px 12px;
border-radius: 4px;
font-weight: 700;
}
.clear-btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
background: #e74c3c;
color: white;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.clear-btn:hover {
background: #c0392b;
transform: translateY(-1px);
}
.canvas-container {
display: flex;
justify-content: center;
margin: 1.5rem 0;
padding: 1.5rem;
background: var(--vp-c-bg);
border-radius: 12px;
border: 2px solid var(--vp-c-divider);
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.05);
}
canvas {
border: 3px solid var(--vp-c-divider);
border-radius: 8px;
cursor: crosshair;
background: #ffffff;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.code-display {
margin-top: 1.5rem;
padding: 1.25rem;
background: #1e293b;
border-radius: 12px;
overflow-x: auto;
border: 2px solid #334155;
}
.code-display h4 {
color: #f8fafc;
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
font-weight: 600;
}
.code-display pre {
margin: 0;
}
.code-display code {
color: #e2e8f0;
font-family: var(--vp-font-family-mono);
font-size: 0.75rem;
line-height: 1.7;
}
.explanation {
margin: 1.5rem 0;
padding: 1.25rem;
background: var(--vp-c-bg);
border-radius: 8px;
border: 1px solid var(--vp-c-divider);
}
.explanation h4 {
margin: 0 0 0.75rem 0;
color: var(--vp-c-text-1);
font-size: 0.875rem;
font-weight: 600;
}
.explanation ul {
margin: 0;
padding-left: 1.25rem;
}
.explanation li {
margin-bottom: 0.5rem;
color: var(--vp-c-text-2);
font-size: 0.875rem;
line-height: 1.6;
}
.info-box {
margin-top: 1.5rem;
padding: 1rem 1.25rem;
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border-radius: 12px;
border-left: 4px solid #f59e0b;
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.2);
}
.info-box p {
margin: 0;
font-size: 0.875rem;
color: #92400e;
display: flex;
align-items: flex-start;
gap: 0.5rem;
line-height: 1.6;
}
.info-box .icon {
font-size: 1.125rem;
flex-shrink: 0;
}
</style>