Siv3Dで作る かぼちゃの煮つけ

この記事はSiv3D Advent Calendar 2016 19日目の記事です。

Advent Calenderっていうのは、クリスマス限定の凝った日めくりみたいなやつです。ほら、あの、指で箱とかカードとかを「ベリッ」って破くと、中の絵柄が見えて楽しいやつ。

はじめに

さあ!

さあ!

ついに!

今年もッ!!

やってまいりましたあ!!!

冬至! 冬至でございます!!!

まあ、冬至は21日なので、今日じゃないんだけどね。

冬至!

冬至といえば!

そう、かぼちゃです!!!!!!!

私ニシャスは冬至にかぼちゃの煮物を食べることを強力に推進しており、ここ最近はTwitterで布教しようと冬至の日やハロウィンの日などに地道な活動をしているのですが、 今はホームページをリニューアル中なので、その進行も兼ねてこうして記事を書いておるわけですね。

しかも、今回はSiv3Dのアドベントカレンダー記事を執筆することになり、いつもとは違った方面から冬至のかぼちゃを布教する機会を得たわけであります。

Siv3Dとは

アドベントカレンダー経由でこのサイトに来た方にとっては全くの不要ですが、そうでない方にとってご存じない単語だと思われるので説明をしておきますと、Siv3Dというのは、

Siv3D は C++ で楽しく簡単にゲームやメディアアートを作れるライブラリです。

"Play Siv3D!" http://play-siv3d.hateblo.jp/

ということで、プログラミングするときに一緒に使うと、効率的にゲームなどを作れるツール、それがSiv3Dであります。

ちなみに名前からして3D専門かと思いきや別にそうでもなくて2Dゲームもバリバリ作れます。

Siv3Dのプロジェクトを新規作成するだけで1行もコードを打たずにウィンドウを出せたりするので、ゲーム作りにじっくり注力できるわけです。

そのSiv3Dがかぼちゃの煮物と何の関係が?

実際、「ゲームとマルチメディアのためのC++用ライブラリ」と、「冬至に欠かせないかぼちゃの煮付け」をどう関連させるかというのは難しく、2日ほど考えました。キッチンスケールとPCを繋いで重量を読ませたりできると良かったのですがそんな秤は殆ど出回っていないので、 結局、料理補佐プログラムを作ろうという事になりました。

では、作りますぞ。

プログラムはとりあえず置いておいて、先に煮物の方を作ってみます。素材用にひたすら写真を撮ります。画像素材は大事ですからね。

いつもは割と適当に作りますが今回はネット上のレシピ等を参考にして作りました。なかなか美味しいじゃないの。ただちょっと甘いから砂糖は減量な。

ひとまず作ったかぼちゃの煮つけ

煮物を作ったら、ソフトの方で何をさせればいいかを考えます。

  • クリックで手順を進めるようにする
    • かぼちゃの計量
    • かぼちゃを切る
    • 調味料を準備
    • etc...
  • かぼちゃの重量を打ち込ませる
  • かぼちゃの重量から、調味料の使用量を提示する
  • 音声でナビを行う
  • あとは写真を出してわかりやすくする

まあこんなものでしょう。

使うものと書くコード

シーンマネージャー

今回はSiv3Dに付属しているHamFrameworkのSceneManagerを使用することにしました。

ゲームを作る際にはタイトル画面やオプション画面といった場面(シーン)の遷移に使われるクラスですが、今回のプログラムも調理中のそれぞれの手順をシーンとみなせるため、活用可能と判断しました。

どう手を抜くか

野菜を切る、調味料を提示する、煮る、など、多くのシーンを使いますが、基本的にはどのシーンも文字と写真を出して手順を知らせるだけです。似たような処理が続くので俄然手を抜きたくなります。

理想的な処理の構想

テキストの表示、画像の表示用にクラスを作っておいて、あとはシーンのフレームごとの更新処理、描画処理は1行で済ませられるようにします。

クラスを用意する

そうするとテキストと画像用に、「裏で面倒なことをやってくれるクラス」を作ることになりそうです。

あとは、プレゼンテーションのような動かし方をするので、文字や画像の表示の間に待ち時間を入れる必要もありそうです。待ち時間といえば、煮物なので煮る時間を計るタイマーも欲しいです。なので、

を作ることにしました。とりあえず作ったコードを載せておきます。行き当たりばったりでコード打ってるので、色々と突っ込まれたら土下座するしかない. constとか普段使わんし

