“天地间阴阳本如齿轮般精密咬合,维系着四季轮转与万物生息。但不知何时,裂隙悄然出现——阴阳的平衡被打破,许多地区乱象丛生,生灵涂炭。玩家遂化身为灵动的阴阳鱼,游走于这些阴阳失序之地,将形态切换于阴鱼和阳鱼之间,在炽阳之地吸阳释阴,于幽阴之处吸阴释阳,直至自然重归有序,万物再复生机。”
米娜桑好久不见,这段时间为了把游戏开发的知识用在实践上,笔者和几个小伙伴决定开始制作一系列的小游戏,并命名为“游载千年“项目,体验一把游戏开发的快乐。
那么今天来介绍一下我们几个菜鸟大学生共同制作的这款休闲解谜类型的小游戏——《阴阳季变录》,同时也对我所负责的程序部分进行简单的复盘。
作品展示
《阴阳季变录》游戏内容演示
一、游戏概述
1、游戏名称:《阴阳季变录》
2、主体类型:2D横板平台跳跃,休闲解谜
3、场景风格:大自然,四季
二、故事背景
天地之间,阴阳二气本应和谐共存。但不知何时,这样的平衡被打破,大地上涌现出无数的极阴之地与极阳之地。极阴之地终日笼罩着刺骨寒霜,草木冻结;极阳之地终日受火焰般的灼烧,花草枯萎。而玩家将要控制阴阳鱼,能够调和阴阳,使天地间失衡之地散寒为暖、降火为阴,最终将世间所有的失衡之地都调理平衡,直至阴阳共生。
三、基础玩法
在大自然的游戏场景中,有一些区域处于“阴阳失衡”的状态。玩家可以控制不同形态的阴阳鱼,对这些区域中的阴气或阳气进行吸收,阴阳鱼的形态是随着吸收阴气还是阳气决定的,当场景中所有区域都达到阴阳平衡状态时,关卡通过。
四、游戏下载
关于这款游戏的源文件与素材我都同步在了github上,感兴趣的可以看看。也可以在这里下载到我们的游戏本体,进行游玩,请复制到浏览器打开。
https://github.com/starmatch9/Chronogame-YinYangJiBianLu/releases
游戏也发布在了itch.io上,复制到浏览器后可以直接在页面中下载。
https://starmatch9.itch.io/starmatch9yinyangjibianlu
这里也提供了百度网盘的下载链接,点击最下方阅读原文即可下载。
https://pan.baidu.com/s/1EaFlXp_VsjB79oHQ1QxvyQ?pwd=6s3q【提取码: 6s3q】
诚挚地期待各位感兴趣的玩家能来体验一下我们的游戏,提出建议与看法,找出游戏中的问题。
淡入淡出效果
本文接下来的内容我会记录如何实现该游戏用到的某些功能,也是为以后更多游戏的制作打下基础。
首先就是“淡入淡出”的效果了。可能因为我是新手,要经常用到淡入淡出这一功能,像游戏场景开始时、场景结束时还有开场动画操作说明等等等等。
但其实实现方法十分简单,但奈何我在一开始是把代码分散在各个协程中的,十分不利于重复利用。现在就来写一段ImageUI的淡入淡出实现吧。
//淡入
public IEnumerator imageLoad(Image image)
{
//运行时间
float elapsedTime = 0f;
//确定颜色变化的时间节点
Color startColor = image.color;
Color noColor = new Color(startColor.r, startColor.g, startColor.b, 0f);
image.color = noColor;
//淡入0.5秒
while (elapsedTime < 1.5f)
{
//按照百分比从0到1插值
float alpha = Mathf.Lerp(0, 1, elapsedTime / 1.5f);
image.color = new Color(startColor.r, startColor.g, startColor.b, alpha);
elapsedTime += Time.deltaTime;
//等待下一帧
yield return null;
}
image.color = new Color(startColor.r, startColor.g, startColor.b, 1f);
}
//淡出
public IEnumerator imageFade(Image image)
{
//运行时间
float elapsedTime = 0f;
//确定颜色变化的时间节点
Color startColor = image.color;
Color noColor = new Color(startColor.r, startColor.g, startColor.b, 0f);
while (elapsedTime < 1.5f)
{
float alpha = Mathf.Lerp(1, 0, elapsedTime / 1.5f);
image.color = new Color(startColor.r, startColor.g, startColor.b, alpha);
elapsedTime += Time.deltaTime;
//等待下一帧
yield return null;
}
image.color = noColor;
}
现在来分析一下上面的代码(其实淡入淡出的英文是FadeIn和FadeOut)。elapsedTime是用来计时的,是来监测淡入淡出的时长是否与我们设定的时长一样。这个计时器从零开始计时,在while循环内,每一轮循环都进行时间累计,其中“Time.deltaTime”是一帧为多少秒的意思。并严格等待一帧,也就是说while每一帧循环一轮。
至于Mathf.Lerp 是 Unity 中的一个 线性插值函数,用于在两个值之间进行平滑过渡。其参数的前两位代表插值范围,第三位代表插值系数,大于零小于一,若将Lerp表示为Lerp(a, b, t)的话,他们他们之间有 结果 = a + (b - a) * t 这个式子。可以直观的理解为结果是从起始值向终点值的方向前进了“区间大小 * t”个单位。所以我们的代码就成功模拟出了图像的淡入淡出。
但如果是BGM的淡入淡出呢?核心还是Lerp,基本框架没变。
public IEnumerator bgmPlay()
{
//设置专属音乐
//设置开始音量为0
teachBGM.volume = 0f;
teachBGM.Play();
//累计时间
float elapsed = 0;
while (elapsed < 3f)
{
teachBGM.volume = Mathf.Lerp(0f, 0.05f, elapsed / 3f);
//增量
elapsed += Time.deltaTime;
yield return null;
}
//测试发现只有这个音量不吵
teachBGM.volume = 0.05f;
}
其实像这样哪个协程用的到就往哪写这段代码效率是非常低的,所以我们知道原理以后,可以直接编写一个淡入淡出的类组件脚本。这样的话要用的使用把他拖到游戏对象上,选择是要变文字、图像还是背景音乐,即插即用,十分方便。
public class FadeEffect : MonoBehaviour
{
public enum FadeType
{
FadeIn,
FadeOut,
FadeInOut
}
public enum FadeTarget {
Audio,
TextPro,
Sprite,
ImageUI
}
/*效果对象面板*/
[Header("效果对象设置")]
//一开始让用户选择淡入淡出的目标对象是什么
[Tooltip("只有被选中的“target”上的内容才会生效")]
public FadeTarget target = FadeTarget.Audio;
public AudioSource audioSource = null;
public TextMeshProUGUI textMeshPro = null;
public Renderer spriteRenderer = null;
public UnityEngine.UI.Image image = null;
/*淡入淡出设置面板*/
[Header("淡入淡出设置")]
//选择效果类型:是只淡入?还是只淡出?还是连续淡入淡出
public FadeType fadeType = FadeType.FadeIn;
//持续时间
public float durationFadeIn = 1.0f;
public float durationFadeOut = 1.0f;
//唤醒就开始
public bool awakeStart = true;
//淡出后设置物体为未激活
public bool disableAfterFadeOut = true;
//选择淡入淡出时的停留时间
[Tooltip("当选择“Fade In Out”时生效,表示中间停留时间")]
public float stayTime = 1f;
/*颜色设置面板*/
[Header("颜色设置")]
[Tooltip("效果开始时的颜色")]
public Color startColor = Color.black;
[Tooltip("效果结束时的颜色")]
public Color endColor = Color.black;
[Tooltip("当选择“Fade In Out”时生效,表示中间显示的颜色")]
public Color targetColor = Color.white;
/*音量设置面板*/
[Header("音量设置")]
[Tooltip("效果开始时的音量")]
public float startVolume = 0f;
[Tooltip("效果结束时的音量")]
public float endVolume = 1f;
[Tooltip("当选择“Fade In Out”时生效,表示中间显示的音量")]
public float targetVolume = 1f;
private void Start()
{
if (awakeStart)
{
execute();
}
}
public void execute()
{
if(fadeType == FadeType.FadeIn)
{
if(target == FadeTarget.Audio)
{
StartCoroutine(FadeIn(audioSource));
}
else if(target == FadeTarget.TextPro) {
StartCoroutine(FadeIn(textMeshPro));
}
else if(target == FadeTarget.Sprite)
{
StartCoroutine(FadeIn(spriteRenderer));
}
else if (target == FadeTarget.ImageUI)
{
StartCoroutine(FadeIn(image));
}
}
else if(fadeType == FadeType.FadeOut)
{
if (target == FadeTarget.Audio)
{
StartCoroutine(FadeOut(audioSource));
}
else if (target == FadeTarget.TextPro)
{
StartCoroutine(FadeOut(textMeshPro));
}
else if (target == FadeTarget.Sprite)
{
StartCoroutine(FadeOut(spriteRenderer));
}
else if (target == FadeTarget.ImageUI)
{
StartCoroutine(FadeOut(image));
}
}
else if (fadeType == FadeType.FadeInOut)
{
if (target == FadeTarget.Audio)
{
StartCoroutine(FadeInOut(audioSource));
}
else if (target == FadeTarget.TextPro)
{
StartCoroutine(FadeInOut(textMeshPro));
}
else if (target == FadeTarget.Sprite)
{
StartCoroutine(FadeInOut(spriteRenderer));
}
else if (target == FadeTarget.ImageUI)
{
StartCoroutine(FadeInOut(image));
}
}
}
private IEnumerator FadeIn(UnityEngine.UI.Image ima)
{
float elapsedTime = 0f;
ima.color = startColor;
while (elapsedTime < durationFadeIn)
{
//按照百分比从0到1插值
float r = Mathf.Lerp(startColor.r, endColor.r, elapsedTime / durationFadeIn);
float b = Mathf.Lerp(startColor.b, endColor.b, elapsedTime / durationFadeIn);
float g = Mathf.Lerp(startColor.g, endColor.g, elapsedTime / durationFadeIn);
float a = Mathf.Lerp(startColor.a, endColor.a, elapsedTime / durationFadeIn);
ima.color = new Color(r, b, g, a);
elapsedTime += Time.deltaTime;
//等待下一帧
yield return null;
}
//持续时间结束
ima.color = endColor;
}
private IEnumerator FadeOut(UnityEngine.UI.Image ima)
{
//运行时间,用于累加
float elapsedTime = 0f;
ima.color = startColor;
while (elapsedTime < durationFadeOut)
{
//按照百分比从0到1插值
float r = Mathf.Lerp(startColor.r, endColor.r, elapsedTime / durationFadeOut);
float b = Mathf.Lerp(startColor.b, endColor.b, elapsedTime / durationFadeOut);
float g = Mathf.Lerp(startColor.g, endColor.g, elapsedTime / durationFadeOut);
float a = Mathf.Lerp(startColor.a, endColor.a, elapsedTime / durationFadeOut);
ima.color = new Color(r, b, g, a);
elapsedTime += Time.deltaTime;
//等待下一帧
yield return null;
}
//持续时间结束
ima.color = endColor;
if (disableAfterFadeOut)
{
ima.transform.gameObject.SetActive(false);
}
}
//剩下的代码很长,但很相似,就省略掉了
}
这样一来,我们就可以在显示器里进行淡入淡出的设置了。
关于PlayerPrefs
在游戏制作中常常会用到许多Unity的API,比如通过使用SceneManager.LoadScene(name)切换场景,通过使用DontDestroyOnLoad(gameObject)保证一些游戏对象不会因为场景交替而销毁。但是我在使用PlayerPrefs时,被弄晕了好久。
中间老是在编辑器里清空存档,但打包出来的exe无论如何都还是原来的存档。直到我在游戏中加了一个按钮清空PlayerPrefs,才发现其存储数据的位置对于编辑器和exe文件是不一样的。
这里就来详细记录一下关于PlayerPrefs的使用吧。
1、功能:一种简单的键值对存储系统,它的主要功能是保存游戏的配置信息或者用户偏好设置。这些数据会被存储在本地,当游戏关闭后再次启动,之前保存的数据依然可以被读取使用。
2、用法:PlayerPrefs只允许存储三种数据类型,int,float,string。键值对中的键(key)与值(value)都只能是这三种类型。
存储数据时存入键值对,读取数据时通过键获取值。以下为具体操作。
保存数据:
PlayerPrefs.SetInt(key, value):将整数关联到键。
PlayerPrefs.SetFloat(key, value):将浮点数关联到键。
PlayerPrefs.SetString(key, value):将字符串关联到键。
PlayerPrefs.Save():手动保存以上的Set。但是应用退出时数据会自动保存,所以通常情况下不常用。
读取数据:
PlayerPrefs.GetInt(key):返回已保存的整数,如果整数不存在,返回0。
PlayerPrefs.GetFloat(key):返回已保存的浮点数,如果浮点数不存在,返回0.0f。
PlayerPrefs.GetString(key):回已保存的字符串,如果字符串不存在,返回空串""。
PlayerPrefs.GetInt(key, defult):返回已保存的整数,如果整数不存在,返回自设定的默认值defult。其他类型一样。
其他:
PlayerPrefs.HasKey(key):用于检查是否存在指定的键key,返回布尔值。
PlayerPrefs.DeleteKey(key):删除指定键key及其对应的数据。
PlayerPrefs.DeleteAll():删除所有保存的数据。
关于以上的用法,我们游戏的关卡状态就是通过PlayerPrefs实现的。
//场景加载时解锁对应关卡
void Start()
{
//获取当前场景的名称
string name = SceneManager.GetActiveScene().name;
// 保存字符串数据
PlayerPrefs.SetInt(name, 1);
// 调用 Save 方法将数据写入磁盘
PlayerPrefs.Save();
}
然后玩家选择关卡时,通过PlayerPrefs解锁对应的关卡。
public void check()
{
//获取对象名称,与场景名一一对应
string name = gameObject.name;
// 遍历所有子物体,找到标签是Lock的
foreach (Transform child in transform)
{
if (child.CompareTag("Lock"))
{
//解锁
if(PlayerPrefs.GetInt(name, 0) == 1)
{
child.gameObject.SetActive(false);
}
else
{
child.gameObject.SetActive(true);
}
}
}
}
3、存储路径:对于Windows系统,数据存储在注册表中,路径为“HKEY_CURRENT_USER\Software\[公司名称]\[产品名称]”。如图为我们游戏的注册表记录。
有关PlayerPrefs的存储会涉及到对磁盘的I/O操作,所以考虑到性能不应该频繁使用Save()函数。
小结
这也是我第一次尝试还算完整的小游戏开发了。而把作品发给朋友们测试时,那种感觉才是最满足的。所以收获最大的方面可能是游戏设计的方面而不是程序,比如中间的过场文字出现一次就够了,应该加入跳过功能。测试的时候也出现了许许多多的bug,需要我们绞尽脑汁去改进。不过有一就有二,希望未来的游戏能越做越好。
欢迎大家体验这款小游戏!如果有问题和建议可以直接留言反馈。也欢迎各位期待我们之后的小游戏。
想看更多内容,也欢迎关注我的个人微信公众号!