Skip to content
第02回 プレイヤーの弾とオブジェクトプール

4 / 4 弾の移動更新と画面外での再利用 ​

このステップの目的

発射された弾を毎フレーム前進させ、画面外へ出た弾を自動停止させてプールへ戻す処理を実装します。

ゲームループへの組み込みと処理順序 ​

開始用コードで接続済みの、弾の管理クラスとGameの更新・描画の流れを確認します。呼び出しを重複して追加せず、実装した3関数がこの流れから呼ばれることを追います。

Game.cppcpp
void Game::Update(float deltaTime)
{
    player.Update(input, deltaTime);
    playerShots.Update(deltaTime);

    player.TryShoot(input, playerShots);
}

void Game::Draw() const
{
    player.Draw(images);
    playerShots.Draw(images);
}

描画の順序にも注目してください。プレイヤー機体を描画した後に弾を描画することで、機体の前方へ弾が重なって自然に見えるようになります。

弾の移動と画面外判定 ​

PlayerShotManager::Update()は、配列内のすべての弾に対してUpdate()を呼び出します。

PlayerShotManager.cppcpp
void PlayerShotManager::Update(float deltaTime)
{
    for (PlayerShot& shot : shots)
        shot.Update(deltaTime);
}

個々の弾の挙動は、PlayerShot::Update()に実装します。
弾が停止中(!isActive)であれば何もしません。有効な場合は、速度とdeltaTimeを用いて上方向(-Y方向)へ座標を進めます。

PlayerShot.cppcpp
void PlayerShot::Update(float deltaTime)
{
    if (!isActive)
        return;

    position.y -= tuning::playerShot::Speed * deltaTime;

    // DrawRect全体が画面の上へ出た弾を再利用できる状態に戻す
    Rect drawRect = GetDrawRect();
    if (drawRect.y + drawRect.height < 0.0f)
       Deactivate();
}

画面外判定の境界値 ​

弾が画面の上端(Y=0)を越えたかどうかの判定では、弾の中心座標ではなく「描画矩形の下端(drawRect.y + drawRect.height)」を調べます。
中心座標で判定すると、後ろ半分がまだ画面に見えている間に弾が消えてしまいます。自然に見せるため、弾全体が画面外へ出てからDeactivate()を呼びます。

停止した弾はメモリから削除されず、isActive = falseになります。次の発射時にPlayerShotManager::Spawn()が停止中の弾を見つけ、新しい弾として再利用します。

発展知識:有効要素を詰めるプール方式

この教材では、固定配列の全要素を調べてisActiveを確認します。実装が単純で、初学者にも処理を追いやすい方式です。

弾が何千発もあるゲームでは、停止中の弾まで毎フレーム調べると無駄が生じます。その場合は、有効な弾を配列の前方に集め、消えた弾を末尾の有効な弾と入れ替える方法がよく使われます。これをSwap & Popと呼びます。

スワップによる詰め替えの例cpp
// 停止した弾の位置に、現在末尾にある有効な弾を上書きして有効数を減らす
shots[index] = shots[activeShotCount - 1];
--activeShotCount;

どちらにも利点と注意点があります。たとえば、要素の順序が変わることや、参照を保持している場合の扱いに注意が必要です。管理方法を変えるときも、PlayerShotManagerの内部だけを変更でき、GameやPlayerには影響しません。これがカプセル化の利点です。

チェックリスト
  • ZまたはSpaceキーを押して、弾が上方向へまっすぐ進むことを確認できる
  • キーを押し続けた際に、一定間隔で複数の弾が連続して発射されることを確認できる
  • 画面上端から外へ出た弾が安全に消滅し、その後も上限に達することなく弾を撃ち続けられることを確認できる
  • プレイヤーを移動させながら撃った際、弾がその場に置き去りにされず発射位置から前進することを確認できる
  • Gameクラスが弾の配列構造を直接操作せず、すべてPlayerShotManager経由で完結していることを説明できる

まとめと次回への引き継ぎ ​

