Programmeur gameplay

Rewinder

A propos

Rewinder est un jeu de course à la première personne avec un mécanisme de voyage dans le temps. L'objectif du jeu est de s'échapper d'une prison abstraite en voyageant à travers différents mondes reliés par des portails. Pour les ouvrir, le joueur doit collecter des objets disséminés dans des niveaux acrobatiques. À long terme, le joueur visera le meilleur score à chaque niveau et le meilleur temps global passé à jouer. Moins il mettra de temps à terminer un niveau, meilleur sera son score.

Info Projet

  • Role: Programmeur Gameplay
  • Équipe: 6
  • Durée: 6 mois
  • Moteur: Unity 3D

Introduction

Sur Rewinder, j'étais le developpeur principal pour les systèmes du joueur. Mes responsabilités incluaient la définition de l'architecture du jeu, la mise en œuvre du contrôleur de personnage et le maintien de la stabilité technique globale tout au long du développement.

L'équipe était composée de 2 programmeurs et de 4 artistes, ce qui signifiait que les décisions architecturales étaient importantes dès le début. Nous avions besoin d’une configuration permettant aux programmeurs de travailler en parallèle, d’itérer rapidement et de déboguer les problèmes sans constamment empiéter sur le travail des autres.

Au cours de ce projet, je me suis particulièrement intéressé au State Pattern et j'ai décidé de l'utiliser comme base pour le contrôleur du joueur. Cet article passe en revue cette décision, l'architecture qui en a résulté et un défi de gameplay concret : mettre en œuvre la course sur mur et le saut sur mur d'une manière qui semble réactive et naturelle.

Pourquoi le State Pattern?

Avant d'aborder l'implémentation, j'avais deux préoccupations principales : la clarté et la débogage avec extensibilité.

Je voulais de la clarté, car les contrôleurs des joueurs ont tendance à se transformer en gros blocs conditionnels à mesure que les fonctionnalités s'accumulent. Je voulais une structure où chaque comportement vivait dans un lieu clairement défini.

Avec plusieurs personnes travaillant sur la base de code, savoir où se trouvait un comportement et pourquoi il était exécuté devait être évident.

Le state pattern s'adapte bien à ces contraintes. Le comportement de chaque joueur (inactif, courir, sauter, courir sur un mur, sauter sur un mur, grimper, tomber…) pouvait exister de manière isolée, tandis qu'une state machine centrale gérait l'exécution.

J'ai délibérément choisi des transitions gérées à l'intérieur des états, et non via une table de transition globale. Cela maintenait la logique proche du comportement et réduisait l’indirection.

J'ai également choisi une approche basée sur l'interface au lieu d'une approche basée sur l'héritage. Cela évitait un couplage rigide et rendait la réflexion sur les différents états du joueur plus faciles à aborder.

Implementation de systeme d'états

À la base, le système se compose de :

  • Une StateMachine appartenant au joueur.

  • Une interface IState partagée implémentée par tous les états concrets.

Chaque état définit son propre cycle de vie :

namespace InadeStd.Player.StateSystem
{
    /// <summary> Base interface for a state </summary>
    public interface IState
    {
        /// <summary> Called once, when we enter the state </summary>
        void Enter();
        
        /// <summary> Called each frame, when we are in the state </summary>
        void Execute();
        
        /// <summary> Called each fixed frame, when we are in the state </summary>
        void FixedExecute();
        
        /// <summary> Called once, when we exit the state </summary>
        void Exit();

    }
}

The state machine itself is intentionally minimal:

using UnityEngine;

namespace InadeStd.Player.StateSystem
{
    /// <summary> Base class for a state machine </summary>
    public class StateMachine
    {
        ///<summary> The current state we're in </summary>
        private IState m_currentState;

        /// <summary> Method to change state. It needs a new state as parameter</summary>
        /// <param name="p_newState"> The new state we want </param>
        public void ChangeState(IState p_newState)
        {
            m_currentState?.Exit();

            m_currentState = p_newState;
            m_currentState.Enter();
        }
        
        /// <summary>The update method of the state machine. It should be called each frame.</summary>
        public void Update()
        {
            m_currentState?.Execute();
        }

        public void FixedUpdate()
        {
            m_currentState?.FixedExecute();
        }
        
    }
}

Cette simplicité était intentionnelle. La state machine ne décide de rien, elle délègue l’exécution. Toutes les décisions de jeu se situent à l'intérieur des états eux-mêmes, ce qui rend les bugs plus faciles à suivre et les comportements plus faciles à modifier.

Course sur le mur & Saut de mur: Un challenge guidé par le gameplay

L’un des défis les plus intéressants de Rewinder consistait à implémenter la course et le saut sur les murs d’une manière plus fluide que mécanique.

Du point de vue de la conception, le joueur devait :

  • Détecter les murs de manière fiable

  • Distinguer entre l'escalade et la course sur mur

  • Se déplacer le long du mur avec une sensation d'élan et avec courbure qui semble naturelle

  • Transition nette vers des sauts ou des chutes

Stratégie de détection de murs

Pour déduire l'intention du joueur, j'ai implémenté une approche multi-raycast. Un petit ensemble de raycasts est envoyé depuis la position du joueur, légèrement décalé, vérifiant :

  • Directement vers l'avant

  • Gauche et droite

  • Avant-gauche et avant-droite

Cela a permis au contrôleur de classer la situation en fonction de la position de contact sur la géométrie.

/// <summary>
/// This method launches rays in the different directions we defined. If it touches something, we launch the wall run state.
/// </summary>
public void WallRunChecker()
{
    Vector3 p_startB = new Vector3(transform.position.x, transform.position.y + 0.1f, transform.position.z);
    m_isClimbing = Physics.Raycast(transform.position, transform.forward, 0.9f, m_playerDataVar.m_wallrunLayer);
    m_touchLeft = Physics.Raycast(p_startB, -transform.right, out RaycastHit p_hitL, 0.7f, m_playerDataVar.m_wallrunLayer);
    m_touchRight = Physics.Raycast(p_startB, transform.right, out RaycastHit p_hitR, 0.7f, m_playerDataVar.m_wallrunLayer);
    m_touchLeftForward = Physics.Raycast(p_startB, -transform.right + transform.forward, out RaycastHit p_hitLF, 0.7f, m_playerDataVar.m_wallrunLayer);
    m_touchRightForward = Physics.Raycast(p_startB, transform.right + transform.forward, out RaycastHit p_hitRF, 0.7f, m_playerDataVar.m_wallrunLayer);

    //Left side
    if(m_touchLeftForward && m_touchLeft)
    {
        /*if(m_touchLeftForward)
            m_currentWallRunSurfaceNormal = p_hitLF.normal;
        else if (m_touchLeft)*/
        m_currentWallRunSurfaceNormal = p_hitL.normal;
        
        // Get the 90° "direction" based on which side the wall is. This is used to fake gravity.
        m_wallPerpendicularAngle = 90.0f;
        // Set the direction in which the vector will be rotated to go upwards
        m_currentWallrunAngle = (m_moveDirection.y >= 0 ? -m_playerDataVar.m_wallrunAngle : -m_playerDataVar.m_wallrunAngleNegative);
        
        m_stateMachine.ChangeState(m_wallRunState);
    }
    //Right side
    else if (m_touchRightForward && m_touchRight)
    {
        /*if(m_touchRightForward)
            m_currentWallRunSurfaceNormal = p_hitRF.normal;
        else if (m_touchRight)*/
        m_currentWallRunSurfaceNormal = p_hitR.normal;
        
        // Get the 90° "direction" based on which side the wall is. This is used to fake gravity.
        m_wallPerpendicularAngle = -90.0f;
        // Set the direction in which the vector will be rotated to go upwards, depending on the entrance Y velocity
        m_currentWallrunAngle = (m_moveDirection.y >= 0 ?m_playerDataVar.m_wallrunAngle : m_playerDataVar.m_wallrunAngleNegative);
        
        m_stateMachine.ChangeState(m_wallRunState);
    }
    else if(m_isClimbing)
    {
        m_playerAnim.ResetTrigger("ResumeRun");
        m_playerAnim.SetTrigger("ClimbWall");
        m_stateMachine.ChangeState(m_climbState);
    }
}

Le point important ici n’est pas les raycasts eux-mêmes, mais l’inférence d’intention : le contrôleur décide de ce que le joueur veut faire en fonction du contexte spatial.

Avancer sur le mur

Une fois dans l’état de course sur le mur, le mouvement est calculé par rapport à la normale à la surface du mur.

La direction est projetée le long du mur et légèrement tournée vers le haut, puis elle s'incurve progressivement vers le bas pour simuler une perte d'élan.

Cela produit une trajectoire ressemblant à une courbe convexe, donnant a la course sur le mur une sensation naturelle de montée et de descente plutôt qu'une glisse à plat.

/// <summary> Method to apply the movement of the player along a wall </summary>
/// <param name="p_hitNormal"> The normal vector of the wall we're moving on</param>
private void MoveAlongWall(Vector3 p_hitNormal)
{
    // Check if we're still on the wall
    m_inWallRun = Physics.Raycast(m_owner.transform.position, -p_hitNormal, 0.7f,
        m_owner.m_playerDataVar.m_wallrunLayer);
    
    if (m_inWallRun)
    {
        StateCheck.OnWall = true;
        
        // Set the wall run speed
        m_owner.m_currentWallrunSpeed = m_owner.m_moveDirection.magnitude + m_owner.m_playerDataVar.m_wallrunEntranceSpeedBoost;
        m_owner.m_currentWallrunSpeed += m_owner.m_playerDataVar.m_wallrunSpeedBoost; 
        
        // Clamp it so we don't go too fast
        m_owner.m_currentWallrunSpeed = Mathf.Clamp(m_owner.m_currentWallrunSpeed, 0.0f, m_owner.m_playerDataVar.m_maximumWallrunSpeed);

        // Set the base wall direction and the move direction (which is wall direction rotated to go slightly upwards)
        Vector3 p_wallForwardDirection = Vector3.ProjectOnPlane(m_owner.transform.forward, p_hitNormal).normalized;
        m_owner.m_wallrunBaseDirection = Quaternion.AngleAxis(m_owner.m_currentWallrunAngle, p_hitNormal) * p_wallForwardDirection;

        // Calculate the version of the vector that adds fake gravity
        Vector3 p_direction;
        if (m_owner.m_wallrunSlowdownTime < 1.0f)
        { 
            // Set direction
            p_direction = Vector3.Lerp(m_owner.m_wallrunBaseDirection, Quaternion.AngleAxis(m_owner.m_wallPerpendicularAngle, p_hitNormal) * m_owner.m_wallrunBaseDirection, m_owner.m_wallrunSlowdownTime);
            // Augment the slowdown factor
            m_owner.m_wallrunSlowdownTime += Time.deltaTime * m_owner.m_playerDataVar.m_wallrunSlowdown;
        }
        else { p_direction = Quaternion.AngleAxis(m_owner.m_wallPerpendicularAngle, p_hitNormal) * m_owner.m_wallrunBaseDirection; } // Set direction
        
        // Move along the vector
        m_owner.m_moveDirection = p_direction * m_owner.m_currentWallrunSpeed;
        m_owner.m_controller.Move(m_owner.m_moveDirection*Time.deltaTime);
        
        if (Input.GetButtonDown(m_owner.m_playerDataVar.m_jumpKey)) { m_owner.m_stateMachine.ChangeState(m_owner.m_wallJumpState); } // Check if we jump to change to wall jump
    }
    else { m_owner.m_stateMachine.ChangeState(m_owner.m_fallFromWallRunState); } //Check if we've reach the end of the wall to change to a fall
    
}

En gardant cette logique contenue dans l’état d’exécution de la course sur le mur, le réglage de parametres et l’itération sont devenus beaucoup plus faciles. Les ajustements du gameplay ne risquaient pas de briser un autre module ou un autre état.

Choix Architecturaux

Le projet impliquait plusieurs programmeurs, la modularité était une priorité. La principale décision que nous avons prise a été d'avoir des Assembly Definitions pour chaque module. Cela a contribué à faire respecter les limites et à réduire les dépendances accidentelles.

Une autre décision que nous avons prise a été d'utiliser des Scriptable Objects pour les données de configuration partagées. Cela a permis aux concepteurs et à nous, les programmeurs, de modifier les valeurs sans modifier le code.

Nous avons également utilisé un singleton GameManager léger, strictement pour les systèmes qui devaient persister dans toutes les scènes.

L'architecture du joueur basée sur un systeme d'état a également porté ses fruits : l'ajout ou l'ajustement de comportements nécessitait rarement de toucher à des systèmes non liés, ce qui réduisait les conflits de fusion et le temps de débogage.

Ce que j'ai apprit

Ce projet était ma première expérience en tant que chef technique pour une base de code partagée.

J'ai appris qu'une structure claire l'emporte sur les solutions intelligentes lorsque l'on travaille en équipe et que l'itération révèle les défauts de conception plus rapidement que la théorie.

Séparer l’expérimentation rapide de la stabilité structurelle est efficace lorsque les collaborateurs ont des styles de travail différents. Et comme mon coéquipier en programmation était excellent en expérimentation rapide, le travail entre nous était agréable et productif.

Du point de vue du gameplay, Rewinder était une expérience qui nous a rendu humble. De nombreuses mécaniques qui paraissaient bonnes sur le papier ne se sont révélées satisfaisantes qu'après de nombreux tests de jeu et réglages. Apprendre quand abandonner une idée ou la simplifier était tout aussi important que de la mettre en œuvre.

Rewinder était une expérience d’apprentissage à la fois technique et collaborative. Cela a renforcé mon intérêt pour la conception de systemes qui sont non seulement fonctionnels, mais compréhensibles, adaptables et agréables à utiliser, surtout en cadre d'équipe !

Si vous souhaitez essayer le jeu vous-même, vous pouvez le trouver ici :