From dc2aa59d58a8427100415488f0e26b47f0ca8e82 Mon Sep 17 00:00:00 2001 From: SepComet <2428390463@qq.com> Date: Mon, 16 Mar 2026 11:53:24 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=A8=E9=83=A8=E5=88=86=20EventCom?= =?UTF-8?q?ponent=20=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/GameMain/DataTables/Event.txt | 12 +- .../CustomComponent/EventNodeComponent.cs | 46 +- .../InventoryGenerationComponent.cs | 193 ++++- .../InventoryGenerationRandomContext.cs | 1 + .../PlayerInventoryTowerRosterService.cs | 23 +- Assets/GameMain/Scripts/DataTable/DREvent.cs | 34 +- .../Definition/Enum/InventoryTagSourceType.cs | 1 + .../Event/EventEffect/AddRandomCompsEffect.cs | 22 +- .../Definition/Event/EventOptionExecutor.cs | 490 +++++++++++ .../Event/EventOptionExecutor.cs.meta | 11 + .../Generation/InventoryTagRandomContext.cs | 9 + .../Scripts/Factory/EventEffectFactory.cs | 27 + .../UI/Game/Context/EventOptionItemContext.cs | 2 + .../UI/Game/Context/RepoFormContext.cs | 1 + .../RepoFormController.ContextBuilder.cs | 2 + .../UI/Game/Controller/EventFormController.cs | 26 +- .../UI/Game/Controller/RepoFormController.cs | 4 +- .../UI/Game/RawData/EventFormRawData.cs | 15 +- .../UI/Game/UseCase/EventFormUseCase.cs | 80 +- .../Scripts/UI/Game/View/EventOptionItem.cs | 41 +- .../GameMain/Scripts/UI/Game/View/RepoForm.cs | 18 + Assets/GameMain/UI/UIForms/RepoForm.prefab | 172 ++++ .../EditMode/EventDefinitionFactoryTests.cs | 43 + .../EditMode/EventOptionExecutorTests.cs | 803 ++++++++++++++++++ .../EditMode/EventOptionExecutorTests.cs.meta | 11 + 数据表/Event.xlsx | Bin 12211 -> 12510 bytes 26 files changed, 2028 insertions(+), 59 deletions(-) create mode 100644 Assets/GameMain/Scripts/Definition/Event/EventOptionExecutor.cs create mode 100644 Assets/GameMain/Scripts/Definition/Event/EventOptionExecutor.cs.meta create mode 100644 Assets/Tests/EditMode/EventOptionExecutorTests.cs create mode 100644 Assets/Tests/EditMode/EventOptionExecutorTests.cs.meta diff --git a/Assets/GameMain/DataTables/Event.txt b/Assets/GameMain/DataTables/Event.txt index b8190fb..e69f4a6 100644 --- a/Assets/GameMain/DataTables/Event.txt +++ b/Assets/GameMain/DataTables/Event.txt @@ -1,6 +1,6 @@ -# Id 列1 Title Description Options -# int string string string -# 事件编号 策划备注 事件题目 事件描述 事件选项 - 1 赌马 一名商人邀请你下注。赢了就能赚一笔。 [{"optionText":"下注 100(金)- 稳健:70% 赢 150","requirements":[{"type":"GoldAtLeast","param":{"Count":100}}],"costEffects":[{"type":"AddGold","param":{"Count":-100}}],"rewardEffects":[{"type":"AddGold","param":{"Count":150}}],"probability":0.7},{"optionText":"下注 100(金)- 激进:30% 赢 250","requirements":[{"type":"GoldAtLeast","param":{"Count":100}}],"costEffects":[{"type":"AddGold","param":{"Count":-100}}],"rewardEffects":[{"type":"AddGold","param":{"Count":250}}],"probability":0.3}] - 2 工匠的熔炉 工匠以金币交换防御塔组件。 [{"optionText":"交出 3 个白色组件,获得 50 金币","requirements":[{"type":"CompCountAtLeast","param":{"Count":3,"Rarity":"White"}}],"costEffects":[{"type":"RemoveRandomComps","param":{"Count":3,"Rarity":"White"}}],"rewardEffects":[{"type":"AddGold","param":{"Count":50}}]},{"optionText":"交出 2 个绿色组件,获得 70 金币","requirements":[{"type":"CompCountAtLeast","param":{"Count":2,"Rarity":"Green"}}],"costEffects":[{"type":"RemoveRandomComps","param":{"Count":2,"Rarity":"Green"}}],"rewardEffects":[{"type":"AddGold","param":{"Count":70}}]},{"optionText":"交出 1 个蓝色组件,获得 80 金币","requirements":[{"type":"CompCountAtLeast","param":{"Count":1,"Rarity":"Blue"}}],"costEffects":[{"type":"RemoveRandomComps","param":{"Count":1,"Rarity":"Blue"}}],"rewardEffects":[{"type":"AddGold","param":{"Count":80}}]},{"optionText":"拒绝","rewardEffects":[]}] - 3 代价与回报 某种黑暗力量向你索取代价。 [{"optionText":"展示你的防御塔","requirements":[{"type":"TowerCountAtLeast","param":{"Count":2}}],"rewardEffects":[{"type":"AddGold","param":{"Count":50}}]},{"optionText":"离开","rewardEffects":[]}] +# Id 列1 Title Description Option1 Option2 Option3 Option4 +# int string string string string string string +# 事件编号 策划备注 事件题目 事件描述 选项 1 选项 2 选项 3 选项 4 + 1 赌马 一名商人邀请你下注。赢了就能赚一笔。 {"optionText":"下注 100(金)- 稳健:70% 赢 150","requirements":[{"type":"GoldAtLeast","param":{"Count":100}}],"costEffects":[{"type":"AddGold","param":{"Count":-100}}],"rewardEffects":[{"type":"AddGold","param":{"Count":150}}],"probability":0.7} {"optionText":"下注 100(金)- 激进:30% 赢 250","requirements":[{"type":"GoldAtLeast","param":{"Count":100}}],"costEffects":[{"type":"AddGold","param":{"Count":-100}}],"rewardEffects":[{"type":"AddGold","param":{"Count":250}}],"probability":0.3} + 2 工匠的熔炉 工匠愿意熔炼你的旧组件,换给你一件更有希望的新货。 {"optionText":"交出 2 个白色组件,获得 1 个白色或绿色组件","requirements":[{"type":"CompCountAtLeast","param":{"Count":2,"Rarity":"White"}}],"costEffects":[{"type":"RemoveRandomComps","param":{"Count":2,"Rarity":"White"}}],"rewardEffects":[{"type":"AddRandomComps","param":{"Count":1,"MinRarity":"White","MaxRarity":"Green"}}]} {"optionText":"交出 2 个绿色组件,获得 1 个绿色或蓝色组件","requirements":[{"type":"CompCountAtLeast","param":{"Count":2,"Rarity":"Green"}}],"costEffects":[{"type":"RemoveRandomComps","param":{"Count":2,"Rarity":"Green"}}],"rewardEffects":[{"type":"AddRandomComps","param":{"Count":1,"MinRarity":"Green","MaxRarity":"Blue"}}]} {"optionText":"交出 2 个蓝色组件,获得 1 个蓝色或紫色组件","requirements":[{"type":"CompCountAtLeast","param":{"Count":2,"Rarity":"Blue"}}],"costEffects":[{"type":"RemoveRandomComps","param":{"Count":2,"Rarity":"Blue"}}],"rewardEffects":[{"type":"AddRandomComps","param":{"Count":1,"MinRarity":"Blue","MaxRarity":"Purple"}}]} {"optionText":"拒绝","requirements":[],"rewardEffects":[]} + 3 代价与回报 某种黑暗力量承诺给你金币,但它索取的代价会落在你的一座防御塔上。 {"optionText":"让黑暗力量抽取一座防御塔 20 点耐久,获得 50 金币","requirements":[{"type":"TowerCountAtLeast","param":{"Count":1}}],"costEffects":[{"type":"DamageRandomTowersEndurance","param":{"Count":1,"Amount":20}}],"rewardEffects":[{"type":"AddGold","param":{"Count":50}}]} {"optionText":"离开","requirements":[],"rewardEffects":[]} diff --git a/Assets/GameMain/Scripts/CustomComponent/EventNodeComponent.cs b/Assets/GameMain/Scripts/CustomComponent/EventNodeComponent.cs index 585a966..b2b8f64 100644 --- a/Assets/GameMain/Scripts/CustomComponent/EventNodeComponent.cs +++ b/Assets/GameMain/Scripts/CustomComponent/EventNodeComponent.cs @@ -15,6 +15,7 @@ namespace GeometryTD.CustomComponent public class EventNodeComponent : GameFrameworkComponent { private RunNodeExecutionContext _activeContext; + private EventItem _activeEvent; private readonly List _eventItems = new List(); @@ -64,17 +65,21 @@ namespace GeometryTD.CustomComponent return; } - int index = Random.Range(0, _eventItems.Count); - EventItem randomEvent = _eventItems[index]; - if (_eventFormUseCase == null) { Log.Warning("EventNodeComponent StartEvent failed. Event form is not initialized."); return; } - + _activeContext = context != null ? context.Clone() : null; - _eventFormUseCase.SetCurrentEvent(randomEvent); + _activeEvent = SelectActiveEvent(_activeContext); + if (_activeEvent == null) + { + Log.Warning("EventNodeComponent StartEvent failed. No active event could be resolved."); + return; + } + + _eventFormUseCase.BindEvent(_activeEvent, _activeContext); GameEntry.UIRouter.OpenUI(UIFormType.EventForm); GameEntry.Event.Fire( this, @@ -107,6 +112,8 @@ namespace GeometryTD.CustomComponent private void ClearActiveNodeContext() { _activeContext = null; + _activeEvent = null; + _eventFormUseCase?.Clear(); } private static EventOption[] ParseOptions(string optionsRaw) @@ -200,5 +207,34 @@ namespace GeometryTD.CustomComponent return effects.ToArray(); } + + private EventItem SelectActiveEvent(RunNodeExecutionContext context) + { + if (_eventItems.Count <= 0) + { + return null; + } + + if (context == null) + { + int randomIndex = Random.Range(0, _eventItems.Count); + return _eventItems[randomIndex]; + } + + System.Random random = new System.Random(BuildSelectionSeed(context)); + return _eventItems[random.Next(0, _eventItems.Count)]; + } + + private static int BuildSelectionSeed(RunNodeExecutionContext context) + { + unchecked + { + int seed = 17; + seed = seed * 31 + context.RunSeed; + seed = seed * 31 + context.SequenceIndex; + seed = seed * 31 + context.NodeId; + return seed; + } + } } } diff --git a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationComponent.cs b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationComponent.cs index 3a29985..0c08fce 100644 --- a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationComponent.cs +++ b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationComponent.cs @@ -102,18 +102,78 @@ namespace GeometryTD.CustomComponent } } + public IReadOnlyList BuildEventRewardComponents( + int count, + RarityType rarity, + int runSeed, + int sequenceIndex, + int eventId, + int optionIndex, + int effectIndex) + { + return BuildEventRewardComponents( + count, + rarity, + rarity, + runSeed, + sequenceIndex, + eventId, + optionIndex, + effectIndex); + } + + public IReadOnlyList BuildEventRewardComponents( + int count, + RarityType minRarity, + RarityType maxRarity, + int runSeed, + int sequenceIndex, + int eventId, + int optionIndex, + int effectIndex) + { + EnsureComponentTables(); + + int resolvedCount = Mathf.Max(0, count); + if (resolvedCount <= 0) + { + return System.Array.Empty(); + } + + RarityType normalizedMinRarity = InventoryRarityRuleService.NormalizeComponentRarity(minRarity); + RarityType normalizedMaxRarity = InventoryRarityRuleService.NormalizeComponentRarity(maxRarity); + if (normalizedMinRarity > normalizedMaxRarity) + { + throw new System.InvalidOperationException( + $"Event reward rarity range is invalid: {normalizedMinRarity} > {normalizedMaxRarity}."); + } + + List result = new List(resolvedCount); + for (int i = 0; i < resolvedCount; i++) + { + InventoryGenerationRandomContext randomContext = CreateEventRandomContext( + runSeed, + sequenceIndex, + eventId, + optionIndex, + effectIndex, + i); + Random random = randomContext.CreateRandom(); + result.Add(BuildRandomEventComponentItem(normalizedMinRarity, normalizedMaxRarity, randomContext, random)); + } + + return result; + } + private void EnsureShopTables() { _shopPriceTable ??= GameEntry.DataTable.GetDataTable(); - _muzzleCompTable ??= GameEntry.DataTable.GetDataTable(); - _bearingCompTable ??= GameEntry.DataTable.GetDataTable(); - _baseCompTable ??= GameEntry.DataTable.GetDataTable(); + EnsureComponentTables(); - if (_shopPriceTable == null || _muzzleCompTable == null || _bearingCompTable == null || - _baseCompTable == null) + if (_shopPriceTable == null) { throw new System.InvalidOperationException( - "InventoryGenerationComponent requires ShopPrice, MuzzleComp, BearingComp, and BaseComp data tables."); + "InventoryGenerationComponent requires ShopPrice data table."); } if (_shopPriceRows.Count > 0) @@ -155,14 +215,12 @@ namespace GeometryTD.CustomComponent private void EnsureDropTables() { _dropPoolTable ??= GameEntry.DataTable.GetDataTable(); - _muzzleCompTable ??= GameEntry.DataTable.GetDataTable(); - _bearingCompTable ??= GameEntry.DataTable.GetDataTable(); - _baseCompTable ??= GameEntry.DataTable.GetDataTable(); + EnsureComponentTables(); - if (_dropPoolTable == null || _muzzleCompTable == null || _bearingCompTable == null || _baseCompTable == null) + if (_dropPoolTable == null) { throw new System.InvalidOperationException( - "InventoryGenerationComponent requires OutGameDropPool, MuzzleComp, BearingComp, and BaseComp data tables."); + "InventoryGenerationComponent requires OutGameDropPool data table."); } } @@ -224,5 +282,118 @@ namespace GeometryTD.CustomComponent return droppedItem; } + + private void EnsureComponentTables() + { + _muzzleCompTable ??= GameEntry.DataTable.GetDataTable(); + _bearingCompTable ??= GameEntry.DataTable.GetDataTable(); + _baseCompTable ??= GameEntry.DataTable.GetDataTable(); + + if (_muzzleCompTable == null || _bearingCompTable == null || _baseCompTable == null) + { + throw new System.InvalidOperationException( + "InventoryGenerationComponent requires MuzzleComp, BearingComp, and BaseComp data tables."); + } + } + + private TowerCompItemData BuildRandomEventComponentItem( + RarityType minRarity, + RarityType maxRarity, + InventoryGenerationRandomContext randomContext, + Random random) + { + RarityType rarity = ResolveEventRewardRarity(minRarity, maxRarity, random); + int slotRoll = random.Next(0, 3); + return slotRoll switch + { + 0 => BuildRandomEventMuzzleItem(rarity, randomContext, random), + 1 => BuildRandomEventBearingItem(rarity, randomContext, random), + _ => BuildRandomEventBaseItem(rarity, randomContext, random) + }; + } + + private static RarityType ResolveEventRewardRarity( + RarityType minRarity, + RarityType maxRarity, + Random random) + { + if (minRarity >= maxRarity) + { + return minRarity; + } + + int rarityValue = random.Next((int)minRarity, (int)maxRarity + 1); + return (RarityType)rarityValue; + } + + private MuzzleCompItemData BuildRandomEventMuzzleItem( + RarityType rarity, + InventoryGenerationRandomContext randomContext, + Random random) + { + DRMuzzleComp[] rows = _muzzleCompTable.GetAllDataRows(); + DRMuzzleComp config = rows[random.Next(0, rows.Length)]; + return ComponentItemFactory.CreateMuzzle( + config, + randomContext.CreateStableItemInstanceId(), + rarity, + randomContext.CreateTagRandomContext(config.Id)); + } + + private BearingCompItemData BuildRandomEventBearingItem( + RarityType rarity, + InventoryGenerationRandomContext randomContext, + Random random) + { + DRBearingComp[] rows = _bearingCompTable.GetAllDataRows(); + DRBearingComp config = rows[random.Next(0, rows.Length)]; + return ComponentItemFactory.CreateBearing( + config, + randomContext.CreateStableItemInstanceId(), + rarity, + randomContext.CreateTagRandomContext(config.Id)); + } + + private BaseCompItemData BuildRandomEventBaseItem( + RarityType rarity, + InventoryGenerationRandomContext randomContext, + Random random) + { + DRBaseComp[] rows = _baseCompTable.GetAllDataRows(); + DRBaseComp config = rows[random.Next(0, rows.Length)]; + return ComponentItemFactory.CreateBase( + config, + randomContext.CreateStableItemInstanceId(), + rarity, + randomContext.CreateTagRandomContext(config.Id)); + } + + private static InventoryGenerationRandomContext CreateEventRandomContext( + int runSeed, + int sequenceIndex, + int eventId, + int optionIndex, + int effectIndex, + int itemIndex) + { + return new InventoryGenerationRandomContext( + runSeed, + sequenceIndex, + InventoryTagSourceType.Event, + BuildEventLocalOrdinal(eventId, optionIndex, effectIndex, itemIndex)); + } + + private static int BuildEventLocalOrdinal(int eventId, int optionIndex, int effectIndex, int itemIndex) + { + unchecked + { + int value = 17; + value = value * 31 + eventId; + value = value * 31 + optionIndex; + value = value * 31 + effectIndex; + value = value * 31 + itemIndex; + return value & int.MaxValue; + } + } } } diff --git a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationRandomContext.cs b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationRandomContext.cs index 029029b..3a9e460 100644 --- a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationRandomContext.cs +++ b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationRandomContext.cs @@ -44,6 +44,7 @@ namespace GeometryTD.Definition { InventoryTagSourceType.Shop => InventoryTagRandomContext.CreateShop(RunSeed, NodeSequenceIndex, LocalOrdinal, configId), InventoryTagSourceType.Reward => InventoryTagRandomContext.CreateReward(RunSeed, NodeSequenceIndex, LocalOrdinal, configId), + InventoryTagSourceType.Event => InventoryTagRandomContext.CreateEvent(RunSeed, NodeSequenceIndex, LocalOrdinal, configId), _ => InventoryTagRandomContext.CreateDrop(RunSeed, NodeSequenceIndex, LocalOrdinal, configId) }; } diff --git a/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryTowerRosterService.cs b/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryTowerRosterService.cs index 2a6bd79..4488642 100644 --- a/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryTowerRosterService.cs +++ b/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryTowerRosterService.cs @@ -35,14 +35,27 @@ namespace GeometryTD.CustomComponent } public int ReduceTowerEndurance(IReadOnlyList towerInstanceIds, float enduranceLoss) + { + return InventoryTowerEnduranceUtility.ReduceTowerEndurance( + _queryModel.Inventory, + towerInstanceIds, + enduranceLoss); + } + } + + public static class InventoryTowerEnduranceUtility + { + public static int ReduceTowerEndurance( + BackpackInventoryData inventory, + IReadOnlyList towerInstanceIds, + float enduranceLoss) { float resolvedLoss = Mathf.Max(0f, enduranceLoss); - BackpackInventoryData inventory = _queryModel.Inventory; - if (resolvedLoss <= 0f || + if (inventory?.Towers == null || + inventory.Towers.Count <= 0 || + resolvedLoss <= 0f || towerInstanceIds == null || - towerInstanceIds.Count <= 0 || - inventory.Towers == null || - inventory.Towers.Count <= 0) + towerInstanceIds.Count <= 0) { return 0; } diff --git a/Assets/GameMain/Scripts/DataTable/DREvent.cs b/Assets/GameMain/Scripts/DataTable/DREvent.cs index fac79cd..1a1167d 100644 --- a/Assets/GameMain/Scripts/DataTable/DREvent.cs +++ b/Assets/GameMain/Scripts/DataTable/DREvent.cs @@ -1,4 +1,5 @@ using UnityGameFramework.Runtime; +using Newtonsoft.Json.Linq; namespace GeometryTD.DataTable { @@ -30,6 +31,14 @@ namespace GeometryTD.DataTable /// 原始字符串(如 JSON 文本),不在此处做解析。 public string OptionsRaw { get; private set; } + public string Option1Raw { get; private set; } + + public string Option2Raw { get; private set; } + + public string Option3Raw { get; private set; } + + public string Option4Raw { get; private set; } + public override bool ParseDataRow(string dataRowString, object userData) { string[] columnStrings = dataRowString.Split(DataTableExtension.DataSplitSeparators); @@ -44,9 +53,30 @@ namespace GeometryTD.DataTable index++; Title = columnStrings[index++]; Description = columnStrings[index++]; - OptionsRaw = columnStrings[index++]; + Option1Raw = columnStrings[index++]; + Option2Raw = columnStrings[index++]; + Option3Raw = columnStrings[index++]; + Option4Raw = columnStrings[index++]; + OptionsRaw = BuildOptionsRaw(Option1Raw, Option2Raw, Option3Raw, Option4Raw); return true; } + + private static string BuildOptionsRaw(params string[] optionColumns) + { + JArray array = new JArray(); + for (int i = 0; i < optionColumns.Length; i++) + { + string optionRaw = optionColumns[i]; + if (string.IsNullOrWhiteSpace(optionRaw)) + { + continue; + } + + array.Add(JObject.Parse(optionRaw)); + } + + return array.ToString(Newtonsoft.Json.Formatting.None); + } } -} \ No newline at end of file +} diff --git a/Assets/GameMain/Scripts/Definition/Enum/InventoryTagSourceType.cs b/Assets/GameMain/Scripts/Definition/Enum/InventoryTagSourceType.cs index 1ac871d..2f21259 100644 --- a/Assets/GameMain/Scripts/Definition/Enum/InventoryTagSourceType.cs +++ b/Assets/GameMain/Scripts/Definition/Enum/InventoryTagSourceType.cs @@ -6,5 +6,6 @@ namespace GeometryTD.Definition Shop = 2, Drop = 3, Reward = 4, + Event = 5, } } diff --git a/Assets/GameMain/Scripts/Definition/Event/EventEffect/AddRandomCompsEffect.cs b/Assets/GameMain/Scripts/Definition/Event/EventEffect/AddRandomCompsEffect.cs index 3760cdb..d711a20 100644 --- a/Assets/GameMain/Scripts/Definition/Event/EventEffect/AddRandomCompsEffect.cs +++ b/Assets/GameMain/Scripts/Definition/Event/EventEffect/AddRandomCompsEffect.cs @@ -18,11 +18,31 @@ namespace GeometryTD.Definition { public int Count; public RarityType Rarity; + public RarityType MinRarity; + public RarityType MaxRarity; public AddRandomCompsParam(int count, RarityType rarity) { Count = count; - Rarity = rarity; + RarityType normalizedRarity = InventoryRarityRuleService.NormalizeComponentRarity(rarity); + Rarity = normalizedRarity; + MinRarity = normalizedRarity; + MaxRarity = normalizedRarity; + } + + public AddRandomCompsParam(int count, RarityType minRarity, RarityType maxRarity) + { + Count = count; + MinRarity = InventoryRarityRuleService.NormalizeComponentRarity(minRarity); + MaxRarity = InventoryRarityRuleService.NormalizeComponentRarity(maxRarity); + if (MinRarity > MaxRarity) + { + throw new System.ArgumentOutOfRangeException( + nameof(minRarity), + $"AddRandomComps rarity range is invalid: {MinRarity} > {MaxRarity}."); + } + + Rarity = MinRarity == MaxRarity ? MinRarity : RarityType.None; } } } diff --git a/Assets/GameMain/Scripts/Definition/Event/EventOptionExecutor.cs b/Assets/GameMain/Scripts/Definition/Event/EventOptionExecutor.cs new file mode 100644 index 0000000..b94fe72 --- /dev/null +++ b/Assets/GameMain/Scripts/Definition/Event/EventOptionExecutor.cs @@ -0,0 +1,490 @@ +using System; +using System.Collections.Generic; +using GeometryTD.CustomComponent; +using GeometryTD.CustomUtility; +using GeometryTD.Procedure; +using UnityEngine; + +namespace GeometryTD.Definition +{ + public sealed class EventOptionExecutor + { + public EventOptionAvailability EvaluateOption(EventOption option, BackpackInventoryData inventory) + { + if (option == null) + { + return EventOptionAvailability.Blocked("选项配置无效"); + } + + EventRequirementBase[] requirements = option.Requirements ?? Array.Empty(); + for (int i = 0; i < requirements.Length; i++) + { + EventRequirementBase requirement = requirements[i]; + if (requirement == null) + { + continue; + } + + if (!IsRequirementSatisfied(requirement, inventory)) + { + return EventOptionAvailability.Blocked(BuildBlockedReason(requirement)); + } + } + + return EventOptionAvailability.Selectable(); + } + + public EventOptionExecutionResult Execute( + EventItem eventItem, + int optionIndex, + RunNodeExecutionContext context, + BackpackInventoryData workingInventory) + { + if (eventItem == null || workingInventory == null) + { + return EventOptionExecutionResult.Rejected("事件数据无效"); + } + + if (optionIndex < 0 || optionIndex >= eventItem.Options.Length) + { + return EventOptionExecutionResult.Rejected("事件选项索引无效"); + } + + EventOption option = eventItem.Options[optionIndex]; + EventOptionAvailability availability = EvaluateOption(option, workingInventory); + if (!availability.IsSelectable) + { + return EventOptionExecutionResult.Rejected(availability.BlockedReason); + } + + ApplyEffects(option.CostEffects, eventItem, optionIndex, context, workingInventory, isRewardPhase: false); + + bool isProbabilitySuccess = RollProbability(eventItem.Id, optionIndex, context, option.Probability); + if (isProbabilitySuccess) + { + ApplyEffects(option.RewardEffects, eventItem, optionIndex, context, workingInventory, isRewardPhase: true); + } + + return EventOptionExecutionResult.Accepted(isProbabilitySuccess); + } + + private void ApplyEffects( + EventEffectBase[] effects, + EventItem eventItem, + int optionIndex, + RunNodeExecutionContext context, + BackpackInventoryData workingInventory, + bool isRewardPhase) + { + if (effects == null || effects.Length <= 0) + { + return; + } + + for (int i = 0; i < effects.Length; i++) + { + EventEffectBase effect = effects[i]; + if (effect == null) + { + continue; + } + + switch (effect.EffectType) + { + case EventEffectType.AddGold: + ApplyAddGoldEffect((AddGoldParam)effect.Param, workingInventory); + break; + case EventEffectType.RemoveRandomComps: + ApplyRemoveRandomComponentsEffect( + (RemoveRandomCompsParam)effect.Param, + eventItem.Id, + optionIndex, + context, + workingInventory, + i); + break; + case EventEffectType.AddRandomComps: + ApplyAddRandomComponentsEffect( + (AddRandomCompsParam)effect.Param, + eventItem.Id, + optionIndex, + context, + workingInventory, + i); + break; + case EventEffectType.DamageRandomTowersEndurance: + ApplyDamageRandomTowerEnduranceEffect( + (DamageRandomTowerEnduranceParam)effect.Param, + eventItem.Id, + optionIndex, + context, + workingInventory, + i); + break; + default: + throw new InvalidOperationException( + $"Unsupported event effect at runtime: {effect.EffectType} (rewardPhase={isRewardPhase})."); + } + } + } + + private static void ApplyAddGoldEffect(AddGoldParam param, BackpackInventoryData workingInventory) + { + int nextGold = workingInventory.Gold + (param?.Count ?? 0); + if (nextGold < 0) + { + throw new InvalidOperationException("Event gold effect would reduce gold below zero."); + } + + workingInventory.Gold = nextGold; + } + + private static void ApplyRemoveRandomComponentsEffect( + RemoveRandomCompsParam param, + int eventId, + int optionIndex, + RunNodeExecutionContext context, + BackpackInventoryData workingInventory, + int effectIndex) + { + int removeCount = Mathf.Max(0, param?.Count ?? 0); + if (removeCount <= 0) + { + return; + } + + List candidates = CollectLooseComponents(workingInventory, param.Rarity); + if (candidates.Count < removeCount) + { + throw new InvalidOperationException("Event component removal effect does not have enough candidates."); + } + + System.Random random = CreateRandom(context, eventId, optionIndex, effectIndex, 101); + Shuffle(candidates, random); + for (int i = 0; i < removeCount; i++) + { + RemoveComponentByInstanceId(workingInventory, candidates[i]); + } + } + + private static void ApplyAddRandomComponentsEffect( + AddRandomCompsParam param, + int eventId, + int optionIndex, + RunNodeExecutionContext context, + BackpackInventoryData workingInventory, + int effectIndex) + { + int addCount = Mathf.Max(0, param?.Count ?? 0); + if (addCount <= 0) + { + return; + } + + if (GameEntry.InventoryGeneration == null) + { + throw new InvalidOperationException("Event component generation requires InventoryGenerationComponent."); + } + + IReadOnlyList generatedComponents = GameEntry.InventoryGeneration.BuildEventRewardComponents( + addCount, + param.MinRarity, + param.MaxRarity, + context?.RunSeed ?? 0, + context?.SequenceIndex ?? -1, + eventId, + optionIndex, + effectIndex); + for (int i = 0; i < generatedComponents.Count; i++) + { + AddComponentToInventory(workingInventory, generatedComponents[i]); + } + } + + private static void ApplyDamageRandomTowerEnduranceEffect( + DamageRandomTowerEnduranceParam param, + int eventId, + int optionIndex, + RunNodeExecutionContext context, + BackpackInventoryData workingInventory, + int effectIndex) + { + int towerCount = Mathf.Max(0, param?.Count ?? 0); + float enduranceLoss = Mathf.Max(0, param?.Amount ?? 0); + if (towerCount <= 0 || enduranceLoss <= 0f || workingInventory.Towers == null || workingInventory.Towers.Count <= 0) + { + return; + } + + List candidateTowerIds = new List(workingInventory.Towers.Count); + for (int i = 0; i < workingInventory.Towers.Count; i++) + { + TowerItemData tower = workingInventory.Towers[i]; + if (tower != null && tower.InstanceId > 0) + { + candidateTowerIds.Add(tower.InstanceId); + } + } + + if (candidateTowerIds.Count <= 0) + { + return; + } + + System.Random random = CreateRandom(context, eventId, optionIndex, effectIndex, 211); + Shuffle(candidateTowerIds, random); + int resolvedCount = Mathf.Min(towerCount, candidateTowerIds.Count); + List selectedTowerIds = candidateTowerIds.GetRange(0, resolvedCount); + InventoryTowerEnduranceUtility.ReduceTowerEndurance(workingInventory, selectedTowerIds, enduranceLoss); + } + + private static bool IsRequirementSatisfied(EventRequirementBase requirement, BackpackInventoryData inventory) + { + switch (requirement.RequirementType) + { + case EventRequirementType.GoldAtLeast: + return (inventory?.Gold ?? 0) >= ((GoldAtLeastParam)requirement.Param).Gold; + case EventRequirementType.CompCountAtLeast: + CompCountAtLeastParam compParam = (CompCountAtLeastParam)requirement.Param; + return CollectLooseComponents(inventory, compParam.Rarity).Count >= compParam.Count; + case EventRequirementType.TowerCountAtLeast: + return (inventory?.Towers?.Count ?? 0) >= ((TowerCountAtLeastParam)requirement.Param).Count; + default: + throw new InvalidOperationException( + $"Unsupported event requirement at runtime: {requirement.RequirementType}."); + } + } + + private static string BuildBlockedReason(EventRequirementBase requirement) + { + switch (requirement.RequirementType) + { + case EventRequirementType.GoldAtLeast: + return $"需要至少 {((GoldAtLeastParam)requirement.Param).Gold} 金币"; + case EventRequirementType.CompCountAtLeast: + CompCountAtLeastParam compParam = (CompCountAtLeastParam)requirement.Param; + return $"需要至少 {compParam.Count} 个未装配的{GetRarityText(compParam.Rarity)}组件"; + case EventRequirementType.TowerCountAtLeast: + return $"需要至少 {((TowerCountAtLeastParam)requirement.Param).Count} 座防御塔"; + default: + throw new InvalidOperationException( + $"Unsupported event requirement at runtime: {requirement.RequirementType}."); + } + } + + private static bool RollProbability(int eventId, int optionIndex, RunNodeExecutionContext context, float probability) + { + float clampedProbability = Mathf.Clamp01(probability); + if (clampedProbability >= 1f) + { + return true; + } + + if (clampedProbability <= 0f) + { + return false; + } + + System.Random random = CreateRandom(context, eventId, optionIndex, 0, 17); + return random.NextDouble() <= clampedProbability; + } + + private static System.Random CreateRandom( + RunNodeExecutionContext context, + int eventId, + int optionIndex, + int effectIndex, + int salt) + { + unchecked + { + int seed = 17; + seed = seed * 31 + (context?.RunSeed ?? 0); + seed = seed * 31 + (context?.SequenceIndex ?? -1); + seed = seed * 31 + eventId; + seed = seed * 31 + optionIndex; + seed = seed * 31 + effectIndex; + seed = seed * 31 + salt; + return new System.Random(seed); + } + } + + private static List CollectLooseComponents( + BackpackInventoryData inventory, + RarityType rarity) + { + List result = new List(); + if (inventory == null) + { + return result; + } + + RarityType normalizedRarity = InventoryRarityRuleService.NormalizeComponentRarity(rarity); + CollectFromList(result, inventory.MuzzleComponents, normalizedRarity); + CollectFromList(result, inventory.BearingComponents, normalizedRarity); + CollectFromList(result, inventory.BaseComponents, normalizedRarity); + return result; + } + + private static void CollectFromList( + List destination, + List source, + RarityType rarity) + where TComp : TowerCompItemData + { + if (source == null) + { + return; + } + + for (int i = 0; i < source.Count; i++) + { + TComp component = source[i]; + if (component == null || component.IsAssembledIntoTower) + { + continue; + } + + if (InventoryRarityRuleService.NormalizeComponentRarity(component.Rarity) != rarity) + { + continue; + } + + destination.Add(component); + } + } + + private static void RemoveComponentByInstanceId(BackpackInventoryData inventory, TowerCompItemData component) + { + switch (component) + { + case MuzzleCompItemData muzzleComp: + RemoveByInstanceId(inventory.MuzzleComponents, muzzleComp.InstanceId); + break; + case BearingCompItemData bearingComp: + RemoveByInstanceId(inventory.BearingComponents, bearingComp.InstanceId); + break; + case BaseCompItemData baseComp: + RemoveByInstanceId(inventory.BaseComponents, baseComp.InstanceId); + break; + default: + throw new InvalidOperationException($"Unsupported component type for event removal: {component?.GetType().Name}"); + } + } + + private static void AddComponentToInventory(BackpackInventoryData inventory, TowerCompItemData component) + { + if (component == null) + { + return; + } + + switch (component) + { + case MuzzleCompItemData muzzleComp: + inventory.MuzzleComponents.Add(InventoryCloneUtility.CloneMuzzleComp(muzzleComp)); + break; + case BearingCompItemData bearingComp: + inventory.BearingComponents.Add(InventoryCloneUtility.CloneBearingComp(bearingComp)); + break; + case BaseCompItemData baseComp: + inventory.BaseComponents.Add(InventoryCloneUtility.CloneBaseComp(baseComp)); + break; + default: + throw new InvalidOperationException($"Unsupported component type for event addition: {component.GetType().Name}"); + } + } + + private static void RemoveByInstanceId(List components, long instanceId) + where TComp : TowerCompItemData + { + if (components == null) + { + return; + } + + for (int i = 0; i < components.Count; i++) + { + TComp component = components[i]; + if (component != null && component.InstanceId == instanceId) + { + components.RemoveAt(i); + return; + } + } + + throw new InvalidOperationException($"Failed to remove component instance #{instanceId} from inventory."); + } + + private static void Shuffle(IList values, System.Random random) + { + for (int i = values.Count - 1; i > 0; i--) + { + int swapIndex = random.Next(0, i + 1); + (values[i], values[swapIndex]) = (values[swapIndex], values[i]); + } + } + + private static string GetRarityText(RarityType rarity) + { + return InventoryRarityRuleService.NormalizeComponentRarity(rarity) switch + { + RarityType.White => "白色", + RarityType.Green => "绿色", + RarityType.Blue => "蓝色", + RarityType.Purple => "紫色", + RarityType.Red => "红色", + _ => "未知" + }; + } + } + + public sealed class EventOptionAvailability + { + private EventOptionAvailability(bool isSelectable, string blockedReason) + { + IsSelectable = isSelectable; + BlockedReason = blockedReason ?? string.Empty; + } + + public bool IsSelectable { get; } + + public string BlockedReason { get; } + + public static EventOptionAvailability Selectable() + { + return new EventOptionAvailability(true, string.Empty); + } + + public static EventOptionAvailability Blocked(string blockedReason) + { + return new EventOptionAvailability(false, blockedReason); + } + } + + public sealed class EventOptionExecutionResult + { + private EventOptionExecutionResult(bool isAccepted, bool isProbabilitySuccess, string failureReason) + { + IsAccepted = isAccepted; + IsProbabilitySuccess = isProbabilitySuccess; + FailureReason = failureReason ?? string.Empty; + } + + public bool IsAccepted { get; } + + public bool IsProbabilitySuccess { get; } + + public string FailureReason { get; } + + public static EventOptionExecutionResult Accepted(bool isProbabilitySuccess) + { + return new EventOptionExecutionResult(true, isProbabilitySuccess, string.Empty); + } + + public static EventOptionExecutionResult Rejected(string failureReason) + { + return new EventOptionExecutionResult(false, false, failureReason); + } + } +} diff --git a/Assets/GameMain/Scripts/Definition/Event/EventOptionExecutor.cs.meta b/Assets/GameMain/Scripts/Definition/Event/EventOptionExecutor.cs.meta new file mode 100644 index 0000000..cd57ac5 --- /dev/null +++ b/Assets/GameMain/Scripts/Definition/Event/EventOptionExecutor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8e4d7125aa7b4cb9aa62cb0bf9301d5b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Definition/Tag/Generation/InventoryTagRandomContext.cs b/Assets/GameMain/Scripts/Definition/Tag/Generation/InventoryTagRandomContext.cs index 0dac7c8..24adbb6 100644 --- a/Assets/GameMain/Scripts/Definition/Tag/Generation/InventoryTagRandomContext.cs +++ b/Assets/GameMain/Scripts/Definition/Tag/Generation/InventoryTagRandomContext.cs @@ -53,6 +53,15 @@ namespace GeometryTD.Definition configId); } + public static InventoryTagRandomContext CreateEvent(int runSeed, int nodeSequenceIndex, int eventOrdinal, int configId) + { + return new InventoryTagRandomContext( + runSeed, + InventoryTagSourceType.Event, + ComposeLocalItemInstanceId(nodeSequenceIndex, eventOrdinal), + configId); + } + public static long ComposeLocalItemInstanceId(int nodeSequenceIndex, int localOrdinal) { long normalizedSequence = Math.Max(0, nodeSequenceIndex) + 1L; diff --git a/Assets/GameMain/Scripts/Factory/EventEffectFactory.cs b/Assets/GameMain/Scripts/Factory/EventEffectFactory.cs index f1a7017..f77abd9 100644 --- a/Assets/GameMain/Scripts/Factory/EventEffectFactory.cs +++ b/Assets/GameMain/Scripts/Factory/EventEffectFactory.cs @@ -31,6 +31,21 @@ namespace GeometryTD.Factory case EventEffectType.AddRandomComps: { int count = GetInt(param, "Count"); + bool hasMinRarity = TryGetString(param, "MinRarity", out string minRarityRaw); + bool hasMaxRarity = TryGetString(param, "MaxRarity", out string maxRarityRaw); + if (hasMinRarity || hasMaxRarity) + { + if (!hasMinRarity || !hasMaxRarity) + { + throw new System.InvalidOperationException( + "AddRandomComps requires both MinRarity and MaxRarity when using ranged rarity config."); + } + + RarityType minRarity = EnumUtility.Get(minRarityRaw); + RarityType maxRarity = EnumUtility.Get(maxRarityRaw); + return new AddRandomCompsEffect(new AddRandomCompsParam(count, minRarity, maxRarity), probability); + } + RarityType rarity = EnumUtility.Get(GetString(param, "Rarity")); return new AddRandomCompsEffect(new AddRandomCompsParam(count, rarity), probability); } @@ -73,5 +88,17 @@ namespace GeometryTD.Factory return token.Value(); } + + private static bool TryGetString(JObject param, string key, out string value) + { + value = string.Empty; + if (param == null || !param.TryGetValue(key, out JToken token) || token.Type == JTokenType.Null) + { + return false; + } + + value = token.Value(); + return !string.IsNullOrWhiteSpace(value); + } } } diff --git a/Assets/GameMain/Scripts/UI/Game/Context/EventOptionItemContext.cs b/Assets/GameMain/Scripts/UI/Game/Context/EventOptionItemContext.cs index 7ee2f98..856fded 100644 --- a/Assets/GameMain/Scripts/UI/Game/Context/EventOptionItemContext.cs +++ b/Assets/GameMain/Scripts/UI/Game/Context/EventOptionItemContext.cs @@ -4,5 +4,7 @@ namespace GeometryTD.UI { public int OptionIndex; public string OptionText; + public bool IsSelectable; + public string BlockedReason; } } diff --git a/Assets/GameMain/Scripts/UI/Game/Context/RepoFormContext.cs b/Assets/GameMain/Scripts/UI/Game/Context/RepoFormContext.cs index e6e1afb..cf3fffb 100644 --- a/Assets/GameMain/Scripts/UI/Game/Context/RepoFormContext.cs +++ b/Assets/GameMain/Scripts/UI/Game/Context/RepoFormContext.cs @@ -4,6 +4,7 @@ namespace GeometryTD.UI { public class RepoFormContext : UIContext { + public string GoldText; public CombineAreaContext CombineAreaContext; public CompAreaContext CompAreaContext; public ParticipantAreaContext ParticipantAreaContext; diff --git a/Assets/GameMain/Scripts/UI/Game/ContextBuilder/RepoFormController.ContextBuilder.cs b/Assets/GameMain/Scripts/UI/Game/ContextBuilder/RepoFormController.ContextBuilder.cs index fe4eedf..cc63c0d 100644 --- a/Assets/GameMain/Scripts/UI/Game/ContextBuilder/RepoFormController.ContextBuilder.cs +++ b/Assets/GameMain/Scripts/UI/Game/ContextBuilder/RepoFormController.ContextBuilder.cs @@ -140,6 +140,7 @@ namespace GeometryTD.UI return new RepoFormContext { + GoldText = $"金币: {rawData.Inventory.Gold}", CombineAreaContext = new CombineAreaContext(), CompAreaContext = new CompAreaContext { @@ -322,6 +323,7 @@ namespace GeometryTD.UI return; } + Form.RefreshGoldText(latestContext.GoldText); Form.RefreshParticipantArea(latestContext.ParticipantAreaContext); ApplyParticipantSelection(); } diff --git a/Assets/GameMain/Scripts/UI/Game/Controller/EventFormController.cs b/Assets/GameMain/Scripts/UI/Game/Controller/EventFormController.cs index 7838a4e..92493f2 100644 --- a/Assets/GameMain/Scripts/UI/Game/Controller/EventFormController.cs +++ b/Assets/GameMain/Scripts/UI/Game/Controller/EventFormController.cs @@ -73,7 +73,7 @@ namespace GeometryTD.UI private static EventFormContext BuildContext(EventFormRawData rawData) { - if (rawData?.EventItem == null) + if (rawData == null) { return null; } @@ -82,24 +82,30 @@ namespace GeometryTD.UI for (int i = 0; i < options.Length; i++) { string optionText = string.Empty; - if (rawData.EventItem.Options != null && i < rawData.EventItem.Options.Length && - rawData.EventItem.Options[i] != null) + bool isSelectable = false; + string blockedReason = string.Empty; + if (rawData.OptionItems != null && i < rawData.OptionItems.Length && + rawData.OptionItems[i] != null) { - optionText = rawData.EventItem.Options[i].OptionText; + optionText = rawData.OptionItems[i].OptionText; + isSelectable = rawData.OptionItems[i].IsSelectable; + blockedReason = rawData.OptionItems[i].BlockedReason; } options[i] = new EventOptionItemContext { OptionIndex = i, - OptionText = optionText + OptionText = optionText, + IsSelectable = isSelectable, + BlockedReason = blockedReason }; } return new EventFormContext { - EventId = rawData.EventItem.Id, - Title = rawData.EventItem.Title, - Description = rawData.EventItem.Description, + EventId = rawData.EventId, + Title = rawData.Title, + Description = rawData.Description, OptionItems = options }; } @@ -117,7 +123,7 @@ namespace GeometryTD.UI return; } - m_UseCase.SelectOption(args.SelectedItemId); + m_UseCase.TrySelectOption(args.SelectedItemId); } } -} \ No newline at end of file +} diff --git a/Assets/GameMain/Scripts/UI/Game/Controller/RepoFormController.cs b/Assets/GameMain/Scripts/UI/Game/Controller/RepoFormController.cs index 18e46e7..7870dfc 100644 --- a/Assets/GameMain/Scripts/UI/Game/Controller/RepoFormController.cs +++ b/Assets/GameMain/Scripts/UI/Game/Controller/RepoFormController.cs @@ -245,8 +245,8 @@ namespace GeometryTD.UI RepoFormContext latestContext = BuildContext(latestRawData); if (latestContext != null) { - Form.RefreshUI(latestContext); - ApplyParticipantSelection(); + SetContext(latestContext); + RefreshCurrentUI(); } } diff --git a/Assets/GameMain/Scripts/UI/Game/RawData/EventFormRawData.cs b/Assets/GameMain/Scripts/UI/Game/RawData/EventFormRawData.cs index 64a373a..5b9f5a9 100644 --- a/Assets/GameMain/Scripts/UI/Game/RawData/EventFormRawData.cs +++ b/Assets/GameMain/Scripts/UI/Game/RawData/EventFormRawData.cs @@ -1,9 +1,18 @@ -using GeometryTD.Definition; - namespace GeometryTD.UI { public class EventFormRawData { - public EventItem EventItem; + public int EventId; + public string Title; + public string Description; + public EventOptionRawData[] OptionItems; + } + + public sealed class EventOptionRawData + { + public int OptionIndex; + public string OptionText; + public bool IsSelectable; + public string BlockedReason; } } diff --git a/Assets/GameMain/Scripts/UI/Game/UseCase/EventFormUseCase.cs b/Assets/GameMain/Scripts/UI/Game/UseCase/EventFormUseCase.cs index 17e3197..39ea5b7 100644 --- a/Assets/GameMain/Scripts/UI/Game/UseCase/EventFormUseCase.cs +++ b/Assets/GameMain/Scripts/UI/Game/UseCase/EventFormUseCase.cs @@ -1,6 +1,5 @@ -using GeometryTD.CustomComponent; -using GeometryTD.CustomEvent; using GeometryTD.Definition; +using GeometryTD.Procedure; using UnityGameFramework.Runtime; namespace GeometryTD.UI @@ -8,10 +7,21 @@ namespace GeometryTD.UI public class EventFormUseCase : IUIUseCase { private EventItem _currentEvent; + private RunNodeExecutionContext _currentContext; + private readonly EventOptionExecutor _executor = new EventOptionExecutor(); - public void SetCurrentEvent(EventItem eventItem) + public void BindEvent( + EventItem eventItem, + RunNodeExecutionContext context) { _currentEvent = eventItem; + _currentContext = context != null ? context.Clone() : null; + } + + public void Clear() + { + _currentEvent = null; + _currentContext = null; } public EventFormRawData CreateInitialModel() @@ -23,26 +33,74 @@ namespace GeometryTD.UI return new EventFormRawData { - EventItem = _currentEvent + EventId = _currentEvent.Id, + Title = _currentEvent.Title, + Description = _currentEvent.Description, + OptionItems = BuildOptionItems() }; } - public EventOption SelectOption(int optionIndex) + public bool TrySelectOption(int optionIndex) { if (_currentEvent == null || _currentEvent.Options == null) { - return null; + return false; } if (optionIndex < 0 || optionIndex >= _currentEvent.Options.Length) { - Log.Warning("EventFormUseCase.SelectOption() option index is invalid: {0}", optionIndex); - return null; + Log.Warning("EventFormUseCase.TrySelectOption() option index is invalid: {0}", optionIndex); + return false; + } + + BackpackInventoryData workingInventory = GameEntry.PlayerInventory != null + ? GameEntry.PlayerInventory.GetInventorySnapshot() + : new BackpackInventoryData(); + EventOptionExecutionResult executionResult = _executor.Execute( + _currentEvent, + optionIndex, + _currentContext, + workingInventory); + if (!executionResult.IsAccepted) + { + Log.Warning( + "EventFormUseCase.TrySelectOption() rejected. EventId={0}, OptionIndex={1}, Reason={2}", + _currentEvent.Id, + optionIndex, + executionResult.FailureReason); + return false; + } + + if (GameEntry.PlayerInventory != null) + { + GameEntry.PlayerInventory.ReplaceInventorySnapshot(workingInventory); } - // TODO: 执行 Requirements 校验、CostEffects/RewardEffects 结算与后续节点推进。 GameEntry.EventNode.EndEvent(); - return _currentEvent.Options[optionIndex]; + return true; + } + + private EventOptionRawData[] BuildOptionItems() + { + EventOption[] options = _currentEvent.Options ?? System.Array.Empty(); + EventOptionRawData[] result = new EventOptionRawData[options.Length]; + BackpackInventoryData inventory = GameEntry.PlayerInventory != null + ? GameEntry.PlayerInventory.GetInventorySnapshot() + : new BackpackInventoryData(); + for (int i = 0; i < options.Length; i++) + { + EventOption option = options[i]; + EventOptionAvailability availability = _executor.EvaluateOption(option, inventory); + result[i] = new EventOptionRawData + { + OptionIndex = i, + OptionText = option?.OptionText ?? string.Empty, + IsSelectable = availability.IsSelectable, + BlockedReason = availability.BlockedReason + }; + } + + return result; } } -} \ No newline at end of file +} diff --git a/Assets/GameMain/Scripts/UI/Game/View/EventOptionItem.cs b/Assets/GameMain/Scripts/UI/Game/View/EventOptionItem.cs index 03ced53..0275ae9 100644 --- a/Assets/GameMain/Scripts/UI/Game/View/EventOptionItem.cs +++ b/Assets/GameMain/Scripts/UI/Game/View/EventOptionItem.cs @@ -7,8 +7,18 @@ namespace GeometryTD.UI public class EventOptionItem : MonoBehaviour { [SerializeField] private TMP_Text _optionText; - + + private static readonly Color SelectableTextColor = Color.white; + private static readonly Color BlockedTextColor = new Color(0.75f, 0.75f, 0.75f, 1f); + private int _optionIndex; + private bool _isSelectable; + private CommonButton _commonButton; + + private void Awake() + { + _commonButton = GetComponent(); + } public void OnInit(EventOptionItemContext context) { @@ -20,9 +30,16 @@ namespace GeometryTD.UI } _optionIndex = context.OptionIndex; + _isSelectable = context.IsSelectable; if (_optionText != null) { - _optionText.text = context.OptionText ?? string.Empty; + _optionText.text = BuildDisplayText(context); + _optionText.color = _isSelectable ? SelectableTextColor : BlockedTextColor; + } + + if (_commonButton != null) + { + _commonButton.Interactive = _isSelectable; } gameObject.SetActive(!string.IsNullOrEmpty(context.OptionText)); @@ -31,20 +48,38 @@ namespace GeometryTD.UI public void OnReset() { _optionIndex = -1; + _isSelectable = false; if (_optionText != null) { _optionText.text = string.Empty; + _optionText.color = SelectableTextColor; + } + + if (_commonButton != null) + { + _commonButton.Interactive = true; } } public void OnClick() { - if (_optionIndex < 0) + if (_optionIndex < 0 || !_isSelectable) { return; } GameEntry.Event.Fire(this, EventOptionItemSelectedEventArgs.Create(_optionIndex)); } + + private static string BuildDisplayText(EventOptionItemContext context) + { + string optionText = context.OptionText ?? string.Empty; + if (context.IsSelectable || string.IsNullOrEmpty(context.BlockedReason)) + { + return optionText; + } + + return $"{optionText}\n{context.BlockedReason}"; + } } } diff --git a/Assets/GameMain/Scripts/UI/Game/View/RepoForm.cs b/Assets/GameMain/Scripts/UI/Game/View/RepoForm.cs index b3fe4c7..770dbaf 100644 --- a/Assets/GameMain/Scripts/UI/Game/View/RepoForm.cs +++ b/Assets/GameMain/Scripts/UI/Game/View/RepoForm.cs @@ -1,4 +1,5 @@ using GeometryTD.CustomEvent; +using TMPro; using UnityEngine; using UnityGameFramework.Runtime; @@ -12,6 +13,8 @@ namespace GeometryTD.UI [SerializeField] private ParticipantArea _participantArea; + [SerializeField] private TMP_Text _goldText; + public void RefreshUI(RepoFormContext context) { if (context == null) @@ -19,11 +22,21 @@ namespace GeometryTD.UI return; } + RefreshGoldText(context.GoldText); + _combineArea?.OnInit(context.CombineAreaContext); _compArea?.OnInit(context.CompAreaContext); _participantArea?.OnInit(context.ParticipantAreaContext); } + public void RefreshGoldText(string goldText) + { + if (_goldText != null) + { + _goldText.text = goldText ?? string.Empty; + } + } + public bool TryAssignItemToCombineArea(RepoItemContext itemContext) { if (_combineArea == null) @@ -69,6 +82,11 @@ namespace GeometryTD.UI protected override void OnClose(bool isShutdown, object userData) { + if (_goldText != null) + { + _goldText.text = string.Empty; + } + _combineArea?.OnReset(); _compArea?.OnReset(); _participantArea?.OnReset(); diff --git a/Assets/GameMain/UI/UIForms/RepoForm.prefab b/Assets/GameMain/UI/UIForms/RepoForm.prefab index 39c1040..89d0b9b 100644 --- a/Assets/GameMain/UI/UIForms/RepoForm.prefab +++ b/Assets/GameMain/UI/UIForms/RepoForm.prefab @@ -130,6 +130,140 @@ MonoBehaviour: m_Spacing: {x: 20, y: 20} m_Constraint: 1 m_ConstraintCount: 6 +--- !u!1 &3121582388666951180 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 8127279669446431800} + - component: {fileID: 3226573329967497035} + - component: {fileID: 661567130228843717} + m_Layer: 5 + m_Name: GoldText + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &8127279669446431800 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3121582388666951180} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 956737910225412855} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &3226573329967497035 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3121582388666951180} + m_CullTransparentMesh: 1 +--- !u!114 &661567130228843717 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3121582388666951180} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: 1234555 + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2} + m_sharedMaterial: {fileID: 2180264, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4294967295 + m_fontColor: {r: 1, g: 1, b: 1, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 71.6 + m_fontSizeBase: 36 + m_fontWeight: 400 + m_enableAutoSizing: 1 + m_fontSizeMin: 18 + m_fontSizeMax: 72 + m_fontStyle: 0 + m_HorizontalAlignment: 4 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_enableWordWrapping: 1 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 1 + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} --- !u!1 &3151563931008387230 GameObject: m_ObjectHideFlags: 0 @@ -380,6 +514,7 @@ MonoBehaviour: _combineArea: {fileID: 2687805356437946940} _compArea: {fileID: 370202498391855143} _participantArea: {fileID: 6217930899804600847} + _goldText: {fileID: 661567130228843717} --- !u!1 &4000410608128749255 GameObject: m_ObjectHideFlags: 0 @@ -971,6 +1106,7 @@ RectTransform: - {fileID: 437137211665287909} - {fileID: 4661661401541872014} - {fileID: 6194629833245286025} + - {fileID: 956737910225412855} m_Father: {fileID: 3043356307630469178} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} @@ -1091,6 +1227,42 @@ MonoBehaviour: m_FillOrigin: 0 m_UseSpriteMesh: 0 m_PixelsPerUnitMultiplier: 1 +--- !u!1 &8409825260122624528 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 956737910225412855} + m_Layer: 5 + m_Name: GoldArea + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &956737910225412855 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8409825260122624528} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 8127279669446431800} + m_Father: {fileID: 948419798348630685} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 1, y: 1} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: -1000, y: -100} + m_SizeDelta: {x: 400, y: 80} + m_Pivot: {x: 0.5, y: 0.5} --- !u!1001 &19160693396753940 PrefabInstance: m_ObjectHideFlags: 0 diff --git a/Assets/Tests/EditMode/EventDefinitionFactoryTests.cs b/Assets/Tests/EditMode/EventDefinitionFactoryTests.cs index b169278..e0be4d2 100644 --- a/Assets/Tests/EditMode/EventDefinitionFactoryTests.cs +++ b/Assets/Tests/EditMode/EventDefinitionFactoryTests.cs @@ -1,5 +1,6 @@ using GeometryTD.Definition; using GeometryTD.Factory; +using GeometryTD.DataTable; using Newtonsoft.Json.Linq; using NUnit.Framework; @@ -75,6 +76,33 @@ namespace GeometryTD.Tests.EditMode Assert.That(removeRandomCompEffect.Probability, Is.EqualTo(0.25f)); } + [Test] + public void EffectFactory_Creates_AddRandomComponents_With_Ranged_Rarity() + { + EventEffectBase effect = EventEffectFactory.Create( + "AddRandomComps", + JObject.Parse(@"{ ""Count"": 1, ""MinRarity"": ""Green"", ""MaxRarity"": ""Blue"" }"), + 0.6f); + + Assert.That(effect, Is.TypeOf()); + AddRandomCompsParam param = (AddRandomCompsParam)effect.Param; + Assert.That(param.Count, Is.EqualTo(1)); + Assert.That(param.MinRarity, Is.EqualTo(RarityType.Green)); + Assert.That(param.MaxRarity, Is.EqualTo(RarityType.Blue)); + Assert.That(param.Rarity, Is.EqualTo(RarityType.None)); + Assert.That(effect.Probability, Is.EqualTo(0.6f)); + } + + [Test] + public void EffectFactory_Throws_When_Ranged_Rarity_Config_Is_Incomplete() + { + Assert.That( + () => EventEffectFactory.Create( + "AddRandomComps", + JObject.Parse(@"{ ""Count"": 1, ""MinRarity"": ""Green"" }")), + Throws.TypeOf()); + } + [Test] public void EffectFactory_Creates_DamageRandomTowerEnduranceEffect_With_Expected_Params() { @@ -98,5 +126,20 @@ namespace GeometryTD.Tests.EditMode Assert.That(EventEffectFactory.Create(string.Empty, JObject.Parse("{}")), Is.Null); Assert.That(EventEffectFactory.Create("UnknownEffect", JObject.Parse("{}")), Is.Null); } + + [Test] + public void DREvent_ParseDataRow_Builds_OptionsRaw_From_Four_Columns() + { + DREvent row = new DREvent(); + bool parsed = row.ParseDataRow( + "\t1\t\t测试事件\t测试描述\t{\"optionText\":\"选项一\",\"rewardEffects\":[]}\t{\"optionText\":\"选项二\",\"rewardEffects\":[]}\t\t", + null); + + Assert.That(parsed, Is.True); + JArray options = JArray.Parse(row.OptionsRaw); + Assert.That(options.Count, Is.EqualTo(2)); + Assert.That(options[0].Value("optionText"), Is.EqualTo("选项一")); + Assert.That(options[1].Value("optionText"), Is.EqualTo("选项二")); + } } } diff --git a/Assets/Tests/EditMode/EventOptionExecutorTests.cs b/Assets/Tests/EditMode/EventOptionExecutorTests.cs new file mode 100644 index 0000000..3f7e873 --- /dev/null +++ b/Assets/Tests/EditMode/EventOptionExecutorTests.cs @@ -0,0 +1,803 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using GameFramework.DataTable; +using GeometryTD.CustomComponent; +using GeometryTD.DataTable; +using GeometryTD.Definition; +using GeometryTD.Procedure; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace GeometryTD.Tests.EditMode +{ + public sealed class EventOptionExecutorTests + { + private GameObject _inventoryGenerationObject; + private InventoryGenerationComponent _originalInventoryGeneration; + + [SetUp] + public void SetUp() + { + TagDefinitionRegistry.ResetToDefaults(); + TagGenerationRuleRegistry.ResetToDefaults(); + RarityTagBudgetRuleRegistry.ResetToDefaults(); + } + + [TearDown] + public void TearDown() + { + TagDefinitionRegistry.ResetToDefaults(); + TagGenerationRuleRegistry.ResetToDefaults(); + RarityTagBudgetRuleRegistry.ResetToDefaults(); + + SetStaticInventoryGeneration(_originalInventoryGeneration); + _originalInventoryGeneration = null; + + if (_inventoryGenerationObject != null) + { + Object.DestroyImmediate(_inventoryGenerationObject); + _inventoryGenerationObject = null; + } + } + + [Test] + public void EvaluateOption_Blocks_When_Gold_Requirement_Not_Met() + { + EventOptionExecutor executor = new EventOptionExecutor(); + EventOption option = new EventOption( + "下注 100", + new EventRequirementBase[] + { + new GoldAtLeastRequirement(new GoldAtLeastParam(100)) + }, + Array.Empty(), + Array.Empty()); + + EventOptionAvailability availability = executor.EvaluateOption( + option, + new BackpackInventoryData { Gold = 60 }); + + Assert.That(availability.IsSelectable, Is.False); + Assert.That(availability.BlockedReason, Is.EqualTo("需要至少 100 金币")); + } + + [Test] + public void Execute_Applies_Cost_Before_Probability_And_Skips_Reward_On_Failure() + { + EventOptionExecutor executor = new EventOptionExecutor(); + EventItem eventItem = new EventItem( + 1, + "赌马", + "测试事件", + new[] + { + new EventOption( + "下注", + new EventRequirementBase[] + { + new GoldAtLeastRequirement(new GoldAtLeastParam(100)) + }, + new EventEffectBase[] + { + new AddGoldEffect(new AddGoldParam(-100)) + }, + new EventEffectBase[] + { + new AddGoldEffect(new AddGoldParam(250)) + }, + probability: 0f) + }); + BackpackInventoryData inventory = new BackpackInventoryData { Gold = 120 }; + + EventOptionExecutionResult result = executor.Execute( + eventItem, + 0, + CreateContext(), + inventory); + + Assert.That(result.IsAccepted, Is.True); + Assert.That(result.IsProbabilitySuccess, Is.False); + Assert.That(inventory.Gold, Is.EqualTo(20)); + } + + [Test] + public void Execute_Removes_Only_Loose_Components_Of_Requested_Rarity() + { + EventOptionExecutor executor = new EventOptionExecutor(); + EventItem eventItem = new EventItem( + 2, + "工匠", + "测试事件", + new[] + { + new EventOption( + "交出 2 个白色组件", + new EventRequirementBase[] + { + new CompCountAtLeastRequirement(new CompCountAtLeastParam(2, RarityType.White)) + }, + new EventEffectBase[] + { + new RemoveRandomCompsEffect(new RemoveRandomCompsParam(2, RarityType.White)) + }, + Array.Empty()) + }); + BackpackInventoryData inventory = new BackpackInventoryData + { + MuzzleComponents = new List + { + new MuzzleCompItemData + { + InstanceId = 10001, + Name = "已装配白枪口", + Rarity = RarityType.White, + IsAssembledIntoTower = true + }, + new MuzzleCompItemData + { + InstanceId = 10002, + Name = "未装配白枪口", + Rarity = RarityType.White, + IsAssembledIntoTower = false + } + }, + BearingComponents = new List + { + new BearingCompItemData + { + InstanceId = 20001, + Name = "未装配白轴承", + Rarity = RarityType.White, + IsAssembledIntoTower = false + }, + new BearingCompItemData + { + InstanceId = 20002, + Name = "绿色轴承", + Rarity = RarityType.Green, + IsAssembledIntoTower = false + } + } + }; + + EventOptionExecutionResult result = executor.Execute(eventItem, 0, CreateContext(), inventory); + + Assert.That(result.IsAccepted, Is.True); + Assert.That(inventory.MuzzleComponents.Count, Is.EqualTo(1)); + Assert.That(inventory.MuzzleComponents[0].InstanceId, Is.EqualTo(10001)); + Assert.That(inventory.BearingComponents.Count, Is.EqualTo(1)); + Assert.That(inventory.BearingComponents[0].InstanceId, Is.EqualTo(20002)); + } + + [Test] + public void Execute_AddRandomComponents_Is_Stable_For_Same_Context() + { + BindInventoryGeneration(); + EventOptionExecutor executor = new EventOptionExecutor(); + EventItem eventItem = new EventItem( + 3, + "奖励组件", + "测试事件", + new[] + { + new EventOption( + "获得两个蓝色组件", + Array.Empty(), + Array.Empty(), + new EventEffectBase[] + { + new AddRandomCompsEffect(new AddRandomCompsParam(2, RarityType.Blue)) + }) + }); + RunNodeExecutionContext context = CreateContext(); + + BackpackInventoryData firstInventory = new BackpackInventoryData(); + BackpackInventoryData secondInventory = new BackpackInventoryData(); + + EventOptionExecutionResult firstResult = executor.Execute(eventItem, 0, context, firstInventory); + EventOptionExecutionResult secondResult = executor.Execute(eventItem, 0, context, secondInventory); + + Assert.That(firstResult.IsAccepted, Is.True); + Assert.That(secondResult.IsAccepted, Is.True); + Assert.That(BuildComponentSignature(secondInventory), Is.EqualTo(BuildComponentSignature(firstInventory))); + } + + [Test] + public void Execute_ComponentExchange_Removes_Two_And_Returns_One_In_Configured_Range() + { + BindInventoryGeneration(); + EventOptionExecutor executor = new EventOptionExecutor(); + EventItem eventItem = new EventItem( + 5, + "组件交换", + "测试事件", + new[] + { + new EventOption( + "交出 2 个绿色组件,获得 1 个绿色或蓝色组件", + new EventRequirementBase[] + { + new CompCountAtLeastRequirement(new CompCountAtLeastParam(2, RarityType.Green)) + }, + new EventEffectBase[] + { + new RemoveRandomCompsEffect(new RemoveRandomCompsParam(2, RarityType.Green)) + }, + new EventEffectBase[] + { + new AddRandomCompsEffect(new AddRandomCompsParam(1, RarityType.Green, RarityType.Blue)) + }) + }); + BackpackInventoryData inventory = new BackpackInventoryData + { + MuzzleComponents = new List + { + new MuzzleCompItemData + { + InstanceId = 10001, + Name = "绿枪口 A", + Rarity = RarityType.Green, + IsAssembledIntoTower = false + } + }, + BearingComponents = new List + { + new BearingCompItemData + { + InstanceId = 20001, + Name = "绿轴承 B", + Rarity = RarityType.Green, + IsAssembledIntoTower = false + } + } + }; + + EventOptionExecutionResult result = executor.Execute(eventItem, 0, CreateContext(), inventory); + + Assert.That(result.IsAccepted, Is.True); + Assert.That(CountLooseComponentsOfRarity(inventory, RarityType.Green), Is.LessThanOrEqualTo(1)); + Assert.That(CountAllComponents(inventory), Is.EqualTo(1)); + + TowerCompItemData rewardItem = GetOnlyComponent(inventory); + Assert.That(rewardItem.Rarity == RarityType.Green || rewardItem.Rarity == RarityType.Blue, Is.True); + } + + [Test] + public void Execute_EnduranceForGold_Applies_Cost_Before_Reward() + { + EventOptionExecutor executor = new EventOptionExecutor(); + EventItem eventItem = new EventItem( + 6, + "耐久换金币", + "测试事件", + new[] + { + new EventOption( + "扣耐久换金币", + new EventRequirementBase[] + { + new TowerCountAtLeastRequirement(new TowerCountAtLeastParam(1)) + }, + new EventEffectBase[] + { + new DamageRandomTowerEnduranceEffect(new DamageRandomTowerEnduranceParam(1, 20)) + }, + new EventEffectBase[] + { + new AddGoldEffect(new AddGoldParam(50)) + }) + }); + BackpackInventoryData inventory = CreateParticipantInventory(endurance: 30f); + inventory.Gold = 10; + + EventOptionExecutionResult result = executor.Execute(eventItem, 0, CreateContext(), inventory); + + Assert.That(result.IsAccepted, Is.True); + Assert.That(inventory.Gold, Is.EqualTo(60)); + Assert.That(inventory.MuzzleComponents[0].Endurance, Is.EqualTo(10f)); + Assert.That(inventory.BearingComponents[0].Endurance, Is.EqualTo(10f)); + Assert.That(inventory.BaseComponents[0].Endurance, Is.EqualTo(10f)); + } + + [Test] + public void Execute_Damaged_Participant_Tower_Can_Be_Cleaned_Up_By_Procedure_Service() + { + EventOptionExecutor executor = new EventOptionExecutor(); + EventItem eventItem = new EventItem( + 4, + "耐久惩罚", + "测试事件", + new[] + { + new EventOption( + "损坏防御塔", + Array.Empty(), + Array.Empty(), + new EventEffectBase[] + { + new DamageRandomTowerEnduranceEffect(new DamageRandomTowerEnduranceParam(1, 20)) + }) + }); + BackpackInventoryData inventory = CreateParticipantInventory(endurance: 10f); + + EventOptionExecutionResult result = executor.Execute(eventItem, 0, CreateContext(), inventory); + ProcedureMainParticipantTowerCleanupResult cleanupResult = + ProcedureMainParticipantTowerCleanupService.RemoveBrokenParticipantTowers(inventory, 4); + + Assert.That(result.IsAccepted, Is.True); + Assert.That(cleanupResult.HasAnyRemovedTower, Is.True); + Assert.That(inventory.ParticipantTowerInstanceIds, Is.Empty); + } + + [Test] + public void EventTable_Uses_Only_Runtime_Supported_Types() + { + string filePath = ResolveRepoFilePath("Assets/GameMain/DataTables/Event.txt"); + Assert.That(File.Exists(filePath), Is.True, filePath); + + HashSet supportedRequirementTypes = new HashSet + { + "GoldAtLeast", + "CompCountAtLeast", + "TowerCountAtLeast" + }; + HashSet supportedEffectTypes = new HashSet + { + "AddGold", + "RemoveRandomComps", + "AddRandomComps", + "DamageRandomTowersEndurance" + }; + + foreach (string line in File.ReadLines(filePath)) + { + if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#", StringComparison.Ordinal)) + { + continue; + } + + DREvent row = new DREvent(); + Assert.That(row.ParseDataRow(line, null), Is.True, line); + JArray options = JArray.Parse(row.OptionsRaw); + for (int i = 0; i < options.Count; i++) + { + JObject optionObject = options[i] as JObject; + Assert.That(optionObject, Is.Not.Null, $"EventId={row.Id}, OptionIndex={i}"); + AssertSupportedTypes( + row.Id, + i, + optionObject["requirements"] as JArray, + supportedRequirementTypes, + isRequirement: true); + AssertSupportedTypes( + row.Id, + i, + optionObject["costEffects"] as JArray, + supportedEffectTypes, + isRequirement: false); + AssertSupportedTypes( + row.Id, + i, + optionObject["rewardEffects"] as JArray, + supportedEffectTypes, + isRequirement: false); + } + } + } + + [Test] + public void EventNodeComponent_Selects_Same_Event_For_Same_Context() + { + GameObject gameObject = new GameObject("EventNodeComponentTests"); + try + { + EventNodeComponent component = gameObject.AddComponent(); + List eventItems = GetPrivateField>(component, "_eventItems"); + eventItems.Add(new EventItem(11, "事件一", string.Empty, Array.Empty())); + eventItems.Add(new EventItem(12, "事件二", string.Empty, Array.Empty())); + eventItems.Add(new EventItem(13, "事件三", string.Empty, Array.Empty())); + + MethodInfo method = typeof(EventNodeComponent).GetMethod( + "SelectActiveEvent", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null); + + RunNodeExecutionContext context = CreateContext(); + EventItem first = (EventItem)method.Invoke(component, new object[] { context }); + EventItem second = (EventItem)method.Invoke(component, new object[] { context }); + + Assert.That(second, Is.Not.Null); + Assert.That(second.Id, Is.EqualTo(first.Id)); + } + finally + { + Object.DestroyImmediate(gameObject); + } + } + + private void BindInventoryGeneration() + { + _originalInventoryGeneration = GameEntry.InventoryGeneration; + _inventoryGenerationObject = new GameObject("EventInventoryGenerationTests"); + InventoryGenerationComponent component = _inventoryGenerationObject.AddComponent(); + SetPrivateField(component, "_muzzleCompTable", new FakeDataTable( + CreateMuzzleRow(1, "火焰枪口", "[Fire,Ice,Crit]"), + CreateMuzzleRow(2, "暴击枪口", "[Crit,Shatter,Execution]"))); + SetPrivateField(component, "_bearingCompTable", new FakeDataTable( + CreateBearingRow(1, "寒冰轴承", "[Ice,Shatter]"), + CreateBearingRow(2, "穿透轴承", "[Pierce,Crit]"))); + SetPrivateField(component, "_baseCompTable", new FakeDataTable( + CreateBaseRow(1, "迅捷底座", "[Fire,Crit]"), + CreateBaseRow(2, "处决底座", "[Execution,Ice]"))); + SetStaticInventoryGeneration(component); + } + + private static RunNodeExecutionContext CreateContext() + { + return new RunNodeExecutionContext + { + RunId = "testrun", + RunSeed = 12345, + NodeId = 101, + NodeType = RunNodeType.Event, + SequenceIndex = 3 + }; + } + + private static BackpackInventoryData CreateParticipantInventory(float endurance) + { + return new BackpackInventoryData + { + MuzzleComponents = new List + { + new MuzzleCompItemData + { + InstanceId = 10001, + Name = "枪口", + Rarity = RarityType.Blue, + Endurance = endurance, + IsAssembledIntoTower = true + } + }, + BearingComponents = new List + { + new BearingCompItemData + { + InstanceId = 20001, + Name = "轴承", + Rarity = RarityType.Blue, + Endurance = endurance, + IsAssembledIntoTower = true + } + }, + BaseComponents = new List + { + new BaseCompItemData + { + InstanceId = 30001, + Name = "底座", + Rarity = RarityType.Blue, + Endurance = endurance, + IsAssembledIntoTower = true + } + }, + Towers = new List + { + new TowerItemData + { + InstanceId = 90001, + Name = "测试防御塔", + MuzzleComponentInstanceId = 10001, + BearingComponentInstanceId = 20001, + BaseComponentInstanceId = 30001 + } + }, + ParticipantTowerInstanceIds = new List { 90001 } + }; + } + + private static string BuildComponentSignature(BackpackInventoryData inventory) + { + IEnumerable items = + inventory.MuzzleComponents.Cast() + .Concat(inventory.BearingComponents) + .Concat(inventory.BaseComponents) + .OrderBy(item => item.InstanceId); + return string.Join( + "|", + items.Select(item => + { + string tags = item.Tags == null || item.Tags.Length <= 0 + ? string.Empty + : string.Join(",", item.Tags.Select(tag => tag.ToString())); + return $"{item.InstanceId}:{item.SlotType}:{item.ConfigId}:{item.Name}:{item.Rarity}:{tags}"; + })); + } + + private static int CountLooseComponentsOfRarity(BackpackInventoryData inventory, RarityType rarity) + { + return GetAllComponents(inventory) + .Count(component => !component.IsAssembledIntoTower && component.Rarity == rarity); + } + + private static int CountAllComponents(BackpackInventoryData inventory) + { + return GetAllComponents(inventory).Count(); + } + + private static TowerCompItemData GetOnlyComponent(BackpackInventoryData inventory) + { + return GetAllComponents(inventory).Single(); + } + + private static IEnumerable GetAllComponents(BackpackInventoryData inventory) + { + return inventory.MuzzleComponents.Cast() + .Concat(inventory.BearingComponents) + .Concat(inventory.BaseComponents); + } + + private static void AssertSupportedTypes( + int eventId, + int optionIndex, + JArray entries, + HashSet supportedTypes, + bool isRequirement) + { + if (entries == null) + { + return; + } + + for (int i = 0; i < entries.Count; i++) + { + JObject entry = entries[i] as JObject; + Assert.That(entry, Is.Not.Null, $"EventId={eventId}, OptionIndex={optionIndex}, EntryIndex={i}"); + string rawType = entry.Value("type"); + Assert.That( + supportedTypes.Contains(rawType), + Is.True, + $"{(isRequirement ? "Requirement" : "Effect")} type '{rawType}' is not supported at runtime. EventId={eventId}, OptionIndex={optionIndex}, EntryIndex={i}"); + } + } + + private static void SetStaticInventoryGeneration(InventoryGenerationComponent component) + { + FieldInfo backingField = typeof(GameEntry).GetField( + "k__BackingField", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.That(backingField, Is.Not.Null); + backingField.SetValue(null, component); + } + + private static TField GetPrivateField(object instance, string fieldName) + { + FieldInfo field = instance.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null, fieldName); + return (TField)field.GetValue(instance); + } + + private static void SetPrivateField(object instance, string fieldName, object value) + { + FieldInfo field = instance.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null, fieldName); + field.SetValue(instance, value); + } + + private static string ResolveRepoFilePath(string relativePath) + { + DirectoryInfo directory = new DirectoryInfo(Directory.GetCurrentDirectory()); + while (directory != null) + { + string candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + return Path.Combine(Directory.GetCurrentDirectory(), relativePath); + } + + private static DRMuzzleComp CreateMuzzleRow(int id, string name, string possibleTags) + { + DRMuzzleComp row = new DRMuzzleComp(); + Assert.That( + row.ParseDataRow($"\t{id}\t\t{name}\t[10,20,30,40,50]\t3\t0.15\tNormalBullet\t\t{possibleTags}", null), + Is.True); + return row; + } + + private static DRBearingComp CreateBearingRow(int id, string name, string possibleTags) + { + DRBearingComp row = new DRBearingComp(); + Assert.That( + row.ParseDataRow($"\t{id}\t\t{name}\t[1,2,3,4,5]\t0.5\t[10,20,30,40,50]\t1\t\t{possibleTags}", null), + Is.True); + return row; + } + + private static DRBaseComp CreateBaseRow(int id, string name, string possibleTags) + { + DRBaseComp row = new DRBaseComp(); + Assert.That( + row.ParseDataRow($"\t{id}\t\t{name}\t[2,4,6,8,10]\t-0.25\tFire\t\t{possibleTags}", null), + Is.True); + return row; + } + + private sealed class FakeDataTable : IDataTable where TRow : class, IDataRow + { + private readonly Dictionary _rowsById = new(); + + public FakeDataTable(params TRow[] rows) + { + if (rows == null) + { + return; + } + + for (int i = 0; i < rows.Length; i++) + { + TRow row = rows[i]; + if (row != null) + { + _rowsById[row.Id] = row; + } + } + } + + public string Name => typeof(TRow).Name; + public string FullName => typeof(TRow).FullName; + public Type Type => typeof(TRow); + public int Count => _rowsById.Count; + public TRow this[int id] => GetDataRow(id); + public TRow MinIdDataRow => _rowsById.Count <= 0 ? null : GetDataRow(GetOrderedIds()[0]); + public TRow MaxIdDataRow => _rowsById.Count <= 0 ? null : GetDataRow(GetOrderedIds()[^1]); + + public bool HasDataRow(int id) => _rowsById.ContainsKey(id); + + public bool HasDataRow(Predicate condition) => GetDataRow(condition) != null; + + public TRow GetDataRow(int id) => _rowsById.TryGetValue(id, out TRow row) ? row : null; + + public TRow GetDataRow(Predicate condition) + { + if (condition == null) + { + return null; + } + + foreach (TRow row in _rowsById.Values) + { + if (row != null && condition(row)) + { + return row; + } + } + + return null; + } + + public TRow[] GetDataRows(Predicate condition) + { + List results = new(); + GetDataRows(condition, results); + return results.ToArray(); + } + + public void GetDataRows(Predicate condition, List results) + { + results?.Clear(); + if (condition == null || results == null) + { + return; + } + + foreach (TRow row in _rowsById.Values) + { + if (row != null && condition(row)) + { + results.Add(row); + } + } + } + + public TRow[] GetDataRows(Comparison comparison) + { + List results = new(); + GetDataRows(comparison, results); + return results.ToArray(); + } + + public void GetDataRows(Comparison comparison, List results) + { + results?.Clear(); + if (results == null) + { + return; + } + + results.AddRange(_rowsById.Values); + if (comparison != null) + { + results.Sort(comparison); + } + } + + public TRow[] GetDataRows(Predicate condition, Comparison comparison) + { + List results = new(); + GetDataRows(condition, comparison, results); + return results.ToArray(); + } + + public void GetDataRows(Predicate condition, Comparison comparison, List results) + { + GetDataRows(condition, results); + if (results != null && comparison != null) + { + results.Sort(comparison); + } + } + + public TRow[] GetAllDataRows() + { + List results = new(); + GetAllDataRows(results); + return results.ToArray(); + } + + public void GetAllDataRows(List results) + { + results?.Clear(); + if (results == null) + { + return; + } + + foreach (int id in GetOrderedIds()) + { + results.Add(_rowsById[id]); + } + } + + public bool AddDataRow(string dataRowString, object userData) => throw new NotSupportedException(); + public bool AddDataRow(byte[] dataRowBytes, int startIndex, int length, object userData) => throw new NotSupportedException(); + public bool RemoveDataRow(int id) => _rowsById.Remove(id); + + public void RemoveAllDataRows() + { + _rowsById.Clear(); + } + + public IEnumerator GetEnumerator() + { + foreach (int id in GetOrderedIds()) + { + yield return _rowsById[id]; + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + private int[] GetOrderedIds() + { + int[] ids = _rowsById.Keys.ToArray(); + Array.Sort(ids); + return ids; + } + } + } +} diff --git a/Assets/Tests/EditMode/EventOptionExecutorTests.cs.meta b/Assets/Tests/EditMode/EventOptionExecutorTests.cs.meta new file mode 100644 index 0000000..3892b1c --- /dev/null +++ b/Assets/Tests/EditMode/EventOptionExecutorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b3563fbb433e44fa9c91a61c401e511b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/数据表/Event.xlsx b/数据表/Event.xlsx index 6edf68a11782812a7d7056b58e772d116c37a5e5..2a7a15f0018221fbbeed9b7237c6f9344e4788a7 100644 GIT binary patch delta 6246 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