【Unity】 节奏类游戏的表盘卡点功能

目录

1:前言

2:开始  ---(方案一根据音频数据自动生成节奏点)

2.1:功能实现选择---音频可视化

2.2:结论-(结果不准确)

3:游戏表盘的实现----(方案二自给自足,自动输入用时自动读取)

3.1:游戏节奏表盘类型选取

 3.2:游戏功能分析

4:表盘功能的详细实现

4.1:表盘的输入功能

 4.2:表盘的读取功能

5:总结

6:项目源码及参考资料


1:前言

        最近迷上音乐游戏,发现随着带感的背景音乐和满屏的节奏点,让我很兴奋,游戏让我耳目手同时行动,产生了非常强烈的代入感,不过音游类的游戏最注重的还是节奏点的卡点,如果能够闭着眼睛玩下去并且连续Combo那绝对称得上是一款好游戏。所以我决定复刻一个简单的音游卡点功能!

(图片来自网络侵删)


2:开始  ---(方案一根据音频数据自动生成节奏点)

2.1:功能实现选择---音频可视化

一开始我构想音游节奏点随着背景音乐的高低频率变化,那么可以用unity自带的音频数据分析来实现,说干就干!

先上效果图:

首先我们先分析频率较高的点在什么时候出现?

  • 系统会分析每一帧的音频并转化为一个数组。
  • 我们截取需要的音乐并记录下来,该期间出现每一个组数据的平均值。
  • 根据平均值的60%设置阈值,并输出打印出来,观察可行性。

                 1.  分析结束后我们开始写代码,首先是音频分析部分,用unity自带的API。

 float[] spectrum = new float[256];
//将音频片段转化为数据部分
        AudioListener.GetSpectrumData(spectrum, 0, FFTWindow.Rectangular);
        timeCount -= Time.deltaTime;
        if (timeCount <= 0)
        {
            for (int i = 0; i < cubes.Count; i++)
            {
                cubes[i].transform.localScale = new Vector3(cubes[i].localScale.x, spectrum[i] * StepCount, cubes[i].localScale.z);
            }
        //每隔0.1s检测一次
            timeCount = 0.1f;
        }

                2.然后就是分析每次的片段数组平均值。 

  private void OnDisable()
    {
//该片段总体的平均值
        print(sum / m);
    }
//每0.1s数据的平均值
    float Pingjun(float[] data)
    {
        float a = 0;
        foreach (var item in data)
        {
            a += item;
        }
        return a / data.Length;
    }

        3.设置阈值再次遍历

 if (Pingjun(spectrum) > 0.0035f)
        {
//记录点,当前平均值大于阈值时在该时刻输出
            print(1);
        }
        sum += Pingjun(spectrum);

2.2:结论-(结果不准确)

        结果并没有我想象的那么好,有时对得上有时对不上,音乐的频率和音高和我们听到的是有差别的,可能音乐内部数据比我们想象的更复杂,所以我要换一种方法。


3:游戏表盘的实现----(方案二自给自足,自动输入用时自动读取)

3.1:游戏节奏表盘类型选取

        第一个是传统瀑布型节奏点分别从不同轨道下落并跟着背景音乐同步下落。

(图片源于网络侵删)

        


第二种则是比较小众的表盘式节奏点,伴随着音乐节奏不断更新自己的表盘点,玩家可以在根据音乐节奏去击点,然后配合一些动作类游戏也会让玩家有很强的代入感。本文选择此种类型来展开讨论和分析其功能如何实现 。大概类型为下图

 3.2:游戏功能分析

        先放出结果,带着结果去分析。

        表盘应包含哪些元素 

  • 拥有一个指针并且能每隔一段时间去围绕中心旋转。
  • 表盘上包含许多节奏点(下文称点)。

        表盘应包含哪些功能

  • 指针和点之间有触发条件。
  • 指针能随着表盘动态更新,以波为单位。
  • 点拥有3种状态一种为失活不显示,一种为激活显示为黑色,一种为激活但没有卡上为红色圆圈。

4:表盘功能的详细实现

4.1:表盘的输入功能

        首先需要实现指针转动的功能 ,指针每隔0.5s旋转一次,旋转一次为10°正好对应表盘点数36个,一波为18s。

IEnumerator pointerCoroutine()
    {
        while (true)
        {
        //时间间隔默认为0.5s

            yield return waitForSeconds;
           //speed为旋转的度数默认为10°
            transform.Rotate(new Vector3(0, 0, Speed));
        }
    }

        其次需要实现表盘波数的数据结构,我们需要一个数据存储波数,一个数组存储当前表盘应出现的点,数组长度默认为36对应表盘36个点,其中数组值只有0和1,0表示点隐藏,1表示点显示。由于我们需要在游戏运行时记录数据并且退出后保存数据,所以我们采用ScriptableObject类。


[CreateAssetMenu(fileName ="newitem_data",menuName ="CreateData/Create New item_Data")]
public class item_Data : ScriptableObject
{
    //表盘上的点数集合
   public List<ItemData> itemDatas;
}

