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
66
Assets/_Project/Levels/GoalVolume.cs
Normal file
66
Assets/_Project/Levels/GoalVolume.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Levels/GoalVolume.cs.meta
Normal file
2
Assets/_Project/Levels/GoalVolume.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 755948f0244afea4da6ed2e4e3d27579
|
||||
131
Assets/_Project/Levels/LeakExitVolume.cs
Normal file
131
Assets/_Project/Levels/LeakExitVolume.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Levels/LeakExitVolume.cs.meta
Normal file
2
Assets/_Project/Levels/LeakExitVolume.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: d4da5d6673a12f546ac47563abc49d5a
|
||||
258
Assets/_Project/Levels/LevelAuthoring.cs
Normal file
258
Assets/_Project/Levels/LevelAuthoring.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Levels/LevelAuthoring.cs.meta
Normal file
2
Assets/_Project/Levels/LevelAuthoring.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 37de128911d7bbc4299ea87cdb520ed7
|
||||
144
Assets/_Project/Levels/LevelData.cs
Normal file
144
Assets/_Project/Levels/LevelData.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Levels/LevelData.cs.meta
Normal file
2
Assets/_Project/Levels/LevelData.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 6d4e9c37b9205f3408a8225823f7a4da
|
||||
68
Assets/_Project/Levels/PlayerZoneVolume.cs
Normal file
68
Assets/_Project/Levels/PlayerZoneVolume.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Levels/PlayerZoneVolume.cs.meta
Normal file
2
Assets/_Project/Levels/PlayerZoneVolume.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5e8ab74b9038bd64782043bfc21f4cd7
|
||||
118
Assets/_Project/Levels/SpawnerVolume.cs
Normal file
118
Assets/_Project/Levels/SpawnerVolume.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Levels/SpawnerVolume.cs.meta
Normal file
2
Assets/_Project/Levels/SpawnerVolume.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: a756762f899008f45b2986bdcb11c819
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Levels/VolumeBase.cs.meta
Normal file
2
Assets/_Project/Levels/VolumeBase.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c95a277ca66b4a34e8de9e5ee420f145
|
||||
Loading…
Add table
Add a link
Reference in a new issue