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

【three.js】3D小游戏讲解 小兔兔骑萝卜飞~ three.js小游戏 阅读详情

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


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

效果展示

在这里插入图片描述

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

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

本文知识点

  • 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;

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

在这里插入图片描述

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

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

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

在这里插入图片描述

已知视野角度 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);

在这里插入图片描述

添加光源

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

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

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

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

在这里插入图片描述

添加柱杆

生成柱杆

柱杆的形状是一个圆柱体,我们可以用 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 物体,初始位置位于场景中央,即物体的中心位于三维坐标原点 (0, 0, 0) 处。因此,我们还需要调整柱杆的位置,使其立在桌面上。

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

在这里插入图片描述

对应的代码实现:

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

在这里插入图片描述

3 根柱杆的排列

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

在这里插入图片描述

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

在这里插入图片描述

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

在这里插入图片描述

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);

在这里插入图片描述

代码重构

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

为了解决这个问题,我们需要及时对代码进行重构,将不同的细节进行分层管理。这里采用了 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°),旋转过程中路径经过的面就会形成一个几何体,可以用来创建圆环、碗、瓶子等形状。可以将其想象成在旋转的陶轮上做陶胚,手指沿着泥胚的多个位置贴合成一条路径(如下图中的红线),在陶轮旋转后就能得到所需形状的胚体。

在这里插入图片描述

代码实现方面,为 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 物体如下图所示:

在这里插入图片描述

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

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) {...}
}

在这里插入图片描述

添加盘子

生成盘子

对于带孔的盘子这种形状,我们首先会想到用圆环缓冲几何体(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);

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

16.胖盘子.gif17.甜甜圈.png

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

torus.scale.z = 0.1;

在这里插入图片描述

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

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

在这里插入图片描述

在这里插入图片描述

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

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

在这里插入图片描述

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

在这里插入图片描述

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

在这里插入图片描述

盘子的代码如下:

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);
  }
}

在这里插入图片描述

多个盘子的堆叠显示

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

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

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);

在这里插入图片描述

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

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

在这里插入图片描述

添加标签文本

为了更加清晰地指代不同的柱杆和盘子,我们计划为它们添加文本标签。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;
  }
  ...
}

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

在这里插入图片描述

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


下期预告:

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

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

关于 OpenTiny

在这里插入图片描述

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标签,一起参与开源贡献~

Three.js做了一个网页版的我的世界 笔者在前一阵子接触到Three.js后, 发现了它能为前端 3D 可视化 / 动画 / 游戏方向带来的无限可能, 正好最近在与朋友重温我的世界, 便有了用Three.js来仿制 MineCraft 的想法, 正好也可以通过一个有趣的项目来学习一下前端 3D 领域方块的放置 / 破坏选择不同的方块类型移动和碰撞检测随机的地形和树木生成无限的世界保存 / 读取游戏音效和背景音乐可调节的渲染距离和视野范围基本的 UI生成水更多的保存栏位手机支持玩法介绍也可以在。 阅读详情

相关推荐

用 Three.js 创建 3D 游戏:基础框架与常见问题解决方案

Three.js 是一个强大的 WebGL 库,可以帮助我们快速构建 3D 游戏。本文将介绍如何用 Three.js 创建一个 3D 游戏的基础框架,并探讨开发中的常见问题及解决方案。

一名热衷于技术的全栈开发者,专注于前端与后端的全面技术探索。在这里,我将分享我在技术领域的学习与成长,助力更多开发者的进步。 1175

分享 10 个基于 Three.js 实现的小游戏

今天给大家分享10个基于Three.js 实现的小游戏。只需一个浏览器,就可以「打开下方链接,部分支持移动端」在线玩耍,简直不要泰裤辣!该学的学、想玩的玩,今天星期五,祝大家周末愉快。《Slow Roads》3D赛车游戏01Slow Roads是一款程序生成的休闲驾驶游戏,可让您暂时脱离生活,无休止地奔向遥远的地平线。设置适合您心情的风景,播放一些音乐,然后开车就行了。只需一个浏览器,就能驾车从森...

