湊ドキュメント

湊(みなと)は、伺か/SSP用のSHIORIエンジンです。 Rustで実装されており、.mntファイルに書かれたスクリプトを読み込んでゴーストを動かします。

このドキュメントでは湊の使い方を説明します。

どこから読むか

  • 初めて湊を使う方は、湊とはから順に読んでください。
  • 里々・YAYAから移ってくる方は、移行ガイドから始めると、違いを先に押さえられます。
  • 困ったときは、エラーと対処を見てください。

湊とは

湊(みなと)は、伺か/SSP用のSHIORIエンジンです。 Rustで実装されており、.mntファイルに書かれたスクリプトを読み込んでゴーストを動かします。

特徴

  • セリフとロジックが同じブロックに書ける
  • 永続データ(save)を配列・マップで構造化して持てる
  • 関数定義・match・foreachなど、複雑なロジックにも対応
  • パースエラーは日本語で行番号付きで表示される
  • config.toml で debug_log = true にするとログファイルが生成される
  • SAORIで外部DLLと連携できる

スクリプトの例

OnBoot => {
    global save.訪問回数 += 1
    if (save.訪問回数 == 1) {
        湊: はじめまして。
    } else {
        湊: また来てくれたんですね。${save.訪問回数}回目です。
    }
}

条件分岐・永続データの読み書き・文字列展開が、すべて同じブロックの中で完結します。

インストールと最初のゴースト

必要なもの

  • SSP(最新版推奨)
  • 湊のDLL(minato.dll)

ファイル構成

ゴーストのフォルダに以下の構成を用意します。

ghost/master/
├── shiori.dll       ← minato.dll をリネーム
├── config.toml
└── talks/
    └── main.mnt

config.toml の最小構成

[characters]
"湊" = "\\0"

キャラクター名とSAKURAスクリプトのタグを対応させます。 サイドにキャラクターがいる場合は \\1 も追加します。

[characters]
"湊" = "\\0"
"助手" = "\\1"

日本語のキャラクター名は、TOMLの仕様上キー名を "湊" のように引用符で囲む必要があります。 引用符なしの 湊 = "\\0" と書くと config.toml のパースに失敗し、ゴーストが起動しません。

main.mnt の最小構成

OnBoot => {
    湊: こんにちは。
}

これだけでSSPがゴーストを起動したとき「こんにちは。」と喋ります。

動作確認

SSPでゴーストをロードして起動セリフが出れば成功です。

うまく動かない場合は config.toml で debug_log = true に設定してください。 ゴーストフォルダに minato_load.log が生成され、エラーの詳細が記録されています。

[settings]
debug_log = true

2回目以降の起動では save.json の system.debug_log が config.toml より優先されます。 詳しくは設定(config.toml)を参照してください。

SSPに読み込ませる前に書き間違いを確かめたいときは、構文チェッカーが使えます。

基本の書き方

このセクションでは湊のスクリプトの基本的な構造を説明します。

ファイル構成

湊のゴーストは以下のファイルで構成されます。

ghost/master/
├── shiori.dll       ← minato.dll をリネーム
├── config.toml      ← キャラクター設定・動作設定
├── save.json        ← 永続データ(自動生成)
└── talks/
    ├── main.mnt     ← メインスクリプト
    └── *.mnt        ← include で分割可能

config.toml

キャラクターの設定と動作設定を書きます。詳細は設定を参照してください。

talks/

.mnt ファイルにトークとロジックを書きます。 main.mnt が必ず読み込まれます。ファイルが大きくなった場合は include で分割できます。

.mntファイルのトップレベルに書けるもの

トークや関数の { } の外(トップレベル)に書けるのは、次の4つだけです。

書けるもの例説明
トーク定義OnBoot => { ... }トーク定義
関数定義func greet(name) { ... }func定義と呼び出し
globalglobal save.count ?= 0global / save
includeinclude "events.mnt"include

コメントと空行は、どこに書いてもかまいません。

include "events.mnt"

global save.count ?= 0

func greet(name) {
    湊: ${name}さん、こんにちは。
}

OnBoot => {
    let name = "ユーザー"   // let はトークの中なら書ける
    greet(name)
}

セリフ、let、if、for、call などの文は、トークか関数の中にしか書けません。

let をトップレベルに書いたときは「let はトークや関数の中でしか使えません」というエラーになります。 それ以外の文をトップレベルに書くと「予期しないファイル末尾があります」というエラーになります。 このエラーが出たら、その行がトークや関数の { } の外にはみ出していないか確かめてください。

save.json

global save.* で保存したデータが自動的にここに書き込まれます。 手動で編集する必要はありません。

自動的に作られるその他のファイル

湊は、ゴーストのフォルダ(ghost/master/)に、次のファイルを作ることがあります。

ファイル内容
minato_load.logdebug_log = true のときの動作ログ(追記されます)
minato_request.logdebug_log = true のときの、直近にSSPから届いたリクエストの内容(イベントごとに上書きされます)
minato_debug.logdebug_log = true のときに、台本の log() が書き出すログ(追記されます)
save.json.corrupt.baksave.json が壊れていて読めなかったときに、元のファイルを退避したもの
save.json.panic.bak湊の内部エラーのあとに保存するとき、上書き前の save.json を退避したもの
save.json.panic.bak.notifiedsave.json.panic.bak について、警告をもう表示したことを示す空のファイル。これがあると、同じ退避について警告は繰り返し表示されません
save.json.tmpsave.json を保存している最中の一時ファイル。保存が終わると save.json に置き換わり、消えます
(ファイル名).minato.tmpfile_write() で書き込んでいる最中の一時ファイル。書き込みが終わると消えます

これらは湊が管理するファイルなので、手動で編集する必要はありません。 minato_ で始まるファイルは、台本の file_write() などからは書き込めません。 配布するときは、save.json や .bak などを含めないようにしてください。

トーク定義

湊のスクリプトの基本単位はトークです。 イベント名と処理ブロックを => でつなげて書きます。

基本的な書き方

OnBoot => {
    湊: こんにちは。
}

OnBoot はSSPがゴーストを起動したときに呼ばれるイベントです。 {} の中に処理を書きます。

同じイベントを複数定義する

同じイベント名で複数のトークを定義できます。 ランダムに選ばれて実行されます。

OnRandomTalk => {
    湊: 今日もいい天気ですね。
}

OnRandomTalk => {
    湊: 何か用ですか?
}

OnRandomTalk => {
    湊: ……。
}

ドット付きのイベント名

SSPのイベントには、OnUpdate.OnDownloadBegin のようにドットを含むIDがあります。 湊では、イベント名をそのままドット付きで書けます。

OnUpdate.OnDownloadBegin => {
    湊: 更新ファイルをダウンロードしています。
}

OnUpdate.OnMD5CompareBegin => {
    湊: ファイルを確認しています。
}

条件フィルタ(if(...))も、ドット無しのトークと同じように付けられます。

OnUpdate.OnDownloadBegin if(save.verbose) => {
    湊: 少し時間がかかるかもしれません。
}

ドット付きのトークは call OnUpdate.OnDownloadBegin のように呼び出すこともできます。 ドット付きにできるのはトーク名だけです。func の名前にはドットを使えません。

主なイベント一覧

イベント名タイミング
OnBoot起動時
OnClose終了時
OnRandomTalk定期的なランダムトーク、およびユーザーが手動でトークを要求したとき
OnMouseDoubleClickダブルクリック時
OnChoiceSelectOn で始まらないIDの選択肢が選ばれたとき(選択肢とウェイト)

SSPが送るイベントはSSPのドキュメントを参照してください。

湊が読み替える・内部で使うイベント

次の3つはSSPから届いたあと湊が内部で処理するため、OnAITalk => { ... }、 OnMinuteChange => { ... }、OnGotVirtualTime => { ... } と書いても呼ばれません。

SSPのイベント湊での扱い
OnAITalkOnRandomTalk に読み替えて実行されます。手動トークも定期トークも OnRandomTalk => { ... } に書いてください
OnMinuteChange湊が時刻の取得とランダムトークの発火判定に内部で使います。間隔が経過していて、かつSSPがトークを再生できる状態(Reference3 が 1)であれば OnRandomTalk が実行されます。OnMinuteChange 自体を台本で受け取ることはできません
OnGotVirtualTime湊が現在時刻(SSPの仮想時刻)を受け取るために内部で使います。受け取った時刻は now に反映されます。台本で受け取ることはできません

湊は、OnBoot と OnMinuteChange への応答の末尾に、SSPへ時刻を問い合わせる \![get,property,OnGotVirtualTime,...] を自動で付け足します。 そのSSPからの返事が OnGotVirtualTime です。台本に書く必要はありません。

SSPのリソース要求(version / craftman)

SSPは、イベントのほかに、On で始まらないID(リソース)でSHIORIに問い合わせることがあります。 湊は、次の2つにだけ、台本とは関係なく固定の値で答えます。

リソースID湊の応答
version湊のバージョン(例: 0.1.0)
craftmanmizuki

これらはゴーストの読み込み前後でも答えます。台本で version => { ... } のように書いても、値を変えることはできません。 それ以外の On で始まらないリソース(homeurl や sakura.name など)には、何も返しません(204)。

セリフの書き方

基本

キャラクター名の後に : を書き、セリフを続けます。

OnBoot => {
    湊: こんにちは。
}

キャラクター名は config.toml の [characters] に登録した名前と一致している必要があります。

複数行のセリフ

セリフを複数行に分けて書くと、自動的に \n で連結されます。

OnBoot => {
    湊: こんにちは。
    今日もよろしくお願いします。
}

別のキャラクターに切り替えると、前のキャラクターのセリフが確定します。

OnBoot => {
    湊: こんにちは。
    助手: よろしくお願いします。
}

サーフェスの指定

セリフの前に [数字] を書くとサーフェスを切り替えられます。

OnBoot => {
    [0]湊: にっこり。
    [1]湊: あれ。
}

SAKURAスクリプトの埋め込み

セリフの中にSAKURAスクリプトをそのまま書けます。

OnClose => {
    湊: またね。
    \-
}

\n(改行)や \-(終了)などはそのまま使えます。 選択肢(\q[…])やウェイト(\w5 など)の書き方は選択肢とウェイトを見てください。

文字列展開

セリフの中で変数や式を展開できます。詳細は文字列展開を参照してください。

OnBoot => {
    湊: ${save.訪問回数}回目の起動です。
}

注意: 「英数.英数」は変数参照になる

セリフ本文の先頭や ${} の直後で、スペースや半角記号を挟まずに半角の . までが続くと、 ${} がなくても変数参照として読み取られ、その部分の文が消えます。

OnBoot => {
    // NG: 「体重は57.8kgです」が消える
    湊: 体重は57.8kgです。
    // OK
    湊: ${"体重は57.8kgです。"}
}

詳しくは文字列展開の「「${}」なしの変数参照に注意」を参照してください。

選択肢とウェイト

湊には、選択肢やウェイト専用の構文はありません。 SAKURAスクリプトの \q[…] や \w… をセリフの中にそのまま書きます。

選択肢

選ばれたらイベントを実行する

\q[ラベル,ID] で選択肢を出します。 ID を On で始まる名前にすると、選ばれたときにSSPがそのイベントを直接実行します。 選ばれたときのトークを、その名前で定義してください。

OnBoot => {
    湊: どっちにしますか?
    \q[りんご,OnApple]
    \q[みかん,OnOrange]
}

OnApple => {
    湊: りんごですね。
}

OnOrange => {
    湊: みかんですね。
}
\0どっちにしますか?\n\q[りんご,OnApple]\n\q[みかん,OnOrange]\e
\0りんごですね。\e
\0みかんですね。\e

1行目が OnBoot の出力、2行目と3行目がそれぞれの選択肢を選んだときの出力です。

\q[…] だけの行は、直前のセリフに \n(改行)付きでつながります。 選択肢が縦に並ぶのはこのためです。

選択肢に値を持たせる

\q[ラベル,OnID,値0,値1] のように、IDのあとに値を続けられます。 値は、選ばれたイベントの reference["0"]、reference["1"] に入ります。 同じイベントで、選ばれた選択肢を見分けたいときに使います。

OnBoot => {
    湊: お茶にしますか?
    \q[はい,OnAnswer,はい]
    \q[いいえ,OnAnswer,いいえ]
}

OnAnswer => {
    湊: 「${reference["0"]}」ですね。
}
\0お茶にしますか?\n\q[はい,OnAnswer,はい]\n\q[いいえ,OnAnswer,いいえ]\e
\0「はい」ですね。\e
\0「いいえ」ですね。\e

reference についてはnow・referenceの使い方も見てください。

選択肢の名前のトークを呼ぶ

ID を On で始めないと、SSPは OnChoiceSelect イベントを送り、reference["0"] にIDを入れます。 OnChoiceSelect で call reference.0 と書けば、IDと同じ名前のトークを呼べます。 選択肢を増やしても、OnChoiceSelect を書き換える必要はありません。

OnBoot => {
    湊: どっち?
    \q[はい,はい]
    \q[いいえ,いいえ]
}

OnChoiceSelect => {
    call reference.0
}

はい => {
    湊: はいが選ばれました。
}

いいえ => {
    湊: いいえが選ばれました。
}
\0どっち?\n\q[はい,はい]\n\q[いいえ,いいえ]\e
\0はいが選ばれました。\e
(204)
  • 同じ名前のトークが複数あれば、その中からランダムに1つ選ばれます。
  • 存在しない名前のときは何も出力されません(上の例の3行目。SHIORIの応答は204になります)。エラーにはなりません。

call reference.0 は動的なcallです。構文チェッカーで notice が出ることがありますが、無視してかまいません。

選択肢を条件で出す

\q[…] の行を if で囲むと、条件を満たすときだけ選択肢を出せます。

OnBoot => {
    湊: どれにしますか?
    \q[りんご,OnApple]
    if (save.見た) {
        \n\q[みかん,OnOrange]
    }
}

OnSet => {
    global save.見た = true
}
\0どれにしますか?\n\q[りんご,OnApple]\e
(204)
\0どれにしますか?\n\q[りんご,OnApple]\n\q[みかん,OnOrange]\e

if の中の \q[…] は直前のセリフにつながらないので、改行の \n を自分で書いてください。

ウェイト

湊は、ウェイトを自動では入れません。何も書かないと、間を置かずに一気に表示されます。 間を空けたいところに、SAKURAスクリプトのウェイトを書いてください。

書き方意味
\w1〜\w950ミリ秒×数字だけ待つ(\w5 で250ミリ秒)
\_w[500]指定したミリ秒だけ待つ
OnBoot => {
    湊: えっと……\w9\w9それは\_w[1000]秘密です。
}
\0えっと……\w9\w9それは\_w[1000]秘密です。\e

文字列の中でも \ は1つ

"…" の文字列の中でも、ウェイトは \w5 と1つのバックスラッシュで書きます。 \\w5 と2つ書くと、\\ がそのまま残り、SSPには「\ という文字を表示する」と解釈されてしまいます。

OnBoot => {
    let ok = "あ\w5い"
    let ng = "あ\\w5い"
    湊: ${ok}|${ng}
}
\0あ\w5い|あ\\w5い\e

句読点にまとめてウェイトを付ける

replace を使った関数を作っておくと、句読点のあとにウェイトを付けられます。

func wait(s) {
    return replace(replace(s, "、", "、\w3"), "。", "。\w9")
}

OnBoot => {
    湊: ${wait("今日は、いい天気ですね。")}
}
\0今日は、\w3いい天気ですね。\w9\e

この関数は、使うセリフごとに ${wait("…")} と書く必要があります。 ゴースト全体の出力にまとめて付けたいときは、SSPの OnTranslate イベントを使う方法があります。 詳しくは移行ガイドの3. イベントを見てください。

コメント

一行コメント

// から行末までがコメントになります。

OnBoot => {
    // これはコメントです
    湊: こんにちは。
}

セリフの行末には書けない

湊: こんにちは。 // メモ のように、セリフの行の末尾に // を書いても、コメントにはなりません。 // メモ までがセリフの一部として出力されます。コメントは、次のように別の行に書いてください。

OnBoot => {
    // 起動時のあいさつ
    湊: こんにちは。
}

ブロックコメント

/* から */ までがコメントになります。複数行にまたがって書けます。

OnBoot => {
    /*
        ここはコメントです。
        複数行書けます。
    */
    湊: こんにちは。
}

トークをコメントアウトする

開発中のトークを一時的に無効にしたいときに使います。

/*
OnRandomTalk => {
    湊: まだ書きかけのトークです。
}
*/

変数とデータ

このセクションでは湊の変数とデータの扱い方を説明します。

let(ローカル変数)

let はトークの中だけで使えるローカル変数です。 宣言したブロック({ })を出るか、トークの実行が終わると消えます。

let はトークや関数の外(ファイルのトップレベル)には書けません。書くと「let はトークや関数の中でしか使えません」というエラーになります。 複数のトークで同じ値を使いたいときは、トップレベルに global を書いてください。

global 一覧 = {"ふれいむ": ["フレイム", "炎を放つ魔法。"]}

OnBoot => {
    湊: ${一覧["ふれいむ"][0]}
}

基本的な書き方

OnBoot => {
    let name = "湊"
    湊: 私の名前は${name}です。
}

値の種類

数値・文字列・bool・配列・マップが使えます。

OnBoot => {
    let count = 0
    let name = "湊"
    let flag = true
    let items = ["あ", "い", "う"]
    let data = {"key": "value"}
}

計算

OnBoot => {
    let a = 10
    let b = 3
    let sum = a + b
    湊: ${a}と${b}を足すと${sum}です。
}

再代入

let で宣言した変数は後から値を変えられます。

OnBoot => {
    let count = 0
    count += 1
    count += 1
    湊: ${count}回カウントしました。
}

添字を使った代入

配列やマップの要素は、添字を使って書き換えられます。添字には変数や式も書けます。

OnBoot => {
    let map = [[0, 0, 0], [0, 0, 0]]
    let y = 1
    let x = 2
    map[y][x] = 1
    map[y - 1][x] += 5
    湊: ${map[1][2]} ${map[0][2]}
}

出力は 1 5 になります。

  • 配列の範囲外(長さ以上、-1、1.5 など)の添字への代入は無視され、「配列の添字 N は範囲外です(長さ M)」という warning が出ます。配列は代入では伸びません。
  • 未定義の変数への添字代入は、配列ではなくマップになります。 m[y][x] = 1 と書くと、m は {"1": {"2": 1}} というマップになります。配列として使いたいときは、先に let m = [[0, 0, 0], [0, 0, 0]] のように初期化してください。

let の有効範囲

let の変数は、宣言したブロック({ })の中だけで有効です。 if、for、foreach、while、match の中で let すると、そのブロックを出たときに消えます。

一方、let なしの代入(c = 5)は、外側に同名のローカル変数があれば、その変数を更新します。

OnBoot => {
    let a = 1
    let c = 1
    if (true) {
        let a = 2
        c = 5
        let b = 3
    }
    湊: a=${a} b=${b} c=${c}
}

出力は a=1 b= c=5 になります。

  • let a = 2 は、if の中に新しい a を作ります。外の a は 1 のままです。
  • c = 5 は、外の c を更新します。
  • b は if の中で消えるので、外では空です。

let なしの代入は「起動中グローバル」になる

let を付けずに x = 5 と書いたとき、その名前のローカル変数がなければ、起動中だけ有効なグローバル変数が作られます。 他のトークからも見えますが、save.json には保存されず、ゴーストを終了すると消えます。

OnBoot => {
    counter = 5
}

OnClose => {
    湊: counter=${counter}
}

OnBoot で作った counter が、別のトーク OnClose から見えます。

トークの中だけで使う一時的な値は、名前がぶつかって他のトークに影響しないよう、必ず let で宣言してください。 起動中だけ共有したい値は global work.x = 1 のように書きます(名前が work で始まる、というだけの約束です)。 再起動後も残したい値は global save.x に置きます(global / save(永続化))。

これらの違いは、里々やYAYAから移る場合に特に問題になります。詳しくは5. 変数を参照してください。

global / save(永続化)

global はゴースト全体で共有される変数です。 global save.… のように save の下に置いた値だけが、ゴーストを閉じても残ります。 データは save.json に自動的に保存・読み込みされます(保存されるのはゴーストの終了時と再読み込み時です)。

save 以外に置いた値(global foo = 5 や global work.x = 1 など)は、ゴーストを起動しているあいだだけ他のトークから見えますが、save.json には保存されず、終了すると消えます。 save.json に残したい値には、必ず save. を付けてください。書き忘れてもエラーは出ません。

基本的な書き方

OnBoot => {
    global save.訪問回数 += 1
    湊: ${save.訪問回数}回目の起動です。
}

save. の後にキー名を書きます。 初回は null から始まるので、+= で数値を足すと自動的に数値として扱われます。

初期値を設定する

?= を使うと、値が null のときだけ代入します。 初期化に便利です。

OnBoot => {
    global save.訪問回数 ?= 0
    global save.訪問回数 += 1
    湊: ${save.訪問回数}回目の起動です。
}

代入演算子一覧

演算子意味
=代入
+=加算して代入
-=減算して代入
*=乗算して代入
/=除算して代入
%=剰余を代入
?=nullのときだけ代入

ネストしたデータ

マップをネストして構造化できます。

OnBoot => {
    global save.stats.win += 1
    湊: ${save.stats.win}勝しました。
}

配列の保存

OnBoot => {
    global save.履歴 ?= []
    global save.履歴 = push(save.履歴, "起動")
    湊: 履歴に追加しました。
}

letとglobalの違い

letglobal save.…global(save 以外)
スコープトーク内(ブロック内)のみゴースト全体ゴースト全体
永続化されないsave.json に保存されない(終了で消える)
用途一時的な計算訪問回数・フラグ等起動中だけ共有したい値

値の種類

湊で扱える値の種類は6つです。

数値

整数・小数どちらも同じ Number 型です。

let a = 10
let b = 3.14
let c = -5

文字列

シングルクォート '...' またはダブルクォート "..." で囲みます。 ダブルクォートの中では ${} で変数や式を展開できます。

let name = '湊'
let greeting = "こんにちは、${name}さん。"

bool

true または false です。

let flag = true
let other = false

配列

[] で囲み、, で区切ります。

let items = ["あ", "い", "う"]
let numbers = [1, 2, 3]

インデックスは0始まりです。

let items = ["あ", "い", "う"]
// あ
湊: ${items[0]}
// い
湊: ${items[1]}

マップ

{} で囲み、キー: 値 の形で書きます。

let data = {"name": "湊", "age": 17}
湊: ${data["name"]}

null

値が存在しない状態です。未定義の変数を参照すると null になります。 ?? 演算子で null のときの代替値を指定できます。

湊: ${save.未定義 ?? "初期値"}

型の判定

is_num・is_str・is_bool・is_array・is_map・is_null で値の型を確かめられます。 type_of(値) は型の名前("number"・"string" など)を返します。

// true
湊: ${is_num(42)}
// false(数字でも文字列)
湊: ${is_num("42")}
// array
湊: ${type_of([1, 2])}

くわしくは組み込み関数を参照してください。

型の変換

関数意味
to_str(値)文字列に変換
to_num(値)数値に変換
let n = to_num("42")
let s = to_str(123)

文字列展開

セリフや文字列の中で変数・式・関数の結果を展開できます。

基本

${} の中に変数名や式を書きます。

OnBoot => {
    global save.訪問回数 += 1
    湊: ${save.訪問回数}回目の起動です。
}

式の展開

計算式をそのまま書けます。

OnBoot => {
    let a = 10
    let b = 3
    湊: ${a + b}です。
}

関数の展開

ビルトイン関数や自分で定義した関数も展開できます。

OnBoot => {
    湊: 「こんにちは」は${len("こんにちは")}文字です。
}

null合体演算子

?? を使うと値が null のときの代替値を指定できます。

OnBoot => {
    湊: ${save.名前 ?? "名無し"}さん、こんにちは。
}

ネストしたアクセス

マップや配列のネストもそのまま展開できます。

OnBoot => {
    let data = {"name": "湊", "age": 17}
    湊: 名前は${data["name"]}、年齢は${data["age"]}です。
}

「${}」なしの変数参照に注意

セリフ本文の先頭、または**${} の直後**では、${} を付けなくても save.訪問回数 のような「名前.名前」の形が変数参照として読み取られます。 ここでいう「名前」は、英数字・_・日本語の文字(「、」「。」などの句読点や記号を含む)が 途切れず続いたものです。

このため、セリフの先頭から半角の .(小数点やバージョン番号など)まで、 スペースや半角記号を挟まずに文字が続いていると、その全体が変数参照として扱われます。 そんな変数は存在しないので値は空になり、その部分の文が消えます。

OnBoot => {
    湊: 体重は57.8kgです。
    // 「体重は57」と「8kgです」が「.」でつながった変数参照とみなされ、
    // 「体重は57.8kgです」が消えて「。」だけが残る
}

. の前に半角スペースや半角記号があれば変数参照にはなりません。 また、本文の途中(先頭でも ${} の直後でもない位置)に書いた 57.8 は、そのまま表示されます。

回避するには、${} の中に文字列として書きます。

OnBoot => {
    湊: ${"体重は57.8kgです。"}
    湊: 体重は${57.8}kgです。
}

${} の直後も同じ規則が適用されます(${name}abc.def の「abc.def」が変数参照になります)。 小数点などを含む文章を書くときは、迷ったら ${"..."} を使ってください。

シングルクォート文字列との違い

シングルクォート '...' の中では展開されません。 展開が必要な場合はダブルクォート "..." を使います。

let a = '${save.訪問回数}'  // 展開されない、文字列そのまま
let b = "${save.訪問回数}"  // 展開される

制御構文

このセクションでは湊の制御構文を説明します。

if / else

条件によって処理を分岐します。

基本

OnBoot => {
    if ((save.訪問回数 ?? 0) == 0) {
        湊: はじめまして。
    } else {
        湊: また来てくれたんですね。
    }
}

まだ一度も代入していない save.訪問回数 は null で、null == 0 は偽になります。 初期値が0のつもりで比較するときは、この例のように (save.訪問回数 ?? 0) == 0 と書くか、あらかじめ global save.訪問回数 ?= 0 で初期化しておきます。

else if

OnBoot => {
    if (save.好感度 >= 100) {
        湊: 大好きです。
    } else if (save.好感度 >= 50) {
        湊: 好きです。
    } else {
        湊: ……。
    }
}

比較演算子

演算子意味
==等しい
!=等しくない
<より小さい
<=以下
>より大きい
>=以上

論理演算子

