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

本文由孟智强同学原创。
接上期:《用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';  // 柱杆的各个部件设置 namereturn 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🌟TinyEngine、TinyVue、TinyNG、TinyCLI~

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/310178.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

蓝桥杯物联网竞赛_STM32L071_16_EEPROM

仍然是没有考过的知识点 朴素的讲就是板子中一块不会因为断电重启而导致数值初始化的一片地址 要注意的是有时候容易把板子什么写错导致板子什么地址写坏了导致程序无法烧录&#xff0c;这个时候记得一直按flash键烧录&#xff0c;烧录时会报错&#xff0c;点击确定&#xff0…

活动预告|NineData 创始人CEO叶正盛将参加QCon全球软件开发大会,共话AI大模型技术在数据库DevOps的实践

4月13日下午&#xff0c;NineData创始人&CEO叶正盛即将参加InfoQ中国主办的『QCon全球软件开发大会北京站』的技术大会。在本次技术峰会上&#xff0c;叶正盛将以《AI大模型技术在数据库DevOps的实践》为主题&#xff0c;深入剖析AI大模型技术在数据库DevOps领域的最新进展…

idea新建一个springboot项目

本文分为几个部分&#xff0c; 首先是在idea中新建项目&#xff0c; 然后是配置 项目的目录&#xff08;新建controller、service、dao等&#xff09;&#xff0c; 然后是自定义的一些工具类&#xff08;比如启动后打印地址等&#xff09;。 1.、创建篇 新建项目&#xff0…

概念解读稳定性保障

什么是稳定 百度百科关于稳定的定义&#xff1a; “稳恒固定&#xff1b;没有变动。” 很明显这里的“稳定”是相对的&#xff0c;通常会有参照物&#xff0c;例如 A 车和 B 车保持相同速度同方向行驶&#xff0c;达到相对平衡相对稳定的状态。 那么软件质量的稳定是指什么…

24年做抖音选什么赛道?比起带货,我更倾向你们做这个

我是王路飞。 谁能想到在18年的夏天给无数人留下美好回忆的抖音&#xff0c;如今已经成为了谁也绕不开的创业平台呢&#xff1f; 注意&#xff0c;我说的是“绕不开”。 毕竟在流量为王的时代&#xff0c;流量在哪&#xff0c;我们就要在哪。 而如今&#xff0c;流量全都集…

【热门话题】PyTorch:深度学习领域的强大工具

&#x1f308;个人主页: 鑫宝Code &#x1f525;热门专栏: 闲话杂谈&#xff5c; 炫酷HTML | JavaScript基础 ​&#x1f4ab;个人格言: "如无必要&#xff0c;勿增实体" 文章目录 PyTorch&#xff1a;深度学习领域的强大工具一、PyTorch概述二、PyTorch核心特性…

EasyRecovery数据恢复软件2024百度云网盘下载链接

EasyRecovery数据恢复软件是一款功能强大的数据恢复工具&#xff0c;它能够帮助用户从各种存储设备中恢复丢失或误删除的文件数据。无论是由于意外删除、格式化、病毒攻击还是其他原因导致的数据丢失&#xff0c;EasyRecovery都能提供有效的解决方案。 该软件支持多种存储介质…

【攻防世界】mfw(.git文件泄露)

首先进入题目环境&#xff0c;检查页面、页面源代码、以及URL&#xff1a; 发现页面无异常。 使用 dirsearch 扫描网站&#xff0c;检查是否存在可访问的文件或者文件泄露&#xff1a; 发现 可访问界面/templates/ 以及 .git文件泄露&#xff0c;故使用 GItHack 来查看泄露的 …

有图片转成PDF文件格式的方法吗?分享图片转成PDF文件的方法

将图片转换为PDF文件是一个相对简单的过程&#xff0c;但也需要一定的步骤和注意事项。下面&#xff0c;我将详细介绍如何将图片转换为PDF文件&#xff0c;包括所需的工具、步骤以及可能遇到的问题和解决方案。 首先&#xff0c;我们需要一个能够将图片转换为PDF文件的工具。市…

docker-compose yaml指定具体容器网桥ip网段subnet;docker创建即指定subnet;docker取消自启动

