仓库组件 + 防御塔升级

- `PlayerInventoryComponent`:玩家库存组件,收集金币/组件/防御塔等道具
- 补全防御塔的升级逻辑,最高 5 级
This commit is contained in:
SepComet 2026-03-02 19:50:12 +08:00
parent 5ba94828a8
commit daba9cbdf9
20 changed files with 753 additions and 138 deletions

View File

@ -5,14 +5,12 @@
// Feedback: mailto:ellan@gameframework.cn
//------------------------------------------------------------
using UnityEngine;
using UnityGameFramework.Runtime;
/// <summary>
/// 游戏入口。
/// </summary>
public partial class GameEntry : MonoBehaviour
public partial class GameEntry
{
/// <summary>
/// 获取游戏基础组件。

View File

@ -8,6 +8,8 @@ public partial class GameEntry
{
public static BuiltinDataComponent BuiltinData { get; private set; }
public static PlayerInventoryComponent PlayerInventory { get; private set; }
public static HPBarComponent HPBar { get; private set; }
public static UIRouterComponent UIRouter { get; private set; }
@ -23,16 +25,12 @@ public partial class GameEntry
private static void InitCustomComponents()
{
BuiltinData = UnityGameFramework.Runtime.GameEntry.GetComponent<BuiltinDataComponent>();
PlayerInventory = UnityGameFramework.Runtime.GameEntry.GetComponent<PlayerInventoryComponent>();
HPBar = UnityGameFramework.Runtime.GameEntry.GetComponent<HPBarComponent>();
UIRouter = UnityGameFramework.Runtime.GameEntry.GetComponent<UIRouterComponent>();
EventNode = UnityGameFramework.Runtime.GameEntry.GetComponent<EventNodeComponent>();
CombatNode = UnityGameFramework.Runtime.GameEntry.GetComponent<CombatNodeComponent>();
ShopNode = UnityGameFramework.Runtime.GameEntry.GetComponent<ShopNodeComponent>();
ResolutionAdapter = UnityGameFramework.Runtime.GameEntry.GetComponent<ResolutionAdapterComponent>();
if (ResolutionAdapter == null)
{
UnityGameFramework.Runtime.Log.Warning(
"ResolutionAdapterComponent is missing. Please add it in Launcher scene and inject UI roots.");
}
}
}

View File

@ -8,6 +8,8 @@ namespace Components
public class DefenseTowerController : MonoBehaviour
{
private const string AttackRangeIndicatorObjectName = "AttackRangeIndicator";
private const int MinTowerLevel = 0;
private const int MaxTowerLevel = 4;
private static Material s_AttackRangeSharedMaterial;
[SerializeField] private ShooterMuzzleComp _muzzleComp;
@ -15,7 +17,6 @@ namespace Components
[SerializeField] private BasicBaseComp _baseComp;
[SerializeField] private Transform _scanOrigin;
[SerializeField] [Min(0.02f)] private float _retargetInterval = 0.1f;
[SerializeField] private bool _autoUpdate = true;
[SerializeField] private LineRenderer _attackRangeRenderer;
[SerializeField] [Min(12)] private int _attackRangeSegments = 64;
[SerializeField] [Min(0.005f)] private float _attackRangeLineWidth = 0.08f;
@ -25,6 +26,7 @@ namespace Components
private Transform _currentTarget;
private float _retargetTimer;
private float _attackRange;
private int _towerLevel;
public Transform CurrentTarget => _currentTarget;
@ -33,17 +35,12 @@ namespace Components
ResolveComponents();
}
private void Update()
{
if (!_autoUpdate)
{
return;
}
OnUpdate(Time.deltaTime);
}
public void OnInit(DefenseTowerStatsData stats)
{
OnInit(stats, MinTowerLevel);
}
public void OnInit(DefenseTowerStatsData stats, int towerLevel)
{
ResolveComponents();
if (stats == null)
@ -51,10 +48,16 @@ namespace Components
return;
}
_muzzleComp?.OnInit(stats.AttackDamage, stats.AttackMethodType);
_bearingComp?.OnInit(stats.RotateSpeed, stats.AttackRange);
_baseComp?.OnInit(stats.AttackSpeed, stats.AttackPropertyType);
SetAttackRange(stats.AttackRange);
_towerLevel = Mathf.Clamp(towerLevel, MinTowerLevel, MaxTowerLevel);
int attackDamage = ResolveIntValue(stats.AttackDamage, _towerLevel, 1, 1);
float rotateSpeed = ResolveFloatValue(stats.RotateSpeed, _towerLevel, 180f, 1f);
float attackRange = ResolveFloatValue(stats.AttackRange, _towerLevel, 5f, 0.1f);
float attackSpeed = ResolveFloatValue(stats.AttackSpeed, _towerLevel, 1f, 0.01f);
_muzzleComp?.OnInit(attackDamage, stats.AttackMethodType);
_bearingComp?.OnInit(rotateSpeed, attackRange);
_baseComp?.OnInit(attackSpeed, stats.AttackPropertyType);
SetAttackRange(attackRange);
SetAttackRangeVisible(false);
_currentTarget = null;
_retargetTimer = 0f;
@ -65,6 +68,7 @@ namespace Components
SetAttackRangeVisible(false);
_currentTarget = null;
_retargetTimer = 0f;
_towerLevel = MinTowerLevel;
_muzzleComp?.OnReset();
_bearingComp?.OnReset();
_baseComp?.OnReset();
@ -86,11 +90,6 @@ namespace Components
RebuildAttackRangeGeometry();
}
public void SetAutoUpdate(bool autoUpdate)
{
_autoUpdate = autoUpdate;
}
public void SetTarget(Transform target)
{
_currentTarget = target;
@ -195,6 +194,30 @@ namespace Components
return target != null && target.gameObject.activeInHierarchy;
}
private static int ResolveIntValue(int[] values, int level, int fallback, int minValue)
{
int resolved = Mathf.Max(minValue, fallback);
if (values == null || values.Length <= 0)
{
return resolved;
}
int index = Mathf.Clamp(level, 0, values.Length - 1);
return Mathf.Max(minValue, values[index]);
}
private static float ResolveFloatValue(float[] values, int level, float fallback, float minValue)
{
float resolved = Mathf.Max(minValue, fallback);
if (values == null || values.Length <= 0)
{
return resolved;
}
int index = Mathf.Clamp(level, 0, values.Length - 1);
return Mathf.Max(minValue, values[index]);
}
private void EnsureAttackRangeRenderer()
{
if (_attackRangeRenderer == null)

View File

@ -245,6 +245,7 @@ namespace GeometryTD.CustomComponent
{
int defeatedEnemyCount = _enemyManager.DefeatedEnemyCount;
BackpackInventoryData rewardInventory = _combatResourceManager.GetRewardInventorySnapshot();
GameEntry.PlayerInventory?.MergeInventory(rewardInventory);
// Step 1: stop runtime and clear enemy entities only.
_enemyManager.EndPhase();

View File

@ -0,0 +1,408 @@
using System;
using GeometryTD.Definition;
using GeometryTD.UI;
using UnityEngine;
using UnityGameFramework.Runtime;
namespace GeometryTD.CustomComponent
{
public class PlayerInventoryComponent : GameFrameworkComponent
{
private BackpackInventoryData _inventory = new BackpackInventoryData();
private long _nextInstanceId = 1;
private bool _initialized;
public int Gold
{
get
{
EnsureInitialized();
return _inventory.Gold;
}
}
public void OnInit()
{
_inventory = CloneInventory(RepoFormUseCase.SampleInventory());
RebuildNextInstanceId();
_initialized = true;
Log.Info(
"PlayerInventory initialized. Gold={0}, Tower={1}, Muzzle={2}, Bearing={3}, Base={4}.",
_inventory.Gold,
_inventory.Towers.Count,
_inventory.MuzzleComponents.Count,
_inventory.BearingComponents.Count,
_inventory.BaseComponents.Count);
}
public BackpackInventoryData GetInventorySnapshot()
{
EnsureInitialized();
return CloneInventory(_inventory);
}
public void MergeInventory(BackpackInventoryData gainedInventory)
{
EnsureInitialized();
if (gainedInventory == null)
{
return;
}
int gainedGold = Mathf.Max(0, gainedInventory.Gold);
int gainedMuzzleCount = 0;
int gainedBearingCount = 0;
int gainedBaseCount = 0;
int gainedTowerCount = 0;
if (gainedGold > 0)
{
_inventory.Gold += gainedGold;
}
if (gainedInventory.MuzzleComponents != null)
{
for (int i = 0; i < gainedInventory.MuzzleComponents.Count; i++)
{
MuzzleCompItemData source = gainedInventory.MuzzleComponents[i];
if (source == null)
{
continue;
}
MuzzleCompItemData cloned = CloneMuzzleComp(source);
cloned.InstanceId = AllocateInstanceId();
_inventory.MuzzleComponents.Add(cloned);
gainedMuzzleCount++;
}
}
if (gainedInventory.BearingComponents != null)
{
for (int i = 0; i < gainedInventory.BearingComponents.Count; i++)
{
BearingCompItemData source = gainedInventory.BearingComponents[i];
if (source == null)
{
continue;
}
BearingCompItemData cloned = CloneBearingComp(source);
cloned.InstanceId = AllocateInstanceId();
_inventory.BearingComponents.Add(cloned);
gainedBearingCount++;
}
}
if (gainedInventory.BaseComponents != null)
{
for (int i = 0; i < gainedInventory.BaseComponents.Count; i++)
{
BaseCompItemData source = gainedInventory.BaseComponents[i];
if (source == null)
{
continue;
}
BaseCompItemData cloned = CloneBaseComp(source);
cloned.InstanceId = AllocateInstanceId();
_inventory.BaseComponents.Add(cloned);
gainedBaseCount++;
}
}
if (gainedInventory.Towers != null)
{
for (int i = 0; i < gainedInventory.Towers.Count; i++)
{
DefenseTowerItemData source = gainedInventory.Towers[i];
if (source == null)
{
continue;
}
DefenseTowerItemData cloned = CloneTower(source);
cloned.InstanceId = AllocateInstanceId();
_inventory.Towers.Add(cloned);
gainedTowerCount++;
}
}
if (gainedGold > 0 || gainedMuzzleCount > 0 || gainedBearingCount > 0 || gainedBaseCount > 0 ||
gainedTowerCount > 0)
{
Log.Info(
"PlayerInventory merged reward. Gold+{0}, Tower+{1}, Muzzle+{2}, Bearing+{3}, Base+{4}.",
gainedGold,
gainedTowerCount,
gainedMuzzleCount,
gainedBearingCount,
gainedBaseCount);
}
}
public bool TryConsumeGold(int costGold)
{
EnsureInitialized();
int resolvedCost = Mathf.Max(0, costGold);
if (resolvedCost <= 0)
{
return true;
}
if (_inventory.Gold < resolvedCost)
{
return false;
}
_inventory.Gold -= resolvedCost;
return true;
}
public void AddGold(int gainGold)
{
EnsureInitialized();
int resolvedGain = Mathf.Max(0, gainGold);
if (resolvedGain <= 0)
{
return;
}
_inventory.Gold += resolvedGain;
}
private void EnsureInitialized()
{
if (_initialized)
{
return;
}
OnInit();
}
private long AllocateInstanceId()
{
if (_nextInstanceId < 1)
{
_nextInstanceId = 1;
}
return _nextInstanceId++;
}
private void RebuildNextInstanceId()
{
long maxInstanceId = 0;
if (_inventory.Towers != null)
{
for (int i = 0; i < _inventory.Towers.Count; i++)
{
DefenseTowerItemData item = _inventory.Towers[i];
if (item != null)
{
maxInstanceId = Math.Max(maxInstanceId, item.InstanceId);
}
}
}
if (_inventory.MuzzleComponents != null)
{
for (int i = 0; i < _inventory.MuzzleComponents.Count; i++)
{
MuzzleCompItemData item = _inventory.MuzzleComponents[i];
if (item != null)
{
maxInstanceId = Math.Max(maxInstanceId, item.InstanceId);
}
}
}
if (_inventory.BearingComponents != null)
{
for (int i = 0; i < _inventory.BearingComponents.Count; i++)
{
BearingCompItemData item = _inventory.BearingComponents[i];
if (item != null)
{
maxInstanceId = Math.Max(maxInstanceId, item.InstanceId);
}
}
}
if (_inventory.BaseComponents != null)
{
for (int i = 0; i < _inventory.BaseComponents.Count; i++)
{
BaseCompItemData item = _inventory.BaseComponents[i];
if (item != null)
{
maxInstanceId = Math.Max(maxInstanceId, item.InstanceId);
}
}
}
_nextInstanceId = Math.Max(1, maxInstanceId + 1);
}
private static BackpackInventoryData CloneInventory(BackpackInventoryData source)
{
BackpackInventoryData cloned = new BackpackInventoryData();
if (source == null)
{
return cloned;
}
cloned.Gold = Mathf.Max(0, source.Gold);
if (source.MuzzleComponents != null)
{
for (int i = 0; i < source.MuzzleComponents.Count; i++)
{
MuzzleCompItemData item = source.MuzzleComponents[i];
if (item != null)
{
cloned.MuzzleComponents.Add(CloneMuzzleComp(item));
}
}
}
if (source.BearingComponents != null)
{
for (int i = 0; i < source.BearingComponents.Count; i++)
{
BearingCompItemData item = source.BearingComponents[i];
if (item != null)
{
cloned.BearingComponents.Add(CloneBearingComp(item));
}
}
}
if (source.BaseComponents != null)
{
for (int i = 0; i < source.BaseComponents.Count; i++)
{
BaseCompItemData item = source.BaseComponents[i];
if (item != null)
{
cloned.BaseComponents.Add(CloneBaseComp(item));
}
}
}
if (source.Towers != null)
{
for (int i = 0; i < source.Towers.Count; i++)
{
DefenseTowerItemData item = source.Towers[i];
if (item != null)
{
cloned.Towers.Add(CloneTower(item));
}
}
}
return cloned;
}
private static MuzzleCompItemData CloneMuzzleComp(MuzzleCompItemData source)
{
return new MuzzleCompItemData
{
InstanceId = source.InstanceId,
ConfigId = source.ConfigId,
Name = source.Name,
Rarity = source.Rarity,
Endurance = source.Endurance,
Constraint = source.Constraint,
Tags = CloneTags(source.Tags),
AttackDamage = CloneIntArray(source.AttackDamage),
DamageRandomRate = source.DamageRandomRate,
AttackMethodType = source.AttackMethodType
};
}
private static BearingCompItemData CloneBearingComp(BearingCompItemData source)
{
return new BearingCompItemData
{
InstanceId = source.InstanceId,
ConfigId = source.ConfigId,
Name = source.Name,
Rarity = source.Rarity,
Endurance = source.Endurance,
Constraint = source.Constraint,
Tags = CloneTags(source.Tags),
RotateSpeed = CloneFloatArray(source.RotateSpeed),
AttackRange = CloneFloatArray(source.AttackRange)
};
}
private static BaseCompItemData CloneBaseComp(BaseCompItemData source)
{
return new BaseCompItemData
{
InstanceId = source.InstanceId,
ConfigId = source.ConfigId,
Name = source.Name,
Rarity = source.Rarity,
Endurance = source.Endurance,
Constraint = source.Constraint,
Tags = CloneTags(source.Tags),
AttackSpeed = CloneFloatArray(source.AttackSpeed),
AttackPropertyType = source.AttackPropertyType
};
}
private static DefenseTowerItemData CloneTower(DefenseTowerItemData source)
{
return new DefenseTowerItemData
{
InstanceId = source.InstanceId,
Name = source.Name,
Rarity = source.Rarity,
Endurance = source.Endurance,
MuzzleComponentInstanceId = source.MuzzleComponentInstanceId,
BearingComponentInstanceId = source.BearingComponentInstanceId,
BaseComponentInstanceId = source.BaseComponentInstanceId,
Stats = CloneTowerStats(source.Stats)
};
}
private static DefenseTowerStatsData CloneTowerStats(DefenseTowerStatsData source)
{
if (source == null)
{
return new DefenseTowerStatsData();
}
return new DefenseTowerStatsData
{
AttackDamage = source.AttackDamage,
DamageRandomRate = source.DamageRandomRate,
RotateSpeed = source.RotateSpeed,
AttackRange = source.AttackRange,
AttackSpeed = source.AttackSpeed,
AttackMethodType = source.AttackMethodType,
AttackPropertyType = source.AttackPropertyType,
Tags = CloneTags(source.Tags)
};
}
private static int[] CloneIntArray(int[] source)
{
return source != null ? (int[])source.Clone() : Array.Empty<int>();
}
private static float[] CloneFloatArray(float[] source)
{
return source != null ? (float[])source.Clone() : Array.Empty<float>();
}
private static TagType[] CloneTags(TagType[] source)
{
return source != null ? (TagType[])source.Clone() : Array.Empty<TagType>();
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5db8d7bb243947fdbb9fe75a9d234d10
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -9,11 +9,11 @@ namespace GeometryTD.Definition
[Serializable]
public sealed class DefenseTowerStatsData
{
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 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 TagType[] Tags { get; set; }

View File

@ -8,13 +8,16 @@ namespace GeometryTD.Entity.EntityData
public class DefenseTowerData : EntityDataBase
{
[SerializeField] private DefenseTowerStatsData _stats = new DefenseTowerStatsData();
[SerializeField] private int _towerLevel = 0;
public DefenseTowerData(int entityId, int typeId, Vector3 position, Quaternion rotation, DefenseTowerStatsData stats)
public DefenseTowerData(int entityId, int typeId, Vector3 position, Quaternion rotation,
DefenseTowerStatsData stats, int towerLevel = 0)
: base(entityId, typeId)
{
Position = position;
Rotation = rotation;
_stats = stats ?? new DefenseTowerStatsData();
_towerLevel = Mathf.Max(0, towerLevel);
}
public DefenseTowerStatsData Stats
@ -22,5 +25,11 @@ namespace GeometryTD.Entity.EntityData
get => _stats;
set => _stats = value ?? new DefenseTowerStatsData();
}
public int TowerLevel
{
get => _towerLevel;
set => _towerLevel = Mathf.Max(0, value);
}
}
}

View File

@ -8,9 +8,15 @@ namespace GeometryTD.Entity
{
public sealed class CombatSelectInputService
{
public bool TryBuildUserData(Tilemap tilemap, Transform mapTransform,
IReadOnlyDictionary<Vector3Int, int> towerEntityIdByFoundationCell, Func<Vector3Int, bool> isFoundationCell,
int upgradeCost, int destroyGain, out CombatSelectFormUserData userData)
public bool TryBuildUserData(
Tilemap tilemap,
Transform mapTransform,
IReadOnlyDictionary<Vector3Int, int> towerEntityIdByFoundationCell,
Func<Vector3Int, bool> isFoundationCell,
Func<int, bool> isTowerAtMaxLevel,
int upgradeCost,
int destroyGain,
out CombatSelectFormUserData userData)
{
userData = null;
if (tilemap == null || !TryGetPointerWorldPosition(tilemap, mapTransform, out Vector3 worldPosition,
@ -44,13 +50,15 @@ namespace GeometryTD.Entity
WorldPosition = worldPosition,
CellPosition = clickedCell,
TowerEntityId = towerEntityId,
IsTowerAtMaxLevel = towerEntityId != 0 && isTowerAtMaxLevel != null && isTowerAtMaxLevel(towerEntityId),
UpgradeCost = Mathf.Max(0, upgradeCost),
DestroyGain = Mathf.Max(0, destroyGain)
};
return true;
}
private static bool TryGetPointerWorldPosition(Tilemap tilemap, Transform mapTransform, out Vector3 worldPosition,
private static bool TryGetPointerWorldPosition(Tilemap tilemap, Transform mapTransform,
out Vector3 worldPosition,
out Vector2 contentPosition)
{
worldPosition = Vector3.zero;
@ -63,7 +71,9 @@ namespace GeometryTD.Entity
}
Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition);
float mapPlaneZ = tilemap != null ? tilemap.transform.position.z : (mapTransform != null ? mapTransform.position.z : 0f);
float mapPlaneZ = tilemap != null
? tilemap.transform.position.z
: (mapTransform != null ? mapTransform.position.z : 0f);
Vector3 planeNormal = mainCamera.transform.forward.sqrMagnitude > Mathf.Epsilon
? -mainCamera.transform.forward
: Vector3.forward;

View File

@ -1,4 +1,5 @@
using Components;
using GeometryTD.Definition;
using GeometryTD.Entity.EntityData;
using UnityGameFramework.Runtime;
@ -13,6 +14,17 @@ namespace GeometryTD.Entity
_towerController?.SetAttackRangeVisible(visible);
}
public bool TryApplyStats(DefenseTowerStatsData stats, int towerLevel)
{
if (_towerController == null || stats == null)
{
return false;
}
_towerController.OnInit(stats, towerLevel);
return true;
}
protected override void OnInit(object userData)
{
base.OnInit(userData);
@ -41,8 +53,7 @@ namespace GeometryTD.Entity
return;
}
_towerController.SetAutoUpdate(false);
_towerController.OnInit(towerData.Stats);
_towerController.OnInit(towerData.Stats, towerData.TowerLevel);
}
protected override void OnUpdate(float elapseSeconds, float realElapseSeconds)

View File

@ -242,7 +242,8 @@ namespace GeometryTD.Entity
if (_combatSelectInputService == null ||
!_combatSelectInputService.TryBuildUserData(Tilemap, CachedTransform,
_towerPlacementService != null ? _towerPlacementService.TowerEntityIdByFoundationCell : null,
IsFoundationCell, _upgradeCost, _destroyGain, out CombatSelectFormUserData userData))
IsFoundationCell, IsTowerAtMaxLevel, _upgradeCost, _destroyGain,
out CombatSelectFormUserData userData))
{
userData = new CombatSelectFormUserData
{
@ -254,6 +255,11 @@ namespace GeometryTD.Entity
GameEntry.UIRouter.OpenUI(UIFormType.CombatSelectForm, userData);
}
private bool IsTowerAtMaxLevel(int towerEntityId)
{
return _towerPlacementService != null && _towerPlacementService.IsTowerAtMaxLevel(towerEntityId);
}
private void ApplySelectedObject(CombatSelectFormUserData userData)
{
_towerSelectionPresenter?.ApplySelectedObject(userData);

View File

@ -35,6 +35,7 @@ namespace GeometryTD.Procedure
GameEntry.EventNode.OnInit();
GameEntry.CombatNode.OnInit(LevelThemeType.Plain);
GameEntry.ShopNode.OnInit();
GameEntry.PlayerInventory?.OnInit();
_repoFormUseCase = new RepoFormUseCase();
GameEntry.UIRouter.BindUIUseCase(UIFormType.RepoForm, _repoFormUseCase);

View File

@ -1,26 +1,44 @@
using System;
using System.Collections.Generic;
using GeometryTD.Definition;
using GeometryTD.Entity;
using GeometryTD.Entity.EntityData;
using GeometryTD.Pathfinding;
using UnityEngine;
using UnityEngine.Tilemaps;
using UnityGameFramework.Runtime;
namespace GeometryTD.Map
{
public sealed class TowerPlacementService
{
private const int DefaultTowerTypeId = 401;
private const int MinTowerLevel = 0;
private const int MaxTowerLevel = 4;
private readonly Dictionary<Vector3Int, int> _towerEntityIdByFoundationCell = new();
private readonly Dictionary<int, Vector3Int> _foundationCellByTowerEntityId = new();
private readonly Dictionary<int, DefenseTowerStatsData> _towerStatsByEntityId = new();
private readonly Dictionary<int, int> _towerLevelByEntityId = new();
private readonly List<int> _towerEntityIdBuffer = new();
public IReadOnlyDictionary<Vector3Int, int> TowerEntityIdByFoundationCell => _towerEntityIdByFoundationCell;
public IReadOnlyDictionary<int, Vector3Int> FoundationCellByTowerEntityId => _foundationCellByTowerEntityId;
public bool IsTowerAtMaxLevel(int towerEntityId)
{
if (towerEntityId == 0)
{
return false;
}
DefenseTowerStatsData towerStats =
_towerStatsByEntityId.TryGetValue(towerEntityId, out DefenseTowerStatsData cachedStats)
? cachedStats
: null;
int currentLevel = GetTowerLevel(towerEntityId);
int maxLevel = ResolveMaxTowerLevel(towerStats);
return currentLevel >= maxLevel;
}
public bool TryBuildTower(Vector3Int foundationCell, Func<Vector3Int, bool> isFoundationCell, int buildIndex,
int[] buildTowerCosts, int towerTypeId, Tilemap tilemap, Func<int, bool> tryConsumeCoin,
Action<int> addCoin,
@ -53,6 +71,7 @@ namespace GeometryTD.Map
_towerEntityIdByFoundationCell[foundationCell] = newTowerEntityId;
_foundationCellByTowerEntityId[newTowerEntityId] = foundationCell;
_towerStatsByEntityId[newTowerEntityId] = CloneTowerStats(towerStats);
_towerLevelByEntityId[newTowerEntityId] = MinTowerLevel;
towerEntityId = newTowerEntityId;
return true;
}
@ -68,42 +87,36 @@ namespace GeometryTD.Map
return false;
}
int requiredUpgradeCost = Mathf.Max(0, upgradeCost);
if (tryConsumeCoin != null && !tryConsumeCoin(requiredUpgradeCost))
{
return false;
}
DefenseTowerStatsData oldStats =
DefenseTowerStatsData towerStats =
_towerStatsByEntityId.TryGetValue(towerEntityId, out DefenseTowerStatsData cachedStats)
? CloneTowerStats(cachedStats)
: BuildTowerStats(0);
DefenseTowerStatsData upgradedStats = CloneTowerStats(oldStats);
ApplyUpgradeToStats(upgradedStats);
HideTowerEntity(towerEntityId);
_towerEntityIdByFoundationCell.Remove(foundationCell);
_foundationCellByTowerEntityId.Remove(towerEntityId);
_towerStatsByEntityId.Remove(towerEntityId);
if (!TryShowTowerEntity(foundationCell, upgradedStats, towerTypeId, tilemap, out int newTowerEntityId))
int currentTowerLevel = GetTowerLevel(towerEntityId);
int maxTowerLevel = ResolveMaxTowerLevel(towerStats);
if (currentTowerLevel >= maxTowerLevel)
{
if (TryShowTowerEntity(foundationCell, oldStats, towerTypeId, tilemap, out int fallbackTowerEntityId))
{
_towerEntityIdByFoundationCell[foundationCell] = fallbackTowerEntityId;
_foundationCellByTowerEntityId[fallbackTowerEntityId] = foundationCell;
_towerStatsByEntityId[fallbackTowerEntityId] = CloneTowerStats(oldStats);
resultTowerEntityId = fallbackTowerEntityId;
}
addCoin?.Invoke(requiredUpgradeCost);
resultTowerEntityId = towerEntityId;
return false;
}
_towerEntityIdByFoundationCell[foundationCell] = newTowerEntityId;
_foundationCellByTowerEntityId[newTowerEntityId] = foundationCell;
_towerStatsByEntityId[newTowerEntityId] = CloneTowerStats(upgradedStats);
resultTowerEntityId = newTowerEntityId;
int requiredUpgradeCost = Mathf.Max(0, upgradeCost);
if (tryConsumeCoin != null && !tryConsumeCoin(requiredUpgradeCost))
{
resultTowerEntityId = towerEntityId;
return false;
}
int nextTowerLevel = Mathf.Clamp(currentTowerLevel + 1, MinTowerLevel, maxTowerLevel);
if (!TryApplyTowerStats(towerEntityId, towerStats, nextTowerLevel))
{
addCoin?.Invoke(requiredUpgradeCost);
resultTowerEntityId = towerEntityId;
return false;
}
_towerStatsByEntityId[towerEntityId] = CloneTowerStats(towerStats);
_towerLevelByEntityId[towerEntityId] = nextTowerLevel;
resultTowerEntityId = towerEntityId;
return true;
}
@ -120,6 +133,7 @@ namespace GeometryTD.Map
_towerEntityIdByFoundationCell.Remove(foundationCell);
_foundationCellByTowerEntityId.Remove(towerEntityId);
_towerStatsByEntityId.Remove(towerEntityId);
_towerLevelByEntityId.Remove(towerEntityId);
addCoin?.Invoke(Mathf.Max(0, destroyGain));
return true;
}
@ -140,6 +154,7 @@ namespace GeometryTD.Map
_towerEntityIdByFoundationCell.Clear();
_foundationCellByTowerEntityId.Clear();
_towerStatsByEntityId.Clear();
_towerLevelByEntityId.Clear();
_towerEntityIdBuffer.Clear();
}
@ -148,6 +163,7 @@ namespace GeometryTD.Map
_towerEntityIdByFoundationCell.Clear();
_foundationCellByTowerEntityId.Clear();
_towerStatsByEntityId.Clear();
_towerLevelByEntityId.Clear();
_towerEntityIdBuffer.Clear();
}
@ -175,7 +191,8 @@ namespace GeometryTD.Map
int typeId = towerTypeId > 0 ? towerTypeId : DefaultTowerTypeId;
Vector3 towerPosition = tilemap != null ? tilemap.GetCellCenterWorld(foundationCell) : foundationCell;
towerPosition.z = 0f;
var towerData = new DefenseTowerData(entityId, typeId, towerPosition, Quaternion.identity, towerStats);
var towerData = new DefenseTowerData(entityId, typeId, towerPosition, Quaternion.identity, towerStats,
MinTowerLevel);
GameEntry.Entity.ShowDefenseTower(towerData);
towerEntityId = entityId;
@ -203,11 +220,11 @@ namespace GeometryTD.Map
case 0:
return new DefenseTowerStatsData
{
AttackDamage = 200,
DamageRandomRate = 0f,
RotateSpeed = 200f,
AttackRange = 4.5f,
AttackSpeed = 1.5f,
AttackDamage = new[] { 200, 220, 240, 260, 300 },
DamageRandomRate = new[] { 0f, 0f, 0f, 0f, 0f },
RotateSpeed = new[] { 200f, 210f, 220f, 230f, 240f },
AttackRange = new[] { 4.5f, 4.7f, 4.9f, 5.1f, 5.3f },
AttackSpeed = new[] { 1.5f, 1.2f, 1.1f, 1.0f, 0.8f },
AttackMethodType = AttackMethodType.NormalBullet,
AttackPropertyType = AttackPropertyType.Physics,
Tags = Array.Empty<TagType>()
@ -215,11 +232,11 @@ namespace GeometryTD.Map
case 1:
return new DefenseTowerStatsData
{
AttackDamage = 260,
DamageRandomRate = 0f,
RotateSpeed = 160f,
AttackRange = 5f,
AttackSpeed = 1.2f,
AttackDamage = new[] { 200, 220, 240, 260, 300 },
DamageRandomRate = new[] { 0f, 0f, 0f, 0f, 0f },
RotateSpeed = new[] { 200f, 210f, 220f, 230f, 240f },
AttackRange = new[] { 4.5f, 4.7f, 4.9f, 5.1f, 5.3f },
AttackSpeed = new[] { 1.5f, 1.2f, 1.1f, 1.0f, 0.8f },
AttackMethodType = AttackMethodType.NormalBullet,
AttackPropertyType = AttackPropertyType.Fire,
Tags = Array.Empty<TagType>()
@ -227,11 +244,11 @@ namespace GeometryTD.Map
case 2:
return new DefenseTowerStatsData
{
AttackDamage = 340,
DamageRandomRate = 0f,
RotateSpeed = 140f,
AttackRange = 5.5f,
AttackSpeed = 0.95f,
AttackDamage = new[] { 200, 220, 240, 260, 300 },
DamageRandomRate = new[] { 0f, 0f, 0f, 0f, 0f },
RotateSpeed = new[] { 200f, 210f, 220f, 230f, 240f },
AttackRange = new[] { 4.5f, 4.7f, 4.9f, 5.1f, 5.3f },
AttackSpeed = new[] { 1.5f, 1.2f, 1.1f, 1.0f, 0.8f },
AttackMethodType = AttackMethodType.NormalBullet,
AttackPropertyType = AttackPropertyType.Ice,
Tags = Array.Empty<TagType>()
@ -239,11 +256,11 @@ namespace GeometryTD.Map
case 3:
return new DefenseTowerStatsData
{
AttackDamage = 440,
DamageRandomRate = 0f,
RotateSpeed = 120f,
AttackRange = 6f,
AttackSpeed = 0.75f,
AttackDamage = new[] { 200, 220, 240, 260, 300 },
DamageRandomRate = new[] { 0f, 0f, 0f, 0f, 0f },
RotateSpeed = new[] { 200f, 210f, 220f, 230f, 240f },
AttackRange = new[] { 4.5f, 4.7f, 4.9f, 5.1f, 5.3f },
AttackSpeed = new[] { 1.5f, 1.2f, 1.1f, 1.0f, 0.8f },
AttackMethodType = AttackMethodType.NormalBullet,
AttackPropertyType = AttackPropertyType.Poison,
Tags = Array.Empty<TagType>()
@ -251,11 +268,11 @@ namespace GeometryTD.Map
default:
return new DefenseTowerStatsData
{
AttackDamage = 200,
DamageRandomRate = 0f,
RotateSpeed = 180f,
AttackRange = 5f,
AttackSpeed = 1f,
AttackDamage = new[] { 200, 220, 240, 260, 300 },
DamageRandomRate = new[] { 0f, 0f, 0f, 0f, 0f },
RotateSpeed = new[] { 200f, 210f, 220f, 230f, 240f },
AttackRange = new[] { 4.5f, 4.7f, 4.9f, 5.1f, 5.3f },
AttackSpeed = new[] { 1.5f, 1.2f, 1.1f, 1.0f, 0.8f },
AttackMethodType = AttackMethodType.NormalBullet,
AttackPropertyType = AttackPropertyType.Physics,
Tags = Array.Empty<TagType>()
@ -273,28 +290,63 @@ namespace GeometryTD.Map
TagType[] copiedTags = source.Tags != null ? (TagType[])source.Tags.Clone() : Array.Empty<TagType>();
return new DefenseTowerStatsData
{
AttackDamage = source.AttackDamage,
DamageRandomRate = source.DamageRandomRate,
RotateSpeed = source.RotateSpeed,
AttackRange = source.AttackRange,
AttackSpeed = source.AttackSpeed,
AttackDamage = source.AttackDamage != null ? (int[])source.AttackDamage.Clone() : Array.Empty<int>(),
DamageRandomRate = source.DamageRandomRate != null
? (float[])source.DamageRandomRate.Clone()
: Array.Empty<float>(),
RotateSpeed = source.RotateSpeed != null ? (float[])source.RotateSpeed.Clone() : Array.Empty<float>(),
AttackRange = source.AttackRange != null ? (float[])source.AttackRange.Clone() : Array.Empty<float>(),
AttackSpeed = source.AttackSpeed != null ? (float[])source.AttackSpeed.Clone() : Array.Empty<float>(),
AttackMethodType = source.AttackMethodType,
AttackPropertyType = source.AttackPropertyType,
Tags = copiedTags
};
}
private static void ApplyUpgradeToStats(DefenseTowerStatsData stats)
private int GetTowerLevel(int towerEntityId)
{
if (stats == null)
if (towerEntityId == 0 || !_towerLevelByEntityId.TryGetValue(towerEntityId, out int towerLevel))
{
return;
return MinTowerLevel;
}
stats.AttackDamage = Mathf.Max(1, stats.AttackDamage + 3);
stats.AttackSpeed = Mathf.Max(0.01f, stats.AttackSpeed + 0.2f);
stats.AttackRange = Mathf.Max(0.1f, stats.AttackRange + 0.4f);
stats.RotateSpeed = Mathf.Max(1f, stats.RotateSpeed + 10f);
return Mathf.Clamp(towerLevel, MinTowerLevel, MaxTowerLevel);
}
private static int ResolveMaxTowerLevel(DefenseTowerStatsData stats)
{
int maxCount = Mathf.Max(
GetLength(stats?.AttackDamage),
GetLength(stats?.DamageRandomRate),
GetLength(stats?.RotateSpeed),
GetLength(stats?.AttackRange),
GetLength(stats?.AttackSpeed));
if (maxCount <= 0)
{
return MinTowerLevel;
}
return Mathf.Clamp(maxCount - 1, MinTowerLevel, MaxTowerLevel);
}
private static bool TryApplyTowerStats(int towerEntityId, DefenseTowerStatsData towerStats, int towerLevel)
{
if (towerEntityId == 0 || towerStats == null || GameEntry.Entity == null)
{
return false;
}
if (GameEntry.Entity.GetGameEntity(towerEntityId) is not DefenseTowerEntity towerEntity)
{
return false;
}
return towerEntity.TryApplyStats(towerStats, towerLevel);
}
private static int GetLength<T>(T[] values)
{
return values != null ? values.Length : 0;
}
}
}

View File

@ -98,10 +98,12 @@ namespace GeometryTD.UI
switch (userData.ClickObjectType)
{
case CombatSelectClickObjectType.Foundation:
_useCase.SetUpgradeVisible(true);
_useCase.ShowForFoundation(userData.ContentPosition);
break;
case CombatSelectClickObjectType.Tower:
_useCase.SetUpgradePrice(userData.UpgradeCost);
_useCase.SetUpgradeVisible(!userData.IsTowerAtMaxLevel);
_useCase.SetDestroyGain(userData.DestroyGain);
_useCase.ShowForTower(userData.ContentPosition);
break;
@ -133,6 +135,7 @@ namespace GeometryTD.UI
return null;
}
_useCase.SetUpgradeVisible(true);
_useCase.ShowForFoundation(contentPosition);
return OpenUI(_useCase.TryRefresh());
}
@ -146,6 +149,7 @@ namespace GeometryTD.UI
}
_useCase.SetUpgradePrice(upgradeCost);
_useCase.SetUpgradeVisible(true);
_useCase.SetDestroyGain(destroyGain);
_useCase.ShowForTower(contentPosition);
return OpenUI(_useCase.TryRefresh());

View File

@ -9,6 +9,7 @@ namespace GeometryTD.UI
public Vector3 WorldPosition;
public Vector3Int CellPosition;
public int TowerEntityId;
public bool IsTowerAtMaxLevel;
public int UpgradeCost;
public int DestroyGain;
}

View File

@ -6,7 +6,6 @@ namespace GeometryTD.UI
{
public class CombatFinishFormUseCase : IUIUseCase
{
private readonly RepoFormUseCase _repoFormUseCase = new RepoFormUseCase();
private CombatScheduler _combatScheduler;
private int _defeatedEnemyCount;
private int _gainedGold;
@ -57,8 +56,7 @@ namespace GeometryTD.UI
BackpackInventoryData rewardInventory = _rewardInventory;
if (rewardInventory == null)
{
RepoFormRawData repoRawData = _repoFormUseCase.CreateInitialModel();
rewardInventory = repoRawData != null ? repoRawData.Inventory : null;
rewardInventory = RepoFormUseCase.SampleInventory();
}
return new CombatFinishFormRawData

View File

@ -134,6 +134,11 @@ namespace GeometryTD.UI
_upgradeOption.Price = Mathf.Max(0, cost);
}
public void SetUpgradeVisible(bool visible)
{
_upgradeOption.IsVisible = visible;
}
public void SetDestroyGain(int gain)
{
_destroyOption.Price = Mathf.Max(0, gain);

View File

@ -7,14 +7,16 @@ namespace GeometryTD.UI
{
public RepoFormRawData CreateInitialModel()
{
BackpackInventoryData sample = BuildSampleInventory();
BackpackInventoryData sample = GameEntry.PlayerInventory != null
? GameEntry.PlayerInventory.GetInventorySnapshot()
: SampleInventory();
return new RepoFormRawData
{
Inventory = sample
};
}
private static BackpackInventoryData BuildSampleInventory()
public static BackpackInventoryData SampleInventory()
{
BackpackInventoryData inventory = new BackpackInventoryData
{
@ -75,11 +77,11 @@ namespace GeometryTD.UI
BaseComponentInstanceId = baseComp.InstanceId,
Stats = new DefenseTowerStatsData
{
AttackDamage = 30,
DamageRandomRate = 0.05f,
RotateSpeed = 12f,
AttackRange = 2f,
AttackSpeed = 1.5f,
AttackDamage = new[] { 200, 220, 240, 260, 300 },
DamageRandomRate = new[] { 0f, 0f, 0f, 0f, 0f },
RotateSpeed = new[] { 200f, 210f, 220f, 230f, 240f },
AttackRange = new[] { 4.5f, 4.7f, 4.9f, 5.1f, 5.3f },
AttackSpeed = new[] { 1.5f, 1.2f, 1.1f, 1.0f, 0.8f },
AttackMethodType = AttackMethodType.NormalBullet,
AttackPropertyType = AttackPropertyType.Fire,
Tags = new[] { TagType.Fire, TagType.BurnSpread }
@ -98,11 +100,11 @@ namespace GeometryTD.UI
BaseComponentInstanceId = 0,
Stats = new DefenseTowerStatsData
{
AttackDamage = 50,
DamageRandomRate = 0.03f,
RotateSpeed = 20f,
AttackRange = 4f,
AttackSpeed = 1f,
AttackDamage = new[] { 200, 220, 240, 260, 300 },
DamageRandomRate = new[] { 0f, 0f, 0f, 0f, 0f },
RotateSpeed = new[] { 200f, 210f, 220f, 230f, 240f },
AttackRange = new[] { 4.5f, 4.7f, 4.9f, 5.1f, 5.3f },
AttackSpeed = new[] { 1.5f, 1.2f, 1.1f, 1.0f, 0.8f },
AttackMethodType = AttackMethodType.NormalBullet,
AttackPropertyType = AttackPropertyType.Physics,
Tags = new[] { TagType.Pierce }

View File

@ -153,11 +153,9 @@ Transform:
m_Children:
- {fileID: 513208573}
- {fileID: 1604812193}
- {fileID: 159392563}
- {fileID: 1549230541}
- {fileID: 1758164286}
- {fileID: 2007255511}
- {fileID: 428539048}
- {fileID: 1322505022}
m_Father: {fileID: 1852670053}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &120093239
@ -275,12 +273,12 @@ Transform:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 159392562}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 119167776}
m_Father: {fileID: 1322505022}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &159392564
MonoBehaviour:
@ -883,6 +881,85 @@ LightingSettings:
m_PVRTiledBaking: 0
m_NumRaysToShootPerTexel: -1
m_RespectSceneVisibilityWhenBakingGI: 0
--- !u!1 &1239157580
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1239157581}
- component: {fileID: 1239157582}
m_Layer: 0
m_Name: PlayerInventory
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &1239157581
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1239157580}
serializedVersion: 2
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1322505022}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1239157582
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1239157580}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5db8d7bb243947fdbb9fe75a9d234d10, type: 3}
m_Name:
m_EditorClassIdentifier:
--- !u!1 &1322505021
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1322505022}
m_Layer: 0
m_Name: TowerDefense
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &1322505022
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1322505021}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1239157581}
- {fileID: 159392563}
- {fileID: 1549230541}
- {fileID: 1758164286}
m_Father: {fileID: 119167776}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1454214586
GameObject:
m_ObjectHideFlags: 0
@ -991,12 +1068,12 @@ Transform:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1549230540}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 119167776}
m_Father: {fileID: 1322505022}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1549230542
MonoBehaviour:
@ -1184,12 +1261,12 @@ Transform:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1758164285}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 119167776}
m_Father: {fileID: 1322505022}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1758164287
MonoBehaviour:

