这是纯前端手搓虚拟世界第三篇。
小小抱怨一下,感觉没啥人关注这种哇。
悲伤~

本期代码量超标!!慎重!
gogogo!
欸,朋友,我们已经在前面构建了点(Point2D)和线(Segment)这两个类了嘛。

咳咳~这两天看新疆风味视频有点多了。脑子里大概模仿了下。
回归正题,在前两篇中,我们搞出了一个分形树 还有一个喷绘效果。但那些玩意,从艺术角度来说就很空洞。
为什么?因为它无法交互,光看不能摸
一个真正的虚拟世界,没有"上帝",那是不完美的。本篇要解决的,就是给这个虚拟世界装上"上帝之手",而这个上帝之手的连接线,就是鼠标。这样,就能从"看画模式"进化到"编辑模式"。
战略思考
好了,暂停下吹牛,先思考下,如果是鼠标交互目前的虚拟世界,该怎么弄???

思考完毕,我给出我的方案。再撸一个类**图形编辑器(Graph Editor)**,总体如下:
- 数据容器(graph) :我们需要一个容器,专门管理所有的
Point(点)和Segment(线段)。不能再像画分型图那样写死坐标了。 - 交互控制器(graphEditor):监听鼠标的点击(Left Click)、移动(Move)、右键(Right Click)。
- 视觉交互 :
- 鼠标悬停在点上,点要变亮(Hover 态)。
- 选中一个点后,鼠标移动要带出一条虚线(Intention,意图)。
- 点击右键,取消操作或删除元素。
嗯~跟你想的差不多吧?
战术制定
好了,思路有了,开始具体的代码实现。
为了代码的健壮性,不能把代码全堆在 index.js 里,不然后续,就会变成屎山,身为一个有抱负的前端佬,我们得先抽个象。
详细步骤如下:
Graph类。它像一个数据库,只管存点、存线、加、删、画。GraphEditor类。它是逻辑大脑,负责处理整个鼠标事件和交互,判断"我现在点到了谁"。- 构建一个数学工具。计算鼠标靠近哪个点以及距离,用于判断是否选中(也就是高中数学:两点间距离)。
- 重写
World类 。启用requestAnimationFrame动画循环,因为交互是实时的,要绘制线的拉伸效果,画面需要每次都去更新,目前定为每秒刷新 60 次,后续会重点说明这个。
先不解释,上代码!
数据容器:Graph
在 src 下新建 math 文件夹,创建 graph.js。 它的职责非常单纯:管数据。
javascript
// src/math/graph.js
export default class Graph {
constructor(points = [], segments = []) {
this.points = points;
this.segments = segments;
}
// 添加点
addPoint(point) {
this.points.push(point);
}
// 判断是否已经有点在这个位置了(防止重叠点)
containsPoint(point) {
return this.points.find((p) => p.equals(point));
}
// 添加线段
addSegment(seg) {
this.segments.push(seg);
}
// 判断线段是否已存在
containsSegment(seg) {
return this.segments.find((s) => s.equals(seg));
}
// 尝试添加点(去重)
tryAddPoint(point) {
if (!this.containsPoint(point)) {
this.addPoint(point);
return true;
}
return false;
}
// 尝试添加线段(去重)
tryAddSegment(seg) {
if (!this.containsSegment(seg)) {
this.addSegment(seg);
return true;
}
return false;
}
// 删除点(非常重要:删点的时候,连着这个点的线也要一起删掉!)
removePoint(point) {
// 1. 从 points 数组移除
this.points.splice(this.points.indexOf(point), 1);
// 2. 过滤掉所有包含这个点的线段
const segs = this.segments.filter((s) => s.includes(point));
for (const seg of segs) {
this.removeSegment(seg);
}
}
// 删除线
removeSegment(seg) {
this.segments.splice(this.segments.indexOf(seg), 1);
}
// 这里的 draw 只是一个简单的代理,把任务分发给具体的元素
draw(ctx) {
for (const seg of this.segments) {
seg.draw(ctx);
}
for (const point of this.points) {
point.draw(ctx);
}
}
}
补充修改: 我们的
Point和Segment类之前比较简单,为了支持上面的去重和删除,我们需要在Point2D.js和Segment.js加两个小 方法(不用重写,加方法即可)。
手动给 src/primitives/point2D.js 添加:
javascript
// 判断两个点坐标是否一样
equals(point) {
return this.x === point.x && this.y === point.y;
}
手动给 src/primitives/segment.js 添加:
javascript
// 判断线段是否包含某个点
includes(point) {
return this.p1.equals(point) || this.p2.equals(point);
}
// 判断两条线是否一样(方向不同也算同一条线)
equals(seg) {
return this.includes(seg.p1) && this.includes(seg.p2);
}
交互控制器:GraphEditor
这是今天的重头戏。在 src 下新建 editors 文件夹,创建 graphEditor.js。
逻辑有点绕,我给你理一下:
- 鼠标移动 (Move) -> 检查附近有没有点 -> 有就高亮 (
hovered)。 - 鼠标点击 (Down) ->
- 左键 :
- 如果点在空地 -> 创建新点。
- 如果之前选中了一个点 (
selected) -> 连线。 - 最后把当前点设为
selected(作为下一次连线的起点)。
- 右键 :
- 如果正在连线(有
selected) -> 取消选中(停止连线)。 - 如果没有连线,但鼠标下有点 (
hovered) -> 删除这个点。
- 如果正在连线(有
- 左键 :
javascript
// src/editors/graphEditor.js
import Point2D from "../primitives/point2D.js";
import Segment from "../primitives/segment.js";
export default class GraphEditor {
constructor(canvas, graph) {
this.canvas = canvas;
this.graph = graph;
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.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 = new Point2D(evt.offsetX, evt.offsetY);
// 检查鼠标有没有悬停在某个点上
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" });
}
}
}
注意 :上面的代码里用到了
Segment的dash属性,你需要去segment.js的draw方法里微调一下,加个setLineDash。
微调 src/primitives/segment.js 的 draw 方法:
javascript
draw(ctx, { width = 2, color = "black", dash = [] } = {}) {
ctx.beginPath();
ctx.lineWidth = width;
ctx.strokeStyle = color;
ctx.setLineDash(dash); // 新增:支持虚线
ctx.moveTo(this.p1.x, this.p1.y);
ctx.lineTo(this.p2.x, this.p2.y);
ctx.stroke();
ctx.setLineDash([]); // 重置,防止影响其他绘制
}
组装世界:重构 World
现在我们把 Graph 和 GraphEditor 装进 World 里,并启动动画循环。
修改 src/index.js:
javascript
import Point2D from "./primitives/point2D.js";
import Segment from "./primitives/segment.js";
import Graph from "./math/graph.js";
import GraphEditor from "./editors/graphEditor.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;
// 1. 初始化空图
this.graph = new Graph();
// 2. 初始化编辑器
this.editor = new GraphEditor(this.canvas, this.graph);
// 3. 启动动画循环
this.animate();
}
animate() {
// 清空画布(重要!否则画面会重叠)
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 让编辑器去决定画什么(它包含图和交互UI)
this.editor.display();
// 递归调用,保持 60FPS
requestAnimationFrame(() => this.animate());
}
// 原来的 display 方法可以删了,或者留着作纪念
}
效果讲解
现在,当你运行页面时,用鼠标点点看看:
- 高亮 (Hover):创建一个点后,鼠标移到点上会有蓝色高亮。
- 虚拟连线 (Intent):会有虚拟连线,引导直线方向。
- 删除点和线 :当你右键删除点时,
Graph类的removePoint不仅删了点,还顺手把连接这个点的所有线段都干掉了。
如果看解释有点晕,试一试就知道了!!!!

最后秀一把?微抖的上帝之手。
实在没啥可以秀的,非要秀,我直接给你画个爱心:
