Skip to content
第19回 地形との衝突解決

4 / 4 コリジョン抜けのメカニズムと安全な移動限界

このステップの目的

離散的な移動(ディスクリート判定)における「コリジョン抜け(トンネリング現象)」の発生条件を理解し、安全な設計範囲と高速移動時の対策を学びます。

コリジョン抜け(トンネリング現象)のメカニズム

本教材で採用している衝突判定は、**「1フレーム分の移動を行った後の最終位置で、重なっているタイルを調べる」**方式(離散的衝突判定:Discrete Collision Detection)です。

この方式は実装が簡潔で計算負荷が低い利点がありますが、移動後の矩形が壁を完全に越えるほど大きな移動では、途中の壁を検出できません。発生する移動量は壁の厚みだけでなく、プレイヤーの幅と開始位置にも依存します。

コリジョン抜け(トンネリング)が発生する例text
移動前の位置 [自機] ────(1フレームで80px移動)────> [自機] 移動後の位置
                    │ 壁 (厚さ32px) │

上の図のように、移動先の矩形が壁の反対側まで抜けると、移動前も移動後も「壁と重なっていない」状態となるため、壁をすり抜けてしまいます。これをコリジョン抜けまたはトンネリング現象と呼びます。

本プロジェクトにおける設計値の安全性

本ゲームの設計パラメータでは、通常のプレイにおいてコリジョン抜けが発生しない範囲に収めています。

  • タイルの厚み: 32ピクセル
  • プレイヤーの水平最高速度: 240ピクセル/秒
  • 60FPSでの1フレームあたりの最大横移動量: $240 \times \frac{1}{60} = \mathbf{4.0 \text{ ピクセル}}$
  • 最大落下速度(MaxFallSpeed): 720ピクセル/秒
  • 60FPSでの1フレームあたりの最大縦移動量: $720 \times \frac{1}{60} = \mathbf{12.0 \text{ ピクセル}}$

最大でも1フレームに4〜12ピクセルしか移動しないため、厚さ32ピクセルの壁や床を飛び越えることはありません。最大落下速度(MaxFallSpeed)を制限しているのは、操作感の調整に加えて、このすり抜け防止の目的もあります。

発展知識:高速オブジェクトへの対策

銃弾のような高速オブジェクトや可変フレームレート下での大きな移動量を扱う場合、以下の対策が検討されます。

  1. サブステップ分割移動:
    1フレームの移動量をタイル幅未満(例: 20ピクセル単位)に分割し、Move → Resolve を複数回繰り返す方法。
  2. 連続的衝突判定(Swept AABB):
    移動前後の軌跡と地形が交差する時刻($0.0 \le t \le 1.0$)を幾何学的に計算する手法。

通常のアクションゲームであれば、最高速度の上限を適切に設計することで、離散的な判定でも安定した挙動を維持できます。

動作確認の手順

プロジェクトをビルドして実行し、以下の項目を確認します。

  1. 左右の壁への衝突:
    • 左右の壁に向かって移動した際、壁の表面で停止し、通り抜けないこと
  2. 足場の乗り越えと着地:
    • ジャンプしてブロックの上に乗り、足場として着地できること
  3. 天井への頭打ち:
    • ブロックの下側からジャンプし、頭部が接触した瞬間に上昇が止まり落下へ転じること
  4. 角への接触挙動:
    • ブロックの角をかすめるように斜めにジャンプした際、上空へ不自然に押し出されず、安定して移動できること
チェックリスト
  • 移動後の矩形が壁を完全に飛び越えたときに起きる「コリジョン抜け」の理屈を説明できる
  • 最高速度の制限によって、すり抜けが防止されている理由を理解している
  • 左右の壁、床、天井のすべてにおいて、意図通りに衝突と押し戻しが機能することを確認できる
  • ブロックの角に接触した際にも、不自然な押し戻しが発生しないことを確認できる

次回への引き継ぎ

第19回では、軸分離アプローチを用いた地形衝突解決システムを実装しました。

次回(第20回)は、プレイヤーのキャラクターチップを用いたアニメーションと、効果音の再生処理を実装します。移動・ジャンプ・着地などの状態変化に合わせてスプライトを切り替え、音声を連携させます。

