Skip to content
第23回 トラップとゲーム進行

4 / 4 終了条件とリトライを接続する

このステップの目的

トゲトラップ・敵接触・落下・ゴールの4つの終了条件を優先順位に従って判定し、ステージリセット機能と音響制御を連動させてリトライ処理を実装します。

プレイ中の終了条件判定フロー

毎フレームのゲーム進行処理(UpdatePlaying)では、プレイヤーの更新後に終了条件を順番に検査します。

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

    if (stage_.IsHazardAtRect(player_.GetHitbox()))
    {
        ChangeState(GameState::GameOver);
        return;
    }

    enemies_.Update(stage_, deltaTime);

    if (ResolvePlayerEnemyCollisions())
    {
        ChangeState(GameState::GameOver);
        return;
    }

    if (HasPlayerFallen())
    {
        ChangeState(GameState::GameOver);
        return;
    }

    if (player_.GetHitbox().Overlaps(stage_.GetGoalTrigger()))
    {
        ChangeState(GameState::GameClear);
        return;
    }

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

状態変更時の即時 return

いずれかの終了条件を満たして ChangeState() を呼び出した際は、そのフレームの更新処理を即座に return で終了させます。

もし処理を継続してしまうと、「トゲに触れてゲームオーバーになった同一フレームで、同時にゴール判定に触れてゲームクリアへ上書きされる」といった二重遷移の矛盾が生じます。また、ゲームオーバーになったキャラクターの移動に合わせてカメラが不必要に動くのを防ぐ効果もあります。

落下判定の実装

プレイヤーが足場を踏み外して画面外へ落下した判定は、ステージ全体の物理的な高さと余白(マージン)を基準にして判定します。

cpp
bool Game::HasPlayerFallen() const
{
    return player_.GetPosition().y >
        stage_.GetWorldHeight() + tuning::stage::FallResetMargin;
}

固定値(ウィンドウの高さなど)ではなく、CSVから求めたステージの総高さ(GetWorldHeight())にマージンを加算したラインを基準にします。これにより、縦方向に長いステージを作成した場合でも判定位置が自動的に追従し、プレイヤーが画面外へ完全に消えてからゲームオーバーになる自然な判定が成立します。

ResetStage() による初期状態の復元

リトライ時やゲーム開始時には、ResetStage() が各オブジェクトを初期状態へと復元します。

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

リセット時のカメラ即時更新

プレイヤーを初期座標に戻した直後に camera_.Update() を呼び出すことで、カメラの座標が前回のゲームオーバー地点に残ったまま描画が始まるのを防ぎます。リセット時にカメラ位置を同期させることで、リトライの瞬間に画面が大きく跳ぶ不自然な描画を防ぎます。

本章のまとめ

本章では、トゲトラップの危険判定、敵との接触判定(踏みつけ vs 被弾)、落下判定、ゴールの判定、そして GameStateChangeState() によるリトライ処理を構築しました。これにより、タイトルからゲーム本編、結果表示、リスタートまで一連のゲームサイクルが動作するようになりました。

次回(第24回)では、タイルセットの拡張を通した設計の検証、Debug/Releaseビルドの切り替え、配布用パッケージングの手順を学習します。

チェックリスト
  • 4つの終了条件を優先順位に従って判定し、遷移時に即時 return して二重遷移を防ぐことができる
  • ステージ全体の高さと余白マージンを用いて、縦スクロールに対応した自然な落下判定を実装できる
  • ResetStage() でプレイヤー、敵、カメラを確実に初期化し、カメラ位置を同期できる

参考実装

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

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

        ChangeState(GameState::Title);
        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();
        }
    }

    GameState Game::GetState() const
    {
        return state_;
    }

    std::size_t Game::GetActiveEnemyCount() const
    {
        return enemies_.GetActiveCount();
    }

    void Game::Update(float deltaTime)
    {
        UpdateDebugToggle();

        switch (state_)
        {
        case GameState::Title:
            if (input_.IsPressed(Key::Jump))
                ChangeState(GameState::Playing);
            break;
        case GameState::Playing:
            UpdatePlaying(deltaTime);
            break;
        case GameState::GameClear:
        case GameState::GameOver:
            if (input_.IsPressed(Key::Jump))
                ChangeState(GameState::Playing);
            else if (input_.IsPressed(Key::BackToTitle))
                ChangeState(GameState::Title);
            break;
        }
    }

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

        if (stage_.IsHazardAtRect(player_.GetHitbox()))
        {
            ChangeState(GameState::GameOver);
            return;
        }

        enemies_.Update(stage_, deltaTime);

        if (ResolvePlayerEnemyCollisions())
        {
            ChangeState(GameState::GameOver);
            return;
        }

        if (HasPlayerFallen())
        {
            ChangeState(GameState::GameOver);
            return;
        }

        if (player_.GetHitbox().Overlaps(stage_.GetGoalTrigger()))
        {
            ChangeState(GameState::GameClear);
            return;
        }

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

    void Game::Draw() const
    {
        DrawBackground();

        if (state_ == GameState::Title)
        {
            DrawTitle();
            return;
        }

        DrawWorld();
        DrawHud();
        DrawDebug();

        if (state_ == GameState::GameClear ||
            state_ == GameState::GameOver)
        {
            DrawResult();
        }
    }

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

    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::DrawTitle() const
    {
        const int textColor = GetColor(255, 255, 255);
        DrawString(190, 170, L"DX LIBRARY 2D PLATFORMER", textColor);
        DrawString(252, 230, L"Z: START", textColor);
        DrawString(244, 270, L"ESC: QUIT", textColor);
    }

    void Game::DrawResult() const
    {
        DrawBox(140, 150, 500, 330, GetColor(20, 30, 45), TRUE);
        const int textColor = GetColor(255, 255, 255);

        if (state_ == GameState::GameClear)
            DrawString(260, 185, L"GAME CLEAR", textColor);
        else
            DrawString(255, 185, L"GAME OVER", textColor);

        DrawString(215, 240, L"Z: RETRY", textColor);
        DrawString(215, 275, L"T: TITLE", 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);

        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  magenta: trap", textColor);
    }

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

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

    void Game::ChangeState(GameState nextState)
    {
        state_ = nextState;

        switch (state_)
        {
        case GameState::Title:
            sounds_.PlayBgm(SoundId::TitleBgm);
            break;
        case GameState::Playing:
            ResetStage();
            sounds_.PlayBgm(SoundId::PlayingBgm);
            break;
        case GameState::GameClear:
            sounds_.StopBgm();
            sounds_.PlaySe(SoundId::Goal);
            break;
        case GameState::GameOver:
            sounds_.StopBgm();
            sounds_.PlaySe(SoundId::GameOver);
            break;
        }
    }

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

    bool Game::ResolvePlayerEnemyCollisions()
    {
        const dxgame::Rect playerHitbox = player_.GetHitbox();

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

            const dxgame::Rect enemyHitbox = enemy.GetHitbox();
            if (!playerHitbox.Overlaps(enemyHitbox))
                continue;

            const float playerBottom =
                playerHitbox.y + playerHitbox.height;
            const float enemyMiddle =
                enemyHitbox.y + enemyHitbox.height * 0.5f;
            const bool stomped = player_.GetVelocity().y > 0.0f &&
                playerBottom <= enemyMiddle;

            if (stomped)
            {
                enemy.Defeat();
                player_.BounceFromEnemy();
                return false;
            }

            return true;
        }

        return false;
    }

    bool Game::HasPlayerFallen() const
    {
        return player_.GetPosition().y >
            stage_.GetWorldHeight() + tuning::stage::FallResetMargin;
    }
}
Source/Platformer/Game.h
Source/Platformer/Game.hcpp
#pragma once

#include <cstddef>

#include "Platformer/Camera.h"
#include "Platformer/EnemyManager.h"
#include "Platformer/ImageManager.h"
#include "Platformer/Input.h"
#include "Platformer/Player.h"
#include "Platformer/SoundManager.h"
#include "Platformer/Stage.h"

namespace dxgame::platformer
{
    enum class GameState
    {
        Title,
        Playing,
        GameClear,
        GameOver
    };

    // 入力、プレイヤー、ステージ、敵配置、描画順を調整する
    class Game
    {
    public:
        // 必須画像、音声、CSVを読み込む
        bool Initialize();
        // ウィンドウ終了まで更新と描画を繰り返す
        void MainLoop();
        GameState GetState() const;
        std::size_t GetActiveEnemyCount() const;

    private:
        void Update(float deltaTime);
        void UpdatePlaying(float deltaTime);
        void Draw() const;
        void DrawBackground() const;
        void DrawWorld() const;
        void DrawHud() const;
        void DrawTitle() const;
        void DrawResult() const;
        void DrawDebug() const;
        void UpdateDebugToggle();
        void ResetStage();
        void ChangeState(GameState nextState);
        void HandlePlayerEvents(const PlayerEvents& events);
        bool ResolvePlayerEnemyCollisions();
        bool HasPlayerFallen() const;

        Input input_;
        ImageManager images_;
        SoundManager sounds_;
        Stage stage_;
        Camera camera_;
        Player player_;
        EnemyManager enemies_;
        GameState state_ = GameState::Title;
        bool debugVisible_ = false;
    };
}
Source/Platformer/ImageManager.cpp
Source/Platformer/ImageManager.cppcpp
#include <Windows.h>
#include <array>
#include <string>

#include "DxLib.h"

#include "Platformer/ImageManager.h"

namespace dxgame::platformer
{
    namespace
    {
        struct ImageDefinition
        {
            ImageId id;
            const wchar_t* path;
        };

        constexpr ImageDefinition imageDefinitions[] =
        {
            { ImageId::Background, L"Assets/Images/Background.png" },
            { ImageId::Player, L"Assets/Images/Player.png" },
            { ImageId::Enemy, L"Assets/Images/Enemy.png" },
            { ImageId::Tile, L"Assets/Images/Tile.png" },
            { ImageId::TileMoss, L"Assets/Images/TileMoss.png" },
            { ImageId::Spike, L"Assets/Images/Spike.png" },
            { ImageId::Goal, L"Assets/Images/Goal.png" }
        };

        constexpr std::size_t ImageIdCount =
            static_cast<std::size_t>(ImageId::Count);
        constexpr std::size_t ImageDefinitionCount =
            sizeof(imageDefinitions) / sizeof(imageDefinitions[0]);

        static_assert(ImageDefinitionCount == ImageIdCount,
            "Register one imageDefinitions entry for every ImageId.");

        bool HasCompleteImageDefinitions()
        {
            std::array<bool, ImageIdCount> registered = {};

            for (const ImageDefinition& definition : imageDefinitions)
            {
                const std::size_t index =
                    static_cast<std::size_t>(definition.id);
                if (index >= ImageIdCount || registered[index] ||
                    definition.path == nullptr ||
                    definition.path[0] == L'\0')
                {
                    return false;
                }
                registered[index] = true;
            }

            for (bool isRegistered : registered)
            {
                if (!isRegistered)
                    return false;
            }
            return true;
        }
    }

    ImageManager::~ImageManager()
    {
        Release();
    }

    bool ImageManager::LoadAll()
    {
        Release();

        if (!HasCompleteImageDefinitions())
        {
            MessageBoxW(nullptr,
                L"ImageIdと画像定義の登録に重複または不足があります。",
                L"Image Definition Error", MB_OK);
            return false;
        }

        for (const ImageDefinition& definition : imageDefinitions)
        {
            const std::size_t index = static_cast<std::size_t>(definition.id);
            ImageResource& resource = resources_[index];
            resource.handle = LoadGraph(definition.path);

            if (resource.handle == -1 ||
                GetGraphSize(resource.handle, &resource.width,
                    &resource.height) == -1)
            {
                std::wstring message = definition.path;
                message += L" の読み込みまたはサイズ取得に失敗しました。";
                MessageBoxW(nullptr, message.c_str(), L"Asset Load Error", MB_OK);
                Release();
                return false;
            }
        }

        return true;
    }

    const ImageResource& ImageManager::Get(ImageId id) const
    {
        return resources_[static_cast<std::size_t>(id)];
    }

    void ImageManager::Draw(ImageId id, const dxgame::Rect& rect) const
    {
        const ImageResource& image = Get(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),
            image.handle, TRUE);
    }

    void ImageManager::DrawFrame(ImageId id, int frameIndex, int frameCount,
        const dxgame::Rect& rect, bool flipX) const
    {
        const ImageResource& image = Get(id);
        const int sourceWidth = image.width / frameCount;
        const int sourceX = sourceWidth * frameIndex;
        const double scaleX = static_cast<double>(rect.width) / sourceWidth;
        const double scaleY = static_cast<double>(rect.height) / image.height;

        DrawRectRotaGraph3(
            static_cast<int>(rect.x + rect.width * 0.5f),
            static_cast<int>(rect.y + rect.height * 0.5f),
            sourceX, 0, sourceWidth, image.height,
            sourceWidth / 2, image.height / 2,
            scaleX, scaleY, 0.0,
            image.handle, TRUE, flipX ? TRUE : FALSE);
    }

    void ImageManager::Release()
    {
        for (ImageResource& resource : resources_)
        {
            if (resource.handle != -1)
                DeleteGraph(resource.handle);

            resource = {};
        }
    }
}
Source/Platformer/ImageManager.h
Source/Platformer/ImageManager.hcpp
#pragma once

#include <array>
#include <cstddef>

#include "DxGame/Rect.h"

namespace dxgame::platformer
{
    enum class ImageId
    {
        Background,
        Player,
        Enemy,
        Tile,
        TileMoss,
        Spike,
        Goal,
        Count
    };

    // DXライブラリの画像ハンドルと元画像の大きさを所有する
    struct ImageResource
    {
        int handle = -1;
        int width = 0;
        int height = 0;
    };

    // 画像の読み込み、解放、矩形描画をまとめて管理する
    class ImageManager
    {
    public:
        ImageManager() = default;
        ~ImageManager();

        ImageManager(const ImageManager&) = delete;
        ImageManager& operator=(const ImageManager&) = delete;

        // 必須画像をすべて読み込む
        bool LoadAll();
        // 識別子から画像情報を取得する
        const ImageResource& Get(ImageId id) const;
        // 画像を指定した矩形へ拡大縮小して描画する
        void Draw(ImageId id, const dxgame::Rect& rect) const;
        // スプライトシートの1フレームを指定した矩形へ描画する
        void DrawFrame(ImageId id, int frameIndex, int frameCount,
            const dxgame::Rect& rect, bool flipX) const;

    private:
        void Release();

        static constexpr std::size_t imageCount =
            static_cast<std::size_t>(ImageId::Count);

        std::array<ImageResource, imageCount> resources_ = {};
    };
}
Source/Platformer/Input.cpp
Source/Platformer/Input.cppcpp
#include "DxLib.h"

#include "Platformer/Input.h"

namespace dxgame::platformer
{
    void Input::Update()
    {
        previous_ = current_;

        for (std::size_t index = 0; index < keyCount; index++)
        {
            const Key key = static_cast<Key>(index);
            current_[index] = CheckHitKey(ToDxKey(key)) != 0;
        }
    }

    bool Input::IsHeld(Key key) const
    {
        return current_[static_cast<std::size_t>(key)];
    }

    bool Input::IsPressed(Key key) const
    {
        const std::size_t index = static_cast<std::size_t>(key);
        return current_[index] && !previous_[index];
    }

