Przejdź do treści

Creating an Advanced 2D Platformer Player Controller in Godot with C

Building a responsive and feature-rich player controller is crucial for any 2D platformer. In this comprehensive tutorial, we'll create a professional-grade player controller in Godot using C# that includes advanced movement mechanics like wall jumping, double jumping, wall sliding, and climbing. Our controller will also be structured for easy expansion with future features like combat and shooting.

What You'll Learn

By the end of this tutorial, you'll have a complete player controller that supports:

  • Dual sprite system (separate upper and lower body animations)
  • Smooth horizontal movement with proper air control
  • Jump and double jump mechanics
  • Wall hanging and sliding with realistic physics
  • Wall jumping with directional force
  • Climbing system
  • Automatic sprite flipping based on movement direction
  • Extensible architecture for adding new abilities

Prerequisites

  • Godot 4.x with C# support enabled
  • Basic understanding of C# programming
  • Familiarity with Godot's node system and scenes
  • Understanding of 2D physics concepts

Step 1: Setting Up the Project

1.1 Create a New Godot Project

  1. Open Godot and create a new project
  2. Ensure C# support is enabled (you'll need .NET SDK installed)
  3. Set up your project settings for 2D development

1.2 Configure Input Map

Navigate to Project → Project Settings → Input Map and add the following actions:

  • move_left (assign A key and Left arrow)
  • move_right (assign D key and Right arrow)
  • jump (assign Space key)
  • move_up (assign W key and Up arrow)
  • move_down (assign S key and Down arrow)

Step 2: Creating the Player Scene

2.1 Scene Structure

Create a new scene and set up the following node hierarchy:

Player (CharacterBody2D)
├── CollisionShape2D
├── UpperBodySprite (AnimatedSprite2D)
└── LowerBodySprite (AnimatedSprite2D)

2.2 Configure the Collision Shape

  1. Select the CollisionShape2D node
  2. In the Inspector, create a new CapsuleShape2D
  3. Adjust the capsule size to match your player sprite dimensions
  4. Position it appropriately relative to your sprites

2.3 Set Up Sprite Frames

For both UpperBodySprite and LowerBodySprite:

  1. Create new SpriteFrames resources
  2. Add the following animations:
  3. idle - Standing still animation
  4. move - Walking/running animation
  5. jump - Jump start animation
  6. fall - Falling animation
  7. wallhang - Hanging on wall animation
  8. wallslide - Sliding down wall animation
  9. climb - Climbing animation

Pro Tip: You can create placeholder animations with simple colored rectangles to test the system before adding final artwork.

Step 3: Implementing the Player Controller Script

3.1 Create the Script

  1. Right-click on the Player node
  2. Select Attach Script
  3. Choose C# as the language
  4. Name it Player.cs

3.2 The Complete Player Controller Code

Replace the default script content with the following comprehensive player controller:

using Godot;

public partial class Player : CharacterBody2D
{
    // Node references
    [Export] public AnimatedSprite2D UpperBodySprite { get; set; }
    [Export] public AnimatedSprite2D LowerBodySprite { get; set; }
    [Export] public CollisionShape2D CollisionShape { get; set; }

    // Movement constants
    [Export] public float Speed { get; set; } = 300.0f;
    [Export] public float JumpVelocity { get; set; } = -400.0f;
    [Export] public float WallJumpVelocity { get; set; } = -350.0f;
    [Export] public float WallJumpHorizontalForce { get; set; } = 200.0f;
    [Export] public float WallHangTime { get; set; } = 2.0f;
    [Export] public float WallSlideSpeed { get; set; } = 100.0f;
    [Export] public float ClimbSpeed { get; set; } = 150.0f;

    // State variables
    private PlayerState _currentState = PlayerState.Idle;
    private bool _facingRight = true;
    private int _jumpCount = 0;
    private const int MaxJumps = 2;
    private float _wallHangTimer = 0.0f;
    private bool _canWallJump = false;
    private bool _isWallSliding = false;

