Introduction

Ayaka project was initially a project for OSPP 2022. It is a voice novel engine which aims at 3 goals: simple, free, concentrate.

Simple

A visual novel should be easy to author. The author need little knowledge of programming. We provide a config file format based on YAML and TeX-like commands. It is easy to learn and author your own voice novel.

Free

You are free to implement anything you'd like with our plugin system. We provide a simple, but also strong script plugin by default. The plugin system provides possibility to calculate and hook important parts in the runtime.

Concentrate

The project is separated into 4 parts: runtime, plugins, config, frontend. You can concentrate at one part without knowing about others. If you like, you can write config file only, and author a voice novel. You can also write your own fantastic game by customizing the frontend.

Start from zero

Here is the guide with zero knowledge of computer programming. You will learn to install Rust and install Makefile. Then you can follow the instructions in Start from source.

Install Rust

Rust is a modern, compiled language, which means it doesn't need a special runtime environment or an interpreter to execute. What we need to install is the development environment of Rust.

Browse to the official cite of Rust first. Then we download following the installation guide on the official cite.

Rust can run on Windows, Linux, macOS, FreeBSD and NetBSD.

If you are working on Windows

Choose to download 32-bit or 64-bit rustup installer according your operating system.

If you are working on Unix

Run the following command in the terminal

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

and follow the instructions.

After the installation done, type rust --version, cargo --version and rustup --version in the terminal to make sure the installation valid.

Switch to nightly

rustup install nightly
rustup default nightly

Notes on every time before running...

We use nightly toolchain to compile. You need to update regularly

rustup update

and

git pull

Install Makefile

Ayaka uses Makefile to organize the compilation and testing of the components.

If you are working on Windows

We suggest using MSYS2 distributed make or MSYS2 distributed mingw32-make, or chocolatey distributed make.

Install MSYS2 distributed one

Download the installer from MSYS2 official site and follow the installation instructions.

And run the following command in the MSYS2 shell

pacman -S make

to install make. If you would like a specific MinGW Makefile, e.g. MinGW64 one, run

pacman -S mingw-w64-x86_64-make

to install mingw32-make in that environment.

Install chocolatey distributed one

Follow the instructions on the chocolatey official site to install chocolatey.

Run the following command

choco install make

Add make to PATH environmental variable

If you use MSYS2, make won't be add into PATH, and you can only use it in the MSYS2 shell. To make it easier, you can choose to add the corresponding directory of MSYS2 /usr/bin (or MinGW64 /mingw64/bin), usually C:\msys64\usr\bin (or C:\msys64\mingw64\bin) into environmental variable PATH.

You can also choose to not changing the environmental variable, but let MSYS2 shell inherit the variables from Windows, to call cargo and npm in it. According to the solution from developers, you can change environmental variable MSYS2_PATH_TYPE to inherit.

If you are working on Linux

Distros of Linux differ, but usually the package name should be make.

Start from source

Prerequisites

All platforms need Rust and Nodejs installed.

  • Rust: nightly toolchain.
  • Nodejs: 14.18+/16+ required by Vite.
  • tauri-cli: 2.0.0-beta.18 ensure latest beta version, required by Tauri.
$ cargo install tauri-cli --version 2.0.0-beta.18
...
$ cargo tauri --version
tauri-cli 2.0.0-beta.18

Windows

Windows 10+ is recommended but any Windows that Rust supports is OK.

WebView2 is required by Tauri. It comes with the latest Edge browser.

To run the Makefile toolchain, you need GNU Make from MSYS2 project, either for Msys2(make) or Mingw64(mingw32-make).

Note that if you have a WSL bash.exe in PATH before MSYS2 one, the npm command may fail.

Linux

webkit2gtk is needed. We only support webkit2gtk-4.0 required by Tauri.

macOS

Generally we don't need anything more, but you should ensure that make is installed.

Clone from source

$ git clone https://github.com/Uni-Gal/Ayaka.git
$ cd Ayaka

Add targets for WebAssembly

$ rustup target add wasm32-unknown-unknown

Test the utilities

$ make test

Run examples

$ # Run Fibonacci2
$ make example-Fibonacci2
$ # Run Orga in GUI
$ make example-Orga-gui

Release build of frontends

$ make release

Config

Ayaka config is based on YAML. The structure is simple to author.

The articles below uses ayaka-check to show the example, it may behave a little different in GUI. To run the examples, you need to create a config file with full structure, go to the bins folder and run

$ cargo run --package ayaka-check -- path/to/config.yaml --auto

File structure

Directory structure

