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; } }