软件构造思想在Unity项目中的实践举例(1)

本文系笔者在学习软件构造课程期间所写,不保证通用性和正确性,仅供参考。

目录

  1. 前言
  2. OOP?那可太OOP了
  3. 为音符划定基本属性
  4. 规范场景间信息的传递
  5. 本地化支持
  6. 结语

一、前言

看到这个发布日期,这个发布间隔,和这个奇怪的选题内容,大概很容易觉得我在水博客凑学分吧。但是其实不是,我很早就想开这个“系列”了,内容选材早都想好了,但是懒x 于是最终还是到了被ddl逼着发的地步了。真的,截图为证qwq

挖坑不填是这样的

好吧,那么为什么会想到这么一个主题呢,因为在学习软件改造的同期,我也在自学unity,并且正在做一个自己的小项目。软件构造显然主要是面向一个完整的软件应用、工程,而unity项目刚好就是这么一个不大不小的工程。随着软件构造课程的学习,我逐渐发现在写这个项目的时候,我开始用软件构造的思想去看待我的代码了,我会考虑表示泄漏,会设计一些注释......这不禁让我意识到,软件构造确实有所用途。所以在这里把我体悟到的一些思想用例子的形式表现出来,也是一种参考吧。

当然,因为是unity,所以用的语言是C#,而不是课程要求的Java了。不过因为主要讲的是体现的思想,事实上也没有展示太多实际的代码。

二、OOP?那可太OOP了

第一部分就来讲unity本身吧!软件构造的课程中,花了一章的篇幅详细介绍了OOP的编程思想,而unity制作项目本身就表现出很强的OOP思想,仅举两个十分浅显的例子:

2.1 GameObject与脚本

在Unity中,每个空间中的物体(以下称为“游戏物体”)都是基类GameObject的继承,这与Java或者C#中每个类型都是Object的继承有异曲同工之妙(当然GameObject本身也是继承Object的)。

而控制一个游戏物体的行为,就要用到脚本:脚本是一个C#文件,其内容通常是一个继承了MonoBehaviour的类,而MonoBehaviour本身又已经集成了很多游戏物体通用的功能:如通过反射机制,每帧调用所有激活的脚本中的Update()函数,亦或用StartCoroutine()函数轻松地开启协程......这体现出了良好的复用性,为项目开发带来了方便。

在软件构造的实验中,我们为一个大的功能群定义了一个接口,只要继承了这个接口,就要保证实现接口所定义的功能;同时,如果一个类实现了多个接口,它就具有了多种功能群,而在unity中,这种思想体现在脚本添加上,一个物体添加了多个脚本,就拥有了它们定义的多个功能。这种“插件”思想我在之前的一篇博客关于在Interface和Abstract Class间选择的一些思考也有提到。

Unity

2.2 拖动式委托

unity的脚本中封装了一个很好用的功能,就是可以从场景中拖动物体将其赋值给某个脚本中的rep,如下图所示,把左边的prefab拖到右边的脚本GUI上,就可以为其赋值。

拖动

稍微一寻思就会发现这是一种典型的委托思想,脚本中定义的或许只是一个普通的GameObject,但是为它赋上一个特定的游戏物体后,脚本所做的一切就被赋予了意义。如果脚本有良好的封装,这就成为了一个具有良好功能的小工具箱一样的存在。

三、为音符划定基本属性

那么现在开始说说我的项目吧!这是一个音乐游戏,游戏过程中会有音符从屏幕上方掉落,玩家需要做的就是在音符落到屏幕下方特定位置时,以准确的时机按下对应的按键,以跟随音乐节奏。

在这个场景下,核心内容显然就是这一个个音符(Note)。在我的设定下,这些音符有的操作宽松,在比较大的时间区间内按下任意按键就可以判定成功;有的要求严格,要在较窄的时间内按下特定按键才能判定成功。这个过程中,可以发现这些音符虽然属性不同,但还是能提取出共性的。经过分析,提取出共性因素如下:

  1. 每个Note需要标明自己的类型(一个枚举类),且有一个id便于标识;
  2. 每个Note都有一个生成在屏幕上的时刻和一个判定时间的时刻,对判定时刻,有多个时间区间,实际点击时间落在不同的区间内获得不同评价。
  3. 基于游戏特色,每个Note都附着在一个轨道上,由轨道控制Note的横向运动。
  4. 每个Note都需要一段在屏幕上的运动用来让玩家提前判断点击时机;
  5. 每个Note需要一次初始化,来获得它对应的独特信息。

根据上面总结的共性,定义一个记录信息的基类BaseNote,和一个控制音符物体行为的基类BaseNoteController:

public abstract class BaseNote
{
    public NoteType noteType;
    public int id;
    public float timeJudge;
    public float timeInstantiate;   //The time when it appears in screen
    public int belongingTrack;
}
public abstract class BaseNoteController : MonoBehaviour
{
    //Judge Range of (timeJudge - timeInput). negative number means late, and positive means early.
    [System.Serializable]
    public class JudgeRange
    {
        public JudgeType judgeType;
        public float start; //Includes
        public float end;   //Not include
    }
    public List<JudgeRange> rangeList;

    // Serializable and Public
    [SerializeField] protected TimeProvider timeProvider;
    [SerializeField] protected PositionConverter m_camera;
    [SerializeField] protected Transform sprite;
    [SerializeField] protected Transform hitEffect;

