用three.js做一个3D汉诺塔游戏(上)

这篇具有很好参考价值的文章主要介绍了用three.js做一个3D汉诺塔游戏(上)。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

本文由孟智强同学原创,主要介绍了如何利用 three.js 开发 3D 应用,涵盖 3D 场景搭建、透视相机、几何体、材质、光源、3D 坐标计算、补间动画以及物体交互实现等知识点。


入门 three.js 也有一阵子了,我发现用它做 3D 挺有趣的,而且学习门槛也不算高。在这篇博文中,我想分享一下利用 three.js 做一个 3D 版汉诺塔(河内塔)的过程,以及对 three.js 相关知识点进行一次较为全面的实战总结。希望能与大家交流技术心得和经验,一起共同进步。

效果展示

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

游戏规则:将串在左边柱杆(A柱)上的盘子全部挪进右边柱杆(C柱)即可获胜;一次只能挪动最上面的一个盘子;每个盘子的上面只能放置比它小的盘子;可利用中间的柱杆(B柱)来中转、倒换盘子。

可自由选择游戏难度(盘子数量),游戏中途可随时重开,获胜后会有该局耗时和步数的统计信息。

  • 在线演示:https://kagol.github.io/hanoi/

  • 编程语言:JavaScript(ES6+)

  • 代码架构:MVP

  • 用到的框架/库

    • three.js - JavaScript 3D 框架
    • tween.js - 提供动画支持
    • canvas-confetti - 提供彩屑特效支持

本文知识点

  • 3D 场景初始化:场景、相机、渲染器
  • 透视相机的位置调整
  • 几何体:BoxGeometry、CylinderGeometry、LatheGeometry
  • 材质:MeshLambertMaterial、MeshPhongMaterial、MeshBasicMaterial
  • 光源:AmbientLight、SpotLightHelper、DirectionalLight
  • 更新材质的纹理:TextureLoader
  • 渲染 3D 文本:TextGeometry、FontLoader
  • 实现物体阴影效果
  • 3D 坐标的计算
  • 物体交互的实现:Raycaster、坐标归一化
  • 3D 资源的销毁释放
  • 补间动画、动画编排
  • MVP 架构、class 等

初始化

为了方便演示,避免引入底层框架(Vue、React、Angular…)的代码增加复杂度,本文中的案例没有使用前端底层框架和工程脚手架,而采用传统的 HTML 单文件方式来编写代码。

首先,准备一个空白容器,让它的尺寸与浏览器视窗大小相同,以充分利用屏幕空间。

<style>
  body {
    padding: 0;
    margin: 0;
    font: normal 14px/1.42857 Tahoma;
  }

  #app {height: 100vh;}
</style>

<div id="app"></div> <!-- 空白容器 -->

对于 JS 脚本,使用 导入映射 配置资源的 CDN 地址,这样就可以像使用 npm 包一样导入相关资源。

<script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/+esm",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
    }
  }
</script>
<script type="module">
  import * as THREE from 'three';  // 丝滑导入 three.js
</script>

接下来,创建一个场景(Scene)、一个透视相机(PerspectiveCamera)和一个 WebGL 渲染器(WebGLRenderer),并将渲染器添加到 DOM 中。同时,编写一个渲染函数,使用requestAnimationFrame 方法循环渲染场景。以下是最基础的初始化代码:

<script type="module">
  import * as THREE from 'three';
  
  const containerEl = document.getElementById('app');
  const { width, height } = containerEl.getBoundingClientRect();

  /* 场景 */
  const scene = new THREE.Scene();
  
  /* 相机 */
  const fov = 45;  // 视野角度
  const camera = new THREE.PerspectiveCamera(fov, width / height, 1, 500);

  /* 渲染器 */
  const renderer = new THREE.WebGLRenderer({ alpha: true });
  renderer.setSize(width, height);
  renderer.setClearColor('#f8f8f6', 1);  // 设置初始化背景
  containerEl.appendChild(renderer.domElement);

  // 渲染场景(循环)
  (function animate() {
    requestAnimationFrame(animate);
    renderer.render(scene, camera);
  }());
</script>

上面 PerspectiveCamera 设置了 4 个参数,其中最后 2 个参数分别是相机视锥体的近端面和远端面,默认是 0.1 和 2000。这里将其设为 1 和 500,让相机与物体产生的视椎体 “更小、更接近”,以节省渲染性能。

