Files
nightclub-arcadia/NightclubArcadia/Assets/Scripts/Interaction/DialogueInteractor.cs
T
lennartandClaude Opus 5 9a0618fd31 replace the NPC scaffold with data-driven prefabs, and retire the builders
NPCStandIn was written to be dropped on a sphere and carried its identity in
per-instance public fields. The cost of that showed up in the scene itself: of
the two spheres, one was the Bartender and the other was named "Bartender (1)"
in the hierarchy while being configured as the Chair — data changed, name never
was. Nothing outside the scene could see what existed, and two copies could
disagree.

Identity now lives in an InteractableDefinition asset and the scene only places
a prefab. The migration matched the old objects by yarn node rather than by
name, which is the only reason the chair ended up as the chair.

InteractableDefinition rather than the planned "character definition": it covers
a character and a talkable object equally, so Bartender.prefab sits under
Prefabs/Characters and Chair.prefab under Prefabs/Interactables.

Three components collapse into one. DialogueInteractable was in no scene at all
— a dead trigger-zone variant of the same idea. ClickInteractableBridge existed
only to adapt NPCStandIn without modifying it, a constraint that died with
NPCStandIn; DialogueInteractor implements IClickInteractable itself. The parts
worth keeping were lifted out rather than discarded: WorldSpaceLabel and
InteractionPrompt are now normal components on prefab children, so a label can
be seen and positioned in the Editor instead of existing only at runtime.
InputDeviceTracker moved into NightclubArcadia.Core and gained a namespace,
leaving no global-namespace runtime types.

Characters get the same pipeline as Skills and Candidates:
Assets/Characters/characters.json is the source of truth, CharacterSetup
regenerates the definition assets on script reload and removes orphans. Adding
an NPC is a JSON entry plus a prefab — no code, and no Unity for the data half.

Phase 5.4 deletes the one-shot builders rather than disabling them. UILayerSetup,
PlayerControlSetup, UILayerSetupAutoRun and YarnDemoSetup assumed the
single-scene layout, had already destroyed a level scene and turned Systems
binary once each, and after NPCStandIn went they no longer compiled. The prefabs
they used to generate are authored and committed; the wiring knowledge is in
those prefabs and in git history. The asset generators — Skill, Candidate,
Character — are untouched and still work.

EditMode 42/42, PlayMode 7/7, YarnCheck 9 files / 35 nodes. All three scenes
still text.

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

171 lines
5.8 KiB
C#

using NightclubArcadia.Core;
using NightclubArcadia.Dialogue;
using NightclubArcadia.UI;
using UnityEngine;
using UnityEngine.InputSystem;
using Yarn.Unity;
namespace NightclubArcadia.Interaction
{
/// <summary>
/// Anything the player can walk up to and talk to: a character, or an object with
/// something to say.
///
/// Replaces NPCStandIn, which was written to be dropped on a sphere primitive and
/// carried its identity in per-instance public fields. Two objects could claim the
/// same character and disagree, and in practice did — the scene's second "Bartender"
/// was the chair, renamed in the data but not in the hierarchy.
///
/// Identity now comes from an InteractableDefinition asset, so a prefab is the thing
/// and the scene only places it. Also replaces DialogueInteractable, a trigger-zone
/// variant of the same idea that was in no scene at all.
/// </summary>
[RequireComponent(typeof(SphereCollider))]
public sealed class DialogueInteractor : MonoBehaviour, IClickInteractable
{
[Header("Identity")]
[SerializeField] InteractableDefinition definition;
[Header("Presentation (optional)")]
[SerializeField] WorldSpaceLabel label;
[SerializeField] InteractionPrompt prompt;
[Header("Overrides")]
[Tooltip("Leave empty to use the Canvas under the DialogueRunner.")]
[SerializeField] GameObject dialogueUI;
DialogueRunner runner;
Transform player;
InputAction interact;
bool playerInRange;
public Transform ApproachPoint => transform;
public float ApproachRadius =>
definition != null ? Mathf.Max(0.15f, definition.InteractionRange - 0.5f) : 0.15f;
/// <summary>
/// The dialogue runner, resolved on demand and cached only on success.
///
/// Never resolved once in Awake: the runner lives in the Systems scene and this
/// object lives in a level, so it can wake up before the runner exists. Caching a
/// failure there left NPCs permanently mute for the session. See SceneOrderTests.
/// </summary>
public DialogueRunner ResolvedRunner =>
runner != null ? runner : runner = SceneServices.TryResolve<DialogueRunner>(null);
Transform Player =>
player != null ? player : player = SceneServices.Player;
void Awake()
{
if (definition == null)
{
Debug.LogError($"[Interactor] {name} has no InteractableDefinition; it will do nothing.", this);
enabled = false;
return;
}
GetComponent<SphereCollider>().isTrigger = false;
if (label != null)
{
label.gameObject.SetActive(definition.ShowLabel);
label.SetText(definition.DisplayName);
}
prompt?.SetShown(false);
interact = new InputAction("Interact");
interact.AddBinding("<Keyboard>/f");
interact.AddBinding("<Gamepad>/buttonSouth");
interact.performed += _ =>
{
if (playerInRange)
{
Interact();
}
};
}
void OnEnable() => interact?.Enable();
void OnDisable() => interact?.Disable();
void Update()
{
var target = Player;
if (target == null)
{
return;
}
var inRange = Vector3.Distance(transform.position, target.position) <= definition.InteractionRange;
if (inRange == playerInRange)
{
return;
}
playerInRange = inRange;
prompt?.SetShown(inRange);
}
/// <summary>Entry point for both the proximity keypress and click-to-move arrival.</summary>
public void Interact()
{
var dialogue = ResolvedRunner;
if (dialogue == null)
{
Debug.LogError(
$"[Interactor] {definition.DisplayName}: no DialogueRunner in any loaded scene. " +
"It lives in Systems.unity — open Bootstrap, or Tools → Nightclub Arcadia → " +
"Open Game Scenes.", this);
return;
}
if (UIStateController.Instance != null && UIStateController.Instance.IsMenuOpen)
{
return;
}
if (!dialogue.IsDialogueRunning)
{
StartDialogueAsync(dialogue);
}
}
public void OnApproached() => Interact();
async void StartDialogueAsync(DialogueRunner dialogue)
{
// The dialogue UI is hidden when the player leaves a conversation via
// <<enter_environment>>. Make it visible again before the first line renders.
if (DialogueUIVisibility.Show(ResolveDialogueUI(dialogue)))
{
// 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 dialogue.StartDialogue(definition.YarnNode);
}
GameObject ResolveDialogueUI(DialogueRunner dialogue)
{
if (dialogueUI != null)
{
return dialogueUI;
}
var canvas = dialogue.GetComponentInChildren<Canvas>(true);
if (canvas == null)
{
Debug.LogWarning(
$"[Interactor] No Canvas under {dialogue.name} for {definition.DisplayName}", this);
return null;
}
return canvas.gameObject;
}
}
}