diff --git a/Assets/Gothic-Core/Scripts/Adapters/Npc/INpcSubtitles.cs b/Assets/Gothic-Core/Scripts/Adapters/Npc/INpcSubtitles.cs index 5056843e2..117b3c7b3 100644 --- a/Assets/Gothic-Core/Scripts/Adapters/Npc/INpcSubtitles.cs +++ b/Assets/Gothic-Core/Scripts/Adapters/Npc/INpcSubtitles.cs @@ -4,5 +4,6 @@ public interface INpcSubtitles { void ShowSubtitles(string text); void HideSubtitles(); + void ScheduleHide(float delay); } } diff --git a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/Output.cs b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/Output.cs index 8b1f54eae..c063c04db 100644 --- a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/Output.cs +++ b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/Output.cs @@ -4,20 +4,18 @@ using Gothic.Core.Models.Container; using Gothic.Core.Services; using Gothic.Core.Services.Caches; -using Gothic.Core.Services.Config; using Gothic.Core.Services.Npc; -using Gothic.Core.Adapters.Npc; using Gothic.Core.Extensions; -using Gothic.Core.Const; using Reflex.Attributes; using UnityEngine; using Random = UnityEngine.Random; +using Logger = Gothic.Core.Logging.Logger; +using LogCat = Gothic.Core.Logging.LogCat; namespace Gothic.Core.Domain.Npc.Actions.AnimationActions { public class Output : AbstractAnimationAction { - [Inject] private readonly ConfigService _configService; [Inject] private readonly DialogService _dialogService; [Inject] private readonly AudioService _audioService; [Inject] private readonly NpcService _npcService; @@ -50,12 +48,15 @@ public override void Start() } var audioClip = _audioService.CreateAudioClip(OutputName); - _audioPlaySeconds = audioClip.length; + if (audioClip == null) + Logger.LogWarning($"[Output] Audio not found: {OutputName} — using fallback duration", LogCat.Dialog); + _audioPlaySeconds = audioClip != null ? audioClip.length : 3f; // Hero if (_isHeroSpeaking) { - _npcService.GetHeroGameObject().GetComponent().PlayOneShot(audioClip); + if (audioClip != null) + _npcService.GetHeroGameObject().GetComponent().PlayOneShot(audioClip); PrintDialog(); } @@ -69,7 +70,8 @@ public override void Start() PrefabProps.AnimationSystem.PlayAnimation(_randomDialogAnimationName); PrefabProps.AnimationSystem.PlayHeadAnimation(HeadMorph.HeadMorphType.Viseme); - PrefabProps.NpcSound.PlayOneShot(audioClip); + if (audioClip != null) + PrefabProps.NpcSound.PlayOneShot(audioClip); PrintDialog(); } @@ -81,13 +83,9 @@ private void PrintDialog() var currentMessage = _gameStateService.Dialogs.CutsceneLibrary.Blocks.Find(x => x.Name == OutputName).Message; if (_isHeroSpeaking) - { _npcService.GetHeroContainer().PrefabProps.NpcSubtitles.ShowSubtitles(currentMessage.Text); - } else - { PrefabProps.NpcSubtitles.ShowSubtitles(currentMessage.Text); - } } /// @@ -108,6 +106,13 @@ private int GetDialogGestureCount() return _gameStateService.Dialogs.GestureCount; } + // Used by OutputSvm for hero greeting: audio plays fire-and-forget, subtitles auto-hide after clip ends. + protected void StartHeroFireAndForget() + { + _npcService.GetHeroContainer().PrefabProps.NpcSubtitles.ScheduleHide(_audioPlaySeconds); + IsFinishedFlag = true; + } + public override void StopImmediately() { _audioPlaySeconds = 0f; @@ -126,6 +131,8 @@ public override void StopImmediately() public override bool IsFinished() { + if (IsFinishedFlag) return true; + _audioPlaySeconds -= Time.deltaTime; if (_audioPlaySeconds <= 0f) @@ -138,8 +145,11 @@ public override bool IsFinished() // NPC else { - PrefabProps.AnimationSystem.StopAnimation(_randomDialogAnimationName); - PrefabProps.AnimationSystem.StopHeadAnimation(HeadMorph.HeadMorphType.Viseme); + if (_randomDialogAnimationName != null) + { + PrefabProps.AnimationSystem.StopAnimation(_randomDialogAnimationName); + PrefabProps.AnimationSystem.StopHeadAnimation(HeadMorph.HeadMorphType.Viseme); + } PrefabProps.NpcSubtitles.HideSubtitles(); } diff --git a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/OutputSvm.cs b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/OutputSvm.cs index 088dd5b55..803a194cb 100644 --- a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/OutputSvm.cs +++ b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/OutputSvm.cs @@ -18,9 +18,20 @@ public OutputSvm(AnimationAction action, NpcContainer npcContainer) : base(actio public override void Start() { var svm = VmCacheService.TryGetSvmData(NpcInstance.Voice); - _preparedSvmFileName = svm.GetAudioName(Action.String0); + _preparedSvmFileName = svm?.GetAudioName(Action.String0); + + if (_preparedSvmFileName == null) + { + IsFinishedFlag = true; + return; + } base.Start(); + + // Hero SVM (e.g. "Hej ty!") is fire-and-forget — NPC queue continues immediately + // so the NPC can turn and greet in parallel. Regular Output dialog lines stay blocking. + if (Action.Int0 == 0) + StartHeroFireAndForget(); } } } diff --git a/Assets/Gothic-Core/Scripts/Extensions/ZenKitExtension.cs b/Assets/Gothic-Core/Scripts/Extensions/ZenKitExtension.cs index a98faa185..cf6ad4984 100644 --- a/Assets/Gothic-Core/Scripts/Extensions/ZenKitExtension.cs +++ b/Assets/Gothic-Core/Scripts/Extensions/ZenKitExtension.cs @@ -318,6 +318,7 @@ public static string GetAudioName(this SvmInstance svm, string svmEntry) "$smalltalk23" => svm.Smalltalk23, "$smalltalk24" => svm.Smalltalk24, "$om" => svm.Om, + "$sc_heywaitasecond" => svm.ScHeyWaitASecond, "$sc_heyturnaround" => svm.ScHeyTurnAround, "$sc_heyturnaround02" => svm.ScHeyTurnAround02, "$sc_heyturnaround03" => svm.ScHeyTurnAround03, diff --git a/Assets/Gothic-Core/Scripts/Models/Npc/DialogModel.cs b/Assets/Gothic-Core/Scripts/Models/Npc/DialogModel.cs index b49973e30..6b883d68f 100644 --- a/Assets/Gothic-Core/Scripts/Models/Npc/DialogModel.cs +++ b/Assets/Gothic-Core/Scripts/Models/Npc/DialogModel.cs @@ -10,6 +10,13 @@ public class DialogModel public List Instances = new(); public bool IsInDialog; + /// + /// Set from C# when the hero physically initiates dialog (collision/grab). + /// False when the NPC initiates (e.g. important dialog walks to player). + /// Used to suppress hero's reactive "hey" SVM lines for NPC-initiated conversations. + /// + public bool WasPlayerInitiated; + public CutsceneLibrary CutsceneLibrary; public int GestureCount; @@ -20,6 +27,7 @@ public class DialogModel public void Dispose() { IsInDialog = false; + WasPlayerInitiated = false; CurrentInstance = null; CurrentOptions.Clear(); GestureCount = 0; diff --git a/Assets/Gothic-Core/Scripts/Services/Npc/NpcService.cs b/Assets/Gothic-Core/Scripts/Services/Npc/NpcService.cs index ec605f8f7..9bb9c3b97 100644 --- a/Assets/Gothic-Core/Scripts/Services/Npc/NpcService.cs +++ b/Assets/Gothic-Core/Scripts/Services/Npc/NpcService.cs @@ -487,8 +487,84 @@ public void SetDialogs(NpcContainer npcContainer) .Where(dialog => dialog.Npc == npcIndex) .OrderByDescending(dialog => dialog.Important) .ToList(); + + if (!npcContainer.Props.Dialogs.Any()) + TryAssignAmbientDialogs(npcContainer); + } + + // ZenKit bug: Daedalus member assignment like `Info_Grd_6_EXIT.npc = X` inside + // function bodies fails with "C_INFO.NPC without an instance set". B_AssignAmbientInfos + // relies on this to link ambient C_INFO instances to NPCs at runtime. We replicate + // the assignment here by matching symbol name patterns (INFO___*) + // and setting InfoInstance.Npc directly via the ZenKitCS setter. + private void TryAssignAmbientDialogs(NpcContainer npcContainer) + { + var guildAbbr = GetAmbientGuildAbbreviation(npcContainer.Instance.Guild); + if (guildAbbr == null) + return; + + _ambientInfoByPrefix ??= BuildAmbientInfoPrefixLookup(); + + var voice = npcContainer.Instance.Voice; + if (!_ambientInfoByPrefix.TryGetValue($"INFO_{guildAbbr}_{voice}", out var ambientDialogs) && + !_ambientInfoByPrefix.TryGetValue($"INFO_MINE_{guildAbbr}_{voice}", out ambientDialogs)) + return; + + var npcIndex = npcContainer.Instance.Index; + foreach (var dialog in ambientDialogs) + dialog.Npc = npcIndex; + + npcContainer.Props.Dialogs = ambientDialogs.OrderByDescending(d => d.Important).ToList(); } + // Cached lookup: "INFO_GRD_6" → list of ambient C_INFO instances with that guild+voice prefix. + // Built lazily from symbol names to avoid repeated GetSymbolByIndex calls per frame. + private Dictionary> _ambientInfoByPrefix; + + private Dictionary> BuildAmbientInfoPrefixLookup() + { + var lookup = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var info in _gameStateService.Dialogs.Instances) + { + var name = _vm.GetSymbolByIndex(info.Index)?.Name; + if (name == null) continue; + + var parts = name.Split('_'); + if (parts.Length < 4 || !parts[0].Equals("INFO", StringComparison.OrdinalIgnoreCase)) + continue; + + string key; + if (parts[1].Equals("MINE", StringComparison.OrdinalIgnoreCase) && parts.Length >= 5 && int.TryParse(parts[3], out var mineVoice) && mineVoice <= 15) + key = $"INFO_MINE_{parts[2]}_{parts[3]}"; + else if (int.TryParse(parts[2], out var campVoice) && campVoice <= 15) + key = $"INFO_{parts[1]}_{parts[2]}"; + else + continue; + + if (!lookup.TryGetValue(key, out var list)) + { + list = new List(); + lookup[key] = list; + } + list.Add(info); + } + return lookup; + } + + private static string GetAmbientGuildAbbreviation(int guild) => guild switch + { + (int)VmGothicEnums.Guild.GIL_GRD => "GRD", + (int)VmGothicEnums.Guild.GIL_STT => "STT", + (int)VmGothicEnums.Guild.GIL_VLK => "VLK", + (int)VmGothicEnums.Guild.GIL_SLD => "SLD", + (int)VmGothicEnums.Guild.GIL_ORG => "ORG", + (int)VmGothicEnums.Guild.GIL_BAU => "BAU", + (int)VmGothicEnums.Guild.GIL_SFB => "SFB", + (int)VmGothicEnums.Guild.GIL_NOV => "NOV", + (int)VmGothicEnums.Guild.GIL_TPL => "TPL", + _ => null + }; + private void EventNpcMeshCullingChanged(NpcContainer npcContainer, NpcLoader npcLoader, bool isInVisibleRange, bool wasOutOfDistance) { // Alter position tracking of NPC diff --git a/Assets/Gothic-Core/Scripts/Services/Player/DialogService.cs b/Assets/Gothic-Core/Scripts/Services/Player/DialogService.cs index 6635f89e6..a1234a8cf 100644 --- a/Assets/Gothic-Core/Scripts/Services/Player/DialogService.cs +++ b/Assets/Gothic-Core/Scripts/Services/Player/DialogService.cs @@ -96,7 +96,6 @@ public void StartDialog(NpcContainer npcContainer, bool initialDialogStarting) continue; } - // TODO - Should be outsourced to some VmManager.Call function which sets and resets values. var oldSelf = _gameStateService.GothicVm.GlobalSelf; var oldOther = _gameStateService.GothicVm.GlobalOther; @@ -111,12 +110,19 @@ public void StartDialog(NpcContainer npcContainer, bool initialDialogStarting) { continue; } - + // We can now add the dialog selectableDialogs.Add(dialog); } selectableDialogs = selectableDialogs.OrderBy(d => d.Nr).ToList(); + + if (!selectableDialogs.Any()) + { + StopDialog(npcContainer); + return; + } + _contextDialogService.FillDialog(npcContainer.Instance, selectableDialogs); _contextDialogService.ShowDialog(npcContainer.Go); } @@ -183,18 +189,25 @@ public void ExtAiOutput(NpcInstance self, NpcInstance target, string outputName) npcTalkingTo.GetUserData())); } - /// - /// SVM (Standard Voice Module) dialogs are only for NPCs between each other. Not related to Hero dialogs. - /// public void ExtAiOutputSvm(NpcInstance npc, NpcInstance target, string svmName) { - var npcContainer = GetNpcContainer(npc); + var isHero = npc.Id == 0; - if (target != null) + // Hero SVM: enqueue on target's queue (hero's queue is never processed); hero container provides voice ID. + if (isHero && target != null) { - Logger.LogWarning("Ai_OutputSvm() - Handling with target not yet implemented!", LogCat.Dialog); + // NPC walked to player (e.g. important dialog) — hero's "hey" reactive lines make no sense. + if (!_gameStateService.Dialogs.WasPlayerInitiated) + return; + + var heroContainer = GetNpcContainer(npc); + target.GetUserData().Props.AnimationQueue.Enqueue(new OutputSvm( + new AnimationAction(int0: 0, string0: svmName), + heroContainer)); + return; } + var npcContainer = GetNpcContainer(npc); npcContainer.Props.AnimationQueue.Enqueue(new OutputSvm( new AnimationAction(int0: npcContainer.Instance.Id, string0: svmName), npcContainer)); @@ -279,6 +292,7 @@ public void StopDialog(NpcContainer npc) _gameStateService.Dialogs.CurrentInstance = null; _gameStateService.Dialogs.CurrentOptions.Clear(); _gameStateService.Dialogs.IsInDialog = false; + _gameStateService.Dialogs.WasPlayerInitiated = false; // WIP: unlocking movement _contextInteractionService.UnlockPlayer(); diff --git a/Assets/Gothic-VR/Scripts/Adapters/UI/VRSubtitles.cs b/Assets/Gothic-VR/Scripts/Adapters/UI/VRSubtitles.cs index 9538df458..68ccc2c35 100644 --- a/Assets/Gothic-VR/Scripts/Adapters/UI/VRSubtitles.cs +++ b/Assets/Gothic-VR/Scripts/Adapters/UI/VRSubtitles.cs @@ -51,6 +51,7 @@ public void ShowSubtitles(string text) if (!_configService.Gothic.IniSubtitles) return; + CancelInvoke(nameof(HideSubtitles)); gameObject.SetActive(true); _dialogText.text = text; } @@ -59,6 +60,8 @@ public void HideSubtitles() { gameObject.SetActive(false); } + + public void ScheduleHide(float delay) => Invoke(nameof(HideSubtitles), delay); } } #endif diff --git a/Assets/Gothic-VR/Scripts/Adapters/VRNpc.cs b/Assets/Gothic-VR/Scripts/Adapters/VRNpc.cs index 8664f2289..0eba7af39 100644 --- a/Assets/Gothic-VR/Scripts/Adapters/VRNpc.cs +++ b/Assets/Gothic-VR/Scripts/Adapters/VRNpc.cs @@ -53,6 +53,7 @@ public void OnGrabbed(HVRGrabberBase grabber, HVRGrabbable grabbable) } else { + _gameStateService.Dialogs.WasPlayerInitiated = true; _npcAiService.ExecutePerception(VmGothicEnums.PerceptionType.AssessTalk, _npcData.Props, _npcData.Instance, null, (NpcInstance)_gameStateService.GothicVm.GlobalHero); } }