Permalink
Cannot retrieve contributors at this time
Name already in use
A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
ink/ink-engine-runtime/StoryState.cs
Go to fileThis commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
1341 lines (1099 sloc)
48.1 KB
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Generic; | |
using System.Text; | |
using System.Diagnostics; | |
using System.IO; | |
namespace Ink.Runtime | |
{ | |
/// <summary> | |
/// All story state information is included in the StoryState class, | |
/// including global variables, read counts, the pointer to the current | |
/// point in the story, the call stack (for tunnels, functions, etc), | |
/// and a few other smaller bits and pieces. You can save the current | |
/// state using the json serialisation functions ToJson and LoadJson. | |
/// </summary> | |
public class StoryState | |
{ | |
/// <summary> | |
/// The current version of the state save file JSON-based format. | |
/// </summary> | |
// | |
// Backward compatible changes since v8: | |
// v10: dynamic tags | |
// v9: multi-flows | |
public const int kInkSaveStateVersion = 10; | |
const int kMinCompatibleLoadVersion = 8; | |
/// <summary> | |
/// Callback for when a state is loaded | |
/// </summary> | |
public event Action onDidLoadState; | |
/// <summary> | |
/// Exports the current state to json format, in order to save the game. | |
/// </summary> | |
/// <returns>The save state in json format.</returns> | |
public string ToJson() { | |
var writer = new SimpleJson.Writer(); | |
WriteJson(writer); | |
return writer.ToString(); | |
} | |
/// <summary> | |
/// Exports the current state to json format, in order to save the game. | |
/// For this overload you can pass in a custom stream, such as a FileStream. | |
/// </summary> | |
public void ToJson(Stream stream) { | |
var writer = new SimpleJson.Writer(stream); | |
WriteJson(writer); | |
} | |
/// <summary> | |
/// Loads a previously saved state in JSON format. | |
/// </summary> | |
/// <param name="json">The JSON string to load.</param> | |
public void LoadJson(string json) | |
{ | |
var jObject = SimpleJson.TextToDictionary (json); | |
LoadJsonObj(jObject); | |
if(onDidLoadState != null) onDidLoadState(); | |
} | |
/// <summary> | |
/// Gets the visit/read count of a particular Container at the given path. | |
/// For a knot or stitch, that path string will be in the form: | |
/// | |
/// knot | |
/// knot.stitch | |
/// | |
/// </summary> | |
/// <returns>The number of times the specific knot or stitch has | |
/// been enountered by the ink engine.</returns> | |
/// <param name="pathString">The dot-separated path string of | |
/// the specific knot or stitch.</param> | |
public int VisitCountAtPathString(string pathString) | |
{ | |
int visitCountOut; | |
if ( _patch != null ) { | |
var container = story.ContentAtPath(new Path(pathString)).container; | |
if (container == null) | |
throw new Exception("Content at path not found: " + pathString); | |
if( _patch.TryGetVisitCount(container, out visitCountOut) ) | |
return visitCountOut; | |
} | |
if (_visitCounts.TryGetValue(pathString, out visitCountOut)) | |
return visitCountOut; | |
return 0; | |
} | |
public int VisitCountForContainer(Container container) | |
{ | |
if (!container.visitsShouldBeCounted) | |
{ | |
story.Error("Read count for target (" + container.name + " - on " + container.debugMetadata + ") unknown."); | |
return 0; | |
} | |
int count = 0; | |
if (_patch != null && _patch.TryGetVisitCount(container, out count)) | |
return count; | |
var containerPathStr = container.path.ToString(); | |
_visitCounts.TryGetValue(containerPathStr, out count); | |
return count; | |
} | |
public void IncrementVisitCountForContainer(Container container) | |
{ | |
if( _patch != null ) { | |
var currCount = VisitCountForContainer(container); | |
currCount++; | |
_patch.SetVisitCount(container, currCount); | |
return; | |
} | |
int count = 0; | |
var containerPathStr = container.path.ToString(); | |
_visitCounts.TryGetValue(containerPathStr, out count); | |
count++; | |
_visitCounts[containerPathStr] = count; | |
} | |
public void RecordTurnIndexVisitToContainer(Container container) | |
{ | |
if( _patch != null ) { | |
_patch.SetTurnIndex(container, currentTurnIndex); | |
return; | |
} | |
var containerPathStr = container.path.ToString(); | |
_turnIndices[containerPathStr] = currentTurnIndex; | |
} | |
public int TurnsSinceForContainer(Container container) | |
{ | |
if (!container.turnIndexShouldBeCounted) | |
{ | |
story.Error("TURNS_SINCE() for target (" + container.name + " - on " + container.debugMetadata + ") unknown."); | |
} | |
int index = 0; | |
if ( _patch != null && _patch.TryGetTurnIndex(container, out index) ) { | |
return currentTurnIndex - index; | |
} | |
var containerPathStr = container.path.ToString(); | |
if (_turnIndices.TryGetValue(containerPathStr, out index)) | |
{ | |
return currentTurnIndex - index; | |
} | |
else | |
{ | |
return -1; | |
} | |
} | |
public int callstackDepth { | |
get { | |
return callStack.depth; | |
} | |
} | |
// REMEMBER! REMEMBER! REMEMBER! | |
// When adding state, update the Copy method, and serialisation. | |
// REMEMBER! REMEMBER! REMEMBER! | |
public List<Runtime.Object> outputStream { | |
get { | |
return _currentFlow.outputStream; | |
} | |
} | |
public List<Choice> currentChoices { | |
get { | |
// If we can continue generating text content rather than choices, | |
// then we reflect the choice list as being empty, since choices | |
// should always come at the end. | |
if( canContinue ) return new List<Choice>(); | |
return _currentFlow.currentChoices; | |
} | |
} | |
public List<Choice> generatedChoices { | |
get { | |
return _currentFlow.currentChoices; | |
} | |
} | |
// TODO: Consider removing currentErrors / currentWarnings altogether | |
// and relying on client error handler code immediately handling StoryExceptions etc | |
// Or is there a specific reason we need to collect potentially multiple | |
// errors before throwing/exiting? | |
public List<string> currentErrors { get; private set; } | |
public List<string> currentWarnings { get; private set; } | |
public VariablesState variablesState { get; private set; } | |
public CallStack callStack { | |
get { | |
return _currentFlow.callStack; | |
} | |
// set { | |
// _currentFlow.callStack = value; | |
// } | |
} | |
public List<Runtime.Object> evaluationStack { get; private set; } | |
public Pointer divertedPointer { get; set; } | |
public int currentTurnIndex { get; private set; } | |
public int storySeed { get; set; } | |
public int previousRandom { get; set; } | |
public bool didSafeExit { get; set; } | |
public Story story { get; set; } | |
/// <summary> | |
/// String representation of the location where the story currently is. | |
/// </summary> | |
public string currentPathString { | |
get { | |
var pointer = currentPointer; | |
if (pointer.isNull) | |
return null; | |
else | |
return pointer.path.ToString(); | |
} | |
} | |
public Runtime.Pointer currentPointer { | |
get { | |
return callStack.currentElement.currentPointer; | |
} | |
set { | |
callStack.currentElement.currentPointer = value; | |
} | |
} | |
public Pointer previousPointer { | |
get { | |
return callStack.currentThread.previousPointer; | |
} | |
set { | |
callStack.currentThread.previousPointer = value; | |
} | |
} | |
public bool canContinue { | |
get { | |
return !currentPointer.isNull && !hasError; | |
} | |
} | |
public bool hasError | |
{ | |
get { | |
return currentErrors != null && currentErrors.Count > 0; | |
} | |
} | |
public bool hasWarning { | |
get { | |
return currentWarnings != null && currentWarnings.Count > 0; | |
} | |
} | |
public string currentText | |
{ | |
get | |
{ | |
if( _outputStreamTextDirty ) { | |
var sb = new StringBuilder (); | |
bool inTag = false; | |
foreach (var outputObj in outputStream) { | |
var textContent = outputObj as StringValue; | |
if (!inTag && textContent != null) { | |
sb.Append(textContent.value); | |
} else { | |
var controlCommand = outputObj as ControlCommand; | |
if( controlCommand != null ) { | |
if( controlCommand.commandType == ControlCommand.CommandType.BeginTag ) { | |
inTag = true; | |
} else if( controlCommand.commandType == ControlCommand.CommandType.EndTag ) { | |
inTag = false; | |
} | |
} | |
} | |
} | |
_currentText = CleanOutputWhitespace (sb.ToString ()); | |
_outputStreamTextDirty = false; | |
} | |
return _currentText; | |
} | |
} | |
string _currentText; | |
// Cleans inline whitespace in the following way: | |
// - Removes all whitespace from the start and end of line (including just before a \n) | |
// - Turns all consecutive space and tab runs into single spaces (HTML style) | |
public string CleanOutputWhitespace(string str) | |
{ | |
var sb = new StringBuilder(str.Length); | |
int currentWhitespaceStart = -1; | |
int startOfLine = 0; | |
for (int i = 0; i < str.Length; i++) { | |
var c = str[i]; | |
bool isInlineWhitespace = c == ' ' || c == '\t'; | |
if (isInlineWhitespace && currentWhitespaceStart == -1) | |
currentWhitespaceStart = i; | |
if (!isInlineWhitespace) { | |
if (c != '\n' && currentWhitespaceStart > 0 && currentWhitespaceStart != startOfLine) { | |
sb.Append(' '); | |
} | |
currentWhitespaceStart = -1; | |
} | |
if (c == '\n') | |
startOfLine = i + 1; | |
if (!isInlineWhitespace) | |
sb.Append(c); | |
} | |
return sb.ToString(); | |
} | |
public List<string> currentTags | |
{ | |
get | |
{ | |
if( _outputStreamTagsDirty ) { | |
_currentTags = new List<string>(); | |
bool inTag = false; | |
var sb = new StringBuilder (); | |
foreach (var outputObj in outputStream) { | |
var controlCommand = outputObj as ControlCommand; | |
if( controlCommand != null ) { | |
if( controlCommand.commandType == ControlCommand.CommandType.BeginTag ) { | |
if( inTag && sb.Length > 0 ) { | |
var txt = CleanOutputWhitespace(sb.ToString()); | |
_currentTags.Add(txt); | |
sb.Clear(); | |
} | |
inTag = true; | |
} | |
else if( controlCommand.commandType == ControlCommand.CommandType.EndTag ) { | |
if( sb.Length > 0 ) { | |
var txt = CleanOutputWhitespace(sb.ToString()); | |
_currentTags.Add(txt); | |
sb.Clear(); | |
} | |
inTag = false; | |
} | |
} | |
else if( inTag ) { | |
var strVal = outputObj as StringValue; | |
if( strVal != null ) { | |
sb.Append(strVal.value); | |
} | |
} | |
else { | |
var tag = outputObj as Tag; | |
if (tag != null && tag.text != null && tag.text.Length > 0) { | |
_currentTags.Add (tag.text); // tag.text has whitespae already cleaned | |
} | |
} | |
} | |
if( sb.Length > 0 ) { | |
var txt = CleanOutputWhitespace(sb.ToString()); | |
_currentTags.Add(txt); | |
sb.Clear(); | |
} | |
_outputStreamTagsDirty = false; | |
} | |
return _currentTags; | |
} | |
} | |
List<string> _currentTags; | |
public string currentFlowName { | |
get { | |
return _currentFlow.name; | |
} | |
} | |
public bool currentFlowIsDefaultFlow { | |
get { | |
return _currentFlow.name == kDefaultFlowName; | |
} | |
} | |
public List<string> aliveFlowNames { | |
get { | |
if( _aliveFlowNamesDirty ) { | |
_aliveFlowNames = new List<string>(); | |
if (_namedFlows != null) | |
{ | |
foreach (string flowName in _namedFlows.Keys) { | |
if (flowName != kDefaultFlowName) { | |
_aliveFlowNames.Add(flowName); | |
} | |
} | |
} | |
_aliveFlowNamesDirty = false; | |
} | |
return _aliveFlowNames; | |
} | |
} | |
List<string> _aliveFlowNames; | |
public bool inExpressionEvaluation { | |
get { | |
return callStack.currentElement.inExpressionEvaluation; | |
} | |
set { | |
callStack.currentElement.inExpressionEvaluation = value; | |
} | |
} | |
public StoryState (Story story) | |
{ | |
this.story = story; | |
_currentFlow = new Flow(kDefaultFlowName, story); | |
OutputStreamDirty(); | |
_aliveFlowNamesDirty = true; | |
evaluationStack = new List<Runtime.Object> (); | |
variablesState = new VariablesState (callStack, story.listDefinitions); | |
_visitCounts = new Dictionary<string, int> (); | |
_turnIndices = new Dictionary<string, int> (); | |
currentTurnIndex = -1; | |
// Seed the shuffle random numbers | |
int timeSeed = DateTime.Now.Millisecond; | |
storySeed = (new Random (timeSeed)).Next () % 100; | |
previousRandom = 0; | |
GoToStart(); | |
} | |
public void GoToStart() | |
{ | |
callStack.currentElement.currentPointer = Pointer.StartOf (story.mainContentContainer); | |
} | |
internal void SwitchFlow_Internal(string flowName) | |
{ | |
if(flowName == null) throw new System.Exception("Must pass a non-null string to Story.SwitchFlow"); | |
if( _namedFlows == null ) { | |
_namedFlows = new Dictionary<string, Flow>(); | |
_namedFlows[kDefaultFlowName] = _currentFlow; | |
} | |
if( flowName == _currentFlow.name ) { | |
return; | |
} | |
Flow flow; | |
if( !_namedFlows.TryGetValue(flowName, out flow) ) { | |
flow = new Flow(flowName, story); | |
_namedFlows[flowName] = flow; | |
_aliveFlowNamesDirty = true; | |
} | |
_currentFlow = flow; | |
variablesState.callStack = _currentFlow.callStack; | |
// Cause text to be regenerated from output stream if necessary | |
OutputStreamDirty(); | |
} | |
internal void SwitchToDefaultFlow_Internal() | |
{ | |
if( _namedFlows == null ) return; | |
SwitchFlow_Internal(kDefaultFlowName); | |
} | |
internal void RemoveFlow_Internal(string flowName) | |
{ | |
if(flowName == null) throw new System.Exception("Must pass a non-null string to Story.DestroyFlow"); | |
if(flowName == kDefaultFlowName) throw new System.Exception("Cannot destroy default flow"); | |
// If we're currently in the flow that's being removed, switch back to default | |
if( _currentFlow.name == flowName ) { | |
SwitchToDefaultFlow_Internal(); | |
} | |
_namedFlows.Remove(flowName); | |
_aliveFlowNamesDirty = true; | |
} | |
// Warning: Any Runtime.Object content referenced within the StoryState will | |
// be re-referenced rather than cloned. This is generally okay though since | |
// Runtime.Objects are treated as immutable after they've been set up. | |
// (e.g. we don't edit a Runtime.StringValue after it's been created an added.) | |
// I wonder if there's a sensible way to enforce that..?? | |
public StoryState CopyAndStartPatching() | |
{ | |
var copy = new StoryState(story); | |
copy._patch = new StatePatch(_patch); | |
// Hijack the new default flow to become a copy of our current one | |
// If the patch is applied, then this new flow will replace the old one in _namedFlows | |
copy._currentFlow.name = _currentFlow.name; | |
copy._currentFlow.callStack = new CallStack (_currentFlow.callStack); | |
copy._currentFlow.currentChoices.AddRange(_currentFlow.currentChoices); | |
copy._currentFlow.outputStream.AddRange(_currentFlow.outputStream); | |
copy.OutputStreamDirty(); | |
// The copy of the state has its own copy of the named flows dictionary, | |
// except with the current flow replaced with the copy above | |
// (Assuming we're in multi-flow mode at all. If we're not then | |
// the above copy is simply the default flow copy and we're done) | |
if( _namedFlows != null ) { | |
copy._namedFlows = new Dictionary<string, Flow>(); | |
foreach(var namedFlow in _namedFlows) | |
copy._namedFlows[namedFlow.Key] = namedFlow.Value; | |
copy._namedFlows[_currentFlow.name] = copy._currentFlow; | |
copy._aliveFlowNamesDirty = true; | |
} | |
if (hasError) { | |
copy.currentErrors = new List<string> (); | |
copy.currentErrors.AddRange (currentErrors); | |
} | |
if (hasWarning) { | |
copy.currentWarnings = new List<string> (); | |
copy.currentWarnings.AddRange (currentWarnings); | |
} | |
// ref copy - exactly the same variables state! | |
// we're expecting not to read it only while in patch mode | |
// (though the callstack will be modified) | |
copy.variablesState = variablesState; | |
copy.variablesState.callStack = copy.callStack; | |
copy.variablesState.patch = copy._patch; | |
copy.evaluationStack.AddRange (evaluationStack); | |
if (!divertedPointer.isNull) | |
copy.divertedPointer = divertedPointer; | |
copy.previousPointer = previousPointer; | |
// visit counts and turn indicies will be read only, not modified | |
// while in patch mode | |
copy._visitCounts = _visitCounts; | |
copy._turnIndices = _turnIndices; | |
copy.currentTurnIndex = currentTurnIndex; | |
copy.storySeed = storySeed; | |
copy.previousRandom = previousRandom; | |
copy.didSafeExit = didSafeExit; | |
return copy; | |
} | |
public void RestoreAfterPatch() | |
{ | |
// VariablesState was being borrowed by the patched | |
// state, so restore it with our own callstack. | |
// _patch will be null normally, but if you're in the | |
// middle of a save, it may contain a _patch for save purpsoes. | |
variablesState.callStack = callStack; | |
variablesState.patch = _patch; // usually null | |
} | |
public void ApplyAnyPatch() | |
{ | |
if (_patch == null) return; | |
variablesState.ApplyPatch(); | |
foreach(var pathToCount in _patch.visitCounts) | |
ApplyCountChanges(pathToCount.Key, pathToCount.Value, isVisit:true); | |
foreach (var pathToIndex in _patch.turnIndices) | |
ApplyCountChanges(pathToIndex.Key, pathToIndex.Value, isVisit:false); | |
_patch = null; | |
} | |
void ApplyCountChanges(Container container, int newCount, bool isVisit) | |
{ | |
var counts = isVisit ? _visitCounts : _turnIndices; | |
counts[container.path.ToString()] = newCount; | |
} | |
void WriteJson(SimpleJson.Writer writer) | |
{ | |
writer.WriteObjectStart(); | |
// Flows | |
writer.WritePropertyStart("flows"); | |
writer.WriteObjectStart(); | |
// Multi-flow | |
if( _namedFlows != null ) { | |
foreach(var namedFlow in _namedFlows) { | |
writer.WriteProperty(namedFlow.Key, namedFlow.Value.WriteJson); | |
} | |
} | |
// Single flow | |
else { | |
writer.WriteProperty(_currentFlow.name, _currentFlow.WriteJson); | |
} | |
writer.WriteObjectEnd(); | |
writer.WritePropertyEnd(); // end of flows | |
writer.WriteProperty("currentFlowName", _currentFlow.name); | |
writer.WriteProperty("variablesState", variablesState.WriteJson); | |
writer.WriteProperty("evalStack", w => Json.WriteListRuntimeObjs(w, evaluationStack)); | |
if (!divertedPointer.isNull) | |
writer.WriteProperty("currentDivertTarget", divertedPointer.path.componentsString); | |
writer.WriteProperty("visitCounts", w => Json.WriteIntDictionary(w, _visitCounts)); | |
writer.WriteProperty("turnIndices", w => Json.WriteIntDictionary(w, _turnIndices)); | |
writer.WriteProperty("turnIdx", currentTurnIndex); | |
writer.WriteProperty("storySeed", storySeed); | |
writer.WriteProperty("previousRandom", previousRandom); | |
writer.WriteProperty("inkSaveVersion", kInkSaveStateVersion); | |
// Not using this right now, but could do in future. | |
writer.WriteProperty("inkFormatVersion", Story.inkVersionCurrent); | |
writer.WriteObjectEnd(); | |
} | |
void LoadJsonObj(Dictionary<string, object> jObject) | |
{ | |
object jSaveVersion = null; | |
if (!jObject.TryGetValue("inkSaveVersion", out jSaveVersion)) { | |
throw new Exception ("ink save format incorrect, can't load."); | |
} | |
else if ((int)jSaveVersion < kMinCompatibleLoadVersion) { | |
throw new Exception("Ink save format isn't compatible with the current version (saw '"+jSaveVersion+"', but minimum is "+kMinCompatibleLoadVersion+"), so can't load."); | |
} | |
// Flows: Always exists in latest format (even if there's just one default) | |
// but this dictionary doesn't exist in prev format | |
object flowsObj = null; | |
if (jObject.TryGetValue("flows", out flowsObj)) { | |
var flowsObjDict = (Dictionary<string, object>)flowsObj; | |
// Single default flow | |
if( flowsObjDict.Count == 1 ) | |
_namedFlows = null; | |
// Multi-flow, need to create flows dict | |
else if( _namedFlows == null ) | |
_namedFlows = new Dictionary<string, Flow>(); | |
// Multi-flow, already have a flows dict | |
else | |
_namedFlows.Clear(); | |
// Load up each flow (there may only be one) | |
foreach(var namedFlowObj in flowsObjDict) { | |
var name = namedFlowObj.Key; | |
var flowObj = (Dictionary<string, object>)namedFlowObj.Value; | |
// Load up this flow using JSON data | |
var flow = new Flow(name, story, flowObj); | |
if( flowsObjDict.Count == 1 ) { | |
_currentFlow = new Flow(name, story, flowObj); | |
} else { | |
_namedFlows[name] = flow; | |
} | |
} | |
if( _namedFlows != null && _namedFlows.Count > 1 ) { | |
var currFlowName = (string)jObject["currentFlowName"]; | |
_currentFlow = _namedFlows[currFlowName]; | |
} | |
} | |
// Old format: individually load up callstack, output stream, choices in current/default flow | |
else { | |
_namedFlows = null; | |
_currentFlow.name = kDefaultFlowName; | |
_currentFlow.callStack.SetJsonToken ((Dictionary < string, object > )jObject ["callstackThreads"], story); | |
_currentFlow.outputStream = Json.JArrayToRuntimeObjList ((List<object>)jObject ["outputStream"]); | |
_currentFlow.currentChoices = Json.JArrayToRuntimeObjList<Choice>((List<object>)jObject ["currentChoices"]); | |
object jChoiceThreadsObj = null; | |
jObject.TryGetValue("choiceThreads", out jChoiceThreadsObj); | |
_currentFlow.LoadFlowChoiceThreads((Dictionary<string, object>)jChoiceThreadsObj, story); | |
} | |
OutputStreamDirty(); | |
_aliveFlowNamesDirty = true; | |
variablesState.SetJsonToken((Dictionary < string, object> )jObject["variablesState"]); | |
variablesState.callStack = _currentFlow.callStack; | |
evaluationStack = Json.JArrayToRuntimeObjList ((List<object>)jObject ["evalStack"]); | |
object currentDivertTargetPath; | |
if (jObject.TryGetValue("currentDivertTarget", out currentDivertTargetPath)) { | |
var divertPath = new Path (currentDivertTargetPath.ToString ()); | |
divertedPointer = story.PointerAtPath (divertPath); | |
} | |
_visitCounts = Json.JObjectToIntDictionary((Dictionary<string, object>)jObject["visitCounts"]); | |
_turnIndices = Json.JObjectToIntDictionary((Dictionary<string, object>)jObject["turnIndices"]); | |
currentTurnIndex = (int)jObject ["turnIdx"]; | |
storySeed = (int)jObject ["storySeed"]; | |
// Not optional, but bug in inkjs means it's actually missing in inkjs saves | |
object previousRandomObj = null; | |
if( jObject.TryGetValue("previousRandom", out previousRandomObj) ) { | |
previousRandom = (int)previousRandomObj; | |
} else { | |
previousRandom = 0; | |
} | |
} | |
public void ResetErrors() | |
{ | |
currentErrors = null; | |
currentWarnings = null; | |
} | |
public void ResetOutput(List<Runtime.Object> objs = null) | |
{ | |
outputStream.Clear (); | |
if( objs != null ) outputStream.AddRange (objs); | |
OutputStreamDirty(); | |
} | |
// Push to output stream, but split out newlines in text for consistency | |
// in dealing with them later. | |
public void PushToOutputStream(Runtime.Object obj) | |
{ | |
var text = obj as StringValue; | |
if (text) { | |
var listText = TrySplittingHeadTailWhitespace (text); | |
if (listText != null) { | |
foreach (var textObj in listText) { | |
PushToOutputStreamIndividual (textObj); | |
} | |
OutputStreamDirty(); | |
return; | |
} | |
} | |
PushToOutputStreamIndividual (obj); | |
OutputStreamDirty(); | |
} | |
public void PopFromOutputStream (int count) | |
{ | |
outputStream.RemoveRange (outputStream.Count - count, count); | |
OutputStreamDirty (); | |
} | |
// At both the start and the end of the string, split out the new lines like so: | |
// | |
// " \n \n \n the string \n is awesome \n \n " | |
// ^-----------^ ^-------^ | |
// | |
// Excess newlines are converted into single newlines, and spaces discarded. | |
// Outside spaces are significant and retained. "Interior" newlines within | |
// the main string are ignored, since this is for the purpose of gluing only. | |
// | |
// - If no splitting is necessary, null is returned. | |
// - A newline on its own is returned in a list for consistency. | |
List<Runtime.StringValue> TrySplittingHeadTailWhitespace(Runtime.StringValue single) | |
{ | |
string str = single.value; | |
int headFirstNewlineIdx = -1; | |
int headLastNewlineIdx = -1; | |
for (int i = 0; i < str.Length; i++) { | |
char c = str [i]; | |
if (c == '\n') { | |
if (headFirstNewlineIdx == -1) | |
headFirstNewlineIdx = i; | |
headLastNewlineIdx = i; | |
} | |
else if (c == ' ' || c == '\t') | |
continue; | |
else | |
break; | |
} | |
int tailLastNewlineIdx = -1; | |
int tailFirstNewlineIdx = -1; | |
for (int i = str.Length-1; i >= 0; i--) { | |
char c = str [i]; | |
if (c == '\n') { | |
if (tailLastNewlineIdx == -1) | |
tailLastNewlineIdx = i; | |
tailFirstNewlineIdx = i; | |
} | |
else if (c == ' ' || c == '\t') | |
continue; | |
else | |
break; | |
} | |
// No splitting to be done? | |
if (headFirstNewlineIdx == -1 && tailLastNewlineIdx == -1) | |
return null; | |
var listTexts = new List<Runtime.StringValue> (); | |
int innerStrStart = 0; | |
int innerStrEnd = str.Length; | |
if (headFirstNewlineIdx != -1) { | |
if (headFirstNewlineIdx > 0) { | |
var leadingSpaces = new StringValue (str.Substring (0, headFirstNewlineIdx)); | |
listTexts.Add(leadingSpaces); | |
} | |
listTexts.Add (new StringValue ("\n")); | |
innerStrStart = headLastNewlineIdx + 1; | |
} | |
if (tailLastNewlineIdx != -1) { | |
innerStrEnd = tailFirstNewlineIdx; | |
} | |
if (innerStrEnd > innerStrStart) { | |
var innerStrText = str.Substring (innerStrStart, innerStrEnd - innerStrStart); | |
listTexts.Add (new StringValue (innerStrText)); | |
} | |
if (tailLastNewlineIdx != -1 && tailFirstNewlineIdx > headLastNewlineIdx) { | |
listTexts.Add (new StringValue ("\n")); | |
if (tailLastNewlineIdx < str.Length - 1) { | |
int numSpaces = (str.Length - tailLastNewlineIdx) - 1; | |
var trailingSpaces = new StringValue (str.Substring (tailLastNewlineIdx + 1, numSpaces)); | |
listTexts.Add(trailingSpaces); | |
} | |
} | |
return listTexts; | |
} | |
void PushToOutputStreamIndividual(Runtime.Object obj) | |
{ | |
var glue = obj as Runtime.Glue; | |
var text = obj as Runtime.StringValue; | |
bool includeInOutput = true; | |
// New glue, so chomp away any whitespace from the end of the stream | |
if (glue) { | |
TrimNewlinesFromOutputStream(); | |
includeInOutput = true; | |
} | |
// New text: do we really want to append it, if it's whitespace? | |
// Two different reasons for whitespace to be thrown away: | |
// - Function start/end trimming | |
// - User defined glue: <> | |
// We also need to know when to stop trimming, when there's non-whitespace. | |
else if( text ) { | |
// Where does the current function call begin? | |
var functionTrimIndex = -1; | |
var currEl = callStack.currentElement; | |
if (currEl.type == PushPopType.Function) { | |
functionTrimIndex = currEl.functionStartInOuputStream; | |
} | |
// Do 2 things: | |
// - Find latest glue | |
// - Check whether we're in the middle of string evaluation | |
// If we're in string eval within the current function, we | |
// don't want to trim back further than the length of the current string. | |
int glueTrimIndex = -1; | |
for (int i = outputStream.Count - 1; i >= 0; i--) { | |
var o = outputStream [i]; | |
var c = o as ControlCommand; | |
var g = o as Glue; | |
// Find latest glue | |
if (g) { | |
glueTrimIndex = i; | |
break; | |
} | |
// Don't function-trim past the start of a string evaluation section | |
else if (c && c.commandType == ControlCommand.CommandType.BeginString) { | |
if (i >= functionTrimIndex) { | |
functionTrimIndex = -1; | |
} | |
break; | |
} | |
} | |
// Where is the most agressive (earliest) trim point? | |
var trimIndex = -1; | |
if (glueTrimIndex != -1 && functionTrimIndex != -1) | |
trimIndex = Math.Min (functionTrimIndex, glueTrimIndex); | |
else if (glueTrimIndex != -1) | |
trimIndex = glueTrimIndex; | |
else | |
trimIndex = functionTrimIndex; | |
// So, are we trimming then? | |
if (trimIndex != -1) { | |
// While trimming, we want to throw all newlines away, | |
// whether due to glue or the start of a function | |
if (text.isNewline) { | |
includeInOutput = false; | |
} | |
// Able to completely reset when normal text is pushed | |
else if (text.isNonWhitespace) { | |
if( glueTrimIndex > -1 ) | |
RemoveExistingGlue (); | |
// Tell all functions in callstack that we have seen proper text, | |
// so trimming whitespace at the start is done. | |
if (functionTrimIndex > -1) { | |
var callstackElements = callStack.elements; | |
for (int i = callstackElements.Count - 1; i >= 0; i--) { | |
var el = callstackElements [i]; | |
if (el.type == PushPopType.Function) { | |
el.functionStartInOuputStream = -1; | |
} else { | |
break; | |
} | |
} | |
} | |
} | |
} | |
// De-duplicate newlines, and don't ever lead with a newline | |
else if (text.isNewline) { | |
if (outputStreamEndsInNewline || !outputStreamContainsContent) | |
includeInOutput = false; | |
} | |
} | |
if (includeInOutput) { | |
outputStream.Add (obj); | |
OutputStreamDirty(); | |
} | |
} | |
void TrimNewlinesFromOutputStream() | |
{ | |
int removeWhitespaceFrom = -1; | |
// Work back from the end, and try to find the point where | |
// we need to start removing content. | |
// - Simply work backwards to find the first newline in a string of whitespace | |
// e.g. This is the content \n \n\n | |
// ^---------^ whitespace to remove | |
// ^--- first while loop stops here | |
int i = outputStream.Count-1; | |
while (i >= 0) { | |
var obj = outputStream [i]; | |
var cmd = obj as ControlCommand; | |
var txt = obj as StringValue; | |
if (cmd || (txt && txt.isNonWhitespace)) { | |
break; | |
} | |
else if (txt && txt.isNewline) { | |
removeWhitespaceFrom = i; | |
} | |
i--; | |
} | |
// Remove the whitespace | |
if (removeWhitespaceFrom >= 0) { | |
i=removeWhitespaceFrom; | |
while(i < outputStream.Count) { | |
var text = outputStream [i] as StringValue; | |
if (text) { | |
outputStream.RemoveAt (i); | |
} else { | |
i++; | |
} | |
} | |
} | |
OutputStreamDirty(); | |
} | |
// Only called when non-whitespace is appended | |
void RemoveExistingGlue() | |
{ | |
for (int i = outputStream.Count - 1; i >= 0; i--) { | |
var c = outputStream [i]; | |
if (c is Glue) { | |
outputStream.RemoveAt (i); | |
} else if( c is ControlCommand ) { // e.g. BeginString | |
break; | |
} | |
} | |
OutputStreamDirty(); | |
} | |
public bool outputStreamEndsInNewline { | |
get { | |
if (outputStream.Count > 0) { | |
for (int i = outputStream.Count - 1; i >= 0; i--) { | |
var obj = outputStream [i]; | |
if (obj is ControlCommand) // e.g. BeginString | |
break; | |
var text = outputStream [i] as StringValue; | |
if (text) { | |
if (text.isNewline) | |
return true; | |
else if (text.isNonWhitespace) | |
break; | |
} | |
} | |
} | |
return false; | |
} | |
} | |
public bool outputStreamContainsContent { | |
get { | |
foreach (var content in outputStream) { | |
if (content is StringValue) | |
return true; | |
} | |
return false; | |
} | |
} | |
public bool inStringEvaluation { | |
get { | |
for (int i = outputStream.Count - 1; i >= 0; i--) { | |
var cmd = outputStream [i] as ControlCommand; | |
if (cmd && cmd.commandType == ControlCommand.CommandType.BeginString) { | |
return true; | |
} | |
} | |
return false; | |
} | |
} | |
public void PushEvaluationStack(Runtime.Object obj) | |
{ | |
// Include metadata about the origin List for list values when | |
// they're used, so that lower level functions can make use | |
// of the origin list to get related items, or make comparisons | |
// with the integer values etc. | |
var listValue = obj as ListValue; | |
if (listValue) { | |
// Update origin when list is has something to indicate the list origin | |
var rawList = listValue.value; | |
if (rawList.originNames != null) { | |
if( rawList.origins == null ) rawList.origins = new List<ListDefinition>(); | |
rawList.origins.Clear(); | |
foreach (var n in rawList.originNames) { | |
ListDefinition def = null; | |
story.listDefinitions.TryListGetDefinition (n, out def); | |
if( !rawList.origins.Contains(def) ) | |
rawList.origins.Add (def); | |
} | |
} | |
} | |
evaluationStack.Add(obj); | |
} | |
public Runtime.Object PopEvaluationStack() | |
{ | |
var obj = evaluationStack [evaluationStack.Count - 1]; | |
evaluationStack.RemoveAt (evaluationStack.Count - 1); | |
return obj; | |
} | |
public Runtime.Object PeekEvaluationStack() | |
{ | |
return evaluationStack [evaluationStack.Count - 1]; | |
} | |
public List<Runtime.Object> PopEvaluationStack(int numberOfObjects) | |
{ | |
if(numberOfObjects > evaluationStack.Count) { | |
throw new System.Exception ("trying to pop too many objects"); | |
} | |
var popped = evaluationStack.GetRange (evaluationStack.Count - numberOfObjects, numberOfObjects); | |
evaluationStack.RemoveRange (evaluationStack.Count - numberOfObjects, numberOfObjects); | |
return popped; | |
} | |
/// <summary> | |
/// Ends the current ink flow, unwrapping the callstack but without | |
/// affecting any variables. Useful if the ink is (say) in the middle | |
/// a nested tunnel, and you want it to reset so that you can divert | |
/// elsewhere using ChoosePathString(). Otherwise, after finishing | |
/// the content you diverted to, it would continue where it left off. | |
/// Calling this is equivalent to calling -> END in ink. | |
/// </summary> | |
public void ForceEnd() | |
{ | |
callStack.Reset(); | |
_currentFlow.currentChoices.Clear(); | |
currentPointer = Pointer.Null; | |
previousPointer = Pointer.Null; | |
didSafeExit = true; | |
} | |
// Add the end of a function call, trim any whitespace from the end. | |
// We always trim the start and end of the text that a function produces. | |
// The start whitespace is discard as it is generated, and the end | |
// whitespace is trimmed in one go here when we pop the function. | |
void TrimWhitespaceFromFunctionEnd () | |
{ | |
Debug.Assert (callStack.currentElement.type == PushPopType.Function); | |
var functionStartPoint = callStack.currentElement.functionStartInOuputStream; | |
// If the start point has become -1, it means that some non-whitespace | |
// text has been pushed, so it's safe to go as far back as we're able. | |
if (functionStartPoint == -1) { | |
functionStartPoint = 0; | |
} | |
// Trim whitespace from END of function call | |
for (int i = outputStream.Count - 1; i >= functionStartPoint; i--) { | |
var obj = outputStream [i]; | |
var txt = obj as StringValue; | |
var cmd = obj as ControlCommand; | |
if (!txt) continue; | |
if (cmd) break; | |
if (txt.isNewline || txt.isInlineWhitespace) { | |
outputStream.RemoveAt (i); | |
OutputStreamDirty (); | |
} else { | |
break; | |
} | |
} | |
} | |
public void PopCallstack (PushPopType? popType = null) | |
{ | |
// Add the end of a function call, trim any whitespace from the end. | |
if (callStack.currentElement.type == PushPopType.Function) | |
TrimWhitespaceFromFunctionEnd (); | |
callStack.Pop (popType); | |
} | |
// Don't make public since the method need to be wrapped in Story for visit counting | |
public void SetChosenPath(Path path, bool incrementingTurnIndex) | |
{ | |
// Changing direction, assume we need to clear current set of choices | |
_currentFlow.currentChoices.Clear (); | |
var newPointer = story.PointerAtPath (path); | |
if (!newPointer.isNull && newPointer.index == -1) | |
newPointer.index = 0; | |
currentPointer = newPointer; | |
if( incrementingTurnIndex ) | |
currentTurnIndex++; | |
} | |
public void StartFunctionEvaluationFromGame (Container funcContainer, params object[] arguments) | |
{ | |
callStack.Push (PushPopType.FunctionEvaluationFromGame, evaluationStack.Count); | |
callStack.currentElement.currentPointer = Pointer.StartOf (funcContainer); | |
PassArgumentsToEvaluationStack (arguments); | |
} | |
public void PassArgumentsToEvaluationStack (params object [] arguments) | |
{ | |
// Pass arguments onto the evaluation stack | |
if (arguments != null) { | |
for (int i = 0; i < arguments.Length; i++) { | |
if (!(arguments [i] is int || arguments [i] is float || arguments [i] is string || arguments [i] is bool || arguments [i] is InkList)) { | |
throw new System.ArgumentException ("ink arguments when calling EvaluateFunction / ChoosePathStringWithParameters must be int, float, string, bool or InkList. Argument was "+(arguments [i] == null ? "null" : arguments [i].GetType().Name)); | |
} | |
PushEvaluationStack (Runtime.Value.Create (arguments [i])); | |
} | |
} | |
} | |
public bool TryExitFunctionEvaluationFromGame () | |
{ | |
if( callStack.currentElement.type == PushPopType.FunctionEvaluationFromGame ) { | |
currentPointer = Pointer.Null; | |
didSafeExit = true; | |
return true; | |
} | |
return false; | |
} | |
public object CompleteFunctionEvaluationFromGame () | |
{ | |
if (callStack.currentElement.type != PushPopType.FunctionEvaluationFromGame) { | |
throw new Exception ("Expected external function evaluation to be complete. Stack trace: "+callStack.callStackTrace); | |
} | |
int originalEvaluationStackHeight = callStack.currentElement.evaluationStackHeightWhenPushed; | |
// Do we have a returned value? | |
// Potentially pop multiple values off the stack, in case we need | |
// to clean up after ourselves (e.g. caller of EvaluateFunction may | |
// have passed too many arguments, and we currently have no way to check for that) | |
Runtime.Object returnedObj = null; | |
while (evaluationStack.Count > originalEvaluationStackHeight) { | |
var poppedObj = PopEvaluationStack (); | |
if (returnedObj == null) | |
returnedObj = poppedObj; | |
} | |
// Finally, pop the external function evaluation | |
PopCallstack (PushPopType.FunctionEvaluationFromGame); | |
// What did we get back? | |
if (returnedObj) { | |
if (returnedObj is Runtime.Void) | |
return null; | |
// Some kind of value, if not void | |
var returnVal = returnedObj as Runtime.Value; | |
// DivertTargets get returned as the string of components | |
// (rather than a Path, which isn't public) | |
if (returnVal.valueType == ValueType.DivertTarget) { | |
return returnVal.valueObject.ToString (); | |
} | |
// Other types can just have their exact object type: | |
// int, float, string. VariablePointers get returned as strings. | |
return returnVal.valueObject; | |
} | |
return null; | |
} | |
public void AddError(string message, bool isWarning) | |
{ | |
if (!isWarning) { | |
if (currentErrors == null) currentErrors = new List<string> (); | |
currentErrors.Add (message); | |
} else { | |
if (currentWarnings == null) currentWarnings = new List<string> (); | |
currentWarnings.Add (message); | |
} | |
} | |
void OutputStreamDirty() | |
{ | |
_outputStreamTextDirty = true; | |
_outputStreamTagsDirty = true; | |
} | |
// REMEMBER! REMEMBER! REMEMBER! | |
// When adding state, update the Copy method and serialisation | |
// REMEMBER! REMEMBER! REMEMBER! | |
Dictionary<string, int> _visitCounts; | |
Dictionary<string, int> _turnIndices; | |
bool _outputStreamTextDirty = true; | |
bool _outputStreamTagsDirty = true; | |
StatePatch _patch; | |
Flow _currentFlow; | |
Dictionary<string, Flow> _namedFlows; | |
const string kDefaultFlowName = "DEFAULT_FLOW"; | |
bool _aliveFlowNamesDirty = true; | |
} | |
} | |