threejs 实现纪念碑谷(含编辑器)

719 阅读8分钟

基础环境

  • 正交相机

    本游戏的玩点就在于从摄像机看过去,本来三维空间上不能连接的位置连接了,所以必须使用正交相机,使得前后物体大小一样,视图上看上去,才是连接的状态,游戏才有可玩性。

  • 统一单位

    某些组件必须以一个统一长度作为单位,在用户操作时,视图才会恰巧连接上。

  • 关卡

    通过json保存关卡场景,在显示关卡时,通过编辑器解析还原。由于每个单独关卡的交互具有特殊性,所以除路径交互外,关卡的其他交互是由关卡实现类特殊实现。

组件

为了实现可编辑关卡,所以游戏中的元素必须以组件的形式存在,达到可快速拆分、组合的目地。通过组件的搭建形成关卡。

组件本身需要提供一些参数,通过参数,能改变组件的样式、功能。

普通组件

普通组件在游戏中,只作为场景的一部分,不具备交互性,只用生成对应的模型即可。

在此以Door类(门窗)为例:

import { unitWidth } from "@constants";
import {
  BoxGeometry,
  ExtrudeGeometry,
  Group,
  Matrix4,
  Mesh,
  Shape,
  Vector3,
} from "three";
import { CSG } from "three-csg-ts";
import Component from "./lib/recordable";

// 门/窗
class Door extends Component {
  getDefaultProps() {
    return [
      {
        doorNumber: 1,
        curveSegments: 1,
        width: unitWidth,
        width1: unitWidth,
        doorTop: unitWidth / 4,
        doorBottom: 0,
        doorWidth: unitWidth / 4,
        doorDepth: unitWidth / 4,
        height: unitWidth,
      },
    ];
  }

  generateElement() {
    this.generateDoor();
  }
  generateDoor() {
    const obj = this.userData.props?.[0];

    const width = obj?.width;
    const width1 = obj?.width1;
    const doorWidth = obj?.doorWidth;
    const doorTop = obj?.doorTop;
    const doorBottom = obj?.doorBottom;
    const curveSegments = obj?.curveSegments;
    const doorNumber = obj?.doorNumber;
    const doorDepth = obj?.doorDepth;

    const height = obj?.height;

    const cubeGeometry = new BoxGeometry(width, height, width1);
    const cubeMaterial = this.getDefaultMaterial();
    const cube = new Mesh(cubeGeometry, cubeMaterial);

    const customeShape = new Shape();

    customeShape.moveTo(-doorWidth / 2, height / 2 - doorTop);

    customeShape.absarc(
      0,
      height / 2 - doorTop,
      doorWidth / 2,
      (2 * Math.PI) / 2,
      0,
      true
    );

    customeShape.lineTo(doorWidth / 2, height / 2 - doorTop);

    customeShape.lineTo(doorWidth / 2, -height / 2 + doorBottom);
    customeShape.lineTo(-doorWidth / 2, -height / 2 + doorBottom);

    customeShape.lineTo(-doorWidth / 2, height / 2 - doorTop);

    const extrudeSettings = {
      depth: doorDepth,
      bevelEnabled: false,
      curveSegments,
    };

    const doorGeometry = new ExtrudeGeometry(customeShape, extrudeSettings);

    const cubem = new Matrix4();
    cubem.makeTranslation(0, 0, width / 2 - doorDepth);
    doorGeometry.applyMatrix4(cubem);

    let subRes: Mesh = cube;
    for (let i = 0; i < doorNumber; i++) {
      const geo = doorGeometry.clone();

      const cubem = new Matrix4();
      cubem.makeRotationAxis(new Vector3(0, 1, 0), (i * Math.PI) / 2);
      geo.applyMatrix4(cubem);

      const doorItem = new Mesh(geo, cubeMaterial);

      subRes = CSG.subtract(subRes, doorItem);
    }
    const g = new Group();

    const gubem = new Matrix4();
    gubem.makeTranslation(0, -height / 2, 0);
    g.applyMatrix4(gubem);

    g.add(subRes);
    this.add(g);
  }
}
(Door as any).cnName = "门窗";
(Door as any).constName = "Door";
export default Door;

效果如下图: image.png

