概要
プレイヤーや敵を表すクラスの原型を実装し,ゲームのメインループ内に適切に組み込む.
はじめに
前回
テキスト管理の話でした.
今回
キャラクタークラスを作成して動かします.
ゲームにおけるオブジェクト管理には色々な設計方法があるようですが,今回はそのあたりに踏み込むことはせず,まずは形になればOKということにします.
開発環境
Windows11 Home 25H2
Visual Studio Community 2026
使用している外部ライブラリ:
SDL3 (version: 3.2.26)
SDL3_ttf (version: 3.2.2)
クラスの設計
プレイヤーと敵に共通する部分を設計するActorクラスを抽象クラスとして用意し,PlayerクラスとEnemyクラスはそれを継承します.
さらに先を考えれば,アイテムなども含めた基底のクラスを用意した方が良いのかもしれませんが,今回は触れません.
Actorクラス
詳細なEnemy,Playerの実装は定まっていないので,今回は仮に攻撃力と体力,速度などをメンバとして持つこととし,自分のターンが回ってきたら移動したり攻撃したりする,というものを考えます.
概形
class Actor
{
public:
Actor (Game* game) : mGame(game){};
virtual ~Actor ( );
// Setter, Getter省略
bool TimePass ( );
virtual void Action ( ) = 0;
virtual void Attack ( Actor* target ) = 0;
virtual void TakeDamage ( int damage ) = 0;
private:
Game* mGame;
int mAtkDice, mAtkSides;
int mHP;
int mSpeed;
int mEnergy;
Pos mPos;
TileData mTileData;
TextInstance mText;
constexpr static int ENERGY_FOR_TURN = 1000;
};
bool Actor::TimePass ( )
{
mEnergy -= mSpeed;
if ( mEnergy < 0 ) {
mEnergy += ENERGY_FOR_TURN;
mEnergy = ( ( mEnergy < 0 ) ? 0 : mEnergy );
return true;
}
return false;
}
各メンバの意味
Game* mGame;
Gameクラスはゲーム全体の状態管理クラス.Actor側から外の要素にアクセスする必要のあるタイミングが存在するはず(e.g., 新しいActorの作成を行う場合,Gameにその存在を知らせたい)なので,ポインタをコンストラクタで受け取ります.
これを依存性の注入(Dependency Injection)と言うそうです.他にGameクラスをシングルトンにする(インスタンスを一つに制限してグローバルアクセスを可能にする)という手法もありますが,今回はDIで対応します.
int mAtkDice, mAtkSides;
int mHP;
int mSpeed;
int mEnergy;
ゲーム的なパラメータ.よくあるローグライクのように,攻撃力はダイスロールで決定しましょう.mAtkDiceがダイス数,mAtkSidesがダイス面数です.
Pos mPos;
TileData mTileData;
TextInstance mText;
それぞれ他で定義したクラスですが,意味合いとしてはActorの位置情報,文字と色の情報,テキスト描画情報であり,最低限マップ上に配置して描画するために必要なものとなります.TileDataとTextInstanceは情報としては重複していますが今のところ保持しています.
bool TimePass ( );
virtual void Action ( ) = 0;
Timepassはゲーム内時間が1ターン進んだ時にそのActorが自分の行動ターンを獲得できるか判定する関数です.PlayerもEnemyも共通で良い(ようなシステムにするつもりである)ので仮想関数にはしません.中身の仕様はソースを見ればわかると思います.
行動を定義するのがAction関数で,こちらは仮想関数になります.Timepassで真が返ってきたからと言って即座に行動できるわけでは無いので,Timepass関数内でActionを呼ぶことは無く,そのあたりの制御はGameクラスに任せます.
virtual void Attack ( Actor* target ) = 0;
virtual void TakeDamage ( int damage ) = 0;
攻撃行動とダメージを受けたときの挙動を表すための純粋仮想関数.今回はこれらの関数で互いに干渉してもらいます.
Enemyクラス
Actorクラスから継承してきます.仮想関数のオーバーロード部分の実装のみ記載します.
void Enemy::Action()
{
int rand = mRNG->GetRandInt(0, 8);
Pos pos = GetPos ( );
pos.x += rand % 3 - 1;
pos.y += rand / 3 - 1;
auto actor = mGame->GetActorAt ( pos );
if ( actor == (Actor*)mGame->mPlayer ) {
Attack ( (Actor*)mGame->mPlayer );
return;
} else if ( actor != nullptr ) {
return;
}
if (!mGame->isOccupied(pos)) {
SetPos(pos);
}
}
void Enemy::Attack ( Actor* target )
{
int damage = mRNG->DiceRoll( mAtkDice, mAtkSides );
target->TakeDamage ( damage );
mGame->mMessage->AddMessage ( "An enemy attacked you for " + std::to_string ( damage ) + " damage." );
}
void Enemy::TakeDamage ( int damage )
{
mHP -= damage;
if ( mHP <= 0 ) {
SetPos ( Pos ( 0, 0 ) );
}
}
Action関数については,まだ敵の行動アルゴリズムを作っていないので,上下左右斜めからランダムに選択して移動or攻撃してもらうという雑なシステムです.Gameクラスに,移動先に他のActorがいるかどうか,移動できるかを判定する関数を作っておき,そこにアクセスして可能ならPlayerを攻撃したり移動したりします.
なおmRNGは疑似乱数生成器をラップしたクラスで,ポインタをコンストラクタで受け取っておきます.
Attack関数はランダムにダメージの値を決定して相手ActorのTakeDamage関数を呼ぶという仕様です.同士討ちもできますがその場合も"attacked you"のメッセージが表示される,こちらも雑な仕様となっております.
TakeDamageはそのまま受けたダメージをmHPから差し引きます.mHPが0を下回ったときは,(Actor消去の仕組みを作るのが面倒なので)マップの端に追いやるのみとなっています.
Playerクラス
Playerの行動についてはまだGameクラス内にナイーブに記述しているので,Action関数では何も行いません.AttackとTakeDamageは今のところEnemyクラス準拠で,ほぼ同じものになっています.
クラスの利用・ゲーム内時間の管理
参考文献1のサンプルでは,毎フレーム各Actorに前フレームからの時間を渡して更新処理を行わせます.しかし,今作ろうとしているゲームはゲーム内時間と現実時間は同期しないので,そのスタイルでは上手く行きません.
ここではGameクラス内に状態を表す列挙体を作ることで対応します.
enum class GameState : short
{
WaitForInput,
GameTimePass,
Paused,
Quit
};
まず,初期化処理中にGameクラスのメンバとしてActorのインスタンスのリストを持っておきます.ただしPlayerのみ別で保持します.
std::vector<mActors*> mActors;
Player* mPlayer;
Game内のメインループ(Update関数)で以下のように処理します.
void Game::Update()
{
// ゲーム内時間経過,playerのターンになったら抜ける
while(mGameState == GameState::GameTimePass ) {
std::vector<Actor*> actorsToAct;
for ( auto& actor : mActors ) {
if ( actor->TimePass ( ) ) {
actorsToAct.emplace_back ( actor );
}
}
if ( mPlayer->TimePass ( ) ) {
mGameState = GameState::WaitForInput;
}
for ( auto& actor : actorsToAct ) {
actor->Action ( );
}
}
}
//ここでPlayerに対する入力処理を行う.Playerの行動が終了したら
// mGameState = GameState::GameTimePass;とする.
例えば将来的にActionに対して多少の演出を加えたいと思った場合は,GameStateにEffect中を表す状態を追加して,かつ上記ソースのactorToActを適切に保持し,mGameStateを上手くGameTimePass/Effectの間でスイッチすることで実現できるはず.
おわりに
まだまだ原型も良いところですがキャラクターを動かせてなによりです.
ゲーム内時間経過のアルゴリズム自体は手探りで書いているので,そのうち大きく変えるかもしれません.
無事にキャラクターまでたどり着いたので,次回はPlayer視点の視野計算に関する記事を書く予定です.
参考文献
- Sanjay Madhav. 吉川邦夫訳. ゲームプログラミング C++. 翔泳社, 2018.