前端达人 1万+

threejs——开发一款塔防游戏

gif 图较大,耐心等待,源码见文末为了上班摸鱼合理的玩游戏,我写了一个3d塔防游戏,其中功能包含动画、敌人运动、放置武器、升级武器、销毁武器、动态检测等功能。

2401_84190925的博客 1764

Three.JS游戏开发入门

就在不久前,创建和部署游戏的唯一方法是选择像 Unity 或 Unreal 这样的游戏引擎,学习语言,然后打包游戏并将其部署到你选择的平台上。试图通过浏览器向用户提供游戏的想法似乎是一项不可能完成的任务。幸运的是,由于浏览器技术的进步和硬件加速在所有流行的浏览器中都可用,JavaScript 性能的改进以及可用处理能力的稳步提高,为浏览器创建交互式游戏体验变得越来越普遍。在本文中,我们将了解如何使用 Three.js 创建游戏。但首先,让我们回顾一下 Three.js 是什么以及为什么它是游戏开发的好选择。

新缸中之脑 9174

三款threejs开发的游戏.zip

基于Three.js 开发的三款小游戏,源码来自于网络,具体包括 碰撞检测穿马路动画 ;飞机TheAviator;游戏-SearchX

3DSlicer冠脉分割实战:从DICOM导入到三维重建的完整流程(附常见问题解决)

本文详细介绍了使用3DSlicer进行冠脉分割的完整流程,从DICOM数据导入到三维重建,涵盖环境配置、视图优化、心脏区域分割及冠脉精细分割等关键步骤。针对常见问题提供实用解决方案,帮助医学影像从业者高效完成心血管结构分析,提升诊断准确性。

weixin_29271053的博客 628

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

在上一期,我们成功地搭建了基础的 3D 场景。在本期中,我们将对场景进行优化,使其在视觉上更加真实,并为场景中的物体添加交互,同时编写游戏流程控制逻辑,最终完成这款3D汉诺塔游戏。

OpenTiny的博客 1699

S32K144 SDK实战:FTM输入捕获模块的配置与应用

本文详细介绍了S32K144 SDK中FTM输入捕获模块的配置与应用实践。从基础认知到开发环境搭建,再到引脚配置和中断处理技巧,全面解析了如何利用FTM模块实现高精度信号测量。特别适用于电机控制、工业测速等场景,帮助开发者快速掌握S32K144的FTM输入捕获功能。

weixin_42566424的博客 217

three.js做一个3D汉诺塔游戏

入门 three.js 也有一阵子了,我发现用它做 3D 挺有趣的,而且学习门槛也不算高。在这篇博文中,我想分享一下利用 three.js 做一个 3D 版汉诺塔(河内塔)的过程,以及对 three.js 相关知识点进行一次较为全面的实战总结。希望能与大家交流技术心得和经验,一起共同进步。效果展示游戏规则:将串在左边柱杆(A柱)上的盘子全部挪进右边柱杆(C柱)即可获胜;一次只能挪动最上面的一个盘子...

frontend_frank的博客 533

linux虚拟机和电脑ping通(可上网)

linux虚拟机(Ubuntu)和电脑ping通。

观则明的博客 4117

Topas——基于Geant4的放射治疗蒙特卡罗算法模拟工具

本文内容参考Joseph Perl 的线上会议,视频详情可见于,该视频会议内容主要介绍Topas功能、软件架构、工作流程、参数设定、物理模型、具体案例等,对TOPAS的基本入门使用方法以及使用规定作出阐述。

fy方的博客 7605

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

