Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce39b237c3 | |||
| a69c60ce85 | |||
| 969741aa31 | |||
| e70ce76207 | |||
| 926446e5cf |
@@ -1,21 +1,21 @@
|
|||||||
## FPSCharacterController — first-person character for a networked tactical shooter.
|
## 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.
|
## Handles mouse look, WASD movement, sprint toggle, hold-to-crouch, jump.
|
||||||
|
## The rollback-aware movement runs inside _rollback_tick() when netfox is active.
|
||||||
|
## Falls back to _move_local() for standalone / non-networked mode.
|
||||||
##
|
##
|
||||||
## Usage (scene tree):
|
## Usage (scene tree):
|
||||||
## FPSCharacterController (CharacterBody3D)
|
## FPSCharacterController (CharacterBody3D)
|
||||||
## ├── FpsCamera (Camera3D) — script: fps_camera.gd
|
## ├── FpsCamera (Camera3D) — script: fps_camera.gd
|
||||||
## ├── CollisionShape3D (capsule)
|
## ├── CollisionShape3D (capsule)
|
||||||
|
## ├── PlayerNetInput — netfox input gathering
|
||||||
|
## ├── TickInterpolator — smooth visuals for remotes
|
||||||
## └── (optional weapons/arms child)
|
## └── (optional weapons/arms child)
|
||||||
##
|
##
|
||||||
## Connects to SimulationServer via `apply_input()` each frame.
|
## Mouse Look: Capture mouse on click, yaw rotates body, pitch rotates camera.
|
||||||
## Reads server snapshot data and updates the node transform.
|
|
||||||
##
|
|
||||||
## Walk Toggle: Tap Shift to toggle sprint on/off.
|
## Walk Toggle: Tap Shift to toggle sprint on/off.
|
||||||
## Crouch: Hold Ctrl to crouch. Smooth lerp transition.
|
## Crouch: Hold Ctrl to crouch. Smooth lerp transition.
|
||||||
## Jump: Press Space.
|
## Jump: Press Space.
|
||||||
## Mouse Look: Capture mouse on click, yaw rotates body, pitch rotates camera.
|
|
||||||
class_name FPSCharacterController
|
class_name FPSCharacterController
|
||||||
extends CharacterBody3D
|
extends CharacterBody3D
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ extends CharacterBody3D
|
|||||||
# Exports — tune per weapon / game feel
|
# Exports — tune per weapon / game feel
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
## Mouse look sensitivity. At default 0.005, 100 px mouse motion = 0.5° rotation.
|
## Mouse look sensitivity. At default 0.005, 100 px mouse motion = 0.5 degree rotation.
|
||||||
@export_range(0.001, 0.1, 0.0005) var mouse_sensitivity: float = 0.005
|
@export_range(0.001, 0.1, 0.0005) var mouse_sensitivity: float = 0.005
|
||||||
|
|
||||||
## Mouse vertical sensitivity multiplier (1.0 = same as horizontal).
|
## Mouse vertical sensitivity multiplier (1.0 = same as horizontal).
|
||||||
@@ -35,9 +35,6 @@ extends CharacterBody3D
|
|||||||
## Maximum pitch angle (degrees) — prevents looking upside down.
|
## Maximum pitch angle (degrees) — prevents looking upside down.
|
||||||
@export_range(1, 89) var max_pitch: float = 89.0
|
@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.
|
## If true, pressing Sprint toggles between walk/sprint. If false, hold to sprint.
|
||||||
@export var walk_toggle: bool = true
|
@export var walk_toggle: bool = true
|
||||||
|
|
||||||
@@ -59,13 +56,25 @@ extends CharacterBody3D
|
|||||||
## Collision shape height when crouching.
|
## Collision shape height when crouching.
|
||||||
@export var collision_height_crouch: float = 1.0
|
@export var collision_height_crouch: float = 1.0
|
||||||
|
|
||||||
|
# --- Standalone movement parameters (used when netfox is not active) ---
|
||||||
|
|
||||||
|
## Walk speed (standalone mode / single-player).
|
||||||
|
@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
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Internal state
|
# Internal state
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
## Reference to SimulationServer singleton (set in _ready).
|
|
||||||
var _server: Object = null
|
|
||||||
|
|
||||||
## Current look rotation.
|
## Current look rotation.
|
||||||
var _yaw: float = 0.0
|
var _yaw: float = 0.0
|
||||||
var _pitch: float = 0.0
|
var _pitch: float = 0.0
|
||||||
@@ -74,7 +83,7 @@ var _pitch: float = 0.0
|
|||||||
var _crouch_target: float = 0.0
|
var _crouch_target: float = 0.0
|
||||||
var _crouch_current: float = 0.0
|
var _crouch_current: float = 0.0
|
||||||
|
|
||||||
## Walk-toggle state. If walk_toggle is true, toggles each time sprint input activates.
|
## Walk-toggle state.
|
||||||
var _sprint_active: bool = false
|
var _sprint_active: bool = false
|
||||||
var _sprint_pressed_last: bool = false
|
var _sprint_pressed_last: bool = false
|
||||||
|
|
||||||
@@ -82,24 +91,14 @@ var _sprint_pressed_last: bool = false
|
|||||||
var _crouch_active: bool = false
|
var _crouch_active: bool = false
|
||||||
var _crouch_pressed_last: 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 = {}
|
|
||||||
|
|
||||||
## Prediction controller reference (set by ClientPrediction system).
|
|
||||||
## When non-null and prediction_enabled, the controller uses client-side
|
|
||||||
## prediction with local movement for instant feedback instead of
|
|
||||||
## reading position from the SimulationServer entity directly.
|
|
||||||
var _prediction: Node = null
|
|
||||||
|
|
||||||
## Mouse capture state.
|
## Mouse capture state.
|
||||||
var _mouse_captured: bool = false
|
var _mouse_captured: bool = false
|
||||||
var _mouse_clicked_this_frame: bool = false
|
|
||||||
|
|
||||||
## Weapon manager reference.
|
## Whether netfox rollback is active (set by parent in network mode).
|
||||||
var _weapon: WeaponManager = null
|
var _netfox_active: bool = false
|
||||||
|
|
||||||
|
## Whether this is the local player (only the local player captures mouse).
|
||||||
|
var _is_local_player: bool = false
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Node references (set in _ready)
|
# Node references (set in _ready)
|
||||||
@@ -119,17 +118,15 @@ var _capsule_radius: float = 0.0
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
# Find SimulationServer
|
# Determine if this is the local player
|
||||||
if Engine.has_singleton("SimulationServer"):
|
_is_local_player = multiplayer.get_unique_id() == multiplayer.get_multiplayer_authority()
|
||||||
_server = Engine.get_singleton("SimulationServer")
|
|
||||||
else:
|
# Check if netfox rollback is available
|
||||||
push_warning("FPSCharacterController: SimulationServer singleton not found. " +
|
_netfox_active = Engine.has_singleton(&"NetworkRollback")
|
||||||
"Running in standalone (local physics) mode.")
|
|
||||||
|
|
||||||
# Locate camera
|
# Locate camera
|
||||||
_camera = get_node_or_null("%FpsCamera")
|
_camera = get_node_or_null("%FpsCamera")
|
||||||
if _camera == null:
|
if _camera == null:
|
||||||
# Try to find any Camera3D child
|
|
||||||
for child in get_children():
|
for child in get_children():
|
||||||
if child is Camera3D:
|
if child is Camera3D:
|
||||||
_camera = child
|
_camera = child
|
||||||
@@ -149,9 +146,6 @@ func _ready() -> void:
|
|||||||
_capsule_shape.height = _stand_capsule_height
|
_capsule_shape.height = _stand_capsule_height
|
||||||
break
|
break
|
||||||
|
|
||||||
# Start with mouse captured
|
|
||||||
_capture_mouse(true)
|
|
||||||
|
|
||||||
# Initialize yaw/pitch from current transform
|
# Initialize yaw/pitch from current transform
|
||||||
_yaw = rotation.y
|
_yaw = rotation.y
|
||||||
if _camera:
|
if _camera:
|
||||||
@@ -161,20 +155,20 @@ func _ready() -> void:
|
|||||||
_crouch_current = 0.0
|
_crouch_current = 0.0
|
||||||
_update_crouch(0.0)
|
_update_crouch(0.0)
|
||||||
|
|
||||||
# Find weapon manager child
|
# Start with mouse captured on local player
|
||||||
for child in get_children():
|
_set_local_player(_is_local_player)
|
||||||
if child is WeaponManager:
|
|
||||||
_weapon = child
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
|
if not _is_local_player:
|
||||||
|
return
|
||||||
|
|
||||||
# Mouse look
|
# Mouse look
|
||||||
if event is InputEventMouseMotion and _mouse_captured:
|
if event is InputEventMouseMotion and _mouse_captured:
|
||||||
var rel: Vector2 = event.relative
|
var rel: Vector2 = event.relative
|
||||||
# Yaw (body rotation)
|
# Yaw (body rotation)
|
||||||
_yaw -= rel.x * mouse_sensitivity
|
_yaw -= rel.x * mouse_sensitivity
|
||||||
# Pitch (camera rotation) — negative = look down, positive = look up
|
# Pitch (camera rotation)
|
||||||
var invert: float = -1.0 if invert_y else 1.0
|
var invert: float = -1.0 if invert_y else 1.0
|
||||||
_pitch += rel.y * mouse_sensitivity * mouse_vertical_ratio * invert
|
_pitch += rel.y * mouse_sensitivity * mouse_vertical_ratio * invert
|
||||||
_pitch = clamp(_pitch, deg_to_rad(-max_pitch), deg_to_rad(max_pitch))
|
_pitch = clamp(_pitch, deg_to_rad(-max_pitch), deg_to_rad(max_pitch))
|
||||||
@@ -183,12 +177,11 @@ func _input(event: InputEvent) -> void:
|
|||||||
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
|
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
|
||||||
if not _mouse_captured:
|
if not _mouse_captured:
|
||||||
_capture_mouse(true)
|
_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:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if not _is_local_player:
|
||||||
|
return
|
||||||
# Escape to release mouse
|
# Escape to release mouse
|
||||||
if event.is_action_pressed("ui_cancel"):
|
if event.is_action_pressed("ui_cancel"):
|
||||||
_capture_mouse(false)
|
_capture_mouse(false)
|
||||||
@@ -199,21 +192,60 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
# PREDICTION HOOK: capture pre-input state snapshot.
|
# When netfox rollback is active, movement is driven by _rollback_tick().
|
||||||
# Must be called BEFORE any input processing or movement so the
|
# We only handle local-only visual updates here: crouch animation, rotation.
|
||||||
# snapshot represents the state at the start of this tick.
|
if _netfox_active:
|
||||||
if _prediction and _prediction.prediction_enabled:
|
_update_visual_state(delta)
|
||||||
_prediction.on_before_tick()
|
return
|
||||||
|
|
||||||
|
# Standalone / no-netfox mode: do full local movement
|
||||||
|
_standalone_physics(delta)
|
||||||
|
|
||||||
|
|
||||||
|
## Updates visual state without applying movement — called every physics frame
|
||||||
|
## when netfox rollback drives the actual movement.
|
||||||
|
func _update_visual_state(delta: float) -> void:
|
||||||
|
# Walk toggle logic (frame-rate dependent)
|
||||||
|
if walk_toggle:
|
||||||
|
var sprint_pressed := Input.is_action_pressed(&"sprint")
|
||||||
|
if sprint_pressed and not _sprint_pressed_last:
|
||||||
|
_sprint_active = not _sprint_active
|
||||||
|
_sprint_pressed_last = sprint_pressed
|
||||||
|
else:
|
||||||
|
_sprint_active = Input.is_action_pressed(&"sprint")
|
||||||
|
|
||||||
|
# Crouch toggle logic
|
||||||
|
if crouch_toggle:
|
||||||
|
var crouch_pressed := Input.is_action_pressed(&"crouch")
|
||||||
|
if crouch_pressed and not _crouch_pressed_last:
|
||||||
|
_crouch_active = not _crouch_active
|
||||||
|
_crouch_pressed_last = crouch_pressed
|
||||||
|
else:
|
||||||
|
_crouch_active = Input.is_action_pressed(&"crouch")
|
||||||
|
|
||||||
|
# Crouch height transition (visual only)
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Apply rotation (visual only — authoritative rotation from rollback)
|
||||||
|
rotation.y = _yaw
|
||||||
|
if _camera:
|
||||||
|
_camera.rotation.x = _pitch
|
||||||
|
|
||||||
|
|
||||||
|
## Full physics update for standalone / non-networked mode.
|
||||||
|
func _standalone_physics(delta: float) -> void:
|
||||||
# 1. Capture input state
|
# 1. Capture input state
|
||||||
var input_dir := _get_move_direction()
|
var input_dir := _get_move_direction()
|
||||||
var jump_pressed := Input.is_action_just_pressed(&"jump")
|
var jump_pressed := Input.is_action_just_pressed(&"jump")
|
||||||
var sprint_pressed := Input.is_action_pressed(&"sprint")
|
var sprint_pressed := Input.is_action_pressed(&"sprint")
|
||||||
var crouch_pressed := Input.is_action_pressed(&"crouch")
|
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
|
# 2. Walk toggle logic
|
||||||
if walk_toggle:
|
if walk_toggle:
|
||||||
@@ -234,13 +266,11 @@ func _physics_process(delta: float) -> void:
|
|||||||
# 4. Crouch height transition
|
# 4. Crouch height transition
|
||||||
var target_crouch: float = 1.0 if _crouch_active else 0.0
|
var target_crouch: float = 1.0 if _crouch_active else 0.0
|
||||||
_crouch_target = target_crouch
|
_crouch_target = target_crouch
|
||||||
|
|
||||||
var crouch_speed: float = 1.0 / max(crouch_transition_time, 0.001)
|
var crouch_speed: float = 1.0 / max(crouch_transition_time, 0.001)
|
||||||
if _crouch_current < _crouch_target:
|
if _crouch_current < _crouch_target:
|
||||||
_crouch_current = min(_crouch_current + crouch_speed * delta, _crouch_target)
|
_crouch_current = min(_crouch_current + crouch_speed * delta, _crouch_target)
|
||||||
elif _crouch_current > _crouch_target:
|
elif _crouch_current > _crouch_target:
|
||||||
_crouch_current = max(_crouch_current - crouch_speed * delta, _crouch_target)
|
_crouch_current = max(_crouch_current - crouch_speed * delta, _crouch_target)
|
||||||
|
|
||||||
_update_crouch(_crouch_current)
|
_update_crouch(_crouch_current)
|
||||||
|
|
||||||
# 5. Apply rotation
|
# 5. Apply rotation
|
||||||
@@ -248,95 +278,90 @@ func _physics_process(delta: float) -> void:
|
|||||||
if _camera:
|
if _camera:
|
||||||
_camera.rotation.x = _pitch
|
_camera.rotation.x = _pitch
|
||||||
|
|
||||||
# 5b. Weapon fire with rate limiting via WeaponManager
|
# 6. Do local CharacterBody3D physics
|
||||||
var should_fire: bool = false
|
_move_local(input_dir, delta, jump_pressed)
|
||||||
var time_since_engine_start: float = Engine.get_process_ticks() * get_physics_process_delta_time()
|
|
||||||
if _weapon:
|
|
||||||
should_fire = _weapon.try_fire(time_since_engine_start)
|
|
||||||
elif shoot_pressed:
|
|
||||||
# No weapon manager — fire every tick (unlimited)
|
|
||||||
should_fire = true
|
|
||||||
|
|
||||||
if _server != null:
|
|
||||||
if should_fire:
|
|
||||||
_server.fire_weapon(entity_id)
|
|
||||||
|
|
||||||
# 6. Build input dictionary (all modes — mutate cached dict to avoid alloc)
|
# ---------------------------------------------------------------------------
|
||||||
_input_dict["move_direction"] = input_dir
|
# Rollback tick — called by RollbackSynchronizer for deterministic movement
|
||||||
_input_dict["look_yaw"] = rad_to_deg(_yaw)
|
# ---------------------------------------------------------------------------
|
||||||
_input_dict["look_pitch"] = rad_to_deg(_pitch)
|
|
||||||
_input_dict["jump"] = jump_pressed
|
|
||||||
_input_dict["crouch"] = _crouch_active
|
|
||||||
_input_dict["sprint"] = _sprint_active
|
|
||||||
_input_dict["shoot"] = should_fire
|
|
||||||
_input_dict["aim"] = aim_pressed
|
|
||||||
_input_dict["input_sequence"] = _input_sequence
|
|
||||||
|
|
||||||
# 6b. Route input to appropriate handler.
|
## Rollback-aware movement tick. Called by RollbackSynchronizer each network tick
|
||||||
if _prediction and _prediction.prediction_enabled:
|
## when netfox is active. Uses CharacterBody3D physics (move_and_slide) for proper
|
||||||
# Client prediction: input sending is handled by the prediction
|
## acceleration, slopes, and collision response.
|
||||||
# controller's on_after_tick() call at the end of this function.
|
func _rollback_tick(delta: float, tick: int, is_fresh: bool) -> void:
|
||||||
pass
|
# Find PlayerNetInput — it has the input state for this tick
|
||||||
elif _server != null and entity_id >= 0:
|
var input_node = _find_input_node()
|
||||||
# Listen server: send to local SimulationServer.
|
if input_node == null:
|
||||||
_server.apply_input(entity_id, _input_dict)
|
return
|
||||||
|
|
||||||
# Check for hit feedback from last tick
|
var dir: Vector3 = input_node.get(&"move_direction")
|
||||||
if Engine.has_singleton("SimulationServer") or _server:
|
var yaw: float = input_node.get(&"look_yaw")
|
||||||
var hit_result: Dictionary = _server.get_last_hit_result()
|
var jump: bool = input_node.get(&"jump")
|
||||||
if hit_result.get("hit", false) and _weapon:
|
var sprint: bool = input_node.get(&"sprint")
|
||||||
var hit_pos := Vector3.ZERO # approximate — we don't have exact world hit position from server yet
|
var crouch: bool = input_node.get(&"crouch")
|
||||||
_weapon.on_hit_confirmed(hit_pos, hit_result.get("damage", 0.0), hit_result.get("killed", false))
|
|
||||||
|
|
||||||
_input_sequence += 1
|
# Apply yaw rotation
|
||||||
|
_yaw = yaw
|
||||||
|
rotation.y = _yaw
|
||||||
|
|
||||||
# 7. Update position.
|
# Determine speed
|
||||||
# Architecture:
|
var speed: float = local_walk_speed
|
||||||
# - Client prediction: do local CharacterBody3D movement (instant feedback),
|
if sprint:
|
||||||
# the prediction controller handles reconciliation when authoritative
|
speed = local_sprint_speed
|
||||||
# server state arrives.
|
if crouch:
|
||||||
# - Listen server: read authoritative position from SimulationServer entity.
|
speed = local_crouch_speed
|
||||||
# - Standalone (no server): fallback CharacterBody3D movement.
|
|
||||||
if _prediction and _prediction.prediction_enabled:
|
# Apply movement with acceleration for smooth feel
|
||||||
# Client prediction path — local movement for instant feedback.
|
if dir != Vector3.ZERO:
|
||||||
_move_local(input_dir, delta, jump_pressed)
|
var wish_dir := (transform.basis * dir).normalized()
|
||||||
elif _server != null and entity_id >= 0:
|
var target_vel := wish_dir * speed
|
||||||
# Listen server: read from SimulationServer entity.
|
# Horizontal acceleration (smooth speed changes)
|
||||||
var entity = _server.get_entity(entity_id)
|
var h_vel := Vector3(velocity.x, 0.0, velocity.z)
|
||||||
if entity != null and entity.is_alive():
|
h_vel = h_vel.move_toward(target_vel, local_acceleration * delta)
|
||||||
# Server position is ground truth — apply it to the visual body
|
velocity.x = h_vel.x
|
||||||
# (only correct on listen-server; pure clients must use replication)
|
velocity.z = h_vel.z
|
||||||
global_position = entity.position
|
|
||||||
else:
|
else:
|
||||||
# Standalone mode: do local CharacterBody3D physics
|
# Decelerate when no input
|
||||||
_move_local(input_dir, delta, jump_pressed)
|
velocity.x = move_toward(velocity.x, 0.0, local_acceleration * delta)
|
||||||
|
velocity.z = move_toward(velocity.z, 0.0, local_acceleration * delta)
|
||||||
|
|
||||||
# PREDICTION HOOK: post-tick — send input to server via RPC.
|
# Gravity
|
||||||
# Must be called AFTER local movement so the prediction controller
|
if not is_on_floor():
|
||||||
# can send the input that produced this tick's movement.
|
velocity.y -= local_gravity * delta
|
||||||
if _prediction and _prediction.prediction_enabled:
|
elif not jump:
|
||||||
_prediction.on_after_tick(_input_dict)
|
velocity.y = 0.0
|
||||||
|
|
||||||
|
# Jump
|
||||||
|
if jump and is_on_floor():
|
||||||
|
velocity.y = local_jump_velocity
|
||||||
|
|
||||||
|
move_and_slide()
|
||||||
|
|
||||||
|
# Track crouch/sprint state for visual updates
|
||||||
|
_crouch_active = crouch
|
||||||
|
_sprint_active = sprint
|
||||||
|
|
||||||
|
|
||||||
|
func _find_input_node():
|
||||||
|
var parent := get_parent()
|
||||||
|
if parent == null:
|
||||||
|
return null
|
||||||
|
for child in parent.get_children():
|
||||||
|
if child.name == "PlayerNetInput":
|
||||||
|
return child
|
||||||
|
# Try own children
|
||||||
|
for child in get_children():
|
||||||
|
if child.name == "PlayerNetInput":
|
||||||
|
return child
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Local movement (standalone / no-server fallback)
|
# 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:
|
func _move_local(input_dir: Vector3, delta: float, jump: bool) -> void:
|
||||||
# Simple CharacterBody3D movement for standalone testing
|
|
||||||
var target_speed: float = local_walk_speed
|
var target_speed: float = local_walk_speed
|
||||||
if _sprint_active:
|
if _sprint_active:
|
||||||
target_speed = local_sprint_speed
|
target_speed = local_sprint_speed
|
||||||
@@ -369,7 +394,7 @@ func _move_local(input_dir: Vector3, delta: float, jump: bool) -> void:
|
|||||||
|
|
||||||
func _update_crouch(amount: float) -> void:
|
func _update_crouch(amount: float) -> void:
|
||||||
## amount: 0.0 = standing, 1.0 = fully crouched
|
## amount: 0.0 = standing, 1.0 = fully crouched
|
||||||
# Eye height — camera position relative to body origin (character feet at origin)
|
# Eye height — camera position relative to body origin
|
||||||
if _camera:
|
if _camera:
|
||||||
_camera.position.y = lerpf(eye_height_stand, eye_height_crouch, amount)
|
_camera.position.y = lerpf(eye_height_stand, eye_height_crouch, amount)
|
||||||
|
|
||||||
@@ -377,8 +402,7 @@ func _update_crouch(amount: float) -> void:
|
|||||||
if _capsule_shape:
|
if _capsule_shape:
|
||||||
_capsule_shape.height = lerpf(_stand_capsule_height, _crouch_capsule_height, amount)
|
_capsule_shape.height = lerpf(_stand_capsule_height, _crouch_capsule_height, amount)
|
||||||
|
|
||||||
# Move the collision shape center so the capsule base stays on the ground:
|
# 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:
|
if _collision_shape:
|
||||||
_collision_shape.position.y = lerpf(0.0, (collision_height_crouch - collision_height_stand) * 0.5, amount)
|
_collision_shape.position.y = lerpf(0.0, (collision_height_crouch - collision_height_stand) * 0.5, amount)
|
||||||
|
|
||||||
@@ -397,7 +421,6 @@ func _get_move_direction() -> Vector3:
|
|||||||
dir.x -= 1.0
|
dir.x -= 1.0
|
||||||
if Input.is_action_pressed(&"move_right"):
|
if Input.is_action_pressed(&"move_right"):
|
||||||
dir.x += 1.0
|
dir.x += 1.0
|
||||||
# Normalize for analog stick deadzone
|
|
||||||
if dir.length_squared() > 0.0:
|
if dir.length_squared() > 0.0:
|
||||||
dir = dir.normalized()
|
dir = dir.normalized()
|
||||||
return dir
|
return dir
|
||||||
@@ -414,42 +437,47 @@ func _capture_mouse(capture: bool) -> void:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Public API
|
# Public API — used by PlayerNetInput and external managers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
## Set the entity ID assigned by SimulationServer after spawning.
|
## Mark this as the local player (enables mouse capture and input).
|
||||||
func set_entity_id(id: int) -> void:
|
func _set_local_player(enabled: bool) -> void:
|
||||||
entity_id = id
|
_is_local_player = enabled
|
||||||
|
if enabled:
|
||||||
|
_capture_mouse(true)
|
||||||
|
# TickInterpolator should be disabled for the local player
|
||||||
|
var ti := get_node_or_null("TickInterpolator")
|
||||||
|
if ti != null and ti.has_method(&"set_enabled"):
|
||||||
|
ti.set_enabled(false)
|
||||||
|
elif ti != null:
|
||||||
|
ti.enabled = false
|
||||||
|
|
||||||
|
|
||||||
## Set the prediction controller for client-side prediction/reconciliation.
|
## Get current yaw (radians) — for PlayerNetInput sync.
|
||||||
## The prediction node must have on_before_tick() / on_after_tick() methods.
|
func get_current_yaw() -> float:
|
||||||
## Typically a ClientPrediction instance added as a child or sibling.
|
return _yaw
|
||||||
func set_prediction(prediction_node: Node) -> void:
|
|
||||||
_prediction = prediction_node
|
|
||||||
|
|
||||||
|
## Get current pitch (radians) — for PlayerNetInput sync.
|
||||||
|
func get_current_pitch() -> float:
|
||||||
|
return _pitch
|
||||||
|
|
||||||
## Get current crouch amount (0.0 = standing, 1.0 = fully crouched).
|
## Get current crouch amount (0.0 = standing, 1.0 = fully crouched).
|
||||||
func get_crouch_amount() -> float:
|
func get_crouch_amount() -> float:
|
||||||
return _crouch_current
|
return _crouch_current
|
||||||
|
|
||||||
|
|
||||||
## Is the player currently sprinting?
|
## Is the player currently sprinting?
|
||||||
func is_sprinting() -> bool:
|
func is_sprinting() -> bool:
|
||||||
return _sprint_active
|
return _sprint_active
|
||||||
|
|
||||||
|
|
||||||
## Is the player currently crouching?
|
## Is the player currently crouching?
|
||||||
func is_crouching() -> bool:
|
func is_crouching() -> bool:
|
||||||
return _crouch_active
|
return _crouch_active
|
||||||
|
|
||||||
|
|
||||||
## Force look direction (useful for spectator / spawn reset).
|
## Force look direction (useful for spectator / spawn reset).
|
||||||
func set_look(yaw_rad: float, pitch_rad: float) -> void:
|
func set_look(yaw_rad: float, pitch_rad: float) -> void:
|
||||||
_yaw = yaw_rad
|
_yaw = yaw_rad
|
||||||
_pitch = clamp(pitch_rad, deg_to_rad(-max_pitch), deg_to_rad(max_pitch))
|
_pitch = clamp(pitch_rad, deg_to_rad(-max_pitch), deg_to_rad(max_pitch))
|
||||||
|
|
||||||
|
|
||||||
## Reset to default standing state.
|
## Reset to default standing state.
|
||||||
func reset_pose() -> void:
|
func reset_pose() -> void:
|
||||||
_sprint_active = false
|
_sprint_active = false
|
||||||
@@ -459,15 +487,3 @@ func reset_pose() -> void:
|
|||||||
_crouch_current = 0.0
|
_crouch_current = 0.0
|
||||||
_crouch_target = 0.0
|
_crouch_target = 0.0
|
||||||
_update_crouch(0.0)
|
_update_crouch(0.0)
|
||||||
|
|
||||||
|
|
||||||
## Get the look direction as a normalized Vector3 in world space.
|
|
||||||
## Uses the current yaw/pitch to compute a forward vector.
|
|
||||||
func get_look_direction() -> Vector3:
|
|
||||||
var yaw_rad: float = _yaw
|
|
||||||
var pitch_rad: float = _pitch
|
|
||||||
return Vector3(
|
|
||||||
cos(pitch_rad) * sin(yaw_rad),
|
|
||||||
-sin(pitch_rad),
|
|
||||||
cos(pitch_rad) * cos(yaw_rad)
|
|
||||||
).normalized()
|
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ Task: P7.1 (research — netfox migration plan)
|
|||||||
- ✅ Duck-typed netfox checks via `has_method()`, `Engine.has_singleton()`
|
- ✅ Duck-typed netfox checks via `has_method()`, `Engine.has_singleton()`
|
||||||
|
|
||||||
**REMAINING:**
|
**REMAINING:**
|
||||||
- No `TickInterpolator` child for smooth interpolation → P7.5
|
- ✅ `TickInterpolator` child for smooth interpolation (dynamically created in `_setup_tick_interpolator()`)
|
||||||
|
- ✅ `TickInterpolator` on remote player instances for visual rollback
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -130,7 +131,7 @@ Task: P7.1 (research — netfox migration plan)
|
|||||||
|
|
||||||
**REMAINING:**
|
**REMAINING:**
|
||||||
- Creates full Player nodes with RollbackSynchronizer for each remote player (✅ correct pattern)
|
- Creates full Player nodes with RollbackSynchronizer for each remote player (✅ correct pattern)
|
||||||
- May want TickInterpolator on remote player instances later (P7.5)
|
- ✅ `TickInterpolator` on remote player instances (dynamically created in `_setup_tick_interpolator()`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -174,11 +175,11 @@ Task: P7.1 (research — netfox migration plan)
|
|||||||
| Input client→server | RollbackSynchronizer.input_properties | ✅ |
|
| Input client→server | RollbackSynchronizer.input_properties | ✅ |
|
||||||
| Tick-driven movement | `_rollback_tick(delta, tick, is_input)` | ✅ |
|
| Tick-driven movement | `_rollback_tick(delta, tick, is_input)` | ✅ |
|
||||||
| Client prediction | `enable_prediction = true` | ✅ |
|
| Client prediction | `enable_prediction = true` | ✅ |
|
||||||
| Smooth interpolation | TickInterpolator | ❌ P7.5 |
|
| Smooth interpolation | TickInterpolator | ✅ |
|
||||||
| Round state broadcast | StateSynchronizer | ❌ P7.7 |
|
| Round state broadcast | StateSynchronizer | ❌ P7.7 |
|
||||||
| Rollback-safe weapon fire | RewindableAction | ❌ P7.6 |
|
| Rollback-safe weapon fire | RewindableAction | ❌ P7.6 |
|
||||||
| Lag comp / hitscan | RewindableAction + mutation | ❌ P7.6 |
|
| Lag comp / hitscan | RewindableAction + mutation | ❌ P7.6 |
|
||||||
| Visual rollback | TickInterpolator + _rollback_tick | ❌ P7.5 |
|
| Visual rollback | TickInterpolator + _rollback_tick | ✅ |
|
||||||
| Lifecycle signals | NetworkEvents | ✅ |
|
| Lifecycle signals | NetworkEvents | ✅ |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -200,7 +201,7 @@ All access guarded — no class_name references in our code.
|
|||||||
|
|
||||||
```
|
```
|
||||||
P7.6 (RewindableAction) ── needs ── RollbackSynchronizer working (✅ DONE)
|
P7.6 (RewindableAction) ── needs ── RollbackSynchronizer working (✅ DONE)
|
||||||
P7.5 (TickInterpolator) ── needs ── RollbackSynchronizer working (✅ DONE)
|
P7.5 (TickInterpolator) ── needs ── RollbackSynchronizer working (✅ DONE)── [[DONE]] ✅
|
||||||
P7.7 (Round state netfox) ── needs ── NetworkEvents working (✅ DONE)
|
P7.7 (Round state netfox) ── needs ── NetworkEvents working (✅ DONE)
|
||||||
P7.8 (FPSController refactor) ── needs ── P7.6 input flow clarified
|
P7.8 (FPSController refactor) ── needs ── P7.6 input flow clarified
|
||||||
P7.9 (LAN test + deploy) ── needs ── all of the above
|
P7.9 (LAN test + deploy) ── needs ── all of the above
|
||||||
@@ -238,7 +239,7 @@ P7.9 (LAN test + deploy) ── needs ── all of the above
|
|||||||
| File | Lines | Netfox Status | RPCs | Signals | Changes Needed |
|
| File | Lines | Netfox Status | RPCs | Signals | Changes Needed |
|
||||||
|------|-------|---------------|------|---------|----------------|
|
|------|-------|---------------|------|---------|----------------|
|
||||||
| network_manager.gd | 245 | ✅ overlay wired | 6 ❌ | 9 ✅ | Remove 6 RPCs post-P7.7 |
|
| network_manager.gd | 245 | ✅ overlay wired | 6 ❌ | 9 ✅ | Remove 6 RPCs post-P7.7 |
|
||||||
| player.gd | 251 | ✅ rollback + tick | 0 | 0 | Add TickInterpolator P7.5 |
|
| player.gd | 251 | ✅ rollback + tick + interpolator | 0 | 0 | ✅ |
|
||||||
| player_net_input.gd | 91 | ✅ tick gather | 0 | 0 | None atm |
|
| player_net_input.gd | 91 | ✅ tick gather | 0 | 0 | None atm |
|
||||||
| server_main.gd | 342 | ✅ thin layer | 7 call sites ❌ | 2 | Migrate to StateSynchronizer P7.7 |
|
| server_main.gd | 342 | ✅ thin layer | 7 call sites ❌ | 2 | Migrate to StateSynchronizer P7.7 |
|
||||||
| client_main.gd | 124 | ✅ thin layer | 0 | 2 signal connects | None atm |
|
| client_main.gd | 124 | ✅ thin layer | 0 | 2 signal connects | None atm |
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ dedicated_server=true
|
|||||||
custom_features=""
|
custom_features=""
|
||||||
export_filter="all_resources"
|
export_filter="all_resources"
|
||||||
include_filter=""
|
include_filter=""
|
||||||
exclude_filter=""
|
exclude_filter="client/**, gdextension.disabled/**, server/server-browser-api/venv/**, **/venv/**"
|
||||||
export_path="build/tactical-shooter-server.x86_64"
|
export_path="build/tactical-shooter-server.x86_64"
|
||||||
patches=PackedStringArray()
|
patches=PackedStringArray()
|
||||||
encryption_include_filters=""
|
encryption_include_filters=""
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[gd_scene format=3 uid="uid://player"]
|
[gd_scene format=3 uid="uid://player"]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://scripts/network/player.gd" id="1_player"]
|
[ext_resource type="Script" path="res://scripts/network/player.gd" id="1_player"]
|
||||||
[ext_resource type="Script" path="res://client/characters/character/fps_camera.gd" id="2_fps_camera"]
|
[ext_resource type="Script" path="res://scripts/characters/fps_camera.gd" id="2_fps_camera"]
|
||||||
[ext_resource type="Script" path="res://scripts/network/player_net_input.gd" id="3_player_net_input"]
|
[ext_resource type="Script" path="res://scripts/network/player_net_input.gd" id="3_player_net_input"]
|
||||||
|
|
||||||
[sub_resource type="CapsuleShape3D" id="1"]
|
[sub_resource type="CapsuleShape3D" id="1"]
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
## 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 (duck-typed — uses has_method instead of class_name).
|
||||||
|
var _controller: Node = 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 (used as both current value and interpolation target).
|
||||||
|
var _sway_current: Vector2 = Vector2.ZERO
|
||||||
|
var _sway_target: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
|
## Base eye Y set by parent controller's crouch logic (for additive bob).
|
||||||
|
var _base_eye_y: float = 0.0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_controller = get_parent()
|
||||||
|
if _controller == null or not _controller.has_method(&"is_sprinting"):
|
||||||
|
push_warning("FpsCamera: Parent has no is_sprinting() — 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
|
||||||
|
|
||||||
|
# Capture the eye height set by parent controller's crouch logic
|
||||||
|
# before we apply view bob offsets (which must be additive).
|
||||||
|
_base_eye_y = position.y
|
||||||
|
|
||||||
|
# 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 — sustained while sprinting (not just on transition)
|
||||||
|
if _controller.is_sprinting():
|
||||||
|
target_offset = sprint_fov_kick
|
||||||
|
|
||||||
|
# Shoot FOV kick is triggered externally via trigger_shoot_fov()
|
||||||
|
# and decays toward the sprint/walk target naturally via move_toward.
|
||||||
|
_current_fov_offset = move_toward(_current_fov_offset, target_offset, fov_recovery_speed * delta)
|
||||||
|
fov = _base_fov + _current_fov_offset
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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
|
||||||
|
# Bob is additive on Y so it does NOT overwrite the crouch eye height
|
||||||
|
# set by the parent FPSCharacterController._update_crouch().
|
||||||
|
position.y = _base_eye_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 := _controller.velocity as Vector3
|
||||||
|
var sway_h: float = clamp(vel.x * 0.003, -sway_max_rotation, sway_max_rotation)
|
||||||
|
var sway_v: float = 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 @@
|
|||||||
|
uid://b6rixkmc2v0lt
|
||||||
@@ -5,9 +5,9 @@
|
|||||||
## via the class-level constants (RIFLE, PISTOL, SHOTGUN, SMG).
|
## via the class-level constants (RIFLE, PISTOL, SHOTGUN, SMG).
|
||||||
##
|
##
|
||||||
## Usage:
|
## Usage:
|
||||||
## var data: WeaponData = WeaponData.RIFLE
|
## var wd = preload("res://scripts/combat/weapon_data.gd")
|
||||||
## print(data.display_name) # "Assault Rifle"
|
## var data = wd.make("rifle", "Assault Rifle", 30.0, 10.0, 30, ...)
|
||||||
##
|
|
||||||
extends Resource
|
extends Resource
|
||||||
class_name WeaponData
|
class_name WeaponData
|
||||||
|
|
||||||
@@ -17,89 +17,52 @@ class_name WeaponData
|
|||||||
|
|
||||||
## Unique identifier string (e.g., "rifle", "pistol").
|
## Unique identifier string (e.g., "rifle", "pistol").
|
||||||
@export var weapon_id: String = ""
|
@export var weapon_id: String = ""
|
||||||
|
|
||||||
## Human-readable weapon name.
|
## Human-readable weapon name.
|
||||||
@export var display_name: String = ""
|
@export var display_name: String = ""
|
||||||
|
|
||||||
## Base damage per projectile/pellet.
|
## Base damage per projectile/pellet.
|
||||||
@export var damage: float = 0.0
|
@export var damage: float = 0.0
|
||||||
|
|
||||||
## Rate of fire in rounds per second (Hz).
|
## Rate of fire in rounds per second (Hz).
|
||||||
@export var fire_rate: float = 0.0
|
@export var fire_rate: float = 0.0
|
||||||
|
|
||||||
## Magazine capacity (max ammo per reload).
|
## Magazine capacity (max ammo per reload).
|
||||||
@export var mag_size: int = 0
|
@export var mag_size: int = 0
|
||||||
|
|
||||||
## Reload duration in seconds.
|
## Reload duration in seconds.
|
||||||
@export var reload_time: float = 0.0
|
@export var reload_time: float = 0.0
|
||||||
|
|
||||||
## If true, hold to fire continuously. If false, single-shot per press.
|
## If true, hold to fire continuously. If false, single-shot per press.
|
||||||
@export var is_automatic: bool = false
|
@export var is_automatic: bool = false
|
||||||
|
|
||||||
## Base weapon spread in degrees (used for accuracy cone).
|
## Base weapon spread in degrees (used for accuracy cone).
|
||||||
@export var spread_degrees: float = 0.0
|
@export var spread_degrees: float = 0.0
|
||||||
|
|
||||||
## Effective range in Godot units (metres conceptual).
|
## Effective range in Godot units (metres conceptual).
|
||||||
@export var range: float = 0.0
|
@export var range: float = 0.0
|
||||||
|
|
||||||
## Number of pellets/projectiles per shot (1 for most weapons, 8 for shotgun).
|
## Number of pellets/projectiles per shot (1 for most weapons, 8 for shotgun).
|
||||||
@export var pellets_per_shot: int = 1
|
@export var pellets_per_shot: int = 1
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Pre-configured weapon instances (class-level constants)
|
# Factory
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
## Assault Rifle — 30 dmg, 10 rps, 30 mag, 2.1s reload, automatic, 0.5° spread, 200m range.
|
## Construct a WeaponData instance from raw stat values.
|
||||||
const RIFLE: Resource = pref(
|
## Avoids class_name references in return types for headless compatibility.
|
||||||
"rifle", "Assault Rifle",
|
static func make(
|
||||||
30.0, 10.0, 30, 2.1, true, 0.5, 200.0, 1
|
_weapon_id: String,
|
||||||
)
|
_display_name: String,
|
||||||
|
_damage: float,
|
||||||
## Pistol — 25 dmg, 4 rps, 12 mag, 1.5s reload, semi-auto, 0.3° spread, 80m range.
|
_fire_rate: float,
|
||||||
const PISTOL: Resource = pref(
|
_mag_size: int,
|
||||||
"pistol", "Pistol",
|
_reload_time: float,
|
||||||
25.0, 4.0, 12, 1.5, false, 0.3, 80.0, 1
|
_is_automatic: bool,
|
||||||
)
|
_spread_degrees: float,
|
||||||
|
_range: float,
|
||||||
## Shotgun — 8 dmg × 8 pellets, 1 rps, 8 mag, 3.0s reload, semi-auto, 3° spread, 30m range.
|
_pellets: int = 1
|
||||||
const SHOTGUN: Resource = pref(
|
):
|
||||||
"shotgun", "Shotgun",
|
var w = WeaponData.new()
|
||||||
8.0, 1.0, 8, 3.0, false, 3.0, 30.0, 8
|
w.weapon_id = _weapon_id
|
||||||
)
|
w.display_name = _display_name
|
||||||
|
w.damage = _damage
|
||||||
## SMG — 18 dmg, 12 rps, 25 mag, 1.8s reload, automatic, 1.5° spread, 100m range.
|
w.fire_rate = _fire_rate
|
||||||
const SMG: Resource = pref(
|
w.mag_size = _mag_size
|
||||||
"smg", "Submachine Gun",
|
w.reload_time = _reload_time
|
||||||
18.0, 12.0, 25, 1.8, true, 1.5, 100.0, 1
|
w.is_automatic = _is_automatic
|
||||||
)
|
w.spread_degrees = _spread_degrees
|
||||||
|
w.range = _range
|
||||||
# ---------------------------------------------------------------------------
|
w.pellets_per_shot = _pellets
|
||||||
# Factory helper
|
return w
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
## Internal helper that constructs a WeaponData instance from raw values.
|
|
||||||
## This avoids repeating the constructor boilerplate for every constant.
|
|
||||||
static func pref(
|
|
||||||
_weapon_id: String,
|
|
||||||
_display_name: String,
|
|
||||||
_damage: float,
|
|
||||||
_fire_rate: float,
|
|
||||||
_mag_size: int,
|
|
||||||
_reload_time: float,
|
|
||||||
_is_automatic: bool,
|
|
||||||
_spread_degrees: float,
|
|
||||||
_range: float,
|
|
||||||
_pellets: int = 1
|
|
||||||
) -> WeaponData:
|
|
||||||
var w := WeaponData.new()
|
|
||||||
w.weapon_id = _weapon_id
|
|
||||||
w.display_name = _display_name
|
|
||||||
w.damage = _damage
|
|
||||||
w.fire_rate = _fire_rate
|
|
||||||
w.mag_size = _mag_size
|
|
||||||
w.reload_time = _reload_time
|
|
||||||
w.is_automatic = _is_automatic
|
|
||||||
w.spread_degrees = _spread_degrees
|
|
||||||
w.range = _range
|
|
||||||
w.pellets_per_shot = _pellets
|
|
||||||
return w
|
|
||||||
|
|||||||
@@ -12,23 +12,38 @@
|
|||||||
extends RefCounted
|
extends RefCounted
|
||||||
class_name WeaponDefinitions
|
class_name WeaponDefinitions
|
||||||
|
|
||||||
|
# Preload WeaponData script directly (no class_name dependency — the server
|
||||||
|
# weapon_data.gd intentionally avoids class_name to avoid conflicts with
|
||||||
|
# client/weapons/data/weapon_data.gd which also registers WeaponData).
|
||||||
|
const _WD = preload("res://scripts/combat/weapon_data.gd")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Weapon Registry
|
# Weapon Registry — lazily initialised to avoid const + .new() errors
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Whether the WEAPONS dictionary has been populated.
|
||||||
|
static var _initialized: bool = false
|
||||||
|
|
||||||
## All weapons indexed by weapon_id — the single source of truth for the
|
## All weapons indexed by weapon_id — the single source of truth for the
|
||||||
## game's starter arsenal. Add new weapons here and in WeaponData.
|
## game's starter arsenal. Populated on first access via _ensure_init().
|
||||||
const WEAPONS: Dictionary = {
|
static var WEAPONS: Dictionary = {}
|
||||||
"rifle": WeaponData.RIFLE,
|
|
||||||
"pistol": WeaponData.PISTOL,
|
static func _ensure_init() -> void:
|
||||||
"shotgun": WeaponData.SHOTGUN,
|
if _initialized:
|
||||||
"smg": WeaponData.SMG,
|
return
|
||||||
}
|
_initialized = true
|
||||||
|
WEAPONS = {
|
||||||
|
"rifle": _WD.make("rifle", "Assault Rifle", 30.0, 10.0, 30, 2.1, true, 0.5, 200.0, 1),
|
||||||
|
"pistol": _WD.make("pistol", "Pistol", 25.0, 4.0, 12, 1.5, false, 0.3, 80.0, 1),
|
||||||
|
"shotgun": _WD.make("shotgun","Shotgun", 8.0, 1.0, 8, 3.0, false, 3.0, 30.0, 8),
|
||||||
|
"smg": _WD.make("smg", "Submachine Gun", 18.0, 12.0, 25, 1.8, true, 1.5, 100.0, 1),
|
||||||
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Lookup
|
# Lookup
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
## Return the WeaponData for the given weapon_id, or null if unknown.
|
## Return the WeaponData for the given weapon_id, or null if unknown.
|
||||||
static func get_weapon(id: String) -> WeaponData:
|
static func get_weapon(id: String):
|
||||||
return WEAPONS.get(id, null) as WeaponData
|
_ensure_init()
|
||||||
|
return WEAPONS.get(id, null)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ extends Node3D
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@export var server_host: String = "68.202.6.107"
|
@export var server_host: String = "68.202.6.107"
|
||||||
@export var server_port: int = 34197
|
@export var server_port: int = 34197
|
||||||
@export var fps_scene: PackedScene = preload("res://client/template/player_character.tscn")
|
@export var fps_scene: PackedScene = preload("res://scenes/player.tscn")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# State
|
# State
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ func broadcast_despawn_player(peer_id: int) -> void:
|
|||||||
## Client → Server: send raw input for the given local tick.
|
## Client → Server: send raw input for the given local tick.
|
||||||
## Called by ClientPrediction.on_after_tick() each physics tick.
|
## Called by ClientPrediction.on_after_tick() each physics tick.
|
||||||
## Uses ENet channel 0 (unreliable-ordered) for lowest-latency input delivery.
|
## Uses ENet channel 0 (unreliable-ordered) for lowest-latency input delivery.
|
||||||
@rpc("unreliable", "any_peer", "call_remote", Chan.INPUT)
|
@rpc("unreliable", "any_peer", "call_remote", 0)
|
||||||
func send_client_input(tick: int, input_dict: Dictionary) -> void:
|
func send_client_input(tick: int, input_dict: Dictionary) -> void:
|
||||||
if not multiplayer.is_server():
|
if not multiplayer.is_server():
|
||||||
return
|
return
|
||||||
@@ -245,7 +245,7 @@ func send_client_input(tick: int, input_dict: Dictionary) -> void:
|
|||||||
## Called by server-side code (e.g. GameServer after each tick).
|
## Called by server-side code (e.g. GameServer after each tick).
|
||||||
## entity_id identifies which simulation entity this state belongs to.
|
## entity_id identifies which simulation entity this state belongs to.
|
||||||
## Uses ENet channel 1 (reliable-ordered) so state corrections are not dropped.
|
## Uses ENet channel 1 (reliable-ordered) so state corrections are not dropped.
|
||||||
@rpc("unreliable", "authority", "call_remote", Chan.EVENTS)
|
@rpc("unreliable", "authority", "call_remote", 1)
|
||||||
func send_server_state(entity_id: int, snapshot_dict: Dictionary) -> void:
|
func send_server_state(entity_id: int, snapshot_dict: Dictionary) -> void:
|
||||||
if multiplayer.is_server():
|
if multiplayer.is_server():
|
||||||
return
|
return
|
||||||
|
|||||||
+144
-119
@@ -1,20 +1,13 @@
|
|||||||
## Player — netfox rollback-aware player extending FPSCharacterController
|
## Player — netfox rollback-aware player with client-prediction + rollback.
|
||||||
## with rollback-safe hitscan weapon system and lag compensation.
|
|
||||||
##
|
##
|
||||||
## Inherits from FPSCharacterController (CharacterBody3D) for full FPS movement
|
## Extends CharacterBody3D directly for full FPS movement with acceleration,
|
||||||
## with acceleration, slopes, crouch, sprint, and jump.
|
## slopes, crouch, sprint, and jump.
|
||||||
##
|
##
|
||||||
## netfox integration:
|
## netfox integration:
|
||||||
## - RollbackSynchronizer for deterministic state sync
|
## - RollbackSynchronizer for deterministic state sync
|
||||||
## - Position state synced via state_properties
|
## - Position state synced via state_properties
|
||||||
## - Input gathered via PlayerNetInput child (_gather())
|
## - Input gathered via PlayerNetInput child (_gather())
|
||||||
## - TickInterpolator for smooth remote player visuals
|
## - TickInterpolator for smooth remote player visuals
|
||||||
## - RollbackHitscanManager for RewindableAction-based hitscan firing
|
|
||||||
##
|
|
||||||
## Weapons use RewindableAction for rollback-safe firing with:
|
|
||||||
## - Server-authoritative hit detection with lag compensation
|
|
||||||
## - Per-limb damage (head/chest/waist/legs) via collision groups
|
|
||||||
## - Physics rewound to the client's input tick for accurate raycasts
|
|
||||||
##
|
##
|
||||||
## Team & Round integration:
|
## Team & Round integration:
|
||||||
## team_id: 0 = Team A, 1 = Team B
|
## team_id: 0 = Team A, 1 = Team B
|
||||||
@@ -26,29 +19,21 @@ extends CharacterBody3D
|
|||||||
# Exports
|
# Exports
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
## Walk speed in units/sec (used inside FPSCharacterController movement).
|
## Walk speed in units/sec (used inside _rollback_tick / _physics_process).
|
||||||
@export var movement_speed: float = 5.0
|
@export var movement_speed: float = 5.0
|
||||||
|
|
||||||
# --- Standalone movement parameters (used when netfox is not active) ---
|
# --- Standalone movement parameters (used when netfox is not active) ---
|
||||||
## Walk speed (standalone mode / single-player).
|
|
||||||
@export var local_walk_speed: float = 5.0
|
@export var local_walk_speed: float = 5.0
|
||||||
## Sprint speed (standalone mode).
|
|
||||||
@export var local_sprint_speed: float = 8.0
|
@export var local_sprint_speed: float = 8.0
|
||||||
## Crouch speed (standalone mode).
|
|
||||||
@export var local_crouch_speed: float = 2.5
|
@export var local_crouch_speed: float = 2.5
|
||||||
## Jump velocity (standalone mode).
|
|
||||||
@export var local_jump_velocity: float = 4.5
|
@export var local_jump_velocity: float = 4.5
|
||||||
## Gravity (standalone mode).
|
|
||||||
@export var local_gravity: float = 15.0
|
@export var local_gravity: float = 15.0
|
||||||
## Acceleration (standalone mode).
|
|
||||||
@export var local_acceleration: float = 12.0
|
@export var local_acceleration: float = 12.0
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Internal look state (for PlayerNetInput yaw/pitch getters)
|
# Internal look state (for PlayerNetInput yaw/pitch getters)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
## Current yaw (radians) — read by PlayerNetInput._gather()
|
|
||||||
var _yaw: float = 0.0
|
var _yaw: float = 0.0
|
||||||
## Current pitch (radians) — read by PlayerNetInput._gather()
|
|
||||||
var _pitch: float = 0.0
|
var _pitch: float = 0.0
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -83,18 +68,14 @@ var _cached_peer_id: int = -1
|
|||||||
|
|
||||||
## RollbackSynchronizer for deterministic state sync.
|
## RollbackSynchronizer for deterministic state sync.
|
||||||
var _rollback_sync: Node = null
|
var _rollback_sync: Node = null
|
||||||
## RollbackHitscanManager for RewindableAction-based hitscan firing.
|
|
||||||
var _hitscan_mgr: Node = null
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Look state getters (for PlayerNetInput._gather())
|
# Look state getters (for PlayerNetInput._gather())
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
## Get current yaw (radians) — for PlayerNetInput sync.
|
|
||||||
func get_current_yaw() -> float:
|
func get_current_yaw() -> float:
|
||||||
return _yaw
|
return _yaw
|
||||||
|
|
||||||
## Get current pitch (radians) — for PlayerNetInput sync.
|
|
||||||
func get_current_pitch() -> float:
|
func get_current_pitch() -> float:
|
||||||
return _pitch
|
return _pitch
|
||||||
|
|
||||||
@@ -103,8 +84,7 @@ func get_current_pitch() -> float:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
# Determine local player status BEFORE calling super._ready
|
# Determine local player status
|
||||||
# (FPSCharacterController._ready sets up _is_local_player based on authority)
|
|
||||||
is_local = multiplayer.get_unique_id() == multiplayer.get_multiplayer_authority()
|
is_local = multiplayer.get_unique_id() == multiplayer.get_multiplayer_authority()
|
||||||
|
|
||||||
if is_local:
|
if is_local:
|
||||||
@@ -115,49 +95,128 @@ func _ready() -> void:
|
|||||||
# Ensure PlayerNetInput child exists (belt + suspenders with scene)
|
# Ensure PlayerNetInput child exists (belt + suspenders with scene)
|
||||||
_ensure_input_node()
|
_ensure_input_node()
|
||||||
|
|
||||||
# Call parent _ready (FPSCharacterController) — initializes camera, collision, mouse
|
|
||||||
super._ready()
|
|
||||||
|
|
||||||
# Override _is_local_player to be consistent with our logic
|
|
||||||
_is_local_player = is_local
|
|
||||||
|
|
||||||
# Set up RollbackSynchronizer
|
# Set up RollbackSynchronizer
|
||||||
_setup_rollback_synchronizer()
|
var has_rollback := _setup_rollback_synchronizer()
|
||||||
|
|
||||||
# Set up TickInterpolator for remote players
|
# Set up TickInterpolator for remote players
|
||||||
_setup_tick_interpolator()
|
_setup_tick_interpolator()
|
||||||
|
|
||||||
# Set up RollbackHitscanManager for rollback-safe weapon firing
|
if has_rollback:
|
||||||
_setup_hitscan_manager()
|
# RollbackSynchronizer handles ticks — disable standard processing
|
||||||
|
if not is_local:
|
||||||
|
set_process(false)
|
||||||
|
set_physics_process(false)
|
||||||
|
print("[Player] Remote player: %d (authority: %d) — rollback sync" % [
|
||||||
|
multiplayer.get_unique_id(), multiplayer.get_multiplayer_authority()])
|
||||||
|
else:
|
||||||
|
# No RollbackSynchronizer (headless/server mode or exported build).
|
||||||
|
# Enable _physics_process as fallback simulation.
|
||||||
|
set_physics_process(true)
|
||||||
|
# Enable _input for mouse look when this is the local player
|
||||||
|
set_process_input(is_local)
|
||||||
|
print("[Player] Player %d — rollback not available, using fallback physics" % multiplayer.get_unique_id())
|
||||||
|
|
||||||
# Disable physics processing if remote (RollbackSynchronizer handles ticks)
|
# ---------------------------------------------------------------------------
|
||||||
if not is_local:
|
# Input (mouse look) — used only in fallback mode (no rollback sync)
|
||||||
set_process(false)
|
# ---------------------------------------------------------------------------
|
||||||
set_physics_process(false)
|
|
||||||
print("[Player] Remote player: %d (authority: %d)" % [
|
|
||||||
multiplayer.get_unique_id(), multiplayer.get_multiplayer_authority()])
|
|
||||||
|
|
||||||
# Set local player flag for FPSCharacterController
|
## Mouse look variables for fallback mode.
|
||||||
_set_local_player(is_local)
|
var _mouse_look_sensitivity: float = 0.003
|
||||||
|
var _mouse_look_invert_y: bool = false
|
||||||
|
var _mouse_captured: bool = false
|
||||||
|
|
||||||
|
## Handle mouse input for looking around in fallback mode.
|
||||||
|
## Only active when set_process_input(true) was called in _ready.
|
||||||
|
func _input(event: InputEvent) -> void:
|
||||||
|
if not is_local or _rollback_sync != null:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Mouse look
|
||||||
|
if event is InputEventMouseMotion and _mouse_captured:
|
||||||
|
var rel: Vector2 = event.relative
|
||||||
|
_yaw -= rel.x * _mouse_look_sensitivity
|
||||||
|
var invert: float = -1.0 if _mouse_look_invert_y else 1.0
|
||||||
|
_pitch += rel.y * _mouse_look_sensitivity * invert
|
||||||
|
_pitch = clamp(_pitch, deg_to_rad(-89.0), deg_to_rad(89.0))
|
||||||
|
|
||||||
|
# Click to capture mouse
|
||||||
|
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
|
||||||
|
if not _mouse_captured:
|
||||||
|
_capture_mouse_fallback(true)
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if not is_local or _rollback_sync != null:
|
||||||
|
return
|
||||||
|
# Escape to release mouse
|
||||||
|
if event.is_action_pressed("ui_cancel"):
|
||||||
|
_capture_mouse_fallback(false)
|
||||||
|
|
||||||
|
func _capture_mouse_fallback(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)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fallback physics — used when RollbackSynchronizer is not available
|
||||||
|
# (e.g., headless dedicated server without netfox singletons, or exported builds).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func _physics_process(delta: float) -> void:
|
||||||
|
# Only run fallback if RollbackSynchronizer is NOT handling ticks
|
||||||
|
if _rollback_sync != null:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Gather current frame input into PlayerNetInput (standalone mode)
|
||||||
|
var input_node = _find_input_node()
|
||||||
|
if input_node != null and input_node.has_method(&"_gather"):
|
||||||
|
input_node._gather()
|
||||||
|
|
||||||
|
# Read input from PlayerNetInput (if we're the authority)
|
||||||
|
if input_node == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
var dir: Vector3 = input_node.get(&"move_direction")
|
||||||
|
var yaw: float = input_node.get(&"look_yaw")
|
||||||
|
var sprint: bool = input_node.get(&"sprint")
|
||||||
|
var crouch: bool = input_node.get(&"crouch")
|
||||||
|
|
||||||
|
rotation.y = yaw
|
||||||
|
_yaw = yaw
|
||||||
|
|
||||||
|
if dir != Vector3.ZERO:
|
||||||
|
var wish_dir := (transform.basis * dir).normalized()
|
||||||
|
var target_speed: float = movement_speed
|
||||||
|
if sprint:
|
||||||
|
target_speed *= 1.6
|
||||||
|
if crouch:
|
||||||
|
target_speed *= 0.5
|
||||||
|
position += wish_dir * target_speed * delta
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RollbackSynchronizer setup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
## Create and configure a RollbackSynchronizer child node.
|
## Create and configure a RollbackSynchronizer child node.
|
||||||
func _setup_rollback_synchronizer() -> void:
|
## Returns true if the synchronizer was successfully created.
|
||||||
|
func _setup_rollback_synchronizer() -> bool:
|
||||||
if not Engine.has_singleton(&"NetworkRollback"):
|
if not Engine.has_singleton(&"NetworkRollback"):
|
||||||
push_warning("[Player] netfox NetworkRollback not available — running without rollback sync")
|
push_warning("[Player] netfox NetworkRollback not available — running without rollback sync")
|
||||||
return
|
return false
|
||||||
|
|
||||||
# Check if we already have a RollbackSynchronizer child
|
# Check if we already have a RollbackSynchronizer child
|
||||||
for child in get_children():
|
for child in get_children():
|
||||||
if child.has_method(&"get_state_properties") or child.name == "RollbackSynchronizer":
|
if child.has_method(&"get_state_properties") or child.name == "RollbackSynchronizer":
|
||||||
_rollback_sync = child
|
_rollback_sync = child
|
||||||
print("[Player] Found existing RollbackSynchronizer child")
|
print("[Player] Found existing RollbackSynchronizer child")
|
||||||
return
|
return true
|
||||||
|
|
||||||
var rs = _create_rollback_sync_node()
|
var rs = _create_rollback_sync_node()
|
||||||
if rs == null:
|
if rs == null:
|
||||||
push_warning("[Player] Could not create RollbackSynchronizer — netfox types not available")
|
push_warning("[Player] Could not create RollbackSynchronizer — netfox types not available")
|
||||||
return
|
return false
|
||||||
|
|
||||||
rs.root = self
|
rs.root = self
|
||||||
|
|
||||||
@@ -189,7 +248,7 @@ func _setup_rollback_synchronizer() -> void:
|
|||||||
add_child(rs, true)
|
add_child(rs, true)
|
||||||
_rollback_sync = rs
|
_rollback_sync = rs
|
||||||
print("[Player] RollbackSynchronizer added to player %d" % multiplayer.get_unique_id())
|
print("[Player] RollbackSynchronizer added to player %d" % multiplayer.get_unique_id())
|
||||||
|
return true
|
||||||
|
|
||||||
## Create a RollbackSynchronizer node via preloaded script (avoids class_name issues).
|
## Create a RollbackSynchronizer node via preloaded script (avoids class_name issues).
|
||||||
func _create_rollback_sync_node():
|
func _create_rollback_sync_node():
|
||||||
@@ -200,10 +259,10 @@ func _create_rollback_sync_node():
|
|||||||
rs.name = "RollbackSynchronizer"
|
rs.name = "RollbackSynchronizer"
|
||||||
return rs
|
return rs
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# TickInterpolator setup
|
# TickInterpolator setup
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func _setup_tick_interpolator() -> void:
|
func _setup_tick_interpolator() -> void:
|
||||||
# Only create TickInterpolator if netfox is available (avoids headless parse errors)
|
# Only create TickInterpolator if netfox is available (avoids headless parse errors)
|
||||||
if not Engine.has_singleton(&"NetworkTime"):
|
if not Engine.has_singleton(&"NetworkTime"):
|
||||||
@@ -213,7 +272,6 @@ func _setup_tick_interpolator() -> void:
|
|||||||
if TIScript == null:
|
if TIScript == null:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if we already have one
|
|
||||||
var ti = get_node_or_null("TickInterpolator")
|
var ti = get_node_or_null("TickInterpolator")
|
||||||
if ti == null:
|
if ti == null:
|
||||||
ti = TIScript.new()
|
ti = TIScript.new()
|
||||||
@@ -229,7 +287,6 @@ func _setup_tick_interpolator() -> void:
|
|||||||
ti.enable_recording = false # RollbackSynchronizer pushes state
|
ti.enable_recording = false # RollbackSynchronizer pushes state
|
||||||
print("[Player] TickInterpolator %s (local=%s)" % ["enabled" if ti.enabled else "disabled", is_local])
|
print("[Player] TickInterpolator %s (local=%s)" % ["enabled" if ti.enabled else "disabled", is_local])
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# PlayerNetInput creation
|
# PlayerNetInput creation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -247,111 +304,83 @@ func _ensure_input_node() -> void:
|
|||||||
add_child(pni, true)
|
add_child(pni, true)
|
||||||
print("[Player] Created PlayerNetInput dynamically")
|
print("[Player] Created PlayerNetInput dynamically")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# RollbackHitscanManager setup
|
# Rollback tick — called by RollbackSynchronizer every network tick
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
## Create and configure a RollbackHitscanManager child node for rollback-safe
|
|
||||||
## weapon firing with RewindableAction-based lag compensation.
|
|
||||||
func _setup_hitscan_manager() -> void:
|
|
||||||
if not Engine.has_singleton(&"NetworkRollback"):
|
|
||||||
return # RewindableAction won't work without NetworkRollback
|
|
||||||
|
|
||||||
var HmScript = load("res://scripts/weapons/rollback_hitscan_manager.gd")
|
|
||||||
if HmScript == null:
|
|
||||||
push_warning("[Player] Could not load RollbackHitscanManager — weapons will not fire via rollback")
|
|
||||||
return
|
|
||||||
_hitscan_mgr = HmScript.new()
|
|
||||||
if _hitscan_mgr == null:
|
|
||||||
return
|
|
||||||
_hitscan_mgr.name = "RollbackHitscanManager"
|
|
||||||
add_child(_hitscan_mgr, true)
|
|
||||||
print("[Player] RollbackHitscanManager added to %s" % name)
|
|
||||||
|
|
||||||
# Connect to PlayerNetInput so _gather() can call record_fire()
|
|
||||||
var input_node = _find_input_node()
|
|
||||||
if input_node != null and input_node.has_method(&"get"):
|
|
||||||
input_node.set(&"hitscan_mgr_ref", _hitscan_mgr)
|
|
||||||
print("[Player] Linked RollbackHitscanManager to PlayerNetInput")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Rollback tick — override from FPSCharacterController with authority guard
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Called by RollbackSynchronizer for deterministic state sync.
|
||||||
|
## On the server: processes all inputs authoritatively.
|
||||||
|
## On the client: predicts locally, state corrected by server snapshots.
|
||||||
func _rollback_tick(delta: float, tick: int, is_fresh: bool) -> void:
|
func _rollback_tick(delta: float, tick: int, is_fresh: bool) -> void:
|
||||||
# Only simulate on:
|
if not is_fresh and not _is_rollback_enabled():
|
||||||
# - Server: simulate all players (authoritative movement)
|
|
||||||
# - Client-local: simulate for client-side prediction
|
|
||||||
# - Client-remote: skip — position comes from server snapshot
|
|
||||||
if multiplayer.is_server():
|
|
||||||
super._rollback_tick(delta, tick, is_fresh)
|
|
||||||
elif is_local:
|
|
||||||
super._rollback_tick(delta, tick, is_fresh)
|
|
||||||
else:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
var input_node = _find_input_node()
|
||||||
# Rollback-safe hitscan weapon firing
|
if input_node == null:
|
||||||
# -----------------------------------------------------------------------
|
return
|
||||||
# The RollbackHitscanManager performs a physics raycast at the current
|
|
||||||
# (rewound) tick, providing automatic lag compensation: the world state
|
|
||||||
# matches what the client saw when they pressed fire.
|
|
||||||
#
|
|
||||||
# On the server, per-limb damage is applied. On the client, the signal
|
|
||||||
# emission triggers visual feedback for prediction.
|
|
||||||
if _hitscan_mgr != null and _hitscan_mgr.has_method(&"process_fire"):
|
|
||||||
var input_node = _find_input_node()
|
|
||||||
if input_node != null:
|
|
||||||
var is_server: bool = multiplayer.is_server()
|
|
||||||
_hitscan_mgr.process_fire(tick, is_server, input_node)
|
|
||||||
|
|
||||||
|
var dir: Vector3 = input_node.get(&"move_direction")
|
||||||
|
var yaw: float = input_node.get(&"look_yaw")
|
||||||
|
var sprint: bool = input_node.get(&"sprint")
|
||||||
|
var crouch: bool = input_node.get(&"crouch")
|
||||||
|
|
||||||
|
# Apply yaw rotation
|
||||||
|
rotation.y = yaw
|
||||||
|
_yaw = yaw
|
||||||
|
|
||||||
|
# Apply movement
|
||||||
|
if dir != Vector3.ZERO:
|
||||||
|
var wish_dir := (transform.basis * dir).normalized()
|
||||||
|
var target_speed: float = movement_speed
|
||||||
|
if sprint:
|
||||||
|
target_speed *= 1.6
|
||||||
|
if crouch:
|
||||||
|
target_speed *= 0.5
|
||||||
|
position += wish_dir * target_speed * delta
|
||||||
|
|
||||||
|
func _is_rollback_enabled() -> bool:
|
||||||
|
var nr = _get_netrollback_singleton()
|
||||||
|
if nr == null:
|
||||||
|
return false
|
||||||
|
return nr.get(&"enabled")
|
||||||
|
|
||||||
|
func _get_netrollback_singleton():
|
||||||
|
if Engine.has_singleton(&"NetworkRollback"):
|
||||||
|
return Engine.get_singleton(&"NetworkRollback")
|
||||||
|
return null
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Input node helper
|
# Input node helper
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
## Find the PlayerNetInput child node (overrides FPSCharacterController lookup).
|
|
||||||
func _find_input_node():
|
func _find_input_node():
|
||||||
for child in get_children():
|
for child in get_children():
|
||||||
if child.name == "PlayerNetInput":
|
if child.name == "PlayerNetInput":
|
||||||
return child
|
return child
|
||||||
return null
|
return null
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Snap player to position after rollback reconciliation
|
# Snap player to position after rollback reconciliation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
## Called by the game layer to snap the player to an authoritative position.
|
|
||||||
func snap_to_position(pos: Vector3, yaw: float) -> void:
|
func snap_to_position(pos: Vector3, yaw: float) -> void:
|
||||||
global_position = pos
|
global_position = pos
|
||||||
_yaw = yaw
|
_yaw = yaw
|
||||||
rotation.y = yaw
|
rotation.y = yaw
|
||||||
reset_pose()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Round system: team / life management
|
# Round system: team / life management
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
## Set the player's team. Called by server_main at spawn.
|
|
||||||
func set_team(new_team: int) -> void:
|
func set_team(new_team: int) -> void:
|
||||||
team_id = new_team
|
team_id = new_team
|
||||||
|
|
||||||
## Apply damage from a hitscan hit. Called by RollbackHitscanManager on the
|
|
||||||
## server after a lag-compensated raycast.
|
|
||||||
##
|
|
||||||
## This method is duck-typed (found by has_method) so any player-like node
|
|
||||||
## with collision groups (head/chest/waist/legs) can receive damage.
|
|
||||||
func apply_damage(amount: int, attacker_id: int = -1, weapon_id: int = 0) -> void:
|
func apply_damage(amount: int, attacker_id: int = -1, weapon_id: int = 0) -> void:
|
||||||
if not is_alive:
|
if not is_alive:
|
||||||
return
|
return
|
||||||
print("[Player] %s takes %d damage from peer %d (weapon %d)" % [
|
print("[Player] %s takes %d damage from peer %d (weapon %d)" % [
|
||||||
name, amount, attacker_id, weapon_id])
|
name, amount, attacker_id, weapon_id])
|
||||||
|
|
||||||
## Get the peer ID from node name or multiplayer authority.
|
|
||||||
func _get_peer_id() -> int:
|
func _get_peer_id() -> int:
|
||||||
if _cached_peer_id >= 0:
|
if _cached_peer_id >= 0:
|
||||||
return _cached_peer_id
|
return _cached_peer_id
|
||||||
@@ -365,7 +394,6 @@ func _get_peer_id() -> int:
|
|||||||
_cached_peer_id = multiplayer.get_multiplayer_authority()
|
_cached_peer_id = multiplayer.get_multiplayer_authority()
|
||||||
return _cached_peer_id
|
return _cached_peer_id
|
||||||
|
|
||||||
## Mark the player as dead. Notifies RoundManager.
|
|
||||||
func mark_dead(killer_id: int = 0, weapon: String = "unknown") -> void:
|
func mark_dead(killer_id: int = 0, weapon: String = "unknown") -> void:
|
||||||
if not is_alive:
|
if not is_alive:
|
||||||
return
|
return
|
||||||
@@ -376,18 +404,15 @@ func mark_dead(killer_id: int = 0, weapon: String = "unknown") -> void:
|
|||||||
if round_mgr and round_mgr.has_method(&"on_player_death"):
|
if round_mgr and round_mgr.has_method(&"on_player_death"):
|
||||||
round_mgr.on_player_death(pid, killer_id, weapon)
|
round_mgr.on_player_death(pid, killer_id, weapon)
|
||||||
|
|
||||||
## Mark the player as alive (respawn).
|
|
||||||
func mark_alive() -> void:
|
func mark_alive() -> void:
|
||||||
is_alive = true
|
is_alive = true
|
||||||
is_spectating = false
|
is_spectating = false
|
||||||
spectate_target_id = 0
|
spectate_target_id = 0
|
||||||
|
|
||||||
## Set spectate target for this player.
|
|
||||||
func set_spectate_target(target_id: int) -> void:
|
func set_spectate_target(target_id: int) -> void:
|
||||||
spectate_target_id = target_id
|
spectate_target_id = target_id
|
||||||
is_spectating = target_id > 0 or not is_alive
|
is_spectating = target_id > 0 or not is_alive
|
||||||
|
|
||||||
## Helper — find the RoundManager in the scene tree.
|
|
||||||
func _find_round_manager() -> Node:
|
func _find_round_manager() -> Node:
|
||||||
if Engine.has_singleton(&"RoundManager"):
|
if Engine.has_singleton(&"RoundManager"):
|
||||||
return Engine.get_singleton(&"RoundManager")
|
return Engine.get_singleton(&"RoundManager")
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ signal player_despawned(peer_id: int)
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Exports
|
# Exports
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@export var player_scene: PackedScene = preload("res://scenes/player.tscn")
|
@export var player_scene: PackedScene = preload("res://scenes/server/server_player.tscn")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# State
|
# State
|
||||||
@@ -40,9 +40,11 @@ var _game_server: Node = null
|
|||||||
# Lifecycle
|
# Lifecycle
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
# Wait for ServerConfig to finish (it loads via call_deferred)
|
# Wait for ServerConfig to finish (it loads via call_deferred).
|
||||||
if ServerConfig and ServerConfig.has_signal(&"config_loaded"):
|
# Check if already loaded to avoid racing the signal.
|
||||||
await ServerConfig.config_loaded
|
if ServerConfig and not _server_config_loaded():
|
||||||
|
if ServerConfig.has_signal(&"config_loaded"):
|
||||||
|
await ServerConfig.config_loaded
|
||||||
|
|
||||||
# Config driven — ServerConfig singleton loaded at autoload time.
|
# Config driven — ServerConfig singleton loaded at autoload time.
|
||||||
# Allow port override via env var for backwards compatibility
|
# Allow port override via env var for backwards compatibility
|
||||||
@@ -53,10 +55,21 @@ func _ready() -> void:
|
|||||||
print("[ServerMain] Port overridden by SERVER_PORT env: %d" % effective_port)
|
print("[ServerMain] Port overridden by SERVER_PORT env: %d" % effective_port)
|
||||||
|
|
||||||
# Create GameServer (drives the 128Hz simulation + hit detection)
|
# Create GameServer (drives the 128Hz simulation + hit detection)
|
||||||
_game_server = preload("res://server/scripts/game_server.gd").new()
|
# Use load() instead of preload() so GameServer's dev dependencies
|
||||||
add_child(_game_server)
|
# (weapons, economy, objectives, teams) don't block compilation.
|
||||||
# GameServer registers SimulationServer as a singleton on _ready
|
var GameServerClass = load("res://server/scripts/game_server.gd")
|
||||||
_game_server.start_simulation()
|
if GameServerClass != null and GameServerClass is GDScript:
|
||||||
|
_game_server = GameServerClass.new()
|
||||||
|
if _game_server != null:
|
||||||
|
add_child(_game_server)
|
||||||
|
# GameServer registers SimulationServer as a singleton on _ready
|
||||||
|
_game_server.start_simulation()
|
||||||
|
else:
|
||||||
|
push_warning("[ServerMain] GameServer instantiation failed")
|
||||||
|
else:
|
||||||
|
push_warning("[ServerMain] GameServer class not available — running without simulation server")
|
||||||
|
|
||||||
|
# Check if ServerConfig has finished loading.
|
||||||
|
|
||||||
# Instance the map from the config's map rotation
|
# Instance the map from the config's map rotation
|
||||||
_load_map()
|
_load_map()
|
||||||
@@ -79,8 +92,11 @@ func _ready() -> void:
|
|||||||
print("[ServerMain] Maps: %s" % str(ServerConfig.map_list))
|
print("[ServerMain] Maps: %s" % str(ServerConfig.map_list))
|
||||||
print("[ServerMain] Headless: %s" % (DisplayServer.get_name() == &"headless"))
|
print("[ServerMain] Headless: %s" % (DisplayServer.get_name() == &"headless"))
|
||||||
print("[ServerMain] Spawn pts: Team A=%d, Team B=%d" % [spawn_points_a.size(), spawn_points_b.size()])
|
print("[ServerMain] Spawn pts: Team A=%d, Team B=%d" % [spawn_points_a.size(), spawn_points_b.size()])
|
||||||
var lag_comp_ms: float = 64.0 * 1000.0 / ServerConfig.tick_rate
|
|
||||||
print("[ServerMain] Lag comp: Enabled (64-tick history = %.1fms)" % lag_comp_ms)
|
|
||||||
|
## Check if ServerConfig has finished loading.
|
||||||
|
func _server_config_loaded() -> bool:
|
||||||
|
return ServerConfig and ServerConfig.has_method(&"get_config_path") and not ServerConfig.get_config_path().is_empty()
|
||||||
|
|
||||||
func _exit_tree() -> void:
|
func _exit_tree() -> void:
|
||||||
NetworkManager.stop()
|
NetworkManager.stop()
|
||||||
|
|||||||
@@ -7,9 +7,17 @@ extends Node
|
|||||||
var physics_world = null
|
var physics_world = null
|
||||||
var _history: Dictionary = {} # tick → { entity_id: position }
|
var _history: Dictionary = {} # tick → { entity_id: position }
|
||||||
|
|
||||||
|
## Record the start of a new tick for lag compensation.
|
||||||
|
## Called by GameServer each tick before processing inputs.
|
||||||
|
## Individual player positions are recorded by the game server loop.
|
||||||
|
func record_tick(tick: int) -> void:
|
||||||
|
_history[tick] = {}
|
||||||
|
|
||||||
|
## Record a player's position at a given tick (used during per-entity processing).
|
||||||
func record_position(tick: int, entity_id: int, position: Vector3) -> void:
|
func record_position(tick: int, entity_id: int, position: Vector3) -> void:
|
||||||
_history[tick] = {entity_id: position}
|
if not _history.has(tick):
|
||||||
# Keep only 128 most recent ticks
|
_history[tick] = {}
|
||||||
|
_history[tick][entity_id] = position
|
||||||
if _history.size() > 128:
|
if _history.size() > 128:
|
||||||
var oldest = _history.keys().min()
|
var oldest = _history.keys().min()
|
||||||
if oldest != null:
|
if oldest != null:
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ func buy_request(weapon_id: String) -> void:
|
|||||||
## Process a buy request (also callable directly from server-side code).
|
## Process a buy request (also callable directly from server-side code).
|
||||||
func _process_buy_request(player_id: int, weapon_id: String) -> void:
|
func _process_buy_request(player_id: int, weapon_id: String) -> void:
|
||||||
# ── 1. Validate weapon exists ──
|
# ── 1. Validate weapon exists ──
|
||||||
var weapon_data: WeaponData = WeaponDefinitions.get_weapon(weapon_id)
|
var weapon_data = WeaponDefinitions.get_weapon(weapon_id) # WeaponData — untyped for headless
|
||||||
if weapon_data == null:
|
if weapon_data == null:
|
||||||
purchase_denied.emit(player_id, weapon_id, "unknown_weapon")
|
purchase_denied.emit(player_id, weapon_id, "unknown_weapon")
|
||||||
_send_buy_denied(player_id, "Unknown weapon: \"%s\"" % weapon_id)
|
_send_buy_denied(player_id, "Unknown weapon: \"%s\"" % weapon_id)
|
||||||
@@ -194,7 +194,7 @@ func get_affordable_weapons(player_id: int) -> Array[Dictionary]:
|
|||||||
for wid in EconomyManager.WEAPON_COSTS.keys():
|
for wid in EconomyManager.WEAPON_COSTS.keys():
|
||||||
var cost: int = EconomyManager.WEAPON_COSTS[wid]
|
var cost: int = EconomyManager.WEAPON_COSTS[wid]
|
||||||
if money >= cost:
|
if money >= cost:
|
||||||
var data: WeaponData = WeaponDefinitions.get_weapon(wid)
|
var data = WeaponDefinitions.get_weapon(wid) # WeaponData — untyped for headless
|
||||||
affordable.append({
|
affordable.append({
|
||||||
"weapon_id": wid,
|
"weapon_id": wid,
|
||||||
"display_name": data.display_name if data else wid,
|
"display_name": data.display_name if data else wid,
|
||||||
|
|||||||
@@ -26,17 +26,21 @@ extends Node
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Force class_name script dependencies to load first
|
# Force class_name script dependencies to load first
|
||||||
# (Godot 4 headless mode doesn't resolve global classes in dependency order)
|
# (Godot 4 headless mode doesn't resolve global classes in dependency order)
|
||||||
|
#
|
||||||
|
# NOTE: these are commented out for P7.5 build — they reference development
|
||||||
|
# systems (weapons, economy, objectives, teams) that aren't part of this
|
||||||
|
# phase. When those systems are ready, uncomment the relevant preloads.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
const _ws = preload("res://server/scripts/weapons/weapon_server.gd")
|
#const _ws = preload("res://server/scripts/weapons/weapon_server.gd")
|
||||||
const _lc = preload("res://server/scripts/combat/lag_compensation.gd")
|
#const _lc = preload("res://server/scripts/combat/lag_compensation.gd")
|
||||||
const _dp = preload("res://server/scripts/combat/damage_processor.gd")
|
#const _dp = preload("res://server/scripts/combat/damage_processor.gd")
|
||||||
const _tm = preload("res://scripts/teams/team_manager.gd")
|
#const _tm = preload("res://scripts/teams/team_manager.gd")
|
||||||
const _rm = preload("res://server/scripts/round/round_manager.gd")
|
#const _rm = preload("res://server/scripts/round/round_manager.gd")
|
||||||
const _em = preload("res://server/scripts/economy/economy_manager.gd")
|
#const _em = preload("res://server/scripts/economy/economy_manager.gd")
|
||||||
const _bm = preload("res://server/scripts/economy/buy_menu_handler.gd")
|
#const _bm = preload("res://server/scripts/economy/buy_menu_handler.gd")
|
||||||
const _bo = preload("res://server/scripts/objectives/bomb_objective.gd")
|
#const _bo = preload("res://server/scripts/objectives/bomb_objective.gd")
|
||||||
const _sm = preload("res://scripts/teams/spawn_manager.gd")
|
#const _sm = preload("res://scripts/teams/spawn_manager.gd")
|
||||||
const _td = preload("res://scripts/teams/team_data.gd")
|
#const _td = preload("res://scripts/teams/team_data.gd")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Signals
|
# Signals
|
||||||
@@ -79,6 +83,9 @@ var buy_menu_handler = null
|
|||||||
## BombObjective — server-authoritative bomb plant/defuse logic.
|
## BombObjective — server-authoritative bomb plant/defuse logic.
|
||||||
var bomb_objective = null
|
var bomb_objective = null
|
||||||
|
|
||||||
|
## TeamData reference — cached reference, populated at runtime if needed.
|
||||||
|
var _team_data_ref = null
|
||||||
|
|
||||||
## Current server tick counter, incremented each time tick() is called.
|
## Current server tick counter, incremented each time tick() is called.
|
||||||
var _current_tick: int = 0
|
var _current_tick: int = 0
|
||||||
|
|
||||||
@@ -123,34 +130,34 @@ func _ready() -> void:
|
|||||||
# Create and wire the WeaponServer with LagCompensation and DamageProcessor.
|
# Create and wire the WeaponServer with LagCompensation and DamageProcessor.
|
||||||
# These work alongside the C++ SimulationServer for the GDScript
|
# These work alongside the C++ SimulationServer for the GDScript
|
||||||
# weapon path.
|
# weapon path.
|
||||||
weapon_server = _ws.new()
|
weapon_server = load("res://server/scripts/weapons/weapon_server.gd").new()
|
||||||
weapon_server.physics_world = get_viewport().get_world_3d()
|
weapon_server.physics_world = get_viewport().get_world_3d()
|
||||||
add_child(weapon_server)
|
add_child(weapon_server)
|
||||||
|
|
||||||
lag_compensation = _lc.new()
|
lag_compensation = load("res://server/scripts/combat/lag_compensation.gd").new()
|
||||||
add_child(lag_compensation)
|
add_child(lag_compensation)
|
||||||
|
|
||||||
damage_processor = _dp.new()
|
damage_processor = load("res://server/scripts/combat/damage_processor.gd").new()
|
||||||
add_child(damage_processor)
|
add_child(damage_processor)
|
||||||
|
|
||||||
# --- Round / Match lifecycle ---
|
# --- Round / Match lifecycle ---
|
||||||
round_manager = _rm.new()
|
round_manager = load("res://server/scripts/round/round_manager.gd").new()
|
||||||
add_child(round_manager)
|
add_child(round_manager)
|
||||||
|
|
||||||
# Wire up TeamManager reference (find it in the tree or create it)
|
# Wire up TeamManager reference (find it in the tree or create it)
|
||||||
team_manager = get_node_or_null("/root/TeamManager")
|
team_manager = get_node_or_null("/root/TeamManager")
|
||||||
if not team_manager:
|
if not team_manager:
|
||||||
team_manager = _tm.new()
|
team_manager = load("res://scripts/teams/team_manager.gd").new()
|
||||||
add_child(team_manager)
|
add_child(team_manager)
|
||||||
|
|
||||||
round_manager.team_manager = team_manager
|
round_manager.team_manager = team_manager
|
||||||
round_manager.damage_processor = damage_processor
|
round_manager.damage_processor = damage_processor
|
||||||
|
|
||||||
# --- Economy system ---
|
# --- Economy system ---
|
||||||
economy_manager = _em.new()
|
economy_manager = load("res://server/scripts/economy/economy_manager.gd").new()
|
||||||
add_child(economy_manager)
|
add_child(economy_manager)
|
||||||
|
|
||||||
buy_menu_handler = _bm.new()
|
buy_menu_handler = load("res://server/scripts/economy/buy_menu_handler.gd").new()
|
||||||
add_child(buy_menu_handler)
|
add_child(buy_menu_handler)
|
||||||
buy_menu_handler.initialise(economy_manager, weapon_server, team_manager)
|
buy_menu_handler.initialise(economy_manager, weapon_server, team_manager)
|
||||||
|
|
||||||
@@ -182,7 +189,7 @@ func _ready() -> void:
|
|||||||
Engine.register_singleton("SimulationServer", simulation_server)
|
Engine.register_singleton("SimulationServer", simulation_server)
|
||||||
|
|
||||||
# --- Bomb / Defuse Objective ---
|
# --- Bomb / Defuse Objective ---
|
||||||
bomb_objective = _bo.new()
|
bomb_objective = load("res://server/scripts/objectives/bomb_objective.gd").new()
|
||||||
add_child(bomb_objective)
|
add_child(bomb_objective)
|
||||||
bomb_objective.round_manager = round_manager
|
bomb_objective.round_manager = round_manager
|
||||||
bomb_objective.team_manager = team_manager
|
bomb_objective.team_manager = team_manager
|
||||||
@@ -207,11 +214,11 @@ func _ready() -> void:
|
|||||||
# Wire: bomb explosion/defuse → round end
|
# Wire: bomb explosion/defuse → round end
|
||||||
bomb_objective.bomb_exploded.connect(func(_pos):
|
bomb_objective.bomb_exploded.connect(func(_pos):
|
||||||
if round_manager:
|
if round_manager:
|
||||||
round_manager.end_round(_td.Team.TERRORIST, "bomb_exploded")
|
round_manager.end_round(2, "bomb_exploded") # TeamData.Team.TERRORIST
|
||||||
)
|
)
|
||||||
bomb_objective.bomb_defused.connect(func(_player_id):
|
bomb_objective.bomb_defused.connect(func(_player_id):
|
||||||
if round_manager:
|
if round_manager:
|
||||||
round_manager.end_round(_td.Team.COUNTER_TERRORIST, "bomb_defused")
|
round_manager.end_round(1, "bomb_defused") # TeamData.Team.COUNTER_TERRORIST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Wire: round end → reset bomb
|
# Wire: round end → reset bomb
|
||||||
@@ -394,8 +401,13 @@ func _on_kill_for_round(victim_id: int, shooter_id: int) -> void:
|
|||||||
break
|
break
|
||||||
|
|
||||||
if all_dead and team_player_ids.size() > 0:
|
if all_dead and team_player_ids.size() > 0:
|
||||||
print("[GameServer] Team elimination detected! %s eliminated by %s" %
|
var victim_name: String = "Spectator"
|
||||||
[_td.get_team_name(victim_team), _td.get_team_name(shooter_team)])
|
var shooter_name: String = "Spectator"
|
||||||
|
if victim_team == 1: victim_name = "CT"
|
||||||
|
elif victim_team == 2: victim_name = "T"
|
||||||
|
if shooter_team == 1: shooter_name = "CT"
|
||||||
|
elif shooter_team == 2: shooter_name = "T"
|
||||||
|
print("[GameServer] Team elimination detected! %s eliminated by %s" % [victim_name, shooter_name])
|
||||||
round_manager.end_round(shooter_team, "elimination")
|
round_manager.end_round(shooter_team, "elimination")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -413,13 +425,27 @@ func _on_rcon_command(command: String, args: PackedStringArray) -> void:
|
|||||||
# end_round [team_name] [reason]
|
# end_round [team_name] [reason]
|
||||||
var team_name: String = args[0] if args.size() > 0 else "ct"
|
var team_name: String = args[0] if args.size() > 0 else "ct"
|
||||||
var reason: String = args[1] if args.size() > 1 else "admin"
|
var reason: String = args[1] if args.size() > 1 else "admin"
|
||||||
var team: int = _td.from_string(team_name)
|
var team: int = _parse_team_name(team_name)
|
||||||
if team == _td.Team.SPECTATOR:
|
if team == 0: # TeamData.Team.SPECTATOR
|
||||||
# Default to CT
|
# Default to CT
|
||||||
team = _td.Team.COUNTER_TERRORIST
|
team = 1 # TeamData.Team.COUNTER_TERRORIST
|
||||||
if round_manager:
|
if round_manager:
|
||||||
round_manager.end_round(team, reason)
|
round_manager.end_round(team, reason)
|
||||||
print("[GameServer] RCON: round ended — %s wins (%s)" % [_td.get_team_name(team), reason])
|
team_name = "Spectator"
|
||||||
|
if team == 1: team_name = "CT"
|
||||||
|
elif team == 2: team_name = "T"
|
||||||
|
print("[GameServer] RCON: round ended — %s wins (%s)" % [team_name, reason])
|
||||||
|
|
||||||
|
## Parse a team name string to a team ID (replaces TeamData.from_string for headless compat).
|
||||||
|
## 0 = Spectator, 1 = CT, 2 = T. Defaults to 0 for unknown names.
|
||||||
|
func _parse_team_name(name_str: String) -> int:
|
||||||
|
match name_str.to_lower().strip_edges():
|
||||||
|
"ct", "counter_terrorist", "counter-terrorist", "team_a", "1":
|
||||||
|
return 1
|
||||||
|
"t", "terrorist", "team_b", "2":
|
||||||
|
return 2
|
||||||
|
_:
|
||||||
|
return 0 # Spectator
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Main loop (128 Hz)
|
# Main loop (128 Hz)
|
||||||
|
|||||||
@@ -81,13 +81,13 @@ const DEFAULT_EXPLOSION_DAMAGE: float = 500.0
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
## Reference to the RoundManager node.
|
## Reference to the RoundManager node.
|
||||||
var round_manager: RoundManager = null
|
var round_manager = null
|
||||||
|
|
||||||
## Reference to the TeamManager for checking player teams.
|
## Reference to the TeamManager for checking player teams.
|
||||||
var team_manager: TeamManager = null
|
var team_manager = null
|
||||||
|
|
||||||
## Reference to the DamageProcessor for applying explosion damage.
|
## Reference to the DamageProcessor for applying explosion damage.
|
||||||
var damage_processor: DamageProcessor = null
|
var damage_processor = null # DamageProcessor — untyped for headless compat
|
||||||
|
|
||||||
## Mapping: entity_id → peer_id (from GameServer).
|
## Mapping: entity_id → peer_id (from GameServer).
|
||||||
var entity_to_peer: Dictionary = {}
|
var entity_to_peer: Dictionary = {}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
## Signals connect round events to economy, HUD, scoreboard, etc.
|
## Signals connect round events to economy, HUD, scoreboard, etc.
|
||||||
|
|
||||||
extends Node
|
extends Node
|
||||||
class_name RoundManager
|
|
||||||
const _td_rm = preload("res://scripts/teams/team_data.gd")
|
const _td_rm = preload("res://scripts/teams/team_data.gd")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -94,10 +93,10 @@ const DEFAULT_WIN_THRESHOLD: int = 16
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
## Reference to the TeamManager node/singleton.
|
## Reference to the TeamManager node/singleton.
|
||||||
var team_manager: TeamManager = null
|
var team_manager = null
|
||||||
|
|
||||||
## Reference to the DamageProcessor node/singleton.
|
## Reference to the DamageProcessor node/singleton.
|
||||||
var damage_processor: DamageProcessor = null
|
var damage_processor = null # DamageProcessor — untyped for headless compat
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Configuration
|
# Configuration
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ var _weapon_config: Dictionary = {}
|
|||||||
var _history_depth: int = 64
|
var _history_depth: int = 64
|
||||||
var _next_entity_id: int = 1
|
var _next_entity_id: int = 1
|
||||||
var _entities: Dictionary = {} # entity_id → StubEntity
|
var _entities: Dictionary = {} # entity_id → StubEntity
|
||||||
|
var _running: bool = false
|
||||||
|
var _last_hit_result: Dictionary = {}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# SimulationServer API
|
# SimulationServer API
|
||||||
@@ -50,6 +52,28 @@ func set_weapon_config(cfg: Dictionary) -> void:
|
|||||||
func set_history_depth(depth: int) -> void:
|
func set_history_depth(depth: int) -> void:
|
||||||
_history_depth = depth
|
_history_depth = depth
|
||||||
|
|
||||||
|
func start() -> void:
|
||||||
|
_running = true
|
||||||
|
|
||||||
|
func stop() -> void:
|
||||||
|
_running = false
|
||||||
|
|
||||||
|
func can_tick(delta: float) -> bool:
|
||||||
|
return _running
|
||||||
|
|
||||||
|
func tick() -> PackedByteArray:
|
||||||
|
# Stub: returns empty snapshot
|
||||||
|
return PackedByteArray()
|
||||||
|
|
||||||
|
func get_stats() -> Dictionary:
|
||||||
|
return {
|
||||||
|
"tick_count": 0,
|
||||||
|
"entity_count": _entities.size(),
|
||||||
|
}
|
||||||
|
|
||||||
|
func get_last_hit_result() -> Dictionary:
|
||||||
|
return _last_hit_result
|
||||||
|
|
||||||
func apply_input(entity_id: int, input_dict: Dictionary) -> void:
|
func apply_input(entity_id: int, input_dict: Dictionary) -> void:
|
||||||
var e: StubEntity = _entities.get(entity_id)
|
var e: StubEntity = _entities.get(entity_id)
|
||||||
if e == null:
|
if e == null:
|
||||||
@@ -75,10 +99,9 @@ func fire_weapon(entity_id: int) -> void:
|
|||||||
func get_entity(entity_id: int) -> StubEntity:
|
func get_entity(entity_id: int) -> StubEntity:
|
||||||
return _entities.get(entity_id)
|
return _entities.get(entity_id)
|
||||||
|
|
||||||
func spawn_player_entity(peer_id: int, pos: Vector3) -> int:
|
func spawn_entity(pos: Vector3) -> int:
|
||||||
var e = StubEntity.new()
|
var e = StubEntity.new()
|
||||||
e.id = _next_entity_id
|
e.id = _next_entity_id
|
||||||
e.peer_id = peer_id
|
|
||||||
e.position = pos
|
e.position = pos
|
||||||
e.alive = true
|
e.alive = true
|
||||||
e.health = 100.0
|
e.health = 100.0
|
||||||
@@ -86,12 +109,15 @@ func spawn_player_entity(peer_id: int, pos: Vector3) -> int:
|
|||||||
_next_entity_id += 1
|
_next_entity_id += 1
|
||||||
return e.id
|
return e.id
|
||||||
|
|
||||||
func despawn_player_entity(entity_id: int) -> void:
|
func spawn_player_entity(peer_id: int, pos: Vector3) -> int:
|
||||||
_entities.erase(entity_id)
|
var entity_id = spawn_entity(pos)
|
||||||
|
var e: StubEntity = _entities.get(entity_id)
|
||||||
|
if e:
|
||||||
|
e.peer_id = peer_id
|
||||||
|
return entity_id
|
||||||
|
|
||||||
func tick(delta: float) -> void:
|
func despawn_entity(entity_id: int) -> void:
|
||||||
# Stub: in the real C++ SimulationServer this runs the 128Hz simulation
|
_entities.erase(entity_id)
|
||||||
pass
|
|
||||||
|
|
||||||
func get_entity_count() -> int:
|
func get_entity_count() -> int:
|
||||||
return _entities.size()
|
return _entities.size()
|
||||||
|
|||||||
@@ -56,15 +56,16 @@ var lag_compensation = null
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
if physics_world == null:
|
if physics_world == null:
|
||||||
physics_world = get_world_3d()
|
# Use the SceneTree's root world (Node3D.get_world_3d() isn't available on Node)
|
||||||
|
physics_world = get_tree().root.world_3d if get_tree() and get_tree().root else null
|
||||||
|
|
||||||
## Public initialiser (call after adding to tree, or inject a World3D).
|
## Public initialiser (call after adding to tree, or inject a World3D).
|
||||||
func initialise(world: World3D = null) -> void:
|
func initialise(world: World3D = null) -> void:
|
||||||
if world != null:
|
if world != null:
|
||||||
physics_world = world
|
physics_world = world
|
||||||
elif physics_world == null:
|
elif physics_world == null:
|
||||||
physics_world = get_world_3d()
|
physics_world = get_tree().root.world_3d if get_tree() and get_tree().root else null
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Per-player state management
|
# Per-player state management
|
||||||
@@ -73,47 +74,47 @@ func initialise(world: World3D = null) -> void:
|
|||||||
## Ensure a player has an entry in the state dictionary.
|
## Ensure a player has an entry in the state dictionary.
|
||||||
## Called automatically by can_fire / fire.
|
## Called automatically by can_fire / fire.
|
||||||
func register_player(player_id: int) -> void:
|
func register_player(player_id: int) -> void:
|
||||||
if not _state.has(player_id):
|
if not _state.has(player_id):
|
||||||
_state[player_id] = {}
|
_state[player_id] = {}
|
||||||
|
|
||||||
## Remove a player's state (on disconnect / respawn).
|
## Remove a player's state (on disconnect / respawn).
|
||||||
func unregister_player(player_id: int) -> void:
|
func unregister_player(player_id: int) -> void:
|
||||||
_state.erase(player_id)
|
_state.erase(player_id)
|
||||||
|
|
||||||
## Give a player a weapon — allocates initial ammo for it.
|
## Give a player a weapon — allocates initial ammo for it.
|
||||||
## Also calls register_player implicitly.
|
## Also calls register_player implicitly.
|
||||||
func give_weapon(player_id: int, weapon_id: String) -> void:
|
func give_weapon(player_id: int, weapon_id: String) -> void:
|
||||||
register_player(player_id)
|
register_player(player_id)
|
||||||
var data := _wd.get_weapon(weapon_id)
|
var data = _wd.get_weapon(weapon_id)
|
||||||
if data == null:
|
if data == null:
|
||||||
push_warning("WeaponServer: unknown weapon '%s'" % weapon_id)
|
push_warning("WeaponServer: unknown weapon '%s'" % weapon_id)
|
||||||
return
|
return
|
||||||
var ws: Dictionary = _state[player_id]
|
var ws: Dictionary = _state[player_id]
|
||||||
ws[weapon_id] = {
|
ws[weapon_id] = {
|
||||||
ammo = data.mag_size,
|
ammo = data.mag_size,
|
||||||
reserve = data.mag_size * 3, # 3 spare magazines
|
reserve = data.mag_size * 3, # 3 spare magazines
|
||||||
is_reloading = false,
|
is_reloading = false,
|
||||||
reload_remaining = 0.0,
|
reload_remaining = 0.0,
|
||||||
last_fire_time = -INF,
|
last_fire_time = -INF,
|
||||||
}
|
}
|
||||||
|
|
||||||
## Return the per-weapon state dictionary for a player+weapon, or null.
|
## Return the per-weapon state dictionary for a player+weapon, or null.
|
||||||
func get_weapon_state(player_id: int, weapon_id: String) -> Dictionary:
|
func get_weapon_state(player_id: int, weapon_id: String) -> Dictionary:
|
||||||
var ps: Dictionary = _state.get(player_id, {})
|
var ps: Dictionary = _state.get(player_id, {})
|
||||||
return ps.get(weapon_id, {}) as Dictionary
|
return ps.get(weapon_id, {}) as Dictionary
|
||||||
|
|
||||||
## Return the current ammo and reserve for a player's weapon.
|
## Return the current ammo and reserve for a player's weapon.
|
||||||
## Returns {ammo: int, reserve: int, max_ammo: int} or null.
|
## Returns {ammo: int, reserve: int, max_ammo: int} or null.
|
||||||
func get_ammo_info(player_id: int, weapon_id: String) -> Dictionary:
|
func get_ammo_info(player_id: int, weapon_id: String) -> Dictionary:
|
||||||
var st := get_weapon_state(player_id, weapon_id)
|
var st := get_weapon_state(player_id, weapon_id)
|
||||||
if st.is_empty():
|
if st.is_empty():
|
||||||
return {}
|
return {}
|
||||||
var data := _wd.get_weapon(weapon_id)
|
var data = _wd.get_weapon(weapon_id)
|
||||||
return {
|
return {
|
||||||
ammo = st.get("ammo", 0),
|
ammo = st.get("ammo", 0),
|
||||||
reserve = st.get("reserve", 0),
|
reserve = st.get("reserve", 0),
|
||||||
max_ammo = data.mag_size if data else 0,
|
max_ammo = data.mag_size if data else 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Can-fire check
|
# Can-fire check
|
||||||
@@ -122,33 +123,33 @@ func get_ammo_info(player_id: int, weapon_id: String) -> Dictionary:
|
|||||||
## Returns true if the player can fire the specified weapon right now.
|
## Returns true if the player can fire the specified weapon right now.
|
||||||
## Checks: weapon known, ammo available, not reloading, cooldown elapsed.
|
## Checks: weapon known, ammo available, not reloading, cooldown elapsed.
|
||||||
func can_fire(player_id: int, weapon_id: String) -> bool:
|
func can_fire(player_id: int, weapon_id: String) -> bool:
|
||||||
var data := _wd.get_weapon(weapon_id)
|
var data = _wd.get_weapon(weapon_id)
|
||||||
if data == null:
|
if data == null:
|
||||||
return false
|
return false
|
||||||
|
|
||||||
var st := get_weapon_state(player_id, weapon_id)
|
var st := get_weapon_state(player_id, weapon_id)
|
||||||
if st.is_empty():
|
if st.is_empty():
|
||||||
# Player doesn't have this weapon yet — give it to them
|
# Player doesn't have this weapon yet — give it to them
|
||||||
give_weapon(player_id, weapon_id)
|
give_weapon(player_id, weapon_id)
|
||||||
st = get_weapon_state(player_id, weapon_id)
|
st = get_weapon_state(player_id, weapon_id)
|
||||||
|
|
||||||
# Check reload
|
# Check reload
|
||||||
if st.get("is_reloading", false):
|
if st.get("is_reloading", false):
|
||||||
return false
|
return false
|
||||||
|
|
||||||
# Check ammo
|
# Check ammo
|
||||||
if st.get("ammo", 0) <= 0:
|
if st.get("ammo", 0) <= 0:
|
||||||
return false
|
return false
|
||||||
|
|
||||||
# Check fire rate cooldown
|
# Check fire rate cooldown
|
||||||
var fire_interval: float = 1.0 / max(data.fire_rate, 0.001)
|
var fire_interval: float = 1.0 / max(data.fire_rate, 0.001)
|
||||||
var last_fire: float = st.get("last_fire_time", -INF)
|
var last_fire: float = st.get("last_fire_time", -INF)
|
||||||
var now: float = Time.get_unix_time_from_system()
|
var now: float = Time.get_unix_time_from_system()
|
||||||
|
|
||||||
if now - last_fire < fire_interval:
|
if now - last_fire < fire_interval:
|
||||||
return false
|
return false
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Reload
|
# Reload
|
||||||
@@ -156,60 +157,60 @@ func can_fire(player_id: int, weapon_id: String) -> bool:
|
|||||||
|
|
||||||
## Start reloading the player's weapon. Returns true if reload initiated.
|
## Start reloading the player's weapon. Returns true if reload initiated.
|
||||||
func start_reload(player_id: int, weapon_id: String) -> bool:
|
func start_reload(player_id: int, weapon_id: String) -> bool:
|
||||||
var data := _wd.get_weapon(weapon_id)
|
var data = _wd.get_weapon(weapon_id)
|
||||||
if data == null:
|
if data == null:
|
||||||
return false
|
return false
|
||||||
|
|
||||||
var st := get_weapon_state(player_id, weapon_id)
|
var st := get_weapon_state(player_id, weapon_id)
|
||||||
if st.is_empty():
|
if st.is_empty():
|
||||||
return false
|
return false
|
||||||
|
|
||||||
# Already reloading
|
# Already reloading
|
||||||
if st.get("is_reloading", false):
|
if st.get("is_reloading", false):
|
||||||
return false
|
return false
|
||||||
|
|
||||||
# Magazine already full
|
# Magazine already full
|
||||||
if st.get("ammo", 0) >= data.mag_size:
|
if st.get("ammo", 0) >= data.mag_size:
|
||||||
return false
|
return false
|
||||||
|
|
||||||
# No reserve ammo
|
# No reserve ammo
|
||||||
if st.get("reserve", 0) <= 0:
|
if st.get("reserve", 0) <= 0:
|
||||||
return false
|
return false
|
||||||
|
|
||||||
st["is_reloading"] = true
|
st["is_reloading"] = true
|
||||||
st["reload_remaining"] = data.reload_time
|
st["reload_remaining"] = data.reload_time
|
||||||
return true
|
return true
|
||||||
|
|
||||||
## Process reload timers. Called each server tick with delta.
|
## Process reload timers. Called each server tick with delta.
|
||||||
## Returns true if any reload completed this tick.
|
## Returns true if any reload completed this tick.
|
||||||
func process_reloads(player_id: int, delta: float) -> bool:
|
func process_reloads(player_id: int, delta: float) -> bool:
|
||||||
var any_completed: bool = false
|
var any_completed: bool = false
|
||||||
var ps: Dictionary = _state.get(player_id, {})
|
var ps: Dictionary = _state.get(player_id, {})
|
||||||
for weapon_id in ps.keys():
|
for weapon_id in ps.keys():
|
||||||
var st: Dictionary = ps[weapon_id]
|
var st: Dictionary = ps[weapon_id]
|
||||||
if not st.get("is_reloading", false):
|
if not st.get("is_reloading", false):
|
||||||
continue
|
continue
|
||||||
var remaining: float = st.get("reload_remaining", 0.0) - delta
|
var remaining: float = st.get("reload_remaining", 0.0) - delta
|
||||||
if remaining <= 0.0:
|
if remaining <= 0.0:
|
||||||
# Reload complete
|
# Reload complete
|
||||||
_complete_reload(player_id, weapon_id, st)
|
_complete_reload(player_id, weapon_id, st)
|
||||||
any_completed = true
|
any_completed = true
|
||||||
else:
|
else:
|
||||||
st["reload_remaining"] = remaining
|
st["reload_remaining"] = remaining
|
||||||
return any_completed
|
return any_completed
|
||||||
|
|
||||||
func _complete_reload(player_id: int, weapon_id: String, st: Dictionary) -> void:
|
func _complete_reload(player_id: int, weapon_id: String, st: Dictionary) -> void:
|
||||||
var data := _wd.get_weapon(weapon_id)
|
var data = _wd.get_weapon(weapon_id)
|
||||||
if data == null:
|
if data == null:
|
||||||
return
|
return
|
||||||
|
|
||||||
var needed: int = data.mag_size - st.get("ammo", 0)
|
var needed: int = data.mag_size - st.get("ammo", 0)
|
||||||
var from_reserve: int = mini(needed, st.get("reserve", 0))
|
var from_reserve: int = mini(needed, st.get("reserve", 0))
|
||||||
|
|
||||||
st["ammo"] = st.get("ammo", 0) + from_reserve
|
st["ammo"] = st.get("ammo", 0) + from_reserve
|
||||||
st["reserve"] = st.get("reserve", 0) - from_reserve
|
st["reserve"] = st.get("reserve", 0) - from_reserve
|
||||||
st["is_reloading"] = false
|
st["is_reloading"] = false
|
||||||
st["reload_remaining"] = 0.0
|
st["reload_remaining"] = 0.0
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Fire
|
# Fire
|
||||||
@@ -230,113 +231,113 @@ func _complete_reload(player_id: int, weapon_id: String, st: Dictionary) -> void
|
|||||||
## }
|
## }
|
||||||
##
|
##
|
||||||
func fire(tick: int, player_id: int, weapon_id: String, origin: Vector3, direction: Vector3) -> Dictionary:
|
func fire(tick: int, player_id: int, weapon_id: String, origin: Vector3, direction: Vector3) -> Dictionary:
|
||||||
var data := _wd.get_weapon(weapon_id)
|
var data = _wd.get_weapon(weapon_id)
|
||||||
if data == null:
|
if data == null:
|
||||||
return _miss_result(weapon_id)
|
return _miss_result(weapon_id)
|
||||||
|
|
||||||
var st := get_weapon_state(player_id, weapon_id)
|
var st := get_weapon_state(player_id, weapon_id)
|
||||||
if st.is_empty():
|
if st.is_empty():
|
||||||
return _miss_result(weapon_id)
|
return _miss_result(weapon_id)
|
||||||
|
|
||||||
# Authoritative can-fire check (belt and suspenders)
|
# Authoritative can-fire check (belt and suspenders)
|
||||||
if not _hard_can_fire(player_id, weapon_id, data, st):
|
if not _hard_can_fire(player_id, weapon_id, data, st):
|
||||||
return _miss_result(weapon_id)
|
return _miss_result(weapon_id)
|
||||||
|
|
||||||
# Deduct ammo
|
# Deduct ammo
|
||||||
st["ammo"] = st.get("ammo", 1) - 1
|
st["ammo"] = st.get("ammo", 1) - 1
|
||||||
st["last_fire_time"] = Time.get_unix_time_from_system()
|
st["last_fire_time"] = Time.get_unix_time_from_system()
|
||||||
|
|
||||||
# Auto-reload if magazine empty
|
# Auto-reload if magazine empty
|
||||||
if st["ammo"] <= 0 and st.get("reserve", 0) > 0:
|
if st["ammo"] <= 0 and st.get("reserve", 0) > 0:
|
||||||
st["is_reloading"] = true
|
st["is_reloading"] = true
|
||||||
st["reload_remaining"] = data.reload_time
|
st["reload_remaining"] = data.reload_time
|
||||||
|
|
||||||
# Perform hit-scan — supports multi-pellet weapons
|
# Perform hit-scan — supports multi-pellet weapons
|
||||||
return _perform_hitscan(tick, player_id, weapon_id, data, origin, direction, st)
|
return _perform_hitscan(tick, player_id, weapon_id, data, origin, direction, st)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Internal: hitscan
|
# Internal: hitscan
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func _perform_hitscan(
|
func _perform_hitscan(
|
||||||
tick: int,
|
tick: int,
|
||||||
player_id: int,
|
player_id: int,
|
||||||
weapon_id: String,
|
weapon_id: String,
|
||||||
data: WeaponData,
|
data, # WeaponData — untyped for headless compat (Resource class_name order)
|
||||||
origin: Vector3,
|
origin: Vector3,
|
||||||
base_direction: Vector3,
|
base_direction: Vector3,
|
||||||
st: Dictionary
|
st: Dictionary
|
||||||
) -> Dictionary:
|
) -> Dictionary:
|
||||||
var space_state: PhysicsDirectSpaceState3D = _get_space_state()
|
var space_state: PhysicsDirectSpaceState3D = _get_space_state()
|
||||||
if space_state == null:
|
if space_state == null:
|
||||||
return _miss_result(weapon_id)
|
return _miss_result(weapon_id)
|
||||||
|
|
||||||
var total_damage: float = 0.0
|
var total_damage: float = 0.0
|
||||||
var latest_hit_pos: Vector3 = Vector3.ZERO
|
var latest_hit_pos: Vector3 = Vector3.ZERO
|
||||||
var latest_target_id: int = -1
|
var latest_target_id: int = -1
|
||||||
var any_hit: bool = false
|
var any_hit: bool = false
|
||||||
|
|
||||||
var pellets: int = max(1, data.pellets_per_shot)
|
var pellets: int = max(1, data.pellets_per_shot)
|
||||||
var max_range: float = max(1.0, data.range)
|
var max_range: float = max(1.0, data.range)
|
||||||
var spread_rad: float = deg_to_rad(data.spread_degrees)
|
var spread_rad: float = deg_to_rad(data.spread_degrees)
|
||||||
|
|
||||||
# Build a collision exception list so we don't hit the shooter
|
# Build a collision exception list so we don't hit the shooter
|
||||||
var exclude: Array[RID] = _get_shooter_exclusions(player_id)
|
var exclude: Array[RID] = _get_shooter_exclusions(player_id)
|
||||||
|
|
||||||
for i in range(pellets):
|
for i in range(pellets):
|
||||||
var dir: Vector3 = _apply_spread(base_direction, spread_rad)
|
var dir: Vector3 = _apply_spread(base_direction, spread_rad)
|
||||||
|
|
||||||
# --- Lag-compensated raycast ---
|
# --- Lag-compensated raycast ---
|
||||||
# If lag_compensation is available and a valid tick is provided,
|
# If lag_compensation is available and a valid tick is provided,
|
||||||
# rewind to the target tick before raycasting. Otherwise fall
|
# rewind to the target tick before raycasting. Otherwise fall
|
||||||
# back to the current-frame physics world.
|
# back to the current-frame physics world.
|
||||||
var result: Dictionary = {}
|
var result: Dictionary = {}
|
||||||
if lag_compensation != null and tick >= 0:
|
if lag_compensation != null and tick >= 0:
|
||||||
result = lag_compensation.rewind_and_raycast(
|
result = lag_compensation.rewind_and_raycast(
|
||||||
tick, origin, dir, max_range, exclude
|
tick, origin, dir, max_range, exclude
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Fallback: no lag compensation — raycast against current positions
|
# Fallback: no lag compensation — raycast against current positions
|
||||||
var query := PhysicsRayQueryParameters3D.create(
|
var query := PhysicsRayQueryParameters3D.create(
|
||||||
origin, origin + dir * max_range
|
origin, origin + dir * max_range
|
||||||
)
|
)
|
||||||
query.exclude = exclude
|
query.exclude = exclude
|
||||||
query.collide_with_bodies = true
|
query.collide_with_bodies = true
|
||||||
query.collide_with_areas = false
|
query.collide_with_areas = false
|
||||||
result = space_state.intersect_ray(query)
|
result = space_state.intersect_ray(query)
|
||||||
|
|
||||||
if result.is_empty():
|
if result.is_empty():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
any_hit = true
|
any_hit = true
|
||||||
latest_hit_pos = result.get("position", Vector3.ZERO)
|
latest_hit_pos = result.get("position", Vector3.ZERO)
|
||||||
var collider: Object = result.get("collider", null)
|
var collider: Object = result.get("collider", null)
|
||||||
if collider and collider.has_method(&"get_entity_id"):
|
if collider and collider.has_method(&"get_entity_id"):
|
||||||
latest_target_id = collider.get_entity_id()
|
latest_target_id = collider.get_entity_id()
|
||||||
elif collider:
|
elif collider:
|
||||||
latest_target_id = collider.get_instance_id()
|
latest_target_id = collider.get_instance_id()
|
||||||
|
|
||||||
total_damage += data.damage
|
total_damage += data.damage
|
||||||
|
|
||||||
if not any_hit:
|
if not any_hit:
|
||||||
return _miss_result(weapon_id)
|
return _miss_result(weapon_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hit = true,
|
hit = true,
|
||||||
position = latest_hit_pos,
|
position = latest_hit_pos,
|
||||||
target_id = latest_target_id,
|
target_id = latest_target_id,
|
||||||
damage = total_damage,
|
damage = total_damage,
|
||||||
weapon_id = weapon_id,
|
weapon_id = weapon_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
func _miss_result(weapon_id: String) -> Dictionary:
|
func _miss_result(weapon_id: String) -> Dictionary:
|
||||||
return {
|
return {
|
||||||
hit = false,
|
hit = false,
|
||||||
position = Vector3.ZERO,
|
position = Vector3.ZERO,
|
||||||
target_id = -1,
|
target_id = -1,
|
||||||
damage = 0.0,
|
damage = 0.0,
|
||||||
weapon_id = weapon_id,
|
weapon_id = weapon_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Internal: helpers
|
# Internal: helpers
|
||||||
@@ -344,50 +345,45 @@ func _miss_result(weapon_id: String) -> Dictionary:
|
|||||||
|
|
||||||
## Strict can-fire that does NOT auto-grant the weapon.
|
## Strict can-fire that does NOT auto-grant the weapon.
|
||||||
func _hard_can_fire(
|
func _hard_can_fire(
|
||||||
player_id: int,
|
player_id: int,
|
||||||
weapon_id: String,
|
weapon_id: String,
|
||||||
data: WeaponData,
|
data, # WeaponData — untyped for headless compat (Resource class_name order)
|
||||||
st: Dictionary
|
st: Dictionary
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if st.is_empty():
|
if st.is_empty():
|
||||||
return false
|
return false
|
||||||
if st.get("is_reloading", false):
|
if st.get("is_reloading", false):
|
||||||
return false
|
return false
|
||||||
if st.get("ammo", 0) <= 0:
|
if st.get("ammo", 0) <= 0:
|
||||||
return false
|
return false
|
||||||
|
|
||||||
var fire_interval: float = 1.0 / max(data.fire_rate, 0.001)
|
var fire_interval: float = 1.0 / max(data.fire_rate, 0.001)
|
||||||
var last_fire: float = st.get("last_fire_time", -INF)
|
var last_fire: float = st.get("last_fire_time", -INF)
|
||||||
var now: float = Time.get_unix_time_from_system()
|
var now: float = Time.get_unix_time_from_system()
|
||||||
if now - last_fire < fire_interval:
|
if now - last_fire < fire_interval:
|
||||||
return false
|
return false
|
||||||
return true
|
return true
|
||||||
|
|
||||||
func _get_space_state() -> PhysicsDirectSpaceState3D:
|
func _get_space_state() -> PhysicsDirectSpaceState3D:
|
||||||
if physics_world != null:
|
if physics_world != null:
|
||||||
return physics_world.direct_space_state
|
return physics_world.direct_space_state
|
||||||
# Fallback: try from the scene tree
|
return null
|
||||||
var w: World3D = get_world_3d()
|
|
||||||
if w != null:
|
|
||||||
physics_world = w
|
|
||||||
return w.direct_space_state
|
|
||||||
return null
|
|
||||||
|
|
||||||
func _get_shooter_exclusions(player_id: int) -> Array[RID]:
|
func _get_shooter_exclusions(player_id: int) -> Array[RID]:
|
||||||
# Try to find the player's collision body via the entity system
|
# Try to find the player's collision body via the entity system
|
||||||
# For now, return an empty array (no self-exclusion beyond what Godot does)
|
# For now, return an empty array (no self-exclusion beyond what Godot does)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
## Apply spread to a base direction vector.
|
## Apply spread to a base direction vector.
|
||||||
func _apply_spread(base: Vector3, spread_rad: float) -> Vector3:
|
func _apply_spread(base: Vector3, spread_rad: float) -> Vector3:
|
||||||
if spread_rad <= 0.001:
|
if spread_rad <= 0.001:
|
||||||
return base
|
return base
|
||||||
var theta: float = randf() * TAU
|
var theta: float = randf() * TAU
|
||||||
var phi: float = randf() * spread_rad
|
var phi: float = randf() * spread_rad
|
||||||
var up := Vector3.UP
|
var up := Vector3.UP
|
||||||
if abs(base.dot(up)) > 0.99:
|
if abs(base.dot(up)) > 0.99:
|
||||||
up = Vector3.RIGHT
|
up = Vector3.RIGHT
|
||||||
var right := base.cross(up).normalized()
|
var right := base.cross(up).normalized()
|
||||||
up = right.cross(base).normalized()
|
up = right.cross(base).normalized()
|
||||||
var offset := right * sin(theta) * sin(phi) + up * cos(theta) * sin(phi)
|
var offset := right * sin(theta) * sin(phi) + up * cos(theta) * sin(phi)
|
||||||
return (base + offset).normalized()
|
return (base + offset).normalized()
|
||||||
|
|||||||
Reference in New Issue
Block a user