在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官方网站
模型资源网站
代码仓库地址

相关推荐
我要洋人死1 小时前
导航栏及下拉菜单的实现
前端·css·css3
科技探秘人1 小时前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
科技探秘人1 小时前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome
JerryXZR1 小时前
前端开发中ES6的技术细节二
前端·javascript·es6
七星静香1 小时前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel
q2498596931 小时前
前端预览word、excel、ppt
前端·word·excel
小华同学ai1 小时前
wflow-web:开源啦 ,高仿钉钉、飞书、企业微信的审批流程设计器,轻松打造属于你的工作流设计器
前端·钉钉·飞书
Gavin_9151 小时前
【JavaScript】模块化开发
前端·javascript·vue.js
懒大王爱吃狼3 小时前
Python教程:python枚举类定义和使用
开发语言·前端·javascript·python·python基础·python编程·python书籍
逐·風7 小时前
unity关于自定义渲染、内存管理、性能调优、复杂物理模拟、并行计算以及插件开发
前端·unity·c#