    // Input flags
    private bool _inputLeft = false;
    private bool _inputRight = false;
    private bool _inputJump = false;
    private bool _inputJumpPressed = false;
    private bool _inputUp = false;
    private bool _inputDown = false;

    // Physics
    private float _gravity = ProjectSettings.GetSetting("physics/2d/default_gravity").AsSingle();

    public enum PlayerState
    {
        Idle,
        Moving,
        Jumping,
        Falling,
        WallHanging,
        WallSliding,
        Climbing,
        // Future states for extensibility
        MeleeAttack,
        Shooting
    }

    public enum AnimationType
    {
        Idle,
        Move,
        Jump,
        Fall,
        WallHang,
        WallSlide,
        Climb,
        // Future animations
        MeleeAttack,
        Shoot
    }

    public override void _Ready()
    {
        // Ensure sprites are properly assigned
        if (UpperBodySprite == null)
            UpperBodySprite = GetNode<AnimatedSprite2D>("UpperBodySprite");
        if (LowerBodySprite == null)
            LowerBodySprite = GetNode<AnimatedSprite2D>("LowerBodySprite");
        if (CollisionShape == null)
            CollisionShape = GetNode<CollisionShape2D>("CollisionShape2D");

        // Initialize state
        UpdateAnimation(AnimationType.Idle);
    }

    public override void _PhysicsProcess(double delta)
    {
        HandleInput();
        UpdateState(delta);
        ApplyMovement(delta);
        UpdateAnimation();
        MoveAndSlide();
    }

    private void HandleInput()
    {
        _inputLeft = Input.IsActionPressed("move_left");
        _inputRight = Input.IsActionPressed("move_right");
        _inputJump = Input.IsActionPressed("jump");
        _inputJumpPressed = Input.IsActionJustPressed("jump");
        _inputUp = Input.IsActionPressed("move_up");
        _inputDown = Input.IsActionPressed("move_down");
    }

    private void UpdateState(double delta)
    {
        bool wasOnFloor = IsOnFloor();
        bool isOnWall = IsOnWall();

        // Reset jump count when on floor
        if (IsOnFloor())
        {
            _jumpCount = 0;
            _canWallJump = false;
            _isWallSliding = false;
            _wallHangTimer = 0.0f;
        }

        // Handle wall interactions
        if (isOnWall && !IsOnFloor())
        {
            HandleWallInteraction(delta);
        }
        else
        {
            _wallHangTimer = 0.0f;
            _canWallJump = false;
            _isWallSliding = false;
        }

        // Determine current state based on conditions
        switch (_currentState)
        {
            case PlayerState.Idle:
            case PlayerState.Moving:
                if (!IsOnFloor())
                {
                    if (Velocity.Y < 0)
                        _currentState = PlayerState.Jumping;
                    else
                        _currentState = PlayerState.Falling;
                }
                else if (_inputLeft || _inputRight)
                {
                    _currentState = PlayerState.Moving;
                }
                else
                {
                    _currentState = PlayerState.Idle;
                }
                break;

            case PlayerState.Jumping:
            case PlayerState.Falling:
                if (IsOnFloor())
                {
                    if (_inputLeft || _inputRight)
                        _currentState = PlayerState.Moving;
                    else
                        _currentState = PlayerState.Idle;
                }
                else if (_currentState == PlayerState.Jumping && Velocity.Y >= 0)
                {
                    _currentState = PlayerState.Falling;
                }
                break;

            case PlayerState.WallHanging:
            case PlayerState.WallSliding:
                if (!isOnWall || IsOnFloor())
                {
                    if (IsOnFloor())
                    {
                        if (_inputLeft || _inputRight)
                            _currentState = PlayerState.Moving;
                        else
                            _currentState = PlayerState.Idle;
                    }
                    else
                    {
                        _currentState = PlayerState.Falling;
                    }
                }
                break;

            case PlayerState.Climbing:
                if (!isOnWall)
                {
                    _currentState = PlayerState.Falling;
                }
                else if (IsOnFloor())
                {
                    _currentState = PlayerState.Idle;
                }
                break;
        }
    }

