frontispiece / packages / ink-processor / src / data / visible-state.ts
visible-state.ts
Raw
/*
 * 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
}