1
\$\begingroup\$

I recently needed to detect agents within a certain-field of view and came across this question in gamedev stack exchange. To learn how it works, I followed the first answer's guidance and decided to make a program that demonstrates how a "a field of view like collision detection" is done. But throughout the process, I struggled a lot with how to structure the program. Here is the code.

#define OLC_PGE_APPLICATION
#include "olcPixelGameEngine.h"

#define PI 3.14159f
#define MAX(a, b) a > b ? a : b
#define MIN(a, b) a > b ? b : a

struct Point
{
    Point()
    {
    }

    Point(olc::vf2d _position, float _directionAngle, float _rotationAngle) :
        position(_position), directionAngle(_directionAngle), rotationAngle(_rotationAngle)
    {
    }
    olc::vf2d position = { 0.0f, 0.0f };
    float directionAngle = 0.0f;
    float rotationAngle = 0.0f;
    bool withinSensoryRange = false;
    olc::Pixel color;
};

struct Triangle
{
    Triangle()
    {
    }

    Triangle(olc::vf2d _p1, olc::vf2d _p2, olc::vf2d _p3) :
        p1(_p1), p2(_p2), p3(_p3)
    {
    }

    olc::vf2d p1 = { 0.0f,   -7.0f };
    olc::vf2d p2 = { -5.0f,   5.0f };
    olc::vf2d p3 = { 5.0f,    5.0f };

    Triangle TranslateAndRotate(const float rotationAngle, olc::vf2d offset)
    {
        Triangle tri;
        tri.p1.x = cosf(rotationAngle) * p1.x - sinf(rotationAngle) * p1.y + offset.x;
        tri.p1.y = sinf(rotationAngle) * p1.x + cosf(rotationAngle) * p1.y + offset.y;
        tri.p2.x = cosf(rotationAngle) * p2.x - sinf(rotationAngle) * p2.y + offset.x;
        tri.p2.y = sinf(rotationAngle) * p2.x + cosf(rotationAngle) * p2.y + offset.y;
        tri.p3.x = cosf(rotationAngle) * p3.x - sinf(rotationAngle) * p3.y + offset.x;
        tri.p3.y = sinf(rotationAngle) * p3.x + cosf(rotationAngle) * p3.y + offset.y;
        return tri;
    }
};

class PlayGround : public olc::PixelGameEngine
{

public:

    PlayGround()
    {
        sAppName = "PlayGround";
    }

private:

    bool debug = true;

private:

    Triangle agent1;
    float rotationAngle1 = 0.0f;
    float sensoryRadius1 = 50.0f;
    float fov1 = PI;
    float agent1Speed = 120.0f;
    float directionPointDistance1 = 60.0f;
    olc::vf2d position1 = { 300.0f, 150.0f };

private:

    olc::Pixel offWhite = olc::Pixel(200, 200, 200);

private:

    float pointsSpeed = 10.0f;
    int nPoints = 1000;
    std::vector<std::unique_ptr<Point>> points;

private:

    float GetDistance(float x1, float y1, float x2, float y2)
    {
        return sqrtf((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1));
    }

    float DirectionAngle(float rotationAngle)
    {
        return rotationAngle - (PI / 2.0f);
    }

private:

    bool OnUserCreate() override
    {
        for (int i = 0; i < nPoints; i++)
        {
            //4 random floats between 0 and 1 for initializing x, y and rotation angle and direction angle for point
            float rx = static_cast <float> (rand()) / static_cast <float> (RAND_MAX);
            float ry = static_cast <float> (rand()) / static_cast <float> (RAND_MAX);
            float rra = static_cast <float> (rand()) / static_cast <float> (RAND_MAX);
            float rda = static_cast <float> (rand()) / static_cast <float> (RAND_MAX);
            std::unique_ptr<Point> point = std::make_unique<Point>(olc::vf2d(rx * 600, ry * 300), rda * (PI * 2), rra * (PI * 2));
            points.push_back(std::move(point));
        }

        return true;
    }

