Major changes to editor tools, and adding new layer for buildable towers

This commit is contained in:
Matt F 2026-05-01 10:50:03 -07:00
parent a4e28bc93f
commit b44eeaeeff
21 changed files with 2867 additions and 89 deletions

View file

@ -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 &amp; Play / Cancel"
/// 5. Hashes match -> proceed silently
/// 6. Hashes differ -> 3-button "Bake &amp; 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;
}
}
}