S6 回归与文档收尾
This commit is contained in:
parent
ac1c91c1b4
commit
09ebe6e3f3
|
|
@ -38,10 +38,10 @@ namespace GeometryTD.Definition
|
||||||
return new Dictionary<RarityType, RarityTagBudgetRule>
|
return new Dictionary<RarityType, RarityTagBudgetRule>
|
||||||
{
|
{
|
||||||
[RarityType.White] = CreateRule(RarityType.White, 0, 1),
|
[RarityType.White] = CreateRule(RarityType.White, 0, 1),
|
||||||
[RarityType.Green] = CreateRule(RarityType.Green, 1, 1),
|
[RarityType.Green] = CreateRule(RarityType.Green, 0, 2),
|
||||||
[RarityType.Blue] = CreateRule(RarityType.Blue, 1, 2),
|
[RarityType.Blue] = CreateRule(RarityType.Blue, 1, 3),
|
||||||
[RarityType.Purple] = CreateRule(RarityType.Purple, 2, 2),
|
[RarityType.Purple] = CreateRule(RarityType.Purple, 1, 3),
|
||||||
[RarityType.Red] = CreateRule(RarityType.Red, 2, 3)
|
[RarityType.Red] = CreateRule(RarityType.Red, 2, 4)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,10 @@ namespace GeometryTD.Definition
|
||||||
[TagType.BurnSpread] = CreateRule(TagType.BurnSpread, RarityType.White, 20),
|
[TagType.BurnSpread] = CreateRule(TagType.BurnSpread, RarityType.White, 20),
|
||||||
[TagType.IgniteBurst] = CreateRule(TagType.IgniteBurst, RarityType.Green, 15),
|
[TagType.IgniteBurst] = CreateRule(TagType.IgniteBurst, RarityType.Green, 15),
|
||||||
[TagType.Inferno] = CreateRule(TagType.Inferno, RarityType.Purple, 5),
|
[TagType.Inferno] = CreateRule(TagType.Inferno, RarityType.Purple, 5),
|
||||||
[TagType.Ice] = CreateRule(TagType.Ice, RarityType.White, 20),
|
[TagType.Ice] = CreateRule(TagType.Ice, RarityType.White, 1),
|
||||||
[TagType.FreezeMask] = CreateRule(TagType.FreezeMask, RarityType.White, 20),
|
[TagType.FreezeMask] = CreateRule(TagType.FreezeMask, RarityType.White, 20),
|
||||||
[TagType.Shatter] = CreateRule(TagType.Shatter, RarityType.Green, 15),
|
[TagType.Shatter] = CreateRule(TagType.Shatter, RarityType.Green, 15),
|
||||||
[TagType.AbsoluteZero] = CreateRule(TagType.AbsoluteZero, RarityType.Purple, 5),
|
[TagType.AbsoluteZero] = CreateRule(TagType.AbsoluteZero, RarityType.Purple, 1),
|
||||||
[TagType.Pierce] = CreateRule(TagType.Pierce, RarityType.White, 20),
|
[TagType.Pierce] = CreateRule(TagType.Pierce, RarityType.White, 20),
|
||||||
[TagType.Crit] = CreateRule(TagType.Crit, RarityType.White, 20),
|
[TagType.Crit] = CreateRule(TagType.Crit, RarityType.White, 20),
|
||||||
[TagType.Overpenetrate] = CreateRule(TagType.Overpenetrate, RarityType.Green, 15),
|
[TagType.Overpenetrate] = CreateRule(TagType.Overpenetrate, RarityType.Green, 15),
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,14 @@ namespace GeometryTD.CustomUtility
|
||||||
InstanceId = 10001,
|
InstanceId = 10001,
|
||||||
ConfigId = 1,
|
ConfigId = 1,
|
||||||
Name = "元素枪口",
|
Name = "元素枪口",
|
||||||
Rarity = InventoryRarityRuleService.NormalizeComponentRarity(RarityType.Green),
|
Rarity = InventoryRarityRuleService.NormalizeComponentRarity(RarityType.Blue),
|
||||||
Endurance = 90f,
|
Endurance = 90f,
|
||||||
IsAssembledIntoTower = true,
|
IsAssembledIntoTower = true,
|
||||||
AttackDamage = new[] { 200, 300, 400, 500, 800 },
|
AttackDamage = new[] { 200, 300, 400, 500, 800 },
|
||||||
DamageRandomRate = 0.05f,
|
DamageRandomRate = 0.05f,
|
||||||
AttackMethodType = AttackMethodType.NormalBullet,
|
AttackMethodType = AttackMethodType.NormalBullet,
|
||||||
Constraint = string.Empty,
|
Constraint = string.Empty,
|
||||||
Tags = ResolveSeedTags(FirePool, RarityType.Green, 10001, 1)
|
Tags = ResolveSeedTags(FirePool, RarityType.Blue, 10001, 1)
|
||||||
};
|
};
|
||||||
|
|
||||||
BearingCompItemData bearing = new BearingCompItemData
|
BearingCompItemData bearing = new BearingCompItemData
|
||||||
|
|
@ -39,13 +39,13 @@ namespace GeometryTD.CustomUtility
|
||||||
InstanceId = 20001,
|
InstanceId = 20001,
|
||||||
ConfigId = 1,
|
ConfigId = 1,
|
||||||
Name = "元素轴承",
|
Name = "元素轴承",
|
||||||
Rarity = InventoryRarityRuleService.NormalizeComponentRarity(RarityType.Green),
|
Rarity = InventoryRarityRuleService.NormalizeComponentRarity(RarityType.Blue),
|
||||||
Endurance = 1f,
|
Endurance = 1f,
|
||||||
IsAssembledIntoTower = true,
|
IsAssembledIntoTower = true,
|
||||||
RotateSpeed = new[] { 100f, 120f, 130f, 140f, 150f },
|
RotateSpeed = new[] { 100f, 120f, 130f, 140f, 150f },
|
||||||
AttackRange = new[] { 3f, 4f, 5f, 6f, 8f },
|
AttackRange = new[] { 3f, 4f, 5f, 6f, 8f },
|
||||||
Constraint = string.Empty,
|
Constraint = string.Empty,
|
||||||
Tags = ResolveSeedTags(FirePool, RarityType.Green, 20001, 1)
|
Tags = ResolveSeedTags(FirePool, RarityType.Blue, 20001, 1)
|
||||||
};
|
};
|
||||||
|
|
||||||
BaseCompItemData baseComp = new BaseCompItemData
|
BaseCompItemData baseComp = new BaseCompItemData
|
||||||
|
|
@ -53,13 +53,13 @@ namespace GeometryTD.CustomUtility
|
||||||
InstanceId = 30001,
|
InstanceId = 30001,
|
||||||
ConfigId = 1,
|
ConfigId = 1,
|
||||||
Name = "元素底座",
|
Name = "元素底座",
|
||||||
Rarity = InventoryRarityRuleService.NormalizeComponentRarity(RarityType.Green),
|
Rarity = InventoryRarityRuleService.NormalizeComponentRarity(RarityType.Blue),
|
||||||
Endurance = 88f,
|
Endurance = 88f,
|
||||||
IsAssembledIntoTower = true,
|
IsAssembledIntoTower = true,
|
||||||
AttackSpeed = new[] { 1f, 2f, 3f, 3.5f, 0.7f },
|
AttackSpeed = new[] { 1f, 2f, 3f, 3.5f, 0.7f },
|
||||||
AttackPropertyType = AttackPropertyType.Fire,
|
AttackPropertyType = AttackPropertyType.Fire,
|
||||||
Constraint = string.Empty,
|
Constraint = string.Empty,
|
||||||
Tags = ResolveSeedTags(FirePool, RarityType.Green, 30001, 1)
|
Tags = ResolveSeedTags(FirePool, RarityType.Blue, 30001, 1)
|
||||||
};
|
};
|
||||||
|
|
||||||
TowerItemData tower = new TowerItemData
|
TowerItemData tower = new TowerItemData
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using System.Reflection;
|
||||||
using GeometryTD.CustomComponent;
|
using GeometryTD.CustomComponent;
|
||||||
using GeometryTD.DataTable;
|
using GeometryTD.DataTable;
|
||||||
using GeometryTD.Definition;
|
using GeometryTD.Definition;
|
||||||
|
using GeometryTD.UI;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
|
|
@ -110,6 +111,198 @@ namespace GeometryTD.Tests.EditMode
|
||||||
Is.EqualTo(CombatParticipantTowerValidationFailureReason.BrokenMuzzleComponent));
|
Is.EqualTo(CombatParticipantTowerValidationFailureReason.BrokenMuzzleComponent));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void BuildSettlementContext_On_Full_BaseHp_Win_Adds_BonusGold_And_Opens_RewardSelection()
|
||||||
|
{
|
||||||
|
CreateBoundPlayerInventory(CreateInventory(100f, 100f, 100f));
|
||||||
|
CombatRunResourceStore resourceStore = new CombatRunResourceStore();
|
||||||
|
DRLevel level = CreateLevel(baseHp: 100, startCoin: 20, rewardGold: 30);
|
||||||
|
resourceStore.InitializeForCombat(level);
|
||||||
|
|
||||||
|
CombatSettlementContext settlementContext = new CombatSettlementService().BuildSettlementContext(
|
||||||
|
didCombatWin: true,
|
||||||
|
currentLevel: level,
|
||||||
|
defeatedEnemyCount: 5,
|
||||||
|
resourceStore: resourceStore);
|
||||||
|
|
||||||
|
Assert.That(settlementContext.Result.DidCombatWin, Is.True);
|
||||||
|
Assert.That(settlementContext.Result.FinalBaseHp, Is.EqualTo(100));
|
||||||
|
Assert.That(settlementContext.Result.MaxBaseHp, Is.EqualTo(100));
|
||||||
|
Assert.That(settlementContext.Result.GainedGold, Is.EqualTo(39));
|
||||||
|
Assert.That(settlementContext.Result.RewardInventory.Gold, Is.EqualTo(39));
|
||||||
|
Assert.That(settlementContext.Summary.GainedGold, Is.EqualTo(39));
|
||||||
|
Assert.That(settlementContext.Flags.ShouldOpenRewardSelection, Is.True);
|
||||||
|
Assert.That(settlementContext.Flags.DidEnterRewardSelection, Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void BuildSettlementContext_On_High_BaseHp_Win_Adds_SettlementGold_Without_RewardSelection()
|
||||||
|
{
|
||||||
|
CreateBoundPlayerInventory(CreateInventory(100f, 100f, 100f));
|
||||||
|
CombatRunResourceStore resourceStore = new CombatRunResourceStore();
|
||||||
|
DRLevel level = CreateLevel(baseHp: 100, startCoin: 20, rewardGold: 30);
|
||||||
|
resourceStore.InitializeForCombat(level);
|
||||||
|
SetCurrentBaseHp(resourceStore, 90);
|
||||||
|
|
||||||
|
CombatSettlementContext settlementContext = new CombatSettlementService().BuildSettlementContext(
|
||||||
|
didCombatWin: true,
|
||||||
|
currentLevel: level,
|
||||||
|
defeatedEnemyCount: 3,
|
||||||
|
resourceStore: resourceStore);
|
||||||
|
|
||||||
|
Assert.That(settlementContext.Result.FinalBaseHp, Is.EqualTo(90));
|
||||||
|
Assert.That(settlementContext.Result.GainedGold, Is.EqualTo(33));
|
||||||
|
Assert.That(settlementContext.Result.RewardInventory.Gold, Is.EqualTo(33));
|
||||||
|
Assert.That(settlementContext.Flags.ShouldOpenRewardSelection, Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void BuildSettlementContext_On_Loss_Keeps_DropGold_Without_SettlementBonus_Or_RewardSelection()
|
||||||
|
{
|
||||||
|
CreateBoundPlayerInventory(CreateInventory(100f, 100f, 100f));
|
||||||
|
CombatRunResourceStore resourceStore = new CombatRunResourceStore();
|
||||||
|
DRLevel level = CreateLevel(baseHp: 100, startCoin: 20, rewardGold: 30);
|
||||||
|
resourceStore.InitializeForCombat(level);
|
||||||
|
resourceStore.AddEnemyDefeatedReward(gainedCoin: 0, gainedGold: 7);
|
||||||
|
|
||||||
|
CombatSettlementContext settlementContext = new CombatSettlementService().BuildSettlementContext(
|
||||||
|
didCombatWin: false,
|
||||||
|
currentLevel: level,
|
||||||
|
defeatedEnemyCount: 3,
|
||||||
|
resourceStore: resourceStore);
|
||||||
|
|
||||||
|
Assert.That(settlementContext.Result.DidCombatWin, Is.False);
|
||||||
|
Assert.That(settlementContext.Result.GainedGold, Is.EqualTo(7));
|
||||||
|
Assert.That(settlementContext.Result.RewardInventory.Gold, Is.EqualTo(7));
|
||||||
|
Assert.That(settlementContext.Flags.ShouldOpenRewardSelection, Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TryPrepareRewardSelection_Returns_False_When_Required_Dependency_Is_Missing()
|
||||||
|
{
|
||||||
|
CreateBoundPlayerInventory(CreateInventory(100f, 100f, 100f));
|
||||||
|
CombatSettlementContext settlementContext = new CombatSettlementContext();
|
||||||
|
settlementContext.Flags.ShouldOpenRewardSelection = true;
|
||||||
|
|
||||||
|
bool prepared = new CombatSettlementService().TryPrepareRewardSelection(
|
||||||
|
settlementContext,
|
||||||
|
null,
|
||||||
|
displayPhaseIndex: 1,
|
||||||
|
themeType: LevelThemeType.Plain,
|
||||||
|
rewardSelectFormUseCase: new RewardSelectFormUseCase(),
|
||||||
|
onRewardSelected: _ => { },
|
||||||
|
onGiveUp: null);
|
||||||
|
|
||||||
|
Assert.That(prepared, Is.False);
|
||||||
|
Assert.That(settlementContext.Flags.ShouldOpenRewardSelection, Is.True);
|
||||||
|
Assert.That(settlementContext.Flags.DidEnterRewardSelection, Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ApplySelectedReward_Appends_Selected_Component_To_RewardInventory()
|
||||||
|
{
|
||||||
|
CombatSettlementContext settlementContext = new CombatSettlementContext();
|
||||||
|
settlementContext.Result.RewardInventory = new BackpackInventoryData();
|
||||||
|
MuzzleCompItemData reward = new MuzzleCompItemData
|
||||||
|
{
|
||||||
|
InstanceId = 70001,
|
||||||
|
Name = "奖励枪口"
|
||||||
|
};
|
||||||
|
|
||||||
|
new CombatSettlementService().ApplySelectedReward(
|
||||||
|
settlementContext,
|
||||||
|
new RewardSelectItemRawData
|
||||||
|
{
|
||||||
|
RewardId = reward.InstanceId,
|
||||||
|
SlotType = TowerCompSlotType.Muzzle,
|
||||||
|
SourceItem = reward
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.That(settlementContext.Result.RewardInventory.MuzzleComponents.Count, Is.EqualTo(1));
|
||||||
|
Assert.That(settlementContext.Result.RewardInventory.MuzzleComponents[0], Is.SameAs(reward));
|
||||||
|
Assert.That(settlementContext.Summary.RewardInventory, Is.SameAs(settlementContext.Result.RewardInventory));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void CommitSettlementInventory_Merges_Selected_Reward_Component_Into_PlayerInventory()
|
||||||
|
{
|
||||||
|
PlayerInventoryComponent inventoryComponent = CreateBoundPlayerInventory(CreateInventory(100f, 100f, 100f));
|
||||||
|
CombatRunResourceStore resourceStore = new CombatRunResourceStore();
|
||||||
|
DRLevel level = CreateLevel(baseHp: 100, startCoin: 20, rewardGold: 30);
|
||||||
|
resourceStore.InitializeForCombat(level);
|
||||||
|
int muzzleCountBeforeCommit = inventoryComponent.GetInventorySnapshot().MuzzleComponents.Count;
|
||||||
|
|
||||||
|
CombatSettlementService settlementService = new CombatSettlementService();
|
||||||
|
CombatSettlementContext settlementContext = settlementService.BuildSettlementContext(
|
||||||
|
didCombatWin: true,
|
||||||
|
currentLevel: level,
|
||||||
|
defeatedEnemyCount: 4,
|
||||||
|
resourceStore: resourceStore);
|
||||||
|
MuzzleCompItemData reward = new MuzzleCompItemData
|
||||||
|
{
|
||||||
|
InstanceId = 70002,
|
||||||
|
Name = "结算奖励枪口"
|
||||||
|
};
|
||||||
|
settlementService.ApplySelectedReward(
|
||||||
|
settlementContext,
|
||||||
|
new RewardSelectItemRawData
|
||||||
|
{
|
||||||
|
RewardId = reward.InstanceId,
|
||||||
|
SlotType = TowerCompSlotType.Muzzle,
|
||||||
|
SourceItem = reward
|
||||||
|
});
|
||||||
|
|
||||||
|
settlementService.CommitSettlementInventory(settlementContext);
|
||||||
|
|
||||||
|
BackpackInventoryData committedInventory = inventoryComponent.GetInventorySnapshot();
|
||||||
|
Assert.That(committedInventory.MuzzleComponents.Count, Is.EqualTo(muzzleCountBeforeCommit + 1));
|
||||||
|
MuzzleCompItemData mergedReward = committedInventory.MuzzleComponents[^1];
|
||||||
|
Assert.That(mergedReward.Name, Is.EqualTo("结算奖励枪口"));
|
||||||
|
Assert.That(mergedReward.SlotType, Is.EqualTo(TowerCompSlotType.Muzzle));
|
||||||
|
Assert.That(mergedReward.InstanceId, Is.Not.EqualTo(70002));
|
||||||
|
Assert.That(committedInventory.Gold, Is.EqualTo(39));
|
||||||
|
Assert.That(GetTowerComponents(committedInventory, 90001).Muzzle.Endurance, Is.EqualTo(99f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void CommitSettlementInventory_Is_Idempotent_After_First_Commit()
|
||||||
|
{
|
||||||
|
PlayerInventoryComponent inventoryComponent = CreateBoundPlayerInventory(CreateInventory(100f, 100f, 100f));
|
||||||
|
CombatRunResourceStore resourceStore = new CombatRunResourceStore();
|
||||||
|
DRLevel level = CreateLevel(baseHp: 100, startCoin: 20, rewardGold: 30);
|
||||||
|
resourceStore.InitializeForCombat(level);
|
||||||
|
|
||||||
|
CombatSettlementService settlementService = new CombatSettlementService();
|
||||||
|
CombatSettlementContext settlementContext = settlementService.BuildSettlementContext(
|
||||||
|
didCombatWin: true,
|
||||||
|
currentLevel: level,
|
||||||
|
defeatedEnemyCount: 2,
|
||||||
|
resourceStore: resourceStore);
|
||||||
|
|
||||||
|
settlementService.CommitSettlementInventory(settlementContext);
|
||||||
|
BackpackInventoryData firstCommittedInventory = inventoryComponent.GetInventorySnapshot();
|
||||||
|
float firstEndurance = GetTowerComponents(firstCommittedInventory, 90001).Muzzle.Endurance;
|
||||||
|
int firstAffectedTowerCount = settlementContext.Result.Endurance.AffectedTowerCount;
|
||||||
|
int firstGold = firstCommittedInventory.Gold;
|
||||||
|
|
||||||
|
settlementService.CommitSettlementInventory(settlementContext);
|
||||||
|
BackpackInventoryData secondCommittedInventory = inventoryComponent.GetInventorySnapshot();
|
||||||
|
|
||||||
|
Assert.That(settlementContext.Flags.IsCommitted, Is.True);
|
||||||
|
Assert.That(GetTowerComponents(secondCommittedInventory, 90001).Muzzle.Endurance, Is.EqualTo(firstEndurance));
|
||||||
|
Assert.That(settlementContext.Result.Endurance.AffectedTowerCount, Is.EqualTo(firstAffectedTowerCount));
|
||||||
|
Assert.That(secondCommittedInventory.Gold, Is.EqualTo(firstGold));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetCurrentBaseHp(CombatRunResourceStore resourceStore, int currentBaseHp)
|
||||||
|
{
|
||||||
|
FieldInfo backingField = typeof(CombatRunResourceStore).GetField(
|
||||||
|
"<CurrentBaseHp>k__BackingField",
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
Assert.That(backingField, Is.Not.Null);
|
||||||
|
backingField.SetValue(resourceStore, currentBaseHp);
|
||||||
|
}
|
||||||
|
|
||||||
private PlayerInventoryComponent CreateBoundPlayerInventory(BackpackInventoryData inventory)
|
private PlayerInventoryComponent CreateBoundPlayerInventory(BackpackInventoryData inventory)
|
||||||
{
|
{
|
||||||
_originalPlayerInventory = GameEntry.PlayerInventory;
|
_originalPlayerInventory = GameEntry.PlayerInventory;
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,20 @@ namespace GeometryTD.Tests.EditMode
|
||||||
TagGenerationRuleRegistry.ResetToDefaults();
|
TagGenerationRuleRegistry.ResetToDefaults();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TagGenerationRuleRegistry_Defaults_Align_With_Current_TagTable_Baseline()
|
||||||
|
{
|
||||||
|
TagGenerationRuleRegistry.ResetToDefaults();
|
||||||
|
|
||||||
|
Assert.That(TagGenerationRuleRegistry.TryGetRule(TagType.Ice, out TagGenerationRule iceRule), Is.True);
|
||||||
|
Assert.That(iceRule.MinRarity, Is.EqualTo(RarityType.White));
|
||||||
|
Assert.That(iceRule.Weight, Is.EqualTo(1));
|
||||||
|
|
||||||
|
Assert.That(TagGenerationRuleRegistry.TryGetRule(TagType.AbsoluteZero, out TagGenerationRule absoluteZeroRule), Is.True);
|
||||||
|
Assert.That(absoluteZeroRule.MinRarity, Is.EqualTo(RarityType.Purple));
|
||||||
|
Assert.That(absoluteZeroRule.Weight, Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void ResolveRarityTagBudget_Uses_TableDriven_Range_Rules()
|
public void ResolveRarityTagBudget_Uses_TableDriven_Range_Rules()
|
||||||
{
|
{
|
||||||
|
|
@ -238,6 +252,28 @@ namespace GeometryTD.Tests.EditMode
|
||||||
RarityTagBudgetRuleRegistry.ResetToDefaults();
|
RarityTagBudgetRuleRegistry.ResetToDefaults();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void RarityTagBudgetRuleRegistry_Defaults_Align_With_Current_BudgetTable_Baseline()
|
||||||
|
{
|
||||||
|
RarityTagBudgetRuleRegistry.ResetToDefaults();
|
||||||
|
|
||||||
|
Assert.That(RarityTagBudgetRuleRegistry.TryGetRule(RarityType.Green, out RarityTagBudgetRule greenRule), Is.True);
|
||||||
|
Assert.That(greenRule.MinCount, Is.EqualTo(0));
|
||||||
|
Assert.That(greenRule.MaxCount, Is.EqualTo(2));
|
||||||
|
|
||||||
|
Assert.That(RarityTagBudgetRuleRegistry.TryGetRule(RarityType.Blue, out RarityTagBudgetRule blueRule), Is.True);
|
||||||
|
Assert.That(blueRule.MinCount, Is.EqualTo(1));
|
||||||
|
Assert.That(blueRule.MaxCount, Is.EqualTo(3));
|
||||||
|
|
||||||
|
Assert.That(RarityTagBudgetRuleRegistry.TryGetRule(RarityType.Purple, out RarityTagBudgetRule purpleRule), Is.True);
|
||||||
|
Assert.That(purpleRule.MinCount, Is.EqualTo(1));
|
||||||
|
Assert.That(purpleRule.MaxCount, Is.EqualTo(3));
|
||||||
|
|
||||||
|
Assert.That(RarityTagBudgetRuleRegistry.TryGetRule(RarityType.Red, out RarityTagBudgetRule redRule), Is.True);
|
||||||
|
Assert.That(redRule.MinCount, Is.EqualTo(2));
|
||||||
|
Assert.That(redRule.MaxCount, Is.EqualTo(4));
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void CreateSampleInventory_Generates_SeedTags_Within_Launch_Set_And_Matches_Tower_Stats()
|
public void CreateSampleInventory_Generates_SeedTags_Within_Launch_Set_And_Matches_Tower_Stats()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,419 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Reflection;
|
||||||
|
using GameFramework.DataTable;
|
||||||
|
using GeometryTD.CustomComponent;
|
||||||
|
using GeometryTD.DataTable;
|
||||||
|
using GeometryTD.Definition;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace GeometryTD.Tests.EditMode
|
||||||
|
{
|
||||||
|
public sealed class PlayerInventoryTowerAssemblyServiceTests
|
||||||
|
{
|
||||||
|
private GameObject _inventoryObject;
|
||||||
|
private PlayerInventoryComponent _originalPlayerInventory;
|
||||||
|
|
||||||
|
[TearDown]
|
||||||
|
public void TearDown()
|
||||||
|
{
|
||||||
|
SetStaticPlayerInventory(_originalPlayerInventory);
|
||||||
|
if (_inventoryObject != null)
|
||||||
|
{
|
||||||
|
UnityEngine.Object.DestroyImmediate(_inventoryObject);
|
||||||
|
_inventoryObject = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TryAssembleTower_Builds_Expected_Rarity_Stats_And_Tags()
|
||||||
|
{
|
||||||
|
PlayerInventoryComponent inventoryComponent = CreateBoundPlayerInventory(new BackpackInventoryData
|
||||||
|
{
|
||||||
|
MuzzleComponents =
|
||||||
|
{
|
||||||
|
new MuzzleCompItemData
|
||||||
|
{
|
||||||
|
InstanceId = 10001,
|
||||||
|
ConfigId = 1,
|
||||||
|
Name = "测试枪口",
|
||||||
|
Rarity = RarityType.Green,
|
||||||
|
AttackDamage = new[] { 10, 20, 30, 40, 50 },
|
||||||
|
DamageRandomRate = 0.15f,
|
||||||
|
AttackMethodType = AttackMethodType.NormalBullet,
|
||||||
|
Tags = new[] { TagType.Fire }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
BearingComponents =
|
||||||
|
{
|
||||||
|
new BearingCompItemData
|
||||||
|
{
|
||||||
|
InstanceId = 20001,
|
||||||
|
ConfigId = 1,
|
||||||
|
Name = "测试轴承",
|
||||||
|
Rarity = RarityType.Blue,
|
||||||
|
RotateSpeed = new[] { 1f, 2f, 3f, 4f, 5f },
|
||||||
|
AttackRange = new[] { 10f, 20f, 30f, 40f, 50f },
|
||||||
|
Tags = new[] { TagType.Ice }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
BaseComponents =
|
||||||
|
{
|
||||||
|
new BaseCompItemData
|
||||||
|
{
|
||||||
|
InstanceId = 30001,
|
||||||
|
ConfigId = 1,
|
||||||
|
Name = "测试底座",
|
||||||
|
Rarity = RarityType.Purple,
|
||||||
|
AttackSpeed = new[] { 2f, 4f, 6f, 8f, 10f },
|
||||||
|
AttackPropertyType = AttackPropertyType.Fire,
|
||||||
|
Tags = new[] { TagType.Fire }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
InjectAssemblyTables(
|
||||||
|
inventoryComponent,
|
||||||
|
new FakeDataTable<DRMuzzleComp>(CreateMuzzleRow()),
|
||||||
|
new FakeDataTable<DRBearingComp>(CreateBearingRow()),
|
||||||
|
new FakeDataTable<DRBaseComp>(CreateBaseRow()));
|
||||||
|
|
||||||
|
bool assembled = inventoryComponent.TryAssembleTower(10001, 20001, 30001, out TowerItemData tower);
|
||||||
|
BackpackInventoryData committedInventory = inventoryComponent.GetInventorySnapshot();
|
||||||
|
|
||||||
|
Assert.That(assembled, Is.True);
|
||||||
|
Assert.That(tower, Is.Not.Null);
|
||||||
|
Assert.That(tower.Rarity, Is.EqualTo(RarityType.Blue));
|
||||||
|
Assert.That(tower.Stats.AttackDamage, Is.EqualTo(new[] { 20, 23, 26, 29, 32 }));
|
||||||
|
Assert.That(tower.Stats.RotateSpeed, Is.EqualTo(new[] { 3f, 3.5f, 4f, 4.5f, 5f }));
|
||||||
|
Assert.That(tower.Stats.AttackRange, Is.EqualTo(new[] { 30f, 31f, 32f, 33f, 34f }));
|
||||||
|
Assert.That(tower.Stats.AttackSpeed, Is.EqualTo(new[] { 8f, 7.75f, 7.5f, 7.25f, 7f }));
|
||||||
|
Assert.That(tower.Stats.DamageRandomRate, Is.EqualTo(0.15f));
|
||||||
|
Assert.That(tower.Stats.AttackMethodType, Is.EqualTo(AttackMethodType.NormalBullet));
|
||||||
|
Assert.That(tower.Stats.AttackPropertyType, Is.EqualTo(AttackPropertyType.Fire));
|
||||||
|
Assert.That(tower.Stats.Tags, Is.EqualTo(new[] { TagType.Fire, TagType.Ice }));
|
||||||
|
Assert.That(tower.Stats.TagRuntimes, Has.Length.EqualTo(2));
|
||||||
|
Assert.That(tower.Stats.TagRuntimes[0].TagType, Is.EqualTo(TagType.Fire));
|
||||||
|
Assert.That(tower.Stats.TagRuntimes[0].TotalStack, Is.EqualTo(2));
|
||||||
|
Assert.That(tower.Stats.TagRuntimes[1].TagType, Is.EqualTo(TagType.Ice));
|
||||||
|
Assert.That(tower.Stats.TagRuntimes[1].TotalStack, Is.EqualTo(1));
|
||||||
|
Assert.That(committedInventory.Towers, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(committedInventory.MuzzleComponents[0].IsAssembledIntoTower, Is.True);
|
||||||
|
Assert.That(committedInventory.BearingComponents[0].IsAssembledIntoTower, Is.True);
|
||||||
|
Assert.That(committedInventory.BaseComponents[0].IsAssembledIntoTower, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TryAssembleTower_Returns_False_When_Required_Config_Row_Is_Missing()
|
||||||
|
{
|
||||||
|
PlayerInventoryComponent inventoryComponent = CreateBoundPlayerInventory(new BackpackInventoryData
|
||||||
|
{
|
||||||
|
MuzzleComponents =
|
||||||
|
{
|
||||||
|
new MuzzleCompItemData
|
||||||
|
{
|
||||||
|
InstanceId = 10001,
|
||||||
|
ConfigId = 1,
|
||||||
|
Rarity = RarityType.White,
|
||||||
|
AttackDamage = new[] { 10, 20, 30, 40, 50 },
|
||||||
|
AttackMethodType = AttackMethodType.NormalBullet
|
||||||
|
}
|
||||||
|
},
|
||||||
|
BearingComponents =
|
||||||
|
{
|
||||||
|
new BearingCompItemData
|
||||||
|
{
|
||||||
|
InstanceId = 20001,
|
||||||
|
ConfigId = 1,
|
||||||
|
Rarity = RarityType.White,
|
||||||
|
RotateSpeed = new[] { 1f, 2f, 3f, 4f, 5f },
|
||||||
|
AttackRange = new[] { 1f, 2f, 3f, 4f, 5f }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
BaseComponents =
|
||||||
|
{
|
||||||
|
new BaseCompItemData
|
||||||
|
{
|
||||||
|
InstanceId = 30001,
|
||||||
|
ConfigId = 999,
|
||||||
|
Rarity = RarityType.White,
|
||||||
|
AttackSpeed = new[] { 1f, 2f, 3f, 4f, 5f },
|
||||||
|
AttackPropertyType = AttackPropertyType.Physics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
InjectAssemblyTables(
|
||||||
|
inventoryComponent,
|
||||||
|
new FakeDataTable<DRMuzzleComp>(CreateMuzzleRow()),
|
||||||
|
new FakeDataTable<DRBearingComp>(CreateBearingRow()),
|
||||||
|
new FakeDataTable<DRBaseComp>(CreateBaseRow()));
|
||||||
|
|
||||||
|
bool assembled = inventoryComponent.TryAssembleTower(10001, 20001, 30001, out TowerItemData tower);
|
||||||
|
BackpackInventoryData committedInventory = inventoryComponent.GetInventorySnapshot();
|
||||||
|
|
||||||
|
Assert.That(assembled, Is.False);
|
||||||
|
Assert.That(tower, Is.Null);
|
||||||
|
Assert.That(committedInventory.Towers, Has.Count.EqualTo(0));
|
||||||
|
Assert.That(committedInventory.MuzzleComponents[0].IsAssembledIntoTower, Is.False);
|
||||||
|
Assert.That(committedInventory.BearingComponents[0].IsAssembledIntoTower, Is.False);
|
||||||
|
Assert.That(committedInventory.BaseComponents[0].IsAssembledIntoTower, Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlayerInventoryComponent CreateBoundPlayerInventory(BackpackInventoryData inventory)
|
||||||
|
{
|
||||||
|
_originalPlayerInventory = GameEntry.PlayerInventory;
|
||||||
|
_inventoryObject = new GameObject("TestPlayerInventory");
|
||||||
|
PlayerInventoryComponent inventoryComponent = _inventoryObject.AddComponent<PlayerInventoryComponent>();
|
||||||
|
SetStaticPlayerInventory(inventoryComponent);
|
||||||
|
inventoryComponent.ReplaceInventorySnapshot(inventory);
|
||||||
|
return inventoryComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetStaticPlayerInventory(PlayerInventoryComponent inventoryComponent)
|
||||||
|
{
|
||||||
|
FieldInfo backingField = typeof(GameEntry).GetField(
|
||||||
|
"<PlayerInventory>k__BackingField",
|
||||||
|
BindingFlags.Static | BindingFlags.NonPublic);
|
||||||
|
Assert.That(backingField, Is.Not.Null);
|
||||||
|
backingField.SetValue(null, inventoryComponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void InjectAssemblyTables(
|
||||||
|
PlayerInventoryComponent inventoryComponent,
|
||||||
|
IDataTable<DRMuzzleComp> muzzleTable,
|
||||||
|
IDataTable<DRBearingComp> bearingTable,
|
||||||
|
IDataTable<DRBaseComp> baseTable)
|
||||||
|
{
|
||||||
|
FieldInfo serviceField = typeof(PlayerInventoryComponent).GetField(
|
||||||
|
"_towerAssemblyService",
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
Assert.That(serviceField, Is.Not.Null);
|
||||||
|
object assemblyService = serviceField.GetValue(inventoryComponent);
|
||||||
|
Assert.That(assemblyService, Is.Not.Null);
|
||||||
|
|
||||||
|
SetPrivateField(assemblyService, "_drMuzzleComp", muzzleTable);
|
||||||
|
SetPrivateField(assemblyService, "_drBearingComp", bearingTable);
|
||||||
|
SetPrivateField(assemblyService, "_drBaseComp", baseTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
field.SetValue(instance, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DRMuzzleComp CreateMuzzleRow()
|
||||||
|
{
|
||||||
|
DRMuzzleComp row = new DRMuzzleComp();
|
||||||
|
Assert.That(row.ParseDataRow("\t1\t\t测试枪口\t[10,20,30,40,50]\t3\t0.15\tNormalBullet\t\t[Fire]", null), Is.True);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DRBearingComp CreateBearingRow()
|
||||||
|
{
|
||||||
|
DRBearingComp row = new DRBearingComp();
|
||||||
|
Assert.That(row.ParseDataRow("\t1\t\t测试轴承\t[1,2,3,4,5]\t0.5\t[10,20,30,40,50]\t1\t\t[Ice]", null), Is.True);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DRBaseComp CreateBaseRow()
|
||||||
|
{
|
||||||
|
DRBaseComp row = new DRBaseComp();
|
||||||
|
Assert.That(row.ParseDataRow("\t1\t\t测试底座\t[2,4,6,8,10]\t-0.25\tFire\t\t[Fire]", null), Is.True);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeDataTable<TRow> : IDataTable<TRow> where TRow : class, IDataRow
|
||||||
|
{
|
||||||
|
private readonly Dictionary<int, TRow> _rowsById = new();
|
||||||
|
|
||||||
|
public FakeDataTable(params TRow[] rows)
|
||||||
|
{
|
||||||
|
if (rows != null)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
return _rowsById.ContainsKey(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasDataRow(Predicate<TRow> condition)
|
||||||
|
{
|
||||||
|
return GetDataRow(condition) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TRow GetDataRow(int id)
|
||||||
|
{
|
||||||
|
return _rowsById.TryGetValue(id, out TRow row) ? row : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TRow GetDataRow(Predicate<TRow> 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<TRow> condition)
|
||||||
|
{
|
||||||
|
List<TRow> results = new();
|
||||||
|
GetDataRows(condition, results);
|
||||||
|
return results.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void GetDataRows(Predicate<TRow> condition, List<TRow> 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<TRow> comparison)
|
||||||
|
{
|
||||||
|
List<TRow> results = new();
|
||||||
|
GetDataRows(comparison, results);
|
||||||
|
return results.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void GetDataRows(Comparison<TRow> comparison, List<TRow> results)
|
||||||
|
{
|
||||||
|
results?.Clear();
|
||||||
|
if (results == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.AddRange(_rowsById.Values);
|
||||||
|
if (comparison != null)
|
||||||
|
{
|
||||||
|
results.Sort(comparison);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public TRow[] GetDataRows(Predicate<TRow> condition, Comparison<TRow> comparison)
|
||||||
|
{
|
||||||
|
List<TRow> results = new();
|
||||||
|
GetDataRows(condition, comparison, results);
|
||||||
|
return results.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void GetDataRows(Predicate<TRow> condition, Comparison<TRow> comparison, List<TRow> results)
|
||||||
|
{
|
||||||
|
GetDataRows(condition, results);
|
||||||
|
if (results != null && comparison != null)
|
||||||
|
{
|
||||||
|
results.Sort(comparison);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public TRow[] GetAllDataRows()
|
||||||
|
{
|
||||||
|
List<TRow> results = new();
|
||||||
|
GetAllDataRows(results);
|
||||||
|
return results.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void GetAllDataRows(List<TRow> 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)
|
||||||
|
{
|
||||||
|
return _rowsById.Remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveAllDataRows()
|
||||||
|
{
|
||||||
|
_rowsById.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerator<TRow> GetEnumerator()
|
||||||
|
{
|
||||||
|
foreach (int id in GetOrderedIds())
|
||||||
|
{
|
||||||
|
yield return _rowsById[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerator IEnumerable.GetEnumerator()
|
||||||
|
{
|
||||||
|
return GetEnumerator();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int[] GetOrderedIds()
|
||||||
|
{
|
||||||
|
int[] ids = new int[_rowsById.Count];
|
||||||
|
_rowsById.Keys.CopyTo(ids, 0);
|
||||||
|
Array.Sort(ids);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: aa889597670a1524ebf4e952871c5414
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -0,0 +1,531 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Reflection;
|
||||||
|
using CustomComponent;
|
||||||
|
using GeometryTD.CustomComponent;
|
||||||
|
using GeometryTD.CustomEvent;
|
||||||
|
using GeometryTD.Definition;
|
||||||
|
using GeometryTD.Procedure;
|
||||||
|
using GeometryTD.UI;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace GeometryTD.Tests.EditMode
|
||||||
|
{
|
||||||
|
public sealed class ProcedureMainFlowTests
|
||||||
|
{
|
||||||
|
private GameObject _uiRouterObject;
|
||||||
|
private GameObject _inventoryObject;
|
||||||
|
private GameObject _nodeMapFormObject;
|
||||||
|
private UIRouterComponent _originalUIRouter;
|
||||||
|
private PlayerInventoryComponent _originalPlayerInventory;
|
||||||
|
|
||||||
|
[TearDown]
|
||||||
|
public void TearDown()
|
||||||
|
{
|
||||||
|
SetStaticUIRouter(_originalUIRouter);
|
||||||
|
SetStaticPlayerInventory(_originalPlayerInventory);
|
||||||
|
|
||||||
|
if (_uiRouterObject != null)
|
||||||
|
{
|
||||||
|
Object.DestroyImmediate(_uiRouterObject);
|
||||||
|
_uiRouterObject = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_inventoryObject != null)
|
||||||
|
{
|
||||||
|
Object.DestroyImmediate(_inventoryObject);
|
||||||
|
_inventoryObject = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_nodeMapFormObject != null)
|
||||||
|
{
|
||||||
|
Object.DestroyImmediate(_nodeMapFormObject);
|
||||||
|
_nodeMapFormObject = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void OnNodeEnter_Accepted_Event_Closes_Hub_And_Enters_NodeActive()
|
||||||
|
{
|
||||||
|
RecorderUIRouter recorderRouter = CreateBoundUIRouter();
|
||||||
|
ProcedureMain procedure = CreateProcedureMain(CreateTwoNodeRun(), ProcedureMainFlowPhase.Hub);
|
||||||
|
|
||||||
|
InvokePrivate(
|
||||||
|
procedure,
|
||||||
|
"OnNodeEnter",
|
||||||
|
null,
|
||||||
|
NodeEnterEventArgs.Create("testrun", 101, RunNodeType.Combat, 0));
|
||||||
|
|
||||||
|
Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.NodeActive));
|
||||||
|
Assert.That(recorderRouter.NodeMapController.CloseCount, Is.EqualTo(1));
|
||||||
|
Assert.That(recorderRouter.MainController.CloseCount, Is.EqualTo(1));
|
||||||
|
Assert.That(recorderRouter.DialogController.OpenCount, Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void OnNodeEnter_Ignores_Mismatched_Node_And_Leaves_Hub_Visible()
|
||||||
|
{
|
||||||
|
RecorderUIRouter recorderRouter = CreateBoundUIRouter();
|
||||||
|
ProcedureMain procedure = CreateProcedureMain(CreateTwoNodeRun(), ProcedureMainFlowPhase.Hub);
|
||||||
|
|
||||||
|
InvokePrivate(
|
||||||
|
procedure,
|
||||||
|
"OnNodeEnter",
|
||||||
|
null,
|
||||||
|
NodeEnterEventArgs.Create("testrun", 999, RunNodeType.Combat, 0));
|
||||||
|
|
||||||
|
Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.Hub));
|
||||||
|
Assert.That(recorderRouter.NodeMapController.CloseCount, Is.EqualTo(0));
|
||||||
|
Assert.That(recorderRouter.MainController.CloseCount, Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void OnNodeComplete_Normal_Completion_Returns_To_Hub_And_Advances_Run()
|
||||||
|
{
|
||||||
|
RecorderUIRouter recorderRouter = CreateBoundUIRouter();
|
||||||
|
RunState runState = CreateTwoNodeRun();
|
||||||
|
ProcedureMain procedure = CreateProcedureMain(runState, ProcedureMainFlowPhase.NodeActive);
|
||||||
|
|
||||||
|
InvokePrivate(
|
||||||
|
procedure,
|
||||||
|
"OnNodeComplete",
|
||||||
|
null,
|
||||||
|
NodeCompleteEventArgs.Create(
|
||||||
|
"testrun",
|
||||||
|
101,
|
||||||
|
RunNodeType.Combat,
|
||||||
|
0,
|
||||||
|
RunNodeCompletionStatus.Completed,
|
||||||
|
true,
|
||||||
|
new BackpackInventoryData { Gold = 77 }));
|
||||||
|
|
||||||
|
Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.Hub));
|
||||||
|
Assert.That(runState.CurrentNodeIndex, Is.EqualTo(1));
|
||||||
|
Assert.That(runState.Nodes[0].Status, Is.EqualTo(RunNodeStatus.Completed));
|
||||||
|
Assert.That(runState.CurrentNode.Status, Is.EqualTo(RunNodeStatus.Available));
|
||||||
|
Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(77));
|
||||||
|
Assert.That(recorderRouter.NodeMapController.OpenCount, Is.EqualTo(1));
|
||||||
|
Assert.That(recorderRouter.MainController.OpenCount, Is.EqualTo(1));
|
||||||
|
Assert.That(recorderRouter.DialogController.OpenCount, Is.EqualTo(0));
|
||||||
|
|
||||||
|
NodeMapFormUseCase nodeMapUseCase = GetNodeMapUseCase(procedure);
|
||||||
|
NodeMapFormRawData rawData = nodeMapUseCase.CreateInitialModel();
|
||||||
|
Assert.That(rawData.ProgressText, Is.EqualTo("1 / 2"));
|
||||||
|
Assert.That(rawData.CurrentNodeText, Is.EqualTo("当前节点: 事件"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void OnNodeComplete_Exception_Returns_To_Hub_And_Marks_Current_Node_Exception()
|
||||||
|
{
|
||||||
|
RecorderUIRouter recorderRouter = CreateBoundUIRouter();
|
||||||
|
RunState runState = CreateTwoNodeRun();
|
||||||
|
ProcedureMain procedure = CreateProcedureMain(runState, ProcedureMainFlowPhase.NodeActive);
|
||||||
|
|
||||||
|
InvokePrivate(
|
||||||
|
procedure,
|
||||||
|
"OnNodeComplete",
|
||||||
|
null,
|
||||||
|
NodeCompleteEventArgs.Create(
|
||||||
|
"testrun",
|
||||||
|
101,
|
||||||
|
RunNodeType.Combat,
|
||||||
|
0,
|
||||||
|
RunNodeCompletionStatus.Exception,
|
||||||
|
false,
|
||||||
|
new BackpackInventoryData { Gold = 12 }));
|
||||||
|
|
||||||
|
Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.Hub));
|
||||||
|
Assert.That(runState.CurrentNodeIndex, Is.EqualTo(0));
|
||||||
|
Assert.That(runState.CurrentNode.Status, Is.EqualTo(RunNodeStatus.Exception));
|
||||||
|
Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(12));
|
||||||
|
Assert.That(recorderRouter.NodeMapController.OpenCount, Is.EqualTo(1));
|
||||||
|
Assert.That(recorderRouter.MainController.OpenCount, Is.EqualTo(1));
|
||||||
|
Assert.That(recorderRouter.DialogController.OpenCount, Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void OnNodeComplete_With_Broken_Participant_Tower_Opens_Removal_Dialog_After_Returning_To_Hub()
|
||||||
|
{
|
||||||
|
RecorderUIRouter recorderRouter = CreateBoundUIRouter();
|
||||||
|
PlayerInventoryComponent inventoryComponent = CreateBoundPlayerInventory(CreateParticipantInventory(100f));
|
||||||
|
RunState runState = CreateTwoNodeRun();
|
||||||
|
ProcedureMain procedure = CreateProcedureMain(runState, ProcedureMainFlowPhase.NodeActive);
|
||||||
|
BackpackInventoryData brokenSnapshot = inventoryComponent.GetInventorySnapshot();
|
||||||
|
brokenSnapshot.MuzzleComponents[0].Endurance = 0f;
|
||||||
|
|
||||||
|
InvokePrivate(
|
||||||
|
procedure,
|
||||||
|
"OnNodeComplete",
|
||||||
|
null,
|
||||||
|
NodeCompleteEventArgs.Create(
|
||||||
|
"testrun",
|
||||||
|
101,
|
||||||
|
RunNodeType.Combat,
|
||||||
|
0,
|
||||||
|
RunNodeCompletionStatus.Completed,
|
||||||
|
true,
|
||||||
|
brokenSnapshot));
|
||||||
|
|
||||||
|
BackpackInventoryData replacedInventory = inventoryComponent.GetInventorySnapshot();
|
||||||
|
DialogFormRawData dialogRawData = recorderRouter.DialogController.LastOpenedUserData as DialogFormRawData;
|
||||||
|
|
||||||
|
Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.Hub));
|
||||||
|
Assert.That(replacedInventory.ParticipantTowerInstanceIds.Count, Is.EqualTo(0));
|
||||||
|
Assert.That(recorderRouter.NodeMapController.OpenCount, Is.EqualTo(1));
|
||||||
|
Assert.That(recorderRouter.MainController.OpenCount, Is.EqualTo(1));
|
||||||
|
Assert.That(recorderRouter.DialogController.OpenCount, Is.EqualTo(1));
|
||||||
|
Assert.That(dialogRawData, Is.Not.Null);
|
||||||
|
Assert.That(dialogRawData.Title, Is.EqualTo("出战塔已损坏"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void OnNodeComplete_Boss_Run_Completed_Shows_Run_Complete_Dialog_And_Does_Not_Override_It_With_Cleanup_Dialog()
|
||||||
|
{
|
||||||
|
RecorderUIRouter recorderRouter = CreateBoundUIRouter();
|
||||||
|
PlayerInventoryComponent inventoryComponent = CreateBoundPlayerInventory(CreateParticipantInventory(100f));
|
||||||
|
RunState runState = RunStateFactory.Create(
|
||||||
|
LevelThemeType.Plain,
|
||||||
|
inventoryComponent.GetInventorySnapshot(),
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new RunNodeSeed { NodeId = 901, NodeType = RunNodeType.BossCombat, LinkedLevelId = 4 }
|
||||||
|
},
|
||||||
|
"testrun");
|
||||||
|
ProcedureMain procedure = CreateProcedureMain(runState, ProcedureMainFlowPhase.NodeActive);
|
||||||
|
BackpackInventoryData brokenSnapshot = inventoryComponent.GetInventorySnapshot();
|
||||||
|
brokenSnapshot.MuzzleComponents[0].Endurance = 0f;
|
||||||
|
|
||||||
|
InvokePrivate(
|
||||||
|
procedure,
|
||||||
|
"OnNodeComplete",
|
||||||
|
null,
|
||||||
|
NodeCompleteEventArgs.Create(
|
||||||
|
"testrun",
|
||||||
|
901,
|
||||||
|
RunNodeType.BossCombat,
|
||||||
|
0,
|
||||||
|
RunNodeCompletionStatus.Completed,
|
||||||
|
true,
|
||||||
|
brokenSnapshot));
|
||||||
|
|
||||||
|
BackpackInventoryData replacedInventory = inventoryComponent.GetInventorySnapshot();
|
||||||
|
DialogFormRawData dialogRawData = recorderRouter.DialogController.LastOpenedUserData as DialogFormRawData;
|
||||||
|
|
||||||
|
Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.RunCompletedPendingFinish));
|
||||||
|
Assert.That(runState.IsCompleted, Is.True);
|
||||||
|
Assert.That(replacedInventory.ParticipantTowerInstanceIds.Count, Is.EqualTo(0));
|
||||||
|
Assert.That(recorderRouter.NodeMapController.CloseCount, Is.EqualTo(1));
|
||||||
|
Assert.That(recorderRouter.MainController.CloseCount, Is.EqualTo(1));
|
||||||
|
Assert.That(recorderRouter.DialogController.OpenCount, Is.EqualTo(1));
|
||||||
|
Assert.That(dialogRawData, Is.Not.Null);
|
||||||
|
Assert.That(dialogRawData.Title, Is.EqualTo("Run Complete"));
|
||||||
|
Assert.That(dialogRawData.Message, Is.EqualTo("Boss node completed. This run is finished and will return to the main menu."));
|
||||||
|
Assert.That(dialogRawData.OnClickConfirm, Is.Not.Null);
|
||||||
|
|
||||||
|
dialogRawData.OnClickConfirm(null);
|
||||||
|
|
||||||
|
Assert.That(GetReturnToMenuPending(procedure), Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void OnNodeMapNodeEnterRequested_Ignores_Request_When_FlowPhase_Is_Not_Hub()
|
||||||
|
{
|
||||||
|
RecorderUIRouter recorderRouter = CreateBoundUIRouter();
|
||||||
|
ProcedureMain procedure = CreateProcedureMain(CreateTwoNodeRun(), ProcedureMainFlowPhase.NodeActive);
|
||||||
|
|
||||||
|
InvokePrivate(
|
||||||
|
procedure,
|
||||||
|
"OnNodeMapNodeEnterRequested",
|
||||||
|
CreateNodeMapFormSender(),
|
||||||
|
NodeMapNodeEnterRequestedEventArgs.Create("testrun", 101, RunNodeType.Combat, 0));
|
||||||
|
|
||||||
|
Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.NodeActive));
|
||||||
|
Assert.That(recorderRouter.DialogController.OpenCount, Is.EqualTo(0));
|
||||||
|
Assert.That(recorderRouter.NodeMapController.CloseCount, Is.EqualTo(0));
|
||||||
|
Assert.That(recorderRouter.MainController.CloseCount, Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void OnNodeMapNodeEnterRequested_Ignores_Request_When_Target_Node_Does_Not_Match_Current_Node()
|
||||||
|
{
|
||||||
|
RecorderUIRouter recorderRouter = CreateBoundUIRouter();
|
||||||
|
ProcedureMain procedure = CreateProcedureMain(CreateTwoNodeRun(), ProcedureMainFlowPhase.Hub);
|
||||||
|
|
||||||
|
InvokePrivate(
|
||||||
|
procedure,
|
||||||
|
"OnNodeMapNodeEnterRequested",
|
||||||
|
CreateNodeMapFormSender(),
|
||||||
|
NodeMapNodeEnterRequestedEventArgs.Create("testrun", 102, RunNodeType.Event, 1));
|
||||||
|
|
||||||
|
Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.Hub));
|
||||||
|
Assert.That(recorderRouter.DialogController.OpenCount, Is.EqualTo(0));
|
||||||
|
Assert.That(recorderRouter.NodeMapController.CloseCount, Is.EqualTo(0));
|
||||||
|
Assert.That(recorderRouter.MainController.CloseCount, Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void OnNodeMapNodeEnterRequested_Blocked_Combat_With_Null_Inventory_Opens_Block_Dialog()
|
||||||
|
{
|
||||||
|
RecorderUIRouter recorderRouter = CreateBoundUIRouter();
|
||||||
|
ProcedureMain procedure = CreateProcedureMain(CreateTwoNodeRun(), ProcedureMainFlowPhase.Hub);
|
||||||
|
|
||||||
|
InvokePrivate(
|
||||||
|
procedure,
|
||||||
|
"OnNodeMapNodeEnterRequested",
|
||||||
|
CreateNodeMapFormSender(),
|
||||||
|
NodeMapNodeEnterRequestedEventArgs.Create("testrun", 101, RunNodeType.Combat, 0));
|
||||||
|
|
||||||
|
DialogFormRawData dialogRawData = recorderRouter.DialogController.LastOpenedUserData as DialogFormRawData;
|
||||||
|
|
||||||
|
Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.Hub));
|
||||||
|
Assert.That(recorderRouter.DialogController.CloseCount, Is.EqualTo(1));
|
||||||
|
Assert.That(recorderRouter.DialogController.OpenCount, Is.EqualTo(1));
|
||||||
|
Assert.That(dialogRawData, Is.Not.Null);
|
||||||
|
Assert.That(dialogRawData.Title, Is.EqualTo("无法进入战斗"));
|
||||||
|
Assert.That(dialogRawData.Message, Is.EqualTo("当前无法读取库存快照,暂时不能进入战斗。请重新进入本轮流程后再试。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void OnNodeMapNodeEnterRequested_Blocked_Combat_With_No_Valid_Participant_Tower_Opens_Block_Dialog()
|
||||||
|
{
|
||||||
|
RecorderUIRouter recorderRouter = CreateBoundUIRouter();
|
||||||
|
PlayerInventoryComponent inventoryComponent = CreateBoundPlayerInventory(CreateParticipantInventory(100f));
|
||||||
|
BackpackInventoryData invalidInventory = inventoryComponent.GetInventorySnapshot();
|
||||||
|
invalidInventory.ParticipantTowerInstanceIds.Clear();
|
||||||
|
inventoryComponent.ReplaceInventorySnapshot(invalidInventory);
|
||||||
|
|
||||||
|
ProcedureMain procedure = CreateProcedureMain(CreateTwoNodeRun(), ProcedureMainFlowPhase.Hub);
|
||||||
|
|
||||||
|
InvokePrivate(
|
||||||
|
procedure,
|
||||||
|
"OnNodeMapNodeEnterRequested",
|
||||||
|
CreateNodeMapFormSender(),
|
||||||
|
NodeMapNodeEnterRequestedEventArgs.Create("testrun", 101, RunNodeType.Combat, 0));
|
||||||
|
|
||||||
|
DialogFormRawData dialogRawData = recorderRouter.DialogController.LastOpenedUserData as DialogFormRawData;
|
||||||
|
|
||||||
|
Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.Hub));
|
||||||
|
Assert.That(recorderRouter.DialogController.CloseCount, Is.EqualTo(1));
|
||||||
|
Assert.That(recorderRouter.DialogController.OpenCount, Is.EqualTo(1));
|
||||||
|
Assert.That(dialogRawData, Is.Not.Null);
|
||||||
|
Assert.That(dialogRawData.Title, Is.EqualTo("无法进入战斗"));
|
||||||
|
Assert.That(
|
||||||
|
dialogRawData.Message,
|
||||||
|
Is.EqualTo("参战区至少需要 1 座完整装配了枪口、轴承、底座的塔,才能进入战斗。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private RecorderUIRouter CreateBoundUIRouter()
|
||||||
|
{
|
||||||
|
_originalUIRouter = GameEntry.UIRouter;
|
||||||
|
_uiRouterObject = new GameObject("ProcedureMainFlowTests-UIRouter");
|
||||||
|
|
||||||
|
UIRouterComponent uiRouter = _uiRouterObject.AddComponent<UIRouterComponent>();
|
||||||
|
SetStaticUIRouter(uiRouter);
|
||||||
|
|
||||||
|
RecorderUIRouter recorderRouter = new RecorderUIRouter(uiRouter);
|
||||||
|
recorderRouter.Bind(UIFormType.NodeMapForm);
|
||||||
|
recorderRouter.Bind(UIFormType.MainForm);
|
||||||
|
recorderRouter.Bind(UIFormType.DialogForm);
|
||||||
|
recorderRouter.Bind(UIFormType.RepoForm);
|
||||||
|
return recorderRouter;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlayerInventoryComponent CreateBoundPlayerInventory(BackpackInventoryData inventory)
|
||||||
|
{
|
||||||
|
_originalPlayerInventory = GameEntry.PlayerInventory;
|
||||||
|
_inventoryObject = new GameObject("ProcedureMainFlowTests-Inventory");
|
||||||
|
PlayerInventoryComponent inventoryComponent = _inventoryObject.AddComponent<PlayerInventoryComponent>();
|
||||||
|
SetStaticPlayerInventory(inventoryComponent);
|
||||||
|
inventoryComponent.ReplaceInventorySnapshot(inventory);
|
||||||
|
return inventoryComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private NodeMapForm CreateNodeMapFormSender()
|
||||||
|
{
|
||||||
|
_nodeMapFormObject = new GameObject("ProcedureMainFlowTests-NodeMapForm");
|
||||||
|
return _nodeMapFormObject.AddComponent<NodeMapForm>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProcedureMain CreateProcedureMain(RunState runState, ProcedureMainFlowPhase flowPhase)
|
||||||
|
{
|
||||||
|
ProcedureMain procedure = new ProcedureMain();
|
||||||
|
NodeMapFormUseCase nodeMapUseCase = new NodeMapFormUseCase();
|
||||||
|
nodeMapUseCase.SetRunState(runState);
|
||||||
|
|
||||||
|
SetPrivateField(procedure, "_currentRunState", runState);
|
||||||
|
SetPrivateField(procedure, "_nodeMapFormUseCase", nodeMapUseCase);
|
||||||
|
SetPrivateField(procedure, "_flowPhase", flowPhase);
|
||||||
|
SetPrivateField(procedure, "_isRunCompleteDialogShown", false);
|
||||||
|
SetPrivateField(procedure, "_isReturnToMenuPending", false);
|
||||||
|
return procedure;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetStaticUIRouter(UIRouterComponent uiRouter)
|
||||||
|
{
|
||||||
|
FieldInfo backingField = typeof(GameEntry).GetField(
|
||||||
|
"<UIRouter>k__BackingField",
|
||||||
|
BindingFlags.Static | BindingFlags.NonPublic);
|
||||||
|
Assert.That(backingField, Is.Not.Null);
|
||||||
|
backingField.SetValue(null, uiRouter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetStaticPlayerInventory(PlayerInventoryComponent inventoryComponent)
|
||||||
|
{
|
||||||
|
FieldInfo backingField = typeof(GameEntry).GetField(
|
||||||
|
"<PlayerInventory>k__BackingField",
|
||||||
|
BindingFlags.Static | BindingFlags.NonPublic);
|
||||||
|
Assert.That(backingField, Is.Not.Null);
|
||||||
|
backingField.SetValue(null, inventoryComponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void InvokePrivate(object instance, string methodName, params object[] args)
|
||||||
|
{
|
||||||
|
MethodInfo method = instance.GetType().GetMethod(
|
||||||
|
methodName,
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
Assert.That(method, Is.Not.Null);
|
||||||
|
method.Invoke(instance, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
field.SetValue(instance, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T GetPrivateField<T>(object instance, string fieldName)
|
||||||
|
{
|
||||||
|
FieldInfo field = instance.GetType().GetField(
|
||||||
|
fieldName,
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
Assert.That(field, Is.Not.Null);
|
||||||
|
return (T)field.GetValue(instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProcedureMainFlowPhase GetFlowPhase(ProcedureMain procedure)
|
||||||
|
{
|
||||||
|
return GetPrivateField<ProcedureMainFlowPhase>(procedure, "_flowPhase");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool GetReturnToMenuPending(ProcedureMain procedure)
|
||||||
|
{
|
||||||
|
return GetPrivateField<bool>(procedure, "_isReturnToMenuPending");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NodeMapFormUseCase GetNodeMapUseCase(ProcedureMain procedure)
|
||||||
|
{
|
||||||
|
return GetPrivateField<NodeMapFormUseCase>(procedure, "_nodeMapFormUseCase");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RunState CreateTwoNodeRun()
|
||||||
|
{
|
||||||
|
return RunStateFactory.Create(
|
||||||
|
LevelThemeType.Plain,
|
||||||
|
new BackpackInventoryData { Gold = 50 },
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new RunNodeSeed { NodeId = 101, NodeType = RunNodeType.Combat, LinkedLevelId = 1, SequenceIndex = 0 },
|
||||||
|
new RunNodeSeed { NodeId = 102, NodeType = RunNodeType.Event, SequenceIndex = 1 }
|
||||||
|
},
|
||||||
|
"testrun");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BackpackInventoryData CreateParticipantInventory(float endurance)
|
||||||
|
{
|
||||||
|
BackpackInventoryData inventory = new BackpackInventoryData();
|
||||||
|
inventory.MuzzleComponents.Add(new MuzzleCompItemData
|
||||||
|
{
|
||||||
|
InstanceId = 10001,
|
||||||
|
Name = "枪口",
|
||||||
|
Endurance = endurance
|
||||||
|
});
|
||||||
|
inventory.BearingComponents.Add(new BearingCompItemData
|
||||||
|
{
|
||||||
|
InstanceId = 20001,
|
||||||
|
Name = "轴承",
|
||||||
|
Endurance = endurance
|
||||||
|
});
|
||||||
|
inventory.BaseComponents.Add(new BaseCompItemData
|
||||||
|
{
|
||||||
|
InstanceId = 30001,
|
||||||
|
Name = "底座",
|
||||||
|
Endurance = endurance
|
||||||
|
});
|
||||||
|
inventory.Towers.Add(new TowerItemData
|
||||||
|
{
|
||||||
|
InstanceId = 90001,
|
||||||
|
Name = "合法塔",
|
||||||
|
MuzzleComponentInstanceId = 10001,
|
||||||
|
BearingComponentInstanceId = 20001,
|
||||||
|
BaseComponentInstanceId = 30001
|
||||||
|
});
|
||||||
|
inventory.ParticipantTowerInstanceIds.Add(90001);
|
||||||
|
return inventory;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecorderUIRouter
|
||||||
|
{
|
||||||
|
private readonly Dictionary<UIFormType, RecorderController> _controllers = new();
|
||||||
|
|
||||||
|
public RecorderUIRouter(UIRouterComponent uiRouter)
|
||||||
|
{
|
||||||
|
UIRouter = uiRouter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UIRouterComponent UIRouter { get; }
|
||||||
|
|
||||||
|
public RecorderController NodeMapController => _controllers[UIFormType.NodeMapForm];
|
||||||
|
|
||||||
|
public RecorderController MainController => _controllers[UIFormType.MainForm];
|
||||||
|
|
||||||
|
public RecorderController DialogController => _controllers[UIFormType.DialogForm];
|
||||||
|
|
||||||
|
public void Bind(UIFormType formType)
|
||||||
|
{
|
||||||
|
RecorderController controller = new RecorderController();
|
||||||
|
_controllers[formType] = controller;
|
||||||
|
|
||||||
|
FieldInfo routeControllersField = typeof(UIRouterComponent).GetField(
|
||||||
|
"_routeControllers",
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
Assert.That(routeControllersField, Is.Not.Null);
|
||||||
|
|
||||||
|
Dictionary<UIFormType, IUIFormController> routeControllers =
|
||||||
|
(Dictionary<UIFormType, IUIFormController>)routeControllersField.GetValue(UIRouter);
|
||||||
|
Assert.That(routeControllers, Is.Not.Null);
|
||||||
|
routeControllers[formType] = controller;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecorderController : IUIFormController
|
||||||
|
{
|
||||||
|
public int OpenCount { get; private set; }
|
||||||
|
|
||||||
|
public int CloseCount { get; private set; }
|
||||||
|
|
||||||
|
public object LastOpenedUserData { get; private set; }
|
||||||
|
|
||||||
|
public IUIUseCase BoundUseCase { get; private set; }
|
||||||
|
|
||||||
|
public int? OpenUI(object userData = null)
|
||||||
|
{
|
||||||
|
OpenCount++;
|
||||||
|
LastOpenedUserData = userData;
|
||||||
|
return OpenCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CloseUI()
|
||||||
|
{
|
||||||
|
CloseCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BindUseCase(IUIUseCase useCase)
|
||||||
|
{
|
||||||
|
BoundUseCase = useCase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3b622fd9e87fa514da333f9d8313fdd2
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -50,6 +50,32 @@ namespace GeometryTD.Tests.EditMode
|
||||||
Assert.That(rawData.ConfirmText, Is.EqualTo("知道了"));
|
Assert.That(rawData.ConfirmText, Is.EqualTo("知道了"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void RemoveBrokenParticipantTowers_Does_Not_Remove_Towers_With_Missing_Components()
|
||||||
|
{
|
||||||
|
BackpackInventoryData inventory = CreateInventory();
|
||||||
|
inventory.BaseComponents.RemoveAt(0);
|
||||||
|
|
||||||
|
ProcedureMainParticipantTowerCleanupResult result =
|
||||||
|
ProcedureMainParticipantTowerCleanupService.RemoveBrokenParticipantTowers(inventory, 4);
|
||||||
|
|
||||||
|
Assert.That(result.HasAnyRemovedTower, Is.False);
|
||||||
|
CollectionAssert.AreEqual(new long[] { 90001, 90002 }, inventory.ParticipantTowerInstanceIds);
|
||||||
|
Assert.That(inventory.Towers[0].IsParticipatingInCombat, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void BuildRemovedTowerDialogRawData_Returns_Default_Message_When_No_Tower_Was_Removed()
|
||||||
|
{
|
||||||
|
DialogFormRawData rawData =
|
||||||
|
ProcedureMainParticipantTowerCleanupService.BuildRemovedTowerDialogRawData(
|
||||||
|
new ProcedureMainParticipantTowerCleanupResult());
|
||||||
|
|
||||||
|
Assert.That(rawData.Title, Is.EqualTo("出战塔已损坏"));
|
||||||
|
Assert.That(rawData.Message, Is.EqualTo("当前没有需要移出参战区的损坏防御塔。"));
|
||||||
|
Assert.That(rawData.ConfirmText, Is.EqualTo("知道了"));
|
||||||
|
}
|
||||||
|
|
||||||
private static BackpackInventoryData CreateInventory()
|
private static BackpackInventoryData CreateInventory()
|
||||||
{
|
{
|
||||||
BackpackInventoryData inventory = new BackpackInventoryData();
|
BackpackInventoryData inventory = new BackpackInventoryData();
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,22 @@ namespace GeometryTD.Tests.EditMode
|
||||||
Assert.That(completedRun.RunInventorySnapshot.Gold, Is.EqualTo(10));
|
Assert.That(completedRun.RunInventorySnapshot.Gold, Is.EqualTo(10));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TryAdvanceRun_Returns_NoChange_But_Still_Replaces_Snapshot_For_NonTerminal_Status()
|
||||||
|
{
|
||||||
|
RunState runState = CreateTwoNodeRun();
|
||||||
|
|
||||||
|
ProcedureMainRunAdvanceResult result = ProcedureMainRunFlowService.TryAdvanceRun(
|
||||||
|
runState,
|
||||||
|
RunNodeCompletionStatus.None,
|
||||||
|
new BackpackInventoryData { Gold = 999 });
|
||||||
|
|
||||||
|
Assert.That(result, Is.EqualTo(ProcedureMainRunAdvanceResult.NoChange));
|
||||||
|
Assert.That(runState.CurrentNodeIndex, Is.EqualTo(0));
|
||||||
|
Assert.That(runState.CurrentNode.Status, Is.EqualTo(RunNodeStatus.Available));
|
||||||
|
Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(999));
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void MatchesCurrentNode_Returns_True_For_Current_Node_And_Default_Wildcards()
|
public void MatchesCurrentNode_Returns_True_For_Current_Node_And_Default_Wildcards()
|
||||||
{
|
{
|
||||||
|
|
@ -136,6 +152,23 @@ namespace GeometryTD.Tests.EditMode
|
||||||
Assert.That(sequenceMismatch, Is.False);
|
Assert.That(sequenceMismatch, Is.False);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchesCurrentNode_Returns_False_When_Run_Has_No_Current_Node()
|
||||||
|
{
|
||||||
|
RunState completedRun = RunStateFactory.Create(
|
||||||
|
LevelThemeType.Plain,
|
||||||
|
new BackpackInventoryData { Gold = 10 },
|
||||||
|
new RunNodeSeed[0]);
|
||||||
|
|
||||||
|
bool match = ProcedureMainNodeEventGuardService.MatchesCurrentNode(
|
||||||
|
completedRun,
|
||||||
|
1,
|
||||||
|
RunNodeType.Combat,
|
||||||
|
0);
|
||||||
|
|
||||||
|
Assert.That(match, Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void TryEnterCompletedPendingFinish_Shows_Dialog_Only_Once()
|
public void TryEnterCompletedPendingFinish_Shows_Dialog_Only_Once()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,41 @@ namespace GeometryTD.Tests.EditMode
|
||||||
Assert.That(hitContext.IsCriticalHit, Is.True);
|
Assert.That(hitContext.IsCriticalHit, Is.True);
|
||||||
Assert.That(hitContext.FinalDamage, Is.EqualTo(281));
|
Assert.That(hitContext.FinalDamage, Is.EqualTo(281));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void GetTagStack_Returns_Zero_For_Missing_Invalid_Or_Empty_Data()
|
||||||
|
{
|
||||||
|
Assert.That(TagEffectResolver.GetTagStack(null, TagType.Fire), Is.EqualTo(0));
|
||||||
|
Assert.That(TagEffectResolver.GetTagStack(System.Array.Empty<TagRuntimeData>(), TagType.Fire), Is.EqualTo(0));
|
||||||
|
Assert.That(TagEffectResolver.GetTagStack(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new TagRuntimeData { TagType = TagType.Fire, TotalStack = 0 },
|
||||||
|
new TagRuntimeData { TagType = TagType.Ice, TotalStack = 2 }
|
||||||
|
},
|
||||||
|
TagType.Fire), Is.EqualTo(0));
|
||||||
|
Assert.That(TagEffectResolver.GetTagStack(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new TagRuntimeData { TagType = TagType.Ice, TotalStack = 2 }
|
||||||
|
},
|
||||||
|
TagType.None), Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void GetTagStack_Returns_First_Positive_Matching_Stack()
|
||||||
|
{
|
||||||
|
int stack = TagEffectResolver.GetTagStack(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
null,
|
||||||
|
new TagRuntimeData { TagType = TagType.Fire, TotalStack = 2 },
|
||||||
|
new TagRuntimeData { TagType = TagType.Fire, TotalStack = 4 }
|
||||||
|
},
|
||||||
|
TagType.Fire);
|
||||||
|
|
||||||
|
Assert.That(stack, Is.EqualTo(2));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class EnemyTagStatusRuntimeTests
|
public sealed class EnemyTagStatusRuntimeTests
|
||||||
|
|
@ -273,6 +308,35 @@ namespace GeometryTD.Tests.EditMode
|
||||||
Assert.That(totalDamage, Is.EqualTo(0));
|
Assert.That(totalDamage, Is.EqualTo(0));
|
||||||
Assert.That(runtime.GetMoveSpeedMultiplier(), Is.EqualTo(1f).Within(0.001f));
|
Assert.That(runtime.GetMoveSpeedMultiplier(), Is.EqualTo(1f).Within(0.001f));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void SampleInventory_TowerFireTags_Flow_From_ComponentAggregation_To_BurnDamage()
|
||||||
|
{
|
||||||
|
BackpackInventoryData inventory = InventorySeedUtility.CreateSampleInventory();
|
||||||
|
TowerItemData tower = inventory.Towers[0];
|
||||||
|
|
||||||
|
Assert.That(tower.Stats, Is.Not.Null);
|
||||||
|
Assert.That(tower.Stats.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));
|
||||||
|
|
||||||
|
EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime();
|
||||||
|
int totalDamage = 0;
|
||||||
|
TagEffectResolver.ApplyAfterHit(new AttackPayload
|
||||||
|
{
|
||||||
|
BaseDamage = tower.Stats.AttackDamage[0],
|
||||||
|
AttackPropertyType = tower.Stats.AttackPropertyType,
|
||||||
|
TagRuntimes = tower.Stats.TagRuntimes
|
||||||
|
}, runtime);
|
||||||
|
|
||||||
|
runtime.Tick(1f, damage => totalDamage += damage);
|
||||||
|
runtime.Tick(1f, damage => totalDamage += damage);
|
||||||
|
runtime.Tick(1f, damage => totalDamage += damage);
|
||||||
|
|
||||||
|
Assert.That(runtime.HasStatus(TagType.Fire), Is.False);
|
||||||
|
Assert.That(totalDamage, Is.EqualTo(180));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class EnemyStatusTagRegistryTests
|
public sealed class EnemyStatusTagRegistryTests
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,38 @@ namespace GeometryTD.Tests.EditMode
|
||||||
Assert.That(tags, Is.EqualTo(new[] { TagType.Fire, TagType.Crit }));
|
Assert.That(tags, Is.EqualTo(new[] { TagType.Fire, TagType.Crit }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void BuildRuntimeTagsFromUniqueTags_Filters_Invalid_Values_And_Assigns_One_Stack()
|
||||||
|
{
|
||||||
|
TagRuntimeData[] runtimes = TowerTagAggregationService.BuildRuntimeTagsFromUniqueTags(new[]
|
||||||
|
{
|
||||||
|
TagType.None,
|
||||||
|
TagType.Ice,
|
||||||
|
TagType.Fire,
|
||||||
|
TagType.Ice,
|
||||||
|
(TagType)99
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.That(runtimes, Has.Length.EqualTo(2));
|
||||||
|
Assert.That(runtimes[0].TagType, Is.EqualTo(TagType.Fire));
|
||||||
|
Assert.That(runtimes[0].TotalStack, Is.EqualTo(1));
|
||||||
|
Assert.That(runtimes[1].TagType, Is.EqualTo(TagType.Ice));
|
||||||
|
Assert.That(runtimes[1].TotalStack, Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void FlattenUniqueTags_Ignores_Runtime_Entries_With_NonPositive_Stack()
|
||||||
|
{
|
||||||
|
TagType[] tags = TowerTagAggregationService.FlattenUniqueTags(new[]
|
||||||
|
{
|
||||||
|
new TagRuntimeData { TagType = TagType.Fire, TotalStack = 0 },
|
||||||
|
new TagRuntimeData { TagType = TagType.Ice, TotalStack = -1 },
|
||||||
|
new TagRuntimeData { TagType = TagType.Crit, TotalStack = 1 }
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.That(tags, Is.EqualTo(new[] { TagType.Crit }));
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void BuildTowerTagTexts_Uses_Runtime_Stacks_For_Display()
|
public void BuildTowerTagTexts_Uses_Runtime_Stacks_For_Display()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,8 @@
|
||||||
|
|
||||||
### 当前剩余缺口
|
### 当前剩余缺口
|
||||||
|
|
||||||
- `S3` 已完成收口,不再把“三组件完整合法参战”视为 `P0-10` 的功能缺口;当前只剩文档验收口径与状态同步。
|
- `S3` 已完成收口,不再把“三组件完整合法参战”视为 `P0-10` 的功能缺口;对应文档与状态已同步。
|
||||||
- 品质 / Tag / 耐久不再是“只有局部实现”的状态:`S4-02 ~ S4-06` 与 `S5-01 ~ S5-04` 已落地;M1 当前真正未完全收口的只剩 `S4-07` 的三表方案命名、文档口径与少量配置映射收尾。
|
- 品质 / Tag / 耐久不再是“只有局部实现”的状态:`S4-02 ~ S4-07` 与 `S5-01 ~ S5-04` 已落地;M1 主功能侧当前已无未收口项,后续重点转入测试补强与旧文档清理。
|
||||||
|
|
||||||
## 阶段 S2 - 对齐节点流程 UI 的 MVP 口径
|
## 阶段 S2 - 对齐节点流程 UI 的 MVP 口径
|
||||||
|
|
||||||
|
|
@ -131,11 +131,11 @@
|
||||||
| [x] | S4-04 | 实现组件实例 Tag 的统一生成入口 | `Assets/GameMain/Scripts/Definition/`<br>`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/` | 掉落、商店、初始种子、事件奖励共用同一生成结果 |
|
| [x] | S4-04 | 实现组件实例 Tag 的统一生成入口 | `Assets/GameMain/Scripts/Definition/`<br>`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/` | 掉落、商店、初始种子、事件奖励共用同一生成结果 |
|
||||||
| [x] | S4-05 | 实现组塔后的 Tag 汇总与展示入口 | `Assets/GameMain/Scripts/Definition/`<br>`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`<br>`Assets/GameMain/Scripts/UI/` | 组件 Tag 可汇总为塔级结果,且 UI 展示口径一致 |
|
| [x] | S4-05 | 实现组塔后的 Tag 汇总与展示入口 | `Assets/GameMain/Scripts/Definition/`<br>`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`<br>`Assets/GameMain/Scripts/UI/` | 组件 Tag 可汇总为塔级结果,且 UI 展示口径一致 |
|
||||||
| [x] | S4-06 | 实现首批基础 Tag 的战斗生效 | `Assets/GameMain/Scripts/Entity/`<br>`Assets/GameMain/Scripts/Components/`<br>`Assets/GameMain/Scripts/Definition/` | 首发 6~8 个基础 Tag 至少完成一批可验证效果,且战斗入口与状态运行时结构可继续扩展 |
|
| [x] | S4-06 | 实现首批基础 Tag 的战斗生效 | `Assets/GameMain/Scripts/Entity/`<br>`Assets/GameMain/Scripts/Components/`<br>`Assets/GameMain/Scripts/Definition/` | 首发 6~8 个基础 Tag 至少完成一批可验证效果,且战斗入口与状态运行时结构可继续扩展 |
|
||||||
| [~] | S4-07 | 补齐 Tag 规则与数据表的映射关系 | `Assets/GameMain/Scripts/DataTable/`<br>`Assets/GameMain/Scripts/Definition/` | 表字段不是只存在而未被消费,Tag 参数可配置可解释 |
|
| [x] | S4-07 | 补齐 Tag 规则与数据表的映射关系 | `Assets/GameMain/Scripts/DataTable/`<br>`Assets/GameMain/Scripts/Definition/` | 三表职责、默认值、运行时消费链与文档口径一致,Tag 参数可配置可解释 |
|
||||||
|
|
||||||
> 2026-03-09 更新:`InventoryRarityRuleService` 已落地;塔品质计算与组件品质归一化已统一收口,`PlayerInventoryTowerAssemblyService`、`ShopFormUseCase`、`EnemyDropResolver`、`InventorySeedUtility` 已接入同一规则入口。你已确认 Unity Test Runner 中 `Assets/Tests/EditMode` 全部通过,其中包含新增的 `InventoryRarityRuleServiceTests`。
|
> 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-03` 文档口径已冻结;`TagSystemDesign.md` 已明确组件实例生成、塔级 `Stack` 汇总、四段触发阶段,以及 MVP 正式首发 7 个 Tag:`Fire`、`Ice`、`Crit`、`Execution`、`Shatter`、`Inferno`、`AbsoluteZero`。`BurnSpread` 已明确后移。后续 `S4-07` 已进一步把配置层固定为 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 三表方案。
|
||||||
>
|
>
|
||||||
> 2026-03-09 更新:`S4-04` 已落地 `ComponentTagGenerationService` 与 `InventoryTagSourceType`;组件实例 Tag 现在统一按 `PossibleTag + Tag.txt.MinRarity + 品质预算` 生成,并只保留当前正式首发 7 个 Tag。`ShopFormUseCase`、`EnemyDropResolver`、`InventorySeedUtility` 已接入该入口,样例库存的组件与塔展示 Tag 已同步为统一结果;同时新增 `ComponentTagGenerationServiceTests`。当前 CLI 下 `dotnet build GeometryTD.sln` 仍因本机缺少 Unity 引用和 `Unity.SourceGenerators*.dll` 失败,未能替代 Unity Test Runner 完成最终验证。
|
> 2026-03-09 更新:`S4-04` 已落地 `ComponentTagGenerationService` 与 `InventoryTagSourceType`;组件实例 Tag 现在统一按 `PossibleTag + Tag.txt.MinRarity + 品质预算` 生成,并只保留当前正式首发 7 个 Tag。`ShopFormUseCase`、`EnemyDropResolver`、`InventorySeedUtility` 已接入该入口,样例库存的组件与塔展示 Tag 已同步为统一结果;同时新增 `ComponentTagGenerationServiceTests`。当前 CLI 下 `dotnet build GeometryTD.sln` 仍因本机缺少 Unity 引用和 `Unity.SourceGenerators*.dll` 失败,未能替代 Unity Test Runner 完成最终验证。
|
||||||
>
|
>
|
||||||
|
|
@ -145,11 +145,7 @@
|
||||||
>
|
>
|
||||||
> 2026-03-10 更新:`S4-06` 已补齐首发剩余 3 个 Tag。`Shatter` 已接入命中前数值修正链,并按“目标已减速”口径增伤;`Inferno` 与 `AbsoluteZero` 已按首发方案作为 `Fire` / `Ice` 的强化 Tag 落地,分别增强 DOT 时长/伤害与减速时长/强度;对应 EditMode 测试已同步补齐。`BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate` 仍保持分类与占位路由,没有实际战斗效果,因此仍属于后续扩展,不计入 `S4-06` 当前完成标准。
|
> 2026-03-10 更新:`S4-06` 已补齐首发剩余 3 个 Tag。`Shatter` 已接入命中前数值修正链,并按“目标已减速”口径增伤;`Inferno` 与 `AbsoluteZero` 已按首发方案作为 `Fire` / `Ice` 的强化 Tag 落地,分别增强 DOT 时长/伤害与减速时长/强度;对应 EditMode 测试已同步补齐。`BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate` 仍保持分类与占位路由,没有实际战斗效果,因此仍属于后续扩展,不计入 `S4-06` 当前完成标准。
|
||||||
>
|
>
|
||||||
> 2026-03-11 更新:`S4-07` 已进入第一阶段实现。当前已新增 `TagConfig.txt` 与 `DRTagConfig`,`ProcedurePreload` 会在加载 `TagConfig` 表后驱动 `TagDefinitionRegistry.LoadFromRows(...)`,把 `TriggerPhase`、`Description` 以及首发 7 个 Tag 的配置参数从表覆盖到运行时强类型 `TagConfig`。`ItemDescForm` 也已开始消费该配置说明;塔详情会优先使用 `TagRuntimes` 构建 `Ice x2` 这类叠层展示。因此 `S4-07` 不再是“完全未开始”,但当前仍只完成了 `TagConfig` 级别的参数映射与 UI 消费,尚未把文档中的完整 `TagRule`(如权重、生成规则等)全部收口。
|
> 2026-03-11 更新:`S4-07` 已完成最终收口。当前仓库已明确采用 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 三表方案作为正式配置口径,其中 `Tag.txt -> DRTag -> TagGenerationRuleRegistry` 负责基础字典、生成门槛、权重与启用态,`RarityTagBudget.txt -> DRRarityTagBudget -> RarityTagBudgetRuleRegistry` 负责按品质的 Tag 数量预算,`TagConfig.txt -> DRTagConfig -> TagDefinitionRegistry` 负责触发阶段、描述与首发 7 个 Tag 的效果参数。`ComponentTagGenerationService`、`ShopFormUseCase`、`EnemyDropResolver`、`InventorySeedUtility`、`ItemDescForm` 与战斗运行时均已接入对应消费链;同时已把 registry 默认值对齐到三表当前数值,避免加载顺序或回退路径重新暴露旧口径。
|
||||||
>
|
|
||||||
> 2026-03-11 更新:`S4-07` 已继续推进到第二阶段。当前 `Tag.txt -> DRTag -> TagGenerationRuleRegistry` 已形成组件 Tag 生成规则闭环,`ComponentTagGenerationService` 不再主要依赖内部 `MinRarity` 硬编码,而是统一消费 `DRTag` 提供的 `MinRarity + Weight`;`ShopFormUseCase`、`EnemyDropResolver`、`InventorySeedUtility` 也已切回这一统一入口。现阶段的职责边界明确为:`Tag.txt` 负责基础字典与生成规则,`TagConfig.txt` 负责触发阶段、描述与效果参数。
|
|
||||||
>
|
|
||||||
> 2026-03-11 补充验证:已通过手工扩充组件 `PossibleTag` 候选池并调整 `Weight` 验证生成权重实际生效;同时把所有 Tag 的 `MinRarity` 提升到 `Red` 后,低品质组件不再生成任何 Tag,说明 `Tag.txt` 中的 `MinRarity` 与 `Weight` 均已进入统一生成链。
|
|
||||||
|
|
||||||
### S4-01 边界结论
|
### S4-01 边界结论
|
||||||
|
|
||||||
|
|
@ -184,10 +180,9 @@
|
||||||
- `S4-06` 已完成:战斗链已支持 Tag 透传与统一结算,正式首发 7 个 Tag `Fire`、`Ice`、`Crit`、`Execution`、`Shatter`、`Inferno`、`AbsoluteZero` 均已有可验证效果。
|
- `S4-06` 已完成:战斗链已支持 Tag 透传与统一结算,正式首发 7 个 Tag `Fire`、`Ice`、`Crit`、`Execution`、`Shatter`、`Inferno`、`AbsoluteZero` 均已有可验证效果。
|
||||||
- `Shatter` 已接入命中前数值修正链;`Inferno`、`AbsoluteZero` 已按“强化现有 `Fire` / `Ice` 状态”的首发口径落地,状态类 Tag 的内部结构继续保持注册式运行时。
|
- `Shatter` 已接入命中前数值修正链;`Inferno`、`AbsoluteZero` 已按“强化现有 `Fire` / `Ice` 状态”的首发口径落地,状态类 Tag 的内部结构继续保持注册式运行时。
|
||||||
- `BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate` 已进入分类与配置骨架,但仍属于后续扩展,不计入当前 `S4` 的完成标准。
|
- `BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate` 已进入分类与配置骨架,但仍属于后续扩展,不计入当前 `S4` 的完成标准。
|
||||||
- `S4-07` 已进入第一阶段实现。当前仓库已具备 `TagConfig.txt -> DRTagConfig -> TagDefinitionRegistry -> ItemDescForm` 的消费闭环,首发 7 个 Tag 的触发阶段、描述与核心参数已可由表覆盖。
|
- `S4-07` 已完成。当前仓库已具备 `Tag.txt -> DRTag -> TagGenerationRuleRegistry -> ComponentTagGenerationService`、`RarityTagBudget.txt -> DRRarityTagBudget -> RarityTagBudgetRuleRegistry -> ComponentTagGenerationService`、`TagConfig.txt -> DRTagConfig -> TagDefinitionRegistry -> 展示 / 战斗运行时` 三条正式消费闭环。
|
||||||
- `S4-07` 已推进到第二阶段。当前仓库同时具备 `Tag.txt -> DRTag -> TagGenerationRuleRegistry -> ComponentTagGenerationService` 与 `TagConfig.txt -> DRTagConfig -> TagDefinitionRegistry -> ItemDescForm` 两条消费闭环,首发 7 个 Tag 的生成规则、触发阶段、描述与核心参数都已开始由表驱动。
|
- 三表方案已经固定为本阶段正式口径,不再把单独的 `TagRule` 表视为当前 M1 的待决定项。
|
||||||
- `S4-07` 已推进到第三阶段。当前仓库新增 `RarityTagBudget.txt -> DRRarityTagBudget -> RarityTagBudgetRuleRegistry -> ComponentTagGenerationService`,组件 Tag 数量预算不再写死在 `ResolveRarityTagBudget(...)` 的 `switch` 中,而是按品质从表驱动;至此生成链的候选过滤、权重抽取、数量预算三段都已进入 DataTable 消费闭环。
|
- `S4-07` 当前通过标准是“表字段被实际消费、参数可解释、运行时与文档口径一致”;这一标准已满足。
|
||||||
- `S4-07` 仍未完成最终收口。当前采用的是 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 的分层方案,而不是文档原方案里的单独 `TagRule`;更深的元数据字段与完整命名口径仍待后续决定是否继续统一。
|
|
||||||
|
|
||||||
### S4-06 当前代码状态
|
### S4-06 当前代码状态
|
||||||
|
|
||||||
|
|
@ -205,18 +200,18 @@
|
||||||
|
|
||||||
### S4 后续执行计划
|
### S4 后续执行计划
|
||||||
|
|
||||||
1. 继续推进 `S4-07`:在现有 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 分层方案基础上,决定是否补成文档中的完整 `TagRule`,或明确当前三表就是本阶段的等价收口方案。
|
1. `S4-07` 已完成,当前不再继续围绕三表命名与职责拆分新任务。
|
||||||
2. `S4-07` 完成标准仍以“表字段被实际消费、参数可解释、运行时与文档口径一致”为准;当前已完成首发 7 个 Tag 的生成规则、数量预算、参数和说明配置化,后续重点转向是否还要继续配置化更深层的元数据字段。
|
2. 后续如果要继续配置化更深层 Tag 元数据,应作为新的增强项单独立项,不回挂到当前 M1。
|
||||||
3. `BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate` 继续留在后续扩展阶段,不因为 `S4-06` 完成而提前进入当前迭代。
|
3. `BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate` 继续留在后续扩展阶段,不因为 `S4-07` 完成而提前进入当前迭代。
|
||||||
|
|
||||||
## 阶段 S5 - 收口耐久规则
|
## 阶段 S5 - 收口耐久规则
|
||||||
|
|
||||||
| 状态 | ID | 任务 | 交付物路径 | 验收标准 |
|
| 状态 | ID | 任务 | 交付物路径 | 验收标准 |
|
||||||
|-----|-------|-----------------------|-------------------------------------------------------------------------------------------------|----------------|
|
|-----|-------|-----------------------|-------------------------------------------------------------------------------------------------|------------------------------|
|
||||||
| [x] | S5-01 | 先确认 M1 是否保留完整耐久闭环 | `docs/CodeX-TODO.md`<br>`docs/TODO.md` | 已明确按“最小耐久闭环”推进,不扩展维修/折价/自动销毁 |
|
| [x] | S5-01 | 先确认 M1 是否保留完整耐久闭环 | `docs/CodeX-TODO.md`<br>`docs/TODO.md` | 已明确按“最小耐久闭环”推进,不扩展维修/折价/自动销毁 |
|
||||||
| [x] | S5-02 | 若保留,定义耐久对属性/出战资格的影响方式 | `Assets/GameMain/Scripts/Definition/`<br>`Assets/GameMain/Scripts/Entity/` | 已统一为“归零失效、不可参战、非归零不衰减” |
|
| [x] | S5-02 | 若保留,定义耐久对属性/出战资格的影响方式 | `Assets/GameMain/Scripts/Definition/`<br>`Assets/GameMain/Scripts/Entity/` | 已统一为“归零失效、不可参战、非归零不衰减” |
|
||||||
| [x] | S5-03 | 实现耐久扣减后的实际生效 | `Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`<br>`Assets/GameMain/Scripts/Entity/` | 已按“每场固定扣 1、仅扣本场参战塔”接进主链 |
|
| [x] | S5-03 | 实现耐久扣减后的实际生效 | `Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`<br>`Assets/GameMain/Scripts/Entity/` | 已按“每场固定扣 1、仅扣本场参战塔”接进主链 |
|
||||||
| [x] | S5-04 | 实现 `0` 耐久失效闭环 | `Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`<br>`Assets/GameMain/Scripts/UI/` | 已实现失效不可参战、仓库提示、节点结束后自动踢出参战区 |
|
| [x] | S5-04 | 实现 `0` 耐久失效闭环 | `Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`<br>`Assets/GameMain/Scripts/UI/` | 已实现失效不可参战、仓库提示、节点结束后自动踢出参战区 |
|
||||||
|
|
||||||
### S5 规划结论
|
### S5 规划结论
|
||||||
|
|
||||||
|
|
@ -264,26 +259,36 @@
|
||||||
|
|
||||||
## 阶段 S6 - 回归与文档收尾
|
## 阶段 S6 - 回归与文档收尾
|
||||||
|
|
||||||
| 状态 | ID | 任务 | 交付物路径 | 验收标准 |
|
| 状态 | ID | 任务 | 交付物路径 | 验收标准 |
|
||||||
|-----|-------|-------------------------|---------------------------------------|--------------------------|
|
|-----|-------|-------------------------|---------------------------------------|------------------------------------------------------------------------------------------|
|
||||||
| [ ] | S6-01 | 补主链路回归测试 | `Assets/GameMain/Tests/` | Run 推进、节点回流、Boss 完成可回归验证 |
|
| [x] | S6-01 | 补主链路回归测试 | `Assets/Tests/EditMode/` | Run 推进、节点回流、Boss 完成可回归验证 |
|
||||||
| [ ] | S6-02 | 补规则侧测试 | `Assets/GameMain/Tests/` | 合法性、品质、Tag、耐久关键公式可验证 |
|
| [x] | S6-02 | 补规则侧测试 | `Assets/Tests/EditMode/` | 合法性、品质、Tag、耐久关键公式可验证 |
|
||||||
| [x] | S6-03 | 回写 `docs/TODO.md` 的真实状态 | `docs/TODO.md` | 文档状态与仓库现状一致 |
|
| [x] | S6-03 | 回写 `docs/TODO.md` 的真实状态 | `docs/TODO.md` | 文档状态与仓库现状一致 |
|
||||||
| [~] | S6-04 | 清理临时描述、过期 TODO、命名偏差 | `docs/`<br>`Assets/GameMain/Scripts/` | `CodeX-TODO` 与 `TODO` 已完成同步;其余旧设计文档仍待后续收尾 |
|
| [x] | S6-04 | 清理临时描述、过期 TODO、命名偏差 | `docs/`<br>`Assets/GameMain/Scripts/` | `CodeX-TODO`、`TODO`、`TagSystemDesign`、`MVP-Scope`、`GameDesign` 已完成主要口径同步;其余文档仅保留后续阶段设计描述 |
|
||||||
|
|
||||||
|
> 2026-03-11 更新:`S6` 的自动化测试实际落点已统一到 `Assets/Tests/EditMode`,不再使用 `Assets/GameMain/Tests/` 作为当前仓库测试路径口径。
|
||||||
|
>
|
||||||
|
> 2026-03-11 更新:本轮已补 `CombatSettlementServiceTests` 的结算金币、奖励选择失败分支、奖励库存写入与提交幂等测试;同时补 `ProcedureMainServicesTests` 的非终态快照替换行为、`ProcedureMainParticipantTowerCleanupServiceTests` 的“缺件不移出/空结果提示”分支,以及 `TowerTagAggregationServiceTests`、`TagCombatRuntimeTests` 的基础工具回归。你已确认本轮在 Unity Test Runner 中重新实跑后全部通过。
|
||||||
|
>
|
||||||
|
> 2026-03-11 更新:`S6-01` 已完成主链路流程编排回归收口。新增 `ProcedureMainFlowTests`,覆盖 `OnNodeEnter` 的 Hub -> NodeActive 切换、`OnNodeComplete` 的正常推进 / 异常回流 / Boss 完成、损坏塔清理不覆盖 Run 完成弹窗,以及 `OnNodeMapNodeEnterRequested` 的主要忽略条件与战斗入口阻塞对话框分支;你已确认本轮在 Unity Test Runner 中重新实跑后全部通过。
|
||||||
|
>
|
||||||
|
> 2026-03-11 更新:`S6-02` 已完成规则侧测试收口。除已有的出战合法性、品质、组件 Tag 生成、塔 Tag 聚合与 Tag 战斗运行时测试外,本轮继续补了 `CombatSettlementServiceTests` 的失败结算掉落金保留、奖励组件并包闭环,以及 `PlayerInventoryTowerAssemblyServiceTests` 的“三组件组塔 -> 品质 / 属性数组 / TagRuntimes / Tags”装配回归;你已确认本轮在 Unity Test Runner 中重新实跑后全部通过。
|
||||||
|
>
|
||||||
|
> 2026-03-11 更新:`S6-04` 已完成。`MVP-Scope.md`、`GameDesign.md`、`TODO.md`、`CodeX-TODO.md` 与 `TagSystemDesign.md` 现已统一回写到当前真实实现口径:战斗奖励改为“敌人概率掉落 + 结算金币 + 满血额外 3 选 1”,商店改为“当前 M1 只保留基础购买”,并移除了“开局二选三组件组两塔”“M1 不做耐久 / 红色品质”“组件/配件混写”等已过期描述。当前 docs 下未再发现会误导 M1 开发的主冲突项,剩余内容仅保留明确标注为后续阶段的设计预案。
|
||||||
|
|
||||||
## 推荐执行顺序
|
## 推荐执行顺序
|
||||||
|
|
||||||
1. `S1 ~ S3` 已完成,不再作为当前主阻塞项。
|
1. `S1 ~ S3` 已完成,不再作为当前主阻塞项。
|
||||||
2. `S4` 只剩 `S4-07` 的最终收口;当前优先任务是确认 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 是否就是本阶段正式方案,并把命名与文档解释统一。
|
2. `S4` 已完成,当前不再把三表方案作为 M1 主阻塞项。
|
||||||
3. `S5` 已完成,当前无需继续按旧耐久设计拆任务。
|
3. `S5` 已完成,当前无需继续按旧耐久设计拆任务。
|
||||||
4. `S6` 转入“测试补强 + 旧文档清理”收尾;重点不再是补主功能,而是把现状固化并避免后续继续按旧口径推进。
|
4. `S6` 转入“测试补强 + 旧文档清理”收尾;重点不再是补主功能,而是把现状固化并避免后续继续按旧口径推进。
|
||||||
5. `S6` 完成后,再决定是否把维修、自动销毁、耐久折价、完整 `TagRule` 元数据等长期设计拆成新的增强阶段。
|
5. `S6` 完成后,再决定是否把维修、自动销毁、耐久折价、更多 Tag 元数据配置化等长期设计拆成新的增强阶段。
|
||||||
|
|
||||||
## 本周建议开工顺序
|
## 本周建议开工顺序
|
||||||
|
|
||||||
1. 先完成 `S4-07` 的最终口径收口,把三表方案、命名和文档解释彻底统一
|
1. 先补 `S6-01 ~ S6-02` 的主链路 / 规则回归测试,把当前 M1 口径固化下来
|
||||||
2. 再补 `S6-01 ~ S6-02` 的主链路 / 规则回归测试,把当前 M1 口径固化下来
|
2. 再继续处理 `S6-04`,清理 `MVP-Scope`、`GameDesign` 等仍保留旧范围描述的文档
|
||||||
3. 最后继续处理 `S6-04`,清理 `MVP-Scope`、`GameDesign` 等仍保留旧范围描述的文档
|
3. 最后再决定是否开启新的增强阶段,而不是回头重开 `S4-07`
|
||||||
|
|
||||||
## 备注
|
## 备注
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,40 +4,40 @@
|
||||||
塔防肉鸽
|
塔防肉鸽
|
||||||
|
|
||||||
## 游戏主循环
|
## 游戏主循环
|
||||||
1. 进入游戏选择初始的塔防配件,组装初始的防御塔开始游戏
|
1. 进入游戏,使用当前样例库存中的组件组装初始防御塔并开始游戏
|
||||||
2. 在关卡中玩家选择不同的节点推进关卡:
|
2. 在关卡中玩家选择不同的节点推进关卡:
|
||||||
- 战斗节点:布置防御塔抵御敌人进攻并收集防御塔组件与配件
|
- 战斗节点:布置防御塔抵御敌人进攻并收集防御塔组件
|
||||||
- 事件节点:随机事件(不含战斗)
|
- 事件节点:随机事件(不含战斗)
|
||||||
- 商店节点:购买/出售防御塔配件/组件,购买关卡内道具
|
- 商店节点:购买防御塔组件
|
||||||
3. 节点后调整:玩家可使用组件和配件自由组装防御塔以抵御更强的敌人进攻
|
3. 节点后调整:玩家可使用三组件自由组装防御塔以抵御更强的敌人进攻
|
||||||
4. 开始新一轮关卡
|
4. 开始新一轮关卡
|
||||||
|
|
||||||
## 具体说明
|
## 具体说明
|
||||||
### 一、战前准备:
|
### 一、战前准备:
|
||||||
1. 选择 3 种组件任意两个以完成 2 个防御塔的组装
|
1. 当前 M1 以样例库存和仓库内三组件组装链为准,不再要求独立的“开局二选三组件并组出两座塔”前置流程
|
||||||
|
|
||||||
### 二、节点:
|
### 二、节点:
|
||||||
1. 游戏内有多种主题地图,每种主题地图视为一大关,不同的主题地图敌人掉落以及关卡奖励的组件/配件属性偏好不同,玩家在完成一大关后可自由选择下一大关的主题地图,关卡难度由玩家攻略的顺序决定,与主题地图本身没有太大关系
|
1. 当前 M1 只实现单一固定 Run:每个大关固定 10 个节点,最后一个节点固定为 Boss 战斗节点;多主题地图与大关间选择保留到后续阶段
|
||||||
2. 主题地图示例:
|
2. 主题地图示例(后续阶段):
|
||||||
- 火山:高温(战斗节点中会随机触发火山喷发在地图上生成岩浆格)
|
- 火山:高温(战斗节点中会随机触发火山喷发在地图上生成岩浆格)
|
||||||
- 对于防御塔:部分组件在该高温下能发挥更强/更弱性能,若岩浆生成在防御塔上将会进一步强化高温对组件性能的影响
|
- 对于防御塔:部分组件在该高温下能发挥更强/更弱性能,若岩浆生成在防御塔上将会进一步强化高温对组件性能的影响
|
||||||
- 对于敌人:敌人多具有火焰抗性,部分敌人能在岩浆格上更快的行走
|
- 对于敌人:敌人多具有火焰抗性,部分敌人能在岩浆格上更快的行走
|
||||||
- 山地:地势起伏(地图格子有额外的高度条件,悬崖格子)
|
- 山地:地势起伏(地图格子有额外的高度条件,悬崖格子)
|
||||||
- 对于防御塔:不同高度的攻击会有攻击范围变化,高打低范围加强,低打高范围缩小
|
- 对于防御塔:不同高度的攻击会有攻击范围变化,高打低范围加强,低打高范围缩小
|
||||||
- 对于敌人:不同高度会有移动速度的差异,高往低走移速加快,低往高走移速降低,陆地敌人被击退到悬崖格子立即死亡
|
- 对于敌人:不同高度会有移动速度的差异,高往低走移速加快,低往高走移速降低,陆地敌人被击退到悬崖格子立即死亡
|
||||||
3. 每个大关有 10 个节点,最后一个节点固定为 Boss 战斗格,完成胜利波次后就会结束该大关,选择并进入下一大关
|
3. 当前每个大关有 10 个固定节点,完成 Boss 节点后进入正式结束态并返回主菜单,不在 M1 内继续展开下一大关选择
|
||||||
|
|
||||||
#### 1. 战斗节点
|
#### 1. 战斗节点
|
||||||
1. 玩家携带一定数量的防御塔进入关卡,在关卡内规定的位置布置防御塔,具体的游戏逻辑与一般的塔防游戏类似:
|
1. 玩家携带一定数量的防御塔进入关卡,在关卡内规定的位置布置防御塔,具体的游戏逻辑与一般的塔防游戏类似:
|
||||||
- 关卡开始有一些资源用于布置防御塔,击杀敌人获取资源来布置或升级防御塔。
|
- 关卡开始有一些资源用于布置防御塔,击杀敌人获取资源来布置或升级防御塔。
|
||||||
- 敌人会选择最短路径由出怪口向玩家基地前进(部分防御塔配件可以阻挡道路,以延长怪物的行进路线)
|
- 敌人会选择最短路径由出怪口向玩家基地前进;道路阻挡与更复杂的路径改写机制保留到后续阶段
|
||||||
2. 击杀敌人除了获取关卡内使用的资源外,还有概率掉落防御塔组件/配件,每个小关卡以及大关卡结束后也会奖励防御塔组件/配件以及一些金币用于商店购买
|
2. 击杀敌人除了获取关卡内使用的资源外,还有概率掉落防御塔组件;每个小关卡结束后也会奖励组件与金币用于后续节点
|
||||||
3. 关卡内设定一个胜利波次,当玩家存活的波次达到后会根据基地生命产生不同的事件:
|
3. 关卡内设定一个胜利波次,当玩家存活的波次达到后会根据基地生命产生不同的事件:
|
||||||
- = 100% :获得额外 30% 的金币,以及额外 1 次组件 3 选 1
|
- = 100% :获得额外 30% 的金币,以及额外 1 次组件 3 选 1
|
||||||
- >= 80% :获得额外 10% 的金币
|
- >= 80% :获得额外 10% 的金币
|
||||||
- >= 50% :无加成
|
- >= 50% :无加成
|
||||||
- < 50%:损失携带防御塔耐久
|
- < 50%:损失携带防御塔耐久
|
||||||
4. 胜利后可以选择直接结束该关卡,也可以继续关卡以获得更多的组件/配件掉落,胜利波次后的波次将会迅速提升敌人的强度,同时较小幅度地提高爆率
|
4. “胜利后继续挑战”保留到后续阶段,当前 M1 以正常结算回流节点地图为准
|
||||||
|
|
||||||
#### 2. 事件节点
|
#### 2. 事件节点
|
||||||
玩家经历一些有选项的随机事件,获取额外的奖励/惩罚。
|
玩家经历一些有选项的随机事件,获取额外的奖励/惩罚。
|
||||||
|
|
@ -45,19 +45,19 @@
|
||||||
示例:
|
示例:
|
||||||
1. 玩家花费 100 金币赌马,有两个选择:(1) 30% 赢,赢了获得 250 金币。(2) 70% 赢,赢了获得 150 金币
|
1. 玩家花费 100 金币赌马,有两个选择:(1) 30% 赢,赢了获得 250 金币。(2) 70% 赢,赢了获得 150 金币
|
||||||
2. 玩家提供 2 个防御塔组件,获得 1 个不低于原来品质的防御塔组件
|
2. 玩家提供 2 个防御塔组件,获得 1 个不低于原来品质的防御塔组件
|
||||||
3. 消耗 2 个随机防御塔所有配件的耐久获得 150 金币
|
3. 耐久换金币事件保留到后续阶段;当前 M1 只保留最小耐久闭环
|
||||||
|
|
||||||
#### 3. 商店节点
|
#### 3. 商店节点
|
||||||
1. 玩家购买防御塔组件/配件,高品质的商品要价更高,售出价格为购买价格的一半,出售防御塔时价格为其所有配件价格之和再加10%,出售配件时耐久度会影响配件的价格
|
1. 当前 M1 只实现组件商店的基础购买;商店内出售、刷新、复杂定价、卖塔加成与耐久折价保留到后续阶段
|
||||||
2. 玩家购买道具,道具包含各种各样的效果,比如提高关卡内初始金币,提高敌人掉落金币倍率……
|
2. 道具系统保留到后续阶段
|
||||||
|
|
||||||
### 三、节点后的调整
|
### 三、节点后的调整
|
||||||
1. 组装防御塔:每个防御塔都必须有枪口(攻击组件)、轴承(旋转组件)、底座(功能组件)三个组件,防御塔的品质由组件决定。品质不仅决定了防御塔的基础属性,还可以为防御塔提供配件槽位(配件也能为防御塔提供额外的属性)以及 Tag 数量与品质
|
1. 组装防御塔:每个防御塔都必须有枪口(攻击组件)、轴承(旋转组件)、底座(功能组件)三个组件,防御塔的品质由组件决定。当前 M1 已实现三组件完整合法参战、品质统一计算、组件实例 Tag 生成、塔级 Tag 汇总,以及首发 7 个 Tag 的战斗效果
|
||||||
- 组件功能(某些组件还有全局性的属性倍率,比如高伤害穿透攻击的枪口会绑一个 0.5x攻击速度 的属性倍率来约束防御塔性能):
|
- 组件功能(某些组件还有全局性的属性倍率,比如高伤害穿透攻击的枪口会绑一个 0.5x攻击速度 的属性倍率来约束防御塔性能):
|
||||||
- 枪口(攻击组件):决定攻击伤害,攻击方式(普通子弹、范围伤害、穿透激光……)
|
- 枪口(攻击组件):决定攻击伤害,攻击方式(普通子弹、范围伤害、穿透激光……)
|
||||||
- 轴承(旋转组件):决定枪口转速(某些攻击方式只有对准敌人后才能进行攻击),攻击范围
|
- 轴承(旋转组件):决定枪口转速(某些攻击方式只有对准敌人后才能进行攻击),攻击范围
|
||||||
- 底座(功能组件):决定攻击频率,攻击属性(火焰、毒素、冰……)
|
- 底座(功能组件):决定攻击频率,攻击属性(火焰、毒素、冰……)
|
||||||
- 品质计算:每个组件提供一定的品质权重(白:1,绿:2,蓝:3,紫:4,红:5),比如三个配件是 2 绿 1 白,那么防御塔的品质是 (2+2+1)/3=1.67,四舍五入后为 2 也就是绿色品质。
|
- 品质计算:每个组件提供一定的品质权重(白:1,绿:2,蓝:3,紫:4,红:5),比如三个组件是 2 绿 1 白,那么防御塔的品质是 (2+2+1)/3=1.67,四舍五入后为 2 也就是绿色品质。
|
||||||
- 各品质的配件槽:白/绿:0,蓝:1,紫:2,红:3。
|
- 各品质的配件槽与更深的配件系统保留到后续阶段
|
||||||
- 组件 Tag:不同的品质随机出现的 Tag 等级与数量不同,例如白色组件有 50% 不出 Tag,40% 出 1 级 Tag,10% 出 2 级 Tag
|
- 组件 Tag 当前正式采用 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 三表方案:`Tag.txt` 负责基础字典、生成门槛、权重与启用态,`RarityTagBudget.txt` 负责按品质的 Tag 数量预算,`TagConfig.txt` 负责触发阶段、描述与效果参数
|
||||||
2. 拆解防御塔:组件/配件有耐久属性,组件/配件的耐久与其提供的属性相关,50% 耐久时就只能提供原来一半的属性值,当耐久为 0 时将会直接销毁
|
2. 拆解与耐久:当前 M1 只保留最小耐久闭环,即战斗后按参战塔真实扣减、`0` 耐久失效并拦截参战 / 战斗入口;连续属性衰减、自动销毁与维修系统保留到后续阶段
|
||||||
|
|
|
||||||
|
|
@ -49,12 +49,12 @@
|
||||||
|
|
||||||
战斗奖励:
|
战斗奖励:
|
||||||
|
|
||||||
* 战斗结束提供 3选1 组件奖励
|
* 敌人可概率掉落组件与金币
|
||||||
* 固定金币奖励
|
* 战斗结算提供金币奖励
|
||||||
|
* 基地满血通关时额外提供 1 次组件 3选1 奖励
|
||||||
|
|
||||||
不做:
|
不做:
|
||||||
|
|
||||||
* 随机掉落
|
|
||||||
* 多路径
|
* 多路径
|
||||||
* 地图机制
|
* 地图机制
|
||||||
* 精细敌人AI
|
* 精细敌人AI
|
||||||
|
|
@ -86,13 +86,13 @@
|
||||||
|
|
||||||
实现:
|
实现:
|
||||||
|
|
||||||
* 显示3个随机组件
|
* 显示4个随机组件
|
||||||
* 可刷新一次
|
* 可购买组件
|
||||||
* 可购买
|
|
||||||
* 可出售组件
|
|
||||||
|
|
||||||
不做:
|
不做:
|
||||||
|
|
||||||
|
* 商店内出售组件
|
||||||
|
* 商店刷新
|
||||||
* 动态定价
|
* 动态定价
|
||||||
* 经济平衡
|
* 经济平衡
|
||||||
* 道具系统
|
* 道具系统
|
||||||
|
|
@ -109,20 +109,19 @@
|
||||||
* 塔槽位
|
* 塔槽位
|
||||||
* 拖拽组装
|
* 拖拽组装
|
||||||
* 组件替换
|
* 组件替换
|
||||||
* 实时属性预览
|
* 基础属性与 Tag 展示
|
||||||
|
|
||||||
组件结构(精简):
|
组件结构(精简):
|
||||||
|
|
||||||
* 枪口
|
* 枪口
|
||||||
* 轴承
|
* 轴承
|
||||||
* 底座
|
* 底座
|
||||||
* 基础Tag系统(只保留 6~8 个)
|
* 基础 Tag 系统(首发 7 个:`Fire`、`Ice`、`Crit`、`Execution`、`Shatter`、`Inferno`、`AbsoluteZero`)
|
||||||
|
|
||||||
不做:
|
不做:
|
||||||
|
|
||||||
* 耐久系统
|
* 完整维修 / 折价 / 自动销毁系统
|
||||||
* 稀有品质红色
|
* 高级 Tag 联动
|
||||||
* 高级Tag联动
|
|
||||||
* 复杂触发矩阵
|
* 复杂触发矩阵
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -152,8 +151,8 @@
|
||||||
|
|
||||||
* 总组件数量 ≤ 20
|
* 总组件数量 ≤ 20
|
||||||
* Tag数量 ≤ 8
|
* Tag数量 ≤ 8
|
||||||
* 品质等级:白 / 绿 / 蓝 / 紫(不做红)
|
* 品质等级:白 / 绿 / 蓝 / 紫 / 红
|
||||||
* 每塔最大槽位:2
|
* 仅要求三组件主结构,不扩展更深的配件槽系统
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -184,7 +183,7 @@
|
||||||
2. 节点之间流程无阻塞
|
2. 节点之间流程无阻塞
|
||||||
3. 组件可组装并生效
|
3. 组件可组装并生效
|
||||||
4. 战斗可胜可负
|
4. 战斗可胜可负
|
||||||
5. 商店可购买/出售
|
5. 商店可购买
|
||||||
6. 不出现流程死锁
|
6. 不出现流程死锁
|
||||||
7. 无严重崩溃或逻辑错误
|
7. 无严重崩溃或逻辑错误
|
||||||
|
|
||||||
|
|
|
||||||
26
docs/TODO.md
26
docs/TODO.md
|
|
@ -12,9 +12,9 @@
|
||||||
## M1 当前口径(2026-03-11)
|
## M1 当前口径(2026-03-11)
|
||||||
|
|
||||||
- 当前仓库已经具备 `ProcedureMain + NodeMapForm + CombatNode + EventNode + ShopNode` 的主流程 Run 闭环,可以从主菜单进入游戏,完成固定 10 节点流程,并在 Boss 结算后进入正式结束态并回到主菜单。
|
- 当前仓库已经具备 `ProcedureMain + NodeMapForm + CombatNode + EventNode + ShopNode` 的主流程 Run 闭环,可以从主菜单进入游戏,完成固定 10 节点流程,并在 Boss 结算后进入正式结束态并回到主菜单。
|
||||||
- `NodeMapForm` 已满足 MVP 所需的节点流程界面;M1 当前真正未完全收口的只剩 `P0-11` 对应的三表方案命名、文档解释与少量配置映射收尾。
|
- `NodeMapForm` 已满足 MVP 所需的节点流程界面;M1 主功能侧已完成收口,后续重点转入测试补强与旧文档清理。
|
||||||
- `P0-10` 的“三组件完整合法参战”主链已完成:当前参战分配、战斗入口最终校验、失败原因与拦截提示都已接入统一合法性判断入口,现阶段不再把它视为 M1 功能缺口。
|
- `P0-10` 的“三组件完整合法参战”主链已完成:当前参战分配、战斗入口最终校验、失败原因与拦截提示都已接入统一合法性判断入口,现阶段不再把它视为 M1 功能缺口。
|
||||||
- `P0-11` 已不再只是“局部展示字段”:当前品质计算、Tag 生成、塔级 Tag 汇总、首发 7 个 Tag 的战斗效果、以及 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 三表驱动链路都已存在;剩余缺口主要是 `S4-07` 的最终文档口径与少量配置收口,而不是主功能缺失。
|
- `P0-11` 已完成收口:当前品质计算、Tag 生成、塔级 Tag 汇总、首发 7 个 Tag 的战斗效果、以及 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 三表驱动链路都已落地,代码默认值、运行时消费链与文档口径现已统一。
|
||||||
- `P0-12` 的“最小耐久闭环”主链已落地:当前已实现战斗后按参战塔真实扣减耐久、`0` 耐久失效并拦截参战/战斗入口、仓库详情损坏提示,以及节点结束后自动将损坏塔移出参战区并弹窗说明。
|
- `P0-12` 的“最小耐久闭环”主链已落地:当前已实现战斗后按参战塔真实扣减耐久、`0` 耐久失效并拦截参战/战斗入口、仓库详情损坏提示,以及节点结束后自动将损坏塔移出参战区并弹窗说明。
|
||||||
|
|
||||||
## 里程碑 M1(P0)- 最小可玩闭环
|
## 里程碑 M1(P0)- 最小可玩闭环
|
||||||
|
|
@ -22,17 +22,17 @@
|
||||||
| 状态 | ID | 任务 | 交付物路径 | 验收标准 |
|
| 状态 | ID | 任务 | 交付物路径 | 验收标准 |
|
||||||
|-----|-------|-------------------------------------|----------------------------------------------------------------------------------------|--------------------------------------------------------------------------|
|
|-----|-------|-------------------------------------|----------------------------------------------------------------------------------------|--------------------------------------------------------------------------|
|
||||||
| [x] | P0-01 | 冻结 MVP 范围(只保留:战斗节点/事件节点/商店节点/节点后组装) | `docs/MVP-Scope.md` | 明确“做/不做”清单,团队评审通过 |
|
| [x] | P0-01 | 冻结 MVP 范围(只保留:战斗节点/事件节点/商店节点/节点后组装) | `docs/MVP-Scope.md` | 明确“做/不做”清单,团队评审通过 |
|
||||||
| [x] | P0-02 | 补齐数据表:组件、配件、敌人、波次、节点、事件、商店商品 | `Assets/GameMain/DataTables/*.txt` | 游戏启动可无报错加载全部新增表 |
|
| [x] | P0-02 | 补齐数据表:组件、敌人、波次、节点、事件、商店商品 | `Assets/GameMain/DataTables/*.txt` | 游戏启动可无报错加载全部新增表 |
|
||||||
| [x] | P0-03 | 新增/完善 DataRow 解析类 | `Assets/GameMain/Scripts/DataTable/*.cs` | 每个新增表都有对应 DR 类并成功解析 |
|
| [x] | P0-03 | 新增/完善 DataRow 解析类 | `Assets/GameMain/Scripts/DataTable/*.cs` | 每个新增表都有对应 DR 类并成功解析 |
|
||||||
| [x] | P0-04 | 建立单局 Run 状态模型(金币、生命、库存、当前节点) | `Assets/GameMain/Scripts/Procedure/` | 已有 `RunState` / 推进模型 / 固定序列 / EditMode 测试,并已接入 `ProcedureMain` 主流程闭环 |
|
| [x] | P0-04 | 建立单局 Run 状态模型(金币、生命、库存、当前节点) | `Assets/GameMain/Scripts/Procedure/` | 已有 `RunState` / 推进模型 / 固定序列 / EditMode 测试,并已接入 `ProcedureMain` 主流程闭环 |
|
||||||
| [x] | P0-05 | 实现 10 节点地图生成(最后节点固定 Boss) | `Assets/GameMain/Scripts/Procedure/`<br>`Assets/GameMain/Scripts/UI/Game/` | 已有固定 10 节点序列、当前节点限制、Boss 终点链路与节点流程入口;MVP 不额外要求节点地图选择 UI 或表现层打磨 |
|
| [x] | P0-05 | 实现 10 节点地图生成(最后节点固定 Boss) | `Assets/GameMain/Scripts/Procedure/`<br>`Assets/GameMain/Scripts/UI/Game/` | 已有固定 10 节点序列、当前节点限制、Boss 终点链路与节点流程入口;MVP 不额外要求节点地图选择 UI 或表现层打磨 |
|
||||||
| [x] | P0-06 | 实现节点选择与跳转流程(战斗/事件/商店) | `Assets/GameMain/Scripts/Procedure/` | 战斗 / 事件 / 商店已统一接入节点进入、完成、异常回流;Boss 完成后会进入正式结束态并返回主菜单 |
|
| [x] | P0-06 | 实现节点选择与跳转流程(战斗/事件/商店) | `Assets/GameMain/Scripts/Procedure/` | 战斗 / 事件 / 商店已统一接入节点进入、完成、异常回流;Boss 完成后会进入正式结束态并返回主菜单 |
|
||||||
| [x] | P0-07 | 战斗节点基础玩法:放置塔、出怪、基地扣血、胜负判定 | `Assets/GameMain/Scripts/Entity/`<br>`Assets/GameMain/Scripts/Scene/` | 可完整打一场并得到胜利/失败结果 |
|
| [x] | P0-07 | 战斗节点基础玩法:放置塔、出怪、基地扣血、胜负判定 | `Assets/GameMain/Scripts/Entity/`<br>`Assets/GameMain/Scripts/Scene/` | 可完整打一场并得到胜利/失败结果 |
|
||||||
| [x] | P0-08 | 胜利波次与结算规则(100/80/50/<50) | `Assets/GameMain/Scripts/Procedure/` | 结算奖励与惩罚严格匹配设计文档 |
|
| [x] | P0-08 | 胜利波次与结算规则(100/80/50/<50) | `Assets/GameMain/Scripts/Procedure/` | 结算奖励与惩罚严格匹配设计文档 |
|
||||||
| [x] | P0-09 | 敌人掉落与关卡奖励(组件/配件/金币) | `Assets/GameMain/Scripts/Entity/` | 战斗结束能发放掉落并写入库存 |
|
| [x] | P0-09 | 敌人掉落与关卡奖励(组件/金币) | `Assets/GameMain/Scripts/Entity/` | 战斗结束能发放掉落并写入库存 |
|
||||||
| [x] | P0-10 | 节点后组装:枪口/轴承/底座三组件约束 | `Assets/GameMain/Scripts/Entity/`<br>`Assets/GameMain/Scripts/UI/Templates/GameScene/` | 已完成“三组件完整合法参战”的统一校验链、战斗入口最终拦截与失败原因反馈;不再作为当前 M1 功能缺口 |
|
| [x] | P0-10 | 节点后组装:枪口/轴承/底座三组件约束 | `Assets/GameMain/Scripts/Entity/`<br>`Assets/GameMain/Scripts/UI/Templates/GameScene/` | 已完成“三组件完整合法参战”的统一校验链、战斗入口最终拦截与失败原因反馈;不再作为当前 M1 功能缺口 |
|
||||||
| [~] | P0-11 | 品质 / Tag 规则统一入口(白绿蓝紫红) | `Assets/GameMain/Scripts/Definition/`<br>`Assets/GameMain/Scripts/Entity/` | 当前已完成品质统一、Tag 生成/汇总/展示与首发 7 个 Tag 的战斗生效;剩余工作主要是三表方案的最终收口与文档同步 |
|
| [x] | P0-11 | 品质 / Tag 规则统一入口(白绿蓝紫红) | `Assets/GameMain/Scripts/Definition/`<br>`Assets/GameMain/Scripts/Entity/` | 已完成品质统一、Tag 生成/汇总/展示、首发 7 个 Tag 的战斗生效,以及三表方案的正式收口与文档同步 |
|
||||||
| [x] | P0-12 | 组件/配件耐久最小闭环 | `Assets/GameMain/Scripts/Procedure/`<br>`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`<br>`Assets/GameMain/Scripts/UI/` | 已实现战斗后真实扣减、`0` 耐久失效拦截、仓库损坏提示与节点结束后的自动移出参战区;自动销毁/维修系统保留到后续阶段 |
|
| [x] | P0-12 | 组件耐久最小闭环 | `Assets/GameMain/Scripts/Procedure/`<br>`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`<br>`Assets/GameMain/Scripts/UI/` | 已实现战斗后真实扣减、`0` 耐久失效拦截、仓库损坏提示与节点结束后的自动移出参战区;自动销毁/维修系统保留到后续阶段 |
|
||||||
|
|
||||||
## 里程碑 M2(P1)- 核心深度
|
## 里程碑 M2(P1)- 核心深度
|
||||||
|
|
||||||
|
|
@ -40,13 +40,13 @@
|
||||||
|-----|-------|-------------------------------|-------------------------------------------------------------------------------------------|--------------------|
|
|-----|-------|-------------------------------|-------------------------------------------------------------------------------------------|--------------------|
|
||||||
| [ ] | P1-01 | 事件节点系统(选项、概率、奖励/惩罚执行器) | `Assets/GameMain/Scripts/Procedure/`<br>`Assets/GameMain/Scripts/UI/Templates/GameScene/` | 可配置并执行至少 10 个事件 |
|
| [ ] | P1-01 | 事件节点系统(选项、概率、奖励/惩罚执行器) | `Assets/GameMain/Scripts/Procedure/`<br>`Assets/GameMain/Scripts/UI/Templates/GameScene/` | 可配置并执行至少 10 个事件 |
|
||||||
| [ ] | P1-02 | 落地设计中的 3 个示例事件(赌马/组件交换/耐久换金币) | `Assets/GameMain/DataTables/*.txt` | 三个事件可在局内完整触发与结算 |
|
| [ ] | P1-02 | 落地设计中的 3 个示例事件(赌马/组件交换/耐久换金币) | `Assets/GameMain/DataTables/*.txt` | 三个事件可在局内完整触发与结算 |
|
||||||
| [ ] | P1-03 | 商店节点:购买/出售组件与配件 | `Assets/GameMain/Scripts/UI/Templates/GameScene/` | 买卖后库存与金币实时正确更新 |
|
| [ ] | P1-03 | 商店节点:购买/出售组件 | `Assets/GameMain/Scripts/UI/Templates/GameScene/` | 买卖后库存与金币实时正确更新 |
|
||||||
| [ ] | P1-04 | 商店定价规则:买价、半价回收、卖塔+10%、耐久折价 | `Assets/GameMain/Scripts/Utility/`<br>`Assets/GameMain/Scripts/Entity/` | 各种交易结果符合公式 |
|
| [ ] | P1-04 | 商店定价规则:买价、半价回收、卖塔+10%、耐久折价 | `Assets/GameMain/Scripts/Utility/`<br>`Assets/GameMain/Scripts/Entity/` | 各种交易结果符合公式 |
|
||||||
| [ ] | P1-05 | 道具系统(影响初始金币、掉金倍率等) | `Assets/GameMain/Scripts/Entity/`<br>`Assets/GameMain/DataTables/*.txt` | 至少 5 个道具可叠加生效 |
|
| [ ] | P1-05 | 道具系统(影响初始金币、掉金倍率等) | `Assets/GameMain/Scripts/Entity/`<br>`Assets/GameMain/DataTables/*.txt` | 至少 5 个道具可叠加生效 |
|
||||||
| [ ] | P1-06 | 战斗后“继续挑战”机制(高强度高爆率) | `Assets/GameMain/Scripts/Procedure/` | 选择继续后敌人强度明显提升且爆率提升 |
|
| [ ] | P1-06 | 战斗后“继续挑战”机制(高强度高爆率) | `Assets/GameMain/Scripts/Procedure/` | 选择继续后敌人强度明显提升且爆率提升 |
|
||||||
| [ ] | P1-07 | 火山主题规则(高温、岩浆格、抗火敌人) | `Assets/GameMain/Scripts/Scene/`<br>`Assets/GameMain/Scripts/Entity/` | 岩浆效果可视且会改变战斗结果 |
|
| [ ] | P1-07 | 火山主题规则(高温、岩浆格、抗火敌人) | `Assets/GameMain/Scripts/Scene/`<br>`Assets/GameMain/Scripts/Entity/` | 岩浆效果可视且会改变战斗结果 |
|
||||||
| [ ] | P1-08 | 山地主题规则(高度、悬崖、位移致死) | `Assets/GameMain/Scripts/Scene/`<br>`Assets/GameMain/Scripts/Entity/` | 高低地形影响攻防与移速,悬崖击退生效 |
|
| [ ] | P1-08 | 山地主题规则(高度、悬崖、位移致死) | `Assets/GameMain/Scripts/Scene/`<br>`Assets/GameMain/Scripts/Entity/` | 高低地形影响攻防与移速,悬崖击退生效 |
|
||||||
| [ ] | P1-09 | 主题地图掉落偏好(按主题偏置组件/配件) | `Assets/GameMain/DataTables/*.txt`<br>`Assets/GameMain/Scripts/Entity/` | 不同主题统计掉落分布显著不同 |
|
| [ ] | P1-09 | 主题地图掉落偏好(按主题偏置组件) | `Assets/GameMain/DataTables/*.txt`<br>`Assets/GameMain/Scripts/Entity/` | 不同主题统计掉落分布显著不同 |
|
||||||
| [ ] | P1-10 | 大关完成后可选下一主题地图 | `Assets/GameMain/Scripts/Procedure/`<br>`Assets/GameMain/Scripts/UI/Templates/GameScene/` | 通关后至少可在 2 个主题间选择 |
|
| [ ] | P1-10 | 大关完成后可选下一主题地图 | `Assets/GameMain/Scripts/Procedure/`<br>`Assets/GameMain/Scripts/UI/Templates/GameScene/` | 通关后至少可在 2 个主题间选择 |
|
||||||
|
|
||||||
## 里程碑 M3(P2)- 打磨与上线准备
|
## 里程碑 M3(P2)- 打磨与上线准备
|
||||||
|
|
@ -58,14 +58,14 @@
|
||||||
| [ ] | P2-03 | 组装/拆解交互优化(预览属性变化) | `Assets/GameMain/Scripts/UI/Templates/GameScene/` | 改装前后差异可视化展示 |
|
| [ ] | P2-03 | 组装/拆解交互优化(预览属性变化) | `Assets/GameMain/Scripts/UI/Templates/GameScene/` | 改装前后差异可视化展示 |
|
||||||
| [ ] | P2-04 | 存档与读档(局外货币、库存、解锁进度) | `Assets/GameMain/Scripts/Utility/`<br>`Assets/GameMain/Scripts/Procedure/` | 重启游戏后进度一致恢复 |
|
| [ ] | P2-04 | 存档与读档(局外货币、库存、解锁进度) | `Assets/GameMain/Scripts/Utility/`<br>`Assets/GameMain/Scripts/Procedure/` | 重启游戏后进度一致恢复 |
|
||||||
| [ ] | P2-05 | 平衡性首轮调参(敌人曲线、经济曲线、掉落曲线) | `Assets/GameMain/DataTables/*.txt` | 3 局平均时长与胜率落在预期区间 |
|
| [ ] | P2-05 | 平衡性首轮调参(敌人曲线、经济曲线、掉落曲线) | `Assets/GameMain/DataTables/*.txt` | 3 局平均时长与胜率落在预期区间 |
|
||||||
| [~] | P2-06 | 最低限度自动化测试(公式与关键流程) | `Assets/` 下新增 `Editor`/`Runtime` 测试 | 已有 `Assets/Tests/EditMode` 覆盖 `RunState`、`NodeCompleteEventArgs`、`ProcedureMain` 关键服务;更广的公式与流程回归仍待补齐 |
|
| [~] | P2-06 | 最低限度自动化测试(公式与关键流程) | `Assets/Tests/` 下的 `EditMode`/`PlayMode` 测试 | 已有 `Assets/Tests/EditMode` 覆盖 `RunState`、`NodeCompleteEventArgs`、`ProcedureMain` 关键服务,以及 `CombatSettlement`、损坏塔清理、品质、Tag、耐久等关键规则;更广的流程编排回归与 PlayMode 覆盖仍待补齐 |
|
||||||
| [ ] | P2-07 | 性能与稳定性检查(长局、内存、异常日志) | `docs/PerformanceReport.md` | 连续游玩 30 分钟无阻断性问题 |
|
| [ ] | P2-07 | 性能与稳定性检查(长局、内存、异常日志) | `docs/PerformanceReport.md` | 连续游玩 30 分钟无阻断性问题 |
|
||||||
|
|
||||||
## 本周建议开工顺序
|
## 本周建议开工顺序
|
||||||
|
|
||||||
1. 先完成 `P0-11` 的三表口径收口,把 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 的实际消费链、命名和文档解释彻底对齐
|
1. 先补 `S6` 侧的主链路 / 规则回归测试,把当前 M1 口径固化下来
|
||||||
2. 再补 `S6` 侧的主链路 / 规则回归测试,把当前 M1 口径固化下来
|
2. 再继续同步 `MVP-Scope.md`、`GameDesign.md` 等仍保留旧范围描述的文档,避免后续继续按旧耐久 / Tag 口径推进
|
||||||
3. 最后继续同步 `MVP-Scope.md`、`GameDesign.md` 等仍保留旧范围描述的文档,避免后续继续按旧耐久 / Tag 口径推进
|
3. 最后再决定是否把更多 Tag 元数据配置化、维修系统等长期设计拆成新的增强阶段
|
||||||
|
|
||||||
## 设计优化 Backlog(新增)
|
## 设计优化 Backlog(新增)
|
||||||
|
|
||||||
|
|
@ -83,7 +83,7 @@
|
||||||
## 小目标
|
## 小目标
|
||||||
1. CombatNodeComponent:战斗节点的逻辑补全
|
1. CombatNodeComponent:战斗节点的逻辑补全
|
||||||
- 完整塔防流程:初始硬币,敌人掉落硬币数量、基地生命与失败设计
|
- 完整塔防流程:初始硬币,敌人掉落硬币数量、基地生命与失败设计
|
||||||
- 敌人随机掉落局外资源(金币、组件/配件)
|
- 敌人随机掉落局外资源(金币、组件)
|
||||||
- 无限波次下敌人血量、资源爆率的增加
|
- 无限波次下敌人血量、资源爆率的增加
|
||||||
- 基地血量满足一定条件的额外奖励
|
- 基地血量满足一定条件的额外奖励
|
||||||
2. ShopNodeComponent:商店节点的逻辑补全
|
2. ShopNodeComponent:商店节点的逻辑补全
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,25 @@
|
||||||
# Tag System Design
|
# Tag System Design
|
||||||
|
|
||||||
最后更新:2026-03-09
|
最后更新:2026-03-11
|
||||||
|
|
||||||
> 目标:梳理 GeometryTD 的 Tag 系统设计问题,明确组件随机生成规则、战斗生效方式,以及后续实现顺序。
|
> 目标:固定 GeometryTD 当前 M1 的 Tag 系统正式口径,明确三表配置职责、实际消费链,以及后续增强边界。
|
||||||
|
|
||||||
## 1. 当前现状
|
## 1. 当前现状
|
||||||
|
|
||||||
当前仓库已经有 Tag 相关的基础数据结构,但还没有形成完整系统:
|
当前仓库已经形成 M1 所需的 Tag 最小闭环:
|
||||||
|
|
||||||
- `TagType` 已定义 12 个 Tag,见 `Assets/GameMain/Scripts/Definition/Enum/TagType.cs`
|
- `TagType` 已定义 12 个 Tag,见 `Assets/GameMain/Scripts/Definition/Enum/TagType.cs`
|
||||||
- `Tag.txt` 已定义 Tag 名称与 `MinRarity`
|
- `Tag.txt` 已定义 Tag 名称、`MinRarity`、`Weight`、`IsImplemented`
|
||||||
- `MuzzleComp.txt`、`BearingComp.txt`、`BaseComp.txt` 已定义 `PossibleTag`
|
- `MuzzleComp.txt`、`BearingComp.txt`、`BaseComp.txt` 已定义 `PossibleTag`
|
||||||
- 组件实例、塔实例、UI 展示链路都已有 `Tags` 字段
|
- `RarityTagBudget.txt` 已定义按品质的 Tag 数量预算
|
||||||
|
- `TagConfig.txt` 已定义触发阶段、描述与首发 Tag 的核心参数
|
||||||
|
- 组件实例、塔实例、UI 展示链路都已接入统一的生成、汇总与说明消费口径
|
||||||
|
|
||||||
当前问题也很明确:
|
当前 `S4-07` 收尾只处理三件事:
|
||||||
|
|
||||||
- 组件实例现在更接近“直接复制 `PossibleTag` 全量候选”,不是真正的随机生成
|
- 明确三表就是 M1 的正式配置方案,不再保留 `TagRule` 作为当前未决项
|
||||||
- `Tag.txt` 只有最低品质限制,不足以描述权重、互斥、数量、效果参数
|
- 统一默认值、运行时加载与文档描述,避免回退到旧口径
|
||||||
- 塔组装阶段当前只是简单做并集去重,无法表达重复 Tag 是叠层还是浪费
|
- 把未首发 Tag 保持为占位扩展,而不是误判为当前必须实现的功能缺口
|
||||||
- 战斗链路里,子弹命中只传 `damage + AttackPropertyType`,还没有 Tag 结算入口
|
|
||||||
|
|
||||||
因此,当前 Tag 更像“静态标签字段”,而不是“有来源、有汇总、有战斗行为”的系统。
|
|
||||||
|
|
||||||
## 2. 设计目标
|
## 2. 设计目标
|
||||||
|
|
||||||
|
|
@ -99,15 +98,10 @@ Tag 系统需要同时满足四个目标:
|
||||||
|
|
||||||
### 4.2 运行时结构
|
### 4.2 运行时结构
|
||||||
|
|
||||||
后续运行时模型固定从当前单纯的 `TagType[]` 演进为两层结构:
|
当前 M1 运行时结构固定为两层:
|
||||||
|
|
||||||
```csharp
|
- 组件实例继续保存 `TagType[]`
|
||||||
public sealed class TagEntryData
|
- 塔实例保存汇总后的 `TagRuntimeData[]`
|
||||||
{
|
|
||||||
public TagType TagType { get; set; }
|
|
||||||
public int Stack { get; set; }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public sealed class TagRuntimeData
|
public sealed class TagRuntimeData
|
||||||
|
|
@ -119,7 +113,7 @@ public sealed class TagRuntimeData
|
||||||
|
|
||||||
职责划分:
|
职责划分:
|
||||||
|
|
||||||
- 组件实例保存 `TagEntryData[]`
|
- 组件实例保存 `TagType[]`
|
||||||
- 塔实例保存汇总后的 `TagRuntimeData[]`
|
- 塔实例保存汇总后的 `TagRuntimeData[]`
|
||||||
- UI 展示可以继续只展示 Tag 名称,但底层不再丢失层数信息
|
- UI 展示可以继续只展示 Tag 名称,但底层不再丢失层数信息
|
||||||
|
|
||||||
|
|
@ -160,23 +154,15 @@ Tag 随机应发生在“组件实例创建时”,而不是组塔时。
|
||||||
|
|
||||||
每个品质都保留独立的 Tag 数量预算,并由 `RarityTagBudget.txt` 驱动,而不是按概率硬编码在代码里。
|
每个品质都保留独立的 Tag 数量预算,并由 `RarityTagBudget.txt` 驱动,而不是按概率硬编码在代码里。
|
||||||
|
|
||||||
推荐默认值:
|
当前正式默认值:
|
||||||
|
|
||||||
| 品质 | 推荐数量 |
|
| 品质 | 推荐数量 |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| White | `0~1` |
|
| White | `0~1` |
|
||||||
| Green | `1` |
|
| Green | `0~2` |
|
||||||
| Blue | `1~2` |
|
| Blue | `1~3` |
|
||||||
| Purple | `2` |
|
| Purple | `1~3` |
|
||||||
| Red | `2~3` |
|
| Red | `2~4` |
|
||||||
|
|
||||||
当前若严格遵循 MVP 范围,也可以先只做到:
|
|
||||||
|
|
||||||
- `White`: `0~1`
|
|
||||||
- `Green`: `1`
|
|
||||||
- `Blue`: `1~2`
|
|
||||||
- `Purple`: `2`
|
|
||||||
- `Red`: 暂不开放或仅内部保留
|
|
||||||
|
|
||||||
### 5.4 随机规则
|
### 5.4 随机规则
|
||||||
|
|
||||||
|
|
@ -210,7 +196,7 @@ Tag 随机必须可复现。
|
||||||
|
|
||||||
固定流程:
|
固定流程:
|
||||||
|
|
||||||
1. 收集三组件的 `TagEntryData[]`
|
1. 收集三组件的 `TagType[]`
|
||||||
2. 按 `TagType` 分组
|
2. 按 `TagType` 分组
|
||||||
3. 累加 `Stack`
|
3. 累加 `Stack`
|
||||||
4. 输出塔级别的 `TagRuntimeData[]`
|
4. 输出塔级别的 `TagRuntimeData[]`
|
||||||
|
|
@ -444,14 +430,13 @@ Tag 触发阶段固定拆成四段:
|
||||||
|
|
||||||
而 `BurnSpread`、`Pierce`、`Overpenetrate`、`FreezeMask`、`IgniteBurst` 都作为后续扩展,不属于当前 `S4` 首发范围。
|
而 `BurnSpread`、`Pierce`、`Overpenetrate`、`FreezeMask`、`IgniteBurst` 都作为后续扩展,不属于当前 `S4` 首发范围。
|
||||||
|
|
||||||
## 11. 后续文档与代码动作
|
## 11. 当前收口结论
|
||||||
|
|
||||||
1. 先基于本设计回写 `docs/CodeX-TODO.md` 的 `S4-03` 边界
|
1. `S4-07` 已确认采用 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 三层表结构作为正式方案
|
||||||
2. 在 `S4-07` 中补齐并消费 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 三层表结构
|
2. `ComponentTagGenerationService` 已收口组件实例生成,不再直接复制 `PossibleTag`
|
||||||
3. 新增 `ComponentTagGenerationService`,先收口实例生成
|
3. `TowerTagAggregationService` 已收口塔级汇总,重复 Tag 会转为 `TagRuntimeData.TotalStack`
|
||||||
4. 新增 `TowerTagAggregationService`,替换当前简单并集逻辑
|
4. `AttackPayload + HitContext + TagEffectResolver` 已成为当前战斗内的统一 Tag 挂载点
|
||||||
5. 评估 `AttackPayload` 是否并入现有 `BulletData`,还是单独引入
|
5. 首发只实现固定 7 个基础 Tag,避免超出 `MVP-Scope`
|
||||||
6. 第一批只落固定 7 个基础 Tag,避免超出 `MVP-Scope`
|
|
||||||
|
|
||||||
## 12. 默认决策
|
## 12. 默认决策
|
||||||
|
|
||||||
|
|
@ -460,10 +445,11 @@ Tag 触发阶段固定拆成四段:
|
||||||
- Tag 在组件实例创建时随机,不在组塔时随机
|
- Tag 在组件实例创建时随机,不在组塔时随机
|
||||||
- `PossibleTag` 是候选池,不是最终实例值
|
- `PossibleTag` 是候选池,不是最终实例值
|
||||||
- 塔级 Tag 汇总保留 `Stack`
|
- 塔级 Tag 汇总保留 `Stack`
|
||||||
- 配置层采用 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 分层结构
|
- 配置层正式采用 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 分层结构
|
||||||
- 第一批战斗效果优先做状态类与数值修正类,不优先做穿透 / 爆炸 / 传播
|
- 第一批战斗效果优先做状态类与数值修正类,不优先做穿透 / 爆炸 / 传播
|
||||||
- MVP 正式首发集合固定为 `Fire`、`Ice`、`Crit`、`Execution`、`Shatter`、`Inferno`、`AbsoluteZero`
|
- MVP 正式首发集合固定为 `Fire`、`Ice`、`Crit`、`Execution`、`Shatter`、`Inferno`、`AbsoluteZero`
|
||||||
- `BurnSpread` 明确后移,不作为当前首发集合成员
|
- `BurnSpread` 明确后移,不作为当前首发集合成员
|
||||||
|
- `TagGroup` 当前只作为分类展示元数据,不进入运行时生成、汇总或战斗规则链
|
||||||
|
|
||||||
# 总体结构
|
# 总体结构
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
BuildSettlementContext_On_Loss_Keeps_DropGold_Without_SettlementBonus_Or_RewardSelection (0.004s)
|
||||||
|
---
|
||||||
|
System.NullReferenceException : Object reference not set to an instance of an object
|
||||||
|
---
|
||||||
|
at GeometryTD.CustomComponent.CombatRunResourceStore.FireCoinChangedEvent (System.Int32 deltaCoin) [0x00011] in D:\Learn\GameLearn\UnityProjects\GeometryTD\Assets\GameMain\Scripts\CustomComponent\CombatNode\CombatScheduler\CombatRunResourceStore.cs:259
|
||||||
|
at GeometryTD.CustomComponent.CombatRunResourceStore.AddEnemyDefeatedReward (System.Int32 gainedCoin, System.Int32 gainedGold) [0x00057] in D:\Learn\GameLearn\UnityProjects\GeometryTD\Assets\GameMain\Scripts\CustomComponent\CombatNode\CombatScheduler\CombatRunResourceStore.cs:187
|
||||||
|
at GeometryTD.Tests.EditMode.CombatSettlementServiceTests.BuildSettlementContext_On_Loss_Keeps_DropGold_Without_SettlementBonus_Or_RewardSelection () [0x00036] in D:\Learn\GameLearn\UnityProjects\GeometryTD\Assets\Tests\EditMode\CombatSettlementServiceTests.cs:166
|
||||||
|
at (wrapper managed-to-native) System.Reflection.RuntimeMethodInfo.InternalInvoke(System.Reflection.RuntimeMethodInfo,object,object[],System.Exception&)
|
||||||
|
at System.Reflection.RuntimeMethodInfo.Invoke (System.Object obj, System.Reflection.BindingFlags invokeAttr, System.Reflection.Binder binder, System.Object[] parameters, System.Globalization.CultureInfo culture) [0x0006a] in <1071a2cb0cb3433aae80a793c277a048>:0
|
||||||
|
|
||||||
|
CommitSettlementInventory_Merges_Selected_Reward_Component_Into_PlayerInventory (0.003s)
|
||||||
|
---
|
||||||
|
Expected: True
|
||||||
|
But was: False
|
||||||
|
---
|
||||||
|
at GeometryTD.Tests.EditMode.CombatSettlementServiceTests.CommitSettlementInventory_Merges_Selected_Reward_Component_Into_PlayerInventory () [0x000a3] in D:\Learn\GameLearn\UnityProjects\GeometryTD\Assets\Tests\EditMode\CombatSettlementServiceTests.cs:257
|
||||||
Loading…
Reference in New Issue