更新
This commit is contained in:
131
Assets/Scripts/MainGame/Base/AttackContext.cs
Normal file
131
Assets/Scripts/MainGame/Base/AttackContext.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Continentis.MainGame.Card;
|
||||
|
||||
namespace Continentis.MainGame
|
||||
{
|
||||
/// <summary>
|
||||
/// 攻击上下文,携带来源卡牌和标签信息。
|
||||
/// 替代 Attack() 方法中零散的 boolean 参数,提供可扩展的伤害标记体系。
|
||||
/// </summary>
|
||||
public class AttackContext
|
||||
{
|
||||
/// <summary>触发此次攻击的来源卡牌(可为 null)。</summary>
|
||||
public CardInstance sourceCard;
|
||||
|
||||
/// <summary>攻击标签集合,用于控制伤害计算与事件触发行为。</summary>
|
||||
public HashSet<string> tags;
|
||||
|
||||
/// <summary>
|
||||
/// 伤害属性关键词(如 "Physics"、"Fire"),驱动 offset 和元素乘区计算。
|
||||
/// 为 null 时回退到卡牌元素关键词(向后兼容)。
|
||||
/// </summary>
|
||||
public List<string> damageKeywords;
|
||||
|
||||
/// <summary>
|
||||
/// 基础伤害读取的属性名(如 "Damage_Physics")。
|
||||
/// 为 null 或空时默认读取 "Damage"。
|
||||
/// </summary>
|
||||
public string baseDamageAttributeName;
|
||||
|
||||
public AttackContext()
|
||||
{
|
||||
tags = new HashSet<string>();
|
||||
}
|
||||
|
||||
public AttackContext(CardInstance sourceCard) : this()
|
||||
{
|
||||
this.sourceCard = sourceCard;
|
||||
}
|
||||
|
||||
/// <summary>检查是否包含指定标签。</summary>
|
||||
public bool HasTag(string tag)
|
||||
{
|
||||
return tags != null && tags.Contains(tag);
|
||||
}
|
||||
|
||||
/// <summary>检查是否包含任意一个指定标签。</summary>
|
||||
public bool HasAnyTag(params string[] checkTags)
|
||||
{
|
||||
if (tags == null || tags.Count == 0) return false;
|
||||
return checkTags.Any(t => tags.Contains(t));
|
||||
}
|
||||
|
||||
/// <summary>链式添加标签。</summary>
|
||||
public AttackContext WithTag(string tag)
|
||||
{
|
||||
tags ??= new HashSet<string>();
|
||||
tags.Add(tag);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>链式添加多个标签。</summary>
|
||||
public AttackContext WithTags(params string[] newTags)
|
||||
{
|
||||
tags ??= new HashSet<string>();
|
||||
foreach (string tag in newTags)
|
||||
{
|
||||
tags.Add(tag);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>链式设置伤害属性关键词。</summary>
|
||||
public AttackContext WithDamageKeywords(params string[] keywords)
|
||||
{
|
||||
damageKeywords = new List<string>(keywords);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>链式设置基础伤害属性名。</summary>
|
||||
public AttackContext WithBaseDamageAttribute(string attributeName)
|
||||
{
|
||||
baseDamageAttributeName = attributeName;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>创建一个携带来源卡牌的默认上下文。</summary>
|
||||
public static AttackContext Default(CardInstance card = null)
|
||||
{
|
||||
return new AttackContext(card);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预定义的攻击标签常量。
|
||||
/// Mod 开发者可以使用自定义字符串扩展标签体系,无需修改此类。
|
||||
/// </summary>
|
||||
public static class AttackTags
|
||||
{
|
||||
/// <summary>
|
||||
/// 完全静默攻击:不触发任何攻击事件(角色 EventSubmodule + Buff 层均跳过)。
|
||||
/// 等价于旧 API 的 triggerAttackEvent = false。
|
||||
/// </summary>
|
||||
public const string Silent = "Silent";
|
||||
|
||||
/// <summary>
|
||||
/// 响应式攻击:由 Buff 被动效果产生的附带攻击。
|
||||
/// 触发角色 EventSubmodule 事件,但跳过 Buff 层的 onDealAttack/onGetAttacked,
|
||||
/// 从机制上杜绝 Buff 触发链的无限递归。
|
||||
/// </summary>
|
||||
public const string Reactive = "Reactive";
|
||||
|
||||
/// <summary>
|
||||
/// 生命移除:无视闪避、格挡、护盾,不触发任何事件。
|
||||
/// 直接对目标造成生命值扣减,类似 Dota 2 的 HP Removal。
|
||||
/// </summary>
|
||||
public const string HpRemoval = "HpRemoval";
|
||||
|
||||
/// <summary>
|
||||
/// 反弹伤害:由反弹/反伤机制产生的伤害。
|
||||
/// Buff 可检查此标签以避免"反弹的反弹"导致无限循环。
|
||||
/// </summary>
|
||||
public const string Reflected = "Reflected";
|
||||
|
||||
/// <summary>必中,无视闪避。</summary>
|
||||
public const string GuaranteedHit = "GuaranteedHit";
|
||||
|
||||
/// <summary>穿透,无视格挡。</summary>
|
||||
public const string Penetrating = "Penetrating";
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/MainGame/Base/AttackContext.cs.meta
Normal file
2
Assets/Scripts/MainGame/Base/AttackContext.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a9deb152fc38db9458198d27672d8a30
|
||||
@@ -30,6 +30,9 @@ namespace Continentis.MainGame
|
||||
public GameObject inspectionCardObject;
|
||||
public SerializableDictionary<string, Sprite> intentionMarkIcons;
|
||||
public SerializableDictionary<string, CardViewCollection> cardViewCollections;
|
||||
public Sprite defaultCardImage;
|
||||
|
||||
[Header("Buffs")] public Sprite defaultBuffIcon;
|
||||
|
||||
[Header("GeneralUI")] public GameObject customImage;
|
||||
public GameObject informationBox;
|
||||
@@ -61,7 +64,7 @@ namespace Continentis.MainGame
|
||||
|
||||
public partial class BasePrefabs
|
||||
{
|
||||
public DamageNumber GenerateHurtText(int amount, CombatCharacterViewBase characterView)
|
||||
public DamageNumber GenerateHurtText(int amount, CombatCharacterViewBase characterView, Color color)
|
||||
{
|
||||
Vector3 position = characterView.transform.position + Vector3.up * 0.5f;
|
||||
|
||||
@@ -70,14 +73,15 @@ namespace Continentis.MainGame
|
||||
position = characterView.numbersPivot.position;
|
||||
}
|
||||
|
||||
DamageNumber infoText = GenerateHurtText(amount, position);
|
||||
DamageNumber infoText = GenerateHurtText(amount, position, color);
|
||||
return infoText;
|
||||
}
|
||||
|
||||
public DamageNumber GenerateHurtText(int amount, Vector3 position)
|
||||
public DamageNumber GenerateHurtText(int amount, Vector3 position, Color color)
|
||||
{
|
||||
DamageNumber hurtText = GenerateCombatText(hurtDamageNumber, position);
|
||||
hurtText.number = amount;
|
||||
hurtText.SetColor(color);
|
||||
return hurtText;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,16 +33,16 @@ namespace Continentis.MainGame
|
||||
public const string ManaRecoverPerAction = "ManaRecoverPerAction";
|
||||
|
||||
// ── 防御 ────────────────────────────────────
|
||||
public const string Block = "Block";
|
||||
public const string Shield = "Shield";
|
||||
public const string Dodge = "Dodge";
|
||||
public const string Block = "Block";
|
||||
public const string TemporaryHealth = "TemporaryHealth";
|
||||
public const string Dodge = "Dodge";
|
||||
|
||||
public const string BlockGainOffset = "BlockGainOffset";
|
||||
public const string BlockGainMultiplier = "BlockGainMultiplier";
|
||||
public const string DodgeGainOffset = "DodgeGainOffset";
|
||||
public const string DodgeGainMultiplier = "DodgeGainMultiplier";
|
||||
public const string ShieldGainOffset = "ShieldGainOffset";
|
||||
public const string ShieldGainMultiplier = "ShieldGainMultiplier";
|
||||
public const string BlockGainOffset = "BlockGainOffset";
|
||||
public const string BlockGainMultiplier = "BlockGainMultiplier";
|
||||
public const string DodgeGainOffset = "DodgeGainOffset";
|
||||
public const string DodgeGainMultiplier = "DodgeGainMultiplier";
|
||||
public const string TemporaryHealthGainOffset = "TemporaryHealthGainOffset";
|
||||
public const string TemporaryHealthGainMultiplier = "TemporaryHealthGainMultiplier";
|
||||
|
||||
public const string KeepBlockOnActionStart = "KeepBlockOnActionStart";
|
||||
public const string KeepDodgeOnActionStart = "KeepDodgeOnActionStart";
|
||||
|
||||
@@ -33,9 +33,12 @@ namespace Continentis.MainGame
|
||||
TextInterpreter.SetFunction("Attribute", new Func<string, bool, string>((name, high) => GetAttribute(card, name, high, false)));
|
||||
TextInterpreter.SetFunction("Attribute", new Func<string, bool, bool, string>((name, high, percent) => GetAttribute(card, name, high, percent)));
|
||||
|
||||
string result = DynamicTextInterpreter.Parse(TextInterpreter, textToInterpret);
|
||||
// 第一阶段:根据卡牌上下文(持有者、目标等)裁剪条件标签块
|
||||
string resolved = CardTextTagResolver.Resolve(card, textToInterpret);
|
||||
|
||||
// 第二阶段:Dynamic Expresso 求值 $Attribute / $Keyword 等表达式
|
||||
string result = DynamicTextInterpreter.Parse(TextInterpreter, resolved);
|
||||
|
||||
Debug.Log($"Interpreted Text: {result}");
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
144
Assets/Scripts/MainGame/Base/Interpreters/CardTextTagResolver.cs
Normal file
144
Assets/Scripts/MainGame/Base/Interpreters/CardTextTagResolver.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
using Continentis.MainGame.Card;
|
||||
using Continentis.MainGame.Character;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Continentis.MainGame
|
||||
{
|
||||
/// <summary>
|
||||
/// 卡牌文本条件标签解析器。
|
||||
/// 在 Dynamic Expresso 求值之前,根据卡牌的上下文(持有者、目标等)裁剪条件标签块。
|
||||
///
|
||||
/// 支持的标签:
|
||||
/// [Showcase]...[/Showcase] 仅在展示界面(无持有者)显示
|
||||
/// [Owner: PlayerHero]...[/Owner] 持有者为 PlayerHero 时显示
|
||||
/// [Owner: CombatNPC]...[/Owner] 持有者为 CombatNPC 时显示
|
||||
/// [Target: PlayerHero]...[/Target] 目标为 PlayerHero 时显示
|
||||
/// [Target: CombatNPC]...[/Target] 目标为 CombatNPC 时显示
|
||||
///
|
||||
/// 解析规则:
|
||||
/// - 文本无任何标签 → 原样返回
|
||||
/// - 无持有者(展示界面) → 仅显示 [Showcase],剥离 [Owner] / [Target]
|
||||
/// - 有持有者,无目标 → 按持有者类型反推默认目标(PlayerHero → CombatNPC, 反之亦然)
|
||||
/// - 有持有者,有目标 → 按实际目标类型匹配 [Target]
|
||||
/// </summary>
|
||||
public static class CardTextTagResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// 匹配条件标签块。支持两种格式:
|
||||
/// 带参数:[Category: Value]content[/Category]
|
||||
/// 无参数:[Showcase]content[/Showcase]
|
||||
/// </summary>
|
||||
private static readonly Regex TagPattern = new Regex(
|
||||
@"\[(?<category>Showcase|Owner|Target)(?:\s*:\s*(?<value>\w+))?\](?<content>.*?)\[/\k<category>\]",
|
||||
RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 解析文本中的条件标签块,根据卡牌上下文保留或移除。
|
||||
/// 如果文本中没有任何标签,原样返回。
|
||||
/// </summary>
|
||||
public static string Resolve(CardInstance card, string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return text;
|
||||
if (!TagPattern.IsMatch(text)) return text;
|
||||
|
||||
CharacterBase owner = card.owner as CharacterBase;
|
||||
CharacterBase target = card.currentTextTarget;
|
||||
bool isShowcase = (owner == null);
|
||||
|
||||
return TagPattern.Replace(text, match =>
|
||||
{
|
||||
string category = match.Groups["category"].Value;
|
||||
string value = match.Groups["value"].Success ? match.Groups["value"].Value : null;
|
||||
string content = match.Groups["content"].Value;
|
||||
|
||||
switch (category.ToLower())
|
||||
{
|
||||
case "showcase":
|
||||
return ResolveShowcase(isShowcase, content);
|
||||
case "owner":
|
||||
return ResolveOwner(owner, value, content);
|
||||
case "target":
|
||||
return ResolveTarget(owner, target, value, content);
|
||||
default:
|
||||
Debug.LogWarning($"[CardTextTagResolver] Unknown tag category: '{category}'.");
|
||||
return string.Empty;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [Showcase] 标签:仅在展示界面(无持有者)显示。
|
||||
/// </summary>
|
||||
private static string ResolveShowcase(bool isShowcase, string content)
|
||||
{
|
||||
return isShowcase ? content : string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [Owner: X] 标签:根据持有者类型匹配。展示界面下不显示。
|
||||
/// </summary>
|
||||
private static string ResolveOwner(CharacterBase owner, string value, string content)
|
||||
{
|
||||
if (owner == null) return string.Empty;
|
||||
return MatchesCharacterType(owner, value) ? content : string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [Target: X] 标签:根据目标类型匹配。
|
||||
/// - 展示界面:不显示
|
||||
/// - 有明确目标:按实际目标匹配
|
||||
/// - 无明确目标:按持有者反推默认目标(PlayerHero → CombatNPC,反之亦然)
|
||||
/// </summary>
|
||||
private static string ResolveTarget(CharacterBase owner, CharacterBase target, string value, string content)
|
||||
{
|
||||
if (owner == null) return string.Empty;
|
||||
|
||||
// 有明确目标时,按实际类型匹配
|
||||
if (target != null)
|
||||
{
|
||||
return MatchesCharacterType(target, value) ? content : string.Empty;
|
||||
}
|
||||
|
||||
// 无明确目标时,按持有者类型反推默认目标
|
||||
if (owner is PlayerHero)
|
||||
{
|
||||
return string.Equals(value, "CombatNPC", StringComparison.OrdinalIgnoreCase) ? content : string.Empty;
|
||||
}
|
||||
|
||||
if (owner is CombatNPC)
|
||||
{
|
||||
return string.Equals(value, "PlayerHero", StringComparison.OrdinalIgnoreCase) ? content : string.Empty;
|
||||
}
|
||||
|
||||
Debug.LogWarning($"[CardTextTagResolver] Cannot infer default target for owner type '{owner.GetType().Name}'.");
|
||||
return content; // 未知持有者类型,保留内容作为兜底
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查角色是否匹配指定的类型名。
|
||||
/// </summary>
|
||||
private static bool MatchesCharacterType(CharacterBase character, string typeName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(typeName))
|
||||
{
|
||||
Debug.LogWarning("[CardTextTagResolver] Tag value is empty for Owner/Target tag.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeName.ToLower() switch
|
||||
{
|
||||
"playerhero" => character is PlayerHero,
|
||||
"combatnpc" => character is CombatNPC,
|
||||
_ => LogUnknownType(typeName)
|
||||
};
|
||||
}
|
||||
|
||||
private static bool LogUnknownType(string typeName)
|
||||
{
|
||||
Debug.LogWarning($"[CardTextTagResolver] Unknown character type: '{typeName}'.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ec0a58018fe36a1469ea1ee568a662e0
|
||||
@@ -99,8 +99,7 @@ namespace Continentis.MainGame
|
||||
}
|
||||
else
|
||||
{
|
||||
Texture2D defaultTex = ModManager.GetAsset<Texture2D>("BuffIcon_Basic_Default");
|
||||
this.icon = SpriteExtension.Create(defaultTex);
|
||||
this.icon = MainGameManager.Instance.basePrefabs.defaultBuffIcon;
|
||||
}
|
||||
|
||||
if (buff.contentSubmodule != null)
|
||||
@@ -286,6 +285,17 @@ namespace Continentis.MainGame
|
||||
maximumCount = Mathf.Max(maximumCount, other.maximumCount);
|
||||
remainingCount = Mathf.Max(remainingCount, other.remainingCount);
|
||||
}
|
||||
|
||||
public void PickHigherCount(int maximumCount, int remainingCount)
|
||||
{
|
||||
if (isInfinite)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this.maximumCount = Mathf.Max(this.maximumCount, maximumCount);
|
||||
this.remainingCount = Mathf.Max(this.remainingCount, remainingCount);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,32 @@ namespace Continentis.MainGame.Card
|
||||
{
|
||||
return contentSubmodule.keywords.Contains(keyword);
|
||||
}
|
||||
|
||||
public bool HasAnyKeyword(params string[] keywords)
|
||||
{
|
||||
bool hasAny = false;
|
||||
|
||||
foreach (var keyword in keywords)
|
||||
{
|
||||
hasAny = HasKeyword(keyword);
|
||||
if (hasAny) break;
|
||||
}
|
||||
|
||||
return hasAny;
|
||||
}
|
||||
|
||||
public bool HasAllKeywords(params string[] keywords)
|
||||
{
|
||||
bool hasAll = true;
|
||||
|
||||
foreach (var keyword in keywords)
|
||||
{
|
||||
hasAll = HasKeyword(keyword);
|
||||
if (!hasAll) break;
|
||||
}
|
||||
|
||||
return hasAll;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
public static class CardAttributes
|
||||
{
|
||||
/// <summary> 体力值消耗 </summary>
|
||||
public const string StaminaCost = "StaminaCost";
|
||||
public const string StaminaCost = "Stamina_Cost";
|
||||
|
||||
/// <summary> 魔力值消耗 </summary>
|
||||
public const string ManaCost = "ManaCost";
|
||||
public const string ManaCost = "Mana_Cost";
|
||||
|
||||
/// <summary> 目标数量(0为自身,-1为全体) </summary>
|
||||
public const string TargetCount = "TargetCount";
|
||||
public const string TargetCount = "Target_Count";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,9 +69,10 @@ namespace Continentis.MainGame.Card
|
||||
[LabelText("功能文本 Key")]
|
||||
public string functionText;
|
||||
|
||||
[FormerlySerializedAs("cardDescription")]
|
||||
[BoxGroup("Display"), PropertyOrder(9)]
|
||||
[LabelText("卡牌描述 Key")]
|
||||
public string cardDescription;
|
||||
public string descriptionText;
|
||||
|
||||
[BoxGroup("Display"), PropertyOrder(10)]
|
||||
[ListDrawerSettings(ShowIndexLabels = false), LabelText("布局标签")]
|
||||
|
||||
@@ -26,6 +26,12 @@ namespace Continentis.MainGame.Card
|
||||
public CharacterBase user;
|
||||
public CombatTeam usingTeam;
|
||||
|
||||
/// <summary>
|
||||
/// 当前用于文本条件标签解析的目标角色。
|
||||
/// 玩家拖拽卡牌时由 Targeting/Untargeting 设置;NPC 意图创建时由意图系统设置。
|
||||
/// </summary>
|
||||
public CharacterBase currentTextTarget;
|
||||
|
||||
public CardLogicBase cardLogic;
|
||||
public int upgradeLevel;
|
||||
|
||||
@@ -117,18 +123,8 @@ namespace Continentis.MainGame.Card
|
||||
.UpdateTeamPileText(CombatMainManager.Instance.characterController.playerTeam);
|
||||
}
|
||||
|
||||
//下面的部分后续放入CardLogic的初始化函数中
|
||||
card.RefreshCardAttributes();
|
||||
|
||||
if (card.HasKeyword("Instant")) //如果是“瞬发”牌,添加抽牌后立刻打出的事件
|
||||
{
|
||||
card.eventSubmodule.onDraw.InsertByPriority("Instant", new PrioritizedAction(() =>
|
||||
{
|
||||
card.DetectTargetsValidity(out List<CharacterBase> valid, out _, out _);
|
||||
card.Play(card.SetRandomTargets(valid), card.user);
|
||||
}, 99));
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -250,4 +246,4 @@ namespace Continentis.MainGame.Card
|
||||
this.index = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
|
||||
/// <summary> 可选目标为全体角色 </summary>
|
||||
public const string TargetAll = "TargetAll";
|
||||
|
||||
/// <summary> 多目标选择时允许复选 </summary>
|
||||
public const string AllowDuplicateTargets = "AllowDuplicateTargets";
|
||||
|
||||
/// <summary> 物理 </summary>
|
||||
public const string Physics = "Physics";
|
||||
@@ -24,6 +27,27 @@
|
||||
/// <summary> 魔法 </summary>
|
||||
public const string Magic = "Magic";
|
||||
|
||||
/// <summary> 风 </summary>
|
||||
public const string Wind = "Wind";
|
||||
|
||||
/// <summary> 火 </summary>
|
||||
public const string Fire = "Fire";
|
||||
|
||||
/// <summary> 冰 </summary>
|
||||
public const string Ice = "Ice";
|
||||
|
||||
/// <summary> 土 </summary>
|
||||
public const string Earth = "Earth";
|
||||
|
||||
/// <summary> 雷 </summary>
|
||||
public const string Storm = "Storm";
|
||||
|
||||
/// <summary> 光 </summary>
|
||||
public const string Light = "Light";
|
||||
|
||||
/// <summary> 暗 </summary>
|
||||
public const string Darkness = "Darkness";
|
||||
|
||||
/// <summary> 打击(力量) </summary>
|
||||
public const string Strike = "Strike";
|
||||
|
||||
@@ -74,5 +98,8 @@
|
||||
|
||||
/// <summary> 复用(打出后回到手牌) </summary>
|
||||
public const string Reuse = "Reuse";
|
||||
|
||||
/// <summary> 先决(在手牌中时,必须先打出此牌) </summary>
|
||||
public const string Prerequisite = "Prerequisite";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Continentis.MainGame.Character;
|
||||
using Continentis.MainGame.Equipment;
|
||||
using SoftCircuits.Collections;
|
||||
using SLSFramework.General;
|
||||
using SLSFramework.UModAssistance;
|
||||
using UnityEngine;
|
||||
@@ -36,14 +37,129 @@ namespace Continentis.MainGame.Card
|
||||
return null;
|
||||
}
|
||||
|
||||
// 存储所有通过 SubscribeCombatEvent 注册的订阅的移除委托
|
||||
private readonly List<Action> _managedUnsubscribers = new List<Action>();
|
||||
|
||||
public virtual void Initialize(CardInstance cardInstance)
|
||||
{
|
||||
card = cardInstance;
|
||||
logicComponents = new HashSet<CardLogicComponentBase>();
|
||||
card.eventSubmodule.onTargeting += TargetingEffect;
|
||||
card.eventSubmodule.onUntargeting += UntargetingEffect;
|
||||
|
||||
// 自动将卡牌事件子模块的生命周期钩子接入虚方法
|
||||
card.eventSubmodule.onDraw.InsertByPriority(
|
||||
$"{GetType().Name}_OnDraw_{GetHashCode()}",
|
||||
new PrioritizedAction(OnDraw));
|
||||
|
||||
card.eventSubmodule.onCombatStart.InsertByPriority(
|
||||
$"{GetType().Name}_OnCombatStart_{GetHashCode()}",
|
||||
new PrioritizedAction(OnCombatStart));
|
||||
|
||||
card.eventSubmodule.onCombatEnd.InsertByPriority(
|
||||
$"{GetType().Name}_OnCombatEnd_{GetHashCode()}",
|
||||
new PrioritizedAction(OnCombatEnd));
|
||||
|
||||
card.eventSubmodule.onRoundStart.InsertByPriority(
|
||||
$"{GetType().Name}_OnRoundStart_{GetHashCode()}",
|
||||
new PrioritizedAction(OnRoundStart));
|
||||
|
||||
card.eventSubmodule.onRoundEnd.InsertByPriority(
|
||||
$"{GetType().Name}_OnRoundEnd_{GetHashCode()}",
|
||||
new PrioritizedAction(OnRoundEnd));
|
||||
|
||||
card.eventSubmodule.onActionStart.InsertByPriority(
|
||||
$"{GetType().Name}_OnActionStart_{GetHashCode()}",
|
||||
new PrioritizedAction(OnActionStart));
|
||||
|
||||
card.eventSubmodule.onActionEnd.InsertByPriority(
|
||||
$"{GetType().Name}_OnActionEnd_{GetHashCode()}",
|
||||
new PrioritizedAction(OnActionEnd));
|
||||
|
||||
// 关键词驱动的行为统一在此处注册
|
||||
if (card.HasKeyword("Instant"))
|
||||
{
|
||||
//含有Instant关键词,抽到后直接打出
|
||||
card.eventSubmodule.onDraw.InsertByPriority("Instant", new PrioritizedAction(() =>
|
||||
{
|
||||
card.DetectTargetsValidity(out List<CharacterBase> valid, out _, out _);
|
||||
card.Play(card.SetRandomTargets(valid), card.user);
|
||||
}, 99));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 向战斗全局事件字典注册一个托管订阅。
|
||||
/// Dispose() 时无需手动取消——基类会自动移除所有通过此方法注册的订阅。
|
||||
/// </summary>
|
||||
protected void SubscribeCombatEvent(
|
||||
OrderedDictionary<string, PrioritizedAction> eventDict,
|
||||
PrioritizedAction action,
|
||||
int priority = 0)
|
||||
{
|
||||
string key = $"{GetType().Name}_{GetHashCode()}_{_managedUnsubscribers.Count}";
|
||||
action.Priority = priority;
|
||||
eventDict.InsertByPriority(key, action);
|
||||
_managedUnsubscribers.Add(() => eventDict.Remove(key));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 向战斗全局事件字典注册一个带参数的托管订阅。
|
||||
/// Dispose() 时无需手动取消——基类会自动移除所有通过此方法注册的订阅。
|
||||
/// </summary>
|
||||
protected void SubscribeCombatEvent<T>(
|
||||
OrderedDictionary<string, PrioritizedAction<T>> eventDict,
|
||||
PrioritizedAction<T> action,
|
||||
int priority = 0)
|
||||
{
|
||||
string key = $"{GetType().Name}_{GetHashCode()}_{_managedUnsubscribers.Count}";
|
||||
action.Priority = priority;
|
||||
eventDict.InsertByPriority(key, action);
|
||||
_managedUnsubscribers.Add(() => eventDict.Remove(key));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 卡牌销毁时调用(打出、弃牌、消耗)。
|
||||
/// 自动清理所有通过 SubscribeCombatEvent 注册的托管订阅。
|
||||
/// 子类重写时无需调用 base.Dispose(),除非有额外资源需要释放。
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (Action unsubscribe in _managedUnsubscribers)
|
||||
unsubscribe();
|
||||
_managedUnsubscribers.Clear();
|
||||
OnDispose();
|
||||
}
|
||||
|
||||
// ── 生命周期虚方法 ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>抽到此卡牌时调用。</summary>
|
||||
protected virtual void OnDraw() { }
|
||||
|
||||
/// <summary>战斗开始时调用。</summary>
|
||||
protected virtual void OnCombatStart() { }
|
||||
|
||||
/// <summary>战斗结束时调用。</summary>
|
||||
protected virtual void OnCombatEnd() { }
|
||||
|
||||
/// <summary>每回合开始时调用。</summary>
|
||||
protected virtual void OnRoundStart() { }
|
||||
|
||||
/// <summary>每回合结束时调用。</summary>
|
||||
protected virtual void OnRoundEnd() { }
|
||||
|
||||
/// <summary>每次行动开始时调用。</summary>
|
||||
protected virtual void OnActionStart() { }
|
||||
|
||||
/// <summary>每次行动结束时调用。</summary>
|
||||
protected virtual void OnActionEnd() { }
|
||||
|
||||
/// <summary>
|
||||
/// 卡牌销毁时的扩展清理钩子。
|
||||
/// 子类有额外资源需要释放时重写此方法,无需处理 SubscribeCombatEvent 的取消订阅。
|
||||
/// </summary>
|
||||
protected virtual void OnDispose() { }
|
||||
|
||||
public virtual void SetUpLogicComponents() { }
|
||||
|
||||
public T AddLogicComponent<T>() where T : CardLogicComponentBase, new()
|
||||
@@ -233,43 +349,66 @@ namespace Continentis.MainGame.Card
|
||||
#region Attack
|
||||
public partial class CardLogicBase
|
||||
{
|
||||
/// <summary>获取对指定目标的最终伤害值。</summary>
|
||||
public virtual int GetTargetedFinalDamage(CharacterBase target, List<string> elementalTags = null)
|
||||
/// <summary>
|
||||
/// 以当前卡牌作为来源,对目标发动攻击。
|
||||
/// 内部自动构建携带 sourceCard 的 AttackContext,确保 Buff 层能正确识别来源卡牌信息。
|
||||
/// 卡牌脚本中所有的攻击调用都应优先使用此方法,而非直接调用 user.Attack()。
|
||||
/// </summary>
|
||||
protected AttackResult AttackTarget(CharacterBase target, int damage, AttackContext ctx = null)
|
||||
{
|
||||
return GetFinalDamage(target, elementalTags, out _, out _, out _, out _);
|
||||
ctx ??= new AttackContext(card);
|
||||
if (ctx.sourceCard == null) ctx.sourceCard = card;
|
||||
return user.Attack(target, damage, ctx);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取对指定目标的最终伤害值。
|
||||
/// ctx 中的 damageKeywords 驱动 offset 和元素乘区计算,baseDamageAttributeName 指定基础伤害属性名。
|
||||
/// ctx 为 null 时回退到卡牌元素关键词和默认 "Damage" 属性(向后兼容)。
|
||||
/// </summary>
|
||||
public virtual int GetTargetedFinalDamage(CharacterBase target, AttackContext ctx = null)
|
||||
{
|
||||
return GetFinalDamage(target, ctx, out _, out _, out _, out _);
|
||||
}
|
||||
|
||||
/// <summary>获取无目标时的最终伤害值。</summary>
|
||||
public virtual int GetNoTargetFinalDamage(List<string> elementalTags = null)
|
||||
public virtual int GetNoTargetFinalDamage(AttackContext ctx = null)
|
||||
{
|
||||
return GetFinalDamage(null, elementalTags, out _, out _, out _, out _);
|
||||
return GetFinalDamage(null, ctx, out _, out _, out _, out _);
|
||||
}
|
||||
|
||||
protected virtual int GetFinalDamage(
|
||||
CharacterBase target, List<string> elementalTags,
|
||||
CharacterBase target, AttackContext ctx,
|
||||
out float baseDamageAfterOffset, out float elementalMultiplier,
|
||||
out float magicMultiplier, out float finalMultiplier)
|
||||
{
|
||||
bool haveTarget = target != null;
|
||||
elementalTags ??= card.GetElementalKeywords();
|
||||
|
||||
// 从 AttackContext 中读取伤害关键词和属性名,null 时回退到卡牌默认值
|
||||
List<string> damageKeywords = ctx?.damageKeywords ?? card.GetElementalKeywords();
|
||||
string baseDamageAttr = ctx?.baseDamageAttributeName;
|
||||
|
||||
// Physics / Magic offset 由 damageKeywords 驱动,与卡牌标记关键词无关
|
||||
int physicsOffset = 0;
|
||||
if (card.HasKeyword("Physics") || card.HasKeyword("Slash") || card.HasKeyword("Prick") || card.HasKeyword("Strike"))
|
||||
physicsOffset = user.GetAttribute("PhysicsDamageDealtOffset");
|
||||
if (damageKeywords.Contains("Physics"))
|
||||
physicsOffset = user.GetAttribute(CharacterAttributes.PhysicsDamageDealtOffset);
|
||||
|
||||
int magicOffset = 0;
|
||||
if (card.HasKeyword("Magic") || card.HasKeyword("Arcane") || card.HasKeyword("Sorcery"))
|
||||
magicOffset = user.GetAttribute("MagicDamageDealtOffset");
|
||||
if (damageKeywords.Contains("Magic"))
|
||||
magicOffset = user.GetAttribute(CharacterAttributes.MagicDamageDealtOffset);
|
||||
|
||||
// 元素乘区:遍历 damageKeywords 中属于 elementTags 的部分
|
||||
elementalMultiplier = 1f;
|
||||
foreach (string element in elementalTags)
|
||||
foreach (string keyword in damageKeywords)
|
||||
{
|
||||
float targetGain = haveTarget ? target.GetRawAttribute(element + "DamageGainMultiplier", 1f) : 1f;
|
||||
elementalMultiplier *= user.GetRawAttribute(element + "DamageDealtMultiplier", 1f) * targetGain;
|
||||
if (!MainGameManager.Instance.elementTags.Contains(keyword)) continue;
|
||||
float targetGain = haveTarget ? target.GetRawAttribute(keyword + "DamageGainMultiplier", 1f) : 1f;
|
||||
elementalMultiplier *= user.GetRawAttribute(keyword + "DamageDealtMultiplier", 1f) * targetGain;
|
||||
}
|
||||
|
||||
// 魔法乘区:由 damageKeywords 中含 Magic/Arcane/Sorcery 时触发
|
||||
magicMultiplier = 1f;
|
||||
if (card.HasKeyword("Magic") || card.HasKeyword("Arcane") || card.HasKeyword("Sorcery"))
|
||||
if (damageKeywords.Contains("Magic") || damageKeywords.Contains("Arcane") || damageKeywords.Contains("Sorcery"))
|
||||
{
|
||||
float targetGain = haveTarget ? target.GetRawAttribute("MagicDamageGainMultiplier", 1f) : 1f;
|
||||
magicMultiplier = user.GetRawAttribute("MagicDamageDealtMultiplier", 1f) * targetGain;
|
||||
@@ -278,7 +417,8 @@ namespace Continentis.MainGame.Card
|
||||
float targetFinalGain = haveTarget ? target.GetRawAttribute("FinalDamageGainMultiplier", 1f) : 1f;
|
||||
finalMultiplier = user.GetRawAttribute("FinalDamageDealtMultiplier", 1f) * targetFinalGain;
|
||||
|
||||
baseDamageAfterOffset = card.attributeSubmodule.GetCurrentAttribute("Damage") + physicsOffset + magicOffset;
|
||||
string damageAttr = string.IsNullOrEmpty(baseDamageAttr) ? "Damage" : baseDamageAttr;
|
||||
baseDamageAfterOffset = card.attributeSubmodule.GetCurrentAttribute(damageAttr) + physicsOffset + magicOffset;
|
||||
float finalDamage = baseDamageAfterOffset * elementalMultiplier * magicMultiplier * finalMultiplier;
|
||||
|
||||
return Mathf.RoundToInt(finalDamage);
|
||||
@@ -358,6 +498,17 @@ namespace Continentis.MainGame.Card
|
||||
|
||||
/// <summary>取消选中目标时触发的效果(在逻辑组件的 Untargeting 之前执行)。</summary>
|
||||
public virtual void UntargetingEffect() { }
|
||||
|
||||
/// <summary>
|
||||
/// 标记 hint shadow 在下一帧刷新,不触发文本重解析。
|
||||
/// 子类在战场状态变化时调用此方法,而非直接操作 dirtyMark。
|
||||
/// </summary>
|
||||
protected void InvalidateHint() => card.contentSubmodule.hintDirtyMark = true;
|
||||
/// 返回 null 表示不显示提示阴影;返回具体颜色则启用对应颜色的 hintShadow。
|
||||
/// 此方法在 ContentSubmodule.RefreshContent() 时自动调用,
|
||||
/// 子类可重写以实现"有可用目标时绿色/无可用目标时红色"等动态提示。
|
||||
/// </summary>
|
||||
public virtual Color? GetHintColor() => null;
|
||||
}
|
||||
|
||||
/// <summary>卡牌逻辑组件基类。</summary>
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace Continentis.MainGame.Card
|
||||
/// </summary>
|
||||
public virtual void Targeting(CharacterBase target)
|
||||
{
|
||||
currentTextTarget = target;
|
||||
eventSubmodule.onTargeting.Invoke(target);
|
||||
}
|
||||
|
||||
@@ -29,6 +30,7 @@ namespace Continentis.MainGame.Card
|
||||
/// </summary>
|
||||
public virtual void Untargeting()
|
||||
{
|
||||
currentTextTarget = null;
|
||||
eventSubmodule.onUntargeting.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -136,11 +138,26 @@ namespace Continentis.MainGame.Card
|
||||
}
|
||||
else
|
||||
{
|
||||
while (targets.Count < maximumTargets && valid.Count > 0)
|
||||
bool allowDuplicate = HasKeyword(CardKeywords.AllowDuplicateTargets);
|
||||
|
||||
if (allowDuplicate)
|
||||
{
|
||||
CharacterBase target = valid[Random.Range(0, valid.Count)];
|
||||
valid.Remove(target);
|
||||
targets.Add(target);
|
||||
// 放回抽样:可重复选中同一目标
|
||||
for (int i = 0; i < maximumTargets; i++)
|
||||
{
|
||||
targets.Add(valid[Random.Range(0, valid.Count)]);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 不放回抽样(原逻辑)
|
||||
List<CharacterBase> pool = new List<CharacterBase>(valid);
|
||||
while (targets.Count < maximumTargets && pool.Count > 0)
|
||||
{
|
||||
CharacterBase target = pool[Random.Range(0, pool.Count)];
|
||||
pool.Remove(target);
|
||||
targets.Add(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,13 +166,13 @@ namespace Continentis.MainGame.Card
|
||||
|
||||
public virtual bool CheckBeforePlay()
|
||||
{
|
||||
if (!user.CheckEnoughStamina(GetAttribute("StaminaCost")))
|
||||
if (!user.CheckEnoughStamina(GetAttribute(CardAttributes.StaminaCost)))
|
||||
{
|
||||
MainGameManager.Instance.basePrefabs.GenerateInfoText("Not Enough Stamina", user.characterView);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!user.CheckEnoughMana(GetAttribute("ManaCost")))
|
||||
if (!user.CheckEnoughMana(GetAttribute(CardAttributes.ManaCost)))
|
||||
{
|
||||
MainGameManager.Instance.basePrefabs.GenerateInfoText("Not Enough Mana", user.characterView);
|
||||
return false;
|
||||
@@ -192,13 +209,13 @@ namespace Continentis.MainGame.Card
|
||||
{
|
||||
if (!noConsumption)
|
||||
{
|
||||
this.user.ModifyStamina(-GetAttribute("StaminaCost"));
|
||||
this.user.ModifyMana(-GetAttribute("ManaCost"));
|
||||
this.user.ModifyStamina(-GetAttribute(CardAttributes.StaminaCost));
|
||||
this.user.ModifyMana(-GetAttribute(CardAttributes.ManaCost));
|
||||
}
|
||||
|
||||
Debug.Log($"Starting to play card: {contentSubmodule.cardName}");
|
||||
|
||||
CommandQueueManager.Instance.AddCommand(new Cmd_Function(() =>
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(() =>
|
||||
{
|
||||
playSubmodule.isDuringPlayEffect = true;
|
||||
eventSubmodule.onBeforePlay.Invoke(targetList);
|
||||
@@ -211,7 +228,7 @@ namespace Continentis.MainGame.Card
|
||||
|
||||
CommandQueueManager.Instance.AddCommand(PlayEffect(targetList));
|
||||
CommandQueueManager.Instance.AddCommand(cardLogic.PlayEffect(targetList));
|
||||
CommandQueueManager.Instance.AddCommand(new Cmd_Function(() =>
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(() =>
|
||||
{
|
||||
eventSubmodule.onAfterPlay.Invoke(targetList);
|
||||
combatBuffSubmodule.buffList.For(buff => buff.usageSubmodule?.UpdateModule());
|
||||
@@ -308,6 +325,8 @@ namespace Continentis.MainGame.Card
|
||||
KeyValuePair<string, List<CardInstance>> currentPile = deck.GetCardLocation(this, out int index);
|
||||
if (!cardData.upgradeNode.isTerminalNode)
|
||||
{
|
||||
// 先 Dispose 旧 Logic,再替换,避免旧 Logic 的托管订阅泄漏
|
||||
cardLogic?.Dispose();
|
||||
DestroyHandCardView();
|
||||
|
||||
CardData newData = cardData.upgradeNode.upgradeCards[0]; //后续可改为选择升级方向
|
||||
|
||||
@@ -111,6 +111,6 @@ namespace Continentis.MainGame.Card
|
||||
|
||||
public partial class AttributeSubmodule
|
||||
{
|
||||
public int targetCount => GetRoundCurrentAttribute("TargetCount", -2);
|
||||
public int targetCount => GetRoundCurrentAttribute(CardAttributes.TargetCount, -2);
|
||||
}
|
||||
}
|
||||
@@ -22,15 +22,21 @@ namespace Continentis.MainGame.Card
|
||||
/// </summary>
|
||||
public bool dirtyMark;
|
||||
|
||||
/// <summary>
|
||||
/// 标记:hint shadow 颜色需要刷新,不触发文本重解析
|
||||
/// </summary>
|
||||
public bool hintDirtyMark;
|
||||
|
||||
public ContentSubmodule(CardInstance card) : base(card)
|
||||
{
|
||||
keywords = card.cardData.keywords;
|
||||
cardName = card.cardData.displayName.Localize();
|
||||
cardSprite = card.cardData.cardSprite;
|
||||
cardSprite = card.cardData.cardSprite ?? MainGameManager.Instance.basePrefabs.defaultCardImage;
|
||||
originalFunctionText = card.cardData.functionText.Localize();
|
||||
cardRarity = card.cardData.cardRarity;
|
||||
cardType = card.cardData.cardType;
|
||||
dirtyMark = false;
|
||||
hintDirtyMark = false;
|
||||
|
||||
Observable.EveryLateUpdate().Subscribe(_ =>
|
||||
{
|
||||
@@ -39,11 +45,12 @@ namespace Continentis.MainGame.Card
|
||||
RefreshContent();
|
||||
dirtyMark = false;
|
||||
}
|
||||
if (hintDirtyMark)
|
||||
{
|
||||
RefreshHintShadow();
|
||||
hintDirtyMark = false;
|
||||
}
|
||||
}).AddTo(card.disposables);
|
||||
|
||||
//CardDescriptionInterpreter.InterpretDescription(card);
|
||||
//keywords = CardDescriptionInterpreter.GetKeywords(card.cardData.cardDescription);
|
||||
//Debug.Log($"Extracted Keywords: {string.Join(", ", keywords)}");
|
||||
}
|
||||
|
||||
public void RefreshContent()
|
||||
@@ -51,6 +58,19 @@ namespace Continentis.MainGame.Card
|
||||
CardTextInterpreter.InterpretText(owner);
|
||||
owner.handCardView?.Setup();
|
||||
owner.intentionCardView?.Setup();
|
||||
|
||||
// 文本刷新后,hint 也需要同步更新
|
||||
hintDirtyMark = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 仅刷新 hint shadow 颜色,不触发文本重解析。
|
||||
/// </summary>
|
||||
public void RefreshHintShadow()
|
||||
{
|
||||
if (owner.handCardView == null || owner.handCardView.isSelecting) return;
|
||||
Color? hintColor = owner.cardLogic?.GetHintColor();
|
||||
owner.handCardView.UpdateHintShadow(hintColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ namespace Continentis.MainGame.Card
|
||||
|
||||
public bool isDuringPlaying;
|
||||
|
||||
private void Update()
|
||||
protected virtual void Update()
|
||||
{
|
||||
if (isHovering)
|
||||
{
|
||||
@@ -110,9 +110,9 @@ namespace Continentis.MainGame.Card
|
||||
cardTypeText.text = this.card.contentSubmodule.cardType.ToString();
|
||||
|
||||
staminaCostText.rectTransform.parent.gameObject.SetActive(true);
|
||||
staminaCostText.text = this.card.attributeSubmodule.GetRoundCurrentAttribute("StaminaCost").ToString();
|
||||
staminaCostText.text = this.card.attributeSubmodule.GetRoundCurrentAttribute(CardAttributes.StaminaCost).ToString();
|
||||
|
||||
int manaCost = this.card.attributeSubmodule.GetRoundCurrentAttribute("ManaCost");
|
||||
int manaCost = this.card.attributeSubmodule.GetRoundCurrentAttribute(CardAttributes.ManaCost);
|
||||
manaCostText.rectTransform.parent.gameObject.SetActive(manaCost > 0);
|
||||
manaCostText.text = manaCost.ToString();
|
||||
|
||||
|
||||
@@ -43,6 +43,46 @@ namespace Continentis.MainGame.Card
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据颜色智能更新提示阴影:null 关闭,非 null 启用对应颜色。
|
||||
/// 避免相同颜色重复 tween 导致闪烁。
|
||||
/// </summary>
|
||||
public void UpdateHintShadow(Color? color)
|
||||
{
|
||||
if (color == null)
|
||||
{
|
||||
if (hintShadow.gameObject.activeSelf)
|
||||
{
|
||||
DisableHintShadow();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Color targetColor = color.Value;
|
||||
|
||||
if (hintShadow.gameObject.activeSelf)
|
||||
{
|
||||
// 已启用:仅在颜色差异足够大时 tween,避免每帧闪烁
|
||||
if (!ApproximatelyEqualColor(hintShadow.color, targetColor))
|
||||
{
|
||||
hintShadowTweener?.Kill();
|
||||
hintShadowTweener = hintShadow.DOColor(targetColor, 0.2f).Play();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
EnableHintShadow(targetColor);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ApproximatelyEqualColor(Color a, Color b, float tolerance = 0.01f)
|
||||
{
|
||||
return Mathf.Abs(a.r - b.r) < tolerance
|
||||
&& Mathf.Abs(a.g - b.g) < tolerance
|
||||
&& Mathf.Abs(a.b - b.b) < tolerance
|
||||
&& Mathf.Abs(a.a - b.a) < tolerance;
|
||||
}
|
||||
|
||||
public void EnableSelectShadow()
|
||||
{
|
||||
selectShadow.gameObject.SetActive(true);
|
||||
|
||||
@@ -6,6 +6,7 @@ using Continentis.MainGame.UI;
|
||||
using SLSFramework.General;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
namespace Continentis.MainGame.Card
|
||||
{
|
||||
@@ -17,6 +18,32 @@ namespace Continentis.MainGame.Card
|
||||
public List<CharacterBase> conditionNotMetTargets = new List<CharacterBase>();
|
||||
public List<CharacterBase> invalidTargets = new List<CharacterBase>();
|
||||
|
||||
#region Multi-Target Selection State
|
||||
|
||||
/// <summary>是否正处于多目标逐次点击选择模式。</summary>
|
||||
private bool isMultiTargetSelecting;
|
||||
|
||||
/// <summary>多目标模式下已选中的目标列表(可含重复)。</summary>
|
||||
private readonly List<CharacterBase> multiTargetSelectedList = new List<CharacterBase>();
|
||||
|
||||
/// <summary>多目标模式需要的总选择次数。</summary>
|
||||
private int multiTargetRequired;
|
||||
|
||||
/// <summary>多目标模式下卡牌固定显示的位置(箭头起点)。</summary>
|
||||
private Vector3 multiTargetCardAnchor;
|
||||
|
||||
#endregion
|
||||
|
||||
protected override void Update()
|
||||
{
|
||||
base.Update();
|
||||
|
||||
if (isMultiTargetSelecting)
|
||||
{
|
||||
HandleMultiTargetInput();
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnPointerEnter(PointerEventData eventData)
|
||||
{
|
||||
base.OnPointerEnter(eventData);
|
||||
@@ -78,11 +105,23 @@ namespace Continentis.MainGame.Card
|
||||
card.user = CombatMainManager.Instance.currentCharacter;
|
||||
card.DetectTargetsValidity(out validTargets, out conditionNotMetTargets, out invalidTargets);
|
||||
|
||||
if (card.attributeSubmodule.targetCount == 1)
|
||||
int targetCount = card.attributeSubmodule.targetCount;
|
||||
|
||||
if (targetCount > 1)
|
||||
{
|
||||
// 多目标模式:先执行通用的可打出校验
|
||||
if (!CheckCanStartPlay())
|
||||
{
|
||||
ResetSelectionState();
|
||||
return;
|
||||
}
|
||||
EnterMultiTargetMode(targetCount);
|
||||
}
|
||||
else if (targetCount == 1)
|
||||
{
|
||||
CombatUIManager.Instance.arrowsPage.GeneratePointerArrow(cardTransform.position, cardTransform.position, true);
|
||||
}
|
||||
else if (card.attributeSubmodule.targetCount == -1)
|
||||
else if (targetCount == -1)
|
||||
{
|
||||
CombatUIManager.Instance.arrowsPage.GeneratePointerArrow(cardTransform.position, cardTransform.position, true);
|
||||
|
||||
@@ -95,6 +134,9 @@ namespace Continentis.MainGame.Card
|
||||
|
||||
public void OnDrag(PointerEventData eventData)
|
||||
{
|
||||
// 多目标模式下拖拽无效,交互由 Update 中的点击处理
|
||||
if (isMultiTargetSelecting) return;
|
||||
|
||||
RectTransform arrowCanvasRect = CombatUIManager.Instance.arrowsPage.rectTransform;
|
||||
Camera uiCamera = CombatUIManager.Instance.uiCamera;
|
||||
Camera worldCamera = CombatUIManager.Instance.combatCamera;
|
||||
@@ -117,7 +159,7 @@ namespace Continentis.MainGame.Card
|
||||
card.contentSubmodule.dirtyMark = true;
|
||||
}
|
||||
|
||||
Vector3 startPosition = cardTransform.position; //+ new Vector3(0, cardTransform.rect.height * cardTransform.lossyScale.y / 2, 0);
|
||||
Vector3 startPosition = cardTransform.position;
|
||||
Vector3 endPosition = SpaceConverter.ScreenPointToUIPoint(arrowCanvasRect, eventData.position, uiCamera);
|
||||
PointerArrow mainPointerArrow = CombatUIManager.Instance.arrowsPage.mainPointerArrow;
|
||||
|
||||
@@ -222,6 +264,9 @@ namespace Continentis.MainGame.Card
|
||||
|
||||
public void OnEndDrag(PointerEventData eventData)
|
||||
{
|
||||
// 多目标模式下,拖拽结束只是从拖拽过渡到点击模式,不做打出判定
|
||||
if (isMultiTargetSelecting) return;
|
||||
|
||||
CombatCharacterViewBase hoveringCharacterView = CombatUIManager.Instance.hoveringCharacterView;
|
||||
CharacterBase hoveringCharacter = hoveringCharacterView != null ? hoveringCharacterView.character : null;
|
||||
Camera uiCamera = CombatUIManager.Instance.uiCamera;
|
||||
@@ -242,11 +287,7 @@ namespace Continentis.MainGame.Card
|
||||
return;
|
||||
}
|
||||
|
||||
if (card.HasKeyword("Unplayable")) // 如果有“不能打出”关键词,直接返回
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!CheckCanStartPlay()) return;
|
||||
|
||||
if (!card.HasKeyword("TargetSelf"))
|
||||
{
|
||||
@@ -290,5 +331,211 @@ namespace Continentis.MainGame.Card
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#region Play Validation
|
||||
|
||||
/// <summary>
|
||||
/// 检查卡牌是否可以被打出(Unplayable / Prerequisite 校验)。
|
||||
/// 校验失败时会生成提示文本。
|
||||
/// </summary>
|
||||
private bool CheckCanStartPlay()
|
||||
{
|
||||
if (card.HasKeyword("Unplayable"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!card.HasKeyword("Prerequisite"))
|
||||
{
|
||||
CharacterBase currentCharacter = CombatMainManager.Instance.currentCharacter;
|
||||
List<CardInstance> handPile = currentCharacter.deckSubmodule.HandPile;
|
||||
for (int i = 0; i < handPile.Count; i++)
|
||||
{
|
||||
if (handPile[i].HasKeyword("Prerequisite"))
|
||||
{
|
||||
MainGameManager.Instance.basePrefabs.GenerateInfoText(
|
||||
"Keyword_Prerequisite_Warning".Localize(), currentCharacter.characterView);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>重置选择状态(不触发 Untargeting 事件)。</summary>
|
||||
private void ResetSelectionState()
|
||||
{
|
||||
isSelecting = false;
|
||||
isHovering = false;
|
||||
CombatUIManager.Instance.selectingCardView = null;
|
||||
canvas.overrideSorting = false;
|
||||
canvas.sortingOrder = 0;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multi-Target Selection
|
||||
|
||||
/// <summary>进入多目标逐次选择模式。</summary>
|
||||
private void EnterMultiTargetMode(int required)
|
||||
{
|
||||
isMultiTargetSelecting = true;
|
||||
multiTargetRequired = required;
|
||||
multiTargetSelectedList.Clear();
|
||||
|
||||
// 记录卡牌当前位置作为箭头起点锚点
|
||||
multiTargetCardAnchor = cardTransform.position;
|
||||
|
||||
UpdateMultiTargetHint();
|
||||
}
|
||||
|
||||
/// <summary>多目标模式下的输入处理,在 Update 中调用。</summary>
|
||||
private void HandleMultiTargetInput()
|
||||
{
|
||||
// Escape 取消全部
|
||||
if (Keyboard.current.escapeKey.wasPressedThisFrame)
|
||||
{
|
||||
ExitMultiTargetMode();
|
||||
return;
|
||||
}
|
||||
|
||||
// 右键撤销上一步
|
||||
if (Mouse.current.rightButton.wasPressedThisFrame)
|
||||
{
|
||||
UndoLastMultiTarget();
|
||||
return;
|
||||
}
|
||||
|
||||
// 左键选择目标
|
||||
if (Mouse.current.leftButton.wasPressedThisFrame)
|
||||
{
|
||||
CombatCharacterViewBase hoveringView = CombatUIManager.Instance.hoveringCharacterView;
|
||||
if (hoveringView == null) return;
|
||||
|
||||
CharacterBase hoveringCharacter = hoveringView.character;
|
||||
|
||||
// 判断是否为有效目标
|
||||
if (!validTargets.Contains(hoveringCharacter)) return;
|
||||
|
||||
// 判断是否允许重复选择
|
||||
if (!card.HasKeyword(CardKeywords.AllowDuplicateTargets)
|
||||
&& multiTargetSelectedList.Contains(hoveringCharacter))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 选中目标
|
||||
multiTargetSelectedList.Add(hoveringCharacter);
|
||||
|
||||
// 生成固定箭头指向该目标
|
||||
RectTransform arrowCanvasRect = CombatUIManager.Instance.arrowsPage.rectTransform;
|
||||
Camera worldCamera = CombatUIManager.Instance.combatCamera;
|
||||
Camera uiCamera = CombatUIManager.Instance.uiCamera;
|
||||
Vector3 targetWorldPos = hoveringView.transform.position;
|
||||
Vector3 targetUIPos = SpaceConverter.WorldPointToUIPoint(
|
||||
arrowCanvasRect, targetWorldPos, worldCamera, uiCamera);
|
||||
|
||||
// 第一支箭头为 main,后续为 other
|
||||
bool isMain = multiTargetSelectedList.Count == 1;
|
||||
CombatUIManager.Instance.arrowsPage.GeneratePointerArrow(
|
||||
multiTargetCardAnchor, targetUIPos, isMain);
|
||||
|
||||
// 触发 TargetingEffect 更新伤害预览
|
||||
card.Targeting(hoveringCharacter);
|
||||
card.contentSubmodule.dirtyMark = true;
|
||||
|
||||
UpdateMultiTargetHint();
|
||||
|
||||
// 检查是否选满
|
||||
if (multiTargetSelectedList.Count >= multiTargetRequired)
|
||||
{
|
||||
ConfirmMultiTargetPlay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>撤销多目标模式下的上一步选择。</summary>
|
||||
private void UndoLastMultiTarget()
|
||||
{
|
||||
if (multiTargetSelectedList.Count > 0)
|
||||
{
|
||||
multiTargetSelectedList.RemoveAt(multiTargetSelectedList.Count - 1);
|
||||
CombatUIManager.Instance.arrowsPage.RemoveLastPointerArrow();
|
||||
|
||||
// 如果还有已选目标,触发最后一个目标的 TargetingEffect
|
||||
if (multiTargetSelectedList.Count > 0)
|
||||
{
|
||||
card.Targeting(multiTargetSelectedList[^1]);
|
||||
}
|
||||
else
|
||||
{
|
||||
card.Untargeting();
|
||||
}
|
||||
|
||||
card.contentSubmodule.dirtyMark = true;
|
||||
UpdateMultiTargetHint();
|
||||
}
|
||||
|
||||
// 已选归零 → 取消选择,卡牌归位
|
||||
if (multiTargetSelectedList.Count == 0)
|
||||
{
|
||||
ExitMultiTargetMode();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>选满后自动打出。</summary>
|
||||
private void ConfirmMultiTargetPlay()
|
||||
{
|
||||
List<CharacterBase> targets = new List<CharacterBase>(multiTargetSelectedList);
|
||||
|
||||
// 清理多目标状态
|
||||
isMultiTargetSelecting = false;
|
||||
multiTargetSelectedList.Clear();
|
||||
multiTargetRequired = 0;
|
||||
CombatUIManager.Instance.arrowsPage.ClearPointerArrows();
|
||||
|
||||
isSelecting = false;
|
||||
isHovering = false;
|
||||
CombatUIManager.Instance.selectingCardView = null;
|
||||
canvas.overrideSorting = false;
|
||||
canvas.sortingOrder = 0;
|
||||
|
||||
// 打出卡牌
|
||||
if (!card.Play(targets))
|
||||
{
|
||||
card.eventSubmodule.onUntargeting();
|
||||
card.contentSubmodule.dirtyMark = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>退出多目标选择模式(取消操作),清理所有状态,卡牌归位。</summary>
|
||||
private void ExitMultiTargetMode()
|
||||
{
|
||||
isMultiTargetSelecting = false;
|
||||
multiTargetSelectedList.Clear();
|
||||
multiTargetRequired = 0;
|
||||
|
||||
CombatUIManager.Instance.arrowsPage.ClearPointerArrows();
|
||||
|
||||
isSelecting = false;
|
||||
isHovering = false;
|
||||
CombatUIManager.Instance.selectingCardView = null;
|
||||
canvas.overrideSorting = false;
|
||||
canvas.sortingOrder = 0;
|
||||
|
||||
card.eventSubmodule.onUntargeting();
|
||||
card.contentSubmodule.dirtyMark = true;
|
||||
}
|
||||
|
||||
/// <summary>更新多目标选择的计数提示。</summary>
|
||||
private void UpdateMultiTargetHint()
|
||||
{
|
||||
string hint = $"{multiTargetSelectedList.Count} / {multiTargetRequired}";
|
||||
MainGameManager.Instance.basePrefabs.GenerateInfoText(
|
||||
hint, card.user.characterView, Color.cyan);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,7 @@ namespace Continentis.MainGame.Card
|
||||
categoryName = resolvedCategory;
|
||||
displayName = $"Card_{resolvedMod}_{className}_DisplayName";
|
||||
functionText = $"Card_{resolvedMod}_{className}_FunctionText";
|
||||
descriptionText = $"Card_{resolvedMod}_{className}_Description";
|
||||
|
||||
EditorUtility.SetDirty(this);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using Continentis.MainGame.Character;
|
||||
using Continentis.Mods.Basic.Cards;
|
||||
using UnityEngine;
|
||||
@@ -19,66 +20,67 @@ namespace Continentis.MainGame.Card
|
||||
/// <summary>
|
||||
/// 设置伤害值
|
||||
/// </summary>
|
||||
/// <param name="additive">是否为叠加,true为叠加,false为覆盖</param>
|
||||
/// <param name="originalDamage">原始伤害值,仅在additive为true时有效,否则被覆盖为BaseDamage</param>
|
||||
/// <param name="damageAttributeName">要写入的属性名,默认为 "Damage"</param>
|
||||
/// <param name="damageOffset">伤害增量</param>
|
||||
public void SetDamage(int damageOffset, bool additive = false, int originalDamage = 0)
|
||||
/// <param name="additive">是否为叠加,true为叠加,false为覆盖</param>
|
||||
/// <param name="originalDamage">原始伤害值,仅在additive为true时有效</param>
|
||||
public void SetDamage(int damageOffset, string damageAttributeName = "Damage", bool additive = false, int originalDamage = 0)
|
||||
{
|
||||
card.SetVariableAttribute("Damage", damageOffset, additive, originalDamage);
|
||||
card.SetVariableAttribute(damageAttributeName, damageOffset, additive, originalDamage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 默认伤害计算,仅使用卡牌基础伤害(无任何属性加成)
|
||||
/// </summary>
|
||||
public void SetDamage_Default()
|
||||
public void SetDamage_Default(string damageAttributeName = "Damage")
|
||||
{
|
||||
SetDamage(0);
|
||||
SetDamage(0, damageAttributeName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 斩击伤害计算,伤害=基础伤害+(力量加成+敏捷加成)/2
|
||||
/// </summary>
|
||||
public void SetDamage_Slash(bool additive = false, int originalDamage = 0)
|
||||
public void SetDamage_Slash(string damageAttributeName = "Damage", bool additive = false, int originalDamage = 0)
|
||||
{
|
||||
float rawDamageOffsetFromStrength = user.GetRawAttribute("OffsetFromStrength");
|
||||
float rawDamageOffsetFromAgility = user.GetRawAttribute("OffsetFromAgility");
|
||||
SetDamage(Mathf.RoundToInt((rawDamageOffsetFromStrength + rawDamageOffsetFromAgility) / 2f), additive, originalDamage);
|
||||
SetDamage(Mathf.RoundToInt((rawDamageOffsetFromStrength + rawDamageOffsetFromAgility) / 2f), damageAttributeName, additive, originalDamage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打击伤害计算,伤害=基础伤害+力量加成
|
||||
/// </summary>
|
||||
public void SetDamage_Strike(bool additive = false, int originalDamage = 0)
|
||||
public void SetDamage_Strike(string damageAttributeName = "Damage", bool additive = false, int originalDamage = 0)
|
||||
{
|
||||
int damageOffset = user.GetAttribute("OffsetFromStrength");
|
||||
SetDamage(damageOffset, additive, originalDamage);
|
||||
SetDamage(damageOffset, damageAttributeName, additive, originalDamage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 突刺伤害计算,伤害=基础伤害+敏捷加成
|
||||
/// </summary>
|
||||
public void SetDamage_Prick(bool additive = false, int originalDamage = 0)
|
||||
public void SetDamage_Prick(string damageAttributeName = "Damage", bool additive = false, int originalDamage = 0)
|
||||
{
|
||||
int damageOffset = user.GetAttribute("OffsetFromAgility");
|
||||
SetDamage(damageOffset, additive, originalDamage);
|
||||
SetDamage(damageOffset, damageAttributeName, additive, originalDamage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 奥术伤害计算,伤害=基础伤害+智力加成
|
||||
/// </summary>
|
||||
public void SetDamage_Arcane(bool additive = false, int originalDamage = 0)
|
||||
public void SetDamage_Arcane(string damageAttributeName = "Damage", bool additive = false, int originalDamage = 0)
|
||||
{
|
||||
int damageOffset = user.GetAttribute("OffsetFromIntelligence");
|
||||
SetDamage(damageOffset, additive, originalDamage);
|
||||
SetDamage(damageOffset, damageAttributeName, additive, originalDamage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 契术伤害计算,伤害=基础伤害+魅力加成
|
||||
/// </summary>
|
||||
public void SetDamage_Sorcery(bool additive = false, int originalDamage = 0)
|
||||
public void SetDamage_Sorcery(string damageAttributeName = "Damage", bool additive = false, int originalDamage = 0)
|
||||
{
|
||||
int damageOffset = user.GetAttribute("OffsetFromCharisma");
|
||||
SetDamage(damageOffset, additive, originalDamage);
|
||||
SetDamage(damageOffset, damageAttributeName, additive, originalDamage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,9 @@ namespace Continentis.MainGame.Card
|
||||
{
|
||||
card.SetAttribute("DisplayDodge", card.GetAttribute("Dodge"));
|
||||
}
|
||||
else if(card.HasAttribute("Shield"))
|
||||
else if(card.HasAttribute("TemporaryHealth"))
|
||||
{
|
||||
card.SetAttribute("DisplayShield", card.GetAttribute("Shield"));
|
||||
card.SetAttribute("DisplayTemporaryHealth", card.GetAttribute("TemporaryHealth"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +32,9 @@ namespace Continentis.MainGame.Card
|
||||
{
|
||||
card.SetAttribute("DisplayDodge", card.GetAttribute("Dodge"));
|
||||
}
|
||||
else if(card.HasAttribute("Shield"))
|
||||
else if(card.HasAttribute("TemporaryHealth"))
|
||||
{
|
||||
card.SetAttribute("DisplayShield", card.GetAttribute("Shield"));
|
||||
card.SetAttribute("DisplayTemporaryHealth", card.GetAttribute("TemporaryHealth"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,11 +102,11 @@ namespace Continentis.MainGame.Card
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置护盾值,默认无加成
|
||||
/// 设置临时生命值,默认无加成
|
||||
/// </summary>
|
||||
public void SetShield(bool additive = false, int originalShield = 0)
|
||||
{
|
||||
card.SetVariableAttribute("Shield", 0, additive, originalShield);
|
||||
card.SetVariableAttribute("TemporaryHealth", 0, additive, originalShield);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 97fdf9e2a23cdfa40a8486c04fa81663
|
||||
@@ -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);
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,5 +149,8 @@
|
||||
|
||||
/// <summary> 来自魅力的增减益 </summary>
|
||||
public const string OffsetFromCharisma = "OffsetFromCharisma";
|
||||
|
||||
/// <summary> 通用增减益 </summary>
|
||||
public const string UniversalOffset = "UniversalOffset";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d62ac580deaf21a428e43e567b08f790
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 84dd7cd1188860440897bf13fe67b5bb
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a7e70a75f86711418ab773b6494763e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4cc14aaaad81bf3428dc9c19c1138a5d
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 30d25484995a4fb45a05436995e9a0fa
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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() { }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 41e6e47005c77fb4994040c6b9763669
|
||||
@@ -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 实现,无动画驱动器。");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
108
Assets/Scripts/MainGame/Character/CharacterView/FrameAnimator.cs
Normal file
108
Assets/Scripts/MainGame/Character/CharacterView/FrameAnimator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 63273f95cd581594d85ac454f2395265
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a4fe7995a9658f4580e2984545da6ff
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d607d72147dad7441aca976ff675e6f2
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 665a1fe9fd0c77c46b1a8567c185f7ab
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using Continentis.MainGame.Character;
|
||||
using DG.Tweening;
|
||||
using Lean.Pool;
|
||||
using SLSFramework.General;
|
||||
using SLSFramework.UModAssistance;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
@@ -96,6 +97,8 @@ namespace Continentis.MainGame.Combat
|
||||
npcs[fraction].Add(npc);
|
||||
characters.Add(npc);
|
||||
combatCharacterViews.Add(view);
|
||||
|
||||
CombatMainManager.Instance.eventCollection.onCharacterJoin.Invoke(npc);
|
||||
}
|
||||
|
||||
SetViewPositions();
|
||||
@@ -229,6 +232,22 @@ namespace Continentis.MainGame.Combat
|
||||
Object.Destroy(character.characterView.gameObject);
|
||||
|
||||
SetViewPositions();
|
||||
CheckCombatEnd();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查战斗胜负条件:敌方全灭→胜利,玩家方全灭→失败。
|
||||
/// </summary>
|
||||
private void CheckCombatEnd()
|
||||
{
|
||||
if (enemies.Count == 0)
|
||||
{
|
||||
CombatMainManager.Instance.EndCombat(isVictory: true);
|
||||
}
|
||||
else if (playerHeroes.Count == 0)
|
||||
{
|
||||
CombatMainManager.Instance.EndCombat(isVictory: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,10 +285,9 @@ namespace Continentis.MainGame.Combat
|
||||
enemiesList.AddRange(npcs[Fraction.Ally]);
|
||||
return enemiesList;
|
||||
}
|
||||
else // Neutral
|
||||
else // Neutral:中立单位视所有非中立方为敌人
|
||||
{
|
||||
List<CharacterBase> enemiesList = new List<CharacterBase>();
|
||||
enemiesList.AddRange(npcs[Fraction.Player]);
|
||||
List<CharacterBase> enemiesList = new List<CharacterBase>(playerHeroes);
|
||||
enemiesList.AddRange(npcs[Fraction.Ally]);
|
||||
enemiesList.AddRange(npcs[Fraction.Enemy]);
|
||||
return enemiesList;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using Continentis.MainGame.Character;
|
||||
using SoftCircuits.Collections;
|
||||
using SLSFramework.General;
|
||||
using UnityEngine;
|
||||
@@ -27,13 +28,25 @@ namespace Continentis.MainGame.Combat
|
||||
/// 回合结束
|
||||
/// </summary>
|
||||
public OrderedDictionary<string, PrioritizedAction> onRoundEnd;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 角色加入战场时(参数为新加入的角色)
|
||||
/// </summary>
|
||||
public OrderedDictionary<string, PrioritizedAction<CharacterBase>> onCharacterJoin;
|
||||
|
||||
/// <summary>
|
||||
/// 角色死亡时(参数为死亡角色)
|
||||
/// </summary>
|
||||
public OrderedDictionary<string, PrioritizedAction<CharacterBase>> onCharacterDeath;
|
||||
|
||||
public CombatEventCollection()
|
||||
{
|
||||
onCombatStart = new OrderedDictionary<string, PrioritizedAction>();
|
||||
onCombatEnd = new OrderedDictionary<string, PrioritizedAction>();
|
||||
onRoundStart = new OrderedDictionary<string, PrioritizedAction>();
|
||||
onRoundEnd = new OrderedDictionary<string, PrioritizedAction>();
|
||||
onCharacterJoin = new OrderedDictionary<string, PrioritizedAction<CharacterBase>>();
|
||||
onCharacterDeath = new OrderedDictionary<string, PrioritizedAction<CharacterBase>>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,210 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using Continentis.MainGame.Card;
|
||||
using Continentis.MainGame.Character;
|
||||
using SLSFramework.General;
|
||||
using UnityEngine;
|
||||
|
||||
public class CombatLogs : MonoBehaviour
|
||||
namespace Continentis.MainGame.Combat
|
||||
{
|
||||
// Start is called once before the first execution of Update after the MonoBehaviour is created
|
||||
void Start()
|
||||
public enum CombatLogType
|
||||
{
|
||||
|
||||
RoundStart,
|
||||
ActionStart,
|
||||
Attack,
|
||||
BuffApply,
|
||||
BuffRemove,
|
||||
CardPlay,
|
||||
Death
|
||||
}
|
||||
|
||||
// Update is called once per frame
|
||||
void Update()
|
||||
public class CombatLogEntry
|
||||
{
|
||||
|
||||
public int round;
|
||||
public int actionIndex;
|
||||
public float timestamp;
|
||||
public CombatLogType type;
|
||||
public string message;
|
||||
public Dictionary<string, object> metadata;
|
||||
|
||||
public CombatLogEntry(int round, int actionIndex, CombatLogType type, string message)
|
||||
{
|
||||
this.round = round;
|
||||
this.actionIndex = actionIndex;
|
||||
this.timestamp = Time.time;
|
||||
this.type = type;
|
||||
this.message = message;
|
||||
this.metadata = new Dictionary<string, object>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 战斗日志系统:记录战斗中的关键事件,支持查询与格式化输出。
|
||||
/// 挂载在战斗场景的 CombatMainManager 同级或子对象上。
|
||||
/// </summary>
|
||||
public class CombatLogs : Singleton<CombatLogs>
|
||||
{
|
||||
private const string LogPrefix = "[CombatLog]";
|
||||
|
||||
private readonly List<CombatLogEntry> allEntries = new List<CombatLogEntry>();
|
||||
private CombatMainManager combatManager;
|
||||
|
||||
// ── 初始化与事件订阅 ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 由 CombatMainManager.StartCombat() 调用,订阅全局及各角色事件。
|
||||
/// </summary>
|
||||
public void Initialize(CombatMainManager manager)
|
||||
{
|
||||
allEntries.Clear();
|
||||
combatManager = manager;
|
||||
|
||||
// 订阅全局回合开始事件
|
||||
manager.eventCollection.onRoundStart.InsertByPriority(
|
||||
"CombatLogs_RoundStart",
|
||||
new PrioritizedAction(() => LogRoundStart(manager.currentRound), 0));
|
||||
|
||||
// 订阅角色攻击完成事件
|
||||
foreach (CharacterBase character in manager.characterController.characters)
|
||||
{
|
||||
SubscribeCharacterEvents(character);
|
||||
}
|
||||
|
||||
// 订阅死亡事件
|
||||
manager.eventCollection.onCharacterDeath.InsertByPriority(
|
||||
"CombatLogs_Death",
|
||||
new PrioritizedAction<CharacterBase>(LogDeath, 0));
|
||||
}
|
||||
|
||||
/// <summary>为单个角色订阅攻击完成事件(可供动态加入战场的角色调用)。</summary>
|
||||
public void SubscribeCharacterEvents(CharacterBase character)
|
||||
{
|
||||
CharacterBase captured = character;
|
||||
character.eventSubmodule.onFinishAttack.InsertByPriority(
|
||||
"CombatLogs_FinishAttack",
|
||||
new PrioritizedAction<CharacterBase, AttackResult>(
|
||||
(target, result) => LogAttack(result), 0));
|
||||
}
|
||||
|
||||
// ── 日志写入 API ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>记录攻击结果。</summary>
|
||||
public void LogAttack(AttackResult result)
|
||||
{
|
||||
string msg = result.isDodged
|
||||
? $"{result.attacker.data.displayName} 攻击 {result.target.data.displayName}(闪避)"
|
||||
: $"{result.attacker.data.displayName} 攻击 {result.target.data.displayName}:" +
|
||||
$"起始伤害={result.startDamage}, 格挡={result.blockedDamage}, " +
|
||||
$"护盾={result.shieldedDamage}, 实际={result.hurtDamage}";
|
||||
|
||||
var entry = new CombatLogEntry(
|
||||
combatManager.currentRound, combatManager.currentActionIndex,
|
||||
CombatLogType.Attack, msg);
|
||||
entry.metadata["attacker"] = result.attacker.data.displayName;
|
||||
entry.metadata["target"] = result.target.data.displayName;
|
||||
entry.metadata["hurtDamage"] = result.hurtDamage;
|
||||
entry.metadata["isDodged"] = result.isDodged;
|
||||
|
||||
WriteEntry(entry);
|
||||
}
|
||||
|
||||
/// <summary>记录 Buff 施加事件。</summary>
|
||||
public void LogBuffApply(CharacterCombatBuffBase buff, CharacterBase target)
|
||||
{
|
||||
string msg = $"Buff 施加:{buff.GetType().Name} → {target.data.displayName}";
|
||||
var entry = new CombatLogEntry(
|
||||
combatManager.currentRound, combatManager.currentActionIndex,
|
||||
CombatLogType.BuffApply, msg);
|
||||
entry.metadata["buffType"] = buff.GetType().Name;
|
||||
entry.metadata["target"] = target.data.displayName;
|
||||
|
||||
WriteEntry(entry);
|
||||
}
|
||||
|
||||
/// <summary>记录 Buff 移除事件。</summary>
|
||||
public void LogBuffRemove(CharacterCombatBuffBase buff)
|
||||
{
|
||||
string msg = $"Buff 移除:{buff.GetType().Name} ← {buff.attachedCharacter?.data.displayName}";
|
||||
var entry = new CombatLogEntry(
|
||||
combatManager.currentRound, combatManager.currentActionIndex,
|
||||
CombatLogType.BuffRemove, msg);
|
||||
entry.metadata["buffType"] = buff.GetType().Name;
|
||||
|
||||
WriteEntry(entry);
|
||||
}
|
||||
|
||||
/// <summary>记录卡牌使用事件。</summary>
|
||||
public void LogCardPlay(CardInstance card, List<CharacterBase> targets)
|
||||
{
|
||||
StringBuilder targetNames = new StringBuilder();
|
||||
foreach (CharacterBase t in targets)
|
||||
{
|
||||
if (targetNames.Length > 0) targetNames.Append(", ");
|
||||
targetNames.Append(t.data.displayName);
|
||||
}
|
||||
string msg = $"卡牌使用:{card.cardData.displayName} → [{targetNames}]";
|
||||
var entry = new CombatLogEntry(
|
||||
combatManager.currentRound, combatManager.currentActionIndex,
|
||||
CombatLogType.CardPlay, msg);
|
||||
entry.metadata["cardName"] = card.cardData.displayName;
|
||||
|
||||
WriteEntry(entry);
|
||||
}
|
||||
|
||||
/// <summary>记录角色死亡事件。</summary>
|
||||
public void LogDeath(CharacterBase character)
|
||||
{
|
||||
string msg = $"{character.data.displayName} 死亡(回合 {combatManager.currentRound})";
|
||||
var entry = new CombatLogEntry(
|
||||
combatManager.currentRound, combatManager.currentActionIndex,
|
||||
CombatLogType.Death, msg);
|
||||
entry.metadata["character"] = character.data.displayName;
|
||||
entry.metadata["fraction"] = character.fraction.ToString();
|
||||
|
||||
WriteEntry(entry);
|
||||
}
|
||||
|
||||
/// <summary>记录回合开始事件。</summary>
|
||||
public void LogRoundStart(int round)
|
||||
{
|
||||
string msg = $"── 第 {round} 回合开始 ──";
|
||||
var entry = new CombatLogEntry(round, 0, CombatLogType.RoundStart, msg);
|
||||
WriteEntry(entry);
|
||||
}
|
||||
|
||||
// ── 查询 API ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>按类型和数量查询日志条目。</summary>
|
||||
public List<CombatLogEntry> Query(CombatLogType type, int lastN = 10)
|
||||
{
|
||||
var result = new List<CombatLogEntry>();
|
||||
for (int i = allEntries.Count - 1; i >= 0 && result.Count < lastN; i--)
|
||||
{
|
||||
if (allEntries[i].type == type)
|
||||
result.Add(allEntries[i]);
|
||||
}
|
||||
result.Reverse();
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>获取所有日志条目。</summary>
|
||||
public IReadOnlyList<CombatLogEntry> GetAllEntries() => allEntries;
|
||||
|
||||
// ── 格式化 ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>将条目格式化为人类可读字符串。</summary>
|
||||
public string FormatEntry(CombatLogEntry entry)
|
||||
{
|
||||
return $"[R{entry.round:D2}|A{entry.actionIndex:D2}|{entry.timestamp:F2}s] {entry.type}: {entry.message}";
|
||||
}
|
||||
|
||||
// ── 内部写入 ──────────────────────────────────────────────────────
|
||||
|
||||
private void WriteEntry(CombatLogEntry entry)
|
||||
{
|
||||
allEntries.Add(entry);
|
||||
Debug.Log($"{LogPrefix} {FormatEntry(entry)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Continentis.MainGame.Card;
|
||||
using Continentis.MainGame.Character;
|
||||
using Continentis.MainGame.Commands;
|
||||
using Continentis.MainGame.Equipment;
|
||||
using Continentis.MainGame.Saving;
|
||||
using Continentis.MainGame.UI;
|
||||
using SLSFramework.General;
|
||||
using SLSFramework.UModAssistance;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Continentis.MainGame.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 战斗进行状态枚举
|
||||
/// </summary>
|
||||
public enum CombatState
|
||||
{
|
||||
InProgress,
|
||||
ConfirmingRound,
|
||||
Victory,
|
||||
Defeat
|
||||
}
|
||||
|
||||
public partial class CombatMainManager : Singleton<CombatMainManager>
|
||||
{
|
||||
public CombatCharacterController characterController;
|
||||
@@ -23,6 +33,7 @@ namespace Continentis.MainGame.Combat
|
||||
public int currentRound;
|
||||
public int currentActionIndex;
|
||||
public CharacterBase currentCharacter;
|
||||
public CombatState combatState;
|
||||
}
|
||||
|
||||
public partial class CombatMainManager
|
||||
@@ -54,6 +65,8 @@ namespace Continentis.MainGame.Combat
|
||||
{
|
||||
public void StartCombat()
|
||||
{
|
||||
combatState = CombatState.InProgress;
|
||||
|
||||
foreach (CharacterBase character in characterController.characters)
|
||||
character.InitializeCards();
|
||||
|
||||
@@ -62,23 +75,19 @@ namespace Continentis.MainGame.Combat
|
||||
eventCollection.onCombatStart.Invoke();
|
||||
|
||||
foreach (CharacterBase character in characterController.characters)
|
||||
{
|
||||
character.eventSubmodule.onCombatStart.Invoke();
|
||||
foreach (EquipmentBase equipment in character.equipmentSubmodule.currentEquipments)
|
||||
equipment.eventSubmodule.onCombatStart.Invoke();
|
||||
}
|
||||
character.DispatchCombatStart();
|
||||
|
||||
foreach (CardInstance card in characterController.characters
|
||||
.SelectMany(character => character.deckSubmodule.GetAllCards()))
|
||||
{
|
||||
card.eventSubmodule.onCombatStart.Invoke();
|
||||
}
|
||||
// 1.2b — 初始化 CombatLogs 并订阅事件
|
||||
CombatLogs.Instance.Initialize(this);
|
||||
|
||||
NextRound();
|
||||
}
|
||||
|
||||
public void NextRound()
|
||||
{
|
||||
// 战斗已结束则不再推进
|
||||
if (combatState != CombatState.InProgress) return;
|
||||
|
||||
currentRound++;
|
||||
|
||||
// UI 反馈:回合提示动画(同步,纯 UI 无逻辑影响)
|
||||
@@ -108,45 +117,67 @@ namespace Continentis.MainGame.Combat
|
||||
if (intendedCard.targets.Count > 0)
|
||||
{
|
||||
CardInstance card = intendedCard.cardInstance;
|
||||
card.eventSubmodule.onTargeting(intendedCard.targets[0]);
|
||||
card.Targeting(intendedCard.targets[0]);
|
||||
card.contentSubmodule.dirtyMark = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 以下所有事件入队,确保 Buff 触发的视觉效果顺序正确 ────────────
|
||||
// ── 进入"回合前确认"阶段:等待玩家点击 Confirm ───────────────────
|
||||
combatState = CombatState.ConfirmingRound;
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(() =>
|
||||
{
|
||||
CombatMainPage page = CombatUIManager.Instance.combatMainPage;
|
||||
page.SetButtonAction(ConfirmRound, "Confirm", true);
|
||||
}));
|
||||
|
||||
CommandBase waitSignal = Cmd.WaitForSignal(out confirmRoundSignal);
|
||||
CommandQueueManager.Instance.AddCommand(waitSignal);
|
||||
|
||||
// ── 玩家确认后,回合正式开始:Buff 触发链入队 ────────────────────
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(() =>
|
||||
{
|
||||
combatState = CombatState.InProgress;
|
||||
CombatUIManager.Instance.combatMainPage.SetButtonAction(
|
||||
EndAction, "Waiting...", false);
|
||||
}));
|
||||
|
||||
foreach (CharacterBase character in characterController.characters)
|
||||
{
|
||||
CharacterBase captured = character;
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(
|
||||
() => captured.eventSubmodule.onRoundStart.Invoke()));
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(
|
||||
() => captured.equipmentSubmodule.currentEquipments.ForEach(
|
||||
equipment => equipment.eventSubmodule.onRoundStart.Invoke())));
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(
|
||||
() => captured.combatBuffSubmodule.RoundStart()));
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(() => captured.DispatchRoundStart()));
|
||||
}
|
||||
|
||||
// ── 所有回合开始事件处理完毕后,进入第一个行动 ────────────────────
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(NextAction));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 玩家在"回合前确认"阶段点击 Confirm 后调用,释放队列暂停信号。
|
||||
/// </summary>
|
||||
public void ConfirmRound()
|
||||
{
|
||||
if (combatState != CombatState.ConfirmingRound) return;
|
||||
confirmRoundSignal?.Invoke();
|
||||
confirmRoundSignal = null;
|
||||
}
|
||||
|
||||
// 持有当前回合的信号委托,ConfirmRound() 调用后置 null 防止重复触发
|
||||
private Action confirmRoundSignal;
|
||||
|
||||
public void NextAction()
|
||||
{
|
||||
// 战斗已结束则不再推进
|
||||
if (combatState != CombatState.InProgress) return;
|
||||
|
||||
if (characterController.actionOrderList.Count == 0)
|
||||
{
|
||||
// 回合结束:所有角色的 onRoundEnd 和 Buff 触发均入队
|
||||
foreach (CharacterBase character in characterController.characters)
|
||||
{
|
||||
CharacterBase captured = character;
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(
|
||||
() => captured.eventSubmodule.onRoundEnd.Invoke()));
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(
|
||||
() => captured.equipmentSubmodule.currentEquipments.ForEach(
|
||||
equipment => equipment.eventSubmodule.onRoundEnd.Invoke())));
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(
|
||||
() => captured.combatBuffSubmodule.RoundEnd()));
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(() => captured.DispatchRoundEnd()));
|
||||
}
|
||||
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(NextRound));
|
||||
@@ -158,15 +189,14 @@ namespace Continentis.MainGame.Combat
|
||||
CharacterBase actionCharacter = currentCharacter;
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(() =>
|
||||
{
|
||||
actionCharacter.eventSubmodule.onActionStart.Invoke();
|
||||
actionCharacter.combatBuffSubmodule.ActionStart();
|
||||
actionCharacter.DispatchActionStart();
|
||||
actionCharacter.recordSubmodule.SetAction(currentRound, ++currentActionIndex);
|
||||
}));
|
||||
|
||||
CombatMainPage combatMainPage = CombatUIManager.Instance.combatMainPage;
|
||||
|
||||
if (currentCharacter is PlayerHero playerHero)
|
||||
{
|
||||
CombatMainPage combatMainPage = CombatUIManager.Instance.combatMainPage;
|
||||
|
||||
playerHero.deckSubmodule.SetUpHandCardViews();
|
||||
combatMainPage.handPile.isUpdatingLayout = false;
|
||||
|
||||
@@ -179,23 +209,24 @@ namespace Continentis.MainGame.Combat
|
||||
}));
|
||||
|
||||
combatMainPage.combatResourcesDisplayer.SetCharacter(playerHero);
|
||||
CombatUIManager.Instance.combatMainPage.endActionButton
|
||||
.GetComponentInChildren<TMP_Text>().text = "End Action";
|
||||
CombatUIManager.Instance.combatMainPage.endActionButton.interactable = true;
|
||||
combatMainPage.SetButtonAction(EndAction, "End Action", true);
|
||||
}
|
||||
else if (currentCharacter is CombatNPC)
|
||||
{
|
||||
if (currentCharacter.fraction == Fraction.Enemy)
|
||||
CombatUIManager.Instance.combatMainPage.endActionButton.GetComponentInChildren<TMP_Text>().text = "Enemy Action";
|
||||
else if (currentCharacter.fraction == Fraction.Ally)
|
||||
CombatUIManager.Instance.combatMainPage.endActionButton.GetComponentInChildren<TMP_Text>().text = "Ally Action";
|
||||
else
|
||||
CombatUIManager.Instance.combatMainPage.endActionButton.GetComponentInChildren<TMP_Text>().text = "Others Action";
|
||||
|
||||
CombatUIManager.Instance.combatMainPage.endActionButton.interactable = false;
|
||||
string npcLabel = currentCharacter.fraction switch
|
||||
{
|
||||
Fraction.Enemy => "Enemy Action",
|
||||
Fraction.Ally => "Ally Action",
|
||||
_ => "Others Action"
|
||||
};
|
||||
combatMainPage.SetButtonAction(null, npcLabel, false);
|
||||
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Wait(0.25f));
|
||||
|
||||
// 2.3e — PreAction 钩子:NPC 出牌前
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(() =>
|
||||
currentCharacter.intentionSubmodule.currentIntention.PreAction()));
|
||||
|
||||
foreach (IntendedCard intendedCard in currentCharacter.intentionSubmodule.intendedCards)
|
||||
{
|
||||
IntendedCard captured = intendedCard;
|
||||
@@ -205,11 +236,15 @@ namespace Continentis.MainGame.Combat
|
||||
Cmd.Do(() => {
|
||||
captured.cardInstance.Play(captured.targets, currentCharacter);
|
||||
captured.cardInstance.DestroyIntentionCardView();
|
||||
}),
|
||||
}),
|
||||
Cmd.Wait(0.25f)
|
||||
));
|
||||
}
|
||||
|
||||
// 2.3e — PostAction 钩子:NPC 出完全部卡牌后
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(() =>
|
||||
currentCharacter.intentionSubmodule.currentIntention.PostAction()));
|
||||
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(EndAction));
|
||||
}
|
||||
|
||||
@@ -221,23 +256,11 @@ namespace Continentis.MainGame.Combat
|
||||
|
||||
public void EndAction()
|
||||
{
|
||||
CombatUIManager.Instance.combatMainPage.endActionButton
|
||||
.GetComponentInChildren<TMP_Text>().text = "Waiting...";
|
||||
CombatUIManager.Instance.combatMainPage.endActionButton.interactable = false;
|
||||
|
||||
CommandQueueManager.Instance.AddCommand(
|
||||
Cmd.Do(currentCharacter.eventSubmodule.onActionEnd.Invoke));
|
||||
|
||||
foreach (CardInstance card in currentCharacter.deckSubmodule.GetAllCards())
|
||||
{
|
||||
CardInstance captured = card;
|
||||
CommandQueueManager.Instance.AddCommand(
|
||||
Cmd.Do(captured.eventSubmodule.onActionEnd.Invoke));
|
||||
}
|
||||
CombatUIManager.Instance.combatMainPage.SetButtonAction(null, "Waiting...", false);
|
||||
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(() =>
|
||||
{
|
||||
currentCharacter.combatBuffSubmodule.ActionEnd();
|
||||
currentCharacter.DispatchActionEnd();
|
||||
|
||||
if (currentCharacter is PlayerHero playerHero)
|
||||
{
|
||||
@@ -259,4 +282,73 @@ namespace Continentis.MainGame.Combat
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
public partial class CombatMainManager
|
||||
{
|
||||
/// <summary>
|
||||
/// 结束战斗,触发胜负流程。由 CombatCharacterController.CheckCombatEnd() 调用。
|
||||
/// </summary>
|
||||
public void EndCombat(bool isVictory)
|
||||
{
|
||||
// 防止重复触发
|
||||
if (combatState != CombatState.InProgress) return;
|
||||
|
||||
combatState = isVictory ? CombatState.Victory : CombatState.Defeat;
|
||||
|
||||
// 触发战斗结束全局事件
|
||||
eventCollection.onCombatEnd.Invoke();
|
||||
|
||||
foreach (CharacterBase character in characterController.characters)
|
||||
character.DispatchCombatEnd();
|
||||
|
||||
// 清理所有卡牌 Logic 的托管订阅
|
||||
foreach (CharacterBase character in characterController.characters)
|
||||
foreach (CardInstance card in character.deckSubmodule.GetAllCards())
|
||||
card.cardLogic?.Dispose();
|
||||
|
||||
if (isVictory)
|
||||
{
|
||||
CombatVictory();
|
||||
}
|
||||
else
|
||||
{
|
||||
CombatDefeat();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 战斗胜利处理:收集战后英雄 HP,通知 MainGameManager 推进节点并存档。
|
||||
/// </summary>
|
||||
private void CombatVictory()
|
||||
{
|
||||
CombatUIManager.Instance.combatMainPage.SetButtonAction(null, "Victory!", false);
|
||||
|
||||
// 将玩家英雄的战后 HP 与 RunSave 中的 HeroSave 对应:按 currentRun.heroes 顺序匹配
|
||||
var heroResults = new List<(string heroID, int currentHP)>();
|
||||
List<HeroSave> runHeroes = MainGameManager.Instance.currentRun?.heroes;
|
||||
if (runHeroes != null)
|
||||
{
|
||||
for (int i = 0; i < Mathf.Min(characterController.playerHeroes.Count, runHeroes.Count); i++)
|
||||
{
|
||||
int hp = Mathf.RoundToInt(characterController.playerHeroes[i].GetAttribute("Health"));
|
||||
heroResults.Add((runHeroes[i].characterDataID, hp));
|
||||
}
|
||||
}
|
||||
|
||||
// CommandQueue 执行完毕后再切场景,避免队列中残余命令在新场景报错
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(() =>
|
||||
MainGameManager.Instance.ExitCombat(isVictory: true, heroResults)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 战斗失败处理:通知 MainGameManager 删档并返回主菜单。
|
||||
/// </summary>
|
||||
private void CombatDefeat()
|
||||
{
|
||||
CombatUIManager.Instance.combatMainPage.SetButtonAction(null, "Defeat...", false);
|
||||
|
||||
CommandQueueManager.Instance.AddCommand(Cmd.Do(() =>
|
||||
MainGameManager.Instance.ExitCombat(isVictory: false)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,29 +8,36 @@ using UnityEngine;
|
||||
|
||||
namespace Continentis.MainGame.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// 播放角色动画的命令。
|
||||
/// 优先使用 ICharacterAnimator 接口驱动,同时保留帧精确事件轮询能力。
|
||||
/// 当角色使用 FrameAnimator(AnimatorPlus2D)时支持按帧/归一化时间触发 Action。
|
||||
/// </summary>
|
||||
public class Cmd_PlayAnimation : CommandBase
|
||||
{
|
||||
private readonly CombatCharacterViewBase characterView;
|
||||
private readonly Animator animator;
|
||||
private readonly ICharacterAnimator characterAnimator;
|
||||
private readonly bool waitForFinish;
|
||||
private readonly float overrideDuration;
|
||||
private readonly string animationName;
|
||||
private readonly int layer;
|
||||
|
||||
private AnimationClip clip;
|
||||
private float ClipScaledLength => clip.length / animator.speed;
|
||||
private float ClipLength => clip != null ? clip.length : DefaultAnimationDuration;
|
||||
private readonly Dictionary<float, Action> animationActions = new Dictionary<float, Action>();
|
||||
|
||||
private const float DefaultAnimationDuration = 0.5f;
|
||||
|
||||
public Cmd_PlayAnimation(CombatCharacterViewBase characterView, string animationName,
|
||||
bool waitForFinish = true, float overrideDuration = -1f, int layer = 0)
|
||||
{
|
||||
this.characterView = characterView;
|
||||
this.animator = characterView.animator;
|
||||
this.characterAnimator = characterView.CharacterAnimator;
|
||||
this.animationName = animationName;
|
||||
this.waitForFinish = waitForFinish;
|
||||
this.overrideDuration = overrideDuration;
|
||||
this.layer = layer;
|
||||
characterView.animations.TryGetValue(animationName, out clip);
|
||||
|
||||
// 从 CharacterData 获取 AnimationClip 引用(用于计算时长和帧事件)
|
||||
characterView.character.data.animations.TryGetValue(animationName, out clip);
|
||||
}
|
||||
|
||||
/// <summary>在动画的指定归一化时间点(0~1)执行 Action。</summary>
|
||||
@@ -66,22 +73,14 @@ namespace Continentis.MainGame.Commands
|
||||
|
||||
protected override async UniTask ExecuteAsync(CommandContext outerContext)
|
||||
{
|
||||
if (animator == null || string.IsNullOrEmpty(animationName))
|
||||
if (characterAnimator == null || string.IsNullOrEmpty(animationName))
|
||||
{
|
||||
Debug.LogWarning("[Cmd_PlayAnimation] Animator 或动画名称为空。");
|
||||
Debug.LogWarning("[Cmd_PlayAnimation] CharacterAnimator 或动画名称为空。");
|
||||
return;
|
||||
}
|
||||
|
||||
// 确认播放目标动画,回退到 "Action"
|
||||
string finalName = characterView.animations.ContainsKey(animationName) ? animationName : "Action";
|
||||
|
||||
if (!characterView.animations.TryGetValue(finalName, out clip))
|
||||
{
|
||||
Debug.LogWarning($"[Cmd_PlayAnimation] 找不到动画片段:{finalName}");
|
||||
return;
|
||||
}
|
||||
|
||||
characterView.animatorPlus2D.Play(clip);
|
||||
// 通过 ICharacterAnimator 播放
|
||||
characterAnimator.PlayAction(animationName);
|
||||
|
||||
// 帧轮询动画事件(fire-and-forget,不阻塞命令流)
|
||||
if (animationActions.Count > 0)
|
||||
@@ -89,22 +88,25 @@ namespace Continentis.MainGame.Commands
|
||||
|
||||
if (waitForFinish)
|
||||
{
|
||||
float duration = overrideDuration >= 0f ? overrideDuration / animator.speed : ClipScaledLength;
|
||||
float duration = overrideDuration >= 0f ? overrideDuration : ClipLength;
|
||||
await UniTask.Delay(TimeSpan.FromSeconds(duration));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 基于经过时间轮询帧事件,兼容所有动画驱动器。
|
||||
/// </summary>
|
||||
private async UniTaskVoid PollAnimationActionsAsync()
|
||||
{
|
||||
float elapsed = 0f;
|
||||
float totalDuration = ClipScaledLength;
|
||||
float totalDuration = ClipLength;
|
||||
var pending = new Dictionary<float, Action>(animationActions);
|
||||
|
||||
while (elapsed < totalDuration && pending.Count > 0)
|
||||
{
|
||||
await UniTask.Yield(PlayerLoopTiming.Update);
|
||||
elapsed += Time.deltaTime;
|
||||
float normalizedTime = animator.GetCurrentAnimatorStateInfo(layer).normalizedTime % 1f;
|
||||
float normalizedTime = Mathf.Clamp01(elapsed / totalDuration);
|
||||
|
||||
foreach (float key in pending.Keys.ToList())
|
||||
{
|
||||
|
||||
@@ -1,39 +1,53 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Continentis.MainGame.Character;
|
||||
using Continentis.MainGame.Saving;
|
||||
using SLSFramework.General;
|
||||
using SLSFramework.UModAssistance;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Serialization;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
namespace Continentis.MainGame
|
||||
{
|
||||
// ── 常量与核心字段 ──────────────────────────────────────────────────────────
|
||||
public partial class MainGameManager : Singleton<MainGameManager>
|
||||
{
|
||||
private const string CombatSceneName = "GameScene";
|
||||
private const string MenuSceneName = "MenuScene";
|
||||
|
||||
public readonly HashSet<string> elementTags = new HashSet<string>()
|
||||
{
|
||||
"Wind", "Fire", "Water", "Earth", "Storm", "Light", "Darkness", "Physics"
|
||||
"Wind", "Fire", "Ice", "Earth", "Storm", "Light", "Darkness", "Physics"
|
||||
};
|
||||
|
||||
public BasePrefabs basePrefabs;
|
||||
}
|
||||
|
||||
// ── 游戏数据字段 ────────────────────────────────────────────────────────────
|
||||
public partial class MainGameManager
|
||||
{
|
||||
/// <summary>全关键词合并表,由各 Mod 的 KeywordData 合并而来。</summary>
|
||||
public KeywordData keywordData;
|
||||
public CombatOrganizer organizer;
|
||||
public List<CharacterData> playerHeroDataList;
|
||||
public List<CharacterData> enemyDataList;
|
||||
|
||||
/// <summary>当前跑局的存档快照,既是运行时状态也是持久化来源。null 表示无进行中的跑局。</summary>
|
||||
public RunSave currentRun;
|
||||
|
||||
/// <summary>当前战斗的玩家英雄 CharacterData 列表,由 PrepareCombat() 填充。</summary>
|
||||
public List<CharacterData> playerHeroDataList = new List<CharacterData>();
|
||||
|
||||
/// <summary>当前战斗的敌方 CharacterData 列表,由 PrepareCombat() 填充。</summary>
|
||||
public List<CharacterData> enemyDataList = new List<CharacterData>();
|
||||
}
|
||||
|
||||
|
||||
// ── 初始化 ──────────────────────────────────────────────────────────────────
|
||||
public partial class MainGameManager
|
||||
{
|
||||
protected override void Awake()
|
||||
{
|
||||
base.Awake();
|
||||
|
||||
// 合并所有 Mod 的 KeywordData 到全局查询表
|
||||
keywordData = ScriptableObject.CreateInstance<KeywordData>();
|
||||
foreach (KeyValuePair<string,ScriptableObject> pair in ModManager.Database[typeof(KeywordData)])
|
||||
foreach (KeyValuePair<string, ScriptableObject> pair in ModManager.Database[typeof(KeywordData)])
|
||||
{
|
||||
KeywordData data = pair.Value as KeywordData;
|
||||
foreach (var keyword in data!.interpretedKeywords)
|
||||
@@ -41,26 +55,193 @@ namespace Continentis.MainGame
|
||||
keywordData.interpretedKeywords.TryAdd(keyword.Key, keyword.Value);
|
||||
}
|
||||
}
|
||||
|
||||
organizer = ModManager.GetAsset<CombatOrganizer>("Basic", "Basic_CombatOrganizer");
|
||||
Debug.Log($"Organizer exists: {organizer != null}");
|
||||
foreach (string character in organizer.playerCharacters)
|
||||
|
||||
// 消费 InformationTransistor 中的主菜单意图
|
||||
InfoTransistor transistor = InfoTransistor.Instance;
|
||||
if (transistor == null)
|
||||
{
|
||||
Debug.Log($"Loading player character: {character}");
|
||||
playerHeroDataList.Add(ModManager.GetData<CharacterData>(character));
|
||||
Debug.LogError("[MainGame] InformationTransistor 未找到,无法确定跑局意图。");
|
||||
return;
|
||||
}
|
||||
|
||||
InfoTransistor.Menu.MenuIntent intent = transistor.menuToMainGame.menuIntent;
|
||||
RunConfig config = transistor.menuToMainGame.pendingRunConfig;
|
||||
|
||||
foreach (string character in organizer.enemyCharacters)
|
||||
Debug.Log($"[MainGame] Awake:收到主菜单意图 {intent},RunConfig {(config != null ? config.name : "null")}。");
|
||||
// 消费后重置,避免场景重新加载时重复执行
|
||||
transistor.menuToMainGame.menuIntent = InfoTransistor.Menu.MenuIntent.None;
|
||||
transistor.menuToMainGame.pendingRunConfig = null;
|
||||
|
||||
switch (intent)
|
||||
{
|
||||
Debug.Log($"Loading enemy character: {character}");
|
||||
enemyDataList.Add(ModManager.GetData<CharacterData>(character));
|
||||
case InfoTransistor.Menu.MenuIntent.StartNewRun:
|
||||
StartNewRun(config);
|
||||
break;
|
||||
case InfoTransistor.Menu.MenuIntent.ContinueRun:
|
||||
ContinueRun();
|
||||
break;
|
||||
default:
|
||||
Debug.LogWarning("[MainGame] Awake 时未收到有效的跑局意图,跳过初始化。");
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// ── 跑局流程 API ────────────────────────────────────────────────────────────
|
||||
public partial class MainGameManager
|
||||
{
|
||||
/// <summary>
|
||||
/// 开始一次新跑局:从 RunConfig 初始化英雄和关卡序列,写入开局存档,准备首场战斗数据。
|
||||
/// RunConfig 仅在此处读取一次,结果完整拷贝到 RunSave,后续不再查询它。
|
||||
/// 由 Awake 消费 StartNewRun 意图时调用。
|
||||
/// </summary>
|
||||
private void StartNewRun(RunConfig config)
|
||||
{
|
||||
currentRun = new RunSave(
|
||||
Random.Range(int.MinValue, int.MaxValue),
|
||||
config.startingGold,
|
||||
new List<string>(config.encounterSequenceIDs)
|
||||
);
|
||||
|
||||
foreach (string heroID in config.initialHeroIDs)
|
||||
{
|
||||
CharacterData data = CharacterData.Get(heroID);
|
||||
if (data == null)
|
||||
{
|
||||
Debug.LogError($"[MainGame] StartNewRun:找不到角色数据 '{heroID}',已跳过。");
|
||||
continue;
|
||||
}
|
||||
|
||||
int maxHP = Mathf.RoundToInt(data.coreAttributes.TryGetValue("MaximumHealth", out float hp) ? hp : 0f);
|
||||
HeroSave hero = new HeroSave(heroID, maxHP, maxHP);
|
||||
|
||||
foreach (string cardID in data.initialDeckRef)
|
||||
hero.deck.Add(new CardSave(cardID));
|
||||
|
||||
currentRun.heroes.Add(hero);
|
||||
}
|
||||
|
||||
SaveManager.Instance.SaveRunSave(currentRun);
|
||||
Debug.Log($"[MainGame] 新跑局开始(配置:{config.name},节点数:{currentRun.combatNodeIDs.Count},种子:{currentRun.randomSeed})");
|
||||
|
||||
PrepareCombat(currentRun.currentNodeIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 SaveManager 缓存恢复跑局并准备当前节点的战斗数据。
|
||||
/// 由 Awake 消费 ContinueRun 意图时调用。
|
||||
/// </summary>
|
||||
private void ContinueRun()
|
||||
{
|
||||
currentRun = SaveManager.Instance.GetRunSave();
|
||||
if (currentRun == null)
|
||||
{
|
||||
Debug.LogError("[MainGame] ContinueRun:SaveManager 缓存中无进行中的跑局。");
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.Log($"[MainGame] 继续跑局(节点 {currentRun.currentNodeIndex} / {currentRun.combatNodeIDs.Count})");
|
||||
PrepareCombat(currentRun.currentNodeIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据节点索引填充玩家英雄和敌方角色列表。
|
||||
/// 仅准备运行时数据供 CombatMainManager 使用,不涉及场景加载。
|
||||
/// </summary>
|
||||
private void PrepareCombat(int nodeIndex)
|
||||
{
|
||||
if (nodeIndex >= currentRun.combatNodeIDs.Count)
|
||||
{
|
||||
Debug.Log("[MainGame] 所有关卡已通关,跑局结束!");
|
||||
// TODO: Phase 4 — 显示通关结算界面
|
||||
return;
|
||||
}
|
||||
|
||||
// 填充玩家英雄列表
|
||||
playerHeroDataList.Clear();
|
||||
foreach (HeroSave hero in currentRun.heroes)
|
||||
{
|
||||
CharacterData data = CharacterData.Get(hero.characterDataID);
|
||||
if (data != null)
|
||||
playerHeroDataList.Add(data);
|
||||
else
|
||||
Debug.LogError($"[MainGame] PrepareCombat:找不到角色数据 '{hero.characterDataID}'。");
|
||||
}
|
||||
|
||||
// 填充敌方列表
|
||||
string nodeID = currentRun.combatNodeIDs[nodeIndex];
|
||||
CombatNodeData nodeData = CombatNodeData.Get(nodeID);
|
||||
if (nodeData == null)
|
||||
{
|
||||
Debug.LogError($"[MainGame] PrepareCombat:找不到 CombatNodeData '{nodeID}'。");
|
||||
return;
|
||||
}
|
||||
|
||||
enemyDataList.Clear();
|
||||
foreach (string enemyID in nodeData.enemyCharacterIDs)
|
||||
{
|
||||
CharacterData data = CharacterData.Get(enemyID);
|
||||
if (data != null)
|
||||
enemyDataList.Add(data);
|
||||
else
|
||||
Debug.LogError($"[MainGame] PrepareCombat:找不到敌方角色数据 '{enemyID}'。");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 战斗结束回调,由 CombatMainManager 调用。
|
||||
/// 胜利时回写 HP、推进节点并存档,然后通过 InfoTransistor 写入 ContinueRun 意图并重载场景。
|
||||
/// 失败时清除跑局存档(保留 PlayerSave)并返回主菜单。
|
||||
/// 注意:MainGameManager 是场景局部单例,重载场景后当前实例会被销毁,
|
||||
/// 新实例的 Awake 会从 InfoTransistor 消费意图并重新初始化。
|
||||
/// </summary>
|
||||
public void ExitCombat(bool isVictory, IReadOnlyList<(string heroID, int currentHP)> heroResults = null)
|
||||
{
|
||||
if (isVictory)
|
||||
{
|
||||
if (heroResults != null)
|
||||
{
|
||||
foreach (var (heroID, hp) in heroResults)
|
||||
{
|
||||
HeroSave hero = currentRun.heroes.Find(h => h.characterDataID == heroID);
|
||||
if (hero != null)
|
||||
hero.currentHP = hp;
|
||||
}
|
||||
}
|
||||
|
||||
currentRun.currentNodeIndex++;
|
||||
SaveManager.Instance.SaveRunSave(currentRun);
|
||||
Debug.Log($"[MainGame] 战斗胜利,节点推进至 {currentRun.currentNodeIndex},存档已写入。");
|
||||
|
||||
// 检查是否所有节点已通关
|
||||
if (currentRun.currentNodeIndex >= currentRun.combatNodeIDs.Count)
|
||||
{
|
||||
Debug.Log("[MainGame] 所有关卡已通关,跑局结束!");
|
||||
// TODO: Phase 4 — 显示通关结算界面,之后返回主菜单
|
||||
return;
|
||||
}
|
||||
|
||||
// 写入 ContinueRun 意图,重载场景以启动下一场战斗
|
||||
// TODO: Phase 4 — 先进入奖励选择界面,完成后再走此流程
|
||||
InfoTransistor transistor = InfoTransistor.Instance;
|
||||
transistor.menuToMainGame.menuIntent = InfoTransistor.Menu.MenuIntent.ContinueRun;
|
||||
transistor.menuToMainGame.pendingRunConfig = null;
|
||||
SceneManager.LoadScene(CombatSceneName);
|
||||
}
|
||||
else
|
||||
{
|
||||
SaveManager.Instance.DeleteRunSave();
|
||||
Debug.Log("[MainGame] 战斗失败,跑局存档已清除(PlayerSave 保留),返回主菜单。");
|
||||
SceneManager.LoadScene(MenuSceneName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 静态工具方法 ────────────────────────────────────────────────────────────
|
||||
public partial class MainGameManager
|
||||
{
|
||||
public static void GenerateInfoText(string content, CombatCharacterViewBase characterView, Color color = default, float size = 1)
|
||||
public static void GenerateInfoText(string content, CombatCharacterViewBase characterView, Color color = default, float size = 1)
|
||||
=> Instance.basePrefabs.GenerateInfoText(content, characterView, color, size);
|
||||
}
|
||||
}
|
||||
8
Assets/Scripts/MainGame/Run.meta
Normal file
8
Assets/Scripts/MainGame/Run.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b17e93b21d881114683507aa86efc1e9
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
27
Assets/Scripts/MainGame/Run/CombatNodeData.cs
Normal file
27
Assets/Scripts/MainGame/Run/CombatNodeData.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.Collections.Generic;
|
||||
using SLSFramework.UModAssistance;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Continentis.MainGame
|
||||
{
|
||||
/// <summary>
|
||||
/// 单场战斗遭遇的配置资产(设计时资产,由 Mod 制作者在 Editor 中配置)。
|
||||
/// 定义该节点出现的敌方阵容。
|
||||
/// DataID 格式:CombatNodeData_ModName_NodeName
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Continentis/Run/CombatNodeData", fileName = "CombatNodeData")]
|
||||
public class CombatNodeData : ScriptableObject
|
||||
{
|
||||
[Header("敌方阵容")]
|
||||
[Tooltip("敌方角色的 CharacterData DataID 列表,格式:CharacterData_ModName_EnemyName")]
|
||||
public List<string> enemyCharacterIDs;
|
||||
|
||||
/// <summary>
|
||||
/// 通过 DataID 从 ModManager 数据库查找 CombatNodeData。
|
||||
/// </summary>
|
||||
public static CombatNodeData Get(string dataID)
|
||||
{
|
||||
return ModManager.GetData<CombatNodeData>(dataID);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/MainGame/Run/CombatNodeData.cs.meta
Normal file
2
Assets/Scripts/MainGame/Run/CombatNodeData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ef3a02eaec5c6dc4a9bc831346b28f8e
|
||||
28
Assets/Scripts/MainGame/Run/RunConfig.cs
Normal file
28
Assets/Scripts/MainGame/Run/RunConfig.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System.Collections.Generic;
|
||||
using SLSFramework.UModAssistance;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Continentis.MainGame
|
||||
{
|
||||
/// <summary>
|
||||
/// 跑局规则配置(设计时资产,由 Mod 制作者在 Editor 中配置)。
|
||||
/// 仅在 MainGameManager.StartNewRun() 时读取一次:
|
||||
/// 英雄列表用于初始化 HeroSave,关卡序列拷贝至 RunSave.combatNodeIDs。
|
||||
/// 跑局进行中不再查询此资产。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Continentis/Run/RunConfig", fileName = "RunConfig")]
|
||||
public class RunConfig : ScriptableObject
|
||||
{
|
||||
[Header("英雄配置")]
|
||||
[Tooltip("初始英雄的 CharacterData DataID 列表,格式:CharacterData_ModName_HeroName")]
|
||||
public List<string> initialHeroIDs;
|
||||
|
||||
[Header("关卡序列")]
|
||||
[Tooltip("线性关卡序列的 CombatNodeData DataID 列表,按顺序推进,格式:CombatNodeData_ModName_NodeName")]
|
||||
public List<string> encounterSequenceIDs;
|
||||
|
||||
[Header("起始资源")]
|
||||
[Tooltip("跑局开始时的初始金币数量")]
|
||||
public int startingGold;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/MainGame/Run/RunConfig.cs.meta
Normal file
2
Assets/Scripts/MainGame/Run/RunConfig.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7c9f10354a642a848a3bdbdf63ae5c07
|
||||
8
Assets/Scripts/MainGame/Saving.meta
Normal file
8
Assets/Scripts/MainGame/Saving.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 175507222ecd9de478dd227f1cc02a8d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
40
Assets/Scripts/MainGame/Saving/GameSave.cs
Normal file
40
Assets/Scripts/MainGame/Saving/GameSave.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
|
||||
namespace Continentis.MainGame.Saving
|
||||
{
|
||||
/// <summary>
|
||||
/// 游戏存档的根节点,作为单一顶层对象写入 ES3 文件。
|
||||
/// ES3 用法:ES3.Save("GameSave", gameSave, "save.es3")
|
||||
/// ES3.Load<GameSave>("GameSave", "save.es3")
|
||||
/// </summary>
|
||||
public class GameSave
|
||||
{
|
||||
/// <summary>
|
||||
/// 存档格式版本号。当数据结构发生破坏性变更时递增,
|
||||
/// SaveManager 读取时据此执行迁移逻辑。
|
||||
/// </summary>
|
||||
public int saveVersion = 1;
|
||||
|
||||
/// <summary>最后一次保存的时间戳(UTC)。</summary>
|
||||
public DateTime saveTime;
|
||||
|
||||
/// <summary>
|
||||
/// 玩家账号级别数据(解锁内容、统计数据),跨跑局持久。
|
||||
/// </summary>
|
||||
public PlayerSave playerData;
|
||||
|
||||
/// <summary>
|
||||
/// 当前进行中的跑局快照。null 表示无进行中的跑局(主菜单状态)。
|
||||
/// </summary>
|
||||
public RunSave currentRun;
|
||||
|
||||
public GameSave()
|
||||
{
|
||||
playerData = new PlayerSave();
|
||||
currentRun = null;
|
||||
}
|
||||
|
||||
/// <summary>判断当前是否存在未完成的跑局。</summary>
|
||||
public bool HasActiveRun => currentRun != null;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/MainGame/Saving/GameSave.cs.meta
Normal file
2
Assets/Scripts/MainGame/Saving/GameSave.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a6d5fe0b6ae9a064b839dd40c4ad91d8
|
||||
8
Assets/Scripts/MainGame/Saving/PlayerSave.meta
Normal file
8
Assets/Scripts/MainGame/Saving/PlayerSave.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 30a96afbfcef5ed46b1813d61f7825ef
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
31
Assets/Scripts/MainGame/Saving/PlayerSave/PlayerSave.cs
Normal file
31
Assets/Scripts/MainGame/Saving/PlayerSave/PlayerSave.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Continentis.MainGame.Saving
|
||||
{
|
||||
/// <summary>
|
||||
/// 玩家账号级别的存档数据。
|
||||
/// 跨跑局持久,记录解锁内容和统计数据。
|
||||
/// </summary>
|
||||
public class PlayerSave
|
||||
{
|
||||
/// <summary>总跑局次数(含失败)。</summary>
|
||||
public int totalRuns;
|
||||
|
||||
/// <summary>总胜利次数(成功通关次数)。</summary>
|
||||
public int totalVictories;
|
||||
|
||||
/// <summary>
|
||||
/// 已解锁内容的 DataID 列表。
|
||||
/// 供菜单界面、职业选择、卡牌图鉴等系统查询。
|
||||
/// 格式与 ModManager 资产 DataID 保持一致:Type_ModName_AssetName
|
||||
/// </summary>
|
||||
public List<string> unlockedContentIDs;
|
||||
|
||||
// TODO: 后续扩展 — 成就记录、统计数据(最高伤害、最长存活回合等)
|
||||
|
||||
public PlayerSave()
|
||||
{
|
||||
unlockedContentIDs = new List<string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b12d4d21c439d5c41ae0c797248037d8
|
||||
8
Assets/Scripts/MainGame/Saving/RunSave.meta
Normal file
8
Assets/Scripts/MainGame/Saving/RunSave.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c9283203f9ca79f4e96919a472f55d65
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
28
Assets/Scripts/MainGame/Saving/RunSave/CardSave.cs
Normal file
28
Assets/Scripts/MainGame/Saving/RunSave/CardSave.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace Continentis.MainGame.Saving
|
||||
{
|
||||
/// <summary>
|
||||
/// 单张卡牌的存档快照。
|
||||
/// 只记录能够重建运行时 CardInstance 所需的最小信息。
|
||||
/// </summary>
|
||||
public class CardSave
|
||||
{
|
||||
/// <summary>
|
||||
/// 卡牌数据 DataID,格式:CardData_ModName_CardName
|
||||
/// 通过 ModManager.GetData<CardData>(cardDataID) 还原。
|
||||
/// </summary>
|
||||
public string cardDataID;
|
||||
|
||||
/// <summary>
|
||||
/// 升级层数(0 = 未升级)。
|
||||
/// </summary>
|
||||
public int upgradeLevel;
|
||||
|
||||
public CardSave() { }
|
||||
|
||||
public CardSave(string cardDataID, int upgradeLevel = 0)
|
||||
{
|
||||
this.cardDataID = cardDataID;
|
||||
this.upgradeLevel = upgradeLevel;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/MainGame/Saving/RunSave/CardSave.cs.meta
Normal file
2
Assets/Scripts/MainGame/Saving/RunSave/CardSave.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e280529f33e40e4408aeaff395007ad6
|
||||
53
Assets/Scripts/MainGame/Saving/RunSave/HeroSave.cs
Normal file
53
Assets/Scripts/MainGame/Saving/RunSave/HeroSave.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Continentis.MainGame.Saving
|
||||
{
|
||||
/// <summary>
|
||||
/// 单个玩家英雄的存档快照。
|
||||
/// 记录英雄在跑局中跨战斗保持的所有可变状态。
|
||||
/// </summary>
|
||||
public class HeroSave
|
||||
{
|
||||
/// <summary>
|
||||
/// 英雄角色数据 DataID,格式:CharacterData_ModName_HeroName
|
||||
/// 通过 ModManager.GetData<CharacterData>(characterDataID) 还原。
|
||||
/// </summary>
|
||||
public string characterDataID;
|
||||
|
||||
/// <summary>
|
||||
/// 当前 HP(跨战斗保持,受到伤害后降低,不自动回满)。
|
||||
/// </summary>
|
||||
public int currentHP;
|
||||
|
||||
/// <summary>
|
||||
/// 当前最大 HP(初始值来自 CharacterData,装备或跑局事件可能修改)。
|
||||
/// </summary>
|
||||
public int maxHP;
|
||||
|
||||
/// <summary>
|
||||
/// 当前完整牌组快照,包括初始牌、奖励牌和装备附带牌。
|
||||
/// </summary>
|
||||
public List<CardSave> deck;
|
||||
|
||||
/// <summary>
|
||||
/// 当前装备 DataID 列表,格式:EquipmentData_ModName_EquipmentName
|
||||
/// 通过 ModManager.GetData<EquipmentData>(id) 还原。
|
||||
/// </summary>
|
||||
public List<string> equipmentIDs;
|
||||
|
||||
public HeroSave()
|
||||
{
|
||||
deck = new List<CardSave>();
|
||||
equipmentIDs = new List<string>();
|
||||
}
|
||||
|
||||
public HeroSave(string characterDataID, int currentHP, int maxHP)
|
||||
{
|
||||
this.characterDataID = characterDataID;
|
||||
this.currentHP = currentHP;
|
||||
this.maxHP = maxHP;
|
||||
deck = new List<CardSave>();
|
||||
equipmentIDs = new List<string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/MainGame/Saving/RunSave/HeroSave.cs.meta
Normal file
2
Assets/Scripts/MainGame/Saving/RunSave/HeroSave.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f70f65f2802e0e942bc433d15442e5af
|
||||
59
Assets/Scripts/MainGame/Saving/RunSave/RunSave.cs
Normal file
59
Assets/Scripts/MainGame/Saving/RunSave/RunSave.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Continentis.MainGame.Saving
|
||||
{
|
||||
/// <summary>
|
||||
/// 当次跑局的完整存档快照。
|
||||
/// 记录跑局开始配置、当前进度、资源,以及所有英雄的状态。
|
||||
/// </summary>
|
||||
public class RunSave
|
||||
{
|
||||
/// <summary>
|
||||
/// 当次跑局使用的配置 DataID,格式:RunConfig_ModName_ConfigName
|
||||
/// 通过 ModManager.GetData<RunConfig>(runConfigID) 还原关卡序列等初始设置。
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// 当前关卡序列进度,即 combatNodeIDs 的当前索引。
|
||||
/// </summary>
|
||||
public int currentNodeIndex;
|
||||
|
||||
/// <summary>
|
||||
/// 关卡序列快照:开局时从 RunConfig.encounterSequenceIDs 拷贝而来。
|
||||
/// 格式:CombatNodeData_ModName_NodeName
|
||||
/// EnterCombat 直接读取此列表,跑局中无需再查询 RunConfig。
|
||||
/// </summary>
|
||||
public List<string> combatNodeIDs;
|
||||
|
||||
/// <summary>
|
||||
/// 当前金币数量。
|
||||
/// </summary>
|
||||
public int gold;
|
||||
|
||||
/// <summary>
|
||||
/// 跑局随机种子,用于保证重新进入游戏后奖励池与随机事件一致。
|
||||
/// </summary>
|
||||
public int randomSeed;
|
||||
|
||||
/// <summary>
|
||||
/// 所有玩家英雄的状态快照列表,顺序与 RunConfig.initialHeroIDs 一致。
|
||||
/// </summary>
|
||||
public List<HeroSave> heroes;
|
||||
|
||||
// TODO: Phase 4+ — 添加 relics(遗物列表)和 mapNodeRecord(地图选择记录)
|
||||
|
||||
public RunSave()
|
||||
{
|
||||
combatNodeIDs = new List<string>();
|
||||
heroes = new List<HeroSave>();
|
||||
}
|
||||
|
||||
public RunSave(int randomSeed, int startingGold, List<string> combatNodeIDs)
|
||||
{
|
||||
this.randomSeed = randomSeed;
|
||||
this.gold = startingGold;
|
||||
this.combatNodeIDs = combatNodeIDs ?? new List<string>();
|
||||
this.currentNodeIndex = 0;
|
||||
heroes = new List<HeroSave>();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/MainGame/Saving/RunSave/RunSave.cs.meta
Normal file
2
Assets/Scripts/MainGame/Saving/RunSave/RunSave.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 57ef00a1f49761341923dcb281587be4
|
||||
123
Assets/Scripts/MainGame/Saving/SaveManager.cs
Normal file
123
Assets/Scripts/MainGame/Saving/SaveManager.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using Continentis.MainGame.Saving;
|
||||
using SLSFramework.General;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Continentis.MainGame
|
||||
{
|
||||
/// <summary>
|
||||
/// 负责游戏存档的读写、校验和删除。
|
||||
/// 跨场景持久,挂载在初始场景的 SaveManager GameObject 上。
|
||||
/// 内存中缓存一份 GameSave,所有操作均通过缓存进行,
|
||||
/// 保证 PlayerSave(成就、统计)不会被跑局操作意外覆盖。
|
||||
/// </summary>
|
||||
public class SaveManager : Singleton<SaveManager>
|
||||
{
|
||||
private const string SaveKey = "GameSave";
|
||||
private const string SaveFilePath = "save.es3";
|
||||
|
||||
/// <summary>内存中的存档缓存,Awake 时从文件加载,不存在则新建。</summary>
|
||||
private GameSave _cache;
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────
|
||||
|
||||
protected override void Awake()
|
||||
{
|
||||
Initialize(dontDestroy: true);
|
||||
LoadOrCreate();
|
||||
}
|
||||
|
||||
// ── 查询 API ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>返回缓存中的 PlayerSave(账号级数据)。</summary>
|
||||
public PlayerSave PlayerData => _cache.playerData;
|
||||
|
||||
/// <summary>判断当前是否存在未完成的跑局。</summary>
|
||||
public bool HasActiveRun() => _cache.HasActiveRun;
|
||||
|
||||
// ── 跑局存档 API ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 将 RunSave 写入缓存并持久化。
|
||||
/// 由 MainGameManager 在开局和节点边界(战斗胜利后)调用。
|
||||
/// </summary>
|
||||
public void SaveRunSave(RunSave runSave)
|
||||
{
|
||||
_cache.currentRun = runSave;
|
||||
Flush();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除当前跑局(战斗失败 / 放弃跑局),保留 PlayerSave。
|
||||
/// </summary>
|
||||
public void DeleteRunSave()
|
||||
{
|
||||
_cache.currentRun = null;
|
||||
Flush();
|
||||
Debug.Log("[Save] 跑局存档已清除(PlayerSave 保留)。");
|
||||
}
|
||||
|
||||
/// <summary>返回缓存中的 RunSave,若无进行中的跑局则返回 null。</summary>
|
||||
public RunSave GetRunSave() => _cache.currentRun;
|
||||
|
||||
// ── 账号存档 API ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 更新 PlayerSave 并持久化(解锁内容、统计数据变更时调用)。
|
||||
/// </summary>
|
||||
public void SavePlayerData(PlayerSave playerSave)
|
||||
{
|
||||
_cache.playerData = playerSave;
|
||||
Flush();
|
||||
}
|
||||
|
||||
// ── 全局存档 API ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 清除所有存档数据,包括 PlayerSave(账号重置时调用)。
|
||||
/// </summary>
|
||||
public void DeleteAllData()
|
||||
{
|
||||
if (ES3.FileExists(SaveFilePath))
|
||||
{
|
||||
ES3.DeleteFile(SaveFilePath);
|
||||
Debug.Log("[Save] 所有存档已删除。");
|
||||
}
|
||||
|
||||
_cache = new GameSave();
|
||||
}
|
||||
|
||||
// ── 内部方法 ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>从文件加载 GameSave;文件不存在则新建并立即持久化。</summary>
|
||||
private void LoadOrCreate()
|
||||
{
|
||||
if (!ES3.FileExists(SaveFilePath))
|
||||
{
|
||||
_cache = new GameSave();
|
||||
Flush();
|
||||
Debug.Log("[Save] 未找到存档文件,已新建。");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_cache = ES3.Load<GameSave>(SaveKey, SaveFilePath, defaultValue: null) ?? new GameSave();
|
||||
Debug.Log($"[Save] 存档加载成功(版本 {_cache.saveVersion})。");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[Save] 存档加载异常,将新建:{e.Message}");
|
||||
_cache = new GameSave();
|
||||
Flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>将当前缓存写入文件。</summary>
|
||||
private void Flush()
|
||||
{
|
||||
_cache.saveTime = DateTime.UtcNow;
|
||||
ES3.Save<GameSave>(SaveKey, _cache, SaveFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/MainGame/Saving/SaveManager.cs.meta
Normal file
2
Assets/Scripts/MainGame/Saving/SaveManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ac19ca7000e542e4390e00760eda2fad
|
||||
@@ -26,6 +26,17 @@ namespace Continentis.MainGame.UI
|
||||
{
|
||||
foreach (var pointerArrow in pointerArrows) LeanPool.Despawn(pointerArrow.gameObject);
|
||||
pointerArrows.Clear();
|
||||
mainPointerArrow = null;
|
||||
}
|
||||
|
||||
/// <summary>移除并回收最后一支箭头(用于多目标选择的撤销操作)。</summary>
|
||||
public void RemoveLastPointerArrow()
|
||||
{
|
||||
if (pointerArrows.Count == 0) return;
|
||||
PointerArrow last = pointerArrows[^1];
|
||||
pointerArrows.RemoveAt(pointerArrows.Count - 1);
|
||||
if (last == mainPointerArrow) mainPointerArrow = null;
|
||||
LeanPool.Despawn(last.gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,29 @@ namespace Continentis.MainGame.UI
|
||||
public RoundHint roundHint;
|
||||
public Button endActionButton;
|
||||
|
||||
private Action currentButtonAction;
|
||||
|
||||
protected override void Awake()
|
||||
{
|
||||
base.Awake();
|
||||
endActionButton.onClick.AddListener(CombatMainManager.Instance.EndAction);
|
||||
endActionButton.onClick.AddListener(OnButtonClicked);
|
||||
// 默认绑定结束行动
|
||||
SetButtonAction(CombatMainManager.Instance.EndAction, "End Action", false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 切换按钮回调、文本和可交互状态,统一从此处管理。
|
||||
/// </summary>
|
||||
public void SetButtonAction(Action action, string label, bool interactable)
|
||||
{
|
||||
currentButtonAction = action;
|
||||
endActionButton.GetComponentInChildren<TMPro.TMP_Text>().text = label;
|
||||
endActionButton.interactable = interactable;
|
||||
}
|
||||
|
||||
private void OnButtonClicked()
|
||||
{
|
||||
currentButtonAction?.Invoke();
|
||||
}
|
||||
|
||||
public void ClearAllCardViews()
|
||||
|
||||
@@ -43,6 +43,13 @@ namespace Continentis.MainGame
|
||||
{
|
||||
combatMainPage.teamSwitchButton.UpdateTeamPileText(CombatMainManager.Instance.characterController.playerTeam);
|
||||
}
|
||||
|
||||
/// <summary>是否正处于多目标选择模式(左键/右键由 HandCardView 接管)。</summary>
|
||||
public bool IsInMultiTargetMode()
|
||||
{
|
||||
return selectingCardView is HandCardView handCard
|
||||
&& handCard.card.attributeSubmodule.targetCount > 1;
|
||||
}
|
||||
}
|
||||
|
||||
public partial class CombatUIManager
|
||||
@@ -53,6 +60,8 @@ namespace Continentis.MainGame
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool isMultiTargetMode = IsInMultiTargetMode();
|
||||
|
||||
Ray ray = combatCamera.ScreenPointToRay(Mouse.current.position.ReadValue());
|
||||
|
||||
@@ -63,7 +72,8 @@ namespace Continentis.MainGame
|
||||
hoveringCharacterView ??= hit.collider.GetComponent<CombatCharacterViewBase>();
|
||||
hoveringCharacterView.SetOutline(true);
|
||||
|
||||
if (Mouse.current.leftButton.wasPressedThisFrame)
|
||||
// 多目标模式下,左键由 HandCardView 处理,跳过默认的角色面板选中逻辑
|
||||
if (!isMultiTargetMode && Mouse.current.leftButton.wasPressedThisFrame)
|
||||
{
|
||||
if (hoveringCharacterView == selectingCharacterView)
|
||||
{
|
||||
@@ -80,7 +90,7 @@ namespace Continentis.MainGame
|
||||
}
|
||||
else
|
||||
{
|
||||
if (Mouse.current.leftButton.wasPressedThisFrame)
|
||||
if (!isMultiTargetMode && Mouse.current.leftButton.wasPressedThisFrame)
|
||||
{
|
||||
selectingCharacterView?.hudContainer.DisableHUD("SelectingDot");
|
||||
selectingCharacterView = null;
|
||||
@@ -92,7 +102,7 @@ namespace Continentis.MainGame
|
||||
hoveringCharacterView?.SetOutline(false);
|
||||
hoveringCharacterView = null;
|
||||
|
||||
if (Mouse.current.leftButton.wasPressedThisFrame)
|
||||
if (!isMultiTargetMode && Mouse.current.leftButton.wasPressedThisFrame)
|
||||
{
|
||||
selectingCharacterView?.hudContainer.DisableHUD("SelectingDot");
|
||||
selectingCharacterView = null;
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace Continentis.MainGame.UI
|
||||
|
||||
public void Highlight(bool isHighlighted)
|
||||
{
|
||||
Debug.Log($"Highlighting {character.data.displayName}: {isHighlighted}");
|
||||
//Debug.Log($"Highlighting {character.data.displayName}: {isHighlighted}");
|
||||
|
||||
if (isHighlighted)
|
||||
{
|
||||
|
||||
@@ -27,10 +27,10 @@ namespace Continentis.MainGame.UI
|
||||
|
||||
int currentBlock = currentAttributes.GetRoundValue("Block");
|
||||
int currentDodge = currentAttributes.GetRoundValue("Dodge");
|
||||
int currentShield = currentAttributes.GetRoundValue("Shield");
|
||||
int currentTemporaryHealth = currentAttributes.GetRoundValue("TemporaryHealth");
|
||||
defenseModule.UpdateBlock(currentBlock);
|
||||
defenseModule.UpdateDodge(currentDodge);
|
||||
defenseModule.UpdateShield(currentShield);
|
||||
defenseModule.UpdateShield(currentTemporaryHealth);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user