S4-07 process 1

This commit is contained in:
SepComet 2026-03-11 10:18:14 +08:00
parent 52f9e212b9
commit 9de2e50262
32 changed files with 803 additions and 194 deletions

1
.gitignore vendored
View File

@ -105,3 +105,4 @@ InitTestScene*.unity*
/.dotnet /.dotnet
/.idea /.idea
~$*.xlsx ~$*.xlsx
/tools

View File

@ -1,5 +1,4 @@
# Tag表 # Id 列1 Name MinRarity
# Id Name MinRarity
# int string RarityType # int string RarityType
# Tag编号 策划备注 Tag名 获取最低稀有度 # Tag编号 策划备注 Tag名 获取最低稀有度
1 元素 Fire White 1 元素 Fire White

View File

@ -0,0 +1,51 @@
# Id 列1 TagType Weight TriggerPhase Description Param
# int TagType int TagTriggerPhase string string
# Tag配置编号 策划备注 所属Tag类型 权重 触发阶段 描述 参数Json
1 元素 Fire 20 OnAfterHit 持续对敌人造成<color=red>火</color>伤害 {\
"BurnDurationSeconds": 3,\
"BurnDamagePerSecondPerStack": 20,\
"MaxEffectiveStack": 5\
}
2 元素 BurnSpread 20 燃烧向邻近敌人传播 {\
"SpreadRadius": 0,\
"SpreadDamageRate": 0\
}
3 元素 IgniteBurst 15 燃烧结束或击杀时爆炸 {\
"BurstRadius": 0,\
"BurstDamageRate": 0\
}
4 元素 Inferno 5 OnAfterHit 强化燃烧伤害或持续时间 {\
"BonusBurnDurationSeconds": 0,\
"BonusBurnDamagePerSecondPerStack": 0\
}
5 控制 Ice 20 OnAfterHit 命中附加减速 {\
"SlowDurationSeconds": 2,\
"SlowRatioPerStack": 0.2,\
"MinMoveSpeedMultiplier": 0.4\
}
6 控制 FreezeMask 20 冻结积累条 / 冻结面具机制 {\
"FreezeBuildUpPerStack": 0\
}
7 控制 Shatter 15 OnBeforeHit 对已减速 / 已冻结目标增伤 {\
"RequiresSlowedTarget": true,\
"DamageBonusPerStack": 0\
}
8 控制 AbsoluteZero 5 OnAfterHit 强化减速,或提高冻结触发速度 {\
"BonusSlowDurationSeconds": 1,\
"BonusSlowRatioPerStack": 0.1\
}
9 穿透 Pierce 20 子弹贯穿多个目标 {\
"ExtraPierceCount": 2\
}
10 穿透 Crit 20 OnBeforeHit 命中前按概率暴击 {\
"CritChancePerStack": 0.1,\
"CritDamageMultiplier": 1.5\
}
11 穿透 Overpenetrate 15 贯穿后保留部分伤害继续飞行 {\
"ExtraPenetrationCount": 0,\
"RemainingDamageRate": 0\
}
12 穿透 Execution 5 OnBeforeHit 对低血量目标增伤或直接处决 {\
"TargetHealthThreshold": 0.3,\
"DamageBonusPerStack": 0.5\
}

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 237e82d08a4faa64da2401808bc652fc
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -7,7 +7,7 @@
102 关于 AboutForm Medium False True 102 关于 AboutForm Medium False True
110 主界面 MainForm Medium False False 110 主界面 MainForm Medium False False
111 仓库UI RepoForm Medium False False 111 仓库UI RepoForm Medium False False
112 大地图UI NodeMapForm Medium False True 112 大地图UI NodeMapForm Medium False False
113 详细信息 ItemDescForm Medium True False 113 详细信息 ItemDescForm Medium True False
114 奖励选择UI RewardSelectForm Medium False True 114 奖励选择UI RewardSelectForm Medium False True
130 事件UI EventForm Medium False True 130 事件UI EventForm Medium False True

View File

@ -0,0 +1,41 @@
using GeometryTD.CustomUtility;
using GeometryTD.Definition;
using UnityGameFramework.Runtime;
namespace GeometryTD.DataTable
{
public sealed class DRTagConfig : DataRowBase
{
private int m_Id;
public override int Id => m_Id;
public TagType TagType { get; private set; }
public TagTriggerPhase TriggerPhase { get; private set; }
public string Description { get; private set; }
public string ParamJson { get; private set; }
public override bool ParseDataRow(string dataRowString, object userData)
{
string[] columnStrings = dataRowString.Split(DataTableExtension.DataSplitSeparators);
for (int i = 0; i < columnStrings.Length; i++)
{
columnStrings[i] = columnStrings[i].Trim(DataTableExtension.DataTrimSeparators);
}
int index = 0;
index++;
m_Id = int.Parse(columnStrings[index++]);
index++;
TagType = EnumUtility<TagType>.Get(columnStrings[index++]);
TriggerPhase = EnumUtility<TagTriggerPhase>.Get(columnStrings[index++]);
Description = columnStrings[index++];
ParamJson = columnStrings[index++];
return true;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: c3cd426c0ec84c0bbfd9918d768464f9
timeCreated: 1773139200

View File

@ -4,24 +4,6 @@ using UnityEngine;
namespace GeometryTD.Definition namespace GeometryTD.Definition
{ {
/// <summary>
/// 防御塔独立属性快照。
/// 注意:这里是塔实例的独立值,不通过组件引用实时计算。
/// </summary>
[Serializable]
public sealed class TowerStatsData
{
public int[] AttackDamage { get; set; }
public float DamageRandomRate { get; set; }
public float[] RotateSpeed { get; set; }
public float[] AttackRange { get; set; }
public float[] AttackSpeed { get; set; }
public AttackMethodType AttackMethodType { get; set; }
public AttackPropertyType AttackPropertyType { get; set; }
public TagRuntimeData[] TagRuntimes { get; set; }
public TagType[] Tags { get; set; }
}
/// <summary> /// <summary>
/// 背包内防御塔实例数据。 /// 背包内防御塔实例数据。
/// </summary> /// </summary>

View File

@ -0,0 +1,22 @@
using System;
namespace GeometryTD.Definition
{
/// <summary>
/// 防御塔独立属性快照。
/// 注意:这里是塔实例的独立值,不通过组件引用实时计算。
/// </summary>
[Serializable]
public sealed class TowerStatsData
{
public int[] AttackDamage { get; set; }
public float DamageRandomRate { get; set; }
public float[] RotateSpeed { get; set; }
public float[] AttackRange { get; set; }
public float[] AttackSpeed { get; set; }
public AttackMethodType AttackMethodType { get; set; }
public AttackPropertyType AttackPropertyType { get; set; }
public TagRuntimeData[] TagRuntimes { get; set; }
public TagType[] Tags { get; set; }
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 33934c152fda4ff4827aabfbf6f65856
timeCreated: 1773194446

View File

@ -21,15 +21,19 @@ namespace GeometryTD.Definition
} }
FireTagState state = runtime.GetOrCreateState<FireTagState>(TagType); FireTagState state = runtime.GetOrCreateState<FireTagState>(TagType);
float burnDamagePerSecond = Mathf.Max(0f, int infernoStack = TagEffectResolver.GetTagStack(attackPayload?.TagRuntimes, TagType.Inferno);
config.BurnDamagePerSecondPerStack * runtimeData.TotalStack); float burnDamagePerSecond = Mathf.Max(
0f,
config.BurnDamagePerSecondPerStack * runtimeData.TotalStack + GetInfernoBonusDamagePerSecond(infernoStack));
if (burnDamagePerSecond <= 0f) if (burnDamagePerSecond <= 0f)
{ {
return; return;
} }
state.RemainingDuration = Mathf.Max(state.RemainingDuration, config.BurnDurationSeconds); state.RemainingDuration = Mathf.Max(
state.RemainingDuration,
config.BurnDurationSeconds + GetInfernoBonusDuration(infernoStack));
state.DamagePerSecond = Mathf.Max(state.DamagePerSecond, burnDamagePerSecond); state.DamagePerSecond = Mathf.Max(state.DamagePerSecond, burnDamagePerSecond);
runtime.Activate(TagType); runtime.Activate(TagType);
} }
@ -64,5 +68,39 @@ namespace GeometryTD.Definition
state.PendingDamage = 0f; state.PendingDamage = 0f;
return false; return false;
} }
private static float GetInfernoBonusDuration(int infernoStack)
{
if (infernoStack <= 0 ||
!TagConfigRegistry.TryGetDefinition(TagType.Inferno, out TagDefinitionData definition))
{
return 0f;
}
InfernoTagConfig infernoConfig = definition.Config as InfernoTagConfig;
if (infernoConfig == null || !infernoConfig.IsImplemented)
{
return 0f;
}
return infernoStack * infernoConfig.BonusBurnDurationSeconds;
}
private static float GetInfernoBonusDamagePerSecond(int infernoStack)
{
if (infernoStack <= 0 ||
!TagConfigRegistry.TryGetDefinition(TagType.Inferno, out TagDefinitionData definition))
{
return 0f;
}
InfernoTagConfig infernoConfig = definition.Config as InfernoTagConfig;
if (infernoConfig == null || !infernoConfig.IsImplemented)
{
return 0f;
}
return infernoStack * infernoConfig.BonusBurnDamagePerSecondPerStack;
}
} }
} }

