Friday, 26 August 2022

Web Tutorial: The Collision Detector

Video games, particularly of the side-scrolling shooter variety, all have collision detection as a mechanic. I've often run into needing to solve those problems. What I needed was a utility of some kind, to tell me when one object is sharing the same space as another object in a two-dimensional area.

And I have made just such a utility, using VueJS. The use of VueJS was to shorten development time; what really matters is that the formula I use to determine collision, is correct and can be visually proven to be correct.

In this context, one object can be said to have "collided" with another, if horizontally and vertically their coordinates intersect. I'll expound on that later, but for now, here's some code. The HTML includes one div with the id cdApp, a remote link to VueJS and the instantiation of the Vue object.

<html>
    <head>
        <title>Collision Detection Utility</title>

        <style>

        </style>
    </head>

    <body>
        <div id="cdApp">

        </div>

        <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/1.0.18/vue.min.js"></script>

        <script>
            var app = new Vue
            (

            );
        </script>
    </body>
</html>


I'm going to go through the next few bits really fast, because they aren't all that important to the final product. Within the cdApp div, we have a few other divs. The classes are dashboard, notice and space. dashboard is to house all the controls we will need, notice is for the displaying of messages, and space is the visual representation of the two objects and their coordinates.
<div id="cdApp">
    <div class="dashboard">

    </div>

    <div class="notice">

    </div>

    <div class="space">

    </div>

</div>


In the styling, note that we have given all divs a red outline to provide visibility. cdApp is going to take up full width and height of the screen. The position property is set to absolute, and the top and left properties are 0.
<style>
    div { outline: 1px solid red;}

    #cdApp
    {
        width: 100%;
        height: 100%;
        position: absolute;
        top: 0;
        left: 0;
    }

</style>


dashboard, notice and space all take up full width, but have varying heights. For notice, I have set text to be aligned center and given a top and bottom border. For space, I have set a translucent orange background.
<style>
    div { outline: 1px solid red;}

    #cdApp
    {
        width: 100%;
        height: 100%;
        position: absolute;
        top: 0;
        left: 0;
    }

    .dashboard
    {
        position: relative;
        width: 100%;
        height: 25%;
    }

    .notice
    {
        position: relative;
        width: 100%;
        height: 3%;
        text-align: center;
        border-top: 1px solid black;
        border-bottom: 1px solid black;
    }

    .space
    {
        position: relative;
        width: 100%;
        height: 72%;
        background-color: rgba(255, 200, 0, 0.1);
    }

</style>


This is what I intend.




Now let us build the dashboard. There are basically two sets of controls - one for each object.
<div class="dashboard">
    <fieldset>
        <legend>Obj A</legend>
    </fieldset>

    <fieldset>
        <legend>Obj B</legend>
    </fieldset>

</div>


For neatness, we will style fieldsets by fixing widths and heights, floating left and making sure the display property is set to block.
.space
{
    position: relative;
    width: 100%;
    height: 72%;
    background-color: rgba(255, 200, 0, 0.1);
}

fieldset
{
    width: 20em;
    height: 10em;
    float: left;
    display: block;
}


Each fieldset will have four range inputs - for left margin, top margin, width and height. Note the values and attributes - these are going to reflect the initial values for the two objects.
<div class="dashboard">
    <fieldset>
        <legend>Obj A</legend>
        <br /><label>Obj A Left</label><input type="range" min="0" max="300" value="10" step="1">
        <br /><label>Obj A Top</label><input type="range" min="0" max="300" value="10" step="1">
        <br /><label>Obj A Width</label><input type="range" min="0" max="300" value="100" step="1">
        <br /><label>Obj A Height</label><input type="range" min="0" max="300" value="100" step="1">

    </fieldset>

    <fieldset>
        <legend>Obj B</legend>
        <br /><label>Obj B Left</label><input type="range" min="0" max="300" value="50" step="1">
        <br /><label>Obj B Top</label><input type="range" min="0" max="300" value="50" step="1">
        <br /><label>Obj B Width</label><input type="range" min="0" max="300" value="300" step="1">
        <br /><label>Obj B Height</label><input type="range" min="0" max="300" value="300" step="1">

    </fieldset>
