This commit is contained in:
SoulliesOfficial
2026-04-01 12:23:27 -04:00
parent aff7ac0e03
commit c3b1561375
933 changed files with 114333 additions and 119360 deletions

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 97fdf9e2a23cdfa40a8486c04fa81663

View File

@@ -5,6 +5,7 @@ using Continentis.MainGame.Card;
using Continentis.MainGame.Combat;
using Lean.Pool;
using NaughtyAttributes;
using SLSFramework.General;
using SLSFramework.UModAssistance;
using UnityEngine;
using Object = UnityEngine.Object;
@@ -135,7 +136,23 @@ namespace Continentis.MainGame.Character
public virtual void Die()
{
CombatMainManager.Instance.characterController.RemoveCharacter(this);
// TODO: 1.1c — 死亡动画命令(入队 Cmd_PlayAnimation + VFX待动画系统完善后替换
Debug.Log($"[Combat] {data.displayName} 死亡");
CharacterBase self = this;
// 触发 onDeath 事件,供 Buff / 技能系统在角色移除前响应
CommandQueueManager.Instance.AddCommand(Cmd.Do(() =>
{
self.eventSubmodule.onDeath.Invoke();
CombatMainManager.Instance.eventCollection.onCharacterDeath.Invoke(self);
}));
// 角色移除:从战场数据结构中清理,并触发胜负检查
CommandQueueManager.Instance.AddCommand(Cmd.Do(() =>
{
CombatMainManager.Instance.characterController.RemoveCharacter(self);
}));
}
}
}

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using Continentis.MainGame.Card;
using Continentis.MainGame.Combat;
using Continentis.MainGame.UI;
using SLSFramework.General;
using UnityEngine;
@@ -100,6 +101,9 @@ namespace Continentis.MainGame.Character
(attachedCharacter.characterView.hudContainer.enablingHUDs["CharacterBuffCollection"] as HUD_CharacterBuffCollection)
?.AddBuffIcon(this);
}
// 1.2c — 记录 Buff 首次施加日志
CombatLogs.Instance?.LogBuffApply(this, attachedCharacter);
}
else
{
@@ -114,6 +118,9 @@ namespace Continentis.MainGame.Character
public override void Remove()
{
// 1.2c — 记录 Buff 移除日志
CombatLogs.Instance?.LogBuffRemove(this);
OnBuffRemove();
if (iconSubmodule != null)
@@ -189,5 +196,15 @@ namespace Continentis.MainGame.Character
generalAttributeSubmodule?.RefreshAllModifiedAttributes();
}
}
/// <summary>
/// 将一条命令加入 Reaction Lane确保 Buff 触发的动画/视觉效果
/// 在当前 Main Lane 命令完成后、下一条 Main Lane 命令执行前播放。
/// Buff 中需要动画的被动响应(反击、护盾反伤等)应使用此方法入队。
/// </summary>
protected void EnqueueReaction(CommandBase cmd)
{
CommandQueueManager.Instance.AddCommand(cmd, CommandLane.Reaction);
}
}
}

View File

@@ -149,5 +149,8 @@
/// <summary> 来自魅力的增减益 </summary>
public const string OffsetFromCharisma = "OffsetFromCharisma";
/// <summary> 通用增减益 </summary>
public const string UniversalOffset = "UniversalOffset";
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d62ac580deaf21a428e43e567b08f790

View File

