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

View File

@@ -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";
}
}

View File

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

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -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;
}

View 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;
}
}
}

View File

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

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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";
}
}

View File

@@ -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("布局标签")]

View File

@@ -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;
}
}
}
}

View File

@@ -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";
}
}

View File

@@ -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>

View File

@@ -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]; //后续可改为选择升级方向

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();

View File

@@ -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);

View File

@@ -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
}
}

View File

@@ -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);

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;

View File

@@ -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>>();
}
}
}

View File

@@ -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)}");
}
}
}

View File

@@ -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)));
}
}
}

View File

@@ -8,29 +8,36 @@ using UnityEngine;
namespace Continentis.MainGame.Commands
{
/// <summary>
/// 播放角色动画的命令。
/// 优先使用 ICharacterAnimator 接口驱动,同时保留帧精确事件轮询能力。
/// 当角色使用 FrameAnimatorAnimatorPlus2D时支持按帧/归一化时间触发 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())
{

View File

@@ -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] ContinueRunSaveManager 缓存中无进行中的跑局。");
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);
}
}

View File

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

View 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);
}
}
}

View File

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

View 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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7c9f10354a642a848a3bdbdf63ae5c07

View File

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

View File

@@ -0,0 +1,40 @@
using System;
namespace Continentis.MainGame.Saving
{
/// <summary>
/// 游戏存档的根节点,作为单一顶层对象写入 ES3 文件。
/// ES3 用法ES3.Save("GameSave", gameSave, "save.es3")
/// ES3.Load&lt;GameSave&gt;("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;
}
}

View File

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

View File

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

View 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>();
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
namespace Continentis.MainGame.Saving
{
/// <summary>
/// 单张卡牌的存档快照。
/// 只记录能够重建运行时 CardInstance 所需的最小信息。
/// </summary>
public class CardSave
{
/// <summary>
/// 卡牌数据 DataID格式CardData_ModName_CardName
/// 通过 ModManager.GetData&lt;CardData&gt;(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;
}
}
}

View File

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

View 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&lt;CharacterData&gt;(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&lt;EquipmentData&gt;(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>();
}
}
}

View File

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

View 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&lt;RunConfig&gt;(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>();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 57ef00a1f49761341923dcb281587be4

View 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);
}
}
}

View File

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

View File

@@ -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);
}
}
}

View File

@@ -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()

View File

@@ -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;

View File

@@ -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)
{

View File

@@ -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);
}
}