Kagari
キーワードを入力してドキュメントを検索します。

ガイド

システムのレシピ

ゲームシステムの実例です。プログラムとタイトル画面、ゲームビュー、設定画面、セーブ・ロード画面、CG ギャラリー、音楽鑑賞、回想の解放、バックログを扱います。std::system、std::system::nav、std::system::saves、std::ui、std::ui::style を使います。UI フォームには .kix ファイルが必要です。

レシピは次のような構成のプロジェクトを想定しています。

text
src/
main.kix // main, settings, bindings
story.kix // the scenes
ui.kix // the modes

kagari.toml には entry = "src/main.kix" を設定します。以下の各ファイルは、そのスニペットをページの順に連結したもので、ページ全体でコンパイルが通ります。アセットのパスは、プロジェクトの assets/ ディレクトリ以下のファイルを指します。

プログラムとタイトル画面

main は System を返します。設定ハンドルは不変の static で、エンジンは Settings に挙げられたものを読みます。

main.kix
mod story;
mod ui;
use std::narrative::text::text_style;
use std::screen::size;
use std::system::{Action, Binding, Input, Key, bind, default_bindings};
pub static TEXT_SPEED: Setting<Duration> = setting(ms(30));
pub static AUTO_DELAY: Setting<Duration> = setting(ms(1300));
pub static AUTO_DELAY_PER_CHAR: Setting<Duration> = setting(ms(60));
pub static SKIP_UNREAD: Setting<bool> = setting(false);
pub static MASTER: Setting<Volume> = setting(volume(100));
pub static MUSIC: Setting<Volume> = setting(volume(60));
pub static SOUND: Setting<Volume> = setting(volume(100));
pub static VOICE: Setting<Volume> = setting(volume(100));
pub static VOICE_SUSTAIN: Setting<bool> = setting(false);
pub static WAIT_FOR_VOICE: Setting<bool> = setting(true);
pub static WINDOW_OPACITY: Setting<Int<0, 100>> = setting(80);
pub static FULLSCREEN: Setting<bool> = setting(false);
pub static ANIMATIONS: Setting<bool> = setting(true);
const FONT: FontAsset = asset("fonts/default.otf");
fn main() -> System {
System {
title: "Example",
resolution: size(1280, 720),
start: ui::Title,
game: ui::GameView,
save_slots: 20,
auto_slots: 3,
backlog: 200,
font: FONT,
text: text_style(26, WHITE),
settings: Settings {
text_speed: TEXT_SPEED,
auto_delay: AUTO_DELAY,
auto_delay_per_char: AUTO_DELAY_PER_CHAR,
skip_unread: SKIP_UNREAD,
master: MASTER,
music: MUSIC,
sound: SOUND,
voice: VOICE,
voice_sustain: VOICE_SUSTAIN,
wait_for_voice: WAIT_FOR_VOICE,
window_opacity: WINDOW_OPACITY,
fullscreen: FULLSCREEN,
animations: ANIMATIONS,
},
bindings: bindings(),
}
}
const fn bindings() -> Vec<Binding> {
let mut list = default_bindings();
list.push(bind(Input::Key(Key::A), Action::ToggleAuto));
list.push(bind(Input::Key(Key::L), Action::OpenInGame(ui::Backlog)));
list
}

タイトル画面は RootPage です。nav::start で物語を始め、nav::call でオーバーレイを開き、nav::goto でページを切り替え、set_music でシステム音楽を再生します。