</div>


And here are the controls. It doesn't look pretty, but it doesn't need to.




In this next div, add this text. This is supposed to be a visual indicator to tell you if collision has been achieved.
<div class="notice">
    H: |
    V: |
    Collision:

</div>


Not much to look at, but it'll do... for now.




For the final div, this is where your objects are visually represented. Add these divs. They will both be styled using obj, then objA and objB respectively. Do not bother refreshing your browser; you won't see anything because their heights and widths aren't even specified.
<div class="space">
    <div class="obj objA"></div>
    <div class="obj objB"></div>

</div>


Now for some VueJS!

Our intention is to take advantage of VueJS's two-way binding feature. Before that happens, we will need to specify some data and methods. First. use the el property to specify that VueJS will operate in the cdApp div. Then specify data, methods and created as properties. created is a callback which fires off when the page loads.
<script>
    var app = new Vue
    (
        {
            el: "#cdApp",
            data:
            {

            },
            methods:
            {

            },
            created: function()
            {

            }
        }

    );
</script>


In data, we declare v, h and collision. All of them are false by default.
data:
{
    v: false,
    h: false,
    collision: false

},


Then we have the objA and objB properties. Each is an object that has top, left, width and height properties. Note that the values for these properties correspond to the initial values we set for the controls!
data:
{
    v: false,
    h: false,
    collision: false,
    objA: { left: 10, top: 10, width: 100, height: 100},
    objB: { left: 50, top: 50, width: 300, height: 300}

},


Remember the divs we put in the div that has the CSS class space? Let's now specify styles for these divs. They will take their values from the objA and objB object properties.
<div class="space">
    <div class="obj objA" style="left: {{ objA.left }}px; top: {{ objA.top }}px; width: {{ objA.width }}px; height: {{ objA.height }}px;"></div>
    <div class="obj objB" style="left: {{ objB.left }}px; top: {{ objB.top }}px; width: {{ objB.width }}px; height: {{ objB.height }}px;"></div>
</div>


In the CSS, make sure obj has position property set to absolute. For objA, we have a blue outline and for objB, we have a green outline. This is to visually differentiate the two objects; the colors don't matter that much as long as they are both different.
.space
{
    position: relative;
    width: 100%;
    height: 72%;
    background-color: rgba(255, 200, 0, 0.1);
}

.obj
{
    position: absolute;
}

.objA
{
    outline: 1px solid blue;
}

.objB
{
    outline: 1px solid green;
}


fieldset
{
    width: 20em;
    height: 10em;
    float: left;
    display: block;
}


The plot thickens! Now you can see your objects.




Let us now use the v-model attribute and set it to the left property of the objA object. This leverages on VueJS's two-way binding. Thus, when the value of this input changes, the left property of  the objA object will change, and vice versa.
<fieldset>
    <legend>Obj A</legend>
    <br /><label>Obj A Left</label><input type="range" min="0" max="300" value="10" step="1" v-model="objA.left">
    <br /><label>Obj A Top</label><input type="range" min="0" max="300" value="10" step="1">
    <br /><label>Obj A Width</label><input type="range" min="0" max="300" value="100" step="1">
    <br /><label>Obj A Height</label><input type="range" min="0" max="300" value="100" step="1">
</fieldset>


Add this to reflect the value of the left property at any time.
<fieldset>
    <legend>Obj A</legend>
    <br /><label>Obj A Left</label><input type="range" min="0" max="300" value="10" step="1" v-model="objA.left"> {{ objA.left }}
    <br /><label>Obj A Top</label><input type="range" min="0" max="300" value="10" step="1">
    <br /><label>Obj A Width</label><input type="range" min="0" max="300" value="100" step="1">
    <br /><label>Obj A Height</label><input type="range" min="0" max="300" value="100" step="1">
