// Assets/_Project/Scripts/Editor/Levels/LevelAuthoringPlayModeHook.cs using UnityEditor; using UnityEngine; using TD.Levels; namespace TD.Levels.Editor { /// /// 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. /// [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( 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; } } /// /// Cancels the Play mode transition. Must be called during /// ExitingEditMode for the cancel to take effect cleanly. /// private static void AbortPlay() { EditorApplication.isPlaying = false; } } }