diff --git a/client/assets/scenes/modular/kit_demo.gd.uid b/client/assets/scenes/modular/kit_demo.gd.uid new file mode 100644 index 0000000..128341d --- /dev/null +++ b/client/assets/scenes/modular/kit_demo.gd.uid @@ -0,0 +1 @@ +uid://c6dxl1qhan4yl diff --git a/client/characters/character/fps_camera.gd.uid b/client/characters/character/fps_camera.gd.uid new file mode 100644 index 0000000..390a94e --- /dev/null +++ b/client/characters/character/fps_camera.gd.uid @@ -0,0 +1 @@ +uid://bjtf5jiedmwx8 diff --git a/client/characters/character/fps_character_controller.gd b/client/characters/character/fps_character_controller.gd index 48c9a45..2d385b3 100644 --- a/client/characters/character/fps_character_controller.gd +++ b/client/characters/character/fps_character_controller.gd @@ -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() diff --git a/client/characters/character/fps_character_controller.gd.uid b/client/characters/character/fps_character_controller.gd.uid new file mode 100644 index 0000000..fd3c48f --- /dev/null +++ b/client/characters/character/fps_character_controller.gd.uid @@ -0,0 +1 @@ +uid://cpbvk1itjonm diff --git a/client/characters/input/input_handler.gd.uid b/client/characters/input/input_handler.gd.uid new file mode 100644 index 0000000..818b6d3 --- /dev/null +++ b/client/characters/input/input_handler.gd.uid @@ -0,0 +1 @@ +uid://bvkhxr1bv6lth diff --git a/client/characters/weapon/weapon_manager.gd b/client/characters/weapon/weapon_manager.gd new file mode 100644 index 0000000..943052e --- /dev/null +++ b/client/characters/weapon/weapon_manager.gd @@ -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) diff --git a/client/characters/weapon/weapon_manager.gd.uid b/client/characters/weapon/weapon_manager.gd.uid new file mode 100644 index 0000000..203e971 --- /dev/null +++ b/client/characters/weapon/weapon_manager.gd.uid @@ -0,0 +1 @@ +uid://dywlbha8xivx4 diff --git a/client/scripts/bake_lighting.gd.uid b/client/scripts/bake_lighting.gd.uid new file mode 100644 index 0000000..ac0b0bf --- /dev/null +++ b/client/scripts/bake_lighting.gd.uid @@ -0,0 +1 @@ +uid://q8nutj781fyf diff --git a/client/scripts/bake_lightmaps.gd.uid b/client/scripts/bake_lightmaps.gd.uid new file mode 100644 index 0000000..65327f6 --- /dev/null +++ b/client/scripts/bake_lightmaps.gd.uid @@ -0,0 +1 @@ +uid://d0mbp6gs2mwlr diff --git a/client/scripts/test_headless.gd.uid b/client/scripts/test_headless.gd.uid new file mode 100644 index 0000000..7daf093 --- /dev/null +++ b/client/scripts/test_headless.gd.uid @@ -0,0 +1 @@ +uid://dbtpbhja3nk60 diff --git a/client/scripts/validate_lights.gd.uid b/client/scripts/validate_lights.gd.uid new file mode 100644 index 0000000..fda3ad5 --- /dev/null +++ b/client/scripts/validate_lights.gd.uid @@ -0,0 +1 @@ +uid://cwvi2ic1k6ubn diff --git a/client/scripts/validate_map.gd.uid b/client/scripts/validate_map.gd.uid new file mode 100644 index 0000000..51e6e97 --- /dev/null +++ b/client/scripts/validate_map.gd.uid @@ -0,0 +1 @@ +uid://olpdh1gx2gca diff --git a/client/scripts/validate_polycount.gd.uid b/client/scripts/validate_polycount.gd.uid new file mode 100644 index 0000000..7fd562d --- /dev/null +++ b/client/scripts/validate_polycount.gd.uid @@ -0,0 +1 @@ +uid://bqau1kivb87fk diff --git a/client/scripts/validate_scene.gd.uid b/client/scripts/validate_scene.gd.uid new file mode 100644 index 0000000..d714299 --- /dev/null +++ b/client/scripts/validate_scene.gd.uid @@ -0,0 +1 @@ +uid://d0go24t1lsx2j diff --git a/client/scripts/validate_textures.gd.uid b/client/scripts/validate_textures.gd.uid new file mode 100644 index 0000000..324dca3 --- /dev/null +++ b/client/scripts/validate_textures.gd.uid @@ -0,0 +1 @@ +uid://bas55o1773n21 diff --git a/docs/playtest-guide.md b/docs/playtest-guide.md new file mode 100644 index 0000000..c02378b --- /dev/null +++ b/docs/playtest-guide.md @@ -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=` 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* diff --git a/export_presets.cfg b/export_presets.cfg index a05f450..05700fb 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -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 diff --git a/gdextension/simulation/.sconsign.dblite b/gdextension/simulation/.sconsign.dblite new file mode 100644 index 0000000..9ccbbfc Binary files /dev/null and b/gdextension/simulation/.sconsign.dblite differ diff --git a/gdextension/simulation/gdextension/simulation.gdextension.disabled b/gdextension/simulation/gdextension/simulation.gdextension.disabled new file mode 100644 index 0000000..1606bb1 --- /dev/null +++ b/gdextension/simulation/gdextension/simulation.gdextension.disabled @@ -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" diff --git a/gdextension/simulation/gdextension/simulation.gdextension.uid b/gdextension/simulation/gdextension/simulation.gdextension.uid new file mode 100644 index 0000000..63b280b --- /dev/null +++ b/gdextension/simulation/gdextension/simulation.gdextension.uid @@ -0,0 +1 @@ +uid://kl43xi0ixseg diff --git a/gdextension/simulation/godot-cpp b/gdextension/simulation/godot-cpp new file mode 160000 index 0000000..d5cc777 --- /dev/null +++ b/gdextension/simulation/godot-cpp @@ -0,0 +1 @@ +Subproject commit d5cc777a89d899665fb61f1650ef0dc0cf6488c4 diff --git a/gdextension/simulation/src/simulation_server.cpp b/gdextension/simulation/src/simulation_server.cpp index 0718a5b..e697abf 100644 --- a/gdextension/simulation/src/simulation_server.cpp +++ b/gdextension/simulation/src/simulation_server.cpp @@ -3,6 +3,7 @@ #include #include #include +#include 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(static_cast(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(rand() % 360) * (kPi / 180.0f); + float phi = static_cast(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(static_cast(config["base_damage"])); + if (config.has("head_multiplier")) wd.head_multiplier = static_cast(static_cast(config["head_multiplier"])); + if (config.has("body_multiplier")) wd.body_multiplier = static_cast(static_cast(config["body_multiplier"])); + if (config.has("arm_multiplier")) wd.arm_multiplier = static_cast(static_cast(config["arm_multiplier"])); + if (config.has("leg_multiplier")) wd.leg_multiplier = static_cast(static_cast(config["leg_multiplier"])); + if (config.has("max_range")) wd.max_range = static_cast(static_cast(config["max_range"])); + if (config.has("spread_degrees")) wd.spread_degrees = static_cast(static_cast(config["spread_degrees"])); + if (config.has("fire_rate_hz")) weapon_fire_rate_hz_ = static_cast(static_cast(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(last_hit_result_.entity_id); + result["damage"] = last_hit_result_.damage; + result["distance"] = last_hit_result_.distance; + result["hitbox_id"] = static_cast(last_hit_result_.hitbox_id); + result["killed"] = last_hit_result_.killed; + return result; +} + godot::PackedByteArray SimulationServer::serialize_state() { const uint16_t count = static_cast(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); diff --git a/gdextension/simulation/src/simulation_server.h b/gdextension/simulation/src/simulation_server.h index 2699f8f..3ec8514 100644 --- a/gdextension/simulation/src/simulation_server.h +++ b/gdextension/simulation/src/simulation_server.h @@ -12,12 +12,37 @@ #include #include +#include #include #include #include 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 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 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 queued_inputs_; - // Queued weapon fires (entity IDs) - std::vector pending_fires_; + // Queued weapon fires with lag-compensation info + std::vector 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 position_history_; + uint32_t history_depth_ = 64; + uint32_t history_write_index_ = 0; // Last serialized snapshot per entity (for delta compression) std::unordered_map last_snapshots_; diff --git a/scripts/config/server_config.gd.uid b/scripts/config/server_config.gd.uid new file mode 100644 index 0000000..2446596 --- /dev/null +++ b/scripts/config/server_config.gd.uid @@ -0,0 +1 @@ +uid://bn01mhwtitvyf diff --git a/scripts/map_packaging/map_downloader.gd.uid b/scripts/map_packaging/map_downloader.gd.uid new file mode 100644 index 0000000..a9f6835 --- /dev/null +++ b/scripts/map_packaging/map_downloader.gd.uid @@ -0,0 +1 @@ +uid://bhgxvmel117tg diff --git a/scripts/map_packaging/pack_map.gd.uid b/scripts/map_packaging/pack_map.gd.uid new file mode 100644 index 0000000..ed09229 --- /dev/null +++ b/scripts/map_packaging/pack_map.gd.uid @@ -0,0 +1 @@ +uid://dskw00g1p1ghe diff --git a/scripts/network/server_main.gd b/scripts/network/server_main.gd index 36aa198..4f92d3f 100644 --- a/scripts/network/server_main.gd +++ b/scripts/network/server_main.gd @@ -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 # --------------------------------------------------------------------------- @@ -42,21 +47,27 @@ func _ready() -> void: if OS.has_environment("SERVER_PORT"): 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() - + # Start the ENet server var err: Error = NetworkManager.start_server(effective_port) if err != OK: push_error("[ServerMain] Failed to start server: %s" % error_string(err)) get_tree().quit(1) return - + # Connect signals NetworkManager.player_connected.connect(_on_player_connected) NetworkManager.player_disconnected.connect(_on_player_disconnected) - + print("[ServerMain] Tactical Shooter server ready") print("[ServerMain] Port: %d" % effective_port) print("[ServerMain] Name: \"%s\"" % ServerConfig.server_name) @@ -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() @@ -137,36 +149,50 @@ func _spawn_player(peer_id: int) -> void: else: player = Node3D.new() player.set_script(preload("res://scripts/network/player.gd")) - + player.name = "Player_%d" % peer_id player.set_multiplayer_authority(peer_id) - + # Assign spawn position — alternate between Team A and Team B spawns var is_team_a: bool = (peer_id % 2) == 0 var pool: Array[Vector3] = spawn_points_a if is_team_a else spawn_points_b var spawn_pos: Vector3 = pool[peer_id % pool.size()] if pool.size() > 0 else Vector3.ZERO spawn_pos.y = 0.0 player.position = spawn_pos - + add_child(player, true) 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) - + print("[ServerMain] Spawned player %d at (%.1f, %.1f) team=%s" % [peer_id, spawn_pos.x, spawn_pos.z, "A" if is_team_a else "B"]) 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) - + # Broadcast despawn to all clients NetworkManager.broadcast_despawn_player.rpc(peer_id) - + print("[ServerMain] Despawned player %d" % peer_id) else: # Still broadcast even if server doesn't have the player diff --git a/server/plugins/hello_world/hello_world.gd.uid b/server/plugins/hello_world/hello_world.gd.uid new file mode 100644 index 0000000..7841dd4 --- /dev/null +++ b/server/plugins/hello_world/hello_world.gd.uid @@ -0,0 +1 @@ +uid://b4o1jrr5mmue0 diff --git a/server/scripts/game_server.gd b/server/scripts/game_server.gd new file mode 100644 index 0000000..07a5ac6 --- /dev/null +++ b/server/scripts/game_server.gd @@ -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) diff --git a/server/scripts/game_server.gd.uid b/server/scripts/game_server.gd.uid new file mode 100644 index 0000000..b26fed6 --- /dev/null +++ b/server/scripts/game_server.gd.uid @@ -0,0 +1 @@ +uid://cie4mab2d8q6t diff --git a/server/scripts/plugin_api/plugin_base.gd.uid b/server/scripts/plugin_api/plugin_base.gd.uid new file mode 100644 index 0000000..1617ff0 --- /dev/null +++ b/server/scripts/plugin_api/plugin_base.gd.uid @@ -0,0 +1 @@ +uid://b7p2vpaanma77 diff --git a/server/scripts/plugin_api/plugin_manager.gd b/server/scripts/plugin_api/plugin_manager.gd index c1170bb..7d7090c 100644 --- a/server/scripts/plugin_api/plugin_manager.gd +++ b/server/scripts/plugin_api/plugin_manager.gd @@ -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 diff --git a/server/scripts/plugin_api/plugin_manager.gd.uid b/server/scripts/plugin_api/plugin_manager.gd.uid new file mode 100644 index 0000000..9c10eb9 --- /dev/null +++ b/server/scripts/plugin_api/plugin_manager.gd.uid @@ -0,0 +1 @@ +uid://bjfhqc378oifb diff --git a/server/scripts/plugin_api/plugin_manifest.gd.uid b/server/scripts/plugin_api/plugin_manifest.gd.uid new file mode 100644 index 0000000..255b117 --- /dev/null +++ b/server/scripts/plugin_api/plugin_manifest.gd.uid @@ -0,0 +1 @@ +uid://de5u8rubtedqh diff --git a/server/scripts/rcon_command_handler.gd.uid b/server/scripts/rcon_command_handler.gd.uid new file mode 100644 index 0000000..459dcf8 --- /dev/null +++ b/server/scripts/rcon_command_handler.gd.uid @@ -0,0 +1 @@ +uid://d2jf8ky1luqdw diff --git a/server/scripts/rcon_server.gd.uid b/server/scripts/rcon_server.gd.uid new file mode 100644 index 0000000..927e8ff --- /dev/null +++ b/server/scripts/rcon_server.gd.uid @@ -0,0 +1 @@ +uid://dien2h11upj8b