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 4f77f6d..29725f7 100644 --- a/scenes/player.tscn +++ b/scenes/player.tscn @@ -1,6 +1,33 @@ +<<<<<<< HEAD [gd_scene load_steps=2 format=3] [ext_resource type="Script" path="res://scripts/player_spawner.gd" id="1"] [node name="Player" type="CharacterBody3D"] script = ExtResource("1") +======= +[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") + +[node name="Camera3D" type="Camera3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.6, 0) +current = true +>>>>>>> origin/main diff --git a/scripts/player.gd b/scripts/player.gd new file mode 100644 index 0000000..ff37c6e --- /dev/null +++ b/scripts/player.gd @@ -0,0 +1,148 @@ +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. + + +# --------------------------------------------------------------------------- +# Movement constants +# --------------------------------------------------------------------------- + +const WALK_SPEED: float = 5.0 +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, capture the mouse for look +# --------------------------------------------------------------------------- + +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. + # + # 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 as Node3D).global_position + # Align player forward with spawn yaw (optional polish). + var spawn_yaw := (spawn as Node3D).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) + + # 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 +# --------------------------------------------------------------------------- + +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventKey: + var key := event as InputEventKey + if key.pressed and not key.echo: + _update_input_direction(key) + elif not key.pressed: + _clear_input_direction(key) + # 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) + + +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: + # 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 + + +# --------------------------------------------------------------------------- +# 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 + + # 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 + + move_and_slide() diff --git a/scripts/smoke_test_player.gd b/scripts/smoke_test_player.gd new file mode 100644 index 0000000..22629fb --- /dev/null +++ b/scripts/smoke_test_player.gd @@ -0,0 +1,93 @@ +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: + print("SMOKE: starting player smoke test") + _run() + + +func _run() -> void: + await process_frame + print("SMOKE: loading game scene") + + # 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) + quit(1) + return + + await scene_changed + 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 = game.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: Node = player.get_node("CollisionShape3D") + _check("Player has CollisionShape3D", collision != null) + _check("CollisionShape3D has a shape", + collision is CollisionShape3D and (collision as CollisionShape3D).shape != null) + + var mesh: Node = player.get_node("CapsuleMesh") + _check("Player has CapsuleMesh", 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 = 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. 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) + + # 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) + 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")) + _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) + + +func _check(description: String, condition: bool) -> void: + if condition: + print(" PASS: ", description) + _passed += 1 + else: + printerr(" FAIL: ", description) + _failed += 1