添加桌台

在汉诺塔游戏中,场景里主要的 3D 物体包括桌台、柱杆和盘子,我们先来添加最简单的桌台到场景中。

桌台的形状是一个长方体,我们可以使用 BoxGeometry 来实现它,网格材质则使用 MeshLambertMaterial 模拟木质的非光泽表面。

const tableSize = {
  width: 30,  // 长
  depth: 10,  // 宽
  height: 0.5  // 高
};
const geometry = new THREE.BoxGeometry(  // 立方缓冲几何体
  ...['width', 'height', 'depth'].map(key => tableSize[key])
);
const material = new THREE.MeshLambertMaterial({ color: '#cccca6' });  // 材质
const table = new THREE.Mesh(geometry, material);
scene.add(table);  // 添加到场景

为方便调试,我们添加相机轨道控制器 (OrbitControls) 来控制相机的旋转、缩放和平移,从而可以控制场景的视角和观察点。另外,再添加辅助坐标轴和辅助网格线,方便更加直观地查看物体的位置。

import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

/* 相机轨道控制器 */
new OrbitControls(camera, renderer.domElement);

const axesHelper = new THREE.AxesHelper(100);  // 辅助坐标轴
const gridHelper = new THREE.GridHelper(50, 50);  // 辅助网格线
scene.add(axesHelper, gridHelper);

调整相机位置

运行上述代码后,桌台并没有在视图中“显示”出来。这是因为添加到场景中的桌台默认位于三维坐标原点 (0, 0, 0),而相机的默认位置也是三维坐标原点,此时相机位于桌台内部,相机视野范围内无法看到桌台。所以我们需要调整相机的 z 轴坐标位置,例如:

camera.position.z = 20;

这样,桌台以及辅助线就能够显示在视野中了。(桌台为什么是黑色的?后面会讲)

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

在场景初始化时,我们希望场景中的所有物体能够整体以合适的大小显示在视野中。那么,多少才算“合适的大小”呢?我们不需要为每个物体单独设置合适的尺寸,因为在透视相机中,场景中的物体遵循“近大远小”的规则。因此,只需调整相机的远近位置就能控制它们整体的视觉大小。

在汉诺塔游戏中,桌台是所有物体中宽度最大的物体,因此我们需要将其大小占满视野的宽度。为了实现这一点,我们需要求出相机在 z 轴上的坐标。

如果我们从桌台正上方,也就是通过场景 y 轴往下看,就会看到桌台的俯视图。一图胜千言,下图中 AC 的长度就是相机在 z 轴上的坐标。

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

已知视野角度 fov = 45°,则 ∠ACB = fov / 2,AB = 30 / 2,求 AC 的长度。利用三角函数中正切公式即可求出,代码如下:

const angle = fov / 2;  // 夹角
const rad = THREE.MathUtils.degToRad(angle);  // 转为弧度值
const distanceZ = tableSize.width / 2 / Math.tan(rad);
/**
 * 调整相机的 X 轴位置,让视野能同时看到桌台的顶部和侧面
 * 调整相机的 Z 轴位置,使桌台完整显示到场景 
 */
camera.position.set(0, 15, distanceZ);

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

添加光源

前面添加的桌台是一片漆黑,这是因为我们使用了 MeshLambertMaterial 材质,这种材质需要光源才能反射光线,从而显示物体表面。

我们来添加两个光源到场景中:一个环境光,用来照亮场景中的所有物体;一个平行光,模拟太阳光的效果,让物体产生明暗面,增强立体效果。

const ambientLight = new THREE.AmbientLight('#fff', 1);  // 环境光
const directLight = new THREE.DirectionalLight('#fff', 3);  // 平行光
scene.add(ambientLight, directLight);

有了光源后,桌台就能显示出颜色了。

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

添加柱杆

生成柱杆

柱杆的形状是一个圆柱体,我们可以用 CylinderGeometry 来实现它,网格材质则使用 MeshPhongMaterial 模拟光泽表面。

const pillarSize = {
  height: 5.4,  // 高度
  radius: 0.2,  // 半径
};
const pillarGeometry = new THREE.CylinderGeometry(
  ...['radius', 'radius', 'height'].map(key => pillarSize[key])
);
const pillarMaterial = new THREE.MeshPhongMaterial({ 
  color: '#e6e6e9',
  emissive: '#889',  // 放射光
});
const pillar = new THREE.Mesh(pillarGeometry, pillarMaterial);
table.add(pillar);  // 添加到 table 物体中

当渲染圆柱体时,可能会在曲面边缘产生毛边。为解决这个问题,我们可以开启渲染器的抗锯齿功能,这样可以让圆柱体的边缘在视觉显示上更加平滑。

/* 渲染器 */
const renderer = new THREE.WebGLRenderer({ 
  alpha: true, 
  antialias: true  // 开启抗锯齿
});

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

调整柱杆位置

由于添加到 three.js 场景中的 3D 物体,初始位置位于场景中央,即物体的中心位于三维坐标原点 (0, 0, 0) 处。因此,我们还需要调整柱杆的位置,使其立在桌面上。

具体来说,想让柱杆从桌台的中心位置变成位于桌台顶部,需要将柱杆位置向量的 y 坐标设置为柱杆高度的一半加上桌台高度的一半。参考下图,柱杆从左边的位置变成右边的位置,变动的距离(y 轴坐标)等于 a + b 的值,其中 a 为柱杆高度的一半,b 为桌台高度的一半。

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

对应的代码实现:

const y = (pillarSize.height + tableSize.height) / 2;
pillar.position.y = y;

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

3 根柱杆的排列

我们需要使用 3 根柱杆来移动盘子,这 3 根柱杆依次等距排开呈一行,那么它们的间距需要多少才合适呢?受 CSS 中 flex 布局的启发,我们可以得到柱杆的 3 种水平间距排列方式:

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

我们为其中的柱杆加上盘子的因素。通过观察,可以明显看出中间的 space-around 方案间距最为合理,可以避免盘子的溢出或重叠,是我们想要的排列方式。如下图所示:

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

在 space-around 方式的排列中,第一个元素到行首的距离和最后一个元素到行尾的距离等于相邻元素之间距离的一半。如果我们设第一个柱杆到桌台左边缘的间距为 x,则柱杆之间的间距就等于 2x,所以 x + 2x = tableWidth / 2,如此就能算出间距。如下图所示:

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

3 根柱杆的 y 和 z 轴坐标相同,我们接下来只需求出它们的 x 轴坐标即可。中间柱杆的 x 轴坐标为 0,根据前面求出的间距值,就可以算出第一根和最后一根柱杆的 x 轴坐标。对应的代码如下:

const pillarB = new THREE.Mesh(pillarGeometry, pillarMaterial);
const y = (pillarSize.height + tableSize.height) / 2;
const unitX = tableSize.width / 2 / 3;  // x + 2x = tableSize.width / 2

pillarB.position.y = y;

const pillarA = pillarB.clone();
pillarA.position.x = -unitX * 2;

const pillarC = pillarA.clone();
pillarC.position.x *= -1;

table.add(pillarA, pillarB, pillarC);

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

代码重构

现在我们已经成功添加了桌台和柱杆,但是如果我们回头看一下代码,会发现所有的细节都被平铺在同一层级上,而且,我们还需要添加关于盘子的代码以及复杂的交互逻辑。可以预见,我们的代码将逐步演变成面条式代码,变得难以维护。

为了解决这个问题,我们需要及时对代码进行重构,将不同的细节进行分层管理。这里采用了 MVP 架构,将代码分为三个层级:模型层、视图层和代理层。模型层负责数据的管理,视图层负责展示数据和渲染 UI,代理层则负责协调模型层和视图层之间的交互,同时处理一些业务逻辑。

重构后的代码如下:

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const model = {
  tableSize: {
    width: 30,  // 长
    depth: 10,  // 宽
    height: 0.5  // 高
  },
  pillarSize: {
    height: 5.4,
    radius: 0.2
  },

  scene: new THREE.Scene()
};

/* 容器 */
const containerView = {
  init() {
    this.el = document.getElementById('app');
  },
  get size() {
    return this.el.getBoundingClientRect();
  }
};

