您的位置:  首页 > 技术杂谈 > 正文

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

2024-04-09 11:01 https://my.oschina.net/u/6769809/blog/11051347 OpenTiny社区 次阅读 条评论

接上期:《用three.js做一个3D汉诺塔游戏(上)》

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


为桌台添加材质纹理

为物体添加适当的材质纹理,可以使其视觉效果产生质的飞跃。接下来,我们将为桌台添加一种木质纹理,用到的纹理贴图来自Pixabay.com

我们使用 TextureLoader 来加载纹理贴图,其 load 方法第1个参数为贴图的 URL 字符串,该方法返回一个纹理对象,可直接赋值给材质对象的颜色贴图属性 map。代码实现如下:

class Table {
  constructor({ width, height, depth }) {
    const geometry = new THREE.BoxGeometry(width, height, depth);
    // 纹理贴图
    const url = 'https://cdn.pixabay.com/photo/2016/12/26/13/47/fresno-1932211_1280.jpg';
    const material = new THREE.MeshLambertMaterial({ 
      color: '#cccca6',
      map: new THREE.TextureLoader().load(url)  // 纹理贴图
    });
    
    return new THREE.Mesh(geometry, material);
  }
}

然而,我们发现这样做并不完美:由于纹理贴图存在网络加载延时,所以在贴图加载完成前,桌台始终是黑色的,只有贴图加载完成后,桌台才一瞬间有了外观。如下图所示:

对此,我们需要在技术上做出一些改进,来解决纹理贴图加载前后变化的突兀感。这里有2种改进方案:

  1. 预加载,等纹理贴图加载完成后,再生成带纹理效果的桌台;
  2. 渐进式加载,桌台先显示默认颜色,等纹理贴图加载完成后,再附加纹理效果。

这里我们选择方案2,因为方案2不会阻塞桌台的渲染,有着更好的用户体验。渐进式加载的原理就是在贴图加载完成后,标记材质对象的 needsUpdate 属性为 true,这样渲染器会在下一个渲染循环动态更新材质的纹理。核心代码如下:

const material = new THREE.MeshLambertMaterial({ color: '#cccca6' });

// 动态更新材质纹理
new THREE.TextureLoader().load(url, (texture) => {
  material.needsUpdate = true;
  material.map = texture;
});

加载效果如下图所示:

优化光照效果

在 three.js 中,反光材质的物体表面会因为光照的不同而呈现出不同的明暗效果,其中光源的强弱、照射面和光线夹角等参数都会对物体的渲染效果产生影响。目前我们的场景效果并不理想:柱杆看上去灰蒙蒙的,盘子则是透出一股廉价的塑料味,都缺乏真实感。正所谓,效果不够,光照来凑,我们来调整光源参数,优化光照效果,让场景更加自然、真实。

让我们先对 Lights 类进行改造,新增一个距离参数,作为调整光源位置的基准值。我们将桌台长度、柱杆高度和桌台宽度分别作为光源在 x、y、z 方向上的位置基准值进行传递,以便于更加精确地设置光源位置,达到更好的照明效果。

class Lights {
  constructor({ directionX, directionY, directionZ }) {...}
}

const presenter = {
	init() {
    ...
    const lights = new Lights({
      directionX: model.tableSize.width,
      directionY: model.pillarSize.height,
      directionZ: model.tableSize.depth
    });
  }
  ...
};

接下来,我们需要对之前已有的平行光源位置进行调整。为了更直观地调试光照效果,我们可以添加 DirectionalLightHelper 来帮助我们更好地观察光源位置平面和光照方向。

class Lights {
  constructor({ directionX, directionY, directionZ }) {
    const ambientLight = new THREE.AmbientLight('#fff', 1);  // 环境光

    const directLight = new THREE.DirectionalLight('#fff', 3);  // 平行光
    directLight.position.set(-directionX / 3, directionY * 4, directionZ * 1.5);

    const directLightHelper = new THREE.DirectionalLightHelper(directLight, 1, '#f00');

    return [ambientLight, directLight, directLightHelper];
  }
}

经过这一步平行光源的位置调整,我们看到柱杆和盘子已经变得光滑透亮。(下图中的红线为平行光源辅助观察线)

最后我们再添加一个米黄色的聚光灯光源,中和下场景的“高冷”基调。

class Lights {
  constructor(...) {
    ...
    const spotLight = new THREE.SpotLight('#fdf4d5');
    spotLight.position.set(5, directionY * 4, 0);
    spotLight.angle = Math.PI / 2;  // 光线照射范围角度
    spotLight.power = 2000;  // 光源功率(流明)
    const spotLightHelper = new THREE.SpotLightHelper(spotLight, '#00f');

    return [ambientLight, directLight, directLightHelper, spotLight, spotLightHelper];
  }
}

