More major updates to tools, added map area volume, made gold manager network managed per player.
This commit is contained in:
parent
b44eeaeeff
commit
56dc775c68
18 changed files with 632 additions and 283 deletions
|
|
@ -89,7 +89,8 @@ namespace TD.Levels.Editor
|
|||
|
||||
Debug.Log($"[LevelBake] {outcome} — '{ctx.Authoring.mapName}' " +
|
||||
$"({ctx.PlayerZoneVolumes.Count} player zones, {ctx.SpawnerVolumes.Count} spawners, " +
|
||||
$"{ctx.LeakExitVolumes.Count} leak exits, {ctx.GoalVolumes.Count} goals; " +
|
||||
$"{ctx.LeakExitVolumes.Count} leak exits, {ctx.GoalVolumes.Count} goals, " +
|
||||
$"{ctx.MapAreaVolumes.Count} map areas; " +
|
||||
$"grid {ctx.GridSize.x}×{ctx.GridSize.y}; {report.WarningCount} warnings)");
|
||||
|
||||
if (report.WarningCount > 0)
|
||||
|
|
@ -124,16 +125,18 @@ namespace TD.Levels.Editor
|
|||
var rootTransform = ctx.Authoring.transform;
|
||||
|
||||
// Scoped scan: only volumes parented under _LevelAuthoring's transform are part of the bake.
|
||||
ctx.PlayerZoneVolumes = rootTransform.GetComponentsInChildren<PlayerZoneVolume>(includeInactive: false).ToList();
|
||||
ctx.SpawnerVolumes = rootTransform.GetComponentsInChildren<SpawnerVolume>(includeInactive: false).ToList();
|
||||
ctx.LeakExitVolumes = rootTransform.GetComponentsInChildren<LeakExitVolume>(includeInactive: false).ToList();
|
||||
ctx.GoalVolumes = rootTransform.GetComponentsInChildren<GoalVolume>(includeInactive: false).ToList();
|
||||
ctx.PlayerZoneVolumes = rootTransform.GetComponentsInChildren<PlayerZoneVolume>(includeInactive: false).ToList();
|
||||
ctx.SpawnerVolumes = rootTransform.GetComponentsInChildren<SpawnerVolume>(includeInactive: false).ToList();
|
||||
ctx.LeakExitVolumes = rootTransform.GetComponentsInChildren<LeakExitVolume>(includeInactive: false).ToList();
|
||||
ctx.GoalVolumes = rootTransform.GetComponentsInChildren<GoalVolume>(includeInactive: false).ToList();
|
||||
ctx.MapAreaVolumes = rootTransform.GetComponentsInChildren<MapAreaVolume>(includeInactive: false).ToList();
|
||||
|
||||
ctx.AllVolumes = new List<VolumeBase>();
|
||||
ctx.AllVolumes.AddRange(ctx.PlayerZoneVolumes);
|
||||
ctx.AllVolumes.AddRange(ctx.SpawnerVolumes);
|
||||
ctx.AllVolumes.AddRange(ctx.LeakExitVolumes);
|
||||
ctx.AllVolumes.AddRange(ctx.GoalVolumes);
|
||||
ctx.AllVolumes.AddRange(ctx.MapAreaVolumes);
|
||||
|
||||
// Full-scene scan: catch volumes that exist but aren't parented under _LevelAuthoring.
|
||||
var orphanCandidates = UnityEngine.Object.FindObjectsByType<VolumeBase>(FindObjectsInactive.Exclude);
|
||||
|
|
@ -149,9 +152,10 @@ namespace TD.Levels.Editor
|
|||
// hash input ordering and stable iteration in subsequent phases.
|
||||
ctx.AllVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
|
||||
ctx.PlayerZoneVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
|
||||
ctx.SpawnerVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
|
||||
ctx.LeakExitVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
|
||||
ctx.GoalVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
|
||||
ctx.SpawnerVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
|
||||
ctx.LeakExitVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
|
||||
ctx.GoalVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
|
||||
ctx.MapAreaVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
|
||||
}
|
||||
|
||||
// Builds the canonical path string for a transform, relative to the root, with sibling
|
||||
|
|
@ -214,6 +218,17 @@ namespace TD.Levels.Editor
|
|||
report.Error("P2-2", $"Found {ctx.GoalVolumes.Count} GoalVolume(s); LevelAuthoring expects {auth.expectedGoalCount}.");
|
||||
}
|
||||
|
||||
// P2-22: at least one MapAreaVolume is present.
|
||||
// The map area is required (not inferred) so that gameplay bounds stay decoupled from
|
||||
// visual geometry. See "Lessons Learned" — inferring playable bounds from terrain
|
||||
// mesh extent was deliberately rejected.
|
||||
if (ctx.MapAreaVolumes.Count == 0)
|
||||
{
|
||||
report.Error("P2-22", "No MapAreaVolume found. Every map must have at least one " +
|
||||
"MapAreaVolume defining the playable bounds (where the builder " +
|
||||
"can move and the camera can pan).");
|
||||
}
|
||||
|
||||
// P2-16: author non-empty (soft warning)
|
||||
if (string.IsNullOrWhiteSpace(auth.author))
|
||||
{
|
||||
|
|
@ -403,6 +418,7 @@ namespace TD.Levels.Editor
|
|||
ctx.PerVolumeTiles = new Dictionary<VolumeBase, HashSet<Vector2Int>>();
|
||||
ctx.PerOwnerZoneTiles = new Dictionary<PlayerSlot, HashSet<Vector2Int>>();
|
||||
ctx.PerGoalTiles = new List<HashSet<Vector2Int>>();
|
||||
ctx.MapAreaTiles = new HashSet<Vector2Int>();
|
||||
|
||||
bool initializedAggregate = false;
|
||||
int aggMinX = 0, aggMinY = 0, aggMaxX = 0, aggMaxY = 0;
|
||||
|
|
@ -461,6 +477,14 @@ namespace TD.Levels.Editor
|
|||
{
|
||||
ctx.PerGoalTiles.Add(tiles);
|
||||
}
|
||||
|
||||
// Map area: union all MapAreaVolume tiles into a single set. The map area is the
|
||||
// union of all MapAreaVolumes, so a tile is "in the map" if ANY MapAreaVolume
|
||||
// covers it.
|
||||
if (v is MapAreaVolume)
|
||||
{
|
||||
foreach (var t in tiles) ctx.MapAreaTiles.Add(t);
|
||||
}
|
||||
}
|
||||
|
||||
if (!initializedAggregate)
|
||||
|
|
@ -498,12 +522,27 @@ namespace TD.Levels.Editor
|
|||
// 2D arrays during composition; flattened to 1D in Phase 6.
|
||||
ctx.PlacementGrid2D = new PlacementState[width, height]; // defaults to Outside (0)
|
||||
ctx.WalkabilityGrid2D = new bool[width, height]; // defaults to false
|
||||
ctx.MapAreaGrid2D = new bool[width, height]; // defaults to false
|
||||
|
||||
// Map-area composition: a tile is "in map" iff any MapAreaVolume covers it. We have
|
||||
// the union pre-computed in ctx.MapAreaTiles from Phase 3.
|
||||
foreach (var t in ctx.MapAreaTiles)
|
||||
{
|
||||
int gx = t.x - ctx.GridOriginTile.x;
|
||||
int gy = t.y - ctx.GridOriginTile.y;
|
||||
ctx.MapAreaGrid2D[gx, gy] = true;
|
||||
}
|
||||
|
||||
// Volume-iterating algorithm: O(total tile-coverage) rather than O(grid * volumes).
|
||||
foreach (var v in ctx.AllVolumes)
|
||||
{
|
||||
if (!ctx.PerVolumeTiles.TryGetValue(v, out var tiles)) continue;
|
||||
|
||||
// MapAreaVolume contributes ONLY to the map-area grid (handled above) — not to
|
||||
// walkability or placement. Buffer tiles (in-map but not gameplay) must remain
|
||||
// non-walkable so enemies cannot path through them.
|
||||
if (v is MapAreaVolume) continue;
|
||||
|
||||
bool isInvalid = IsInvalidValidity(v);
|
||||
bool isPlayerZone = v is PlayerZoneVolume;
|
||||
|
||||
|
|
@ -536,10 +575,10 @@ namespace TD.Levels.Editor
|
|||
switch (v)
|
||||
{
|
||||
case PlayerZoneVolume pz: return pz.placementValidity == PlacementValidity.Invalid;
|
||||
case SpawnerVolume sv: return sv.placementValidity == PlacementValidity.Invalid;
|
||||
case LeakExitVolume lv: return lv.placementValidity == PlacementValidity.Invalid;
|
||||
case GoalVolume gv: return gv.placementValidity == PlacementValidity.Invalid;
|
||||
default: return false;
|
||||
case SpawnerVolume sv: return sv.placementValidity == PlacementValidity.Invalid;
|
||||
case LeakExitVolume lv: return lv.placementValidity == PlacementValidity.Invalid;
|
||||
case GoalVolume gv: return gv.placementValidity == PlacementValidity.Invalid;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -774,6 +813,45 @@ namespace TD.Levels.Editor
|
|||
report.Warning("P5-11", $"Walkability grid has {components} disconnected regions. " +
|
||||
"Some areas of the map are unreachable from others.");
|
||||
}
|
||||
|
||||
// P5-12: every gameplay volume's tiles must be a subset of the map area.
|
||||
// Coverage rule from the design spec — gameplay can't poke outside the playable map.
|
||||
// Skipped if no MapAreaVolume exists (P2-22 already errored; this would just spam).
|
||||
if (ctx.MapAreaTiles.Count > 0)
|
||||
{
|
||||
ValidateGameplayContainedInMapArea(ctx, report);
|
||||
}
|
||||
}
|
||||
|
||||
// P5-12: each gameplay volume (PlayerZone, Spawner, LeakExit, Goal) must have all its
|
||||
// tiles inside ctx.MapAreaTiles. Reports one error per offending volume with the count of
|
||||
// out-of-map tiles to avoid drowning the report in per-tile errors.
|
||||
private static void ValidateGameplayContainedInMapArea(BakeContext ctx, BakeReport report)
|
||||
{
|
||||
foreach (var v in ctx.AllVolumes)
|
||||
{
|
||||
if (v is MapAreaVolume) continue; // The map area itself doesn't need to contain itself.
|
||||
if (!ctx.PerVolumeTiles.TryGetValue(v, out var tiles)) continue;
|
||||
|
||||
int outsideCount = 0;
|
||||
Vector2Int firstOutside = Vector2Int.zero;
|
||||
foreach (var t in tiles)
|
||||
{
|
||||
if (!ctx.MapAreaTiles.Contains(t))
|
||||
{
|
||||
if (outsideCount == 0) firstOutside = t;
|
||||
outsideCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (outsideCount > 0)
|
||||
{
|
||||
report.Error("P5-12",
|
||||
$"{v.GetType().Name} '{v.name}' has {outsideCount} tile(s) outside the map area " +
|
||||
$"(first offender: {firstOutside}). Every gameplay volume must be fully contained " +
|
||||
"within the union of MapAreaVolumes.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int CountWalkableComponents(HashSet<Vector2Int> walkable)
|
||||
|
|
@ -830,6 +908,7 @@ namespace TD.Levels.Editor
|
|||
data.PlacementGrid = new PlacementState[total];
|
||||
data.WalkabilityGrid = new bool[total];
|
||||
data.OwnerGrid = new PlayerSlot[total]; // defaults to PlayerSlot.None (=0)
|
||||
data.MapAreaGrid = new bool[total]; // defaults to false
|
||||
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
|
|
@ -838,6 +917,7 @@ namespace TD.Levels.Editor
|
|||
int idx = y * width + x;
|
||||
data.PlacementGrid[idx] = ctx.PlacementGrid2D[x, y];
|
||||
data.WalkabilityGrid[idx] = ctx.WalkabilityGrid2D[x, y];
|
||||
data.MapAreaGrid[idx] = ctx.MapAreaGrid2D[x, y];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -984,26 +1064,26 @@ namespace TD.Levels.Editor
|
|||
|
||||
// -- Hash computation -------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Computes the authoring hash for a LevelAuthoring's scene state,
|
||||
/// suitable for comparing against <see cref="LevelData.AuthoringHash"/>
|
||||
/// to detect drift from baked data.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Delegates to the same hashing routine the bake itself uses, so equal
|
||||
/// hashes mean the scene would bake to equivalent LevelData. Hashes
|
||||
/// only Layer 1 authoring inputs (volumes + metadata); visual scene
|
||||
/// content (terrain, lighting, decorations) is NOT included.
|
||||
///
|
||||
/// Performs its own volume discovery and canonical sort. Does NOT run
|
||||
/// pre-validation or rasterization, so it returns a hash even for scenes
|
||||
/// that would fail the bake (e.g. missing zones, overlapping volumes).
|
||||
/// That's intentional: the play-mode hook needs a hash even when the
|
||||
/// scene is broken, so it can report "drift" as the actionable problem
|
||||
/// rather than silently differing from the last successful bake.
|
||||
/// </remarks>
|
||||
public static string ComputeAuthoringHash(LevelAuthoring authoring)
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the authoring hash for a LevelAuthoring's scene state,
|
||||
/// suitable for comparing against <see cref="LevelData.AuthoringHash"/>
|
||||
/// to detect drift from baked data.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Delegates to the same hashing routine the bake itself uses, so equal
|
||||
/// hashes mean the scene would bake to equivalent LevelData. Hashes
|
||||
/// only Layer 1 authoring inputs (volumes + metadata); visual scene
|
||||
/// content (terrain, lighting, decorations) is NOT included.
|
||||
///
|
||||
/// Performs its own volume discovery and canonical sort. Does NOT run
|
||||
/// pre-validation or rasterization, so it returns a hash even for scenes
|
||||
/// that would fail the bake (e.g. missing zones, overlapping volumes).
|
||||
/// That's intentional: the play-mode hook needs a hash even when the
|
||||
/// scene is broken, so it can report "drift" as the actionable problem
|
||||
/// rather than silently differing from the last successful bake.
|
||||
/// </remarks>
|
||||
public static string ComputeAuthoringHash(LevelAuthoring authoring)
|
||||
{
|
||||
if (authoring == null)
|
||||
return string.Empty;
|
||||
|
||||
|
|
@ -1018,13 +1098,15 @@ namespace TD.Levels.Editor
|
|||
var spawners = rootTransform.GetComponentsInChildren<SpawnerVolume>(includeInactive: false);
|
||||
var leakExits = rootTransform.GetComponentsInChildren<LeakExitVolume>(includeInactive: false);
|
||||
var goals = rootTransform.GetComponentsInChildren<GoalVolume>(includeInactive: false);
|
||||
var mapAreas = rootTransform.GetComponentsInChildren<MapAreaVolume>(includeInactive: false);
|
||||
|
||||
ctx.AllVolumes = new System.Collections.Generic.List<VolumeBase>(
|
||||
playerZones.Length + spawners.Length + leakExits.Length + goals.Length);
|
||||
playerZones.Length + spawners.Length + leakExits.Length + goals.Length + mapAreas.Length);
|
||||
ctx.AllVolumes.AddRange(playerZones);
|
||||
ctx.AllVolumes.AddRange(spawners);
|
||||
ctx.AllVolumes.AddRange(leakExits);
|
||||
ctx.AllVolumes.AddRange(goals);
|
||||
ctx.AllVolumes.AddRange(mapAreas);
|
||||
|
||||
// Match Phase 1's canonical sort exactly. The hash routine re-orders
|
||||
// by canonical path internally, but sorting AllVolumes here keeps
|
||||
|
|
@ -1240,6 +1322,7 @@ namespace TD.Levels.Editor
|
|||
target.PlacementGrid = src.PlacementGrid;
|
||||
target.WalkabilityGrid = src.WalkabilityGrid;
|
||||
target.OwnerGrid = src.OwnerGrid;
|
||||
target.MapAreaGrid = src.MapAreaGrid;
|
||||
target.PlayerZones = src.PlayerZones;
|
||||
target.Goals = src.Goals;
|
||||
|
||||
|
|
@ -1272,6 +1355,7 @@ namespace TD.Levels.Editor
|
|||
public List<SpawnerVolume> SpawnerVolumes;
|
||||
public List<LeakExitVolume> LeakExitVolumes;
|
||||
public List<GoalVolume> GoalVolumes;
|
||||
public List<MapAreaVolume> MapAreaVolumes;
|
||||
|
||||
// Phase 2 outputs
|
||||
public HashSet<PlayerSlot> ExpectedOwners;
|
||||
|
|
@ -1281,6 +1365,7 @@ namespace TD.Levels.Editor
|
|||
public Dictionary<VolumeBase, HashSet<Vector2Int>> PerVolumeTiles;
|
||||
public Dictionary<PlayerSlot, HashSet<Vector2Int>> PerOwnerZoneTiles;
|
||||
public List<HashSet<Vector2Int>> PerGoalTiles;
|
||||
public HashSet<Vector2Int> MapAreaTiles;
|
||||
public Vector2Int MapMinTile;
|
||||
public Vector2Int MapMaxTile;
|
||||
|
||||
|
|
@ -1289,6 +1374,7 @@ namespace TD.Levels.Editor
|
|||
public Vector2Int GridSize;
|
||||
public PlacementState[,] PlacementGrid2D;
|
||||
public bool[,] WalkabilityGrid2D;
|
||||
public bool[,] MapAreaGrid2D;
|
||||
|
||||
// Phase 5 outputs
|
||||
public HashSet<PlayerSlot> GoalAdjacentZones;
|
||||
|
|
@ -1297,4 +1383,4 @@ namespace TD.Levels.Editor
|
|||
public LevelData AssembledData;
|
||||
public string ThumbnailPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue