之前有实现过基于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
对象,并监听keydown
和keyup
两个事件,为的就是监听哪个按键是正在被按下的,当知道按下了哪些按键后就可以进行对应的操作了。
模型旋转
上面我们已经绑定了键盘的事件,现在就来处理对应按键的交互。在此,我们需要知道以下两点:
- 当按
W
时,人物往前;按A
人物往左,按S
人物往后,按D
人物往右; - 当按组合键时,例如
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();
}
容我解释一下上述代码的意义:
- 先看前两行设置,主要就是设置模型的旋转顺序和默认方向;
ts
this.model.scene.rotation.order = 'YXZ'; // 旋转顺序以Y轴为主,防止获取到不正确的弧度值
this.model.scene.rotation.y = Math.PI; // 默认背向摄像机
-
再来看
handleRotation
方法,该方法其实就是两个作用:- 先获取到按键对应的旋转弧度值;
- 通过setFromAxisAngle方法将弧度值以Y轴方向转为对应的四元数;
- 将四元数通过rotateTowards方法设置给模型,以此来旋转模型。
-
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个状态(因为模型给的动画挺多的(^▽^)),状态如下:
- 站立;
- 行走;
- 奔跑;
- 跳起;
- 落下;
- 跳舞;
根据以上类型,我们可以通过如下代码生成一个枚举状态:
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);
}
行走
好的,现在已经准备好了动画需要的一切前提。
我们继续来思考我们的状态如何进行修改,先进行第一步简单的"行走"状态:
- 当我们按下
W/A/S/D
按键时,应当执行为WALK
状态; - 当没有任何按键触发时,应当触发为
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
状态,只有当触发其他键盘事件时才会修改为其他状态。
完成代码的编写后,当我们触发按键事件时,呈现的效果应该如下图所示:
奔跑
上面我们已经实现了主角的行走操作,现在继续实现奔跑的操作,奔跑应该如下:
- 当行走的时候按下
Shift
键时,触发奔跑状态; - 当没有行走状态时,按下
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点来实现:
- 人物的初始方向向量为摄像机照向的方向;
- 当按下
W/A/S/D
按键后,人物进行运动; - 根据不同的朝向,人物运动方向向量应该偏移到对应的角度。
根据以上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点即可:
- 将计算的运动位置也赋值给摄像机的位置;
- 将摄像机的目标设置为角色的位置。 上面两点实现非常的容易,代码如下所示:
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点即可:
- 站立或者行走跑步时都可以进行跳跃;
- 跳跃受重力影响会在一个高度后往下掉,掉到平面时停下;
- 我们这里的跳跃只需要按下"空格键"即可进入状态,状态消失则是由是否还受重力影响来判断。
好了,明白以上几点后我们开始编辑角色跳跃的代码:
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
赋值来处理的,但是当我们加入八叉树来检测碰撞后,之前的操作都得重新修改。
现在的思路如下:
- 我们使用胶囊体来模型角色;
- 按键等操作的速度不能再直接赋值给
position
对象了,需要修改胶囊体的位置; - 检测胶囊体和场景是否碰撞,如果碰撞则将新的正确位置值赋值给胶囊体;
- 将胶囊体的位置信息赋值给角色位置信息。
当这样来实现时,才能保证主角能正确的进行与场景的碰撞检测,重新修改后的如下:
- 新增一个
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));
}
}
}
- 新增场景类,并进行八叉树的初始化:
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() { }
}
- 移除掉人物跳跃的方法逻辑:
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;
}
- 修改移动时候的代码逻辑:
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);
}
- 每一帧同步胶囊体的位置到角色的位置:
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
来实现螺旋丸特效等...这些都只有以后一边学习一边实践了。
来一个简单的总结吧:
尽管走路容易磕磕绊绊...
尽管跳跃有时不大灵光...
尽管撞墙人物不断抽搐...
尽管...
有太多的问题存在了 ╮(╯▽╰)╭
但是,我们至少实现了效果,虽不曾理想。但是学习就是酱紫的,不积跬步无以至千里,大家加油吧,努力去成为心中的火影 ヾ(◍°∇°◍)ノ゙!