【Virtual World 005】上帝之眼

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

高瞻远瞩

前端佬们,上一篇我们基本实现了"一个无限空间"的canvas,好虽然是好,但这种方式,你的视野被锁死了。这就好比你在看地图时,却被禁用了"缩放"功能一样。

这完全丧失了那种高瞻远瞩的感觉。

本篇要给加这套逻辑,核心交互是:鼠标滚轮滚动时,以鼠标当前位置为中心,进行放大或缩小。

别小看这个"以鼠标为中心",很多初学者写出来的缩放,一滚轮下去,画面直接飞到九霄云外去了(通常是缩放到 (0,0) 原点去了)。我们的目标,是丝般顺滑的专业级缩放。


战略思考

还是老规矩,怎么去实现这玩意呢??

应该有前端佬的第一想法,是canvas的缩放 scale 功能。

bingo!!优秀如你!

就是它。


战术规划

  • 第一步:数学原理修正。引入缩放(Scale)后,屏幕上的 100px 可能只代表世界里的 50px(放大 2 倍)。之前的公式要改。

  • 第二步:实现"定点缩放"

    • 原理:在缩放发生 ,记下鼠标指向的世界坐标 P1
    • 执行缩放(改变 zoom 值)。
    • 在缩放发生 ,计算鼠标现在指向的世界坐标 P2
    • 计算偏移量 delta = P2 - P1,并把它加回到视口的 offset 里。
    • 一句话解释:如果画面因为缩放跑偏了,我们就把它拽回来。
  • 第三步:渲染层应用translatescale 的顺序至关重要。


上代码

更改Viewport

src/view/viewport.js 动大手术。

注意: 这里我们要修正一下上一篇的 getMouse 逻辑。标准的图形学做法是:放大 = 视野变小 = 坐标除以缩放系数

JavaScript

kotlin 复制代码
// 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; // 1 = 100%, 2 = 200%
    this.center = new Point2D(canvas.width / 2, canvas.height / 2);
    // 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) {
    // 1. 减去屏幕中心(把原点挪到屏幕中间)
    // 2. 除以缩放(反向映射:屏幕像素 / 缩放 = 世界距离)
    // 3. 减去摄像机偏移
    const p = new Point2D(
      (evt.offsetX - this.center.x) / this.zoom - this.offset.x,
      (evt.offsetY - this.center.y) / this.zoom - this.offset.y
    );
    return p;
  }

  // 获取计算后的偏移量(给 Canvas 渲染用)
  getOffset() {
      return new Point2D(
          this.offset.x + this.center.x,
          this.offset.y + this.center.y
      );
  }

  #addEventListeners() {
    // ... (保留上一篇的空格键监听代码) ...
    // ... (保留上一篇的 mousedown 监听代码) ...
    // ... (保留上一篇的 mouseup 监听代码) ...
    
    // 【新增】鼠标滚轮监听
    this.canvas.addEventListener("wheel", (evt) => this.#handleWheel(evt));
    
    // ... (保留 mousemove,但要注意里面 getMouse 的调用) ...
    // 稍微修正一下 mousemove 里的逻辑,确保它是基于最新的 zoom 计算的
     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); 
      }
    });
  }

  // 【新增】处理滚轮缩放
  #handleWheel(evt) {
    // 1. 判断滚轮方向 (向上滚是负数-放大,向下滚是正数-缩小)
    const dir = Math.sign(evt.deltaY); 
    // 2. 定义缩放步长 (每次滚大概变 10%)
    const step = 0.1;
    
    // 此处应用"定点缩放"策略:
    // 第一步:缩放前,鼠标在世界里的哪里?
    // (注意:这里直接透传 evt 进去算,不需要 subtractDragOffset)
    const mouseBefore = this.getMouse(evt);

    // 第二步:执行缩放
    this.zoom += dir * step;
    // 限制一下缩放范围,别缩没了或者放太大
    this.zoom = Math.max(1, Math.min(5, this.zoom));

    // 第三步:缩放后,鼠标在世界里的哪里?
    const mouseAfter = this.getMouse(evt);

    // 第四步:计算偏差,修正 offset
    // 既然鼠标不应该动,那如果 mouseAfter 变了,说明世界"滑"走了
    // 我们要把世界拽回来
    this.offset.x += (mouseAfter.x - mouseBefore.x);
    this.offset.y += (mouseAfter.y - mouseBefore.y);
    
    // 阻止浏览器默认的滚动页面行为
    evt.preventDefault();
  }
}

补充说明 :你可能注意到 mousemove 里我没怎么改。因为 getMouse 内部已经应用了 this.zoom。只要 getMouse 公式是对的,拖拽平移逻辑就能自动适配缩放(比如放大后,拖拽会变慢,这是符合物理直觉的,因为你视野变小了)。