config.yaml
├─paras
│ ├─ja
│ │ ├─start.yaml
│ │ └─end.yaml
│ └─zh-Hans
│   ├─start.yaml
│   └─end.yaml
└─res
  ├─ja.yaml
  └─zh-Hans.yaml

Properties

The total config file is a GameConfig object. Here shows the properties:

PropertyDescription
titleThe title of the game.
base_langThe base language.
parasThe paragraph path.
startThe start paragraph.
authorOptional. The author of the game.
pluginsOptional. The PluginConfig object.
resOptional. The resource path.
propsOptional. The custom properties.

The PluginConfig object contains the base directory and the plugin names:

PropertyDescription
dirThe directory.
modulesThe plugin names.

A Paragraph object is a collection of texts:

PropertyDescription
tagThe tag and key of the paragraph.
textsThe texts.
titleOptional. The title of the paragraph.
nextOptional. The next paragraph.

The visibility of paragraphs

Only the paragraph whose tag is the same as the file name(without extension) is public to all paragraphs. The rest paragraphs in this file could only be referenced by the paragraphs in the same file.

For example, for start.yaml

- tag: start
  next: foo
- tag: foo
  next: bar
- tag: bar
  next: end

and end.yaml

- tag: end
  next: foo
- tag: foo
  next: bar
- tag: bar

The foo and bar referenced are the ones in the same file, while start and end could be referenced from other files.

Basic example

This is a config example, with 2 paragraphs.

config.yaml
└─paras
  └─en
    ├─para1.yaml
    └─para2.yaml

config.yaml

title: Title
base_lang: en
paras: paras
start: para1

para1.yaml

- tag: para1
  texts:
    - This is the first line.
    - This is the second line.
  next: para2

para2.yaml

- tag: para2
  texts:
    - The first line of the second paragraph.

The output will be

This is the first line.
This is the second line.
The first line of the second paragraph.

You can see that the game starts at the first paragraph para1, and it jumps to para2 after para1 ends. The game exits after para2 ends, because it doesn't specify the next paragraph.

Specify character

The dialogues should be marked with the speaking character. We specify a character with /// prefix.

Add the character names

The character names should be placed in res at the front of the game. Not only because the character name is too long to type again and again, but also to make i18n easy.

The key of the character name should be prefixed with ch_:

ch_foo: A. Foo
ch_bar: B. Bar

You can then specify the character with the command:

- /foo//This is the first line.
- /bar//This is the second line.

These two lines will output as:

_A. Foo_This is the first line.
_B. Bar_This is the second line.

Note the double slashes in /foo// could not be simplified.

Specify the alias of the character

Sometimes we need a temporary alias of the current character:

- /foo/Person 1st/This is the first line.
- /bar/Person 2nd/This is the second line.

The output will be

_Person 1st_This is the first line.
_Person 2nd_This is the second line.

Resources

The resources are indexed by locale:

config.yaml
└─res
  ├─en.yaml
  ├─ja.yaml
  └─zh.yaml

You can specify other locales, too. The keys not specified in other locales will fallback to base_lang ones.

en.yaml

foo: Foo
bar: Bar

zh.yaml

foo: 天
bar: 地

Reference resources

You can reference resources in texts with \res{} command.

- The foo value is \res{foo}
- The bar value is \res{bar}

Internationalization

ICU

The i18n feature are supported by ICU4X with CLDR data. We use CLDR to choose the best fit locale for current system.

Simplify translation

The translation of the texts is always a difficult job. You don't need to copy all commands as is. For example, the original text (ja)

- bg: 0
- /rd//団長!車の用意できました!
- switches:
  - おう!
  - 止まるんじゃねぇぞ!
  - 止まれ!

could be translated as (zh-Hans)

-
- 团长!车已经准备好了!
- switches:
  - 哦!
  - 不要停下来啊!
  - 停下!

Fallback

The resources, commands, and even paragraphs could be fell back, if a translated one is not apparent. However, some other ones couldn't be fell back.

Fallback with empty text

If a certain translated line is empty, it will fall back to the base language one.

Switches

The switches could be specified with switches command.

Some special global variables are used. $? is the index of selected switch (start from 0). $<num> indicates whether a switch is enabled, and is cleared after one switch is selected. If a specific $<num> variable is not defined or defined as null or ~, it is treated as true.

- 'Choose a switch:'
- exec: $3 = false
- switches:
  - Switch 1
  - Switch 2
  - Not enabled
- You chose switch \var{?}

Script

Ayaka script is dynamic typed. The only supported types are unit ~, boolean, integer, and string.

