/* * 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 } }