Files
nightclub-arcadia/NightclubArcadia/Assets/Scripts/Dialogue/DialogueInteractable.cs
T
lennartandClaude Opus 5 ec4619d9dc resolve cross-scene dependencies at the point of use, not in Awake
Fixes "[SceneServices] Bartender: could not resolve dialogueRunner".

Resolving a cross-scene dependency in Awake is a race by construction. The
dialogue runner lives in Systems; an NPC lives in the level. Open the level on
its own, or let the scenes load in the other order, and the NPC wakes with no
Systems scene in sight. Worse, the failed lookup was cached into the serialized
field, so it stayed broken for the rest of the session rather than recovering
once Systems appeared.

NPCStandIn and DialogueInteractable now resolve lazily through a property and
cache only a successful result, so any load order works. Awake still does a
silent best-effort pass, which keeps the common case free.

SceneServices splits in two accordingly. TryResolve is silent and is what
speculative callers use; Resolve logs an error and belongs only where the
dependency is genuinely required. Both now include inactive objects — a system
parked inactive is still the object we mean, and excluding it reported "not
found" for something sitting in the hierarchy. The error text now says where the
runner lives and how to open the scenes, instead of asking a question.

BootstrapTests only ever covered the happy order, which is why it stayed green
while this was broken. SceneOrderTests loads the level BEFORE Systems and holds
that NPCs still reach the runner. It also asserts it is reproducing the race —
it counts the NPCs whose Awake-time resolution failed and fails if that is zero,
so the test cannot quietly start passing for the wrong reason. It currently
reports 2 of 2, which is the bug this commit fixes.

EditMode 42/42, PlayMode 7/7, YarnCheck 9 files / 35 nodes.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 21:42:54 +02:00

94 lines
3.2 KiB
C#

using UnityEngine;
using UnityEngine.InputSystem;
using Yarn.Unity;
using NightclubArcadia.Core;
namespace NightclubArcadia.Dialogue
{
/// <summary>
/// Trigger-zone interactable: player walks in range, presses E, and this
/// starts the given Yarn node. The reverse of DialogueTransitionController's
/// <<enter_environment>> — this is the "step into dialogue" half of the loop.
/// </summary>
public class DialogueInteractable : MonoBehaviour
{
[SerializeField] private DialogueRunner dialogueRunner;
[SerializeField] private GameObject dialogueUI;
[SerializeField] private string yarnNodeName = "Bartender_Chat";
private bool playerInRange;
private InputAction interactAction;
void Awake()
{
// Best-effort and silent; the real lookup happens at the point of use, since
// Systems may not have loaded when this level object awakes. See Runner.
dialogueRunner = SceneServices.TryResolve(dialogueRunner);
interactAction = new InputAction("Interact");
interactAction.AddBinding("<Keyboard>/e");
interactAction.AddBinding("<Gamepad>/buttonNorth");
interactAction.performed += _ =>
{
if (playerInRange)
{
StartConversation();
}
};
}
/// <summary>
/// The dialogue runner, resolved on demand rather than in Awake, so that load
/// order between this level scene and Systems does not matter. Cached once found.
/// </summary>
DialogueRunner Runner =>
dialogueRunner != null
? dialogueRunner
: dialogueRunner = SceneServices.TryResolve<DialogueRunner>(null);
void OnEnable() => interactAction?.Enable();
void OnDisable() => interactAction?.Disable();
void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Player"))
{
playerInRange = true;
}
}
void OnTriggerExit(Collider other)
{
if (other.CompareTag("Player"))
{
playerInRange = false;
}
}
async void StartConversation()
{
var runner = Runner;
if (runner == null)
{
Debug.LogError(
$"[DialogueInteractable] {name}: no DialogueRunner in any loaded scene. " +
"It lives in Systems.unity — open Bootstrap, or Tools → Nightclub Arcadia → " +
"Open Game Scenes.", this);
return;
}
// Player controls are locked by DialogueTransitionController off the
// runner's onDialogueStart event — nothing to do for that here.
if (DialogueUIVisibility.Show(dialogueUI))
{
// Only reached if the UI was found deactivated; give TMP a
// frame to re-cache its Canvas in OnEnable before any text is
// generated.
await Awaitable.NextFrameAsync();
}
await runner.StartDialogue(yarnNodeName);
}
}
}