#![allow(unused)]
fn main() {
pub enum RawValue {
    Unit,
    Bool(bool),
    Num(i64),
    Str(String),
}
}

Using ayacript

ayacript is the plugin that provides Ayaka script functionalities. You need to add ayascript to the config file. See Plugin.

Execute scripts

Execute a piece of script(we call it program) with exec command:

- exec: $res = 1 + 1
- 1 + 1 = \var{res}

The output is

1 + 1 = 2

The script $res = 1 + 1 is evaluated, and the result is 2. It is then converted to string and appended to the text.

Example: Fibonacci

With the config file, we can even calculate some math problems. For example, Fibonacci:

- tag: init
  texts:
    - '1'
    - exec: $n = 50; $a = 1; $b = 1; $i = 1;
    - \var{b}
  next: loop
- tag: loop
  texts:
    - exec: c = $b; $b += $a; $a = c; $i += 1;
    - \var{b}
    - exec: $next = if($i < $n, "loop")
  next: \var{next}

Runtime

The runtime, aka "backend", is the engine of a game. It provides functionality to load config, settings, plugins, and control the full status.

Run a game

The CLI tool in bins/ayaka-check is a full example to run a game.

Open a config file

use ayaka_runtime::*;
let mut context = Context::open("../../../examples/Fibonacci/config.yaml", FrontendType::Text).await?;

The context object should be initialized first to start from an initial record.

context.init_new();

Then you can iterate the actions:

while let Some(action) = context.next_run() {
    //...
}

Get the open status

The context also implements Stream. The OpenStatus could be iterated before the future awaited.

use ayaka_runtime::*;
let context = Context::open("../../../examples/Fibonacci/config.yaml", FrontendType::Text);
let mut context = std::pin::pin!(context);
while let Some(status) = context.next().await {
    println!("{:?}", status);
}
let mut context = context.await?;

Plugin

All plugins should target WebAssembly. We now support wasm32-unknown-unknown target.

The plugin runtime is supported by Wasmer. Our platform support is largely limited by this engine.

We provide a crate ayaka-bindings to easily author a plugin in Rust.

Load plugins

Specify the plugin directory in the config file:

plugins:
  dir: path/to/plugins

The runtime will try to load all WebAssembly file in the directory. If you want to specify some of them, or specify the load order, specify them in modules:

plugins:
  dir: path/to/plugins
  modules:
    - foo
    - bar

You don't need to specify the extension.

WASM directory mappings

The parent directory of the config file (aka. the root directory) is mapped to / in the plugins. Some plugins, e.g. media, need to determine if the resource files exist. Therefore, the files should be placed under the root directory. Symbolic links may not work if they point to directories outside the root directory.

The text processing workflow

digraph {
  ori[label="Raw text"];
  lines[label="Structural lines"];
  exec[label="Run scripts"];
  cmd[label="Custom commands"];
  texts[label="Final texts"];
  history[label="Record history"];
  output[label="Output"];

  ori -> lines [label="TextParser"];
  lines -> exec [label="ProgramParser"];
  exec -> cmd [label="text plugins"];
  cmd -> texts [label="action plugins"];
  texts -> history;
  history -> output;
}

Script plugin

All plugins are script plugins. Scripts could call methods in the script plugins.

Calling methods

A plugin method is referenced with <module>.<fn>(...) grammar. If you would like to call the rnd function in random module,

- exec: $i = random.rnd()

Pass the parameters in the brace ().

As the scripts are calculated at runtime, if there's no plugin called random, or no method called rnd inside random, it will give a warning, and continue with RawValue::Unit.

Author a script plugin

Here we're going to author a script plugin meet to return a string "Hello".

use ayaka_bindings::*;

#[export]
fn plugin_type() -> PluginType {
    PluginType::default()
}

#[export]
fn hello(_args: Vec<RawValue>) -> RawValue {
    RawValue::Str("Hello".to_string())
}

And call the function:

- exec: $hello = meet.hello()
- \var{hello} from plugin!

If it builds successfully, and you set the right path to the plugins, it will output:

Hello from plugin!

Existing plugins

There are some existing script (only) plugins:

PluginDescription
ayalogLog to runtime.
randomGenerate random numbers.

Text plugin

Text plugins deal with custom TeX-like commands.

Register commands

Here we will register a command \hello and call it in the config file. You also need plugin_type to specify that it is a text plugin.

use ayaka_bindings::*;

#[export]
fn plugin_type() -> PluginType {
    PluginType::builder().text(["hello"]).build()
}