[System.Serializable]
public class ItemData
{
    //波数
    public int wave;
    //时间暂时未用上
    public float time;
    //该波数的状态表0表示隐藏,1表示应该出现。
    public int[] statetable;
    public ItemData()
    {}
    public ItemData(int wave,float time,int[] statetable)
    {
        this.wave=wave;
        this.time=time;
        this.statetable=statetable;
    }
}

        最后就是如何去输入了,大概思路就是指针旋转一次对应该波数数据结构中的数组索引值自增一表示该位置的点(36个点),如果按下空格键则证明此处为节奏点,将该处的数组值改为1。并在指针组件Z轴为0时默认进入下一波的录制,并且会将数组重新初始化,数组值全为0。


public class PrinfScript : MonoBehaviour
{
    [SerializeField] float Speed = 10;
    WaitForSeconds waitForSeconds;
    [SerializeField] float time = 0.5f;
    public item_Data items;
    public int wave = 0;
    float wavenexttime;
    float wavetime = 0;
    int statetable_count = 0;
    public int[] statetable = new int[36];

    public AudioSource bgm;
    public Slider bgmslider;

    private void Awake()
    {
        Array.Clear(statetable, 0, statetable.Length - 1);
        waitForSeconds = new WaitForSeconds(time);
        wavenexttime = wavetime;
    }
    void Start()
    {
        transform.rotation = Quaternion.Euler(Vector3.zero);
        StartCoroutine(nameof(pointerCoroutine));
        StartCoroutine(nameof(StatetAction));

    }
    private void Update()
    {
      
        if (Input.GetKeyDown(KeyCode.Space))
        {
            if (statetable[statetable_count] != 1)
            {
                statetable[statetable_count] = 1;
            }
        }
    }
   
    /// <summary>
    /// 指针索引动态更新
    /// </summary>
    /// <returns></returns>
    IEnumerator pointerCoroutine()
    {
        while (true)
        {
            yield return waitForSeconds;
            statetable_count += 1;
            statetable_count = statetable_count % 35;
            transform.Rotate(new Vector3(0, 0, Speed));
        }
    }
    /// <summary>
    /// 每经过一轮自动更新
    /// </summary>
    /// <returns></returns>
    IEnumerator StatetAction()
    {
        while (true)
        {
            if (TransformRotation.Instance.GetInspectorRotationValueMethod(transform).z == 0)
            {
                wavetime = wavenexttime;
                ItemData pnode = new ItemData();
                pnode.wave = wave;
                pnode.time = wavetime;
                int[] pnodeInt = new int[statetable.Length];
                for (int i = 0; i < statetable.Length; i++)
                {
                    pnodeInt[i] = statetable[i];
                }
                pnode.statetable=pnodeInt;
                items.itemDatas.Add(pnode);
                wavenexttime = bgm.time;
                wave++;
                Array.Clear(statetable, 0, statetable.Length - 1);
                yield return new WaitForSeconds(0.7f);
            }
            yield return null;
        }

    }

录取时先创建一个ScriptableObject文件,并拖入该脚本中。

 (如何创建数据文件)

 (数据文件样式)


 4.2:表盘的读取功能

        先不说表盘如何读取,我们先解决如何判定指针和点什么时候接触什么时候离开,起初有三种方案:

  1. 碰撞检测,在OnCollisionEnter2D时开始执行接触时的逻辑,在OnCollisionExit2D执行结束时的逻辑,在OnCollisionStay2D处理逻辑。
  2. 运用Physics2D.IgnoreLayerCollision频繁的忽略层级来判断是否进入检测范围。
  3. 触发检测时OnCollisionEnter2D开启触发逻辑处理协程,OnCollisionExit2D关闭逻辑处理协程。

经过多轮测试比较发现第三种是最稳定的,前两种相对于不太稳定有时能检测到有时则不能检测到。


检测的问题解决了,下一步就是检测逻辑应包含那些?观察下图

  1. 当检测时按下空格键会播放指针伸缩动画。
  2. 检测成功点消失,检测失败点变为红色。

将该脚本挂在36个点上。

public class Item : MonoBehaviour
{

    bool IsClick = false;
    //输入正确的图片显示
    [SerializeField] Sprite Trueimage;
    //输入错误的图片显示
    [SerializeField] Sprite Falseimage;
    //获得图片组件
    SpriteRenderer idlesprit;
    //指针
    [SerializeField] GameObject pointer;
    Animator animator;
    private void Awake()
    {
        idlesprit = GetComponent<SpriteRenderer>();
        animator = GetComponent<Animator>();
    }
    private void OnEnable()
    {
        idlesprit.sprite = Trueimage;
        animator.Play("itemAnimation");
    }
    private void OnDisable()
    {
        StopAllCoroutines();
    }
    /// <summary>
    /// 指针进入开始执行协程
    /// </summary>
    public void StartCoroutine()
    {
        StartCoroutine(nameof(GetSpaceCoroutine));
    }
    /// <summary>
    /// 指针退出执行携程
    /// </summary>
    public void StopCoroutine()
    {
        //如果状态为未点击则更改图片的样式
        if (IsClick == false)
        {
            idlesprit.sprite = Falseimage;
        }
        StopCoroutine(nameof(GetSpaceCoroutine));
    }
    /// <summary>
    /// 指针和点数重叠的逻辑处理
    /// </summary>
    /// <returns></returns>
    IEnumerator GetSpaceCoroutine()
    {
        while (true)
        {
            if (Input.GetKeyDown(KeyCode.Space) && gameObject.activeSelf)
            {
                IsClick = true;
                //播放伸缩动画
                pointer.GetComponent<bornpoint>().PlayerAnimation();
                gameObject.SetActive(false);
                
            }
            yield return null;
        }
    }

