theme: cyanosis
开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第5天,点击查看活动详情
这个游戏的复杂程度已经超出我的学习范围了,所以我不能把完整的步骤写出来。 我会尽可能把一些可以借鉴的套路讲出来。
准备资源
上一个游戏的资源准备就是找到即可。 这个游戏对资源的处理也是比较重要的一个方面,虽然我略过了,但还是清楚它的重要性。
场景
首先是场景,游戏地图可谓是游戏中最大的一部分资源了。
原本找到的文件是max文件 ,在 https://anyconv.com/ 这个网站转fbx, 然后可以导入blend,进行编辑。 我确实不会建模软件。
总而言之一番编辑之后,我们还需要unity3d。
场景限制和寻路问题
人物在地面上行走,这是很普通的一个场景, 但是如果地面凹凸不平呢?如何让人物一直贴着地面,而不飘起来或者陷下去?如何让人物不穿墙而过? 不能一直使用距离检测吧,因为这样要检测的物体未免太多。
这里使用的思路是,在原本的场景模型表面,多生成一张膜
,覆盖在原本的模型表面, 就是一个曲面,人物只能行走在这个膜上。 比如说,如果原地图中有一棵树,那么这个曲面这块就会空缺出来,这样人物就只能绕过去。
有了这个曲面之后,我们只需要一个竖直向下的射线,检测交点,人物永远位于这个交点,或者在z方向上保持距离,具体和模型的基点有关。 如果你的地图模型本来就是这样,那么这个套路可以直接用上。
这里没有说寻路,寻路其实就是找两个点之间一条线,并且绕开障碍物(或者说断开的地方),也需要这样一个平面。具体算法较为复杂。
最后我们还是用blender导出gltf文件。
使用unity3d的具体做法,参考https://www.bilibili.com/video/BV1mU4y1P7fL?p=34
人物
然后就是人物模型了,作为一个人来说,就肯定不能直接直来直去的操控它,我们需要一些动画, 行走,奔跑 ,射击,中弹, 躺尸。
https://www.mixamo.com/#/ 这个网站不仅可以下载人物模型,还能给模型带上动画,可免费商用。
原文件中附带的贴图较大,这里将其尺寸缩减为1024 X 1024 ,如果不改会不会怎么样,别的不知道,但是加载肯定慢很多。 这里推荐了一个开源的图形处理软件 krita,可以替代ps的部分功能。
具体操作请自行搜索,或者参考https://www.bilibili.com/video/BV1mU4y1P7fL?p=25
开始写代码
首先还是一个主模块 game ,game负责 游戏的开始 结束 和其他模块的初始化。
constructor里面,还是常规的初始化,场景 ,相机,光影,后处理的pass ,事件监听。
至于如何设置阴影,如何优化(减少消耗),想必各位都知道了。 下面,不会事无巨细,只会拿出几个可通用的点,因为我也偷了很多懒啊,即便如此,还是花了好几个星期。
进度条
这个游戏里要加载的东西很多, 场景,人物模型,npc模型,而且都是分散的。
这里是封装好的进度条,会区分加载哪个update(assetName, loaded, total){
总进度就是全部加起来,这样就能计算出百分比了。
当然,我想说的是另一个东西,那就是全部加载完成。看到全部两字,下意识就想起了Promise.All
,确实是可以的,如果把每个资源加载都返回一个Promise实例,装进一个数组里。 但是,这里的加载不太方便保证这个数组能拿到全部的promise .
我们用了实时渲染,所以必然有一个每次渲染前都会调用的函数,类似setInterval
,知道requestFrameAnimation
的,就更不用说了。
只需在,在各个需要进度条的模块里添一个一个类似 ready这样的字段,在一个函数里统一判断即可,很原始,很有用。
startRendering(){
if( this.user.ready && !this.bulletHandler && this.npcHandler.ready){
this.bulletHandler = new BulletHandler(this) ;
this.controller = new Controller(this) ;
this.loadingBar.visible = false;
this.renderer.setAnimationLoop(this.render.bind(this));
this.ui.visible = true ;
this.initSounds()
}
}
设置动画
three 使用动画如下. three 的动画是修改物体的 transform 数据,所以是多个动画叠加在一起的,当然,我想不到这个的使用场景。
this.mixer = new AnimationMixer(gltf.scene);
const clip = gltf.scene.animations[name];
const action = this.mixer.clipAction(clip);
action.play()
....
//更新函数里
this.mixer.update(dt)
当你不操作人物的时候,人物就杵在那儿,是不是太单调了点。需要给它来点动画,比如左顾右盼之类的,这种就可以直接循环播放。 不过,这里有多个动画,希望能变着花样播放他们。
基本思路就是,当一个动画要播放结束时,随机播放另一个不重复的动画。
随机一个不重复的动画就是直接随机一个索引,如果和之前相同就继续随机。
newAnim(){
const keys = Object.keys(this.animations);
let index;
do{
index = Math.floor( Math.random() * keys.length );
}while(keys[index]==this.actionName);
this.action = keys[index];
setTimeout( this.newAnim.bind(this), 3000 );
}
随机解决了,那么如何知道上一个动画什么时候结束?其实我也不知道上一个动画啥时候结束。但是,一般来说,加载好模型之后,动画的参数就固定了。
这里直接固定了3000ms改一次动画, 重点就在两个动画的过渡,three有现成的api
crossFadeTo(targetAction, duration)
从当前的动画(this)过渡到下一个动画 targetAction, 过渡耗时。
这个方法不包含play的作用,要执行下一个动画还是要调用play
与之对应的还有fadeIn fadeOut crossFadeFrom
// 不操作人物的时候
action.play()
if (this.curAction){// 因为有可能没有播放任何动画
this.curAction.crossFadeTo(action, 0.5);
}
this.curAction = action;
动画本身很简单,但是游戏中设置动画的逻辑较为复杂,大概分为三种。
1 人物移动 移动时停掉当前动画开始循环播放walk,速度达到一定值切换为 run
2 人物射击 目前只考虑静止射击,就是说其他操作要为射击让路 。射击动画这里需要我们自己提前调整好枪的角度和位置,射击的起始点是枪口,也就是说,如果处于其他动作下,射击就不太好瞄准。
3 人物中弹 中弹是一个短暂的动画,它执行一次
上面还涉及到具体的逻辑判断以及音效的播放。另外,由于抢模型是后面加上去的,没有动画,所以需要手动补间。
中弹导致的数据变化,也是在这里进行。我是认为只要在更改动画之前处理好数据就行,所以好像还是得在这里。
set action(name) {
if (this.actionName === name.toLowerCase()) {
return
}
console.log(name);
if (name === 'shot') {
this.health -= 25;
if (this.health >= 0) {// 血槽空了 人还活着,四条命好像确实是这个样子
name = 'hit';
this.game.active = false;
setTimeout(() => {
this.game.active = true;
}, 1000);
}
// console.log(name);
// 开启死亡视觉
this.game.tintScreen(name);
this.game.ui.health = max(0, min(this.health / 100, 1))
if (this.sfx) { this.sfx.play('eve_groan') }
}
if (this.sfx) {
if (name === 'walk' || name === 'firingwalk' || name === 'run') {
this.sfx.play('footsteps')
} else {
this.sfx.stop('footsteps')
}
}
const clip = this.animations[name.toLowerCase()];
if (clip) {
const action = this.mixer.clipAction(clip);
if (name === 'shot') {
action.clampWhenFinished = true;// 停在最后一帧
action.setLoop(LoopOnce)
this.dead = true ;
this.game.gameover()
}
// 没有给firing 设置循环,所以说,默认就是循环吗
action.reset();
const nofade = this.actionName === 'shot'
this.actionName = name.toLowerCase();
action.play();
if (this.curAction) {
if (nofade) {
this.curAction.enabled = false;
} else {
this.curAction.crossFadeTo(action, .5);
}
}
this.curAction = action;
if (this.rifle && this.rifleDirection) {
const q = this.rifleDirection[name.toLowerCase()];
if (q) {
const start = new Quaternion();
start.copy(this.rifle.quaternion);
this.rifle.quaternion.copy(q)
this.rifle.rotateX(PI / 2); // 这个是让枪竖起来
const end = new Quaternion().copy(this.rifle.quaternion)
this.rotateRifle = { start, end, time: 0 }
this.rifle.quaternion.copy(start)
}
}
}
}
相机和人物控制
这个可以说,只要是涉及探索地图类的,都可能用到。 前面已经说过,用一个额外的曲面来保证人物永远站在平面上。
人物操控
我们这里也用通常的 wsa来操控人物, ws前进后退,ad左右旋转而非移动。 这个操控很简单, 再加上,把相机放进人物的组里,就可以实现镜头跟随。 这里镜头的位置,需要自己调整。
keyHandler() {
if (this.keys.w) { this.move.up += .1 }
if (this.keys.s) { this.move.up += -.1 }
if (this.keys.a) { this.move.right += -.1 }
if (this.keys.d) { this.move.right += .1 }
// 限制速度最大值
this.move.up = max(-1, min(1, this.move.up))
this.move.right = max(-1, min(1, this.move.right))
}
相机操控
说实话,我不想把相机放在人物正后方, 因为,这样只能看到人物的背影。但是这样是最利于瞄准的。 如果你想用第一人称视角,那就是就是把相机放在人物的头那里,也许是胸,具体位置自己调整,为了能看到手,肯定是需要调整相机的near。
除了上面的,我们还希望可以直接观察四周的景象,而不用转动人物,那么就加上一个,按下并移动鼠标,旋转相机。松开鼠标,相机回归旋转之前的状态。 那么是否每次移动鼠标就记录一下当前状态呢?
这里直接维护了一个 object3d ,放在人物的组里,不移动鼠标的时候,就用它去更新鼠标的位置,当移动鼠标时,直接修改相机的角度,松开鼠标后,再继续用那个物体更新相机。
mouseMove(e) {
if(this.user.dead) return
// 竟然有movementX Y 这样方便的值 offetX相对于容器内边界 应该是除掉了border
if (!this.keys.mousedown) return;
let dx = e.offsetX - this.keys.mouseorigin.x;
let dy = e.offsetY - this.keys.mouseorigin.y;
// if(dx < -100 ) dx =-100
// if(dx > 100 ) dx = 100
console.log(e);
dx = max(-100, min(100, dx));
dy = max(-100, min(100, dy));
dx /= 100; dy /= 100;
this.onLook(-dy, dx) // dx dy都是向量 这个是逆时针旋转90度啊
}
onLook(up, right) {
console.log(up, right);
this.look.up = up * .25;
this.look.right = -right
}
update(dt = .0167) {
if(this.user.dead) return ;
let playMoved = false;// 玩家是否处于移动状态
let speed;
if (this.gamepad) {
this.gamepadHandler();
} else if (this.keys) {
this.keyHandler()
}
if (this.move.up !== 0) {
const forward = this.forward.clone().applyQuaternion(this.target.quaternion); // 本地 z轴的世界位
speed = this.move.up > 0 ? this.speed * dt : this.speed * dt * .3; // 限制后退的速度只有前进的。3
speed *= this.move.up; // 再乘上up方向的作甚 这才是速度 不考虑横向移动的速度
const pos = this.target.position.clone().add(forward.multiplyScalar(speed));// z轴的长度这里就改变了啊
pos.y += 2; // y+2作甚
this.raycaster.set(pos, this.down);// 射线向下 是为了找人物在导航网格上的投影点,前面的y+2 也就可以理解了
const intersects = this.raycaster.intersectObject(this.navMesh);
if (intersects.length > 0) { // 如果在网络上
this.target.position.copy(intersects[0].point);
playMoved = true;
}
}
if (abs(this.move.right) > .1) { // 现在的逻辑 按下至少就+。1 所以这个判断没毛病
const theta = dt * (this.move.right - .1) * 1; // 绝对值大于.1才行动, 但是这里是直接减 ,这样看来 等于是速度区间往左偏移了 。1
this.target.rotateY(theta);
playMoved = true;
}
if (playMoved) {
this.cameraBase.getWorldPosition(this.tempVec3);
this.camera.position.lerp(this.tempVec3, .7); // 第二个参数是类似斜率,需要注意的是, 这个参数类似一个指数的底数。
let run = false;
if (speed > .03) {
if (this.overRunSpeedTime) { // 这个over就是某次初始化的时间
const elapsedTime = this.clock.elapsedTime - this.overRunSpeedTime;
run = elapsedTime > .1;// 起跑时间大于。1 ?
} else {
this.overRunSpeedTime = this.clock.elapsedTime;
}
} else {
this.overRunSpeedTime = 0; // 行动结束 重置这个时间
}
// 已经在移动了至少也是行走
if (run) {
this.user.action = 'run'
} else {
this.user.action = 'walk'
}
} else { // fix 开火的时候不要执行
if (this.user && !this.user.isFiring) { this.user.action = 'idle' }
}
// 镜头
if (this.look.up === 0 && abs(this.look.right) < 0.00001) {
let lerpSpeed = .7;
this.cameraBase.getWorldPosition(this.tempVec3);
let seeThrough =true ;
// 只有在不运镜的时候才需要判断角色的可见性
if(!seeThrough && !this.game.seeUser(this.tempVec3)){
this.highCamera.getWorldQuaternion(this.temQuat) ;
this.highCamera.getWorldPosition(this.tempVec3)
}else {
this.game.seeUser(this.tempVec3, seeThrough)
this.cameraBase.getWorldQuaternion(this.temQuat);
}
this.camera.position.lerp(this.tempVec3, lerpSpeed);
this.camera.quaternion.slerp(this.temQuat, lerpSpeed)
} else { // 此时就是人物不动 旋转镜头
// console.log(this.look.up);
const delta = 1 * dt;
// 不对 这里应该理解为,他想要先绕y轴 ,再绕本地的x轴旋转
this.camera.rotateOnWorldAxis(this.yAxis, this.look.right * delta);// y 和 x的旋转分开了啊
const cameraXAxis = this.xAxis.clone().applyQuaternion(this.camera.quaternion); // y轴一直没变 所以上面直接,这里确要旋转
this.camera.rotateOnWorldAxis(cameraXAxis, this.look.up * delta); // 左右和俯仰啊
}
}
人物被遮挡
解决这个问题有两种办法。
一种办法就是,当角色被遮挡时自动切换至一个位置更高的相机,因为我们的场景是没有顶的,肯定不会遮挡的。 另一个办法就是,让相机和角色之间的物体变成透明。
两种办法都需要射线检测,检测视线是否被遮挡
在game.js中新增seeuser方法 ,接受相机的位置。
增加一个高镜头的位置,不过实际上是用同一个相机,只不过用另一个物体记录位置。
因为没有限制射线的长度,所以,判断第一个物体到相机的距离 和角色到相机的距离, 如果这个距离大于相机到角色的距离,那么就是没有遮挡。
rayCaster 捕获的物体会按距离近远排序。
seeUser(pos =new Vector3(), seeThrough =false){
this.tempVec3.copy(this.user.position).sub(pos).normalize() ;
this.rayCaster.set(pos, this.tempVec3) ;
const intersects = this.rayCaster.intersectObjects(this.factory.children, true) // 检测子集
// this.user.visible = true
// 把遮挡角色的物体放进这个数组
if(this?.seeThrough?.length){
this.seeThrough.forEach((child)=> {
child.material.transparent = false ;
})
}
if(intersects.length){
const dist = this.tempVec3.copy(this.user.position).distanceTo(pos) ; // 第一个物体如果比人物还远 那就是没有遮挡
this.user.visible = intersects[0].distance > dist ;
if(seeThrough && !this.user.visible){
intersects.some((child)=> { // 可以用some 是因为这个射线经过的物体
if(child.distance < dist ){
this.seeThrough.push(child.object) // object属性才是
child.object.material.transparent =true ;
child.object.material.opacity = .3 ;
}else { return true}
})
}else { this.seeThrough = []}
}
return this.user.visible ;
}
剩余部分
除了上面所说,就是NPC和射击了,可以说这个才是游戏的主要玩法逻辑。 但是,我太菜了,写不出来,大略说一下。
自动寻路
人物是靠键盘移动的,其实也可以点击自动寻路。这里主要用在NPC上了。 当NPC发现角色时,NPC就会往人物的所在位置寻路。
这里是直接用了 Pathfinding.js这个库,大致用法如下. navmesh就是之前处理好的地面薄膜。
newPath是主要逻辑,入参就是目的地的坐标Vec3 ,效果就是会自动寻路。
initPathfinding(navmesh) {
this.pathfinder = new Pathfinding();
this.pathfinder.setZoneData('factory', Pathfinding.createZone(navmesh.geometry, .02))
}
newPath(pt) {
const player = this.object;
if (this.pathfinder === undefined) {
this.calculatedPath = [pt.clone()];
//Calculate target direction
this.setTargetDirection(pt.clone());
this.action = 'walking';
return;
}
if (this.sfx) this.sfx.play('footsteps')
//console.log(`New path to ${pt.x.toFixed(1)}, ${pt.y.toFixed(2)}, ${pt.z.toFixed(2)}`);
const targetGroup = this.pathfinder.getGroup(this.ZONE, pt);
const closestTargetNode = this.pathfinder.getClosestNode(pt, this.ZONE, targetGroup);
// Calculate a path to the target and store it
this.calculatedPath = this.pathfinder.findPath(player.position, pt, this.ZONE, this.navMeshGroup);
if (this.calculatedPath && this.calculatedPath.length) {
this.action = 'walking';
this.setTargetDirection(this.calculatedPath[0].clone());
if (this.showPath) {
if (this.pathLines) this.app.scene.remove(this.pathLines);
const material = new LineBasicMaterial({
color: this.pathColor,
linewidth: 2
});
const points = [player.position];
// Draw debug lines
this.calculatedPath.forEach(function (vertex) {
points.push(vertex.clone());
});
let geometry = new BufferGeometry().setFromPoints(points);
this.pathLines = new Line(geometry, material);
this.app.scene.add(this.pathLines);
// Draw debug spheres except the last one. Also, add the player position.
const debugPath = [player.position].concat(this.calculatedPath);
debugPath.forEach(vertex => {
geometry = new SphereGeometry(0.2);
const material = new MeshBasicMaterial({ color: this.pathColor });
const node = new Mesh(geometry, material);
node.position.copy(vertex);
this.pathLines.add(node);
});
}
} else {
this.action = 'idle';
if (this.sfx) this.sfx.stop('footsteps');
if (this.pathfinder) {
const closestPlayerNode = this.pathfinder.getClosestNode(player.position, this.ZONE, this.navMeshGroup);
const clamped = new Vector3();
this.pathfinder.clampStep(
player.position,
pt.clone(),
closestPlayerNode,
this.ZONE,
this.navMeshGroup,
clamped);
}
if (this.pathLines) this.app.scene.remove(this.pathLines);
}
}
人物射击
角色射击的逻辑,大概就是下面这个样子。
其中有个问题就是,子弹速度很快,有可能这一帧的时候在人体的一侧,下一帧的时候,已经跑到另一侧了。所以,需要在每一帧的时候,再细分检测会不会碰到人物。
graph TD
A([按下空格])-->B[触发keydown]
B-->C{是否repeat?}
C--是-->D[不做修改]
C--否-->E[调用fire true]
E-->F([触发uer.setfiring])
F-->H(初始化子弹时间,设置动画为fire)
H-->I(触发动画变更)
a(初始化bulletHandler)-->b[调用更新方法]
b-->c(user.update,<br>在一次fire中.6s之后开始持续调用shoot)
b-->d(bulletHandler.update)
c-->e(指定枪口的位置后,<br>调用bulletHandler的createBullet,<br>更新子弹时间,<br>如此在按住空格不松的情况下每.6s秒会调用一次)
NPC挂机
当人物距离npc 20m 且视线不被遮挡,且在视角内时(有可能是背对着),就触发追击角色的动作。
flowchart TD
A(更新)-->B[更新动画混合器以及步枪的补间]
B-->C{如果npc活着且游戏活跃玩家活着}
C--是-->D{是否有敌情}
C--否-->E[如果正在开火]
D--否-->F([检测20米内有敌人])
F--敌人可视-->I(开启战斗状态)
I--距离小于10-->J[清空当前路线\n动作改为idle]
I--距离大于10-->K[新路线目标敌人]
D--是-->L(仍然检测20米内)
L--仍在20内-->M{是否正在开火}
M--是-->N{当前路线}
N--无-->O[朝向敌人]
N--有-->P[如果不在10度可视区域内]
P-->Q[关闭开火继续行走]
E-->H[关闭开火和战斗]
结语
没说的东西还有,适配移动端,血腥视觉,都还比较简单。有兴趣的看看教程和代码就知道了。
本文到这里就结束了,一个完整的游戏,哪怕是不完整的写下来,提升也很大。
下一篇,还是游戏,不过,会很简单。
下面是我照着写的,还有不少bug。
代码片段
参考链接
暂无评论内容