Skip to content
第10回 エフェクトと演出

4 / 4 背景スクロールの実装と演出の動作確認 ​

このステップの目的

2枚の背景画像を連結して切れ目なく下方向へスクロールさせ、エフェクトとサウンドを含めた全体演出を確認します。

2枚連結による無限スクロールのアルゴリズム ​

画面サイズの背景を1枚だけ下へ動かすと、画像の上に黒い隙間ができます。

隙間を防ぐため、同じ背景を上下に2枚並べます。1画面分動いたら位置を元に戻す方法で、途切れずにスクロールさせます。

Game.cppcpp
void Game::DrawBackground() const
{
    // 背景を上下に2枚並べて切れ目なく繰り返す
    int scroll = static_cast<int>(playTime * tuning::background::ScrollSpeed);
    int scrollOffset = scroll % tuning::ScreenHeight;

    Rect first =
    {
        0.0f,
        static_cast<float>(scrollOffset - tuning::ScreenHeight),
        static_cast<float>(tuning::ScreenWidth),
        static_cast<float>(tuning::ScreenHeight)
    };

    Rect second =
    {
        0.0f,
        static_cast<float>(scrollOffset),
        static_cast<float>(tuning::ScreenWidth),
        static_cast<float>(tuning::ScreenHeight)
    };

    images.Draw(ImageId::Background, first);
    images.Draw(ImageId::Background, second);
}

スクロール計算の仕組み ​

  1. playTime * ScrollSpeedによって、経過時間に応じてスクロール量が増加します。
  2. % tuning::ScreenHeight(画面の高さ480で割った余り)を取ることで、offsetは常に0 〜 479の範囲を滑らかにループします。
  3. secondはY = offsetの位置(画面中央〜下部)に描画され、firstはその真上であるY = offset - 480(画面上部の隙間を埋める位置)に描画されます。

offsetが480に達すると0へ戻ります。2枚の位置関係が連続しているため、継ぎ目が分からず、自然に背景が繰り返されます。

動作確認の手順 ​

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

  1. 背景の無限スクロール
    ゲーム開始直後から、奥の宇宙背景が滑らかに下方へ流れ、画面端で途切れたり黒い帯が出たりしないことを確認します。
  2. 命中エフェクトと効果音の同期
    敵に弾を当てた際、当たった位置に黄色の光の輪がふわっと広がって消え、同時に「ピシッ」と命中SEが鳴ることを確認します。
  3. 被弾エフェクトの確認
    敵弾に接触した際、接触位置にエフェクトが発生することを確認します。
  4. 描画レイヤーの重なり
    背景の上にキャラクターが描かれ、キャラクターの上にエフェクトが描かれ、さらにその手前にUIテキストが重なっていることを確認します。
チェックリスト
  • 宇宙背景が途切れることなく下方へ無限ループスクロールすることを確認できる
  • 敵への命中時に、敵の中心座標から円形エフェクトが広がってフェードアウトすることを確認できる
  • プレイヤーの被弾時に、被弾位置へエフェクトが表示されることを確認できる
  • エフェクト、効果音、スコア加算が完全に同期して動作していることを説明できる

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

第10回では、プールで管理するエフェクトと、背景2枚による無限スクロールを実装しました。これで、見た目と手応えに関する基本実装がすべて完了しました。

第11回からは、成果物の作成と検証を行います。Releaseビルドで作ったEXEと素材を配布フォルダへまとめ、Visual Studioを使わずに起動します。他の人のPCでも動く成果物にする工程を学びます。

参考実装 ​

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

Source/Shooting/EffectManager.cpp
Source/Shooting/EffectManager.cppcpp
#include <DxLib.h>

#include "Shooting/Tuning/GameTuning.h"

#include "Shooting/EffectManager.h"

namespace dxgame::shooting
{
    void EffectManager::Spawn(const Vector2& center)
    {
        // 先頭から停止中のエフェクトを探して再利用する
        for (Effect& effect : effects)
        {
            if (effect.isActive)
                continue;

            effect.center = center;
            effect.age = 0.0f;
            effect.isActive = true;
            return;
        }
    }

    void EffectManager::Update(float deltaTime)
    {
        for (Effect& effect : effects)
        {
            if (!effect.isActive)
                continue;

            effect.age += deltaTime;
            if (effect.age >= tuning::effect::Duration)
                effect.isActive = false;
        }
    }

