枘凿六合,天下第一

【枘凿六合】是《崩坏:星穹铁道》中的一款二阶魔方解密游戏,记录 Threejs 实现过程。

背景

启发来自 Threejs 的文章–如何绘制透明的物体,在网上搜了一圈也没有找到【枘凿六合】的例子,便想着自己实现一下。

不过魔方类的技术文章已经有很多实现了,如:

简化

设想

  1. 【合集 8.21 已更新 93 话】Blender 2.9-3.4 黑铁骑士 Ⅱ 系统零基础入门教程(持续更新+中文字幕+普通话+不敷衍+义务教育+案例+学习)
  2. three.js 辉光的两种实现方法,及解决添加辉光后背景变成黑色的问题

思路

首先,【枘凿六合】是个二阶魔方,八个方块位于空间直角坐标系的八个卦限中,投射面板位置固定且坐标系不会转动,绘制应该不成问题。

然后是玩家操作上,仅可选择某一个面的方块,然后“左右”转动选择的方块。

当同一方向上的方块和投射面满足消消乐条件时,即可得分。

实现

基础场景

见文档:创建一个场景(Creating a scene)

绘制方块

见文档:如何绘制透明的物体

可按照卦限给方块编号 1~8,以配置文件的方式设置方块的颜色及附加信息,便于调整方块的颜色,构造不同关卡。
可附着一个 Box3 对象来标记选中时线框高亮。

// 方块分类
const ECubeType = Object.freeze({
  solid: {
    color: new THREE.Color().setHSL(0 / 8, 1, 0.5),
    value: 1,
  },
  hyaline: {
    color: new THREE.Color().setHSL(3 / 8, 1, 0.8),
    value: 0,
  },
});
// 方块配置
const CubeCfg = {
  d: 0.8,
  size: 1.2,
  options: [
    {
      name: "octant-1",
      x: 1,
      y: 1,
      z: 1,
      type: "hyaline",
    },...
  ],
};
// 创建方块
this.cubeGroup = this._createCubes(CubeCfg);
this.scene.add(this.cubeGroup);

选择共面

如何选择共面呢?

在崩铁中通过点击方向箭头选择共面,并添加选中效果(发光);这里直接搞六个选择共面的按钮和左右按钮,用相对定位糊上即可。

如何确定选中哪些方块呢,毕竟方块是可以旋转走的?

  • Box3Helper:设置六个 Box3Helper 并编号,每个 Box3Helper 都能包含四个位置;使用其 Box 对象的 distanceToPoint 方法来判断方块是否在选中的 Box3Helper 范围内,即方块是否选中。
  • Raycaster:设置八条射线,可组合出六个面,通过射线相交来找选中的方块;且射线也可监测出投射面板,检查结果也更方便。
/**
  * 创建射线组
  */