CookingTextクラス


    class CookingText
    {
    public:
    	CookingText::CookingText(Vec2 pt = Vec2(0,0), String str_disp = L"", String str_say = L"", int drawarea_width = 0) :
        pt(pt), str_display(str_disp), str_say(str_say), drawarea_width(drawarea_width) {}

    	bool update()
    	{
    		if (!active)
    		{
    			sw.start();
    			if(str_say != L"")Say(str_say);
    			active = true;
    		}
    		else
    		{
    			if (Input::MouseL.clicked && !display_complete)
    			{
    				display_complete = true;
    				return false;
    			}
    			disp_char_length = sw.ms() / wait_ms_per_char;
    			if (disp_char_length > str_display.length || display_complete)
    			{
    				disp_char_length = str_display.length;
    				display_complete = true;
    			}
    		}
    		return display_complete;
    	}
    	bool draw() const
    	{
    		if (!active)return false;
    		if(drawarea_width > 0)DrawTextInStrictedWidth(str_display.substr(0,disp_char_length), font, drawarea_width,pt);
    		else font.draw(str_display.substr(0,disp_char_length), pt);
    		return display_complete;
    	}
    	void change_fontsize(int newsize)
    	{
    		font.release();
    		font = Font(newsize);
    	}

    	bool		display_complete = false;

    private:
    	Font		font = Font(12);					//フォント
    	int			drawarea_width = 0;					//0以外を指定するとその幅以内に描画する
    	Vec2		pt;									//描画位置
    	int			wait_ms_per_char = 100;				//文字送りのウェイト
    	size_t		disp_char_length = 0;				//表示されている文字数
    	bool		active = false;						//1回でもupdateされたらtrue
    	String		str_display;						//表示される文字の全貌
    	String		str_say;							//喋る文字の内容
    	Stopwatch	sw;									//ストップウォッチ。この時間で文字を表示する。最初のupdateで動作開始。

    };

CookingImageクラス


    class CookingImage
    {
    public:
    	CookingImage::CookingImage(Texture texture, Vec2 pos = Vec2(0, 0)) : texture(texture), pos(pos) {}

    	bool update()
    	{
    		if (!active)sw.start();
    		active = true;
    		return (sw.ms() >= 1000);
    	}

    	bool draw() const
    	{
    		if (!active)return false;
    		texture.draw(pos, Color(255, 255, 255, (sw.ms() >= 1000) ? 255 : sw.ms() / 4));
    		return (sw.ms() >= 1000);
    	}

    private:
    	bool		active = false;				//1回でもupdateされるとtrueになる
    	Vec2		pos;						//描画位置
    	Texture		texture;					//texture
    	Stopwatch	sw;							//最初のupdateで動き出しアニメーションの時間基準になる

    };

CookingWaitクラス


    class CookingWait
    {
    public:
    	CookingWait::CookingWait(int waittime_ms = 1000) : waittime(waittime_ms) {}

    	bool update()
    	{
    		if (!active)sw.start();
    		active = true;
    		return (sw.ms() >= waittime);
    	}

    private:
    	bool		active = false;
    	int			waittime = 500;
    	Stopwatch	sw;
    };

これらの主な動きとしては

といった感じです。

CookingTimerクラス


    class CookingTimer
    {
    public:
    	CookingTimer(int32 init_sec = 180, Vec2 center = Vec2(0,0)) : setting_sec(init_sec), center(center) {}
    	bool update()
    	{
    		if (sw.s() >= setting_sec)
    		{
    			snd.play();
    			return true;
    		}
    		else
    		{
                if (!(sw.s() & 31))SetThreadExecutionState(ES_SYSTEM_REQUIRED);
    			if (timer_circle.movedBy(center).leftClicked)
    			{
    				if (!sw.isActive() || sw.isPaused())sw.start();
    				else sw.pause();
    			}
                if (!sw.isPaused() && setting_sec >= 120 && setting_sec - sw.s() == 60)
    			{
    				if (!Speech::IsSpeaking())Say(L"残り1分です");
    			}
    		}
    		return false;
    	}
    	void draw() const
    	{
    		double rad = (sw.s() >= setting_sec) ? 0 : 2.0 * Pi * (setting_sec -  sw.s()) / setting_sec;
    		timer_circle.movedBy(center).drawArc(0, rad, 40, 0, Palette::Aquamarine);
    		timer_circle.movedBy(center).drawFrame(1, 2, Palette::White);
    		if (sw.s() >= setting_sec)
    		{
    			if(!((sw.ms() / 500) & 1))font(L"■時間です■").drawAt(center, Palette::Red);
    		}
    		else
    		{
    			font(L"残り" + ToString((setting_sec - sw.s()) / 60) + L"分 " + ToString((setting_sec - sw.s()) % 60) + L"秒").drawAt(center);
    			if (!sw.isActive())
    			{
    				font(L"クリックで開始").regionCenter(center + Vec2(0, 25)).stretched(5).draw(Palette::Green); //font.draw()系はRectを返すのでまとめられる
    				font(L"クリックで開始").drawCenter(center + Vec2(0, 25), Palette::Yellow);
    			}
    			if (sw.isPaused())font(L"■一時停止中■").drawAt(center + Vec2(0, 25), Palette::Red);
    		}
    	}

    private:
    	int32		setting_sec;
    	Vec2		center{ 0,0 };
    	Stopwatch	sw;
    	Circle		timer_circle{ 100 };
    	Font		font{ 11 };
    	Sound		snd{ L"sound\\alarm.wav",SoundLoop::All };
    };

