在前面的章节中,我们学习了如何在Canvas上绘制各种图形、处理图像以及创建动画。然而,一个真正交互式的Canvas应用还需要能够响应用户的输入。本章将详细介绍如何在Canvas中处理各种用户交互事件,包括鼠标、键盘、触摸等输入方式,以及如何构建响应式的Canvas应用。

8.1 事件基础与事件模型

8.1.1 DOM事件系统概述

Canvas元素作为DOM的一部分,可以利用浏览器的事件系统来处理用户交互。浏览器事件模型包括三个阶段:

  1. 捕获阶段:事件从文档根节点向下传播到目标元素
  2. 目标阶段:事件到达目标元素
  3. 冒泡阶段:事件从目标元素向上冒泡到文档根节点
// 添加事件监听器的基本语法
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应用。同时,我们也学习了如何处理多种输入设备,使应用能够在不同平台上良好运行。

练习

  1. 扩展绘图应用,添加更多工具(如橡皮擦、直线工具、形状工具等)。
  2. 为图形编辑器添加更多功能,如调整形状大小、旋转形状、改变形状颜色等。
  3. 改进游戏控制系统,添加得分系统、生命值系统和游戏难度调整。
  4. 实现一个简单的Canvas画板应用,支持多种绘图工具和图层管理。
  5. 创建一个交互式数据可视化应用,允许用户通过鼠标交互来探索数据。

在下一章中,我们将学习如何实现高级动画和物理效果,为Canvas应用添加更多生动和真实的交互体验。