完成后的效果如下图所示:(下图中的蓝线为聚光灯光源辅助观察线)

开启阴影效果

伴随着光源一起的自然是阴影,开启阴影能显著增强物体的立体效果。在现实世界中,阴影的产生需要光源、被照射物体和显示阴影的地方,这三者缺一不可。

在 three.js 中,出于性能考虑,实时渲染的阴影默认是关闭的,如果想要实现阴影效果,需要进行一番设置。与现实世界阴影的生成相似,这些设置都与光源、被照射物和阴影显示物有关,下面我们来逐一进行设置。

  1. 渲染器 开启阴影渲染支持

    const rendererView = {
      init(...) {
        ...
        this.renderer.shadowMap.enabled = true;
        this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
      },
      ...
    };
    

    THREE.PCFSoftShadowMap 是 Three.js 中的一种阴影映射技术,它使用了 Percentage Closer Filtering (PCF) 和 Soft Shadows 技术来实现更加真实的阴影效果。PCF 技术可以减少阴影的锯齿感,Soft Shadows 技术可以让阴影边缘更加柔和。

  2. 光源 开启阴影投射,使被照射物体产生阴影

    directLight.castShadow = true;  // 为平行光源开启阴影投射
    
  3. 调整光源的 阴影相机 参数,控制阴影的渲染范围到合适大小

    超出阴影相机范围的阴影不会被渲染,所以要将阴影相机的范围扩大到能完整包含柱杆和盘子。使用 CameraHelper 辅助对象可以帮助我们更好的观测阴影相机的视野范围。

    directLight.shadow.camera.left = -directionX;
    directLight.shadow.camera.right = directionX;
    directLight.shadow.camera.top = directionZ;
    directLight.shadow.camera.bottom = -directionZ;
    
    const shadowCamera = new THREE.CameraHelper(directLight.shadow.camera);
    
  4. 允许 被照射物体 产生阴影

    这里设置允许柱杆和盘子产生阴影,并且允许其他物体产生的阴影可以投射到它们表面(接收阴影)。需要注意,castShadowreceiveShadow 要设置到 Mesh 对象上,不能设置到 Group 上。

    /* 柱杆 */
    class Pillar {
      constructor(...) {
        ...
        const body = new THREE.Mesh(geometry, material);
        body.castShadow = true;  // 允许产生阴影
        body.receiveShadow = true;  // 允许接收阴影
        ...
      },
      ...
    }
    
    /* 盘子阴影设置同上 */
    class Plate {...}
    
  5. 设置接收并 显示阴影 的物体

    此处设置桌台来接收柱杆和盘子产生的阴影。

    class Table {
      constructor(...) {
        ...
        const mesh = new THREE.Mesh(geometry, material);
        mesh.receiveShadow = true;
    		...
      },
    	...
    }
    

开启阴影后的效果如下图所示:(图中包含阴影相机辅助观察线)

我们很快发现在实现阴影效果后,盘子的表面布满了“皱纹”,变得不自然起来,正如下图所示:

这是因为盘子材质对象的 side 使用了 THREE.DoubleSide,材质会渲染内外两层(FrontSide、BackSide),这两层都会产生阴影,造成阴影交错重叠,于是便出现“皱纹”。要修复这个问题,我们只需设置盘子材质的阴影只来自外层(FrontSide)即可:

const material = new THREE.MeshLambertMaterial({
  color,
  side: THREE.DoubleSide
});
material.shadowSide = THREE.FrontSide;  // 只生成外层阴影

效果如下:

另外一个小细节:柱杆下方的底座有一定的高度,其直径大于盘子孔径,所以按照物理定律,最下面的盘子会被撑开一定高度,与桌面形成一个间隙。让我们在代码中来完善这一细节:

const presenter = {
  ...
  addPlates() {
  	...
    const { baseHeight } = model.pillarSize;

    Array.from({ length: nums }).forEach((v, i) => {
      ...
      const plate = new Plate(...);

      plate.position.y = tableHeight + plateHeight * i + baseHeight;  // 加上柱杆底座高度
      group.add(plate);
    });
    ...
  }
};

现在最下方的盘子没有直接贴在桌面上了,它与桌面形成了与柱杆底座高度一致的间隙,同时盘子下方的阴影也更加自然了。效果如下图所示:

交互设计

在现实世界中,汉诺塔游戏是用手抓放盘子进行从柱杆移出、移动位置和放入柱杆这三种操作的,如果想要在游戏中还原类似操作的话,就要使用鼠标拖拽的方式进行模拟。然而,这实现起来并不容易:

  1. 鼠标拖拽与 OrbitControls 的场景镜头旋转操作是存在冲突的,要同时兼容这两种操作还是有点棘手的;
  2. 按照游戏的真实物理设定,盘子在从柱杆中移出前,只能垂直上下移动,移出后才可自由移动,这里得考虑各种边界限定的问题,而且非常容易出 bug;
  3. 鼠标拖拽的精度和手抓放的精度是不一样的,对玩家的操作要求很高,会直接影响游戏可玩性,想要保障用户体验的话,还得写碰撞检测来辅助玩家的操作,可谓相当麻烦。

