この記事はSiv3D Advent Calendar 2024の20日目の記事です。
はじめに
C++20で、新しい言語構文であるコルーチンが導入されました。
これをSiv3Dで活用するためのタスク型コルーチンライブラリ「CoTaskLib」を開発したので紹介します。
CoTaskLibを導入すれば、コルーチンを活用してゲーム内の「待ち」を含む処理を簡単に実装できるようになります。
例えば「アニメーションAを再生し、終わるまで待ってからアニメーションBを再生する」といった複数フレームにまたがる処理を、あたかも通常の関数のように時系列順に記述できます。
Co::Task<> Example()
{
co_await AnimationA(); // アニメーションAを再生
co_await AnimationB(); // 終わったら、アニメーションBを再生
}
本記事では、CoTaskLibの使い方について紹介していきます。
タスクとは?
CoTaskLibにおける「タスク」とは、コルーチンの言語構文を使って記述できる「複数フレームをまたぐことができる関数」です。
タスク内では、C++20コルーチンのキーワード(co_await
/co_return
/co_yield
)のうち、下記の2つを使用します。
-
co_await
: 他のタスクを実行し、その完了を待ちます。 -
co_return
: タスクから結果を返します。
※ CoTaskLibではco_yield
は使用しません
実際に使ってみよう
CoTaskLibを利用する際は、下記のコードをひな型として利用します。
ここからは、MainTask関数へ処理内容を実装していきます。
#include <Siv3D.hpp>
#include <CoTaskLib.hpp>
Co::Task<> MainTask()
{
// ここへ処理内容をコルーチンで記述する
co_return;
}
void Main()
{
// CoTaskLibを初期化
Co::Init();
// MainTaskを実行する。runner変数が有効な間、実行される
const auto runner = MainTask().runScoped();
while (System::Update())
{
}
}
1. 指定時間待つ(Delay
)
Co::Delay
関数を使うことで、ゲームのメインループを止めることなく時間待ちできます。
co_await Co::Delay(1s);
これを使って、時間待ちを含む処理を下記のように時間の流れ通りに記述できます。
Co::Task<> MainTask()
{
Print << U"スタート!";
co_await Co::Delay(1s); // 1秒待つ
Print << U"1秒経過!";
co_await Co::Delay(1s); // 1秒待つ
Print << U"2秒経過!";
co_await Co::Delay(1s); // 1秒待つ
Print << U"3秒経過!";
}
(参考) 上記の例をCoTaskLibを使わずに実装した場合のコード例
もし仮にCoTaskLibを使用しないで実装する場合、Stopwatch
を使って時間経過を自前で判定する必要があります。
時間が経過した瞬間のみ実行するために、下記のように前回フレームでの経過秒数(prevSec
)もチェックする必要があります。
void Main()
{
Stopwatch stopwatch{ StartImmediately::Yes };
Print << U"スタート!";
// 前回フレームでの秒数
int32 prevSec = 0;
while (System::Update())
{
// 今回フレームでの秒数
const int32 sec = stopwatch.s();
if (sec >= 1 && prevSec < 1)
{
Print << U"1秒経過!";
}
else if (sec >= 2 && prevSec < 2)
{
Print << U"2秒経過!";
}
else if (sec >= 3 && prevSec < 3)
{
Print << U"3秒経過!";
}
prevSec = sec;
}
}
2. タスクから戻り値を返す
Co::Task<T>
のように戻り値の型T
を指定すれば、タスクからco_return
で戻り値を返すことができます。返された戻り値はco_await
で受け取ることができます。
下記の例では、1秒経過後にタスクから戻り値が返され、それをco_await
で受け取って出力しています。
Co::Task<int32> SubTask()
{
co_await Co::Delay(1s); // 1秒待つ
co_return 42; // 結果を返す
}
Co::Task<> MainTask()
{
// SubTaskを実行し、結果を受け取る
int32 result = co_await SubTask();
Print << result; // 42が出力される
}
3. 条件を満たすまで待機(WaitUntil
)
WaitUntil
関数を使用すれば、指定された条件を満たすまで待機することができます。
指定された関数を毎フレーム実行し、その戻り値がfalseの間、待機します。
co_await Co::WaitUntil(/*ここへ条件の関数を指定*/);
例えば下記のように、マウスクリックされる(MouseL.down()
がtrueを返す)まで待機できます。
Co::Task<> MainTask()
{
Print << U"クリックされるのを待っています...";
// マウスクリックされるまで待機
co_await Co::WaitUntil([] { return MouseL.down(); });
Print << U"クリックされました!";
}
マウス入力(MouseL
/MouseR
)やキーボード入力(KeyA
/KeyB
/...)など、down
関数が実装された型の値であれば、上記の代わりにCo::WaitUntilDown
関数を利用して記述することもできます。
Co::Task<> MainTask()
{
Print << U"クリックされるのを待っています...";
// マウスクリックされるまで待機
co_await Co::WaitUntilDown(MouseL);
Print << U"クリックされました!";
}
(参考) 上記の例をCoTaskLibを使わずに実装した場合のコード例
もし仮にCoTaskLibを使用しないで実装する場合、下記のように毎フレームif文で判定して実行します。
ただし、2回目以降のクリックは無視する必要があるため、実行済みかどうかを保持する状態変数(isClicked
)を用意しなければいけません。
void Main()
{
Print << U"クリックされるのを待っています...";
// クリック済みかどうかを状態として保持
bool isClicked = false;
while (System::Update())
{
if (!isClicked && MouseL.down())
{
Print << U"クリックされました!";
isClicked = true;
}
}
}
4. 複数のタスクを同時に実行する(Any
、All
)
Any
関数やAll
関数を利用すれば、複数のタスクを同時に実行できます。
Any
: いずれかのタスクが完了するまで待機
複数のタスクを同時に実行し、いずれか1つのタスクが完了するまで待機します。
co_await Co::Any(タスク1, タスク2, ...);
これを利用して、下記のようにいずれかの入力を待つことができます。
Co::Task<> MainTask()
{
Print << U"クリックまたはEnterキーが押下されるのを待っています...";
// マウスクリックまたはEnterキーが押下されるまで待機
co_await Co::Any(
Co::WaitUntilDown(MouseL),
Co::WaitUntilDown(KeyEnter));
Print << U"クリックまたはEnterキーが押下されました!";
}
さらに、各タスクの戻り値はOptional
のtupleで受け取ることができます。そのため、どのタスクが完了したかによって処理を分岐することもできます。
Co::Task<> MainTask()
{
Print << U"クリックまたはEnterキーが押下されるのを待っています...";
// マウスクリックまたはEnterキーが押下されるまで待機
const auto [isClicked, isEnter] = co_await Co::Any(
Co::WaitUntilDown(MouseL),
Co::WaitUntilDown(KeyEnter));
if (isClicked)
{
// マウスクリックされた場合
Print << U"クリックされました!";
}
else
{
// Enterキーが押下された場合
Print << U"Enterが押下されました!";
}
}
補足: 戻り値がないタスクに対するAny
/All
の戻り値の型について
Any
/All
関数では、戻り値がないタスク(Co::Task<>
)に対しては戻り値の型はCo::VoidResult
型(空の構造体)へ置換されます(void型はtupleとして返せないため)。
上記の例のCo::WaitUntilDown
も戻り値がないタスクなので、Co::Any
からはstd::tuple<Optional<Co::VoidResult>, Optional<Co::VoidResult>>
型の値が返されます。isClicked
変数とisEnter
変数はそれぞれOptional<Co::VoidResult>
型になり、それをif文で利用しています。
Co::All
: 全てのタスクが完了するまで待機
複数のタスクを同時に実行し、全てのタスクが完了するまで待機します。
co_await Co::All(タスク1, タスク2, ...);
複数のタスクを同時実行したい場合に便利です。
Co::Task<> SubTask()
{
Print << U"スタート!";
co_await Co::Delay(1s);
Print << U"1秒経過!";
co_await Co::Delay(1s);
Print << U"2秒経過!";
co_await Co::Delay(1s);
Print << U"3秒経過!";
}
Co::Task<> MainTask()
{
// 2つのSubTaskを同時に実行する
co_await Co::All(SubTask(), SubTask());
}
(参考) 上記の例をCoTaskLibを使わずに実装した場合のコード例
もし仮にCoTaskLibを使わずに実装する場合、状態を含む処理を複数使い回すには下記のようにクラスとして実装する必要があるため少し面倒です。
class Counter
{
private:
Stopwatch m_stopwatch{ StartImmediately::Yes };
int32 m_prevSec = 0;
public:
Counter()
{
Print << U"スタート!";
}
void update()
{
const int32 sec = m_stopwatch.s();
if (sec >= 1 && m_prevSec < 1)
{
Print << U"1秒経過!";
}
else if (sec >= 2 && m_prevSec < 2)
{
Print << U"2秒経過!";
}
else if (sec >= 3 && m_prevSec < 3)
{
Print << U"3秒経過!";
}
m_prevSec = sec;
}
};
void Main()
{
Counter counter1;
Counter counter2;
while (System::Update())
{
counter1.update();
counter2.update();
}
}
注意: タスクの同時実行はシングルスレッドで動作します
ここでの「複数のタスクを同時に実行」とは、メインスレッド内での疑似的な並列実行を指します。マルチスレッドを使った並列処理ではありません。
具体的には、CoTaskLibは毎フレーム、現在実行中のタスクの1フレーム分の処理をメインスレッド内で1タスクずつ順に実行していく仕組みになっています(これはSystem::Update()
内で実行されます)。そのため、同時実行中のタスクが異なるスレッドで並列に実行されてしまうことはなく、スレッド安全性・データ競合などを心配する必要もありません。
5. 描画処理を含むタスクを実装する(シーケンス)
シーケンスを利用することで、描画処理(draw関数)を含むタスクを実装できます。
シーケンスを実装するには、SequenceBase<T>
を継承したクラスを作成し、下記のようにstart関数・draw関数をオーバーライドします。
class ExampleSequence : public Co::SequenceBase<>
{
private:
Co::Task<> start() override
{
// ここへタスクを実装
co_return;
}
void draw() const override
{
// 毎フレーム呼ばれる描画処理を実装
}
}
シーケンスは、Play<T>()
関数で再生できます。
もしシーケンスにコンストラクタ引数がある場合は、Play
関数の引数として指定できます。
co_await Co::Play<ExampleSequence>(/*コンストラクタ引数(あれば)*/);
下記は、星形を描画するシーケンスの例です。コンストラクタ引数として描画する星の色を受け取っています。
class StarSequence : public Co::SequenceBase<>
{
public:
// コンストラクタ
explicit StarSequence(const ColorF& color)
: m_color(color)
{
}
private:
ColorF m_color;
Co::Task<> start() override
{
// 1秒待機
co_await Co::Delay(1s);
}
void draw() const override
{
// 星形を描画
Shape2D::Star(200, Scene::Center()).draw(m_color);
}
};
Co::Task<> MainTask()
{
// 赤色→青色→黄色の順番で星形を描画
co_await Co::Play<StarSequence>(Palette::Red);
co_await Co::Play<StarSequence>(Palette::Blue);
co_await Co::Play<StarSequence>(Palette::Yellow);
}
(参考) 上記の例をCoTaskLibを使わずに実装した場合のコード例
もし仮にCoTaskLibを使わずに実装する場合、下記のように時間をもとに色を分岐する実装になると思います。
コード自体はシンプルですが、全体の経過時間をもとに判定しているので、途中に処理を挟みたくなった場合はそれぞれの秒数を順に後ろにずらしていく変更が必要になります。
void DrawStar(const ColorF& color)
{
Shape2D::Star(200, Scene::Center()).draw(color);
}
void Main()
{
Stopwatch stopwatch{ StartImmediately::Yes };
while (System::Update())
{
const Duration elapsed = stopwatch.elapsed();
// 経過時間に応じて色を切り替えて描画
if (elapsed < 1s)
{
DrawStar(Palette::Red);
}
else if (elapsed < 2s)
{
DrawStar(Palette::Blue);
}
else if (elapsed < 3s)
{
DrawStar(Palette::Yellow);
}
}
}
6. イージングで滑らかに値を変化させる(Ease
)
Ease
関数を使うことで、時間をかけて滑らかに値を変化させることができます。
co_await Co::Ease(変数のポインタ, 時間の長さ).to(目標値).play();
座標を移動する例
下記は、Vec2
型のm_position
を操作し、円の描画位置の座標を滑らかに変化させる例です。
class EaseExample : public Co::SequenceBase<>
{
private:
Vec2 m_position{ 100, 100 };
Co::Task<> start() override
{
// 0.5秒かけて(100, 100)から(400, 500)に移動
co_await Co::Ease(&m_position, 0.5s).to(400, 500).play();
// 0.2秒待機
co_await Co::Delay(0.2s);
// 0.5秒かけて(400, 500)から(700, 100)に移動
co_await Co::Ease(&m_position, 0.5s).to(700, 100).play();
// 0.2秒待機
co_await Co::Delay(0.2s);
}
void draw() const override
{
Circle{ m_position, 50 }.draw();
}
};
Co::Task<> MainTask()
{
co_await Co::Play<EaseExample>();
}
補足: イージング関数の種類について
Co::Ease
関数では、イージング関数にデフォルトでEaseOutQuad
(目標値に早めに近づく曲線カーブ)が指定されています。
異なるイージング関数を利用したい場合、setEase
関数で指定できます。
co_await Co::Ease(&m_value, 1s).to(1.0).setEase(EaseInQuad).play();
なお、イージングに線形関数を利用したい(直線的に推移させたい)場合、あらかじめ用意されているCo::LinearEase
関数が利用できます。
co_await Co::LinearEase(&m_value, 1s).to(1.0).play();
(参考) 上記の例をCoTaskLibを使わずに実装した場合のコード例
もし仮にCoTaskLibを使わずに実装する場合、状態を列挙型で表現する必要があるためソースコードが複雑になりやすいです。
class MovingCircle
{
public:
void update()
{
const double elapsedSec = stopwatch.sF();
double rate = 0.0;
switch (state)
{
case State::MoveDownRight:
// 0.5秒かけて(100,100)から(400,500)へ移動
rate = EaseOutQuad(Min(elapsedSec / 0.5, 1.0));
m_position.x = Math::Lerp(100.0, 400.0, rate);
m_position.y = Math::Lerp(100.0, 500.0, rate);
if (elapsedSec >= 0.5)
{
state = State::Wait1;
stopwatch.restart();
}
break;
case State::Wait1:
// 0.2秒待機
if (elapsedSec >= 0.2)
{
state = State::MoveUpRight;
stopwatch.restart();
}
break;
case State::MoveUpRight:
// 0.5秒かけて(400,500)から(700,100)へ移動
rate = EaseOutQuad(Min(elapsedSec / 0.5, 1.0));
m_position.x = Math::Lerp(400.0, 700.0, rate);
m_position.y = Math::Lerp(500.0, 100.0, rate);
if (elapsedSec >= 0.5)
{
state = State::Wait2;
stopwatch.restart();
}
break;
case State::Wait2:
// 0.2秒待機
if (elapsedSec >= 0.2)
{
state = State::Finished;
}
break;
default:
break;
}
}
void draw() const
{
Circle{ m_position, 50 }.draw();
}
bool isFinished() const
{
return state == State::Finished;
}
private:
enum class State
{
MoveDownRight,
Wait1,
MoveUpRight,
Wait2,
Finished,
};
Vec2 m_position{ 100, 100 };
Stopwatch stopwatch{ StartImmediately::Yes };
State state = State::MoveDownRight;
};
void Main()
{
MovingCircle movingCircle;
while (System::Update())
{
if (!movingCircle.isFinished())
{
movingCircle.update();
movingCircle.draw();
}
}
}
アルファ値を変化させてフェードイン・フェードアウトする例
下記は、double
型のm_alpha
を0~1で変化させ、指定したテキストをフェード表示する例です。
// テキストをフェード表示・クリック待機するシーケンス
class TextFadeSequence : public Co::SequenceBase<>
{
public:
explicit TextFadeSequence(StringView text)
: m_text(text)
{
}
private:
Font m_font{ 60 };
String m_text;
double m_alpha = 0.0;
bool m_isWaiting = false;
Co::Task<> start() override
{
// 1秒かけてアルファ値を1に変化させる
co_await Co::Ease(&m_alpha, 1s).to(1.0).play();
// クリックされるまで待機
m_isWaiting = true;
co_await Co::WaitUntilDown(MouseL);
m_isWaiting = false;
// 1秒かけてアルファ値を0に変化させる
co_await Co::Ease(&m_alpha, 1s).to(0.0).play();
}
void draw() const override
{
const ColorF textColor{ Palette::White, m_alpha };
m_font(m_text).drawAt(Scene::Center(), textColor);
if (m_isWaiting)
{
m_font(U"クリックで次へ").drawAt(40, Scene::Center().movedBy(0, 150));
}
}
};
Co::Task<> MainTask()
{
co_await Co::Play<TextFadeSequence>(U"コルーチンを使えば");
co_await Co::Play<TextFadeSequence>(U"待つ処理が");
co_await Co::Play<TextFadeSequence>(U"簡単に実装できます!");
co_await Co::Play<TextFadeSequence>(U"すごいでしょ?");
}
(参考) 上記の例をCoTaskLibを使わずに実装した場合のコード例
こちらの例も、CoTaskLibを使わないで実装すると複雑な状態管理が必要になります。
{
public:
explicit TextFade(StringView text)
: m_text(text)
{
}
void update()
{
const auto elapsedTime = m_stopwatch.elapsed();
switch (m_state)
{
case State::FadeIn: // フェードイン中
m_alpha = Min(elapsedTime / 1s, 1.0);
if (elapsedTime >= 1s)
{
m_state = State::Waiting;
m_stopwatch.restart();
}
break;
case State::Waiting: // クリック待ち
m_isWaiting = true;
if (MouseL.down())
{
m_isWaiting = false;
m_state = State::FadeOut;
m_stopwatch.restart();
}
break;
case State::FadeOut: // フェードアウト中
m_alpha = Max(1.0 - (elapsedTime / 1s), 0.0);
if (elapsedTime >= 1s)
{
m_state = State::Finished; // 完了
}
break;
default:
break;
}
}
void draw() const
{
const ColorF textColor{ Palette::White, m_alpha };
m_font(m_text).drawAt(Scene::Center(), textColor);
if (m_isWaiting)
{
m_font(U"クリックで次へ").drawAt(40, Scene::Center().movedBy(0, 150));
}
}
bool isFinished() const
{
return m_state == State::Finished;
}
private:
enum class State
{
FadeIn,
Waiting,
FadeOut,
Finished,
};
Font m_font{ 60 };
String m_text;
double m_alpha = 0.0;
bool m_isWaiting = false;
Stopwatch m_stopwatch{ StartImmediately::Yes };
State m_state = State::FadeIn;
};
void Main()
{
Array<String> texts = {
U"コルーチンを使えば",
U"待つ処理が",
U"簡単に実装できます!",
U"すごいでしょ?",
};
size_t index = 0;
Optional<TextFade> current;
while (System::Update())
{
if (index >= texts.size())
{
// 全テキストを表示済みのため何もしない
continue;
}
if (!current)
{
current = TextFade{ texts[index] };
}
current->update();
current->draw();
if (current->isFinished())
{
// 次のテキストへ
current.reset();
++index;
}
}
}
7. シーン遷移を実装する(SceneBase
)
シーンは、ゲーム内の大まかな画面(例: タイトル画面、ゲーム画面、リザルト画面など)を表す単位です。
シーケンスと少し似ていますが、シーン同士を互いに遷移できる点が異なります。
シーンを実装するには、下記のようにSceneBase
を継承したクラスを作成し、メンバ関数をオーバーライドします。
class ExampleScene1 : public Co::SceneBase
{
private:
Co::Task<> fadeIn() override
{
// フェードイン処理(startと同時に実行開始される)
co_await Co::ScreenFadeIn(1s);
}
Co::Task<> start() override
{
// シーン開始時に実行されるタスク
// 下記で次のシーン遷移先を指定できる
requestNextScene<ExampleScene2>();
}
Co::Task<> fadeOut() override
{
// フェードアウト処理(startの後に実行される)
co_await Co::ScreenFadeOut(1s);
}
void draw() const override
{
// シーンの毎フレームの描画処理
}
};
シーンを実行するには、下記のようにPlaySceneFrom<T>()
関数に最初のシーンを指定します。
// TitleSceneから始まる一連のシーン遷移を実行
co_await Co::PlaySceneFrom<TitleScene>();
シーンを利用したゲームサンプル
シーン機能を実際に活用して、サンプルゲームを実装してみました。
タイトル画面・ゲーム画面・結果画面の3つのシーンを備えた、シンプルなクリック連打ゲームです。
ソースコードを見る
#include <Siv3D.hpp>
#include <CoTaskLib.hpp>
// 前方宣言
class GameScene;
class ResultScene;
// タイトル画面
class TitleScene : public Co::SceneBase
{
private:
Font m_fontBold{ 60, Typeface::Bold };
Font m_font{ 30 };
Co::Task<> fadeIn() override
{
co_await Co::ScreenFadeIn(1s);
}
Co::Task<> start() override
{
// クリックしたら、ゲーム画面へ遷移
co_await Co::WaitUntilDown(MouseL);
requestNextScene<GameScene>();
}
Co::Task<> fadeOut() override
{
co_await Co::ScreenFadeOut(1s, Palette::White);
}
void draw() const override
{
m_fontBold(U"連打ゲーム").drawAt(Scene::Center().movedBy(0, -50));
ColorF messageColor;
if (isFadingOut())
{
// クリック後は点滅
messageColor = ColorF{ Palette::White, Periodic::Square0_1(0.1s) };
}
else
{
messageColor = ColorF{ Palette::White, Periodic::Sine0_1(2s) };
}
m_font(U"クリックで始めます").drawAt(Scene::Center().movedBy(0, 50), messageColor);
}
};
// ゲーム画面
class GameScene : public Co::SceneBase
{
private:
// メッセージ表示シーケンス
class ShowMessage : public Co::SequenceBase<>
{
public:
explicit ShowMessage(StringView text, Duration duration)
: m_text(text)
, m_duration(duration)
{
}
private:
Font m_font{ 120 };
String m_text;
Duration m_duration;
double m_scale = 1.0;
double m_alpha = 1.0;
Co::Task<> start() override
{
co_await Co::All(
Co::Ease(&m_scale, m_duration).to(1.2).play(),
Co::Ease(&m_alpha, m_duration).to(0.0).setEase(EaseInQuad).play());
}
void draw() const override
{
m_font(m_text).drawAt(120 * m_scale, Scene::Center(), ColorF{ Palette::Yellow, m_alpha });
}
};
Font m_font{ 80 };
Timer m_gameTimer{ 5s };
int32 m_clickCount = 0;
bool m_hasEnded = false;
Co::Task<> fadeIn() override
{
co_await Co::ScreenFadeIn(1s, Palette::White);
}
Co::Task<> start() override
{
// カウントダウン
co_await Co::Play<ShowMessage>(U"3", 1s);
co_await Co::Play<ShowMessage>(U"2", 1s);
co_await Co::Play<ShowMessage>(U"1", 1s);
// ゲーム処理
co_await Co::All(
Co::Play<ShowMessage>(U"START", 1.5s),
game());
co_await Co::Play<ShowMessage>(U"終了!", 1.5s);
// 結果画面へ遷移
requestNextScene<ResultScene>(m_clickCount);
}
Co::Task<> game()
{
// ゲームは毎フレームの処理として実装
co_await Co::UpdaterTask<void>(
[this] (Co::TaskFinishSource<void>& tfs)
{
if (MouseL.down())
{
// 連打回数をカウント
++m_clickCount;
}
if (m_gameTimer.reachedZero())
{
// ゲーム終了
tfs.requestFinish();
}
});
}
Co::Task<> fadeOut() override
{
co_await Co::ScreenFadeOut(1s);
}
void draw() const override
{
m_font(U"スコア: {}"_fmt(m_clickCount)).drawAt(Scene::Center().movedBy(0, -50));
m_font(U"残り{}秒"_fmt(m_gameTimer.s_ceil())).drawAt(40, Scene::Center().movedBy(0, 50));
}
};
// 結果画面
class ResultScene : public Co::SceneBase
{
public:
explicit ResultScene(int clickCount)
: m_clickCount(clickCount)
{
}
Co::Task<> fadeIn() override
{
co_await Co::ScreenFadeIn(1s);
}
Co::Task<> start() override
{
// クリックしたら、タイトル画面へ遷移
co_await Co::WaitUntilDown(MouseL);
requestNextScene<TitleScene>();
}
Co::Task<> fadeOut() override
{
co_await Co::ScreenFadeOut(1s);
}
void draw() const override
{
m_fontBold(U"結果").drawAt(Scene::Center().movedBy(0, -200));
m_fontBold(U"スコア: {}"_fmt(m_clickCount)).drawAt(Scene::Center().movedBy(0, -30));
m_font(U"クリックでタイトルに戻ります").drawAt(Scene::Center().movedBy(0, 100), ColorF{ Palette::White, Periodic::Sine0_1(2s) });
}
private:
Font m_fontBold{ 50, Typeface::Bold };
Font m_font{ 30 };
int m_clickCount;
};
Co::Task<> MainTask()
{
// 一連のシーン遷移をタイトル画面から開始
co_await Co::PlaySceneFrom<TitleScene>();
}
void Main()
{
Co::Init();
const auto runner = MainTask().runScoped();
while (System::Update())
{
}
}
(参考) 上記の例をCoTaskLibを使わずに実装した場合のコード例
Siv3Dにも標準のシーン管理機能があり、ある程度似たように実装できます。しかし、毎フレームの処理として実装する必要があるため、TimerやStopwatchを駆使して状態を自前で管理する必要があります。
また、シーン間の値の受け渡しにコンストラクタ引数を利用することができないため、グローバル変数に近い存在である共有データを利用して受け渡さないといけません。
#include <Siv3D.hpp>
struct SharedData
{
int32 resultScore = 0;
std::function<void(const ColorF&)> setFadeColor;
};
using App = SceneManager<String, SharedData>;
// タイトル画面
class TitleScene : public App::Scene
{
private:
Font m_fontBold{ 60, Typeface::Bold };
Font m_font{ 30 };
double m_messageAlpha = 0.0;
bool m_fadeOut = false;
public:
TitleScene(const InitData& init)
: IScene(init)
{
}
void update() override
{
m_messageAlpha = Periodic::Sine0_1(2s);
if (MouseL.down())
{
// クリックでゲーム画面に遷移
getData().setFadeColor(Palette::White);
changeScene(U"Game");
}
}
void updateFadeIn(double) override { update(); }
void updateFadeOut(double) override
{
// クリック後は点滅
m_messageAlpha = Periodic::Square0_1(0.1s);
}
void draw() const override
{
m_fontBold(U"連打ゲーム").drawAt(Scene::Center().movedBy(0, -50));
const ColorF messageColor{ Palette::White, m_messageAlpha };
m_font(U"クリックで始めます").drawAt(Scene::Center().movedBy(0, 50), messageColor);
}
};
// ゲーム画面
class GameScene : public App::Scene
{
private:
Font m_font{ 80 };
Font m_messageFont{ 120 };
Timer m_countdownTimer{ 3s, StartImmediately::Yes };
Timer m_startMessageTimer{ 1.5s, StartImmediately::No };
Timer m_gameTimer{ 5s, StartImmediately::No };
Timer m_endMessageTimer{ 1.5s, StartImmediately::No };
int32 m_clickCount = 0;
void drawMessage(StringView message, double rate) const
{
const double scale = Math::Lerp(1.0, 1.2, rate);
const double alpha = Math::Lerp(1.0, 0.0, rate);
const ColorF color{ Palette::Yellow, alpha };
m_messageFont(U"{}"_fmt(message)).drawAt(120 * scale, Scene::Center(), color);
}
public:
GameScene(const InitData& init)
: IScene(init)
{
}
void update() override
{
if (m_countdownTimer.isRunning())
{
// カウントダウン中(何もしない)
}
else if (m_countdownTimer.reachedZero() && !m_gameTimer.isStarted())
{
// カウントダウン後、ゲーム開始
m_gameTimer.start();
m_startMessageTimer.start();
}
else if (m_gameTimer.isRunning())
{
// ゲーム中
if (MouseL.down())
{
++m_clickCount;
}
}
else if (m_gameTimer.reachedZero() && !m_endMessageTimer.isStarted())
{
// ゲーム終了後、終了メッセージ表示
m_endMessageTimer.start();
}
else if (m_endMessageTimer.isRunning())
{
// 終了メッセージ中(何もしない)
}
else if (m_endMessageTimer.reachedZero())
{
// 終了メッセージ後、結果画面に遷移
getData().resultScore = m_clickCount;
getData().setFadeColor(Palette::Black);
changeScene(U"Result");
}
}
void draw() const override
{
// カウントダウン表示
if (m_countdownTimer.isRunning())
{
const int32 sec = m_countdownTimer.s_ceil();
const double rate = sec - m_countdownTimer.sF();
drawMessage(U"{}"_fmt(sec), rate);
}
// 終了メッセージ
if (m_endMessageTimer.isRunning())
{
const double rate = m_endMessageTimer.progress0_1();
drawMessage(U"終了!", rate);
}
m_font(U"スコア: {}"_fmt(m_clickCount)).drawAt(Scene::Center().movedBy(0, -50));
m_font(U"残り{}秒"_fmt(m_gameTimer.s_ceil())).drawAt(40, Scene::Center().movedBy(0, 50));
}
};
// 結果画面
class ResultScene : public App::Scene
{
private:
Font m_font{ 50, Typeface::Bold };
Font m_messageFont{ 30 };
int32 m_score;
public:
ResultScene(const InitData& init)
: IScene(init)
, m_score(getData().resultScore)
{
}
void update() override
{
if (MouseL.down())
{
changeScene(U"Title");
}
}
void draw() const override
{
m_font(U"結果").drawAt(Scene::Center().movedBy(0, -200));
m_font(U"スコア: {}"_fmt(m_score)).drawAt(Scene::Center().movedBy(0, -30));
m_messageFont(U"クリックでタイトルに戻ります").drawAt(Scene::Center().movedBy(0, 100), ColorF{ Palette::White, Periodic::Sine0_1(2s) });
}
};
void Main()
{
App manager;
manager.add<TitleScene>(U"Title");
manager.add<GameScene>(U"Game");
manager.add<ResultScene>(U"Result");
manager.get()->setFadeColor = [&] (const ColorF& color) { manager.setFadeColor(color); };
while (System::Update())
{
if (!manager.update())
{
break;
}
}
}
CoTaskLibを上手に利用するコツ
最後に、CoTaskLibを上手に利用するためのコツを紹介します。
★コツ1: runScoped
関数によるタスク実行はなるべく減らす
TaskのrunScoped
関数を利用すれば、通常の関数内からでもタスクを新たに実行することができます。
しかし、メインとなるタスク1個以外でrunScoped
関数を利用するのはなるべく避け、同時実行が必要な場合もなるべくco_await
とAny
/All
でやりくりすることをお勧めします。
下記は失敗例で、runScoped
関数で実行したことで値の受け渡しにグローバル変数が必要になってしまった例です。
// グローバル変数
Optional<int32> value;
Co::Task<> Task1()
{
// ... (何らかの処理)
value = 42; // 結果をvalueに代入
}
Co::Task<> Task2()
{
// valueに結果が入るまで待つ
co_await Co::WaitUntil([&] { return value.has_value(); });
// ... (valueを使った何らかの処理)
}
void Main()
{
Co::Init();
// 2つのタスクを同時に実行(runner変数が有効な間実行される)
const auto runner1 = MainTask1().runScoped();
const auto runner2 = MainTask2().runScoped();
while (System::Update())
{
}
}
複数のタスクをrunScopedで別々に実行していると、このように外部の変数(グローバル変数など)を使ってデータをやり取りする必要が出てしまう場面があります。
そうすると、現在実行中のどのタスクが処理の進行に影響しているのかが分かりづらくなり、バグを修正するときの原因究明が難しくなります。
runScopedはなるべく避け、co_await
で実行することで、下記のように処理の流れを明確にできます。
Co::Task<> Task1()
{
// ... (何らかの処理)
co_return 42; // 結果は戻り値で返す
}
Co::Task<> Task2(int32 value)
{
// ... (valueを使った何らかの処理)
}
Co::Task<> MainTask()
{
// Task1から戻り値を受け取り、それを使って次のTask2を実行
int32 value = co_await Task1();
co_await Task2(value);
}
★コツ2: 無理に全てタスクで実装しようとしない。update関数での実装も検討する
タスクを使うことで「待ち」を含む処理が簡単に書けるようになりました。
しかし、処理内容によってはタスクを使った実装よりも、毎フレームの処理(update関数)として実装する方が向いている場合があります。
-
タスクで実装する方が向いている例:
- ターン制バトル: ターン終了やユーザーの選択を待つ処理がタスクで楽に書けます
- ノベルゲーム: テキスト表示やクリックを待つ処理がタスクで楽に書けます
-
毎フレームの処理(update関数)として実装する方が向いている例:
- アクションゲーム: キャラクターを動かすために毎フレーム入力を処理する必要があります
- 音楽ゲーム: 入力をリアルタイムにタイミング判定するため毎フレーム入力を処理する必要があります
タスクで実装しにくい処理は無理にタスクとして実装しようとせず、適宜下記の機能を利用してupdate関数を併用して実装するのがコツです。
下記3つの機能を使えば、毎フレーム実行されるupdate関数をタスクとして利用することができます。
UpdaterTask
: タスクをupdate関数で記述
UpdaterTaskの使い方
下記3通りの書き方があります。
co_await Co::UpdaterTask(
[] ()
{
// 毎フレーム実行される
});
co_await Co::UpdaterTask<void>(
[] (Co::TaskFinishSource<void>& tfs)
{
// 毎フレーム実行される
if (/*何らかの条件を満たしたら*/)
{
// 下記で終了できる
tfs.requestFinish();
}
});
int32 result = co_await Co::UpdaterTask<int32>(
[] (Co::TaskFinishSource<int32>& tfs)
{
// 毎フレーム実行される
if (/*何らかの条件を満たしたら*/)
{
// 下記で戻り値を設定して終了できる
tfs.requestFinish(42);
}
});
UpdaterSequenceBase
: シーケンスをupdate関数で記述
UpdaterSequenceBaseの使い方
下記を通常のシーケンスと同じように利用できます。
class ExampleSequence : public Co::UpdaterSequenceBase<>
{
private:
void update() override
{
// ここに毎フレームの処理を記述
if (/*何らかの条件*/)
{
// シーケンスを終了させたい場合はrequestFinish関数を呼ぶ
requestFinish();
}
}
void draw() const override
{
// ここに毎フレームの描画処理を記述
}
}
UpdaterSceneBase
: シーンをupdate関数で記述
UpdaterSceneBaseの使い方
下記を通常のシーンと同じように利用できます。
class ExampleScene1 : public Co::UpdaterSceneBase
{
private:
Co::Task<> fadeIn() override
{
// フェードイン処理(updateと同時に実行される)
co_await Co::ScreenFadeIn(1s);
}
void update() override
{
// ここに毎フレームの処理を記述
if (/*何らかの条件*/)
{
// 下記で次のシーンへ遷移できる
requestNextScene<GameScene>();
}
if (/*何らかの条件*/)
{
// 下記でシーン遷移を終了できる
requestSceneFinish();
}
}
Co::Task<> fadeOut() override
{
// フェードアウト処理(update内でrequest系の関数を呼んで終了した後に実行される)
co_await Co::ScreenFadeOut(1s);
}
void draw() const override
{
// シーンの毎フレームの描画処理
}
};
まとめ
Siv3D用コルーチンライブラリCoTaskLibを使えば、「待ち」を含む処理を簡単に書けます。
- タスクとは、コルーチンの構文を使って書ける「複数フレームをまたぐことができる関数」
- シーケンスとは「タスクとdraw関数をセットにした、描画処理を含むタスク」
- イージング機能(
Ease
)を使えば、値を滑らかに推移させられる - シーン機能を利用することで、ゲーム内の大まかな画面ごとの処理もタスクとして実装できる