/* Yarn Spinner is licensed to you under the terms found in the file LICENSE.md. */ using System.Collections.Generic; using System.Threading; using UnityEngine; using Yarn.Markup; using Yarn.Unity.Attributes; #if USE_TMP using TMPro; #else using TextMeshProUGUI = Yarn.Unity.TMPShim; using TMP_Text = Yarn.Unity.TMPShim; #endif #nullable enable namespace Yarn.Unity { /// /// A dialogue presenter that listens for user input and sends requests to a to advance the presentation of the current line, /// either by asking a dialogue runner to hurry up its delivery, advance to /// the next line, or cancel the entire dialogue session. /// public sealed partial class LineAdvancer : DialoguePresenterBase, IActionMarkupHandler { [MustNotBeNull("Line Advancer needs to know which Dialogue Runner should be told to tell it to show the next line.")] [Tooltip("The dialogue runner that will receive requests to advance or cancel content.")] [SerializeField] DialogueRunner? runner; /// /// The that this LineAdvancer should subscribe to for notifications that the line is fully visible. /// /// When is called, if the line is fully visible, the object will have its method called (instead of its method). /// This behaviour is only the case when the is set to false. /// [SerializeField] DialoguePresenterBase? presenter; /// /// Should this line advancer use different actions for hurrying up a line and advancing a line? /// /// /// When this is false if the player requests a line to hurry up and the line is fully shown the method will be called instead of the method. /// This behaviour is only the case when is not null and the presenter is presenting it's line content via it's property. /// [SerializeField] private bool separateHurryUpAndAdvanceControls = false; public bool SeparateHurryUpAndAdvanceControls => separateHurryUpAndAdvanceControls; /// /// If , repeatedly signalling that the line /// should be hurried up will cause the line advancer to request that /// the next line be shown. /// /// [Space] [Tooltip("Does repeatedly requesting a line advance cancel the line?")] public bool multiAdvanceIsCancel = false; /// /// The number of times that a 'hurry up' signal occurs before the line /// advancer requests that the next line be shown. /// /// [ShowIf(nameof(multiAdvanceIsCancel))] [Indent] [Label("Advance Count")] [Tooltip("The number of times that a line advance occurs before the current line is cancelled.")] public int advanceRequestsBeforeCancellingLine = 2; /// /// The number of times that this object has received an indication that /// the line should be advanced. /// /// /// This value is reset to zero when a new line is run. When the line is /// advanced, this value is incremented. If this value ever meets or /// exceeds , the line /// will be cancelled. /// private int numberOfAdvancesThisLine = 0; /// /// The type of input that this line advancer responds to. /// public enum InputMode { /// /// The line advancer responds to Input Actions from the Unity /// Input System. /// InputActions, /// /// The line advancer responds to keypresses on the keyboard. /// KeyCodes, /// /// The line advancer does not respond to any input. /// /// When a line advancer's is set /// to , call the , and /// methods directly from /// your code to control line advancement. [InspectorName("Manual")] None, /// /// The line advancer responds to input from the legacy Input /// Manager. /// LegacyInputAxes, [InspectorName("Third-Party")] External, } /// /// The type of input that this line advancer responds to. /// /// [Tooltip("The type of input that this line advancer responds to.")] [Space] [MessageBox(sourceMethod: nameof(ValidateInputMode))] [SerializeField] InputMode inputMode; // when using the same input for different actions, for example using spacebar to select an option but also spacebar to hurry up lines // the action for hurrying up the line will happen the same frame as the action for selection // so if a line follows options (very common), that line might well get told to instantly hurry up // which isn't ideal, so this tracks the frame that content arrives and hurry up events cannot run the same frame as their content appears private int frameContentReceived = 0; /// /// Validates the current value of , and /// potentially returns a message box to display. /// private MessageBoxAttribute.Message ValidateInputMode() { #pragma warning disable CS0162 // Unreachable code detected if (this.inputMode == InputMode.None) { return MessageBoxAttribute.Info($"To use this component, call the following methods on it:\n\n" + $"- {nameof(this.RequestLineHurryUp)}()\n" + $"- {nameof(this.RequestNextLine)}()\n" + $"- {nameof(this.RequestOptionHurryUp)}()\n" + $"- {nameof(this.RequestDialogueCancellation)}()" ); } if (this.inputMode == InputMode.LegacyInputAxes && !InputSystemAvailability.enableLegacyInput) { return MessageBoxAttribute.Warning("The Input Manager (Old) system is not enabled.\n\nEither change this setting to Input Actions, or enable Input Manager (Old) in Project Settings > Player > Configuration > Active Input Handling."); } if (this.inputMode == InputMode.InputActions) { if (InputSystemAvailability.inputSystemInstalled == false) { return MessageBoxAttribute.Warning("Please install the Unity Input System package to use Input Actions.\n\nFalling back to the keyboard in the meantime."); } if (!InputSystemAvailability.enableInputSystem) { return MessageBoxAttribute.Warning("The Unity Input System is not enabled.\n\nEither change this setting, or enable Input System in Project Settings > Player > Configuration > Active Input Handling.\n\nFalling back to the keyboard in the meantime."); } } if (this.inputMode == InputMode.External) { if (TryGetComponent(out _) == false) { return MessageBoxAttribute.Info($"Add an input-handling component that implements the {nameof(ILineAdvancerInput)} interface."); } } return MessageBoxAttribute.NoMessage; #pragma warning restore CS0162 // Unreachable code detected } // used to track the status of the presentation // you can think of this as a variation on multiple presses to advance a line // where if the presenter is awaiting input it is reasonable that pressing hurry up would advance to the next piece of content // but the default presenters can't really tell that apart // so the line advancer instead will handle this // this only works if the line advancer is added as a processor onto the presenters typewriter // but that is ok as that is the default // as people replace those defaults with more complex views and presenters they will also want to replace the line advancer anyways private enum PresentationStatus { Unknown, LineBegan, LineWaiting, OptionsBegan, OptionsWaiting, } private PresentationStatus status = PresentationStatus.Unknown; private void Start() { // If we have a dialogue presenter configured, register ourselves as // a temporal processor, so that we get notified when the line is // fully visible. This is so that when a line is fully visible, the // 'hurry up' action will instead trigger a 'next line' action, // (because there's nothing left to hurry up.) if (runner == null || presenter == null) { return; } if (!separateHurryUpAndAdvanceControls) { var listOfPresenters = new List(runner.DialoguePresenters) { this }; runner.DialoguePresenters = listOfPresenters; presenter.Typewriter?.ActionMarkupHandlers.Add(this); } } public void OnValidate() { #if UNITY_EDITOR UnityEditor.EditorApplication.delayCall += SetupInputMode; #endif } #if UNITY_EDITOR private void SetupInputMode() { // This method gets called via a delayCall, so by the time this // method gets called, this object may no longer exist. Early out if // that's the case. if (this == null) { return; } switch (this.inputMode) { case InputMode.KeyCodes: SetupInput(i => { if (hasTransferredLegacyMapping) { return; } i.hurryUpLineKeyCode = legacyHurryUpLineKeyCode; i.nextLineKeyCode = legacyNextLineKeyCode; i.hurryUpOptionsKeyCode = legacyHurryUpOptionsKeyCode; i.cancelDialogueKeyCode = legacyCancelDialogueKeyCode; hasTransferredLegacyMapping = true; }); break; case InputMode.InputActions: #if USE_INPUTSYSTEM SetupInput(i => { if (hasTransferredLegacyMapping) { return; } i.hurryUpLineAction = legacyHurryUpLineAction; i.nextLineAction = legacyNextLineAction; i.hurryUpOptionsAction = legacyHurryUpOptionsAction; i.cancelDialogueAction = legacyCancelDialogueAction; i.enableActions = legacyEnableActions; hasTransferredLegacyMapping = true; }); #else Debug.LogWarning("This line advancer was configured to use the Unity Input System, but it's not installed. Please install the Input System package."); #endif break; case InputMode.LegacyInputAxes: SetupInput(i => { if (hasTransferredLegacyMapping) { return; } i.hurryUpLineAxis = legacyHurryUpLineAxis; i.nextLineAxis = legacyNextLineAxis; i.hurryUpOptionsAxis = legacyHurryUpOptionsAxis; i.cancelDialogueAxis = legacyCancelDialogueAxis; hasTransferredLegacyMapping = true; }); break; case InputMode.None: foreach (var c in this.GetComponents()) { if (!(c is ILineAdvancerInput)) { continue; } DestroyImmediate(c); UnityEditor.EditorUtility.SetDirty(this); } break; case InputMode.External: foreach (var c in this.GetComponents()) { if (c is LineAdvancerInput.KeyCodes k) { DestroyImmediate(k); UnityEditor.EditorUtility.SetDirty(this); continue; } if (c is LineAdvancerInput.InputActions i) { DestroyImmediate(i); UnityEditor.EditorUtility.SetDirty(this); continue; } if (c is LineAdvancerInput.LegacyInputAxes a) { DestroyImmediate(a); UnityEditor.EditorUtility.SetDirty(this); continue; } } break; } } private void SetupInput(System.Action? onLineAdvancerInputComponentAdded) where T : MonoBehaviour, ILineAdvancerInput { if (UnityEditor.PrefabUtility.IsPartOfPrefabAsset(this) && UnityEditor.SceneManagement.PrefabStageUtility.GetPrefabStage(this.gameObject) == null) { // We're in a prefab, and we're not in 'prefab edit' mode. Don't modify this object. return; } var components = this.GetComponents(); bool needsAdding = true; ILineAdvancerInput input; foreach (var existing in components) { if (!(existing is ILineAdvancerInput existingInput)) { continue; } if (existing is T) { needsAdding = false; input = existingInput; continue; } else { UnityEditor.EditorUtility.SetDirty(this); DestroyImmediate(existing); } } if (needsAdding) { var newInput = this.gameObject.AddComponent(); input = newInput; input.LineAdvancer = this; UnityEditor.EditorUtility.SetDirty(this); onLineAdvancerInputComponentAdded?.Invoke(newInput); } } #endif /// /// Called by a dialogue runner when dialogue starts to add input action /// handlers for advancing the line. /// /// A completed task. public override YarnTask OnDialogueStartedAsync() { if (TryGetComponent(out var input)) { input.OnDialogueStarted(); } ResetLineTracking(); return YarnTask.CompletedTask; } /// /// Called by a dialogue runner when dialogue ends to remove the input /// action handlers. /// /// A completed task. public override YarnTask OnDialogueCompleteAsync() { if (TryGetComponent(out var input)) { input.OnDialogueComplete(); } ResetLineTracking(); return YarnTask.CompletedTask; } /// /// Called by a dialogue presenter to signal that a line is running. /// /// /// A completed task. public override YarnTask RunLineAsync(LocalizedLine line, LineCancellationToken token) { // A new line has come in, so reset the number of times we've seen a // request to skip. ResetLineTracking(); status = PresentationStatus.LineBegan; frameContentReceived = Time.frameCount; return YarnTask.CompletedTask; } /// /// Called by a dialogue presenter to signal that options are running. /// /// /// A completed task indicating that no option was selected by /// this view. public override YarnTask RunOptionsAsync(DialogueOption[] dialogueOptions, LineCancellationToken cancellationToken) { ResetLineTracking(); status = PresentationStatus.OptionsBegan; frameContentReceived = Time.frameCount; return DialogueRunner.NoOptionSelected; } private void ResetLineTracking() { numberOfAdvancesThisLine = 0; status = PresentationStatus.Unknown; } private void RequestLineHurryUpInternal() { if (frameContentReceived == Time.frameCount) { return; } // in this mode we NEED to be in a state where a line showing, regardless of it's completion state if (!separateHurryUpAndAdvanceControls) { if (!(status == PresentationStatus.LineBegan || status == PresentationStatus.LineWaiting)) { return; } } // Increment our counter of line advancements, and depending on the // new count, request that the runner 'soft-cancel' the line or // cancel the entire line // this is true regardless of if we are the hurry up mode or not numberOfAdvancesThisLine += 1; if (multiAdvanceIsCancel && numberOfAdvancesThisLine >= advanceRequestsBeforeCancellingLine) { RequestNextLine(); } else { // at this stage we want to hurry up if we are in multiAdvanceIsCancel // and either hurry up or skip the line depending on the state if (separateHurryUpAndAdvanceControls) { if (runner != null) { runner.RequestHurryUpLine(); } else { Debug.LogError($"{nameof(LineAdvancer)} dialogue runner is null", this); } } else { if (status == PresentationStatus.LineWaiting) { RequestNextLine(); } else { if (runner != null) { runner.RequestHurryUpLine(); } else { Debug.LogError($"{nameof(LineAdvancer)} dialogue runner is null", this); } } } } } /// /// Requests that the line be hurried up. /// /// If this method has been called more times for a single line /// than , this method requests /// that the dialogue runner proceed to the next line. Otherwise, it /// requests that the dialogue runner instruct all line views to hurry /// up their presentation of the current line. /// public void RequestLineHurryUp() { // Increment our counter of line advancements, and depending on the // new count, request that the runner 'soft-cancel' the line or // cancel the entire line numberOfAdvancesThisLine += 1; if (multiAdvanceIsCancel && numberOfAdvancesThisLine >= advanceRequestsBeforeCancellingLine) { RequestNextLine(); } else { if (runner != null) { runner.RequestHurryUpLine(); } else { Debug.LogError($"{nameof(LineAdvancer)} dialogue runner is null", this); } } } public void RequestOptionHurryUp() { if (frameContentReceived == Time.frameCount) { return; } if (runner == null) { Debug.LogError($"Unable to hurry up options, {nameof(LineAdvancer)} dialogue runner is null", this); return; } if (!separateHurryUpAndAdvanceControls) { if (status == PresentationStatus.OptionsBegan || status == PresentationStatus.OptionsWaiting) { runner.RequestHurryUpOption(); } } else { runner.RequestHurryUpOption(); } } /// /// Requests that the dialogue runner proceeds to the next line. /// public void RequestNextLine() { ResetLineTracking(); if (runner != null) { runner.RequestNextLine(); } else { Debug.LogError($"{nameof(LineAdvancer)} dialogue runner is null", this); } } /// /// Requests that the dialogue runner to instruct all line views to /// dismiss their content, and then stops the dialogue. /// public void RequestDialogueCancellation() { ResetLineTracking(); // Stop the dialogue runner, which will cancel the current line as // well as the entire dialogue. if (runner != null) { runner.Stop().Forget(); } } /// /// Called by Unity every frame to check to see if, depending on , the should take /// action. /// private void Update() { } public void OnPrepareForLine(MarkupParseResult line, TMP_Text text) { return; } public void OnLineDisplayBegin(MarkupParseResult line, TMP_Text text) { return; } public YarnTask OnCharacterWillAppear(int currentCharacterIndex, MarkupParseResult line, CancellationToken cancellationToken) { return YarnTask.CompletedTask; } public void OnLineDisplayComplete() { if (status == PresentationStatus.LineBegan) { status = PresentationStatus.LineWaiting; } else if (status == PresentationStatus.OptionsBegan) { status = PresentationStatus.OptionsWaiting; } } public void OnLineWillDismiss() { return; } public void OnInputHurryUpLines() => RequestLineHurryUpInternal(); public void OnInputNextContent() => RequestNextLine(); public void OnInputHurryUpOptions() => RequestOptionHurryUp(); public void OnInputCancelDialogue() => RequestDialogueCancellation(); } public interface ILineAdvancerInput { public LineAdvancer? LineAdvancer { get; set; } public void OnDialogueStarted(); public void OnDialogueComplete(); } // Previous versions of LineAdvancer stored all of their configuration data // in the LineAdvancer component itself. To avoid data loss, LineAdvancer // keeps these fields around, and moves them into the ILineAdvancerInput // components when they're added. public partial class LineAdvancer { [HideInInspector] [SerializeField] bool hasTransferredLegacyMapping = false; #if USE_INPUTSYSTEM /// /// The Input Action that triggers a request to advance to the next /// piece of content. /// [UnityEngine.Serialization.FormerlySerializedAs("hurryUpLineAction")] [HideInInspector] [SerializeField] UnityEngine.InputSystem.InputActionReference? legacyHurryUpLineAction; /// /// The Input Action that triggers an instruction to cancel the current /// line. /// [UnityEngine.Serialization.FormerlySerializedAs("nextLineAction")] [HideInInspector] [SerializeField] UnityEngine.InputSystem.InputActionReference? legacyNextLineAction; /// /// The Input Action that triggers an instruction to hurry up presenting the current options /// [UnityEngine.Serialization.FormerlySerializedAs("hurryUpOptionsAction")] [HideInInspector] [SerializeField] UnityEngine.InputSystem.InputActionReference? legacyHurryUpOptionsAction; /// /// The Input Action that triggers an instruction to cancel the entire /// dialogue. /// [UnityEngine.Serialization.FormerlySerializedAs("cancelDialogueAction")] [HideInInspector] [SerializeField] UnityEngine.InputSystem.InputActionReference? legacyCancelDialogueAction; /// /// If true, the , and Input /// Actions will be enabled when the the dialogue runner signals that a /// line is running. /// [UnityEngine.Serialization.FormerlySerializedAs("enableActions")] [HideInInspector] [SerializeField] bool legacyEnableActions = true; #endif /// /// The legacy Input Axis that triggers a request to advance to the next /// piece of content. /// [UnityEngine.Serialization.FormerlySerializedAs("hurryUpLineAxis")] [HideInInspector] [SerializeField] string? legacyHurryUpLineAxis = "Jump"; /// /// The legacy Input Axis that triggers an instruction to cancel the /// current line. /// [UnityEngine.Serialization.FormerlySerializedAs("nextLineAxis")] [HideInInspector] [SerializeField] string? legacyNextLineAxis = "Cancel"; /// /// The legacy Input Axis that triggers an instruction to hurry up presenting the current options /// [UnityEngine.Serialization.FormerlySerializedAs("hurryUpOptionsAxis")] [HideInInspector] [SerializeField] string? legacyHurryUpOptionsAxis = "Jump"; /// /// The legacy Input Axis that triggers an instruction to cancel the /// entire dialogue. /// [UnityEngine.Serialization.FormerlySerializedAs("cancelDialogueAxis")] [HideInInspector] [SerializeField] string? legacyCancelDialogueAxis = ""; /// /// The that triggers a request to advance to the /// next piece of content. /// [UnityEngine.Serialization.FormerlySerializedAs("hurryUpLineKeyCode")] [HideInInspector] [SerializeField] KeyCode legacyHurryUpLineKeyCode = KeyCode.Space; /// /// The that triggers an instruction to cancel the /// current line. /// [UnityEngine.Serialization.FormerlySerializedAs("nextLineKeyCode")] [HideInInspector] [SerializeField] KeyCode legacyNextLineKeyCode = KeyCode.Escape; /// /// The that triggers an instruction to hurry up presenting options /// [UnityEngine.Serialization.FormerlySerializedAs("hurryUpOptionsKeyCode")] [HideInInspector] [SerializeField] KeyCode legacyHurryUpOptionsKeyCode = KeyCode.Space; /// /// The that triggers an instruction to cancel the /// entire dialogue. /// [UnityEngine.Serialization.FormerlySerializedAs("cancelDialogueKeyCode")] [HideInInspector] [SerializeField] KeyCode legacyCancelDialogueKeyCode = KeyCode.None; } }