【Virtual World 04】我们的目标,无限宇宙!!
这是纯前端手搓虚拟世界第四篇。
前端佬们,前三篇的基础打好,基本创建一个单体的应用没啥问题了。但前端佬肯定发现一个很尴尬的问题!
![]()
那就是我们的 Canvas 只有 600x600。
如果你想直接搞一幅《清明上河图》,那画着画着,就会发现——没地儿了。
立Flag
按照最简单的方法,把canvas加大,把屏幕加大,这样就不只600x600了。
But!!!!
装X装到头,抠抠搜搜加那点资源,还是不够用。既然是虚拟世界,我们的目前,就应该无限宇宙!
这篇,就是要把想法变成现实,一个通向无限世界的“窗口”,实现一个虚拟摄像机(Virtual Camera) 。你看到的只是摄像机拍到的画面,而世界本身,你的梦想有多大,那么就有多大。
![]()
战略思考
上了class的贼船,首先想法就是抽象一个类,来管理这个。Viewport(视口) ,专门处理这些事情。
它的核心功能只有两个:
- 坐标转换:搞清楚“鼠标点在屏幕上的 (100,100)”到底对应“虚拟世界里的哪一个坐标”。
- 平移交互(Pan) :按住 空格键 + 鼠标左键拖拽,移动摄像机,浏览世界的其他角落。
嗯,够逼格了!
![]()
基础原理
要个无限空间其实是一个视觉小把戏,首先我们得确认浏览器是不会允许你创建一个 width: 99999999px 的 Canvas 的,这样浏览器的内存会直接嗝屁。
然后我们移动鼠标位置是固定不变的,大致示意图如下:
![]()
虚拟层就是在数学层面上的模拟层,所有的移动,添加都是放到虚拟层上。
我们脑子里始终要记得两套坐标系:
-
屏幕坐标系 (Screen Space) :
- 这是物理现实。
- 原点
(0,0)永远在 Canvas 的左上角。 - 范围有限,比如
600x600。 -
用途:接收鼠标事件 (
evt.offsetX,evt.offsetY),最终渲染图像。
-
世界坐标系 (World Space) :
- 这是虚拟数据。
- 原点
(0,0)是世界的中心。 - 范围无限,你的点可以是
(-5000, 99999)。 -
用途:存储
Point和Segment的真实位置。
视口变换的原理,并不是真的把 Canvas 的 DOM 元素拖走了,而是我们在绘制之前,对所有的坐标做了一次数学偏移。
想象一下你拿着一个相机(视口)在拍风景,如果你想看右边的树,你需要把相机向右移,但在相机取景框里,那棵树看起来是向左移了。
嗯,大概就是这样了。
上代码
听不懂??直接上代码!!!!
![]()
视口控制器: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
});
}
}
这里歇一歇,理理代码思路。
![]()
**“按住空格”**这个逻辑在 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:
-
构造函数接收
viewport。 -
事件监听改用
viewport.getMouse(evt)。 - 冲突解决:如果按住了空格(正在拖拽视图),就不要触发“画点”的逻辑。
// 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());
}
}
大概示意图
![]()
嗯....NICE!
今天就这样了。
完整代码戳这里:github.com/Float-none/…