OpenSiv3Dで音ゲーのような何かをつくってみる【サマーブログリレー2019 9日目】
この記事はICONサマーブログリレー2019 9日目の記事です。
はじめに
こんばんは、プロ班の 杞憂 です。
(遅刻しました。大変申し訳ございません。)
9月に入ってから、BlenderでモデリングしたりBlenderモデリングの動画を観たりしていて、全然プログラミングしていません。それでいいのか。プロ班なのに。
まあ今後Blender勉強会とかあれば以前よりも戦力にはなるんじゃないかなと思います。
ところで、一応この記事がサマーブログリレー2019のトリらしいですね。
トリっぽいことを書く予定はありませんが。
参りましょう。
準備
つくりたいゲーム:4レーン、ノーツが上から下へ降ってくるシンプルな音ゲー
使うツール:OpenSiv3D (v0.4.0)
Siv3Dとは?
Siv3Dは、ゲームやメディアアートの制作に役立つC++ライブラリです。ゲーム制作がラクになるツールです。
ちなみに、Siv3Dをより実用的でモダンな設計に置き換えた(現在開発中のため一部機能が未実装)ものがOpenSiv3Dです。
なぜSiv3Dでつくるのか?
少し前に、DxLibで音ゲーのような何かをつくってみましたが、
(設計とかあまり考えずに書いたので分かりづらいコードに...)
今回はSiv3Dを選択しました。
DxLibとSiv3DはともにC++ライブラリですが、前者は関数ベース、後者はクラスベースでつくられています。私はDxLibでゲームを制作していた時もクラスとかじゃんじゃんつくっていたので、クラスベースでも特に問題なかったです。チラッとソースコード読みにいった時にtemplateとか学べて、むしろ勉強になりました。
また、Siv3Dにはゲーム制作において便利な機能が多く備わっています。
基本的な図形や当たり判定、キーボード/マウス入力、簡単なエフェクト、アセット管理、テキストファイル、シーン遷移などなど、ゲーム制作においてほぼ必須となる機能はほとんど用意されています。
(個人的にはUnityでもお世話になっているNavMeshが存在していることに驚きました。)
以上より、私はSiv3Dの方がより現代的で、よりラクにゲーム制作ができると感じ、こちらを選びました。
なぜOpenSiv3Dでつくるのか
Siv3DでもOpenSiv3Dでもつくれると思うのですが、私の環境だとOpenSiv3Dしかうまく導入できなかったのでOpenSiv3Dでつくります。どうせなら新しいほうがいいな~とも思っていたので結果オーライ。
また、うちにはWindowsしかないので確認はしていませんが、マルチプラットフォームでもあるのでMacやLinuxユーザの方も使えるみたいです。
(Siv3Dはプロジェクトテンプレートには表示されるのですがいざビルドするとエラーが出るし、どうやらツールセットのバージョンを140に下げないといけないみたいで諦めました。VisualStudioを2019にしてしまったことを後悔しています...)
OpenSiv3Dの導入
こちらのページの「OpenSiv3D SDK のインストール」に従ってOpenSiv3D(v0.4.0)を導入してください。
また、必要な環境も上記ページに書かれています。Windows 7 SP1 / 8.1 / 10 の方は Visual Studio 2019 version 16.1 が必要となります。
制作開始
事前に下の過去記事を読んでいただけますと理解が深まるかと思います。
(私もこちらの記事からいろいろ学びました)
プロジェクトの作成
OpenSiv3Dのプロジェクトを作成します。
(プロジェクトの保存場所を把握しておいてください。後で必要になります。)
ノーツが降ってくるようにする
コードを見る
#include <Siv3D.hpp> // OpenSiv3D v0.4.0 static const int32 LANE_NUM = 4; static const int32 JUDGE_Y = 540; class Note { double _perfectTime; // constつけるとなぜかエラー double _x; // constつけるとなぜかエラー double _y; static const int32 _WIDTH = 50; static const int32 _HEIGHT = 20; public: Note(double p, double x) : _perfectTime(p), _x(x) { } void update(double t) { // ノーツが画面に出現してから2秒で判定ラインに到達する _y = JUDGE_Y / 2 * (t - _perfectTime) + JUDGE_Y; } bool isErasable(double t) { // ノーツが判定ラインを超えてから2秒後に自身を消去 if (2 < (t - _perfectTime)) { return true; } return false; } void draw() const { Rect((int32)_x - _WIDTH / 2, (int32)_y - _HEIGHT / 2, _WIDTH, _HEIGHT).draw(Palette::Greenyellow); } }; class NoteManager { Array<Array<Note>> _notesList; public: NoteManager() { for (size_t i = 0; i < LANE_NUM; i++) // auto i : step(LANENUM)だとWarning { _notesList << Array<Note>(); } _notesList[0] << Note(2, 140); _notesList[0] << Note(3, 140); _notesList[1] << Note(3.5, 230); } void update(double t) { for (auto& notes : _notesList) { for (auto& note : notes) { note.update(t); } } for (auto& notes : _notesList) { for (auto it = notes.begin(); it != notes.end(); it++) { // 消去フラグが立っていたら消去 if ((*it).isErasable(t)) { it = notes.erase(it); break; } } } } void draw() const { //String str = U""; for (const auto& notes : _notesList) { for (const auto& note : notes) { note.draw(); } //str += Format(notes.size()) + U" "; } //ClearPrint(); //Print << str; } }; void Main() { Window::SetTitle(U"Otoge no Youna Nanika"); NoteManager _noteManager; const Font _titleFont{ 26 }; const Font _versionFont{ 16 }; Scene::SetBackground(ColorF(0.5, 0.6, 0.7)); const double startTime = Scene::Time(); while (System::Update()) { ///// 更新 ///// const double nowTime = Scene::Time() - startTime; _noteManager.update(nowTime); ///// 描画 ///// for (auto i : step(LANE_NUM)) { Rect(100 + i * 90, 0, 80, 600).draw(); } Line(80, JUDGE_Y, 470, JUDGE_Y).draw(4, Palette::Blue); _titleFont(U"Otoge no Youna Nanika").drawAt(620, 270); _versionFont(U"ver. summer blog 2019").drawAt(620, 300); _noteManager.draw(); } }
このコードを実行すると、下の写真のようにノーツが降ってきます。
NoteManagerクラスのdraw()関数内のコメントアウト4つの//を外すと、左上に各レーンのノーツの総数が表示されると思います。ノーツが判定ラインを越えた少し後にノーツが削除される様子が確認できると思います。
ノーツを表しているNoteクラスはノーツを叩くべきタイミング_perfectTime
とノーツのX座標_x
、Y座標_y
を持っています。update()関数でY座標を毎フレーム更新して、draw()関数でノーツを描画しています。
ノーツをレーンごと配列で保持しているのがNoteManagerクラスです。保持している全てのノーツのupdate()、draw()、isErasable()を行い、isErasable()でtrueを返してきたノーツを削除します。コンストラクタ中の
_notesList[0] << Note(2, 140); _notesList[0] << Note(3, 140); _notesList[1] << Note(3.5, 230);
で配列にノーツを追加しています。
ちなみに、for (auto i : step(N))
はfor (int i = 0; i < N; i++)
と同じ意味です。1
また、for (auto& item : items)
は範囲for文です。foreach文みたいなものです。
譜面を読み込む
_notesList[レーン番号] << Note(叩くべきタイミング, X座標);
を大量に書くのはつらいので、外部に用意したテキストファイルから譜面データを読み込む形式にしたいと思います。
先程プロジェクトを作成した際に保存した場所に、名前がプロジェクト名のフォルダがあると思います。そのフォルダの中にプロジェクト名.sln
があるはずです。おそらくそれと同じ階層に、名前がプロジェクト名のフォルダがまたあると思います。そのフォルダの中にAppフォルダがあると思います。ありましたか?
(私はOtogeTest20190923プロジェクトを~/Visual Studio 2019/Projects/
に保存したので下の写真のような位置にAppフォルダがあります)
Appフォルダの中にhumen.txt
を作成してください。テキストファイルの中身は下の譜面データをコピペしてください。
譜面データを見る
BPM:134 OFFSET:-1.3 #START 00100100,10000100,00010000,00000001 0100,01,01010000,00010000 10000100,10010000,00100001,00110100 00010000,00101000,00001000,01000000 10110101, 01111000, 11110111, , 10110101, 01111000, 10110101, 01111000, 10110101, 01111000, 11110111, 00000011, 10101111, 11010001, 10101111, 01110011, 10101111, 11010001, 10101111, 01100011,
今回使用する譜面データの形式は、「太鼓さん次郎」さんの譜面データ形式を4レーン用に拡張したものです。
1レーンの1小節目,2レーンの1小節目,3レーンの1小節目,4レーンの1小節目 1レーンの2小節目,2レーンの2小節目 1レーンの3小節目 1レーンの4小節目,2レーンの4小節目,3レーンの4小節目,4レーンの4小節目
上のようなイメージです。2、3行目のように省略することもできます。
ここまでできたら、先程のコードに下のHumenクラスを追加して、
Humenクラスを見る
class Humen { double _bpm = 120; double _offset = 0; public: Array<Array<double>> perfectTimes; Humen() { for (size_t i = 0; i < LANE_NUM; i++) { perfectTimes << Array<double>(); } } void load() { TextReader reader(U"humen.txt"); if (!reader) { throw Error(U"Failed to open `humen.txt`"); } String line; while (reader.readLine(line)) { auto list = line.split(':'); if (list[0] == U"#START") { break; } if (list[0] == U"BPM") { _bpm = Parse<double>(list[1]); } else if (list[0] == U"OFFSET") { _offset = Parse<double>(list[1]); } } for (size_t row = 0; reader.readLine(line); row++) { auto list = line.split(','); for (auto col : step(list.size())) { String word = list[col]; for (auto i : step(word.size())) { if ('1' <= word[i] && word[i] <= '9') { perfectTimes[col] << 60 * 4 * (row + (double)i / word.size()) / _bpm - _offset; } } } } } };
NoteManagerクラスのコンストラクタ中の
_notesList[0] << Note(2, 140); _notesList[0] << Note(3, 140); _notesList[1] << Note(3.5, 230);
を、
Humen humen; humen.load(); for (auto laneNum : step(LANE_NUM)) { for (auto i : step(humen.perfectTimes[laneNum].size())) { _notesList[laneNum] << Note(humen.perfectTimes[laneNum][i], 140 + 90 * laneNum); } }
に置き換えて実行してみてください。
Humenクラスはload()関数でhumen.txt
を開いて1行ずつ読み込み、譜面データをメンバ変数や配列に格納します。NoteManagerはHumenクラスの配列を利用してノーツを生成し配列に追加しています。
曲&効果音を流す
曲と、キーを押したときに鳴る効果音を追加していきたいと思います。
先程のコードを下のように追加・修正してください。
コードを見る
#include <Siv3D.hpp> // OpenSiv3D v0.4.0 static const int32 LANE_NUM = 4; static const int32 JUDGE_Y = 540; static const Array<Key> keys = { KeyZ, KeyX, KeyC, KeyV }; ~(略)~ class Game { NoteManager _noteManager; const Array<String> _strKeys = { U"Z", U"X", U"C", U"V" }; const Font _keyFont{ 50 }; const Font _titleFont{ 26 }; const Font _versionFont{ 16 }; public: Game() { AudioAsset(U"BGM").play(); Scene::SetBackground(ColorF(0.5, 0.6, 0.7)); } void update() { _noteManager.update(AudioAsset(U"BGM").posSec()); } void draw() const { for (auto i : step(LANE_NUM)) { Rect(100 + i * 90, 0, 80, 600).draw(Palette::White); } Line(80, JUDGE_Y, 470, JUDGE_Y).draw(4, Palette::Blue); _titleFont(U"Otoge no Youna Nanica").drawAt(Vec2(620, 270), Palette::White); _versionFont(U"ver. summer blog 2019").drawAt(Vec2(620, 300), Palette::White); for (auto i : step(LANE_NUM)) { if (keys[i].down()) { AudioAsset(U"Sound").playOneShot(); } } for (auto i : step(LANE_NUM)) { Color keyColor; keyColor = keys[i].pressed() ? Palette::Skyblue : Palette::Gray; _keyFont(_strKeys[i]).drawAt(Vec2(140 + 90 * i, 570), keyColor); } _noteManager.draw(); } }; void Main() { AudioAsset::Register(U"BGM", U"example/test.mp3"); AudioAsset::Register(U"Sound", GMInstrument::Piano1, PianoKey::A4, 0.5s, AssetParameter::LoadImmediately()); Game game; while (System::Update()) { game.update(); game.draw(); } }
このコードを実行すると、曲が流れ、ZXCVキーのどれかを押すと効果音が流れると思います。
GameクラスはMain()関数内のコードをクラスにまとめただけです。
ところで、_noteManager.update(nowTime);
がしれっと_noteManager.update(AudioAsset(U"BGM").posSec());
に代わっています。元々nowTime = 現在時刻 - 開始時刻
で時間を取得していたのですが、流している曲の再生位置を利用することで曲の一時停止とノーツの一時停止を同期できそう、というメリットがあるのでこのようにしています。一時停止は試していないので本当にできるかどうかはわかりません()
ちなみに、Appフォルダに好きな曲や効果音を入れて、
AudioAsset::Register(U"BGM", U"yourFavoriteSong.mp3"); AudioAsset::Register(U"Sound", U"yourFavoriteSE.wav");
とすれば好きな曲や効果音が流れます。
ノーツを叩けるようにする
といってもノーツを叩くだけなら数行追加するだけで良いので、ついでにキーの押下状態も表示するようにしてみましょう。
コードを見る
#include <Siv3D.hpp> // OpenSiv3D v0.4.0 static const int32 LANE_NUM = 4; static const int32 JUDGE_Y = 540; static const Array<Key> keys = { KeyZ, KeyX, KeyC, KeyV }; class Note { double _perfectTime; double _x; double _y; static const int32 _WIDTH = 50; static const int32 _HEIGHT = 20; Key _key; public: Note(double p, double x, Key k) : _perfectTime(p), _x(x), _key(k) { } void update(double t) { // ノーツが画面に出現してから2秒で判定ラインに到達する _y = JUDGE_Y / 2 * (t - _perfectTime) + JUDGE_Y; } bool isErasable(double t) { if (-0.3 < (t - _perfectTime) && (t - _perfectTime) < 0.12 && _key.down()) { return true; } // ノーツが判定ラインを超えてから2秒後に自身を消去 if (2 < (t - _perfectTime)) { return true; } return false; } void draw() const { Rect((int32)_x - _WIDTH / 2, (int32)_y - _HEIGHT / 2, _WIDTH, _HEIGHT).draw(Palette::Greenyellow); } }; class Humen { double _bpm = 120; double _offset = 0; public: Array<Array<double>> perfectTimes; Humen() { for (size_t i = 0; i < LANE_NUM; i++) { perfectTimes << Array<double>(); } } void load() { TextReader reader(U"humen.txt"); if (!reader) { throw Error(U"Failed to open `humen.txt`"); } String line; while (reader.readLine(line)) { auto list = line.split(':'); if (list[0] == U"#START") { break; } if (list[0] == U"BPM") { _bpm = Parse<double>(list[1]); } else if (list[0] == U"OFFSET") { _offset = Parse<double>(list[1]); } } for (size_t row = 0; reader.readLine(line); row++) { auto list = line.split(','); for (auto col : step(list.size())) { String word = list[col]; for (auto i : step(word.size())) { if ('1' <= word[i] && word[i] <= '9') { perfectTimes[col] << 60 * 4 * (row + (double)i / word.size()) / _bpm - _offset; } } } } } }; class NoteManager { Array<Array<Note>> _notesList; public: NoteManager() { for (size_t i = 0; i < LANE_NUM; i++) { _notesList << Array<Note>(); } Humen humen; humen.load(); for (auto laneNum : step(LANE_NUM)) { for (auto i : step(humen.perfectTimes[laneNum].size())) { _notesList[laneNum] << Note(humen.perfectTimes[laneNum][i], 140 + 90 * laneNum, keys[laneNum]); } } } void update(double t) { for (auto& notes : _notesList) { for (auto& note : notes) { note.update(t); } } for (auto& notes : _notesList) { for (auto it = notes.begin(); it != notes.end(); it++) { // 消去フラグが立っていたら消去 if ((*it).isErasable(t)) { it = notes.erase(it); break; } } } } void draw() const { //String str = U""; for (const auto& notes : _notesList) { for (const auto& note : notes) { note.draw(); } //str += Format(notes.size()) + U" "; } //ClearPrint(); //Print << str; } }; class Game { NoteManager _noteManager; const Array<String> _strKeys = { U"Z", U"X", U"C", U"V" }; const Font _keyFont{ 50 }; const Font _titleFont{ 26 }; const Font _versionFont{ 16 }; public: Game() { AudioAsset(U"BGM").play(); Scene::SetBackground(ColorF(0.5, 0.6, 0.7)); } void update() { _noteManager.update(AudioAsset(U"BGM").posSec()); } void draw() const { for (auto i : step(LANE_NUM)) { Rect(100 + i * 90, 0, 80, 600).draw(Palette::White); } Line(80, JUDGE_Y, 470, JUDGE_Y).draw(4, Palette::Blue); _titleFont(U"Otoge no Youna Nanica").drawAt(Vec2(620, 270), Palette::White); _versionFont(U"ver. summer blog 2019").drawAt(Vec2(620, 300), Palette::White); for (auto i : step(LANE_NUM)) { if (keys[i].down()) { AudioAsset(U"Sound").playOneShot(); } } for (auto i : step(LANE_NUM)) { Color keyColor; keyColor = keys[i].pressed() ? Palette::Skyblue : Palette::Gray; _keyFont(_strKeys[i]).drawAt(Vec2(140 + 90 * i, 570), keyColor); } _noteManager.draw(); } }; void Main() { Window::SetTitle(U"Otoge no Youna Nanika"); AudioAsset::Register(U"BGM", U"example/test.mp3"); AudioAsset::Register(U"Sound", GMInstrument::Piano1, PianoKey::A4, 0.5s, AssetParameter::LoadImmediately()); Game game; while (System::Update()) { game.update(); game.draw(); } }
このコードを実行すると、下の写真のようになります。
おわりに
とりあえず音ゲーのような何かはつくれました。
つ く れ ま し た。 いいね?
「Nanika」が一部「Nanica」になっていたりしますが気にしたら負けだ。
ちなみに、今回は時間の都合上実装しませんでしたが、SceneManagerを使えばタイトル画面や結果表示画面などのシーン遷移も簡単に出来ますし、タスクシステムを使えば「Perfect」「Good」といった表示もラクに実装出来ます。また、ScoreクラスをつくってPerfect、Good、Missの数やコンボ数を持たせればより音ゲーっぽくなりそうです。この辺は余裕があればvol.2で紹介するかもしれません~
以上です!ここまで読んでいただきありがとうございました!
おまけ
タイトルとは全く関係ないおまけです。
ブログリレーは「"好き"を布教する場所」でもあると私は思っていて、そういう意味ではこの記事でSiv3Dを布教したことになるのですが、もう少し私の趣味に近い布教もしてみようと思います。2
花譜を推せ
ほら、こんな記事読んでないで花譜さんの歌を聴きにいって。いいから。
4月に現会長の記事で存在を知り、めちゃくちゃ推されたので試しに聴いてみたらドハマりしました。
会長のようにちゃんとした考察は出来ないのですが、透き通った歌声とカンザキイオリさんの歌詞がすごくいいんです。なんか...こう...「好き!」
書いてたらまた聴きたくなってきた...
いいから聴いて。