    public abstract void InfoInit(BaseNote note);
    public abstract void SpriteInit();
    public abstract void UpdatePos();
    public abstract bool HandleInput(float timeInput);
}

里面有一些rep被设置为了public,这是为了方便在unity编辑器里调试,成品时会设为private。

可以看到,必要的域已经定义好,而一些没有确定的,比如如何初始化,如何运动则设置为了抽象函数。这样,不仅现有的几种音符可以很好搭建起框架,以后想要再创造新的音符也会方便很多。

四、规范场景间信息的传递

在unity项目中常常会需要切换场景,而有的时候还需要在场景间传递信息。比如在我的项目中,玩家会在选择关卡的场景中选定一首歌,然后切换到游玩的场景,并且需要把玩家选了什么从选关场景传递到游玩场景中。考虑到这个需求可能会相当频繁地出现,于是决定将其抽象化,变得可复用。

为此,定义一个接口IPassInfo,作为所有可能要传递的信息的标识。这里面可以再定义一些这些信息共有的行为,不过目前并没有任何内容,这个接口便仅作为一个标签。然后,再定义一个InfoReader,用来获取传递的信息。

public class InfoReader : MonoBehaviour
{
    public bool IsRead { get; private set; } = false;

    private IPassInfo info = null;

    public void SetInfo(IPassInfo info)
    {
        this.info = info;
    }

    public I ReadInfo<I>() where I : IPassInfo
    {
        if (info is not I || info == null) Debug.LogError("Invalid read operation.");
        IsRead = true;
        return (I)info;
    }
}

C#中有很方便的“属性”,可以优雅地简化Java中getter、setter等的定义,如该类中的IsRead。经过这样的设置,每次切换场景时只需在一个游戏物体上附上一个InfoReader,把它带到下一个场景就可以实现场景切换了。场景切换目前是用另一个类SceneLoader来实现,而InfoReader又可以看作是SceneLoader的一个visitor,SceneLoader还可以使用别的传递信息类来应对不同实际情况。于是,通过这一系列的解耦与委托,实现了信息传递的通解,并且便于扩展和更改。

五、本地化支持

在游戏中,本地化也是一个经常遇到的问题,说白了就是游戏要支持不同的语言。那么在游戏中怎么实现不同语言的切换呢?我们先定义一个接口IMultiLanguage,其中定义了一个抽象方法GetString(),返回当前语言状态下的内容;还有一个对应各语言的枚举。

public interface IMultiLanguage
{
    public enum LanguageLabel
    {
        en = 0,
        zh_s = 1,
        zh_t = 2,
    }

    public string GetString();
}

为什么要定义这样一个接口呢?因为游戏中的文本可能有很多形式,也许只是按钮上的一个单词,也许是一大串剧情文本,对它们可能要采取不同的处理措施。现在实现一个简单的MultiLanguageString:

public class MultiLanguageString : IMultiLanguage
{
    // ...en, zh_s, zh_t的定义

    public string GetString()
    {
        int language = PlayerPrefs.GetInt("Language");
        return language switch
        {
            (int)LanguageLabel.en => en,
            (int)LanguageLabel.zh_s => zh_s,
            (int)LanguageLabel.zh_t => zh_t,
            _ => en
        };
    }

有了可以存储并获得不同语言文本的类,还需要一个读取的类,这里我定义了一个TextReader,它从系统中存储的文本文件中读取内容,并返回一个MultiLanguageString。可以看到,这里用到了函数重载,其中一个方法具有函数委托,客户端可以自定义一个TextParser来用自己的格式来进行文本文件的parse。

public class TextReader
{
    public enum ReadTextFrom { Resources_Texts, Assets_Texts };
    public delegate Dictionary<string, MultiLanguageString> TextParser(string content);
    private static readonly Dictionary<string, Dictionary<string, MultiLanguageString>> textValues = new();

    public static MultiLanguageString GetMultiLanguageString(string fileName, string label, ReadTextFrom readFrom = ReadTextFrom.Resources_Texts)
    {...}

    public static MultiLanguageString GetMultiLanguageString(string fileName, string label, TextParser textParser, ReadTextFrom readFrom = ReadTextFrom.Resources_Texts)
    {...}

}

复习过程中我感觉这门课也十分热衷于考察读取文本中的信息来构建对象,那这个类默认是怎么读取文件的呢?事实上是用了json格式来存储数据,于是直接调用现有的json解析库即可,方便快捷。

六、结语

本文介绍了我在项目中的一些小实践,并解释了它们体现了什么软件构造的思想。其实一开始的时候这些实现都相当不成熟,但随着自己的学习和软件构造课程的体会,我一步步地改进代码,提高各方面性能,这个过程也反过来深入着我对软件构造的认识。

可以发现本文主要是对OOP相关内容的一些实践,而下一篇文则将围绕项目目前最复杂的一个过程展开:读取谱面文件,并通过解析生成一个关卡。这里面便涉及到更多其他软件构造的思想了。

附上该项目的github网址,不是很经常push,但欢迎锐评:https://github.com/Selakz/project-dl
顺便再附个工程内截图证明上面的东西不是在瞎扯x

当前进展

posted on 2024-05-27 23:59  Senolytics  阅读(11)  评论(0编辑  收藏  举报