找回密码
 立即注册
查看: 567|回复: 0

[简易教程] 【Unity】UGUI系列教程——OSU!Battle!

[复制链接]
发表于 2020-12-23 19:04 | 显示全部楼层 |阅读模式
前言
有些认真的读者反映OSU!和本教程的表现方式是有差异的。因为我做的游戏来讲解功能的主要目的是让给多想学习Unity制作游戏的读者更有兴趣来学习教程,而不是枯燥的背组件的使用方式和参数作用。我更想让UGUI偏向实用方向讲解,因此每次写教程之前我都是需要自己花时间想下怎么用最少的知识点完成我们想要效果。本期根据上期的预告,将会对OSU!的Battle部分进行简单实现和讲解。能想学习Unity的UGUI功能的读者也能够有所受用,这是我的初衷,而若是能让想制作音乐游戏的读者能有所启发,那真是再好不过了。


预览效果






这里只实现点击和拖拽,转动圆盘的效果会在之后的教程中介绍实现方法。这里由于时间原因没有搁置未实现。


游戏需要的知识
序列帧动画:
序列帧动画原理是和我们看电视上动画的原理一样,当图片不停按顺序切换,利用视觉残留效果,会显示出运动的感觉。因此我们只用对Image图片做固定时间切换就可以实现,脚本方法不阐述了,这里说一个Unity的简单实现方法


创建一个Animator挂载到需要显示序列帧图片的地方






直接将序列帧图片拖到Animation窗口的动画Clip中






记得动画Clip资源设置成循环播放。




九宫格图片:
九宫格图片在UI中广泛运用,为了优化资源大小,我们做中间过渡简单的背景图和长条UI的时候并不会实际画游戏中需要的图片大小。而是利用设置九宫格图片拉伸得到。
选择你要设置的九宫格图片






点击Spite Editor按钮,出现九宫格编辑界面






我们如果这样设置九宫格,那么在中间正方形区域将会被拉伸,而外围区域的图片将会保持形状处理。
这里我们需要将一个圆形拉伸成胶囊形状的UI,于是这样设置,只给中间留2像素就够了






对Image组件的Image Type选择Sliced就后,调节Width就好了








小知识点:
对你想统一修改某挂点下所有UI物体的透明度,挂载Canvas Group组件就好了。








搭建Note界面






点击圈的界面很简单,只需要一个可以点击按钮Btn_Judge,一个提示作用的白色圈Img_AimCircle。
点击后根据点击准确度打开得分提示GoodState、PerfectState、FailState就好了。






滑动条需要增加跟随小球移动的操作,需要增加移动位置开始点Tran_StartPoint,移动结束位置点Tran_EndPoint
因为得分提示UI和黄色的范围提示UI要跟着小球一起移动,我们便创建一个移动物体挂点Tran_MovePos,将这几个需要一起移动的UI放在下面。


逻辑功能的实现:
针对很多新手程序员来说,写脚本最难的在于实现功能的模块化处理。脚本与脚本之间的重复代码过多,耦合过多,这样很不利于维护和处理业务逻辑。
OSU!的点击圈和滑动条效果其实有很多相似的地方,比如他们都需要开始的延迟时间,这个时候其实是让玩家做好下一步的准备,他们都有一个判定时间,在这个判定时间内点击到判定区域开始计算得分,而滑动条只多了一步滑动操作。都有结算显示,根据操作来打开不同的得分提示,最后统一的删除清理。


我们先将统一部分的功能实现,创建一个NoteLogic脚本来做为公共的逻辑脚本处理。
NoteLogic的主要函数:
时间变化,在特定的状态计时,达到目标值后进行状态切换
private void Update()
    {
        switch (curState)
        {
            case eState.Delay:
                {
                    curTime += Time.deltaTime;
                    if (curTime > delayTime)
                    {
                        curTime = 0;
                        SetCurState(eState.Wait);
                    }
                }
                break;
            case eState.Operation:
            case eState.Wait:
                {
                    curTime += Time.deltaTime;
                    if (curTime > startTime+judgeTime+0.3f)
                    {
                        curTime = 0;
                        SetCurState(eState.Over);
                    }
                }
                break;
            case eState.Over:
                {
                    curTime += Time.deltaTime;
                    if (curTime > desTime)
                    {
                        SetCurState(eState.None);
                        Destroy(gameObject);
                    }
                }
                break;
        }
    }

设置当前状态函数,通关枚举类型变化来实现状态切换
  public void SetCurState(eState rState)
    {
        curState = rState;

        switch (curState)
        {
            //界面在延迟等待的阶段处理渐入效果
            case eState.Delay:
                curTime = 0;
                var canvasGroup = gameObject.GetComponent<CanvasGroup>();
                canvasGroup.alpha = 0;
                gameObject.GetComponent<CanvasGroup>().DOFade(1, delayTime);
                break;
            //等待判定阶段,将圆圈图片做缩放动画
            case eState.Wait:
                if (curType != LevelNoteData.eNoteType.Disk)
                {
                    circleTipObj.gameObject.SetActive(true);
                    circleTipObj.transform.DOScale(1, startTime).OnComplete(() => { circleTipObj.gameObject.SetActive(false); });
                }
                break;
            //操作阶段调用虚方法,让继承的类来自定义该状态功能
            case eState.Operation:
                if (curType != LevelNoteData.eNoteType.Disk)
                {
                    circleTipObj.gameObject.SetActive(false);
                }
                OnJudgetOperation();
                break;
            //结束状态打开得分提示
            case eState.Over:
                curTime = 0;
                ShowScore();
                break;
        }
    }



虚函数,继承的子类来实现这里的功能


    /// <summary>
    /// 做判定操作使用的虚函数
    /// </summary>
    public virtual void OnJudgetOperation()
    {

    }

打开的得分提示UI,这里设置角度的原因是部分Note会旋转位置,而打开的提示UI不能随着父物体旋转而旋转


    public void ShowScore()
    {
        if (mainShowObj != null)
        {
            mainShowObj.gameObject.SetActive(false);
        }

        switch (curScore)
        {
            case eScore.Good:
                statePointArr[0].gameObject.SetActive(true);
                statePointArr[0].transform.eulerAngles = Vector3.zero;
                break;
            case eScore.Perfect:
                statePointArr[1].gameObject.SetActive(true);
                statePointArr[1].transform.eulerAngles = Vector3.zero;
                break;
            case eScore.Fail:
                statePointArr[2].gameObject.SetActive(true);
                statePointArr[2].transform.eulerAngles = Vector3.zero;
                break;
        }
    }


HitCircle和Slider实现
创建HitCircle和Slider脚本并继承NoteLogic类,通过重载OnJudgetOperation函数来做各自独立的功能处理。
HitCircle脚本只用点击后判断出得分,改变当前的状态为Over就结束了。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using DG.Tweening;

public class HitCircle : NoteLogic
{

    public override void OnJudgetOperation()
    {
        //这里简单的通过时间的差值来判断得分
        float dValue = Mathf.Abs(curTime  - startTime);

        if (dValue < startTime * 0.35f)
        {
            curScore = eScore.Perfect;
        }
        else if (dValue < startTime * 0.7f)
        {
            curScore = eScore.Good;
        }
        else
        {
            curScore = eScore.Fail;
        }

        SetCurState(eState.Over);
    }

}


而Slider脚本需要额外扩展挂点,因此可以直接在子类声明变量。
[Header("移动挂点")]Unity的一个属性,它能再Inspector界面的变量上显示你添加的字符串。
我们直接在功能处理中让移动点移动到目标位置就好了,当移动到目标点后判定得分,改变状态为结束。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using DG.Tweening;

public class Slider : NoteLogic
{
    [Header("移动挂点")]
    public GameObject movePoint;
    [Header("移动开始挂点")]
    public GameObject moveStartPoint;
    [Header("移动目标挂点")]
    public GameObject moveTargetPoint;
    [Header("滚动球物体")]
    public GameObject rollBar;
    [Header("移动提示圈")]
    public RectTransform moveTipObj;

