牧师与魔鬼Priests and Devils--动作分离版

一、前言

        在上一篇MVC模式的基础上进行修改,使得动作管理从场景控制中分离出来,简化场景控制器的设计。

        上一篇博客:

牧师与魔鬼Priests and Devils--unity小游戏-CSDN博客

        游戏视频:

牧师与魔鬼Priests and Devils--动作分离版--unity小游戏

二、动作分离和集成

1. 代码框架的改变

        在上个基于MVC模式的设计中,场景控制器管理了太多的事情,不仅要实现资源加载、动作控制,还有处理游戏规则(即判断游戏是否结束等)和处理用户交互事件。因此将动作控制从场景控制器中分离出来,专门设计一个动作管理器来管理动作的进行。除此之外,还额外设计一个裁判类,用来判断游戏是否结束。

2. 动作分离和集成的原理

        动作分离:将游戏对象的不同行为逻辑分离到独立的脚本或组件中,每个脚本或组件负责处理特定的行为。通过将行为逻辑分离,可以使代码更加模块化和可维护。每个行为脚本可以独立编写、测试和调试,降低了代码的复杂度,并且可以更容易地修改、扩展或重用特定的行为。

        动作集成:将不同的行为逻辑整合到游戏对象中,以实现完整的功能。将这些不同行为动作的脚本添加到同一个游戏对象中,可以使其能够同时按照需求执行不同的行为。

        动作分离和集成的可以提高代码的可维护性、可读性和可扩展性。通过将行为逻辑分离到独立的脚本或组件中,可以更好地组织和管理代码。同时,通过集成这些行为逻辑,游戏对象可以实现复杂的行为交互和功能组合,使其具备丰富的行为表现和玩法。

3. 动作管理器的设计UML图

设计思路如下:

  • 通过门面模式(控制器模式)输出组合好的几个动作,共原来程序调用。
    • 好处,动作如何组合变成动作模块内部的事务
    • 这个门面就是 CCActionManager
  • 通过组合模式实现动作组合,按组合模式设计方法
    • 必须有一个抽象事物表示该类事物的共性,例如 SSAction,表示动作,不管是基本动作或是组合后动作
    • 基本动作,用户设计的基本动作类。 例如:CCMoveToAction
    • 组合动作,由(基本或组合)动作组合的类。例如:CCSequenceAction
  • 接口回调(函数回调)实现管理者与被管理者解耦
    • 如组合对象实现一个事件抽象接口(ISSCallback),作为监听器(listener)监听子动作的事件
    • 被组合对象使用监听器传递消息给管理者。至于管理者如何处理就是实现这个监听器的人说了算了
    • 例如:每个学生做完作业通过邮箱发消息给学委,学委是谁,如何处理,学生就不用操心了
  • 通过模板方法,让使用者减少对动作管理过程细节的要求
    • SSActionManager 作为 CCActionManager 基类

三、具体实现

1. 动作管理器

        将原本的动作控制移出Controllers目录,并创建一个新的目录Actions用于存储与动作管理器实现相关的脚本:

        Actions目录下脚本文件如下:

(1)SSAction

        SSAction是动作的基类,所有的单个动作以及组合动作都是继承于此。SSAction定义了一个enable变量判断动作是否可以进行,一个destroy变量判断是否需要摧毁某个动作(一般来说执行完成的动作就需要被摧毁)。同时定义了动作所绑定的游戏对象gameobject以及游戏对象的Transform组件用来控制位置移动,并且定义了一个callback变量用来实现消息通知。在SSAction中,所有的方法都是虚方法,需要子类(即具体的动作)进行重写实现多态。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// 动作基类
public class SSAction : ScriptableObject {  // 不需要绑定 GameObject 对象的可编程基类

	public bool enable = true;   // 动作可以进行
	public bool destory = false;     // 是否需要摧毁该动作

	public GameObject gameobject { get; set; }    // 该动作作用的游戏对象
	public Transform transform { get; set; }      // 该动作作用的游戏对象的 Transform 组件
	public ISSActionCallback callback { get; set; }  // 实现消息通知,避免与动作管理者直接依赖

	protected SSAction () {}   // 防止用户自己 new 抽象的对象

