文章目录
简介:我是一名Unity
游戏开发工程师,皮皮是我养的猫,会讲人话,它接到了喵星的特殊任务:学习编程,学习Unity
游戏开发。
于是,发生了一系列有趣的故事。
13.1 皮皮猫打字机游戏
皮皮:“铲屎官,你为什么打字速度这么快?”
我:“一个字,练。”
皮皮:“教教我,怎么连打字速度。”
我:“来,我给你做一个打字练习游戏吧。”
我:“当你可以一分钟连击180次的时候,你就可以出山了。”
皮皮:“作为猫族,不能被速度打败。”
游戏画面如下:
模块设计如下
本工程使用的Unity
版本为2020.1.14f1c1 (64-bit)
,工程已上传到GitHub
,感兴趣的同学可以下载下来学习。GitHub
地址:https://github.com/linxinfa/Unity-TypeWriting-Game
13.2 场景制作
13.2.1 入口场景
EntryScene.unity
一个标题文本(
Text
组件),一个开始按钮(Button
组件),一个难度选择勾选(ToggleGroup
和Toggle
组件)。背景图使用
SpriteRender
组件在3D
摄像机中渲染。难度等级的勾选使用了
ToggleGroup
组件,用来给Toggle
分组。子节点中的
Toggle
需要指明相同的ToggleGroup
。实现单选的效果。
13.2.2 游戏场景
GameScene.unity
一个血条(
Slider
组件),一个得分(Text
组件),一个字母盘(GridLayoutGroup
、Text
组件)、一个连击(Text
组件),一个角色(SpriteRenderer
、Animator
组件)、一个背景图(SpriteRenderer
组件)。其中字母盘只做一个字母,游戏中进行动态克隆。
再做游戏结束面板,提供一个返回和重来的按钮。
13.2.3 场景切换
点击菜单File - Build Settings...
。
将场景添加到
Scenes In Build
中。代码中,通过
SceneManager.LoadScene
切换场景,如下
// 进入GameScene场景
UnityEngine.SceneManagement.SceneManager.LoadScene(1);
13.3 游戏管理器
游戏管理器GameMgr
,它需要包括数据和逻辑,如下。
13.3.1 数据定义
/// <summary>
/// 难度等级
/// </summary>
public int hardLevel { get; set; }
/// <summary>
/// 得分
/// </summary>
public int score { get; set; }
/// <summary>
/// 最大血量
/// </summary>
private const int MAX_BLOOD = 1500;
/// <summary>
/// 血量
/// </summary>
public int blood
{
get { return m_blood; }
set
{
m_blood = value;
if (m_blood <= 0)
{
gameOver = true;
// Todo 抛出事件
}
}
}
private int m_blood = 0;
/// <summary>
/// 连击数量
/// </summary>
public int comboCnt { get; set; }
/// <summary>
/// 连击定时器
/// </summary>
public float comboTimer { get; set; }
/// <summary>
/// 游戏结束
/// </summary>
public bool gameOver { get; private set; }
/// <summary>
/// 按键列表
/// </summary>
public List<KeyCode> keyList { get { return m_keyList; } }
private List<KeyCode> m_keyList = new List<KeyCode>();
13.3.2 生成字母盘
生成字母盘(16个字母),要求每个字母都不重复,生成的字母存到m_keyList
中。
/// <summary>
/// 生成字母盘
/// </summary>
private void GenKeys()
{
for (int i = 0; i < 16; ++i)
{
m_keyList.Add(GenOneKey());
}
}
/// <summary>
/// 生成一个字母
/// </summary>
/// <returns></returns>
private KeyCode GenOneKey()
{
var key = (KeyCode)UnityEngine.Random.Range((int)KeyCode.A, (int)KeyCode.Z);
for(int i=0,cnt=m_keyList.Count;i<cnt;++i)
{
if(m_keyList[i] == key)
{
// 如果生成的字母已存在,则递归生成
return GenOneKey();
}
}
return key;
}
13.3.3 按键判断
我们需要先判断按键类型,封装一个接口GetKeyDownCode
。
/// <summary>
/// 获取按键类型
/// </summary>
/// <returns></returns>
public KeyCode GetKeyDownCode()
{
if (Input.anyKeyDown)
{
foreach (KeyCode keyCode in Enum.GetValues(typeof(KeyCode)))
{
if (Input.GetKeyDown(keyCode))
{
return keyCode;
}
}
}
return KeyCode.None;
}
然后判断按下的按键是否在字母盘中,返回对应的索引,如果不在字母盘中,则返回-1。
/// <summary>
/// 判断按键是否在字母盘中
/// </summary>
/// <param name="key">按键</param>
/// <returns></returns>
private int IsKeyBingo(KeyCode key)
{
for (int i = 0, cnt = m_keyList.Count; i < cnt; ++i)
{
if (m_keyList[i] == key)
return i;
}
return -1;
}
按键正确的时候,执行连击计算,加血加分,生成新的字母,抛事件更新ui
。
/// <summary>
/// 按键正确
/// </summary>
private void OnKeyBingo(int bingoIndex)
{
// 加连击
++comboCnt;
if (comboCnt >= 3)
{
// 加血加分,连击加持
blood += 150;
if (blood > MAX_BLOOD)
blood = MAX_BLOOD;
score += 20;
}
else
{
// 加血加分
blood += 50;
score += 10;
}
// 生成新的字母
var oldKey = m_keyList[bingoIndex];
var newKey = GenOneKey();
m_keyList[bingoIndex] = newKey;
// Todo 抛事件,更新ui
}
按键错误的时候,连击中断,扣血,抛事件更新ui
。
/// <summary>
/// 按键错误
/// </summary>
private void OnKeyError()
{
// 连击中断
comboCnt = 0;
// 扣血
blood -= 30;
// Todo 抛事件,更新ui
}
13.3.4 连击定时器
如下,其中Time.deltaTime
是一帧的间隔时间。每帧调用UpdateComboTimer
,对comboTimer
进行帧间隔时间递减,通过comboTimer
判断是否超过时间限制,超过则中断连击。
/// <summary>
/// 连击定时器
/// </summary>
public void UpdateComboTimer()
{
if (comboTimer > 0)
{
comboTimer -= Time.deltaTime;
// 超过时间限制,连击断开
if (comboTimer <= 0)
{
comboCnt = 0;
// Todo 抛事件更新连击ui
}
}
}
13.4 动画控制器Animator
Unity
可以用两种方式控制动画
1 Animation
,这种方式简单,直接 Play(“Idle”)
或者CoRSSFade(“Idle”)
就可以播放动画;
2 Animator
,Unity5.x
之后推荐使用这种方式,因为里面可以加上混合动画,让动画切换更加平滑。
13.4.1 添加Animator
点击菜单Window - Animation - Animation
,可以打开Animation
窗口,快捷键是Ctrl+6
。
选中某个物体后,可以为该物体添加或编辑动画,比如选中一个空物体,由于没有动画,会出现一个
Create
按钮。点击
Create
按钮,会弹出窗口设置文件保存路劲。创建成功后,物体上会出现一个
Animator
组件。.controller
文件是一个动画状态机,在Unity
中双击它会打开Animator
窗口,即可看到里面的内容,我们可以在这个窗口中组织各个动画文件。.anim
是动画文件,在Unity
中双击它会打开Animation
窗口,我们可以在这个窗口中制作动画。13.4.2 Animator状态机
每个Animator Controller
都会自带三个状态:Any State
,Entry
和 Exit
。
13.4.2.1 Any State状态
表示任意状态的特殊状态。例如我们如果希望角色在任何状态下都有可能切换到死亡状态,那么Any State
就可以帮我们做到。当你发现某个状态可以从任何状态以相同的条件跳转到时,那么你就可以用Any State
来简化过渡关系。
13.4.2.2 Entry状态
表示状态机的入口状态。当我们为某个GameObject
添加上Animator
组件时,这个组件就会开始发挥它的作用。
如果Animator Controller
控制多个Animation
的播放,那么默认情况下Animator
组件会播放哪个动画呢? 由Entry
来决定的。
但是Entry
本身并不包含动画,而是指向某个带有动画的状态,并设置其为默认状态。被设置为默认状态的状态会显示为 橘黄色。
当然,你可以随时在任意一个状态上通过
鼠标右键->Set as Layer Default State
更改默认状态。记住, Entry
在Animator
组件被激活后 无条件 跳转到默认状态,并且每个Layer
有且仅有一个默认状态。
13.4.2.3 Exit状态
表示状态机的出口状态,以红色标识。如果你的动画控制器只有一层,那么这个状态可能并没有什么卵用。但是当你需要从子状态机中返回到上一层(Layer
)时,把状态指向Exit
就可以了。
13.4.3 动画状态的属性
我们可以选中某个自定义状态,并在Inspector
窗口下观察它具有的属性
属性名 | 描述 |
---|---|
Motion | 状态对应的动画。每个状态的基本属性,直接选择已定义好的动画(Animation Clip)即可 |
Speed | 动画播放的速度。默认值为1,表示速度为原动画的1.0倍。 |
Mutiplier | 勾选右侧的Parameter后可用,即在计算Speed的时考虑 区域1 中定义的某个参数。若选择的参数为smooth,则动画播放速度的计算公式为 smooth * speed * fps(animation clip中指定) |
Mirror | 仅适用于humanoid animation(人型机动画) |
Cycle Offset | 周期偏移,取值范围为0-1.0,用于控制动画起始的偏移量。把它和正弦函数的offset进行对比就能够理解了,只会影响起始动画的播放位置。 |
Foot IK | 仅适用于humanoid animation(人型机动画) |
Write Default | 最好保持默认,感兴趣可以参考官方手册 |
Transitions | 该状态向其他状态发起的过渡列表,包含了Solo和Mute两个参数,在预览状态机的效果时起作用 |
Add BehavIoUr | 用于向状态添加“行为 |
13.4.4 状态间的过渡关系(Transitions)
状态间的过渡关系,直观上说它们就是连接不同状态的有向箭头。
要创建一个从
状态A
到状态B
的过渡,直接在状态A
上 鼠标右键 - Make Transition
并把出现的箭头拖拽到状态B
上点击鼠标左边即可。13.4.5 添加状态控制参数
参数有Float
,Int
,Bool
,Trigger
。
Float
、Int
用来控制一个动画状态的参数,比如速度方向等可以用数值量化的东西,Bool
用来控制动画状态的转变,比如从走路转变到跑步,Trigger
本质上也是bool
类型,但它默认为false
,且当程序设置为true
后,它会自动变回false
。
13.4.6 编辑切换状态的条件
点击连线,在Inspecter
窗口中可以进行设置,在Conditions
栏下可以添加条件,如下图表示当参数Animstate
为0
时会执行这个动画Any State
到New Animation2
的过渡
13.5 猫娘动画状态机
13.5.1 模型资源下载
从Assets Store
上下载猫娘模型,资源地址:https://assetstore.unity.com/packages/2d/characters/fancydoll-c000-little-cat-girl-112776
13.5.2 动画循环设置
模型自带了一些动画
idle
(站立)、walk
(走路)、run
(跑)需要循环播放,勾选Loop Time
。get_hit
(受击)、die
(阵亡)不需要循环播放,不勾选Loop Time
。13.5.3 状态过渡设置
我们需要通过Animator
将这些动画进行合理的组织,如下
添加变量
Action
,过渡条件根据Action
的值进行判断。过渡条件如下
状态1 | 状态2 | 条件 |
---|---|---|
Any State | idle | Action == 1 |
Any State | get_hit | Action == 4 |
get_hit | idle | 无 |
Any State | die | Action == 5 |
13.6 角色动画控制器
角色动画控制器CharacteraniCtrler
,它需要包括数据和逻辑,如下。
运行中的状态过渡
13.6.1 数据定义
/// <summary>
/// 状态定义,默认为Idle状态。
/// </summary>
public enum CharacteraniId
{
Idle = 1,
Walk = 2,
Run = 3,
Hit = 4,
Death = 5,
}
/// <summary>
/// 角色动画Animator组件
/// </summary>
private Animator m_animator;
/// <summary>
/// 动画队列
/// </summary>
private Queue<int> m_animQueue = new Queue<int>();
13.6.2 直接播放动画接口
/// <summary>
/// 立即播放某个动画
/// </summary>
/// <param name="name">动画名称</param>
private void PlayAniImmediately(string name)
{
if (IsDeath) return;
m_animator.CrossFade(name, 0.1f, 0);
}
如立即播放走路动画
public void PlayWalk()
{
PlayAniImmediately("walk");
}
13.6.3 设置变量值
// 设置Action变量值为4
m_animator.SetInteger("Action", 4);
封装成接口
private const string STR_ACTION = "Action";
/// <summary>
/// 播放不同动作ID
/// </summary>
/// <param name="isJump"></param>
///
public void PlayAnimation(int actionID)
{
if (IsDeath) return;
if (m_animator == null)
return;
if (!m_animator.isInitialized || m_animator.IsInTransition(0))
{
// 如果正在过渡,则先塞到队列中
m_animQueue.Enqueue(actionID);
return;
}
m_animator.SetInteger(STR_ACTION, actionID);
}
13.6.4 根据状态队列设置状态
提供一个LateUpdate
接口每帧调用,设置了Action
值需要在下一帧的时候重置为0,然后从队列中取下一个状态进行处理。
/// <summary>
/// 每帧调用
/// </summary>
public void LateUpdate()
{
if (m_animator == null)
{
return;
}
if (!m_animator.isInitialized || m_animator.IsInTransition(0))
{
return;
}
if (null == mClips)
mClips = m_animator.GetCurrentAnimatorClipInfo(0);
if (null == mClips || mClips.Length == 0)
return;
int actionID = m_animator.GetInteger(STR_ACTION);
if (actionID > 0)
{
//将Action复位
m_animator.SetInteger(STR_ACTION, 0);
}
//将剩余队列的动作重新拿出来播放
PlayRemainAction();
}
/// <summary>
/// 将剩余队列的动作重新拿出来播放
/// </summary>
void PlayRemainAction()
{
if (m_animQueue.Count > 0)
{
PlayAnimation(m_animQueue.Dequeue());
}
}
13.7 入口场景脚本
入口场景脚本EntryScene.cs
挂在Canvas
上,设置Start Game Btn
和Tgl Group
。
代码如下
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
public class EntryScene : MonoBehavIoUr
{
public Button startGameBtn;
public ToggleGroup tglGroup;
void Start()
{
startGameBtn.onClick.AddListener(() =>
{
// 根据勾选,缓存难度等级
foreach (var item in tglGroup.Activetoggles())
{
GameMgr.Instance.hardLevel = int.Parse(item.name);
break;
}
// 进入Game场景
SceneManager.LoadScene(1);
});
}
}
13.8 游戏场景脚本
游戏场景脚本GameScene.cs
挂在Canvas
上,设置公开的成员对象。
主要根据各种事件更新
ui
。
using System;
using UnityEngine;
using UnityEngine.UI;
public class GameScene : MonoBehavIoUr
{
public Animator anitor;
public Text comboText;
public Text scoreText;
public Slider bloodSlider;
public Image bloodImage;
public GameOverDlg gameOverDlg;
public KeyGrid keyGrid;
private CharacteraniCtrler m_aniCtrler;
private void Awake()
{
// 注册事件
Eventdispatcher.Instance.Regist(EventNameDef.EVENT_KEY_BINGO_INDEX, OnEventKeyBingoIndex);
Eventdispatcher.Instance.Regist(EventNameDef.EVENT_COMBO, OnEventCombo);
Eventdispatcher.Instance.Regist(EventNameDef.EVENT_PLAY_ANI, OnEventPlayAni);
Eventdispatcher.Instance.Regist(EventNameDef.EVENT_UPDATE_score, OnEventUpdatescore);
Eventdispatcher.Instance.Regist(EventNameDef.EVENT_RESTART_GAME, OnEventRestartGame);
Eventdispatcher.Instance.Regist(EventNameDef.EVENT_GAMEOVER, OnEventGameOver);
m_aniCtrler = new CharacteraniCtrler();
m_aniCtrler.Init(anitor);
// 开始游戏
StartGame();
}
private void OnDestroy()
{
// 注销事件
Eventdispatcher.Instance.UnRegist(EventNameDef.EVENT_KEY_BINGO_INDEX, OnEventKeyBingoIndex);
Eventdispatcher.Instance.UnRegist(EventNameDef.EVENT_COMBO, OnEventCombo);
Eventdispatcher.Instance.UnRegist(EventNameDef.EVENT_PLAY_ANI, OnEventPlayAni);
Eventdispatcher.Instance.UnRegist(EventNameDef.EVENT_UPDATE_score, OnEventUpdatescore);
Eventdispatcher.Instance.UnRegist(EventNameDef.EVENT_RESTART_GAME, OnEventRestartGame);
Eventdispatcher.Instance.UnRegist(EventNameDef.EVENT_GAMEOVER, OnEventGameOver);
}
/// <summary>
/// 开始游戏
/// </summary>
private void StartGame()
{
GameMgr.Instance.Init();
TextEffect.Init();
// 初始化血量
bloodSlider.maxValue = GameMgr.Instance.blood;
bloodSlider.value = GameMgr.Instance.blood;
bloodImage.enabled = true;
// 生成字母盘
keyGrid.CreateKeyList(GameMgr.Instance.keyList);
comboText.gameObject.SetActive(false);
scoreText.text = "0";
gameOverDlg.Hide();
}
void Update()
{
if (GameMgr.Instance.gameOver) return;
// 更新连击定时器
GameMgr.Instance.UpdateComboTimer();
// 更新血量ui
bloodSlider.value = GameMgr.Instance.blood;
GameMgr.Instance.blood -= GameMgr.Instance.hardLevel;
// 按键判断
var keyCode = GameMgr.Instance.GetKeyDownCode();
if (KeyCode.None == keyCode) return;
GameMgr.Instance.OnKey(keyCode);
}
private void LateUpdate()
{
// 更新动画控制器
m_aniCtrler.LateUpdate();
}
/// <summary>
/// 按键正确事件
/// </summary>
/// <param name="args"></param>
private void OnEventKeyBingoIndex(params object[] args)
{
int index = (int)args[0];
KeyCode oldKey = (KeyCode)args[1];
KeyCode newKey = (KeyCode)args[2];
keyGrid.UpdateKeyByIndex(index, oldKey, newKey);
}
/// <summary>
/// 连击事件
/// </summary>
/// <param name="args"></param>
private void OnEventCombo(params object[] args)
{
var combo = (int)args[0];
comboText.text = "连击" + combo;
comboText.gameObject.SetActive(combo >= 3);
}
/// <summary>
/// 播放动画事件
/// </summary>
/// <param name="args"></param>
private void OnEventPlayAni(params object[] args)
{
var ani = (string)args[0];
switch (ani)
{
case "idle": m_aniCtrler.PlayAnimation((int)CharacteraniId.Idle); break;
case "walk": GameMgr.Instance.comboTimer = 0.5f; m_aniCtrler.PlayWalk(); break;
case "run": GameMgr.Instance.comboTimer = 0.5f; m_aniCtrler.PlayRun(); break;
case "hit": m_aniCtrler.PlayAnimation((int)CharacteraniId.Hit); break;
case "die": m_aniCtrler.PlayDieImmediately(); break;
}
}
/// <summary>
/// 更新得分事件
/// </summary>
/// <param name="args"></param>
private void OnEventUpdatescore(params object[] args)
{
var score = (int)args[0];
scoreText.text = score.ToString();
}
/// <summary>
/// 游戏结束事件
/// </summary>
/// <param name="args"></param>
private void OnEventGameOver(params object[] args)
{
bloodImage.enabled = false;
gameOverDlg.Show(GameMgr.Instance.score);
}
/// <summary>
/// 重新开始游戏事件
/// </summary>
/// <param name="args"></param>
private void OnEventRestartGame(params object[] args)
{
m_aniCtrler.PlayReviveImmediately();
StartGame();
}
}
其中事件定义如下
/// <summary>
/// 事件定义
/// </summary>
public class EventNameDef
{
/// <summary>
/// 按键正确事件
/// </summary>
public const string EVENT_KEY_BINGO_INDEX = "EVENT_KEY_BINGO_INDEX";
/// <summary>
/// 连击事件
/// </summary>
public const string EVENT_COMBO = "EVENT_COMBO";
/// <summary>
/// 播放动画事件
/// </summary>
public const string EVENT_PLAY_ANI = "EVENT_PLAY_ANI";
/// <summary>
/// 游戏结束事件
/// </summary>
public const string EVENT_GAMEOVER = "EVENT_GAMEOVER";
/// <summary>
/// 更新得分事件
/// </summary>
public const string EVENT_UPDATE_score = "EVENT_UPDATE_score";
/// <summary>
/// 重新开始游戏事件
/// </summary>
public const string EVENT_RESTART_GAME = "EVENT_RESTART_GAME";
}
13.9 文字特效动画
制作一个
TextEffect.prefab
预设,添加动画如下。由于游戏中需要重复显示这个特效,所以采用对象池方式。
特效动画结束时回收到对象池中,这样可以反复利用。为了监听动画结束,在动画的最后一帧添加帧事件。
创建一个
TextEffect.cs
脚本,挂到预设上,提供一个OnAnimationEnd
共有方法
public void OnAnimationEnd()
这样就可以设置帧事件的响应函数了
TextEffect.cs
脚本如下
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
/// <summary>
/// 文本特效
/// </summary>
public class TextEffect : MonoBehavIoUr
{
/// <summary>
/// 初始化
/// </summary>
public static void Init()
{
if(null != s_root)
{
Destroy(s_root.gameObject);
s_root = null;
}
s_objPool.Clear();
var canvas = GameObject.Find("Canvas");
if (null != canvas)
{
var rootObj = new GameObject("EffectRoot");
s_root = rootObj.transform;
s_root.SetParent(canvas.transform, false);
}
}
/// <summary>
/// 显示特效
/// </summary>
/// <param name="text"></param>
/// <param name="pos"></param>
public static void Show(string text, Vector3 pos)
{
if (null == s_prefab)
{
s_prefab = Resources.Load<GameObject>("TextEffect");
}
TextEffect bhv = null;
if (s_objPool.Count > 0)
{
// 从对象池中取对象,
bhv = s_objPool.Dequeue();
}
else
{
var obj = Instantiate(s_prefab);
obj.transform.SetParent(s_root, false);
bhv = obj.GetComponent<TextEffect>();
}
bhv.gameObject.SetActive(true);
bhv.transform.position = pos;
bhv.keyText.text = text;
}
/// <summary>
/// 动画结束事件的响应函数
/// </summary>
public void OnAnimationEnd()
{
gameObject.SetActive(false);
// 对象回收
s_objPool.Enqueue(this);
}
private static GameObject s_prefab;
/// <summary>
/// 对象池
/// </summary>
private static Queue<TextEffect> s_objPool = new Queue<TextEffect>();
/// <summary>
/// 根节点
/// </summary>
private static Transform s_root;
/// <summary>
/// 文字组件
/// </summary>
public Text keyText;
}
完成。
如果有什么疑问,欢迎留言或私信。
《学Unity的猫》——第十四章:Unity实现文件上传下载,支持续传,猫后爪的秘密
版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 [email protected] 举报,一经查实,本站将立刻删除。