    void EffectManager::Draw() const
    {
        for (const Effect& effect : effects)
        {
            if (!effect.isActive)
                continue;

            // progressは開始時の0から終了時の1まで変化する
            float progress = effect.age / tuning::effect::Duration;
            float radiusRange = tuning::effect::EndRadius - tuning::effect::StartRadius;
            float radius = tuning::effect::StartRadius + radiusRange * progress;

            // 円を広げながら透明にして短い爆発を表現する
            int alpha = static_cast<int>(255.0f * (1.0f - progress));

            SetDrawBlendMode(DX_BLENDMODE_ALPHA, alpha);

            DrawCircle(
                static_cast<int>(effect.center.x),
                static_cast<int>(effect.center.y),
                static_cast<int>(radius),
                GetColor(255, 220, 80),
                FALSE);

        }

        SetDrawBlendMode(DX_BLENDMODE_NOBLEND, 0);
    }

    void EffectManager::Reset()
    {
        for (Effect& effect : effects)
            effect.isActive = false;
    }
}
Source/Shooting/EffectManager.h
Source/Shooting/EffectManager.hcpp
#pragma once

#include <array>
#include <cstddef>

#include "DxGame/Vector2.h"

namespace dxgame::shooting
{
    // 命中時に広がって消える円形エフェクトを固定数管理する
    class EffectManager
    {
    public:
        // 停止中のエフェクトをcenterで開始する
        // 空きがない場合は何もしない
        void Spawn(const Vector2& center);

        // すべてのエフェクトの経過時間を進め表示時間を終えたものを停止する
        // deltaTimeは1フレームの経過秒数
        void Update(float deltaTime);

        // 実行中のエフェクトを経過時間に応じた半径と透明度で描く
        void Draw() const;

        // すべてのエフェクトを停止して空の状態に戻す
        void Reset();

    private:
        // 1つのエフェクトの中心座標、経過時間、実行状態を保持する
        struct Effect
        {
            Vector2 center = {};
            float age = 0.0f;
            bool isActive = false;
        };

        // エフェクトの最大数
        static constexpr std::size_t Capacity = 32;
        std::array<Effect, Capacity> effects = {};
    };
}
Source/Shooting/Game.cpp
Source/Shooting/Game.cppcpp
#include <cmath>
#include <string>

#include <DxLib.h>

#include <Windows.h>

#include "Config/ApplicationConfig.h"
#include "Shooting/Tuning/GameTuning.h"

