frontispiece / packages / ink-hookstate / src / index.ts
index.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 { ExtensionFactory, hookstate, State, useHookstate } from "@hookstate/core"
import type {
	InkStorySaveState,
	VisualInkAllLineTypes,
	VisualInkState,
} from "@a-morphous/frontispiece-ink-processor/types"
import {
	getStoryStateObject,
	InkParserConfigParams,
	InkProcessorEngine,
	InkStory
} from "@a-morphous/frontispiece-ink-processor"

// the ink story has to be stored outside of hookstate.
export type InkHookStateType = {
	visualState: VisualInkState
	inkState: Record<string, any>
}

/* Used to retrieve all the function-only types */
type PickMatching<T, V> = { [K in keyof T as T[K] extends V ? K : never]: T[K] }
type ExtractMethods<T> = PickMatching<T, Function>

/**
 * Spins up the entirety of hookstate, with the result being both a hook that can be put into React / Preact functional components,
 * and a global object that can be used to update the state outside a component.
 * @param story - the Ink story, already compiled,
 * @param config
 * @param extension
 * @returns
 */
export const createInkHookState = (
	story: InkStory,
	config?: InkParserConfigParams,
	extension?: ExtensionFactory<InkHookStateType, {}, {}>
) => {
	const inkProcessor: InkProcessorEngine = new InkProcessorEngine(story, config)

	const globalInkHookState = hookstate<InkHookStateType>(
		{
			visualState: inkProcessor.visualState,
			inkState: {},
		},
		extension
	)

	inkProcessor.events.on('updatedVisualInkState', (evt) => {
		globalInkHookState.inkState.set(getStoryStateObject(evt.data.story))
		globalInkHookState.visualState.set(evt.data.visualInkState)
	})

	// we pass a single lines object around in `getVisibleLines` so we don't redraw every
	// single line when a new one is added.
	const visibleLines: VisualInkAllLineTypes[] = []

	const getInkHookStateController = (state: State<InkHookStateType>) => {
		const returnObj: ExtractMethods<InkProcessorEngine> = Reflect.ownKeys(
			Object.getPrototypeOf(inkProcessor)
		).reduce((accumulator, value) => {
			if (value !== "constructor") {
				const desc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(inkProcessor), value)
				if (!desc) {
					return { ...accumulator }
				}
				let fn = desc.value ?? desc.get
				if (!fn) {
					return { ...accumulator }
				}

				return { ...accumulator, [value]: fn.bind(inkProcessor) }
			}
		}, {}) as ExtractMethods<InkProcessorEngine>
		return {
			...returnObj,
			_state: state,

			/**
			 * Most functions are mirrored to top level. However, if you need anything specific,
			 * best to get it directly from the engine.
			 */
			_engine: inkProcessor,

			import: (save: InkStorySaveState) => {
				inkProcessor.import(save)
				state.visualState.set(save.visualInkState)
				state.inkState.set(save.storyState)
			},

			/**
			 * Returns an array of lines to be rendered by the frontend. Ensures that only the lines that are returned
			 * are the ones that 'should' be rendered, in order from oldest to newest.
			 *
			 * This does not render the currently available choices.
			 * @param options List of options that determine which lines get returned.
			 * @param options.numberOfSectionsToFetch Limit the number of sections to get lines from, starting from the most recent. By default, there are no limits.
			 * @param options.showHidden Whether to show lines that have been previously cleared. By default, is false. Set to true to produce a backlog of lines.
			 * @param options.showChosenChoice Whether to display the choices that were made previously. By default, is false. Generally unneeded because Ink duplicates the choice in the response.
			 * @param options.showCommands Whether to show commands in the display. Defaults to false, this is useful only if there's some visual parsing to work with commands, or for debugging.
			 * @returns - array of lines to be rendered on screen.
			 */
			getVisibleLines: (options) => {
				const mergedOptions = {
					...options,
					arrayReference: visibleLines,
				}
				return inkProcessor.getVisibleLines(
					mergedOptions,
					state.visualState.get({ noproxy: true }) as VisualInkState
				)
			},

			shouldShowChoices: () => {
				return !inkProcessor.canAdvance && !inkProcessor.locked
			},
		}
	}

	const createUseHookState = () => {
		const state = useHookstate(globalInkHookState)
		return getInkHookStateController(state)
	}

	return {
		InkHookStateController: getInkHookStateController(globalInkHookState),
		useInkHookState: createUseHookState,
	}
}

export type InkHookStateControllerType = ReturnType<
	typeof createInkHookState
>["InkHookStateController"]
export type UseInkHookStateType = ReturnType<typeof createInkHookState>["useInkHookState"]