组件子类中需要重写Component父类的getDefaultProps,即组件的默认参数,在修改组件参数弹框中读取此方法返回的值,动态遍历显示并支持修改。

组件生成时,会调用generateElement方法,生成组件对应的模型。Door类中,首先使用customeShape画出门的形状,生成门的模型,再用一个正方体减去门的模型,生成最终模型。这里使用了第三方库three-csg-ts,可对模型做相交、并集、减法操作。

就这样一个组件就生成好啦。可通过这种方式生成其他组件,如屋檐、爬梯、桥墩、屋顶等。

可交互组件

可交互组件是在普通组件的基础上实现的,本游戏的可交互组件主要分为两种:平面内的旋转,平面内的移动。

  • 旋转

组件的旋转原理:

image.png

旋转要素包含旋转角度与旋转方向。

在旋转组件中建立一个平面(平面A),模型中心到鼠标上一个位置的向量(向量A)与到下一个位置的向量(向量B)之间的夹角既是所旋转的角度。

旋转的方向可由向量A与向量B的向量积(向量C)确定,根据向量积性质,向量C垂直于平面A,要获取旋转方向,只需要判断向量C是否与平面A的法向量(向量D)同一个方向,这里取向量C与向量D的数量积,判断>0还是<0,就能获取方向。

旋转组件ValveControl类(控制点)示例:

import { unitWidth } from "@constants";
import {
  BufferGeometry,
  CircleGeometry,
  Color,
  CylinderBufferGeometry,
  Group,
  Material,
  Matrix4,
  Mesh,
  MeshBasicMaterial,
  MeshMatcapMaterial,
  Plane,
  TextureLoader,
  Vector3,
} from "three";
import Rotable from "./lib/rotable";
import matcap2 from "../assets/matcap/matcap2.png";
import matcap3 from "../assets/matcap/matcap3.png";
import texture1 from "../assets/texture/texture1.png";
import { animate, keyframes, easeInOut, linear } from "popmotion";

// 控制杆
class ValveControl extends Rotable {
  constructor(...args: any) {
    super(...args);
  }
  g!: Group;
  generateElement(): void {
    this.g = new Group();

    this.plugHeight = unitWidth * 0.7;
    this.plugR = 6.4;

    this.rodWidth = 22;
    this.rodR = 2.2;
    this.rodEndWidth = 6;
    this.rodEndR = 3.4;

    this.plugTexture = this.plugTexture || texture1;
    this.largeCircleColor = this.largeCircleColor || 0xece4b2;
    this.smallCircleColor = this.smallCircleColor || 0x6a6b39;
    this.rodTexture = this.rodTexture || matcap2;
    this.rodEndTexture = this.rodEndTexture || matcap3;
    this.generatePlug();
    this.generateRod();
  }

  plugHeight = unitWidth * 0.6;
  plugR = 6;

  rodWidth = 30;
  rodR = 2.2;
  rodEndWidth = 8;
  rodEndR = 3.4;

  plane!: Plane;

  plugTexture = texture1;
  rodTexture = matcap2;
  rodEndTexture = matcap3;
  largeCircleColor = 0xece4b2;
  smallCircleColor = 0x6a6b39;

  setProgramProps({
    plugTexture,
    rodTexture,
    rodEndTexture,
    largeCircleColor,
    smallCircleColor,
  }: {
    plugTexture?: string;
    rodTexture?: string;
    rodEndTexture?: string;
    largeCircleColor?: number;
    smallCircleColor?: number;
  }) {
    this.plugTexture = plugTexture || this.plugTexture;
    this.rodTexture = rodTexture || this.rodTexture;
    this.rodEndTexture = rodEndTexture || this.rodEndTexture;
    this.largeCircleColor = largeCircleColor || this.largeCircleColor;
    this.smallCircleColor = smallCircleColor || this.smallCircleColor;

    this.changeProps(...JSON.parse(JSON.stringify(this.userData.props)));
  }

