3D游戏(7)——模型与动画
1、智能巡逻兵
游戏设计要求
- 创建一个地图和若干巡逻兵(使用动画);
- 每个巡逻兵走一个3~5个边的凸多边型,位置数据是相对地址。即每次确定下一个目标位置,用自己当前位置为原点计算;
- 巡逻兵碰撞到障碍物,则会自动选下一个点为目标;
- 巡逻兵在设定范围内感知到玩家,会自动追击玩家;
- 失去玩家目标后,继续巡逻;
- 计分:玩家每次甩掉一个巡逻兵计一分,与巡逻兵碰撞游戏结束;
程序设计要求
- 必须使用订阅与发布模式传消息
- subject:OnLostGoal
- Publisher: ?
- Subscriber: ?
- 工厂模式生产巡逻兵
友善提示1:生成 3~5个边的凸多边型
- 随机生成矩形
- 在矩形每个边上随机找点,可得到 3 - 4 的凸多边型
- 5 ?
友善提示2:参考以前博客,给出自己新玩法
游戏程序设计
首先,在找素材的时候找到一个别具一格的素材。Scivolo Character Controller
素材包里面不仅有一个有趣的地形(可供巡逻兵游戏的进行),还实现了第三人称的玩家模型,不仅可以通过鼠标来控制视图,可以上下左右走动,还可以跳,爬楼梯的更是不在话下。
因而,地图稍微改改布局,改宽改大,就直接用作本次游戏的场景了。人物的话计划也是改改模型即可,脚本还是用原来的。怎一个舒服了得
稍微将原来的地图改改,适当加几个AreaCollider,用于后续触发巡逻兵的追捕,如此一来这个游戏的地图就完成了。
需要注意的是AreaCollider里面的碰撞体需要设置isTrigger。
可惜的是,如此好的一个地图却无法做成预制,或许是因为这个资源包里面的Mesh Filter缺少的缘故,做成预制之后再拖出来就复原不了现在这个样子了。
原因就是做成预制之后上图的Mesh就变成了空。
这么一来的话,做不成预制,就无法像以往那样在运行时再实例化地图了,只能够通过修改Active这种蠢蠢的方式了。手动狗头
人物模型的话呢,我也找到了一个不错的,VoxelCharacters。在这里我们用到三种人物预制。
Hero用作玩家。
Zombie用作巡逻兵。
当然,为给游戏增加一点趣味性,BlueIdol用作我们此次任务需要拯救的公主。手动狗头。
动作的话,三者都是一样的,新建一个Animator Controllerperson
,
设置好参数,状态,触发isWin的话就跳到win状态,触发isDead的话就跳到die状态,isRunning为true的话就转到状态run,否则保持在Initial State。
四个状态的动作设置如上图所示,用的全都是素材包VoxelCharacters里面的动作。
毋庸置疑,事先需要把该添加的组件(动画,刚体,碰撞体),添加好。
Zombie和BlueIdol的设置都是一样的,现在任务就是把前面下载到的玩家资源里面的模型更换掉。
把原来的除了Camera Target以外的都可以删掉了,把Hero模型添加进来,同时为了方便,也可以把摄像机(素材包里面挂了脚本OrbitingCamera的摄像机,不是普通摄像机)拖进来。
这样一来直接运行的话肯定是bug一大堆,需要稍作修改。
Camera Target的高度需要调调,
脚本OrbitingCamera里面的Distance也需要调,
脚本CharacterCapsule里面的胶囊碰撞体的半径和高度要调,这里因为在脚本里面有添加碰撞体,因而在Hero里面就不需要添加刚体和碰撞体组件。
接下来就是修改脚本SimpleCharacterController里面的速度等参数,同时新增一个参数Hero。
在运动的时候需要对hero的动画进行操作。同时将原来直接对于transform
的操作全部改为hero.transform
,比如输入方向键时对游戏物体方向的旋转,只需要Hero模型旋转即可。
这么改改应该就可以了,不知道有没有遗漏的地方,具体详情可以观察我的具体代码,后续会给出仓库地址,这个素材包的脚本全都在Mente Bacata/Scivolo Character Controller
文件夹里面,可以找找。
这个动作有点憨憨
这么一来,可以说是场地,Player全都弄好了,接下来可以具体实现这个游戏了,可以在以往代码的基础上做修改,用的同样是MVC架构。
先从FirstController
开始,
public interface IUserAction
{
void Restart();
}
首先在这个游戏里面,IUserAction
只需要一个Restart接口即可。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class FirstController : MonoBehaviour, ISceneController, IUserAction {
private bool isRuning;
private int score;
private CCActionManager actionManager;
private GameEventManager eventManager = GameEventManager.GetInstance();
private GameObject player;
private GameObject BlueIdol;
private List<GameObject> Zombies = new List<GameObject>();
private int currentArea = 99;
public GameObject plane;
// the first scripts
void Awake () {
SSDirector director = SSDirector.getInstance ();
director.setFPS (60);
director.currentSceneController = this;
director.currentSceneController.LoadResources ();
this.gameObject.AddComponent<UserGUI>();
this.gameObject.AddComponent<CCActionManager>();
actionManager=this.gameObject.GetComponent<CCActionManager>();
GameEventManager.onPlayerEnterArea+=OnPlayerEnterArea;
GameEventManager.onZombieCollideWithPlayer+=OnZombieCollideWithPlayer;
GameEventManager.onPlayerCollideWithBlueIdol+=OnPlayerCollideWithBlueIdol;
score=0;
}
// loading resources for the first scence
public void LoadResources () {
isRuning=true;
// Map.LoadPlane();
plane.SetActive(true);
LoadZombies();
LoadPlayer();
LoadBlueIdol();
}
public bool GetIsRuning(){
return isRuning;
}
public void JudgeCallback(bool isRuning,int score){
this.score+=score;
this.gameObject.GetComponent<UserGUI>().score=this.score;
this.isRuning=isRuning;
}
public void Pause ()
{
throw new System.NotImplementedException ();
}
public void Resume ()
{
throw new System.NotImplementedException ();
}
private void OnPlayerEnterArea(int area){
if(isRuning&¤tArea!=area){
if(currentArea!=99){
score++;
this.gameObject.GetComponent<UserGUI>().score=this.score;
Zombies[currentArea].GetComponent<Zombie>().isFollowing=false;
}
currentArea=area;
Zombies[currentArea].GetComponent<Zombie>().isFollowing=true;
actionManager.Trace(Zombies[currentArea],player);
}
}
private void OnZombieCollideWithPlayer(){
isRuning=false;
player.transform.GetChild(1).gameObject.GetComponent<Animator>().SetTrigger("isDead");
this.gameObject.GetComponent<UserGUI>().gameMessage="You Lose!!";
player.tag="Finish";
player.GetComponent<Rigidbody>().isKinematic=true;
Zombies[currentArea].GetComponent<Zombie>().isFollowing=false;
actionManager.Stop();
for(int i=0;i<11;i++){
Zombies[i].GetComponent<Animator>().SetTrigger("isWin");
}
}
private void OnPlayerCollideWithBlueIdol(){
isRuning=false;
player.transform.GetChild(1).gameObject.GetComponent<Animator>().SetTrigger("isWin");
BlueIdol.GetComponent<Animator>().SetTrigger("isWin");
this.gameObject.GetComponent<UserGUI>().gameMessage="You Win!!";
player.tag="Finish";
player.GetComponent<Rigidbody>().isKinematic=true;
Zombies[currentArea].GetComponent<Zombie>().isFollowing=false;
actionManager.Stop();
for(int i=0;i<11;i++){
Zombies[i].GetComponent<Animator>().SetTrigger("isDead");
}
}
private void LoadZombies(){
GameObject ZombiePrefab=Resources.Load<GameObject>("Prefabs/Zombie");
for(int i=0;i<11;i++){
GameObject Zombie = Instantiate(ZombiePrefab);
Zombie.AddComponent<Zombie>().area=i;
Zombie.GetComponent<Rigidbody>().freezeRotation=true;
Zombie.AddComponent<ZombieCollider>();
Zombie.name="Zombie"+i;
Zombie.GetComponent<Animator>().Play("Initial State");
Zombie.GetComponent<Animator>().SetBool("isRunning",true);
Zombie.transform.position=Map.center[i]+new Vector3(0,5,0);
Zombies.Add(Zombie);
}
}
private void LoadPlayer(){
player=Instantiate(Resources.Load<GameObject>("Prefabs/Player"));
player.GetComponent<Rigidbody>().freezeRotation=true;
player.AddComponent<PlayerCollider>();
player.transform.position=new Vector3(0,0,-380);
player.tag="Player";
}
private void LoadBlueIdol(){
BlueIdol=Instantiate(Resources.Load<GameObject>("Prefabs/BlueIdol"));
BlueIdol.GetComponent<Rigidbody>().freezeRotation=true;
BlueIdol.transform.position=new Vector3(0,0,140);
BlueIdol.tag="Finish";
}
#region IUserAction implementation
public void Restart ()
{
isRuning=true;
this.gameObject.GetComponent<UserGUI>().gameMessage="";
this.gameObject.GetComponent<UserGUI>().score=0;
score=0;
currentArea=99;
player.transform.position=new Vector3(0,0,-380);
player.GetComponent<Rigidbody>().isKinematic=false;
player.transform.rotation=Quaternion.AngleAxis(0,Vector3.up);
player.transform.GetChild(1).gameObject.GetComponent<Animator>().Play("Initial State");
for(int i=0;i<11;i++)
{
Zombies[i].GetComponent<Animator>().Play("Initial State");
Zombies[i].GetComponent<Animator>().SetBool("isRunning",true);
Zombies[i].transform.position=Map.center[i]+new Vector3(0,5,0);
Zombies[i].GetComponent<Zombie>().isFollowing=false;
actionManager.GoAround(Zombies[i]);
}
player.tag="Player";
}
#endregion
// Use this for initialization
void Start () {
//give advice first
}
// Update is called once per frame
void Update () {
if(isRuning){
for(int i=0;i<11;i++){
if(i!=currentArea){
actionManager.GoAround(Zombies[i]);
}
else{
Zombies[i].GetComponent<Zombie>().isFollowing=true;
actionManager.Trace(Zombies[i],player);
}
}
}
}
}
在Awake函数里面依旧是对各个变量的初始化,这一点毋庸置疑,同时因为我们使用订阅与发布模式,在GameEventManager
里面我们会有玩家进入区域、巡逻兵与玩家碰撞、玩家与公主碰面等事件的处理,因而在FirstController
的Awake函数里面需要对这些事件进行赋值,以符合订阅与发布模式。
LoadResources里包含有地形、巡逻兵、玩家、公主的加载,地形的话前面也有说到,做不成预制,无可奈何只能够通过SetActive这种蠢蠢的方式。玩家、公主的加载只是普通的游戏对象预制实例化过程,并作相应的初始化操作。而巡逻兵的加载,虽说是需要用工厂模式,但是在这里,考虑到只是在最初的时候产生出来就完事了,后续都不会再有添加/删除操作了,因而偷了一下懒,只是把预制加载出一个游戏对象,再把这个游戏对象实例化11个出来就完事了。
接下来就是玩家进入区域、巡逻兵与玩家碰撞、玩家与公主碰面三种事件的处理函数,OnPlayerEnterArea表明玩家进入了一个新的区域,这个区域内的巡逻兵就会来追你,旧的那个区域的巡逻兵也就被甩开了,不再追逐,分数+1;OnZombieCollideWithPlayer的话,玩家就被追上了,游戏就结束了,输了;OnPlayerCollideWithBlueIdol表明玩家已经“救出”公主了,游戏也就结束了,巡逻兵不再追逐,玩家胜利。
Restart函数则是对游戏参数恢复初始状态,巡逻兵、玩家等位置也恢复回初始状态。
Update函数主要是针对巡逻兵而言,当前处在追逐状态的巡逻兵依旧在追逐,否则继续巡逻。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GameEventManager
{
private static GameEventManager _instance;
public delegate void OnPlayerEnterArea(int area);
public static event OnPlayerEnterArea onPlayerEnterArea;
public delegate void OnZombieCollideWithPlayer();
public static event OnZombieCollideWithPlayer onZombieCollideWithPlayer;
public delegate void OnPlayerCollideWithBlueIdol();
public static event OnPlayerCollideWithBlueIdol onPlayerCollideWithBlueIdol;
private GameEventManager()
{
}
public static GameEventManager GetInstance()
{
if (_instance == null) {
_instance = new GameEventManager();
}
return _instance;
}
public void PlayerEnterArea(int area)
{
onPlayerEnterArea?.Invoke(area);
}
public void ZombieCollideWithPlayer()
{
onZombieCollideWithPlayer?.Invoke();
}
public void PlayerCollideWithBlueIdol()
{
onPlayerCollideWithBlueIdol?.Invoke();
}
}
GameEventManager
就是玩家进入区域、巡逻兵与玩家碰撞、玩家与公主碰面三种事件的处理,根据FirstController发不出来的内容来处理。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Map : Object
{
private static GameObject planePrefab=Resources.Load<GameObject>("Prefabs/Plane");
public static Vector3[] center=new Vector3[]{new Vector3(-55,0,-300),new Vector3(0,0,-300),new Vector3(55,0,-300),new Vector3(-55,0,-140),new Vector3(0,0,-115),new Vector3(55,0,-90),new Vector3(-30,0,-17),new Vector3(30,0,-17),new Vector3(0,0,50),new Vector3(55,0,120),new Vector3(-55,0,120)};
public static void LoadPlane(){
GameObject map=Instantiate(planePrefab);
}
}
前面巡逻兵加载、Restart等函数里面都涉及到有位置属性,这些都保存在Map
里面,如果地形需要从预制加载的话,也是在这里实现。
接下来就到细致一点的了。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Zombie : MonoBehaviour
{
public int area;
public bool isFollowing=false;
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
}
}
Zombie
里面存的是当前巡逻兵处在的巡逻区域、是否处在追逐状态。就好比前一个打飞碟作业的UFOData
的作用。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class AreaCollider : MonoBehaviour
{
public int area;
public void OnTriggerEnter(Collider collider){
if(collider.gameObject.tag=="Player"){
GameEventManager.GetInstance().PlayerEnterArea(area);
}
}
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
}
}
AreaCollider
要挂载到地图的11个区域里面,当这个区域里面检测到有碰撞体Player进来的时候就调用GameEventManager
里面的PlayerEnterArea函数。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ZombieCollider : MonoBehaviour
{
public void OnCollisionEnter(Collision collision){
if(collision.gameObject.tag=="Player"){
GameEventManager.GetInstance().ZombieCollideWithPlayer();
}
else{
}
}
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
}
}
ZombieCollider
则是检测跟Player的碰撞,然后调用ZombieCollideWithPlayer函数。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerCollider : MonoBehaviour
{
public void OnCollisionEnter(Collision collision){
if(collision.gameObject.tag=="Finish"){
GameEventManager.GetInstance().PlayerCollideWithBlueIdol();
}
else{
}
}
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
}
}
PlayerCollider
也是类似的操作。
如此一来,剩下的就只有动作的实现了。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CCActionManager : SSActionManager, ISSActionCallback
{
private int currentArea=-1;
Dictionary<int,CCMoveToAction> moveToActions=new Dictionary<int,CCMoveToAction>();
protected new void Start()
{
}
public void Trace(GameObject Zombie,GameObject player){
var area=Zombie.GetComponent<Zombie>().area;
if(area==currentArea){
return;
}
currentArea=area;
if(moveToActions.ContainsKey(area)){
moveToActions[area].destroy=true;
}
CCTraceAction action=CCTraceAction.GetSSAction(player,25);
this.RunAction(Zombie,action,this);
}
public void GoAround(GameObject Zombie){
var area=Zombie.GetComponent<Zombie>().area;
if(moveToActions.ContainsKey(area)){
return;
}
var target=GetGoAroundTarget(Zombie);
CCMoveToAction action=CCMoveToAction.GetSSAction(target,12,area);
moveToActions.Add(area,action);
this.RunAction(Zombie,action,this);
}
public void Stop(){
foreach(var x in moveToActions.Values){
x.destroy=true;
}
moveToActions.Clear();
currentArea=-1;
}
private Vector3 GetGoAroundTarget(GameObject Zombie){
Vector3 pos=Zombie.transform.position;
var area=Zombie.GetComponent<Zombie>().area;
float x_down=Map.center[area].x-27.5f;
float x_up=Map.center[area].x+27.5f;
float z_down=Map.center[area].z-40;
float z_up=Map.center[area].z+40;
var move=new Vector3(Random.Range(-15,15),0,Random.Range(-15,15));
var next=pos+move;
int tryCount=0;
while(!(next.x>x_down&&next.x<x_up&&next.z>z_down&&next.z<z_up)||next==pos){
move=new Vector3(Random.Range(-15,15),0,Random.Range(-15,15));
next=pos+move;
if((++tryCount)>100){
next=Map.center[area];
break;
}
}
return next;
}
public void SSActionEvent(SSAction source,
SSActionEventType events=SSActionEventType.Competed,
int intParam=0,
string strParam=null,
Object objectParam=null){
var area=source.gameObject.GetComponent<Zombie>().area;
if(moveToActions.ContainsKey(area)){
moveToActions.Remove(area);
}
}
}
在先前的CCActionManager
上做修改。同时需要用一个Dictionary来记录正在执行过程中的动作,因为好比巡逻兵的巡逻,它需要到达一个目标点之后才转向另一个目标点前进。回调函数就是将那个记录从Dictionary清除。
巡逻的目标点则是通过函数GetGoAroundTarget来完成,通过一种随机取点的方式。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CCMoveToAction : SSAction
{
public Vector3 target;
public float speed;
public int area;
public static CCMoveToAction GetSSAction(Vector3 target, float speed, int area){
CCMoveToAction action=ScriptableObject.CreateInstance<CCMoveToAction>();
action.target=target;
action.speed=speed;
action.area=area;
return action;
}
// Start is called before the first frame update
public override void Start()
{
if(target-gameObject.transform.position!=Vector3.zero){
Quaternion rotation=Quaternion.LookRotation(target-gameObject.transform.position,Vector3.up);
gameObject.transform.rotation=rotation;
}
}
// Update is called once per frame
public override void Update()
{
if((gameObject.transform.position-target).magnitude<0.1f){
destroy=true;
callback.SSActionEvent(this);
}
else{
gameObject.transform.position=Vector3.MoveTowards(gameObject.transform.position,target,speed*Time.deltaTime);
}
}
}
CCMoveToAction
实现的是巡逻兵每次巡逻的走动。跟先前牧师与魔鬼的实现有些许类似,向一个固定的目的地移动,但是需要涉及方向的转动。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CCTraceAction : SSAction
{
public GameObject target;
public float speed;
public static CCTraceAction GetSSAction(GameObject target,float speed){
CCTraceAction action = CreateInstance<CCTraceAction>();
action.target = target;
action.speed = speed;
return action;
}
// Start is called before the first frame update
public override void Start()
{
}
// Update is called once per frame
public override void Update()
{
gameObject.transform.position=Vector3.MoveTowards(gameObject.transform.position,target.transform.position,speed*Time.deltaTime);
if(gameObject.GetComponent<Zombie>().isFollowing==false||(gameObject.transform.position-target.transform.position).sqrMagnitude<0.1f){
destroy=true;
callback.SSActionEvent(this);
}
else{
Quaternion rotation=Quaternion.LookRotation(target.transform.position-gameObject.transform.position,Vector3.up);
gameObject.transform.rotation=rotation;
}
}
}
而CCTraceAction
则是要朝向Player移动。
最后,UserGUI
直接套用先前的差不多了,游戏可以快乐玩耍了。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class UserGUI : MonoBehaviour {
private IUserAction action;
public string gameMessage;
public int score;
void Start () {
action = SSDirector.getInstance ().currentSceneController as IUserAction;
score=0;
}
void OnGUI() {
float width = Screen.width / 6;
float height = Screen.height / 12;
//action.Check();
GUIStyle style = new GUIStyle();
style.normal.textColor = Color.red;
style.fontSize = 30;
GUI.Label(new Rect(320,100,50,200),gameMessage,style);
GUI.Label(new Rect(width*2, 0, width, height),"Score: ",style);
GUI.Label(new Rect(width*3, 0, width, height),score.ToString(),style);
if (GUI.Button(new Rect(0, 0, width, height), "Restart")) {
action.Restart();
}
string paused_title = SSDirector.getInstance ().Paused ? "Resume" : "Pause!";
if (GUI.Button(new Rect(width, 0, width, height), paused_title)) {
SSDirector.getInstance ().Paused = !SSDirector.getInstance ().Paused;
}
}
}