参考実装

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

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;

        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();
        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();
        player_.Update(input_, stage_, deltaTime);
        ResetAfterFall();
        camera_.Update(player_.GetPosition(), stage_.GetWorldWidth());
    }

    void Game::Draw() const
    {
        DrawBackground();
        stage_.Draw(camera_.GetX(), 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);
    }

    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);

        DrawBox(8, 76, 300, 178, 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"csv: %d",
            stage_.IsLoadedFromCsv() ? 1 : 0);
        DrawString(14, 164,
            L"cyan: draw  red/yellow: hit  blue: solid", 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());
        camera_.Update(player_.GetPosition(), stage_.GetWorldWidth());
    }

}
Source/Platformer/Player.cpp
Source/Platformer/Player.cppcpp
#include <algorithm>
#include <cmath>

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

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

    PlayerEvents Player::Update(const Input& input, const Stage& stage,
        float deltaTime)
    {
        PlayerEvents events;
        UpdateInput(input, deltaTime, events);
        UpdatePhysics(stage, deltaTime, events);
        return events;
    }

    void Player::Draw(const Camera& camera, const ImageManager& images) const
    {
        const dxgame::Rect screenRect = camera.WorldToScreen(GetDrawRect());
        images.Draw(ImageId::Player, screenRect);
    }

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

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

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

    const dxgame::Vector2& Player::GetVelocity() const
    {
        return velocity_;
    }

    bool Player::IsOnGround() const
    {
        return onGround_;
    }

    void Player::UpdateInput(const Input& input, float deltaTime,
        PlayerEvents& events)
    {
        const bool moveLeft = input.IsHeld(Key::Left);
        const bool moveRight = input.IsHeld(Key::Right);

        if (moveLeft && !moveRight)
        {
            velocity_.x -= tuning::player::Acceleration * deltaTime;
        }
        else if (moveRight && !moveLeft)
        {
            velocity_.x += tuning::player::Acceleration * deltaTime;
        }
        else if (velocity_.x > 0.0f)
        {
            velocity_.x = std::max(0.0f,
                velocity_.x - tuning::player::Friction * deltaTime);
        }
        else if (velocity_.x < 0.0f)
        {
            velocity_.x = std::min(0.0f,
                velocity_.x + tuning::player::Friction * deltaTime);
        }

        velocity_.x = std::clamp(velocity_.x,
            -tuning::player::MaxSpeed, tuning::player::MaxSpeed);

        if (onGround_ && input.IsPressed(Key::Jump))
        {
            velocity_.y = tuning::player::JumpPower;
            remainderY_ = 0.0f;
            onGround_ = false;
            events.jumped = true;
        }
    }

    void Player::UpdatePhysics(const Stage& stage, float deltaTime,
        PlayerEvents& events)
    {
        const bool wasOnGround = onGround_;
        const float gravity = velocity_.y < 0.0f
            ? tuning::player::RiseGravity
            : tuning::player::FallGravity;
        velocity_.y += gravity * deltaTime;
        velocity_.y = std::min(velocity_.y,
            tuning::player::MaxFallSpeed);

        MoveHorizontal(stage, deltaTime);
        MoveVertical(stage, deltaTime);

        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;
        }

        if (!wasOnGround && onGround_)
            events.landed = true;
    }

    void Player::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)
        {
            const int probeMove = velocity_.x > 0.0f ? 1
                : velocity_.x < 0.0f ? -1 : 0;
            if (probeMove != 0)
            {
                dxgame::Rect probe = GetHitbox();
                if (stage.ResolveHorizontal(probe, probeMove))
                {
                    velocity_.x = 0.0f;
                    remainderX_ = 0.0f;
                }
            }
            return;
        }

        dxgame::Rect movedRect = GetHitbox();
        if (stage.ResolveHorizontal(movedRect, pixelMove))
        {
            velocity_.x = 0.0f;
            remainderX_ = 0.0f;
        }

        position_.x = movedRect.GetCenter().x;
    }

    void Player::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)
            return;

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

        position_.y = movedRect.GetCenter().y;
    }
}
Source/Platformer/Player.h
Source/Platformer/Player.hcpp
#pragma once

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

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

    // Player内で成立した出来事を、音や演出へ接続するためにGameへ返す
    struct PlayerEvents
    {
        bool jumped = false;
        bool landed = false;
    };

    // プレイヤーの位置、速度、接地状態を管理する
    class Player
    {
    public:
        // プレイ開始時の状態へ戻す
        void Reset(const dxgame::Vector2& position);
        // 入力、重力、地形衝突、アニメーションを1フレーム更新する
        PlayerEvents Update(const Input& input, const Stage& stage,
            float deltaTime);
        // カメラを考慮してプレイヤーを描画する
        void Draw(const Camera& camera, const ImageManager& images) const;

        dxgame::Rect GetHitbox() const;
        dxgame::Rect GetDrawRect() const;
        const dxgame::Vector2& GetPosition() const;
        const dxgame::Vector2& GetVelocity() const;
        bool IsOnGround() const;

    private:
        void UpdateInput(const Input& input, float deltaTime,
            PlayerEvents& events);
        void UpdatePhysics(const Stage& stage, float deltaTime,
            PlayerEvents& events);
        void MoveHorizontal(const Stage& stage, float deltaTime);
        void MoveVertical(const Stage& stage, float deltaTime);

        dxgame::Vector2 position_ = {};
        dxgame::Vector2 velocity_ = {};
        float remainderX_ = 0.0f;
        float remainderY_ = 0.0f;
        bool onGround_ = false;
    };
}
Source/Platformer/Stage.cpp
Source/Platformer/Stage.cppcpp
#include <algorithm>
#include <cmath>
#include <fstream>
#include <sstream>
#include <stdexcept>
#include <string>
#include <utility>

#include "DxLib.h"

#include "Config/ApplicationConfig.h"
#include "Platformer/Stage.h"

namespace dxgame::platformer
{
    namespace
    {
        struct TileDefinition
        {
            StageCell cell;
            ImageId imageId;
            bool isSolid;
        };

        constexpr TileDefinition tileDefinitions[] =
        {
            { StageCell::Brick, ImageId::Tile, true },
            { StageCell::Moss, ImageId::TileMoss, true }
        };

        const TileDefinition* FindTileDefinition(StageCell cell)
        {
            for (const TileDefinition& definition : tileDefinitions)
            {
                if (definition.cell == cell)
                    return &definition;
            }
            return nullptr;
        }

        bool TryParseStageCell(int value, StageCell& cell)
        {
            cell = static_cast<StageCell>(value);
            if (cell == StageCell::Empty ||
                cell == StageCell::Goal ||
                cell == StageCell::PlayerSpawn)
            {
                return true;
            }

            return FindTileDefinition(cell) != nullptr;
        }
    }

    bool Stage::Initialize(const std::filesystem::path& csvPath)
    {
        loadedFromCsv_ = LoadFromCsv(csvPath);
        return loadedFromCsv_;
    }

    bool Stage::IsSolidAtRect(const dxgame::Rect& rect) const
    {
        if (rect.width <= 0.0f || rect.height <= 0.0f)
            return false;

        const int leftTile = PixelToTile(rect.x);
        const int rightTile = PixelToTile(rect.x + rect.width - 0.001f);
        const int topTile = PixelToTile(rect.y);
        const int bottomTile = PixelToTile(rect.y + rect.height - 0.001f);

        for (int y = topTile; y <= bottomTile; y++)
        {
            for (int x = leftTile; x <= rightTile; x++)
            {
                if (IsSolidTile(x, y))
                    return true;
            }
        }

        return false;
    }

    bool Stage::ResolveHorizontal(dxgame::Rect& rect, int moveX) const
    {
        if (moveX == 0)
            return false;

        // 移動後の矩形だけを調べるため、1フレームでタイルを越える
        // 極端な速度ではコリジョンを通り抜ける可能性がある。
        // 極端な速度ではコリジョンを通り抜ける可能性がある
        rect.x += static_cast<float>(moveX);
        const int leftTile = PixelToTile(rect.x);
        const int rightTile = PixelToTile(rect.x + rect.width - 0.001f);
        const int topTile = PixelToTile(rect.y);
        const int bottomTile = PixelToTile(rect.y + rect.height - 0.001f);
        bool hit = false;
        float resolvedX = rect.x;

        for (int y = topTile; y <= bottomTile; y++)
        {
            for (int x = leftTile; x <= rightTile; x++)
            {
                if (!IsSolidTile(x, y))
                    continue;

                const float candidate = (moveX > 0)
                    ? static_cast<float>(x * TileSize) - rect.width
                    : static_cast<float>((x + 1) * TileSize);

                if (!hit ||
                    (moveX > 0 && candidate < resolvedX) ||
                    (moveX < 0 && candidate > resolvedX))
                {
                    resolvedX = candidate;
                }
                hit = true;
            }
        }

        rect.x = resolvedX;
        return hit;
    }

