【游戏开发日记01】UI界面管理及音频管理

本文进度:界面切换&音频播放。

0 前言

一直觉得自己的代码写得不够“漂亮”。展开来说就是一些数据结构、设计原则、设计模式只知道理论,但不能很好地实践到工程中。所以近期开始看一些dalao的源码,学习一些开发思路,同时巩固一下理论知识。

大概每天都会更新一些新学到的东西。

1 单例模式

1.1 单例模式

在Unity中我们用到的最多的设计模式之一就是单例模式。单例模式(Singleton)保证一个类仅有一个实例,并提供一个访问它的全局访问点。

需要注意的是:

  • 单例类只能有一个实例。
  • 单例类必须自己创建自己的唯一实例。
  • 单例类必须给所有其他对象提供这一实例。

以下为三种常见单例模式:

1.1.1 懒汉式单例

public class SingletonClass{
	private static SingletonClass instance;
	//私有化构造函数,保证实例为单例类自己创建
	private SingletonClass(){}
	public static Singleton Instance{
		get{
			if(instance==null) instance = new SingletonClass();
			return instance;
		}
	}
}

直到对象请求实例时才执行实例化,这样的方法称为懒汉单例化。懒汉单例化避免了在程序启动时实例化不必要的单例,避免了内存浪费。

然而,这种实现的主要缺点是在多线程下不安全。如果独立的执行线程同时进入Instance属性方法,那么可能会创建多个SingletonClass对象的实例。

有许多方法可以解决这个问题。其中一种是使用叫双重检查锁(double-check locking)的惯用法。然而,C#结合CLR(公共语言运行库)提供了一种静态初始化(static initialization)方法,它可以避免这些问题,而不需要开发者显式地编写线程安全代码。

1.1.2 饿汉式单例

public sealed class SingletonClass{
	private static readonly SingletonClass instance = new SingletonClass();
	private SIngletonClass(){}
	public static SingletonClass Instance{
		get{ return instance; }
	}
}

静态初始化方法又称为饿汉单例化。在这种方法中,当类中任何成员第一次被引用时,实例会被创建(与静态变量的特性有关)。CLR负责变量初始化。该类标记为sealed以防止派生,因为派生可能会增加实例。此外,变量被标记为readonly,这意味着只能在静态初始化期间(此处显示的)或在类构造函数中对其赋值。

1.1.3 双重检查锁

静态初始化对于大多数情况是可行的。但当程序必须延迟实例化,使用非默认构造函数或在实例化之前执行其他任务,和在多线程环境中工作时(存在着不能依赖CLR确保线程安全的情况),你就需要别的解决方案了。

其中一种解决方法是使用双重检查锁来隔离线程,避免同时创建单例的新实例。

public sealed class SingletonClass{
	private static volatile SingletonClass instance;
	private static object syncRoot = new Object();
	private SingletonClass(){}
	public static SingletonClass Instance{
		get{
			//先判断再加锁,避免频繁加锁造成性能消耗
			if(instance==null){
				lock(syncRoot){
					//加锁后再判断,避免在等待锁的过程中变量已被修改。
					if(instance==null) instance = new SingletonClass();
				}
			}
		}
	}
}

这种方法确保只创建一个实例,并且只在需要实例时创建。另外,将变量声明为volatile(volatile关键字指示一个字段可以由多个同时执行的线程修改),以确保在访问实例变量之前完成对实例变量的赋值,解决程序运行中带来的一些指令重排问题。最后,这种方法使用syncRoot实例来上锁,而不是锁住类型本身,以避免死锁。

这种双重检查锁的方法解决了线程并发性问题,同时避免了在每次调用Instance属性方法时使用独占锁。它还允许你延迟实例化,直到对象第一次被访问。事实上,程序很少使用这种实现。在大多数情况下,静态初始化方法就足矣。

1.2 Unity单例写法

在这里记录两种Unity内常用的单例模式的写法。

1.2.1 普通单例

普通单例在Awake()中实现。在Unity中Awake()方法一般在Start()方法前调用,而我们一般在Start()中获取物体组件或者对字段初始化赋值,因此我们在Awake()中实现单例模式的主要逻辑。

public class SingletonClass : MonoBehaviour{
	public static SingletonClass Instance;
	public void Awake(){
		if(Instance==null) Instance = this;
		else Destory(gameObject);
	}
}

1.2.2 通用单例模式

利用泛型使类模板化,通过让子类继承单例模式就可以让子类实现单例模式的功能。

//单例基类
public class SingletonBaseClass<T> : MonoBehaviour{
	public static T Instance;
	public void Awake(){
		if(Instance==null) Instance = this;
		else Destory(gameObject);    
	}
}
//单例子类
public class SingletonClass<T> : SingletonBaseClass<SingletonClass>{
	...
}

