在Three.js中继承火之意志——木叶村下忍鸣人参见!

之前有实现过基于three.js的八叉树碰撞检测,当我们继续拓展时,利用three.js的便捷开发,尝试实现一个火影忍者的世界,当然只是一个小世界,一个简单的人物,一个简单的场景,一些简单的交互,现在开始吧!

前言

如果你还不懂three.js这个框架的话,请先阅读官方文档学习并熟悉基础知识(如场景,相机,模型加载等)。没错,只需要懂基础就行,我们不会涉及太高级的话题,毕竟我也不会  ̄□ ̄||。

我会按照我习惯的方式写代码,各位看官不必纠结代码写法,了解思路或实现过程即可。另外,文章是边写代码边写的可能整体思路不是很流畅,还请理解。

感兴趣的可以点这里链接看看在线Demo吧,模型由10到20M,用的gitee服务初次加载可能会比较慢。

资源获取

在3D开发中,模型是很重要的,在我的细心挑选下,我在sketchfab找到了几个不错的火影忍者相关的模型,各位可以到我的仓库地址里面去下载。

开始

一切准备就绪后,我们就可以开始构建我们的火影世界了٩(๑>◡<๑)۶!

我决定先从人物入手,先在场景中引入人物模型,并完成按键跑步等操作,这可能比先放置场景更繁琐,但我可是主角鸣人哟,必须是第一个(〃'▽'〃)!

人物创建

引入人物模型

通过模型加载,我们可以很容易的将模型添加到场景中,代码如下:

ts 复制代码
export class Naruto extends EventEmitter {
  game: Game;
  scene: Scene;
  model!: GLTF;
  constructor() {
    super();
    this.game = Game.getInstance();
    this.scene = this.game.gameScene.scene;
    this.init();
  }

  init() {
    this.model = this.game.resource.getModel('hatch_naruto') as GLTF;
    this.scene.add(this.model.scene);
  }

  update() { }
}

上面的我只通过我定义的方法this.game.resource.getModel('hatch_naruto')引入了模型,其实方法里就是通过GLTFLoader加载模型,各自都可以有不同的写法。

完成上述步骤后,打开网页我们可以看到在页面中我们的主角鸣人已经出现了,如下图:

按键事件

当我们的人物出现后,就需要通过绑定按键事件来实现对人物的操作,最常见的就是通过W/A/S/D四个按键来控制前进的方向,下面我们先绑定事件:

ts 复制代码
type Keypress = { w: boolean, a: boolean, s: boolean, d: boolean };
keypress: Keypress = { w: false, a: false, s: false, d: false };
initEvents() {
    document.addEventListener('keydown', evt => {
      const key = evt.key.toLocaleLowerCase();
      if (Reflect.ownKeys(this.keypress).includes(key)) {
        this.keypress[key as keyof Keypress] = true;
      }
    });
    document.addEventListener('keyup', evt => {
      const key = evt.key.toLocaleLowerCase();
      if (Reflect.ownKeys(this.keypress).includes(key)) {
        this.keypress[key as keyof Keypress] = false;
      }
}

上面我们通过定义一个keypress对象,并监听keydownkeyup两个事件,为的就是监听哪个按键是正在被按下的,当知道按下了哪些按键后就可以进行对应的操作了。

模型旋转

上面我们已经绑定了键盘的事件,现在就来处理对应按键的交互。在此,我们需要知道以下两点:

  1. 当按W时,人物往前;按A人物往左,按S人物往后,按D人物往右;
  2. 当按组合键时,例如WA人物应该往左上方45度的角,其他以此类推;

因为,人物的不同朝向就是对模型进行不同方向的Y轴旋转,所以实现上面两点就其实是:
通过不同的按键事件来对人物进行不同程度的Y轴旋转。

知道需要实现的原理后,进行如下编码:

ts 复制代码
  this.model.scene.rotation.order = 'YXZ';
  this.model.scene.rotation.y = Math.PI;
  handleRotation() {
    const offsetRadian = this.getOffsetRadian();
    this.quaternion.setFromAxisAngle(new Vector3(0, 1, 0), offsetRadian);
    this.model.scene.quaternion.rotateTowards(this.quaternion, 0.2);  // 第二个参数为旋转最大值,可理解为过渡效果
  }

  getOffsetRadian() {
    let radian = this.model.scene.rotation.y; // 获取当前旋转的弧度值
    if (this.keypress['w']) {
      radian = Math.PI; // 前方的旋转的弧度
      if (this.keypress['a']) {
        radian = Math.PI * 1.25; // 右前方的旋转弧度
      } else if (this.keypress['d']) {
        radian = Math.PI * 0.75; // 左前方的旋转弧度
      }
    } else if (this.keypress['s']) {
      radian = 0; // 后方的旋转弧度
      if (this.keypress['a']) {
        radian = Math.PI * -0.25; // 左后方的旋转弧度
      } else if (this.keypress['d']) {
        radian = Math.PI * 0.25; // 右后方的旋转弧度
      }
    } else if (this.keypress['a']) {
      radian = -Math.PI * 0.5; // 左边的旋转弧度
    } else if (this.keypress['d']) {
      radian = Math.PI * 0.5; // 右边的旋转弧度
    }
    return radian;
  }

  update() {
    this.handleRotation();
  }

容我解释一下上述代码的意义:

  1. 先看前两行设置,主要就是设置模型的旋转顺序和默认方向;
ts 复制代码
this.model.scene.rotation.order = 'YXZ';  // 旋转顺序以Y轴为主,防止获取到不正确的弧度值
this.model.scene.rotation.y = Math.PI;  // 默认背向摄像机
  1. 再来看handleRotation方法,该方法其实就是两个作用:

    1. 先获取到按键对应的旋转弧度值;
    2. 通过setFromAxisAngle方法将弧度值以Y轴方向转为对应的四元数;
    3. 将四元数通过rotateTowards方法设置给模型,以此来旋转模型。
  2. getOffsetRadian方法的作用就是根据不同按键返回不同的旋转弧度值。

完成效果如下图:

摄像机旋转问题

目前我们的模型旋转操作是正常的,但是当我加入OrbitControls轨道控制器时,一边旋转视角一边按键操作人物旋转时,会产生一个问题:

当我通过按键朝向某一个方向时,例如这里朝向「左边」方向,但是一旦通过轨道控制器旋转角度后,人物的朝向就将不再是左边,这样的结果就是导致操作混乱。

问题如下图所示:

要解决这个问题,其实关键点就是摄像机和人物模型的拍摄夹角大小

当我通过轨道控制器旋转摄像机时,摄像机会和人物模型产生一个夹角,只需要将我们旋转的弧度加上这个夹角的弧度,即可解决上述问题。下面这张大佬做的图我很喜欢,表示的很清楚:

知道解决方案后,我们对旋转的方法进行一个优化,代码如下:

ts 复制代码
handleRotation() {
    const camera = this.game.gameCamera.camera;
    const player = this.model.scene;
    const angleYFromCamera = Math.atan2(
      camera.position.x - player.position.x,
      camera.position.z - player.position.z,
    );
    const offsetRadian = this.getOffsetRadian();
    this.quaternion.setFromAxisAngle(new Vector3(0, 1, 0), offsetRadian + angleYFromCamera);
    this.model.scene.quaternion.rotateTowards(this.quaternion, 0.2);
}

上面主要通过Math.atan2方法来求取摄像机人物模型XZ距离,也就是对边邻边这两个值,运用三角函数中的正切函数就能求取夹角的弧度值了。

求出偏移的弧度值后,与我们不同的按键对应的弧度值进行一个相加,即可得到新的旋转弧度,人物也就始终能在摄像机运动时也一直保持我们希望的方向,最后效果如下图:

角色状态

当我们完成模型的旋转后,我们就可以开始让主角进行动画,来丰富场景了。

在此之前,上面的代码其实是有一个问题的,当我通过轨道控制器旋转视角(没有触发按键事件)时,人物模型会自己不断旋转。

这是因为update每一帧都在触发,每一次都会获取新的rotation.y值导致的一直旋转,这里我们就可以通过设计角色的状态来进行动画的同时,随便也能修复这个问题。

在这个例子中,我给主角设计6个状态(因为模型给的动画挺多的(^▽^)),状态如下:

  1. 站立;
  2. 行走;
  3. 奔跑;
  4. 跳起;
  5. 落下;
  6. 跳舞;

根据以上类型,我们可以通过如下代码生成一个枚举状态:

ts 复制代码
enum Status {
  IDLE = 'mixamo.com', // 站立
  WALK = 'Walking', // 行走
  RUNNING = 'Running', // 奔跑
  JUMP = 'Male Dynamic Pose', // 跳起
  FALLING = 'Falling Idle', // 落下
  DANCING = 'Hip Hop Dancing' // 跳舞
}

上面的枚举对应我们每一个动画的名称,所以现在先把动画所需要的对象进行初始化,代码如下:

ts 复制代码
mixer!: AnimationMixer;
actionAction!: AnimationAction;
animationMap: Map<string, AnimationAction> = new Map();
this.mixer = new AnimationMixer(this.model.scene);
this.mixer.timeScale = 0.001;  // 这个理解为动画的执行频率
this.model.animations.forEach(animation => {
  const actionClip = this.mixer.clipAction(animation);
  // 这里的name是对应我上面定义的枚举的值的
  this.animationMap.set(animation.name, actionClip);
});

别忘了,还需要在每一帧执行如下代码,否则动画是不能生效的:

ts 复制代码
update() {
    this.mixer && this.mixer.update(delta);
}

行走

好的,现在已经准备好了动画需要的一切前提。

我们继续来思考我们的状态如何进行修改,先进行第一步简单的"行走"状态:

  1. 当我们按下W/A/S/D按键时,应当执行为WALK状态;
  2. 当没有任何按键触发时,应当触发为IDLE状态。

明白以上两点后,我们很容易可以进行如下的编码:

ts 复制代码
  changeStatus() {
    let isIdle = true;
    for (const key in this.keypress) {
      if (this.keypress[key as keyof Keypress]) {
        isIdle = false;
        break;
      }
    }
    if (isIdle) {
      this.status = Status.IDLE;
    } else {
      this.status = Status.WALK;
    }

    // 获取当前状态对应的动画
    const clipAction = this.animationMap.get(this.status);

    // 如果当前触发的动画已经是和clipAction一样的,就不需要再次执行动画了
    if (!clipAction || this.actionAction === clipAction) {
      return;
    }

    // 将当前执行的动画过渡进行退出
    this.actionAction && this.actionAction.fadeOut(0.2);
    // 将需要执行的动画过渡进行播放
    clipAction.reset().fadeIn(0.2).play();
    // 重新赋值当前的执行动画
    this.actionAction = clipAction;
  }
  update() {
    const delta = this.game.time.delta;
    this.changeStatus();
    if (this.status === Status.WALK || this.status === Status.RUNNING) {
      this.handleRotation();
    }
    this.mixer && this.mixer.update(delta);
  }

上面,我们默认的状态为IDLE状态,只有当触发其他键盘事件时才会修改为其他状态。

完成代码的编写后,当我们触发按键事件时,呈现的效果应该如下图所示:

奔跑

上面我们已经实现了主角的行走操作,现在继续实现奔跑的操作,奔跑应该如下:

  1. 当行走的时候按下Shift键时,触发奔跑状态;
  2. 当没有行走状态时,按下Shift按键也不会触发奔跑。

知道上面两点后,我们进行代码编写,如下所示:

ts 复制代码
// keypress: Keypress = { w: false, a: false, s: false, d: false, shift: false }; 新增shift属性
let isIdle = true;
for (const key of ['w', 'a', 's', 'd']) {
  if (this.keypress[key as keyof Keypress]) {
    isIdle = false;
    break;
  }
}
if (isIdle) {
  this.status = Status.IDLE;
} else {
  this.status = Status.WALK;
  if (this.keypress['shift']) {
    this.status = Status.RUNNING;
  }
}

上面代码只需要在非站立的状态时,进行判断是否按下shift键来进行奔跑状态的切换,状态切换自然又会同步到动画的状态,实现效果图如下:

跳舞

跳舞其实更加的简单了,就是一点:站立状态按下E键进行跳舞(当然也可以是其他按键(✺ω✺)),知道逻辑后进行如下的代码修改:

ts 复制代码
//   keypress: Keypress = { w: false, a: false, s: false, d: false, shift: false, e: false };
if (isIdle) {
  this.status = Status.IDLE;
  if (this.keypress['e']) {
    this.status = Status.DANCING;
  }
} else {
  this.status = Status.WALK;
  if (this.keypress['shift']) {
    this.status = Status.RUNNING;
  }
}

完成以上代码修改后,我们再来看看实习效果,如下图:

跳起/落下

跳起和落下因为要涉及速度和重力落下速度影响,后面再进行实现。

角色运动

角色运动还是一个比较麻烦的事儿,不同的运动方式对应的实现思路也可能不同,在我们这个例子中,其实运动的方向就是人物的朝向加上摄像机的朝向。

当知道运动的方向后,再将人物的模型位置按运动的速率 来偏移,即可实现人物运动。

由此,我们应当按照以下3点来实现:

  1. 人物的初始方向向量为摄像机照向的方向;
  2. 当按下W/A/S/D按键后,人物进行运动;
  3. 根据不同的朝向,人物运动方向向量应该偏移到对应的角度。

根据以上3点,我们进行如下的代码编写,如下所示:

ts 复制代码
  handleMove(delta: number) {
    // 获取运动速率,这里乘以一个0.001是避免速率太大导致运动幅度太大
    const speed = (this.status === Status.WALK ? this.walkSpeed : this.runSpeed) * 0.001;
    const direction = new Vector3();
    // 获取人物的初始方向,就是摄像机照的方向
    this.game.gameCamera.camera.getWorldDirection(direction);
    direction.normalize();
    direction.y = 0;
    // 将按键对应的旋转弧度应用到初始方向里面去,因为默认设置了Math.PI的旋转弧度,所以这里要加上
    const offsetRadian = this.getOffsetRadian();
    direction.applyAxisAngle(new Vector3(0, 1, 0), offsetRadian + Math.PI);

    // 将方向向量乘以速率和delta来获得X和Z轴的速率
    const moveX = speed * direction.x * delta;
    const moveZ = speed * direction.z * delta;
    // 重新赋值位置
    this.model.scene.position.x += moveX;
    this.model.scene.position.z += moveZ;
  }

完成以上的代码后,为了能更加清楚的看到角色运动的过程,我给场景中加入GridHelper来做一个对照:

ts 复制代码
this.game.gameScene.scene.add(new GridHelper(100, 100));

完成以上操作后,我们来看看实际的效果图:

摄像机跟随

虽然我们的角色已经可以自由的运动了,但是现在依然有一个问题,那就是摄像机没有进行跟随,这样导致的问题就是人物会逐渐消失在我们的视野。

解决这个问题,其实很简单,我们只需要做一下2点即可:

  1. 将计算的运动位置也赋值给摄像机的位置;
  2. 将摄像机的目标设置为角色的位置。 上面两点实现非常的容易,代码如下所示:
ts 复制代码
handleMove(delta: number) {
   // 省略前面的角色移动逻辑
   this.lookAtPlayer(moveX, moveZ);
}
lookAtPlayer(moveX: number, moveZ: number) {
    // 重新赋值位置
    const player = this.model.scene;
    player.position.x += moveX;
    player.position.z += moveZ;

    const controls = this.game.gameControls.controls;
    controls.object.position.x += moveX;
    controls.object.position.z += moveZ;
    const target = player.position.clone();
    target.y = player.position.y + 1;
    controls.target = target;
}

上面通过获取角色的位置信息,动态来修改摄像机的拍摄点和位置,实现效果图如下:

角色跳跃/落下

角色的跳跃比起行走和跑步要稍微麻烦一点点,当然也只是一点点。处理起来还是比较容易的,我们只需要分析以下两3点即可:

  1. 站立或者行走跑步时都可以进行跳跃;
  2. 跳跃受重力影响会在一个高度后往下掉,掉到平面时停下;
  3. 我们这里的跳跃只需要按下"空格键"即可进入状态,状态消失则是由是否还受重力影响来判断。

好了,明白以上几点后我们开始编辑角色跳跃的代码:

ts 复制代码
//   keypress: Keypress = { w: false, a: false, s: false, d: false, shift: false, e: false, space: false };
// jumpSpeed: number = 0; 跳跃的初速度
// isMove: boolean = false; 是否在移动角色,这个将替代之前的isIdle状态

  document.addEventListener('keydown', evt => {
    const key = evt.key.toLocaleLowerCase().replace(' ', 'space');
    if (Reflect.ownKeys(this.keypress).includes(key)) {
      this.keypress[key as keyof Keypress] = true;
    }
    // 如果按下跳跃键且跳跃速度为0时对初速度进行赋值,且跑步状态时跳跃速率更大
    if (this.keypress['space'] && this.jumpSpeed === 0) {
      this.jumpSpeed = this.status === Status.RUNNING ? 8 : 5;
    }
  });

  handleJump(delta: number) {
    // 设置一个较小的系数,使得运动幅度不会很大
    const ratio = 0.001;
    // 计算跳起初速度
    const speed = this.jumpSpeed * delta * ratio;
    const player = this.model.scene;
    // 人物往上运动
    player.position.y += speed;
    // 对初速做重力运算
    this.jumpSpeed += -this.game.gameWorld.grivity * ratio * delta;
    // 当人物高度小于等于0时作重置处理
    if (player.position.y <= 0) {  // 这里先用0来判断是否着地
      player.position.y = 0;
      this.jumpSpeed = 0;
      this.keypress['space'] = false;
    }
  }
  
  changeStatus() {
      // 省略前面代码
      if (this.keypress['space']) {
          this.status = this.jumpSpeed > 0 ? Status.JUMP : Status.FALLING;
        }
      // 省略后面代码
  }
  
  update() {
    const delta = this.game.time.delta;
    this.changeStatus();
    if (this.status === Status.WALK || this.status === Status.RUNNING) {
      this.handleRotation();
      this.handleMove(delta);
    }
    if (this.status === Status.JUMP || this.status === Status.FALLING) {
      this.isMove ? this.handleMove(delta) : this.lookAtPlayer(0, 0);
      this.handleJump(delta);
    }
    this.mixer && this.mixer.update(delta);
  }

上面我们的状态一直都是用的"单状态"处理的,实际上跳跃和跑步或者行走是可以并存的,但是这里的例子不是很复杂,所以就简单加一个this.isMove来协助处理逻辑了。

上面代码编写成功后,最后效果图如下所示:

到目前为止,人物的各个状态都基本已经完成,接下来就是场景模型的构建了。

场景创建

我们先使用办公室的模型来创建场景,找到训练场的模型资源按照导入人物模型一样,很容易的就可以把模型导入到场景中,效果如下图:

场景模型是导入进来了,可是我们发现角色是可以随意穿墙的,我们得使用八叉树来实现碰撞逻辑的检测。

是否还记得之前文章我说的Octree八叉树碰撞?如果没印象了,再来看看这张图:

使用八叉树

在这之前,我们对角色的移动和跳跃等操作都是通过直接给position赋值来处理的,但是当我们加入八叉树来检测碰撞后,之前的操作都得重新修改。

现在的思路如下:

  1. 我们使用胶囊体来模型角色;
  2. 按键等操作的速度不能再直接赋值给position对象了,需要修改胶囊体的位置;
  3. 检测胶囊体和场景是否碰撞,如果碰撞则将新的正确位置值赋值给胶囊体;
  4. 将胶囊体的位置信息赋值给角色位置信息。

当这样来实现时,才能保证主角能正确的进行与场景的碰撞检测,重新修改后的如下:

  1. 新增一个SceneOctree的类:
ts 复制代码
import { EventEmitter } from 'events';
import { Group, Scene, Vector3 } from 'three';
import { Game } from '../index';
import { Octree } from 'three/examples/jsm/math/Octree';
import { Capsule } from 'three/examples/jsm/math/Capsule';

export class SceneOctree extends EventEmitter {
  game!: Game;
  scene!: Scene;
  octree!: Octree;
  capsule!: Capsule; // 胶囊体信息
  capsuleOnFloor!: boolean; // 角色是否着地
  fallingSpeed: number = 0; // 下降速度
  static instance: SceneOctree;
  constructor() {
    super();
    if (SceneOctree.instance) {
      return SceneOctree.instance;
    }
    SceneOctree.instance = this;
    this.game = Game.getInstance();
    this.scene = this.game.gameScene.scene;
    this.octree = new Octree();
    this.capsule = new Capsule(new Vector3(0, 0.3, 0), new Vector3(0, 1.3, 0), 0.3);
  }

  init(scene: Group) {
    this.octree
    this.octree.fromGraphNode(scene);
  }

  update() {
    // 如果没有着地则计算重力速度让角色降落
    if (!this.capsuleOnFloor) {
      const ratio = 0.00001;
      this.fallingSpeed += -this.game.gameWorld.grivity * ratio * this.game.time.delta;
      this.capsule.translate(new Vector3(0, this.fallingSpeed, 0));
    }
    this.capsuleOnFloor = false;
    // 碰撞检测
    const result = this.octree.capsuleIntersect(this.capsule);
    if (result) {
      const { depth, normal } = result;
      this.capsuleOnFloor = normal.y > 0;
      if (this.capsuleOnFloor) {
        // 着地后降落速度归0处理
        this.fallingSpeed = 0;
      }
      // 将人物碰撞的部分移出
      this.capsule.translate(normal.multiplyScalar(depth));
    }
  }
}
  1. 新增场景类,并进行八叉树的初始化:
ts 复制代码
import { EventEmitter } from 'events';
import { Scene, Vector3 } from 'three';
import { Game } from '../index';
import { GLTF } from 'three/examples/jsm/loaders/GLTFLoader';
import { SceneOctree } from './octree';

export class Examen extends EventEmitter {
  game: Game;
  scene: Scene;
  model!: GLTF;
  sceneOctree: SceneOctree;
  constructor() {
    super();
    this.game = Game.getInstance();
    this.scene = this.game.gameScene.scene;
    this.sceneOctree = new SceneOctree();
    this.init();
  }

  init() {
    this.model = this.game.resource.getModel('naruto_sala_examen_chunnin') as GLTF;
    this.model.scene.position.set(0, -4.5 * 10, -24 * 10);
    this.model.scene.scale.set(5, 5, 5);
    this.sceneOctree.init(this.model.scene);
    this.scene.add(this.model.scene);
  }

  update() { }
}
  1. 移除掉人物跳跃的方法逻辑:
ts 复制代码
// 按键事件
// 如果按下跳跃键且跳跃速度为0时对初速度进行赋值,且跑步状态时跳跃速率更大
if (this.keypress['space'] && ![Status.JUMP, Status.FALLING].includes(this.status)) {
  // jumpSpeed移除,改为给SceneOctree类的fallingSpeed赋值
  this.sceneOctree.fallingSpeed = (this.status === Status.RUNNING ? 8 : 5) * 0.01;
}
// update方法删除以下
if (this.status === Status.JUMP || this.status === Status.FALLING) {
  this.isMove ? this.handleMove(delta) : this.lookAtPlayer(0, 0);
  // this.handleJump(delta); 移除掉人物跳跃的逻辑
}
// changeStatus方法增加以下(通过胶囊体信息来判断是否着地来改状态)
if (this.status === Status.FALLING && this.sceneOctree.capsuleOnFloor) {
  this.status = Status.IDLE;
  this.keypress['space'] = false;
}
  1. 修改移动时候的代码逻辑:
ts 复制代码
handleMove(delta: number) {
    // 获取运动速率,这里乘以一个0.001是避免速率太大导致运动幅度太大
    const speed = (this.status === Status.WALK ? this.walkSpeed : this.runSpeed) * 0.001;
    const direction = new Vector3();
    // 获取人物的初始方向,就是摄像机照的方向
    this.game.gameCamera.camera.getWorldDirection(direction);
    direction.normalize();
    direction.y = 0;
    // 将按键对应的旋转弧度应用到初始方向里面去,因为默认设置了Math.PI的旋转弧度,所以这里要加上
    const offsetRadian = this.getOffsetRadian();
    direction.applyAxisAngle(new Vector3(0, 1, 0), offsetRadian + Math.PI);

    // 将方向向量乘以速率和delta来获得X和Z轴的速率
    const moveX = speed * direction.x * delta;
    const moveZ = speed * direction.z * delta;
    // 修改人物的position改为修改胶囊体的位置信息
    this.sceneOctree.capsule.translate(new Vector3(moveX, 0, moveZ));
    this.lookAtPlayer(moveX, moveZ);
}
  1. 每一帧同步胶囊体的位置到角色的位置:
ts 复制代码
syncCapsule() {
    const player = this.model.scene;
    const { start, radius } = this.sceneOctree.capsule;
    const position = start.clone();
    position.y -= radius;
    player.position.copy(position);
}

为了比较好的参考,我还新增了胶囊体几何来呈现,完成后的效果图如下:

现在人物已经和场景有了碰撞检测的机制了,我们的角色也更加真实了。

目前解决不了的问题 使用八叉树碰撞检测后,有时候跳跃不生效(可能是重力计算不合理导致的); 和不规则几何碰撞时人物疯狂抖动,还不明白原因。

后续的优化只有在不断学习中来进行一个了解吧,目前就暂时做到这个地步,来一张截图来结束吧:

结束语

字数太多了掘金的文章编辑好卡好卡了,那基于three.js的第三人称分享就暂时到此为止了。

感觉像打了个小型单机游戏一样,有点上头,遇到困难也很挫败。

但其实过程也很开心,技术好的话我们还可以用辉光效果模拟九尾模式的外衣,用shader来实现螺旋丸特效等...这些都只有以后一边学习一边实践了。

来一个简单的总结吧:

尽管走路容易磕磕绊绊...

尽管跳跃有时不大灵光...

尽管撞墙人物不断抽搐...

尽管...

有太多的问题存在了 ╮(╯▽╰)╭

但是,我们至少实现了效果,虽不曾理想。但是学习就是酱紫的,不积跬步无以至千里,大家加油吧,努力去成为心中的火影 ヾ(◍°∇°◍)ノ゙!

对应资料

three.js官方网站
模型资源网站
代码仓库地址

相关推荐
汪子熙18 分钟前
Angular 服务器端应用 ng-state tag 的作用介绍
前端·javascript·angular.js
Envyᥫᩣ27 分钟前
《ASP.NET Web Forms 实现视频点赞功能的完整示例》
前端·asp.net·音视频·视频点赞
Мартин.4 小时前
[Meachines] [Easy] Sea WonderCMS-XSS-RCE+System Monitor 命令注入
前端·xss
昨天;明天。今天。6 小时前
案例-表白墙简单实现
前端·javascript·css
数云界6 小时前
如何在 DAX 中计算多个周期的移动平均线
java·服务器·前端
风清扬_jd6 小时前
Chromium 如何定义一个chrome.settingsPrivate接口给前端调用c++
前端·c++·chrome
安冬的码畜日常6 小时前
【玩转 JS 函数式编程_006】2.2 小试牛刀:用函数式编程(FP)实现事件只触发一次
开发语言·前端·javascript·函数式编程·tdd·fp·jasmine
ChinaDragonDreamer6 小时前
Vite:为什么选 Vite
前端
小御姐@stella6 小时前
Vue 之组件插槽Slot用法(组件间通信一种方式)
前端·javascript·vue.js
GISer_Jing6 小时前
【React】增量传输与渲染
前端·javascript·面试