#include "Shooting/Game.h"

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

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

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

    namespace
    {
        using SoundId = SoundManager::Id;
        using ImageId = ImageManager::Id;
    }

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

        const SoundManager::LoadResult soundResult = sounds.LoadAll();
        if (!soundResult.succeeded)
        {
            ShowResourceLoadError(L"音声", soundResult.failedPath);
            return false;
        }

        ChangeState(State::Title);

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

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

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

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

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

        return true;
    }

    void Game::Update(float deltaTime)
    {
        switch (state)
        {
        case State::Title:
            UpdateTitle();
            break;

        case State::Playing:
            UpdatePlaying(deltaTime);
            break;

        case State::GameClear:
        case State::GameOver:
            UpdateResult();
            break;
        }
    }

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

        switch (state)
        {
        case State::Title:
            DrawTitle();
            break;

        case State::Playing:
            DrawPlaying();
            break;

        case State::GameClear:
        case State::GameOver:
            DrawPlaying();
            DrawResult();
            break;
        }

    }

    void Game::StartGame()
    {
        // 前回の結果を残さず、全オブジェクトを開始状態へ戻す
        player.Reset();
        playerShots.Reset();
        enemies.Reset();
        enemyShots.Reset();
        effects.Reset();
        playTime = 0.0f;
        timeLeft = tuning::game::TimeLimit;
        score = 0;

        ChangeState(State::Playing);
    }

    void Game::ChangeState(State nextState)
    {
        state = nextState;

        // Stateに入ったときのBGMをここで切り替える
        switch (state)
        {
        case State::Title:
            sounds.PlayBgm(SoundId::TitleBgm);
            break;

        case State::Playing:
            sounds.PlayBgm(SoundId::PlayingBgm);
            break;

        case State::GameClear:
            sounds.StopBgm();
            sounds.PlaySe(SoundId::Clear);
            break;

        case State::GameOver:
            sounds.StopBgm();
            sounds.PlaySe(SoundId::GameOver);
            break;
        }
    }

    void Game::UpdateTitle()
    {
        if (input.IsPressed(Input::Action::Confirm))
            StartGame();
    }

    void Game::UpdatePlaying(float deltaTime)
    {
        playTime += deltaTime;
        timeLeft -= deltaTime;
        if (timeLeft < 0.0f)
            timeLeft = 0.0f;

        player.Update(input, deltaTime);
        playerShots.Update(deltaTime);
        enemies.Update(deltaTime);
        enemyShots.Update(deltaTime);
        effects.Update(deltaTime);

        if (player.TryShoot(input, playerShots))
        {
            sounds.PlaySe(SoundId::Shot);
        }

        ResolveCollisions();

        if (!player.IsAlive())
        {
            ChangeState(State::GameOver);
            return;
        }

        if (score >= tuning::game::ClearScore || timeLeft <= 0.0f)
        {
            ChangeState(State::GameClear);
            return;
        }

        // 発射時点のプレイヤー位置を、すべての敵が狙う位置として共有する
        Vector2 playerPosition = player.GetHitbox().GetCenter();
        for (Enemy& enemy : enemies.GetAll())
        {
            enemy.TryShoot(enemyShots, playerPosition);
        }
    }

    void Game::UpdateResult()
    {
        if (input.IsPressed(Input::Action::Confirm))
            StartGame();
        else
        if (input.IsPressed(Input::Action::Back))
            ChangeState(State::Title);
    }

    void Game::ResolveCollisions()
    {
        // プレイヤーの弾は最初に重なった敵だけに命中させる
        for (PlayerShot& playerShot : playerShots.GetAll())
        {
            if (!playerShot.IsActive())
                continue;

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

                if (!playerShot.GetHitbox().Overlaps(enemy.GetHitbox()))
                    continue;

                enemy.TakeDamage(tuning::playerShot::Damage);
                playerShot.Deactivate();
                effects.Spawn(enemy.GetHitbox().GetCenter());
                if (!enemy.IsActive())
                    ++score;
                sounds.PlaySe(SoundId::Hit);
                break;
            }
        }

        for (EnemyShot& enemyShot : enemyShots.GetAll())
        {
            if (!enemyShot.IsActive())
                continue;

            if (!enemyShot.GetHitbox().Overlaps(player.GetHitbox()))
                continue;

            enemyShot.Deactivate();
            effects.Spawn(enemyShot.GetHitbox().GetCenter());
            if (player.TryTakeDamage())
                sounds.PlaySe(SoundId::Damage);
        }

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

            if (!enemy.GetHitbox().Overlaps(player.GetHitbox()))
                continue;

            enemy.Deactivate();
            effects.Spawn(enemy.GetHitbox().GetCenter());
            if (player.TryTakeDamage())
                sounds.PlaySe(SoundId::Damage);
        }
    }

    void Game::DrawTitle() const
    {
        DrawString(194, 128, L"VERTICAL SHOOTING", GetColor(80, 220, 255));
        DrawString(248, 210, L"PRESS ENTER", GetColor(255, 230, 80));
        DrawString(208, 276, L"ARROW / WASD : MOVE", GetColor(210, 218, 232));
        DrawString(238, 306, L"Z / SPACE : SHOT", GetColor(210, 218, 232));
    }

    void Game::DrawPlaying() const
    {
        // 描画順はプレイヤー、プレイヤーの弾、敵、敵の弾とし、エフェクトを最後に重ねる
        player.Draw(images);
        playerShots.Draw(images);
        enemies.Draw(images);
        enemyShots.Draw(images);
        effects.Draw();
        DrawHud();
    }

    void Game::DrawBackground() const
    {
        // 背景を上下に2枚並べて切れ目なく繰り返す
        int scroll = static_cast<int>(playTime * tuning::background::ScrollSpeed);
        int scrollOffset = scroll % tuning::ScreenHeight;

        Rect first =
        {
            0.0f,
            static_cast<float>(scrollOffset - tuning::ScreenHeight),
            static_cast<float>(tuning::ScreenWidth),
            static_cast<float>(tuning::ScreenHeight)
        };

        Rect second =
        {
            0.0f,
            static_cast<float>(scrollOffset),
            static_cast<float>(tuning::ScreenWidth),
            static_cast<float>(tuning::ScreenHeight)
        };

        images.Draw(ImageId::Background, first);
        images.Draw(ImageId::Background, second);
    }

    void Game::DrawResult() const
    {
        SetDrawBlendMode(DX_BLENDMODE_ALPHA, 168);
        DrawBox(0, 0, tuning::ScreenWidth, tuning::ScreenHeight, GetColor(0, 0, 0), TRUE);
        SetDrawBlendMode(DX_BLENDMODE_NOBLEND, 0);

        if (state == State::GameClear)
            DrawString(254, 164, L"GAME CLEAR", GetColor(100, 255, 160));
        else
            DrawString(254, 164, L"GAME OVER", GetColor(255, 96, 112));

        DrawFormatString(276, 216, GetColor(255, 255, 255), L"SCORE %02d", score);
        DrawString(170, 282, L"ENTER : RETRY    T : TITLE", GetColor(255, 230, 80));
    }

    void Game::DrawHud() const
    {
        int secondsLeft = static_cast<int>(std::ceil(timeLeft));
        DrawFormatString(16, 16, GetColor(255, 255, 255), L"SCORE %02d / %02d", score, tuning::game::ClearScore);
        DrawFormatString(16, 42, GetColor(255, 255, 255), L"HP %d", player.GetHp());
        DrawFormatString(532, 16, GetColor(255, 255, 255), L"TIME %02d", secondsLeft);
    }

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

