Major changes to editor tools, and adding new layer for buildable towers
This commit is contained in:
parent
a4e28bc93f
commit
b44eeaeeff
21 changed files with 2867 additions and 89 deletions
86
Assets/_Project/Scripts/Editor/Levels/BakeReport.cs
Normal file
86
Assets/_Project/Scripts/Editor/Levels/BakeReport.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace TD.Levels.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Accumulates errors and warnings produced during a bake run. Phases append to the report
|
||||
/// and can check <see cref="HasErrors"/> at phase boundaries to decide whether to abort.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The bake never bails on the first error within a phase — all errors in a phase are
|
||||
/// collected before the phase reports back. A phase boundary is the place where the pipeline
|
||||
/// checks <see cref="HasErrors"/> and decides whether to continue or abort.
|
||||
/// </remarks>
|
||||
public class BakeReport
|
||||
{
|
||||
private readonly List<string> _errors = new List<string>();
|
||||
private readonly List<string> _warnings = new List<string>();
|
||||
|
||||
/// <summary>Errors collected so far. Hard-error phases abort the bake when this is non-empty.</summary>
|
||||
public IReadOnlyList<string> Errors => _errors;
|
||||
|
||||
/// <summary>Warnings collected so far. Warnings never abort the bake.</summary>
|
||||
public IReadOnlyList<string> Warnings => _warnings;
|
||||
|
||||
/// <summary>True if any hard errors have been recorded.</summary>
|
||||
public bool HasErrors => _errors.Count > 0;
|
||||
|
||||
/// <summary>Total error count.</summary>
|
||||
public int ErrorCount => _errors.Count;
|
||||
|
||||
/// <summary>Total warning count.</summary>
|
||||
public int WarningCount => _warnings.Count;
|
||||
|
||||
/// <summary>Records a hard error. The check ID (e.g. "P2-1") is prefixed for traceability.</summary>
|
||||
public void Error(string checkId, string message)
|
||||
{
|
||||
_errors.Add($"[{checkId}] {message}");
|
||||
}
|
||||
|
||||
/// <summary>Records a soft warning. The check ID (e.g. "P2-8") is prefixed for traceability.</summary>
|
||||
public void Warning(string checkId, string message)
|
||||
{
|
||||
_warnings.Add($"[{checkId}] {message}");
|
||||
}
|
||||
|
||||
/// <summary>Records an error not associated with a numbered check (e.g. "phase 1 found null authoring").</summary>
|
||||
public void Error(string message)
|
||||
{
|
||||
_errors.Add(message);
|
||||
}
|
||||
|
||||
/// <summary>Records a warning not associated with a numbered check (e.g. "thumbnail render failed").</summary>
|
||||
public void Warning(string message)
|
||||
{
|
||||
_warnings.Add(message);
|
||||
}
|
||||
|
||||
/// <summary>Renders the full report as a human-readable string for console logging.</summary>
|
||||
public string Format()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
if (_errors.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"Errors ({_errors.Count}):");
|
||||
for (int i = 0; i < _errors.Count; i++)
|
||||
{
|
||||
sb.AppendLine($" • {_errors[i]}");
|
||||
}
|
||||
}
|
||||
if (_warnings.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"Warnings ({_warnings.Count}):");
|
||||
for (int i = 0; i < _warnings.Count; i++)
|
||||
{
|
||||
sb.AppendLine($" • {_warnings[i]}");
|
||||
}
|
||||
}
|
||||
if (_errors.Count == 0 && _warnings.Count == 0)
|
||||
{
|
||||
sb.AppendLine("(no errors or warnings)");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Editor/Levels/BakeReport.cs.meta
Normal file
2
Assets/_Project/Scripts/Editor/Levels/BakeReport.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c8feac7ae8882984ab8fc3bf96e73709
|
||||
181
Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditor.cs
Normal file
181
Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditor.cs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// Assets/_Project/Scripts/Editor/Levels/LevelAuthoringEditor.cs
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
|
||||
namespace TD.Levels.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom inspector for <see cref="LevelAuthoring"/>. Provides:
|
||||
/// - Standard property fields (via DrawDefaultInspector)
|
||||
/// - "Bake LevelData" button
|
||||
/// - "Sync Status" indicator: shows whether the scene matches its baked data
|
||||
/// - "Last Bake Status" panel (outcome, timestamp, warnings, hash, grid)
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Refresh Thumbnail button is intentionally not present in this version.
|
||||
/// Refreshing a thumbnail without re-baking would require duplicating the
|
||||
/// bake's volume-discovery and rasterization phases just to compute the map
|
||||
/// bounds the thumbnail camera frames; the maintenance hazard outweighs the
|
||||
/// convenience for now. Revisit when authoring real maps reveals it as a
|
||||
/// real friction point.
|
||||
/// </remarks>
|
||||
[CustomEditor(typeof(LevelAuthoring))]
|
||||
public class LevelAuthoringEditor : UnityEditor.Editor
|
||||
{
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
// Standard fields — preserve all the [Tooltip], [Header], etc. authored on the type.
|
||||
DrawDefaultInspector();
|
||||
|
||||
EditorGUILayout.Space(8);
|
||||
DrawBakeSection();
|
||||
|
||||
EditorGUILayout.Space(8);
|
||||
DrawSyncStatusSection();
|
||||
|
||||
EditorGUILayout.Space(8);
|
||||
DrawLastBakeStatusSection();
|
||||
}
|
||||
|
||||
// ---------- Bake button ----------
|
||||
|
||||
private void DrawBakeSection()
|
||||
{
|
||||
var auth = (LevelAuthoring)target;
|
||||
|
||||
EditorGUILayout.LabelField("Bake", EditorStyles.boldLabel);
|
||||
|
||||
// Disable the button when targetAsset is unset — the bake's first hard error would be
|
||||
// P2-13 anyway, so save the user the click.
|
||||
using (new EditorGUI.DisabledScope(auth.targetAsset == null))
|
||||
{
|
||||
if (GUILayout.Button("Bake LevelData", GUILayout.Height(28)))
|
||||
{
|
||||
// The pipeline logs its own success/failure messages and report.
|
||||
LevelBakePipeline.Bake(auth);
|
||||
// Force a repaint so Sync Status and Last Bake Status update immediately.
|
||||
Repaint();
|
||||
}
|
||||
}
|
||||
|
||||
if (auth.targetAsset == null)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"Assign a LevelData ScriptableObject to Target Asset before baking.",
|
||||
MessageType.Info);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Sync status indicator (new this session) ----------
|
||||
|
||||
private void DrawSyncStatusSection()
|
||||
{
|
||||
var auth = (LevelAuthoring)target;
|
||||
|
||||
EditorGUILayout.LabelField("Sync Status", EditorStyles.boldLabel);
|
||||
|
||||
// Without a target asset there's nothing to compare against.
|
||||
if (auth.targetAsset == null)
|
||||
{
|
||||
EditorGUILayout.HelpBox("No target asset assigned.", MessageType.None);
|
||||
return;
|
||||
}
|
||||
|
||||
// No baked hash means the asset has never been baked; the comparison
|
||||
// would be meaningless.
|
||||
string bakedHash = auth.targetAsset.AuthoringHash;
|
||||
if (string.IsNullOrEmpty(bakedHash))
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"Never baked. Click Bake LevelData to generate baked data.",
|
||||
MessageType.None);
|
||||
return;
|
||||
}
|
||||
|
||||
// Recompute the scene's authoring hash on every OnInspectorGUI call.
|
||||
// Unity throttles inspector repaints on idle frames, and the hash
|
||||
// is sub-millisecond on the largest planned map (~30 volumes for
|
||||
// the 9-player map). If profiling later shows this is a problem,
|
||||
// we can cache the hash and invalidate on Undo.undoRedoPerformed +
|
||||
// EditorSceneManager.sceneDirtied — but the simple version is
|
||||
// probably fine for the project's scale.
|
||||
string currentHash = LevelBakePipeline.ComputeAuthoringHash(auth);
|
||||
|
||||
if (currentHash == bakedHash)
|
||||
{
|
||||
EditorGUILayout.HelpBox("✓ Scene matches baked data.", MessageType.Info);
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"⚠ Scene differs from baked data — bake recommended.",
|
||||
MessageType.Warning);
|
||||
|
||||
// Showing the first few chars of each hash makes it obvious
|
||||
// when "out of sync" is real vs. a glitch. Useful for debugging
|
||||
// the hash function itself if it ever misbehaves.
|
||||
EditorGUILayout.LabelField(" Scene hash:", ShortHash(currentHash));
|
||||
EditorGUILayout.LabelField(" Baked hash:", ShortHash(bakedHash));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Last bake status (existing, mildly tidied) ----------
|
||||
|
||||
private void DrawLastBakeStatusSection()
|
||||
{
|
||||
var auth = (LevelAuthoring)target;
|
||||
|
||||
EditorGUILayout.LabelField("Last Bake Status", EditorStyles.boldLabel);
|
||||
|
||||
// Transient failure flag overrides the asset's recorded outcome — failed bakes don't
|
||||
// modify the asset, so the asset still shows the prior successful bake. Surface the
|
||||
// failure separately so the user knows the current scene's last attempt failed.
|
||||
if (LevelAuthoringEditorState.HasUncommittedFailure)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"Last bake attempt FAILED. The target asset still reflects the previous successful bake. " +
|
||||
"See the console for the error report.",
|
||||
MessageType.Error);
|
||||
}
|
||||
|
||||
if (auth.targetAsset == null)
|
||||
{
|
||||
EditorGUILayout.LabelField("(no target asset)");
|
||||
return;
|
||||
}
|
||||
|
||||
var data = auth.targetAsset;
|
||||
if (string.IsNullOrEmpty(data.LastBakeTimestamp))
|
||||
{
|
||||
EditorGUILayout.LabelField("Never baked.");
|
||||
return;
|
||||
}
|
||||
|
||||
EditorGUILayout.LabelField("Outcome:", data.LastBakeOutcome.ToString());
|
||||
EditorGUILayout.LabelField("Timestamp (UTC):", data.LastBakeTimestamp);
|
||||
EditorGUILayout.LabelField("Warnings:", data.LastBakeWarningCount.ToString());
|
||||
|
||||
if (data.GridSize.x > 0 && data.GridSize.y > 0)
|
||||
{
|
||||
EditorGUILayout.LabelField("Grid size:", $"{data.GridSize.x} × {data.GridSize.y}");
|
||||
EditorGUILayout.LabelField("Grid origin:", data.GridOriginTile.ToString());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(data.AuthoringHash))
|
||||
{
|
||||
EditorGUILayout.LabelField("Hash:", ShortHash(data.AuthoringHash));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
// First 8 chars of the SHA256 hex with an ellipsis. Plenty for human
|
||||
// comparison; the full hash is far too long for the inspector.
|
||||
private static string ShortHash(string hash)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hash)) return "";
|
||||
return (hash.Length > 8 ? hash.Substring(0, 8) : hash) + "…";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 46bb419f816638646b8ca42c55f72e6e
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
namespace TD.Levels.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Editor-only transient state for the level bake workflow. Lives only in editor memory —
|
||||
/// not serialized to any asset, not shipped in builds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The bake's failure state is intentionally not persisted to <see cref="LevelData"/> —
|
||||
/// failed bakes don't modify the asset on disk, so the previous successful bake (if any)
|
||||
/// remains intact. This static flag exists so the custom inspector can show a "FAILED"
|
||||
/// status next to the last bake info, and so the play-mode dirty-detection hook (next
|
||||
/// session) knows whether the in-memory state has uncommitted failures.
|
||||
///
|
||||
/// Resets to false on success. Resets to false when Unity reloads (this is editor-only
|
||||
/// memory, not a serialized field), which is acceptable — the user will simply see the
|
||||
/// last successful bake's status until they bake again.
|
||||
/// </remarks>
|
||||
public static class LevelAuthoringEditorState
|
||||
{
|
||||
/// <summary>
|
||||
/// True if the most recent bake attempt failed (hard error in any phase). Cleared on
|
||||
/// successful bake or on Unity reload.
|
||||
/// </summary>
|
||||
public static bool HasUncommittedFailure;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: aeee2805cc297ca41bab91e40dcd7dee
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
// Assets/_Project/Scripts/Editor/Levels/LevelAuthoringPlayModeHook.cs
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using TD.Levels;
|
||||
|
||||
namespace TD.Levels.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Static editor-only hook that intercepts Play mode entry and validates the
|
||||
/// scene's LevelAuthoring against its baked LevelData. Catches the common
|
||||
/// failure mode of "edited the scene, forgot to bake, played stale data".
|
||||
///
|
||||
/// Decision tree (executes in PlayModeStateChange.ExitingEditMode, before
|
||||
/// Play actually begins, so we can abort cleanly):
|
||||
/// 1. No LevelAuthoring in scene -> proceed silently
|
||||
/// 2. Multiple LevelAuthoring -> error dialog, abort
|
||||
/// 3. targetAsset null -> 2-button "Continue Anyway / Cancel"
|
||||
/// 4. AuthoringHash empty -> 2-button "Bake & Play / Cancel"
|
||||
/// 5. Hashes match -> proceed silently
|
||||
/// 6. Hashes differ -> 3-button "Bake & Play / Play Anyway / Cancel"
|
||||
///
|
||||
/// Aborts via EditorApplication.isPlaying = false during ExitingEditMode.
|
||||
/// During this window EditorApplication.isPlayingOrWillChangePlaymode is
|
||||
/// true but isPlaying is false; setting isPlaying = false at this point
|
||||
/// cancels the Play attempt cleanly.
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
internal static class LevelAuthoringPlayModeHook
|
||||
{
|
||||
// Static constructor runs on every editor reload (script recompile or
|
||||
// domain reload), wiring the event handler. It's idempotent because
|
||||
// we always remove first (no-op if not subscribed) then add.
|
||||
static LevelAuthoringPlayModeHook()
|
||||
{
|
||||
EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
|
||||
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
|
||||
}
|
||||
|
||||
private static void OnPlayModeStateChanged(PlayModeStateChange state)
|
||||
{
|
||||
// We only intercept the moment we're about to enter Play mode.
|
||||
// Other phases (EnteredPlayMode, ExitingPlayMode, EnteredEditMode)
|
||||
// are not our concern.
|
||||
if (state != PlayModeStateChange.ExitingEditMode)
|
||||
return;
|
||||
|
||||
// Find LevelAuthoring instances in the scene. We only consider
|
||||
// active ones; an inactive LevelAuthoring is treated as
|
||||
// "not present" because its volumes won't be discovered by the
|
||||
// bake's scoped scan either.
|
||||
//
|
||||
// FindObjectsByType is the Unity 6+ replacement for the deprecated
|
||||
// FindObjectsOfType. We use the no-sort-mode overload: in Unity 6.4+
|
||||
// the FindObjectsSortMode parameter has been deprecated (Unity is
|
||||
// moving from InstanceID toward EntityId, and stable sort order
|
||||
// can't be guaranteed in the new world). Passing it now produces
|
||||
// a CS0618 warning. We don't need stable order here -- a single
|
||||
// expected result, or zero, or "more than one" handled as an error.
|
||||
var authorings = Object.FindObjectsByType<LevelAuthoring>(
|
||||
FindObjectsInactive.Exclude);
|
||||
|
||||
// Case 1: No LevelAuthoring in scene -> proceed silently.
|
||||
// This is the normal case for menu scenes, bootstrap scenes, etc.
|
||||
if (authorings.Length == 0)
|
||||
return;
|
||||
|
||||
// Case 2: Multiple LevelAuthoring -> error and abort.
|
||||
// The bake assumes one per scene (it walks _LevelAuthoring's
|
||||
// subtree), so playing with two would be ambiguous.
|
||||
if (authorings.Length > 1)
|
||||
{
|
||||
EditorUtility.DisplayDialog(
|
||||
"Multiple LevelAuthoring components",
|
||||
$"Found {authorings.Length} LevelAuthoring components in this scene. " +
|
||||
"Only one is allowed per scene. Remove the extras before entering Play mode.",
|
||||
"OK");
|
||||
AbortPlay();
|
||||
return;
|
||||
}
|
||||
|
||||
var authoring = authorings[0];
|
||||
|
||||
// Case 3: targetAsset is null. We can't dirty-check without one,
|
||||
// because there's no LevelData to compare the current scene's
|
||||
// hash against. Ask the user if they want to play anyway.
|
||||
if (authoring.targetAsset == null)
|
||||
{
|
||||
bool proceed = EditorUtility.DisplayDialog(
|
||||
"No LevelData target assigned",
|
||||
$"The LevelAuthoring on '{authoring.gameObject.name}' has no targetAsset assigned, " +
|
||||
"so its scene state cannot be checked against baked data.\n\n" +
|
||||
"Continue into Play mode anyway?",
|
||||
"Continue Anyway",
|
||||
"Cancel");
|
||||
|
||||
if (!proceed)
|
||||
AbortPlay();
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 4: Asset has never been baked (hash empty/null).
|
||||
// Offer Bake & Play, since there's no useful "play with stale data"
|
||||
// option when there's no baked data at all.
|
||||
string bakedHash = authoring.targetAsset.AuthoringHash;
|
||||
if (string.IsNullOrEmpty(bakedHash))
|
||||
{
|
||||
bool bakeAndPlay = EditorUtility.DisplayDialog(
|
||||
"Level has never been baked",
|
||||
$"'{authoring.targetAsset.name}' has no baked data yet. " +
|
||||
"Bake now and then enter Play mode?",
|
||||
"Bake && Play",
|
||||
"Cancel");
|
||||
|
||||
if (!bakeAndPlay)
|
||||
{
|
||||
AbortPlay();
|
||||
return;
|
||||
}
|
||||
|
||||
// Lean A: if Bake & Play is requested and the bake fails,
|
||||
// abort Play. The bake errors will already be in the console.
|
||||
bool bakeOk = LevelBakePipeline.Bake(authoring);
|
||||
if (!bakeOk)
|
||||
{
|
||||
Debug.LogError("[PlayModeHook] Bake failed; aborting Play. See above for errors.");
|
||||
AbortPlay();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute the hash of the current scene state and compare to baked.
|
||||
// ComputeAuthoringHash(LevelAuthoring) is a pure function over the
|
||||
// same canonical input string the bake uses (it delegates to the
|
||||
// same private hash routine), so equal strings means equivalent
|
||||
// bakes.
|
||||
string currentHash = LevelBakePipeline.ComputeAuthoringHash(authoring);
|
||||
|
||||
// Case 5: Hashes match -> baked data is in sync with the scene.
|
||||
// Proceed silently; this is the happy path.
|
||||
if (currentHash == bakedHash)
|
||||
return;
|
||||
|
||||
// Case 6: Hashes differ -> 3-button dialog with the explicit
|
||||
// "Play Anyway" escape hatch. DisplayDialogComplex returns:
|
||||
// 0 = primary button (Bake & Play)
|
||||
// 1 = "alt" button (Cancel)
|
||||
// 2 = secondary (Play Anyway)
|
||||
// The button-to-return-code mapping is fixed by Unity's API, so
|
||||
// we have to map our intent onto it carefully.
|
||||
int choice = EditorUtility.DisplayDialogComplex(
|
||||
"Scene differs from baked data",
|
||||
$"The scene has been edited since '{authoring.targetAsset.name}' was last baked. " +
|
||||
"Playing now will use stale baked data.\n\n" +
|
||||
"Bake the level before playing, play with stale data anyway, or cancel?",
|
||||
"Bake && Play", // ok / button 0
|
||||
"Cancel", // alt / button 1
|
||||
"Play Anyway"); // secondary / button 2
|
||||
|
||||
switch (choice)
|
||||
{
|
||||
case 0: // Bake & Play
|
||||
bool ok = LevelBakePipeline.Bake(authoring);
|
||||
if (!ok)
|
||||
{
|
||||
Debug.LogError("[PlayModeHook] Bake failed; aborting Play. See above for errors.");
|
||||
AbortPlay();
|
||||
}
|
||||
return;
|
||||
|
||||
case 2: // Play Anyway
|
||||
// Lean C: log a console warning so debugging weirdness
|
||||
// later has a breadcrumb. Don't persist anything; the
|
||||
// user explicitly chose this.
|
||||
Debug.LogWarning(
|
||||
$"[PlayModeHook] Entering Play mode with stale baked data for " +
|
||||
$"'{authoring.targetAsset.name}'. Last bake: {authoring.targetAsset.LastBakeTimestamp}.");
|
||||
return;
|
||||
|
||||
case 1: // Cancel
|
||||
default:
|
||||
AbortPlay();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels the Play mode transition. Must be called during
|
||||
/// ExitingEditMode for the cancel to take effect cleanly.
|
||||
/// </summary>
|
||||
private static void AbortPlay()
|
||||
{
|
||||
EditorApplication.isPlaying = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 65d1bbee09d1bd646a6bc07583836b6e
|
||||
1300
Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs
Normal file
1300
Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5933991b7c238d048bb1c9cd2a23dbd9
|
||||
214
Assets/_Project/Scripts/Editor/Levels/VolumeEditTool.cs
Normal file
214
Assets/_Project/Scripts/Editor/Levels/VolumeEditTool.cs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
using UnityEditor;
|
||||
using UnityEditor.EditorTools;
|
||||
using UnityEditor.ShortcutManagement;
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
|
||||
namespace TD.Levels.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom scene-view tool for resizing <see cref="VolumeBase"/> volumes by dragging individual
|
||||
/// edges, instead of Unity's default symmetric BoxCollider sizing. Each drag is snapped to
|
||||
/// whole tiles (1.0 world units), so volumes always stay tile-aligned.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Activate from the scene-view toolbar when a VolumeBase is selected. Four handles appear at
|
||||
/// the midpoints of the volume's four horizontal edges (N/S/E/W). Dragging a handle moves only
|
||||
/// that edge — the opposite edge stays put.
|
||||
///
|
||||
/// The tool refuses to operate on rotated volumes (P2-19 hard-error in bake). Rotation must
|
||||
/// be zeroed before the tool will edit. This surfaces the bake constraint at authoring time.
|
||||
///
|
||||
/// Edits modify <see cref="BoxCollider.size"/> and <see cref="BoxCollider.center"/> together;
|
||||
/// the GameObject's Transform is left alone. This keeps the asymmetry contained to the
|
||||
/// collider component, where it belongs.
|
||||
/// </remarks>
|
||||
[EditorTool("Resize Volume Edge", typeof(VolumeBase))]
|
||||
public class VolumeEditTool : EditorTool
|
||||
{
|
||||
// Visual constants.
|
||||
private const float HandleY = 0.10f; // world Y at which to draw handles
|
||||
private const float HandleSize = 0.30f; // world-space radius of the handle disc
|
||||
private const float MinSize = 1.0f; // smallest allowed size on either tile axis (1 tile)
|
||||
private const float SnapIncrement = 1.0f; // tile size — the snap unit
|
||||
|
||||
// Handle colors (X-axis red, Z-axis blue, matching Unity's standard convention).
|
||||
private static readonly Color XAxisColor = new Color(0.95f, 0.35f, 0.35f, 1f);
|
||||
private static readonly Color ZAxisColor = new Color(0.35f, 0.55f, 0.95f, 1f);
|
||||
|
||||
public override void OnToolGUI(EditorWindow window)
|
||||
{
|
||||
// Multi-selection: don't draw anything when more than one volume is selected. Bulk
|
||||
// edge-edits are ambiguous (which volumes' edges align?) and out of scope for now.
|
||||
if (targets == null) return;
|
||||
int count = 0;
|
||||
foreach (var _ in targets) { count++; if (count > 1) break; }
|
||||
if (count != 1) return;
|
||||
|
||||
var volume = target as VolumeBase;
|
||||
if (volume == null) return;
|
||||
|
||||
var col = volume.GetComponent<BoxCollider>();
|
||||
if (col == null) return;
|
||||
|
||||
// Refuse to operate on rotated volumes. Show an in-scene warning instead.
|
||||
Vector3 e = volume.transform.eulerAngles;
|
||||
if (!IsZeroRotation(e))
|
||||
{
|
||||
DrawRotationWarning(volume, e);
|
||||
return;
|
||||
}
|
||||
|
||||
DrawEdgeHandles(volume, col);
|
||||
}
|
||||
|
||||
private static bool IsZeroRotation(Vector3 eulerAngles)
|
||||
{
|
||||
const float tol = 0.01f;
|
||||
// Normalize each axis to (-180..180] before comparing.
|
||||
float dx = NormalizeAngle(eulerAngles.x);
|
||||
float dy = NormalizeAngle(eulerAngles.y);
|
||||
float dz = NormalizeAngle(eulerAngles.z);
|
||||
return Mathf.Abs(dx) <= tol && Mathf.Abs(dy) <= tol && Mathf.Abs(dz) <= tol;
|
||||
}
|
||||
|
||||
private static float NormalizeAngle(float a)
|
||||
{
|
||||
a = a % 360f;
|
||||
if (a > 180f) a -= 360f;
|
||||
else if (a < -180f) a += 360f;
|
||||
return a;
|
||||
}
|
||||
|
||||
private void DrawRotationWarning(VolumeBase volume, Vector3 euler)
|
||||
{
|
||||
var col = volume.GetComponent<BoxCollider>();
|
||||
Vector3 labelPos = col != null
|
||||
? new Vector3(col.bounds.center.x, HandleY + 0.5f, col.bounds.center.z)
|
||||
: volume.transform.position + Vector3.up * 0.5f;
|
||||
|
||||
// Use the editor's Handles label with a colored background-ish hint via a sphere.
|
||||
Handles.color = Color.red;
|
||||
Handles.SphereHandleCap(0, labelPos, Quaternion.identity, 0.2f, EventType.Repaint);
|
||||
Handles.Label(labelPos + new Vector3(0.3f, 0f, 0.3f),
|
||||
$"VolumeEditTool: zero out rotation to edit edges (current: {euler}).");
|
||||
}
|
||||
|
||||
private void DrawEdgeHandles(VolumeBase volume, BoxCollider col)
|
||||
{
|
||||
// Compute the four edge-midpoint world positions. We treat the volume's local axes as
|
||||
// world axes (zero rotation already enforced above). The transform's position can still
|
||||
// translate the volume, so we use TransformPoint to get world positions.
|
||||
//
|
||||
// BoxCollider.center and BoxCollider.size are LOCAL to the GameObject's transform.
|
||||
Vector3 localCenter = col.center;
|
||||
Vector3 localSize = col.size;
|
||||
float halfX = localSize.x * 0.5f;
|
||||
float halfZ = localSize.z * 0.5f;
|
||||
|
||||
Vector3 eastLocal = localCenter + new Vector3(halfX, 0f, 0f);
|
||||
Vector3 westLocal = localCenter + new Vector3(-halfX, 0f, 0f);
|
||||
Vector3 northLocal = localCenter + new Vector3(0f, 0f, halfZ);
|
||||
Vector3 southLocal = localCenter + new Vector3(0f, 0f, -halfZ);
|
||||
|
||||
Transform t = volume.transform;
|
||||
Vector3 eastWorld = WithY(t.TransformPoint(eastLocal), HandleY);
|
||||
Vector3 westWorld = WithY(t.TransformPoint(westLocal), HandleY);
|
||||
Vector3 northWorld = WithY(t.TransformPoint(northLocal), HandleY);
|
||||
Vector3 southWorld = WithY(t.TransformPoint(southLocal), HandleY);
|
||||
|
||||
// Render and process each handle. Handles.Slider returns the new world position; we
|
||||
// compute the snapped tile-delta and apply it to the collider's size/center.
|
||||
DrawAxisHandle(col, eastWorld, Vector3.right, axisIsX: true, edgeIsPositive: true, XAxisColor);
|
||||
DrawAxisHandle(col, westWorld, Vector3.right, axisIsX: true, edgeIsPositive: false, XAxisColor);
|
||||
DrawAxisHandle(col, northWorld, Vector3.forward, axisIsX: false, edgeIsPositive: true, ZAxisColor);
|
||||
DrawAxisHandle(col, southWorld, Vector3.forward, axisIsX: false, edgeIsPositive: false, ZAxisColor);
|
||||
}
|
||||
|
||||
// Draw a single edge handle and apply the resulting size/center change to the collider.
|
||||
// - `direction`: the world axis the handle slides along (Vector3.right for E/W, Vector3.forward for N/S).
|
||||
// - `axisIsX`: true if this handle modifies size.x/center.x; false if size.z/center.z.
|
||||
// - `edgeIsPositive`: true if this is the +axis edge (East or North); false for -axis (West or South).
|
||||
private void DrawAxisHandle(BoxCollider col, Vector3 worldPos, Vector3 direction,
|
||||
bool axisIsX, bool edgeIsPositive, Color color)
|
||||
{
|
||||
Handles.color = color;
|
||||
|
||||
EditorGUI.BeginChangeCheck();
|
||||
// FreeMoveHandle would allow drag in any direction; we constrain to the axis using Slider.
|
||||
Vector3 newWorldPos = Handles.Slider(worldPos, direction, HandleSize, Handles.SphereHandleCap, 0f);
|
||||
if (!EditorGUI.EndChangeCheck()) return;
|
||||
|
||||
// How far did the handle move along its axis, in world units?
|
||||
float worldDelta = axisIsX
|
||||
? (newWorldPos.x - worldPos.x)
|
||||
: (newWorldPos.z - worldPos.z);
|
||||
|
||||
// Snap to whole tiles. Discard sub-tile motion until the user drags a full tile.
|
||||
int tileDelta = Mathf.RoundToInt(worldDelta / SnapIncrement);
|
||||
if (tileDelta == 0) return;
|
||||
|
||||
float worldDeltaSnapped = tileDelta * SnapIncrement;
|
||||
|
||||
// For the negative edge (W or S), dragging "outward" means dragging toward more
|
||||
// negative axis values; the size should still grow. Normalize so a positive `outwardDelta`
|
||||
// always means "grow."
|
||||
float outwardDelta = edgeIsPositive ? worldDeltaSnapped : -worldDeltaSnapped;
|
||||
|
||||
Vector3 size = col.size;
|
||||
Vector3 center = col.center;
|
||||
|
||||
float currentSize = axisIsX ? size.x : size.z;
|
||||
float newSize = currentSize + outwardDelta;
|
||||
|
||||
// Clamp at minimum tile size. If the user tries to shrink past min, discard the input
|
||||
// (don't move the edge at all). This is more predictable than partially honoring it.
|
||||
if (newSize < MinSize) return;
|
||||
|
||||
// Apply the change. The edge that's NOT being dragged should stay put. To keep the
|
||||
// opposite edge fixed, the center must shift by half the size change in the direction
|
||||
// of the edge being dragged.
|
||||
//
|
||||
// Example (east edge dragged outward by 2 tiles): size.x += 2; center.x += 1.
|
||||
// Example (west edge dragged outward by 1 tile): size.x += 1; center.x -= 0.5.
|
||||
float centerShift = (edgeIsPositive ? 1f : -1f) * (outwardDelta * 0.5f);
|
||||
|
||||
if (axisIsX)
|
||||
{
|
||||
size.x = newSize;
|
||||
center.x += centerShift;
|
||||
}
|
||||
else
|
||||
{
|
||||
size.z = newSize;
|
||||
center.z += centerShift;
|
||||
}
|
||||
|
||||
Undo.RecordObject(col, "Resize Volume Edge");
|
||||
col.size = size;
|
||||
col.center = center;
|
||||
EditorUtility.SetDirty(col);
|
||||
}
|
||||
|
||||
private static Vector3 WithY(Vector3 v, float y)
|
||||
{
|
||||
v.y = y;
|
||||
return v;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hotkey activation. The [Shortcut] attribute registers this with Unity's Shortcut Manager
|
||||
// (Edit → Shortcuts), where it can be rebound. The default binding is B, mnemonic for "Box".
|
||||
//
|
||||
// The shortcut activates the tool whether or not a VolumeBase is currently selected — when
|
||||
// nothing relevant is selected, the tool is "active" but draws nothing. The handles appear
|
||||
// as soon as a VolumeBase is selected.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
[Shortcut("Tools/Resize Volume Edge", KeyCode.B)]
|
||||
private static void ActivateShortcut()
|
||||
{
|
||||
ToolManager.SetActiveTool<VolumeEditTool>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: f4527cb4870dae24280db768d6af69c8
|
||||
Loading…
Add table
Add a link
Reference in a new issue