【Virtual World 03】上帝之手

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

小小抱怨一下,感觉没啥人关注这种哇。

悲伤~

本期代码量超标!!慎重!

gogogo!

欸,朋友,我们已经在前面构建了点(Point2D)和线(Segment)这两个类了嘛。

咳咳~这两天看新疆风味视频有点多了。脑子里大概模仿了下。

回归正题,在前两篇中,我们搞出了一个分形树 还有一个喷绘效果。但那些玩意,从艺术角度来说就很空洞。

为什么?因为它无法交互,光看不能摸

一个真正的虚拟世界,没有"上帝",那是不完美的。本篇要解决的,就是给这个虚拟世界装上"上帝之手",而这个上帝之手的连接线,就是鼠标。这样,就能从"看画模式"进化到"编辑模式"。


战略思考

好了,暂停下吹牛,先思考下,如果是鼠标交互目前的虚拟世界,该怎么弄???

思考完毕,我给出我的方案。再撸一个类**图形编辑器(Graph Editor)**,总体如下:

  1. 数据容器(graph) :我们需要一个容器,专门管理所有的Point(点)和Segment(线段)。不能再像画分型图那样写死坐标了。
  2. 交互控制器(graphEditor):监听鼠标的点击(Left Click)、移动(Move)、右键(Right Click)。
  3. 视觉交互
    • 鼠标悬停在点上,点要变亮(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);
    }
  }
}

补充修改: 我们的 PointSegment 类之前比较简单,为了支持上面的去重和删除,我们需要在 Point2D.jsSegment.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

逻辑有点绕,我给你理一下:

  1. 鼠标移动 (Move) -> 检查附近有没有点 -> 有就高亮 (hovered)。
  2. 鼠标点击 (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" });
    }
  }
}

注意 :上面的代码里用到了 Segmentdash 属性,你需要去 segment.jsdraw 方法里微调一下,加个 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

现在我们把 GraphGraphEditor 装进 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 方法可以删了,或者留着作纪念
}

效果讲解

现在,当你运行页面时,用鼠标点点看看:

  1. 高亮 (Hover):创建一个点后,鼠标移到点上会有蓝色高亮。
  2. 虚拟连线 (Intent):会有虚拟连线,引导直线方向。
  3. 删除点和线 :当你右键删除点时,Graph 类的 removePoint 不仅删了点,还顺手把连接这个点的所有线段都干掉了。

如果看解释有点晕,试一试就知道了!!!!


最后秀一把?微抖的上帝之手。

实在没啥可以秀的,非要秀,我直接给你画个爱心:

相关推荐
招来红月3 小时前
记录JS 实用API
javascript
别叫我->学废了->lol在线等3 小时前
演示 hasattr 和 ** 解包操作符
开发语言·前端·python
霍夫曼3 小时前
UTC时间与本地时间转换问题
java·linux·服务器·前端·javascript
DARLING Zero two♡3 小时前
浏览器里跑 AI 语音转写?Whisper Web + cpolar让本地服务跑遍全网
前端·人工智能·whisper
꒰ঌ小武໒꒱3 小时前
文件上传全维度知识体系:从基础原理到高级优化
javascript·node.js
Lovely Ruby3 小时前
前端er Go-Frame 的学习笔记:实现 to-do 功能(三),用 docker 封装成镜像,并且同时启动前后端数据库服务
前端·学习·golang
深红4 小时前
玩转小程序AR-实战篇
前端·微信小程序·webvr
银空飞羽4 小时前
让Trae SOLO全自主学习开发近期爆出的React RCE漏洞靶场并自主利用验证(CVE-2025-55182)
前端·人工智能·安全
钮钴禄·爱因斯晨4 小时前
DevUI 组件生态与 MateChat 智能应用:企业级前端智能化实战
前端