クッキングタイマーのクラスは表示に拘っていますが、時間の計測機能にアラームや残り1分を伝える機能、システムスタンバイを抑止する機能を付けた程度です。

ちなみにCookingTextで呼んでいる、「指定幅内で改行させるための関数」(DrawTextInStrictedWidth)は次のような内容です。


    int DrawTextInStrictedWidth(const String	&str, const Font &font, double width = 0, Vec2 pos = Vec2{ (0.0),(0.0) },
        const Color &color = Palette::White, double lineheight = 1.0)
    {
    	if (width == 0)width = 9999;
    	String			drawstr;
    	unsigned int	l;
    	size_t			char_len = font.drawableCharacters(str, width);
    	if (char_len == 0)return 0;

    	for (l = 0; l*char_len < str.length; l++)
    	{
    		drawstr += str.substr(l*char_len, char_len);
    		drawstr += L"\n";
    	}
    	font.draw(drawstr, pos, color, lineheight);
    	return l;
    }

本プログラムではこれで充分ですが、元の文字列に改行が入っているとうまく動作しないなど、かなりいい加減な関数なので、 よい子はこちらを参考にしたほうが良いでしょう。 ※将来バージョンで、矩形を引数に取るとその内部に描画する機能が実装されるかもしれません。

それぞれのシーンを作る

ここまで作っておくと、最初の構想通りにコードを書くことができ、各調理シーンの制作が非常に簡単になります。何いィーーッ、パワポで作ったほうが簡単だとーーッ!?


    class Cut : public  MyApp::Scene
    {
    public:
    	CookingText			text1 = CookingText(Vec2(30, 20), L"かぼちゃを切る", L"");
    	CookingImage		image1 = CookingImage(Texture(L"Image\\cut.jpg"), Vec2(150, 110));
    	CookingText			text2 = CookingText(Vec2(50, 380), L"かぼちゃのワタを取り、切ります。\n硬くて切れない場合は、ラップをして電子レンジで2分前後加熱すると\nよいでしょう。", L"かぼちゃのワタを取り、切ります。\n硬くて切れない場合は、ラップをして、電子レンジで2分前後、加熱すると良いでしょう。");

    	void init() override	{	text1.change_fontsize(24);	}
    	void update() override
    	{
    		if (!text1.update())return;
    		if (!image1.update())return;
    		if (!text2.update())return;
    		if (Input::MouseL.clicked)changeScene(L"Fire");
    	}
    	void draw() const override
    	{
    		text1.draw();
    		image1.draw();
    		text2.draw();
    	}
    };


    class Fire : public  MyApp::Scene
    {
    public:
    	CookingText		text1 = CookingText(Vec2(30, 20), L"火にかける", L"");
    	CookingImage	image1 = CookingImage(Texture(L"Image\\boil1.jpg"), Vec2(0, 80));
    	CookingText		text2 = CookingText(Vec2(350, 120), L"かぼちゃと調味料を鍋に入れ、加熱します。調味料は、全て加えます。続いて、鍋が沸騰するまでの間に落とし蓋を用意します。(続く)", L"かぼちゃと調味料を鍋に入れ、加熱します。調味料は、全て加えます。続いて、鍋が沸騰するまでの間に落としぶたを用意します。", 260);

    	void init() override {	text1.change_fontsize(24);	}
    	void update() override
    	{
    		if (!text1.update())return;
    		if (!image1.update())return;
    		if (!text2.update())return;
    		if (Input::MouseL.clicked)changeScene(L"Otoshibuta");
    	}
    	void draw() const override
    	{
    		text1.draw();
    		image1.draw();
    		text2.draw();
    	}
    };



    class Otoshibuta : public  MyApp::Scene
    {
    public:
    	CookingText		text1 = CookingText(Vec2(30, 20), L"落とし蓋", L"");
    	CookingImage	image1 = CookingImage(Texture(L"Image\\otoshibuta.jpg"), Vec2(160, 80));
    	CookingText		text2 = CookingText(Vec2(30, 360), L"沸騰するまでの間に、落とし蓋を用意します。\n落とし蓋は、アルミホイルや、キッチンペーパーで作ることができます。\n鍋の形にだいたい大きさを合わせ、一部穴を空けておきます。",
    														L"沸騰するまでの間に、落としぶたを用意します。落としぶたは、アルミホイルや、キッチンペーパーで作ることができます。鍋の形にだいたい大きさを合わせ、一部、穴を空けておきます。", 0);

    	void init() override { text1.change_fontsize(24); }
    	void update() override
    	{
    		if (!text1.update())return;
    		if (!image1.update())return;
    		if (!text2.update())return;
    		if (Input::MouseL.clicked)changeScene(L"Boil");
    	}
    	void draw() const override
    	{
    		text1.draw();
    		image1.draw();
    		text2.draw();
    	}
    };