其中

if(Instance==null) Instance = this;
else Destory(gameObject);

也可以写为:

//返回SingletonClass类型第一个激活的加载的物体。
if(Instance==null) Instance = FindObjectOfType(typeof(SingletonClass)) as SingletonClass;

但需要注意的是,以上的代码都没有考虑多线程的情况,当类在不同线程创建时这种写法的单例模式会失效。

2 委托(delegate)

2.1 概述

为了实现方法参数化,提出了委托的概念。委托是一种类,是一种引用类型,可以指向一个或者多个方法。在委托对象的引用中存放的不是数据的引用,而是方法的引用。存储的方法要求类型兼容(即返回值和参数与委托相同)。

2.2 自定义委托

2.2.1 声明格式

public delegate void Mydelegate();  //该委托类型可以指向任何一个返回值为空,参数列表为空的其他方法。

2.2.2 委托的订阅

1.单播委托

一个委托封装了一个方法的形式叫做【单播委托】。

mydelegate = new Mydelegate(Method);  //完整订阅格式
mydelegate = Method;    //简洁订阅格式,“=”可以用“+=”代替。
2.多播委托

一个委托封装了多个方法叫做【多播委托】。

mydelegate = MethodA;
mydelegate += MethodB;
mydelegate += MethodC;

多播委托切记只有第一个方法的订阅可以使用赋值操作(即“=”),之后的订阅必须是“+=”,不然的话之后的订阅会覆盖之前的订阅。

3.简单例子
public class Test : MonoBehaviour{
	public delegate int Mydelegate(int a,int b);  //嵌套类
	Mydelegate mydelegate;
	private void OnEnable(){
		mydelegate = Add;
		mydelegate += Mutiply;
	}
	public void Update(){
		if(Input.GetKeyDown(keyCode.Space)){
			Debug.Log(mydelegate(2,3));
		}
	}
	public int Add(int a,int b){
		Debug.Log(a+b);
		return a+b;
	}
	public int Mutiply(int a,int b){
		Debug.Log(a*b);
		return a*b;
	}
}

输出:

5   //执行Add(2,3)
6   //执行Mutiply(2,3)
6   //执行Debug.Log(6)

2.3 Action&Func

Action<>和Func<>是C#自带的委托类型。

2.3.1 Action

Action委托一定指向一个无返回值的方法,参数可有可无。

//声明
Action action;
Action<参数> action;

代码示例:

public class Test : MonoBehaviour{
	//指向一个无返回值,参数为string的方法
	Action<string> action;  
	//OnEnable和Awake/Start的区别在于,当挂载脚本的游戏物体被取消激活再重新激活的时候,脚本的Awake/Start都不会重新执行,而OnEnable会重新在第一帧执行一次。
	private void OnEnable(){
		action = SayHello;
	}
	void Update(){
		//用户按下空格时
		if(Input.GetKeyDown(keyCode.Space){
			action("Ben");   //or action.Invoke("Ben");
		}
	}
	private void SayHello(string name){
		Debug.Log("Hello~"+name);
	}
}

2.3.2 Func

Func<>一定指向一个有返回值的方法,参数可有可无。

//声明
Func<返回值> func;
Func<参数,参数,返回值> func;

2.4 常用用法

2.4.1 模板方法

有一处不确定,其余代码都是固定写好的,这个不确定的部分(参数)就是靠我们传进来的委托类型的参数所包含的方法来填补。由于这个方法一般需要返回值,所以一般都用Func委托作为模板方法。

举例:这个例子是为了实现输出玩家的夺旗数,死亡数和击杀数最高的玩家名称。

1.定义玩家基本信息类。

public class PlayerStatus{
	public string playerName;
	public int killNum,flagNum,deathNum;
}

2.声明一个委托类型。

public delegate int GetTopScoreDelegate(PlayerStatus player);

3.创建与委托类型兼容的方法。

public int GetKillNum(PlayerStatus player){
	return player.killNum;
}
public int GetFlagNum(PlayerStatus player){
	return player.flagNum;
}
public int GetDeathNum(PlayerStatus player){
	return player.deathNum;
}

4.建立以委托类型为参数的方法。

public string GetTopName(GetTopScoreDelegate _delegate){
	int topNum = 0;
	string name = "";
	foreach(PlayerStatus player in playerStatuses){
		int tempNum = _delegate(player);
		if(tempNum>topNum){
			topNum = tempNum;
			name = player.playerName;
		}
	}
	return name;
}

5.使用。

public void Start(){
	topKillName = GetTopName(GetKillName);
	topFlagName = GetTopName(GetFlagName);
	topDeathName = GetTopName(GetDeathName);
	Debug.Log(topKillName+" "+topFlagName+" "+topDeathName);
}

6.补充

可以用Lambda表达式匿名函数作为方法参数,这样可以省略第四个步骤。

Lambda表达式:(输入参数的参数名)=>return返回的数值。

public void Start(){
	topKillName = GetTopName((player)=>player.killNum);
	topFlagName = GetTopName((player)=>player.flagNum);
	topDeathName = GetTopName((player)=>player.deathNum);
	Debug.Log(topKillName+" "+topFlagName+" "+topDeathName);
}

2.4.2 回调方法

以回调方法的形式使用委托,根据逻辑,动态选择是否调用。

例子:

public class Test : MonoBehaviour{
	public Box WrapProduct(Func<Product> _func,Action<Product> _action){
		Box box = new Box();
		box.Product = _func();
		if(_func().Price>5) _action(_func());
		return box;
	}
}

该方法的参数有一个返回值为Product类型的Fuc<>委托和一个参数为Product类型的Action<>委托,将func委托的返回值赋给box.Product,如果func()的返回值的Price大于了5则将func返回的Product类型返回值,作为Action委托的参数使用。那如果func()的返回值的Price不大于5,那么永远也不会调用Action委托所以Action是通过内部逻辑才会调用的,所以这里的Action委托所封装的方法是作为回调方法使用的,则Func委托所封装的方法是作为模板方法使用的。

3 实践

准备:将页面搭建好存放为预制体。

UI预制体

3.1 处理界面关系

3.1.1 UIBase

UIBase作为界面基类,所有界面类需继承自UIBase。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// 界面基类
/// </summary>
public class UIBase : MonoBehaviour
{
    //方法1:显示
    public virtual void Show() {
        gameObject.SetActive(true);
    }
    