由此可见,无论是出于开发难度、开发工作量还是用户体验考量,我们都没有必要一味追求还原真实操作,我们应当将重点放在提高游戏的可玩性和趣味性上。

我们最终选择了简单的鼠标悬浮、点击来交互。具体来说,当玩家用鼠标悬浮到可交互的盘子或柱杆时,我们会立即给出一个视觉反馈,提示玩家当前物体可交互;当玩家点击可交互的盘子时,会根据预设的移动路径来自动完成盘子的移动过程,并且通过过渡动画让移动过程更加丝滑。这种交互方式不仅简单易懂,而且能够提高游戏的可玩性和趣味性,对应的开发工作量也少了很多。

动画设计

根据设定,我们需要为盘子的四种交互形态设计动画,包括可交互(悬停/离开)、从柱杆移出、平移和放入柱杆。

盘子形态触发方式动画设计相关属性
可交互态鼠标悬停或离开鼠标悬停时,盘子抬起一小段距离,同时尺寸略微变大。鼠标离开时,恢复原始位置和大小。position、scale
从柱杆移出点击盘子盘子沿柱杆向上移动(y 轴方向),直至从柱杆完全脱出,再倾斜一定角度后停止(增加趣味性)。position、rotation
平移点击柱杆盘子水平移动到所点击柱杆的正上方,然后开始“放入柱杆”(接下一形态)。position
放入柱杆点击柱杆盘子回正倾斜,然后沿柱杆向下移动(y 轴方向),直到柱杆底部停止,同时恢复原始大小,如柱杆中已有其他盘子,则位置停在最顶层的盘子上方。position、rotation、scale

扩展盘子和柱杆的代码

本章节可能是本文中最枯燥的部分,它包含大量逻辑性的代码片段,又无法直接呈现效果。但这部分内容却是非常重要的,因为后续的交互功能都是在此基础上完成的,所以请耐心阅读。

根据我们的交互设计和动画设计,我们计划将盘子的交互和动画能力封装在 Plate 类内部,以实现高内聚的代码。此外,盘子和柱杆之间存在包含关系,因此我们在代码中也将这一关系抽象出来,为 Pillar 类扩展添加盘子、移除盘子等能力,从而使逻辑实现更加清晰易懂。

维护盘子状态数据

我们将 Plate 类设置为继承自 THREE.Mesh,以便让它具备 Mesh 的能力。在游戏过程中,为了方便进行逻辑处理,我们还需要维护盘子自身的一些状态信息,这些信息包括:

  1. size - 盘子的尺寸信息,用于位置计算;
  2. order - 盘子的编号,用来做盘子的堆叠逻辑判断,例如大盘子不能放置在小盘子上面;
  3. offsetY - 缓存盘子当前的原始堆叠位置(y 轴方向),用于在可交互态鼠标离开盘子后,盘子回到原位;
  4. pillarInfo - 盘子所属柱杆的信息,用于移出逻辑的处理;
  5. pickable - 盘子是否允许拾取(是否可交互);
  6. picked - 盘子是否已被拾取。

我们将上述信息存储到 userData 中,代码如下:

class Plate extends THREE.Mesh {
  constructor(size, color, i) {
    super();

    ['size', 'order', 'offsetY', 'pillarInfo', 'pickable', 'picked'].forEach((key) => {
      Object.defineProperty(this, key, {
        get() {
          return this.userData[key];
        },
        set(value) {
          this.userData[key] = value;
        }
      });
    });

    this.size = size;
    this.order = i;

    this.geometry = this.#createGeometry();
    this.material = new THREE.MeshLambertMaterial(...);
    this.material.shadowSide = THREE.FrontSide;
    this.castShadow = true;
    this.receiveShadow = true;
    const text = this.#createLabel(i);
    this.add(text);
  }
  ...
}

实现盘子补间动画