    bool OnUserUpdate(float elapsedTime) override
    {

        //USER CONTROLS

        if (GetKey(olc::UP).bHeld)
        {
            position1.x += cosf(DirectionAngle(rotationAngle1)) * elapsedTime * agent1Speed;
            position1.y += sinf(DirectionAngle(rotationAngle1)) * elapsedTime * agent1Speed;
        }
        if (GetKey(olc::RIGHT).bHeld)
            rotationAngle1 += 3.0f * elapsedTime;
        if (GetKey(olc::LEFT).bHeld)
            rotationAngle1 -= 3.0f * elapsedTime;
        if (GetKey(olc::Q).bHeld)
            fov1 -= 3.0f * elapsedTime;
        if (GetKey(olc::W).bHeld)
            fov1 += 3.0f * elapsedTime;
        if (GetKey(olc::A).bHeld)
            sensoryRadius1 -= 50.0f * elapsedTime;
        if (GetKey(olc::S).bHeld)
            sensoryRadius1 += 50.0f * elapsedTime;
        if (GetKey(olc::D).bPressed)
            debug = !debug;

        fov1 = MAX(MIN(fov1, PI), 0);
        sensoryRadius1 = MAX(MIN(sensoryRadius1, 200), 0);

        //TRANSFORMATIONS FOR TRIANGLE

        Triangle transformedAgent1 = agent1.TranslateAndRotate(rotationAngle1, position1);

        //points that connects to the triangle to show the directiom vector
        olc::vf2d direction1;
        direction1.x = (cosf(DirectionAngle(rotationAngle1)) * directionPointDistance1) + position1.x;
        direction1.y = (sinf(DirectionAngle(rotationAngle1)) * directionPointDistance1) + position1.y;

        //these are the two field of view points one at angle + fov and other at angle - fov
        olc::vf2d fovPoints11;
        olc::vf2d fovPoints12;

        //calculating position based on the position of triangle, fov and the sensory range
        fovPoints11.x = (cosf(DirectionAngle(rotationAngle1 + fov1)) * sensoryRadius1) + position1.x;
        fovPoints11.y = (sinf(DirectionAngle(rotationAngle1 + fov1)) * sensoryRadius1) + position1.y;

        fovPoints12.x = (cosf(DirectionAngle(rotationAngle1 - fov1)) * sensoryRadius1) + position1.x;
        fovPoints12.y = (sinf(DirectionAngle(rotationAngle1 - fov1)) * sensoryRadius1) + position1.y;

        //COLLISION DETECTION

        //within the sensory radius
        for (auto& point : points)
        {
            float distance = GetDistance(point->position.x, point->position.y, position1.x, position1.y);
            if (distance < sensoryRadius1)
                point->withinSensoryRange = true;
            else
            {
                point->color = olc::BLACK;
                point->withinSensoryRange = false;
            }
        }

        //within the field of view
        for (auto& point : points)
        {
            if (point->withinSensoryRange)
            {
                olc::vf2d normalizedForwardVector = (direction1 - position1).norm();
                olc::vf2d normalizedPointCentreVector = (point->position - position1).norm();

                float dot = normalizedPointCentreVector.dot(normalizedForwardVector);

                if (dot >= cosf(fov1))
                    debug ? point->color = olc::RED : point->color = olc::WHITE;
                else
                    debug ? point->color = olc::GREEN : point->color = olc::BLACK;
            }
        }

        //RENDERING 

        Clear(olc::Pixel(52, 55, 54));

        if (debug)
        {
            //draw control instructions
            DrawString(2, 40, "This is a toy program made to demonstrate how collision\ndetection within "
                "a field of view works. Black flies represent the\npoints that are comletely out "
                "of range. In debug mode,\nGreen ones represent the ones that are within the sensory\nraidus. The "
                "ones in the sensory radius are tested to\nsee if they are in the field of view, and "
                "if they\nare,they appear red.\n\nWhen debug mode is off, white flies\nrepresent the flies that can "
                "be seen", offWhite);

            DrawString(2, 10,
                "Press up, right and left keys for movement.\n"
                "Press w to increase FOV and q to reduce it.\n"
                "Press s to increase sensory range and a to decrease it.", offWhite);
        }

        DrawString(2, 290, "Press d to toggle text and geometric debug data.", olc::Pixel(200, 250, 200));

        //display info 
        std::ostringstream fovValue;
        fovValue << "FOV: " << round(fov1 * 2.0f * (180 / PI)) << " degrees";
        DrawString(440, 280, fovValue.str(), offWhite);

        std::ostringstream sensoryRangeValue;
        sensoryRangeValue << "Sensory Range: " << round(sensoryRadius1);
        DrawString(440, 265, sensoryRangeValue.str(), offWhite);


        //transform (wobble while moving forward) and draw all the points
        for (auto& point : points)
        {
            point->rotationAngle += 0.05f;
            point->directionAngle -= 0.05f;
            point->position.x += cosf(point->directionAngle) * sinf(point->rotationAngle) * elapsedTime * pointsSpeed;
            point->position.y += sinf(point->directionAngle) * sinf(point->rotationAngle) * elapsedTime * pointsSpeed;

            if (point->rotationAngle > PI * 2)
                point->rotationAngle = 0;
            if (point->rotationAngle < 0)
                point->rotationAngle = PI * 2;
            if (point->directionAngle > PI * 2)
                point->directionAngle = 0;
            if (point->directionAngle < 0)
                point->directionAngle = PI * 2;

            if (point->position.x > 600)
                point->position.x = 0;
            if (point->position.x < 0)
                point->position.x = 600;
            if (point->position.y > 300)
                point->position.y = 0;
            if (point->position.y < 0)
                point->position.y = 300;

            Draw((int)point->position.x, (int)point->position.y, point->color);
        }


        if (debug)
        {
            //lines from centre of triangle to fov points
            DrawLine((int)position1.x, (int)position1.y, (int)fovPoints11.x, (int)fovPoints11.y, olc::RED);
            DrawLine((int)position1.x, (int)position1.y, (int)fovPoints12.x, (int)fovPoints12.y, olc::RED);
            //field of view points
            FillCircle((int)fovPoints11.x, (int)fovPoints11.y, 2, olc::RED);
            FillCircle((int)fovPoints12.x, (int)fovPoints12.y, 2, olc::RED);
            //color the points between the two fov points in red
            float tempAngle = DirectionAngle(rotationAngle1 + fov1);
            while (tempAngle > DirectionAngle(rotationAngle1 - fov1))
            {
                for (int i = 0; i < 3; i++)
                    Draw((int)(cosf(tempAngle) * (sensoryRadius1 + i)) + position1.x,
                        (int)(sinf(tempAngle) * (sensoryRadius1 + i)) + position1.y, olc::RED);
                tempAngle -= 0.01f;
            }
            //draw sensory radius
            DrawCircle((int)position1.x, (int)position1.y, sensoryRadius1, olc::GREEN);
            //the straingt line signifying direction
            DrawLine((int)position1.x, (int)position1.y, (int)direction1.x, (int)direction1.y, offWhite);
        }
        //Draw the main triangle body
        FillTriangle(
            (int)transformedAgent1.p1.x, (int)transformedAgent1.p1.y,
            (int)transformedAgent1.p2.x, (int)transformedAgent1.p2.y,
            (int)transformedAgent1.p3.x, (int)transformedAgent1.p3.y,
            offWhite);


        return true;
    }
};