</fieldset>


Now adjust the value of the first slider. The number should change! And notice that since we tied the left property of the objA object to the left property of the blue square, it moves when you change the value!




Do the same for all the other inputs. They should control the left, top, width and height properties of the objA and objB objects accordingly.
<fieldset>
    <legend>Obj A</legend>
    <br /><label>Obj A Left</label><input type="range" min="0" max="300" value="10" step="1" v-model="objA.left"> {{ objA.left }}
    <br /><label>Obj A Top</label><input type="range" min="0" max="300" value="10" step="1" v-model="objA.top"> {{ objA.top }}
    <br /><label>Obj A Width</label><input type="range" min="0" max="300" value="100" step="1" v-model="objA.width"> {{ objA.width }}
    <br /><label>Obj A Height</label><input type="range" min="0" max="300" value="100" step="1" v-model="objA.height"> {{ objA.height }}
</fieldset>

<fieldset>
    <legend>Obj B</legend>
    <br /><label>Obj B Left</label><input type="range" min="0" max="300" value="50" step="1" v-model="objB.left"> {{ objB.left }}
    <br /><label>Obj B Top</label><input type="range" min="0" max="300" value="50" step="1" v-model="objB.top"> {{ objB.top }}
    <br /><label>Obj B Width</label><input type="range" min="0" max="300" value="300" step="1" v-model="objB.width"> {{ objB.width }}
    <br /><label>Obj B Height</label><input type="range" min="0" max="300" value="300" step="1" v-model="objB.height"> {{ objB.height }}
</fieldset>


Now the two objects should change shape and location when you change the values of the inputs!




Detecting Collision

The final thing we are going to do here, is calculate whether objA and objB have collided. For that, there are two components - the display and the calculation. The display is fairly straightforward. Just create the getCollision() method. The prop parameter is what's used to determine what data we examine. By default, return the string "No".
methods:
{
    getCollision: function(prop) {
        return "No";
    }

},


However, if the property in Vue's data is true, return "Yes".
methods:
{
    getCollision: function(prop) {
        if (this[prop]) return "Yes";
        return "No";
    }
},


In the HTML, add these calls to the getCollision() method.
<div class="notice">
    H: {{ getCollision('h') }} |
    V: {{ getCollision('v') }} |
    Collision: {{ getCollision('collision') }}
</div>


And now you see the strings "No", in the display. Even though the two objects are obviously collided. That's because all the data is false right now and we have no mechanism to change those values. That will be fixed with the introduction of detectCollision()!




First, create the method.
methods:
{
    getCollision: function(prop) {
        if (this[prop]) return "Yes";
        return "No";
    },
    detectCollision: function() {

    }

},


Here, we create the variables h, v and collided and set them to false. Then at the end, we set collided to true if both h and v are true.
detectCollision: function() {
    var collided = false;
    var h = false;
    var v = false;

    if (h && v) collided = true;

}


After that, we set the h, v and collided properties to have the same values as their counterparts in this method.
detectCollision: function() {
    var collided = false;
    var h = false;
    var v = false;

    if (h && v) collided = true;

    this.h = h;
    this.v = v;
    this.collision = collided;

}


This next part is for us to calculate h and v. h is the horizontal, and v is the vertical. collided can only be true when both h and v are true, which means objA and objB's horizontal and vertical coordinates both intersect.