ui.kix
use std::screen::{CENTER, rgba};
use std::system::set_music;
use std::ui::{Align, Fit};
use std::ui::style::{ButtonStyle, Look, Paint, button_style};
use std::narrative::text::text_style;
use crate::story;
const TITLE_BG: ImageAsset = asset("image/title.png");
const TITLE_THEME: AudioAsset = asset("music/title.ogg");
const MENU_BUTTON: ButtonStyle = button_style(Look { background: Paint::None, text: text_style(28, WHITE) })
.hover(Look { background: Paint::Color(rgb(60, 60, 90)), text: text_style(28, WHITE) })
.focus(Look { background: Paint::Color(rgb(60, 60, 90)), text: text_style(28, WHITE) })
.change(tween(ms(150)));
pub fn Title() -> RootPage {
set_music(Some(looped(TITLE_THEME)), crossfade(ms(500)));
RootPage(layer![
image!(fit = Fit::Cover, TITLE_BG).cover(),
column![
button!{style = MENU_BUTTON, on_click = || start_game(), t!["Start"]},
button!{style = MENU_BUTTON, on_click = || nav::call(Load), t!["Load"]},
button!{style = MENU_BUTTON, on_click = || nav::goto(Gallery), t!["Gallery"]},
button!{style = MENU_BUTTON, on_click = || nav::goto(MusicRoom), t!["Music"]},
button!{style = MENU_BUTTON, on_click = || nav::goto(Replays), t!["Replay"]},
button!{style = MENU_BUTTON, on_click = || nav::call(Config), t!["Config"]},
button!{style = MENU_BUTTON, on_click = || nav::dialog(ConfirmQuit), t!["Quit"]},
].gap(12).at(point(640, 470), CENTER),
])
}
fn start_game() {
nav::start(story::Main, (CLEARED_AZUSA.get(),), on_ending)
}
fn ConfirmQuit() -> Dialog {
Dialog(column![
t!["Quit the game?"],
row![
button!{on_click = || nav::quit(), t!["Yes"]},
button!{on_click = || nav::back(), t!["No"]},
].gap(24),
].align(Align::Center, Align::Center).background(Paint::Color(rgba(0, 0, 0, 200))))
}

Title コンポーネントはページが表示されるたびに一度実行されるので、プレイヤーがタイトルに戻るたびに set_music がタイトル曲を再生し直します。nav::quit は確認をしないので、ConfirmQuit ダイアログが確認します。

物語がエンディングを返す場合は、終了ハンドラで処理し、Meta の値に記録します。

kix
pub static CLEARED_AZUSA: Meta<bool> = meta(false);
fn on_ending(ending: story::Ending) {
match ending {
story::Ending::AzusaEnd => { CLEARED_AZUSA.set(true); nav::goto(Credits) }
story::Ending::NormalEnd => nav::goto(Credits),
}
}
fn Credits() -> RootPage {
RootPage(column![
t!["Thank you for playing."],
button!{on_click = || nav::goto(Title), t!["Back to title"]},
])
}

story.kix はエントリーシーンとその結果から始まります。残りは 回想シーン で追加します。

story.kix
pub enum Ending {
AzusaEnd,
NormalEnd,
}
#[scene]
#[entry]
pub pushdown fn Main(cleared: bool) -> Ending {
s!(t!["Hello."]);
FestivalNight();
if cleared { Ending::AzusaEnd } else { Ending::NormalEnd }
}
const fn festival(screen: Screen) -> Screen {
screen
}

ゲームビュー

System::game は、ゲーム画面、メッセージウィンドウ、選択肢のボタン、クイックメニューを描きます。Narrative ハンドルから読み取るものはすべてリアクティブです。

