Appearance
第01回 オリエンテーションとベースコード確認
この授業の目的
前授業で扱ったC++とDXライブラリを用いて、横スクロール型の2Dプラットフォーマーを段階的に制作します
制作を通じて、基本的なゲームプログラミングの技術を身につけることが目的です
全12回の授業の流れ
- オリエンテーションとベースコード確認
分割済みベースコードの構成を確認し、今後の制作のための制作環境を整える - ゲームループの理解と実装
ゲームループの基本構造を理解し、処理の順序を意識した実装を行う - 画像表示の導入とエラー確認
画像の読み込み処理に失敗時のエラー表示を実装し、実務でのエラー処理の基本を学ぶ - マップデータの外部化(CSV読込)とスクロール
外部データの読み込みに対応してプログラムとデータの分離を学び、スクロール処理を導入して画面の動的表示を実装する - キャラクターアニメーション
スプライトシートを部分描画し、移動と接地状態に応じて待機、走行、上昇、落下を切り替える - サウンドとゲームイベント
BGMとSEを管理し、ゲーム中の出来事が発生した瞬間に再生する - CSVによる敵の複数配置
CSVの配置情報と固定長配列を使い、複数の敵をまとめて管理する - 敵の物理挙動とライフサイクル
敵へ重力と地形衝突を追加し、落下無効化とリトライ時の復元を実装する - 敵AIとステートマシン
待機、巡回、追跡、やられ状態を実装し、敵ごとの状態遷移を管理する - ゲーム進行と状態遷移
敵の状態とゲーム全体の状態を配布コードで確認し、Releaseビルドと公開用zipの作成手順を学ぶ - 操作感の設計と改善
可変ジャンプ、コヨーテタイム、入力バッファを実装し、根拠を持って操作感を調整する - デバッグ・ブラッシュアップと発表
デバッグと回帰確認で作品を仕上げ、実装意図を発表して制作を振り返る
今回の授業内容
- 全12回の授業目標と制作の流れを確認する
- 分割済みプロジェクトを実行し、デバッグ起動の手順を確認する
main、Game、Stage、Commonの責務を理解する- 左右移動、ジャンプ、重力、床衝突、ゴール判定の実装箇所を確認する
- 数値やマップを変更し、挙動の違いを比較する
分割済みベースコードを前提に、制作の出発点に立つ
前授業で扱ったC++とDXライブラリを使って制作を進めます
前授業ではすべての処理をひとつのファイルにまとめていましたが、今回は責務分離を意識して、分割済みのソースコードから制作を始めます
この時点で、左右移動、ジャンプ、重力、床衝突、ゴール判定といった基本動作は実装済みです
まずコードの構成を確認し、今後の制作環境を整えます
今回の授業の配布コード
第01回では2Dプラットフォーマーを動作させるための最小構成のサンプルを使います
床、ゴール、プレイヤーは DrawBox で描画し、画像はまだ使用しません
プロジェクト構成と基本動作を確認し、次回以降の授業で画像描画やマップ外部化などを追加していきます
配布ファイル一覧
| ファイル | 役割 |
|---|---|
| Source/main.cpp | DXライブラリの初期化、ゲーム開始、終了処理 |
| Source/Common.h | Rect、矩形判定、数値補助関数 |
| Source/Game.h | Game、Player の宣言 |
| Source/Game.cpp | 入力、物理更新、状態更新、描画 |
| Source/Stage.h | ステージの定数、関数、ゴールの宣言 |
| Source/Stage.cpp | マップデータ、床判定、ステージ描画、ゴール取得 |
環境確認と操作
仕様通りに動くことを確認する
- DXライブラリ用プロジェクトを作成する
- 配布ファイルの
Source内のファイルをプロジェクトへ追加する - ビルドして実行する
- 左右キーで移動できることを確認する
- Z キーでジャンプできることを確認する
- 床に乗れること、ゴールで
GAME CLEARになることを確認する - 画面下へ落ちると
GAME OVERになることを確認する - クリア後またはゲームオーバー後に R キーでリトライできることを確認する
操作方法
| 操作 | 内容 |
|---|---|
| 左右キー | プレイヤーを左右に移動する |
| Z | 地面にいるときジャンプする |
| R | クリア後またはゲームオーバー後にリトライする |
| ESC | ゲームを終了する |
コード構造の理解
ベースコードの構成と各ファイルの役割
main.cpp
DXライブラリの初期化、ウィンドウ設定、描画先設定、Gameの起動と終了を担当しますCommon.h
複数のファイルで使う共通の型や関数をまとめますGame.h/.cpp
プレイヤー、入力、物理更新、ゲーム状態、描画の流れを担当しますStage.h/.cpp
マップ、床判定、ゴール、ステージ描画を担当します
ソースファイルとヘッダーファイルの役割
この教材のベースコードは、最初から複数のファイルに分かれています
分割済みコードを読む前に、まず ヘッダーファイル と ソースファイル の役割を理解しましょう
.hはヘッダーファイルです
他のファイルから使う型、クラス、関数の名前や形を書きます.cppはソースファイルです
ヘッダーで示した関数の中身や、実際に動く処理を書きます#includeは、別のファイルに書かれた宣言を読み込むために使います
たとえば Game.h には、Game クラスが持つ関数名やメンバー変数が書かれています
以下は説明用の抜粋です
Game.hcpp
class Game
{
public:
bool Initialize();
void MainLoop();
private:
// ゲームの状態を表す列挙型
enum GameState
{
Playing,
GameClear,
GameOver
};
void StartPlay();
void ResetPlay();
void ResetPlayer();
void Update();
// 省略一方で Game.cpp には Game.h で宣言した関数の具体的な処理を書きます
Game.cppcpp
// ゲームのメインループ
void Game::MainLoop()
{
// ESCキーが押されるまで、更新 -> 描画 -> 画面反映を繰り返す
while (ProcessMessage() == 0 && CheckHitKey(KEY_INPUT_ESCAPE) == 0)
{
Update();
ClearDrawScreen();
Draw();
ScreenFlip();
}
}このように関数名、戻り値、引数だけを先に示すことを 宣言 と呼び、関数の具体的な処理を書くことを 定義 と呼びます
#includeでヘッダーファイルを連結する
.h に宣言を書いておくと、別の .cpp からその型や関数を使えるようになります
たとえば main.cpp は Game.h を読み込むことで、Game クラスを使えるようになります
main.cppcpp
#include "DxLib.h"
#include "Game.h"また、Game.h は Stage と Rect を使うため、内部で Common.h と Stage.h を読み込んでいます
Game.hcpp
#include "Common.h"
#include "Stage.h"include について
#include は、指定したファイルの内容をその場にコピーするプリプロセッサ命令です#include "ファイル名" の形式で書くと、同じフォルダ内のファイルを読み込みます#include <ファイル名> の形式で書くと、コンパイラが用意した標準ライブラリを読み込みます
#include は単に指定のファイルを読み込み連結する命令です
通常はヘッダーファイルを読み込み宣言を取り込むために使用されます
ですが、実は読み込むファイルはヘッダーファイルに限らずソースファイルやその他のファイルでも構いません
マクロを併用すれば、CSVファイルを直接読み込みソースコードとして扱うことなども可能ですが、ここでは割愛します
ビルド時の注意点
#include でヘッダーを読み込むだけで、関数の中身まで自動で追加されるわけではありません
ビルド時には、main.cpp、Game.cpp、Stage.cpp などの .cpp がそれぞれコンパイルされます
必要な .cpp がプロジェクトに追加されていないと、宣言は見えていても実際の処理が見つからず、リンクエラーになります
ファイルがプロジェクトに追加されているか、ビルド対象になっているかを確認しましょう
| ファイル | 主な中身 |
|---|---|
Common.h | Rect、IsHitRect、Clamp、AbsInt |
Stage.h | Stage クラスの宣言 |
Stage.cpp | マップデータ、Stage::Initialize、床判定、ステージ描画 |
Game.h | Player、Game クラスの宣言 |
Game.cpp | 入力、物理更新、状態更新、描画 |
main.cpp | DXライブラリ初期化、Game の起動 |
ヘッダーの二重読み込みを防ぐ
#pragma once は同じヘッダーを1回だけ読むための指定
ヘッダーファイルの先頭には、次のような行があります
Game.hcpp
#pragma onceこれは、同じヘッダーファイルが1つの .cpp の中で何度も読み込まれないようにするための指定です
たとえば Game.h が Stage.h を読み込み、別の場所でも Stage.h を読み込むと同じクラス定義が複数回取り込まれてしまい、「同じものが重複して定義されている」というエラーの原因になります
ヘッダーに #pragma once を書いておくと、そのヘッダーはファイル中で1回だけ読み込まれるため、こうした重複を防げます
二重読み込みを防ぐ標準的な書き方
次のような #ifndef / #define / #endif を使ってガードする方法もあります
Game.hcpp
#ifndef GAME_H
#define GAME_H
// ヘッダーの内容
#endif実は #pragma once はC++の標準仕様ではありません
ですが多くのコンパイラでサポートされており、簡潔で便利なため広く使われています
この教材でも簡単のため #pragma once を使っています
pragma について
#pragma はコンパイラに対する指示を表すプリプロセッサ命令です
コンパイラはそれぞれ独自の #pragma を持っていますが、#pragma once は多くのコンパイラでサポートされています#pragma once は Visual Studio、GCC、Clang などの主要なコンパイラでサポートされていますが、動作に違いがあったり、特殊なプロジェクトでは期待通りに動作しない場合もあります
主要処理の読解
ゲーム全体の入口となるmain.cpp
main.cpp では、DXライブラリを初期化し、Game の初期化とメインループを呼び出します
ゲーム中の細かい処理は main.cpp に直接書かず、Game に任せています
main.cppcpp
// ゲームを初期化し、メインループを開始する
Game game;
if (!game.Initialize())
{
DxLib_End();
return -1;
}
game.MainLoop();Game game; でゲーム全体を管理するオブジェクトを作成し、game.Initialize() でステージとプレイヤーを初期化します
初期化に成功したら、game.MainLoop() で毎フレームの更新と描画を繰り返します
共通処理をまとめるCommon.h
Rect は、位置と大きさをまとめる型です
プレイヤー、床判定、ゴール判定で同じ形のデータとして扱えるようにしています
Common.hcpp
// 左上座標と幅・高さで表すシンプルな矩形
struct Rect
{
int x;
int y;
int width;
int height;
};IsHitRect は、2つの四角形が重なっているかを返します
第01回では、プレイヤーとゴールの接触判定に使います
Common.hcpp
// 2つの矩形が少しでも重なっていればtrueを返す
inline bool IsHitRect(const Rect& a, const Rect& b)
{
return
a.x < b.x + b.width &&
b.x < a.x + a.width &&
a.y < b.y + b.height &&
b.y < a.y + a.height;
}地形とゴールを管理するStage
Stage は、タイルサイズ、マップの幅と高さ、ゴール位置を扱います
床の判定やステージの描画も担当します
Stage.hcpp
// ステージを表すクラス
class Stage
{
public:
// マップデータの数字が何を表しているかを定義する
enum TileType
{
TileEmpty = 0,
TileSolid = 1,
TileGoal = 2
};
// ステージ全体で使うタイルの基本サイズ
static const int tileSize = 32;
static const int mapWidth = 20;
static const int mapHeight = 15;
bool Initialize();
bool IsSolidTile(int tileX, int tileY) const;
bool IsSolidAtRect(const Rect& rect) const;
void Draw() const;
Rect GetGoal() const;
private:
void DrawTile(int tileX, int tileY) const;
void DrawGoal() const;
private:
// ゴール位置はInitializeでステージ座標から設定する
Rect goal = { 0, 0, tileSize, tileSize };
};| 要素 | 役割 |
|---|---|
tileSize | 1タイルの大きさ |
TileType | マップデータの数字が表す内容の定義 |
tiles | Stage.cpp に置かれているマップデータ |
TileSolid | 床として扱うタイル |
goal | ゴールの矩形 |
IsSolidAtRect | プレイヤーの矩形が床に当たっているかを調べる関数 |
Draw | 床とゴールを描画する関数 |
Stage::Initialize では、マップデータの中から TileGoal を探し、その位置をゴールの矩形として設定します
ゴールが見つかった場合は true、見つからなかった場合は false を返し、ステージを開始できるか呼び出し側へ通知します
Stage.cppcpp
// ステージの初期化
bool Stage::Initialize()
{
// マップ上のゴールタイルと同じ位置に、当たり判定用の矩形を置く
for (int y = 0; y < mapHeight; y++)
{
for (int x = 0; x < mapWidth; x++)
{
if (tiles[y][x] == TileGoal)
{
goal = { x * tileSize, y * tileSize, tileSize, tileSize };
return true;
}
}
}
return false;
}矩形がまたいでいるタイルを調べて床を判定する
プレイヤーはピクセル単位で移動しますが、床はタイル単位で配置されています
床とプレイヤーの接触を検知するために、プレイヤーの矩形がどのタイル範囲に重なっているかを計算してから、範囲に床タイルが含まれているか確認します
ピクセル座標からタイル座標への変換は PixelToTile で行います
Stage.cppcpp
// ピクセル座標をタイル座標に変換する
int PixelToTile(int pixel)
{
// C++の整数除算は負の値を0方向へ丸めるため、自前で下方向へ丸めます。
if (pixel >= 0)
return pixel / Stage::tileSize;
return (pixel - Stage::tileSize + 1) / Stage::tileSize;
}Stage.cppcpp
// 矩形が少しでも重なっているタイル範囲を調べる
int leftTile = PixelToTile(rect.x);
int rightTile = PixelToTile(rect.x + rect.width - 1);
int topTile = PixelToTile(rect.y);
int bottomTile = PixelToTile(rect.y + rect.height - 1);この範囲内に床タイルが1つでもあれば、床に当たっていると判断しますPixelToTile では、負の座標でも正しく左側や上側のマップ外として扱えるように、タイル番号を下方向へ丸めています
Stage.cppcpp
for (int y = topTile; y <= bottomTile; y++)
{
for (int x = leftTile; x <= rightTile; x++)
{
if (IsSolidTile(x, y))
return true;
}
}タイル座標がマップの左、右、上にはみ出した場合は壁扱いにします
下方向だけは落下できるように、床ではない扱いにしています
Stage.cppcpp
// マップの左右と上は壁扱いにして、画面外へ進めないようにする
if (tileX < 0 || tileX >= mapWidth)
return true;
if (tileY < 0)
return true;
// 下方向は落下できるように、マップ外でも床ではない扱いにする
if (tileY >= mapHeight)
return false;
return tiles[tileY][tileX] == TileSolid;プレイヤーの現在状態をまとめるPlayer
Player は、プレイヤーの位置、速度、接地状態といった現在の状態をまとめる構造体です
Game.hcpp
// プレイヤーの状態を表す構造体
struct Player
{
// 位置と大きさは、左上を基準にした矩形で扱う
int x;
int y;
int width;
int height;
// 縦横の速度と、1ピクセル未満の移動量
float vx;
float vy;
float remainderX;
float remainderY;
// 接地しているかどうかのフラグ true の時のみジャンプ可能
bool onGround;
};| 要素 | 役割 |
|---|---|
x, y | プレイヤーの位置 |
width, height | プレイヤーの大きさ |
vx | 横方向の速度 |
vy | 縦方向の速度 |
remainderX, remainderY | まだ座標へ反映していない1ピクセル未満の移動量 |
onGround | 接地中かどうか |
onGround をチェックすることでジャンプを接地中だけに制限し、空中で何度もジャンプできないようにしています
座標は当たり判定に使いやすい整数で保持し、速度と端数をfloatにすることで、1ピクセル未満の加速や重力も表現できるようにしています
GameStateでゲーム状態を管理する
Game.hcpp
class Game
{
private:
// ゲームの状態を表す列挙型
enum GameState
{
Playing,
GameClear,
GameOver
};
};| 状態 | 意味 |
|---|---|
Playing | 通常プレイ中 |
GameClear | ゴール到達後 |
GameOver | 画面下へ落下した後 |
ゲーム内の状態を分けることで、プレイ中だけプレイヤーを更新したり、ゲームオーバーやゲームクリア状態だけ R キーでのリトライを受け付けるようにできます
現在の状態に応じた更新処理の分岐は switch で書いています
ゲーム開始時とリトライ時は、StartPlayからプレイ中のデータを初期化してPlayingへ遷移します
Game.cppcpp
void Game::StartPlay()
{
ResetPlay();
gameState = Playing;
}
void Game::ResetPlay()
{
ResetPlayer();
}StartPlayはプレイ開始全体、ResetPlayはプレイ中に変化するデータの初期化を担当します
Game.cppcpp
// ゲームの更新処理
void Game::Update()
{
// ゲーム状態に応じて更新処理を分岐する
switch(gameState)
{
case Playing:
UpdatePlaying();
break;
case GameClear:
case GameOver:
UpdateResult();
break;
}
}必要な処理を毎フレーム繰り返すゲームループ
Game::MainLoop は、ゲームが終了するまで毎フレーム Update と Draw を呼びます
Game.cppcpp
// ESCキーが押されるまで、更新 -> 描画 -> 画面反映を繰り返す
while (ProcessMessage() == 0 && CheckHitKey(KEY_INPUT_ESCAPE) == 0)
{
Update();
ClearDrawScreen();
Draw();
ScreenFlip();
}Update()で入力、移動、判定、状態更新を行うClearDrawScreen()で前フレームの画面を消すDraw()で現在の状態を描画するScreenFlip()で描画結果を画面に反映する
第02回では、この手順をさらに詳しく扱います
入力とプレイヤーの移動速度
キー入力での横速度の増減と摩擦
左右キーで横方向の速度 vx を増減します
キーを押していないときは摩擦で少しずつ止まるようにしています
Game.cppcpp
// 左右キーで横方向の速度を変化させます。
if (CheckHitKey(KEY_INPUT_LEFT))
{
player.vx -= accel;
}
else
if (CheckHitKey(KEY_INPUT_RIGHT))
{
player.vx += accel;
}
else
{
if (player.vx > 0)
player.vx = Clamp(player.vx - friction, 0.0f, maxSpeed);
else
if (player.vx < 0)
player.vx = Clamp(player.vx + friction, -maxSpeed, 0.0f);
}
// 速度が大きくなりすぎないように上限をかける
player.vx = Clamp(player.vx, -maxSpeed, maxSpeed);ジャンプ入力と重力
ジャンプは onGround が true のときだけ実行します
空中で何度もジャンプできないようにするためです
Game.cppcpp
// 地面にいるときだけジャンプを受け付ける
if (CheckHitKey(KEY_INPUT_Z) && player.onGround)
{
player.vy = jumpPower;
player.onGround = false;
}重力は毎フレーム vy に加算します
ただしこのままでは落下速度が大きくなりすぎる危険があります
ここでは Clamp で速度に制限をかけています
Game.cppcpp
// 重力で落下速度を増やし、速く落ちすぎないように制限する
player.vy += gravity;
player.vy = Clamp(player.vy, -30.0f, maxFallSpeed);移動と床への衝突
横と縦を分けて移動し、個別にめりこみを防ぐ
速度が決定したら、横方向 → 縦方向の順に移動します
Game.cppcpp
// 横と縦を分けて動かすと、床や壁にめり込んだときの戻し処理が簡単になる
MoveHorizontal(player.vx);
MoveVertical(player.vy);
Rect groundProbe = PlayerRect();
groundProbe.y += 1;
player.onGround = stage.IsSolidAtRect(groundProbe);移動後の重なりをタイル境界へ戻す
移動後のRectが床タイルと重なった場合は、移動方向に応じて床または壁の境界まで位置を戻します
横と縦を分けて判定することで、斜めに動いて床に衝突した際にもいきなり停止するのではなく、床に沿って滑るような動きを作ることができます
Game.cppcpp
// 1ピクセル未満の移動量を次のフレームへ残す
player.remainderY += move;
const int pixelMove = static_cast<int>(player.remainderY);
player.remainderY -= pixelMove;
Rect movedRect = PlayerRect();
if (stage.ResolveVertical(movedRect, pixelMove))
{
if (pixelMove > 0) player.onGround = true;
player.vy = 0.0f;
player.remainderY = 0.0f;
}
player.y = movedRect.y;ResolveVertical()は移動後のRectと重なるタイルを調べ、下向きならタイル上端、上向きならタイル下端へ補正します
あわせて縦方向の速度を 0 にして、頭をぶつけた際も床に乗った際も速度が残らないようにしています
また、下向きに動いて床に当たった場合は onGround = true で接地を記録し、ジャンプ可能状態に戻します
通常の移動速度はタイルサイズより十分小さい値に制限しています
1フレームでタイルを通過する高速な弾などには、移動前後を結ぶSwept判定や線分判定を別途使用します
ゴール判定とゲームオーバー
プレイヤーが画面下へ大きく落ちたらゲームオーバーです。
Game.cppcpp
// ステージの下へ落ちたらゲームオーバー
if (player.y > Stage::mapHeight * Stage::tileSize + 120)
{
gameState = GameOver;
return;
}プレイヤーの矩形とゴールの矩形が重なったらゲームクリアです。
Game.cppcpp
// プレイヤーの矩形とゴールの矩形が重なったらクリア
if (IsHitRect(PlayerRect(), stage.GetGoal()))
{
gameState = GameClear;
}ステージ、プレイヤー、HUD、結果表示を分けて描く
Draw は、背景、プレイ中の見た目、結果表示を順番に描きます。
Game.cppcpp
// ゲームの描画処理
void Game::Draw()
{
DrawBackground();
DrawPlaying();
if (gameState != Playing)
{
DrawResult();
}
}| 関数 | 描画するもの |
|---|---|
DrawBackground | 空と地面の背景 |
Stage::Draw | 床とゴール |
DrawPlayer | プレイヤー |
DrawHud | 操作説明 |
DrawResult | クリアまたはゲームオーバーの表示 |
処理の入口からコードを読み進める
- Source/main.cpp の
game.MainLoop()を読む - Source/Game.cpp の
MainLoopからUpdateとDrawを追う UpdatePlayingで入力、物理、ゲームオーバー判定、ゴール判定を読むUpdatePlayerInputでキー入力による速度変化を読むUpdatePlayerPhysicsで重力と移動処理を読むMoveHorizontalとMoveVerticalで床衝突を読む- Source/Stage.cpp の
IsSolidAtRectでタイル判定を読む
実習
実習1: ファイルごとの責務を説明する
main.cppが直接担当している処理を書き出すGameが担当している処理を書き出すStageが担当している処理を書き出すCommon.hに置かれている処理を書き出す- 「プレイヤーがゴールに触れたとき、どのファイルのどの関数で判定しているか」を説明する
実習2: 数値を変えて挙動を比較する
以下のうち1つだけ変更し、実行結果を確認してから元に戻します。
Game.hのmaxSpeedを変えるGame.hのjumpPowerを変えるGame.hのgravityを変えるGame.cppのResetPlayerで初期位置を変えるStage.cppのtilesで床の位置を変えるStage.cppのtilesでTileGoalの位置を変える
変更したら、次の3点を記録します。
- どのファイルのどの値を変えたか
- 画面上で何が変わったか
- その値が何の役割を持っていたか
つまずきやすい点
- ソースファイルをプロジェクトに追加しておらず、リンクエラーになる
Game.cpp、Stage.cppのどちらかを追加し忘れている#include "DxLib.h"の設定ができていないSetDrawScreen(DX_SCREEN_BACK)を呼ばず、描画がちらつくScreenFlip()を呼ばず、画面が更新されないplayer.onGroundがtrueにならず、ジャンプできないtilesのTileSolidとTileEmptyの意味を取り違える
今回の到達目標
- 分割済み構成でプロジェクトを実行できる
main、Game、Stage、Commonの責務を説明できる- 左右移動、ジャンプ、重力、床衝突、ゴール判定の処理場所を説明できる
- ゲームループの処理手順を理解し説明できる
まとめ
今回は、この制作のベースとなるコードの役割と機能を確認しました
- 第01回用の分割済みベースコードを実行しました
main、Game、Stage、Commonの責務を確認しました- 横移動、ジャンプ、重力、床衝突、ゴール判定がどこで行われているかを確認しました