    bool Stage::ResolveVertical(dxgame::Rect& rect, int moveY) const
    {
        if (moveY == 0)
            return false;

        // 移動後の矩形だけを調べるため、1フレームでタイルを越える
        // 極端な速度ではコリジョンを通り抜ける可能性がある。
        // 極端な速度ではコリジョンを通り抜ける可能性がある
        rect.y += static_cast<float>(moveY);
        const int leftTile = PixelToTile(rect.x);
        const int rightTile = PixelToTile(rect.x + rect.width - 0.001f);
        const int topTile = PixelToTile(rect.y);
        const int bottomTile = PixelToTile(rect.y + rect.height - 0.001f);
        bool hit = false;
        float resolvedY = rect.y;

        for (int y = topTile; y <= bottomTile; y++)
        {
            for (int x = leftTile; x <= rightTile; x++)
            {
                if (!IsSolidTile(x, y))
                    continue;

                const float candidate = (moveY > 0)
                    ? static_cast<float>(y * TileSize) - rect.height
                    : static_cast<float>((y + 1) * TileSize);

                if (!hit ||
                    (moveY > 0 && candidate < resolvedY) ||
                    (moveY < 0 && candidate > resolvedY))
                {
                    resolvedY = candidate;
                }
                hit = true;
            }
        }

        rect.y = resolvedY;
        return hit;
    }

    void Stage::Draw(int cameraX, const ImageManager& images) const
    {
        const int firstTileX = std::max(0, cameraX / TileSize);
        const int lastTileX = std::min(mapWidth_ - 1,
            (cameraX + dxgame::config::ScreenWidth) / TileSize + 1);

        for (int y = 0; y < mapHeight_; y++)
        {
            for (int x = firstTileX; x <= lastTileX; x++)
            {
                const StageCell cell = cells_[y][x];
                if (cell == StageCell::Empty)
                    continue;

                DrawTile(x * TileSize - cameraX, y * TileSize, cell, images);
            }
        }

        const dxgame::Rect goalDrawRect = GetGoalDrawRect();
        images.Draw(ImageId::Goal, {
            goalDrawRect.x - static_cast<float>(cameraX), goalDrawRect.y,
            goalDrawRect.width, goalDrawRect.height
        });
    }

    void Stage::DrawCollisionDebug(int cameraX) const
    {
        const int firstTileX = std::max(0, cameraX / TileSize);
        const int lastTileX = std::min(mapWidth_ - 1,
            (cameraX + dxgame::config::ScreenWidth) / TileSize + 1);
        const unsigned int solidColor = GetColor(70, 150, 255);

        for (int y = 0; y < mapHeight_; y++)
        {
            for (int x = firstTileX; x <= lastTileX; x++)
            {
                const TileDefinition* definition =
                    FindTileDefinition(cells_[y][x]);
                if (definition == nullptr || !definition->isSolid)
                    continue;

                const int left = x * TileSize - cameraX;
                const int top = y * TileSize;
                DrawBox(left, top, left + TileSize, top + TileSize,
                    solidColor, FALSE);
            }
        }
    }

    dxgame::Rect Stage::GetGoalDrawRect() const
    {
        return tuning::goal::DrawRect.MovedBy(goalPosition_);
    }

    dxgame::Rect Stage::GetGoalTrigger() const
    {
        return tuning::goal::Trigger.MovedBy(goalPosition_);
    }

    const dxgame::Vector2& Stage::GetPlayerSpawn() const
    {
        return playerSpawnPosition_;
    }

    int Stage::GetWorldWidth() const
    {
        return mapWidth_ * TileSize;
    }

    int Stage::GetWorldHeight() const
    {
        return mapHeight_ * TileSize;
    }

    int Stage::GetMapWidth() const
    {
        return mapWidth_;
    }

    int Stage::GetMapHeight() const
    {
        return mapHeight_;
    }

    bool Stage::IsLoadedFromCsv() const
    {
        return loadedFromCsv_;
    }

    const std::wstring& Stage::GetLastLoadError() const
    {
        return lastLoadError_;
    }