View File

@ -24,9 +24,12 @@ namespace GeometryTD.Definition
} }
IceTagState state = runtime.GetOrCreateState<IceTagState>(TagType); IceTagState state = runtime.GetOrCreateState<IceTagState>(TagType);
float slowMultiplier = Mathf.Max(config.MinMoveSpeedMultiplier, int absoluteZeroStack = TagEffectResolver.GetTagStack(attackPayload?.TagRuntimes, TagType.AbsoluteZero);
1f - runtimeData.TotalStack * config.SlowRatioPerStack); float slowRatio = runtimeData.TotalStack * config.SlowRatioPerStack + GetAbsoluteZeroBonusSlowRatio(absoluteZeroStack);
state.RemainingDuration = Mathf.Max(state.RemainingDuration, config.SlowDurationSeconds); float slowMultiplier = Mathf.Max(config.MinMoveSpeedMultiplier, 1f - slowRatio);
state.RemainingDuration = Mathf.Max(
state.RemainingDuration,
config.SlowDurationSeconds + GetAbsoluteZeroBonusDuration(absoluteZeroStack));
state.SlowMultiplier = Mathf.Min(state.SlowMultiplier, slowMultiplier); state.SlowMultiplier = Mathf.Min(state.SlowMultiplier, slowMultiplier);
runtime.Activate(TagType); runtime.Activate(TagType);
} }
@ -46,5 +49,39 @@ namespace GeometryTD.Definition
IceTagState state = runtime.GetState<IceTagState>(TagType); IceTagState state = runtime.GetState<IceTagState>(TagType);
return state == null ? 1f : state.SlowMultiplier; return state == null ? 1f : state.SlowMultiplier;
} }
private static float GetAbsoluteZeroBonusDuration(int absoluteZeroStack)
{
if (absoluteZeroStack <= 0 ||
!TagConfigRegistry.TryGetDefinition(TagType.AbsoluteZero, out TagDefinitionData definition))
{
return 0f;
}
AbsoluteZeroTagConfig absoluteZeroConfig = definition.Config as AbsoluteZeroTagConfig;
if (absoluteZeroConfig == null || !absoluteZeroConfig.IsImplemented)
{
return 0f;
}
return absoluteZeroStack * absoluteZeroConfig.BonusSlowDurationSeconds;
}
private static float GetAbsoluteZeroBonusSlowRatio(int absoluteZeroStack)
{
if (absoluteZeroStack <= 0 ||
!TagConfigRegistry.TryGetDefinition(TagType.AbsoluteZero, out TagDefinitionData definition))
{
return 0f;
}
AbsoluteZeroTagConfig absoluteZeroConfig = definition.Config as AbsoluteZeroTagConfig;
if (absoluteZeroConfig == null || !absoluteZeroConfig.IsImplemented)
{
return 0f;
}
return absoluteZeroStack * absoluteZeroConfig.BonusSlowRatioPerStack;
}
} }
} }

View File

@ -9,7 +9,7 @@ namespace GeometryTD.Definition
{ {
} }
public float BonusSlowDurationSeconds { get; set; } = 0f; public float BonusSlowDurationSeconds { get; set; } = 1f;
public float BonusSlowRatioPerStack { get; set; } = 0f; public float BonusSlowRatioPerStack { get; set; } = 0.1f;
} }
} }

View File

@ -11,5 +11,6 @@ namespace GeometryTD.Definition
public float BurnDurationSeconds { get; set; } = 3f; public float BurnDurationSeconds { get; set; } = 3f;
public float BurnDamagePerSecondPerStack { get; set; } = 20f; public float BurnDamagePerSecondPerStack { get; set; } = 20f;
public int MaxEffectiveStack { get; set; } = 5;
} }
} }

View File

@ -9,7 +9,7 @@ namespace GeometryTD.Definition
{ {
} }
public float BonusBurnDurationSeconds { get; set; } = 0f; public float BonusBurnDurationSeconds { get; set; } = 2f;
public float BonusBurnDamagePerSecondPerStack { get; set; } = 0f; public float BonusBurnDamagePerSecondPerStack { get; set; } = 20f;
} }
} }

View File

@ -9,6 +9,6 @@ namespace GeometryTD.Definition
{ {
} }
public int ExtraPierceCount { get; set; } = 0; public int ExtraPierceCount { get; set; } = 2;
} }
} }

View File

@ -10,6 +10,6 @@ namespace GeometryTD.Definition
} }
public bool RequiresSlowedTarget { get; set; } = true; public bool RequiresSlowedTarget { get; set; } = true;
public float DamageBonusPerStack { get; set; } = 0f; public float DamageBonusPerStack { get; set; } = 0.25f;
} }
} }

View File

