Open8

「Siv3D ゲームジャム 2025」の参加人数と同じ数だけ Siv3D 記事を書く

Ryo SuzukiRyo Suzuki

Microsoft/Proxy ライブラリを用いて、Siv3D の図形型とユーザ定義の図形型を統一的に表現できる型を実現する

Microsoft/Proxy について

  • 継承を使わず、あらかじめ定義した「ファサード(インターフェイス)」を満たす任意の型を共通のプロキシで扱える型消去ライブラリ(構造的ポリモーフィズム)
  • GitHub: https://github.com/microsoft/proxy/
  • ヘッダオンリーライブラリ。インクルードするだけで使える

継承と比べた利点

  • Rect/Circle/RoundRect などに基底クラスが無くても、所定のメンバ関数名とシグネチャを満たせばそのまま扱える
  • 既存型に継承や virtual を追加する必要がない非侵入性

std::variant と比べた利点

  • std::variant は型の列挙を事前に固定する必要があり、追加時に定義と visitor を改修する必要がある。proxy は「要件を満たす型なら何でも可」で、既存コードに手を入れず拡張できる
  • std::visit のボイラープレート不要。m_shape->draw() のように直接呼べる

Siv3D での活用サンプル

  • Button クラスは ButtonShape のファサード要件(draw(), mouseOver() メンバ関数)を満たす任意の型を所有できる
  • Siv3D の図形クラスはそのまま利用可能。ユーザ定義型も要件を満たすだけで利用可能

コード
# include <Siv3D.hpp>

// https://github.com/microsoft/proxy
// 型消去により、所定の「ファサード(インターフェイス)」を満たす任意型を共通のプロキシ(型消去ポインタ)で扱える
# include <proxy/proxy.h>

// メンバ関数呼び出し用の「ディスパッチタグ」を定義。
// 第 2 引数には実装側のメンバ関数名を渡す。具体的な関数シグネチャは後段の add_convention で指定する。
PRO_DEF_MEM_DISPATCH(MemDraw, draw);
PRO_DEF_MEM_DISPATCH(MemMouseOver, mouseOver);

// ButtonShape が要求する操作群(インターフェイス)を「ファサード」として宣言。
// add_convention<タグ, シグネチャ> を列挙し、build で確定する。
// これにより、異なる型でも draw(ColorF) と mouseOver() を持つものは同一の「ButtonShape」として扱える。
struct ButtonShape
	: pro::facade_builder
	::add_convention<MemDraw, void(const ColorF&) const>
	::add_convention<MemMouseOver, bool() const>
	::support_copy<pro::constraint_level::nontrivial>
	::build {
};

// 与えられた型のポインタが ButtonShape のファサード要件を満たして proxy 化できるかを、コンパイル時にチェックするコンセプト
template <class Type>
concept ButtonShapeProxiable = pro::proxiable<std::add_pointer_t<std::remove_cvref_t<Type>>, ButtonShape>;

class Button
{
public:

	[[nodiscard]]
	bool mouseOver() const
	{
		// プロキシ経由で、実体のメンバ関数 mouseOver() を呼び出す
		return m_shape->mouseOver();
	}

	[[nodiscard]]
	bool down() const
	{
		return (mouseOver() && MouseL.down());
	}

	void draw() const
	{
		const bool mouseOver = this->mouseOver();
		const bool pressed = (mouseOver && MouseL.pressed());
		const ColorF color = (pressed ? PressedColor : (mouseOver ? HoverColor : DefaultColor));
		// ここでもプロキシ経由で draw() を呼び出す。実体の型(Rect, Circle, 自作型など)に依存しない
		m_shape->draw(color);
	}

	[[nodiscard]]
	static Button Make(const ButtonShapeProxiable auto& shape)
	{
		Button button;
		button.m_shape = pro::make_proxy<ButtonShape>(shape);
		return button;
	}

private:

	static constexpr ColorF DefaultColor{ 0.9 };
	static constexpr ColorF HoverColor{ 0.8, 0.9, 1.0 };
	static constexpr ColorF PressedColor{ 0.7 };

	// 異なるクラス(Rect, Circle, RoundRect, ユーザー定義型など)を
	// 「ButtonShape」という共通インターフェイスで型消去して扱うためのプロキシ
	pro::proxy<ButtonShape> m_shape;
};

class MyShape
{
public:

	MyShape(const Circle& circle1, const Circle& circle2)
		: m_circle1{ circle1 }
		, m_circle2{ circle2 } {
	}

	// ButtonShape で要求されるシグネチャ(void(const ColorF&) const)に一致
	void draw(const ColorF& color) const
	{
		m_circle1.draw(color);
		m_circle2.draw(color);
	}

	// ButtonShape で要求されるシグネチャ(bool() const)に一致
	bool mouseOver() const
	{
		return (m_circle1.mouseOver() || m_circle2.mouseOver());
	}

private:

	Circle m_circle1;
	Circle m_circle2;
};

void Main()
{
	Array<Button> buttons;

	// Rect / Circle / RoundRect は Siv3D 側で draw(ColorF) const と mouseOver() const を提供しているため、
	// ButtonShape ファサードに適合し、プロキシとして格納される
	buttons << Button::Make(Rect{ 100, 100, 200, 100 });
	buttons << Button::Make(Circle{ 200, 300, 50 });
	buttons << Button::Make(RoundRect{ 100, 400, 200, 100, 20 });

	// ユーザー定義型 MyShape も同じシグネチャのメンバ関数を実装しているので適合する
	buttons << Button::Make(MyShape{ Circle{ 500, 200, 50 }, Circle{ 600, 200, 50 } });

	while (System::Update())
	{
		for (const auto& button : buttons)
		{
			button.draw();
		}

		for (auto&& [index, button] : Indexed(buttons))
		{
			if (button.down())
			{
				Print << U"Button {} clicked"_fmt(index);
			}
		}
	}
}
Ryo SuzukiRyo Suzuki

toml11 ライブラリを用いて、TOML v1.0.0 に対応する TOML パーサを作る

概要

  • Siv3D v0.6 世代の s3d::TOMLReader は、使用している内部ライブラリ(tomlcpp)の制約で TOML v0.5.0 までのサポート
  • 本記事では toml11 を利用して、TOML v1.0.0 に対応した、Siv3D から使いやすい新しいパーサクラス s3d::ext::TOML を作成した

既存の s3d::TOMLReader からの変更点

  • TOML v1.0.0 をサポートした
  • 文字列からのパースに対応した
    • JSON と同様に TOML::Load(path) または TOML::Parse(content) で作成するように
  • LocalTime, LocalDate, LocalDateTime, OffsetDateTime を区別するようにした
  • .tableArrayView().arrayView() に統合するなどメンバ関数を整理した
  • 要素の変更や書き出し機能は引き続き未サポート(Siv3D v0.8 での実装待ち)
  • s3d::TOMLReader でサポートされている、[U"Window.title"][U"Window"][U"title"] にする分割機能は、TOML の quoted key と相性が悪いため廃止した。これは Siv3D v0.8 の新クラスでも正式に廃止予定

ライブラリコード

サンプル 1

  • v1.0.0 で許可される、異なる型が混在する配列に対応
test.toml
items = [10, "こんにちは", true]
コード
Main.cpp
# include <Siv3D.hpp>
# include "extTOML.hpp"

void Main()
{
	Print << U"--- s3d::TOMLReader ---";
	{
		const TOMLReader toml{ U"test.toml" };
		Print << (toml ? U"読み込み成功" : U"読み込み失敗"); // 失敗
	}

	Print << U"--- s3d::ext::TOML ---";
	{
		const ext::TOML toml = ext::TOML::Load(U"test.toml");
		Print << (toml ? U"読み込み成功" : U"読み込み失敗"); // 成功

		for (const auto& elem : toml[U"items"].arrayView())
		{
			if (elem.isInt())
			{
				Print << elem.get<int32>();
			}
			else if (elem.isString())
			{
				Print << elem.getString();
			}
			else if (elem.isBool())
			{
				Print << elem.get<bool>();
			}
		}
	}

	while (System::Update())
	{

	}
}

サンプル 2

テスト用TOML
toml.toml
# ===============================
# トップレベルのキーと各種スカラー
# ===============================
title = "TOML テストデータ"