    private void HandleWallInteraction(double delta)
    {
        _canWallJump = true;

        // Check if player is trying to climb
        if (_inputUp && CanClimb())
        {
            _currentState = PlayerState.Climbing;
            return;
        }

        // Handle wall hanging and sliding
        if (Velocity.Y >= 0) // Only when falling or stationary
        {
            if (_wallHangTimer < WallHangTime)
            {
                _currentState = PlayerState.WallHanging;
                _wallHangTimer += (float)delta;
            }
            else
            {
                _currentState = PlayerState.WallSliding;
                _isWallSliding = true;
            }
        }
    }

    private void ApplyMovement(double delta)
    {
        switch (_currentState)
        {
            case PlayerState.Idle:
            case PlayerState.Moving:
                HandleGroundMovement();
                ApplyGravity(delta);
                HandleJump();
                break;

            case PlayerState.Jumping:
            case PlayerState.Falling:
                HandleAirMovement();
                ApplyGravity(delta);
                HandleDoubleJump();
                HandleWallJump();
                break;

            case PlayerState.WallHanging:
                HandleWallHang();
                HandleWallJump();
                break;

            case PlayerState.WallSliding:
                HandleWallSlide(delta);
                HandleWallJump();
                break;

            case PlayerState.Climbing:
                HandleClimbing();
                break;
        }

        // Update facing direction
        UpdateFacingDirection();
    }

    private void HandleGroundMovement()
    {
        Vector2 velocity = Velocity;

        if (_inputLeft)
        {
            velocity.X = -Speed;
        }
        else if (_inputRight)
        {
            velocity.X = Speed;
        }
        else
        {
            velocity.X = Mathf.MoveToward(velocity.X, 0, Speed * 0.1f);
        }

        Velocity = velocity;
    }

    private void HandleAirMovement()
    {
        Vector2 velocity = Velocity;

        if (_inputLeft)
        {
            velocity.X = -Speed * 0.8f; // Reduced air control
        }
        else if (_inputRight)
        {
            velocity.X = Speed * 0.8f;
        }

        Velocity = velocity;
    }

    private void HandleJump()
    {
        if (_inputJumpPressed && IsOnFloor())
        {
            Vector2 velocity = Velocity;
            velocity.Y = JumpVelocity;
            Velocity = velocity;
            _jumpCount = 1;
        }
    }

    private void HandleDoubleJump()
    {
        if (_inputJumpPressed && _jumpCount < MaxJumps && !_canWallJump)
        {
            Vector2 velocity = Velocity;
            velocity.Y = JumpVelocity;
            Velocity = velocity;
            _jumpCount++;
        }
    }

    private void HandleWallJump()
    {
        if (_inputJumpPressed && _canWallJump)
        {
            Vector2 velocity = Velocity;
            velocity.Y = WallJumpVelocity;

            // Determine wall jump direction
            if (IsOnWallOnly())
            {
                int wallDirection = GetWallNormal().X > 0 ? 1 : -1;
                velocity.X = wallDirection * WallJumpHorizontalForce;
            }

            Velocity = velocity;
            _jumpCount = 1;
            _canWallJump = false;
            _wallHangTimer = 0.0f;
        }
    }

    private void HandleWallHang()
    {
        Vector2 velocity = Velocity;
        velocity.Y = 0; // Stop falling while hanging
        Velocity = velocity;
    }

    private void HandleWallSlide(double delta)
    {
        Vector2 velocity = Velocity;

        // Gradually increase slide speed from 0 to WallSlideSpeed
        float slideProgress = (_wallHangTimer - WallHangTime) / (WallSlideSpeed / _gravity);
        slideProgress = Mathf.Clamp(slideProgress, 0.0f, 1.0f);

        velocity.Y = Mathf.Lerp(0, WallSlideSpeed, slideProgress);
        Velocity = velocity;
    }

    private void HandleClimbing()
    {
        Vector2 velocity = Velocity;

        if (_inputUp)
        {
            velocity.Y = -ClimbSpeed;
        }
        else if (_inputDown)
        {
            velocity.Y = ClimbSpeed;
        }
        else
        {
            velocity.Y = 0;
        }

        velocity.X = 0;
        Velocity = velocity;
    }