#[export]
fn hello(_args: Vec<String>, _ctx: TextProcessContext) -> TextProcessResult {
    let mut res = TextProcessResult::default();
    res.line.push_back_chars("Hello");
    res
}

Call the command in the config file:

- \hello world!

And it outputs:

Hello world!

The process results

The TextProcessResult object is some lines and properties to be added to the current action. line will be appended to the current position of the command, and props will be set and update.

Existing plugins

PluginDescription
basictexBasic TeX commands.
live2dLive2D commands.

Line plugin

Line plugins provides custom line types.

Register commands

Here we will register a command \hello and call it in the config file. You also need plugin_type to specify that it is a line plugin.

use ayaka_bindings::*;

#[export]
fn plugin_type() -> PluginType {
    PluginType::builder().line(["hello"]).build()
}

#[export]
fn hello(_ctx: LineProcessContext) -> LineProcessResult {
    let mut res = LineProcessResult::default();
    res.locals.insert("hello".to_string(), RawValue::Str("Hello".to_string()));
    res
}

Call the command in the config file:

- hello:
- \var{hello} world!

And it outputs:

Hello world!

The process results

The LineProcessResult object contains the global variables and temp variables. The temp variables will only apply to this specific line.

Existing plugins

PluginDescription
ayacriptAyaka script.
mediaMultimedia commands.

Action plugin

Action plugins deal with the whole action after the text being parsed. An action plugin usually modifies the text, for example, parses the Markdown text and output to HTML.

Action plugins could access the last action in the history, will text plugins cannot.

Simple Markdown plugin

Action plugins don't need to register anything, but only to specify in the plugin_type:

use ayaka_bindings::*;
use pulldown_cmark::*;

#[export]
fn plugin_type() -> PluginType {
    PluginType::builder().action().build()
}

#[export]
fn process_action(mut ctx: ActionProcessContext) -> ActionProcessResult {
    let line = ctx
        .action
        .line
        .into_iter()
        .map(|s| s.into_string())
        .collect::<Vec<_>>()
        .concat();
    let parser = Parser::new(&line);
    let mut html_output = String::new();
    html::push_html(&mut html_output, parser);
    ctx.action.clear();
    ctx.action.push_back_chars(html_output);
    ActionProcessResult { action: ctx.action }
}

Add the plugin to the modules list, and all markdown text will be translated to HTML.

You may notice that the HTML tags are treated as ActionLine::Chars, which means they will be displayed one by one on GUI frontends. Our existing markdown plugin resolves this problem by providing a custom writer.

Existing plugins

PluginDescription
live2dInherit models layout and deal with hiding.
markdownProcess Markdown texts.
mediaVoice of each action.

Game plugin

Game plugins adjust some properties of the game before any record starts.

Insert a global property

use ayaka_bindings::*;

#[export]
fn plugin_type() -> PluginType {
    PluginType::builder().game().build()
}

#[export]
fn process_game(mut ctx: GameProcessContext) -> GameProcessResult {
    ctx.props.insert("hello".to_string(), "Hello world!".to_string());
    GameProcessResult { props: ctx.props }
}

Existing plugins

PluginDescription
live2dGet correct path of Live2D models.
mediaGet correct path of background image at home.

GUI

The ayaka-gui is a sample GUI frontend. It provides basic functionalities, including control flow, multimedia, i18n and Live2D integration.

The frontend is based on Tauri with Vue.

Live2D

The Live2D functionality is powered by pixi-live2d-display.

Packaging

We support package format called ayapack. Internally it is a TAR format archive, without any compression. We load the package file with memory map to reduce allocations.

Packaging

tar executable is pre-installed on almost all platforms. There may be BSD-Tar or GNU-Tar. For a directory foo that contains config.yaml, we can simply execute

$ cd foo
$ tar -cf foo.ayapack *

The parameter c means creating a package, and f means the following parameter is the package path.

Details

The details of the parsing and loading are in the vfs-tar.

Supported platforms

Ayaka is cross-platform, and the triples are supported with 3 tiers.

Tier 1

Ensured to work well with these triples:

  • x86_64-pc-windows-msvc
  • x86_64-unknown-linux-gnu
  • x86_64-apple-darwin

Tier 2

Should work well with these triples, but not tested:

  • i686-pc-windows-msvc
  • aarch64-pc-windows-msvc
  • i686-pc-windows-gnu
  • x86_64-pc-windows-gnu
  • aarch64-unknown-linux-gnu
  • aarch64-apple-darwin

Tier 3

May not build or run because of dependencies:

  • s390x-unknown-linux-gnu