Again And Again

现在数据层对了,但现在运行代码,会发现画面没有任何变化。

憋急,因为 Canvas 还不知道要缩放。

src/index.js,需要调整 ctx 的变换顺序。

脑子里记着:先平移到中心,再缩放,再平移回视口偏移。

JavaScript

kotlin 复制代码
// src/index.js
// ... imports

export default class World {
  // ... constructor 不变

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

    this.ctx.save();
    
    // 1. 把坐标原点移到屏幕中心
    // 为什么要这么做?因为 scale 默认是基于 (0,0) 左上角缩放的。
    // 我们希望基于屏幕中心缩放,这样更符合直觉。
    this.ctx.translate(this.canvas.width / 2, this.canvas.height / 2);
    
    // 2. 应用缩放
    this.ctx.scale(this.viewport.zoom, this.viewport.zoom);
    
    // 3. 应用视口偏移 (Viewport Offset)
    // 这里的 offset 已经在 Viewport 类里处理得很好了
    const offset = this.viewport.getOffset();
    // 这里的 translate 是为了让 (0,0) 回到它该去的地方
    // 注意:因为上面已经把原点移到了中心,这里要减去中心吗?
    // 不,因为 Viewport.getOffset 已经包含了 center 的逻辑?
    // 让我们看一眼 Viewport.js 的 getOffset: return this.offset + this.center;
    
    // 等等,这里的数学有点绕。让我们用最简单的推导:
    // 我们希望 point.x 最终渲染在: (point.x + offset.x) * zoom + center.x
    // Canvas 的变换栈是反向生效的(或者说矩阵乘法):
    
    // 修正后的渲染逻辑:
    // 我们在 Viewport.js 的 getMouse 里用的公式是: (Screen - Center) / Zoom - Offset
    // 逆推回 Screen: Screen = (World + Offset) * Zoom + Center
    
    // 所以 Canvas 代码应该是:
    // Translate(Center) -> Scale(Zoom) -> Translate(Offset)
    
    // 这里的 offset 只需要 viewport.offset,不需要加 center
    // 让我们回去微调一下 Viewport.js 的 getOffset 方法,让它只返回纯粹的 offset
    
    const viewportOffset = this.viewport.getOffset(); 
    this.ctx.translate(this.viewport.offset.x, this.viewport.offset.y);

    // 画它!
    this.editor.display();
    
    this.ctx.restore();

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

为了配合上面 World 的清晰逻辑,我把 src/view/viewport.js 里的 getOffset 删掉,或者直接访问 this.viewport.offset

为了代码整洁,在 Viewport 里加个 getter,只返回 offset 属性即可,不需要加 center

JavaScript

javascript 复制代码
// 修改 src/view/viewport.js
  getOffset() {
     return this.offset;
  }

完美

现在运行代码,你会发现:

  • 对着某个点放大,那个点会稳稳地停在鼠标指尖下,周围的世界向外扩散。
  • 线条会随着放大变粗(因为 ctx.lineWidth 也被放大了)。这其实挺好的,细节看得更清楚。如果你不希望线变粗,那就是另外一个进阶话题(逆缩放线宽)。

现在虚拟世界已经初具规模,已经实现类的如下:

  • 原子层:点、线(Point2D/Segment)。
  • 数据层:图结构(Graph)。
  • 交互层:编辑器(Editor)。
  • 视觉层:无限画布 + 自由缩放(Viewport)。

完全可以毫不吹水的说,我们已经构建了一个类似于 AutoCAD 或 Google Maps 的基础引擎。

嘎嘎~

另,这个专栏打算放一放了,好像没啥人关注,实在没啥动力了~

相关推荐
浩星3 小时前
css实现类似element官网的磨砂屏幕效果
前端·javascript·css
一只小风华~3 小时前
Vue.js 核心知识点全面解析
前端·javascript·vue.js
2022.11.7始学前端3 小时前
n8n第七节 只提醒重要的待办
前端·javascript·ui·n8n
SakuraOnTheWay3 小时前
React Grab实践 | 记一次与Cursor的有趣对话
前端·cursor
阿星AI工作室3 小时前
gemini3手势互动圣诞树保姆级教程来了!附提示词
前端·人工智能
徐小夕3 小时前
知识库创业复盘:从闭源到开源,这3个教训价值百万
前端·javascript·github
xhxxx3 小时前
函数执行完就销毁?那闭包里的变量凭什么活下来!—— 深入 JS 内存模型
前端·javascript·ecmascript 6
StarkCoder3 小时前
求求你试试 DiffableDataSource!别再手算 indexPath 了(否则迟早崩)
前端
fxshy3 小时前
Cursor 前端Global Cursor Rules
前端·cursor
红彤彤3 小时前
前端接入sse(EventSource)(@fortaine/fetch-event-source)
前端