frontispiece / packages / ink-processor / src / engine.ts
engine.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 {
	InkBindingListenerCallback,
	InkBindingListenerInterface,
	InkBindingManager,
} from "./binding/ink-binding"
import { InkParserConfigParams } from "./config"
import { Command, CommandHandler } from "./data/command"
import {
	getLatestVisibleLineInSection,
	getNumberOfLinesLeftToShow,
	getVisibleLinesInSection,
	isSectionComplete,
} from "./data/story-section"
import {
	advance,
	createEmptyVisualInkState,
	getActiveSection,
	hideAllVisibleSections,
	hideOlderLines,
	insertLinesAtPresent,
	makeChoice,
} from "./data/visible-state"
import type { EmitterType } from "./engine/events"
import { Plugin, PluginEngine } from "./engine/plugin"
import type { InkStory } from "./inkTypes"
import type {
	GetVisibleLinesInStateOptions,
	InkProcessor,
	InkStorySaveState,
	VisualInkAllLineTypes,
	VisualInkLine,
	VisualInkState,
	VisualStateProcessor,
} from "./types"
import { EventEmitter } from "./engine/event-emitter"

export class InkProcessorEngine implements InkProcessor, VisualStateProcessor, PluginEngine {
	protected config: InkParserConfigParams
	protected _locked: boolean = false

	// systems
	protected binding: InkBindingManager
	protected commands: CommandHandler

	protected bindingListeners: Map<InkBindingListenerCallback, InkBindingListenerInterface>
	protected eventEmitter: EventEmitter<EmitterType>

	constructor(
		story: InkStory,
		config: Partial<InkParserConfigParams> = {},
		plugins: Plugin[] = []
	) {
		this.binding = new InkBindingManager(story)
		this.commands = new CommandHandler()
		this.config = {
			commandIdentifier: "$",
			...config,
		}

		this.eventEmitter = new EventEmitter()
		this.bindingListeners = new Map()

		for (let plugin of plugins) {
			plugin.initialize(this)
		}

		this.eventEmitter.fire("initialized", {
			engine: this,
		})

		this.binding.addListener({
			setInkStory: (story: InkStory, visualInkState: VisualInkState) => {
				this.eventEmitter.fire("setInkStory", {
					story,
					visualInkState,
				})
			},
			updateStoryVisualInkState: (story: InkStory, visualInkState: VisualInkState) => {
				this.eventEmitter.fire("updatedVisualInkState", {
					story,
					visualInkState,
				})
			},
		})
	}

	// GETTERS
	/////////////

	public get configuration() {
		return this.config
	}

	public get events() {
		return this.eventEmitter
	}

	/**
	 * The Ink Story object. Please do not modify or call functions on the story outside of the engine;
	 * This is mostly for passing the story around if absolutely needed.
	 */
	public get inkStory() {
		return this.binding.getInkStory()
	}

	/**
	 * Loads a new story into the engine, resetting visual state.
	 */
	public set inkStory(story: InkStory) {
		this.binding.setInkStory(story)
	}

	/**
	 * The visual state object associated with this engine. Contains the current state of the world, which can be used to examine
	 * how far the story has gone.
	 */
	public get visualState() {
		return this.binding.getVisualInkState()
	}

	public set visualState(state: VisualInkState) {
		this.binding.updateStoryVisualInkState(state)
	}

	/**
	 * The latest section that has been reached in the story. A section is all of the content available up until the next choice.
	 */
	public get activeSection() {
		return this.getActiveSection()
	}

	/**
	 * The latest line that has been reachedd in the story.
	 */
	public get activeLine() {
		return this.getActiveLine()
	}

	/**
	 * All of the currently available choices in the visual state.
	 */
	public get choices() {
		return this.getChoices()
	}

	/**
	 * The number of lines in the current section that still hasn't been shown.
	 */
	public get linesRemaining() {
		return getNumberOfLinesLeftToShow(getActiveSection(this.visualState))
	}

	public get locked() {
		return this._locked
	}

	public set locked(value: boolean) {
		this._locked = value
		this.eventEmitter.fire("locked", {
			engine: this,
			locked: this.locked,
		})
	}

	public get canAdvance() {
		if (this.locked) {
			return false
		}
		return !isSectionComplete(getActiveSection(this.visualState))
	}

	// GENERIC GETTERS
	////////////////////

	// these are the same functions as above, but can work on an external VisualState object as well
	// which is useful for working with tracked objects.

	public getActiveSection(visualInkState?: VisualInkState) {
		const targetVisualState = visualInkState ?? this.visualState
		return getActiveSection(targetVisualState)
	}

	public getActiveLine(visualInkState?: VisualInkState) {
		const targetVisualState = visualInkState ?? this.visualState
		return getLatestVisibleLineInSection(getActiveSection(targetVisualState))
	}

	public getChoices(visualInkState?: VisualInkState) {
		const targetVisualState = visualInkState ?? this.visualState
		return getActiveSection(targetVisualState)?.choices ?? []
	}

