人物动画
在Unity中为人物添加动画状态机
在代码中实现每回合开始和结束时执行的动画在打出不同类型牌时人物进行的动画切换
public class PlayerAnimation : MonoBehaviour
{
private Player player;
private Animator animator;
private void Awake()
{
player = GetComponent<Player>();
animator = GetComponentInChildren<Animator>();
}
private void OnEnable()
{
animator.Play("sleep");
animator.SetBool("isSleep",true);
}
public void PlayerTurnBeginAnimation()
{
animator.SetBool("isSleep",false);
animator.SetBool("isParry",false);
}
public void PlayerTurnEndAnimation()
{
if (player.defense.currentValue > 0)
{
animator.SetBool("isParry",true);
}
else
{
animator.SetBool("isSleep",true);
}
}
public void OnPlayCardEvent(object obj)
{
Card card=obj as Card;
switch (card.cardData.cardType)
{
case CardType.Attack:
animator.SetTrigger("attack");
break;
case CardType.Defense:
break;
case CardType.Abilities:
animator.SetTrigger("skill");
break;
}
}
}
敌人意图AI逻辑
创建EnemyActionDataSO存储敌人的所有行为
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName = "EnemyActionDataSO", menuName = "Enemy/EnemyActionDataSO")]
public class EnemyActionDataSO : ScriptableObject
{
public List<EnemyAction> actions;
}
[System.Serializable]
public struct EnemyAction
{
public Sprite intentSprite;
public Effect effect;
}
在Enemy中添加OnPlayerTurnBegin()方法,实现在玩家回合开始时随机生成敌人回合将要执行的意图
OnEnemyTurnBegin()方法实现在敌人回合开始时根据意图的不同类型实现不同的效果
public class Enemy : CharacterBase
{
public EnemyActionDataSO actionDataSo;
public EnemyAction currentAction;
protected Player player;
protected override void Awake()
{
base.Awake();
player = GameObject.FindGameObjectWithTag("Player").GetComponent<Player>();
}
public virtual void OnPlayerTurnBegin()//在意图列表中随机取一个意图
{
var randomIndex = Random.Range(0, actionDataSo.actions.Count);
currentAction = actionDataSo.actions[randomIndex];
}
public virtual void OnEnemyTurnBegin()
{
switch (currentAction.effect.targetType)
{
case EffectTargetType.Self:
Skill();
break;
case EffectTargetType.All:
break;
case EffectTargetType.Target:
Attack();
break;
}
}
public virtual void Skill()
{
currentAction.effect.Execute(this,this);
}
public virtual void Attack()
{
currentAction.effect.Execute(this,player);
}
}
在HealthBarController中添加SetIntentElement()方法实现玩家回合开始时在血条上方显示敌人意图的UI,并在HideIntentElement()方法实现回合结束隐藏敌人意图UI
public void SetIntentElement()
{
intentSprite.style.display = DisplayStyle.Flex;
intentSprite.style.backgroundImage = new StyleBackground(enemy.currentAction.intentSprite);
// Check if the current action is an attack and handle debuff calculation
var value = enemy.currentAction.effect.value;
if (enemy.currentAction.effect.GetType() == typeof(DemageEffect)) // Typo: DemageEffect should be DamageEffect
{
value = (int)math.round(enemy.currentAction.effect.value * enemy.baseStrength);
}
intentAmount.text = value.ToString();
}
public void HideIntentElement()
{
intentSprite.style.display = DisplayStyle.None;
}
敌人的动画执行逻辑
因为敌人进行攻击,技能等操作时动画播放的比效果慢一步,所以要使用协程控制相对时间
保证在动画播放到百分之六十,且不在过渡动画中,且当前执行的动画为我传入参数的该动画时才执行相应的效果
IEnumerator ProcessDelayAction(string actionName)
{//保证在动画播放到百分之六十,且不在过渡动画中,且当前执行的动画为我传入参数的该动画时才执行相应的效果
animator.SetTrigger(actionName);
yield return new WaitUntil((() =>
animator.GetCurrentAnimatorStateInfo(0).normalizedTime % 1.0f > 0.6f
&& !animator.IsInTransition(0)
&&animator.GetCurrentAnimatorStateInfo(0).IsName(actionName)));
if (actionName == "attack")
{
currentAction.effect.Execute(this,player);
}
else
{
currentAction.effect.Execute(this,this);
}
}
对战胜负逻辑
Game Manger中创建游戏胜利和失败的事件并在实现OnCharacterDeadEvent方法在创建人物和怪物死亡时分别调用事件
public void OnCharacterDeadEvent(object character)
{
if (character is Player)
{
//结束游戏
StartCoroutine(EventDelayAction(gameOverEvent));
}
if (character is Enemy)
{
aliveEnemyList.Remove((character as Enemy));
if (aliveEnemyList.Count == 0)
{
//发送获胜通知
StartCoroutine(EventDelayAction(gameWinEvent));
}
}
}
实现在游戏胜利或失败后清空手牌的方法
public void ReleaseAllCards(object obj)
{
foreach (var card in handCardObjectList)
{
cardManger.DiscardCard(card.gameObject);
}
handCardObjectList.Clear();
InitializeDeck();
}
协程方法实现在游戏结束后延迟调用清空卡牌事件
IEnumerator EventDelayAction(ObjectEventSO eventSo)
{
//延迟手牌销毁的方法
yield return new WaitForSeconds(1.5f);
eventSo.RaisEvent(null,this);
}
创建UIManger脚本,实现游戏不同状态下的GamePanel管理
根据不同的房间实现不同的游戏UI面板,关闭所有游戏面板,游戏失败时开启对应面板,游戏胜利时开启对应面板
public class UIManger : MonoBehaviour
{
[Header("面板")]
public GameObject gameplayPanel;
public GameObject gameOverPanel;
public GameObject gameWinPanel;
public void OnLoadRoomEvent(object data)
{
Room currentRoom = (Room)data;
switch (currentRoom.roomDataSo.roomType)
{
case RoomType.MinorEnemy:
gameOverPanel.SetActive(true);
break;
case RoomType.EliteEnemy:
gameOverPanel.SetActive(true);
break;
case RoomType.Boss:
gameOverPanel.SetActive(true);
break;
case RoomType.RestRoom:
break;
case RoomType.Shop:
break;
case RoomType.Treasure:
break;
}
}
/// <summary>
/// 加载地图事件
/// </summary>
public void HideAllPanels()
{
gameplayPanel.SetActive(false);
gameOverPanel.SetActive(false);
gameWinPanel.SetActive(false);
}
public void OnGameOverEvent()
{
gameplayPanel.SetActive(false);
gameOverPanel.SetActive(true);
}
public void OnGameWinEvent()
{
gameplayPanel.SetActive(false);
gameWinPanel.SetActive(true);
}
}
在TurnBaseManger方法中实现进入不同类型的房间时生成玩家的方法
public void OnRoomLoadedEvent(object obj)
{
Room room = obj as Room;
switch (room.roomDataSo.roomType)
{
case RoomType.MinorEnemy:
case RoomType.EliteEnemy:
case RoomType.Boss:
playerObj.SetActive(true);
GameStart();
break;
case RoomType.RestRoom:
playerObj.SetActive(true);
break;
case RoomType.Shop:
case RoomType.Treasure:
playerObj.SetActive(false);
break;
}
}
OnRoomLoadedEvent函数是由Scene Load Manger监听AfterRoomLoadedEvent事件从而启动的,而该事件传入的数据为currentRoomVector,从该数据中无法获取到所需要的房间数据,因此要更改传入的数据类型为Room
//存储连线房间数据
afterRoomLoadEvent.RaisEvent(currentRoom,this);
该事件原本的作用是房间进入后的事件,传递Vector更新房间状态和与其连线的其他房间的状态,并在GameManger中进行监听。所以要创建一个新事件执行被改变的原事件的方法
在LoadMap中添加更改房间状态事件执行的方法
public async void LoadMap()
{
await UnLoadSceneTask();
if (currentRoomVector != Vector2Int.one * -1)
{
updateRoomEvent.RaisEvent(currentRoomVector,this);
}
currentScene = map;
await LoadSceneTask();
}
为保证if语句判断条件的正确,还需要在start方法中初始化vector值
private void Start()
{
currentRoomVector = Vector2Int.one * -1;
}
加载到对应房间场景时就要在敌人列表中获取到所有敌人,返回到地图场景时清空敌人列表
public void OnRoomLoadedEvent(object obj)
{
var enemies = FindObjectsByType<Enemy>(FindObjectsInactive.Include, FindObjectsSortMode.None);
foreach (var enemy in enemies)
{
aliveEnemyList.Add(enemy);
}
}
制作胜利和抽卡面板
运用UITooolkit创建胜利面板,并使用脚本控制按钮逻辑
点击返回地图和抽卡按钮时会启动相对应的事件
public class GameWinPanel : MonoBehaviour
{
private VisualElement rootElement;
private Button pickCardButton;
private Button backToMapButton;
[Header("事件广播")]
public ObjectEventSO loadMapEvent;
public ObjectEventSO pickCardEvent;
private void Awake()
{
rootElement = GetComponent<UIDocument>().rootVisualElement;
pickCardButton = rootElement.Q<Button>("PickCardButton");
backToMapButton = rootElement.Q<Button>("BackToMapButton");
backToMapButton.clicked += OnBackToMapButtonClicked;
pickCardButton.clicked+= OnPickCardButtonClicked;
}
private void OnPickCardButtonClicked()
{
pickCardEvent.RaisEvent(null,this);
}
private void OnBackToMapButtonClicked()
{
loadMapEvent.RaisEvent(null,this);
}
}
UI Manger会监听抽卡事件,并在抽卡事件执行时启用对应的抽卡面板
抽卡面板的实际逻辑
如何在UITooolkit中添加额外的temlete进去
UITooolkit中的新方法可以绑定卡片数据,将数据中的某一项直接传入
Card Manger中获取卡牌数据(GetNewCardData)和往卡牌库中添加新卡牌的方法(UnlockCard)
public CardDataSO GetNewCardData()
{//随机获取卡牌数据,保证相邻卡牌数据不同
var randomIndex = 0;
do
{
randomIndex =UnityEngine.Random.Range(0, cardDataList.Count);
} while (previousIndex == randomIndex);
previousIndex = randomIndex;
return cardDataList[randomIndex];
}
/// <summary>
/// 解锁添加新卡牌
/// </summary>
/// <param name="newCardData"></param>
public void UnlockCard(CardDataSO newCardData)
{
var newCard = new CardLibraryEntry
{
cardData = newCardData,
count = 1,
};
if (currentCardLibrary.cardLibraryList.Contains(newCard))
{
var target = currentCardLibrary.cardLibraryList.Find(t => t.cardData == newCardData);
target.count++;
}
else
{
currentCardLibrary.cardLibraryList.Add(newCard);
}
}
在PickCardPanel中实现在代码启用时从所有卡牌类型中抽取三张新卡牌的卡牌数据(GetNewCardData),将卡牌初始化并添加到列表中供玩家选择
点按卡牌会启用OnCardClicked函数,禁用当前选中的卡牌并启用其他卡牌,实现选择卡牌的方法
确认按钮会调用Onconfirme中的方法使CardManger中的UnlockCard将当前选中的卡牌传入卡牌库中,让玩家可在之后的回合中抽取到该卡牌,并通过事件调用函数实现UI面板的关闭
public class PickCardPanel : MonoBehaviour
{
public CardManger cardManger;
private VisualElement rootElement;
public VisualTreeAsset cardTemplate;
private VisualElement cardContainer;
private CardDataSO currentCardData;
private List<Button> cardButtons = new List<Button>();
private Button confirmeButton;
[Header("广播")]
public ObjectEventSO finishCardEvent;
private void OnEnable()
{
rootElement = GetComponent<UIDocument>().rootVisualElement;
cardContainer=rootElement.Q<VisualElement>("Container");
confirmeButton=rootElement.Q<Button>("ConfirmButton");
for (int i = 0; i < 3; i++)
{
var card = cardTemplate.Instantiate();
var data = cardManger.GetNewCardData();
//初始化
Init(card,data);
card.style.height = 310;
//button点按的事件
var cardButton = card.Q<Button>("Card");
cardContainer.Add(card);
cardButtons.Add(cardButton);
cardButton.clicked += () => OnCardClicked(cardButton,data);
confirmeButton.clicked += Onconfirme;
}
}
private void Onconfirme()
{
cardManger.UnlockCard(currentCardData);
finishCardEvent.RaisEvent(null,this);
}
private void OnCardClicked(Button cardButton,CardDataSO data)
{
currentCardData = data;
Debug.Log(currentCardData.cardName);
for (int i = 0; i < cardButtons.Count; i++)
{
if (cardButtons[i] == cardButton)
{
cardButton[i].SetEnabled(false);
}
else
{
cardButton[i].SetEnabled(true);
}
}
}
//使用传入的数据初始化卡牌
public void Init(VisualElement card,CardDataSO cardData)
{
card.dataSource = cardData;
var cardSpriteElement = card.Q<VisualElement>("CardSprite");
var cardCost = card.Q<Label>("EnergyCost");
var cardDescription = card.Q<Label>("CardDescription");
var cardName = card.Q<Label>("CardName");
var cardType = card.Q<Label>("CardType");
cardSpriteElement.style.backgroundImage = new StyleBackground(cardData.cardImage);
cardCost.text = cardData.cost.ToString();
cardName.text = cardData.cardName;
cardType.text = cardData.description;
cardType.text = cardData.cardType switch
{
CardType.Attack =>"Attack",
CardType.Defense => "Skill",
CardType.Abilities => "Abilities",
_ => throw new ArgumentOutOfRangeException()
};
}
}
GameOver及Menu面板
创建game Over面板和相应的代码逻辑,实现点击按钮返回主菜单的逻辑
public class GameOverPanel : MonoBehaviour
{
private Button backToStartButton;
public ObjectEventSO loadMenuEvent;
private void OnEnable()
{
GetComponent<UIDocument>().rootVisualElement.Q<Button>("BackToStartButton").clicked += BackToStart;
}
private void BackToStart()
{
loadMenuEvent.RaisEvent(null,this);
}
}
创建菜单面板和相应的代码逻辑,实现点击按钮开始新游戏和退出游戏的操作
public class MenuPanel : MonoBehaviour
{
private VisualElement rootElemrnt;
private Button newGameButton, quitGameButton;
public ObjectEventSO newGameEvent;
private void OnEnable()
{
rootElemrnt = GetComponent<UIDocument>().rootVisualElement;
newGameButton = rootElemrnt.Q<Button>("NewGameButton");
quitGameButton = rootElemrnt.Q<Button>("QuitGameButton");
newGameButton.clicked += OnNewGameButtonClicked;
quitGameButton.clicked += OnQuitGameButtonClicked;
}
private void OnQuitGameButtonClicked() => Application.Quit();
private void OnNewGameButtonClicked()
{
newGameEvent.RaisEvent(null,this);
}
}
在SceneLoadManager中实现加载菜单的逻辑
public async void LoadMenu()
{
if (currentScene != null)
{
await UnLoadSceneTask();
}
currentScene = menu;
await LoadSceneTask();
}
在Game Manger中监听游戏的开始,实现在游戏开始时清空地图
public void OnNewGameEvent()
{
mapLayout.MapRoomDataList.Clear();
mapLayout.LineDataList.Clear();
}
使用事件系统完成以下场景转换时需要完成的逻辑
- 调整在新游戏开始时的UI面板逻辑
- 实现游戏结束时关闭人物
- 游戏开始时更新人物状态和血条
- 在游戏开始时清空地图
实现休息房间的逻辑
创建休息房间的UI面板并通过代码和事件系统完成按钮回血和返回地图的功能,同时进入场景时让player执行死亡动画
public class RestRoomPanel : MonoBehaviour
{
private VisualElement rootElement;
private Button restButton, backToMapButton;
public ObjectEventSO loadMapEvent;
private CharacterBase player;
public Effect restEffect;
private void OnEnable()
{
rootElement = GetComponent<UIDocument>().rootVisualElement;
restButton=rootElement.Q<Button>("RestButton");
backToMapButton = rootElement.Q<Button>("BackToMapButton");
player = FindAnyObjectByType<Player>(FindObjectsInactive.Include);//即使player没激活也调用
restButton.clicked += OnRestButtonClicked;
backToMapButton.clicked += OnBackToMapButtonClicked;
}
private void OnBackToMapButtonClicked()
{
loadMapEvent.RaisEvent(null,this);
}
private void OnRestButtonClicked()
{
restEffect.Execute(player,null);
restButton.SetEnabled(false);
}
}
在Treasure中给宝箱添加box碰撞体,在代码中实现点击宝箱打开GameWin页面获得奖励
public class TreasureButton : MonoBehaviour,IPointerDownHandler
{
public ObjectEventSO gameWinEvent;
public void OnPointerDown(PointerEventData eventData)
{
gameWinEvent.RaisEvent(null,this);
}
}
Boss制作和整体流程
按照敌人的设计流程设计Boss,添加Boss死后GameOver的代码
if (character is Boss)
{
StartCoroutine((EventDelayAction(gameOverEvent)));
}else if (character is Enemy)
{
aliveEnemyList.Remove((character as Enemy));
if (aliveEnemyList.Count == 0)
{
//发送获胜通知
StartCoroutine(EventDelayAction(gameWinEvent));
}
}
整体走流程的时候出来不少bug。。。。
实现场景淡入淡出
新建一个黑色的Panel,添加代码控制使panel可见的变量opacity
public class FadePanel : MonoBehaviour
{
private VisualElement backGround;
private void Awake()
{
backGround = GetComponent<UIDocument>().rootVisualElement.Q<VisualElement>("backGround");
}
public void FadeIn(float duration)
{
DOVirtual.Float(0, 1, duration, value =>
{
backGround.style.opacity = value;
}).SetEase(Ease.InQuad);
}
public void FadeOut(float duration)
{
DOVirtual.Float(1, 0, duration, value =>
{
backGround.style.opacity = value;
}).SetEase(Ease.InQuad);
}
}
在Scene Load Manger中添加代码加载开场动画(与加载Menu界面的方法相同)
public async void LoadInto()
{
if (currentScene != null)
{
await UnLoadSceneTask();
}
currentScene = into;
await LoadSceneTask();
}
遇到的bug
人物的stand动画是静止的但其他动画正常显示,且stand动画的looptime已经勾选
检查后发现是wake->shand之间的动画没有成功转换,原因是在没有转换条件的情况下未勾选has Exit time选项
条件(Conditions)
一个转换可以有一个条件、多个条件,或者根本没有条件。如果您的转换没有任何条件,那么Unity编辑器只考虑退出时间(Exit Time,英文中没说明一点,如果没有Condition的Transition的话,你想要Exit Time是触发,那HasExitTime一定要勾上,不然就没有效果了),当到达退出时间(Exit Time)时就会发生转换。如果转换具有一个或多个条件,则在触发转换之前必须满足所有条件。
血条没有在Awake中给enemy变量赋值从而导致了报空的问题
死亡动画在没有取消勾选can Transition To Self时会一直执行
二编,在transition的setting中,有点难找
gamewin的界面能正常出现但是只能在第一个房间中点击选择卡牌和返回地图 第二个房间之后的都只能弹出gamewin窗口 点击选择卡牌返回地图是没有反应的
按钮点击事件的注册放的生命周期函数不对,换成OnEnable就解决了
TODO:
(函数的生命周期;Awake,OnEnable,Start的区别)
所有怪物的血条每次进入房间的时候都没有更新
例如:在上一个房间将怪物消灭后怪物的血量变成0,进入下个房间时尽管是不同的怪物血条依旧为0
上一关中施加的buff在下一关中依旧存在
感觉时characterbase中赋值的问题或者是新游戏开始时没有重置数据
二编:Enemy在继承characterbase中的Start方法时没有写base.Start(),导致子类没有继承到父类中的初始化血量的方法