    bool Stage::LoadFromCsv(const std::filesystem::path& csvPath)
    {
        Clear();

        std::ifstream file(csvPath);
        if (!file.is_open())
            return FailLoad(L"ファイルを開けません。");

        bool foundGoal = false;
        bool foundPlayerSpawn = false;
        int expectedWidth = -1;
        std::string line;
        int y = 0;

        while (std::getline(file, line))
        {
            const int lineNumber = y + 1;
            if (line.empty())
                return FailLoad(L"空行は使用できません。", lineNumber, 1);
            if (line.back() == ',')
            {
                const int columnNumber =
                    static_cast<int>(std::count(
                        line.begin(), line.end(), ',')) + 1;
                return FailLoad(L"空のセルは使用できません。",
                    lineNumber, columnNumber);
            }

            std::stringstream lineStream(line);
            std::string cell;
            int x = 0;
            std::vector<StageCell> row;

            while (std::getline(lineStream, cell, ','))
            {
                const int columnNumber = x + 1;
                int value = 0;
                std::size_t parsedLength = 0;
                try
                {
                    value = std::stoi(cell, &parsedLength);
                }
                catch (const std::invalid_argument&)
                {
                    return FailLoad(L"整数を入力してください。",
                        lineNumber, columnNumber);
                }
                catch (const std::out_of_range&)
                {
                    return FailLoad(L"整数の範囲を超えています。",
                        lineNumber, columnNumber);
                }

                if (parsedLength != cell.size())
                {
                    return FailLoad(L"整数以外の文字が含まれています。",
                        lineNumber, columnNumber);
                }

                StageCell csvCell = StageCell::Empty;
                if (!TryParseStageCell(value, csvCell))
                {
                    return FailLoad(
                        L"未定義のStageCell値です: " +
                        std::to_wstring(value),
                        lineNumber, columnNumber);
                }

                StageCell mapCell = StageCell::Empty;
                if (csvCell == StageCell::Goal)
                {
                    if (foundGoal)
                    {
                        return FailLoad(L"Goalは1つだけ配置してください。",
                            lineNumber, columnNumber);
                    }

                    goalPosition_ = {
                        x * static_cast<float>(TileSize) + TileSize * 0.5f,
                        y * static_cast<float>(TileSize) + TileSize * 0.5f
                    };
                    foundGoal = true;
                }
                else if (csvCell == StageCell::PlayerSpawn)
                {
                    if (foundPlayerSpawn)
                    {
                        return FailLoad(
                            L"PlayerSpawnは1つだけ配置してください。",
                            lineNumber, columnNumber);
                    }

                    playerSpawnPosition_ = {
                        x * static_cast<float>(TileSize) + TileSize * 0.5f,
                        y * static_cast<float>(TileSize) + TileSize * 0.5f
                    };
                    foundPlayerSpawn = true;
                }
                else
                {
                    mapCell = csvCell;
                }

                row.push_back(mapCell);
                x++;
            }

            if (row.empty())
                return FailLoad(L"セルがありません。", lineNumber, 1);

            if (expectedWidth < 0)
            {
                expectedWidth = static_cast<int>(row.size());
            }
            else if (static_cast<int>(row.size()) != expectedWidth)
            {
                const int actualWidth = static_cast<int>(row.size());
                const int errorColumn = actualWidth < expectedWidth
                    ? actualWidth + 1
                    : expectedWidth + 1;
                return FailLoad(
                    L"列数が一致しません。必要: " +
                    std::to_wstring(expectedWidth) + L"、実際: " +
                    std::to_wstring(actualWidth),
                    lineNumber, errorColumn);
            }

            cells_.push_back(std::move(row));
            y++;
        }

        if (y == 0)
            return FailLoad(L"CSVに行がありません。");
        if (!foundGoal)
            return FailLoad(L"Goalがありません。");
        if (!foundPlayerSpawn)
            return FailLoad(L"PlayerSpawnがありません。");

        mapWidth_ = expectedWidth;
        mapHeight_ = y;
        return true;
    }

    bool Stage::FailLoad(const std::wstring& reason,
        int lineNumber, int columnNumber)
    {
        std::wstring message;
        if (lineNumber > 0)
        {
            message = L"行" + std::to_wstring(lineNumber);
            if (columnNumber > 0)
                message += L"、列" + std::to_wstring(columnNumber);
            message += L": ";
        }
        message += reason;

        Clear();
        lastLoadError_ = std::move(message);
        return false;
    }

