/* * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { advanceSectionLine, getLatestVisibleLineInSection, isSectionComplete, } from "./story-section" import type { InkEngine, VisualInkLineOrCommand, VisualInkSection, VisualInkState } from "../types" import { createVisualInkCommand, createVisualInkLine } from "./line" import { createVisualInkChoice } from "./choice" import { InkParserConfigParams } from "../config" import { CommandHandler } from "./command" import { InkStory } from "../inkTypes" import { refreshLineInSection, refreshSection, refreshSections } from "../utils/immutable-utils" // getters ///////////// export const getActiveSection = (visualInkState: VisualInkState) => { return visualInkState.sections[visualInkState.sections.length - 1] } // initializers ////////////////// export const createEmptyVisualInkState = (): VisualInkState => { return { sections: [], } } // reducers //////////// /** * Maximally continues a story, pushing all of the new lines and choices into a VisualInkState * @param story * @param visualInkState * @returns {StorySection | undefined} */ const _preloadNextSectionInVisualInkState = ( story: InkStory, visualInkState: VisualInkState, config?: InkParserConfigParams ): VisualInkState => { if (!story.canContinue) { return visualInkState } const inkLineCreator = config?.lineParser ?? createVisualInkLine const inkChoiceCreator = config?.choiceParser ?? createVisualInkChoice let sectionPath = "" let previousSectionPath = "" let previousPathString = "" const newSection: VisualInkSection = { lines: [], choices: [], allPaths: [], activeLine: -1, } while (story.canContinue) { const line = story.Continue() if (story.state.currentPathString) { // process path // for sections, we only care about knot.stitch so we cut out the rest of it const pathSplits = story.state.currentPathString.split(".") sectionPath = pathSplits.length > 2 ? `${pathSplits[0]}.${pathSplits[1]}` : pathSplits[0] // we set the startingPath of a section if it doesn't already have one, *or* if it's the first // distinct path from the previous section, to denote a change in knot. // This is handy because often choices lead to new knots, and without this functionality // the section's path would always be the path of the item *before* the choice. if (sectionPath !== previousSectionPath || !newSection.primaryPath) { newSection.primaryPath = sectionPath } if (sectionPath !== previousSectionPath || !newSection.allPaths.length) { newSection.allPaths.push(sectionPath) } previousPathString = story.state.currentPathString previousSectionPath = sectionPath } const visualLine = createVisualInkCommand(line, config) ?? inkLineCreator(line) visualLine.path = story.state.currentPathString ?? previousPathString visualLine.tags = [...story.currentTags] newSection.lines.push(visualLine) } // need to make an immutable copy. for (let i = 0; i < story.currentChoices.length; i++) { const choice = story.currentChoices[i] newSection.choices.push(inkChoiceCreator(choice, i)) } const draftInkState = { ...visualInkState } refreshSections(draftInkState, visualInkState) draftInkState.sections.push(newSection) return draftInkState } /** * Advances the story a single line, or by an entire section. * If the whole section is advanced at once, all commands in the middle are skipped. * @param story * @param visualInkState * @param continueMaximally Skips the whole section. Currently unused. * @returns */ const _advanceInternal = ( story: InkStory, visualInkState: VisualInkState, config?: InkParserConfigParams, continueMaximally?: boolean ): VisualInkState => { const tempVisualInkState = _preloadNextSectionInVisualInkState(story, visualInkState, config) const section = getActiveSection(tempVisualInkState) if (isSectionComplete(section)) { return tempVisualInkState } if (continueMaximally) { const newVisualState = { ...tempVisualInkState } newVisualState.sections = [...tempVisualInkState.sections] refreshSection(newVisualState, visualInkState, newVisualState.sections.length - 1) const draftSection = getActiveSection(newVisualState) draftSection.activeChoiceIndex = undefined return newVisualState } const draftSection = getActiveSection(tempVisualInkState) if (!draftSection) { return tempVisualInkState } const newVisualState = { ...tempVisualInkState } refreshSections(newVisualState, tempVisualInkState) newVisualState.sections.pop() newVisualState.sections.push(advanceSectionLine(draftSection)) return newVisualState } /** * Advances the story by a single line, also triggering commands along the way. * @param story * @param visualInkState * @param commandHandler the command handler to run any commands. Needs to be set to run commands. * @param config configuration, passed through to internal functions in order to use the proper line and choice parsers. * @param engine an InkEngine to pass to the commandHandler, which is then passed to the commands. * @todo This is starting to have a kitchen sink problem of passing parameters into it. We might want to think about how we can consolodate those parameters, * even if it's into an 'options' object. * @returns */ export const advance = ( story: InkStory, visualInkState: VisualInkState, commandHandler?: CommandHandler, config?: InkParserConfigParams, engine?: InkEngine, ): VisualInkState => { const currentInkState = _advanceInternal(story, visualInkState, config, false) const lastLine = getLatestVisibleLineInSection(getActiveSection(currentInkState)) if (visualInkState === currentInkState) { return visualInkState } if (lastLine.type === "command") { if (!commandHandler) { // move along if we don't care about commands return _advanceInternal(story, currentInkState, config, false) } const commandReturns = commandHandler.processCommand(lastLine, currentInkState, engine) // not having a return object is the same as saying that this command should not impact story flow if (!commandReturns) { return advance(story, currentInkState, commandHandler, config, engine) } // if we do impact story flow, we check to see if we mutated the visualState from within the command if (commandReturns.interruptStoryFlow) { if (commandReturns.mutatedVisualState) { return commandReturns.mutatedVisualState } return currentInkState } // if the command should not interrupt story flow, we continue as-is. if (commandReturns.mutatedVisualState) { return advance(story, commandReturns.mutatedVisualState, commandHandler, config, engine) } return advance(story, currentInkState, commandHandler, config, engine) } return currentInkState } export const hideAllVisibleSections = (visualInkState: VisualInkState): VisualInkState => { const draftInkState: VisualInkState = { ...visualInkState } draftInkState.sections = [...visualInkState.sections] for (let i = 0; i < draftInkState.sections.length; i++) { let section = draftInkState.sections[i] if (section !== getActiveSection(draftInkState) && section.isHidden !== true) { // only hide the section, we can ignore the lines section = refreshSection(draftInkState, visualInkState, i) section.isHidden = true } else { // hide the currently visible lines for (let j = 0; j <= section.activeLine; j++) { const scopedLine = section.lines[j] if (scopedLine.isHidden) { continue } let line = refreshLineInSection(draftInkState, visualInkState, i, j) line.isHidden = true } } } return draftInkState } /** * Hides lines in the story until only `maximumLinesToShow` of the newest lines are visible. * @param visualInkState * @param maximumLinesToShow * @param includeCommands - set to true if you show commands, and it'll hide those properly too. False is the default * and recommended; if commands are included but not visible, the actual number of lines visible on screen may not match * the maximumLinesToShow if one of those 'shown' lines include a command. * @returns */ export const hideOlderLines = ( visualInkState: VisualInkState, maximumLinesToShow: number, includeCommands: boolean = false ): VisualInkState => { if (maximumLinesToShow <= 0) { return hideAllVisibleSections(visualInkState) } const draft: VisualInkState = { ...visualInkState } let numLinesRemaining = maximumLinesToShow let sectionPointer: number = draft.sections.indexOf(getActiveSection(draft)) if (sectionPointer < 0) { return } let section = draft.sections[sectionPointer] for (let i = 0; i <= section.activeLine; i++) { // go backwards let scopedLine = section.lines[section.activeLine - i] if (scopedLine.type === "command" && !includeCommands) { continue } if (numLinesRemaining > 0 && !scopedLine.isHidden) { numLinesRemaining -= 1 continue } scopedLine = refreshLineInSection(draft, visualInkState, sectionPointer, section.activeLine - i) scopedLine.isHidden = true } sectionPointer -= 1 // now the remaining sections for (; sectionPointer >= 0; sectionPointer -= 1) { let section = draft.sections[sectionPointer] if (section.isHidden) { continue } if (numLinesRemaining <= 0) { // hide the whole section section = refreshSection(draft, visualInkState, sectionPointer) section.isHidden = true } else { for (let i = 0; i < section.lines.length; i++) { // hide individual lines let lineIndex = section.lines.length - 1 - i let scopedLine = section.lines[lineIndex] if (scopedLine.type === "command" && !includeCommands) { continue } if (numLinesRemaining > 0 && !scopedLine.isHidden) { numLinesRemaining -= 1 continue } scopedLine = refreshLineInSection(draft, visualInkState, sectionPointer, lineIndex) scopedLine.isHidden = true } } } return draft } export const makeChoice = ( story: InkStory, visualInkState: VisualInkState, choiceIndex: number ): VisualInkState => { story.ChooseChoiceIndex(choiceIndex) const draftVisualState: VisualInkState = { ...visualInkState, } refreshSection(draftVisualState, visualInkState, draftVisualState.sections.length - 1) const draftSection = getActiveSection(draftVisualState) draftSection.activeChoiceIndex = choiceIndex return draftVisualState } export const insertLinesAtPresent = ( story: InkStory, visualInkState: VisualInkState, lines: string[], config?: InkParserConfigParams ): VisualInkState => { const inkLineCreator = config?.lineParser ?? createVisualInkLine let draft = visualInkState if (!visualInkState.sections) { draft = _preloadNextSectionInVisualInkState(story, visualInkState, config) } let currentSection = { ...getActiveSection(draft) } const currentLine = currentSection.activeLine const visualLines: VisualInkLineOrCommand[] = [] for (let line of lines) { visualLines.push(createVisualInkCommand(line, config) ?? inkLineCreator(line)) } currentSection.lines.splice(currentLine + 1, 0, ...visualLines) draft.sections.pop() draft.sections.push(currentSection) return draft }