From 7790670559f3a9deb839edd2614e08bfd49bef65 Mon Sep 17 00:00:00 2001 From: claude-checkpoint-tooling Date: Fri, 21 Aug 2026 00:39:13 +0000 Subject: [PATCH] WIP: add Camera3D, fix jump/forward-direction bugs, add mouse capture, build game.tscn composition --- scenes/game.tscn | 10 +++++++ scenes/player.tscn | 4 +++ scripts/player.gd | 53 ++++++++++++++++++++++++++++-------- scripts/smoke_test_player.gd | 34 ++++++++++++++--------- 4 files changed, 76 insertions(+), 25 deletions(-) create mode 100644 scenes/game.tscn diff --git a/scenes/game.tscn b/scenes/game.tscn new file mode 100644 index 0000000..4bdcbc2 --- /dev/null +++ b/scenes/game.tscn @@ -0,0 +1,10 @@ +[gd_scene load_steps=3 format=3 uid="uid://game_scene_pc2"] + +[ext_resource type="PackedScene" uid="uid://c49a36fc7db_test_level" path="res://scenes/worlds/test_level.tscn" id="1_level"] +[ext_resource type="PackedScene" uid="uid://player_scene_001" path="res://scenes/player.tscn" id="2_player"] + +[node name="Game" type="Node3D"] + +[node name="Level" parent="." instance=ExtResource("1_level")] + +[node name="Player" parent="." instance=ExtResource("2_player")] diff --git a/scenes/player.tscn b/scenes/player.tscn index e54774a..88452b1 100644 --- a/scenes/player.tscn +++ b/scenes/player.tscn @@ -18,3 +18,7 @@ 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) +current = true diff --git a/scripts/player.gd b/scripts/player.gd index 682e204..ff37c6e 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -2,9 +2,9 @@ class_name Player extends CharacterBody3D ## The player character controller. ## -## A simple CharacterBody3D with WASD/arrow locomotion, gravity, and a -## first-person Camera3D. Reads the spawn point from the current scene's -## `PlayerSpawn` Marker3D (see docs/architecture/pc2_test_level.md §3). +## 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. @@ -14,19 +14,29 @@ extends CharacterBody3D # --------------------------------------------------------------------------- const WALK_SPEED: float = 5.0 -const JUMP_VELOCITY: float = -4.5 +const JUMP_VELOCITY: float = 4.5 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 + +@onready var _camera: Camera3D = $Camera3D # --------------------------------------------------------------------------- -# _ready — spawn at PlayerSpawn marker +# _ready — spawn at PlayerSpawn marker, capture the mouse for look # --------------------------------------------------------------------------- func _ready() -> void: @@ -52,6 +62,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. + if DisplayServer.get_name() != "headless": + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + # --------------------------------------------------------------------------- # _unhandled_input — capture keyboard / mouse for movement @@ -60,11 +75,18 @@ func _ready() -> void: func _unhandled_input(event: InputEvent) -> void: if event is InputEventKey: var key := event as InputEventKey - if key.pressed: + if key.pressed and not key.echo: _update_input_direction(key) - else: + elif not key.pressed: _clear_input_direction(key) - elif event is InputEventMouseMotion: + # Release the mouse so the player can get back to a menu/cursor. + if key.pressed and key.keycode == KEY_ESCAPE: + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + elif event is InputEventMouseButton and event.pressed: + # Clicking back into the viewport re-captures the mouse. + if Input.mouse_mode == Input.MOUSE_MODE_VISIBLE and DisplayServer.get_name() != "headless": + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + elif event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: _handle_mouse_look(event as InputEventMouseMotion) @@ -93,7 +115,12 @@ func _clear_input_direction(key: InputEventKey) -> void: func _handle_mouse_look(event: InputEventMouseMotion) -> void: - rotation.y -= event.relative.x * 0.002 + # 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. + 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 # --------------------------------------------------------------------------- @@ -108,10 +135,12 @@ func _physics_process(delta: float) -> void: # Jump. if is_on_floor() and Input.is_action_just_pressed("ui_accept"): velocity.y = JUMP_VELOCITY - return - # Build movement direction from input. - var direction := (_input_direction.x * transform.basis.x + + # Build movement direction from input. Godot's local "forward" is -Z, not + # +Z, so W (input.y = +1) must use -basis.z -- using +basis.z here would + # move the player backward when pressing forward (confirmed by reading + # Camera3D/Node3D's own convention: a node looks down its local -Z). + var direction := (_input_direction.x * transform.basis.x - _input_direction.y * transform.basis.z).normalized() velocity.x = direction.x * WALK_SPEED velocity.z = direction.z * WALK_SPEED diff --git a/scripts/smoke_test_player.gd b/scripts/smoke_test_player.gd index d631608..22629fb 100644 --- a/scripts/smoke_test_player.gd +++ b/scripts/smoke_test_player.gd @@ -18,13 +18,15 @@ func _initialize() -> void: func _run() -> void: await process_frame - print("SMOKE: loading test level scene") + print("SMOKE: loading game scene") - # Load the test level scene (which includes the player). Use the same - # SceneTree API as the real game so `current_scene` is populated before - # the scene is added and its _ready() callbacks fire. That ordering is - # what the player's spawn lookup (get_tree().current_scene) depends on. - var packed: PackedScene = load("res://scenes/worlds/test_level.tscn") + # Load scenes/game.tscn -- the real PC2 composition point (Level + Player + # as siblings, per docs/architecture/pc2_test_level.md §4), not the level + # scene directly. Use the same SceneTree API as the real game so + # `current_scene` is populated before the scene is added and its + # _ready() callbacks fire. That ordering is what the player's spawn + # lookup (get_tree().current_scene) depends on. + var packed: PackedScene = load("res://scenes/game.tscn") var result := change_scene_to_packed(packed) if result != OK: printerr("SMOKE: failed to change scene, error ", result) @@ -32,11 +34,11 @@ func _run() -> void: return await scene_changed - var level: Node3D = current_scene as Node3D - print("SMOKE: level ready") + var game: Node3D = current_scene as Node3D + print("SMOKE: game scene ready") # 1. Verify the player node exists in the scene tree. - var player: Node = level.get_node("Player") + var player: Node = game.get_node("Player") _check("Player node exists in test level", player != null) _check("Player is a CharacterBody3D", player is CharacterBody3D) @@ -52,13 +54,12 @@ func _run() -> void: mesh is MeshInstance3D and (mesh as MeshInstance3D).mesh != null) # 3. Verify the player spawned at the PlayerSpawn marker position. - var spawn_marker: Marker3D = level.get_node("PlayerSpawn") + var spawn_marker: Marker3D = game.get_node("Level/PlayerSpawn") _check("PlayerSpawn marker exists", spawn_marker != null) # The player's _ready() should have moved it to the spawn marker's - # global position. Since both are direct children of the level root - # (and the level root is at the origin), their global positions equal - # their local positions. + # global position. Compare global positions directly (not local) since + # Player and Level sit at different depths under Game. var expected_pos: Vector3 = spawn_marker.global_position _check("Player spawned at PlayerSpawn position", player.global_position == expected_pos) @@ -71,6 +72,13 @@ func _run() -> void: _check("Player has WALK_SPEED constant", constants.has("WALK_SPEED")) _check("Player has GRAVITY constant", constants.has("GRAVITY")) _check("Player has JUMP_VELOCITY constant", constants.has("JUMP_VELOCITY")) + _check("JUMP_VELOCITY is positive (upward)", float(constants.get("JUMP_VELOCITY", -1.0)) > 0.0) + + # 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") + _check("Player has a Camera3D", camera is Camera3D) + _check("Camera3D is current", camera is Camera3D and (camera as Camera3D).current) print("\n=== SMOKE RESULT: %d/%d passed ===" % [_passed, _passed + _failed]) quit(0 if _failed == 0 else 1)