	// Use this for initialization
	public virtual void Start () {   // 申明虚方法,通过重写实现多态
		throw new System.NotImplementedException ();
	}
	
	// Update is called once per frame
	public virtual void Update () {
		throw new System.NotImplementedException ();
	}
		
}

(2)CCMoveToAction

        CCMoveToAction继承自动作基类SSAction。在该游戏中的动作只有移动,因此CCMoveToAction定义了一个目的地位置的变量target和移动的速度。该类还实现了一个返回动作类型的函数GetSSAction来获取当前的动作并返回动作对象。正如上文所言,CCMoveToAction需要对SSAction声明的虚方法进行重写,在Update中持续执行移动的动作直到到达目的地。到达目的地后需要摧毁该动作(因为该动作已经完成),并且通过回调函数通知动作管理器该动作已经完成的消息。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// 某个具体的动作实现
public class CCMoveToAction : SSAction
{
	public float speed;       // 移动速度
	public Vector3 target;   // 目的地的位置(坐标)

	public static CCMoveToAction GetSSAction(Vector3 target, float speed){  // 创建一个新的动作对象
		CCMoveToAction action = ScriptableObject.CreateInstance<CCMoveToAction> ();   // 使用 CreateInstance 方法可以创建一个新的 ScriptableObject 实例并返回
		action.target = target;
		action.speed = speed;
		return action;     // 返回动作对象
	}

	public override void Update ()     //用override声明重写
	{
		this.transform.localPosition = Vector3.MoveTowards(this.transform.localPosition, target, speed * Time.deltaTime);  // 移动到目的地
		if (this.transform.localPosition == target) {     // 如果已经到达目的地
            //waiting for destroy
			this.destory = true;         // 动作执行完成,需要销毁
			this.callback.SSActionEvent (this) ;    // 通知动作管理者动作完成
			return;
		}
	}

	public override void Start () {
	}
}

(3)CCSequenceAction

        CCSequenceAction是一个动作组合序列,也是继承自SSAction,具有一个SSAction类型的列表用于存储一组动作,一个变量repeat用于存储动作序列重复执行的次数(如果为-1则不断重复执行0,一个变量start用于存储当前执行的动作在列表中的下标/索引值)。CCSequenceAction同样有个返回当前动作的函数GetSSAction,不过返回的不是单个动作而是整个动作序列,包含一个start可以标明当前执行的动作。该类也同样重写了SSAction的函数,在start函数中依次执行动作序列中每个动作的start函数,在update函数中根据循环重复的次数repeat以及当前的动作执行相应的update函数。

        与单个动作不同的是,CCSequenceAction还需要实现ISSActionCallback接口,重写SSActionEvent函数来传递动作是否执行完毕的信息。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// 动作组合序列
public class CCSequenceAction : SSAction, ISSActionCallback
{
	public List<SSAction> sequence;            // 列表存储动作序列
	public int repeat = -1; //repeat forever      动作序列重复执行的次数
	public int start = 0;   // 当前执行的动作序列的下标

	public static CCSequenceAction GetSSAction(int repeat, int start , List<SSAction> sequence){
		CCSequenceAction action = ScriptableObject.CreateInstance<CCSequenceAction> ();
		action.repeat = repeat;          // 动作序列多一个参数,重复次数
		action.sequence= sequence;
		action.start = start;
		return action;
	}
		
	public override void Update ()
	{
		if (sequence.Count == 0) return;   //动作序列为空,执行结束
		if (start < sequence.Count) {
			sequence [start].Update ();      // 执行当前动作
		}
	}

	public void SSActionEvent (SSAction source, SSActionEventType events = SSActionEventType.Competeted, int intParam = 0, string strParam = null, Object objectParam = null)
	{
		source.destory = false;
		this.start++;
		if (this.start >= sequence.Count) {   // 执行整个动作序列的所有动作
			this.start = 0;
			if (repeat > 0) repeat--;     // 开始下一次循环
			if (repeat == 0) { this.destory = true; this.callback.SSActionEvent (this); } // 所有序列动作执行完成,准备摧毁动作,回调
		}
	}

	// Use this for initialization
	public override void Start () {
		foreach (SSAction action in sequence) {     // 依次执行动作序列中的每个动作
			action.gameobject = this.gameobject;
			action.transform = this.transform;
			action.callback = this;
			action.Start ();
		}
	}