第02回では、弾クラスと管理クラスを分け、停止した弾を使い回すオブジェクトプールを実装しました。メモリの確保と破棄を繰り返さずに、多数の弾を安全かつ高速に連射できます。

第03回では、標的となる敵の出現と移動を実装します。今回の個体クラスと管理クラスの分け方を使い、規則的な出現と異なる移動パターンを作ります。

参考実装 ​

今回追加・変更するファイルの一例です。宣言や補助処理を確認したいときに開いてください。自分のコードへ必要な変更を反映し、既存の調整値や素材をそのまま上書きしないようにします。

Source/DxGame/Rect.h
Source/DxGame/Rect.hcpp
#pragma once

#include "DxGame/Vector2.h"

namespace dxgame
{
    // 左上座標と縦横の幅で矩形を表す
    // 矩形の位置や大きさを表すために使用する
    struct Rect
    {
        float x = 0.0f;
        float y = 0.0f;
        float width = 0.0f;
        float height = 0.0f;

        // 原点が矩形の中央になるRectを返す
        static constexpr Rect Centered(float width, float height)
        {
            return { -width * 0.5f, -height * 0.5f, width, height };
        }

        // offsetだけ移動した矩形を返す 大きさは変わらない
        // offsetだけ移動した矩形を返す(大きさは不変)
        constexpr Rect MovedBy(const Vector2& offset) const
        {
            return { x + offset.x, y + offset.y, width, height };
        }

    };
}
Source/Shooting/Game.cpp
Source/Shooting/Game.cppcpp
#include <DxLib.h>

#include <string>

#include <Windows.h>

#include "Config/ApplicationConfig.h"

#include "Shooting/Game.h"

namespace
{
    void ShowResourceLoadError(
        const wchar_t* resourceType,
        const std::wstring& failedPath)
    {
        std::wstring message = resourceType;
        message += L"の読み込みに失敗しました。\n\n対象: ";
        message += failedPath;
        message += L"\n\nAssetsフォルダーとファイル名を確認してください。";

        MessageBoxW(
            nullptr,
            message.c_str(),
            L"リソース読み込みエラー",
            MB_OK | MB_ICONERROR);
    }
}

namespace dxgame::shooting
{
    namespace config = dxgame::config;

    bool Game::Run()
    {
        const ImageManager::LoadResult imageResult = images.LoadAll();
        if (!imageResult.succeeded)
        {
            ShowResourceLoadError(L"画像", imageResult.failedPath);
            return false;
        }

        player.Reset();
        playerShots.Reset();

        // 小数分の待ち時間を次フレームへ持ち越す
        double nextFrame = GetNowCount();

        while (ProcessMessage() == 0)
        {
            input.Update();
            if (input.IsDown(Input::Action::Exit))
                break;

            Update(config::DeltaTime);
            ClearDrawScreen();
            Draw();
            ScreenFlip();

            nextFrame += config::FrameMilliseconds;
            int waitMilliseconds = static_cast<int>(nextFrame - GetNowCount());

            if (waitMilliseconds > 0)
                WaitTimer(waitMilliseconds);
            else
                nextFrame = GetNowCount();
        }

        return true;
    }

    void Game::Update(float deltaTime)
    {
        player.Update(input, deltaTime);
        playerShots.Update(deltaTime);

        player.TryShoot(input, playerShots);
    }

    void Game::Draw() const
    {
        player.Draw(images);
        playerShots.Draw(images);
    }
}
Source/Shooting/Game.h
Source/Shooting/Game.hcpp
#pragma once

#include "Shooting/ImageManager.h"
#include "Shooting/Input.h"
#include "Shooting/Player.h"
#include "Shooting/PlayerShotManager.h"

namespace dxgame::shooting
{
    // ゲームに必要なオブジェクトを所有し1フレームの処理を取りまとめる
    class Game
    {
    public:
        // 必須画像を読み込んでゲームループを開始する
        // 画像を読み込めない場合はループを開始せずfalseを返す
        bool Run();