    public override void OnJudgetOperation()
    {
        movePoint.transform.position = moveStartPoint.transform.position;
        rollBar.gameObject.SetActive(true);

        Tweener tween = null;
        //Dotween可以很方便各种链接Update功能和Complete功能
        tween = movePoint.transform.DOMove(moveTargetPoint.transform.position, judgeTime).OnComplete(() =>
        {
            //当球移动到目标位置后调用

            float dValue = Mathf.Abs(startTime+judgeTime -curTime);

            //和点击圆圈的判定一样,其实也可以做不同的处理
            if (dValue < startTime * 0.35f)
            {
                curScore = eScore.Perfect;
            }
            else if (dValue < startTime * 0.7f)
            {
                curScore = eScore.Good;
            }
            else
            {
                curScore = eScore.Fail;
            }

            rollBar.gameObject.SetActive(false);
            SetCurState(eState.Over);

        }).OnUpdate(() =>
        {
            //在每一帧移动中判断鼠标是否超出跟随小球移动的黄色圆圈
            if (Vector3.Distance(Input.mousePosition, moveTipObj.position) > moveTipObj.sizeDelta.x * 0.5f)
            {
                if (tween != null)
                {
                    tween.Kill();
                }
                curScore = eScore.Fail;
                rollBar.gameObject.SetActive(false);
                SetCurState(eState.Over);
            }
        });
    }

}


可以继续链加Update方法来每帧判断处理鼠标是否移出判定范围了






将脚本挂在显示的界面物体上,看下效果
HitCircle:






Slider:








关卡配置
通用UI组件
之前我们说过了UI做成预提体的方便之处,这里我们要将这两个非界面类型的HitCircle和Slider物体也做成预制体,这样通用组件化可以很方便我们动态搭建出界面效果。
将HitCircle和Slider保存成预制体






然后我们只用按照音乐播放的时间,在特定时刻动态读取创建出Com_HitCircle和Com_Slider,设置好位置和角度,再给脚本传入延迟、等待、判定、销毁时间就可以实现一个简单音乐战斗了。
配置我们的关卡数据
OSU!的Battle流程其实就是一个根据时间排序好的Note列表,我们先创建一个LevelNoteData类来储存数据。






储存关卡信息就先临时放在Page_GamePlay界面的UI脚本上吧,其实正式数据是决不能这样做的,这里是想快速实现效果避免创建过多的类导致说明混乱。
我们先规定levelNoteList存储的信息都是按照时间从小到大排序的,每帧判定当前时间是否满足最近的快要创建的Note的创建时间,若满足就判断最近的Note类型,创建出对应的UI通用组件,并对脚本赋值。
创建完成后就可以将这个Note移出关卡信息列表了。








简单介绍关卡编辑
创建一个Editor脚本添加[CustomEditor(typeof(Page_GamePlay))]属性便可以修改所有用到挂载Page_GamePlay脚本的Inspector界面信息。






这样写完之后,我们看到的脚本参数是这样的。






这里我就不细讲Editor脚本的用法了,因为这里做脚本数据储存的方式并不常规,而且这种编辑关卡的方法太过容易失误。我这里主要是为了完成教程说明,才临时用这用方法存储数据。


总结:
OSU!的战斗功能先简单讲解了点击圆圈和跟随Slider两种,下一期将会讲缺失的画圈和一个背景视频插入的效果。这一期重点在于介绍UI系统相关的九宫格和序列帧动画,以及游戏玩法相关的脚本功能和信息编辑存储。有兴趣的读者朋友可以下载Demo了解,最后附上下载地址。
chs71371/OSU_Battle










对游戏开发感兴趣的同学,欢迎围观我们:【皮皮关游戏开发教育】 ,会定期更新各种教程干货,更有别具一格的线下小班教育。在你学习进步的路上,有皮皮关陪你!~
我们的官网地址:http://levelpp.com/
我们的游戏开发技术交流群:610475807
我们的微信公众号:皮皮关

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-23 11:56 , Processed in 0.092631 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表