21 Mar 2010 ruoso   » (Journeyer)

Writing games in Perl - Part 3 - Collision detection

Following posts 1 and 2 on the subject of writing games in Perl, now we are going to add colision.

The idea is quite simple, we are going to add another square to the game, and when the ball hits it, it will change direction. Following the way we were working, I'm going to add another object, called Wall.

The first thing is modelling our wall, which will be a rectangle, so it has the following attributes.

package Wall;
use Moose;
use Util;
use SDL::Rect;

# Position - vertical and horizontal
has pos_v => (is => 'rw', isa => 'Num', default => 0);
has pos_h => (is => 'rw', isa => 'Num', default => 0.12);

# Width and height
has width => (is => 'rw', isa => 'Num', default => 0.005);
has height => (is => 'rw', isa => 'Num', default => 0.4);

Unlike the ball, a wall doesn't move, so we don't need a time_lapse method, but we still have the get_rect and draw methods.

sub get_rect {
  my ($self, $height, $width) = @_;

  my $inverted_v = ($height - ($self->pos_v + $self->height));

  my $x = Util::m2px( $self->pos_h );
  my $y = Util::m2px( $inverted_v );
  my $h = Util::m2px( $self->height );
  my $w = Util::m2px( $self->width );

  my $screen_w = Util::m2px( $width );
  my $screen_h = Util::m2px( $height );

  if ($x  $screen_w) {
    $w -= ($x + $w) - $screen_w;
  }

  if ($y  $screen_h) {
    $h -= ($y + $h) - $screen_h;
  }

  return SDL::Rect->new( $x, $y, $w, $h );
}

my $color;
sub draw {
  my ($self, $surface, $height, $width) = @_;
  unless ($color) {
    $color = SDL::Video::map_RGB
      ( $surface->format(),
        255, 0, 0 ); # red
  }
  SDL::Video::fill_rect
      ( $surface,
        $self->get_rect($height, $width),
        $color );
}

See the first post for more details on the get_rect and draw codes.

Now we need to add our wall to the game, that will mean a simple change in our main code, first we need to load the Wall module, then initialize the Wall just after initializing the ball, and finally calling the draw method just after calling the same method on ball.

use Wall;
my $wall = Wall->new;
$wall->draw($app, $height, $width);

If you tried to run the code at this point, you'll notice you won't see any wall. That happens because the application is only updating the screen where the ball is passing. The Wall needs to be drawn a first time, and the screen needs to be updated at that position. This prevents us from re-updating the wall rect everytime, which is pointless, since the wall is static - that code goes right before the main loop.

# let's draw the wall for the first time.
$wall->draw($app, $height, $width);
SDL::Video::update_rects
  ( $app,
    $wall->get_rect($height, $width) );

Now we need to check for a collision. This should happen in the place of the time_lapse call. Note that while I neglected math in the movement part, here it's more complicated because I need to react in a reasonable manner depending on how the collision happened. But as we're working in Perl and we have CPAN, I can just use Collision::2D (zpmorgan++ for working on this and pointing me in the correct direction)

If you don't have the Collision::2D module installed, just call

# cpan Collision::2D

If you're not sure wether you have it or not, just try installing it anyway, it will suceed if the module is already installed.

use Collision::2D ':all';
sub collide {
    my ($ball, $wall, $time) = @_;
    my $rect = hash2rect({ x => $wall->pos_h, y => $wall->pos_v,
                           h => $wall->height, w => $wall->width });
    my $circ = hash2circle({ x => $ball->cen_h, y => $ball->cen_v,
                             radius => $ball->radius,
                             xv => $ball->vel_h,
                             yv => $ball->vel_v });
    return dynamic_collision($circ, $rect, interval => $time);
}

I assumed an API that wasn't currently implemented in our Ball object, so I changed the ball so that pos_v, pos_h, width and height return the bounding dimensions for the ball I won't put the code in the post, but you can check at the github repo.

