Liminalia

シナリオ形式 v1

1. 目的

  • TyranoScript の .ks が抱える問題(暗黙の規則、記号の衝突、文字列だけの値、混線したエラー、 JS と jQuery への逃げ)を、形式の段階で起こらないようにする。
  • AI と一緒に書くことを前提にする。 人が書いても AI が書いても同じ形になり、 機械で検査でき、間違いがすぐ位置付きで分かること。
  • 既存の TyranoScript の作品を .ks から取り込み、そのまま動かせること(lim import)。

2. AI と作るための原則

  1. 書き方は一通り。 別名、省略形、同じ意味の別構文を作らない。 位置引数は命令ごとに決まっていて、名前付きでは書けない。真偽は必ず true / false と書く。
  2. 一行で意味が閉じる。 台詞の行は話者を自分で持つ。直前の行の状態に頼らない。 どの行を抜き出して直しても、話者の割り当てや行構造が崩れない。
  3. 本文は書いたとおりに出る。 本文で特殊なのは { } \ だけ。{ は必ず書き方の始まりで、 当てはまらなければエラーになる(黙って文字として出すことはしない)。
  4. 命令の仕様は一か所。 命令、引数、型、既定値はレジストリ(組み込みは packages/lang/src/registry.ts、 作品固有のものはプラグインの命令の表)で定義し、検査、補完、リファレンス、実行がすべてそこを読む。 TyranoScript の .ks のタグとの対応(変換表)は命令の仕様に書かず、取り込みの側(packages/ks-import と プラグインの ./import)に置く。変換表は引数の型、既定値を仕様から引き、書き出すゲームには入らない。
  5. 間違いは実行前に、位置と直し方付きで出す。 診断は行、桁、識別子、直し方の候補(hint)を持つ。 綴りの近い命令名、引数名、ラベル、キャストは候補として示す。
  6. 動かした結果を機械で見られる。 ブラウザなしで流した結果を JSON で出し、指定行の画面写真も撮れる(§6)。
  7. 乱数と時間は固定できる。 同じ入力なら同じ結果になる。

3. 形式

拡張子 .lim。UTF-8、行単位。一ファイルに一章などのまとまりを書き、中をラベルで区切る。

行頭種類例
話者名 + 「 『 ( (台詞ユイ「おはよう、今日は早いのね」
@命令@chara ユイカ smile at=left fade=300ms
# ラベル# north_road
//コメント// ここから回想
空行区切り(意味なし)
それ以外地の文窓の外では、雨が上がりかけていた。
  • 地の文と台詞は 一行が一クリック(.ks の …[p] に当たる)。
  • 行頭の ASCII の空白とタブは字下げで、意味を持たない。全角空白は取り除かないので、本文の字下げは全角空白で書く。
  • 行頭の記号を本文にしたいときは \ を前に付ける(\# は見出しではない)。
  • 台詞になるのは、プロジェクトに登録した話者名の直後に括弧が来る行だけ。それは「愛」だった は地の文。 話者名は表示用の名前(ユイ ??? 店員 など)で、キャスト(立ち絵の持ち主)とは別に登録する。 台詞の形(行の頭の名前のすぐ後に括弧が始まり、その括弧が行末で閉じる)なのに地の文になった行は、検査が警告する (unknown-speaker。話者名の綴りの誤り、話者に足し忘れ、名前と括弧のあいだの空白。近い話者名を候補に出す)。 台詞の形の地の文を書くときは、行の頭を全角空白で字下げする。
  • ラベルは # 名前。名前は文字、数字、_、-。#名前(空白なし)はエラーで、直し方を示す。

3.1 本文中の書き方

書き方意味
`{r 漢字かんじ}`
{click}行の途中のクリック待ち(.ks の [l])
{br}同じページの中での改行(.ks の [r])
{speed 600ms ……}囲んだ部分だけ文字を出す速さを変える(一文字あたりの時間)
{@chara ユイカ smile}本文がその位置まで出たときに命令を実行する(クリック待ちの直後に表情を変えるなど)
{= game.name}値の埋め込み
{b 太字} {i 斜体} {c #cc3333 色}装飾(入れ子可)
{size 32 大きな文字} {mark #ffff66 印}文字の大きさ(px)、蛍光ペンのような背景色(入れ子可)
{fx wave 波打つ文字}文字の動き(shake 震え、wave 波打つ、pop 弾んで出る、fade じわりと出る、glow 光る。入れ子可)
{dot 大事}傍点(圏点。形は ui.text.emphasis、既定はゴマ。入れ子可)
{img icons/heart.png} {img icons/heart.png 32}本文の中の画像(image フォルダ。高さは px、省くと文字の大きさ。一字として出し、バックログにも出る。書き出しの素材に入る)
\{ \} \\{ } \ そのもの

& % [ ] # : などはすべてただの文字。

縦書きは @vertical on(off で横書きに戻す)か、作品全体なら liminalia.json の ui.message.vertical: true。 行は右から左へ進み、ルビは文字の右に付く。メッセージ枠の位置と大きさを書いていなければ、枠は画面の右に縦長に置く。 NVL(@nvl on)では段落を右から積み、はみ出したら古い段落が右へ流れて隠れる(行末にぶら下げた句読点は切らない)。 吹き出し(@balloon)は横書きのまま。ルビの大きさは ui.text.rubySize(親文字に対する倍率。既定 0.45)、NVL の段落の間は ui.text.paragraphGap(文字の大きさに対する倍率。既定 0.5)で変える。縦書きのルビの位置はルビの大きさから決めるので、 作品の CSS でルビの font-size を直接書き換えず、rubySize で変える。 切り替えると本文は消え、状態に残るのでロードしても同じになる。

ルビの親文字と読みは飾りのないただの文字で、中に {…} は書けない(装飾はルビの外側を囲む: {b {r 漢字|かんじ}})。 \ は次の一文字をそのまま文字にする。親文字に | を含めるときは \|、どちらかに { } を含めるときは \{ \} と書く (読みは } までなので、読みの中の | はそのままでよい)。

見えない文字は警告する(invisible-space)。幅ゼロの文字(U+200B など、BOM)はどの行でも、ノーブレークスペース、全角スペース などの区切りにならない空白は、命令、ラベルの行("…" の中は除く)と選択肢の飛び先から後ろで。どれも区切りとして読まれず、 命令や名前の一部になって見つけにくい誤りになるため。

本文中の命令 {@…} は、行単位の命令と同じ書き方、同じ検査になる。同じページのまま演出を差し込むときだけに使い、 ページの前後で済む演出は @ の行で書く。

本文中の命令は、文字がその位置まで出たときに順に実行する。送り方によって次のように動く。

送り方文字本文中の命令
普通に読む一文字ずつ書いた位置で実行し、待つ命令はその時間だけ待つ
文字を出している途中で押す次の {click}(かページの終わり)までをすぐ出す(ルビ、装飾の切れ目でも止めない)普通に読むときと同じ時間で実行する({@wait 1s} は待ち、その後ろの文字はすぐ出る)
スキップすぐ出すすべて実行するが、時間はすべて 0(@wait、フェード、揺れ、動きはすぐ終わる)

スキップ中は、ボイス(@voice、@voice-auto)を鳴らさず、鳴っているボイスも止める。効果音(@se)は鳴らし、BGM の切り替えは フェードなしで行う。オートでは、ページを出し切って(ボイスがあれば鳴り終わって)から決めた時間で送る。

3.2 命令

  • 形は @名前 位置引数… key=value …。命令名は英小文字、数字、-。
  • 値の書き方と型:
書き方型
300ms 1.5s時間(単位は必須。300 だけはエラー)
3 -0.5数値
true false真偽
#000 #cc3333 #cc333380色
"空白を含む文字列"文字列
room_night.jpg ユイカ文字列(空白を含まないもの)
(式)式。実行時に評価する(@wait (time)、at=(temp.pos))
  • 既定値は書かない(lim fmt が整える。§6)。
  • 位置引数は命令ごとに決まっていて、名前付きでは書けない。同じ型の位置引数を二つ並べる命令は作らない (並べ違えても検査で見つからないため。@chara ユイカ smile はキャストと差分で型が違う。@keyframe のように文字列が二つ 要る命令は、二つ目から名前付き(frames=)にする)。組み込み、標準の命令はテストで確かめている。
  • 演出を待つかどうか(wait)の既定は、命令の種類で決まる:
種類既定命令
場面を作る(背景、画像、画面全体、カメラ、揺れ、移動)待つ@bg @image @free-image @transition @mask @blend @camera @quake @chara-move など
立ち絵の出し入れ、表情待たない@chara @chara-hide @chara-hide-all
繰り返す動き、重ねる動画待たない@anim @kanim @blend-movie
音待たない@bgm @se @voice(待つなら @wait-bgm @wait-se @wait-voice)

立ち絵は台詞と一緒に変わるのが普通なので待たず、背景や画面全体の切り替えは終わってから本文を出す。wait= を書けばそれが優先する。 命令ごとの既定は commands.md の各命令の wait(wait を持たずに待つ命令は「終わるまで次に進まない」と書いてある)。

  • 引数の型には、ほかに飛び先(@sleep-game extra#gallery。#ラベル、ファイル、ファイル#ラベル で、検査でラベルとファイルの 実在を確かめる)と変数(@text-input game.name。命令の結果を入れる game.x / system.x / temp.x)がある。

  • 画面効果 @effect のうち radial-blur(回転のぼかし)、zoom-blur(ズームのぼかし)、mosaic、ripple(波紋)、shader は WebGL でかける。かける場所(画面全体かレイヤー)の絵を毎コマ写し取り、シェーダーに通したものを上に出す(写すのは位置、大きさ、 不透明度、CSS のフィルタと立ち絵の反転で、回転などの変形は写らない。同じ場所に二つかけると、後のものだけが見える)。 shader は作品のフラグメントシェーダー(GLSL ES 1.0。others のフォルダからのパス)で、次のものを受け取る(書いていなければ 足す): u_texture(写し取った絵)、v_uv(0〜1、左下が 0)、u_resolution(px)、u_time(秒。speed 倍、演出を弱める設定では 0)、 u_strength(strength=)、u_center(x= y=。0〜1)。values="glow=0.5 tint=0.2" は uniform float glow; などで受け取る。

3.3 立ち絵

@chara ユイカ normal at=left fade=500ms
@chara ユイカ smile fade=300ms
@chara ユイカ normal appearance=casual at=center flip=true
@chara レン angry at=auto if-hidden=true
@chara ハル normal x=120 y=40
@chara-hide ユイカ fade=300ms

立ち絵の仕組みは立ち絵プラグイン Tyrano Tatie(tatie)と同じで、キャスト → 立ち絵セット(appearance。服装、時代、状態など) → 差分(variant。表情、ポーズ)→ 画像と枠(位置と大きさ)をキャストの定義(§5)に置き、シナリオでは名前で指す。

  • 出すのも替えるのも @chara 一つ。 表示していなければフェードインで出し、表示中なら替える。

  • 出ている人物は、書かなかった差分、立ち絵セット、位置、反転を今のまま保つ(位置は at、x、y のどれも書かなかったとき。 どれかを書けば、書いたもので置き直す)。表情だけ変えるなら @chara ユイカ smile。 出ていない人物は、書かなかったものを既定にする(基準の差分、キャストの既定の立ち絵セット、center、反転なし)。

  • 表示中の差し替えは、前と比べて次のどれかになる。

    • 画像も枠(位置と大きさ)も同じ: 即座に(反転だけ変わる)
    • 画像が同じで枠が違う: fade の半分で消え、枠を替え、残りで現れる
    • 枠が同じで画像が違う: 画像だけを重ねて切り替える。cross=false なら新しい画像を出し切ってから古い画像を消す
    • 両方違う: 立ち絵全体を重ねて切り替える

    切り替えの途中で次の @chara が来たら、途中の切り替えは終わった姿にしてから次を始める。

  • if-hidden=true は表示していないときだけ出す(表示中なら何もしない。tatie の mode=show)。

  • 立ち絵の色調は @tone night fade=1s(すべての立ち絵。これから出すものも)か @tone sepia who=ユイカ(その人物だけ)で変え、 @tone none で戻す。組み込みは sepia、mono、night、evening、dark で、liminalia.json の ui.tones で足したり置き換えたりできる。

  • 奥行きのある背景は @plane 名前 絵 depth=0.9(bgimage の絵を背景の上に重ねる)。@camera で動かすと、奥行き(0〜1)の分だけ 小さく動く(0 は立ち絵と同じ、1 に近いほど動かない。負は手前で、より大きく動く。front=true で立ち絵より手前)。奥のものほど 後ろに重ね、@plane-hide か @bg で外れる。カメラの途中の位置も、同じ時間と緩急で奥行きの分だけ遅らせて動かす。

  • 被写界深度は @dof sprites(立ち絵にピントを合わせ、背景と前景の画像をぼかす)、@dof background(立ち絵と前景の画像を ぼかす)、@dof who=ユイカ(その人物に合わせ、背景と前景の画像をぼかし、ほかの立ち絵を半分の強さでぼかす)。blur=(px。既定 6)、 fade= を書ける。@filter と重ねてかかり、@dof none で戻す。

  • 顔ウィンドウ(liminalia.json の ui.face)の表情は、その人物の立ち絵を表示中なら立ち絵の差分、表示していなければ @face ユイカ smile(appearance= も書ける)で決めたもの。立ち絵を消すと、消す前の差分を引き継ぐ。どれも無ければ基準の差分。

  • パーツ合成の立ち絵(§5 の layers)は、差分に層の絵を + でつないで足せる。@chara ユイカ smile+blush は差分 smile に 頬の層の blush を重ねる。@chara ユイカ +closed のように差分を省くと、基準の差分に足す。 + の後ろはどの層の絵の名前でもよく、同じ層の絵は後のものが勝つ。無い名前は差分の無いときと同じく lim check のエラー。

  • 立ち絵セットは、書いたもの → キャストの defaultAppearance → default → 最初のもの、の順にあるものを使う。

  • 位置は at、x、y で決める。

    • at=名前: 差分の枠の positions にある立ち位置。枠のある差分には left center right が必ずある。 実行時に無い名前は center(tatie と同じ)だが、検査ではエラーにする
    • at=auto: 自動配置。auto の立ち絵どうしで横に並べる。後から出したものほど左で、画面の幅を(数 + 1)で 等分した位置に中心を置き、下端を画面の下端にそろえる(TyranoScript の pos_mode と同じ)。 立ち絵を出す、消すたびに、残りの auto の立ち絵も滑らせて並べ直す
    • x= y=: 左上の座標(px)。書いた軸は at より優先する(at=auto y=40 は横だけ自動配置)。 x と y を両方書くときは at は書かない(書くとエラー)
  • 状態に残るのは差分、立ち絵セット、at、x / y、反転だけで、実際の座標と大きさは描画側が今表示中の立ち絵の状態と キャストの定義から毎回計算する。ロードしても同じ画面になる。

  • lim check は、キャスト、立ち絵セット、差分、立ち位置がキャストの定義に無ければ、綴りの近い候補付きでエラーにする。 書かなかった値を保った結果の組み合わせ(服装だけ替えたら、その服装に今の表情が無い、など)は検査では分からないので、 実行器が @chara のたびに確かめて実行時の診断にする(lim coverage、制作画面の点検で拾える)。

  • 基準の差分(立ち絵セットだけ書いて出したときの差分)は、立ち絵セットの defaultVariant → normal → default → 最初のもの。 どれも無い立ち絵セット(defaultVariant を書かず、normal も default も無い)は、lim check が警告する (cast-ambiguous。どの差分で出るかが並び順で決まるため)。同じく、既定の立ち絵セットが決まらないキャスト、 枠に left center right の無い差分も警告する。

3.4 ボイス

@voice yui/001.ogg
ユイ「おはよう」
@voice-auto ユイ pattern="yui/{n:3}.ogg"
ユイ「この台詞は yui/001.ogg」
ユイ「この台詞は yui/002.ogg」
レン「待って{@voice ren/wait.ogg}」
  • ボイスは assets/voice/ に置く(folder=sound で効果音のフォルダからも探す)。
  • @voice を本文の行の前に書くと、次の台詞で鳴らす(一度だけ)。本文の中に書くと、その場で鳴らしてそのページのボイスにする。
  • @voice-auto 話者 pattern=… は、その話者の台詞ごとに連番のファイルを鳴らす。{n} が番号、{n:3} は 3 桁にそろえる。 @voice を書いた台詞はそちらを鳴らし、番号は進めない。@voice-auto-off は番号を保ったまま止め、@voice-auto 話者 (pattern なし。話者も省略ですべて)で再開する。start=false は設定だけしておく(TyranoScript の [voconfig])。
  • 番号は状態に残るので、ロードしても同じ台詞で同じファイルが鳴る。
  • 数え方の番号は、台詞を一行足すとその後ろがすべてずれ、分かれ道のどちらを通ったかでも変わる。収録したら lim voice-lock で 台詞ごとに番号を固定する(voice-lock.json。§5)。固定があれば、固定した番号を鳴らし、固定に無い台詞(収録の後に足した台詞)は 鳴らさない。
  • 次のページへ進むと前のボイスを止める(設定画面で「鳴らし続ける」にもできる)。オートはボイスが鳴り終わってから送る。 スキップ中は鳴らさない。履歴の各ページから鳴らし直せる。

3.5 制御

@if game.trust >= 2
ユイ「なら、話は早いわ」
@elif game.trust == 1
ユイ「……考えておくわ」
@else
ユイ「……そう」
@end

@choice
- 北の道を行く -> #north_road
- 南の道を行く -> #south_road
@end

@choice
- 扉を調べる -> #door image=button/door.png hover=button/door_on.png x=120 y=300
- -> #window x=600 y=200 width=180 height=240
@end

@choice
- 鍵を使う -> #unlock if=(game.has_key)
- 引き返す -> #back
@end

@set game.trust += 1
@let count = game.trust * 2
@jump chapter2#start
@call common#interlude
@return
  • ブロックは必ず @end で閉じる。字下げは見た目だけ。
  • 飛び先は #ラベル(同じファイル)、ファイル(先頭)、ファイル#ラベル。実行時に決めるなら (式) (@jump (game.next)。値はこの三つの形の文字列)。
    • 検査と分岐の地図は、式の行き先の候補を式から読む: 式の中の文字列((game.good ? "#a" : "#b"))、式が変数だけなら その変数に @set で入れる文字列と liminalia.json の初期値、@let の局所変数ならその @let の式。候補はそれぞれ実在を確かめる
    • 読めない(文字列以外を入れる @set がある、命令の結果を入れる、など)ときは、候補を to= で書く (@jump (game.next) to=#north,#south。選択肢は - 文 -> (game.next) to=#a,#b)。候補を読めず to= も無ければ警告 (unknown-targets)。to= を書くと、実行時に候補に無い行き先へは飛ばずにエラーにする
  • ラベルは @if の外に置く。@if の中のラベルはエラー(label-in-if。飛び込むと条件を確かめずに枝の途中から始まるため。 実行器もそこへは飛ばない)。
  • 選択肢の行は - 文 -> 飛び先 で、飛び先のあとに見た目を書ける。image=(hover=)なら画像のボタン、 image= がなく width= と height= があれば見えない押せる範囲、どちらもなければ文字のボタン。x= y= で置き場所を決める。 文は画面なしの記録や読み上げに使い、画像のボタンと押せる範囲のときだけ省ける(- -> #window …)。 call=true の選択肢は飛び先を呼び出し(@call)、@return でその選択肢へ戻ってもう一度出す(設定の切り替えのボタンなど)。
  • 条件を満たしていない選択肢を、隠さずに押せない状態で見せるなら locked=(式)(真なら押せない。if= と一緒に書ける)。
  • 制限時間は @choice timeout=10s default=2 と @choice の行に書く。残り時間のバーを出し、時間切れなら default(上から何番目か)の 選択肢を選ぶ。default を省くか、その選択肢が表示されていない、押せないときは、選ばずに @end の後へ進む。
  • 選択肢を出す条件は、その行に if=(式) と書く(式は必ず (…)。真偽は @if と同じ見方)。偽の選択肢は出さず、 残りを詰めて出す(TyranoScript の [glink cond=…] と同じ)。すべて偽なら選択肢を出さずに、クリックでは進まない状態で止まる (@stop と同じ。TyranoScript で cond がすべて偽の [glink] のあとの [s] と同じで、実行時に警告 no-choice-shown を出す)。 選択肢を @if で囲んで出し分けることはできない(@choice の中は選択肢の行だけ)。 すべての選択肢に if= があれば検査が警告する(all-options-conditional。どれも出ずに止まることがあるため)。 ただし、条件が変数をそのまま真偽に使うか値と ==、!= で比べ、!、&&、|| でつないだものだけなら、変数の値の 組み合わせをすべて試し、どれかが必ず真になると分かれば警告しない(true、false とだけ比べる変数は真か偽のどちらか、 ほかの変数は比べた値、未設定、それ以外のどれかとみなす。最後の選択肢がほかをすべて済ませたときだけ出る、などの書き方)。
  • 式は JavaScript ではない小さな式言語。四則、比較、論理、三項、配列、添字、登録済み関数 (乱数、数、文字列と配列、現実の日時の関数。一覧は commands.md の「式の関数」)。配列を変える関数(push など)は元を変えずに新しい配列を返す。 == は型も比べる。文字列は "…" だけ。代入は @set / @let でだけ行う。
    • + は数どうしなら足し算、どちらかが文字列ならつなげる("第" + game.n + "章"。数、真偽は文字にする)。それ以外の組み合わせはエラー
    • 0 で割るとエラー(黙って null にはしない)。関数の引数の数が違えば検査でエラー(bad-arguments)
    • random() は 0 以上 1 未満、random(n) は 0 以上 n 未満の整数。乱数は状態の種(seed)から作るのでセーブに入り、 ロードすれば同じ結果になる。lim coverage は既定で種を一つに固定して流す(--seeds N で種を変えて N 通り)
  • 変数は寿命ごとに分ける: game.(セーブに入る)、system.(全セーブ共通)、temp.(そのプレイ中だけ)、 @let の局所変数(そのファイル、または @define の中だけ)。 初期値は liminalia.json の variables({ "game.trust": 0, "system.cleared": false })に書く。game. は「はじめから」のたびに この値で始まり(ロードしたセーブに無い変数もこの値で補う)、system. はまだ値が無いときにこの値になる。temp. には書けない。
  • 変数の名前、メンバー、キー(game.x、game["x"]、@let の名前、@define のパラメータ名)に __proto__、constructor、prototype は使えない (検査のエラー forbidden-property。実行時も、これらをたどる代入はエラーにして代入せず、読み出しは null)。
  • 部品は @define 名前(引数: 型 = 既定値, …) 〜 @end。型は string number int bool duration color。 呼び出しは命令と同じ形(@title-card text=第一章)。中では引数を (引数名) で命令に渡す。 引数名の - は、中の式では _ と書く(first-name は {= first_name}。式の - は引き算のため)。 呼び出し元の変数は書き換えない。中で引数と同じ名前の @let はエラー(shadows-param)。
  • 別のファイルの @define を呼ぶときは、ファイルの一番外側に @use ファイル を書く(@use common)。 @use したファイルの @define だけを呼べ、実行器はそのファイルを先に読み込む(ロードしたあとも呼べる)。 読み込むのは @define だけで、そのファイルの本文は実行しない。互いに @use していても(循環していても)一度だけ読む。 検査は、@use せずに呼んでいれば、どのファイルを @use すればよいかを示す。

3.6 逃げ道

@script 〜 @end で TypeScript を書ける。中身は @end だけの行まで、行の規則を適用せずそのまま持つ。 DOM や内部状態は直接触らず、エンジンが渡す API だけを使う。 liminalia.json に "allowScript": true を書いた作品でだけ使える(書いていなければ検査のエラー script-not-allowed)。 作品固有の演出は、逃げ道ではなくプラグインの命令として作る(@liminalia/sdk)。

4. 例(.ks からの取り込み)

元の .ks(キャラクターは別のファイルで [chara_new name="ユイカ" jname="ユイ" …] と定義してある):

[chara_show  name="ユイカ"  face="smile"  ]
[tb_start_text mode=1 ]
# ユイカ
「おはよう、今日は早いのね」[p]
#
窓の外では、雨が上がりかけていた。[p]
[_tb_end_text]

.lim:

@chara ユイカ smile at=auto fade=1s wait=true if-hidden=true
ユイ「おはよう、今日は早いのね」
窓の外では、雨が上がりかけていた。

tb_start_text / _tb_end_text / [p] / 話者の切り替え行が消える。命令は .lim の既定値と同じものを書かない一方、 TyranoScript の既定([chara_show] の自動配置、time=1000、wait=true、表示中なら何もしない)は .lim の既定と違うので書き出す。話者は名前欄に出る文字列([chara_new] の jname)になる。 tatie の [tatie name="ユイカ" variant="smile" position="left" mode="show"] は @chara ユイカ smile at=left fade=500ms if-hidden=true になる。

5. プロジェクト

プロジェクトのフォルダの直下に liminalia.json を置く(項目は packages/project/src/index.ts)。 話者とキャスト(立ち絵の定義)は cast.json に置く。

{
  "speakers": ["ユイ", "レン", "???"],
  "cast": {
    "ユイカ": {
      "defaultAppearance": "uniform",
      "appearances": {
        "uniform": {
          "label": "制服",
          "variants": {
            "normal": {
              "image": "chara/yuika/uniform_normal.png",
              "frame": { "width": 440, "height": 680, "positions": { "left": { "left": 60, "top": 40 }, "center": { "left": 420, "top": 40 }, "right": { "left": 780, "top": 40 } } }
            },
            "smile": {
              "image": "chara/yuika/uniform_smile.png",
              "frame": { "width": 440, "height": 680, "positions": { "left": { "left": 60, "top": 40 }, "center": { "left": 420, "top": 40 }, "right": { "left": 780, "top": 40 } } }
            }
          }
        },
        "casual": { "label": "私服", "variants": { "normal": { "image": "chara/yuika/casual_normal.png", "frame": { "width": 460, "height": 700 } } } }
      }
    },
    "レン": {
      "appearances": { "default": { "variants": { "normal": { "image": "chara/ren/normal.png" } } } }
    }
  }
}

形は tatie の設定(settings.js の characters)と同じで、差分の画像だけ storage ではなく image と書く。

  • appearances: 立ち絵セットの名前 → { label(表示用の名前・任意), variants }。defaultAppearance は任意。 立ち絵の無い人物(名前の色だけを決める話者など)は {} でよい。
  • color: 名前の欄の文字の色(CSS の色。任意)。キャスト名か speaker が台詞の話者名と同じキャストのものを使う。
  • variants: 差分の名前 → { image, cacheKey, frame }。image は assets/fgimage/ からのパス。 cacheKey は書かなくてよい。書き出し(lim build)は素材ごとに中身のハッシュを project/assets.json に書き、 遊ぶ側はそれを素材の URL に付けるので、同じ名前のまま差し替えた画像や音も古いキャッシュで出ない。 開発中(lim dev、制作画面)の素材は毎回確かめ直して返す。cacheKey があればそれも URL に付ける (キャストの画面は、同じ名前の画像を上書きしたときだけ、開いている舞台のために付け替える)。
  • frame: 枠。width / height は表示する大きさ(px。書かなければ 632×843)、positions は 立ち位置の名前 → { left, top }(左上の座標)。left center right は書かなくてもあり、書かなければ (0, 0)。 auto は予約された名前で、positions には書かない(§3.3)。
  • lip、blink: 口パク、まばたきの重ね画像(image と同じ大きさで、口、目の部分だけを描いたもの)。lip は少し開いた口 → 大きく開いた口、blink は半目 → 閉じた目の順。口はその人物のボイスが鳴っていればその大きさで、鳴っていなければ その人物のページの文字を出している間だけ動く。話者名がキャスト名と違うときは、キャストに speaker(本文の話者名)を書く。
  • layers(立ち絵セットに置く): パーツ合成。下から重ねる層の並び { name, options: { 絵の名前: 画像 }, default }。 差分は image の代わりに parts(層の名前 → 絵の名前。"" はその層を出さない)を書き、書かなかった層は default (無ければ出さない)。遊ぶ側は層を重ねた一枚を作ってから出すので、切り替えや枠の扱いは一枚絵と同じ。 層の絵は同じ大きさ(左上を揃えて重ねる)で描く。 PSD、PSB からは lim psd(§6)で作れる。
  • face: 顔ウィンドウの絵。差分に書けば絵(assets/fgimage/ からのパス)、立ち絵セットに書けば立ち絵(パーツ合成なら重ねた後) から切り出す範囲 { x, y, width, height }(px)。差分の絵が優先する。どちらも無い人物には顔ウィンドウを出さない。
  • model: 画像の代わりにプラグインが描く立ち絵({ "type": "live2d", … }。type 以外はそのプラグインが読む)。 image は位置と大きさを決める静止画になる。描き方はプラグインの extensions.spriteParts(docs/plugins.md)。
  • frame を書かない差分は、画像の実寸で出し、名前の立ち位置は center だけ(画面下の中央)。 自動配置(at=auto)か座標(x / y)で置く立ち絵に使う。
  • lim import は、TyranoScript の [chara_new] / [chara_face] から作る(立ち絵セットは default 一つ、 差分 default は [chara_new] の storage。width / height があれば枠にする)。tatie の設定 (data/others/plugin/tatie/settings.js)があれば重ね、同じ立ち絵セット、差分は tatie の設定が勝つ。

素材のパスは、書き出す前の検査で実在を確かめる。

セーブの版

セーブデータは version(セーブの形の版)を持つ。読み込むときに版を見て、古い版は版ごとの変換を順にたどって今の版にする。 この版のゲームより新しい版のセーブ、形の崩れたセーブは読まずに知らせる(遊ぶ画面は「読み込めません」と出し、今のプレイは捨てない)。

  • 版を上げるのは、古いセーブがそのまま読めない形に変えたときだけ(packages/runtime/src/save.ts の SAVE_VERSION)。 上げたら、一つ前の版から変換する関数を足し、新しい版の見本のセーブ(packages/runtime/test/fixtures/save-v<版>.json)を足す。 変換と古い見本は消さない(テストがすべての見本を今の版にして続きから遊べるかを確かめる)
  • 項目を足すだけで、無ければ既定で補える変更は版を上げない(読み込むときに補う。変数の初期値も同じ)
  • 台本の書き換えで再開する位置が動いたときは、近い行から再開する(save-relocated)

ボイスの番号の固定

voice-lock.json(paths.voiceLock)は、連番のボイス(§3.4)の番号を台詞ごとに固定する。lim voice-lock が作り、直す。

{
  "lines": {
    "start:v26410599": { "speaker": "ユイ", "n": 1, "source": "「……お客さんか」" }
  },
  "retired": {
    "start:v8d01e2aa": { "speaker": "ユイ", "n": 7, "source": "「消した台詞」" }
  }
}
  • 鍵はファイル、話者、原文から作る(同じファイルに同じ台詞が何度もあれば何度目か)。行を足しても動かしても変わらない
  • 初めて作るときは、「はじめから」から選択肢の選び方を変えて何通りか流し、今の数え方で鳴る番号を写す(収録した番号のまま固定する)。 通り方で番号が変わった台詞、ほかの台詞と同じ番号が鳴った台詞は一覧に出す。流して届かなかった台詞には新しい番号を振る
  • 作り直しても、固定した番号は変えない。新しい台詞には、話者ごとに今までの一番大きい番号の次から振る。原文を少し書き換えた台詞は 前の番号を引き継ぐ(同じファイル、同じ話者で、原文が十分に近いもの)。消した台詞の番号は retired に残し、別の台詞に振らない (台詞を戻せば前の番号に戻る)
  • lim check は、固定に無い台詞(鳴らない。voice-unlocked)、同じ話者の同じ番号(エラー)、無いボイスのファイル(missing-voice。 @voice-auto の型が一つに決まる話者だけ)、今のシナリオに無い台詞の番号(voice-lock-outdated)を知らせる
  • 開発サーバーは起動したときの固定を読む(作り直したら開き直す)。書き出しはゲームに入れる

liminalia.json の afterLoad(ファイル#ラベル)は、ロードの直後に呼び出す位置(@return でロードしたページへ戻る)。 temp. はセーブに入らないので、ロード後に要るものはここで計算し直す。saves はセーブの置き場所(デスクトップ版)と守り方 (README の書き出し)。

持ち物と探索

items に持ち物の名前 → { name(表示する名前), image(image フォルダの絵。任意), description(説明。任意) } を書く。

"items": { "old_key": { "name": "古い鍵", "image": "items/key.png", "description": "錆びている。" } }
  • @item-add old_key(count=、notify=false で知らせを出さない)、@item-remove old_key(count= を省くとすべて)で増減し、 式の item("old_key") で数を読む。数はゲームの変数(式からは書けない名前)に入るので、セーブ、巻き戻し、ロードでそのまま戻る。
  • メニューに「持ち物」が出る(items があるときだけ。クイックメニューには ui.menuBar.items の items で置ける)。@open-screen items でも開く。
  • @item-select temp.used は持ち物の画面を出して一つ選ばせ、名前を変数に入れる(閉じたら空の文字)。調べる場所で使うものを選ばせるときに。
  • 調べる場所は選択肢の押せる範囲(x y width height)で作り、cursor=(乗せたときのカーソルの画像)と tip=(乗せたときに出す 短い説明)を付けられる。調べ終えた場所を隠すなら if=、押せなくするなら locked=。
  • lim check は、items の形と、シナリオの @item-add、@item-remove、item("…") の名前が items にあるかを確かめる。

おまけ(CG、回想、音楽、ムービー、キャラクター、実績)

liminalia.json の extra に一覧を書くと、既定のシステム画面のタイトルに「おまけ」が出る(何か一つでも解放してから)。 シナリオからは @open-screen extra で開く。

"extra": {
  "cg": [{ "id": "ending_a", "title": "夜明け", "images": ["stills/ending_a.png", "stills/ending_a2.png"] }],
  "replay": [{ "id": "ending_a", "title": "夜明けの丘", "target": "ending#a", "thumb": "stills/ending_a.png" }],
  "music": [{ "file": "quiet_night.ogg", "title": "静かな夜" }],
  "achievements": [{ "id": "first_star", "title": "最初の流れ星", "description": "流れ星を見つけた", "hidden": false }]
}
  • cg: images は assets/bgimage/ からのパス。どれかを @cg-unlock するか @bg で出したら見られる(見られるのは解放した 画像だけで、クリックで次の差分へ送る)。thumb は一覧の画像(省略で解放した最初の画像)。
  • replay: @replay-unlock id で見られるようにする。target から再生し、@replay-end か終わりでおまけの回想へ戻る。 回想の間はセーブ、ロードできない。@replay-end は回想でないときは何もしないので、本編の流れの中に置いてよい。
  • music: @bgm で一度鳴らした曲を聞ける。
  • achievements: @achieve id で解放する(はじめての解放だけ知らせる)。hidden は解放するまで名前と説明を伏せる。 icon は assets/image/ からのパス。デスクトップ版は Steam の実績にも送る(README の書き出しの build.steam)。
  • movies: { file, title, thumb, subtitles }。@movie で一度流したものを見られる(file、subtitles は assets/video/、 thumb は assets/image/ から)。押すと画面いっぱいに操作の帯つきで流す。
  • characters: { who(キャスト名), name } の並び。立ち絵を出した差分だけを、立ち絵セットごとに大きく見られる (パーツ合成の立ち絵は重ねた絵。@chara smile+blush は smile を見たことにする)。
  • 解放の記録はシステム変数(全セーブ共通)の cg_unlocked、replay_unlocked、bgm_heard、achievements、movies_seen、sprites_seen。
  • lim check は、一覧の形、回想の始まりの位置を確かめ、シナリオの解放の命令が一覧に無い名前を指していれば警告する。

多言語

liminalia.json の languages に遊べる言語を書く(先頭が原文の言語)。原文のシナリオはそのままで、ほかの言語の訳は lang/<言語>.json に置く。

"languages": ["ja", "en"]
{
  "language": "en",
  "speakers": { "アカリ": "Akari" },
  "lines": {
    "start:l3f2a91c0": { "source": "「着いた……っ」", "text": "“Made it…!”", "speaker": "アカリ" },
    "start:c0a1b2c3d": { "source": "扉を調べる", "text": "Check the door", "speaker": "choice" }
  },
  "ui": { "title.start": "Begin" }
}
  • 訳すのは本文の行(台詞、地の文)、選択肢の文、話者名、システム画面の文言。
  • lines の鍵は、ファイル名と原文から作る(lim i18n が作るので手で書かない)。原文を書き換えると鍵が変わり、古い訳は そのままでは使われなくなる。lim i18n en(制作画面の訳の画面も同じ)は今ある訳を残し、新しい行を訳を空で足す。 使われなくなった訳は捨てない:
    • 原文が同じ行がほかの鍵にある(ファイルの名前を変えた、行を別のファイルへ移した)→ 訳をそのまま引き継ぐ
    • 同じファイルに近い原文の訳がある(原文を少し書き換えた)→ 訳は空のまま、previous に古い原文と訳を添える。 制作画面の訳の画面は「前の訳」として見せ、押せば訳の欄に入れる
    • どれにも当たらない訳 → stale に取っておく(原文を元に戻せばその行に戻る。要らなければ消してよい) 訳が空のまま使われなくなった行だけを外す。制作画面で台本の名前を変えたときは、訳の鍵も一緒に書き換える。
  • text は .lim の本文と同じ書き方(ルビ、{br}、{click}、本文中の命令)。原文に {@voice …} などの本文中の命令が あれば訳にも書く(lim check が落としたものを警告する)。訳が空の行は原文を出す。
  • システム画面の文言は、日本語と英語を組み込みで持つ。ほかの言語は英語を下敷きにし、ui の鍵(title.start、menu.save など。既定のシステム画面の strings.ts の表の道筋)で上書きする。
  • 遊ぶ言語は設定画面の「言語」で選ぶ(覚えておく)。選んでいなければ、ブラウザの言語に合うものか原文の言語。 遊んでいる途中で替えると、システム画面はすぐ、本文は次のページから替わる。
  • 言語ごとの素材(文字の入った画像、ボイス、動画の字幕など)は、assets/<種類>/@<言語>/ の下に元と同じパスで置く (assets/bgimage/@en/title.png は英語で遊んでいるときの assets/bgimage/title.png)。無いものは元の素材を使う。 遊んでいる途中で言語を替えると、次に出すものから替わる。制作画面の素材の一覧は、元の素材の使われ方を見せる。
  • 状態(変数、既読、セーブ)は言語によらず共通。話者ごとの処理(ボイスの音量、口パク)は原文の話者名で見る。

6. 道具(lim)

コマンド役割
lim new <フォルダ>ひな形(templates/default)から新しいプロジェクトを作る(--title / --id)
lim check構文、型、未定義の話者/ラベル/キャスト、別ファイルへの飛び先を検査する。直し方の候補付き(--json)
lim trace <場所> [--choose 1,2]ブラウザなしで流し、ページ、話者、命令、選択を出す(--json)
lim coverage全ファイルを選び方を変えて流し、本文の網羅率と実行時の診断を出す(--runs N。--seeds N で乱数の種を変えて流す)
lim fmt命令の行を整える(下)。--check は書き換えず、整っていないファイルがあれば失敗する
lim voice-lock連番のボイスの番号を台詞ごとに固定する(§5 のボイスの番号の固定)
lim shot <場所> / lim shot --title指定したページかタイトル画面の画面写真を撮る
lim devプレイヤーの開発サーバー
lim profile [<場所>]プレイヤーを開いて決めたページ数を進めながら、フレームレート、メモリ、素材の読み込み、DOM の要素の数を計る(player.md)
lim studio制作画面(lim dev と同じ開発サーバーに載る。studio.md)
lim importTyranoScript の .ks から取り込む。本文の一致を照合し、変換できない箇所は理由付きで一覧にする。マクロは一つのファイルの @define に集める(タグごとの扱いは tyrano-import.md)
lim import-ink / lim import-yarnInk、Yarn Spinner のシナリオを変換して取り込む。扱えない構文はコメントにして報告に出す(ink-yarn-import.md)
lim backport --base <基準>取り込みで作った作品の .lim の変更を .ks に書き戻し、取り込み直して確かめる(tyrano-backport.md)
lim build <browser|mac|windows|android|ios|all>書き出す。書き出す前に検査し、書き出したあと起動して確かめる(スマホ版は README の書き出し。ios --device で実機に入れて起動する)
lim reference命令のリファレンスを仕様の正本から生成する(--json で機械可読)
lim patch --base <前の書き出し>前の書き出しとの差分から、変わったものだけを入れたパッチ(パック)を作る(distribution.md のパッチ)
lim psd <ファイル> <キャラクター>PSD、PSB のレイヤーをパーツ合成の立ち絵として取り込む(一番上のグループが層、その中のレイヤーが選べる絵、表示しているものが既定。--appearance で立ち絵セット、--folder で絵を置く fgimage の中のフォルダ。制作画面のキャラクターの「差分を追加」に PSD を落としても同じ)
lim stats台詞と地の文の文字数(ファイル別、シーン別、話者別)とボイスの付いた台詞の数、表記ゆれの候補を集計する(text-stats.md)
lim i18n <言語>その言語の訳のファイル(lang/<言語>.json)を今のシナリオに合わせる(§5 の多言語)

lim fmt が整えるのは命令の行だけで、本文、台詞の行は字下げも含めて変えない(行末の空白だけ消す)。

  • 命令: 名前と引数のあいだ、引数どうしを空白一つに、外しても同じ値に読める "…" を外し、名前付きの引数を命令の定義の順に (位置引数は前に。同じ名前の引数が二度あれば並べ替えない)
  • @set @let は = の前後を空白一つに。@if などの式の中は変えない
  • 選択肢は -> から後ろ(飛び先と引数)を命令と同じように。表示する文は変えない
  • 字下げ(行頭の空白)は変えない。@script の中は一切変えない
  • 構文の誤りがあるファイルは整えない。整えた結果は、整える前と同じ構文木になる(テストで確かめている)

予定: lim query(変数、素材、ラベルの参照)、lim prose extract / apply (本文だけを抜き出して直し、本文以外が変わっていないことを確かめて戻す)。

場所の指定は全ツール共通で ファイル または ファイル#ラベル。