演算子意味
&&かつ
||または
!否定
OnBoot => {
    if (save.訪問回数 >= 10 && save.好感度 >= 50) {
        湊: たくさん来てくれていますね。
    }
}

elseなし

else は省略できます。

OnBoot => {
    if ((save.訪問回数 ?? 0) == 0) {
        global save.初回フラグ = true
    }
    湊: こんにちは。
}

for / foreach

for

カウンタを使ったループです。

OnBoot => {
    let sum = 0
    for (let i = 0; i < 5; i++) {
        sum += i
    }
    湊: 合計は${sum}です。
}

for (初期化; 条件; 更新) の形で書きます。

書ける形

for の3つの部分には、次の制限があります。

  • 初期化: let 変数名 = 値 の形だけ書けます。for (i = 0; ...) のように let を付けない書き方や、初期化を空にする書き方はできません。カウンタは、この for の中だけで使える変数になります。
  • 条件: 通常の式(比較や && など)が書けます。
  • 更新: 下の表の4つの形だけ書けます。

カウンタの更新

書き方意味
i++1増やす
i--1減らす
i += 22増やす
i -= 22減らす

これ以外の形は、パースエラーになります。たとえば i *= 2 や i = i + 1、save.n++(save の中の値など、単純な変数名でないもの)は書けません。 それらが必要なときは、while を使ってください。

foreach

配列やマップの要素を順番に処理します。

配列

OnBoot => {
    let items = ["あ", "い", "う"]
    foreach items as i, v {
        湊: ${i}番目は${v}です。
    }
}

i がインデックス(0始まり)、v が値です。 インデックスだけ使う場合は v を省略できます。 変数を1つだけ書くと、それはインデックス(マップの場合はキー)になり、要素の値は取れません。値も使うときは i, v のように2つ書きます。

OnBoot => {
    let items = ["あ", "い", "う"]
    foreach items as i {
        湊: ${i}番目。
    }
}

マップ

OnBoot => {
    let data = {"name": "湊", "age": "17"}
    foreach data as k, v {
        湊: ${k}は${v}です。
    }
}

k がキー、v が値です。配列と同じく、変数を1つだけ書くとキーになります。

ループの上限

無限ループを防ぐため、ループは最大2000回で自動的に停止します。

while

条件式が真の間、ブロックを繰り返し実行します。

OnBoot => {
    let n = 0
    while (n < 3) {
        n += 1
        湊: ${n}回目です。
    }
}

while (条件) { ... } の形で書きます。 条件式は、ブロックを実行する前に毎回評価されます。最初から偽ならブロックは一度も実行されません。

for と違って、初期化や更新の場所がありません。 ループの外で変数を用意し、ブロックの中で自分で更新します。 条件を偽にする処理(上の例の n += 1)を忘れると、ループが終わりません。

注意: セリフの直後に代入を書かない

セリフの直後の行は、let global call if などのキーワードで始まっていない限り、セリフの続き行として読まれます。 そのため、次のようにセリフのあとに n += 1 を書くと、代入は実行されず、n += 1 という文字がセリフとして出力されます。 n が増えないので、ループは上限の2000回まで続いて打ち切られます。

// NG
while (n < 3) {
    湊: ${n}回目です。
    n += 1
}

代入などの処理は、上の例のようにセリフより前に書いてください。セリフのあとに書きたいときは、; だけの行を挟みます。 詳しくは10. 文字コード・改行・エスケープを参照してください。

break / continue / return

ループの途中で抜けたり、次の周回へ進んだりできます。詳しくはbreak / continue / returnを参照してください。

OnBoot => {
    let n = 0
    while (true) {
        n += 1
        if (n == 2) {
            continue    // 2のときは表示せず次へ
        }
        if (n > 4) {
            break       // 4を超えたら抜ける
        }
        湊: ${n}
    }
}

while (true) のように条件を常に真にして、break で抜ける書き方もできます。 return を書くと、ループだけでなくトーク(や関数)全体を終了します。

ブロックの中の変数

ブロックの中で let した変数は、その周回の中だけで使えます。 次の周回には持ち越されません。 周回をまたいで値を持ち続けたい変数は、while の前で宣言してください。

ループの上限

無限ループを防ぐため、ループは最大2000回で自動的に停止します。 上限に達すると、while 文が打ち切られたことを知らせる警告が出ます。 条件式が偽にならない書き方になっていないか確認してください。

match

値によって処理を分岐します。多くの分岐がある場合に if / else if より読みやすくなります。

基本

OnBoot => {
    match save.好感度 {
        0 => {
            湊: はじめまして。
        }
        1 | 2 => {
            湊: 少し仲良くなりましたね。
        }
        _ => {
            湊: よく来てくれますね。
        }
    }
}

_ はワイルドカードで、どのパターンにも一致しない場合に実行されます。

複数のパターン

| で複数の値をまとめられます。

OnBoot => {
    match now.曜日 {
        0 | 6 => {
            湊: 今日は休日ですね。
        }
        _ => {
            湊: 今日は平日ですね。
        }
    }
}

文字列のmatch

数値だけでなく文字列にも使えます。

OnBoot => {
    match save.天気 {
        "晴れ" => {
            湊: いい天気ですね。
        }
        "雨" => {
            湊: 雨ですね。
        }
        _ => {
            湊: 今日はどんな天気ですか?
        }
    }
}

matchとifの使い分け

単一の値との比較が多い場合は match、範囲の比較(>= など)が必要な場合は if / else if が向いています。

break / continue / return

break

ループを途中で抜けます。for foreach while の中で使えます。

OnBoot => {
    let sum = 0
    for (let i = 0; i < 10; i++) {
        if (i == 5) {
            break
        }
        sum += i
    }
    湊: ${sum}で止まりました。
}

continue

現在のループの残りの処理をスキップして次のループに進みます。

OnBoot => {
    let result = ""
    for (let i = 0; i < 5; i++) {
        if (i % 2 == 0) {
            continue
        }
        result += to_str(i)
    }
    湊: 奇数は${result}です。
}

return

関数から値を返して終了します。func の中で使います。

func greet(name) {
    if (name == "") {
        return "名無しさん"
    }
    return name
}

OnBoot => {
    湊: こんにちは、${greet(save.名前)}。
}

値なしの return はそのまま関数を終了します。

func check() {
    if (!save.フラグ) {
        return
    }
    // フラグが真のとき(true、0以外の数値、空でない文字列など)だけここに来る
    global save.カウント += 1
}

ループの外でのbreak / continue

ループの外で break や continue を使うとパースエラーになります。 静的チェックで検出されます。

関数

このセクションでは湊の関数の使い方を説明します。

func定義と呼び出し

基本

func キーワードで関数を定義します。

func greet(name) {
    return "こんにちは、" + name + "。"
}

OnBoot => {
    湊: ${greet("湊")}
}

引数なし

func random_greeting() {
    if (rand() % 2 == 0) {
        return "こんにちは。"
    }
    return "やあ。"
}

OnBoot => {
    湊: ${random_greeting()}
}

複数の引数

func add(a, b) {
    return a + b
}

OnBoot => {
    湊: ${add(3, 5)}です。
}

グローバル変数の操作

関数の中から global でsaveを操作できます。

func increment(key) {
    global save[key] += 1
}

OnBoot => {
    increment("訪問回数")
    湊: ${save.訪問回数}回目です。
}

定義場所

関数は、基本的にトークの外(トップレベル)に定義します。 トップレベルの関数は、読み込み時に登録され、どのトークからも呼べます。 include で別ファイルに分けることもできます。

トークの中で定義する

func は、トークやブロックの中でも書けます。

OnBoot => {
    func hi(n) {
        return "hi " + n
    }
    湊: ${hi("a")}
}

ただし、トークの中の func は、その行が実行されたときに登録されます。 また、一度登録された関数は、そのトークの中だけでなく、以降は他のトークからも呼べるようになります(ゴーストを終了するまで残ります)。

  • 定義の行が実行される前に呼ぶと、その関数はまだ存在しないので、呼び出しの結果は空になります。
  • 同じ名前の関数を別のトークで定義すると、あとから実行されたほうで置き換わります。
  • 呼ぶ側のトークで名前が見つからない場合の確認には、talk_exists("名前") が使えます。

トークの中だけで使うつもりの関数が、他のトークから見えてしまうことがあるので、特別な理由がなければトップレベルに定義してください。

再帰

再帰呼び出し(関数が関数を呼ぶ入れ子)は、深さ約100が上限です(正確には、最初の呼び出しから数えて101段目までです)。 上限を超えた呼び出しは実行されず、空の値になります。

call文

call 文は、別のトークや関数をその場で呼び出します。 呼び出された側が出力したセリフは、call を書いた位置にそのままつながります。

書き方

call 名前

名前 にはトーク名か関数名を書きます。引数のリストや () は付けません。

トークを呼ぶ

イベント名に限らず、任意の名前でトークを定義できます。 それを call で呼び出せます。

挨拶 => {
    湊: こんにちは。
}

OnBoot => {
    call 挨拶
    湊: 今日もよろしくお願いします。
}

出力は \0こんにちは。今日もよろしくお願いします。 のように、call した位置に展開されます。 話者が同じ間はスコープタグ(\0)が繰り返されないので、呼ばれたトークのセリフと call の後のセリフは、そのままつながります。 話者が変わったときは、通常どおりタグが入ります。

呼ばれたトークは、通常のイベントと同じように次の処理が行われます。

  • 同じ名前のトークが複数あれば、条件フィルタ(if(...))で絞り込んだうえで、ランダムにひとつ選ばれます
  • 選ばれたトークが return で値を返すと、その値もセリフとして出力されます
挨拶 if((save.訪問回数 ?? 0) == 0) => {
    湊: はじめまして。
}

挨拶 => {
    湊: また会いましたね。
}

すべての候補が除外されたとき

call したトークの候補が、if(...) によってすべて除外されると、何も出力されません。 このときは「候補が全てcondで除外され、何も出力されませんでした」という notice が記録されます。

関数を呼ぶ

func で定義した関数も call で呼べます。 関数の中でセリフを書いていれば、その出力が call の位置に出ます。 return で返した値も、出力に加わります。

func 挨拶する() {
    湊: こんにちは。
}

OnBoot => {
    call 挨拶する
}

call は引数を渡せません。引数付きで呼びたいときは、次の 名前(引数) の書き方を使います。

名前() の書き方

文の位置(行の先頭)に 名前(引数) と書くと、call 名前 とほぼ同じ動作になります。 トークでも関数でも呼べ、関数には引数を渡せます。

func add(a, b) {
    湊: ${a}と${b}
}

OnBoot => {
    add(1, 2)          // 湊: 1と2
}

log(...) のようなビルトイン関数を文の位置に書いた場合は、副作用だけが実行され、戻り値は出力されません。

OnBoot => {
    log('起動しました')   // 何も出力されない
    湊: こんにちは。
}

呼び出す名前は、ユーザー定義の関数、トーク、ビルトイン関数の順に探されます。

なお、${挨拶する()} や let v = 挨拶する() のように式の中で書いた場合は、 戻り値が値として使われます。セリフの出力は call や文位置の 名前() のときだけです。

動的なcall

変数に入っている文字列を名前として呼ぶこともできます。 reference.0(SSPから渡されたreference)のように、実行時に名前が決まる場合に使えます。

OnBoot => {
    let target = "挨拶"
    call target       // 「挨拶」トークを呼ぶ
}

変数の値が文字列でないか空のときは、変数名そのものがトーク名・関数名として探されます。 その名前のトークも関数も存在しない場合は、何も起こりません。 定義されていない名前を call すると、静的チェックで notice が出ます (動的callの場合は無視して構いません)。

call OnUpdate.OnTypo のようなドット付きの名前は、call reference.0 などの動的callと見分けられないため、 通常は静的チェックの対象外です。ただし、OnUpdate.OnDownloadBegin のように同じ「OnUpdate.」で始まるトークが 定義されているのに、その名前だけが無い場合は、打ち間違いの可能性が高いとして notice が出ます。

呼び出しの深さ

call の入れ子(トークが別のトークを呼ぶ、関数が関数を呼ぶ)は、深さ約100が上限です(正確には、最初の呼び出しから数えて101段目までです)。 上限を超えた呼び出しは実行されません。

ビルトイン関数

数値・算術

関数説明例
floor(n)切り捨てfloor(3.7) → 3
ceil(n)切り上げceil(3.2) → 4
round(n)四捨五入round(3.5) → 4
trunc(n)小数部を切り捨てtrunc(3.9) → 3
abs(n)絶対値abs(-5) → 5
min(a, b)小さい方min(3, 5) → 3
max(a, b)大きい方max(3, 5) → 5
min(arr) / max(arr)配列内の最小 / 最大(空配列は null)max([3, 9, 5]) → 9
clamp(n, lo, hi)範囲内に収めるclamp(15, 0, 10) → 10
sqrt(n)平方根sqrt(4) → 2
rand()ランダムな整数rand() % 6 → 0〜5
rand(lo, hi)lo 以上 hi 以下のランダムな整数(両端を含む。lo > hi なら入れ替え)rand(1, 6) → 1〜6
pow(a, b)べき乗pow(2, 10) → 1024
sign(n)符号(負なら -1、0なら 0、正なら 1)sign(-3) → -1
exp(n)eの累乗exp(1) → 2.71828...
ln(n)自然対数(0以下は null)ln(exp(2)) → 2
log10(n)常用対数(0以下は null)log10(1000) → 3
sum(arr)数値配列の合計(空配列は 0)sum([1, 2, 3]) → 6
PI()円周率(括弧が必要。PI だけでは値になりません)PI() → 3.14159...
to_rad(deg)度をラジアンに変換to_rad(180) → 3.14159...
to_deg(rad)ラジアンを度に変換to_deg(PI()) → 180
to_hex(n)16進数文字列に変換to_hex(255) → "ff"
to_hex(n, digits)桁数指定で16進数に変換to_hex(255, 4) → "00ff"

自然対数は ln です。log は対数ではなくデバッグ出力用の関数(制御・ユーティリティ)なので注意してください。

三角関数

関数説明
sin(n)サイン(ラジアン)
cos(n)コサイン(ラジアン)
tan(n)タンジェント(ラジアン)
asin(n)アークサイン
acos(n)アークコサイン
atan2(y, x)アークタンジェント

文字列

関数説明例
len(s)文字数len("こんにちは") → 5
contains(s, sub)部分文字列を含むかcontains("abcde", "bc") → true
starts_with(s, p)文字列で始まるかstarts_with("abc", "ab") → true
ends_with(s, p)文字列で終わるかends_with("abc", "bc") → true
replace(s, from, to)文字列を置換replace("abc", "b", "X") → "aXc"
split(s, sep)文字列を分割して配列にsplit("a,b,c", ",") → ["a","b","c"]
join(arr, sep)配列を結合して文字列にjoin(["a","b","c"], ",") → "a,b,c"
trim(s)前後の空白を除去trim(" abc ") → "abc"
substr(s, start, count)部分文字列を取得substr("abcde", 1, 3) → "bcd"
index_of(s, sub)部分文字列の位置index_of("abcde", "bc") → 1
count(s, sub)部分文字列の出現回数count("pineapple", "p") → 3
to_lower(s)小文字に変換to_lower("Pineapple") → "pineapple"
to_upper(s)大文字に変換to_upper("Pineapple") → "PINEAPPLE"
to_hankaku(s)全角の英数字・記号・スペース・カタカナを半角に変換(濁点は分解)to_hankaku("ABC123") → "ABC123"
to_zenkaku(s)半角の英数字・記号・スペース・カタカナを全角に変換(濁点は合成)to_zenkaku("ガギ abc") → "ガギ abc"
chr(n)文字コードから文字に変換chr(65) → "A"
to_str(n)数値を文字列に変換to_str(123) → "123"
to_num(s)文字列を数値に変換to_num("123") → 123
format(fmt,…)書式付き文字列に変換詳細

count contains starts_with ends_with は大文字・小文字を区別します。 大文字小文字を無視して比較したい場合は to_lower で統一してから使ってください。

// 大文字小文字を無視してカウントする例
let n = count(to_lower("Pineapple"), "p")
// n == 3

正規表現

関数説明
regex_match(s, pat)パターンに一致するか
regex_find(s, pat)最初に一致した文字列を返す
regex_captures(s, pat)キャプチャグループを配列で返す
regex_replace(s, pat, rep)パターンに一致した部分を置換
regex_split(s, pat)パターンで分割して配列に
OnBoot => {
    let s = "2026年1月1日"
    let caps = regex_captures(s, "(\d+)年(\d+)月(\d+)日")
    湊: ${caps[1]}年${caps[2]}月${caps[3]}日ですね。
}

湊の文字列リテラルの中では \ はそのまま1文字として扱われます。 正規表現の \d などは \\d ではなく \d と書いてください (\\d と書くと \ が2文字残り、意図したパターンに一致しません)。

配列

関数説明例
len(arr)要素数len([1,2,3]) → 3
first(arr)最初の要素first([1,2,3]) → 1
last(arr)最後の要素last([1,2,3]) → 3
push(arr, v)末尾に追加した新しい配列を返すpush([1,2], 3) → [1,2,3]
pop(arr)末尾を除いた新しい配列を返すpop([1,2,3]) → [1,2]
slice(arr, start, end)部分配列を返すslice([1,2,3,4], 1, 3) → [2,3]
index_of(arr, v)要素の位置index_of([1,2,3], 2) → 1
count(arr, v)要素の出現回数count([1,2,1], 1) → 2
sum(arr)数値配列の合計(空配列は 0)sum([1,2,3]) → 6
min(arr)最小の要素(空配列は null)min([3,1,2]) → 1
max(arr)最大の要素(空配列は null)max([3,1,2]) → 3
sort(arr)昇順にソートsort([10,9,2]) → [2,9,10]
sort(arr, "desc")降順にソートsort([10,9,2], "desc") → [10,9,2]
sort(arr, "kana")かな順にソートsort(["ばなな","イチゴ","かき","ぱいん","がむ"], "kana") → ["イチゴ","かき","がむ","ぱいん","ばなな"]
reverse(arr)逆順にした新しい配列を返すreverse([1,2,3]) → [3,2,1]
unique(arr)重複を除いた新しい配列を返すunique([1,2,1,3]) → [1,2,3]

push pop sort reverse unique は元の配列を変更しません。新しい配列を返します。配列以外を渡すと、sort reverse unique は空配列を返します。

sortのモード

第2引数で並べ方を選びます。

  • 省略、または "kana" "desc" 以外の文字列("asc" など)のときは昇順です。エラーにはなりません。
  • 昇順・降順では、数値同士は数値の大小で比べます。それ以外は文字列表示のコードポイント順で比べます(sort([10,9,2]) は [2,9,10] ですが、sort(["10","9","2"]) は ["10","2","9"] です)。
  • "kana" では、数値も文字列表示で比べます(sort([10,9], "kana") → [10,9])。
  • "desc" と "kana" は同時に指定できません。逆かな順は reverse(sort(arr, "kana")) と書きます。

かな順の基準

"kana" は、各要素の文字列表示を次のように読み替えたキーで、コードポイント順に並べます。

  • カタカナはひらがなとして比べます(イ=い)。
  • 濁音・半濁音は清音と同じ扱いです(が=か、ぱ=は)。
  • 小書き文字は大きい字と同じ扱いです(ゃ=や、っ=つ)。
  • 長音「ー」は「あ」として比べます。「らあめん」と「らーめん」は同じキーです。
  • キーが同じ項目は、元の並びのままです(安定ソート)。
  • 漢字は読みに変換されず、コードポイント順で並びます(sort(["柿","梨","あ"], "kana") → ["あ","柿","梨"])。
  • 「ゐ」「ゑ」「ヴ」「ヵ」「ヶ」は読み替えの対象外です。「ヴ」は「う」と同じ扱いにならず、ひらがなの後ろに並びます(sort(["ヴ","う","え"], "kana") → ["う","え","ヴ"])。

漢字まじりの項目を五十音順に並べる

漢字は読みに変換されないので、読みをキーにしたマップを作り、キーを "kana" で並べます。表示名は値に持たせます。

OnBoot => {
    // 読み → [表示名, 説明]
    let 一覧 = {
        "ふれいむ": ["フレイム", "炎を放つ魔法。"],
        "ばりあー": ["バリアー", "身を守る障壁を張る。"],
        "あいすばーぐ": ["アイスバーグ", "巨大な氷塊を呼び出す魔法。"]
    }
    let 読み = sort(keys(一覧), "kana")
    foreach 読み as i, よみ {
        湊: ${一覧[よみ][0]}
    }
}

アイスバーグ、バリアー、フレイムの順に表示されます。

OnBoot => {
    global save.履歴 ?= []
    global save.履歴 = push(save.履歴, "起動")
}

マップ

関数説明例
has_key(map, key)キーが存在するかhas_key(data, "name") → true
keys(map)キーの配列を返す
values(map)値の配列を返す
delete(map, key)キーを除いた新しいマップを返す
len(map)キーの数
get(map, key, default)キーの値を返す。なければ default(省略時は null)get(data, "age", 0)

get は配列にも使えます(get(arr, 添字, default))。範囲外の添字や負の添字でも警告を出さず、default を返します。 data["age"] のように直接参照すると、存在しないキーや範囲外の添字では通知や警告が出ます。

日付・時刻

関数説明
days_since(year, month, day)指定した日から今日までの日数
days_between(y1, m1, d1, y2, m2, d2)1つ目の日から2つ目の日までの日数(2つ目のほうが後なら正の数)

存在しない日付(2026, 2, 30 など)を渡すと、どちらも null を返します。

OnBoot => {
    let days = days_since(2026, 1, 1)
    湊: 2026年1月1日から${days}日経ちました。
}

制御・ユーティリティ

関数説明例
choose(cond, a, b)condが真ならa、偽ならb(選ばれなかった側の式は評価されません)choose(flag, "はい", "いいえ")
talk_exists(name)その名前のトークまたは関数が定義されているかtalk_exists("挨拶") → true
log(v, ...)値をログに書き出す(戻り値は null)log("起動しました")

log は、debug_log が有効なときだけ、ゴーストフォルダの minato_debug.log に [SCRIPT] メッセージ の形で書き込みます。 複数の値を渡すと , でつないで1行にします。無効なときは何もしません。

システム連携

関数説明
get_property(name)SSPのプロパティを取得する
saori(dll, arg0, arg1, ...)SAORIを呼び出す

saori の詳細はSAORI連携を参照してください。

get_property

SSPに、そのプロパティの値を問い合わせます(SSTPの GetProperty)。プロパティ名はSSPのドキュメントを見てください。

OnBoot => {
    let name = get_property("currentghost.name")
    湊: いまのゴーストは${name}です。
}
  • 戻り値はいつも文字列です。SSPにつながらない、応答が遅い、そのプロパティがない、といった理由で取れなかったときは、空文字列 "" が返ります。エラーや警告にはなりません。
  • 問い合わせのあいだ、湊は応答を待ちます。待つのは通常、長くても1回あたり1秒前後ですが、そのあいだSSPが固まったように見えることがあります。ランダムトークのたびに呼ぶような使い方は避けてください。

外部呼び出しは1イベント10回まで

get_property() と saori() は、合わせて1イベントあたり10回までしか呼べません。 SSPやSAORIの応答を待つあいだにSSP全体が止まるのを、ループで何度も繰り返さないための上限です。

11回目以降は実際には呼び出されず、get_property() は空文字列、saori() は空の配列を返します。

関数上限を超えたとき
saori()応答の ErrorLevel に warning が付く
get_property()警告は出ない。debug_log = true のときのログ(minato_load.log)にだけ記録される

数はイベントごとに0に戻ります。トークの条件フィルタの中で呼んだ分も数えます。 ループの中で呼ぶ必要があるときは、ループの前に1回だけ呼んで、結果を let に入れて使ってください。

OnBoot => {
    // NG: 11回目から空になる
    for (let i = 0; i < 20; i++) {
        let r = saori("calc.dll", i)
    }

    // OK: 1回だけ呼んで使い回す
    let name = get_property("currentghost.name")
    for (let i = 0; i < 20; i++) {
        湊: ${name}
    }
}

型の判定

関数説明例
is_num(v)v が数値かis_num(42) → true、is_num("42") → false
is_str(v)v が文字列かis_str("42") → true
is_bool(v)v が true / false かis_bool(false) → true
is_array(v)v が配列かis_array([1, 2]) → true
is_map(v)v がマップかis_map({"a": 1}) → true
is_null(v)v が null かis_null(save.名前) → true
type_of(v)型の名前を返す("number"・"string"・"bool"・"array"・"map"・"null" のどれか)type_of(3.14) → "number"
  • 調べるのは今の値の型だけです。"42" のような数字の文字列は文字列なので、is_num("42") は false です。
  • 未定義の変数は null になるので、is_null(save.未定義) は true、type_of(save.未定義) は "null" です。
  • 引数を省略すると null を渡したのと同じ扱いになります。

JSONから読み込んだ値を使う前に確かめるときに便利です。

let data = json_parse(file_read("ghost/master/config.json"))
if (is_num(data.回数)) {
    湊: ${data.回数}回目です。
}

JSON

関数説明戻り値
json_parse(s)JSONの文字列を値(マップ・配列など)に変換する変換した値。失敗したら null
json_stringify(v, pretty)値をJSONの文字列に変換する。pretty が true なら字下げ付き、省略すると1行JSONの文字列。失敗したら null

ファイル操作

関数説明戻り値
file_read(path, enc)テキストファイルを読み込む内容の文字列。失敗したら null
file_write(path, text, enc)ファイルに書き込む(既存の内容は置き換え)成功したら true、失敗したら false
file_append(path, text, enc)ファイルの末尾に追記する(なければ作る)成功したら true、失敗したら false
file_move(from, to, overwrite)ファイルを移動(名前変更)する成功したら true、失敗したら false

書き込みできる場所の制限や使用例はファイルとJSONを参照してください。

format

書式を指定して文字列に変換します。 小数点以下の桁数を揃えたいときや、ゼロ埋めをしたいときに使います。

基本

format(フォーマット文字列, 値1, 値2, ...)

よくある困りごと

体重や確率を表示すると桁数がばらついて見づらい。

// to_str だと桁数が揃わない
// "57.8"
湊: 体重は${to_str(57.8)}kgです。
// "0.3333"
湊: 確率は${to_str(0.3333)}です。

format で解決

// "57.8"
湊: 体重は${format("%.1f", 57.8)}kgです。
// "33%"
湊: 確率は${format("%.0f", 33.33)}%です。
// "007番目"
湊: ${format("%03d", 7)}番目。

フォーマット指定子一覧

