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
@@ -0,0 +1 @@
uid://c6dxl1qhan4yl
@@ -0,0 +1 @@
uid://bjtf5jiedmwx8
@@ -92,6 +92,9 @@ var _input_dict: Dictionary = {}
var _mouse_captured: bool = false
var _mouse_clicked_this_frame: bool = false
## Weapon manager reference.
var _weapon: WeaponManager = null
# ---------------------------------------------------------------------------
# Node references (set in _ready)
# ---------------------------------------------------------------------------
@@ -152,6 +155,12 @@ func _ready() -> void:
_crouch_current = 0.0
_update_crouch(0.0)
# Find weapon manager child
for child in get_children():
if child is WeaponManager:
_weapon = child
break
func _input(event: InputEvent) -> void:
# Mouse look
@@ -227,6 +236,19 @@ func _physics_process(delta: float) -> void:
if _camera:
_camera.rotation.x = _pitch
# 5b. Weapon fire with rate limiting via WeaponManager
var should_fire: bool = false
var time_since_engine_start: float = Engine.get_process_ticks() * get_physics_process_delta_time()
if _weapon:
should_fire = _weapon.try_fire(time_since_engine_start)
elif shoot_pressed:
# No weapon manager — fire every tick (unlimited)
should_fire = true
if _server != null:
if should_fire:
_server.fire_weapon(entity_id)
# 6. Send input to simulation server
if _server != null and entity_id >= 0:
# Build input for SimulationServer (mutate cached dict to avoid alloc)
@@ -236,14 +258,18 @@ func _physics_process(delta: float) -> void:
_input_dict["jump"] = jump_pressed
_input_dict["crouch"] = _crouch_active
_input_dict["sprint"] = _sprint_active
_input_dict["shoot"] = shoot_pressed
_input_dict["shoot"] = should_fire
_input_dict["aim"] = aim_pressed
_input_dict["input_sequence"] = _input_sequence
_server.apply_input(entity_id, _input_dict)
if shoot_pressed:
_server.fire_weapon(entity_id)
# Check for hit feedback from last tick
if Engine.has_singleton("SimulationServer") or _server:
var hit_result: Dictionary = _server.get_last_hit_result()
if hit_result.get("hit", false) and _weapon:
var hit_pos := Vector3.ZERO # approximate — we don't have exact world hit position from server yet
_weapon.on_hit_confirmed(hit_pos, hit_result.get("damage", 0.0), hit_result.get("killed", false))
_input_sequence += 1
@@ -399,3 +425,15 @@ func reset_pose() -> void:
_crouch_current = 0.0
_crouch_target = 0.0
_update_crouch(0.0)
## Get the look direction as a normalized Vector3 in world space.
## Uses the current yaw/pitch to compute a forward vector.
func get_look_direction() -> Vector3:
var yaw_rad: float = _yaw
var pitch_rad: float = _pitch
return Vector3(
cos(pitch_rad) * sin(yaw_rad),
-sin(pitch_rad),
cos(pitch_rad) * cos(yaw_rad)
).normalized()
@@ -0,0 +1 @@
uid://cpbvk1itjonm
@@ -0,0 +1 @@
uid://bvkhxr1bv6lth
+205
View File
@@ -0,0 +1,205 @@
## WeaponManager — hitscan weapon system for tactical FPS.
##
## Manages a single hitscan weapon: fire rate limiting, ammo, reload.
## Communicates with SimulationServer for server-authoritative hit detection.
##
## Architecture:
## - Client-side: fire rate limiting, muzzle VFX, hit marker feedback
## - Server-side (via SimulationServer): hit detection, damage, lag compensation
## - Both sides: ammo tracking (server-authoritative, client-predicted)
##
## Usage (attach as child of FPSCharacterController):
##
## FPSCharacterController
## ├── WeaponManager — fire rate, ammo, VFX
## │ ├── MuzzleFlash — instantiated VFX
## │ └── HitMarker — instantiated VFX
## └── FpsCamera
##
class_name WeaponManager
extends Node
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted when the weapon fires (for visual feedback).
signal weapon_fired(origin: Vector3, direction: Vector3)
## Emitted when this weapon hits an entity.
signal hit_marked(hit_pos: Vector3, damage: float, killed: bool)
## Emitted when ammo changes.
signal ammo_changed(current: int, max: int)
## Emitted when player is killed.
signal player_killed()
# ---------------------------------------------------------------------------
# Exports — Assault Rifle Config
# ---------------------------------------------------------------------------
## Maximum ammo capacity.
@export var max_ammo: int = 30
## Starting ammo (including one magazine).
@export var start_ammo: int = 90
## Time between shots (seconds). 10 Hz = 0.1s between shots.
@export var fire_rate: float = 0.1
## Reload time in seconds.
@export var reload_time: float = 2.0
## Spread increases while firing (degrees).
@export var spread_per_shot: float = 0.5
## Max spread when firing continuously (degrees).
@export var max_spread: float = 4.0
## Spread recovery per second (degrees/s).
@export var spread_recovery: float = 8.0
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var current_ammo: int = 30
var reserve_ammo: int = 90
var is_reloading: bool = false
var _last_shot_time: float = 0.0
var _current_spread: float = 0.0
## Reference to the parent FPSCharacterController (set in _ready).
var _controller: FPSCharacterController = null
## Reference to SimulationServer (if available).
var _server: Object = null
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_controller = get_parent() as FPSCharacterController
if _controller == null:
push_warning("WeaponManager: Parent is not FPSCharacterController. Disabled.")
set_process(false)
return
# Find SimulationServer singleton
if Engine.has_singleton("SimulationServer"):
_server = Engine.get_singleton("SimulationServer")
elif Engine.get_main_loop() and Engine.get_main_loop().root:
# Try finding it in the scene tree
var game_server = Engine.get_main_loop().root.find_child("GameServer", true, false)
if game_server and game_server.has_method(&"get_simulation_server"):
_server = game_server.get_simulation_server()
# Initialize ammo
current_ammo = max_ammo
reserve_ammo = start_ammo
# Signal that ammo changed
ammo_changed.emit(current_ammo, max_ammo)
# ---------------------------------------------------------------------------
# Main loop — called from FPSCharacterController._physics_process per tick
# ---------------------------------------------------------------------------
## Try to fire the weapon. Returns true if a shot was fired.
## Called by the controller when shoot input is detected.
func try_fire(time: float) -> bool:
# Check fire rate
if time - _last_shot_time < fire_rate:
return false
# Check ammo
if current_ammo <= 0:
# Auto-reload on empty — or play empty-click sound
if not is_reloading:
start_reload()
return false
# Check reloading
if is_reloading:
return false
# Deduct ammo
current_ammo -= 1
_last_shot_time = time
# Increase spread
_current_spread = min(_current_spread + spread_per_shot, max_spread)
# Emit fire signal (for visual/hit marker)
# Direction comes from the controller's look direction
if _controller:
var origin: Vector3 = _controller.global_position
origin.y += _controller.eye_height_stand
var direction: Vector3 = _controller.get_look_direction()
weapon_fired.emit(origin, direction)
ammo_changed.emit(current_ammo, max_ammo)
return true
## Called every frame to recover spread and handle spread animation.
func _process(delta: float) -> void:
# Recover spread
if _current_spread > 0.0:
_current_spread = max(0.0, _current_spread - spread_recovery * delta)
## Start reloading.
func start_reload() -> void:
if is_reloading or current_ammo >= max_ammo or reserve_ammo <= 0:
return
is_reloading = true
await get_tree().create_timer(reload_time).timeout
# Reload logic
var needed: int = max_ammo - current_ammo
var from_reserve: int = min(needed, reserve_ammo)
current_ammo += from_reserve
reserve_ammo -= from_reserve
is_reloading = false
ammo_changed.emit(current_ammo, max_ammo)
## Called when a hit is confirmed by the server.
func on_hit_confirmed(hit_pos: Vector3, damage: float, killed: bool) -> void:
hit_marked.emit(hit_pos, damage, killed)
if killed:
player_killed.emit()
## Get current spread (degrees).
func get_current_spread() -> float:
return _current_spread
## Get the current weapon fire direction including spread cone.
func get_spread_direction(base_direction: Vector3) -> Vector3:
if _current_spread <= 0.001:
return base_direction
var spread_rad: float = deg_to_rad(_current_spread)
var theta: float = randf() * TAU
var phi: float = randf() * spread_rad
# Create a random perpendicular vector
var up := Vector3.UP
if abs(base_direction.dot(up)) > 0.99:
up = Vector3.RIGHT
var right := base_direction.cross(up).normalized()
up = right.cross(base_direction).normalized()
var offset := right * sin(theta) * sin(phi) + up * cos(theta) * sin(phi)
return (base_direction + offset).normalized()
## Reset weapon state (for respawn).
func reset() -> void:
current_ammo = max_ammo
reserve_ammo = start_ammo
is_reloading = false
_current_spread = 0.0
_last_shot_time = 0.0
ammo_changed.emit(current_ammo, max_ammo)
@@ -0,0 +1 @@
uid://dywlbha8xivx4
+1
View File
@@ -0,0 +1 @@
uid://q8nutj781fyf
+1
View File
@@ -0,0 +1 @@
uid://d0mbp6gs2mwlr
+1
View File
@@ -0,0 +1 @@
uid://dbtpbhja3nk60
+1
View File
@@ -0,0 +1 @@
uid://cwvi2ic1k6ubn
+1
View File
@@ -0,0 +1 @@
uid://olpdh1gx2gca
+1
View File
@@ -0,0 +1 @@
uid://bqau1kivb87fk
+1
View File
@@ -0,0 +1 @@
uid://d0go24t1lsx2j
+1
View File
@@ -0,0 +1 @@
uid://bas55o1773n21
+204
View File
@@ -0,0 +1,204 @@
# Playtest Guide — Tactical Shooter
> Phase 0 dedicated server (headless ENet, 128-tick simulation, GDExtension core).
> For internal / LAN playtesting only.
---
## Server Address
| Network | Address | Notes |
|---------|----------------|---------------------------------|
| Local | `127.0.0.1` | Run server + client on same box |
| LAN | `192.168.0.127`| Local network (eth0) |
| Tailscale | `100.89.190.113` | Tailnet (VPN-like, works from anywhere both peers have Tailscale) |
**Port:** `34197` (default — ENet gameplay port)
Override via environment variable:
```bash
SERVER_PORT=34197 ./tactical-shooter-server.x86_64 --headless
```
---
## Building
### Server (Linux, headless)
```bash
# Export from Godot Editor (preset: "Linux Server"):
# Project → Export → Linux Server → Export Project
# Output: build/tactical-shooter-server.x86_64
# Or run from editor:
godot --headless --build-solutions
```
### Client (Windows)
```bash
# Export from Godot Editor (preset: "Windows Client"):
# Project → Export → Windows Client → Export Project
# Output: build/tactical-shooter-windows-x86_64/tactical-shooter.exe
```
---
## How to Launch
### 1. Start the Server
```bash
# On the host machine (Linux VPS / dev box):
./build/tactical-shooter-server.x86_64 --headless
```
The server logs its ready state:
```
[ServerMain] Tactical Shooter server ready
[ServerMain] Port: 34197
[ServerMain] Name: "Tactical Shooter Server"
[ServerMain] Tick rate: 128 Hz
[ServerMain] Maps: test_range
[ServerMain] Headless: True
```
Stop with `Ctrl+C`.
### 2. Launch the Client
**Option A — Exported binary (Windows):**
```bash
.\tactical-shooter.exe
```
The client auto-connects to `127.0.0.1:34197` by default. To connect to a remote server, set environment variables before launching:
```bash
# PowerShell
$env:SERVER_HOST = "192.168.0.127"
$env:SERVER_PORT = "34197"
.\tactical-shooter.exe
# CMD
set SERVER_HOST=192.168.0.127
set SERVER_PORT=34197
tactical-shooter.exe
```
**Option B — Godot Editor (any OS):**
1. Open the project in Godot 4
2. Open `res://scenes/client/client_main.tscn`
3. Select the `ClientMain` node
4. Set `Server Host` to the server's IP (e.g. `192.168.0.127`)
5. Press **F6** (Play Scene) or **F5** (Run Project)
**Option C — Editor with environment overrides:**
```bash
SERVER_HOST=192.168.0.127 SERVER_PORT=34197 godot res://scenes/client/client_main.tscn
```
---
## Default Input Bindings
These are documented in `client/characters/input/input_handler.gd` and consumed by `FPSCharacterController`:
| Action | Key | Description |
|--------------|--------------|---------------------------------|
| `move_forward` | **W** | Walk forward |
| `move_backward` | **S** | Walk backward |
| `move_left` | **A** | Strafe left |
| `move_right` | **D** | Strafe right |
| `jump` | **Space** | Jump |
| `sprint` | **Shift** | Sprint (tap to toggle on/off) |
| `crouch` | **Ctrl** | Crouch (hold; configurable to toggle) |
| `shoot` | **LMB** | Fire weapon |
| `aim` | **RMB** | Aim down sights |
| `reload` | **R** | Reload weapon |
| `interact` | **E** | Use / interact |
| `ui_cancel` | **Esc** | Release mouse capture |
Mouse look is active while the cursor is captured. Click anywhere in the window to re-capture after pressing Esc.
---
## Server Configuration
Edit `config/default_server_config.cfg` before building, or place a `server_config.cfg` next to the binary at runtime.
Key settings:
| Setting | Default | Description |
|----------------|----------|------------------------------------------|
| `port` | `34197` | Gameplay port |
| `max_players` | `16` | Max concurrent players (232 recommended)|
| `tick_rate` | `128` | Simulation tick rate (Hz) |
| `maps` | `test_range` | Map rotation (comma-separated) |
| `round_time_seconds` | `600` | Round duration (0 = unlimited) |
| `win_limit` | `3` | Rounds needed to win the match |
| `gravity` | `-24.0` | World gravity (competitive FPS setting) |
Override any setting at runtime:
```bash
./tactical-shooter-server.x86_64 --headless -- --port 34197 --max-players 16 --maps test_range
```
---
## Known Limitations (Phase 0)
This is a **foundations prototype**. The following are not yet implemented:
1. **No server browser** — join by IP only. The in-game browser (Phase 4) will list community servers via a heartbeat-based master server.
2. **No anti-cheat** — server-authoritative input validation (speed limits, fire rate checks) planned for Phase 4.
3. **No round system** — players spawn and move freely. Bomb-defuse / elimination / economy loop is Phase 2.
4. **No buy menu / economy** — no weapon purchasing. Only the default Assault Rifle hitscan weapon is available.
5. **Single map** (`test_range`) — the grey-box test environment. Map rotation supports multiple maps in config but only one exists.
6. **Client hardcodes `127.0.0.1`** — the client scene defaults to connecting to `127.0.0.1:34197`. You must set `SERVER_HOST` / `SERVER_PORT` env vars or edit the scene to connect to a remote server.
7. **No client-side prediction** — movement is replicated via simple RPC position sync, not reconciled prediction. Netcode will feel laggy under high ping. Client prediction + server reconciliation is Phase 1.
8. **No RCON** — remote admin console is disabled by default (`rcon.enabled=false`). Phase 4 adds TCP-based admin commands.
9. **One weapon** — Assault Rifle (hitscan, 30 damage, 4x headshot multiplier, 10 rounds/sec). No pistol, sniper, or utility items.
10. **Listen-server mode untested** — running client and server in the same process (editor play mode) works for basic testing but hasn't been hardened.
---
## Troubleshooting
| Symptom | Likely Cause | Fix |
|---------|-------------|-----|
| Client says `Connection failed` | Server not running or wrong IP/port | Verify server is running (`ps aux \| grep tactical`), check IP and port |
| Client connects but sees no other players | Firewall blocking ENet port 34197 | Open UDP port 34197 on the server's firewall |
| `[ServerMain] Map scene not found` | Map path incorrect in config | Check `maps` setting in `config/default_server_config.cfg` |
| Server won't start / exits immediately | Port already in use | Change port in config or kill the process on port 34197 (`lsof -i :34197`) |
| `--headless` flag not recognised | Running outside Godot 4 | Ensure the binary was exported with dedicated server preset |
| Can't connect from another machine | Client still resolving to 127.0.0.1 | Pass `SERVER_HOST=<server-ip>` env var to client |
---
## Quick-Start Cheat Sheet
```bash
# 1. Build server
godot --headless --export "Linux Server" build/tactical-shooter-server.x86_64
# 2. Run server
./build/tactical-shooter-server.x86_64 --headless
# 3. Connect from client (same machine — new terminal)
SERVER_HOST=127.0.0.1 godot res://scenes/client/client_main.tscn
# 4. Connect from another machine on LAN
SERVER_HOST=192.168.0.127 godot res://scenes/client/client_main.tscn
```
---
*Last updated: July 2026*
+46 -10
View File
@@ -1,13 +1,13 @@
[preset.0]
name="Linux Server"
platform="Linux/X11"
name="Windows Desktop"
platform="Windows Desktop"
runnable=true
dedicated_server=true
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="build/tactical-shooter-server.x86_64"
export_path="build/tactical-shooter-windows-x86_64/tactical-shooter.exe"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
@@ -29,19 +29,55 @@ codesign/identity=""
codesign/timestamp=true
codesign/timestamp_server_url=""
application/icon=""
application/identifier="tactical-shooter-client"
application/name="Tactical Shooter"
application/app_category="game"
application/file_version="0.1.0"
application/product_version="0.1.0"
display/width=1280
display/height=720
xr_features/enabled=false
xr_features/hand_tracking=false
xr_features/passthrough=false
[preset.1]
name="Linux Server"
platform="Linux/X11"
runnable=true
dedicated_server=true
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="build/tactical-shooter-server.x86_64"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
encrypt_pck=false
encrypt_directory=false
script_encryption_key=""
[preset.1.options]
custom_template/debug=""
custom_template/release=""
binary_format/embed_pck=true
binary_format/architecture="x86_64"
texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
texture_format/no_bptc_fallbacks=true
dotnet/include_scripts_content=false
dotnet/rollforward_to_latest_prerelease=true
codesign/identity=""
codesign/timestamp=true
codesign/timestamp_server_url=""
application/icon=""
application/identifier="tactical-shooter-server"
application/name="Tactical Shooter Server"
application/app_category="game"
application/file_version="0.1.0"
application/product_version="0.1.0"
application/company_name=""
application/copyright=""
application/trademarks=""
display/width=1
display/height=1
display/handheld/dpi=72
display/handheld/dpi_mode="disabled"
display/scale_mode="disabled"
xr_features/enabled=false
xr_features/hand_tracking=false
xr_features/passthrough=false
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_;
+1
View File
@@ -0,0 +1 @@
uid://bn01mhwtitvyf
@@ -0,0 +1 @@
uid://bhgxvmel117tg
+1
View File
@@ -0,0 +1 @@
uid://dskw00g1p1ghe
+29 -3
View File
@@ -7,9 +7,11 @@
## 1. Load config (env overrides)
## 2. Load the first map from the config's map rotation
## 3. Start ENet server on the configured port
## 4. Spawn players on the map using spawn markers
## 5. Broadcast spawn via RPC so clients create visual player nodes
## 6. Log player join/leave and replicate position
## 4. Create GameServer and start simulation
## 5. Spawn players on the map using spawn markers
## 6. Broadcast spawn via RPC so clients create visual player nodes
## 7. Log player join/leave and replicate position
## 8. Hitscan weapon with lag compensation runs in GameServer
extends Node
@@ -31,6 +33,9 @@ var players: Dictionary = {} # peer_id → Node (player instance)
var spawn_points_a: Array[Vector3] = [] # Team A spawns
var spawn_points_b: Array[Vector3] = [] # Team B spawns
# Game server (drives simulation)
var _game_server: Node = null
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
@@ -43,6 +48,12 @@ func _ready() -> void:
effective_port = int(OS.get_environment("SERVER_PORT"))
print("[ServerMain] Port overridden by SERVER_PORT env: %d" % effective_port)
# Create GameServer (drives the 128Hz simulation + hit detection)
_game_server = preload("res://server/scripts/game_server.gd").new()
add_child(_game_server)
# GameServer registers SimulationServer as a singleton on _ready
_game_server.start_simulation()
# Instance the map from the config's map rotation
_load_map()
@@ -64,6 +75,7 @@ func _ready() -> void:
print("[ServerMain] Maps: %s" % ServerConfig.map_list)
print("[ServerMain] Headless: %s" % (DisplayServer.get_name() == &"headless"))
print("[ServerMain] Spawn pts: Team A=%d, Team B=%d" % [spawn_points_a.size(), spawn_points_b.size()])
print("[ServerMain] Lag comp: Enabled (64-tick history = %dms)" % [64.0 * 1000.0 / ServerConfig.tick_rate])
func _exit_tree() -> void:
NetworkManager.stop()
@@ -152,6 +164,14 @@ func _spawn_player(peer_id: int) -> void:
players[peer_id] = player
player_spawned.emit(peer_id)
# Register with GameServer to get a simulation entity
if _game_server != null and _game_server.has_method(&"spawn_player_entity"):
var entity_id: int = _game_server.spawn_player_entity(peer_id, spawn_pos)
if entity_id >= 0:
# Store entity_id on player node for reference
player.set_meta(&"entity_id", entity_id)
print("[ServerMain] Player %d assigned entity %d" % [peer_id, entity_id])
# Broadcast spawn to all clients so they create a visual player node
NetworkManager.broadcast_spawn_player.rpc(peer_id, spawn_pos, is_team_a)
@@ -160,6 +180,12 @@ func _spawn_player(peer_id: int) -> void:
func _despawn_player(peer_id: int) -> void:
if peer_id in players:
var player: Node = players[peer_id]
# Despawn entity from GameServer
if _game_server != null and player.has_meta(&"entity_id"):
var entity_id: int = player.get_meta(&"entity_id")
_game_server.despawn_player_entity(entity_id)
player.queue_free()
players.erase(peer_id)
player_despawned.emit(peer_id)
@@ -0,0 +1 @@
uid://b4o1jrr5mmue0
+174
View File
@@ -0,0 +1,174 @@
## GameServer — drives the 128Hz simulation loop.
##
## Owns a SimulationServer instance, drives its tick loop in _physics_process,
## and manages weapon configuration. Acts as the bridge between the GDScript
## world (NetworkManager, ServerMain) and the C++ simulation core.
##
## Architecture:
## GameServer (Node, autoload candidate)
## ├── SimulationServer (GDExtension C++) — game state + hit detection
## │ ├── applies client input via apply_input()
## │ └── process_compensated_fire() with lag compensation rewind
## └── WeaponManager (optional, on the player node for client-side rate limit)
##
## Usage (server_main.gd):
## func _ready():
## var gs = GameServer.new()
## add_child(gs)
## gs.configure(ServerConfig.tick_rate, ServerConfig.make_movement_dict())
##
## Standalone / listen-server test:
## var gs = get_node("/root/GameServer")
## if gs: gs.start_simulation()
##
extends Node
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted after each simulation tick with hit results.
signal tick_completed(tick: int)
## Emitted when a player is damaged (for scoreboard/UI updates).
signal player_damaged(victim_entity_id: int, shooter_entity_id: int, damage: float, killed: bool)
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var simulation_server: Object = null
var is_running: bool = false
# Map entity_id → peer_id for broadcasting damage events
var entity_to_peer: Dictionary = {} # entity_id (int) → peer_id (int)
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
# Create the SimulationServer
simulation_server = SimulationServer.new()
simulation_server.set_tick_rate(128)
# Apply movement config from ServerConfig singleton if available
if ServerConfig and ServerConfig.has_method(&"make_movement_dict"):
simulation_server.set_movement_config(ServerConfig.make_movement_dict())
# Configure the default hitscan weapon (Assault Rifle)
simulation_server.set_weapon_config({
"base_damage": 30.0,
"head_multiplier": 4.0,
"body_multiplier": 1.0,
"arm_multiplier": 0.75,
"leg_multiplier": 0.6,
"max_range": 500.0,
"spread_degrees": 0.5,
"fire_rate_hz": 10.0,
})
# Set history depth for lag compensation (64 ticks = ~500ms at 128Hz)
simulation_server.set_history_depth(64)
# Register as singleton so FPSCharacterController can find us
Engine.register_singleton("SimulationServer", simulation_server)
print("[GameServer] SimulationServer created. Tick rate: %d Hz" % simulation_server.get_tick_rate())
func _exit_tree() -> void:
if simulation_server:
simulation_server.stop()
simulation_server = null
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
## Start the simulation.
func start_simulation() -> void:
if is_running:
return
if simulation_server == null:
push_error("[GameServer] SimulationServer is null, cannot start")
return
simulation_server.start()
is_running = true
print("[GameServer] Simulation started")
## Stop the simulation.
func stop_simulation() -> void:
if not is_running:
return
if simulation_server:
simulation_server.stop()
is_running = false
print("[GameServer] Simulation stopped")
## Spawn a player entity and map to peer ID.
## Returns the entity ID assigned by the simulation server.
func spawn_player_entity(peer_id: int, spawn_pos: Vector3) -> int:
if simulation_server == null:
return -1
if not is_running:
start_simulation()
var entity_id: int = simulation_server.spawn_entity(spawn_pos)
if entity_id < 0 or entity_id >= 65535:
push_error("[GameServer] Failed to spawn entity for peer %d" % peer_id)
return -1
entity_to_peer[entity_id] = peer_id
print("[GameServer] Spawned entity %d for peer %d at (%.1f, %.1f, %.1f)" % [entity_id, peer_id, spawn_pos.x, spawn_pos.y, spawn_pos.z])
return entity_id
## Despawn a player entity.
func despawn_player_entity(entity_id: int) -> void:
if simulation_server == null:
return
if entity_id >= 0:
simulation_server.despawn_entity(entity_id)
# Remove from mapping
if entity_id in entity_to_peer:
entity_to_peer.erase(entity_id)
## Get the peer ID for a given entity ID.
func get_peer_for_entity(entity_id: int) -> int:
return entity_to_peer.get(entity_id, -1)
## Get the underlying SimulationServer reference.
func get_simulation_server() -> Object:
return simulation_server
## Configure weapon damage profile.
func set_weapon(config: Dictionary) -> void:
if simulation_server:
simulation_server.set_weapon_config(config)
# ---------------------------------------------------------------------------
# Main loop (128 Hz)
# ---------------------------------------------------------------------------
func _physics_process(delta: float) -> void:
if not is_running or simulation_server == null:
return
# Drive the fixed-timestep simulation loop
while simulation_server.can_tick(delta):
var snapshot: PackedByteArray = simulation_server.tick()
# snapshot is the serialized state — send to network layer in Phase 2
# For now, just emit the tick completed signal
tick_completed.emit(simulation_server.get_stats().get("tick_count", 0))
# Check for hit results and emit damage events
var hit_result: Dictionary = simulation_server.get_last_hit_result()
if hit_result.get("hit", false):
var victim_id: int = hit_result.get("entity_id", -1)
var damage: float = hit_result.get("damage", 0.0)
var killed: bool = hit_result.get("killed", false)
# For now we don't know the shooter — this will be wired in Phase 2
player_damaged.emit(victim_id, -1, damage, killed)
+1
View File
@@ -0,0 +1 @@
uid://cie4mab2d8q6t
@@ -0,0 +1 @@
uid://b7p2vpaanma77
+3 -3
View File
@@ -21,7 +21,8 @@
## plugin rescan — re-scan the plugins directory
extends Node
class_name PluginManager
## Duplicate class_name removed — already registered as autoload singleton.
## class_name PluginManager
# ---------------------------------------------------------------------------
# Signals
@@ -558,8 +559,7 @@ func _rcon_info(args: PackedStringArray) -> String:
func _rcon_rescan(_args: PackedStringArray) -> String:
var count: int = rescan()
return "Rescan complete: %d manifest(s) found, %d plugin(s) loaded.\\r\\n" %
[count, loaded_plugins.size()]
return "Rescan complete: %d manifest(s) found, %d plugin(s) loaded.\\r\\n" % [count, loaded_plugins.size()]
# ---------------------------------------------------------------------------
# Utility / logging
@@ -0,0 +1 @@
uid://bjfhqc378oifb
@@ -0,0 +1 @@
uid://de5u8rubtedqh
@@ -0,0 +1 @@
uid://d2jf8ky1luqdw
+1
View File
@@ -0,0 +1 @@
uid://dien2h11upj8b