@ -1,103 +1,174 @@
using System.Collections.Generic; using System.Collections.Generic;
using GeometryTD.DataTable;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace GeometryTD.Definition namespace GeometryTD.Definition
{ {
public static class TagConfigRegistry public static class TagConfigRegistry
{ {
private static readonly Dictionary<TagType, TagDefinitionData> DefinitionsByTag = private static readonly Dictionary<TagType, TagDefinitionData> DefinitionsByTag = CreateDefaultDefinitions();
new Dictionary<TagType, TagDefinitionData>
{
[TagType.Fire] = new TagDefinitionData
{
TagType = TagType.Fire,
Category = TagCategory.Status,
TriggerPhase = TagTriggerPhase.OnAfterHit,
Config = new FireTagConfig(true)
},
[TagType.BurnSpread] = new TagDefinitionData
{
TagType = TagType.BurnSpread,
Category = TagCategory.AttackShape,
TriggerPhase = TagTriggerPhase.OnKill,
Config = new BurnSpreadTagConfig(false)
},
[TagType.IgniteBurst] = new TagDefinitionData
{
TagType = TagType.IgniteBurst,
Category = TagCategory.AttackShape,
TriggerPhase = TagTriggerPhase.OnKill,
Config = new IgniteBurstTagConfig(false)
},
[TagType.Inferno] = new TagDefinitionData
{
TagType = TagType.Inferno,
Category = TagCategory.Status,
TriggerPhase = TagTriggerPhase.OnAfterHit,
Config = new InfernoTagConfig(false)
},
[TagType.Ice] = new TagDefinitionData
{
TagType = TagType.Ice,
Category = TagCategory.Status,
TriggerPhase = TagTriggerPhase.OnAfterHit,
Config = new IceTagConfig(true)
},
[TagType.FreezeMask] = new TagDefinitionData
{
TagType = TagType.FreezeMask,
Category = TagCategory.AttackShape,
TriggerPhase = TagTriggerPhase.OnAfterHit,
Config = new FreezeMaskTagConfig(false)
},
[TagType.Shatter] = new TagDefinitionData
{
TagType = TagType.Shatter,
Category = TagCategory.NumericModifier,
TriggerPhase = TagTriggerPhase.OnBeforeHit,
Config = new ShatterTagConfig(false)
},
[TagType.AbsoluteZero] = new TagDefinitionData
{
TagType = TagType.AbsoluteZero,
Category = TagCategory.Status,
TriggerPhase = TagTriggerPhase.OnAfterHit,
Config = new AbsoluteZeroTagConfig(false)
},
[TagType.Pierce] = new TagDefinitionData
{
TagType = TagType.Pierce,
Category = TagCategory.AttackShape,
TriggerPhase = TagTriggerPhase.OnHit,
Config = new PierceTagConfig(false)
},
[TagType.Crit] = new TagDefinitionData
{
TagType = TagType.Crit,
Category = TagCategory.NumericModifier,
TriggerPhase = TagTriggerPhase.OnBeforeHit,
Config = new CritTagConfig(true)
},
[TagType.Overpenetrate] = new TagDefinitionData
{
TagType = TagType.Overpenetrate,
Category = TagCategory.AttackShape,
TriggerPhase = TagTriggerPhase.OnHit,
Config = new OverpenetrateTagConfig(false)
},
[TagType.Execution] = new TagDefinitionData
{
TagType = TagType.Execution,
Category = TagCategory.NumericModifier,
TriggerPhase = TagTriggerPhase.OnBeforeHit,
Config = new ExecutionTagConfig(true)
}
};
public static IReadOnlyDictionary<TagType, TagDefinitionData> Definitions => DefinitionsByTag; public static IReadOnlyDictionary<TagType, TagDefinitionData> Definitions => DefinitionsByTag;
public static void ResetToDefaults()
{
DefinitionsByTag.Clear();
foreach (KeyValuePair<TagType, TagDefinitionData> pair in CreateDefaultDefinitions())
{
DefinitionsByTag.Add(pair.Key, pair.Value);
}
}
public static void LoadFromRows(IEnumerable<DRTagConfig> rows)
{
ResetToDefaults();
foreach (DRTagConfig row in rows)
{
ApplyRow(row);
}
}
public static bool TryGetDefinition(TagType tagType, out TagDefinitionData definition) public static bool TryGetDefinition(TagType tagType, out TagDefinitionData definition)
{ {
return DefinitionsByTag.TryGetValue(tagType, out definition); return DefinitionsByTag.TryGetValue(tagType, out definition);
} }
private static Dictionary<TagType, TagDefinitionData> CreateDefaultDefinitions()
{
return new Dictionary<TagType, TagDefinitionData>
{
[TagType.Fire] = CreateDefinition(TagType.Fire, TagCategory.Status, TagTriggerPhase.OnAfterHit, new FireTagConfig(true)),
[TagType.BurnSpread] = CreateDefinition(TagType.BurnSpread, TagCategory.AttackShape, TagTriggerPhase.OnKill, new BurnSpreadTagConfig(false)),
[TagType.IgniteBurst] = CreateDefinition(TagType.IgniteBurst, TagCategory.AttackShape, TagTriggerPhase.OnKill, new IgniteBurstTagConfig(false)),
[TagType.Inferno] = CreateDefinition(TagType.Inferno, TagCategory.Status, TagTriggerPhase.OnAfterHit, new InfernoTagConfig(true)),
[TagType.Ice] = CreateDefinition(TagType.Ice, TagCategory.Status, TagTriggerPhase.OnAfterHit, new IceTagConfig(true)),
[TagType.FreezeMask] = CreateDefinition(TagType.FreezeMask, TagCategory.AttackShape, TagTriggerPhase.OnAfterHit, new FreezeMaskTagConfig(false)),
[TagType.Shatter] = CreateDefinition(TagType.Shatter, TagCategory.NumericModifier, TagTriggerPhase.OnBeforeHit, new ShatterTagConfig(true)),
[TagType.AbsoluteZero] = CreateDefinition(TagType.AbsoluteZero, TagCategory.Status, TagTriggerPhase.OnAfterHit, new AbsoluteZeroTagConfig(true)),
[TagType.Pierce] = CreateDefinition(TagType.Pierce, TagCategory.AttackShape, TagTriggerPhase.OnHit, new PierceTagConfig(false)),
[TagType.Crit] = CreateDefinition(TagType.Crit, TagCategory.NumericModifier, TagTriggerPhase.OnBeforeHit, new CritTagConfig(true)),
[TagType.Overpenetrate] = CreateDefinition(TagType.Overpenetrate, TagCategory.AttackShape, TagTriggerPhase.OnHit, new OverpenetrateTagConfig(false)),
[TagType.Execution] = CreateDefinition(TagType.Execution, TagCategory.NumericModifier, TagTriggerPhase.OnBeforeHit, new ExecutionTagConfig(true))
};
}
private static TagDefinitionData CreateDefinition(
TagType tagType,
TagCategory category,
TagTriggerPhase triggerPhase,
TagConfigBase config)
{
return new TagDefinitionData
{
TagType = tagType,
Category = category,
TriggerPhase = triggerPhase,
Description = string.Empty,
Config = config
};
}
private static void ApplyRow(DRTagConfig row)
{
Debug.Assert(row != null);
Debug.Assert(DefinitionsByTag.TryGetValue(row.TagType, out TagDefinitionData definition));
definition.TriggerPhase = row.TriggerPhase;
definition.Description = row.Description ?? string.Empty;
if (string.IsNullOrWhiteSpace(row.ParamJson))
{
return;
}
JObject param = JObject.Parse(row.ParamJson);
switch (row.TagType)
{
case TagType.Fire:
ApplyFireConfig((FireTagConfig)definition.Config, param);
break;
case TagType.Inferno:
ApplyInfernoConfig((InfernoTagConfig)definition.Config, param);
break;
case TagType.Ice:
ApplyIceConfig((IceTagConfig)definition.Config, param);
break;
case TagType.Shatter:
ApplyShatterConfig((ShatterTagConfig)definition.Config, param);
break;
case TagType.AbsoluteZero:
ApplyAbsoluteZeroConfig((AbsoluteZeroTagConfig)definition.Config, param);
break;
case TagType.Crit:
ApplyCritConfig((CritTagConfig)definition.Config, param);
break;
case TagType.Execution:
ApplyExecutionConfig((ExecutionTagConfig)definition.Config, param);
break;
}
}
private static void ApplyFireConfig(FireTagConfig config, JObject param)
{
config.BurnDurationSeconds = ReadFloat(param, nameof(FireTagConfig.BurnDurationSeconds), config.BurnDurationSeconds);
config.BurnDamagePerSecondPerStack = ReadFloat(param, nameof(FireTagConfig.BurnDamagePerSecondPerStack), config.BurnDamagePerSecondPerStack);
config.MaxEffectiveStack = ReadInt(param, nameof(FireTagConfig.MaxEffectiveStack), config.MaxEffectiveStack);
}
private static void ApplyInfernoConfig(InfernoTagConfig config, JObject param)
{
config.BonusBurnDurationSeconds = ReadFloat(param, nameof(InfernoTagConfig.BonusBurnDurationSeconds), config.BonusBurnDurationSeconds);
config.BonusBurnDamagePerSecondPerStack = ReadFloat(param, nameof(InfernoTagConfig.BonusBurnDamagePerSecondPerStack), config.BonusBurnDamagePerSecondPerStack);
}
private static void ApplyIceConfig(IceTagConfig config, JObject param)
{
config.SlowDurationSeconds = ReadFloat(param, nameof(IceTagConfig.SlowDurationSeconds), config.SlowDurationSeconds);
config.SlowRatioPerStack = ReadFloat(param, nameof(IceTagConfig.SlowRatioPerStack), config.SlowRatioPerStack);
config.MinMoveSpeedMultiplier = ReadFloat(param, nameof(IceTagConfig.MinMoveSpeedMultiplier), config.MinMoveSpeedMultiplier);
}
private static void ApplyShatterConfig(ShatterTagConfig config, JObject param)
{
config.RequiresSlowedTarget = ReadBool(param, nameof(ShatterTagConfig.RequiresSlowedTarget), config.RequiresSlowedTarget);
config.DamageBonusPerStack = ReadFloat(param, nameof(ShatterTagConfig.DamageBonusPerStack), config.DamageBonusPerStack);
}
private static void ApplyAbsoluteZeroConfig(AbsoluteZeroTagConfig config, JObject param)
{
config.BonusSlowDurationSeconds = ReadFloat(param, nameof(AbsoluteZeroTagConfig.BonusSlowDurationSeconds), config.BonusSlowDurationSeconds);
config.BonusSlowRatioPerStack = ReadFloat(param, nameof(AbsoluteZeroTagConfig.BonusSlowRatioPerStack), config.BonusSlowRatioPerStack);
}
private static void ApplyCritConfig(CritTagConfig config, JObject param)
{
config.CritChancePerStack = ReadFloat(param, nameof(CritTagConfig.CritChancePerStack), config.CritChancePerStack);
config.CritDamageMultiplier = ReadFloat(param, nameof(CritTagConfig.CritDamageMultiplier), config.CritDamageMultiplier);
}
private static void ApplyExecutionConfig(ExecutionTagConfig config, JObject param)
{
config.TargetHealthThreshold = ReadFloat(param, nameof(ExecutionTagConfig.TargetHealthThreshold), config.TargetHealthThreshold);
config.DamageBonusPerStack = ReadFloat(param, nameof(ExecutionTagConfig.DamageBonusPerStack), config.DamageBonusPerStack);
}
private static float ReadFloat(JObject param, string key, float defaultValue)
{
JToken token = param[key];
return token == null ? defaultValue : token.Value<float>();
}
private static int ReadInt(JObject param, string key, int defaultValue)
{
JToken token = param[key];
return token == null ? defaultValue : token.Value<int>();
}
private static bool ReadBool(JObject param, string key, bool defaultValue)
{
JToken token = param[key];
return token == null ? defaultValue : token.Value<bool>();
}
} }
} }

View File