/* 相机 */
const cameraView = {
  init(width, height) {
    this.fov = 45;
    this.camera = new THREE.PerspectiveCamera(this.fov, width / height, 1, 500);
  },
  fitPosition(layoutWidth) {
    const angle = this.camera.fov / 2;  // 夹角
    const rad = THREE.MathUtils.degToRad(angle);  // 转为弧度值
    const cameraZ = layoutWidth / 2 / Math.tan(rad);
    // 调整相机的 Z 轴位置,使桌台元素完整显示到场景
    this.camera.position.set(0, 15, cameraZ);
  }
};

/* 渲染器 */
const rendererView = {
  init(width, height) {
    this.renderer = new THREE.WebGLRenderer({ 
      alpha: true,
      antialias: true  // 开启抗锯齿
    });
    this.domElement = this.renderer.domElement;
    this.setSize(width, height);
    this.renderer.setClearColor('#f8f8f6', 1);
  },
  appendToDOM(dom) {
    dom.appendChild(this.domElement);
  },
  setSize(width, height) {
    this.renderer.setSize(width, height);
  },
  render(scene, camera) {
    this.renderer.render(scene, camera);
  }
};

/* 轨道控制器 */
const controlsView = {
  init(camera, domElement) {
    const controls = new OrbitControls(camera, domElement);
    return controls;
  }
};

/* 辅助 */
class Helpers {
  constructor() {
    const axesHelper = new THREE.AxesHelper(100);
    const gridHelper = new THREE.GridHelper(50, 50);
    return [axesHelper, gridHelper];
  }
}

/* 灯光 */
class Lights {
  constructor() {
    const ambientLight = new THREE.AmbientLight('#fff', 1);  // 环境光
    const directLight = new THREE.DirectionalLight('#fff', 3);  // 平行光
    return [ambientLight, directLight];
  }
}

/* 桌台 */
class Table {
  constructor({ width, height, depth }) {
    const geometry = new THREE.BoxGeometry(width, height, depth);
    const material = new THREE.MeshLambertMaterial({ color: '#cccca6' });
    return new THREE.Mesh(geometry, material);
  }
}

/* 柱杆 */
class Pillar {
  constructor({ radius, height }) {
    const geometry = new THREE.CylinderGeometry(radius, radius, height);
    const material = new THREE.MeshPhongMaterial({ 
      color: '#e6e6e9',
      emissive: '#889', 
    });
    return new THREE.Mesh(geometry, material);
  }
}

const presenter = {
  init() {
    // 初始化容器
    containerView.init();
    const { width, height } = containerView.size;

    // 初始化相机
    cameraView.init(width, height);
    cameraView.fitPosition(model.tableSize.width);

    // 初始化渲染器
    rendererView.init(width, height);
    rendererView.appendToDOM(containerView.el);

    // 初始化相机轨道控制器
    controlsView.init(cameraView.camera, rendererView.domElement);

    // 添加辅助
    model.scene.add(...new Helpers());

    // 添加灯光
    model.scene.add(...new Lights());

    // 添加桌台元素
    model.scene.add(new Table(model.tableSize));

    // 添加柱杆
    this.addPillars();

    this.animate();
  },

  addPillars() {
    const { width: tableWidth, height: tableHeight } = model.tableSize;
    const { height: pillarHeight } = model.pillarSize;
    const y = (tableHeight + pillarHeight) / 2;
    const unitX = tableWidth / 2 / 3;
    const pillarsMap = new Map([
      ...['A', 'B', 'C'].map(key => [key, new Pillar(model.pillarSize)])
    ]);

    pillarsMap.get('A').position.set(-unitX * 2, y, 0);
    pillarsMap.get('B').position.set(0, y, 0);
    pillarsMap.get('C').position.set(unitX * 2, y, 0);

    const pillars = [...pillarsMap.values()];
    model.scene.add(...pillars);
  },

  /* 渲染循环 */
  animate() {
    requestAnimationFrame(this.animate.bind(this));
    rendererView.render(model.scene, cameraView.camera);
  }
};

presenter.init();

添加柱杆底座

完成代码重构后,代码层级就清晰多了,方便后续拓展功能。

我们计划在柱杆底部增加一个底座,以增强其装饰效果。底座的形状是一个上小下大中间空的喇叭状物体,由于这种形状没有现成的几何体可用,我们决定采用车削缓冲几何体(LatheGeometry)来生成它。

