按照作者的初衷,基本上本书已经介绍完了所有的准备知识,本章我们介绍一个简单的Web3D游戏,并希望他能够给读者带来一些游戏设计和开发方面的灵感和经验。
游戏开发是一个很有挑战性的话题,现代计算机游戏都设计的越来越庞大复杂,剧情跌宕起伏、装备不断升级、画质精彩绝伦,令人着迷,但其背后的中心思想都是让玩家享受博弈的乐趣,因此构建一个博弈的框架是游戏开发的核心和关键。
商业游戏还要考虑收益问题,比如明确核心玩法和目标受众、注重用户体验并控制游戏难度、打磨游戏内容与剧情等,当然所有的这些目标最终要靠相关技术(声光电效果、动画技术、交互技术等)来实现,这才是本章的主要目的。
本书的主要内容都是围绕3D技术展开的,第五章的GPLControls类又解决了3D场景巡游和碰撞检测的问题,因此用他开发FPS(First-person Shooting Game,第一人称视角射击游戏)类的游戏再合适不过了。
我考虑了几天,构思了一个游戏场景:主角不幸迷失在一片原始森林中,孤身一人面临大批敌人的抓捕,要生存下来就必须战斗,我给他取了一个名字:孤军奋战,游戏的目标非常简单:打死所有敌人或者被敌人抓住,游戏的主画面如下。
<iframe allowfullscreen="true" data-mediaembed="csdn" frameborder="0" id="p9PZcGTb-1735977266219" src="https://live.csdn.net/v/embed/441791"></iframe>孤军奋战
目录
6.1 游戏设计
6.1.1 游戏背景
在一次军事行动中,主角不幸与大部队失散,迷失在一片原始森林中,孤身一人面临大批敌人的抓捕,他们训练有素、凶残至极,要生存下来就必须战斗。幸好,主角的武器相当给力,拥有一把手枪(无限子弹)和一把冲锋枪(100发子弹)。
6.1.2 游戏目标
敌人没有武器,但会试图生擒你,他们从四面八方向你追来,你必须尽可能的跑动或躲避起来,并且在移动过程中全部打死他们(胜利)或者被他们抓住(失败)。
6.1.3 游戏素材
1. 模型素材
序号 | 模型 | 描述 |
1 | 模型名称:手枪 模型格式:OBJ 模型文件:pistol.obj 素材来源:互联网 用 途:单发射击 | |
2 | 模型名称:冲锋枪 模型格式:OBJ 模型文件:ak101.obj 素材来源:互联网 用 途:连发射击 | |
3 | 模型名称:红松 模型格式:OBJ 模型文件:pine.obj 素材来源:互联网 用 途:构建森林 | |
4 | 模型名称:士兵 模型格式:GLB 模型文件:Soldier.glb 素材来源:threejs 用 途:模拟敌人 |
之所以要选用两种枪,是为了给游戏的装备升级和后期扩展留下空间,至于场景设计(红松)和怪物设计(士兵),本着轻量化的思路,没有再添加更多的元素;场景中的天空盒、山脉、安全塔楼、地板,均沿用了第五章的设计。
2. 声音素材
序号 | 声音文件 | 用途 |
1 | die.wav | 击毙怪物 |
2 | shot.wav | 射击声音 |
3 | sinister.wav | 被怪物抓住(游戏失败) |
声音可以烘托游戏的氛围,是游戏设计的必备元素,商业游戏应该要有自己的音乐团队(如背景音乐、怪物叫声、格斗声音等等),本例中的声音素材均来自互联网。
6.2 游戏开发
6.2.1 引擎设计
6.2.1.1 工作原理
对于FPS游戏来说,引擎是非常重要的,他决定了运动目标的移动规则(如碰撞精度)和仿真程度(如起跳、蹲下、跑步等),直接影响游戏的视觉效果和用户体验,我们以GPLControls类为基础,开发GameControls类专门用于该游戏的制作。
GameControls类继承自GPLControls类,而GPLControls类是基于八叉树(Octree)实现碰撞检测的,由于Octree仅仅支持球体、胶囊型两种(射线不能用于模拟物理实体)目标的碰撞检测,因此GameControls类也仅能支持这两种类型的物理目标,在轻量级的Web应用中,他们足够用了,比如人物可以使用胶囊体模拟、子弹可以使用球体模拟。
GameControls类的主要功能包括:处理球体、胶囊体的初始化、加入场景、移出场景、位置更新和碰撞检测。由于子弹被设计为球体,我们约定子弹与刚性目标发生碰撞后,要按碰撞面法线反弹,反弹后杀伤力不减(设计为碰撞后销毁子弹也是可行的)。
特别需要注意:Sphere(球体)、Capsule(胶囊体)是Octree中用于检测碰撞的两种数学模型,他们仅用于数学运算,并不是真正的3D对象(ThreeObject),不会被渲染到用户屏幕上去,因此正确的做法是为每一个3D对象绑定一个合适的数学模型,并且让二者的位置实时保持一致,其中数学模型用于碰撞检测,3D对象用于渲染屏幕,在GameControls类中只对数学模型做位置更新(引擎的任务),3D对象的位置更新则交由前端来完成(业务的任务,复制数学模型的位置即可)。
为保证引擎端和业务端的协同工作,3D对象应具备一些约定的属性或方法,以保证可以在引擎端正确地处理模型信息,我们约定如下:
数学模型 | 必备属性 | 必备方法 |
Sphere | position:球体中心位置,类型:Vector3 radius:球体半径,类型:float speed:移动速度,类型:float velocity:移动方向,类型:Vector3 | destroy:销毁对象 |
Capsule | position:胶囊体底部位置,类型:Vector3 start:胶囊底部(不含弧面),类型:Vector3 end:胶囊顶部(不含弧面),类型:Vector3 radius:两头弧面半径,类型:float onSurface:是否落于表面,类型:bool freeFallTime:自由落体时间,类型:float velocity:移动方向,类型:Vector3 moveSpeed:移动速度,类型:float | destroy:销毁对象 |
6.2.1.2 实现代码
import { Sphere, Vector3 } from 'three';
import { Capsule } from '../js_three/jsm/math/Capsule.js';
import { GPLControls } from './GPLControls.js';
class GameControls extends GPLControls {
constructor(app, floorHeight) {
super(app, floorHeight);
this.SpherePhysics = {
physics: [], //ThreeObject对象
spheres: [] //碰撞检测模型
};
this.CapsulePhysics = {
physics: [], //ThreeObject对象
capsules: [] //碰撞检测模型
};
}
//增加球形碰撞检测对象
addSpherePhysics(object) {
this.app.addObject(object);
let sphere = new Sphere(object.position, object.radius);
this.SpherePhysics.physics.push(object);
this.SpherePhysics.spheres.push(sphere);
}
//删除球形碰撞检测对象-根据数组序号
removeSpherePhysicsBySerial(xh) {
if(xh > this.SpherePhysics.physics.length - 1) return;
if(xh < 0) return;
this.app.removeObject(this.SpherePhysics.physics[xh]); //删除对象
this.SpherePhysics.physics[xh].destroy(); //销毁,释放内存
this.SpherePhysics.physics.splice(xh, 1);
this.SpherePhysics.spheres.splice(xh, 1);
}
//增加胶囊形碰撞检测对象
addCapsulePhysics(object) {
this.app.addObject(object);
let capsule = new Capsule();
capsule.set(object.start, object.end, object.radius);
this.CapsulePhysics.physics.push(object);
this.CapsulePhysics.capsules.push(capsule);
}
//删除胶囊形碰撞检测对象(如被击中)-根据数组序号
removeCapsulePhysicsBySerial_die(s) {
if(s > this.CapsulePhysics.physics.length - 1) return;
if(s < 0) return;
this.app.removeObject(this.CapsulePhysics.physics[s]); //删除对象
this.CapsulePhysics.physics[s].die(); //销毁,释放内存
this.CapsulePhysics.physics.splice(s, 1);
this.CapsulePhysics.capsules.splice(s, 1);
}
//更新所有目标
update(delta) {
super.update(delta); //调用父类update方法(巡游控制、摄像机移动)
if(!this.isLocked) return; //仅锁定屏幕时游戏才进行
//更新球形目标位置
for(let i = 0; i < this.SpherePhysics.physics.length; i++) {
let speed = this.SpherePhysics.physics[i].speed;
let radius = this.SpherePhysics.physics[i].radius;
let velocity = this.SpherePhysics.physics[i].velocity.clone();
let offset = velocity.multiplyScalar(speed * delta);
this.SpherePhysics.physics[i].position.add(offset);
this.SpherePhysics.spheres[i].set(
this.SpherePhysics.physics[i].position,
radius
);
}
//处理球形目标碰撞
if(this.collisionDetection) {
for(let i = 0; i < this.SpherePhysics.physics.length; i++) {
let result = this.octree.sphereIntersect(
this.SpherePhysics.spheres[i]);
if(result) {
//碰撞后反弹
let offset = result.normal.clone().multiplyScalar(
result.depth);
this.SpherePhysics.physics[i].position.add(offset);
//反射向量
this.SpherePhysics.physics[i].velocity.reflect(
result.normal);
//闪烁碰撞体
if(result.mesh.name.substring(0, 1) == '!')
this.flick(result.mesh);
}
}
}
//更新胶囊形目标位置
for(let i = 0; i < this.CapsulePhysics.physics.length; i++) {
let moveSpeed = this.CapsulePhysics.physics[i].moveSpeed;
let velocity = this.CapsulePhysics.physics[i].velocity.clone();
let freeFallTime = this.CapsulePhysics.physics[i].freeFallTime;
//重力感应下落(高处坠落)
let offset = velocity.multiplyScalar(moveSpeed * delta);
if(!this.CapsulePhysics.physics[i].onSurface) {
let h = -this.gravity * Math.pow(freeFallTime, 2) / 2;
offset.y += h;
}
this.CapsulePhysics.physics[i].position.add(offset);
//更新胶囊体
this.CapsulePhysics.capsules[i].set(
this.CapsulePhysics.physics[i].start,
this.CapsulePhysics.physics[i].end,
this.CapsulePhysics.physics[i].radius
);
}
//处理胶囊型目标碰撞
if(this.collisionDetection) {
for(let i = 0; i < this.CapsulePhysics.physics.length; i++) {
//获取位置
let position = this.CapsulePhysics.physics[i].position.clone();
//指向摄像机的向量
let camPos = this.camera.position.clone();
camPos.y = position.y; //去掉Y轴分量
let dn = camPos.sub(position).normalize(); //从胶囊体指向摄像机的向量(归一化)
//修改方向向量
this.CapsulePhysics.physics[i].velocity.copy(dn);
dn.negate(); //用于修改lookAt指向(原模型的面朝向是反的)
//修改面朝向
let direction = this.CapsulePhysics.physics[i].position.
clone().add(dn);
this.CapsulePhysics.physics[i].soldier.lookAt(direction);
//检测是否有碰撞
let result = this.octree.capsuleIntersect(
this.CapsulePhysics.capsules[i]);
if(result) {
//更新位置,消除碰撞
let offset = result.normal.clone().multiplyScalar(
result.depth);
this.CapsulePhysics.physics[i].position.add(offset);
let y = new Vector3(0, 1, 0);
if(result.normal.angleTo(y) < Math.PI / 6) {
//法线与Y轴的角度小于30度时,认为落于某物体表面
this.CapsulePhysics.physics[i].onSurface = true;
this.CapsulePhysics.physics[i].freeFallTime = 0;
} else
this.CapsulePhysics.physics[i].onSurface = false;
} else
this.CapsulePhysics.physics[i].onSurface = false;
//防止无限下坠(用户暂停游戏后,可能导致胶囊体无限下坠)
if(this.CapsulePhysics.physics[i].position.y<this.floorHeight){
this.CapsulePhysics.physics[i].position.y=this.floorHeight;
this.CapsulePhysics.physics[i].onSurface = true;
}
}
}
}
}
export {
GameControls
}
在这个设计中,士兵(胶囊体)可能被障碍物挡住而无法前进,也可能通过楼梯跃上安全台,如下图所示:
6.2.2 前端实现
一个完整的游戏过程,无论其体量大小,步骤都是基本一致的,从游戏开场(载入模型),到游戏进行(持续奔跑、射击),到游戏结束(胜利或失败),这个中间会有大量的工作需要处理(比如异步载入、释放资源、状态判定等),很难预先把所有的工作都规划的毫无破绽,我花了一周的时间来调试他。
根据游戏内容,设计了7个类,如下表所示。
序号 | 类名称 | 功能 | 核心方法 |
1 | MyApp | 应用程序类 | init:初始化场景 update:更新场景 handleKeyDown:换枪 handleMouseDown:射击 |
2 | SolidBody | 场景中的刚性物体 | createPlane:创建地面 createTower:创建安全台 loadTree:载入松树模型 |
3 | Pistol | 手枪 | init:载入手枪模型 shooting:射击 |
4 | Pistol_Bullet | 手枪子弹 | init:发射 sounding:枪响 destroy:销毁 |
5 | MachineGun | 冲锋枪 | init:载入冲锋枪模型 shooting:射击 |
6 | MachineGun_Bullet | 冲锋枪子弹 | init:发射 sounding:枪响 destroy:销毁 |
7 | Soldier | 士兵 | init:载入士兵模型 destroy:销毁(清空内存) die:死亡(被击中) win:胜利(抓住玩家) |
6.2.2.1 导入相关类
import * as THREE from 'three';
import { mergeGeometries } from './js_three/jsm/utils/BufferGeometryUtils.js';
import { MTLLoader } from './js_three/jsm/loaders/MTLLoader.js';
import { OBJLoader } from './js_three/jsm/loaders/OBJLoader.js';
import { GLTFLoader } from './js_three/jsm/loaders/GLTFLoader.js';
import { Logger } from './js_threesim/logger.js';
import { ThreeApp, ThreeObject } from './js_threesim/threesim.js';
import { ModelAnalysis_OBJ } from './js_threesim/ModelAnalysis_OBJ_noScale.js';
import { Sound3D } from './js_threesim/sound3d.js';
import { GameControls } from './js_threesim/GameControls.js';
import { SkyBox } from './js_Game/SkyBox.js';
import { Moutain } from './js_Game/Moutain.js';
其中手枪、冲锋枪的模型是OBJ格式的,士兵模型是GLB格式的,我们引入了相关的模型导入类,Sound3D类是一个处理音频的类,他可以根据音源与摄像机的距离自动调整音量大小,代码如下:
import { Vector3 } from 'three';
class Sound3D {
constructor(url, radius, volume) { //volumn:0~1
this.audio = document.createElement('audio');
var source = document.createElement('source');
source.src = url;
this.audio.appendChild(source);
this.audio.controls = "controls";
this.audio.loop = "loop";
this.position = new Vector3();
this.radius = radius;
this.volume = volume;
}
play() {
this.audio.play();
}
replay() {
this.audio.currentTime = 0;
this.audio.play();
}
pause() {
this.audio.pause();
}
SetPosition(v) {
this.position.copy(v);
}
update(camera) {
let distance = this.position.distanceTo(camera.position);
if(distance <= this.radius) {
this.audio.volume = this.volume * (1 - distance / this.radius);
} else {
this.audio.volume = 0;
}
}
dispose() {
this.audio.remove();
}
}
export {
Sound3D
}
6.2.2.2 全局变量
let gVar = {
sceneRidus: 200, //场景大小
sceneColor: 0xa0bba0, //场景主色调
floorHeight: 0, //地板高度
phyModels: new THREE.Object3D(), //用于碰撞检测的固定模型
availableGuns: [], //所有可用的枪,格式{id:0, gun:mesh, name:'pistol'}
currentGun: 'pistol', //初始状态使用手枪
selectGun(gunName) { //换枪
app.camera.clear(); //删除所有子类(删除旧枪)
this.availableGuns.forEach((gun, index) => {
if(gun.name == gunName) {
app.camera.add(gun.gun);
this.currentGun = gunName;
}
});
},
soldierTotals: 50, //士兵总数(敌人)
soldierLoadded: 0, //已载入的士兵数
soldiers: [], //所有士兵对象
killed: 0, //击毙目标数量
gameReady: false, //判断游戏是否全部载入完成
readyForGame: { //所有等待载入的模型
model_tree: false,
model_pistol: false,
model_machineGun: false,
model_soldier: false
},
setReady: function(x) { //某项载入完成
this.readyForGame[x] = true;
},
checkAllReady: function() { //检查所有项目是否完成
let result = true;
for(let [key, value] of Object.entries(this.readyForGame)) {
if(value == false) {
result = false;
continue;
}
}
if(result) { //所有项目载入完成,准备开始游戏
app.scene.add(this.phyModels);
app.controls = new GameControls(app, this.floorHeight);
app.controls.initOctree(this.phyModels);
app.controls.runSpeed = 15;
gVar.selectGun(gVar.currentGun);
//app.controls.octHelper.visible = true; //用于调试Octree
for(let i = 0; i < this.soldiers.length; i++) //所有士兵加入引擎
app.controls.addCapsulePhysics(this.soldiers[i]);
$('#start').text('Click to Play');
}
}
}
$("#start").click(function() {
let txt = this.textContent;
if(txt == 'Click to Play') {
gVar.gameReady = true;
this.style.display = 'none';
app.controls.lock();
app.focus();
}
});
全局变量gVar是一个对象,他控制着整个游戏的进度,由于模型载入是异步的,游戏开场部分(checkAllReady函数)的工作有一定的先后顺序,他们是有前后依赖关系的。(1)等待所有模型载入完成;(2)初始化GameControls控制器;(3)选择当前枪械、加入所有士兵;(4)游戏准备完成(gVar.gameReady = true)
6.2.2.3 应用程序类
class MyApp extends ThreeApp {
constructor() {
super();
}
init(param) {
super.init(param);
this.param = param;
this.clock = new THREE.Clock();
this.scene.fog = new THREE.Fog(0x555555, 30, 100); //加入雾
const directionalLight = new THREE.DirectionalLight(0xffffff, 2);
directionalLight.position.set(1, 1, 0);
this.scene.add(directionalLight);
const ambientLight = new THREE.AmbientLight(0x7f7f7f, 1);
this.scene.add(ambientLight);
//添加天空盒
let sky = new SkyBox();
sky.init(gVar.sceneRidus, 0x99ccff, 0xffffff);
this.addObject(sky);
//添加山脉
for(let i = 0; i < Math.PI * 2; i += Math.PI / 4) {
let moutain = new Moutain();
moutain.init(150, 50, gVar.sceneColor);
this.addObject(moutain);
moutain.setPosition(
gVar.sceneRidus * Math.sin(i), -1,
gVar.sceneRidus * Math.cos(i)
);
}
//添加刚性目标(地面、跳台、松树)
let solidBody = new SolidBody();
solidBody.init(gVar.sceneRidus, gVar.sceneColor);
this.addObject(solidBody);
//初始化手枪
let pistol = new Pistol();
pistol.init();
this.addObject(pistol);
this.pistol = pistol;
//初始化机枪
let machineGun = new MachineGun();
machineGun.init();
this.addObject(machineGun);
this.machineGun = machineGun;
//初始化士兵
for(let i = 0; i < gVar.soldierTotals; i++) {
let pos = new THREE.Vector3(
THREE.MathUtils.randFloat(-100, 100),
4,
THREE.MathUtils.randFloat(-100, 100)
);
let velocity = new THREE.Vector3(1, 0, 0);
let soldier = new Soldier();
soldier.init(pos, velocity);
this.addObject(soldier);
gVar.soldiers.push(soldier);
}
this.camera.position.set(0, 10, 0);
this.focus();
}
onWindowResize() {
super.onWindowResize(this);
if(gVar.gameReady) {
this.controls.handleResize(); //控制器跟窗口大小相关
}
}
update() {
if(gVar.gameReady) {
super.update();
let delta = this.clock.getDelta();
this.controls.update(delta);
//检测子弹生存时间是否超时(2秒),超时销毁(防止子弹过多)
for(let i = 0;i <this.controls.SpherePhysics.physics.length;i++) {
if(this.controls.SpherePhysics.physics[i].liveTime > this.controls.SpherePhysics.physics[i].lifeTime) {
this.controls.removeSpherePhysicsBySerial(i);
}
}
//检测士兵是否抓住玩家
for(let i = 0;i<this.controls.CapsulePhysics.physics.length;i++) {
let hh = this.controls.CapsulePhysics.physics[i].height / 2;
if(this.controls.CapsulePhysics.physics[i].distance < hh) {
//士兵距离摄像机的距离小于身高一半时,认为抓住了玩家
gVar.gameReady = false; //结束游戏
this.controls.CapsulePhysics.physics[i].win();
$('#start').text('Game Over');
$("#start").show(); //css('display','block');
this.controls.unLock();
clearInterval(this.mouseInterval);
//防止游戏失败后,仍然在开枪射击
}
}
//检测子弹是否击中士兵(二者同时销毁)
for(let i = 0;i < this.controls.SpherePhysics.physics.length;i++) {
for(let j = 0; j < this.controls.CapsulePhysics.physics.length; j++) {
let bulletPos = this.controls.SpherePhysics.physics[i].position;
let soldierPos = this.controls.CapsulePhysics.physics[j].heartPosition;
if(bulletPos.distanceTo(soldierPos) < 1) {
//子弹距离心脏1米之内认为击中目标
this.controls.removeCapsulePhysicsBySerial_die(j);
//清除士兵
this.controls.removeSpherePhysicsBySerial(i);
//清除子弹
gVar.killed++; //击毙数加1
break; //该子弹已销毁,处理下一颗子弹
}
}
}
//检测是否击中全部目标
if(gVar.killed >= gVar.soldierTotals) {
gVar.gameReady = false; //结束游戏
$('#start').text('Mission completed!');
$("#start").show();
this.controls.unLock();
clearInterval(this.mouseInterval);//停止射击
this.update = function() {}; //停止更新对象
}
logger.log('人眼高度:', this.camera.position.y.toFixed(2));
logger.log('立于表面:', this.controls.onSurface);
logger.log('手枪子弹:', this.pistol.shootingCount);
logger.log('机枪子弹:', this.machineGun.shootingCount, ' / ', this.machineGun.bulletCount);
logger.log('击中目标:', gVar.killed, ' / ', gVar.soldierTotals);
logger.render();
}
}
//换枪
handleKeyDown(event) {
if(this.controls.isLocked === false) return;
switch(event.key) {
case '1': //选择手枪
gVar.selectGun('pistol');
break;
case '2': //选择冲锋枪
gVar.selectGun('machineGun');
break;
}
}
//射击
handleMouseDown(x, y, event) {
if(this.controls.isLocked === false) return;
if(event.button == 0) { //左键按下
switch(gVar.currentGun) {
case 'pistol': //手枪单发
this.pistol.shooting();
break;
case 'machineGun': //冲锋枪连发
this.mouseInterval = setInterval(
() => {
this.machineGun.shooting();
},
50
);
}
}
}
//机枪停止连续射击
handleMouseUp(x, y, event) {
if(this.controls.isLocked === false) return;
if(event.button == 0) {
clearInterval(this.mouseInterval);
}
}
}
应用程序类的绝大多数代码逻辑上比较简单,请注意冲锋枪连续射击部分的代码,当按下鼠标左键时,利用setInterval函数每隔50微秒(每秒20发)自动连续射击,松开鼠标左键时,清空这个时间间隔。
6.2.2.4 刚性物体类
class SolidBody extends ThreeObject {
constructor() {
super();
}
init(radius, color) {
this.createPlane(radius, color); //添加地面
this.createTower(); //跳台
this.loadTree(); //载入树
}
//地面
createPlane(radius, color) {
let geometry = new THREE.PlaneGeometry(radius * 2, radius * 2);
geometry.rotateX(-Math.PI / 2);
let texture = new THREE.TextureLoader().load('./images/grass.jpg');
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(radius, radius);
let material = new THREE.MeshBasicMaterial({
map: texture
});
let plane = new THREE.Mesh(geometry, material);
plane.name = 'Ground';
gVar.phyModels.add(plane); //加入刚性物体列表
}
//跳台
createTower() {
let width = 10,
height = 4;
let geometry = new THREE.BoxGeometry(width, height, width);
geometry.translate(0, height / 2, 0);
let material = new THREE.MeshBasicMaterial({
map: new THREE.TextureLoader().load('./images/brick5.jpg')
});
let mesh = new THREE.Mesh(geometry, material);
mesh.name = '!Tower'; //击中后闪烁
gVar.phyModels.add(mesh);
//台阶
let geometrys = [];
let hh = 0.2, xx = 3, zz = 1, num = height / hh;
for(let i = 0; i < num; i++) {
let g = new THREE.BoxGeometry(xx, hh, zz);
g.translate(0, hh / 2 + i * hh, -(i * zz / 2));
geometrys.push(g);
}
geometry = mergeGeometries(geometrys);
geometry.translate(0, 0, num * zz / 2 + width / 2);
material = new THREE.MeshBasicMaterial({
map: new THREE.TextureLoader().load('./images/brick.jpg')
});
mesh = new THREE.Mesh(geometry, material);
mesh.rotation.set(0, Math.PI, 0);
mesh.name = 'Stair';
gVar.phyModels.add(mesh);
}
//松树
loadTree() {
let that = this;
let model = {
model_name: '松树',
file_path: './models/obj/tree/',
mtl_file: 'pine.mtl',
model_file: 'pine.obj'
};
new MTLLoader()
.setPath(model.file_path)
.load(model.mtl_file, function(materials) {
materials.preload();
new OBJLoader()
.setMaterials(materials)
.setPath(model.file_path)
.load(model.model_file, function(mesh) {
let rr = 90, dd = Math.PI / 4, ll = 3;
for(let i = 0; i < ll; i++) {
for(let j = 0 + i * dd / ll; j < Math.PI * 2 + i * dd / ll; j += dd) {
let m = mesh.clone();
m.traverse(function(obj) {
if(obj instanceof THREE.Mesh) {
obj.geometry.center();
obj.name = "!" + obj.name; //需要闪烁
let s = THREE.MathUtils.randFloat(10, 100 - i * 10);
obj.scale.set(s, s, s); //调整树的大小
obj.position.set(
rr * Math.sin(j),
0,
rr * Math.cos(j)
)
gVar.phyModels.add(obj);
}
});
}
rr -= 20;
}
gVar.setReady("model_tree"); //树载入完成
gVar.checkAllReady(); //检测所否所有模型都已载入
});
});
}
}
6.2.2.5 手枪类
class Pistol extends ThreeObject {
constructor() {
super();
this.shootingCount = 0; //发射子弹数
}
init() {
this.setObject3D(new THREE.Object3D()); //很重要,否则找不到APP对象
let model = {
model_name: '手枪',
file_path: './models/obj/',
mtl_file: 'pistol.mtl',
model_file: 'pistol.obj'
};
let obj = new ModelAnalysis_OBJ();
obj.init(model.file_path, model.mtl_file, model.model_file);
obj.subScribe("finished", (...v) => {
let pistol = new THREE.Object3D();
for(let i = 0; i < obj.SimObjects.length; i++) { //2个子类
let s = 0.015;
let mesh = obj.SimObjects[i].mesh;
mesh.scale.set(s, s, s);
mesh.position.set(0.2, -1.3, -1.5); //右手持枪
pistol.add(mesh);
}
gVar.availableGuns.push({
id: 0,
gun: pistol,
name: 'pistol'
});
gVar.setReady('model_pistol');
gVar.checkAllReady();
});
}
//射击
shooting() {
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2(0, 0);
//从摄像机指向鼠标点的射线(对于PointerLock控制器来说,鼠标点始终位于屏幕中心)
raycaster.setFromCamera(pointer, this.getApp().camera);
//子弹出镗位置,注意枪口相对于摄像机的偏移量
let pos = new THREE.Vector3(0.2, -0.3, -2.2);
pos.applyMatrix4(this.getApp().camera.matrixWorld);
//子弹方向
let vel = new THREE.Vector3();
vel.copy(raycaster.ray.direction);
vel.setLength(1); //非常重要,否则向量反射运算会出错。
let bullet = new Pistol_Bullet();
bullet.init(pos, vel);
bullet.sounding(); //开枪声
this.shootingCount++; //发射手枪子弹数加1
this.getApp().controls.addSpherePhysics(bullet); //子弹加入物理引擎中
}
}
手枪模型的载入,我们使用了一种新的方法,使用了ModelAnalysis_OBJ类,这种模型分析方法我们在第三章中曾经介绍过,他可以简化代码,类的详细代码此处不再赘述。
关于射击(shooting)动作,子弹总是从摄像机附件(枪口位置)发射,飞向鼠标点所在位置,对于PointerLock控制器来说,鼠标点是始终位于屏幕中心的,可以在html页面的中心点处添加一个准心(一幅背景透明的图片,如下图所示),这可以提高用户瞄准的精度。
根据GameControls引擎的设计,子弹在飞行过程中如果碰到障碍物会反弹,但由于子弹速度太快,碰撞检测有可能会失败,我们在第一章中已经介绍过原理,因此子弹反弹或者是击中目标(士兵),有一个概率的问题,这跟真实世界多少有点相悖(子弹穿身而过却没有击中目标)。
6.2.2.6 手枪子弹类
class Pistol_Bullet extends ThreeObject {
constructor() {
super();
this.radius = 0.3; //子弹半径
this.position = new THREE.Vector3(); //子弹当前位置
this.velocity = new THREE.Vector3(); //子弹方向向量
this.speed = 70;
//子弹能全效进行碰撞检测的最高速度为this.radius * 2 * 60 = 36
this.liveTime = 0; //生存时间,永远增长
this.lifeTime = 2; //生命时间,2秒钟后自动消亡(防止子弹过多)
}
init(pos, vec) {
this.position.copy(pos);
this.velocity.copy(vec);
let mesh = new THREE.Mesh(
new THREE.SphereGeometry(this.radius, 12, 12), //尽量减少计算量
new THREE.MeshBasicMaterial({
color: 0xffff00
})
);
mesh.position.copy(this.position);
this.id = mesh.uuid;
this.mesh = mesh;
this.setObject3D(mesh);
this.sound = new Sound3D('mp3/shot.wav', 5, 1); //射击声音
this.sound.audio.loop = false;
this.clock = new THREE.Clock();
}
sounding() { //开枪声
this.sound.replay();
}
destroy() {
this.mesh.geometry.dispose();
this.mesh.material.dispose();
this.mesh.removeFromParent(); //删除mesh
this.update = function() {}; //不再更新对象
this.sound.dispose();
}
update() {
let delta = this.clock.getDelta();
this.liveTime += delta;
this.mesh.position.copy(this.position); //更新mesh位置
}
}
请注意在update方法中,我们更新了网格的位置(this.mesh,即子弹对象),他拷贝了this.position,而这个坐标是在GameControls类中更新的,也可以这么理解:每一个物体的坐标本质上都是在GameControls类中更新的,用户屏幕上看到的物体其实只是在跟随这个坐标移动而已。
6.2.2.7 冲锋枪类
class MachineGun extends ThreeObject {
constructor() {
super();
this.shootingCount = 0; //发射子弹数
this.bulletCount = 100; //子弹总数
}
init() {
this.setObject3D(new THREE.Object3D()); //很重要,否则找不到APP对象
let model = {
model_name: '机枪',
file_path: './models/obj/',
mtl_file: '',
model_file: 'ak101.obj'
};
let obj = new ModelAnalysis_OBJ();
obj.init(model.file_path, model.mtl_file, model.model_file);
obj.subScribe("finished", (...v) => {
let machineGun = new THREE.Object3D();
for(let i = 0; i < obj.SimObjects.length; i++) {
//原始机枪模型偏转了180°,并且没有居中,需要处理
let s = 0.1;
let geometry = obj.SimObjects[i].mesh.geometry.clone();
geometry.rotateY(Math.PI);
let offset = new THREE.Vector3();
geometry.computeBoundingBox();
geometry.boundingBox.getCenter(offset);
geometry.center();
let material = new THREE.MeshPhongMaterial({
map: new THREE.TextureLoader().load("images/mental.jpg"),
color: 0x6a6a6a,
shininess: 1000
});
let mesh = new THREE.Mesh(geometry, material);
mesh.scale.set(s, s, s);
mesh.position.set(0.2, -0.6, -1.2); //右手持枪
machineGun.add(mesh);
}
gVar.availableGuns.push({
id: 0,
gun: machineGun,
name: 'machineGun'
});
gVar.setReady('model_machineGun');
gVar.checkAllReady();
});
}
//射击
shooting() {
if(this.shootingCount >= this.bulletCount) return; //子弹打完了
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
pointer.set(0, 0);
raycaster.setFromCamera(pointer, this.getApp().camera);
let pos = new THREE.Vector3(0.2, -0.3, -2.2);
pos.applyMatrix4(this.getApp().camera.matrixWorld);
//子弹方向
let vel = new THREE.Vector3();
vel.copy(raycaster.ray.direction);
vel.setLength(1); //非常重要,否则向量反射运算会出错。
let bullet = new MachineGun_Bullet();
bullet.init(pos, vel);
bullet.sounding(); //开枪声
this.shootingCount++; //发射机枪子弹数加1
this.getApp().controls.addSpherePhysics(bullet); //子弹加入物理引擎中
}
}
由于冲锋枪的模型是从互联网上下载的,他有一些问题(没有材质,枪口冲着玩家,模型没有归到原点),我们对他进行了一些处理。
由于冲锋枪的威力太大,我们给他加了100发子弹的总数限制,你可以随意修改(考虑一下装备升级)。
6.2.2.8 冲锋枪子弹类
class MachineGun_Bullet extends ThreeObject {
constructor() {
super();
this.radius = 0.3; //子弹半径
this.position = new THREE.Vector3(); //子弹当前位置
this.velocity = new THREE.Vector3(); //子弹方向向量
this.speed = 80; //子弹初速度
this.liveTime = 0; //生存时间,永远增长
this.lifeTime = 2; //生命时间,2秒钟后自动消亡(防止子弹过多)
}
init(pos, vec) {
this.position.copy(pos);
this.velocity.copy(vec);
let mesh = new THREE.Mesh(
new THREE.SphereGeometry(this.radius, 12, 12), //尽量减少计算量
new THREE.MeshBasicMaterial({
color: 0xff0000
})
);
mesh.position.copy(this.position);
this.id = mesh.uuid;
this.mesh = mesh;
this.setObject3D(mesh);
this.sound = new Sound3D('mp3/shot.wav', 5, 1); //射击声音
this.sound.audio.loop = false;
this.clock = new THREE.Clock();
}
sounding() { //开枪声
this.sound.replay();
}
destroy() {
this.mesh.geometry.dispose();
this.mesh.material.dispose();
this.mesh.removeFromParent(); //删除mesh
this.update = function() {}; //不再更新对象
this.sound.dispose();
}
update() {
let delta = this.clock.getDelta();
this.liveTime += delta;
this.mesh.position.copy(this.position);
}
}
冲锋枪子弹和手枪子弹有两个区别:一是速度更快(80,手枪是70);二是颜色为红色(手枪子弹是黄色的)。
6.2.2.9 士兵类
class Soldier extends ThreeObject {
constructor() {
super();
this.position = new THREE.Vector3(); //胶囊体底部位置
this.distance = 1000; //距离摄像机的距离,小于身高一半认为抓住了玩家
this.root = new THREE.Object3D(); //根模型
this.velocity = new THREE.Vector3(0, 1, 0); //方向向量
this.moveSpeed = 10 * THREE.MathUtils.randFloat(0.2, 1); // 移动速度
this.freeFallTime = 0; //自由落体时间
this.onSurface = false; //是否立于物体表面
//胶囊数据
this.radius = 0.3; //身宽
this.height = 1.6; //身高
this.start = this.position.clone().add(new THREE.Vector3(0, this.raduis, 0));
this.end = this.position.clone().add(new THREE.Vector3(0, this.radius + this.height, 0));
//要害位置(躯干中间位置),用于计算是否被击中
this.heartPosition = new THREE.Vector3().lerpVectors(this.start, this.end, 0.5);
}
init(pos, vec) {
this.position.copy(pos);
this.velocity.copy(vec);
this.root.position.copy(pos);
this.setObject3D(this.root);
this.sound_die = new Sound3D('mp3/die.wav', 5, 1);
this.sound_die.audio.loop = false;
this.sound_win = new Sound3D('mp3/sinister.wav', 5, 1);
this.sound_win.audio.loop = false;
this.clock = new THREE.Clock();
this.running = true; //不停奔跑,直至抓住主角
let that = this;
const loader = new GLTFLoader();
loader.load('models/glb/Soldier.glb', function(gltf) {
let model = gltf.scene;
let animations = gltf.animations;
that.scale = 1.0;
model.scale.setScalar(that.scale);
that.root.add(model);
that.soldier = model;
const mixer = new THREE.AnimationMixer(model);
mixer.clipAction(animations[1]).play();
// 0:idle ; 1:run ; 2:stand ; 3:walk
that.mixer = mixer;
let box = new THREE.BoxHelper(model);
box.geometry.computeBoundingBox();
let modelBox_max = box.geometry.boundingBox.max;
let modelBox_min = box.geometry.boundingBox.min;
let lx = (modelBox_max.x - modelBox_min.x);
let ly = (modelBox_max.y - modelBox_min.y);
let lz = (modelBox_max.z - modelBox_min.z);
//console.log(lx,ly,lz); //1.84 , 1.83 , 0.44
that.radius = lx / 2; //更新身体宽度,Z轴的一半
that.height = ly; //更新身高
gVar.soldierLoadded++; //载入的目标数
if(gVar.soldierLoadded >= gVar.soldierTotals) { //载入完成
gVar.setReady('model_soldier');
gVar.checkAllReady();
}
});
}
update() {
super.update();
//更新模型位置
this.root.position.copy(this.position);
//更新胶囊位置
this.start = this.position.clone().add(new THREE.Vector3(0, this.raduis, 0));
this.end = this.position.clone().add(new THREE.Vector3(0, this.radius + this.height, 0));
//更新要害位置
this.heartPosition = new THREE.Vector3().lerpVectors(this.start, this.end, 0.5);
//更新距离摄像机的距离
this.distance = this.heartPosition.distanceTo (this.getApp().camera.position);
if(!this.running) return; //抓住玩家后,停止动画播放
//暂停时,士兵动画是否继续
//if(!this.getApp().controls.isLocked) return;
let delta = this.clock.getDelta();
this.freeFallTime += delta;
this.mixer.update(delta);
}
destroy() { //销毁目标
if(this.soldier) {
this.soldier.traverse(function(object) {
if(object.isMesh) {
object.geometry.dispose();
object.material.dispose();
}
});
}
this.root.removeFromParent(); //删除mesh
this.update = function() {}; //不再更新对象
this.sound_die.dispose();
this.sound_win.dispose();
}
die() { //被击中
this.sound_die.replay();
this.destroy();
}
win() { //胜利
this.sound_win.replay();
this.running = false;
}
}
士兵模型是GLB格式的,并且携带动画数据(gltf.animations),我们使用了其中的跑步动画(animations[1]),这让游戏画面看起来更加真实(跑步追捕玩家),另外我们计算了模型的包围盒大小,这些数据需要用于胶囊体的计算(身高、身宽)。
如果游戏过程中,玩家按下了Q键(退出锁屏状态),这时候有多种选择,比如是否暂停游戏、是否停止跑步动画等,代码中的做法是暂停游戏,但不停止跑步动画(士兵原地跑步)。
6.2.2.10 运行程序
let logger = new Logger(document.querySelector('#debug pre'));
let container = document.getElementById("container");
let app = new MyApp();
app.init({
container: container
});
app.run();
6.3 游戏测试
6.3.1 游戏开场
6.3.2 手枪射击画面
6.3.3 冲锋枪射击画面
6.3.4 游戏胜利(击毙所有敌人)
6.3.5 游戏失败(被抓获)
标签:Threejs,THREE,mesh,开发,let,position,new,physics,第六篇 From: https://blog.csdn.net/sirtzhh007/article/details/144931062