入门 three.js 也有一阵子了,我发现用它做 3D 挺有趣的,而且学习门槛也不算高。在这篇博文中,我想分享一下利用 three.js 做一个 3D 版汉诺塔(河内塔)的过程,以及对 three.js 相关知识点进行一次较为全面的实战总结。希望能与大家交流技术心得和经验,一起共同进步。效果展示游戏规则:将串在左边柱杆(A柱)上的盘子全部挪进右边柱杆(C柱)即可获胜;一次只能挪动最上面的一个盘子...

趣谈前端 527

【win10】win 10 电脑喇叭音频显示红叉的处理方法

目录 一、报错情况 二、“音频服务未运行”解决方法 三、“未插入扬声器或耳机”解决方法 一、报错情况 (1)当电脑的扬声器喇叭图标上带有红X符号,提示“音频服务未运行”时: (2)小喇叭上面有红叉,显示“未插入扬声器或耳机”时: 二、“音频服务未运行”解决方法 (1)开始菜单搜索“服务”: (2)没有启动的点击“启动”,已启动的点击“重新启动”: (3)继续,右...

顾三殇 —— 博客空间(软件测试) 17万+

所有博客导航

。。

nameofcsdn的博客 1万+

2、探索谷歌云平台(GCP):入门指南

本文全面介绍了谷歌云平台(GCP)的入门知识,涵盖其全球基础设施、核心服务和使用方法。内容包括GCP区域与可用区结构、创建免费账户、配置计算资源(如GCE虚拟机)、存储类型(PD、Filestore、GCS)、网络架构(VPC)、组织与项目管理、身份访问控制(IAM),以及计算、存储、数据库、大数据和人工智能等各类云服务。文章还提供了资源规划、安全管理和成本控制等实用建议,帮助用户快速上手并高效利用GCP构建可扩展的云解决方案。

uber9的博客 255

鲁棒优化 (Robust Optimization) 算法详解及案例分析

鲁棒优化是一种用于处理不确定性优化问题的方法。其目标是在不确定性参数的可能取值范围内,找到一个对不确定性具有鲁棒性的解。

闲人编程的博客 4329

基于three.js实现的点击盒子消除游戏

基于three.js实现的点击盒子消除游戏

gudada010的博客 1278

10、硬件工程师笔试面试——继电器(2025版本)

在设计电路时,应综合考虑SSR的优势和局限性,确保选择合适的型号,并采取适当的设计措施来优化性能和可靠性。继电器的主要功能是利用较小的电流或电压来控制较大电流或电压的电路,实现信号的放大和隔离。在处理继电器故障时,应综合考虑电源、线圈、触点和负载等因素,及时进行故障诊断和处理,以确保电路系统的稳定性和安全性。由于汽车电子系统的复杂性,继电器的设计和选择必须考虑到系统的特定要求,包括电流容量、电压等级、环境条件(如温度、湿度、振动)以及所需的保护和隔离功能。确定线圈的工作电压或电流,以及吸合电压或电流。

XU157303764的博客 3733

Threejs开发指南(第六篇 游戏开发(孤军奋战))

主角不幸迷失在一片原始森林中,孤身一人面临大批敌人的抓捕,要生存下来就必须战斗,我给他取了一个名字:孤军奋战,游戏的目标非常简单:打死所有敌人或者被敌人抓住。

sirtzhh007的博客 1998

Camera2Demo

在Camera2 的基础上进行封装,使得Camera2一堆初始化逻辑与Activity分离,降低耦合度,完成预览拍照并保存的操作

怀旧版电脑GBC模拟器

再现原版GBC掌机的界面,电脑运行连游戏界面都一样大小,很怀旧,很真实,(英文版)强烈推荐,很小的文件,很难才找到的。

微信小游戏使用three.js开发总结2023.9.12

微信小游戏开发,游戏开发,three.js开发,小游戏,3D,3D决对,酒馆卡牌,卡牌,微信游戏,h5游戏,微信群

理发 868

ET2012新版免狗免安装绿色软件

ET2012新版免狗免安祖昂绿色软件。,新界面,新视觉,

2024年10个超炫酷的前端 3D 开源项目,那几个你用?