为了方便动画编排,我们引入了 tween.js 这个库来处理动画逻辑,下面我们来利用 tween.js 实现盘子的过渡动画。

  • tweenHover - 抬起/放下(鼠标悬停/离开)

    根据我们的动画设计,此处包含抬起/放下(position.y)和缩放(scale)两种过渡动画,而且是同时进行的。对此,我们分别编写这两种动画,再使用 TWEEN.Group 将它们合并为一个补间动画集,以支持两个动画同时进行。代码实现如下:

    class Plate extends THREE.Mesh {
      ...
      #tweenHover(isHover) {
        const { height } = this.size;
        const tweenGroup = new TWEEN.Group();
        const scaleValue = isHover ? 1.1 : 1;  // 缩放比
        // 缩放动画
        const scaleTween = new TWEEN.Tween(this.scale)
          .to({ x: scaleValue, y: scaleValue, z: scaleValue }, 200)
          .start();
        // 抬起/放下动画
        const liftTween = new TWEEN.Tween(this.position)
          .to({ y: this.offsetY + (isHover ? height / 2 : 0) }, 200)
          .start();
    
        tweenGroup.add(scaleTween, liftTween);
        tweenGroup.update();
      }
    }
    
  • tweenPickUp - 拾取(从柱杆移出)

    "盘子拾取"包含两个过渡动画:垂直移动(position.y)和倾斜(rotation)。为了让这两个动画依次进行,我们使用了 chain(链接补间)来连接它们。同时,我们还为位置移动的补间动画添加了 TWEEN.Easing.Quadratic.Out 缓动参数,以模拟拾取盘子的真实运动效果。下面是代码实现:

    class Plate extends THREE.Mesh {
      ...
      #tweenPickUp() {
        const { height } = this.pillarInfo.size;
        const upDistence = height + height / 2;
        const angleRad = THREE.MathUtils.degToRad(15);  // 倾斜角度(转为弧度)
        const slantTween = new TWEEN.Tween(this.rotation)
          .to({ x: angleRad }, 150);
    
        return new TWEEN.Tween(this.position)
          .to({ y: upDistence }, 200)
          .easing(TWEEN.Easing.Quadratic.Out)
          .chain(slantTween)
          .start();
      }
    }
    
  • tweenPutIn - 放入柱杆(含平移到柱杆动画)

    “放入柱杆”动画是由平移(position.x)、回正倾斜(rotation)、缩放(scale)和放下(position.y)四个过渡动画组成的,其中最后两个过渡动画在前两个动画依次完成后同时进行。这里有个特殊场景,就是拾取的盘子又放回了原柱杆,此时无需进行平移,直接进行后续的动画即可。

    这四个过渡动画中的最后一个“放下”动画比较特殊,需要动态计算目标柱杆中盘子的总堆叠高度,用来决定当前盘子的最终位置。所以我们需要先扩展一个获取当前柱杆可放置盘子位置的方法,代码实现如下:

    class Plate extends THREE.Mesh {
      ...
      getPlacementPosition() {
        const { height: pillarHeight, baseHeight, plateHeight } = this.userData.size;
        // 与坐标原点的 y 距离
        const distanceOriginY = this.position.y - pillarHeight / 2;
        const startY = plateHeight / 2 + distanceOriginY + baseHeight;  // 柱杆底部y轴坐标
        const stackHeight = this.plates.length * plateHeight;  // 堆叠高度
        const vector = new THREE.Vector3();
    
        vector.copy(this.position);
        vector.setY(startY + stackHeight);
    
        return vector;
      }
    }
    

    该方法会返回一个当前可放置盘子位置的三维向量,有了这个坐标向量,我们就可以控制盘子放入柱杆中的位置了。下面是 tweenPutIn 方法的代码实现:

    class Plate extends THREE.Mesh {
      ...
      #tweenPutIn(pillar) {
        const currentPillar = presenter.getPillar(this.pillarInfo.tag);
        const targetPillar = pillar || currentPillar;
        const isSamePillar = currentPillar.id === targetPillar.id;
        const placementPosition = targetPillar.getPlacementPosition();
    
        // 回正倾斜
        const slantTween = new TWEEN.Tween(this.rotation)
          .to({ x: 0 }, 150);
    
        // 平移
        const panTween = new TWEEN.Tween(this.position)
          .to({
            x: targetPillar.position.x,
            y: this.position.y
          }, isSamePillar ? 0 : 450)  // 如果是同一个柱杆,无需平移(无过渡时间)
          .easing(TWEEN.Easing.Quadratic.Out);
    
        // 放下
        const putdownTween = new TWEEN.Tween(this.position)
          .to({ y: placementPosition.y }, 400)  // 放入的最终位置
          .easing(TWEEN.Easing.Quadratic.Out);
    
        // 缩放
        const scaleTween = new TWEEN.Tween(this.scale)
          .to({ x: 1, y: 1, z: 1 }, 200);
    
        // 动画编排
        slantTween.chain(putdownTween, scaleTween);
        panTween.chain(slantTween).start();
    
        return putdownTween;
      }
    }
    

    方法最后,我们返回了 putdownTween,这是因为后续的一些逻辑处理需要等待补间动画完成后才能进行。通过返回 Tween 实例,我们可以在动画完成后触发 onComplete 回调函数,以便进行后续的逻辑处理。

完成补间动画定义后,我们再为 Plate 类扩展两个公共方法,用来调用这些私有动画方法。代码如下:

class Plate extends THREE.Mesh {
  ...
  
  hover(isHover) {
    this.#tweenHover(isHover);
  }

  appendTo(pillar) {
    return this.#tweenPutIn(pillar);
  }
}

存储盘子实例

类似于 Plate 类,我们为 Pillar 类继承 THREE.Group,并抽象出一个 plates 属性,用来存储放入当前柱杆的盘子实例。

class Pillar extends THREE.Group {  
  ...
  constructor(...) {
    super();
    
    ...
    this.plates = [];  // 存储放入的盘子实例
    ...
    this.add(body, base, text);
  }
  
  popOutPlate() {  // 移除盘子方法
    const topPlate = this.plates.pop();

    if (this.plates.length) {
      // 弹出顶层盘子后,其下方盘子允许拾取(可交互)
      this.plates.slice(-1)[0].pickable = true;
    }

    return topPlate;
  }
}

为柱杆添加盘子

我们之前在代理层的 addPlates 方法中,是将所有盘子设置好位置,再逐一放到一个 Group 中定位,最后添加到场景中。现在我们已经将 Pillar 类中的 plates 属性抽象出来,用于映射游戏中将盘子添加到柱杆的逻辑。因此,根据代码设计的职责分离原则,既然我们要为柱杆添加盘子,那么将这些细节放到 Pillar 类中处理会更加合适。

我们为 Pillar 类扩展一个 addPlate 方法来添加盘子,这里需要考虑以下两点:

  1. 盘子过渡动画只用在交互环节,初始化时是一次性添加所有盘子,并不需要过渡动画,需要区别处理这两种场景;
  2. 盘子的过渡动画是一个耗时异步任务,而将盘子实例 push 到 Pillar 类的 plates 数组中是一个同步任务,需要注意逻辑顺序,否则导致盘子在过渡动画结束后并没有落在预期的位置上。

代码实现如下:

class Pillar extends THREE.Group {  
  ...
	addPlate(plate, animateCallback) {
    if (this.plates.length) {
      // 当前柱杆最顶层的盘子不可拾取(即将被盖住)
      this.plates.slice(-1)[0].pickable = false;
    }

    plate.pickable = true;

    if (typeof animateCallback === 'function') {
      plate.appendTo(this).onComplete(() => {  // 过渡动画完成后再进行数据更新
        plate.offsetY = plate.position.y;
        plate.pillarInfo = this.userData;
        plate.picked = false;
        this.plates.push(plate);
        animateCallback();  // 过渡动画结束后回调
      });
      return;
    }

    /* 初始化时直接添加的场景 */
    const vector = this.getPlacementPosition();

    plate.position.copy(vector);
    plate.offsetY = vector.y;
    plate.pillarInfo = this.userData;
    plate.picked = false;
    this.plates.push(plate);
  }
}

为方便全局调用 Pillar 类的方法,我们在模型层存储柱杆的引用,并在代理层新增 getPillar 方法以供全局获取柱杆数据:

const model = {
  ...
  pillarsMap: new Map(
    ['A', 'B', 'C'].map(k => [k, null])
  )
};

const presenter = {
  ...
  getPillar(tag) {
    return model.pillarsMap.get(tag);
  }
};

既然已经将添加盘子的细节封装到 Pillar 类内部了,那么代理层的 addPlates 方法就可以更加简洁明了:

const presenter = {
  ...
  addPlates() {
    ...

    Array.from({ length: nums }).forEach((v, i) => {
      ...
      this.getPillar('A').addPlate(plate);  // 为柱杆A添加盘子
    });

    model.scene.add(...this.getPillar('A').plates);  // 将所有盘子添加到场景中
  }
};

实现交互

原理

在 three.js 中,如果想要实现与 3D 物体的交互,并不能像在 DOM 中那样直接为 3D 物体绑定事件来完成。three.js 中使用了一种叫光线投影的技术(Raycaster)来捕捉物体,其原理是从相机朝屏幕交互坐标点发射一条无限长的射线,检测射线与场景中的物体是否相交,并按照由近到远的顺序返回所有相交的物体信息。这样,我们就可以从中筛选出实际想要交互的物体,来进行交互处理了。

例如:当我们在屏幕上用鼠标点击场景时,如何获取该点击所在的 3D 物体(如果有的话)呢?

当鼠标点击屏幕,有了交互点 p(图中红点),相机发出一条射线(图中红线),射线的方向由交互点 p 确定。如果这条射线最终穿过了场景中的绿色 3D 物体(与之相交),则返回的 Raycaster 对象数组中就会包含这个 3D 物体的信息,反之则不会包含这个 3D 物体的信息。

需要注意的是,Raycaster 可以检测到 MeshAxesHelperGridHelper 等对象,但是不能直接检测 Group,只能检测 Group 中的上述对象。

