three games 之射击游戏


theme: cyanosis

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第5天,点击查看活动详情

这个游戏的复杂程度已经超出我的学习范围了,所以我不能把完整的步骤写出来。 我会尽可能把一些可以借鉴的套路讲出来。

准备资源

上一个游戏的资源准备就是找到即可。 这个游戏对资源的处理也是比较重要的一个方面,虽然我略过了,但还是清楚它的重要性。

场景

首先是场景,游戏地图可谓是游戏中最大的一部分资源了。

原本找到的文件是max文件 ,在 https://anyconv.com/ 这个网站转fbx, 然后可以导入blend,进行编辑。 我确实不会建模软件。

总而言之一番编辑之后,我们还需要unity3d。

场景限制和寻路问题

人物在地面上行走,这是很普通的一个场景, 但是如果地面凹凸不平呢?如何让人物一直贴着地面,而不飘起来或者陷下去?如何让人物不穿墙而过? 不能一直使用距离检测吧,因为这样要检测的物体未免太多。

这里使用的思路是,在原本的场景模型表面,多生成一张,覆盖在原本的模型表面, 就是一个曲面,人物只能行走在这个膜上。 比如说,如果原地图中有一棵树,那么这个曲面这块就会空缺出来,这样人物就只能绕过去。

image.png
有了这个曲面之后,我们只需要一个竖直向下的射线,检测交点,人物永远位于这个交点,或者在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

image.png

开始写代码

首先还是一个主模块 game ,game负责 游戏的开始 结束 和其他模块的初始化。

image.png
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。
代码片段

参考链接

b站大学

老爷子的github

我的gitee

© 版权声明
THE END
喜欢就支持一下吧
点赞10 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容