    bool Input::IsReleased(Key key) const
    {
        const std::size_t index = static_cast<std::size_t>(key);
        return !current_[index] && previous_[index];
    }

    int Input::ToDxKey(Key key)
    {
        switch (key)
        {
        case Key::Left:
            return KEY_INPUT_LEFT;
        case Key::Right:
            return KEY_INPUT_RIGHT;
        case Key::Jump:
            return KEY_INPUT_Z;
        case Key::BackToTitle:
            return KEY_INPUT_T;
        case Key::Debug:
            return KEY_INPUT_F1;
        case Key::Escape:
            return KEY_INPUT_ESCAPE;
        case Key::Count:
            break;
        }

        return 0;
    }
}
Source/Platformer/Input.h
Source/Platformer/Input.hcpp
#pragma once

#include <array>
#include <cstddef>

namespace dxgame::platformer
{
    enum class Key
    {
        Left,
        Right,
        Jump,
        BackToTitle,
        Debug,
        Escape,
        Count
    };

    // キーの現在状態と前フレーム状態を管理する
    class Input
    {
    public:
        // DXライブラリからキー状態を読み取り、前フレームとの差を更新する
        void Update();

        // キーが押され続けているかを返す
        bool IsHeld(Key key) const;
        // 今フレームに押されたかを返す
        bool IsPressed(Key key) const;
        // 今フレームに離されたかを返す
        bool IsReleased(Key key) const;

    private:
        static constexpr std::size_t keyCount =
            static_cast<std::size_t>(Key::Count);

        static int ToDxKey(Key key);