初始化

首先,我们在代理层新增一个 initInteraction 方法,并在其中创建一个 Raycaster 实例,以及一个 THREE.Vector2 对象,用于存储鼠标的位置。

const presenter = {
  init() {
    this.initInteraction();  // 初始化事件交互
  },
  ...
  initInteraction() {
    this.raycaster = new THREE.Raycaster();
    this.mouse = new THREE.Vector2();
	}
};

然后,定义一个 getIntersects 方法,该方法接收一个屏幕坐标参数,最终返回与射线相交的 Raycaster 对象数组,以便我们获取当前交互的 3D 物体。

const presenter = {
  ...
  getIntersects({ clientX, clientY }) {
    const { width, height, top, left } = containerView.el.getBoundingClientRect();
		
		// 坐标归一化
    this.mouse.x = (clientX - left) / width * 2 - 1;
    this.mouse.y = -(clientY - top) / height * 2 + 1;
    this.raycaster.setFromCamera(this.mouse, cameraView.camera);  // 设置射线的起点和方向

    return this.raycaster.intersectObjects(model.scene.children); // 返回场景中与射线相交的物体
  }
};

在上面的代码中: mouse.xmouse.y 的值是通过将鼠标指针的屏幕坐标转换为标准化设备坐标(NDC)(或称为归一化坐标)来计算得到的;event.clientXevent.clientY 是鼠标事件对象中的属性,表示鼠标指针在窗口中的坐标位置。

关于归一化坐标相关知识,请参阅:https://zhuanlan.zhihu.com/p/429526076

监听交互事件

我们通过监听 three.js 实例所在 DOM 容器的交互事件的方式,来间接地获取场景中 3D 物体的交互事件,同时结合光线投影技术,获取交互的 3D 物体对象。在我们的游戏中,柱杆和盘子是需要交互的对象,交互方式为 hover 和 click。对于 click 交互,我们可以直接监听现成的 click 事件。但对于 hover 交互,由于交互事件是监听整个 DOM 容器的,我们无法通过监听 mouseentermouseleave 事件来区分场景中 3D 物体的鼠标悬停和离开。“悬停”和“离开”都是指针的移动事件,因此,我们在这里通过监听 pointermove 事件来实现 hover 交互。

绑定事件监听的代码如下:

/* 容器 */
const containerView = {
  ...
  listenEvent(evtName, cb) {
    this.el.addEventListener(evtName, cb, false);
  }
};

const presenter = {
  ...
  initInteraction() {
    this.raycaster = new THREE.Raycaster();
    this.mouse = new THREE.Vector2();

    // 指针移动事件
    containerView.listenEvent('pointermove', (e) => {
      const object = this.getIntersects(e)[0];

      this.handlePlateHover(object);
      this.handlePillarHover(object);
    });

    // 鼠标点击
    containerView.listenEvent('click', (e) => {
      const object = this.getIntersects(e)[0];

      this.handlePlateClick(object);
      this.handlePillarClick(object);
    });
	},
  handlePlateHover() {...},
  handlePlateClick() {...},
  handlePillarHover() {...},
  handlePillarClick() {...}
};

响应交互

为方便筛选交互元素,我们为可交互物体的 Mesh 设置 name 值,剔除与交互无关的对象。这里只需为柱杆和盘子的 Mesh 设置 name 即可。

/* 柱杆 */
class Pillar extends THREE.Group {
  ...
  constructor(...) {
    ...
    const parts = [body, base, text].map(part => {
      part.name = 'pillar';  // 柱杆的各个部件设置 name
      return part;
    });
		...
  }
}

/* 盘子 */
class Plate extends THREE.Mesh {
  ...
  constructor(...) {
    ...
    this.name = 'plate';  // 盘子设置 name
  }
}

const presenter = {
  ...
  getIntersects(...) {
    ...
    // 移除无name值的对象(与交互无关的物体)
    return this.raycaster.intersectObjects(model.scene.children)
      .flatMap(({ object }) => object.name ? [object] : []);
  }
};

下面以盘子为例,为其完善鼠标 hover 交互的响应逻辑。

我们通过监听 pointermove 事件来实现 hover 交互,但如何区分鼠标在场景中 3D 物体上的悬停和离开交互?这里我们在模型层加一个 lastHoveredPlate 属性,默认为 null,每次触发 pointermove 事件时就用返回的 Raycaster 对象更新这个值。通过判断 lastHoveredPlate 和本次的 Raycaster 对象是否为同一个,就能区分悬停和离开这两种交互了。

代码实现如下:

const model = {
  ...
  lastHoveredPlate: null
};