“车削缓冲几何体”这个翻译可能对于不了解机械加工的人来说比较生硬,不容易理解,私以为可以翻译成“旋转塑形几何体”比较直观,能够清晰地展现其原理和应用。其原理是先确定一系列的点,这些点连成一条线(路径),然后绕 y 轴旋转一定角度(默认是旋转360°),旋转过程中路径经过的面就会形成一个几何体,可以用来创建圆环、碗、瓶子等形状。可以将其想象成在旋转的陶轮上做陶胚,手指沿着泥胚的多个位置贴合成一条路径(如下图中的红线),在陶轮旋转后就能得到所需形状的胚体。

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

代码实现方面,为 Pillar 类拓展一个底座生成私有方法,利用正弦的特性来生成曲线路径点(这里用了10个路径点),传递给 LatheGeometry 构造函数生成几何体。关键代码如下:

const model = {
  ...
  pillarSize: {
    ...
    baseHeight: 0.18  // 底座高度
  }
};

class Pillar {
  ...  
  #createBase(r, height) {
    const pointNum = 10;
    const unitY = height / (pointNum - 1);
    const points = Array.from({ length: pointNum }).map((v, i) =>
      new THREE.Vector2(
        Math.sin(i * r) * r + r,
        -unitY * i
      )
    );
    const geometry = new THREE.LatheGeometry(points, 32);
    const material = new THREE.MeshLambertMaterial({
      color: '#353546',
      side: THREE.DoubleSide
    });

    return new THREE.Mesh(geometry, material);
  }
}

生成的底座 3D 物体如下图所示:

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

我们把它添加到柱杆的底部:

class Pillar {
  constructor({ radius, height, baseHeight }) {
    const geometry = new THREE.CylinderGeometry(radius, radius, height);
    const material = new THREE.MeshPhongMaterial({
      color: '#e6e6e9',
      emissive: '#889',  // 放射光
    });

    const body = new THREE.Mesh(geometry, material);
    const base = this.#createBase(radius, baseHeight);
    base.position.y = -height / 2 + baseHeight;
    body.add(base);

    return body;
  }
  
  #createBase(r, height) {...}
}

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

添加盘子

生成盘子

对于带孔的盘子这种形状,我们首先会想到用圆环缓冲几何体(TorusGeometry)来实现:通过调整圆环半径(第1个参数)控制盘子的大小,调整管道半径(第2个参数)控制盘子中心孔的大小。

const geometry = new THREE.TorusGeometry(2, 1.5);
const material = new THREE.MeshLambertMaterial({ color: '#FB6571' });
const torus = new THREE.Mesh(geometry, material);
scene.add(torus);

但是想要较小的中心孔径,就得设置较粗的管道半径,这么一来,制作出来的盘子就会“胖”得像甜甜圈!

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web 用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

好在我们可以压缩这个“甜甜圈”在 z 轴方向的大小(缩放比)让它变薄。

torus.scale.z = 0.1;

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

不过效果还是差强人意,它并不像汉诺塔中那种很“润”的盘子。

我们的另一个方案是把盘子按显示的面分成4个部分,再分别生成这4个部分的形状,最后组合成盘子。

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

这种方式效果看着还行,不过需要处理 4 个形状的生成及组合,代码稍显复杂。最终我们采用了之前做柱杆底座时的车削几何体(旋转塑形几何体)来生成盘子形状。还记得用法吗?

下图是最终生成的盘子的横截面,绿线是 y 轴,蓝线是 x 轴。

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

根据旋转塑形几何体的特点,我们只需定义出 x 轴方向的形状路径(横截面),它就能绕 y 轴旋转生成完整的形状。其中 x 轴方向的形状路径如下图所示:

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

由于图形沿 x 轴的上下两部分是对称的,所以实际上我们只需关注上半部分的路径形状,即先定义上半部分的 5 个路径点(下图中的红点),再翻转生成下半部分的路径点。

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

盘子的代码如下:

class Plate {
  constructor(size, color) {
    this.size = size;

    const geometry = this.#createGeometry();
    const material = new THREE.MeshLambertMaterial({
      color,
      side: THREE.DoubleSide
    });

    return new THREE.Mesh(geometry, material);
  }
  