kix
use std::narrative::{MenuArm, PlayMode};
use std::narrative::text::TextStyle;
use std::screen::{BOTTOM_CENTER, insets};
use std::system::saves::SaveSlot;
use std::ui::{each, label, message, show};
const WINDOW_STYLE: TextStyle = text_style(26, WHITE);
const NAME_STYLE: TextStyle = text_style(28, hex(0xFFE0A0));
fn who_text(game: Narrative) -> Text {
match game.line() {
Some(line) => match line.who {
Some(who) => who,
None => "",
},
None => "",
}
}
fn menu_arms(game: Narrative) -> Vec<MenuArm> {
match game.menu() {
Some(menu) => menu.arms,
None => Vec::new(),
}
}
fn arm_button(game: Narrative, arm: MenuArm) -> Element {
button!{
style = MENU_BUTTON,
disabled = match arm.arm {
Some(_) => false,
None => true,
},
on_click = || match arm.arm {
Some(a) => game.select(a),
None => {}
},
label(arm.text),
}.width(500)
}
fn toggle_mode(game: Narrative, mode: PlayMode) {
if game.mode() == mode { game.set_mode(PlayMode::Normal) } else { game.set_mode(mode) }
}
pub fn GameView(game: Narrative) -> Element {
let (hidden, set_hidden) = signal(false);
layer![
game_screen!(game = game).cover(),
show(
|| !hidden(),
|| column![
text!(style = NAME_STYLE, who_text(game)),
message(game, WINDOW_STYLE).grow(1),
row![
button!{on_click = || game.save(SaveSlot::Quick), t!["Q.Save"]},
button!{on_click = || { let _ = nav::load(SaveSlot::Quick); }, t!["Q.Load"]},
button!{on_click = || game.call(Save), t!["Save"]},
button!{on_click = || nav::call(Load), t!["Load"]},
button!{on_click = || toggle_mode(game, PlayMode::Auto), t!["Auto"]},
button!{on_click = || toggle_mode(game, PlayMode::Skip), t!["Skip"]},
button!{on_click = || game.call(Backlog), t!["Log"]},
button!{on_click = || nav::call(Config), t!["Config"]},
button!{on_click = || set_hidden(true), t!["Hide"]},
].gap(8),
]
.width(1200)
.height(200)
.padding(insets(16, 40, 16, 40))
.background(Paint::Color(rgba(0, 0, 0, 160))),
).at(point(640, 710), BOTTOM_CENTER),
show(
|| has_menu(game),
|| each(|| menu_arms(game), |arm| arm_button(game, arm)).gap(24),
).at(point(640, 300), CENTER),
show(|| hidden(), || button!{on_click = || set_hidden(false)}.width(1280).height(720)).cover(),
]
}
fn has_menu(game: Narrative) -> bool {
match game.menu() {
Some(_) => true,
None => false,
}
}

each はアームごとに 1 つのボタンを保ちます。無効なアーム(arm.arm が None)はグレーアウトして表示されます。

設定画面

コンフィグ画面は Overlay なので、タイトルからもゲームからも開けます。設定は get で読み、set で書きます。get はリアクティブなので、どのコントロールもシグナルなしで現在の値を表示します。

kix
use std::ui::option;
use crate::{AUTO_DELAY, FULLSCREEN, MUSIC, SKIP_UNREAD, SOUND, TEXT_SPEED, VOICE, WINDOW_OPACITY};
fn volume_row(name: Text, handle: Setting<Volume>) -> Element {
row![
label(name).width(200),
slider!{
value = handle.get().level,
on_change = |v| handle.set(Volume { level: v, muted: handle.get().muted }),
}.width(400),
toggle!{
value = handle.get().muted,
on_change = |m| handle.set(Volume { level: handle.get().level, muted: m }),
t!["Mute"],
},
].gap(16).align(Align::Start, Align::Center)
}
fn speed_button(name: Text, handle: Setting<Duration>, value: Duration) -> Element {
toggle!{value = handle.get().ms == value.ms, on_change = |_on| handle.set(value), label(name)}
}
fn Config() -> Overlay {
Overlay(column![
volume_row("Music", MUSIC),
volume_row("Sound", SOUND),
volume_row("Voice", VOICE),
row![
label("Text speed").width(200),
speed_button("Slow", TEXT_SPEED, ms(60)),
speed_button("Normal", TEXT_SPEED, ms(30)),
speed_button("Fast", TEXT_SPEED, ms(10)),
speed_button("Instant", TEXT_SPEED, ms(0)),
].gap(8),
row![
label("Auto delay").width(200),
speed_button("Short", AUTO_DELAY, ms(800)),
speed_button("Normal", AUTO_DELAY, ms(1300)),
speed_button("Long", AUTO_DELAY, ms(3000)),
].gap(8),
row![
label("Window").width(200),
select!{
value = WINDOW_OPACITY.get(),
on_change = |v| WINDOW_OPACITY.set(v),
option(50, label("50%")),
option(80, label("80%")),
option(100, label("100%")),
},
].gap(8),
toggle!{value = SKIP_UNREAD.get(), on_change = |v| SKIP_UNREAD.set(v), t!["Skip unread text"]},
toggle!{value = FULLSCREEN.get(), on_change = |v| FULLSCREEN.set(v), t!["Fullscreen"]},
button!{on_click = || nav::back(), t!["Back"]},
].gap(16).padding(insets(40, 80, 40, 80)).background(Paint::Color(rgba(0, 0, 0, 220))))
}