# 整数(10進/16進/8進/2進・アンダースコア・符号)
answer    = 42
neg-int   = -1
plus-int  = +99
hex-int   = 0xDEAD_BEEF
oct-int   = 0o755
bin-int   = 0b1101_0101
zero      = 0

# 浮動小数(通常/指数/アンダースコア/符号)
pi        = 3.14159
planck    = 6.626E-34
big-float = 1_000_000.000_001
small     = -0.01
exp-pos   = 5e+22
exp-neg   = 1e-9

# 特殊な浮動小数(実装が対応していれば有効)
inf-pos   = inf
inf-neg   = -inf
not-a-num = nan

# 真偽値
ready     = true
archived  = false

# ===============================
# 文字列(基本/複数行/リテラル)
# ===============================
basic-str = "こんにちは \"TOML\" world\n改行テスト\tタブOK。Unicode: \u3053\u3093\u306B\u3061\u306F \U0001F680"

multiline-basic = """
複数行の"基本"文字列です。
末尾バックスラッシュで改行を無視します。\
ここは同じ行に連結されます。
"""

# リテラル(エスケープ解釈なし)
literal-str = 'Windowsパス C:\Users\name\Desktop\file.txt と バックスラッシュ \ はそのまま'

multiline-literal = '''
複数行の 'リテラル' 文字列。
"ダブルクォート" も \ バックスラッシュ もそのまま入ります。
'''

# 引用キー・日本語キー
"key with spaces" = "value"
'key.with.dots'   = "ドットを含むキー(単一キー扱い)"
"日本語キー"        = "OK"

# ===============================
# 日付/時刻系(RFC 3339/ローカル)
# ===============================
odt-utc     = 1979-05-27T07:32:00Z
odt-offset  = 1979-05-27T00:32:00+09:00
odt-fraction= 1979-05-27T07:32:00.999999Z

ldt         = 1979-05-27T07:32:00       # Local Date-Time
ld          = 1979-05-27                 # Local Date
lt          = 07:32:00                   # Local Time
lt-fraction = 00:32:00.999999

# ===============================
# 配列(単一型・ネスト・末尾カンマ)
# ===============================
ints = [1, 2, 3, 4, 5,]        # 末尾カンマ
strs = ["a", "b", "c"]
dates = [1979-05-27, 1980-01-01]
nested-ints = [[1, 2], [3, 4, 5]]
empty = []                      # 空配列

# インラインテーブルとその配列
point = { x = 1, y = 2 }
name  = { first = "John", last = "Smith" }
line  = [ { x = 1, y = 2 }, { x = 2, y = 3 }, ]

empty-table = {}               # 空のインラインテーブル

# ===============================
# ドット付きキー(暗黙テーブル生成)
# ===============================
site.name = "example"
site."owner name" = "Alice"
site.created = 2021-01-01

# ===============================
# 通常のテーブルとサブテーブル
# ===============================
[server]
ip      = "192.168.1.1"
ports   = [8001, 8001, 8002]   # 値の重複はOK
enabled = true

[server.cache]
enabled  = true
max_size = 1_024

# 引用キーを含むサブテーブル名
[servers]

[servers.alpha]
ip   = "10.0.0.1"
role = "frontend"

[servers."beta.gamma"]  # ドットを含むキー(引用で単一キー)
ip   = "10.0.0.2"
role = "backend"

# ===============================
# 配列テーブル(オブジェクト配列)
# ===============================
[[product]]
name = "Hammer"
sku  = 738594937

[[product]]
name  = "Nail"
sku   = 28475839
color = "gray"

# サブテーブルを含む配列テーブル要素
[[fruit]]
name = "apple"

  [fruit.physical]
  color = "red"
  shape = "round"

[[fruit]]
name = "banana"

  [fruit.physical]
  color = "yellow"
  shape = "curved"
コード
Main.cpp
# include <Siv3D.hpp>
# include "extTOML.hpp"

void TestCase(String name)
{
	Console << U"==========================";
	Console << U"  " + name;
	Console << U"==========================";
}

void ShowTable(const ext::TOML& root)
{
	if (not root.hasValue())
	{
		return;
	}

	auto PrintTable = [&](auto&& self, const ext::TOML& table, int32 d) -> void
		{
			for (auto&& [key, value] : table.tableView())
			{
				const String indent((d * 2), U' ');

				auto RecurseTable = [&](const ext::TOML& child)
					{
						Console << U"{}[{}]"_fmt(indent, key);
						self(self, child, (d + 1));
					};

				if (value.isTable())
				{
					RecurseTable(value);
				}
				else if (value.isArray())
				{
					Console << U"{}{}:"_fmt(indent, key);

					for (const auto& elem : value.arrayView())
					{
						if (elem.isTable())
						{
							RecurseTable(elem);
						}
						else
						{
							Console << U"{}{}"_fmt(indent, elem.format());
						}
					}
				}
				else
				{
					Console << U"{}{}: {}"_fmt(indent, key, value.format());
				}
			}
		};

	PrintTable(PrintTable, root, 0);
}

void Main()
{
	{
		{
			const ext::TOML toml = ext::TOML::Load(U"toml.toml");

			TestCase(U"format()");
			Console << toml.format();

			TestCase(U"get<int32>()");
			Console << toml[U"answer"].get<int32>();

			TestCase(U"get<double>()");
			Console << toml[U"pi"].get<double>();
			Console << toml[U"inf-pos"].get<double>();

			TestCase(U"get<bool>()");
			Console << toml[U"ready"].get<bool>();

			TestCase(U"getString()");
			Console << toml[U"basic-str"].getString();

			TestCase(U"日本語キー");
			Console << toml[U"日本語キー"].getString();

			TestCase(U"odt-utc");
			{
				{
					const auto [odt, offset] = toml[U"odt-utc"].getOffsetDateTime();
					Console << odt.toDateTime();
					Console << U"offset: " << offset << U" minutes";
				}

				{
					const auto [odt, offset] = toml[U"odt-offset"].getOffsetDateTime();
					Console << odt.toDateTime();
					Console << U"offset: " << offset << U" minutes";
				}

				{
					const auto [odt, offset] = toml[U"odt-fraction"].getOffsetDateTime();
					Console << odt.toDateTime().format(U"yyyy-MM-dd HH:mm:ss.SSS");
					Console << U"offset: " << offset << U" minutes";
				}
			}

			TestCase(U"LocalDateTime");
			{
				const auto ldt = toml[U"ldt"].getLocalDateTime();
				Console << ldt.toDateTime().format(U"yyyy-MM-dd HH:mm:ss.SSS");
			}

			TestCase(U"LocalDate");
			{
				const auto ld = toml[U"ld"].getLocalDate();
				Console << ld.format(U"yyyy-MM-dd");
			}

			TestCase(U"LocalTime");
			{
				const auto lt = toml[U"lt"].getLocalTime();
				Console << lt.toDateTime().format(U"HH:mm:ss.SSS");
			}

			TestCase(U"Array<int32>");
			{
				Console << toml[U"ints"].size();
				Console << toml[U"ints"].getArray<int32>();
			}

			TestCase(U"Array<String>");
			{
				Console << toml[U"strs"].getArray<String>();
			}

			TestCase(U"Array<Date>");
			{
				Console << toml[U"dates"].getArray<Date>();
			}

			TestCase(U"Array<Array<int>>");
			{
				Console << toml[U"nested-ints"].getArray<Array<int32>>();
			}

			TestCase(U"空配列");
			{
				Console << toml[U"empty"].size();
				Console << toml[U"empty"].isEmptyArray();
				Console << toml[U"empty"].getArray<int32>();
				Console << toml[U"empty"].getArray<String>();
				Console << toml[U"empty"].getArray<ext::TOML>();
				Console << toml[U"empty"].getArray<Array<int32>>();
			}

			TestCase(U"インラインテーブル");
			{
				{
					const auto table = toml[U"point"];
					Console << table.size();
					Console << U"x: {}, y: {}"_fmt(table[U"x"].get<int32>(), table[U"y"].get<int32>());
				}

				{
					Console << toml[U"name"].size();
					Console << U"first: {}, last: {}"_fmt(
						toml[U"name"][U"first"].get<String>(),
						toml[U"name"][U"last"].get<String>());
				}
			}

			TestCase(U"インラインテーブルの配列");
			{
				const auto lines = toml[U"line"].getArray<ext::TOML>();
				Console << lines.size();
				for (const auto& line : lines)
				{
					Console << U"x: {}, y: {}"_fmt(line[U"x"].get<int32>(), line[U"y"].get<int32>());
				}
			}

			TestCase(U"空のインラインテーブル");
			{
				const auto emptyTable = toml[U"empty-table"];
				Console << emptyTable.isTable();
				Console << emptyTable.size();
			}

			TestCase(U"arrayView");
			{
				for (const auto& elem : toml[U"ints"].arrayView())
				{
					Console << elem.get<int32>();
				}
			}

			TestCase(U"array of tables");
			{
				const auto tables = toml[U"product"].getArray<ext::TOML>();
				Console << tables.size();

				for (const auto& table : tables)
				{
					Console << U"name: {}, sku: {}"_fmt(
						table[U"name"].get<String>(), table[U"sku"].get<int>());
				}
			}

			TestCase(U"ShowTable");
			{
				ShowTable(toml);
			}
		}

		TestCase(U"ShowTable (empty)");
		{
			ShowTable(ext::TOML{});
		}

		TestCase(U"isValid");
		{
			const ext::TOML toml = ext::TOML::Load(U"toml2.toml");
			Console << (toml.isValid() == false);
		}

		TestCase(U"Parse");
		{
			const ext::TOML toml = ext::TOML::Parse(U"key = \"value\"\n"
				"key2 = 123\n"
				"key3 = true\n");
			ShowTable(toml);
		}

		TestCase(U"Parse (comment only)");
		{
			const ext::TOML toml = ext::TOML::Parse(U"# This is a comment\n"
				"# Another comment\n");
			ShowTable(toml);
		}

		TestCase(U"Parse (empty)");
		{
			const ext::TOML toml = ext::TOML::Parse(U"");
			ShowTable(toml);
		}

		TestCase(U"Parse (invalid)");
		{
			const ext::TOML toml = ext::TOML::Parse(U"a");
			Console << (toml.isValid() == false);
			ShowTable(toml);
		}
	}

	while (System::Update())
	{

	}
}
Ryo SuzukiRyo Suzuki

ペンタブレットの筆圧情報を利用して毛筆ライクな線を描く

  • ペンタブの筆圧を使って、毛筆風の太さ・にじみを表現するサンプル
  • 左クリック(ペン先)を押している間は、移動量に応じて筆圧を補間しながら描画
  • 動かさずに押し続けると「滲み stayPressure」でじわっと太る
  • 離した後は、圧が自然に抜ける余韻(減衰しながら進む)を描く

コード
# include <Siv3D.hpp>

class Pen
{
public:

	/// @brief 現在の状態をデバッグ表示します
	void show()
	{
		ClearPrint();
		Print << (m_pos ? U"Pos: {:.1f}"_fmt(*m_pos) : U"Pos: None");
		Print << U"Pressure: {:.3f}"_fmt(m_pressure);
		Print << U"Direction: {:.2f}"_fmt(m_direction);
		Print << U"StayPressure: {:.2f}"_fmt(m_stayPressure);
	}

	/// @brief 筆の状態を更新し、必要に応じて画像へ描画します
	/// @param image 描き込み先の画像
	/// @param deltaTime 前フレームからの経過時間(秒)
	/// @return 描き込みが行われた場合 true, それ以外の場合は false
	bool update(Image& image, const double deltaTime = Scene::DeltaTime())
	{
		// 目標値:現在の実筆圧
		const double targetPressure = Pentablet::Pressure();

		// 目標値:現在のカーソル位置
		const Vec2 targetPos = Cursor::PosF();

		// ペン(左ボタン)が押されている間
		if (MouseL.pressed())
		{
			if (not m_pos) // 書き始め(前フレームまで未押下)
			{
				// 位置と筆圧を反映させる
				m_pos = targetPos;
				m_pressure = targetPressure;

				// 描画
				return paint(image);
			}

			// 前回の状態からの移動距離
			const double moveDistance = m_pos->distanceFrom(targetPos);

			if (moveDistance < 1.0) // ほぼ静止:にじみを増やす
			{
				// 筆圧のみ追従する
				m_pressure = targetPressure;

				// 静止時間に応じてにじみ成分を増加させる
				m_stayPressure = Min((m_stayPressure + deltaTime), 1.0);

				// 描画
				return paint(image);
			}

			// にじみをリセットする
			m_stayPressure = 0.0;

			// 方向ベクトルを更新する
			m_direction = (targetPos - *m_pos).normalized();

			const double pressureDelta = ((targetPressure - m_pressure) / moveDistance);
			bool updated = false;

			// 1px 刻みで位置を進めて、その都度描画する
			while (1.0 < m_pos->distanceFrom(targetPos))
			{
				// 筆圧を滑らかに変化させる
				m_pressure += pressureDelta;

				// 位置を 1px 進める
				m_pos = Math::MoveTowards(*m_pos, targetPos, 1.0);

				// 描画
				updated |= paint(image);
			}

			return updated;
		}
		else // 離したあとの余韻(圧を徐々に抜きながら進む)
		{
			bool updated = false;

			// 圧が十分小さくなるまで、方向へ少しずつ進めて描く
			while (0.02 < m_pressure)
			{
				// 1ステップで少しずつ減衰
				m_pressure = Math::MoveTowards(m_pressure, 0.0, 0.02);

				// 最後の筆方向へ進める
				*m_pos += m_direction;

				// 描画
				updated |= paint(image);
			}

			// 状態をリセットする
			m_pos.reset();
			m_pressure = m_stayPressure = 0.0;

			return updated;
		}
	}

private:

	/// @brief 実際の描き込みを行います。現在位置と筆圧から太さを決め、円ブラシで塗ります
	/// @param image 描き込み先の画像
	/// @return 描き込みが行われた場合 true, それ以外の場合は false
	bool paint(Image& image)
	{
		if (not m_pos)
		{
			return false;
		}

		// 太さ:筆圧ベース + 静止によるにじみ分を加算する
		const double thickness = ((m_pressure * 40.0) + (m_stayPressure * 5.0));

		// わずかなオフセットを与えて重なりのムラを軽減する(テイスト付け)
		const Vec2 center = (*m_pos + thickness * Vec2{ 1, 1 });

		// 半透明の黒インクを重ね塗りする(にじみ感が出る)
		Circle{ center, thickness }.paint(image, ColorF{ 0.0, 0.1 });

		return true;
	}

	/// @brief 現在位置(未開始時は none)
	Optional<Vec2> m_pos;

	/// @brief 進行方向(正規化ベクトル)
	Vec2 m_direction{ 0.0, 0.0 };

	/// @brief 現在の筆圧(0.0~1.0)
	double m_pressure = 0.0;

	/// @brief 静止によるにじみ成分(0.0~1.0)
	double m_stayPressure = 0.0;
};

void Main()
{
	// ペンタブドライバが利用可能か確認する
	if (not Pentablet::IsAvailable())
	{
		throw Error{ U"Pen tablet is not available" };
	}

	// キャンバスのサイズ
	constexpr Size CanvasSize{ 1280, 720 };

	// ウィンドウサイズをキャンバスに合わせる
	Window::Resize(CanvasSize);

	// 画像
	Image image{ CanvasSize, Palette::White };

	// 描いた結果を転送する先の動的テクスチャ
	DynamicTexture texture{ image };

	Pen pen;

	while (System::Update())
	{
		// スペースキーでキャンバスをクリアする
		if (KeySpace.down())
		{
			image.fill(Palette::White);
			texture.fill(image);
		}

		// 状態が更新されたらテクスチャに反映する
		if (pen.update(image))
		{
			texture.fill(image);
		}

		// 必要ならデバッグ表示
		// pen.show();

		// キャンバス描画
		texture.draw();
	}
}
Ryo SuzukiRyo Suzuki

曇っていくガラスを拭うエフェクト

  • 元画像をガウシアンブラー+縮小/拡大でぼかし、曇りの色を付加した「曇りテクスチャ」を生成
  • 拭う場合は、減算ブレンドにより曇りのアルファを削る
  • 一定間隔で、加算ブレンドにより曇りが徐々に戻る
  • カスタムシェーダ不要。RenderTexture の機能だけで実現可能

https://x.com/Reputeless/status/1961456742044512505

コード
# include <Siv3D.hpp>

/// @brief 曇った窓ガラスを拭うエフェクトを管理するクラス
class FoggedTexture
{
public:

	/// @brief デフォルトコンストラクタ
	[[nodiscard]]
	FoggedTexture() = default;

	/// @brief エフェクトを初期化します。
	/// @param original 曇っていない元のテクスチャ
	/// @param fogColor 曇りの色
	[[nodiscard]]
	explicit FoggedTexture(const Texture& original, const ColorF& fogColor)
		: m_fogColor{ fogColor }
		, m_original{ original }
		, m_fogged{ MakeFogged(original, fogColor) } {}

	/// @brief 元のテクスチャを返します。
	/// @return 元のテクスチャ
	[[nodiscard]]
	const Texture& original() const noexcept
	{
		return m_original;
	}

	/// @brief 曇り表現のテクスチャを返します。
	/// @return 曇り表現のテクスチャ
	[[nodiscard]]
	const RenderTexture& fogged() const noexcept
	{
		return m_fogged;
	}

	/// @brief 完全に曇った状態にリセットします。
	void reset()
	{
		m_fogged = MakeFogged(m_original, m_fogColor);
	}

	/// @brief 指定した位置を拭います(曇りテクスチャのアルファを減らして透明にします)。
	/// @param lastPos 前回拭った位置(クリックした瞬間は nullopt)
	/// @param targetPos 今回拭う位置
	/// @param wipeRadius 拭う半径
	/// @param wipeStrength 拭う強さ(0.0 ~ 1.0)
	void wipe(Optional<Vec2>& lastPos, const Vec2& targetPos, double wipeRadius, double wipeStrength)
	{
		const ColorF wipeColor{ 1.0, wipeStrength };
		const ScopedRenderTarget2D target{ m_fogged };

		// RGB は変えない。A は dstA - srcA で薄くなるようにする
		const ScopedRenderStates2D blend{ AlphaSubtract() };

		if (not lastPos)
		{
			// クリックした瞬間は単発の円
			Circle{ targetPos, wipeRadius }.draw(wipeColor);
			lastPos = targetPos;
		}
		else
		{
			// 高速にドラッグした場合の隙間を埋める
			while (4.0 < lastPos->distanceFrom(targetPos))
			{
				lastPos = Math::MoveTowards(*lastPos, targetPos, 4.0);
				Circle{ *lastPos, wipeRadius }.draw(wipeColor);
			}
		}
	}

	/// @brief 全体に曇りを追加します。
	/// @param amount 曇りの追加量(0.0 ~ 1.0)
	void addFog(double amount)
	{
		const ScopedRenderTarget2D target{ m_fogged };
		const ScopedRenderStates2D blend{ AlphaAdd() };
		Rect{ m_fogged.size() }.draw(ColorF{ 1.0, amount });
	}

private:

	/// @brief 曇りの色
	ColorF m_fogColor{ 1.0, 0.7 };

	/// @brief 元のテクスチャ
	Texture m_original;

	/// @brief 曇り表現のテクスチャ(拭いたり霧を足したりする描画対象)
	RenderTexture m_fogged;

	[[nodiscard]]
	static void AddFog(const RenderTexture& texture, const ColorF& fogColor)
	{
		{
			const ScopedRenderTarget2D target{ texture };
			Rect{ texture.size() }.draw(fogColor);
		}
		Graphics2D::Flush();
	}

	[[nodiscard]]
	static RenderTexture MakeFogged(const Texture& texture, const ColorF& fogColor)
	{
		const Size originalSize{ texture.size() };
		const Size smallSize{ originalSize / 8 };
		RenderTexture large1{ originalSize }, large2{ originalSize };
		RenderTexture small1{ smallSize }, small2{ smallSize };

		Shader::Copy(texture, large1);
		Shader::GaussianBlur(large1, large2, large1);
		Shader::Downsample(large1, small1);
		Shader::GaussianBlur(small1, small2, small1);
		Shader::Downsample(small1, large1);

		AddFog(large1, fogColor);

		return large1;
	}

	/// @brief 曇りの拭き取り用のブレンドステートを返します。
	/// @return 曇りの拭き取り用のブレンドステート
	[[nodiscard]]
	static BlendState AlphaSubtract()
	{
		BlendState state = BlendState::Default2D;
		state.src = Blend::Zero;
		state.dst = Blend::One;
		state.srcAlpha = Blend::SrcAlpha;
		state.dstAlpha = Blend::One;
		state.op = BlendOp::Add;
		state.opAlpha = BlendOp::RevSubtract;
		return state;
	}

	/// @brief 曇りの復帰用のブレンドステートを返します。
	/// @return 曇りの復帰用のブレンドステート
	[[nodiscard]]
	static BlendState AlphaAdd()
	{
		BlendState state = AlphaSubtract();
		state.opAlpha = BlendOp::Add;
		return state;
	}
};

void Main()
{
	constexpr Size TargetSize{ 1280, 720 };
	Window::Resize(TargetSize);

	// 拭き取りの半径
	const double WipeRadius = 100.0;

	// 拭き取りの強さ(0.0 ~ 1.0)
	const double WipeStrength = 0.1;

	// 曇りの色
	constexpr ColorF FogColor{ 1.0, 0.7 };

	// 曇りテクスチャの作成
	FoggedTexture foggedTexture{ Texture{ Image{ U"example/bay.jpg" }.scaled(TargetSize) }, FogColor };

	// 最後に拭った位置
	Optional<Vec2> lastPos;

	// 曇りを追加する間隔(秒)と、その累積時間
	constexpr double FogAddInterval = 0.04;
	double fogAddAccumulatedTime = 0.0;

	while (System::Update())
	{
		// 経過時間を加算
		fogAddAccumulatedTime += Scene::DeltaTime();

		// 曇りの追加時間に達したら曇りを追加
		if (FogAddInterval < fogAddAccumulatedTime)
		{
			const int32 count = static_cast<int32>(fogAddAccumulatedTime / FogAddInterval);
			foggedTexture.addFog(Math::Saturate(count * 0.1));
			fogAddAccumulatedTime -= (count * FogAddInterval);
		}

		// 拭う
		if (MouseL.pressed())
		{
			foggedTexture.wipe(lastPos, Cursor::PosF(), WipeRadius, WipeStrength);
		}
		else if (MouseL.up()) // 離したら最後の位置をリセット
		{
			lastPos.reset();
		}

		// 右クリックで拭き取りをリセット
		if (MouseR.down())
		{
			foggedTexture.reset();
		}

		// 元画像を描く
		foggedTexture.original().draw();

		// その上に曇りテクスチャを描く
		foggedTexture.fogged().draw();
	}
}
Ryo SuzukiRyo Suzuki

自作クラスを JSON に対応させる

  • 次の 2 つをする
    • Formatter を定義して Format に対応させる
    • operator>> を定義して Parse に対応させる
コード
# include <Siv3D.hpp>

struct Position
{
	int16 x = 0;
	int16 y = 0;

	// フォーマット可能にする
	friend void Formatter(FormatData& formatData, const Position& value)
	{
		formatData.string += U"({}, {})"_fmt(value.x, value.y);
	}

	// パース可能にする
	friend std::wistream& operator >>(std::wistream& is, Position& value)
	{
		wchar_t unused;
		return is >> unused     // '(' を読み飛ばす
			>> value.x          // x を読み取る
			>> unused           // ',' を読み飛ばす
			>> value.y          // y を読み取る
			>> unused;          // ')' を読み飛ばす
	}
};

void Main()
{
	const Position pos{ 100, 200 };
	Print << pos;
	Print << Parse<Position>(U"(300, 400)");

	{
		JSON json;
		json[U"pos1"] = Position{ 11, 111 };
		json[U"pos2"] = Position{ 22, 222 };
		json.save(U"pos.json");
	}

	{
		const JSON json = JSON::Load(U"pos.json");
		const Position pos1 = json[U"pos1"].get<Position>();
		const Position pos2 = json[U"pos2"].get<Position>();
		Print << pos1;
		Print << pos2;
	}

	while (System::Update())
	{

	}
}

Ryo SuzukiRyo Suzuki

原神 Web イベント『空月の歌』に登場する UI を再現する

https://x.com/Reputeless/status/1966498363932631455

コード
# include <Siv3D.hpp> // Siv3D v0.6.16