    private:
        // プレイヤーを1フレーム分更新する
        // deltaTimeは移動計算で1フレーム分として使う秒数
        void Update(float deltaTime);

        // プレイヤーを描く
        void Draw() const;

        ImageManager images;
        Input input;
        Player player;
        PlayerShotManager playerShots;
    };
}
Source/Shooting/ImageManager.cpp
Source/Shooting/ImageManager.cppcpp
#include <array>
#include <cstddef>

#include <DxLib.h>

#include "Shooting/ImageManager.h"

namespace dxgame::shooting
{
    namespace
    {
        using ImageId = ImageManager::Id;

        struct ImageEntry
        {
            ImageId id;
            const wchar_t* path;
        };

        constexpr std::array<ImageEntry, static_cast<std::size_t>(ImageId::Count)> ImageTable =
        {
            ImageEntry { ImageId::Player, L"Assets/Images/Player.png" },
            ImageEntry { ImageId::PlayerShot, L"Assets/Images/Bullet.png" }
        };

        std::size_t ToIndex(ImageId id)
        {
            return static_cast<std::size_t>(id);
        }
    }

    ImageManager::ImageManager()
    {
        handles.fill(-1);
    }

    ImageManager::~ImageManager()
    {
        for (int handle : handles)
        {
            if (handle != -1)
                DeleteGraph(handle);
        }
    }

    ImageManager::LoadResult ImageManager::LoadAll()
    {
        for (const ImageEntry& entry : ImageTable)
        {
            std::size_t index = ToIndex(entry.id);
            handles[index] = LoadGraph(entry.path);

            if (handles[index] == -1)
                return { false, entry.path };
        }

        return { true, {} };
    }

    void ImageManager::Draw(Id id, const Rect& rect) const
    {
        int handle = handles[ToIndex(id)];
        DrawExtendGraph(
            static_cast<int>(rect.x),
            static_cast<int>(rect.y),
            static_cast<int>(rect.x + rect.width),
            static_cast<int>(rect.y + rect.height),
            handle,
            TRUE);
    }
}
Source/Shooting/ImageManager.h
Source/Shooting/ImageManager.hcpp
#pragma once

#include <array>
#include <cstddef>
#include <string>

#include "DxGame/Rect.h"

namespace dxgame::shooting
{
    // 画像の読み込み、ハンドルの所有、拡大縮小描画、解放を管理する
    class ImageManager
    {
    public:
        // ゲーム内で使う画像を種類ごとに識別する
        enum class Id
        {
            Player,
            PlayerShot,
            Count
        };

        // 全ハンドルを未取得の状態で初期化する
        ImageManager();

        // 取得済みの画像ハンドルをすべて解放する
        ~ImageManager();

        // ハンドルを二重に解放しないようコピー構築を禁止する
        ImageManager(const ImageManager&) = delete;

        // ハンドルを二重に解放しないようコピー代入を禁止する
        ImageManager& operator=(const ImageManager&) = delete;

        // 画像の読み込み結果と、失敗した場合の相対パスを表す
        struct LoadResult
        {
            bool succeeded = true;
            std::wstring failedPath;
        };

        // 対応表にある画像をすべて読み込む
        // 1つでも読み込めない場合は、その画像の相対パスとともに失敗を返す
        LoadResult LoadAll();

        // idの画像をrectの大きさへ拡大縮小して描く
        void Draw(Id id, const Rect& rect) const;

    private:
        std::array<int, static_cast<std::size_t>(Id::Count)> handles = {};
    };
}
Source/Shooting/Input.cpp
Source/Shooting/Input.cppcpp
#include <DxLib.h>

#include "Shooting/Input.h"

namespace dxgame::shooting
{
    namespace
    {
        using Action = Input::Action;

        // Actionをビットフラグに変換する
        constexpr unsigned int ToBit(Action action)
        {
            return 1u << static_cast<unsigned int>(action);
        }

        // Actionと物理キーの対応
        struct Binding
        {
            Action action;
            int key;
        };