int main()
{
    PlayGround playGround;
    if (playGround.Construct(600, 300, 2, 2))
        playGround.Start();
}

I am sure its bad and tons of optimizations can be made, but for this particular question, I want to focus on how I could have structured it better. Thank you.

It is made using pixel game engine, so if you wish to test it out, it needs this. Its a single file library, so easy to set up.

\$\endgroup\$
1
\$\begingroup\$

Avoid using macros

Try to avoid macros where possible; usually a better solution is available. For constants, prefer using constexpr:

static constexpr float PI = 3.14159...f;

Instead of MIN and MAX, just use std::min() and std::max(). Or if you can use C++17, use std::clamp().

Constructors and member initialization

If you need to explicitly add a default constructor, prefer doing that using = default.

When initializing members to zero, you can do that very concisely using value initialization, which looks like this:

olc::vf2d position{};

Separate translation and rotation

Instead of having a TranslateAndRotate() function, consider splitting that up into a separate Translate() and Rotate(). This is more flexible, and it also removes an ambiguity: does your function translate first and then rotate, or the other way around? You can also greatly simplify these functions, especially when using a little helper to rotate single Points:

Triangle Translate(olc::vf2d offset)
{
    return {p1 + offset, p2 + offset, p3 + offset};
}

Triangle Rotate(float angle)
{
    static const auto rotate = [](olc::vf2d p, float angle) -> olc::vf2d {
        return {
            p.x * std::cos(angle) - p.y * std::sin(angle),
            p.x * std::sin(angle) + p.y * std::cos(angle)
        };
    };

    return {rotate(p1, angle), rotate(p2, angle), rotate(p3, angle)};
}