  onRotateBegin() {
    const endMaterial = this.userData.endMaterial as MeshMatcapMaterial;
    const plugMaterial1 = this.userData.plugMaterial1 as MeshMatcapMaterial;
    const plugMaterial2 = this.userData.plugMaterial2 as MeshMatcapMaterial;
    animate({
      from: 1,
      to: 0,
      duration: 400,
      onUpdate: (latest) => {
        endMaterial.opacity = latest;
        plugMaterial1.opacity = latest;
        plugMaterial2.opacity = latest;
      },
    });
  }
  onRotateEnd() {
    const endMaterial = this.userData.endMaterial as MeshMatcapMaterial;
    const plugMaterial1 = this.userData.plugMaterial1 as MeshMatcapMaterial;
    const plugMaterial2 = this.userData.plugMaterial2 as MeshMatcapMaterial;
    animate({
      from: 0,
      to: 1,
      duration: 400,
      onUpdate: (latest) => {
        endMaterial.opacity = latest;
        plugMaterial1.opacity = latest;
        plugMaterial2.opacity = latest;
      },
    });
  }

  // 中间的阀塞
  generatePlug() {
    const texture = new TextureLoader().load(this.plugTexture);

    const material = new MeshMatcapMaterial({
      depthTest: this.getZIndex() ? false : true,
      map: texture,
    });

    const cubeMaterial = material;

    var geometry = new CylinderBufferGeometry(
      this.plugR,
      this.plugR,
      this.plugHeight,
      32
    );

    const geometry1 = new CircleGeometry(this.plugR, 32);
    const material1 = new MeshBasicMaterial({ color: this.largeCircleColor });
    this.userData.plugMaterial1 = material1;
    const circle = new Mesh(geometry1, material1);

    const circleM = new Matrix4();
    circleM
      .makeTranslation(0, this.plugHeight / 2 + 0.01, 0)
      .multiply(
        new Matrix4().makeRotationAxis(new Vector3(1, 0, 0), -Math.PI / 2)
      );
    geometry1.applyMatrix4(circleM);

    const geometry2 = geometry1.clone();
    geometry2.scale(0.5, 1, 0.5).translate(0, 0.01, 0);
    const material2 = new MeshBasicMaterial({ color: this.smallCircleColor });
    this.userData.plugMaterial2 = material2;
    const circle2 = new Mesh(geometry2, material2);
    this.g.add(circle);
    this.g.add(circle2);

    const cubem = new Matrix4();
    cubem.makeTranslation(0, this.plugHeight / 2 - unitWidth / 2, 0);
    this.g.applyMatrix4(cubem);

    var cylinder = new Mesh(geometry, cubeMaterial);
    this.g.add(cylinder);
  }
  // 阀杆
  generateRod() {
    var rod = new Group();
    var geometry = new CylinderBufferGeometry(
      this.rodR,
      this.rodR,
      this.rodWidth * 2,
      32
    );
    const cubeMaterial = this.getDefaultMaterial({
      textureSrc: this.rodTexture,
    });

    var verticalCylinder = new Mesh(geometry, cubeMaterial);

    const vm = new Matrix4();
    vm.makeRotationX(Math.PI / 2);
    verticalCylinder.applyMatrix4(vm);

    var horizontalCylinder = new Mesh(geometry, cubeMaterial);

    this.userData.verticalCylinder = verticalCylinder;
    this.userData.horizontalCylinder = horizontalCylinder;

    const hm = new Matrix4();
    hm.makeRotationZ(Math.PI / 2);
    horizontalCylinder.applyMatrix4(hm);

    rod.add(verticalCylinder);
    rod.add(horizontalCylinder);

    var endGeometry = new CylinderBufferGeometry(
      this.rodEndR,
      this.rodEndR,
      this.rodEndWidth,
      32
    );

    this.userData.endGeometry = endGeometry;

    const endm = new Matrix4();
    endGeometry.applyMatrix4(
      new Matrix4()
        .makeTranslation(this.rodWidth, 0, 0)
        .multiply(endm.makeRotationZ(Math.PI / 2))
    );
    const endMaterial = this.getDefaultMaterial({
      textureSrc: this.rodEndTexture,
    });

    var endCylinder = new Mesh(endGeometry, endMaterial);
    endMaterial.transparent = false;
    this.userData.endMaterial = endMaterial;

    for (let i = 0; i < 4; i++) {
      const mesh = endCylinder.clone();
      const meshm = new Matrix4();
      meshm.makeRotationAxis(new Vector3(0, 1, 0), (i * Math.PI) / 2);
      mesh.applyMatrix4(meshm);

      rod.add(mesh);
    }
    this.g.add(rod);
    this.add(this.g);
  }

