Skip to content
第22回 敵の物理と巡回状態

4 / 4 足場端の先読みによる落下防止AI

このステップの目的

敵キャラクターの進行方向の足元を調べるセンサー矩形を構築し、足場の切れ端(崖)を検知して落下する前に方向転換する巡回AIを実装します。

足場端の先読みが必要な理由

壁衝突による反転だけでは、周囲に壁のない浮島状の足場に配置された敵キャラクターは、端まで歩いた後にそのまま崖下へ落下してしまいます。

敵が指定された足場の上を往復巡回し続けるためには、実際に足を踏み外す前に進行方向の足元に床タイルが存在するかどうかを「1歩先読み」するセンサーが必要です。

text
          [敵] ---> (進行方向)
          +----+
          |    |
==========+----+ . [センサー(1x1)]
  床タイル       |   ここが空白なら反転
                 v (崖)

足元プローブ矩形の算出

敵の進行方向の直前下部を検査するため、敵の当たり判定矩形(Hitbox)を基準にして、幅1ピクセル・高さ1ピクセルの細い調査用矩形(プローブ)を作成します。

Enemy.cppcpp
bool Enemy::HasGroundAhead(const Stage& stage) const
{
    const dxgame::Rect hitbox = GetHitbox();
    const float probeX = facingRight_
        ? hitbox.x + hitbox.width
        : hitbox.x - 1.0f;
    const dxgame::Rect groundProbe = {
        probeX, hitbox.y + hitbox.height + 1.0f, 1.0f, 1.0f
    };
    return stage.IsSolidAtRect(groundProbe);
}
  • X座標: 右向きの場合は右端(hitbox.x + hitbox.width)、左向きの場合は左端の1ピクセル外側(hitbox.x - 1.0f
  • Y座標: 敵の底面よりも1ピクセル下(hitbox.y + hitbox.height + 1.0f

このプローブ矩形を Stage::IsSolidAtRect() に渡すことで、進行方向の足元に足場が存在するかを判定します。

二重反転の防止と判定条件の統合

方向転換の判定を行う際は、以下の2点に留意します。

  1. 接地中であることの確認(onGround_:
    敵が空中を落下している最中は足元に床が存在しないため、空中で先読みを行うと着地するまで毎フレーム反転を繰り返してしまいます。先読み判定は接地中に限定します。
  2. 壁衝突との二重反転防止:
    壁衝突の判定と足元の判定を独立した if 文で別々に処理すると、壁と崖が重なる地形で1フレームに2回反転し、結果として向きが変わらない不具合が生じます。
Enemy.cppcpp
// 壁に接触したか、または「接地中かつ前方に床がない」場合に反転する
if (hitWall || (onGround_ && !HasGroundAhead(stage)))
{
    TurnAround();
}

論理OR演算子(||)の短絡評価により、壁に接触したフレームでは足元調査をスキップし、1フレームにつき確実に1回だけの反転が行われます。

チェックリスト
  • 敵の進行方向と足底の位置から1ピクセルの足元先読みプローブ矩形を正しく算出できる
  • 空中落下中の誤反転を防ぐため、接地中(onGround_)に限定して先読みを行う理由を説明できる
  • 壁衝突と崖検知が同時に起きた際の二重反転を防ぎ、1フレームに1回だけ反転させる統合条件式を実装できる

次回への引き継ぎ

第22回では、敵単体のライフサイクル、重力と軸分離衝突処理の適用、壁接触による即時反転、足元先読みプローブによる崖落ち防止AIを実装しました。

次回(第23回)は、トゲトラップの判定、敵との踏みつけ・被弾判定、およびタイトル・プレイ中・クリア・ゲームオーバーというゲーム全体の進行状態を管理するステートマシンを構築します。

参考実装

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

Source/Platformer/Enemy.cpp
Source/Platformer/Enemy.cppcpp
#include <algorithm>

#include "Platformer/Camera.h"
#include "Platformer/Enemy.h"
#include "Platformer/ImageManager.h"
#include "Platformer/Stage.h"
#include "Platformer/Tuning/GameTuning.h"

namespace dxgame::platformer
{
    void Enemy::Reset(const dxgame::Vector2& position)
    {
        position_ = position;
        velocity_ = {};
        remainderX_ = 0.0f;
        remainderY_ = 0.0f;
        onGround_ = false;
        facingRight_ = false;
        state_ = EnemyState::Patrol;
    }

    void Enemy::Update(const Stage& stage, float deltaTime)
    {
        if (!IsActive())
            return;

        velocity_.x = facingRight_
            ? tuning::enemy::MoveSpeed
            : -tuning::enemy::MoveSpeed;
        velocity_.y = std::min(
            velocity_.y + tuning::enemy::Gravity * deltaTime,
            tuning::enemy::MaxFallSpeed);

        const bool hitWall = MoveHorizontal(stage, deltaTime);
        MoveVertical(stage, deltaTime);

        if (hitWall || (onGround_ && !HasGroundAhead(stage)))
            TurnAround();
    }

    void Enemy::Draw(const Camera& camera, const ImageManager& images) const
    {
        if (!IsActive())
            return;

        images.Draw(ImageId::Enemy, camera.WorldToScreen(GetDrawRect()));
    }

    void Enemy::Defeat()
    {
        state_ = EnemyState::Defeated;
        velocity_ = {};
        remainderX_ = 0.0f;
        remainderY_ = 0.0f;
    }

    bool Enemy::IsActive() const
    {
        return state_ == EnemyState::Patrol;
    }

    dxgame::Rect Enemy::GetHitbox() const
    {
        return tuning::enemy::Hitbox.MovedBy(position_);
    }

    dxgame::Rect Enemy::GetDrawRect() const
    {
        return tuning::enemy::DrawRect.MovedBy(position_);
    }

    dxgame::Vector2 Enemy::GetPosition() const
    {
        return position_;
    }

    EnemyState Enemy::GetState() const
    {
        return state_;
    }

    bool Enemy::MoveHorizontal(const Stage& stage, float deltaTime)
    {
        remainderX_ += velocity_.x * deltaTime;
        const int pixelMove = static_cast<int>(remainderX_);
        remainderX_ -= static_cast<float>(pixelMove);
        if (pixelMove == 0)
            return false;

        dxgame::Rect movedRect = GetHitbox();
        const bool hitWall = stage.ResolveHorizontal(movedRect, pixelMove);
        position_.x = movedRect.GetCenter().x;

        if (hitWall)
        {
            velocity_.x = 0.0f;
            remainderX_ = 0.0f;
        }

        return hitWall;
    }

    void Enemy::MoveVertical(const Stage& stage, float deltaTime)
    {
        remainderY_ += velocity_.y * deltaTime;
        const int pixelMove = static_cast<int>(remainderY_);
        remainderY_ -= static_cast<float>(pixelMove);

        if (pixelMove != 0)
        {
            dxgame::Rect movedRect = GetHitbox();
            if (stage.ResolveVertical(movedRect, pixelMove))
            {
                velocity_.y = 0.0f;
                remainderY_ = 0.0f;
            }
            position_.y = movedRect.GetCenter().y;
        }

        dxgame::Rect groundProbe = GetHitbox();
        groundProbe.y += 1.0f;
        onGround_ = stage.IsSolidAtRect(groundProbe);
        if (onGround_ && velocity_.y >= 0.0f)
        {
            velocity_.y = 0.0f;
            remainderY_ = 0.0f;
        }
    }

    bool Enemy::HasGroundAhead(const Stage& stage) const
    {
        const dxgame::Rect hitbox = GetHitbox();
        const float probeX = facingRight_
            ? hitbox.x + hitbox.width
            : hitbox.x - 1.0f;
        const dxgame::Rect groundProbe = {
            probeX, hitbox.y + hitbox.height + 1.0f, 1.0f, 1.0f
        };
        return stage.IsSolidAtRect(groundProbe);
    }

    void Enemy::TurnAround()
    {
        facingRight_ = !facingRight_;
    }
}
Source/Platformer/Enemy.h
Source/Platformer/Enemy.hcpp
#pragma once

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

namespace dxgame::platformer
{
    class Camera;
    class ImageManager;
    class Stage;

    enum class EnemyState
    {
        Patrol,
        Defeated
    };

    // ステージ上に配置される敵1体の状態を管理する
    class Enemy
    {
    public:
        void Reset(const dxgame::Vector2& position);
        void Update(const Stage& stage, float deltaTime);
        void Draw(const Camera& camera, const ImageManager& images) const;

        void Defeat();
        bool IsActive() const;

        dxgame::Rect GetHitbox() const;
        dxgame::Rect GetDrawRect() const;
        dxgame::Vector2 GetPosition() const;
        EnemyState GetState() const;

    private:
        bool MoveHorizontal(const Stage& stage, float deltaTime);
        void MoveVertical(const Stage& stage, float deltaTime);
        bool HasGroundAhead(const Stage& stage) const;
        void TurnAround();

        dxgame::Vector2 position_ = {};
        dxgame::Vector2 velocity_ = {};
        float remainderX_ = 0.0f;
        float remainderY_ = 0.0f;
        bool onGround_ = false;
        bool facingRight_ = false;
        EnemyState state_ = EnemyState::Patrol;
    };
}
Source/Platformer/EnemyManager.cpp
Source/Platformer/EnemyManager.cppcpp
#include "Platformer/Camera.h"
#include "Platformer/EnemyManager.h"
#include "Platformer/ImageManager.h"
#include "Platformer/Stage.h"

namespace dxgame::platformer
{
    void EnemyManager::Reset(const std::vector<SpawnPoint>& spawnPoints)
    {
        enemies_.clear();
        enemies_.reserve(spawnPoints.size());

        for (const SpawnPoint& spawnPoint : spawnPoints)
        {
            Enemy enemy;
            enemy.Reset(spawnPoint.position);
            enemies_.push_back(enemy);
        }
    }

    void EnemyManager::Update(const Stage& stage, float deltaTime)
    {
        for (Enemy& enemy : enemies_)
            enemy.Update(stage, deltaTime);
    }

    void EnemyManager::Draw(const Camera& camera,
        const ImageManager& images) const
    {
        for (const Enemy& enemy : enemies_)
            enemy.Draw(camera, images);
    }

    std::size_t EnemyManager::GetCount() const
    {
        return enemies_.size();
    }

    std::size_t EnemyManager::GetActiveCount() const
    {
        std::size_t activeCount = 0;
        for (const Enemy& enemy : enemies_)
        {
            if (enemy.IsActive())
                activeCount++;
        }
        return activeCount;
    }
}
Source/Platformer/EnemyManager.h
Source/Platformer/EnemyManager.hcpp
#pragma once

#include <vector>

#include "Platformer/Enemy.h"
#include "Platformer/Stage.h"

namespace dxgame::platformer
{
    class Camera;
    class Game;
    class ImageManager;

    // CSVから読み込んだ敵配置をEnemyの集合として所有する
    class EnemyManager
    {
    public:
        // ステージの配置情報から敵を作り直す
        void Reset(const std::vector<SpawnPoint>& spawnPoints);
        // 有効な敵の物理と状態を更新する
        void Update(const Stage& stage, float deltaTime);
        // カメラ内の敵を描画する
        void Draw(const Camera& camera, const ImageManager& images) const;

        std::size_t GetCount() const;
        std::size_t GetActiveCount() const;

    private:
        friend class Game;

        std::vector<Enemy>& GetAll() { return enemies_; }
        const std::vector<Enemy>& GetAll() const { return enemies_; }

        std::vector<Enemy> enemies_;
    };
}
Source/Platformer/Game.cpp
Source/Platformer/Game.cppcpp
#include <Windows.h>
#include <string>

#include "DxLib.h"

#include "Config/ApplicationConfig.h"
#include "Platformer/Game.h"
#include "Platformer/Tuning/GameTuning.h"

namespace dxgame::platformer
{
    bool Game::Initialize()
    {
        if (!images_.LoadAll())
            return false;

        const ImageResource& playerImage = images_.Get(ImageId::Player);
        if (playerImage.width % tuning::animation::PlayerFrameCount != 0)
        {
            MessageBoxW(nullptr,
                L"Player.pngの横幅を8で割り切れません。",
                L"Player Image Error", MB_OK);
            return false;
        }

        if (!sounds_.LoadAll())
            return false;
        constexpr wchar_t StagePath[] = L"Assets/Maps/Stage01.csv";
        if (!stage_.Initialize(StagePath))
        {
            const std::wstring message = std::wstring(StagePath) + L"\n" +
                stage_.GetLastLoadError();
            MessageBoxW(nullptr, message.c_str(), L"Map Load Error", MB_OK);
            return false;
        }

        ResetStage();
        sounds_.PlayBgm(SoundId::PlayingBgm);
        return true;
    }

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

        while (ProcessMessage() == 0)
        {
            input_.Update();
            if (input_.IsHeld(Key::Escape))
                break;

            Update(dxgame::config::DeltaTime);

            ClearDrawScreen();
            Draw();
            ScreenFlip();

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

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

    void Game::Update(float deltaTime)
    {
        UpdateDebugToggle();
        const PlayerEvents events = player_.Update(input_, stage_, deltaTime);
        HandlePlayerEvents(events);
        enemies_.Update(stage_, deltaTime);

        ResetAfterFall();
        camera_.Update(player_.GetPosition(), stage_.GetWorldWidth());
    }

    void Game::Draw() const
    {
        DrawBackground();
        stage_.Draw(camera_.GetX(), images_);
        enemies_.Draw(camera_, images_);
        player_.Draw(camera_, images_);
        DrawHud();
        DrawDebug();
    }

    void Game::DrawBackground() const
    {
        images_.Draw(ImageId::Background, { 0.0f, 0.0f,
            static_cast<float>(dxgame::config::ScreenWidth),
            static_cast<float>(dxgame::config::ScreenHeight) });
    }

    void Game::DrawHud() const
    {
        const int textColor = GetColor(255, 255, 255);
        DrawString(12, 10, L"LEFT/RIGHT: MOVE", textColor);
        DrawString(12, 30, L"Z: JUMP", textColor);
        DrawString(12, 50, L"F1: DEBUG    ESC: QUIT", textColor);
        DrawFormatString(470, 10, textColor, L"ENEMIES: %d",
            static_cast<int>(enemies_.GetActiveCount()));
    }

    void Game::DrawDebug() const
    {
        if (!debugVisible_)
            return;

        stage_.DrawCollisionDebug(camera_.GetX());

        const dxgame::Rect playerDrawRect =
            camera_.WorldToScreen(player_.GetDrawRect());
        DrawBox(static_cast<int>(playerDrawRect.x),
            static_cast<int>(playerDrawRect.y),
            static_cast<int>(playerDrawRect.x + playerDrawRect.width),
            static_cast<int>(playerDrawRect.y + playerDrawRect.height),
            GetColor(80, 240, 255), FALSE);

        const dxgame::Rect playerRect =
            camera_.WorldToScreen(player_.GetHitbox());
        DrawBox(static_cast<int>(playerRect.x), static_cast<int>(playerRect.y),
            static_cast<int>(playerRect.x + playerRect.width),
            static_cast<int>(playerRect.y + playerRect.height),
            GetColor(255, 80, 80), FALSE);

        const dxgame::Rect goalDrawRect =
            camera_.WorldToScreen(stage_.GetGoalDrawRect());
        DrawBox(static_cast<int>(goalDrawRect.x),
            static_cast<int>(goalDrawRect.y),
            static_cast<int>(goalDrawRect.x + goalDrawRect.width),
            static_cast<int>(goalDrawRect.y + goalDrawRect.height),
            GetColor(80, 240, 255), FALSE);

        const dxgame::Rect goalRect =
            camera_.WorldToScreen(stage_.GetGoalTrigger());
        DrawBox(static_cast<int>(goalRect.x), static_cast<int>(goalRect.y),
            static_cast<int>(goalRect.x + goalRect.width),
            static_cast<int>(goalRect.y + goalRect.height),
            GetColor(255, 220, 40), FALSE);

        for (const Enemy& enemy : enemies_.GetAll())
        {
            if (!enemy.IsActive())
                continue;

            const dxgame::Rect enemyDrawRect =
                camera_.WorldToScreen(enemy.GetDrawRect());
            DrawBox(static_cast<int>(enemyDrawRect.x),
                static_cast<int>(enemyDrawRect.y),
                static_cast<int>(enemyDrawRect.x + enemyDrawRect.width),
                static_cast<int>(enemyDrawRect.y + enemyDrawRect.height),
                GetColor(80, 240, 255), FALSE);

            const dxgame::Rect enemyRect =
                camera_.WorldToScreen(enemy.GetHitbox());
            DrawBox(static_cast<int>(enemyRect.x),
                static_cast<int>(enemyRect.y),
                static_cast<int>(enemyRect.x + enemyRect.width),
                static_cast<int>(enemyRect.y + enemyRect.height),
                GetColor(80, 255, 120), FALSE);
        }

        DrawBox(8, 76, 320, 198, GetColor(0, 0, 0), TRUE);
        const int textColor = GetColor(255, 255, 255);
        const dxgame::Vector2& position = player_.GetPosition();
        const dxgame::Vector2& velocity = player_.GetVelocity();
        DrawFormatString(14, 84, textColor,
            L"pos: %.1f, %.1f", position.x, position.y);
        DrawFormatString(14, 104, textColor,
            L"velocity: %.2f, %.2f", velocity.x, velocity.y);
        DrawFormatString(14, 124, textColor,
            L"ground: %d camera: %d", player_.IsOnGround() ? 1 : 0,
            camera_.GetX());
        DrawFormatString(14, 144, textColor,
            L"enemies: %d csv: %d",
            static_cast<int>(enemies_.GetCount()),
            stage_.IsLoadedFromCsv() ? 1 : 0);
        DrawString(14, 164,
            L"cyan: draw  red/green/yellow: hit", textColor);
        DrawString(14, 184, L"blue: solid tile", textColor);
    }

    void Game::UpdateDebugToggle()
    {
        if (input_.IsPressed(Key::Debug))
            debugVisible_ = !debugVisible_;
    }

    void Game::ResetAfterFall()
    {
        if (player_.GetPosition().y <=
            stage_.GetWorldHeight() + tuning::stage::FallResetMargin)
        {
            return;
        }

        ResetStage();
    }

    void Game::ResetStage()
    {
        player_.Reset(stage_.GetPlayerSpawn());
        enemies_.Reset(stage_.GetEnemySpawns());
        camera_.Update(player_.GetPosition(), stage_.GetWorldWidth());
    }

    void Game::HandlePlayerEvents(const PlayerEvents& events)
    {
        if (events.jumped)
            sounds_.PlaySe(SoundId::Jump);
        if (events.landed)
            sounds_.PlaySe(SoundId::Land);
    }

}
Source/Platformer/Tuning/GameTuning.h
Source/Platformer/Tuning/GameTuning.hcpp
#pragma once

#include "Config/ApplicationConfig.h"
#include "DxGame/Rect.h"

namespace dxgame::platformer::tuning
{
    // 距離はピクセル、時間は秒、速度はピクセル/秒、
    // 加速度と重力はピクセル/秒²を基本単位とする。
    // 加速度と重力はピクセル/秒²を基本単位とする
    namespace player
    {
        // 中心座標を基準にした表示範囲と地形判定範囲
        // 画像本来の大きさとは独立して調整する
        inline constexpr Rect DrawRect =
            Rect::Centered(32.0f, 48.0f).MovedBy({ 0.0f, -8.0f });
        inline constexpr Rect Hitbox = Rect::Centered(24.0f, 30.0f);

        // 左右入力中の加速度、最大速度、無入力時の減速度
        // 速度の単位はピクセル/秒、加減速度はピクセル/秒²
        inline constexpr float Acceleration = 1260.0f;
        inline constexpr float MaxSpeed = 270.0f;
        inline constexpr float Friction = 900.0f;

        // 上昇中と落下中の重力、および最大落下速度
        // 重力の単位はピクセル/秒²、速度はピクセル/秒
        inline constexpr float RiseGravity = 1620.0f;
        inline constexpr float FallGravity = 2700.0f;
        inline constexpr float MaxFallSpeed = 720.0f;

        // ジャンプ開始時の上向き速度 単位はピクセル/秒
        // ジャンプ開始時の上向き速度(単位:ピクセル/秒)
        // 画面上方向はY座標が減るため負の値にする
        inline constexpr float JumpPower = -660.0f;

        // 上昇中にボタンを離したとき、上向き速度へ掛ける倍率
        inline constexpr float JumpCutMultiplier = 0.5f;

        // 足場を離れた後もジャンプを受け付けるフレーム数
        inline constexpr int CoyoteFrameLimit = 6;

        // 着地前に押したジャンプを記憶するフレーム数
        // 固定60Hzではどちらも6フレームで約0.1秒
        inline constexpr int JumpBufferFrameLimit = 6;
    }

    namespace stage
    {
        // タイル画像の元サイズとは独立した、ワールド上の1マスの大きさ
        inline constexpr int TileSize = 32;

        // マップ下端からこの距離を越えたら落下とみなす
        inline constexpr float FallResetMargin = 128.0f;
    }

    namespace camera
    {
        // 追従対象を画面内のどのX座標へ置くか
        inline constexpr float TargetScreenX =
            dxgame::config::ScreenWidth * 0.5f;
    }

    namespace animation
    {
        // Player.pngに横一列で並ぶフレームの総数
        inline constexpr int PlayerFrameCount = 8;

        // 待機と走行に使うフレーム数
        inline constexpr int IdleFrameCount = 2;
        inline constexpr int RunFrameCount = 4;

        // 各1枚を表示し続ける更新フレーム数
        inline constexpr int IdleFrameDuration = 20;
        inline constexpr int RunFrameDuration = 6;
    }

    namespace enemy
    {
        // 敵の中心座標を基準にした表示範囲と命中範囲
        inline constexpr Rect DrawRect = Rect::Centered(32.0f, 32.0f);
        inline constexpr Rect Hitbox = Rect::Centered(28.0f, 28.0f);

        // 巡回速度、重力、最大落下速度
        inline constexpr float MoveSpeed = 72.0f;
        inline constexpr float Gravity = 1800.0f;
        inline constexpr float MaxFallSpeed = 600.0f;
    }

    namespace goal
    {
        // ゴール画像の表示範囲と、クリア成立に使う範囲
        inline constexpr Rect DrawRect = Rect::Centered(32.0f, 32.0f);
        inline constexpr Rect Trigger = Rect::Centered(20.0f, 28.0f);
    }
}

ゲームプログラミング実践