        // ゲーム内の操作と物理キーの対応表
        constexpr Binding Bindings[] =
        {
            { Action::MoveLeft, KEY_INPUT_LEFT },
            { Action::MoveLeft, KEY_INPUT_A },
            { Action::MoveRight, KEY_INPUT_RIGHT },
            { Action::MoveRight, KEY_INPUT_D },
            { Action::MoveUp, KEY_INPUT_UP },
            { Action::MoveUp, KEY_INPUT_W },
            { Action::MoveDown, KEY_INPUT_DOWN },
            { Action::MoveDown, KEY_INPUT_S },
            { Action::Shoot, KEY_INPUT_Z },
            { Action::Shoot, KEY_INPUT_SPACE },
            { Action::Exit, KEY_INPUT_ESCAPE }
        };
    }

    void Input::Update()
    {
        current = 0;

        for (const Binding& binding : Bindings)
        {
            if (CheckHitKey(binding.key))
                current |= ToBit(binding.action);
        }
    }

    bool Input::IsDown(Action action) const
    {
        return (current & ToBit(action)) != 0;
    }
}
Source/Shooting/Input.h
Source/Shooting/Input.hcpp
#pragma once

namespace dxgame::shooting
{
    // シューティングゲームに必要な入力の状況を返す
    class Input
    {
    public:
        // ゲーム内で扱う入力別のアクション
        enum class Action
        {
            MoveLeft,
            MoveRight,
            MoveUp,
            MoveDown,
            Shoot,
            Exit
        };

        // 物理キーを調べて現在の入力状態を更新する
        void Update();

        // 押されている間はtrueを返す
        bool IsDown(Action action) const;

    private:
        unsigned int current = 0;
    };
}
Source/Shooting/Player.cpp
Source/Shooting/Player.cppcpp
#include <algorithm>
#include <cmath>

#include "Shooting/ImageManager.h"
#include "Shooting/Input.h"
#include "Shooting/PlayerShotManager.h"
#include "Shooting/Tuning/GameTuning.h"

#include "Shooting/Player.h"

namespace dxgame::shooting
{
    using Action = Input::Action;

    void Player::Reset()
    {
        position = tuning::player::StartPosition;
        shotTimer = 0.0f;
    }

    void Player::Update(const Input& input, float deltaTime)
    {
        Vector2 direction;

        if (input.IsDown(Action::MoveLeft))
            direction.x -= 1.0f;
        if (input.IsDown(Action::MoveRight))
            direction.x += 1.0f;
        if (input.IsDown(Action::MoveUp))
            direction.y -= 1.0f;
        if (input.IsDown(Action::MoveDown))
            direction.y += 1.0f;

        // 斜め入力を長さ1に収める
        // 方向と長さの計算は第05回で詳しく扱う
        float length = std::sqrt(direction.x * direction.x + direction.y * direction.y);

        if (length > 1.0f)
        {
            direction.x /= length;
            direction.y /= length;
        }

        position.x += direction.x * tuning::player::MoveSpeed * deltaTime;
        position.y += direction.y * tuning::player::MoveSpeed * deltaTime;

        // 指定の範囲全体が画面内に残るよう位置を制限する
        const Rect& bounds = tuning::player::Bounds;

        float minX = -bounds.x;
        float minY = -bounds.y;
        float maxX = tuning::ScreenWidth - bounds.x - bounds.width;
        float maxY = tuning::ScreenHeight - bounds.y - bounds.height;
        position.x = std::clamp(position.x, minX, maxX);
        position.y = std::clamp(position.y, minY, maxY);

        shotTimer = std::max(0.0f, shotTimer - deltaTime);
    }

    void Player::Draw(const ImageManager& images) const
    {
        images.Draw(ImageManager::Id::Player, GetDrawRect());
    }

    bool Player::TryShoot(const Input& input, PlayerShotManager& shots)
    {
        if (!input.IsDown(Action::Shoot) || shotTimer > 0.0f)
            return false;

        if (!shots.Spawn(GetMuzzlePosition()))
            return false;

        shotTimer = tuning::player::ShotInterval;
        return true;
    }

Vector2 Player::GetMuzzlePosition() const
{
    return
    {
        position.x,
        position.y - 32.0f
    };
}