View File

@ -14,11 +14,11 @@
| 状态 | ID | 任务 | 交付物路径 | 验收标准 |
|-----|-------|-------------------------------------|----------------------------------------------------------------------------------------|---------------------------|
| [x] | P0-01 | 冻结 MVP 范围(只保留:战斗节点/事件节点/商店节点/节点后组装) | `docs/MVP-Scope.md` | 明确“做/不做”清单,团队评审通过 |
| [ ] | P0-02 | 补齐数据表:组件、配件、敌人、波次、节点、事件、商店商品 | `Assets/GameMain/DataTables/*.txt` | 游戏启动可无报错加载全部新增表 |
| [ ] | P0-03 | 新增/完善 DataRow 解析类 | `Assets/GameMain/Scripts/DataTable/*.cs` | 每个新增表都有对应 DR 类并成功解析 |
| [ ] | P0-04 | 建立单局 Run 状态模型(金币、生命、库存、当前节点) | `Assets/GameMain/Scripts/Procedure/` | 进入/退出节点时状态可持续传递 |
| [x] | P0-02 | 补齐数据表:组件、配件、敌人、波次、节点、事件、商店商品 | `Assets/GameMain/DataTables/*.txt` | 游戏启动可无报错加载全部新增表 |
| [x] | P0-03 | 新增/完善 DataRow 解析类 | `Assets/GameMain/Scripts/DataTable/*.cs` | 每个新增表都有对应 DR 类并成功解析 |
| [x] | P0-04 | 建立单局 Run 状态模型(金币、生命、库存、当前节点) | `Assets/GameMain/Scripts/Procedure/` | 进入/退出节点时状态可持续传递 |
| [ ] | P0-05 | 实现 10 节点地图生成(最后节点固定 Boss | `Assets/GameMain/Scripts/Scene/` | 每局都生成 10 节点且第 10 节点为 Boss |
| [ ] | P0-06 | 实现节点选择与跳转流程(战斗/事件/商店) | `Assets/GameMain/Scripts/Procedure/` | 节点完成后可返回地图并进入下个节点 |
| [x] | P0-06 | 实现节点选择与跳转流程(战斗/事件/商店) | `Assets/GameMain/Scripts/Procedure/` | 节点完成后可返回地图并进入下个节点 |
| [ ] | P0-07 | 战斗节点基础玩法:放置塔、出怪、基地扣血、胜负判定 | `Assets/GameMain/Scripts/Entity/`<br>`Assets/GameMain/Scripts/Scene/` | 可完整打一场并得到胜利/失败结果 |
| [ ] | P0-08 | 胜利波次与结算规则100/80/50/<50 | `Assets/GameMain/Scripts/Procedure/` | 结算奖励与惩罚严格匹配设计文档 |
| [ ] | P0-09 | 敌人掉落与关卡奖励(组件/配件/金币) | `Assets/GameMain/Scripts/Entity/` | 战斗结束能发放掉落并写入库存 |