  #createGeometry() {
    const { radius, height, poreRadius } = this.size;
    const sideRadius = radius - poreRadius;
    const topPoints = [  // 上半部分的5个路径点(二维)
      new THREE.Vector2(poreRadius, 0),
      new THREE.Vector2(poreRadius, height / 2),
      new THREE.Vector2(sideRadius - 0.08, height / 2),
      new THREE.Vector2(sideRadius, height / 4),
      new THREE.Vector2(sideRadius, 0)
    ];
    // 翻转生成下半部分的路径点
    const bottomPoints = topPoints.map(vector =>
      vector.clone().setY(vector.y * -1)
    ).reverse();
    const points = [...topPoints, ...bottomPoints];

    return new THREE.LatheGeometry(points, 64);
  }
}

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

多个盘子的堆叠显示

在汉诺塔游戏中,有多个大小不同的盘子,游戏开始前,这些盘子按照从大到小的顺序从下往上依次堆叠,形成一个塔状结构。

我们先在模型层完善盘子的配置数据,为多个盘子准备不同的颜色:

const model = {
  ...
  plate: {
    nums: 5,  // 盘子数量
    height: 0.5,
    colors: [
      '#c186e0', '#997feb', '#59b1ff', '#36cfc9',
      '#bae637', '#e7d558', '#ff9c6e', '#ff6b6b'
    ]
  }
}

然后在代理层添加 addPlates 方法用来生成并添加多个盘子:

const presenter = {
  init() {
    ...
    this.addPlates();  // 添加盘子
  }
  addPlates() {
    
  }
	...
};

下面来编写 addPlates 这个方法。

汉诺塔中的盘子是按一定的大小规律堆叠在一起的,多个盘子的大小可以使用等比数列来控制。我们只需设定最大的一个盘子的尺寸,就能利用等比数列通项公式 a(n) = a1 × r^(n-1) 算出其他盘子的尺寸来。同时,盘子是沿 y 轴堆叠在一起的,只需算出第一个盘子的坐标,就能根据盘子所在堆叠层数算出其 y 轴坐标来。addPlates 代码实现如下:

const { height: plateHeight, colors, nums } = model.plate;
const { height: tableHeight, depth: tableDepth } = model.tableSize;
const maxPlateRadius = tableDepth / 2.5;
const platePoreRadius = model.pillarSize.radius + 0.04;  // 孔径(比支柱大一点)
const group = new THREE.Group();

Array.from({ length: nums }).forEach((v, i) => {
  // 使用等比数列从大到小创建不同半径的圆盘,0.87 为公比
  // an = a1 × r^(n-1)
  const r = maxPlateRadius - i * 0.87 ** (nums - 1);
  const plate = new Plate({ 
    radius: r, 
    height: plateHeight, 
    poreRadius: platePoreRadius
  }, colors[i]);

  plate.position.y = tableHeight + plateHeight * i;  // 第i个盘子的位置
  group.add(plate);
});

model.scene.add(group);

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

我们再将堆叠盘子 group 对象的位置设为左侧柱杆的位置,让盘子串在左侧柱杆上。

const unitX = model.tableSize.width / 2 / 3;
group.position.x = -unitX * 2;

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

添加标签文本

为了更加清晰地指代不同的柱杆和盘子,我们计划为它们添加文本标签。three.js 中实现文字有多种方案可供选择(传送门),这里我们选择文字几何体(TextGeometry)方案。

加载字体文件

文字几何体 TextGeometry 是一个 three.js 的附加组件,需要显式导入。此外,为了使其正常工作,还需要载入专用的字体文件( typeface.json)。

import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';
import { FontLoader } from 'three/addons/loaders/FontLoader.js';

FontLoader 用来异步加载字体,这里加载 three.js 自带的字体。在模型层定义字体加载方法:

const model = {
  ...
  font: null,
  loadFont: () => {
    const url = 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/fonts/'
    const fontName = 'helvetiker_regular.typeface.json';

    return new Promise((resolve, reject) => {
      if (model.font) {
        return resolve();
      }

      new FontLoader().load(url + fontName,
        (font) => {
          // 字体加载成功,font 是一个表示字体的 Shape 类型的数组
          model.font = font;
          resolve();
        },
        null,
        (err) => reject(err)
      );
    });
  }
};