        std::array<bool, keyCount> current_ = {};
        std::array<bool, keyCount> previous_ = {};
    };
}
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;
        animation_ = PlayerAnimation::Idle;
        animationFrame_ = 0;
        animationTimer_ = 0;
        facingRight_ = true;
        coyoteFrames_ = 0;
        jumpBufferFrames_ = 0;
    }

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

    void Player::Draw(const Camera& camera, const ImageManager& images) const
    {
        const dxgame::Rect screenRect = camera.WorldToScreen(GetDrawRect());
        images.DrawFrame(ImageId::Player, GetAnimationFrame(),
            tuning::animation::PlayerFrameCount, screenRect, !facingRight_);
    }

    void Player::BounceFromEnemy()
    {
        velocity_.y = tuning::player::StompBounceSpeed;
        remainderY_ = 0.0f;
        onGround_ = false;
        coyoteFrames_ = 0;
    }

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

    PlayerAnimation Player::GetAnimation() const
    {
        return animation_;
    }

    int Player::GetAnimationFrame() const
    {
        switch (animation_)
        {
        case PlayerAnimation::Idle:
            return animationFrame_;
        case PlayerAnimation::Run:
            return 2 + animationFrame_;
        case PlayerAnimation::Jump:
            return 6;
        case PlayerAnimation::Fall:
            return 7;
        }

        return 0;
    }

    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;
            facingRight_ = false;
        }
        else if (moveRight && !moveLeft)
        {
            velocity_.x += tuning::player::Acceleration * deltaTime;
            facingRight_ = true;
        }
        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_)
            coyoteFrames_ = tuning::player::CoyoteFrameLimit;
        else if (coyoteFrames_ > 0)
            coyoteFrames_--;

        // 着地直前の入力を捨てず、数フレームだけ予約しておく。
        // 着地直前の入力を捨てず、数フレームだけ予約しておく
        if (input.IsPressed(Key::Jump))
            jumpBufferFrames_ = tuning::player::JumpBufferFrameLimit;
        else if (jumpBufferFrames_ > 0)
            jumpBufferFrames_--;

        // 接地猶予と入力予約が同時に残っているときだけジャンプする。
        // 接地猶予と入力予約が同時に残っているときだけジャンプする
        if (coyoteFrames_ > 0 && jumpBufferFrames_ > 0)
        {
            velocity_.y = tuning::player::JumpPower;
            onGround_ = false;
            coyoteFrames_ = 0;
            jumpBufferFrames_ = 0;
            events.jumped = true;
        }

        // 上昇中にボタンを離したときだけ上向き速度を弱める。
        // 上昇中にボタンを離したときだけ上向き速度を弱める
        if (input.IsReleased(Key::Jump) && velocity_.y < 0.0f)
            velocity_.y *= tuning::player::JumpCutMultiplier;
    }

    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::UpdateAnimation()
    {
        PlayerAnimation nextAnimation = PlayerAnimation::Idle;
        if (!onGround_)
        {
            nextAnimation = velocity_.y < 0.0f
                ? PlayerAnimation::Jump
                : PlayerAnimation::Fall;
        }
        else if (std::abs(velocity_.x) > 0.1f)
        {
            nextAnimation = PlayerAnimation::Run;
        }

        if (nextAnimation != animation_)
        {
            animation_ = nextAnimation;
            animationFrame_ = 0;
            animationTimer_ = 0;
        }

        const int frameCount = animation_ == PlayerAnimation::Idle
            ? tuning::animation::IdleFrameCount
            : animation_ == PlayerAnimation::Run
            ? tuning::animation::RunFrameCount
            : 1;
        const int frameDuration = animation_ == PlayerAnimation::Idle
            ? tuning::animation::IdleFrameDuration
            : tuning::animation::RunFrameDuration;

        animationTimer_++;
        if (animationTimer_ >= frameDuration)
        {
            animationTimer_ = 0;
            animationFrame_ = (animationFrame_ + 1) % frameCount;
        }
    }

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

    enum class PlayerAnimation
    {
        Idle,
        Run,
        Jump,
        Fall
    };

    // プレイヤーの位置、速度、接地状態、表示アニメーションを管理する
    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;

        // 敵を踏んだときに上向き速度を与える
        void BounceFromEnemy();

        dxgame::Rect GetHitbox() const;
        dxgame::Rect GetDrawRect() const;
        const dxgame::Vector2& GetPosition() const;
        const dxgame::Vector2& GetVelocity() const;
        bool IsOnGround() const;
        PlayerAnimation GetAnimation() const;
        int GetAnimationFrame() const;

    private:
        void UpdateInput(const Input& input, float deltaTime,
            PlayerEvents& events);
        void UpdatePhysics(const Stage& stage, float deltaTime,
            PlayerEvents& events);
        void UpdateAnimation();
        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;
        PlayerAnimation animation_ = PlayerAnimation::Idle;
        int animationFrame_ = 0;
        int animationTimer_ = 0;
        bool facingRight_ = true;
        int coyoteFrames_ = 0;
        int jumpBufferFrames_ = 0;
    };
}
Source/Platformer/SoundManager.cpp
Source/Platformer/SoundManager.cppcpp
#include <Windows.h>
#include <array>
#include <string>

#include "DxLib.h"

#include "Platformer/SoundManager.h"

namespace dxgame::platformer
{
    namespace
    {
        struct SoundDefinition
        {
            SoundId id;
            const wchar_t* path;
            int volume;
        };

        constexpr SoundDefinition soundDefinitions[] =
        {
            { SoundId::TitleBgm, L"Assets/Sounds/TitleBgm.wav", 140 },
            { SoundId::PlayingBgm, L"Assets/Sounds/PlayingBgm.wav", 140 },
            { SoundId::Jump, L"Assets/Sounds/Jump.wav", 200 },
            { SoundId::Land, L"Assets/Sounds/Land.wav", 180 },
            { SoundId::Goal, L"Assets/Sounds/Goal.wav", 200 },
            { SoundId::GameOver, L"Assets/Sounds/GameOver.wav", 200 }
        };

        constexpr std::size_t SoundIdCount =
            static_cast<std::size_t>(SoundId::Count);
        constexpr std::size_t SoundDefinitionCount =
            sizeof(soundDefinitions) / sizeof(soundDefinitions[0]);

