Comment éviter un couplage de script étroit dans Unity?

24

Il y a quelque temps, j'ai commencé à travailler avec Unity et je suis toujours aux prises avec le problème des scripts étroitement couplés. Comment puis-je structurer mon code pour éviter ce problème?

Par exemple:

Je veux avoir des systèmes de santé et de mort dans des scripts séparés. Je veux également avoir différents scripts de marche interchangeables qui me permettent de changer la façon dont le personnage du joueur est déplacé (contrôles inertiels basés sur la physique comme dans Mario par rapport à des contrôles serrés et nerveux comme dans Super Meat Boy). Le script Santé doit contenir une référence au script Mort, afin qu'il puisse déclencher la méthode Die () lorsque la santé des joueurs atteint 0. Le script Mort doit contenir une référence au script de marche utilisé, pour désactiver la marche à mort (I suis fatigué des zombies).

Je créerais normalement des interfaces, comme IWalking, IHealthet IDeath, de sorte que je puisse changer ces éléments à volonté sans casser le reste de mon code. Je voudrais les configurer par un script séparé sur l'objet joueur, par exemple PlayerScriptDependancyInjector. Peut-être que ce script serait public IWalking, IHealthetIDeath les attributs, de sorte que les dépendances peuvent être définies par le concepteur de niveau de l'inspecteur en faisant glisser et déposer des scripts appropriés.

Cela me permettrait d'ajouter simplement des comportements aux objets de jeu facilement et de ne pas me soucier des dépendances codées en dur.

Le problème dans Unity

Le problème est que dans Unity, je ne peux pas exposer les interfaces dans l'inspecteur, et si j'écris mes propres inspecteurs, les références ne seront pas sérialisées, et c'est beaucoup de travail inutile. C'est pourquoi il me reste à écrire du code étroitement couplé. Mon Deathscript expose une référence à un InertiveWalkingscript. Mais si je décide que je veux que le personnage du joueur contrôle étroitement, je ne peux pas simplement glisser-déposer le TightWalkingscript, je dois changer le Deathscript. Ça craint. Je peux y faire face, mais mon âme pleure chaque fois que je fais quelque chose comme ça.

Quelle est l'alternative préférée aux interfaces dans Unity? Comment résoudre ce problème? J'ai trouvé ça , mais ça me dit ce que je sais déjà, et ça ne me dit pas comment faire ça dans Unity! Cela aussi discute de ce qui doit être fait, pas comment, cela ne résout pas le problème du couplage étroit entre les scripts.

Dans l'ensemble, je pense que ceux-ci sont écrits pour les personnes qui sont arrivées à Unity avec une formation en conception de jeux qui apprennent juste à coder, et il y a très peu de ressources sur Unity pour les développeurs réguliers. Existe-t-il un moyen standard de structurer votre code dans Unity ou dois-je trouver ma propre méthode?

KL
la source
1
The problem is that in Unity I can't expose interfaces in the inspectorJe ne pense pas comprendre ce que vous entendez par "exposer l'interface dans l'inspecteur" parce que ma première pensée a été "pourquoi pas?"
jhocking
@jhocking demande aux développeurs de l'unité. Vous ne le voyez tout simplement pas dans l'inspecteur. Il n'apparaît simplement pas là si vous le définissez comme un attribut public, et tous les autres attributs le font.
KL
1
ohhh vous voulez utiliser l'interface comme type de variable, pas seulement référencer un objet avec cette interface.
jhocking
1
@jzx Je connais et utilise la conception SOLIDE et je suis un grand fan des livres de Robert C. Martins, mais cette question porte sur la façon de le faire dans Unity. Et non, je n'ai pas lu cet article, je le lis tout de suite, merci :)
KL

Réponses:

14

Il existe plusieurs méthodes pour éviter un couplage de script étroit. Interne à Unity est une fonction SendMessage qui, lorsqu'elle est ciblée sur GameObject d'un Monobehaviour, envoie ce message à tout ce qui se trouve sur l'objet de jeu. Vous pourriez donc avoir quelque chose comme ça dans votre objet de santé:

[SerializeField]
private int _currentHealth;
public int currentHealth
{
    get { return _currentHealth;}
    protected set
    {
        _currentHealth = value;
        gameObject.SendMessage("HealthChanged", value, SendMessageOptions.DontRequireReceiver);
    }
}

