From 87f78b02269a742aa089b4e3d16749fca395f6e0 Mon Sep 17 00:00:00 2001 From: daddy Date: Sat, 5 Sep 2026 23:13:07 +0000 Subject: [PATCH] Third-person camera + fix the avatar customize preview You couldn't see your own character because the camera was first-person (inside the avatar's head), and the customize screen's 3D preview rendered nothing. - player.tscn: camera now hangs off a SpringArm3D on a CameraPivot, 4 m behind + above the player, pulled in on collision. player.gd: yaw still turns the body, pitch orbits the arm (starts tilted down to frame the avatar). - avatar_customization_ui.gd: the preview SubViewport had own_world_3d=false, so it shared the Control-only main world -- no scenario, nothing rendered. Set own_world_3d=true, add a WorldEnvironment with ambient fill + a key/fill light pair, make the camera current, tweak framing. - smoke_test_player: assert the third-person SpringArm rig (18/18). - smoke_test_enter_world: wait longer for the scene change -- a real POST /join spawns a dedicated server, which is slow (9/9). Client-side change -- rebuild the exe to get it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ --- scenes/player.tscn | 13 ++++++-- scripts/avatar/avatar_customization_ui.gd | 35 +++++++++++++++++---- scripts/player.gd | 38 +++++++++++++---------- scripts/smoke_test_enter_world.gd | 2 +- scripts/smoke_test_player.gd | 8 ++++- 5 files changed, 68 insertions(+), 28 deletions(-) diff --git a/scenes/player.tscn b/scenes/player.tscn index 88452b1..4047391 100644 --- a/scenes/player.tscn +++ b/scenes/player.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=3 format=3 uid="uid://player_scene_001"] +[gd_scene load_steps=4 format=3 uid="uid://player_scene_001"] [ext_resource type="Script" path="res://scripts/player.gd" id="1_player"] @@ -19,6 +19,13 @@ shape = SubResource("CapsuleShape1") [node name="CapsuleMesh" type="MeshInstance3D" parent="."] mesh = SubResource("CapsuleMesh1") -[node name="Camera3D" type="Camera3D" parent="."] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.6, 0) +[node name="CameraPivot" type="Node3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.7, 0) + +[node name="SpringArm3D" type="SpringArm3D" parent="CameraPivot"] +transform = Transform3D(-1, 0, -8.7422777e-08, 0, 1, 0, 8.7422777e-08, 0, -1, 0, 0, 0) +spring_length = 4.0 +margin = 0.2 + +[node name="Camera3D" type="Camera3D" parent="CameraPivot/SpringArm3D"] current = true diff --git a/scripts/avatar/avatar_customization_ui.gd b/scripts/avatar/avatar_customization_ui.gd index a45989f..5cec4a6 100644 --- a/scripts/avatar/avatar_customization_ui.gd +++ b/scripts/avatar/avatar_customization_ui.gd @@ -165,14 +165,31 @@ func _build_ui() -> void: _preview_viewport.name = "PreviewViewport" _preview_viewport.size = Vector2i(464, 560) _preview_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + _preview_viewport.transparent_bg = true + # Its own 3D world -- a SubViewport that shares the (Control-only) main + # world has no scenario/environment set up and renders nothing. + _preview_viewport.own_world_3d = true _preview_container.add_child(_preview_viewport) + # Environment: ambient fill so the model reads even from a bad light angle. + var world_env := WorldEnvironment.new() + world_env.name = "PreviewEnv" + var env := Environment.new() + env.background_mode = Environment.BG_COLOR + env.background_color = Color(0.10, 0.11, 0.14) + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + env.ambient_light_color = Color(0.6, 0.6, 0.65) + env.ambient_light_energy = 1.0 + world_env.environment = env + _preview_viewport.add_child(world_env) + # Camera for the preview. _preview_camera = Camera3D.new() _preview_camera.name = "PreviewCamera" # look_at needs a global transform, which only exists once in the tree. _preview_camera.look_at_from_position( - Vector3(0, 1.0, 3.0), Vector3(0, 1.0, 0), Vector3.UP) + Vector3(0, 1.0, 3.2), Vector3(0, 0.9, 0), Vector3.UP) + _preview_camera.current = true _preview_viewport.add_child(_preview_camera) # Avatar root inside the viewport. @@ -180,11 +197,17 @@ func _build_ui() -> void: _preview_root.name = "AvatarPreviewRoot" _preview_viewport.add_child(_preview_root) - # A light so the avatar is visible. - var light := DirectionalLight3D.new() - light.name = "PreviewLight" - light.rotation_degrees = Vector3(-45, 30, 0) - _preview_viewport.add_child(light) + # Key + fill lights so the avatar is clearly visible. + var key := DirectionalLight3D.new() + key.name = "PreviewKeyLight" + key.rotation_degrees = Vector3(-35, 35, 0) + key.light_energy = 1.2 + _preview_viewport.add_child(key) + var fill := DirectionalLight3D.new() + fill.name = "PreviewFillLight" + fill.rotation_degrees = Vector3(-20, -140, 0) + fill.light_energy = 0.4 + _preview_viewport.add_child(fill) # --- Right: controls --- var controls_panel := PanelContainer.new() diff --git a/scripts/player.gd b/scripts/player.gd index 996c9c5..ef7348a 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -2,11 +2,10 @@ class_name Player extends CharacterBody3D ## The player character controller. ## -## A simple CharacterBody3D with WASD/arrow locomotion, gravity, jump, and a -## first-person Camera3D with mouse-look. Reads the spawn point from the -## current scene's `PlayerSpawn` Marker3D (see docs/architecture/pc2_test_level.md §3). -## -## No networking, no chunk code, no avatar UI — PC2 is local-only. +## A CharacterBody3D with WASD/arrow locomotion, gravity, jump, and a +## third-person camera (SpringArm3D behind the player) with mouse-look -- +## yaw turns the body, pitch orbits the camera. Reads the spawn point from +## the current scene's `PlayerSpawn` Marker3D. # --------------------------------------------------------------------------- @@ -20,23 +19,25 @@ const GRAVITY: float = 9.8 ## Mouse-look sensitivity (radians per pixel of relative motion). const MOUSE_SENSITIVITY: float = 0.003 -## Camera pitch is clamped so the player can't flip past looking straight -## up/down (which would otherwise let mouse-look invert on itself). -const PITCH_LIMIT: float = deg_to_rad(89.0) - # --------------------------------------------------------------------------- # Input state # --------------------------------------------------------------------------- var _input_direction := Vector2.ZERO -var _camera_pitch: float = 0.0 +var _camera_pitch: float = -0.25 ## Vertical offset applied to the assembled avatar so its feet (origin y=0) ## line up with the bottom of the player's collision capsule. const AVATAR_FEET_OFFSET: float = -0.8 -@onready var _camera: Camera3D = $Camera3D +## Third-person pitch limits (radians): a bit up, a lot down (looking over +## the avatar's shoulder toward the ground). +const PITCH_MIN: float = -1.1 +const PITCH_MAX: float = 0.45 + +@onready var _camera: Camera3D = $CameraPivot/SpringArm3D/Camera3D +@onready var _camera_pivot: Node3D = $CameraPivot @onready var _capsule_mesh: MeshInstance3D = $CapsuleMesh var _avatar_view: Node3D = null @@ -69,8 +70,11 @@ func _ready() -> void: push_warning("player.gd: no PlayerSpawn marker found; spawning at origin") global_position = Vector3(0, 1.0, 0) - # Capture the mouse for FPS-style look. Headless/test runs have no real - # window to capture input for, so guard against that rather than error. + # Start the camera arm tilted down a little so the avatar is framed. + _camera_pivot.rotation.x = _camera_pitch + + # Capture the mouse for look. Headless/test runs have no real window to + # capture input for, so guard against that rather than error. if DisplayServer.get_name() != "headless": Input.mouse_mode = Input.MOUSE_MODE_CAPTURED @@ -143,11 +147,11 @@ func _clear_input_direction(key: InputEventKey) -> void: func _handle_mouse_look(event: InputEventMouseMotion) -> void: # Yaw rotates the whole body (so movement direction turns with the - # look direction); pitch only tilts the camera, so the capsule itself - # never tips over. + # look direction); pitch orbits the camera arm around the player, so the + # capsule itself never tips over. rotation.y -= event.relative.x * MOUSE_SENSITIVITY - _camera_pitch = clampf(_camera_pitch - event.relative.y * MOUSE_SENSITIVITY, -PITCH_LIMIT, PITCH_LIMIT) - _camera.rotation.x = _camera_pitch + _camera_pitch = clampf(_camera_pitch - event.relative.y * MOUSE_SENSITIVITY, PITCH_MIN, PITCH_MAX) + _camera_pivot.rotation.x = _camera_pitch # --------------------------------------------------------------------------- diff --git a/scripts/smoke_test_enter_world.gd b/scripts/smoke_test_enter_world.gd index 7ab0ca6..f92cbee 100644 --- a/scripts/smoke_test_enter_world.gd +++ b/scripts/smoke_test_enter_world.gd @@ -81,7 +81,7 @@ func _run() -> void: return enter_button.pressed.emit() - await _wait_for_scene_change(instance, 60) + await _wait_for_scene_change(instance, 600) # generous -- a real POST /join may spawn a dedicated server (slow) var game_scene := current_scene if not _check("game.tscn became current_scene after Enter World", game_scene != null and game_scene != instance): diff --git a/scripts/smoke_test_player.gd b/scripts/smoke_test_player.gd index 22629fb..2acaaec 100644 --- a/scripts/smoke_test_player.gd +++ b/scripts/smoke_test_player.gd @@ -76,9 +76,15 @@ func _run() -> void: # 5. Verify the player has a Camera3D so the game is actually viewable # from the player's perspective, not just a walking capsule with no view. - var camera: Node = player.get_node_or_null("Camera3D") + var camera: Node = player.find_child("Camera3D", true, false) _check("Player has a Camera3D", camera is Camera3D) _check("Camera3D is current", camera is Camera3D and (camera as Camera3D).current) + # Third-person rig: the camera hangs off a SpringArm3D (so it pulls in + # when a wall is behind the player) with real length. + var arm: Node = player.find_child("SpringArm3D", true, false) + _check("Camera is on a SpringArm3D (third-person)", arm is SpringArm3D) + _check("SpringArm has real length", arm is SpringArm3D and (arm as SpringArm3D).spring_length >= 2.0) + _check("Camera is a descendant of the SpringArm", camera != null and arm != null and arm.is_ancestor_of(camera)) print("\n=== SMOKE RESULT: %d/%d passed ===" % [_passed, _passed + _failed]) quit(0 if _failed == 0 else 1)