    private void ApplyGravity(double delta)
    {
        Vector2 velocity = Velocity;

        if (!IsOnFloor())
        {
            velocity.Y += _gravity * (float)delta;
        }

        Velocity = velocity;
    }

    private void UpdateFacingDirection()
    {
        if (_inputRight && !_facingRight)
        {
            _facingRight = true;
            FlipSprites(false);
        }
        else if (_inputLeft && _facingRight)
        {
            _facingRight = false;
            FlipSprites(true);
        }
    }

    private void FlipSprites(bool flip)
    {
        UpperBodySprite.FlipH = flip;
        LowerBodySprite.FlipH = flip;
    }

    private void UpdateAnimation(AnimationType? forceAnimation = null)
    {
        AnimationType targetAnimation;

        if (forceAnimation.HasValue)
        {
            targetAnimation = forceAnimation.Value;
        }
        else
        {
            targetAnimation = _currentState switch
            {
                PlayerState.Idle => AnimationType.Idle,
                PlayerState.Moving => AnimationType.Move,
                PlayerState.Jumping => AnimationType.Jump,
                PlayerState.Falling => AnimationType.Fall,
                PlayerState.WallHanging => AnimationType.WallHang,
                PlayerState.WallSliding => AnimationType.WallSlide,
                PlayerState.Climbing => AnimationType.Climb,
                _ => AnimationType.Idle
            };
        }

        PlayAnimation(targetAnimation);
    }

    private void PlayAnimation(AnimationType animation)
    {
        string animationName = animation.ToString().ToLower();

        // Play animation on both sprites
        if (UpperBodySprite.SpriteFrames.HasAnimation(animationName))
        {
            UpperBodySprite.Play(animationName);
        }

        if (LowerBodySprite.SpriteFrames.HasAnimation(animationName))
        {
            LowerBodySprite.Play(animationName);
        }
    }

    private bool CanClimb()
    {
        // Override this method to add climb condition logic (e.g., ladder detection)
        return IsOnWall(); // Simple implementation - can climb any wall
    }

    // Utility methods for future extensibility
    protected virtual void OnActionStarted(PlayerState action)
    {
        // Override in derived classes or use signals for action events
    }

    protected virtual void OnActionEnded(PlayerState action)
    {
        // Override in derived classes or use signals for action events
    }

    protected virtual bool CanPerformAction(PlayerState action)
    {
        // Override to add action-specific conditions
        return true;
    }

    // Public methods for external systems
    public PlayerState GetCurrentState() => _currentState;
    public bool IsFacingRight() => _facingRight;
    public bool IsWallSliding() => _isWallSliding;
    public float GetWallHangTimeRemaining() => Mathf.Max(0, WallHangTime - _wallHangTimer);
}

Step 4: Configuring the Inspector Properties

After attaching the script, configure the exported properties in the Inspector:

4.1 Node References

  • UpperBodySprite: Drag your UpperBodySprite node here
  • LowerBodySprite: Drag your LowerBodySprite node here
  • CollisionShape: Drag your CollisionShape2D node here

4.2 Movement Parameters

Adjust these values to fine-tune your player's feel:

  • Speed: 300 (horizontal movement speed)
  • Jump Velocity: -400 (initial jump force, negative for upward)
  • Wall Jump Velocity: -350 (wall jump upward force)
  • Wall Jump Horizontal Force: 200 (horizontal push from wall)
  • Wall Hang Time: 2.0 (seconds before sliding starts)
  • Wall Slide Speed: 100 (maximum slide speed)
  • Climb Speed: 150 (vertical climbing speed)

Step 5: Creating a Test Level

5.1 Basic Level Setup

Create a new scene for testing:

Main (Node2D)
├── Player (instance of your player scene)
└── Level (Node2D)
    ├── Ground (StaticBody2D)
    │   ├── CollisionShape2D (RectangleShape2D)
    │   └── Sprite2D
    └── Wall (StaticBody2D)
        ├── CollisionShape2D (RectangleShape2D)
        └── Sprite2D

