using UnityEngine; using System; using System.Collections; using System.Collections.Generic; namespace Fungus { /** * Permitted states for Dialogs. */ public enum DialogMode { /// Dialog has no pending content to display so is not shown. Idle, /// Dialog is currently writing out content. Writing, /// Dialog has finished writing out content and is waiting for player input. Waiting } /** * Interface for Dialog implementations. * This allows us to introduce new types of Dialog in future. */ public interface IDialog { /** * Returns the current state of the Dialog. */ DialogMode GetDialogMode(); /** * Display a line of story text. * If any options have previously been added using AddOption(), these will be displayed and the * user must choose an option to continue. The sayAction parameter is ignored * @param sayText The line of story text to be displayed. * @param sayAction Delegate method to call when the player taps to continue. */ void Say(string sayText, Action sayAction); /** * Clear the current list of options previously added using AddOption(). */ void ClearOptions(); /** * Add an option to the list of options to be displayed on the next call to Say(). * @param optionText Text to display on the button for the option. * @param optionAction Delegate method to call when the option button is pressed. */ void AddOption(string optionText, Action optionAction); /** * Sets a time limit for the player to choose between multiple options. * If the player fails to make a choice in time then the timeoutAction delegate method is called. * This setting only applies to the next Say() command and will be reset afterwards. * @timeoutDuration Length of time for the player to choose an option. * @timeoutAction Delegate method to call when player fails to choose an option. */ void SetTimeout(float timeoutDuration, Action timeoutAction); } [ExecuteInEditMode] public class Dialog : MonoBehaviour, IDialog { /** * Padding values for each side of the dialog. * Values are in normalized screen coords [0..1] */ [Serializable] public class Layout { /** * Push the left dialog edge away from the left side of the screen. */ [Tooltip("Push the left dialog edge away from the left side of the screen.")] public bool leftSpace = false; /** * Push the top dialog edge away from the top side of the screen. */ [Tooltip("Push the top dialog edge away from the top side of the screen.")] public bool topSpace = true; /** * Push the right dialog edge away from the right side of the screen. */ [Tooltip("Push the right dialog edge away from the right side of the screen.")] public bool rightSpace = false; /** * Push the bottom dialog edge away from the bottom side of the screen. */ [Tooltip("Push the bottom dialog edge away from the bottom side of the screen.")] public bool bottomSpace = false; /** * Minimum dimensions of the dialog in normalized screen coordinates [0..1]. * The dialog may expand beyond these dimensions to fit content. */ [Tooltip("Minimum dimensions of the dialog in normalized screen coordinates [0..1].")] public Vector2 size = new Vector2(1f, 0.25f); /** * Offset the dialog relative to the top left corner of the screen. * Coordinates are in normalized screen coordinates [0..1] */ [Tooltip("Offset the dialog relative to the top left corner of the screen.")] public Vector2 offset; } /** * Layout values used to control size and position of the dialog. */ [Tooltip("Layout values used to control size and position of the dialog.")] public Layout layout; /** * Defines the dialog display properties of a game character. */ [System.Serializable] public class Character { /** * Side of screen to display character image. */ public enum ImageSide { /// Display image on the left side of the dialog. Left, /// Display image on the right side of the dialog. Right } /** * Identifier for this character for use with the SetCharacter() command. */ [Tooltip("Identifier for this character for use with the SetCharacter() command.")] public string characterID; /** * Name text to display for the character on the dialog. * If empty then the name field is not displayed. */ [Tooltip("Name text to display for the character on the dialog.")] public string name; /** * The color of the name text label. * This always overrides any color value specified in the NameStyle property. */ [Tooltip("The color of the name text label.")] public Color nameColor; /** * Image to display for the character. * If no image is specified then no character image will be displayed. */ [Tooltip("Image to display for the character.")] public Texture2D image; /** * Side of dialog where character image will be displayed. */ [Tooltip("Side of dialog where character image will be displayed.")] public ImageSide imageSide; } /** * List of game characters that can be displayed using this dialog. */ [Tooltip("List of game characters that can be displayed using this dialog.")] public Character[] characters; /** * Active character to use when displaying dialog (set using the SetCharacter() command). */ [Tooltip("Active character to use when displaying dialog.")] public int activeCharacter; /** * Writing speed for say text in characters per second. */ [Range(0, 1000)] [Tooltip("Writing speed for say text in characters per second.")] public int writingSpeed = 100; /** * Sound to play while text is being written in the dialog. */ [Tooltip("Sound to play while text is being written in the dialog.")] public AudioClip writingSound; /** * Loop the writing sound as long as text is being written. */ [Tooltip("Loop the writing sound while text is being written.")] public bool loopWritingSound = true; /** * Sound effect to play when the player taps to continue. */ [Tooltip("Sound effect to play when the player taps to continue.")] public AudioClip continueSound; /** * Icon to display under the story text when waiting for player to tap to continue. */ [Tooltip("Icon to display under the story text when waiting for player to tap to continue.")] public Texture2D continueIcon; /** * Number of buttons to display in the same row when showing multiple options. */ [Range(0, 10)] [Tooltip("Number of buttons to display in the same row when showing multiple options.")] public int buttonsPerRow = 2; /** * Minimum width of each button as a fraction of the screen width [0..1]. * This is useful to create a column of buttons with matching width. */ [Range(0, 1)] [Tooltip("Minimum width of each button as a fraction of the screen width [0..1].")] public float minButtonWidth = 0; /** * Padding values for each side of the character image. */ [System.Serializable] public class ImagePadding { /** * Padding to apply to left side of image as a fraction of screen height [-2..2]. */ [Range(-2,2)] [Tooltip("Padding to apply to left side of image as a fraction of screen height [-2..2].")] public float left; /** * Padding to apply to right side of image as a fraction of screen height [-2..2]. */ [Range(-2,2)] [Tooltip("Padding to apply to right side of image as a fraction of screen height [-2..2].")] public float right; /** * Padding to apply to top side of image as a fraction of screen height [-1..1]. */ [Range(-1,1)] [Tooltip("Padding to apply to top side of image as a fraction of screen height [-1..1].")] public float top; /** * Padding to apply to bottom side of image as a fraction of screen height [-1..1]. */ [Range(-1,1)] [Tooltip("Padding to apply to bottom side of image as a fraction of screen height [-1..1].")] public float bottom; } /** * Padding offset to apply around the character image. */ [Tooltip("Padding offset to apply around the character image.")] public ImagePadding imagePadding; /** * Scale of character image, specified as a fraction of current screen height [0..1]. */ [Range(0, 1)] [Tooltip("Scale of character image, specified as a fraction of current screen height [0..1].")] public float imageScale = 0.25f; /** * Animation frames for multiple choice time indicator. */ [Tooltip("Animation frames for multiple choice time indicator.")] public Texture2D[] timerAnimation; /** * Scale of timer image, specified as a fraction of current screen height [0..1]. */ [Range(0, 1)] [Tooltip("Scale of timer image, specified as a fraction of current screen height [0..1].")] public float timerScale = 0.2f; /** * Sound effect to play when time indicator advances. */ [Tooltip("Sound effect to play when time indicator advances.")] public AudioClip timerSound; /** * Style for dialog box background. */ [Tooltip("Style for dialog box background.")] public GUIStyle boxStyle; /** * Style for name text. */ [Tooltip("Style for name text.")] public GUIStyle nameTextStyle; /** * Style for say text. */ [Tooltip("Style for say text.")] public GUIStyle sayTextStyle; /** * Style for option buttons */ [Tooltip("Style for option buttons.")] public GUIStyle buttonStyle; DialogMode dialogMode; public DialogMode GetDialogMode() { return dialogMode; } class Option { public string optionText; public Action optionAction; public Option(string _optionText, Action _optionAction) { optionText = _optionText; optionAction = _optionAction; } } List<Option> options = new List<Option>(); float timeoutDuration; Action timeoutAction; float timeoutTimer; int timeoutAnimationIndex; string sayText = ""; string previousSayText = ""; string displayedSayText = ""; Action deferredAction; Action continueAction; bool executeAsCommand; float continueTimer; bool instantCompleteText; bool fullscreen; // Cache scaled GUIStyle objects so we're not creating lots of new objects every frame GUIStyle cachedBoxStyle; GUIStyle cachedNameTextStyle; GUIStyle cachedSayTextStyle; GUIStyle cachedButtonStyle; void Start() { if (!Application.isPlaying) { return; } CacheScaledStyles(); fullscreen = Screen.fullScreen; } void Update() { writingSpeed = Math.Max(writingSpeed, 0); buttonsPerRow = Math.Max(buttonsPerRow, 1); minButtonWidth = Mathf.Max(minButtonWidth, 0); imageScale = Mathf.Max(imageScale, 0); timerScale = Mathf.Max(timerScale, 0); layout.size.x = Mathf.Clamp01(layout.size.x); layout.size.y = Mathf.Clamp01(layout.size.y); layout.offset.x = Mathf.Clamp01(layout.offset.x); layout.offset.y = Mathf.Clamp01(layout.offset.y); if (!Application.isPlaying) { return; } if (continueTimer > 0) { continueTimer -= Time.deltaTime; continueTimer = Mathf.Max(continueTimer, 0f); } if (sayText.Length == 0) { dialogMode = DialogMode.Idle; } // Check if the sayText field has been modified directly. // If that has happened, then write the text with no Action if (sayText != previousSayText && sayText.Length > 0) { StopAllCoroutines(); Say(sayText, null); } if (timeoutTimer > 0) { timeoutTimer -= Time.deltaTime; timeoutTimer = Mathf.Max(timeoutTimer, 0f); } // Update cached GUIStyles when running in editor or when switching to/from fullscreen if (Application.isEditor || fullscreen != Screen.fullScreen) { CacheScaledStyles(); fullscreen = Screen.fullScreen; } } void CacheScaledStyles() { cachedBoxStyle = ScaleFontSize(boxStyle); cachedNameTextStyle = ScaleFontSize(nameTextStyle); cachedSayTextStyle = ScaleFontSize(sayTextStyle); cachedButtonStyle = ScaleFontSize(buttonStyle); } public void Say(string _sayText, Action sayAction) { string copyText = _sayText; // Hack: Handle Say(""); by writing a single space character. if (copyText.Length == 0) { copyText = " "; } continueAction = sayAction; previousSayText = copyText; if (timeoutDuration > 0 && options.Count > 0) { // Activate timeout timer timeoutTimer = timeoutDuration; } WriteSayText(copyText); } void WriteSayText(string _sayText) { // Disable quick continue for a short period to prevent accidental taps continueTimer = 0.4f; instantCompleteText = false; sayText = _sayText; if (_sayText.Contains("<")) { // Hack to avoid displaying partial color tag text - write instantly displayedSayText = _sayText; dialogMode = DialogMode.Waiting; } else { // Use a coroutine to write the story text out over time StartCoroutine(WriteStoryInternal()); } } // Coroutine to write story text out over a period of time IEnumerator WriteStoryInternal() { // Zero CPS means write instantly if (writingSpeed == 0) { displayedSayText = sayText; yield break; } GameObject typingAudio = null; if (writingSound != null) { typingAudio = new GameObject("WritingSound"); typingAudio.AddComponent<AudioSource>(); typingAudio.audio.clip = writingSound; typingAudio.audio.loop = loopWritingSound; typingAudio.audio.Play(); } dialogMode = DialogMode.Writing; displayedSayText = ""; // Make one character visible at a time float writeDelay = (1f / (float)writingSpeed); float timeAccumulator = 0f; int i = 0; while (true) { if (instantCompleteText) { instantCompleteText = false; displayedSayText = sayText; break; } timeAccumulator += Time.deltaTime; while (timeAccumulator > writeDelay) { i++; timeAccumulator -= writeDelay; } if (i >= sayText.Length) { displayedSayText = sayText; break; } else { string left = sayText.Substring(0, i + 1); string right = sayText.Substring(i + 1); displayedSayText = left; displayedSayText += "<color=#FFFFFF00>"; displayedSayText += right; displayedSayText += "</color>"; } yield return null; } dialogMode = DialogMode.Waiting; if (typingAudio != null) { Destroy(typingAudio); } } GUIStyle ScaleFontSize(GUIStyle style) { GUIStyle styleCopy = new GUIStyle(style); styleCopy.fontSize = Mathf.RoundToInt(styleCopy.fontSize * ((float)Screen.height / 768f)); return styleCopy; } public void ClearOptions() { options.Clear(); } public void AddOption(string optionText, Action optionAction) { options.Add(new Option(optionText, optionAction)); } public void SetTimeout(float _timeoutDuration, Action _timeoutAction) { timeoutDuration = _timeoutDuration; timeoutAction = _timeoutAction; } public void SetCharacter(string characterID) { for (int i = 0; i < characters.Length; ++i) { if (characters[i].characterID == characterID) { activeCharacter = i; return; } } Debug.Log ("Failed to find character ID " + characterID); } public virtual void OnGUI() { if (!Application.isPlaying || dialogMode == DialogMode.Idle || characters.Length == 0 || activeCharacter >= characters.Length) { return; } Rect areaRect = new Rect(layout.offset.x * Screen.width, layout.offset.y * Screen.height, Screen.width, Screen.height); Rect sideImageRect = new Rect(); Rect dialogRect = new Rect(); // The left and right padding values are also calculated based on Screen.height to give // consistent padding regardless of the screen aspect ratio RectOffset imagePaddingRect = new RectOffset(Mathf.RoundToInt(imagePadding.left * Screen.height), Mathf.RoundToInt(imagePadding.right * Screen.height), Mathf.RoundToInt(imagePadding.top * Screen.height), Mathf.RoundToInt(imagePadding.bottom * Screen.height)); Character character = characters[activeCharacter]; GUILayout.BeginArea(areaRect); { if (layout.topSpace) { GUILayout.FlexibleSpace(); } GUILayout.BeginHorizontal(); { if (layout.leftSpace) { GUILayout.FlexibleSpace(); } GUILayout.BeginHorizontal(cachedBoxStyle, GUILayout.MinWidth(Screen.width * layout.size.x), GUILayout.MinHeight(Screen.height * layout.size.y)); { if (character.imageSide == Character.ImageSide.Left && character.image != null) { // Reserve a rect for the side image based on the current screen height and imageScale float sideImageHeight = Screen.height * imageScale; float sideImageWidth = (sideImageHeight / character.image.height) * character.image.width; float w = sideImageWidth + imagePaddingRect.left + imagePaddingRect.right; float h = sideImageHeight + imagePaddingRect.top + imagePaddingRect.bottom; sideImageRect = GUILayoutUtility.GetRect(w, h, GUILayout.Width(w), GUILayout.Height(h)); } else if (character.imageSide == Character.ImageSide.Right) { DrawTimer(); } GUILayout.BeginVertical(GUILayout.ExpandWidth(false)); { if (character.name.Length > 0) { cachedNameTextStyle.normal.textColor = character.nameColor; GUILayout.Label(new GUIContent(character.name), cachedNameTextStyle); } GUILayout.FlexibleSpace(); GUILayout.Label(new GUIContent(displayedSayText), cachedSayTextStyle); GUILayout.FlexibleSpace(); // Show buttons for player options, or the continue icon if there are no options if (options.Count > 0) { GUILayout.BeginHorizontal(); { GUILayout.BeginVertical(); { int buttonCount = 0; for (int i = 0; i < options.Count; ++i) { if (buttonCount == 0) { GUILayout.BeginHorizontal(); GUILayout.FlexibleSpace(); } buttonCount++; if (GUILayout.Button(options[i].optionText, cachedButtonStyle, GUILayout.MinWidth(Screen.width * minButtonWidth))) { AudioSource.PlayClipAtPoint(continueSound, new Vector3()); if (options[i].optionAction == null) { deferredAction = DoNullAction; executeAsCommand = false; } else { deferredAction = options[i].optionAction; executeAsCommand = true; } } if (buttonCount == buttonsPerRow || i == options.Count - 1) { GUILayout.FlexibleSpace(); GUILayout.EndHorizontal(); buttonCount = 0; } } } GUILayout.EndVertical(); } GUILayout.EndHorizontal(); } else if (continueIcon != null) { GUILayout.BeginHorizontal(); GUILayout.FlexibleSpace(); Rect continueButtonRect = GUILayoutUtility.GetRect(continueIcon.width, continueIcon.height, GUILayout.MaxWidth(continueIcon.width), GUILayout.MaxHeight(continueIcon.height)); if (dialogMode == DialogMode.Waiting) { continueButtonRect.y += Mathf.Sin(Time.timeSinceLevelLoad * 15f) * (float)(continueIcon.height * 0.2f); GUI.DrawTexture(continueButtonRect, continueIcon); } GUILayout.FlexibleSpace(); GUILayout.EndHorizontal(); } GUILayout.FlexibleSpace(); } GUILayout.EndVertical(); if (character.imageSide == Character.ImageSide.Right && character.image != null) { // Reserve a rect for the side image based on the current screen height and imageScale float sideImageHeight = Screen.height * imageScale; float sideImageWidth = (sideImageHeight / character.image.height) * character.image.width; float w = sideImageWidth + imagePaddingRect.left + imagePaddingRect.right; float h = sideImageHeight + imagePaddingRect.top + imagePaddingRect.bottom; sideImageRect = GUILayoutUtility.GetRect(w, h, GUILayout.Width(w), GUILayout.Height(h)); } else if (character.imageSide == Character.ImageSide.Left) { DrawTimer(); } } GUILayout.EndHorizontal(); // Get rect of dialog for testing mouse hit later on dialogRect = GUILayoutUtility.GetLastRect(); if (layout.rightSpace) { GUILayout.FlexibleSpace(); } } GUILayout.EndHorizontal(); if (layout.bottomSpace) { GUILayout.FlexibleSpace(); } } GUILayout.EndArea(); if (character.image != null) { // Adjust side image rect based on aspect ratio of the image. float sideImageHeight = character.image.height * ((sideImageRect.width - imagePaddingRect.left - imagePaddingRect.right) / character.image.width); sideImageRect.yMax = sideImageRect.yMin + sideImageHeight + imagePaddingRect.bottom + imagePaddingRect.top; sideImageRect = imagePaddingRect.Remove(sideImageRect); // Adjust rect based on layout offset sideImageRect.x += areaRect.x; sideImageRect.y += areaRect.y; // Draw side image GUI.DrawTexture(sideImageRect, character.image); } bool clickedOnDialog = (Input.GetMouseButtonUp(0) && dialogRect.Contains(Event.current.mousePosition)); if (dialogMode == DialogMode.Writing) { if (clickedOnDialog || Input.GetKeyUp("space")) { instantCompleteText = true; } } if (dialogMode == DialogMode.Waiting) { // Player can continue by clicking anywhere if (options.Count == 0 && continueTimer == 0 && (clickedOnDialog || Input.GetKeyUp("space"))) { if (continueSound != null) { AudioSource.PlayClipAtPoint(continueSound, new Vector3()); } if (continueAction == null) { deferredAction = DoNullAction; executeAsCommand = false; } else { deferredAction = continueAction; executeAsCommand = false; } } // Check if timeout timer has expired if (options.Count > 0 && timeoutDuration > 0 && timeoutTimer == 0) { deferredAction = timeoutAction; executeAsCommand = true; } } // Execute any deferred actions on the last call to OnGUI if (Event.current.type == EventType.Repaint) { if (deferredAction != null) { Action tempAction = deferredAction; displayedSayText = ""; sayText = ""; deferredAction = null; dialogMode = DialogMode.Idle; ClearOptions(); timeoutAction = null; timeoutDuration = 0; timeoutTimer = 0; timeoutAnimationIndex = 0; if (executeAsCommand) { executeAsCommand = false; // Execute next command CommandQueue commandQueue = Game.GetInstance().commandQueue; commandQueue.CallCommandMethod(tempAction); } else { tempAction(); } } } } void DrawTimer() { if (timeoutTimer > 0 && timerAnimation.Length > 0) { GUILayout.BeginHorizontal(); { GUILayout.FlexibleSpace(); GUILayout.BeginVertical(); { GUILayout.FlexibleSpace(); float t = 1f - timeoutTimer / timeoutDuration; int index = Mathf.RoundToInt(Mathf.Lerp(0, timerAnimation.Length - 1, t)); // Play a sound effect for each animation frame if (timeoutAnimationIndex != index && timerSound != null) { AudioSource.PlayClipAtPoint(timerSound, Vector3.zero); } timeoutAnimationIndex = index; float height = Screen.height * timerScale; GUILayout.Box(timerAnimation[timeoutAnimationIndex], new GUIStyle(), GUILayout.Height(height)); GUILayout.FlexibleSpace(); } GUILayout.EndVertical(); GUILayout.FlexibleSpace(); } GUILayout.EndHorizontal(); } } void DoNullAction() {} } }