🦔

Siv3Dを使って着せ替えゲームを作る

2024/12/24に公開

背景

こちらはSiv3D Advent Calendar 2024の24日目の記事となります。

Siv3Dアドベントカレンダー2024、15日目の記事でほぼ初めてSiv3D(というか組み込みではないC++)
を使った、まつはちです。
https://qiita.com/matsuhachi/items/e24ce6d49489d0570d54

普段はハードウェアエンジニアであり、C++わからん人間ですが、着せ替えゲームが作りたい!、ということで、作ってみることにしました。
前回はQiitaで書きましたが、ゲーム要素が強めなのでZennで投稿します。

キャラクターは動く人形キボウちゃんを使用しました。
クリスマスイヴということでクリスマス仕様の着せ替えゲームです!

前回の記事に登場しない新たな要素として、
アセット管理
ファイルシステム
が登場します。

実装

まずは着せ替えゲームにどんな要素があるか考えます。

  • レイヤーの扱い
  • ボタンによる着替え操作
  • 帽子・トップス・ボトムス・靴下、といったジャンル分け
  • 画像の表示

ど、どうやるんだ…?すでに挫折しそうですが調べてみます。

イラストの制作(レイヤー構造の検討)

サンタクロースの格好がどんなパーツで構成されているか考えてみます。

  • 素体
  • 帽子
  • トップス
  • ボトムス
  • おてて

とりあえずいろんなパターンをそれぞれ描いてみます。(約8時間)

描いてみた感じ、素体<靴<ボトムス<トップス<おてて<帽子、で描画できればよさそうです。
袋を担いだおててのような、おてて部分は手前に、袋部分は素体裏に配置する必要があるので、ここは工夫が必要そうです。バリエーションが増えると、このようなパーツが増える気がします。

ファイルの読み出し&ジャンル分け

調べてみるとジャンル分けにはアセット管理が便利そうです。
これは画像や文字、音楽のようなデータの管理に使えるらしいです。

今回こちらを採用した理由はこちら。

  • 登録する際に、ファイル名から抽出した名前で登録できる
  • タグが付けられる
  • データのロードやリリースがしやすい
  • メモリ上での連続性が必要ない
  • タグのつけ方によってコーデセットへの着替えもできそう

各パーツの名前には、hat_1.pnd、hat_2.png...と連番で名前をつけていきました。

Textureの名前でAssetは登録し、タグにジャンルと番号を振り分けました。
もっと良い実装があると思うので追々考えます。

	for (const auto& path : paths)
	{
		const Array<String> result =FileSystem::BaseName(path).split(U'_');
		TextureAsset::Register({ FileSystem::BaseName(path),  {result[0],result[1]}}, path);
		TextureAsset::Load(result[1]);
	}

ファイルシステムによれば、Siv3Dのファイル検索は便利で、拡張子や、ファイル名からの名前の抽出が簡単に行えるようです。

 FileSystem::BaseName(path)

これでファイル名が呼び出せるらしい…。拡張子だけ抜き出したりもできる。

ボタン部の実装


Siv3Dには図形の描画機能や、その描画図形のあたり判定機能があるので、これを使います。
クリック時に白にすると一瞬で元の色に戻るため、クリック時の服の操作と、ボタンの見え方の処理は分け、押している間は白くなるような処理としています。
フォントファイルからフォントを作成する¶を使ってフォントも埋め込みました。1px単位でフォントの位置調整したのが若干面倒。

	const Triangle triHatR = Triangle{ 705, 100, 75 ,90_deg };
    ...
    triHatR.draw(Palette::Orange);
    ...
	if (tri.leftClicked())
	{
		++count;
		if (count > (max-1)) count = 1;
	}
	if (tri.leftPressed()) tri.draw(Palette::White);

服の描画

アセット管理機能のサンプルコードより、アセット一覧からタグで特定のものを抜き出すものを使用しました。
全てのTextureデータから靴、ボトム、トップス、帽子、おててのタグのついたものをそれぞれ検索し、今何番目の衣装かに従って描画します。

