地图初步
This commit is contained in:
465
Assets/Scripts/MainGame/GameRun/Map/MapGenerator.cs
Normal file
465
Assets/Scripts/MainGame/GameRun/Map/MapGenerator.cs
Normal file
@@ -0,0 +1,465 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Cielonos.MainGame.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// 全联通地图生成器,生成类似"以撒的结合"/"挺进地牢"的 2D 网格地图。
|
||||
/// 所有节点双向互通,玩家可以自由走遍地图中的每一个房间。
|
||||
/// </summary>
|
||||
public static class MapGenerator
|
||||
{
|
||||
/// <summary>网格四方向偏移。</summary>
|
||||
private static readonly Vector2Int[] Directions =
|
||||
{
|
||||
Vector2Int.up,
|
||||
Vector2Int.down,
|
||||
Vector2Int.left,
|
||||
Vector2Int.right
|
||||
};
|
||||
|
||||
/// <summary>生成失败时允许的最大重试次数。</summary>
|
||||
private const int MAX_GENERATION_RETRIES = 10;
|
||||
|
||||
/// <summary>
|
||||
/// 根据配置生成一份完整的全联通 RunMapData。
|
||||
/// 若特殊节点配额无法满足,则重新生成,最多重试 <see cref="MAX_GENERATION_RETRIES"/> 次。
|
||||
/// 所有重试均失败时返回 null 并输出警告。
|
||||
/// </summary>
|
||||
public static RunMapData Generate(MapGenerationConfig config)
|
||||
{
|
||||
for (int attempt = 1; attempt <= MAX_GENERATION_RETRIES; attempt++)
|
||||
{
|
||||
RunMapData result = TryGenerate(config);
|
||||
if (result != null)
|
||||
return result;
|
||||
|
||||
Debug.Log(
|
||||
$"[MapGenerator] 第 {attempt} 次生成失败(特殊节点配额未满足),正在重新生成...");
|
||||
}
|
||||
|
||||
Debug.LogWarning(
|
||||
$"[MapGenerator] 连续 {MAX_GENERATION_RETRIES} 次生成均失败,终止地图生成。" +
|
||||
$"请检查 specialNodeCounts / specialNodeMinSameTypeDistance / targetRoomCount 是否过于严格。");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行单次地图生成。若特殊节点配额无法完全满足则返回 null。
|
||||
/// </summary>
|
||||
private static RunMapData TryGenerate(MapGenerationConfig config)
|
||||
{
|
||||
RunMapData mapData = new RunMapData
|
||||
{
|
||||
nodes = new Dictionary<Vector2Int, RunMapNode>()
|
||||
};
|
||||
|
||||
// --- 1. 起点 ---
|
||||
Vector2Int startPos = Vector2Int.zero;
|
||||
mapData.startPosition = startPos;
|
||||
PlaceNode(mapData, startPos, MapNodeType.Start);
|
||||
|
||||
// --- 2. 扩展房间 ---
|
||||
ExpandRooms(mapData, config);
|
||||
|
||||
// --- 3. 建立所有相邻节点之间的双向连接 ---
|
||||
ConnectAllAdjacentNodes(mapData);
|
||||
|
||||
// --- 4. 放置 Boss 节点(最远的死胡同) ---
|
||||
PlaceBossNode(mapData, config);
|
||||
|
||||
// --- 5. 分配特殊节点类型(配额未满足则本次生成失败)---
|
||||
if (!AssignSpecialNodes(mapData, config))
|
||||
return null;
|
||||
|
||||
// --- 6. 为剩余节点分配 ZoneData ---
|
||||
AssignZoneData(mapData, config);
|
||||
|
||||
// --- 7. 计算 UI 坐标 ---
|
||||
AssignUIPositions(mapData, config);
|
||||
|
||||
mapData.totalNodes = mapData.nodes.Count;
|
||||
return mapData;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 房间扩展(类以撒算法)
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// 从起点开始,使用队列式随机扩展在网格上铺开房间。
|
||||
/// 每次从已有房间中随机选一个,向随机方向尝试扩展。
|
||||
/// </summary>
|
||||
private static void ExpandRooms(RunMapData mapData, MapGenerationConfig config)
|
||||
{
|
||||
int targetCount = config.targetRoomCount + 1; // +1 包含起点
|
||||
List<Vector2Int> existingPositions = new List<Vector2Int>(mapData.nodes.Keys);
|
||||
|
||||
int maxAttempts = targetCount * 20;
|
||||
int attempts = 0;
|
||||
|
||||
while (mapData.nodes.Count < targetCount && attempts < maxAttempts)
|
||||
{
|
||||
attempts++;
|
||||
|
||||
// 从已有节点中随机选一个作为扩展源
|
||||
Vector2Int source = existingPositions[Random.Range(0, existingPositions.Count)];
|
||||
|
||||
// 随机选择一个方向
|
||||
Vector2Int dir = Directions[Random.Range(0, Directions.Length)];
|
||||
Vector2Int candidate = source + dir;
|
||||
|
||||
// 检查是否已存在、是否在网格范围内
|
||||
if (mapData.nodes.ContainsKey(candidate)) continue;
|
||||
if (!IsWithinGrid(candidate, config.gridRadius)) continue;
|
||||
|
||||
// 限制邻居数量,避免地图过于密集(最多 3 个已有邻居)
|
||||
int adjacentCount = CountAdjacentNodes(mapData, candidate);
|
||||
if (adjacentCount > MAX_ADJACENT_FOR_EXPANSION) continue;
|
||||
|
||||
PlaceNode(mapData, candidate, MapNodeType.NormalCombat);
|
||||
existingPositions.Add(candidate);
|
||||
}
|
||||
|
||||
if (mapData.nodes.Count < targetCount)
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[MapGenerator] 仅生成 {mapData.nodes.Count}/{targetCount} 个房间" +
|
||||
$"(网格半径 {config.gridRadius} 可能过小)。");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>扩展时允许的最大已有邻居数,避免生成过于密集的块状区域。</summary>
|
||||
private const int MAX_ADJACENT_FOR_EXPANSION = 2;
|
||||
|
||||
// ================================================================
|
||||
// 连接建立
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// 遍历所有节点,为每对相邻节点建立双向连接。
|
||||
/// </summary>
|
||||
private static void ConnectAllAdjacentNodes(RunMapData mapData)
|
||||
{
|
||||
foreach (KeyValuePair<Vector2Int, RunMapNode> kvp in mapData.nodes)
|
||||
{
|
||||
Vector2Int pos = kvp.Key;
|
||||
RunMapNode node = kvp.Value;
|
||||
|
||||
foreach (Vector2Int dir in Directions)
|
||||
{
|
||||
Vector2Int neighborPos = pos + dir;
|
||||
if (mapData.nodes.ContainsKey(neighborPos) &&
|
||||
!node.connectedPositions.Contains(neighborPos))
|
||||
{
|
||||
node.connectedPositions.Add(neighborPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Boss 放置
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// 将距离起点最远的死胡同节点设为 Boss 节点。
|
||||
/// 若没有足够远的死胡同,则选距离最远的任意节点。
|
||||
/// </summary>
|
||||
private static void PlaceBossNode(RunMapData mapData, MapGenerationConfig config)
|
||||
{
|
||||
Dictionary<Vector2Int, int> distances = ComputeBfsDistances(mapData, mapData.startPosition);
|
||||
|
||||
// 优先选择满足最小距离的死胡同(度数 == 1)
|
||||
Vector2Int bestPos = mapData.startPosition;
|
||||
int bestDist = -1;
|
||||
|
||||
foreach (KeyValuePair<Vector2Int, int> kvp in distances)
|
||||
{
|
||||
if (kvp.Key == mapData.startPosition) continue;
|
||||
|
||||
RunMapNode node = mapData.nodes[kvp.Key];
|
||||
bool isDeadEnd = node.Degree == 1;
|
||||
bool meetsMinDist = kvp.Value >= config.minBossDistance;
|
||||
|
||||
if (meetsMinDist && isDeadEnd && kvp.Value > bestDist)
|
||||
{
|
||||
bestDist = kvp.Value;
|
||||
bestPos = kvp.Key;
|
||||
}
|
||||
}
|
||||
|
||||
// 若没找到满足条件的死胡同,退而求其次选最远节点
|
||||
if (bestDist < 0)
|
||||
{
|
||||
foreach (KeyValuePair<Vector2Int, int> kvp in distances)
|
||||
{
|
||||
if (kvp.Key == mapData.startPosition) continue;
|
||||
if (kvp.Value > bestDist)
|
||||
{
|
||||
bestDist = kvp.Value;
|
||||
bestPos = kvp.Key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mapData.nodes[bestPos].nodeType = MapNodeType.BossCombat;
|
||||
mapData.bossPosition = bestPos;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 特殊节点分配
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// 按配额分配特殊节点。优先放在死胡同,其次放在低度数节点。
|
||||
/// 所有特殊节点需满足:与起点的最小距离约束、同类节点间的最小距离约束。
|
||||
/// </summary>
|
||||
/// <returns>所有类型的配额均完全满足时返回 true;任意类型放置数量不足时返回 false。</returns>
|
||||
private static bool AssignSpecialNodes(RunMapData mapData, MapGenerationConfig config)
|
||||
{
|
||||
Dictionary<Vector2Int, int> distancesFromStart =
|
||||
ComputeBfsDistances(mapData, mapData.startPosition);
|
||||
|
||||
// 收集可用候选节点(排除起点和 Boss)
|
||||
List<Vector2Int> candidates = mapData.nodes.Keys
|
||||
.Where(pos =>
|
||||
pos != mapData.startPosition &&
|
||||
pos != mapData.bossPosition &&
|
||||
distancesFromStart.TryGetValue(pos, out int dist) &&
|
||||
dist >= config.specialNodeMinDistanceFromStart)
|
||||
.OrderBy(pos => mapData.nodes[pos].Degree) // 死胡同优先
|
||||
.ThenByDescending(pos => distancesFromStart[pos]) // 距离远的优先
|
||||
.ToList();
|
||||
|
||||
// 记录每种类型已放置的节点坐标,用于同类距离检查
|
||||
Dictionary<MapNodeType, List<Vector2Int>> placedByType =
|
||||
new Dictionary<MapNodeType, List<Vector2Int>>();
|
||||
|
||||
foreach (KeyValuePair<MapNodeType, int> quota in config.specialNodeCounts)
|
||||
{
|
||||
MapNodeType nodeType = quota.Key;
|
||||
int targetCount = quota.Value;
|
||||
|
||||
if (!placedByType.ContainsKey(nodeType))
|
||||
placedByType[nodeType] = new List<Vector2Int>();
|
||||
|
||||
int placed = 0;
|
||||
List<Vector2Int> toRemove = new List<Vector2Int>();
|
||||
|
||||
foreach (Vector2Int pos in candidates)
|
||||
{
|
||||
if (placed >= targetCount) break;
|
||||
|
||||
// 同类距离约束检查
|
||||
if (!SatisfiesSameTypeDistance(mapData, pos, placedByType[nodeType],
|
||||
config.specialNodeMinSameTypeDistance))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
mapData.nodes[pos].nodeType = nodeType;
|
||||
placedByType[nodeType].Add(pos);
|
||||
placed++;
|
||||
toRemove.Add(pos);
|
||||
}
|
||||
|
||||
// 从候选池移除已分配的节点
|
||||
foreach (Vector2Int pos in toRemove)
|
||||
candidates.Remove(pos);
|
||||
|
||||
// 配额未满足,本次生成失败
|
||||
if (placed < targetCount)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查候选位置与所有已放置的同类节点之间的 BFS 距离是否满足最小距离约束。
|
||||
/// </summary>
|
||||
private static bool SatisfiesSameTypeDistance(
|
||||
RunMapData mapData,
|
||||
Vector2Int candidate,
|
||||
List<Vector2Int> placedSameType,
|
||||
int minDistance)
|
||||
{
|
||||
if (placedSameType.Count == 0 || minDistance <= 1) return true;
|
||||
|
||||
// 从候选位置出发做一次 BFS,只需要探索到 minDistance 深度即可
|
||||
Dictionary<Vector2Int, int> distances = ComputeBfsDistancesWithLimit(
|
||||
mapData, candidate, minDistance);
|
||||
|
||||
foreach (Vector2Int placedPos in placedSameType)
|
||||
{
|
||||
if (distances.TryGetValue(placedPos, out int dist) && dist < minDistance)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限深度 BFS,仅探索到 maxDepth 步即停止,用于距离约束检查以减少不必要的遍历。
|
||||
/// </summary>
|
||||
private static Dictionary<Vector2Int, int> ComputeBfsDistancesWithLimit(
|
||||
RunMapData mapData, Vector2Int start, int maxDepth)
|
||||
{
|
||||
Dictionary<Vector2Int, int> distances = new Dictionary<Vector2Int, int>();
|
||||
Queue<Vector2Int> queue = new Queue<Vector2Int>();
|
||||
|
||||
distances[start] = 0;
|
||||
queue.Enqueue(start);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
Vector2Int current = queue.Dequeue();
|
||||
int currentDist = distances[current];
|
||||
|
||||
if (currentDist >= maxDepth) continue;
|
||||
|
||||
RunMapNode node = mapData.nodes[current];
|
||||
foreach (Vector2Int neighbor in node.connectedPositions)
|
||||
{
|
||||
if (!distances.ContainsKey(neighbor))
|
||||
{
|
||||
distances[neighbor] = currentDist + 1;
|
||||
queue.Enqueue(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return distances;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// ZoneData 分配
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// 为所有战斗类型节点分配 ZoneData 资产名。
|
||||
/// </summary>
|
||||
private static void AssignZoneData(RunMapData mapData, MapGenerationConfig config)
|
||||
{
|
||||
foreach (RunMapNode node in mapData.nodes.Values)
|
||||
{
|
||||
node.zoneDataAssetName = ResolveZoneDataName(node.nodeType, config);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据节点类型从配置中随机抽取对应的 ZoneData 资产名。
|
||||
/// 非战斗类型节点返回空字符串。
|
||||
/// </summary>
|
||||
private static string ResolveZoneDataName(MapNodeType nodeType, MapGenerationConfig config)
|
||||
{
|
||||
switch (nodeType)
|
||||
{
|
||||
case MapNodeType.NormalCombat:
|
||||
return PickRandom(config.normalCombatZoneDataNames);
|
||||
case MapNodeType.EliteCombat:
|
||||
return PickRandom(config.eliteCombatZoneDataNames);
|
||||
case MapNodeType.BossCombat:
|
||||
return config.bossCombatZoneDataName ?? string.Empty;
|
||||
default:
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// UI 坐标
|
||||
// ================================================================
|
||||
|
||||
/// <summary>
|
||||
/// 根据网格坐标计算每个节点的 UI 位置。
|
||||
/// </summary>
|
||||
private static void AssignUIPositions(RunMapData mapData, MapGenerationConfig config)
|
||||
{
|
||||
foreach (KeyValuePair<Vector2Int, RunMapNode> kvp in mapData.nodes)
|
||||
{
|
||||
kvp.Value.position = new Vector2(
|
||||
kvp.Key.x * config.nodeSpacing,
|
||||
kvp.Key.y * config.nodeSpacing
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 工具方法
|
||||
// ================================================================
|
||||
|
||||
/// <summary>在指定位置创建并放置一个节点。</summary>
|
||||
private static void PlaceNode(RunMapData mapData, Vector2Int pos, MapNodeType nodeType)
|
||||
{
|
||||
RunMapNode node = new RunMapNode
|
||||
{
|
||||
gridPosition = pos,
|
||||
nodeType = nodeType,
|
||||
position = Vector2.zero,
|
||||
sceneName = string.Empty,
|
||||
zoneDataAssetName = string.Empty,
|
||||
connectedPositions = new List<Vector2Int>()
|
||||
};
|
||||
mapData.nodes[pos] = node;
|
||||
}
|
||||
|
||||
/// <summary>检查坐标是否在网格范围内。</summary>
|
||||
private static bool IsWithinGrid(Vector2Int pos, int gridRadius)
|
||||
{
|
||||
return Mathf.Abs(pos.x) <= gridRadius && Mathf.Abs(pos.y) <= gridRadius;
|
||||
}
|
||||
|
||||
/// <summary>计算候选位置周围已有节点的数量。</summary>
|
||||
private static int CountAdjacentNodes(RunMapData mapData, Vector2Int pos)
|
||||
{
|
||||
int count = 0;
|
||||
foreach (Vector2Int dir in Directions)
|
||||
{
|
||||
if (mapData.nodes.ContainsKey(pos + dir)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BFS 计算从起点到所有节点的最短步数距离。
|
||||
/// </summary>
|
||||
private static Dictionary<Vector2Int, int> ComputeBfsDistances(
|
||||
RunMapData mapData, Vector2Int start)
|
||||
{
|
||||
Dictionary<Vector2Int, int> distances = new Dictionary<Vector2Int, int>();
|
||||
Queue<Vector2Int> queue = new Queue<Vector2Int>();
|
||||
|
||||
distances[start] = 0;
|
||||
queue.Enqueue(start);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
Vector2Int current = queue.Dequeue();
|
||||
int currentDist = distances[current];
|
||||
|
||||
RunMapNode node = mapData.nodes[current];
|
||||
foreach (Vector2Int neighbor in node.connectedPositions)
|
||||
{
|
||||
if (!distances.ContainsKey(neighbor))
|
||||
{
|
||||
distances[neighbor] = currentDist + 1;
|
||||
queue.Enqueue(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return distances;
|
||||
}
|
||||
|
||||
/// <summary>从列表中随机取一个元素,列表为空时返回空字符串。</summary>
|
||||
private static string PickRandom(List<string> list)
|
||||
{
|
||||
if (list == null || list.Count == 0) return string.Empty;
|
||||
return list[Random.Range(0, list.Count)];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user