上記3つともメンバ変数の初期化以外は殆ど同じことをしているので、クラス化して更に短くすることもできると思います(初期化宣言が凄いことになりそうだが……)

単一のシーンを実行するとこんな感じです。

シーン実行アニメ

他のシーンもタイマーや入力フォームなどを使いますが、基本的にはこんな感じで作っていきます。

技術的な話

アドベントカレンダーっぽく、多少技術的な話もしておいたほうが良いかと思うので参考程度に。

Say関数について

Siv3Dに最近(August-2016版)に追加されたSay関数のお話。

Say関数で読み上げ中に再度Say関数を実行すると、読み上げ内容が中断されて新しい文字列の読み上げを行います。こうした割り込みを抑止したい場合は、Speech::IsSpeaking関数を使うと、喋っている途中かどうかの判断ができます。

また、音量を変えたい場合はSpeech::SetVolume関数で変更ができます。

ちなみに表示と読み上げを同時に行う今回のようなプログラムの場合は、表示用の文字列と読み上げ用の文字列を共通にしてしまうと読み上げ内容が不自然になる場合がそれなりに出ます。 「中火」を「なかび」、「大さじ」を「だいさじ」と読むなどするので、 あまり気にかけない場合を除いて両者の文字列は分けて、固定の文章を読ませるような場合は一度テストで聞いてやり、読み上げ内容に問題がないか確認したほうが良いでしょう。

changeSceneについて

HamframeworkのchangeSceneでシーンを入れ替えて、元のシーンに戻す場合、元のシーンのクラスの内容は破棄されているので、「煮ている時間に暇つぶしのゲームをさせたい」ような場合、 ストップウォッチをシーンのクラスのメンバに入れていると、ゲームを終えて戻った際にはストップウォッチは"新品に変わっていて"、計測はされていません。 こういう場合はStopwatchを共通データの中に入れる必要が出ます。 ゲームのシーン遷移であれば、例えばタイトル画面に戻った場合はアニメーションなどを最初からやり直したいと考えるのが普通なので、 別にそういう動作で何の不思議もないのですが。

テキストボックスの設置

ユーザーにかぼちゃの重量を入力させたかったので、Siv3DのGUI機能を使えば簡単だろうと思ったのですが、ダイアログボックスを出すのは簡単なものの、メインのウィンドウにコントロールを貼り付けるような動作は想定されていない模様で、 背景色を透明にしたり余白を削ったりして強引にやってしまいました。ただ、もしかしたら別の方法でできるのかも。

実行画面

初期化時にこんなコードを書いています。


    gui.show(false);
    gui.add(L"Edit1", GUITextField::Create(none));
    gui.style.padding = 0;
    gui.style.background.color = Color(0, 0, 0, 0);
    gui.style.borderColor = Color(0, 0, 0, 0);
    gui.setPos(Point(200, 430));

ダウンロード

プログラムの全容はここに記述してもアレなので、実行ファイルとともに置いておきます。

ちなみに本ソフトは、

今年の冬至にかぼちゃの煮つけを作らないといけないライセンス

……になっていたりはしませんが、折角だからみんなかぼちゃの煮つけ作ってね。ソースコード付きでMITライセンスなので、自分好みの味にレシピをカスタマイズ=プログラムを書き換えして新たに配布することもできるぞッ! 君だけの最強のかぼちゃの煮つけを作って友達に差をつけろ! ちなみに作ったソフトでの煮付け試作は時間がなくてできなかったから、鍋とか焦がしたらごめんね

その他やりたかったこと

アドベントカレンダーに間に合わせないといけなかったので粗があったり修正したい点がいくらかあったりします。

例えばかぼちゃの量に想定外の(1トンとか)重量が入力されると入れる調味料が大さじ1000杯とかになってしまうので、「日が暮れるぞ?」って表示させてゲームオーバーになる分岐を作りたかったとか、 裏技を入力するとかぼちゃのポタージュのレシピに切り替わえても良かったかなとか、重量入力時に「中1個」とかのボタン並べて選ばせたりとか。

タブレット持って買い物に行く人用にかぼちゃの選び方から説明しても良かったかもしれぬ。

こういうのは想像力が掻き立てられますね。

まあ、

何はともあれ、

今年の冬至は12月21日です。投稿日が冬至というわけではないので、すぐご覧になったのなら、まだまだ間に合いますよ!

え? ゆず湯? そんなものする必要があるのかね?