Appearance
第01回 オリエンテーションとベースコード確認
この授業の目的
- 前授業で扱った
C++、DXライブラリを用いて、横スクロール系の 2D プラットフォーマーを段階的に制作します - 制作を通じて、基本的なゲームプログラミングの技術を身につけるのが目的です
全8回の授業の流れ
- オリエンテーションとベースコード確認
分割済みベースコードの構成を確認し、今後の制作のための制作環境を整える - ゲームループの理解と実装
ゲームループの基本構造を理解し、処理の順序を意識した実装を行う - 画像表示の導入とエラー確認
画像の読み込み処理に失敗時のエラー表示を実装し、実務でのエラー処理の基本を学ぶ - マップデータの外部化(CSV読込)とスクロール
外部データの読み込みに対応してプログラムとデータの分離を学び、スクロール処理を導入して画面の動的表示を実装する - 物理パラメーター調整と操作感の改善
等加速度運動の基本を学び、速度、重力、ジャンプ速度といったパラメーターを調整して操作の感触を改善する - ステートマシン入門
プレイヤー以外のキャラクターやステージギミックを実装を通じて、状態遷移の考え方を学ぶ - 調整・デバッグ・安定化
ゲームバランスの調整とデバッグを行い、制作物の品質確保のための手法を学ぶ - 最終仕上げと発表
作品と実装意図を発表し、制作を通じて学んだことを振り返る
今回の授業内容
- 授業全体の目標共有
- 分割済みプロジェクトの構造確認
- 実行手順とデバッグ起動の確認
- 主要クラスの責務確認
- 左右移動、ジャンプ、重力、床衝突、ゴール判定の確認
- 小さな改変を通じた差分確認
分割済みベースコードを前提に、制作の出発点に立つ
- 前授業で扱った
C++、DXライブラリ、ゲームループ、矩形当たり判定を使って制作を進めます - 前授業ではすべての処理をひとつのファイルに押し込んでいましたが、今回は責務分離を意識して ソースコードは分割済み です
- この時点で、左右移動、ジャンプ、重力、床衝突、ゴール判定といった基本的な動作がすでに実装されています
- まず分割済みベースコードの構成を確認し、今後の制作のための制作環境を整えます
配布コード
第01回用の最小構成サンプル
第01回では2Dプラットフォーマーを動作させるための最小構成のサンプルを使います
床、ゴール、プレイヤーは DrawBox で描画し、画像はまだ使用しません
プロジェクト構成と基本動作を確認し、次回以降の授業で画像描画やマップ外部化などを追加していきます
| ファイル | 役割 |
|---|---|
| README.md | サンプル全体の説明 |
| src/main.cpp | DXライブラリの初期化、ゲーム開始、終了処理 |
| src/Common.h | Rect、矩形判定、数値補助関数 |
| src/Game.h | Game、Player の宣言 |
| src/Game.cpp | 入力、物理更新、状態更新、描画 |
| src/Stage.h | ステージの定数、関数、ゴールの宣言 |
| src/Stage.cpp | マップデータ、床判定、ステージ描画、ゴール取得 |
実行確認
まずは仕様通りに動くことを確認する
- DXライブラリ用プロジェクトを作成する
- 配布ファイルの
src内のファイルをプロジェクトへ追加する - ビルドして実行する
- 左右キーで移動できることを確認する
Zキーでジャンプできることを確認する- 床に乗れること、ゴールで
GAME CLEARになることを確認する - 画面下へ落ちると
GAME OVERになることを確認する - クリア後またはゲームオーバー後に
Rキーでリトライできることを確認する
操作方法
| 操作 | 内容 |
|---|---|
| 左右キー | プレイヤーを左右に移動する |
Z | 地面にいるときジャンプする |
R | クリア後またはゲームオーバー後にリトライする |
ESC | ゲームを終了する |
最小完成条件
まずは小さくても遊べる状態を確認する
- 左右キーでプレイヤーが移動できる
Zキーでジャンプできる- 重力で落下する
- 床にぶつかると止まる
- ゴールに触れるとクリアになる
- 落下するとゲームオーバーになる
- クリア後やゲームオーバー後にリトライできる
ベースコードの構成
ファイルと担当する処理
main.cpp
DXライブラリの初期化、ウィンドウ設定、描画先設定、Gameの起動と終了を担当しますCommon.h
複数のファイルで使う共通の型や関数をまとめますGame.h/.cpp
プレイヤー、入力、物理更新、ゲーム状態、描画の流れを担当しますStage.h/.cpp
マップ、床判定、ゴール、ステージ描画を担当します
第01回では、この分担を読み取り、「どこを編集すると何が変わるか」を確認します。
ソースファイルとヘッダーファイル
.h には形を、.cpp には処理を書く
この教材のベースコードは、最初から複数のファイルに分かれています
分割済みコードを読む前に、まず ヘッダーファイル と ソースファイル の役割を理解しましょう
.hはヘッダーファイルです
他のファイルから使う型、クラス、関数の名前や形を書きます.cppはソースファイルです
ヘッダーで示した関数の中身や、実際に動く処理を書きます#includeは、別のファイルに書かれた宣言を読み込むために使います
たとえば Game.h には、Game クラスが持つ関数名やメンバー変数が書かれています。以下は説明用の抜粋です。
cpp
class Game
{
public:
bool Initialize();
void MainLoop();
private:
// ゲームの状態を表す列挙型
enum GameState
{
Playing,
GameClear,
GameOver
};
void ResetPlay();
void ResetPlayer();
void Update();
void UpdatePlaying();
void UpdateResult();
void UpdatePlayerInput();
void UpdatePlayerPhysics();
void MoveHorizontal(int move);
void MoveVertical(int move);
Rect PlayerRect() const;
void Draw();
void DrawBackground() const;
void DrawPlaying() const;
void DrawResult() const;
void DrawPlayer() const;
void DrawHud() const;
private:
// 画面サイズ
static const int screenWidth = 640;
static const int screenHeight = 480;
// プレイヤーの動きに関係する調整値
static const int accel = 1;
static const int maxSpeed = 5;
static const int friction = 1;
static const int gravity = 1;
static const int maxFallSpeed = 14;
static const int jumpPower = -14;
Stage stage;
Player player = {};
GameState gameState = Playing;
};一方で Game.cpp には、Game.h で宣言した関数の具体的な処理を書きます。
cpp
// ゲームのメインループ
void Game::MainLoop()
{
// ESCキーが押されるまで、更新 -> 描画 -> 画面反映を繰り返す
while (ProcessMessage() == 0 && CheckHitKey(KEY_INPUT_ESCAPE) == 0)
{
Update();
ClearDrawScreen();
Draw();
ScreenFlip();
}
}このように、関数名、戻り値、引数だけを先に示すことを 宣言 と呼び、関数の具体的な処理を書くことを 定義 と呼びます
ファイル同士のつながり
#include で宣言を読み込む
.h に宣言を書いておくと、別の .cpp からその型や関数を使えるようになります
たとえば main.cpp は Game.h を読み込むことで、Game クラスを使えるようになります
cpp
#include "DxLib.h"
#include "Game.h"また、Game.h は Stage と Rect を使うため、内部で Common.h と Stage.h を読み込んでいます
cpp
#include "Common.h"
#include "Stage.h"ビルド時の注意点
ただし #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回だけ読むための指定
ヘッダーファイルの先頭には、次のような行があります
cpp
#pragma onceこれは、同じヘッダーファイルが1つの .cpp の中で何度も読み込まれないようにするための指定です
たとえば Game.h が Stage.h を読み込み、別の場所でも Stage.h を読み込むと同じクラス定義が複数回取り込まれてしまい、「同じものが重複して定義されている」というエラーの原因になります
ヘッダーに #pragma once を書いておくと、そのヘッダーはファイル中で1回だけ読み込まれるため、こうした重複を防げます
二重読み込みを防ぐ標準的な書き方
次のような #ifndef / #define / #endif を使ってガードする方法もあります
cpp
#ifndef GAME_H
#define GAME_H
// ヘッダーの内容
#endif実は #pragma once はC++の標準仕様ではありません
ですが多くのコンパイラでサポートされており、簡潔で便利なため広く使われています
この教材でも簡単のため #pragma once を使っています
実行順の確認
main.cpp はゲーム全体の入口
main.cpp では、DXライブラリを初期化し、Game の初期化とメインループを呼び出します
ゲーム中の細かい処理は main.cpp に直接書かず、Game に任せています
cpp
// ゲームを初期化し、メインループを開始する
Game game;
if (!game.Initialize())
{
DxLib_End();
return -1;
}
game.MainLoop();Game game;でゲーム全体を管理するオブジェクトを作りますgame.Initialize()でステージとプレイヤーを初期化しますgame.MainLoop()で毎フレームの更新と描画を繰り返します
共通処理
Common.h はこのプロジェクト共通の道具箱
Rect は、位置と大きさをまとめる型です
プレイヤー、床判定、ゴール判定で同じ形のデータとして扱えるようにしています
cpp
// 左上座標と幅・高さで表すシンプルな矩形
struct Rect
{
int x;
int y;
int width;
int height;
};IsHitRect は、2つの四角形が重なっているかを返します
第01回では、プレイヤーとゴールの接触判定に使います
cpp
// 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 は、タイルサイズ、マップの幅と高さ、ゴール位置を扱います
床の判定やステージの描画も担当します
cpp
// ステージを表すクラス
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;
void 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に置かれているマップデータですtilesのTileSolidは床として扱いますgoalはゴールの矩形ですIsSolidAtRectは、プレイヤーの矩形が床に当たっているかを調べますDrawは床とゴールを描画します
Stage::Initialize では、マップデータの中から TileGoal を探し、その位置をゴールの矩形として設定します
cpp
// ステージの初期化
void 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;
}
}
}
}床判定
矩形がまたいでいるタイルを調べる
プレイヤーはピクセル単位で移動しますが、床はタイル単位で配置されています
床とプレイヤーの接触を検知するために、プレイヤーの矩形がどのタイル範囲に重なっているかを計算してから、範囲に床タイルが含まれているか確認します
ピクセル座標からタイル座標への変換は PixelToTile で行います
cpp
// ピクセル座標をタイル座標に変換する
int PixelToTile(int pixel)
{
// C++の整数除算は負の値を0方向へ丸めるため、自前で下方向へ丸めます。
if (pixel >= 0)
return pixel / Stage::tileSize;
return (pixel - Stage::tileSize + 1) / Stage::tileSize;
}cpp
// 矩形が少しでも重なっているタイル範囲を調べる
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 では、負の座標でも正しく左側や上側のマップ外として扱えるように、タイル番号を下方向へ丸めています
cpp
for (int y = topTile; y <= bottomTile; y++)
{
for (int x = leftTile; x <= rightTile; x++)
{
if (IsSolidTile(x, y))
return true;
}
}タイル座標がマップの左、右、上にはみ出した場合は壁扱いにします
下方向だけは落下できるように、床ではない扱いにしています
cpp
// マップの左右と上は壁扱いにして、画面外へ進めないようにする
if (tileX < 0 || tileX >= mapWidth)
return true;
if (tileY < 0)
return true;
// 下方向は落下できるように、マップ外でも床ではない扱いにする
if (tileY >= mapHeight)
return false;
return tiles[tileY][tileX] == TileSolid;プレイヤー
Player は位置、速度、接地状態を持つ
Player は、プレイヤーの現在状態をまとめる構造体です
cpp
// プレイヤーの状態を表す構造体
struct Player
{
// 位置と大きさは、左上を基準にした矩形で扱う
int x;
int y;
int width;
int height;
// 縦横の速度、1フレームあたりに動くピクセル数
int vx;
int vy;
// 接地しているかどうかのフラグ true の時のみジャンプ可能
bool onGround;
};x,yはプレイヤーの位置ですwidth,heightはプレイヤーの大きさですvxは横方向の速度ですvyは縦方向の速度ですonGroundは接地中かどうかを表します
ジャンプできるのを接地中の時だけに制限することで、空中で何度もジャンプできないようにしています
ゲーム状態
Game 内の GameState でプレイ中、クリア、ゲームオーバーの状態を管理する
cpp
class Game
{
private:
// ゲームの状態を表す列挙型
enum GameState
{
Playing,
GameClear,
GameOver
};
};Playingは通常プレイ中ですGameClearはゴール到達後ですGameOverは画面下へ落下した後です
ゲーム内の状態を分けることで、プレイ中だけプレイヤーを更新したり、ゲームオーバーやゲームクリア状態だけ R キーでのリトライを受け付けるようにできます
現在の状態に応じた更新処理の分岐は switch で書いています
cpp
// ゲームの更新処理
void Game::Update()
{
// ゲーム状態に応じて更新処理を分岐する
switch(gameState)
{
case Playing:
UpdatePlaying();
break;
case GameClear:
case GameOver:
UpdateResult();
break;
}
}ゲームループ
必要な処理を手順通りに毎フレーム繰り返す
Game::MainLoop は、ゲームが終了するまで毎フレーム Update と Draw を呼びます
cpp
// ESCキーが押されるまで、更新 -> 描画 -> 画面反映を繰り返す
while (ProcessMessage() == 0 && CheckHitKey(KEY_INPUT_ESCAPE) == 0)
{
Update();
ClearDrawScreen();
Draw();
ScreenFlip();
}Update()で入力、移動、判定、状態更新を行うClearDrawScreen()で前フレームの画面を消すDraw()で現在の状態を描画するScreenFlip()で描画結果を画面に反映する
第02回では、この手順をさらに詳しく扱います
入力と速度
キー入力での横速度の増減と摩擦
左右キーで横方向の速度 vx を増減します
キーを押していないときは摩擦で少しずつ止まるようにしています
cpp
// 左右キーで横方向の速度を変化させます。
if (CheckHitKey(KEY_INPUT_LEFT))
{
player.vx -= accel;
}
else
if (CheckHitKey(KEY_INPUT_RIGHT))
{
player.vx += accel;
}
else
{
if (player.vx > 0) player.vx -= friction;
else
if (player.vx < 0) player.vx += friction;
}
// 速度が大きくなりすぎないように上限をかける
player.vx = Clamp(player.vx, -maxSpeed, maxSpeed);ジャンプ入力と重力
ジャンプは onGround が true のときだけ実行します
空中で何度もジャンプできないようにするためです
cpp
// 地面にいるときだけジャンプを受け付ける
if (CheckHitKey(KEY_INPUT_Z) && player.onGround)
{
player.vy = jumpPower;
player.onGround = false;
}重力は毎フレーム vy に加算します
ただしこのままでは落下速度が大きくなりすぎる危険があります
ここでは Clamp で速度に制限をかけています
cpp
// 重力で落下速度を増やし、速く落ちすぎないように制限する
player.vy += gravity;
player.vy = Clamp(player.vy, -30, maxFallSpeed);移動と床への衝突
横と縦を分けて移動し、個別にめりこみを防ぐ
速度が決定したら、横方向 → 縦方向の順に移動します
cpp
// 横と縦を分けて動かすと、床や壁にめり込んだときの戻し処理が簡単になる
MoveHorizontal(player.vx);
player.onGround = false;
MoveVertical(player.vy);1ピクセルずつ動かして、当たったら戻す
移動の量が大きいと、判定の際に床のタイルの境界を飛び越してしまって床をすり抜けることがあります
このサンプルでは 1ピクセルずつ動かして都度判定し、床に当たったら1戻すことでめり込みを防いでいます
横と縦を分けて判定することで、斜めに動いて床に衝突した際にもいきなり停止するのではなく、床に沿って滑るような動きを作ることができます
cpp
// 縦方向も1ピクセルずつ動かし、床や天井に当たったら止めます。
for (int i = 0; i < AbsInt(move); i++)
{
player.y += step;
if (stage.IsSolidAtRect(PlayerRect()))
{
player.y -= step;
if (step > 0) player.onGround = true;
player.vy = 0;
break;
}
}- 下向きに動いて床に当たったら
onGround = trueにします - 当たった方向の速度は
0にします - 衝突を検知した際は直前の位置まで戻すことで、床へのめり込みを防ぎます
stepは 1 または -1 の値ですmoveの符号に応じて上下どちらに動かすかを決めます
ゴール判定とゲームオーバー
プレイヤーの矩形とゴールの矩形を比較する
プレイヤーが画面下へ大きく落ちたらゲームオーバーです。
cpp
// ステージの下へ落ちたらゲームオーバー
if (player.y > Stage::mapHeight * Stage::tileSize + 120)
{
gameState = GameOver;
return;
}プレイヤーの矩形とゴールの矩形が重なったらゲームクリアです。
cpp
// プレイヤーの矩形とゴールの矩形が重なったらクリア
if (IsHitRect(PlayerRect(), stage.GetGoal()))
{
gameState = GameClear;
}描画
ステージ、プレイヤー、HUD、結果表示を分けて描く
Draw は、背景、プレイ中の見た目、結果表示を順番に描きます。
cpp
// ゲームの描画処理
void Game::Draw()
{
DrawBackground();
DrawPlaying();
if (gameState != Playing)
{
DrawResult();
}
}DrawBackgroundは空と地面の背景を描きますStage::Drawは床とゴールを描きますDrawPlayerはプレイヤーを描きますDrawHudは操作説明を描きますDrawResultはクリアまたはゲームオーバーの表示を描きます
コードの読み方
処理の入口から順を追って読み進める
- src/main.cpp の
game.MainLoop()を読む - src/Game.cpp の
MainLoopからUpdateとDrawを追う UpdatePlayingで入力、物理、ゲームオーバー判定、ゴール判定を読むUpdatePlayerInputでキー入力による速度変化を読むUpdatePlayerPhysicsで重力と移動処理を読むMoveHorizontalとMoveVerticalで床衝突を読む- src/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の責務を確認しました- 横移動、ジャンプ、重力、床衝突、ゴール判定がどこで行われているかを確認しました
次回予告
第02回 ゲームループの理解と実装
ゲームループの中で行われている処理の手順を追うことでゲームの基本的な動作の流れを理解し、状態遷移の基本を学びます