Adding big batch of structural code provided by Claude to start designing levels

This commit is contained in:
Matt F 2026-04-27 22:55:23 -07:00
parent 0ed4df8bc9
commit a4e28bc93f
26 changed files with 1951 additions and 0 deletions

View file

@ -0,0 +1,66 @@
using UnityEngine;
using TD.Core;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace TD.Levels
{
/// <summary>
/// Authoring volume marking the win-condition target. Enemies that reach a goal volume reduce
/// the shared player life pool. Goal tiles are <see cref="PlacementState.Restricted"/> in the
/// baked grid but remain walkable so enemies can enter them.
/// </summary>
/// <remarks>
/// Goals have no ownership — they are shared across all players. Multiple goal volumes are
/// allowed; the designer specifies the expected count via
/// <see cref="LevelAuthoring.expectedGoalCount"/> and the bake validates the count matches.
///
/// Final defenders are identified by their player zone being 4-adjacent to a goal volume's
/// tile coverage; they do not have a LeakExitVolume.
/// </remarks>
public class GoalVolume : VolumeBase
{
[Tooltip("Whether tiles in this volume are buildable. Defaults to Invalid.")]
public PlacementValidity placementValidity = PlacementValidity.Invalid;
// Goals draw above player zones to be visually anchoring landmarks. Higher alpha than
// other volume types (60% per gizmo design).
private const float FillYLevel = 0.04f;
protected override bool GetAlwaysShowToggle(LevelAuthoring authoring)
{
return authoring.alwaysShowGoals;
}
private void OnDrawGizmosSelected()
{
DrawGizmosCore();
}
private void OnDrawGizmos()
{
if (ShouldDrawAlwaysOn())
{
DrawGizmosCore();
}
}
private void DrawGizmosCore()
{
Color goalColor = PlayerColors.Goal;
DrawTileCoverageFill(goalColor, alpha: 0.60f, yLevel: FillYLevel);
DrawRectangularOutline(goalColor, yLevel: FillYLevel);
#if UNITY_EDITOR
var col = Collider;
if (col != null)
{
Vector3 labelPos = new Vector3(col.bounds.center.x, FillYLevel + 0.1f, col.bounds.center.z);
Handles.Label(labelPos, "GOAL");
}
#endif
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 755948f0244afea4da6ed2e4e3d27579

View file

@ -0,0 +1,131 @@
using UnityEngine;
using TD.Core;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace TD.Levels
{
/// <summary>
/// Authoring volume marking the boundary where enemies leak from one player's zone into
/// another's. Leak exit tiles are <see cref="PlacementState.Restricted"/> in the baked grid
/// but remain walkable.
/// </summary>
/// <remarks>
/// Leak topology is recorded as metadata in the baked <see cref="LevelData"/> for lobby UI
/// and future wave-balancing systems, but the runtime gameplay loop does not depend on it —
/// enemies pathfind on the unified walkability grid and naturally cross zone boundaries.
///
/// Final defenders (the player whose zone is goal-adjacent) do NOT have a LeakExitVolume.
/// They are identified by zone-to-goal adjacency at bake time.
///
/// Multiple leak exits with the same source zone may exist (e.g., Player 5 in the 9-player
/// map splits leaks 50/50 between Player 4 and Player 6). The <see cref="weight"/> field
/// controls the split. The bake normalizes weights to sum to 1.0 across a source zone's
/// leak exits.
/// </remarks>
public class LeakExitVolume : VolumeBase
{
[Tooltip("Which player's zone this leak exits FROM.")]
public PlayerSlot sourceZone = PlayerSlot.Player1;
[Tooltip("Which player's zone this leak feeds INTO.")]
public PlayerSlot target = PlayerSlot.Player2;
[Tooltip("Relative weight for split exits. Bake normalizes weights to sum to 1.0 across all " +
"leak exits sharing the same source zone. Set both leaks' weights to 1.0 for a 50/50 split.")]
public float weight = 1.0f;
[Tooltip("Whether tiles in this volume are buildable. Defaults to Invalid.")]
public PlacementValidity placementValidity = PlacementValidity.Invalid;
// Leak exits draw above player zones but below spawners.
private const float FillYLevel = 0.04f;
private const float ArrowYLevel = 0.05f;
private const float ArrowLength = 1.5f;
protected override bool GetAlwaysShowToggle(LevelAuthoring authoring)
{
return authoring.alwaysShowLeakExits;
}
private void OnDrawGizmosSelected()
{
DrawGizmosCore();
}
private void OnDrawGizmos()
{
if (ShouldDrawAlwaysOn())
{
DrawGizmosCore();
}
}
private void DrawGizmosCore()
{
// Leak exit fill uses the SOURCE zone's color (visually attaches the leak to the zone
// it leaves). The target is conveyed by the arrow direction and label.
Color baseColor = PlayerColors.Get(sourceZone);
DrawTileCoverageFill(baseColor, alpha: 0.40f, yLevel: FillYLevel);
DrawRectangularOutline(baseColor, yLevel: FillYLevel);
// Arrow points from this leak exit's center toward the target zone's center.
var col = Collider;
if (col != null)
{
Vector3 origin = new Vector3(col.bounds.center.x, ArrowYLevel, col.bounds.center.z);
Vector3 targetCenter = FindTargetZoneCenter();
if (targetCenter.sqrMagnitude > 0f) // sentinel: zero means "not found"
{
Vector3 directionXZ = new Vector3(targetCenter.x - origin.x, 0f, targetCenter.z - origin.z);
DrawArrow(origin, directionXZ, ArrowLength, baseColor);
}
}
#if UNITY_EDITOR
if (col != null)
{
Vector3 labelPos = new Vector3(col.bounds.center.x, ArrowYLevel + 0.1f, col.bounds.center.z);
Handles.Label(labelPos, $"→ {FormatPlayerName(target)}");
}
#endif
}
/// <summary>
/// Computes the world-space center of the target zone by averaging the bounds-centers
/// of all PlayerZoneVolumes whose <see cref="PlayerZoneVolume.owner"/> matches
/// <see cref="target"/>. Returns <c>Vector3.zero</c> if no matching zone is found —
/// the caller treats zero as a "not found" sentinel.
/// </summary>
/// <remarks>
/// Performs a scene-wide query each gizmo draw. This is fine for our worst case
/// (9-player map: 8 leak exits × ~10 zone volumes = ~80 considerations per frame, only
/// when gizmos are drawing). If this ever shows up as an editor-perf issue, switch to a
/// cached lookup invalidated on hierarchy change.
/// </remarks>
private Vector3 FindTargetZoneCenter()
{
var zones = Object.FindObjectsByType<PlayerZoneVolume>(FindObjectsInactive.Exclude);
if (zones == null || zones.Length == 0) return Vector3.zero;
Vector3 accumulated = Vector3.zero;
int count = 0;
for (int i = 0; i < zones.Length; i++)
{
if (zones[i] == null) continue;
if (zones[i].owner != target) continue;
var c = zones[i].GetComponent<BoxCollider>();
if (c == null) continue;
accumulated += c.bounds.center;
count++;
}
if (count == 0) return Vector3.zero;
return accumulated / count;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d4da5d6673a12f546ac47563abc49d5a

View file

@ -0,0 +1,258 @@
using System.Collections.Generic;
using UnityEngine;
using TD.Core;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace TD.Levels
{
/// <summary>
/// Scene-level coordinator for level authoring. One per scene, attached to an empty
/// GameObject named <c>_LevelAuthoring</c> whose subtree contains all the volumes for the map.
/// </summary>
/// <remarks>
/// LevelAuthoring is the entry point for the bake operation: the custom inspector exposes a
/// "Bake LevelData" button that triggers the seven-phase algorithm, writing into the
/// <see cref="targetAsset"/> ScriptableObject.
///
/// This class also owns the per-volume-type "always show gizmos" toggles. Each volume walks
/// its parent hierarchy to find this LevelAuthoring and reads the relevant toggle.
///
/// In the current session the bake methods are stubs — only the field schema and gizmo
/// rendering are implemented. The full bake script is the next session's work.
/// </remarks>
public class LevelAuthoring : MonoBehaviour
{
// -------------------------------------------------------------------
// Bake target and lobby-facing metadata.
// -------------------------------------------------------------------
[Header("Bake Target")]
[Tooltip("The ScriptableObject this scene bakes into. Must be set before baking.")]
public LevelData targetAsset;
[Header("Map Metadata")]
[Tooltip("Display name for the lobby UI.")]
public string mapName = "";
[Tooltip("Number of players this map is designed for. Used by the lobby to filter maps " +
"and by bake validation. Allowed values: 1, 2, 3, 4, 5, 9.")]
public int playerCount = 1;
[Tooltip("Expected number of GoalVolume instances. Bake validates the count matches.")]
public int expectedGoalCount = 1;
[Tooltip("Optional description for the lobby UI. Empty triggers a soft warning at bake time.")]
[TextArea(2, 5)]
public string mapDescription = "";
[Tooltip("Optional author/credit text. Empty triggers a soft warning at bake time.")]
public string author = "";
// -------------------------------------------------------------------
// Gizmo visibility toggles. Each volume type reads its own toggle to decide whether to
// draw always-on or only-when-selected.
// -------------------------------------------------------------------
[Header("Gizmo Visibility (Always-Show Toggles)")]
[Tooltip("If true, PlayerZoneVolume gizmos draw whether or not the volume is selected.")]
public bool alwaysShowPlayerZones = false;
[Tooltip("If true, SpawnerVolume gizmos draw whether or not the volume is selected.")]
public bool alwaysShowSpawners = false;
[Tooltip("If true, LeakExitVolume gizmos draw whether or not the volume is selected.")]
public bool alwaysShowLeakExits = false;
[Tooltip("If true, GoalVolume gizmos draw whether or not the volume is selected.")]
public bool alwaysShowGoals = false;
// -------------------------------------------------------------------
// Bake API (stubs this session — full implementation comes next session).
// -------------------------------------------------------------------
/// <summary>
/// Runs the seven-phase bake algorithm and writes the result into <see cref="targetAsset"/>.
/// </summary>
/// <returns>True if the bake succeeded (with or without warnings); false if validation
/// failed or any other hard error occurred. On failure, the existing targetAsset on disk
/// is left untouched.</returns>
/// <remarks>
/// STUB — full implementation is the next session's work. Currently logs a not-implemented
/// message and returns false.
/// </remarks>
public bool BakeLevelData()
{
Debug.LogWarning("[LevelAuthoring] BakeLevelData is not yet implemented. " +
"The seven-phase bake algorithm will be added in the next session.");
return false;
}
/// <summary>
/// Re-renders just the lobby thumbnail without doing a full bake. Useful when only visual
/// scene content (terrain, decorations) has changed.
/// </summary>
/// <remarks>
/// STUB — full implementation is part of the bake script work. Currently logs a
/// not-implemented message.
/// </remarks>
public void RefreshThumbnail()
{
Debug.LogWarning("[LevelAuthoring] RefreshThumbnail is not yet implemented. " +
"Thumbnail rendering will be added with the bake script.");
}
// -------------------------------------------------------------------
// Map-level gizmos: origin marker, map bounding rect, combined player zone outlines.
// Always draw when LevelAuthoring is in the scene (no per-toggle gating for these).
// -------------------------------------------------------------------
private const float OriginMarkerY = 0.005f;
private const float MapBoundsY = 0.01f;
private const float CombinedZoneOutlineY = 0.03f;
private const float OriginMarkerSize = 0.4f;
private void OnDrawGizmos()
{
DrawOriginMarker();
DrawMapBoundingRect();
DrawCombinedPlayerZoneOutlines();
}
private void DrawOriginMarker()
{
Color prev = Gizmos.color;
Gizmos.color = Color.white;
// A small "+" cross at world (0,0) plus a sphere for visibility.
Vector3 origin = new Vector3(0f, OriginMarkerY, 0f);
Gizmos.DrawSphere(origin, 0.1f);
Gizmos.DrawLine(origin + new Vector3(-OriginMarkerSize, 0f, 0f),
origin + new Vector3( OriginMarkerSize, 0f, 0f));
Gizmos.DrawLine(origin + new Vector3(0f, 0f, -OriginMarkerSize),
origin + new Vector3(0f, 0f, OriginMarkerSize));
Gizmos.color = prev;
#if UNITY_EDITOR
Handles.Label(origin + new Vector3(0.15f, 0.05f, 0.15f), "Tile (0,0)");
#endif
}
private void DrawMapBoundingRect()
{
// Find every VolumeBase in this LevelAuthoring's subtree (matches the bake's scoped scan)
// and union their tile bounding rectangles.
var volumes = GetComponentsInChildren<VolumeBase>(includeInactive: false);
if (volumes == null || volumes.Length == 0) return;
bool initialized = false;
Vector2Int minTile = Vector2Int.zero;
Vector2Int maxTile = Vector2Int.zero;
for (int i = 0; i < volumes.Length; i++)
{
var col = volumes[i] != null ? volumes[i].GetComponent<BoxCollider>() : null;
if (col == null) continue;
if (!VolumeBase.TryGetTightTileRect(col.bounds, out Vector2Int vMin, out Vector2Int vMax))
{
continue; // volume covers no tiles (e.g., bounds don't intersect Y=0)
}
if (!initialized)
{
minTile = vMin;
maxTile = vMax;
initialized = true;
}
else
{
if (vMin.x < minTile.x) minTile.x = vMin.x;
if (vMin.y < minTile.y) minTile.y = vMin.y;
if (vMax.x > maxTile.x) maxTile.x = vMax.x;
if (vMax.y > maxTile.y) maxTile.y = vMax.y;
}
}
if (!initialized) return;
// Convert tile-corner indices to world-space corners. Tiles are center-based with
// TILE_SIZE = 1, so a tile at (x,y) spans world XZ from (x-0.5, y-0.5) to (x+0.5, y+0.5).
float halfTile = GridCoordinates.TILE_SIZE * 0.5f;
Vector3 sw = new Vector3(minTile.x - halfTile, MapBoundsY, minTile.y - halfTile);
Vector3 se = new Vector3(maxTile.x + halfTile, MapBoundsY, minTile.y - halfTile);
Vector3 ne = new Vector3(maxTile.x + halfTile, MapBoundsY, maxTile.y + halfTile);
Vector3 nw = new Vector3(minTile.x - halfTile, MapBoundsY, maxTile.y + halfTile);
Color prev = Gizmos.color;
Gizmos.color = new Color(1f, 1f, 1f, 0.6f); // muted white
Gizmos.DrawLine(sw, se);
Gizmos.DrawLine(se, ne);
Gizmos.DrawLine(ne, nw);
Gizmos.DrawLine(nw, sw);
Gizmos.color = prev;
}
private void DrawCombinedPlayerZoneOutlines()
{
// Group all PlayerZoneVolumes in this subtree by owner, then for each owner compute
// the union of their tile coverage and draw the perimeter using the general
// (non-rectangular) algorithm. This visualizes L-shaped multi-volume zones as a
// single unified outline.
var zones = GetComponentsInChildren<PlayerZoneVolume>(includeInactive: false);
if (zones == null || zones.Length == 0) return;
// Build per-owner tile sets.
var perOwner = new Dictionary<PlayerSlot, HashSet<Vector2Int>>();
for (int i = 0; i < zones.Length; i++)
{
var z = zones[i];
if (z == null) continue;
var col = z.GetComponent<BoxCollider>();
if (col == null) continue;
if (!perOwner.TryGetValue(z.owner, out var set))
{
set = new HashSet<Vector2Int>();
perOwner[z.owner] = set;
}
// Use the shared rasterizer so this stays in lock-step with what the bake will see.
VolumeBase.RasterizeBoundsToTiles(col.bounds, t => set.Add(t));
}
// For each owner, draw perimeter using the general algorithm.
float halfTile = GridCoordinates.TILE_SIZE * 0.5f;
Color prev = Gizmos.color;
foreach (var kv in perOwner)
{
Color outlineColor = PlayerColors.Get(kv.Key);
Gizmos.color = outlineColor;
foreach (var tile in kv.Value)
{
// World-space corners of this tile.
Vector3 sw = new Vector3(tile.x - halfTile, CombinedZoneOutlineY, tile.y - halfTile);
Vector3 se = new Vector3(tile.x + halfTile, CombinedZoneOutlineY, tile.y - halfTile);
Vector3 ne = new Vector3(tile.x + halfTile, CombinedZoneOutlineY, tile.y + halfTile);
Vector3 nw = new Vector3(tile.x - halfTile, CombinedZoneOutlineY, tile.y + halfTile);
// For each of the four edges, draw it ONLY if the neighbor across that edge
// is not in the covered set (i.e., the edge is on the perimeter).
if (!kv.Value.Contains(new Vector2Int(tile.x, tile.y - 1))) Gizmos.DrawLine(sw, se); // south edge
if (!kv.Value.Contains(new Vector2Int(tile.x + 1, tile.y))) Gizmos.DrawLine(se, ne); // east edge
if (!kv.Value.Contains(new Vector2Int(tile.x, tile.y + 1))) Gizmos.DrawLine(ne, nw); // north edge
if (!kv.Value.Contains(new Vector2Int(tile.x - 1, tile.y))) Gizmos.DrawLine(nw, sw); // west edge
}
}
Gizmos.color = prev;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 37de128911d7bbc4299ea87cdb520ed7

View file

@ -0,0 +1,144 @@
using System;
using UnityEngine;
using TD.Core;
namespace TD.Levels
{
/// <summary>
/// Baked level data. Authored in a Unity scene via volume MonoBehaviours; produced by
/// <see cref="LevelAuthoring.BakeLevelData"/>; consumed at runtime as the canonical map
/// definition.
/// </summary>
/// <remarks>
/// LevelData is the canonical map identity. The lobby browses LevelData assets; loading a
/// map = read LevelData → load the referenced scene at <see cref="ScenePath"/>.
///
/// All flat grid arrays use row-major indexing: <c>grid[y * GridSize.x + x]</c>.
/// Origin-relative: <see cref="GridOriginTile"/> is the world-tile coordinate that grid index
/// (0,0) corresponds to. Convert world-tile to grid-index via
/// <c>worldTile - GridOriginTile</c>.
/// </remarks>
[CreateAssetMenu(fileName = "LevelData", menuName = "TD/Level Data", order = 1)]
public class LevelData : ScriptableObject
{
// -------------------------------------------------------------------
// Lobby-facing metadata.
// -------------------------------------------------------------------
[Header("Map Metadata")]
public string MapName;
public int PlayerCount;
public string MapDescription;
public string Author;
[Tooltip("Auto-generated by bake. Use the Refresh Thumbnail button on LevelAuthoring to " +
"re-render without doing a full bake.")]
public Sprite MapThumbnail;
[Tooltip("Path to the scene this LevelData was baked from. Auto-populated by bake.")]
public string ScenePath;
// -------------------------------------------------------------------
// Bake metadata (used for dirty-detection and diagnostic display).
// -------------------------------------------------------------------
[Header("Bake Metadata")]
[Tooltip("Hash of the authoring inputs at last bake. Compared at Play-mode entry to detect " +
"unbaked changes. Captures volumes + LevelAuthoring metadata; does NOT capture " +
"visual scene content.")]
public string AuthoringHash;
[Tooltip("ISO 8601 UTC timestamp of the last successful bake.")]
public string LastBakeTimestamp;
public BakeOutcome LastBakeOutcome;
public int LastBakeWarningCount;
// -------------------------------------------------------------------
// Grid metadata.
// -------------------------------------------------------------------
[Header("Grid")]
[Tooltip("World-tile coordinate that grid index (0,0) corresponds to. Origin-relative " +
"addressing: worldTile - GridOriginTile gives the index into the flat arrays.")]
public Vector2Int GridOriginTile;
[Tooltip("Width × height of the grid in tiles.")]
public Vector2Int GridSize;
// -------------------------------------------------------------------
// Per-tile flat arrays. All indexed as grid[y * GridSize.x + x].
// -------------------------------------------------------------------
[Tooltip("Per-tile placement state: Outside / Buildable / Restricted. Length = GridSize.x * GridSize.y.")]
public PlacementState[] PlacementGrid;
[Tooltip("Per-tile initial walkability (does NOT account for towers — they stamp footprints " +
"at runtime). Length = GridSize.x * GridSize.y.")]
public bool[] WalkabilityGrid;
[Tooltip("Per-tile owning player slot. PlayerSlot.None for tiles not in any player zone. " +
"Length = GridSize.x * GridSize.y.")]
public PlayerSlot[] OwnerGrid;
// -------------------------------------------------------------------
// Per-zone and per-goal structures (populated by bake from volume data).
// -------------------------------------------------------------------
[Tooltip("One entry per declared player zone, sorted by Owner.")]
public PlayerZoneData[] PlayerZones;
[Tooltip("One entry per GoalVolume, sorted by min tile coordinate.")]
public GoalData[] Goals;
}
/// <summary>
/// Per-zone baked data. The runtime can use this to enumerate a player's spawners and leak
/// exits without scanning the full grid.
/// </summary>
[Serializable]
public class PlayerZoneData
{
public PlayerSlot Owner;
[Tooltip("Spawners in this zone, sorted by SpawnerIdInZone.")]
public SpawnerData[] Spawners;
[Tooltip("Leak exits OUT OF this zone, sorted by Target enum value. Empty for the final " +
"defender (whose zone is goal-adjacent and has no leak exit).")]
public LeakExitData[] LeakExits;
}
[Serializable]
public class SpawnerData
{
public int SpawnerIdInZone;
[Tooltip("Tile coordinate of the spawner's center (its volume's bounds center, projected to grid).")]
public Vector2Int TilePosition;
[Tooltip("Full tile coverage of the spawner volume.")]
public Vector2Int[] TileArea;
public Direction Facing;
}
[Serializable]
public class LeakExitData
{
public PlayerSlot Target;
[Tooltip("Full tile coverage of the leak exit volume.")]
public Vector2Int[] TileArea;
[Tooltip("Weight normalized so all leak exits sharing the same source zone sum to 1.0.")]
public float NormalizedWeight;
}
[Serializable]
public class GoalData
{
[Tooltip("Full tile coverage of the goal volume.")]
public Vector2Int[] TileArea;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6d4e9c37b9205f3408a8225823f7a4da

View file

@ -0,0 +1,68 @@
using UnityEngine;
using TD.Core;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace TD.Levels
{
/// <summary>
/// Authoring volume that defines a player's owned territory. Tiles covered by a
/// PlayerZoneVolume are <see cref="PlacementState.Buildable"/> in the baked grid (unless
/// covered by an Invalid-validity volume — Invalid wins).
/// </summary>
/// <remarks>
/// A single player's zone may consist of multiple PlayerZoneVolume instances with the same
/// <see cref="owner"/>; the bake unions their tile coverage. This supports L-shapes and other
/// non-rectangular zone footprints.
/// </remarks>
public class PlayerZoneVolume : VolumeBase
{
[Tooltip("Which player owns this zone.")]
public PlayerSlot owner = PlayerSlot.Player1;
[Tooltip("Whether tiles in this volume are buildable. Defaults to Allowed.")]
public PlacementValidity placementValidity = PlacementValidity.Allowed;
// Per-volume gizmo Y level (see gizmo design summary). Player zones draw lowest so
// overlapping spawners/leak exits/goals stack on top of them.
private const float FillYLevel = 0.02f;
protected override bool GetAlwaysShowToggle(LevelAuthoring authoring)
{
return authoring.alwaysShowPlayerZones;
}
// Selection-only by default. Always-on path runs from OnDrawGizmos when the toggle is set.
private void OnDrawGizmosSelected()
{
DrawGizmosCore();
}
private void OnDrawGizmos()
{
if (ShouldDrawAlwaysOn())
{
DrawGizmosCore();
}
}
private void DrawGizmosCore()
{
Color baseColor = PlayerColors.Get(owner);
DrawTileCoverageFill(baseColor, alpha: 0.35f, yLevel: FillYLevel);
DrawRectangularOutline(baseColor, yLevel: FillYLevel);
#if UNITY_EDITOR
// Label centered on the volume's bounds, identifying which player owns this build area.
var col = Collider;
if (col != null)
{
Vector3 labelPos = new Vector3(col.bounds.center.x, FillYLevel + 0.1f, col.bounds.center.z);
Handles.Label(labelPos, $"{FormatPlayerName(owner)} Build Area");
}
#endif
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5e8ab74b9038bd64782043bfc21f4cd7

View file

@ -0,0 +1,118 @@
using UnityEngine;
using TD.Core;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace TD.Levels
{
/// <summary>
/// Authoring volume marking where enemies spawn into a player's zone. Spawner tiles are
/// <see cref="PlacementState.Restricted"/> in the baked grid (no tower placement allowed)
/// but remain walkable so enemies can leave the spawner.
/// </summary>
/// <remarks>
/// A zone may have multiple spawners; <see cref="spawnerIdInZone"/> disambiguates them.
/// Player 5 in the 9-player Wintermaul map is the canonical multi-spawner zone.
///
/// The spawner declares an owning player via <see cref="owner"/> rather than relying on
/// spatial containment within a PlayerZoneVolume. The bake validates (with a soft warning)
/// that the spawner's tiles fall inside its declared owner's zone.
/// </remarks>
public class SpawnerVolume : VolumeBase
{
[Tooltip("Which player's zone owns this spawner. Explicit — not inferred from spatial overlap.")]
public PlayerSlot owner = PlayerSlot.Player1;
[Tooltip("Disambiguator for zones with multiple spawners. Should be 0 for single-spawner zones, " +
"and contiguous starting from 0 for multi-spawner zones (e.g., 0 and 1).")]
public int spawnerIdInZone = 0;
[Tooltip("Direction enemies face when they spawn. Used for visual orientation and may bias " +
"initial enemy movement direction.")]
public Direction spawnFacing = Direction.South;
[Tooltip("Whether tiles in this volume are buildable. Defaults to Invalid (no placement on spawners).")]
public PlacementValidity placementValidity = PlacementValidity.Invalid;
// Spawners draw above player zones so the player zone color reads as background.
private const float FillYLevel = 0.06f;
private const float ArrowYLevel = 0.07f;
private const float ArrowLength = 1.5f; // 1.5 tiles per gizmo design
protected override bool GetAlwaysShowToggle(LevelAuthoring authoring)
{
return authoring.alwaysShowSpawners;
}
private void OnDrawGizmosSelected()
{
DrawGizmosCore();
}
private void OnDrawGizmos()
{
if (ShouldDrawAlwaysOn())
{
DrawGizmosCore();
}
}
private void DrawGizmosCore()
{
Color baseColor = PlayerColors.Get(owner);
DrawTileCoverageFill(baseColor, alpha: 0.40f, yLevel: FillYLevel);
DrawRectangularOutline(baseColor, yLevel: FillYLevel);
// Direction arrow originates from the volume's center and extends 1.5 tiles in the
// declared facing direction, rendered in opaque owner color.
var col = Collider;
if (col != null)
{
Vector3 arrowOrigin = new Vector3(col.bounds.center.x, ArrowYLevel, col.bounds.center.z);
Vector3 arrowDir = DirectionToWorld(spawnFacing);
DrawArrow(arrowOrigin, arrowDir, ArrowLength, baseColor);
}
#if UNITY_EDITOR
// Label conditional on multi-spawner status:
// - Single-spawner zone: "Player N Spawn"
// - Multi-spawner zone: "Player N Spawn 0", "Player N Spawn 1", etc.
// Multi-spawner detection scans other SpawnerVolumes in the scene with the same owner.
// Same posture as the leak-exit target lookup: cheap on realistic maps, can be cached
// if it ever shows up as an editor-perf issue.
if (col != null)
{
Vector3 labelPos = new Vector3(col.bounds.center.x, ArrowYLevel + 0.1f, col.bounds.center.z);
string labelText = ZoneHasMultipleSpawners()
? $"{FormatPlayerName(owner)} Spawn {spawnerIdInZone}"
: $"{FormatPlayerName(owner)} Spawn";
Handles.Label(labelPos, labelText);
}
#endif
}
/// <summary>
/// Returns true if more than one active SpawnerVolume in the scene shares this spawner's
/// <see cref="owner"/>. Used to decide whether to suffix the gizmo label with the spawner ID.
/// </summary>
private bool ZoneHasMultipleSpawners()
{
var spawners = Object.FindObjectsByType<SpawnerVolume>(FindObjectsInactive.Exclude);
if (spawners == null) return false;
int count = 0;
for (int i = 0; i < spawners.Length; i++)
{
if (spawners[i] == null) continue;
if (spawners[i].owner == owner)
{
count++;
if (count > 1) return true;
}
}
return false;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a756762f899008f45b2986bdcb11c819

View file

@ -0,0 +1,319 @@
using UnityEngine;
using TD.Core;
namespace TD.Levels
{
/// <summary>
/// Abstract base class for all level authoring volumes (player zones, spawners, leak exits, goals).
/// </summary>
/// <remarks>
/// Every concrete volume requires a <see cref="BoxCollider"/>. The collider's bounds (in world
/// space) define the volume's spatial extent, which the bake script rasterizes to tiles via
/// <see cref="BoxCollider.bounds"/> and <c>bounds.Contains(tileCenter)</c>.
///
/// Volumes are authoring-time artifacts only — they are NOT part of the runtime gameplay scene.
/// At runtime the bake's output (<see cref="LevelData"/>) is the sole source of truth.
///
/// Visibility model: gizmos draw via <c>OnDrawGizmosSelected</c> by default (selection-only).
/// The owning <see cref="LevelAuthoring"/> exposes per-type toggles to make a category always-on.
/// Volumes not parented under a <c>LevelAuthoring</c> (orphans) always behave as selection-only;
/// they will also produce warnings during bake.
/// </remarks>
[RequireComponent(typeof(BoxCollider))]
[DisallowMultipleComponent]
public abstract class VolumeBase : MonoBehaviour
{
// Cached collider reference. Looked up lazily and refreshed if it goes null
// (e.g., the user manually removed the component).
private BoxCollider _cachedCollider;
// Cached LevelAuthoring lookup. We walk parents to find it once, then keep the reference.
// If the cached reference becomes null (parent moved, scene changed), the next access re-resolves.
private LevelAuthoring _cachedAuthoring;
/// <summary>
/// Gets the volume's <see cref="BoxCollider"/>, looking it up lazily. Returns null if the
/// component has been removed (which violates <c>[RequireComponent]</c> but can happen if
/// a user manually removes it).
/// </summary>
protected BoxCollider Collider
{
get
{
if (_cachedCollider == null)
{
_cachedCollider = GetComponent<BoxCollider>();
}
return _cachedCollider;
}
}
/// <summary>
/// Walks the parent transform hierarchy to find the owning <see cref="LevelAuthoring"/>
/// component. Returns null if this volume is orphaned (not parented under a LevelAuthoring).
/// Result is cached; the cache invalidates automatically if the reference goes null.
/// </summary>
protected LevelAuthoring FindOwningAuthoring()
{
if (_cachedAuthoring != null)
{
return _cachedAuthoring;
}
// GetComponentInParent walks up the hierarchy. Includes inactive parents to handle the
// case where _LevelAuthoring is temporarily disabled during scene work.
_cachedAuthoring = GetComponentInParent<LevelAuthoring>(includeInactive: true);
return _cachedAuthoring;
}
/// <summary>
/// When implemented by a subclass, returns whether the corresponding "always show" toggle
/// is enabled on the supplied <see cref="LevelAuthoring"/>. Each volume type maps to a
/// different toggle field.
/// </summary>
/// <param name="authoring">The owning LevelAuthoring (guaranteed non-null).</param>
protected abstract bool GetAlwaysShowToggle(LevelAuthoring authoring);
/// <summary>
/// Decides whether the "always show" path should run for this volume. Returns true if a
/// LevelAuthoring is found in parents AND its corresponding toggle is enabled. Orphans
/// always return false (always-show requires an authoring to read the toggle from).
/// </summary>
protected bool ShouldDrawAlwaysOn()
{
var authoring = FindOwningAuthoring();
if (authoring == null)
{
return false;
}
return GetAlwaysShowToggle(authoring);
}
// -------------------------------------------------------------------
// Static rasterization helper. Single source of truth for converting a Bounds to the
// set of tiles its rasterization covers. The bake will use the same primitive (a tile
// is "covered" iff bounds.Contains(tileCenter)) so gizmos and bake stay in lock-step.
// -------------------------------------------------------------------
/// <summary>
/// Iterates every tile whose center lies inside <paramref name="bounds"/> and invokes
/// <paramref name="onTile"/> for each one. The candidate range is computed via
/// <see cref="GridCoordinates.WorldToGrid"/> with a one-tile padding on each side to guard
/// against rounding surprises (WorldToGrid uses round-to-nearest, which can over- or
/// under-shoot by one when bounds align with tile edges); the per-tile
/// <c>bounds.Contains(tileCenter)</c> test inside the loop guarantees correctness.
/// </summary>
public static void RasterizeBoundsToTiles(Bounds bounds, System.Action<Vector2Int> onTile)
{
if (onTile == null) return;
Vector2Int candidateMin = GridCoordinates.WorldToGrid(new Vector2(bounds.min.x, bounds.min.z));
Vector2Int candidateMax = GridCoordinates.WorldToGrid(new Vector2(bounds.max.x, bounds.max.z));
int xLo = candidateMin.x - 1;
int xHi = candidateMax.x + 1;
int yLo = candidateMin.y - 1;
int yHi = candidateMax.y + 1;
for (int x = xLo; x <= xHi; x++)
{
for (int y = yLo; y <= yHi; y++)
{
Vector3 tileCenter = GridCoordinates.GridToWorld(new Vector2Int(x, y));
Vector3 testPoint = new Vector3(tileCenter.x, bounds.center.y, tileCenter.z);
if (bounds.Contains(testPoint))
{
onTile(new Vector2Int(x, y));
}
}
}
}
/// <summary>
/// Computes the tight tile rectangle for a <see cref="Bounds"/> — the rectangle that
/// exactly contains every tile whose center is inside the bounds. Returns false if no
/// tile centers fall inside the bounds.
/// </summary>
public static bool TryGetTightTileRect(Bounds bounds, out Vector2Int minTile, out Vector2Int maxTile)
{
bool any = false;
int minX = 0, maxX = 0, minY = 0, maxY = 0;
RasterizeBoundsToTiles(bounds, t =>
{
if (!any)
{
minX = maxX = t.x;
minY = maxY = t.y;
any = true;
}
else
{
if (t.x < minX) minX = t.x;
if (t.x > maxX) maxX = t.x;
if (t.y < minY) minY = t.y;
if (t.y > maxY) maxY = t.y;
}
});
if (!any)
{
minTile = Vector2Int.zero;
maxTile = Vector2Int.zero;
return false;
}
minTile = new Vector2Int(minX, minY);
maxTile = new Vector2Int(maxX, maxY);
return true;
}
/// <summary>
/// Instance-side convenience: computes the tight tile rectangle for THIS volume's collider.
/// </summary>
protected bool TryGetTightTileRect(out Vector2Int minTile, out Vector2Int maxTile)
{
minTile = Vector2Int.zero;
maxTile = Vector2Int.zero;
var col = Collider;
if (col == null) return false;
return TryGetTightTileRect(col.bounds, out minTile, out maxTile);
}
/// <summary>
/// Draws a translucent fill over the volume's tile coverage at the specified world Y.
/// Uses per-tile flat cube gizmos (Option A from the gizmo design discussion).
/// </summary>
/// <param name="fillColor">Fully-opaque color; alpha will be applied via <paramref name="alpha"/>.</param>
/// <param name="alpha">Alpha 0..1 applied to the fill.</param>
/// <param name="yLevel">World Y at which to draw the fill. Stagger by volume type to avoid z-fighting.</param>
protected void DrawTileCoverageFill(Color fillColor, float alpha, float yLevel)
{
if (!TryGetTightTileRect(out Vector2Int minTile, out Vector2Int maxTile)) return;
// For a single BoxCollider, every tile in the tight rectangle is covered (the rect was
// built from actually-covered tiles, and the covered set is always rectangular for a
// BoxCollider's bounds). So we can iterate the rect directly without re-checking
// bounds.Contains per tile.
Color prev = Gizmos.color;
Gizmos.color = PlayerColors.WithAlpha(fillColor, alpha);
Vector3 tileSize = new Vector3(GridCoordinates.TILE_SIZE, 0.01f, GridCoordinates.TILE_SIZE);
for (int x = minTile.x; x <= maxTile.x; x++)
{
for (int y = minTile.y; y <= maxTile.y; y++)
{
Vector3 center = GridCoordinates.GridToWorld(new Vector2Int(x, y));
Vector3 drawCenter = new Vector3(center.x, yLevel, center.z);
Gizmos.DrawCube(drawCenter, tileSize);
}
}
Gizmos.color = prev;
}
/// <summary>
/// Draws an opaque rectangular perimeter outline around the volume's tile coverage at the
/// specified world Y. For single-volume rasterization the coverage is always rectangular
/// (a BoxCollider's tile rasterization can only produce a tile rectangle), so we draw the
/// four edges of the tight tile rect.
/// </summary>
/// <param name="outlineColor">Outline color (alpha applied as supplied).</param>
/// <param name="yLevel">World Y at which to draw the outline.</param>
protected void DrawRectangularOutline(Color outlineColor, float yLevel)
{
if (!TryGetTightTileRect(out Vector2Int minTile, out Vector2Int maxTile)) return;
// Convert tile-corner indices to world-space corners. A tile at (x, y) spans world XZ
// from (x - 0.5, y - 0.5) to (x + 0.5, y + 0.5) (TILE_SIZE = 1, center-based).
float halfTile = GridCoordinates.TILE_SIZE * 0.5f;
Vector3 swCorner = new Vector3(minTile.x - halfTile, yLevel, minTile.y - halfTile);
Vector3 seCorner = new Vector3(maxTile.x + halfTile, yLevel, minTile.y - halfTile);
Vector3 neCorner = new Vector3(maxTile.x + halfTile, yLevel, maxTile.y + halfTile);
Vector3 nwCorner = new Vector3(minTile.x - halfTile, yLevel, maxTile.y + halfTile);
Color prev = Gizmos.color;
Gizmos.color = outlineColor;
Gizmos.DrawLine(swCorner, seCorner);
Gizmos.DrawLine(seCorner, neCorner);
Gizmos.DrawLine(neCorner, nwCorner);
Gizmos.DrawLine(nwCorner, swCorner);
Gizmos.color = prev;
}
/// <summary>
/// Draws a line+cone arrow from <paramref name="origin"/> in the given world-space
/// direction. The arrow shaft is drawn with <see cref="Gizmos.DrawLine"/> and the
/// arrowhead is built from four lines forming a small cone.
/// </summary>
/// <param name="origin">Arrow start point (world space).</param>
/// <param name="direction">Unit-length direction the arrow points in.</param>
/// <param name="length">Total arrow length in world units.</param>
/// <param name="color">Arrow color (alpha applied as supplied).</param>
protected void DrawArrow(Vector3 origin, Vector3 direction, float length, Color color)
{
if (direction.sqrMagnitude < 0.0001f) return;
direction = direction.normalized;
Vector3 tip = origin + direction * length;
// Pick an "up" axis perpendicular to the arrow direction in the XZ plane. Since our
// arrows are always horizontal (Y is fixed), we want the arrowhead to flare in the
// horizontal plane. The right-vector relative to the direction does this:
// right = cross(direction, world up). If direction is parallel to world up
// (vertical arrow, which we don't expect), fall back to world forward.
Vector3 right = Vector3.Cross(direction, Vector3.up);
if (right.sqrMagnitude < 0.0001f)
{
right = Vector3.Cross(direction, Vector3.forward);
}
right.Normalize();
// Arrowhead size proportional to arrow length.
float headLength = length * 0.25f;
float headWidth = length * 0.15f;
Vector3 headBase = tip - direction * headLength;
Vector3 leftWing = headBase - right * headWidth;
Vector3 rightWing = headBase + right * headWidth;
Color prev = Gizmos.color;
Gizmos.color = color;
Gizmos.DrawLine(origin, tip);
Gizmos.DrawLine(tip, leftWing);
Gizmos.DrawLine(tip, rightWing);
Gizmos.DrawLine(leftWing, rightWing); // close the head for visibility
Gizmos.color = prev;
}
/// <summary>
/// Converts a <see cref="Direction"/> enum to a unit world-space vector in the XZ plane.
/// North = +Z, South = -Z, East = +X, West = -X (matches grid-y mapped to world-z).
/// </summary>
protected static Vector3 DirectionToWorld(Direction direction)
{
switch (direction)
{
case Direction.North: return new Vector3(0f, 0f, 1f);
case Direction.South: return new Vector3(0f, 0f, -1f);
case Direction.East: return new Vector3(1f, 0f, 0f);
case Direction.West: return new Vector3(-1f, 0f, 0f);
default: return Vector3.zero;
}
}
/// <summary>
/// Returns a human-readable label for a player slot, used by gizmo labels for consistency
/// across volume types. Currently formatted as "Player N" (or "Player ?" for None / unknown).
/// </summary>
public static string FormatPlayerName(PlayerSlot slot)
{
if (slot == PlayerSlot.None) return "Player ?";
return "Player " + ((byte)slot).ToString();
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c95a277ca66b4a34e8de9e5ee420f145