Third-person camera + fix the avatar customize preview
checkpoint / checkpoint (push) Successful in 1m34s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
This commit is contained in:
2026-09-05 23:13:07 +00:00
parent bd1bdc417a
commit 87f78b0226
5 changed files with 68 additions and 28 deletions
+29 -6
View File
@@ -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()
+21 -17
View File
@@ -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
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -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):
+7 -1
View File
@@ -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)