691 lines
19 KiB
Haxe
691 lines
19 KiB
Haxe
package hank;
|
|
|
|
using Type;
|
|
|
|
import hank.HankBuffer;
|
|
|
|
using StringTools;
|
|
|
|
import haxe.ds.Option;
|
|
|
|
using hank.Extensions;
|
|
using HankAST.ASTExtension;
|
|
|
|
import hank.Choice;
|
|
import hank.Choice.ChoiceInfo;
|
|
import hank.Choice.FallbackChoiceInfo;
|
|
using Choice.ChoiceExtension;
|
|
|
|
import hank.HankAST.ExprType;
|
|
import hank.StoryTree;
|
|
import hank.Alt.AltInstance;
|
|
|
|
/**
|
|
Possible states of the story being executed.
|
|
**/
|
|
enum StoryFrame {
|
|
HasText(text:String);
|
|
HasChoices(choices:Array<String>);
|
|
Finished;
|
|
}
|
|
|
|
typedef InsertionHook = Dynamic->String;
|
|
|
|
enum EmbedMode {
|
|
/**
|
|
The current embedded Stories are to be executed sequentially until they return Finished
|
|
*/
|
|
Tunnel;
|
|
|
|
/**
|
|
The current embedded Stories are to be executed sequentially until they return HasChoices. Choices will be aggregated into a single frame
|
|
*/
|
|
Thread;
|
|
}
|
|
|
|
/**
|
|
Runtime interpreter for Hank stories.
|
|
**/
|
|
@:allow(hank.StoryTestCase)
|
|
class Story {
|
|
var hInterface:HInterface;
|
|
|
|
public var insertionHooks:Map<String, InsertionHook>;
|
|
|
|
var random:Random;
|
|
|
|
var ast:HankAST;
|
|
var exprIndex:Int;
|
|
|
|
var storyTree:StoryNode;
|
|
var viewCounts:Map<StoryNode, Int>;
|
|
var nodeScopes:Array<StoryNode>;
|
|
var altInstances:Map<Alt, AltInstance> = new Map();
|
|
|
|
var parser:Parser;
|
|
var embedMode:EmbedMode = Tunnel;
|
|
var embeddedBlocks:Array<Story> = [];
|
|
var parent:Option<Story> = None;
|
|
|
|
var choicesTaken:Array<Int> = [];
|
|
var weaveDepth = 0;
|
|
|
|
var storedFrame:Option<StoryFrame> = None;
|
|
|
|
function new(r:Random, p:Parser, ast:HankAST, st:StoryNode, sc:Array<StoryNode>, vc:Map<StoryNode, Int>, hi:HInterface) {
|
|
this.insertionHooks = new Map();
|
|
|
|
this.random = r;
|
|
this.parser = p;
|
|
this.ast = ast;
|
|
this.storyTree = st;
|
|
this.nodeScopes = sc;
|
|
this.viewCounts = vc;
|
|
this.hInterface = hi;
|
|
}
|
|
|
|
function currentFile() {
|
|
return ast[0].position.file;
|
|
}
|
|
|
|
function embeddedStory(h:String):Story {
|
|
var ast = parser.parseString(h);
|
|
|
|
var story = new Story(random, // embedded stories must continue giving deterministic random numbers without resetting -- to avoid exploitable behavior
|
|
|
|
parser, ast, // embedded stories have their OWN AST of Hank statements
|
|
|
|
// but they keep the parent's view count tree and current scope
|
|
storyTree,
|
|
nodeScopes, viewCounts, hInterface);
|
|
story.exprIndex = 0;
|
|
story.parent = Some(this);
|
|
return story;
|
|
}
|
|
|
|
function storyFork(t:String,readonly:Bool=false):Story {
|
|
// Everything is the same as when embedding blocks, but a fork uses the same AST as its parent -- simply starting after a hypothetical divert
|
|
var story = new Story(random, parser, this.ast, storyTree, nodeScopes,
|
|
if (readonly) storyTree.createViewCounts() else viewCounts, hInterface);
|
|
// Just trust me that in Tunneling mode, the embedded stories don't need a parent. This is because I was too lazy to disambiguate tunnel mode from embedded mode.
|
|
if (embedMode == Thread)
|
|
story.parent = Some(this);
|
|
// trace('story parent: ${story.parent.match(Some(_))}');
|
|
story.divertTo(t);
|
|
return story;
|
|
}
|
|
|
|
public static function FromAST(script:String, ast:HankAST, ?randomSeed:Int):Story {
|
|
var random = new Random(randomSeed);
|
|
var storyTree = StoryNode.FromAST(ast);
|
|
var nodeScopes = [storyTree];
|
|
var viewCounts = storyTree.createViewCounts();
|
|
|
|
var hInterface = new HInterface(storyTree, viewCounts);
|
|
|
|
var story = new Story(random, new Parser(), ast, storyTree, nodeScopes, viewCounts, hInterface);
|
|
hInterface.setStory(story);
|
|
hInterface.addVariable('story', story);
|
|
|
|
story.runRootIncludedHaxe(script);
|
|
story.exprIndex = ast.findFile(script);
|
|
return story;
|
|
}
|
|
|
|
public function addVariable(name:String, val:Dynamic) {
|
|
hInterface.addVariable(name, val);
|
|
}
|
|
|
|
public static function FromFile(script:String, ?files:PreloadedFiles, ?randomSeed:Int):Story {
|
|
var parser = new Parser();
|
|
var ast = parser.parseFile(script, files);
|
|
return Story.FromAST(script, ast, randomSeed);
|
|
}
|
|
|
|
/* Go through each included file executing all Haxe embedded at root level */
|
|
private function runRootIncludedHaxe(rootFile:String) {
|
|
var i = 0;
|
|
while (i < ast.findFile(rootFile)) {
|
|
var file = ast[i].position.file;
|
|
switch (ast[i].expr) {
|
|
case EHaxeLine(h) | EHaxeBlock(h):
|
|
hInterface.runEmbeddedHaxe(h, nodeScopes);
|
|
i += 1;
|
|
default:
|
|
i = ast.findEOF(file) + 1;
|
|
}
|
|
}
|
|
// TODO when parsing an included file, make sure the first line that isn't embedded haxe (block or line form) is a Knot
|
|
}
|
|
|
|
public function nextFrame():StoryFrame {
|
|
switch (storedFrame) {
|
|
case Some(f):
|
|
storedFrame = None;
|
|
return f;
|
|
default:
|
|
}
|
|
|
|
// trace (embeddedBlocks.length);
|
|
// trace (embedMode);
|
|
while (embeddedBlocks.length > 0) {
|
|
switch (embedMode) {
|
|
case Tunnel:
|
|
var nf = embeddedBlocks[0].nextFrame();
|
|
if (nf == Finished) {
|
|
embeddedBlocks.remove(embeddedBlocks[0]);
|
|
} else {
|
|
return nf;
|
|
}
|
|
case Thread:
|
|
var idx = 0;
|
|
while (idx < embeddedBlocks.length) {
|
|
var nf = embeddedBlocks[idx].nextFrame();
|
|
switch (nf) {
|
|
case HasChoices(_) | Finished:
|
|
// trace('Hit end of flow for thread $idx');
|
|
idx++;
|
|
// exprIndex++;
|
|
continue;
|
|
default:
|
|
return nf;
|
|
}
|
|
}
|
|
|
|
// All of the threaded blocks are out of content, so follow the original fork until it also runs out of flow.
|
|
break;
|
|
}
|
|
}
|
|
if (exprIndex >= ast.length) {
|
|
if (embedMode == Thread) {
|
|
// trace('Warning: Hit EOF while threading (are you sure you meant to do that?)');
|
|
return nextChoiceFrame();
|
|
}
|
|
return Finished;
|
|
}
|
|
|
|
// trace('It fell to the roots next expr: ${ast[exprIndex].expr}');
|
|
var rootNf = processExpr(ast[exprIndex].expr);
|
|
|
|
// if (parent == None) trace('root frame: $rootNf');
|
|
|
|
if (embedMode == Thread) {
|
|
if (rootNf == Finished) {
|
|
return nextChoiceFrame();
|
|
}
|
|
}
|
|
|
|
return rootNf;
|
|
}
|
|
|
|
private function processExpr(expr:ExprType):StoryFrame {
|
|
switch (expr) {
|
|
case EOutput(output):
|
|
exprIndex += 1;
|
|
var text = output.format(this, hInterface, random, altInstances, nodeScopes, false).trim();
|
|
// We need to check if the next expr is a divert or thread because it might lead to a pre-glued output
|
|
|
|
if (exprIndex < ast.length) {
|
|
var nextExpr = ast[exprIndex].expr;
|
|
var nextIdx = exprIndex;
|
|
switch (nextExpr) {
|
|
case EDivert(targets):
|
|
nextIdx = indexOf(targets[0]);
|
|
case EThread(target):
|
|
nextIdx = indexOf(target);
|
|
default:
|
|
}
|
|
if (nextIdx < ast.length)
|
|
nextExpr = ast[nextIdx].expr;
|
|
// trace(nextExpr);
|
|
switch (nextExpr) {
|
|
case EGather(_, _, exp) | ETagged(EGather(_,_,exp), _):
|
|
nextExpr = exp;
|
|
default:
|
|
}
|
|
switch (nextExpr) {
|
|
case EOutput(output) | ETagged(EOutput(output), _):
|
|
if (output.startsWithGlue())
|
|
text = Output.appendNextText(this, text + " ", Output.GLUE_ERROR);
|
|
default:
|
|
}
|
|
}
|
|
|
|
return finalTextProcessing(text);
|
|
case EHaxeLine(h):
|
|
exprIndex += 1;
|
|
|
|
hInterface.runEmbeddedHaxe(h, nodeScopes);
|
|
return nextFrame();
|
|
case EHaxeBlock(h):
|
|
exprIndex += 1;
|
|
hInterface.runEmbeddedHaxe(h, nodeScopes);
|
|
return nextFrame();
|
|
// Fallback choices simply advance flow using divert syntax by not specifying a target
|
|
case EDivert([""]):
|
|
exprIndex += 1;
|
|
|
|
// The most common form of divert is to one other location.
|
|
case EDivert([oneTarget]):
|
|
divertTo(oneTarget);
|
|
|
|
return nextFrame();
|
|
|
|
// Tunneling statements!
|
|
case EDivert(targets):
|
|
switch (targets.pop()) {
|
|
case target if (target != ''):
|
|
// If the last target isn't empty, we want to fork the main story to start at that point once the tunnels are done.
|
|
// trace('this divert');
|
|
divertTo(target);
|
|
case '':
|
|
exprIndex++;
|
|
case null:
|
|
throw 'No divert targets!';
|
|
}
|
|
|
|
// Spawn the rest of the forks in tunneling mode
|
|
for (target in targets) {
|
|
// trace('embedded $target');
|
|
var fork = storyFork(target);
|
|
// trace(fork != null);
|
|
embeddedBlocks.push(fork);
|
|
// trace(embeddedBlocks.length);
|
|
}
|
|
|
|
// trace(embeddedBlocks.length);
|
|
|
|
return nextFrame();
|
|
|
|
case EThread(target):
|
|
// The thread only needs to be added once
|
|
exprIndex++;
|
|
embedMode = Thread;
|
|
// trace('before: ${embeddedBlocks.length}');
|
|
embeddedBlocks.push(storyFork(target));
|
|
// trace('after: ${embeddedBlocks.length}');
|
|
// ^ These before/after comments help diagnose whether divert() is erasing the embedded blocks before they can start
|
|
// trace ('starting thread $target');
|
|
var nf = nextFrame();
|
|
// trace('frame immediately after: $nf');
|
|
return nf;
|
|
|
|
case EGather(label, depth, nextExpr):
|
|
// gathers need to update their view counts
|
|
switch (label) {
|
|
case Some(l):
|
|
var node = resolveNodeInScope(l)[0];
|
|
viewCounts[node] += 1;
|
|
case None:
|
|
}
|
|
weaveDepth = depth;
|
|
return processExpr(nextExpr);
|
|
|
|
case EChoice(choice):
|
|
if (choice.depth > weaveDepth) {
|
|
weaveDepth = choice.depth;
|
|
} else if (choice.depth < weaveDepth) {
|
|
gotoNextGather();
|
|
return nextFrame();
|
|
}
|
|
|
|
return nextChoiceFrame();
|
|
|
|
default:
|
|
trace('$expr is not implemented');
|
|
return Finished;
|
|
}
|
|
return Finished;
|
|
}
|
|
|
|
private function nextChoiceFrame() {
|
|
var optionsText = [
|
|
for (choiceInfo in availableChoices())
|
|
choiceInfo.choice.output.format(this, hInterface, random, altInstances, nodeScopes, false)
|
|
];
|
|
if (optionsText.length > 0) {
|
|
return finalChoiceProcessing(optionsText);
|
|
} else {
|
|
var fallback = fallbackChoice();
|
|
switch (fallback.choiceInfo.choice.divertTarget) {
|
|
case Some(t) if (t.length > 0):
|
|
var fallbackText = evaluateChoice(fallback.choiceInfo.choice);
|
|
if (fallbackText.length > 0) {
|
|
throw 'For some reason a fallback choice evaluated to text!';
|
|
}
|
|
return nextFrame();
|
|
default:
|
|
exprIndex = fallback.index + 1;
|
|
weaveDepth = fallback.choiceInfo.choice.depth + 1;
|
|
return nextFrame();
|
|
}
|
|
}
|
|
}
|
|
|
|
private function traceChoiceArray(choices:Array<ChoiceInfo>) {
|
|
for (choiceInfo in choices) {
|
|
trace('${choiceInfo.choice.toString()}: #${choiceInfo.tags.join(" #")}');
|
|
}
|
|
trace('---');
|
|
}
|
|
|
|
private function availableChoices():Array<ChoiceInfo> {
|
|
var choices = new Array<ChoiceInfo>();
|
|
|
|
// If we're threading, collect all the childrens' choices, too.
|
|
if (embedMode == Thread) {
|
|
var idx = 0;
|
|
for (thread in embeddedBlocks) {
|
|
choices = choices.concat(thread.availableChoices());
|
|
// trace('after fork $idx:');
|
|
// traceChoiceArray(choices);
|
|
idx++;
|
|
}
|
|
}
|
|
|
|
if (exprIndex < ast.length &&
|
|
(ast[exprIndex].expr.match(EChoice(_))
|
|
|| ast[exprIndex].expr.match(EGather(_, _, EChoice(_))))) {
|
|
var allChoiceInfo = ast.collectChoices(exprIndex, weaveDepth).choices;
|
|
for (choiceInfo in allChoiceInfo) {
|
|
if (choicesTaken.indexOf(choiceInfo.choice.id) == -1 || !choiceInfo.choice.onceOnly) {
|
|
switch (choiceInfo.choice.condition) {
|
|
case Some(expr):
|
|
if (!hInterface.cond(expr, nodeScopes)) {
|
|
continue;
|
|
}
|
|
case None:
|
|
}
|
|
if (!choiceInfo.choice.output.isEmpty()) {
|
|
choices.push(choiceInfo);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// trace('final:');
|
|
// traceChoiceArray(choices);
|
|
|
|
return choices;
|
|
}
|
|
|
|
private function fallbackChoice():FallbackChoiceInfo {
|
|
var choiceInfo = ast.collectChoices(exprIndex, weaveDepth);
|
|
var lastChoice = choiceInfo.choices[choiceInfo.choices.length - 1];
|
|
if (lastChoice.choice.output.isEmpty()) {
|
|
return {choiceInfo: lastChoice, index: choiceInfo.fallbackIndex};
|
|
} else {
|
|
throw 'there is no fallback choice!';
|
|
}
|
|
}
|
|
|
|
private function gotoNextGather() {
|
|
var gatherIndex = ast.findNextGather(currentFile(), exprIndex + 1, weaveDepth);
|
|
|
|
if (gatherIndex == -1) {
|
|
throw 'Ran out of choice content, and there is no gather';
|
|
}
|
|
|
|
exprIndex = gatherIndex;
|
|
}
|
|
|
|
@:allow(hank.HankInterp)
|
|
private function resolveNodeInScope(label:String, ?whichScope:Array<StoryNode>):Array<StoryNode> {
|
|
if (whichScope == null)
|
|
whichScope = nodeScopes;
|
|
|
|
// Resolve the target's first part from the deepest current scope outwards
|
|
var targetParts = label.split('.');
|
|
|
|
var newScopes = [];
|
|
for (i in 0...whichScope.length) {
|
|
var scope = whichScope[i];
|
|
switch (scope.resolve(targetParts[0])) {
|
|
case Some(node):
|
|
newScopes = whichScope.slice(i);
|
|
newScopes.insert(0, node);
|
|
// Then resolve the rest of the parts inward from there
|
|
for (part in targetParts.slice(1)) {
|
|
var scope = newScopes[0];
|
|
switch (scope.resolve(part)) {
|
|
case Some(innerNode):
|
|
newScopes.insert(0, innerNode);
|
|
case None:
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case None:
|
|
}
|
|
}
|
|
return newScopes;
|
|
}
|
|
|
|
|
|
private function resolveScopes(target:String) {
|
|
var newScopes = if (target.startsWith("@")) {
|
|
var parts = target.split('.');
|
|
|
|
var root:Array<StoryNode> = hInterface.getVariable(parts[0].substr(1));
|
|
if (parts.length > 1) {
|
|
var subTarget = parts.slice(1).join('.');
|
|
// trace(subTarget);
|
|
resolveNodeInScope(subTarget, root);
|
|
} else {
|
|
root;
|
|
};
|
|
} else { resolveNodeInScope(target); };
|
|
// trace('$target is $newScopes');
|
|
|
|
if (newScopes == null // happens when a divert target variable doesn't exist
|
|
|| newScopes.length == 0) // happens when target can't be resolved
|
|
throw 'Divert target not found: $target';
|
|
|
|
return newScopes;
|
|
}
|
|
|
|
/** Get the AST index of the given divert target, without diverting **/
|
|
private function indexOf(target:String) {
|
|
var disposableFork = storyFork(target,true);
|
|
// TODO this probably causes substantial wasting of memory (could lead to garbage collector problems?)
|
|
// trace('index of $target is ${disposableFork.exprIndex}/${ast.length}');
|
|
return disposableFork.exprIndex;
|
|
}
|
|
|
|
public function divertTo(target:String) {
|
|
// Don't try to divert to a fallback target
|
|
if (target.length == 0) {
|
|
return;
|
|
}
|
|
switch (parent) {
|
|
case Some(p) if (p.embedMode == Tunnel):
|
|
// A divert from inside embedded hank, must leave the embedded context
|
|
p.embeddedBlocks = [];
|
|
p.divertTo(target);
|
|
exprIndex = ast.length; // Must return finished
|
|
return;
|
|
default:
|
|
}
|
|
|
|
// trace('diverting to $target');
|
|
var newScopes = resolveScopes(target);
|
|
var targetIdx = newScopes[0].astIndex;
|
|
|
|
// update the expression index
|
|
exprIndex = targetIdx;
|
|
var target = newScopes[0];
|
|
weaveDepth = 0;
|
|
|
|
// Update the view count
|
|
switch (ast[exprIndex].expr) {
|
|
case EKnot(_):
|
|
// if it's a knot, increase its view count and increase index by one more
|
|
viewCounts[target] += 1;
|
|
|
|
exprIndex += 1;
|
|
// If a knot directly starts with a stitch, run it
|
|
switch (ast[exprIndex].expr) {
|
|
case EStitch(label):
|
|
var firstStitch = resolveNodeInScope(label, newScopes)[0];
|
|
viewCounts[firstStitch] += 1;
|
|
exprIndex += 1;
|
|
default:
|
|
}
|
|
weaveDepth = 0;
|
|
|
|
case EStitch(_):
|
|
// if it's a stitch, increase its view count
|
|
viewCounts[target] += 1;
|
|
|
|
var enclosingKnot = newScopes[1];
|
|
// If we weren't in the stitch's containing section before, increase its viewcount
|
|
if (nodeScopes.indexOf(enclosingKnot) == -1) {
|
|
viewCounts[enclosingKnot] += 1;
|
|
}
|
|
exprIndex += 1;
|
|
weaveDepth = 0;
|
|
|
|
case EChoice(c):
|
|
storedFrame = Some(finalTextProcessing(evaluateChoice(c)));
|
|
return;
|
|
|
|
// Choices and gathers update their own view counts
|
|
default:
|
|
}
|
|
// Update nodeScopes to point to the new scope
|
|
nodeScopes = newScopes;
|
|
}
|
|
|
|
public function choose(choiceIndex:Int):String {
|
|
var nf = nextFrame();
|
|
if (!nf.match(HasChoices(_))) {
|
|
throw 'Trying to make a choice when next frame is $nf';
|
|
}
|
|
// trace('choosing $choiceIndex');
|
|
// If tunnel-embedded, let the proper embedded section evaluate the choice
|
|
if (embeddedBlocks.length > 0 && embedMode == Tunnel) {
|
|
return embeddedBlocks[0].choose(choiceIndex);
|
|
} else {
|
|
// if not embedded, actually make the choice. availableChoices() accounts for aggregating threaded choices
|
|
var output = evaluateChoice(availableChoices()[choiceIndex].choice);
|
|
if (embedMode == Thread) {
|
|
embedMode = Tunnel;
|
|
embeddedBlocks = [];
|
|
}
|
|
|
|
return output;
|
|
}
|
|
}
|
|
|
|
function evaluateChoice(choice:Choice):String {
|
|
// if the choice has a label, increment its view count
|
|
switch (choice.label) {
|
|
case Some(l):
|
|
var node = switch (resolveNodeInScope(l)) {
|
|
case []:
|
|
// the choice is being diverted to, which means it's out of scope. Find it from the StoryTree's choice map another way
|
|
storyTree.nodeForChoice(choice.id);
|
|
|
|
case nodePath:
|
|
nodePath[0];
|
|
};
|
|
|
|
viewCounts[node] += 1;
|
|
case None:
|
|
}
|
|
weaveDepth = choice.depth + 1;
|
|
// if the choice is onceOnly, add its id to the shit list
|
|
if (choice.onceOnly) {
|
|
choicesTaken.push(choice.id);
|
|
}
|
|
|
|
switch (choice.divertTarget) {
|
|
case Some(t):
|
|
divertTo(t);
|
|
case None:
|
|
exprIndex = ast.indexOfChoice(choice.id) + 1;
|
|
}
|
|
|
|
var output = choice.output.format(this, hInterface, random, altInstances, nodeScopes, true);
|
|
return finalChoiceOutputProcessing(output);
|
|
}
|
|
|
|
/** Parse and run embedded Hank script on the fly. **/
|
|
public function runEmbeddedHank(h:String) {
|
|
embedMode = Tunnel;
|
|
embeddedBlocks.push(embeddedStory(h));
|
|
}
|
|
|
|
private function removeDoubleSpaces(t:String) {
|
|
var intermediate = t;
|
|
while (intermediate.indexOf(' ') != -1) {
|
|
intermediate = intermediate.replace(' ', ' ');
|
|
}
|
|
return intermediate;
|
|
}
|
|
|
|
private function finalTextProcessing(t:String) {
|
|
if (t.length > 0)
|
|
return HasText(removeDoubleSpaces(t).trim());
|
|
else
|
|
return nextFrame();
|
|
}
|
|
|
|
private function finalChoiceOutputProcessing(t:String) {
|
|
return removeDoubleSpaces(t).trim();
|
|
}
|
|
|
|
private function finalChoiceProcessing(choices:Array<String>) {
|
|
return HasChoices([for (c in choices) removeDoubleSpaces(c).trim()]);
|
|
}
|
|
|
|
/**
|
|
Classes and Enums can have dynamically specified behaviors for when they are embedded in Hank output.
|
|
*/
|
|
public function formatForInsertion(value:Dynamic):String {
|
|
if (value == null) {
|
|
throw 'Trying to format null for insertion!';
|
|
}
|
|
// Static extension syntax wasn't working here, probably because of type parameters
|
|
var c = Type.getClass(value);
|
|
var e = Type.getEnum(value);
|
|
var typeName = if (c != null) {
|
|
Type.getClassName(c);
|
|
} else if (e != null) {
|
|
Type.getEnumName(e);
|
|
} else {
|
|
null;
|
|
}
|
|
|
|
// trace(typeName);
|
|
if (typeName != null && insertionHooks.exists(typeName)) {
|
|
return insertionHooks[typeName](value);
|
|
}
|
|
|
|
// If no special hook is defined for the value to insert,
|
|
return Std.string(value);
|
|
}
|
|
|
|
public function run(
|
|
showText:(String,Void->Void)->Void,
|
|
showChoices:(Array<String>,Int->Void)->Void,
|
|
finish:Void->Void)
|
|
{
|
|
var loop = run.bind(showText, showChoices, finish);
|
|
switch (nextFrame()) {
|
|
case HasText(text):
|
|
showText(text, () -> {
|
|
loop();
|
|
});
|
|
case HasChoices(choices):
|
|
showChoices(choices, (choiceIndex) -> {
|
|
choose(choiceIndex);
|
|
loop();
|
|
});
|
|
case Finished:
|
|
finish();
|
|
}
|
|
}
|
|
}
|