feat: update docs and components, fix DLQ demo bug
This commit is contained in:
@@ -0,0 +1,607 @@
|
||||
<!--
|
||||
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 #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.playback-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.play-btn,
|
||||
.reset-btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
background: #2ecc71;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
background: #27ae60;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
background: #95a5a6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
background: #7f8c8d;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.animation-selector {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.animation-selector label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.animation-selector select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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: 20px 0;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
canvas {
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.code-display {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #2c3e50;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-display h4 {
|
||||
color: #ecf0f1;
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.code-display pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-display code {
|
||||
color: #ecf0f1;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.explanation {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.explanation h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.explanation ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.explanation li {
|
||||
margin-bottom: 8px;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
margin-top: 15px;
|
||||
padding: 12px;
|
||||
background: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #856404;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-box .icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,421 @@
|
||||
<!--
|
||||
CanvasBasicsDemo.vue
|
||||
Canvas 基础演示组件
|
||||
|
||||
用途:
|
||||
展示 Canvas 2D 的基本绘图能力,包括矩形、圆形、线条和文字的绘制
|
||||
|
||||
交互功能:
|
||||
- 形状选择:选择不同的基本形状
|
||||
- 颜色调整:自定义填充和描边颜色
|
||||
- 参数调整:控制大小、位置等参数
|
||||
- 实时绘制:即时在 Canvas 上显示效果
|
||||
-->
|
||||
<template>
|
||||
<div class="canvas-basics-demo">
|
||||
<div class="control-panel">
|
||||
<div class="shape-selector">
|
||||
<label>Shape / 形状</label>
|
||||
<div class="button-group">
|
||||
<button
|
||||
v-for="shape in shapes"
|
||||
:key="shape.value"
|
||||
:class="{ active: currentShape === shape.value }"
|
||||
@click="currentShape = shape.value"
|
||||
>
|
||||
{{ shape.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="parameters">
|
||||
<div class="param-row">
|
||||
<label>Fill Color / 填充颜色</label>
|
||||
<input type="color" v-model="fillColor" />
|
||||
</div>
|
||||
|
||||
<div class="param-row">
|
||||
<label>Stroke Color / 描边颜色</label>
|
||||
<input type="color" v-model="strokeColor" />
|
||||
</div>
|
||||
|
||||
<div class="param-row">
|
||||
<label>Stroke Width / 描边宽度: {{ strokeWidth }}px</label>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="strokeWidth"
|
||||
min="1"
|
||||
max="20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="param-row" v-if="currentShape === 'rect'">
|
||||
<label>Size / 大小: {{ rectSize }}px</label>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="rectSize"
|
||||
min="20"
|
||||
max="200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="param-row" v-if="currentShape === 'circle'">
|
||||
<label>Radius / 半径: {{ circleRadius }}px</label>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="circleRadius"
|
||||
min="10"
|
||||
max="150"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="param-row" v-if="currentShape === 'line'">
|
||||
<label>Line Length / 线条长度: {{ lineLength }}px</label>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="lineLength"
|
||||
min="50"
|
||||
max="300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="draw-btn" @click="draw">
|
||||
<span class="icon">🎨</span>
|
||||
Draw / 绘制
|
||||
</button>
|
||||
|
||||
<button class="clear-btn" @click="clearCanvas">
|
||||
<span class="icon">🗑️</span>
|
||||
Clear / 清除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="canvas-container">
|
||||
<canvas ref="canvasRef" width="600" height="400"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="code-display">
|
||||
<h4>Code / 代码</h4>
|
||||
<pre><code>{{ currentCode }}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
<span class="icon">💡</span>
|
||||
<strong>提示:</strong>
|
||||
Canvas 是一个位图画布,所有绘制都是像素操作。绘制后无法修改已有内容,只能覆盖或清除重绘。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
|
||||
const canvasRef = ref(null)
|
||||
const currentShape = ref('rect')
|
||||
const fillColor = ref('#3498db')
|
||||
const strokeColor = ref('#2c3e50')
|
||||
const strokeWidth = ref(2)
|
||||
const rectSize = ref(100)
|
||||
const circleRadius = ref(50)
|
||||
const lineLength = ref(150)
|
||||
|
||||
const shapes = [
|
||||
{ value: 'rect', label: 'Rectangle / 矩形' },
|
||||
{ value: 'circle', label: 'Circle / 圆形' },
|
||||
{ value: 'line', label: 'Line / 线条' }
|
||||
]
|
||||
|
||||
const currentCode = computed(() => {
|
||||
const codeTemplates = {
|
||||
rect: `const canvas = document.getElementById('myCanvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
ctx.fillStyle = '${fillColor.value}'
|
||||
ctx.strokeStyle = '${strokeColor.value}'
|
||||
ctx.lineWidth = ${strokeWidth.value}
|
||||
|
||||
// 绘制填充矩形
|
||||
ctx.fillRect(${300 - rectSize.value / 2}, ${200 - rectSize.value / 2}, ${rectSize.value}, ${rectSize.value})
|
||||
|
||||
// 绘制描边矩形
|
||||
ctx.strokeRect(${300 - rectSize.value / 2}, ${200 - rectSize.value / 2}, ${rectSize.value}, ${rectSize.value})`,
|
||||
|
||||
circle: `const canvas = document.getElementById('myCanvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
ctx.fillStyle = '${fillColor.value}'
|
||||
ctx.strokeStyle = '${strokeColor.value}'
|
||||
ctx.lineWidth = ${strokeWidth.value}
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(300, 200, ${circleRadius.value}, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.stroke()`,
|
||||
|
||||
line: `const canvas = document.getElementById('myCanvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
ctx.strokeStyle = '${strokeColor.value}'
|
||||
ctx.lineWidth = ${strokeWidth.value}
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(${300 - lineLength.value / 2}, 200)
|
||||
ctx.lineTo(${300 + lineLength.value / 2}, 200)
|
||||
ctx.stroke()`
|
||||
}
|
||||
|
||||
return codeTemplates[currentShape.value]
|
||||
})
|
||||
|
||||
const clearCanvas = () => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
// 清除画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 设置样式
|
||||
ctx.fillStyle = fillColor.value
|
||||
ctx.strokeStyle = strokeColor.value
|
||||
ctx.lineWidth = strokeWidth.value
|
||||
|
||||
const centerX = canvas.width / 2
|
||||
const centerY = canvas.height / 2
|
||||
|
||||
// 根据选择的形状绘制
|
||||
switch (currentShape.value) {
|
||||
case 'rect':
|
||||
ctx.fillRect(
|
||||
centerX - rectSize.value / 2,
|
||||
centerY - rectSize.value / 2,
|
||||
rectSize.value,
|
||||
rectSize.value
|
||||
)
|
||||
ctx.strokeRect(
|
||||
centerX - rectSize.value / 2,
|
||||
centerY - rectSize.value / 2,
|
||||
rectSize.value,
|
||||
rectSize.value
|
||||
)
|
||||
break
|
||||
|
||||
case 'circle':
|
||||
ctx.beginPath()
|
||||
ctx.arc(centerX, centerY, circleRadius.value, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
break
|
||||
|
||||
case 'line':
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(centerX - lineLength.value / 2, centerY)
|
||||
ctx.lineTo(centerX + lineLength.value / 2, centerY)
|
||||
ctx.stroke()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 监听参数变化,自动重绘
|
||||
watch(
|
||||
[fillColor, strokeColor, strokeWidth, rectSize, circleRadius, lineLength],
|
||||
() => {
|
||||
draw()
|
||||
}
|
||||
)
|
||||
|
||||
watch(currentShape, () => {
|
||||
draw()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
draw()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.canvas-basics-demo {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.shape-selector {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.shape-selector label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.button-group button {
|
||||
padding: 8px 16px;
|
||||
border: 2px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.button-group button:hover {
|
||||
border-color: #3498db;
|
||||
background: #f0f8ff;
|
||||
}
|
||||
|
||||
.button-group button.active {
|
||||
border-color: #3498db;
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
|
||||
.param-row input[type="color"] {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.draw-btn,
|
||||
.clear-btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.draw-btn {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.draw-btn:hover {
|
||||
background: #2980b9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: #c0392b;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 20px 0;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
canvas {
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.code-display {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #2c3e50;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-display h4 {
|
||||
color: #ecf0f1;
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.code-display pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-display code {
|
||||
color: #ecf0f1;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
margin-top: 15px;
|
||||
padding: 12px;
|
||||
background: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #856404;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-box .icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,416 @@
|
||||
<!--
|
||||
CoordinateSystemDemo.vue
|
||||
Canvas 坐标系统演示组件
|
||||
|
||||
用途:
|
||||
展示 Canvas 的坐标系统,包括原点位置、坐标方向、网格绘制等
|
||||
|
||||
交互功能:
|
||||
- 网格显示:开关网格线和坐标轴
|
||||
- 点位置拖拽:拖动点查看坐标变化
|
||||
- 坐标显示:实时显示鼠标位置和选中点坐标
|
||||
-->
|
||||
<template>
|
||||
<div class="coordinate-demo">
|
||||
<div class="control-panel">
|
||||
<div class="toggle-group">
|
||||
<label class="toggle-option">
|
||||
<input type="checkbox" v-model="showGrid" />
|
||||
<span>Show Grid / 显示网格</span>
|
||||
</label>
|
||||
|
||||
<label class="toggle-option">
|
||||
<input type="checkbox" v-model="showAxis" />
|
||||
<span>Show Axis / 显示坐标轴</span>
|
||||
</label>
|
||||
|
||||
<label class="toggle-option">
|
||||
<input type="checkbox" v-model="showCoordinates" />
|
||||
<span>Show Coordinates / 显示坐标</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="info-display">
|
||||
<div class="info-item">
|
||||
<span class="label">Canvas Width:</span>
|
||||
<span class="value">600px</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Canvas Height:</span>
|
||||
<span class="value">400px</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Mouse Position:</span>
|
||||
<span class="value">({{ mouseX }}, {{ mouseY }})</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="selectedPoint">
|
||||
<span class="label">Selected Point:</span>
|
||||
<span class="value">({{ selectedPoint.x }}, {{ selectedPoint.y }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="canvas-container">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
width="600"
|
||||
height="400"
|
||||
@mousemove="handleMouseMove"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseup="handleMouseUp"
|
||||
@mouseleave="handleMouseUp"
|
||||
></canvas>
|
||||
</div>
|
||||
|
||||
<div class="explanation">
|
||||
<h4>Canvas Coordinate System / Canvas 坐标系统</h4>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Origin / 原点:</strong>在左上角,坐标为 (0, 0)
|
||||
</li>
|
||||
<li>
|
||||
<strong>X Axis / X 轴:</strong>向右为正方向,从 0 到 canvas.width
|
||||
</li>
|
||||
<li>
|
||||
<strong>Y Axis / Y 轴:</strong>向下为正方向,从 0 到 canvas.height
|
||||
</li>
|
||||
<li>
|
||||
<strong>Unit / 单位:</strong>像素 (px),与 CSS 像素 1:1 对应
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="code-display">
|
||||
<h4>Example Code / 示例代码</h4>
|
||||
<pre><code>// 绘制坐标轴
|
||||
const canvas = document.getElementById('myCanvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
// X 轴(红色)
|
||||
ctx.strokeStyle = '#e74c3c'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, 0)
|
||||
ctx.lineTo(canvas.width, 0)
|
||||
ctx.stroke()
|
||||
|
||||
// Y 轴(蓝色)
|
||||
ctx.strokeStyle = '#3498db'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, 0)
|
||||
ctx.lineTo(0, canvas.height)
|
||||
ctx.stroke()
|
||||
|
||||
// 绘制点
|
||||
ctx.fillStyle = '#2ecc71'
|
||||
ctx.beginPath()
|
||||
ctx.arc({{ Math.round(selectedPoint?.x || 100) }}, {{ Math.round(selectedPoint?.y || 100) }}, 5, 0, Math.PI * 2)
|
||||
ctx.fill()</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
<span class="icon">💡</span>
|
||||
<strong>提示:</strong>
|
||||
Canvas 的 Y 轴方向与传统数学坐标系相反,向下为正。这在处理图形定位时需要特别注意。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
|
||||
const canvasRef = ref(null)
|
||||
const showGrid = ref(true)
|
||||
const showAxis = ref(true)
|
||||
const showCoordinates = ref(true)
|
||||
const mouseX = ref(0)
|
||||
const mouseY = ref(0)
|
||||
const selectedPoint = ref(null)
|
||||
const isDragging = ref(false)
|
||||
|
||||
const points = [
|
||||
{ x: 100, y: 100 },
|
||||
{ x: 300, y: 200 },
|
||||
{ x: 500, y: 100 }
|
||||
]
|
||||
|
||||
const drawGrid = (ctx) => {
|
||||
if (!showGrid.value) return
|
||||
|
||||
ctx.strokeStyle = '#f0f0f0'
|
||||
ctx.lineWidth = 1
|
||||
|
||||
// 垂直线
|
||||
for (let x = 0; x <= 600; x += 50) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, 0)
|
||||
ctx.lineTo(x, 400)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// 水平线
|
||||
for (let y = 0; y <= 400; y += 50) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, y)
|
||||
ctx.lineTo(600, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
const drawAxis = (ctx) => {
|
||||
if (!showAxis.value) return
|
||||
|
||||
ctx.lineWidth = 2
|
||||
|
||||
// X 轴
|
||||
ctx.strokeStyle = '#e74c3c'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, 0)
|
||||
ctx.lineTo(600, 0)
|
||||
ctx.stroke()
|
||||
|
||||
// Y 轴
|
||||
ctx.strokeStyle = '#3498db'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, 0)
|
||||
ctx.lineTo(0, 400)
|
||||
ctx.stroke()
|
||||
|
||||
// 原点标记
|
||||
ctx.fillStyle = '#2c3e50'
|
||||
ctx.font = '12px Arial'
|
||||
ctx.fillText('(0,0)', 5, 15)
|
||||
}
|
||||
|
||||
const drawPoints = (ctx) => {
|
||||
points.forEach((point, index) => {
|
||||
// 绘制点
|
||||
ctx.fillStyle = index === 0 ? '#e74c3c' : index === 1 ? '#3498db' : '#2ecc71'
|
||||
ctx.beginPath()
|
||||
ctx.arc(point.x, point.y, 8, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// 绘制坐标
|
||||
if (showCoordinates.value) {
|
||||
ctx.fillStyle = '#2c3e50'
|
||||
ctx.font = '12px Arial'
|
||||
ctx.fillText(`(${Math.round(point.x)}, ${Math.round(point.y)})`, point.x + 12, point.y - 12)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 绘制各层
|
||||
drawGrid(ctx)
|
||||
drawAxis(ctx)
|
||||
drawPoints(ctx)
|
||||
}
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
mouseX.value = Math.round(e.clientX - rect.left)
|
||||
mouseY.value = Math.round(e.clientY - rect.top)
|
||||
|
||||
// 拖拽逻辑
|
||||
if (isDragging.value && selectedPoint.value) {
|
||||
selectedPoint.value.x = mouseX.value
|
||||
selectedPoint.value.y = mouseY.value
|
||||
draw()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
|
||||
// 检查是否点击了某个点
|
||||
points.forEach((point) => {
|
||||
const distance = Math.sqrt((x - point.x) ** 2 + (y - point.y) ** 2)
|
||||
if (distance < 15) {
|
||||
selectedPoint.value = point
|
||||
isDragging.value = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
watch([showGrid, showAxis, showCoordinates], () => {
|
||||
draw()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
draw()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.coordinate-demo {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.toggle-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.toggle-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.toggle-option input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.info-display {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.info-item .value {
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #2c3e50;
|
||||
background: #f0f0f0;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 20px 0;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
canvas {
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 4px;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.explanation {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.explanation h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.explanation ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.explanation li {
|
||||
margin-bottom: 8px;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.code-display {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #2c3e50;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-display h4 {
|
||||
color: #ecf0f1;
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.code-display pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-display code {
|
||||
color: #ecf0f1;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
margin-top: 15px;
|
||||
padding: 12px;
|
||||
background: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #856404;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-box .icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,669 @@
|
||||
<!--
|
||||
EventHandlingDemo.vue
|
||||
Canvas 事件处理演示组件
|
||||
|
||||
用途:
|
||||
展示 Canvas 中的鼠标、键盘事件处理,包括点击、拖拽、悬停等交互
|
||||
|
||||
交互功能:
|
||||
- 鼠标点击:在点击位置创建对象
|
||||
- 拖拽:拖动对象移动
|
||||
- 悬停:高亮显示鼠标下的对象
|
||||
- 键盘控制:使用键盘控制对象
|
||||
-->
|
||||
<template>
|
||||
<div class="event-demo">
|
||||
<div class="control-panel">
|
||||
<div class="mode-selector">
|
||||
<label>Interaction Mode / 交互模式</label>
|
||||
<div class="button-group">
|
||||
<button
|
||||
v-for="mode in modes"
|
||||
:key="mode.value"
|
||||
:class="{ active: currentMode === mode.value }"
|
||||
@click="currentMode = mode.value"
|
||||
>
|
||||
{{ mode.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="instructions">
|
||||
<h4>Instructions / 操作说明</h4>
|
||||
<ul>
|
||||
<li v-if="currentMode === 'click'">
|
||||
<strong>Click Mode:</strong>点击画布创建圆形,按住 Shift 可创建不同颜色
|
||||
</li>
|
||||
<li v-if="currentMode === 'drag'">
|
||||
<strong>Drag Mode:</strong>拖拽圆形移动位置,拖拽时会改变颜色
|
||||
</li>
|
||||
<li v-if="currentMode === 'hover'">
|
||||
<strong>Hover Mode:</strong>鼠标悬停在圆形上会高亮显示并显示坐标
|
||||
</li>
|
||||
<li v-if="currentMode === 'keyboard'">
|
||||
<strong>Keyboard Mode:</strong>使用方向键移动选中的圆形,Delete 键删除
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="event-log">
|
||||
<h4>Event Log / 事件日志</h4>
|
||||
<div class="log-container">
|
||||
<div
|
||||
v-for="(log, index) in eventLogs"
|
||||
:key="index"
|
||||
class="log-entry"
|
||||
:class="log.type"
|
||||
>
|
||||
<span class="log-time">{{ log.time }}</span>
|
||||
<span class="log-message">{{ log.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="clear-btn" @click="clearAll">
|
||||
<span class="icon">🗑️</span>
|
||||
Clear All / 清除全部
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="canvas-container">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
width="600"
|
||||
height="400"
|
||||
@click="handleClick"
|
||||
@mousemove="handleMouseMove"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseup="handleMouseUp"
|
||||
@mouseleave="handleMouseLeave"
|
||||
tabindex="0"
|
||||
@keydown="handleKeyDown"
|
||||
></canvas>
|
||||
</div>
|
||||
|
||||
<div class="code-display">
|
||||
<h4>Event Handling Code / 事件处理代码</h4>
|
||||
<pre><code>{{ currentCode }}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="explanation">
|
||||
<h4>Event Handling Tips / 事件处理要点</h4>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>坐标转换:</strong>
|
||||
使用 getBoundingClientRect() 获取 Canvas 在页面中的位置,计算相对坐标
|
||||
</li>
|
||||
<li>
|
||||
<strong>碰撞检测:</strong>
|
||||
对于圆形,计算鼠标位置到圆心的距离;对于矩形,判断点是否在范围内
|
||||
</li>
|
||||
<li>
|
||||
<strong>事件委托:</strong>
|
||||
Canvas 只有一个元素,需要手动判断事件发生在哪个对象上
|
||||
</li>
|
||||
<li>
|
||||
<strong>性能优化:</strong>
|
||||
使用 requestAnimationFrame 优化重绘,避免频繁操作
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const canvasRef = ref(null)
|
||||
const currentMode = ref('click')
|
||||
const circles = ref([])
|
||||
const selectedCircle = ref(null)
|
||||
const hoveredCircle = ref(null)
|
||||
const isDragging = ref(false)
|
||||
const eventLogs = ref([])
|
||||
|
||||
const modes = [
|
||||
{ value: 'click', label: 'Click / 点击' },
|
||||
{ value: 'drag', label: 'Drag / 拖拽' },
|
||||
{ value: 'hover', label: 'Hover / 悬停' },
|
||||
{ value: 'keyboard', label: 'Keyboard / 键盘' }
|
||||
]
|
||||
|
||||
const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c']
|
||||
|
||||
const currentCode = computed(() => {
|
||||
const templates = {
|
||||
click: `// 点击创建圆形
|
||||
canvas.addEventListener('click', (e) => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
|
||||
const circle = {
|
||||
x: x,
|
||||
y: y,
|
||||
radius: 30,
|
||||
color: '#3498db'
|
||||
}
|
||||
|
||||
circles.push(circle)
|
||||
draw()
|
||||
})`,
|
||||
|
||||
drag: `// 拖拽移动圆形
|
||||
let isDragging = false
|
||||
let selectedCircle = null
|
||||
|
||||
canvas.addEventListener('mousedown', (e) => {
|
||||
const { x, y } = getMousePos(e)
|
||||
|
||||
// 检测点击了哪个圆形
|
||||
circles.forEach(circle => {
|
||||
const dist = Math.sqrt((x - circle.x) ** 2 + (y - circle.y) ** 2)
|
||||
if (dist < circle.radius) {
|
||||
isDragging = true
|
||||
selectedCircle = circle
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
if (isDragging && selectedCircle) {
|
||||
const { x, y } = getMousePos(e)
|
||||
selectedCircle.x = x
|
||||
selectedCircle.y = y
|
||||
draw()
|
||||
}
|
||||
})
|
||||
|
||||
canvas.addEventListener('mouseup', () => {
|
||||
isDragging = false
|
||||
selectedCircle = null
|
||||
})`,
|
||||
|
||||
hover: `// 悬停高亮
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
const { x, y } = getMousePos(e)
|
||||
let hovered = null
|
||||
|
||||
// 检测悬停
|
||||
circles.forEach(circle => {
|
||||
const dist = Math.sqrt((x - circle.x) ** 2 + (y - circle.y) ** 2)
|
||||
if (dist < circle.radius) {
|
||||
hovered = circle
|
||||
}
|
||||
})
|
||||
|
||||
if (hovered) {
|
||||
canvas.style.cursor = 'pointer'
|
||||
// 绘制高亮效果
|
||||
ctx.strokeStyle = '#e74c3c'
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath()
|
||||
ctx.arc(hovered.x, hovered.y, hovered.radius + 5, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
} else {
|
||||
canvas.style.cursor = 'default'
|
||||
}
|
||||
|
||||
draw()
|
||||
})`,
|
||||
|
||||
keyboard: `// 键盘控制
|
||||
canvas.tabIndex = 0 // 使 canvas 可以获取焦点
|
||||
canvas.focus()
|
||||
|
||||
canvas.addEventListener('keydown', (e) => {
|
||||
const step = 10
|
||||
|
||||
switch(e.key) {
|
||||
case 'ArrowUp':
|
||||
selectedCircle.y -= step
|
||||
break
|
||||
case 'ArrowDown':
|
||||
selectedCircle.y += step
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
selectedCircle.x -= step
|
||||
break
|
||||
case 'ArrowRight':
|
||||
selectedCircle.x += step
|
||||
break
|
||||
case 'Delete':
|
||||
circles = circles.filter(c => c !== selectedCircle)
|
||||
selectedCircle = null
|
||||
break
|
||||
}
|
||||
|
||||
draw()
|
||||
})`
|
||||
}
|
||||
|
||||
return templates[currentMode.value]
|
||||
})
|
||||
|
||||
const addLog = (message, type = 'info') => {
|
||||
const now = new Date()
|
||||
const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
|
||||
|
||||
eventLogs.value.unshift({ time, message, type })
|
||||
if (eventLogs.value.length > 10) {
|
||||
eventLogs.value.pop()
|
||||
}
|
||||
}
|
||||
|
||||
const getMousePos = (e) => {
|
||||
const canvas = canvasRef.value
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
return {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 绘制所有圆形
|
||||
circles.value.forEach((circle) => {
|
||||
// 填充
|
||||
ctx.fillStyle = circle.color
|
||||
ctx.beginPath()
|
||||
ctx.arc(circle.x, circle.y, circle.radius, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// 描边
|
||||
ctx.strokeStyle = '#2c3e50'
|
||||
ctx.lineWidth = 2
|
||||
ctx.stroke()
|
||||
|
||||
// 高光
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'
|
||||
ctx.beginPath()
|
||||
ctx.arc(circle.x - circle.radius * 0.3, circle.y - circle.radius * 0.3, circle.radius * 0.4, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// 选中状态
|
||||
if (circle === selectedCircle.value) {
|
||||
ctx.strokeStyle = '#e74c3c'
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath()
|
||||
ctx.arc(circle.x, circle.y, circle.radius + 5, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// 悬停状态
|
||||
if (circle === hoveredCircle.value && currentMode.value === 'hover') {
|
||||
ctx.fillStyle = 'rgba(231, 76, 60, 0.2)'
|
||||
ctx.beginPath()
|
||||
ctx.arc(circle.x, circle.y, circle.radius + 10, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// 显示坐标
|
||||
ctx.fillStyle = '#2c3e50'
|
||||
ctx.font = '12px Arial'
|
||||
ctx.fillText(`(${Math.round(circle.x)}, ${Math.round(circle.y)})`, circle.x + circle.radius + 10, circle.y)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleClick = (e) => {
|
||||
if (currentMode.value !== 'click') return
|
||||
|
||||
const { x, y } = getMousePos(e)
|
||||
const color = e.shiftKey ? colors[Math.floor(Math.random() * colors.length)] : '#3498db'
|
||||
|
||||
circles.value.push({
|
||||
x,
|
||||
y,
|
||||
radius: 30,
|
||||
color
|
||||
})
|
||||
|
||||
addLog(`Created circle at (${Math.round(x)}, ${Math.round(y)})`, 'success')
|
||||
draw()
|
||||
}
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
const { x, y } = getMousePos(e)
|
||||
|
||||
if (currentMode.value === 'drag' && isDragging.value && selectedCircle.value) {
|
||||
selectedCircle.value.x = x
|
||||
selectedCircle.value.y = y
|
||||
draw()
|
||||
return
|
||||
}
|
||||
|
||||
if (currentMode.value === 'hover') {
|
||||
let found = null
|
||||
circles.value.forEach((circle) => {
|
||||
const dist = Math.sqrt((x - circle.x) ** 2 + (y - circle.y) ** 2)
|
||||
if (dist < circle.radius) {
|
||||
found = circle
|
||||
}
|
||||
})
|
||||
|
||||
if (found !== hoveredCircle.value) {
|
||||
hoveredCircle.value = found
|
||||
if (found) {
|
||||
addLog(`Hovering circle at (${Math.round(found.x)}, ${Math.round(found.y)})`, 'info')
|
||||
}
|
||||
}
|
||||
draw()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
if (currentMode.value !== 'drag') return
|
||||
|
||||
const { x, y } = getMousePos(e)
|
||||
|
||||
circles.value.forEach((circle) => {
|
||||
const dist = Math.sqrt((x - circle.x) ** 2 + (y - circle.y) ** 2)
|
||||
if (dist < circle.radius) {
|
||||
isDragging.value = true
|
||||
selectedCircle.value = circle
|
||||
addLog(`Started dragging circle at (${Math.round(x)}, ${Math.round(y)})`, 'info')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (isDragging.value) {
|
||||
addLog(`Dropped circle at (${Math.round(selectedCircle.value.x)}, ${Math.round(selectedCircle.value.y)})`, 'success')
|
||||
}
|
||||
isDragging.value = false
|
||||
selectedCircle.value = null
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isDragging.value = false
|
||||
selectedCircle.value = null
|
||||
hoveredCircle.value = null
|
||||
draw()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (currentMode.value !== 'keyboard') return
|
||||
|
||||
if (!selectedCircle.value && circles.value.length > 0) {
|
||||
selectedCircle.value = circles.value[0]
|
||||
}
|
||||
|
||||
if (!selectedCircle.value) return
|
||||
|
||||
const step = 10
|
||||
let moved = false
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
selectedCircle.value.y -= step
|
||||
moved = true
|
||||
break
|
||||
case 'ArrowDown':
|
||||
selectedCircle.value.y += step
|
||||
moved = true
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
selectedCircle.value.x -= step
|
||||
moved = true
|
||||
break
|
||||
case 'ArrowRight':
|
||||
selectedCircle.value.x += step
|
||||
moved = true
|
||||
break
|
||||
case 'Delete':
|
||||
case 'Backspace':
|
||||
circles.value = circles.value.filter((c) => c !== selectedCircle.value)
|
||||
addLog('Deleted circle', 'warning')
|
||||
selectedCircle.value = circles.value[0] || null
|
||||
moved = true
|
||||
break
|
||||
}
|
||||
|
||||
if (moved) {
|
||||
e.preventDefault()
|
||||
draw()
|
||||
}
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
circles.value = []
|
||||
selectedCircle.value = null
|
||||
hoveredCircle.value = null
|
||||
addLog('Cleared all circles', 'warning')
|
||||
draw()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化几个圆形
|
||||
circles.value = [
|
||||
{ x: 150, y: 200, radius: 30, color: '#e74c3c' },
|
||||
{ x: 300, y: 200, radius: 30, color: '#3498db' },
|
||||
{ x: 450, y: 200, radius: 30, color: '#2ecc71' }
|
||||
]
|
||||
draw()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.event-demo {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mode-selector {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.mode-selector label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.button-group button {
|
||||
padding: 8px 16px;
|
||||
border: 2px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.button-group button:hover {
|
||||
border-color: #3498db;
|
||||
background: #f0f8ff;
|
||||
}
|
||||
|
||||
.button-group button.active {
|
||||
border-color: #3498db;
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
margin-bottom: 15px;
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.instructions h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #2c3e50;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.instructions ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.instructions li {
|
||||
margin-bottom: 6px;
|
||||
color: #555;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.event-log {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.event-log h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #2c3e50;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: #95a5a6;
|
||||
font-family: 'Courier New', monospace;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.log-entry.info .log-message {
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.log-entry.success .log-message {
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.log-entry.warning .log-message {
|
||||
color: #f39c12;
|
||||
}
|
||||
|
||||
.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: 20px 0;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
canvas {
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 4px;
|
||||
cursor: crosshair;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
canvas:focus {
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.code-display {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #2c3e50;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-display h4 {
|
||||
color: #ecf0f1;
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.code-display pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-display code {
|
||||
color: #ecf0f1;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.explanation {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.explanation h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.explanation ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.explanation li {
|
||||
margin-bottom: 8px;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,571 @@
|
||||
<!--
|
||||
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 #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.effect-selector {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.effect-selector label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.button-group button {
|
||||
padding: 8px 16px;
|
||||
border: 2px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.button-group button:hover {
|
||||
border-color: #3498db;
|
||||
background: #f0f8ff;
|
||||
}
|
||||
|
||||
.button-group button.active {
|
||||
border-color: #3498db;
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.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: 20px 0;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
canvas {
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 4px;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.code-display {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #2c3e50;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-display h4 {
|
||||
color: #ecf0f1;
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.code-display pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-display code {
|
||||
color: #ecf0f1;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.explanation {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.explanation h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.explanation ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.explanation li {
|
||||
margin-bottom: 8px;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
margin-top: 15px;
|
||||
padding: 12px;
|
||||
background: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #856404;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-box .icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,794 @@
|
||||
<!--
|
||||
PerformanceDemo.vue
|
||||
Canvas 性能优化演示组件
|
||||
|
||||
用途:
|
||||
展示 Canvas 性能优化技术,包括离屏 Canvas、减少重绘、图层管理等
|
||||
|
||||
交互功能:
|
||||
- 性能对比:优化前后的性能对比
|
||||
- 对象数量调整:测试不同负载下的性能
|
||||
- FPS 显示:实时显示帧率
|
||||
- 优化开关:启用/禁用各种优化技术
|
||||
-->
|
||||
<template>
|
||||
<div class="performance-demo">
|
||||
<div class="control-panel">
|
||||
<div class="test-selector">
|
||||
<label>Performance Test / 性能测试</label>
|
||||
<div class="button-group">
|
||||
<button
|
||||
v-for="test in tests"
|
||||
:key="test.value"
|
||||
:class="{ active: currentTest === test.value }"
|
||||
@click="switchTest(test.value)"
|
||||
>
|
||||
{{ test.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="parameters">
|
||||
<div class="param-row">
|
||||
<label>Object Count / 对象数量: {{ objectCount }}</label>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="objectCount"
|
||||
min="100"
|
||||
max="5000"
|
||||
step="100"
|
||||
@input="resetTest"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="optimization-toggles">
|
||||
<label>Optimizations / 优化技术</label>
|
||||
<div class="toggle-grid">
|
||||
<label class="toggle-option" v-if="currentTest === 'redraw'">
|
||||
<input type="checkbox" v-model="useDirtyRect" />
|
||||
<span>Dirty Rect / 脏矩形</span>
|
||||
</label>
|
||||
|
||||
<label class="toggle-option" v-if="currentTest === 'layer'">
|
||||
<input type="checkbox" v-model="useOffscreenCanvas" />
|
||||
<span>Offscreen Canvas / 离屏画布</span>
|
||||
</label>
|
||||
|
||||
<label class="toggle-option" v-if="currentTest === 'batch'">
|
||||
<input type="checkbox" v-model="useBatching" />
|
||||
<span>Batch Rendering / 批量渲染</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<span class="label">FPS:</span>
|
||||
<span class="value" :class="{ good: fps >= 55, warning: fps >= 30 && fps < 55, bad: fps < 30 }">
|
||||
{{ fps }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="label">Frame Time:</span>
|
||||
<span class="value">{{ frameTime }}ms</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="label">Objects:</span>
|
||||
<span class="value">{{ objectCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="reset-btn" @click="resetTest">
|
||||
<span class="icon">🔄</span>
|
||||
Restart Test / 重新测试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="canvas-container">
|
||||
<canvas ref="canvasRef" width="600" height="400"></canvas>
|
||||
<canvas
|
||||
v-if="useOffscreenCanvas"
|
||||
ref="offscreenCanvasRef"
|
||||
width="600"
|
||||
height="400"
|
||||
style="display: none"
|
||||
></canvas>
|
||||
</div>
|
||||
|
||||
<div class="comparison" v-if="showComparison">
|
||||
<h4>Performance Comparison / 性能对比</h4>
|
||||
<div class="comparison-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Technique / 技术</th>
|
||||
<th>FPS</th>
|
||||
<th>Improvement / 提升</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Baseline / 基准</td>
|
||||
<td>{{ baselineFps }}</td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
<tr v-if="useDirtyRect">
|
||||
<td>Dirty Rect / 脏矩形</td>
|
||||
<td>{{ fps }}</td>
|
||||
<td>{{ (((fps - baselineFps) / baselineFps) * 100).toFixed(1) }}%</td>
|
||||
</tr>
|
||||
<tr v-if="useOffscreenCanvas">
|
||||
<td>Offscreen Canvas / 离屏画布</td>
|
||||
<td>{{ fps }}</td>
|
||||
<td>{{ (((fps - baselineFps) / baselineFps) * 100).toFixed(1) }}%</td>
|
||||
</tr>
|
||||
<tr v-if="useBatching">
|
||||
<td>Batch Rendering / 批量渲染</td>
|
||||
<td>{{ fps }}</td>
|
||||
<td>{{ (((fps - baselineFps) / baselineFps) * 100).toFixed(1) }}%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-display">
|
||||
<h4>Optimization Code / 优化代码</h4>
|
||||
<pre><code>{{ optimizationCode }}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="explanation">
|
||||
<h4>Performance Tips / 性能优化要点</h4>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>减少重绘:</strong>
|
||||
只重绘变化的部分(脏矩形技术),避免不必要的 clearRect
|
||||
</li>
|
||||
<li>
|
||||
<strong>离屏 Canvas:</strong>
|
||||
预渲染静态内容到离屏 Canvas,减少每帧的绘制操作
|
||||
</li>
|
||||
<li>
|
||||
<strong>批量渲染:</strong>
|
||||
减少状态切换(fillStyle、strokeStyle 等),批量处理相同类型的绘制
|
||||
</li>
|
||||
<li>
|
||||
<strong>对象池:</strong>
|
||||
复用对象,减少垃圾回收压力
|
||||
</li>
|
||||
<li>
|
||||
<strong>requestAnimationFrame:</strong>
|
||||
使用浏览器提供的动画 API,优化渲染时机
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const canvasRef = ref(null)
|
||||
const offscreenCanvasRef = ref(null)
|
||||
const currentTest = ref('redraw')
|
||||
const objectCount = ref(1000)
|
||||
const useDirtyRect = ref(false)
|
||||
const useOffscreenCanvas = ref(false)
|
||||
const useBatching = ref(false)
|
||||
const fps = ref(0)
|
||||
const frameTime = ref(0)
|
||||
const baselineFps = ref(0)
|
||||
const showComparison = ref(false)
|
||||
|
||||
let animationId = null
|
||||
let lastTime = 0
|
||||
let frameCount = 0
|
||||
let fpsTime = 0
|
||||
let objects = []
|
||||
let offscreenCtx = null
|
||||
|
||||
const tests = [
|
||||
{ value: 'redraw', label: 'Minimize Redraw / 减少重绘' },
|
||||
{ value: 'layer', label: 'Layer Management / 图层管理' },
|
||||
{ value: 'batch', label: 'Batch Rendering / 批量渲染' }
|
||||
]
|
||||
|
||||
const optimizationCode = computed(() => {
|
||||
const templates = {
|
||||
redraw: `// 脏矩形优化 - 只重绘变化的部分
|
||||
function draw() {
|
||||
// 不清除整个画布,只清除变化的区域
|
||||
if (useDirtyRect) {
|
||||
objects.forEach(obj => {
|
||||
if (obj.moved) {
|
||||
// 清除旧位置
|
||||
ctx.clearRect(
|
||||
obj.oldX - obj.size,
|
||||
obj.oldY - obj.size,
|
||||
obj.size * 2,
|
||||
obj.size * 2
|
||||
)
|
||||
// 绘制新位置
|
||||
obj.draw(ctx)
|
||||
obj.moved = false
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 传统方式:清除整个画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
objects.forEach(obj => obj.draw(ctx))
|
||||
}
|
||||
}`,
|
||||
|
||||
layer: `// 离屏 Canvas - 预渲染静态内容
|
||||
// 初始化时创建离屏 Canvas
|
||||
const offscreenCanvas = document.createElement('canvas')
|
||||
const offscreenCtx = offscreenCanvas.getContext('2d')
|
||||
|
||||
// 预渲染静态背景
|
||||
function drawBackground(ctx) {
|
||||
ctx.fillStyle = '#f0f0f0'
|
||||
ctx.fillRect(0, 0, 600, 400)
|
||||
// 绘制网格等静态内容...
|
||||
}
|
||||
|
||||
// 只绘制一次到离屏 Canvas
|
||||
drawBackground(offscreenCtx)
|
||||
|
||||
// 主渲染循环
|
||||
function draw() {
|
||||
if (useOffscreenCanvas) {
|
||||
// 直接复制预渲染的内容
|
||||
ctx.drawImage(offscreenCanvas, 0, 0)
|
||||
} else {
|
||||
// 每帧重新绘制背景
|
||||
drawBackground(ctx)
|
||||
}
|
||||
|
||||
// 只绘制动态对象
|
||||
objects.forEach(obj => obj.draw(ctx))
|
||||
}`,
|
||||
|
||||
batch: `// 批量渲染 - 减少状态切换
|
||||
function draw() {
|
||||
if (useBatching) {
|
||||
// 按颜色分组
|
||||
const batches = {}
|
||||
objects.forEach(obj => {
|
||||
if (!batches[obj.color]) {
|
||||
batches[obj.color] = []
|
||||
}
|
||||
batches[obj.color].push(obj)
|
||||
})
|
||||
|
||||
// 批量绘制相同颜色的对象
|
||||
Object.keys(batches).forEach(color => {
|
||||
ctx.fillStyle = color // 只设置一次颜色
|
||||
batches[color].forEach(obj => {
|
||||
ctx.beginPath()
|
||||
ctx.arc(obj.x, obj.y, obj.size, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// 传统方式:每个对象都切换状态
|
||||
objects.forEach(obj => {
|
||||
ctx.fillStyle = obj.color // 频繁切换状态
|
||||
ctx.beginPath()
|
||||
ctx.arc(obj.x, obj.y, obj.size, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
})
|
||||
}
|
||||
}`
|
||||
}
|
||||
|
||||
return templates[currentTest.value]
|
||||
})
|
||||
|
||||
const initObjects = () => {
|
||||
objects = []
|
||||
const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6']
|
||||
|
||||
for (let i = 0; i < objectCount.value; i++) {
|
||||
objects.push({
|
||||
x: Math.random() * 600,
|
||||
y: Math.random() * 400,
|
||||
size: 2 + Math.random() * 3,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
vx: (Math.random() - 0.5) * 2,
|
||||
vy: (Math.random() - 0.5) * 2,
|
||||
oldX: 0,
|
||||
oldY: 0,
|
||||
moved: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const initOffscreenCanvas = () => {
|
||||
if (!offscreenCanvasRef.value) return
|
||||
offscreenCtx = offscreenCanvasRef.value.getContext('2d')
|
||||
|
||||
// 预渲染静态背景
|
||||
offscreenCtx.fillStyle = '#fafafa'
|
||||
offscreenCtx.fillRect(0, 0, 600, 400)
|
||||
|
||||
// 绘制网格
|
||||
offscreenCtx.strokeStyle = '#e0e0e0'
|
||||
offscreenCtx.lineWidth = 1
|
||||
for (let x = 0; x < 600; x += 50) {
|
||||
offscreenCtx.beginPath()
|
||||
offscreenCtx.moveTo(x, 0)
|
||||
offscreenCtx.lineTo(x, 400)
|
||||
offscreenCtx.stroke()
|
||||
}
|
||||
for (let y = 0; y < 400; y += 50) {
|
||||
offscreenCtx.beginPath()
|
||||
offscreenCtx.moveTo(0, y)
|
||||
offscreenCtx.lineTo(600, y)
|
||||
offscreenCtx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
const drawRedrawTest = (ctx) => {
|
||||
if (useDirtyRect.value) {
|
||||
// 只重绘移动的对象
|
||||
objects.forEach((obj) => {
|
||||
if (obj.moved) {
|
||||
ctx.clearRect(obj.oldX - obj.size - 1, obj.oldY - obj.size - 1, obj.size * 2 + 2, obj.size * 2 + 2)
|
||||
ctx.fillStyle = obj.color
|
||||
ctx.beginPath()
|
||||
ctx.arc(obj.x, obj.y, obj.size, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
obj.moved = false
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 清除整个画布
|
||||
ctx.clearRect(0, 0, 600, 400)
|
||||
ctx.fillStyle = '#fafafa'
|
||||
ctx.fillRect(0, 0, 600, 400)
|
||||
|
||||
// 绘制所有对象
|
||||
objects.forEach((obj) => {
|
||||
ctx.fillStyle = obj.color
|
||||
ctx.beginPath()
|
||||
ctx.arc(obj.x, obj.y, obj.size, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const drawLayerTest = (ctx) => {
|
||||
if (useOffscreenCanvas.value && offscreenCtx) {
|
||||
// 复制预渲染的背景
|
||||
ctx.drawImage(offscreenCanvasRef.value, 0, 0)
|
||||
} else {
|
||||
// 绘制背景
|
||||
ctx.fillStyle = '#fafafa'
|
||||
ctx.fillRect(0, 0, 600, 400)
|
||||
ctx.strokeStyle = '#e0e0e0'
|
||||
ctx.lineWidth = 1
|
||||
for (let x = 0; x < 600; x += 50) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, 0)
|
||||
ctx.lineTo(x, 400)
|
||||
ctx.stroke()
|
||||
}
|
||||
for (let y = 0; y < 400; y += 50) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, y)
|
||||
ctx.lineTo(600, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制动态对象
|
||||
objects.forEach((obj) => {
|
||||
ctx.fillStyle = obj.color
|
||||
ctx.beginPath()
|
||||
ctx.arc(obj.x, obj.y, obj.size, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
})
|
||||
}
|
||||
|
||||
const drawBatchTest = (ctx) => {
|
||||
ctx.clearRect(0, 0, 600, 400)
|
||||
ctx.fillStyle = '#fafafa'
|
||||
ctx.fillRect(0, 0, 600, 400)
|
||||
|
||||
if (useBatching.value) {
|
||||
// 按颜色分组批量渲染
|
||||
const batches = {}
|
||||
objects.forEach((obj) => {
|
||||
if (!batches[obj.color]) {
|
||||
batches[obj.color] = []
|
||||
}
|
||||
batches[obj.color].push(obj)
|
||||
})
|
||||
|
||||
Object.keys(batches).forEach((color) => {
|
||||
ctx.fillStyle = color
|
||||
batches[color].forEach((obj) => {
|
||||
ctx.beginPath()
|
||||
ctx.arc(obj.x, obj.y, obj.size, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// 逐个渲染
|
||||
objects.forEach((obj) => {
|
||||
ctx.fillStyle = obj.color
|
||||
ctx.beginPath()
|
||||
ctx.arc(obj.x, obj.y, obj.size, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updateObjects = () => {
|
||||
objects.forEach((obj) => {
|
||||
obj.oldX = obj.x
|
||||
obj.oldY = obj.y
|
||||
obj.x += obj.vx
|
||||
obj.y += obj.vy
|
||||
|
||||
if (obj.x < 0 || obj.x > 600) obj.vx = -obj.vx
|
||||
if (obj.y < 0 || obj.y > 400) obj.vy = -obj.vy
|
||||
|
||||
if (obj.x !== obj.oldX || obj.y !== obj.oldY) {
|
||||
obj.moved = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
updateObjects()
|
||||
|
||||
switch (currentTest.value) {
|
||||
case 'redraw':
|
||||
drawRedrawTest(ctx)
|
||||
break
|
||||
case 'layer':
|
||||
drawLayerTest(ctx)
|
||||
break
|
||||
case 'batch':
|
||||
drawBatchTest(ctx)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const animate = (timestamp) => {
|
||||
if (!lastTime) lastTime = timestamp
|
||||
const deltaTime = timestamp - lastTime
|
||||
frameTime.value = deltaTime.toFixed(2)
|
||||
|
||||
frameCount++
|
||||
fpsTime += deltaTime
|
||||
if (fpsTime >= 1000) {
|
||||
fps.value = Math.round((frameCount * 1000) / fpsTime)
|
||||
if (!showComparison.value && !useDirtyRect.value && !useOffscreenCanvas.value && !useBatching.value) {
|
||||
baselineFps.value = fps.value
|
||||
}
|
||||
frameCount = 0
|
||||
fpsTime = 0
|
||||
showComparison.value = true
|
||||
}
|
||||
|
||||
lastTime = timestamp
|
||||
draw()
|
||||
animationId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
const switchTest = (test) => {
|
||||
currentTest.value = test
|
||||
resetTest()
|
||||
}
|
||||
|
||||
const resetTest = () => {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId)
|
||||
}
|
||||
lastTime = 0
|
||||
frameCount = 0
|
||||
fpsTime = 0
|
||||
fps.value = 0
|
||||
baselineFps.value = 0
|
||||
showComparison.value = false
|
||||
useDirtyRect.value = false
|
||||
useOffscreenCanvas.value = false
|
||||
useBatching.value = false
|
||||
|
||||
initObjects()
|
||||
if (currentTest.value === 'layer') {
|
||||
initOffscreenCanvas()
|
||||
}
|
||||
|
||||
animationId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
watch([useDirtyRect, useOffscreenCanvas, useBatching], () => {
|
||||
showComparison.value = true
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initObjects()
|
||||
initOffscreenCanvas()
|
||||
animationId = requestAnimationFrame(animate)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.performance-demo {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.test-selector {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.test-selector label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.button-group button {
|
||||
padding: 8px 16px;
|
||||
border: 2px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.button-group button:hover {
|
||||
border-color: #3498db;
|
||||
background: #f0f8ff;
|
||||
}
|
||||
|
||||
.button-group button.active {
|
||||
border-color: #3498db;
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.parameters {
|
||||
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%;
|
||||
}
|
||||
|
||||
.optimization-toggles {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.optimization-toggles label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.toggle-grid {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toggle-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 8px 12px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.toggle-option input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.stat-item .value.good {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.stat-item .value.warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.stat-item .value.bad {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #95a5a6;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
background: #7f8c8d;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 20px 0;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
canvas {
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.comparison {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.comparison h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.comparison-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.comparison-table th,
|
||||
.comparison-table td {
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.comparison-table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.code-display {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #2c3e50;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-display h4 {
|
||||
color: #ecf0f1;
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.code-display pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-display code {
|
||||
color: #ecf0f1;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.explanation {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.explanation h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.explanation ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.explanation li {
|
||||
margin-bottom: 8px;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user