an approach to hitboxes
Apologies for the really long post. I'm going to bookmark this in case I want to reference it in the future.
First, some definitions are in order. It's tempting to just call everything a hitbox, because a box that hits or takes hits could be called a hitbox technically. So these are the definitions I'll be using to keep things separate:
hitbox - a rectangular region that hits other things, typically hurtboxes, blockboxes and level geometry
hurtbox - a rectangular region that gets hit by other things and takes damage
blockbox - a rectangular region that blocks other things without taking damage (i.e.: a shield)
I'm going to walk through creating the code for these. For my purposes, I'll be using GameMaker:Studio, but I'll try to explain the process in a general enough way that the methods can be used in other suites.
To start, I'm going to set up a controller object. This will handle debug draw as well track time and similar phenomena. It doesn't need a sprite, but I'm going to set its draw depth to a large negative value so it will be closer to the camera than anything else in the room. For simplicity's sake, I'm going to call the controller object "k". This is so that references to the controller's variables will be short to type. Most any letter of the alphabet will do, but I've arrived at k over the years.
Code:
/// k Creation code
time = 0;
debug = true;
/// k Begin Step code
time++;
Next, I'm going to create a new object for each the hitbox, hurtbox and blockbox types. I'm going to give the sprite mask a square sprite -- it doesn't matter the size, just as long as you remember it. Mine is 32x32. I'm also going to uncheck Visible on each one. Hitboxes shouldn't be visible except in debug mode, which I'll cover in just a bit.
Hurtboxes and blockboxes are going to be passive agents, with actions happening to them, so they'll be easier to implement than the hitboxes. I'm going to start with the blockbox. The function below sets up a blockbox and its spatial coordinates as well as linking it to the parent object and its relative position.
Code:
/// doBlockbox(x1, y1, x2, y2)
with (instance_create(argument0, argument1, blockbox)) {
daddy = other.id;
image_xscale = (argument2-argument0)/32;
image_yscale = (argument3-argument1)/32;
xOffset = x-daddy.x;
yOffset = y-daddy.y;
return self.id;
}
The xOffset and yOffset variables need to be handled manually, so we'll make another script to handle that. This function should be called after the movement is applied in the parent object to keep the two in sync.
Code:
/// updateBox(box id)
with (argument0) {
x = daddy.x+xOffset;
y = daddy.y+yOffset;
}
Finally, for a little bit of garbage collecting, the blockbox should be cleared if its parent is destroyed. The parent might have already cleared it, so this is just a safety measure.
Code:
/// blockbox Step code
if (!instance_exists(daddy)) {
instance_destroy();
}
Next I'm going to make a hurtbox. It's passive like the blockbox, but should also have times where it's inert to further damage so it's not being penalized a multiple of times in subsequent frames. So first we're going to make a script for creating hurtboxes:
Code:
/// doHurtbox(x1, y1, x2, y2)
with (instance_create(argument0, argument1, hurtbox)) {
daddy = other.id;
image_xscale = (argument2-argument0)/32;
image_yscale = (argument3-argument1)/32;
xOffset = x-daddy.x;
yOffset = y-daddy.y;
return self.id;
}
It's identical to the blockbox one. However, the create and step codes have a few extra lines:
Code:
/// hurtbox Create code
active = true;
resetTime = 0;
/// hurtbox Step code
if (!active && resetTime <= k.time) {
active = true;
}
if (!instance_exists(daddy)) {
instance_destroy();
}
That's that for the hurtbox and blockbox, so now I'm going to make a function to spawn a new hitbox. In addition to setting up the spatial coordinates and parentage, this function adds duration and damage properties. I might cover other features like directions and special types later, but I wanted to keep this relatively simple.
Code:
/// doHitbox(x1, y1, x2, y2, duration, damage)
with (instance_create(argument0, argument1, hitbox)) {
daddy = other.id;
sugardaddy = noone;
image_xscale = (argument2-argument0)/32;
image_yscale = (argument3-argument1)/32;
if (argument4 > 0) {
dieTime = k.time+argument4;
} else {
dieTime = 0;
}
xOffset = x-daddy.x;
yOffset = y-daddy.y;
damage = argument5;
return self.id;
}
The hitbox object's create code is very simple. It's just setting up some flags for in case the hitbox gets temporarily interrupted by a blockbox.
Code:
/// hitbox Create code
blocked = false;
resetTime = 0;
The step code for the hitbox is very complicated, so I'm going to walk through it slowly. First, rather simply, we want to clear the blocked flag if its time is up. We also want to purge the hitbox if it's no longer needed. You don't want stray hitboxes lying around and possibly damaging things unnecessarily. So start the hitbox step code with this:
Code:
/// hitbox Step code
if (blocked && resetTime <= k.time) {
blocked = false;
}
if ((dieTime != 0 && dieTime <= k.time) || !instance_exists(daddy)) {
instance_destroy();
exit;
}
Next, I want to add some local variables to the code. The scope of these temporary variables is only this code block, and everywhere in this code block, which makes them useful for holding onto temporary information that's not useful elsewhere and for passing values between nested instances. Although GameMaker doesn't require it, it's good practice to declare your temporary variables at the top of the code block.
Code:
/// hitbox Step code
var l, r, dmg, _x, _y;
l = bbox_left;
r = bbox_right;
dmg = damage;
if (blocked && resetTime <= k.time) {
blocked = false;
}
if ((dieTime != 0 && dieTime <= k.time) || !instance_exists(daddy)) {
instance_destroy();
exit;
}
The bbox_ variables (top, left, right, bottom) are dynamically updated when you change the x, y, image_xscale, image_yscale and image_angle variables of an object. They map to the smallest axis-aligned bounding box coordinates that can wrap your object. So they're very useful for collision checks.
The first thing I'm going to do is check for collisions against level geometry. You don't have to do this if you don't want attacks to collide with the level, but I think it adds to the sense of place. I've put all my collide-able objects under a parent object simply called "c". If you haven't already noticed, I really like short names on things. We're going to add the following snippet to the hitbox step code:
Code:
// hitbox Step code continued
with (c) {
if (collision_rectangle(bbox_left, bbox_top, bbox_right, bbox_bottom, other.id, false, false)) {
_x = mean(l, r);
if (other.bbox_left >= bbox_left) {
l = max(other.bbox_left, bbox_right+1);
_x = l;
} else if (other.bbox_right <= bbox_right) {
r = min(other.bbox_right, bbox_left-1);
_x = r;
}
_y = mean(other.bbox_top, other.bbox_bottom);
with (other.daddy) {
hitX = _x;
hitY = _y;
event_user(3);
}
if (l != other.bbox_left || r != other.bbox_right) {
with (other) {
x = l;
image_xscale = (r-l)/32;
}
if (l >= r) {
with (other) {
instance_destroy();
}
}
} else {
with (other) {
instance_destroy();
}
}
}
}
Most of what that code does is shrink the hitbox so that it's no longer inside of a wall. The other thing it does is fire a call-back user event in the parent of the hitbox. Suppose the parent is a swordfighter swinging her sword, this call-back event tells the swordfighter that her sword has struck something solid and she may, if she's so programmed, recoil from the event.
For all objects using hitboxes and hurtboxes, I've reserved user events 1 through 4. In order, these events are for (1) hurtbox damaged, (2) blockbox struck, (3) hitbox interrupted, and (4) hitbox hit some hurtbox. Not every object needs to have a user event for each of these. For example, objects without shields will never get user event #2 called off, and objects that don't attack won't have #3 or #4 called off, or maybe an object with a hitbox is better if it doesn't recoil at all.
After getting out of the way of the walls, hitboxes should check to see if they are blocked by shields. For that, let's add the following snippet to the hitbox step code:
Code:
// hitbox Step code continued
if (!blocked) {
with (blockbox) {
if (daddy != other.daddy && collision_rectangle(bbox_left, bbox_top, bbox_right, bbox_bottom, other.id, false, false)) {
other.blocked = true;
other.resetTime = k.time+10;
_x = mean(bbox_left, bbox_right, other.bbox_left, other.bbox_right);
_y = mean(bbox_top, bbox_bottom, other.bbox_top, other.bbox_bottom);
with (daddy) {
hitX = _x;
hitY = _y;
event_user(2);
}
with (other.daddy) {
hitX = _x;
hitY = _y;
event_user(3);
}
}
}
}
Finally, if it's not yet blocked, we need to check if the hitbox is hitting any hurtboxes. This next snippet assumes that any parent object with a hurtbox will have a health variable called hp. If you don't have that, or have it called something else, that's what you need to change. This is the last part of the hitbox step code, the part that actually deals damage.
Code:
// hitbox Step code continued
if (!blocked) {
with (hurtbox) {
if (active && daddy != other.daddy && daddy != other.sugardaddy && collision_rectangle(bbox_left, bbox_top, bbox_right, bbox_bottom, other.id, false, false)) {
active = false;
resetTime = max(k.time+10, other.dieTime);
_x = mean(bbox_left, bbox_right, other.bbox_left, other.bbox_right);
_y = mean(bbox_top, bbox_bottom, other.bbox_top, other.bbox_bottom);
with (other) {
with (daddy) {
hitX = _x;
hitY = _y;
event_user(4);
}
}
with (daddy) {
if (dmg == 0) {
hp = 0;
} else {
hp -= dmg;
}
hitX = _x;
hitY = _y;
event_user(1);
}
}
}
}
Let's go back to the controller object. I've set the debug flag to start at true, and you can make it toggle however you like. Probably this mode shouldn't be exposed in the final game, but it's invaluable while working on the project. In the following code, I'm going to have the hitboxes drawn as a translucent rectangle outlined by an opaque border to clearly demarcate the given zone but still expose underlying graphics.
Code:
/// k Draw code
if (debug) {
draw_set_color($FF5F00);
with (blockbox) {
draw_set_alpha(0.2);
draw_rectangle(bbox_left, bbox_top, bbox_right, bbox_bottom, false);
draw_set_alpha(1);
draw_rectangle(bbox_left, bbox_top, bbox_right, bbox_bottom, true);
}
draw_set_color($00FF3F);
with (hurtbox) {
draw_set_alpha(0.2);
draw_rectangle(bbox_left, bbox_top, bbox_right, bbox_bottom, false);
draw_set_alpha(1);
draw_rectangle(bbox_left, bbox_top, bbox_right, bbox_bottom, true);
}
draw_set_color($FF007F);
with (hitbox) {
draw_set_alpha(0.2);
draw_rectangle(bbox_left, bbox_top, bbox_right, bbox_bottom, false);
draw_set_alpha(1);
draw_rectangle(bbox_left, bbox_top, bbox_right, bbox_bottom, true);
}
}
And that should be all! As a review, to spawn a hitbox or other you need to call doHitbox(). To keep the box in sync with the parent after movement, call updateBox(). To react to events, set up actions in user events 1 4.
Here's an example project file with the code implemented:
Google Drive link