[Add] Terrain collision and gravity.

Physics and collision system to make players interact with the generated
terraine without falling through the world like lemmings.

It uses a shared bilinear interpolation function for terrain height for
smooth and consistant height queries on the client and server.
This commit is contained in:
Ritchie Cunningham 2025-09-16 22:13:15 +01:00
parent 7ba0b348bc
commit 744c41b8ce
13 changed files with 142 additions and 19 deletions

View File

@ -15,7 +15,7 @@ public:
PlayerBase(void); PlayerBase(void);
void update(double dt); void apply_gravity_and_collision(float terrain_height);
void set_velocity_direction(const InputState& input, const BettolaMath::Vec3& cam_front); void set_velocity_direction(const InputState& input, const BettolaMath::Vec3& cam_front);
void set_position(const BettolaMath::Vec3& pos); void set_position(const BettolaMath::Vec3& pos);
void set_yaw(float yaw) { _yaw = yaw; } void set_yaw(float yaw) { _yaw = yaw; }
@ -24,6 +24,8 @@ public:
const BettolaMath::Vec3& get_position(void) const { return _position; } const BettolaMath::Vec3& get_position(void) const { return _position; }
float get_yaw(void) const { return _yaw; } float get_yaw(void) const { return _yaw; }
void update(double dt);
protected: protected:
unsigned int _id; unsigned int _id;
BettolaMath::Vec3 _position; BettolaMath::Vec3 _position;

View File

@ -0,0 +1,49 @@
#pragma once
#include <cmath>
#include <algorithm>
#include "bettola/game/chunk.h"
namespace BettolaLib {
namespace Game {
namespace Terrain {
/*
* Performs bilinear interpolation on a chunk's heightmap to find
* a smooth height at a given local coord.
*/
inline float get_height_at(const BettolaLib::Game::Chunk& chunk, float local_x, float local_z) {
/*
* Mesh generation uses (WIDTH-1), so coordinates for interpolation
* should be within the range [0, WIDTH-2].
*/
float max_coord = CHUNK_WIDTH-2;
if(local_x < 0 || local_x > max_coord || local_z < 0 || local_z > max_coord) {
int x = std::max(0, std::min((int)local_x, CHUNK_WIDTH - 1));
int z = std::max(0, std::min((int)local_z, CHUNK_HEIGHT - 1));
return chunk.heightmap[z*CHUNK_WIDTH+x] * 5.0f; /* Apply height scaling. */
}
/* Get integer and fracctional parts of the coords. */
int x_int = (int)local_x;
int z_int = (int)local_z;
float x_frac = local_x - x_int;
float z_frac = local_z - z_int;
/* Get heights of the four corners of the grid cell. */
float h00 = chunk.heightmap[z_int * CHUNK_WIDTH+x_int];
float h10 = chunk.heightmap[z_int * CHUNK_WIDTH+(x_int+1)];
float h01 = chunk.heightmap[(z_int+1) * CHUNK_WIDTH+x_int];
float h11 = chunk.heightmap[(z_int+1) * CHUNK_WIDTH+(x_int+1)];
/* Bilinear interpolation. */
float h_x0 = h00 * (1 - x_frac) + h10 * x_frac;
float h_x1 = h01 * (1 - x_frac) + h11 * x_frac;
float height = h_x0 * (1 - z_frac) + h_x1 * z_frac;
return height * 5.0f; /* Apply the same scaling used in ChunkMesh. */
}
} /* namespace Terrain. */
} /* namespace Game. */
} /* namespace BettolaLib. */

View File

@ -9,6 +9,7 @@ struct PlayerStateMessage {
unsigned int player_id; unsigned int player_id;
float x; float x;
float y; float y;
float z;
float yaw; float yaw;
unsigned int last_processed_sequence; unsigned int last_processed_sequence;
}; };

View File

@ -1,6 +1,7 @@
#include <cmath> #include <cmath>
#include "bettola/game/player_base.h" #include "bettola/game/player_base.h"
#include "bettola/network/player_input_message.h" #include "bettola/network/player_input_message.h"
#include "game/player.h"
/* Use a static variable for ID generation across all player types. */ /* Use a static variable for ID generation across all player types. */
static unsigned int next_player_id = 1; static unsigned int next_player_id = 1;
@ -13,11 +14,21 @@ PlayerBase::PlayerBase(void) :
_speed(20.0f) {} _speed(20.0f) {}
void PlayerBase::update(double dt) { void PlayerBase::update(double dt) {
/* Apply gravity force. */
_velocity.y -= 30.0f * dt;
_position.x += _velocity.x * dt; _position.x += _velocity.x * dt;
_position.y += _velocity.y * dt; _position.y += _velocity.y * dt;
_position.z += _velocity.z * dt; _position.z += _velocity.z * dt;
} }
void PlayerBase::apply_gravity_and_collision(float terrain_height) {
if(_position.y < terrain_height) {
_position.y = terrain_height;
_velocity.y = 0;
}
}
void PlayerBase::set_position(const BettolaMath::Vec3& pos) { void PlayerBase::set_position(const BettolaMath::Vec3& pos) {
_position = pos; _position = pos;
} }
@ -40,11 +51,9 @@ void PlayerBase::set_velocity_direction(const InputState& input, const BettolaMa
float move_mag = sqrt(move_dir.x*move_dir.x + move_dir.z*move_dir.z); float move_mag = sqrt(move_dir.x*move_dir.x + move_dir.z*move_dir.z);
if(move_mag > 0.0f) { if(move_mag > 0.0f) {
_velocity.x = (move_dir.x / move_mag) * _speed; _velocity.x = (move_dir.x / move_mag) * _speed;
_velocity.y = 0.0; _velocity.z = (move_dir.z / move_mag) * _speed;
_velocity.z = (move_dir.z / move_mag) * _speed; /* y velocity is for z-axis. */
} else { } else {
_velocity.x = 0.0f; _velocity.x = 0.0f;
_velocity.y = 0.0f;
_velocity.z = 0.0f; _velocity.z = 0.0f;
} }
} }

View File

@ -7,6 +7,7 @@
#include <SDL3/SDL_video.h> #include <SDL3/SDL_video.h>
#include <cstdlib> #include <cstdlib>
#include <cstdio> #include <cstdio>
#include "bettola/math/vec3.h"
#include "game/player.h" #include "game/player.h"
#include "graphics/camera.h" #include "graphics/camera.h"

View File

@ -1,20 +1,21 @@
#include "remote_player.h" #include "remote_player.h"
RemotePlayer::RemotePlayer(unsigned int id, float x, float y) : RemotePlayer::RemotePlayer(unsigned int id, float x, float y, float z) :
PlayerBase(), _target_x(x), _target_y(y), _target_yaw(0.0f) { PlayerBase(), _target_x(x), _target_y(y), _target_z(z), _target_yaw(0.0f) {
_id = id; /* Manually set id from the server. */ _id = id; /* Manually set id from the server. */
_position = {x, 0.0f, y }; _position = {x, y, z};
} }
void RemotePlayer::update(double dt) { void RemotePlayer::update(double dt) {
const float interp_speed = 15.0f; const float interp_speed = 15.0f;
_position.x += (_target_x - _position.x) * interp_speed * dt; _position.x += (_target_x - _position.x) * interp_speed * dt;
_position.z += (_target_y - _position.z) * interp_speed * dt; /* y is z. */ _position.y += (_target_y - _position.y) * interp_speed * dt;
_position.z += (_target_z - _position.z) * interp_speed * dt;
/* TODO: Snap the yaw, we'll interpolate later if we need. */ /* TODO: Snap the yaw, we'll interpolate later if we need. */
_yaw = _target_yaw; _yaw = _target_yaw;
} }
void RemotePlayer::set_target_position(float x, float y, float yaw) { void RemotePlayer::set_target_position(float x, float y, float z, float yaw) {
_target_x = x; _target_y = y; _target_yaw = yaw; _target_x = x; _target_y = y; _target_z = z; _target_yaw = yaw;
} }

View File

@ -4,13 +4,14 @@
class RemotePlayer : public PlayerBase { class RemotePlayer : public PlayerBase {
public: public:
RemotePlayer(unsigned int id, float x, float y); RemotePlayer(unsigned int id, float x, float y, float z);
void update(double dt); void update(double dt);
void set_target_position(float x, float y, float yaw); void set_target_position(float x, float y, float z, float yaw);
private: private:
float _target_x; float _target_x;
float _target_y; float _target_y;
float _target_z;
float _target_yaw; float _target_yaw;
}; };

View File

@ -1,6 +1,7 @@
#include "world.h" #include "world.h"
#include <cstring> #include <cstring>
#include "bettola/game/chunk.h" #include "bettola/game/chunk.h"
#include "bettola/game/terrain.h"
#include "graphics/chunk_mesh.h" #include "graphics/chunk_mesh.h"
World::~World(void) { World::~World(void) {
@ -10,7 +11,7 @@ World::~World(void) {
} }
void World::add_chunk(int chunk_x, int chunk_z, const float* heightmap) { void World::add_chunk(int chunk_x, int chunk_z, const float* heightmap) {
ChunkPos pos = { chunk_x, chunk_z }; ChunkPos pos = {chunk_x, chunk_z};
if(_chunk_meshes.count(pos)) { if(_chunk_meshes.count(pos)) {
/* TODO: Chunk already exists, perhaps update it later.. */ /* TODO: Chunk already exists, perhaps update it later.. */
return; return;
@ -19,5 +20,24 @@ void World::add_chunk(int chunk_x, int chunk_z, const float* heightmap) {
BettolaLib::Game::Chunk chunk; BettolaLib::Game::Chunk chunk;
memcpy(chunk.heightmap.data(), heightmap, chunk.heightmap.size()*sizeof(float)); memcpy(chunk.heightmap.data(), heightmap, chunk.heightmap.size()*sizeof(float));
_chunks[pos] = chunk;
_chunk_meshes[pos] = new ChunkMesh(chunk_x, chunk_z, chunk); _chunk_meshes[pos] = new ChunkMesh(chunk_x, chunk_z, chunk);
} }
float World::get_height(float world_x, float world_z) const {
float chunk_width = (float)(BettolaLib::Game::CHUNK_WIDTH -1);
float chunk_height = (float)(BettolaLib::Game::CHUNK_HEIGHT-1);
int chunk_x = floor(world_x / chunk_width);
int chunk_z = floor(world_z / chunk_height);
float local_x = world_x - (chunk_x * chunk_width);
float local_z = world_z - (chunk_z * chunk_height);
auto it = _chunks.find({chunk_x, chunk_z});
if(it == _chunks.end()) {
return 0.0f; /* No chunk data. */
}
return BettolaLib::Game::Terrain::get_height_at(it->second, local_x, local_z);
}

View File

@ -2,6 +2,7 @@
#include <map> #include <map>
#include "bettola/game/chunk.h"
#include "graphics/chunk_mesh.h" #include "graphics/chunk_mesh.h"
/* Allow the use of a pair of ints as a map key. */ /* Allow the use of a pair of ints as a map key. */
@ -18,6 +19,7 @@ class World {
public: public:
~World(void); ~World(void);
float get_height(float world_x, float world_z) const;
void add_chunk(int chunk_x, int chunk_z, const float* heightmap); void add_chunk(int chunk_x, int chunk_z, const float* heightmap);
const std::map<ChunkPos, ChunkMesh*>& get_chunk_meshes(void) const { const std::map<ChunkPos, ChunkMesh*>& get_chunk_meshes(void) const {
return _chunk_meshes; return _chunk_meshes;
@ -25,4 +27,5 @@ public:
private: private:
std::map<ChunkPos, ChunkMesh*> _chunk_meshes; std::map<ChunkPos, ChunkMesh*> _chunk_meshes;
std::map<ChunkPos, BettolaLib::Game::Chunk> _chunks;
}; };

View File

@ -129,7 +129,7 @@ void GameClient::_process_game_state(const BettolaLib::Network::GameStateMessage
if(ps.player_id == _our_player_id) { if(ps.player_id == _our_player_id) {
/* This is our player. Reconcile. */ /* This is our player. Reconcile. */
_player.set_position({ps.x, 0.0f, ps.y}); _player.set_position({ps.x, ps.y, ps.z});
/* Remove all inputs from our history that the server has processed. */ /* Remove all inputs from our history that the server has processed. */
while(!_pending_inputs.empty() && while(!_pending_inputs.empty() &&
@ -140,8 +140,12 @@ void GameClient::_process_game_state(const BettolaLib::Network::GameStateMessage
/* Now, re-apply any remaining inputs that the server hasn't /* Now, re-apply any remaining inputs that the server hasn't
* seen yet. This'll bring the client up to date with most recent inputs. * seen yet. This'll bring the client up to date with most recent inputs.
*/ */
for(const auto& input : _pending_inputs) { for(const auto& input_msg : _pending_inputs) {
_player.apply_input(input); PlayerBase::InputState input_state = { input_msg.up, input_msg.down,
input_msg.left, input_msg.right };
BettolaMath::Vec3 cam_front = { input_msg.cam_front_x, input_msg.cam_front_y,
input_msg.cam_front_z };
_player.set_velocity_direction(input_state, cam_front);
} }
} else { } else {
/* Remote player. Find if we already know about them.. */ /* Remote player. Find if we already know about them.. */
@ -151,10 +155,10 @@ void GameClient::_process_game_state(const BettolaLib::Network::GameStateMessage
if(it != _remote_players.end()) { if(it != _remote_players.end()) {
/* Found 'em! update their target pos. */ /* Found 'em! update their target pos. */
it->set_target_position(ps.x, ps.y, ps.yaw); it->set_target_position(ps.x, ps.y, ps.z, ps.yaw);
} else { } else {
/* They are new, add them to our list. */ /* They are new, add them to our list. */
_remote_players.emplace_back(ps.player_id, ps.x, ps.y); _remote_players.emplace_back(ps.player_id, ps.x, ps.y, ps.z);
} }
} }
} }
@ -197,6 +201,9 @@ void GameClient::send_input(PlayerBase::InputState& input,
void GameClient::update_players(float dt) { void GameClient::update_players(float dt) {
_player.update(dt); _player.update(dt);
float terrain_height = _world.get_height(_player.get_position().x,
_player.get_position().z);
_player.apply_gravity_and_collision(terrain_height);
for(auto& p : _remote_players) { for(auto& p : _remote_players) {
p.update(dt); p.update(dt);
} }

View File

@ -53,6 +53,10 @@ void Game::process_udp_message(const char* buffer, size_t size, const sockaddr_i
BettolaMath::Vec3 cam_front = { msg.cam_front_x, msg.cam_front_y, msg.cam_front_z }; BettolaMath::Vec3 cam_front = { msg.cam_front_x, msg.cam_front_y, msg.cam_front_z };
player->set_velocity_direction(input, cam_front); player->set_velocity_direction(input, cam_front);
player->update(msg.dt); player->update(msg.dt);
float terrain_height = _world.get_height(player->get_position().x,
player->get_position().z);
player->apply_gravity_and_collision(terrain_height);
} }
} }
} }
@ -66,7 +70,8 @@ void Game::broadcast_game_state(BettolaLib::Network::UDPSocket& udp_socket) {
msg.players[i].player_id = _players[i]->get_id(); msg.players[i].player_id = _players[i]->get_id();
const auto& pos = _players[i]->get_position(); const auto& pos = _players[i]->get_position();
msg.players[i].x = pos.x; msg.players[i].x = pos.x;
msg.players[i].y = pos.z; /* Send z as y for the 2D style network message. */ msg.players[i].y = pos.y;
msg.players[i].z = pos.z;
msg.players[i].yaw = _players[i]->get_yaw(); msg.players[i].yaw = _players[i]->get_yaw();
msg.players[i].last_processed_sequence = _players[i]->get_last_processed_sequence(); msg.players[i].last_processed_sequence = _players[i]->get_last_processed_sequence();
} }

View File

@ -18,6 +18,7 @@ public:
World(void); World(void);
BettolaLib::Game::Chunk& get_chunk(int x, int z); BettolaLib::Game::Chunk& get_chunk(int x, int z);
float get_height(float world_x, float world_z);
private: private:
void _generate_chunk(BettolaLib::Game::Chunk& chunk, int chunk_x, int chunk_z); void _generate_chunk(BettolaLib::Game::Chunk& chunk, int chunk_x, int chunk_z);

View File

@ -0,0 +1,23 @@
#include <cmath>
#include "world.h"
#include "bettola/game/chunk.h"
#include "bettola/game/terrain.h"
float World::get_height(float world_x, float world_z) {
/*
* Mesh generation uses (WIDTH-1), so account for it
* when calculating the chunk and local coords.
*/
float chunk_width = (float)(BettolaLib::Game::CHUNK_WIDTH-1);
float chunk_height = (float)(BettolaLib::Game::CHUNK_HEIGHT-1);
int chunk_x = floor(world_x / chunk_width);
int chunk_z = floor(world_z / chunk_height);
float local_x = world_x - (chunk_x * chunk_width);
float local_z = world_z - (chunk_z * chunk_height);
BettolaLib::Game::Chunk& chunk = get_chunk(chunk_x, chunk_z);
return BettolaLib::Game::Terrain::get_height_at(chunk, local_x, local_z);
}