An easy to use Unity 3D library for creating illustrated Interactive Fiction games and more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

695 lines
17 KiB

using UnityEngine;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace Fungus
{
/**
* Manages and draws a text box for rendering story text and multiple choice menus.
*/
[ExecuteInEditMode]
public class PageController : MonoBehaviour, IDialog
{
/// Options for default Page position on screen
public enum PagePosition
{
/// Page appears full-size and horizontally centered at top of screen.
Top,
/// Page appears centered in middle of screen, with height fitted to content.
Middle,
/// Page appears full-size and horizontally centered at bottom of screen.
Bottom
}
/**
* Defines a rect in normalized screen space coordinates.
* e.g. x1 = 0 means left of screen, x2 = 1 means right of screen.
*/
public class ScreenRect
{
public float x1;
public float y1;
public float x2;
public float y2;
}
/// Options for controlling page layout
public enum Layout
{
/// Use the full rect to display the page.
FullSize,
/// Resize to fit displayed text and snap to top of rect.
FitToTop,
/// Resize to fit displayed text and snap to middle of rect.
FitToMiddle,
/// Resize to fit displayed text and snap to bottom of rect.
FitToBottom
}
/// Controls layout of content within Page rect.
public Layout layout = Layout.FullSize;
/// Supported states for Page
public enum Mode
{
/// No content to be displayed.
Idle,
/// Show a single line of text and wait for player input.
Say,
/// Show a multiple choice menu and wait for player to select an option.
Choose
};
/**
* The style to apply when displaying Pages.
*/
public PageStyle activePageStyle;
/// Current Page story telling state
[HideInInspector]
public Mode mode = Mode.Idle;
/// Screen space rect for Page in pixels.
[HideInInspector]
public Rect pageRect;
/**
* Writing speed for page text.
*/
public int charactersPerSecond = 60;
/**
* Icon to display when waiting for player input to continue
*/
public Texture2D continueIcon;
/**
* Position of continue and swipe icons in normalized screen space coords.
* (0,0) = top left, (1,1) = bottom right
*/
public Vector2 iconPosition = new Vector2(1,1);
/**
* Default screen position for Page when player enters a Room.
*/
public PageController.PagePosition defaultPagePosition;
/**
* Default width and height of Page as a fraction of screen height [0..1]
*/
public Vector2 defaultPageScale = new Vector2(0.75f, 0.25f);
/**
* Automatically center the Page when player is choosing from multiple options.
*/
public bool centerChooseMenu = true;
/**
* Width of Page as a fraction of screen width [0..1] when automatically centering a Choose menu.
* This setting only has an effect when centerChooseMenu is enabled.
*/
public float chooseMenuWidth = 0.5f;
/**
* Sound effect to play when buttons are clicked.
*/
public AudioClip clickSound;
string headerText = "";
string footerText = "";
string displayedStoryText = "";
string originalStoryText = "";
Action deferredAction;
Action continueAction;
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 quickContinueTimer;
/**
* Translates the PageController specific Mode to the more generic DialogMode.
*/
public DialogMode GetDialogMode()
{
switch(mode)
{
case Mode.Say:
case Mode.Choose:
if (FinishedWriting())
{
return DialogMode.Waiting;
}
else
{
return DialogMode.Writing;
}
case Mode.Idle:
default:
return DialogMode.Idle;
}
}
/**
* Calculate a screen space rectangle given normalized screen space coords.
* The resulting rect is clamped to always be on-screen.
*/
public static Rect CalcPageRect(ScreenRect screenRect)
{
Rect rect = new Rect();
rect.xMin = Screen.width * screenRect.x1;
rect.yMin = Screen.height * screenRect.y1;
rect.xMax = Screen.width * screenRect.x2;
rect.yMax = Screen.height * screenRect.y2;
// Clamp to be on-screen
rect.xMax = Mathf.Min(rect.xMax, Screen.width);
rect.xMin = Mathf.Max(rect.xMin, 0);
rect.yMax = Mathf.Min(rect.yMax, Screen.height);
rect.yMin = Mathf.Max(rect.yMin, 0);
return rect;
}
/**
* Calculates a screen rect in normalized screen space coordinates in one of the 'standard' Page positions (top, middle, bottom).
*/
public static ScreenRect CalcScreenRect(Vector2 pageScale, PagePosition pagePosition)
{
float width = Mathf.Clamp01(pageScale.x);
float height = Mathf.Clamp01(pageScale.y);
ScreenRect screenRect = new ScreenRect();
switch (pagePosition)
{
case PagePosition.Top:
screenRect.x1 = 0.5f - width * 0.5f;
screenRect.x2 = 0.5f + width * 0.5f;
screenRect.y1 = 0f;
screenRect.y2 = height;
break;
case PagePosition.Middle:
screenRect.x1 = 0.5f - width * 0.5f;
screenRect.x2 = 0.5f + width * 0.5f;
screenRect.y1 = 0.5f - height * 0.5f;
screenRect.y2 = 0.5f + height * 0.5f;
break;
case PagePosition.Bottom:
screenRect.x1 = 0.5f - width * 0.5f;
screenRect.x2 = 0.5f + width * 0.5f;
screenRect.y1 = 1f - Mathf.Clamp01(height);
screenRect.y2 = 1;
break;
}
return screenRect;
}
/**
* Reset to the default page layout based on properties in Game class.
*/
public void SetDefaultPageLayout()
{
ScreenRect screenRect = CalcScreenRect(defaultPageScale, defaultPagePosition);
pageRect = CalcPageRect(screenRect);
switch (defaultPagePosition)
{
case PageController.PagePosition.Top:
layout = PageController.Layout.FullSize;
break;
case PageController.PagePosition.Middle:
layout = PageController.Layout.FitToMiddle;
break;
case PageController.PagePosition.Bottom:
layout = PageController.Layout.FullSize;
break;
}
}
void Update()
{
if (quickContinueTimer > 0)
{
quickContinueTimer -= Time.deltaTime;
quickContinueTimer = Mathf.Max(quickContinueTimer, 0f);
}
}
public void SetHeader(string _headerText)
{
headerText = _headerText;
}
public void SetFooter(string _footerText)
{
footerText = _footerText;
}
public void Say(string sayText, Action sayAction = null)
{
// IDialog does not support the legacy Choose() command
// Instead, the assumption is that if you call Say() after some options have been added then show the choice menu.
if (options.Count > 0)
{
Choose(sayText);
return;
}
mode = Mode.Say;
continueAction = sayAction;
WriteStory(sayText);
}
public void ClearOptions()
{
options.Clear();
}
public void AddOption(string optionText, Action optionAction)
{
options.Add(new Option(optionText, optionAction));
}
public void Choose(string _chooseText)
{
mode = Mode.Choose;
WriteStory(_chooseText);
}
public void SetTimeout(float _timeoutDuration, Action _timeoutAction)
{
Debug.Log("SetTimeout() is not supported by PageController.");
}
void WriteStory(string storyText)
{
if (activePageStyle == null)
{
return;
}
// Disable quick continue for a short period to prevent accidental taps
quickContinueTimer = 0.8f;
originalStoryText = storyText;
// Hack to avoid displaying partial color tag text
if (storyText.Contains("<"))
{
displayedStoryText = storyText;
}
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 (charactersPerSecond == 0)
{
displayedStoryText = originalStoryText;
yield break;
}
displayedStoryText = "";
// Make one character visible at a time
float writeDelay = (1f / (float)charactersPerSecond);
float timeAccumulator = 0f;
int i = 0;
while (true)
{
timeAccumulator += Time.deltaTime;
while (timeAccumulator > writeDelay)
{
i++;
timeAccumulator -= writeDelay;
}
if (i >= originalStoryText.Length)
{
displayedStoryText = originalStoryText;
break;
}
else
{
string left = originalStoryText.Substring(0, i + 1);
string right = originalStoryText.Substring(i + 1);
displayedStoryText = left;
displayedStoryText += "<color=#FFFFFF00>";
displayedStoryText += right;
displayedStoryText += "</color>";
}
yield return null;
}
}
public bool FinishedWriting()
{
return (displayedStoryText.Length == originalStoryText.Length);
}
public virtual void OnGUI()
{
if (mode == Mode.Idle)
{
return;
}
if (activePageStyle == null)
{
return;
}
if (mode == PageController.Mode.Say &&
FinishedWriting())
{
// Draw the continue icon
if (continueIcon)
{
float x = Screen.width * iconPosition.x;
float y = Screen.height * iconPosition.y;
float width = continueIcon.width;
float height = continueIcon.height;
x = Mathf.Max(x, 0);
y = Mathf.Max(y, 0);
x = Mathf.Min(x, Screen.width - width);
y = Mathf.Min(y, Screen.height - height);
Rect rect = new Rect(x, y, width, height);
GUI.DrawTexture(rect, continueIcon);
}
}
GUIStyle boxStyle = activePageStyle.boxStyle;
GUIStyle headerStyle = activePageStyle.GetScaledHeaderStyle();
GUIStyle footerStyle = activePageStyle.GetScaledFooterStyle();
GUIStyle sayStyle = activePageStyle.GetScaledSayStyle();
GUIStyle optionStyle = activePageStyle.GetScaledOptionStyle();
GUIStyle optionAlternateStyle = activePageStyle.GetScaledOptionAlternateStyle();
Rect outerRect;
Layout tempLayout;
if (mode == Mode.Choose &&
centerChooseMenu)
{
// Position the Choose menu in middle of screen
// The width is controlled by game.chooseMenuWidth
// The height is automatically fitted to the text content
Vector2 pageScale = new Vector2(chooseMenuWidth, 0.5f);
PageController.ScreenRect screenRect = PageController.CalcScreenRect(pageScale, PageController.PagePosition.Middle);
outerRect = PageController.CalcPageRect(screenRect);
tempLayout = PageController.Layout.FitToMiddle;
}
else
{
outerRect = pageRect;
tempLayout = layout;
}
Rect originalRect = outerRect;
Rect innerRect = CalcInnerRect(outerRect);
// Calculate height of each section
float headerHeight = CalcHeaderHeight(innerRect.width);
float footerHeight = CalcFooterHeight(innerRect.width);
float storyHeight = CalcStoryHeight(innerRect.width);
float optionsHeight = CalcOptionsHeight(innerRect.width);
float contentHeight = headerHeight + footerHeight + storyHeight + optionsHeight;
// Adjust outer rect position based on alignment settings
switch (tempLayout)
{
case Layout.FullSize:
outerRect.height = Mathf.Max(outerRect.height, contentHeight + (boxStyle.padding.top + boxStyle.padding.bottom));
outerRect.y = Mathf.Min(outerRect.y, Screen.height - outerRect.height);
break;
case Layout.FitToTop:
outerRect.height = contentHeight + (boxStyle.padding.top + boxStyle.padding.bottom);
outerRect.y = originalRect.yMin;
break;
case Layout.FitToMiddle:
outerRect.height = contentHeight + (boxStyle.padding.top + boxStyle.padding.bottom);
outerRect.y = originalRect.center.y - outerRect.height / 2;
break;
case Layout.FitToBottom:
outerRect.height = contentHeight + (boxStyle.padding.top + boxStyle.padding.bottom);
outerRect.y = originalRect.yMax - outerRect.height;
break;
}
innerRect = CalcInnerRect(outerRect);
// Draw box
Rect boxRect = outerRect;
boxRect.height = contentHeight + (boxStyle.padding.top + boxStyle.padding.bottom);
if (tempLayout == Layout.FullSize)
{
boxRect.height = Mathf.Max(boxRect.height, originalRect.height);
}
GUI.Box(boxRect, "", boxStyle);
// Draw header label
Rect headerRect = innerRect;
headerRect.height = headerHeight;
if (headerHeight > 0)
{
GUI.Label(headerRect, headerText, headerStyle);
}
// Draw say label
Rect storyRect = innerRect;
storyRect.y += headerHeight;
storyRect.height = storyHeight;
GUI.Label(storyRect, displayedStoryText, sayStyle);
// Draw footer label
Rect footerRect = innerRect;
footerRect.y += storyHeight;
footerRect.height = footerHeight;
if (footerHeight > 0)
{
GUI.Label(footerRect, footerText, footerStyle);
}
if (!FinishedWriting())
{
return;
}
// Input handling
if (mode == Mode.Say)
{
// Player can continue by clicking anywhere
if (quickContinueTimer == 0 &&
(Input.GetMouseButtonUp(0) || Input.anyKeyDown) &&
continueAction != null)
{
deferredAction = continueAction;
}
}
else if (mode == Mode.Choose)
{
// Draw option buttons
Rect buttonRect = innerRect;
buttonRect.y += headerHeight + storyHeight;
bool alternateRow = false;
foreach (Option option in options)
{
GUIContent buttonContent = new GUIContent(option.optionText);
buttonRect.height = optionStyle.CalcHeight(buttonContent, innerRect.width);
// Select style for odd/even colored rows
GUIStyle style;
if (alternateRow)
{
style = optionAlternateStyle;
}
else
{
style = optionStyle;
}
alternateRow = !alternateRow;
if (GUI.Button(buttonRect, buttonContent, style))
{
if (option.optionAction != null)
{
// We can't execute the option action yet because OnGUI
// may be called multiple times during a frame, and it's
// not permitted to modify GUI elements within a frame.
// We defer executing the action until OnGUI has completed.
deferredAction = option.optionAction;
break;
}
}
buttonRect.y += buttonRect.height;
}
}
if (Event.current.type == EventType.Repaint)
{
if (deferredAction != null)
{
PlayButtonClick();
Action tempAction = deferredAction;
displayedStoryText = "";
originalStoryText = "";
deferredAction = null;
if (mode == Mode.Choose)
{
ClearOptions();
// Reset to idle, but calling action may set this again
mode = Mode.Idle;
CommandQueue commandQueue = Game.GetInstance().commandQueue;
commandQueue.CallCommandMethod(tempAction);
}
else if (mode == Mode.Say )
{
// Reset to idle, but calling action may set this again
mode = Mode.Idle;
// Execute next command
tempAction();
}
}
}
}
float CalcHeaderHeight(float boxWidth)
{
if (activePageStyle == null ||
mode == Mode.Idle ||
headerText.Length == 0)
{
return 0;
}
GUIStyle headerStyle = activePageStyle.GetScaledHeaderStyle();
GUIContent headerContent = new GUIContent(headerText);
return headerStyle.CalcHeight(headerContent, boxWidth);
}
float CalcFooterHeight(float boxWidth)
{
if (activePageStyle == null ||
mode == Mode.Idle ||
footerText.Length == 0)
{
return 0;
}
GUIStyle footerStyle = activePageStyle.GetScaledFooterStyle();
GUIContent headerContent = new GUIContent(headerText);
return footerStyle.CalcHeight(headerContent, boxWidth);
}
float CalcStoryHeight(float boxWidth)
{
GUIStyle sayStyle = activePageStyle.GetScaledSayStyle();
if (activePageStyle == null ||
mode == Mode.Idle ||
originalStoryText.Length == 0)
{
// Allow a space for story even if there's no text
return sayStyle.lineHeight;
}
GUIContent storyContent = new GUIContent(originalStoryText + "\n");
return sayStyle.CalcHeight(storyContent, boxWidth);
}
float CalcOptionsHeight(float boxWidth)
{
if (activePageStyle == null ||
mode == Mode.Idle ||
options.Count == 0)
{
return 0;
}
// This assumes that the alternate option style is the same height as the regular style
GUIStyle optionStyle = activePageStyle.GetScaledOptionStyle();
float totalHeight = 0;
foreach (Option option in options)
{
GUIContent optionContent = new GUIContent(option.optionText);
float optionHeight = optionStyle.CalcHeight(optionContent, boxWidth);
totalHeight += optionHeight;
}
// Add space at bottom
GUIStyle sayStyle = activePageStyle.GetScaledSayStyle();
totalHeight += sayStyle.lineHeight;
return totalHeight;
}
// Returns smaller internal box rect with padding style applied
Rect CalcInnerRect(Rect outerRect)
{
if (activePageStyle == null)
{
return new Rect();
}
GUIStyle boxStyle = activePageStyle.boxStyle;
Rect innerRect = new Rect(outerRect.x + boxStyle.padding.left,
outerRect.y + boxStyle.padding.top,
outerRect.width - (boxStyle.padding.left + boxStyle.padding.right),
outerRect.height - (boxStyle.padding.top + boxStyle.padding.bottom));
return innerRect;
}
/**
* Plays the button clicked sound effect
*/
public void PlayButtonClick()
{
if (clickSound != null)
{
audio.PlayOneShot(clickSound);
}
}
}
}