    //方法2:隐藏
    public virtual void Hide() {
        gameObject.SetActive(false);
    }

    //方法3:销毁
    public virtual void Close() {
        UIManager.Instance.CloseUI(gameObject.name);
    }
}

3.1.2 UIManager

UIManager作为界面管理类,统一管理各界面,使用单例模式。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// 界面管理器
/// </summary>
public class UIManager : MonoBehaviour
{
    public static UIManager Instance;    //单例
    public List<UIBase> uiList;          //容器:存储界面
    private Transform canvasTF;          //canvas物体作为界面物体的父物体,transform一般用来描述父子关系
    public void Awake() {
        if (Instance == null) Instance = this;
        else Destroy(gameObject);
        uiList = new List<UIBase>();
        canvasTF = GameObject.Find("Canvas").transform;
    }

    //方法1:显示(泛型约束,T需要继承自UIBase
    public UIBase ShowUI<T>(string uiName) where T : UIBase {
        UIBase ui = Find(uiName);
        //集合中没有,需要从Resources/UI文件夹中加载
        if (ui == null) {
            //Instantiate(Object original,Transform parent); 
            //original:要实例化的物体; parent:实例化的物体将作为该物体的子对象
            //PS:Transform可用来指定对象的父子关系,如A物体的trasnform类a是游戏物体B的transform类b的父类的话,物体A也是物体B的父类。
            GameObject obj = Instantiate(Resources.Load("UI/" + uiName), canvasTF) as GameObject;
            obj.name = uiName;           //改名字
            ui = obj.AddComponent<T>();  //添加需要的脚本
            uiList.Add(ui);              //添加到集合进行存储
        }
        //显示
        else {
            ui.Show();
        }
        return ui;
    }

    //方法2:隐藏
    public void HideUI(string uiName) {
        UIBase ui = Find(uiName);
        if (ui != null) {
            ui.Hide();
        }
    }


    //方法3:关闭
    //3.1 关闭所有界面
    public void CloseAllUI() {
        for (int i = uiList.Count - 1; i >= 0; i--) {
            Destroy(uiList[i].gameObject);
        }
        uiList.Clear();
    }
    //3.2 关闭某个界面
    public void CloseUI(string uiName) {
        UIBase ui = Find(uiName);
        if (ui != null) {
            uiList.Remove(ui);
            Destroy(ui.gameObject);
        }
    }

    //辅助方法1:从集合中找到名字对应的界面
    public UIBase Find(string uiName) {
        for (int i = 0; i < uiList.Count; i++) {
            if (uiList[i].name == uiName) {
                return uiList[i];
            }
        }
        return null;
    }
}

3.2 事件监听

这里实现点击【开始游戏】跳出开始界面。

3.2.1 UIEventTrigger

using UnityEngine;
using UnityEngine.EventSystems;
using System;
/// <summary>
/// 事件监听
/// </summary>
public class UIEventTrigger : MonoBehaviour,IPointerClickHandler
{
    //Action:Unity自带委托,PointerEventData:鼠标点击事件
    public Action<GameObject, PointerEventData> onClick;
    