	void OnDestory() {
		//TODO: something
	}
}

(4)SSActionManager

        SSActionManager是动作对象管理器的基类,实现了对于所有动作的基本管理。有一个动作字典actions用来添加所有需要执行的动作,一个waitingAdd列表存储等待加入到字典的动作,一个waitingDelete列表存储等待摧毁的动作对象。在update函数中先是遍历waiting列表,将需要执行的动作加入到字典中;然后遍历字典中每一个工作,判断动作是否可以执行还是执行完成需要摧毁;最后遍历waitingDelete列表中的每个动作并将它们摧毁。同时定义了一个函数RunAction来创建动作、把游戏对象和动作绑定,并绑定该动作实现的消息接收者,最后添加到列表中并执行。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// 动作管理器基类
public class SSActionManager : MonoBehaviour {

	private Dictionary <int, SSAction> actions = new Dictionary <int, SSAction> (); // 使用字典管理动作
	private List <SSAction> waitingAdd = new List<SSAction> (); 
	private List<int> waitingDelete = new List<int> ();

	// Update is called once per frame
	protected void Update () {
		foreach (SSAction ac in waitingAdd) actions [ac.GetInstanceID ()] = ac;
		waitingAdd.Clear ();

		foreach (KeyValuePair <int, SSAction> kv in actions) {
			SSAction ac = kv.Value;
			if (ac.destory) { 
				waitingDelete.Add(ac.GetInstanceID()); // 添加到等到删除的列表
			} else if (ac.enable) { 
				ac.Update (); // update action
			}
		}

		foreach (int key in waitingDelete) {  // 等待删除的动作
			SSAction ac = actions[key]; 
			actions.Remove(key);    // 从字典中删除动作
			Object.Destroy(ac);      // 销毁动作
		}
		waitingDelete.Clear (); // 等到所有动作执行完毕后,清空等待删除的动作列表
	}

	// 执行动作
	public void RunAction(GameObject gameobject, SSAction action, ISSActionCallback manager) {
		action.gameobject = gameobject;     // 设置动作的游戏对象
		action.transform = gameobject.transform;    // 设置动作的游戏对象的 Transform 组件
		action.callback = manager;  // 设置动作的回调接口
		waitingAdd.Add (action);    // 添加到等待执行的动作列表中
		action.Start ();       // 开始执行动作
	}


	// Use this for initialization
	protected void Start () {
	}
}

(5)CCActionManager

        动作管理器的具体实现,继承自基类SSActionManager,同时还需要实现ISSActionCallback的回调接口。由于该游戏包含移动船的动作和移动角色的动作,因此创建一个单动作对象moveBoat,动作序列moveRole(因为移动角色分两步进行,先移到中间位置,再移动到终点)。由于移动过程中无法再操控对象,因此需要一个变量isMoving判断是否在移动。在初始化函数start中,需要先从场景控制器(FirstController)中获取场景对象,并将该动作管理器赋值给场景控制器(以便实现动作控制和信息交互)。而update直接覆盖基类的方法即可。然后在两个动作函数MoveBoat和MoveRole中创建动作并执行。同时还要重载函数SSActionEvent进行消息通信,返回动作是否在执行(即是否在移动)的信息。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// 某个具体动作的动作管理器
public class CCActionManager : SSActionManager, ISSActionCallback {
	
	private FirstController sceneController;       // 场景控制器(第一控制器)
	private CCMoveToAction moveBoat;        // 移动船的动作
	private CCSequenceAction moveRole;      // 移动角色的动作(移动角色的动作分为两步,因此是一个组合动作)
	bool isMoving = false;    		  // 动作是否在进行
	protected new void Start() {  // 开始运行,初始化
		sceneController = (FirstController)SSDirector.GetInstance ().CurrentSceneController;   // 当前的场景取出来
		sceneController.actionManager = this;  
	}

	protected new void Update ()
	{
		base.Update ();   // Update 是方法覆盖,提醒编程人员重新该方法不会多态,且要用 base 调用原方法
	}
		
