阅读视图

发现新文章,点击刷新页面。

【Virtual World 04】我们的目标,无限宇宙!!

这是纯前端手搓虚拟世界第四篇。

前端佬们,前三篇的基础打好,基本创建一个单体的应用没啥问题了。但前端佬肯定发现一个很尴尬的问题!

93B1A00879A9B67271080936B8A2D89CE1D69417_size242_w423_h220.gif

那就是我们的 Canvas 只有 600x600。

如果你想直接搞一幅《清明上河图》,那画着画着,就会发现——没地儿了

立Flag

按照最简单的方法,把canvas加大,把屏幕加大,这样就不只600x600了。

But!!!!

装X装到头,抠抠搜搜加那点资源,还是不够用。既然是虚拟世界,我们的目前,就应该无限宇宙!

这篇,就是要把想法变成现实,一个通向无限世界的“窗口”,实现一个虚拟摄像机(Virtual Camera) 。你看到的只是摄像机拍到的画面,而世界本身,你的梦想有多大,那么就有多大。

timg (1).gif


战略思考

上了class的贼船,首先想法就是抽象一个类,来管理这个。Viewport(视口) ,专门处理这些事情。

它的核心功能只有两个:

  1. 坐标转换:搞清楚“鼠标点在屏幕上的 (100,100)”到底对应“虚拟世界里的哪一个坐标”。
  2. 平移交互(Pan) :按住 空格键 + 鼠标左键拖拽,移动摄像机,浏览世界的其他角落。

嗯,够逼格了!

timg (25).gif


基础原理

要个无限空间其实是一个视觉小把戏,首先我们得确认浏览器是不会允许你创建一个 width: 99999999px 的 Canvas 的,这样浏览器的内存会直接嗝屁。

然后我们移动鼠标位置是固定不变的,大致示意图如下:

image.png

虚拟层就是在数学层面上的模拟层,所有的移动,添加都是放到虚拟层上。

我们脑子里始终要记得两套坐标系:

  • 屏幕坐标系 (Screen Space)

    • 这是物理现实
    • 原点 (0,0) 永远在 Canvas 的左上角。
    • 范围有限,比如 600x600
    • 用途:接收鼠标事件 (evt.offsetX, evt.offsetY),最终渲染图像。
  • 世界坐标系 (World Space)

    • 这是虚拟数据
    • 原点 (0,0) 是世界的中心。
    • 范围无限,你的点可以是 (-5000, 99999)
    • 用途:存储 PointSegment 的真实位置。

视口变换的原理,并不是真的把 Canvas 的 DOM 元素拖走了,而是我们在绘制之前,对所有的坐标做了一次数学偏移。

想象一下你拿着一个相机(视口)在拍风景,如果你想看右边的树,你需要把相机向移,但在相机取景框里,那棵树看起来是向移了。

嗯,大概就是这样了。


上代码

听不懂??直接上代码!!!!

0.gif

视口控制器:Viewport

src 下新建文件夹 view,然后创建 viewport.js

这玩意的数学原理其实就是一个简单的减法:

世界坐标 = 屏幕坐标 - 视口中心偏移量

如果我把视口往右移 100px,那原本的世界原点 (0,0) 现在就在屏幕的 (100,0) 位置。

JavaScript

// src/view/viewport.js
import Point2D from "../primitives/point2D.js";

export default class Viewport {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = canvas.getContext("2d");

    // 视口的初始缩放级别(为下一篇做铺垫)
    this.zoom = 1;
    // 视口的中心偏移量(相当于摄像机的位置)
    // 默认让 (0,0) 在画布中心,这样更符合数学直觉
    this.center = new Point2D(canvas.width / 2, canvas.height / 2);
    // 另一种更通用的做法是记录 offset,也就是 panning 的距离
    this.offset = new Point2D(0, 0);

    // 拖拽状态管理
    this.drag = {
      start: new Point2D(0, 0), // 鼠标按下的起始点
      end: new Point2D(0, 0),   // 鼠标当前的结束点
      offset: new Point2D(0, 0),// 这一瞬间拖了多远
      active: false             // 是否正在拖拽
    };

    this.#addEventListeners();
  }

  // 核心数学题:屏幕坐标 -> 世界坐标
  getMouse(evt) {
    // 现在的计算逻辑:(鼠标位置 - 中心点) * 缩放系数 - 偏移量
    // 暂时先不加缩放,只处理平移
    return new Point2D(
      (evt.offsetX - this.center.x) - this.offset.x,
      (evt.offsetY - this.center.y) - this.offset.y
    );
  }

  // 获取当前的偏移量,给 Canvas 用
  getOffset() {
     return new Point2D(
         this.center.x + this.offset.x, 
         this.center.y + this.offset.y
     );
  }

  #addEventListeners() {
    this.canvas.addEventListener("mousedown", (evt) => {
      // 只有按住空格(Space)且点击左键(0)时,才触发平移
      // 这里的 evt.button == 0 是左键
      // 这里的 evt.ctrlKey / shiftKey 等也可以判断,但我们要判断空格键状态
      // 由于 mousedown 里拿不到键盘持续状态,我们需要一个外部变量或者在 window 上监听键盘
      // 简单的做法:检查 evt 里是否包含按键信息?不包含。
      // 所以我们通常单独存一个键盘状态,或者直接利用 "wheel" 做缩放,用 "middle" 做平移
      // 但既然需求是 "空格+左键",我们需要配合 keydown/keyup
    });
  }
}

这里歇一歇,理理代码思路。

9.gif

**“按住空格”**这个逻辑在 DOM 事件里稍微有点麻烦。因为 mousedown 事件对象里不直接告诉你“空格键是不是正被按着”。

我们需要给 Viewport 增加键盘监听。

修正后的完整 src/view/viewport.js

import Point2D from "../primitives/point2D.js";

export default class Viewport {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = canvas.getContext("2d");

    this.zoom = 1;
    // 这里的 offset 就是我们一共平移了多少距离
    this.offset = new Point2D(0, 0); 
    
    // 拖拽计算用的临时变量
    this.drag = {
      start: new Point2D(0, 0),
      end: new Point2D(0, 0),
      offset: new Point2D(0, 0),
      active: false
    };

    this.#addEventListeners();
  }

  // 计算:鼠标在屏幕上的点,对应的真实世界坐标在哪里
  getMouse(evt, subtractDragOffset = false) {
    const p = new Point2D(
      (evt.offsetX - this.canvas.width / 2) * this.zoom - this.offset.x,
      (evt.offsetY - this.canvas.height / 2) * this.zoom - this.offset.y
    );
    return p;
  }
  
  // 给 Canvas 渲染用的,告诉它该平移多少
  getOffset() {
      return new Point2D(
          this.offset.x + this.canvas.width / 2,
          this.offset.y + this.canvas.height / 2
      );
  }

  #addEventListeners() {
    // 记录空格键状态
    this.isSpacePressed = false;

    window.addEventListener("keydown", (evt) => {
       if (evt.code === "Space") {
           this.isSpacePressed = true;
       }
    });

    window.addEventListener("keyup", (evt) => {
       if (evt.code === "Space") {
           this.isSpacePressed = false;
       }
    });

    this.canvas.addEventListener("mousedown", (evt) => {
      // 只有按住空格 + 左键,才开始拖拽视口
      if (this.isSpacePressed && evt.button === 0) {
        this.drag.start = this.getMouse(evt);
        this.drag.active = true;
      }
    });

    this.canvas.addEventListener("mousemove", (evt) => {
      if (this.drag.active) {
        this.drag.end = this.getMouse(evt);
        // 计算这一瞬间移动了多少
        this.drag.offset = new Point2D(
            this.drag.end.x - this.drag.start.x,
            this.drag.end.y - this.drag.start.y
        );
        // 累加到总偏移量里
        this.offset.x += this.drag.offset.x;
        this.offset.y += this.drag.offset.y;
        
        // 重要:重置 start,因为我们已经处理了这段位移
        // 如果不重置,你会发现画面飞得越来越快
        this.drag.start = this.getMouse(evt); 
      }
    });

    this.canvas.addEventListener("mouseup", () => {
      if (this.drag.active) {
        this.drag.active = false;
      }
    });
  }
}

修改 GraphEditor

这就是上篇埋的坑。之前我们在 GraphEditor 里直接用了 evt.offsetX。现在不行了,必须通过 viewport.getMouse(evt) 来获取坐标。

修改 src/editors/graphEditor.js

  1. 构造函数接收 viewport
  2. 事件监听改用 viewport.getMouse(evt)
  3. 冲突解决:如果按住了空格(正在拖拽视图),就不要触发“画点”的逻辑。
// src/editors/graphEditor.js
import Point2D from "../primitives/point2D.js";
import Segment from "../primitives/segment.js";

export default class GraphEditor {
  constructor(canvas, graph, viewport) {
    this.canvas = canvas;
    this.graph = graph;
    this.viewport = viewport;

    this.ctx = canvas.getContext("2d");

    // 状态机
    this.selected = null; // 当前选中的点(用于连线起点)
    this.hovered = null; // 鼠标悬停的点
    this.dragging = false; // 预留给未来拖拽用
    this.mouse = null; // 当前鼠标位置

    // 启动监听
    this.#addEventListeners();
  }