@ -8,6 +8,7 @@ namespace GeometryTD.Definition
public TagType TagType { get; set; } public TagType TagType { get; set; }
public TagCategory Category { get; set; } public TagCategory Category { get; set; }
public TagTriggerPhase TriggerPhase { get; set; } public TagTriggerPhase TriggerPhase { get; set; }
public string Description { get; set; }
public TagConfigBase Config { get; set; } public TagConfigBase Config { get; set; }
} }
} }

View File

@ -98,10 +98,18 @@ namespace GeometryTD.Definition
private static void ApplyShatter(HitContext hitContext, int stack, bool targetHasSlowStatus, ShatterTagConfig config) private static void ApplyShatter(HitContext hitContext, int stack, bool targetHasSlowStatus, ShatterTagConfig config)
{ {
_ = hitContext; if (config == null || !config.IsImplemented || stack <= 0)
_ = stack; {
_ = targetHasSlowStatus; return;
_ = config; }
if (config.RequiresSlowedTarget && !targetHasSlowStatus)
{
return;
}
float multiplier = 1f + stack * config.DamageBonusPerStack;
hitContext.FinalDamage = Mathf.Max(0, Mathf.RoundToInt(hitContext.FinalDamage * multiplier));
} }
} }
} }

View File

@ -10,17 +10,15 @@ namespace GeometryTD.Definition
Dictionary<TagType, int> stackByTag = new Dictionary<TagType, int>(); Dictionary<TagType, int> stackByTag = new Dictionary<TagType, int>();
if (componentTags != null) if (componentTags != null)
{ {
for (int i = 0; i < componentTags.Length; i++) foreach (var tags in componentTags)
{ {
TagType[] tags = componentTags[i];
if (tags == null) if (tags == null)
{ {
continue; continue;
} }
for (int j = 0; j < tags.Length; j++) foreach (var tagType in tags)
{ {
TagType tagType = tags[j];
if (tagType == TagType.None || !Enum.IsDefined(typeof(TagType), tagType)) if (tagType == TagType.None || !Enum.IsDefined(typeof(TagType), tagType))
{ {
continue; continue;
@ -44,7 +42,7 @@ namespace GeometryTD.Definition
} }
List<TagRuntimeData> runtimes = new List<TagRuntimeData>(stackByTag.Count); List<TagRuntimeData> runtimes = new List<TagRuntimeData>(stackByTag.Count);
foreach (KeyValuePair<TagType, int> pair in stackByTag) foreach (var pair in stackByTag)
{ {
runtimes.Add(new TagRuntimeData runtimes.Add(new TagRuntimeData
{ {
@ -65,9 +63,8 @@ namespace GeometryTD.Definition
} }
HashSet<TagType> uniqueTags = new HashSet<TagType>(); HashSet<TagType> uniqueTags = new HashSet<TagType>();
for (int i = 0; i < tags.Count; i++) foreach (var tagType in tags)
{ {
TagType tagType = tags[i];
if (tagType == TagType.None || !Enum.IsDefined(typeof(TagType), tagType)) if (tagType == TagType.None || !Enum.IsDefined(typeof(TagType), tagType))
{ {
continue; continue;
@ -106,9 +103,8 @@ namespace GeometryTD.Definition
} }
List<TagType> tags = new List<TagType>(tagRuntimes.Count); List<TagType> tags = new List<TagType>(tagRuntimes.Count);
for (int i = 0; i < tagRuntimes.Count; i++) foreach (var runtime in tagRuntimes)
{ {
TagRuntimeData runtime = tagRuntimes[i];
if (runtime == null || runtime.TagType == TagType.None || !Enum.IsDefined(typeof(TagType), runtime.TagType)) if (runtime == null || runtime.TagType == TagType.None || !Enum.IsDefined(typeof(TagType), runtime.TagType))
{ {
continue; continue;

View File

@ -7,11 +7,11 @@ namespace GeometryTD.Entity.EntityData
[Serializable] [Serializable]
public class TowerData : EntityDataBase public class TowerData : EntityDataBase
{ {
[SerializeField] private TowerStatsData _stats = new TowerStatsData(); [SerializeField] private TowerStatsData _stats;
[SerializeField] private int _towerLevel = 0; [SerializeField] private int _towerLevel = 0;
[SerializeField] private Color _muzzleColor = Color.white; [SerializeField] private Color _muzzleColor;
[SerializeField] private Color _bearingColor = Color.white; [SerializeField] private Color _bearingColor;
[SerializeField] private Color _baseColor = Color.white; [SerializeField] private Color _baseColor;
public TowerData(int entityId, int typeId, Vector3 position, Quaternion rotation, public TowerData(int entityId, int typeId, Vector3 position, Quaternion rotation,
TowerStatsData stats, int towerLevel = 0, Color? muzzleColor = null, Color? bearingColor = null, TowerStatsData stats, int towerLevel = 0, Color? muzzleColor = null, Color? bearingColor = null,

View File

@ -31,6 +31,7 @@ namespace GeometryTD.Procedure
"ShopPrice", "ShopPrice",
"Sound", "Sound",
"Tag", "Tag",
"TagConfig",
"OutGameDropPool", "OutGameDropPool",
"UIForm", "UIForm",
"UISound", "UISound",
@ -194,6 +195,15 @@ namespace GeometryTD.Procedure
_loadedFlag[ne.DataTableAssetName] = true; _loadedFlag[ne.DataTableAssetName] = true;
Log.Info("Load data table '{0}' OK.", ne.DataTableAssetName); Log.Info("Load data table '{0}' OK.", ne.DataTableAssetName);
if (ne.DataTableAssetName == AssetUtility.GetDataTableAsset("TagConfig", false))
{
var tagConfigTable = GameEntry.DataTable.GetDataTable<DRTagConfig>();
if (tagConfigTable != null)
{
TagConfigRegistry.LoadFromRows(tagConfigTable.GetAllDataRows());
}
}
} }
private void OnLoadDataTableFailure(object sender, GameEventArgs e) private void OnLoadDataTableFailure(object sender, GameEventArgs e)

View File

@ -18,7 +18,8 @@ namespace GeometryTD.UI
public string Title; public string Title;
public string TypeText; public string TypeText;
public string Description; public string Description;
public string[] TagTexts; public TagType[] Tags;
public TagRuntimeData[] TagRuntimes;
} }
protected override UIFormType UIFormTypeId => UIFormType.CombatFinishForm; protected override UIFormType UIFormTypeId => UIFormType.CombatFinishForm;
@ -138,7 +139,8 @@ namespace GeometryTD.UI
tower.Name, tower.Name,
"Tower", "Tower",
ItemDescUtility.BuildTowerDesc(tower, muzzleMap, bearingMap, baseMap) ?? string.Empty, ItemDescUtility.BuildTowerDesc(tower, muzzleMap, bearingMap, baseMap) ?? string.Empty,
TagDisplayUtility.BuildTowerTagTexts(tower.Stats)); tower.Stats?.Tags,
tower.Stats?.TagRuntimes);
} }
} }
@ -166,7 +168,8 @@ namespace GeometryTD.UI
item.Name, item.Name,
BuildComponentTypeText(item.SlotType), BuildComponentTypeText(item.SlotType),
ItemDescUtility.BuildMuzzleDesc(item) ?? string.Empty, ItemDescUtility.BuildMuzzleDesc(item) ?? string.Empty,
TagDisplayUtility.BuildTagTexts(item.Tags)); item.Tags,
null);
} }
} }
@ -194,7 +197,8 @@ namespace GeometryTD.UI
item.Name, item.Name,
BuildComponentTypeText(item.SlotType), BuildComponentTypeText(item.SlotType),
ItemDescUtility.BuildBearingDesc(item) ?? string.Empty, ItemDescUtility.BuildBearingDesc(item) ?? string.Empty,
TagDisplayUtility.BuildTagTexts(item.Tags)); item.Tags,
null);
} }
} }
@ -222,7 +226,8 @@ namespace GeometryTD.UI
item.Name, item.Name,
BuildComponentTypeText(item.SlotType), BuildComponentTypeText(item.SlotType),
ItemDescUtility.BuildBaseDesc(item) ?? string.Empty, ItemDescUtility.BuildBaseDesc(item) ?? string.Empty,
TagDisplayUtility.BuildTagTexts(item.Tags)); item.Tags,
null);
} }
} }
@ -261,7 +266,13 @@ namespace GeometryTD.UI
return Color.white; return Color.white;
} }
private void AddItemDescSeed(long itemId, string title, string typeText, string description, string[] tagTexts) private void AddItemDescSeed(
long itemId,
string title,
string typeText,
string description,
TagType[] tags,
TagRuntimeData[] tagRuntimes)
{ {
if (itemId <= 0) if (itemId <= 0)
{ {
@ -273,13 +284,19 @@ namespace GeometryTD.UI
Title = string.IsNullOrWhiteSpace(title) ? $"Item {itemId}" : title, Title = string.IsNullOrWhiteSpace(title) ? $"Item {itemId}" : title,
TypeText = typeText ?? string.Empty, TypeText = typeText ?? string.Empty,
Description = description ?? string.Empty, Description = description ?? string.Empty,
TagTexts = CloneTagTexts(tagTexts) Tags = CloneTags(tags),
TagRuntimes = CloneTagRuntimes(tagRuntimes)
}; };
} }
private static string[] CloneTagTexts(string[] tagTexts) private static TagType[] CloneTags(TagType[] tags)
{ {
return tagTexts != null ? (string[])tagTexts.Clone() : System.Array.Empty<string>(); return tags != null ? (TagType[])tags.Clone() : System.Array.Empty<TagType>();
}
private static TagRuntimeData[] CloneTagRuntimes(TagRuntimeData[] tagRuntimes)
{
return InventoryCloneUtility.CloneTagRuntimes(tagRuntimes);
} }
private static string BuildComponentTypeText(TowerCompSlotType slotType) private static string BuildComponentTypeText(TowerCompSlotType slotType)
@ -374,7 +391,8 @@ namespace GeometryTD.UI
Description = seed.Description ?? string.Empty, Description = seed.Description ?? string.Empty,
Price = 0, Price = 0,
TargetPos = args.TargetPos, TargetPos = args.TargetPos,
TagTexts = CloneTagTexts(seed.TagTexts) Tags = CloneTags(seed.Tags),
TagRuntimes = CloneTagRuntimes(seed.TagRuntimes)
}); });
} }
@ -402,6 +420,3 @@ namespace GeometryTD.UI
} }
} }
} }

