简介:我是一名Unity
游戏开发工程师,皮皮是我养的猫,会讲人话,它接到了喵星的特殊任务:学习编程,学习Unity
游戏开发。
于是,发生了一系列有趣的故事。
皮皮:“铲屎官,你为什么打字速度这么快?”
我:“一个字,练。”
皮皮:“教教我,怎么连打字速度。”
我:“来,我给你做一个打字练习游戏吧。”
我:“当你可以一分钟连击180次的时候,你就可以出山了。”
皮皮:“作为猫族,不能被速度打败。”
游戏画面如下:
模块设计如下
本工程使用的Unity
版本为2020.1.14f1c1 (64-bit)
,工程已上传到GitHub
,感兴趣的同学可以下载下来学习。
GitHub
地址:https://github.com/linxinfa/Unity-TypeWriting-Game
EntryScene.unity
一个标题文本(Text
组件),一个开始按钮(Button
组件),一个难度选择勾选(ToggleGroup
和Toggle
组件)。
背景图使用SpriteRender
组件在3D
摄像机中渲染。
难度等级的勾选使用了ToggleGroup
组件,用来给Toggle
分组。
子节点中的Toggle
需要指明相同的ToggleGroup
。
实现单选的效果。
GameScene.unity
一个血条(Slider
组件),一个得分(Text
组件),一个字母盘(GridLayoutGroup
、Text
组件)、一个连击(Text
组件),一个角色(SpriteRenderer
、Animator
组件)、一个背景图(SpriteRenderer
组件)。
其中字母盘只做一个字母,游戏中进行动态克隆。
再做游戏结束面板,提供一个返回和重来的按钮。
点击菜单File - Build Settings...
。
将场景添加到Scenes In Build
中。
代码中,通过SceneManager.LoadScene
切换场景,如下
// 进入GameScene场景
UnityEngine.SceneManagement.SceneManager.LoadScene(1);
///
/// 难度等级
///
public int hardLevel { get; set; }
///
/// 得分
///
public int score { get; set; }
///
/// 最大血量
///
private const int MAX_BLOOD = 1500;
///
/// 血量
///
public int blood
{
get { return m_blood; }
set
{
m_blood = value;
if (m_blood <= 0)
{
gameOver = true;
// TODO 抛出事件
}
}
}
private int m_blood = 0;
///
/// 连击数量
///
public int comboCnt { get; set; }
///
/// 连击定时器
///
public float comboTimer { get; set; }
///
/// 游戏结束
///
public bool gameOver { get; private set; }
///
/// 按键列表
///
public List<KeyCode> keyList { get { return m_keyList; } }
private List<KeyCode> m_keyList = new List<KeyCode>();
生成字母盘(16个字母),要求每个字母都不重复,生成的字母存到m_keyList
中。
///
/// 生成字母盘
///
private void GenKeys()
{
for (int i = 0; i < 16; ++i)
{
m_keyList.Add(GenOneKey());
}
}
///
/// 生成一个字母
///
///
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;
}
我们需要先判断按键类型,封装一个接口GetKeyDownCode
。
///
/// 获取按键类型
///
///
public KeyCode GetKeyDownCode()
{
if (Input.anyKeyDown)
{
foreach (KeyCode keyCode in Enum.GetValues(typeof(KeyCode)))
{
if (Input.GetKeyDown(keyCode))
{
return keyCode;
}
}
}
return KeyCode.None;
}
然后判断按下的按键是否在字母盘中,返回对应的索引,如果不在字母盘中,则返回-1。
///
/// 判断按键是否在字母盘中
///
/// 按键
///
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
。
///
/// 按键正确
///
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
。
///
/// 按键错误
///
private void OnKeyError()
{
// 连击中断
comboCnt = 0;
// 扣血
blood -= 30;
// TODO 抛事件,更新ui
}
如下,其中Time.deltaTime
是一帧的间隔时间。每帧调用UpdateComboTimer
,对comboTimer
进行帧间隔时间递减,通过comboTimer
判断是否超过时间限制,超过则中断连击。
///
/// 连击定时器
///
public void UpdateComboTimer()
{
if (comboTimer > 0)
{
comboTimer -= Time.deltaTime;
// 超过时间限制,连击断开
if (comboTimer <= 0)
{
comboCnt = 0;
// TODO 抛事件更新连击ui
}
}
}
Unity
可以用两种方式控制动画
1 Animation
,这种方式简单,直接 Play(“Idle”)
或者CorssFade(“Idle”)
就可以播放动画;
2 Animator
,Unity5.x
之后推荐使用这种方式,因为里面可以加上混合动画,让动画切换更加平滑。
点击菜单Window - Animation - Animation
,可以打开Animation
窗口,快捷键是Ctrl+6
。
选中某个物体后,可以为该物体添加或编辑动画,比如选中一个空物体,由于没有动画,会出现一个Create
按钮。
点击Create
按钮,会弹出窗口设置文件保存路劲。
创建成功后,物体上会出现一个Animator
组件。
并且我们可以在目录中看到生成了两个文件。
.controller
文件是一个动画状态机,在Unity
中双击它会打开Animator
窗口,即可看到里面的内容,我们可以在这个窗口中组织各个动画文件。
.anim
是动画文件,在Unity
中双击它会打开Animation
窗口,我们可以在这个窗口中制作动画。
每个Animator Controller
都会自带三个状态:Any State
, Entry
和 Exit
。
表示任意状态的特殊状态。例如我们如果希望角色在任何状态下都有可能切换到死亡状态,那么Any State
就可以帮我们做到。当你发现某个状态可以从任何状态以相同的条件跳转到时,那么你就可以用Any State
来简化过渡关系。
表示状态机的入口状态。当我们为某个GameObject
添加上Animator
组件时,这个组件就会开始发挥它的作用。
如果Animator Controller
控制多个Animation
的播放,那么默认情况下Animator
组件会播放哪个动画呢? 由Entry
来决定的。
但是Entry
本身并不包含动画,而是指向某个带有动画的状态,并设置其为默认状态。被设置为默认状态的状态会显示为 橘黄色。
当然,你可以随时在任意一个状态上通过 鼠标右键->Set as Layer Default State
更改默认状态。
记住, Entry
在Animator
组件被激活后 无条件 跳转到默认状态,并且每个Layer
有且仅有一个默认状态。
表示状态机的出口状态,以红色标识。如果你的动画控制器只有一层,那么这个状态可能并没有什么卵用。但是当你需要从子状态机中返回到上一层(Layer
)时,把状态指向Exit
就可以了。
我们可以选中某个自定义状态,并在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 | 用于向状态添加“行为 |
状态间的过渡关系,直观上说它们就是连接不同状态的有向箭头。
要创建一个从状态A
到状态B
的过渡,直接在状态A
上 鼠标右键 - Make Transition
并把出现的箭头拖拽到状态B
上点击鼠标左边即可。
参数有Float
,Int
,Bool
,Trigger
。
Float
、Int
用来控制一个动画状态的参数,比如速度方向等可以用数值量化的东西,
Bool
用来控制动画状态的转变,比如从走路转变到跑步,
Trigger
本质上也是bool
类型,但它默认为false
,且当程序设置为true
后,它会自动变回false
。
点击连线,在Inspecter
窗口中可以进行设置,在Conditions
栏下可以添加条件,如下图表示当参数
AnimState
为0
时会执行这个动画Any State
到New Animation2
的过渡
必须在Parameters面板中添加了参数才可以在这里查看到,其次添加的条件为&& ”与” 关系,即必须同时满足。
从Assets Store
上下载猫娘模型,资源地址:https://assetstore.unity.com/packages/2d/characters/fancydoll-c000-little-cat-girl-112776
模型自带了一些动画
idle
(站立)、walk
(走路)、run
(跑)需要循环播放,勾选Loop Time
。
get_hit
(受击)、die
(阵亡)不需要循环播放,不勾选Loop Time
。
我们需要通过Animator
将这些动画进行合理的组织,如下
添加变量Action
,过渡条件根据Action
的值进行判断。
过渡条件如下
状态1 | 状态2 | 条件 |
---|---|---|
Any State | idle | Action == 1 |
Any State | get_hit | Action == 4 |
get_hit | idle | 无 |
Any State | die | Action == 5 |
角色动画控制器CharacterAniCtrler
,它需要包括数据和逻辑,如下。
运行中的状态过渡
///
/// 状态定义,默认为Idle状态。
///
public enum CharacterAniId
{
Idle = 1,
Walk = 2,
Run = 3,
Hit = 4,
Death = 5,
}
///
/// 角色动画Animator组件
///
private Animator m_animator;
///
/// 动画队列
///
private Queue<int> m_animQueue = new Queue<int>();
///
/// 立即播放某个动画
///
/// 动画名称
private void PlayAniImmediately(string name)
{
if (IsDeath) return;
m_animator.CrossFade(name, 0.1f, 0);
}
如立即播放走路动画
public void PlayWalk()
{
PlayAniImmediately("walk");
}
// 设置Action变量值为4
m_animator.SetInteger("Action", 4);
封装成接口
private const string STR_ACTION = "Action";
///
/// 播放不同动作ID
///
///
///
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);
}
提供一个LateUpdate
接口每帧调用,设置了Action
值需要在下一帧的时候重置为0,然后从队列中取下一个状态进行处理。
///
/// 每帧调用
///
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();
}
///
/// 将剩余队列的动作重新拿出来播放
///
void PlayRemainAction()
{
if (m_animQueue.Count > 0)
{
PlayAnimation(m_animQueue.Dequeue());
}
}
入口场景脚本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);
});
}
}
游戏场景脚本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);
}
///
/// 开始游戏
///
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();
}
///
/// 按键正确事件
///
///
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);
}
///
/// 连击事件
///
///
private void OnEventCombo(params object[] args)
{
var combo = (int)args[0];
comboText.text = "连击" + combo;
comboText.gameObject.SetActive(combo >= 3);
}
///
/// 播放动画事件
///
///
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;
}
}
///
/// 更新得分事件
///
///
private void OnEventUpdateScore(params object[] args)
{
var score = (int)args[0];
scoreText.text = score.ToString();
}
///
/// 游戏结束事件
///
///
private void OnEventGameOver(params object[] args)
{
bloodImage.enabled = false;
gameOverDlg.Show(GameMgr.Instance.score);
}
///
/// 重新开始游戏事件
///
///
private void OnEventRestartGame(params object[] args)
{
m_aniCtrler.PlayReviveImmediately();
StartGame();
}
}
其中事件定义如下
///
/// 事件定义
///
public class EventNameDef
{
///
/// 按键正确事件
///
public const string EVENT_KEY_BINGO_INDEX = "EVENT_KEY_BINGO_INDEX";
///
/// 连击事件
///
public const string EVENT_COMBO = "EVENT_COMBO";
///
/// 播放动画事件
///
public const string EVENT_PLAY_ANI = "EVENT_PLAY_ANI";
///
/// 游戏结束事件
///
public const string EVENT_GAMEOVER = "EVENT_GAMEOVER";
///
/// 更新得分事件
///
public const string EVENT_UPDATE_SCORE = "EVENT_UPDATE_SCORE";
///
/// 重新开始游戏事件
///
public const string EVENT_RESTART_GAME = "EVENT_RESTART_GAME";
}
制作一个TextEffect.prefab
预设,添加动画如下。
由于游戏中需要重复显示这个特效,所以采用对象池方式。
特效动画结束时回收到对象池中,这样可以反复利用。为了监听动画结束,在动画的最后一帧添加帧事件。
创建一个TextEffect.cs
脚本,挂到预设上,提供一个OnAnimationEnd
共有方法
public void OnAnimationEnd()
这样就可以设置帧事件的响应函数了
TextEffect.cs
脚本如下
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
///
/// 文本特效
///
public class TextEffect : MonoBehaviour
{
///
/// 初始化
///
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);
}
}
///
/// 显示特效
///
///
///
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;
}
///
/// 动画结束事件的响应函数
///
public void OnAnimationEnd()
{
gameObject.SetActive(false);
// 对象回收
s_objPool.Enqueue(this);
}
private static GameObject s_prefab;
///
/// 对象池
///
private static Queue<TextEffect> s_objPool = new Queue<TextEffect>();
///
/// 根节点
///
private static Transform s_root;
///
/// 文字组件
///
public Text keyText;
}
完成。
如果有什么疑问,欢迎留言或私信。
《学Unity的猫》——第十四章:Unity实现文件上传下载,支持续传,猫后爪的秘密