    void Stage::Clear()
    {
        cells_.clear();
        goalPosition_ = {};
        playerSpawnPosition_ = {};
        mapWidth_ = 0;
        mapHeight_ = 0;
        loadedFromCsv_ = false;
        lastLoadError_.clear();
    }

    void Stage::DrawTile(int screenX, int screenY, StageCell cell,
        const ImageManager& images) const
    {
        const TileDefinition* definition = FindTileDefinition(cell);
        if (definition == nullptr)
            return;

        images.Draw(definition->imageId, {
            static_cast<float>(screenX), static_cast<float>(screenY),
            static_cast<float>(TileSize), static_cast<float>(TileSize)
        });
    }

    int Stage::PixelToTile(float pixel)
    {
        return static_cast<int>(std::floor(pixel / TileSize));
    }

    bool Stage::IsSolidTile(int tileX, int tileY) const
    {
        if (tileX < 0 || tileX >= mapWidth_ || tileY < 0)
            return true;
        if (tileY >= mapHeight_)
            return false;

        const TileDefinition* definition =
            FindTileDefinition(cells_[tileY][tileX]);
        return definition != nullptr && definition->isSolid;
    }
}
Source/Platformer/Stage.h
Source/Platformer/Stage.hcpp
#pragma once

#include <filesystem>
#include <string>
#include <vector>

#include "DxGame/Rect.h"
#include "DxGame/Vector2.h"
#include "Platformer/ImageManager.h"
#include "Platformer/Tuning/GameTuning.h"

namespace dxgame::platformer
{
    // ステージCSVの各整数値と意味を1か所で定義する
    // 数値は外部ファイル形式との約束なので、追加後も既存値を変更しない
    // 外部ファイル形式の定義値のため、追加後も既存値を変更しない
    enum class StageCell : int
    {
        Empty = 0,
        // 1~9: 進行・ギミック
        Goal = 1,
        PlayerSpawn = 2,
        // 10~19: 通常地形・障害物
        Brick = 10,
        Moss = 11,
        // 20~29: トラップ、90~99: 敵配置のために空けておく
    };

    // CSVマップ、地形判定、ゴールを管理する
    class Stage
    {
    public:
        static constexpr int TileSize = tuning::stage::TileSize;

        // 実行フォルダーを基準にCSVを読み込み、失敗理由を保持する
        // 実行フォルダを基準にCSVを読み込み、失敗理由を保持する
        bool Initialize(const std::filesystem::path& csvPath);

        // 矩形が地形と重なっているかを返す
        bool IsSolidAtRect(const dxgame::Rect& rect) const;
        // 横方向の移動後に地形境界へ位置を補正する
        bool ResolveHorizontal(dxgame::Rect& rect, int moveX) const;
        // 縦方向の移動後に地形境界へ位置を補正する
        bool ResolveVertical(dxgame::Rect& rect, int moveY) const;

        // カメラ内に見えるタイルとゴールを描画する
        void Draw(int cameraX, const ImageManager& images) const;
        // カメラ内の地形判定を枠で描画する
        void DrawCollisionDebug(int cameraX) const;

        dxgame::Rect GetGoalDrawRect() const;
        dxgame::Rect GetGoalTrigger() const;
        const dxgame::Vector2& GetPlayerSpawn() const;
        int GetWorldWidth() const;
        int GetWorldHeight() const;
        int GetMapWidth() const;
        int GetMapHeight() const;
        bool IsLoadedFromCsv() const;
        const std::wstring& GetLastLoadError() const;

    private:
        bool LoadFromCsv(const std::filesystem::path& csvPath);
        bool FailLoad(const std::wstring& reason,
            int lineNumber = 0, int columnNumber = 0);
        void Clear();
        void DrawTile(int screenX, int screenY, StageCell cell,
            const ImageManager& images) const;

        static int PixelToTile(float pixel);
        bool IsSolidTile(int tileX, int tileY) const;

        std::vector<std::vector<StageCell>> cells_;
        dxgame::Vector2 goalPosition_ = {};
        dxgame::Vector2 playerSpawnPosition_ = {};
        int mapWidth_ = 0;
        int mapHeight_ = 0;
        bool loadedFromCsv_ = false;
        std::wstring lastLoadError_;
    };
}

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