Adding big batch of structural code provided by Claude to start designing levels
This commit is contained in:
parent
0ed4df8bc9
commit
a4e28bc93f
26 changed files with 1951 additions and 0 deletions
319
Assets/_Project/Levels/VolumeBase.cs
Normal file
319
Assets/_Project/Levels/VolumeBase.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue