diff --git a/Assets/GameMain/Scripts/Base/GameEntry.Custom.cs b/Assets/GameMain/Scripts/Base/GameEntry.Custom.cs index fb8c800..f107607 100644 --- a/Assets/GameMain/Scripts/Base/GameEntry.Custom.cs +++ b/Assets/GameMain/Scripts/Base/GameEntry.Custom.cs @@ -26,6 +26,8 @@ public partial class GameEntry public static InventoryGenerationComponent InventoryGeneration { get; private set; } + public static TagRegistryComponent TagRegistry { get; private set; } + private static void InitCustomComponents() { BuiltinData = UnityGameFramework.Runtime.GameEntry.GetComponent(); @@ -38,5 +40,6 @@ public partial class GameEntry ResolutionAdapter = UnityGameFramework.Runtime.GameEntry.GetComponent(); SpriteCache = UnityGameFramework.Runtime.GameEntry.GetComponent(); InventoryGeneration = UnityGameFramework.Runtime.GameEntry.GetComponent(); + TagRegistry = UnityGameFramework.Runtime.GameEntry.GetComponent(); } } diff --git a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/DropPoolRoller.cs b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/DropPoolRoller.cs index 203424c..73d0e8a 100644 --- a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/DropPoolRoller.cs +++ b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/DropPoolRoller.cs @@ -3,6 +3,7 @@ using GameFramework.DataTable; using GeometryTD.DataTable; using GeometryTD.Definition; using UnityEngine; +using Random = System.Random; namespace GeometryTD.CustomComponent { @@ -22,6 +23,7 @@ namespace GeometryTD.CustomComponent public bool TryRollRow( int displayPhaseIndex, LevelThemeType themeType, + Random random, out DROutGameDropPool selectedRow, out RarityType selectedRarity) { @@ -40,7 +42,7 @@ namespace GeometryTD.CustomComponent return false; } - selectedRarity = RollRarity(displayPhaseIndex); + selectedRarity = RollRarity(displayPhaseIndex, random); if (selectedRarity == RarityType.None) { return false; @@ -71,7 +73,7 @@ namespace GeometryTD.CustomComponent return false; } - int randomWeight = Random.Range(1, totalWeight + 1); + int randomWeight = random.Next(1, totalWeight + 1); int cumulativeWeight = 0; foreach (var row in _eligibleRowBuffer) { @@ -127,7 +129,7 @@ namespace GeometryTD.CustomComponent } } - private RarityType RollRarity(int displayPhaseIndex) + private RarityType RollRarity(int displayPhaseIndex, Random random) { _rarityWeightBuffer.Clear(); float phaseT = Mathf.Clamp01((displayPhaseIndex - 1) / RarityCurveScalePhase); @@ -178,7 +180,7 @@ namespace GeometryTD.CustomComponent return RarityType.None; } - float randomWeight = Random.value * totalWeight; + float randomWeight = (float)(random.NextDouble() * totalWeight); float cumulativeWeight = 0f; foreach (var pair in _rarityWeightBuffer) { diff --git a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationComponent.cs b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationComponent.cs index c26f97c..4b72a8a 100644 --- a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationComponent.cs +++ b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationComponent.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using GameFramework.DataTable; using GeometryTD.DataTable; @@ -7,21 +6,16 @@ using GeometryTD.Factory; using GeometryTD.UI; using UnityEngine; using UnityGameFramework.Runtime; -using Random = UnityEngine.Random; +using Random = System.Random; namespace GeometryTD.CustomComponent { public sealed class InventoryGenerationComponent : GameFrameworkComponent { - private const float DropChanceBase = 0.05f; - private const float DropChancePerPhase = 0.2f; - private const float DropChanceCap = 0.2f; - - private long _nextTempInstanceId = 1000000; private int _runSeed; private int _nodeSequenceIndex = -1; - private int _nextDropTagOrdinal; - private int _nextRewardTagOrdinal; + private int _nextDropOrdinal; + private int _nextRewardOrdinal; private readonly List _shopPriceRows = new(); private IDataTable _shopPriceTable; @@ -32,19 +26,20 @@ namespace GeometryTD.CustomComponent private ShopGoodsBuilder _shopGoodsBuilder; private DropPoolRoller _dropPoolRoller; private RewardCandidateBuilder _rewardCandidateBuilder; + private OutGameDropItemBuilder _outGameDropItemBuilder; public void ConfigureRunContext(int runSeed, int sequenceIndex) { _runSeed = runSeed; _nodeSequenceIndex = sequenceIndex; - _nextDropTagOrdinal = 0; - _nextRewardTagOrdinal = 0; + _nextDropOrdinal = 0; + _nextRewardOrdinal = 0; } public List BuildShopGoods(int goodsCount, int runSeed = 0, int sequenceIndex = -1) { EnsureShopBuilder(); - return _shopGoodsBuilder.BuildGoods(goodsCount, runSeed, sequenceIndex, AllocateTempInstanceId); + return _shopGoodsBuilder.BuildGoods(goodsCount, runSeed, sequenceIndex); } public EnemyDropResult ResolveEnemyDrop(in EnemyDropContext context) @@ -61,14 +56,24 @@ namespace GeometryTD.CustomComponent ? Mathf.Clamp01(enemy.DropPercent * 0.01f) : Mathf.Clamp01(enemy.DropPercent); - if (enemy.DropGold > 0 && dropRate > 0f && Random.value <= dropRate) + int dropOrdinal = AllocateDropOrdinal(); + InventoryGenerationRandomContext randomContext = + new(_runSeed, _nodeSequenceIndex, InventoryTagSourceType.Drop, dropOrdinal); + Random random = randomContext.CreateRandom(); + + if (enemy.DropGold > 0 && dropRate > 0f && random.NextDouble() <= dropRate) { gold = Mathf.Max(0, enemy.DropGold); } TowerCompItemData lootItem = null; - if (ShouldRollOutGameItem(context.DisplayPhaseIndex) && - TryRollOutGameItem(context.DisplayPhaseIndex, context.ThemeType, out TowerCompItemData droppedItem)) + if (OutGameDropRuleService.ShouldRollOutGameItem(context.DisplayPhaseIndex, random) && + TryRollOutGameItem( + context.DisplayPhaseIndex, + context.ThemeType, + randomContext, + random, + out TowerCompItemData droppedItem)) { lootItem = droppedItem; } @@ -86,6 +91,7 @@ namespace GeometryTD.CustomComponent displayPhaseIndex, themeType, candidateCount, + CreateNextRewardRandomContext, BuildRewardCandidateItem); } @@ -99,7 +105,7 @@ namespace GeometryTD.CustomComponent if (_shopPriceTable == null || _muzzleCompTable == null || _bearingCompTable == null || _baseCompTable == null) { - throw new InvalidOperationException( + throw new System.InvalidOperationException( "InventoryGenerationComponent requires ShopPrice, MuzzleComp, BearingComp, and BaseComp data tables."); } @@ -111,7 +117,7 @@ namespace GeometryTD.CustomComponent DRShopPrice[] rows = _shopPriceTable.GetAllDataRows(); if (rows == null || rows.Length <= 0) { - throw new InvalidOperationException( + throw new System.InvalidOperationException( "InventoryGenerationComponent requires at least one shop price row."); } @@ -125,7 +131,7 @@ namespace GeometryTD.CustomComponent if (_shopPriceRows.Count <= 0) { - throw new InvalidOperationException("InventoryGenerationComponent requires non-null shop price rows."); + throw new System.InvalidOperationException("InventoryGenerationComponent requires non-null shop price rows."); } } @@ -139,11 +145,6 @@ namespace GeometryTD.CustomComponent _baseCompTable); } - private long AllocateTempInstanceId() - { - return _nextTempInstanceId++; - } - private void EnsureDropTables() { _dropPoolTable ??= GameEntry.DataTable.GetDataTable(); @@ -153,7 +154,7 @@ namespace GeometryTD.CustomComponent if (_dropPoolTable == null || _muzzleCompTable == null || _bearingCompTable == null || _baseCompTable == null) { - throw new InvalidOperationException( + throw new System.InvalidOperationException( "InventoryGenerationComponent requires OutGameDropPool, MuzzleComp, BearingComp, and BaseComp data tables."); } } @@ -171,14 +172,22 @@ namespace GeometryTD.CustomComponent return _rewardCandidateBuilder; } - private static bool ShouldRollOutGameItem(int displayPhaseIndex) + private OutGameDropItemBuilder EnsureOutGameDropItemBuilder() { - int phaseIndex = Mathf.Max(1, displayPhaseIndex); - float dropChance = Mathf.Clamp(DropChanceBase + (phaseIndex - 1) * DropChancePerPhase, 0f, DropChanceCap); - return Random.value <= dropChance; + EnsureDropTables(); + _outGameDropItemBuilder ??= new OutGameDropItemBuilder( + _muzzleCompTable, + _bearingCompTable, + _baseCompTable); + return _outGameDropItemBuilder; } - private bool TryRollOutGameItem(int displayPhaseIndex, LevelThemeType themeType, out TowerCompItemData droppedItem) + private bool TryRollOutGameItem( + int displayPhaseIndex, + LevelThemeType themeType, + InventoryGenerationRandomContext randomContext, + Random random, + out TowerCompItemData droppedItem) { droppedItem = null; DropPoolRoller dropPoolRoller = EnsureDropPoolRoller(); @@ -186,120 +195,46 @@ namespace GeometryTD.CustomComponent if (!dropPoolRoller.TryRollRow( phaseIndex, themeType, + random, out DROutGameDropPool selectedRow, out RarityType selectedRarity) || selectedRow == null) { return false; } - return TryBuildDropItem( - selectedRow, - selectedRarity, - InventoryTagSourceType.Drop, - AllocateDropTagOrdinal(), - out droppedItem); + return EnsureOutGameDropItemBuilder().TryBuildItem(selectedRow, selectedRarity, randomContext, out droppedItem); } - private bool TryBuildDropItem( + private int AllocateDropOrdinal() + { + return _nextDropOrdinal++; + } + + private int AllocateRewardOrdinal() + { + return _nextRewardOrdinal++; + } + + private InventoryGenerationRandomContext CreateNextRewardRandomContext() + { + return new InventoryGenerationRandomContext( + _runSeed, + _nodeSequenceIndex, + InventoryTagSourceType.Reward, + AllocateRewardOrdinal()); + } + + private TowerCompItemData BuildRewardCandidateItem( DROutGameDropPool row, RarityType rarity, - InventoryTagSourceType sourceType, - int localOrdinal, - out TowerCompItemData droppedItem) + InventoryGenerationRandomContext randomContext) { - droppedItem = null; - if (row.ItemId <= 0 || string.IsNullOrWhiteSpace(row.ItemType)) - { - return false; - } - - string itemType = row.ItemType.Trim(); - if (itemType.Equals("MuzzleComp", StringComparison.OrdinalIgnoreCase)) - { - DRMuzzleComp config = _muzzleCompTable.GetDataRow(row.ItemId); - if (config == null) - { - return false; - } - - droppedItem = ComponentItemFactory.CreateMuzzle( - config, - AllocateTempInstanceId(), - rarity, - CreateRandomContext(sourceType, localOrdinal, config.Id)); - return true; - } - - if (itemType.Equals("BearingComp", StringComparison.OrdinalIgnoreCase)) - { - DRBearingComp config = _bearingCompTable.GetDataRow(row.ItemId); - if (config == null) - { - return false; - } - - droppedItem = ComponentItemFactory.CreateBearing( - config, - AllocateTempInstanceId(), - rarity, - CreateRandomContext(sourceType, localOrdinal, config.Id)); - return true; - } - - if (itemType.Equals("BaseComp", StringComparison.OrdinalIgnoreCase)) - { - DRBaseComp config = _baseCompTable.GetDataRow(row.ItemId); - if (config == null) - { - return false; - } - - droppedItem = ComponentItemFactory.CreateBase( - config, - AllocateTempInstanceId(), - rarity, - CreateRandomContext(sourceType, localOrdinal, config.Id)); - return true; - } - - return false; - } - - private int AllocateDropTagOrdinal() - { - return _nextDropTagOrdinal++; - } - - private int AllocateRewardTagOrdinal() - { - return _nextRewardTagOrdinal++; - } - - private TowerCompItemData BuildRewardCandidateItem(DROutGameDropPool row, RarityType rarity) - { - if (!TryBuildDropItem( - row, - rarity, - InventoryTagSourceType.Reward, - AllocateRewardTagOrdinal(), - out TowerCompItemData droppedItem)) + if (!EnsureOutGameDropItemBuilder().TryBuildItem(row, rarity, randomContext, out TowerCompItemData droppedItem)) { return null; } return droppedItem; } - - private InventoryTagRandomContext CreateRandomContext( - InventoryTagSourceType sourceType, - int localOrdinal, - int configId) - { - return sourceType switch - { - InventoryTagSourceType.Reward => InventoryTagRandomContext.CreateReward(_runSeed, _nodeSequenceIndex, localOrdinal, configId), - _ => InventoryTagRandomContext.CreateDrop(_runSeed, _nodeSequenceIndex, localOrdinal, configId) - }; - } } } diff --git a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationRandomContext.cs b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationRandomContext.cs new file mode 100644 index 0000000..029029b --- /dev/null +++ b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationRandomContext.cs @@ -0,0 +1,64 @@ +using System; +using Random = System.Random; + +namespace GeometryTD.Definition +{ + public readonly struct InventoryGenerationRandomContext + { + public InventoryGenerationRandomContext( + int runSeed, + int nodeSequenceIndex, + InventoryTagSourceType sourceType, + int localOrdinal) + { + RunSeed = runSeed; + NodeSequenceIndex = nodeSequenceIndex; + SourceType = sourceType; + LocalOrdinal = localOrdinal; + } + + public int RunSeed { get; } + + public int NodeSequenceIndex { get; } + + public InventoryTagSourceType SourceType { get; } + + public int LocalOrdinal { get; } + + public Random CreateRandom() + { + return new Random(BuildSeed()); + } + + public long CreateStableItemInstanceId() + { + long normalizedSource = ((long)Math.Max(0, (int)SourceType) + 1L) << 48; + long normalizedSequence = ((long)Math.Max(0, NodeSequenceIndex) + 1L) << 24; + long normalizedOrdinal = (uint)(Math.Max(0, LocalOrdinal) + 1); + return normalizedSource | normalizedSequence | normalizedOrdinal; + } + + public InventoryTagRandomContext CreateTagRandomContext(int configId) + { + return SourceType switch + { + InventoryTagSourceType.Shop => InventoryTagRandomContext.CreateShop(RunSeed, NodeSequenceIndex, LocalOrdinal, configId), + InventoryTagSourceType.Reward => InventoryTagRandomContext.CreateReward(RunSeed, NodeSequenceIndex, LocalOrdinal, configId), + _ => InventoryTagRandomContext.CreateDrop(RunSeed, NodeSequenceIndex, LocalOrdinal, configId) + }; + } + + private int BuildSeed() + { + unchecked + { + int seed = 17; + seed = seed * 31 + RunSeed; + seed = seed * 31 + NodeSequenceIndex; + seed = seed * 31 + (int)SourceType; + seed = seed * 31 + LocalOrdinal; + return seed; + } + } + } +} diff --git a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationRandomContext.cs.meta b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationRandomContext.cs.meta new file mode 100644 index 0000000..65f13b7 --- /dev/null +++ b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationRandomContext.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 95b2ab672a7049ef9356a90d87baca50 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/OutGameDropRuleService.cs b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/OutGameDropRuleService.cs new file mode 100644 index 0000000..609564e --- /dev/null +++ b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/OutGameDropRuleService.cs @@ -0,0 +1,19 @@ +using UnityEngine; +using Random = System.Random; + +namespace GeometryTD.CustomComponent +{ + public static class OutGameDropRuleService + { + private const float DropChanceBase = 0.05f; + private const float DropChancePerPhase = 0.2f; + private const float DropChanceCap = 0.2f; + + public static bool ShouldRollOutGameItem(int displayPhaseIndex, Random random) + { + int phaseIndex = Mathf.Max(1, displayPhaseIndex); + float dropChance = Mathf.Clamp(DropChanceBase + (phaseIndex - 1) * DropChancePerPhase, 0f, DropChanceCap); + return random.NextDouble() <= dropChance; + } + } +} diff --git a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/OutGameDropRuleService.cs.meta b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/OutGameDropRuleService.cs.meta new file mode 100644 index 0000000..0c5376f --- /dev/null +++ b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/OutGameDropRuleService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 519549c7542f40efabf83a2b1b2dff44 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/RewardCandidateBuilder.cs b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/RewardCandidateBuilder.cs index 545b4fa..6cf542c 100644 --- a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/RewardCandidateBuilder.cs +++ b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/RewardCandidateBuilder.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using GeometryTD.DataTable; using GeometryTD.Definition; using UnityEngine; +using Random = System.Random; namespace GeometryTD.CustomComponent { @@ -19,7 +20,8 @@ namespace GeometryTD.CustomComponent int displayPhaseIndex, LevelThemeType themeType, int candidateCount, - Func buildRewardItem) + Func createRandomContext, + Func buildRewardItem) { int resolvedCount = Mathf.Max(0, candidateCount); if (resolvedCount <= 0) @@ -36,9 +38,12 @@ namespace GeometryTD.CustomComponent while (candidates.Count < resolvedCount && attempts < maxAttempts) { attempts++; + InventoryGenerationRandomContext randomContext = createRandomContext(); + Random random = randomContext.CreateRandom(); if (!_dropPoolRoller.TryRollRow( phaseIndex, themeType, + random, out DROutGameDropPool selectedRow, out RarityType selectedRarity) || selectedRow == null) { @@ -50,7 +55,7 @@ namespace GeometryTD.CustomComponent continue; } - TowerCompItemData candidate = buildRewardItem(selectedRow, selectedRarity); + TowerCompItemData candidate = buildRewardItem(selectedRow, selectedRarity, randomContext); if (candidate == null) { continue; @@ -63,16 +68,19 @@ namespace GeometryTD.CustomComponent while (candidates.Count < resolvedCount && attempts < maxAttempts) { attempts++; + InventoryGenerationRandomContext randomContext = createRandomContext(); + Random random = randomContext.CreateRandom(); if (!_dropPoolRoller.TryRollRow( phaseIndex, themeType, + random, out DROutGameDropPool selectedRow, out RarityType selectedRarity) || selectedRow == null) { break; } - TowerCompItemData candidate = buildRewardItem(selectedRow, selectedRarity); + TowerCompItemData candidate = buildRewardItem(selectedRow, selectedRarity, randomContext); if (candidate == null) { continue; diff --git a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/ShopGoodsBuilder.cs b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/ShopGoodsBuilder.cs index 0bed5ca..6a1fab3 100644 --- a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/ShopGoodsBuilder.cs +++ b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/ShopGoodsBuilder.cs @@ -7,6 +7,7 @@ using GeometryTD.Definition; using GeometryTD.Factory; using GeometryTD.UI; using UnityEngine; +using Random = System.Random; namespace GeometryTD.CustomComponent { @@ -32,8 +33,7 @@ namespace GeometryTD.CustomComponent public List BuildGoods( int goodsCount, int runSeed, - int sequenceIndex, - Func allocateInstanceId) + int sequenceIndex) { if (goodsCount <= 0) { @@ -43,7 +43,9 @@ namespace GeometryTD.CustomComponent List goodsItems = new(goodsCount); for (int i = 0; i < goodsCount; i++) { - goodsItems.Add(BuildGoodsItem(i, runSeed, sequenceIndex, allocateInstanceId)); + InventoryGenerationRandomContext randomContext = + new(runSeed, sequenceIndex, InventoryTagSourceType.Shop, i); + goodsItems.Add(BuildGoodsItem(i, randomContext)); } return goodsItems; @@ -51,18 +53,17 @@ namespace GeometryTD.CustomComponent private GoodsItemRawData BuildGoodsItem( int goodsIndex, - int runSeed, - int sequenceIndex, - Func allocateInstanceId) + InventoryGenerationRandomContext randomContext) { - TowerCompItemData sourceItem = BuildRandomComponentItem(goodsIndex, runSeed, sequenceIndex, allocateInstanceId); + Random random = randomContext.CreateRandom(); + TowerCompItemData sourceItem = BuildRandomComponentItem(randomContext, random); return new GoodsItemRawData { GoodsIndex = goodsIndex, Title = sourceItem.Name, TypeText = BuildTypeText(sourceItem.SlotType), Description = BuildDescription(sourceItem), - Price = ResolveRandomPrice(sourceItem.Rarity), + Price = ResolveRandomPrice(sourceItem.Rarity, random), Tags = sourceItem.Tags != null ? (TagType[])sourceItem.Tags.Clone() : Array.Empty(), IconAreaContext = BuildIconAreaContext(sourceItem), SourceItem = sourceItem, @@ -71,70 +72,56 @@ namespace GeometryTD.CustomComponent } private TowerCompItemData BuildRandomComponentItem( - int goodsIndex, - int runSeed, - int sequenceIndex, - Func allocateInstanceId) + InventoryGenerationRandomContext randomContext, + Random random) { - int slotRoll = UnityEngine.Random.Range(0, 3); - DRShopPrice priceRow = _shopPriceRows[UnityEngine.Random.Range(0, _shopPriceRows.Count)]; + int slotRoll = random.Next(0, 3); + DRShopPrice priceRow = _shopPriceRows[random.Next(0, _shopPriceRows.Count)]; RarityType rarity = InventoryRarityRuleService.NormalizeComponentRarity( priceRow != null ? priceRow.Rarity : RarityType.White); return slotRoll switch { - 0 => BuildRandomMuzzleItem(rarity, goodsIndex, runSeed, sequenceIndex, allocateInstanceId), - 1 => BuildRandomBearingItem(rarity, goodsIndex, runSeed, sequenceIndex, allocateInstanceId), - _ => BuildRandomBaseItem(rarity, goodsIndex, runSeed, sequenceIndex, allocateInstanceId) + 0 => BuildRandomMuzzleItem(rarity, randomContext, random), + 1 => BuildRandomBearingItem(rarity, randomContext, random), + _ => BuildRandomBaseItem(rarity, randomContext, random) }; } private MuzzleCompItemData BuildRandomMuzzleItem( RarityType rarity, - int goodsIndex, - int runSeed, - int sequenceIndex, - Func allocateInstanceId) + InventoryGenerationRandomContext randomContext, + Random random) { DRMuzzleComp[] rows = _muzzleCompTable.GetAllDataRows(); - DRMuzzleComp config = rows[UnityEngine.Random.Range(0, rows.Length)]; - long instanceId = allocateInstanceId(); - InventoryTagRandomContext randomContext = - InventoryTagRandomContext.CreateShop(runSeed, sequenceIndex, goodsIndex, config.Id); - return ComponentItemFactory.CreateMuzzle(config, instanceId, rarity, randomContext); + DRMuzzleComp config = rows[random.Next(0, rows.Length)]; + long instanceId = randomContext.CreateStableItemInstanceId(); + return ComponentItemFactory.CreateMuzzle(config, instanceId, rarity, randomContext.CreateTagRandomContext(config.Id)); } private BearingCompItemData BuildRandomBearingItem( RarityType rarity, - int goodsIndex, - int runSeed, - int sequenceIndex, - Func allocateInstanceId) + InventoryGenerationRandomContext randomContext, + Random random) { DRBearingComp[] rows = _bearingCompTable.GetAllDataRows(); - DRBearingComp config = rows[UnityEngine.Random.Range(0, rows.Length)]; - long instanceId = allocateInstanceId(); - InventoryTagRandomContext randomContext = - InventoryTagRandomContext.CreateShop(runSeed, sequenceIndex, goodsIndex, config.Id); - return ComponentItemFactory.CreateBearing(config, instanceId, rarity, randomContext); + DRBearingComp config = rows[random.Next(0, rows.Length)]; + long instanceId = randomContext.CreateStableItemInstanceId(); + return ComponentItemFactory.CreateBearing(config, instanceId, rarity, randomContext.CreateTagRandomContext(config.Id)); } private BaseCompItemData BuildRandomBaseItem( RarityType rarity, - int goodsIndex, - int runSeed, - int sequenceIndex, - Func allocateInstanceId) + InventoryGenerationRandomContext randomContext, + Random random) { DRBaseComp[] rows = _baseCompTable.GetAllDataRows(); - DRBaseComp config = rows[UnityEngine.Random.Range(0, rows.Length)]; - long instanceId = allocateInstanceId(); - InventoryTagRandomContext randomContext = - InventoryTagRandomContext.CreateShop(runSeed, sequenceIndex, goodsIndex, config.Id); - return ComponentItemFactory.CreateBase(config, instanceId, rarity, randomContext); + DRBaseComp config = rows[random.Next(0, rows.Length)]; + long instanceId = randomContext.CreateStableItemInstanceId(); + return ComponentItemFactory.CreateBase(config, instanceId, rarity, randomContext.CreateTagRandomContext(config.Id)); } - private int ResolveRandomPrice(RarityType rarity) + private int ResolveRandomPrice(RarityType rarity, Random random) { for (int i = 0; i < _shopPriceRows.Count; i++) { @@ -143,7 +130,7 @@ namespace GeometryTD.CustomComponent { int min = Mathf.Max(0, row.MinPrice); int max = Mathf.Max(min, row.MaxPrice); - return UnityEngine.Random.Range(min, max + 1); + return random.Next(min, max + 1); } } diff --git a/Assets/GameMain/Scripts/CustomComponent/TagRegistry.meta b/Assets/GameMain/Scripts/CustomComponent/TagRegistry.meta new file mode 100644 index 0000000..493a071 --- /dev/null +++ b/Assets/GameMain/Scripts/CustomComponent/TagRegistry.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 523d63397a9c41fdaaf238bfb0e95357 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/CustomComponent/TagRegistry/TagRegistryComponent.cs b/Assets/GameMain/Scripts/CustomComponent/TagRegistry/TagRegistryComponent.cs new file mode 100644 index 0000000..c00780f --- /dev/null +++ b/Assets/GameMain/Scripts/CustomComponent/TagRegistry/TagRegistryComponent.cs @@ -0,0 +1,64 @@ +using System; +using GameFramework.DataTable; +using GeometryTD.DataTable; +using GeometryTD.Definition; +using UnityGameFramework.Runtime; + +namespace GeometryTD.CustomComponent +{ + public sealed class TagRegistryComponent : GameFrameworkComponent + { + private IDataTable _tagConfigTable; + private IDataTable _tagTable; + private IDataTable _rarityTagBudgetTable; + + public void OnInit() + { + ReloadAllFromLoadedTables(); + } + + public void ReloadAllFromLoadedTables() + { + ReloadTagDefinitionsAndGenerationFromLoadedTables(); + ReloadRarityTagBudgetFromLoadedTable(); + } + + public void ReloadTagDefinitionsAndGenerationFromLoadedTables() + { + EnsureTagDefinitionTables(); + DRTagConfig[] tagConfigRows = _tagConfigTable.GetAllDataRows(); + DRTag[] tagRows = _tagTable.GetAllDataRows(); + + TagGenerationRuleRegistry.LoadFromRows(tagRows); + TagDefinitionRegistry.ReloadFromRows(tagConfigRows, tagRows); + } + + public void ReloadRarityTagBudgetFromLoadedTable() + { + EnsureRarityTagBudgetTable(); + RarityTagBudgetRuleRegistry.LoadFromRows(_rarityTagBudgetTable.GetAllDataRows()); + } + + private void EnsureTagDefinitionTables() + { + _tagConfigTable ??= GameEntry.DataTable.GetDataTable(); + _tagTable ??= GameEntry.DataTable.GetDataTable(); + + if (_tagConfigTable == null || _tagTable == null) + { + throw new InvalidOperationException( + "TagRegistryComponent requires TagConfig and Tag data tables to be loaded before initialization."); + } + } + + private void EnsureRarityTagBudgetTable() + { + _rarityTagBudgetTable ??= GameEntry.DataTable.GetDataTable(); + if (_rarityTagBudgetTable == null) + { + throw new InvalidOperationException( + "TagRegistryComponent requires RarityTagBudget data table to be loaded before initialization."); + } + } + } +} diff --git a/Assets/GameMain/Scripts/CustomComponent/TagRegistry/TagRegistryComponent.cs.meta b/Assets/GameMain/Scripts/CustomComponent/TagRegistry/TagRegistryComponent.cs.meta new file mode 100644 index 0000000..44309fb --- /dev/null +++ b/Assets/GameMain/Scripts/CustomComponent/TagRegistry/TagRegistryComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a268c57d4373494ab9b4d7167099f170 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Factory/OutGameDropItemBuilder.cs b/Assets/GameMain/Scripts/Factory/OutGameDropItemBuilder.cs new file mode 100644 index 0000000..0a5c30a --- /dev/null +++ b/Assets/GameMain/Scripts/Factory/OutGameDropItemBuilder.cs @@ -0,0 +1,88 @@ +using System; +using GameFramework.DataTable; +using GeometryTD.DataTable; +using GeometryTD.Definition; + +namespace GeometryTD.Factory +{ + public sealed class OutGameDropItemBuilder + { + private readonly IDataTable _muzzleCompTable; + private readonly IDataTable _bearingCompTable; + private readonly IDataTable _baseCompTable; + + public OutGameDropItemBuilder( + IDataTable muzzleCompTable, + IDataTable bearingCompTable, + IDataTable baseCompTable) + { + _muzzleCompTable = muzzleCompTable; + _bearingCompTable = bearingCompTable; + _baseCompTable = baseCompTable; + } + + public bool TryBuildItem( + DROutGameDropPool row, + RarityType rarity, + InventoryGenerationRandomContext randomContext, + out TowerCompItemData droppedItem) + { + droppedItem = null; + if (row.ItemId <= 0 || string.IsNullOrWhiteSpace(row.ItemType)) + { + return false; + } + + string itemType = row.ItemType.Trim(); + if (itemType.Equals("MuzzleComp", StringComparison.OrdinalIgnoreCase)) + { + DRMuzzleComp config = _muzzleCompTable.GetDataRow(row.ItemId); + if (config == null) + { + return false; + } + + droppedItem = ComponentItemFactory.CreateMuzzle( + config, + randomContext.CreateStableItemInstanceId(), + rarity, + randomContext.CreateTagRandomContext(config.Id)); + return true; + } + + if (itemType.Equals("BearingComp", StringComparison.OrdinalIgnoreCase)) + { + DRBearingComp config = _bearingCompTable.GetDataRow(row.ItemId); + if (config == null) + { + return false; + } + + droppedItem = ComponentItemFactory.CreateBearing( + config, + randomContext.CreateStableItemInstanceId(), + rarity, + randomContext.CreateTagRandomContext(config.Id)); + return true; + } + + if (itemType.Equals("BaseComp", StringComparison.OrdinalIgnoreCase)) + { + DRBaseComp config = _baseCompTable.GetDataRow(row.ItemId); + if (config == null) + { + return false; + } + + droppedItem = ComponentItemFactory.CreateBase( + config, + randomContext.CreateStableItemInstanceId(), + rarity, + randomContext.CreateTagRandomContext(config.Id)); + return true; + } + + return false; + } + } +} diff --git a/Assets/GameMain/Scripts/Factory/OutGameDropItemBuilder.cs.meta b/Assets/GameMain/Scripts/Factory/OutGameDropItemBuilder.cs.meta new file mode 100644 index 0000000..8b63e53 --- /dev/null +++ b/Assets/GameMain/Scripts/Factory/OutGameDropItemBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c5915901f51c430db50725908113ae1a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Procedure/Base/ProcedurePreload.cs b/Assets/GameMain/Scripts/Procedure/Base/ProcedurePreload.cs index 7efa98b..050ff44 100644 --- a/Assets/GameMain/Scripts/Procedure/Base/ProcedurePreload.cs +++ b/Assets/GameMain/Scripts/Procedure/Base/ProcedurePreload.cs @@ -196,45 +196,6 @@ namespace GeometryTD.Procedure _loadedFlag[ne.DataTableAssetName] = true; Log.Info("Load data table '{0}' OK.", ne.DataTableAssetName); - - if (ne.DataTableAssetName == AssetUtility.GetDataTableAsset("TagConfig", false)) - { - ReloadTagRegistriesFromLoadedTables(); - } - - if (ne.DataTableAssetName == AssetUtility.GetDataTableAsset("Tag", false)) - { - ReloadTagRegistriesFromLoadedTables(); - } - - if (ne.DataTableAssetName == AssetUtility.GetDataTableAsset("RarityTagBudget", false)) - { - var tagBudgetTable = GameEntry.DataTable.GetDataTable(); - if (tagBudgetTable != null) - { - RarityTagBudgetRuleRegistry.LoadFromRows(tagBudgetTable.GetAllDataRows()); - } - } - } - - private static void ReloadTagRegistriesFromLoadedTables() - { - DRTagConfig[] tagConfigRows = null; - var tagConfigTable = GameEntry.DataTable.GetDataTable(); - if (tagConfigTable != null) - { - tagConfigRows = tagConfigTable.GetAllDataRows(); - } - - DRTag[] tagRows = null; - var tagTable = GameEntry.DataTable.GetDataTable(); - if (tagTable != null) - { - tagRows = tagTable.GetAllDataRows(); - TagGenerationRuleRegistry.LoadFromRows(tagRows); - } - - TagDefinitionRegistry.ReloadFromRows(tagConfigRows, tagRows); } private void OnLoadDataTableFailure(object sender, GameEventArgs e) diff --git a/Assets/GameMain/Scripts/Procedure/ProcedureMain/ProcedureMain.cs b/Assets/GameMain/Scripts/Procedure/ProcedureMain/ProcedureMain.cs index 58ec36e..5ae35dc 100644 --- a/Assets/GameMain/Scripts/Procedure/ProcedureMain/ProcedureMain.cs +++ b/Assets/GameMain/Scripts/Procedure/ProcedureMain/ProcedureMain.cs @@ -34,6 +34,7 @@ namespace GeometryTD.Procedure GameEntry.Event.Subscribe(NodeEnterEventArgs.EventId, OnNodeEnter); GameEntry.Event.Subscribe(NodeMapNodeEnterRequestedEventArgs.EventId, OnNodeMapNodeEnterRequested); + GameEntry.TagRegistry.OnInit(); GameEntry.EventNode.OnInit(); GameEntry.CombatNode.OnInit(LevelThemeType.Plain); GameEntry.ShopNode.OnInit(); diff --git a/Assets/GameMain/Scripts/UI/General/UseCase/RewardSelectFormUseCase.cs b/Assets/GameMain/Scripts/UI/General/UseCase/RewardSelectFormUseCase.cs index 8be124b..b08d3d7 100644 --- a/Assets/GameMain/Scripts/UI/General/UseCase/RewardSelectFormUseCase.cs +++ b/Assets/GameMain/Scripts/UI/General/UseCase/RewardSelectFormUseCase.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using GeometryTD.Definition; using UnityEngine; -using Random = UnityEngine.Random; namespace GeometryTD.UI { @@ -19,6 +18,7 @@ namespace GeometryTD.UI private bool _allowRefreshOnce = true; private bool _allowGiveUp = true; private bool _hasRefreshed; + private int _selectionOffset; private string _tipText = "Select one reward"; public void ConfigureRewardPool( @@ -49,6 +49,7 @@ namespace GeometryTD.UI _allowGiveUp = allowGiveUp; _tipText = string.IsNullOrWhiteSpace(tipText) ? "Select one reward" : tipText; _hasRefreshed = false; + _selectionOffset = 0; _currentModel = null; } @@ -80,6 +81,7 @@ namespace GeometryTD.UI _allowGiveUp = allowGiveUp; _tipText = string.IsNullOrWhiteSpace(tipText) ? "Select one reward" : tipText; _hasRefreshed = false; + _selectionOffset = 0; _currentModel = null; } @@ -115,6 +117,11 @@ namespace GeometryTD.UI } _hasRefreshed = true; + if (_rewardPool.Count > 0) + { + _selectionOffset = (_selectionOffset + _displayCount) % _rewardPool.Count; + } + _currentModel = BuildModel(); return _currentModel; } @@ -172,22 +179,11 @@ namespace GeometryTD.UI } int finalCount = Mathf.Clamp(_displayCount, 1, _rewardPool.Count); - int[] indexes = new int[_rewardPool.Count]; - for (int i = 0; i < indexes.Length; i++) - { - indexes[i] = i; - } - - for (int i = 0; i < finalCount; i++) - { - int randomIndex = Random.Range(i, indexes.Length); - (indexes[i], indexes[randomIndex]) = (indexes[randomIndex], indexes[i]); - } - RewardSelectItemRawData[] results = new RewardSelectItemRawData[finalCount]; for (int i = 0; i < finalCount; i++) { - RewardSelectItemRawData source = _rewardPool[indexes[i]]; + int sourceIndex = (_selectionOffset + i) % _rewardPool.Count; + RewardSelectItemRawData source = _rewardPool[sourceIndex]; results[i] = source; } diff --git a/Assets/Launcher.unity b/Assets/Launcher.unity index f63cbba..da0e66f 100644 --- a/Assets/Launcher.unity +++ b/Assets/Launcher.unity @@ -158,6 +158,7 @@ Transform: - {fileID: 1322505022} - {fileID: 1062564689} - {fileID: 1554147129} + - {fileID: 336799288} m_Father: {fileID: 1852670053} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &120093239 @@ -294,6 +295,50 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 0a0c1c547ca24c95819f5f62f0bd3ea3, type: 3} m_Name: m_EditorClassIdentifier: +--- !u!1 &336799287 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 336799288} + - component: {fileID: 336799289} + m_Layer: 0 + m_Name: TagRegistery + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &336799288 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 336799287} + serializedVersion: 2 + 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: 119167776} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &336799289 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 336799287} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a268c57d4373494ab9b4d7167099f170, type: 3} + m_Name: + m_EditorClassIdentifier: --- !u!1001 &343730742 PrefabInstance: m_ObjectHideFlags: 0 diff --git a/Assets/Tests/EditMode/InventoryGenerationStabilityTests.cs b/Assets/Tests/EditMode/InventoryGenerationStabilityTests.cs new file mode 100644 index 0000000..3e0c5c2 --- /dev/null +++ b/Assets/Tests/EditMode/InventoryGenerationStabilityTests.cs @@ -0,0 +1,437 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using GameFramework.DataTable; +using GeometryTD.CustomComponent; +using GeometryTD.DataTable; +using GeometryTD.Definition; +using GeometryTD.UI; +using NUnit.Framework; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace GeometryTD.Tests.EditMode +{ + public sealed class InventoryGenerationStabilityTests + { + private GameObject _gameObject; + private InventoryGenerationComponent _component; + + [SetUp] + public void SetUp() + { + TagDefinitionRegistry.ResetToDefaults(); + TagGenerationRuleRegistry.ResetToDefaults(); + RarityTagBudgetRuleRegistry.ResetToDefaults(); + + _gameObject = new GameObject("InventoryGenerationStabilityTests"); + _component = _gameObject.AddComponent(); + BindTables(_component); + } + + [TearDown] + public void TearDown() + { + TagDefinitionRegistry.ResetToDefaults(); + TagGenerationRuleRegistry.ResetToDefaults(); + RarityTagBudgetRuleRegistry.ResetToDefaults(); + + if (_gameObject != null) + { + Object.DestroyImmediate(_gameObject); + _gameObject = null; + _component = null; + } + } + + [Test] + public void BuildShopGoods_Is_Reproducible_For_Same_RunSeed_And_SequenceIndex() + { + string first = BuildShopSignature(runSeed: 1001, sequenceIndex: 3); + string second = BuildShopSignature(runSeed: 1001, sequenceIndex: 3); + + Assert.That(second, Is.EqualTo(first)); + } + + [Test] + public void BuildShopGoods_Distinguishes_Different_RunSeed() + { + HashSet signatures = new HashSet(); + for (int runSeed = 1001; runSeed < 1017; runSeed++) + { + signatures.Add(BuildShopSignature(runSeed, sequenceIndex: 3)); + } + + Assert.That(signatures.Count, Is.GreaterThan(1)); + } + + [Test] + public void ResolveEnemyDrop_Is_Reproducible_For_Same_RunSeed_And_SequenceIndex() + { + string first = BuildEnemyDropSignature(runSeed: 2001, sequenceIndex: 5); + string second = BuildEnemyDropSignature(runSeed: 2001, sequenceIndex: 5); + + Assert.That(second, Is.EqualTo(first)); + } + + [Test] + public void ResolveEnemyDrop_Distinguishes_Different_RunSeed() + { + HashSet signatures = new HashSet(); + for (int runSeed = 2001; runSeed < 2033; runSeed++) + { + signatures.Add(BuildEnemyDropSignature(runSeed, sequenceIndex: 5)); + } + + Assert.That(signatures.Count, Is.GreaterThan(1)); + } + + [Test] + public void BuildRewardCandidates_Is_Reproducible_For_Same_RunSeed_And_SequenceIndex() + { + string first = BuildRewardCandidateSignature(runSeed: 3001, sequenceIndex: 7); + string second = BuildRewardCandidateSignature(runSeed: 3001, sequenceIndex: 7); + + Assert.That(second, Is.EqualTo(first)); + } + + [Test] + public void BuildRewardCandidates_Distinguishes_Different_RunSeed() + { + HashSet signatures = new HashSet(); + for (int runSeed = 3001; runSeed < 3017; runSeed++) + { + signatures.Add(BuildRewardCandidateSignature(runSeed, sequenceIndex: 7)); + } + + Assert.That(signatures.Count, Is.GreaterThan(1)); + } + + [Test] + public void RewardSelectFormUseCase_Uses_Stable_Order_And_Deterministic_Refresh_Rotation() + { + RewardSelectFormUseCase useCase = new RewardSelectFormUseCase(); + useCase.ConfigureRewardPool( + new[] + { + CreateRewardRawData(1, "一号"), + CreateRewardRawData(2, "二号"), + CreateRewardRawData(3, "三号"), + CreateRewardRawData(4, "四号") + }, + displayCount: 3, + refreshCost: 0, + allowRefreshOnce: true, + allowGiveUp: false); + + RewardSelectFormRawData initialModel = useCase.CreateInitialModel(); + RewardSelectFormRawData refreshedModel = useCase.TryRefresh(); + + Assert.That(initialModel.RewardItems.Select(item => item.Title).ToArray(), Is.EqualTo(new[] { "一号", "二号", "三号" })); + Assert.That(refreshedModel.RewardItems.Select(item => item.Title).ToArray(), Is.EqualTo(new[] { "四号", "一号", "二号" })); + } + + private string BuildShopSignature(int runSeed, int sequenceIndex) + { + List goods = _component.BuildShopGoods(4, runSeed, sequenceIndex); + return string.Join("|", goods.Select(BuildGoodsSignaturePart)); + } + + private string BuildEnemyDropSignature(int runSeed, int sequenceIndex) + { + _component.ConfigureRunContext(runSeed, sequenceIndex); + EnemyDropResult result = _component.ResolveEnemyDrop(new EnemyDropContext(CreateEnemyRow(), 8, LevelThemeType.Plain)); + return BuildDropSignaturePart(result); + } + + private string BuildRewardCandidateSignature(int runSeed, int sequenceIndex) + { + _component.ConfigureRunContext(runSeed, sequenceIndex); + IReadOnlyList candidates = _component.BuildRewardCandidates(8, LevelThemeType.Plain, 3); + return string.Join("|", candidates.Select(BuildItemSignaturePart)); + } + + private static string BuildGoodsSignaturePart(GoodsItemRawData goods) + { + return $"{goods.GoodsIndex}:{goods.Price}:{BuildItemSignaturePart(goods.SourceItem)}"; + } + + private static string BuildDropSignaturePart(EnemyDropResult result) + { + return $"{result.Coin}:{result.Gold}:{BuildItemSignaturePart(result.LootItem)}"; + } + + private static string BuildItemSignaturePart(TowerCompItemData item) + { + if (item == null) + { + return "null"; + } + + 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 RewardSelectItemRawData CreateRewardRawData(long instanceId, string title) + { + return new RewardSelectItemRawData + { + Title = title, + SlotType = TowerCompSlotType.Muzzle, + SourceItem = new MuzzleCompItemData + { + InstanceId = instanceId, + Name = title, + } + }; + } + + private static void BindTables(InventoryGenerationComponent component) + { + SetPrivateField(component, "_shopPriceTable", new FakeDataTable( + CreateShopPriceRow(1, RarityType.Blue, 30, 35), + CreateShopPriceRow(2, RarityType.Purple, 60, 70))); + SetPrivateField(component, "_dropPoolTable", new FakeDataTable( + CreateDropPoolRow(1, "MuzzleComp", 1, "[50,40,30,10,0]"), + CreateDropPoolRow(2, "MuzzleComp", 2, "[10,30,50,20,0]"), + CreateDropPoolRow(3, "BearingComp", 1, "[20,40,20,10,0]"), + CreateDropPoolRow(4, "BaseComp", 1, "[20,20,40,10,0]"))); + 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]"))); + } + + 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 DRShopPrice CreateShopPriceRow(int id, RarityType rarity, int minPrice, int maxPrice) + { + DRShopPrice row = new DRShopPrice(); + Assert.That(row.ParseDataRow($"\t{id}\t\t{rarity}\t{minPrice}\t{maxPrice}", null), Is.True); + return row; + } + + private static DROutGameDropPool CreateDropPoolRow(int id, string itemType, int itemId, string weights) + { + DROutGameDropPool row = new DROutGameDropPool(); + Assert.That( + row.ParseDataRow($"\t{id}\t\tPlain\t{itemType}\t{itemId}\t{weights}\t[1,1,1,1,1]\t[99,99,99,99,99]", null), + Is.True); + return row; + } + + 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 static DREnemy CreateEnemyRow() + { + DREnemy row = new DREnemy(); + Assert.That(row.ParseDataRow("\t1\t\t1\t100\t1\t1\t3\t5\t1", 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/InventoryGenerationStabilityTests.cs.meta b/Assets/Tests/EditMode/InventoryGenerationStabilityTests.cs.meta new file mode 100644 index 0000000..1f9dcd9 --- /dev/null +++ b/Assets/Tests/EditMode/InventoryGenerationStabilityTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3236620385724df2a9146f077f7413ae +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/CodeX-TODO.md b/docs/CodeX-TODO.md index f82824c..fa16d5d 100644 --- a/docs/CodeX-TODO.md +++ b/docs/CodeX-TODO.md @@ -9,7 +9,7 @@ - 商店、敌人掉落、满血 3 选 1 候选都在各自生成组件实例,逻辑分散,后续改规则要改多处。 - `ShopFormUseCase`、旧 `EnemyDropResolver`、`CombatSettlementService` 都曾同时承担“流程 + 规则 + 数据组装”的混合职责。 -- `runSeed` 目前只稳定了 Tag 生成,没有稳定整个组件产出流程。 +- 重构前 `runSeed` 只稳定了 Tag 生成,没有稳定整个组件产出流程;当前已补齐到商店、掉落、奖励候选与奖励展示顺序。 - Tag 模块本身已经分成 `Generation / Aggregation / Combat / Metadata / Presentation`,但初始化入口还散在流程代码里。 ## 重构边界 @@ -30,7 +30,7 @@ - `TagDefinitionRegistry` - `TagGenerationRuleRegistry` - `RarityTagBudgetRuleRegistry` - - 替代当前散在 `ProcedurePreload` 里的 Tag 注册表装载逻辑。 + - 替代旧的 `ProcedurePreload` 直连刷新逻辑,并收口当前主流程初始化入口。 ### 不适合抽成 `GameFrameworkComponent` 的模块 @@ -54,6 +54,8 @@ - `Assets/GameMain/Scripts/Factory/ComponentItemFactory.cs` - 唯一负责“从配置行构造 `TowerCompItemData`”。 +- `Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationRandomContext.cs` + - 统一承载商店 / 掉落 / 奖励候选的随机合同。 - `Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/ShopGoodsBuilder.cs` - 只负责商店货物生成。 - `Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/DropPoolRoller.cs` @@ -101,7 +103,9 @@ - `ProcedurePreload` - 移除 Tag 注册表刷新细节。 - - 只保留“数据表加载完成后通知组件刷新”。 + - 只保留资源与数据表预加载职责。 +- `ProcedureMain` + - 进入主流程时主动调用 `GameEntry.TagRegistry.OnInit()`。 - `TagRegistryComponent` - 成为 Tag 相关表与注册表的唯一运行时入口。 @@ -157,9 +161,9 @@ | 状态 | ID | 任务 | 目标 | |-----|-------|----------------------------------|--------------------| -| [ ] | G4-01 | 新增 `TagRegistryComponent` | 建立 Tag 运行时入口 | -| [ ] | G4-02 | 挪出 `ProcedurePreload` 中 Tag 注册逻辑 | 流程代码不再直接维护 Tag 注册表 | -| [ ] | G4-03 | 对齐 Tag 加载时机与重载入口 | 初始化路径明确 | +| [x] | G4-01 | 新增 `TagRegistryComponent` | 建立 Tag 运行时入口 | +| [x] | G4-02 | 挪出 `ProcedurePreload` 中 Tag 注册逻辑 | 流程代码不再直接维护 Tag 注册表 | +| [x] | G4-03 | 对齐 Tag 加载时机与重载入口 | 初始化路径明确 | ### G4 验收标准 @@ -171,9 +175,9 @@ | 状态 | ID | 任务 | 目标 | |-----|-------|------------------------|---------------------| -| [ ] | G5-01 | 统一商店 / 掉落 / 奖励候选的随机上下文 | 全部产出链路使用一致合同 | -| [ ] | G5-02 | 补齐整体产出的 `runSeed` 稳定性 | 不只稳定 Tag,也稳定物品产出 | -| [ ] | G5-03 | 增加对应 EditMode 测试 | 同 Run 可复现,跨 Run 可区分 | +| [x] | G5-01 | 统一商店 / 掉落 / 奖励候选的随机上下文 | 全部产出链路使用一致合同 | +| [x] | G5-02 | 补齐整体产出的 `runSeed` 稳定性 | 不只稳定 Tag,也稳定物品产出 | +| [x] | G5-03 | 增加对应 EditMode 测试 | 同 Run 可复现,跨 Run 可区分 | ### G5 验收标准 @@ -188,6 +192,7 @@ - `Assets/GameMain/Scripts/UI/Shop/UseCase/ShopFormUseCase.cs` - `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSettlementService.cs` - `Assets/GameMain/Scripts/Procedure/Base/ProcedurePreload.cs` +- `Assets/GameMain/Scripts/Procedure/ProcedureMain/ProcedureMain.cs` - `Assets/GameMain/Scripts/Base/GameEntry.Custom.cs` - `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs` @@ -196,6 +201,7 @@ - `Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationComponent.cs` - `Assets/GameMain/Scripts/CustomComponent/TagRegistry/TagRegistryComponent.cs` - `Assets/GameMain/Scripts/Factory/ComponentItemFactory.cs` +- `Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationRandomContext.cs` - `Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/ShopGoodsBuilder.cs` - `Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/DropPoolRoller.cs` - `Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/RewardCandidateBuilder.cs` @@ -215,7 +221,7 @@ 2. 先替换商店货物生成。 3. 再替换战斗掉落与奖励候选生成。 4. 再拆战斗结算计算与提交。 -5. 最后收 `TagRegistryComponent` 与 `ProcedurePreload` 的 Tag 初始化入口。 +5. 最后收 `TagRegistryComponent` 与主流程中的 Tag 初始化入口。 ## 通过标准 @@ -223,3 +229,12 @@ - 掉落、商店、奖励候选不再各自维护相似逻辑。 - Tag 模块保留现有分层,不再继续和流程代码缠在一起。 - `GameFrameworkComponent` 只承接运行时入口,不承接纯规则实现。 + +## 当前结果 + +- `InventoryGenerationComponent` 已成为商店、战斗掉落、奖励候选的统一运行时入口。 +- 掉落概率规则与掉落物品构造已继续下沉到 `OutGameDropRuleService` 与 `OutGameDropItemBuilder`,`InventoryGenerationComponent` 保持入口编排职责。 +- `TagRegistryComponent` 已成为 Tag 运行时入口,并由 `ProcedureMain` 主动初始化。 +- `TagRegistryComponent` 已改为 fail-fast 初始化,缺少必要数据表时会直接暴露错误。 +- `InventoryGenerationRandomContext` 已统一商店、掉落、奖励候选的随机合同,并补齐稳定临时实例 Id。 +- `Assets/Tests/EditMode/InventoryGenerationStabilityTests.cs` 已覆盖 G5 的可复现性验收点。 diff --git a/docs/CombatNodeArchitecture.md b/docs/CombatNodeArchitecture.md index f9d9fc1..9f9761f 100644 --- a/docs/CombatNodeArchitecture.md +++ b/docs/CombatNodeArchitecture.md @@ -310,12 +310,30 @@ - 在内部编排: - `DropPoolRoller` - `RewardCandidateBuilder` - - `ComponentItemFactory` + - `OutGameDropRuleService` + - `OutGameDropItemBuilder` + - `InventoryGenerationRandomContext` 约束: - `CombatNode` 域不直接持有或复制组件产出规则。 - `CombatScheduler` 与结算状态链只调用统一入口,不直接访问掉落池滚动或组件实例构造细节。 -- `InventoryGenerationComponent` 负责组件实例生成、Tag 随机上下文以及 `runSeed/sequenceIndex` 相关上下文。 +- `InventoryGenerationComponent` 负责运行时入口、稳定临时实例 Id、Tag 随机上下文以及 `runSeed/sequenceIndex` 相关上下文。 +- 掉落是否产出组件由 `OutGameDropRuleService` 决定;掉落池行到组件实例的构造由 `OutGameDropItemBuilder` 决定。 + +### 4.5.x InventoryGenerationRandomContext + +文件:`Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationRandomContext.cs` + +目标职责: +- 统一承载组件产出链路的随机合同。 +- 统一派生: + - 商店 / 掉落 / 奖励候选的稳定随机流 + - 稳定临时组件 `InstanceId` + - `InventoryTagRandomContext` + +约束: +- `ShopGoodsBuilder`、`DropPoolRoller`、`RewardCandidateBuilder` 不再直接使用全局 `UnityEngine.Random`。 +- 同一 `runSeed + sequenceIndex + sourceType + localOrdinal` 下,应得到一致的物品本体与 Tag 结果。 ### 4.6 CombatSettlementCalculator diff --git a/docs/TagSystemDesign.md b/docs/TagSystemDesign.md index 2b04f4b..455e98f 100644 --- a/docs/TagSystemDesign.md +++ b/docs/TagSystemDesign.md @@ -1,6 +1,6 @@ # Tag System Design -最后更新:2026-03-12 +最后更新:2026-03-13 > 目标:这是 GeometryTD 当前 Tag 系统的唯一正式口径。 > 本文档只记录当前真实实现、当前边界与正式规则。 @@ -76,7 +76,9 @@ M1 已完成 Tag 最小闭环: - 当前负责 `MinRarity`、`Weight` - `Tag.txt + TagConfig.txt -> TagDefinitionRegistry.ReloadFromRows(...)` - 当前负责 `IsImplemented`、`TriggerPhase`、`Description` 与正式运行时参数 -- `ProcedurePreload` 在 `Tag` 或 `TagConfig` 任一表加载完成后,都会基于当前已加载的两张表组合重建定义层 +- `ProcedureMain -> GameEntry.TagRegistry.OnInit() -> TagRegistryComponent` +- 当前在主流程进入时统一基于已加载数据表重建 Tag 定义层与生成规则层 +- `TagRegistryComponent` 对 `TagConfig`、`Tag`、`RarityTagBudget` 三张表采用 fail-fast 依赖约束;缺表时直接暴露初始化错误,不再静默跳过 - `RarityTagBudget.txt -> DRRarityTagBudget -> RarityTagBudgetRuleRegistry` - 当前负责按品质读取 `MinCount / MaxCount` @@ -100,9 +102,9 @@ public sealed class TagRuntimeData 当前统一入口是 `ComponentTagGenerationService`,以下链路共用同一套规则: - `InventorySeedUtility` -- `ShopFormUseCase` -- `EnemyDropResolver` -- 结算奖励候选组件 +- `InventoryGenerationComponent.BuildShopGoods(...)` +- `InventoryGenerationComponent.ResolveEnemyDrop(...)` +- `InventoryGenerationComponent.BuildRewardCandidates(...)` 当前流程: @@ -124,7 +126,15 @@ Tag 随机结果的正式上下文为: - `ItemInstanceId` - `ConfigId` -当前运行时通过 `InventoryTagRandomContext` 统一承载上述字段。 +当前运行时通过 `InventoryGenerationRandomContext + InventoryTagRandomContext` 统一承载上述字段: + +- `InventoryGenerationRandomContext` + - 统一承载 `runSeed + nodeSequenceIndex + sourceType + localOrdinal` + - 统一派生产出链路自己的稳定随机流 + - 统一派生稳定的临时组件 `InstanceId` +- `InventoryTagRandomContext` + - 承载 Tag 生成所需的 `RunSeed / SourceType / ItemInstanceId / ConfigId` + - 由 `InventoryGenerationRandomContext` 进一步派生 各来源构造口径: