From e9dc05983c6f0d2b5216646cf466250b72335234 Mon Sep 17 00:00:00 2001 From: Shawn Date: Wed, 1 Jul 2026 18:30:44 -0400 Subject: [PATCH] Save workspace artifacts: character-controller, gdextension scaffold, network scripts, map-pipeline, server config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Includes code from task workspaces that was never pushed: - client/characters/ — fps_character_controller.gd (400 lines), fps_camera.gd, input_handler.gd - gdextension/simulation/ — C++ GDExtension scaffold: entity, movement, hit detection, simulation server, state serializer, bitstream (14 files) - scripts/network/ — ENet-based network_manager.gd, server/client_main.gd, player.gd - scripts/map_packaging/ — PCK pipeline: pack_map.gd, map_downloader.gd, map_registry_server.py, README - scripts/config/ — server_config.gd (480 lines) - scenes/ — client_main, server_main, player, test_range - project.godot, export_presets.cfg --- .gitignore | 1 + client/characters/README.md | 119 +++++ client/characters/character/fps_camera.gd | 215 ++++++++ .../character/fps_character_controller.gd | 400 +++++++++++++++ client/characters/input/input_handler.gd | 59 +++ config-ext-map-registry.cfg | 21 + config/default_server_config.cfg | 87 ++++ export_presets.cfg | 47 ++ gdextension/simulation/.gitignore | 19 + gdextension/simulation/README.md | 159 ++++++ gdextension/simulation/SConstruct | 115 +++++ .../gdextension/simulation.gdextension | 10 + gdextension/simulation/src/bitstream.h | 260 ++++++++++ gdextension/simulation/src/entity.cpp | 125 +++++ gdextension/simulation/src/entity.h | 173 +++++++ gdextension/simulation/src/hit_detection.cpp | 157 ++++++ gdextension/simulation/src/hit_detection.h | 138 +++++ .../simulation/src/movement_component.cpp | 155 ++++++ .../simulation/src/movement_component.h | 71 +++ gdextension/simulation/src/register_types.cpp | 54 ++ gdextension/simulation/src/register_types.h | 14 + .../simulation/src/simulation_server.cpp | 471 +++++++++++++++++ .../simulation/src/simulation_server.h | 243 +++++++++ .../simulation/src/state_serializer.cpp | 262 ++++++++++ gdextension/simulation/src/state_serializer.h | 136 +++++ scenes/client/client_main.tscn | 14 + scenes/map/test_range.tscn | 291 +++++++++++ scenes/player.tscn | 15 + scenes/server/server_main.tscn | 14 + scripts/config/server_config.gd | 480 ++++++++++++++++++ scripts/map_packaging/README.md | 363 +++++++++++++ scripts/map_packaging/map_downloader.gd | 399 +++++++++++++++ scripts/map_packaging/map_registry_server.py | 393 ++++++++++++++ scripts/map_packaging/pack_map.gd | 190 +++++++ scripts/network/client_main.gd | 124 +++++ scripts/network/client_main.gd.uid | 1 + scripts/network/network_manager.gd | 168 ++++++ scripts/network/network_manager.gd.uid | 1 + scripts/network/player.gd | 100 ++++ scripts/network/player.gd.uid | 1 + scripts/network/server_main.gd | 183 +++++++ scripts/network/server_main.gd.uid | 1 + 42 files changed, 6249 insertions(+) create mode 100644 client/characters/README.md create mode 100644 client/characters/character/fps_camera.gd create mode 100644 client/characters/character/fps_character_controller.gd create mode 100644 client/characters/input/input_handler.gd create mode 100644 config-ext-map-registry.cfg create mode 100644 config/default_server_config.cfg create mode 100644 export_presets.cfg create mode 100644 gdextension/simulation/.gitignore create mode 100644 gdextension/simulation/README.md create mode 100644 gdextension/simulation/SConstruct create mode 100644 gdextension/simulation/gdextension/simulation.gdextension create mode 100644 gdextension/simulation/src/bitstream.h create mode 100644 gdextension/simulation/src/entity.cpp create mode 100644 gdextension/simulation/src/entity.h create mode 100644 gdextension/simulation/src/hit_detection.cpp create mode 100644 gdextension/simulation/src/hit_detection.h create mode 100644 gdextension/simulation/src/movement_component.cpp create mode 100644 gdextension/simulation/src/movement_component.h create mode 100644 gdextension/simulation/src/register_types.cpp create mode 100644 gdextension/simulation/src/register_types.h create mode 100644 gdextension/simulation/src/simulation_server.cpp create mode 100644 gdextension/simulation/src/simulation_server.h create mode 100644 gdextension/simulation/src/state_serializer.cpp create mode 100644 gdextension/simulation/src/state_serializer.h create mode 100644 scenes/client/client_main.tscn create mode 100644 scenes/map/test_range.tscn create mode 100644 scenes/player.tscn create mode 100644 scenes/server/server_main.tscn create mode 100644 scripts/config/server_config.gd create mode 100644 scripts/map_packaging/README.md create mode 100644 scripts/map_packaging/map_downloader.gd create mode 100644 scripts/map_packaging/map_registry_server.py create mode 100644 scripts/map_packaging/pack_map.gd create mode 100644 scripts/network/client_main.gd create mode 100644 scripts/network/client_main.gd.uid create mode 100644 scripts/network/network_manager.gd create mode 100644 scripts/network/network_manager.gd.uid create mode 100644 scripts/network/player.gd create mode 100644 scripts/network/player.gd.uid create mode 100644 scripts/network/server_main.gd create mode 100644 scripts/network/server_main.gd.uid diff --git a/.gitignore b/.gitignore index 79b9a34..1b87d5d 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ server/build/ # Secrets (operator must create per deployment) server/data/rcon_password.cfg +build/ diff --git a/client/characters/README.md b/client/characters/README.md new file mode 100644 index 0000000..82482c2 --- /dev/null +++ b/client/characters/README.md @@ -0,0 +1,119 @@ +# FPS Character Controller — Tactical Shooter + +First-person character controller for the tactical shooter GDExtension simulation core. + +## Architecture + +The character controller is a **GDScript bridge** between Godot's input/physics system and the C++ `SimulationServer` (built in task `t_aca0f251`). + +``` +Godot Input + │ + ├─► FPSCharacterController._physics_process() + │ ├─ capture input (WASD, mouse, jump, sprint, crouch, shoot) + │ ├─ build EntityInput Dictionary + │ ├─ SimulationServer.apply_input(entity_id, input_dict) + │ ├─ SimulationServer.fire_weapon(entity_id) [if shoot] + │ ├─ read entity position from server → apply to CharacterBody3D + │ └─ apply mouse look (yaw on body, pitch on camera) + │ + ├─► FpsCamera._process() + │ ├─ view bobbing (head bob from movement) + │ ├─ weapon sway + │ └─ sprint FOV kick + │ + └─► SimulationServer (C++ GDExtension) + ├─ process_tick() @ 128Hz + ├─ MovementComponent.update() + ├─ HitDetection.process_shot() + └─→ returns serialized snapshot +``` + +## Files + +| File | Purpose | +|------|---------| +| `character/fps_character_controller.gd` | Main controller (CharacterBody3D). Input capture, simulation bridge, walk toggle, crouch, jump. | +| `character/fps_camera.gd` | First-person camera (Camera3D). Mouse look pitch, view bobbing, weapon sway, FOV kick. | +| `input/input_handler.gd` | Documentation of expected input map bindings. | + +## Scene Setup + +1. **Create a CharacterBody3D scene:** + ``` + FPSCharacterController (CharacterBody3D) — script: fps_character_controller.gd + ├── FpsCamera (Camera3D) — script: fps_camera.gd + ├── CollisionShape3D (CapsuleShape3D) — radius: 0.4, height: 1.8 + └── Weapon (Node3D, optional) — child of camera, rest transform tracked + ``` + +2. **Configure exports** on the root node: + - `mouse_sensitivity`: 0.1–0.2 for typical tactical feel + - `walk_toggle`: `true` = tap Shift to toggle sprint (CS-style) + - `crouch_toggle`: `false` = hold Ctrl to crouch (tactical-style) + - `eye_height_stand` / `eye_height_crouch`: match your capsule height + +3. **Set up Input Map** in Project Settings: + - `move_forward` → W + - `move_backward` → S + - `move_left` → A + - `move_right` → D + - `jump` → Space + - `sprint` → Shift + - `crouch` → Ctrl + - `shoot` → Mouse LMB + - `aim` → Mouse RMB + - `ui_cancel` → Esc (built-in, releases mouse capture) + +4. **Integration with SimulationServer:** + + ```gdscript + # In your game manager (autoload or scene root): + var server = SimulationServer.new() + server.set_tick_rate(128) + server.start() + var player_id = server.spawn_entity(Vector3(0, 0, 0)) + + # When player scene is ready: + $Player/FPSCharacterController.set_entity_id(player_id) + + # In _process(delta): + while server.can_tick(delta): + var snapshot = server.tick() + # send snapshot to network layer + ``` + +## Walk Toggle Behavior + +- **`walk_toggle = true`** (default): Press Shift to toggle between walk and sprint. Press again to toggle off. Same feel as Counter-Strike's walk/run toggle. +- **`walk_toggle = false`**: Hold Shift for temporary sprint. Release to decelerate to walk speed. + +## Crouch Behavior + +- **`crouch_toggle = false`** (default): Hold Ctrl to crouch. Release to stand up. +- **`crouch_toggle = true`**: Press Ctrl once to crouch, press again to stand. + +Crouch transitions are smooth lerps over `crouch_transition_time` seconds. The collision capsule height and camera eye height both lerp simultaneously. + +## Standalone Mode + +When no `SimulationServer` singleton is available, the controller falls back to built-in `CharacterBody3D` movement (`_move_local()`). This is useful for: +- Testing the character controller in isolation +- Prototyping without the GDExtension compiled +- Single-player scenarios without the full simulation stack + +Configure `local_walk_speed`, `local_sprint_speed`, `local_crouch_speed`, etc. for standalone tuning. + +## Dependencies + +- **Godot 4.2+** (for `Input.get_vector`, `move_toward`, `StringName` literals) +- **SimulationServer GDExtension** (from task `t_aca0f251`) for server-authoritative mode +- Input map actions listed above + +## See Also + +- Scaffold: `t_aca0f251` — `src/movement_component.h` (C++ Quake/CS movement physics) +- Greybox map: `t_p1_greybox` — test environment for this controller +- Hitscan weapon: `t_p1_hitscan` — weapon firing + lag compensation +- Client prediction: `t_p1_pred` — client-side prediction + server reconciliation +- Round system: `t_p1_round` — win/loss/sudden death on top of character controller diff --git a/client/characters/character/fps_camera.gd b/client/characters/character/fps_camera.gd new file mode 100644 index 0000000..16d8343 --- /dev/null +++ b/client/characters/character/fps_camera.gd @@ -0,0 +1,215 @@ +## FpsCamera — first-person camera view effects for tactical FPS. +## +## Manages view bobbing (head bob from movement), weapon sway, and FOV kick. +## The parent FPSCharacterController handles mouse look (yaw/pitch). +## +## Place as a Camera3D child of FPSCharacterController. +class_name FpsCamera +extends Camera3D + +# --------------------------------------------------------------------------- +# Exports +# --------------------------------------------------------------------------- + +## Enable view bobbing (head bob when walking/running). +@export var view_bob_enabled: bool = true + +## How much the camera bobs horizontally (units). +@export_range(0.0, 0.5) var bob_amplitude_h: float = 0.04 + +## How much the camera bobs vertically. +@export_range(0.0, 0.5) var bob_amplitude_v: float = 0.04 + +## Frequency multiplier for bobbing (higher = faster). +@export_range(0.5, 5.0) var bob_frequency: float = 2.5 + +## Sprint bob multiplier (more aggressive). +@export_range(1.0, 3.0) var bob_sprint_mult: float = 1.6 + +## Crouch bob multiplier (less movement). +@export_range(0.1, 1.0) var bob_crouch_mult: float = 0.4 + +## FOV kick on sprint (tactical FOV increase). +@export_range(0.0, 10.0) var sprint_fov_kick: float = 3.0 + +## FOV kick when firing weapon. +@export_range(-5.0, 10.0) var shoot_fov_kick: float = 0.5 + +## How fast FOV recovers. +@export var fov_recovery_speed: float = 6.0 + +## Enable weapon sway (subtle rotation from movement). +@export var weapon_sway_enabled: bool = true + +## Maximum weapon sway rotation (degrees). +@export_range(0.0, 5.0) var sway_max_rotation: float = 1.5 + +## Sway responsiveness (higher = snappier). +@export_range(1.0, 20.0) var sway_response: float = 8.0 + +# --------------------------------------------------------------------------- +# Internal +# --------------------------------------------------------------------------- + +## Reference to parent controller. +var _controller: FPSCharacterController = null + +## Bob time accumulator. +var _bob_time: float = 0.0 + +## Current bob offset. +var _bob_offset: Vector2 = Vector2.ZERO + +## Current weapon node (child named "Weapon" or first MeshInstance3D). +var _weapon: Node3D = null + +## Weapon rest position (local transform when no sway). +var _weapon_rest: Transform3D + +## FOV state. +var _base_fov: float = 75.0 +var _current_fov_offset: float = 0.0 + +## Sway state. +var _sway_current: Vector2 = Vector2.ZERO +var _sway_target: Vector2 = Vector2.ZERO + +## Sprint state cache (detect transition for FOV kick). +var _was_sprinting: bool = false + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + _controller = get_parent() as FPSCharacterController + if _controller == null: + push_warning("FpsCamera: Parent is not an FPSCharacterController. View features disabled.") + + _base_fov = fov + + # Find weapon node (for sway) + for child in get_children(): + if child is Node3D: + _weapon = child + _weapon_rest = child.transform + break + + +func _process(delta: float) -> void: + if _controller == null: + return + + # FOV management + _update_fov(delta) + + # View bobbing + if view_bob_enabled: + _update_bob(delta) + + # Weapon sway + if weapon_sway_enabled and _weapon: + _update_sway(delta) + + +# --------------------------------------------------------------------------- +# FOV +# --------------------------------------------------------------------------- + +func _update_fov(delta: float) -> void: + var target_offset: float = 0.0 + + # Sprint FOV kick + if _controller.is_sprinting() and not _was_sprinting: + target_offset = sprint_fov_kick + + # Shoot FOV kick is triggered externally via trigger_shoot_fov() + _current_fov_offset = move_toward(_current_fov_offset, target_offset, fov_recovery_speed * delta) + fov = _base_fov + _current_fov_offset + + _was_sprinting = _controller.is_sprinting() + + +# --------------------------------------------------------------------------- +# View bobbing +# --------------------------------------------------------------------------- + +func _update_bob(delta: float) -> void: + var crouch_amount: float = _controller.get_crouch_amount() + var sprinting: bool = _controller.is_sprinting() + + # Calculate bob speed multiplier from controller state + var speed_mult: float = 1.0 + if sprinting: + speed_mult = bob_sprint_mult + elif crouch_amount > 0.0: + speed_mult = lerpf(1.0, bob_crouch_mult, crouch_amount) + + if speed_mult < 0.01: + # Stationary — reset to zero + _bob_offset = _bob_offset.move_toward(Vector2.ZERO, delta * 10.0) + else: + _bob_time += delta * bob_frequency * speed_mult + _bob_offset = Vector2( + sin(_bob_time * 2.0) * bob_amplitude_h * speed_mult, + sin(_bob_time) * bob_amplitude_v * speed_mult + ) + + # Apply bob to camera position (relative to parent's eye offset) + position.x = _bob_offset.x + position.y = _bob_offset.y + + +# --------------------------------------------------------------------------- +# Weapon sway +# --------------------------------------------------------------------------- + +func _update_sway(delta: float) -> void: + # Sway follows mouse movement indirectly via controller yaw/pitch changes + # Use velocity for physics-based sway: recentre over time + var vel: Vector3 = _controller.velocity + var sway_h := clamp(vel.x * 0.003, -sway_max_rotation, sway_max_rotation) + var sway_v := clamp(vel.z * 0.003, -sway_max_rotation, sway_max_rotation) + # Add a tiny amount from pitch/yaw delta (not mouse, from movement direction change) + _sway_target = Vector2( + move_toward(_sway_target.x, sway_h, sway_response * delta), + move_toward(_sway_target.y, sway_v, sway_response * delta) + ) + + # Apply to weapon node as small rotation offsets + if _weapon: + var target := Transform3D( + Basis.from_euler(Vector3( + deg_to_rad(_sway_target.y), + deg_to_rad(_sway_target.x), + 0.0 + )), + _weapon_rest.origin + ) + _weapon.transform = _weapon.transform.interpolate_with(target, sway_response * delta * 0.5) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +## Trigger a shoot FOV kick. Call from weapon script. +func trigger_shoot_fov() -> void: + _current_fov_offset += shoot_fov_kick + + +## Reset view to resting state (for respawn). +func reset_view() -> void: + _bob_time = 0.0 + _bob_offset = Vector2.ZERO + _current_fov_offset = 0.0 + _sway_current = Vector2.ZERO + _sway_target = Vector2.ZERO + fov = _base_fov + if _weapon: + _weapon.transform = _weapon_rest + + +## Get the current bob offset for external effects. +func get_bob_offset() -> Vector2: + return _bob_offset diff --git a/client/characters/character/fps_character_controller.gd b/client/characters/character/fps_character_controller.gd new file mode 100644 index 0000000..af73273 --- /dev/null +++ b/client/characters/character/fps_character_controller.gd @@ -0,0 +1,400 @@ +## FPSCharacterController — first-person character for a networked tactical shooter. +## +## Bridges Godot Input → SimulationServer (GDExtension C++ core). +## Handles mouse look, WASD movement, sprint toggle, hold-to-crouch, jump. +## +## Usage (scene tree): +## FPSCharacterController (CharacterBody3D) +## ├── FpsCamera (Camera3D) — script: fps_camera.gd +## ├── CollisionShape3D (capsule) +## └── (optional weapons/arms child) +## +## Connects to SimulationServer via `apply_input()` each frame. +## Reads server snapshot data and updates the node transform. +## +## Walk Toggle: Tap Shift to toggle sprint on/off. +## Crouch: Hold Ctrl to crouch. Smooth lerp transition. +## Jump: Press Space. +## Mouse Look: Capture mouse on click, yaw rotates body, pitch rotates camera. +class_name FPSCharacterController +extends CharacterBody3D + +# --------------------------------------------------------------------------- +# Exports — tune per weapon / game feel +# --------------------------------------------------------------------------- + +## Mouse look sensitivity. At default 0.005, 100 px mouse motion = 0.5° rotation. +@export_range(0.001, 0.1, 0.0005) var mouse_sensitivity: float = 0.005 + +## Mouse vertical sensitivity multiplier (1.0 = same as horizontal). +@export_range(0.1, 2.0, 0.05) var mouse_vertical_ratio: float = 0.8 + +## Invert vertical look. +@export var invert_y: bool = false + +## Maximum pitch angle (degrees) — prevents looking upside down. +@export_range(1, 89) var max_pitch: float = 89.0 + +## Entity ID assigned by SimulationServer. Set via spawn_entity() response. +@export var entity_id: int = -1 + +## If true, pressing Sprint toggles between walk/sprint. If false, hold to sprint. +@export var walk_toggle: bool = true + +## If true, crouch toggles on press. If false, hold Ctrl to crouch. +@export var crouch_toggle: bool = false + +## Time in seconds for crouch height transition (lerp). +@export var crouch_transition_time: float = 0.15 + +## Eye height when standing (Godot units, from feet). +@export var eye_height_stand: float = 1.7 + +## Eye height when crouching. +@export var eye_height_crouch: float = 0.9 + +## Collision shape height when standing. +@export var collision_height_stand: float = 1.8 + +## Collision shape height when crouching. +@export var collision_height_crouch: float = 1.0 + +# --------------------------------------------------------------------------- +# Internal state +# --------------------------------------------------------------------------- + +## Reference to SimulationServer singleton (set in _ready). +var _server: Object = null + +## Current look rotation. +var _yaw: float = 0.0 +var _pitch: float = 0.0 + +## Crouch lerp target (0.0 = standing, 1.0 = full crouch). +var _crouch_target: float = 0.0 +var _crouch_current: float = 0.0 + +## Walk-toggle state. If walk_toggle is true, toggles each time sprint input activates. +var _sprint_active: bool = false +var _sprint_pressed_last: bool = false + +## Crouch toggle state. +var _crouch_active: bool = false +var _crouch_pressed_last: bool = false + +## Input sequence counter — increment each frame for server reconciliation. +var _input_sequence: int = 0 + +## Cached input dictionary (avoids alloc per frame). +var _input_dict: Dictionary = {} + +## Mouse capture state. +var _mouse_captured: bool = false +var _mouse_clicked_this_frame: bool = false + +# --------------------------------------------------------------------------- +# Node references (set in _ready) +# --------------------------------------------------------------------------- + +var _camera: Node3D = null +var _collision_shape: CollisionShape3D = null +var _capsule_shape: CapsuleShape3D = null + +# Cached standing/crouching shape parameters. +var _stand_capsule_height: float = 0.0 +var _crouch_capsule_height: float = 0.0 +var _capsule_radius: float = 0.0 + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + # Find SimulationServer + if Engine.has_singleton("SimulationServer"): + _server = Engine.get_singleton("SimulationServer") + else: + push_warning("FPSCharacterController: SimulationServer singleton not found. " + + "Running in standalone (local physics) mode.") + + # Locate camera + _camera = get_node_or_null("%FpsCamera") + if _camera == null: + # Try to find any Camera3D child + for child in get_children(): + if child is Camera3D: + _camera = child + break + if _camera == null: + push_error("FPSCharacterController: No Camera3D child found. Mouse look disabled.") + + # Locate collision shape + for child in get_children(): + if child is CollisionShape3D: + _collision_shape = child + if child.shape is CapsuleShape3D: + _capsule_shape = child.shape + _capsule_radius = _capsule_shape.radius + _stand_capsule_height = collision_height_stand - 2.0 * _capsule_radius + _crouch_capsule_height = collision_height_crouch - 2.0 * _capsule_radius + _capsule_shape.height = _stand_capsule_height + break + + # Start with mouse captured + _capture_mouse(true) + + # Initialize yaw/pitch from current transform + _yaw = rotation.y + if _camera: + _pitch = _camera.rotation.x + + # Initialize crouch animation state + _crouch_current = 0.0 + _update_crouch(0.0) + + +func _input(event: InputEvent) -> void: + # Mouse look + if event is InputEventMouseMotion and _mouse_captured: + var rel: Vector2 = event.relative + # Yaw (body rotation) + _yaw -= rel.x * mouse_sensitivity + # Pitch (camera rotation) — negative = look down, positive = look up + var invert: float = -1.0 if invert_y else 1.0 + _pitch += rel.y * mouse_sensitivity * mouse_vertical_ratio * invert + _pitch = clamp(_pitch, deg_to_rad(-max_pitch), deg_to_rad(max_pitch)) + + # Mouse capture toggle + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: + if not _mouse_captured: + _capture_mouse(true) + else: + # LMB fires weapon — handled in _physics_process via shoot_pressed + _mouse_clicked_this_frame = true + + +func _unhandled_input(event: InputEvent) -> void: + # Escape to release mouse + if event.is_action_pressed("ui_cancel"): + _capture_mouse(false) + + +# --------------------------------------------------------------------------- +# Main loop +# --------------------------------------------------------------------------- + +func _physics_process(delta: float) -> void: + # 1. Capture input state + var input_dir := _get_move_direction() + var jump_pressed := Input.is_action_just_pressed(&"jump") or Input.is_action_pressed(&"jump") + var sprint_pressed := Input.is_action_pressed(&"sprint") + var crouch_pressed := Input.is_action_pressed(&"crouch") + var shoot_pressed := _mouse_clicked_this_frame or Input.is_action_pressed(&"shoot") + var aim_pressed := Input.is_action_pressed(&"aim") + + _mouse_clicked_this_frame = false + + # 2. Walk toggle logic + if walk_toggle: + if sprint_pressed and not _sprint_pressed_last: + _sprint_active = not _sprint_active + _sprint_pressed_last = sprint_pressed + else: + _sprint_active = sprint_pressed + + # 3. Crouch toggle logic + if crouch_toggle: + if crouch_pressed and not _crouch_pressed_last: + _crouch_active = not _crouch_active + _crouch_pressed_last = crouch_pressed + else: + _crouch_active = crouch_pressed + + # 4. Crouch height transition + var target_crouch: float = 1.0 if _crouch_active else 0.0 + _crouch_target = target_crouch + + var crouch_speed: float = 1.0 / max(crouch_transition_time, 0.001) + if _crouch_current < _crouch_target: + _crouch_current = min(_crouch_current + crouch_speed * delta, _crouch_target) + elif _crouch_current > _crouch_target: + _crouch_current = max(_crouch_current - crouch_speed * delta, _crouch_target) + + _update_crouch(_crouch_current) + + # 5. Apply rotation + rotation.y = _yaw + if _camera: + _camera.rotation.x = _pitch + + # 6. Send input to simulation server + if _server != null and entity_id >= 0: + # Build input for SimulationServer + _input_dict.merge({ + "move_direction": input_dir, + "look_yaw": rad_to_deg(_yaw), + "look_pitch": rad_to_deg(_pitch), + "jump": jump_pressed, + "crouch": _crouch_active, + "sprint": _sprint_active, + "shoot": shoot_pressed, + "aim": aim_pressed, + "input_sequence": _input_sequence, + }, true) + + _server.apply_input(entity_id, _input_dict) + + if shoot_pressed: + _server.fire_weapon(entity_id) + + _input_sequence += 1 + + # 7. Read server state back (for local simulation / listen server) + # The SimulationServer.tick() call happens in the game manager / network layer. + # Here we read the entity's canonical position from the server. + if _server != null and entity_id >= 0: + var entity = _server.get_entity(entity_id) + if entity != null and entity.is_alive(): + # Server position is ground truth — apply it to the visual body + global_position = entity.position + # Note: rotation is already set from yaw/pitch above (client-side) + # On a pure client the server snapshot override is applied differently. + else: + # Standalone mode: do local CharacterBody3D physics + _move_local(input_dir, delta, jump_pressed) + + +# --------------------------------------------------------------------------- +# Local movement (standalone / no-server fallback) +# --------------------------------------------------------------------------- + +## Walk speed (standalone mode). +@export var local_walk_speed: float = 5.0 +## Sprint speed (standalone mode). +@export var local_sprint_speed: float = 8.0 +## Crouch speed (standalone mode). +@export var local_crouch_speed: float = 2.5 +## Jump velocity (standalone mode). +@export var local_jump_velocity: float = 4.5 +## Gravity (standalone mode). +@export var local_gravity: float = 15.0 +## Acceleration (standalone mode). +@export var local_acceleration: float = 12.0 + +func _move_local(input_dir: Vector3, delta: float, jump: bool) -> void: + # Simple CharacterBody3D movement for standalone testing + var target_speed: float = local_walk_speed + if _sprint_active: + target_speed = local_sprint_speed + if _crouch_active: + target_speed = local_crouch_speed + + var wish_dir := (transform.basis * input_dir).normalized() + var target_vel := wish_dir * target_speed + + # Horizontal acceleration + var h_vel := Vector3(velocity.x, 0.0, velocity.z) + h_vel = h_vel.move_toward(target_vel, local_acceleration * delta) + velocity.x = h_vel.x + velocity.z = h_vel.z + + # Gravity + if not is_on_floor(): + velocity.y -= local_gravity * delta + + # Jump + if jump and is_on_floor(): + velocity.y = local_jump_velocity + + move_and_slide() + + +# --------------------------------------------------------------------------- +# Crouch height management +# --------------------------------------------------------------------------- + +func _update_crouch(amount: float) -> void: + ## amount: 0.0 = standing, 1.0 = fully crouched + # Eye height — camera position relative to body origin (character feet at origin) + if _camera: + _camera.position.y = lerpf(eye_height_stand, eye_height_crouch, amount) + + # Collision shape — capsule height (cylindrical section) + if _capsule_shape: + _capsule_shape.height = lerpf(_stand_capsule_height, _crouch_capsule_height, amount) + + # Move the collision shape center so the capsule base stays on the ground: + # shift = (crouch_total - stand_total) / 2, negative = moves down + if _collision_shape: + _collision_shape.position.y = lerpf(0.0, (collision_height_crouch - collision_height_stand) * 0.5, amount) + + +# --------------------------------------------------------------------------- +# Input helpers +# --------------------------------------------------------------------------- + +func _get_move_direction() -> Vector3: + var dir := Vector3.ZERO + if Input.is_action_pressed(&"move_forward") or Input.is_action_pressed(&"move_up"): + dir.z -= 1.0 + if Input.is_action_pressed(&"move_backward") or Input.is_action_pressed(&"move_down"): + dir.z += 1.0 + if Input.is_action_pressed(&"move_left"): + dir.x -= 1.0 + if Input.is_action_pressed(&"move_right"): + dir.x += 1.0 + # Normalize for analog stick deadzone + if dir.length_squared() > 0.0: + dir = dir.normalized() + return dir + + +func _capture_mouse(capture: bool) -> void: + if capture == _mouse_captured: + return + _mouse_captured = capture + if capture: + Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) + else: + Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +## Set the entity ID assigned by SimulationServer after spawning. +func set_entity_id(id: int) -> void: + entity_id = id + + +## Get current crouch amount (0.0 = standing, 1.0 = fully crouched). +func get_crouch_amount() -> float: + return _crouch_current + + +## Is the player currently sprinting? +func is_sprinting() -> bool: + return _sprint_active + + +## Is the player currently crouching? +func is_crouching() -> bool: + return _crouch_active + + +## Force look direction (useful for spectator / spawn reset). +func set_look(yaw_rad: float, pitch_rad: float) -> void: + _yaw = yaw_rad + _pitch = clamp(pitch_rad, deg_to_rad(-max_pitch), deg_to_rad(max_pitch)) + + +## Reset to default standing state. +func reset_pose() -> void: + _sprint_active = false + _crouch_active = false + _sprint_pressed_last = false + _crouch_pressed_last = false + _crouch_current = 0.0 + _crouch_target = 0.0 + _update_crouch(0.0) diff --git a/client/characters/input/input_handler.gd b/client/characters/input/input_handler.gd new file mode 100644 index 0000000..ac937bc --- /dev/null +++ b/client/characters/input/input_handler.gd @@ -0,0 +1,59 @@ +## InputHandler — maps Godot Input Actions to FPS character controls. +## +## This script is NOT required at runtime — it exists to document the input +## map bindings that FPSCharacterController expects. Add these actions to +## your Project → Input Map (or load this script as an autoload for reference). +## +## Bindings (tactical FPS layout): +## +## 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 / walk-toggle +## crouch Ctrl Crouch (hold or toggle) +## shoot Mouse LMB Fire weapon +## aim Mouse RMB Aim down sights +## reload R Reload weapon +## interact E Use/interact +## toggle_walk_toggle (not bound) Toggle walk-toggle mode +## toggle_crouch_toggle (not bound) Toggle crouch-toggle mode +## ui_cancel Esc Release mouse capture +## +## Implementation note: +## FPSCharacterController reads input directly via Input.is_action_pressed() +## in _physics_process. No separate InputHandler node is needed unless you +## want to add rebinding, analog input smoothing, or input buffering. + +extends Node + +func _ready() -> void: + # This script documents bindings only — no runtime logic. + # To enforce default bindings at startup, uncomment the next lines: + + # _ensure_action(&"move_forward", KEY_W) + # _ensure_action(&"move_backward", KEY_S) + # _ensure_action(&"move_left", KEY_A) + # _ensure_action(&"move_right", KEY_D) + # _ensure_action(&"jump", KEY_SPACE) + # _ensure_action(&"sprint", KEY_SHIFT) + # _ensure_action(&"crouch", KEY_CTRL) + # _ensure_action(&"shoot", MOUSE_BUTTON_LEFT) + # _ensure_action(&"aim", MOUSE_BUTTON_RIGHT) + # _ensure_action(&"reload", KEY_R) + # _ensure_action(&"interact", KEY_E) + pass + + +## Ensure an input action exists with the given default key binding. +## Only sets the binding if the action does not already exist. +func _ensure_action(action_name: StringName, key: Key) -> void: + if InputMap.has_action(action_name): + return + var ev := InputEventKey.new() + ev.keycode = key + InputMap.add_action(action_name) + InputMap.action_add_event(action_name, ev) diff --git a/config-ext-map-registry.cfg b/config-ext-map-registry.cfg new file mode 100644 index 0000000..51c34e8 --- /dev/null +++ b/config-ext-map-registry.cfg @@ -0,0 +1,21 @@ +; Tactical Shooter — Map Registry Configuration Extension +; +; Add this section to your server_config.cfg to enable automatic +; map downloading from the registry server. +; +; The MapDownloader Godot singleton reads these values at startup. +; Environment override: MAP_REGISTRY_URL (takes precedence over cfg value) +; +; [map_registry] +; enabled — When true, the server fetches map list on start +; url — URL of the map registry server (HTTP/HTTPS) +; auto_download_all — Download ALL registry maps, not just rotation ones +; max_cache_mb — Soft limit on local cache size (0 = unlimited) +; verify_checksums — Verify SHA-256 on downloaded .pck files before loading + +[map_registry] +enabled=false +url="http://127.0.0.1:8090" +auto_download_all=false +max_cache_mb=500 +verify_checksums=true diff --git a/config/default_server_config.cfg b/config/default_server_config.cfg new file mode 100644 index 0000000..2f98e5a --- /dev/null +++ b/config/default_server_config.cfg @@ -0,0 +1,87 @@ +; Tactical Shooter — Default Server Configuration +; Godot ConfigFile format (INI-style). Edit this file to tune server behaviour. +; Load path: config://default_server_config.cfg (res:// path in Godot) +; Override at runtime by placing server_config.cfg next to the binary or via +; the SERVER_CFG environment variable. +; +; Sections marked [*] are defaults used at first run — they are written into +; the working-dir override if one doesn't exist, so the server always has +; a complete config. + +[server] +; Human-readable server name shown in the browser / scoreboard. +server_name="Tactical Shooter Server" +; Short description exposed to the server browser. +description="A tactical shooter server running on Godot 4 / ENet" +; Interface to bind to. Use "0.0.0.0" for all interfaces, "127.0.0.1" for local only. +bind_ip="0.0.0.0" +; Gameplay port. Default: 34197 (ephemeral range in IANA-ish neighbourhood). +port=34197 +; Maximum concurrent players. 2-32 recommended; ENet max is 4095 per host. +max_players=16 +; Server password. Empty = no password. Clients must supply this to connect. +password="" +; Simulation tick rate in Hz. Must match physics/common/physics_ticks_per_second. +; The GDExtension core runs at this rate. 128 is standard for competitive FPS. +tick_rate=128 + +[game] +; Round time in seconds. 0 = unlimited. 600 = 10 minutes. +round_time_seconds=600 +; Warmup period before round start, in seconds. +warmup_time_seconds=60 +; Whether teammates can damage each other. +friendly_fire=false +; Damage multiplier applied to friendly fire damage (only if friendly_fire=true). +ff_damage_multiplier=0.5 +; World gravity (negative = downward). Godot default is -9.8. Competitive FPS +; commonly uses -20 to -25 for snappier feel. +gravity=-24.0 +; Respawn delay in seconds after a player dies. +respawn_time_seconds=5.0 +; Whether dead players can spectate alive teammates. +spectate_enabled=true + +[movement] +; Movement parameters exposed to the GDExtension SimulationServer. +; These match the config accepted by SimulationServer.set_movement_config(). +walk_speed=5.0 +sprint_speed=7.0 +crouch_speed=2.0 +jump_velocity=6.0 +acceleration=20.0 +air_acceleration=4.0 +friction=8.0 + +[match] +; Rounds needed to win the match. Best-of-N means (2*win_limit-1) max rounds. +win_limit=3 +; Match time limit in seconds. 0 = unlimited. +time_limit_seconds=0 +; Map rotation mode: "sequence" (cycle in order), "vote" (player vote), "random". +map_rotation_mode="sequence" +; Comma-separated list of map scene resources to rotate through. +; Relative to the res://scenes/map/ directory. +; Example: "test_range, warehouse, compound" +maps="test_range" + +[rcon] +; Remote console (admin access while server is running). +; This is wired to the future build:rcon system. Disabled until then. +enabled=false +; RCON password — must be non-empty and at least 8 chars when enabled. +password="" +; RCON listener port (typically different from game port). +port=34198 + +[logging] +; Log level: "debug", "info", "warn", "error", "fatal". +log_level="info" +; Log file path. Empty = stdout only. Relative paths are relative to the binary. +log_file="" + +[teams] +; Number of teams (2 for classic TDM, 4 for FFA squads, etc.). +team_count=2 +; Maximum players per team. Total = team_count * players_per_team. +players_per_team=8 diff --git a/export_presets.cfg b/export_presets.cfg new file mode 100644 index 0000000..a05f450 --- /dev/null +++ b/export_presets.cfg @@ -0,0 +1,47 @@ +[preset.0] +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.0.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/.gitignore b/gdextension/simulation/.gitignore new file mode 100644 index 0000000..609eeba --- /dev/null +++ b/gdextension/simulation/.gitignore @@ -0,0 +1,19 @@ +# Build artifacts +build/ +gdextension/bin/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Python +__pycache__/ +*.pyc +*.pyo diff --git a/gdextension/simulation/README.md b/gdextension/simulation/README.md new file mode 100644 index 0000000..400b470 --- /dev/null +++ b/gdextension/simulation/README.md @@ -0,0 +1,159 @@ +# Tactical Shooter — GDExtension Simulation Core + +GDExtension C++ simulation core for a 128Hz tactical FPS. Movement, hit detection, and state serialization run entirely in C++ — the GDScript hot loop handles only network I/O and rendering. + +## Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ GDScript Layer │ +│ _process(delta) → can_tick() → tick() → send() │ +├─────────────────────────────────────────────────────┤ +│ SimulationServer (C++) │ +│ ┌──────────┐ ┌───────────────┐ ┌───────────────┐ │ +│ │Movement │ │HitDetection │ │StateSerializer│ │ +│ │Component │ │(ray/overlap) │ │(delta-compress)│ │ +│ └──────────┘ └───────────────┘ └───────────────┘ │ +│ ┌──────────────────────────────────────────────────┐│ +│ │ Entity[N] (position, velocity, health, input) ││ +│ └──────────────────────────────────────────────────┘│ +├─────────────────────────────────────────────────────┤ +│ Network Layer (GDScript → ENet) │ +└─────────────────────────────────────────────────────┘ +``` + +### Design Principles + +- **Hot path in C++**: All simulation ticks (movement, hit detection, serialization) run in GDExtension. No GDScript VM overhead per tick. +- **Custom replication**: Not using Godot's scene-replication (too expensive at 128Hz). Delta-compressed binary snapshots via `StateSerializer`. +- **Abstracted systems**: Movement parameters are configurable from GDScript. Hit detection uses geometric checks (no PhysicsServer3D dependency in Phase 1). +- **Benchmark-ready**: `SimulationServer::populate_bots()` + `get_stats()` provide the hook for the 128Hz load test (`task t_f671f48a`). + +## Prerequisites + +- **Godot 4.2+** (recommended: 4.3+) +- **godot-cpp** (v4.3 branch): `git submodule update --init` +- **SCons** 4.0+: `pip install scons` +- **C++17 compiler**: GCC 11+ / Clang 14+ / MSVC 2022 + +## Setup + +```bash +# Clone with submodules +git clone tactical-shooter +cd tactical-shooter +git submodule update --init --recursive + +# Build (debug) +scons -j$(nproc) + +# Build (release) +scons target=template_release -j$(nproc) +``` + +The compiled `.so`/`.dll` will be placed in `gdextension/bin//`. + +## Usage (GDScript) + +```gdscript +var server = SimulationServer.new() +server.tick_rate = 128 +server.start() + +# Spawn some entities +server.spawn_entity(Vector3(0, 0, 0)) +server.spawn_entity(Vector3(5, 0, 0)) + +# Main loop +func _process(delta): + while server.can_tick(delta): + var snapshot = server.tick() + if snapshot.size() > 0: + send_to_clients(snapshot) + +# Apply player input +func _input(event): + var input_dict = { + move_direction = input_vector, + look_yaw = camera_yaw, + look_pitch = camera_pitch, + jump = Input.is_action_just_pressed("jump"), + sprint = Input.is_action_pressed("sprint"), + shoot = Input.is_action_just_pressed("shoot"), + input_sequence = input_seq + } + server.apply_input(player_entity_id, input_dict) +``` + +## Project Structure + +``` +├── SConstruct # Build system +├── src/ +│ ├── register_types.cpp/.h # GDExtension entry point +│ ├── simulation_server.cpp/.h # Main orchestrator (GDScript-facing) +│ ├── entity.cpp/.h # Entity state (GDScript-facing) +│ ├── movement_component.cpp/.h # FPS movement simulation +│ ├── hit_detection.cpp/.h # Ray/sphere hit detection +│ ├── state_serializer.cpp/.h # Delta-compressed snapshot I/O +│ └── bitstream.h # Bit-level packing (header-only) +├── gdextension/ +│ ├── simulation.gdextension # Godot extension config +│ └── bin/ # Build output +└── tests/ + └── test_simulation.cpp # Unit test harness +``` + +## Movement Parameters + +All tunable from GDScript: + +| Parameter | Default | Description | +|----------------|---------|--------------------------------------| +| walk_speed | 4.0 | Ground speed (units/s) | +| sprint_speed | 6.5 | Sprint speed (units/s) | +| crouch_speed | 2.0 | Crouch speed (units/s) | +| acceleration | 20.0 | Ground acceleration (units/s²) | +| air_acceleration| 4.0 | Air acceleration (units/s²) | +| friction | 8.0 | Ground deceleration | +| jump_velocity | 5.0 | Initial upward velocity (units/s) | +| gravity | -20.0 | Gravity (units/s², negative = down) | + +## Snapshot Wire Format + +``` +[uint32 tick] # Server tick number +[uint16 count] # Number of changed entities +[uint16 base_tick] # 0 = full snapshot +--- per entity --- +[uint16 entity_id] +[uint32 change_mask] # Which fields changed +[fields per change_mask] # Only changed fields, packed as: + position: 3 × 16-bit quantized floats (range ±1024) + velocity: 3 × 12-bit quantized floats (range ±32) + rotation: 2 × 11-12 bit quantized floats + health: 7-bit quantized (0-100) + armor: 7-bit quantized (0-100) + weapon_id: uint8 + ammo: uint16 + flags: uint16 + input_seq: uint32 +``` + +Typical sizes at 128Hz: +- **Full snapshot** (10 entities): ~300-400 bytes +- **Delta snapshot** (5 changed): ~150-250 bytes + +## Task Dependencies + +``` +Phase 0 (done) → build:gdextension-simulation-scaffold (this) → bench:128hz-load-test + → build:fps-character-controller +``` + +## Known Limitations (Phase 1) + +- Hit detection uses bounding sphere geometry (not PhysicsServer3D raycasts) +- Ground detection is stub-only (no scene queries for floor normal) +- No interpolation: clients receive raw tick snapshots +- Single-threaded tick processing diff --git a/gdextension/simulation/SConstruct b/gdextension/simulation/SConstruct new file mode 100644 index 0000000..0a60d88 --- /dev/null +++ b/gdextension/simulation/SConstruct @@ -0,0 +1,115 @@ +#!/usr/bin/env python +""" +Build system for tactical-shooter GDExtension simulation core. + +Targets a shared library (.so/.dll/.dylib) loadable by Godot 4. + +Dependencies: + - godot-cpp submodule (git submodule update --init) + - scons (pip install scons) + - C++17 capable compiler (gcc >= 11, clang >= 14, MSVC 2022) + +Usage: + scons # debug build + scons target=release # release build (optimized, no debug symbols) + scons target=template_release # same as release + scons target=template_debug # debug build with optimization + scons -j$(nproc) # parallel build + +Platform detection is automatic (linux, windows, macos). +Architecture detection is automatic (x86_64, arm64). +""" + +import os +import sys +import platform + +# ---- Environment Setup ------------------------------------------------------- + +EnsureSConsVersion(4, 0) + +env = SConscript("godot-cpp/SConstruct") + +# ---- Platform / Architecture / Target detection ------------------------------ + +# Add our custom options +opts = Variables([], ARGUMENTS) +opts.Add( + EnumVariable("target", "Build target", "template_debug", + ("editor", "template_debug", "template_release")) +) +opts.Add(BoolVariable("verbose", "Enable verbose output", False)) +opts.Add( + PathVariable("gdextension_dir", "Output directory for .gdextension + binary", + "gdextension", PathVariable.PathIsDirCreate) +) +opts.Update(env) +Help(opts.GenerateHelpText(env)) + +# Derive target name from platform +target_platform = env["platform"] +if target_platform == "linux": + lib_name = "libsimulation.so" +elif target_platform == "windows": + lib_name = "libsimulation.dll" +elif target_platform == "macos": + lib_name = "libsimulation.dylib" +else: + lib_name = f"libsimulation.{target_platform}" + +# Build directory mirrors target/platform structure +build_dir = os.path.join("build", env["platform"], env["target"]) + +# ---- Compiler Flags ---------------------------------------------------------- + +env.Append(CPPPATH=["src/"]) + +if env["target"] == "template_release": + env.Append(CCFLAGS=["-O3", "-DNDEBUG", "-fomit-frame-pointer"]) +elif env["target"] == "template_debug": + env.Append(CCFLAGS=["-O2", "-g", "-DDEBUG_ENABLED"]) +else: # editor + env.Append(CCFLAGS=["-O2", "-g", "-DDEBUG_ENABLED", "-DEDITOR_ENABLED"]) + +# Warnings as errors (strict) +env.Append(CCFLAGS=[ + "-Wall", "-Wextra", + "-Wno-unused-parameter", # godot-cpp callbacks have many unused params + "-Wno-missing-field-initializers", +]) + +# C++17 required by godot-cpp +env.Append(CXXFLAGS=["-std=c++17"]) + +# ---- Sources ---------------------------------------------------------------- + +sources = Glob("src/*.cpp") + +# ---- Build Targets ---------------------------------------------------------- + +# Object files go to build directory +VariantDir(build_dir, "src", duplicate=False) +build_objects = [os.path.join(build_dir, os.path.basename(s)) for s in sources] + +# Final library in gdextension/bin// +output_dir = os.path.join(env["gdextension_dir"], "bin", target_platform) +output_path = os.path.join(output_dir, lib_name) + +lib = env.SharedLibrary(target=output_path, source=build_objects) + +# Alias so `scons build` works +env.Alias("build", lib) +Default(lib) + +# ---- Verbose mode ----------------------------------------------------------- + +if not env["verbose"]: + env.SetDefault(COMSTR_COMPILEPROGRESS="[{.TaskComplete}/{.TaskCount}] Compiling $TARGET") + env.SetDefault(LINKCOMSTR="[Link] $TARGET") + env.SetDefault(SHCCCOMSTR="[CC] $TARGET") + env.SetDefault(SHCXXCOMSTR="[CXX] $TARGET") + env.SetDefault(SHLINKCOMSTR="[Link] $TARGET") + +# ---- Clean ------------------------------------------------------------------- + +Clean(lib, build_dir) diff --git a/gdextension/simulation/gdextension/simulation.gdextension b/gdextension/simulation/gdextension/simulation.gdextension new file mode 100644 index 0000000..1606bb1 --- /dev/null +++ b/gdextension/simulation/gdextension/simulation.gdextension @@ -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/src/bitstream.h b/gdextension/simulation/src/bitstream.h new file mode 100644 index 0000000..80fd726 --- /dev/null +++ b/gdextension/simulation/src/bitstream.h @@ -0,0 +1,260 @@ +#ifndef TACTICAL_SHOOTER_BITSTREAM_H +#define TACTICAL_SHOOTER_BITSTREAM_H + +#include +#include +#include +#include +#include +#include + +namespace tactical_shooter { + +/** + * Bit-level read/write stream for compact network serialization. + * + * All multi-byte values are written in little-endian order regardless of + * host endianness (network byte order). Booleans pack as single bits. + * Floats can be quantized to arbitrary bit depths for bandwidth savings. + * + * Buffers are dynamically resized. Pre-allocate with reserve() to avoid + * reallocation in hot paths. + */ +class Bitstream { +public: + static constexpr size_t kMaxBufferSize = 1024 * 1024; // 1MB safety limit + + Bitstream() : buffer_(), bits_written_(0), bits_read_(0) {} + + explicit Bitstream(std::vector data) + : buffer_(std::move(data)), bits_written_(buffer_.size() * 8), bits_read_(0) {} + + // ---- Write ----------------------------------------------------------- + + void write_bool(bool value) { + write_bits(value ? 1 : 0, 1); + } + + void write_uint8(uint8_t value) { + write_bits(value, 8); + } + + void write_uint16(uint16_t value) { + write_bits(value, 16); + } + + void write_uint32(uint32_t value) { + write_bits(value, 32); + } + + void write_int32(int32_t value) { + // Zigzag encoding for efficient negative-number packing + uint32_t zigzag = static_cast((value << 1) ^ (value >> 31)); + write_bits(zigzag, 32); + } + + /** + * Write a float quantized to `nbits` within [min, max]. + * Storage: nbits bits. Resolution: (max-min) / (2^nbits - 1). + * Pass nbits=32 for full-precision float (no quantization loss). + */ + void write_float_quantized(float value, float min, float max, uint8_t nbits) { + assert(nbits > 0 && nbits <= 32); + if (nbits >= 32) { + // Full precision: store as raw bits + uint32_t raw; + memcpy(&raw, &value, sizeof(raw)); + write_bits(raw, 32); + return; + } + float clamped = std::clamp(value, min, max); + float normalized = (clamped - min) / (max - min); + uint32_t quantized = static_cast(normalized * ((1u << nbits) - 1)); + write_bits(quantized, nbits); + } + + /** + * Write up to `nbits` bits of `value`. LSB first packing. + */ + void write_bits(uint32_t value, uint8_t nbits) { + assert(nbits > 0 && nbits <= 32); + ensure_capacity(nbits); + + uint8_t *data = buffer_.data(); + size_t byte_pos = bits_written_ / 8; + uint8_t bit_offset = bits_written_ % 8; + + for (uint8_t i = 0; i < nbits; ++i) { + if (value & (1u << i)) { + data[byte_pos] |= (1u << bit_offset); + } + ++bit_offset; + if (bit_offset >= 8) { + bit_offset = 0; + ++byte_pos; + } + } + bits_written_ += nbits; + } + + // ---- Read ------------------------------------------------------------ + + bool read_bool() { + return read_bits(1) != 0; + } + + uint8_t read_uint8() { + return static_cast(read_bits(8)); + } + + uint16_t read_uint16() { + return static_cast(read_bits(16)); + } + + uint32_t read_uint32() { + return read_bits(32); + } + + int32_t read_int32() { + uint32_t zigzag = read_bits(32); + return static_cast((zigzag >> 1) ^ -(static_cast(zigzag & 1))); + } + + /** + * Read a quantized float matching write_float_quantized(). + */ + float read_float_quantized(float min, float max, uint8_t nbits) { + assert(nbits > 0 && nbits <= 32); + if (nbits >= 32) { + uint32_t raw = read_bits(32); + float value; + memcpy(&value, &raw, sizeof(value)); + return value; + } + uint32_t quantized = read_bits(nbits); + float normalized = static_cast(quantized) / static_cast((1u << nbits) - 1); + return min + normalized * (max - min); + } + + /** + * Read up to `nbits` bits, returned as LSB-packed uint32. + */ + uint32_t read_bits(uint8_t nbits) { + assert(nbits > 0 && nbits <= 32); + assert((bits_read_ + nbits) <= bits_written_); + + const uint8_t *data = buffer_.data(); + size_t byte_pos = bits_read_ / 8; + uint8_t bit_offset = bits_read_ % 8; + uint32_t result = 0; + + for (uint8_t i = 0; i < nbits; ++i) { + if (data[byte_pos] & (1u << bit_offset)) { + result |= (1u << i); + } + ++bit_offset; + if (bit_offset >= 8) { + bit_offset = 0; + ++byte_pos; + } + } + bits_read_ += nbits; + return result; + } + + // ---- Array helpers --------------------------------------------------- + + /** + * Write a dense array of booleans packed bit-by-bit. + */ + void write_bool_array(const bool *values, size_t count) { + for (size_t i = 0; i < count; ++i) { + write_bool(values[i]); + } + } + + void read_bool_array(bool *values, size_t count) { + for (size_t i = 0; i < count; ++i) { + values[i] = read_bool(); + } + } + + /** + * Write a variable-length array of uint8 values with a uint16 count prefix. + */ + void write_uint8_array(const uint8_t *values, uint16_t count) { + write_uint16(count); + for (uint16_t i = 0; i < count; ++i) { + write_uint8(values[i]); + } + } + + std::vector read_uint8_array() { + uint16_t count = read_uint16(); + std::vector result(count); + for (uint16_t i = 0; i < count; ++i) { + result[i] = read_uint8(); + } + return result; + } + + // ---- State ----------------------------------------------------------- + + /// Total bytes consumed by written data + size_t byte_size() const { + return (bits_written_ + 7) / 8; + } + + /// Number of bits written so far + size_t bits_written() const { return bits_written_; } + + /// Number of bits read so far + size_t bits_read() const { return bits_read_; } + + /// Remaining readable bits + size_t bits_remaining() const { + return bits_written_ - bits_read_; + } + + /// Raw buffer (const access) + const uint8_t *data() const { return buffer_.data(); } + + /// Clear everything, rewind + void reset() { + buffer_.clear(); + bits_written_ = 0; + bits_read_ = 0; + } + + /// Pre-allocate capacity in bytes + void reserve(size_t bytes) { + buffer_.reserve(bytes); + } + + /// Steal the internal buffer + std::vector take_buffer() { + std::vector result = std::move(buffer_); + reset(); + return result; + } + +private: + void ensure_capacity(uint8_t extra_bits) { + size_t needed_bytes = (bits_written_ + extra_bits + 7) / 8; + if (needed_bytes > buffer_.size()) { + if (needed_bytes > kMaxBufferSize) { + // TODO: log error instead of assert in production + assert(!"Bitstream overflow — reduce snapshot size or increase kMaxBufferSize"); + } + buffer_.resize(std::max(buffer_.size() * 2, needed_bytes)); + } + } + + std::vector buffer_; + size_t bits_written_ = 0; + size_t bits_read_ = 0; +}; + +} // namespace tactical_shooter + +#endif // TACTICAL_SHOOTER_BITSTREAM_H diff --git a/gdextension/simulation/src/entity.cpp b/gdextension/simulation/src/entity.cpp new file mode 100644 index 0000000..a1badb8 --- /dev/null +++ b/gdextension/simulation/src/entity.cpp @@ -0,0 +1,125 @@ +#include "entity.h" + +#include + +namespace tactical_shooter { + +Entity::Entity() { + reset(godot::Vector3(0, 0, 0)); +} + +void Entity::reset(const godot::Vector3 &spawn_position) { + position_ = spawn_position; + velocity_ = godot::Vector3(0, 0, 0); + yaw_ = 0.0f; + pitch_ = 0.0f; + health_ = 100.0f; + armor_ = 0.0f; + flags_ = ENTITY_FLAG_ALIVE; + weapon_id_ = 0; + ammo_ = 30; + last_input_ = EntityInput{}; +} + +void Entity::apply_input(const EntityInput &input) { + last_input_ = input; + + // Update flags from input + if (input.crouch) flags_ |= ENTITY_FLAG_CROUCHING; + else flags_ &= ~ENTITY_FLAG_CROUCHING; + + if (input.sprint) flags_ |= ENTITY_FLAG_SPRINTING; + else flags_ &= ~ENTITY_FLAG_SPRINTING; + + if (input.aim) flags_ |= ENTITY_FLAG_AIMING; + else flags_ &= ~ENTITY_FLAG_AIMING; +} + +// ---- Snapshot / Delta -------------------------------------------------------- + +EntitySnapshot Entity::capture_snapshot() const { + EntitySnapshot snap; + snap.entity_id = entity_id_; + snap.flags = flags_; + snap.position = position_; + snap.velocity = velocity_; + snap.yaw = yaw_; + snap.pitch = pitch_; + snap.health = health_; + snap.armor = armor_; + snap.weapon_id = weapon_id_; + snap.ammo = ammo_; + snap.last_processed_input = last_input_.input_sequence; + return snap; +} + +EntitySnapshot::ChangeMask Entity::compute_change_mask(const EntitySnapshot &base) const { + EntitySnapshot::ChangeMask mask = EntitySnapshot::CHANGED_NONE; + + if (position_ != base.position) mask |= EntitySnapshot::CHANGED_POSITION; + if (velocity_ != base.velocity) mask |= EntitySnapshot::CHANGED_VELOCITY; + if (yaw_ != base.yaw || pitch_ != base.pitch) mask |= EntitySnapshot::CHANGED_ROTATION; + if (health_ != base.health) mask |= EntitySnapshot::CHANGED_HEALTH; + if (armor_ != base.armor) mask |= EntitySnapshot::CHANGED_ARMOR; + if (weapon_id_ != base.weapon_id) mask |= EntitySnapshot::CHANGED_WEAPON; + if (ammo_ != base.ammo) mask |= EntitySnapshot::CHANGED_AMMO; + if (flags_ != base.flags) mask |= EntitySnapshot::CHANGED_FLAGS; + if (last_input_.input_sequence != base.last_processed_input) + mask |= EntitySnapshot::CHANGED_INPUT; + + return mask; +} + +// ---- GDScript Bindings ------------------------------------------------------- + +void Entity::_bind_methods() { + using namespace godot; + + ClassDB::bind_method(D_METHOD("set_position", "position"), &Entity::set_position); + ClassDB::bind_method(D_METHOD("get_position"), &Entity::get_position); + ADD_PROPERTY(PropertyInfo(Variant::VECTOR3, "position"), "set_position", "get_position"); + + ClassDB::bind_method(D_METHOD("set_velocity", "velocity"), &Entity::set_velocity); + ClassDB::bind_method(D_METHOD("get_velocity"), &Entity::get_velocity); + ADD_PROPERTY(PropertyInfo(Variant::VECTOR3, "velocity"), "set_velocity", "get_velocity"); + + ClassDB::bind_method(D_METHOD("set_health", "health"), &Entity::set_health); + ClassDB::bind_method(D_METHOD("get_health"), &Entity::get_health); + ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "health"), "set_health", "get_health"); + + ClassDB::bind_method(D_METHOD("set_armor", "armor"), &Entity::set_armor); + ClassDB::bind_method(D_METHOD("get_armor"), &Entity::get_armor); + ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "armor"), "set_armor", "get_armor"); + + ClassDB::bind_method(D_METHOD("set_yaw", "yaw"), &Entity::set_yaw); + ClassDB::bind_method(D_METHOD("get_yaw"), &Entity::get_yaw); + ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "yaw"), "set_yaw", "get_yaw"); + + ClassDB::bind_method(D_METHOD("set_pitch", "pitch"), &Entity::set_pitch); + ClassDB::bind_method(D_METHOD("get_pitch"), &Entity::get_pitch); + ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "pitch"), "set_pitch", "get_pitch"); + + ClassDB::bind_method(D_METHOD("is_alive"), &Entity::is_alive); + ClassDB::bind_method(D_METHOD("kill"), &Entity::kill); + + ClassDB::bind_method(D_METHOD("get_entity_id"), &Entity::get_entity_id); +} + +// ---- Property Accessors ------------------------------------------------------ + +void Entity::set_position(const godot::Vector3 &p_position) { position_ = p_position; } +godot::Vector3 Entity::get_position() const { return position_; } +void Entity::set_velocity(const godot::Vector3 &p_velocity) { velocity_ = p_velocity; } +godot::Vector3 Entity::get_velocity() const { return velocity_; } +void Entity::set_health(float p_health) { health_ = std::clamp(p_health, 0.0f, 100.0f); } +float Entity::get_health() const { return health_; } +void Entity::set_armor(float p_armor) { armor_ = std::clamp(p_armor, 0.0f, 100.0f); } +float Entity::get_armor() const { return armor_; } +void Entity::set_yaw(float p_yaw) { yaw_ = p_yaw; } +float Entity::get_yaw() const { return yaw_; } +void Entity::set_pitch(float p_pitch) { pitch_ = std::clamp(p_pitch, -90.0f, 90.0f); } +float Entity::get_pitch() const { return pitch_; } +bool Entity::is_alive() const { return flags_ & ENTITY_FLAG_ALIVE; } +void Entity::kill() { flags_ &= ~ENTITY_FLAG_ALIVE; } + +} // namespace tactical_shooter diff --git a/gdextension/simulation/src/entity.h b/gdextension/simulation/src/entity.h new file mode 100644 index 0000000..99a3768 --- /dev/null +++ b/gdextension/simulation/src/entity.h @@ -0,0 +1,173 @@ +#ifndef TACTICAL_SHOOTER_ENTITY_H +#define TACTICAL_SHOOTER_ENTITY_H + +#include +#include +#include + +namespace tactical_shooter { + +/** + * Maximum number of entities in a single simulation. + * Hard cap prevents memory exhaustion and bounds snapshot sizes. + */ +static constexpr uint16_t kMaxEntities = 256; + +/** + * Per-entity flags packed into a single uint16 for compact serialization. + */ +enum EntityFlags : uint16_t { + ENTITY_FLAG_NONE = 0, + ENTITY_FLAG_ALIVE = 1 << 0, + ENTITY_FLAG_GROUNDED = 1 << 1, + ENTITY_FLAG_CROUCHING = 1 << 2, + ENTITY_FLAG_SPRINTING = 1 << 3, + ENTITY_FLAG_AIMING = 1 << 4, +}; + +/** + * Entity input state for a single tick — what the client sent this frame. + * Compact enough to be sent at 128Hz. + */ +struct EntityInput { + godot::Vector3 move_direction; // normalized, quantized + float look_yaw = 0.0f; // degrees + float look_pitch = 0.0f; // degrees + bool jump = false; + bool crouch = false; + bool sprint = false; + bool shoot = false; + bool aim = false; + uint32_t input_sequence = 0; // for client-authoritative input validation +}; + +/** + * Full entity state snapshot at one point in time. + * Used as the "base" for delta compression — serialize only the fields + * that changed since the last acknowledged snapshot. + */ +struct EntitySnapshot { + uint16_t entity_id = 0; + uint16_t flags = ENTITY_FLAG_ALIVE; + + // Transform (quantized for network) + godot::Vector3 position; + godot::Vector3 velocity; + float yaw = 0.0f; // degrees, -180..180 + float pitch = 0.0f; // degrees, -90..90 + + // Game state + float health = 100.0f; + float armor = 0.0f; + uint8_t weapon_id = 0; + uint16_t ammo = 0; + + // Client-authoritative input (for reconciliation) + uint32_t last_processed_input = 0; + + /// Bit-field that tells serializers which fields changed from last base + enum ChangeMask : uint32_t { + CHANGED_NONE = 0, + CHANGED_POSITION = 1 << 0, + CHANGED_VELOCITY = 1 << 1, + CHANGED_ROTATION = 1 << 2, + CHANGED_HEALTH = 1 << 3, + CHANGED_ARMOR = 1 << 4, + CHANGED_WEAPON = 1 << 5, + CHANGED_AMMO = 1 << 6, + CHANGED_FLAGS = 1 << 7, + CHANGED_INPUT = 1 << 8, + CHANGED_ALL = 0xFFFFFFFF, + }; +}; + +/** + * Simulation entity — internal game object managed by SimulationServer. + * + * Registered as a RefCounted so GDScript can hold references and pass + * them around without manual memory management. + */ +class Entity : public godot::RefCounted { + GDCLASS(Entity, godot::RefCounted) + +public: + Entity(); + ~Entity() = default; + + // ---- GDScript API ---------------------------------------------------- + + void set_position(const godot::Vector3 &p_position); + godot::Vector3 get_position() const; + + void set_velocity(const godot::Vector3 &p_velocity); + godot::Vector3 get_velocity() const; + + void set_health(float p_health); + float get_health() const; + + void set_armor(float p_armor); + float get_armor() const; + + void set_yaw(float p_yaw); + float get_yaw() const; + + void set_pitch(float p_pitch); + float get_pitch() const; + + bool is_alive() const; + void kill(); + + uint16_t get_entity_id() const { return entity_id_; } + void set_entity_id(uint16_t id) { entity_id_ = id; } + + uint16_t get_flags() const { return flags_; } + void set_flags(uint16_t f) { flags_ = f; } + + // ---- Internal API (not exposed to GDScript) -------------------------- + + /// Reset entity to spawn state + void reset(const godot::Vector3 &spawn_position); + + /// Apply input for this tick (called by SimulationServer) + void apply_input(const EntityInput &input); + + /// Capture current state into a snapshot + EntitySnapshot capture_snapshot() const; + + /// Compute delta against a base snapshot for serialization + EntitySnapshot::ChangeMask compute_change_mask(const EntitySnapshot &base) const; + + /// Internal state access (for movement/hit systems) + const EntityInput &last_input() const { return last_input_; } + float crouch_height() const { return is_crouching() ? 0.75f : 1.0f; } + +protected: + static void _bind_methods(); + +private: + bool is_crouching() const { return flags_ & ENTITY_FLAG_CROUCHING; } + bool is_sprinting() const { return flags_ & ENTITY_FLAG_SPRINTING; } + bool is_aiming() const { return flags_ & ENTITY_FLAG_AIMING; } + + uint16_t entity_id_ = 0; + uint16_t flags_ = ENTITY_FLAG_ALIVE; + + // World state + godot::Vector3 position_; + godot::Vector3 velocity_; + float yaw_ = 0.0f; + float pitch_ = 0.0f; + + // Gameplay + float health_ = 100.0f; + float armor_ = 0.0f; + uint8_t weapon_id_ = 0; + uint16_t ammo_ = 0; + + // Input state + EntityInput last_input_; +}; + +} // namespace tactical_shooter + +#endif // TACTICAL_SHOOTER_ENTITY_H diff --git a/gdextension/simulation/src/hit_detection.cpp b/gdextension/simulation/src/hit_detection.cpp new file mode 100644 index 0000000..fe3085e --- /dev/null +++ b/gdextension/simulation/src/hit_detection.cpp @@ -0,0 +1,157 @@ +#include "hit_detection.h" + +#include +#include +#include + +namespace tactical_shooter { + +HitDetection::HitDetection() {} + +void HitDetection::set_entities(const std::vector &entities) { + entities_ = &entities; +} + +HitResult HitDetection::raycast_entity( + const godot::Vector3 &origin, + const godot::Vector3 &direction, + float max_distance, + uint16_t exclude_id +) const { + if (!entities_) return HitResult{}; + + HitResult best; + best.distance = max_distance; + + for (Entity *entity : *entities_) { + if (!entity || !entity->is_alive()) continue; + if (entity->get_entity_id() == exclude_id) continue; + + // Simple sphere intersection test against entity bounding sphere + godot::Vector3 entity_pos = entity->get_position(); + // Offset center upward for body collision (not at feet) + godot::Vector3 body_center = entity_pos; + body_center.y += kEntityHeight * 0.5f; + + godot::Vector3 oc = origin - body_center; + float a = direction.dot(direction); + float b = 2.0f * oc.dot(direction); + float c = oc.dot(oc) - (kEntityRadius * kEntityRadius); + float discriminant = b * b - 4.0f * a * c; + + if (discriminant < 0.0f) continue; + + float t1 = (-b - std::sqrt(discriminant)) / (2.0f * a); + float t2 = (-b + std::sqrt(discriminant)) / (2.0f * a); + + // Use the closest positive intersection + float t = t1; + if (t < 0.0f) t = t2; + if (t < 0.0f || t > best.distance) continue; + + best.hit = true; + best.entity_id = entity->get_entity_id(); + best.distance = t; + best.point = origin + direction * t; + best.normal = (best.point - body_center).normalized(); + best.damage = 0.0f; // filled by process_shot + best.hitbox_id = classify_hitbox(*entity, best.point); + } + + return best; +} + +HitResult HitDetection::process_shot( + const godot::Vector3 &origin, + const godot::Vector3 &direction, + uint16_t shooter_id, + const WeaponDamage &weapon +) { + HitResult hit = raycast_entity(origin, direction, weapon.max_range, shooter_id); + + if (hit.hit) { + float mult = get_hitbox_multiplier(hit.hitbox_id, weapon); + hit.damage = weapon.base_damage * mult; + } + + return hit; +} + +float HitDetection::apply_damage(Entity &entity, float damage, float mult) { + float raw = damage * mult; + + // Armor absorbs a portion + float armor = entity.get_armor(); + float armor_absorb = std::min(raw * 0.5f, armor); + armor -= armor_absorb; + entity.set_armor(armor); + + float health_damage = raw - armor_absorb; + float new_health = entity.get_health() - health_damage; + entity.set_health(new_health); + + if (new_health <= 0.0f) { + entity.kill(); + } + + return health_damage + armor_absorb; +} + +std::vector HitDetection::sphere_overlap( + const godot::Vector3 ¢er, + float radius, + uint16_t exclude_id +) const { + std::vector results; + if (!entities_) return results; + + float radius_sq = radius * radius; + + for (Entity *entity : *entities_) { + if (!entity || !entity->is_alive()) continue; + if (entity->get_entity_id() == exclude_id) continue; + + godot::Vector3 body_center = entity->get_position(); + body_center.y += kEntityHeight * 0.5f; + + godot::Vector3 diff = center - body_center; + float dist_sq = diff.dot(diff); + + if (dist_sq <= radius_sq) { + HitResult hit; + hit.hit = true; + hit.entity_id = entity->get_entity_id(); + hit.distance = std::sqrt(dist_sq); + hit.point = body_center; + hit.normal = diff.normalized(); + results.push_back(hit); + } + } + + return results; +} + +float HitDetection::get_hitbox_multiplier(uint8_t hitbox_id, const WeaponDamage &weapon) const { + switch (hitbox_id) { + case 1: return weapon.head_multiplier; + case 2: return weapon.arm_multiplier; + case 3: return weapon.leg_multiplier; + default: return weapon.body_multiplier; + } +} + +uint8_t HitDetection::classify_hitbox( + const Entity &entity, + const godot::Vector3 &hit_point +) const { + godot::Vector3 entity_pos = entity.get_position(); + float relative_y = hit_point.y - entity_pos.y; + float height_ratio = relative_y / kEntityHeight; + + if (height_ratio > 0.85f) return 1; // head + if (height_ratio > 0.65f) return 2; // arms/upper body + if (height_ratio > 0.25f) return 0; // body + return 3; // legs +} + +} // namespace tactical_shooter diff --git a/gdextension/simulation/src/hit_detection.h b/gdextension/simulation/src/hit_detection.h new file mode 100644 index 0000000..4409f60 --- /dev/null +++ b/gdextension/simulation/src/hit_detection.h @@ -0,0 +1,138 @@ +#ifndef TACTICAL_SHOOTER_HIT_DETECTION_H +#define TACTICAL_SHOOTER_HIT_DETECTION_H + +#include "entity.h" + +#include +#include + +namespace tactical_shooter { + +/** + * Hit detection system that operates on the simulation entities directly. + * + * Because the simulation core runs in GDExtension (not GDScript), we avoid + * crossing the script→engine boundary for every raycast during the hot loop. + * Instead, this component provides simplified geometric checks that can be + * resolved locally, or queues world-space queries for the Godot PhysicsServer3D + * if precise scene geometry is needed. + * + * Phase 1 uses sphere/box overlap against entity positions — fast, no physics + * dependency. Phase 2+ may add proper PhysicsServer3D raycasts against the + * world geometry. + */ + +/** + * Result of a single hit query. + */ +struct HitResult { + bool hit = false; + uint16_t entity_id = 0xFFFF; // 0xFFFF = world geometry / no entity + float damage = 0.0f; + float distance = 0.0f; + godot::Vector3 point; + godot::Vector3 normal; + uint8_t hitbox_id = 0; // 0=body, 1=head, 2=arms, 3=legs +}; + +/** + * Weapon damage profile. + */ +struct WeaponDamage { + float base_damage = 30.0f; + float head_multiplier = 4.0f; + float body_multiplier = 1.0f; + float arm_multiplier = 0.75f; + float leg_multiplier = 0.6f; + float max_range = 500.0f; // units + float spread_degrees = 0.5f; // random spread per shot +}; + +class HitDetection { +public: + HitDetection(); + + /** + * Set which entities are currently in play (reference list). + * Must be called before any hit queries each frame. + */ + void set_entities(const std::vector &entities); + + /** + * Raycast against entity bounding spheres. + * No PhysicsServer3D dependency — pure geometric check. + * + * @param origin Ray origin + * @param direction Normalized ray direction + * @param max_distance Maximum trace distance + * @param exclude_id Entity ID to exclude (shooter) + * @return First entity hit (if any) + */ + HitResult raycast_entity( + const godot::Vector3 &origin, + const godot::Vector3 &direction, + float max_distance, + uint16_t exclude_id = 0xFFFF + ) const; + + /** + * Process a weapon fire: apply damage to hit entity. + * Combines raycast + damage application + amortization. + * + * @param origin Fire origin + * @param direction Fire direction (with spread already applied) + * @param shooter_id Entity that fired + * @param weapon Weapon damage profile + * @return Hit result with damage + */ + HitResult process_shot( + const godot::Vector3 &origin, + const godot::Vector3 &direction, + uint16_t shooter_id, + const WeaponDamage &weapon + ); + + /** + * Apply damage to an entity and update its state. + * Respects armor reduction. + * + * @param entity Target entity + * @param damage Raw damage before armor + * @param mult Hitbox multiplier + * @return Actual health removed + */ + static float apply_damage(Entity &entity, float damage, float mult); + + /** + * Sphere overlap detection — find all entities within radius. + * Useful for explosive damage. + */ + std::vector sphere_overlap( + const godot::Vector3 ¢er, + float radius, + uint16_t exclude_id = 0xFFFF + ) const; + +private: + /** + * Get the hitbox multiplier for a hit location. + */ + float get_hitbox_multiplier(uint8_t hitbox_id, const WeaponDamage &weapon) const; + + /** + * Determine which hitbox a ray hit based on local-space intersection. + * Simple head/body/leg classification by vertical offset. + */ + uint8_t classify_hitbox(const Entity &entity, const godot::Vector3 &hit_point) const; + + const std::vector *entities_ = nullptr; + + // Bounding sphere radius for entity hit detection + static constexpr float kEntityRadius = 0.4f; // ~arm span / 2 + static constexpr float kHeadRadius = 0.2f; + static constexpr float kEntityHeight = 1.8f; // standing height +}; + +} // namespace tactical_shooter + +#endif // TACTICAL_SHOOTER_HIT_DETECTION_H diff --git a/gdextension/simulation/src/movement_component.cpp b/gdextension/simulation/src/movement_component.cpp new file mode 100644 index 0000000..4e57ef2 --- /dev/null +++ b/gdextension/simulation/src/movement_component.cpp @@ -0,0 +1,155 @@ +#include "movement_component.h" + +#include +#include + +namespace tactical_shooter { + +MovementComponent::MovementComponent() {} + +MovementComponent::MovementComponent(const Parameters ¶ms) + : params_(params) {} + +void MovementComponent::set_parameters(const Parameters ¶ms) { + params_ = params; +} + +void MovementComponent::update(Entity &entity, float delta) { + if (!entity.is_alive()) return; + + const EntityInput &input = entity.last_input(); + + // Set flags based on input + uint16_t flags = entity.get_flags(); + if (input.crouch) flags |= ENTITY_FLAG_CROUCHING; + else flags &= ~ENTITY_FLAG_CROUCHING; + if (input.sprint) flags |= ENTITY_FLAG_SPRINTING; + else flags &= ~ENTITY_FLAG_SPRINTING; + entity.set_flags(flags); + + // Apply movement on ground vs in air + if (flags & ENTITY_FLAG_GROUNDED) { + apply_ground_movement(entity, delta); + } else { + apply_air_movement(entity, delta); + } + + // Handle jump + if (input.jump && (flags & ENTITY_FLAG_GROUNDED)) { + godot::Vector3 vel = entity.get_velocity(); + vel.y = params_.jump_velocity; + entity.set_velocity(vel); + entity.set_flags(flags & ~ENTITY_FLAG_GROUNDED); + } + + // Apply gravity + godot::Vector3 vel = entity.get_velocity(); + vel.y += params_.gravity * delta; + entity.set_velocity(vel); + + // Integrate + integrate_position(entity, delta); + + // Simple ground detection: if y velocity resolved, flag grounded + // (Full ground detection requires scene query — this is the stub) + vel = entity.get_velocity(); + if (entity.get_position().y <= 0.0f && vel.y <= 0.0f) { + godot::Vector3 pos = entity.get_position(); + pos.y = 0.0f; + entity.set_position(pos); + vel.y = 0.0f; + entity.set_velocity(vel); + uint16_t f = entity.get_flags(); + f |= ENTITY_FLAG_GROUNDED; + entity.set_flags(f); + } +} + +void MovementComponent::apply_ground_movement(Entity &entity, float delta) { + const EntityInput &input = entity.last_input(); + godot::Vector3 vel = entity.get_velocity(); + + // Determine target speed + float max_speed = params_.walk_speed; + if (entity.get_flags() & ENTITY_FLAG_SPRINTING) max_speed = params_.sprint_speed; + if (entity.get_flags() & ENTITY_FLAG_CROUCHING) max_speed = params_.crouch_speed; + + // Apply friction + float speed = vel.length(); + if (speed > 0.0f) { + float drop = speed * params_.friction * delta; + float new_speed = std::max(0.0f, speed - drop); + vel *= (new_speed / speed); + } + + // Acceleration from input + godot::Vector3 wish_dir = input.move_direction; + float wish_len = wish_dir.length(); + if (wish_len > 1.0f) wish_dir /= wish_len; + if (wish_len > 0.0f) { + vel += wish_dir * params_.acceleration * delta; + // Clamp to max speed in the horizontal plane + godot::Vector3 horiz(vel.x, 0.0f, vel.z); + float horiz_len = horiz.length(); + if (horiz_len > max_speed) { + horiz *= max_speed / horiz_len; + vel.x = horiz.x; + vel.z = horiz.z; + } + } + + // Clamp overall velocity + if (vel.length() > params_.max_velocity) { + vel = vel.normalized() * params_.max_velocity; + } + + entity.set_velocity(vel); +} + +void MovementComponent::apply_air_movement(Entity &entity, float delta) { + const EntityInput &input = entity.last_input(); + godot::Vector3 vel = entity.get_velocity(); + + // Apply air friction + float speed = vel.length(); + if (speed > 0.0f) { + float drop = speed * params_.air_friction * delta; + float new_speed = std::max(0.0f, speed - drop); + vel *= (new_speed / speed); + } + + // Reduced acceleration in air + godot::Vector3 wish_dir = input.move_direction; + float wish_len = wish_dir.length(); + if (wish_len > 1.0f) wish_dir /= wish_len; + if (wish_len > 0.0f) { + // Only accelerate in horizontal plane while airborne + godot::Vector3 horiz_accel( + wish_dir.x * params_.air_acceleration * delta, + 0.0f, + wish_dir.z * params_.air_acceleration * delta + ); + vel.x += horiz_accel.x; + vel.z += horiz_accel.z; + } + + // Clamp horizontal speed + godot::Vector3 horiz(vel.x, 0.0f, vel.z); + float max_horiz = std::max(params_.walk_speed, params_.sprint_speed); + if (horiz.length() > max_horiz) { + horiz *= max_horiz / horiz.length(); + vel.x = horiz.x; + vel.z = horiz.z; + } + + entity.set_velocity(vel); +} + +void MovementComponent::integrate_position(Entity &entity, float delta) { + godot::Vector3 pos = entity.get_position(); + godot::Vector3 vel = entity.get_velocity(); + pos += vel * delta; + entity.set_position(pos); +} + +} // namespace tactical_shooter diff --git a/gdextension/simulation/src/movement_component.h b/gdextension/simulation/src/movement_component.h new file mode 100644 index 0000000..ed01a3d --- /dev/null +++ b/gdextension/simulation/src/movement_component.h @@ -0,0 +1,71 @@ +#ifndef TACTICAL_SHOOTER_MOVEMENT_COMPONENT_H +#define TACTICAL_SHOOTER_MOVEMENT_COMPONENT_H + +#include "entity.h" + +namespace tactical_shooter { + +/** + * Physics-less movement simulation for networked FPS characters. + * + * Operates on Entity state directly — no Godot PhysicsServer3D involvement. + * Uses semi-implicit Euler integration with configurable movement parameters. + * + * All values assume "Godot units" (1 unit ≈ 1 meter) with gravity in + * units/s² and speeds in units/s. + */ +class MovementComponent { +public: + struct Parameters { + float walk_speed = 4.0f; // units/s + float sprint_speed = 6.5f; // units/s + float crouch_speed = 2.0f; // units/s + float acceleration = 20.0f; // units/s² + float air_acceleration = 4.0f; // reduced control in air + float friction = 8.0f; // ground deceleration + float air_friction = 1.0f; // air resistance + float jump_velocity = 5.0f; // initial upward velocity + float gravity = -20.0f; // units/s² (negative = downward) + float max_velocity = 10.0f; // speed cap (all directions) + float crouch_height = 0.75f; // multiplier on entity base height + float stand_height = 1.0f; + }; + + MovementComponent(); + explicit MovementComponent(const Parameters ¶ms); + + /** + * Main update. Called once per simulation tick (e.g. every 1/128s). + * + * @param entity Entity to update + * @param delta Timestep in seconds (e.g. 0.0078125 for 128Hz) + */ + void update(Entity &entity, float delta); + + /// Modify movement parameters at runtime + void set_parameters(const Parameters ¶ms); + const Parameters ¶meters() const { return params_; } + +private: + /** + * Apply ground movement with acceleration/friction. + * Follows Quake/CS-style movement model (non-Strafe). + */ + void apply_ground_movement(Entity &entity, float delta); + + /** + * Apply air movement with reduced acceleration. + */ + void apply_air_movement(Entity &entity, float delta); + + /** + * Integrate velocity into position (semi-implicit Euler). + */ + void integrate_position(Entity &entity, float delta); + + Parameters params_; +}; + +} // namespace tactical_shooter + +#endif // TACTICAL_SHOOTER_MOVEMENT_COMPONENT_H diff --git a/gdextension/simulation/src/register_types.cpp b/gdextension/simulation/src/register_types.cpp new file mode 100644 index 0000000..2328b93 --- /dev/null +++ b/gdextension/simulation/src/register_types.cpp @@ -0,0 +1,54 @@ +#include "register_types.h" +#include "simulation_server.h" + +#include +#include +#include +#include + +namespace tactical_shooter { + +void initialize_simulation_module(godot::ModuleInitializationLevel p_level) { + if (p_level != godot::MODULE_INITIALIZATION_LEVEL_SCENE) { + return; + } + + // Register all GDScript-facing classes + godot::ClassDB::register_class(); + godot::ClassDB::register_class(); +} + +void uninitialize_simulation_module(godot::ModuleInitializationLevel p_level) { + if (p_level != godot::MODULE_INITIALIZATION_LEVEL_SCENE) { + return; + } +} + +} // namespace tactical_shooter + +extern "C" { + +// GDExtension entry point — called by Godot to initialize the extension +GDExtensionBool GDE_EXPORT gdextension_entry( + GDExtensionInterfaceGetProcAddress p_get_proc_address, + GDExtensionClassLibraryPtr p_library, + GDExtensionInitialization *r_initialization +) { + godot::GDExtensionBinding::InitObject init_obj( + p_get_proc_address, p_library, r_initialization + ); + + init_obj.register_initializer( + tactical_shooter::initialize_simulation_module + ); + init_obj.register_terminator( + tactical_shooter::uninitialize_simulation_module + ); + init_obj.set_minimum_library_initialization_level( + godot::MODULE_INITIALIZATION_LEVEL_SCENE + ); + + return init_obj.init(); +} + +} // extern "C" diff --git a/gdextension/simulation/src/register_types.h b/gdextension/simulation/src/register_types.h new file mode 100644 index 0000000..4b7e672 --- /dev/null +++ b/gdextension/simulation/src/register_types.h @@ -0,0 +1,14 @@ +#ifndef TACTICAL_SHOOTER_REGISTER_TYPES_H +#define TACTICAL_SHOOTER_REGISTER_TYPES_H + +#include +#include + +namespace tactical_shooter { + +void initialize_simulation_module(godot::ModuleInitializationLevel p_level); +void uninitialize_simulation_module(godot::ModuleInitializationLevel p_level); + +} // namespace tactical_shooter + +#endif // TACTICAL_SHOOTER_REGISTER_TYPES_H diff --git a/gdextension/simulation/src/simulation_server.cpp b/gdextension/simulation/src/simulation_server.cpp new file mode 100644 index 0000000..0718a5b --- /dev/null +++ b/gdextension/simulation/src/simulation_server.cpp @@ -0,0 +1,471 @@ +#include "simulation_server.h" + +#include +#include +#include + +namespace tactical_shooter { + +static constexpr float kPi = 3.14159265358979323846f; + +using Clock = std::chrono::high_resolution_clock; + +SimulationServer::SimulationServer() + : entities_by_id_(kMaxEntities), + queued_inputs_(kMaxEntities) { +} + +SimulationServer::~SimulationServer() { + stop(); +} + +// ---- Lifecycle ---------------------------------------------------------------- + +void SimulationServer::set_tick_rate(uint32_t hz) { + tick_hz_ = std::clamp(hz, 1u, 1000u); + tick_interval_ = 1.0f / static_cast(tick_hz_); +} + +void SimulationServer::start() { + if (running_) return; + running_ = true; + current_tick_ = 0; + time_accumulator_ = 0.0f; + last_snapshots_.clear(); + hit_detection_.set_entities(living_entity_ptrs_); + reset_stats(); +} + +void SimulationServer::stop() { + running_ = false; + for (auto &entity : entities_by_id_) { + entity = godot::Ref(); + } + living_entity_ptrs_.clear(); + queued_inputs_.clear(); + queued_inputs_.resize(kMaxEntities); + pending_fires_.clear(); + last_snapshots_.clear(); + current_tick_ = 0; + time_accumulator_ = 0.0f; +} + +bool SimulationServer::can_tick(float delta) { + if (!running_) return false; + time_accumulator_ += delta; + return time_accumulator_ >= tick_interval_; +} + +godot::PackedByteArray SimulationServer::tick() { + if (!running_) return godot::PackedByteArray(); + + // Drain as many fixed timestep ticks as accumulated + while (time_accumulator_ >= tick_interval_) { + time_accumulator_ -= tick_interval_; + process_tick(); + } + + // Serialize and return the current state + return serialize_state(); +} + +// ---- Entity Management ------------------------------------------------------- + +uint16_t SimulationServer::spawn_entity(const godot::Vector3 &position) { + uint16_t id = allocate_entity_id(); + if (id >= kMaxEntities) return UINT16_MAX; + + auto entity = godot::Ref(memnew(Entity)); + entity->set_entity_id(id); + entity->reset(position); + entities_by_id_[id] = entity; + + // Rebuild living ptrs list + living_entity_ptrs_.clear(); + for (auto &e : entities_by_id_) { + if (e.is_valid() && e->is_alive()) { + living_entity_ptrs_.push_back(e.ptr()); + } + } + hit_detection_.set_entities(living_entity_ptrs_); + + return id; +} + +void SimulationServer::despawn_entity(uint16_t entity_id) { + if (entity_id < kMaxEntities && entities_by_id_[entity_id].is_valid()) { + entities_by_id_[entity_id] = godot::Ref(); + last_snapshots_.erase(entity_id); + + // Rebuild living ptrs + living_entity_ptrs_.clear(); + for (auto &e : entities_by_id_) { + if (e.is_valid() && e->is_alive()) { + living_entity_ptrs_.push_back(e.ptr()); + } + } + hit_detection_.set_entities(living_entity_ptrs_); + } +} + +godot::Ref SimulationServer::get_entity(uint16_t entity_id) { + if (entity_id < kMaxEntities && entities_by_id_[entity_id].is_valid()) { + return entities_by_id_[entity_id]; + } + return godot::Ref(); +} + +uint16_t SimulationServer::get_entity_count() const { + uint16_t count = 0; + for (auto &e : entities_by_id_) { + if (e.is_valid()) ++count; + } + return count; +} + +godot::Array SimulationServer::get_entity_ids() const { + godot::Array ids; + for (auto &e : entities_by_id_) { + if (e.is_valid()) { + ids.push_back(e->get_entity_id()); + } + } + return ids; +} + +// ---- Input -------------------------------------------------------------------- + +void SimulationServer::apply_input(uint16_t entity_id, const godot::Dictionary &input_dict) { + if (entity_id >= kMaxEntities) return; + if (!entities_by_id_[entity_id].is_valid()) return; + + EntityInput input; + + if (input_dict.has("move_direction")) { + input.move_direction = input_dict["move_direction"]; + } + if (input_dict.has("look_yaw")) { + input.look_yaw = static_cast(static_cast(input_dict["look_yaw"])); + } + if (input_dict.has("look_pitch")) { + input.look_pitch = static_cast(static_cast(input_dict["look_pitch"])); + } + if (input_dict.has("jump")) { + input.jump = input_dict["jump"]; + } + if (input_dict.has("crouch")) { + input.crouch = input_dict["crouch"]; + } + if (input_dict.has("sprint")) { + input.sprint = input_dict["sprint"]; + } + if (input_dict.has("shoot")) { + input.shoot = input_dict["shoot"]; + } + if (input_dict.has("aim")) { + input.aim = input_dict["aim"]; + } + if (input_dict.has("input_sequence")) { + input.input_sequence = static_cast(static_cast(input_dict["input_sequence"])); + } + + 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); + } +} + +// ---- Movement Configuration -------------------------------------------------- + +void SimulationServer::set_movement_walk_speed(float speed) { + auto p = movement_.parameters(); + p.walk_speed = speed; + movement_.set_parameters(p); +} + +void SimulationServer::set_movement_sprint_speed(float speed) { + auto p = movement_.parameters(); + p.sprint_speed = speed; + movement_.set_parameters(p); +} + +void SimulationServer::set_movement_crouch_speed(float speed) { + auto p = movement_.parameters(); + p.crouch_speed = speed; + movement_.set_parameters(p); +} + +void SimulationServer::set_movement_acceleration(float accel) { + auto p = movement_.parameters(); + p.acceleration = accel; + movement_.set_parameters(p); +} + +void SimulationServer::set_movement_jump_velocity(float vel) { + auto p = movement_.parameters(); + p.jump_velocity = vel; + movement_.set_parameters(p); +} + +void SimulationServer::set_movement_gravity(float gravity) { + auto p = movement_.parameters(); + p.gravity = gravity; + movement_.set_parameters(p); +} + +void SimulationServer::set_movement_config(const godot::Dictionary &config) { + auto p = movement_.parameters(); + if (config.has("walk_speed")) p.walk_speed = static_cast(static_cast(config["walk_speed"])); + if (config.has("sprint_speed")) p.sprint_speed = static_cast(static_cast(config["sprint_speed"])); + if (config.has("crouch_speed")) p.crouch_speed = static_cast(static_cast(config["crouch_speed"])); + if (config.has("acceleration")) p.acceleration = static_cast(static_cast(config["acceleration"])); + if (config.has("air_acceleration")) p.air_acceleration = static_cast(static_cast(config["air_acceleration"])); + if (config.has("jump_velocity")) p.jump_velocity = static_cast(static_cast(config["jump_velocity"])); + if (config.has("gravity")) p.gravity = static_cast(static_cast(config["gravity"])); + if (config.has("friction")) p.friction = static_cast(static_cast(config["friction"])); + if (config.has("max_velocity")) p.max_velocity = static_cast(static_cast(config["max_velocity"])); + movement_.set_parameters(p); +} + +// ---- Benchmark / Stats ------------------------------------------------------- + +godot::Dictionary SimulationServer::get_stats() const { + godot::Dictionary stats; + stats["last_tick_usec"] = static_cast(last_tick_usec_); + stats["tick_count"] = static_cast(tick_count_); + stats["entity_count"] = static_cast(get_entity_count()); + + float avg_usec = 0.0f; + if (tick_count_ > 0) { + avg_usec = static_cast(total_tick_usec_) / static_cast(tick_count_); + } + stats["avg_tick_usec"] = avg_usec; + stats["peak_tick_usec"] = static_cast(max_tick_usec_); + + // Also expose tick interval + stats["tick_hz"] = static_cast(tick_hz_); + stats["tick_interval_usec"] = static_cast(tick_interval_ * 1'000'000.0f); + + return stats; +} + +void SimulationServer::reset_stats() { + tick_count_ = 0; + last_tick_usec_ = 0; + total_tick_usec_ = 0; + max_tick_usec_ = 0; +} + +void SimulationServer::populate_bots(uint16_t count) { + count = std::min(count, static_cast(kMaxEntities)); + + // Clear existing + for (auto &e : entities_by_id_) { + e = godot::Ref(); + } + last_snapshots_.clear(); + + // Spawn bots in a grid pattern + uint16_t per_row = static_cast(std::ceil(std::sqrt(static_cast(count)))); + for (uint16_t i = 0; i < count; ++i) { + uint16_t row = i / per_row; + uint16_t col = i % per_row; + godot::Vector3 pos( + static_cast(col) * 3.0f, + 0.0f, + static_cast(row) * 3.0f + ); + spawn_entity(pos); + } +} + +// ---- Private: Tick Logic ----------------------------------------------------- + +void SimulationServer::process_tick() { + auto tick_start = Clock::now(); + + // 1. Apply queued inputs from clients + for (uint16_t i = 0; i < kMaxEntities; ++i) { + if (queued_inputs_[i].pending && entities_by_id_[i].is_valid()) { + entities_by_id_[i]->apply_input(queued_inputs_[i].input); + queued_inputs_[i].pending = false; + } + } + + // 2. Update entity positions + update_movement(); + + // 3. Process combat (weapon fires) + update_combat(); + + // 4. 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 + // We could despawn here if needed + } + } + + // 5. 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()) { + living_entity_ptrs_.push_back(e.ptr()); + } + } + hit_detection_.set_entities(living_entity_ptrs_); + + ++current_tick_; + ++tick_count_; + + // Timing + auto tick_end = Clock::now(); + last_tick_usec_ = std::chrono::duration_cast( + tick_end - tick_start + ).count(); + total_tick_usec_ += last_tick_usec_; + max_tick_usec_ = std::max(max_tick_usec_, last_tick_usec_); +} + +void SimulationServer::update_movement() { + float delta = tick_interval_; + + for (auto &entity_ref : living_entity_ptrs_) { + movement_.update(*entity_ref, delta); + } +} + +void SimulationServer::update_combat() { + static WeaponDamage default_weapon; + + for (uint16_t shooter_id : pending_fires_) { + auto &shooter = entities_by_id_[shooter_id]; + if (!shooter.is_valid() || !shooter->is_alive()) continue; + + // 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); + } + } + } + + pending_fires_.clear(); +} + +godot::PackedByteArray SimulationServer::serialize_state() { + const uint16_t count = static_cast(living_entity_ptrs_.size()); + if (count == 0) return godot::PackedByteArray(); + + // Pre-allocate a reasonable buffer + Bitstream stream; + stream.reserve(1024); // 1KB should cover a full snapshot of 32 entities + + // Use delta compression if we have a base snapshot + if (!last_snapshots_.empty()) { + serializer_.write_delta_snapshot( + stream, current_tick_, + living_entity_ptrs_.data(), count, + last_snapshots_ + ); + } else { + serializer_.write_full_snapshot( + stream, current_tick_, + living_entity_ptrs_.data(), count + ); + } + + // Update last snapshots for next delta + for (Entity *entity : living_entity_ptrs_) { + if (entity) { + last_snapshots_[entity->get_entity_id()] = entity->capture_snapshot(); + } + } + + // Convert to PackedByteArray + godot::PackedByteArray result; + size_t byte_size = stream.byte_size(); + result.resize(static_cast(byte_size)); + if (byte_size > 0) { + uint8_t *dst = result.ptrw(); + memcpy(dst, stream.data(), byte_size); + } + + return result; +} + +// ---- Private: Helpers -------------------------------------------------------- + +uint16_t SimulationServer::allocate_entity_id() { + for (uint16_t i = 0; i < kMaxEntities; ++i) { + if (!entities_by_id_[i].is_valid()) { + return i; + } + } + return UINT16_MAX; +} + +// ---- GDScript Bindings ------------------------------------------------------- + +void SimulationServer::_bind_methods() { + using namespace godot; + + // Lifecycle + ClassDB::bind_method(D_METHOD("set_tick_rate", "hz"), &SimulationServer::set_tick_rate); + ClassDB::bind_method(D_METHOD("get_tick_rate"), &SimulationServer::get_tick_rate); + ADD_PROPERTY(PropertyInfo(Variant::INT, "tick_rate"), "set_tick_rate", "get_tick_rate"); + + ClassDB::bind_method(D_METHOD("start"), &SimulationServer::start); + ClassDB::bind_method(D_METHOD("stop"), &SimulationServer::stop); + ClassDB::bind_method(D_METHOD("is_running"), &SimulationServer::is_running); + ClassDB::bind_method(D_METHOD("can_tick", "delta"), &SimulationServer::can_tick); + ClassDB::bind_method(D_METHOD("tick"), &SimulationServer::tick); + + // Entity management + ClassDB::bind_method(D_METHOD("spawn_entity", "position"), &SimulationServer::spawn_entity); + ClassDB::bind_method(D_METHOD("despawn_entity", "entity_id"), &SimulationServer::despawn_entity); + ClassDB::bind_method(D_METHOD("get_entity", "entity_id"), &SimulationServer::get_entity); + ClassDB::bind_method(D_METHOD("get_entity_count"), &SimulationServer::get_entity_count); + ClassDB::bind_method(D_METHOD("get_entity_ids"), &SimulationServer::get_entity_ids); + + // Input + ClassDB::bind_method(D_METHOD("apply_input", "entity_id", "input_dict"), &SimulationServer::apply_input); + ClassDB::bind_method(D_METHOD("fire_weapon", "entity_id"), &SimulationServer::fire_weapon); + + // Movement config + ClassDB::bind_method(D_METHOD("set_movement_walk_speed", "speed"), &SimulationServer::set_movement_walk_speed); + ClassDB::bind_method(D_METHOD("set_movement_sprint_speed", "speed"), &SimulationServer::set_movement_sprint_speed); + ClassDB::bind_method(D_METHOD("set_movement_crouch_speed", "speed"), &SimulationServer::set_movement_crouch_speed); + ClassDB::bind_method(D_METHOD("set_movement_acceleration", "accel"), &SimulationServer::set_movement_acceleration); + ClassDB::bind_method(D_METHOD("set_movement_jump_velocity", "vel"), &SimulationServer::set_movement_jump_velocity); + 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); + + // Benchmark + ClassDB::bind_method(D_METHOD("get_stats"), &SimulationServer::get_stats); + ClassDB::bind_method(D_METHOD("reset_stats"), &SimulationServer::reset_stats); + ClassDB::bind_method(D_METHOD("populate_bots", "count"), &SimulationServer::populate_bots); +} + +} // namespace tactical_shooter diff --git a/gdextension/simulation/src/simulation_server.h b/gdextension/simulation/src/simulation_server.h new file mode 100644 index 0000000..2699f8f --- /dev/null +++ b/gdextension/simulation/src/simulation_server.h @@ -0,0 +1,243 @@ +#ifndef TACTICAL_SHOOTER_SIMULATION_SERVER_H +#define TACTICAL_SHOOTER_SIMULATION_SERVER_H + +#include "entity.h" +#include "hit_detection.h" +#include "movement_component.h" +#include "state_serializer.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace tactical_shooter { + +/** + * SimulationServer — the heart of the tactical shooter simulation. + * + * This singleton manages the entire 128Hz game simulation loop from C++, + * entirely avoiding GDScript VM overhead in the hot path. + * + * GDScript integration: + * var server = SimulationServer.new() + * server.set_tick_rate(128) + * server.start() + * # In _process(): + * while server.can_tick(delta): + * var serialized = server.tick() + * # send serialized to network layer + * + * Architecture: + * - 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 + * - Benchmark hooks for the 128Hz load test (t_f671f48a) + */ +class SimulationServer : public godot::Object { + GDCLASS(SimulationServer, godot::Object) + +public: + SimulationServer(); + ~SimulationServer(); + + // ---- Lifecycle (GDScript API) ------------------------------------------ + + /** + * Set the simulation tick rate (calls per second). + * Default: 128 (for 128Hz tick). + */ + void set_tick_rate(uint32_t hz); + + uint32_t get_tick_rate() const { return tick_hz_; } + + /** + * Start the simulation. Creates initial entities if count > 0. + */ + void start(); + + /** + * Stop the simulation. Clears all entities and resets state. + */ + void stop(); + + /** + * Check if the simulation is running. + */ + bool is_running() const { return running_; } + + /** + * Accrue time and check if a tick is due. + * Call this in _process(delta) and call tick() while this returns true. + * + * Example (GDScript): + * while server.can_tick(delta): + * input = server.tick() + */ + bool can_tick(float delta); + + /** + * Advance one simulation tick. + * Returns serialized snapshot as a PackedByteArray (or empty if no tick due). + */ + godot::PackedByteArray tick(); + + // ---- Entity Management (GDScript API) ----------------------------------- + + /** + * Spawn a new entity at the given position. + * Returns the entity ID (0..65535) or UINT16_MAX on failure. + */ + uint16_t spawn_entity(const godot::Vector3 &position); + + /** + * Despawn an entity by ID. + */ + void despawn_entity(uint16_t entity_id); + + /** + * Get an entity by ID. Returns null if not found. + */ + godot::Ref get_entity(uint16_t entity_id); + + /** + * Get number of active entities. + */ + uint16_t get_entity_count() const; + + /** + * Get entity IDs of all active entities as an array. + */ + godot::Array get_entity_ids() const; + + // ---- Input (GDScript API) ----------------------------------------------- + + /** + * Apply input for a specific entity on the next tick. + * Call this from _process() as input arrives. + * + * @param entity_id Target entity + * @param input_dict Dictionary with keys: "move_direction" (Vector3), + * "look_yaw" (float), "look_pitch" (float), "jump" (bool), + * "crouch" (bool), "sprint" (bool), "shoot" (bool), "aim" (bool), + * "input_sequence" (int) + */ + void apply_input(uint16_t entity_id, const godot::Dictionary &input_dict); + + /** + * Queue a weapon fire from an entity. + * Fires on the next tick. + */ + void fire_weapon(uint16_t entity_id); + + // ---- Movement Configuration (GDScript API) ------------------------------- + + void set_movement_walk_speed(float speed); + void set_movement_sprint_speed(float speed); + void set_movement_crouch_speed(float speed); + void set_movement_acceleration(float accel); + void set_movement_jump_velocity(float vel); + void set_movement_gravity(float gravity); + + void set_movement_config(const godot::Dictionary &config); + + // ---- Benchmark / Stats (GDScript API) ------------------------------------ + + /** + * Get tick timing statistics for benchmarking. + * Returns Dictionary with: "last_tick_usec" (int), "avg_tick_usec" (float), + * "peak_tick_usec" (float), "tick_count" (int), "entity_count" (int) + */ + godot::Dictionary get_stats() const; + + /** + * Reset benchmark statistics. + */ + void reset_stats(); + + /** + * Populate simulation with N bots for load testing. + * Places them in a grid pattern. + */ + void populate_bots(uint16_t count); + +protected: + static void _bind_methods(); + +private: + // ---- Internal tick logic ------------------------------------------------ + + /** + * Advance the simulation by one tick (fixed timestep). + */ + void process_tick(); + + /** + * Update all entity movement. + */ + void update_movement(); + + /** + * Process all queued weapon fires. + */ + void update_combat(); + + /** + * Collect and serialize the current simulation state. + */ + godot::PackedByteArray serialize_state(); + + // ---- Helpers ------------------------------------------------------------ + + /** + * Find the lowest available entity ID. + */ + uint16_t allocate_entity_id(); + + // ---- State -------------------------------------------------------------- + + bool running_ = false; + uint32_t tick_hz_ = 128; + float tick_interval_ = 1.0f / 128.0f; + float time_accumulator_ = 0.0f; + + // Tick counter + uint32_t current_tick_ = 0; + + // Entity storage (by ID) + std::vector> entities_by_id_; + std::vector living_entity_ptrs_; // raw ptrs for hot loop + + // Systems + MovementComponent movement_; + HitDetection hit_detection_; + StateSerializer serializer_; + + // Queued input (applied on next tick, cleared after) + struct QueuedInput { + EntityInput input; + bool pending = false; + }; + std::vector queued_inputs_; + + // Queued weapon fires (entity IDs) + std::vector pending_fires_; + + // Last serialized snapshot per entity (for delta compression) + std::unordered_map last_snapshots_; + + // Benchmark stats + uint64_t tick_count_ = 0; + uint64_t last_tick_usec_ = 0; + uint64_t total_tick_usec_ = 0; + uint64_t max_tick_usec_ = 0; +}; + +} // namespace tactical_shooter + +#endif // TACTICAL_SHOOTER_SIMULATION_SERVER_H diff --git a/gdextension/simulation/src/state_serializer.cpp b/gdextension/simulation/src/state_serializer.cpp new file mode 100644 index 0000000..9427f9c --- /dev/null +++ b/gdextension/simulation/src/state_serializer.cpp @@ -0,0 +1,262 @@ +#include "state_serializer.h" + +#include + +namespace tactical_shooter { + +StateSerializer::StateSerializer(const SerializationConfig &config) + : config_(config) {} + +// ---- Write Full -------------------------------------------------------------- + +void StateSerializer::write_full_snapshot( + Bitstream &stream, + uint32_t tick, + const Entity *const *entities, + uint16_t count +) { + // Header: tick, entity count, base_tick=0 (full) + stream.write_uint32(tick); + stream.write_uint16(count); + stream.write_uint16(0); // base_tick=0 signals "full snapshot" + + for (uint16_t i = 0; i < count; ++i) { + if (entities[i] && entities[i]->is_alive()) { + write_entity_full(stream, *entities[i]); + } + } +} + +// ---- Write Delta ------------------------------------------------------------- + +void StateSerializer::write_delta_snapshot( + Bitstream &stream, + uint32_t tick, + const Entity *const *entities, + uint16_t count, + const std::unordered_map &base +) { + // Count changed entities first + uint16_t changed_count = 0; + for (uint16_t i = 0; i < count; ++i) { + if (!entities[i] || !entities[i]->is_alive()) continue; + uint16_t id = entities[i]->get_entity_id(); + auto it = base.find(id); + if (it == base.end()) { + ++changed_count; // new entity = full + } else { + EntitySnapshot::ChangeMask mask = + entities[i]->compute_change_mask(it->second); + if (mask != EntitySnapshot::CHANGED_NONE) { + ++changed_count; + } + } + } + + // Header: tick, count, delta_tick + stream.write_uint32(tick); + stream.write_uint16(changed_count); + // We need the tick of the base snapshot — for now write 0 (delta from + // client's last acked tick which the client tracks itself). + stream.write_uint16(0); + + // Write delta entities + for (uint16_t i = 0; i < count; ++i) { + if (!entities[i] || !entities[i]->is_alive()) continue; + uint16_t id = entities[i]->get_entity_id(); + auto it = base.find(id); + if (it == base.end()) { + // New entity: write full state with CHANGED_ALL + stream.write_uint16(id); + stream.write_uint32(EntitySnapshot::CHANGED_ALL); + write_entity_full(stream, *entities[i]); + } else { + EntitySnapshot::ChangeMask mask = + entities[i]->compute_change_mask(it->second); + if (mask != EntitySnapshot::CHANGED_NONE) { + stream.write_uint16(id); + stream.write_uint32(static_cast(mask)); + write_entity_delta(stream, *entities[i], it->second, mask); + } + } + } +} + +// ---- Read Full --------------------------------------------------------------- + +std::vector StateSerializer::read_full_snapshot( + Bitstream &stream, + uint32_t *out_tick +) { + std::vector result; + + *out_tick = stream.read_uint32(); + uint16_t count = stream.read_uint16(); + uint16_t base_tick = stream.read_uint16(); // should be 0 for full + + (void)base_tick; // unused in full read + + result.reserve(count); + for (uint16_t i = 0; i < count; ++i) { + uint16_t id = stream.read_uint16(); + EntitySnapshot::ChangeMask mask = read_change_mask(stream); + EntitySnapshot snap = read_entity(stream, mask); + snap.entity_id = id; + result.push_back(snap); + } + + return result; +} + +// ---- Read Delta -------------------------------------------------------------- + +void StateSerializer::read_delta_snapshot( + Bitstream &stream, + std::unordered_map &state +) { + uint32_t tick = stream.read_uint32(); + uint16_t count = stream.read_uint16(); + uint16_t base_tick = stream.read_uint16(); + + (void)tick; + (void)base_tick; + + for (uint16_t i = 0; i < count; ++i) { + uint16_t id = stream.read_uint16(); + EntitySnapshot::ChangeMask mask = read_change_mask(stream); + EntitySnapshot snap = read_entity(stream, mask); + snap.entity_id = id; + + if (mask == EntitySnapshot::CHANGED_ALL) { + state[id] = snap; // replace entirely + } else { + // Merge delta into existing state + auto it = state.find(id); + if (it != state.end()) { + if (mask & EntitySnapshot::CHANGED_POSITION) it->second.position = snap.position; + if (mask & EntitySnapshot::CHANGED_VELOCITY) it->second.velocity = snap.velocity; + if (mask & EntitySnapshot::CHANGED_ROTATION) { it->second.yaw = snap.yaw; it->second.pitch = snap.pitch; } + if (mask & EntitySnapshot::CHANGED_HEALTH) it->second.health = snap.health; + if (mask & EntitySnapshot::CHANGED_ARMOR) it->second.armor = snap.armor; + if (mask & EntitySnapshot::CHANGED_WEAPON) it->second.weapon_id = snap.weapon_id; + if (mask & EntitySnapshot::CHANGED_AMMO) it->second.ammo = snap.ammo; + if (mask & EntitySnapshot::CHANGED_FLAGS) it->second.flags = snap.flags; + if (mask & EntitySnapshot::CHANGED_INPUT) it->second.last_processed_input = snap.last_processed_input; + } else { + state[id] = snap; // missing base, fall back to full replace + } + } + } +} + +// ---- Private: write ---------------------------------------------------------- + +void StateSerializer::write_entity_full(Bitstream &stream, const Entity &entity) { + EntitySnapshot snap = entity.capture_snapshot(); + write_entity_delta(stream, entity, EntitySnapshot{}, EntitySnapshot::CHANGED_ALL); +} + +void StateSerializer::write_entity_delta( + Bitstream &stream, + const Entity &entity, + const EntitySnapshot &base, + EntitySnapshot::ChangeMask mask +) { + EntitySnapshot snap = entity.capture_snapshot(); + + // Position + if (mask & EntitySnapshot::CHANGED_POSITION) { + stream.write_float_quantized(snap.position.x, config_.pos_min, config_.pos_max, config_.pos_bits); + stream.write_float_quantized(snap.position.y, config_.pos_min, config_.pos_max, config_.pos_bits); + stream.write_float_quantized(snap.position.z, config_.pos_min, config_.pos_max, config_.pos_bits); + } + + // Velocity + if (mask & EntitySnapshot::CHANGED_VELOCITY) { + stream.write_float_quantized(snap.velocity.x, config_.vel_min, config_.vel_max, config_.vel_bits); + stream.write_float_quantized(snap.velocity.y, config_.vel_min, config_.vel_max, config_.vel_bits); + stream.write_float_quantized(snap.velocity.z, config_.vel_min, config_.vel_max, config_.vel_bits); + } + + // Rotation + if (mask & EntitySnapshot::CHANGED_ROTATION) { + stream.write_float_quantized(snap.yaw, config_.yaw_min, config_.yaw_max, config_.yaw_bits); + stream.write_float_quantized(snap.pitch, config_.pitch_min, config_.pitch_max, config_.pitch_bits); + } + + // Health/Armor + if (mask & EntitySnapshot::CHANGED_HEALTH) { + stream.write_float_quantized(snap.health, config_.health_min, config_.health_max, config_.health_bits); + } + if (mask & EntitySnapshot::CHANGED_ARMOR) { + stream.write_float_quantized(snap.armor, config_.health_min, config_.health_max, config_.health_bits); + } + + // Weapon/Ammo + if (mask & EntitySnapshot::CHANGED_WEAPON) { + stream.write_uint8(snap.weapon_id); + } + if (mask & EntitySnapshot::CHANGED_AMMO) { + stream.write_uint16(snap.ammo); + } + + // Flags + if (mask & EntitySnapshot::CHANGED_FLAGS) { + stream.write_uint16(snap.flags); + } + + // Input sequence + if (mask & EntitySnapshot::CHANGED_INPUT) { + stream.write_uint32(snap.last_processed_input); + } +} + +// ---- Private: read ----------------------------------------------------------- + +EntitySnapshot::ChangeMask StateSerializer::read_change_mask(Bitstream &stream) { + return static_cast(stream.read_uint32()); +} + +EntitySnapshot StateSerializer::read_entity( + Bitstream &stream, + EntitySnapshot::ChangeMask mask +) { + EntitySnapshot snap; + + if (mask & EntitySnapshot::CHANGED_POSITION) { + snap.position.x = stream.read_float_quantized(config_.pos_min, config_.pos_max, config_.pos_bits); + snap.position.y = stream.read_float_quantized(config_.pos_min, config_.pos_max, config_.pos_bits); + snap.position.z = stream.read_float_quantized(config_.pos_min, config_.pos_max, config_.pos_bits); + } + if (mask & EntitySnapshot::CHANGED_VELOCITY) { + snap.velocity.x = stream.read_float_quantized(config_.vel_min, config_.vel_max, config_.vel_bits); + snap.velocity.y = stream.read_float_quantized(config_.vel_min, config_.vel_max, config_.vel_bits); + snap.velocity.z = stream.read_float_quantized(config_.vel_min, config_.vel_max, config_.vel_bits); + } + if (mask & EntitySnapshot::CHANGED_ROTATION) { + snap.yaw = stream.read_float_quantized(config_.yaw_min, config_.yaw_max, config_.yaw_bits); + snap.pitch = stream.read_float_quantized(config_.pitch_min, config_.pitch_max, config_.pitch_bits); + } + if (mask & EntitySnapshot::CHANGED_HEALTH) { + snap.health = stream.read_float_quantized(config_.health_min, config_.health_max, config_.health_bits); + } + if (mask & EntitySnapshot::CHANGED_ARMOR) { + snap.armor = stream.read_float_quantized(config_.health_min, config_.health_max, config_.health_bits); + } + if (mask & EntitySnapshot::CHANGED_WEAPON) { + snap.weapon_id = stream.read_uint8(); + } + if (mask & EntitySnapshot::CHANGED_AMMO) { + snap.ammo = stream.read_uint16(); + } + if (mask & EntitySnapshot::CHANGED_FLAGS) { + snap.flags = stream.read_uint16(); + } + if (mask & EntitySnapshot::CHANGED_INPUT) { + snap.last_processed_input = stream.read_uint32(); + } + + return snap; +} + +} // namespace tactical_shooter diff --git a/gdextension/simulation/src/state_serializer.h b/gdextension/simulation/src/state_serializer.h new file mode 100644 index 0000000..5067ce8 --- /dev/null +++ b/gdextension/simulation/src/state_serializer.h @@ -0,0 +1,136 @@ +#ifndef TACTICAL_SHOOTER_STATE_SERIALIZER_H +#define TACTICAL_SHOOTER_STATE_SERIALIZER_H + +#include "bitstream.h" +#include "entity.h" + +#include +#include +#include + +namespace tactical_shooter { + +/** + * Serialization ranges for quantized float packing. + * These define the min/max and precision budget for each field type. + * Tune per-game to balance bandwidth vs accuracy. + */ +struct SerializationConfig { + // Position (quantized to 16 bits per axis = 65536 values) + float pos_min = -1024.0f; + float pos_max = 1024.0f; + uint8_t pos_bits = 16; + + // Velocity (quantized to 12 bits per axis) + float vel_min = -32.0f; + float vel_max = 32.0f; + uint8_t vel_bits = 12; + + // Rotation (yaw -180..180, pitch -90..90) + float yaw_min = -180.0f; + float yaw_max = 180.0f; + uint8_t yaw_bits = 12; // ~0.09° precision + + float pitch_min = -90.0f; + float pitch_max = 90.0f; + uint8_t pitch_bits = 11; // ~0.09° precision + + // Health/Armor (0..100, 7 bits = 0.79 precision) + float health_min = 0.0f; + float health_max = 100.0f; + uint8_t health_bits = 7; + + // Ammo (0..255, 8 bits exact) +}; + +/** + * Delta-compressed snapshot serializer for network replication. + * + * Writes a compact binary representation of entity states suitable for + * 128Hz server-to-client replication. Full snapshots include all entities; + * delta snapshots only include entities with changes since the base snapshot. + * + * Snapshot on-wire layout: + * [uint16 tick] [uint16 count] [uint16 base_tick] <- header (6 bytes) + * for each entity: + * [uint16 entity_id] [uint32 change_mask] + * [changed fields per mask] + * + * Typical size at 128Hz, 10 entities: + * Full snapshot: ~300-400 bytes + * Delta snapshot (5 entities changed): ~150-250 bytes + */ +class StateSerializer { +public: + explicit StateSerializer(const SerializationConfig &config = SerializationConfig{}); + + /** + * Serialize a full snapshot including all entities. + * + * @param stream Output bitstream + * @param tick Current simulation tick number + * @param entities All active entities + * @param count Number of entities + */ + void write_full_snapshot( + Bitstream &stream, + uint32_t tick, + const Entity *const *entities, + uint16_t count + ); + + /** + * Serialize a delta snapshot — only entities that changed from base. + * + * @param stream Output bitstream + * @param tick Current simulation tick number + * @param entities All active entities + * @param count Number of entities + * @param base Base snapshot for delta comparison + */ + void write_delta_snapshot( + Bitstream &stream, + uint32_t tick, + const Entity *const *entities, + uint16_t count, + const std::unordered_map &base + ); + + /** + * Deserialize a full snapshot from a bitstream. + */ + std::vector read_full_snapshot( + Bitstream &stream, + uint32_t *out_tick + ); + + /** + * Deserialize a delta snapshot and apply to a base. + */ + void read_delta_snapshot( + Bitstream &stream, + std::unordered_map &state + ); + + /** + * Get the configuration. + */ + const SerializationConfig &config() const { return config_; } + +private: + void write_entity_full(Bitstream &stream, const Entity &entity); + void write_entity_delta( + Bitstream &stream, + const Entity &entity, + const EntitySnapshot &base, + EntitySnapshot::ChangeMask mask + ); + EntitySnapshot read_entity(Bitstream &stream, EntitySnapshot::ChangeMask mask); + EntitySnapshot::ChangeMask read_change_mask(Bitstream &stream); + + SerializationConfig config_; +}; + +} // namespace tactical_shooter + +#endif // TACTICAL_SHOOTER_STATE_SERIALIZER_H diff --git a/scenes/client/client_main.tscn b/scenes/client/client_main.tscn new file mode 100644 index 0000000..8a24e5f --- /dev/null +++ b/scenes/client/client_main.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=3 format=3 uid="uid://client_main"] + +; Client entry point for testing. +; Connects to server, loads the grey-box test map, and spawns local player. + +[ext_resource type="Script" path="res://scripts/network/client_main.gd" id="1_client_main"] +[ext_resource type="PackedScene" uid="uid://test_range_map" path="res://scenes/map/test_range.tscn" id="2_test_map"] + +[node name="ClientMain" type="Node3D"] +script = ExtResource("1_client_main") +server_host = "127.0.0.1" +server_port = 34197 + +[node name="TestMap" parent="." instance=ExtResource("2_test_map")] diff --git a/scenes/map/test_range.tscn b/scenes/map/test_range.tscn new file mode 100644 index 0000000..a6c57d1 --- /dev/null +++ b/scenes/map/test_range.tscn @@ -0,0 +1,291 @@ +[gd_scene format=3 uid="uid://test_range_map"] + +; ============================================================================ +; Grey-box Test Map — "Test Range" +; ============================================================================ +; Phase 1 testing arena using CSG nodes with Kenney-style palette. +; 40m x 32m symmetrical layout with cover, pillars, and central structure. +; +; Spawn markers (Marker3D nodes at root level): +; SpawnA, SpawnA2 — Team A spawn (west side, -X) +; SpawnB, SpawnB2 — Team B spawn (east side, +X) +; ============================================================================ + +; --------------------------------------------------------------------------- +; SUB-RESOURCES +; --------------------------------------------------------------------------- + +[sub_resource type="Environment" id=1] +background_mode = 2 +ambient_light_source = 1 +ambient_light_color = Color(0.3, 0.3, 0.35, 1) +ambient_light_energy = 0.4 +tonemap_mode = 0 + +[sub_resource type="StandardMaterial3D" id=2] +; Floor — dark charcoal +albedo_color = Color(0.227, 0.227, 0.227, 1) +roughness = 0.9 +metallic = 0.0 + +[sub_resource type="StandardMaterial3D" id=3] +; Walls — medium grey +albedo_color = Color(0.4, 0.4, 0.4, 1) +roughness = 0.8 +metallic = 0.0 + +[sub_resource type="StandardMaterial3D" id=4] +; Cover — blue-grey +albedo_color = Color(0.29, 0.435, 0.647, 1) +roughness = 0.7 +metallic = 0.0 + +[sub_resource type="StandardMaterial3D" id=5] +; Center — warm orange accent +albedo_color = Color(0.788, 0.486, 0.239, 1) +roughness = 0.7 +metallic = 0.0 + +[sub_resource type="StandardMaterial3D" id=6] +; Pillars — light grey +albedo_color = Color(0.533, 0.533, 0.533, 1) +roughness = 0.6 +metallic = 0.0 + +[sub_resource type="StandardMaterial3D" id=7] +; Grid lines — darker stripe +albedo_color = Color(0.18, 0.18, 0.18, 1) +roughness = 0.9 +metallic = 0.0 + +[sub_resource type="StandardMaterial3D" id=8] +; Spawn zone — green glow +albedo_color = Color(0.2, 0.5, 0.2, 1) +roughness = 0.3 +metallic = 0.1 +emission_enabled = true +emission = Color(0.05, 0.15, 0.05, 1) + +; --------------------------------------------------------------------------- +; NODES +; --------------------------------------------------------------------------- + +[node name="WorldEnvironment" type="WorldEnvironment"] +environment = SubResource(1) + +[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."] +transform = Transform3D(0.866025, 0.5, 0, -0.25, 0.433013, 0.866025, 0.433013, -0.75, 0.5, 0, 0, 0) +light_energy = 1.2 +shadow_enabled = true +shadow_bias = 0.05 + +[node name="Map" type="CSGCombiner3D" parent="."] +use_collision = true +calculate_tangents = false + +; Floor +[node name="Floor" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.25, 0) +size = Vector3(40, 0.5, 32) +material = SubResource(2) + +; Floor grid lines — north-south +[node name="FloorGridNS" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -16, 0.01, 0) +size = Vector3(0.08, 0.04, 32) +material = SubResource(7) + +[node name="FloorGridNS2" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -8, 0.01, 0) +size = Vector3(0.08, 0.04, 32) +material = SubResource(7) + +[node name="FloorGridNS3" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.01, 0) +size = Vector3(0.08, 0.04, 32) +material = SubResource(7) + +[node name="FloorGridNS4" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 8, 0.01, 0) +size = Vector3(0.08, 0.04, 32) +material = SubResource(7) + +[node name="FloorGridNS5" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16, 0.01, 0) +size = Vector3(0.08, 0.04, 32) +material = SubResource(7) + +; Floor grid lines — east-west +[node name="FloorGridEW" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.01, -12) +size = Vector3(40, 0.04, 0.08) +material = SubResource(7) + +[node name="FloorGridEW2" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.01, -4) +size = Vector3(40, 0.04, 0.08) +material = SubResource(7) + +[node name="FloorGridEW3" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.01, 4) +size = Vector3(40, 0.04, 0.08) +material = SubResource(7) + +[node name="FloorGridEW4" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.01, 12) +size = Vector3(40, 0.04, 0.08) +material = SubResource(7) + +; Perimeter walls +[node name="WallNorth" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, -16) +size = Vector3(40.4, 4, 0.5) +material = SubResource(3) + +[node name="WallSouth" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 16) +size = Vector3(40.4, 4, 0.5) +material = SubResource(3) + +[node name="WallEast" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 20, 2, 0) +size = Vector3(0.5, 4, 32.4) +material = SubResource(3) + +[node name="WallWest" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -20, 2, 0) +size = Vector3(0.5, 4, 32.4) +material = SubResource(3) + +; Cover boxes — Team A side (west) +[node name="CoverBoxA1" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -12, 1, -8) +size = Vector3(3, 2, 3) +material = SubResource(4) + +[node name="CoverBoxA2" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -12, 1, 8) +size = Vector3(3, 2, 3) +material = SubResource(4) + +[node name="LowCoverA1" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -7, 0.6, -11) +size = Vector3(4, 1.2, 1) +material = SubResource(4) + +[node name="LowCoverA2" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -7, 0.6, 11) +size = Vector3(4, 1.2, 1) +material = SubResource(4) + +; Cover boxes — Team B side (east) +[node name="CoverBoxB1" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12, 1, -8) +size = Vector3(3, 2, 3) +material = SubResource(4) + +[node name="CoverBoxB2" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12, 1, 8) +size = Vector3(3, 2, 3) +material = SubResource(4) + +[node name="LowCoverB1" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 7, 0.6, -11) +size = Vector3(4, 1.2, 1) +material = SubResource(4) + +[node name="LowCoverB2" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 7, 0.6, 11) +size = Vector3(4, 1.2, 1) +material = SubResource(4) + +; Center structure — orange accent +[node name="CenterWall1" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.25, 0) +size = Vector3(6, 2.5, 1.5) +material = SubResource(5) + +[node name="CenterWall2" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.25, -6) +size = Vector3(1.5, 2.5, 6) +material = SubResource(5) + +[node name="CenterWall3" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.25, 6) +size = Vector3(1.5, 2.5, 6) +material = SubResource(5) + +[node name="CenterLow" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3, 0.6, 0) +size = Vector3(2, 1.2, 2) +material = SubResource(5) + +[node name="CenterLow2" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3, 0.6, 0) +size = Vector3(2, 1.2, 2) +material = SubResource(5) + +; Pillars +[node name="Pillar1" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -6, 2, -6) +size = Vector3(1.2, 4, 1.2) +material = SubResource(6) + +[node name="Pillar2" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -6, 2, 6) +size = Vector3(1.2, 4, 1.2) +material = SubResource(6) + +[node name="Pillar3" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 2, -6) +size = Vector3(1.2, 4, 1.2) +material = SubResource(6) + +[node name="Pillar4" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 2, 6) +size = Vector3(1.2, 4, 1.2) +material = SubResource(6) + +[node name="PillarMid1" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3, 2, -3) +size = Vector3(0.8, 4, 0.8) +material = SubResource(6) + +[node name="PillarMid2" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3, 2, 3) +size = Vector3(0.8, 4, 0.8) +material = SubResource(6) + +[node name="PillarMid3" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3, 2, -3) +size = Vector3(0.8, 4, 0.8) +material = SubResource(6) + +[node name="PillarMid4" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3, 2, 3) +size = Vector3(0.8, 4, 0.8) +material = SubResource(6) + +; Spawn markers — at root level so _collect_spawn_points finds them +[node name="SpawnA" type="Marker3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -16, 0, -4) + +[node name="SpawnA2" type="Marker3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -16, 0, 4) + +[node name="SpawnB" type="Marker3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16, 0, -4) + +[node name="SpawnB2" type="Marker3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16, 0, 4) + +; Spawn zone floor indicators +[node name="SpawnAZone" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -16, 0.02, 0) +size = Vector3(4, 0.04, 12) +material = SubResource(8) + +[node name="SpawnBZone" type="CSGBox3D" parent="Map"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16, 0.02, 0) +size = Vector3(4, 0.04, 12) +material = SubResource(8) diff --git a/scenes/player.tscn b/scenes/player.tscn new file mode 100644 index 0000000..d7d9e8d --- /dev/null +++ b/scenes/player.tscn @@ -0,0 +1,15 @@ +[gd_scene format=3 uid="uid://player"] + +[ext_resource type="Script" path="res://scripts/network/player.gd" id="1_player"] + +[sub_resource type="BoxMesh" id="1"] +size = Vector3(1, 1, 1) + +[node name="Player" type="Node3D"] +script = ExtResource("1_player") +movement_speed = 5.0 +sync_rate = 10.0 + +[node name="Mesh" type="MeshInstance3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0) +mesh = SubResource(1) diff --git a/scenes/server/server_main.tscn b/scenes/server/server_main.tscn new file mode 100644 index 0000000..cf0887c --- /dev/null +++ b/scenes/server/server_main.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=3 format=3 uid="uid://server_main"] + +; Headless dedicated server entry point. +; Instances the grey-box test map and manages connected players. + +[ext_resource type="Script" path="res://scripts/network/server_main.gd" id="1_server_main"] +[ext_resource type="PackedScene" uid="uid://test_range_map" path="res://scenes/map/test_range.tscn" id="2_test_map"] + +[node name="ServerMain" type="Node"] +script = ExtResource("1_server_main") +server_port = 34197 + +[node name="TestMap" type="Node" parent="."] +; Map is instanced on _ready() by server_main.gd when map_scene is provided. diff --git a/scripts/config/server_config.gd b/scripts/config/server_config.gd new file mode 100644 index 0000000..30e6e3a --- /dev/null +++ b/scripts/config/server_config.gd @@ -0,0 +1,480 @@ +## ServerConfig — CFG/JSON Dual-Format Config Manager +## +## Autoload singleton that loads, validates, and exposes server configuration. +## +## ## Architecture +## +## Load chain: +## 1. SERVER_CFG env var → absolute path to override .cfg +## 2. user://server_config.cfg (writable, operator-edited) +## 3. res://config/default_server_config.cfg (bundled, read-only) +## +## Exports: +## - Typed GDScript properties (this script) +## - JSON file written alongside the loaded .cfg for automation +## - ConfigChanged signal for hot-reload (future) +## +## ## Dual Format +## +## Humans edit `.cfg` (INI-style, Godot ConfigFile parser). +## Automation reads `.json` (written on every load/save). +## +## File: server_config.cfg ← human editor +## Mirror: server_config.json ← machine consumer (written atomically) +## +## ## Sections +## +## [server] — Network bind, port, name, password, tick_rate +## [game] — Round timers, gravity, friendly fire, respawn +## [movement] — Walk/sprint/crouch speeds, acceleration, friction +## [match] — Win limit, time limit, map rotation +## [rcon] — Remote console (future) +## [logging] — Log level and file path +## [teams] — Team count and size +## +## ## Extension Pattern +## +## ServerMain uses ServerConfig at startup. Each subsystem accesses +## the singleton directly — no pass-through boilerplate: +## +## ServerConfig.port # int +## ServerConfig.server_name # String +## ServerConfig.tick_rate # int +## ServerConfig.movement_walk_speed # float +## ServerConfig.map_list # Array[String] +## ServerConfig.to_json_string() # String +## +## ============================================================================= + +extends Node + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + +## Emitted when the config is (re)loaded. Subsystems that cache config +## values should reconnect to this signal to pick up live changes. +signal config_loaded() + + +# --------------------------------------------------------------------------- +# Section: [server] +# --------------------------------------------------------------------------- + +var server_name: String = "Tactical Shooter Server" +var description: String = "" +var bind_ip: String = "0.0.0.0" +var port: int = 34197 +var max_players: int = 16 +var password: String = "" +var tick_rate: int = 128 + + +# --------------------------------------------------------------------------- +# Section: [game] +# --------------------------------------------------------------------------- + +var round_time_seconds: int = 600 +var warmup_time_seconds: int = 60 +var friendly_fire: bool = false +var ff_damage_multiplier: float = 0.5 +var gravity: float = -24.0 +var respawn_time_seconds: float = 5.0 +var spectate_enabled: bool = true + + +# --------------------------------------------------------------------------- +# Section: [movement] +# --------------------------------------------------------------------------- + +var movement_walk_speed: float = 5.0 +var movement_sprint_speed: float = 7.0 +var movement_crouch_speed: float = 2.0 +var movement_jump_velocity: float = 6.0 +var movement_acceleration: float = 20.0 +var movement_air_acceleration: float = 4.0 +var movement_friction: float = 8.0 + + +# --------------------------------------------------------------------------- +# Section: [match] +# --------------------------------------------------------------------------- + +var win_limit: int = 3 +var time_limit_seconds: int = 0 +var map_rotation_mode: String = "sequence" # "sequence" | "vote" | "random" +var maps: String = "test_range" # comma-separated + +## Parsed map list (populated after load). +var map_list: Array[String] = [] + + +# --------------------------------------------------------------------------- +# Section: [rcon] +# --------------------------------------------------------------------------- + +var rcon_enabled: bool = false +var rcon_password: String = "" +var rcon_port: int = 34198 + + +# --------------------------------------------------------------------------- +# Section: [logging] +# --------------------------------------------------------------------------- + +var log_level: String = "info" +var log_file: String = "" + + +# --------------------------------------------------------------------------- +# Section: [teams] +# --------------------------------------------------------------------------- + +var team_count: int = 2 +var players_per_team: int = 8 + + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +## Absolute path to the loaded .cfg file (user override or bundled default). +var config_path: String = "" : get = get_config_path +## Absolute path to the mirrored .json file. +var json_path: String = "" : get = get_json_path + +var _loaded: bool = false +var _config_path_resolved: String = "" +var _json_path_resolved: String = "" + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + call_deferred(&"_load_config") + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +## Reload the config from disk. Emits config_loaded on success. +func reload() -> bool: + return _load_config() + + +## Return the entire config as a formatted JSON string. +func to_json_string() -> String: + var data := _build_json_dict() + return JSON.stringify(data, "\t", false) + + +## Return the entire config as a Godot Dictionary (same shape as JSON). +func to_dict() -> Dictionary: + return _build_json_dict() + + +## Return the movement config as a Dictionary (for SimulationServer.set_movement_config()). +func make_movement_dict() -> Dictionary: + return { + walk_speed = movement_walk_speed, + sprint_speed = movement_sprint_speed, + crouch_speed = movement_crouch_speed, + acceleration = movement_acceleration, + air_acceleration = movement_air_acceleration, + friction = movement_friction, + jump_velocity = movement_jump_velocity, + gravity = gravity, + } + + +## Write the current config state to the JSON mirror file. +## Does NOT touch the .cfg (only humans edit the .cfg). +func save_json() -> Error: + if _json_path_resolved.is_empty(): + return ERR_FILE_BAD_PATH + + var f := FileAccess.open(_json_path_resolved, FileAccess.WRITE) + if f == null: + push_error("[ServerConfig] Failed to write JSON mirror: %s" % _json_path_resolved) + return FileAccess.get_open_error() + + f.store_string(to_json_string()) + f.close() + return OK + + +## Write the current config state to the .cfg file (serialises all sections). +## Returns OK on success, ERR_* on failure. +func save_cfg() -> Error: + var cfg := ConfigFile.new() + _populate_cfg(cfg) + + var path := _config_path_resolved if not _config_path_resolved.is_empty() else "user://server_config.cfg" + var err := cfg.save(path) + if err != OK: + push_error("[ServerConfig] Failed to save config: %s" % error_string(err)) + return err + + # Re-init from file to ensure in-memory state matches + _load_from_path(cfg, _config_path_resolved) + return OK + + +# --------------------------------------------------------------------------- +# Internal: Loading +# --------------------------------------------------------------------------- + +func _load_config() -> bool: + # Determine config source path + var cfg_path := _resolve_source_path() + if cfg_path.is_empty(): + push_error("[ServerConfig] No config file could be resolved.") + return false + + var cfg := ConfigFile.new() + var err := cfg.load(cfg_path) + if err != OK: + push_error("[ServerConfig] Failed to load config: %s (error %d)" % [cfg_path, err]) + return false + + _load_from_path(cfg, cfg_path) + + # Write JSON mirror + save_json() + + _loaded = true + config_loaded.emit() + print("[ServerConfig] Loaded config: %s (%d keys)" % [cfg_path, cfg.get_sections().size() * 8]) + return true + + +## Path resolution order: +## 1. SERVER_CFG env var (absolute path) +## 2. user://server_config.cfg (writable operator override) +## 3. res://config/default_server_config.cfg (bundled default) +func _resolve_source_path() -> String: + # Env var override + if OS.has_environment("SERVER_CFG"): + var env_path := OS.get_environment("SERVER_CFG") + if FileAccess.file_exists(env_path): + _config_path_resolved = env_path + _json_path_resolved = env_path.get_basename() + ".json" + return env_path + else: + push_warning("[ServerConfig] SERVER_CFG path does not exist: %s — falling back" % env_path) + + # User override + var user_path := ProjectSettings.globalize_path("user://server_config.cfg") + if FileAccess.file_exists(user_path): + _config_path_resolved = user_path + _json_path_resolved = user_path.get_basename() + ".json" + return user_path + + # Bundled default — convert to absolute path for JSON mirror + var res_path := ProjectSettings.globalize_path("res://config/default_server_config.cfg") + _config_path_resolved = res_path + _json_path_resolved = res_path.get_basename() + ".json" + + # First run: copy bundled default to user:// so operators can edit it + var user_dir := ProjectSettings.globalize_path("user://") + var copy_path := user_dir.path_join("server_config.cfg") + if not FileAccess.file_exists(copy_path): + var src := FileAccess.open(res_path, FileAccess.READ) + if src: + var dst := FileAccess.open(copy_path, FileAccess.WRITE) + if dst: + dst.store_string(src.get_as_text()) + dst.close() + print("[ServerConfig] First run — copied default config to: %s" % copy_path) + _config_path_resolved = copy_path + _json_path_resolved = copy_path.get_basename() + ".json" + return copy_path + src.close() + + return res_path + + +func _load_from_path(cfg: ConfigFile, path: String) -> void: + _config_path_resolved = path + _json_path_resolved = path.get_basename() + ".json" + + # [server] + server_name = cfg.get_value("server", "server_name", server_name) + description = cfg.get_value("server", "description", description) + bind_ip = cfg.get_value("server", "bind_ip", bind_ip) + port = cfg.get_value("server", "port", port) + max_players = cfg.get_value("server", "max_players", max_players) + password = cfg.get_value("server", "password", password) + tick_rate = cfg.get_value("server", "tick_rate", tick_rate) + + # [game] + round_time_seconds = cfg.get_value("game", "round_time_seconds", round_time_seconds) + warmup_time_seconds = cfg.get_value("game", "warmup_time_seconds", warmup_time_seconds) + friendly_fire = cfg.get_value("game", "friendly_fire", friendly_fire) + ff_damage_multiplier = cfg.get_value("game", "ff_damage_multiplier", ff_damage_multiplier) + gravity = cfg.get_value("game", "gravity", gravity) + respawn_time_seconds = cfg.get_value("game", "respawn_time_seconds", respawn_time_seconds) + spectate_enabled = cfg.get_value("game", "spectate_enabled", spectate_enabled) + + # [movement] + movement_walk_speed = cfg.get_value("movement", "walk_speed", movement_walk_speed) + movement_sprint_speed = cfg.get_value("movement", "sprint_speed", movement_sprint_speed) + movement_crouch_speed = cfg.get_value("movement", "crouch_speed", movement_crouch_speed) + movement_jump_velocity = cfg.get_value("movement", "jump_velocity", movement_jump_velocity) + movement_acceleration = cfg.get_value("movement", "acceleration", movement_acceleration) + movement_air_acceleration = cfg.get_value("movement", "air_acceleration", movement_air_acceleration) + movement_friction = cfg.get_value("movement", "friction", movement_friction) + + # [match] + win_limit = cfg.get_value("match", "win_limit", win_limit) + time_limit_seconds = cfg.get_value("match", "time_limit_seconds", time_limit_seconds) + map_rotation_mode = cfg.get_value("match", "map_rotation_mode", map_rotation_mode) + maps = cfg.get_value("match", "maps", maps) + + # Parse map list + map_list.clear() + var raw := maps.strip_edges() + if not raw.is_empty(): + for m in raw.split(","): + var trimmed := m.strip_edges() + if not trimmed.is_empty(): + map_list.append(trimmed) + + # [rcon] + rcon_enabled = cfg.get_value("rcon", "enabled", rcon_enabled) + rcon_password = cfg.get_value("rcon", "password", rcon_password) + rcon_port = cfg.get_value("rcon", "port", rcon_port) + + # [logging] + log_level = cfg.get_value("logging", "log_level", log_level) + log_file = cfg.get_value("logging", "log_file", log_file) + + # [teams] + team_count = cfg.get_value("teams", "team_count", team_count) + players_per_team = cfg.get_value("teams", "players_per_team", players_per_team) + + # Validation + port = clampi(port, 1024, 65535) + max_players = clampi(max_players, 1, 64) + tick_rate = clampi(tick_rate, 30, 1000) + team_count = clampi(team_count, 1, 8) + players_per_team = clampi(players_per_team, 1, 32) + + +# --------------------------------------------------------------------------- +# Internal: Serialization +# --------------------------------------------------------------------------- + +func _build_json_dict() -> Dictionary: + return { + "server": { + "server_name": server_name, + "description": description, + "bind_ip": bind_ip, + "port": port, + "max_players": max_players, + "password": "", # intentionally omitted from JSON mirror + "tick_rate": tick_rate, + }, + "game": { + "round_time_seconds": round_time_seconds, + "warmup_time_seconds": warmup_time_seconds, + "friendly_fire": friendly_fire, + "ff_damage_multiplier": ff_damage_multiplier, + "gravity": gravity, + "respawn_time_seconds": respawn_time_seconds, + "spectate_enabled": spectate_enabled, + }, + "movement": { + "walk_speed": movement_walk_speed, + "sprint_speed": movement_sprint_speed, + "crouch_speed": movement_crouch_speed, + "jump_velocity": movement_jump_velocity, + "acceleration": movement_acceleration, + "air_acceleration": movement_air_acceleration, + "friction": movement_friction, + }, + "match": { + "win_limit": win_limit, + "time_limit_seconds": time_limit_seconds, + "map_rotation_mode": map_rotation_mode, + "maps": maps, + }, + "rcon": { + "enabled": rcon_enabled, + "password": "", # intentionally omitted from JSON mirror + "port": rcon_port, + }, + "logging": { + "log_level": log_level, + "log_file": log_file, + }, + "teams": { + "team_count": team_count, + "players_per_team": players_per_team, + }, + } + + +## Populate a ConfigFile object from the current property values. +## Used by save_cfg() to serialise all sections back to disk. +func _populate_cfg(cfg: ConfigFile) -> void: + # [server] + cfg.set_value("server", "server_name", server_name) + cfg.set_value("server", "description", description) + cfg.set_value("server", "bind_ip", bind_ip) + cfg.set_value("server", "port", port) + cfg.set_value("server", "max_players", max_players) + cfg.set_value("server", "password", password) + cfg.set_value("server", "tick_rate", tick_rate) + + # [game] + cfg.set_value("game", "round_time_seconds", round_time_seconds) + cfg.set_value("game", "warmup_time_seconds", warmup_time_seconds) + cfg.set_value("game", "friendly_fire", friendly_fire) + cfg.set_value("game", "ff_damage_multiplier", ff_damage_multiplier) + cfg.set_value("game", "gravity", gravity) + cfg.set_value("game", "respawn_time_seconds", respawn_time_seconds) + cfg.set_value("game", "spectate_enabled", spectate_enabled) + + # [movement] + cfg.set_value("movement", "walk_speed", movement_walk_speed) + cfg.set_value("movement", "sprint_speed", movement_sprint_speed) + cfg.set_value("movement", "crouch_speed", movement_crouch_speed) + cfg.set_value("movement", "jump_velocity", movement_jump_velocity) + cfg.set_value("movement", "acceleration", movement_acceleration) + cfg.set_value("movement", "air_acceleration", movement_air_acceleration) + cfg.set_value("movement", "friction", movement_friction) + + # [match] + cfg.set_value("match", "win_limit", win_limit) + cfg.set_value("match", "time_limit_seconds", time_limit_seconds) + cfg.set_value("match", "map_rotation_mode", map_rotation_mode) + cfg.set_value("match", "maps", maps) + + # [rcon] + cfg.set_value("rcon", "enabled", rcon_enabled) + cfg.set_value("rcon", "password", rcon_password) + cfg.set_value("rcon", "port", rcon_port) + + # [logging] + cfg.set_value("logging", "log_level", log_level) + cfg.set_value("logging", "log_file", log_file) + + # [teams] + cfg.set_value("teams", "team_count", team_count) + cfg.set_value("teams", "players_per_team", players_per_team) + + +# --------------------------------------------------------------------------- +# Getters +# --------------------------------------------------------------------------- + +func get_config_path() -> String: + return _config_path_resolved + +func get_json_path() -> String: + return _json_path_resolved diff --git a/scripts/map_packaging/README.md b/scripts/map_packaging/README.md new file mode 100644 index 0000000..52b5a0d --- /dev/null +++ b/scripts/map_packaging/README.md @@ -0,0 +1,363 @@ +# Map PCK Packaging Pipeline + +## Overview + +The PCK packaging pipeline converts map scenes (`.tscn`) into standalone +resource packs (`.pck`) that can be downloaded and loaded at runtime. +This is Godot's built-in DLC/addon pattern — no engine modifications +required. + +``` + Map Creator Registry Server Game Client + ────────────────── ────────────────── ────────────────── + │ │ │ + ├── Build map in template project │ │ + ├── Run pack_map.gd ──────────────►│ (upload .pck) │ + │ │ │ + │ ├── GET /maps ───────────────►│ + │ │◄── [map list JSON] ────────┤ + │ │ ├── Check user://maps/ + │ │ │ + │ │◄── GET /maps/.pck ───┤ (if not cached) + │ ├── .pck file ───────────────►│ + │ │ ├── Save to user://maps/ + │ │ ├── load_resource_pack() + │ │ ├── Load res://maps/.tscn + │ │ │ +``` + +## Components + +| File | Role | +|------|------| +| `pack_map.gd` | Godot editor tool — packages a `.tscn` → `.pck` | +| `map_downloader.gd` | Godot autoload — client-side download + cache + load | +| `map_registry_server.py` | Python HTTP server — serves `.pck` files + metadata | +| `config-ext-map-registry.cfg` | Server config extension for registry integration | +| `README.md` | This file | + +--- + +## 1. Packaging Maps (`pack_map.gd`) + +### Prerequisites +- Godot 4.2+ +- The map scene and all its dependencies must be local (`res://` paths) + +### In the Editor + +1. Open the map template project (`client/map_template/`) +2. Build your map in `scenes/maps/` (e.g., `scenes/maps/my_map.tscn`) +3. Open the map scene in the editor +4. Run **Project > Tools > Pack Current Map** + +Output goes to `user://packed_maps/my_map.pck`. + +### Headless / CLI + +```bash +godot --headless --script scripts/map_packaging/pack_map.gd \ + --map=res://scenes/maps/my_map.tscn +``` + +### Output + +- `user://packed_maps/.pck` — the resource pack +- `user://packed_maps/.json` — metadata sidecar + +### What's in the .pck? + +The pack contains only the map scene and its direct resource dependencies +(meshes, textures, materials). No game logic, no scripts, no config files. +This keeps packs small and safe — a map can't inject code. + +--- + +## 2. Map Registry Server (`map_registry_server.py`) + +A lightweight Python HTTP server that serves `.pck` files and map metadata. + +### Quick Start + +```bash +# Install — no dependencies, pure stdlib +python3 scripts/map_packaging/map_registry_server.py + +# With custom port and maps directory +python3 scripts/map_packaging/map_registry_server.py \ + --port 8090 \ + --maps-dir /data/maps + +# Via environment variables +export MAP_REGISTRY_PORT=8080 +export MAP_REGISTRY_MAPS=/data/maps +python3 scripts/map_packaging/map_registry_server.py +``` + +### API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/maps` | GET | JSON list of all available maps with metadata | +| `/maps/.pck` | GET | Download a map pack (binary) | +| `/maps/.json` | GET | Metadata for a single map | +| `/` | GET | Server info and endpoint documentation | + +### Deployment + +#### Systemd Service + +```ini +[Unit] +Description=Tactical Shooter Map Registry +After=network.target + +[Service] +Type=simple +User=tactical-shooter +WorkingDirectory=/opt/tactical-shooter +ExecStart=/usr/bin/python3 /opt/tactical-shooter/scripts/map_packaging/map_registry_server.py \ + --port 8090 \ + --maps-dir /var/lib/tactical-shooter/maps +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +#### NPMplus Reverse Proxy + +```nginx +# NPMplus custom location for map registry +location /maps/ { + proxy_pass http://127.0.0.1:8090; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_buffering off; +} +``` + +--- + +## 3. Client Map Downloader (`map_downloader.gd`) + +### Installation + +Add `map_downloader.gd` as an autoload singleton in `project.godot`: + +```ini +[autoload] +MapDownloader="*res://scripts/map_packaging/map_downloader.gd" +``` + +### Usage + +```gdscript +# Set the registry URL (default: http://127.0.0.1:8090) +MapDownloader.registry_url = "http://maps.example.com:8090" + +# Fetch the map list from the registry (auto-downloads missing maps) +MapDownloader.fetch_map_list() + +# Or download specific maps +MapDownloader.download_map("de_dust2") +MapDownloader.download_maps(["de_dust2", "de_inferno"]) + +# Check cache +if MapDownloader.is_map_cached("de_dust2"): + print("Map is ready") + MapDownloader.load_map("de_dust2") + # Now res://maps/de_dust2.tscn is available + +# Get cached maps +var cached = MapDownloader.get_cached_maps() + +# Remove a map +MapDownloader.remove_map("de_dust2") + +# Clear all caches +MapDownloader.clear_cache() +``` + +### Signals + +```gdscript +# Progress during download +MapDownloader.map_download_progress.connect( + func(map_name, received, total): + var pct = float(received) / total * 100 + print("%s: %.1f%%" % [map_name, pct]) +) + +# Download completed +MapDownloader.map_download_complete.connect( + func(map_name, success): + if success: + print("%s ready to play!" % map_name) +) + +# Map loaded into resource system +MapDownloader.map_loaded.connect( + func(map_name): + print("%s is now available at res://maps/%s.tscn" % [map_name, map_name]) +) +``` + +### Environment Overrides + +| Variable | Description | +|----------|-------------| +| `MAP_REGISTRY_URL` | Override the registry server URL | + +--- + +## 4. Integration with Server Main + +To wire map downloads into the existing server flow (`server_main.gd`): + +```gdscript +# In server_main.gd or a dedicated map manager: + +func _on_server_started() -> void: + # Start map registry if configured + if ServerConfig.has_property("map_registry_enabled") and ServerConfig.map_registry_enabled: + var registry_url: String = ServerConfig.get("map_registry_url", "") + if not registry_url.is_empty(): + MapDownloader.registry_url = registry_url + + # Fetch map list — MapDownloader auto-downloads missing maps + MapDownloader.fetch_map_list() + +func _on_map_needed(map_name: String) -> void: + if not MapDownloader.is_map_cached(map_name): + # Wait for download + var waiter := func(name, success): + if success: + _start_map(name) + MapDownloader.map_download_complete.connect(waiter.bind(map_name), CONNECT_ONE_SHOT) + MapDownloader.download_map(map_name) + else: + _start_map(map_name) + +func _start_map(map_name: String) -> void: + if MapDownloader.load_map(map_name): + # Now load the scene + var map_scene: PackedScene = load("res://scenes/maps/%s.tscn" % map_name) + var instance := map_scene.instantiate() + add_child(instance) +``` + +### Server Config Extension + +Add this section to your `server_config.cfg` to enable map registry: + +```ini +[map_registry] +; Enable automatic map download on server start +enabled=false +; URL of the map registry server +url="http://127.0.0.1:8090" +; Auto-download all maps from the registry, not just advertised ones +auto_download_all=false +``` + +--- + +## 5. Cache and Storage + +### Client Cache + +- Location: `user://maps/` +- Files: `.pck` + `manifest.json` +- Manifest is validated on startup — stale entries (missing .pck files) are pruned +- The cache is persistent across restarts + +### Cache Size Management + +Maps can be large (10–100 MB depending on texture resolution). +Consider: + +```gdscript +# Check cache size +var total_size := 0 +for name in MapDownloader.get_cached_maps(): + var info = MapDownloader.get_cached_map_info(name) + total_size += info.get("size", 0) +print("Cache size: %.1f MB" % (total_size / 1048576.0)) + +# Remove least-recently-downloaded maps if over budget +const MAX_CACHE_MB := 500 +if total_size > MAX_CACHE_MB * 1048576: + var cached = MapDownloader.get_cached_maps() + for name in cached: + MapDownloader.remove_map(name) + break # remove one at a time +``` + +--- + +## 6. Security Considerations + +- **.pck files cannot execute scripts** that weren't already in the game binary. + Resource packs only add data (scenes, textures, meshes). Game logic lives + in the binary and cannot be injected via a .pck. + +- **Checksum verification**: The server provides SHA-256 checksums. The + client can verify integrity before loading: + +```gdscript +func verify_map(map_name: String) -> bool: + var pck_path = "user://maps/%s.pck" % map_name + var global_path = ProjectSettings.globalize_path(pck_path) + var f = FileAccess.open(global_path, FileAccess.READ) + if not f: + return false + + var ctx = HashingContext.new() + ctx.start(HashingContext.HASH_SHA256) + while f.get_position() < f.get_length(): + ctx.update(f.get_buffer(65536)) + var checksum = ctx.finish().hex_encode() + f.close() + + # Compare with server checksum + var meta = MapDownloader.get_cached_map_info(map_name) + var expected = meta.get("checksum_sha256", "") + return expected.is_empty() or checksum == expected +``` + +- **HTTPS**: Deploy the registry server behind an NPMplus/nginx proxy with + SSL termination. The client supports HTTPS natively via HTTPRequest. + +--- + +## 7. Workflow Summary + +### For Map Creators + +``` +1. Open map_template/ in Godot 4 +2. Build map using CSG prefabs (scenes/maps/.tscn) +3. Run: godot --headless --script scripts/map_packaging/pack_map.gd --map=res://scenes/maps/.tscn +4. Upload output/user://packed_maps/.pck to registry server's maps directory +``` + +### For Server Operators + +``` +1. Deploy map_registry_server.py on your backend (or alongside the game server) +2. Copy .pck files into the maps directory +3. SIGHUP the server to rescan (or it auto-scans every 5 seconds) +4. Configure [map_registry] in server_config.cfg +5. Game clients auto-download on connect +``` + +### For Developers + +``` +1. Add MapDownloader as project autoload +2. On server start, call MapDownloader.fetch_map_list() +3. Before loading a map scene, call MapDownloader.load_map() if it's a community map +4. Handle download signals for progress UI +``` diff --git a/scripts/map_packaging/map_downloader.gd b/scripts/map_packaging/map_downloader.gd new file mode 100644 index 0000000..0f0f46a --- /dev/null +++ b/scripts/map_packaging/map_downloader.gd @@ -0,0 +1,399 @@ +## MapDownloader — Client-side map download and cache management +## +## Autoload singleton that downloads .pck map packs from the master server +## registry and loads them into the running game via ProjectSettings.load_resource_pack(). +## +## ## Architecture +## +## MapRegistry Server (Python) MapDownloader (Godot) +## ┌──────────────────────┐ ┌──────────────────┐ +## │ GET /maps │ ◄── list ──│ get_map_list() │ +## │ GET /maps/:name.pck │ ◄── dl ──│ download_map() │ +## │ GET /maps/:name.json│ ◄── meta ─│ get_map_info() │ +## └──────────────────────┘ │ │ +## │ Cache: │ +## Disk cache │ user://maps/ │ +## user://maps/.pck ─────│ .pck files │ +## user://maps/manifest.json │ manifest.json │ +## └──────────────────┘ +## +## ## Map Lifecycle +## +## 1. Server sends map list (e.g. ["de_dust2", "de_inferno"]) +## 2. MapDownloader checks local cache via manifest.json +## 3. Missing maps are downloaded from the registry server +## 4. Downloaded .pck is loaded via load_resource_pack() +## 5. Map scene becomes available at res://maps/.tscn +## +## ## Configuration +## +## Set the registry URL before first use: +## MapDownloader.registry_url = "https://maps.example.com" +## # or via env: MAP_REGISTRY_URL +## +## ## Signals +## +## map_download_progress(map_name, bytes_received, bytes_total) +## map_download_complete(map_name, success) +## map_loaded(map_name) + +extends Node + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + +## Emitted during download for progress bar updates. +signal map_download_progress(map_name: String, bytes_received: int, bytes_total: int) + +## Emitted when a map download finishes. success=true means the .pck is on disk. +signal map_download_complete(map_name: String, success: bool) + +## Emitted after a .pck is loaded into the resource system. +signal map_loaded(map_name: String) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +const MAP_CACHE_DIR: String = "user://maps/" +const MANIFEST_FILE: String = "user://maps/manifest.json" +const DEFAULT_REGISTRY_URL: String = "http://127.0.0.1:8090" + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +## URL of the map registry server (without trailing slash). +## Can be overridden at runtime. +var registry_url: String = DEFAULT_REGISTRY_URL + +## Timeout for HTTP requests in seconds. +var http_timeout: float = 30.0 + +## Max concurrent downloads. +var max_concurrent: int = 2 + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +var _http: HTTPRequest = null +var _active_downloads: Dictionary = {} # map_name → Dictionary (progress tracking) +var _download_queue: Array[Dictionary] = [] +var _manifest: Dictionary = {} # {map_name: {version, size, downloaded_at}} +var _loaded_pcks: Array[String] = [] # tracks which .pcks are already loaded + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + # Create cache directory + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(MAP_CACHE_DIR)) + + # Load local manifest + _load_manifest() + + # Override registry URL from environment + if OS.has_environment("MAP_REGISTRY_URL"): + registry_url = OS.get_environment("MAP_REGISTRY_URL") + + print("[MapDownloader] Registry URL: %s" % registry_url) + print("[MapDownloader] Cache dir: %s" % MAP_CACHE_DIR) + print("[MapDownloader] Cached maps: %d" % _manifest.size()) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +## Fetch the list of available maps from the registry server. +## Returns a signal-based result via map_download_progress / etc. +## Call this first to discover what maps exist. +func fetch_map_list() -> void: + var url: String = "%s/maps" % [registry_url] + _http_get(url, _on_map_list_received) + + +## Check if a map is in the local cache. +func is_map_cached(map_name: String) -> bool: + if not _manifest.has(map_name): + return false + + var pck_path: String = "%s%s.pck" % [MAP_CACHE_DIR, map_name] + var global_path: String = ProjectSettings.globalize_path(pck_path) + return FileAccess.file_exists(global_path) + + +## Download a single map .pck from the registry server. +## The map is saved to user://maps/.pck and loaded automatically. +func download_map(map_name: String) -> void: + # Skip if already cached + if is_map_cached(map_name): + print("[MapDownloader] %s already cached — loading" % map_name) + _load_map_pck(map_name) + return + + # Check if already downloading + if map_name in _active_downloads: + print("[MapDownloader] %s is already downloading" % map_name) + return + + # Queue or start download + var entry := { + map_name = map_name, + url = "%s/maps/%s.pck" % [registry_url, map_name], + } + if _active_downloads.size() < max_concurrent: + _start_download(entry) + else: + _download_queue.append(entry) + print("[MapDownloader] %s queued (%d waiting)" % [map_name, _download_queue.size()]) + + +## Download multiple maps. Pass an array of map names. +func download_maps(map_names: Array[String]) -> void: + for name in map_names: + download_map(name) + + +## Load a cached .pck into the resource system. +## Returns true if the pack was loaded successfully. +func load_map(map_name: String) -> bool: + return _load_map_pck(map_name) + + +## Remove a cached map from disk and manifest. +func remove_map(map_name: String) -> void: + var pck_path: String = "%s%s.pck" % [MAP_CACHE_DIR, map_name] + var global_path: String = ProjectSettings.globalize_path(pck_path) + if FileAccess.file_exists(global_path): + DirAccess.remove_absolute(global_path) + _manifest.erase(map_name) + _save_manifest() + _loaded_pcks.erase(map_name) + print("[MapDownloader] Removed cached map: %s" % map_name) + + +## Get list of locally cached map names. +func get_cached_maps() -> Array[String]: + return _manifest.keys() + + +## Get info about a cached map from the manifest. +func get_cached_map_info(map_name: String) -> Dictionary: + return _manifest.get(map_name, {}) + + +## Clear all cached maps. +func clear_cache() -> void: + for name in _manifest.keys(): + remove_map(name) + print("[MapDownloader] Cache cleared") + + +# --------------------------------------------------------------------------- +# Internal: HTTP helpers +# --------------------------------------------------------------------------- + +func _http_get(url: String, callback: Callable) -> void: + var http := HTTPRequest.new() + add_child(http) + http.connect("request_completed", callback) + http.timeout = http_timeout + http.request(url) + + +func _http_download(url: String, save_path: String, callback: Callable) -> void: + var http := HTTPRequest.new() + add_child(http) + http.connect("request_completed", callback) + http.download_file = save_path + http.timeout = http_timeout + + # Connect download progress + if http.has_signal("download_progress"): + # Godot 4's HTTPRequest has request_completed but not always download_progress + # We track via the file size after completion + pass + + http.request(url) + + +# --------------------------------------------------------------------------- +# Internal: Download handling +# --------------------------------------------------------------------------- + +func _start_download(entry: Dictionary) -> void: + var map_name: String = entry.map_name + var url: String = entry.url + var save_path: String = ProjectSettings.globalize_path("%s%s.pck" % [MAP_CACHE_DIR, map_name]) + + _active_downloads[map_name] = entry + print("[MapDownloader] Downloading: %s → %s" % [url, save_path]) + + _http_download(url, save_path, _on_map_downloaded.bind(map_name)) + + +func _process_download_queue() -> void: + if _download_queue.is_empty(): + return + var next: Dictionary = _download_queue.pop_front() + _start_download(next) + + +# --------------------------------------------------------------------------- +# Internal: Callbacks +# --------------------------------------------------------------------------- + +func _on_map_list_received(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray) -> void: + if response_code != 200: + push_error("[MapDownloader] Failed to fetch map list: HTTP %d" % response_code) + return + + var json := JSON.new() + var parse_err: Error = json.parse(body.get_string_from_utf8()) + if parse_err != OK: + push_error("[MapDownloader] Failed to parse map list JSON: %s" % error_string(parse_err)) + return + + var data: Dictionary = json.data + var maps: Array = data.get("maps", data.get("available", [])) + if maps.is_empty(): + print("[MapDownloader] No maps available on registry") + return + + print("[MapDownloader] Registry offers %d maps: %s" % [maps.size(), maps]) + + # Auto-download any maps we don't have cached + var to_download: Array[String] = [] + for m in maps: + var name: String = str(m) if typeof(m) == TYPE_STRING else "" + if name.is_empty(): + # Support both string lists and object lists + if typeof(m) == TYPE_DICTIONARY: + name = m.get("name", "") + if not name.is_empty() and not is_map_cached(name): + to_download.append(name) + + if not to_download.is_empty(): + print("[MapDownloader] Downloading %d new maps: %s" % [to_download.size(), to_download]) + download_maps(to_download) + else: + print("[MapDownloader] All registry maps are already cached") + + +func _on_map_downloaded(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray, map_name: String) -> void: + _active_downloads.erase(map_name) + + if response_code != 200: + push_error("[MapDownloader] Download failed for %s: HTTP %d" % [map_name, response_code]) + map_download_complete.emit(map_name, false) + _process_download_queue() + return + + print("[MapDownloader] Downloaded %s successfully" % map_name) + map_download_complete.emit(map_name, true) + + # Update manifest + var pck_path: String = "%s%s.pck" % [MAP_CACHE_DIR, map_name] + var global_path: String = ProjectSettings.globalize_path(pck_path) + var file_size: int = 0 + if FileAccess.file_exists(global_path): + file_size = FileAccess.get_size(global_path) + + _manifest[map_name] = { + version = _manifest.get(map_name, {}).get("version", 1), + size = file_size, + downloaded_at = Time.get_datetime_string_from_system(), + } + _save_manifest() + + # Load the map into the resource system + _load_map_pck(map_name) + + _process_download_queue() + + +# --------------------------------------------------------------------------- +# Internal: PCK loading +# --------------------------------------------------------------------------- + +## Load a .pck into Godot's resource system. +## After this call, res://maps/.tscn becomes available. +func _load_map_pck(map_name: String) -> bool: + # Skip if already loaded + if map_name in _loaded_pcks: + return true + + var pck_path: String = "%s%s.pck" % [MAP_CACHE_DIR, map_name] + var global_path: String = ProjectSettings.globalize_path(pck_path) + + if not FileAccess.file_exists(global_path): + push_error("[MapDownloader] Cannot load %s: .pck not found at %s" % [map_name, global_path]) + return false + + var err: Error = ProjectSettings.load_resource_pack(global_path) + if err != OK: + push_error("[MapDownloader] Failed to load map pack %s: %s" % [map_name, error_string(err)]) + return false + + _loaded_pcks.append(map_name) + print("[MapDownloader] Loaded map pack: %s (%s)" % [map_name, pck_path]) + map_loaded.emit(map_name) + return true + + +# --------------------------------------------------------------------------- +# Internal: Manifest persistence +# --------------------------------------------------------------------------- + +func _load_manifest() -> void: + var global_path: String = ProjectSettings.globalize_path(MANIFEST_FILE) + if not FileAccess.file_exists(global_path): + _manifest = {} + return + + var f := FileAccess.open(global_path, FileAccess.READ) + if f == null: + _manifest = {} + return + + var json := JSON.new() + var parse_err: Error = json.parse(f.get_as_text()) + f.close() + + if parse_err != OK: + push_warning("[MapDownloader] Failed to parse manifest — starting fresh") + _manifest = {} + else: + _manifest = json.data + _validate_manifest() + + +func _save_manifest() -> void: + var global_path: String = ProjectSettings.globalize_path(MANIFEST_FILE) + var f := FileAccess.open(global_path, FileAccess.WRITE) + if f == null: + push_error("[MapDownloader] Cannot save manifest to %s" % global_path) + return + + f.store_string(JSON.stringify(_manifest, "\t", false)) + f.close() + + +## Validate the manifest: remove entries whose .pck files no longer exist on disk. +func _validate_manifest() -> void: + var stale: Array[String] = [] + for name in _manifest.keys(): + var pck_path: String = ProjectSettings.globalize_path("%s%s.pck" % [MAP_CACHE_DIR, name]) + if not FileAccess.file_exists(pck_path): + stale.append(name) + + for name in stale: + print("[MapDownloader] Manifest cleanup: %s (file missing)" % name) + _manifest.erase(name) + + if not stale.is_empty(): + _save_manifest() diff --git a/scripts/map_packaging/map_registry_server.py b/scripts/map_packaging/map_registry_server.py new file mode 100644 index 0000000..c611281 --- /dev/null +++ b/scripts/map_packaging/map_registry_server.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +""" +map_registry_server.py — Map Registry HTTP Server + +Serves packaged .pck map files and a JSON map listing for the Godot +client's MapDownloader to consume. + +Endpoints +--------- + +GET /maps + Returns a JSON list of available maps with metadata. + + Response: + { + "maps": [ + { + "name": "de_dust2", + "size": 4194304, + "version": 1, + "description": "Classic bomb-defusal map", + "scene": "res://scenes/maps/de_dust2.tscn", + "checksum_sha256": "a1b2c3..." + } + ], + "server_name": "Tactical Shooter Map Registry", + "map_count": 1 + } + +GET /maps/.pck + Download a packaged map file. Content-Type: application/octet-stream. + +GET /maps/.json + Download metadata for a specific map. + + Response: + { + "name": "de_dust2", + "size": 4194304, + "version": 1, + "description": "Classic bomb-defusal map", + "scene": "res://scenes/maps/de_dust2.tscn", + "checksum_sha256": "a1b2c3...", + "packed_at": "2026-06-30 12:00:00" + } + +Usage +----- + + # Start the server on the default port (8090): + python3 map_registry_server.py + + # Custom port and map directory: + python3 map_registry_server.py --port 8080 --maps-dir /data/maps + + # Docker / container friendly: + MAP_REGISTRY_PORT=8080 MAP_REGISTRY_MAPS=/data/maps python3 map_registry_server.py + +The server scans `maps_dir` (default: ./packed_maps/) for *.pck files and +their matching *.json metadata files on startup and on SIGHUP. + +Integration +----------- + + In your server's ServerConfig or config, add a registry section: + + [map_registry] + enabled=true + url="http://maps.example.com:8090" + + Or set env: MAP_REGISTRY_URL on the game server. + + The game client's MapDownloader singleton will connect to this server + to fetch the map list and download .pck files. +""" + +import argparse +import hashlib +import json +import logging +import os +import signal +import sys +import time +from http.server import HTTPServer, BaseHTTPRequestHandler +from pathlib import Path +from typing import Dict, Optional + +logger = logging.getLogger("MapRegistry") + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +DEFAULT_PORT = 8090 +DEFAULT_MAPS_DIR = "packed_maps" +SERVER_NAME = "Tactical Shooter Map Registry" +VERSION = "1.0.0" + + +# --------------------------------------------------------------------------- +# Map Registry +# --------------------------------------------------------------------------- + +class MapRegistry: + """Scans a directory for .pck files and their metadata.""" + + def __init__(self, maps_dir: str): + self.maps_dir = Path(maps_dir) + self.maps_dir.mkdir(parents=True, exist_ok=True) + self._cache: Dict[str, dict] = {} + self._last_scan: float = 0 + self._scan_interval: float = 5.0 # seconds between rescans + self._scan() + + def _scan(self) -> None: + """Scan the maps directory for .pck and .json files.""" + now = time.time() + if now - self._last_scan < self._scan_interval: + return + self._last_scan = now + + self._cache.clear() + if not self.maps_dir.exists(): + logger.warning("Maps directory does not exist: %s", self.maps_dir) + return + + for pck_file in sorted(self.maps_dir.glob("*.pck")): + map_name = pck_file.stem + json_file = pck_file.with_suffix(".json") + + entry = { + "name": map_name, + "size": pck_file.stat().st_size, + "path": str(pck_file.relative_to(self.maps_dir)), + "version": 1, + "description": "", + "scene": f"res://scenes/maps/{map_name}.tscn", + "checksum_sha256": "", + "packed_at": "", + } + + # Load metadata from sidecar JSON if it exists + if json_file.exists(): + try: + with open(json_file, "r") as f: + meta = json.load(f) + entry["version"] = meta.get("version", 1) + entry["description"] = meta.get("description", "") + entry["packed_at"] = meta.get("packed_at", "") + entry["source_scene"] = meta.get("source_scene", "") + except (json.JSONDecodeError, OSError) as e: + logger.warning("Failed to parse metadata for %s: %s", map_name, e) + + # Compute SHA-256 checksum (on first scan, cached in memory) + ck = self._compute_checksum(pck_file) + if ck: + entry["checksum_sha256"] = ck + + self._cache[map_name] = entry + logger.debug("Registered map: %s (%d bytes)", map_name, entry["size"]) + + logger.info("Scanned %s: %d maps registered", self.maps_dir, len(self._cache)) + + def _compute_checksum(self, path: Path) -> str: + """Compute SHA-256 checksum of a file.""" + try: + h = hashlib.sha256() + with open(path, "rb") as f: + while True: + chunk = f.read(65536) + if not chunk: + break + h.update(chunk) + return h.hexdigest() + except OSError as e: + logger.warning("Checksum failed for %s: %s", path.name, e) + return "" + + def get_map_list(self) -> dict: + """Return the full map listing as a dict.""" + self._scan() + return { + "maps": list(self._cache.values()), + "server_name": SERVER_NAME, + "server_version": VERSION, + "map_count": len(self._cache), + } + + def get_map_info(self, name: str) -> Optional[dict]: + """Return metadata for a single map.""" + self._scan() + return self._cache.get(name) + + def get_map_file(self, name: str) -> Optional[Path]: + """Return the filesystem path to a .pck file, or None.""" + self._scan() + entry = self._cache.get(name) + if entry is None: + return None + pck_path = self.maps_dir / entry["path"] + return pck_path if pck_path.exists() else None + + +# --------------------------------------------------------------------------- +# HTTP Handler +# --------------------------------------------------------------------------- + +class MapRegistryHandler(BaseHTTPRequestHandler): + """HTTP request handler for the map registry API.""" + + # Shared across all instances (set by the server) + registry: MapRegistry = None # type: ignore + + def log_message(self, format: str, *args) -> None: + logger.info("%s - %s", self.client_address[0], format % args) + + def _send_json(self, data: dict, status: int = 200) -> None: + body = json.dumps(data, indent=2).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(body) + + def _send_error(self, status: int, message: str) -> None: + self._send_json({"error": message}, status) + + def _send_file(self, path: Path, content_type: str = "application/octet-stream") -> None: + try: + file_size = path.stat().st_size + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(file_size)) + self.send_header("Content-Disposition", f'attachment; filename="{path.name}"') + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + + with open(path, "rb") as f: + while True: + chunk = f.read(65536) + if not chunk: + break + self.wfile.write(chunk) + except OSError as e: + logger.error("File send failed for %s: %s", path.name, e) + self._send_error(500, "Internal server error") + + def do_GET(self) -> None: + path = self.path.rstrip("/") + + # GET /maps — list all available maps + if path == "/maps": + self._send_json(self.registry.get_map_list()) + return + + # GET /maps/.pck — download a map file + if path.startswith("/maps/") and path.endswith(".pck"): + map_name = path[len("/maps/"):-len(".pck")] + map_file = self.registry.get_map_file(map_name) + if map_file: + self._send_file(map_file) + else: + self._send_error(404, f"Map '{map_name}' not found") + return + + # GET /maps/.json — get metadata for a single map + if path.startswith("/maps/") and path.endswith(".json"): + map_name = path[len("/maps/"):-len(".json")] + info = self.registry.get_map_info(map_name) + if info: + self._send_json(info) + else: + self._send_error(404, f"Map '{map_name}' not found") + return + + # GET / — server info + if path == "/" or path == "": + self._send_json({ + "service": SERVER_NAME, + "version": VERSION, + "endpoints": { + "list_maps": "/maps", + "download_map": "/maps/.pck", + "map_metadata": "/maps/.json", + }, + }) + return + + self._send_error(404, f"Not found: {path}") + + def do_OPTIONS(self) -> None: + """CORS preflight.""" + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + + +# --------------------------------------------------------------------------- +# Server +# --------------------------------------------------------------------------- + +def create_server(port: int, maps_dir: str) -> HTTPServer: + """Create and configure the HTTP server.""" + registry = MapRegistry(maps_dir) + MapRegistryHandler.registry = registry + + server = HTTPServer(("0.0.0.0", port), MapRegistryHandler) + server.timeout = 0.5 # allow signal handling + + logger.info("Map Registry Server v%s", VERSION) + logger.info(" Listen: http://0.0.0.0:%d", port) + logger.info(" Maps dir: %s", registry.maps_dir.resolve()) + logger.info(" Maps found: %d", len(registry._cache)) + logger.info(" Endpoints:") + logger.info(" List: GET /maps") + logger.info(" Download: GET /maps/.pck") + logger.info(" Metadata: GET /maps/.json") + + return server + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Tactical Shooter Map Registry Server", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument( + "--port", "-p", + type=int, + default=int(os.environ.get("MAP_REGISTRY_PORT", DEFAULT_PORT)), + help="HTTP port (default: %d)" % DEFAULT_PORT, + ) + parser.add_argument( + "--maps-dir", "-d", + type=str, + default=os.environ.get("MAP_REGISTRY_MAPS", DEFAULT_MAPS_DIR), + help="Directory containing .pck files (default: %s)" % DEFAULT_MAPS_DIR, + ) + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="Enable debug logging", + ) + return parser.parse_args() + + +def main(): + args = parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + server = create_server(args.port, args.maps_dir) + + # Handle SIGTERM/SIGINT for graceful shutdown + shutdown_requested = False + + def handle_signal(sig, frame): + nonlocal shutdown_requested + if not shutdown_requested: + logger.info("Shutdown requested (signal %d)", sig) + shutdown_requested = True + server.shutdown() + + signal.signal(signal.SIGTERM, handle_signal) + signal.signal(signal.SIGINT, handle_signal) + + # SIGHUP rescans the map directory + if hasattr(signal, "SIGHUP"): + def handle_hup(sig, frame): + logger.info("SIGHUP received — rescanning maps") + server.RequestHandlerClass.registry._scan() + signal.signal(signal.SIGHUP, handle_hup) + + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + logger.info("Server stopped") + + +if __name__ == "__main__": + main() diff --git a/scripts/map_packaging/pack_map.gd b/scripts/map_packaging/pack_map.gd new file mode 100644 index 0000000..e5ea9b9 --- /dev/null +++ b/scripts/map_packaging/pack_map.gd @@ -0,0 +1,190 @@ +## pack_map.gd — Map PCK Exporter +## +## Editor tool script that exports a map scene (.tscn) as a standalone +## .pck resource pack. The .pck contains only the map scene and its +## direct dependencies (meshes, textures, materials) — no game logic. +## +## Usage: +## 1. Open the map template project (client/map_template/) in Godot. +## 2. Build your map in the scenes/maps/ directory. +## 3. Run this script from the Editor > Tools menu, or via the CLI: +## godot --headless --script scripts/map_packaging/pack_map.gd --map=res://scenes/maps/my_map.tscn +## +## Output: +## user://packed_maps/my_map.pck (ready to upload to the registry server) +## +## The exported .pck can be loaded in-game via: +## ProjectSettings.load_resource_pack("user://maps/my_map.pck") +## +## Requirements: +## - Godot 4.2+ +## - The map scene must use only local resources (relative paths within the project) +## - External dependencies (e.g. shared game assets) should be excluded — the +## .pck is meant to be additive content, not a full replacement. + +tool +extends EditorScript + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +## Output directory relative to user:// +const OUTPUT_DIR: String = "packed_maps" + +## Godot export mode — packs all dependencies recursively. +const EXPORT_MODE: int = PackedScene.GEN_FLAG_SAVE_NONE + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +func _run() -> void: + # Determine the map scene path + var scene_path: String = _resolve_scene_path() + if scene_path.is_empty(): + print("Usage: godot --headless --script pack_map.gd --map=res://scenes/maps/.tscn") + print(" Or run from Editor > Tools > Pack Current Map") + return + + # Validate the scene exists + if not ResourceLoader.exists(scene_path): + push_error("[PackMap] Scene not found: %s" % scene_path) + return + + # Derive map name from file name + var map_name: String = scene_path.get_file().trim_suffix(".tscn") + var output_path: String = "user://%s/%s.pck" % [OUTPUT_DIR, map_name] + + # Ensure output directory exists + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("user://%s" % OUTPUT_DIR)) + + print("[PackMap] Packaging: %s" % scene_path) + print("[PackMap] Output: %s" % output_path) + + # Load the scene + var scene: PackedScene = load(scene_path) + if scene == null: + push_error("[PackMap] Failed to load scene: %s" % scene_path) + return + + # Generate the .pck via Godot's built-in packer. + # We do this by creating a temporary PackedScene that encapsulates the map, + # then using ProjectSettings.save_resource_pack to extract only the needed resources. + var err: Error = _export_pck(scene, scene_path, output_path) + if err != OK: + push_error("[PackMap] Export failed: %s" % error_string(err)) + return + + print("[PackMap] Successfully packed: %s (→ %s)" % [map_name, output_path]) + print("[PackMap] File size: %d bytes" % FileAccess.get_size(output_path)) + + # Write a sidecar JSON with metadata + _write_metadata(map_name, output_path, scene_path) + + +# --------------------------------------------------------------------------- +# Internal +# --------------------------------------------------------------------------- + +## Resolve the --map CLI argument or use the currently open scene. +func _resolve_scene_path() -> String: + # CLI argument takes precedence + for arg in OS.get_cmdline_args(): + if arg.begins_with("--map="): + return arg.trim_prefix("--map=") + + # Fallback: use the currently open scene in the editor + var current_scene_path: String = "" + if Engine.is_editor_hint(): + # In editor mode, try to get the current scene from the editor interface + var editor_interface: EditorInterface = get_editor_interface() + if editor_interface: + current_scene_path = editor_interface.get_current_scene().scene_file_path + + return current_scene_path + + +## Export the scene as a .pck file containing only its direct dependencies. +func _export_pck(scene: PackedScene, scene_path: String, output_path: String) -> Error: + # Strategy: pack the scene + all its dependencies into a .pck using + # ResourceSaver. We create a packaging helper that simulates what + # Godot's export process does for resource packs. + + # Create a collection of all dependencies we need to include + var deps: Array[String] = _collect_dependencies(scene_path) + + # Add the scene itself + deps.append(scene_path) + + # We need to strip the "res://" prefix and map to correct paths + # Create a PackedScene copy with packed resources + var packed: PackedScene = _build_packed_map(scene_path, deps) + if packed == null: + return ERR_FILE_CORRUPT + + # Write the .pck using ProjectSettings.save_resource_pack() + var files_to_pack: PackedStringArray = PackedStringArray() + for dep in deps: + var global_path: String = ProjectSettings.globalize_path(dep) + if FileAccess.file_exists(global_path): + files_to_pack.append(dep) + + if files_to_pack.is_empty(): + return ERR_FILE_NOT_FOUND + + var err: Error = ProjectSettings.save_resource_pack(output_path, files_to_pack) + return err + + +## Collect all dependencies of a scene recursively. +func _collect_dependencies(scene_path: String) -> Array[String]: + var deps: Array[String] = [] + var visited: Dictionary = {} + var pending: Array[String] = [scene_path] + + while not pending.is_empty(): + var current: String = pending.pop_front() + if current in visited: + continue + visited[current] = true + + # Skip external / built-in resources + if current.begins_with("builtin://") or current.begins_with("uid://"): + continue + + # Get dependencies from ResourceLoader + var dep_list: PackedStringArray = ResourceLoader.get_dependencies(current) + for dep in dep_list: + if dep not in visited and dep.begins_with("res://"): + deps.append(dep) + pending.append(dep) + + return deps + + +## Build a PackedScene from the map and its dependencies. +func _build_packed_map(scene_path: String, deps: Array[String]) -> PackedScene: + # Load the scene and pack it + var scene: PackedScene = load(scene_path) + if scene == null: + return null + return scene + + +## Write a JSON metadata file alongside the .pck. +func _write_metadata(map_name: String, pck_path: String, scene_path: String) -> void: + var meta := { + map_name = map_name, + pck_path = pck_path, + source_scene = scene_path, + godot_version = Engine.get_version_info(), + packed_at = Time.get_datetime_string_from_system(), + } + + var meta_path: String = pck_path.trim_suffix(".pck") + ".json" + var f := FileAccess.open(meta_path, FileAccess.WRITE) + if f: + f.store_string(JSON.stringify(meta, "\t", false)) + f.close() + print("[PackMap] Metadata written: %s" % meta_path) diff --git a/scripts/network/client_main.gd b/scripts/network/client_main.gd new file mode 100644 index 0000000..d75fe25 --- /dev/null +++ b/scripts/network/client_main.gd @@ -0,0 +1,124 @@ +## Client Main — Client entry point for testing. +## +## Connects to the server. Receives broadcast_spawn_player / +## broadcast_despawn_player RPCs from the server and creates +## visual player nodes locally so each client sees every other player. +## +## Phase 0: simple position replication via RPC + box mesh player nodes. + +extends Node3D + +# --------------------------------------------------------------------------- +# Exports +# --------------------------------------------------------------------------- +@export var server_host: String = "127.0.0.1" +@export var server_port: int = 34197 +@export var player_scene: PackedScene = preload("res://scenes/player.tscn") + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- +# remote_players[peer_id] = Node3D — visual representation of other players +var remote_players: Dictionary = {} +var connected: bool = false + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- +func _ready() -> void: + # Connect to server after a short delay + await get_tree().create_timer(0.5).timeout + _connect_to_server() + +func _connect_to_server() -> void: + if OS.has_environment("SERVER_HOST"): + server_host = OS.get_environment("SERVER_HOST") + if OS.has_environment("SERVER_PORT"): + server_port = int(OS.get_environment("SERVER_PORT")) + + print("[ClientMain] Connecting to %s:%d ..." % [server_host, server_port]) + + var err: Error = NetworkManager.join_server(server_host, server_port) + if err != OK: + push_error("[ClientMain] Connection failed: %s" % error_string(err)) + await get_tree().create_timer(2.0).timeout + _connect_to_server() + return + + # Setup camera and lights + _setup_scene() + + connected = true + + # Connect replication signals from NetworkManager + # These fire when the server broadcasts spawn_player / despawn_player + NetworkManager.remote_player_spawned.connect(_on_remote_player_spawned) + NetworkManager.remote_player_despawned.connect(_on_remote_player_despawned) + + print("[ClientMain] Connected to server. Peer ID: %d" % multiplayer.get_unique_id()) + +func _setup_scene() -> void: + # Simple 3/4 top-down camera for Phase 0 testing + var camera := Camera3D.new() + camera.current = true + camera.position = Vector3(0, 16, 12) + camera.rotation_degrees.x = -55 + add_child(camera) + + # Ambient light so we can see the box meshes + var light := DirectionalLight3D.new() + light.light_energy = 1.0 + light.shadow_enabled = true + light.position = Vector3(10, 20, 10) + light.look_at(Vector3.ZERO) + add_child(light) + +# --------------------------------------------------------------------------- +# Player replication handlers (called when server broadcasts via RPC) +# --------------------------------------------------------------------------- +func _on_remote_player_spawned(peer_id: int, pos: Vector3) -> void: + if peer_id == multiplayer.get_unique_id(): + # This is OUR player — the server has authority over it, + # but we don't need to create a duplicate. Our local player + # node is managed by the server's authority system. + # The player.gd script will handle input on this peer. + return + + if peer_id in remote_players: + push_warning("[ClientMain] Remote player %d already exists, skipping" % peer_id) + return + + # Create a remote player node for visualization + var player: Node3D + if player_scene: + player = player_scene.instantiate() + else: + player = Node3D.new() + player.set_script(preload("res://scripts/network/player.gd")) + + player.name = "RemotePlayer_%d" % peer_id + player.set_multiplayer_authority(peer_id) + player.position = pos + + add_child(player, true) + remote_players[peer_id] = player + + print("[ClientMain] Spawned remote player %d at (%.1f, %.1f)" % [peer_id, pos.x, pos.z]) + +func _on_remote_player_despawned(peer_id: int) -> void: + if peer_id == multiplayer.get_unique_id(): + return + + if peer_id in remote_players: + remote_players[peer_id].queue_free() + remote_players.erase(peer_id) + print("[ClientMain] Despawned remote player %d" % peer_id) + +func _exit_tree() -> void: + if connected: + NetworkManager.stop() + + # Clean up any remaining remote players + for p in remote_players.values(): + p.queue_free() + remote_players.clear() diff --git a/scripts/network/client_main.gd.uid b/scripts/network/client_main.gd.uid new file mode 100644 index 0000000..64a9e00 --- /dev/null +++ b/scripts/network/client_main.gd.uid @@ -0,0 +1 @@ +uid://blpy6rdyfy2b1 diff --git a/scripts/network/network_manager.gd b/scripts/network/network_manager.gd new file mode 100644 index 0000000..fd7f081 --- /dev/null +++ b/scripts/network/network_manager.gd @@ -0,0 +1,168 @@ +## NetworkManager — ENet Transport + Player Replication Singleton +## +## Autoload that wraps Godot 4's ENetMultiplayerPeer and provides +## player spawn/despawn broadcasting to all connected clients. +## Server creates RPC broadcasts; clients receive and emit signals. +## +## Architecture: +## server mode → start_server(port) → ENetMultiplayerPeer server +## client mode → join_server(host,port)→ ENetMultiplayerPeer client +## replication → _broadcast_spawn_player / _broadcast_despawn_player +## (server→all clients RPC) +## +## Channels (3-lane layout per Phase 0 research): +## 0 unreliable-ordered → 128Hz input / transform deltas +## 1 reliable-ordered → game events, spawn, damage, chat +## 2 unreliable → telemetry / VOIP metadata + +extends Node + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- +signal server_started(port: int) +signal server_stopped() +signal player_connected(peer_id: int) +signal player_disconnected(peer_id: int) +signal connection_succeeded() +signal connection_failed(error_message: String) + +# --- Player replication signals (emitted on all peers after RPC broadcast) --- +signal remote_player_spawned(peer_id: int, pos: Vector3) +signal remote_player_despawned(peer_id: int) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +const DEFAULT_PORT: int = 34197 +const CHANNELS: int = 3 # 0=input, 1=events, 2=telemetry + +# Max clients is read from ServerConfig at start_server() time. +# This default is used before ServerConfig is available. +var max_clients: int = 16 + +# Channel indices +enum Chan { + INPUT = 0, + EVENTS = 1, + TELEMETRY = 2, +} + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- +var is_server: bool = false : get = _is_server +var is_client: bool = false : get = _is_client +var peer: ENetMultiplayerPeer = null + +func _is_server() -> bool: + return is_server + +func _is_client() -> bool: + return is_client + +# --------------------------------------------------------------------------- +# Server API +# --------------------------------------------------------------------------- +## Start a dedicated server on [port]. +## Uses max_clients (which should be set from ServerConfig before calling). +## Returns OK or ERR_* on failure. +func start_server(port: int = DEFAULT_PORT) -> Error: + if peer and peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED: + stop() + + # If ServerConfig is available, use it for max_clients + if ServerConfig and ServerConfig.has_method(&"get_config_path"): + max_clients = ServerConfig.max_players + + peer = ENetMultiplayerPeer.new() + peer.set_bind_ip("*") + + var err: Error = peer.create_server(port, max_clients, CHANNELS, 0, 0) + if err != OK: + peer = null + return err + + multiplayer.multiplayer_peer = peer + multiplayer.multiplayer_peer.peer_connected.connect(_on_peer_connected) + multiplayer.multiplayer_peer.peer_disconnected.connect(_on_peer_disconnected) + + is_server = true + server_started.emit(port) + print("[NetworkManager] Server started on port %d" % port) + return OK + +## Stop the server / disconnect. +func stop() -> void: + if not peer: + return + + if is_server: + multiplayer.multiplayer_peer.peer_connected.disconnect(_on_peer_connected) + multiplayer.multiplayer_peer.peer_disconnected.disconnect(_on_peer_disconnected) + + peer.close() + multiplayer.multiplayer_peer = null + peer = null + is_server = false + is_client = false + server_stopped.emit() + print("[NetworkManager] Stopped") + +# --------------------------------------------------------------------------- +# Client API +# --------------------------------------------------------------------------- +## Connect to a remote server. +func join_server(host: String, port: int = DEFAULT_PORT) -> Error: + if peer and peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED: + stop() + + peer = ENetMultiplayerPeer.new() + var err: Error = peer.create_client(host, port, CHANNELS, 0, 0) + if err != OK: + peer = null + return err + + multiplayer.multiplayer_peer = peer + connection_succeeded.emit() + print("[NetworkManager] Connecting to %s:%d ..." % [host, port]) + + is_client = true + return OK + +# --------------------------------------------------------------------------- +# Player Replication RPCs (broadcast server → all clients) +# --------------------------------------------------------------------------- +## Server calls this when a new player joins. +## Broadcasts to all clients so they can create a visual player node. +@rpc("authority", "call_local", "reliable") +func broadcast_spawn_player(peer_id: int, pos: Vector3, is_team_a: bool) -> void: + if not multiplayer.is_server(): + print("[NetworkManager] Client received spawn: peer=%d at (%.1f, %.1f)" % [peer_id, pos.x, pos.z]) + remote_player_spawned.emit(peer_id, pos) + +## Server calls this when a player leaves. +@rpc("authority", "call_local", "reliable") +func broadcast_despawn_player(peer_id: int) -> void: + if not multiplayer.is_server(): + print("[NetworkManager] Client received despawn: peer=%d" % peer_id) + remote_player_despawned.emit(peer_id) + +# --------------------------------------------------------------------------- +# Event handlers +# --------------------------------------------------------------------------- +func _on_peer_connected(id: int) -> void: + print("[NetworkManager] Peer connected: %d" % id) + player_connected.emit(id) + +func _on_peer_disconnected(id: int) -> void: + print("[NetworkManager] Peer disconnected: %d" % id) + player_disconnected.emit(id) + +func _process(_delta: float) -> void: + # Godot does internal ENet polling via MultiplayerAPI; + # explicit polling is reserved for future custom packet handling. + pass + +func _exit_tree() -> void: + stop() diff --git a/scripts/network/network_manager.gd.uid b/scripts/network/network_manager.gd.uid new file mode 100644 index 0000000..fcf41cd --- /dev/null +++ b/scripts/network/network_manager.gd.uid @@ -0,0 +1 @@ +uid://crq322sfr42al diff --git a/scripts/network/player.gd b/scripts/network/player.gd new file mode 100644 index 0000000..c63d621 --- /dev/null +++ b/scripts/network/player.gd @@ -0,0 +1,100 @@ +## Player — Replicated player state. +## +## Server-authoritative movement model (Phase 0 placeholder). +## The player who owns this node (multiplayer authority) sends input, +## server validates and broadcasts position to all peers. +## +## Phase 0: simple position RPC replication. +## Phase 1+: full input → movement → state broadcast loop. + +extends Node3D + +# --------------------------------------------------------------------------- +# Exports +# --------------------------------------------------------------------------- +@export var movement_speed: float = 5.0 +@export var sync_rate: float = 10.0 # Hz — how often to broadcast state + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- +var is_local: bool = false # True for the local player instance +var is_client_controlled: bool = false + +var _sync_timer: float = 0.0 + +# Interpolation state for remote players +var _remote_position: Vector3 = Vector3.ZERO +var _remote_velocity: Vector3 = Vector3.ZERO + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- +func _ready() -> void: + is_local = multiplayer.get_unique_id() == multiplayer.get_multiplayer_authority() + + if is_local: + # This is our own player — we'll send input to server + is_client_controlled = not multiplayer.is_server() + if is_client_controlled: + print("[Player] Local player (client-controlled): %d" % multiplayer.get_unique_id()) + else: + # Remote player — disable direct control + set_process(false) + set_physics_process(false) + print("[Player] Remote player: %d (authority: %d)" % [multiplayer.get_unique_id(), multiplayer.get_multiplayer_authority()]) + +# --------------------------------------------------------------------------- +# Client-side: send position to server +# --------------------------------------------------------------------------- +func _process(delta: float) -> void: + if not is_client_controlled: + return + + # Simple movement for Phase 0 testing + var input_dir := Vector3.ZERO + if Input.is_action_pressed("ui_right"): + input_dir.x += 1.0 + if Input.is_action_pressed("ui_left"): + input_dir.x -= 1.0 + if Input.is_action_pressed("ui_up"): + input_dir.z -= 1.0 + if Input.is_action_pressed("ui_down"): + input_dir.z += 1.0 + + if input_dir != Vector3.ZERO: + input_dir = input_dir.normalized() + position += input_dir * movement_speed * delta + + # Rate-limited position update to server + _sync_timer += delta + if _sync_timer >= (1.0 / sync_rate): + _sync_timer = 0.0 + _send_position.rpc_id(1, position) + +@rpc("unreliable", "any_peer") +func _send_position(pos: Vector3) -> void: + # Server receives position from client + if not multiplayer.is_server(): + return + + var peer_id: int = multiplayer.get_remote_sender_id() + if peer_id != multiplayer.get_multiplayer_authority(): + # Peer is not authorized for this player — reject + push_warning("[Player] Unauthorized position update from peer %d" % peer_id) + return + + # Server validates and broadcasts to all other peers + position = pos + _broadcast_position.rpc(pos) + +@rpc("unreliable", "authority", "call_local") +func _broadcast_position(pos: Vector3) -> void: + # All peers (including server) update this player's position + if not is_local: + # Remote player — store for interpolation + _remote_position = pos + position = pos + else: + # Local player — authoritative position already set via input + pass diff --git a/scripts/network/player.gd.uid b/scripts/network/player.gd.uid new file mode 100644 index 0000000..c1e33d4 --- /dev/null +++ b/scripts/network/player.gd.uid @@ -0,0 +1 @@ +uid://c5b3teqp6yha diff --git a/scripts/network/server_main.gd b/scripts/network/server_main.gd new file mode 100644 index 0000000..36aa198 --- /dev/null +++ b/scripts/network/server_main.gd @@ -0,0 +1,183 @@ +## Server Main — Headless dedicated server entry point. +## +## Runs as `server_main.tscn` which is the project's main scene. +## In headless mode (--headless), this is the only running scene. +## +## On ready: +## 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 + +extends Node + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- +signal player_spawned(peer_id: int) +signal player_despawned(peer_id: int) + +# --------------------------------------------------------------------------- +# Exports +# --------------------------------------------------------------------------- +@export var player_scene: PackedScene = preload("res://scenes/player.tscn") + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- +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 + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- +func _ready() -> void: + # Config driven — ServerConfig singleton loaded at autoload time. + # Allow port override via env var for backwards compatibility + # (container/VPS deployments that set SERVER_PORT). + var effective_port: int = ServerConfig.port + 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) + + # 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) + print("[ServerMain] Tick rate: %d Hz" % ServerConfig.tick_rate) + 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()]) + +func _exit_tree() -> void: + NetworkManager.stop() + +# --------------------------------------------------------------------------- +# Map loading +# --------------------------------------------------------------------------- +func _load_map() -> void: + # Load the first map from the config's rotation list + var maps_list: Array[String] = ServerConfig.map_list + if maps_list.is_empty(): + push_warning("[ServerMain] Map rotation is empty — running without map geometry") + return + + var first_map_path: String = maps_list[0] + var full_path: String = "res://scenes/map/%s.tscn" % first_map_path + + if not ResourceLoader.exists(full_path): + push_error("[ServerMain] Map scene not found: %s" % full_path) + return + + var map_scene_res: PackedScene = load(full_path) + var map_instance: Node = map_scene_res.instantiate() + map_instance.name = "Map_%s" % first_map_path + add_child(map_instance, true) + + print("[ServerMain] Loaded map: %s" % first_map_path) + + # Collect spawn markers from the map scene + _collect_spawn_points(map_instance) + +## Walk the map scene looking for Marker3D nodes named "SpawnA*" and "SpawnB*". +func _collect_spawn_points(map_root: Node) -> void: + _collect_spawn_points_recursive(map_root) + + # Fallback: only after full recursion, if still empty + if spawn_points_a.is_empty() and spawn_points_b.is_empty(): + print("[ServerMain] No spawn markers found — using default positions") + spawn_points_a.append(Vector3(-16, 0, -4)) + spawn_points_a.append(Vector3(-16, 0, 4)) + spawn_points_b.append(Vector3(16, 0, -4)) + spawn_points_b.append(Vector3(16, 0, 4)) + +func _collect_spawn_points_recursive(node: Node) -> void: + if node is Marker3D: + var name_lower: String = node.name.to_lower() + if name_lower.begins_with("spawna") or name_lower.begins_with("spawn_a"): + spawn_points_a.append(node.position) + elif name_lower.begins_with("spawnb") or name_lower.begins_with("spawn_b"): + spawn_points_b.append(node.position) + + for child in node.get_children(): + _collect_spawn_points_recursive(child) + +# --------------------------------------------------------------------------- +# Player management +# --------------------------------------------------------------------------- +func _on_player_connected(peer_id: int) -> void: + print("[ServerMain] Player joined: %d. Total: %d" % [peer_id, multiplayer.get_peers().size() + 1]) + _spawn_player(peer_id) + +func _on_player_disconnected(peer_id: int) -> void: + print("[ServerMain] Player left: %d" % peer_id) + _despawn_player(peer_id) + +func _spawn_player(peer_id: int) -> void: + # Create player node on the server + var player: Node + if player_scene: + player = player_scene.instantiate() + 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) + + # 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] + 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 + NetworkManager.broadcast_despawn_player.rpc(peer_id) + +# --------------------------------------------------------------------------- +# Server tick (128 Hz) +# --------------------------------------------------------------------------- +func _physics_process(delta: float) -> void: + if not NetworkManager.is_server: + return + + # Future: authoritative physics tick, state broadcast, etc. + pass diff --git a/scripts/network/server_main.gd.uid b/scripts/network/server_main.gd.uid new file mode 100644 index 0000000..1fce958 --- /dev/null +++ b/scripts/network/server_main.gd.uid @@ -0,0 +1 @@ +uid://dtwj2vairvxnt