Save workspace artifacts: character-controller, gdextension scaffold, network scripts, map-pipeline, server config
Includes code from task workspaces that was never pushed: - client/characters/ — fps_character_controller.gd (400 lines), fps_camera.gd, input_handler.gd - gdextension/simulation/ — C++ GDExtension scaffold: entity, movement, hit detection, simulation server, state serializer, bitstream (14 files) - scripts/network/ — ENet-based network_manager.gd, server/client_main.gd, player.gd - scripts/map_packaging/ — PCK pipeline: pack_map.gd, map_downloader.gd, map_registry_server.py, README - scripts/config/ — server_config.gd (480 lines) - scenes/ — client_main, server_main, player, test_range - project.godot, export_presets.cfg
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
# FPS Character Controller — Tactical Shooter
|
||||
|
||||
First-person character controller for the tactical shooter GDExtension simulation core.
|
||||
|
||||
## Architecture
|
||||
|
||||
The character controller is a **GDScript bridge** between Godot's input/physics system and the C++ `SimulationServer` (built in task `t_aca0f251`).
|
||||
|
||||
```
|
||||
Godot Input
|
||||
│
|
||||
├─► FPSCharacterController._physics_process()
|
||||
│ ├─ capture input (WASD, mouse, jump, sprint, crouch, shoot)
|
||||
│ ├─ build EntityInput Dictionary
|
||||
│ ├─ SimulationServer.apply_input(entity_id, input_dict)
|
||||
│ ├─ SimulationServer.fire_weapon(entity_id) [if shoot]
|
||||
│ ├─ read entity position from server → apply to CharacterBody3D
|
||||
│ └─ apply mouse look (yaw on body, pitch on camera)
|
||||
│
|
||||
├─► FpsCamera._process()
|
||||
│ ├─ view bobbing (head bob from movement)
|
||||
│ ├─ weapon sway
|
||||
│ └─ sprint FOV kick
|
||||
│
|
||||
└─► SimulationServer (C++ GDExtension)
|
||||
├─ process_tick() @ 128Hz
|
||||
├─ MovementComponent.update()
|
||||
├─ HitDetection.process_shot()
|
||||
└─→ returns serialized snapshot
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `character/fps_character_controller.gd` | Main controller (CharacterBody3D). Input capture, simulation bridge, walk toggle, crouch, jump. |
|
||||
| `character/fps_camera.gd` | First-person camera (Camera3D). Mouse look pitch, view bobbing, weapon sway, FOV kick. |
|
||||
| `input/input_handler.gd` | Documentation of expected input map bindings. |
|
||||
|
||||
## Scene Setup
|
||||
|
||||
1. **Create a CharacterBody3D scene:**
|
||||
```
|
||||
FPSCharacterController (CharacterBody3D) — script: fps_character_controller.gd
|
||||
├── FpsCamera (Camera3D) — script: fps_camera.gd
|
||||
├── CollisionShape3D (CapsuleShape3D) — radius: 0.4, height: 1.8
|
||||
└── Weapon (Node3D, optional) — child of camera, rest transform tracked
|
||||
```
|
||||
|
||||
2. **Configure exports** on the root node:
|
||||
- `mouse_sensitivity`: 0.1–0.2 for typical tactical feel
|
||||
- `walk_toggle`: `true` = tap Shift to toggle sprint (CS-style)
|
||||
- `crouch_toggle`: `false` = hold Ctrl to crouch (tactical-style)
|
||||
- `eye_height_stand` / `eye_height_crouch`: match your capsule height
|
||||
|
||||
3. **Set up Input Map** in Project Settings:
|
||||
- `move_forward` → W
|
||||
- `move_backward` → S
|
||||
- `move_left` → A
|
||||
- `move_right` → D
|
||||
- `jump` → Space
|
||||
- `sprint` → Shift
|
||||
- `crouch` → Ctrl
|
||||
- `shoot` → Mouse LMB
|
||||
- `aim` → Mouse RMB
|
||||
- `ui_cancel` → Esc (built-in, releases mouse capture)
|
||||
|
||||
4. **Integration with SimulationServer:**
|
||||
|
||||
```gdscript
|
||||
# In your game manager (autoload or scene root):
|
||||
var server = SimulationServer.new()
|
||||
server.set_tick_rate(128)
|
||||
server.start()
|
||||
var player_id = server.spawn_entity(Vector3(0, 0, 0))
|
||||
|
||||
# When player scene is ready:
|
||||
$Player/FPSCharacterController.set_entity_id(player_id)
|
||||
|
||||
# In _process(delta):
|
||||
while server.can_tick(delta):
|
||||
var snapshot = server.tick()
|
||||
# send snapshot to network layer
|
||||
```
|
||||
|
||||
## Walk Toggle Behavior
|
||||
|
||||
- **`walk_toggle = true`** (default): Press Shift to toggle between walk and sprint. Press again to toggle off. Same feel as Counter-Strike's walk/run toggle.
|
||||
- **`walk_toggle = false`**: Hold Shift for temporary sprint. Release to decelerate to walk speed.
|
||||
|
||||
## Crouch Behavior
|
||||
|
||||
- **`crouch_toggle = false`** (default): Hold Ctrl to crouch. Release to stand up.
|
||||
- **`crouch_toggle = true`**: Press Ctrl once to crouch, press again to stand.
|
||||
|
||||
Crouch transitions are smooth lerps over `crouch_transition_time` seconds. The collision capsule height and camera eye height both lerp simultaneously.
|
||||
|
||||
## Standalone Mode
|
||||
|
||||
When no `SimulationServer` singleton is available, the controller falls back to built-in `CharacterBody3D` movement (`_move_local()`). This is useful for:
|
||||
- Testing the character controller in isolation
|
||||
- Prototyping without the GDExtension compiled
|
||||
- Single-player scenarios without the full simulation stack
|
||||
|
||||
Configure `local_walk_speed`, `local_sprint_speed`, `local_crouch_speed`, etc. for standalone tuning.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Godot 4.2+** (for `Input.get_vector`, `move_toward`, `StringName` literals)
|
||||
- **SimulationServer GDExtension** (from task `t_aca0f251`) for server-authoritative mode
|
||||
- Input map actions listed above
|
||||
|
||||
## See Also
|
||||
|
||||
- Scaffold: `t_aca0f251` — `src/movement_component.h` (C++ Quake/CS movement physics)
|
||||
- Greybox map: `t_p1_greybox` — test environment for this controller
|
||||
- Hitscan weapon: `t_p1_hitscan` — weapon firing + lag compensation
|
||||
- Client prediction: `t_p1_pred` — client-side prediction + server reconciliation
|
||||
- Round system: `t_p1_round` — win/loss/sudden death on top of character controller
|
||||
@@ -0,0 +1,215 @@
|
||||
## FpsCamera — first-person camera view effects for tactical FPS.
|
||||
##
|
||||
## Manages view bobbing (head bob from movement), weapon sway, and FOV kick.
|
||||
## The parent FPSCharacterController handles mouse look (yaw/pitch).
|
||||
##
|
||||
## Place as a Camera3D child of FPSCharacterController.
|
||||
class_name FpsCamera
|
||||
extends Camera3D
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exports
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Enable view bobbing (head bob when walking/running).
|
||||
@export var view_bob_enabled: bool = true
|
||||
|
||||
## How much the camera bobs horizontally (units).
|
||||
@export_range(0.0, 0.5) var bob_amplitude_h: float = 0.04
|
||||
|
||||
## How much the camera bobs vertically.
|
||||
@export_range(0.0, 0.5) var bob_amplitude_v: float = 0.04
|
||||
|
||||
## Frequency multiplier for bobbing (higher = faster).
|
||||
@export_range(0.5, 5.0) var bob_frequency: float = 2.5
|
||||
|
||||
## Sprint bob multiplier (more aggressive).
|
||||
@export_range(1.0, 3.0) var bob_sprint_mult: float = 1.6
|
||||
|
||||
## Crouch bob multiplier (less movement).
|
||||
@export_range(0.1, 1.0) var bob_crouch_mult: float = 0.4
|
||||
|
||||
## FOV kick on sprint (tactical FOV increase).
|
||||
@export_range(0.0, 10.0) var sprint_fov_kick: float = 3.0
|
||||
|
||||
## FOV kick when firing weapon.
|
||||
@export_range(-5.0, 10.0) var shoot_fov_kick: float = 0.5
|
||||
|
||||
## How fast FOV recovers.
|
||||
@export var fov_recovery_speed: float = 6.0
|
||||
|
||||
## Enable weapon sway (subtle rotation from movement).
|
||||
@export var weapon_sway_enabled: bool = true
|
||||
|
||||
## Maximum weapon sway rotation (degrees).
|
||||
@export_range(0.0, 5.0) var sway_max_rotation: float = 1.5
|
||||
|
||||
## Sway responsiveness (higher = snappier).
|
||||
@export_range(1.0, 20.0) var sway_response: float = 8.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Reference to parent controller.
|
||||
var _controller: FPSCharacterController = null
|
||||
|
||||
## Bob time accumulator.
|
||||
var _bob_time: float = 0.0
|
||||
|
||||
## Current bob offset.
|
||||
var _bob_offset: Vector2 = Vector2.ZERO
|
||||
|
||||
## Current weapon node (child named "Weapon" or first MeshInstance3D).
|
||||
var _weapon: Node3D = null
|
||||
|
||||
## Weapon rest position (local transform when no sway).
|
||||
var _weapon_rest: Transform3D
|
||||
|
||||
## FOV state.
|
||||
var _base_fov: float = 75.0
|
||||
var _current_fov_offset: float = 0.0
|
||||
|
||||
## Sway state.
|
||||
var _sway_current: Vector2 = Vector2.ZERO
|
||||
var _sway_target: Vector2 = Vector2.ZERO
|
||||
|
||||
## Sprint state cache (detect transition for FOV kick).
|
||||
var _was_sprinting: bool = false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
_controller = get_parent() as FPSCharacterController
|
||||
if _controller == null:
|
||||
push_warning("FpsCamera: Parent is not an FPSCharacterController. View features disabled.")
|
||||
|
||||
_base_fov = fov
|
||||
|
||||
# Find weapon node (for sway)
|
||||
for child in get_children():
|
||||
if child is Node3D:
|
||||
_weapon = child
|
||||
_weapon_rest = child.transform
|
||||
break
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _controller == null:
|
||||
return
|
||||
|
||||
# FOV management
|
||||
_update_fov(delta)
|
||||
|
||||
# View bobbing
|
||||
if view_bob_enabled:
|
||||
_update_bob(delta)
|
||||
|
||||
# Weapon sway
|
||||
if weapon_sway_enabled and _weapon:
|
||||
_update_sway(delta)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FOV
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _update_fov(delta: float) -> void:
|
||||
var target_offset: float = 0.0
|
||||
|
||||
# Sprint FOV kick
|
||||
if _controller.is_sprinting() and not _was_sprinting:
|
||||
target_offset = sprint_fov_kick
|
||||
|
||||
# Shoot FOV kick is triggered externally via trigger_shoot_fov()
|
||||
_current_fov_offset = move_toward(_current_fov_offset, target_offset, fov_recovery_speed * delta)
|
||||
fov = _base_fov + _current_fov_offset
|
||||
|
||||
_was_sprinting = _controller.is_sprinting()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# View bobbing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _update_bob(delta: float) -> void:
|
||||
var crouch_amount: float = _controller.get_crouch_amount()
|
||||
var sprinting: bool = _controller.is_sprinting()
|
||||
|
||||
# Calculate bob speed multiplier from controller state
|
||||
var speed_mult: float = 1.0
|
||||
if sprinting:
|
||||
speed_mult = bob_sprint_mult
|
||||
elif crouch_amount > 0.0:
|
||||
speed_mult = lerpf(1.0, bob_crouch_mult, crouch_amount)
|
||||
|
||||
if speed_mult < 0.01:
|
||||
# Stationary — reset to zero
|
||||
_bob_offset = _bob_offset.move_toward(Vector2.ZERO, delta * 10.0)
|
||||
else:
|
||||
_bob_time += delta * bob_frequency * speed_mult
|
||||
_bob_offset = Vector2(
|
||||
sin(_bob_time * 2.0) * bob_amplitude_h * speed_mult,
|
||||
sin(_bob_time) * bob_amplitude_v * speed_mult
|
||||
)
|
||||
|
||||
# Apply bob to camera position (relative to parent's eye offset)
|
||||
position.x = _bob_offset.x
|
||||
position.y = _bob_offset.y
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Weapon sway
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _update_sway(delta: float) -> void:
|
||||
# Sway follows mouse movement indirectly via controller yaw/pitch changes
|
||||
# Use velocity for physics-based sway: recentre over time
|
||||
var vel: Vector3 = _controller.velocity
|
||||
var sway_h := clamp(vel.x * 0.003, -sway_max_rotation, sway_max_rotation)
|
||||
var sway_v := clamp(vel.z * 0.003, -sway_max_rotation, sway_max_rotation)
|
||||
# Add a tiny amount from pitch/yaw delta (not mouse, from movement direction change)
|
||||
_sway_target = Vector2(
|
||||
move_toward(_sway_target.x, sway_h, sway_response * delta),
|
||||
move_toward(_sway_target.y, sway_v, sway_response * delta)
|
||||
)
|
||||
|
||||
# Apply to weapon node as small rotation offsets
|
||||
if _weapon:
|
||||
var target := Transform3D(
|
||||
Basis.from_euler(Vector3(
|
||||
deg_to_rad(_sway_target.y),
|
||||
deg_to_rad(_sway_target.x),
|
||||
0.0
|
||||
)),
|
||||
_weapon_rest.origin
|
||||
)
|
||||
_weapon.transform = _weapon.transform.interpolate_with(target, sway_response * delta * 0.5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Trigger a shoot FOV kick. Call from weapon script.
|
||||
func trigger_shoot_fov() -> void:
|
||||
_current_fov_offset += shoot_fov_kick
|
||||
|
||||
|
||||
## Reset view to resting state (for respawn).
|
||||
func reset_view() -> void:
|
||||
_bob_time = 0.0
|
||||
_bob_offset = Vector2.ZERO
|
||||
_current_fov_offset = 0.0
|
||||
_sway_current = Vector2.ZERO
|
||||
_sway_target = Vector2.ZERO
|
||||
fov = _base_fov
|
||||
if _weapon:
|
||||
_weapon.transform = _weapon_rest
|
||||
|
||||
|
||||
## Get the current bob offset for external effects.
|
||||
func get_bob_offset() -> Vector2:
|
||||
return _bob_offset
|
||||
@@ -0,0 +1,400 @@
|
||||
## FPSCharacterController — first-person character for a networked tactical shooter.
|
||||
##
|
||||
## Bridges Godot Input → SimulationServer (GDExtension C++ core).
|
||||
## Handles mouse look, WASD movement, sprint toggle, hold-to-crouch, jump.
|
||||
##
|
||||
## Usage (scene tree):
|
||||
## FPSCharacterController (CharacterBody3D)
|
||||
## ├── FpsCamera (Camera3D) — script: fps_camera.gd
|
||||
## ├── CollisionShape3D (capsule)
|
||||
## └── (optional weapons/arms child)
|
||||
##
|
||||
## Connects to SimulationServer via `apply_input()` each frame.
|
||||
## Reads server snapshot data and updates the node transform.
|
||||
##
|
||||
## Walk Toggle: Tap Shift to toggle sprint on/off.
|
||||
## Crouch: Hold Ctrl to crouch. Smooth lerp transition.
|
||||
## Jump: Press Space.
|
||||
## Mouse Look: Capture mouse on click, yaw rotates body, pitch rotates camera.
|
||||
class_name FPSCharacterController
|
||||
extends CharacterBody3D
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exports — tune per weapon / game feel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Mouse look sensitivity. At default 0.005, 100 px mouse motion = 0.5° rotation.
|
||||
@export_range(0.001, 0.1, 0.0005) var mouse_sensitivity: float = 0.005
|
||||
|
||||
## Mouse vertical sensitivity multiplier (1.0 = same as horizontal).
|
||||
@export_range(0.1, 2.0, 0.05) var mouse_vertical_ratio: float = 0.8
|
||||
|
||||
## Invert vertical look.
|
||||
@export var invert_y: bool = false
|
||||
|
||||
## Maximum pitch angle (degrees) — prevents looking upside down.
|
||||
@export_range(1, 89) var max_pitch: float = 89.0
|
||||
|
||||
## Entity ID assigned by SimulationServer. Set via spawn_entity() response.
|
||||
@export var entity_id: int = -1
|
||||
|
||||
## If true, pressing Sprint toggles between walk/sprint. If false, hold to sprint.
|
||||
@export var walk_toggle: bool = true
|
||||
|
||||
## If true, crouch toggles on press. If false, hold Ctrl to crouch.
|
||||
@export var crouch_toggle: bool = false
|
||||
|
||||
## Time in seconds for crouch height transition (lerp).
|
||||
@export var crouch_transition_time: float = 0.15
|
||||
|
||||
## Eye height when standing (Godot units, from feet).
|
||||
@export var eye_height_stand: float = 1.7
|
||||
|
||||
## Eye height when crouching.
|
||||
@export var eye_height_crouch: float = 0.9
|
||||
|
||||
## Collision shape height when standing.
|
||||
@export var collision_height_stand: float = 1.8
|
||||
|
||||
## Collision shape height when crouching.
|
||||
@export var collision_height_crouch: float = 1.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Reference to SimulationServer singleton (set in _ready).
|
||||
var _server: Object = null
|
||||
|
||||
## Current look rotation.
|
||||
var _yaw: float = 0.0
|
||||
var _pitch: float = 0.0
|
||||
|
||||
## Crouch lerp target (0.0 = standing, 1.0 = full crouch).
|
||||
var _crouch_target: float = 0.0
|
||||
var _crouch_current: float = 0.0
|
||||
|
||||
## Walk-toggle state. If walk_toggle is true, toggles each time sprint input activates.
|
||||
var _sprint_active: bool = false
|
||||
var _sprint_pressed_last: bool = false
|
||||
|
||||
## Crouch toggle state.
|
||||
var _crouch_active: bool = false
|
||||
var _crouch_pressed_last: bool = false
|
||||
|
||||
## Input sequence counter — increment each frame for server reconciliation.
|
||||
var _input_sequence: int = 0
|
||||
|
||||
## Cached input dictionary (avoids alloc per frame).
|
||||
var _input_dict: Dictionary = {}
|
||||
|
||||
## Mouse capture state.
|
||||
var _mouse_captured: bool = false
|
||||
var _mouse_clicked_this_frame: bool = false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node references (set in _ready)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _camera: Node3D = null
|
||||
var _collision_shape: CollisionShape3D = null
|
||||
var _capsule_shape: CapsuleShape3D = null
|
||||
|
||||
# Cached standing/crouching shape parameters.
|
||||
var _stand_capsule_height: float = 0.0
|
||||
var _crouch_capsule_height: float = 0.0
|
||||
var _capsule_radius: float = 0.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
# Find SimulationServer
|
||||
if Engine.has_singleton("SimulationServer"):
|
||||
_server = Engine.get_singleton("SimulationServer")
|
||||
else:
|
||||
push_warning("FPSCharacterController: SimulationServer singleton not found. " +
|
||||
"Running in standalone (local physics) mode.")
|
||||
|
||||
# Locate camera
|
||||
_camera = get_node_or_null("%FpsCamera")
|
||||
if _camera == null:
|
||||
# Try to find any Camera3D child
|
||||
for child in get_children():
|
||||
if child is Camera3D:
|
||||
_camera = child
|
||||
break
|
||||
if _camera == null:
|
||||
push_error("FPSCharacterController: No Camera3D child found. Mouse look disabled.")
|
||||
|
||||
# Locate collision shape
|
||||
for child in get_children():
|
||||
if child is CollisionShape3D:
|
||||
_collision_shape = child
|
||||
if child.shape is CapsuleShape3D:
|
||||
_capsule_shape = child.shape
|
||||
_capsule_radius = _capsule_shape.radius
|
||||
_stand_capsule_height = collision_height_stand - 2.0 * _capsule_radius
|
||||
_crouch_capsule_height = collision_height_crouch - 2.0 * _capsule_radius
|
||||
_capsule_shape.height = _stand_capsule_height
|
||||
break
|
||||
|
||||
# Start with mouse captured
|
||||
_capture_mouse(true)
|
||||
|
||||
# Initialize yaw/pitch from current transform
|
||||
_yaw = rotation.y
|
||||
if _camera:
|
||||
_pitch = _camera.rotation.x
|
||||
|
||||
# Initialize crouch animation state
|
||||
_crouch_current = 0.0
|
||||
_update_crouch(0.0)
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
# Mouse look
|
||||
if event is InputEventMouseMotion and _mouse_captured:
|
||||
var rel: Vector2 = event.relative
|
||||
# Yaw (body rotation)
|
||||
_yaw -= rel.x * mouse_sensitivity
|
||||
# Pitch (camera rotation) — negative = look down, positive = look up
|
||||
var invert: float = -1.0 if invert_y else 1.0
|
||||
_pitch += rel.y * mouse_sensitivity * mouse_vertical_ratio * invert
|
||||
_pitch = clamp(_pitch, deg_to_rad(-max_pitch), deg_to_rad(max_pitch))
|
||||
|
||||
# Mouse capture toggle
|
||||
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
|
||||
if not _mouse_captured:
|
||||
_capture_mouse(true)
|
||||
else:
|
||||
# LMB fires weapon — handled in _physics_process via shoot_pressed
|
||||
_mouse_clicked_this_frame = true
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
# Escape to release mouse
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
_capture_mouse(false)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
# 1. Capture input state
|
||||
var input_dir := _get_move_direction()
|
||||
var jump_pressed := Input.is_action_just_pressed(&"jump") or Input.is_action_pressed(&"jump")
|
||||
var sprint_pressed := Input.is_action_pressed(&"sprint")
|
||||
var crouch_pressed := Input.is_action_pressed(&"crouch")
|
||||
var shoot_pressed := _mouse_clicked_this_frame or Input.is_action_pressed(&"shoot")
|
||||
var aim_pressed := Input.is_action_pressed(&"aim")
|
||||
|
||||
_mouse_clicked_this_frame = false
|
||||
|
||||
# 2. Walk toggle logic
|
||||
if walk_toggle:
|
||||
if sprint_pressed and not _sprint_pressed_last:
|
||||
_sprint_active = not _sprint_active
|
||||
_sprint_pressed_last = sprint_pressed
|
||||
else:
|
||||
_sprint_active = sprint_pressed
|
||||
|
||||
# 3. Crouch toggle logic
|
||||
if crouch_toggle:
|
||||
if crouch_pressed and not _crouch_pressed_last:
|
||||
_crouch_active = not _crouch_active
|
||||
_crouch_pressed_last = crouch_pressed
|
||||
else:
|
||||
_crouch_active = crouch_pressed
|
||||
|
||||
# 4. Crouch height transition
|
||||
var target_crouch: float = 1.0 if _crouch_active else 0.0
|
||||
_crouch_target = target_crouch
|
||||
|
||||
var crouch_speed: float = 1.0 / max(crouch_transition_time, 0.001)
|
||||
if _crouch_current < _crouch_target:
|
||||
_crouch_current = min(_crouch_current + crouch_speed * delta, _crouch_target)
|
||||
elif _crouch_current > _crouch_target:
|
||||
_crouch_current = max(_crouch_current - crouch_speed * delta, _crouch_target)
|
||||
|
||||
_update_crouch(_crouch_current)
|
||||
|
||||
# 5. Apply rotation
|
||||
rotation.y = _yaw
|
||||
if _camera:
|
||||
_camera.rotation.x = _pitch
|
||||
|
||||
# 6. Send input to simulation server
|
||||
if _server != null and entity_id >= 0:
|
||||
# Build input for SimulationServer
|
||||
_input_dict.merge({
|
||||
"move_direction": input_dir,
|
||||
"look_yaw": rad_to_deg(_yaw),
|
||||
"look_pitch": rad_to_deg(_pitch),
|
||||
"jump": jump_pressed,
|
||||
"crouch": _crouch_active,
|
||||
"sprint": _sprint_active,
|
||||
"shoot": shoot_pressed,
|
||||
"aim": aim_pressed,
|
||||
"input_sequence": _input_sequence,
|
||||
}, true)
|
||||
|
||||
_server.apply_input(entity_id, _input_dict)
|
||||
|
||||
if shoot_pressed:
|
||||
_server.fire_weapon(entity_id)
|
||||
|
||||
_input_sequence += 1
|
||||
|
||||
# 7. Read server state back (for local simulation / listen server)
|
||||
# The SimulationServer.tick() call happens in the game manager / network layer.
|
||||
# Here we read the entity's canonical position from the server.
|
||||
if _server != null and entity_id >= 0:
|
||||
var entity = _server.get_entity(entity_id)
|
||||
if entity != null and entity.is_alive():
|
||||
# Server position is ground truth — apply it to the visual body
|
||||
global_position = entity.position
|
||||
# Note: rotation is already set from yaw/pitch above (client-side)
|
||||
# On a pure client the server snapshot override is applied differently.
|
||||
else:
|
||||
# Standalone mode: do local CharacterBody3D physics
|
||||
_move_local(input_dir, delta, jump_pressed)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local movement (standalone / no-server fallback)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Walk speed (standalone mode).
|
||||
@export var local_walk_speed: float = 5.0
|
||||
## Sprint speed (standalone mode).
|
||||
@export var local_sprint_speed: float = 8.0
|
||||
## Crouch speed (standalone mode).
|
||||
@export var local_crouch_speed: float = 2.5
|
||||
## Jump velocity (standalone mode).
|
||||
@export var local_jump_velocity: float = 4.5
|
||||
## Gravity (standalone mode).
|
||||
@export var local_gravity: float = 15.0
|
||||
## Acceleration (standalone mode).
|
||||
@export var local_acceleration: float = 12.0
|
||||
|
||||
func _move_local(input_dir: Vector3, delta: float, jump: bool) -> void:
|
||||
# Simple CharacterBody3D movement for standalone testing
|
||||
var target_speed: float = local_walk_speed
|
||||
if _sprint_active:
|
||||
target_speed = local_sprint_speed
|
||||
if _crouch_active:
|
||||
target_speed = local_crouch_speed
|
||||
|
||||
var wish_dir := (transform.basis * input_dir).normalized()
|
||||
var target_vel := wish_dir * target_speed
|
||||
|
||||
# Horizontal acceleration
|
||||
var h_vel := Vector3(velocity.x, 0.0, velocity.z)
|
||||
h_vel = h_vel.move_toward(target_vel, local_acceleration * delta)
|
||||
velocity.x = h_vel.x
|
||||
velocity.z = h_vel.z
|
||||
|
||||
# Gravity
|
||||
if not is_on_floor():
|
||||
velocity.y -= local_gravity * delta
|
||||
|
||||
# Jump
|
||||
if jump and is_on_floor():
|
||||
velocity.y = local_jump_velocity
|
||||
|
||||
move_and_slide()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Crouch height management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _update_crouch(amount: float) -> void:
|
||||
## amount: 0.0 = standing, 1.0 = fully crouched
|
||||
# Eye height — camera position relative to body origin (character feet at origin)
|
||||
if _camera:
|
||||
_camera.position.y = lerpf(eye_height_stand, eye_height_crouch, amount)
|
||||
|
||||
# Collision shape — capsule height (cylindrical section)
|
||||
if _capsule_shape:
|
||||
_capsule_shape.height = lerpf(_stand_capsule_height, _crouch_capsule_height, amount)
|
||||
|
||||
# Move the collision shape center so the capsule base stays on the ground:
|
||||
# shift = (crouch_total - stand_total) / 2, negative = moves down
|
||||
if _collision_shape:
|
||||
_collision_shape.position.y = lerpf(0.0, (collision_height_crouch - collision_height_stand) * 0.5, amount)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _get_move_direction() -> Vector3:
|
||||
var dir := Vector3.ZERO
|
||||
if Input.is_action_pressed(&"move_forward") or Input.is_action_pressed(&"move_up"):
|
||||
dir.z -= 1.0
|
||||
if Input.is_action_pressed(&"move_backward") or Input.is_action_pressed(&"move_down"):
|
||||
dir.z += 1.0
|
||||
if Input.is_action_pressed(&"move_left"):
|
||||
dir.x -= 1.0
|
||||
if Input.is_action_pressed(&"move_right"):
|
||||
dir.x += 1.0
|
||||
# Normalize for analog stick deadzone
|
||||
if dir.length_squared() > 0.0:
|
||||
dir = dir.normalized()
|
||||
return dir
|
||||
|
||||
|
||||
func _capture_mouse(capture: bool) -> void:
|
||||
if capture == _mouse_captured:
|
||||
return
|
||||
_mouse_captured = capture
|
||||
if capture:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
else:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Set the entity ID assigned by SimulationServer after spawning.
|
||||
func set_entity_id(id: int) -> void:
|
||||
entity_id = id
|
||||
|
||||
|
||||
## Get current crouch amount (0.0 = standing, 1.0 = fully crouched).
|
||||
func get_crouch_amount() -> float:
|
||||
return _crouch_current
|
||||
|
||||
|
||||
## Is the player currently sprinting?
|
||||
func is_sprinting() -> bool:
|
||||
return _sprint_active
|
||||
|
||||
|
||||
## Is the player currently crouching?
|
||||
func is_crouching() -> bool:
|
||||
return _crouch_active
|
||||
|
||||
|
||||
## Force look direction (useful for spectator / spawn reset).
|
||||
func set_look(yaw_rad: float, pitch_rad: float) -> void:
|
||||
_yaw = yaw_rad
|
||||
_pitch = clamp(pitch_rad, deg_to_rad(-max_pitch), deg_to_rad(max_pitch))
|
||||
|
||||
|
||||
## Reset to default standing state.
|
||||
func reset_pose() -> void:
|
||||
_sprint_active = false
|
||||
_crouch_active = false
|
||||
_sprint_pressed_last = false
|
||||
_crouch_pressed_last = false
|
||||
_crouch_current = 0.0
|
||||
_crouch_target = 0.0
|
||||
_update_crouch(0.0)
|
||||
@@ -0,0 +1,59 @@
|
||||
## InputHandler — maps Godot Input Actions to FPS character controls.
|
||||
##
|
||||
## This script is NOT required at runtime — it exists to document the input
|
||||
## map bindings that FPSCharacterController expects. Add these actions to
|
||||
## your Project → Input Map (or load this script as an autoload for reference).
|
||||
##
|
||||
## Bindings (tactical FPS layout):
|
||||
##
|
||||
## Action Key Description
|
||||
## ───────────────────────────────────────────────────────
|
||||
## move_forward W Walk forward
|
||||
## move_backward S Walk backward
|
||||
## move_left A Strafe left
|
||||
## move_right D Strafe right
|
||||
## jump Space Jump
|
||||
## sprint Shift Sprint / walk-toggle
|
||||
## crouch Ctrl Crouch (hold or toggle)
|
||||
## shoot Mouse LMB Fire weapon
|
||||
## aim Mouse RMB Aim down sights
|
||||
## reload R Reload weapon
|
||||
## interact E Use/interact
|
||||
## toggle_walk_toggle (not bound) Toggle walk-toggle mode
|
||||
## toggle_crouch_toggle (not bound) Toggle crouch-toggle mode
|
||||
## ui_cancel Esc Release mouse capture
|
||||
##
|
||||
## Implementation note:
|
||||
## FPSCharacterController reads input directly via Input.is_action_pressed()
|
||||
## in _physics_process. No separate InputHandler node is needed unless you
|
||||
## want to add rebinding, analog input smoothing, or input buffering.
|
||||
|
||||
extends Node
|
||||
|
||||
func _ready() -> void:
|
||||
# This script documents bindings only — no runtime logic.
|
||||
# To enforce default bindings at startup, uncomment the next lines:
|
||||
|
||||
# _ensure_action(&"move_forward", KEY_W)
|
||||
# _ensure_action(&"move_backward", KEY_S)
|
||||
# _ensure_action(&"move_left", KEY_A)
|
||||
# _ensure_action(&"move_right", KEY_D)
|
||||
# _ensure_action(&"jump", KEY_SPACE)
|
||||
# _ensure_action(&"sprint", KEY_SHIFT)
|
||||
# _ensure_action(&"crouch", KEY_CTRL)
|
||||
# _ensure_action(&"shoot", MOUSE_BUTTON_LEFT)
|
||||
# _ensure_action(&"aim", MOUSE_BUTTON_RIGHT)
|
||||
# _ensure_action(&"reload", KEY_R)
|
||||
# _ensure_action(&"interact", KEY_E)
|
||||
pass
|
||||
|
||||
|
||||
## Ensure an input action exists with the given default key binding.
|
||||
## Only sets the binding if the action does not already exist.
|
||||
func _ensure_action(action_name: StringName, key: Key) -> void:
|
||||
if InputMap.has_action(action_name):
|
||||
return
|
||||
var ev := InputEventKey.new()
|
||||
ev.keycode = key
|
||||
InputMap.add_action(action_name)
|
||||
InputMap.action_add_event(action_name, ev)
|
||||
Reference in New Issue
Block a user