        static_assert(SoundDefinitionCount == SoundIdCount,
            "Register one soundDefinitions entry for every SoundId.");

        bool HasCompleteSoundDefinitions()
        {
            std::array<bool, SoundIdCount> registered = {};

            for (const SoundDefinition& definition : soundDefinitions)
            {
                const std::size_t index =
                    static_cast<std::size_t>(definition.id);
                if (index >= SoundIdCount || registered[index] ||
                    definition.path == nullptr ||
                    definition.path[0] == L'\0' ||
                    definition.volume < 0 || definition.volume > 255)
                {
                    return false;
                }
                registered[index] = true;
            }

            for (bool isRegistered : registered)
            {
                if (!isRegistered)
                    return false;
            }
            return true;
        }
    }

    SoundManager::SoundManager()
    {
        handles_.fill(-1);
    }

    SoundManager::~SoundManager()
    {
        Release();
    }

    bool SoundManager::LoadAll()
    {
        Release();

        if (!HasCompleteSoundDefinitions())
        {
            MessageBoxW(nullptr,
                L"SoundIdと音声定義の登録に重複、不足、または不正な音量があります。",
                L"Sound Definition Error", MB_OK);
            return false;
        }

        for (const SoundDefinition& definition : soundDefinitions)
        {
            const std::size_t index = static_cast<std::size_t>(definition.id);
            handles_[index] = LoadSoundMem(definition.path);
            if (handles_[index] == -1)
            {
                std::wstring message = definition.path;
                message += L" の読み込みに失敗しました。";
                MessageBoxW(nullptr, message.c_str(), L"Sound Load Error", MB_OK);
                Release();
                return false;
            }

            ChangeVolumeSoundMem(definition.volume, handles_[index]);
        }

        return true;
    }

    void SoundManager::PlayBgm(SoundId id)
    {
        if (currentBgm_ == id)
            return;

        StopBgm();
        PlaySoundMem(handles_[static_cast<std::size_t>(id)], DX_PLAYTYPE_LOOP);
        currentBgm_ = id;
    }

    void SoundManager::PlaySe(SoundId id) const
    {
        PlaySoundMem(handles_[static_cast<std::size_t>(id)], DX_PLAYTYPE_BACK);
    }

    void SoundManager::StopBgm()
    {
        if (currentBgm_ == SoundId::Count)
            return;

        StopSoundMem(handles_[static_cast<std::size_t>(currentBgm_)]);
        currentBgm_ = SoundId::Count;
    }

    void SoundManager::Release()
    {
        StopBgm();

        for (int& handle : handles_)
        {
            if (handle != -1)
                DeleteSoundMem(handle);

            handle = -1;
        }
    }
}
Source/Platformer/SoundManager.h
Source/Platformer/SoundManager.hcpp
#pragma once

#include <array>
#include <cstddef>

namespace dxgame::platformer
{
    enum class SoundId
    {
        TitleBgm,
        PlayingBgm,
        Jump,
        Land,
        Goal,
        GameOver,
        Count
    };

    // DXライブラリの音声ハンドルを所有し、BGMとSEを管理する
    class SoundManager
    {
    public:
        SoundManager();
        ~SoundManager();

        SoundManager(const SoundManager&) = delete;
        SoundManager& operator=(const SoundManager&) = delete;

        // 必須音声をすべて読み込む
        bool LoadAll();
        // BGMをループ再生する。同じBGMは再起動しない
        // BGMをループ再生する(同一BGMの多重再生を防止)
        void PlayBgm(SoundId id);
        // SEを一度再生する
        void PlaySe(SoundId id) const;
        // 再生中のBGMを停止する
        void StopBgm();

    private:
        void Release();

        static constexpr std::size_t soundCount =
            static_cast<std::size_t>(SoundId::Count);

