游戏规则与要求
游戏设计要求:
- 创建一个地图和若干巡逻兵(使用动画);
- 每个巡逻兵走一个3~5个边的凸多边型,位置数据是相对地址。即每次确定下一个目标位置,用自己当前位置为原点计算;
- 巡逻兵碰撞到障碍物,则会自动选下一个点为目标;
- 巡逻兵在设定范围内感知到玩家,会自动追击玩家;
- 失去玩家目标后,继续巡逻;
- 计分:玩家每次甩掉一个巡逻兵计一分,与巡逻兵碰撞游戏结束;
程序设计要求:
-
必须使用订阅与发布模式传消息
-
工厂模式生产巡逻兵
游戏规则
- 玩家通过键盘控制人物在地图上移动
- 地图分为九个部分,有八个巡逻兵在出生点以外的部分巡逻
- 当玩家进入一个部分时该部分的巡逻兵就会追逐玩家,若玩家甩开巡逻兵则分数加一,若玩家与巡逻兵触碰则游戏结束。
具体实现
人物模型
使用了前辈的资源 YBoy
- 动画状态机
将所使用的动画都拖入人物动画器的Base Layer中,实现人物动作:
- 按WASD进行行走和转向
- 站立不动时按下空格后跳一小步,行走时按下空格向前翻滚
- 奔跑时按下空格向前跳跃,在空中跳跃完成后下落,下落到地面时翻滚
- 按SHIFT奔跑
- ground动画混合树
由站立,走路,奔跑三个动画组成
-
创建过渡与参数控制
-
ActorController
脚本控制参数
//变量
public GameObject model; //人物模型
public PlayerInput pi; //用户输入
public float walkSpeed = 1.5f; //行走速度
public float runMultiplier = 2.7f; //奔跑速度
public float jumpVelocity = 4f; //跳跃速度
public float rollVelocity = 1f; //翻滚速度
[SerializeField]
private Animator anim; //动画控制器
private Rigidbody rigid; //刚体组件
private Vector3 planarVec; //平面移动向量
private Vector3 thrustVec; //跳跃冲量
private bool lockPlanar = false; //跳跃时锁死平面移动向量
//刷新每秒60次
void Update() {
//修改动画混合树
float targetRunMulti = pi.run ? 2.0f : 1.0f;
anim.SetFloat("forward", pi.Dmag * Mathf.Lerp(anim.GetFloat("forward"), targetRunMulti, 0.3f));
//播放翻滚动画
if (rigid.velocity.magnitude > 1.0f) {
anim.SetTrigger("roll");
}
//播放跳跃动画
if (pi.jump) {
anim.SetTrigger("jump");
}
//转向
if(pi.Dmag > 0.01f) {
Vector3 targetForward = Vector3.Slerp(model.transform.forward, pi.Dvec, 0.2f);
model.transform.forward = targetForward;
}
if(!lockPlanar) {
//保存供物理引擎使用
planarVec = pi.Dmag * model.transform.forward * walkSpeed * (pi.run ? runMultiplier : 1.0f);
}
}
//物理引擎每秒50次
private void FixedUpdate() {
//修改位置、速度
rigid.velocity = new Vector3(planarVec.x, rigid.velocity.y, planarVec.z) + thrustVec;
//一帧
thrustVec = Vector3.zero;
}
- PlayerInput
获取玩家输入,控制角色的行走与转向,同时,在空中不允许用户输入,还需要解决同时按下W和A时移动速度会与只按下W不同的问题,即将矩形坐标转为圆坐标。
public class PlayerInput : MonoBehaviour {
[Header("---- KeyCode Settings ----")]
public string keyUp = "w";
public string keyDown = "s";
public string keyLeft = "a";
public string keyRight = "d";
public string keyA = "left shift";
public string keyB = "space";
public string keyC = "k";
public string keyD;
public string keyJUp = "up";
public string keyJDown = "down";
public string keyJLeft = "left";
public string keyJRight = "right";
[Header("---- Output Settings ----")]
public float Dup;
public float Dright;
public float Dmag;
public Vector3 Dvec;
public float Jup;
public float Jright;
public bool run;
public bool jump;
private bool lastJump;
[Header("---- Other Settings ----")]
public bool inputEnabled = true;
private float targetDup;
private float targetDright;
private float velocityDup;
private float velocityDright;
void Start() {}
void Update() {
Jup = (Input.GetKey(keyJUp)) ? 1.0f : 0 - (Input.GetKey(keyJDown) ? 1.0f : 0);
Jright = (Input.GetKey(keyJRight)) ? 1.0f : 0 - (Input.GetKey(keyJLeft) ? 1.0f : 0);
targetDup = (Input.GetKey(keyUp) ? 1.0f : 0) - (Input.GetKey(keyDown) ? 1.0f : 0);
targetDright = (Input.GetKey(keyRight) ? 1.0f : 0) - (Input.GetKey(keyLeft) ? 1.0f : 0);
if(!inputEnabled) {
targetDup = 0;
targetDright = 0;
}
//平滑变动
Dup = Mathf.SmoothDamp(Dup, targetDup, ref velocityDup, 0.1f);
Dright = Mathf.SmoothDamp(Dright, targetDright, ref velocityDright, 0.1f);
/*矩形坐标转圆坐标*/
Vector2 tempDAxis = SquareToCircle(new Vector2(Dup, Dright));
float Dup2 = tempDAxis.x;
float Dright2 = tempDAxis.y;
Dmag = Mathf.Sqrt((Dup2 * Dup2) + (Dright2 * Dright2));
Dvec = Dright * transform.right + Dup * transform.forward;
run = Input.GetKey(keyA);
/*跳跃*/
bool newJump = Input.GetKey(keyB);
lastJump = jump;
if(lastJump == false && newJump == true) {
jump = true;
}
else {
jump = false;
}
}
/*矩形坐标转圆坐标*/
private Vector2 SquareToCircle(Vector2 input) {
Vector2 output = Vector2.zero;
output.x = input.x * Mathf.Sqrt(1 - (input.y * input.y) / 2.0f);
output.y = input.y * Mathf.Sqrt(1 - (input.x * input.x) / 2.0f);
return output;
}
}
- FSM
用于发送消息
- FSMOnEnter——进入状态时向父级发送消息
- FSMOnExit——在状态退出时发出消息
- FSMOnUpdate——在状态刷新时发出消息,用来实现翻滚和跳跃
- FSMClearSignals——在状态进入和退出时清除多余的Trigger,否则按一下空格会给jump参数储存多个Trigger,跳跃多次
public class FSMClearSignals : StateMachineBehaviour { public string[] ClearAtEnter; public string[] ClearAtExit; public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { foreach (var signal in ClearAtEnter) { animator.ResetTrigger(signal); } } public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { foreach (var signal in ClearAtExit) { animator.ResetTrigger(signal); } }
}
```
巡逻兵
同样是 YBoy的
与玩家使用同一个模型和动画器,不过通过代码控制其只有行走和奔跑两个动作,巡逻时行走,追逐玩家时奔跑。
控制其的代码实现:
- GuardPatrolAction
实现追逐玩家部分,其中Update只更新平面移动的向量,FixedUpdate使用该向量进行移动,通过修改rigid的velocity来s实现移动。
//Update只更新平面移动的向量
public override void Update() {
//保留供物理引擎调用
planarVec = gameobject.transform.forward * data.walkSpeed;
}
//FixedUpdate使用该向量进行移动,通过修改rigid的velocity来移动
public override void FixedUpdate() {
//巡逻
Gopatrol();
//玩家进入该区域,巡逻结束,开始追逐
if (data.playerSign == data.sign) {
this.destroy = true;
this.callback.SSActionEvent(this, SSActionEventType.Competeted, 0, this.gameobject);
}
}
- GuardFollowAction
实现玩家离开后正常巡逻,和与GuardPatrolAction类似。
public override void Update() {
//保留供物理引擎调用
planarVec = gameobject.transform.forward * speed;
}
public override void FixedUpdate() {
transform.LookAt(player.transform.position);
rigid.velocity = new Vector3(planarVec.x, rigid.velocity.y, planarVec.z);
//如果玩家脱离该区域则继续巡逻
if (data.playerSign != data.sign) {
this.destroy = true;
this.callback.SSActionEvent(this, SSActionEventType.Competeted, 1, this.gameobject);
}
}
- GuardActionManager
实现巡逻兵动作切换的功能,回调函数SSActionEvent,当一个动作被销毁时调用另一个动作
public void SSActionEvent(
SSAction source, SSActionEventType events = SSActionEventType.Competeted,
int intParam = 0, GameObject objectParam = null) {
if (intParam == 0) {
//追逐
GuardFollowAction follow = GuardFollowAction.GetSSAction(player);
this.RunAction(objectParam, follow, this);
} else {
//巡逻
GuardPatrolAction move = GuardPatrolAction.GetSSAction(objectParam.gameObject.GetComponent<GuardData>().start_position);
this.RunAction(objectParam, move, this);
Singleton<GameEventManager>.Instance.PlayerEscape();
}
}
地图部分
地图分为九个格子,每个区域有自己的碰撞器,并挂载AreaCollide.cs
AreaCollide:当玩家进入时设置场景控制器中的玩家区域标志,然后场景控制器通知对应的巡逻兵追逐玩家。
public class AreaCollide : MonoBehaviour {
public int sign = 0;
private FirstSceneController sceneController;
private void Start() {
sceneController = SSDirector.GetInstance().CurrentScenceController as FirstSceneController;
}
void OnTriggerEnter(Collider collider) {
if (collider.gameObject.tag == "Player") {
sceneController.playerSign = sign;
}
}
}
消息订阅/发布模式
也称为观察者(Observer)模式:多个对象间存在一对多关系,当一个对象发生改变时,把这种改变通知给其他多个对象,从而影响其他对象的行为。
- GameEventManager
专门发布事件,订阅者可以订阅该类的事件,当其他类发生改变的时候,会使用GameEventManager的方法发布消息,触发相应事件。
public delegate void ScoreEvent();
public static event ScoreEvent ScoreChange;
public delegate void GameoverEvent();
public static event GameoverEvent GameoverChange;
public void PlayerEscape() {
if (ScoreChange != null) {
ScoreChange();
}
}
public void PlayerGameover(){
if (GameoverChange != null) {
GameoverChange();
}
}
- FirstSenceController
作为订阅者,订阅了GameEventManager中的事件,只要相应事件发生,就会导致场景控制器调用注册的方法。
void OnEnable() {
GameEventManager.ScoreChange += AddScore;
GameEventManager.GameoverChange += Gameover;
}
void OnDisable() {
GameEventManager.ScoreChange -= AddScore;
GameEventManager.GameoverChange -= Gameover;
}
void AddScore() {
recorder.AddScore();
}
void Gameover() {
game_over = true;
}
工厂模式
这部分的代码和上一次的作业是基本一致的,就不赘述了
- GuardFactory
巡逻兵工厂
private GameObject guard = null; //巡逻兵
private List<GameObject> used = new List<GameObject>(); //正在使用的巡逻兵列表
private Vector3[] vec = new Vector3[9]; //每个巡逻兵的初始位置
public List<GameObject> GetPatrols() {
int[] pos_x = { -6, 4, 13 };
int[] pos_z = { -4, 6, -13 };
int index = 0;
for(int i=0;i < 3;i++) {
for(int j=0;j < 3;j++) {
vec[index] = new Vector3(pos_x[i], 0, pos_z[j]);
index++;
}
}
for(int i = 0; i < 8; i++) {
guard = Instantiate(Resources.Load<GameObject>("Prefabs/Guard"));
guard.transform.position = vec[i];
guard.GetComponent<GuardData>().sign = i + 1;
guard.GetComponent<GuardData>().start_position = vec[i];
guard.GetComponent<Animator>().SetFloat("forward", 1);
used.Add(guard);
}
return used;
}
第三人称
在cameraHandle中增加一个空对象cameraPos位置调整到人物模型的脖子后方,并挂载CameraController.cs脚本,用于控制摄像机的位置。
public PlayerInput pi;
public float horizontalSpeed = 100f;
public float verticalSpeed = 80f;
public float cameraDampValue = 0.5f;
private GameObject playerHandle;
private GameObject cameraHandle;
private float tempEulerX;
private GameObject model;
private GameObject camera;
private Vector3 cameraDampVelocity;
void Awake() {
cameraHandle = transform.parent.gameObject;
playerHandle = cameraHandle.transform.parent.gameObject;
model = playerHandle.GetComponent<ActorController>().model;
camera = Camera.main.gameObject;
tempEulerX = 20f;
}
// Update is called once per frame
void FixedUpdate() {
Vector3 tempModelEuler = model.transform.eulerAngles;
playerHandle.transform.Rotate(Vector3.up, pi.Jright * horizontalSpeed * Time.fixedDeltaTime);
tempEulerX -= pi.Jup * verticalSpeed * Time.fixedDeltaTime;
tempEulerX = Mathf.Clamp(tempEulerX, -35, 30);
cameraHandle.transform.localEulerAngles = new Vector3(tempEulerX, 0, 0);
model.transform.eulerAngles = tempModelEuler;
camera.transform.position = Vector3.SmoothDamp(
camera.transform.position, transform.position,
ref cameraDampVelocity, cameraDampValue);
camera.transform.eulerAngles = transform.eulerAngles;
}
其他部分
这部分的代码也都基本沿用之前的
- UserGUI
实现显示部分
private IUserAction action;
private GUIStyle score_style = new GUIStyle();
private GUIStyle text_style = new GUIStyle();
private GUIStyle over_style = new GUIStyle();
void Start () {
action = SSDirector.GetInstance().CurrentScenceController as IUserAction;
text_style.normal.textColor = new Color(0, 0, 0, 1);
text_style.fontSize = 16;
score_style.normal.textColor = new Color(1,0.92f,0.016f,1);
score_style.fontSize = 16;
over_style.fontSize = 25;
}
private void OnGUI() {
GUI.Label(new Rect(Screen.width - 170, 10, 200, 50), "分数:", text_style);
GUI.Label(new Rect(Screen.width - 130, 10, 200, 50), action.GetScore().ToString(), score_style);
// GUI.Label(new Rect(Screen.width / 2 - 80, 10, 100, 100), "WASD移动,方向键移动视角", text_style);
//GUI.Label(new Rect(Screen.width / 2 - 80, 30, 100, 100), "空格跳跃,Shift奔跑", text_style);
// GUI.Label(new Rect(Screen.width / 2 - 80, 50, 100, 100), "成功躲避巡逻兵追捕加1分", text_style);
if (action.GetGameover()) {
GUI.Label(new Rect(Screen.width / 2 - 50, Screen.width / 2 - 250, 100, 100), "游戏结束", over_style);
if (GUI.Button(new Rect(Screen.width / 2 - 50, Screen.width / 2 - 150, 100, 50), "重新开始")) {
action.Restart();
return;
}
}
}
游戏结果
后记
熟悉了上课讲的动画和模型,发布与订阅模式定义了一种一对多的依赖关系,实现了让多个订阅者对象同时监听某一个主题对象,这个对象在状态发生变化时会通知所有订阅者对象,使它们能够自动更新自己。
这里就是有点问题,老师的课件中观察者模式和订阅/发布模式是一样的,但搜了一下,有一定区别,如下:
从表面上看:
- 观察者模式里,只有两个角色 —— 观察者 + 被观察者
- 发布订阅模式里,却不仅仅只有发布者和订阅者两个角色,还有一个经常被我们忽略的 —— 经纪人Broker
往更深层次讲:
- 观察者和被观察者,是松耦合的关系
- 发布者和订阅者,则完全不存在耦合
从使用层面上讲:
- 观察者模式,多用于单个应用内部
- 发布订阅模式,则更多的是一种跨应用的模式(cross-application pattern),比如我们常用的消息中间件
感觉还是有一些区别的,之后再仔细看看。