Narrator: Ink Narrative Scripting Language for Lua

repository·master·Indexed 41 days ago

https://github.com/astrochili/narrator

A Lua implementation of the Ink narrative scripting language parser and runtime. Narrator allows developers to convert Ink scripts into static Books (Lua tables) and manage runtime state via Story objects, supporting state saving, loading, and external function binding. Compatible with pure Lua, Löve 2D, and Defold.

Tokens
3.7K
Snippets
14
Records
14
Agent score
1%

What's inside Narrator

  1. How to use story.migrate for version updates

    master

    When updating your Ink files, you can prevent player save corruption by providing a migration function to story.migrate. This function is called during story:load_state().

    • state: The loaded state table.
    • old_version: The version of the saved state.
    • new_version: The version of the current book (defined by the 'version' constant in your Ink content, or 0 if not specified).

    If new_version > old_version, you can modify the state table (e.g., renaming variables or updating visit counts) before returning it.

    local function migrate(state, old_version, new_version)
      if new_version == old_version then
        return state
      end
    
      -- Example: Migration for version 2
      if new_version == 2 then
        local old_mood = state.variables['mood']
        if old_mood then
          -- Convert old numeric mood to new string mood
          state.variables['mood'] = old_mood < 50 and 'sadly' or 'sunny'
        end
      end
    
      return state
    end
    
    story.migrate = migrate
    story:load_state(saved_state)
  2. How Narrator works: Books vs Stories

    master

    Narrator implements the Ink narrative scripting language in Lua using two primary abstractions:

    • Book: A passive, static model of your Ink script (represented as a Lua table). Think of this as a game level or a book on a shelf.
    • Story: The runtime state of a Book. This tracks the current progress, variables, and state of a specific playthrough. Think of this as the active process of reading the book.

    You parse an Ink file to create a Book, and then initialize a Story from that book to actually play the narrative.

    -- Parse a book from the Ink file.
    local book = narrator.parse_file('stories.game')
    
    -- Init a story from the book
    local story = narrator.init_story(book)
  3. How to navigate and read story paragraphs

    master

    Once a story is initialized, use the following methods to progress through the narrative:

    1. story:begin(): Starts the story and generates the first chunk of paragraphs and choices.
    2. story:can_continue(): Returns true if there are paragraphs available to output.
    3. story:continue(steps): Retrieves the next paragraphs.
      • Pass nothing or 0 to get all currently available paragraphs.
      • Pass 1 to get exactly one next paragraph (not wrapped in an array).
      • Returns a table: { text = '...', tags = { 'tag1', ... } }. Note that tags may be nil.
    4. story:can_choose(): Returns true if there are choices available. Returns false if there are still paragraphs available to continue.
    while story:can_continue() do
      local paragraph = story:continue(1)
      print(paragraph.text)
    end
  4. How to save and load game state

    master

    Narrator allows you to persist the story state using story:save_state() and story:load_state(state).

    1. Saving: Call story:save_state() to get a table representing the current progress, variables, and visits. You can then save this table using your engine's storage system.
    2. Loading: Retrieve the saved table and pass it to story:load_state(state) to restore the story.
    -- Save the state
    local state = story:save_state()
    manager.save(state)
    
    -- Load the state
    local state = manager.load()
    story:load_state(state)
  5. How to handle choices in a story

    master

    When story:can_choose() is true, you can interact with the player using these methods:

    1. story:get_choices(): Returns an array of available choices. A choice is a table: { text = '...', tags = { ... } }. It returns an empty array if paragraphs are still available to continue.
    2. story:choose(index): Makes a choice by passing the index received from get_choices(). This will do nothing if can_continue() is false.

    Note on Tags: Choice tags are a Narrator feature. They appear in the choice object and can also appear in the subsequent answer paragraph (matching default Ink behavior).

    -- Get available choices and output them to the player
    local choices = story:get_choices()
    for i, choice in ipairs(choices) do
      print(i .. ') ' .. choice.text)
    end
    
    -- Make a choice (e.g., from user input)
    local answer = tonumber(io.read())
    story:choose(answer)
    
    -- Get the resulting paragraphs
    local new_paragraphs = story:continue()
  6. Install Narrator for Löve or pure Lua

    master

    To use Narrator in a standard Lua environment (like Löve 2D):

    1. Download the latest release archive from GitHub.
    2. Install the lpeg dependency via luarocks (required for parsing):
      $ luarocks install lpeg
    3. Require the module in your code:
      local narrator = require('narrator.narrator')

    Important Production Note: lpeg is only required for the parsing phase. For production builds, it is recommended to parse your Ink files into Lua books during development and ship the pre-parsed books to avoid needing lpeg in your final game distribution.

    local narrator = require('narrator.narrator')
  7. Install Narrator for Defold

    master

    To use Narrator in a Defold project:

    1. Add the following URLs as dependencies in your Defold project:
      • https://github.com/astrochili/narrator/archive/master.zip
      • https://github.com/astrochili/defold-lpeg/archive/master.zip
    2. Require the module in your scripts:
      local narrator = require('narrator.narrator')
    local narrator = require('narrator.narrator')
  8. Quick example: Playing an Ink story in Lua

    master

    This example demonstrates the full lifecycle of a Narrator story: parsing a file, initializing a story, iterating through paragraphs, handling choices, and processing player input.

    Note: This example assumes a CLI environment using io.read() and print() for interaction.

    local narrator = require('narrator.narrator')
    
    -- Parse a book from the Ink file.
    local book = narrator.parse_file('stories.game')
    
    -- Init a story from the book
    local story = narrator.init_story(book)
    
    -- Begin the story
    story:begin()
    
    while story:can_continue() do
    
      -- Get current paragraphs to output
      local paragraphs = story:continue()
    
      for _, paragraph in ipairs(paragraphs) do
        local text = paragraph.text
    
        -- You can handle tags as you like, but we attach them to text here.
        if paragraph.tags then
          text = text .. ' #' .. table.concat(paragraph.tags, ' #')
        end
    
        -- Output text to the player
        print(text)
      end
    
      -- If there is no choice it seems like the game is over
      if not story:can_choose() then break end
    
      -- Get available choices and output to the player
      local choices = story:get_choices()
      for i, choice in ipairs(choices) do
        print(i .. ') ' .. choice.text)
      end
    
      -- Read the choice from the player input
      local answer = tonumber(io.read())
    
      -- Send answer to the story to generate new paragraphs
      story:choose(answer)
    end
  9. Initialize a story instance with narrator.init_story()

    master

    To run a story in production, initialize a story instance from a book using narrator.init_story(book).

    Best Practice: For production, parse and save your book to a .lua file during development, then simply require() that Lua file and pass it to init_story.

    -- Require a parsed and saved before book
    local book = require('stories.game')
    
    -- Init a story instance
    local story = narrator.init_story(book)
  10. Parse Ink files with narrator.parse_file()

    master

    Use narrator.parse_file(path, params) to parse an Ink file from the file system. It returns a book instance.

    Supported path notations include:

    • 'stories/game.ink'
    • 'stories/game'
    • 'stories.game'

    To save the parsed book as a Lua file (to avoid parsing at runtime in production), pass { save = true } in the params table. This will save the book to the same path as the input, but with a .lua extension.

    -- Parse a Ink file at path 'stories/game.ink'
    local book = narrator.parse_file('stories.game')
    
    -- Parse a Ink file at path 'stories/game.ink'
    -- and save the book at path 'stories/game.lua'
    local book = narrator.parse_file('stories.game', { save = true })
  11. Parse Ink content with narrator.parse_content()

    master

    Use narrator.parse_content(content, inclusions) when you want to parse Ink content directly from strings rather than reading from the file system. This is useful in environments like Defold where you might load files as custom resources using sys.load_resource() and want to avoid using the io module.

    • content: The string containing the root Ink content.
    • inclusions (optional): An array of strings containing the content of included Ink files.
    local content = 'Content of a root Ink file'
    local inclusions = {
      'Content of an included Ink file',
      'Content of another included Ink file'
    }
    
    -- Parse a string with Ink content
    local book = narrator.parse_content(content)
    
    -- Parse a string with Ink content and inclusions
    local book = narrator.parse_content(content, inclusions)
  12. Reference: story.bind() for external function calls

    master

    Use story:bind(func_name, handler) to allow Ink code to call Lua functions. The handler can be a function that returns a value or one that does not.

    -- Bind a function without parameters or return value
    story:bind('beep', function() print('Beep! 😃') end)
    
    -- Bind a function with parameters and a return value
    story:bind('sum', function(x, y) return x + y end)