中秋博饼是流行于福建省厦门市的传统民俗,国家级非物质文化遗产之一。
明月洒光,洒出人间无限浪漫;桂花飘香,飘出大地五彩缤纷;月饼圆圆,圆出家人欢声笑语;提前祝大伙祝你中秋快乐,永远平安。
今天让我们走进闽南的乡俗,一起使用Threejs实现 中秋博饼 游戏。
实现步骤
实现主要有以下几个步骤:
- 等待骰子、盆子模型加载
- 物理引擎场景模拟
- 3D场景物体与引擎物体 "绑定"
- 游戏规则
模型加载
3D 模型网站有很多,其中较为知名的是 Sketchfab ,其中有丰富的免费模型作为使用,并且有很多模型格式提供下载,如fbx、obj、gltf、glb等。
glTF与glb模型格式
gltf是JSON文件,里面包括了所有场景的信息,如scene、textures等,是常见的三维模型标准文件格式。 gltf的贴图、几何等资源可以直接通过base64的格式内联,也可指向外部的资源。所以gltf通常以文件夹的方式传输,里面含有需要外联的资源文件,
glb格式则是gltf的二进制版本,由于base64的编解码需要时间与空间,所以引用了glb格式文件。相同文件下,glb格式文件会比gltf文件更小一些,本文使用到的正是glb格式。
Threejs模型加载
Threejs包中 提供了 FBXLoader、OBJLoader、GLTFLoader等方式进行模型的加载,其中GLTFLoader可加载gltf与glb两种格式。用法非常简单,直接贴代码。
/**
* 加载GLTF
*/
let loadGLTF = async (url): Promise<THREE.Group> => {
return new Promise(resolve => {
const loader = new GLTFLoader();
loader.load(url, object => {
resolve(object as THREE.Group);
});
});
};
/**
* 初始化模型
*/
let loadModel = async () => {
let basinModel = await loadGLTF("/basin2.glb");
let diceModel = await loadGLTF("/dice.glb");
// 加载色子
dice = diceModel.scene.children[0];
dice.scale.set(0.1, 0.1, 0.1);
dice.traverse(function (child) {
if (child.isMesh) {
child.castShadow = true;
child.material.metalness = 1;
child.material.emissive = child.material.color;
child.material.emissiveMap = child.material.map;
}
});
// 加载盆子
basin = basinModel.scene.children[0];
basin.scale.set(18, 18, 18);
basin.position.y = 0.7;
basin.position.x = 0;
scene.add(basin);
};
现在我们可以看到一个盆子在屏幕中
物理引擎场景模拟
Threejs的物理引擎我选择使用 cannon-es,主要是使用其中的碰撞和重力模拟。
文档的首页示例是偏简单的,我们首先需要初始化一个世界,然后设置他的重力方向与大小,并设置在物体静止的时候是睡眠状态的,减少计算
let world = new CANNON.World();
world.gravity.set(0, -9.82, 0);
world.allowSleep = true;
然后我们进行物体的模拟,骰子我们可以使用引擎中提供的 Box 类来进行生成。
/**
* 创建色子物理引擎函数
*/
const generateDiceBody = () => {
const size = 0.1;
const halfExtents = new CANNON.Vec3(size, size, size);
const dice = new CANNON.Body({
mass: 0.1,
material: new CANNON.Material({
friction: 0.1,
restitution: 0.7
}),
shape: new CANNON.Box(halfExtents)
});
dice.sleepSpeedLimit = 1.0;
world.addBody(dice);
return dice;
};
我们使用了Body类生成World需要的主体,mass是物体质量,material是材质,由Material类生成,friction是摩擦力,restitution是弹性系数,可通过不断调整来达到最好的效果。
sleepSpeedLimit 属性是刚体进入SLEEP状态的判断条件,当速度此值则进入睡眠状态。
盆子模拟
cannon-es文档中提供了以下方式生成形状
Box、Sphere、Plane都是 threejs 熟悉的几何体无法生成盆子这种凹面体,ConvexPolyhedron是凸多面体,Particle是粒子,显然都无法实现。
只剩下Trimesh修剪网格,用法是用顶点数据进行生成,模型中正好有这些信息。
正当我写完以后以为可以大功告成时,运行一看却发现碰撞效果失效,骰子直接从盆子传过去。 百思不得其解之时,在github提的issue中看到以下回答
原来修建网格不支持碰撞!!!!
好好好,你这样整是吧,行行行。
现在只剩下Heightfield高度场了,高度场是通过提供的高度数据来生成形状。我们通过勾股定理判断X、Z是否在圆内,如果在则高度为-1.7,否则为0,这样我们通过Heightfield类来生成凹面体的形状。
/**
* 初始化盆子刚体
*/
const initBasin = () => {
const numRows = 60;
const numCols = 60;
let heights: number[][] = [];
for (let i = 0; i < numRows; i++) {
const row: number[] = [];
for (let j = 0; j < numCols; j++) {
const x = (j / (numCols - 1) - 0.5) * 20;
const z = (i / (numRows - 1) - 0.5) * 20;
const radius = 4.2;
const height = Math.sqrt(x * x + z * z) <= radius ? -1.7 : 0;
row.push(height);
}
heights.push(row);
}
const heightfieldShape = new CANNON.Heightfield(heights, {
elementSize: 0.2
});
const heightfieldBody = new CANNON.Body({
mass: 0,
material: new CANNON.Material({
friction: 0.1,
restitution: 0.7
})
});
heightfieldBody.addShape(heightfieldShape);
heightfieldBody.position.set(-5.9, 1.3, 5.9);
heightfieldBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
world.addBody(heightfieldBody);
};
在写文章的时候突然想到其实并不需要借助物理引擎,碰撞可以通过八叉树Octree进行检测,重力可以自己模拟,有兴趣的小伙伴可以试试看
这里也不得不抱怨一下cannon-es,首先没有中文文档,二来是使用人数也相对较少,遇到问题解决需要花很长的时间来找答案。
3D场景物体与引擎物体 "绑定"
我们定义一个对象dices保存骰子的引擎刚体和threejs物体的信息。
let dices: { tDice?: THREE.Object3D; cDice?: CANNON.Body }[] = [];
/**
* 初始化色子位置
*/
let initDice = () => {
for (let i = 1; i <= 6; i++) {
let cDice = generateDiceBody();
cDice.quaternion.setFromEuler(Math.PI / 2, 0, 0);
let tDice = dice.clone();
scene.add(tDice);
dices.push({ tDice: tDice, cDice: cDice });
}
};
然后将引擎刚体的旋转位移赋值给threejs物体
dices.forEach(({ cDice, tDice }) => {
if (cDice && tDice) {
tDice.position.copy(cDice.position);
tDice.quaternion.copy(cDice.quaternion);
}
});
至此,3D场景就已经搭建好了,接下来就到了游戏逻辑的处理。
游戏规则
游戏规则如下图
要想知道博的什么名首先需要获得每个骰子的点数,我们可以通过每个方向的旋转角度与朝上初始面来得出。
/**
* 获取色子点数
*/
let getDicePoints = () => {
let points: number[] = [];
dices.forEach(({ cDice, tDice }) => {
let xAngle = Math.round((tDice!.rotation.x / Math.PI) * 180);
let zAngle = Math.round((tDice!.rotation.z / Math.PI) * 180);
let point;
if (xAngle == -90) {
point = 1;
} else if (xAngle == 90) {
point = 5;
} else if (xAngle + zAngle == -90 || xAngle + zAngle == 270) {
point = 4;
} else if (xAngle + zAngle == 0 || xAngle + zAngle == 360) {
point = 3;
} else if (xAngle + zAngle == 180 || xAngle + zAngle == -180) {
point = 6;
} else {
point = 2;
}
points.push(point);
});
return points;
};
得到每个骰子的点数后,我们需要根据规则来看博饼结果。
const getName = () => {
let obj = {};
result.value.forEach(index => {
if (obj[index]) {
obj[index] += 1;
} else {
obj[index] = 1;
}
});
if (obj[4] == 1) {
if (Object.keys(obj).length === 6) {
return "对堂";
} else {
return "一秀";
}
} else if (obj[4] == 2) {
return "二举";
} else if (obj[4] == 3) {
return "三红";
} else if (obj[4] == 4) {
if (obj[2] == 2) {
return "状元插金花";
} else {
return "状元";
}
} else if (obj[4] == 5) {
return "五王";
} else if (obj[4] == 6) {
return "六捧红";
} else if (Object.keys(obj).some((key)=> obj[key] == 4)) {
return "四进";
} else if (obj[6] == 5) {
return "五子登科";
} else if (obj[6] == 6) {
return "手捧黑";
}
return "再接再厉";
};
result 是一个数字元组,保存每个骰子的点数
let result: Ref<number[]> = ref([]);
我们需要每个点 与 每个点出现的次数,通过上面的函数得到以下数据结构
{1:1,2:1,3:4} // key = 点数, value = 出现次数
然后就可以通过规则来得出博饼结果。
以上就是这个小游戏的核心,完结啦!
最终效果:
是不是非常nice ~
最后
我们通过本文学习到了以下知识
- 模型知识、下载地址、gltf与glb的区别等
- 物理引擎的使用与简单形状和复杂形状的刚体模拟 (高度场)
- 博饼游戏规则。
相信大家在看完这篇文章后,能够对博饼、Threejs 都有了深刻的理解,也相信你们能够实现更棒的效果。
最后球球大家点点赞和收藏一下吧,球球了