Sokoban example (Part 2) ‐ Player controls - EmanuelG-Gaming/Icosahedron GitHub Wiki
In the previous chapter, we've looked at two different ways to render a tiled level: a method that uses separate meshes for each tile, and a method that batches each tile into one map that can be rendered using a draw call. This time, we'll focus more on the gameplay side of things. Remember how I've said that arrays store discrete information? Well, the player controls can be implemented in two ways:
- Using continuous coordinates (floating-point values) - this might require some more complicated collision detection techniques
- Using discrete coordinates (integer values) - simpler, but limits our collision shapes to be the shape of a tile.
The latter seems to be a better option for a turn-based game, while the first is used more for real-time games. For our particular purposes, let's think of Sokoban as a turn-based puzzle game.
Because we're limited to integer coordinates, the collision detection becomes a tile-is-solid check for each turn. This means that the player cannot move to a wall, but can push boxes that they themselves cannot be pushed further if there's a wall in a direction.
To do this, let's declare an integer vector for the player's position:
ic::Vec2i playerPosition = { 2, 1 };
We'll also make the camera follow the player, along with the rendering:
camera.position.x() = playerPosition.x();
camera.position.y() = playerPosition.y();
// When rendering:
renderer.draw_rectangle(tileBatch, tileAtlas.get_entry("white"), playerPosition.x(), playerPosition.y(), 0.5f, 0.5f, ic::Colors::yellow);
Because this is a turn-based game, the app calls the bool turn(int newPlayerPositionX, int newPlayerPositionY) function when the player tries to move. The boolean flag tells us if the player succeeds or fails doing so, and is implemented as follows:
bool turn(int newPlayerPositionX, int newPlayerPositionY) {
if (is_wall_at(newPlayerPositionX, newPlayerPositionY)) {
return false;
}
if (is_box_at(newPlayerPositionX, newPlayerPositionY)) {
int dx = newPlayerPositionX - playerPosition.x();
int dy = newPlayerPositionY - playerPosition.y();
int nextPosX = newPlayerPositionX + dx;
int nextPosY = newPlayerPositionY + dy;
// Trivial case: don't push if there's a second box
if (is_box_at(nextPosX, nextPosY) || is_wall_at(nextPosX, nextPosY)) {
return false;
}
}
return true;
}
The is_<something>_at methods are mostly applications of the y * WIDTH + x formula, but we can place boundary conditions so that the player cannot push boxes outside the level rectangle, because it would most likely cause a crash. However, addings walls on the perimeter of the level might save us some time on the computation, as we don't have to place if conditions around those functions.
To add player movement, one can do that via the keyboard (WASD - arrow keys) or by using the cursor at desired locations around the player. The first thing we'll gonna do is add a function that moves the player and the box (if there is) by a specific amount, depending on the turn:
void move_by(int dx, int dy) {
int nextPosX = playerPosition.x() + dx;
int nextPosY = playerPosition.y() + dy;
if (turn(nextPosX, nextPosY)) {
playerPosition.x() = nextPosX;
playerPosition.y() = nextPosY;
if (is_box_at(playerPosition.x(), playerPosition.y())) {
boxes[playerPosition.y() * WIDTH + playerPosition.x()] = 0;
boxes[(playerPosition.y() + dy) * WIDTH + (playerPosition.x() + dx)] = 1;
}
}
}
Then we implement the keyboard controls themselves:
ic::KeyboardController *keyboard = new ic::KeyboardController();
keyboard->add_key_down_action([this]() {
move_by(0, 1);
}, KEY_W);
// Rest of the ASD keys...
keyboard->add_key_down_action([this]() {
move_by(0, 1);
}, KEY_UP);
keyboard->add_key_down_action([this]() {
move_by(-1, 0);
}, KEY_LEFT);
keyboard->add_key_down_action([this]() {
move_by(0, -1);
}, KEY_DOWN);
keyboard->add_key_down_action([this]() {
move_by(1, 0);
}, KEY_RIGHT);
ic::InputHandler::get().add_input(keyboard, "WASD");
This is nice and all, but what about the cursor? This latter method takes into account the scaling and position of the camera:
bool mouseHeld = false;
ic::MouseController *controller = new ic::MouseController();
controller->add_mouse_down_action([this]() {
ic::Vec2i p = ic::InputHandler::get().find_mouse("mouse")->get_cursor_position();
ic::Vec2f pos = { p.x() * 1.0f, p.y() * 1.0f };
ic::Vec2f levelPos = camera.unproject(pos);
if (point_inside_rectangle(levelPos.x(), levelPos.y(), playerPosition.x(), playerPosition.y(), 1.5f, 1.5f)) {
mouseHeld = true;
}
});
controller->add_mouse_up_action([this]() {
if (!mouseHeld) return;
ic::Vec2i p = ic::InputHandler::get().find_mouse("mouse")->get_cursor_position();
ic::Vec2f pos = { p.x() * 1.0f, p.y() * 1.0f };
ic::Vec2f levelPos = camera.unproject(pos);
for (int i = 0; i < 4; i++) {
ic::Vec2i dir = cardinalDirections[i];
int nextPosX = playerPosition.x() + dir.x();
int nextPosY = playerPosition.y() + dir.y();
if (point_inside_rectangle(levelPos.x(), levelPos.y(), nextPosX, nextPosY, 0.25f, 0.25f)) {
move_by(dir.x(), dir.y());
break;
}
}
mouseHeld = !mouseHeld;
});
ic::InputHandler::get().add_input(controller, "mouse");
Then we draw the graphical indicators when the mouse is held down:
// Mouse hold indicator
if (mouseHeld) {
for (int i = 0; i < 4; i++) {
// The four unit vectors that point to the semiaxes of the Cartesian plane:
// vec2(1, 0),
// vec2(0, 1),
// vec2(-1, 0),
// vec2(0, -1)
ic::Vec2i dir = cardinalDirections[i];
int nextX = playerPosition.x() + dir.x();
int nextY = playerPosition.y() + dir.y();
if (!turn(nextX, nextY)) continue;
renderer.draw_rectangle(tileBatch, tileAtlas.get_entry("circle"), nextX, nextY, 0.25f, 0.25f);
}
}
We are almost close, but did you notice how choppy the player movement is? The camera seems to change position instantly as the player walks. One solution is to linearly interpolate the player's and the boxes' positions.