スライダーの範囲は値の Int 型の範囲なので、音量スライダーは min や max なしで 0 から 100 まで動きます。

安定性: 予定 — ゲームビューの外でのテキスト表示のプレビュー(現在の文字速度を示すサンプル行)は、まだ使えません。

セーブ・ロード画面

セーブには実行中のゲームが必要なので、セーブ画面は Narrative ハンドルを受け取る GameOverlay です。ロード画面は通常のオーバーレイで、タイトルからも使えます。saves::info はリアクティブなので、スロットは書き込みや削除のすぐ後に更新されます。

kix
use std::system::saves;
use std::system::saves::SaveSlot;
use std::ui::{scroll, thumbnail};
fn slot_text(slot: SaveSlot) -> Text {
match saves::info(slot) {
Some(info) => match info.text {
Some(text) => text,
None => "",
},
None => "Empty",
}
}
fn slots() -> Vec<u32> {
let mut list = Vec::new();
for k in 0..20 {
list.push(k);
}
list
}
fn slot_view(slot: SaveSlot) -> Element {
row![
thumbnail(slot).width(160).height(90),
text!(slot_text(slot)),
].gap(16)
}
fn Save(game: Narrative) -> GameOverlay {
GameOverlay(column![
scroll(|| each(|| slots(), |k| row![
button!{on_click = || game.save(SaveSlot::Manual(k)), slot_view(SaveSlot::Manual(k))},
button!{on_click = || saves::delete(SaveSlot::Manual(k)), t!["Delete"]},
])).grow(1),
button!{on_click = || nav::back(), t!["Back"]},
].background(Paint::Color(rgba(0, 0, 0, 200))))
}
fn Load() -> Overlay {
let (error, set_error) = signal("");
Overlay(column![
scroll(|| each(|| slots(), |k| button!{
on_click = || match nav::load(SaveSlot::Manual(k)) {
Ok(()) => {}
Err(divergence) => set_error(divergence.describe()),
},
slot_view(SaveSlot::Manual(k)),
})).grow(1),
text!(error()),
button!{on_click = || nav::back(), t!["Back"]},
].background(Paint::Color(rgba(0, 0, 0, 200))))
}
  • 空のスロットは「Empty」と表示され、サムネイルは空です。
  • スクリプトが変更されてセーブが合わなくなった場合、nav::load は Err を返します。スタックは変わらず、画面に Divergence::describe() を表示します。
  • オートセーブは SaveSlot::Auto(0) … Auto(auto_slots - 1) で、エンジンが選択肢ごとと終了時に書き込みます。ロード画面で同じように一覧できます。

seen_image は画像がこれまでに画面に出たかどうかを返すので、ギャラリーのために物語側で記録をする必要はありません。image! のソースはリアクティブです。

kix
use std::system::seen_image;
use std::ui::Fit;
mod CG {
pub const FIREWORKS: ImageAsset = asset("cg/fireworks.png");
pub const ROOF: ImageAsset = asset("cg/roof.png");
pub const STATION: ImageAsset = asset("cg/station.png");
}
const LOCKED_TILE: ImageAsset = asset("cg/locked.png");
fn cg_or_lock(cg: ImageAsset) -> ImageAsset {
if seen_image(cg) { cg } else { LOCKED_TILE }
}
fn cg_tile(cg: ImageAsset, set_viewing: Fn(Option<ImageAsset>)) -> Element {
button!{
disabled = !seen_image(cg),
on_click = || set_viewing(Some(cg)),
image!(fit = Fit::Cover, cg_or_lock(cg)),
}.width(320).height(180)
}
fn Gallery() -> RootPage {
let (viewing, set_viewing) = signal(None);
RootPage(layer![
column![
row![
cg_tile(CG::FIREWORKS, set_viewing),
cg_tile(CG::ROOF, set_viewing),
cg_tile(CG::STATION, set_viewing),
].gap(24),
button!{on_click = || nav::goto(Title), t!["Back"]},
].gap(24).at(point(640, 360), CENTER),
show(
|| match viewing() {
Some(_) => true,
None => false,
},
|| button!{
on_click = || set_viewing(None),
image!(fit = Fit::Contain, match viewing() {
Some(cg) => cg,
None => LOCKED_TILE,
}),
}.width(1280).height(720),
).cover(),
])
}

