using UnityEngine;
using TD.Core;
namespace TD.Levels
{
///
/// Abstract base class for all level authoring volumes (player zones, spawners, leak exits, goals).
///
///
/// Every concrete volume requires a . The collider's bounds (in world
/// space) define the volume's spatial extent, which the bake script rasterizes to tiles via
/// and bounds.Contains(tileCenter).
///
/// Volumes are authoring-time artifacts only — they are NOT part of the runtime gameplay scene.
/// At runtime the bake's output () is the sole source of truth.
///
/// Visibility model: gizmos draw via OnDrawGizmosSelected by default (selection-only).
/// The owning exposes per-type toggles to make a category always-on.
/// Volumes not parented under a LevelAuthoring (orphans) always behave as selection-only;
/// they will also produce warnings during bake.
///
[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;
///
/// Gets the volume's , looking it up lazily. Returns null if the
/// component has been removed (which violates [RequireComponent] but can happen if
/// a user manually removes it).
///
protected BoxCollider Collider
{
get
{
if (_cachedCollider == null)
{
_cachedCollider = GetComponent();
}
return _cachedCollider;
}
}
///
/// Walks the parent transform hierarchy to find the owning
/// 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.
///
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(includeInactive: true);
return _cachedAuthoring;
}
///
/// When implemented by a subclass, returns whether the corresponding "always show" toggle
/// is enabled on the supplied . Each volume type maps to a
/// different toggle field.
///
/// The owning LevelAuthoring (guaranteed non-null).
protected abstract bool GetAlwaysShowToggle(LevelAuthoring authoring);
///
/// 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).
///
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.
// -------------------------------------------------------------------
///
/// Iterates every tile whose center lies inside and invokes
/// for each one. The candidate range is computed via
/// 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
/// bounds.Contains(tileCenter) test inside the loop guarantees correctness.
///
public static void RasterizeBoundsToTiles(Bounds bounds, System.Action 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));
}
}
}
}
///
/// Computes the tight tile rectangle for a — the rectangle that
/// exactly contains every tile whose center is inside the bounds. Returns false if no
/// tile centers fall inside the bounds.
///
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;
}
///
/// Instance-side convenience: computes the tight tile rectangle for THIS volume's collider.
///
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);
}
///
/// 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).
///
/// Fully-opaque color; alpha will be applied via .
/// Alpha 0..1 applied to the fill.
/// World Y at which to draw the fill. Stagger by volume type to avoid z-fighting.
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;
}
///
/// 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.
///
/// Outline color (alpha applied as supplied).
/// World Y at which to draw the outline.
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;
}
///
/// Draws a line+cone arrow from in the given world-space
/// direction. The arrow shaft is drawn with and the
/// arrowhead is built from four lines forming a small cone.
///
/// Arrow start point (world space).
/// Unit-length direction the arrow points in.
/// Total arrow length in world units.
/// Arrow color (alpha applied as supplied).
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;
}
///
/// Converts a 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).
///
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;
}
}
///
/// 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).
///
public static string FormatPlayerName(PlayerSlot slot)
{
if (slot == PlayerSlot.None) return "Player ?";
return "Player " + ((byte)slot).ToString();
}
}
}