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. Uses a half-open interval (min inclusive, // max exclusive) on XZ so that: // - a volume sized to N whole tiles rasterizes to exactly N tiles (no off-by-one), // - two volumes that share a boundary (e.g., one ending at X=20, next starting at // X=20) do not double-claim the boundary tile. // Gizmos and bake share this helper so they 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 half-open /// interval 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)); // Half-open interval on XZ: min inclusive, max exclusive. Y axis is implicitly // satisfied because we test at bounds.center.y, which is always within Y range. bool xIn = tileCenter.x >= bounds.min.x && tileCenter.x < bounds.max.x; bool zIn = tileCenter.z >= bounds.min.z && tileCenter.z < bounds.max.z; if (xIn && zIn) { 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, y) to (x+1, y+1) (TILE_SIZE = 1, edge-aligned). float tileSize = GridCoordinates.TILE_SIZE; Vector3 swCorner = new Vector3(minTile.x * tileSize, yLevel, minTile.y * tileSize); Vector3 seCorner = new Vector3((maxTile.x + 1) * tileSize, yLevel, minTile.y * tileSize); Vector3 neCorner = new Vector3((maxTile.x + 1) * tileSize, yLevel, (maxTile.y + 1) * tileSize); Vector3 nwCorner = new Vector3(minTile.x * tileSize, yLevel, (maxTile.y + 1) * tileSize); 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(); } } }