[SerializeField]
private int _maxHealth;
public int maxHealth
{
    get { return _maxHealth;}
    protected set
    {
        _maxHealth = value;
        if (currentHealth > _maxHealth)
        {
            currentHealth = _maxHealth;
        }
    }
}

public void ChangeHealth(int deltaChange)
{
    currentHealth += deltaChange;
}

C'est vraiment simplifié mais cela devrait montrer précisément où je veux en venir. La propriété lançant le message comme vous le feriez pour un événement. Votre script de mort intercepterait alors ce message (et s'il n'y a rien pour l'intercepter, SendMessageOptions.DontRequireReceiver vous garantira de ne pas recevoir d'exception) et effectuerait des actions basées sur la nouvelle valeur de santé après qu'il a été défini. Ainsi:

void HealthChanged(int newHealth)
{
    if (newHealth <= 0)
    {
        Die();
    }
}

void Die ()
{
    Debug.Log ("I am undone!");
    DestroyObject (gameObject);
}

Il n'y a cependant aucune garantie d'ordre dans ce cas. D'après mon expérience, cela va toujours du script le plus haut sur un GameObject au script le plus bas, mais je ne miserais pas sur tout ce que vous ne pouvez pas observer par vous-même.

Il existe d'autres solutions que vous pourriez étudier, à savoir la création d'une fonction GameObject personnalisée qui obtient des composants et ne renvoie que les composants qui ont une interface spécifique au lieu de Type, cela prendrait un peu plus de temps mais pourrait bien servir vos objectifs.

En outre, vous pouvez écrire un éditeur personnalisé qui vérifie qu'un objet est affecté à une position dans l'éditeur pour cette interface avant de valider réellement l'affectation (dans ce cas, vous affecteriez le script comme normal, ce qui lui permettrait d'être sérialisé, mais en lançant une erreur s'il n'a pas utilisé la bonne interface et a refusé l'affectation). J'ai déjà fait les deux auparavant et je peux produire ce code, mais il faudra un certain temps pour le trouver en premier.

Brett Satoru
la source
5
SendMessage () résout cette situation et j'utilise cette commande dans de nombreuses situations, mais un système de messagerie non ciblé comme celui-ci est moins efficace que d'avoir directement une référence au récepteur. Vous devez être prudent de vous fier à cette commande pour toutes les communications de routine entre les objets.
jhocking
2
Je suis un fan personnel de l'utilisation des événements et des délégués où les objets composants connaissent les systèmes centraux les plus internes mais les systèmes centraux ne savent rien des composants. Mais je n'étais pas sûr à 100% que c'était ainsi qu'ils voulaient que leur réponse soit conçue. Je suis donc allé avec quelque chose qui a répondu à comment découpler complètement les scripts.
Brett Satoru
1
Je pense également qu'il existe des plugins disponibles dans le magasin de ressources d'unité qui peuvent exposer des interfaces et d'autres constructions de programmation non prises en charge par l'inspecteur Unity, cela pourrait valoir la peine d'avoir une recherche rapide.
Matthew Pigram
7

Personnellement, je n'utilise JAMAIS SendMessage. Il y a toujours une dépendance entre vos composants avec SendMessage, c'est juste très mal affiché et facile à casser. L'utilisation d'interfaces et / ou de délégués supprime vraiment la nécessité d'utiliser SendMessage (ce qui est plus lent, bien que cela ne devrait pas être un problème tant qu'il ne doit pas l'être).

http://forum.unity3d.com/threads/free-vfw-full-set-of-drawers-savesystem-serialize-interfaces-generics-auto-props-delegates.266165/

Utilisez les trucs de ce mec. Il fournit un tas de trucs d'éditeur comme des interfaces exposées et des délégués. Il y a des tas de trucs comme ça, ou vous pouvez même faire les vôtres. Quoi que vous fassiez, NE compromettez PAS votre conception en raison du manque de prise en charge des scripts par Unity. Ces choses devraient être dans Unity par défaut.

Si ce n'est pas une option, faites glisser et déposez les classes monobehaviour à la place et convertissez-les dynamiquement dans l'interface requise. C'est plus de travail et moins élégant, mais cela fait le travail.

Ben
la source
2
Personnellement, je crains de devenir trop dépendant des plugins et des bibliothèques pour des trucs de bas niveau comme celui-ci. Je suis probablement un peu paranoïaque, mais cela me semble trop risqué de voir mon projet se casser parce que leur code se casse après une mise à jour Unity.
jhocking
J'utilisais ce cadre et je me suis finalement arrêté car j'avais la raison même pour laquelle @jhocking mentionne me ronger l'esprit (et cela n'a pas aidé cela d'une mise à jour à l'autre, il est passé d'un assistant éditeur à un système qui comprenait tout mais l'évier de cuisine tout en brisant la compatibilité descendante [et en supprimant une ou deux fonctionnalités plutôt utiles])
Selali Adobor
6

Une approche spécifique à Unity qui me vient à l'esprit serait GetComponents<MonoBehaviour>() à obtenir une liste de scripts, puis à convertir ces scripts en variables privées pour les interfaces spécifiques. Vous souhaitez le faire dans Start () sur le script d'injecteur de dépendance. Quelque chose comme:

MonoBehaviour[] list = GetComponents<MonoBehaviour>();
for (int i = 0; i < list.Length; i++) {
    if (list[i] is IHealth) {
        Debug.Log("Found it!");
    }
}

En fait, avec cette méthode, vous pourriez même demander aux composants individuels de dire au script d'injection de dépendances quelles sont leurs dépendances, en utilisant le commande typeof () et le type 'Type' . En d'autres termes, les composants donnent à l'injecteur de dépendances une liste de types, puis l'injecteur de dépendances renvoie une liste d'objets de ces types.


Alternativement, vous pouvez simplement utiliser des classes abstraites au lieu d'interfaces. Une classe abstraite peut accomplir à peu près le même objectif qu'une interface, et vous pouvez référencer cela dans l'inspecteur.

jhocking
la source
Je ne peux pas hériter de plusieurs classes alors que je peux implémenter plusieurs interfaces. Cela pourrait être un problème, mais je suppose que c'est beaucoup mieux que rien :) Merci pour votre réponse!
KL
Quant à l'édition - une fois que j'ai la liste des scripts, comment mon code sait-il quel script ne peut pas utiliser pour quelle interface?
KL
1
édité pour expliquer cela aussi
jhocking
1
Dans Unity 5, vous pouvez utiliser GetComponent<IMyInterface>()et GetComponents<IMyInterface>()directement, qui retourne le ou les composants qui l'implémentent.
S. Tarık Çetin
5

D'après ma propre expérience, le développement de jeu implique traditionnellement une approche plus pragmatique que le développement industriel (avec moins de couches d'abstraction). En partie parce que vous voulez privilégier les performances à la maintenabilité, et aussi parce que vous êtes moins susceptible de réutiliser votre code dans d'autres contextes (il y a généralement un fort couplage intrinsèque entre les graphiques et la logique du jeu)

Je comprends parfaitement votre préoccupation, en particulier parce que les performances sont moins critiques avec les appareils récents, mais j'ai le sentiment que vous devrez développer vos propres solutions si vous souhaitez appliquer les meilleures pratiques de POO industrielle au développement de jeux Unity (comme l'injection de dépendances, etc...).

sdabet
la source
2