View File

@ -43,7 +43,8 @@ namespace GeometryTD.UI
tower.Name, tower.Name,
"Tower", "Tower",
ItemDescUtility.BuildTowerDesc(tower, muzzleMap, bearingMap, baseMap) ?? string.Empty, ItemDescUtility.BuildTowerDesc(tower, muzzleMap, bearingMap, baseMap) ?? string.Empty,
TagDisplayUtility.BuildTowerTagTexts(tower.Stats)); tower.Stats?.Tags,
tower.Stats?.TagRuntimes);
} }
} }
@ -71,7 +72,8 @@ namespace GeometryTD.UI
item.Name, item.Name,
BuildComponentTypeText(item.SlotType), BuildComponentTypeText(item.SlotType),
ItemDescUtility.BuildMuzzleDesc(item) ?? string.Empty, ItemDescUtility.BuildMuzzleDesc(item) ?? string.Empty,
TagDisplayUtility.BuildTagTexts(item.Tags)); item.Tags,
null);
} }
} }
@ -99,7 +101,8 @@ namespace GeometryTD.UI
item.Name, item.Name,
BuildComponentTypeText(item.SlotType), BuildComponentTypeText(item.SlotType),
ItemDescUtility.BuildBearingDesc(item) ?? string.Empty, ItemDescUtility.BuildBearingDesc(item) ?? string.Empty,
TagDisplayUtility.BuildTagTexts(item.Tags)); item.Tags,
null);
} }
} }
@ -127,7 +130,8 @@ namespace GeometryTD.UI
item.Name, item.Name,
BuildComponentTypeText(item.SlotType), BuildComponentTypeText(item.SlotType),
ItemDescUtility.BuildBaseDesc(item) ?? string.Empty, ItemDescUtility.BuildBaseDesc(item) ?? string.Empty,
TagDisplayUtility.BuildTagTexts(item.Tags)); item.Tags,
null);
} }
} }
@ -169,7 +173,13 @@ namespace GeometryTD.UI
_compAreaTowerIds.Add(itemContext.InstanceId); _compAreaTowerIds.Add(itemContext.InstanceId);
} }
private void AddItemDescSeed(long itemId, string title, string typeText, string description, string[] tagTexts) private void AddItemDescSeed(
long itemId,
string title,
string typeText,
string description,
TagType[] tags,
TagRuntimeData[] tagRuntimes)
{ {
if (itemId <= 0) if (itemId <= 0)
{ {
@ -181,7 +191,8 @@ namespace GeometryTD.UI
Title = string.IsNullOrWhiteSpace(title) ? $"Item {itemId}" : title, Title = string.IsNullOrWhiteSpace(title) ? $"Item {itemId}" : title,
TypeText = typeText ?? string.Empty, TypeText = typeText ?? string.Empty,
Description = description ?? string.Empty, Description = description ?? string.Empty,
TagTexts = tagTexts != null ? (string[])tagTexts.Clone() : Array.Empty<string>() Tags = CloneTags(tags),
TagRuntimes = CloneTagRuntimes(tagRuntimes)
}; };
} }

View File

@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using GeometryTD.CustomEvent; using GeometryTD.CustomEvent;
using GeometryTD.CustomUtility;
using GeometryTD.Definition; using GeometryTD.Definition;
using GameFramework.Event; using GameFramework.Event;
using UnityEngine; using UnityEngine;
@ -22,7 +23,8 @@ namespace GeometryTD.UI
public string Title; public string Title;
public string TypeText; public string TypeText;
public string Description; public string Description;
public string[] TagTexts; public TagType[] Tags;
public TagRuntimeData[] TagRuntimes;
} }
protected override UIFormType UIFormTypeId => UIFormType.RepoForm; protected override UIFormType UIFormTypeId => UIFormType.RepoForm;
@ -151,10 +153,21 @@ namespace GeometryTD.UI
Description = seed.Description ?? string.Empty, Description = seed.Description ?? string.Empty,
Price = 0, Price = 0,
TargetPos = args.TargetPos, TargetPos = args.TargetPos,
TagTexts = CloneTagTexts(seed.TagTexts) Tags = CloneTags(seed.Tags),
TagRuntimes = CloneTagRuntimes(seed.TagRuntimes)
}); });
} }
private static TagType[] CloneTags(TagType[] tags)
{
return tags != null ? (TagType[])tags.Clone() : System.Array.Empty<TagType>();
}
private static TagRuntimeData[] CloneTagRuntimes(TagRuntimeData[] tagRuntimes)
{
return InventoryCloneUtility.CloneTagRuntimes(tagRuntimes);
}
private void OnRepoItemDragEnded(object sender, GameEventArgs e) private void OnRepoItemDragEnded(object sender, GameEventArgs e)
{ {
if (!IsEventFromCurrentForm(sender)) if (!IsEventFromCurrentForm(sender))

View File

@ -32,20 +32,39 @@ namespace GeometryTD.UI
{ {
Title = rawData.Title, Title = rawData.Title,
TypeText = rawData.TypeText, TypeText = rawData.TypeText,
Description = rawData.Description, Description = BuildDescription(rawData),
Price = rawData.Price, Price = rawData.Price,
TargetPos = rawData.TargetPos, TargetPos = rawData.TargetPos,
Tags = BuildTags(rawData) Tags = BuildTags(rawData)
}; };
} }
private static string BuildDescription(ItemDescFormRawData rawData)
{
string baseDescription = rawData?.Description ?? string.Empty;
string tagDescription = rawData?.TagRuntimes != null && rawData.TagRuntimes.Length > 0
? TagDisplayUtility.BuildTagDescriptionText(rawData.TagRuntimes)
: TagDisplayUtility.BuildTagDescriptionText(rawData?.Tags);
if (string.IsNullOrWhiteSpace(tagDescription))
{
return baseDescription;
}
if (string.IsNullOrWhiteSpace(baseDescription))
{
return tagDescription;
}
return $"{baseDescription}\n{tagDescription}";
}
private static TagItemContext[] BuildTags(ItemDescFormRawData rawData) private static TagItemContext[] BuildTags(ItemDescFormRawData rawData)
{ {
string[] tagTexts = rawData?.TagTexts; string[] tagTexts = rawData?.TagRuntimes != null && rawData.TagRuntimes.Length > 0
if ((tagTexts == null || tagTexts.Length <= 0) && rawData?.Tags != null) ? TagDisplayUtility.BuildTagTexts(rawData.TagRuntimes)
{ : rawData?.Tags != null
tagTexts = TagDisplayUtility.BuildTagTexts(rawData.Tags); ? TagDisplayUtility.BuildTagTexts(rawData.Tags)
} : null;
if (tagTexts == null || tagTexts.Length <= 0) if (tagTexts == null || tagTexts.Length <= 0)
{ {

View File

@ -11,6 +11,6 @@ namespace GeometryTD.UI
public int Price; public int Price;
public Vector3 TargetPos; public Vector3 TargetPos;
public TagType[] Tags; public TagType[] Tags;
public string[] TagTexts; public TagRuntimeData[] TagRuntimes;
} }
} }

View File