本文将分享 10 个超炫酷的前端 3D 开源项目。从令人惊叹的视觉效果到富有创新概念的交互体验,这些项目展示了前端技术的无限可能。无论你是新手还是经验丰富的开发者,都值得一探究竟!

骑着牛的奇兵的博客 2315

OV9734 DATASHEET,完整规格书

OV9734完整规格书,包括电气参数,尺寸规格,寄存器配置表等

Three.js 游戏开发(一)

Three.js 是一个易于学习的网络 3D 图形库。这本书解释了 Three.js API 以及如何使用它来构建沉浸式在线游戏。当你完成这本书时,你将能够通过他们的网络浏览器触及数百万玩家,并且你将在构建过程中创建如第一人称射击游戏等令人兴奋的项目。我已经制作游戏超过十年了。当我发现 Three.js 时,我第一个构建的项目非常类似于你将在第三章中构建的第一人称射击游戏,名为探索和交互。我被它能快速创建有趣游戏而无需对库有任何先前的了解所吸引。在使用 Three.js 进行游戏开发。

龙哥盟 1468

(转)使用Three.js制作一个基本的3D飞行游戏

今天,我们将使用Three.js创建一个简单的3D飞机,使WebGL更简单。由于GLSL的复杂性和语法,WebGL对许多开发人员来说是一个相当陌生的世界。但是通过Three.js,浏览器中的3D变得非常容易实现。 在本教程中,我们将创建一个简单的3D场景,并在两个主要部分中进行一些交互。 在第一部分,我们将解释Three.js的基础知识,以及如何建立一个非常简单的场景。 第二部分详细介绍了如...

qq_21222737的博客 2647

使用three.js,实现微信3D小游戏系列教程,框架篇(一)

对于程序员而言,在现阶段的游戏设计中,他们主要聚焦于两大核心要素:一是游戏的可玩性,确保玩家能够沉浸其中,享受游戏带来的乐趣;二是游戏的性能,力求在保障游戏流畅运行的同时,为玩家提供稳定、高质量的游戏体验。在三维图形和游戏开发领域,three.js 作为一个基于 WebGL 的 JavaScript 库,提供了强大的功能来创建和显示动画化的 3D 计算机图形。转载于: https://www.cnblogs.com/yeyunfei/p/18972476。// 线段所在直线的交点坐标 (x , y)

weixin_35817272的博客 188

Three.js实现3D推箱子小游戏

最近一直在学 Three.js ,看到别人做出那么多炫酷的效果,觉得太厉害了,于是决定从一些简单的效果开始做。所以打算借这个 小游戏[1] 来认真学习一下 Three.js 。在线预览https://liamwu50.github.io/three-sokoban-live/游戏介绍"推箱子" 游戏最早是由日本游戏开发者Hiroyuki Imabayashi 于1982年开发和发布的。这款游戏的日...

frontend_frank的博客 1367

Three.js 动作冒险3D页游开发进阶:从「技术落地」到「体验闭环」的全链路优化革命

本文探讨了游戏开发中全链路体验优化的关键技术,重点从加载、运行和交互三个阶段提升玩家体验。在加载阶段,通过渐进式加载和动态渲染减少等待时间;运行阶段采用智能调度技术动态调整资源分配,保持帧率稳定;交互阶段则通过多感官反馈增强操作体验。文章还介绍了数据驱动的持续优化方法,强调从玩家视角出发,将技术优化转化为沉浸式游戏体验。通过Three.js的具体代码示例,展示了如何实现这些优化策略,旨在帮助开发者打造更流畅、更具吸引力的游戏体验。

fearhacker的专栏 1015
上一篇: 智启未来:TinyEngine 低代码引擎版本更新,畅享升级体验
下一篇: 前十名单公布|OpenTiny 前端 Web 应用开发挑战赛初赛结果揭晓~
OpenTiny社区
博客等级 码龄4年 1540粉丝 266原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值