指定子意味例結果
%d整数format("%d", 42.9)"42"
%f小数(デフォルト6桁)format("%f", 3.14)"3.140000"
%.Nf小数点以下N桁format("%.2f", 3.14159)"3.14"
%.0f小数点以下を丸めるformat("%.0f", 3.7)"4"
%s文字列format("%s", "湊")"湊"
%Nd幅N文字で右寄せformat("%5d", 42)" 42"
%0Nd幅N文字でゼロ埋めformat("%03d", 7)"007"
%%% 自体format("%%")"%"

複数の値

フォーマット文字列に複数の指定子を書くと、順番に値が埋め込まれます。

OnBoot => {
    let y = now.年
    let m = now.月
    let d = now.日
    湊: 今日は${format("%d年%02d月%02d日", y, m, d)}です。
}

save の値を表示する

OnBoot => {
    湊: 体重は${format("%.1f", save.体重)}kgです。
    湊: 勝率は${format("%.1f", save.勝利数 / save.対戦数 * 100)}%です。
}

注意

  • %d は小数を整数に切り捨てます。四捨五入したい場合は round() を組み合わせてください。
  • 指定子の数より値が少ない場合は 0 または空文字列として扱われます。
// 四捨五入してから整数表示
// "4"
湊: ${format("%d", round(3.7))}

トーク制御

このセクションでは湊のトーク選択と制御の仕組みを説明します。

条件フィルタ

トークに条件を付けると、条件を満たすときだけ選択候補に入ります。

基本

イベント名の後に if(条件式) を書きます。

OnRandomTalk if(save.好感度 >= 50) => {
    湊: 仲良くなりましたね。
}

OnRandomTalk => {
    湊: こんにちは。
}

save.好感度 >= 50 のときは両方が候補になります。 満たさないときは2番目だけが候補になります。

複数の条件

&& や || で組み合わせられます。

OnRandomTalk if(save.好感度 >= 50 && now.時 >= 18) => {
    湊: 夜ですね。仲良くなりましたね。
}

全候補が条件を満たさない場合

すべての候補が条件を満たさない場合、そのイベントは実行されません。 必要に応じて条件なしのトークを用意しておくと安全です。

OnRandomTalk if(save.フラグ == true) => {
    湊: フラグが立っています。
}

OnRandomTalk => {
    湊: 通常のトークです。
}

ランダムトークの仕組み

ランダムトークの発火

湊はSSPから OnMinuteChange イベントを受け取るたびに、 一定時間が経過していれば OnRandomTalk を実行します。 (OnMinuteChange は湊が内部で使うため、台本に OnMinuteChange => { ... } を書いても呼ばれません)

ただし、OnMinuteChange の Reference3(SSPがトークを再生できる状態かどうか。再生できるときは 1)が 1 でないときは、一定時間が経過していても OnRandomTalk は実行されません。 たとえば、他のトークの再生中などでSSPがトークを受け付けられないときは、ランダムトークは発火しません。 次の OnMinuteChange で、あらためて判定されます。

時間の間隔は config.toml で設定できます。

[settings]
talk_interval_secs = 300   # 基本間隔(秒)
talk_jitter_secs = 180     # ゆらぎ(秒)

この例では300〜480秒のランダムな間隔でランダムトークが発火します。

初回起動後は save.json の system.talk_interval / system.talk_jitter が config.toml より優先されます。 詳しくは設定(config.toml)を参照してください。

手動でのランダムトーク

SSPのメニューや \a タグからユーザーが手動でトークを要求すると OnAITalk イベントが発生します。 湊は OnAITalk を OnRandomTalk として処理します。 手動のトークも OnRandomTalk => { ... } に書いてください(OnAITalk => { ... } は呼ばれません)。

トーク選択の流れ

  1. OnRandomTalk の全候補を取得する
  2. 条件フィルタ(if(...))を評価して候補を絞り込む
  3. 候補からランダムにひとつ選ぶ(直前に選ばれたトークはなるべく避け、全候補が出そろうまで同じものは選ばない)
  4. 選ばれたトークを実行する

直前のランダムトークの記録(save.last_talk)

OnRandomTalk(OnAITalk を含む)でトークが実行されるたびに、湊はその出力(末尾の \e を除いたさくらスクリプト)を save.last_talk に自動で保存します。 書いた覚えのない last_talk が save.json に入るのは、このためです。

OnRandomTalk => {
    湊: 前回のトークは「${save.last_talk}」でした。
}

不要であれば、無視して構いません。

仮想時刻

湊はSSPから現在時刻を取得して now 変数に格納します。 湊が起動してからSSPの時刻を取得できるまでは、日本標準時(UTC+9)の現在時刻が使われます。 now の詳細はnow・referenceの使い方を参照してください

now・referenceの使い方

now

now は現在時刻を持つマップです。トークの中でいつでも参照できます。

キー内容例
now.年年2026
now.月月1〜12
now.日日1〜31
now.時時0〜23
now.分分0〜59
now.秒秒0〜59
now.曜日曜日0=月〜6=日

nowの使用例

OnRandomTalk => {
    if (now.時 >= 6 && now.時 < 12) {
        湊: おはようございます。
    } else if (now.時 >= 12 && now.時 < 18) {
        湊: こんにちは。
    } else {
        湊: こんばんは。
    }
}

曜日の判定

OnRandomTalk => {
    match now.曜日 {
        0 | 1 | 2 | 3 | 4 => {
            湊: 今日は平日ですね。
        }
        5 | 6 => {
            湊: 今日は休日ですね。
        }
    }
}

reference

reference はSSPからイベントと一緒に渡される付加情報です。 マップ形式で、キーは番号の文字列です。

OnMouseDoubleClick => {
    湊: クリックされた場所はX=${reference["0"]}、Y=${reference["1"]}です。
}

どのイベントでどの reference が渡されるかはSSPのドキュメントを参照してください。

status

status は、SSPがイベントと一緒に送る Status ヘッダの内容を持つマップです。 ゴーストの今の状態を、トークの中で判定できます。

キー内容
status.talking発話中か
status.choosing選択肢の表示中か
status.minimizing最小化中か
status.induction誘導中か
status.passiveパッシブモードか
status.timecriticalタイムクリティカルな状態か
status.nouserbreakユーザーによる中断ができない状態か
status.onlineオンライン状態か
status.rawStatus ヘッダの元の文字列

raw 以外は true / false です。Status ヘッダにそのフラグが含まれていれば true になります。 ヘッダがないイベントでは、すべて false です(前のイベントの値は持ち越されません)。

OnRandomTalk => {
    if (status.minimizing) {
        // 最小化中は何も喋らない
        return
    }
    湊: こんにちは。
}

各フラグの意味の詳細は、SSPのドキュメントの Status ヘッダの項を参照してください。

SAORI連携

SAORIは伺かのプラグイン規格です。外部のDLLを呼び出して、湊だけでは実現できない機能を追加できます。

基本

saori(dll名, 引数0, 引数1, ...) で呼び出します。 戻り値は配列です。

OnBoot => {
    let result = saori("hoge.dll", "引数1", "引数2")
    湊: 結果は${result[0]}です。
}

DLLの配置

SAORIのDLLはゴーストフォルダに置きます。

ghost/master/
├── shiori.dll
├── config.toml
├── hoge.dll        ← SAORIのDLL
└── talks/
    └── main.mnt

戻り値

SAORIの戻り値は配列として返されます。 インデックス0が最初の戻り値です。

OnBoot => {
    let result = saori("hoge.dll", "引数")
    let value0 = result[0]
    let value1 = result[1]
}

DLLの読み込みエラー

DLLが見つからない場合や読み込みに失敗した場合、パスが不正(絶対パスや .. を含む)な場合は、 空の文字列が返されます(配列ではありません)。 len(result) は 0 になり、result[0] のように添字でアクセスすると null になって警告が出ます。

このときのエラー(error レベル)は、debug_log の設定に関係なく、SHIORI応答の ErrorLevel / ErrorDescription ヘッダに載ります。 debug_log = true にすると、minato_load.log にも詳細が記録されます。

戻り値が使えたかどうかは、len(result) で確かめてください。

注意

  • 湊はSAORIに、引数を UTF-8 で送ります(Charset: UTF-8)。応答もUTF-8として読みます。Shift_JISへの変換は行いません。
  • ただし、DLLを読み込むときにSAORIへ渡すDLLのフォルダのパスは、Shift_JIS(表せない場合はUTF-8)です。
  • リクエストの Charset ヘッダを見ずにShift_JISで処理する古いSAORIに日本語の引数を渡すと、文字化けや誤動作の原因になります。使うSAORIごとに確認してください。詳しくは8. SAORI呼び出しを参照してください。
  • saori() は、get_property() と合わせて1イベントあたり10回までしか呼べません。11回目以降は呼び出されず、空の配列が返り、警告が出ます。詳しくは外部呼び出しは1イベント10回までを見てください。
  • DLLは初回呼び出し時に読み込まれ、ゴーストが終了(または再読み込み)されるまで保持されます。ただし、次の場合は例外です。
    • 同時に保持できるDLLは32個までです。超えると、最も長く使われていないものから解放されます。
    • 応答が5秒以内に返らなかったDLLは、そのゴーストを再読み込みするまで、二度と呼び出されません。
    • 1回のイベントの処理中に呼べる回数は、saori() と get_property() を合わせて10回までです。超えた呼び出しは実行されません。

設定(config.toml)

config.toml はゴーストフォルダに置く設定ファイルです。

キャラクター設定

[characters]
"湊" = "\\0"
"助手" = "\\1"

キャラクター名とSAKURAスクリプトのタグを対応させます。 スクリプト内でキャラクター名を使うとここで設定したタグに変換されます。

OnBoot => {
    // \0こんにちは。 に変換される
    湊: こんにちは。
    // \1よろしく。 に変換される
    助手: よろしく。
}

キー名は引用符で囲む

日本語のキャラクター名は、TOMLの仕様上キー名を "湊" のように引用符で囲む必要があります。 引用符なしの 湊 = "\\0" と書くとパースエラーになり、ゴーストが起動しません。 半角英数字だけの名前(minato = "\\0" など)なら引用符は不要です。

値の "\\0" は、TOMLのエスケープ規則により \\ が \ 1文字を表すため、\0(バックスラッシュと0)になります。 "\0" と1つだけ書くとTOMLの不正なエスケープになり、config.toml の読み込みに失敗してゴーストが起動しません。

未登録のキャラクター名

[characters] に登録していない名前をセリフに使った場合、その名前は \0 として扱われます。 セリフは出ますが、話者が \0 になります。

動作設定

[settings]
talk_interval_secs = 300    # ランダムトークの基本間隔(秒)
talk_jitter_secs = 180      # ランダムトークの揺らぎ(秒)
debug_log = false           # trueにするとminato_load.logなどを生成する
auto_newline = true         # 話者が戻ったときに \n を自動で挿入するか

すべて省略可能です。省略した項目は上に書いたデフォルト値になります。

talk_interval_secs / talk_jitter_secs

ランダムトークの発火間隔を設定します。 実際の間隔は talk_interval_secs から talk_interval_secs + talk_jitter_secs の間でランダムになります。

デフォルトは300〜480秒(5〜8分)です。

debug_log

true にするとゴーストフォルダに minato_load.log(動作ログ)、minato_request.log(直近のSSPからのリクエスト)が生成されます。 台本の log() を使っている場合は、minato_debug.log にもその出力が書かれます。 各ファイルの内容はファイル構成を参照してください。 動作がおかしいときのデバッグに使います。 リリース時は false に戻してください。

auto_newline

デフォルトは true です。 話者が切り替わったあと、すでに喋ったことのある話者に戻るときに、 タグの直後へ \n を自動で挿入します。

OnBoot => {
    湊: こんにちは。
    助手: よろしくお願いします。
    // \0\nそれでは始めましょう。 のように、タグの直後に改行が入る
    湊: それでは始めましょう。
}

false にすると自動の改行は入りません。改行が必要なら \n を自分で書きます。

save.json の system.* が優先される

talk_interval_secs / talk_jitter_secs / debug_log の3つは、 初回起動のときに config.toml の値が使われ、ゴーストの終了時などに save.json の system.talk_interval / system.talk_jitter / system.debug_log として保存されます。

2回目以降の起動では、save.json に保存された値が config.toml より優先されます。 そのため、初回起動後に config.toml の値を書き換えても反映されません。 たとえば config.toml で debug_log = true にしても、save.json の system.debug_log が false なら、ログは出ません。

設定を変更したいときは、次のどちらかを行ってください。

  • ゴーストを終了してから save.json の system の該当項目を書き換える
  • save.json の system を削除する(config.toml の値が使われるようになります)

スクリプトの中から global system.talk_interval = 600 のように書き換えることもでき、 その値も save.json に保存されます。

system 変数の一覧

台本からは、system というマップで湊の設定や情報を参照できます。

キー内容保存
system.talk_intervalランダムトークの基本間隔(秒)save.json に保存される
system.talk_jitterランダムトークの揺らぎ(秒)save.json に保存される
system.debug_logログ出力が有効か(true / false)save.json に保存される
system.ghost_dirゴーストの master フォルダのパス保存されない(起動のたびに設定される)
system.version湊のバージョン(例: "0.1.0")保存されない(起動のたびに設定される)

system.ghost_dir と system.version は情報の参照用です。値を書き換えても、次の起動では元に戻ります。

使われない設定項目

次の2つは config.toml に書いてもエラーにはなりませんが、現在の湊では効果がありません。

項目説明
shuffle_reset読み込まれますが、実装のどこでも使われていません。トークの選択は、直前に選ばれたトークをなるべく避け、全候補が出そろうまで同じものは選ばない仕組みで固定されています
encoding読み込まれますが、実装のどこでも使われていません。スクリプトの文字コードは指定できません(UTF-8で保存してください)

最小構成

[settings] は省略できます。省略した場合はすべてデフォルト値が使われます。

[characters]
"湊" = "\\0"

include

include を使うと別の .mnt ファイルを読み込めます。 スクリプトが大きくなってきたときにファイルを分割するのに使います。

基本

include "random.mnt"
include "boot.mnt"

main.mnt の先頭に書くのが一般的です。 main.mnt は talks/ フォルダの中にあるので、talks/ は付けずに、main.mnt と同じフォルダからの相対パスで書きます。 include "talks/random.mnt" と書くと talks/talks/random.mnt を探してしまい、読み込みエラーになります。

ファイル構成の例

ghost/master/
├── shiori.dll
├── config.toml
└── talks/
    ├── main.mnt        ← includeをまとめる
    ├── boot.mnt        ← 起動・終了系
    ├── random.mnt      ← ランダムトーク
    └── func.mnt        ← 関数定義
// main.mnt
include "boot.mnt"
include "random.mnt"
include "func.mnt"

注意

  • パスは、include を書いたファイルがあるフォルダからの相対パスです。main.mnt から書くときは talks/ フォルダが基準ですが、サブフォルダの中のファイルから include するときは、そのサブフォルダが基準になります(たとえば talks/sub/a.mnt の中の include "b.mnt" は talks/sub/b.mnt を読み込みます)
  • 同じファイルを複数回includeしても1回だけ読み込まれます
  • includeは再帰的に使えます(includeしたファイルの中でincludeできます)

トップレベルの定義

include したファイルでもトーク・関数・グローバル変数の定義がすべて使えます。

// func.mnt
func greet(name) {
    return "こんにちは、" + name + "。"
}

global save.バージョン = "1.0"

ファイルとJSON

湊の台本から、ゴーストのフォルダにあるテキストファイルを読み書きできます。 JSONの関数と組み合わせると、辞書や設定を別ファイルで管理できます。

できること・できないこと

ゴーストを壊さないように、ファイル操作には制限があります。最初にここを確認してください。

できることできないこと
テキストファイルの読み込み(1MBまで)1MBを超えるファイルや、文字コードが合わないファイルの読み込み
ghost/master の中への書き込み・追記ghost/master の外への書き込み
ファイルの移動・名前変更(file_move)ファイルのコピーと削除(関数がありません)
UTF-8 と Shift_JIS の読み書きフォルダの作成・移動
JSONの読み込み・書き出し絶対パス・ドライブ指定・.. を含むパスの指定

ghost/master の中でも、次のものには書き込めません(file_move の移動元・移動先も同じです)。

  • talks フォルダの中(台本)
  • config.toml、descript.txt、save.json で始まるファイル
  • .dll ファイル
  • minato_ で始まるファイル

コピーしたいときは file_read で読んで file_write で書き、削除したいときは空の内容を書き込むか、別の名前に移動して使わないようにしてください。

ファイルの読み書き

関数説明戻り値
file_read(path, enc)テキストファイルを読み込む内容の文字列。失敗したら null
file_write(path, text, enc)ファイルに書き込む(既存の内容は置き換え)成功したら true、失敗したら false
file_append(path, text, enc)ファイルの末尾に追記する(なければ作る)成功したら true、失敗したら false
file_move(from, to, overwrite)ファイルを移動(名前変更)する成功したら true、失敗したら false
OnBoot => {
    let ok = file_write("ghost/master/memo.txt", "こんにちは")
    湊: ${ok}|${file_read("ghost/master/memo.txt")}
}
  • path は、ゴーストのホーム(ghost フォルダの1つ上)からの相対パスで書きます。/ でも \ でも構いません。
  • enc(文字コード)は省略でき、既定は UTF-8 です。Shift_JIS のファイルは "sjis" を指定します("utf8" も指定できます)。
  • file_move は、移動先に同名のファイルがあると、既定では移動せず false を返します。上書きするには、第3引数に true を指定します。
  • file_write は一時ファイルを経由して書き込むので、途中で落ちても既存のファイルが壊れません(file_append は直接追記します)。

JSON

関数説明戻り値
json_parse(s)JSONの文字列を値(マップ・配列など)に変換する変換した値。失敗したら null
json_stringify(v, pretty)値をJSONの文字列に変換する。pretty が true なら字下げ付き、省略すると1行JSONの文字列。失敗したら null
  • マップのキーは、JSONに書かれた順(台本で入れた順)のまま保たれます。
  • 先頭にBOMが付いたJSONも読めます。
  • JSONの null を正しく読んだ場合も null が返りますが、このときは警告は出ません。
  • 数値は内部ではすべて小数として扱います。整数で表せる値は 100 のように整数で書き出します。ただし 9007199254740992(2の53乗)を超える整数は正確には扱えません。
  • JSONで表せない数値(NaN・無限大)は null として書き出します。
  • json_stringify は、入れ子が100段を超える値や、結果が1MB(file_read で読める上限)を超える値は変換せず、警告を出して null を返します。

例:JSONで辞書を管理する

リポジトリの examples/json_dict に、JSONファイルでゴーストの辞書を管理するサンプルがあります。ここではその要点を紹介します。

ghost/master/dict.json に「分類名 → 言葉の配列」を書いておきます。

{
  "挨拶": ["やあ。", "こんにちは。"],
  "話題": ["今日はいい天気だね。", "お茶でも飲もうか。"]
}

読み込みでは、file_read と json_parse のどちらが失敗しても空の辞書にしておきます。こうすると、後の処理が null を相手に警告を出し続けることがありません。 JSONとして正しくても、中身がマップでなければ使えないので、is_map で確かめます(is_map(null) は false なので、読み込みの失敗もここでまとめて扱えます)。

func dict_load() {
    let text = file_read("ghost/master/dict.json")
    if (is_null(text)) {
        global dict = {}
        return false
    }
    let data = json_parse(text)
    if (!is_map(data)) {
        global dict = {}
        return false
    }
    global dict = data
    return true
}

保存では、手で編集しやすいように字下げ付きで書き出します。

func dict_save() {
    let text = json_stringify(dict, true)
    if (is_null(text)) {
        return false
    }
    return file_write("ghost/master/dict.json", text)
}

分類の中身は is_array で確かめます。dict.json を手で書き間違えて "挨拶": "やあ。" のように配列でなくなっていても、空の分類として扱えば他の関数がそのまま動きます。

func dict_words(cat) {
    let words = get(dict, cat, [])
    if (!is_array(words)) {
        return []
    }
    return words
}

使う側では、戻り値がセリフに出ないように変数で受けます。

OnBoot => {
    let loaded = dict_load()
    うきわ君: ${dict_word("挨拶")}
}

ランダムに1つ選ぶ dict_word、追加・削除の dict_add / dict_remove など、残りの関数はサンプルの talks/dict.mnt を見てください。

例:アイテム表を読み込む(ダンジョンRPG)

リポジトリの examples/dungeon_rpg は、探索・戦闘・店・持ち物があるダンジョンRPGのサンプルです。 アイテムとモンスターの強さを items.json と monsters.json に書いておき、起動時に読み込みます。

{
  "薬草": { "種類": "回復", "値段": 10, "回復": 15 },
  "銅の剣": { "種類": "武器", "値段": 40, "攻撃": 3 }
}

店の品ぞろえは、読み込んだ表を foreach で回して選択肢にしています。表に行を足せば、台本を直さなくても店に並びます。

OnShop => {
    うきわ君: いらっしゃい。
    foreach items as name, item {
        \q[${name}(${num_field(item, "値段")}G),OnBuy,${name}]
    }
    \q[戻る,OnMenu]
}

表の数値は num_field で読みます。表を手で書いて "値段": "10" のように文字列にしてしまっても、is_num と is_str で確かめて数値に直します。

func num_field(row, key) {
    let v = get(row, key, 0)
    if (is_num(v)) {
        return v
    }
    if (is_str(v)) {
        return to_num(v)
    }
    return 0
}

読み込みでは、表全体がマップでなければ空の表にし、マップでない行は読み飛ばします。くわしくはサンプルの talks/rpg.mnt を見てください。

\q[...] だけの行でも、セリフと同じように ${...} が展開されます。

主人公のHPや所持金、持ち物は save に置くので、ゴーストを閉じても続きから遊べます。

失敗したとき

読み書きに失敗しても、台本は止まらずに null や false が返ります。原因の調べ方はよくあるミスを参照してください。

エラーと対処

このセクションでは湊のエラーの読み方と対処法を説明します。

構文チェッカー

SSPにゴーストを読み込ませなくても、手元でスクリプトの誤りを確かめられるツールがあります。 構文エラーと静的解析の結果(未定義の call、ループの外の break など)を一覧で表示します。

チェックするだけなので、save.json を読み書きしたり、SAORIを読み込んだりはしません。

ツールは2種類あります。

ツール向いている人
minato_check_gui.exe(GUI版)ダブルクリックで使いたい人
minato_check.exe(CLI版)コマンドラインやスクリプトから使いたい人

どちらも同じチェックを行います。

GUI版(minato_check_gui)

  1. minato_check_gui.exe をダブルクリックして起動します。
  2. ゴーストのフォルダか、talks フォルダの中の .mnt ファイルを窓にドラッグ&ドロップします。 ドロップできないときは「フォルダを選ぶ」「ファイルを選ぶ」ボタンから選んでください。
  3. エラー・警告・お知らせが色分けされて一覧で表示されます。

exeのアイコンにフォルダをドロップして起動することもできます。 前回選んだフォルダは記憶され、次に選択画面を開くとそこから始まります。

渡すフォルダは、ゴーストのフォルダ(ghost/master を含むフォルダ)でも、ghost/master そのものでもかまいません。 talks/main.mnt がある場所を自動で探します。

CLI版(minato_check)

talks/main.mnt を含むフォルダ(ゴーストの ghost/master)を指定して実行します。

minato_check.exe "C:\ghost\mighost\ghost\master"

GUI版と違い、CLI版には ghost/master そのものを渡してください。

オプション意味
--no-color色付き表示をやめる(ログに保存するときなど)
-h / --help使い方を表示する

診断は1件につき次のような形で表示されます。

[error] main.mntの4行目(OnBoot内): メッセージ ヒント: …

問題がなければ「構文・静的解析ともに問題ありませんでした」と表示されます。 診断は標準エラー出力に、問題なしのメッセージだけが標準出力に出ます。

終了コード

終了コード意味
0問題なし、または warning / notice だけ
1構文エラーか、errorレベルの静的解析結果がある

コミット前のチェックやCIに組み込むときは、この終了コードで判定してください。

ドラッグ&ドロップで使う

minato_check.exe と tools\minato_check.bat を同じフォルダに置くと、 minato_check.bat に ghost/master フォルダをドラッグ&ドロップするだけでチェックできます。 結果を読めるように、終わったあと窓は閉じずに止まります。

自分でビルドする

Rustの環境があれば、ソースからビルドできます。

cargo build --release --features cli --bin minato_check

--features cli はチェッカー専用の依存を有効にするためのもので、本体DLLのビルドには必要ありません。

表示されるレベル

レベル意味
errorこのままでは読み込めない。直す必要がある
warning動くが、意図どおりでない可能性が高い
notice動作には影響しない。念のための知らせ

それぞれのエラーの読み方はパースエラーの読み方を、よくある原因はよくあるミスを見てください。

パースエラーの読み方

湊はスクリプトの読み込み時にエラーを検出すると、 起動時のセリフとしてエラーメッセージを表示します。

エラーメッセージの形式

パースエラー:
main.mntの3行目(OnBoot内): ループの外で break を使っています
sub.mntの9行目(OnClose内): ループの外で continue を使っています

1件ごとに ファイル名の行番号行目: メッセージ の形式で表示されます。 include したファイルで起きたエラーには、そのファイルの名前が付きます。 静的チェックのエラーには、(OnClose内) のように、どのトーク・関数の中かが付きます。 直し方の手がかりがあるときは、次の行に ヒント: が続きます。

パースエラー:
main.mntの1行目: 1行目の「{」が閉じられていません
ヒント: 対応する「}」を書いてください