  disable() {
    if (this.userData.disabled) {
      return;
    }
    // 需要disabled掉点击旋转
    const verticalCylinder = this.userData.verticalCylinder as Mesh;
    const horizontalCylinder = this.userData.horizontalCylinder as Mesh;
    this.userData.disabled = true;
    let moveDistence = this.rodWidth;
    animate({
      from: 1,
      to: 0.5,
      duration: 200,
      onUpdate: (latest) => {
        verticalCylinder.scale.set(1, latest, 1);
        horizontalCylinder.scale.set(1, latest, 1);
        this.userData.endGeometry.translate(
          (this.rodWidth * latest - moveDistence) * 0.7,
          0,
          0
        );
        moveDistence = this.rodWidth * latest;
      },
    });
  }
  enable() {
    if (!this.userData.disabled) {
      return;
    }
    const verticalCylinder = this.userData.verticalCylinder as Mesh;
    const horizontalCylinder = this.userData.horizontalCylinder as Mesh;

    this.userData.disabled = false;

    let moveDistence = this.rodWidth;
    animate({
      from: 1,
      to: 0.5,
      duration: 200,
      onUpdate: (latest) => {
        verticalCylinder.scale.set(1, 1 - latest + 0.5, 1);
        horizontalCylinder.scale.set(1, 1 - latest + 0.5, 1);
        this.userData.endGeometry.translate(
          -(this.rodWidth * latest - moveDistence) * 0.7,
          0,
          0
        );
        moveDistence = this.rodWidth * latest;
      },
    });
  }
}
(ValveControl as any).cnName = "控制点";
(ValveControl as any).constName = "ValveControl";
export default ValveControl;

控制点效果如下图: image.png 组件基础模型生成跟普通组件一样。

这里主要是实现了onRotateBegin(旋转开始)、onRotateEnd(旋转结束)、enable(启用)、disable(禁用)四个方法,不同的旋转组件可以有自己不同的实现。

针对此组件:onRotateBegin时,组件会微亮,onRotateEnd时,组件还原。disable时,组件会缩起来,enable时,组件还原。

旋转功能中的平面在父组件中生成:

import { scene } from "@env";
import { Plane, PlaneHelper, Vector3 } from "three";
import Component from "./recordable";
abstract class Rotable extends Component {
  constructor(...args: any) {
    super(...args);
    this.generateRotablePlane();
  }
  worldPosition!: Vector3;
  generateRotablePlane() {
    const p = new Vector3().copy(this.up);
    this.updateMatrixWorld(true);

    this.localToWorld(p);
    const plane = new Plane();
    const worldP1 = new Vector3();
    const worldP2 = new Vector3(1, 0, 0);
    const worldP3 = new Vector3(0, 0, 1);
    this.localToWorld(worldP1);
    this.localToWorld(worldP2);
    this.localToWorld(worldP3);
    this.worldPosition = worldP1;
    plane.setFromCoplanarPoints(worldP1, worldP3, worldP2);
    this.userData.rotablePlane = plane;
  }
  onRotate(axis: Vector3, angle: number) {}
  onRotated(totalangle: number) {}
  abstract onRotateBegin(): void;
  abstract onRotateEnd(): void;
}
export default Rotable;

在用户交互旋转时,会调用旋转组件的onRotate方法,传入平面法向量以及旋转角度。

export const setRotation: IpinterdownHander = ({ raycaster, next }) => {
  if (store.isRotable && store.rotationComponent) {
    const target = new Vector3();
    raycaster.ray.intersectPlane(
      store.rotationComponent.userData.rotablePlane,
      target
    );

    target.sub((store.rotationComponent as any).worldPosition);

    let angle = rotationPointer.angleTo(target);

    const velocity = new Vector3();
    velocity.crossVectors(rotationPointer, target);

    const dirction =
      velocity.dot(store.rotationComponent.userData.rotablePlane.normal) > 0
        ? 1
        : -1;
    angle = dirction * angle;
    totalAngle += angle;
    (store.rotationComponent as any)?.onRotate(
      store.rotationComponent.userData.rotablePlane.normal,
      angle
    );

    rotationPointer.copy(target);
  }
  next();
};

