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:
2026-07-01 18:30:44 -04:00
parent 2cf57a989f
commit e9dc05983c
42 changed files with 6249 additions and 0 deletions
+1
View File
@@ -27,3 +27,4 @@ server/build/
# Secrets (operator must create per deployment)
server/data/rcon_password.cfg
build/
+119
View File
@@ -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.10.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
+215
View File
@@ -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)
+59
View File
@@ -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)
+21
View File
@@ -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
+87
View File
@@ -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
+47
View File
@@ -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
+19
View File
@@ -0,0 +1,19 @@
# Build artifacts
build/
gdextension/bin/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Python
__pycache__/
*.pyc
*.pyo
+159
View File
@@ -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
+115
View File
@@ -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"
+260
View File
@@ -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
+125
View File
@@ -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
+173
View File
@@ -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 &center,
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
+138
View File
@@ -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 &center,
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 &params)
: params_(params) {}
void MovementComponent::set_parameters(const Parameters &params) {
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 &params);
/**
* 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 &params);
const Parameters &parameters() 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
+14
View File
@@ -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")]
+291
View File
@@ -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)
+15
View File
@@ -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)
+14
View File
@@ -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.
+480
View File
@@ -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
+363
View File
@@ -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 (10100 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
```
+399
View File
@@ -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()
+190
View File
@@ -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)
+124
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://blpy6rdyfy2b1
+168
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://crq322sfr42al
+100
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://c5b3teqp6yha
+183
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dtwj2vairvxnt