@ -92,5 +92,84 @@ namespace GeometryTD.CustomUtility
return BuildTagTexts(towerStats.Tags); return BuildTagTexts(towerStats.Tags);
} }
public static string BuildTagDescriptionText(IReadOnlyList<TagType> tags)
{
if (tags == null || tags.Count <= 0)
{
return string.Empty;
}
List<string> results = new List<string>(tags.Count);
for (int i = 0; i < tags.Count; i++)
{
TagType tagType = tags[i];
if (tagType == TagType.None)
{
continue;
}
string tagName = ResolveTagName(tagType);
string tagDescription = ResolveTagDescription(tagType);
if (string.IsNullOrWhiteSpace(tagDescription))
{
continue;
}
results.Add(string.IsNullOrWhiteSpace(tagName) ? tagDescription : $"{tagName}: {tagDescription}");
}
return string.Join("\n", results);
}
public static string BuildTagDescriptionText(IReadOnlyList<TagRuntimeData> tagRuntimes)
{
if (tagRuntimes == null || tagRuntimes.Count <= 0)
{
return string.Empty;
}
List<string> results = new List<string>(tagRuntimes.Count);
for (int i = 0; i < tagRuntimes.Count; i++)
{
TagRuntimeData runtime = tagRuntimes[i];
if (runtime == null || runtime.TagType == TagType.None || runtime.TotalStack <= 0)
{
continue;
}
string tagName = ResolveTagName(runtime.TagType);
if (runtime.TotalStack > 1 && !string.IsNullOrWhiteSpace(tagName))
{
tagName = $"{tagName} x{runtime.TotalStack}";
}
string tagDescription = ResolveTagDescription(runtime.TagType);
if (string.IsNullOrWhiteSpace(tagDescription))
{
continue;
}
results.Add(string.IsNullOrWhiteSpace(tagName) ? tagDescription : $"{tagName}: {tagDescription}");
}
return string.Join("\n", results);
}
public static string ResolveTagDescription(TagType tagType)
{
if (tagType == TagType.None)
{
return string.Empty;
}
if (TagConfigRegistry.TryGetDefinition(tagType, out TagDefinitionData definition) &&
!string.IsNullOrWhiteSpace(definition.Description))
{
return definition.Description;
}
return string.Empty;
}
} }
} }

View File

