モダンC++を使ってオセロを作ってみた（DXライブラリ）

今さら感はあるもののオセロで最強のAIを作ってみた！って記事です、モチベが結構出てきたの書いてみたコードですC++初めてまだ半年しか経っていないので大目に見てください。完全な自分のコードではなく調べながら作ったものです。何言ってんのかわかんねーよって人はごめんなさいって感じです；；リンクは貼っておきます。
IDE　visual studio 2022
projectのプロパティを開きC++/言語/C++言語標準ISO　C++20標準(/std:C++20)にしておいてください。
C++の機能で分からないことがある場合は<a href= "https://rinatz.github.io/cpp-book/" >ここ</a>または自力で調べてみてください
まずはメインにてDXライブラリの初期化の一部をします。
<details><summary>main.cpp</summary>

```C++:main 
#include "GameManager.h"
#include "DxLib.h"
int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int) {
	ChangeWindowMode(TRUE);                      
	SetGraphMode(1920, 1080, 32);               
	SetMainWindowText("Othello Game");          
	SetOutApplicationLogValidFlag(FALSE);       
	if (DxLib_Init() != 0) { return -1; }        // 初期化に失敗した場合は終了}
	SetDrawScreen(DX_SCREEN_BACK);               // 裏画面を描画先に設定
	SetMouseDispFlag(TRUE);                      // マウスカーソルを表示状態に
	GameManager game;
	game.run();                                  // ゲームループ開始
	DxLib_End();                                 // DXライブラリ使用の終了処理
	return 0;
}
```
</details>
この記事を見に来ているということは少しだけでもDXライブラリの知識があると考えていますので初期化の説明は割愛します。
もしわからない人は皆さま<a href= "https://af-e.net/dx-library-initialization/" >こちら</a>
に行かれるそうですよ。


<details><summary>board.h</summary>

```C++:board.h
#pragma once
#include <vector>
#include <array>
#include <optional>


enum class Disc { Empty, Black, White };

class Board {
public:
	Board() noexcept;                           
	void reset() noexcept;                      
	[[nodiscard]] bool is_valid_move(int row, int col, Disc color) const noexcept;
	[[nodiscard]] std::vector<std::pair<int, int>> get_valid_moves(Disc color) const;
	bool place_disc(int row, int col, Disc color);
	[[nodiscard]] bool has_valid_move(Disc color) const noexcept;
	[[nodiscard]] int count_discs(Disc color) const noexcept;
	[[nodiscard]] std::optional<Disc> winner() const noexcept;
	[[nodiscard]] Disc get_disc(int r, int c) const noexcept;
	static constexpr int SIZE = 8;                // ボードのサイズ（8x8）
private:
	std::array<std::array<Disc, SIZE>, SIZE> cells;     // 各マスの状態を保持
	inline static constexpr std::array<std::pair<int, int>, 8> directions = { {
		{0,1},{0,-1},{1,0},{-1,0},{1,1},{1,-1},{-1,1},{-1,-1}
	} };  // 8方向の隣接座標への変位
	static bool in_bounds(int r, int c) noexcept;        // 座標がボード内か判定
	static Disc opponent_of(Disc color) noexcept;        // 相手の色を取得
};
```

</details>
<details><summary>board.cpp</summary>

```C++:board.cpp
#include "Board.h"
#include <algorithm>
#include <ranges>
using namespace std;

Board::Board() noexcept {
	reset();
}

void Board::reset() noexcept {
	// 全マスを Empty に初期化
	for (auto& row : cells) {
		for (auto& cell : row) {
			cell = Disc::Empty;
		}
	}
	// 初期配置（中央4マスに黒白配置）
	cells[3][3] = Disc::White;
	cells[3][4] = Disc::Black;
	cells[4][3] = Disc::Black;
	cells[4][4] = Disc::White;
}

bool Board::in_bounds(int r, int c) noexcept {
	return 0 <= r && r < SIZE && 0 <= c && c < SIZE;
}

Disc Board::opponent_of(Disc color) noexcept {
	if (color == Disc::Black) return Disc::White;
	if (color == Disc::White) return Disc::Black;
	return Disc::Empty;
}

bool Board::is_valid_move(int row, int col, Disc color) const noexcept {
	if (!in_bounds(row, col) || cells[row][col] != Disc::Empty) {
		return false;  // 盤外または既に石あり
	}
	Disc opp = opponent_of(color);
	if (opp == Disc::Empty) return false;
	// 8方向をチェック
	for (auto [dr, dc] : directions) {
		int r = row + dr;
		int c = col + dc;
		bool found_opponent = false;
		// 隣が相手の石か確認
		if (in_bounds(r, c) && cells[r][c] == opp) {
			found_opponent = true;
			r += dr;
			c += dc;
		}
		else {
			continue;  // 隣が相手でなければこの方向は不可
		}
		// 相手の石の先を進めていく
		while (in_bounds(r, c)) {
			if (cells[r][c] == opp) {
				// 相手の石が連続
				r += dr;
				c += dc;
			}
			else if (cells[r][c] == color) {
				// 自分の石で挟めた
				if (found_opponent) {
					return true;
				}
				else {
					break;
				}
			}
			else {
				// 空マスに到達 or 想定外の値
				break;
			}
		}
	}
	return false;
}

std::vector<std::pair<int, int>> Board::get_valid_moves(Disc color) const {
	std::vector<std::pair<int, int>> moves;
	moves.reserve(32);
	for (int r = 0; r < SIZE; ++r) {
		for (int c = 0; c < SIZE; ++c) {
			if (is_valid_move(r, c, color)) {
				moves.emplace_back(r, c);
			}
		}
	}
	
	return moves;
}

bool Board::place_disc(int row, int col, Disc color) {
	if (!is_valid_move(row, col, color)) {
		return false;
	}
	Disc opp = opponent_of(color);
	cells[row][col] = color;  // 石を置く
	// 8方向それぞれについて挟める相手石をひっくり返す
	for (auto [dr, dc] : directions) {
		int r = row + dr;
		int c = col + dc;
		if (!in_bounds(r, c) || cells[r][c] != opp) continue;  // 隣が相手でなければ次
		std::vector<std::pair<int, int>> to_flip;
		// 相手の石を一方向にずっとたどる
		while (in_bounds(r, c) && cells[r][c] == opp) {
			to_flip.emplace_back(r, c);
			r += dr;
			c += dc;
		}
		// 最後に自分の石があればその方向の石をひっくり返す
		if (in_bounds(r, c) && cells[r][c] == color) {
			for (auto [fr, fc] : to_flip) {
				cells[fr][fc] = color;
			}
		}
	}
	return true;
}

bool Board::has_valid_move(Disc color) const noexcept {
	for (int r = 0; r < SIZE; ++r) {
		for (int c = 0; c < SIZE; ++c) {
			if (is_valid_move(r, c, color)) {
				return true;
			}
		}
	}
	return false;
}

int Board::count_discs(Disc color) const noexcept {
	int count = 0;
	for (const auto& row : cells) {
		count += std::ranges::count(row, color);
	}
	return count;
}

std::optional<Disc> Board::winner() const noexcept {
	int black = count_discs(Disc::Black);
	int white = count_discs(Disc::White);
	if (black == white) {
		return std::nullopt;  // 引き分け
	}
	return (black > white ? Disc::Black : Disc::White);
}

Disc Board::get_disc(int r, int c) const noexcept {
	return cells[r][c];
}


```
</details>

ここではオセロのボードを管理するためのクラスを実装しています。主な機能としては
盤面の初期化、石を置く、置ける場所の確認、石のカウント、勝者の判定を行っています。
要するにオセロのゲーム進行に必要なボード操作をしていますね。


<details><summary>renderer.h</summary>

```
#pragma once
#include "Board.h"

class Renderer {
public:
	Renderer(int board_offset_x, int board_offset_y, int cell_size);
	void draw_board(const Board& board) const noexcept;
	void draw_hints(const Board& board, Disc color) const noexcept;
	void draw_result(std::optional<Disc> winner_opt) const noexcept;
private:
	int offset_x;
	int offset_y;
	int cell;
	unsigned int col_board;
	unsigned int col_line;
	unsigned int col_black;
	unsigned int col_white;
	unsigned int col_hint;
	unsigned int col_text;
};

```

</details>



<details><summary>renderer.cpp</summary>

```
#include "renderer.h"
#include "DxLib.h"
#include <string>
using namespace std;

Renderer::Renderer(int board_offset_x, int board_offset_y, int cell_size)
	: offset_x(board_offset_x), offset_y(board_offset_y), cell(cell_size) {
	// 使用する色を取得
	col_board = GetColor(0, 128, 0);
		col_line = GetColor(0, 0, 0);     
	col_black = GetColor(0, 0, 0); 
		col_white = GetColor(255, 255, 255);
		col_hint = GetColor(0, 255, 255);  
	col_text = GetColor(255, 255, 255);
}

void Renderer::draw_board(const Board& board) const noexcept {
	// ボード背景（緑色の矩形）を描画
	DrawBox(offset_x, offset_y, offset_x + cell * Board::SIZE,
		offset_y + cell * Board::SIZE, col_board, TRUE);
	// 格子線を描画
	for (int i = 0; i <= Board::SIZE; ++i) {
		// 縦線
		DrawLine(offset_x + i * cell, offset_y,
			offset_x + i * cell, offset_y + cell * Board::SIZE, col_line);
		// 横線
		DrawLine(offset_x, offset_y + i * cell,
			offset_x + cell * Board::SIZE, offset_y + i * cell, col_line);
	}
	// 石を描画
	int radius = cell / 2 - 2;  // 石の半径（マス目より少し小さめにする）
	for (int r = 0; r < Board::SIZE; ++r) {
		for (int c = 0; c < Board::SIZE; ++c) {
			Disc d = board.get_disc(r, c);
			if (d == Disc::Black) {
				// 黒石を描画
				int cx = offset_x + c * cell + cell / 2;
				int cy = offset_y + r * cell + cell / 2;
				DrawCircle(cx, cy, radius, col_black, TRUE);
			}
			else if (d == Disc::White) {
				// 白石を描画
				int cx = offset_x + c * cell + cell / 2;
				int cy = offset_y + r * cell + cell / 2;
				DrawCircle(cx, cy, radius, col_white, TRUE);
			}
		}
	}
}

void Renderer::draw_hints(const Board& board, Disc color) const noexcept {
	// 打てる手の位置に小さな円を描画してヒント表示
	auto moves = board.get_valid_moves(color);
	int hr = cell / 8;  // ヒント用の小さな円の半径
	for (auto [r, c] : moves) {
		int cx = offset_x + c * cell + cell / 2;
		int cy = offset_y + r * cell + cell / 2;
		DrawCircle(cx, cy, hr, col_hint, TRUE);
	}
}

void Renderer::draw_result(std::optional<Disc> winner_opt) const noexcept {

	string result;
	if (!winner_opt.has_value()) {
		result = "Draw!";  // 引き分け
	}
	else if (winner_opt.value() == Disc::Black) {
		result = "Player (Black) Wins!";
	}
	else {
		result = "AI (White) Wins!";
	}
	// 盤面中央付近に結果文字列を描画
	int text_x = offset_x + (cell * Board::SIZE) / 2 - 100;
	int text_y = offset_y + (cell * Board::SIZE) / 2 - 10;
	DrawString(text_x, text_y, result.c_str(), col_text);
}

```

</details>

盤面のオフセットとセルのサイズを受け取って色を設定したりしています。
DXライブラリの描画関数をカプセル化して盤面のヒント結果を描画させています。
あとゲームの結果なんかとかも。


<details><summary>ai.h</summary>

```
#pragma once
#include "Board.h"
#include <optional>

class AI {
public:
	AI(Disc ai_color);
	[[nodiscard]] std::optional<std::pair<int, int>> find_best_move(const Board& board) const;
private:
	Disc ai_color;
	inline static const int weight[Board::SIZE][Board::SIZE] = {
		{100, -20,  10,   5,   5,  10, -20, 100},
		{-20, -50,  -2,  -2,  -2,  -2, -50, -20},
		{ 10,  -2,   5,   1,   1,   5,  -2,  10},
		{  5,  -2,   1,   1,   1,   1,  -2,   5},
		{  5,  -2,   1,   1,   1,   1,  -2,   5},
		{ 10,  -2,   5,   1,   1,   5,  -2,  10},
		{-20, -50,  -2,  -2,  -2,  -2, -50, -20},
		{100, -20,  10,   5,   5,  10, -20, 100}
	};
	int depth_limit;
	Disc opponent_of(Disc color) const noexcept;
	[[nodiscard]] int evaluate(const Board& board) const noexcept;
	int minimax(Board board, Disc current, int depth, int alpha, int beta) const;
	
};


```


</details>





<details><summary>ai.cpp</summary>

```
#include "ai.h"
#include <limits>
using namespace std;

AI::AI(Disc ai_color) : ai_color(ai_color) {
	depth_limit = 5;  // 探索深さを5手先までに設定
}

Disc AI::opponent_of(Disc color) const noexcept {
	return (color == Disc::Black ? Disc::White
		: (color == Disc::White ? Disc::Black : Disc::Empty));
}

int AI::evaluate(const Board& board) const noexcept {
	Disc opp = opponent_of(ai_color);
	int score = 0;
	// 位置ごとの重みを集計
	for (int r = 0; r < Board::SIZE; ++r) {
		for (int c = 0; c < Board::SIZE; ++c) {
			Disc d = board.get_disc(r, c);
			if (d == ai_color) {
				score += weight[r][c];
			}
			else if (d == opp) {
				score -= weight[r][c];
			}
		}
	}
	// 確定石の数を数える（簡易的な安定判定）
	auto stable_count = [&](Disc color) {
		int count = 0;
		bool stable[Board::SIZE][Board::SIZE] = {};  // 安定判定済みフラグ
		// 各隅から同じ色が途切れず続く範囲を安定とみなす
		if (board.get_disc(0, 0) == color) {
			stable[0][0] = true;
			for (int x = 1; x < Board::SIZE; ++x) {
				if (board.get_disc(0, x) == color && stable[0][x - 1]) stable[0][x] = true;
				else break;
			}
			for (int y = 1; y < Board::SIZE; ++y) {
				if (board.get_disc(y, 0) == color && stable[y - 1][0]) stable[y][0] = true;
				else break;
			}
		}
		if (board.get_disc(0, Board::SIZE - 1) == color) {
			stable[0][Board::SIZE - 1] = true;
			for (int x = Board::SIZE - 2; x >= 0; --x) {
				if (board.get_disc(0, x) == color && stable[0][x + 1]) stable[0][x] = true;
				else break;
			}
			for (int y = 1; y < Board::SIZE; ++y) {
				if (board.get_disc(y, Board::SIZE - 1) == color && stable[y - 1][Board::SIZE - 1]) stable[y][Board::SIZE - 1] = true;
				else break;
			}
		}
		if (board.get_disc(Board::SIZE - 1, 0) == color) {
			stable[Board::SIZE - 1][0] = true;
			for (int x = 1; x < Board::SIZE; ++x) {
				if (board.get_disc(Board::SIZE - 1, x) == color && stable[Board::SIZE - 1][x - 1]) stable[Board::SIZE - 1][x] = true;
				else break;
			}
			for (int y = Board::SIZE - 2; y >= 0; --y) {
				if (board.get_disc(y, 0) == color && stable[y + 1][0]) stable[y][0] = true;
				else break;
			}
		}
		if (board.get_disc(Board::SIZE - 1, Board::SIZE - 1) == color) {
			stable[Board::SIZE - 1][Board::SIZE - 1] = true;
			for (int x = Board::SIZE - 2; x >= 0; --x) {
				if (board.get_disc(Board::SIZE - 1, x) == color && stable[Board::SIZE - 1][x + 1]) stable[Board::SIZE - 1][x] = true;
				else break;
			}
			for (int y = Board::SIZE - 2; y >= 0; --y) {
				if (board.get_disc(y, Board::SIZE - 1) == color && stable[y + 1][Board::SIZE - 1]) stable[y][Board::SIZE - 1] = true;
				else break;
			}
		}

		for (int i = 0; i < Board::SIZE; ++i) {
			for (int j = 0; j < Board::SIZE; ++j) {
				if (stable[i][j] && board.get_disc(i, j) == color) {
					count++;
				}
			}
		}
		return count;
		};
	int stable_ai = stable_count(ai_color);
	int stable_opp = stable_count(opp);
	score += (stable_ai - stable_opp) * 20; 
	return score;
}

int AI::minimax(Board board, Disc current, int depth, int alpha, int beta) const {
	if (depth == 0 || (!board.has_valid_move(Disc::Black) && !board.has_valid_move(Disc::White))) {
		return evaluate(board);
	}
	Disc opp = opponent_of(current);
	if (current == ai_color) {
		int maxEval = numeric_limits<int>::min();
		auto moves = board.get_valid_moves(current);
		if (moves.empty()) {
			// 手番をパス
			if (board.has_valid_move(opp)) {
				return minimax(board, opp, depth, alpha, beta);
			}
			else {
				return evaluate(board);
			}
		}
		for (auto [r, c] : moves) {
			Board newBoard = board;
			newBoard.place_disc(r, c, current);
			int eval = minimax(newBoard, opp, depth - 1, alpha, beta);
			if (eval > maxEval) maxEval = eval;
			if (eval > alpha) alpha = eval;
			if (beta <= alpha) break;  // 枝刈り
		}
		return maxEval;
	}
	else {
		// 相手（プレイヤー）の手番：評価値を最小化する
		int minEval = numeric_limits<int>::max();
		auto moves = board.get_valid_moves(current);
		if (moves.empty()) {
			if (board.has_valid_move(opp)) {
				return minimax(board, opp, depth, alpha, beta);
			}
			else {
				return evaluate(board);
			}
		}
		for (auto [r, c] : moves) {
			Board newBoard = board;
			newBoard.place_disc(r, c, current);
			int eval = minimax(newBoard, opp, depth - 1, alpha, beta);
			if (eval < minEval) minEval = eval;
			if (eval < beta) beta = eval;
			if (beta <= alpha) break;
		}
		return minEval;
	}
}

optional<pair<int, int>> AI::find_best_move(const Board& board) const {
	auto moves = board.get_valid_moves(ai_color);
	if (moves.empty()) {
		return nullopt;  // 手がないのでパス
	}
	int bestVal = numeric_limits<int>::min();
	pair<int, int> bestMove = moves[0];
	// 全ての合法手を試し、最も良い評価値の手を選ぶ
	for (auto [r, c] : moves) {
		Board newBoard = board;
		newBoard.place_disc(r, c, ai_color);
		// 1手打った後、残りdepth_limit-1の深さで探索
		int val = minimax(newBoard, opponent_of(ai_color), depth_limit - 1,
			numeric_limits<int>::min(), numeric_limits<int>::max());
		if (val > bestVal) {
			bestVal = val;
			bestMove = { r, c };
		}
	}
	return bestMove;
}

```

