Appearance
第21回 敵の配置とジャンプ操作の改善
4 / 4 コヨーテタイムと先行入力バッファ
このステップの目的
人間の知覚・操作感覚とフレーム単位の物理判定のズレを補正する「コヨーテタイム」と「先行入力バッファ」を導入し、意図したタイミングで確実にジャンプが成立する操作補助を実装します。
人間の知覚と物理判定のズレ
プラットフォーマーにおいて、フレーム単位の厳密な判定のみに依存すると、プレイヤーの意図した入力が不成立となるケースが生じます。代表的な例は次の2点です。
- 崖際の入力こぼし:
崖の端ギリギリでジャンプしようとする際、人間の目にはまだ足場上にいるように見えても、プログラム上は1フレーム早く空中に踏み出していることがあります。その瞬間にボタンを押してもジャンプは発動せず、そのまま落下してしまいます。 - 着地前の入力こぼし:
落下しながら「着地と同時に再ジャンプしたい」と考え、地面に触れる数フレーム前にボタンを押した場合、まだ接地していないため入力が無効化され、着地後に停止してしまいます。
これらは判定の不正ではなく、プレイヤーの入力意図を短時間の猶予枠で拾い上げるための設計です。
text
【コヨーテタイム】
足場 =====|
|... 猶予時間(数フレーム)... この間もジャンプ成立
v 落下
【先行入力バッファ】
落下中 ... [ボタン押下](入力を数フレーム保持)
|
地面 =====v 着地 ---> 保持していた入力で即座にジャンプ2つの猶予フレームカウンタの管理
この処理を実現するため、プレイヤー内部に2つのカウンタ変数を設けます。
Player.cppcpp
// 足場を離れた直後もジャンプできるよう受付時間を残す(コヨーテタイム)
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_--;
}60FPS動作において、上限値(FrameLimit)を 5〜6 フレーム(約0.08〜0.1秒)程度に設定すると、遅延を感じさせずに自然な操作補助となります。
ジャンプ成立とカウンタの即時消費
ジャンプは、2つのカウンタが両方とも 0 より大きい場合に成立します。
Player.cppcpp
if (coyoteFrames_ > 0 && jumpBufferFrames_ > 0)
{
velocity_.y = tuning::player::JumpPower;
onGround_ = false;
coyoteFrames_ = 0;
jumpBufferFrames_ = 0;
events.jumped = true;
}成立時にカウンタをリセットする理由
ジャンプ成立時にカウンタを 0 にリセットしない場合、次のフレームでも coyoteFrames_ > 0 や jumpBufferFrames_ > 0 が残り、1回のボタン押下で空中二重ジャンプが誤発動してしまいます。
ジャンプが成立した瞬間に権利を消費(ゼロクリア)することで、1回の入力に対して確実に1回だけのジャンプを保証します。なお、リトライ時の Reset() でも両カウンタを 0 に戻します。
チェックリスト
- コヨーテタイムと先行入力バッファがそれぞれ解決する操作上の課題を説明できる
- 接地状態とボタン押下タイミングに基づいて2つのフレームカウンタを管理できる
- ジャンプ成立時に両カウンタを0クリアし、二重ジャンプの誤発動を防ぐ仕組みを実装できる
次回への引き継ぎ
第21回では、CSVからの敵出現位置読み込み、固定長配列による EnemyManager、可変ジャンプおよび入力アシスト(コヨーテタイム・先行入力バッファ)を実装しました。
次回(第22回)は、配置した敵キャラクターに重力と地形衝突判定、壁接触時の反転、および足場の端(崖)を検知して落下せずに往復巡回する自律移動AIを実装します。
参考実装
今回追加・変更するファイルの一例です。宣言や補助処理を確認したいときに開いてください。自分のコードへ必要な変更を反映し、既存の調整値や素材をそのまま上書きしないようにします。
Source/Platformer/Enemy.cpp
Source/Platformer/Enemy.cppcpp
#include "Platformer/Camera.h"
#include "Platformer/Enemy.h"
#include "Platformer/ImageManager.h"
#include "Platformer/Tuning/GameTuning.h"
namespace dxgame::platformer
{
void Enemy::Reset(const dxgame::Vector2& position)
{
position_ = position;
}
void Enemy::Draw(const Camera& camera, const ImageManager& images) const
{
images.Draw(ImageId::Enemy, camera.WorldToScreen(GetDrawRect()));
}
dxgame::Rect Enemy::GetHitbox() const
{
return tuning::enemy::Hitbox.MovedBy(position_);
}
dxgame::Rect Enemy::GetDrawRect() const
{
return tuning::enemy::DrawRect.MovedBy(position_);
}
dxgame::Vector2 Enemy::GetPosition() const
{
return position_;
}
}Source/Platformer/Enemy.h
Source/Platformer/Enemy.hcpp
#pragma once
#include "DxGame/Rect.h"
#include "DxGame/Vector2.h"
namespace dxgame::platformer
{
class Camera;
class ImageManager;
// ステージ上に配置される敵1体の状態を管理する
class Enemy
{
public:
void Reset(const dxgame::Vector2& position);
void Draw(const Camera& camera, const ImageManager& images) const;
dxgame::Rect GetHitbox() const;
dxgame::Rect GetDrawRect() const;
dxgame::Vector2 GetPosition() const;
private:
dxgame::Vector2 position_ = {};
};
}Source/Platformer/EnemyManager.cpp
Source/Platformer/EnemyManager.cppcpp
#include "Platformer/Camera.h"
#include "Platformer/EnemyManager.h"
#include "Platformer/ImageManager.h"
namespace dxgame::platformer
{
void EnemyManager::Reset(const std::vector<SpawnPoint>& spawnPoints)
{
enemies_.clear();
enemies_.reserve(spawnPoints.size());
for (const SpawnPoint& spawnPoint : spawnPoints)
{
Enemy enemy;
enemy.Reset(spawnPoint.position);
enemies_.push_back(enemy);
}
}
void EnemyManager::Draw(const Camera& camera,
const ImageManager& images) const
{
for (const Enemy& enemy : enemies_)
enemy.Draw(camera, images);
}
std::size_t EnemyManager::GetCount() const
{
return enemies_.size();
}
}Source/Platformer/EnemyManager.h
Source/Platformer/EnemyManager.hcpp
#pragma once
#include <vector>
#include "Platformer/Enemy.h"
#include "Platformer/Stage.h"
namespace dxgame::platformer
{
class Camera;
class ImageManager;
// CSVから読み込んだ敵配置をEnemyの集合として所有する
class EnemyManager
{
public:
// ステージの配置情報から敵を作り直す
void Reset(const std::vector<SpawnPoint>& spawnPoints);
// カメラ内の敵を描画する
void Draw(const Camera& camera, const ImageManager& images) const;
std::size_t GetCount() const;
private:
std::vector<Enemy> enemies_;
};
}Source/Platformer/Game.cpp
Source/Platformer/Game.cppcpp
#include <Windows.h>
#include <string>
#include "DxLib.h"
#include "Config/ApplicationConfig.h"
#include "Platformer/Game.h"
#include "Platformer/Tuning/GameTuning.h"
namespace dxgame::platformer
{
bool Game::Initialize()
{
if (!images_.LoadAll())
return false;
const ImageResource& playerImage = images_.Get(ImageId::Player);
if (playerImage.width % tuning::animation::PlayerFrameCount != 0)
{
MessageBoxW(nullptr,
L"Player.pngの横幅を8で割り切れません。",
L"Player Image Error", MB_OK);
return false;
}
if (!sounds_.LoadAll())
return false;
constexpr wchar_t StagePath[] = L"Assets/Maps/Stage01.csv";
if (!stage_.Initialize(StagePath))
{
const std::wstring message = std::wstring(StagePath) + L"\n" +
stage_.GetLastLoadError();
MessageBoxW(nullptr, message.c_str(), L"Map Load Error", MB_OK);
return false;
}
ResetStage();
sounds_.PlayBgm(SoundId::PlayingBgm);
return true;
}
void Game::MainLoop()
{
// 小数分の待ち時間を次フレームへ持ち越す
double nextFrame = GetNowCount();
while (ProcessMessage() == 0)
{
input_.Update();
if (input_.IsHeld(Key::Escape))
break;
Update(dxgame::config::DeltaTime);
ClearDrawScreen();
Draw();
ScreenFlip();
nextFrame += dxgame::config::FrameMilliseconds;
const int waitMilliseconds =
static_cast<int>(nextFrame - GetNowCount());
if (waitMilliseconds > 0)
WaitTimer(waitMilliseconds);
else
nextFrame = GetNowCount();
}
}
void Game::Update(float deltaTime)
{
UpdateDebugToggle();
const PlayerEvents events = player_.Update(input_, stage_, deltaTime);
HandlePlayerEvents(events);
ResetAfterFall();
camera_.Update(player_.GetPosition(), stage_.GetWorldWidth());
}
void Game::Draw() const
{
DrawBackground();
stage_.Draw(camera_.GetX(), images_);
enemies_.Draw(camera_, images_);
player_.Draw(camera_, images_);
DrawHud();
DrawDebug();
}
void Game::DrawBackground() const
{
images_.Draw(ImageId::Background, { 0.0f, 0.0f,
static_cast<float>(dxgame::config::ScreenWidth),
static_cast<float>(dxgame::config::ScreenHeight) });
}
void Game::DrawHud() const
{
const int textColor = GetColor(255, 255, 255);
DrawString(12, 10, L"LEFT/RIGHT: MOVE", textColor);
DrawString(12, 30, L"Z: JUMP", textColor);
DrawString(12, 50, L"F1: DEBUG ESC: QUIT", textColor);
DrawFormatString(470, 10, textColor, L"ENEMIES: %d",
static_cast<int>(enemies_.GetCount()));
}
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"enemies: %d csv: %d",
static_cast<int>(enemies_.GetCount()),
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());
enemies_.Reset(stage_.GetEnemySpawns());
camera_.Update(player_.GetPosition(), stage_.GetWorldWidth());
}
void Game::HandlePlayerEvents(const PlayerEvents& events)
{
if (events.jumped)
sounds_.PlaySe(SoundId::Jump);
if (events.landed)
sounds_.PlaySe(SoundId::Land);
}
}Source/Platformer/Game.h
Source/Platformer/Game.hcpp
#pragma once
#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
{
// 入力、プレイヤー、ステージ、敵配置、描画順を調整する
class Game
{
public:
// 必須画像、音声、CSVを読み込む
bool Initialize();
// ウィンドウ終了まで更新と描画を繰り返す
void MainLoop();
private:
void Update(float deltaTime);
void Draw() const;
void DrawBackground() const;
void DrawHud() const;
void DrawDebug() const;
void UpdateDebugToggle();
void ResetAfterFall();
void ResetStage();
void HandlePlayerEvents(const PlayerEvents& events);
Input input_;
ImageManager images_;
SoundManager sounds_;
Stage stage_;
Camera camera_;
Player player_;
EnemyManager enemies_;
bool debugVisible_ = false;
};
}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_);
}
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;
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/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 ||
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::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_;
}
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;
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,
// 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 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 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);
}
namespace goal
{
// ゴール画像の表示範囲と、クリア成立に使う範囲
inline constexpr Rect DrawRect = Rect::Centered(32.0f, 32.0f);
inline constexpr Rect Trigger = Rect::Centered(20.0f, 28.0f);
}
}