ギャラリーはルートページで、nav::goto(Gallery) で入り、nav::goto(Title) で出ます。ルートページはベースなので、ここで nav::back() を使うとエラー E6002 になります。

音楽鑑賞

heard はトラックがこれまでに再生されたかどうかを返し、set_music はシステム音楽チャンネルでトラックを再生します。このチャンネルはゲームが実行されていない間に聞こえます。

kix
use std::system::{heard, set_music};
const THEME: AudioAsset = asset("music/theme.ogg");
const EVENING: AudioAsset = asset("music/evening.ogg");
fn track_button(title: Text, track: AudioAsset) -> Element {
button!{
disabled = !heard(track),
on_click = || set_music(Some(looped(track)), crossfade(ms(500))),
text!(if heard(track) { title } else { "???" }),
}
}
fn MusicRoom() -> RootPage {
RootPage(column![
track_button("Theme", THEME),
track_button("Evening", EVENING),
button!{on_click = || set_music(None, crossfade(ms(500))), t!["Stop"]},
button!{on_click = || nav::goto(Title), t!["Back"]},
].gap(12))
}

安定性: 予定 — 現在再生中のものの読み取り(現在のトラックの強調表示や、再生位置・一時停止状態の表示のため)は、まだ設計されていません。

回想シーン

回想は別の #[entry] シーンで、nav::start で開始します。プレイヤーが初めてそのシーンに到達したときに物語が write で書き込む Meta<bool> によって解放されます。

story.kix
pub static SEEN_FESTIVAL: Meta<bool> = meta(false);
#[scene]
pushdown fn FestivalNight() {
s!(render = festival, t!["The lanterns came on one by one."]);
// …
SEEN_FESTIVAL.write(true);
}
#[scene]
#[entry]
pub pushdown fn FestivalReplay() {
FestivalNight();
}
ui.kix
fn Replays() -> RootPage {
RootPage(column![
button!{
disabled = !story::SEEN_FESTIVAL.get(),
on_click = || nav::start(story::FestivalReplay, (), on_replay_end),
t!["Festival night"],
},
button!{on_click = || nav::goto(Title), t!["Back"]},
])
}
fn on_replay_end(_end: ()) {
nav::goto(Replays)
}
  • 回想は新しい物語として始まり、物語変数は初期値を持ちます。回想するシーンに必要な状態は、エントリーの引数として渡します。
  • Meta::get はリアクティブなので、メタの値が変わるとすぐにボタンが有効になります。
  • 物語はメタに書き込むだけで(write は pushdown fn です)、読むことはありません。メタが物語に届くのはエントリーの引数を通してだけです。

バックログ

バックログは Narrative::history() を一覧するゲームオーバーレイです。ゲームビューから game.call(Backlog) で、またはバインディングの Action::OpenInGame(Backlog) で開きます。

kix
use std::narrative::{Entry, Line};
use std::ui::scroll;
fn line_text(line: Line) -> Text {
match line.text {
Some(text) => text,
None => "",
}
}
fn entry_view(entry: Entry) -> Element {
match entry {
Entry::Line(line) => text!(line_text(line)),
Entry::Chosen(text) => label(text),
}
}
pub fn Backlog(game: Narrative) -> GameOverlay {
GameOverlay(column![
scroll(|| each(|| game.history(), |entry| entry_view(entry))).grow(1),
button!{on_click = || nav::back(), t!["Close"]},
].background(Paint::Color(rgba(0, 0, 0, 200))))
}

エントリー、ボイスの再生(Narrative::replay_voice)、巻き戻し(Narrative::rewind)については std::narrative で説明しています。

最終更新日 2026年10月29日