	public bool IsMoving() {  // 判断是否正在移动
		return isMoving;
	}
	//移动船
    public void MoveBoat(GameObject boat, Vector3 target, float speed)
    {
        if (isMoving) return;  // 正在移动时无法再次移动	
        isMoving = true;
        moveBoat = CCMoveToAction.GetSSAction(target, speed);  // 定义动作
        this.RunAction(boat, moveBoat, this);  // 执行动作
    }

    //移动人
    public void MoveRole(GameObject role, Vector3 mid_destination, Vector3 destination, float speed)
    {
        if (isMoving) return;  // 正在移动时无法再次移动
        isMoving = true;
		// 组合动作,先向中间位置移动,再向目的地移动
        moveRole = CCSequenceAction.GetSSAction(0, 0, new List<SSAction> { CCMoveToAction.GetSSAction(mid_destination, speed), CCMoveToAction.GetSSAction(destination, speed) });
        this.RunAction(role, moveRole, this);  // 执行组合动作
    }

	#region ISSActionCallback implementation
	public void SSActionEvent (SSAction source, SSActionEventType events = SSActionEventType.Competeted, int intParam = 0, string strParam = null, Object objectParam = null)
	{
		isMoving = false;  // 动作执行完成,回调
	}
	#endregion
}

(6)ISSActionCallback

        回调函数接口,该接口作为接收通知对象的抽象类型,包含一个枚举类型SSActionEventType来判断动作事件的类型。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public enum SSActionEventType:int { Started, Competeted }

// 回调接口
public interface ISSActionCallback
{
	void SSActionEvent(SSAction source, 
		SSActionEventType events = SSActionEventType.Competeted,
		int intParam = 0 , 
		string strParam = null, 
		Object objectParam = null);
}


2. 裁判类

        裁判类被放置到了Controllers目录中

Referee

        将判断游戏是否结束的函数从场景控制器中分离出来,实现了一个裁判类Referee。该类需要获得场景控制器对象,以及游戏对象中的左河岸、右河岸、船(这三个不是必须的,可以通过访问FirstController中的对象直接获取,这里写了主要是为了减少代码长度)。在start函数中获取场景控制器对象以及三个游戏对象。然后在update中不断判断游戏是否结束,判断逻辑与之前FirstController中的check函数一致,唯一不同的是需要通过回调函数Callback将游戏结束的信息返回给场景控制器。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Referee : MonoBehaviour
{
    public FirstController firstCtrl;
    public Shore leftShore;
    public Shore rightShore;
    public Boat boat;
    void Start()
    {
        firstCtrl = (FirstController)SSDirector.GetInstance().CurrentSceneController;   // 当前的场景取出来
        this.leftShore= firstCtrl.leftShoreController.GetShore();
        this.rightShore = firstCtrl.rightShoreController.GetShore();
        this.boat = firstCtrl.boatController.GetBoatModel();
    }
    void Update()     // 将FirstController中的Update方法移动到这里
    {
        firstCtrl = (FirstController)SSDirector.GetInstance().CurrentSceneController;   // 当前的场景取出来
        if (!firstCtrl.isRunning) return;     // 如果游戏结束,那么无需再检查
        if (firstCtrl.time <= 0) {
            firstCtrl.Callback(false, "时间到! 游戏失败!");
            return;
        }
        firstCtrl.Callback(true, "");    // 游戏信息置空
        //判断是否已经胜利
        if (rightShore.priestCount == 3) {  // 如果右岸的牧师数量为3,游戏胜利
            firstCtrl.Callback(false, "牧师成功过岸,游戏胜利!");
        }
        else{
            int leftPriestCount, rightPriestCount, leftDevilCount, rightDevilCount;  // 两岸和船上的牧师和魔鬼数量
            leftPriestCount = leftShore.priestCount + (boat.isRight ? 0 : boat.priestCount);
            rightPriestCount = rightShore.priestCount + (boat.isRight ? boat.priestCount : 0);
            leftDevilCount = leftShore.devilCount + (boat.isRight ? 0 : boat.devilCount);
            rightDevilCount = rightShore.devilCount + (boat.isRight ? boat.devilCount : 0);
            if ((leftPriestCount != 0 && leftPriestCount < leftDevilCount) || (rightPriestCount != 0 && rightPriestCount < rightDevilCount)) {  // 如果任意一边的牧师数量不为0且小于魔鬼数量,游戏失败
                firstCtrl.Callback(false, "牧师被魔鬼杀死,游戏结束!");
            }
        }
    }
}

3. 场景控制器

        将动作和判断游戏结束的代码从场景控制器中移除之后,FirstController中的代码也要进行一些修改:

FirstController

        首先要额外定义一个动作管理器对象actionManager。在动作函数部分MoveBoat和MoveRole不再执行动作,而是根据当前游戏对象的相对位置来确定移动的目的地,然后创建一个动作交给动作管理器actionManage来执行。判断游戏结束不再需要check函数了,而是通过回调函数Callback从裁判类Referee中获取游戏是否结束以及需要显示在游戏界面上的信息。当然还需要在awake函数中给游戏对象添加动作管理器组件和裁判类组件。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;


// 场记
// 具体类型:FirstController
public class FirstController : MonoBehaviour, ISceneController, IUserAction {  // 对于ISceneController和IUserAction接口的具体实现
    public ShoreCtrl leftShoreController, rightShoreController;     // 左岸和右岸的控制器
    River river;     // 河流对象
    BackGround backGround;      // 背景对象
    public BoatCtrl boatController;    // 船的控制器
    RoleCtrl[] roleControllers;   // 人物的控制器,每个人物分别对应一个控制器
    public bool isRunning;   // 游戏是否在运行
    public float time;   // 游戏倒计时
    SSDirector director;   // 导演实例
    public CCActionManager actionManager;  // 新增一个动作管理器***

    public void LoadResources() {     // 对于ISceneController的具体实现,加载资源,初始化游戏
        //角色部分role
        roleControllers = new RoleCtrl[6];      // 数组存储6个角色/控制器
        for (int i = 0; i < 6; ++i) {
            roleControllers[i] = new RoleCtrl();
            roleControllers[i].CreateRole(Position.role_shore[i], i < 3 ? true : false, i);   // 创建角色,传入角色在左岸的位置,依次填写角色的id,前三个是牧师,后三个是魔鬼
        }

        //河岸部分Shore
        leftShoreController = new ShoreCtrl();
        leftShoreController.CreateShore(Position.left_shore);
        leftShoreController.GetShore().shore.name = "left_shore";
        rightShoreController = new ShoreCtrl();
        rightShoreController.CreateShore(Position.right_shore);
        rightShoreController.GetShore().shore.name = "right_shore";

        //将人物添加并定位至左岸  
        foreach (RoleCtrl roleController in roleControllers)      // 遍历角色控制器
        {
            roleController.GetRoleModel().role.transform.localPosition = leftShoreController.AddRole(roleController.GetRoleModel());  // 设置每个角色在左岸的位置
        }

        //船部分boat
        boatController = new BoatCtrl();
        boatController.CreateBoat(Position.left_boat);

        //河流对象river
        river = new River(Position.river);

        //背景对象backGround
        backGround = new BackGround(Position.background);

        isRunning = true;    // 游戏开始运行
        time = 60;        // 游戏倒计时60s
    }

    public void destroyResourse(){
        Object.DestroyImmediate(leftShoreController.GetShore().shore);
        Object.DestroyImmediate(rightShoreController.GetShore().shore);
        Object.DestroyImmediate(boatController.GetBoatModel().boat);
        Object.DestroyImmediate(river.river);
        Object.DestroyImmediate(backGround.bg);
        foreach (RoleCtrl roleController in roleControllers) {
            Object.DestroyImmediate(roleController.GetRoleModel().role);
        }
    }

    public void MoveBoat() {    // 对于IUserAction的具体实现,移动船
        if(isRunning==false || boatController.isEmpty()) return;  // 如果游戏结束或船上没人,那么无法再移动船
        Vector3 destination = boatController.GetBoatModel().isRight ? Position.left_boat : Position.right_boat;
        actionManager.MoveBoat(boatController.GetBoatModel().boat, destination, 5);        // 动作交给动作管理器处理
        boatController.GetBoatModel().isRight = !boatController.GetBoatModel().isRight;       // 每次移动之后,方向取反
    }