void DrawButton(const Circle& orbit, const double angleOnOrbit, const Mat3x3& homography, const std::pair<Texture, double>& icon, double& iconTime)
{
	const Vec2 pos = orbit.getPointByAngle(angleOnOrbit);
	double scale = homography.transformPoint(pos).distanceFrom(homography.transformPoint(pos.movedBy(1, 0)));
	const Vec2 bottomCenter = homography.transformPoint(pos);
	const Circle baseCircle{ Arg::bottomCenter = bottomCenter, 30 };

	const double delta = Scene::DeltaTime();

	if (baseCircle.mouseOver() || ((iconTime == 1.0) && Cursor::Delta().isZero()))
	{
		Cursor::RequestStyle(CursorStyle::Hand);
		iconTime = (1.0 < iconTime) ? delta : Min((iconTime + delta * 1.5), 1.0);
	}
	else if (iconTime != 0.0)
	{
		iconTime = (2.0 <= iconTime) ? 0.0 : (iconTime + delta * 2);
	}

	double cs = scale;
	double is = scale;

	if (iconTime <= 1.0)
	{
		cs *= (1.0 + EaseOutBack(Math::Saturate(iconTime * 1.5)) * 0.25);
		is *= (1.0 + EaseOutBack(iconTime * 1.2) * 0.5);
	}
	else
	{
		cs *= (1.0 + EaseInQuart(2.0 - iconTime) * 0.25);
		is *= (1.0 + EaseInQuart(Math::Saturate(1.0 - (iconTime - 1.0) * 1.2)) * 0.5);
	}

	const Circle circle{ baseCircle.center, (baseCircle.r * cs) };
	circle.draw(ColorF{ 0.02, 0.02, 0.05, 1.0 }, ColorF{ 0.02, 0.02, 0.05, 0.8 });
	circle.drawFrame((0.8 * cs), ColorF{ 0.8 });
	circle.scaled(0.95).drawFrame((0.4 * cs), ColorF{ 0.6 });
	circle.scaled(0.90).drawFrame((0.3 * cs), ColorF{ 0.4 });
	circle.scaled(0.85).drawFrame((0.2 * cs), ColorF{ 0.2 });

	if ((0.0 < iconTime) && (iconTime <= 1.0))
	{
		circle.drawFrame(baseCircle.r * cs * 0.22, 0.0, ColorF{ 0.7, 0.5, 1.0, (0.2 * EaseOutQuart(iconTime)) });
		const double e = EaseOutCirc(iconTime);

		for (int32 i = 0; i < 4; ++i)
		{
			const double angle = (i * 90_deg) + (30_deg + e * 60_deg);
			const Transformer2D tr{ Mat3x2::Translate(0, circle.r).rotated(angle).translated(circle.center) };
			Shape2D::Astroid(Vec2{ 0, 0 }, (3 * cs), (5 * cs)).draw(ColorF{ 0.95, 0.95, 1.0 });
		}

		{
			const double t = (0.5 - AbsDiff(0.5, iconTime));
			const ScopedRenderStates2D add{ BlendState::Additive, SamplerState::ClampAniso };
			circle.draw((ColorF{ 0.7, 0.5, 1.0 } *t), ColorF{ 0.0 });
		}
	}
	else if (1.0 < iconTime)
	{
		const double t = (2.0 - iconTime);
		circle.drawFrame(baseCircle.r * cs * 0.22, 0.0, ColorF{ 0.7, 0.5, 1.0, (0.2 * EaseInQuart(t)) });

		for (int32 i = 0; i < 4; ++i)
		{
			const double angle = (i * 90_deg);
			const Transformer2D tr{ Mat3x2::Translate(0, circle.r).rotated(angle).translated(circle.center) };
			Shape2D::Astroid(Vec2{ 0, 0 }, (3 * cs * t), (5 * cs * t)).draw(ColorF{ 0.95, 0.95, 1.0 });
		}
	}

	icon.first.scaled(0.45 * is).rotated(icon.second).drawAt(baseCircle.center, ColorF{ 1.0, Math::Saturate(0.5 * is) });
}

Vec2 GetPoint(const Vec2& wheelCenter, const int32 orbit, double angle, double ellipseRotationAngle)
{
	if (orbit == 0)
	{
		return Circle{ wheelCenter, 480 }.getPointByAngle(angle);
	}
	else if (orbit == 1)
	{
		angle -= ellipseRotationAngle;
		const Vec2 pointOnEllipse{ (wheelCenter.x + 540 * std::sin(angle)), (wheelCenter.y - 520 * std::cos(angle)) };
		return Mat3x2::Rotate(ellipseRotationAngle, wheelCenter).transformPoint(pointOnEllipse);
	}
	else
	{
		return Circle{ wheelCenter, 598 }.getPointByAngle(angle);
	}
}

void DrawCrossingLine(const Vec2& wheelCenter, const int32 fromOrbit, const double fromAngle, const int32 toOrbit, const double toAngle, double tAngle, const double ellipseRotationAngle)
{
	Line{ GetPoint(wheelCenter, fromOrbit, (fromAngle + tAngle), ellipseRotationAngle), GetPoint(wheelCenter, toOrbit, (toAngle + tAngle), ellipseRotationAngle) }.draw(0.4);
}

void DrawCrossingBezier(const Vec2& wheelCenter, const int32 fromOrbit, const double fromAngle,
	const int32 midOrbit, const double midAngle, const int32 toOrbit, const double toAngle, double tAngle, const double ellipseRotationAngle)
{
	const Vec2 p0 = GetPoint(wheelCenter, fromOrbit, (fromAngle + tAngle), ellipseRotationAngle);
	const Vec2 p1 = GetPoint(wheelCenter, midOrbit, (midAngle + tAngle), ellipseRotationAngle);
	const Vec2 p2 = GetPoint(wheelCenter, toOrbit, (toAngle + tAngle), ellipseRotationAngle);
	Bezier2{ p0, p1, p2 }.draw(0.4);
}

