Yarnspinnex

Compiles Yarn Spinner dialogue into a Yarnspinnex.Script (plain data, safe to cache) and runs it one event at a time against a Yarnspinnex.Cursor (plain data, safe to persist). No processes.

def deps do
  [{:yarnspinnex, github: "atavistock/yarnspinnex"}]
end

Quick start

title: Start
---
Sally: Hi there. How's it going?
-> Great, thanks!
    Sally: Glad to hear it.
-> Could be better...
    Sally: Aw, I'm sorry.
===
{:ok, script} = Yarnspinnex.compile_string(source)
cursor = Yarnspinnex.start(script, gold: 10)

case Yarnspinnex.step(script, cursor, context) do
  {:line, %Yarnspinnex.Line{speaker: speaker, text: text}, cursor} -> ...
  {:command, name, args, cursor} -> ...
  {:options, options, cursor} -> {:ok, cursor} = Yarnspinnex.choose(script, cursor, index, context)
  {:done, cursor} -> cursor.vars
end

start/2 enters the Start node, or the first node if there is none; enter/3 enters any node by title.

Syntax

Sally: Your score is {$score}. #line:0a1b2c #shout
<<set $score += 5>>
<<declare $score = 0 as number>>
<<if $score > 10>>
    Sally: Impressive!
<<elseif $score > 0>>
    Sally: Not bad.
<<else>>
    Sally: Ouch.
<<endif>>
-> Buy a pie <<if $gold >= 10>>
    <<spend_gold 10>>
-> Leave
<<jump NextScene>>
[[NextScene]]
[[Say goodbye|NextScene]]
<<stop>>

Expressions:

Variables, context, functions, commands

$player in a script is player in Elixir. Vars and context may be keyword lists or maps with atom or string keys. Nested values must be maps, and lookup tries the string key first, then the atom key.

Compile options:

Yarnspinnex.compile_string(source,
  functions: MyGame.DialogueFunctions,
  context: [:player, :time_of_day],
  variables: [:met_baker],
  strict: true
)
defmodule MyGame.DialogueFunctions do
  @behaviour Yarnspinnex.HostFunctions

  @impl true
  def declared, do: [{"gold", 0}, {"visited", 1}]

  @impl true
  def call("gold", [], context), do: context.player.gold
  def call("visited", [node], context), do: MyGame.Dialogue.visited?(context.player, node)
end

call/3 gets the context exactly as passed to step/3.

Projections

Scripts read scalars, plain maps of scalars, or a Yarnspinnex.Projection. Reading a field on a struct, comparing a non-scalar, or rendering one in a line raises, so a domain object passed by mistake fails loudly instead of comparing false. Put anything that needs an argument or a lookup in a host function.

context =
  Yarnspinnex.Projection.new(%{
    player: Yarnspinnex.Projection.new(%{class: "mage", level: 5}, source: player),
    time_of_day: "night"
  })

The script sees fields, while host functions also see source. Fields whose names start with _ are unreadable, which covers __struct__ and __meta__. An unknown field reads as nil, and fields are read-only.

Errors

The two Yarnspinnex errors carry node and line, and their message reads like line 12 in node "Baker": cannot apply > to nil and 5.

Persisting

Usually you only need to persist cursor.vars and start each conversation at whichever node fits, branching on state:

title: Baker
---
<<if visited("Baker_Intro")>>
    <<jump Baker_Waiting>>
<<else>>
    <<jump Baker_Intro>>
<<endif>>
===

To resume mid-conversation across requests, store the cursor itself:

save!(id, :erlang.term_to_binary(cursor))

cursor = :erlang.binary_to_term(load!(id), [:safe])

with :ok <- Yarnspinnex.check_cursor(script, cursor),
     {:ok, cursor} <- Yarnspinnex.choose(script, cursor, index, context) do
  Yarnspinnex.step(script, cursor, context)
end

Diagrams

Yarnspinnex.Mermaid.node_graph/1 draws nodes and jumps, and flowchart/2 draws every statement. Pass nodes: [...] to draw only some nodes.

File.write!("dialogue.mmd", Yarnspinnex.Mermaid.flowchart(script))

Not supported