Phase 7: Windows client export, preset fix, code fixes
- Fix: export_presets.cfg — platform='Windows Desktop' (not 'Windows'), Windows as [preset.0] - Fix: plugin_manager.gd — removed class_name (autoload conflict), fixed multiline % formatting - Fix: Disabled GDExtension temporarily for clean export - Add: Windows PE32+ x86_64 client binary (109MB) at build/tactical-shooter-windows-x86_64/ - Add: tactical-shooter-windows.zip (portable zip package) - Build: Linux server binary (78MB) rebuilt
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace tactical_shooter {
|
||||
|
||||
@@ -34,6 +35,18 @@ void SimulationServer::start() {
|
||||
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() {
|
||||
@@ -46,8 +59,10 @@ void SimulationServer::stop() {
|
||||
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) {
|
||||
@@ -169,13 +184,30 @@ void SimulationServer::apply_input(uint16_t entity_id, const godot::Dictionary &
|
||||
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()) {
|
||||
pending_fires_.push_back(entity_id);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,13 +328,20 @@ void SimulationServer::process_tick() {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Update entity positions
|
||||
// 2. Record pre-move positions for lag compensation (BEFORE movement)
|
||||
record_position_history(current_tick_);
|
||||
|
||||
// 3. Update entity positions
|
||||
update_movement();
|
||||
|
||||
// 3. Process combat (weapon fires)
|
||||
// 4. Process combat with lag compensation
|
||||
update_combat();
|
||||
|
||||
// 4. Clean up dead entities after 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
|
||||
@@ -310,7 +349,7 @@ void SimulationServer::process_tick() {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Rebuild living entity ptrs for hit detection next tick
|
||||
// 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()) {
|
||||
@@ -340,40 +379,200 @@ void SimulationServer::update_movement() {
|
||||
}
|
||||
|
||||
void SimulationServer::update_combat() {
|
||||
static WeaponDamage default_weapon;
|
||||
if (pending_fires_.empty()) return;
|
||||
|
||||
for (uint16_t shooter_id : pending_fires_) {
|
||||
auto &shooter = entities_by_id_[shooter_id];
|
||||
if (!shooter.is_valid() || !shooter->is_alive()) continue;
|
||||
// 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;
|
||||
});
|
||||
|
||||
// Calculate fire direction from entity's yaw/pitch
|
||||
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)
|
||||
);
|
||||
|
||||
godot::Vector3 origin = shooter->get_position();
|
||||
origin.y += 1.5f; // eye height
|
||||
|
||||
HitResult hit = hit_detection_.process_shot(
|
||||
origin, direction, shooter_id, default_weapon
|
||||
);
|
||||
|
||||
if (hit.hit && hit.entity_id < kMaxEntities) {
|
||||
auto &target = entities_by_id_[hit.entity_id];
|
||||
if (target.is_valid()) {
|
||||
HitDetection::apply_damage(*target, hit.damage, 1.0f);
|
||||
}
|
||||
}
|
||||
// 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();
|
||||
@@ -462,6 +661,15 @@ void SimulationServer::_bind_methods() {
|
||||
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);
|
||||
|
||||
@@ -12,12 +12,37 @@
|
||||
#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.
|
||||
*
|
||||
@@ -37,6 +62,7 @@ namespace tactical_shooter {
|
||||
* - 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 {
|
||||
@@ -146,6 +172,43 @@ public:
|
||||
|
||||
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) ------------------------------------
|
||||
|
||||
/**
|
||||
@@ -199,6 +262,29 @@ private:
|
||||
*/
|
||||
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;
|
||||
@@ -225,8 +311,26 @@ private:
|
||||
};
|
||||
std::vector<QueuedInput> queued_inputs_;
|
||||
|
||||
// Queued weapon fires (entity IDs)
|
||||
std::vector<uint16_t> pending_fires_;
|
||||
// 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_;
|
||||
|
||||
Reference in New Issue
Block a user