@@ -0,0 +1,77 @@
using SLSFramework.General;
namespace Continentis.MainGame.Character
{
/// <summary>
/// 角色生命周期事件的统一分发入口。
/// 每个 Dispatch 方法按固定顺序触发:角色 → 装备 → Buff → 卡牌,
/// 消除 CombatMainManager 中手动遍历各层级的冗余代码。
/// </summary>
public partial class CharacterBase
{
/// <summary>
/// 战斗开始时分发:角色 → 装备 → Buff → 卡牌。
/// </summary>
public void DispatchCombatStart()
{
eventSubmodule.onCombatStart.Invoke();
equipmentSubmodule.currentEquipments.ForEach(e => e.eventSubmodule.onCombatStart.Invoke());
combatBuffSubmodule.CombatStart();
deckSubmodule.GetAllCards().ForEach(c => c.eventSubmodule.onCombatStart.Invoke());
}
/// <summary>
/// 战斗结束时分发:角色 → 装备 → Buff。
/// </summary>
public void DispatchCombatEnd()
{
eventSubmodule.onCombatEnd.Invoke();
equipmentSubmodule.currentEquipments.ForEach(e => e.eventSubmodule.onCombatEnd.Invoke());
combatBuffSubmodule.CombatEnd();
}
/// <summary>
/// 回合开始时分发:角色 → 装备 → Buff含回合计数更新 → 卡牌。
/// </summary>
public void DispatchRoundStart()
{
eventSubmodule.onRoundStart.Invoke();
equipmentSubmodule.currentEquipments.ForEach(e => e.eventSubmodule.onRoundStart.Invoke());
combatBuffSubmodule.RoundStart();
deckSubmodule.GetAllCards().ForEach(c => c.eventSubmodule.onRoundStart.Invoke());
}
/// <summary>
/// 回合结束时分发:角色 → 装备 → Buff → 卡牌。
/// </summary>
public void DispatchRoundEnd()
{
eventSubmodule.onRoundEnd.Invoke();
equipmentSubmodule.currentEquipments.ForEach(e => e.eventSubmodule.onRoundEnd.Invoke());
combatBuffSubmodule.RoundEnd();
deckSubmodule.GetAllCards().ForEach(c => c.eventSubmodule.onRoundEnd.Invoke());
}
/// <summary>
/// 行动开始时分发:角色 → 装备 → Buff含行动计数更新 → 卡牌。
/// </summary>
public void DispatchActionStart()
{
eventSubmodule.onActionStart.Invoke();
equipmentSubmodule.currentEquipments.ForEach(e => e.eventSubmodule.onActionStart.Invoke());
combatBuffSubmodule.ActionStart();
deckSubmodule.GetAllCards().ForEach(c => c.eventSubmodule.onActionStart.Invoke());
}
/// <summary>
/// 行动结束时分发:角色 → 装备 → Buff → 卡牌。
/// </summary>
public void DispatchActionEnd()
{
eventSubmodule.onActionEnd.Invoke();
equipmentSubmodule.currentEquipments.ForEach(e => e.eventSubmodule.onActionEnd.Invoke());
combatBuffSubmodule.ActionEnd();
deckSubmodule.GetAllCards().ForEach(c => c.eventSubmodule.onActionEnd.Invoke());
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 84dd7cd1188860440897bf13fe67b5bb

View File

@@ -8,9 +8,18 @@ namespace Continentis.MainGame.Character
public partial class CharacterLogicBase
{
protected CharacterBase character;
/// <summary>在角色被创建后调用,用于注册 Intention 和订阅事件。</summary>
public virtual void Initialize(CharacterBase character)
{
this.character = character;
}
/// <summary>
/// 角色 HP 发生变化且百分比穿越整十档时由 <see cref="CharacterBase.HealthRemoval"/> 回调。
/// Boss Logic 可重写此方法实现阶段切换。
/// <paramref name="healthPercentage"/> 范围 [0, 1],例如 0.5 代表剩余 50% 血量。
/// </summary>
public virtual void OnHealthThreshold(float healthPercentage) { }
}
}

View File

@@ -79,22 +79,38 @@ namespace Continentis.MainGame.Character
public partial class CharacterBase
{
/// <summary>
/// 攻击目标
/// 攻击目标(新版本,使用 AttackContext 携带标签与来源信息)。
/// </summary>
/// <param name="target">目标</param>
/// <param name="target">攻击目标</param>
/// <param name="startDamage">初始伤害</param>
/// <param name="ignoreDodge">是否无视闪避</param>
/// <param name="ignoreBlock">是否无视格挡</param>
/// <param name="ignoreShield">是否无视护盾</param>
/// <returns>实际造成的伤害</returns>
public AttackResult Attack(CharacterBase target, int startDamage, CardInstance attackCard = null, bool triggerAttackEvent = true, bool ignoreDodge = false, bool ignoreBlock = false, bool ignoreShield = false)
/// <param name="context">攻击上下文(包含来源卡牌、标签等);传 null 等价于默认上下文</param>
/// <returns>攻击结果</returns>
public AttackResult Attack(CharacterBase target, int startDamage, AttackContext context)
{
if (triggerAttackEvent)
context ??= new AttackContext();
bool isSilent = context.HasTag(AttackTags.Silent);
bool isHpRemoval = context.HasTag(AttackTags.HpRemoval);
bool isReactive = context.HasTag(AttackTags.Reactive);
bool ignoreDodge = context.HasAnyTag(AttackTags.GuaranteedHit, AttackTags.HpRemoval);
bool ignoreBlock = context.HasAnyTag(AttackTags.Penetrating, AttackTags.HpRemoval);
bool ignoreShield = context.HasAnyTag(AttackTags.HpRemoval);
// 静默和生命移除均不触发 onStartAttack
if (!isSilent && !isHpRemoval)
{
eventSubmodule.onStartAttack.Invoke(target);
}
//闪避检测:如果闪避成功,直接结束
// 生命移除:直接扣血,跳过所有防御和事件
if (isHpRemoval)
{
target.HealthRemoval(startDamage, context);
target.characterView.hudContainer.enablingHUDs["MainAttributesBar"].UpdateHud();
return new AttackResult(this, target, startDamage, context, false, 0, 0, startDamage);
}
// 闪避检测
int modifiedStartDamageForDodge = Mathf.RoundToInt(startDamage * GetRawAttribute("DodgeCheckStartDamageMultiplier", 1));
bool dodged = !ignoreDodge && target.CheckDodge(modifiedStartDamageForDodge);
@@ -113,25 +129,51 @@ namespace Continentis.MainGame.Character
{
shielded = remainingDamageAfterBlock - remainingDamageAfterShield;
hurt = remainingDamageAfterShield;
target.HealthRemoval(remainingDamageAfterShield);
target.HealthRemoval(remainingDamageAfterShield, context);
}
}
}
target.characterView.hudContainer.enablingHUDs["MainAttributesBar"].UpdateHud();
AttackResult attackResult = new AttackResult(this, target, startDamage, attackCard, dodged, blocked, shielded, hurt);
if (triggerAttackEvent)
AttackResult attackResult = new AttackResult(this, target, startDamage, context, dodged, blocked, shielded, hurt);
if (!isSilent)
{
// 角色 EventSubmodule 级别事件(始终触发,用于日志等)
eventSubmodule.onFinishAttack.Invoke(target, attackResult);
combatBuffSubmodule.buffList.For(buff =>
target.eventSubmodule.onGetAttacked.Invoke(this, attackResult);
// Buff 层事件:响应式攻击不触发,防止无限递归
if (!isReactive)
{
buff.eventSubmodule?.onDealAttack.Invoke(attackResult);
});
combatBuffSubmodule.buffList.For(buff =>
{
buff.eventSubmodule?.onDealAttack.Invoke(attackResult);
});
target.combatBuffSubmodule.buffList.For(buff =>
{
buff.eventSubmodule?.onGetAttacked.Invoke(attackResult);
});
}
}
return attackResult;
}
/// <summary>
/// 攻击目标(兼容旧版 API内部转换为 AttackContext 调用)。
/// 新代码请优先使用 Attack(target, damage, AttackContext) 重载。
/// </summary>
public AttackResult Attack(CharacterBase target, int startDamage, CardInstance attackCard = null, bool triggerAttackEvent = true, bool ignoreDodge = false, bool ignoreBlock = false, bool ignoreShield = false)
{
var context = new AttackContext(attackCard);
if (!triggerAttackEvent) context.WithTag(AttackTags.Silent);
if (ignoreDodge) context.WithTag(AttackTags.GuaranteedHit);
if (ignoreBlock) context.WithTag(AttackTags.Penetrating);
return Attack(target, startDamage, context);
}
/// <summary>
/// 检查闪避(闪避失败后会清空闪避值)
/// </summary>
@@ -145,12 +187,16 @@ namespace Continentis.MainGame.Character
{
bool success = damage <= dodge;
if (!success)
if (success)
{
MainGameManager.Instance.basePrefabs.GenerateInfoText("Dodged!", characterView);
return true;
}
else
{
attributeSubmodule.generalAttributeGroup.current["Dodge"] = 0;
return false;
}
MainGameManager.Instance.basePrefabs.GenerateInfoText("Dodged!", characterView);
return success;
}
return false;
@@ -190,13 +236,13 @@ namespace Continentis.MainGame.Character
}
/// <summary>
/// 检查护盾(并且扣除护盾值)
/// 检查临时生命(并且扣除临时生命值)
/// </summary>
/// <param name="damage">即将受到的伤害</param>
/// <returns>护盾之后的剩余伤害</returns>
/// <returns>临时生命吸收后的剩余伤害</returns>
public int CheckShield(int damage)
{
int shield = attributeSubmodule.GetGeneralAttribute("Shield");
int shield = attributeSubmodule.GetGeneralAttribute("TemporaryHealth");
if (shield > 0)
{
@@ -206,12 +252,12 @@ namespace Continentis.MainGame.Character
if (!success)
{
attributeSubmodule.generalAttributeGroup.current["Shield"] = 0;
attributeSubmodule.generalAttributeGroup.current["TemporaryHealth"] = 0;
remainingDamage = damage - shield;
}
else
{
attributeSubmodule.generalAttributeGroup.current["Shield"] = shield - damage;
attributeSubmodule.generalAttributeGroup.current["TemporaryHealth"] = shield - damage;
blockedDamage = damage;
}
@@ -222,11 +268,68 @@ namespace Continentis.MainGame.Character
return damage;
}
public void HealthRemoval(int damage)
public void HealthRemoval(int damage, AttackContext context = null)
{
int healthBefore = GetAttribute("Health");
ModifyAttribute("Health", -damage);
MainGameManager.Instance.basePrefabs.GenerateHurtText(damage, characterView);
int healthAfter = GetAttribute("Health");
int maxHealth = GetAttribute("MaximumHealth");
Color dmgTextColor = Color.white;
if (context is { damageKeywords: { Count: > 0 } })
{
foreach (string elementTag in MainGameManager.Instance.elementTags)
{
if (context.damageKeywords.Contains(elementTag))
{
switch (elementTag)
{
case "Fire":
dmgTextColor = Color.red;
break;
case "Ice":
dmgTextColor = Color.cyan;
break;
case "Wind":
dmgTextColor = Color.lightGreen;
break;
case "Earth":
dmgTextColor = Color.darkGoldenRod;
break;
case "Storm":
dmgTextColor = Color.magenta;
break;
case "Light":
dmgTextColor = Color.yellowNice;
break;
case "Darkness":
dmgTextColor = Color.rebeccaPurple;
break;
default:
dmgTextColor = Color.white;
break;
}
}
}
}
MainGameManager.Instance.basePrefabs.GenerateHurtText(damage, characterView, dmgTextColor);
// 血量百分比阈值检查:穿越整十档时通知 LogicBase如 Boss 阶段切换)
if (maxHealth > 0 && logicBase != null)
{
float percentBefore = (float)healthBefore / maxHealth;
float percentAfter = (float)healthAfter / maxHealth;
// 找出所有被穿越的整十档(从高到低依次触发)
for (int threshold = 9; threshold >= 0; threshold--)
{
float t = threshold * 0.1f;
if (percentBefore > t && percentAfter <= t)
{
logicBase.OnHealthThreshold(t);
}
}
}
if (GetAttribute("Health") <= 0)
{
Die();
@@ -282,15 +385,15 @@ namespace Continentis.MainGame.Character
}
/// <summary>
/// 添加护盾(护盾不会自动清空)
/// 添加临时生命(不会自动清空)
/// </summary>
public void AddShield(int shield, CharacterBase target = null)
{
int baseShieldAfterOffset = shield + GetAttribute("ShieldGainOffset");
int finalShield = Mathf.RoundToInt(baseShieldAfterOffset * GetRawAttribute("ShieldGainMultiplier", 1));
int baseShieldAfterOffset = shield + GetAttribute("TemporaryHealthGainOffset");
int finalShield = Mathf.RoundToInt(baseShieldAfterOffset * GetRawAttribute("TemporaryHealthGainMultiplier", 1));
target ??= this;
target.ModifyAttribute("Shield", finalShield);
target.ModifyAttribute("TemporaryHealth", finalShield);
target.characterView.hudContainer.UpdateAllHUD();
}
}
@@ -355,8 +458,8 @@ namespace Continentis.MainGame.Character
}
intended.Add(new IntendedCard(card, targets));
remainingStamina -= card.GetAttribute("StaminaCost");
remainingMana -= card.GetAttribute("ManaCost");
remainingStamina -= card.GetAttribute(CardAttributes.StaminaCost);
remainingMana -= card.GetAttribute(CardAttributes.ManaCost);
}
// 行动力不足则跳过该卡
}
@@ -398,8 +501,8 @@ namespace Continentis.MainGame.Character
intended.Add(new IntendedCard(chosen, targets));
normal.Remove(chosen);
remainingStamina -= chosen.GetAttribute("StaminaCost");
remainingMana -= chosen.GetAttribute("ManaCost");
remainingStamina -= chosen.GetAttribute(CardAttributes.StaminaCost);
remainingMana -= chosen.GetAttribute(CardAttributes.ManaCost);
}
}
@@ -408,8 +511,8 @@ namespace Continentis.MainGame.Character
bool CanAfford(CardInstance card, int stamina, int mana)
{
return card.GetAttribute("StaminaCost") <= stamina &&
card.GetAttribute("ManaCost") <= mana;
return card.GetAttribute(CardAttributes.StaminaCost) <= stamina &&
card.GetAttribute(CardAttributes.ManaCost) <= mana;
}
public bool CheckAvailabilityAndSetTargets(CardInstance card, out List<CharacterBase> targets)

View File

@@ -60,6 +60,16 @@ namespace Continentis.MainGame.Character
public partial class CombatBuffSubmodule
{
public void CombatStart()
{
buffList.For(buff => buff.eventSubmodule?.onCombatStart?.Invoke());
}
public void CombatEnd()
{
buffList.For(buff => buff.eventSubmodule?.onCombatEnd?.Invoke());
}
public void RoundStart()
{
buffList.For(buff => buff.roundCountSubmodule?.Update());
@@ -73,13 +83,13 @@ namespace Continentis.MainGame.Character
public void ActionStart()
{
Debug.Log($"{owner.data.displayName} is starting an action. Current action count this round: {owner.actionCountThisRound}");
//Debug.Log($"{owner.data.displayName} is starting an action. Current action count this round: {owner.actionCountThisRound}");
if (owner.actionCountThisRound == 0)
{
Debug.Log($"{owner.data.displayName} is starting their first action this round. Buff count of {buffList.Count} will update their round first action counts.");
//Debug.Log($"{owner.data.displayName} is starting their first action this round. Buff count of {buffList.Count} will update their round first action counts.");
buffList.For(buff =>
{
Debug.Log($"Updating round first action count for buff: {buff.contentSubmodule.displayName}");
//Debug.Log($"Updating round first action count for buff: {buff.contentSubmodule.displayName}");
buff.roundFirstActionCountSubmodule?.Update();
});
}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6a7e70a75f86711418ab773b6494763e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -14,15 +14,17 @@ namespace Continentis.MainGame.Character
public DeckSubmodule(CharacterBase character) : base(character)
{
piles = new Dictionary<string, List<CardInstance>>();
piles.Add("Storage", new List<CardInstance>());
piles.Add("Hand", new List<CardInstance>());
piles.Add("Draw", new List<CardInstance>());
piles.Add("Discard", new List<CardInstance>());
piles.Add("Exhaust", new List<CardInstance>());
piles.Add("Grave", new List<CardInstance>());
piles.Add("Pool", new List<CardInstance>());
piles.Add("Intention", new List<CardInstance>());
piles = new Dictionary<string, List<CardInstance>>
{
{ Piles.Storage, new List<CardInstance>() },
{ Piles.Hand, new List<CardInstance>() },
{ Piles.Draw, new List<CardInstance>() },
{ Piles.Discard, new List<CardInstance>() },
{ Piles.Exhaust, new List<CardInstance>() },
{ Piles.Grave, new List<CardInstance>() },
{ Piles.Pool, new List<CardInstance>() },
{ Piles.Intention, new List<CardInstance>() }
};
}
}
@@ -51,11 +53,11 @@ namespace Continentis.MainGame.Character
if (cardCount > DrawPile.Count && DiscardPile.Count > 0)
{
Debug.Log("抽牌堆牌数不足,且弃牌堆有牌,正在洗牌...");
//Debug.Log("抽牌堆牌数不足,且弃牌堆有牌,正在洗牌...");
ReshuffleDeck();
}
Debug.Log($"准备抽取 {cardCount} 张卡牌。");
//Debug.Log($"准备抽取 {cardCount} 张卡牌。");
return new CommandGroup(ExecutionMode.Sequential,
new Cmd_DrawCards(this, cardCount, interval),
@@ -71,7 +73,7 @@ namespace Continentis.MainGame.Character
if (drawCardsGroup.groupContext.TryGet(CommandContextKeys.DrawnCards, out List<CardInstance> cards))
return cards;
Debug.LogWarning("[DeckSubmodule] groupContext 中未找到 DrawnCards。");
//Debug.LogWarning("[DeckSubmodule] groupContext 中未找到 DrawnCards。");
return new List<CardInstance>();
}
@@ -232,13 +234,13 @@ namespace Continentis.MainGame.Character
throw new KeyNotFoundException($"Pile '{pileName}' not found.");
}
public List<CardInstance> StoragePile => Pile("Storage");
public List<CardInstance> HandPile => Pile("Hand");
public List<CardInstance> DrawPile => Pile("Draw");
public List<CardInstance> DiscardPile => Pile("Discard");
public List<CardInstance> ExhaustPile => Pile("Exhaust");
public List<CardInstance> GravePile => Pile("Grave");
public List<CardInstance> PoolPile => Pile("Pool");
public List<CardInstance> IntentionPile => Pile("Intention");
public List<CardInstance> StoragePile => Pile(Piles.Storage);
public List<CardInstance> HandPile => Pile(Piles.Hand);
public List<CardInstance> DrawPile => Pile(Piles.Draw);
public List<CardInstance> DiscardPile => Pile(Piles.Discard);
public List<CardInstance> ExhaustPile => Pile(Piles.Exhaust);
public List<CardInstance> GravePile => Pile(Piles.Grave);
public List<CardInstance> PoolPile => Pile(Piles.Pool);
public List<CardInstance> IntentionPile => Pile(Piles.Intention);
}
}