Okay, now it's time to check for collisions and act accordingly. Again, we'll assume an 100% efficient collision, so the code looks like:

  my $frame_elapsed_time = ($now - $oldtime)/1000;
  if (my $coll = Util::collide($ball, $wall, $frame_elapsed_time)) {
      # need to place the ball in the result after the bounce given
      # the time elapsed after the collision.
      my $collision_remaining_time = $frame_elapsed_time - $coll->time;
      my $movement_before_collision_h = $ball->vel_h * $coll->time;
      my $movement_before_collision_v = $ball->vel_v * $coll->time;
      my $movement_after_collision_h = $ball->vel_h * $collision_remaining_time;
      my $movement_after_collision_v = $ball->vel_v * $collision_remaining_time;
      if ($coll->axis eq 'x') {
          $ball->cen_h(($ball->cen_h + $movement_before_collision_h) +
                       ($movement_after_collision_h * -1));
          $ball->cen_v($ball->cen_v +
                       $movement_before_collision_v +
                       $movement_after_collision_v);
          $ball->vel_h($ball->vel_h * -1);
      } elsif ($coll->axis eq 'y') {
          $ball->cen_v(($ball->cen_v + $movement_before_collision_v) +
                       ($movement_after_collision_v * -1));
          $ball->cen_h($ball->cen_h +
                       $movement_before_collision_h +
                       $movement_after_collision_h);
          $ball->vel_v($ball->vel_v * -1);
      } elsif (ref $coll->axis eq 'ARRAY') {
          my ($xv, $yv) = @{$coll->bounce_vector};
          $ball->cen_h(($ball->cen_h + $movement_before_collision_h) +
                       ($xv * $collision_remaining_time));
          $ball->vel_h($xv);
          $ball->cen_v(($ball->cen_v + $movement_before_collision_v) +
                       ($yv * $collision_remaining_time));
          $ball->vel_v($yv);
      } else {
          warn 'BAD BALL!';
          $ball->cen_h(($ball->cen_h + $movement_before_collision_h) +
                       ($movement_after_collision_h * -1));
          $ball->cen_v(($ball->cen_v + $movement_before_collision_v) +
                       ($movement_after_collision_v * -1));
          $ball->vel_h($ball->vel_h * -1);
          $ball->vel_v($ball->vel_v * -1);
      }
  } else {
      $ball->time_lapse($oldtime, $now, $height, $width);
  }

Okay, the above code was a bit complicated, let's brake it down...

  my $frame_elapsed_time = ($now - $oldtime)/1000;

Collision::2D works with time in seconds, it calculates if the two objects would have collided during the duration of this frame.

  if (my $coll = Util::collide($ball, $wall, $frame_elapsed_time)) {
     ...
  } else {
      $ball->time_lapse($oldtime, $now, $height, $width);
  }

Now we check if there was a collision. If not, we just proceed to the regular code that calculates the new position for the ball.

      my $collision_remaining_time = $frame_elapsed_time - $coll->time;
      my $movement_before_collision_h = $ball->vel_h * $coll->time;
      my $movement_before_collision_v = $ball->vel_v * $coll->time;
      my $movement_after_collision_h = $ball->vel_h * $collision_remaining_time;
      my $movement_after_collision_v = $ball->vel_v * $collision_remaining_time;

In the case we have a collision, Collision::2D tells us when and how it happened. In order to implement the bouncing, I also calculate how far they would have been proceeded before and after the collision.

      if ($coll->axis eq 'x') {
          ...
      } elsif ($coll->axis eq 'y') {
          ...
      } elsif (ref $coll->axis eq 'ARRAY') {
          ...
      } else {
          ...
      }

The method that describes how the collision happened is "axis". If it was a purely horizontal colision, it will return 'x', if it was purely vertical, it will return 'y', if it was mixed, it will return a vector that describes it. In the case of a bug, it will return undef.

          $ball->cen_h(($ball->cen_h + $movement_before_collision_h) +
                       ($movement_after_collision_h * -1));
          $ball->cen_v($ball->cen_v +
                       $movement_before_collision_v +
                       $movement_after_collision_v);
          $ball->vel_h($ball->vel_h * -1);

In the case of perfect horizontal or vertical collision (or bug), we reposition the ball by first calculating where it would be at the time of the collision and then bounce it away - depending on how the collision occurred.

          my ($xv, $yv) = @{$coll->bounce_vector};
          $ball->cen_h(($ball->cen_h + $movement_before_collision_h) +
                       ($xv * $collision_remaining_time));
          $ball->vel_h($xv);
          $ball->cen_v(($ball->cen_v + $movement_before_collision_v) +
                       ($yv * $collision_remaining_time));
          $ball->vel_v($yv);

This last part of the code uses a cool feature for Collision::2D, which returns a bounce information for that collision, which we then use to figure out the correct position after the bounce.

And now we can run our code. I have made some other changes not explained here, because they are just settings that control the behavior. Remember to access the github repo for more details.

Now a small video of the game running.

Syndicated 2010-03-20 22:03:50 from Daniel Ruoso

Latest blog entries     Older blog entries

New Advogato Features

New HTML Parser: The long-awaited libxml2 based HTML parser code is live. It needs further work but already handles most markup better than the original parser.

Keep up with the latest Advogato features by reading the Advogato status blog.

If you're a C programmer with some spare time, take a look at the mod_virgule project page and help us with one of the tasks on the ToDo list!