diff --git a/src/Features/Players/Domain/PlayerLook.cs b/src/Features/Players/Domain/PlayerLook.cs
new file mode 100644
index 0000000..6d2000e
--- /dev/null
+++ b/src/Features/Players/Domain/PlayerLook.cs
@@ -0,0 +1,24 @@
+using Godot;
+
+namespace Game.Features.Players.Domain;
+
+public sealed class PlayerLook(float mouseSensitivity, float maxLookAngleDegrees)
+{
+ public float MouseSensitivity { get; } = mouseSensitivity;
+ public float MaxLookAngleDegrees { get; } = maxLookAngleDegrees;
+ public float Pitch { get; private set; }
+
+ ///
+ /// Yaw rotation (radians) to apply for this mouse movement.
+ ///
+ public float CalculateYawDelta(float mouseDeltaX) => -mouseDeltaX * MouseSensitivity;
+
+ ///
+ /// Updates for this mouse movement.
+ ///
+ public void UpdatePitch(float mouseDeltaY)
+ {
+ float pitch = Pitch - mouseDeltaY * MouseSensitivity;
+ Pitch = Mathf.Clamp(pitch, Mathf.DegToRad(-MaxLookAngleDegrees), Mathf.DegToRad(MaxLookAngleDegrees));
+ }
+}
diff --git a/src/Features/Players/Domain/PlayerLook.cs.uid b/src/Features/Players/Domain/PlayerLook.cs.uid
new file mode 100644
index 0000000..a2561bb
--- /dev/null
+++ b/src/Features/Players/Domain/PlayerLook.cs.uid
@@ -0,0 +1 @@
+uid://dke8w2hp71p1g
diff --git a/src/Features/Players/Domain/PlayerMovement.cs b/src/Features/Players/Domain/PlayerMovement.cs
new file mode 100644
index 0000000..e2f6099
--- /dev/null
+++ b/src/Features/Players/Domain/PlayerMovement.cs
@@ -0,0 +1,54 @@
+using Godot;
+
+namespace Game.Features.Players.Domain;
+
+///
+/// Per-frame input for movement calculation.
+///
+/// World-space right direction of the body.
+/// World-space back direction of the body.
+/// Movement input, X = left/right, Y = forward/back.
+/// Whether the body is currently grounded.
+/// Whether jump was just pressed this frame.
+/// Physics frame delta time in seconds.
+public readonly record struct MovementInput(
+ Vector3 Right,
+ Vector3 Back,
+ Vector2 Direction,
+ bool IsOnFloor,
+ bool JumpPressed,
+ float Delta
+);
+
+public sealed class PlayerMovement(float moveSpeed, float jumpVelocity, float gravity)
+{
+ public float MoveSpeed { get; } = moveSpeed;
+ public float JumpVelocity { get; } = jumpVelocity;
+ public float Gravity { get; } = gravity;
+
+ ///
+ /// Calculates next-frame velocity from input and ground state.
+ ///
+ /// Body's velocity before this frame.
+ /// Per-frame movement input.
+ public Vector3 CalculateVelocity(Vector3 currentVelocity, MovementInput input)
+ {
+ Vector3 velocity = currentVelocity;
+
+ if (!input.IsOnFloor)
+ {
+ velocity.Y -= Gravity * input.Delta;
+ }
+ else if (input.JumpPressed)
+ {
+ velocity.Y = JumpVelocity;
+ }
+
+ Vector3 moveDirection = input.Right * input.Direction.X + input.Back * input.Direction.Y;
+ Vector3 moveDirectionNormalized = moveDirection.Normalized();
+ velocity.X = moveDirectionNormalized.X * MoveSpeed;
+ velocity.Z = moveDirectionNormalized.Z * MoveSpeed;
+
+ return velocity;
+ }
+}
diff --git a/src/Features/Players/Domain/PlayerMovement.cs.uid b/src/Features/Players/Domain/PlayerMovement.cs.uid
new file mode 100644
index 0000000..ca981bb
--- /dev/null
+++ b/src/Features/Players/Domain/PlayerMovement.cs.uid
@@ -0,0 +1 @@
+uid://chnyf8efw3xw2
diff --git a/src/Features/Players/Godot/PlayerController.cs b/src/Features/Players/Godot/PlayerController.cs
index adc18c3..21d9dda 100644
--- a/src/Features/Players/Godot/PlayerController.cs
+++ b/src/Features/Players/Godot/PlayerController.cs
@@ -1,10 +1,63 @@
-using Game.Features.Player.Domain;
+using System.Linq;
+using Game.Features.Players.Domain;
+using Game.Godot.Extensions;
+using Game.Godot.Mappings;
using Godot;
-using System;
-namespace Game.Features.Player.Godot;
+namespace Game.Features.Players.Godot;
public partial class PlayerController : Node3D
{
[Export] public CharacterBody3D Body;
+ [Export] public Camera3D Camera;
+
+ [Export] public float MoveSpeed = 4.0f;
+ [Export] public float JumpVelocity = 4.5f;
+ [Export] public float MouseSensitivity = 0.003f;
+ [Export] public float MaxLookAngleDegrees = 89.0f;
+
+ private PlayerMovement _movement;
+ private PlayerLook _look;
+
+ private static bool IsMouseCaptured() => Input.MouseMode == Input.MouseModeEnum.Captured;
+
+ public override void _Ready()
+ {
+ this.AssertRequiredExports();
+
+ float gravity = (float)ProjectSettings.GetSetting(ProjectSetting.Gravity.SettingPath());
+ _movement = new PlayerMovement(MoveSpeed, JumpVelocity, gravity);
+ _look = new PlayerLook(MouseSensitivity, MaxLookAngleDegrees);
+ }
+
+ public override void _UnhandledInput(InputEvent @event)
+ {
+ if (@event is not InputEventMouseMotion mouseMotion || !IsMouseCaptured()) return;
+
+ Body.RotateY(_look.CalculateYawDelta(mouseMotion.Relative.X));
+ _look.UpdatePitch(mouseMotion.Relative.Y);
+ Camera.Rotation = new Vector3(_look.Pitch, 0f, 0f);
+ }
+
+ public override void _PhysicsProcess(double delta)
+ {
+ Vector2 inputDirection = Input.GetVector(
+ InputAction.MoveLeft.ActionName(),
+ InputAction.MoveRight.ActionName(),
+ InputAction.MoveForward.ActionName(),
+ InputAction.MoveBack.ActionName()
+ );
+
+ MovementInput input = new(
+ Right: Body.Transform.Basis.X,
+ Back: Body.Transform.Basis.Z,
+ Direction: inputDirection,
+ IsOnFloor: Body.IsOnFloor(),
+ JumpPressed: Input.IsActionJustPressed(InputAction.Jump.ActionName()),
+ Delta: (float)delta
+ );
+
+ Body.Velocity = _movement.CalculateVelocity(Body.Velocity, input);
+ Body.MoveAndSlide();
+ }
}
diff --git a/src/Features/Players/Godot/PlayerNode.tscn b/src/Features/Players/Godot/PlayerNode.tscn
index 1d0315e..69e6d5f 100644
--- a/src/Features/Players/Godot/PlayerNode.tscn
+++ b/src/Features/Players/Godot/PlayerNode.tscn
@@ -1,8 +1,11 @@
[gd_scene format=4 uid="uid://bka1dmgdi5btd"]
-[ext_resource type="Script" uid="uid://mjm7ghkibnxa" path="res://src/Features/Player/Godot/PlayerController.cs" id="1_gkqfc"]
+[ext_resource type="Script" uid="uid://mjm7ghkibnxa" path="res://src/Features/Players/Godot/PlayerController.cs" id="1_gkqfc"]
[ext_resource type="Texture2D" uid="uid://bymgknsy145ap" path="res://assets/models/simple-character-psx_character_256.png" id="2_tduai"]
+[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_130ks"]
+radius = 0.25
+
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_sgsg4"]
resource_name = "character_256"
cull_mode = 2
@@ -287,16 +290,21 @@ _surfaces = [{
}]
blend_shape_mode = 0
-[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_130ks"]
-radius = 0.25
-
-[node name="Player" type="Node3D" unique_id=26823676 node_paths=PackedStringArray("characterBody3D")]
+[node name="Player" type="Node3D" unique_id=26823676 node_paths=PackedStringArray("Body", "Camera")]
script = ExtResource("1_gkqfc")
-characterBody3D = NodePath("Mesh/CharacterBody3D")
+Body = NodePath("CharacterBody3D")
+Camera = NodePath("CharacterBody3D/Camera3D")
-[node name="Mesh" type="Node3D" parent="." unique_id=388629767]
+[node name="CharacterBody3D" type="CharacterBody3D" parent="." unique_id=1327672780]
-[node name="Skeleton3D" type="Skeleton3D" parent="Mesh" unique_id=183161254]
+[node name="CollisionShape3D" type="CollisionShape3D" parent="CharacterBody3D" unique_id=1006347064]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
+shape = SubResource("CapsuleShape3D_130ks")
+
+[node name="Mesh" type="Node3D" parent="CharacterBody3D" unique_id=388629767]
+transform = Transform3D(-1, 0, -8.742278e-08, 0, 1, 0, 8.742278e-08, 0, -1, 0, 0, 0)
+
+[node name="Skeleton3D" type="Skeleton3D" parent="CharacterBody3D/Mesh" unique_id=183161254]
transform = Transform3D(0.1225099, 0, 0, 0, 0.1225099, 0, 0, 0, 0.1225099, 0, 0, 0)
bones/0/name = "spine"
bones/0/parent = -1
@@ -747,23 +755,22 @@ bones/63/position = Vector3(0.24846391, -0.054724697, 0.53722215)
bones/63/rotation = Quaternion(-0.689515, 0.6895178, -0.15673877, -0.15673946)
bones/63/scale = Vector3(1, 1, 1)
-[node name="arms" type="MeshInstance3D" parent="Mesh/Skeleton3D" unique_id=1995581269]
+[node name="arms" type="MeshInstance3D" parent="CharacterBody3D/Mesh/Skeleton3D" unique_id=1995581269]
mesh = SubResource("ArrayMesh_wr7hk")
skin = SubResource("Skin_dmu40")
skeleton = NodePath("..")
-[node name="body" type="MeshInstance3D" parent="Mesh/Skeleton3D" unique_id=2033799835]
+[node name="body" type="MeshInstance3D" parent="CharacterBody3D/Mesh/Skeleton3D" unique_id=2033799835]
mesh = SubResource("ArrayMesh_5ti08")
skin = SubResource("Skin_dmu40")
skeleton = NodePath("..")
-[node name="head" type="MeshInstance3D" parent="Mesh/Skeleton3D" unique_id=1273139092]
+[node name="head" type="MeshInstance3D" parent="CharacterBody3D/Mesh/Skeleton3D" unique_id=1273139092]
+layers = 2
mesh = SubResource("ArrayMesh_31tkm")
skin = SubResource("Skin_dmu40")
skeleton = NodePath("..")
-[node name="CharacterBody3D" type="CharacterBody3D" parent="Mesh" unique_id=1327672780]
-
-[node name="CollisionShape3D" type="CollisionShape3D" parent="Mesh/CharacterBody3D" unique_id=1006347064]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
-shape = SubResource("CapsuleShape3D_130ks")
+[node name="Camera3D" type="Camera3D" parent="CharacterBody3D" unique_id=718409984]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.817, 0)
+cull_mask = 1048573