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

背景
启发来自 Threejs 的文章–如何绘制透明的物体,在网上搜了一圈也没有找到【枘凿六合】的例子,便想着自己实现一下。
不过魔方类的技术文章已经有很多实现了,如:
- Github · I-cannot-deal-with-Rubiks-cube: 使用 Three.js+Vue 编写的 3D 前端魔方游戏
- Github · Rubiks-Cube: Three.js 实现魔方小游戏
- 知乎 · THREEJS 的三阶魔方
- 知乎 · ThreeJS 四步制作一个简易魔方:魔方旋转
- three.js 制作魔方:了解tween 的补间动画
简化
设想
- 【合集 8.21 已更新 93 话】Blender 2.9-3.4 黑铁骑士 Ⅱ 系统零基础入门教程(持续更新+中文字幕+普通话+不敷衍+义务教育+案例+学习)
- three.js 辉光的两种实现方法,及解决添加辉光后背景变成黑色的问题
思路
首先,【枘凿六合】是个二阶魔方,八个方块位于空间直角坐标系的八个卦限中,投射面板位置固定且坐标系不会转动,绘制应该不成问题。
然后是玩家操作上,仅可选择某一个面的方块,然后“左右”转动选择的方块。
当同一方向上的方块和投射面满足消消乐条件时,即可得分。
实现
基础场景
绘制方块
见文档:如何绘制透明的物体
可按照卦限给方块编号 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();
}
胜利结算
校验结果比较简单:
- 每条射线穿过 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);
}
}