How to achieve smooth tile-based movement with C++ and SDL?

Started by
10 comments, last by Khatharr 6 years, 10 months ago

I've been wondering how to work out the logic for creating smooth tile-based movement for a player.

These are the functions being used to handle player movement as of now.  (Input and animation is already being handled, but not in the code.  I just want to show the basics of how the logic works for now.)

void Player::startMovingUp() {
    mVelocityY = -kWalkSpeed;
    mDirectionFacing = UP;
}

void Player::startMovingDown() {
    mVelocityY = kWalkSpeed;
    mDirectionFacing = DOWN;
}

void Player::startMovingLeft() {
    mVelocityX = -kWalkSpeed;
    mDirectionFacing = LEFT;
}

void Player::startMovingRight() {
    mVelocityX = kWalkSpeed;
    mDirectionFacing = RIGHT;
}

void Player::stopMoving() {
    mVelocityX = 0;
    mVelocityY = 0;
}

All of these values are updated in the player update function. (Everything is time-based, rather than frame-based. That's where the elapsed_time_ms variable comes in.)

void Player::update(int elapsed_time_ms) {
    mX += round(mVelocityX * elapsed_time_ms);
    mY += round(mVelocityY * elapsed_time_ms);
}

This is perfectly fine to move the player around. But I want my player to be moving on a tiled map, locked to a grid, rather than just moving freely like this.

Each tile in the game have fixed dimensions of 16 by 16 pixels.  This may be important, so I'm just leaving it here.

Advertisement

Well there are a few ways you can do this. One way is quite simply loop over the tiles performing bound tests based on the player position, and pass on the positional data stored in the player tile, and set that directly to the players pos. The other way is to just divide the pixel position of the player by the tile fixed height, and width like so


x = 100 / 16 = 3

y = 100 / 16 = 3

This you can use as an index into the tilemap, or however you're storing this to get the positional data directly, and set it to the player.

EDIT

Ah, I misinterpreted. I thought you were looking to have player snapping to individual grid cells

Simply interpolate between the old and new positions in the grid and ignore input during the animation.  Perhaps something like:


void Player::update(float dt) {
	if ( this->move_state == MoveState::STOPPED ) {
		int2 dir = this->process_move_input();
		if ( dir ) {
			this->move_state = MoveState::MOVING;
			this->move_timer = this->move_speed; // in tiles per second
			this->old_pos = this->pos;
			this->pos += dir;
		}
	}
	else if ( this->move_state == MoveState::MOVING ) {
		this->move_timer -= dt;
		if ( this->move_timer <= 0 ) {
			this->move_timer = 0;
			this->move_state = MoveState::STOPPED;
		}
	}
}

void Player::draw() {
	int2 pos_px;

	if ( this->move_state == MoveState::MOVING ) {
		// move_timer goes from 1 to 0, so math on positions is reversed from the traditional A→B interpolation
		pos_px = this->pos + float2(this->old_pos - this->pos) * (this->move_timer / this->move_speed) * TILE_SIZE_PX;
	}
	else {
		pos_px = this->pos * TILE_SIZE_PX;
	}

	// draw avatar at pos_px
}

 

In the past I've actually done away with the "moving"/"stopped" state indicator and just checked whether the interpolation value was in the normalized range.


if(move_timer > 0) {
  moving
}
else {
  listening to input
}
void hurrrrrrrr() {__asm sub [ebp+4],5;}

There are ten kinds of people in this world: those who understand binary and those who don't.
On 6/19/2017 at 6:24 PM, Khatharr said:

In the past I've actually done away with the "moving"/"stopped" state indicator and just checked whether the interpolation value was in the normalized range.



if(move_timer > 0) {
  moving
}
else {
  listening to input
}

This is by far the most elegant and simple approach in my experience, but wouldn't this result in a single frame stutter on tile arrival if a direction input is held? Input listening should happen first and it should be two if statements instead of an if/else (right? I'm not tripping am I?)

That's correct.

It doesn't really bother me with 2D sprite animation, but I used this technique in a 3D rendered game and the skip was a lot more noticeable.

For clarity, what you end up with is:


if(move_timer <= 0) {
  listening to input
}
if(move_timer > 0) {
  moving
}

 

void hurrrrrrrr() {__asm sub [ebp+4],5;}

There are ten kinds of people in this world: those who understand binary and those who don't.

I've actually done something using two boolean variables.  isMoving checks whether the player is currently moving, if they player is moving, no input will be taken.  The other variable atTarget checks whether the player has reached the target tile or not.  If the player is atTarget and isMoving, the player will stop (Meaning isMoving and atTarget are both set to false).  This works ok, but it's buggy and results with a frame skip, making the player look jittery.

This is basically how the logic works.  It has to really be fixed up, but I hope you understand where I want to go with it.


void update(int elapsed_time_ms) {
  if (isWalking == true && atTarget == false) {
    // Update player movement
  }
  
  // Check if the player has reached the target
  if (player_x == targetX && player_y == targetY) atTarget == true;
  
  // Stop movement
  if (atTarget == true && isWalking == true) // Stop walking (isWalking == false)
}

// Just one direction for a basic example
void moveRight() {
  if (!isMoving) {
    isMoving = true;
    atTarget = false;
    targetX += 16;
    velocityX = speed;
  }
}

This code really has to be cleaned up.  Movement is buggy and it causes the player animation to stutter every time they walk over a tile.  Also, if the player goes even 1 pixel over the target, they continue moving without being able to stop.  Which kinda sucks.

Since you're describing a 2D tiled world, I'd move away from the dt/elapsed_time approach and go with a fixed timestep. This way you can express speed in terms of "pixels per update", and just make that number a factor of your tile width.


enum Direction { NONE, NORTH, SOUTH, EAST, WEST };

class Actor {
  Direction facing = SOUTH;
  int move_time = 0;
  int framesToCrossOneTile = 8; //(use a factor of tile width so you don't get slop)
  int speed = tileWidth / framesToCrossOneTile;
  
  void update() { //use a fixed timestep for this kind of game - it will make your life much easier
    if(move_time <= 0) { //process input
      Direction inputDir = getInputDirection();
      if(inputDir == facing) { move_time = framesToCrossOneTile; }
      else { facing = inputDir; }
    }
    if(move_time > 0) { //process motion
      move_time--;
      
      switch(facing) {
      case NORTH: y -= speed; break;
      case OTHER_DIRS: .... break;
      }
    }
  }

};

For a simple fixed timestep you can go to your top-level loop and do something like:


const int frameDuration = 17;
const int maxTimePerFrame = 200;
int accumulatedTime = 0;

while(game_running) {
  accumulatedTime += getDT();
  
  //if the game loses focus or processing is delayed for some reason you don't
  //want to suddenly jump way ahead, so limit the maximum speed
  if(accumulatedTime > maxTimePerFrame) { accumulatedTime = maxTimePerFrame; }

  //update the simulation using fixed time steps as many times as necessary in
  //order to keep pace with the clock
  while(accumulatedTime >= frameDuration) {
    //note that "excess" time will be retained
    accumulatedTime -= frameDuration;
    update();
  }
  
  draw();
}

 

If you simply can't live with a rate that's directly divisible by the tile width then you need to do some gymnastics to keep things running smoothly. In that case you're better off setting a value indicating your end coordinate (either x or y - you don't need both) in pixels. As you move the character you check to see whether you're at or beyond that point. If so then immediately check the input to see if the motion should continue (just change the endpoint) or if you should snap the position to the desired endpoint. In that case a 'moving' bool is the better approach.

Make sense?

void hurrrrrrrr() {__asm sub [ebp+4],5;}

There are ten kinds of people in this world: those who understand binary and those who don't.

I like to do an interpolation between tile positions.  You can Lerp it or do something fancier if you like.  It also makes it trivial to speed up, and things remain at a constant time if you decide to resize your tiles.

I'd be fine with using the fixed timestep method that you're talking about.  It just doesn't seem like the standard though.  Do many people use this?  If so, how do they manage to keep up a constant framerate on lower end systems that may require a lower one?  You can't just switch the frame rate if everything is based on frames, you'd have to change a lot of things in the game before it ran at the proper speed.

This topic is closed to new replies.

Advertisement