const presenter = {
  ...
  handlePlateHover(plate) {
    const lastPlate = model.lastHoveredPlate;
    const resetLastPlate = () => {
      // 移除上一次悬浮的盘子(如有)的样式
      if (lastPlate && !lastPlate.picked) {
        lastPlate.hover(false);  // 恢复原位
        model.lastHoveredPlate = null;
      }
    };

    if (plate?.name !== 'plate') {  // 当前悬浮物体不是盘子
      resetLastPlate();
      return;
    }

    if (!plate.pickable) {  // 不允许拾取
      resetLastPlate();
      return;
    }

    if (plate.id !== lastPlate?.id) {  // 当前悬浮物体不是上一次悬浮的盘子
      resetLastPlate();
      plate.hover(true);  // 悬停动画
      model.lastHoveredPlate = plate;
    }
  }
}

下面再以柱杆为例,为其完善鼠标 click 交互的响应逻辑。

根据我们的交互设定,当盘子被点击拾取后,再点击柱杆,盘子就会放入点击的柱杆中(包含过渡动画)。由于柱杆是一个 Group,所以射线检测并返回的是柱杆内部的 Mesh 对象,我们可以通过获取其 parent 来拿到柱杆实例,从而操作柱杆的数据。代码如下:

const presenter = {
  ...
  handlePillarClick(pillarPart) {
    if (pillarPart?.name !== 'pillar') {  // 当前点击的不是柱杆
      return;
    }

    const pillar = pillarPart.parent;  // 柱杆实例
    const pickedPlate = model.currentPickedPlate;

    if (pickedPlate) {  // 已有拾取的盘子
      const targetTopPlate = pillar.plates.slice(-1)[0];  // 目标柱杆最顶层的盘子

      // 判断是否满足放置条件
      if (targetTopPlate && targetTopPlate.order < pickedPlate.order) {
        return;
      }

      pillar.addPlate(pickedPlate, () => {
        if (pillar.tag === 'C') {
          // 检查是否过关
        }
      });
      model.currentPickedPlate = null;
    }
  }
};

优化交互体验

在测试中,我们发现游戏存在两个体验不佳的地方:

  1. 拾取盘子后,有些柱杆不允许放置(大盘子不能放在小盘子上方),但没有任何提示;
  2. 柱杆太细,交互时鼠标不容易点到它。

这两个问题的核心是缺乏即时反馈和可交互区域过小。

优化交互即时反馈

为了解决第一个问题,我们采取以下措施:

  1. 增加鼠标指针的变化,当悬停在可交互物体上时,指针变为手型,否则指针恢复默认。

    我们可以为容器新增一个切换指针样式的方法,以便根据交互情况灵活地控制指针样式的变化。代码如下:

    /* 容器 */
    const containerView = {
      ...
      togglePointer(intersecting) {
        this.el.style.setProperty('cursor', intersecting ? 'pointer' : 'default');
      }
    };
    
  2. 增加一个放置占位提示,标记出盘子将会放入哪个柱杆中,如条件不允许放置的,则不显示放置占位提示。

    占位提示核心原理就是当有盘子被拾取时,从该盘子克隆出一个盘子轮廓,并根据鼠标悬停的柱杆是否允许放置来显示轮廓,以此来指示放置位置。代码实现如下:

    /* 盘子放置指示 */
    const placementView = {
      init(scene) {
        this.ghostPlate = new THREE.Mesh();
        scene.add(this.ghostPlate);
      },
      createFrom(plate) {
        presenter.dispose3dObject(this.ghostPlate);  // 销毁已有的占位指示
    
        this.ghostPlate.visible = false;
        this.ghostPlate.geometry = plate.geometry.clone();
        this.ghostPlate.material = plate.material.clone();
        this.ghostPlate.material.transparent = true;
        this.ghostPlate.material.opacity = 0.5;
      },
      display(position) {
        this.ghostPlate.visible = Boolean(position);
    
        if (position) {
          this.ghostPlate.position.copy(position);
        }
      }
    };
    
    const presenter = {
      init() {
        ...
        placementView.init(model.scene);
      },
      dispose3dObject(obj) {
        if (obj.geometry) {
          obj.geometry.dispose();  // 清除几何体
        }
    
        if (obj.material) {
          obj.material.dispose();  // 清除材质
        }
    
        if (Array.isArray(obj.children)) {
          obj.children.forEach((child) => {
            this.dispose3dObject(child);  // 递归
          });
        }
      }
      ...
    };
    

    注:上面代码中有个销毁 3D 对象的方法,这是用来释放已废弃或未使用的 3D 资源,避免内存泄露。three.js 中直接将 Mesh(网格)从场景中移除,Mesh 中的 Geometry(几何体)和 Material(材质)对象并不会被自动释放,相反,这些资源必须使用 API 来手动释放。 详情:https://threejs.org/docs/#manual/zh/introduction/How-to-dispose-of-objects

做完以上两步,我们需要在交互操作时调用相应方法来切换鼠标指针样式和显示放置位置提示,代码较多,这里不再展示,请参阅源码中 initInteraction、handlePlateHover、handlePlateClick、handlePillarHover 和 handlePillarClick 方法的实现。

优化完毕的效果如下:

优化可交互区域

第二个问题是由于柱杆太细导致交互区域过小造成的。在 Web UI 中,我们经常通过增加 paddingborder-width 等措施扩大可交互区域的面积,方便用户操作。

插图来自:https://ishadeed.com/article/clickable-area/

同理,在我们的场景中,我们可以为柱杆套个“holder”,使柱杆变得又粗又长,增加可交互区域面积,提升用户体验。

为避免影响视觉效果,我们设置这个 holder 不可见。相关代码如下:

class Pillar extends THREE.Group {
  ...
  constructor(...) {
    ...
    const holder = this.#createHolder(body);

    const parts = [body, base, text, holder].map(part => {
      part.name = 'pillar';
      return part;
    });

    this.add(...parts);
  }
  ...
  #createHolder(body) {
    const mesh = body.clone();
    const { height } = this.userData.size;
    // 与坐标原点的 y 距离
    const distanceOriginY = this.position.y - height / 2;
    const scaleY = 1.5;

    mesh.visible = false;  // 不显示
    mesh.position.copy(body.position);
    mesh.scale.set(3, scaleY, 3);
    mesh.position.y = height * scaleY / 2 + distanceOriginY;

    return mesh;
  }
}

优化完毕的效果如下:

完善游戏流程

开始/重玩

我们的游戏提供了重新开始机制,在玩家点击重玩按钮后,需要对通关统计信息进行重置,销毁已存在的盘子并释放资源,然后重新为柱杆 A 添加盘子,以完成初始化。代码实现如下:

const presenter = {
  ...
  startGame() {
  	// 重置统计信息
    model.steps = 0;
    model.startTime = Date.now();
  },

  resetGame(addedNums) {
    // 销毁盘子
    model.pillarsMap.forEach((pillar) => {
      while(pillar.plates.length) {
        const plate = pillar.plates.pop();
        model.scene.remove(plate);
        this.dispose3dObject(plate);
      }
    });

    // 销毁已经拾取的盘子
    if (model.currentPickedPlate) {
      model.scene.remove(model.currentPickedPlate);
      this.dispose3dObject(model.currentPickedPlate);
      model.currentPickedPlate = null;
      placementView.display(false);
    }

    if (typeof addedNums === 'number') {
      // 更新盘子数量
      this.updatePlateNums(model.plate.nums + addedNums);
    }

    this.addPlates();  // 重新添加盘子
    this.startGame();
  }
};

判断胜利

根据设定,当所有盘子都被移动到柱杆 C 中时,游戏即为胜利。那么在代码中如何进行判断呢?

首先,需要在柱杆 C 每次放入盘子时进行判断,我们把这个时机放在盘子过渡动画完成之后。代码如下:

const presenter = {
  ...
  handlePillarClick(pillarPart) {
    ...
    pillar.addPlate(pickedPlate, () => {
      if (pillar.tag === 'C') {
        this.checkResult();  // 检查结果
      }
    });
		...
  }
};

其次,需要判断柱杆 C 中的盘子数量是否等于本局盘子的总数量;

最后,需要判断盘子是否按照从大到小的顺序自下而上排列。

const presenter = {
  ...
  checkResult() {
    const targetPlates = this.getPillar('C').plates;
    const { plate, startTime, steps } = model;

    if (targetPlates.length === plate.nums) {  // 数量是否相等
      // 判断排列顺序
      if (targetPlates.every((item, i) => item.order === plate.nums - i)) {
        ...
      }
    }
  }
};

引导页、过关画面以及盘子数量设置的 UI 实现,非本文重点,请参阅源码中的相关实现,这里不再赘述。

结语

本文洋洋洒洒上下两篇加起来已有一万多字,较为系统全面地介绍了如何利用 three.js 创建 3D 场景、添加物体、设置光源和相机、实现交互等操作。虽然我们涵盖了尽可能多的细节,但仍然有一些细节无法一一尽述。希望本文能够对大家学习 three.js 有所帮助。

由于时间精力有限,如本文中有表述不清或者错漏之处,还请不吝指出,我们将会及时进行修改和完善。感谢您的阅读和支持!


临末问小伙伴们一个问题:为什么在本文汉诺塔游戏中最多只设置了8个盘子,而不是更多?咱们评论区见。

关于 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🌟TinyEngineTinyVueTinyNGTinyCLI~

如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

展开阅读全文
  • 0
    感动
  • 0
    路过
  • 0
    高兴
  • 0
    难过
  • 0
    搞笑
  • 0
    无聊
  • 0
    愤怒
  • 0
    同情
热度排行
友情链接