ゲームシステムの実例です。プログラムとタイトル画面、ゲームビュー、設定画面、セーブ・ロード画面、CG ギャラリー、音楽鑑賞、回想の解放、バックログを扱います。std::system、std::system::nav、std::system::saves、std::ui、std::ui::style を使います。UI フォームには .kix ファイルが必要です。
レシピは次のような構成のプロジェクトを想定しています。
src/ main.kix // main, settings, bindings story.kix // the scenes ui.kix // the modeskagari.toml には entry = "src/main.kix" を設定します。以下の各ファイルは、そのスニペットをページの順に連結したもので、ページ全体でコンパイルが通ります。アセットのパスは、プロジェクトの assets/ ディレクトリ以下のファイルを指します。
プログラムとタイトル画面
main は System を返します。設定ハンドルは不変の static で、エンジンは Settings に挙げられたものを読みます。
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 でシステム音楽を再生します。
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 の値に記録します。
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 はエントリーシーンとその結果から始まります。残りは 回想シーン で追加します。
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 ハンドルから読み取るものはすべてリアクティブです。
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 はリアクティブなので、どのコントロールもシグナルなしで現在の値を表示します。
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 はリアクティブなので、スロットは書き込みや削除のすぐ後に更新されます。
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)で、エンジンが選択肢ごとと終了時に書き込みます。ロード画面で同じように一覧できます。
CG ギャラリー
seen_image は画像がこれまでに画面に出たかどうかを返すので、ギャラリーのために物語側で記録をする必要はありません。image! のソースはリアクティブです。
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 はシステム音楽チャンネルでトラックを再生します。このチャンネルはゲームが実行されていない間に聞こえます。
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> によって解放されます。
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();}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) で開きます。
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 で説明しています。