diff --git a/AGENTS.md b/AGENTS.md index 58bfdfb..cb1fddf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,34 +1,355 @@ -# Repository Guidelines +# Project Philosophy -## Project Structure & Module Organization +This project prioritizes **clarity, correctness, and maintainability** over defensive boilerplate or unnecessary abstraction. + +AI-generated code should: + +* follow existing architecture +* keep logic simple and explicit +* avoid hiding bugs +* avoid speculative abstractions + +If uncertain, prefer **simple direct code** over generalized frameworks. + +## Repository Guidelines + +### Project Structure & Module Organization - `Assets/` contains all Unity assets and code. Game-specific content lives under `Assets/GameMain/`. - `Assets/GameMain/Scripts/` holds gameplay code organized by domain (`Procedure/`, `Entity/`, `UI/`, `Scene/`, `Sound/`, `Utility/`). - `Assets/GameMain/Scenes/` stores Unity scenes (start from `Assets/Launcher.unity`). - `Assets/GameMain/Configs/` and `Assets/GameMain/DataTables/` store runtime configuration and data tables. - `StreamingAssets/` is for runtime-loaded files that must be preserved on build. - `docs/` contains design notes (see `docs/GameDesign.md`). -- `Êý¾Ý±í/` is a top-level data table workspace; keep it in sync with `Assets/GameMain/DataTables/` when exporting. +- `���ݱ�/` is a top-level data table workspace; keep it in sync with `Assets/GameMain/DataTables/` when exporting. -## Build, Test, and Development Commands +### Build, Test, and Development Commands - Open the project with Unity Hub and load `GeometryTD` as a Unity project. - Play locally via the Unity Editor Play button (no custom CLI runner is defined in this repo). - Build via Unity: `File > Build Settings...` then choose target and build. - IDE support: open `GeometryTD.sln` or `Assembly-CSharp.csproj` for C# navigation and tooling. -## Coding Style & Naming Conventions +### Coding Style & Naming Conventions - C# uses 4-space indentation and Allman braces (see `Assets/GameMain/Scripts/Procedure/ProcedureLaunch.cs`). - Types, methods, and public members use `PascalCase`; locals and parameters use `camelCase`. - Namespaces follow `GeometryTD.*` by feature area (example: `GeometryTD.Procedure`). - Keep Unity `.meta` files with their assets; do not delete or regenerate them manually. -## Testing Guidelines -- No project-specific tests are present yet. Unity¡¯s Test Runner is available if tests are added. -- If you add tests, place them under `Assets/` with `Editor` or `Runtime` assembly definition files and use `*Tests.cs` naming. +--- -## Commit & Pull Request Guidelines -- This repository does not include Git history in the workspace, so no commit convention can be inferred. -- Use clear, scoped commit messages (example: `Add tower targeting component`), and include a brief PR description plus screenshots for visual changes. +## Code Design Principles -## Configuration Tips -- Project settings live in `ProjectSettings/` and `Packages/`; review diffs carefully when they change. -- Avoid committing `Library/`, `Temp/`, or `Logs/` outputs; they are generated by Unity. +### 1. Prefer explicit logic over abstraction + +Do not introduce abstractions unless they are clearly justified. + +Avoid creating layers such as: + +``` +Manager +Service +Provider +Repository +Coordinator +Bridge +``` + +unless the architecture explicitly requires them. + +Simple gameplay logic should remain simple. + +Bad example: + +``` +EnemyService -> EnemyManager -> EnemyRepository +``` + +Good example: + +``` +EnemySpawner +Enemy +EnemyAI +``` + +--- + +### 2. Do not create abstractions for a single implementation + +Avoid introducing interfaces unless multiple implementations are expected. + +Bad: + +``` +IEnemyService +EnemyService +``` + +Good: + +``` +EnemySystem +``` + +Interfaces are only appropriate when: + +* multiple implementations are required +* plugin/mod systems are involved +* testing requires mocking +* architecture explicitly defines extension points + +--- + +### 3. Avoid speculative generalization + +Do not design systems for hypothetical future use. + +Implement only what the current feature requires. + +Avoid: + +* generic service layers +* premature plugin systems +* unnecessary configuration frameworks + +--- + +## Defensive Programming Policy + +### Boundary validation only + +Validation is appropriate only at **true system boundaries**, such as: + +* user input +* network input +* file/config loading +* inspector/external data +* public API boundaries + +Internal gameplay logic should assume that invariants are already satisfied. + +Do not add redundant validation inside internal code. + +--- + +### Do not hide errors + +Do not silently ignore invalid states. + +Avoid code such as: + +``` +if (enemy == null) + return; +``` + +or + +``` +if (config == null) + continue; +``` + +unless the behavior is intentionally designed. + +Unexpected states should be **visible**, not hidden. + +--- + +### Prefer fail-fast over silent failure + +If a condition should never happen in correct execution: + +Use assertions. + +Example: + +``` +Debug.Assert(config != null); +``` + +Do not convert invariant violations into no-op behavior. + +Bad: + +``` +if (config == null) + return; +``` + +Good: + +``` +Debug.Assert(config != null); +``` + +--- + +### Do not weaken required dependencies + +Required references must remain required. + +Do not turn required dependencies into optional ones by adding defensive checks. + +Bad: + +``` +if (enemy.Config == null) + return; +``` + +If a dependency is required by design, assume it exists. + +--- + +## Error Handling + +Do not introduce `try/catch` blocks unless there is a **clear recovery strategy**. + +Avoid: + +``` +try +{ + DoSomething(); +} +catch (Exception e) +{ + Debug.LogError(e); +} +``` + +Exceptions should generally propagate during development so bugs are visible. + +--- + +## Early Return Policy + +Early returns are acceptable for **normal control flow simplification**. + +However, do not use early returns to hide invariant violations or missing required state. + +Bad: + +``` +if (enemy == null) + return; +``` + +Good: + +``` +if (!enemy.IsAlive) + return; +``` + +--- + +## Logging + +Avoid excessive logging. + +Do not log inside per-frame loops unless necessary. + +Avoid logs like: + +``` +Debug.Log("Updating enemy"); +``` + +Use logging only when it provides meaningful debugging value. + +--- + +## Utility Classes + +Avoid creating generic utility classes such as: + +``` +GameUtils +CommonHelper +MathHelper +``` + +Prefer placing behavior in the domain object responsible for it. + +Example: + +Bad: + +``` +DamageHelper.CalculateDamage(...) +``` + +Good: + +``` +enemy.CalculateDamage(...) +``` + +--- + +## Data Structures + +Avoid large context objects with many loosely related fields. + +Bad: + +``` +EffectContext +{ + int value; + int count; + float rate; + object source; + object target; +} +``` + +Prefer explicit parameters or strongly typed structures. + +--- + +## Code Generation Rules for AI + +Before adding any of the following: + +* null check +* range check +* fallback default +* try/catch +* abstraction layer + +First determine: + +1. Is this a boundary input? +2. Is the value optional by design? +3. Is there a real recovery strategy? + +If the answer is **no**, do not add defensive code. + +--- + +## Preferred Coding Style + +Prefer: + +* simple classes +* explicit logic +* minimal indirection +* clear ownership of behavior + +Avoid: + +* unnecessary patterns +* speculative architecture +* defensive boilerplate + +--- + +## Guiding Principle + +Prefer: + +**clear failure over hidden corruption** + +A bug should surface near its source rather than being silently ignored. diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/EnemyDrop/EnemyDropResolver.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/EnemyDrop/EnemyDropResolver.cs index 3185563..5795946 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/EnemyDrop/EnemyDropResolver.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/EnemyDrop/EnemyDropResolver.cs @@ -17,17 +17,20 @@ namespace GeometryTD.CustomComponent private readonly List _eligibleDropPoolBuffer = new(); private readonly Dictionary _rarityRollWeightBuffer = new(); + private readonly Dictionary _tagMinRarityByTag = new(); private IDataTable _drOutGameDropPool; private IDataTable _drMuzzleComp; private IDataTable _drBearingComp; private IDataTable _drBaseComp; + private IDataTable _drTag; private long _nextDropItemInstanceId = 1; public void Reset() { _eligibleDropPoolBuffer.Clear(); _rarityRollWeightBuffer.Clear(); + _tagMinRarityByTag.Clear(); _nextDropItemInstanceId = 1; } @@ -304,6 +307,35 @@ namespace GeometryTD.CustomComponent return _drOutGameDropPool; } + private bool EnsureTagMinRarityLookup() + { + if (_tagMinRarityByTag.Count > 0) + { + return true; + } + + _drTag ??= GameEntry.DataTable.GetDataTable(); + if (_drTag == null) + { + return false; + } + + DRTag[] rows = _drTag.GetAllDataRows(); + for (int i = 0; i < rows.Length; i++) + { + DRTag row = rows[i]; + if (row == null || row.Id <= 0) + { + continue; + } + + TagType tagType = (TagType)row.Id; + _tagMinRarityByTag[tagType] = row.MinRarity; + } + + return _tagMinRarityByTag.Count > 0; + } + private bool TryBuildDropItem(DROutGameDropPool row, out TowerCompItemData droppedItem) { droppedItem = null; @@ -340,21 +372,34 @@ namespace GeometryTD.CustomComponent return false; } + if (!EnsureTagMinRarityLookup()) + { + return false; + } + DRMuzzleComp config = _drMuzzleComp.GetDataRow(row.ItemId); if (config == null) { return false; } + long instanceId = _nextDropItemInstanceId++; + RarityType rarity = InventoryRarityRuleService.NormalizeComponentRarity(row.Rarity); droppedItem = new MuzzleCompItemData { - InstanceId = _nextDropItemInstanceId++, + InstanceId = instanceId, ConfigId = config.Id, Name = config.Name, - Rarity = InventoryRarityRuleService.NormalizeComponentRarity(row.Rarity), + Rarity = rarity, Endurance = 100f, Constraint = config.Constraint, - Tags = config.PossibleTag != null ? (TagType[])config.PossibleTag.Clone() : Array.Empty(), + Tags = InventoryTagRuleService.ResolveComponentTags( + config.PossibleTag, + rarity, + InventoryTagSourceType.Drop, + instanceId, + config.Id, + _tagMinRarityByTag), AttackDamage = config.AttackDamage != null ? (int[])config.AttackDamage.Clone() : Array.Empty(), DamageRandomRate = config.DamageRandomRate, AttackMethodType = config.AttackMethodType @@ -371,21 +416,34 @@ namespace GeometryTD.CustomComponent return false; } + if (!EnsureTagMinRarityLookup()) + { + return false; + } + DRBearingComp config = _drBearingComp.GetDataRow(row.ItemId); if (config == null) { return false; } + long instanceId = _nextDropItemInstanceId++; + RarityType rarity = InventoryRarityRuleService.NormalizeComponentRarity(row.Rarity); droppedItem = new BearingCompItemData { - InstanceId = _nextDropItemInstanceId++, + InstanceId = instanceId, ConfigId = config.Id, Name = config.Name, - Rarity = InventoryRarityRuleService.NormalizeComponentRarity(row.Rarity), + Rarity = rarity, Endurance = 100f, Constraint = config.Constraint, - Tags = config.PossibleTag != null ? (TagType[])config.PossibleTag.Clone() : Array.Empty(), + Tags = InventoryTagRuleService.ResolveComponentTags( + config.PossibleTag, + rarity, + InventoryTagSourceType.Drop, + instanceId, + config.Id, + _tagMinRarityByTag), RotateSpeed = config.RotateSpeed != null ? (float[])config.RotateSpeed.Clone() : Array.Empty(), AttackRange = config.AttackRange != null ? (float[])config.AttackRange.Clone() : Array.Empty() }; @@ -401,21 +459,34 @@ namespace GeometryTD.CustomComponent return false; } + if (!EnsureTagMinRarityLookup()) + { + return false; + } + DRBaseComp config = _drBaseComp.GetDataRow(row.ItemId); if (config == null) { return false; } + long instanceId = _nextDropItemInstanceId++; + RarityType rarity = InventoryRarityRuleService.NormalizeComponentRarity(row.Rarity); droppedItem = new BaseCompItemData { - InstanceId = _nextDropItemInstanceId++, + InstanceId = instanceId, ConfigId = config.Id, Name = config.Name, - Rarity = InventoryRarityRuleService.NormalizeComponentRarity(row.Rarity), + Rarity = rarity, Endurance = 100f, Constraint = config.Constraint, - Tags = config.PossibleTag != null ? (TagType[])config.PossibleTag.Clone() : Array.Empty(), + Tags = InventoryTagRuleService.ResolveComponentTags( + config.PossibleTag, + rarity, + InventoryTagSourceType.Drop, + instanceId, + config.Id, + _tagMinRarityByTag), AttackSpeed = config.AttackSpeed != null ? (float[])config.AttackSpeed.Clone() : Array.Empty(), AttackPropertyType = config.AttackPropertyType }; diff --git a/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryTowerAssemblyService.cs b/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryTowerAssemblyService.cs index 4cee921..1e3d8f2 100644 --- a/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryTowerAssemblyService.cs +++ b/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryTowerAssemblyService.cs @@ -107,8 +107,12 @@ namespace GeometryTD.CustomComponent AttackSpeed = BuildLevelFloatArray(baseComp.AttackSpeed, baseComp.Rarity, baseConfig.AttackSpeedPerLevel), AttackMethodType = muzzleComp.AttackMethodType, AttackPropertyType = baseComp.AttackPropertyType, - Tags = MergeTags(muzzleComp.Tags, bearingComp.Tags, baseComp.Tags) + TagRuntimes = TowerTagAggregationService.AggregateTowerTags( + muzzleComp.Tags, + bearingComp.Tags, + baseComp.Tags) }; + stats.Tags = TowerTagAggregationService.FlattenUniqueTags(stats.TagRuntimes); return true; } @@ -159,31 +163,6 @@ namespace GeometryTD.CustomComponent return rarityBaseArray[rarityIndex]; } - private static TagType[] MergeTags(params TagType[][] sources) - { - HashSet uniqueTags = new HashSet(); - if (sources != null) - { - for (int i = 0; i < sources.Length; i++) - { - TagType[] tags = sources[i]; - if (tags == null) - { - continue; - } - - for (int j = 0; j < tags.Length; j++) - { - uniqueTags.Add(tags[j]); - } - } - } - - TagType[] mergedTags = new TagType[uniqueTags.Count]; - uniqueTags.CopyTo(mergedTags); - return mergedTags; - } - private IDataTable EnsureMuzzleTable() { _drMuzzleComp ??= GameEntry.DataTable.GetDataTable(); diff --git a/Assets/GameMain/Scripts/Definition/DataStruct/TagRuntimeData.cs b/Assets/GameMain/Scripts/Definition/DataStruct/TagRuntimeData.cs new file mode 100644 index 0000000..da34de2 --- /dev/null +++ b/Assets/GameMain/Scripts/Definition/DataStruct/TagRuntimeData.cs @@ -0,0 +1,11 @@ +using System; + +namespace GeometryTD.Definition +{ + [Serializable] + public sealed class TagRuntimeData + { + public TagType TagType { get; set; } + public int TotalStack { get; set; } + } +} diff --git a/Assets/GameMain/Scripts/Definition/DataStruct/TagRuntimeData.cs.meta b/Assets/GameMain/Scripts/Definition/DataStruct/TagRuntimeData.cs.meta new file mode 100644 index 0000000..3b76e3c --- /dev/null +++ b/Assets/GameMain/Scripts/Definition/DataStruct/TagRuntimeData.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 71784829e4844ae3acbd58fb4f88a1ed +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Definition/DataStruct/TowerItemData.cs b/Assets/GameMain/Scripts/Definition/DataStruct/TowerItemData.cs index ee19111..580be83 100644 --- a/Assets/GameMain/Scripts/Definition/DataStruct/TowerItemData.cs +++ b/Assets/GameMain/Scripts/Definition/DataStruct/TowerItemData.cs @@ -18,6 +18,7 @@ namespace GeometryTD.Definition public float[] AttackSpeed { get; set; } public AttackMethodType AttackMethodType { get; set; } public AttackPropertyType AttackPropertyType { get; set; } + public TagRuntimeData[] TagRuntimes { get; set; } public TagType[] Tags { get; set; } } @@ -68,4 +69,4 @@ namespace GeometryTD.Definition [JsonIgnore] public string ComposedIconKey { get; set; } } -} \ No newline at end of file +} diff --git a/Assets/GameMain/Scripts/Definition/Enum/InventoryTagSourceType.cs b/Assets/GameMain/Scripts/Definition/Enum/InventoryTagSourceType.cs new file mode 100644 index 0000000..1ac871d --- /dev/null +++ b/Assets/GameMain/Scripts/Definition/Enum/InventoryTagSourceType.cs @@ -0,0 +1,10 @@ +namespace GeometryTD.Definition +{ + public enum InventoryTagSourceType : byte + { + Seed = 1, + Shop = 2, + Drop = 3, + Reward = 4, + } +} diff --git a/Assets/GameMain/Scripts/Definition/Enum/InventoryTagSourceType.cs.meta b/Assets/GameMain/Scripts/Definition/Enum/InventoryTagSourceType.cs.meta new file mode 100644 index 0000000..a2c9b5e --- /dev/null +++ b/Assets/GameMain/Scripts/Definition/Enum/InventoryTagSourceType.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a31a31d8d96e4d659fb5be2a8f76836d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Definition/InventoryTagRuleService.cs b/Assets/GameMain/Scripts/Definition/InventoryTagRuleService.cs new file mode 100644 index 0000000..8dc4bba --- /dev/null +++ b/Assets/GameMain/Scripts/Definition/InventoryTagRuleService.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; + +namespace GeometryTD.Definition +{ + public static class InventoryTagRuleService + { + private static readonly IReadOnlyDictionary DefaultMinRarityByTag = + new Dictionary + { + { TagType.Fire, RarityType.White }, + { TagType.Ice, RarityType.White }, + { TagType.Crit, RarityType.White }, + { TagType.Shatter, RarityType.Green }, + { TagType.Inferno, RarityType.Purple }, + { TagType.AbsoluteZero, RarityType.Purple }, + { TagType.Execution, RarityType.Purple }, + }; + + private static readonly HashSet SupportedLaunchTags = new HashSet(DefaultMinRarityByTag.Keys); + + public static TagType[] ResolveComponentTags( + IReadOnlyList possibleTags, + RarityType rarity, + InventoryTagSourceType sourceType, + long itemInstanceId, + int configId, + IReadOnlyDictionary minRarityByTag = null) + { + TagType[] eligibleTags = GetEligibleTags(possibleTags, rarity, minRarityByTag); + if (eligibleTags.Length <= 0) + { + return Array.Empty(); + } + + Random random = new Random(BuildStableSeed(rarity, sourceType, itemInstanceId, configId)); + int tagBudget = ResolveTagBudget(rarity, random); + if (tagBudget <= 0) + { + return Array.Empty(); + } + + int finalCount = Math.Min(tagBudget, eligibleTags.Length); + List pool = new List(eligibleTags); + TagType[] result = new TagType[finalCount]; + for (int i = 0; i < finalCount; i++) + { + int index = random.Next(0, pool.Count); + result[i] = pool[index]; + pool.RemoveAt(index); + } + + return result; + } + + public static TagType[] GetEligibleTags( + IReadOnlyList possibleTags, + RarityType rarity, + IReadOnlyDictionary minRarityByTag = null) + { + if (possibleTags == null || possibleTags.Count <= 0) + { + return Array.Empty(); + } + + RarityType normalizedRarity = InventoryRarityRuleService.NormalizeComponentRarity(rarity); + IReadOnlyDictionary rarityLookup = minRarityByTag ?? DefaultMinRarityByTag; + HashSet uniqueTags = new HashSet(); + + for (int i = 0; i < possibleTags.Count; i++) + { + TagType tagType = possibleTags[i]; + if (!IsSupportedLaunchTag(tagType) || tagType == TagType.None || !Enum.IsDefined(typeof(TagType), tagType)) + { + continue; + } + + if (!TryGetMinRarity(tagType, rarityLookup, out RarityType minRarity)) + { + continue; + } + + if (normalizedRarity < InventoryRarityRuleService.NormalizeComponentRarity(minRarity)) + { + continue; + } + + uniqueTags.Add(tagType); + } + + if (uniqueTags.Count <= 0) + { + return Array.Empty(); + } + + TagType[] result = new TagType[uniqueTags.Count]; + uniqueTags.CopyTo(result); + Array.Sort(result); + return result; + } + + public static int ResolveTagBudget(RarityType rarity, Random random) + { + RarityType normalizedRarity = InventoryRarityRuleService.NormalizeComponentRarity(rarity); + random ??= new Random(0); + + switch (normalizedRarity) + { + case RarityType.White: + return random.Next(0, 2); + case RarityType.Green: + return 1; + case RarityType.Blue: + return random.Next(1, 3); + case RarityType.Purple: + return 2; + case RarityType.Red: + return random.Next(2, 4); + default: + return 0; + } + } + + private static bool IsSupportedLaunchTag(TagType tagType) + { + return SupportedLaunchTags.Contains(tagType); + } + + private static bool TryGetMinRarity( + TagType tagType, + IReadOnlyDictionary rarityLookup, + out RarityType minRarity) + { + if (rarityLookup != null && rarityLookup.TryGetValue(tagType, out minRarity)) + { + return true; + } + + if (DefaultMinRarityByTag.TryGetValue(tagType, out minRarity)) + { + return true; + } + + minRarity = RarityType.White; + return false; + } + + private static int BuildStableSeed( + RarityType rarity, + InventoryTagSourceType sourceType, + long itemInstanceId, + int configId) + { + unchecked + { + int seed = 17; + seed = seed * 31 + (int)InventoryRarityRuleService.NormalizeComponentRarity(rarity); + seed = seed * 31 + (int)sourceType; + seed = seed * 31 + configId; + seed = seed * 31 + (int)itemInstanceId; + seed = seed * 31 + (int)(itemInstanceId >> 32); + return seed; + } + } + } +} diff --git a/Assets/GameMain/Scripts/Definition/InventoryTagRuleService.cs.meta b/Assets/GameMain/Scripts/Definition/InventoryTagRuleService.cs.meta new file mode 100644 index 0000000..5f8b0ac --- /dev/null +++ b/Assets/GameMain/Scripts/Definition/InventoryTagRuleService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 56c9c8fd48d246a882bfae5f8c8c02e7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Definition/TowerTagAggregationService.cs b/Assets/GameMain/Scripts/Definition/TowerTagAggregationService.cs new file mode 100644 index 0000000..52bc8ab --- /dev/null +++ b/Assets/GameMain/Scripts/Definition/TowerTagAggregationService.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; + +namespace GeometryTD.Definition +{ + public static class TowerTagAggregationService + { + public static TagRuntimeData[] AggregateTowerTags(params TagType[][] componentTags) + { + Dictionary stackByTag = new Dictionary(); + if (componentTags != null) + { + for (int i = 0; i < componentTags.Length; i++) + { + TagType[] tags = componentTags[i]; + if (tags == null) + { + continue; + } + + for (int j = 0; j < tags.Length; j++) + { + TagType tagType = tags[j]; + if (tagType == TagType.None || !Enum.IsDefined(typeof(TagType), tagType)) + { + continue; + } + + if (stackByTag.TryGetValue(tagType, out int stack)) + { + stackByTag[tagType] = stack + 1; + } + else + { + stackByTag[tagType] = 1; + } + } + } + } + + if (stackByTag.Count <= 0) + { + return Array.Empty(); + } + + List runtimes = new List(stackByTag.Count); + foreach (KeyValuePair pair in stackByTag) + { + runtimes.Add(new TagRuntimeData + { + TagType = pair.Key, + TotalStack = Math.Max(1, pair.Value) + }); + } + + runtimes.Sort((left, right) => left.TagType.CompareTo(right.TagType)); + return runtimes.ToArray(); + } + + public static TagRuntimeData[] BuildRuntimeTagsFromUniqueTags(IReadOnlyList tags) + { + if (tags == null || tags.Count <= 0) + { + return Array.Empty(); + } + + HashSet uniqueTags = new HashSet(); + for (int i = 0; i < tags.Count; i++) + { + TagType tagType = tags[i]; + if (tagType == TagType.None || !Enum.IsDefined(typeof(TagType), tagType)) + { + continue; + } + + uniqueTags.Add(tagType); + } + + if (uniqueTags.Count <= 0) + { + return Array.Empty(); + } + + TagType[] orderedTags = new TagType[uniqueTags.Count]; + uniqueTags.CopyTo(orderedTags); + Array.Sort(orderedTags); + + TagRuntimeData[] runtimes = new TagRuntimeData[orderedTags.Length]; + for (int i = 0; i < orderedTags.Length; i++) + { + runtimes[i] = new TagRuntimeData + { + TagType = orderedTags[i], + TotalStack = 1 + }; + } + + return runtimes; + } + + public static TagType[] FlattenUniqueTags(IReadOnlyList tagRuntimes) + { + if (tagRuntimes == null || tagRuntimes.Count <= 0) + { + return Array.Empty(); + } + + List tags = new List(tagRuntimes.Count); + for (int i = 0; i < tagRuntimes.Count; i++) + { + TagRuntimeData runtime = tagRuntimes[i]; + if (runtime == null || runtime.TagType == TagType.None || !Enum.IsDefined(typeof(TagType), runtime.TagType)) + { + continue; + } + + if (runtime.TotalStack <= 0) + { + continue; + } + + tags.Add(runtime.TagType); + } + + if (tags.Count <= 0) + { + return Array.Empty(); + } + + tags.Sort(); + return tags.ToArray(); + } + } +} diff --git a/Assets/GameMain/Scripts/Definition/TowerTagAggregationService.cs.meta b/Assets/GameMain/Scripts/Definition/TowerTagAggregationService.cs.meta new file mode 100644 index 0000000..46ac831 --- /dev/null +++ b/Assets/GameMain/Scripts/Definition/TowerTagAggregationService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4a2b16218c9c4f90a90c12b975d9a5c6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/UI/Combat/Controller/CombatFinishFormController.cs b/Assets/GameMain/Scripts/UI/Combat/Controller/CombatFinishFormController.cs index f3c4e64..a7e0246 100644 --- a/Assets/GameMain/Scripts/UI/Combat/Controller/CombatFinishFormController.cs +++ b/Assets/GameMain/Scripts/UI/Combat/Controller/CombatFinishFormController.cs @@ -18,7 +18,7 @@ namespace GeometryTD.UI public string Title; public string TypeText; public string Description; - public TagType[] Tags; + public string[] TagTexts; } protected override UIFormType UIFormTypeId => UIFormType.CombatFinishForm; @@ -138,7 +138,7 @@ namespace GeometryTD.UI tower.Name, "Tower", ItemDescUtility.BuildTowerDesc(tower, muzzleMap, bearingMap, baseMap) ?? string.Empty, - tower.Stats != null ? tower.Stats.Tags : null); + TagDisplayUtility.BuildTowerTagTexts(tower.Stats)); } } @@ -166,7 +166,7 @@ namespace GeometryTD.UI item.Name, BuildComponentTypeText(item.SlotType), ItemDescUtility.BuildMuzzleDesc(item) ?? string.Empty, - item.Tags); + TagDisplayUtility.BuildTagTexts(item.Tags)); } } @@ -194,7 +194,7 @@ namespace GeometryTD.UI item.Name, BuildComponentTypeText(item.SlotType), ItemDescUtility.BuildBearingDesc(item) ?? string.Empty, - item.Tags); + TagDisplayUtility.BuildTagTexts(item.Tags)); } } @@ -222,7 +222,7 @@ namespace GeometryTD.UI item.Name, BuildComponentTypeText(item.SlotType), ItemDescUtility.BuildBaseDesc(item) ?? string.Empty, - item.Tags); + TagDisplayUtility.BuildTagTexts(item.Tags)); } } @@ -261,7 +261,7 @@ namespace GeometryTD.UI return Color.white; } - private void AddItemDescSeed(long itemId, string title, string typeText, string description, TagType[] tags) + private void AddItemDescSeed(long itemId, string title, string typeText, string description, string[] tagTexts) { if (itemId <= 0) { @@ -273,13 +273,13 @@ namespace GeometryTD.UI Title = string.IsNullOrWhiteSpace(title) ? $"Item {itemId}" : title, TypeText = typeText ?? string.Empty, Description = description ?? string.Empty, - Tags = CloneTags(tags) + TagTexts = CloneTagTexts(tagTexts) }; } - private static TagType[] CloneTags(TagType[] tags) + private static string[] CloneTagTexts(string[] tagTexts) { - return tags != null ? (TagType[])tags.Clone() : System.Array.Empty(); + return tagTexts != null ? (string[])tagTexts.Clone() : System.Array.Empty(); } private static string BuildComponentTypeText(TowerCompSlotType slotType) @@ -374,7 +374,7 @@ namespace GeometryTD.UI Description = seed.Description ?? string.Empty, Price = 0, TargetPos = args.TargetPos, - Tags = CloneTags(seed.Tags) + TagTexts = CloneTagTexts(seed.TagTexts) }); } @@ -405,4 +405,3 @@ namespace GeometryTD.UI - diff --git a/Assets/GameMain/Scripts/UI/Game/Controller/RepoFormController.ContextBuilder.cs b/Assets/GameMain/Scripts/UI/Game/Controller/RepoFormController.ContextBuilder.cs index 04a8f16..2d01dbd 100644 --- a/Assets/GameMain/Scripts/UI/Game/Controller/RepoFormController.ContextBuilder.cs +++ b/Assets/GameMain/Scripts/UI/Game/Controller/RepoFormController.ContextBuilder.cs @@ -43,7 +43,7 @@ namespace GeometryTD.UI tower.Name, "Tower", ItemDescUtility.BuildTowerDesc(tower, muzzleMap, bearingMap, baseMap) ?? string.Empty, - tower.Stats != null ? tower.Stats.Tags : null); + TagDisplayUtility.BuildTowerTagTexts(tower.Stats)); } } @@ -71,7 +71,7 @@ namespace GeometryTD.UI item.Name, BuildComponentTypeText(item.SlotType), ItemDescUtility.BuildMuzzleDesc(item) ?? string.Empty, - item.Tags); + TagDisplayUtility.BuildTagTexts(item.Tags)); } } @@ -99,7 +99,7 @@ namespace GeometryTD.UI item.Name, BuildComponentTypeText(item.SlotType), ItemDescUtility.BuildBearingDesc(item) ?? string.Empty, - item.Tags); + TagDisplayUtility.BuildTagTexts(item.Tags)); } } @@ -127,7 +127,7 @@ namespace GeometryTD.UI item.Name, BuildComponentTypeText(item.SlotType), ItemDescUtility.BuildBaseDesc(item) ?? string.Empty, - item.Tags); + TagDisplayUtility.BuildTagTexts(item.Tags)); } } @@ -169,7 +169,7 @@ namespace GeometryTD.UI _compAreaTowerIds.Add(itemContext.InstanceId); } - private void AddItemDescSeed(long itemId, string title, string typeText, string description, TagType[] tags) + private void AddItemDescSeed(long itemId, string title, string typeText, string description, string[] tagTexts) { if (itemId <= 0) { @@ -181,15 +181,10 @@ namespace GeometryTD.UI Title = string.IsNullOrWhiteSpace(title) ? $"Item {itemId}" : title, TypeText = typeText ?? string.Empty, Description = description ?? string.Empty, - Tags = CloneTags(tags) + TagTexts = tagTexts != null ? (string[])tagTexts.Clone() : Array.Empty() }; } - private static TagType[] CloneTags(TagType[] tags) - { - return tags != null ? (TagType[])tags.Clone() : Array.Empty(); - } - private static string BuildComponentTypeText(TowerCompSlotType slotType) { return slotType switch @@ -391,4 +386,3 @@ namespace GeometryTD.UI } } } - diff --git a/Assets/GameMain/Scripts/UI/Game/Controller/RepoFormController.cs b/Assets/GameMain/Scripts/UI/Game/Controller/RepoFormController.cs index 3288874..ad12763 100644 --- a/Assets/GameMain/Scripts/UI/Game/Controller/RepoFormController.cs +++ b/Assets/GameMain/Scripts/UI/Game/Controller/RepoFormController.cs @@ -22,7 +22,7 @@ namespace GeometryTD.UI public string Title; public string TypeText; public string Description; - public TagType[] Tags; + public string[] TagTexts; } protected override UIFormType UIFormTypeId => UIFormType.RepoForm; @@ -151,7 +151,7 @@ namespace GeometryTD.UI Description = seed.Description ?? string.Empty, Price = 0, TargetPos = args.TargetPos, - Tags = CloneTags(seed.Tags) + TagTexts = CloneTagTexts(seed.TagTexts) }); } @@ -301,5 +301,9 @@ namespace GeometryTD.UI } #endregion + private static string[] CloneTagTexts(string[] tagTexts) + { + return tagTexts != null ? (string[])tagTexts.Clone() : System.Array.Empty(); + } } } diff --git a/Assets/GameMain/Scripts/UI/General/Context/ItemDescFormContext.cs b/Assets/GameMain/Scripts/UI/General/Context/ItemDescFormContext.cs index 59540be..f3ce428 100644 --- a/Assets/GameMain/Scripts/UI/General/Context/ItemDescFormContext.cs +++ b/Assets/GameMain/Scripts/UI/General/Context/ItemDescFormContext.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; -using GeometryTD.Definition; using UnityEngine; namespace GeometryTD.UI @@ -11,6 +9,6 @@ namespace GeometryTD.UI public string Description; public int Price; public Vector3 TargetPos; - public List Tags; + public TagItemContext[] Tags; } -} \ No newline at end of file +} diff --git a/Assets/GameMain/Scripts/UI/General/Controller/ItemDescFormController.cs b/Assets/GameMain/Scripts/UI/General/Controller/ItemDescFormController.cs index 1e24931..696cdca 100644 --- a/Assets/GameMain/Scripts/UI/General/Controller/ItemDescFormController.cs +++ b/Assets/GameMain/Scripts/UI/General/Controller/ItemDescFormController.cs @@ -1,5 +1,5 @@ -using System.Collections.Generic; using GameFramework.Event; +using GeometryTD.CustomUtility; using GeometryTD.Definition; using UnityGameFramework.Runtime; @@ -35,18 +35,33 @@ namespace GeometryTD.UI Description = rawData.Description, Price = rawData.Price, TargetPos = rawData.TargetPos, - Tags = BuildTags(rawData.Tags) + Tags = BuildTags(rawData) }; } - private static List BuildTags(TagType[] tags) + private static TagItemContext[] BuildTags(ItemDescFormRawData rawData) { - if (tags == null || tags.Length <= 0) + string[] tagTexts = rawData?.TagTexts; + if ((tagTexts == null || tagTexts.Length <= 0) && rawData?.Tags != null) { - return new List(); + tagTexts = TagDisplayUtility.BuildTagTexts(rawData.Tags); } - return new List(tags); + if (tagTexts == null || tagTexts.Length <= 0) + { + return System.Array.Empty(); + } + + TagItemContext[] contexts = new TagItemContext[tagTexts.Length]; + for (int i = 0; i < tagTexts.Length; i++) + { + contexts[i] = new TagItemContext + { + TagName = tagTexts[i] ?? string.Empty + }; + } + + return contexts; } public int? OpenUI(ItemDescFormRawData rawData) diff --git a/Assets/GameMain/Scripts/UI/General/Controller/RewardSelectFormController.cs b/Assets/GameMain/Scripts/UI/General/Controller/RewardSelectFormController.cs index b04a66e..580452e 100644 --- a/Assets/GameMain/Scripts/UI/General/Controller/RewardSelectFormController.cs +++ b/Assets/GameMain/Scripts/UI/General/Controller/RewardSelectFormController.cs @@ -179,44 +179,24 @@ namespace GeometryTD.UI private static TagItemContext[] BuildTagContexts(TagType[] tags) { - if (tags == null || tags.Length <= 0) + string[] tagTexts = TagDisplayUtility.BuildTagTexts(tags); + if (tagTexts == null || tagTexts.Length <= 0) { return System.Array.Empty(); } - TagItemContext[] contexts = new TagItemContext[tags.Length]; - for (int i = 0; i < tags.Length; i++) + TagItemContext[] contexts = new TagItemContext[tagTexts.Length]; + for (int i = 0; i < tagTexts.Length; i++) { - TagType tagType = tags[i]; contexts[i] = new TagItemContext { - TagName = ResolveTagName(tagType) + TagName = tagTexts[i] }; } return contexts; } - private static string ResolveTagName(TagType tagType) - { - if (tagType == TagType.None) - { - return string.Empty; - } - - var tagTable = GameEntry.DataTable.GetDataTable(); - if (tagTable != null) - { - DRTag tagRow = tagTable.GetDataRow((int)tagType); - if (tagRow != null && !string.IsNullOrWhiteSpace(tagRow.Name)) - { - return tagRow.Name; - } - } - - return tagType.ToString(); - } - private void OnRewardSelected(object sender, GameEventArgs e) { if (!IsEventFromCurrentForm(sender) || !(e is RewardSelectItemSelectedEventArgs args)) diff --git a/Assets/GameMain/Scripts/UI/General/RawData/ItemDescFormRawData.cs b/Assets/GameMain/Scripts/UI/General/RawData/ItemDescFormRawData.cs index 8967c3f..707e63e 100644 --- a/Assets/GameMain/Scripts/UI/General/RawData/ItemDescFormRawData.cs +++ b/Assets/GameMain/Scripts/UI/General/RawData/ItemDescFormRawData.cs @@ -11,5 +11,6 @@ namespace GeometryTD.UI public int Price; public Vector3 TargetPos; public TagType[] Tags; + public string[] TagTexts; } } diff --git a/Assets/GameMain/Scripts/UI/General/View/ItemDescForm.cs b/Assets/GameMain/Scripts/UI/General/View/ItemDescForm.cs index 8a97578..83fbc7c 100644 --- a/Assets/GameMain/Scripts/UI/General/View/ItemDescForm.cs +++ b/Assets/GameMain/Scripts/UI/General/View/ItemDescForm.cs @@ -1,6 +1,4 @@ using System.Collections.Generic; -using GeometryTD.DataTable; -using GeometryTD.Definition; using TMPro; using UnityEngine; using UnityEngine.UI; @@ -249,11 +247,11 @@ namespace GeometryTD.UI return value >= min && value <= max; } - private void RefreshTags(List tags) + private void RefreshTags(TagItemContext[] tags) { ClearTags(); - if (_tagAreaParent == null || _tagItemPrefab == null || tags == null || tags.Count <= 0) + if (_tagAreaParent == null || _tagItemPrefab == null || tags == null || tags.Length <= 0) { return; } @@ -263,10 +261,10 @@ namespace GeometryTD.UI _tagItemPrefab.SetActive(false); } - for (int i = 0; i < tags.Count; i++) + for (int i = 0; i < tags.Length; i++) { - string tagName = ResolveTagName(tags[i]); - if (string.IsNullOrWhiteSpace(tagName)) + TagItemContext tagContext = tags[i]; + if (tagContext == null || string.IsNullOrWhiteSpace(tagContext.TagName)) { continue; } @@ -276,17 +274,14 @@ namespace GeometryTD.UI if (tagGo.TryGetComponent(out TagItem tagItem)) { - tagItem.OnInit(new TagItemContext - { - TagName = tagName - }); + tagItem.OnInit(tagContext); } else { TMP_Text tagText = tagGo.GetComponentInChildren(true); if (tagText != null) { - tagText.text = tagName; + tagText.text = tagContext.TagName; } } @@ -308,26 +303,6 @@ namespace GeometryTD.UI _runtimeTagItems.Clear(); } - private static string ResolveTagName(TagType tagType) - { - if (tagType == TagType.None) - { - return string.Empty; - } - - var tagTable = GameEntry.DataTable.GetDataTable(); - if (tagTable != null) - { - DRTag tagRow = tagTable.GetDataRow((int)tagType); - if (tagRow != null && !string.IsNullOrWhiteSpace(tagRow.Name)) - { - return tagRow.Name; - } - } - - return tagType.ToString(); - } - private void CloseSelf() { GameEntry.UI.CloseUIForm(this); diff --git a/Assets/GameMain/Scripts/UI/Shop/Controller/ShopFormController.cs b/Assets/GameMain/Scripts/UI/Shop/Controller/ShopFormController.cs index 72989e2..d02b4d2 100644 --- a/Assets/GameMain/Scripts/UI/Shop/Controller/ShopFormController.cs +++ b/Assets/GameMain/Scripts/UI/Shop/Controller/ShopFormController.cs @@ -1,6 +1,7 @@ using GeometryTD.CustomEvent; using GeometryTD.Definition; using GameFramework.Event; +using GeometryTD.CustomUtility; using UnityEngine; using UnityGameFramework.Runtime; @@ -165,18 +166,7 @@ namespace GeometryTD.UI private static string[] BuildTagTexts(TagType[] tags) { - if (tags == null || tags.Length <= 0) - { - return System.Array.Empty(); - } - - string[] results = new string[tags.Length]; - for (int i = 0; i < tags.Length; i++) - { - results[i] = tags[i].ToString(); - } - - return results; + return TagDisplayUtility.BuildTagTexts(tags); } } } diff --git a/Assets/GameMain/Scripts/UI/Shop/UseCase/ShopFormUseCase.cs b/Assets/GameMain/Scripts/UI/Shop/UseCase/ShopFormUseCase.cs index d59a891..701e05d 100644 --- a/Assets/GameMain/Scripts/UI/Shop/UseCase/ShopFormUseCase.cs +++ b/Assets/GameMain/Scripts/UI/Shop/UseCase/ShopFormUseCase.cs @@ -16,11 +16,13 @@ namespace GeometryTD.UI private readonly List _currentGoods = new List(GoodsCount); private readonly List _shopPriceRows = new List(); + private readonly Dictionary _tagMinRarityByTag = new Dictionary(); private IDataTable _shopPriceTable; private IDataTable _muzzleCompTable; private IDataTable _bearingCompTable; private IDataTable _baseCompTable; + private IDataTable _tagTable; public bool PrepareForOpen() { @@ -99,8 +101,9 @@ namespace GeometryTD.UI _muzzleCompTable ??= GameEntry.DataTable.GetDataTable(); _bearingCompTable ??= GameEntry.DataTable.GetDataTable(); _baseCompTable ??= GameEntry.DataTable.GetDataTable(); + _tagTable ??= GameEntry.DataTable.GetDataTable(); - if (_shopPriceTable == null || _muzzleCompTable == null || _bearingCompTable == null || _baseCompTable == null) + if (_shopPriceTable == null || _muzzleCompTable == null || _bearingCompTable == null || _baseCompTable == null || _tagTable == null) { Log.Warning("ShopFormUseCase.EnsureTables() failed. Missing required data tables."); return false; @@ -124,10 +127,13 @@ namespace GeometryTD.UI } } + EnsureTagMinRarityLookup(); + return _shopPriceRows.Count > 0 && _muzzleCompTable.Count > 0 && _bearingCompTable.Count > 0 && - _baseCompTable.Count > 0; + _baseCompTable.Count > 0 && + _tagMinRarityByTag.Count > 0; } private bool TryBuildRandomGoodsItem(int goodsIndex, out GoodsItemRawData goodsItem) @@ -176,15 +182,23 @@ namespace GeometryTD.UI { DRMuzzleComp[] rows = _muzzleCompTable.GetAllDataRows(); DRMuzzleComp config = rows[UnityEngine.Random.Range(0, rows.Length)]; + long instanceId = _nextTempInstanceId++; + RarityType normalizedRarity = InventoryRarityRuleService.NormalizeComponentRarity(rarity); return new MuzzleCompItemData { - InstanceId = _nextTempInstanceId++, + InstanceId = instanceId, ConfigId = config.Id, Name = config.Name, - Rarity = InventoryRarityRuleService.NormalizeComponentRarity(rarity), + Rarity = normalizedRarity, Endurance = 100f, Constraint = config.Constraint, - Tags = config.PossibleTag != null ? (TagType[])config.PossibleTag.Clone() : Array.Empty(), + Tags = InventoryTagRuleService.ResolveComponentTags( + config.PossibleTag, + normalizedRarity, + InventoryTagSourceType.Shop, + instanceId, + config.Id, + _tagMinRarityByTag), AttackDamage = config.AttackDamage != null ? (int[])config.AttackDamage.Clone() : Array.Empty(), DamageRandomRate = config.DamageRandomRate, AttackMethodType = config.AttackMethodType @@ -195,15 +209,23 @@ namespace GeometryTD.UI { DRBearingComp[] rows = _bearingCompTable.GetAllDataRows(); DRBearingComp config = rows[UnityEngine.Random.Range(0, rows.Length)]; + long instanceId = _nextTempInstanceId++; + RarityType normalizedRarity = InventoryRarityRuleService.NormalizeComponentRarity(rarity); return new BearingCompItemData { - InstanceId = _nextTempInstanceId++, + InstanceId = instanceId, ConfigId = config.Id, Name = config.Name, - Rarity = InventoryRarityRuleService.NormalizeComponentRarity(rarity), + Rarity = normalizedRarity, Endurance = 100f, Constraint = config.Constraint, - Tags = config.PossibleTag != null ? (TagType[])config.PossibleTag.Clone() : Array.Empty(), + Tags = InventoryTagRuleService.ResolveComponentTags( + config.PossibleTag, + normalizedRarity, + InventoryTagSourceType.Shop, + instanceId, + config.Id, + _tagMinRarityByTag), RotateSpeed = config.RotateSpeed != null ? (float[])config.RotateSpeed.Clone() : Array.Empty(), AttackRange = config.AttackRange != null ? (float[])config.AttackRange.Clone() : Array.Empty() }; @@ -213,20 +235,49 @@ namespace GeometryTD.UI { DRBaseComp[] rows = _baseCompTable.GetAllDataRows(); DRBaseComp config = rows[UnityEngine.Random.Range(0, rows.Length)]; + long instanceId = _nextTempInstanceId++; + RarityType normalizedRarity = InventoryRarityRuleService.NormalizeComponentRarity(rarity); return new BaseCompItemData { - InstanceId = _nextTempInstanceId++, + InstanceId = instanceId, ConfigId = config.Id, Name = config.Name, - Rarity = InventoryRarityRuleService.NormalizeComponentRarity(rarity), + Rarity = normalizedRarity, Endurance = 100f, Constraint = config.Constraint, - Tags = config.PossibleTag != null ? (TagType[])config.PossibleTag.Clone() : Array.Empty(), + Tags = InventoryTagRuleService.ResolveComponentTags( + config.PossibleTag, + normalizedRarity, + InventoryTagSourceType.Shop, + instanceId, + config.Id, + _tagMinRarityByTag), AttackSpeed = config.AttackSpeed != null ? (float[])config.AttackSpeed.Clone() : Array.Empty(), AttackPropertyType = config.AttackPropertyType }; } + private void EnsureTagMinRarityLookup() + { + if (_tagMinRarityByTag.Count > 0 || _tagTable == null) + { + return; + } + + DRTag[] rows = _tagTable.GetAllDataRows(); + for (int i = 0; i < rows.Length; i++) + { + DRTag row = rows[i]; + if (row == null || row.Id <= 0) + { + continue; + } + + TagType tagType = (TagType)row.Id; + _tagMinRarityByTag[tagType] = row.MinRarity; + } + } + private int ResolveRandomPrice(RarityType rarity) { for (int i = 0; i < _shopPriceRows.Count; i++) diff --git a/Assets/GameMain/Scripts/Utility/InventoryCloneUtility.cs b/Assets/GameMain/Scripts/Utility/InventoryCloneUtility.cs index a281889..1cb124f 100644 --- a/Assets/GameMain/Scripts/Utility/InventoryCloneUtility.cs +++ b/Assets/GameMain/Scripts/Utility/InventoryCloneUtility.cs @@ -184,6 +184,7 @@ namespace GeometryTD.CustomUtility AttackSpeed = CloneFloatArray(source.AttackSpeed), AttackMethodType = source.AttackMethodType, AttackPropertyType = source.AttackPropertyType, + TagRuntimes = CloneTagRuntimes(source.TagRuntimes), Tags = CloneTags(source.Tags) }; } @@ -202,9 +203,34 @@ namespace GeometryTD.CustomUtility { return source != null ? (TagType[])source.Clone() : Array.Empty(); } + + public static TagRuntimeData[] CloneTagRuntimes(TagRuntimeData[] source) + { + if (source == null || source.Length <= 0) + { + return Array.Empty(); + } + + TagRuntimeData[] cloned = new TagRuntimeData[source.Length]; + for (int i = 0; i < source.Length; i++) + { + TagRuntimeData runtime = source[i]; + if (runtime == null) + { + continue; + } + + cloned[i] = new TagRuntimeData + { + TagType = runtime.TagType, + TotalStack = runtime.TotalStack + }; + } + + return cloned; + } } } - diff --git a/Assets/GameMain/Scripts/Utility/InventorySeedUtility.cs b/Assets/GameMain/Scripts/Utility/InventorySeedUtility.cs index 096a8a6..6c86ab1 100644 --- a/Assets/GameMain/Scripts/Utility/InventorySeedUtility.cs +++ b/Assets/GameMain/Scripts/Utility/InventorySeedUtility.cs @@ -4,6 +4,14 @@ namespace GeometryTD.CustomUtility { public static class InventorySeedUtility { + private static readonly TagType[] FirePool = { TagType.Fire }; + private static readonly TagType[] IceFreezePool = { TagType.Ice, TagType.FreezeMask }; + private static readonly TagType[] CritPiercePool = { TagType.Pierce, TagType.Crit }; + private static readonly TagType[] IceShatterPool = { TagType.Ice, TagType.Shatter }; + private static readonly TagType[] PierceOverpenetratePool = { TagType.Pierce, TagType.Overpenetrate }; + private static readonly TagType[] IceAbsoluteZeroPool = { TagType.Ice, TagType.AbsoluteZero }; + private static readonly TagType[] PierceExecutionPool = { TagType.Pierce, TagType.Execution }; + public static BackpackInventoryData CreateSampleInventory() { BackpackInventoryData inventory = new BackpackInventoryData @@ -23,7 +31,7 @@ namespace GeometryTD.CustomUtility DamageRandomRate = 0.05f, AttackMethodType = AttackMethodType.NormalBullet, Constraint = string.Empty, - Tags = new[] { TagType.Fire } + Tags = ResolveSeedTags(FirePool, RarityType.Green, 10001, 1) }; BearingCompItemData bearing = new BearingCompItemData @@ -37,7 +45,7 @@ namespace GeometryTD.CustomUtility RotateSpeed = new[] { 100f, 120f, 130f, 140f, 150f }, AttackRange = new[] { 3f, 4f, 5f, 6f, 8f }, Constraint = string.Empty, - Tags = new[] { TagType.Fire } + Tags = ResolveSeedTags(FirePool, RarityType.Green, 20001, 1) }; BaseCompItemData baseComp = new BaseCompItemData @@ -51,7 +59,7 @@ namespace GeometryTD.CustomUtility AttackSpeed = new[] { 1f, 2f, 3f, 3.5f, 0.7f }, AttackPropertyType = AttackPropertyType.Fire, Constraint = string.Empty, - Tags = new[] { TagType.Fire } + Tags = ResolveSeedTags(FirePool, RarityType.Green, 30001, 1) }; TowerItemData tower = new TowerItemData @@ -75,9 +83,13 @@ namespace GeometryTD.CustomUtility AttackSpeed = new[] { 1.0f, 1.2f, 1.3f, 1.4f, 0.5f }, AttackMethodType = AttackMethodType.NormalBullet, AttackPropertyType = AttackPropertyType.Fire, - Tags = new[] { TagType.Fire, TagType.BurnSpread } + TagRuntimes = TowerTagAggregationService.AggregateTowerTags( + muzzle.Tags, + bearing.Tags, + baseComp.Tags) } }; + tower.Stats.Tags = TowerTagAggregationService.FlattenUniqueTags(tower.Stats.TagRuntimes); inventory.MuzzleComponents.Add(muzzle); inventory.BaseComponents.Add(baseComp); @@ -96,7 +108,7 @@ namespace GeometryTD.CustomUtility DamageRandomRate = 0.01f, AttackMethodType = AttackMethodType.NormalBullet, Constraint = string.Empty, - Tags = new[] { TagType.Ice, TagType.FreezeMask } + Tags = ResolveSeedTags(IceFreezePool, RarityType.Blue, 10002, 2) }); inventory.MuzzleComponents.Add(new MuzzleCompItemData @@ -111,7 +123,7 @@ namespace GeometryTD.CustomUtility DamageRandomRate = 0.02f, AttackMethodType = AttackMethodType.NormalBullet, Constraint = string.Empty, - Tags = new[] { TagType.Pierce, TagType.Crit } + Tags = ResolveSeedTags(CritPiercePool, RarityType.Purple, 10003, 3) }); inventory.BearingComponents.Add(new BearingCompItemData @@ -125,7 +137,7 @@ namespace GeometryTD.CustomUtility RotateSpeed = new[] { 200f, 250f, 300f, 320f, 350f }, AttackRange = new[] { 6f, 6.5f, 7f, 8f, 8f }, Constraint = string.Empty, - Tags = new[] { TagType.Ice, TagType.Shatter } + Tags = ResolveSeedTags(IceShatterPool, RarityType.Blue, 20002, 2) }); inventory.BearingComponents.Add(new BearingCompItemData @@ -139,7 +151,7 @@ namespace GeometryTD.CustomUtility RotateSpeed = new[] { 60f, 70f, 80f, 90f, 100f }, AttackRange = new[] { 4f, 4.5f, 5f, 5.5f, 6f }, Constraint = string.Empty, - Tags = new[] { TagType.Pierce, TagType.Overpenetrate } + Tags = ResolveSeedTags(PierceOverpenetratePool, RarityType.Purple, 20003, 3) }); inventory.BaseComponents.Add(new BaseCompItemData @@ -153,7 +165,7 @@ namespace GeometryTD.CustomUtility AttackSpeed = new[] { 4f, 4.2f, 4.4f, 4.6f, 4.8f }, AttackPropertyType = AttackPropertyType.Ice, Constraint = string.Empty, - Tags = new[] { TagType.Ice, TagType.AbsoluteZero } + Tags = ResolveSeedTags(IceAbsoluteZeroPool, RarityType.Blue, 30002, 2) }); inventory.BaseComponents.Add(new BaseCompItemData @@ -167,12 +179,22 @@ namespace GeometryTD.CustomUtility AttackSpeed = new[] { 1f, 1f, 1f, 1f, 1f }, AttackPropertyType = AttackPropertyType.Physics, Constraint = string.Empty, - Tags = new[] { TagType.Pierce, TagType.Execution } + Tags = ResolveSeedTags(PierceExecutionPool, RarityType.Purple, 30003, 3) }); inventory.ParticipantTowerInstanceIds.Add(90001); return inventory; } + + private static TagType[] ResolveSeedTags(TagType[] possibleTags, RarityType rarity, long instanceId, int configId) + { + return InventoryTagRuleService.ResolveComponentTags( + possibleTags, + rarity, + InventoryTagSourceType.Seed, + instanceId, + configId); + } } } diff --git a/Assets/GameMain/Scripts/Utility/TagDisplayUtility.cs b/Assets/GameMain/Scripts/Utility/TagDisplayUtility.cs new file mode 100644 index 0000000..093efca --- /dev/null +++ b/Assets/GameMain/Scripts/Utility/TagDisplayUtility.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using GeometryTD.DataTable; +using GeometryTD.Definition; + +namespace GeometryTD.CustomUtility +{ + public static class TagDisplayUtility + { + public static string ResolveTagName(TagType tagType) + { + if (tagType == TagType.None) + { + return string.Empty; + } + + if (GameEntry.DataTable != null) + { + var tagTable = GameEntry.DataTable.GetDataTable(); + if (tagTable != null) + { + DRTag tagRow = tagTable.GetDataRow((int)tagType); + if (tagRow != null && !string.IsNullOrWhiteSpace(tagRow.Name)) + { + return tagRow.Name; + } + } + } + + return tagType.ToString(); + } + + public static string[] BuildTagTexts(IReadOnlyList tags) + { + if (tags == null || tags.Count <= 0) + { + return Array.Empty(); + } + + List results = new List(tags.Count); + for (int i = 0; i < tags.Count; i++) + { + string tagName = ResolveTagName(tags[i]); + if (!string.IsNullOrWhiteSpace(tagName)) + { + results.Add(tagName); + } + } + + return results.ToArray(); + } + + public static string[] BuildTagTexts(IReadOnlyList tagRuntimes) + { + if (tagRuntimes == null || tagRuntimes.Count <= 0) + { + return Array.Empty(); + } + + List results = new List(tagRuntimes.Count); + for (int i = 0; i < tagRuntimes.Count; i++) + { + TagRuntimeData tagRuntime = tagRuntimes[i]; + if (tagRuntime == null || tagRuntime.TagType == TagType.None || tagRuntime.TotalStack <= 0) + { + continue; + } + + string tagName = ResolveTagName(tagRuntime.TagType); + if (string.IsNullOrWhiteSpace(tagName)) + { + continue; + } + + results.Add(tagRuntime.TotalStack > 1 ? $"{tagName} x{tagRuntime.TotalStack}" : tagName); + } + + return results.ToArray(); + } + + public static string[] BuildTowerTagTexts(TowerStatsData towerStats) + { + if (towerStats == null) + { + return Array.Empty(); + } + + if (towerStats.TagRuntimes != null && towerStats.TagRuntimes.Length > 0) + { + return BuildTagTexts(towerStats.TagRuntimes); + } + + return BuildTagTexts(towerStats.Tags); + } + } +} diff --git a/Assets/GameMain/Scripts/Utility/TagDisplayUtility.cs.meta b/Assets/GameMain/Scripts/Utility/TagDisplayUtility.cs.meta new file mode 100644 index 0000000..60dd174 --- /dev/null +++ b/Assets/GameMain/Scripts/Utility/TagDisplayUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 93b93ce08ff34afea2cdf0cfe8f417f6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/InventoryTagRuleServiceTests.cs b/Assets/Tests/EditMode/InventoryTagRuleServiceTests.cs new file mode 100644 index 0000000..8ece44f --- /dev/null +++ b/Assets/Tests/EditMode/InventoryTagRuleServiceTests.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using GeometryTD.CustomUtility; +using GeometryTD.Definition; +using NUnit.Framework; + +namespace GeometryTD.Tests.EditMode +{ + public sealed class InventoryTagRuleServiceTests + { + private static readonly IReadOnlyDictionary MinRarityByTag = + new Dictionary + { + { TagType.Fire, RarityType.White }, + { TagType.Ice, RarityType.White }, + { TagType.Crit, RarityType.White }, + { TagType.Shatter, RarityType.Green }, + { TagType.Inferno, RarityType.Purple }, + { TagType.AbsoluteZero, RarityType.Purple }, + { TagType.Execution, RarityType.Purple }, + }; + + [Test] + public void GetEligibleTags_Filters_Invalid_Unsupported_And_HighRarity_Tags() + { + TagType[] result = InventoryTagRuleService.GetEligibleTags( + new[] + { + TagType.None, + TagType.Fire, + TagType.Fire, + TagType.BurnSpread, + TagType.Inferno, + (TagType)99 + }, + RarityType.Green, + MinRarityByTag); + + Assert.That(result, Is.EqualTo(new[] { TagType.Fire })); + } + + [Test] + public void ResolveComponentTags_Is_Deterministic_For_Same_Context() + { + TagType[] first = InventoryTagRuleService.ResolveComponentTags( + new[] { TagType.Fire, TagType.Ice, TagType.Crit, TagType.Shatter }, + RarityType.Blue, + InventoryTagSourceType.Shop, + 12345, + 7, + MinRarityByTag); + + TagType[] second = InventoryTagRuleService.ResolveComponentTags( + new[] { TagType.Fire, TagType.Ice, TagType.Crit, TagType.Shatter }, + RarityType.Blue, + InventoryTagSourceType.Shop, + 12345, + 7, + MinRarityByTag); + + Assert.That(second, Is.EqualTo(first)); + } + + [Test] + public void ResolveComponentTags_Uses_Purple_Budget_And_Does_Not_Repeat() + { + TagType[] result = InventoryTagRuleService.ResolveComponentTags( + new[] { TagType.Fire, TagType.Ice, TagType.Crit, TagType.Shatter, TagType.Inferno, TagType.Execution }, + RarityType.Purple, + InventoryTagSourceType.Drop, + 9001, + 4, + MinRarityByTag); + + Assert.That(result.Length, Is.EqualTo(2)); + Assert.That(new HashSet(result).Count, Is.EqualTo(result.Length)); + } + + [Test] + public void ResolveComponentTags_Falls_Back_When_Eligible_Count_Is_Lower_Than_Budget() + { + TagType[] result = InventoryTagRuleService.ResolveComponentTags( + new[] { TagType.Fire, TagType.BurnSpread }, + RarityType.Red, + InventoryTagSourceType.Seed, + 42, + 1, + MinRarityByTag); + + Assert.That(result, Is.EqualTo(new[] { TagType.Fire })); + } + + [Test] + public void CreateSampleInventory_Generates_SeedTags_Within_Launch_Set_And_Matches_Tower_Stats() + { + BackpackInventoryData inventory = InventorySeedUtility.CreateSampleInventory(); + TowerItemData tower = inventory.Towers[0]; + MuzzleCompItemData muzzle = inventory.MuzzleComponents[0]; + BearingCompItemData bearing = inventory.BearingComponents[0]; + BaseCompItemData baseComp = inventory.BaseComponents[0]; + + Assert.That(muzzle.Tags, Is.EqualTo(new[] { TagType.Fire })); + Assert.That(bearing.Tags, Is.EqualTo(new[] { TagType.Fire })); + Assert.That(baseComp.Tags, Is.EqualTo(new[] { TagType.Fire })); + Assert.That(tower.Stats.TagRuntimes, Has.Length.EqualTo(1)); + Assert.That(tower.Stats.TagRuntimes[0].TagType, Is.EqualTo(TagType.Fire)); + Assert.That(tower.Stats.TagRuntimes[0].TotalStack, Is.EqualTo(3)); + Assert.That(tower.Stats.Tags, Is.EqualTo(new[] { TagType.Fire })); + } + } +} diff --git a/Assets/Tests/EditMode/InventoryTagRuleServiceTests.cs.meta b/Assets/Tests/EditMode/InventoryTagRuleServiceTests.cs.meta new file mode 100644 index 0000000..571a263 --- /dev/null +++ b/Assets/Tests/EditMode/InventoryTagRuleServiceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9049175734024291804e223045c84b95 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/TowerTagAggregationServiceTests.cs b/Assets/Tests/EditMode/TowerTagAggregationServiceTests.cs new file mode 100644 index 0000000..a0cf837 --- /dev/null +++ b/Assets/Tests/EditMode/TowerTagAggregationServiceTests.cs @@ -0,0 +1,67 @@ +using GeometryTD.CustomUtility; +using GeometryTD.Definition; +using NUnit.Framework; + +namespace GeometryTD.Tests.EditMode +{ + public sealed class TowerTagAggregationServiceTests + { + [Test] + public void AggregateTowerTags_Returns_Stacked_Runtime_Result() + { + TagRuntimeData[] runtimes = TowerTagAggregationService.AggregateTowerTags( + new[] { TagType.Fire, TagType.Crit }, + new[] { TagType.Fire }, + new[] { TagType.Ice }); + + Assert.That(runtimes, Has.Length.EqualTo(3)); + Assert.That(runtimes[0].TagType, Is.EqualTo(TagType.Fire)); + Assert.That(runtimes[0].TotalStack, Is.EqualTo(2)); + Assert.That(runtimes[1].TagType, Is.EqualTo(TagType.Ice)); + Assert.That(runtimes[1].TotalStack, Is.EqualTo(1)); + Assert.That(runtimes[2].TagType, Is.EqualTo(TagType.Crit)); + Assert.That(runtimes[2].TotalStack, Is.EqualTo(1)); + } + + [Test] + public void AggregateTowerTags_Ignores_None_And_Invalid_Values() + { + TagRuntimeData[] runtimes = TowerTagAggregationService.AggregateTowerTags( + new[] { TagType.None, TagType.Fire, (TagType)99 }, + null, + new[] { TagType.Fire }); + + Assert.That(runtimes, Has.Length.EqualTo(1)); + Assert.That(runtimes[0].TagType, Is.EqualTo(TagType.Fire)); + Assert.That(runtimes[0].TotalStack, Is.EqualTo(2)); + } + + [Test] + public void FlattenUniqueTags_Returns_Stable_Unique_Tag_List() + { + TagType[] tags = TowerTagAggregationService.FlattenUniqueTags(new[] + { + new TagRuntimeData { TagType = TagType.Crit, TotalStack = 1 }, + new TagRuntimeData { TagType = TagType.Fire, TotalStack = 2 }, + new TagRuntimeData { TagType = TagType.None, TotalStack = 3 } + }); + + Assert.That(tags, Is.EqualTo(new[] { TagType.Fire, TagType.Crit })); + } + + [Test] + public void BuildTowerTagTexts_Uses_Runtime_Stacks_For_Display() + { + string[] texts = TagDisplayUtility.BuildTowerTagTexts(new TowerStatsData + { + TagRuntimes = new[] + { + new TagRuntimeData { TagType = TagType.Fire, TotalStack = 2 }, + new TagRuntimeData { TagType = TagType.Crit, TotalStack = 1 } + } + }); + + Assert.That(texts, Is.EqualTo(new[] { "Fire x2", "Crit" })); + } + } +} diff --git a/Assets/Tests/EditMode/TowerTagAggregationServiceTests.cs.meta b/Assets/Tests/EditMode/TowerTagAggregationServiceTests.cs.meta new file mode 100644 index 0000000..06af571 --- /dev/null +++ b/Assets/Tests/EditMode/TowerTagAggregationServiceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 11bb7495dfba4efba76782352043fef5 +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 bb1f255..57e5285 100644 --- a/docs/CodeX-TODO.md +++ b/docs/CodeX-TODO.md @@ -128,14 +128,18 @@ | [x] | S4-01 | 先确定 M1 需è¦çš„å“è´¨ / Tag 规则边界 | `docs/CodeX-TODO.md`
`docs/TODO.md` | 文档先对é½ï¼Œå†è½ä»£ç  | | [x] | S4-02 | 把å“è´¨è®¡ç®—æ•´ç†æˆå•ä¸€å…¥å£ | `Assets/GameMain/Scripts/Definition/`
`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/` | ç»„è£…ã€æŽ‰è½ã€å•†åº—使用一致结果 | | [x] | S4-03 | 先固化 Tag 系统设计与首å‘范围 | `docs/TagSystemDesign.md`
`docs/CodeX-TODO.md` | Tag çš„æ¥æºã€æ±‡æ€»ã€ç”Ÿæ•ˆä¸Žé¦–å‘集åˆå£å¾„固定 | -| [ ] | S4-04 | 实现组件实例 Tag 的统一生æˆå…¥å£ | `Assets/GameMain/Scripts/Definition/`
`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/` | 掉è½ã€å•†åº—ã€åˆå§‹ç§å­ã€äº‹ä»¶å¥–励共用åŒä¸€ç”Ÿæˆç»“æžœ | -| [ ] | S4-05 | 实现组塔åŽçš„ Tag æ±‡æ€»ä¸Žå±•ç¤ºå…¥å£ | `Assets/GameMain/Scripts/Definition/`
`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`
`Assets/GameMain/Scripts/UI/` | 组件 Tag 坿±‡æ€»ä¸ºå¡”级结果,且 UI 展示å£å¾„一致 | +| [x] | S4-04 | 实现组件实例 Tag 的统一生æˆå…¥å£ | `Assets/GameMain/Scripts/Definition/`
`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/` | 掉è½ã€å•†åº—ã€åˆå§‹ç§å­ã€äº‹ä»¶å¥–励共用åŒä¸€ç”Ÿæˆç»“æžœ | +| [x] | S4-05 | 实现组塔åŽçš„ Tag æ±‡æ€»ä¸Žå±•ç¤ºå…¥å£ | `Assets/GameMain/Scripts/Definition/`
`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`
`Assets/GameMain/Scripts/UI/` | 组件 Tag 坿±‡æ€»ä¸ºå¡”级结果,且 UI 展示å£å¾„一致 | | [ ] | S4-06 | 实现首批基础 Tag 的战斗生效 | `Assets/GameMain/Scripts/Entity/`
`Assets/GameMain/Scripts/Components/`
`Assets/GameMain/Scripts/Definition/` | é¦–å‘ 6~8 个基础 Tag 至少完æˆä¸€æ‰¹å¯éªŒè¯æ•ˆæžœ | | [ ] | S4-07 | è¡¥é½ Tag 规则与数æ®è¡¨çš„æ˜ å°„关系 | `Assets/GameMain/Scripts/DataTable/`
`Assets/GameMain/Scripts/Definition/` | è¡¨å­—æ®µä¸æ˜¯åªå­˜åœ¨è€Œæœªè¢«æ¶ˆè´¹ï¼ŒTag 傿•°å¯é…ç½®å¯è§£é‡Š | > 2026-03-09 更新:`InventoryRarityRuleService` å·²è½åœ°ï¼›å¡”å“质计算与组件å“质归一化已统一收å£ï¼Œ`PlayerInventoryTowerAssemblyService`ã€`ShopFormUseCase`ã€`EnemyDropResolver`ã€`InventorySeedUtility` 已接入åŒä¸€è§„则入å£ã€‚你已确认 Unity Test Runner 中 `Assets/Tests/EditMode` å…¨éƒ¨é€šè¿‡ï¼Œå…¶ä¸­åŒ…å«æ–°å¢žçš„ `InventoryRarityRuleServiceTests`。 > > 2026-03-09 更新:`S4-03` 文档å£å¾„已冻结;`TagSystemDesign.md` 已明确组件实例生æˆã€å¡”级 `Stack` 汇总ã€å››æ®µè§¦å‘阶段ã€`Tag.txt + TagRule` åŒè¡¨æ–¹å‘ï¼Œä»¥åŠ MVP æ­£å¼é¦–å‘ 7 个 Tag:`Fire`ã€`Ice`ã€`Crit`ã€`Execution`ã€`Shatter`ã€`Inferno`ã€`AbsoluteZero`。`BurnSpread` 已明确åŽç§»ã€‚ +> +> 2026-03-09 更新:`S4-04` å·²è½åœ° `InventoryTagRuleService` 与 `InventoryTagSourceType`;组件实例 Tag 现在统一按 `PossibleTag + Tag.txt.MinRarity + å“质预算` 生æˆï¼Œå¹¶åªä¿ç•™å½“剿­£å¼é¦–å‘ 7 个 Tag。`ShopFormUseCase`ã€`EnemyDropResolver`ã€`InventorySeedUtility` 已接入该入å£ï¼Œæ ·ä¾‹åº“存的组件与塔展示 Tag å·²åŒæ­¥ä¸ºç»Ÿä¸€ç»“æžœï¼›åŒæ—¶æ–°å¢ž `InventoryTagRuleServiceTests`ã€‚å½“å‰ CLI 下 `dotnet build GeometryTD.sln` ä»å› æœ¬æœºç¼ºå°‘ Unity 引用和 `Unity.SourceGenerators*.dll` 失败,未能替代 Unity Test Runner å®Œæˆæœ€ç»ˆéªŒè¯ã€‚ +> +> 2026-03-09 更新:`S4-05` 已新增 `TagRuntimeData` 与 `TowerTagAggregationService`;组塔与样例塔现在统一生æˆå¡”级 `TagRuntimes`,并ä¿ç•™å…¼å®¹ `Tags` 投影。`RepoForm`ã€`CombatFinishForm`ã€`ItemDescForm` 的塔展示已切到èšåˆç»“果,é‡å¤ Tag 以 `xN` æ–‡æœ¬æ˜¾ç¤ºï¼›ç»„ä»¶å±•ç¤ºä»æ²¿ç”¨ç»„件实例 `Tags`ã€‚åŒæ—¶æ–°å¢ž `TowerTagAggregationServiceTests`;本轮改动åŽçš„æœ€ç»ˆéªŒè¯ä»ä»¥ Unity Test Runner 实跑结果为准。 ### S4-01 边界结论