You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何用Canvas实现真正的可点击绘制无限网格?

实现类似生命游戏网站的无限网格(支持鼠标绘制)的思路

我需要创建一个类似生命游戏网站的无限网格,支持鼠标点击在每个网格单元绘制图像,请问实现思路是什么?我尝试过Stack Overflow上《How can I make infinite grid with canvas?》的方案,但它只是一个范围有限的伪无限网格,相关代码如下:

const init_infgrid = () => {
  canvas_infgrid = $c('canvas');
  canvas_infgrid.width = window.innerWidth;
  canvas_infgrid.height = window.innerHeight;
  c_infgrid = canvas_infgrid.getContext('2d');

  canvas_infgrid.addEventListener("mousedown", e => {
    reset();
    start_infgrid = getPos_Infgrid(e)
  });
  canvas_infgrid.addEventListener("mouseup", reset);
  canvas_infgrid.addEventListener("mouseleave", reset);

  canvas_infgrid.addEventListener("mousemove", e => {
      if (!start_infgrid) return;
      let pos = getPos_Infgrid(e);
      c_infgrid.translate(pos.x - start_infgrid.x, pos.y - start_infgrid.y);
      draw();
      start_infgrid = pos;
  });
  $('#canvasarea').appendChild(canvas_infgrid)
  draw()
}
function draw() {
    let step = 50;
    let left = 0.5 - Math.ceil(canvas_infgrid.width / step) * step;
    let top = 0.5 - Math.ceil(canvas_infgrid.height / step) * step;
    let right = 2 * canvas_infgrid.width;
    let bottom = 2 * canvas_infgrid.height;
    c_infgrid.clearRect(left, top, right - left, bottom - top);
    c_infgrid.beginPath();
    for (let x = left; x < right; x += step) {
        c_infgrid.moveTo(x, top);
        c_infgrid.lineTo(x, bottom);
    }
    for (let y = top; y < bottom; y += step) {
        c_infgrid.moveTo(left, y);
        c_infgrid.lineTo(right, y);
    }
    c_infgrid.strokeStyle = "#888";
    c_infgrid.stroke();
}

// Mouse event handling:
let start_infgrid;
const getPos_Infgrid = (e) => ({
    x: e.clientX - canvas_infgrid.offsetLeft,
    y: e.clientY - canvas_infgrid.offsetTop
});

const reset = () => {
    start_infgrid = null;
    //no want to reset translation
    //c_infgrid.setTransform(1, 0, 0, 1, 0, 0); // reset translation
    draw();
}

核心实现思路

原方案的问题在于:仅通过Canvas变换平移网格线,但没有持久化存储用户绘制的单元,且网格线的绘制范围是固定的(2*canvasWidth),本质还是有限范围的伪无限。要实现真正的无限网格,需要从数据存储、坐标映射、绘制逻辑三个层面重构:

1. 用稀疏数据结构存储活跃网格单元

无限网格的大部分区域是空的,不需要全量存储。用哈希表(比如JS的Map)仅记录用户修改过的单元:

  • 键:用"x,y"格式的字符串标识网格单元的逻辑坐标(如"3,-5")
  • 值:存储单元状态(比如是否激活、颜色等)

示例:

const activeCells = new Map();
// 激活某个单元
activeCells.set(`${gridX},${gridY}`, { color: '#000' });
// 取消激活
activeCells.delete(`${gridX},${gridY}`);

2. 维护全局偏移量,实现坐标映射

放弃直接修改Canvas变换矩阵的方式,改用offsetX、offsetY记录当前视口与网格原点的偏移:

  • 像素坐标转网格逻辑坐标:将鼠标点击的Canvas像素位置,转换为无限网格中的逻辑坐标
    const gridStep = 50; // 网格单元大小
    function pixelToGrid(pixelX, pixelY) {
      const gridX = Math.floor((pixelX + offsetX) / gridStep);
      const gridY = Math.floor((pixelY + offsetY) / gridStep);
      return { x: gridX, y: gridY };
    }
    
  • 网格逻辑坐标转像素坐标:绘制时将逻辑坐标转换为Canvas上的像素位置
    function gridToPixel(gridX, gridY) {
      const pixelX = gridX * gridStep - offsetX;
      const pixelY = gridY * gridStep - offsetY;
      return { x: pixelX, y: pixelY };
    }
    

3. 按需绘制网格与活跃单元

只绘制当前视口内及边缘少量区域的内容,保证性能:

  • 绘制网格线:计算当前视口覆盖的网格线范围,批量绘制:
    function drawGrid() {
      const canvasWidth = canvas_infgrid.width;
      const canvasHeight = canvas_infgrid.height;
      const step = gridStep;
    
      // 计算视口覆盖的网格线起始/结束位置
      const startX = Math.floor((0 - offsetX) / step) * step - offsetX;
      const endX = Math.floor((canvasWidth - offsetX) / step) * step + step - offsetX;
      const startY = Math.floor((0 - offsetY) / step) * step - offsetY;
      const endY = Math.floor((canvasHeight - offsetY) / step) * step + step - offsetY;
    
      c_infgrid.clearRect(0, 0, canvasWidth, canvasHeight);
      c_infgrid.beginPath();
      c_infgrid.strokeStyle = '#888';
    
      // 绘制竖线
      for (let x = startX; x <= endX; x += step) {
        c_infgrid.moveTo(x, 0);
        c_infgrid.lineTo(x, canvasHeight);
      }
      // 绘制横线
      for (let y = startY; y <= endY; y += step) {
        c_infgrid.moveTo(0, y);
        c_infgrid.lineTo(canvasWidth, y);
      }
      c_infgrid.stroke();
    }
    
  • 绘制活跃单元:遍历哈希表中的单元,仅绘制在当前视口内的部分:
    function drawActiveCells() {
      const canvasWidth = canvas_infgrid.width;
      const canvasHeight = canvas_infgrid.height;
      const step = gridStep;
    
      c_infgrid.fillStyle = '#000';
      for (const [key, state] of activeCells.entries()) {
        const [gridX, gridY] = key.split(',').map(Number);
        const { x: pixelX, y: pixelY } = gridToPixel(gridX, gridY);
        // 判断是否在视口内
        if (pixelX >= -step && pixelX <= canvasWidth && pixelY >= -step && pixelY <= canvasHeight) {
          c_infgrid.fillRect(pixelX, pixelY, step - 1, step - 1); // 留1px间隙显示网格线
        }
      }
    }
    

4. 交互逻辑重构

  • 平移网格:鼠标拖拽时,更新offsetX和offsetY,然后重新绘制:
    let isDragging = false;
    let lastMousePos = { x: 0, y: 0 };
    
    canvas_infgrid.addEventListener('mousedown', (e) => {
      isDragging = true;
      const pos = getPos_Infgrid(e);
      lastMousePos = pos;
    });
    
    canvas_infgrid.addEventListener('mousemove', (e) => {
      if (!isDragging) return;
      const pos = getPos_Infgrid(e);
      // 更新偏移量:鼠标拖动的距离就是视口偏移的变化量
      offsetX += pos.x - lastMousePos.x;
      offsetY += pos.y - lastMousePos.y;
      lastMousePos = pos;
      // 重绘
      drawGrid();
      drawActiveCells();
    });
    
    canvas_infgrid.addEventListener('mouseup', () => isDragging = false);
    canvas_infgrid.addEventListener('mouseleave', () => isDragging = false);
    
  • 绘制单元:鼠标点击或拖拽时,转换坐标并更新哈希表:
    let isDrawing = false;
    
    canvas_infgrid.addEventListener('mousedown', (e) => {
      // 区分平移和绘制:比如按住Ctrl是平移,否则是绘制
      if (e.ctrlKey) {
        isDragging = true;
        lastMousePos = getPos_Infgrid(e);
      } else {
        isDrawing = true;
        const pos = getPos_Infgrid(e);
        const gridPos = pixelToGrid(pos.x, pos.y);
        const key = `${gridPos.x},${gridPos.y}`;
        activeCells.set(key, { color: '#000' });
        drawGrid();
        drawActiveCells();
      }
    });
    
    canvas_infgrid.addEventListener('mousemove', (e) => {
      if (isDrawing) {
        const pos = getPos_Infgrid(e);
        const gridPos = pixelToGrid(pos.x, pos.y);
        const key = `${gridPos.x},${gridPos.y}`;
        activeCells.set(key, { color: '#000' });
        drawGrid();
        drawActiveCells();
      }
    });
    
    canvas_infgrid.addEventListener('mouseup', () => {
      isDragging = false;
      isDrawing = false;
    });
    

5. 性能优化建议

  • 对活跃单元进行空间分块:将网格分成固定大小的块(比如10x10单元为一块),只加载视口附近的块,减少遍历次数。
  • 重绘时使用requestAnimationFrame,避免频繁同步绘制。
  • 鼠标移动事件添加节流,降低重绘频率。

内容的提问来源于stack exchange,提问作者Quan Vu

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.14 02:10:57