{ の閉じ忘れは、ファイルの末尾ではなく、閉じられていない { を開いた行で報告されます。 閉じられていない { が複数あるときは、最後に開いたもの(いちばん内側)を報告します。

それ以外のエラーで指摘される行は、湊が間違いに気づいた位置です。指摘された行で原因が見つからないときは、その少し前も確認してください。

デバッグログ

config.toml で debug_log = true にすると minato_load.log により詳細な情報が記録されます。

[settings]
debug_log = true

2回目以降の起動では save.json の system.debug_log が config.toml より優先されます。 詳しくは設定(config.toml)を参照してください。

静的チェック

パースが成功した後、以下のチェックが行われます。

チェック内容レベル
ループの外での break / continueエラー
未定義のトーク・関数の callnotice

エラーレベルのものが1つでもあると、ゴースト全体が読み込みに失敗した状態になります。 SSPからのDLLの読み込み自体は成功し、OnBoot のときにエラーの内容が表示されますが、それ以降のイベントには何も返さなくなります(このとき save.json は上書きされません)。 noticeレベルの場合は通常どおり動作します。内容は debug_log = true のときにログに記録されます。

実行中の警告と通知

パースや静的チェックを通ったあとも、トークの実行中に問題が見つかることがあります。 湊はこうした問題では処理を止めず、SSPへの応答の ErrorLevel / ErrorDescription ヘッダに内容を載せます。 複数あるときは、区切り文字でつないで1つのヘッダに入れます。 SSPがこれをどう表示するかは、SSPのバージョンや設定によります。

ErrorLevel には、error、warning、notice のどれかが入ります。主な例は次のとおりです。

状況結果レベル
配列の範囲外の添字(a[3])nullwarning
配列でもマップでもない値への添字アクセスnullwarning
配列の範囲外の添字への代入(a[3] = 1)代入されないwarning
マップに存在しないキーの参照(m["zz"])nullnotice
ゼロ除算(/、%)nullwarning
ループが上限(2000回)に達したループを打ち切るwarning
saori() が1イベントの外部呼び出し上限(get_property() と合わせて10回)を超えた空の配列を返すwarning
call したトークの候補が、すべて if(...) で除外された何も出力されないnotice
saori() やファイル関数の失敗空の値や falsewarning / error
  • 未定義の変数を ${x} で参照しても、空文字列になるだけで、警告も通知も出ません。書き間違いに気づきにくいので注意してください。
  • トークの if(...) 条件の評価中に出た警告と通知は、選ばれなかったトークの条件が原因のことが多いため、記録されません(error レベルだけは記録されます)。
  • トークが選ばれたのに出力が空だったときは、SSPには何も返さず(204)、「出力が空でした」という notice が記録されます。この notice が応答に載るのは、debug_log = true のときだけです。
  • ゴーストの読み込み時に見つかった問題(台本の一番上にある global 文のエラーなど)は、最初のイベントへの応答に、一度だけ載ります。

ログに残したい内容がある場合は、config.toml の debug_log = true を設定し、台本から log() を使うこともできます(ビルトイン関数)。

複数のエラー

エラーはファイル名・行番号の順に並びます。 吹き出しには最初の3件までを表示し、残りは「他n件(minato_check で全件を確認できます)」とまとめます。 全件は minato_check で確認できます。SSPへの応答の ErrorDescription ヘッダにも全件が載ります。 上から順に直していくと効率的です。

よくあるミス

ブロックの閉じ忘れ

{ に対応する } がない場合に発生します。

// NG
OnBoot => {
    湊: こんにちは。
// } を忘れている

インデントを揃えると見つけやすくなります。

比較に = を使う

条件式で == のところを = と書いてしまうケースです。 湊はこれを検出してエラーメッセージを出します。

// NG
if (save.フラグ = true) {

// OK
if (save.フラグ == true) {

キャラクター名のtypo

config.toml に登録していない名前を使っても、セリフは出ます。 ただし未登録の名前は \0(メインキャラ)として扱われるため、 本来 \1 のキャラが喋るはずのセリフが \0 の口から出るなど、話者だけが誤ります。

# config.toml
[characters]
"湊" = "\\0"
"助手" = "\\1"
// NG(「じょしゅ」は登録されていないので \0 で喋ってしまう)
じょしゅ: よろしくお願いします。

// OK
助手: よろしくお願いします。

セリフの話者がおかしいときは、: の前の名前が config.toml の [characters] と 一字一句同じか確認してください。 また、日本語のキャラクター名は config.toml 側で "湊" のように引用符で囲む必要があります。

global と let の混同

let で宣言した変数はトークが終わると消えます。 次回の起動で使いたい値は global save.* に保存してください。

// NG(次回起動時には消えている)
let 訪問回数 = 0
訪問回数 += 1

// OK
global save.訪問回数 += 1

文字列展開の閉じ忘れ

${ に対応する } がない場合に発生します。

// NG
湊: ${save.訪問回数回目です。

// OK
湊: ${save.訪問回数}回目です。

セリフ中の「名前.名前」が消える

セリフの行頭と ${…} の直後では、. を含む語が 変数.キー という変数の参照として読まれます。 読まれる範囲は、半角の空白や記号が出てくるまでです。かなや句読点は区切りにならないので、文の最後まで1つの参照になり、まとめて消えます。

// NG(何も表示されない)
湊: items.jsonを読み込みました。

// NG(items.json の部分だけ消え、「 を読み込みました。」が表示される)
湊: items.json を読み込みました。

// OK(文字列として埋め込む)
湊: ${"items.json"}を読み込みました。

エラーにはならず、セリフはそのまま出ます。ただし応答には警告(変数がないとき)か注意(キーがないとき)が付くので、SSPのエラーログで気づけます。

ファイル名・小数・バージョンなど . を含む語をそのまま見せたいときは、${"..."} で文字列として書いてください。 詳しい例は、移行ガイドのFAQ「セリフが丸ごと消える」にあります。

callした名前のtypo

call で呼んだトーク・関数が見つからなくても、エラーにはならず何も起こりません。 セリフが出ないだけなので、気づきにくいミスです。

静的チェックはこれを notice で知らせます。メッセージは呼び方によって3種類あります。 どれも構文チェッカーで確かめられます。

「は未定義です(動的callなら無視してください)」

call 名前 の名前が、どのトークにも関数にもないときに出ます。

挨拶する => {
    湊: こんにちは。
}

OnBoot => {
    call 挨拶      // 「挨拶する」の書き間違い
}
[notice] main.mntの6行目(OnBoot内): "挨拶" は未定義です(動的callなら無視してください)

ただし、変数に入れた名前を呼ぶ動的callでも同じ notice が出ます。 let target = "挨拶する" のあとの call target などは、変数名 target が未定義と判定されるためです。 動的callのつもりで書いたのなら、この notice は無視してかまいません。

「同じ「〜.」で始まるトークはありますが、この名前はありません」

call OnUpdate.OnTypo のようなドット付きの名前で、同じ前半(OnUpdate.)のトークはあるのに、その名前だけがないときに出ます。

OnUpdate.OnDownloadBegin => {
    湊: ダウンロードを始めるよ。
}

OnBoot => {
    call OnUpdate.OnDownloadBigin   // 「Begin」の書き間違い
}

ドット付きの名前は call reference.0 のような動的callと見分けがつかないので、 同じ前半のトークが1つもないときは何も出ません。

「ビルトイン関数でも talk でも func でもありません」

名前(引数) の形で呼んだ名前が、ビルトイン関数にもトークにも関数にもないときに出ます。 行の先頭に書いた呼び出しだけでなく、let の右辺、if の条件、セリフ中の ${...}、ほかの関数の引数など、式の中の呼び出しも対象です。

func 挨拶する(name) {
    湊: ${name}さん、こんにちは。
}

OnBoot => {
    挨拶すろ("ユーザー")   // 「挨拶する」の書き間違い
}

この形は動的callになることがないので、出たら書き間違いと考えてください。

OnBoot => {
    let n = lenght(items)   // 「len」の書き間違い。これも notice が出る
}

トーク名の後ろに書く if(条件式)(条件フィルタ)の中の呼び出しも対象です。 ビルトイン関数の名前はビルトイン関数で確かめられます。

ループの外でのbreak / continue

ループの外で break や continue を使うとエラーになります。 静的チェックで検出されます。

// NG
OnBoot => {
    break
}

// OK
OnBoot => {
    for (let i = 0; i < 5; i++) {
        break
    }
}

ファイルやJSONが読み込めない

file_read や json_parse は、失敗しても台本を止めずに null を返します。 そのまま使うと、後の処理で別の警告が出て原因がわかりにくくなるので、is_null で確かめてください。

let text = file_read("ghost/master/items.json")
if (is_null(text)) {
    湊: items.json が読めませんでした。
}

失敗した理由は、応答の ErrorLevel / ErrorDescription に警告として載ります。よくある原因は次のとおりです。

症状原因対処
file_read が nullパスが違うパスはゴーストのホーム(ghost フォルダの1つ上)から書きます。"items.json" ではなく "ghost/master/items.json"
file_read が nullファイルが1MBを超えているファイルを分けてください
file_read が nullShift_JIS のファイルを UTF-8 として読んだ第2引数に "sjis" を指定します
json_parse が nullJSONの書き方の誤り(末尾の余分な ,、コメント、' で囲んだ文字列など)警告の「JSONの読み込みに失敗しました(1行目9文字目: 書き方が正しくありません)」の位置を見て直してください
file_write が false書き込めない場所を指定した書けるのは ghost/master の中だけです。talks フォルダや config.toml などには書けません
エラーになる絶対パスや .. を含むパスを指定した禁止されたパスは警告ではなくエラーになります。相対パスで書いてください
file_move が false移動先に同名のファイルがある上書きするなら第3引数に true を指定します

制限の一覧はファイルとJSONにあります。

里々・YAYAから湊への移行ガイド

このガイドは、里々(またはYAYA)でゴーストを作ってきた人が湊に移るときに、何が壊れるかを先に知っておくためのものです。

文法の対応表を並べるだけの資料ではありません。移行でつまずくのは「書き方が違う」ところよりも、見た目は同じコードなのに結果が違うところです。里々が黙ってやってくれていた処理(自動の改行、自動保存、文字列としての数値計算、括弧展開など)が、湊には無かったり、別の形で存在したりします。このガイドはそこを重点的に扱います。

先に結論

湊は里々の上位互換でも、里々の代替として互換動作するエンジンでもありません。 辞書ファイルの形式も、変数の扱いも、セーブデータも、文字コードも別物です。辞書は機械変換できず、トークは書き直しになります。

その代わり、里々では苦労した処理(構造化データ、正規表現、ファイル操作、複雑な条件分岐)が素直に書けます。移行の価値があるかどうかは、段階移行 vs 全置換と湊だけで簡単になる書き方で判断してください。

読み方

移行者の不安を潰していく順に並べています。上から順に読むのがおすすめです。

節内容こんなときに
1. 非互換一覧壊れるものの一覧(チートシート)まず全体像を知りたい
2. 最小ゴーストの移植「こんにちは」を出すところまで手を動かして確認したい
3. イベント*OnBoot、ランダムトーク、独自イベントイベントまわりで悩んだ
4. トーク同名トーク、採用条件、ジャンプトークの選ばれ方が違う
5. 変数保存、型、暗黙の変換変数が消える、計算が合わない
6. 単語群@の代わり単語群を書き直したい
7. 選択肢・条件分岐・ウェイト_、iflist、自動ウェイト選択肢や間の取り方を直したい
8. SAORI呼び出し(登録名,引数)の代わりSAORIを使っている
9. セーブデータの移行と型の違いsatori_savedata.txtの引き継ぎ既存ユーザーのデータを残したい
10. 文字コード・改行・エスケープUTF-8、改行、\、$文字化け・改行のずれ
11. 里々/YAYAにあるが湊にない機能無い機能と代替使っている機能が湊にあるか知りたい
12. 湊だけで簡単になる書き方湊の強み移行のメリットを知りたい
13. 段階移行 vs 全置換機能ごとの星取表移行方針を決めたい
14. FAQ: 見た目は同じで結果が違う暗黙の処理の差動くのに結果がおかしい
15. 三方式の比較里々/YAYA/湊で同じゴーストを書く全体の雰囲気をつかみたい
付録A. YAYAから来た方へYAYA向けの差分YAYAから移行する

凡例

一覧では、影響の大きさを次の印で示します。

  • 🔴 黙って壊れる、または起動しない。 里々のつもりで書くと、エラーも出ずに文が消えたり、データが残らなかったりします。
  • 🟠 動くが結果が違う。 見た目は同じコードで、計算結果や改行位置が変わります。
  • 🟡 湊にない機能。 代替手段があるものは併記します。

このガイドの前提と検証

  • 対象は湊 0.1.6(この文書のリポジトリの現在の実装)です。挙動は実装を正として書いています。ドキュメントと実装が食い違う場合は、実装に合わせ、その旨を明記しています。
  • 湊のサンプルコードは、すべて実際に動かして確認しています。 文中の mnt コードブロックとその出力は、cargo test --test migration_guide で実行され、出力が一致することを毎回検証します。SAORIを使う例だけは、検証用のSAORIで別途確認しています(8. SAORI呼び出し)。
  • 里々・YAYAのコードは参考コードです。里々Wiki・YAYA Wikiの記載に基づいて書いていますが、里々やYAYAの実機では動かしていません。里々/YAYAの挙動を断定できないところは、その旨を書いています。
  • 里々の説明は「里々Wiki」の記述に基づきます。里々のバージョンによって細部が異なる場合があります。

出力の読み方

サンプルの出力(text ブロック)は、湊がSSPに返すさくらスクリプトです。1イベントにつき1行で、(204) は「何も返さない」応答です。

湊は OnBoot と OnMinuteChange の応答の最後に、SSPの仮想時刻を取得するための \![get,property,OnGotVirtualTime,…] を付け足します。ガイドの出力では、これを取り除いて示しています(3. イベントを参照)。

サンプルは、キャラクター 湊(\0)と 助手(\1)を config.toml に登録した状態で動かしています。

1. 非互換一覧(チートシート)

里々(およびYAYA)から湊に移るときに壊れるものの一覧です。「完全互換ではありません」。機能ごとに、どこがどう違うのかを示します。各行の右端のリンクから、詳しい説明に移れます。

  • 🔴 黙って壊れる、または起動しない。エラーも出ずに、文が消えたり、データが残らなかったりします。
  • 🟠 動くが結果が違う。見た目は同じコードで、計算や改行が変わります。
  • 🟡 湊にない機能。

本文のサンプルは、すべて実機(DLL)で動かして確認しています。

🔴 黙って壊れる・起動しない

項目里々湊詳細
辞書の形式* @ $ > の辞書(dic*.txt)名前 => { … } の .mnt。機械変換はできない。全部書き直し2.
config.toml のキャラ名—日本語のキーは必ず引用符で囲む。囲まないと loadu が失敗する2.
台本の文字コード既定 Shift_JISUTF-8(BOMなし)だけ。Shift_JISはロード失敗、BOM付きは先頭のトークだけ黙って壊れる2.、10.
変数の保存変数はすべて自動保存global save.… と書いたものだけ保存。let や global work.… は消える。書き忘れてもエラーは出ない5.
セーブデータsatori_savedata.txt(全部文字列)save.json(型あり)。引き継がれない。手動で移行。全角数字は数値にならない9.
括弧展開 ()変数・単語群・トーク・関数を () で呼ぶ${…} と call。全角 () はただの文字で、そのまま表示される5.、10.
未定義の名前括弧ごとそのまま表示されるので気づける${未定義} は空文字列(警告なし)5.
セリフ中の 英数.英数—セリフの行頭・${}直後の最初の語に . があると、変数参照になり、文が丸ごと消える(体重は57.8kgです。 など)14.
セリフ直後の代入・関数呼び出し—x = 5 や hello() が、セリフの続き行として出力される(代入されない)10.
半角コロンのある続き行—注意:これは… が話者指定になり、「注意:」が消えて改行も入らない10.
1行ブロック—OnBoot => { 湊: こんにちは } は書けない(パースエラー)10.
構文エラー—1つでもあるとゴースト全体が読み込み失敗。OnBoot でエラー表示、以降は無言2.
単語群 @専用構文ない。配列か、同名トーク + return6.
ジャンプ >移ったら戻らないcall は戻ってくる。call の後に return を書く4.
選択肢 _ラベル=ジャンプ先が自動\q[ラベル,ID] を手書き。OnChoiceSelect と call reference.0 で同じ形に7.
独自イベントなでられ、つつかれ、OnSatoriLoad などない。SSPのイベントから自作3.
自動処理自動ウェイト、replace.txt、スコープ切り替え、サーフェス戻しない。手で書くか、関数で作る7.

🟠 動くが結果が違う

項目里々湊詳細
足し算 +数式として加算文字列があると連結("1" + 2 は 12)。reference・SAORI戻り値・移行した値は文字列5.
大小比較数式< > は常に数値で比較。文字列の辞書順比較はできない("a" < "b" は偽)14.
真偽0以外が真0、空文字、null、空の配列・マップが偽。"0" は真14.
未設定の変数(変数は文字列)null。save.n == 0 は偽。+= 1 は 1 になる5.
全角の数字数として計算できる0になる。半角に直して数値で持つ14.
裸の代入 x = 5すべてグローバル・保存let がなければ起動中グローバル(他のトークから見える・保存されない)5.
let の範囲—宣言した { } の中だけ。if の中で作ると外で消える5.
改行* の中の改行は自動で \n連続したセリフ行の間だけ \n。コード行・空行・; を挟むと入らない。ループ内は入らない10.
ランダムトーク名前のない *OnRandomTalk。起動から最短5分。OnMinuteChange 駆動3.
OnAITalk・OnMinuteChange通常のイベント自分で定義しても呼ばれない(湊が内部で処理)3.
間隔の設定$喋り間隔config.toml の値は初回だけ。以降は save.json が優先9.
重複回避方式を選べる固定(一巡)。2候補なら交互。shuffle_reset は読まれない4.
採用条件トーク・単語群・ジャンプに付くトーク定義の if(…) だけ。全候補が偽なら無言(204)4.
未登録の話者名—エラーにならず \0 になる2.
ゼロ除算—null(空)+ 警告。止まらない5.
マップの順序—再起動後はキー名の辞書順になる5.
SAORIの戻りResult が括弧の位置に入るValue0 があると Result は取れない。1イベント10回、5秒で打ち切り8.
出力の文字—Shift_JISにない文字(絵文字など)は &#数値; に化ける10.
OnRandomTalkの記録—結果が save.last_talk に自動で保存される3.
foreach の1変数—foreach 配列 as v の v は添字14.
$ と ${—$ はそのまま書ける。${ は展開の始まりなので、文字として出すなら ${'${'} と書く10.
正規表現の \d—"\d" と1つ書く("\\d" は一致しない)10.
曜日文字(日月火…)now.曜日 は数値で0が月曜(YAYAは0が日曜)14.

🟡 湊にない機能

機能代替詳細
アンカー辞書、コミュニケート(→)、トーク予約($次のトーク)なし。さくらスクリプト・SSPのイベントで自作11.
辞書リロード、$辞書フォルダ、マルチキャラ辞書include だけ11.
手動セーブ、自動セーブ間隔、セーブデータの暗号化なし(保存は終了時と再読み込み時だけ)9.
NOTIFY の自動保存、情報取得変数の一部(起動回数など)自作5.、11.
YAYAの EVAL、名前空間、関数のオプション、行単位のファイル操作なし11.、付録A
各種の設定(呼び出し回数制限、重複回避方式など)固定値(制限の一覧)11.

対応があるもの

すべてが壊れるわけではありません。次のものは、ほぼそのまま移せます。

  • SSPのイベント(OnBoot、OnClose、OnMouseDoubleClick、OnChoiceSelect など)と Reference
  • さくらスクリプト(\s[0]、\w5、\q[…]、\n[half]、\![…] など)。セリフの中にそのまま書ける
  • 同名トークからのランダム選択(一巡固定で、里々の「有効」に近い)
  • SAORIの利用(呼び出し方は変わる)

次は2. 最小ゴーストの移植に進んでください。

2. 最小ゴーストの移植

里々で一番シンプルなゴーストを、湊で動かすところまでやってみます。ここで基本の流れと、最初につまずく3か所(config.toml、文字コード、エラーの見え方)を押さえます。

里々でいちばん短い辞書

*OnBoot
:こんにちは。

これを湊で書くと次のようになります。

OnBoot => {
    湊: こんにちは。
}
\0こんにちは。\e

対応関係は次のとおりです。

里々湊
*OnBootOnBoot => { … }
:こんにちは。湊: こんにちは。
行頭の : で話者を交代湊: 助手: とキャラ名を書く

フォルダ構成

ghost/master/
├── shiori.dll      ← 湊 (minato.dll) をこの名前にして置く
├── config.toml     ← キャラクター名の登録など(必須)
└── talks/
    └── main.mnt    ← 台本(必須)

里々からの変更点は次のとおりです。

里々湊
satori.dllshiori.dll(minato.dll をリネーム)
dic*.txt(自動で全部読み込まれる)talks/main.mnt だけ。他のファイルは include "xxx.mnt" で明示的に読み込む
satori_conf.txtconfig.toml(設定の中身は別物)
satori_savedata.txtsave.json(9. セーブデータの移行)
replace.txt / replace_after.txtなし(7. ウェイト)

descript.txt の shiori, の行は shiori,shiori.dll にします。

config.toml: キャラ名は必ず引用符で囲む

[characters]
"湊" = "\\0"
"助手" = "\\1"

キャラクター名は " で囲みます。 囲まないと config.toml の読み込みに失敗し、DLLのロードそのものが失敗します(湊の loadu が 0 を返すので、SSPはSHIORIの読み込み失敗として扱います)。TOMLの仕様で、日本語のキーは引用符が必要なためです。

値の \\0 の \\ は、TOMLの文字列の中で \ 1文字を表す書き方です。結果として、キャラ名 湊 は \0 に対応します。

キャラ名は、台本の 湊: の部分と完全に一致させます。登録していない名前を書いても、エラーにはなりません。 \0 として扱われます。たとえば みなと: こんにちは と書くと、\0こんにちは になります。話者を間違えても気づきにくいので、注意してください。

OnBoot => {
    みなと: 未登録の名前
    助手: 登録した名前
}
\0未登録の名前\1登録した名前\e

文字コードは UTF-8

main.mnt は UTF-8 で保存します。里々の辞書は Shift_JIS が既定なので、そのままコピーすると読み込めません。

Shift_JISのまま置くと、loadu は成功しますが、OnBoot で次のエラーが出ます。

パースエラー:
ファイルが読み込めません: stream did not contain valid UTF-8

BOM(バイトオーダーマーク)付きのUTF-8は使わないでください。 湊はエラーを出さずに、ファイル先頭のトークだけを読み違えます(OnBoot が <BOM>OnBoot という別名になり、呼ばれなくなります)。エディタの保存設定で「UTF-8(BOMなし)」を選んでください。

config.toml のBOMは問題ありません。

詳しくは10. 文字コード・改行・エスケープを参照してください。

話者が複数いるとき

OnBoot => {
    湊: こんにちは。
    今日もよろしくお願いします。
    助手: よろしくお願いします。
}
\0こんにちは。\n今日もよろしくお願いします。\1よろしくお願いします。\e
  • 同じ話者が続く行は、\n(改行)でつながります。里々の「: の行ごとに自動で改行」に近い動きです。ただし、改行が入るのはセリフの行が連続しているときだけです。詳細は10. 改行を参照してください。
  • 話者が変わると \1 などのスコープタグが入ります。里々のように話者が自動で交代することはなく、キャラ名は毎回書きます。

エラーが出たとき

湊は、台本に構文エラーが1つでもあるとゴースト全体が読み込みに失敗します。エラーのある行だけを飛ばして動くことはありません。

OnBoot が呼ばれたときに、エラーの内容が吹き出しに出ます。それ以降のイベントは何も返さなくなります(このとき save.json は上書きされないので、セーブデータは壊れません)。

OnBoot => {
    湊: 閉じ括弧を忘れた

OnClose => {
    湊: またね
}
\b[2]\0パースエラー:\nmain.mntの1行目: 1行目の「{」が閉じられていません\nヒント: 対応する「}」を書いてください\e
(204)

{ の閉じ忘れは、閉じられていない { を開いた行で指摘されます。上の例では、} を忘れた OnBoot の1行目です。 それ以外のエラーで指摘される行は、湊が間違いに気づいた位置です。指摘された行で原因が見つからないときは、その少し前も確認してください。

SSPを起動せずに確認したいときは、湊に付属の構文チェッカー minato_check を使います。

minato_check.exe "ghost/masterのフォルダ"

talks/main.mnt を含むフォルダ(ゴーストの ghost/master)を指定します。

構文エラーだけでなく、未定義の call 先やループ外の break も検出します。

つまずいたときのチェックリスト

  1. 何も喋らない。 config.toml の書式(キャラ名を引用符で囲んだか)、shiori.dll の名前、talks/main.mnt の位置を確認してください。
  2. OnBoot でパースエラーが出る。 上のメッセージに従って直します。エラーが複数あるときは、上から順に直すと効率的です。
  3. 喋るが文字が化ける。 湊の出力は Shift_JIS に変換されます。Shift_JISで表せない文字は化けます(10. 文字コード)。
  4. ある文だけ出ない。 行の内容が変数参照や続き行として読まれている可能性があります(14. FAQ)。

次は3. イベントに進んでください。

3. イベント

里々のイベントには3種類あります。湊での扱いは、種類によって大きく違います。

種類里々の例湊
① ベースウェア(SSP)が送るイベント*OnBoot、*OnClose、*OnMouseDoubleClickほぼそのまま。OnBoot => と書く
② 里々が自動でやってくれることランダムトーク、$喋り間隔、OnGhostCalled 未定義時の OnBoot 代用一部だけ。ランダムトークは対応、他は自前
③ 里々独自のイベントなでられ、つつかれ、起動回数、*OnSatoriLoad無い。自分で作る

① SSPのイベントはそのまま書ける

*OnBoot は OnBoot => に、(R0) は reference["0"] に置き換えます。

OnMouseDoubleClick => {
    if (reference["4"] == "Head") {
        湊: 頭をつつかないでください。
    } else {
        湊: ${reference["4"]}ですね。
    }
}
\0頭をつつかないでください。\e
\0Bustですね。\e

OnMouseDoubleClick の Reference4 は当たり判定名です。どのイベントにどの Reference が付くかは、SSPのドキュメント(UKADOC)に従います。

Reference は文字列

reference は、キーが文字列の番号("0", "1", …)のマップで、値はすべて文字列です。reference.0 とも書けます。

OnFoo => {
    湊: ${reference.0}/${reference["1"]}
}
\0abc/def\e

値が文字列なので、数値のつもりで + を使うと連結になります。

OnMouseClick => {
    湊: ${reference["0"] + 1}|${to_num(reference["0"]) + 1}
}
\031|4\e

reference["0"] + 1 は "3" + 1 で 31 になります。里々の (R0)+1 は 4 でした。数値として扱いたいときは to_num() を通してください。 比較の < >= は自動で数値化されるので、そのまま使えます(詳しくは5. 変数)。

起動系のイベント

SSPは、OnFirstBoot、OnGhostChanged、OnGhostCalled に何も返さなかった(204)ときに、続けて OnBoot を送ります(SSPの仕様)。里々が持っていた「未定義なら OnBoot を代用する」動きは、SSP上ではそのまま期待できます。湊自身は代用処理をしません。

OnFirstBoot を使うなら、OnFirstBoot => を定義します。初回に何か喋った場合、SSPは OnBoot を続けて送りません。初回のあとにも通常の起動トークを出したいときは、OnFirstBoot の中から call 起動 のように呼びます。

② ランダムトーク

里々では、名前のない * が「ランダムトーク」になり、間隔は $喋り間隔 で決まりました。湊では、OnRandomTalk という名前のトークを書きます。同名を複数書けば、その中から選ばれます。

OnRandomTalk => {
    湊: 今日もいい天気ですね。
}

OnRandomTalk => {
    湊: 何か用ですか?
}
\0今日もいい天気ですね。\e
\0何か用ですか?\e

発火のしくみ

  • SSPが1分ごとに送る OnMinuteChange を、湊が数えます。前回から talk_interval_secs(既定300秒)+ 0〜talk_jitter_secs(既定180秒)が経っていれば、OnRandomTalk を実行します。
  • 起動直後にも、この間隔が適用されます。起動してから最初のランダムトークまで、最短でも5分かかります。
  • 間隔は config.toml に書きます。ただし、一度でも起動して save.json ができると、config.toml の値は無視されます(9. セーブデータ)。

OnMinuteChange と OnAITalk は自分で定義できない

湊は、この2つのイベントを内部で処理します。自分で OnMinuteChange => や OnAITalk => を書いても、呼ばれません。

  • OnMinuteChange は、上記のとおりランダムトークの発火に使われます。
  • OnAITalk(SSPのメニューなどからユーザーが手動でランダムトークを求めたとき)は、OnRandomTalk に読み替えて実行されます。
OnAITalk => {
    湊: OnAITalkは呼ばれない
}

OnRandomTalk => {
    湊: OnRandomTalkが呼ばれる
}

OnMinuteChange => {
    湊: OnMinuteChangeも呼ばれない
}
\0OnRandomTalkが呼ばれる\e
\e

2行目の \e は、OnMinuteChange に対して湊が「何も喋らない」で返した応答です。OnSecondChange など、他のイベントは普通に自分で定義できます。

③ 里々独自のイベントは自分で作る

里々が用意してくれていた独自イベントは、湊にはありません。SSPのイベントを組み合わせて作ります。

里々湊での作り方
*0Headなでられ などの撫で反応OnMouseMove の reference["4"](当たり判定名)を数える
つつかれ(ダブルクリック反応)OnMouseDoubleClick の reference["3"](話者)と reference["4"]
起動回数(情報取得変数)global save.起動回数 += 1 を OnBoot に書く
*OnSatoriLoad(辞書ロード時)main.mnt 直下に global save.x ?= 0 を書く(ロード時に実行される)
$次のトーク(トーク予約)なし。さくらスクリプトの \![raise,OnXxx] などを使う

撫で反応を作る

撫で反応は、OnMouseMove が続けて来た回数を数えて作ります。セーブしたくないカウンタは global work.… に置きます(save 以外は保存されません。5. 変数)。

OnMouseMove => {
    if (reference["4"] == "Head") {
        global work.head += 1
        if (work.head >= 3) {
            global work.head = 0
            湊: くすぐったいです。
        }
    }
}
(204)
(204)
\0くすぐったいです。\e
(204)
{}

3回目で反応し、カウンタが0に戻ります。最後の {} は、終了時に書き出される save の中身です。work は保存されていません。

湊が勝手にやること

里々が勝手にやっていたことがあるように、湊にも、書いていないのにやることがあります。挙動を比べるときに必要なので、まとめておきます。

何を内容
応答の末尾に \e を付けるトークが出力した内容の最後に必ず付きます。出力が空だと、何も返しません(204)
OnBoot、OnMinuteChange の末尾に \![get,property,OnGotVirtualTime,…] を付けるSSPの仮想時刻を取るためです。届いた時刻が now に入ります。OnGotVirtualTime は湊が内部で消費するので、自分では定義できません
OnAITalk を OnRandomTalk に読み替える上記
OnRandomTalk の結果を save.last_talk に保存する書いた覚えのない変数が save.json に入ります。 直前のランダムトークのさくらスクリプトが入っています。不要なら無視して構いません
Status ヘッダを status に展開するstatus.talking, status.choosing, status.minimizing, status.induction, status.passive, status.timecritical, status.nouserbreak, status.online(真偽値)、status.raw(元の文字列)
system 変数を用意するsystem.talk_interval, system.talk_jitter, system.debug_log, system.ghost_dir, system.version

save.last_talk の実際の中身を確認しましょう。

OnRandomTalk => {
    湊: ふつうのトーク
}
\0ふつうのトーク\e
{"last_talk":"\\0ふつうのトーク"}

OnAITalk の応答は \0ふつうのトーク\e で、そこから末尾の \e を除いた文字列が、save.last_talk に入ります(上のJSON表示では \ が \\ と書かれています)。

OnTranslate を使うとき

里々の replace.txt の代わりに、SSPの OnTranslate イベント(さくらスクリプトを送信前に加工できる)を使うことができます。湊でも普通のイベントとして定義できます。

OnTranslate => {
    return replace(reference["0"], "あ", "い")
}
\0いいう\e\e

湊は、応答の末尾に必ず \e を付けます。 OnTranslate に渡されたスクリプトは末尾に \e を含むので、返す文字列の末尾は \e\e になります。SSPがこれをどう扱うかは、SSP側での検証が必要です(この点は実機のSSPでは未確認です)。

次は4. トークに進んでください。

4. トーク

里々の * で始まる文(トーク)は、湊では 名前 => { … } です。同じ名前を複数書くとその中から1つが選ばれる、という基本は里々と同じです。違うのは、選び方の設定がないこと、ジャンプ(>)がないこと、呼び出しの種類が3つあることです。

同名トークからの選ばれ方

あいさつ => {
    湊: おはよう。
}

あいさつ => {
    湊: こんにちは。
}

あいさつ => {
    湊: こんばんは。
}

OnBoot => {
    call あいさつ
}
\0おはよう。\e
\0こんにちは。\e
\0こんばんは。\e

OnBoot の中から call あいさつ で呼ぶと、3つの あいさつ のうち1つが選ばれます。

選び方は固定(一巡するまで同じものを選ばない)

里々では、$文「○」の重複回避 で「無効(完全ランダム)」「直前」「有効(一巡)」「降順」「昇順」を選べました。里々の既定はランダムで、同じものが続くこともあります。

湊は、常に「一巡するまで同じものを選ばない」で固定です。設定で変えることはできません。

  • 3つあれば、3回呼ぶ間に全部が1回ずつ出ます。周回の境目でも、同じものが連続しません。
  • 名前ごとに独立して管理されます。OnRandomTalk も、call で呼ぶトークも同じ扱いです。
  • 候補がちょうど2つのときは、最初の1回だけがランダムで、あとは交互になります(A、B、A、B、…)。「同じものを続けない」規則のため、順序が固定されるのです。
  • この状態は保存されません。ゴーストを起動し直すと、最初からやり直しです。
  • config.toml に shuffle_reset という項目がありますが、現在の実装では読まれていません。値を変えても動作は変わりません。

順番に出したい(里々の「降順」「昇順」)場合や、完全ランダムにしたい場合は、6. 単語群にあるように、配列と rand() で自分で書きます。

採用条件は if(...)

里々の *トーク名【タブ】条件式 に当たるのが、トーク名 if(条件式) => です。

OnRandomTalk if(save.好感度 < 10) => {
    湊: こんにちは。
}

OnRandomTalk if(save.好感度 >= 10) => {
    湊: 仲良くなりましたね。
}

OnSetFavor => {
    global save.好感度 = 10
}
\0こんにちは。\e
(204)
\0仲良くなりましたね。\e

条件式は、トークが呼ばれるたびに評価されます。条件が真のものだけが候補になり、その中から選ばれます。

  • 条件式の書き方は if 文と同じです(== != < >= && || ! と括弧)。里々のように全角記号は使えません。
  • 里々の条件式は「0以外なら真」でした。湊の条件式は真偽で判断されます。空文字列・0・空の配列・null・false が偽で、それ以外は真です。文字列の "0" は真です(5. 変数)。
  • 条件が付けられるのはトーク定義だけです。里々のように、単語群や > の行に条件を付けることはできません。
  • 条件式の中で発生した「存在しないキー」などの通知は、作者向けのログに出ません。条件が偽になるだけです。

候補が全部除外されたら

すべての候補の条件が偽のとき、何も出力されません。SSPのイベントなら、応答は 204(何もしない)です。

OnRandomTalk if(false) => {
    湊: 出ません。
}
(204)

ランダムトークが無言になっても、エラーは出ません。条件なしのトークを1つは用意しておくと安全です。

ジャンプ(>)は call と return で書く

里々の > は、指定したトークにジャンプします。戻ってきません。

*OnBoot
>初回【タブ】(訪問回数)==1
>通常

初回のときは 初回 だけが実行され、続く >通常 は実行されません。

湊の call は呼んだ先が終わったら戻ってきます。ジャンプにするには、call のあとに return を書いて、そこで終わらせます。

OnBoot => {
    global save.訪問回数 += 1
    if (save.訪問回数 == 1) {
        call 初回
        return
    }
    call 通常
}

初回 => {
    湊: はじめまして。
}

通常 => {
    湊: おかえりなさい。
}
\0はじめまして。\e
\0おかえりなさい。\e

return を書かないと、初回 のあとに 通常 も続けて出ます。里々から機械的に置き換えるときに、一番忘れやすい点です。

呼び出しの3つの形

里々の (トーク名) に当たるものが、湊では3つあります。

書き方意味
call トーク名そのトークを実行し、出力をその場に流す
${トーク名()}そのトークを実行し、結果を文字列として取り出す
トーク名()文の位置に置くと call トーク名 と同じ
運勢 => {
    return "大吉"
}

OnBoot => {
    湊: 今日は${運勢()}です
    湊: 今日は
    call 運勢
}
\0今日は大吉です\n今日は大吉\e
  • ${運勢()} は、文字列の途中に埋め込めます。里々の 今日は(運勢)です に当たります。
  • call 運勢 は、その場所に出力を差し込みます。トークの中に 湊: のセリフがあれば、それがそのまま出力されます。
  • トーク名() は、直前の行がセリフのときに call として動きません。セリフの続き行として扱われてしまいます(10. 改行のルール、14. FAQ)。迷ったら、call トーク名 と書くのが安全です。

引数を渡す関数は func で書き、関数名(引数) で呼びます。func はトークとは別の仕組みで、戻り値を返せます。

func 二倍(x) {
    return x * 2
}

OnBoot => {
    湊: ${二倍(21)}
}
\042\e

トークと func に同じ名前を付けると、func が優先されます。名前は分けてください。

名前が変数に入っているとき

里々で ((変数)) のように入れ子で呼んでいたものは、call 変数名 で書けます。変数の中身がトーク名(または関数名)として使われます。

挨拶 => {
    湊: こんにちは。
}

OnBoot => {
    let n = "挨拶"
    call n
}
\0こんにちは。\e

選択肢の reference["0"] に入ったトーク名で呼び出す使い方は、7. 選択肢を参照してください。

トークの途中で終わる

return だけを書くと、そのトークを終了します(セリフはそこまでの分が出力されます)。

OnBoot => {
    湊: A
    return
    湊: B
}
\0A\e

* と @ の区別はない

里々では、*(トーク)は改行やスコープの自動挿入が入り、@(単語群)は入りませんでした。湊は区別せず、**どちらも「トーク」**です。

  • 文字列だけを返したいときは return "…" と書きます(6. 単語群)。
  • セリフを出力したいときは 湊: … と書きます。

次は5. 変数に進んでください。

5. 変数

変数は、里々から移るときにいちばん事故が起きやすいところです。里々では何もしなくても保存されていた変数が、湊では保存されません。 型のない文字列だった値が、湊では型を持ちます。順に見ていきます。

里々の変数は1種類、湊は3種類

里々では、$変数 と書けばそれがゴースト全体で共有され、終了時に自動で satori_savedata.txt に保存されました。里々Wikiにも「変数が自動で保存され、不要になった変数は消す必要がある」と書かれているとおり、里々の変数は基本的にすべてグローバルで、すべて保存されるものでした。

湊には、次の3種類があります。

種類書き方有効範囲保存里々の感覚でいうと
ローカル変数let x = 1そのトークの中(ブロックの中)されない該当なし(一時的な計算用の変数)
セーブ変数global save.x = 1ゴースト全体save.json に保存$x(普通の変数)
起動中グローバルglobal work.x = 1ゴースト全体されない(終了で消える)該当なし(保存したくない変数)

里々の変数は、ほとんどの場合 global save.x に置き換えます。「保存しなくてよい」「その場の計算にしか使わない」変数だけを、let や work に分けます。

自動保存されない

保存されるのは、save の下に置いた値だけです。let で作った変数も、global work.x で作った変数も、終了すると消えます。

OnBoot => {
    let a = 1
    b = 2
    global save.c = 3
    global work.d = 4
    湊: ${a} ${b} ${save.c} ${work.d}
}

OnClose => {
    湊: [${a}] [${b}] [${save.c}] [${work.d}]
}
\01 2 3 4\e
\0[] [2] [3] [4]\e
{"c":3}
\0[] [] [3] []\e

上から順に見ます。

  1. OnBoot では、4つとも値が入っています。
  2. OnClose では、a(let)は消えています。トークが終わると let は消えるためです。b と d は、起動中のあいだは残っています。
  3. @save はゴーストを終了して保存し、save.json の save の中身を見せています({"c":3})。c だけです。
  4. 再起動後の OnClose では、save.c だけが残っています。

「セーブしたいものには必ず global save. を付ける」、これがいちばん大事なルールです。書き忘れても、エラーは出ません。

保存が行われるのは、ゴーストの終了時(とロード時)だけです。里々の $手動セーブ や $自動セーブ間隔 に当たる機能はありません。SSPごと強制終了すると、その起動中に覚えたことは消えます。

裸の代入は起動中グローバルになる

let を付けずに x = 5 と書くと、その名前のローカル変数がなければ、起動中グローバルが作られます。他のトークからも見え、ゴーストを終了するまで残ります。保存はされません。

OnBoot => {
    counter = 5
}

OnClose => {
    湊: counter=${counter}
}
(204)
\0counter=5\e
{}

OnBoot のトークで作った counter が、別のトーク OnClose から見えています(OnBoot は何も喋らないので 204 です)。save には入っていません。

里々では変数がすべてグローバルなので、この動きは意外に感じないかもしれません。ただし、保存されない点が里々と違います。里々の感覚のまま計算用の変数を書くと、他のトークとの間で名前がぶつかり、原因の分かりにくい不具合になります。また、再起動すると値が消えます。

  • トークの中だけの一時変数は、必ず let で宣言する。
  • 起動中だけ共有したい値は global work.… と書く(名前が work で始まる、というだけの約束です)。

関数(func)の中でも同じです。let で宣言したものは関数の中だけ、裸の代入はグローバルになります。

let の有効範囲

let の変数は、宣言したブロック({ })の中だけで有効です。if、for、foreach、match の中で let すると、ブロックを出たときに消えます。

一方、let なしの代入(c = 5)は、外側に同名のローカル変数があればその変数を更新します。

OnBoot => {
    let a = 1
    let c = 1
    if (true) {
        let a = 2
        c = 5
        let b = 3
    }
    湊: a=${a} b=${b} c=${c}
}
\0a=1 b= c=5\e
  • let a = 2 は、if の中に新しい a を作ります。外の a は 1 のままです。
  • c = 5 は、外の c を更新します。
  • b は if の中で消えるので、外では空です。

call や関数呼び出しで呼んだ先のトークや関数からは、呼び出し元の let 変数が見えます(呼び出しの間は、呼び出し元のローカル変数も有効なままだからです)。let なしの代入(x = 5)を呼び出し先で書くと、同名の呼び出し元の let 変数が更新されます。

呼び出し先が let で作った変数は、呼び出しが終わると消えます。呼び出し元の変数に依存すると、名前が偶然ぶつかったときに追いにくい不具合になるので、値は引数か、save / work で渡すのが安全です。

型と暗黙の型変換

里々では、変数の中身はすべて文字列で、式の中で数値として解釈されました。湊には、数値・文字列・真偽値・配列・マップ・null の型があります。

多くの場面で、値は自動的に変換されます。その規則が里々と違うので、最も気をつけてください。

足し算 + は文字列があると連結になる

OnBoot => {
    湊: ${"1" + 2}|${1 + "2"}|${"3" + "4"}|${1 + 2}
    湊: ${"a" < "b"}|${"9" < "10"}|${1 == "1"}|${"0" == 0}
    湊: ${!"0"}|${!0}|${!""}|${!null}|${!"false"}
}
\012|12|34|3\nfalse|true|true|true\nfalse|true|true|true|false\e
  • +: どちらかが文字列なら連結です。"1" + 2 は 12 です。両方が数値のときだけ加算します。
  • - * / %: 文字列は数値に変換して計算します。数値にできなければ 0 として扱います。
  • < <= > >=: 常に数値に変換して比較します。文字列の辞書順比較はできません。"a" < "b" は偽です(両辺が 0 になるため)。
  • == !=: 数値と文字列は、表示した形で比べます。1 == "1" は真です。
  • 真偽: 偽になるのは、false、0、空文字列、空の配列、空のマップ、null。"0" や "false" は真です。

数値のつもりの文字列に注意

reference の値、SAORIの戻り値、セーブデータから移した値は、文字列です。数値のつもりで + すると連結になります。

OnBoot => {
    global save.好感度 = "10"
    湊: ${save.好感度 + 1}|${save.好感度 >= 5}|${save.好感度 == 10}|${to_num(save.好感度) + 1}
}
\0101|true|true|11\e

save.好感度 が文字列 "10" のとき、+ 1 は 101 になります。比較の >= 5 や == 10 は期待どおりなので、問題に気づきにくいのが厄介です。文字列で保存されている数値は、to_num() で数値にしてから使ってください。 特に、global save.x += 1 を文字列に対して行うと、"10" は "101" になります。

全角の数字は数値になりません。 里々では全角の数字も数値として計算できましたが、湊では 0 になります。

OnBoot => {
    let z = "3"
    湊: ${z >= 3}|${to_num(z)}|${z + 1}
}
\0false|0|31\e

里々のセーブデータを移行するときは、全角数字を半角にして、数値として保存し直してください(9. セーブデータ)。

値の表示形式

${…} や文字列連結で表示されるとき、値は次のようになります。

値表示
null空文字列
真偽値true / false
数値整数なら 5、小数なら 2.5。小数は誤差がそのまま出る
配列[1, a](要素をカンマ区切り)
マップ[map]
OnBoot => {
    湊: ${true}|${[1, "a"]}|${{"k": 1}}|${null}|
    湊: ${0.1 + 0.2}|${format("%.1f", 0.1 + 0.2)}|${10 / 4}|${floor(10 / 4)}|${7 % 3}
}
\0true|[1, a]|[map]||\n0.30000000000000004|0.3|2.5|2|1\e

除算は実数です。10 / 4 は 2.5 になります。整数にしたいときは floor() などを使います。小数の桁を揃えたいときは format("%.1f", …) を使います。ゼロで割ると、null になり、警告がログに残ります(エラーで止まりはしません)。

未定義の変数は null

湊では、存在しない変数は null です。参照してもエラーにならず、表示は空文字列です。

OnBoot => {
    湊: [${x}][${save.foo}][${a.b.c}]
    湊: ${save.n == 0}|${(save.n ?? 0) == 0}|${is_null(save.n)}
}

OnClose => {
    global save.n += 1
    global save.s += "a"
    湊: ${save.n}|${save.s}
}
\0[][][]\nfalse|true|true\e
\01|a\e
  • ${x} のように、書き間違えた変数名は、警告も出ずに空になります。里々では、未定義の (変数) は括弧ごとそのまま表示されたので、間違いに気づけましたが、湊では気づけません。
  • save.n == 0 は、n が未設定のとき偽です。 null は空文字列として表示され、0 の表示 "0" と一致しないためです。初期値が 0 の前提で比較するなら、(save.n ?? 0) == 0 と書くか、あらかじめ ?= で初期化しておきます。
  • += は、null に対しては 0(数値の場合)や空文字列(文字列の場合)として働きます。

初期値の与え方

里々では satori_conf.txt の *初期化 に初期値を書きました。湊では、main.mnt の直下(トークの外)に global 文を書きます。ロード時に実行されます。

global save.好感度 ?= 5

OnBoot => {
    global save.好感度 += 1
    湊: ${save.好感度}
}
\06\e
(reload)
\07\e

?= は「値が null のときだけ代入する」です。1回目は初期値の 5 に 1 が足されて 6、再起動後は保存された 6 がそのまま使われて 7 になります。初期値には = でなく ?= を使ってください。 = だと、起動のたびに保存済みの値が初期値で上書きされます。

変数の存在確認と削除

里々湊
(変数「x」の存在)has_key(save, "x")、または save.x が null かどうか(is_null(save.x))
変数の削除global save = delete(save, "x")
OnBoot => {
    global save.stats.win += 1
    global save.stats.lose += 2
    global save.old = "x"
    global save = delete(save, "old")
    湊: ${save.stats.win}勝${save.stats.lose}敗|${has_key(save, "old")}|${has_key(save, "stats")}
}
\01勝2敗|false|true\e
{"stats":{"win":1,"lose":2}}

global save.stats.win += 1 のように、途中のマップは自動で作られます。save.json に保存されたマップのキーの順序は、再起動後は名前の辞書順になります(挿入順は保存されません)。順序に意味がある処理は、配列で持ってください。

配列変数の代わり

里々には配列がなく、$野菜0、$野菜1、(野菜(n)) のように番号付きの変数で代用しました。湊には配列があります。

OnBoot => {
    let 野菜 = ["にんじん", "たまねぎ", "じゃがいも"]
    let n = 2
    湊: ${野菜[n]}|${len(野菜)}
    foreach 野菜 as i, v {
        湊: ${i}:${v}\n
    }
}
\0じゃがいも|30:にんじん\n1:たまねぎ\n2:じゃがいも\n\e

配列の添字は 0 から始まります。範囲外を指すと null になり、警告がログに残ります。範囲外でも警告を出したくないときは、get(配列, 添字, 既定値) を使います。ループの中のセリフの末尾に \n を付けているのは、ループの中では自動で改行が入らないためです(10. 改行)。

情報取得変数の代わり

里々の情報取得変数((現在時) など)に当たるものです。

里々湊
(現在年) (現在月) (現在日)now.年 now.月 now.日
(現在時) (現在分) (現在秒)now.時 now.分 now.秒
(現在曜日)(「日」「月」…)now.曜日(数値。0 が月曜、6 が日曜)
(R0) (R1)reference["0"] reference["1"]
(S0)(SAORIの戻り値)戻り配列の [0](8. SAORI)
起動回数・累計時間なし。global save.起動回数 += 1 のように自分で数える
(隣で起動しているゴースト) などget_property("…")(8. SAORI)

now は、湊が起動したあとSSPの時刻を取得できるまでは、日本標準時(UTC+9)の現在時刻です。取得後はSSPの仮想時刻になります。

エラーは静かに出る

湊は、変数まわりのミスをほとんど止めません。代わりに応答のヘッダにエラーを載せます。

ミス挙動
未定義変数を ${} で参照空文字列。通知なし
未定義の変数に .名前 でアクセスnull。通知なし
配列の範囲外null、警告
マップに存在しないキーを ["キー"] で参照null、通知(notice)
ゼロ除算null、警告

警告と通知は、SHIORI応答の ErrorLevel / ErrorDescription ヘッダに載ります。SSPがこれをどう表示するかは、SSPのバージョンや設定によります。台本を書きながら確認するなら、config.toml の debug_log = true で minato_load.log を出します。ただし、この設定にも注意が必要です(9. セーブデータ)。

YAYAから来た方へ

  • YAYAでは、_ で始まらない変数はグローバルで、自動で保存されます(yaya_variable.cfg)。湊は save の下だけが保存されます。
  • YAYAの変数の初期値は空文字列で、i++ のような処理を数値で初期化せずに使うと文字列になります。湊は null から始まり、+= は数値として働きます。
  • YAYAの _ 変数のスコープは「その { } とそれより深い { }」で、湊の let と同じ考え方です。

次は6. 単語群に進んでください。

6. 単語群

里々の単語群(@)は、湊にはありません。代わりに、次の3つの方法があります。里々の単語群が持っていた「ランダムに1つ選ぶ」「重複を避ける」「条件で絞る」の機能を、どれで実現するかで選びます。

方法ランダム選択重複回避条件で絞る向く場面
① 配列 + rand()○自分で書く自分で書く短い固定リスト。順番に出すなど自由な制御
② 同名トーク + return○自動(一巡)if(...)里々の単語群に一番近い。数が多いとき
③ choose()条件による2択——真偽で2つから選ぶだけ

① 配列と rand()

里々の単語群 @果物 に当たるのは、配列です。ランダムに選ぶには、rand() % 要素数 を添字に使います。

OnBoot => {
    let words = ["りんご", "みかん", "ぶどう"]
    湊: ${words[rand() % len(words)]}が好き。
}
\0りんごが好き。\e
\0みかんが好き。\e
\0ぶどうが好き。\e

rand() は大きな整数(0〜約43億)を返すので、% len(…) で範囲を絞ります。この方法には重複回避がありません。同じ単語が続けて選ばれることもあります。

何度も使うなら、選ぶ処理を関数にしておきます。

func 選ぶ(list) {
    return list[rand() % len(list)]
}

OnBoot => {
    湊: ${選ぶ(["朝", "昼", "夜"])}ですね。
}
\0朝ですね。\e
\0昼ですね。\e
\0夜ですね。\e

順番に選ぶ(里々の「降順」)

里々の $単語群「○」の重複回避【タブ】降順 のように、順番に選びたいときは、保存した番号を使います。

OnBoot => {
    let words = ["春", "夏", "秋", "冬"]
    global save.i ?= 0
    湊: ${words[save.i % len(words)]}
    global save.i += 1
}
\0春\e
\0夏\e
\0秋\e
\0冬\e
\0春\e

単語の追加

里々の (単語の追加,…) は、配列に push で追加します。push は元の配列を変えず、新しい配列を返すので、代入し直す必要があります。

OnBoot => {
    global save.覚えた ?= []
    global save.覚えた = push(save.覚えた, "りんご")
    global save.覚えた = push(save.覚えた, "みかん")
    湊: ${join(save.覚えた, "、")}|${len(save.覚えた)}
}
\0りんご、みかん|2\e

同様に pop、reverse、unique、delete(マップ用)も、新しい値を返すだけで元の値は変えません。

② 同名トークと return

同じ名前のトークを複数書き、return で文字列を返す形です。里々の @ に最も近く、一巡するまで同じものを選ばないことが自動で保証されます(4. トーク)。

果物 => {
    return "りんご"
}

果物 => {
    return "みかん"
}

果物 => {
    return "ぶどう"
}

OnBoot => {
    湊: ${果物()}が好き。
}
\0りんごが好き。\e
\0みかんが好き。\e
\0ぶどうが好き。\e

${果物()} が、里々の (果物) に当たります。

里々の「1回のトークの中で同名の単語群を何度呼んでも、同じ単語は選ばれない」という動きも、この方法なら再現できます。

色 => {
    return "赤"
}

色 => {
    return "青"
}

OnBoot => {
    湊: ${色()}と${色()}
}
\0赤と青\e
\0青と赤\e

2つの候補を2回呼ぶと、必ず別々の色になります。ただし、候補がちょうど2つのときは、最初の1周の順序が決まると、そのあとは毎回同じ順序で交互に選ばれます(周回の境目で同じものを続けないという規則のため)。上の例では、1回の実行中は「赤と青」か「青と赤」のどちらか一方だけが繰り返されます。

条件で絞る

里々の @単語群【タブ】条件式 に当たるのは、トーク定義の if(...) です。

天気 if(save.雨) => {
    return "雨"
}

天気 if(!save.雨) => {
    return "晴れ"
}

OnBoot => {
    湊: 今日は${天気()}です。
}

OnRain => {
    global save.雨 = true
}
\0今日は晴れです。\e
(204)
\0今日は雨です。\e

すべての候補が条件で除外されると、${…} は空文字列になります。 作者向けのログに通知が残ります(セリフは出力されるので、気づきにくい点に注意してください)。

単語の中に単語

return に返す文字列の中で、別の単語を呼べます。

色 => {
    return "赤"
}

りんご => {
    return "${色()}いりんご"
}

OnBoot => {
    湊: ${りんご()}です
}
\0赤いりんごです\e

セリフを含むトークは単語群に向かない

return ではなく、湊: のセリフを書いたトークを ${…()} で呼ぶと、スコープタグ(\0 など)も一緒に文字列に入ってしまいます。

挨拶 => {
    湊: こんにちは
}

OnBoot => {
    湊: 「${挨拶()}」
}
\0「\0こんにちは」\e

単語や短い文だけを返すトークは、return "…" で書いてください。

③ choose

真偽で2つのうち1つを選ぶだけなら、choose(条件, 真のときの値, 偽のときの値) が使えます。里々の (when,条件,真,偽) に当たります。

OnBoot => {
    湊: ${choose(true, "はい", "いいえ")}
}
\0はい\e

選ばれなかった側の式は評価されません(saori() などの副作用がある式を書いても、実行されません)。

里々の単語群の細かい機能

里々湊
1行が1候補①なら配列の1要素、②なら1つのトーク
単語群の中に (別の単語群)②の return "${別のトーク()}"
単語の追加・削除の関数push pop delete(新しい値を返すので代入し直す)
重複回避の方式の変更(無効・直前・降順・昇順)②は「一巡」固定。他の方式は①で自作

YAYAから来た方へ

  • YAYAの 名前 { "a" "b" "c" }(複数の文字列から1つを選ぶ関数)は、②の同名トークに当たります。YAYAでは : nonoverlap を付けて重複を避けますが、湊の②は常に一巡します。
  • YAYAの -- で区切って複数の単語群を連結する書き方は、${…} を並べて書きます。

次は7. 選択肢・条件分岐・ウェイトに進んでください。

7. 選択肢・条件分岐・ウェイト

この節では、里々が「書き方」として用意していた3つ、選択肢(_)、条件分岐(>、when、iflist)、ウェイト(自動挿入)を扱います。

選択肢

里々の _ラベル は、湊にはありません。さくらスクリプトの \q[ラベル,ID] を直接書きます。 里々も内部でこの形に変換していたので、書く内容は同じです。

イベント名を ID にする

SSPの \q[ラベル,ID] は、ID が On で始まる場合、そのイベントを直接実行します。選ばれたときに動くトークを、OnXxx という名前で定義します。

OnBoot => {
    湊: どっちにしますか?
    \q[りんご,OnApple]
    \q[みかん,OnOrange]
}

OnApple => {
    湊: りんごですね。
}

OnOrange => {
    湊: みかんですね。
}
\0どっちにしますか?\n\q[りんご,OnApple]\n\q[みかん,OnOrange]\e
\0りんごですね。\e
\0みかんですね。\e

\q[…] の行は、直前のセリフに \n(改行)付きで連結されます。選択肢が縦に並ぶのは、この改行のおかげです。

\q[ラベル,OnID,値0,値1] のように、ID のあとに値を続けると、それが選ばれたイベントの reference["0"]、reference["1"] に入ります。

OnAnswer => {
    湊: ${reference["0"]}が選ばれました。
}
\0yesが選ばれました。\e

里々のように「ラベル=ジャンプ先のトーク名」にする

里々では、_はい と書くだけで *はい に飛びました。同じ動きにするには、ID を On で始めず、トーク名にします。すると SSPは、OnChoiceSelect イベントを reference["0"] に ID を入れて送ります。これを受けて call reference.0 で呼び出します。

OnBoot => {
    湊: どっち?
    \q[はい,はい]
    \q[いいえ,いいえ]
}

OnChoiceSelect => {
    call reference.0
}

はい => {
    湊: はいが選ばれました。
}

いいえ => {
    湊: いいえが選ばれました。
}
\0どっち?\n\q[はい,はい]\n\q[いいえ,いいえ]\e
\0はいが選ばれました。\e
(204)
  • call reference.0 は、reference["0"] の文字列をトーク名として呼びます。里々と同じく、同名のトークが複数あればランダムに1つ選ばれます。
  • 存在しない名前のときは、何も出力されず 204 になります。エラーにはなりません。
  • 選択肢が増えるたびに OnChoiceSelect の中身を書き換える必要はありません。

里々の選択肢関連の変数

里々の (選択ID) (選択ラベル) (選択番号) に当たる変数は、湊にはありません。SSPが送る Reference から取り出します。

里々湊
(選択ID)OnChoiceSelect の reference["0"](On で始まらない ID のとき)
(選択ラベル)OnChoiceSelectEx の reference["0"](SSPのみ)。ID は reference["1"]
(選択番号)なし

選択肢を条件で出す

里々では \q[(見たもの1フラグ),選択肢…] のように書きました。湊では、\q の行を if で囲みます。

OnBoot => {
    湊: どれにしますか?
    \q[りんご,OnApple]
    if (save.見た) {
        \n\q[みかん,OnOrange]
    }
}

OnSet => {
    global save.見た = true
}
\0どれにしますか?\n\q[りんご,OnApple]\e
(204)
\0どれにしますか?\n\q[りんご,OnApple]\n\q[みかん,OnOrange]\e

if の中の \q の行は、直前のセリフとは連結されないので、改行の \n を自分で書く必要があります。

条件分岐

里々湊
>ジャンプ先【タブ】条件式if (条件) { call … return }(4. ジャンプ)
(when,条件,真の値,偽の値)choose(条件, 真の値, 偽の値)
(iflist,左辺,右辺1,結果1,…,偽の結果)if … else if … else、または match
ssu.dll の if unlessif (条件)、if (!条件)、または choose()
ssu.dll の switch nswitchmatch
条件式の == >= &&(全角も可)== >= &&(半角のみ)

里々の iflist は、範囲で分岐するときによく使われました。湊では、if の連鎖を関数にまとめると読みやすくなります。

func 時間帯(h) {
    if (h < 6) {
        return "深夜"
    } else if (h < 12) {
        return "朝"
    } else if (h < 18) {
        return "昼"
    }
    return "夜"
}

OnBoot => {
    湊: ${時間帯(5)} ${時間帯(11)} ${時間帯(12)} ${時間帯(23)}
    湊: ${choose(true, "はい", "いいえ")}
}
\0深夜 朝 昼 夜\nはい\e

if の条件は、真偽値ではなく どんな値でも書けます。その場合の真偽の判定は、5. 型と暗黙の型変換の規則に従います。

match

値による分岐は match です。数値と文字列は、内容が同じなら一致とみなされます("3" は 3 に一致します)。

OnBoot => {
    let x = "3"
    match x {
        3 => {
            湊: 数値3にマッチ
        }
        _ => {
            湊: その他
        }
    }
}
\0数値3にマッチ\e

_ はどれにも一致しなかったときの分岐です。1 | 2 => のように | で複数の値をまとめられます。

ウェイト

里々が自動で入れていたウェイトは、湊にはありません。 句読点で間を空ける処理(replace.txt や $自動挿入ウェイトタイプ)も、スコープ切り替え時の自動ウェイトもありません。何も書かないと、間のない速い喋りになります。

手で書く

さくらスクリプトの \w5(50ミリ秒)や \_w[500] をそのまま書きます。

文字列リテラルの中の \ は1つだけ

セリフの中でも、文字列の中でも、\w5 は1つのバックスラッシュで書きます。\\w5 と2つ書くと、ウェイトになりません。文字列の中の \\ は2文字のまま残るため、さくらスクリプトとしては「バックスラッシュという文字を表示する」の意味になるからです。

OnBoot => {
    let ok = "あ\w5い"
    let ng = "あ\\w5い"
    湊: ${ok}|${ng}
}
\0あ\w5い|あ\\w5い\e

句読点に自動でウェイトを付ける

里々の replace.txt(句読点の置き換え)に当たる処理は、replace() を使った関数で作れます。

func wait(s) {
    return replace(replace(s, "、", "、\w3"), "。", "。\w9")
}

OnBoot => {
    湊: ${wait("今日は、いい天気ですね。")}
}
\0今日は、\w3いい天気ですね。\w9\e

セリフごとに ${wait("…")} と書く必要があります。全体に一括で適用する仕組みはありません。

すべての出力に一括で適用したい場合は、SSPの OnTranslate イベントを使う方法があります(3. イベント)。ただし、この方法は、選択肢のラベルや \![…] の引数の中の句読点にもウェイトを入れてしまうので、慎重に使ってください。

スコープの切り替えと改行

里々では、: で始まる行ごとに話者が交互に切り替わり、切り替え時に $スコープ切り替え時(\n[half] など)が自動で挿入されました。

湊では、話者を毎回書き、切り替え時の挿入もありません。

OnBoot => {
    湊: 一行目\n[half]
    助手: 二行目
    湊: 三行目
    助手: 四行目
}
\0一行目\n[half]\1二行目\0\n三行目\1\n四行目\e
  • 話者が変わるときは、\0 \1 のタグが入るだけです。\n[half] のような間隔は、自分で書きます。
  • 一度話した話者に戻るときだけ、直後に \n が自動で入ります。 上の例の \0\n三行目 と \1\n四行目 がそうです(config.toml の auto_newline = false で止められます)。

サーフェス

[数字] を話者の前に付けると、その話者の発話の直前に \s[数字] が入ります。セリフの途中に入れたいときは、\s[数字] を直接書きます。

OnBoot => {
    [5]湊: 5番
    湊: \s[3]3番
    助手: 助手
}
\0\s[5]5番\n\s[3]3番\1助手\e

里々の (5) のような括弧でのサーフェス切り替えは使えません。$デフォルトサーフェス や $会話時サーフェス戻し、$サーフェス加算値 のような自動処理もありません。トークの先頭で、必要なサーフェスを毎回書いてください。

次は8. SAORI呼び出しに進んでください。

8. SAORI呼び出し

SAORIを使っているゴーストは、呼び出し部分の書き換えが必要です。書き方は簡単になりますが、戻り値の扱いと呼び出しの制限に、里々とは違う点があります。

呼び出し方

里々では、satori_conf.txt の @SAORI に登録名を書き、(登録名,引数,…) で呼びました。

@SAORI
mciaudio,saori/mciaudio.dll
(mciaudio,load,鳥の詩.mp3)(mciaudio,play)

湊では、登録は不要です。saori("DLLのパス", 引数, …) と、パスを直接書いて呼びます。

OnBoot => {
    let r = saori("saori/mciaudio.dll", "load", "鳥の詩.mp3")
    let r2 = saori("saori/mciaudio.dll", "play")
}
  • パスは ghost/master からの相対パスです。区切りは / でも \ でも構いません。
  • 絶対パス(C:\…、/…)、.. を含むパスは使えません。エラーになります。
  • DLLは、初回の呼び出し時に読み込まれ、ゴーストが終了するまで保持されます。同時に保持できるDLLは32個までで、超えると最も使っていないものから解放されます。
  • 引数は、数値も真偽値も、表示形式の文字列に変換されて渡されます(3 → "3")。
  • 湊本体とビット数が同じDLLでないと読み込めません。

里々の呼び出しを湊に直すと、(登録名,a,b) → saori("パス", "a", "b") です。呼び出しごとにパスを書くのが面倒なら、func で包みます。

func 音楽(cmd, arg) {
    return saori("saori/mciaudio.dll", cmd, arg)
}

戻り値は配列

saori() は、SAORIの戻り値を配列で返します。Value0 が [0]、Value1 が [1] です。里々の (S0) (S1) に当たります。

検証のために、次のように返す小さなSAORI(echo.dll)を作りました。

戻り値内容
Resultecho: + Argument0
Value0Argument0 を大文字にしたもの
Value1引数の個数
Value2全引数を / でつないだもの

これを湊から呼びます。

OnBoot => {
    let r = saori("echo.dll", "abc", "d e", 3)
    湊: len=${len(r)} r0=${r[0]} r1=${r[1]} r2=${r[2]}
}

実際に動かした結果は、次のとおりです。

\0len=3 r0=ABC r1=3 r2=abc/d e/3\e

r[0] は Value0(ABC)、r[1] は Value1(3)、r[2] は Value2(abc/d e/3)です。

Result は取り出せないことがある

上の結果で、Result(echo:abc)はどこにもありません。湊は、Result を Value0 とは別に取り出せません。 SAORIが Value0 を返さないときにだけ、Result が [0] に入ります。Value0 があれば、そちらが優先されます。

里々の (登録名,引数) は、SAORIの Result を、括弧の位置に埋め込む文字列にしました。湊で同じことをするには、その SAORI が Value0 を返さないか、Value0 に欲しい値が入っている必要があります。

SAORI の戻り湊で取れるもの
Result だけr[0] = Result
Result と Value0…r[0] = Value0、r[1] = Value1… (Result は取れない)
Value が無く Result も無い空の配列

ssu.dll のように、Result に個数を、Value0 以降に分割結果を返すSAORI(split など)は、r の要素がそのまま分割結果なので、個数は len(r) で分かります。

Result と Value0 の両方が必要なSAORIを使っているなら、13. 段階移行で述べる別の方法を検討してください。

SAORIが何も返さないとき

返る配列の要素数は、SAORIが返した Value の数で決まります。検証用の echo.dll を引数なしで呼ぶと、Value0 が空文字列の3要素の配列が返りました(SAORIが空の値を返したため)。戻り値の有無に依存した処理は、len(r) で確かめてください。 存在しない番号を参照すると null になり、警告が出ます。

呼び出しの制限

SAORIの呼び出しは、湊がSSPからの要求に答えている最中に同期して行われます。SAORIが応答しないと、SSP全体が止まってしまうため、湊は次の制限を設けています。

制限内容
1イベントあたり10回までsaori() と get_property() の合計です。ループの中で呼ぶと、11回目以降は呼び出されず、空の配列(get_property は空文字列)が返ります。saori() では警告が出ますが、get_property() では警告は出ません(debug_log が有効なときのログにだけ残ります)
応答は5秒まで5秒待っても返らないと打ち切ります。打ち切られたSAORIは、そのゴーストを再読み込みするまで二度と呼び出されません
読み込みエラーDLLが見つからない、必要な関数がない、パスが不正(絶対パス、.. を含むなど)の場合は、エラー(error)を出して空の文字列を返します(配列ではありません)。len(r) は 0、r[0] は null で警告が出ます

ループから呼んだときの結果です。

OnBoot => {
    for (let i = 0; i < 12; i++) {
        let r = saori("echo.dll", "x")
        湊: [${r[1]}]
    }
}
\0[1][1][1][1][1][1][1][1][1][1][][]\e

10回目までは結果が入り、11回目・12回目は空になります。

里々のSAORI呼び出しには、こうした回数制限はありませんでした。1回のトークで何度もSAORIを呼ぶ処理は、まとめて1回で済むように作り直してください。

文字コードとヘッダ

  • 湊はSAORIに、引数を UTF-8 で送ります(Charset: UTF-8)。応答も UTF-8 として読みます。
  • SAORI/1.0 の Sender、SecurityLevel などのヘッダは付けません。
  • DLLを読み込むときにSAORIへ渡すパスは、Shift_JIS(表せない場合はUTF-8)です。

古いSAORIには、リクエストの Charset ヘッダを見ずに Shift_JIS で処理するものがあります。 そうしたSAORIに日本語の引数を渡すと、文字化けや誤動作の原因になります。実際に使うSAORIごとに確認してください(このガイドでは、実在するSAORIとの組み合わせは確認していません)。

get_property

里々の %property[…]、および環境変数の取得に当たるのが get_property("…") です。SSPのプロパティシステムに、SSTP(127.0.0.1:9801)で問い合わせます。

OnBoot => {
    湊: ${get_property("currentghost.name")}
}
  • 結果は文字列です。取得に失敗したときは、空文字列です(SSPが起動していない環境では、常に空です)。
  • 1イベントあたり10回の上限には、saori() と合わせて数えられます。
  • 同期呼び出しのため、SSPの状態によっては、SSPの動作を一瞬止めることがあります。頻繁に使わないでください。

YAYAから来た方へ

  • YAYAの FUNCTIONEX('saori\x.dll', 引数…) は、saori("saori/x.dll", 引数…) に当たります。パスの基準が違う点(YAYAは yaya.dll からの相対、湊は ghost/master からの相対)は、shiori.dll と同じ場所に置いていれば同じです。
  • YAYAでは、Result が関数の戻り値、Value0… が valueex0… に分かれて入ります。湊では両方が1つの配列にまとまり、Result は Value0 があると取れません。 YAYAで Result を使っていたコードは、そのままでは移せません。

次は9. セーブデータの移行と型の違いに進んでください。

9. セーブデータの移行と型の違い

すでにユーザーがいるゴーストを湊に移すなら、里々の satori_savedata.txt を引き継ぐ必要があります。里々のセーブデータは湊では読めません。形式も、値の型も違います。

里々と湊のセーブデータ

里々湊
ファイルsatori_savedata.txt(暗号化すると .sat)save.json
形式1行に1変数。$変数名【タブ】値JSON
保存される変数すべての変数save の下に置いたものだけ(5. 変数)
値の型すべて文字列数値・文字列・真偽値・配列・マップ・null
文字コード既定 Shift_JIS(is_utf8_savedata で UTF-8 も可)UTF-8(BOMなし)
保存のタイミング終了時、$手動セーブ、$自動セーブ間隔終了時と再読み込み時だけ
バックアップsatori_savebackup.txt下記の .bak

里々のセーブデータは、たとえばこのような形です。

*セーブデータ
$好感度	12
$名前	ユーザー
$体重	57.8
$前回起動	2026/09/18
$喋り間隔	180秒

湊の save.json は、たとえばこのようになります。

{
  "save": {
    "名前": "ユーザー",
    "好感度": 12
  },
  "system": {
    "debug_log": false,
    "talk_interval": 300,
    "talk_jitter": 180
  }
}
  • save が、台本の global save.… の中身です。
  • system は湊の設定で、talk_interval、talk_jitter、debug_log の3つだけが保存されます(下記)。
  • JSONの数値は、整数でも 3.0 のように保存されます。 台本からは 3 として見えます。
  • 保存のとき、キーは辞書順に並びます。再起動後、マップの要素の順序は辞書順になります。

移行の方法は2つ

方法向く場面
A. 湊の台本で読み込む配布済みのゴーストを更新するとき。ユーザーの環境に残った satori_savedata.txt を、湊が最初に起動したときに自動で取り込む
B. 変換スクリプトで save.json を作る自分の環境のデータを移したいとき。ユーザーに渡す save.json の見本を作りたいとき

A. 湊の台本で読み込む

湊は、ghost/master の中のファイルを file_read() で読めます。里々の satori_savedata.txt は、そのまま ghost/master に残っているので、湊が起動したときに読み込んで save に移せば、ユーザーは何もせずに引き継げます。

次の台本は、satori_savedata.txt を Shift_JIS として読み、$ で始まる行を save に入れます。全角の数字を半角にし、数値に見えるものは数値に直します。

func 半角化(s) {
    let r = s
    let z = "0123456789"
    for (let i = 0; i < 10; i++) {
        r = replace(r, substr(z, i, 1), to_str(i))
    }
    r = replace(r, ".", ".")
    r = replace(r, "-", "-")
    return r
}

func 取り込み() {
    let text = file_read("ghost/master/satori_savedata.txt", "sjis")
    let n = 0
    foreach split(text, chr(10)) as i, line {
        let row = replace(line, chr(13), "")
        if (starts_with(row, "$")) {
            let p = split(substr(row, 1), chr(9))
            if (len(p) >= 2) {
                let v = replace(p[1], "φ", "")
                let h = 半角化(v)
                if (regex_match(h, '^-?[0-9]+(\.[0-9]+)?$')) {
                    global save[p[0]] = to_num(h)
                } else {
                    global save[p[0]] = v
                }
                n += 1
            }
        }
    }
    return n
}

OnBoot => {
    // ここから3行は動作確認用。実際は、里々のセーブデータがすでに置かれている。
    let tab = chr(9)
    let nl = chr(10)
    file_write("ghost/master/satori_savedata.txt", "*セーブデータ" + nl + "$好感度" + tab + "12" + nl + "$名前" + tab + "ユーザー" + nl + "$体重" + tab + "57.8" + nl, "sjis")

    if (!save.移行済み) {
        let n = 取り込み()
        global save.移行済み = true
        湊: ${n}個の変数を取り込みました。
    }
}
\03個の変数を取り込みました。\e
{"好感度":12,"名前":"ユーザー","体重":57.8,"移行済み":true}
  • 取り込み は、取り込んだ変数の数を返す関数です。OnBoot で、save.移行済み を目印にして、最初の1回だけ実行します。
  • 正規表現は、シングルクォート '…' の中に書くと安全です。ダブルクォート "…" の中では、$ のすぐ後に { があると展開の始まりとして読まれます(10. 文字コード・改行・エスケープ)。
  • foreach 配列 as i, 要素 の形で、i が添字、2つ目が要素です。変数を1つしか書かないと、それは添字になります。
  • OnBoot の最初の3行は、動作確認用です。実際のゴーストでは不要で、if (!save.移行済み) の部分だけを使います。
  • satori_savedata.txt が UTF-8 なら、file_read の第2引数を "utf8" にします。
  • satori_savedata.txt が存在しないときは、file_read は null を返し、何も取り込まれません(警告がログに残ります)。
  • 里々の特殊変数(喋り間隔 など)も取り込まれます。不要なものは、save に入れる前に条件で除外してください。

B. 変換スクリプトで save.json を作る

Windows の PowerShell で動く変換スクリプトです。satori_savedata.txt を読み、save.json を書きます。

# convert-satori-save.ps1
# Converts satori_savedata.txt to save.json (ASCII only on purpose).
param(
    [string]$In  = "satori_savedata.txt",
    [string]$Out = "save.json",
    [switch]$Utf8,
    [string[]]$Exclude = @()
)

# Resolve relative paths against the current PowerShell location (.NET does not follow cd).
$In  = [System.IO.Path]::Combine((Get-Location).Path, $In)
$Out = [System.IO.Path]::Combine((Get-Location).Path, $Out)

$dollar    = [string][char]0xFF04
$phi       = [string][char]0x03C6
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
if ($Utf8) { $enc = $utf8NoBom } else { $enc = [System.Text.Encoding]::GetEncoding(932) }

$toHalf = [System.Text.RegularExpressions.MatchEvaluator]{
    param($m)
    $c = [int][char]$m.Value
    if     ($c -eq 0xFF0E) { "." }
    elseif ($c -eq 0xFF0D) { "-" }
    else                   { [string][char]($c - 0xFEE0) }
}
$fullWidthNumberChars = "[" + [char]0xFF10 + "-" + [char]0xFF19 + [char]0xFF0E + [char]0xFF0D + "]"

$save = [ordered]@{}
foreach ($line in [System.IO.File]::ReadAllLines($In, $enc)) {
    if (-not $line.StartsWith($dollar)) { continue }
    $body = $line.Substring(1)
    $tab  = $body.IndexOf("`t")
    if ($tab -lt 0) { continue }
    $name = $body.Substring(0, $tab)
    if ($Exclude -contains $name) { continue }
    $value = $body.Substring($tab + 1).Replace($phi, "")
    $half  = [regex]::Replace($value, $fullWidthNumberChars, $toHalf)
    if ($half -match '^-?[0-9]+(\.[0-9]+)?$') {
        $save[$name] = [double]$half
    } else {
        $save[$name] = $value
    }
}

$json = @{ save = $save } | ConvertTo-Json -Depth 5
[System.IO.File]::WriteAllText($Out, $json, $utf8NoBom)
Write-Host ("converted: " + $save.Count)

使い方です。PowerShell を開き、satori_savedata.txt とスクリプトのあるフォルダに移動して、次のように実行します。

.\convert-satori-save.ps1 -In satori_savedata.txt -Out save.json -Exclude 喋り間隔,喋り間隔誤差

「スクリプトの実行が無効になっています」と表示されたときは、先に Set-ExecutionPolicy -Scope Process Bypass を実行してください(今開いているPowerShellだけで有効です)。

  • 里々のセーブデータが UTF-8 なら、-Utf8 を付けます。
  • -Exclude に、移行しない変数名をカンマで並べます(里々の特殊変数など)。
  • 「$で始まり、タブで区切られた行」だけを読みます。見出しの *セーブデータ やタブのない行は飛ばします。
  • φ(里々が括弧を保護するために付ける印)は取り除きます。
  • 全角数字を半角にして、12、57.8、-3 のような数値に見える値は数値にします。それ以外(180秒 や日付など)は文字列のままです。
  • BOMなしのUTF-8で書き出します。 BOM付きだと、湊が save.json を「壊れている」と判断して、空のセーブデータで起動します(元のファイルは save.json.corrupt.bak に残ります)。

スクリプトを自分で編集するときの注意 このスクリプトは意図的にASCIIの文字だけで書いてあります。Windows PowerShell 5.1 は、BOMなしのUTF-8で保存された .ps1 を日本語Windowsの文字コード(Shift_JIS)として読むため、日本語(コメントを含む)を書くと構文エラーになります。日本語を書きたいときは、.ps1 を「BOM付きのUTF-8」で保存してください。

上の里々のセーブデータ(喋り間隔 は除外しない)から作られる save.json は、次のとおりです。全角の数字は数値になり、φ は消えています。

{
    "save":  {
                 "好感度":  12,
                 "名前":  "ユーザー",
                 "体重":  57.8,
                 "前回起動":  "2026/09/18",
                 "喋り間隔":  "180秒"
             }
}

この save.json を湊のゴーストの ghost/master に置いて起動したとき、台本からは次のように見えます。

{
    "save":  {
                 "好感度":  12,
                 "名前":  "ユーザー",
                 "体重":  57.8,
                 "前回起動":  "2026/09/18",
                 "喋り間隔":  "180秒"
             }
}
OnBoot => {
    湊: ${save.好感度 + 1}|${save.好感度 >= 10}|${save.体重 * 2}
    湊: ${save.名前}さん、${save.喋り間隔}
    let c = regex_captures(save.前回起動, "(\d+)/(\d+)/(\d+)")
    湊: ${days_between(c[1], c[2], c[3], 2026, 9, 19)}日ぶりです。
}
\013|true|115.6\nユーザーさん、180秒1日ぶりです。\e

型の違いを確認する

移行のときは、変数ごとに「湊ではどの型で持つか」を決めます。

里々での値そのまま移すと湊での持ち方
全角の数(12)文字列 "12"。計算も比較もできない数値 12 に直す(上のスクリプトは自動)
半角の数(12)文字列 "12"。+ 1 は 121 になる数値 12 に直す
フラグ(0 / 1、有効 など)"0" も真になる0/1 の数値、または true/false にする
名前などの文字列そのまま文字列文字列
日付(2026/09/18)文字列文字列のまま、必要なときに regex_captures で分解する(上の例)
番号付きの変数(野菜0、野菜1)別々の変数配列 […] にまとめる(自動変換はされない)
里々の特殊変数(喋り間隔 など)意味のない変数として残る移行しない(-Exclude)

移行後に、動作が変わりやすいのはフラグです。 里々では 0 は偽でしたが、湊では文字列の "0" は真です。変換スクリプトは数値 0、1 にするので問題ありませんが、有効 や 無効 のような文字列のフラグは、台本側を == "有効" のように書き直すか、真偽値に変えてください。

config.toml の間隔設定は save.json が勝つ

talk_interval_secs、talk_jitter_secs、debug_log は、初回の起動時にだけ config.toml の値が使われ、そのあとは save.json の値が優先されます。 起動して一度でも終了すると、save.json の system にこの3つが書き込まれるためです。

たとえば、最初に talk_interval_secs を 300 で起動して終了し、あとで config.toml を 60 に書き換えて起動すると、system.talk_interval は 300 のままです。save.json を消して起動し直すと、60 になります(実際にこの順序で確認しました)。

  • ランダムトークの間隔を、配布済みのユーザーの環境で変えたいときは、config.toml を書き換えても効果がありません。台本の中で global system.talk_interval = 60 のように書き換えます。
  • debug_log も同じ仕組みです。ログを出したいときは、save.json の system.debug_log を true にするか、save.json を消してください。
OnBoot => {
    global system.talk_interval = 60
}

OnClose => {
    湊: ${system.talk_interval}
}
\0300\e
(204)
(reload)
\060\e

台本から書き換えた値は、再起動しても残ります。

壊れたとき、消えたとき

状況湊の動き
save.json が壊れている(JSONとして読めない)元のファイルを save.json.corrupt.bak に移し、空のセーブデータで起動する
湊の実行中に内部エラーが起き、そのあとに保存する上書き前の save.json を save.json.panic.bak に残す。次の起動時に一度だけ警告が出る
台本にエラーがあって読み込めなかったsave.json は上書きしない(セーブデータが空で潰れるのを防ぐ)
ゴーストが強制終了された保存されない。その起動中に覚えたことは消える

里々の satori_savebackup.txt(1世代前のバックアップ)に当たる、通常の世代バックアップはありません。

配布するとき

save.json や .bak ファイルは、配布するファイル(NARなど)に含めません。里々の satori_savedata.txt と同じ扱いです。SSPでは、developer_options.txt に次のように書きます。

ghost/master/save.json,nonar,noupdate

YAYAから来た方へ

YAYAのグローバル変数は yaya_variable.cfg に保存されます。このファイルの形式を読む変換ツールは、このガイドにはありません。移行するときは、変数の一覧を ghost/master に別の形式(名前<タブ>値 の行など)で書き出す処理を、YAYA側に用意してください。それを上のAの台本のように、file_read で取り込むのが確実です。

次は10. 文字コード・改行・エスケープに進んでください。

10. 文字コード・改行・エスケープ

この節は、「エラーは出ないのに、文が消える・改行が変わる・文字が化ける」ときの原因を集めたものです。特に改行のルールは、里々と湊で大きく違います。

文字コード

対象里々湊
台本dic*.txt。既定は Shift_JIS(satori_bootconf.txt の is_utf8_dic で UTF-8 も可)talks/main.mnt(とその include)。UTF-8(BOMなし)だけ
設定ファイルsatori_conf.txtconfig.toml。UTF-8(BOMがあっても読める)
セーブデータsatori_savedata.txt。既定は Shift_JISsave.json。UTF-8(BOMなし)
SSPとの通信里々が処理する常に Shift_JIS(応答に Charset: Shift_JIS を付ける)
ファイル操作—file_read / file_write は既定 UTF-8。第2・3引数に "sjis" を渡すと Shift_JIS
SAORIとの通信—UTF-8(8. SAORI呼び出し)

台本の文字コードを変換する

里々の辞書は書き直すことになるので、多くの場合、エディタで新しい .mnt を作ることになります。既存のテキストをShift_JISからUTF-8(BOMなし)に変換するには、PowerShell で、変換するファイルのあるフォルダに移動して次のようにします。

$enc = New-Object Text.UTF8Encoding($false)
[IO.File]::WriteAllText("$PWD\out.txt", [IO.File]::ReadAllText("$PWD\in.txt", [Text.Encoding]::GetEncoding(932)), $enc)

Set-Content -Encoding UTF8 は、Windows PowerShell 5.1 ではBOM付きで書き出すので、使わないでください。

BOM付きUTF-8は使わない

BOM付きの main.mnt は、エラーを出さずに、ファイルの最初のトークだけを壊します。 先頭の OnBoot が、BOMを含んだ別の名前として登録され、呼ばれなくなります。ファイルの最初のトークだけが動かないので、原因が分かりにくい不具合です。エディタの設定で「UTF-8(BOMなし)」にしてください。

config.toml のBOMは問題ありません。save.json は、BOMがあると壊れたものとして扱われます(9. セーブデータ)。

出力できない文字

湊は、SSPに返すスクリプトを Shift_JIS(Windows-31J)に変換します。Shift_JIS に無い文字は、&#数値; の形(数値文字参照)に置き換わり、そのまま画面に出ます。

OnBoot => {
    湊: ①丸数字と😀と𠮷と髙と−。
}
\0①丸数字と&#128512;と&#134071;と髙と-。\e
  • ①(丸数字)、髙、﨑 などは、Windows-31J にあるので出ます。
  • 絵文字(😀)や 𠮷 などは、&#128512; のように化けます。
  • −(U+2212、数学のマイナス)は、エラーにならず全角ハイフン - に置き換わります。

里々の辞書はもともと Shift_JIS で書かれているので、里々から移した文章は影響を受けません。新しく UTF-8 で書く文章に、絵文字などを入れたときだけ問題になります。

改行のルール

湊が改行として出力するのは、\n(さくらスクリプトの改行)だけで、それが入る条件は決まっています。 里々では、* の中の通常の改行はすべて \n に置き換えられました。湊では、セリフの書き方によって、改行が入る場合と入らない場合があります。

1. 連続したセリフ行には \n が入る

同じ話者の行が続くと、その間に \n が入ります。コロンなしの続き行も同じです。

OnBoot => {
    湊: 一行目
    二行目(続き行)
    湊: 三行目
}
\0一行目\n二行目(続き行)\n三行目\e

2. コード行を挟むと \n が入らない

let や if、foreach など、セリフではない行を挟むと、そこで文が区切られ、改行は入りません。

OnBoot => {
    湊: 一行目
    let x = 1
    湊: 二行目
}
\0一行目二行目\e

for や foreach の中でセリフを出すときも同じです。1回ごとに改行したいなら、セリフの末尾に \n を書きます(5. 配列変数)。

3. 空行も、セリフをつなぐ改行を止める

空行は「文の区切り」として働きます。空行の前後のセリフの間には、改行が入りません。

OnBoot => {
    湊: 一行目
    湊: 二行目

    湊: 空行の後
    湊: 行末セミコロン;
    湊: その次
}
\0一行目\n二行目空行の後\n行末セミコロン\nその次\e
  • 「二行目」と「空行の後」が、つながっています。
  • 行末の ; は取り除かれるだけです。改行の有無には影響しません(「行末セミコロン」の後に \n が入っています)。

4. ; だけの行は「区切り」

; だけを書いた行は、そこまでのセリフを確定させます。改行は入りません。

OnBoot => {
    湊: 一行目
    ;
    湊: 二行目
}
\0一行目二行目\e

5. セリフの直後の行は「続き行」になる

セリフの直後に書いた行は、let / global / call / if などのキーワードで始まっていない限り、セリフの続き行として読まれます。 代入文(x = 5)や関数呼び出し(hello())は、コードではなくセリフになります。

OnBoot => {
    湊: 一行目
    counter = 5
    湊: 二行目
}
\0一行目\ncounter = 5\n二行目\e

counter = 5 が、代入されずにセリフとして出力されています。エラーも出ません。

対策は、次のどれかです。

  • let や global を付ける(global work.counter = 5)。
  • ; だけの行を挟む。
  • call トーク名 の形で呼ぶ(call はキーワードなので、続き行になりません)。

行頭でコードと判断される語は、if else for(foreach を含む)while match func break continue let global return call、{ }、// /*、\、および => を含む行です。これらの語で始まる名前の関数(format(…)、letter = 1 など)も、コードと判断されます。 逆に、それ以外で始まる代入や関数呼び出しは、セリフの直後ではセリフの続き行になります。

6. 半角コロンのある続き行は、話者の指定になる

セリフの行に、半角コロンが含まれ、その前が空白や記号のない文字だけの場合、その行は「話者の指定」と読まれます。

OnBoot => {
    湊: 一行目
    注意:これは続き行のつもり
}
\0一行目これは続き行のつもり\e

注意: が話者名として扱われ(登録されていないので \0 のまま)、「注意:」が消え、改行も入りません。

  • 全角のコロン : は、話者の指定になりません。文中のコロンは全角にしてください。
  • コロンの前に空白があれば(次の行は 例:これ)、話者の指定にはなりません。

7. \ で始まる行は、直前のセリフに \n 付きでつながる

行頭が \ の行は、\-、\e、\![ で始まるものを除き、直前のセリフに \n を挟んでつながります。\q[…](選択肢)が縦に並ぶのはこのためです(7. 選択肢)。

OnBoot => {
    湊: 一行目
    \n[half]
    助手: 二行目
}
\0一行目\n\n[half]\1二行目\e

\n[half] の前にも改行 \n が入るので、改行が2つになっています。間隔だけを入れたいときは、セリフの行末に書きます(湊: 一行目\n[half])。

8. 話者に戻ると \n が入る

一度話した話者が、他の話者のあとにもう一度話すとき、話者のタグの直後に \n が自動で入ります(config.toml の [settings] で auto_newline = false にすると、止められます)。

9. 行頭の空白は削られる

続き行の行頭の空白(全角スペースを含む)は削られます。里々で全角スペースを字下げに使っていた場合、続き行では字下げが消えます。話者の指定のあとの空白は、そのまま出力されます。

OnBoot => {
    湊: 一行目
  二行目(全角スペースで始まる続き行)
    湊: 「全角スペースの後」
}
\0一行目\n二行目(全角スペースで始まる続き行)\n 「全角スペースの後」\e

10. 1行のブロックにセリフは書けない

OnBoot => { 湊: こんにちは } のように、{ と } の間にセリフを書いて1行にまとめることはできません。セリフが行末まで続くため、} がセリフの一部になり、閉じ括弧が見つからなくなります。

OnBoot => { 湊: こんにちは }
\b[2]\0パースエラー:\nmain.mntの1行目: トーク定義は「}」で閉じてください(ファイル末尾付近)\e

if、for、match の中でも同じです。ブロックは、複数行に分けて書いてください。

改行コード

台本のファイルの改行は、CRLFでもLFでも読めます。

$ と \ の扱い

$

${ は、変数・式の展開の開始です。{ が続かない $ は、セリフでも "…" の中でも、ただの文字として出ます。

OnBoot => {
    湊: 100$です。${'${'}も出せます
}
\0100$です。${も出せます\e
  • ${ という並びを文字として出したいときは、${'${'} と書きます(シングルクォートの中は展開されません)。
  • 湊 0.1.3 までは、{ が続かない $ も構文エラーになり、${'$'} と書く必要がありました。この書き方は今も使えます。
  • 正規表現の $(行末)は、'…' の中に書きます('^[0-9]+$')。

\(バックスラッシュ)

セリフでも文字列でも、\ はさくらスクリプトのタグとしてそのまま出力されます。エスケープ処理はありません。\\ は2文字のままで、さくらスクリプトとしては「\ という文字を表示する」の意味になります(7. ウェイト)。

正規表現の \d のような書き方は、\ を1つだけ書きます。

OnBoot => {
    let ok = regex_captures("2026年1月2日", "(\d+)年(\d+)月(\d+)日")
    let ng = regex_captures("2026年1月2日", "(\\d+)年(\\d+)月(\\d+)日")
    湊: ${len(ok)}|${len(ng)}
}
\04|0\e

\\d と書くと、正規表現としては「バックスラッシュ + d」になり、一致しません。\d と書いてください。

里々の記号

* @ $ > _ と全角の () は、湊ではただの文字です。展開されず、そのまま表示されます。

OnBoot => {
    湊: (今日の運勢)は大吉
}
\0(今日の運勢)は大吉\e

里々の辞書から文章をコピーして使うときは、(…) の展開を ${…} に書き換え忘れると、括弧ごと表示されます。φ(括弧をエスケープする記号)も、湊では意味を持たず、文字としてそのまま出力されますので、削除してください。

#(里々のコメント)は、湊では // に、複数行のコメントは /* … */ にします。

湊の書式の細かい規則

  • 行頭の空白は自由です(インデントは何個でも構いません)。
  • 全角の演算子(+ == など)は使えません。半角で書きます。
  • 文字列は "…"(${} の展開あり)と '…'(展開なし)の2種類です。
  • 予約語(if let global など)は、識別子の一部(iffy など)には影響しません。

次は11. 里々/YAYAにあるが湊にない機能に進んでください。

11. 里々/YAYAにあるが湊にない機能

「使っている機能が湊にあるか」を確認するための一覧です。「なし」と書いたものは、代替手段があれば併記しています。

凡例: ✕ なし / △ 一部あり・自作が必要 / ○ 別の形である

里々にあって湊にない機能

辞書の書き方に関わるもの

里々の機能湊代替・備考
括弧展開 ()、入れ子の展開○${…} と call(5.、6.)
$ による代入・変数の自動保存△global save.…(5.)
単語群 @△配列、同名トークと return(6.)
ジャンプ >△call と return(4.)
選択肢 _△\q[ラベル,ID] と OnChoiceSelect(7.)
: による話者の自動交代✕話者を毎回書く
辞書ファイル名(dic*.txt)による自動読み込み✕main.mnt から include で明示する
採用条件を単語群・ジャンプにも付ける✕トーク定義の if(…) だけ
全角記号の演算子・関数名(+、== など)✕半角のみ
変数名の全角・半角の同一視✕A と A は別の名前

自動でやってくれていた処理

里々の機能湊代替・備考
自動挿入ウェイト、replace.txt / replace_after.txt✕\w を手書き、または replace() の関数(7.)
スコープ切り替え時の挿入($スコープ切り替え時)✕\n[half] などを自分で書く
$トーク開始時、$スクリプトの一番頭✕各トークの先頭に書く
$会話時サーフェス戻し、$デフォルトサーフェス○、$サーフェス加算値○✕トークごとに \s[…] を書く
重複回避の方式の切り替え($文「○」の重複回避 など)✕「一巡」に固定(4.)
$呼び出し回数制限、$ジャンプ回数制限✕固定値(下の制限一覧)
辞書の # コメント○// と /* … */

イベント・状態に関わるもの

里々の機能湊代替・備考
独自イベント(なでられ、つつかれなど)△自作(3.)
*OnSatoriLoad など、里々固有のライフサイクル✕main.mnt 直下の global 文がロード時に実行される
情報取得変数((現在時)、(起動回数)など)△now、reference、get_property()。起動回数などは自作(5.)
NOTIFY の自動保存($NOTIFYの自動保存、(導入済み○○))✕該当するイベントを自分で受けて、global に保存する
トーク予約($次のトーク)✕\![raise,…]、\![timerraise,…] などさくらスクリプトで
コミュニケート(→、$Value0)✕専用構文はない。受け取る側は OnCommunicate を普通のイベントとして書ける
アンカー辞書(自動アンカー)✕専用構文はない。\_a[ID] はさくらスクリプトなので使える
辞書リロード、$辞書フォルダ、マルチキャラ辞書切り替え✕include による静的な分割のみ
セーブ関連($手動セーブ、$自動セーブ間隔、$セーブデータ暗号化)✕保存は終了時と再読み込み時だけ(9.)

関数

里々の関数のうち、湊の組み込み関数で置き換えられるものです。

里々湊
(for,…) (while,…) (times,…)for、foreach、while
(set,変数,値)変数 = 値、global save.変数 = 値
(calc,式)式をそのまま書く
(sprintf,書式,…)format("書式", …)
(length,文字列)len(文字列)
(replace,文字列,前,後)replace(文字列, 前, 後)
(when,条件,真,偽)choose(条件, 真, 偽)
(iflist,…) (whenlist,…)if … else if、match
ssu.dll の substr split count replacesubstr split count replace(split は配列を返す)
ssu.dll の erase(消去)replace(文字列, 対象, "")
ssu.dll の compare==
ssu.dll の if unless switch nswitchif、match、choose

YAYAにあって湊にない機能

YAYAの機能の対応は付録Aにまとめています。主なものだけを挙げます。

YAYAの機能湊代替・備考
EVAL(文字列をコードとして実行)✕なし
名前空間(A.B のような関数名)✕トーク名や関数名に . は使えない
関数のオプション(: nonoverlap : sequential など)△nonoverlap に当たる動きは、同名トークで常に働く。順番に出すなら自作(6.)
型の判定(GETTYPE、ISINTEGER)○is_num()・is_str()・is_null()・type_of() など
ファイル操作(FOPEN FREAD FWRITE FDEL FENUM など)△file_read file_write file_append file_move のみ。1行ずつ読む処理、削除、フォルダの列挙はない
システム辞書(yaya_shiori3.dic)が提供する機能✕OnBoot などは自分で書く

湊の設定項目と、読まれない項目

config.toml の設定です。

項目内容
[characters]キャラ名とさくらスクリプトのタグ(必須)
[settings] talk_interval_secs talk_jitter_secsランダムトークの間隔。初回起動後は save.json が優先される(9.)
[settings] debug_logtrue でログを出す。同上
[settings] auto_newline話者に戻るときの自動 \n(既定 true)
[settings] shuffle_reset読み込まれるが、使われない。 設定しても動作は変わらない
[settings] encoding読み込まれるが、使われない。 通信は常に Shift_JIS

制限の一覧

湊には、暴走を防ぐための固定の上限があります。里々のように設定では変えられません。

対象上限超えたとき
for / while のループ回数2000回打ち切って警告
foreach の要素数2000個以降を無視して警告
再帰・入れ子の call の深さ約100(101段目まで)それ以上は呼ばれない
saori() と get_property() の合計1イベントあたり10回呼ばずに空の値を返す。警告が出るのは saori() だけ(get_property() はログのみ)
SAORIの応答待ち5秒打ち切り、そのSAORIは以後呼ばれない
台本の { } ( ) [ ] の入れ子の深さ200読み込みエラー
file_read で読めるファイルの大きさ1MB読めず(null)、警告
format の幅・精度10001000に丸められる

次は12. 湊だけで簡単になる書き方に進んでください。

12. 湊だけで簡単になる書き方

ここまでは、壊れるものの話でした。この節では、逆に、里々では苦労した処理が湊で素直に書けるものを挙げます。移行の価値を判断する材料にしてください。

構造化されたデータ

里々では、$好感度_湊、$勝ち数、$負け数 のように、変数名にキーを埋め込むしかありませんでした。湊では、配列とマップで入れ子にして持てます。途中のマップは自動で作られます。

OnBoot => {
    global save.stats.win += 1
    global save.stats.lose += 2
    global save.fav.湊 = 10
    湊: ${save.stats.win}勝${save.stats.lose}敗
}
\01勝2敗\e
{"stats":{"win":1,"lose":2},"fav":{"湊":10}}

マップの繰り返し

foreach マップ as キー, 値 で、キーと値を順に取り出せます。

OnBoot => {
    let scores = {"りんご": 3, "みかん": 5}
    foreach scores as name, n {
        湊: ${name}は${n}点\n
    }
}
\0りんごは3点\nみかんは5点\n\e

存在しないキーの安全な参照

get(マップ, キー, 既定値) で、キーがなくても警告を出さずに既定値が返ります。?? は null のときだけ代替値を使います。

OnBoot => {
    let m = {"a": 1}
    湊: ${get(m, "a", 0)}|${get(m, "b", 0)}|${m["b"] ?? "なし"}
}
\01|0|なし\e

正規表現と日付の計算

里々では、SAORI(ssu)や自作の関数を組み合わせていた処理が、そのまま書けます。

OnBoot => {
    let c = regex_captures("2026年1月2日", "(\d+)年(\d+)月(\d+)日")
    湊: ${c[1]}-${c[2]}-${c[3]}
    湊: ${days_between(2026, 1, 1, 2026, 1, 31)}日
}
\02026-1-2\n30日\e

regex_match、regex_find、regex_replace、regex_split も使えます。days_since(年, 月, 日) は、指定した日から今日までの日数です。

関数と再帰

引数と戻り値のある関数が書けます。

func 階乗(n) {
    if (n <= 1) {
        return 1
    }
    return n * 階乗(n - 1)
}

OnBoot => {
    湊: 5の階乗は${階乗(5)}
}
\05の階乗は120\e

かな順のソートと整形

里々では、五十音順に並べるには自分で比較の処理を書く必要がありました。湊では sort(配列, "kana") の1行で済みます。ひらがな・カタカナ・濁点・小書き文字の違いを無視して、五十音順に並べます。漢字は読みに変換されないので、漢字まじりの項目は読みをキーにして並べます。format で桁を揃えられます。

OnBoot => {
    湊: ${join(sort(["りんご", "ぶどう", "みかん"], "kana"), ",")}
    湊: ${format("%02d:%02d", 7, 5)}
}
\0ぶどう,みかん,りんご\n07:05\e

ファイルの読み書き

ghost/master の中のテキストファイルを、file_read / file_write / file_append で扱えます(file_move で移動もできます)。パスは、ゴーストのホームからの相対パスで、書き込めるのは ghost/master の中だけです。

OnBoot => {
    let ok = file_write("ghost/master/memo.txt", "こんにちは")
    湊: ${ok}|${file_read("ghost/master/memo.txt")}
    湊: ${file_write("memo.txt", "x")}
}
\0true|こんにちは\nfalse\e
  • 文字コードの既定は UTF-8 です。Shift_JIS のファイルは、file_read(パス, "sjis") で読めます。
  • talks の中、config.toml、descript.txt、save.json で始まるファイル(save.json.bak なども含む)、.dll、minato_ で始まるファイルには書き込めません。詳しくはビルトイン関数の「ファイル操作」を参照してください。
  • 1MBを超えるファイルは読めません。
  • 書き込みは、途中で落ちても既存のファイルが壊れないように、一時ファイルを経由します(file_append を除く)。

事前の構文チェック

里々では、辞書のミスはゴーストを起動して初めて分かることが多くありました。湊には、SSPなしで動く構文チェッカー minato_check があります。

minato_check.exe "ghost/masterのフォルダ"

talks/main.mnt を含むフォルダ(ゴーストの ghost/master)を指定します。 構文エラーは、該当する行の抜粋つきで表示されます。結果はすべて標準エラー出力に出ます(問題がなかったときのメッセージだけ標準出力)。 色付きで表示されます。色を付けたくないときは --no-color を付けてください。さらに、次の静的チェックを行います。

チェックレベル
ループの外の break / continueエラー(ゴーストは起動しない)
存在しないトーク・関数を call している通知(notice)

終了コードは、エラーがなければ 0、あれば 1 です。ビルド・配布の手順に組み込めます。

ログを残す

log("メッセージ") は、config.toml(と save.json)で debug_log が有効なときだけ、minato_debug.log に書き込みます。台本の途中経過を確認できます。

任意のトークが存在するかの確認

talk_exists("名前") で、トークまたは関数が定義されているかを確認できます。ゴーストの一部の機能を、ファイルの有無で切り替える設計にできます。

短絡評価

&&、||、?? は、左辺で結果が決まると右辺を評価しません。choose(条件, 値, 値) も、選ばれなかった側の式は評価しません。副作用のある式(saori() など)を安全に書けます。

次は13. 段階移行 vs 全置換に進んでください。

13. 段階移行 vs 全置換

「少しずつ湊に移す」ことはできるのか、それとも一度に全部置き換えるのか。この節では、機能ごとの状況と、移行の進め方を整理します。

先に結論

基本は「全置換」です。 1つのゴーストで動かせるSHIORIは1つだけなので、里々と湊を同時に動かすことはできません。「里々のまま、湊の機能だけ足す」ことはできません。

段階的に進めたい場合は、辞書を移植しながら、まだ移していない部分だけ里々をSAORIとして呼ぶ、という方法が考えられます(下記)。ただし、この方法は、このガイドでは実機のSSPでは検証していません。

機能ごとの星取表

凡例は次のとおりです。

  • ◎ 里々とほぼ同じように書ける
  • ○ 書き換えれば同等のことができる
  • △ 制限がある、または自作が必要
  • ✕ なし
機能判定移行のメモ
トーク定義・同名トークからのランダム選択○書式が違う。一巡保証の固定(4.)
採用条件○トーク定義だけ(4.)
単語群△専用構文なし。配列か同名トークで(6.)
ジャンプ(>)○call + return(4.)
変数(保存)△自動保存されない。型がある(5.)
セーブデータの引き継ぎ△自動変換はない。台本での取り込みか変換スクリプト(9.)
選択肢○\q[…] を手書き。ラベル=トーク名の形も可能(7.)
ランダムトーク○OnRandomTalk。間隔は save.json に注意(3.)
SSPの基本イベント(OnBoot など)◎ほぼそのまま(3.)
里々独自のイベント(なでられなど)△自作(3.)
自動ウェイト・replace.txt✕手書き、または関数(7.)
スコープ切り替えの自動処理✕話者を毎回書く(7.)
サーフェスの自動処理✕トークごとに書く(7.)
SAORI呼び出し△Result が取れない場合がある。1イベント10回まで(8.)
文字列操作◎組み込み関数で足りる。ssu.dll は不要になることが多い
ループ・関数◎里々よりずっと素直に書ける
配列・マップ◎里々にはない(12.)
正規表現・ファイル操作◎里々にはない(12.)
情報取得変数△now reference get_property()。ない項目は自作(5.)
NOTIFY の自動保存✕必要なイベントを自分で受ける
アンカー・コミュニケート✕専用構文なし(11.)
トーク予約($次のトーク)✕さくらスクリプトで代用
辞書リロード・マルチ辞書✕include だけ
構文チェック◎minato_check がある(12.)

全置換の進め方

  1. 土台を作る(2.)。config.toml と main.mnt、OnBoot で「こんにちは」を出す。
  2. セーブデータの移行を先に決める(9.)。既存のユーザーがいるなら、この設計が一番重い。変数を save の下に置くかどうか、型をどうするか、を決めてから書き始める。
  3. 起動・終了・ランダムトークを移す(3.、4.)。ゴーストが「喋る」状態にする。
  4. 反応(マウス操作、選択肢)を移す(3.、7.)。
  5. SAORI、特殊機能を移す(8.、11.)。
  6. minato_check でチェックし、実機で通しで動かして、14. FAQにあるような「動くが結果が違う」箇所を確認する。

辞書の移植は、1トークずつ手で書き直す作業になります。機械的な変換ツールはありません。* の中身を 湊: に、() を ${} や call に直す作業は、パターンが決まっているので、ファイルごとに順に進められます。

段階的に進める方法(実験的)

里々は、SAORIとして呼び出すこともできます(里々Wikiの「SAORI/里々」)。Argument0 に *トーク名 や @単語群名 を渡すと、里々のルールで整形されたさくらスクリプトが返ります。

湊から里々のSAORI版を呼べば、「移植済みの部分は湊、未移植の部分は里々」という状態を作れるはずです。

OnRandomTalk => {
    // 湊で書き直したトークがなければ、里々のトークを借りる想定
    let r = saori("saori/satori.dll", "旧トーク名")
    return r[0]
}

ただし、次の点に注意が必要です。このガイドでは、この方法を実機では確認していません。

  • 里々のSAORI版は、独自の satori_savedata.txt を持ちます。湊の save とは別の変数になり、片方の変更がもう片方に反映されません。
  • 里々のSAORI版は、SSPのイベントを受け取りません。呼び出すタイミングは、湊が決める必要があります(里々Wikiの注意点)。
  • 里々の選択肢記法(_)や \q[…] を、SAORIとして呼ぶトークの中で使うには注意が必要です。里々が選択肢のタグに内部用の情報(バイト値)を挿入するため、受け取る側がそれを想定していないと、選択肢の飛び先が正しく動きません(里々Wikiの注意点)。選択肢は、湊側で \q[…] を書くのが安全です。
  • 湊は、SAORIの Result を、Value0 があると取り出せません(8.)。里々のSAORI版が返す内容によっては、期待どおりに受け取れない可能性があります。
  • 1イベントあたりのSAORI呼び出しは10回まで、応答は5秒までです。

変数の共有が難しいため、この方法が向くのは、変数をあまり使わない読み物のようなトークに限られます。

どちらを選ぶか

ゴーストの状況おすすめ
小規模(トークのファイルが数個、変数が少ない)全置換。数日で終わる
セーブデータ(変数)が多い・ユーザーが多い全置換。移行の設計(9.)に時間をかける
SAORI依存が大きい(Result を使う)全置換の前に、8.の制限を確認する
里々独自の機能(アンカー、コミュニケート、トーク予約)に依存している移行しない、または機能を諦めて全置換
大規模で、少しずつ移したい上の実験的な方法を、小さく試してから判断する

次は14. FAQ: 見た目は同じで結果が違うに進んでください。

14. FAQ: 見た目は同じコードだけど結果が違う

里々のつもりで書いたコードが、エラーも出ずに、違う結果になる例を集めました。上から、影響が大きく気づきにくい順に並べています。

#症状原因
1セリフが丸ごと消える英数.英数 が変数参照になる
2代入や関数呼び出しが、セリフとして出るセリフ直後の行は続き行になる
3ループの中で改行が入らない改行はセリフ行が連続したときだけ
4足し算が文字列の連結になる+ は文字列があると連結
5"0" の条件が真になる文字列の "0" は真
6未設定の変数が == 0 で偽になるnull は 0 と等しくない
7全角の数字が計算できない全角数字は数値にならない
8文字列の大小比較が効かない< > は数値比較
9if の中で作った変数が外で消えるlet はブロックの中だけ
10他のトークの変数が見える裸の代入は起動中グローバル
11再起動すると値が消えるsave の下だけが保存される
12OnAITalk などが呼ばれない湊が内部で消費する
13config.toml の間隔が効かないsave.json の値が優先される
14話者を間違えても喋る未登録の名前は \0 になる
15変数名を間違えてもエラーにならない未定義は空文字列
162つのトークが交互に出る一巡の規則
17曜日の数字が違う湊は0が月曜
18foreach で要素でなく番号が出る変数が1つだと添字になる
19${ でパースエラー${ は } で閉じる
20トークが選ばれたのに何も喋らない出力が空だと 204

1. セリフが丸ごと消える

里々では出ていたセリフが、湊では消えます。

OnBoot => {
    湊: 体重は57.8kgです。
    湊: バージョンは1.0です。
    湊: 体重は 57.8kgです。
    湊: ver 1.0です。
    湊: 「foo.bar」って何?
    湊: ${1}.5です。
}
\0\n\n体重は 57.8kgです。\nver 1.0です。\n\n1.5です。\e

1、2、5行目が空になっています(3行目と4行目、6行目は出ています)。

原因: セリフの行頭(と、${…} の直後)から、半角の空白や記号が現れるまでの文字の並びの中に . があると、変数.キー という変数の参照として読まれます。存在しない変数なので、空文字列になります。エラーにはなりませんが、応答には警告が付きます(SSPのエラーログに出ます)。日本語の文字と句読点は、すべて「区切られていない文字」として数えられるので、体重は57.8kgです。 のように文全体が1つの参照になります。

対策:

  • . を含む語(小数、バージョン、URL、ファイル名)を、行頭や ${…} の直後の「最初の語」に入れない。
  • 直前に半角の空白を入れると、区切られて避けられます(3行目、4行目のように 体重は 57.8kg と書く)。
  • 数字だけを式として書く方法もあります。体重は${57.8}kgです。 と書くと、57.8 は数値の式として展開されます。

2. セリフの次の行が喋られる

代入や関数呼び出しが、セリフになります。

OnBoot => {
    湊: 一行目
    counter = 5
    湊: 二行目
}
\0一行目\ncounter = 5\n二行目\e

原因: セリフの直後の行は、let、global、call、if などのキーワードで始まっていない限り、続き行として読まれます。

対策: let や global を付ける、; だけの行を挟む、call を使う(10. 改行のルール)。

3. ループの中で改行されない

OnBoot => {
    let a = ["x", "y"]
    foreach a as i, v {
        湊: ${v}
    }
}
\0xy\e

原因: 改行が入るのは、セリフ行が連続しているときだけです。ループの1回ごとに文が区切られます。

対策: 末尾に \n を書く。

4. 数のつもりが連結される

OnMouseClick => {
    湊: ${reference["0"] + 1}
}
\031\e

原因: reference、SAORIの戻り値、移行した変数は文字列です。文字列に対する + は連結です。

対策: to_num() で数値にする(5.)。

5. "0" が真になる

OnBoot => {
    global save.flag = "0"
    if (save.flag) {
        湊: 真
    } else {
        湊: 偽
    }
}
\0真\e

原因: 里々では 0 は偽でしたが、湊で偽になるのは、false、数値の 0、空文字列、null、空の配列・マップです。

6. 未設定の変数 == 0 が偽になる

OnBoot => {
    湊: ${save.n == 0}|${(save.n ?? 0) == 0}
}
\0false|true\e

原因: 未設定の変数は null で、空文字列として表示されます。数値の 0(表示は "0")とは一致しません。

対策: ?= で初期化するか、(save.n ?? 0) == 0 と書く。

7. 全角の数字が 0 になる

OnBoot => {
    let z = "3"
    湊: ${z >= 3}|${to_num(z)}
}
\0false|0\e

対策: 全角の数字は、半角にしてから数値に直す(9. セーブデータ)。

8. 文字列の大小比較ができない

OnBoot => {
    湊: ${"a" < "b"}|${"b" < "a"}
}
\0false|false\e

原因: < > <= >= は、両辺を数値に変換します。数値にならない文字列は 0 なので、どちらも 0 < 0 で偽になります。辞書順で並べたいときは、sort(配列, "kana") などを使います。

9. let の変数が外で消える

OnBoot => {
    if (true) {
        let b = 3
    }
    湊: [${b}]
}
\0[]\e

原因: let の変数は、宣言した { } の中だけで有効です(5.)。

10. 代入した覚えのない変数が他のトークで見える

x = 5 は、let がなければ、起動中グローバルを作ります(5.)。一時変数は必ず let で宣言してください。

11. let で作った変数がセーブされない

保存されるのは、global save.… だけです。let も、global work.… も、終了すると消えます(5.)。

12. OnAITalk や OnMinuteChange が呼ばれない

湊がこの2つのイベントを内部で使うため、自分で OnAITalk => と書いても呼ばれません(OnAITalk は OnRandomTalk として実行され、OnMinuteChange はランダムトークの発火に使われます。3.)。

13. config の間隔を変えても効かない

初回の起動のあと、talk_interval_secs などは save.json の値が優先されます(9.)。

14. 話者の名前を間違えても喋る

config.toml に登録していない名前は、エラーにならず \0 として扱われます(2.)。

15. 未定義の変数がエラーにならない

${x} の x を書き間違えても、警告なしに空文字列になります。里々では括弧ごとそのまま表示されたので気づけました(5.)。

16. 2つのトークが交互に出る

同名のトークがちょうど2つのとき、最初だけランダムで、あとは交互になります(4.)。

17. 曜日の数字が違う

曜日の値
里々(現在曜日) は「日」「月」…の文字
湊now.曜日 は数値。0が月曜、6が日曜
YAYAGETTIME[3] は数値。0が日曜、6が土曜

18. foreach で要素が取れない

OnBoot => {
    let a = ["x", "y"]
    foreach a as v {
        湊: ${v}\n
    }
}
\00\n1\n\e

foreach 配列 as 名前 の名前は、添字です。要素も欲しいときは foreach 配列 as i, v と2つ書きます。

19. 記号の入ったセリフでパースエラー

OnBoot => {
    湊: 100$です。価格は${です
}
\b[2]\0パースエラー:\nmain.mntの2行目: 認識できない文です: 「${です」\e

$ だけなら、そのまま文字として出ます(100$です)。ただし ${ は展開の始まりなので、} で閉じないとパースエラーになります。${ という文字を出したいときは、${'${'} と書きます。

20. トークが選ばれたのに無言になる

OnRandomTalk などで、if の分岐の結果として出力がすべて空になると、湊は何も返しません(204)。SSPからは、イベントが何も返されなかったように見えます。作者向けには「出力が空でした」という通知が残ります。

OnRandomTalk => {
    let z = 1
}
(204)

その他、確認しておきたいこと

  • 応答の末尾に \![get,property,OnGotVirtualTime,…] が付くのは、OnBoot と OnMinuteChange だけです(3.)。
  • 湊は、func と同じ名前のトークがあるとき、func を優先します。
  • 半角と全角の違い(A と A、1 と 1)は、湊では別のものとして扱われます。

次は15. 三方式の比較に進んでください。

15. 三方式の比較

同じゴーストを、里々・YAYA・湊で書き比べます。全体の雰囲気と、どこが違うのかをつかむための節です。

  • 湊のコードは、実際に動かして出力を確認しています(検証済み)。
  • 里々・YAYAのコードは参考コードです。里々Wiki・YAYA Wikiの記載に基づいて書いていますが、里々やYAYAの実機では動かしていません。
  • 里々のコードの → は、タブ文字を表します(里々ではタブが区切りです)。
  • YAYAのコードは、SSPのYAYA用テンプレート(\0\s[0]…\e を自分で書く形)を前提にしています。ランダムトークを呼び出す仕組みはテンプレートによって違うため、関数名(RandomTalk)は仮のものです。

パターン1: 起動あいさつ(訪問回数)とランダムトーク

初回は「はじめまして」、2回目以降は「◯回目」と言う。ランダムトークは2種類。

里々

satori_conf.txt(変数の初期値):

*初期化
$訪問回数→0

辞書:

*OnBoot
$訪問回数=(訪問回数)+1
>初回→(訪問回数)==1
>再訪

*初回
:はじめまして。

*再訪
:また来てくれたんですね。(訪問回数)回目です。

*
:今日もいい天気ですね。

*
:何か用ですか?

YAYA

OnBoot
{
	if ISINTEGER(visits) == 0 {
		visits = 0
	}
	visits++
	if visits == 1 {
		"\0\s[0]はじめまして。\e"
	}
	else {
		"\0\s[0]また来てくれたんですね。%(visits)回目です。\e"
	}
}

RandomTalk
{
	"\0\s[0]今日もいい天気ですね。\e"
	"\0\s[0]何か用ですか?\e"
}

湊

config.toml:

[characters]
"湊" = "\\0"

talks/main.mnt:

OnBoot => {
    global save.訪問回数 += 1
    if (save.訪問回数 == 1) {
        湊: はじめまして。
    } else {
        湊: また来てくれたんですね。${save.訪問回数}回目です。
    }
}

OnRandomTalk => {
    湊: 今日もいい天気ですね。
}

OnRandomTalk => {
    湊: 何か用ですか?
}
\0はじめまして。\e
\0また来てくれたんですね。2回目です。\e

違うところ

里々YAYA湊
初期値satori_conf.txt に書くISINTEGER で確認して代入書かなくてよい(null に += すると1になる)。初期値が要るなら ?=
保存自動自動(グローバル変数)global save. を付けたものだけ
分岐>(ジャンプ)if / elseif / else
ランダムトーク名前のない *関数 RandomTalk(呼び出しはテンプレート次第)OnRandomTalk の同名多重定義
話者・サーフェス: が話者、サーフェスは自動\0\s[0] を毎回書く湊: を毎回書く。[0]湊: でサーフェス

パターン2: 好感度と選択肢

「今日の気分は?」と聞き、選択肢で好感度が増減する。

里々

satori_conf.txt:

*初期化
$好感度→0

辞書:

*OnBoot
:こんにちは。今日の気分は?
_いい→気分良い
_わるい→気分悪い

*気分良い
$好感度=(好感度)+1
:よかった。好感度は(好感度)です。

*気分悪い
$好感度=(好感度)-1
:そっか……。好感度は(好感度)です。

YAYA

OnBoot
{
	"\0\s[0]こんにちは。今日の気分は?\n\q[いい,OnMoodGood]\n\q[わるい,OnMoodBad]\e"
}

OnMoodGood
{
	if ISINTEGER(favor) == 0 {
		favor = 0
	}
	favor++
	"\0\s[0]よかった。好感度は%(favor)です。\e"
}

OnMoodBad
{
	if ISINTEGER(favor) == 0 {
		favor = 0
	}
	favor--
	"\0\s[0]そっか……。好感度は%(favor)です。\e"
}

湊

global save.好感度 ?= 0

OnBoot => {
    湊: こんにちは。今日の気分は?
    \q[いい,OnMoodGood]
    \q[わるい,OnMoodBad]
}

OnMoodGood => {
    global save.好感度 += 1
    湊: よかった。好感度は${save.好感度}です。
}

OnMoodBad => {
    global save.好感度 -= 1
    湊: そっか……。好感度は${save.好感度}です。
}
\0こんにちは。今日の気分は?\n\q[いい,OnMoodGood]\n\q[わるい,OnMoodBad]\e
\0よかった。好感度は1です。\e
\0そっか……。好感度は0です。\e

違うところ

里々YAYA湊
選択肢_ラベル→ID の行。飛び先は同名のトーク\q[…] を書く。飛び先は On… のイベント\q[…] を書く。飛び先は On… のイベント
選択肢の改行自動\n を自分で書く行を分けて書けば、自動で \n がつながる
増減$好感度=(好感度)-1favor--global save.好感度 -= 1
表示(好感度)%(favor)${save.好感度}

パターン3: 単語群と時間帯

「◯◯ですね。△△は好きですか?」と言う。◯◯は時間帯、△△は単語群から選ぶ。

里々

@果物
りんご
みかん
ぶどう

@時間帯
(iflist,(現在時)<,6,深夜,12,朝,18,昼,夜)

*
:(時間帯)ですね。(果物)は好きですか?

YAYA

果物
{
	"りんご"
	"みかん"
	"ぶどう"
}

時間帯
{
	_t = GETTIME()
	if _t[4] < 6 {
		"深夜"
	}
	elseif _t[4] < 12 {
		"朝"
	}
	elseif _t[4] < 18 {
		"昼"
	}
	else {
		"夜"
	}
}

RandomTalk
{
	"\0\s[0]%(時間帯)ですね。%(果物)は好きですか?\e"
}

湊

果物 => {
    return "りんご"
}

果物 => {
    return "みかん"
}

果物 => {
    return "ぶどう"
}

func 時間帯(h) {
    if (h < 6) {
        return "深夜"
    } else if (h < 12) {
        return "朝"
    } else if (h < 18) {
        return "昼"
    }
    return "夜"
}

OnBoot => {
    湊: ${時間帯(now.時)}ですね。${果物()}は好きですか?
}
\0深夜ですね。*は好きですか?\e
\0朝ですね。*は好きですか?\e
\0昼ですね。*は好きですか?\e
\0夜ですね。*は好きですか?\e

* は、りんご・みかん・ぶどうのどれか1つを表します。

違うところ

里々YAYA湊
単語群@果物(1行1単語)関数 果物 { "…" "…" }(複数の文字列から1つ)同名のトークを複数書き、return "…"(または配列)
呼び出し(果物)%(果物)${果物()}
時間による分岐iflistGETTIME() の配列と ifnow.時 と if(関数に切り出し)
重複回避設定で選ぶ: nonoverlap を付ける同名トークなら自動で一巡(配列なら自前)
話者の指定行頭の :文字列の中に \0 を書く行頭の 湊:

全体を通して見えること

  • 里々は「文章を書く」言語、YAYAは「関数を書く」言語、湊はその中間です。セリフを書く感覚は里々に近く、変数・条件・関数の書き方はYAYAに近い作りです。
  • 保存のルールが一番違います。 里々もYAYAも、変数は基本的に自動で保存されますが、湊は save の下だけです。
  • 里々に比べて、湊は暗黙にやってくれることが少ないです。ウェイト、話者の交代、サーフェスの復帰、重複回避の設定など、里々が自動で行っていたことの多くを、自分で書きます。その代わり、何が起きるかは書いてあるコードから読み取りやすくなっています(ただし、この節の各所で述べたように、湊にも暗黙の処理は残っています)。

付録として、YAYAから来た方へがあります。

付録A. YAYAから来た方へ

YAYAでゴーストを作ってきた人が、湊に移るときの差分をまとめます。本文は里々の利用者を主な読者としているので、YAYAの人は、この付録と、各節の末尾にある「YAYAから来た方へ」を合わせて読んでください。

  • YAYA側の記述は、YAYA Wikiに基づいています。YAYAの実機では確認していません。
  • 湊側の挙動は、実装を確認したものです。

考え方の違い

YAYA湊
基本単位関数(イベントも単語群も、すべて関数)トーク(名前 => { })と関数(func)
文字列を出す関数の中に文字列を置くと、その文字列が出力(複数あれば1つを選ぶ)セリフの行(湊: …)を書く。文字列だけ返すなら return "…"
同名の定義書けない(エラー)書ける(ランダムに1つ選ばれる。里々と同じ)
話者・サーフェス文字列の中に \0\s[0] を書く湊:、[0]湊: と書く(キャラ名は config.toml に登録)

構文の対応

YAYA湊
名前 { … }(イベント・トーク)名前 => { … }
名前 { … }(引数を取る関数)func 名前(引数) { … }
複数の文字列から1つを選ぶ同名トークを複数書く、または配列と rand()
-- で区切って、各区間から1つずつ選んで連結${…} を並べて書く(6.)
: nonoverlap不要(同名トークは常に一巡)
: sequentialなし。順番に出すなら自作(6.)
%(x)、%x${x}
_x = 1(ローカル変数)let x = 1
x = 1(グローバル変数。自動保存)global save.x = 1(保存されるのは save の下だけ)
if … {} elseif … {} else {}if (…) {} else if (…) {} else {}(条件は ( ) で囲む)
while、for、foreachwhile、for、foreach(5.)
switch と casematch
reference[0]reference["0"](キーは文字列。値も文字列)
_argv[0](引数)関数の引数名(func f(a, b))
valueex0(SAORIの戻り値)saori() の戻り配列の [0](8.)
FUNCTIONEX('saori\x.dll', …)saori("saori/x.dll", …)(8.)
TOINT(x)、TOSTR(x)to_num(x)、to_str(x)
STRLEN、SUBSTR、REPLACE、SPLITlen、substr、replace、split
STRSTR(s, 検索語, 位置)index_of(s, 検索語)(見つからなければ -1)
ARRAYSIZE(配列)len(配列)
RAND(n)rand() % n
GETTIME() の配列now.年 now.月 now.日 now.時 now.分 now.秒 now.曜日
CALLBYNAME("名前")call 名前を入れた変数
ISFUNC("名前")talk_exists("名前")
RE_SEARCH など正規表現関数regex_match、regex_find、regex_captures、regex_replace、regex_split
LOGGING などlog("…")(debug_log が有効なときだけ書き出す)
ERASEVAR("x")global save = delete(save, "x")

対応する関数でも、引数の順序や境界の扱いが細かく違うことがあります。置き換えたあと、実際に動かして確認してください。

YAYAにあって湊にない機能

  • EVAL(文字列をコードとして実行)
  • 名前空間(A.B のような関数名)
  • 関数のオプション(: sequential など。: nonoverlap に当たる動きは、同名トークで常に働く)
  • 型の判定関数(GETTYPE など)
  • 1行ずつ読むファイル操作、ファイルの削除、フォルダの列挙(FOPEN、FREAD、FDEL、FENUM など)
  • SAVEVAR、RESTOREVAR(湊の保存は自動で、save の下だけ)
  • システム辞書(yaya_shiori3.dic)が提供している定型の関数

詳しくは11. 里々/YAYAにあるが湊にない機能を参照してください。

YAYAの暗黙の処理と、湊の違い

YAYA湊
変数の初期値空文字列(YAYA Wikiに「0または空文字ではない」と明記)null(表示は空文字列)。+= は数値として働く
数値の型整数・実数・文字列の3種類数値(整数と実数の区別なし)・文字列・真偽値・配列・マップ・null
文字列と数値の足し算加算なら文字列の連結(YAYA Wiki)同じく連結
全角の数字数値になれず、文字列同じく文字列(0として計算される)
グローバル変数自動で保存save の下だけ保存。ほかは終了で消える
ローカル変数のスコープ現在とそれより深い { }現在とそれより深い { }(let)。外側の変数に = すると更新される
曜日の数字GETTIME の [3]。0が日曜日now.曜日。0が月曜日
複数の文字列を並べた関数1つがランダムに選ばれる同名トークが複数あるとき、1つがランダムに選ばれる(一巡固定)

セーブデータ

YAYAのグローバル変数は yaya_variable.cfg に保存されています。このファイルを湊の save.json に変換するツールは、このガイドにはありません。

移行するときは、

  1. YAYA側で、移行したい変数を ghost/master にテキストファイル(名前<タブ>値 の行など)として書き出す処理を用意する。
  2. 湊の台本で、file_read して save に取り込む(9. A. 湊の台本で読み込むの台本と同じ形)。

という手順が確実です。

文字コード

YAYAは、UTF-8で書いた辞書も読めます(YAYA Wikiの文字コードの項を参照)。UTF-8で書いてあれば、文章そのものはコピーして使えます。構文は書き換えが必要です。湊の台本は、UTF-8のBOMなしです(10.)。

SAORI

YAYAは、FUNCTIONEX の戻り値が Result、valueex0… が Value0… と分かれています。湊は両方を1つの配列にまとめ、Value0 があると Result を取り出せません(8.)。YAYAで Result を使っている呼び出しは、そのままでは移せません。

コードの書き換え例

YAYAの、訪問回数と選択肢のコード:

OnBoot
{
	if ISINTEGER(visits) == 0 {
		visits = 0
	}
	visits++
	"\0\s[0]%(visits)回目です。\n\q[はい,OnYes]\n\q[いいえ,OnNo]\e"
}

OnYes
{
	"\0\s[0]はいが選ばれました。\e"
}

湊に書き換えたもの:

OnBoot => {
    global save.visits += 1
    湊: ${save.visits}回目です。
    \q[はい,OnYes]
    \q[いいえ,OnNo]
}

OnYes => {
    湊: はいが選ばれました。
}
\01回目です。\n\q[はい,OnYes]\n\q[いいえ,OnNo]\e
\02回目です。\n\q[はい,OnYes]\n\q[いいえ,OnNo]\e
\0はいが選ばれました。\e
  • 変数の初期化(ISINTEGER の確認)は、null に += できるので不要です。
  • visits++ は global save.visits += 1 です。save を付けないと、保存されません。
  • \0\s[0]…\e は書きません(湊: と [0]湊: が代わりをします。\e は湊が付けます)。
  • セリフの途中の \n は、行を分けて書けば自動でつながります。

次は、本文に戻って1. 非互換一覧を確認してください。