由于字体是异步加载的,文本标签必须在字体加载之后才能创建,因此我们需要调整代码逻辑,等待字体加载成功后再往场景中添加柱杆和盘子。

const presenter = {
	init() {
    ...
    model.loadFont().then(() => {
      this.addPillars();  // 添加柱杆
      this.addPlates();  // 添加盘子
    });
  }
  ...
};

创建文本类

定义一个 Text 类,使用 TextGeometry 几何体和 MeshBasicMaterial 材质生成 3D 文本。

class Text {
  constructor(font, text, { size, color }) {
    const geometry = new TextGeometry(String(text), {
      font,
      size,
      height: 0.02
    });
    geometry.center();  // 文本居中
    const material = new THREE.MeshBasicMaterial({ color });

    return new THREE.Mesh(geometry, material);
  }
}

在代理层新加一个 createText 方法,供视图层调用来创建 3D 文本。

const presenter = {
  ...
  createText(text, options) {
    return new Text(model.font, text, options);
  }
};

添加柱杆标签

柱杆类新加一个参数 key,用来接收柱杆标签名称(A/B/C),内部定义方法生成标签文本并将其放置在柱杆顶部:

class Pillar {
  constructor({ radius, height, baseHeight }, key) {
    this.size = { radius, height, baseHeight };
    ...        
    const text = this.#createLabel(key);		
    body.add(text);

    return body;
  }

  #createLabel(str) {
    const { radius, height } = this.size;
    const fontSize = radius * 2;
    const text = presenter.createText(str, {
      size: fontSize,
      color: '#202020'
    });

    text.position.y = height / 2 + fontSize;  // 位于柱杆顶部
    return text;
  }
  ...
}

添加盘子标签

与柱杆一样,我们给盘子类传入序号作为其标签内容,在内部定义方法生成标签文本,“贴”在盘子外围:

class Plate {
  constructor(size, color, i) {
    this.size = size;
    ...
    const text = this.#createLabel(i);
    body.add(text);

    return body;
  }  

  #createLabel(str) {
    const { radius, height, poreRadius } = this.size;
    const text = presenter.createText(str, {
      size: height / 1.6,
      color: '#fff'
    });

    text.position.z = radius - poreRadius;  // 标签位置
    return text;
  }
  ...
}

柱杆和盘子的标签添加完毕后的效果如下图所示:

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

至此,这个案例中的所有 3D 物体已全部搭建完毕。


下期预告:

  1. 细节优化,让场景中的 3D 物体更加真实;
  2. 为场景物体添加交互,让盘子动起来;
  3. 添加开始、重玩、结束等流程控制,完善游戏流程。

下期内容写作中,敬请关注。

关于 OpenTiny

用three.js做一个3D汉诺塔游戏(上),前端,javascript,3d,前端,web

OpenTiny 是一套企业级 Web 前端开发解决方案,提供跨端、跨框架、跨版本的 TinyVue 组件库,包含基于 Angular+TypeScript 的 TinyNG 组件库,拥有灵活扩展的低代码引擎 TinyEngine,具备主题配置系统TinyTheme / 中后台模板 TinyPro/ TinyCLI 命令行等丰富的效率提升工具,可帮助开发者高效开发 Web 应用。


欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~更多视频内容也可关注B站、抖音、小红书、视频号

OpenTiny 也在持续招募贡献者,欢迎一起共建

OpenTiny 官网:https://opentiny.design/
OpenTiny 代码仓库:https://github.com/opentiny/
TinyVue 源码:https://github.com/opentiny/tiny-vue
TinyEngine 源码: https://github.com/opentiny/tiny-engine

欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI~

如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~文章来源地址https://www.toymoban.com/news/detail-843755.html