I used a lambda here, but you could also write that as a regular function. You can now use this as follows:

Triangle transformedAgent1 = agent1.Rotate(rotationAngle1).Translate(position1);

Avoid using smart pointers unnecessarily

There is no reason to use std::unique_ptr to store Points in a vector. Instead just write:

std::vector<Point> points;

When adding points to this class, you can write:

for (int i = 0; i < nPoints; i++)
{
    float rx = ...;
    float ry = ...;
    float rra = ...;
    float rrd = ...;
    points.emplace_back(olc::vf2d{rx * 600, ry * 600}, rda * PI * 2, rra * PI * 2);
}

Use proper random number generators

Since C++11 there are proper random number generator functions, there is no need to use the rather ugly rand().

Use std::

Teach yourself to use std:: consistently when using functions from the standard library, and avoid relying on using namespace std. Also note that using math functions from std:: has the benefit of automatically using the right overload. For example, instead of sinf(...), write std::sin(...), if the argument is a float it will pick the overload for floats. That's one less thing to worry about.

Be consistent using olc::vf2d

If you have a proper type for 2D vectors, you rarely have to pass coordinates in separate floats anymore. For example, GetDistance can be rewritten to:

float GetDistance(olc::vf2d p1, olc::vf2d p2)
{
    return std::hypot(p2.x - p1.x, p2.y - p1.y);
}

And use it like so in OnUserUpdate():

float distance = GetDistance(point.position, position1);

Avoid repetition

Avoid repeating yourself where possible, even if it's small things, like the ternaries in OnUserUpdate():

if (dot >= cosf(fov1))
    debug ? point.color = olc::RED : point.color = olc::WHITE;

This can be rewritten to:

if (dot >= std::cos(fov1))
    point.color = debug ? olc::RED : olc::WHITE;

Split the code into more functions

The function OnUserUpdate() is quite long. Consider splitting it up into multiple functions, so that OnUserUpdate() just gives you a high-level overview of what it is doing:

bool OnUserUpdate(float elapsedTime) override
{
    HandleInput(elapsedTime);
    UpdateState(elapsedTime);
    DrawScreen();

    return true;
}

Each of these functions could be split into multiple parts as well if necessary, for example updating the state could look like:

void UpdateState(float elapsedTime)
{
    CollisionDetection();
    WobblePoints(elapsedTime);
}

And drawing could look like:

void DrawScreen()
{
    Clear({52, 55, 54});

    if (debug)
       DrawDebugInformation();

    DrawInformation();
    DrawPoints();
    DrawBody();
}

Wrapping values

If you wnat to clamp a variable between a low and high value, you can use the std::max+std::min() trick, or C++17's std::clamp(). However, you also have a few variables that you want to let wrap. You are doing this four times, so already you should have created a function to do this that will reduce code duplication.

Your code also checks if a value is larger than, say, PI * 2, and if so you reset it to zero. However, suppose the value was actually PI * 2 + 0.1, then ideally after wrapping the result should be 0.1, not 0. You could change your code to subtract PI * 2 until it is in range, but we can avoid if and while statement altogether by making use of std::floor, like so:

float wrap(float value, float max)
{
     value /= max;
     value -= std::floor(value); // value is now in the range [0, 1)
     value *= max;
     return value;
}

And with this you can write:

point.rotationAngle = wrap(point.rotationAngle, PI * 2);
point.directionAngle = wrap(point.directionAngle, PI * 2);
point.position.x = wrap(point.position.x, 600);
point.position.y = wrap(point.position.y, 300);
\$\endgroup\$
2
  • \$\begingroup\$ Hello, Thanks for the answer. You see the for loop below the comment //transform (wobble while moving forward) and draw all the points. I am doing all the transformations and drawings in the same loop. I was struggling with how to separate those two out. What would be your advice? \$\endgroup\$ – Apple_Banana May 9 at 11:40
  • 1
    \$\begingroup\$ I would indeed separate the code. Make one function that updates the state, and another one that draw everything. I've updated the answer. \$\endgroup\$ – G. Sliepen May 9 at 14:59

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.