Files
ai.interactive.fiction/references/Documentation.md
T

26 KiB
Raw Blame History

AI Interactive Fiction Engine - Technical Documentation

1. Overview

This document provides a comprehensive technical explanation of the AI Interactive Fiction Engine implemented in game.js. The engine leverages inkjs for narrative management and incorporates advanced text rendering features, including custom animations, sophisticated typography using SmartyPants and Hyphenopoly, and optimal line breaking via the Knuth and Plass algorithm, to create rich, dynamic interactive fiction experiences.

2. Core Technologies

The engine is built upon several key technologies:

  • inkjs: Manages the core interactive fiction logic, including story state, branching narrative, choices, and variables.
  • Knuth and Plass Line Breaking Algorithm: Adapted implementation for optimal paragraph justification and line breaking, ensuring high-quality text layout. (Based on Bram Stein's "typeset" library).
  • Hyphenopoly: Provides high-quality, language-aware text hyphenation, crucial for justified text layout.
  • SmartyPants: Enhances typography by converting plain text elements (quotes, dashes) into their typographically correct equivalents.
  • Custom Animation System: A queue-based system handles the precise timing and visual reveal of text elements.
  • JavaScript (ES6+): The primary implementation language.
  • HTML/CSS: Used for structuring the UI and styling the visual presentation.

3. Engine Architecture and Flow

The engine follows a well-defined process from initialization to interactive playback.

3.1. Initialization (window.onload)

Execution begins when the window loads:

  1. Initial State Setup: Global state variables (running, fastForwardingAll, speech, speed, delay) are initialized.
  2. Background Effects: Lighting animations for background visuals are configured.
  3. Text Libraries: Hyphenopoly is initialized for hyphenation support.
  4. Story Loading: The compiled Ink story (JSON format) is fetched and loaded.
  5. Event Listeners: Listeners for user interactions (keyboard input, clicks, slider changes) are attached.
  6. Playback Start: The continueStory() function is called to begin rendering the narrative.

3.2. The Main Playback Loop (continueStory())

The continueStory() function is the engine's core, orchestrating the display of narrative content and handling user choices.

Step-by-step Process:

  1. Setup: Animation state variables (fade_in, running, fastForwardingAll) are reset for the current turn.

  2. Story Content Iteration:

    while(story.canContinue) {
        // Fetch next paragraph & associated tags from inkjs
        var paragraphText = story.Continue();
        var tags = story.currentTags;
    
        // Process tags (Section 3.3)
        // ...
    
        // Process and render text (Section 4)
        // ...
    }
    

    The loop continues as long as inkjs indicates more content is available for the current narrative sequence.

  3. Choice Handling: After exhausting the story.canContinue content, the engine checks for available choices:

    story.currentChoices.forEach(function(choice) {
        // Create interactive DOM elements for each choice
        // Attach event listeners to handle choice selection
        // Apply appropriate styling
    });
    

    When a user selects a choice, an event handler calls story.ChooseChoiceIndex(choice.index) and then triggers continueStory() again to display the subsequent narrative branch.

  4. End of Story: If story.canContinue is false and there are no choices, the story concludes or reaches a waiting point.

3.3. Tag Processing

Ink tags are used to control various non-textual aspects of the presentation:

  • AUDIO <url>: Triggers playback of an audio file.
  • IMAGE <url>: Displays an image.
  • BACKGROUND <url/color>: Changes the background style.
  • CHAPTER: Applies special formatting for chapter headings (often including drop caps).
  • SEPARATOR: Inserts a decorative visual separator element.
  • Custom CSS Classes: Tags can directly add CSS classes to paragraph elements for specific styling.

Tags are processed within the continueStory loop before the associated paragraph text is rendered.

4. Advanced Text Rendering Pipeline

A key feature of this engine is its sophisticated text rendering pipeline, designed to produce professional-quality typography and layout.

4.1. Overview

Text rendering involves several stages for each paragraph:

  1. Preprocessing: Apply SmartyPants for typographic correctness and Hyphenopoly for hyphenation points.
  2. Line Breaking Calculation: Use the Knuth and Plass algorithm (kap function) to determine optimal line breaks for the entire paragraph.
  3. Typesetting and DOM Creation: Use the typesetParagraph function to generate and position DOM elements based on the line break data.
  4. Animation Scheduling: Schedule the appearance of each text fragment using the custom animation system.

4.2. Knuth and Plass Line Breaking

The engine employs an adaptation of the Knuth and Plass line breaking algorithm to achieve superior text justification compared to standard browser methods.

4.2.1. Origin and Adaptation

  • The core logic is based on Bram Stein's "typeset" library.
  • Key Adaptations:
    • HTML Tag Support: Introduced linebreak.tag() nodes to preserve inline HTML formatting (e.g., <b>, <i>, custom spans) during line breaking.
    • Punctuation Handling: Implemented custom logic for refined spacing around punctuation marks (.,:;!?), splitting width into symbolic (25%) and spacing (75%) components.
    • Hyphenation Integration: Tightly integrated with Hyphenopoly, treating hyphenation points as potential breaks with a specific, reduced penalty (25% of normal hyphen width).

4.2.2. Algorithm Principles

  • Optimization Problem: Treats line breaking as minimizing the overall "badness" (demerits) of a paragraph's layout.
  • Global View: Considers the entire paragraph at once, unlike greedy algorithms.
  • Box, Glue, Penalty Model: Represents text as:
    • Boxes: Unbreakable units (words, tagged elements).
    • Glue: Flexible spaces that can stretch or shrink.
    • Penalties: Opportunities for line breaks (e.g., after spaces, hyphens), with associated costs (demerits).
  • Demerits System: Assigns penalties based on line tightness, hyphenation, and relationships between consecutive lines (fitness classes).
  • Optimal Path: Finds the sequence of line breaks with the lowest total demerit score.

4.2.3. Implementation (knuth-and-plass.js, linebreak.js)

  • knuth-and-plass.js (Adapter Layer):
    • Provides the kap(text, measureText, measure, hyphenation) function.
    • Parses the input text (already processed by SmartyPants/Hyphenopoly) into Box, Glue, Penalty, and Tag nodes.
    • Handles special spacing rules for punctuation.
    • Interfaces with the core linebreak.js module.
  • linebreak.js (Core Algorithm):
    • Contains the linebreak(nodes, lines, settings) function.
    • Implements the main algorithm logic: ratio calculation, demerit calculation, fitness class tracking, active node management (using the LinkedList), and path finding.
    • Defines node types: linebreak.box(), linebreak.glue(), linebreak.penalty(), linebreak.tag().
  • Customization Parameters:
    • tolerance: Controls acceptable line stretch/shrink (default: 2).
    • demerits: Configurable penalties for lines, flagged breaks (e.g., consecutive hyphens), and fitness class mismatches.

4.2.4. Supporting Data Structure: LinkedList (linked-list.js)

  • A custom, modern ES6 implementation of a doubly linked list, derived from the typeset library.
  • Purpose: Efficiently manages the "active nodes" (potential breakpoints being considered) within the linebreak.js algorithm. Critical for performance.
  • Features: ES6 class syntax, getter methods (size, first), method chaining.
  • Known Bug: The get last() method incorrectly references this.last instead of this.tail, potentially causing infinite recursion if called. This should be corrected to return this.tail;.

4.2.5. Advantages

  • Superior justification and word spacing.
  • Reduced and more aesthetically placed hyphenation.
  • Avoids jarringly tight or loose lines.
  • Produces professional, book-like typography.
  • Preserves rich text formatting during layout optimization.

4.3. Typography Enhancements (SmartyPants)

  • Before line breaking, text is processed by SmartyPants.smartypantsu(text, 1).
  • This converts:
    • Straight quotes (", ') to curly quotes (“ ”, ).
    • Double hyphens (--) to em-dashes (—).
    • Triple hyphens (---) to em-dashes (—) (configurable).
    • Three periods (...) to ellipses (…).

4.4. Hyphenation (Hyphenopoly)

  • Applied after SmartyPants using hyphenator_en(text, '.hyphenatePipe').
  • Inserts soft hyphen characters (&shy; or similar markers) at valid break points within words, based on linguistic rules.
  • These potential hyphenation points are then treated as Penalty nodes by the line-breaking algorithm, allowing for more flexible justification.

4.5. Typesetting and DOM Rendering (typesetParagraph())

This function translates the calculated layout data from kap into visible, animated DOM elements.

function typesetParagraph(paragraph_data, delay = 0, measure = []) {
    // ... setup paragraph element <p> ...

    // Iterate through lines defined by paragraph_data.breaks
    for(let i = 1; i < paragraph_data.breaks.length; i++) {
        // Iterate through nodes (words, spaces, hyphens, tags) within the line
        for(let j = paragraph_data.breaks[i-1].position; j <= paragraph_data.breaks[i].position; j++) {
            const node = paragraph_data.nodes[j];
            // Create appropriate DOM element (e.g., <span> for words/tags)
            // Set absolute position (left, top) based on calculated widths and line height
            // Apply styles (opacity: 0 initially)
            // Schedule fade-in animation using scheduleTimeout() (Section 5)
            // Update the running 'delay' total for the next element
        }
        // Handle line breaks, adjusting vertical position
    }

    return [p, delay]; // Return the populated <p> element and the final delay value
}
  • Each word, space, or preserved tag becomes a separate, absolutely positioned DOM element (typically a <span>).
  • Positions are determined precisely based on the widths calculated during the line-breaking phase.
  • Initial opacity is set to 0, and fade-in animations are scheduled sequentially.

5. Animation System

The engine features a sophisticated animation system for revealing text dynamically.

5.1. Animation Queue (timeoutQueue)

  • A central array timeoutQueue = [] tracks all pending setTimeout calls.
  • Each entry is an object storing the function to execute, its arguments, and the timeoutId.

5.2. Scheduling Animations (scheduleTimeout)

function scheduleTimeout(func, delay, ...args) {
    // Creates timeoutObject with execute method and null timeoutId
    // Uses setTimeout to schedule execution after 'delay'
    // Inside setTimeout callback: executes func, removes object from timeoutQueue
    // Pushes timeoutObject onto timeoutQueue
    // Returns the timeoutId
}
  • Provides a managed way to schedule functions, ensuring they are tracked.
  • Enables batch operations like fast-forwarding or cancellation.

5.3. Timing and Delay (window.delay, window.speed)

  • window.delay: Accumulates the total delay time as elements are scheduled. Each new element's animation starts after the previous one finishes.
  • window.speed: Controls the duration of each word's fade-in, typically adjusted by a UI slider. The delay increment for a word is often calculated like delay += window.speed * word.length;.
  • Speed Adjustment: The UI slider uses a non-linear function (Math.pow(100.0 - value, 3) / 10000 * 10 + 0.01) for finer control at slower speeds.

5.4. Fast Forwarding (fastForward, fastForwardingAll)

  • The fastForward() function:
    1. Iterates through timeoutQueue.
    2. Calls clearTimeout() for each pending animation.
    3. Immediately executes the scheduled function (timeoutObject.execute()).
    4. Clears the timeoutQueue.
    5. Resets window.delay to 0.
  • Triggered by user actions (spacebar, click) or programmatically.
  • window.fastForwardingAll flag indicates if fast-forward is continuously active (e.g., holding space).
  • Visual Feedback: The page border changes color (e.g., red) when fastForwardingAll is true.

5.5. Animation Interruption (stopAllTimeouts)

  • The stopAllTimeouts() function:
    1. Iterates through timeoutQueue.
    2. Calls clearTimeout() for each pending animation.
    3. Clears the timeoutQueue.
  • Used during navigation, loading saves, or story resets to prevent outdated animations from playing.

6. Additional Features

6.1. Text-to-Speech (TTS)

  • Integrates with an external TTS service (e.g., ElevenLabs API).
  • A UI button toggles speech playback.
  • Attempts to synchronize audio playback with text animation reveal (implementation details not fully specified in the source document).
  • Playback can be interrupted by user interactions or fast-forwarding.

6.2. State Management and Persistence

  • Ink State: The core narrative state (current position, variable values, etc.) is managed by inkjs and can be serialized using story.state.toJson().
  • Local Storage: Used to persist the serialized Ink state and potentially other UI/game states (like rendered history) across browser sessions.
  • Save/Load: Functionality allows users to explicitly save the current state and reload it later. This involves restoring the Ink state via story.state.loadJson() and potentially re-rendering the story history up to that point.

7. External Libraries & Credits

The engine relies on the following external libraries:

8. Practical Refactoring Recommendations (Updated)

Based on the detailed analysis of the engine's functionality and the goal of optimizing for modularity, separation of concerns, and reusability, the following refined refactoring strategy is recommended. This approach breaks the system into highly focused, potentially reusable components, moving away from reliance on global state and tightly coupled logic.

Core Component Modules:

  1. AnimationQueue (animation-queue.js)

    • Responsibility: Solely manage the timing and execution queue for all scheduled animations (primarily text reveal).
    • Core Functions: schedule(func, delay, ...args), fastForward(), stop(), setSpeed(value).
    • State: Manages the internal queue array, the current delay accumulator, and the animation speed.
    • Reusability: Highly reusable for any system requiring sequenced, timed execution with speed control and interruption.
  2. TextProcessor (text-processor.js)

    • Responsibility: Encapsulate text pre-processing steps required before layout calculation.
    • Core Functions: process(text) method that applies typographic enhancements (SmartyPants) and hyphenation (Hyphenopoly).
    • Dependencies: Takes instances or functions of the SmartyPants and Hyphenopoly libraries during construction.
    • Reusability: Reusable wherever this specific text processing pipeline (SmartyPants + Hyphenopoly) is needed.
  3. ParagraphLayout (paragraph-layout.js)

    • Responsibility: Interface with the Knuth-Plass line breaking algorithm (kap function) to calculate optimal line breaks.
    • Core Functions: calculateLayout(processedText, measures) method.
    • Dependencies: Takes the kap function and a measureText function (capable of measuring text widths in the target rendering context) during construction.
    • Reusability: Reusable in any system needing high-quality paragraph line breaking, provided the kap algorithm and a text measurement function are supplied.
  4. LayoutRenderer (layout-renderer.js)

    • Responsibility: Translate the abstract layout data (from ParagraphLayout) into concrete visual elements (DOM nodes in this case). Handle visual tag rendering.
    • Core Functions: renderParagraph(layoutData, measures) creates positioned DOM elements (e.g., <span> for words/tags), applies styles (initial opacity 0), and uses AnimationQueue.schedule to initiate fade-in animations. renderVisualTag(tagType, tagData) handles rendering for IMAGE, BACKGROUND, CHAPTER, SEPARATOR, and applying CSS classes.
    • Dependencies: Takes an AnimationQueue instance and potentially configuration for visual elements.
    • Reusability: Moderately reusable. The core logic of translating layout data is reusable, but the specific DOM creation part is tied to HTML/DOM rendering. Could be adapted for other rendering targets (e.g., Canvas).
  5. AudioManager (audio-manager.js)

    • Responsibility: Manage loading and playback of non-TTS audio effects triggered by tags (e.g., AUDIO <url>).
    • Core Functions: loadSound(id, url), playSound(id), stopSound(id), stopAllSounds().
    • Reusability: Highly reusable component for basic audio management in web applications.
  6. TtsPlayer (tts-player.js)

    • Responsibility: Handle interactions with the Text-to-Speech service (e.g., ElevenLabs API), manage playback, and potentially synchronize with the AnimationQueue.
    • Core Functions: speak(text), stopSpeaking(), setVoice(voiceId), configure(apiKey, ...).
    • Dependencies: May need the AnimationQueue for synchronization if required.
    • Reusability: Reusable for adding TTS functionality, specific implementation depends on the chosen TTS provider API.
  7. PersistenceManager (persistence-manager.js)

    • Responsibility: Handle saving and loading the game state.
    • Core Functions: saveState(stateObject), loadState(). stateObject would contain Ink state JSON, potentially UI state, scroll position, etc.
    • Dependencies: Configurable storage backend (e.g., localStorage wrapper).
    • Reusability: Highly reusable for saving/loading application state to various storage mechanisms.
  8. InkStoryPlayer (ink-story-player.js)

    • Responsibility: Orchestrate the narrative flow specific to the Ink story. Manage the inkjs.Story instance.
    • Core Functions: loadStory(storyContentJson), continueStory(containerElement), chooseChoice(index). It drives the story.Continue() loop, gets text and tags. It delegates tasks:
      • Text processing -> TextProcessor
      • Layout calculation -> ParagraphLayout
      • Rendering paragraphs/visuals -> LayoutRenderer
      • Handling AUDIO tags -> AudioManager
      • Handling speech requests (if implemented via tags) -> TtsPlayer
      • Saving/Loading -> PersistenceManager
    • Dependencies: inkjs.Story class, TextProcessor, ParagraphLayout, LayoutRenderer, AudioManager, TtsPlayer, PersistenceManager.
    • Reusability: Specific to using Ink narratives but uses reusable components for its operations.
  9. UiController (ui-controller.js)

    • Responsibility: Manage user interface interactions (event listeners) and update UI elements (sliders, buttons, status indicators).
    • Core Functions: setupEventListeners(), methods to handle specific events (e.g., handleSpeedChange, handleFastForwardToggle, handleChoiceClick, handleSaveClick, handleLoadClick, handleTtsToggle).
    • Dependencies: Interacts primarily with InkStoryPlayer (e.g., to choose choice, trigger save/load), AnimationQueue (to set speed, trigger fast-forward), TtsPlayer (to toggle speech), AudioManager (potentially to control master volume).
    • Reusability: Specific to the application's UI structure but follows a standard controller pattern.

Main Application Integration (animated-fiction.js or main.js):

// main.js (Example Setup)
import { AnimationQueue } from './animation-queue.js';
import { TextProcessor } from './text-processor.js';
// ... import other modules ...
import { Story as InkStory } from './ink.js'; // Assuming inkjs is available

class AnimatedFictionApp {
    constructor(config) {
        this.config = config; // Story content URL, container element, API keys, etc.
        this.storyContent = null; // Loaded later

        // 1. Instantiate Core Components
        this.animationQueue = new AnimationQueue();
        // Provide external libraries/functions to components that need them
        this.textProcessor = new TextProcessor(SmartyPants, hyphenator_en); // Assuming these are globally available or imported
        this.paragraphLayout = new ParagraphLayout(kap, this.measureDomText.bind(this)); // `kap` algorithm, bind measure func
        this.layoutRenderer = new LayoutRenderer(this.animationQueue);
        this.audioManager = new AudioManager();
        this.ttsPlayer = new TtsPlayer({ apiKey: config.ttsApiKey /*, optional animationQueue */ });
        this.persistenceManager = new PersistenceManager({ storage: localStorage }); // Configure storage backend

        // 2. Instantiate Orchestrator & UI
        this.storyPlayer = new InkStoryPlayer({
            InkStory: InkStory, // Pass the inkjs Story constructor
            textProcessor: this.textProcessor,
            paragraphLayout: this.paragraphLayout,
            layoutRenderer: this.layoutRenderer,
            audioManager: this.audioManager,
            ttsPlayer: this.ttsPlayer,
            persistenceManager: this.persistenceManager,
        });
        this.uiController = new UiController({
            storyPlayer: this.storyPlayer,
            animationQueue: this.animationQueue,
            ttsPlayer: this.ttsPlayer,
            // Pass references to DOM elements (buttons, sliders)
            speedSliderElement: document.getElementById('speedSlider'),
            choiceContainerElement: document.getElementById('choices'),
            // ... other elements
        });
    }

    // Method needed by ParagraphLayout, tied to rendering context
    measureDomText(text, style = '') {
        // Implementation to measure text width in the DOM
        // (e.g., create temporary span, apply style, measure offsetWidth)
        // ... return width ...
    }

    async load() {
        // Fetch story content, etc.
        const response = await fetch(this.config.storyUrl);
        this.storyContent = await response.json();
    }

    start() {
        if (!this.storyContent) throw new Error("Story not loaded");

        // Setup initial states, load saved game if applicable
        const savedState = this.persistenceManager.loadState();
        const initialInkState = savedState ? savedState.inkJson : null;

        this.storyPlayer.loadStory(this.storyContent, initialInkState);
        this.uiController.setupEventListeners();
        // Set initial speed from config or saved state
        this.animationQueue.setSpeed(this.config.initialSpeed || 0.05);

        // Begin the story
        this.storyPlayer.continueStory(document.getElementById(this.config.storyContainerId));
    }
}

// Initialize and start the application
window.onload = async () => {
    const app = new AnimatedFictionApp({
        storyUrl: 'story.json',
        storyContainerId: 'story',
        ttsApiKey: 'YOUR_API_KEY' // Example config
        // ... other configurations
    });
    await app.load();
    app.start();
};

Benefits of this Refined Structure:

  • High Modularity: Each class has a single, well-defined responsibility.
  • Improved Reusability: Components like AnimationQueue, TextProcessor, ParagraphLayout, AudioManager, TtsPlayer, PersistenceManager have minimal dependencies on the specific interactive fiction context and can be reused elsewhere.
  • Clear Separation of Concerns: Narrative logic (InkStoryPlayer) is separated from rendering (LayoutRenderer), timing (AnimationQueue), text manipulation (TextProcessor), UI (UiController), and side effects (Audio/TTS/Persistence).
  • Testability: Individual components can be unit-tested more easily by mocking their dependencies.
  • Maintainability: Changes within one module are less likely to impact others. Replacing a component (e.g., swapping the TTS provider) becomes more manageable.

This refactoring focuses squarely on creating independent, reusable building blocks, aligning perfectly with the goals specified.