到了这里,关于用three.js做一个3D汉诺塔游戏(上)的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • Three.js教程:第一个3D场景

    推荐:将 NSDT场景编辑器加入你3D工具链 其他工具系列: NSDT简石数字孪生 下面的代码完整展示了通过three.js引擎创建的一个三维场景,在场景中绘制并渲染了一个立方体的效果,为了大家更好的宏观了解three.js引擎, 尽量使用了一段短小但完整的代码实现一个实际的三维效果

    2023年04月12日
    浏览(66)
  • three.js(一):认识three.js并创建第一个3D应用

    1-three.js 是什么? three.js是用JavaScript编写的WebGL第三方库; three.js 提供了非常多的3D显示和编辑功能; 具体而言,three.js 是一款运行在浏览器中的 3D 引擎,可以用three.js 创建各种三维场景,并对其进行编辑; 在three.js 的官网上看到许多精彩的演示和文档 three.js 官网:https://thre

    2024年02月11日
    浏览(98)
  • WEB 3D技术 three.js 3D贺卡(2) 加入天空与水面效果

    上文 WEB 3D技术 three.js 3D贺卡(1) 搭建基本项目环境 我们简单搭了一个贺卡雏形 然后 我们要引入一个hdr的一个天空的效果 所以 我们需要在代码中导入 RGBELoader 这里 大家可以选择下载我的hdr资源 WEB 3D技术 three.js 3D贺卡 天空 hdr资源 下载好之后呢 我们在外面套一个 xhdr 文件夹

    2024年01月18日
    浏览(69)
  • WEB 3D技术 three.js 3D贺卡(3) 点光源灯光动画效果

    经过 上文 WEB 3D技术 three.js 3D贺卡(2) 加入天空与水面效果 我们将水面 和 天空的效果搭建了一下 那么 我们将四周 点光源的效果做一下 首先 我们将 renderer.toneMappingExposure 的值 改为 0.1 让效果看着明显一点 这样 整个界面就会暗下来 然后 我们在任意位置 加入代码 创建一个点

    2024年01月19日
    浏览(59)
  • WEB 3D技术 three.js 聚光灯

    本文 我们来说说 点光源和聚光灯 点光源 就像一个电灯泡一样 想四周发散光 而聚光灯就像手电筒一样 像一个方向射过去 距离越远范围越大 光越弱 我们先来看一个聚光灯的效果 我们可以编写代码如下 SpotLight 聚光灯 然后通过 position 设置一下光的位置 运行代码如下 目前看

    2024年01月21日
    浏览(55)
  • Three.js - 实现一个3D地球可视化

    3D地球可视化效果 3D地球的开发并不复杂,对球形物体进行贴图操作,完成球体自转和月球公转,太阳场景设置等即可 上代码 更多详细代码请关注公众号索取(备注:公众号):

    2024年02月04日
    浏览(61)
  • web上构建3d效果 基于three.js的实例

    web页面上提供3D效果,可以为页面提供不少色彩,H5之后canvas 为webgl提供了基础,使得在web页面使用JS也能写出3D的效果,其中three.js作为封装了图形函数 的框架,为入门提供了方便,效果图如下。   以上是官方例子中其中的三个,没接触过的朋友肯定会觉得很神奇,事实上

    2024年02月05日
    浏览(51)
  • Web3D开发经验分享:基于Three.js的Web3D建模案例

    个人主页: 左本Web3D,更多案例预览请点击==》 在线案例 个人简介:专注Web3D使用ThreeJS实现3D效果技巧和学习案例 💕 💕积跬步以至千里,致敬每个爱学习的你。喜欢的话请三连,有问题请私信或者加微信         随着互联网的快速发展,Web3D技术也越来越成熟,越来越

    2024年02月13日
    浏览(50)
  • 【Three.js基础入门】:创建你的第一个3D场景

    Three.js是一种强大的JavaScript库,用于在Web浏览器中创建交互式的3D图形和动画。无需熟练的图形编程经验,你也可以通过Three.js轻松地构建令人惊叹的3D场景。 本文将带你逐步学习如何入门Three.js,从创建一个简单的3D场景开始。 我们将介绍如何使用Three.js创建你的第一个3D场景

    2024年02月16日
    浏览(164)
  • 03.Three.js的入门教程(二)如何创建一个3D地球?

    前言:通过上节课 02.Three.js的入门课程(一),我们了解了Three.js的最小案例DEMO,熟悉了几个重要组成部分。这节课带领大家编写一个3D地球。 一、通过纹理图渲染一个地球 1.1. 创建一个纹理加载器对象TextureLoader,可以加载图片作为纹理贴图; 1.2.完整代码结构 二、小球标

    2024年02月04日
    浏览(69)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包