Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Assets/Gothic-Core/Scripts/Adapters/Npc/INpcSubtitles.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ public interface INpcSubtitles
{
void ShowSubtitles(string text);
void HideSubtitles();
void ScheduleHide(float delay);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<AudioSource>().PlayOneShot(audioClip);
if (audioClip != null)
_npcService.GetHeroGameObject().GetComponent<AudioSource>().PlayOneShot(audioClip);

PrintDialog();
}
Expand All @@ -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();
}
Expand All @@ -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);
}
}

/// <summary>
Expand All @@ -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;
Expand All @@ -126,6 +131,8 @@ public override void StopImmediately()

public override bool IsFinished()
{
if (IsFinishedFlag) return true;

_audioPlaySeconds -= Time.deltaTime;

if (_audioPlaySeconds <= 0f)
Expand All @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
}
1 change: 1 addition & 0 deletions Assets/Gothic-Core/Scripts/Extensions/ZenKitExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions Assets/Gothic-Core/Scripts/Models/Npc/DialogModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ public class DialogModel
public List<InfoInstance> Instances = new();
public bool IsInDialog;

/// <summary>
/// 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.
/// </summary>
public bool WasPlayerInitiated;

public CutsceneLibrary CutsceneLibrary;

public int GestureCount;
Expand All @@ -20,6 +27,7 @@ public class DialogModel
public void Dispose()
{
IsInDialog = false;
WasPlayerInitiated = false;
CurrentInstance = null;
CurrentOptions.Clear();
GestureCount = 0;
Expand Down
76 changes: 76 additions & 0 deletions Assets/Gothic-Core/Scripts/Services/Npc/NpcService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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_<GUILD>_<VOICE>_*)
// 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<string, List<InfoInstance>> _ambientInfoByPrefix;

private Dictionary<string, List<InfoInstance>> BuildAmbientInfoPrefixLookup()
{
var lookup = new Dictionary<string, List<InfoInstance>>(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<InfoInstance>();
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
Expand Down
30 changes: 22 additions & 8 deletions Assets/Gothic-Core/Scripts/Services/Player/DialogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@ public void StartDialog(NpcContainer npcContainer, bool initialDialogStarting)
continue;
}


// TODO - Should be outsourced to some VmManager.Call<int> function which sets and resets values.
var oldSelf = _gameStateService.GothicVm.GlobalSelf;
var oldOther = _gameStateService.GothicVm.GlobalOther;
Expand All @@ -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);
}
Expand Down Expand Up @@ -183,18 +189,25 @@ public void ExtAiOutput(NpcInstance self, NpcInstance target, string outputName)
npcTalkingTo.GetUserData()));
}

/// <summary>
/// SVM (Standard Voice Module) dialogs are only for NPCs between each other. Not related to Hero dialogs.
/// </summary>
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));
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions Assets/Gothic-VR/Scripts/Adapters/UI/VRSubtitles.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public void ShowSubtitles(string text)
if (!_configService.Gothic.IniSubtitles)
return;

CancelInvoke(nameof(HideSubtitles));
gameObject.SetActive(true);
_dialogText.text = text;
}
Expand All @@ -59,6 +60,8 @@ public void HideSubtitles()
{
gameObject.SetActive(false);
}

public void ScheduleHide(float delay) => Invoke(nameof(HideSubtitles), delay);
}
}
#endif
1 change: 1 addition & 0 deletions Assets/Gothic-VR/Scripts/Adapters/VRNpc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down