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:
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://q8nutj781fyf
|
||||
@@ -0,0 +1 @@
|
||||
uid://d0mbp6gs2mwlr
|
||||
@@ -0,0 +1 @@
|
||||
uid://dbtpbhja3nk60
|
||||
@@ -0,0 +1 @@
|
||||
uid://cwvi2ic1k6ubn
|
||||
@@ -0,0 +1 @@
|
||||
uid://olpdh1gx2gca
|
||||
@@ -0,0 +1 @@
|
||||
uid://bqau1kivb87fk
|
||||
@@ -0,0 +1 @@
|
||||
uid://d0go24t1lsx2j
|
||||
@@ -0,0 +1 @@
|
||||
uid://bas55o1773n21
|
||||
@@ -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 (2–32 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
@@ -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
+1
Submodule gdextension/simulation/godot-cpp added at d5cc777a89
@@ -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,13 +379,93 @@ 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
|
||||
// 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);
|
||||
|
||||
@@ -356,22 +475,102 @@ void SimulationServer::update_combat() {
|
||||
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, shooter_id, default_weapon
|
||||
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 = entities_by_id_[hit.entity_id];
|
||||
if (target.is_valid()) {
|
||||
HitDetection::apply_damage(*target, hit.damage, 1.0f);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
pending_fires_.clear();
|
||||
// ---- 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() {
|
||||
@@ -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_;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://bn01mhwtitvyf
|
||||
@@ -0,0 +1 @@
|
||||
uid://bhgxvmel117tg
|
||||
@@ -0,0 +1 @@
|
||||
uid://dskw00g1p1ghe
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cie4mab2d8q6t
|
||||
@@ -0,0 +1 @@
|
||||
uid://b7p2vpaanma77
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dien2h11upj8b
|
||||
Reference in New Issue
Block a user