    Rect Player::GetDrawRect() const
    {
        return tuning::player::DrawRect.MovedBy(position);
    }

}
Source/Shooting/Player.h
Source/Shooting/Player.hcpp
#pragma once

#include "DxGame/Rect.h"
#include "DxGame/Vector2.h"

namespace dxgame::shooting
{
    class Input;
    class ImageManager;
    class PlayerShotManager;

    // プレイヤーの中心座標、移動、発射間隔を管理する
    class Player
    {
    public:
        // プレイヤーを開始位置へ戻す
        void Reset();

        // 入力から移動方向を求めプレイヤーを画面内で移動させる
        // deltaTimeは移動計算で1フレーム分として使う秒数
        void Update(const Input& input, float deltaTime);

        // プレイヤー画像を表示用Rectに合わせて描く
        void Draw(const ImageManager& images) const;

        // 入力と発射間隔を確認し、弾を生成できたらtrueを返す
        bool TryShoot(const Input& input, PlayerShotManager& shots);

        // 表示用Rectを現在位置へ移動して返す
        Rect GetDrawRect() const;

    private:
        // 発射する弾の中心座標を返す
        Vector2 GetMuzzlePosition() const;
        // プレイヤーの中心座標
        Vector2 position = {};
        float shotTimer = 0.0f;
    };
}
Source/Shooting/PlayerShot.cpp
Source/Shooting/PlayerShot.cppcpp
#include "Shooting/ImageManager.h"
#include "Shooting/Tuning/GameTuning.h"

#include "Shooting/PlayerShot.h"

namespace dxgame::shooting
{
    void PlayerShot::Spawn(const Vector2& startPosition)
    {
        position = startPosition;
        isActive = true;
    }

    void PlayerShot::Update(float deltaTime)
    {
        if (!isActive)
            return;

        position.y -= tuning::playerShot::Speed * deltaTime;

        // DrawRect全体が画面の上へ出た弾を再利用できる状態に戻す
        Rect drawRect = GetDrawRect();
        if (drawRect.y + drawRect.height < 0.0f)
           Deactivate();
    }

    void PlayerShot::Draw(const ImageManager& images) const
    {
        if (!isActive)
            return;

        Rect drawRect = GetDrawRect();
        images.Draw(ImageManager::Id::PlayerShot, drawRect);
    }

    bool PlayerShot::IsActive() const
    {
        return isActive;
    }

    void PlayerShot::Deactivate()
    {
        isActive = false;
    }

    Rect PlayerShot::GetDrawRect() const
    {
        return tuning::playerShot::DrawRect.MovedBy(position);
    }

}
Source/Shooting/PlayerShot.h
Source/Shooting/PlayerShot.hcpp
#pragma once

#include "DxGame/Rect.h"
#include "DxGame/Vector2.h"

namespace dxgame::shooting
{
    class ImageManager;

    // プレイヤーが発射する1発の弾の位置と実行状態を管理する
    class PlayerShot
    {
    public:
        // 中心をstartPositionへ移動して弾を実行状態にする
        void Spawn(const Vector2& startPosition);

        // 弾を上方向へ移動し画面外へ出たら停止する
        // deltaTimeは1フレームの経過秒数
        void Update(float deltaTime);

        // 実行中の弾を画像で描く
        void Draw(const ImageManager& images) const;

        // 更新、描画の対象ならtrueを返す
        bool IsActive() const;

        // 弾を更新、描画の対象外にする
        void Deactivate();

        // 表示用Rectを現在位置へ移動して返す
        Rect GetDrawRect() const;

    private:
        // 弾の中心座標
        Vector2 position = {};
        bool isActive = false;
    };
}
Source/Shooting/PlayerShotManager.cpp
Source/Shooting/PlayerShotManager.cppcpp
#include "Shooting/ImageManager.h"

#include "Shooting/PlayerShotManager.h"