    public void MoveRole(Role roleModel) {  // 对于IUserAction的具体实现,移动角色
        if (isRunning == false || actionManager.IsMoving()) return;      // 如果游戏结束或者船正在移动,那么无法再移动角色
        Vector3 destination, mid_destination;   // 目的地和中间位置
        if (roleModel.inBoat) {            // 如果角色在船上
            if (boatController.GetBoatModel().isRight) {   // 如果船在右岸
                destination = rightShoreController.AddRole(roleModel);  // 设置角色的目的地为右岸
            }
            else {    // 如果船在左岸
                destination = leftShoreController.AddRole(roleModel);   // 设置角色的目的地为左岸
            }
            if (roleModel.role.transform.localPosition.y > destination.y) {   // 如果角色的y坐标大于目的地的y坐标,说明是从岸上到船上,那么就先水平移动到中间位置,然后再竖直移动到目的地
                mid_destination = new Vector3(destination.x, roleModel.role.transform.localPosition.y, destination.z);
            }
            else {   // 如果角色的y坐标小于目的地的y坐标,说明是从船上到岸上,那么就先竖直向上移动到中间位置,然后再水平移动到目的地
                mid_destination = new Vector3(roleModel.role.transform.localPosition.x, destination.y, destination.z);
            }
            actionManager.MoveRole(roleModel.role, mid_destination, destination, 5);   // 动作交给动作管理器处理
            roleModel.onRight = boatController.GetBoatModel().isRight;  // 每次移动之后,角色的方向取决于船的方向
            boatController.RemoveRole(roleModel);   // 将角色从船上移动到岸上
        }
        else {     // 如果角色在岸上
            if (boatController.GetBoatModel().isRight == roleModel.onRight) {  // 如果角色和船在同一边
                if (roleModel.onRight) {  // 如果角色在右岸
                    rightShoreController.RemoveRole(roleModel);  // 将角色从右岸移动到船上
                }
                else {  // 如果角色在左岸
                    leftShoreController.RemoveRole(roleModel);   // 将角色从左岸移动到船上
                }
                destination = boatController.AddRole(roleModel);  // 设置角色的目的地为船上
                if (roleModel.role.transform.localPosition.y > destination.y)
                    mid_destination = new Vector3(destination.x, roleModel.role.transform.localPosition.y, destination.z);
                else
                    mid_destination = new Vector3(roleModel.role.transform.localPosition.x, destination.y, destination.z);
                actionManager.MoveRole(roleModel.role, mid_destination, destination, 5);   // 动作交给动作管理器处理
            }
        }
        
    }

    public void Check() {   // 对于IUserAction的具体实现,检查游戏是否结束
        // 由裁判类Referee判断是否结束
    }

    public void Restart(){       // 重新开始游戏
        // director.CurrentSceneController.destroyResourse();
        // this.gameObject.GetComponent<UserGUI>().gameMessage = "";   // 游戏信息置空
        // director.CurrentSceneController.LoadResources();
        // 由于动作管理器部分需要位置信息,不能直接摧毁游戏对象,所以该部分暂时去除
    }

    void Awake() {    // 唤醒函数,在游戏开始前调用一次
        director = SSDirector.GetInstance();   // 获取导演实例
        director.CurrentSceneController = this;   // 设置当前场景控制器为本对象
        director.CurrentSceneController.LoadResources();   // 调用上面的LoadResources()函数,加载资源,初始化游戏
        this.gameObject.AddComponent<UserGUI>();   // 添加UserGUI脚本,显示用户交互界面
        this.gameObject.AddComponent<CCActionManager>();  // 添加动作管理器
        this.gameObject.AddComponent<Referee>();   // 添加裁判类
    }

    void Update() {      // 每帧调用一次
        if (isRunning) {
            time -= Time.deltaTime;     // 游戏剩余时间减少
            this.gameObject.GetComponent<UserGUI>().time = (int)time;   // 设置游戏剩余时间
        }
    }
    // 回调函数
    public void Callback(bool isRuning, string message)
    {
        this.isRunning = isRuning;      // 游戏结束信息
        this.gameObject.GetComponent<UserGUI>().gameMessage = message;
    }
}

四、代码链接

除了上述提及的新增的和修改的代码外,其他的代码与上个项目一致,就不再赘述。代码链接如下:

lab6: 3D游戏编程作业--牧师与魔鬼动作分离版 (gitee.com)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值