        std::array<int, soundCount> handles_ = {};
        SoundId currentBgm_ = SoundId::Count;
    };
}
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
    {
        constexpr float TileWorldSize =
            static_cast<float>(tuning::stage::TileSize);

        struct TileDefinition
        {
            StageCell cell;
            ImageId imageId;
            bool isSolid;
            bool isHazard;
            dxgame::Rect drawRect;
            dxgame::Rect hazardRect;
        };

        constexpr TileDefinition tileDefinitions[] =
        {
            { StageCell::Brick, ImageId::Tile, true, false,
                { 0.0f, 0.0f, TileWorldSize, TileWorldSize }, {} },
            { StageCell::Moss, ImageId::TileMoss, true, false,
                { 0.0f, 0.0f, TileWorldSize, TileWorldSize }, {} },
            { StageCell::Spike, ImageId::Spike, false, true,
                { 0.0f, 0.0f, TileWorldSize, TileWorldSize * 1.25f },
                { TileWorldSize * 0.125f, TileWorldSize * 0.25f,
                    TileWorldSize * 0.75f, TileWorldSize * 0.75f } }
        };

        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 ||
                cell == StageCell::EnemySpawn)
            {
                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::IsHazardAtRect(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 (x < 0 || x >= mapWidth_ || y < 0 || y >= mapHeight_)
                    continue;

                const TileDefinition* definition =
                    FindTileDefinition(cells_[y][x]);
                if (definition == nullptr || !definition->isHazard)
                    continue;

                const dxgame::Rect hazardRect =
                    definition->hazardRect.MovedBy({
                        x * static_cast<float>(TileSize),
                        y * static_cast<float>(TileSize)
                    });
                if (rect.Overlaps(hazardRect))
                    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);
        const unsigned int hazardColor = GetColor(255, 80, 220);

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

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

                if (definition->isHazard)
                {
                    const dxgame::Rect hazardRect =
                        definition->hazardRect.MovedBy({
                            static_cast<float>(left),
                            static_cast<float>(top)
                        });
                    DrawBox(static_cast<int>(hazardRect.x),
                        static_cast<int>(hazardRect.y),
                        static_cast<int>(hazardRect.x + hazardRect.width),
                        static_cast<int>(hazardRect.y + hazardRect.height),
                        hazardColor, 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_;
    }

    const std::vector<SpawnPoint>& Stage::GetEnemySpawns() const
    {
        return enemySpawns_;
    }

    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 if (csvCell == StageCell::EnemySpawn)
                {
                    AddEnemySpawn(x, y);
                }
                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_ = {};
        enemySpawns_.clear();
        mapWidth_ = 0;
        mapHeight_ = 0;
        loadedFromCsv_ = false;
        lastLoadError_.clear();
    }

    void Stage::AddEnemySpawn(int tileX, int tileY)
    {
        enemySpawns_.push_back({
            {
                tileX * static_cast<float>(TileSize) + TileSize * 0.5f,
                tileY * static_cast<float>(TileSize) + TileSize * 0.5f
            }
        });
    }

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

        const dxgame::Rect drawRect = definition->drawRect.MovedBy({
            static_cast<float>(screenX), static_cast<float>(screenY)
        });
        images.Draw(definition->imageId, drawRect);
    }

    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: トラップ
        Spike = 20,
        // 90~99: 敵の配置
        EnemySpawn = 90
    };

    struct SpawnPoint
    {
        dxgame::Vector2 position;
    };

    // 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 IsHazardAtRect(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;
        const std::vector<SpawnPoint>& GetEnemySpawns() 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 AddEnemySpawn(int tileX, int tileY);
        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_ = {};
        std::vector<SpawnPoint> enemySpawns_;
        int mapWidth_ = 0;
        int mapHeight_ = 0;
        bool loadedFromCsv_ = false;
        std::wstring lastLoadError_;
    };
}
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 float StompBounceSpeed = -420.0f;

        // 足場を離れた後もジャンプを受け付けるフレーム数
        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);
    }
}

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