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 6edf68a..2a7a15f 100644 Binary files a/数据表/Event.xlsx and b/数据表/Event.xlsx differ