用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

海康VisionMaster图形定位实战:十字形Mark点精准匹配技巧

本文深入解析了海康VisionMaster图形定位模块在工业自动化中实现十字形Mark点精准匹配的实战技巧。文章从ROI设置、核心参数调优(如定位方式、尺度模式、对比度阈值)到工业场景应用与避坑指南,系统性地分享了提升匹配成功率和稳定性的关键步骤,帮助工程师应对复杂现场环境,实现高效、可靠的视觉定位。

weixin_29285605的博客 260

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

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

OpenTiny的博客 1699

安卓风控对抗实战:如何用AOSP定制ROM绕过Momo、AIDA64等检测(从环境搭建到设备画像)

本文详细解析了如何通过定制AOSP ROM绕过Momo、AIDA64等安卓风控检测工具的设备指纹识别。从环境搭建到硬件参数修改,再到内核级特征混淆,提供了一套完整的底层对抗方案,显著提升设备伪装通过率。

weixin_30687051的博客 490

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

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

frontend_frank的博客 533

数字IC后端实战:OCC电路时钟树综合的5个关键技巧(附Synopsys工具配置)

本文深入探讨了数字IC后端设计中OCC电路时钟树综合的实战技巧。针对工具自动平衡导致的绕线拥塞和时序问题,文章提出了分段处理的核心思路,并详细阐述了通过物理隔离、分段长树策略以及Synopsys工具链(如ICC2、PrimeTime)的精准配置来优化function clk与occ clk路径的关键方法,旨在提升时序收敛性和可制造性。

jwt8token的博客 855

基于STM32环境温湿度监测系统设计

基于STM32环境温湿度监测系统设计1. 实验目的2. 任务和要求3. proteus仿真图4. 实验代码 1. 实验目的 (1) 掌握使用 KEIL MDK5 进行软件开发的具体流程; (2) 掌握硬件电路的设计方法; (3) 掌握 STM32 微控制器的程序设计及代码编写方法。 2. 任务和要求 (1)在 STM32F103 平台下,通过温度传感器 DS18B20 和湿度传感器 DHT11 实现环境温湿度信息的采集; (2)将温度传感器和湿度传感器所采集的数据在 LCD 液晶屏幕上显示出来,超

唯一的博客 2万+

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

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

趣谈前端 527

不刷机!Jetson AGX Orin无损更新内核与设备树的两种安全方法(附备份恢复脚本)

本文详细介绍了Jetson AGX Orin在不刷机的情况下安全更新内核与设备树的两种方法,包括extlinux.conf动态加载和分区精准刷新方案。通过备份恢复脚本和工业级灾备方案,确保零数据丢失,适用于边缘AI和机器人等关键场景。

weixin_40192258的博客 308

所有博客导航

。。

nameofcsdn的博客 1万+

【MATLAB代码】TDOA+TOA融合算法,高精度定位。通过多边定位解算4个锚点、平面目标的位置,附代码

二维平面下的TOA、TDOA及混合定位算法,通过4个锚节点对目标点进行定位。

985工科博士毕业,专攻定位、导航和滤波等算法研究。从业10年,主要使用MATLAB 211

(四)C++自制植物大战僵尸游戏启动流程

鼠标左键单机VS2022上方工具栏中进行项目启动。第一次启动项目需要编译项目中所有代码文件,编译生成需要一定的时间。不同性能的电脑需要消耗的时间差别可能较大,通常在几分钟到几十分钟之间。编译成功后会自动启动运行游戏。qi编译成功后自动启动运行游戏如下图。

尔灵尔亿的博客 2466

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

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

gudada010的博客 1278

科研级YOLOv8 Neck革新:BiFPN双向特征金字塔赋能,让多尺度融合更高效的实战指南

在文件夹下新建BiFPN.py,将上述BiFPNLayer和BiFPN类的代码写入其中。这一步是为了让YOLOv8的代码架构能识别我们的自定义Neck模块。

m0_64661770的博客 153

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

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

sirtzhh007的博客 1998

Camera2Demo

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

计算机控制技术及应用

《计算机控制技术及应用》通过总结作者的科研成果和吸收国内外的先进理论、方法和技术,以计算机控制系统的网络化、开放化、智能化和集成化发展趋势为主线索,主要介绍计算机控制系统的基本概念和计算机控制系统的典型形式,计算机控制系统的数学描述与基本分析方法,以及数字PID控制器、最少拍控制系统、达林算法、状态变量设计法等设计方法及其各种改进算法。重点突出计算机控制系统输入输出通道接口的设计方法和典型控制系统的设计案例与实现方法等。

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

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

理发 868

IEM模型,地表粗糙度模拟

利用微波反演土壤水分时候,利用IEM模型可以实现对随机地表的模拟

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

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

骑着牛的奇兵的博客 2315

LinePhone3.2.7(android端最新版本) sip客户端

SIP客户端For android:LinePhone3.2.7(最新版本),官网地址:http://www.linphone.org/

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、付费专栏及课程。

余额充值