	public getLinesRemaining(visualInkState?: VisualInkState) {
		const targetVisualState = visualInkState ?? this.visualState
		return getNumberOfLinesLeftToShow(getActiveSection(targetVisualState))
	}

	public getCanAdvance(visualInkState?: VisualInkState) {
		const targetVisualState = visualInkState ?? this.visualState
		if (visualInkState === undefined && this.locked) {
			return false
		}
		return !isSectionComplete(getActiveSection(targetVisualState))
	}

	/**
	 *
	 * @param options
	 * @param visualState - optional visual state object, if we want to get visible lines for an external
	 * 	or tracked state (vs the one stored in the processor)
	 * @returns an array of all the lines that should be visible and displayed in the frontend. This is
	 * the function that will be called most often by the consumer to determine what to display.
	 */
	public getVisibleLines(options: GetVisibleLinesInStateOptions, visualInkState?: VisualInkState) {
		const targetVisualState = visualInkState ?? this.visualState
		let sectionsToShow = targetVisualState.sections.length
		if (options.numberOfSectionsToFetch) {
			sectionsToShow = options.numberOfSectionsToFetch
		}
		const lines: VisualInkAllLineTypes[] = options.arrayReference ?? []
		// delete everything in the array pointer
		// so we can have fresh lines without losing the reference to the array object
		lines.splice(0, lines.length)

		for (let i = 0; i < sectionsToShow; i++) {
			const index = targetVisualState.sections.length - sectionsToShow + i
			const section = targetVisualState.sections[index]
			if (section.isHidden && !options.showHidden) {
				continue
			}
			lines.push(...getVisibleLinesInSection(targetVisualState.sections[index], options))
		}

		return lines
	}

	// CONTROLLING THE STORY
	//////////////////////////

	/**
	 * Advances the story a single line. Can be called over again until we reach a choice.
	 * @returns
	 */
	public advance() {
		const newVisualState = advance(
			this.inkStory,
			this.visualState,
			this.commands,
			this.config,
			this
		)
		this.binding.updateStoryVisualInkState(newVisualState)
		this.eventEmitter.fire("advanced", {
			engine: this,
		})
		return newVisualState
	}

	/**
	 * Selects one of the choices and moves onto the line afterwards.
	 * @param choiceIndex
	 * @returns
	 */
	public makeChoice(choiceIndex: number) {
		const newVisualState = makeChoice(this.inkStory, this.visualState, choiceIndex)
		this.binding.updateStoryVisualInkState(newVisualState)
		this.eventEmitter.fire("madeChoice", {
			engine: this,
			choice: choiceIndex,
		})
		return newVisualState
	}

	public hideAllVisible() {
		const newVisualState = hideAllVisibleSections(this.visualState)
		this.binding.updateStoryVisualInkState(newVisualState)
		return newVisualState
	}

	/**
	 * Hides lines in VisualInkState until only `maxVisibleLines` are left visible
	 * @param maxVisibleLines
	 * @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
	 */
	public hideOlderLines(maxVisibleLines: number, includeCommands: boolean = false) {
		const newVisualState = hideOlderLines(this.visualState, maxVisibleLines, includeCommands)
		this.binding.updateStoryVisualInkState(newVisualState)
		return newVisualState
	}

	/**
	 * Performs a generic transformation function on this engine's visual state,
	 * taking in a transform function that should take in the visual state and produce a new one.
	 * Not very useful unless you're extending deep into the engine.
	 * @param transformFunction - a function that takes in a visualInkState and produces a new visualInkState
	 * It's encouraged that you use a library like immer to do the transformation, as the original
	 * visualInkState must NOT be modified as a result of the transformFunction.
	 */
	public transformVisualState(transformFunction: (state: VisualInkState) => VisualInkState) {
		const newVisualState = transformFunction(this.visualState)
		this.binding.updateStoryVisualInkState(newVisualState)
		return newVisualState
	}

	public insertLines(lines: string[]): VisualInkState {
		const newVisualState = insertLinesAtPresent(this.inkStory, this.visualState, lines, this.config)
		this.binding.updateStoryVisualInkState(newVisualState)
		return newVisualState
	}
	// COMMANDS
	////////////

	/**
	 * Adds a new command to the story, which allows it to then run a function when that command
	 * is found in the script.
	 * @param commandName The name of the command, which is used to determine which command to run
	 * @param command The command function that should be called when the command of the proper name is found in the script.
	 */
	public registerCommand(commandName: string, command: Command) {
		this.commands.addCommand(commandName, command)
	}

	// SAVE / LOAD
	////////////////

	public export() {
		return this.binding.exportStory()
	}

	public import(save: InkStorySaveState) {
		this.binding.importStory(save)
	}

	// this resets the story to its initial state
	public reset() {
		this.binding.getInkStory().ResetState()
		this.binding.updateStoryVisualInkState(createEmptyVisualInkState())
	}

	// CONFIGURATION AND PLUGINS
	//////////////////////////////

	public changeLineParser(newParser: (line: string) => VisualInkLine) {
		this.config.lineParser = newParser
	}

	public resetLineParser() {
		this.config.lineParser = undefined
	}
}