レイヤー構造でスパゲティになるのが怖かったため、今回は複雑な依存関係が発生しにくい描き方をし、順番に描画することとしました。
一部、袋を担いだおててだけは苦肉の策で、フラグ処理をしていますが…(全体コードは後述)

また、一度の検索で全てのタグの描画を行うと、服のレイヤーの上下関係が変わってしまうため、各タグごとに毎回検索をかけて描画しています。

		for (auto&& [name, info] : TextureAsset::Enumerate())
		{
			if (info.tags.includes(U"shoes"))
			{
				if (info.tags.includes(Format(countShoes)))  TextureAsset(name).scaled(0.3).draw(20, 20);
			}
		}

		for (auto&& [name, info] : TextureAsset::Enumerate())
		{
			if (info.tags.includes(U"bottoms"))
			{
				if (info.tags.includes(Format(countBottom)))  TextureAsset(name).scaled(0.3).draw(20, 20);
			}
		}

荒削りですが完成しました。筆者一押しのリボンコーデはこちら。

感想

イラスト描いてる時間の方が長かったです。(イラスト8時間、コード4時間)
GUI周りをSiv3Dが担当してくれているのですっきりしたコードになりました。

テキストの配置は1pxレベルで調整を入れたため、ここはもっと良い方法を探したいところです。
また、コード自体確実にもっときれいな書き方があるとは思います…今度レビューしていただく予定です。

  • 背景の追加
  • 表情の追加
  • BGMの追加
  • スタートメニューの追加
  • テキスト揃えのやりかたの調査
  • 衣装の追加
  • ジャンル分けの改善
  • レイヤー構造の改善
  • 着せ替えのエクスポート機能の実装
  • ゲームの配信

は今後機能追加、機能調査をしていこうと思っています。

コード全体像

main.cpp
# include <Siv3D.hpp> // OpenSiv3D v0.6.8

void updateRightTriangle(const Triangle& tri,int32& count,int32 max) {
	if (tri.leftClicked())
	{
		++count;
		if (count > (max-1)) count = 1;
	}
	if (tri.leftPressed()) tri.draw(Palette::White);
}

void updateLeftTriangle(const Triangle& tri, int32& count, int32 max) {
	if (tri.leftClicked())
	{
		--count;
		if (count < 1)  count = max-1; ;

	}
	if (tri.leftPressed()) tri.draw(Palette::White);
}

void Main()
{
	Scene::SetBackground(ColorF{ 0.1, 0.1, 0.5 });

	const Array<FilePath> paths = FileSystem::DirectoryContents(U"fig/", Recursive::No);

	for (const auto& path : paths)
	{
		const Array<String> result =FileSystem::BaseName(path).split(U'_');
		TextureAsset::Register({ FileSystem::BaseName(path),  {result[0],result[1]}}, path);
		TextureAsset::Load(result[1]);
	}

	const Font font{ 45, U"RocknRollOne-Regular.ttf" };

	const Triangle triHatR = Triangle{ 705, 100, 75 ,90_deg };
	const Triangle triHatL = Triangle{ 445, 100, 75 ,270_deg };
	const Triangle triTopR = Triangle{ 705, 225, 75 ,90_deg };
	const Triangle triTopL = Triangle{ 445, 225, 75 ,270_deg };
	const Triangle triHandR = Triangle{ 705, 325, 75 ,90_deg };
	const Triangle triHandL = Triangle{ 445, 325, 75 ,270_deg };
	const Triangle triBottomR = Triangle{ 705, 425, 75 ,90_deg };
	const Triangle triBottomL = Triangle{ 445, 425, 75 ,270_deg };
	const Triangle triShoesR = Triangle{ 705, 525, 75 ,90_deg };
	const Triangle triShoesL = Triangle{ 445, 525, 75 ,270_deg };

	int32 maxHat = 1;
	int32 maxTop = 1;
	int32 maxHand = 0;
	int32 maxBottom =1;
	int32 maxShoes = 1;

	int32 countHat = 1;
	int32 countTop = 1;
	int32 countHand = 1;
	int32 countBottom = 1;
	int32 countShoes = 1;
	bool useBag = false;

	for (auto&& [name, info] : TextureAsset::Enumerate())
	{
		if (info.tags.includes(U"hat")) ++maxHat;

		if (info.tags.includes(U"shoes")) ++maxShoes;

		if (info.tags.includes(U"hand")) ++maxHand;

		if (info.tags.includes(U"bottoms")) ++maxBottom;

		if (info.tags.includes(U"tops")) ++maxTop;
	}

		while (System::Update())
	{
		triHatR.draw(Palette::Orange);
		triHatL.draw(Palette::Orange);
		triTopR.draw(Palette::Orange);
		triTopL.draw(Palette::Orange);
		triHandR.draw(Palette::Orange);
		triHandL.draw(Palette::Orange);
		triBottomR.draw(Palette::Orange);
		triBottomL.draw(Palette::Orange);
		triShoesR.draw(Palette::Orange);
		triShoesL.draw(Palette::Orange);

		font(U"HAT").draw(525, 64);
		font(U"TOP").draw(525, 185);
		font(U"HAND").draw(510, 290);
		font(U"BOTTOM").draw(475, 390);
		font(U"SHOES").draw(495, 487);


		updateRightTriangle(triHatR, countHat,maxHat);
		updateLeftTriangle(triHatL, countHat, maxHat);
		updateRightTriangle(triTopR, countTop, maxTop);
		updateLeftTriangle(triTopL, countTop, maxTop);
		updateRightTriangle(triHandR, countHand, maxHand);
		updateLeftTriangle(triHandL, countHand, maxHand);
		updateRightTriangle(triBottomR, countBottom, maxBottom);
		updateLeftTriangle(triBottomL, countBottom, maxBottom);
		updateRightTriangle(triShoesR, countShoes, maxShoes);
		updateLeftTriangle(triShoesL, countShoes,maxShoes);


		if (useBag) TextureAsset(U"hand_0").scaled(0.3).draw(20, 20);

		for (auto&& [name, info] : TextureAsset::Enumerate())
		{
			if (info.tags.includes(U"man")) TextureAsset(name).scaled(0.3).draw(20, 20);
		}

		for (auto&& [name, info] : TextureAsset::Enumerate())
		{
			if (info.tags.includes(U"hat"))
			{
				if (info.tags.includes(Format(countHat))) TextureAsset(name).scaled(0.3).draw(20, 20);
			}
		}

		for (auto&& [name, info] : TextureAsset::Enumerate())
		{
			if (info.tags.includes(U"shoes"))
			{
				if (info.tags.includes(Format(countShoes)))  TextureAsset(name).scaled(0.3).draw(20, 20);
			}
		}

		for (auto&& [name, info] : TextureAsset::Enumerate())
		{
			if (info.tags.includes(U"bottoms"))
			{
				if (info.tags.includes(Format(countBottom)))  TextureAsset(name).scaled(0.3).draw(20, 20);
			}
		}
		for (auto&& [name, info] : TextureAsset::Enumerate())
		{
			if (info.tags.includes(U"tops"))
			{
				if (info.tags.includes(Format(countTop))) TextureAsset(name).scaled(0.3).draw(20, 20);
			}
		}

		for (auto&& [name, info] : TextureAsset::Enumerate())
		{
			if (info.tags.includes(U"hand"))
			{
				if (info.tags.includes(Format(countHand))) {
					TextureAsset(name).scaled(0.3).draw(20, 20);
					if (countHand == 3) { useBag = true; } else { useBag = false; }
				}
			}
		}
	}
}
ユカイ工学テックブログ

Discussion