namespace dxgame::shooting
{
    bool PlayerShotManager::Spawn(const Vector2& startPosition)
    {
        // 先頭から停止中の弾を探して再利用する
        for (PlayerShot& shot : shots)
        {
            if (shot.IsActive())
                continue;

            shot.Spawn(startPosition);
            return true;
        }
        return false;
    }

    void PlayerShotManager::Update(float deltaTime)
    {
        for (PlayerShot& shot : shots)
            shot.Update(deltaTime);
    }

    void PlayerShotManager::Draw(const ImageManager& images) const
    {
        for (const PlayerShot& shot : shots)
            shot.Draw(images);
    }

    void PlayerShotManager::Reset()
    {
        for (PlayerShot& shot : shots)
            shot.Deactivate();
    }
}
Source/Shooting/PlayerShotManager.h
Source/Shooting/PlayerShotManager.hcpp
#pragma once

#include <array>
#include <cstddef>

#include "Shooting/PlayerShot.h"

namespace dxgame::shooting
{
    class ImageManager;

    // プレイヤー弾を固定数所有し生成、更新、描画、再利用を管理する
    class PlayerShotManager
    {
    public:
        // 停止中の弾の中心をstartPositionに合わせて生成する
        // 空きがない場合は生成せずfalseを返す
        bool Spawn(const Vector2& startPosition);

        // 所有するすべてのプレイヤー弾を更新する
        // deltaTimeは1フレームの経過秒数
        void Update(float deltaTime);

        // 所有するすべてのプレイヤー弾を描く
        void Draw(const ImageManager& images) const;

        // 所有するすべてのプレイヤー弾を停止して空の状態へ戻す
        void Reset();

    private:
        // 停止中の要素を再利用しゲーム中にはメモリを確保しない
        static constexpr std::size_t Capacity = 48;

        std::array<PlayerShot, Capacity> shots = {};
    };
}
Source/Shooting/Tuning/GameTuning.h
Source/Shooting/Tuning/GameTuning.hcpp
#pragma once

#include "DxGame/Rect.h"
#include "DxGame/Vector2.h"

#include "Config/ApplicationConfig.h"

namespace dxgame::shooting::tuning
{
    // ゲーム画面の横幅 単位はピクセル
    // ゲーム画面の横幅(単位:ピクセル)
    inline constexpr int ScreenWidth = 640;

    // ゲーム画面の縦幅 単位はピクセル
    // ゲーム画面の縦幅(単位:ピクセル)
    inline constexpr int ScreenHeight = 480;

    // 座標、距離、Rectの各値はピクセル単位で指定する
    // 時間は秒、速度は1秒あたりの移動量で指定する
    // プレイヤーのpositionは中心座標を表す
    // 各Rectはpositionからの相対範囲として指定する

    namespace player
    {
        // ゲーム開始時のプレイヤー中心座標
        inline constexpr Vector2 StartPosition = { 320.0f, 424.0f };

        // プレイヤー中心を基準にした画像の表示範囲
        inline constexpr Rect DrawRect = Rect::Centered(48.0f, 58.0f).MovedBy({ 0.0f, 3.0f });

        inline constexpr Rect Bounds = Rect::Centered(28.0f, 28.0f);

        // プレイヤーの速度 単位はピクセル/秒
        // プレイヤーの速度(単位:ピクセル/秒)
        inline constexpr float MoveSpeed = 240.0f;

        // 弾を発射してから次に発射できるまでの時間 ここでは8フレーム間隔の意味
        // 弾を発射してから次に発射できるまでの時間(ここでは8フレーム間隔)
        inline constexpr float ShotInterval = 8.0f * config::DeltaTime;
    }

    namespace playerShot
    {
        // 弾の中心を基準にした画像の表示範囲
        inline constexpr Rect DrawRect = Rect::Centered(10.0f, 28.0f);

        // 弾の速度 単位はピクセル/秒
        // 弾の速度(単位:ピクセル/秒)
        inline constexpr float Speed = 480.0f;
    }
}