void Main()
{
	Window::Resize(1280, 720);

	const Size WheelTextureSize{ 1640, 1640 };
	const Point WheelCenter{ WheelTextureSize / 2 };
	const Size SmallWheelTextureSize{ 800, 800 };
	const Point SmallWheelCenter{ SmallWheelTextureSize / 2 };
	const MSRenderTexture renderTexture1{ WheelTextureSize, HasDepth::No, HasMipMap::Yes };
	const MSRenderTexture renderTexture2{ SmallWheelTextureSize, HasDepth::No, HasMipMap::Yes };
	const Array<std::pair<Texture, double>> icons{// { アイコン, 回転角度 }
		{ Texture{ 0xF0A00_icon, 80 }, 0_deg },   // ライトキーパー | lighthouse-on
		{ Texture{ 0xF0F2A_icon, 80 }, 0_deg },   // ファデュイ | snowflake-variant
		{ Texture{ 0xF0C4D_icon, 80 }, 0_deg },   // 霜月の子 | car-tire-alert
		{ Texture{ 0xF02F8_icon, 80 }, 0_deg },   // 雪国の妖精 | image-filter-vintage
		{ Texture{ 0xF1C38_icon, 80 }, 180_deg }, // 宝盗団 | octagram-plus
		{ Texture{ 0xF0EC8_icon, 80 }, 0_deg },   // ヴォイニッチ商会 | sail-boat
		{ Texture{ 0xF18BE_icon, 80 }, 0_deg },   // 西風騎士団 | shield-sword
		{ Texture{ 0xF0CF5_icon, 80 }, 0_deg },   // ワイルドハント | cross-celtic
		{ Texture{ 0xF059F_icon, 80 }, -45_deg }, // 魔女会 | web
		{ Texture{ 0xF0D14_icon, 80 }, 0_deg },   // カチャカチャ・クルムカケ工房 | heart-broken-outline
		{ Texture{ 0xF1382_icon, 80 }, 0_deg },   // 冒険者協会 | compass-rose
	};

	Array<Vec3> stars;
	while (stars.size() < 8000)
	{
		Vec2 pos = RandomVec2(Circle{ 820 });
		const Circular c = pos;
		const double d = c.r;

		// 角度に応じた星の密度調整
		if (d < 290)
		{
			if (InRange(c.theta, 20_deg, 135_deg) || (InRange(c.theta, 10_deg, 145_deg) && RandomBool(0.8)))
			{
				continue;
			}
		}
		else if (d < 460.0)
		{
			if ((not InRange(c.theta, -135_deg, 20_deg)) || (not InRange(c.theta, -125_deg, 10_deg) && RandomBool(0.8)))
			{
				continue;
			}
		}
		else
		{
			if (InRange(c.theta, -20_deg, 105_deg) || (InRange(c.theta, -10_deg, 115_deg) && RandomBool(0.8)))
			{
				continue;
			}
		}

		// 軌道半径に応じた星の密度調整
		if ((d < 110.0) || InRange(d, 220.0, 260.0) || InRange(d, 380.0, 430.0) || InRange(d, 550.0, 580.0) || InRange(d, 690.0, 730.0))
		{
			continue;
		}

		stars.emplace_back(pos.movedBy(WheelCenter), Random(0.2, 1.0));
	}

	Array<Vec4> backgroundStars;
	for (int32 i = 0; i < 150; ++i)
	{
		const Vec2 pos = RandomVec2(Rect{ 1280, 720 });
		const double size = Random(1.0, 3.0);
		const double hue = Random(160.0, 420.0);
		backgroundStars.emplace_back(pos, size, hue);
	}

	Array<double> iconTimes(icons.size());

	while (System::Update())
	{
		const double t = Scene::Time();
		const Vec2 mousePos = Cursor::PosF();
		const double offset = Periodic::Sine1_1(18s);
		const Quad quad1{ Vec2{ 150, 250 }, Vec2{ 1100, 40 }, Vec2{ (2200 + offset * 100), (480 + offset * 60) }, Vec2{ (-1000 + offset * 100), (1160 + offset * 60) } };
		const Quad quad2 = Quad{ Vec2{ 820, 530 }, Vec2{ 1420, 390 }, Vec2{ (2170 + offset * 50), (680 + offset * 30) }, Vec2{ 20 + offset * 50, 1130 + offset * 30 } };
		const Mat3x3 homography1 = Mat3x3::Homography(Rect{ WheelTextureSize }, quad1);

		// 背景
		{
			// グラデーション
			const Rect sceneRect = Scene::Rect();
			Triangle{ sceneRect.tl(), sceneRect.tr(), sceneRect.bl() }.draw(ColorF{ 0.09, 0.07, 0.23 }, ColorF{ 0.02, 0.02, 0.04 }, ColorF{ 0.02, 0.02, 0.04 });
			Triangle{ sceneRect.bl(), sceneRect.tr(), sceneRect.br() }.draw(ColorF{ 0.02, 0.02, 0.04 }, ColorF{ 0.02, 0.02, 0.04 }, ColorF{ 0.09, 0.07, 0.23 });

			// 背景の水玉模様
			for (int32 y = 0; y < 430; ++y)
			{
				for (int32 x = 0; x < 240; ++x)
				{
					if (IsEven(x + y))
					{
						Circle{ (x * 6 + 3), (y * 6 + 3), 2 }.draw(ColorF{ 0.02, 0.02, 0.05, 0.5 });
					}
				}
			}
		}

		// レンダーテクスチャ 1(メインの歯車)
		{
			const ScopedRenderTarget2D target{ renderTexture1.clear(Palette::Black) };

			// 軌道 1
			{
				Circle{ WheelCenter, 155 }.drawFrame(0.5);
				Circle{ WheelCenter, 170 }.drawFrame(1);
				Circle{ WheelCenter, 185 }.drawFrame(2);
			}

			// 軌道 2
			{
				{
					const Circle circle{ WheelCenter, 290 };
					circle.drawFrame(4);

					// larege
					{
						const Transformer2D tr{ Mat3x2::Translate(0, circle.r).rotated(t * -6_deg - 125_deg).translated(WheelCenter) };
						Shape2D::Astroid(Vec2{ 0, 0 }, 40, 58).draw();
						Shape2D::Astroid(Vec2{ 0, 0 }, 28, 37).draw(Palette::Black);
					}
					{
						const Transformer2D tr{ Mat3x2::Translate(0, circle.r).rotated(t * -6_deg).translated(WheelCenter) };
						Shape2D::Astroid(Vec2{ 0, 0 }, 40, 58).draw();
						Shape2D::Astroid(Vec2{ 0, 0 }, 28, 37).draw(Palette::Black);
					}
					{
						const Transformer2D tr{ Mat3x2::Translate(0, circle.r).rotated(t * -6_deg + 125_deg).translated(WheelCenter) };
						Shape2D::Astroid(Vec2{ 0, 0 }, 40, 58).draw();
						Shape2D::Astroid(Vec2{ 0, 0 }, 28, 37).draw(Palette::Black);
					}

					// small
					{
						const Transformer2D tr{ Mat3x2::Translate(0, circle.r + 6).rotated(t * -6_deg - 90_deg).translated(WheelCenter) };
						Shape2D::Astroid(Vec2{ 0, 0 }, 30, 54).draw(Palette::Black);
						Shape2D::Astroid(Vec2{ 0, 0 }, 16, 34).draw();
					}
					{
						const Transformer2D tr{ Mat3x2::Translate(0, circle.r + 6).rotated(t * -6_deg + 90_deg).translated(WheelCenter) };
						Shape2D::Astroid(Vec2{ 0, 0 }, 30, 54).draw(Palette::Black);
						Shape2D::Astroid(Vec2{ 0, 0 }, 16, 34).draw();
					}
				}

				Circle{ WheelCenter, 302 }.drawFrame(0.5);
			}

			// 軌道 2.5
			{
				const Circle circle{ WheelCenter, 385 };

				for (int32 i = 0; i < 240; ++i)
				{
					circle.getPointByAngle(i * 1.5_deg).asCircle(0.8).draw();
				}
			}

			// 軌道 3
			{
				{
					const Circle circle{ WheelCenter, 460 };
					circle.drawFrame(1.5);

					for (int32 i = 0; i < 24; ++i)
					{
						const Transformer2D tr{ Mat3x2::Translate(0, circle.r).rotated(t * 9_deg + i * 15_deg).translated(WheelCenter) };
						Shape2D::Astroid(Vec2{ 0, 0 }, 8, 12).draw();
					}
				}

				Circle{ WheelCenter, 470 }.drawFrame(0.5);
				Circle{ WheelCenter, 480 }.drawFrame(0.25);
			}

			// 軌道 3.5
			const double ellipseRotationAngle = (t * 6_deg);
			{
				const Transformer2D tr{ Mat3x2::Rotate(ellipseRotationAngle, WheelCenter) };
				Ellipse{ WheelCenter, 540, 520 }.drawFrame(0.5);
			}

			// 軌道 3, 3.5, 4 をつなぐ線
			{
				DrawCrossingLine(WheelCenter, 1, 350_deg, 2, 350_deg, (t * 7_deg), ellipseRotationAngle);
				DrawCrossingLine(WheelCenter, 0, 280_deg, 2, 285_deg, (t * 7_deg), ellipseRotationAngle);
				DrawCrossingLine(WheelCenter, 0, 200_deg, 2, 200_deg, (t * 7_deg), ellipseRotationAngle);
				DrawCrossingLine(WheelCenter, 0, 160_deg, 2, 160_deg, (t * 7_deg), ellipseRotationAngle);
				DrawCrossingLine(WheelCenter, 0, 120_deg, 2, 120_deg, (t * 7_deg), ellipseRotationAngle);
				DrawCrossingLine(WheelCenter, 1, 70_deg, 2, 70_deg, (t * 7_deg), ellipseRotationAngle);
				DrawCrossingLine(WheelCenter, 1, 70_deg, 2, 60_deg, (t * 7_deg), ellipseRotationAngle);
				DrawCrossingBezier(WheelCenter, 0, 300_deg, 1, 300_deg, 2, 340_deg, (t * 7_deg), ellipseRotationAngle);
				DrawCrossingBezier(WheelCenter, 0, 120_deg, 1, 120_deg, 2, 90_deg, (t * 7_deg), ellipseRotationAngle);
				DrawCrossingBezier(WheelCenter, 0, 180_deg, 1, 175_deg, 2, 185_deg, (t * 7_deg), ellipseRotationAngle);
				DrawCrossingBezier(WheelCenter, 0, 270_deg, 1, 270_deg, 2, 240_deg, (t * 7_deg), ellipseRotationAngle);
				DrawCrossingBezier(WheelCenter, 2, 0_deg, 1, 20_deg, 2, 40_deg, (t * 7_deg), ellipseRotationAngle);
				DrawCrossingBezier(WheelCenter, 0, 240_deg, 2, 210_deg, 1, 200_deg, (t * 7_deg), ellipseRotationAngle);
			}

			// 軌道 4
			{
				Circle{ WheelCenter, 598 }.drawFrame(0.5);
				Circle{ WheelCenter, 610 }.drawFrame(6);
				Circle{ WheelCenter, 625 }.drawFrame(2.5);
				Circle{ WheelCenter, 634 }.drawFrame(0.5);
			}

			// 軌道 5
			{
				Circle{ WheelCenter, 715 }.drawFrame(0.4);
				Circle{ WheelCenter, 755 }.drawFrame(1);

				{
					const Circle circle{ WheelCenter, 770 };
					circle.drawFrame(4);

					for (int32 i = 0; i < 8; ++i)
					{
						const Transformer2D tr{ Mat3x2::Translate(0, circle.r).rotated(t * -5_deg + i * 45_deg).translated(WheelCenter) };
						Shape2D::Astroid(Vec2{ 0, 0 }, 10, 18).draw();
					}
				}
			}

			// 月
			{
				const double outOffset = Periodic::Sine0_1(12s) * 30;
				const Circle circle{ WheelCenter, (480 + outOffset) };
				const Vec2 center = circle.getPointByAngle(77_deg);

				center.asCircle(160).draw(Palette::Black);
				center.asCircle(156).drawFrame(1);
				center.asCircle(142).drawFrame(13);
				center.asCircle(120).drawFrame(2.5);
				center.asCircle(116).drawFrame(1);

				{
					const Circle inCircle{ center, 100 };

					for (int32 i = 0; i < 90; ++i)
					{
						inCircle.getPointByAngle(i * 4_deg).asCircle(1).draw();
					}
				}

				center.asCircle(86).draw();
				center.movedBy(-13, -18).asCircle(62).draw(Palette::Black);
				center.movedBy(-18, -20).asCircle(52).draw();
				center.movedBy(-26, -24).asCircle(46).draw(Palette::Black);
			}
		}

		// レンダーテクスチャ 2(右下の歯車)
		{
			const ScopedRenderTarget2D target{ renderTexture2.clear(Palette::Black) };

			// 軌道 1
			Circle{ SmallWheelCenter, 214 }.drawFrame(1);

			// 軌道 2
			{
				const Circle circle{ SmallWheelCenter, 228 };

				for (int32 i = 0; i < 240; ++i)
				{
					circle.getPointByAngle(i * 1.5_deg + t * -6_deg).asCircle(1.0).draw();
				}
			}

			// 軌道 3 と 軌道 4 をつなぐ線
			{
				const Circle circle{ SmallWheelCenter, 280 };

				for (int32 i = 0; i < 120; ++i)
				{
					const double angleSize = ((i % 5 == 0) ? 1.7_deg : 0.7_deg);
					circle.drawArc(i * 3_deg + t * -6_deg - (angleSize * 0.5), angleSize, 30, 0, ColorF{ 0.25 });
				}
			}

			// 軌道 3
			Circle{ SmallWheelCenter, 250 }.drawFrame(3);

			// 軌道 4
			{
				const Circle circle{ SmallWheelCenter, 280 };
				circle.drawFrame(1.5);

				for (int32 i = 0; i < 24; ++i)
				{
					const Transformer2D tr{ Mat3x2::Translate(0, circle.r).rotated(i * 15_deg + t * -6_deg).translated(SmallWheelCenter) };
					Shape2D::Astroid(Vec2{ 0, 0 }, 7, 10).draw();
				}
			}

			// 軌道 5
			Circle{ SmallWheelCenter, 295 }.drawFrame(1);
		}

		// レンダーテクスチャへの描画完了処理
		{
			Graphics2D::Flush();
			renderTexture1.resolve();
			renderTexture1.generateMips();
			renderTexture2.resolve();
			renderTexture2.generateMips();
		}

		// 背景の星
		for (const auto& star : backgroundStars)
		{
			const double s = std::sin(Math::Fraction(star.z * 100.0) * 2_pi + t * 0.25_pi);
			const double shine = 1.0 - ((s + 1.0) * 0.4);
			Circle{ star.xy(), (star.z * shine) }.draw(HSV{ star.w, 0.4, 1.0 });
		}

		// 星
		{
			const ScopedRenderStates2D add{ BlendState::Additive, SamplerState::ClampAniso };

			for (const auto& star : stars)
			{
				Vec2 pos = homography1.transformPoint(star.xy());
				const double r = ((homography1.transformPoint(star.xy().movedBy(1, 0)).distanceFrom(pos)) * star.z);
				const double s = std::sin(Math::Fraction(star.z * 100.0) * 2_pi + t * 1_pi);
				const double shine = 1.0 - ((s + 1.0) * 0.4);

				// マウスカーソル周辺の星を押し出す
				if (mousePos.distanceFrom(pos) < 70)
				{
					pos += (pos - mousePos).setLength((70 - mousePos.distanceFrom(pos)) * 0.4);
				}

				Circle{ pos, (r * 3.6) }.draw(ColorF{ 0.15, 0.2, 0.4, shine }, ColorF{ 0.01, 0.01, 0.02, shine });
				Circle{ pos, (r * 0.75) }.draw(ColorF{ 0.4, 0.5, 0.8, shine });
			}
		}

		// レンダーテクスチャを QuadWarp を用いて立体的に描画
		{
			const ScopedRenderStates2D add{ BlendState::Additive, SamplerState::ClampAniso };
			{
				const ScopedColorMul2D colorMul{ ColorF{ 1.0, (0.5 - Periodic::Sine0_1(6s) * 0.25) } };
				Shader::QuadWarp(quad2, renderTexture2);
			}
			{
				const ScopedColorMul2D colorMul{ ColorF{ 1.0, (1.0 - Periodic::Sine0_1(6s) * 0.5) } };
				Shader::QuadWarp(quad1, renderTexture1);
			}
		}

		// Y 軸の点線(Y-up)
		{
			const Vec2 center = homography1.transformPoint(WheelCenter);
			const Vec2 begin{ (center.x - 90), (center.y - 360) };
			const Vec2 dir = (center - begin).normalized();

			for (int32 i = 0; i < 170; ++i)
			{
				Line{ Vec2{ begin + dir * (i * 5) }, Vec2{ begin + dir * (i * 5 + 2.5) } }.draw(0.25);
			}
		}

		// X 軸の点線
		{
			const Vec2 center = homography1.transformPoint(WheelCenter);
			const Vec2 begin{ (center.x - 660), (center.y + 140) };
			const Vec2 dir = (center - begin).normalized();

			for (int32 i = 0; i < 280; ++i)
			{
				Line{ Vec2{ begin + dir * (i * 5) }, Vec2{ begin + dir * (i * 5 + 2.5) } }.draw(0.3);
			}
		}

		// Z 軸の点線
		{
			const double xOffset = (Periodic::Sine1_1(40s) * 300);
			const Vec2 center = homography1.transformPoint(WheelCenter);
			const Vec2 begin{ (center.x - 400 + xOffset), (center.y + 420) };
			const Vec2 dir = (center - begin).normalized();

			// 中心から手前への点線
			for (int32 i = 0; i < 150; ++i)
			{
				const Vec2 pos{ center - dir * (i * 5) };
				pos.asCircle(0.5).draw();
			}

			// 中心から奥への点線
			for (int32 i = 0; i < 150; ++i)
			{
				const Vec2 pos{ center + dir * (i * 5) };
				pos.asCircle(0.4).draw();
			}
		}

		// ボタン
		{
			// 軌道 2
			for (int32 i = 0; i < 3; ++i)
			{
				DrawButton(Circle{ WheelCenter, 270 }, (i * 120_deg + t * -6_deg), homography1, icons[i], iconTimes[i]);
			}

			// 軌道 4
			for (int32 i = 0; i < 8; ++i)
			{
				DrawButton(Circle{ WheelCenter, 580 }, (i * 45_deg + t * -2_deg), homography1, icons[3 + i], iconTimes[3 + i]);
			}
		}
	}
}
Ryo SuzukiRyo Suzuki

