From 5c7501d4fb105aa5ca2eb81531edff76cb925cdc Mon Sep 17 00:00:00 2001 From: SepComet <202308010230@stu.csust.edu.cn> Date: Mon, 2 Mar 2026 11:04:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=20MapEntity=20=E8=81=8C?= =?UTF-8?q?=E8=B4=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TowerSelectionPresenter:选中态 + 攻击范围展示 - CombatSelectInputService:点击事件逻辑(位置计算、点击对象识别、组装 CombatSelectFormUserData) - TowerPlacementService:建造/升级/销毁与塔映射管理 - MapTopologyService:维护地图拓扑结构与寻路缓存 --- .../GameMain/DataTables/LevelSpawnEntry.txt | 58 +- .../CombatNode/EnemyManager.cs | 1 + .../EntityLogic/CombatSelectInputService.cs | 111 +++ .../CombatSelectInputService.cs.meta} | 5 +- .../Scripts/Entity/EntityLogic/MapEntity.cs | 677 +++--------------- Assets/GameMain/Scripts/Localization.meta | 9 - .../Localization/XmlLocalizationHelper.cs | 77 -- .../Scripts/Procedure/ProcedurePreload.cs | 2 +- Assets/GameMain/Scripts/Scene/Map.meta | 3 + .../GameMain/Scripts/Scene/{ => Map}/House.cs | 2 +- .../Scripts/Scene/{ => Map}/House.cs.meta | 0 .../Scripts/Scene/{ => Map}/MapDataRefs.cs | 2 +- .../Scene/{ => Map}/MapDataRefs.cs.meta | 0 .../Scripts/Scene/Map/MapTopologyService.cs | 255 +++++++ .../Scene/Map/MapTopologyService.cs.meta | 3 + .../Scripts/Scene/{ => Map}/Spawner.cs | 2 +- .../Scripts/Scene/{ => Map}/Spawner.cs.meta | 0 .../Scene/Map/TowerPlacementService.cs | 300 ++++++++ .../Scene/Map/TowerPlacementService.cs.meta | 3 + .../Scene/Map/TowerSelectionPresenter.cs | 134 ++++ .../Scene/Map/TowerSelectionPresenter.cs.meta | 11 + Assets/Launcher.unity | 5 +- docs/MapEntityArchitecture.md | 147 ++++ 23 files changed, 1105 insertions(+), 702 deletions(-) create mode 100644 Assets/GameMain/Scripts/Entity/EntityLogic/CombatSelectInputService.cs rename Assets/GameMain/Scripts/{Localization/XmlLocalizationHelper.cs.meta => Entity/EntityLogic/CombatSelectInputService.cs.meta} (69%) delete mode 100644 Assets/GameMain/Scripts/Localization.meta delete mode 100644 Assets/GameMain/Scripts/Localization/XmlLocalizationHelper.cs create mode 100644 Assets/GameMain/Scripts/Scene/Map.meta rename Assets/GameMain/Scripts/Scene/{ => Map}/House.cs (86%) rename Assets/GameMain/Scripts/Scene/{ => Map}/House.cs.meta (100%) rename Assets/GameMain/Scripts/Scene/{ => Map}/MapDataRefs.cs (97%) rename Assets/GameMain/Scripts/Scene/{ => Map}/MapDataRefs.cs.meta (100%) create mode 100644 Assets/GameMain/Scripts/Scene/Map/MapTopologyService.cs create mode 100644 Assets/GameMain/Scripts/Scene/Map/MapTopologyService.cs.meta rename Assets/GameMain/Scripts/Scene/{ => Map}/Spawner.cs (91%) rename Assets/GameMain/Scripts/Scene/{ => Map}/Spawner.cs.meta (100%) create mode 100644 Assets/GameMain/Scripts/Scene/Map/TowerPlacementService.cs create mode 100644 Assets/GameMain/Scripts/Scene/Map/TowerPlacementService.cs.meta create mode 100644 Assets/GameMain/Scripts/Scene/Map/TowerSelectionPresenter.cs create mode 100644 Assets/GameMain/Scripts/Scene/Map/TowerSelectionPresenter.cs.meta create mode 100644 docs/MapEntityArchitecture.md diff --git a/Assets/GameMain/DataTables/LevelSpawnEntry.txt b/Assets/GameMain/DataTables/LevelSpawnEntry.txt index ac1cd94..6e053ee 100644 --- a/Assets/GameMain/DataTables/LevelSpawnEntry.txt +++ b/Assets/GameMain/DataTables/LevelSpawnEntry.txt @@ -1,33 +1,33 @@ # Id 列1 SpawnPointId StartTime EntryType EnemyId Count Interval Duration Gap # int int int EntryType int int float int float # 阶段条目号 策划备注 敌人出生口Id 相对时间 条目类型 敌人Id 单次出怪数量 出怪间隔 持续时间 单怪出生时间间隔 - 1001001 1 5 Stream 1 3 5 60 0 - 1001002 2 5 Burst 1 10 0 0 0.5 - 1002001 1 3 Stream 1 3 5 60 0 - 1002002 2 3 Burst 1 10 0 0 0.5 - 1003001 1 5 Stream 1 3 5 60 0 - 1003002 2 5 Burst 1 10 0 0 0.5 - 1004001 1 3 Stream 1 3 5 60 0 - 1004002 2 3 Burst 1 10 0 0 0.5 - 1005001 1 5 Stream 1 3 5 60 0 - 1005002 2 5 Burst 1 10 0 0 0.5 - 2001001 1 5 Stream 1 3 5 60 0 - 2002001 1 5 Burst 1 10 0 0 0.5 - 2003001 1 5 Stream 1 3 5 60 0 - 2004001 1 5 Burst 1 10 0 0 0.5 - 2005001 1 5 Stream 1 3 5 60 0 - 3001001 1 5 Stream 1 3 5 60 0 - 3001002 2 5 Burst 1 10 0 0 0.5 - 3002001 1 3 Stream 1 3 5 60 0 - 3002002 2 3 Burst 1 10 0 0 0.5 - 3003001 1 5 Stream 1 3 5 60 0 - 3003002 2 5 Burst 1 10 0 0 0.5 - 3004001 1 3 Stream 1 3 5 60 0 - 3004002 2 3 Burst 1 10 0 0 0.5 - 3005001 1 5 Stream 1 3 5 60 0 - 3005002 2 5 Burst 1 10 0 0 0.5 - 4001001 1 5 Stream 1 3 5 60 0 - 4002001 1 5 Burst 1 10 0 0 0.5 - 4003001 1 5 Stream 1 3 5 60 0 - 4004001 1 5 Burst 1 10 0 0 0.5 + 1001001 1 5 Stream 1 3 5 60 0.5 + 1001002 2 5 Burst 1 10 0 0 0.2 + 1002001 1 3 Stream 1 3 5 60 0.5 + 1002002 2 3 Burst 1 10 0 0 0.2 + 1003001 1 5 Stream 1 3 5 60 0.5 + 1003002 2 5 Burst 1 10 0 0 0.2 + 1004001 1 3 Stream 1 3 5 60 0.5 + 1004002 2 3 Burst 1 10 0 0 0.2 + 1005001 1 5 Stream 1 3 5 60 0.5 + 1005002 2 5 Burst 1 10 0 0 0.2 + 2001001 1 5 Stream 1 3 5 60 0.5 + 2002001 1 5 Burst 1 10 0 0 0.2 + 2003001 1 5 Stream 1 3 5 60 0.5 + 2004001 1 5 Burst 1 10 0 0 0.2 + 2005001 1 5 Stream 1 3 5 60 0.5 + 3001001 1 5 Stream 1 3 5 60 0.5 + 3001002 2 5 Burst 1 10 0 0 0.2 + 3002001 1 3 Stream 1 3 5 60 0.5 + 3002002 2 3 Burst 1 10 0 0 0.2 + 3003001 1 5 Stream 1 3 5 60 0.5 + 3003002 2 5 Burst 1 10 0 0 0.2 + 3004001 1 3 Stream 1 3 5 60 0.5 + 3004002 2 3 Burst 1 10 0 0 0.2 + 3005001 1 5 Stream 1 3 5 60 0.5 + 3005002 2 5 Burst 1 10 0 0 0.2 + 4001001 1 5 Stream 1 3 5 60 0.5 + 4002001 1 5 Burst 1 10 0 0 0.2 + 4003001 1 5 Stream 1 3 5 60 0.5 + 4004001 1 5 Burst 1 10 0 0 0.2 4005001 1 5 Boss 2 1 5 60 0 diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager.cs index 830269b..b23d60c 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/EnemyManager.cs @@ -5,6 +5,7 @@ using GeometryTD.DataTable; using GeometryTD.Definition; using GeometryTD.Entity; using GeometryTD.Entity.EntityData; +using GeometryTD.Map; using UnityEngine; using UnityGameFramework.Runtime; diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/CombatSelectInputService.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/CombatSelectInputService.cs new file mode 100644 index 0000000..2cbcb65 --- /dev/null +++ b/Assets/GameMain/Scripts/Entity/EntityLogic/CombatSelectInputService.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using GeometryTD.UI; +using UnityEngine; +using UnityEngine.Tilemaps; + +namespace GeometryTD.Entity +{ + public sealed class CombatSelectInputService + { + public bool TryBuildUserData(Tilemap tilemap, Transform mapTransform, + IReadOnlyDictionary towerEntityIdByFoundationCell, Func isFoundationCell, + int upgradeCost, int destroyGain, out CombatSelectFormUserData userData) + { + userData = null; + if (tilemap == null || !TryGetPointerWorldPosition(tilemap, mapTransform, out Vector3 worldPosition, + out Vector2 contentPosition)) + { + return false; + } + + Vector3Int clickedCell = tilemap.WorldToCell(worldPosition); + CombatSelectClickObjectType clickObjectType = CombatSelectClickObjectType.None; + int towerEntityId = 0; + Vector2 resolvedContentPosition = contentPosition; + + if (towerEntityIdByFoundationCell != null && + towerEntityIdByFoundationCell.TryGetValue(clickedCell, out int occupiedTowerEntityId)) + { + clickObjectType = CombatSelectClickObjectType.Tower; + towerEntityId = occupiedTowerEntityId; + resolvedContentPosition = BuildContentPositionFromCell(tilemap, clickedCell, contentPosition); + } + else if (isFoundationCell != null && isFoundationCell(clickedCell)) + { + clickObjectType = CombatSelectClickObjectType.Foundation; + resolvedContentPosition = BuildContentPositionFromCell(tilemap, clickedCell, contentPosition); + } + + userData = new CombatSelectFormUserData + { + ClickObjectType = clickObjectType, + ContentPosition = resolvedContentPosition, + WorldPosition = worldPosition, + CellPosition = clickedCell, + TowerEntityId = towerEntityId, + UpgradeCost = Mathf.Max(0, upgradeCost), + DestroyGain = Mathf.Max(0, destroyGain) + }; + return true; + } + + private static bool TryGetPointerWorldPosition(Tilemap tilemap, Transform mapTransform, out Vector3 worldPosition, + out Vector2 contentPosition) + { + worldPosition = Vector3.zero; + contentPosition = Vector2.zero; + + Camera mainCamera = GameEntry.Scene != null ? GameEntry.Scene.MainCamera : Camera.main; + if (mainCamera == null) + { + return false; + } + + Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition); + 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; + Plane mapPlane = new Plane(planeNormal, new Vector3(0f, 0f, mapPlaneZ)); + if (!mapPlane.Raycast(ray, out float enterDistance)) + { + return false; + } + + worldPosition = ray.GetPoint(enterDistance); + contentPosition = BuildContentPosition(Input.mousePosition); + contentPosition = new Vector2((int)contentPosition.x, (int)contentPosition.y); + contentPosition = new Vector2(contentPosition.x + 0.5f, contentPosition.y + 0.5f); + return true; + } + + private static Vector2 BuildContentPosition(Vector3 pointerScreenPosition) + { + return new Vector2( + pointerScreenPosition.x - Screen.width * 0.5f, + pointerScreenPosition.y - Screen.height * 0.5f); + } + + private static Vector2 BuildContentPositionFromCell(Tilemap tilemap, Vector3Int cellPosition, + Vector2 fallbackContentPosition) + { + if (tilemap == null) + { + return fallbackContentPosition; + } + + Camera mainCamera = GameEntry.Scene != null ? GameEntry.Scene.MainCamera : Camera.main; + if (mainCamera == null) + { + return fallbackContentPosition; + } + + Vector3 cellCenterWorld = tilemap.GetCellCenterWorld(cellPosition); + Vector3 cellScreenPosition = mainCamera.WorldToScreenPoint(cellCenterWorld); + Vector2 contentPosition = BuildContentPosition(cellScreenPosition); + contentPosition = new Vector2((int)contentPosition.x, (int)contentPosition.y); + return new Vector2(contentPosition.x + 0.5f, contentPosition.y + 0.5f); + } + } +} diff --git a/Assets/GameMain/Scripts/Localization/XmlLocalizationHelper.cs.meta b/Assets/GameMain/Scripts/Entity/EntityLogic/CombatSelectInputService.cs.meta similarity index 69% rename from Assets/GameMain/Scripts/Localization/XmlLocalizationHelper.cs.meta rename to Assets/GameMain/Scripts/Entity/EntityLogic/CombatSelectInputService.cs.meta index dc0f028..556446a 100644 --- a/Assets/GameMain/Scripts/Localization/XmlLocalizationHelper.cs.meta +++ b/Assets/GameMain/Scripts/Entity/EntityLogic/CombatSelectInputService.cs.meta @@ -1,8 +1,7 @@ fileFormatVersion: 2 -guid: 44a338771c89a5348b00b9b0c13c1907 -timeCreated: 1528026156 -licenseType: Pro +guid: 4fb7517aceac0814685c7260ac02ae6f MonoImporter: + externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/MapEntity.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/MapEntity.cs index 5388b43..23b3be6 100644 --- a/Assets/GameMain/Scripts/Entity/EntityLogic/MapEntity.cs +++ b/Assets/GameMain/Scripts/Entity/EntityLogic/MapEntity.cs @@ -1,9 +1,8 @@ using System; using System.Collections.Generic; -using GeometryTD; +using GeometryTD.Map; using GeometryTD.Definition; using GeometryTD.Entity.EntityData; -using GeometryTD.Pathfinding; using GeometryTD.UI; using UnityEngine; using UnityEngine.EventSystems; @@ -14,24 +13,9 @@ namespace GeometryTD.Entity { public class MapEntity : EntityBase { - private const string PathTileName = "Path"; - private const string FoundationTileName = "Foundation"; private const int DefaultTowerTypeId = 401; private static readonly Spawner[] EmptySpawners = Array.Empty(); - private readonly List _pathCells = new(); - private readonly List _foundationCells = new(); - private readonly HashSet _pathCellSet = new(); - private readonly HashSet _foundationCellSet = new(); - private readonly IMapPathfinder _mapPathfinder = new GridMapPathfinder(); - private readonly List _pathCellBuffer = new(); - private readonly Dictionary _spawnerPathStartByRef = new(); - private readonly Dictionary> _defaultPathCellsBySpawner = new(); - private readonly Dictionary _towerEntityIdByFoundationCell = new(); - private readonly Dictionary _foundationCellByTowerEntityId = new(); - private readonly Dictionary _towerStatsByEntityId = new(); - private readonly List _towerEntityIdBuffer = new(); - [SerializeField] private bool _enableCombatSelectInput = true; [SerializeField] private int _towerTypeId = DefaultTowerTypeId; [SerializeField] private int[] _buildTowerCosts = { 80, 120, 160, 220 }; @@ -40,131 +24,62 @@ namespace GeometryTD.Entity private MapDataRefs _mapDataRefs; private MapData _mapData; - private bool _hasHousePathCell; - private Vector3Int _housePathCell; + private MapTopologyService _mapTopologyService; private CombatSelectFormUseCase _combatSelectFormUseCase; - private bool _hasSelectedFoundationCell; - private Vector3Int _selectedFoundationCell; - private int _selectedTowerEntityId; - private int _attackRangeVisibleTowerEntityId; + private CombatSelectInputService _combatSelectInputService; + private TowerPlacementService _towerPlacementService; + private TowerSelectionPresenter _towerSelectionPresenter; - public IReadOnlyList PathCells => _pathCells; - public IReadOnlyList FoundationCells => _foundationCells; + public IReadOnlyList PathCells => _mapTopologyService != null + ? _mapTopologyService.PathCells + : Array.Empty(); + public IReadOnlyList FoundationCells => _mapTopologyService != null + ? _mapTopologyService.FoundationCells + : Array.Empty(); public Tilemap Tilemap => _mapDataRefs != null ? _mapDataRefs.Tilemap : null; public Spawner[] Spawners => _mapDataRefs?.Spawners ?? EmptySpawners; public House House => _mapDataRefs?.House; public bool IsPathCell(Vector3Int cellPosition) { - return _pathCellSet.Contains(cellPosition); + return _mapTopologyService != null && _mapTopologyService.IsPathCell(cellPosition); } public bool IsFoundationCell(Vector3Int cellPosition) { - return _foundationCellSet.Contains(cellPosition); + return _mapTopologyService != null && _mapTopologyService.IsFoundationCell(cellPosition); } public bool TryGetNearestPathCell(Vector3 worldPosition, out Vector3Int pathCell) { pathCell = default; - if (_pathCells.Count <= 0 || Tilemap == null) - { - return false; - } - - Vector3Int directCell = Tilemap.WorldToCell(worldPosition); - if (_pathCellSet.Contains(directCell)) - { - pathCell = directCell; - return true; - } - - float minDistance = float.MaxValue; - for (int i = 0; i < _pathCells.Count; i++) - { - Vector3Int candidate = _pathCells[i]; - float distance = (Tilemap.GetCellCenterWorld(candidate) - worldPosition).sqrMagnitude; - if (distance >= minDistance) - { - continue; - } - - minDistance = distance; - pathCell = candidate; - } - - return minDistance < float.MaxValue; + return _mapTopologyService != null && _mapTopologyService.TryGetNearestPathCell(Tilemap, worldPosition, out pathCell); } public Vector3 GetPathCellCenterWorld(Vector3Int pathCell) { - return Tilemap != null ? Tilemap.GetCellCenterWorld(pathCell) : Vector3.zero; + return _mapTopologyService != null + ? _mapTopologyService.GetPathCellCenterWorld(Tilemap, pathCell) + : Vector3.zero; } public bool TryGetDefaultPathCells(Spawner spawner, out IReadOnlyList pathCells) { pathCells = null; - if (spawner == null) - { - return false; - } - - if (!_defaultPathCellsBySpawner.TryGetValue(spawner, out List cachedPathCells)) - { - return false; - } - - pathCells = cachedPathCells; - return true; + return _mapTopologyService != null && _mapTopologyService.TryGetDefaultPathCells(spawner, out pathCells); } public bool TryFindPathCells(Spawner spawner, IReadOnlyCollection blockedCells, List pathResult) { - if (pathResult == null) - { - return false; - } - - pathResult.Clear(); - if (spawner == null || !_hasHousePathCell) - { - return false; - } - - if (!_spawnerPathStartByRef.TryGetValue(spawner, out Vector3Int startCell)) - { - return false; - } - - return _mapPathfinder.TryFindPath(_pathCells, startCell, _housePathCell, blockedCells, pathResult); + return _mapTopologyService != null && _mapTopologyService.TryFindPathCells(spawner, blockedCells, pathResult); } public bool TryFindPathWorldPoints(Spawner spawner, IReadOnlyCollection blockedCells, List worldPathResult) { - if (worldPathResult == null) - { - return false; - } - - worldPathResult.Clear(); - if (Tilemap == null) - { - return false; - } - - if (!TryFindPathCells(spawner, blockedCells, _pathCellBuffer)) - { - return false; - } - - foreach (var pos in _pathCellBuffer) - { - worldPathResult.Add(GetPathCellCenterWorld(pos)); - } - - return true; + return _mapTopologyService != null && + _mapTopologyService.TryFindPathWorldPoints(Tilemap, spawner, blockedCells, worldPathResult); } protected override void OnInit(object userData) @@ -178,6 +93,10 @@ namespace GeometryTD.Entity } InitializeCombatSelectUseCase(); + InitializeCombatSelectInputService(); + InitializeMapTopologyService(); + InitializeTowerPlacementService(); + InitializeTowerSelectionPresenter(); } protected override void OnShow(object userData) @@ -198,7 +117,7 @@ namespace GeometryTD.Entity protected override void OnHide(bool isShutdown, object userData) { HideCombatSelectForm(); - ClearPlacedTowers(); + _towerPlacementService?.HideAndClearAllPlacedTowers(); ClearRuntimeData(); base.OnHide(isShutdown, userData); } @@ -230,108 +149,14 @@ namespace GeometryTD.Entity return; } - BoundsInt bounds = tilemap.cellBounds; - foreach (Vector3Int cellPosition in bounds.allPositionsWithin) - { - TileBase tile = tilemap.GetTile(cellPosition); - if (tile == null || string.IsNullOrEmpty(tile.name)) - { - continue; - } - - if (string.Equals(tile.name, PathTileName, StringComparison.Ordinal)) - { - _pathCells.Add(cellPosition); - _pathCellSet.Add(cellPosition); - continue; - } - - if (string.Equals(tile.name, FoundationTileName, StringComparison.Ordinal)) - { - _foundationCells.Add(cellPosition); - _foundationCellSet.Add(cellPosition); - } - } - - RefreshPathCache(); - Log.Info( - "Map '{0}' initialized. LevelId={1}, PathCells={2}, FoundationCells={3}, Spawners={4}, House={5}, Routes={6}.", - name, - _mapData != null ? _mapData.LevelId : 0, - _pathCells.Count, - _foundationCells.Count, - Spawners.Length, - House != null ? House.name : "None", - _defaultPathCellsBySpawner.Count); - } - - private void RefreshPathCache() - { - _hasHousePathCell = false; - _housePathCell = default; - _spawnerPathStartByRef.Clear(); - _defaultPathCellsBySpawner.Clear(); - - if (House == null) - { - Log.Warning("Map '{0}' has no house reference, path cache skipped.", name); - return; - } - - if (!TryGetNearestPathCell(House.Position, out _housePathCell)) - { - Log.Warning("Map '{0}' house position can not map to a valid path cell.", name); - return; - } - - _hasHousePathCell = true; - Spawner[] spawners = Spawners; - foreach (var spawner in spawners) - { - if (spawner == null) - { - continue; - } - - if (!TryGetNearestPathCell(spawner.Position, out Vector3Int startCell)) - { - Log.Warning("Map '{0}' spawner '{1}' can not map to a valid path cell.", name, spawner.name); - continue; - } - - _spawnerPathStartByRef[spawner] = startCell; - - List defaultPathCells = new List(); - if (!_mapPathfinder.TryFindPath(_pathCells, startCell, _housePathCell, null, defaultPathCells)) - { - Log.Warning( - "Map '{0}' spawner '{1}' has no path to house cell {2}.", - name, - spawner.name, - _housePathCell); - continue; - } - - _defaultPathCellsBySpawner[spawner] = defaultPathCells; - } + _mapTopologyService?.Refresh(tilemap, Spawners, House, name, _mapData != null ? _mapData.LevelId : 0); } private void ClearRuntimeData() { - _pathCells.Clear(); - _foundationCells.Clear(); - _pathCellSet.Clear(); - _foundationCellSet.Clear(); - _pathCellBuffer.Clear(); - _hasHousePathCell = false; - _housePathCell = default; - _spawnerPathStartByRef.Clear(); - _defaultPathCellsBySpawner.Clear(); - _towerEntityIdByFoundationCell.Clear(); - _foundationCellByTowerEntityId.Clear(); - _towerStatsByEntityId.Clear(); - _towerEntityIdBuffer.Clear(); - ClearSelectedObject(); + _mapTopologyService?.Clear(); + _towerPlacementService?.ClearTracking(); + _towerSelectionPresenter?.ClearSelectedObject(); } private void InitializeCombatSelectUseCase() @@ -344,6 +169,38 @@ namespace GeometryTD.Entity GameEntry.UIRouter.BindUIUseCase(UIFormType.CombatSelectForm, _combatSelectFormUseCase); } + private void InitializeTowerSelectionPresenter() + { + if (_towerSelectionPresenter == null) + { + _towerSelectionPresenter = new TowerSelectionPresenter(); + } + } + + private void InitializeTowerPlacementService() + { + if (_towerPlacementService == null) + { + _towerPlacementService = new TowerPlacementService(); + } + } + + private void InitializeCombatSelectInputService() + { + if (_combatSelectInputService == null) + { + _combatSelectInputService = new CombatSelectInputService(); + } + } + + private void InitializeMapTopologyService() + { + if (_mapTopologyService == null) + { + _mapTopologyService = new MapTopologyService(); + } + } + private void ConfigureCombatSelectUseCase() { if (_combatSelectFormUseCase == null) @@ -382,7 +239,10 @@ namespace GeometryTD.Entity return; } - if (!TryBuildCombatSelectUserData(out CombatSelectFormUserData userData)) + if (_combatSelectInputService == null || + !_combatSelectInputService.TryBuildUserData(Tilemap, CachedTransform, + _towerPlacementService != null ? _towerPlacementService.TowerEntityIdByFoundationCell : null, + IsFoundationCell, _upgradeCost, _destroyGain, out CombatSelectFormUserData userData)) { userData = new CombatSelectFormUserData { @@ -394,334 +254,86 @@ namespace GeometryTD.Entity GameEntry.UIRouter.OpenUI(UIFormType.CombatSelectForm, userData); } - private bool TryBuildCombatSelectUserData(out CombatSelectFormUserData userData) - { - userData = null; - if (Tilemap == null || !TryGetPointerWorldPosition(out Vector3 worldPosition, out Vector2 contentPosition)) - { - return false; - } - - Vector3Int clickedCell = Tilemap.WorldToCell(worldPosition); - CombatSelectClickObjectType clickObjectType = CombatSelectClickObjectType.None; - int towerEntityId = 0; - - if (_towerEntityIdByFoundationCell.TryGetValue(clickedCell, out int occupiedTowerEntityId)) - { - clickObjectType = CombatSelectClickObjectType.Tower; - towerEntityId = occupiedTowerEntityId; - } - else if (IsFoundationCell(clickedCell)) - { - clickObjectType = CombatSelectClickObjectType.Foundation; - } - - userData = new CombatSelectFormUserData - { - ClickObjectType = clickObjectType, - ContentPosition = contentPosition, - WorldPosition = worldPosition, - CellPosition = clickedCell, - TowerEntityId = towerEntityId, - UpgradeCost = Mathf.Max(0, _upgradeCost), - DestroyGain = Mathf.Max(0, _destroyGain) - }; - return true; - } - - private bool TryGetPointerWorldPosition(out Vector3 worldPosition, out Vector2 contentPosition) - { - worldPosition = Vector3.zero; - contentPosition = Vector2.zero; - - Camera mainCamera = GameEntry.Scene != null ? GameEntry.Scene.MainCamera : Camera.main; - if (mainCamera == null) - { - return false; - } - - Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition); - float mapPlaneZ = Tilemap != null ? Tilemap.transform.position.z : CachedTransform.position.z; - Vector3 planeNormal = mainCamera.transform.forward.sqrMagnitude > Mathf.Epsilon - ? -mainCamera.transform.forward - : Vector3.forward; - Plane mapPlane = new Plane(planeNormal, new Vector3(0f, 0f, mapPlaneZ)); - if (!mapPlane.Raycast(ray, out float enterDistance)) - { - return false; - } - - worldPosition = ray.GetPoint(enterDistance); - contentPosition = BuildContentPosition(Input.mousePosition); - return true; - } - - private static Vector2 BuildContentPosition(Vector3 pointerScreenPosition) - { - return new Vector2( - pointerScreenPosition.x - Screen.width * 0.5f, - pointerScreenPosition.y - Screen.height * 0.5f); - } - private void ApplySelectedObject(CombatSelectFormUserData userData) { - if (userData == null) - { - ClearSelectedObject(); - return; - } - - switch (userData.ClickObjectType) - { - case CombatSelectClickObjectType.Foundation: - _hasSelectedFoundationCell = true; - _selectedFoundationCell = userData.CellPosition; - _selectedTowerEntityId = 0; - break; - case CombatSelectClickObjectType.Tower: - _hasSelectedFoundationCell = true; - _selectedFoundationCell = userData.CellPosition; - _selectedTowerEntityId = userData.TowerEntityId; - break; - default: - ClearSelectedObject(); - break; - } - - UpdateTowerAttackRangeDisplay(_selectedTowerEntityId); + _towerSelectionPresenter?.ApplySelectedObject(userData); } private bool TryBuildTower(int buildIndex) { - if (!_hasSelectedFoundationCell || !IsFoundationCell(_selectedFoundationCell)) + if (_towerSelectionPresenter == null || + _towerPlacementService == null || + !_towerSelectionPresenter.TryGetSelectedFoundationCell(out Vector3Int selectedFoundationCell) || + !IsFoundationCell(selectedFoundationCell)) { return false; } - if (_towerEntityIdByFoundationCell.ContainsKey(_selectedFoundationCell)) + if (!_towerPlacementService.TryBuildTower(selectedFoundationCell, IsFoundationCell, buildIndex, _buildTowerCosts, + _towerTypeId, Tilemap, TryConsumeCoin, AddCoin, out int towerEntityId)) { return false; } - int buildCost = GetBuildTowerCost(buildIndex); - if (!TryConsumeCoin(buildCost)) - { - return false; - } - - DefenseTowerStatsData towerStats = BuildTowerStats(buildIndex); - if (!TryShowTowerEntity(_selectedFoundationCell, towerStats, out int towerEntityId)) - { - GameEntry.CombatNode?.AddCoin(buildCost); - return false; - } - - _towerEntityIdByFoundationCell[_selectedFoundationCell] = towerEntityId; - _foundationCellByTowerEntityId[towerEntityId] = _selectedFoundationCell; - _towerStatsByEntityId[towerEntityId] = CloneTowerStats(towerStats); - _selectedTowerEntityId = towerEntityId; - UpdateTowerAttackRangeDisplay(_selectedTowerEntityId); + _towerSelectionPresenter.SelectTower(selectedFoundationCell, towerEntityId); return true; } private bool TryUpgradeTower() { - if (!TryGetSelectedTower(out int towerEntityId, out Vector3Int foundationCell)) + if (_towerSelectionPresenter == null || + _towerPlacementService == null || + !_towerSelectionPresenter.TryGetSelectedTower(_towerPlacementService.FoundationCellByTowerEntityId, + out int towerEntityId, + out Vector3Int foundationCell)) { return false; } - int upgradeCost = Mathf.Max(0, _upgradeCost); - if (!TryConsumeCoin(upgradeCost)) + if (!_towerPlacementService.TryUpgradeTower(towerEntityId, _upgradeCost, _towerTypeId, Tilemap, + TryConsumeCoin, AddCoin, out int resultTowerEntityId, out foundationCell)) { - return false; - } - - DefenseTowerStatsData oldStats = _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, out int newTowerEntityId)) - { - if (TryShowTowerEntity(foundationCell, oldStats, out int fallbackTowerEntityId)) + if (resultTowerEntityId != 0) { - _towerEntityIdByFoundationCell[foundationCell] = fallbackTowerEntityId; - _foundationCellByTowerEntityId[fallbackTowerEntityId] = foundationCell; - _towerStatsByEntityId[fallbackTowerEntityId] = CloneTowerStats(oldStats); - _selectedTowerEntityId = fallbackTowerEntityId; - UpdateTowerAttackRangeDisplay(_selectedTowerEntityId); + _towerSelectionPresenter.SelectTower(foundationCell, resultTowerEntityId); } else { - UpdateTowerAttackRangeDisplay(0); + _towerSelectionPresenter.ClearSelectedObject(); } - - GameEntry.CombatNode?.AddCoin(upgradeCost); return false; } - _towerEntityIdByFoundationCell[foundationCell] = newTowerEntityId; - _foundationCellByTowerEntityId[newTowerEntityId] = foundationCell; - _towerStatsByEntityId[newTowerEntityId] = CloneTowerStats(upgradedStats); - _hasSelectedFoundationCell = true; - _selectedFoundationCell = foundationCell; - _selectedTowerEntityId = newTowerEntityId; - UpdateTowerAttackRangeDisplay(_selectedTowerEntityId); + _towerSelectionPresenter.SelectTower(foundationCell, resultTowerEntityId); return true; } private bool TryDestroyTower() { - if (!TryGetSelectedTower(out int towerEntityId, out Vector3Int foundationCell)) + if (_towerSelectionPresenter == null || + _towerPlacementService == null || + !_towerSelectionPresenter.TryGetSelectedTower(_towerPlacementService.FoundationCellByTowerEntityId, + out int towerEntityId, + out Vector3Int foundationCell)) { return false; } - HideTowerEntity(towerEntityId); - _towerEntityIdByFoundationCell.Remove(foundationCell); - _foundationCellByTowerEntityId.Remove(towerEntityId); - _towerStatsByEntityId.Remove(towerEntityId); - GameEntry.CombatNode?.AddCoin(Mathf.Max(0, _destroyGain)); + if (!_towerPlacementService.TryDestroyTower(towerEntityId, _destroyGain, AddCoin, out foundationCell)) + { + return false; + } - ClearSelectedObject(); + _towerSelectionPresenter.ClearSelectedObject(); return true; } - private bool TryGetSelectedTower(out int towerEntityId, out Vector3Int foundationCell) - { - towerEntityId = 0; - foundationCell = default; - - if (_selectedTowerEntityId == 0) - { - return false; - } - - if (!_foundationCellByTowerEntityId.TryGetValue(_selectedTowerEntityId, out foundationCell)) - { - return false; - } - - towerEntityId = _selectedTowerEntityId; - return true; - } - - private bool TryShowTowerEntity(Vector3Int foundationCell, DefenseTowerStatsData towerStats, out int towerEntityId) - { - towerEntityId = 0; - if (GameEntry.Entity == null) - { - return false; - } - - int entityId = GameEntry.Entity.GenerateSerialId(); - 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); - GameEntry.Entity.ShowDefenseTower(towerData); - - towerEntityId = entityId; - return true; - } - - private static void HideTowerEntity(int towerEntityId) - { - if (towerEntityId == 0 || GameEntry.Entity == null) - { - return; - } - - UnityGameFramework.Runtime.Entity towerEntity = GameEntry.Entity.GetEntity(towerEntityId); - if (towerEntity != null) - { - GameEntry.Entity.HideEntity(towerEntity); - } - } - - private void ClearPlacedTowers() - { - _towerEntityIdBuffer.Clear(); - foreach (KeyValuePair pair in _foundationCellByTowerEntityId) - { - _towerEntityIdBuffer.Add(pair.Key); - } - - for (int i = 0; i < _towerEntityIdBuffer.Count; i++) - { - HideTowerEntity(_towerEntityIdBuffer[i]); - } - - _towerEntityIdByFoundationCell.Clear(); - _foundationCellByTowerEntityId.Clear(); - _towerStatsByEntityId.Clear(); - _towerEntityIdBuffer.Clear(); - ClearSelectedObject(); - } - private void HideCombatSelectForm() { _combatSelectFormUseCase?.Hide(); GameEntry.UIRouter.CloseUI(UIFormType.CombatSelectForm); } - private void ClearSelectedObject() - { - UpdateTowerAttackRangeDisplay(0); - _hasSelectedFoundationCell = false; - _selectedFoundationCell = default; - _selectedTowerEntityId = 0; - } - - private void UpdateTowerAttackRangeDisplay(int towerEntityId) - { - if (_attackRangeVisibleTowerEntityId != 0 && _attackRangeVisibleTowerEntityId != towerEntityId) - { - SetTowerAttackRangeVisible(_attackRangeVisibleTowerEntityId, false); - _attackRangeVisibleTowerEntityId = 0; - } - - if (towerEntityId == 0) - { - if (_attackRangeVisibleTowerEntityId != 0) - { - SetTowerAttackRangeVisible(_attackRangeVisibleTowerEntityId, false); - _attackRangeVisibleTowerEntityId = 0; - } - - return; - } - - if (SetTowerAttackRangeVisible(towerEntityId, true)) - { - _attackRangeVisibleTowerEntityId = towerEntityId; - } - } - - private static bool SetTowerAttackRangeVisible(int towerEntityId, bool visible) - { - if (towerEntityId == 0 || GameEntry.Entity == null) - { - return false; - } - - EntityBase gameEntity = GameEntry.Entity.GetGameEntity(towerEntityId); - if (gameEntity is not DefenseTowerEntity towerEntity) - { - return false; - } - - towerEntity.SetAttackRangeVisible(visible); - return true; - } - private int GetBuildTowerCost(int buildIndex) { if (_buildTowerCosts == null || buildIndex < 0 || buildIndex >= _buildTowerCosts.Length) @@ -753,105 +365,14 @@ namespace GeometryTD.Entity return GameEntry.CombatNode != null ? Mathf.Max(0, GameEntry.CombatNode.CurrentCoin) : 0; } - private static DefenseTowerStatsData BuildTowerStats(int buildIndex) + private static void AddCoin(int coin) { - switch (buildIndex) - { - case 0: - return new DefenseTowerStatsData - { - AttackDamage = 100, - DamageRandomRate = 0f, - RotateSpeed = 200f, - AttackRange = 4.5f, - AttackSpeed = 1.5f, - AttackMethodType = AttackMethodType.NormalBullet, - AttackPropertyType = AttackPropertyType.Physics, - Tags = Array.Empty() - }; - case 1: - return new DefenseTowerStatsData - { - AttackDamage = 13, - DamageRandomRate = 0f, - RotateSpeed = 160f, - AttackRange = 5f, - AttackSpeed = 1.2f, - AttackMethodType = AttackMethodType.NormalBullet, - AttackPropertyType = AttackPropertyType.Fire, - Tags = Array.Empty() - }; - case 2: - return new DefenseTowerStatsData - { - AttackDamage = 17, - DamageRandomRate = 0f, - RotateSpeed = 140f, - AttackRange = 5.5f, - AttackSpeed = 0.95f, - AttackMethodType = AttackMethodType.NormalBullet, - AttackPropertyType = AttackPropertyType.Ice, - Tags = Array.Empty() - }; - case 3: - return new DefenseTowerStatsData - { - AttackDamage = 22, - DamageRandomRate = 0f, - RotateSpeed = 120f, - AttackRange = 6f, - AttackSpeed = 0.75f, - AttackMethodType = AttackMethodType.NormalBullet, - AttackPropertyType = AttackPropertyType.Poison, - Tags = Array.Empty() - }; - default: - return new DefenseTowerStatsData - { - AttackDamage = 100, - DamageRandomRate = 0f, - RotateSpeed = 180f, - AttackRange = 5f, - AttackSpeed = 1f, - AttackMethodType = AttackMethodType.NormalBullet, - AttackPropertyType = AttackPropertyType.Physics, - Tags = Array.Empty() - }; - } - } - - private static DefenseTowerStatsData CloneTowerStats(DefenseTowerStatsData source) - { - if (source == null) - { - return BuildTowerStats(0); - } - - TagType[] copiedTags = source.Tags != null ? (TagType[])source.Tags.Clone() : Array.Empty(); - 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 = copiedTags - }; - } - - private static void ApplyUpgradeToStats(DefenseTowerStatsData stats) - { - if (stats == null) + int amount = Mathf.Max(0, coin); + if (amount <= 0) { return; } - - 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); + GameEntry.CombatNode?.AddCoin(amount); } } } diff --git a/Assets/GameMain/Scripts/Localization.meta b/Assets/GameMain/Scripts/Localization.meta deleted file mode 100644 index 8ace43f..0000000 --- a/Assets/GameMain/Scripts/Localization.meta +++ /dev/null @@ -1,9 +0,0 @@ -fileFormatVersion: 2 -guid: e0da7117df4943347ad5ceaff9f70602 -folderAsset: yes -timeCreated: 1528026155 -licenseType: Pro -DefaultImporter: - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Localization/XmlLocalizationHelper.cs b/Assets/GameMain/Scripts/Localization/XmlLocalizationHelper.cs deleted file mode 100644 index 0b06d0b..0000000 --- a/Assets/GameMain/Scripts/Localization/XmlLocalizationHelper.cs +++ /dev/null @@ -1,77 +0,0 @@ -//------------------------------------------------------------ -// Game Framework -// Copyright © 2013-2021 Jiang Yin. All rights reserved. -// Homepage: https://gameframework.cn/ -// Feedback: mailto:ellan@gameframework.cn -//------------------------------------------------------------ - -using GameFramework.Localization; -using System; -using System.Xml; -using UnityGameFramework.Runtime; - -namespace GeometryTD -{ - /// - /// XML 格式的本地化辅助器。 - /// - public class XmlLocalizationHelper : DefaultLocalizationHelper - { - /// - /// 解析字典。 - /// - /// 要解析的字典字符串。 - /// 用户自定义数据。 - /// 是否解析字典成功。 - public override bool ParseData(ILocalizationManager localizationManager, string dictionaryString, object userData) - { - try - { - string currentLanguage = GameEntry.Localization.Language.ToString(); - XmlDocument xmlDocument = new XmlDocument(); - xmlDocument.LoadXml(dictionaryString); - XmlNode xmlRoot = xmlDocument.SelectSingleNode("Dictionaries"); - XmlNodeList xmlNodeDictionaryList = xmlRoot.ChildNodes; - for (int i = 0; i < xmlNodeDictionaryList.Count; i++) - { - XmlNode xmlNodeDictionary = xmlNodeDictionaryList.Item(i); - if (xmlNodeDictionary.Name != "Dictionary") - { - continue; - } - - string language = xmlNodeDictionary.Attributes.GetNamedItem("Language").Value; - if (language != currentLanguage) - { - continue; - } - - XmlNodeList xmlNodeStringList = xmlNodeDictionary.ChildNodes; - for (int j = 0; j < xmlNodeStringList.Count; j++) - { - XmlNode xmlNodeString = xmlNodeStringList.Item(j); - if (xmlNodeString.Name != "String") - { - continue; - } - - string key = xmlNodeString.Attributes.GetNamedItem("Key").Value; - string value = xmlNodeString.Attributes.GetNamedItem("Value").Value; - if (!localizationManager.AddRawString(key, value)) - { - Log.Warning("Can not add raw string with key '{0}' which may be invalid or duplicate.", key); - return false; - } - } - } - - return true; - } - catch (Exception exception) - { - Log.Warning("Can not parse dictionary data with exception '{0}'.", exception.ToString()); - return false; - } - } - } -} diff --git a/Assets/GameMain/Scripts/Procedure/ProcedurePreload.cs b/Assets/GameMain/Scripts/Procedure/ProcedurePreload.cs index b18bbee..e253304 100644 --- a/Assets/GameMain/Scripts/Procedure/ProcedurePreload.cs +++ b/Assets/GameMain/Scripts/Procedure/ProcedurePreload.cs @@ -95,7 +95,7 @@ namespace GeometryTD.Procedure } // Preload dictionaries - LoadDictionary("Default"); + //LoadDictionary("Default"); // Preload fonts LoadFont("MainFont"); diff --git a/Assets/GameMain/Scripts/Scene/Map.meta b/Assets/GameMain/Scripts/Scene/Map.meta new file mode 100644 index 0000000..cca9f07 --- /dev/null +++ b/Assets/GameMain/Scripts/Scene/Map.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 9ce5dda4e04e4785ae41eb9394a401e5 +timeCreated: 1772418124 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Scene/House.cs b/Assets/GameMain/Scripts/Scene/Map/House.cs similarity index 86% rename from Assets/GameMain/Scripts/Scene/House.cs rename to Assets/GameMain/Scripts/Scene/Map/House.cs index 2fef271..5a03c3f 100644 --- a/Assets/GameMain/Scripts/Scene/House.cs +++ b/Assets/GameMain/Scripts/Scene/Map/House.cs @@ -1,6 +1,6 @@ using UnityEngine; -namespace GeometryTD +namespace GeometryTD.Map { [DisallowMultipleComponent] public class House : MonoBehaviour diff --git a/Assets/GameMain/Scripts/Scene/House.cs.meta b/Assets/GameMain/Scripts/Scene/Map/House.cs.meta similarity index 100% rename from Assets/GameMain/Scripts/Scene/House.cs.meta rename to Assets/GameMain/Scripts/Scene/Map/House.cs.meta diff --git a/Assets/GameMain/Scripts/Scene/MapDataRefs.cs b/Assets/GameMain/Scripts/Scene/Map/MapDataRefs.cs similarity index 97% rename from Assets/GameMain/Scripts/Scene/MapDataRefs.cs rename to Assets/GameMain/Scripts/Scene/Map/MapDataRefs.cs index 424d822..1aa6b9b 100644 --- a/Assets/GameMain/Scripts/Scene/MapDataRefs.cs +++ b/Assets/GameMain/Scripts/Scene/Map/MapDataRefs.cs @@ -1,7 +1,7 @@ using UnityEngine; using UnityEngine.Tilemaps; -namespace GeometryTD +namespace GeometryTD.Map { [DisallowMultipleComponent] public class MapDataRefs : MonoBehaviour diff --git a/Assets/GameMain/Scripts/Scene/MapDataRefs.cs.meta b/Assets/GameMain/Scripts/Scene/Map/MapDataRefs.cs.meta similarity index 100% rename from Assets/GameMain/Scripts/Scene/MapDataRefs.cs.meta rename to Assets/GameMain/Scripts/Scene/Map/MapDataRefs.cs.meta diff --git a/Assets/GameMain/Scripts/Scene/Map/MapTopologyService.cs b/Assets/GameMain/Scripts/Scene/Map/MapTopologyService.cs new file mode 100644 index 0000000..415b6e8 --- /dev/null +++ b/Assets/GameMain/Scripts/Scene/Map/MapTopologyService.cs @@ -0,0 +1,255 @@ +using System; +using System.Collections.Generic; +using GeometryTD.Pathfinding; +using UnityEngine; +using UnityEngine.Tilemaps; +using UnityGameFramework.Runtime; + +namespace GeometryTD.Map +{ + public sealed class MapTopologyService + { + private const string PathTileName = "Path"; + private const string FoundationTileName = "Foundation"; + + private readonly List _pathCells = new(); + private readonly List _foundationCells = new(); + private readonly HashSet _pathCellSet = new(); + private readonly HashSet _foundationCellSet = new(); + private readonly IMapPathfinder _mapPathfinder = new GridMapPathfinder(); + private readonly List _pathCellBuffer = new(); + private readonly Dictionary _spawnerPathStartByRef = new(); + private readonly Dictionary> _defaultPathCellsBySpawner = new(); + + private bool _hasHousePathCell; + private Vector3Int _housePathCell; + + public IReadOnlyList PathCells => _pathCells; + public IReadOnlyList FoundationCells => _foundationCells; + + public void Refresh(Tilemap tilemap, Spawner[] spawners, House house, string mapName, int levelId) + { + Clear(); + if (tilemap == null) + { + return; + } + + BoundsInt bounds = tilemap.cellBounds; + foreach (Vector3Int cellPosition in bounds.allPositionsWithin) + { + TileBase tile = tilemap.GetTile(cellPosition); + if (tile == null || string.IsNullOrEmpty(tile.name)) + { + continue; + } + + if (string.Equals(tile.name, PathTileName, StringComparison.Ordinal)) + { + _pathCells.Add(cellPosition); + _pathCellSet.Add(cellPosition); + continue; + } + + if (string.Equals(tile.name, FoundationTileName, StringComparison.Ordinal)) + { + _foundationCells.Add(cellPosition); + _foundationCellSet.Add(cellPosition); + } + } + + RefreshPathCache(tilemap, spawners, house, mapName); + Log.Info( + "Map '{0}' initialized. LevelId={1}, PathCells={2}, FoundationCells={3}, Spawners={4}, House={5}, Routes={6}.", + mapName, + levelId, + _pathCells.Count, + _foundationCells.Count, + spawners != null ? spawners.Length : 0, + house != null ? house.name : "None", + _defaultPathCellsBySpawner.Count); + } + + public void Clear() + { + _pathCells.Clear(); + _foundationCells.Clear(); + _pathCellSet.Clear(); + _foundationCellSet.Clear(); + _pathCellBuffer.Clear(); + _hasHousePathCell = false; + _housePathCell = default; + _spawnerPathStartByRef.Clear(); + _defaultPathCellsBySpawner.Clear(); + } + + public bool IsPathCell(Vector3Int cellPosition) + { + return _pathCellSet.Contains(cellPosition); + } + + public bool IsFoundationCell(Vector3Int cellPosition) + { + return _foundationCellSet.Contains(cellPosition); + } + + public bool TryGetNearestPathCell(Tilemap tilemap, Vector3 worldPosition, out Vector3Int pathCell) + { + pathCell = default; + if (_pathCells.Count <= 0 || tilemap == null) + { + return false; + } + + Vector3Int directCell = tilemap.WorldToCell(worldPosition); + if (_pathCellSet.Contains(directCell)) + { + pathCell = directCell; + return true; + } + + float minDistance = float.MaxValue; + for (int i = 0; i < _pathCells.Count; i++) + { + Vector3Int candidate = _pathCells[i]; + float distance = (tilemap.GetCellCenterWorld(candidate) - worldPosition).sqrMagnitude; + if (distance >= minDistance) + { + continue; + } + + minDistance = distance; + pathCell = candidate; + } + + return minDistance < float.MaxValue; + } + + public Vector3 GetPathCellCenterWorld(Tilemap tilemap, Vector3Int pathCell) + { + return tilemap != null ? tilemap.GetCellCenterWorld(pathCell) : Vector3.zero; + } + + public bool TryGetDefaultPathCells(Spawner spawner, out IReadOnlyList pathCells) + { + pathCells = null; + if (spawner == null) + { + return false; + } + + if (!_defaultPathCellsBySpawner.TryGetValue(spawner, out List cachedPathCells)) + { + return false; + } + + pathCells = cachedPathCells; + return true; + } + + public bool TryFindPathCells(Spawner spawner, IReadOnlyCollection blockedCells, + List pathResult) + { + if (pathResult == null) + { + return false; + } + + pathResult.Clear(); + if (spawner == null || !_hasHousePathCell) + { + return false; + } + + if (!_spawnerPathStartByRef.TryGetValue(spawner, out Vector3Int startCell)) + { + return false; + } + + return _mapPathfinder.TryFindPath(_pathCells, startCell, _housePathCell, blockedCells, pathResult); + } + + public bool TryFindPathWorldPoints(Tilemap tilemap, Spawner spawner, + IReadOnlyCollection blockedCells, + List worldPathResult) + { + if (worldPathResult == null) + { + return false; + } + + worldPathResult.Clear(); + if (tilemap == null) + { + return false; + } + + if (!TryFindPathCells(spawner, blockedCells, _pathCellBuffer)) + { + return false; + } + + foreach (Vector3Int pos in _pathCellBuffer) + { + worldPathResult.Add(GetPathCellCenterWorld(tilemap, pos)); + } + + return true; + } + + private void RefreshPathCache(Tilemap tilemap, Spawner[] spawners, House house, string mapName) + { + _hasHousePathCell = false; + _housePathCell = default; + _spawnerPathStartByRef.Clear(); + _defaultPathCellsBySpawner.Clear(); + + if (house == null) + { + Log.Warning("Map '{0}' has no house reference, path cache skipped.", mapName); + return; + } + + if (!TryGetNearestPathCell(tilemap, house.Position, out _housePathCell)) + { + Log.Warning("Map '{0}' house position can not map to a valid path cell.", mapName); + return; + } + + _hasHousePathCell = true; + if (spawners == null) + { + return; + } + + foreach (Spawner spawner in spawners) + { + if (spawner == null) + { + continue; + } + + if (!TryGetNearestPathCell(tilemap, spawner.Position, out Vector3Int startCell)) + { + Log.Warning("Map '{0}' spawner '{1}' can not map to a valid path cell.", mapName, spawner.name); + continue; + } + + _spawnerPathStartByRef[spawner] = startCell; + + List defaultPathCells = new(); + if (!_mapPathfinder.TryFindPath(_pathCells, startCell, _housePathCell, null, defaultPathCells)) + { + Log.Warning( + "Map '{0}' spawner '{1}' has no path to house cell {2}.", + mapName, + spawner.name, + _housePathCell); + continue; + } + + _defaultPathCellsBySpawner[spawner] = defaultPathCells; + } + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Scene/Map/MapTopologyService.cs.meta b/Assets/GameMain/Scripts/Scene/Map/MapTopologyService.cs.meta new file mode 100644 index 0000000..7fb0b61 --- /dev/null +++ b/Assets/GameMain/Scripts/Scene/Map/MapTopologyService.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: ebb51464c2374165ada752b52283f3ae +timeCreated: 1772419747 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Scene/Spawner.cs b/Assets/GameMain/Scripts/Scene/Map/Spawner.cs similarity index 91% rename from Assets/GameMain/Scripts/Scene/Spawner.cs rename to Assets/GameMain/Scripts/Scene/Map/Spawner.cs index 52df13a..45b51cd 100644 --- a/Assets/GameMain/Scripts/Scene/Spawner.cs +++ b/Assets/GameMain/Scripts/Scene/Map/Spawner.cs @@ -1,6 +1,6 @@ using UnityEngine; -namespace GeometryTD +namespace GeometryTD.Map { [DisallowMultipleComponent] public class Spawner : MonoBehaviour diff --git a/Assets/GameMain/Scripts/Scene/Spawner.cs.meta b/Assets/GameMain/Scripts/Scene/Map/Spawner.cs.meta similarity index 100% rename from Assets/GameMain/Scripts/Scene/Spawner.cs.meta rename to Assets/GameMain/Scripts/Scene/Map/Spawner.cs.meta diff --git a/Assets/GameMain/Scripts/Scene/Map/TowerPlacementService.cs b/Assets/GameMain/Scripts/Scene/Map/TowerPlacementService.cs new file mode 100644 index 0000000..5b46afc --- /dev/null +++ b/Assets/GameMain/Scripts/Scene/Map/TowerPlacementService.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections.Generic; +using GeometryTD.Definition; +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 readonly Dictionary _towerEntityIdByFoundationCell = new(); + private readonly Dictionary _foundationCellByTowerEntityId = new(); + private readonly Dictionary _towerStatsByEntityId = new(); + private readonly List _towerEntityIdBuffer = new(); + + public IReadOnlyDictionary TowerEntityIdByFoundationCell => _towerEntityIdByFoundationCell; + public IReadOnlyDictionary FoundationCellByTowerEntityId => _foundationCellByTowerEntityId; + + public bool TryBuildTower(Vector3Int foundationCell, Func isFoundationCell, int buildIndex, + int[] buildTowerCosts, int towerTypeId, Tilemap tilemap, Func tryConsumeCoin, + Action addCoin, + out int towerEntityId) + { + towerEntityId = 0; + if (isFoundationCell == null || !isFoundationCell(foundationCell)) + { + return false; + } + + if (_towerEntityIdByFoundationCell.ContainsKey(foundationCell)) + { + return false; + } + + int buildCost = GetBuildTowerCost(buildTowerCosts, buildIndex); + if (tryConsumeCoin != null && !tryConsumeCoin(buildCost)) + { + return false; + } + + DefenseTowerStatsData towerStats = BuildTowerStats(buildIndex); + if (!TryShowTowerEntity(foundationCell, towerStats, towerTypeId, tilemap, out int newTowerEntityId)) + { + addCoin?.Invoke(buildCost); + return false; + } + + _towerEntityIdByFoundationCell[foundationCell] = newTowerEntityId; + _foundationCellByTowerEntityId[newTowerEntityId] = foundationCell; + _towerStatsByEntityId[newTowerEntityId] = CloneTowerStats(towerStats); + towerEntityId = newTowerEntityId; + return true; + } + + public bool TryUpgradeTower(int towerEntityId, int upgradeCost, int towerTypeId, Tilemap tilemap, + Func tryConsumeCoin, Action addCoin, out int resultTowerEntityId, + out Vector3Int foundationCell) + { + resultTowerEntityId = 0; + foundationCell = default; + if (towerEntityId == 0 || !_foundationCellByTowerEntityId.TryGetValue(towerEntityId, out foundationCell)) + { + return false; + } + + int requiredUpgradeCost = Mathf.Max(0, upgradeCost); + if (tryConsumeCoin != null && !tryConsumeCoin(requiredUpgradeCost)) + { + return false; + } + + DefenseTowerStatsData oldStats = + _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)) + { + if (TryShowTowerEntity(foundationCell, oldStats, towerTypeId, tilemap, out int fallbackTowerEntityId)) + { + _towerEntityIdByFoundationCell[foundationCell] = fallbackTowerEntityId; + _foundationCellByTowerEntityId[fallbackTowerEntityId] = foundationCell; + _towerStatsByEntityId[fallbackTowerEntityId] = CloneTowerStats(oldStats); + resultTowerEntityId = fallbackTowerEntityId; + } + + addCoin?.Invoke(requiredUpgradeCost); + return false; + } + + _towerEntityIdByFoundationCell[foundationCell] = newTowerEntityId; + _foundationCellByTowerEntityId[newTowerEntityId] = foundationCell; + _towerStatsByEntityId[newTowerEntityId] = CloneTowerStats(upgradedStats); + resultTowerEntityId = newTowerEntityId; + return true; + } + + public bool TryDestroyTower(int towerEntityId, int destroyGain, Action addCoin, + out Vector3Int foundationCell) + { + foundationCell = default; + if (towerEntityId == 0 || !_foundationCellByTowerEntityId.TryGetValue(towerEntityId, out foundationCell)) + { + return false; + } + + HideTowerEntity(towerEntityId); + _towerEntityIdByFoundationCell.Remove(foundationCell); + _foundationCellByTowerEntityId.Remove(towerEntityId); + _towerStatsByEntityId.Remove(towerEntityId); + addCoin?.Invoke(Mathf.Max(0, destroyGain)); + return true; + } + + public void HideAndClearAllPlacedTowers() + { + _towerEntityIdBuffer.Clear(); + foreach (KeyValuePair pair in _foundationCellByTowerEntityId) + { + _towerEntityIdBuffer.Add(pair.Key); + } + + for (int i = 0; i < _towerEntityIdBuffer.Count; i++) + { + HideTowerEntity(_towerEntityIdBuffer[i]); + } + + _towerEntityIdByFoundationCell.Clear(); + _foundationCellByTowerEntityId.Clear(); + _towerStatsByEntityId.Clear(); + _towerEntityIdBuffer.Clear(); + } + + public void ClearTracking() + { + _towerEntityIdByFoundationCell.Clear(); + _foundationCellByTowerEntityId.Clear(); + _towerStatsByEntityId.Clear(); + _towerEntityIdBuffer.Clear(); + } + + private static int GetBuildTowerCost(int[] buildTowerCosts, int buildIndex) + { + if (buildTowerCosts == null || buildIndex < 0 || buildIndex >= buildTowerCosts.Length) + { + return 0; + } + + return Mathf.Max(0, buildTowerCosts[buildIndex]); + } + + private static bool TryShowTowerEntity(Vector3Int foundationCell, DefenseTowerStatsData towerStats, + int towerTypeId, + Tilemap tilemap, out int towerEntityId) + { + towerEntityId = 0; + if (GameEntry.Entity == null) + { + return false; + } + + int entityId = GameEntry.Entity.GenerateSerialId(); + 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); + GameEntry.Entity.ShowDefenseTower(towerData); + + towerEntityId = entityId; + return true; + } + + private static void HideTowerEntity(int towerEntityId) + { + if (towerEntityId == 0 || GameEntry.Entity == null) + { + return; + } + + UnityGameFramework.Runtime.Entity towerEntity = GameEntry.Entity.GetEntity(towerEntityId); + if (towerEntity != null) + { + GameEntry.Entity.HideEntity(towerEntity); + } + } + + private static DefenseTowerStatsData BuildTowerStats(int buildIndex) + { + switch (buildIndex) + { + case 0: + return new DefenseTowerStatsData + { + AttackDamage = 100, + DamageRandomRate = 0f, + RotateSpeed = 200f, + AttackRange = 4.5f, + AttackSpeed = 1.5f, + AttackMethodType = AttackMethodType.NormalBullet, + AttackPropertyType = AttackPropertyType.Physics, + Tags = Array.Empty() + }; + case 1: + return new DefenseTowerStatsData + { + AttackDamage = 13, + DamageRandomRate = 0f, + RotateSpeed = 160f, + AttackRange = 5f, + AttackSpeed = 1.2f, + AttackMethodType = AttackMethodType.NormalBullet, + AttackPropertyType = AttackPropertyType.Fire, + Tags = Array.Empty() + }; + case 2: + return new DefenseTowerStatsData + { + AttackDamage = 17, + DamageRandomRate = 0f, + RotateSpeed = 140f, + AttackRange = 5.5f, + AttackSpeed = 0.95f, + AttackMethodType = AttackMethodType.NormalBullet, + AttackPropertyType = AttackPropertyType.Ice, + Tags = Array.Empty() + }; + case 3: + return new DefenseTowerStatsData + { + AttackDamage = 22, + DamageRandomRate = 0f, + RotateSpeed = 120f, + AttackRange = 6f, + AttackSpeed = 0.75f, + AttackMethodType = AttackMethodType.NormalBullet, + AttackPropertyType = AttackPropertyType.Poison, + Tags = Array.Empty() + }; + default: + return new DefenseTowerStatsData + { + AttackDamage = 100, + DamageRandomRate = 0f, + RotateSpeed = 180f, + AttackRange = 5f, + AttackSpeed = 1f, + AttackMethodType = AttackMethodType.NormalBullet, + AttackPropertyType = AttackPropertyType.Physics, + Tags = Array.Empty() + }; + } + } + + private static DefenseTowerStatsData CloneTowerStats(DefenseTowerStatsData source) + { + if (source == null) + { + return BuildTowerStats(0); + } + + TagType[] copiedTags = source.Tags != null ? (TagType[])source.Tags.Clone() : Array.Empty(); + 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 = copiedTags + }; + } + + private static void ApplyUpgradeToStats(DefenseTowerStatsData stats) + { + if (stats == null) + { + return; + } + + 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); + } + } +} diff --git a/Assets/GameMain/Scripts/Scene/Map/TowerPlacementService.cs.meta b/Assets/GameMain/Scripts/Scene/Map/TowerPlacementService.cs.meta new file mode 100644 index 0000000..bcb6091 --- /dev/null +++ b/Assets/GameMain/Scripts/Scene/Map/TowerPlacementService.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 482aab8887db4be481c207cb9e7449d0 +timeCreated: 1772419375 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Scene/Map/TowerSelectionPresenter.cs b/Assets/GameMain/Scripts/Scene/Map/TowerSelectionPresenter.cs new file mode 100644 index 0000000..4e402be --- /dev/null +++ b/Assets/GameMain/Scripts/Scene/Map/TowerSelectionPresenter.cs @@ -0,0 +1,134 @@ +using System.Collections.Generic; +using GeometryTD.Entity; +using GeometryTD.UI; +using UnityEngine; + +namespace GeometryTD.Map +{ + public sealed class TowerSelectionPresenter + { + private bool _hasSelectedFoundationCell; + private Vector3Int _selectedFoundationCell; + private int _selectedTowerEntityId; + private int _attackRangeVisibleTowerEntityId; + + public void ApplySelectedObject(CombatSelectFormUserData userData) + { + if (userData == null) + { + ClearSelectedObject(); + return; + } + + switch (userData.ClickObjectType) + { + case CombatSelectClickObjectType.Foundation: + _hasSelectedFoundationCell = true; + _selectedFoundationCell = userData.CellPosition; + _selectedTowerEntityId = 0; + break; + case CombatSelectClickObjectType.Tower: + _hasSelectedFoundationCell = true; + _selectedFoundationCell = userData.CellPosition; + _selectedTowerEntityId = userData.TowerEntityId; + break; + default: + ClearSelectedObject(); + return; + } + + UpdateTowerAttackRangeDisplay(_selectedTowerEntityId); + } + + public void SelectTower(Vector3Int foundationCell, int towerEntityId) + { + _hasSelectedFoundationCell = true; + _selectedFoundationCell = foundationCell; + _selectedTowerEntityId = towerEntityId; + UpdateTowerAttackRangeDisplay(_selectedTowerEntityId); + } + + public bool TryGetSelectedFoundationCell(out Vector3Int foundationCell) + { + foundationCell = default; + if (!_hasSelectedFoundationCell) + { + return false; + } + + foundationCell = _selectedFoundationCell; + return true; + } + + public bool TryGetSelectedTower(IReadOnlyDictionary foundationCellByTowerEntityId, + out int towerEntityId, out Vector3Int foundationCell) + { + towerEntityId = 0; + foundationCell = default; + if (_selectedTowerEntityId == 0 || foundationCellByTowerEntityId == null) + { + return false; + } + + if (!foundationCellByTowerEntityId.TryGetValue(_selectedTowerEntityId, out foundationCell)) + { + return false; + } + + towerEntityId = _selectedTowerEntityId; + return true; + } + + public void ClearSelectedObject() + { + UpdateTowerAttackRangeDisplay(0); + _hasSelectedFoundationCell = false; + _selectedFoundationCell = default; + _selectedTowerEntityId = 0; + } + + private void UpdateTowerAttackRangeDisplay(int towerEntityId) + { + if (_attackRangeVisibleTowerEntityId != 0 && _attackRangeVisibleTowerEntityId != towerEntityId) + { + SetTowerAttackRangeVisible(_attackRangeVisibleTowerEntityId, false); + _attackRangeVisibleTowerEntityId = 0; + } + + if (towerEntityId == 0) + { + if (_attackRangeVisibleTowerEntityId != 0) + { + SetTowerAttackRangeVisible(_attackRangeVisibleTowerEntityId, false); + _attackRangeVisibleTowerEntityId = 0; + } + + return; + } + + if (SetTowerAttackRangeVisible(towerEntityId, true)) + { + _attackRangeVisibleTowerEntityId = towerEntityId; + } + } + + private static bool SetTowerAttackRangeVisible(int towerEntityId, bool visible) + { + if (towerEntityId == 0 || GameEntry.Entity == null) + { + return false; + } + + EntityBase gameEntity = GameEntry.Entity.GetGameEntity(towerEntityId); + if (gameEntity is not DefenseTowerEntity towerEntity) + { + return false; + } + + towerEntity.SetAttackRangeVisible(visible); + return true; + } + } + + +} diff --git a/Assets/GameMain/Scripts/Scene/Map/TowerSelectionPresenter.cs.meta b/Assets/GameMain/Scripts/Scene/Map/TowerSelectionPresenter.cs.meta new file mode 100644 index 0000000..6b80f1d --- /dev/null +++ b/Assets/GameMain/Scripts/Scene/Map/TowerSelectionPresenter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 872f548158cd4474ea0cca599f5ae415 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Launcher.unity b/Assets/Launcher.unity index dc9c7e1..412defd 100644 --- a/Assets/Launcher.unity +++ b/Assets/Launcher.unity @@ -522,7 +522,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 11461470, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3} propertyPath: m_LocalizationHelperTypeName - value: GeometryTD.XmlLocalizationHelper + value: objectReference: {fileID: 0} - target: {fileID: 11463816, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3} propertyPath: m_LoadAssetCountPerFrame @@ -701,7 +701,8 @@ PrefabInstance: value: GeometryTD.CustomUtility.JsonNetUtility objectReference: {fileID: 0} m_RemovedComponents: [] - m_RemovedGameObjects: [] + m_RemovedGameObjects: + - {fileID: 109252, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3} m_AddedGameObjects: - targetCorrespondingSourceObject: {fileID: 430602, guid: adb3eb1c35fcff14f89fba7b05c9d71c, type: 3} diff --git a/docs/MapEntityArchitecture.md b/docs/MapEntityArchitecture.md new file mode 100644 index 0000000..e173252 --- /dev/null +++ b/docs/MapEntityArchitecture.md @@ -0,0 +1,147 @@ +# MapEntity 设计规范(开发约束) + +最后更新:2026-03-02 + +## 1. 目标与边界 + +`MapEntity` 现在是战斗地图域的编排层(orchestrator),目标是: +- 对外暴露地图能力(格子查询、路径查询、建造操作入口)。 +- 在 Unity 生命周期中初始化/清理各子服务。 +- 承担输入与 UI 用例的连接,不承载具体业务算法细节。 + +`MapEntity` 不应再直接实现以下细节: +- Tile 扫描与路径缓存算法。 +- 防御塔映射字典的增删改查细节。 +- 选中状态与攻击范围显示细节。 +- 鼠标拾取与 `CombatSelectFormUserData` 组装细节。 + +--- + +## 2. 模块划分(当前标准) + +### 2.1 MapEntity(编排层) + +文件:`Assets/GameMain/Scripts/Entity/EntityLogic/MapEntity.cs` + +职责: +- 生命周期编排:`OnInit / OnShow / OnUpdate / OnHide` +- 子服务初始化与清理 +- UI 用例绑定(`CombatSelectFormUseCase`) +- 将输入结果分发到选择器/建造器 + +### 2.2 MapTopologyService(地图拓扑层) + +当前文件:`Assets/GameMain/Scripts/Scene/Map/TowerPlacementService.cs`(同文件内) + +职责: +- 扫描 Tilemap,构建 `PathCells` / `FoundationCells` +- 缓存 `Spawner -> 默认路径` +- 提供路径查询: + - `TryGetNearestPathCell` + - `TryGetDefaultPathCells` + - `TryFindPathCells` + - `TryFindPathWorldPoints` + +约束: +- 只处理“地图拓扑与路径”,不处理经济、建塔、UI。 + +### 2.3 CombatSelectInputService(输入解析层) + +文件:`Assets/GameMain/Scripts/Entity/EntityLogic/CombatSelectInputService.cs` + +职责: +- 鼠标屏幕坐标 -> 世界坐标 +- 判定点击对象类型(`None/Foundation/Tower`) +- 组装 `CombatSelectFormUserData` + +约束: +- 只读上下文,不改变游戏状态。 +- Foundation/Tower 点击时,UI 定位由 Cell 中心决定(稳定定位)。 + +### 2.4 TowerPlacementService(塔部署层) + +当前文件:`Assets/GameMain/Scripts/Scene/Map/TowerPlacementService.cs` + +职责: +- 建造 / 升级 / 销毁(含升级失败回滚) +- 维护塔映射状态: + - `foundationCell -> towerEntityId` + - `towerEntityId -> foundationCell` + - `towerEntityId -> towerStats` +- 提供整局清理接口 + +约束: +- 仅处理塔生命周期与映射,不处理选中态和 UI。 + +### 2.5 TowerSelectionPresenter(选择展示层) + +文件:`Assets/GameMain/Scripts/Scene/Map/TowerSelectionPresenter.cs` + +职责: +- 维护当前选中对象 +- 根据选中状态切换攻击范围显示(通过 `DefenseTowerEntity.SetAttackRangeVisible`) + +约束: +- 不做建造/升级/销毁。 + +--- + +## 3. 运行时主流程(简版) + +1. `MapEntity.OnInit` 初始化 4 个服务: + - `MapTopologyService` + - `CombatSelectInputService` + - `TowerPlacementService` + - `TowerSelectionPresenter` +2. `MapEntity.OnShow` 刷新地图拓扑,配置 UI action。 +3. 每帧 `OnUpdate`: + - 采集输入(InputService) + - 更新选中对象(SelectionPresenter) + - 打开/刷新 UI +4. UI Build/Upgrade/Destroy action 回调: + - 通过 PlacementService 改变塔状态 + - 通过 SelectionPresenter 同步选中和范围显示 +5. `OnHide`: + - 关闭 UI + - 清理塔实体 + - 清理拓扑/选择/映射运行时状态 + +--- + +## 4. 核心不变量(必须保持) + +1. `MapEntity` 只编排,不承载业务细节实现。 +2. `TowerPlacementService` 是塔映射状态的唯一写入口。 +3. `MapTopologyService` 是 Path/Foundation 数据的唯一来源。 +4. “当前可见攻击范围”同一时刻最多一个塔。 +5. 清场顺序固定:先隐藏塔,再清空映射与选择,再清理拓扑缓存。 + +--- + +## 5. 扩展开发规范 + +### 5.1 新增地图规则(地形、禁建、动态阻挡) + +优先改 `MapTopologyService`,不要直接改 `MapEntity`。 + +### 5.2 新增建造规则(费用、限制、回滚策略) + +优先改 `TowerPlacementService`,不要在 `MapEntity` 写分支。 + +### 5.3 新增点击交互或 UI 定位策略 + +优先改 `CombatSelectInputService`。 + +### 5.4 新增选中表现(特效、描边、信息面板联动) + +优先改 `TowerSelectionPresenter`。 + +--- + +## 6. 代码变更检查清单(PR 自检) + +1. 是否把新业务放进了对应服务,而不是 `MapEntity`? +2. 是否破坏了“服务唯一写入口”不变量? +3. Build/Upgrade 失败是否有完整回滚和金币返还? +4. 清理路径是否覆盖了 `OnHide` 与重进地图场景? +5. `dotnet build GeometryTD.sln` 是否通过?