    //方法1:给物体加UIEventTrigger脚本
    public static UIEventTrigger Get(GameObject obj) {
        UIEventTrigger trigger = obj.GetComponent<UIEventTrigger>();
        if (trigger == null) {
            trigger = obj.AddComponent<UIEventTrigger>();
        }
        return trigger;
    }

    //方法2:UI对应的鼠标点击事件,触发时调用委托
    public void OnPointerClick(PointerEventData eventData) {
        if (onClick != null) {
            onClick(gameObject, eventData);
        }
    }
}

3.2.2 UIBase

添加了一个事件注册方法。

//方法0:注册事件
public UIEventTrigger Register(string name) {
	Transform tf = transform.Find(name);           //transform.Find(string):找子辈物体
	return UIEventTrigger.Get(tf.gameObject);      //给物体加UIEventTrigger脚本
}

3.2.3 LoginUI

开始界面脚本。

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

/// <summary>
/// 开始界面
/// </summary>
public class LoginUI : UIBase {
    private void Awake() {
        //开始游戏:Register("bg/startBtn"):给物体加EventTrigger脚本,.onClick=onStartGameBtn:委托的方法参数。
        Register("bg/startBtn").onClick = OnStartGameBtn;
    }
    private void OnStartGameBtn() {
        //关闭login界面
        Close();
    }
}

3.2.4 GameApp

作为全局统领脚本,也可以认为是启动入口。

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

/// <summary>
/// 游戏入口脚本
/// </summary>
public class GameApp : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        //显示loginUI界面
        UIManager.Instance.ShowUI<LoginUI>("LoginUI");
    }

}

3.3 音频播放

3.3.1 PlayClipAtPoint()用法

游戏内的声音通常可以分为音乐和音效,音乐通常时间较长,且需要循环播放,音效则时间较短,不需要循环播放。

播放声音的方式常见有两种:

1.建立一个空物体,为每一个音乐或音效添加AudioSource,给每个AudioSource添加相应的声音剪辑,播放时,获取各个AudioSource来播放声音。

2.建立一个空物体,添加一个AudioSource来播放背景音乐,再添加一个AudioSource用来播放音效。该组件的AudioClip属性,在运行时根据需要播放的声音,赋予不同的声音剪辑,以此实现一个AudioSource播放多个音效。但这种方法存在一个问题:当有多个音效需要同时播放时,后播放的音效会终止先播放的音效。解决方案:给所有音效分组,不可能同时播放的音效为一组,再为每一组音效添加一个AudioSource。

但还有一种方法:

static void PlayClipAtPoint(AudioClip clip,Vector3 position,float volume = 1.0F);

使用AudioSource.PlayClipAtPoint播放声音,会自动生成一个名为“One shot audio"的物体,并自动添加AudioSource和相应的AudioClip,同时播放多个声音会生成多个同名物体,各声音播放互不影响,缺点是只能设置音量、位置,不能设置loop。播放完成后,One shot audio自动销毁。

下面的操作就是采用这种方式。

3.3.2 AudioManager

音频管理器。

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

/// <summary>
/// 声音管理器
/// </summary>

public class AudioManager : MonoBehaviour
{
    public static AudioManager Instance;   //单例模式
    public AudioSource bgmSource;          //播放bgm的音频
    public void Awake() {
        if (Instance == null) Instance = this;
        else Destroy(gameObject);
    }

    //方法1:初始化
    public void Init() {
        bgmSource = gameObject.AddComponent<AudioSource>();
    }

    //方法2:播放bgm
    public void PlayBGM(string name,bool isLoop = true) {
        AudioClip clip = Resources.Load<AudioClip>("Sounds/BGM/"+name);
        bgmSource.clip = clip;
        bgmSource.loop = isLoop;
        bgmSource.Play();
    }

    //方法3:播放音效
    public void PlayEffect(string name) {
        AudioClip clip = Resources.Load<AudioClip>("Sounds/" + name);
        AudioSource.PlayClipAtPoint(clip, transform.position);
    }
}

3.3.3 GameApp

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

/// <summary>
/// 游戏入口脚本
/// </summary>
public class GameApp : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        //1.显示loginUI界面
        UIManager.Instance.ShowUI<LoginUI>("LoginUI");
        //2.初始化音频
        AudioManager.Instance.Init();
        AudioManager.Instance.PlayBGM("bgm1");
    }

}

4 结果

进入开始界面,播放bgm,点击【开始游戏】可以进入游戏界面。

LoginUI
游戏界面

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值