#include "Shooting/EffectManager.h"
#include "Shooting/SoundManager.h"
#include "Shooting/ImageManager.h"
#include "Shooting/Input.h"
#include "Shooting/EnemyManager.h"
#include "Shooting/EnemyShotManager.h"
#include "Shooting/Player.h"
#include "Shooting/PlayerShotManager.h"

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

    private:
        // 今どの画面を表示しているかを表す
        enum class State
        {
            Title,
            Playing,
            GameClear,
            GameOver
        };

        // 現在のStateに応じて更新処理を分ける
        void Update(float deltaTime);

        // 現在のStateに応じて描画処理を分ける
        void Draw() const;

        // オブジェクトとスコアを初期化してプレイを開始する
        void StartGame();

        // 現在の画面をnextStateへ変更する
        void ChangeState(State nextState);

        // タイトルで決定入力を待つ
        void UpdateTitle();

        // プレイ中のオブジェクトと命中判定を更新する
        void UpdatePlaying(float deltaTime);

        // 結果画面で再開またはタイトルへの戻りを受け付ける
        void UpdateResult();

        // プレイヤー弾、敵弾、敵本体の重なりを判定する
        void ResolveCollisions();

        // タイトル画面を描く
        void DrawTitle() const;

        // プレイ画面とHUDを描く
        void DrawPlaying() const;

        // 背景画像を上下に2枚並べてスクロール表示する
        void DrawBackground() const;

        // 結果画面を重ねて描く
        void DrawResult() const;

        // スコア、HP、残り時間を描く
        void DrawHud() const;

        ImageManager images;
        SoundManager sounds;
        Input input;
        Player player;
        PlayerShotManager playerShots;
        EnemyManager enemies;
        EnemyShotManager enemyShots;
        EffectManager effects;
        State state = State::Title;
        float playTime = 0.0f;
        float timeLeft = 0.0f;
        int score = 0;
    };
}
Source/Shooting/Tuning/GameTuning.h
Source/Shooting/Tuning/GameTuning.hcpp
#pragma once

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

#include "Config/ApplicationConfig.h"

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

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

    namespace game
    {
        inline constexpr int ClearScore = 30;
        inline constexpr float TimeLimit = 60.0f;
    }

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

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

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

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

        // プレイヤー中心を基準にした命中範囲
        inline constexpr Rect Hitbox = Rect::Centered(28.0f, 28.0f);

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

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

        // 被弾した際の無敵時間 単位は秒
        // 被弾した際の無敵時間(単位:秒)
        inline constexpr float InvincibleTime = 1.5f;

        // ゲーム開始時のHP
        inline constexpr int MaxHp = 3;
    }

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

        // 弾の中心を基準にした命中範囲
        inline constexpr Rect Hitbox = Rect::Centered(8.0f, 20.0f);

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

        // 敵へ与えるダメージ
        inline constexpr int Damage = 1;
    }

    namespace enemy
    {
        inline constexpr int LaneCount = 8;
        inline constexpr float LaneStart = 64.0f;
        inline constexpr float LaneSpacing = 72.0f;
        inline constexpr Rect DrawRect = Rect::Centered(42.0f, 42.0f);
        inline constexpr Rect Hitbox = Rect::Centered(32.0f, 32.0f);
        inline constexpr float BaseSpeed = 120.0f;
        inline constexpr float SpeedStep = 12.0f;
        inline constexpr int SpeedLevels = 4;
        inline constexpr float SpawnInterval = 0.60f;
        inline constexpr float SpawnDelay = 0.40f;
        inline constexpr float ShotDelay = 0.80f;
        inline constexpr float ShotInterval = 1.80f;
        inline constexpr float WaveAmplitude = 30.0f;
        inline constexpr float WaveSpeed = 3.0f;
        inline constexpr int WaveInterval = 3;
        inline constexpr int MaxHp = 1;
    }

    namespace enemyShot
    {
        inline constexpr Rect DrawRect = Rect::Centered(14.0f, 14.0f);
        inline constexpr Rect Hitbox = Rect::Centered(10.0f, 10.0f);
        inline constexpr float Speed = 180.0f;
    }

    namespace sound
    {
        inline constexpr int BgmVolume = 96;
        inline constexpr int SeVolume = 160;
    }

    namespace effect
    {
        inline constexpr float Duration = 0.24f;
        inline constexpr float StartRadius = 6.0f;
        inline constexpr float EndRadius = 30.0f;
    }

    namespace background
    {
        inline constexpr float ScrollSpeed = 48.0f;
    }
}