Jetez un œil à IUnified ( http://u3d.as/content/wounded-wolf/iunified/5H1 ), qui vous permet d'exposer des interfaces dans l'éditeur, comme vous le mentionnez. Il y a un peu plus de câblage à faire par rapport à la déclaration de variable normale, c'est tout.

public interface IPinballValue{
   void Add(int value);
}

[System.Serializable]
public class IPinballValueContainer : IUnifiedContainer<IPinballValue> {}

public class MyClassThatExposesAnInterfaceProperty : MonoBehaviour {
   [SerializeField]
   IPinballValueContainer value;
}

De plus, comme une autre réponse le mentionne, l'utilisation de SendMessage ne supprime pas le couplage. Au lieu de cela, il ne fait pas d'erreur au moment de la compilation. Très mauvais - à mesure que vous développez votre projet, cela peut devenir un cauchemar à entretenir.

Si vous cherchez à découpler votre code sans utiliser d'interface dans l'éditeur, vous pouvez utiliser un système basé sur les événements fortement typé (ou même un système vaguement typé en fait, mais encore une fois, plus difficile à maintenir). J'ai basé le mien sur ce post , qui est super pratique comme point de départ. Soyez conscient de créer un tas d'événements car cela pourrait déclencher le GC. J'ai plutôt contourné le problème avec un pool d'objets, mais c'est principalement parce que je travaille avec un mobile.

ADB
la source
1

Source: http://www.udellgames.com/posts/ultra-useful-unity-snippet-developers-use-interfaces/

[SerializeField]
private GameObject privateGameObjectName;

public GameObject PublicGameObjectName
{
    get { return privateGameObjectName; }
    set
    {
        //Make sure changes to privateGameObjectName ripple to privateInterfaceName too
        if (privateGameObjectName != value)
        {
            privateInterfaceName = null;
            privateGameObjectName = value;
        }
    }
}

private InterfaceType privateInterfaceName;
public InterfaceType PublicInterfaceName
{
    get
    {
        if (privateInterfaceName == null)
        {
            privateInterfaceName = PublicGameObjectName.GetComponent(typeof(InterfaceType)) as InterfaceType;
        }
        return privateInterfaceName;
    }
    set
    {
        //Make sure changes to PublicInterfaceName ripple to PublicGameObjectName too
        if (privateInterfaceName != value)
        {
            privateGameObjectName = (privateInterfaceName as Component).gameObject;
            privateInterfaceName = value;
        }

    }
}
Louis Hong
la source
0

J'utilise quelques stratégies différentes pour contourner les limitations que vous mentionnez.

L'un de ceux que je ne vois pas mentionné utilise un MonoBehaviorqui expose l'accès aux différentes implémentations d'une interface donnée. Par exemple, j'ai une interface qui définit comment les Raycasts sont implémentés nommésIRaycastStrategy

public interface IRaycastStrategy 
{
    bool Cast(Ray ray, out RaycastHit raycastHit, float distance, LayerMask layerMask);
}

Je l'implémente ensuite dans différentes classes avec des classes comme LinecastRaycastStrategyet SphereRaycastStrategyet implémente un MonoBehaviorRaycastStrategySelector qui expose une seule instance de chaque type. Quelque chose comme RaycastStrategySelectionBehavior, qui est implémenté quelque chose comme:

public class RaycastStrategySelectionBehavior : MonoBehavior
{

    private readonly Dictionary<RaycastStrategyType, IRaycastStrategy> _raycastStrategies
        = new Dictionary<RaycastStrategyType, IRaycastStrategy>
        {
            { RaycastStrategyType.Line, new LineRaycastStrategy() },
            { RaycastStrategyType.Sphere, new SphereRaycastStrategy() }
        };

    public RaycastStrategyType StrategyType;


    public IRaycastStrategy RaycastStrategy
    {
        get
        {
            IRaycastStrategy raycastStrategy;
            if (!_raycastStrategies.TryGetValue(StrategyType, out raycastStrategy))
            {
                throw new InvalidOperationException("There is no registered implementation for the selected strategy");
            }
            return raycastStrategy;
        }
    }
}

Si les implémentations sont susceptibles de référencer les fonctions Unity dans leurs constructions (ou simplement pour être du bon côté, j'ai juste oublié cela lors de la saisie de cet exemple) Awake pour éviter les problèmes avec l'appel des constructeurs ou le référencement des fonctions Unity dans l'éditeur ou en dehors du principal fil

Cette stratégie présente certains inconvénients, en particulier lorsque vous devez gérer de nombreuses implémentations différentes qui évoluent rapidement. Les problèmes liés au manque de vérification au moment de la compilation peuvent être résolus en utilisant un éditeur personnalisé, car la valeur d'énumération peut être sérialisée sans aucun travail spécial (bien que j'y renonce généralement, car mes implémentations n'ont pas tendance à être aussi nombreuses).

J'utilise cette stratégie en raison de la flexibilité qu'elle vous offre en étant basée sur MonoBehaviors sans vous forcer à mettre en œuvre les interfaces à l'aide de MonoBehaviors. Cela vous donne "le meilleur des deux mondes", car le sélecteur est accessible à l'aide de GetComponent, la sérialisation est entièrement gérée par Unity et le sélecteur peut appliquer une logique au moment de l'exécution pour changer automatiquement d'implémentation en fonction de certains facteurs.

Selali Adobor
la source