Save workspace artifacts: character-controller, gdextension scaffold, network scripts, map-pipeline, server config
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
This commit is contained in:
@@ -27,3 +27,4 @@ server/build/
|
||||
|
||||
# Secrets (operator must create per deployment)
|
||||
server/data/rcon_password.cfg
|
||||
build/
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,19 @@
|
||||
# Build artifacts
|
||||
build/
|
||||
gdextension/bin/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
@@ -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 <repo-url> 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/<platform>/`.
|
||||
|
||||
## 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
|
||||
@@ -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/<target_platform>/
|
||||
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)
|
||||
@@ -0,0 +1,10 @@
|
||||
[configuration]
|
||||
entry_symbol = "gdextension_entry"
|
||||
compatibility_minimum = "4.2"
|
||||
|
||||
[libraries]
|
||||
linux.x86_64 = "res://gdextension/bin/linux/libsimulation.so"
|
||||
linux.arm64 = "res://gdextension/bin/linux/libsimulation.so"
|
||||
windows.x86_64 = "res://gdextension/bin/windows/libsimulation.dll"
|
||||
macos.x86_64 = "res://gdextension/bin/macos/libsimulation.dylib"
|
||||
macos.arm64 = "res://gdextension/bin/macos/libsimulation.dylib"
|
||||
@@ -0,0 +1,260 @@
|
||||
#ifndef TACTICAL_SHOOTER_BITSTREAM_H
|
||||
#define TACTICAL_SHOOTER_BITSTREAM_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <cassert>
|
||||
#include <climits>
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
|
||||
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<uint8_t> 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<uint32_t>((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<uint32_t>(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<uint8_t>(read_bits(8));
|
||||
}
|
||||
|
||||
uint16_t read_uint16() {
|
||||
return static_cast<uint16_t>(read_bits(16));
|
||||
}
|
||||
|
||||
uint32_t read_uint32() {
|
||||
return read_bits(32);
|
||||
}
|
||||
|
||||
int32_t read_int32() {
|
||||
uint32_t zigzag = read_bits(32);
|
||||
return static_cast<int32_t>((zigzag >> 1) ^ -(static_cast<int32_t>(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<float>(quantized) / static_cast<float>((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<uint8_t> read_uint8_array() {
|
||||
uint16_t count = read_uint16();
|
||||
std::vector<uint8_t> 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<uint8_t> take_buffer() {
|
||||
std::vector<uint8_t> 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<uint8_t> buffer_;
|
||||
size_t bits_written_ = 0;
|
||||
size_t bits_read_ = 0;
|
||||
};
|
||||
|
||||
} // namespace tactical_shooter
|
||||
|
||||
#endif // TACTICAL_SHOOTER_BITSTREAM_H
|
||||
@@ -0,0 +1,125 @@
|
||||
#include "entity.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,173 @@
|
||||
#ifndef TACTICAL_SHOOTER_ENTITY_H
|
||||
#define TACTICAL_SHOOTER_ENTITY_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <godot_cpp/classes/ref_counted.hpp>
|
||||
#include <godot_cpp/variant/vector3.hpp>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,157 @@
|
||||
#include "hit_detection.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
namespace tactical_shooter {
|
||||
|
||||
HitDetection::HitDetection() {}
|
||||
|
||||
void HitDetection::set_entities(const std::vector<Entity *> &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<HitResult> HitDetection::sphere_overlap(
|
||||
const godot::Vector3 ¢er,
|
||||
float radius,
|
||||
uint16_t exclude_id
|
||||
) const {
|
||||
std::vector<HitResult> 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
|
||||
@@ -0,0 +1,138 @@
|
||||
#ifndef TACTICAL_SHOOTER_HIT_DETECTION_H
|
||||
#define TACTICAL_SHOOTER_HIT_DETECTION_H
|
||||
|
||||
#include "entity.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
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<Entity *> &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<HitResult> 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<Entity *> *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
|
||||
@@ -0,0 +1,155 @@
|
||||
#include "movement_component.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -0,0 +1,54 @@
|
||||
#include "register_types.h"
|
||||
#include "simulation_server.h"
|
||||
|
||||
#include <gdextension_interface.h>
|
||||
#include <godot_cpp/core/class_db.hpp>
|
||||
#include <godot_cpp/core/defs.hpp>
|
||||
#include <godot_cpp/godot.hpp>
|
||||
|
||||
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<SimulationServer>();
|
||||
godot::ClassDB::register_class<Entity>();
|
||||
}
|
||||
|
||||
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"
|
||||
@@ -0,0 +1,14 @@
|
||||
#ifndef TACTICAL_SHOOTER_REGISTER_TYPES_H
|
||||
#define TACTICAL_SHOOTER_REGISTER_TYPES_H
|
||||
|
||||
#include <godot_cpp/core/defs.hpp>
|
||||
#include <godot_cpp/godot.hpp>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,471 @@
|
||||
#include "simulation_server.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
|
||||
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<float>(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<Entity>();
|
||||
}
|
||||
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<Entity>(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<Entity>();
|
||||
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<Entity> 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<Entity>();
|
||||
}
|
||||
|
||||
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<float>(static_cast<double>(input_dict["look_yaw"]));
|
||||
}
|
||||
if (input_dict.has("look_pitch")) {
|
||||
input.look_pitch = static_cast<float>(static_cast<double>(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<uint32_t>(static_cast<int64_t>(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<float>(static_cast<double>(config["walk_speed"]));
|
||||
if (config.has("sprint_speed")) p.sprint_speed = static_cast<float>(static_cast<double>(config["sprint_speed"]));
|
||||
if (config.has("crouch_speed")) p.crouch_speed = static_cast<float>(static_cast<double>(config["crouch_speed"]));
|
||||
if (config.has("acceleration")) p.acceleration = static_cast<float>(static_cast<double>(config["acceleration"]));
|
||||
if (config.has("air_acceleration")) p.air_acceleration = static_cast<float>(static_cast<double>(config["air_acceleration"]));
|
||||
if (config.has("jump_velocity")) p.jump_velocity = static_cast<float>(static_cast<double>(config["jump_velocity"]));
|
||||
if (config.has("gravity")) p.gravity = static_cast<float>(static_cast<double>(config["gravity"]));
|
||||
if (config.has("friction")) p.friction = static_cast<float>(static_cast<double>(config["friction"]));
|
||||
if (config.has("max_velocity")) p.max_velocity = static_cast<float>(static_cast<double>(config["max_velocity"]));
|
||||
movement_.set_parameters(p);
|
||||
}
|
||||
|
||||
// ---- Benchmark / Stats -------------------------------------------------------
|
||||
|
||||
godot::Dictionary SimulationServer::get_stats() const {
|
||||
godot::Dictionary stats;
|
||||
stats["last_tick_usec"] = static_cast<int64_t>(last_tick_usec_);
|
||||
stats["tick_count"] = static_cast<int64_t>(tick_count_);
|
||||
stats["entity_count"] = static_cast<int64_t>(get_entity_count());
|
||||
|
||||
float avg_usec = 0.0f;
|
||||
if (tick_count_ > 0) {
|
||||
avg_usec = static_cast<float>(total_tick_usec_) / static_cast<float>(tick_count_);
|
||||
}
|
||||
stats["avg_tick_usec"] = avg_usec;
|
||||
stats["peak_tick_usec"] = static_cast<float>(max_tick_usec_);
|
||||
|
||||
// Also expose tick interval
|
||||
stats["tick_hz"] = static_cast<int64_t>(tick_hz_);
|
||||
stats["tick_interval_usec"] = static_cast<int64_t>(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<uint16_t>(kMaxEntities));
|
||||
|
||||
// Clear existing
|
||||
for (auto &e : entities_by_id_) {
|
||||
e = godot::Ref<Entity>();
|
||||
}
|
||||
last_snapshots_.clear();
|
||||
|
||||
// Spawn bots in a grid pattern
|
||||
uint16_t per_row = static_cast<uint16_t>(std::ceil(std::sqrt(static_cast<float>(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<float>(col) * 3.0f,
|
||||
0.0f,
|
||||
static_cast<float>(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<std::chrono::microseconds>(
|
||||
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<uint16_t>(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<int64_t>(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
|
||||
@@ -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 <godot_cpp/classes/object.hpp>
|
||||
#include <godot_cpp/classes/ref.hpp>
|
||||
#include <godot_cpp/variant/dictionary.hpp>
|
||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
||||
#include <godot_cpp/variant/vector3.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
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<Entity> 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<godot::Ref<Entity>> entities_by_id_;
|
||||
std::vector<Entity *> 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<QueuedInput> queued_inputs_;
|
||||
|
||||
// Queued weapon fires (entity IDs)
|
||||
std::vector<uint16_t> pending_fires_;
|
||||
|
||||
// Last serialized snapshot per entity (for delta compression)
|
||||
std::unordered_map<uint16_t, EntitySnapshot> 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
|
||||
@@ -0,0 +1,262 @@
|
||||
#include "state_serializer.h"
|
||||
|
||||
#include <cassert>
|
||||
|
||||
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<uint16_t, EntitySnapshot> &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<uint32_t>(mask));
|
||||
write_entity_delta(stream, *entities[i], it->second, mask);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Read Full ---------------------------------------------------------------
|
||||
|
||||
std::vector<EntitySnapshot> StateSerializer::read_full_snapshot(
|
||||
Bitstream &stream,
|
||||
uint32_t *out_tick
|
||||
) {
|
||||
std::vector<EntitySnapshot> 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<uint16_t, EntitySnapshot> &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<EntitySnapshot::ChangeMask>(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
|
||||
@@ -0,0 +1,136 @@
|
||||
#ifndef TACTICAL_SHOOTER_STATE_SERIALIZER_H
|
||||
#define TACTICAL_SHOOTER_STATE_SERIALIZER_H
|
||||
|
||||
#include "bitstream.h"
|
||||
#include "entity.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
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<uint16_t, EntitySnapshot> &base
|
||||
);
|
||||
|
||||
/**
|
||||
* Deserialize a full snapshot from a bitstream.
|
||||
*/
|
||||
std::vector<EntitySnapshot> 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<uint16_t, EntitySnapshot> &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
|
||||
@@ -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")]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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/<name>.pck ───┤ (if not cached)
|
||||
│ ├── .pck file ───────────────►│
|
||||
│ │ ├── Save to user://maps/
|
||||
│ │ ├── load_resource_pack()
|
||||
│ │ ├── Load res://maps/<name>.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/<map_name>.pck` — the resource pack
|
||||
- `user://packed_maps/<map_name>.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/<name>.pck` | GET | Download a map pack (binary) |
|
||||
| `/maps/<name>.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: `<map_name>.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/<name>.tscn)
|
||||
3. Run: godot --headless --script scripts/map_packaging/pack_map.gd --map=res://scenes/maps/<name>.tscn
|
||||
4. Upload output/user://packed_maps/<name>.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
|
||||
```
|
||||
@@ -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/<name>.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/<name>.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/<name>.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/<name>.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()
|
||||
@@ -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/<name>.pck
|
||||
Download a packaged map file. Content-Type: application/octet-stream.
|
||||
|
||||
GET /maps/<name>.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/<name>.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/<name>.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/<name>.pck",
|
||||
"map_metadata": "/maps/<name>.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/<name>.pck")
|
||||
logger.info(" Metadata: GET /maps/<name>.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()
|
||||
@@ -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/<map_name>.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)
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://blpy6rdyfy2b1
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://crq322sfr42al
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://c5b3teqp6yha
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dtwj2vairvxnt
|
||||
Reference in New Issue
Block a user