ミニゲームサンプル『Tornado Strike』

遊び方

  1. マウスを動かして進路を決定!

    • 左クリックで――ドーン!竜巻をぶっ放せ!
  2. 目指せ一撃で全壊!

    • すべての家を一発で島の外へ吹き飛ばせばステージクリア!
  3. 油断大敵!

    • 木の後ろにこっそり隠れている家を見落とさないように注意だ!
コード
# include <Siv3D.hpp>

struct Tornado
{
	Bezier2 bezier;
	Bezier2Path path;
	double strength;
};

struct Building
{
	int32 type = 0; // 0: 家, 1: 木
	Vec2 origin{ 0, 0 };
	Vec2 velocity{ 0, 0 };
	Vec2 pos = origin;
};

Array<Building> GenerateBuildings()
{
	Array<Building> buildings(6, Arg::generator = []() { return Building{ Random(0, 1), RandomVec2(Circle{ 500, 400, 190 }), Vec2{ 0, 0 } }; });
	buildings.sort_by([](const auto& a, const auto& b) { return a.pos.y < b.pos.y; });
	buildings[0].type = 0; // 少なくとも 1 つは家にする
	return buildings;
}

void Main()
{
	Window::SetTitle(U"Tornado Strike");
	Window::Resize(600, 800);

	constexpr Quad quad{ { 0,0 }, { 600, 0 }, { 1200, 800 }, { -600, 800 } };
	constexpr double StepTime = 0.005;
	const Font font{ FontMethod::MSDF, 48, Typeface::Heavy };
	const Texture tornadoTexture{ U"🌪"_emoji };
	const Array<Texture> buildingTextures{ Texture{ U"🏡"_emoji }, Texture{ U"🌳"_emoji } };
	const MSRenderTexture renderTexture{ Size{ 1000, 1200 }, HasDepth::No, HasMipMap::Yes };
	const Mat3x3 homography = Mat3x3::Homography(Rect{ renderTexture.size() }, quad);

	Stopwatch resultStopwatch;
	double accumulatedTime = 0.0;
	int32 highScore = 0;
	int32 score = 0;
	Array<Building> buildings = GenerateBuildings();
	Optional<Tornado> tornado;

	while (System::Update())
	{
		const double t = Scene::Time();
		const Vec2 tornadoOffset{ (Periodic::Triangle1_1(0.1s) * 2), (Periodic::Triangle1_1(0.13s) * 2) };
		const Bezier2 bezier{ { 500, 1200 }, (Scene::Center() - (Scene::Center() - Cursor::Pos()) * 4), { 500, 380 } };

		if (tornado)
		{
			accumulatedTime += Scene::DeltaTime();

			while (StepTime <= accumulatedTime) // 竜巻の移動と建物への影響の計算
			{
				accumulatedTime -= StepTime;
				tornado->path.advance(600.0 * StepTime);
				tornado->strength -= (20.0 * StepTime);
				const Vec2 tornadoPos = (tornado->bezier.getPos(tornado->path.getT()) + tornadoOffset);

				for (auto& b : buildings)
				{
					if (b.type == 0) // 家に対してのみ影響
					{
						const Vec2 toTornado = (tornadoPos - b.pos);

						if (const double distance = toTornado.length(); distance < 150.0)
						{
							const double force = ((1.0 - (distance / 150.0)) * tornado->strength * 400.0);
							b.velocity += ((toTornado / distance) * force * StepTime);
						}

						b.pos += (b.velocity * StepTime);
						b.velocity -= (b.velocity * (3 * StepTime));
					}
				}
			}

			if (not resultStopwatch.isStarted())
			{
				if (buildings.none([](const auto& b) { return (b.type == 0) && Circle { 500, 400, 240 }.contains(b.pos); })) // 成功
				{
					resultStopwatch.restart();
					highScore = Max(++score, highScore);
				}
				else if (tornado->strength <= 0.0) // 失敗
				{
					resultStopwatch.restart();
					score = 0;
				}
			}
			else if (1.8s < resultStopwatch) // 次のステージへ
			{
				tornado.reset();
				resultStopwatch.reset();
				buildings = GenerateBuildings();
			}
		}
		else
		{
			Cursor::RequestStyle(CursorStyle::Hand);

			if (MouseL.down()) // 竜巻発射
			{
				tornado = Tornado{ bezier, Bezier2Path{ bezier }, 100.0 };
				accumulatedTime = 0.0;
			}
		}

		{
			const ScopedRenderTarget2D target{ renderTexture.clear(ColorF{ 0.0, 0.4, 0.6 }) };

			for (auto&& [x, y] : step({ 101, 121 })) // 海の描画
			{
				const double a = Math::Pow((std::sin((t + (std::sin(x / 4.0) / 2.0)) * 30_deg + y * 32_deg) * 0.5 + 0.5), 6);
				Circle{ (x * 10), (y * 10), 5 }.draw(ColorF{ 0.1, 0.45, 0.65 }.lerp(ColorF{ 0.6, 0.7, 0.8 }, a));
			}

			font(score).drawAt(196, Vec2{ 500, 1096 }, ColorF{ 1.0, 0.4 });
			font(U"HI-SCORE: {}"_fmt(highScore)).drawAt(20, Vec2{ 400, 1184 }, ColorF{ 1.0, 0.4 });
			Circle{ 500, 400, 244 }.movedBy(0, Periodic::Sine1_1(4s) * 4).draw();
			Circle{ 500, 400, 240 }.stretched(-2).draw(ColorF{ 0.9, 0.7, 0.4 }).stretched(-8, -12).draw(ColorF{ 0.4, 0.8, 0.2 }, ColorF{ 0.5, 0.8, 0.3 });

			if (tornado)
			{
				tornado->bezier.draw(1); // 発射後の進路表示
			}
			else
			{
				bezier.draw(22, ColorF{ 1.0 }).draw(18, ColorF{ 0.9, 0.6, 0.7 }); // 発射前の進路表示
			}
		}

		{
			Graphics2D::Flush();
			renderTexture.resolve();
			renderTexture.generateMips();
			const ScopedRenderStates2D sampler{ SamplerState::ClampAniso };
			Shader::QuadWarp(quad, renderTexture);
		}

		const Vec2 tornadoPos = (tornado ? tornado->bezier.getPos(tornado->path.getT()) : Vec2{ 500, 1200 }) + tornadoOffset;

		for (const auto& b : buildings)
		{
			const Vec2 pos = homography.transformPoint(b.pos);
			const double scale = homography.transformPoint(b.pos).distanceFrom(homography.transformPoint(b.pos.movedBy(1, 0)));

			if (b.type == 0) // 家の描画
			{
				buildingTextures[b.type].scaled(0.6 * scale).rotated(b.pos.distanceFrom(b.origin) * 2_deg).drawAt(pos);
			}
			else // 木の描画
			{
				const double force = Min(Math::Saturate(1.0 - ((tornadoPos - b.pos).length() / 120.0)) * (tornado ? tornado->strength : 0.0), 10.0);
				const Transformer2D tr{ Mat3x2::Translate(0, -60).shearedX(Periodic::Sine1_1(0.2s) * force * 1_deg).translated(pos + Vec2{ 0, 60 }) };
				buildingTextures[b.type].scaled(0.6 * scale).drawAt(Vec2{ 0,0 });
			}
		}

		const double scale = homography.transformPoint(tornadoPos).distanceFrom(homography.transformPoint(tornadoPos.movedBy(1, 0)));
		tornadoTexture.scaled(scale).draw(Arg::bottomCenter = homography.transformPoint(tornadoPos), ColorF{ 1.0, (tornado ? Math::Pow(tornado->strength / 100.0, 0.25) : 1.0) });

		if (resultStopwatch.isRunning()) // 結果表示
		{
			const double s = resultStopwatch.sF();
			const bool miss = (score == 0);
			RectF{ (-4000 + s * 3000), 300, 4000, 200 }.draw(miss ? ColorF{ 0.3, 0.2, 0.5 } : ColorF{ 0.9, 0.6, 0.1 });
			RectF{ (-4000 + s * 3000), 310, 4000, 6 }.draw().movedBy(0, 174).draw();
			font(miss ? U"MISS!" : U"STRIKE!").drawAt((EaseOutBack(Min(s, 1.0)) * 100), Vec2{ 300, 400 }, ColorF{ 1.0, (s < 1.0 ? 1.0 : Max((1.4 - s) * 2.5, 0.0)) });

			if (1.4 < s)
			{
				Scene::Rect().draw(ColorF{ 1.0, (s - 1.4) * 2.5 });
			}
		}
	}
}