Start

Theory

Reference

Examples

← All examples
Physics · ECS · Assets · Rendering

Path Following

A car driving a closed, curved street

End-to-end showcase for components::PathFollow and systems::PathFollowingSystem: a car drives a closed, curved street loop built from math::CatmullRomSpline — 13 waypoints (the first repeated at the end, since a Catmull-Rom chain doesn't wrap on its own) resolved once at spawn time, then walked every frame at a player-adjustable speed, with the car oriented to face the road's tangent direction as it turns.

The street itself is drawn straight from the car's own resolved PathFollow::m_Path — IRenderer has no curve/polygon-fill primitive, so it's painted as a dense trail of overlapping filled circles (via PointAtDistance) with a dashed centerline, rather than needing a second, separately-authored road asset.

Controls

  • UP / DOWN Adjust speed while held
  • SPACE Pause/resume
  • R Reset to the start of the loop
PhysicsECSAssetsRendering
Path Following screenshot

Run it yourself

build just this example
cmake --build --preset windows-debug --target path_following_demo
# binary lands under bin/ (e.g. bin/Debug/path_following_demo.exe on Windows)

See Installation for the full build setup.

Source

examples/path_following_demo/Game.hpp
#pragma once

#include <ASGE/ASGE.hpp>

/**
 * @brief A car (Sprite + components::PathFollow) driving a closed, curved
 *        street loop -- the end-to-end showcase for PathFollow/
 *        PathFollowingSystem: waypoints resolved once into a
 *        math::CatmullRomSpline at spawn time, then walked every frame at a
 *        player-adjustable speed via systems::PathFollowingSystem, wrapping
 *        back to the start on every lap.
 *
 * The street itself is drawn straight from the car's own resolved
 * PathFollow::m_Path -- IRenderer has no curve/polygon-fill primitive, so
 * RenderRoad() paints it as a dense trail of overlapping filled circles
 * with a dashed centerline. UP/DOWN adjust the car's speed while held,
 * SPACE pauses/resumes, R resets it to the start of the loop.
 */
class PathFollowingDemoState final : public asge::game::state::IGameState<int>
{
    asge::ecs::Registry&             m_Registry;
    asge::game::asset::AssetManager& m_Assets;

    asge::ecs::Entity m_Car{ asge::ecs::Entity::Null() };

    float    m_Speed{ 0.0f }; // current units/second, UP/DOWN-adjustable -- set to kBaseSpeed by Reset()
    unsigned m_Laps{ 0 };
    bool     m_Paused{ false };
    float    m_PreviousTraveled{ 0.0f }; // last frame's PathFollow::m_Traveled, to spot a wrap past the finish line

    void SpawnCar(asge::video::IRenderer& inRenderer);
    void UpdateCar();
    void Reset();
    void RenderRoad(asge::video::IRenderer& inRenderer) const;
    void RenderHud(asge::video::IRenderer& inRenderer) const;

public:
    PathFollowingDemoState(asge::ecs::Registry& inRegistry, asge::game::asset::AssetManager& inAssets,
        asge::video::IRenderer& inRenderer);

    [[nodiscard]] std::optional<asge::game::state::Transition<int>>
    Update(float inDeltaTime, asge::input::InputState const& inInput) override;
    void Render(asge::video::IRenderer& inRenderer) override;
    void RenderBackground(asge::video::IRenderer& inRenderer) override;
    [[nodiscard]] asge::graphics::RGBA_Color ClearColor() const noexcept override;
    void OnSystemEvent(asge::event::SystemEvent const& inSysEvent) override;
};

class PathFollowingDemoGame final : public asge::game::Game<int>
{
public:
    explicit PathFollowingDemoGame(asge::video::IRenderer& inRenderer, asge::audio::AudioDevice& inAudioDev);

protected:
    [[nodiscard]] std::unique_ptr<StateType> CreateState(int inId) override;
};
examples/path_following_demo/Game.cpp
#include "Game.hpp"

#include <algorithm>

namespace
{
using asge::input::Keycode;
using asge::math::Float2;
using asge::math::Int2;
using asge::math::Rect;
using asge::graphics::RGBA_Color;
using asge::game::components::PathFollow;
using asge::game::components::Sprite;
using asge::game::components::Transform;

constexpr char const* kCarTexturePath = "textures/car.png";
constexpr float       kCarNativeW = 32.0f; // car.png's own pixel size
constexpr float       kCarNativeH = 20.0f;
constexpr float       kCarScale = 2.25f;
constexpr float       kCarHalfW = ( kCarNativeW * kCarScale ) * 0.5f;
constexpr float       kCarHalfH = ( kCarNativeH * kCarScale ) * 0.5f;

constexpr float kBaseSpeed = 170.0f; // units/second
constexpr float kMinSpeed = 40.0f;
constexpr float kMaxSpeed = 420.0f;
constexpr float kAccel = 160.0f; // units/second^2 while UP/DOWN is held

constexpr float kRoadHalfWidth = 26.0f;
constexpr float kRoadSampleStep = 6.0f; // arc-length spacing between road/dash "paint" dabs
constexpr float kDashOn = 14.0f;
constexpr float kDashOff = 14.0f;
constexpr RGBA_Color kRoadColor{ 68, 68, 76, 255 };
constexpr RGBA_Color kDashColor{ 235, 205, 70, 255 };
constexpr RGBA_Color kGrassColor{ 48, 120, 58, 255 };
}

PathFollowingDemoState::PathFollowingDemoState(
    asge::ecs::Registry& inRegistry, asge::game::asset::AssetManager& inAssets,
    asge::video::IRenderer& inRenderer
)
: m_Registry(inRegistry), m_Assets(inAssets)
{
    SpawnCar(inRenderer);
}

void PathFollowingDemoState::SpawnCar(asge::video::IRenderer& inRenderer)
{
    auto entity = m_Registry.CreateEntity();
    if ( !entity ) { entity.LogError(); return; }
    m_Car = entity.Value();

    m_Registry.AddComponent<Transform>( m_Car, Transform{
        .m_LocalScale = {kCarScale, kCarScale}, .m_WorldScale = {kCarScale, kCarScale}
    } );
    // m_Texture stays null until ResolveAssets below.
    m_Registry.AddComponent<Sprite>( m_Car, Sprite{ .m_VirtualPath = kCarTexturePath } );

    PathFollow pathFollow;
    pathFollow.m_Waypoints = {
        { 660.0f, 340.0f }, { 658.7f, 437.7f }, { 530.0f, 487.2f }, { 400.0f, 484.7f },
        { 270.0f, 487.2f }, { 141.3f, 437.7f }, { 140.0f, 340.0f }, { 208.3f, 267.7f },
        { 270.0f, 192.8f }, { 400.0f, 144.7f }, { 530.0f, 192.8f }, { 591.7f, 267.7f },
        { 660.0f, 340.0f }, // repeats the first point -- a Catmull-Rom chain
                            // doesn't wrap on its own (m_Loop only wraps
                            // m_Traveled), so closing the loop needs the
                            // start point stitched back on as the last one.
    };
    for ( auto& waypoint : pathFollow.m_Waypoints )
    {
        waypoint.x() -= kCarHalfW;
        waypoint.y() -= kCarHalfH;
    }
    pathFollow.m_Loop = true;
    m_Registry.AddComponent<PathFollow>( m_Car, pathFollow );

    // Resolved once, right here at spawn time -- this demo's equivalent of
    // Game::LoadScene()'s one-time ResolveAssets call, not something to
    // repeat every frame (see AssetManager::ResolveAssets' own doc comment).
    m_Assets.ResolveAssets( m_Registry, inRenderer );

    Reset();
}

void PathFollowingDemoState::UpdateCar()
{
    auto pathResult = m_Registry.GetComponent<PathFollow>( m_Car );
    if ( !pathResult ) return;
    auto& path = pathResult.Value().get();

    if ( path.m_Traveled < m_PreviousTraveled ) ++m_Laps; // wrapped back past the finish line
    m_PreviousTraveled = path.m_Traveled;

    path.m_Speed = m_Paused ? 0.0f : m_Speed;
}

void PathFollowingDemoState::Reset()
{
    m_Paused = false;
    m_Speed = kBaseSpeed;
    m_Laps = 0;

    auto pathResult = m_Registry.GetComponent<PathFollow>( m_Car );
    if ( !pathResult ) return;
    auto& path = pathResult.Value().get();
    path.m_Speed = m_Speed;
    path.m_Traveled = 0.0f;
    path.m_Finished = false;
    m_PreviousTraveled = 0.0f;

    if ( path.m_Path.HasSegments() )
    {
        auto const pos = path.m_Path.PointAtDistance( 0.0f );
        if ( auto transformResult = m_Registry.GetComponent<Transform>( m_Car ) )
        {
            auto& t = transformResult.Value().get();
            t.m_LocalCoordinates = pos;
            t.m_LocalRotation = 0.0f; // the next PathFollowingSystem tick recomputes this properly
            t.m_Dirty = true;
        }
    }
}

void PathFollowingDemoState::RenderRoad(asge::video::IRenderer& inRenderer) const
{
    auto pathResult = m_Registry.GetComponent<PathFollow>( m_Car );
    if ( !pathResult ) return;
    auto const& path = pathResult.Value().get().m_Path;

    float const length = path.Length();
    if ( length <= 0.0f ) return;

    // IRenderer has no curve/polygon-fill primitive, so the road surface is
    // just a dense trail of overlapping filled circles walked along the
    // spline's own arc length via PointAtDistance.
    for ( float d = 0.0f; d <= length; d += kRoadSampleStep )
    {
        auto const p = path.PointAtDistance( d );
        inRenderer.DrawCircle(
            Int2{ static_cast<int>(p.x() + kCarHalfW), static_cast<int>(p.y() + kCarHalfH) },
            static_cast<int>(kRoadHalfWidth), kRoadColor, true );
    }

    // Dashed centerline: alternates a dash's worth of small dots with a gap.
    for ( float cursor = 0.0f; cursor <= length; cursor += kDashOn + kDashOff )
    {
        float const dashEnd = std::min( cursor + kDashOn, length );
        for ( float d = cursor; d < dashEnd; d += kRoadSampleStep )
        {
            auto const p = path.PointAtDistance( d );
            inRenderer.DrawCircle(
                Int2{ static_cast<int>(p.x() + kCarHalfW), static_cast<int>(p.y() + kCarHalfH) }, 2, kDashColor, true );
        }
    }
}

void PathFollowingDemoState::RenderHud(asge::video::IRenderer& inRenderer) const
{
    // "PAUSED" indicator (top-left).
    inRenderer.DrawRect(
        Rect{ 20.0f, 20.0f, 30.0f, 16.0f },
        m_Paused ? RGBA_Color{ 240, 120, 60, 255 } : RGBA_Color{ 55, 55, 60, 255 },
        true );

    // Speed bar.
    Rect const speedTrack{ 20.0f, 46.0f, 160.0f, 14.0f };
    inRenderer.DrawRect( speedTrack, RGBA_Color{ 90, 90, 100, 255 }, false );
    float const speedFrac = std::clamp( (m_Speed - kMinSpeed) / (kMaxSpeed - kMinSpeed), 0.0f, 1.0f );
    inRenderer.DrawRect(
        Rect{ speedTrack.m_X, speedTrack.m_Y, speedTrack.m_Width * speedFrac, speedTrack.m_Height },
        RGBA_Color{ 100, 170, 240, 255 }, true );

    // Current-lap progress bar.
    Rect const lapTrack{ 20.0f, 70.0f, 160.0f, 14.0f };
    inRenderer.DrawRect( lapTrack, RGBA_Color{ 90, 90, 100, 255 }, false );
    float lapFrac = 0.0f;
    if ( auto pathResult = m_Registry.GetComponent<PathFollow>( m_Car ) )
    {
        auto const& path = pathResult.Value().get();
        float const length = path.m_Path.Length();
        if ( length > 0.0f ) lapFrac = path.m_Traveled / length;
    }
    inRenderer.DrawRect(
        Rect{ lapTrack.m_X, lapTrack.m_Y, lapTrack.m_Width * lapFrac, lapTrack.m_Height },
        RGBA_Color{ 235, 205, 70, 255 }, true );

    // One small filled square per completed lap, capped so a long session
    // doesn't run the row off-screen.
    constexpr unsigned kMaxPips = 12;
    unsigned const pips = std::min( m_Laps, kMaxPips );
    for ( unsigned ii = 0; ii < pips; ++ii )
    {
        inRenderer.DrawRect(
            Rect{ 20.0f + static_cast<float>(ii) * 14.0f, 94.0f, 10.0f, 10.0f },
            RGBA_Color{ 235, 205, 70, 255 }, true );
    }
}

std::optional<asge::game::state::Transition<int>>
PathFollowingDemoState::Update(float inDeltaTime, asge::input::InputState const& inInput)
{
    if ( inInput.IsKeyDown( Keycode::UP ) )   m_Speed = std::min( m_Speed + kAccel * inDeltaTime, kMaxSpeed );
    if ( inInput.IsKeyDown( Keycode::DOWN ) ) m_Speed = std::max( m_Speed - kAccel * inDeltaTime, kMinSpeed );

    UpdateCar();
    return std::nullopt;
}

void PathFollowingDemoState::RenderBackground(asge::video::IRenderer& inRenderer)
{
    RenderRoad( inRenderer );
}

asge::graphics::RGBA_Color PathFollowingDemoState::ClearColor() const noexcept
{
    return kGrassColor;
}

void PathFollowingDemoState::Render(asge::video::IRenderer& inRenderer)
{
    RenderHud( inRenderer );
}

void PathFollowingDemoState::OnSystemEvent(asge::event::SystemEvent const& inSysEvent)
{
    auto const* keyEvent = inSysEvent.TryGet<asge::event::KeyboardEvent>();
    if ( !keyEvent ) return;
    if ( keyEvent->s_Type != asge::event::EventType::KEYBOARD_KEY_PRESSED ) return;
    if ( keyEvent->s_Repeat ) return; // edge-triggered: one toggle per physical press

    if ( keyEvent->s_Keycode == Keycode::SPACE )    m_Paused = !m_Paused;
    else if ( keyEvent->s_Keycode == Keycode::R )   Reset();
}

PathFollowingDemoGame::PathFollowingDemoGame(asge::video::IRenderer& inRenderer, asge::audio::AudioDevice& inAudioDev)
: Game(inRenderer, inAudioDev)
{
    // ASGE_PATH_FOLLOWING_DEMO_ASSET_DIR is injected by CMakeLists.txt.
    auto mountResult = m_Vfs.Mount("textures", ASGE_PATH_FOLLOWING_DEMO_ASSET_DIR);
    if ( !mountResult ) mountResult.LogError();

    SetInitialState(0);
}

std::unique_ptr<PathFollowingDemoGame::StateType> PathFollowingDemoGame::CreateState([[maybe_unused]] int inId)
{
    return std::make_unique<PathFollowingDemoState>( m_SceneManager.GetRegistry(), m_Assets, m_Renderer );
}

Browse the full example on GitHub →