So we check this. The left property of objA has to fall between the left property of objB and its rightmost corner (which is objB's left property plus its width property). If that is true, h is true.
detectCollision: function() {
    var collided = false;
    var h = false;
    var v = false;

    if (this.objA.left >= this.objB.left && this.objA.left <= this.objB.left + this.objB.width)  h = true;

    if (h && v) collided = true;

    this.h = h;
    this.v = v;
    this.collision = collided;
}


h can also be true if, conversely, the left property of objB falls between the left property of objA and its rightmost corner.
detectCollision: function() {
    var collided = false;
    var h = false;
    var v = false;

    if (this.objA.left >= this.objB.left && this.objA.left <= this.objB.left + this.objB.width)  h = true;
    if (this.objA.left <= this.objB.left && this.objA.left + this.objA.width >= this.objB.left) h = true;

    if (h && v) collided = true;

    this.h = h;
    this.v = v;
    this.collision = collided;
}


Now, for v. If the top property of objA falls between the top property of objB and its bottom (which is the top property of objB plus the height property of objB), then v is true.
detectCollision: function() {
    var collided = false;
    var h = false;
    var v = false;

    if (this.objA.left >= this.objB.left && this.objA.left <= this.objB.left + this.objB.width)  h = true;
    if (this.objA.left <= this.objB.left && this.objA.left + this.objA.width >= this.objB.left) h = true;

    if (this.objA.top >= this.objB.top && this.objA.top <= this.objB.top + this.objB.height) v = true;

    if (h && v) collided = true;

    this.h = h;
    this.v = v;
    this.collision = collided;
}


Also, if the top property of objB falls between the top property of objA and its bottom, then v is true.
detectCollision: function() {
    var collided = false;
    var h = false;
    var v = false;

    if (this.objA.left >= this.objB.left && this.objA.left <= this.objB.left + this.objB.width)  h = true;
    if (this.objA.left <= this.objB.left && this.objA.left + this.objA.width >= this.objB.left) h = true;

    if (this.objA.top >= this.objB.top && this.objA.top <= this.objB.top + this.objB.height) v = true;
    if (this.objA.top <= this.objB.top && this.objA.top + this.objA.height >= this.objB.top) v = true;

    if (h && v) collided = true;

    this.h = h;
    this.v = v;
    this.collision = collided;
}


Make sure detectCollision() is called upon page load.
created: function()
{
    this.detectCollision();
}


And also called whenever any of the input values change. Use the v-on:change attribute.
<fieldset>
    <legend>Obj A</legend>
    <br /><label>Obj A Left</label><input type="range" min="0" max="300" value="10" step="1" v-model="objA.left" v-on:change="detectCollision"> {{ objA.left }}
    <br /><label>Obj A Top</label><input type="range" min="0" max="300" value="10" step="1" v-model="objA.top" v-on:change="detectCollision"> {{ objA.top }}
    <br /><label>Obj A Width</label><input type="range" min="0" max="300" value="100" step="1" v-model="objA.width" v-on:change="detectCollision"> {{ objA.width }}
    <br /><label>Obj A Height</label><input type="range" min="0" max="300" value="100" step="1" v-model="objA.height" v-on:change="detectCollision"> {{ objA.height }}
</fieldset>

<fieldset>
    <legend>Obj B</legend>
    <br /><label>Obj B Left</label><input type="range" min="0" max="300" value="50" step="1" v-model="objB.left" v-on:change="detectCollision"> {{ objB.left }}
    <br /><label>Obj B Top</label><input type="range" min="0" max="300" value="50" step="1" v-model="objB.top" v-on:change="detectCollision"> {{ objB.top }}
    <br /><label>Obj B Width</label><input type="range" min="0" max="300" value="300" step="1" v-model="objB.width" v-on:change="detectCollision"> {{ objB.width }}
    <br /><label>Obj B Height</label><input type="range" min="0" max="300" value="300" step="1" v-model="objB.height" v-on:change="detectCollision"> {{ objB.height }}
</fieldset>


Now see, when you move the objects around, the display shows if h is true...




...or v is true...




...or both! In which case, collided is true!




I should also mention you'll want to turn the red outline off.
div { outline: 0px solid red;}


There you go.




Finally

This web tutorial wasn't pretty, and certainly has very limited mainstream use. But it can be a useful tool for testing collision.

Till our paths collide!
T___T

No comments:

Post a Comment