Phase 7: netfox + godot-jolt stack upgrade
Stack installed: - netfox v1.35.3 (core + extras + noray + internals) - godot-jolt v0.16.0-stable Architecture: - Server: ENet transport (works headless, no netfox deps) - Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator) New/modified: - docs/migration-netfox-plan.md — migration architecture - scripts/network/network_manager.gd — netfox-aware ENet fallback - scripts/network/player.gd — clean base player - client/characters/player_netfox.gd — rollback player w/ WeaponManager - client/characters/input/player_net_input.gd — BaseNetInput subclass - client/characters/character/fps_character_controller.gd — netfox input feed - client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager - client/scripts/round_replicator.gd — client-side round state bridge - server/scripts/round_manager.gd — improved state machine - server/scripts/plugin_api/plugin_manager.gd — refined plugin system - config: enemy_tag, ally_tag for meatball targeting Removed: old C++ SimulationServer GDExtension (replaced by netfox rollback)
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
#ifndef TACTICAL_SHOOTER_BITSTREAM_H
|
||||
#define TACTICAL_SHOOTER_BITSTREAM_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <cassert>
|
||||
#include <climits>
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
|
||||
namespace tactical_shooter {
|
||||
|
||||
/**
|
||||
* Bit-level read/write stream for compact network serialization.
|
||||
*
|
||||
* All multi-byte values are written in little-endian order regardless of
|
||||
* host endianness (network byte order). Booleans pack as single bits.
|
||||
* Floats can be quantized to arbitrary bit depths for bandwidth savings.
|
||||
*
|
||||
* Buffers are dynamically resized. Pre-allocate with reserve() to avoid
|
||||
* reallocation in hot paths.
|
||||
*/
|
||||
class Bitstream {
|
||||
public:
|
||||
static constexpr size_t kMaxBufferSize = 1024 * 1024; // 1MB safety limit
|
||||
|
||||
Bitstream() : buffer_(), bits_written_(0), bits_read_(0) {}
|
||||
|
||||
explicit Bitstream(std::vector<uint8_t> data)
|
||||
: buffer_(std::move(data)), bits_written_(buffer_.size() * 8), bits_read_(0) {}
|
||||
|
||||
// ---- Write -----------------------------------------------------------
|
||||
|
||||
void write_bool(bool value) {
|
||||
write_bits(value ? 1 : 0, 1);
|
||||
}
|
||||
|
||||
void write_uint8(uint8_t value) {
|
||||
write_bits(value, 8);
|
||||
}
|
||||
|
||||
void write_uint16(uint16_t value) {
|
||||
write_bits(value, 16);
|
||||
}
|
||||
|
||||
void write_uint32(uint32_t value) {
|
||||
write_bits(value, 32);
|
||||
}
|
||||
|
||||
void write_int32(int32_t value) {
|
||||
// Zigzag encoding for efficient negative-number packing
|
||||
uint32_t zigzag = static_cast<uint32_t>((value << 1) ^ (value >> 31));
|
||||
write_bits(zigzag, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a float quantized to `nbits` within [min, max].
|
||||
* Storage: nbits bits. Resolution: (max-min) / (2^nbits - 1).
|
||||
* Pass nbits=32 for full-precision float (no quantization loss).
|
||||
*/
|
||||
void write_float_quantized(float value, float min, float max, uint8_t nbits) {
|
||||
assert(nbits > 0 && nbits <= 32);
|
||||
if (nbits >= 32) {
|
||||
// Full precision: store as raw bits
|
||||
uint32_t raw;
|
||||
memcpy(&raw, &value, sizeof(raw));
|
||||
write_bits(raw, 32);
|
||||
return;
|
||||
}
|
||||
float clamped = std::clamp(value, min, max);
|
||||
float normalized = (clamped - min) / (max - min);
|
||||
uint32_t quantized = static_cast<uint32_t>(normalized * ((1u << nbits) - 1));
|
||||
write_bits(quantized, nbits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write up to `nbits` bits of `value`. LSB first packing.
|
||||
*/
|
||||
void write_bits(uint32_t value, uint8_t nbits) {
|
||||
assert(nbits > 0 && nbits <= 32);
|
||||
ensure_capacity(nbits);
|
||||
|
||||
uint8_t *data = buffer_.data();
|
||||
size_t byte_pos = bits_written_ / 8;
|
||||
uint8_t bit_offset = bits_written_ % 8;
|
||||
|
||||
for (uint8_t i = 0; i < nbits; ++i) {
|
||||
if (value & (1u << i)) {
|
||||
data[byte_pos] |= (1u << bit_offset);
|
||||
}
|
||||
++bit_offset;
|
||||
if (bit_offset >= 8) {
|
||||
bit_offset = 0;
|
||||
++byte_pos;
|
||||
}
|
||||
}
|
||||
bits_written_ += nbits;
|
||||
}
|
||||
|
||||
// ---- Read ------------------------------------------------------------
|
||||
|
||||
bool read_bool() {
|
||||
return read_bits(1) != 0;
|
||||
}
|
||||
|
||||
uint8_t read_uint8() {
|
||||
return static_cast<uint8_t>(read_bits(8));
|
||||
}
|
||||
|
||||
uint16_t read_uint16() {
|
||||
return static_cast<uint16_t>(read_bits(16));
|
||||
}
|
||||
|
||||
uint32_t read_uint32() {
|
||||
return read_bits(32);
|
||||
}
|
||||
|
||||
int32_t read_int32() {
|
||||
uint32_t zigzag = read_bits(32);
|
||||
return static_cast<int32_t>((zigzag >> 1) ^ -(static_cast<int32_t>(zigzag & 1)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a quantized float matching write_float_quantized().
|
||||
*/
|
||||
float read_float_quantized(float min, float max, uint8_t nbits) {
|
||||
assert(nbits > 0 && nbits <= 32);
|
||||
if (nbits >= 32) {
|
||||
uint32_t raw = read_bits(32);
|
||||
float value;
|
||||
memcpy(&value, &raw, sizeof(value));
|
||||
return value;
|
||||
}
|
||||
uint32_t quantized = read_bits(nbits);
|
||||
float normalized = static_cast<float>(quantized) / static_cast<float>((1u << nbits) - 1);
|
||||
return min + normalized * (max - min);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read up to `nbits` bits, returned as LSB-packed uint32.
|
||||
*/
|
||||
uint32_t read_bits(uint8_t nbits) {
|
||||
assert(nbits > 0 && nbits <= 32);
|
||||
assert((bits_read_ + nbits) <= bits_written_);
|
||||
|
||||
const uint8_t *data = buffer_.data();
|
||||
size_t byte_pos = bits_read_ / 8;
|
||||
uint8_t bit_offset = bits_read_ % 8;
|
||||
uint32_t result = 0;
|
||||
|
||||
for (uint8_t i = 0; i < nbits; ++i) {
|
||||
if (data[byte_pos] & (1u << bit_offset)) {
|
||||
result |= (1u << i);
|
||||
}
|
||||
++bit_offset;
|
||||
if (bit_offset >= 8) {
|
||||
bit_offset = 0;
|
||||
++byte_pos;
|
||||
}
|
||||
}
|
||||
bits_read_ += nbits;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- Array helpers ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* Write a dense array of booleans packed bit-by-bit.
|
||||
*/
|
||||
void write_bool_array(const bool *values, size_t count) {
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
write_bool(values[i]);
|
||||
}
|
||||
}
|
||||
|
||||
void read_bool_array(bool *values, size_t count) {
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
values[i] = read_bool();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a variable-length array of uint8 values with a uint16 count prefix.
|
||||
*/
|
||||
void write_uint8_array(const uint8_t *values, uint16_t count) {
|
||||
write_uint16(count);
|
||||
for (uint16_t i = 0; i < count; ++i) {
|
||||
write_uint8(values[i]);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<uint8_t> read_uint8_array() {
|
||||
uint16_t count = read_uint16();
|
||||
std::vector<uint8_t> result(count);
|
||||
for (uint16_t i = 0; i < count; ++i) {
|
||||
result[i] = read_uint8();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- State -----------------------------------------------------------
|
||||
|
||||
/// Total bytes consumed by written data
|
||||
size_t byte_size() const {
|
||||
return (bits_written_ + 7) / 8;
|
||||
}
|
||||
|
||||
/// Number of bits written so far
|
||||
size_t bits_written() const { return bits_written_; }
|
||||
|
||||
/// Number of bits read so far
|
||||
size_t bits_read() const { return bits_read_; }
|
||||
|
||||
/// Remaining readable bits
|
||||
size_t bits_remaining() const {
|
||||
return bits_written_ - bits_read_;
|
||||
}
|
||||
|
||||
/// Raw buffer (const access)
|
||||
const uint8_t *data() const { return buffer_.data(); }
|
||||
|
||||
/// Clear everything, rewind
|
||||
void reset() {
|
||||
buffer_.clear();
|
||||
bits_written_ = 0;
|
||||
bits_read_ = 0;
|
||||
}
|
||||
|
||||
/// Pre-allocate capacity in bytes
|
||||
void reserve(size_t bytes) {
|
||||
buffer_.reserve(bytes);
|
||||
}
|
||||
|
||||
/// Steal the internal buffer
|
||||
std::vector<uint8_t> take_buffer() {
|
||||
std::vector<uint8_t> result = std::move(buffer_);
|
||||
reset();
|
||||
return result;
|
||||
}
|
||||
|
||||
private:
|
||||
void ensure_capacity(uint8_t extra_bits) {
|
||||
size_t needed_bytes = (bits_written_ + extra_bits + 7) / 8;
|
||||
if (needed_bytes > buffer_.size()) {
|
||||
if (needed_bytes > kMaxBufferSize) {
|
||||
// TODO: log error instead of assert in production
|
||||
assert(!"Bitstream overflow — reduce snapshot size or increase kMaxBufferSize");
|
||||
}
|
||||
buffer_.resize(std::max(buffer_.size() * 2, needed_bytes));
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<uint8_t> buffer_;
|
||||
size_t bits_written_ = 0;
|
||||
size_t bits_read_ = 0;
|
||||
};
|
||||
|
||||
} // namespace tactical_shooter
|
||||
|
||||
#endif // TACTICAL_SHOOTER_BITSTREAM_H
|
||||
@@ -0,0 +1,125 @@
|
||||
#include "entity.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace tactical_shooter {
|
||||
|
||||
Entity::Entity() {
|
||||
reset(godot::Vector3(0, 0, 0));
|
||||
}
|
||||
|
||||
void Entity::reset(const godot::Vector3 &spawn_position) {
|
||||
position_ = spawn_position;
|
||||
velocity_ = godot::Vector3(0, 0, 0);
|
||||
yaw_ = 0.0f;
|
||||
pitch_ = 0.0f;
|
||||
health_ = 100.0f;
|
||||
armor_ = 0.0f;
|
||||
flags_ = ENTITY_FLAG_ALIVE;
|
||||
weapon_id_ = 0;
|
||||
ammo_ = 30;
|
||||
last_input_ = EntityInput{};
|
||||
}
|
||||
|
||||
void Entity::apply_input(const EntityInput &input) {
|
||||
last_input_ = input;
|
||||
|
||||
// Update flags from input
|
||||
if (input.crouch) flags_ |= ENTITY_FLAG_CROUCHING;
|
||||
else flags_ &= ~ENTITY_FLAG_CROUCHING;
|
||||
|
||||
if (input.sprint) flags_ |= ENTITY_FLAG_SPRINTING;
|
||||
else flags_ &= ~ENTITY_FLAG_SPRINTING;
|
||||
|
||||
if (input.aim) flags_ |= ENTITY_FLAG_AIMING;
|
||||
else flags_ &= ~ENTITY_FLAG_AIMING;
|
||||
}
|
||||
|
||||
// ---- Snapshot / Delta --------------------------------------------------------
|
||||
|
||||
EntitySnapshot Entity::capture_snapshot() const {
|
||||
EntitySnapshot snap;
|
||||
snap.entity_id = entity_id_;
|
||||
snap.flags = flags_;
|
||||
snap.position = position_;
|
||||
snap.velocity = velocity_;
|
||||
snap.yaw = yaw_;
|
||||
snap.pitch = pitch_;
|
||||
snap.health = health_;
|
||||
snap.armor = armor_;
|
||||
snap.weapon_id = weapon_id_;
|
||||
snap.ammo = ammo_;
|
||||
snap.last_processed_input = last_input_.input_sequence;
|
||||
return snap;
|
||||
}
|
||||
|
||||
EntitySnapshot::ChangeMask Entity::compute_change_mask(const EntitySnapshot &base) const {
|
||||
uint32_t mask = EntitySnapshot::CHANGED_NONE;
|
||||
|
||||
if (position_ != base.position) mask |= EntitySnapshot::CHANGED_POSITION;
|
||||
if (velocity_ != base.velocity) mask |= EntitySnapshot::CHANGED_VELOCITY;
|
||||
if (yaw_ != base.yaw || pitch_ != base.pitch) mask |= EntitySnapshot::CHANGED_ROTATION;
|
||||
if (health_ != base.health) mask |= EntitySnapshot::CHANGED_HEALTH;
|
||||
if (armor_ != base.armor) mask |= EntitySnapshot::CHANGED_ARMOR;
|
||||
if (weapon_id_ != base.weapon_id) mask |= EntitySnapshot::CHANGED_WEAPON;
|
||||
if (ammo_ != base.ammo) mask |= EntitySnapshot::CHANGED_AMMO;
|
||||
if (flags_ != base.flags) mask |= EntitySnapshot::CHANGED_FLAGS;
|
||||
if (last_input_.input_sequence != base.last_processed_input)
|
||||
mask |= EntitySnapshot::CHANGED_INPUT;
|
||||
|
||||
return static_cast<EntitySnapshot::ChangeMask>(mask);
|
||||
}
|
||||
|
||||
// ---- GDScript Bindings -------------------------------------------------------
|
||||
|
||||
void Entity::_bind_methods() {
|
||||
using namespace godot;
|
||||
|
||||
ClassDB::bind_method(D_METHOD("set_position", "position"), &Entity::set_position);
|
||||
ClassDB::bind_method(D_METHOD("get_position"), &Entity::get_position);
|
||||
ADD_PROPERTY(PropertyInfo(Variant::VECTOR3, "position"), "set_position", "get_position");
|
||||
|
||||
ClassDB::bind_method(D_METHOD("set_velocity", "velocity"), &Entity::set_velocity);
|
||||
ClassDB::bind_method(D_METHOD("get_velocity"), &Entity::get_velocity);
|
||||
ADD_PROPERTY(PropertyInfo(Variant::VECTOR3, "velocity"), "set_velocity", "get_velocity");
|
||||
|
||||
ClassDB::bind_method(D_METHOD("set_health", "health"), &Entity::set_health);
|
||||
ClassDB::bind_method(D_METHOD("get_health"), &Entity::get_health);
|
||||
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "health"), "set_health", "get_health");
|
||||
|
||||
ClassDB::bind_method(D_METHOD("set_armor", "armor"), &Entity::set_armor);
|
||||
ClassDB::bind_method(D_METHOD("get_armor"), &Entity::get_armor);
|
||||
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "armor"), "set_armor", "get_armor");
|
||||
|
||||
ClassDB::bind_method(D_METHOD("set_yaw", "yaw"), &Entity::set_yaw);
|
||||
ClassDB::bind_method(D_METHOD("get_yaw"), &Entity::get_yaw);
|
||||
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "yaw"), "set_yaw", "get_yaw");
|
||||
|
||||
ClassDB::bind_method(D_METHOD("set_pitch", "pitch"), &Entity::set_pitch);
|
||||
ClassDB::bind_method(D_METHOD("get_pitch"), &Entity::get_pitch);
|
||||
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "pitch"), "set_pitch", "get_pitch");
|
||||
|
||||
ClassDB::bind_method(D_METHOD("is_alive"), &Entity::is_alive);
|
||||
ClassDB::bind_method(D_METHOD("kill"), &Entity::kill);
|
||||
|
||||
ClassDB::bind_method(D_METHOD("get_entity_id"), &Entity::get_entity_id);
|
||||
}
|
||||
|
||||
// ---- Property Accessors ------------------------------------------------------
|
||||
|
||||
void Entity::set_position(const godot::Vector3 &p_position) { position_ = p_position; }
|
||||
godot::Vector3 Entity::get_position() const { return position_; }
|
||||
void Entity::set_velocity(const godot::Vector3 &p_velocity) { velocity_ = p_velocity; }
|
||||
godot::Vector3 Entity::get_velocity() const { return velocity_; }
|
||||
void Entity::set_health(float p_health) { health_ = std::clamp(p_health, 0.0f, 100.0f); }
|
||||
float Entity::get_health() const { return health_; }
|
||||
void Entity::set_armor(float p_armor) { armor_ = std::clamp(p_armor, 0.0f, 100.0f); }
|
||||
float Entity::get_armor() const { return armor_; }
|
||||
void Entity::set_yaw(float p_yaw) { yaw_ = p_yaw; }
|
||||
float Entity::get_yaw() const { return yaw_; }
|
||||
void Entity::set_pitch(float p_pitch) { pitch_ = std::clamp(p_pitch, -90.0f, 90.0f); }
|
||||
float Entity::get_pitch() const { return pitch_; }
|
||||
bool Entity::is_alive() const { return flags_ & ENTITY_FLAG_ALIVE; }
|
||||
void Entity::kill() { flags_ &= ~ENTITY_FLAG_ALIVE; }
|
||||
|
||||
} // namespace tactical_shooter
|
||||
@@ -0,0 +1,173 @@
|
||||
#ifndef TACTICAL_SHOOTER_ENTITY_H
|
||||
#define TACTICAL_SHOOTER_ENTITY_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <godot_cpp/classes/ref_counted.hpp>
|
||||
#include <godot_cpp/variant/vector3.hpp>
|
||||
|
||||
namespace tactical_shooter {
|
||||
|
||||
/**
|
||||
* Maximum number of entities in a single simulation.
|
||||
* Hard cap prevents memory exhaustion and bounds snapshot sizes.
|
||||
*/
|
||||
static constexpr uint16_t kMaxEntities = 256;
|
||||
|
||||
/**
|
||||
* Per-entity flags packed into a single uint16 for compact serialization.
|
||||
*/
|
||||
enum EntityFlags : uint16_t {
|
||||
ENTITY_FLAG_NONE = 0,
|
||||
ENTITY_FLAG_ALIVE = 1 << 0,
|
||||
ENTITY_FLAG_GROUNDED = 1 << 1,
|
||||
ENTITY_FLAG_CROUCHING = 1 << 2,
|
||||
ENTITY_FLAG_SPRINTING = 1 << 3,
|
||||
ENTITY_FLAG_AIMING = 1 << 4,
|
||||
};
|
||||
|
||||
/**
|
||||
* Entity input state for a single tick — what the client sent this frame.
|
||||
* Compact enough to be sent at 128Hz.
|
||||
*/
|
||||
struct EntityInput {
|
||||
godot::Vector3 move_direction; // normalized, quantized
|
||||
float look_yaw = 0.0f; // degrees
|
||||
float look_pitch = 0.0f; // degrees
|
||||
bool jump = false;
|
||||
bool crouch = false;
|
||||
bool sprint = false;
|
||||
bool shoot = false;
|
||||
bool aim = false;
|
||||
uint32_t input_sequence = 0; // for client-authoritative input validation
|
||||
};
|
||||
|
||||
/**
|
||||
* Full entity state snapshot at one point in time.
|
||||
* Used as the "base" for delta compression — serialize only the fields
|
||||
* that changed since the last acknowledged snapshot.
|
||||
*/
|
||||
struct EntitySnapshot {
|
||||
uint16_t entity_id = 0;
|
||||
uint16_t flags = ENTITY_FLAG_ALIVE;
|
||||
|
||||
// Transform (quantized for network)
|
||||
godot::Vector3 position;
|
||||
godot::Vector3 velocity;
|
||||
float yaw = 0.0f; // degrees, -180..180
|
||||
float pitch = 0.0f; // degrees, -90..90
|
||||
|
||||
// Game state
|
||||
float health = 100.0f;
|
||||
float armor = 0.0f;
|
||||
uint8_t weapon_id = 0;
|
||||
uint16_t ammo = 0;
|
||||
|
||||
// Client-authoritative input (for reconciliation)
|
||||
uint32_t last_processed_input = 0;
|
||||
|
||||
/// Bit-field that tells serializers which fields changed from last base
|
||||
enum ChangeMask : uint32_t {
|
||||
CHANGED_NONE = 0,
|
||||
CHANGED_POSITION = 1 << 0,
|
||||
CHANGED_VELOCITY = 1 << 1,
|
||||
CHANGED_ROTATION = 1 << 2,
|
||||
CHANGED_HEALTH = 1 << 3,
|
||||
CHANGED_ARMOR = 1 << 4,
|
||||
CHANGED_WEAPON = 1 << 5,
|
||||
CHANGED_AMMO = 1 << 6,
|
||||
CHANGED_FLAGS = 1 << 7,
|
||||
CHANGED_INPUT = 1 << 8,
|
||||
CHANGED_ALL = 0xFFFFFFFF,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Simulation entity — internal game object managed by SimulationServer.
|
||||
*
|
||||
* Registered as a RefCounted so GDScript can hold references and pass
|
||||
* them around without manual memory management.
|
||||
*/
|
||||
class Entity : public godot::RefCounted {
|
||||
GDCLASS(Entity, godot::RefCounted)
|
||||
|
||||
public:
|
||||
Entity();
|
||||
~Entity() = default;
|
||||
|
||||
// ---- GDScript API ----------------------------------------------------
|
||||
|
||||
void set_position(const godot::Vector3 &p_position);
|
||||
godot::Vector3 get_position() const;
|
||||
|
||||
void set_velocity(const godot::Vector3 &p_velocity);
|
||||
godot::Vector3 get_velocity() const;
|
||||
|
||||
void set_health(float p_health);
|
||||
float get_health() const;
|
||||
|
||||
void set_armor(float p_armor);
|
||||
float get_armor() const;
|
||||
|
||||
void set_yaw(float p_yaw);
|
||||
float get_yaw() const;
|
||||
|
||||
void set_pitch(float p_pitch);
|
||||
float get_pitch() const;
|
||||
|
||||
bool is_alive() const;
|
||||
void kill();
|
||||
|
||||
uint16_t get_entity_id() const { return entity_id_; }
|
||||
void set_entity_id(uint16_t id) { entity_id_ = id; }
|
||||
|
||||
uint16_t get_flags() const { return flags_; }
|
||||
void set_flags(uint16_t f) { flags_ = f; }
|
||||
|
||||
// ---- Internal API (not exposed to GDScript) --------------------------
|
||||
|
||||
/// Reset entity to spawn state
|
||||
void reset(const godot::Vector3 &spawn_position);
|
||||
|
||||
/// Apply input for this tick (called by SimulationServer)
|
||||
void apply_input(const EntityInput &input);
|
||||
|
||||
/// Capture current state into a snapshot
|
||||
EntitySnapshot capture_snapshot() const;
|
||||
|
||||
/// Compute delta against a base snapshot for serialization
|
||||
EntitySnapshot::ChangeMask compute_change_mask(const EntitySnapshot &base) const;
|
||||
|
||||
/// Internal state access (for movement/hit systems)
|
||||
const EntityInput &last_input() const { return last_input_; }
|
||||
float crouch_height() const { return is_crouching() ? 0.75f : 1.0f; }
|
||||
|
||||
protected:
|
||||
static void _bind_methods();
|
||||
|
||||
private:
|
||||
bool is_crouching() const { return flags_ & ENTITY_FLAG_CROUCHING; }
|
||||
bool is_sprinting() const { return flags_ & ENTITY_FLAG_SPRINTING; }
|
||||
bool is_aiming() const { return flags_ & ENTITY_FLAG_AIMING; }
|
||||
|
||||
uint16_t entity_id_ = 0;
|
||||
uint16_t flags_ = ENTITY_FLAG_ALIVE;
|
||||
|
||||
// World state
|
||||
godot::Vector3 position_;
|
||||
godot::Vector3 velocity_;
|
||||
float yaw_ = 0.0f;
|
||||
float pitch_ = 0.0f;
|
||||
|
||||
// Gameplay
|
||||
float health_ = 100.0f;
|
||||
float armor_ = 0.0f;
|
||||
uint8_t weapon_id_ = 0;
|
||||
uint16_t ammo_ = 0;
|
||||
|
||||
// Input state
|
||||
EntityInput last_input_;
|
||||
};
|
||||
|
||||
} // namespace tactical_shooter
|
||||
|
||||
#endif // TACTICAL_SHOOTER_ENTITY_H
|
||||
@@ -0,0 +1,157 @@
|
||||
#include "hit_detection.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
namespace tactical_shooter {
|
||||
|
||||
HitDetection::HitDetection() {}
|
||||
|
||||
void HitDetection::set_entities(const std::vector<Entity *> &entities) {
|
||||
entities_ = &entities;
|
||||
}
|
||||
|
||||
HitResult HitDetection::raycast_entity(
|
||||
const godot::Vector3 &origin,
|
||||
const godot::Vector3 &direction,
|
||||
float max_distance,
|
||||
uint16_t exclude_id
|
||||
) const {
|
||||
if (!entities_) return HitResult{};
|
||||
|
||||
HitResult best;
|
||||
best.distance = max_distance;
|
||||
|
||||
for (Entity *entity : *entities_) {
|
||||
if (!entity || !entity->is_alive()) continue;
|
||||
if (entity->get_entity_id() == exclude_id) continue;
|
||||
|
||||
// Simple sphere intersection test against entity bounding sphere
|
||||
godot::Vector3 entity_pos = entity->get_position();
|
||||
// Offset center upward for body collision (not at feet)
|
||||
godot::Vector3 body_center = entity_pos;
|
||||
body_center.y += kEntityHeight * 0.5f;
|
||||
|
||||
godot::Vector3 oc = origin - body_center;
|
||||
float a = direction.dot(direction);
|
||||
float b = 2.0f * oc.dot(direction);
|
||||
float c = oc.dot(oc) - (kEntityRadius * kEntityRadius);
|
||||
float discriminant = b * b - 4.0f * a * c;
|
||||
|
||||
if (discriminant < 0.0f) continue;
|
||||
|
||||
float t1 = (-b - std::sqrt(discriminant)) / (2.0f * a);
|
||||
float t2 = (-b + std::sqrt(discriminant)) / (2.0f * a);
|
||||
|
||||
// Use the closest positive intersection
|
||||
float t = t1;
|
||||
if (t < 0.0f) t = t2;
|
||||
if (t < 0.0f || t > best.distance) continue;
|
||||
|
||||
best.hit = true;
|
||||
best.entity_id = entity->get_entity_id();
|
||||
best.distance = t;
|
||||
best.point = origin + direction * t;
|
||||
best.normal = (best.point - body_center).normalized();
|
||||
best.damage = 0.0f; // filled by process_shot
|
||||
best.hitbox_id = classify_hitbox(*entity, best.point);
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
HitResult HitDetection::process_shot(
|
||||
const godot::Vector3 &origin,
|
||||
const godot::Vector3 &direction,
|
||||
uint16_t shooter_id,
|
||||
const WeaponDamage &weapon
|
||||
) {
|
||||
HitResult hit = raycast_entity(origin, direction, weapon.max_range, shooter_id);
|
||||
|
||||
if (hit.hit) {
|
||||
float mult = get_hitbox_multiplier(hit.hitbox_id, weapon);
|
||||
hit.damage = weapon.base_damage * mult;
|
||||
}
|
||||
|
||||
return hit;
|
||||
}
|
||||
|
||||
float HitDetection::apply_damage(Entity &entity, float damage, float mult) {
|
||||
float raw = damage * mult;
|
||||
|
||||
// Armor absorbs a portion
|
||||
float armor = entity.get_armor();
|
||||
float armor_absorb = std::min(raw * 0.5f, armor);
|
||||
armor -= armor_absorb;
|
||||
entity.set_armor(armor);
|
||||
|
||||
float health_damage = raw - armor_absorb;
|
||||
float new_health = entity.get_health() - health_damage;
|
||||
entity.set_health(new_health);
|
||||
|
||||
if (new_health <= 0.0f) {
|
||||
entity.kill();
|
||||
}
|
||||
|
||||
return health_damage + armor_absorb;
|
||||
}
|
||||
|
||||
std::vector<HitResult> HitDetection::sphere_overlap(
|
||||
const godot::Vector3 ¢er,
|
||||
float radius,
|
||||
uint16_t exclude_id
|
||||
) const {
|
||||
std::vector<HitResult> results;
|
||||
if (!entities_) return results;
|
||||
|
||||
float radius_sq = radius * radius;
|
||||
|
||||
for (Entity *entity : *entities_) {
|
||||
if (!entity || !entity->is_alive()) continue;
|
||||
if (entity->get_entity_id() == exclude_id) continue;
|
||||
|
||||
godot::Vector3 body_center = entity->get_position();
|
||||
body_center.y += kEntityHeight * 0.5f;
|
||||
|
||||
godot::Vector3 diff = center - body_center;
|
||||
float dist_sq = diff.dot(diff);
|
||||
|
||||
if (dist_sq <= radius_sq) {
|
||||
HitResult hit;
|
||||
hit.hit = true;
|
||||
hit.entity_id = entity->get_entity_id();
|
||||
hit.distance = std::sqrt(dist_sq);
|
||||
hit.point = body_center;
|
||||
hit.normal = diff.normalized();
|
||||
results.push_back(hit);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
float HitDetection::get_hitbox_multiplier(uint8_t hitbox_id, const WeaponDamage &weapon) const {
|
||||
switch (hitbox_id) {
|
||||
case 1: return weapon.head_multiplier;
|
||||
case 2: return weapon.arm_multiplier;
|
||||
case 3: return weapon.leg_multiplier;
|
||||
default: return weapon.body_multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t HitDetection::classify_hitbox(
|
||||
const Entity &entity,
|
||||
const godot::Vector3 &hit_point
|
||||
) const {
|
||||
godot::Vector3 entity_pos = entity.get_position();
|
||||
float relative_y = hit_point.y - entity_pos.y;
|
||||
float height_ratio = relative_y / kEntityHeight;
|
||||
|
||||
if (height_ratio > 0.85f) return 1; // head
|
||||
if (height_ratio > 0.65f) return 2; // arms/upper body
|
||||
if (height_ratio > 0.25f) return 0; // body
|
||||
return 3; // legs
|
||||
}
|
||||
|
||||
} // namespace tactical_shooter
|
||||
@@ -0,0 +1,138 @@
|
||||
#ifndef TACTICAL_SHOOTER_HIT_DETECTION_H
|
||||
#define TACTICAL_SHOOTER_HIT_DETECTION_H
|
||||
|
||||
#include "entity.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace tactical_shooter {
|
||||
|
||||
/**
|
||||
* Hit detection system that operates on the simulation entities directly.
|
||||
*
|
||||
* Because the simulation core runs in GDExtension (not GDScript), we avoid
|
||||
* crossing the script→engine boundary for every raycast during the hot loop.
|
||||
* Instead, this component provides simplified geometric checks that can be
|
||||
* resolved locally, or queues world-space queries for the Godot PhysicsServer3D
|
||||
* if precise scene geometry is needed.
|
||||
*
|
||||
* Phase 1 uses sphere/box overlap against entity positions — fast, no physics
|
||||
* dependency. Phase 2+ may add proper PhysicsServer3D raycasts against the
|
||||
* world geometry.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Result of a single hit query.
|
||||
*/
|
||||
struct HitResult {
|
||||
bool hit = false;
|
||||
uint16_t entity_id = 0xFFFF; // 0xFFFF = world geometry / no entity
|
||||
float damage = 0.0f;
|
||||
float distance = 0.0f;
|
||||
godot::Vector3 point;
|
||||
godot::Vector3 normal;
|
||||
uint8_t hitbox_id = 0; // 0=body, 1=head, 2=arms, 3=legs
|
||||
};
|
||||
|
||||
/**
|
||||
* Weapon damage profile.
|
||||
*/
|
||||
struct WeaponDamage {
|
||||
float base_damage = 30.0f;
|
||||
float head_multiplier = 4.0f;
|
||||
float body_multiplier = 1.0f;
|
||||
float arm_multiplier = 0.75f;
|
||||
float leg_multiplier = 0.6f;
|
||||
float max_range = 500.0f; // units
|
||||
float spread_degrees = 0.5f; // random spread per shot
|
||||
};
|
||||
|
||||
class HitDetection {
|
||||
public:
|
||||
HitDetection();
|
||||
|
||||
/**
|
||||
* Set which entities are currently in play (reference list).
|
||||
* Must be called before any hit queries each frame.
|
||||
*/
|
||||
void set_entities(const std::vector<Entity *> &entities);
|
||||
|
||||
/**
|
||||
* Raycast against entity bounding spheres.
|
||||
* No PhysicsServer3D dependency — pure geometric check.
|
||||
*
|
||||
* @param origin Ray origin
|
||||
* @param direction Normalized ray direction
|
||||
* @param max_distance Maximum trace distance
|
||||
* @param exclude_id Entity ID to exclude (shooter)
|
||||
* @return First entity hit (if any)
|
||||
*/
|
||||
HitResult raycast_entity(
|
||||
const godot::Vector3 &origin,
|
||||
const godot::Vector3 &direction,
|
||||
float max_distance,
|
||||
uint16_t exclude_id = 0xFFFF
|
||||
) const;
|
||||
|
||||
/**
|
||||
* Process a weapon fire: apply damage to hit entity.
|
||||
* Combines raycast + damage application + amortization.
|
||||
*
|
||||
* @param origin Fire origin
|
||||
* @param direction Fire direction (with spread already applied)
|
||||
* @param shooter_id Entity that fired
|
||||
* @param weapon Weapon damage profile
|
||||
* @return Hit result with damage
|
||||
*/
|
||||
HitResult process_shot(
|
||||
const godot::Vector3 &origin,
|
||||
const godot::Vector3 &direction,
|
||||
uint16_t shooter_id,
|
||||
const WeaponDamage &weapon
|
||||
);
|
||||
|
||||
/**
|
||||
* Apply damage to an entity and update its state.
|
||||
* Respects armor reduction.
|
||||
*
|
||||
* @param entity Target entity
|
||||
* @param damage Raw damage before armor
|
||||
* @param mult Hitbox multiplier
|
||||
* @return Actual health removed
|
||||
*/
|
||||
static float apply_damage(Entity &entity, float damage, float mult);
|
||||
|
||||
/**
|
||||
* Sphere overlap detection — find all entities within radius.
|
||||
* Useful for explosive damage.
|
||||
*/
|
||||
std::vector<HitResult> sphere_overlap(
|
||||
const godot::Vector3 ¢er,
|
||||
float radius,
|
||||
uint16_t exclude_id = 0xFFFF
|
||||
) const;
|
||||
|
||||
private:
|
||||
/**
|
||||
* Get the hitbox multiplier for a hit location.
|
||||
*/
|
||||
float get_hitbox_multiplier(uint8_t hitbox_id, const WeaponDamage &weapon) const;
|
||||
|
||||
/**
|
||||
* Determine which hitbox a ray hit based on local-space intersection.
|
||||
* Simple head/body/leg classification by vertical offset.
|
||||
*/
|
||||
uint8_t classify_hitbox(const Entity &entity, const godot::Vector3 &hit_point) const;
|
||||
|
||||
const std::vector<Entity *> *entities_ = nullptr;
|
||||
|
||||
// Bounding sphere radius for entity hit detection
|
||||
static constexpr float kEntityRadius = 0.4f; // ~arm span / 2
|
||||
static constexpr float kHeadRadius = 0.2f;
|
||||
static constexpr float kEntityHeight = 1.8f; // standing height
|
||||
};
|
||||
|
||||
} // namespace tactical_shooter
|
||||
|
||||
#endif // TACTICAL_SHOOTER_HIT_DETECTION_H
|
||||
@@ -0,0 +1,155 @@
|
||||
#include "movement_component.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace tactical_shooter {
|
||||
|
||||
MovementComponent::MovementComponent() {}
|
||||
|
||||
MovementComponent::MovementComponent(const Parameters ¶ms)
|
||||
: params_(params) {}
|
||||
|
||||
void MovementComponent::set_parameters(const Parameters ¶ms) {
|
||||
params_ = params;
|
||||
}
|
||||
|
||||
void MovementComponent::update(Entity &entity, float delta) {
|
||||
if (!entity.is_alive()) return;
|
||||
|
||||
const EntityInput &input = entity.last_input();
|
||||
|
||||
// Set flags based on input
|
||||
uint16_t flags = entity.get_flags();
|
||||
if (input.crouch) flags |= ENTITY_FLAG_CROUCHING;
|
||||
else flags &= ~ENTITY_FLAG_CROUCHING;
|
||||
if (input.sprint) flags |= ENTITY_FLAG_SPRINTING;
|
||||
else flags &= ~ENTITY_FLAG_SPRINTING;
|
||||
entity.set_flags(flags);
|
||||
|
||||
// Apply movement on ground vs in air
|
||||
if (flags & ENTITY_FLAG_GROUNDED) {
|
||||
apply_ground_movement(entity, delta);
|
||||
} else {
|
||||
apply_air_movement(entity, delta);
|
||||
}
|
||||
|
||||
// Handle jump
|
||||
if (input.jump && (flags & ENTITY_FLAG_GROUNDED)) {
|
||||
godot::Vector3 vel = entity.get_velocity();
|
||||
vel.y = params_.jump_velocity;
|
||||
entity.set_velocity(vel);
|
||||
entity.set_flags(flags & ~ENTITY_FLAG_GROUNDED);
|
||||
}
|
||||
|
||||
// Apply gravity
|
||||
godot::Vector3 vel = entity.get_velocity();
|
||||
vel.y += params_.gravity * delta;
|
||||
entity.set_velocity(vel);
|
||||
|
||||
// Integrate
|
||||
integrate_position(entity, delta);
|
||||
|
||||
// Simple ground detection: if y velocity resolved, flag grounded
|
||||
// (Full ground detection requires scene query — this is the stub)
|
||||
vel = entity.get_velocity();
|
||||
if (entity.get_position().y <= 0.0f && vel.y <= 0.0f) {
|
||||
godot::Vector3 pos = entity.get_position();
|
||||
pos.y = 0.0f;
|
||||
entity.set_position(pos);
|
||||
vel.y = 0.0f;
|
||||
entity.set_velocity(vel);
|
||||
uint16_t f = entity.get_flags();
|
||||
f |= ENTITY_FLAG_GROUNDED;
|
||||
entity.set_flags(f);
|
||||
}
|
||||
}
|
||||
|
||||
void MovementComponent::apply_ground_movement(Entity &entity, float delta) {
|
||||
const EntityInput &input = entity.last_input();
|
||||
godot::Vector3 vel = entity.get_velocity();
|
||||
|
||||
// Determine target speed
|
||||
float max_speed = params_.walk_speed;
|
||||
if (entity.get_flags() & ENTITY_FLAG_SPRINTING) max_speed = params_.sprint_speed;
|
||||
if (entity.get_flags() & ENTITY_FLAG_CROUCHING) max_speed = params_.crouch_speed;
|
||||
|
||||
// Apply friction
|
||||
float speed = vel.length();
|
||||
if (speed > 0.0f) {
|
||||
float drop = speed * params_.friction * delta;
|
||||
float new_speed = std::max(0.0f, speed - drop);
|
||||
vel *= (new_speed / speed);
|
||||
}
|
||||
|
||||
// Acceleration from input
|
||||
godot::Vector3 wish_dir = input.move_direction;
|
||||
float wish_len = wish_dir.length();
|
||||
if (wish_len > 1.0f) wish_dir /= wish_len;
|
||||
if (wish_len > 0.0f) {
|
||||
vel += wish_dir * params_.acceleration * delta;
|
||||
// Clamp to max speed in the horizontal plane
|
||||
godot::Vector3 horiz(vel.x, 0.0f, vel.z);
|
||||
float horiz_len = horiz.length();
|
||||
if (horiz_len > max_speed) {
|
||||
horiz *= max_speed / horiz_len;
|
||||
vel.x = horiz.x;
|
||||
vel.z = horiz.z;
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp overall velocity
|
||||
if (vel.length() > params_.max_velocity) {
|
||||
vel = vel.normalized() * params_.max_velocity;
|
||||
}
|
||||
|
||||
entity.set_velocity(vel);
|
||||
}
|
||||
|
||||
void MovementComponent::apply_air_movement(Entity &entity, float delta) {
|
||||
const EntityInput &input = entity.last_input();
|
||||
godot::Vector3 vel = entity.get_velocity();
|
||||
|
||||
// Apply air friction
|
||||
float speed = vel.length();
|
||||
if (speed > 0.0f) {
|
||||
float drop = speed * params_.air_friction * delta;
|
||||
float new_speed = std::max(0.0f, speed - drop);
|
||||
vel *= (new_speed / speed);
|
||||
}
|
||||
|
||||
// Reduced acceleration in air
|
||||
godot::Vector3 wish_dir = input.move_direction;
|
||||
float wish_len = wish_dir.length();
|
||||
if (wish_len > 1.0f) wish_dir /= wish_len;
|
||||
if (wish_len > 0.0f) {
|
||||
// Only accelerate in horizontal plane while airborne
|
||||
godot::Vector3 horiz_accel(
|
||||
wish_dir.x * params_.air_acceleration * delta,
|
||||
0.0f,
|
||||
wish_dir.z * params_.air_acceleration * delta
|
||||
);
|
||||
vel.x += horiz_accel.x;
|
||||
vel.z += horiz_accel.z;
|
||||
}
|
||||
|
||||
// Clamp horizontal speed
|
||||
godot::Vector3 horiz(vel.x, 0.0f, vel.z);
|
||||
float max_horiz = std::max(params_.walk_speed, params_.sprint_speed);
|
||||
if (horiz.length() > max_horiz) {
|
||||
horiz *= max_horiz / horiz.length();
|
||||
vel.x = horiz.x;
|
||||
vel.z = horiz.z;
|
||||
}
|
||||
|
||||
entity.set_velocity(vel);
|
||||
}
|
||||
|
||||
void MovementComponent::integrate_position(Entity &entity, float delta) {
|
||||
godot::Vector3 pos = entity.get_position();
|
||||
godot::Vector3 vel = entity.get_velocity();
|
||||
pos += vel * delta;
|
||||
entity.set_position(pos);
|
||||
}
|
||||
|
||||
} // namespace tactical_shooter
|
||||
@@ -0,0 +1,71 @@
|
||||
#ifndef TACTICAL_SHOOTER_MOVEMENT_COMPONENT_H
|
||||
#define TACTICAL_SHOOTER_MOVEMENT_COMPONENT_H
|
||||
|
||||
#include "entity.h"
|
||||
|
||||
namespace tactical_shooter {
|
||||
|
||||
/**
|
||||
* Physics-less movement simulation for networked FPS characters.
|
||||
*
|
||||
* Operates on Entity state directly — no Godot PhysicsServer3D involvement.
|
||||
* Uses semi-implicit Euler integration with configurable movement parameters.
|
||||
*
|
||||
* All values assume "Godot units" (1 unit ≈ 1 meter) with gravity in
|
||||
* units/s² and speeds in units/s.
|
||||
*/
|
||||
class MovementComponent {
|
||||
public:
|
||||
struct Parameters {
|
||||
float walk_speed = 4.0f; // units/s
|
||||
float sprint_speed = 6.5f; // units/s
|
||||
float crouch_speed = 2.0f; // units/s
|
||||
float acceleration = 20.0f; // units/s²
|
||||
float air_acceleration = 4.0f; // reduced control in air
|
||||
float friction = 8.0f; // ground deceleration
|
||||
float air_friction = 1.0f; // air resistance
|
||||
float jump_velocity = 5.0f; // initial upward velocity
|
||||
float gravity = -20.0f; // units/s² (negative = downward)
|
||||
float max_velocity = 10.0f; // speed cap (all directions)
|
||||
float crouch_height = 0.75f; // multiplier on entity base height
|
||||
float stand_height = 1.0f;
|
||||
};
|
||||
|
||||
MovementComponent();
|
||||
explicit MovementComponent(const Parameters ¶ms);
|
||||
|
||||
/**
|
||||
* Main update. Called once per simulation tick (e.g. every 1/128s).
|
||||
*
|
||||
* @param entity Entity to update
|
||||
* @param delta Timestep in seconds (e.g. 0.0078125 for 128Hz)
|
||||
*/
|
||||
void update(Entity &entity, float delta);
|
||||
|
||||
/// Modify movement parameters at runtime
|
||||
void set_parameters(const Parameters ¶ms);
|
||||
const Parameters ¶meters() const { return params_; }
|
||||
|
||||
private:
|
||||
/**
|
||||
* Apply ground movement with acceleration/friction.
|
||||
* Follows Quake/CS-style movement model (non-Strafe).
|
||||
*/
|
||||
void apply_ground_movement(Entity &entity, float delta);
|
||||
|
||||
/**
|
||||
* Apply air movement with reduced acceleration.
|
||||
*/
|
||||
void apply_air_movement(Entity &entity, float delta);
|
||||
|
||||
/**
|
||||
* Integrate velocity into position (semi-implicit Euler).
|
||||
*/
|
||||
void integrate_position(Entity &entity, float delta);
|
||||
|
||||
Parameters params_;
|
||||
};
|
||||
|
||||
} // namespace tactical_shooter
|
||||
|
||||
#endif // TACTICAL_SHOOTER_MOVEMENT_COMPONENT_H
|
||||
@@ -0,0 +1,54 @@
|
||||
#include "register_types.h"
|
||||
#include "simulation_server.h"
|
||||
|
||||
#include <gdextension_interface.h>
|
||||
#include <godot_cpp/core/class_db.hpp>
|
||||
#include <godot_cpp/core/defs.hpp>
|
||||
#include <godot_cpp/godot.hpp>
|
||||
|
||||
namespace tactical_shooter {
|
||||
|
||||
void initialize_simulation_module(godot::ModuleInitializationLevel p_level) {
|
||||
if (p_level != godot::MODULE_INITIALIZATION_LEVEL_SCENE) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Register all GDScript-facing classes
|
||||
godot::ClassDB::register_class<SimulationServer>();
|
||||
godot::ClassDB::register_class<Entity>();
|
||||
}
|
||||
|
||||
void uninitialize_simulation_module(godot::ModuleInitializationLevel p_level) {
|
||||
if (p_level != godot::MODULE_INITIALIZATION_LEVEL_SCENE) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace tactical_shooter
|
||||
|
||||
extern "C" {
|
||||
|
||||
// GDExtension entry point — called by Godot to initialize the extension
|
||||
GDExtensionBool GDE_EXPORT gdextension_entry(
|
||||
GDExtensionInterfaceGetProcAddress p_get_proc_address,
|
||||
GDExtensionClassLibraryPtr p_library,
|
||||
GDExtensionInitialization *r_initialization
|
||||
) {
|
||||
godot::GDExtensionBinding::InitObject init_obj(
|
||||
p_get_proc_address, p_library, r_initialization
|
||||
);
|
||||
|
||||
init_obj.register_initializer(
|
||||
tactical_shooter::initialize_simulation_module
|
||||
);
|
||||
init_obj.register_terminator(
|
||||
tactical_shooter::uninitialize_simulation_module
|
||||
);
|
||||
init_obj.set_minimum_library_initialization_level(
|
||||
godot::MODULE_INITIALIZATION_LEVEL_SCENE
|
||||
);
|
||||
|
||||
return init_obj.init();
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
@@ -0,0 +1,14 @@
|
||||
#ifndef TACTICAL_SHOOTER_REGISTER_TYPES_H
|
||||
#define TACTICAL_SHOOTER_REGISTER_TYPES_H
|
||||
|
||||
#include <godot_cpp/core/defs.hpp>
|
||||
#include <godot_cpp/godot.hpp>
|
||||
|
||||
namespace tactical_shooter {
|
||||
|
||||
void initialize_simulation_module(godot::ModuleInitializationLevel p_level);
|
||||
void uninitialize_simulation_module(godot::ModuleInitializationLevel p_level);
|
||||
|
||||
} // namespace tactical_shooter
|
||||
|
||||
#endif // TACTICAL_SHOOTER_REGISTER_TYPES_H
|
||||
@@ -0,0 +1,679 @@
|
||||
#include "simulation_server.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace tactical_shooter {
|
||||
|
||||
static constexpr float kPi = 3.14159265358979323846f;
|
||||
|
||||
using Clock = std::chrono::high_resolution_clock;
|
||||
|
||||
SimulationServer::SimulationServer()
|
||||
: entities_by_id_(kMaxEntities),
|
||||
queued_inputs_(kMaxEntities) {
|
||||
}
|
||||
|
||||
SimulationServer::~SimulationServer() {
|
||||
stop();
|
||||
}
|
||||
|
||||
// ---- Lifecycle ----------------------------------------------------------------
|
||||
|
||||
void SimulationServer::set_tick_rate(uint32_t hz) {
|
||||
tick_hz_ = std::clamp(hz, 1u, 1000u);
|
||||
tick_interval_ = 1.0f / static_cast<float>(tick_hz_);
|
||||
}
|
||||
|
||||
void SimulationServer::start() {
|
||||
if (running_) return;
|
||||
running_ = true;
|
||||
current_tick_ = 0;
|
||||
time_accumulator_ = 0.0f;
|
||||
last_snapshots_.clear();
|
||||
hit_detection_.set_entities(living_entity_ptrs_);
|
||||
reset_stats();
|
||||
weapon_config_ = WeaponDamage();
|
||||
weapon_fire_rate_hz_ = 10.0f;
|
||||
|
||||
// Initialize position history ring buffer
|
||||
position_history_.clear();
|
||||
position_history_.resize(history_depth_);
|
||||
history_write_index_ = 0;
|
||||
for (auto &entry : position_history_) {
|
||||
entry.tick = 0;
|
||||
}
|
||||
|
||||
last_hit_result_ = LastHitResult{};
|
||||
}
|
||||
|
||||
void SimulationServer::stop() {
|
||||
running_ = false;
|
||||
for (auto &entity : entities_by_id_) {
|
||||
entity = godot::Ref<Entity>();
|
||||
}
|
||||
living_entity_ptrs_.clear();
|
||||
queued_inputs_.clear();
|
||||
queued_inputs_.resize(kMaxEntities);
|
||||
pending_fires_.clear();
|
||||
last_snapshots_.clear();
|
||||
position_history_.clear();
|
||||
current_tick_ = 0;
|
||||
time_accumulator_ = 0.0f;
|
||||
last_hit_result_ = LastHitResult{};
|
||||
}
|
||||
|
||||
bool SimulationServer::can_tick(float delta) {
|
||||
if (!running_) return false;
|
||||
time_accumulator_ += delta;
|
||||
return time_accumulator_ >= tick_interval_;
|
||||
}
|
||||
|
||||
godot::PackedByteArray SimulationServer::tick() {
|
||||
if (!running_) return godot::PackedByteArray();
|
||||
|
||||
// Drain as many fixed timestep ticks as accumulated
|
||||
while (time_accumulator_ >= tick_interval_) {
|
||||
time_accumulator_ -= tick_interval_;
|
||||
process_tick();
|
||||
}
|
||||
|
||||
// Serialize and return the current state
|
||||
return serialize_state();
|
||||
}
|
||||
|
||||
// ---- Entity Management -------------------------------------------------------
|
||||
|
||||
uint16_t SimulationServer::spawn_entity(const godot::Vector3 &position) {
|
||||
uint16_t id = allocate_entity_id();
|
||||
if (id >= kMaxEntities) return UINT16_MAX;
|
||||
|
||||
auto entity = godot::Ref<Entity>(memnew(Entity));
|
||||
entity->set_entity_id(id);
|
||||
entity->reset(position);
|
||||
entities_by_id_[id] = entity;
|
||||
|
||||
// Rebuild living ptrs list
|
||||
living_entity_ptrs_.clear();
|
||||
for (auto &e : entities_by_id_) {
|
||||
if (e.is_valid() && e->is_alive()) {
|
||||
living_entity_ptrs_.push_back(e.ptr());
|
||||
}
|
||||
}
|
||||
hit_detection_.set_entities(living_entity_ptrs_);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
void SimulationServer::despawn_entity(uint16_t entity_id) {
|
||||
if (entity_id < kMaxEntities && entities_by_id_[entity_id].is_valid()) {
|
||||
entities_by_id_[entity_id] = godot::Ref<Entity>();
|
||||
last_snapshots_.erase(entity_id);
|
||||
|
||||
// Rebuild living ptrs
|
||||
living_entity_ptrs_.clear();
|
||||
for (auto &e : entities_by_id_) {
|
||||
if (e.is_valid() && e->is_alive()) {
|
||||
living_entity_ptrs_.push_back(e.ptr());
|
||||
}
|
||||
}
|
||||
hit_detection_.set_entities(living_entity_ptrs_);
|
||||
}
|
||||
}
|
||||
|
||||
godot::Ref<Entity> SimulationServer::get_entity(uint16_t entity_id) {
|
||||
if (entity_id < kMaxEntities && entities_by_id_[entity_id].is_valid()) {
|
||||
return entities_by_id_[entity_id];
|
||||
}
|
||||
return godot::Ref<Entity>();
|
||||
}
|
||||
|
||||
uint16_t SimulationServer::get_entity_count() const {
|
||||
uint16_t count = 0;
|
||||
for (auto &e : entities_by_id_) {
|
||||
if (e.is_valid()) ++count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
godot::Array SimulationServer::get_entity_ids() const {
|
||||
godot::Array ids;
|
||||
for (auto &e : entities_by_id_) {
|
||||
if (e.is_valid()) {
|
||||
ids.push_back(e->get_entity_id());
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ---- Input --------------------------------------------------------------------
|
||||
|
||||
void SimulationServer::apply_input(uint16_t entity_id, const godot::Dictionary &input_dict) {
|
||||
if (entity_id >= kMaxEntities) return;
|
||||
if (!entities_by_id_[entity_id].is_valid()) return;
|
||||
|
||||
EntityInput input;
|
||||
|
||||
if (input_dict.has("move_direction")) {
|
||||
input.move_direction = input_dict["move_direction"];
|
||||
}
|
||||
if (input_dict.has("look_yaw")) {
|
||||
input.look_yaw = static_cast<float>(static_cast<double>(input_dict["look_yaw"]));
|
||||
}
|
||||
if (input_dict.has("look_pitch")) {
|
||||
input.look_pitch = static_cast<float>(static_cast<double>(input_dict["look_pitch"]));
|
||||
}
|
||||
if (input_dict.has("jump")) {
|
||||
input.jump = input_dict["jump"];
|
||||
}
|
||||
if (input_dict.has("crouch")) {
|
||||
input.crouch = input_dict["crouch"];
|
||||
}
|
||||
if (input_dict.has("sprint")) {
|
||||
input.sprint = input_dict["sprint"];
|
||||
}
|
||||
if (input_dict.has("shoot")) {
|
||||
input.shoot = input_dict["shoot"];
|
||||
}
|
||||
if (input_dict.has("aim")) {
|
||||
input.aim = input_dict["aim"];
|
||||
}
|
||||
if (input_dict.has("input_sequence")) {
|
||||
input.input_sequence = static_cast<uint32_t>(static_cast<int64_t>(input_dict["input_sequence"]));
|
||||
}
|
||||
|
||||
// If the shoot flag just transitioned from false→true, queue a weapon fire
|
||||
// with the tick of this input so lag compensation has the right time reference.
|
||||
if (input.shoot && !queued_inputs_[entity_id].input.shoot) {
|
||||
PendingFire fire;
|
||||
fire.shooter_id = entity_id;
|
||||
fire.fire_tick = current_tick_;
|
||||
fire.input_sequence = input.input_sequence;
|
||||
pending_fires_.push_back(fire);
|
||||
}
|
||||
|
||||
queued_inputs_[entity_id].input = input;
|
||||
queued_inputs_[entity_id].pending = true;
|
||||
}
|
||||
|
||||
void SimulationServer::fire_weapon(uint16_t entity_id) {
|
||||
if (entity_id < kMaxEntities && entities_by_id_[entity_id].is_valid()) {
|
||||
PendingFire fire;
|
||||
fire.shooter_id = entity_id;
|
||||
fire.fire_tick = current_tick_;
|
||||
// Use the input sequence from the shooter's last input if available
|
||||
if (entities_by_id_[entity_id].is_valid()) {
|
||||
fire.input_sequence = entities_by_id_[entity_id]->last_input().input_sequence;
|
||||
}
|
||||
pending_fires_.push_back(fire);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Movement Configuration --------------------------------------------------
|
||||
|
||||
void SimulationServer::set_movement_walk_speed(float speed) {
|
||||
auto p = movement_.parameters();
|
||||
p.walk_speed = speed;
|
||||
movement_.set_parameters(p);
|
||||
}
|
||||
|
||||
void SimulationServer::set_movement_sprint_speed(float speed) {
|
||||
auto p = movement_.parameters();
|
||||
p.sprint_speed = speed;
|
||||
movement_.set_parameters(p);
|
||||
}
|
||||
|
||||
void SimulationServer::set_movement_crouch_speed(float speed) {
|
||||
auto p = movement_.parameters();
|
||||
p.crouch_speed = speed;
|
||||
movement_.set_parameters(p);
|
||||
}
|
||||
|
||||
void SimulationServer::set_movement_acceleration(float accel) {
|
||||
auto p = movement_.parameters();
|
||||
p.acceleration = accel;
|
||||
movement_.set_parameters(p);
|
||||
}
|
||||
|
||||
void SimulationServer::set_movement_jump_velocity(float vel) {
|
||||
auto p = movement_.parameters();
|
||||
p.jump_velocity = vel;
|
||||
movement_.set_parameters(p);
|
||||
}
|
||||
|
||||
void SimulationServer::set_movement_gravity(float gravity) {
|
||||
auto p = movement_.parameters();
|
||||
p.gravity = gravity;
|
||||
movement_.set_parameters(p);
|
||||
}
|
||||
|
||||
void SimulationServer::set_movement_config(const godot::Dictionary &config) {
|
||||
auto p = movement_.parameters();
|
||||
if (config.has("walk_speed")) p.walk_speed = static_cast<float>(static_cast<double>(config["walk_speed"]));
|
||||
if (config.has("sprint_speed")) p.sprint_speed = static_cast<float>(static_cast<double>(config["sprint_speed"]));
|
||||
if (config.has("crouch_speed")) p.crouch_speed = static_cast<float>(static_cast<double>(config["crouch_speed"]));
|
||||
if (config.has("acceleration")) p.acceleration = static_cast<float>(static_cast<double>(config["acceleration"]));
|
||||
if (config.has("air_acceleration")) p.air_acceleration = static_cast<float>(static_cast<double>(config["air_acceleration"]));
|
||||
if (config.has("jump_velocity")) p.jump_velocity = static_cast<float>(static_cast<double>(config["jump_velocity"]));
|
||||
if (config.has("gravity")) p.gravity = static_cast<float>(static_cast<double>(config["gravity"]));
|
||||
if (config.has("friction")) p.friction = static_cast<float>(static_cast<double>(config["friction"]));
|
||||
if (config.has("max_velocity")) p.max_velocity = static_cast<float>(static_cast<double>(config["max_velocity"]));
|
||||
movement_.set_parameters(p);
|
||||
}
|
||||
|
||||
// ---- Benchmark / Stats -------------------------------------------------------
|
||||
|
||||
godot::Dictionary SimulationServer::get_stats() const {
|
||||
godot::Dictionary stats;
|
||||
stats["last_tick_usec"] = static_cast<int64_t>(last_tick_usec_);
|
||||
stats["tick_count"] = static_cast<int64_t>(tick_count_);
|
||||
stats["entity_count"] = static_cast<int64_t>(get_entity_count());
|
||||
|
||||
float avg_usec = 0.0f;
|
||||
if (tick_count_ > 0) {
|
||||
avg_usec = static_cast<float>(total_tick_usec_) / static_cast<float>(tick_count_);
|
||||
}
|
||||
stats["avg_tick_usec"] = avg_usec;
|
||||
stats["peak_tick_usec"] = static_cast<float>(max_tick_usec_);
|
||||
|
||||
// Also expose tick interval
|
||||
stats["tick_hz"] = static_cast<int64_t>(tick_hz_);
|
||||
stats["tick_interval_usec"] = static_cast<int64_t>(tick_interval_ * 1'000'000.0f);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
void SimulationServer::reset_stats() {
|
||||
tick_count_ = 0;
|
||||
last_tick_usec_ = 0;
|
||||
total_tick_usec_ = 0;
|
||||
max_tick_usec_ = 0;
|
||||
}
|
||||
|
||||
void SimulationServer::populate_bots(uint16_t count) {
|
||||
count = std::min(count, static_cast<uint16_t>(kMaxEntities));
|
||||
|
||||
// Clear existing
|
||||
for (auto &e : entities_by_id_) {
|
||||
e = godot::Ref<Entity>();
|
||||
}
|
||||
last_snapshots_.clear();
|
||||
|
||||
// Spawn bots in a grid pattern
|
||||
uint16_t per_row = static_cast<uint16_t>(std::ceil(std::sqrt(static_cast<float>(count))));
|
||||
for (uint16_t i = 0; i < count; ++i) {
|
||||
uint16_t row = i / per_row;
|
||||
uint16_t col = i % per_row;
|
||||
godot::Vector3 pos(
|
||||
static_cast<float>(col) * 3.0f,
|
||||
0.0f,
|
||||
static_cast<float>(row) * 3.0f
|
||||
);
|
||||
spawn_entity(pos);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Private: Tick Logic -----------------------------------------------------
|
||||
|
||||
void SimulationServer::process_tick() {
|
||||
auto tick_start = Clock::now();
|
||||
|
||||
// 1. Apply queued inputs from clients
|
||||
for (uint16_t i = 0; i < kMaxEntities; ++i) {
|
||||
if (queued_inputs_[i].pending && entities_by_id_[i].is_valid()) {
|
||||
entities_by_id_[i]->apply_input(queued_inputs_[i].input);
|
||||
queued_inputs_[i].pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Record pre-move positions for lag compensation (BEFORE movement)
|
||||
record_position_history(current_tick_);
|
||||
|
||||
// 3. Update entity positions
|
||||
update_movement();
|
||||
|
||||
// 4. Process combat with lag compensation
|
||||
update_combat();
|
||||
|
||||
// 5. Process queued weapon fires from fire_weapon() calls (deprecated path,
|
||||
// kept for backwards compat — modern path queues fires via apply_input)
|
||||
// This is now handled inside update_combat() which drains pending_fires_
|
||||
|
||||
// 6. Clean up dead entities after combat
|
||||
for (auto &entity : entities_by_id_) {
|
||||
if (entity.is_valid() && !entity->is_alive()) {
|
||||
// Keep dead entities in list for ragdoll/corpse but mark
|
||||
// We could despawn here if needed
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Rebuild living entity ptrs for hit detection next tick
|
||||
living_entity_ptrs_.clear();
|
||||
for (auto &e : entities_by_id_) {
|
||||
if (e.is_valid() && e->is_alive()) {
|
||||
living_entity_ptrs_.push_back(e.ptr());
|
||||
}
|
||||
}
|
||||
hit_detection_.set_entities(living_entity_ptrs_);
|
||||
|
||||
++current_tick_;
|
||||
++tick_count_;
|
||||
|
||||
// Timing
|
||||
auto tick_end = Clock::now();
|
||||
last_tick_usec_ = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
tick_end - tick_start
|
||||
).count();
|
||||
total_tick_usec_ += last_tick_usec_;
|
||||
max_tick_usec_ = std::max(max_tick_usec_, last_tick_usec_);
|
||||
}
|
||||
|
||||
void SimulationServer::update_movement() {
|
||||
float delta = tick_interval_;
|
||||
|
||||
for (auto &entity_ref : living_entity_ptrs_) {
|
||||
movement_.update(*entity_ref, delta);
|
||||
}
|
||||
}
|
||||
|
||||
void SimulationServer::update_combat() {
|
||||
if (pending_fires_.empty()) return;
|
||||
|
||||
// Sort fires by fire_tick (oldest first) for deterministic processing
|
||||
std::sort(pending_fires_.begin(), pending_fires_.end(),
|
||||
[](const PendingFire &a, const PendingFire &b) {
|
||||
return a.fire_tick < b.fire_tick;
|
||||
});
|
||||
|
||||
// Process each fire with lag compensation
|
||||
for (auto &fire : pending_fires_) {
|
||||
process_compensated_fire(fire);
|
||||
}
|
||||
|
||||
pending_fires_.clear();
|
||||
}
|
||||
|
||||
// ---- Lag Compensation ---------------------------------------------------------
|
||||
|
||||
void SimulationServer::record_position_history(uint32_t tick) {
|
||||
TickPositionSnapshot &slot = position_history_[history_write_index_];
|
||||
slot.tick = tick;
|
||||
slot.entries.clear();
|
||||
|
||||
for (Entity *entity : living_entity_ptrs_) {
|
||||
if (!entity) continue;
|
||||
TickPositionSnapshot::Entry entry;
|
||||
entry.entity_id = entity->get_entity_id();
|
||||
entry.position = entity->get_position();
|
||||
entry.flags = entity->get_flags();
|
||||
slot.entries.push_back(entry);
|
||||
}
|
||||
|
||||
history_write_index_ = (history_write_index_ + 1) % history_depth_;
|
||||
}
|
||||
|
||||
SimulationServer::RewindState SimulationServer::begin_rewind(uint32_t target_tick) {
|
||||
RewindState state;
|
||||
|
||||
// Find the closest position snapshot at or before the target tick
|
||||
// Search backward from the write index
|
||||
for (uint32_t i = 0; i < history_depth_; ++i) {
|
||||
uint32_t idx = (history_write_index_ + history_depth_ - 1 - i) % history_depth_;
|
||||
const TickPositionSnapshot &snap = position_history_[idx];
|
||||
if (snap.tick == 0) break; // uninitialized slot
|
||||
|
||||
if (snap.tick <= target_tick) {
|
||||
// Save current positions, then rewind to snapshot positions
|
||||
for (auto &entry : snap.entries) {
|
||||
if (entry.entity_id < kMaxEntities &&
|
||||
entities_by_id_[entry.entity_id].is_valid()) {
|
||||
// Save current position
|
||||
TickPositionSnapshot::Entry saved;
|
||||
saved.entity_id = entry.entity_id;
|
||||
saved.position = entities_by_id_[entry.entity_id]->get_position();
|
||||
saved.flags = entities_by_id_[entry.entity_id]->get_flags();
|
||||
state.saved.push_back(saved);
|
||||
|
||||
// Rewind to snapshot position
|
||||
entities_by_id_[entry.entity_id]->set_position(entry.position);
|
||||
entities_by_id_[entry.entity_id]->set_flags(entry.flags);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update hit detection with current (now rewound) positions
|
||||
// The living_entity_ptrs_ haven't changed, only positions have
|
||||
return state;
|
||||
}
|
||||
|
||||
void SimulationServer::end_rewind(const RewindState &state) {
|
||||
// Restore all saved positions
|
||||
for (auto &saved : state.saved) {
|
||||
if (saved.entity_id < kMaxEntities &&
|
||||
entities_by_id_[saved.entity_id].is_valid()) {
|
||||
entities_by_id_[saved.entity_id]->set_position(saved.position);
|
||||
entities_by_id_[saved.entity_id]->set_flags(saved.flags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SimulationServer::process_compensated_fire(const PendingFire &fire) {
|
||||
auto &shooter = entities_by_id_[fire.shooter_id];
|
||||
if (!shooter.is_valid() || !shooter->is_alive()) return;
|
||||
|
||||
// Calculate fire direction from shooter's yaw/pitch at time of fire
|
||||
float yaw_rad = shooter->get_yaw() * (kPi / 180.0f);
|
||||
float pitch_rad = shooter->get_pitch() * (kPi / 180.0f);
|
||||
|
||||
godot::Vector3 direction(
|
||||
std::cos(pitch_rad) * std::sin(yaw_rad),
|
||||
-std::sin(pitch_rad),
|
||||
std::cos(pitch_rad) * std::cos(yaw_rad)
|
||||
);
|
||||
|
||||
// Apply spread (random cone)
|
||||
if (weapon_config_.spread_degrees > 0.0f) {
|
||||
float spread_rad = weapon_config_.spread_degrees * (kPi / 180.0f);
|
||||
float theta = static_cast<float>(rand() % 360) * (kPi / 180.0f);
|
||||
float phi = static_cast<float>(rand() % 100) / 100.0f * spread_rad;
|
||||
godot::Vector3 up(0.0f, 1.0f, 0.0f);
|
||||
godot::Vector3 right = direction.cross(up).normalized();
|
||||
if (right.length_squared() < 0.001f) {
|
||||
right = godot::Vector3(1.0f, 0.0f, 0.0f);
|
||||
}
|
||||
up = right.cross(direction).normalized();
|
||||
godot::Vector3 spread_offset = right * std::sin(theta) * std::sin(phi)
|
||||
+ up * std::cos(theta) * std::sin(phi);
|
||||
direction = (direction + spread_offset).normalized();
|
||||
}
|
||||
|
||||
// Shooter's eye position (current — not rewound)
|
||||
godot::Vector3 origin = shooter->get_position();
|
||||
origin.y += 1.5f; // eye height
|
||||
|
||||
// BEGIN LAG COMPENSATION: rewind targets to fire_tick positions
|
||||
RewindState rewind_state = begin_rewind(fire.fire_tick);
|
||||
|
||||
// Restore shooter to current position immediately (don't rewind the shooter)
|
||||
for (auto it = rewind_state.saved.begin(); it != rewind_state.saved.end(); ) {
|
||||
if (it->entity_id == fire.shooter_id) {
|
||||
// Put shooter back to current position
|
||||
entities_by_id_[fire.shooter_id]->set_position(it->position);
|
||||
entities_by_id_[fire.shooter_id]->set_flags(it->flags);
|
||||
// Remove from rewind set so end_rewind doesn't re-restore it
|
||||
it = rewind_state.saved.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
// Update hit detection reference (pointers are still valid, positions changed)
|
||||
hit_detection_.set_entities(living_entity_ptrs_);
|
||||
|
||||
// Fire the shot against rewound target positions
|
||||
HitResult hit = hit_detection_.process_shot(
|
||||
origin, direction, fire.shooter_id, weapon_config_
|
||||
);
|
||||
|
||||
// END LAG COMPENSATION: restore all entity positions
|
||||
end_rewind(rewind_state);
|
||||
hit_detection_.set_entities(living_entity_ptrs_);
|
||||
|
||||
// Apply damage if hit
|
||||
if (hit.hit && hit.entity_id < kMaxEntities) {
|
||||
auto &target_ref = entities_by_id_[hit.entity_id];
|
||||
if (target_ref.is_valid()) {
|
||||
Entity *target_ptr = target_ref.ptr();
|
||||
HitDetection::apply_damage(*target_ptr, hit.damage, 1.0f);
|
||||
|
||||
// Record hit result for GDScript feedback
|
||||
last_hit_result_.hit = true;
|
||||
last_hit_result_.entity_id = hit.entity_id;
|
||||
last_hit_result_.damage = hit.damage;
|
||||
last_hit_result_.distance = hit.distance;
|
||||
last_hit_result_.hitbox_id = hit.hitbox_id;
|
||||
last_hit_result_.killed = !target_ptr->is_alive();
|
||||
}
|
||||
} else {
|
||||
last_hit_result_.hit = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Weapon Config ------------------------------------------------------------
|
||||
|
||||
void SimulationServer::set_weapon_config(const godot::Dictionary &config) {
|
||||
WeaponDamage wd;
|
||||
if (config.has("base_damage")) wd.base_damage = static_cast<float>(static_cast<double>(config["base_damage"]));
|
||||
if (config.has("head_multiplier")) wd.head_multiplier = static_cast<float>(static_cast<double>(config["head_multiplier"]));
|
||||
if (config.has("body_multiplier")) wd.body_multiplier = static_cast<float>(static_cast<double>(config["body_multiplier"]));
|
||||
if (config.has("arm_multiplier")) wd.arm_multiplier = static_cast<float>(static_cast<double>(config["arm_multiplier"]));
|
||||
if (config.has("leg_multiplier")) wd.leg_multiplier = static_cast<float>(static_cast<double>(config["leg_multiplier"]));
|
||||
if (config.has("max_range")) wd.max_range = static_cast<float>(static_cast<double>(config["max_range"]));
|
||||
if (config.has("spread_degrees")) wd.spread_degrees = static_cast<float>(static_cast<double>(config["spread_degrees"]));
|
||||
if (config.has("fire_rate_hz")) weapon_fire_rate_hz_ = static_cast<float>(static_cast<double>(config["fire_rate_hz"]));
|
||||
weapon_config_ = wd;
|
||||
}
|
||||
|
||||
void SimulationServer::set_history_depth(uint32_t ticks) {
|
||||
history_depth_ = std::clamp(ticks, 16u, 256u);
|
||||
}
|
||||
|
||||
godot::Dictionary SimulationServer::get_last_hit_result() const {
|
||||
godot::Dictionary result;
|
||||
result["hit"] = last_hit_result_.hit;
|
||||
result["entity_id"] = static_cast<int64_t>(last_hit_result_.entity_id);
|
||||
result["damage"] = last_hit_result_.damage;
|
||||
result["distance"] = last_hit_result_.distance;
|
||||
result["hitbox_id"] = static_cast<int64_t>(last_hit_result_.hitbox_id);
|
||||
result["killed"] = last_hit_result_.killed;
|
||||
return result;
|
||||
}
|
||||
|
||||
godot::PackedByteArray SimulationServer::serialize_state() {
|
||||
const uint16_t count = static_cast<uint16_t>(living_entity_ptrs_.size());
|
||||
if (count == 0) return godot::PackedByteArray();
|
||||
|
||||
// Pre-allocate a reasonable buffer
|
||||
Bitstream stream;
|
||||
stream.reserve(1024); // 1KB should cover a full snapshot of 32 entities
|
||||
|
||||
// Use delta compression if we have a base snapshot
|
||||
if (!last_snapshots_.empty()) {
|
||||
serializer_.write_delta_snapshot(
|
||||
stream, current_tick_,
|
||||
living_entity_ptrs_.data(), count,
|
||||
last_snapshots_
|
||||
);
|
||||
} else {
|
||||
serializer_.write_full_snapshot(
|
||||
stream, current_tick_,
|
||||
living_entity_ptrs_.data(), count
|
||||
);
|
||||
}
|
||||
|
||||
// Update last snapshots for next delta
|
||||
for (Entity *entity : living_entity_ptrs_) {
|
||||
if (entity) {
|
||||
last_snapshots_[entity->get_entity_id()] = entity->capture_snapshot();
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to PackedByteArray
|
||||
godot::PackedByteArray result;
|
||||
size_t byte_size = stream.byte_size();
|
||||
result.resize(static_cast<int64_t>(byte_size));
|
||||
if (byte_size > 0) {
|
||||
uint8_t *dst = result.ptrw();
|
||||
memcpy(dst, stream.data(), byte_size);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- Private: Helpers --------------------------------------------------------
|
||||
|
||||
uint16_t SimulationServer::allocate_entity_id() {
|
||||
for (uint16_t i = 0; i < kMaxEntities; ++i) {
|
||||
if (!entities_by_id_[i].is_valid()) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return UINT16_MAX;
|
||||
}
|
||||
|
||||
// ---- GDScript Bindings -------------------------------------------------------
|
||||
|
||||
void SimulationServer::_bind_methods() {
|
||||
using namespace godot;
|
||||
|
||||
// Lifecycle
|
||||
ClassDB::bind_method(D_METHOD("set_tick_rate", "hz"), &SimulationServer::set_tick_rate);
|
||||
ClassDB::bind_method(D_METHOD("get_tick_rate"), &SimulationServer::get_tick_rate);
|
||||
ADD_PROPERTY(PropertyInfo(Variant::INT, "tick_rate"), "set_tick_rate", "get_tick_rate");
|
||||
|
||||
ClassDB::bind_method(D_METHOD("start"), &SimulationServer::start);
|
||||
ClassDB::bind_method(D_METHOD("stop"), &SimulationServer::stop);
|
||||
ClassDB::bind_method(D_METHOD("is_running"), &SimulationServer::is_running);
|
||||
ClassDB::bind_method(D_METHOD("can_tick", "delta"), &SimulationServer::can_tick);
|
||||
ClassDB::bind_method(D_METHOD("tick"), &SimulationServer::tick);
|
||||
|
||||
// Entity management
|
||||
ClassDB::bind_method(D_METHOD("spawn_entity", "position"), &SimulationServer::spawn_entity);
|
||||
ClassDB::bind_method(D_METHOD("despawn_entity", "entity_id"), &SimulationServer::despawn_entity);
|
||||
ClassDB::bind_method(D_METHOD("get_entity", "entity_id"), &SimulationServer::get_entity);
|
||||
ClassDB::bind_method(D_METHOD("get_entity_count"), &SimulationServer::get_entity_count);
|
||||
ClassDB::bind_method(D_METHOD("get_entity_ids"), &SimulationServer::get_entity_ids);
|
||||
|
||||
// Input
|
||||
ClassDB::bind_method(D_METHOD("apply_input", "entity_id", "input_dict"), &SimulationServer::apply_input);
|
||||
ClassDB::bind_method(D_METHOD("fire_weapon", "entity_id"), &SimulationServer::fire_weapon);
|
||||
|
||||
// Movement config
|
||||
ClassDB::bind_method(D_METHOD("set_movement_walk_speed", "speed"), &SimulationServer::set_movement_walk_speed);
|
||||
ClassDB::bind_method(D_METHOD("set_movement_sprint_speed", "speed"), &SimulationServer::set_movement_sprint_speed);
|
||||
ClassDB::bind_method(D_METHOD("set_movement_crouch_speed", "speed"), &SimulationServer::set_movement_crouch_speed);
|
||||
ClassDB::bind_method(D_METHOD("set_movement_acceleration", "accel"), &SimulationServer::set_movement_acceleration);
|
||||
ClassDB::bind_method(D_METHOD("set_movement_jump_velocity", "vel"), &SimulationServer::set_movement_jump_velocity);
|
||||
ClassDB::bind_method(D_METHOD("set_movement_gravity", "gravity"), &SimulationServer::set_movement_gravity);
|
||||
ClassDB::bind_method(D_METHOD("set_movement_config", "config"), &SimulationServer::set_movement_config);
|
||||
|
||||
// Weapon config
|
||||
ClassDB::bind_method(D_METHOD("set_weapon_config", "config"), &SimulationServer::set_weapon_config);
|
||||
|
||||
// Lag compensation
|
||||
ClassDB::bind_method(D_METHOD("set_history_depth", "ticks"), &SimulationServer::set_history_depth);
|
||||
|
||||
// Hit feedback
|
||||
ClassDB::bind_method(D_METHOD("get_last_hit_result"), &SimulationServer::get_last_hit_result);
|
||||
|
||||
// Benchmark
|
||||
ClassDB::bind_method(D_METHOD("get_stats"), &SimulationServer::get_stats);
|
||||
ClassDB::bind_method(D_METHOD("reset_stats"), &SimulationServer::reset_stats);
|
||||
ClassDB::bind_method(D_METHOD("populate_bots", "count"), &SimulationServer::populate_bots);
|
||||
}
|
||||
|
||||
} // namespace tactical_shooter
|
||||
@@ -0,0 +1,347 @@
|
||||
#ifndef TACTICAL_SHOOTER_SIMULATION_SERVER_H
|
||||
#define TACTICAL_SHOOTER_SIMULATION_SERVER_H
|
||||
|
||||
#include "entity.h"
|
||||
#include "hit_detection.h"
|
||||
#include "movement_component.h"
|
||||
#include "state_serializer.h"
|
||||
|
||||
#include <godot_cpp/classes/object.hpp>
|
||||
#include <godot_cpp/classes/ref.hpp>
|
||||
#include <godot_cpp/variant/dictionary.hpp>
|
||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
||||
#include <godot_cpp/variant/vector3.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace tactical_shooter {
|
||||
|
||||
/**
|
||||
* Position history ring-buffer entry — stores entity positions at a given tick.
|
||||
* Used for lag compensation: we can rewind targets to where they were when a
|
||||
* shot was fired, even if they've since moved.
|
||||
*/
|
||||
struct TickPositionSnapshot {
|
||||
uint32_t tick = 0;
|
||||
struct Entry {
|
||||
uint16_t entity_id;
|
||||
godot::Vector3 position;
|
||||
uint16_t flags;
|
||||
};
|
||||
std::vector<Entry> entries;
|
||||
};
|
||||
|
||||
/**
|
||||
* A pending weapon fire with time-of-fire information for lag compensation.
|
||||
*/
|
||||
struct PendingFire {
|
||||
uint16_t shooter_id = 0xFFFF;
|
||||
uint32_t fire_tick = 0; // Server tick at which the fire was queued
|
||||
uint32_t input_sequence = 0; // Client's input sequence (for reconciliation)
|
||||
};
|
||||
|
||||
/**
|
||||
* SimulationServer — the heart of the tactical shooter simulation.
|
||||
*
|
||||
* This singleton manages the entire 128Hz game simulation loop from C++,
|
||||
* entirely avoiding GDScript VM overhead in the hot path.
|
||||
*
|
||||
* GDScript integration:
|
||||
* var server = SimulationServer.new()
|
||||
* server.set_tick_rate(128)
|
||||
* server.start()
|
||||
* # In _process():
|
||||
* while server.can_tick(delta):
|
||||
* var serialized = server.tick()
|
||||
* # send serialized to network layer
|
||||
*
|
||||
* Architecture:
|
||||
* - All entity simulation (movement, hit detection) runs in C++
|
||||
* - Serialized state is handed to GDScript for network transport
|
||||
* - Player input arrives from GDScript, gets applied per-entity
|
||||
* - Lag compensation stores a ring buffer of pre-move positions each tick
|
||||
* - Benchmark hooks for the 128Hz load test (t_f671f48a)
|
||||
*/
|
||||
class SimulationServer : public godot::Object {
|
||||
GDCLASS(SimulationServer, godot::Object)
|
||||
|
||||
public:
|
||||
SimulationServer();
|
||||
~SimulationServer();
|
||||
|
||||
// ---- Lifecycle (GDScript API) ------------------------------------------
|
||||
|
||||
/**
|
||||
* Set the simulation tick rate (calls per second).
|
||||
* Default: 128 (for 128Hz tick).
|
||||
*/
|
||||
void set_tick_rate(uint32_t hz);
|
||||
|
||||
uint32_t get_tick_rate() const { return tick_hz_; }
|
||||
|
||||
/**
|
||||
* Start the simulation. Creates initial entities if count > 0.
|
||||
*/
|
||||
void start();
|
||||
|
||||
/**
|
||||
* Stop the simulation. Clears all entities and resets state.
|
||||
*/
|
||||
void stop();
|
||||
|
||||
/**
|
||||
* Check if the simulation is running.
|
||||
*/
|
||||
bool is_running() const { return running_; }
|
||||
|
||||
/**
|
||||
* Accrue time and check if a tick is due.
|
||||
* Call this in _process(delta) and call tick() while this returns true.
|
||||
*
|
||||
* Example (GDScript):
|
||||
* while server.can_tick(delta):
|
||||
* input = server.tick()
|
||||
*/
|
||||
bool can_tick(float delta);
|
||||
|
||||
/**
|
||||
* Advance one simulation tick.
|
||||
* Returns serialized snapshot as a PackedByteArray (or empty if no tick due).
|
||||
*/
|
||||
godot::PackedByteArray tick();
|
||||
|
||||
// ---- Entity Management (GDScript API) -----------------------------------
|
||||
|
||||
/**
|
||||
* Spawn a new entity at the given position.
|
||||
* Returns the entity ID (0..65535) or UINT16_MAX on failure.
|
||||
*/
|
||||
uint16_t spawn_entity(const godot::Vector3 &position);
|
||||
|
||||
/**
|
||||
* Despawn an entity by ID.
|
||||
*/
|
||||
void despawn_entity(uint16_t entity_id);
|
||||
|
||||
/**
|
||||
* Get an entity by ID. Returns null if not found.
|
||||
*/
|
||||
godot::Ref<Entity> get_entity(uint16_t entity_id);
|
||||
|
||||
/**
|
||||
* Get number of active entities.
|
||||
*/
|
||||
uint16_t get_entity_count() const;
|
||||
|
||||
/**
|
||||
* Get entity IDs of all active entities as an array.
|
||||
*/
|
||||
godot::Array get_entity_ids() const;
|
||||
|
||||
// ---- Input (GDScript API) -----------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply input for a specific entity on the next tick.
|
||||
* Call this from _process() as input arrives.
|
||||
*
|
||||
* @param entity_id Target entity
|
||||
* @param input_dict Dictionary with keys: "move_direction" (Vector3),
|
||||
* "look_yaw" (float), "look_pitch" (float), "jump" (bool),
|
||||
* "crouch" (bool), "sprint" (bool), "shoot" (bool), "aim" (bool),
|
||||
* "input_sequence" (int)
|
||||
*/
|
||||
void apply_input(uint16_t entity_id, const godot::Dictionary &input_dict);
|
||||
|
||||
/**
|
||||
* Queue a weapon fire from an entity.
|
||||
* Fires on the next tick.
|
||||
*/
|
||||
void fire_weapon(uint16_t entity_id);
|
||||
|
||||
// ---- Movement Configuration (GDScript API) -------------------------------
|
||||
|
||||
void set_movement_walk_speed(float speed);
|
||||
void set_movement_sprint_speed(float speed);
|
||||
void set_movement_crouch_speed(float speed);
|
||||
void set_movement_acceleration(float accel);
|
||||
void set_movement_jump_velocity(float vel);
|
||||
void set_movement_gravity(float gravity);
|
||||
|
||||
void set_movement_config(const godot::Dictionary &config);
|
||||
|
||||
// ---- Weapon Configuration (GDScript API) ----------------------------------
|
||||
|
||||
/**
|
||||
* Set the weapon damage profile for hitscan weapons.
|
||||
* Called from GDScript to define weapon stats.
|
||||
*
|
||||
* Dictionary keys:
|
||||
* base_damage (float) — base damage per shot
|
||||
* head_multiplier (float) — damage multiplier for head hits
|
||||
* body_multiplier (float) — damage multiplier for body hits
|
||||
* arm_multiplier (float) — damage multiplier for arm hits
|
||||
* leg_multiplier (float) — damage multiplier for leg hits
|
||||
* max_range (float) — maximum shot distance in units
|
||||
* spread_degrees (float) — random spread per shot (degrees)
|
||||
* fire_rate_hz (float) — max shots per second
|
||||
*/
|
||||
void set_weapon_config(const godot::Dictionary &config);
|
||||
|
||||
// ---- Lag Compensation Configuration (GDScript API) -------------------------
|
||||
|
||||
/**
|
||||
* Set the depth of the position history ring buffer (in ticks).
|
||||
* More ticks = can compensate for higher latency but uses more memory.
|
||||
* Default: 64 ticks (~500ms at 128Hz, enough for any reasonable ping).
|
||||
*/
|
||||
void set_history_depth(uint32_t ticks);
|
||||
|
||||
// ---- Damaged / Death Signal (GDScript API via Dictionary return) -----------
|
||||
|
||||
/**
|
||||
* Get the most recent hit result from processing combat.
|
||||
* Returns Dictionary with: hit (bool), entity_id (int), damage (float),
|
||||
* distance (float), hitbox_id (int), killed (bool).
|
||||
* Empty if no shot was processed this tick.
|
||||
*/
|
||||
godot::Dictionary get_last_hit_result() const;
|
||||
|
||||
// ---- Benchmark / Stats (GDScript API) ------------------------------------
|
||||
|
||||
/**
|
||||
* Get tick timing statistics for benchmarking.
|
||||
* Returns Dictionary with: "last_tick_usec" (int), "avg_tick_usec" (float),
|
||||
* "peak_tick_usec" (float), "tick_count" (int), "entity_count" (int)
|
||||
*/
|
||||
godot::Dictionary get_stats() const;
|
||||
|
||||
/**
|
||||
* Reset benchmark statistics.
|
||||
*/
|
||||
void reset_stats();
|
||||
|
||||
/**
|
||||
* Populate simulation with N bots for load testing.
|
||||
* Places them in a grid pattern.
|
||||
*/
|
||||
void populate_bots(uint16_t count);
|
||||
|
||||
protected:
|
||||
static void _bind_methods();
|
||||
|
||||
private:
|
||||
// ---- Internal tick logic ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Advance the simulation by one tick (fixed timestep).
|
||||
*/
|
||||
void process_tick();
|
||||
|
||||
/**
|
||||
* Update all entity movement.
|
||||
*/
|
||||
void update_movement();
|
||||
|
||||
/**
|
||||
* Process all queued weapon fires.
|
||||
*/
|
||||
void update_combat();
|
||||
|
||||
/**
|
||||
* Collect and serialize the current simulation state.
|
||||
*/
|
||||
godot::PackedByteArray serialize_state();
|
||||
|
||||
// ---- Helpers ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Find the lowest available entity ID.
|
||||
*/
|
||||
uint16_t allocate_entity_id();
|
||||
|
||||
// ---- Lag Compensation (Position History) ----------------------------------
|
||||
|
||||
/**
|
||||
* Record pre-move positions of all living entities for the given tick.
|
||||
* Called at the start of process_tick(), BEFORE update_movement().
|
||||
*/
|
||||
void record_position_history(uint32_t tick);
|
||||
|
||||
/**
|
||||
* Rewind target entities to positions stored at a given tick for hit
|
||||
* detection, then restore them back.
|
||||
*/
|
||||
struct RewindState {
|
||||
std::vector<TickPositionSnapshot::Entry> saved;
|
||||
};
|
||||
RewindState begin_rewind(uint32_t target_tick);
|
||||
void end_rewind(const RewindState &state);
|
||||
|
||||
/**
|
||||
* Process a single compensated fire: rewind, check hit, restore.
|
||||
*/
|
||||
void process_compensated_fire(const PendingFire &fire);
|
||||
|
||||
// ---- State --------------------------------------------------------------
|
||||
|
||||
bool running_ = false;
|
||||
uint32_t tick_hz_ = 128;
|
||||
float tick_interval_ = 1.0f / 128.0f;
|
||||
float time_accumulator_ = 0.0f;
|
||||
|
||||
// Tick counter
|
||||
uint32_t current_tick_ = 0;
|
||||
|
||||
// Entity storage (by ID)
|
||||
std::vector<godot::Ref<Entity>> entities_by_id_;
|
||||
std::vector<Entity *> living_entity_ptrs_; // raw ptrs for hot loop
|
||||
|
||||
// Systems
|
||||
MovementComponent movement_;
|
||||
HitDetection hit_detection_;
|
||||
StateSerializer serializer_;
|
||||
|
||||
// Queued input (applied on next tick, cleared after)
|
||||
struct QueuedInput {
|
||||
EntityInput input;
|
||||
bool pending = false;
|
||||
};
|
||||
std::vector<QueuedInput> queued_inputs_;
|
||||
|
||||
// Queued weapon fires with lag-compensation info
|
||||
std::vector<PendingFire> pending_fires_;
|
||||
WeaponDamage weapon_config_;
|
||||
float weapon_fire_rate_hz_ = 10.0f;
|
||||
|
||||
// Last hit result (queried by GDScript for feedback)
|
||||
struct LastHitResult {
|
||||
bool hit = false;
|
||||
uint16_t entity_id = 0xFFFF;
|
||||
float damage = 0.0f;
|
||||
float distance = 0.0f;
|
||||
uint8_t hitbox_id = 0;
|
||||
bool killed = false;
|
||||
};
|
||||
LastHitResult last_hit_result_;
|
||||
|
||||
// Position history ring buffer (for lag compensation)
|
||||
std::vector<TickPositionSnapshot> position_history_;
|
||||
uint32_t history_depth_ = 64;
|
||||
uint32_t history_write_index_ = 0;
|
||||
|
||||
// Last serialized snapshot per entity (for delta compression)
|
||||
std::unordered_map<uint16_t, EntitySnapshot> last_snapshots_;
|
||||
|
||||
// Benchmark stats
|
||||
uint64_t tick_count_ = 0;
|
||||
uint64_t last_tick_usec_ = 0;
|
||||
uint64_t total_tick_usec_ = 0;
|
||||
uint64_t max_tick_usec_ = 0;
|
||||
};
|
||||
|
||||
} // namespace tactical_shooter
|
||||
|
||||
#endif // TACTICAL_SHOOTER_SIMULATION_SERVER_H
|
||||
@@ -0,0 +1,262 @@
|
||||
#include "state_serializer.h"
|
||||
|
||||
#include <cassert>
|
||||
|
||||
namespace tactical_shooter {
|
||||
|
||||
StateSerializer::StateSerializer(const SerializationConfig &config)
|
||||
: config_(config) {}
|
||||
|
||||
// ---- Write Full --------------------------------------------------------------
|
||||
|
||||
void StateSerializer::write_full_snapshot(
|
||||
Bitstream &stream,
|
||||
uint32_t tick,
|
||||
const Entity *const *entities,
|
||||
uint16_t count
|
||||
) {
|
||||
// Header: tick, entity count, base_tick=0 (full)
|
||||
stream.write_uint32(tick);
|
||||
stream.write_uint16(count);
|
||||
stream.write_uint16(0); // base_tick=0 signals "full snapshot"
|
||||
|
||||
for (uint16_t i = 0; i < count; ++i) {
|
||||
if (entities[i] && entities[i]->is_alive()) {
|
||||
write_entity_full(stream, *entities[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Write Delta -------------------------------------------------------------
|
||||
|
||||
void StateSerializer::write_delta_snapshot(
|
||||
Bitstream &stream,
|
||||
uint32_t tick,
|
||||
const Entity *const *entities,
|
||||
uint16_t count,
|
||||
const std::unordered_map<uint16_t, EntitySnapshot> &base
|
||||
) {
|
||||
// Count changed entities first
|
||||
uint16_t changed_count = 0;
|
||||
for (uint16_t i = 0; i < count; ++i) {
|
||||
if (!entities[i] || !entities[i]->is_alive()) continue;
|
||||
uint16_t id = entities[i]->get_entity_id();
|
||||
auto it = base.find(id);
|
||||
if (it == base.end()) {
|
||||
++changed_count; // new entity = full
|
||||
} else {
|
||||
EntitySnapshot::ChangeMask mask =
|
||||
entities[i]->compute_change_mask(it->second);
|
||||
if (mask != EntitySnapshot::CHANGED_NONE) {
|
||||
++changed_count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Header: tick, count, delta_tick
|
||||
stream.write_uint32(tick);
|
||||
stream.write_uint16(changed_count);
|
||||
// We need the tick of the base snapshot — for now write 0 (delta from
|
||||
// client's last acked tick which the client tracks itself).
|
||||
stream.write_uint16(0);
|
||||
|
||||
// Write delta entities
|
||||
for (uint16_t i = 0; i < count; ++i) {
|
||||
if (!entities[i] || !entities[i]->is_alive()) continue;
|
||||
uint16_t id = entities[i]->get_entity_id();
|
||||
auto it = base.find(id);
|
||||
if (it == base.end()) {
|
||||
// New entity: write full state with CHANGED_ALL
|
||||
stream.write_uint16(id);
|
||||
stream.write_uint32(EntitySnapshot::CHANGED_ALL);
|
||||
write_entity_full(stream, *entities[i]);
|
||||
} else {
|
||||
EntitySnapshot::ChangeMask mask =
|
||||
entities[i]->compute_change_mask(it->second);
|
||||
if (mask != EntitySnapshot::CHANGED_NONE) {
|
||||
stream.write_uint16(id);
|
||||
stream.write_uint32(static_cast<uint32_t>(mask));
|
||||
write_entity_delta(stream, *entities[i], it->second, mask);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Read Full ---------------------------------------------------------------
|
||||
|
||||
std::vector<EntitySnapshot> StateSerializer::read_full_snapshot(
|
||||
Bitstream &stream,
|
||||
uint32_t *out_tick
|
||||
) {
|
||||
std::vector<EntitySnapshot> result;
|
||||
|
||||
*out_tick = stream.read_uint32();
|
||||
uint16_t count = stream.read_uint16();
|
||||
uint16_t base_tick = stream.read_uint16(); // should be 0 for full
|
||||
|
||||
(void)base_tick; // unused in full read
|
||||
|
||||
result.reserve(count);
|
||||
for (uint16_t i = 0; i < count; ++i) {
|
||||
uint16_t id = stream.read_uint16();
|
||||
EntitySnapshot::ChangeMask mask = read_change_mask(stream);
|
||||
EntitySnapshot snap = read_entity(stream, mask);
|
||||
snap.entity_id = id;
|
||||
result.push_back(snap);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- Read Delta --------------------------------------------------------------
|
||||
|
||||
void StateSerializer::read_delta_snapshot(
|
||||
Bitstream &stream,
|
||||
std::unordered_map<uint16_t, EntitySnapshot> &state
|
||||
) {
|
||||
uint32_t tick = stream.read_uint32();
|
||||
uint16_t count = stream.read_uint16();
|
||||
uint16_t base_tick = stream.read_uint16();
|
||||
|
||||
(void)tick;
|
||||
(void)base_tick;
|
||||
|
||||
for (uint16_t i = 0; i < count; ++i) {
|
||||
uint16_t id = stream.read_uint16();
|
||||
EntitySnapshot::ChangeMask mask = read_change_mask(stream);
|
||||
EntitySnapshot snap = read_entity(stream, mask);
|
||||
snap.entity_id = id;
|
||||
|
||||
if (mask == EntitySnapshot::CHANGED_ALL) {
|
||||
state[id] = snap; // replace entirely
|
||||
} else {
|
||||
// Merge delta into existing state
|
||||
auto it = state.find(id);
|
||||
if (it != state.end()) {
|
||||
if (mask & EntitySnapshot::CHANGED_POSITION) it->second.position = snap.position;
|
||||
if (mask & EntitySnapshot::CHANGED_VELOCITY) it->second.velocity = snap.velocity;
|
||||
if (mask & EntitySnapshot::CHANGED_ROTATION) { it->second.yaw = snap.yaw; it->second.pitch = snap.pitch; }
|
||||
if (mask & EntitySnapshot::CHANGED_HEALTH) it->second.health = snap.health;
|
||||
if (mask & EntitySnapshot::CHANGED_ARMOR) it->second.armor = snap.armor;
|
||||
if (mask & EntitySnapshot::CHANGED_WEAPON) it->second.weapon_id = snap.weapon_id;
|
||||
if (mask & EntitySnapshot::CHANGED_AMMO) it->second.ammo = snap.ammo;
|
||||
if (mask & EntitySnapshot::CHANGED_FLAGS) it->second.flags = snap.flags;
|
||||
if (mask & EntitySnapshot::CHANGED_INPUT) it->second.last_processed_input = snap.last_processed_input;
|
||||
} else {
|
||||
state[id] = snap; // missing base, fall back to full replace
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Private: write ----------------------------------------------------------
|
||||
|
||||
void StateSerializer::write_entity_full(Bitstream &stream, const Entity &entity) {
|
||||
EntitySnapshot snap = entity.capture_snapshot();
|
||||
write_entity_delta(stream, entity, EntitySnapshot{}, EntitySnapshot::CHANGED_ALL);
|
||||
}
|
||||
|
||||
void StateSerializer::write_entity_delta(
|
||||
Bitstream &stream,
|
||||
const Entity &entity,
|
||||
const EntitySnapshot &base,
|
||||
EntitySnapshot::ChangeMask mask
|
||||
) {
|
||||
EntitySnapshot snap = entity.capture_snapshot();
|
||||
|
||||
// Position
|
||||
if (mask & EntitySnapshot::CHANGED_POSITION) {
|
||||
stream.write_float_quantized(snap.position.x, config_.pos_min, config_.pos_max, config_.pos_bits);
|
||||
stream.write_float_quantized(snap.position.y, config_.pos_min, config_.pos_max, config_.pos_bits);
|
||||
stream.write_float_quantized(snap.position.z, config_.pos_min, config_.pos_max, config_.pos_bits);
|
||||
}
|
||||
|
||||
// Velocity
|
||||
if (mask & EntitySnapshot::CHANGED_VELOCITY) {
|
||||
stream.write_float_quantized(snap.velocity.x, config_.vel_min, config_.vel_max, config_.vel_bits);
|
||||
stream.write_float_quantized(snap.velocity.y, config_.vel_min, config_.vel_max, config_.vel_bits);
|
||||
stream.write_float_quantized(snap.velocity.z, config_.vel_min, config_.vel_max, config_.vel_bits);
|
||||
}
|
||||
|
||||
// Rotation
|
||||
if (mask & EntitySnapshot::CHANGED_ROTATION) {
|
||||
stream.write_float_quantized(snap.yaw, config_.yaw_min, config_.yaw_max, config_.yaw_bits);
|
||||
stream.write_float_quantized(snap.pitch, config_.pitch_min, config_.pitch_max, config_.pitch_bits);
|
||||
}
|
||||
|
||||
// Health/Armor
|
||||
if (mask & EntitySnapshot::CHANGED_HEALTH) {
|
||||
stream.write_float_quantized(snap.health, config_.health_min, config_.health_max, config_.health_bits);
|
||||
}
|
||||
if (mask & EntitySnapshot::CHANGED_ARMOR) {
|
||||
stream.write_float_quantized(snap.armor, config_.health_min, config_.health_max, config_.health_bits);
|
||||
}
|
||||
|
||||
// Weapon/Ammo
|
||||
if (mask & EntitySnapshot::CHANGED_WEAPON) {
|
||||
stream.write_uint8(snap.weapon_id);
|
||||
}
|
||||
if (mask & EntitySnapshot::CHANGED_AMMO) {
|
||||
stream.write_uint16(snap.ammo);
|
||||
}
|
||||
|
||||
// Flags
|
||||
if (mask & EntitySnapshot::CHANGED_FLAGS) {
|
||||
stream.write_uint16(snap.flags);
|
||||
}
|
||||
|
||||
// Input sequence
|
||||
if (mask & EntitySnapshot::CHANGED_INPUT) {
|
||||
stream.write_uint32(snap.last_processed_input);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Private: read -----------------------------------------------------------
|
||||
|
||||
EntitySnapshot::ChangeMask StateSerializer::read_change_mask(Bitstream &stream) {
|
||||
return static_cast<EntitySnapshot::ChangeMask>(stream.read_uint32());
|
||||
}
|
||||
|
||||
EntitySnapshot StateSerializer::read_entity(
|
||||
Bitstream &stream,
|
||||
EntitySnapshot::ChangeMask mask
|
||||
) {
|
||||
EntitySnapshot snap;
|
||||
|
||||
if (mask & EntitySnapshot::CHANGED_POSITION) {
|
||||
snap.position.x = stream.read_float_quantized(config_.pos_min, config_.pos_max, config_.pos_bits);
|
||||
snap.position.y = stream.read_float_quantized(config_.pos_min, config_.pos_max, config_.pos_bits);
|
||||
snap.position.z = stream.read_float_quantized(config_.pos_min, config_.pos_max, config_.pos_bits);
|
||||
}
|
||||
if (mask & EntitySnapshot::CHANGED_VELOCITY) {
|
||||
snap.velocity.x = stream.read_float_quantized(config_.vel_min, config_.vel_max, config_.vel_bits);
|
||||
snap.velocity.y = stream.read_float_quantized(config_.vel_min, config_.vel_max, config_.vel_bits);
|
||||
snap.velocity.z = stream.read_float_quantized(config_.vel_min, config_.vel_max, config_.vel_bits);
|
||||
}
|
||||
if (mask & EntitySnapshot::CHANGED_ROTATION) {
|
||||
snap.yaw = stream.read_float_quantized(config_.yaw_min, config_.yaw_max, config_.yaw_bits);
|
||||
snap.pitch = stream.read_float_quantized(config_.pitch_min, config_.pitch_max, config_.pitch_bits);
|
||||
}
|
||||
if (mask & EntitySnapshot::CHANGED_HEALTH) {
|
||||
snap.health = stream.read_float_quantized(config_.health_min, config_.health_max, config_.health_bits);
|
||||
}
|
||||
if (mask & EntitySnapshot::CHANGED_ARMOR) {
|
||||
snap.armor = stream.read_float_quantized(config_.health_min, config_.health_max, config_.health_bits);
|
||||
}
|
||||
if (mask & EntitySnapshot::CHANGED_WEAPON) {
|
||||
snap.weapon_id = stream.read_uint8();
|
||||
}
|
||||
if (mask & EntitySnapshot::CHANGED_AMMO) {
|
||||
snap.ammo = stream.read_uint16();
|
||||
}
|
||||
if (mask & EntitySnapshot::CHANGED_FLAGS) {
|
||||
snap.flags = stream.read_uint16();
|
||||
}
|
||||
if (mask & EntitySnapshot::CHANGED_INPUT) {
|
||||
snap.last_processed_input = stream.read_uint32();
|
||||
}
|
||||
|
||||
return snap;
|
||||
}
|
||||
|
||||
} // namespace tactical_shooter
|
||||
@@ -0,0 +1,136 @@
|
||||
#ifndef TACTICAL_SHOOTER_STATE_SERIALIZER_H
|
||||
#define TACTICAL_SHOOTER_STATE_SERIALIZER_H
|
||||
|
||||
#include "bitstream.h"
|
||||
#include "entity.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace tactical_shooter {
|
||||
|
||||
/**
|
||||
* Serialization ranges for quantized float packing.
|
||||
* These define the min/max and precision budget for each field type.
|
||||
* Tune per-game to balance bandwidth vs accuracy.
|
||||
*/
|
||||
struct SerializationConfig {
|
||||
// Position (quantized to 16 bits per axis = 65536 values)
|
||||
float pos_min = -1024.0f;
|
||||
float pos_max = 1024.0f;
|
||||
uint8_t pos_bits = 16;
|
||||
|
||||
// Velocity (quantized to 12 bits per axis)
|
||||
float vel_min = -32.0f;
|
||||
float vel_max = 32.0f;
|
||||
uint8_t vel_bits = 12;
|
||||
|
||||
// Rotation (yaw -180..180, pitch -90..90)
|
||||
float yaw_min = -180.0f;
|
||||
float yaw_max = 180.0f;
|
||||
uint8_t yaw_bits = 12; // ~0.09° precision
|
||||
|
||||
float pitch_min = -90.0f;
|
||||
float pitch_max = 90.0f;
|
||||
uint8_t pitch_bits = 11; // ~0.09° precision
|
||||
|
||||
// Health/Armor (0..100, 7 bits = 0.79 precision)
|
||||
float health_min = 0.0f;
|
||||
float health_max = 100.0f;
|
||||
uint8_t health_bits = 7;
|
||||
|
||||
// Ammo (0..255, 8 bits exact)
|
||||
};
|
||||
|
||||
/**
|
||||
* Delta-compressed snapshot serializer for network replication.
|
||||
*
|
||||
* Writes a compact binary representation of entity states suitable for
|
||||
* 128Hz server-to-client replication. Full snapshots include all entities;
|
||||
* delta snapshots only include entities with changes since the base snapshot.
|
||||
*
|
||||
* Snapshot on-wire layout:
|
||||
* [uint16 tick] [uint16 count] [uint16 base_tick] <- header (6 bytes)
|
||||
* for each entity:
|
||||
* [uint16 entity_id] [uint32 change_mask]
|
||||
* [changed fields per mask]
|
||||
*
|
||||
* Typical size at 128Hz, 10 entities:
|
||||
* Full snapshot: ~300-400 bytes
|
||||
* Delta snapshot (5 entities changed): ~150-250 bytes
|
||||
*/
|
||||
class StateSerializer {
|
||||
public:
|
||||
explicit StateSerializer(const SerializationConfig &config = SerializationConfig{});
|
||||
|
||||
/**
|
||||
* Serialize a full snapshot including all entities.
|
||||
*
|
||||
* @param stream Output bitstream
|
||||
* @param tick Current simulation tick number
|
||||
* @param entities All active entities
|
||||
* @param count Number of entities
|
||||
*/
|
||||
void write_full_snapshot(
|
||||
Bitstream &stream,
|
||||
uint32_t tick,
|
||||
const Entity *const *entities,
|
||||
uint16_t count
|
||||
);
|
||||
|
||||
/**
|
||||
* Serialize a delta snapshot — only entities that changed from base.
|
||||
*
|
||||
* @param stream Output bitstream
|
||||
* @param tick Current simulation tick number
|
||||
* @param entities All active entities
|
||||
* @param count Number of entities
|
||||
* @param base Base snapshot for delta comparison
|
||||
*/
|
||||
void write_delta_snapshot(
|
||||
Bitstream &stream,
|
||||
uint32_t tick,
|
||||
const Entity *const *entities,
|
||||
uint16_t count,
|
||||
const std::unordered_map<uint16_t, EntitySnapshot> &base
|
||||
);
|
||||
|
||||
/**
|
||||
* Deserialize a full snapshot from a bitstream.
|
||||
*/
|
||||
std::vector<EntitySnapshot> read_full_snapshot(
|
||||
Bitstream &stream,
|
||||
uint32_t *out_tick
|
||||
);
|
||||
|
||||
/**
|
||||
* Deserialize a delta snapshot and apply to a base.
|
||||
*/
|
||||
void read_delta_snapshot(
|
||||
Bitstream &stream,
|
||||
std::unordered_map<uint16_t, EntitySnapshot> &state
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the configuration.
|
||||
*/
|
||||
const SerializationConfig &config() const { return config_; }
|
||||
|
||||
private:
|
||||
void write_entity_full(Bitstream &stream, const Entity &entity);
|
||||
void write_entity_delta(
|
||||
Bitstream &stream,
|
||||
const Entity &entity,
|
||||
const EntitySnapshot &base,
|
||||
EntitySnapshot::ChangeMask mask
|
||||
);
|
||||
EntitySnapshot read_entity(Bitstream &stream, EntitySnapshot::ChangeMask mask);
|
||||
EntitySnapshot::ChangeMask read_change_mask(Bitstream &stream);
|
||||
|
||||
SerializationConfig config_;
|
||||
};
|
||||
|
||||
} // namespace tactical_shooter
|
||||
|
||||
#endif // TACTICAL_SHOOTER_STATE_SERIALIZER_H
|
||||
Reference in New Issue
Block a user