rnd(M02): extract third-person camera rig
Work-Package: M02-RND-CAMERA-001
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
class_name CameraCollisionPolicy
|
||||
extends RefCounted
|
||||
|
||||
## Replaceable third-person camera-distance policy.
|
||||
## Implementations may inspect the explicit pivot/world boundary but must return
|
||||
## a distance in Godot units without mutating the rig or camera nodes.
|
||||
|
||||
|
||||
## Resolves the camera distance for the current pivot and requested zoom.
|
||||
## The base policy preserves the request unchanged.
|
||||
func resolve_camera_distance(
|
||||
_camera_pivot: Node3D,
|
||||
requested_distance_units: float
|
||||
) -> float:
|
||||
return requested_distance_units
|
||||
@@ -0,0 +1 @@
|
||||
uid://bgd41bb3hvbl1
|
||||
@@ -0,0 +1,12 @@
|
||||
class_name NoCameraCollisionPolicy
|
||||
extends CameraCollisionPolicy
|
||||
|
||||
## Identity collision policy preserving the pre-M02 sandbox camera behavior.
|
||||
|
||||
|
||||
## Returns the requested distance without physics queries or allocations.
|
||||
func resolve_camera_distance(
|
||||
_camera_pivot: Node3D,
|
||||
requested_distance_units: float
|
||||
) -> float:
|
||||
return requested_distance_units
|
||||
@@ -0,0 +1 @@
|
||||
uid://csts45n5rnxro
|
||||
@@ -0,0 +1,169 @@
|
||||
class_name ThirdPersonCameraRig
|
||||
extends Node3D
|
||||
|
||||
## Third-person orbit/zoom state and Camera3D presentation adapter.
|
||||
## The rig owns camera input state and local transforms. Character movement,
|
||||
## terrain, rendering and collision-policy decisions remain external boundaries.
|
||||
|
||||
const NO_CAMERA_COLLISION_POLICY_SCRIPT := preload("res://src/scenes/player/no_camera_collision_policy.gd")
|
||||
|
||||
## Relative Camera3D child used by this rig.
|
||||
@export var camera_path: NodePath = NodePath("Camera3D")
|
||||
## Mouse radians applied per input pixel.
|
||||
@export var mouse_sensitivity_radians_per_pixel: float = 0.003
|
||||
## Minimum orbit pitch in radians.
|
||||
@export var minimum_pitch_radians: float = deg_to_rad(-65.0)
|
||||
## Maximum orbit pitch in radians.
|
||||
@export var maximum_pitch_radians: float = deg_to_rad(35.0)
|
||||
## Pivot height above the character origin in Godot units.
|
||||
@export var pivot_height_units: float = 1.7
|
||||
## Initial requested camera distance in Godot units.
|
||||
@export var initial_distance_units: float = 8.0
|
||||
## Minimum user-requested zoom distance in Godot units.
|
||||
@export var minimum_distance_units: float = 2.0
|
||||
## Maximum user-requested zoom distance in Godot units.
|
||||
@export var maximum_distance_units: float = 18.0
|
||||
## Distance change per zoom action in Godot units.
|
||||
@export var zoom_step_units: float = 1.0
|
||||
## Camera far plane retained from the renderer sandbox baseline.
|
||||
@export var camera_far_plane_units: float = 50000.0
|
||||
|
||||
var yaw_radians: float:
|
||||
get:
|
||||
return _yaw_radians
|
||||
|
||||
var pitch_radians: float:
|
||||
get:
|
||||
return _pitch_radians
|
||||
|
||||
var requested_distance_units: float:
|
||||
get:
|
||||
return _requested_distance_units
|
||||
|
||||
var resolved_distance_units: float:
|
||||
get:
|
||||
return _resolved_distance_units
|
||||
|
||||
var is_mouse_captured: bool:
|
||||
get:
|
||||
return _is_mouse_captured
|
||||
|
||||
var _character_body: Node3D
|
||||
var _camera: Camera3D
|
||||
var _collision_policy: CameraCollisionPolicy
|
||||
var _yaw_radians := 0.0
|
||||
var _pitch_radians := deg_to_rad(-18.0)
|
||||
var _requested_distance_units := 8.0
|
||||
var _resolved_distance_units := 8.0
|
||||
var _is_mouse_captured := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_camera = get_node_or_null(camera_path) as Camera3D
|
||||
if _camera == null:
|
||||
push_error("ThirdPersonCameraRig: Camera3D not found at %s" % camera_path)
|
||||
if _collision_policy == null:
|
||||
_collision_policy = NO_CAMERA_COLLISION_POLICY_SCRIPT.new()
|
||||
_pitch_radians = clampf(_pitch_radians, minimum_pitch_radians, maximum_pitch_radians)
|
||||
_requested_distance_units = clampf(
|
||||
initial_distance_units,
|
||||
minimum_distance_units,
|
||||
maximum_distance_units
|
||||
)
|
||||
if _camera != null:
|
||||
_camera.current = true
|
||||
_camera.far = camera_far_plane_units
|
||||
refresh_camera_transform()
|
||||
|
||||
|
||||
func _physics_process(_delta_seconds: float) -> void:
|
||||
refresh_camera_transform()
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if _is_mouse_captured:
|
||||
_is_mouse_captured = false
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
|
||||
|
||||
## Attaches the character transform rotated by orbit input and captures its initial yaw.
|
||||
func initialize_for_character(character_body: Node3D) -> void:
|
||||
_character_body = character_body
|
||||
if _character_body != null:
|
||||
_yaw_radians = _character_body.rotation.y
|
||||
refresh_camera_transform()
|
||||
|
||||
|
||||
## Replaces camera-distance collision behavior and immediately refreshes the camera.
|
||||
## Passing null is rejected and preserves the current policy.
|
||||
func set_collision_policy(collision_policy: CameraCollisionPolicy) -> void:
|
||||
if collision_policy == null:
|
||||
push_error("ThirdPersonCameraRig: collision policy cannot be null")
|
||||
return
|
||||
_collision_policy = collision_policy
|
||||
refresh_camera_transform()
|
||||
|
||||
|
||||
## Applies remappable camera actions and orbit mouse motion.
|
||||
## Returns true when the event changed or consumed camera state.
|
||||
func handle_camera_input(event: InputEvent) -> bool:
|
||||
if event.is_action_pressed(PlayerInputActions.CAMERA_ROTATE):
|
||||
_is_mouse_captured = true
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
return true
|
||||
if event.is_action_released(PlayerInputActions.CAMERA_ROTATE):
|
||||
_is_mouse_captured = false
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
return true
|
||||
if event.is_action_pressed(PlayerInputActions.CAMERA_ZOOM_IN):
|
||||
_requested_distance_units = clampf(
|
||||
_requested_distance_units - zoom_step_units,
|
||||
minimum_distance_units,
|
||||
maximum_distance_units
|
||||
)
|
||||
refresh_camera_transform()
|
||||
return true
|
||||
if event.is_action_pressed(PlayerInputActions.CAMERA_ZOOM_OUT):
|
||||
_requested_distance_units = clampf(
|
||||
_requested_distance_units + zoom_step_units,
|
||||
minimum_distance_units,
|
||||
maximum_distance_units
|
||||
)
|
||||
refresh_camera_transform()
|
||||
return true
|
||||
if event.is_action_pressed(PlayerInputActions.RELEASE_CURSOR):
|
||||
_is_mouse_captured = false
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
return true
|
||||
if _is_mouse_captured and event is InputEventMouseMotion:
|
||||
_yaw_radians -= event.relative.x * mouse_sensitivity_radians_per_pixel
|
||||
_pitch_radians = clampf(
|
||||
_pitch_radians - event.relative.y * mouse_sensitivity_radians_per_pixel,
|
||||
minimum_pitch_radians,
|
||||
maximum_pitch_radians
|
||||
)
|
||||
if _character_body != null:
|
||||
_character_body.rotation.y = _yaw_radians
|
||||
refresh_camera_transform()
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## Returns the pivot's Godot-world basis for camera-relative sandbox flight.
|
||||
func godot_world_flight_movement_basis() -> Basis:
|
||||
return global_basis
|
||||
|
||||
|
||||
## Applies pivot and Camera3D local transforms using the active collision policy.
|
||||
func refresh_camera_transform() -> void:
|
||||
position = Vector3(0.0, pivot_height_units, 0.0)
|
||||
rotation.x = _pitch_radians
|
||||
if _camera == null:
|
||||
return
|
||||
var policy_distance := _collision_policy.resolve_camera_distance(
|
||||
self,
|
||||
_requested_distance_units
|
||||
) if _collision_policy != null else _requested_distance_units
|
||||
_resolved_distance_units = clampf(policy_distance, 0.0, _requested_distance_units)
|
||||
_camera.position = Vector3(0.0, 0.0, _resolved_distance_units)
|
||||
_camera.rotation = Vector3.ZERO
|
||||
@@ -0,0 +1 @@
|
||||
uid://cq60al4uw0mgo
|
||||
@@ -24,19 +24,10 @@ const ADT_TERRAIN_QUERY_SCRIPT := preload("res://src/gameplay/terrain/adt_terrai
|
||||
@export var sprint_multiplier: float = 6.0
|
||||
@export var flight_vertical_speed: float = 7.0
|
||||
@export var acceleration: float = 28.0
|
||||
@export var mouse_sensitivity: float = 0.003
|
||||
@export var camera_pitch_min: float = deg_to_rad(-65.0)
|
||||
@export var camera_pitch_max: float = deg_to_rad(35.0)
|
||||
@export var camera_height: float = 1.7
|
||||
@export var camera_distance: float = 8.0
|
||||
@export var camera_min_distance: float = 2.0
|
||||
@export var camera_max_distance: float = 18.0
|
||||
@export var camera_zoom_step: float = 1.0
|
||||
@export var ground_offset: float = 0.05
|
||||
@export var ground_snap_speed: float = 24.0
|
||||
|
||||
@export var camera_pivot_path: NodePath = NodePath("CameraPivot")
|
||||
@export var camera_path: NodePath = NodePath("CameraPivot/Camera3D")
|
||||
@export var visual_path: NodePath = NodePath("Visual")
|
||||
@export var character_model_path: String = "res://src/resources/characters/HUMAN/MALE/HumanMale.glb"
|
||||
@export var character_class_id: int = 1
|
||||
@@ -44,15 +35,11 @@ const ADT_TERRAIN_QUERY_SCRIPT := preload("res://src/gameplay/terrain/adt_terrai
|
||||
@export var character_yaw_offset_degrees: float = 90.0
|
||||
@export var animation_blend_time: float = 0.15
|
||||
|
||||
var _camera_pivot: Node3D
|
||||
var _camera: Camera3D
|
||||
var _camera_rig: ThirdPersonCameraRig
|
||||
var _visual: Node3D
|
||||
var _character_root: Node3D
|
||||
var _animation_player: AnimationPlayer
|
||||
var _active_animation := ""
|
||||
var _captured := false
|
||||
var _yaw := 0.0
|
||||
var _pitch := deg_to_rad(-18.0)
|
||||
var _player_input_source: PlayerInputSource
|
||||
var _local_movement_controller: LocalPlayerMovementController
|
||||
var _terrain_query: TerrainQuery
|
||||
@@ -79,12 +66,12 @@ func _ready() -> void:
|
||||
acceleration,
|
||||
sprint_multiplier
|
||||
)
|
||||
_camera_pivot = get_node_or_null(camera_pivot_path) as Node3D
|
||||
_camera = get_node_or_null(camera_path) as Camera3D
|
||||
_camera_rig = get_node_or_null(camera_pivot_path) as ThirdPersonCameraRig
|
||||
_visual = get_node_or_null(visual_path) as Node3D
|
||||
if _camera:
|
||||
_camera.current = true
|
||||
_camera.far = 50000.0
|
||||
if _camera_rig == null:
|
||||
push_error("ThirdPersonWowController: ThirdPersonCameraRig not found at %s" % camera_pivot_path)
|
||||
else:
|
||||
_camera_rig.initialize_for_character(self)
|
||||
if spawn_at_tile_center:
|
||||
var spawn_tile = ADT_TILE_COORDINATE_SCRIPT.new(spawn_tile_x, spawn_tile_y)
|
||||
var half_tile_size := COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS * 0.5
|
||||
@@ -95,41 +82,20 @@ func _ready() -> void:
|
||||
var spawn_ground_sample := _sample_ground_height(global_position)
|
||||
if spawn_ground_sample.is_available:
|
||||
global_position.y = spawn_ground_sample.height_units + ground_offset
|
||||
_yaw = rotation.y
|
||||
_apply_camera_transform()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ROTATE):
|
||||
_captured = true
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
elif event.is_action_released(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ROTATE):
|
||||
_captured = false
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ZOOM_IN):
|
||||
camera_distance = clampf(camera_distance - camera_zoom_step, camera_min_distance, camera_max_distance)
|
||||
_apply_camera_transform()
|
||||
elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ZOOM_OUT):
|
||||
camera_distance = clampf(camera_distance + camera_zoom_step, camera_min_distance, camera_max_distance)
|
||||
_apply_camera_transform()
|
||||
elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.RELEASE_CURSOR):
|
||||
_captured = false
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _captured else Input.MOUSE_MODE_VISIBLE
|
||||
elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.DEBUG_TOGGLE_FLIGHT) and not event.is_echo():
|
||||
if _camera_rig != null:
|
||||
_camera_rig.handle_camera_input(event)
|
||||
if event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.DEBUG_TOGGLE_FLIGHT) and not event.is_echo():
|
||||
_local_movement_controller.toggle_sandbox_flight()
|
||||
|
||||
if _captured and event is InputEventMouseMotion:
|
||||
_yaw -= event.relative.x * mouse_sensitivity
|
||||
_pitch = clampf(_pitch - event.relative.y * mouse_sensitivity, camera_pitch_min, camera_pitch_max)
|
||||
rotation.y = _yaw
|
||||
_apply_camera_transform()
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
var move_intent := _player_input_source.sample_move_intent()
|
||||
var godot_world_movement_basis := global_basis
|
||||
if _local_movement_controller.is_flight_enabled and _camera_pivot:
|
||||
godot_world_movement_basis = _camera_pivot.global_basis
|
||||
if _local_movement_controller.is_flight_enabled and _camera_rig != null:
|
||||
godot_world_movement_basis = _camera_rig.godot_world_flight_movement_basis()
|
||||
global_position += _local_movement_controller.advance(move_intent, godot_world_movement_basis, delta)
|
||||
|
||||
if not _local_movement_controller.is_flight_enabled:
|
||||
@@ -145,8 +111,6 @@ func _physics_process(delta: float) -> void:
|
||||
|
||||
_update_character_animation(horizontal_motion.length_squared() > 0.04)
|
||||
|
||||
_apply_camera_transform()
|
||||
|
||||
func _load_character_visual() -> void:
|
||||
if character_model_path.is_empty() or _visual == null:
|
||||
return
|
||||
@@ -298,15 +262,6 @@ func _character_textures_dir(model_path: String) -> String:
|
||||
return model_path.get_base_dir().path_join(model_path.get_file().get_basename() + "_textures")
|
||||
|
||||
|
||||
func _apply_camera_transform() -> void:
|
||||
if _camera_pivot:
|
||||
_camera_pivot.position = Vector3(0.0, camera_height, 0.0)
|
||||
_camera_pivot.rotation.x = _pitch
|
||||
if _camera:
|
||||
_camera.position = Vector3(0.0, 0.0, camera_distance)
|
||||
_camera.rotation = Vector3.ZERO
|
||||
|
||||
|
||||
func _sample_ground_height(godot_world_position: Vector3) -> TerrainGroundSample:
|
||||
return _terrain_query.sample_ground_height(
|
||||
GODOT_WORLD_POSITION_SCRIPT.new(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
[ext_resource type="Script" uid="uid://yi6lawwjgocg" path="res://src/scenes/streaming/streaming_world_loader.gd" id="1_stream"]
|
||||
[ext_resource type="Script" uid="uid://ltn2ko5kxvo4" path="res://src/scenes/player/third_person_wow_controller.gd" id="2_player"]
|
||||
[ext_resource type="Script" uid="uid://didjth34ut0v1" path="res://src/scenes/sky/wow_sky_controller.gd" id="3_sky"]
|
||||
[ext_resource type="Script" path="res://src/scenes/player/third_person_camera_rig.gd" id="4_camera_rig"]
|
||||
|
||||
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
|
||||
radius = 0.45
|
||||
@@ -107,6 +108,7 @@ mesh = SubResource("PlayerCapsuleMesh_1")
|
||||
|
||||
[node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer" unique_id=499263249]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.7, 0)
|
||||
script = ExtResource("4_camera_rig")
|
||||
|
||||
[node name="Camera3D" type="Camera3D" parent="ThirdPersonPlayer/CameraPivot" unique_id=2142337971]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 8)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
[ext_resource type="Script" uid="uid://yi6lawwjgocg" path="res://src/scenes/streaming/streaming_world_loader.gd" id="1_sisqv"]
|
||||
[ext_resource type="Script" uid="uid://ltn2ko5kxvo4" path="res://src/scenes/player/third_person_wow_controller.gd" id="2_puy8r"]
|
||||
[ext_resource type="Script" uid="uid://didjth34ut0v1" path="res://src/scenes/sky/wow_sky_controller.gd" id="3_43ha7"]
|
||||
[ext_resource type="Script" path="res://src/scenes/player/third_person_camera_rig.gd" id="4_camera_rig"]
|
||||
|
||||
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
|
||||
radius = 0.45
|
||||
@@ -108,6 +109,7 @@ mesh = SubResource("PlayerCapsuleMesh_1")
|
||||
|
||||
[node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer" unique_id=499263249]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.7, 0)
|
||||
script = ExtResource("4_camera_rig")
|
||||
|
||||
[node name="Camera3D" type="Camera3D" parent="ThirdPersonPlayer/CameraPivot" unique_id=2142337971]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 8)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://src/scenes/player/third_person_wow_controller.gd" id="1_player"]
|
||||
[ext_resource type="Script" path="res://src/scenes/player/third_person_camera_rig.gd" id="2_camera_rig"]
|
||||
|
||||
[node name="PlayerInputRegression" type="CharacterBody3D"]
|
||||
script = ExtResource("1_player")
|
||||
@@ -10,5 +11,6 @@ character_model_path = ""
|
||||
[node name="Visual" type="Node3D" parent="."]
|
||||
|
||||
[node name="CameraPivot" type="Node3D" parent="."]
|
||||
script = ExtResource("2_camera_rig")
|
||||
|
||||
[node name="Camera3D" type="Camera3D" parent="CameraPivot"]
|
||||
|
||||
@@ -102,12 +102,13 @@ func _verify_regression_scene(failures: Array[String]) -> void:
|
||||
Input.action_release(PlayerInputActions.MOVE_FORWARD)
|
||||
_expect_near(player.position.z, -7.0, "forward action moves current sandbox controller", failures)
|
||||
|
||||
var camera_distance_before: float = player.get("camera_distance")
|
||||
var camera_rig := player.get_node("CameraPivot") as ThirdPersonCameraRig
|
||||
var camera_distance_before := camera_rig.requested_distance_units
|
||||
var zoom_event := InputEventMouseButton.new()
|
||||
zoom_event.button_index = MOUSE_BUTTON_WHEEL_UP
|
||||
zoom_event.pressed = true
|
||||
player.call("_unhandled_input", zoom_event)
|
||||
_expect_near(player.get("camera_distance"), camera_distance_before - 1.0, "zoom action updates camera distance", failures)
|
||||
_expect_near(camera_rig.requested_distance_units, camera_distance_before - 1.0, "zoom action updates camera distance", failures)
|
||||
|
||||
var flight_toggle_event := InputEventAction.new()
|
||||
flight_toggle_event.action = PlayerInputActions.DEBUG_TOGGLE_FLIGHT
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
extends SceneTree
|
||||
|
||||
## Headless M02 camera state, scene wiring and collision-policy regression.
|
||||
|
||||
const PLAYER_CONTROLLER_PATH := "res://src/scenes/player/third_person_wow_controller.gd"
|
||||
const CAMERA_RIG_PATH := "res://src/scenes/player/third_person_camera_rig.gd"
|
||||
const NO_COLLISION_POLICY_PATH := "res://src/scenes/player/no_camera_collision_policy.gd"
|
||||
const REGRESSION_SCENE_PATH := "res://src/tests/scenes/player_input_regression.tscn"
|
||||
const RUNTIME_SCENE_PATHS: Array[String] = [
|
||||
"res://src/scenes/streaming/eastern_kingdoms_streaming.tscn",
|
||||
"res://src/scenes/streaming/kalimdor_streaming.tscn",
|
||||
]
|
||||
|
||||
|
||||
class FixedDistanceCameraCollisionPolicy extends CameraCollisionPolicy:
|
||||
var _fixed_distance_units: float
|
||||
|
||||
func _init(fixed_distance_units: float) -> void:
|
||||
_fixed_distance_units = fixed_distance_units
|
||||
|
||||
func resolve_camera_distance(
|
||||
_camera_pivot: Node3D,
|
||||
_requested_distance_units: float
|
||||
) -> float:
|
||||
return _fixed_distance_units
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run_verification.call_deferred()
|
||||
|
||||
|
||||
func _run_verification() -> void:
|
||||
var failures: Array[String] = []
|
||||
var player := _instantiate_regression_player(failures)
|
||||
if player != null:
|
||||
_verify_initial_state(player, failures)
|
||||
_verify_zoom_clamps(player, failures)
|
||||
_verify_orbit_and_capture(player, failures)
|
||||
_verify_collision_policy(player, failures)
|
||||
player.free()
|
||||
_verify_source_boundaries(failures)
|
||||
_verify_scene_wiring(failures)
|
||||
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("THIRD_PERSON_CAMERA: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("THIRD_PERSON_CAMERA PASS state_cases=12 policy_cases=3 scenes=3 player_boundary=1")
|
||||
quit(0)
|
||||
|
||||
|
||||
func _instantiate_regression_player(failures: Array[String]) -> CharacterBody3D:
|
||||
var packed_scene := load(REGRESSION_SCENE_PATH) as PackedScene
|
||||
_expect_true(packed_scene != null, "regression scene loads", failures)
|
||||
if packed_scene == null:
|
||||
return null
|
||||
var player := packed_scene.instantiate() as CharacterBody3D
|
||||
_expect_true(player != null, "regression player instantiates", failures)
|
||||
if player == null:
|
||||
return null
|
||||
root.add_child(player)
|
||||
player.set_physics_process(false)
|
||||
var camera_rig := player.get_node("CameraPivot") as ThirdPersonCameraRig
|
||||
camera_rig.set_physics_process(false)
|
||||
return player
|
||||
|
||||
|
||||
func _verify_initial_state(player: CharacterBody3D, failures: Array[String]) -> void:
|
||||
var camera_rig := player.get_node("CameraPivot") as ThirdPersonCameraRig
|
||||
var camera := camera_rig.get_node("Camera3D") as Camera3D
|
||||
_expect_near(camera_rig.yaw_radians, 0.0, "initial yaw", failures)
|
||||
_expect_near(camera_rig.pitch_radians, deg_to_rad(-18.0), "initial pitch", failures)
|
||||
_expect_near(camera_rig.requested_distance_units, 8.0, "initial requested distance", failures)
|
||||
_expect_near(camera_rig.resolved_distance_units, 8.0, "initial resolved distance", failures)
|
||||
_expect_near(camera_rig.position.y, 1.7, "initial pivot height", failures)
|
||||
_expect_near(camera.position.z, 8.0, "initial camera local Z", failures)
|
||||
_expect_near(camera.far, 50000.0, "camera far plane", failures)
|
||||
_expect_true(camera_rig.godot_world_flight_movement_basis().is_equal_approx(camera_rig.global_basis), "flight basis is pivot basis", failures)
|
||||
|
||||
|
||||
func _verify_zoom_clamps(player: CharacterBody3D, failures: Array[String]) -> void:
|
||||
var camera_rig := player.get_node("CameraPivot") as ThirdPersonCameraRig
|
||||
var zoom_in_event := _action_event(PlayerInputActions.CAMERA_ZOOM_IN, true)
|
||||
for _step in range(20):
|
||||
camera_rig.handle_camera_input(zoom_in_event)
|
||||
_expect_near(camera_rig.requested_distance_units, 2.0, "minimum zoom clamp", failures)
|
||||
for _step in range(30):
|
||||
camera_rig.handle_camera_input(_action_event(PlayerInputActions.CAMERA_ZOOM_OUT, true))
|
||||
_expect_near(camera_rig.requested_distance_units, 18.0, "maximum zoom clamp", failures)
|
||||
|
||||
|
||||
func _verify_orbit_and_capture(player: CharacterBody3D, failures: Array[String]) -> void:
|
||||
var camera_rig := player.get_node("CameraPivot") as ThirdPersonCameraRig
|
||||
_expect_true(camera_rig.handle_camera_input(_action_event(PlayerInputActions.CAMERA_ROTATE, true)), "capture action consumed", failures)
|
||||
_expect_true(camera_rig.is_mouse_captured, "mouse capture state", failures)
|
||||
|
||||
var mouse_motion := InputEventMouseMotion.new()
|
||||
mouse_motion.relative = Vector2(100.0, -50.0)
|
||||
_expect_true(camera_rig.handle_camera_input(mouse_motion), "captured mouse motion consumed", failures)
|
||||
_expect_near(camera_rig.yaw_radians, -0.3, "orbit yaw", failures)
|
||||
_expect_near(camera_rig.pitch_radians, deg_to_rad(-18.0) + 0.15, "orbit pitch", failures)
|
||||
_expect_near(player.rotation.y, -0.3, "character follows orbit yaw", failures)
|
||||
|
||||
var clamp_up_motion := InputEventMouseMotion.new()
|
||||
clamp_up_motion.relative = Vector2(0.0, -100000.0)
|
||||
camera_rig.handle_camera_input(clamp_up_motion)
|
||||
_expect_near(camera_rig.pitch_radians, deg_to_rad(35.0), "maximum pitch clamp", failures)
|
||||
var clamp_down_motion := InputEventMouseMotion.new()
|
||||
clamp_down_motion.relative = Vector2(0.0, 100000.0)
|
||||
camera_rig.handle_camera_input(clamp_down_motion)
|
||||
_expect_near(camera_rig.pitch_radians, deg_to_rad(-65.0), "minimum pitch clamp", failures)
|
||||
|
||||
camera_rig.handle_camera_input(_action_event(PlayerInputActions.CAMERA_ROTATE, false))
|
||||
_expect_true(not camera_rig.is_mouse_captured, "mouse release state", failures)
|
||||
|
||||
|
||||
func _verify_collision_policy(player: CharacterBody3D, failures: Array[String]) -> void:
|
||||
var camera_rig := player.get_node("CameraPivot") as ThirdPersonCameraRig
|
||||
var camera := camera_rig.get_node("Camera3D") as Camera3D
|
||||
camera_rig.set_collision_policy(FixedDistanceCameraCollisionPolicy.new(3.0))
|
||||
_expect_near(camera_rig.resolved_distance_units, 3.0, "fixed policy distance", failures)
|
||||
_expect_near(camera.position.z, 3.0, "fixed policy camera transform", failures)
|
||||
camera_rig.set_collision_policy(NoCameraCollisionPolicy.new())
|
||||
_expect_near(camera_rig.resolved_distance_units, 18.0, "identity policy restores request", failures)
|
||||
|
||||
|
||||
func _verify_source_boundaries(failures: Array[String]) -> void:
|
||||
var player_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
|
||||
for forbidden_text in ["var _captured", "var _yaw", "var _pitch", "func _apply_camera_transform", "camera_distance"]:
|
||||
_expect_true(not player_source.contains(forbidden_text), "player omits %s" % forbidden_text, failures)
|
||||
_expect_true(player_source.contains("_camera_rig.handle_camera_input"), "player delegates camera input", failures)
|
||||
|
||||
var rig_source := _read_text(CAMERA_RIG_PATH, failures)
|
||||
_expect_true(rig_source.contains("extends Node3D"), "camera rig owns pivot Node3D", failures)
|
||||
for forbidden_text in ["ADTLoader", "TerrainQuery", "MoveIntent", "AnimationPlayer"]:
|
||||
_expect_true(not rig_source.contains(forbidden_text), "camera rig omits %s" % forbidden_text, failures)
|
||||
|
||||
var no_collision_source := _read_text(NO_COLLISION_POLICY_PATH, failures)
|
||||
for forbidden_text in ["intersect_ray", "direct_space_state", "PhysicsRayQueryParameters3D"]:
|
||||
_expect_true(not no_collision_source.contains(forbidden_text), "baseline policy omits %s" % forbidden_text, failures)
|
||||
|
||||
|
||||
func _verify_scene_wiring(failures: Array[String]) -> void:
|
||||
for scene_path in RUNTIME_SCENE_PATHS + [REGRESSION_SCENE_PATH]:
|
||||
var scene_source := _read_text(scene_path, failures)
|
||||
_expect_true(scene_source.contains("third_person_camera_rig.gd"), "%s references camera rig" % scene_path, failures)
|
||||
_expect_true(scene_source.contains('script = ExtResource("4_camera_rig")') or scene_source.contains('script = ExtResource("2_camera_rig")'), "%s wires CameraPivot script" % scene_path, failures)
|
||||
|
||||
|
||||
func _action_event(action_name: StringName, is_pressed: bool) -> InputEventAction:
|
||||
var input_event := InputEventAction.new()
|
||||
input_event.action = action_name
|
||||
input_event.pressed = is_pressed
|
||||
return input_event
|
||||
|
||||
|
||||
func _read_text(path: String, failures: Array[String]) -> String:
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
failures.append("cannot open %s" % path)
|
||||
return ""
|
||||
return file.get_as_text()
|
||||
|
||||
|
||||
func _expect_near(actual_value: float, expected_value: float, label: String, failures: Array[String]) -> void:
|
||||
if absf(actual_value - expected_value) > 0.000001:
|
||||
failures.append("%s expected %.6f, got %.6f" % [label, expected_value, actual_value])
|
||||
|
||||
|
||||
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
|
||||
if not actual_value:
|
||||
failures.append("%s expected true" % label)
|
||||
Reference in New Issue
Block a user