        下一步就是表盘逻辑处理,怎样动态读取并更新表盘上的点呢?

  1. 将指针的Rotation-Z轴 值作为波数更新和开启下一波数的条件。
  2. 动态读取录制好的文件以波数为索引读取里面的数组。
  3. 遍历数组若为1显示为0则隐藏。

        这里再解释一下,其中有一个GameObject数组包含面板上的36个点,还有一个数组是数据文件中的数组,只是包含36个0或1。其中这两个数组相同索引表示面板上哪一个点,所以结合数据数组可以便捷的显示或隐藏面板的点。

        还有就是在指针Z轴旋转等于-10时预备更新下一波,要做的是清空表盘上的点(全部失活),在Z等于0的时候为下一波开始的时候,根据数据文件对应的波数更新表盘上的点。

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

public class ItemManager : MonoBehaviour
{
    public int WaveCount = 1;
    public int waveCountNext;
    [SerializeField] Transform pointer;
    //包含的指针集合
    [SerializeField] List<GameObject> items;
    //指针回放的ScriptObject文件
    [SerializeField] item_Data itemdata;
    private void Awake()
    {
        for (int i = 0; i < transform.childCount; i++)
        {
            items.Add(transform.GetChild(i).gameObject);
        }
        //更新波数逻辑
        waveCountNext = WaveCount + 1;

    }
    void Start()
    {
        StartCoroutine(nameof(WaveCountCoroutine));
        StartCoroutine(nameof(StartBornCoroutine));
        for (int i = 0; i < itemdata.itemDatas[1].statetable.Length; i++)
        {
            if (itemdata.itemDatas[1].statetable[i] == 1)
            {
                if (!items[i].gameObject.activeSelf)
                {
                    items[i].gameObject.SetActive(true);
                }
            }
        }
    }
    private void OnDisable()
    {
        StopAllCoroutines();
    }
    /// <summary>
    /// 即将结束时要将表盘上的指针清除
    /// </summary>
    /// <returns></returns>
    IEnumerator WaveCountCoroutine()
    {
        while (true)
        {
            if (TransformRotation.Instance.GetInspectorRotationValueMethod(pointer).z == -10)
            {
                WaveCount = waveCountNext;
                //清场
                foreach (var item in items)
                {
                    if (item.activeSelf)
                    {
                        item.SetActive(false);
                    }
                }

                yield return new WaitForSeconds(0.7f);
            }
            yield return null;
        }

    }
    /// <summary>
    /// 加载本次表盘上相应位置出现的点数
    /// </summary>
    /// <returns></returns>
    IEnumerator StartBornCoroutine()
    {
        while (!(WaveCount >= itemdata.itemDatas.Count))
        {

            if (TransformRotation.Instance.GetInspectorRotationValueMethod(pointer).z == 0)
            {
                for (int i = 0; i < itemdata.itemDatas[WaveCount].statetable.Length; i++)
                {
                    if (itemdata.itemDatas[WaveCount].statetable[i] == 1)
                    {
                        items[i].gameObject.SetActive(true);
                    }
                    yield return new WaitForSeconds(0.1f);
                }
                waveCountNext++;
            }
            yield return new WaitUntil(() => WaveCount == waveCountNext);
        }

    }

}

5:总结

        这次想法主要是要参加一个游戏比赛(打打广告多为我们加加油!演示视频链接会在文末给出),尝试着将节奏类卡点游戏结合动作射击。核心功能虽然实现了但是表盘更新会随着游戏帧率产生误差,音乐播放并不会受到帧率的影响,解决方案就是将众多的协程返回一帧改为一帧真实时间(WaitForSecondsRealtime),不过有一些功能也会差生影响,虽然完成了但并不是那么完美!自己还得努力提升。

        项目做的不太完善,希望大佬有什么意见或建议评论区留言或者私信,另外又看不懂的小伙伴也可以私信或者留言问我。感谢大家希望大家能够点赞喜欢。

6:项目源码及参考资料

项目源码:GitHub - JAM893736346/ClockMusic: 节奏类游戏功能启发

项目演示视频:游戏演示_演示 (bilibili.com)

项目参考:【Unity】ScriptableObject的介绍_妈妈说女孩子要自立自强的博客-CSDN博客_unityscriptobject

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值