Files
test-repo/docs/.vitepress/theme/components/appendix/canvas-intro/AnimationLoopDemo.vue
T

639 lines
13 KiB
Vue
Raw Normal View History

<!--
AnimationLoopDemo.vue
Canvas 动画循环演示组件
用途
展示 Canvas 动画的基本原理包括 requestAnimationFrame清除重绘动画循环
交互功能
- 播放控制播放/暂停动画
- 速度调整控制动画速度
- 显示帧率实时显示 FPS
- 多种动画不同的动画效果示例
-->
<template>
<div class="animation-demo">
<div class="control-panel">
<div class="playback-controls">
<button class="play-btn" @click="togglePlay">
<span class="icon">{{ isPlaying ? '⏸️' : '▶️' }}</span>
{{ isPlaying ? 'Pause' : 'Play' }}
</button>
<button class="reset-btn" @click="resetAnimation">
<span class="icon">🔄</span>
Reset / 重置
</button>
</div>
<div class="animation-selector">
<label>Animation / 动画类型</label>
<select v-model="animationType">
<option value="bounce">Bouncing Ball / 弹跳球</option>
<option value="rotate">Rotating Square / 旋转方块</option>
<option value="wave">Wave / 波浪</option>
</select>
</div>
<div class="parameters">
<div class="param-row">
<label>Speed / 速度: {{ speed }}x</label>
<input
type="range"
v-model.number="speed"
min="0.1"
max="3"
step="0.1"
/>
</div>
<div class="param-row">
<label>Object Count / 对象数量: {{ objectCount }}</label>
<input type="range" v-model.number="objectCount" min="1" max="10" />
</div>
</div>
<div class="stats">
<div class="stat-item">
<span class="label">FPS:</span>
<span class="value">{{ fps }}</span>
</div>
<div class="stat-item">
<span class="label">Frame:</span>
<span class="value">{{ frame }}</span>
</div>
</div>
</div>
<div class="canvas-container">
<canvas ref="canvasRef" width="600" height="400"></canvas>
</div>
<div class="code-display">
<h4>Animation Loop Code / 动画循环代码</h4>
<pre><code>{{ animationCode }}</code></pre>
</div>
<div class="explanation">
<h4>Animation Principles / 动画原理</h4>
<ul>
<li>
<strong>requestAnimationFrame</strong>
浏览器提供的动画 API在每次重绘前调用回调函数通常为 60FPS
</li>
<li>
<strong>Clear & Redraw</strong>
每帧先清除画布再重新绘制所有内容
</li>
<li>
<strong>State Update</strong>
更新对象位置角度等状态
</li>
<li>
<strong>Performance</strong>
使用时间差计算位置确保不同刷新率下动画速度一致
</li>
</ul>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>提示</strong>
动画的本质是快速连续绘制静态画面Canvas 每秒可以绘制 60
60FPS形成流畅的动画效果
</p>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
const canvasRef = ref(null)
const isPlaying = ref(false)
const animationType = ref('bounce')
const speed = ref(1)
const objectCount = ref(3)
const fps = ref(0)
const frame = ref(0)
let animationId = null
let lastTime = 0
let frameCount = 0
let fpsTime = 0
// 动画对象状态
const balls = ref([])
const angle = ref(0)
const animationCode = computed(() => {
const templates = {
bounce: `// 弹跳球动画
let balls = [
{ x: 100, y: 100, vx: 2, vy: 3, radius: 20 },
// ... 更多球
]
function animate(timestamp) {
// 清除画布
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 更新和绘制每个球
balls.forEach(ball => {
// 更新位置
ball.x += ball.vx * ${speed.value}
ball.y += ball.vy * ${speed.value}
// 边界碰撞检测
if (ball.x + ball.radius > canvas.width || ball.x - ball.radius < 0) {
ball.vx = -ball.vx
}
if (ball.y + ball.radius > canvas.height || ball.y - ball.radius < 0) {
ball.vy = -ball.vy
}
// 绘制
ctx.beginPath()
ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2)
ctx.fill()
})
// 请求下一帧
requestAnimationFrame(animate)
}
// 启动动画
requestAnimationFrame(animate)`,
rotate: `// 旋转方块动画
let angle = 0
function animate(timestamp) {
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 更新角度
angle += 0.02 * ${speed.value}
// 保存当前状态
ctx.save()
// 移动到中心点
ctx.translate(canvas.width / 2, canvas.height / 2)
// 旋转
ctx.rotate(angle)
// 绘制方块
ctx.fillStyle = '#3498db'
ctx.fillRect(-50, -50, 100, 100)
// 恢复状态
ctx.restore()
requestAnimationFrame(animate)
}`,
wave: `// 波浪动画
let offset = 0
function animate(timestamp) {
ctx.clearRect(0, 0, canvas.width, canvas.height)
offset += 0.05 * ${speed.value}
// 绘制波浪
ctx.beginPath()
ctx.moveTo(0, canvas.height / 2)
for (let x = 0; x < canvas.width; x++) {
const y = canvas.height / 2 + Math.sin(x * 0.02 + offset) * 50
ctx.lineTo(x, y)
}
ctx.strokeStyle = '#3498db'
ctx.lineWidth = 3
ctx.stroke()
requestAnimationFrame(animate)
}`
}
return templates[animationType.value]
})
const initBalls = () => {
balls.value = []
const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6']
for (let i = 0; i < objectCount.value; i++) {
balls.value.push({
x: 100 + Math.random() * 400,
y: 100 + Math.random() * 200,
vx: (Math.random() - 0.5) * 4,
vy: (Math.random() - 0.5) * 4,
radius: 15 + Math.random() * 20,
color: colors[i % colors.length]
})
}
}
const drawBouncingBall = (ctx) => {
balls.value.forEach((ball) => {
// 更新位置
ball.x += ball.vx * speed.value
ball.y += ball.vy * speed.value
// 边界碰撞
if (ball.x + ball.radius > 600 || ball.x - ball.radius < 0) {
ball.vx = -ball.vx
}
if (ball.y + ball.radius > 400 || ball.y - ball.radius < 0) {
ball.vy = -ball.vy
}
// 绘制
ctx.fillStyle = ball.color
ctx.beginPath()
ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2)
ctx.fill()
// 高光效果
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'
ctx.beginPath()
ctx.arc(
ball.x - ball.radius * 0.3,
ball.y - ball.radius * 0.3,
ball.radius * 0.4,
0,
Math.PI * 2
)
ctx.fill()
})
}
const drawRotatingSquare = (ctx) => {
angle.value += 0.02 * speed.value
const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6']
const positions = [
{ x: 200, y: 200 },
{ x: 400, y: 200 },
{ x: 300, y: 300 }
]
positions.slice(0, objectCount.value).forEach((pos, i) => {
ctx.save()
ctx.translate(pos.x, pos.y)
ctx.rotate(angle.value + (i * Math.PI) / 3)
ctx.fillStyle = colors[i % colors.length]
ctx.fillRect(-40, -40, 80, 80)
ctx.restore()
})
}
const drawWave = (ctx) => {
angle.value += 0.05 * speed.value
const colors = ['#e74c3c', '#3498db', '#2ecc71']
for (let w = 0; w < objectCount.value; w++) {
ctx.beginPath()
ctx.moveTo(0, 200)
for (let x = 0; x < 600; x++) {
const y = 200 + Math.sin(x * 0.02 + angle.value + w * 0.5) * (50 + w * 20)
ctx.lineTo(x, y)
}
ctx.strokeStyle = colors[w % colors.length]
ctx.lineWidth = 3
ctx.stroke()
}
}
const draw = () => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
// 清除画布
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 绘制背景
ctx.fillStyle = '#fafafa'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// 根据类型绘制
switch (animationType.value) {
case 'bounce':
drawBouncingBall(ctx)
break
case 'rotate':
drawRotatingSquare(ctx)
break
case 'wave':
drawWave(ctx)
break
}
frame.value++
}
const animate = (timestamp) => {
if (!lastTime) lastTime = timestamp
const deltaTime = timestamp - lastTime
// 计算 FPS
frameCount++
fpsTime += deltaTime
if (fpsTime >= 1000) {
fps.value = Math.round((frameCount * 1000) / fpsTime)
frameCount = 0
fpsTime = 0
}
lastTime = timestamp
draw()
if (isPlaying.value) {
animationId = requestAnimationFrame(animate)
}
}
const togglePlay = () => {
isPlaying.value = !isPlaying.value
if (isPlaying.value) {
lastTime = 0
animationId = requestAnimationFrame(animate)
} else {
if (animationId) {
cancelAnimationFrame(animationId)
}
}
}
const resetAnimation = () => {
if (animationId) {
cancelAnimationFrame(animationId)
}
isPlaying.value = false
frame.value = 0
angle.value = 0
initBalls()
draw()
}
watch(objectCount, () => {
initBalls()
if (!isPlaying.value) {
draw()
}
})
onMounted(() => {
initBalls()
draw()
})
onUnmounted(() => {
if (animationId) {
cancelAnimationFrame(animationId)
}
})
</script>
<style scoped>
.animation-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;
}
.playback-controls {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.play-btn,
.reset-btn {
padding: 0.625rem 1.25rem;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.25s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.play-btn {
background: #2ecc71;
color: white;
}
.play-btn:hover {
background: #27ae60;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(46, 204, 113, 0.4);
}
.reset-btn {
background: #95a5a6;
color: white;
}
.reset-btn:hover {
background: #7f8c8d;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(149, 165, 166, 0.4);
}
.animation-selector {
margin-bottom: 1.25rem;
}
.animation-selector label {
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--vp-c-text-1);
font-size: 0.875rem;
}
.animation-selector select {
width: 100%;
padding: 0.5rem 0.75rem;
border: 2px solid var(--vp-c-divider);
border-radius: 6px;
font-size: 0.875rem;
background: var(--vp-c-bg);
cursor: pointer;
transition: all 0.2s;
}
.animation-selector select:hover {
border-color: var(--vp-c-brand);
}
.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;
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;
}
.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: 6px;
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: 6px;
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>