319 lines
No EOL
14 KiB
C#
319 lines
No EOL
14 KiB
C#
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();
|
|
}
|
|
}
|
|
} |