View File

@@ -0,0 +1,16 @@
using UnityEngine;
namespace Continentis.MainGame
{
public static class Piles
{
public static string Storage = "Storage";
public static string Hand = "Hand";
public static string Draw = "Draw";
public static string Discard = "Discard";
public static string Exhaust = "Exhaust";
public static string Grave = "Grave";
public static string Pool = "Pool";
public static string Intention = "Intention";
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4cc14aaaad81bf3428dc9c19c1138a5d

View File

@@ -24,6 +24,9 @@ namespace Continentis.MainGame.Character
public OrderedDictionary<string, PrioritizedAction<CardInstance, List<CharacterBase>>> onBeforePlayCard; //使用卡牌前,参数为使用的卡牌
public OrderedDictionary<string, PrioritizedAction<CardInstance, List<CharacterBase>>> onAfterPlayCard; //使用卡牌后,参数为使用的卡牌
/// <summary>角色死亡时触发,供 Buff / 技能系统订阅响应</summary>
public OrderedDictionary<string, PrioritizedAction> onDeath; //角色死亡时
public EventSubmodule(CharacterBase character) : base(character)
{
onCombatStart = new OrderedDictionary<string, PrioritizedAction>();
@@ -42,6 +45,8 @@ namespace Continentis.MainGame.Character
onBeforePlayCard = new OrderedDictionary<string, PrioritizedAction<CardInstance, List<CharacterBase>>>();
onAfterPlayCard = new OrderedDictionary<string, PrioritizedAction<CardInstance, List<CharacterBase>>>();
onDeath = new OrderedDictionary<string, PrioritizedAction>();
onActionStart.InsertByPriority("StaminaRecover", new PrioritizedAction(() =>
{
owner.ModifyAttribute("Stamina", owner.GetAttribute("StaminaRecoverPerAction"));
@@ -89,15 +94,19 @@ namespace Continentis.MainGame.Character
public int blockedDamage; //格挡掉的伤害
public int shieldedDamage; //护盾吸收的伤害
public int hurtDamage; //实际受到的伤害
/// <summary>本次攻击的上下文,包含标签等扩展信息。</summary>
public AttackContext context;
public bool IsHurt => hurtDamage > 0; //是否实际受到伤害
public AttackResult(CharacterBase attacker, CharacterBase target, int startDamage, CardInstance attackCard, bool isDodged, int blocked, int shielded, int hurt)
public AttackResult(CharacterBase attacker, CharacterBase target, int startDamage, AttackContext context, bool isDodged, int blocked, int shielded, int hurt)
{
this.attacker = attacker;
this.target = target;
this.attackCard = attackCard;
this.attackCard = context?.sourceCard;
this.startDamage = startDamage;
this.context = context ?? new AttackContext();
this.isDodged = isDodged;
this.blockedDamage = blocked;
this.shieldedDamage = shielded;

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 30d25484995a4fb45a05436995e9a0fa
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -2,12 +2,13 @@ using System;
using System.Collections.Generic;
using Continentis.MainGame.Card;
using SLSFramework.General;
using SoftCircuits.Collections;
using UnityEngine;
using UnityEngine.Events;
namespace Continentis.MainGame.Character
{
public class IntentionSubmodule : SubmoduleBase<CharacterBase>
public partial class IntentionSubmodule : SubmoduleBase<CharacterBase>
{
public List<IntentionBase> allIntentions;
public IntentionBase currentIntention;
@@ -15,12 +16,25 @@ namespace Continentis.MainGame.Character
public List<IntendedCard> intendedCards;
/// <summary>意图卡被移除后触发,参数为被移除的 IntendedCard 和它原来所在的索引。</summary>
public OrderedDictionary<string, PrioritizedAction<IntendedCard, int>> onIntendedCardRemoved;
/// <summary>意图卡被替换后触发,参数为旧 IntendedCard、新 IntendedCard 和所在的索引。</summary>
public OrderedDictionary<string, PrioritizedAction<IntendedCard, IntendedCard, int>> onIntendedCardReplaced;
/// <summary>意图卡被插入后触发,参数为新 IntendedCard 和插入的索引。</summary>
public OrderedDictionary<string, PrioritizedAction<IntendedCard, int>> onIntendedCardInserted;
public IntentionSubmodule(CharacterBase owner) : base(owner)
{
allIntentions = new List<IntentionBase>();
currentIntention = new IntentionBase(this);
getIntendedCards = owner.GetIntendedCards;
intendedCards = new List<IntendedCard>();
onIntendedCardRemoved = new OrderedDictionary<string, PrioritizedAction<IntendedCard, int>>();
onIntendedCardReplaced = new OrderedDictionary<string, PrioritizedAction<IntendedCard, IntendedCard, int>>();
onIntendedCardInserted = new OrderedDictionary<string, PrioritizedAction<IntendedCard, int>>();
}
}
@@ -71,6 +85,10 @@ namespace Continentis.MainGame.Character
}
/// <summary>NPC 本回合出牌前调用,可用于播放蓄力台词、切换动画状态等。</summary>
public virtual void PreAction() { }
/// <summary>NPC 本回合全部卡牌出完后调用,可用于播放结束台词、重置状态等。</summary>
public virtual void PostAction() { }
}
}

View File

@@ -0,0 +1,238 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Continentis.MainGame.Card;
using SLSFramework.General;
using UnityEngine;
using Random = UnityEngine.Random;
namespace Continentis.MainGame.Character
{
public partial class IntentionSubmodule
{
// ──────────────────────────────────────────────────────────────
// 原子操作层
// ──────────────────────────────────────────────────────────────
/// <summary>移除指定位置的意图卡并销毁其视图。</summary>
/// <returns>被移除的 IntendedCard索引越界时返回 null。</returns>
public IntendedCard RemoveIntendedCardAt(int index)
{
if (!IsValidIndex(index)) return null;
IntendedCard removed = intendedCards[index];
removed.cardInstance.DestroyIntentionCardView();
intendedCards.RemoveAt(index);
onIntendedCardRemoved.Invoke(removed, index);
return removed;
}
/// <summary>替换指定位置的意图卡,销毁旧视图并生成新视图。</summary>
/// <returns>被替换掉的旧 IntendedCard索引越界时返回 null。</returns>
public IntendedCard ReplaceIntendedCardAt(int index, IntendedCard newCard)
{
if (!IsValidIndex(index)) return null;
if (newCard == null)
{
Debug.LogWarning("[IntentionSubmodule] ReplaceIntendedCardAt: newCard is null, performing remove instead.");
return RemoveIntendedCardAt(index);
}
IntendedCard oldCard = intendedCards[index];
oldCard.cardInstance.DestroyIntentionCardView();
intendedCards[index] = newCard;
newCard.cardInstance.GenerateIntentionCardView();
// 设置文本解析目标
if (newCard.targets.Count > 0)
{
newCard.cardInstance.Targeting(newCard.targets[0]);
newCard.cardInstance.contentSubmodule.dirtyMark = true;
}
onIntendedCardReplaced.Invoke(oldCard, newCard, index);
return oldCard;
}
/// <summary>在指定位置插入一张新意图卡并生成视图。</summary>
public void InsertIntendedCard(int index, IntendedCard newCard)
{
if (newCard == null)
{
Debug.LogWarning("[IntentionSubmodule] InsertIntendedCard: newCard is null, insertion skipped.");
return;
}
int clampedIndex = Mathf.Clamp(index, 0, intendedCards.Count);
intendedCards.Insert(clampedIndex, newCard);
newCard.cardInstance.GenerateIntentionCardView();
// 设置文本解析目标
if (newCard.targets.Count > 0)
{
newCard.cardInstance.Targeting(newCard.targets[0]);
newCard.cardInstance.contentSubmodule.dirtyMark = true;
}
onIntendedCardInserted.Invoke(newCard, clampedIndex);
}
/// <summary>在末尾追加一张新意图卡并生成视图。</summary>
public void AddIntendedCard(IntendedCard newCard)
{
InsertIntendedCard(intendedCards.Count, newCard);
}
// ──────────────────────────────────────────────────────────────
// 查询 / 工具层
// ──────────────────────────────────────────────────────────────
/// <summary>返回 intendedCards 中的随机索引,列表为空时返回 -1。</summary>
public int GetRandomIntendedCardIndex()
{
return intendedCards.Count == 0 ? -1 : Random.Range(0, intendedCards.Count);
}
/// <summary>
/// 使用过滤器从 intendedCards 中筛选出符合条件的意图卡索引列表。
/// </summary>
/// <param name="filter">返回 true 表示该意图卡通过筛选。</param>
public List<int> GetFilteredIntendedCardIndices(Func<IntendedCard, bool> filter)
{
List<int> result = new List<int>();
for (int i = 0; i < intendedCards.Count; i++)
{
if (filter(intendedCards[i]))
result.Add(i);
}
return result;
}
/// <summary>
/// 使用过滤器从 intendedCards 中随机选取一个符合条件的意图卡索引。
/// </summary>
/// <returns>随机索引,无符合条件的意图卡时返回 -1。</returns>
public int GetRandomFilteredIntendedCardIndex(Func<IntendedCard, bool> filter)
{
List<int> filtered = GetFilteredIntendedCardIndices(filter);
return filtered.Count == 0 ? -1 : filtered[Random.Range(0, filtered.Count)];
}
/// <summary>
/// 尝试从 PoolPile 中获取一张可替换的随机卡牌(排除当前已在 intendedCards 中的卡牌)。
/// </summary>
/// <param name="result">生成的 IntendedCard失败时为 null。</param>
/// <param name="checkAffordability">是否检查体力/法力消耗。</param>
/// <returns>是否成功找到可替换的卡牌。</returns>
public bool TryGetRandomReplacementCard(out IntendedCard result, bool checkAffordability = false)
{
result = null;
HashSet<CardInstance> currentCards = new HashSet<CardInstance>(
intendedCards.Select(ic => ic.cardInstance));
List<CardInstance> candidates = owner.deckSubmodule.PoolPile
.Where(card => !currentCards.Contains(card) && !card.weightSubmodule.forceIgnore)
.ToList();
if (checkAffordability)
{
int stamina = owner.GetAttribute(CharacterAttributes.Stamina);
int mana = owner.GetAttribute(CharacterAttributes.Mana);
candidates = candidates.Where(card =>
card.GetAttribute(CardAttributes.StaminaCost) <= stamina &&
card.GetAttribute(CardAttributes.ManaCost) <= mana).ToList();
}
if (candidates.Count == 0) return false;
CardInstance chosen = candidates[Random.Range(0, candidates.Count)];
if (!owner.CheckAvailabilityAndSetTargets(chosen, out List<CharacterBase> targets))
return false;
result = new IntendedCard(chosen, targets);
return true;
}
// ──────────────────────────────────────────────────────────────
// 组合操作层
// ──────────────────────────────────────────────────────────────
/// <summary>随机移除一张意图卡。</summary>
/// <returns>被移除的 IntendedCard列表为空时返回 null。</returns>
public IntendedCard RemoveRandomIntendedCard()
{
int index = GetRandomIntendedCardIndex();
return index < 0 ? null : RemoveIntendedCardAt(index);
}
/// <summary>
/// 随机移除一张符合过滤条件的意图卡。
/// </summary>
/// <param name="filter">返回 true 表示该意图卡可被移除。</param>
/// <returns>被移除的 IntendedCard无符合条件的意图卡时返回 null。</returns>
public IntendedCard RemoveRandomIntendedCard(Func<IntendedCard, bool> filter)
{
int index = GetRandomFilteredIntendedCardIndex(filter);
return index < 0 ? null : RemoveIntendedCardAt(index);
}
/// <summary>
/// 随机将一张意图卡替换为 PoolPile 中的另一张卡牌。
/// 若无可替换卡牌则降级为移除。
/// </summary>
/// <param name="checkAffordability">是否检查体力/法力消耗。</param>
/// <returns>操作是否成功执行(移除或替换均算成功)。</returns>
public bool ChangeRandomIntendedCard(bool checkAffordability = false)
{
int index = GetRandomIntendedCardIndex();
if (index < 0) return false;
if (TryGetRandomReplacementCard(out IntendedCard replacement, checkAffordability))
{
ReplaceIntendedCardAt(index, replacement);
}
else
{
RemoveIntendedCardAt(index);
}
return true;
}
/// <summary>
/// 随机将一张符合过滤条件的意图卡替换为 PoolPile 中的另一张卡牌。
/// 若无可替换卡牌则降级为移除。
/// </summary>
/// <param name="filter">返回 true 表示该意图卡可被替换。</param>
/// <param name="checkAffordability">是否检查体力/法力消耗。</param>
/// <returns>操作是否成功执行。</returns>
public bool ChangeRandomIntendedCard(Func<IntendedCard, bool> filter, bool checkAffordability = false)
{
int index = GetRandomFilteredIntendedCardIndex(filter);
if (index < 0) return false;
if (TryGetRandomReplacementCard(out IntendedCard replacement, checkAffordability))
{
ReplaceIntendedCardAt(index, replacement);
}
else
{
RemoveIntendedCardAt(index);
}
return true;
}
// ──────────────────────────────────────────────────────────────
// 内部工具
// ──────────────────────────────────────────────────────────────
private bool IsValidIndex(int index)
{
if (index >= 0 && index < intendedCards.Count) return true;
Debug.LogWarning($"[IntentionSubmodule] Index {index} out of range (count: {intendedCards.Count}).");
return false;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 41e6e47005c77fb4994040c6b9763669

View File

@@ -14,7 +14,6 @@ namespace Continentis.MainGame.Character
public GameObject mainView;
public Animator animator;
public AnimatorPlus2D animatorPlus2D;
public SerializableDictionary<string, AnimationClip> animations;
public Collider selector;
@@ -26,7 +25,16 @@ namespace Continentis.MainGame.Character
public List<SpriteRenderer> spriteRenderers;
public List<Material> materials;
/// <summary>
/// 当前使用的动画驱动器,由 Initialize 时自动检测。
/// 外部通过此属性调用 PlayAction / ReturnToIdle 等方法。
/// </summary>
public ICharacterAnimator CharacterAnimator { get; private set; }
/// <summary>
/// 初始化角色视图:收集 SpriteRenderer / Material自动检测并初始化动画驱动器。
/// </summary>
public void Initialize(CharacterBase character)
{
this.character = character;
@@ -39,21 +47,18 @@ namespace Continentis.MainGame.Character
}
SetOutline(false);
animations = new SerializableDictionary<string, AnimationClip>();
foreach (KeyValuePair<string, AnimationClip> anim in character.data.animations)
// 自动检测动画驱动器:优先级 Spine > Frame > Static
CharacterAnimator = GetComponent<SpineAnimator>() as ICharacterAnimator
?? GetComponent<FrameAnimator>() as ICharacterAnimator
?? GetComponent<StaticSpriteAnimator>() as ICharacterAnimator;
if (CharacterAnimator != null)
{
animations.Add(anim.Key, anim.Value);
}
if (animations.TryGetValue("Idle", out AnimationClip idle))
{
animatorPlus2D.defaultIdleClip = idle;
animatorPlus2D.Initialize();
CharacterAnimator.InitializeAnimator(this);
}
else
{
throw new Exception($"No Idle animation found for character {character.data.displayName}");
Debug.LogWarning($"[CharacterView] 角色 '{character.data.displayName}' 未挂载任何 ICharacterAnimator 实现,无动画驱动器。");
}
}
}

View File

@@ -0,0 +1,108 @@
using System;
using System.Collections;
using System.Collections.Generic;
using AnimatorPlus;
using SLSFramework.General;
using UnityEngine;
namespace Continentis.MainGame.Character
{
/// <summary>
/// Tier 2 动画驱动器:帧动画方案,基于 AnimatorPlus2D + AnimationClip。
/// 通过 CharacterData.animations 映射表驱动 Sprite Sheet 帧动画。
/// 适合有完整帧动画序列的像素风角色(如 PixelFantasy 系列)。
/// </summary>
public class FrameAnimator : MonoBehaviour, ICharacterAnimator
{
private AnimatorPlus2D _animatorPlus;
private SerializableDictionary<string, AnimationClip> _animations;
private Coroutine _completionCoroutine;
// ── ICharacterAnimator ──────────────────────────────────────────────
/// <summary>
/// 初始化:从 CombatCharacterViewBase 获取 AnimatorPlus2D 引用和动画映射表。
/// </summary>
public void InitializeAnimator(CombatCharacterViewBase view)
{
_animatorPlus = view.animatorPlus2D;
// 从 CharacterData 拷贝动画映射
_animations = new SerializableDictionary<string, AnimationClip>();
foreach (KeyValuePair<string, AnimationClip> pair in view.character.data.animations)
{
_animations.Add(pair.Key, pair.Value);
}
// 设置 Idle 并初始化 Playable Graph
if (_animations.TryGetValue("Idle", out AnimationClip idle))
{
_animatorPlus.defaultIdleClip = idle;
_animatorPlus.Initialize();
}
else
{
Debug.LogError($"[FrameAnimator] 角色 '{view.character.data.displayName}' 缺少 Idle 动画,无法初始化。");
}
}
/// <summary>
/// 播放指定动作的 AnimationClip播完自动回 Idle 并触发回调。
/// </summary>
public void PlayAction(string actionName, float speed = 1f, Action onComplete = null)
{
StopCompletionCoroutine();
if (!_animations.TryGetValue(actionName, out AnimationClip clip))
{
Debug.LogWarning($"[FrameAnimator] 找不到动画 '{actionName}',跳过播放。");
onComplete?.Invoke();
return;
}
_animatorPlus.Play(clip, speed);
// 动画播完后触发回调AnimatorPlus2D 自动回 Idle此处仅等待时长
if (onComplete != null)
{
float duration = clip.length / Mathf.Max(speed, 0.01f);
_completionCoroutine = StartCoroutine(WaitForCompletion(duration, onComplete));
}
}
/// <summary>
/// 立即停止当前动作,切回 Idle。
/// </summary>
public void ReturnToIdle()
{
StopCompletionCoroutine();
_animatorPlus.Stop();
}
/// <summary>
/// 暂停或恢复 PlayableGraph。
/// </summary>
public void SetPause(bool isPaused)
{
_animatorPlus.SetPause(isPaused);
}
// ── 内部逻辑 ────────────────────────────────────────────────────────
private IEnumerator WaitForCompletion(float duration, Action onComplete)
{
yield return new WaitForSeconds(duration);
onComplete?.Invoke();
_completionCoroutine = null;
}
private void StopCompletionCoroutine()
{
if (_completionCoroutine != null)
{
StopCoroutine(_completionCoroutine);
_completionCoroutine = null;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 63273f95cd581594d85ac454f2395265

View File

@@ -0,0 +1,36 @@
using System;
namespace Continentis.MainGame.Character
{
/// <summary>
/// 角色动画驱动器统一接口。
/// 由具体的 MonoBehaviour 实现,挂载在角色 Prefab 上,
/// CombatCharacterViewBase 在初始化时自动检测并持有引用。
/// </summary>
public interface ICharacterAnimator
{
/// <summary>
/// 初始化动画驱动器,由 CombatCharacterViewBase.Initialize() 调用。
/// </summary>
void InitializeAnimator(CombatCharacterViewBase view);
/// <summary>
/// 播放指定名称的动作(如 "Attack"、"Hit"、"Skill")。
/// 播放完毕后自动回到 Idle并触发 onComplete 回调。
/// </summary>
/// <param name="actionName">动作名称,需与 CharacterData.animations 的 Key 一致。</param>
/// <param name="speed">播放速度倍率,默认 1.0。</param>
/// <param name="onComplete">动作播放完毕后的回调,可为 null。</param>
void PlayAction(string actionName, float speed = 1f, Action onComplete = null);
/// <summary>
/// 立即切回 Idle 状态。
/// </summary>
void ReturnToIdle();
/// <summary>
/// 暂停或恢复动画播放。
/// </summary>
void SetPause(bool isPaused);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6a4fe7995a9658f4580e2984545da6ff

View File

@@ -0,0 +1,52 @@
using System;
using UnityEngine;
namespace Continentis.MainGame.Character
{
/// <summary>
/// Tier 3 动画驱动器Spine 骨骼动画(预留接口)。
/// 美术资源到位后,接入 Spine-Unity Runtime 的 SkeletonAnimation API。
///
/// 预期实现思路:
/// - InitializeAnimator获取 SkeletonAnimation 引用,设置 Idle 动画
/// - PlayAction调用 SkeletonAnimation.AnimationState.SetAnimation()
/// - 利用 Spine 事件系统触发攻击命中帧、特效帧等
/// - 支持动画混合(如上半身攻击 + 下半身移动)
/// </summary>
public class SpineAnimator : MonoBehaviour, ICharacterAnimator
{
// TODO: Spine Runtime 引用
// private SkeletonAnimation _skeleton;
public void InitializeAnimator(CombatCharacterViewBase view)
{
Debug.LogWarning("[SpineAnimator] Spine 动画驱动器尚未实装,当前为占位。");
// TODO: 获取 SkeletonAnimation 组件
// _skeleton = view.mainView.GetComponent<SkeletonAnimation>();
// _skeleton.AnimationState.SetAnimation(0, "idle", true);
}
public void PlayAction(string actionName, float speed = 1f, Action onComplete = null)
{
Debug.LogWarning($"[SpineAnimator] PlayAction('{actionName}') 未实装。");
// TODO:
// var entry = _skeleton.AnimationState.SetAnimation(0, actionName, false);
// entry.TimeScale = speed;
// entry.Complete += _ => {
// _skeleton.AnimationState.SetAnimation(0, "idle", true);
// onComplete?.Invoke();
// };
onComplete?.Invoke();
}
public void ReturnToIdle()
{
// TODO: _skeleton.AnimationState.SetAnimation(0, "idle", true);
}
public void SetPause(bool isPaused)
{
// TODO: _skeleton.timeScale = isPaused ? 0f : 1f;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d607d72147dad7441aca976ff675e6f2

View File

@@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using DG.Tweening;
using SLSFramework.General;
using UnityEngine;
namespace Continentis.MainGame.Character
{
/// <summary>
/// Tier 1 动画驱动器:静态图片切换 + DOTween 位移。
/// 不使用 AnimationClip通过切换 SpriteRenderer.sprite 表现不同动作。
/// 适合尚无帧动画资源的角色,或仅有立绘/静态图片的简易角色。
/// </summary>
public class StaticSpriteAnimator : MonoBehaviour, ICharacterAnimator
{
[Header("Sprite 映射")]
[Tooltip("动作名称 → Sprite 映射表Key 需与 CharacterData.animations 的 Key 对齐(如 Idle、Attack、Hit")]
public SerializableDictionary<string, Sprite> actionSprites;
[Header("DOTween 参数")]
[Tooltip("攻击时前冲距离")]
[SerializeField] private float attackLungeDistance = 0.5f;
[Tooltip("攻击前冲时长")]
[SerializeField] private float attackLungeDuration = 0.15f;
[Tooltip("受击抖动强度")]
[SerializeField] private float hitShakeStrength = 0.3f;
[Tooltip("受击抖动时长")]
[SerializeField] private float hitShakeDuration = 0.2f;
private SpriteRenderer _mainSpriteRenderer;
private Sprite _idleSprite;
private Vector3 _originalLocalPosition;
private Tween _currentTween;
// ── ICharacterAnimator ──────────────────────────────────────────────
/// <summary>
/// 初始化:缓存主 SpriteRenderer 和 Idle Sprite。
/// </summary>
public void InitializeAnimator(CombatCharacterViewBase view)
{
_mainSpriteRenderer = view.mainView.GetComponent<SpriteRenderer>();
if (_mainSpriteRenderer == null)
_mainSpriteRenderer = view.mainView.GetComponentInChildren<SpriteRenderer>();
_originalLocalPosition = view.mainView.transform.localPosition;
// 尝试从映射表获取 Idle sprite回退为当前显示的 sprite
_idleSprite = actionSprites != null && actionSprites.TryGetValue("Idle", out Sprite idle)
? idle
: _mainSpriteRenderer != null ? _mainSpriteRenderer.sprite : null;
// 应用 Idle Sprite
if (_mainSpriteRenderer != null && _idleSprite != null)
_mainSpriteRenderer.sprite = _idleSprite;
}
/// <summary>
/// 播放动作:切换 Sprite + DOTween 动效,完毕后自动回 Idle。
/// </summary>
public void PlayAction(string actionName, float speed = 1f, Action onComplete = null)
{
KillCurrentTween();
// 切换 Sprite
if (_mainSpriteRenderer != null && actionSprites != null &&
actionSprites.TryGetValue(actionName, out Sprite sprite))
{
_mainSpriteRenderer.sprite = sprite;
}
// 根据动作类型执行 DOTween 动效
float adjustedDuration = 1f / Mathf.Max(speed, 0.01f);
Transform mainTransform = _mainSpriteRenderer != null
? _mainSpriteRenderer.transform
: transform;
switch (actionName)
{
case "Attack":
case "Skill":
PlayLungeAnimation(mainTransform, adjustedDuration, onComplete);
break;
case "Hit":
PlayShakeAnimation(mainTransform, adjustedDuration, onComplete);
break;
default:
// 无特殊动效,短暂延迟后回 Idle
float holdDuration = DefaultActionHoldDuration * adjustedDuration;
_currentTween = DOVirtual.DelayedCall(holdDuration, () =>
{
ReturnToIdle();
onComplete?.Invoke();
});
break;
}
}
/// <summary>
/// 立即切回 Idle Sprite 并复位位置。
/// </summary>
public void ReturnToIdle()
{
KillCurrentTween();
if (_mainSpriteRenderer != null && _idleSprite != null)
_mainSpriteRenderer.sprite = _idleSprite;
if (_mainSpriteRenderer != null)
_mainSpriteRenderer.transform.localPosition = _originalLocalPosition;
}
/// <summary>
/// 暂停或恢复当前 Tween。
/// </summary>
public void SetPause(bool isPaused)
{
if (_currentTween != null && _currentTween.IsActive())
{
if (isPaused) _currentTween.Pause();
else _currentTween.Play();
}
}
// ── 内部动效 ────────────────────────────────────────────────────────
private const float DefaultActionHoldDuration = 0.3f;
private void PlayLungeAnimation(Transform target, float durationScale, Action onComplete)
{
float duration = attackLungeDuration * durationScale;
Vector3 lungeOffset = target.right * attackLungeDistance;
_currentTween = DOTween.Sequence()
.Append(target.DOLocalMove(_originalLocalPosition + lungeOffset, duration).SetEase(Ease.OutQuad))
.Append(target.DOLocalMove(_originalLocalPosition, duration).SetEase(Ease.InQuad))
.OnComplete(() =>
{
ReturnToIdle();
onComplete?.Invoke();
});
}
private void PlayShakeAnimation(Transform target, float durationScale, Action onComplete)
{
float duration = hitShakeDuration * durationScale;
_currentTween = target.DOShakePosition(duration, hitShakeStrength, vibrato: 10, randomness: 90, fadeOut: true)
.OnComplete(() =>
{
target.localPosition = _originalLocalPosition;
ReturnToIdle();
onComplete?.Invoke();
});
}
private void KillCurrentTween()
{
if (_currentTween != null && _currentTween.IsActive())
{
_currentTween.Kill();
_currentTween = null;
}
}
private void OnDestroy()
{
KillCurrentTween();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 665a1fe9fd0c77c46b1a8567c185f7ab

View File

@@ -5,14 +5,26 @@ namespace Continentis.MainGame.Character
{
public partial class CombatNPC : CharacterBase
{
/// <summary>当前阶段编号,从 0 开始。Boss Logic 在 OnPhaseChange 中推进此值。</summary>
public int currentPhase;
public CombatNPC(CharacterData data, Fraction fraction) : base(data, fraction)
{
currentPhase = 0;
}
}
public partial class CombatNPC
{
/// <summary>
/// 阶段切换时由 <see cref="CharacterLogicBase.OnHealthThreshold"/> 调用。
/// 子类Boss Logic重写此方法以改变可用 Intention 集合、播放阶段动画等。
/// </summary>
public virtual void OnPhaseChange(int newPhase)
{
Debug.Log($"[Combat] {data.displayName} 进入阶段 {newPhase}");
// TODO: 阶段切换动画/台词,待动画系统完善后替换 Debug.Log
currentPhase = newPhase;
}
}
}