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.

517 lines
13 KiB

using UnityEngine;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace Fungus
{
/**
* A rectangular screen area for rendering story text.
* Rooms may contain any number of Pages.
* If a Page is a child of a View, then transitioning to that View will automatically activate the Page.
*/
[ExecuteInEditMode]
public class Page : MonoBehaviour
{
/// Rectangular bounds used to display page text
public Bounds pageBounds = new Bounds(Vector3.zero, new Vector3(0.25f, 0.25f, 0f));
/// Page position within bounds when display height is less than bounds height
public enum VerticalAlign
{
Top,
Middle,
Bottom
}
public VerticalAlign verticalAlign = VerticalAlign.Middle;
string headerText = "";
string footerText = "";
string displayedStoryText = "";
string originalStoryText = "";
Action deferredAction;
Action continueAction;
public enum Mode
{
Idle,
Say,
Choose
};
[HideInInspector]
public Mode mode = Mode.Idle;
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;
public virtual 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)
{
mode = Mode.Say;
StringTable stringTable = Game.GetInstance().stringTable;
string subbedText = stringTable.SubstituteStrings(sayText);
continueAction = sayAction;
WriteStory(subbedText);
}
public void AddOption(string optionText, Action optionAction)
{
StringTable stringTable = Game.GetInstance().stringTable;
string subbedText = stringTable.FormatLinkText(stringTable.SubstituteStrings(optionText));
options.Add(new Option(subbedText, optionAction));
}
public void Choose(string _chooseText)
{
mode = Mode.Choose;
StringTable stringTable = Game.GetInstance().stringTable;
string subbedText = stringTable.SubstituteStrings(_chooseText);
WriteStory(subbedText);
}
void WriteStory(string storyText)
{
PageStyle pageStyle = Game.GetInstance().activePageStyle;
if (pageStyle == 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()
{
int charactersPerSecond = Game.GetInstance().charactersPerSecond;
// 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 virtual void OnGUI()
{
if (mode == Mode.Idle)
{
return;
}
PageStyle pageStyle = Game.GetInstance().activePageStyle;
if (pageStyle == null)
{
return;
}
GUIStyle boxStyle = pageStyle.boxStyle;
GUIStyle headerStyle = pageStyle.GetScaledHeaderStyle();
GUIStyle footerStyle = pageStyle.GetScaledFooterStyle();
GUIStyle sayStyle = pageStyle.GetScaledSayStyle();
GUIStyle optionStyle = pageStyle.GetScaledOptionStyle();
GUIStyle optionAlternateStyle = pageStyle.GetScaledOptionAlternateStyle();
GUIStyle continueStyle = pageStyle.GetScaledContinueStyle();
Rect pageRect = GetScreenRect();
Rect outerRect = FitRectToScreen(pageRect);
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 (verticalAlign)
{
case VerticalAlign.Top:
outerRect.height = contentHeight + (boxStyle.padding.top + boxStyle.padding.bottom);
outerRect.y = pageRect.yMin;
break;
case VerticalAlign.Middle:
outerRect.height = contentHeight + (boxStyle.padding.top + boxStyle.padding.bottom);
outerRect.y = pageRect.center.y - outerRect.height / 2;
break;
case VerticalAlign.Bottom:
outerRect.height = contentHeight + (boxStyle.padding.top + boxStyle.padding.bottom);
outerRect.y = pageRect.yMax - outerRect.height;
break;
}
// Force outer rect to always be on-screen
// If the rect is bigger than the screen, then the top-left corner will always be visible
outerRect = FitRectToScreen(outerRect);
innerRect = CalcInnerRect(outerRect);
// Draw box
Rect boxRect = outerRect;
boxRect.height = contentHeight + (boxStyle.padding.top + boxStyle.padding.bottom);
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);
}
bool finishedWriting = (displayedStoryText.Length == originalStoryText.Length);
if (!finishedWriting)
{
return;
}
if (mode == Mode.Say)
{
Rect continueRect = CalcContinueRect(outerRect);
GUI.Button(continueRect, new GUIContent(Game.GetInstance().continueText), continueStyle);
// 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)
{
Game.GetInstance().PlayButtonClick();
Action tempAction = deferredAction;
displayedStoryText = "";
originalStoryText = "";
deferredAction = null;
if (mode == Mode.Choose)
{
options.Clear();
// 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)
{
PageStyle pageStyle = Game.GetInstance().activePageStyle;
if (pageStyle == null ||
mode == Mode.Idle ||
headerText.Length == 0)
{
return 0;
}
GUIStyle headerStyle = pageStyle.GetScaledHeaderStyle();
GUIContent headerContent = new GUIContent(headerText);
return headerStyle.CalcHeight(headerContent, boxWidth);
}
float CalcFooterHeight(float boxWidth)
{
PageStyle pageStyle = Game.GetInstance().activePageStyle;
if (pageStyle == null ||
mode == Mode.Idle ||
footerText.Length == 0)
{
return 0;
}
GUIStyle footerStyle = pageStyle.GetScaledFooterStyle();
GUIContent headerContent = new GUIContent(headerText);
return footerStyle.CalcHeight(headerContent, boxWidth);
}
float CalcStoryHeight(float boxWidth)
{
PageStyle pageStyle = Game.GetInstance().activePageStyle;
GUIStyle sayStyle = pageStyle.GetScaledSayStyle();
if (pageStyle == 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)
{
PageStyle pageStyle = Game.GetInstance().activePageStyle;
if (pageStyle == 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 = pageStyle.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 = pageStyle.GetScaledSayStyle();
totalHeight += sayStyle.lineHeight;
return totalHeight;
}
// Force rect to always be on-screen
Rect FitRectToScreen(Rect rect)
{
Rect fittedRect = new Rect();
fittedRect.width = Mathf.Min(rect.width, Screen.width);
fittedRect.height = Mathf.Min(rect.height, Screen.height);
fittedRect.x = Mathf.Min(rect.x, Screen.width - rect.width);
fittedRect.y = Mathf.Min(rect.y, Screen.height - rect.height);
fittedRect.x = Mathf.Max(0, fittedRect.x);
fittedRect.y = Mathf.Max(0, fittedRect.y);
return fittedRect;
}
// Returns smaller internal box rect with padding style applied
Rect CalcInnerRect(Rect outerRect)
{
PageStyle pageStyle = Game.GetInstance().activePageStyle;
if (pageStyle == null)
{
return new Rect();
}
GUIStyle boxStyle = pageStyle.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;
}
Rect CalcContinueRect(Rect outerRect)
{
PageStyle pageStyle = Game.GetInstance().activePageStyle;
if (pageStyle == null)
{
return new Rect();
}
GUIStyle continueStyle = pageStyle.GetScaledContinueStyle();
GUIContent content = new GUIContent(Game.GetInstance().continueText);
float width = continueStyle.CalcSize(content).x;
float height = continueStyle.lineHeight;
float x = outerRect.xMin + (outerRect.width) - (width) - pageStyle.boxStyle.padding.right;
float y = outerRect.yMax - height / 2f;
return new Rect(x, y, width, height);
}
/**
* Returns the page rect in screen space coords
*/
public Rect GetScreenRect()
{
// Y decreases up the screen in GUI space, so top left is rect origin
Vector3 topLeft = transform.position + pageBounds.center;
topLeft.x -= pageBounds.extents.x;
topLeft.y += pageBounds.extents.y;
Vector3 bottomRight = transform.position + pageBounds.center;
bottomRight.x += pageBounds.extents.x;
bottomRight.y -= pageBounds.extents.y;
Camera mainCamera = GameObject.FindGameObjectWithTag("MainCamera").camera;
Vector2 tl = mainCamera.WorldToScreenPoint(topLeft);
Vector2 br = mainCamera.WorldToScreenPoint(bottomRight);
Rect pageRect = new Rect(tl.x, Screen.height - tl.y, br.x - tl.x, tl.y - br.y);
return FitRectToScreen(pageRect);
}
}
}