195 lines
No EOL
8.8 KiB
C#
195 lines
No EOL
8.8 KiB
C#
// 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;
|
|
}
|
|
}
|
|
} |