1、docker-compose yaml指定具体容器网桥ip网段subnet docker-compose 启动yaml有时可能的容器网段与宿主机的ip冲突导致宿主机上不了网&#xff0c;这时候可以更改yaml指定subnet 宿主机内网一般是192**&#xff0c;这时候容器可以指定172* version: 3.9 services:coredns:…

爬虫 | 垃圾处理设施数据的获取与保存

Hi&#xff0c;大家好&#xff0c;我是半亩花海。本项目通过发送网络请求&#xff08;requests&#xff09;&#xff0c;从指定的 URL 获取垃圾处理设施的相关数据&#xff0c;并将数据保存到 CSV 文件中&#xff0c;以供后续分析和利用。 目录 一、项目结构 二、详细说明 三…

string类——常用函数模拟(C++)

本篇中&#xff0c;将会详细的介绍 Cpp 中 string 的使用&#xff0c;以及 string 类常用函数的模拟实现。对于 string 的内置函数来说&#xff0c;存在很多很冗余的用法&#xff0c;很多函数都有很多种用法&#xff0c;本篇将会讲解常用内置函数的常用用法&#xff0c;模拟函数…

【御控物联】 Java JSON结构转换(2):对象To对象——属性重组

文章目录 一、JSON结构转换是什么&#xff1f;二、案例之《JSON对象 To JSON对象》三、代码实现四、在线转换工具五、技术资料 一、JSON结构转换是什么&#xff1f; JSON结构转换指的是将一个JSON对象或JSON数组按照一定规则进行重组、筛选、映射或转换&#xff0c;生成新的JS…

抽奖系统设计

如何设计一个百万级用户的抽奖系统&#xff1f; - 掘金 如何设计百万人抽奖系统…… 在实现抽奖逻辑时&#xff0c;Redis 提供了多种数据结构&#xff0c;选择哪种数据结构取决于具体的抽奖规则和需求。以下是一些常见场景下推荐使用的Redis数据结构&#xff1a; 无序且唯一奖…

RabbitMQ消息模型之Direct消息模型

Direct消息模型 * 路由模型&#xff1a; * 一个交换机可以绑定多个队列 * 生产者给交换机发送消息时&#xff0c;需要指定消息的路由键 * 消费者绑定队列到交换机时&#xff0c;需要指定所需要消费的信息的路由键 * 交换机会根据消息的路由键将消息转发到对应的队…

LabVIEW和2D激光扫描的受电弓滑板磨耗精确测量

LabVIEW和2D激光扫描的受电弓滑板磨耗精确测量 在电气化铁路运输中&#xff0c;受电弓滑板的健康状况对于保障列车安全行驶至关重要。受电弓滑板作为连接电网与列车的直接介质&#xff0c;其磨损情况直接影响到电能的有效传输及列车的稳定运行。精确、快速测量受电弓滑板磨损情…

数据接口测试工具 Postman 介绍!

此文介绍好用的数据接口测试工具 Postman&#xff0c;能帮助您方便、快速、统一地管理项目中使用以及测试的数据接口。 1. Postman 简介 Postman 一款非常流行的 API 调试工具。其实&#xff0c;开发人员用的更多。因为测试人员做接口测试会有更多选择&#xff0c;例如 Jmeter…

算法:双指针

算法&#xff1a;双指针 双指针快慢指针对撞指针总结 双指针 LeetCode 283.移动零 以上题目要求我们把所有0移动到数组的末尾&#xff0c;也就是说&#xff0c;我们要把数组转化为以下状态&#xff1a; [ 非0区域 ] [ 0区域 ] 像这种把一个数组划分为多个区域的题型&#xff0…

顺序表(C语言版)

前言&#xff1a;本篇文章我们来详细的讲解一下顺序的有关知识&#xff0c;这篇文章中博主会以C语言的方式实现顺序表。以及用顺序表去实现通讯录的代码操作。 目录 一.顺序表的概念 二.顺序表的分类 1.静态顺序表 三.动态顺序表的实现 1.顺序表的初始化 2.顺序表的尾插…

【Entity Framework】聊一聊EF中继承关系

【Entity Framework】聊一聊EF中继承关系 文章目录 【Entity Framework】聊一聊EF中继承关系一、概述二、实体类型层次结构映射三、每个层次结构一张表和鉴别器配置四、共享列五、每个类型一张表配置六、每个具体类型一张表配置七、TPC数据库架构八、总结 一、概述 Entity Fra…