5.2 Testing the Controller

Run your scene and test the following mechanics:

  1. Basic Movement: A/D keys for left/right movement
  2. Jumping: Space for jump and double jump
  3. Wall Mechanics: Run toward a wall and test hanging/sliding
  4. Wall Jumping: Jump while against a wall
  5. Climbing: Hold W while against a wall
  6. Sprite Flipping: Verify sprites flip when changing direction

Step 6: Understanding the Architecture

6.1 State Management System

The controller uses a comprehensive state system:

  • PlayerState enum: Defines all possible player states
  • State transitions: Handled in UpdateState() method
  • State-specific behavior: Each state has its own movement logic

6.2 Animation System

The dual-sprite animation system provides flexibility:

  • AnimationType enum: Maps to sprite frame animations
  • Synchronized playback: Both sprites play the same animation
  • Easy expansion: Add new animation types for future features

6.3 Input Handling

Clean separation of input detection and action execution:

  • Input flags: Updated each frame in HandleInput()
  • Action methods: Process inputs based on current state
  • Extensible: Easy to add new input actions

Step 7: Customization and Extension

7.1 Adjusting Game Feel

Fine-tune these aspects for your game's unique feel:

  • Air Control: Modify the 0.8f multiplier in HandleAirMovement()
  • Ground Friction: Adjust the 0.1f value in HandleGroundMovement()
  • Wall Slide Progression: Modify the calculation in HandleWallSlide()

7.2 Adding New States

To add combat or other features:

  1. Add new states to PlayerState enum
  2. Add corresponding animations to AnimationType enum
  3. Implement state-specific logic in ApplyMovement()
  4. Add state transitions in UpdateState()

7.3 Enhanced Climbing System

For more sophisticated climbing (ladders, specific surfaces):

private bool CanClimb()
{
    // Check for climbable objects using area detection
    var spaceState = GetWorld2D().DirectSpaceState;
    var query = PhysicsRayQueryParameters2D.Create(
        GlobalPosition,
        GlobalPosition + Vector2.Right * 10
    );
    var result = spaceState.IntersectRay(query);

    if (result.Count > 0)
    {
        var collider = result["collider"].AsGodotObject();
        return collider.HasMethod("IsClimbable") && 
               collider.Call("IsClimbable").AsBool();
    }

    return false;
}

Troubleshooting Common Issues

Animation Not Playing

  • Ensure animation names in SpriteFrames match the lowercase enum values
  • Check that both upper and lower body sprites have the required animations
  • Verify the sprites are properly assigned in the Inspector

Wall Detection Issues

  • Ensure walls have StaticBody2D or RigidBody2D nodes
  • Check collision layers and masks are properly configured
  • Test with simple rectangular collision shapes first

Movement Feels Sluggish

  • Increase the Speed property
  • Reduce air control multiplier for more responsive air movement
  • Adjust gravity in Project Settings if jumps feel too floaty

Performance Considerations

This controller is optimized for performance:

  • Minimal allocations: Reuses Vector2 structures
  • Efficient state checking: Uses boolean flags instead of repeated method calls
  • Predictable frame rate: All operations are O(1) complexity

Conclusion

You now have a professional-grade 2D platformer controller that provides:

  • Smooth, responsive movement with proper physics
  • Advanced wall mechanics including hanging, sliding, and jumping
  • Dual-sprite animation system for complex character animations
  • Clean, extensible architecture ready for additional features
  • Comprehensive state management for predictable behavior

This controller serves as an excellent foundation for any 2D platformer project. The modular design makes it easy to add new abilities like combat, special moves, or power-ups without restructuring the core system.

Next Steps

Consider extending this controller with:

  • Combat System: Add melee and ranged attack states
  • Power-ups: Implement temporary ability modifications
  • Sound Integration: Add audio cues for actions and state changes
  • Visual Effects: Integrate particle systems for enhanced feedback
  • Advanced Movement: Add mechanics like dashing, sliding, or grappling

The foundation you've built here will support all of these additions while maintaining clean, readable code structure.