  #addEventListeners() {
    // 1. 鼠标按下事件
    this.canvas.addEventListener("mousedown", (evt) => {
      // 只有左键(0)和右键(2)才处理
      if (evt.button == 2) {
        // 右键逻辑
        if (this.selected) {
          this.selected = null; // 取消当前选中,停止连线
        } else if (this.hovered) {
          this.#removePoint(this.hovered); // 删除点
        }
      }

      if (evt.button == 0) {
        // [新增] 如果视口正在被拖拽,或者是空格按下状态,不要画点
        if (this.viewport.drag.active || this.viewport.isSpacePressed) return;

        // 左键逻辑
        // 如果鼠标在某个点上,就选中它;如果不在,就新建一个点并选中它
        if (this.hovered) {
          this.#select(this.hovered);
          this.dragging = true;
          return;
        }
        this.graph.tryAddPoint(this.mouse);
        this.#select(this.mouse); // 自动选中新点,方便连续画线
        this.hovered = this.mouse;
        this.dragging = true;
      }
    });

    // 2. 鼠标移动事件
    this.canvas.addEventListener("mousemove", (evt) => {
      // 获取鼠标在 Canvas 里的坐标(即使 Canvas 缩放或偏移也能用)
      // 这里先简化处理,假设 Canvas 铺满或者无偏移
      // 实际上我们应该写个 getViewportPoint,但暂时先直接读取 offsetX/Y
      this.mouse = this.viewport.getMouse(evt);

      // 检查鼠标有没有悬停在某个点上
      this.hovered = this.#getNearestPoint(this.mouse);

      // 移动的时候不需要重绘吗?需要的,但我们会在 World 里统一驱动动画循环
    });

    // 3. 禁止右键菜单弹出
    this.canvas.addEventListener("contextmenu", (evt) => evt.preventDefault());

    // 4. 鼠标抬起(结束拖拽状态)
    this.canvas.addEventListener("mouseup", () => (this.dragging = false));
  }

  #select(point) {
    // 如果之前已经选中了一个点,现在又选了一个点,说明要连线
    if (this.selected) {
      // 尝试添加线段
      this.graph.tryAddSegment(new Segment(this.selected, point));
    }
    this.selected = point;
  }

  #removePoint(point) {
    this.graph.removePoint(point);
    this.hovered = null;
    if (this.selected == point) {
      this.selected = null;
    }
  }

  // 辅助函数:找离鼠标最近的点
  #getNearestPoint(point, minThreshold = 15) {
    let nearest = null;
    let minDist = Number.MAX_SAFE_INTEGER;

    for (const p of this.graph.points) {
      const dist = Math.hypot(p.x - point.x, p.y - point.y);
      if (dist < minThreshold && dist < minDist) {
        minDist = dist;
        nearest = p;
      }
    }
    return nearest;
  }

  // 专门负责画编辑器相关的 UI(比如高亮、虚线)
  display() {
    this.graph.draw(this.ctx);

    // 如果有悬停的点,画个特殊的样式
    if (this.hovered) {
      this.hovered.draw(this.ctx, { outline: true });
    }

    // 如果有选中的点,也高亮一下
    if (this.selected) {
      // 获取鼠标位置作为意图终点
      const intent = this.hovered ? this.hovered : this.mouse;
      // 画出“虚拟线条”:从选中点 -> 鼠标位置
      new Segment(this.selected, intent).draw(this.ctx, {
        color: "rgba(0,0,0,0.5)",
        width: 1,
        dash: [3, 3],
      });
      this.selected.draw(this.ctx, { outline: true, outlineColor: "blue" });
    }
  }
}

4.3. 重构 World:应用视口变换

最后,去 index.js 把这一切串起来。Canvas 的变换(Translate)需要包裹在 save()restore() 之间,否则你的 UI(如果有的话)也会跟着一起跑。

修改 src/index.js

import Point2D from "./primitives/point2D.js";
import Segment from "./primitives/segment.js";
import Graph from "./math/graph.js";
import GraphEditor from "./editors/graphEditor.js";
import Viewport from "./view/viewport.js"; // 引入新成员

export default class World {
  constructor(canvas, width = 600, height = 600) {
    this.canvas = canvas;
    this.ctx = canvas.getContext("2d");
    this.canvas.width = width;
    this.canvas.height = height;

    this.graph = new Graph();
    // 1. 先初始化视口
    this.viewport = new Viewport(this.canvas);
    // 2. 把视口传给编辑器
    this.editor = new GraphEditor(this.canvas, this.graph, this.viewport);

    this.animate();
  }

  animate() {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

    // [核心步骤] 保存当前状态 -> 移动画布 -> 画画 -> 恢复状态
    this.ctx.save();
    
    // 获取视口当前的偏移,并应用平移变换
    // 注意:这里我们用 scale(1/zoom) 是为了配合鼠标计算,暂时还没做缩放,
    // 但我们可以把 translate 先写好
    const offset = this.viewport.getOffset();
    this.ctx.translate(offset.x, offset.y);
    
    // 所有的绘制现在都在"移动后"的坐标系里进行了
    this.editor.display();
    
    this.ctx.restore();

    requestAnimationFrame(() => this.animate());
  }
}

大概示意图

image.png

嗯....NICE!

今天就这样了。

完整代码戳这里:github.com/Float-none/…

❌