</details>

今回の主役ですね、オセロのAIのロジックを実装するためのクラスです
find_best_move関数を中心にAIが最適な手を選択するための評価と探索をやっています。
盤面の各位置に対して評価点を加算していく重み付きスコアを使っています。
盤面の隅や端にある石も考慮していて、ゲームが進行する中で動かない石を見極めるためです
ミニマックスアルゴリズムを使用してAIが有利な手を選ぶための考え事をしています。枝刈りを使用して不要な探索を省略しています。これにより計算量を減らしています。
後は考えさせるだけです、すべてのおける場所に対してfind_best_move関数が選んでいます。
簡単に言うとこのクラスは、オセロAIの思考エンジンを担当し、ミニマックスアルゴリズムを使用して、AIが最適な手を選択できるようにしています。盤面の評価を重み付けし、安定した石を重要視することで、AIは賢く最適な手を選ぶことができます。とAIが言っています。


お次にgamemanagerのクラスを設計していきます


<details><summary>gamemanager.h</summary>

```C++:gamemanager.h 
#pragma once
#include "Board.h"
#include "AI.h"
#include "Renderer.h"
#include <memory>

/// ゲーム全体の進行を管理するクラス
class GameManager {
public:
	GameManager();
	void run() noexcept;
private:
	std::unique_ptr<Board> board;
	std::unique_ptr<AI> ai;
	std::unique_ptr<Renderer> renderer;
	Disc human_color;
	Disc ai_color;
	int offset_x;
	int offset_y;
	int cell_size;
	int place_sound;
	int finish_sound;
	int bgm_sound;
};
```

</details>





<details><summary>gamemanager.cpp</summary>