在关卡实现类中,设置组件的onRotate方法,并旋转场景中的物体。

configAnimation() {
    const rotationControl: any = getCompFromFlatedArrByName("rotationControl");
    const centerRotable: Object3D | undefined =
      getCompFromFlatedArrByName("centerRotable");
    if (!(rotationControl && centerRotable)) {
      return;
    }
    rotationControl.onRotate = (axis: Vector3, angle: number) => {
      if (centerRotable) {
        centerRotable.rotateOnAxis(axis, angle);
      }
    };
    rotationControl.onRotated = (axis: Vector3, totalAngle: number) => {
      const left = totalAngle % (Math.PI / 2);
      let addonAngle = 0;
      if (centerRotable) {
        if (Math.abs(left) > Math.PI / 4) {
          addonAngle = Math.PI / 2 - Math.abs(left);
        } else {
          addonAngle = -Math.abs(left);
        }
        addonAngle *= totalAngle > 0 ? 1 : -1;

        const caliQuat = new Quaternion();
        caliQuat.setFromAxisAngle(axis, addonAngle);

        const endQuat = centerRotable.quaternion.clone();
        const startQuat = centerRotable.quaternion.clone();
        endQuat.premultiply(caliQuat);
        animate({
          from: startQuat,
          to: endQuat,
          duration: 200,
          onUpdate: (latest: any) => {
            centerRotable.quaternion.copy(
              new Quaternion().set(latest._x, latest._y, latest._z, latest._w)
            );
          },
        });
      }
    };
  }
  • 移动 组件的移动原理跟旋转原理类似,更加的简单,同样是生成一个平面,鼠标的上一个位置与平面的交点到鼠标的下一个位置与平面的交点形成的向量,既是组件的位移向量。以同样的方式,用户交互生成向量,将向量传入移动组件的onMove方法中并调用(只取此向量的一个轴,可以做到物体单轴移动)。

路径组件

路径组件用于动态生成路径图,同交互组件一样,也是在普通组件的基础上进行拓展的。路径组件主要包含一个主体和四个连接点,主体主要是用于响应用户点击事件,下图中是球体(可以做成一个扁的长方体,用户更好点击),四个连接点我是用四个球体表示。

image.png

组组件

组组件主要用于方便编辑,可通过组组件将场景组织成树的形式。

路径寻找

路径寻找基于路径组件,且可通过操作触发自动连接、断开路径。分三步:

  1. 先将所有路径组件收集起来。
  2. 确认路径之间的连接关系。
  3. 使用floyd算法寻找最短路径。

路径组件分为动态路径组件与静态路径组件,静态路径组件在整个关卡中位置都不会改变,动态路径组件会改变,通过.userData.isStatic标识。这是为了优化,部分静态路径组件只需要算一次连接关系即可。

确认路径之间的连接关系是难点。连接关系又分为:

  1. 硬连接,静态路径组件与静态路径组件间的连接,世界坐标不会改变的路径组件之间的连接,可在关卡加载后直接算出。
  2. 半硬连接,静态路径组件与动态路径组件的连接,也可直接通过世界坐标相等确定。
  3. 软连接,动态组件与动态组件间的连接,主要用于用户操作后,在3维空间中本不相连接的路径在摄像机的某个角度上连接了。

在路径组件上增加三个属性:connectMap、connectPointList、pointPositionList,用于记录连接关系。

  • connectMap:记录连接的路径组件上与本路径组件连接的连接点的位置。
  • connectPointList:记录与本路径组件连接的路径组件。
  • pointPositionList:记录本路径组件的连接点的世界位置。

遍历所有路径组件,使用一个Map<string,Path[]>收集连接点,key为连接点世界位置字符串,Path[]为在此连接点的路径组件,两个路径组件,每个有四个连接点,左边的路径组件右边的连接点与右边的路径组件左边的连接点重合,视为连接成功。

那么Map中的值为:键为重合点世界坐标,值为[左边的路径组件,右边的路径组件]

连接关系图解: image.png

确定静态组件之间的连接关系(硬连接)代码如下:

const staticPathPointMap = new Map();

const POSITION_PRECISION = 1;