@ -1,7 +1,9 @@
using Components; using Components;
using GeometryTD.DataTable;
using GeometryTD.Definition; using GeometryTD.Definition;
using GeometryTD.Entity; using GeometryTD.Entity;
using GeometryTD.Entity.EntityData; using GeometryTD.Entity.EntityData;
using GeometryTD.CustomUtility;
using NUnit.Framework; using NUnit.Framework;
using UnityEngine; using UnityEngine;
@ -47,6 +49,47 @@ namespace GeometryTD.Tests.EditMode
Assert.That(lowHealthHit.FinalDamage, Is.EqualTo(150)); Assert.That(lowHealthHit.FinalDamage, Is.EqualTo(150));
Assert.That(highHealthHit.FinalDamage, Is.EqualTo(100)); Assert.That(highHealthHit.FinalDamage, Is.EqualTo(100));
} }
[Test]
public void ResolveBeforeHit_Shatter_OnlyTriggersOnSlowedTarget()
{
AttackPayload attackPayload = new AttackPayload
{
BaseDamage = 100,
AttackPropertyType = AttackPropertyType.Physics,
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.Shatter, TotalStack = 2 }
}
};
HitContext slowedHit = TagEffectResolver.ResolveBeforeHit(attackPayload, 100, 100, true);
HitContext normalHit = TagEffectResolver.ResolveBeforeHit(attackPayload, 100, 100, false);
Assert.That(slowedHit.FinalDamage, Is.EqualTo(150));
Assert.That(normalHit.FinalDamage, Is.EqualTo(100));
}
[Test]
public void ResolveBeforeHit_Shatter_CanStackWithOtherNumericTags()
{
AttackPayload attackPayload = new AttackPayload
{
BaseDamage = 100,
AttackPropertyType = AttackPropertyType.Physics,
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.Crit, TotalStack = 1 },
new TagRuntimeData { TagType = TagType.Execution, TotalStack = 1 },
new TagRuntimeData { TagType = TagType.Shatter, TotalStack = 1 }
}
};
HitContext hitContext = TagEffectResolver.ResolveBeforeHit(attackPayload, 30, 100, true, 0.05f);
Assert.That(hitContext.IsCriticalHit, Is.True);
Assert.That(hitContext.FinalDamage, Is.EqualTo(281));
}
} }
public sealed class EnemyTagStatusRuntimeTests public sealed class EnemyTagStatusRuntimeTests
@ -124,6 +167,101 @@ namespace GeometryTD.Tests.EditMode
Assert.That(runtime.GetMoveSpeedMultiplier(), Is.EqualTo(1f).Within(0.001f)); Assert.That(runtime.GetMoveSpeedMultiplier(), Is.EqualTo(1f).Within(0.001f));
} }
[Test]
public void ApplyAfterHit_FireWithInferno_IncreasesBurnDamageAndDuration()
{
EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime();
AttackPayload attackPayload = new AttackPayload
{
BaseDamage = 100,
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.Fire, TotalStack = 1 },
new TagRuntimeData { TagType = TagType.Inferno, TotalStack = 1 }
}
};
int totalDamage = 0;
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
Assert.That(runtime.HasStatus(TagType.Fire), Is.True);
Assert.That(runtime.HasStatus(TagType.Inferno), Is.False);
runtime.Tick(1f, damage => totalDamage += damage);
runtime.Tick(1f, damage => totalDamage += damage);
runtime.Tick(1f, damage => totalDamage += damage);
runtime.Tick(1f, damage => totalDamage += damage);
runtime.Tick(1f, damage => totalDamage += damage);
Assert.That(totalDamage, Is.EqualTo(200));
Assert.That(runtime.HasStatus(TagType.Fire), Is.False);
}
[Test]
public void ApplyAfterHit_InfernoWithoutFire_DoesNotCreateStatus()
{
EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime();
AttackPayload attackPayload = new AttackPayload
{
BaseDamage = 100,
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.Inferno, TotalStack = 1 }
}
};
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
Assert.That(runtime.HasStatus(TagType.Fire), Is.False);
Assert.That(runtime.HasStatus(TagType.Inferno), Is.False);
}
[Test]
public void ApplyAfterHit_IceWithAbsoluteZero_IncreasesSlowStrengthAndDuration()
{
EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime();
AttackPayload attackPayload = new AttackPayload
{
BaseDamage = 100,
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.Ice, TotalStack = 2 },
new TagRuntimeData { TagType = TagType.AbsoluteZero, TotalStack = 1 }
}
};
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
Assert.That(runtime.HasStatus(TagType.Ice), Is.True);
Assert.That(runtime.HasStatus(TagType.AbsoluteZero), Is.False);
Assert.That(runtime.GetMoveSpeedMultiplier(), Is.EqualTo(0.5f).Within(0.001f));
runtime.Tick(2.5f, null);
Assert.That(runtime.HasStatus(TagType.Ice), Is.True);
runtime.Tick(0.5f, null);
Assert.That(runtime.HasStatus(TagType.Ice), Is.False);
Assert.That(runtime.GetMoveSpeedMultiplier(), Is.EqualTo(1f).Within(0.001f));
}
[Test]
public void ApplyAfterHit_AbsoluteZeroWithoutIce_DoesNotCreateStatus()
{
EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime();
AttackPayload attackPayload = new AttackPayload
{
BaseDamage = 100,
TagRuntimes = new[]
{
new TagRuntimeData { TagType = TagType.AbsoluteZero, TotalStack = 1 }
}
};
TagEffectResolver.ApplyAfterHit(attackPayload, runtime);
Assert.That(runtime.HasStatus(TagType.Ice), Is.False);
Assert.That(runtime.HasStatus(TagType.AbsoluteZero), Is.False);
}
[Test] [Test]
public void Tick_OnlyUpdatesActivatedStatusTags() public void Tick_OnlyUpdatesActivatedStatusTags()
{ {
@ -177,21 +315,84 @@ namespace GeometryTD.Tests.EditMode
} }
[Test] [Test]
public void Definitions_Keep_Unimplemented_Tags_As_Explicit_NoOpPlaceholders() public void Definitions_Mark_FirstBatch_SevenTags_AsImplemented()
{ {
Assert.That(TagConfigRegistry.TryGetDefinition(TagType.Shatter, out TagDefinitionData shatter), Is.True); Assert.That(TagConfigRegistry.TryGetDefinition(TagType.Shatter, out TagDefinitionData shatter), Is.True);
Assert.That(shatter.Config, Is.TypeOf<ShatterTagConfig>()); Assert.That(shatter.Config, Is.TypeOf<ShatterTagConfig>());
Assert.That(((ShatterTagConfig)shatter.Config).IsImplemented, Is.False); Assert.That(((ShatterTagConfig)shatter.Config).IsImplemented, Is.True);
Assert.That(TagConfigRegistry.TryGetDefinition(TagType.Inferno, out TagDefinitionData inferno), Is.True); Assert.That(TagConfigRegistry.TryGetDefinition(TagType.Inferno, out TagDefinitionData inferno), Is.True);
Assert.That(inferno.Config, Is.TypeOf<InfernoTagConfig>()); Assert.That(inferno.Config, Is.TypeOf<InfernoTagConfig>());
Assert.That(((InfernoTagConfig)inferno.Config).IsImplemented, Is.False); Assert.That(((InfernoTagConfig)inferno.Config).IsImplemented, Is.True);
Assert.That(TagConfigRegistry.TryGetDefinition(TagType.AbsoluteZero, out TagDefinitionData absoluteZero), Is.True);
Assert.That(absoluteZero.Config, Is.TypeOf<AbsoluteZeroTagConfig>());
Assert.That(((AbsoluteZeroTagConfig)absoluteZero.Config).IsImplemented, Is.True);
Assert.That(TagConfigRegistry.TryGetDefinition(TagType.Pierce, out TagDefinitionData pierce), Is.True); Assert.That(TagConfigRegistry.TryGetDefinition(TagType.Pierce, out TagDefinitionData pierce), Is.True);
Assert.That(pierce.Config, Is.TypeOf<PierceTagConfig>()); Assert.That(pierce.Config, Is.TypeOf<PierceTagConfig>());
Assert.That(((PierceTagConfig)pierce.Config).IsImplemented, Is.False); Assert.That(((PierceTagConfig)pierce.Config).IsImplemented, Is.False);
} }
[Test]
public void LoadFromRows_Overrides_TriggerPhase_And_ConfigValues()
{
DRTagConfig fireRow = new DRTagConfig();
bool parsed = fireRow.ParseDataRow("\t1\t元素\tFire\tOnAfterHit\t火焰测试描述\t{\"BurnDurationSeconds\":4,\"BurnDamagePerSecondPerStack\":25,\"MaxEffectiveStack\":3}", null);
Assert.That(parsed, Is.True);
TagConfigRegistry.LoadFromRows(new[] { fireRow });
Assert.That(TagConfigRegistry.TryGetDefinition(TagType.Fire, out TagDefinitionData fire), Is.True);
Assert.That(fire.TriggerPhase, Is.EqualTo(TagTriggerPhase.OnAfterHit));
Assert.That(fire.Description, Is.EqualTo("火焰测试描述"));
Assert.That(fire.Config, Is.TypeOf<FireTagConfig>());
FireTagConfig config = (FireTagConfig)fire.Config;
Assert.That(config.BurnDurationSeconds, Is.EqualTo(4f).Within(0.001f));
Assert.That(config.BurnDamagePerSecondPerStack, Is.EqualTo(25f).Within(0.001f));
Assert.That(config.MaxEffectiveStack, Is.EqualTo(3));
TagConfigRegistry.ResetToDefaults();
}
[Test]
public void BuildTagDescriptionText_Uses_Registry_Descriptions()
{
DRTagConfig fireRow = new DRTagConfig();
DRTagConfig shatterRow = new DRTagConfig();
Assert.That(fireRow.ParseDataRow("\t1\t元素\tFire\tOnAfterHit\t持续燃烧\t{\"BurnDurationSeconds\":3,\"BurnDamagePerSecondPerStack\":20,\"MaxEffectiveStack\":5}", null), Is.True);
Assert.That(shatterRow.ParseDataRow("\t7\t控制\tShatter\tOnBeforeHit\t对减速目标增伤\t{\"RequiresSlowedTarget\":true,\"DamageBonusPerStack\":0.25}", null), Is.True);
TagConfigRegistry.LoadFromRows(new[] { fireRow, shatterRow });
string description = TagDisplayUtility.BuildTagDescriptionText(new[] { TagType.Fire, TagType.Shatter });
Assert.That(description, Does.Contain("持续燃烧"));
Assert.That(description, Does.Contain("对减速目标增伤"));
TagConfigRegistry.ResetToDefaults();
}
[Test]
public void BuildTagDescriptionText_FromTagRuntimes_Preserves_StackText()
{
DRTagConfig iceRow = new DRTagConfig();
Assert.That(iceRow.ParseDataRow("\t5\t控制\tIce\tOnAfterHit\t命中附加减速\t{\"SlowDurationSeconds\":2,\"SlowRatioPerStack\":0.2,\"MinMoveSpeedMultiplier\":0.4}", null), Is.True);
TagConfigRegistry.LoadFromRows(new[] { iceRow });
string description = TagDisplayUtility.BuildTagDescriptionText(new[]
{
new TagRuntimeData { TagType = TagType.Ice, TotalStack = 2 }
});
Assert.That(description, Does.Contain("Ice x2"));
Assert.That(description, Does.Contain("命中附加减速"));
TagConfigRegistry.ResetToDefaults();
}
[Test] [Test]
public void AttackShape_Placeholders_Are_Routable_Without_Runtime_Effect() public void AttackShape_Placeholders_Are_Routable_Without_Runtime_Effect()
{ {

View File

@ -130,8 +130,8 @@
| [x] | S4-03 | 先固化 Tag 系统设计与首发范围 | `docs/TagSystemDesign.md`<br>`docs/CodeX-TODO.md` | Tag 的来源、汇总、生效与首发集合口径固定 | | [x] | S4-03 | 先固化 Tag 系统设计与首发范围 | `docs/TagSystemDesign.md`<br>`docs/CodeX-TODO.md` | Tag 的来源、汇总、生效与首发集合口径固定 |
| [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 展示口径一致 |
| [ ] | 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 参数可配置可解释 | | [~] | 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`
> >
@ -143,9 +143,11 @@
> >
> 2026-03-10 更新:`S4-06` 已完成第一段落地。战斗链现在已从旧的 `damage + AttackPropertyType` 入口提升为携带 `TagRuntimeData[]` 的命中载荷;`AttackPayload`、`HitContext`、`TagEffectResolver`、`EnemyTagStatusRuntime` 已接入主命中链。当前已实际生效的基础 Tag 为 `Fire`、`Ice`、`Crit`、`Execution`:其中 `Fire` 已改为独立 DOT 公式,并按 `EnemyEntity` 每帧提供的 `deltaTime` 连续结算;`Ice` 已有独立减速状态;`Crit` 与 `Execution` 已进入命中前数值修正。状态类 Tag 已按“每个 Tag 各自配置文件、状态文件、效果文件”的方式拆开,`EnemyTagStatusRuntime` 只负责按激活 Tag 动态 Tick不再持有具体 Tag 字段。 > 2026-03-10 更新:`S4-06` 已完成第一段落地。战斗链现在已从旧的 `damage + AttackPropertyType` 入口提升为携带 `TagRuntimeData[]` 的命中载荷;`AttackPayload`、`HitContext`、`TagEffectResolver`、`EnemyTagStatusRuntime` 已接入主命中链。当前已实际生效的基础 Tag 为 `Fire`、`Ice`、`Crit`、`Execution`:其中 `Fire` 已改为独立 DOT 公式,并按 `EnemyEntity` 每帧提供的 `deltaTime` 连续结算;`Ice` 已有独立减速状态;`Crit` 与 `Execution` 已进入命中前数值修正。状态类 Tag 已按“每个 Tag 各自配置文件、状态文件、效果文件”的方式拆开,`EnemyTagStatusRuntime` 只负责按激活 Tag 动态 Tick不再持有具体 Tag 字段。
> >
> 2026-03-10 更新:当前 `S4-06` 仍未整体完成。`Shatter`、`Inferno`、`AbsoluteZero` 仍处于“已注册但 no-op 占位”的状态;`BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate` 仍只有分类与占位路由,没有实际战斗效果。因此当前仓库状态应视为“首批 4 个基础 Tag 已进入战斗,首发 7 个 Tag 尚未收满”,而不是 `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-10 更新:`S4-07` 仍未开始正式收口。当前 Tag 参数仍主要承载在代码侧 `TagConfigRegistry` 与各 Tag 配置类中,`Tag.txt` / DataTable 仍只提供基础字典与 `MinRarity` 输入;文档中约定的 `TagRule` 表、触发阶段、权重、效果参数等尚未形成 DataRow 与运行时消费闭环。因此 `S4-07` 继续保持未完成状态。 > 2026-03-10 更新:`S4-07` 仍未开始正式收口。当前 Tag 参数仍主要承载在代码侧 `TagConfigRegistry` 与各 Tag 配置类中,`Tag.txt` / DataTable 仍只提供基础字典与 `MinRarity` 输入;文档中约定的 `TagRule` 表、触发阶段、权重、效果参数等尚未形成 DataRow 与运行时消费闭环。因此 `S4-07` 继续保持未完成状态。
>
> 2026-03-11 更新:`S4-07` 已进入第一阶段实现。当前已新增 `TagConfig.txt``DRTagConfig``ProcedurePreload` 会在加载 `TagConfig` 表后驱动 `TagConfigRegistry.LoadFromRows(...)`,把 `TriggerPhase`、`Description` 以及首发 7 个 Tag 的配置参数从表覆盖到运行时强类型 `TagConfig`。`ItemDescForm` 也已开始消费该配置说明;塔详情会优先使用 `TagRuntimes` 构建 `Ice x2` 这类叠层展示。因此 `S4-07` 不再是“完全未开始”,但当前仍只完成了 `TagConfig` 级别的参数映射与 UI 消费,尚未把文档中的完整 `TagRule`(如权重、生成规则等)全部收口。
### S4-01 边界结论 ### S4-01 边界结论
@ -177,10 +179,11 @@
### S4 当前进度结论2026-03-10 ### S4 当前进度结论2026-03-10
- `S4-02 ~ S4-05` 已完成,品质、组件实例 Tag 生成、塔级 `TagRuntimeData` 汇总、以及 UI 展示口径都已收口。 - `S4-02 ~ S4-05` 已完成,品质、组件实例 Tag 生成、塔级 `TagRuntimeData` 汇总、以及 UI 展示口径都已收口。
- `S4-06` 已完成第一阶段:战斗链已支持 Tag 透传与统一结算,`Fire`、`Ice`、`Crit`、`Execution` 已有可验证效果,状态类 Tag 的内部结构也已从集中字段改为注册式运行时 - `S4-06` 已完成:战斗链已支持 Tag 透传与统一结算,正式首发 7 个 Tag `Fire`、`Ice`、`Crit`、`Execution`、`Shatter`、`Inferno`、`AbsoluteZero` 已有可验证效果。
- 当前 `S4-06` 的未完成部分集中在首发剩余 3 个 Tag`Shatter`、`Inferno`、`AbsoluteZero`。它们虽然已进入 `TagType`、配置类、注册表和占位效果类,但还没有实际战斗语义 - `Shatter` 已接入命中前数值修正链;`Inferno`、`AbsoluteZero` 已按“强化现有 `Fire` / `Ice` 状态”的首发口径落地,状态类 Tag 的内部结构继续保持注册式运行时
- `BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate` 已进入分类与配置骨架,但仍属于后续扩展,不计入当前 `S4` 的完成标准。 - `BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate` 已进入分类与配置骨架,但仍属于后续扩展,不计入当前 `S4` 的完成标准。
- `S4-07` 仍未开始正式实现。当前仓库只有“代码内注册表配置”,还没有把 `TagRule` 设计回写成 DataTable / DataRow / 运行时消费链。 - `S4-07` 已进入第一阶段实现。当前仓库已具备 `TagConfig.txt -> DRTagConfig -> TagConfigRegistry -> ItemDescForm` 的消费闭环,首发 7 个 Tag 的触发阶段、描述与核心参数已可由表覆盖。
- `S4-07` 仍未完成最终收口。当前采用的是 `TagConfig` 表驱动,而不是文档原方案里的完整 `TagRule`;权重、生成规则等字段仍未全部映射回 DataTable / DataRow / 运行时消费链。
### S4-06 当前代码状态 ### S4-06 当前代码状态
@ -193,16 +196,14 @@
- `EnemyTagStatusRuntime` 已不再硬编码 `burn/slow` 字段,而是按激活的状态类 Tag 动态调度 Tick。 - `EnemyTagStatusRuntime` 已不再硬编码 `burn/slow` 字段,而是按激活的状态类 Tag 动态调度 Tick。
- `Fire` 已改为独立 DOT 公式:当前使用独立 `BurnDamagePerSecondPerStack`,并按敌人实体每帧提供的 `deltaTime` 连续结算,不再依赖命中伤害或内部 `TickInterval` - `Fire` 已改为独立 DOT 公式:当前使用独立 `BurnDamagePerSecondPerStack`,并按敌人实体每帧提供的 `deltaTime` 连续结算,不再依赖命中伤害或内部 `TickInterval`
- `Ice` 仍是独立减速状态,移速倍率通过状态运行时聚合得到。 - `Ice` 仍是独立减速状态,移速倍率通过状态运行时聚合得到。
- `Crit`、`Execution` 已通过数值修正链路生效;`Shatter` 仍仅有占位配置与入口,没有实际增伤逻辑。 - `Crit`、`Execution`、`Shatter` 已通过数值修正链路生效;`Shatter` 当前按“目标已减速时增伤”收口。
- `Inferno``AbsoluteZero` 已不再是独立状态效果,而是分别在 `Fire` / `Ice` 生效时读取同次命中的强化 Tag 层数,增强 DOT 时长/伤害与减速时长/强度。
### S4 后续执行计划 ### S4 后续执行计划
1. 先补完 `S4-06` 的首发缺口,只做正式首发集合剩余的 `Shatter`、`Inferno`、`AbsoluteZero`,不提前展开传播、爆炸、穿透、多命中体系。 1. 继续推进 `S4-07`:在现有 `TagConfig` 表驱动基础上,决定是否补成文档中的完整 `TagRule`,或明确将 `TagConfig` 作为当前阶段的等价收口方案。
2. `Shatter` 优先接入现有命中前数值修正链,直接消费“目标已减速”这一当前已存在的状态查询能力。 2. `S4-07` 完成标准仍以“表字段被实际消费、参数可解释、运行时与文档口径一致”为准;当前已完成首发 7 个 Tag 的参数和说明配置化,后续重点转向剩余规则字段是否也要配置化。
3. `Inferno``AbsoluteZero` 优先作为对已有 `Fire` / `Ice` 的强化 Tag 落地,不额外引入第二套状态系统;要求仍沿用当前“单 Tag 配置 + 单 Tag 效果 + 注册式状态运行时”的结构。 3. `BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate` 继续留在后续扩展阶段,不因为 `S4-06` 完成而提前进入当前迭代。
4. 首发 7 个 Tag 全部进入战斗并补齐对应 EditMode 测试后,再将 `S4-06` 标记为完成。
5. 之后进入 `S4-07`:新增并消费 `TagRule` 表或等价 DataTable 映射,把当前代码内的 Tag 参数逐步迁移为可配置数据,而不是继续堆在 `TagConfigRegistry` 默认值里。
6. `S4-07` 完成标准仍以“表字段被实际消费、参数可解释、运行时与文档口径一致”为准,不要求一步到位把全部 12 个 Tag 都配置化。
## 阶段 S5 - 收口耐久规则 ## 阶段 S5 - 收口耐久规则
@ -227,7 +228,7 @@
1. 先做 `S1`,把主流程真正收成一条稳定可验收的链。 1. 先做 `S1`,把主流程真正收成一条稳定可验收的链。
2. `S2` 已完成口径对齐,`NodeMapForm` 不再作为 M1 阻塞项。 2. `S2` 已完成口径对齐,`NodeMapForm` 不再作为 M1 阻塞项。
3. 接下来优先做 `S3`,补齐出战合法性,解决 `P0-10` 3. 接下来优先做 `S3`,补齐出战合法性,解决 `P0-10`
4. 再做 `S4`,统一品质 / Tag 的规则口径,解决 `P0-11`。其中当前优先级已经收口为:先补完 `S4-06` 的首发 7 Tag再做 `S4-07` 的数据表映射。 4. 再做 `S4`,统一品质 / Tag 的规则口径,解决 `P0-11`。其中当前优先级已经收口为:`S4-06` 已完成,下一步转入 `S4-07` 的数据表映射。
5. 最后做 `S5`,决定耐久是完整收口还是同步缩范围,解决 `P0-12` 5. 最后做 `S5`,决定耐久是完整收口还是同步缩范围,解决 `P0-12`
6. 全部完成后做 `S6`,补测试并同步文档状态。 6. 全部完成后做 `S6`,补测试并同步文档状态。
@ -235,9 +236,8 @@
1. `S1``S2` 已完成口径收口,可直接进入规则侧收尾 1. `S1``S2` 已完成口径收口,可直接进入规则侧收尾
2. `S3-01 ~ S3-04` 已完成,当前可转入 `S4` 2. `S3-01 ~ S3-04` 已完成,当前可转入 `S4`
3. 当前先继续完成 `S4-06`,补齐 `Shatter`、`Inferno`、`AbsoluteZero` 3. 当前转入 `S4-07`,把 `TagRule` / DataTable 映射真正接进运行时
4. 然后进入 `S4-07`,把 `TagRule` / DataTable 映射真正接进运行时 4. 然后补 `S6-01 ~ S6-04`
5. 最后补 `S6-01 ~ S6-04`
## 备注 ## 备注

Binary file not shown.