```C++:gamemanager.cpp

#include "gamemanager.h"
#include "DxLib.h"

GameManager::GameManager() {
	board = std::make_unique<Board>();
	human_color = Disc::Black;
	ai_color = Disc::White;
	ai = std::make_unique<AI>(ai_color);
	// ボードを画面中央に配置するための計算
	cell_size = 135;  // 135px * 8 = 1080px （縦方向ピッタリ）
	screen_width_x = (1920 - cell_size * Board::SIZE) / 2;
	screen_height_y = (1080 - cell_size * Board::SIZE) / 2;
	renderer = std::make_unique<Renderer>(screen_width_x, screen_height_y, cell_size);
	// 効果音の読み込み
	place_sound = LoadSoundMem("place.wav");   // 石を置いたときの効果音
	finish_sound = LoadSoundMem("finish.wav"); // ゲーム終了時の効果音
	bgm_sound = LoadSoundMem("Data/bgm.wav");  // BGM
	ChangeVolumeSoundMem(50, bgm_sound);       // 音量を50%に設定
	ChangeVolumeSoundMem(50, place_sound);
	ChangeVolumeSoundMem(50, finish_sound);
	PlaySoundMem(bgm_sound, DX_PLAYTYPE_LOOP); // BGMをループ再生
}

void GameManager::run() noexcept {
	Disc current_player = human_color;
	bool game_over = false;
	std::optional<Disc> winner;
	bool was_mouse_pressed = false;
	char keyState[256];
	// メインループ
	while (ProcessMessage() == 0) {
		ClearDrawScreen();
		
		SetDrawScreen(DX_SCREEN_BACK);
		if (!game_over) {
			
			// 手番に合法手がなければパス処理
			if (!board->has_valid_move(current_player)) {
				Disc other = (current_player == human_color ? ai_color : human_color);
				if (!board->has_valid_move(other)) {
					// 両者打てる手がない -> ゲーム終了
					game_over = true;
					winner = board->winner();
					PlaySoundMem(finish_sound, DX_PLAYTYPE_BACK);
				}
				else {
					// パス：手番を相手に移して継続
					current_player = other;
				}
			}
			else {
				// 通常の手番処理
				if (current_player == human_color) {
					// プレイヤー（人間）の手番
					int mx, my;
					int mouse = GetMouseInput();           // マウスのボタン状態取得
					GetMousePoint(&mx, &my);               // マウス座標を取得
					if ((mouse & MOUSE_INPUT_LEFT) && !was_mouse_pressed) {
						// 左クリックが今フレームで押された
						int col = (mx - screen_width_x) / cell_size;
						int row = (my - screen_height_y) / cell_size;
						if (row >= 0 && row < Board::SIZE && col >= 0 && col < Board::SIZE) {
							if (board->place_disc(row, col, human_color)) {
								PlaySoundMem(place_sound, DX_PLAYTYPE_BACK);
								current_player = ai_color;  // 手番をAIに交代
							}
						}
					}
					// マウス押下状態を保存（クリック判定用）
					was_mouse_pressed = (mouse & MOUSE_INPUT_LEFT) ? true : false;
				}
				else {/
					// AI（コンピュータ）の手番
					auto best_move = ai->find_best_move(*board);
					if (best_move.has_value()) {
						auto [br, bc] = best_move.value();
						board->place_disc(br, bc, ai_color);
						PlaySoundMem(place_sound, DX_PLAYTYPE_BACK);
						current_player = human_color;  // 手番を人間に戻す
					}
					else {
						// AIに指し手がない（基本的にここには来ない想定）
						current_player = human_color;
					}
				}
			}
		}
		// 描画処理
		renderer->draw_board(*board);
		if (!game_over && current_player == human_color) {
			renderer->draw_hints(*board, human_color);
		}
		if (game_over) {
			renderer->draw_result(winner);
		}
		ScreenFlip();
		// ESCキーが押されたらゲームループ終了
		GetHitKeyStateAll(keyState);
		if (keyState[KEY_INPUT_ESCAPE]) {
			break;
		}
	}
}
```

</details>




まぁはいこのクラスはゲームマネージャーさんです。それ以外の何物でもありません笑
そして完成したのがこちーらです。
しっかりオセロっぽくできましたね、あとは難易度の設定やplayer2を追加してから友達とやりましょう。
私はオセロAIの探索深さを6手先までに設定したら案の定ボコボコにされて萎えました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947130/a9961378-0d40-4eb6-8f87-18f682de3b1b.png)
こんな感じで完成です。これから勉強して参考になるようなコードを作っていきたいと考えていますのでチャンネル登録お願いします?