export const generateStaticMap = () => {
  staticPathPointMap.clear();
  Paths.filter((item) => item.userData.isStatic).forEach((item) => {
    item.userData.connectPointList = new Set();
    item.userData.connectMap = new Map();
    item.userData.pointPositionList.forEach((v: Vector3) => {
      const key = v
        .toArray()
        .map((item) => Number(item.toFixed(POSITION_PRECISION)))
        .toString();
      if (!staticPathPointMap.has(key)) {
        staticPathPointMap.set(key, [item]);
      } else {
        staticPathPointMap.get(key).push(item);
      }
    });
  });

  staticPathPointMap.forEach((pathList: Path[], key: string) => {
    pathList.forEach((path: Path) => {
      pathList.forEach((path1: Path) => {
        (path.userData.connectPointList as Set<Path>).add(path1);
        if (path !== path1) {
          const tar = new Vector3().fromArray(
            key.split(",").map((item) => Number(item))
          );
          path.worldToLocal(tar);
          (path.userData.connectMap as Map<Path, Vector3>).set(path1, tar);
        }
      });
    });
  });
};

半硬连接与硬连接关系类似,这里主要介绍软连接的实现。

软连接

建立一个面向摄像机的平面(平面B),从摄像机看下去在平面上同一个点的连接点视为连接。就像拿枪打靶,枪是摄像机,靶是平面,开枪后,子弹路径上的点被视为同一位置,因为打在靶上是在同一个点上。代码实现是将连接点投影到平面B上,如果投影到了同一点,那么两个路径视为连接关系。如下图:

image.png 软连接主要代码实现:

// 动态节点与动态节点的连接(采用相机投影位置),视差连接
    dynamicPathPointMap.clear();
    filteredPaths.forEach((item) => {
      item.userData.pointPositionList.forEach((v: Vector3) => {
        const projectV = new Vector3()
          .copy(v)
          .projectOnPlane(projectPlaneNormal);
        const key = projectV
          .toArray()
          .map((item) => Number(item.toFixed(POSITION_PRECISION)))
          .toString();
        if (!dynamicPathPointMap.has(key)) {
          const mapv = [item];
          dynamicPathPointMap.set(key, mapv);
        } else {
          const mapv = dynamicPathPointMap.get(key);
          mapv.push(item);
          mapv.map((path: Path) => {
            mapv.map((path1: Path) => {
              if (path !== path1) {
                const v: Vector3 | null = path.userData.pointPositionList.find(
                  (vect: Vector3) => {
                    const projectV = new Vector3()
                      .copy(vect)
                      .projectOnPlane(projectPlaneNormal);
                    const pkey = projectV
                      .toArray()
                      .map((item) => Number(item.toFixed(POSITION_PRECISION)))
                      .toString();
                    return pkey === key;
                  }
                );
                if (v) {
                  path.worldToLocal(v);
                  if (path.userData.connectMap) {
                    (path.userData.connectMap as Map<Path, Vector3>).set(
                      path1,
                      v
                    );
                  } else {
                    path.userData.connectMap = new Map();
                    (path.userData.connectMap as Map<Path, Vector3>).set(
                      path1,
                      v
                    );
                  }
                }
              }
            });
          });
        }
      });
    });

    dynamicPathPointMap.forEach((pathList: Path[], key: string) => {
      pathList.forEach((path: Path) => {
        pathList.forEach((path1: Path) => {
          (path.userData.connectPointList as Set<Path>).add(path1);
        });
      });
    });

其他

matcap

本项目是模拟纪念碑谷,在编码时,通过光照以及物体本身的颜色难以还原纪念碑谷的效果,故而选择使用matcap技术还原纪念碑谷的效果

我对matcap技术简单的理解:当你看一个物体时,只能看到物体相对于你的正面,你所看到的所有面的法向量,都是朝向你这一边,而一个半球面可以涵盖所有面对你这边的法向量。所以,可以用一个圆型的图片表示物体面向你的所有的颜色,面向你的每个面,都能在圆形图片中,找到对应的点。我在此项目中作用的matcap图片是通过css渐变制作的。

项目地址:github.com/snhwv/Monum… ,欢迎star

项目预览地址:http://192.144.225.99/ ,欢迎试玩

参考资料: www.youtube.com/watch?v=mCC…

如有问题或意见,可以在评论区交流~