From 2fa7b1f475d9e5df3cdc11698c85be444db660ba Mon Sep 17 00:00:00 2001 From: architect-bot Date: Thu, 20 Aug 2026 22:26:08 +0000 Subject: [PATCH 1/9] WIP checkpoint: PC2: Basic visible player character representation --- scenes/worlds/test_level.tscn | 70 +++++++++++++++++++++ scripts/player.gd | 111 ++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 scenes/worlds/test_level.tscn create mode 100644 scripts/player.gd diff --git a/scenes/worlds/test_level.tscn b/scenes/worlds/test_level.tscn new file mode 100644 index 0000000..9935db9 --- /dev/null +++ b/scenes/worlds/test_level.tscn @@ -0,0 +1,70 @@ +[gd_scene load_steps=1 format=3] + +[node name="TestLevel" type="Node3D"] + +[node name="Ground" type="StaticBody3D" parent="."] + +[node name="GroundMesh" type="MeshInstance3D" parent="Ground"] +mesh = SubResource("mesh_ground") + +[node name="Props" type="Node3D" parent="."] + +[node name="Prop1" type="StaticBody3D" parent="Props"] + +[node name="Prop1Mesh" type="MeshInstance3D" parent="Props/Prop1"] +mesh = SubResource("mesh_box1") + +[node name="Prop2" type="StaticBody3D" parent="Props"] + +[node name="Prop2Mesh" type="MeshInstance3D" parent="Props/Prop2"] +mesh = SubResource("mesh_cylinder") + +[node name="Prop3" type="StaticBody3D" parent="Props"] + +[node name="Prop3Mesh" type="MeshInstance3D" parent="Props/Prop3"] +mesh = SubResource("mesh_sphere") + +[node name="Prop4" type="StaticBody3D" parent="Props"] + +[node name="Prop4Mesh" type="MeshInstance3D" parent="Props/Prop4"] +mesh = SubResource("mesh_box2") + +[node name="Prop5" type="StaticBody3D" parent="Props"] + +[node name="Prop5Mesh" type="MeshInstance3D" parent="Props/Prop5"] +mesh = SubResource("mesh_box3") + +[node name="Sun" type="DirectionalLight3D" parent="."] +rotation_degrees = Vector3(-45.0, 0.0, 0.0) +shadow_enabled = true + +[node name="WorldEnvironment" type="WorldEnvironment" parent="."] +environment = SubResource("env_bg") + +[node name="PlayerSpawn" type="Marker3D" parent="."] +position = Vector3(0, 1.0, 0) + +[sub_resource type="BoxMesh" id="mesh_ground"] +size = Vector3(40, 1, 40) + +[sub_resource type="BoxMesh" id="mesh_box1"] +size = Vector3(1.5, 2.0, 1.5) + +[sub_resource type="CylinderMesh" id="mesh_cylinder"] +top_radius = 0.5 +bottom_radius = 0.5 +height = 2.0 + +[sub_resource type="SphereMesh" id="mesh_sphere"] +radius = 0.75 + +[sub_resource type="BoxMesh" id="mesh_box2"] +size = Vector3(1.0, 3.0, 1.0) + +[sub_resource type="BoxMesh" id="mesh_box3"] +size = Vector3(2.0, 1.0, 2.0) + +[sub_resource type="Environment" id="env_bg"] +background_mode = 1 +background_color = Color(0.4, 0.6, 0.9, 1) +ambient_light_energy = 0.6 diff --git a/scripts/player.gd b/scripts/player.gd new file mode 100644 index 0000000..06a2f78 --- /dev/null +++ b/scripts/player.gd @@ -0,0 +1,111 @@ +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). +## +## No networking, no chunk code, no avatar UI — PC2 is local-only. + + +# --------------------------------------------------------------------------- +# Movement constants +# --------------------------------------------------------------------------- + +const WALK_SPEED: float = 5.0 +const JUMP_VELOCITY: float = -4.5 +const GRAVITY: float = 9.8 + + +# --------------------------------------------------------------------------- +# Input state +# --------------------------------------------------------------------------- + +var _input_direction := Vector2.ZERO + + +# --------------------------------------------------------------------------- +# _ready — spawn at PlayerSpawn marker +# --------------------------------------------------------------------------- + +func _ready() -> void: + # Per §3 of pc2_test_level.md: look up PlayerSpawn from the current + # scene root. The lookup walks the full subtree (recursive=true) so + # it finds the marker whether it is a direct child of the level or + # nested deeper. + var spawn := get_tree().current_scene.find_child("PlayerSpawn", true, false) + if spawn is Node3D: + global_position = spawn.global_position + # Align player forward with spawn yaw (optional polish). + var spawn_yaw := spawn.rotation.y + rotation.y = spawn_yaw + else: + push_warning("player.gd: no PlayerSpawn marker found; spawning at origin") + global_position = Vector3(0, 1.0, 0) + + +# --------------------------------------------------------------------------- +# _unhandled_input — capture keyboard / mouse for movement +# --------------------------------------------------------------------------- + +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventKey: + var key := event as InputEventKey + if key.pressed: + _update_input_direction(key) + else: + _clear_input_direction(key) + elif event is InputEventMouseMotion: + _handle_mouse_look(event as InputEventMouseMotion) + + +func _update_input_direction(key: InputEventKey) -> void: + match key.keycode: + KEY_W, KEY_UP: + _input_direction.y += 1.0 + KEY_S, KEY_DOWN: + _input_direction.y -= 1.0 + KEY_A, KEY_LEFT: + _input_direction.x -= 1.0 + KEY_D, KEY_RIGHT: + _input_direction.x += 1.0 + + +func _clear_input_direction(key: InputEventKey) -> void: + match key.keycode: + KEY_W, KEY_UP: + _input_direction.y -= 1.0 + KEY_S, KEY_DOWN: + _input_direction.y += 1.0 + KEY_A, KEY_LEFT: + _input_direction.x += 1.0 + KEY_D, KEY_RIGHT: + _input_direction.x -= 1.0 + + +func _handle_mouse_look(event: InputEventMouseMotion) -> void: + rotation.y -= event.relative.x * 0.002 + + +# --------------------------------------------------------------------------- +# Physics — gravity, jump, move_and_slide +# --------------------------------------------------------------------------- + +func _physics_process(delta: float) -> void: + # Apply gravity. + if not is_on_floor(): + velocity.y -= GRAVITY * delta + + # 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 + + _input_direction.y * transform.basis.z).normalized() + velocity.x = direction.x * WALK_SPEED + velocity.z = direction.z * WALK_SPEED + + move_and_slide() -- 2.52.0 From f70b5b2ee55324233bf568385354703c31af3d46 Mon Sep 17 00:00:00 2001 From: architect-bot Date: Thu, 20 Aug 2026 22:28:06 +0000 Subject: [PATCH 2/9] WIP checkpoint: PC2: Basic visible player character representation --- scenes/player.tscn | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 scenes/player.tscn diff --git a/scenes/player.tscn b/scenes/player.tscn new file mode 100644 index 0000000..e54774a --- /dev/null +++ b/scenes/player.tscn @@ -0,0 +1,20 @@ +[gd_scene load_steps=3 format=3 uid="uid://player_scene_001"] + +[ext_resource type="Script" path="res://scripts/player.gd" id="1_player"] + +[sub_resource type="CapsuleMesh" id="CapsuleMesh1"] +radius = 0.35 +height = 1.6 + +[sub_resource type="CapsuleShape3D" id="CapsuleShape1"] +radius = 0.35 +height = 1.6 + +[node name="Player" type="CharacterBody3D"] +script = ExtResource("1_player") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="."] +shape = SubResource("CapsuleShape1") + +[node name="CapsuleMesh" type="MeshInstance3D" parent="."] +mesh = SubResource("CapsuleMesh1") -- 2.52.0 From 406ff3457e61fb6bda22234dab3b636357e4f54f Mon Sep 17 00:00:00 2001 From: architect-bot Date: Thu, 20 Aug 2026 22:50:48 +0000 Subject: [PATCH 3/9] WIP checkpoint: PC2: Basic visible player character representation --- scenes/worlds/test_level.tscn | 8 +++- scripts/smoke_test_player.gd | 75 +++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 scripts/smoke_test_player.gd diff --git a/scenes/worlds/test_level.tscn b/scenes/worlds/test_level.tscn index 9935db9..f9ab25b 100644 --- a/scenes/worlds/test_level.tscn +++ b/scenes/worlds/test_level.tscn @@ -1,4 +1,6 @@ -[gd_scene load_steps=1 format=3] +[gd_scene load_steps=2 format=3] + +[ext_resource type="PackedScene" path="res://scenes/player.tscn" id="1_player"] [node name="TestLevel" type="Node3D"] @@ -44,6 +46,10 @@ environment = SubResource("env_bg") [node name="PlayerSpawn" type="Marker3D" parent="."] position = Vector3(0, 1.0, 0) +[node name="Player" type="CharacterBody3D" parent="."] +position = Vector3(0, 1.0, 0) +script = ExtResource("1_player") + [sub_resource type="BoxMesh" id="mesh_ground"] size = Vector3(40, 1, 40) diff --git a/scripts/smoke_test_player.gd b/scripts/smoke_test_player.gd new file mode 100644 index 0000000..04d63b2 --- /dev/null +++ b/scripts/smoke_test_player.gd @@ -0,0 +1,75 @@ +extends SceneTree +## Smoke test for the player character scene and its spawn behavior. +## +## Run with: +## godot --headless --path . --script res://scripts/smoke_test_player.gd +## +## Loads the test level, verifies the player is present, spawned at the +## PlayerSpawn marker, and has the expected CharacterBody3D structure. + +var passed := 0 +var failed := 0 + + +func _initialize() -> void: + _run() + + +func _run() -> void: + await process_frame + + # Load the test level scene (which now includes the player). + var packed: PackedScene = load("res://scenes/worlds/test_level.tscn") + var level: Node3D = packed.instantiate() + root.add_child(level) + await process_frame + + # 1. Verify the player node exists in the scene tree. + var player := level.get_node("Player") + _check("Player node exists in test level", player != null) + _check("Player is a CharacterBody3D", player is CharacterBody3D) + + # 2. Verify the player has the expected child structure. + var collision := player.get_node("CollisionShape3D") + _check("Player has CollisionShape3D", collision != null) + _check("CollisionShape3D has a shape", collision.shape != null) + + var mesh := player.get_node("CapsuleMesh") + _check("Player has CapsuleMesh", mesh != null) + _check("CapsuleMesh has a mesh", mesh.mesh != null) + + # 3. Verify the player spawned at the PlayerSpawn marker position. + var spawn_marker := level.get_node("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 root, their + # global positions equal their local positions. + var expected_pos := spawn_marker.global_position + _check("Player spawned at PlayerSpawn position", + player.global_position == expected_pos) + + # 4. Verify the player script class_name is correct. + var script := player.get_script() + _check("Player has a script attached", script != null) + _check("Player script is Player class", script is GDScript) + + # 5. Verify the player has movement constants defined. + _check("Player has WALK_SPEED constant", + script.get_constant("WALK_SPEED") != null) + _check("Player has GRAVITY constant", + script.get_constant("GRAVITY") != null) + _check("Player has JUMP_VELOCITY constant", + script.get_constant("JUMP_VELOCITY") != null) + + print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed]) + quit(0 if failed == 0 else 1) + + +func _check(description: String, condition: bool) -> void: + if condition: + print(" PASS: ", description) + passed += 1 + else: + printerr(" FAIL: ", description) + failed += 1 -- 2.52.0 From 5803f4706917bba8180b36f312a3f9fdf2da0677 Mon Sep 17 00:00:00 2001 From: architect-bot Date: Thu, 20 Aug 2026 23:19:05 +0000 Subject: [PATCH 4/9] WIP checkpoint: PC2: Basic visible player character representation --- scripts/smoke_test_player.gd | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/smoke_test_player.gd b/scripts/smoke_test_player.gd index 04d63b2..adec01f 100644 --- a/scripts/smoke_test_player.gd +++ b/scripts/smoke_test_player.gd @@ -39,13 +39,13 @@ func _run() -> void: _check("CapsuleMesh has a mesh", mesh.mesh != null) # 3. Verify the player spawned at the PlayerSpawn marker position. - var spawn_marker := level.get_node("PlayerSpawn") + var spawn_marker: Marker3D = level.get_node("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 root, their # global positions equal their local positions. - var expected_pos := spawn_marker.global_position + var expected_pos: Vector3 = spawn_marker.global_position _check("Player spawned at PlayerSpawn position", player.global_position == expected_pos) -- 2.52.0 From 8118f3074b9f6e95c7a1b5ba093a7664a7218590 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Thu, 20 Aug 2026 23:46:29 +0000 Subject: [PATCH 5/9] WIP checkpoint: PC2: Basic visible player character representation --- scripts/smoke_test_player.gd | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/smoke_test_player.gd b/scripts/smoke_test_player.gd index adec01f..fc1c5e9 100644 --- a/scripts/smoke_test_player.gd +++ b/scripts/smoke_test_player.gd @@ -25,21 +25,21 @@ func _run() -> void: await process_frame # 1. Verify the player node exists in the scene tree. - var player := level.get_node("Player") + var player: CharacterBody3D = level.get_node("Player") as CharacterBody3D _check("Player node exists in test level", player != null) _check("Player is a CharacterBody3D", player is CharacterBody3D) # 2. Verify the player has the expected child structure. - var collision := player.get_node("CollisionShape3D") + var collision: CollisionShape3D = player.get_node("CollisionShape3D") as CollisionShape3D _check("Player has CollisionShape3D", collision != null) _check("CollisionShape3D has a shape", collision.shape != null) - var mesh := player.get_node("CapsuleMesh") + var mesh: MeshInstance3D = player.get_node("CapsuleMesh") as MeshInstance3D _check("Player has CapsuleMesh", mesh != null) _check("CapsuleMesh has a mesh", mesh.mesh != null) # 3. Verify the player spawned at the PlayerSpawn marker position. - var spawn_marker: Marker3D = level.get_node("PlayerSpawn") + var spawn_marker: Marker3D = level.get_node("PlayerSpawn") as Marker3D _check("PlayerSpawn marker exists", spawn_marker != null) # The player's _ready() should have moved it to the spawn marker's @@ -50,7 +50,7 @@ func _run() -> void: player.global_position == expected_pos) # 4. Verify the player script class_name is correct. - var script := player.get_script() + var script: GDScript = player.get_script() as GDScript _check("Player has a script attached", script != null) _check("Player script is Player class", script is GDScript) -- 2.52.0 From 945c498a317173f8b203a1a9b40fa47a30c9da60 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Fri, 21 Aug 2026 00:01:08 +0000 Subject: [PATCH 6/9] WIP checkpoint: PC2: Basic visible player character representation --- scenes/worlds/test_level.tscn | 55 +++++++++++++++++------------------ scripts/smoke_test_player.gd | 16 ++++++---- 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/scenes/worlds/test_level.tscn b/scenes/worlds/test_level.tscn index f9ab25b..2112bf9 100644 --- a/scenes/worlds/test_level.tscn +++ b/scenes/worlds/test_level.tscn @@ -1,7 +1,32 @@ -[gd_scene load_steps=2 format=3] +[gd_scene load_steps=9 format=3] [ext_resource type="PackedScene" path="res://scenes/player.tscn" id="1_player"] +[sub_resource type="BoxMesh" id="mesh_ground"] +size = Vector3(40, 1, 40) + +[sub_resource type="BoxMesh" id="mesh_box1"] +size = Vector3(1.5, 2.0, 1.5) + +[sub_resource type="CylinderMesh" id="mesh_cylinder"] +top_radius = 0.5 +bottom_radius = 0.5 +height = 2.0 + +[sub_resource type="SphereMesh" id="mesh_sphere"] +radius = 0.75 + +[sub_resource type="BoxMesh" id="mesh_box2"] +size = Vector3(1.0, 3.0, 1.0) + +[sub_resource type="BoxMesh" id="mesh_box3"] +size = Vector3(2.0, 1.0, 2.0) + +[sub_resource type="Environment" id="env_bg"] +background_mode = 1 +background_color = Color(0.4, 0.6, 0.9, 1) +ambient_light_energy = 0.6 + [node name="TestLevel" type="Node3D"] [node name="Ground" type="StaticBody3D" parent="."] @@ -46,31 +71,5 @@ environment = SubResource("env_bg") [node name="PlayerSpawn" type="Marker3D" parent="."] position = Vector3(0, 1.0, 0) -[node name="Player" type="CharacterBody3D" parent="."] +[node name="Player" parent="." instance=ExtResource("1_player")] position = Vector3(0, 1.0, 0) -script = ExtResource("1_player") - -[sub_resource type="BoxMesh" id="mesh_ground"] -size = Vector3(40, 1, 40) - -[sub_resource type="BoxMesh" id="mesh_box1"] -size = Vector3(1.5, 2.0, 1.5) - -[sub_resource type="CylinderMesh" id="mesh_cylinder"] -top_radius = 0.5 -bottom_radius = 0.5 -height = 2.0 - -[sub_resource type="SphereMesh" id="mesh_sphere"] -radius = 0.75 - -[sub_resource type="BoxMesh" id="mesh_box2"] -size = Vector3(1.0, 3.0, 1.0) - -[sub_resource type="BoxMesh" id="mesh_box3"] -size = Vector3(2.0, 1.0, 2.0) - -[sub_resource type="Environment" id="env_bg"] -background_mode = 1 -background_color = Color(0.4, 0.6, 0.9, 1) -ambient_light_energy = 0.6 diff --git a/scripts/smoke_test_player.gd b/scripts/smoke_test_player.gd index fc1c5e9..20826d0 100644 --- a/scripts/smoke_test_player.gd +++ b/scripts/smoke_test_player.gd @@ -12,34 +12,38 @@ var failed := 0 func _initialize() -> void: + print("SMOKE: starting player smoke test") _run() func _run() -> void: await process_frame + print("SMOKE: loading test level scene") # Load the test level scene (which now includes the player). var packed: PackedScene = load("res://scenes/worlds/test_level.tscn") var level: Node3D = packed.instantiate() root.add_child(level) - await process_frame + print("SMOKE: level instantiated, waiting for ready") + await level.ready + print("SMOKE: level ready") # 1. Verify the player node exists in the scene tree. - var player: CharacterBody3D = level.get_node("Player") as CharacterBody3D + var player := level.get_node("Player") _check("Player node exists in test level", player != null) _check("Player is a CharacterBody3D", player is CharacterBody3D) # 2. Verify the player has the expected child structure. - var collision: CollisionShape3D = player.get_node("CollisionShape3D") as CollisionShape3D + var collision := player.get_node("CollisionShape3D") _check("Player has CollisionShape3D", collision != null) _check("CollisionShape3D has a shape", collision.shape != null) - var mesh: MeshInstance3D = player.get_node("CapsuleMesh") as MeshInstance3D + var mesh := player.get_node("CapsuleMesh") _check("Player has CapsuleMesh", mesh != null) _check("CapsuleMesh has a mesh", mesh.mesh != null) # 3. Verify the player spawned at the PlayerSpawn marker position. - var spawn_marker: Marker3D = level.get_node("PlayerSpawn") as Marker3D + var spawn_marker: Marker3D = level.get_node("PlayerSpawn") _check("PlayerSpawn marker exists", spawn_marker != null) # The player's _ready() should have moved it to the spawn marker's @@ -50,7 +54,7 @@ func _run() -> void: player.global_position == expected_pos) # 4. Verify the player script class_name is correct. - var script: GDScript = player.get_script() as GDScript + var script := player.get_script() _check("Player has a script attached", script != null) _check("Player script is Player class", script is GDScript) -- 2.52.0 From 5966fa9044de4a367aef6493ae0b37fa35ed3823 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Fri, 21 Aug 2026 00:06:54 +0000 Subject: [PATCH 7/9] WIP checkpoint: PC2: Basic visible player character representation --- scripts/player.gd | 14 +++++++-- scripts/smoke_test_player.gd | 55 ++++++++++++++++++++---------------- 2 files changed, 41 insertions(+), 28 deletions(-) diff --git a/scripts/player.gd b/scripts/player.gd index 06a2f78..682e204 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -34,11 +34,19 @@ func _ready() -> void: # scene root. The lookup walks the full subtree (recursive=true) so # it finds the marker whether it is a direct child of the level or # nested deeper. - var spawn := get_tree().current_scene.find_child("PlayerSpawn", true, false) + # + # current_scene is null in headless --script mode and in any scene + # before the main scene is fully swapped in, so guard it rather than + # blindly dereferencing. + var current_scene: Node = get_tree().current_scene + var spawn: Node = null + if current_scene != null: + spawn = current_scene.find_child("PlayerSpawn", true, false) + if spawn is Node3D: - global_position = spawn.global_position + global_position = (spawn as Node3D).global_position # Align player forward with spawn yaw (optional polish). - var spawn_yaw := spawn.rotation.y + var spawn_yaw := (spawn as Node3D).rotation.y rotation.y = spawn_yaw else: push_warning("player.gd: no PlayerSpawn marker found; spawning at origin") diff --git a/scripts/smoke_test_player.gd b/scripts/smoke_test_player.gd index 20826d0..02e4c0a 100644 --- a/scripts/smoke_test_player.gd +++ b/scripts/smoke_test_player.gd @@ -7,8 +7,8 @@ extends SceneTree ## Loads the test level, verifies the player is present, spawned at the ## PlayerSpawn marker, and has the expected CharacterBody3D structure. -var passed := 0 -var failed := 0 +var _passed := 0 +var _failed := 0 func _initialize() -> void: @@ -20,60 +20,65 @@ func _run() -> void: await process_frame print("SMOKE: loading test level scene") - # Load the test level scene (which now includes the player). + # Load the test level scene (which includes the player). var packed: PackedScene = load("res://scenes/worlds/test_level.tscn") var level: Node3D = packed.instantiate() + + # Order matters: set_current_scene() requires the node's parent to already + # be root, so add_child() must come first. This mirrors what a real + # change_scene_to_packed() does at runtime. root.add_child(level) + current_scene = level + print("SMOKE: level instantiated, waiting for ready") await level.ready print("SMOKE: level ready") # 1. Verify the player node exists in the scene tree. - var player := level.get_node("Player") + var player: Node = level.get_node("Player") _check("Player node exists in test level", player != null) _check("Player is a CharacterBody3D", player is CharacterBody3D) # 2. Verify the player has the expected child structure. - var collision := player.get_node("CollisionShape3D") + var collision: Node = player.get_node("CollisionShape3D") _check("Player has CollisionShape3D", collision != null) - _check("CollisionShape3D has a shape", collision.shape != null) + _check("CollisionShape3D has a shape", + collision is CollisionShape3D and (collision as CollisionShape3D).shape != null) - var mesh := player.get_node("CapsuleMesh") + var mesh: Node = player.get_node("CapsuleMesh") _check("Player has CapsuleMesh", mesh != null) - _check("CapsuleMesh has a mesh", mesh.mesh != null) + _check("CapsuleMesh has a mesh", + 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") _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 root, their - # global positions equal their local positions. + # 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. var expected_pos: Vector3 = spawn_marker.global_position _check("Player spawned at PlayerSpawn position", player.global_position == expected_pos) - # 4. Verify the player script class_name is correct. - var script := player.get_script() + # 4. Verify the player script class_name is correct and has the + # movement constants defined. + var script: GDScript = player.get_script() as GDScript _check("Player has a script attached", script != null) - _check("Player script is Player class", script is GDScript) + var constants: Dictionary = script.get_script_constant_map() + _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")) - # 5. Verify the player has movement constants defined. - _check("Player has WALK_SPEED constant", - script.get_constant("WALK_SPEED") != null) - _check("Player has GRAVITY constant", - script.get_constant("GRAVITY") != null) - _check("Player has JUMP_VELOCITY constant", - script.get_constant("JUMP_VELOCITY") != null) - - print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed]) - quit(0 if failed == 0 else 1) + print("\n=== SMOKE RESULT: %d/%d passed ===" % [_passed, _passed + _failed]) + quit(0 if _failed == 0 else 1) func _check(description: String, condition: bool) -> void: if condition: print(" PASS: ", description) - passed += 1 + _passed += 1 else: printerr(" FAIL: ", description) - failed += 1 + _failed += 1 -- 2.52.0 From e2a77b055f4ec8e0c49686bdc8c5a3327691241a Mon Sep 17 00:00:00 2001 From: coder-bot Date: Fri, 21 Aug 2026 00:09:44 +0000 Subject: [PATCH 8/9] WIP checkpoint: PC2: Basic visible player character representation --- scripts/smoke_test_player.gd | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/scripts/smoke_test_player.gd b/scripts/smoke_test_player.gd index 02e4c0a..d631608 100644 --- a/scripts/smoke_test_player.gd +++ b/scripts/smoke_test_player.gd @@ -20,18 +20,19 @@ func _run() -> void: await process_frame print("SMOKE: loading test level scene") - # Load the test level scene (which includes the player). + # 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") - var level: Node3D = packed.instantiate() + var result := change_scene_to_packed(packed) + if result != OK: + printerr("SMOKE: failed to change scene, error ", result) + quit(1) + return - # Order matters: set_current_scene() requires the node's parent to already - # be root, so add_child() must come first. This mirrors what a real - # change_scene_to_packed() does at runtime. - root.add_child(level) - current_scene = level - - print("SMOKE: level instantiated, waiting for ready") - await level.ready + await scene_changed + var level: Node3D = current_scene as Node3D print("SMOKE: level ready") # 1. Verify the player node exists in the scene tree. -- 2.52.0 From 7790670559f3a9deb839edd2614e08bfd49bef65 Mon Sep 17 00:00:00 2001 From: claude-checkpoint-tooling Date: Fri, 21 Aug 2026 00:39:13 +0000 Subject: [PATCH 9/9] 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) -- 2.52.0