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:
2026-07-01 19:46:52 -04:00
parent e385eae0f5
commit 9ea98aa7b8
36 changed files with 1094 additions and 64 deletions
Binary file not shown.
@@ -0,0 +1,10 @@
[configuration]
entry_symbol = "gdextension_entry"
compatibility_minimum = "4.2"
[libraries]
linux.x86_64 = "res://gdextension/bin/linux/libsimulation.so"
linux.arm64 = "res://gdextension/bin/linux/libsimulation.so"
windows.x86_64 = "res://gdextension/bin/windows/libsimulation.dll"
macos.x86_64 = "res://gdextension/bin/macos/libsimulation.dylib"
macos.arm64 = "res://gdextension/bin/macos/libsimulation.dylib"
@@ -0,0 +1 @@
uid://kl43xi0ixseg
Submodule gdextension/simulation/godot-cpp added at d5cc777a89
+240 -32
View File
@@ -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);
+106 -2
View File
@@ -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_;