首页 > 其他分享 >Threejs开发指南(第六篇 游戏开发(孤军奋战))

Threejs开发指南(第六篇 游戏开发(孤军奋战))

时间:2025-01-04 16:30:09浏览次数:6  
标签:Threejs THREE mesh 开发 let position new physics 第六篇

按照作者的初衷,基本上本书已经介绍完了所有的准备知识,本章我们介绍一个简单的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 游戏背景

6.1.2 游戏目标

6.1.3 游戏素材

6.2 游戏开发

6.2.1 引擎设计

6.2.1.1 工作原理

6.2.1.2 实现代码

6.2.2 前端实现

6.2.2.1 导入相关类

6.2.2.2 全局变量

6.2.2.3 应用程序类

6.2.2.4 刚性物体类

6.2.2.5 手枪类

6.2.2.6 手枪子弹类

6.2.2.7 冲锋枪类

6.2.2.8 冲锋枪子弹类

6.2.2.9 士兵类

6.2.2.10 运行程序

6.3 游戏测试

6.3.1 游戏开场

6.3.2 手枪射击画面

6.3.3 冲锋枪射击画面

6.3.4 游戏胜利(击毙所有敌人)

6.3.5 游戏失败(被抓获)


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

相关文章

  • 程序开发体会
    通过此次程序开发,我认识到精心进行软件分析设计的必要性。1.需求分析此次程序的需求比较明确,但是依然有一些细节有待考虑。包括在搜索时得到所有同一类型的所有物品的需求、在删除时先确认要删除的物品信息无误的需求等。这些需求的分析需要开发者站在用户的视角下进行考虑,通过......
  • activiti6.0.0 二次开发兼容达梦数据库(亲测有效)
    一、前因最近公司做数据库国产化,数据从MySql数据库中迁移到达梦(DM8),在迁移过程中,当迁移工作流(Activiti6.0.0)时,提换达梦(DM8)数据库驱动后启动过程报错:Causedby:org.activiti.engine.ActivitiException:couldn'tdeductdatabasetypefromdatabaseproductname'DMDBMS'二......
  • 开发规范.NET-v1.0.241127
    一、编程规范(一)命名风格命名要找更有表现力的词,更专业的词,比如获取数据不用get而使用fetch别害怕长名称,长而具有描述性的名称比短而令人费解的名称好为作用域大的名字采用更长的名字,作用域小的使用短名字给变量名带上重要的细节,比如加上单位ms等。【强制】严禁......
  • 开发规范JAVA-v1.0_.241127
    一、编程规约(一)命名风格【强制】代码中的命名均不能以下划线或美元符号开始,也不能以下划线或美元符号结束。反例:_name/_name/$Object/name/name$/Object$【强制】代码中的命名严禁使用拼音与英文混合的方式,更不允许直接使用中文的方式。说明:正确的英文拼写和......
  • 虾皮店铺商品API接口的开发、运用与收益
    在电子商务蓬勃发展的今天,电商平台纷纷开放API接口,为开发者提供了丰富的数据资源和功能。作为东南亚领先的电商平台,虾皮(Shopee)通过其开放的API接口,为商家和开发者提供了全面的数据支持,极大地推动了电商数据分析与应用的发展。本文将详细介绍虾皮店铺商品API接口的开发、运用及......
  • 支付宝开放平台及支付宝开发文档
    支付宝支付商户号、商户名称支付宝公钥支付宝公钥(alipay_public_key),是第一次上传应用公钥之后,支付宝平台自动生成的值,非密钥工具生成的应用公钥值,该值主要用于验证支付宝返回的数据通知(即验签)。详情见:https://opendocs.alipay.com/support/01rauu应用公钥、应用私钥......
  • 一书从零到精通入门大模型开发了!《从零开始大模型开发与微调》【附PDF】
    前言在人工智能领域,大型预训练模型(LargePre-trainedModels,LPMs)已经成为推动自然语言处理(NLP)技术发展的重要力量。这些模型在海量数据上进行预训练,能够捕捉到丰富的语言模式和知识,进而在各种下游任务上展现出卓越的性能。今天,给大家分享的这份手册以PyTorch2.0为基础......
  • uni-app开发微信小程序后,解决主包过大,无法上传代码问题
    1、在开发工具HBuilderX,点击运行>运行到模拟器>运行时是否压缩代码, 小程序运行时,这里会提示2、所以,可以选择发行>小程序-微信 3、重新获取AppId后,继续点击发行,则会编译成功了  4、另外,在package.json文件里面加入 --minimize最小压缩 "dev:mp-weixin":"cross-e......
  • JAVA开发中 MyBatis XML 映射文件 的作用
    MyBatisXML映射文件(通常是以.xml结尾的文件,例如UserMapper.xml)是MyBatis框架的重要组成部分,主要用于定义SQL语句、结果映射关系以及参数绑定。它的作用是将Java方法与SQL语句关联起来,实现持久化操作(如查询、插入、更新和删除)。以下是关于MyBatisXML映射文......
  • 【从零开始入门unity游戏开发之——unity篇04】unity6基础入门——场景窗口(Scene)和层
    文章目录场景窗口(Scene)和层级窗口(Hierarchy)一、层级窗口(`Hierarchy`)1、添加新的对象(物体)2、`Hierarchy`层级窗口快捷键3、搜索二、Scene场景窗口1、工具栏控制台2、操作物体位置角度和缩放工具栏(1)平移(2)移动(3)旋转(4)缩放(5)矩形工具(6)综合(7)编辑碰撞体积3、窗口上方工具条内......