在前面的章节中,我们学习了如何在Canvas上绘制各种图形、处理图像以及创建动画。然而,一个真正交互式的Canvas应用还需要能够响应用户的输入。本章将详细介绍如何在Canvas中处理各种用户交互事件,包括鼠标、键盘、触摸等输入方式,以及如何构建响应式的Canvas应用。
8.1 事件基础与事件模型
8.1.1 DOM事件系统概述
Canvas元素作为DOM的一部分,可以利用浏览器的事件系统来处理用户交互。浏览器事件模型包括三个阶段:
- 捕获阶段:事件从文档根节点向下传播到目标元素
- 目标阶段:事件到达目标元素
- 冒泡阶段:事件从目标元素向上冒泡到文档根节点
// 添加事件监听器的基本语法
canvas.addEventListener(eventType, eventHandler, useCapture);
// 示例:监听点击事件
const canvas = document.getElementById('myCanvas');
canvas.addEventListener('click', function(event) {
console.log('Canvas被点击了!');
console.log('点击坐标:', event.clientX, event.clientY);
});
8.1.2 Canvas事件坐标系统
在处理Canvas事件时,需要特别注意坐标系统的转换。浏览器事件提供的坐标通常是相对于视口或文档的,而Canvas内部使用自己的坐标系统。
// 将浏览器坐标转换为Canvas坐标的工具函数
function getCanvasCoordinates(canvas, event) {
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
}
// 使用示例
canvas.addEventListener('mousemove', function(event) {
const coords = getCanvasCoordinates(canvas, event);
console.log('Canvas坐标:', coords.x, coords.y);
});
8.1.3 事件委托与性能优化
当Canvas中有多个交互元素时,为每个元素单独添加事件监听器可能会导致性能问题。事件委托是一种常用的优化技术,它利用事件冒泡机制,在父元素上设置一个事件监听器来处理所有子元素的事件。
// Canvas中的事件委托示例
const shapes = []; // 存储所有可交互的形状
// 添加形状
function addShape(shape) {
shapes.push(shape);
}
// 检查点击是否在某个形状内
function getShapeAtPoint(x, y) {
// 从后向前检查(后添加的在上层)
for (let i = shapes.length - 1; i >= 0; i--) {
if (shapes[i].containsPoint(x, y)) {
return shapes[i];
}
}
return null;
}
// 单一事件监听器处理所有形状的点击
canvas.addEventListener('click', function(event) {
const coords = getCanvasCoordinates(canvas, event);
const clickedShape = getShapeAtPoint(coords.x, coords.y);
if (clickedShape) {
clickedShape.onClick(event);
}
});
8.2 鼠标事件处理
8.2.1 基本鼠标事件
Canvas支持多种鼠标事件,包括:
mousedown
:鼠标按下时触发mouseup
:鼠标释放时触发mousemove
:鼠标移动时触发click
:鼠标点击时触发dblclick
:鼠标双击时触发mouseenter
:鼠标进入元素时触发mouseleave
:鼠标离开元素时触发contextmenu
:右键点击时触发
// 鼠标事件示例
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
// 绘制函数
function draw(x, y) {
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fill();
}
// 鼠标按下时开始绘制
let isDrawing = false;
canvas.addEventListener('mousedown', function(event) {
isDrawing = true;
const coords = getCanvasCoordinates(canvas, event);
draw(coords.x, coords.y);
});
// 鼠标移动时继续绘制
canvas.addEventListener('mousemove', function(event) {
if (!isDrawing) return;
const coords = getCanvasCoordinates(canvas, event);
draw(coords.x, coords.y);
});
// 鼠标释放或离开时停止绘制
canvas.addEventListener('mouseup', function() {
isDrawing = false;
});
canvas.addEventListener('mouseleave', function() {
isDrawing = false;
});
8.2.2 拖拽与选择操作
实现拖拽功能是Canvas交互中的常见需求。以下是一个简单的拖拽示例:
class DraggableShape {
constructor(x, y, width, height, color) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.color = color;
this.isDragging = false;
this.dragOffsetX = 0;
this.dragOffsetY = 0;
}
draw(ctx) {
ctx.fillStyle = this.color;
ctx.fillRect(this.x, this.y, this.width, this.height);
}
containsPoint(x, y) {
return x >= this.x && x <= this.x + this.width &&
y >= this.y && y <= this.y + this.height;
}
startDrag(x, y) {
this.isDragging = true;
this.dragOffsetX = x - this.x;
this.dragOffsetY = y - this.y;
}
drag(x, y) {
if (this.isDragging) {
this.x = x - this.dragOffsetX;
this.y = y - this.dragOffsetY;
}
}
stopDrag() {
this.isDragging = false;
}
}
// 使用示例
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
const shapes = [
new DraggableShape(50, 50, 100, 100, 'red'),
new DraggableShape(200, 100, 80, 80, 'blue'),
new DraggableShape(100, 200, 120, 60, 'green')
];
let activeShape = null;
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
shapes.forEach(shape => shape.draw(ctx));
requestAnimationFrame(render);
}
canvas.addEventListener('mousedown', function(event) {
const coords = getCanvasCoordinates(canvas, event);
// 从后向前检查(后添加的在上层)
for (let i = shapes.length - 1; i >= 0; i--) {
if (shapes[i].containsPoint(coords.x, coords.y)) {
activeShape = shapes[i];
activeShape.startDrag(coords.x, coords.y);
// 将当前形状移到数组末尾(视觉上移到最上层)
shapes.splice(i, 1);
shapes.push(activeShape);
break;
}
}
});
canvas.addEventListener('mousemove', function(event) {
if (activeShape) {
const coords = getCanvasCoordinates(canvas, event);
activeShape.drag(coords.x, coords.y);
}
});
canvas.addEventListener('mouseup', function() {
if (activeShape) {
activeShape.stopDrag();
activeShape = null;
}
});
render();
8.2.3 鼠标滚轮事件
鼠标滚轮事件可用于实现缩放功能:
// 鼠标滚轮缩放示例
let scale = 1;
const scaleStep = 0.1;
const minScale = 0.5;
const maxScale = 3;
canvas.addEventListener('wheel', function(event) {
event.preventDefault(); // 阻止页面滚动
const coords = getCanvasCoordinates(canvas, event);
const wheelDelta = event.deltaY;
// 确定缩放方向
if (wheelDelta > 0) {
// 缩小
scale = Math.max(minScale, scale - scaleStep);
} else {
// 放大
scale = Math.min(maxScale, scale + scaleStep);
}
// 以鼠标位置为中心进行缩放
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(coords.x, coords.y);
ctx.scale(scale, scale);
ctx.translate(-coords.x, -coords.y);
// 绘制内容
shapes.forEach(shape => shape.draw(ctx));
ctx.restore();
});
8.3 键盘事件处理
8.3.1 键盘事件基础
键盘事件通常在文档级别监听,因为Canvas元素本身不能获取焦点(除非设置了tabindex
属性)。
// 键盘事件示例
const keysPressed = {};
// 记录按下的键
document.addEventListener('keydown', function(event) {
keysPressed[event.key] = true;
});
// 记录释放的键
document.addEventListener('keyup', function(event) {
keysPressed[event.key] = false;
});
// 在游戏循环中使用按键状态
function gameLoop() {
// 移动玩家角色
if (keysPressed['ArrowUp'] || keysPressed['w']) {
player.moveUp();
}
if (keysPressed['ArrowDown'] || keysPressed['s']) {
player.moveDown();
}
if (keysPressed['ArrowLeft'] || keysPressed['a']) {
player.moveLeft();
}
if (keysPressed['ArrowRight'] || keysPressed['d']) {
player.moveRight();
}
// 更新游戏状态和渲染
update();
render();
requestAnimationFrame(gameLoop);
}
gameLoop();
8.3.2 焦点管理
为了让Canvas能够直接接收键盘事件,可以设置tabindex
属性:
<canvas id="myCanvas" width="800" height="600" tabindex="1"></canvas>
// 确保Canvas获取焦点
canvas.focus();
// 直接在Canvas上监听键盘事件
canvas.addEventListener('keydown', function(event) {
console.log('Canvas接收到键盘事件:', event.key);
});
8.3.3 快捷键与组合键
实现快捷键和组合键功能:
// 组合键示例
document.addEventListener('keydown', function(event) {
// Ctrl+S 保存
if (event.ctrlKey && event.key === 's') {
event.preventDefault(); // 阻止浏览器默认行为
saveCanvas();
}
// Ctrl+Z 撤销
if (event.ctrlKey && event.key === 'z') {
event.preventDefault();
undoLastAction();
}
// Shift+拖动实现多选
if (event.shiftKey) {
isMultiSelectMode = true;
}
});
document.addEventListener('keyup', function(event) {
if (event.key === 'Shift') {
isMultiSelectMode = false;
}
});
function saveCanvas() {
console.log('保存Canvas内容');
// 实现保存逻辑
}
function undoLastAction() {
console.log('撤销上一步操作');
// 实现撤销逻辑
}
8.4 触摸事件处理
8.4.1 触摸事件基础
移动设备上的触摸事件与鼠标事件有所不同,主要包括:
touchstart
:触摸开始时触发touchmove
:触摸移动时触发touchend
:触摸结束时触发touchcancel
:触摸被中断时触发
// 触摸事件示例
canvas.addEventListener('touchstart', function(event) {
event.preventDefault(); // 阻止默认行为(如滚动)
const touch = event.touches[0];
const coords = getCanvasCoordinates(canvas, touch);
console.log('触摸开始:', coords.x, coords.y);
// 处理触摸开始逻辑
});
canvas.addEventListener('touchmove', function(event) {
event.preventDefault();
const touch = event.touches[0];
const coords = getCanvasCoordinates(canvas, touch);
console.log('触摸移动:', coords.x, coords.y);
// 处理触摸移动逻辑
});
canvas.addEventListener('touchend', function(event) {
event.preventDefault();
console.log('触摸结束');
// 处理触摸结束逻辑
});
8.4.2 多点触控处理
处理多点触控,如捏合缩放:
// 多点触控示例 - 捏合缩放
let initialDistance = 0;
let initialScale = 1;
function getDistance(touch1, touch2) {
const dx = touch1.clientX - touch2.clientX;
const dy = touch1.clientY - touch2.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
canvas.addEventListener('touchstart', function(event) {
if (event.touches.length === 2) {
// 两个手指触摸 - 准备缩放
initialDistance = getDistance(event.touches[0], event.touches[1]);
initialScale = scale;
}
});
canvas.addEventListener('touchmove', function(event) {
if (event.touches.length === 2) {
// 计算新距离
const currentDistance = getDistance(event.touches[0], event.touches[1]);
// 计算缩放比例
const scaleFactor = currentDistance / initialDistance;
scale = initialScale * scaleFactor;
scale = Math.max(minScale, Math.min(maxScale, scale));
// 重新渲染
render();
}
});
8.4.3 手势识别
实现简单的手势识别:
// 手势识别示例 - 滑动方向
let touchStartX = 0;
let touchStartY = 0;
let touchEndX = 0;
let touchEndY = 0;
canvas.addEventListener('touchstart', function(event) {
const touch = event.touches[0];
touchStartX = touch.clientX;
touchStartY = touch.clientY;
});
canvas.addEventListener('touchend', function(event) {
const touch = event.changedTouches[0];
touchEndX = touch.clientX;
touchEndY = touch.clientY;
// 计算水平和垂直移动距离
const deltaX = touchEndX - touchStartX;
const deltaY = touchEndY - touchStartY;
// 判断手势方向
if (Math.abs(deltaX) > Math.abs(deltaY)) {
// 水平滑动
if (deltaX > 50) {
console.log('向右滑动');
// 处理向右滑动
} else if (deltaX < -50) {
console.log('向左滑动');
// 处理向左滑动
}
} else {
// 垂直滑动
if (deltaY > 50) {
console.log('向下滑动');
// 处理向下滑动
} else if (deltaY < -50) {
console.log('向上滑动');
// 处理向上滑动
}
}
});
8.5 综合交互系统
8.5.1 交互管理器
创建一个统一的交互管理器来处理各种输入:
class InteractionManager {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.shapes = [];
this.activeShape = null;
this.isDragging = false;
this.isMultiSelectMode = false;
this.selectedShapes = [];
this.keysPressed = {};
// 绑定事件处理方法
this.bindEvents();
}
bindEvents() {
// 鼠标事件
this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
this.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
this.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
this.canvas.addEventListener('wheel', this.handleWheel.bind(this));
// 键盘事件
document.addEventListener('keydown', this.handleKeyDown.bind(this));
document.addEventListener('keyup', this.handleKeyUp.bind(this));
// 触摸事件
this.canvas.addEventListener('touchstart', this.handleTouchStart.bind(this));
this.canvas.addEventListener('touchmove', this.handleTouchMove.bind(this));
this.canvas.addEventListener('touchend', this.handleTouchEnd.bind(this));
}
addShape(shape) {
this.shapes.push(shape);
}
getShapeAtPoint(x, y) {
for (let i = this.shapes.length - 1; i >= 0; i--) {
if (this.shapes[i].containsPoint(x, y)) {
return this.shapes[i];
}
}
return null;
}
handleMouseDown(event) {
const coords = getCanvasCoordinates(this.canvas, event);
const clickedShape = this.getShapeAtPoint(coords.x, coords.y);
if (clickedShape) {
if (this.isMultiSelectMode) {
// 多选模式
if (this.selectedShapes.includes(clickedShape)) {
// 如果已选中,则取消选中
const index = this.selectedShapes.indexOf(clickedShape);
this.selectedShapes.splice(index, 1);
} else {
// 否则添加到选中列表
this.selectedShapes.push(clickedShape);
}
} else {
// 单选模式
this.selectedShapes = [clickedShape];
this.activeShape = clickedShape;
this.isDragging = true;
clickedShape.startDrag(coords.x, coords.y);
}
} else {
// 点击空白区域,清除选择
if (!this.isMultiSelectMode) {
this.selectedShapes = [];
}
}
this.render();
}
handleMouseMove(event) {
const coords = getCanvasCoordinates(this.canvas, event);
if (this.isDragging && this.activeShape) {
this.activeShape.drag(coords.x, coords.y);
this.render();
}
}
handleMouseUp() {
if (this.activeShape) {
this.activeShape.stopDrag();
this.activeShape = null;
this.isDragging = false;
}
}
handleWheel(event) {
// 实现缩放逻辑
}
handleKeyDown(event) {
this.keysPressed[event.key] = true;
// 处理快捷键
if (event.key === 'Shift') {
this.isMultiSelectMode = true;
}
// 处理方向键移动选中的形状
if (this.selectedShapes.length > 0) {
const moveStep = 5;
if (event.key === 'ArrowUp') {
this.selectedShapes.forEach(shape => {
shape.y -= moveStep;
});
this.render();
} else if (event.key === 'ArrowDown') {
this.selectedShapes.forEach(shape => {
shape.y += moveStep;
});
this.render();
} else if (event.key === 'ArrowLeft') {
this.selectedShapes.forEach(shape => {
shape.x -= moveStep;
});
this.render();
} else if (event.key === 'ArrowRight') {
this.selectedShapes.forEach(shape => {
shape.x += moveStep;
});
this.render();
}
}
}
handleKeyUp(event) {
this.keysPressed[event.key] = false;
if (event.key === 'Shift') {
this.isMultiSelectMode = false;
}
}
handleTouchStart(event) {
event.preventDefault();
// 实现触摸开始逻辑
}
handleTouchMove(event) {
event.preventDefault();
// 实现触摸移动逻辑
}
handleTouchEnd(event) {
event.preventDefault();
// 实现触摸结束逻辑
}
render() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 绘制所有形状
this.shapes.forEach(shape => {
shape.draw(this.ctx);
// 为选中的形状绘制选择框
if (this.selectedShapes.includes(shape)) {
this.ctx.strokeStyle = '#00f';
this.ctx.lineWidth = 2;
this.ctx.strokeRect(
shape.x - 2,
shape.y - 2,
shape.width + 4,
shape.height + 4
);
}
});
}
}
// 使用示例
const canvas = document.getElementById('myCanvas');
const interactionManager = new InteractionManager(canvas);
// 添加一些形状
interactionManager.addShape(new DraggableShape(50, 50, 100, 100, 'red'));
interactionManager.addShape(new DraggableShape(200, 100, 80, 80, 'blue'));
interactionManager.addShape(new DraggableShape(100, 200, 120, 60, 'green'));
// 初始渲染
interactionManager.render();
8.5.2 交互状态与模式
实现不同的交互模式,如选择模式、绘制模式等:
class CanvasEditor {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.shapes = [];
this.history = [];
this.currentMode = 'select'; // 'select', 'draw', 'erase'
this.interactionManager = new InteractionManager(canvas);
this.setupToolbar();
}
setupToolbar() {
// 创建工具栏按钮
const selectButton = document.getElementById('selectMode');
const drawButton = document.getElementById('drawMode');
const eraseButton = document.getElementById('eraseMode');
selectButton.addEventListener('click', () => this.setMode('select'));
drawButton.addEventListener('click', () => this.setMode('draw'));
eraseButton.addEventListener('click', () => this.setMode('erase'));
}
setMode(mode) {
this.currentMode = mode;
// 更新交互管理器的行为
if (mode === 'select') {
this.interactionManager.enableSelection();
this.interactionManager.disableDrawing();
this.interactionManager.disableErasing();
} else if (mode === 'draw') {
this.interactionManager.disableSelection();
this.interactionManager.enableDrawing();
this.interactionManager.disableErasing();
} else if (mode === 'erase') {
this.interactionManager.disableSelection();
this.interactionManager.disableDrawing();
this.interactionManager.enableErasing();
}
// 更新UI状态
document.getElementById('selectMode').classList.toggle('active', mode === 'select');
document.getElementById('drawMode').classList.toggle('active', mode === 'draw');
document.getElementById('eraseMode').classList.toggle('active', mode === 'erase');
}
addShape(shape) {
this.shapes.push(shape);
this.interactionManager.addShape(shape);
this.saveToHistory();
}
removeShape(shape) {
const index = this.shapes.indexOf(shape);
if (index !== -1) {
this.shapes.splice(index, 1);
this.interactionManager.removeShape(shape);
this.saveToHistory();
}
}
saveToHistory() {
// 保存当前状态到历史记录
const state = JSON.stringify(this.shapes);
this.history.push(state);
// 限制历史记录长度
if (this.history.length > 50) {
this.history.shift();
}
}
undo() {
if (this.history.length > 1) {
// 移除当前状态
this.history.pop();
// 恢复到上一个状态
const previousState = JSON.parse(this.history[this.history.length - 1]);
this.shapes = previousState;
this.interactionManager.setShapes(this.shapes);
this.interactionManager.render();
}
}
}
// 使用示例
const canvas = document.getElementById('myCanvas');
const editor = new CanvasEditor(canvas);
// 默认选择模式
editor.setMode('select');
8.5.3 撤销/重做系统
实现完整的撤销/重做功能:
class HistoryManager {
constructor(editor) {
this.editor = editor;
this.undoStack = [];
this.redoStack = [];
this.maxStackSize = 50;
this.isPerformingUndoRedo = false;
}
saveState() {
if (this.isPerformingUndoRedo) return;
// 保存当前状态
const state = this.editor.getState();
this.undoStack.push(state);
// 清空重做栈
this.redoStack = [];
// 限制栈大小
if (this.undoStack.length > this.maxStackSize) {
this.undoStack.shift();
}
}
undo() {
if (this.undoStack.length <= 1) return; // 保留初始状态
// 保存当前状态到重做栈
const currentState = this.undoStack.pop();
this.redoStack.push(currentState);
// 恢复到上一个状态
const previousState = this.undoStack[this.undoStack.length - 1];
this.isPerformingUndoRedo = true;
this.editor.setState(previousState);
this.isPerformingUndoRedo = false;
}
redo() {
if (this.redoStack.length === 0) return;
// 获取要重做的状态
const stateToRedo = this.redoStack.pop();
this.undoStack.push(stateToRedo);
this.isPerformingUndoRedo = true;
this.editor.setState(stateToRedo);
this.isPerformingUndoRedo = false;
}
clear() {
// 保留当前状态作为唯一状态
const currentState = this.editor.getState();
this.undoStack = [currentState];
this.redoStack = [];
}
}
// 在CanvasEditor类中集成历史管理器
class CanvasEditor {
constructor(canvas) {
// ... 其他初始化代码 ...
this.historyManager = new HistoryManager(this);
// 添加快捷键
document.addEventListener('keydown', (event) => {
if (event.ctrlKey && event.key === 'z') {
event.preventDefault();
this.historyManager.undo();
} else if (event.ctrlKey && event.key === 'y') {
event.preventDefault();
this.historyManager.redo();
}
});
}
getState() {
// 返回当前编辑器状态的深拷贝
return JSON.parse(JSON.stringify({
shapes: this.shapes,
selectedShapes: this.interactionManager.selectedShapes.map(shape => this.shapes.indexOf(shape))
}));
}
setState(state) {
// 恢复编辑器状态
this.shapes = state.shapes;
// 更新交互管理器
this.interactionManager.setShapes(this.shapes);
this.interactionManager.selectedShapes = state.selectedShapes.map(index => this.shapes[index]);
// 重新渲染
this.interactionManager.render();
}
// 在所有修改操作后保存状态
addShape(shape) {
this.shapes.push(shape);
this.interactionManager.addShape(shape);
this.historyManager.saveState();
}
removeShape(shape) {
const index = this.shapes.indexOf(shape);
if (index !== -1) {
this.shapes.splice(index, 1);
this.interactionManager.removeShape(shape);
this.historyManager.saveState();
}
}
// ... 其他方法 ...
}
8.6 实用交互模式与案例
8.6.1 绘图应用
实现简单的绘图应用:
class DrawingApp {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.isDrawing = false;
this.brushSize = 5;
this.brushColor = '#000000';
this.lastX = 0;
this.lastY = 0;
this.paths = [];
this.currentPath = [];
this.bindEvents();
this.setupControls();
}
bindEvents() {
this.canvas.addEventListener('mousedown', this.startDrawing.bind(this));
this.canvas.addEventListener('mousemove', this.draw.bind(this));
this.canvas.addEventListener('mouseup', this.stopDrawing.bind(this));
this.canvas.addEventListener('mouseout', this.stopDrawing.bind(this));
// 触摸事件
this.canvas.addEventListener('touchstart', this.handleTouchStart.bind(this));
this.canvas.addEventListener('touchmove', this.handleTouchMove.bind(this));
this.canvas.addEventListener('touchend', this.handleTouchEnd.bind(this));
}
setupControls() {
// 颜色选择器
const colorPicker = document.getElementById('colorPicker');
colorPicker.addEventListener('change', (e) => {
this.brushColor = e.target.value;
});
// 画笔大小滑块
const sizeSlider = document.getElementById('sizeSlider');
sizeSlider.addEventListener('input', (e) => {
this.brushSize = e.target.value;
});
// 清除按钮
const clearButton = document.getElementById('clearButton');
clearButton.addEventListener('click', () => {
this.clearCanvas();
});
// 撤销按钮
const undoButton = document.getElementById('undoButton');
undoButton.addEventListener('click', () => {
this.undo();
});
}
startDrawing(e) {
const coords = getCanvasCoordinates(this.canvas, e);
this.isDrawing = true;
this.lastX = coords.x;
this.lastY = coords.y;
this.currentPath = [{
x: coords.x,
y: coords.y,
size: this.brushSize,
color: this.brushColor
}];
// 绘制起点
this.ctx.beginPath();
this.ctx.arc(coords.x, coords.y, this.brushSize / 2, 0, Math.PI * 2);
this.ctx.fillStyle = this.brushColor;
this.ctx.fill();
}
draw(e) {
if (!this.isDrawing) return;
const coords = getCanvasCoordinates(this.canvas, e);
// 保存点信息
this.currentPath.push({
x: coords.x,
y: coords.y,
size: this.brushSize,
color: this.brushColor
});
// 绘制线段
this.ctx.beginPath();
this.ctx.moveTo(this.lastX, this.lastY);
this.ctx.lineTo(coords.x, coords.y);
this.ctx.lineWidth = this.brushSize;
this.ctx.lineCap = 'round';
this.ctx.strokeStyle = this.brushColor;
this.ctx.stroke();
this.lastX = coords.x;
this.lastY = coords.y;
}
stopDrawing() {
if (this.isDrawing) {
this.isDrawing = false;
// 保存当前路径
if (this.currentPath.length > 1) {
this.paths.push(this.currentPath);
}
}
}
handleTouchStart(e) {
e.preventDefault();
const touch = e.touches[0];
const mouseEvent = new MouseEvent('mousedown', {
clientX: touch.clientX,
clientY: touch.clientY
});
this.canvas.dispatchEvent(mouseEvent);
}
handleTouchMove(e) {
e.preventDefault();
const touch = e.touches[0];
const mouseEvent = new MouseEvent('mousemove', {
clientX: touch.clientX,
clientY: touch.clientY
});
this.canvas.dispatchEvent(mouseEvent);
}
handleTouchEnd(e) {
e.preventDefault();
const mouseEvent = new MouseEvent('mouseup');
this.canvas.dispatchEvent(mouseEvent);
}
clearCanvas() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.paths = [];
}
undo() {
if (this.paths.length === 0) return;
// 移除最后一条路径
this.paths.pop();
// 重新绘制所有路径
this.redrawPaths();
}
redrawPaths() {
// 清除画布
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 重新绘制所有路径
for (const path of this.paths) {
if (path.length < 2) continue;
// 绘制起点
const startPoint = path[0];
this.ctx.beginPath();
this.ctx.arc(startPoint.x, startPoint.y, startPoint.size / 2, 0, Math.PI * 2);
this.ctx.fillStyle = startPoint.color;
this.ctx.fill();
// 绘制路径
for (let i = 1; i < path.length; i++) {
const prevPoint = path[i - 1];
const currentPoint = path[i];
this.ctx.beginPath();
this.ctx.moveTo(prevPoint.x, prevPoint.y);
this.ctx.lineTo(currentPoint.x, currentPoint.y);
this.ctx.lineWidth = currentPoint.size;
this.ctx.lineCap = 'round';
this.ctx.strokeStyle = currentPoint.color;
this.ctx.stroke();
}
}
}
}
// 使用示例
const canvas = document.getElementById('drawingCanvas');
const drawingApp = new DrawingApp(canvas);
8.6.2 图形编辑器
实现简单的图形编辑器:
class ShapeEditor {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.shapes = [];
this.selectedShape = null;
this.currentMode = 'select'; // 'select', 'rectangle', 'circle', 'triangle'
this.isDrawing = false;
this.startX = 0;
this.startY = 0;
this.bindEvents();
this.setupToolbar();
}
bindEvents() {
this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
this.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
this.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
}
setupToolbar() {
document.getElementById('selectTool').addEventListener('click', () => this.setMode('select'));
document.getElementById('rectangleTool').addEventListener('click', () => this.setMode('rectangle'));
document.getElementById('circleTool').addEventListener('click', () => this.setMode('circle'));
document.getElementById('triangleTool').addEventListener('click', () => this.setMode('triangle'));
document.getElementById('deleteTool').addEventListener('click', () => this.deleteSelectedShape());
}
setMode(mode) {
this.currentMode = mode;
// 更新工具栏UI
document.getElementById('selectTool').classList.toggle('active', mode === 'select');
document.getElementById('rectangleTool').classList.toggle('active', mode === 'rectangle');
document.getElementById('circleTool').classList.toggle('active', mode === 'circle');
document.getElementById('triangleTool').classList.toggle('active', mode === 'triangle');
}
handleMouseDown(e) {
const coords = getCanvasCoordinates(this.canvas, e);
if (this.currentMode === 'select') {
// 选择模式 - 检查是否点击了某个形状
this.selectedShape = null;
for (let i = this.shapes.length - 1; i >= 0; i--) {
if (this.shapes[i].containsPoint(coords.x, coords.y)) {
this.selectedShape = this.shapes[i];
this.selectedShape.startDrag(coords.x, coords.y);
break;
}
}
} else {
// 绘制模式 - 开始创建新形状
this.isDrawing = true;
this.startX = coords.x;
this.startY = coords.y;
}
this.render();
}
handleMouseMove(e) {
const coords = getCanvasCoordinates(this.canvas, e);
if (this.currentMode === 'select' && this.selectedShape) {
// 拖动选中的形状
this.selectedShape.drag(coords.x, coords.y);
this.render();
} else if (this.isDrawing) {
// 预览正在绘制的形状
this.render();
// 绘制预览形状
const width = coords.x - this.startX;
const height = coords.y - this.startY;
this.ctx.strokeStyle = '#000';
this.ctx.lineWidth = 2;
if (this.currentMode === 'rectangle') {
this.ctx.strokeRect(this.startX, this.startY, width, height);
} else if (this.currentMode === 'circle') {
const radius = Math.sqrt(width * width + height * height);
this.ctx.beginPath();
this.ctx.arc(this.startX, this.startY, radius, 0, Math.PI * 2);
this.ctx.stroke();
} else if (this.currentMode === 'triangle') {
this.ctx.beginPath();
this.ctx.moveTo(this.startX, this.startY);
this.ctx.lineTo(coords.x, coords.y);
this.ctx.lineTo(this.startX - (coords.x - this.startX), coords.y);
this.ctx.closePath();
this.ctx.stroke();
}
}
}
handleMouseUp(e) {
const coords = getCanvasCoordinates(this.canvas, e);
if (this.currentMode === 'select' && this.selectedShape) {
this.selectedShape.stopDrag();
} else if (this.isDrawing) {
// 创建新形状
const width = coords.x - this.startX;
const height = coords.y - this.startY;
if (Math.abs(width) > 5 && Math.abs(height) > 5) {
let newShape;
if (this.currentMode === 'rectangle') {
newShape = new Rectangle(
this.startX,
this.startY,
width,
height,
'#3498db'
);
} else if (this.currentMode === 'circle') {
const radius = Math.sqrt(width * width + height * height);
newShape = new Circle(
this.startX,
this.startY,
radius,
'#e74c3c'
);
} else if (this.currentMode === 'triangle') {
newShape = new Triangle(
this.startX,
this.startY,
coords.x,
coords.y,
this.startX - (coords.x - this.startX),
coords.y,
'#2ecc71'
);
}
if (newShape) {
this.shapes.push(newShape);
}
}
}
this.isDrawing = false;
this.render();
}
deleteSelectedShape() {
if (this.selectedShape) {
const index = this.shapes.indexOf(this.selectedShape);
if (index !== -1) {
this.shapes.splice(index, 1);
this.selectedShape = null;
this.render();
}
}
}
render() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 绘制所有形状
for (const shape of this.shapes) {
shape.draw(this.ctx);
// 为选中的形状绘制选择框
if (shape === this.selectedShape) {
this.ctx.strokeStyle = '#00f';
this.ctx.lineWidth = 2;
if (shape instanceof Rectangle) {
this.ctx.strokeRect(
shape.x - 2,
shape.y - 2,
shape.width + 4,
shape.height + 4
);
} else if (shape instanceof Circle) {
this.ctx.beginPath();
this.ctx.arc(shape.x, shape.y, shape.radius + 2, 0, Math.PI * 2);
this.ctx.stroke();
} else if (shape instanceof Triangle) {
this.ctx.beginPath();
this.ctx.moveTo(shape.x1 - 2, shape.y1 - 2);
this.ctx.lineTo(shape.x2 + 2, shape.y2 + 2);
this.ctx.lineTo(shape.x3 - 2, shape.y3 + 2);
this.ctx.closePath();
this.ctx.stroke();
}
}
}
}
}
// 形状类定义
class Rectangle {
constructor(x, y, width, height, color) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.color = color;
this.dragOffsetX = 0;
this.dragOffsetY = 0;
}
draw(ctx) {
ctx.fillStyle = this.color;
ctx.fillRect(this.x, this.y, this.width, this.height);
}
containsPoint(x, y) {
return x >= this.x && x <= this.x + this.width &&
y >= this.y && y <= this.y + this.height;
}
startDrag(x, y) {
this.dragOffsetX = x - this.x;
this.dragOffsetY = y - this.y;
}
drag(x, y) {
this.x = x - this.dragOffsetX;
this.y = y - this.dragOffsetY;
}
stopDrag() {
// 拖动结束
}
}
class Circle {
constructor(x, y, radius, color) {
this.x = x;
this.y = y;
this.radius = radius;
this.color = color;
this.dragOffsetX = 0;
this.dragOffsetY = 0;
}
draw(ctx) {
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
}
containsPoint(x, y) {
const dx = x - this.x;
const dy = y - this.y;
return dx * dx + dy * dy <= this.radius * this.radius;
}
startDrag(x, y) {
this.dragOffsetX = x - this.x;
this.dragOffsetY = y - this.y;
}
drag(x, y) {
this.x = x - this.dragOffsetX;
this.y = y - this.dragOffsetY;
}
stopDrag() {
// 拖动结束
}
}
class Triangle {
constructor(x1, y1, x2, y2, x3, y3, color) {
this.x1 = x1;
this.y1 = y1;
this.x2 = x2;
this.y2 = y2;
this.x3 = x3;
this.y3 = y3;
this.color = color;
this.dragOffsetX = 0;
this.dragOffsetY = 0;
}
draw(ctx) {
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.moveTo(this.x1, this.y1);
ctx.lineTo(this.x2, this.y2);
ctx.lineTo(this.x3, this.y3);
ctx.closePath();
ctx.fill();
}
containsPoint(x, y) {
// 使用重心坐标法判断点是否在三角形内
const denominator = ((this.y2 - this.y3) * (this.x1 - this.x3) + (this.x3 - this.x2) * (this.y1 - this.y3));
const a = ((this.y2 - this.y3) * (x - this.x3) + (this.x3 - this.x2) * (y - this.y3)) / denominator;
const b = ((this.y3 - this.y1) * (x - this.x3) + (this.x1 - this.x3) * (y - this.y3)) / denominator;
const c = 1 - a - b;
return a >= 0 && a <= 1 && b >= 0 && b <= 1 && c >= 0 && c <= 1;
}
startDrag(x, y) {
this.dragOffsetX = x - this.x1;
this.dragOffsetY = y - this.y1;
}
drag(x, y) {
const dx = x - this.dragOffsetX - this.x1;
const dy = y - this.dragOffsetY - this.y1;
this.x1 += dx;
this.y1 += dy;
this.x2 += dx;
this.y2 += dy;
this.x3 += dx;
this.y3 += dy;
}
stopDrag() {
// 拖动结束
}
}
// 使用示例
const canvas = document.getElementById('shapeEditor');
const editor = new ShapeEditor(canvas);
8.6.3 游戏控制系统
实现简单的游戏控制系统:
class GameController {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.player = new Player(canvas.width / 2, canvas.height / 2);
this.enemies = [];
this.bullets = [];
this.keysPressed = {};
this.mousePosition = { x: 0, y: 0 };
this.isMouseDown = false;
this.lastShotTime = 0;
this.shotCooldown = 250; // 射击冷却时间(毫秒)
this.bindEvents();
this.spawnEnemies();
this.gameLoop();
}
bindEvents() {
// 键盘事件
document.addEventListener('keydown', (e) => {
this.keysPressed[e.key] = true;
});
document.addEventListener('keyup', (e) => {
this.keysPressed[e.key] = false;
});
// 鼠标事件
this.canvas.addEventListener('mousemove', (e) => {
this.mousePosition = getCanvasCoordinates(this.canvas, e);
});
this.canvas.addEventListener('mousedown', () => {
this.isMouseDown = true;
});
this.canvas.addEventListener('mouseup', () => {
this.isMouseDown = false;
});
this.canvas.addEventListener('mouseleave', () => {
this.isMouseDown = false;
});
// 触摸事件
this.canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
this.isMouseDown = true;
if (e.touches.length > 0) {
this.mousePosition = getCanvasCoordinates(this.canvas, e.touches[0]);
}
});
this.canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
if (e.touches.length > 0) {
this.mousePosition = getCanvasCoordinates(this.canvas, e.touches[0]);
}
});
this.canvas.addEventListener('touchend', (e) => {
e.preventDefault();
this.isMouseDown = false;
});
}
spawnEnemies() {
// 生成敌人
for (let i = 0; i < 5; i++) {
const x = Math.random() * this.canvas.width;
const y = Math.random() * this.canvas.height;
this.enemies.push(new Enemy(x, y));
}
}
update() {
// 更新玩家位置
const moveSpeed = 5;
if (this.keysPressed['ArrowUp'] || this.keysPressed['w']) {
this.player.y -= moveSpeed;
}
if (this.keysPressed['ArrowDown'] || this.keysPressed['s']) {
this.player.y += moveSpeed;
}
if (this.keysPressed['ArrowLeft'] || this.keysPressed['a']) {
this.player.x -= moveSpeed;
}
if (this.keysPressed['ArrowRight'] || this.keysPressed['d']) {
this.player.x += moveSpeed;
}
// 限制玩家在画布范围内
this.player.x = Math.max(0, Math.min(this.canvas.width, this.player.x));
this.player.y = Math.max(0, Math.min(this.canvas.height, this.player.y));
// 更新玩家朝向(面向鼠标位置)
this.player.updateRotation(this.mousePosition);
// 处理射击
if (this.isMouseDown) {
const currentTime = Date.now();
if (currentTime - this.lastShotTime > this.shotCooldown) {
this.lastShotTime = currentTime;
this.player.shoot(this.bullets);
}
}
// 更新子弹
for (let i = this.bullets.length - 1; i >= 0; i--) {
this.bullets[i].update();
// 移除超出画布的子弹
if (this.bullets[i].isOutOfBounds(this.canvas)) {
this.bullets.splice(i, 1);
continue;
}
// 检测子弹与敌人的碰撞
for (let j = this.enemies.length - 1; j >= 0; j--) {
if (this.bullets[i] && this.bullets[i].collidesWith(this.enemies[j])) {
this.enemies.splice(j, 1);
this.bullets.splice(i, 1);
break;
}
}
}
// 更新敌人
for (const enemy of this.enemies) {
enemy.update(this.player);
// 检测敌人与玩家的碰撞
if (enemy.collidesWith(this.player)) {
console.log('游戏结束!');
// 实现游戏结束逻辑
}
}
// 如果所有敌人都被消灭,生成新的敌人
if (this.enemies.length === 0) {
this.spawnEnemies();
}
}
render() {
// 清除画布
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 绘制玩家
this.player.draw(this.ctx);
// 绘制子弹
for (const bullet of this.bullets) {
bullet.draw(this.ctx);
}
// 绘制敌人
for (const enemy of this.enemies) {
enemy.draw(this.ctx);
}
}
gameLoop() {
this.update();
this.render();
requestAnimationFrame(this.gameLoop.bind(this));
}
}
class Player {
constructor(x, y) {
this.x = x;
this.y = y;
this.radius = 20;
this.color = '#3498db';
this.rotation = 0; // 朝向角度(弧度)
}
updateRotation(targetPosition) {
// 计算朝向鼠标位置的角度
const dx = targetPosition.x - this.x;
const dy = targetPosition.y - this.y;
this.rotation = Math.atan2(dy, dx);
}
draw(ctx) {
ctx.save();
// 绘制玩家主体
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
// 绘制朝向指示器
ctx.strokeStyle = '#fff';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(this.x, this.y);
ctx.lineTo(
this.x + Math.cos(this.rotation) * this.radius,
this.y + Math.sin(this.rotation) * this.radius
);
ctx.stroke();
ctx.restore();
}
shoot(bullets) {
// 创建新子弹
const bullet = new Bullet(
this.x + Math.cos(this.rotation) * this.radius,
this.y + Math.sin(this.rotation) * this.radius,
this.rotation
);
bullets.push(bullet);
}
collidesWith(entity) {
const dx = this.x - entity.x;
const dy = this.y - entity.y;
const distance = Math.sqrt(dx * dx + dy * dy);
return distance < this.radius + entity.radius;
}
}
class Bullet {
constructor(x, y, angle) {
this.x = x;
this.y = y;
this.radius = 5;
this.color = '#e74c3c';
this.speed = 10;
this.angle = angle;
}
update() {
// 根据角度移动子弹
this.x += Math.cos(this.angle) * this.speed;
this.y += Math.sin(this.angle) * this.speed;
}
draw(ctx) {
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
}
isOutOfBounds(canvas) {
return this.x < 0 || this.x > canvas.width ||
this.y < 0 || this.y > canvas.height;
}
collidesWith(entity) {
const dx = this.x - entity.x;
const dy = this.y - entity.y;
const distance = Math.sqrt(dx * dx + dy * dy);
return distance < this.radius + entity.radius;
}
}
class Enemy {
constructor(x, y) {
this.x = x;
this.y = y;
this.radius = 15;
this.color = '#e74c3c';
this.speed = 2;
}
update(player) {
// 向玩家移动
const dx = player.x - this.x;
const dy = player.y - this.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 0) {
this.x += (dx / distance) * this.speed;
this.y += (dy / distance) * this.speed;
}
}
draw(ctx) {
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
}
collidesWith(entity) {
const dx = this.x - entity.x;
const dy = this.y - entity.y;
const distance = Math.sqrt(dx * dx + dy * dy);
return distance < this.radius + entity.radius;
}
}
// 使用示例
const canvas = document.getElementById('gameCanvas');
const gameController = new GameController(canvas);
8.7 小结与练习
在本章中,我们学习了如何在Canvas中处理各种用户交互事件,包括鼠标事件、键盘事件和触摸事件。我们还探讨了如何构建交互式Canvas应用,如绘图工具、图形编辑器和简单的游戏。
通过合理地组织代码和使用事件委托等技术,我们可以创建高效、响应迅速的Canvas应用。同时,我们也学习了如何处理多种输入设备,使应用能够在不同平台上良好运行。
练习
- 扩展绘图应用,添加更多工具(如橡皮擦、直线工具、形状工具等)。
- 为图形编辑器添加更多功能,如调整形状大小、旋转形状、改变形状颜色等。
- 改进游戏控制系统,添加得分系统、生命值系统和游戏难度调整。
- 实现一个简单的Canvas画板应用,支持多种绘图工具和图层管理。
- 创建一个交互式数据可视化应用,允许用户通过鼠标交互来探索数据。
在下一章中,我们将学习如何实现高级动画和物理效果,为Canvas应用添加更多生动和真实的交互体验。