_createRaycasters(d, size) {
    const raycasterOptions = [
      /**
       * 投射面: XY平面
       * 方向:Z负半轴
       */
      {
        name: "raycaster-1",
        origin: new THREE.Vector3(d, d, d + size),
        direction: new THREE.Vector3(0, 0, -1),
        type: "solid",
      },...
   ]
   return raycasterOptions
// 创建投射组
this.raycasterOptions = this._createRaycasters(CubeCfg.d, CubeCfg.size);
// 投射板
this.boardGroup = this._addProjectionBoard(this.raycasterOptions, CubeCfg.d, CubeCfg.size);
this.scene.add(this.boardGroup);
// 划分选择面
this.planeOptions = [
    {
        name: "plane-1",
        raycasterIndexs: [0, 1], // xz,y>0
        rotateAxis: new THREE.Vector3(0, 1, 0),
        leftRad: Math.PI / 2,
        rightRad: -Math.PI / 2,
    },...
]

旋转方块

一开始调用 Object3D 的一些旋转 API(rotateOnAxis、rotateOnWorldAxis、rotateX…),结果是方块原地打转(方块各面纹理不同时可观察出来)。

是 Object3D 本地坐标系的原因,后来参考知乎 · ThreeJS 四步制作一个简易魔方评论区方法,秒了。

注意:这里的旋转仅仅是方块位置(看成中心点)的旋转,实际上方块旋转过程中还应该考虑自身的旋转,否则动画效果会比较怪异。

/**
   * @descption 旋转动画
   * @param {*} cubes 旋转的方块
   * @param {*} rotateAxis 旋转轴
   * @param {*} rad 旋转角度
   */
  _rotate(cubes, rotateAxis, rad) {
    // 更新起始数据
    cubes.forEach((cube) => {
      cube.recoardStart = {
        position: cube.position.clone(),
        rotation: new THREE.Vector3(0, 0, 0),
      };
      cube.rotation.x = 0;
      cube.rotation.y = 0;
      cube.rotation.z = 0;
    });
    // 旋转动画
    const LOCAL_AXIS = rotateAxis.x ? "x" : rotateAxis.y ? "y" : "z";
    const TWEEN_DURATION = 1000;
    const tween = new TWEEN.Tween({ percentage: 0 });
    tween.easing(TWEEN.Easing.Quartic.InOut);
    tween.onUpdate(({ percentage }) => {
      cubes.forEach((cube) => {
        // 旋转面
        cube.rotation[LOCAL_AXIS] =
          cube.recoardStart.rotation[LOCAL_AXIS] + percentage * rad;
        const newPosition = cube.recoardStart.position.clone();
        newPosition.applyAxisAngle(rotateAxis, percentage * rad);
        // 旋转位置
        cube.position.set(newPosition.x, newPosition.y, newPosition.z);
      });
    });
    tween.onComplete(() => {
      /** 校验结果 */
      this._check();
    });
    tween.to({ percentage: 1 }, TWEEN_DURATION);
    tween.start();
  }

胜利结算

校验结果比较简单:

  1. 每条射线穿过 2 个方块和一个投射面,若满足消消乐规则,则给投射面一个高亮效果,代表成功;
  2. 若所有投射面都高亮,则表示关卡胜利。
/**
   * 校验结果
   */
  _check() {
    this.raycasterOptions.forEach((e) => {
      const checkSet = new Set();
      this.gzsRaycaster.set(e.origin, e.direction);
      const intersects = this.gzsRaycaster.intersectObjects(
        this.scene.children
      );
      for (let i = 0; i < intersects.length; i++) {
        if (intersects[i].object.isMesh) {
          checkSet.add(intersects[i].object);
        }
      }
      const checkArr = Array.from(checkSet);
      checkArr[2].trigger(
        checkArr[0].rccs.value === checkArr[1].rccs.value &&
          checkArr[1].rccs.value === checkArr[2].rccs.value
      );
    });
    let pass = this.boardGroup.children.every((e) => e.children[0].visible);
    setTimeout(() => {
      if (pass) {
        alert("出院!");
      }
    }, 100);
  }

优化

UI太丑,魔方可用贴图或模型。下一步尝试 blender 建模。

代码

// 枘凿六合
import * as THREE from "three";
import * as TWEEN from "@tweenjs/tween.js";
import ThreeMap from "./ThreeMap";

const ECubeType = Object.freeze({
  solid: {
    color: new THREE.Color().setHSL(0 / 8, 1, 0.5),
    value: 1,
  },
  hyaline: {
    color: new THREE.Color().setHSL(3 / 8, 1, 0.8),
    value: 0,
  },
});

const CubeCfg = {
  d: 0.8,
  size: 1.2,
  options: [
    {
      name: "octant-1",
      x: 1,
      y: 1,
      z: 1,
      type: "hyaline",
    },
    {
      name: "octant-2",
      x: -1,
      y: 1,
      z: 1,
      type: "solid",
    },
    {
      name: "octant-3",
      x: -1,
      y: -1,
      z: 1,
      type: "solid",
    },
    {
      name: "octant-4",
      x: 1,
      y: -1,
      z: 1,
      type: "hyaline",
    },
    {
      name: "octant-5",
      x: 1,
      y: 1,
      z: -1,
      type: "solid",
    },
    {
      name: "octant-6",
      x: -1,
      y: 1,
      z: -1,
      type: "hyaline",
    },
    {
      name: "octant-7",
      x: -1,
      y: -1,
      z: -1,
      type: "hyaline",
    },
    {
      name: "octant-8",
      x: 1,
      y: -1,
      z: -1,
      type: "solid",
    },
  ],
};

export default class GzsThreeMap extends ThreeMap {
  _initView() {
    this._createScene();
    this._addLight(-1, 2, 4);
    this._addLight(1, -1, -2);
    this._createCamera(this.options.camera.far);
    this._createRender(this.rootElement);
    this._createControls(this.camera, this.renderer.domElement);
    this._createAxesHelper();
    this.switchAnimate();
    // 相机归位
    this.camera.position.set(4.6, 3, 5.14);
    this.camera.up.set(0, -1, 0);
    this.camera.lookAt(0, 0, 0);
    // 创建方块
    this.cubeGroup = this._createCubes(CubeCfg);
    this.scene.add(this.cubeGroup);
    // 创建投射组
    this.raycasterOptions = this._createRaycasters(CubeCfg.d, CubeCfg.size);
    // 投射板
    this.boardGroup = this._addProjectionBoard(
      this.raycasterOptions,
      CubeCfg.d,
      CubeCfg.size
    );
    this.scene.add(this.boardGroup);
    // 划分选择面
    this.planeOptions = [
      {
        name: "plane-1",
        raycasterIndexs: [0, 1], // xz,y>0
        rotateAxis: new THREE.Vector3(0, 1, 0),
        leftRad: Math.PI / 2,
        rightRad: -Math.PI / 2,
      },
      {
        name: "plane-2",
        raycasterIndexs: [2, 3], // xz,y<0
        rotateAxis: new THREE.Vector3(0, 1, 0),
        leftRad: Math.PI / 2,
        rightRad: -Math.PI / 2,
      },
      {
        name: "plane-3",
        raycasterIndexs: [0, 3], // yz,x>0
        rotateAxis: new THREE.Vector3(1, 0, 0),
        leftRad: Math.PI / 2,
        rightRad: -Math.PI / 2,
      },
      {
        name: "plane-4",
        raycasterIndexs: [1, 2], // yz,x<0
        rotateAxis: new THREE.Vector3(1, 0, 0),
        leftRad: Math.PI / 2,
        rightRad: -Math.PI / 2,
      },
      {
        name: "plane-5",
        raycasterIndexs: [4, 7], // xy,z>0
        rotateAxis: new THREE.Vector3(0, 0, 1),
        leftRad: Math.PI / 2,
        rightRad: -Math.PI / 2,
      },
      {
        name: "plane-6",
        raycasterIndexs: [5, 6], // yz,x<0
        rotateAxis: new THREE.Vector3(0, 0, 1),
        leftRad: Math.PI / 2,
        rightRad: -Math.PI / 2,
      },
    ];
    // 投射
    this.gzsRaycaster = new THREE.Raycaster();
    this.gzsRaycaster.camera = this.camera;
    // 当前选中面
    this.currentPlane = undefined;
    this.currentMeshSet = new Set();

    this.scene.background = new THREE.Color("white");
  }

  /**
   * 动画监听
   */
  _animateListener() {
    TWEEN.update();
  }

  /**
   * 添加光照
   */
  _addLight(...pos) {
    const color = 0xffffff;
    const intensity = 1;
    const light = new THREE.DirectionalLight(color, intensity);
    light.position.set(-1, 2, 4);
    light.position.set(...pos);
    this.scene.add(light);
  }

  /**
   * 新建立方体
   * @param {*} cubeCfg
   * @returns
   */
  _createCubes(cubeCfg) {
    const { d, size, options } = cubeCfg;
    const geometry = new THREE.BoxGeometry(size, size, size);
    const cubeGroup = new THREE.Group();

    options.forEach((e) => {
      // 立方体
      const material = new THREE.MeshPhongMaterial({
        color: ECubeType[e.type].color,
        opacity: 0.5,
        transparent: true,
        side: THREE.DoubleSide,
      });
      const cube = new THREE.Mesh(geometry, material);
      cube.position.set(e.x * d, e.y * d, e.z * d);
      cube.name = e.name;
      cube.rccs = {
        value: ECubeType[e.type].value,
      };
      // 选中效果
      const box = new THREE.Box3();
      box.setFromCenterAndSize(
        new THREE.Vector3(0, 0, 0),
        new THREE.Vector3(size, size, size)
      );
      const helper = new THREE.Box3Helper(box, 0xffff00);
      helper.visible = false;
      helper.name = "box3Helper";
      cube.add(helper);
      // 触发选中效果
      cube.trigger = (visible) => {
        cube.children[0].visible = visible;
      };
      cubeGroup.add(cube);
    });
    return cubeGroup;
  }

  /**
   * 创建射线组
   */
  _createRaycasters(d, size) {
    const raycasterOptions = [
      /**
       * 投射面: XY平面
       * 方向:Z负半轴
       */
      {
        name: "raycaster-1",
        origin: new THREE.Vector3(d, d, d + size),
        direction: new THREE.Vector3(0, 0, -1),
        type: "solid",
      },
      {
        name: "raycaster-2",
        origin: new THREE.Vector3(-d, d, d + size),
        direction: new THREE.Vector3(0, 0, -1),
        type: "solid",
      },
      {
        name: "raycaster-3",
        origin: new THREE.Vector3(-d, -d, d + size),
        direction: new THREE.Vector3(0, 0, -1),
        type: "hyaline",
      },
      {
        name: "raycaster-4",
        origin: new THREE.Vector3(d, -d, d + size),
        direction: new THREE.Vector3(0, 0, -1),
        type: "hyaline",
      },
      /**
       * 投射面: YZ平面
       * 方向:X负半轴
       */
      {
        name: "raycaster-5",
        origin: new THREE.Vector3(d + size, d, d),
        direction: new THREE.Vector3(-1, 0, 0),
        type: "solid",
      },
      {
        name: "raycaster-6",
        origin: new THREE.Vector3(d + size, d, -d),
        direction: new THREE.Vector3(-1, 0, 0),
        type: "solid",
      },
      {
        name: "raycaster-7",
        origin: new THREE.Vector3(d + size, -d, -d),
        direction: new THREE.Vector3(-1, 0, 0),
        type: "hyaline",
      },
      {
        name: "raycaster-8",
        origin: new THREE.Vector3(d + size, -d, d),
        direction: new THREE.Vector3(-1, 0, 0),
        type: "hyaline",
      },
    ];
    return raycasterOptions;
  }

  /**
   * @descption 增加投影面
   */
  _addProjectionBoard(raycasterOptions, d, size) {
    const boardGroup = new THREE.Group();

    raycasterOptions.forEach((e) => {
      const dx = e.direction.x === -1 ? 0.1 : size;
      const dy = size;
      const dz = e.direction.z === -1 ? 0.1 : size;
      const geometry = new THREE.BoxGeometry(
        e.direction.x === -1 ? 0.1 : size,
        size,
        e.direction.z === -1 ? 0.1 : size
      );
      const material = new THREE.MeshPhongMaterial({
        color: ECubeType[e.type].color,
        opacity: 0.6,
        transparent: true,
        side: THREE.DoubleSide,
      });
      const board = new THREE.Mesh(geometry, material);
      board.position.set(
        e.direction.x === -1 ? -e.origin.x : e.origin.x,
        e.origin.y,
        e.direction.z === -1 ? -e.origin.z : e.origin.z
      );
      board.rccs = {
        value: ECubeType[e.type].value,
      };
      // 检查效果
      const box = new THREE.Box3();
      box.setFromCenterAndSize(
        new THREE.Vector3(0, 0, 0),
        new THREE.Vector3(dx, dy, dz)
      );
      const helper = new THREE.Box3Helper(box, 0xffff00);
      helper.visible = false;
      helper.name = "box3Helper";
      board.add(helper);
      // 触发通过效果
      board.trigger = (visible) => {
        board.children[0].visible = visible;
      };
      boardGroup.add(board);
    });
    return boardGroup;
  }

  /**
   * 按面选择
   * @param {*} name
   */
  selectFrame(name) {
    const planeName = `plane-${name}`;
    this.currentPlane = this.planeOptions.find((e) => e.name === planeName);
    this.currentMeshSet.clear();
    this.cubeGroup.children.forEach((e) => {
      e.trigger(false);
    });
    this.currentPlane.raycasterIndexs.forEach((e) => {
      const { origin, direction } = this.raycasterOptions[e];
      this.gzsRaycaster.set(origin, direction);
      const intersects = this.gzsRaycaster.intersectObjects(
        this.cubeGroup.children
      );
      for (let i = 0; i < intersects.length; i++) {
        if (intersects[i].object.isMesh) {
          this.currentMeshSet.add(intersects[i].object);
        }
      }
    });
    this.currentMeshSet.forEach((e) => {
      e.trigger(true);
    });
  }

  rotateLeft() {
    this._rotate(
      this.currentMeshSet,
      this.currentPlane.rotateAxis,
      this.currentPlane.leftRad
    );
  }

  rotateRight() {
    this._rotate(
      this.currentMeshSet,
      this.currentPlane.rotateAxis,
      this.currentPlane.rightRad
    );
  }

  /**
   * @descption 旋转动画
   * @param {*} cubes 旋转的方块
   * @param {*} rotateAxis 旋转轴
   * @param {*} rad 旋转角度
   */
  _rotate(cubes, rotateAxis, rad) {
    // 更新起始数据
    cubes.forEach((cube) => {
      cube.recoardStart = {
        position: cube.position.clone(),
        rotation: new THREE.Vector3(0, 0, 0),
      };
      cube.rotation.x = 0;
      cube.rotation.y = 0;
      cube.rotation.z = 0;
    });
    // 旋转动画
    const LOCAL_AXIS = rotateAxis.x ? "x" : rotateAxis.y ? "y" : "z";
    const TWEEN_DURATION = 1000;
    const tween = new TWEEN.Tween({ percentage: 0 });
    tween.easing(TWEEN.Easing.Quartic.InOut);
    tween.onUpdate(({ percentage }) => {
      cubes.forEach((cube) => {
        // 旋转面
        cube.rotation[LOCAL_AXIS] =
          cube.recoardStart.rotation[LOCAL_AXIS] + percentage * rad;
        const newPosition = cube.recoardStart.position.clone();
        newPosition.applyAxisAngle(rotateAxis, percentage * rad);
        // 旋转位置
        cube.position.set(newPosition.x, newPosition.y, newPosition.z);
      });
    });
    tween.onComplete(() => {
      /** 校验结果 */
      this._check();
    });
    tween.to({ percentage: 1 }, TWEEN_DURATION);
    tween.start();
  }

  /**
   * 校验结果
   */
  _check() {
    this.raycasterOptions.forEach((e) => {
      const checkSet = new Set();
      this.gzsRaycaster.set(e.origin, e.direction);
      const intersects = this.gzsRaycaster.intersectObjects(
        this.scene.children
      );
      for (let i = 0; i < intersects.length; i++) {
        if (intersects[i].object.isMesh) {
          checkSet.add(intersects[i].object);
        }
      }
      const checkArr = Array.from(checkSet);
      checkArr[2].trigger(
        checkArr[0].rccs.value === checkArr[1].rccs.value &&
          checkArr[1].rccs.value === checkArr[2].rccs.value
      );
    });
    let pass = this.boardGroup.children.every((e) => e.children[0].visible);
    setTimeout(() => {
      if (pass) {
        alert("出院!");
      }
    }, 100);
  }
}