Créer un jeu

Au Nouvel An 2023, je devais trouver quelque chose Ă  faire. Je voulais crĂ©er quelque chose qui m’aiderait Ă  amĂ©liorer mes compĂ©tences en programmation tout en restant amusant. AprĂšs le carillon de minuit, le lendemain, j’ai Ă©tabli quelques plans.

En rĂ©flĂ©chissant Ă  quelques idĂ©es, j’avais une bibliothĂšque Python basĂ©e sur les 5W1H, une application photo de type mirrorless, et un jeu mobile 2D. Chacune dĂ©coulait d’un programme Python, d’une simple application Android Studio, ou de mon prĂ©cĂ©dent projet Unity.

Mais le dĂ©veloppement de jeu avait l’air trop amusant. L’expĂ©rience passĂ©e avec Unity m’avait marquĂ©, et l’idĂ©e de pouvoir utiliser mes propres assets maison me semblait passionnante. MĂȘme si c’était difficile, le thĂšme de crĂ©er un programme avec mon propre matĂ©riau qu’on ne trouve nulle part ailleurs me semblait trĂšs attrayant. Comme je commençais Ă  apprĂ©cier l’orientĂ© objet, j’ai voulu utiliser un langage orientĂ© objet correctement, et j’ai donc commencĂ© Ă  crĂ©er un jeu mobile 2D.

Aperçu du projet

gantt
    title Cubic Survival — Temps investi
    Développement initial: crit, 2023-02-07, 60d
    Développement avancé: a1, 2023-06-19, 150d
    Publication test sur le store: crit, milestone, 2023-03-28, 0d
    Republication sur le store: a3, 2023-10-29, 18d

La période de développement étant divisée en phases initiale et avancée, je vais les passer en revue briÚvement dans deux articles distincts. Ce post couvre donc le développement initial, surligné en rouge dans le diagramme ci-dessus.

Au dĂ©part, je voulais simplement survoler rapidement les caractĂ©ristiques de l’orientĂ© objet, sans savoir que je m’attacherais autant Ă  ce projet. Il n’y avait donc pas de plan ou d’objectif systĂ©matique ; tout au plus avais-je quelques souhaits assez abstraits comme ceux-ci.

  • CrĂ©er un design visuellement minimaliste.
  • ImplĂ©menter un mouvement de camĂ©ra fluide.
  • Appliquer efficacement une conception orientĂ©e objet.
  • Utiliser des coroutines.

La pĂ©riode de dĂ©veloppement Ă©tant longue, ces objectifs ont Ă©tĂ© atteints les uns aprĂšs les autres. Les dĂ©tails sur comment et oĂč chacun a Ă©tĂ© rĂ©alisĂ© Ă©tant longs Ă  expliquer, je les aborderai en dĂ©tail dans cet article et le suivant.

Développement initial

beta-play
Au dĂ©but, je pensais qu’un Ă©vĂ©nement devrait se produire aprĂšs avoir Ă©liminĂ© quelques ennemis

J’ai commencĂ© par du clone coding. L’idĂ©e Ă©tait d’abord d’essayer de reproduire un jeu cĂ©lĂšbre que je pourrais imiter Ă  petite Ă©chelle.

J’ai d’abord consultĂ© Brawl Stars, dont je gardais un bon souvenir d’y avoir jouĂ© avec des amis au lycĂ©e. Cependant, plutĂŽt que de copier le systĂšme du jeu, il s’agissait surtout de comprendre ce qu’est une plateforme mobile 2D.

Joystick

joystick

Je voulais implémenter un joystick typique des jeux mobiles 2D : un à gauche pour le déplacement du joueur, un à droite pour viser.

Pour le crĂ©er, j’ai utilisĂ© la classe OnScreenStick du package UnityEngine.InputSystem.OnScreen, en crĂ©ant deux scripts basĂ©s sur cette classe qui Translate() le joueur et un objet transparent de visĂ©e selon le dĂ©phasage. Comme il y avait peu de ressources corĂ©ennes sur le package OnScreen, j’ai beaucoup consultĂ© la documentation officielle.

En passant, j’avais beaucoup d’idĂ©es que j’aurais aimĂ© implĂ©menter : relier visuellement le stick et le point central via un LineRenderer, un retour Ă©lastique du stick vers le centre, ou des contrĂŽles de joystick diffĂ©rents selon les armes. Mais mes compĂ©tences Ă©taient insuffisantes Ă  l’époque, et certaines idĂ©es entraient en conflit avec la structure du jeu. Je n’ai donc implĂ©mentĂ© qu’un retour vibratoire Ă  chaque pression et relĂąchement du joystick.

Apparition et comportement des ennemis

enemycoming

void spawnEnemy(GameObject Enemy, float east, float west, float south, float north)
{
    float spawnPointX = Random.Range(west, east);
    float spawnPointY = Random.Range(south, north);

    instantiatedEnemy = Instantiate(
        enemy,
        player.transform.position + new Vector3(spawnPointX, spawnPointY),
        transform.rotation
    );
}

IEnumerator spawnEnemies()
{
    for (int i = 0; i < data.spawnCount; i++)
    {
        spawnEnemy(Enemy, east, west, south, north);
        yield return new WaitForSeconds(spawnDelay);
    }
}

Au dĂ©but, j’avais créé un objet portail pour instancier les ennemis Ă  des points dĂ©finis, mais le rĂ©sultat aprĂšs apparition me semblait trop monotone. J’ai donc Ă©crit le code ci-dessus pour que les ennemis apparaissent autour du joueur.

Les quatre paramĂštres east, west, south, north gĂ©nĂšrent des coordonnĂ©es alĂ©atoires Ă  une certaine distance du joueur. Pour que les ennemis n’apparaissent pas soudainement autour du joueur, ces coordonnĂ©es sont spĂ©cialement traitĂ©es pour ĂȘtre en dehors de la zone rendue par l’écran.

Dans Unity, il n’existe pas de mĂ©thode simple comme Delay() pour introduire un dĂ©lai ; la plupart des recommandations suggĂšrent d’utiliser des coroutines, ce qui a Ă©tĂ© ma premiĂšre occasion d’en utiliser une. J’ai créé une coroutine qui spawn les ennemis Ă  intervalles de spawnDelay.

void Move()
{
    dirTowardsPlayer = (player.transform.position - gameObject.transform.position).normalized;
    transform.Translate(dirTowardsPlayer * speed * Time.deltaTime);
}

void OnCollisionEnter2D(Collision2D collider)
{
    if (collider.gameObject.tag == "player")
    {
        player.hp -= damage;

        Vibration.Vibrate((long)20);
        Destroy(gameObject);
    }
}

Les ennemis se dĂ©placent vers le joueur par dĂ©faut, et lors d’une collision avec le joueur, ils rĂ©duisent ses PV de damage avec un retour vibratoire, puis sont dĂ©truits via Destroy().

Inventaire et objets

Alors que la structure du jeu prenait forme, j’ai pensĂ© qu’un inventaire pour stocker des objets et les utiliser plus tard serait utile. C’est une question sur laquelle j’ai beaucoup rĂ©flĂ©chi : dans de nombreux jeux, l’interface d’inventaire se prĂ©sente soit comme une fenĂȘtre sĂ©parĂ©e, soit comme un bouton Ă  bascule sans vraie interface. Les deux ne me satisfaisaient pas.

J’ai plutĂŽt visĂ© un inventaire capable de contenir plusieurs objets sans nuire Ă  l’expĂ©rience de jeu. J’ai donc remplacĂ© la visĂ©e manuelle assignĂ©e au joystick droit par un auto-aim, et j’y ai assignĂ© une nouvelle fonction d’accĂšs Ă  l’inventaire. Maintenir le joystick droit enfoncĂ© ouvre l’inventaire ; relĂącher le doigt le ferme.

inventory

public struct InventoryData
{
    public string[]     Code;
    public GameObject[] UI;
    public GameObject[] ItemUI;
    public GameObject   Weapon;
    public int[]        Rounds;
}

for (int i = 0; i < InventoryData.InventoryUI.Length; i++)
    InventoryData.UI[i].transform.position = Vector3.Lerp(currentPos, targetPos[i], 2*t);

L’inventaire utilise 8 objets qui se dĂ©ploient autour du joueur lors de l’accĂšs. Pour centraliser les donnĂ©es nĂ©cessaires (identifiants d’objets, objets d’inventaire, objets d’items, donnĂ©es d’armes, munitions, etc.), j’ai créé la structure ci-dessus.

Les objets sont divisĂ©s en deux : un objet destinĂ© Ă  apparaĂźtre sur le terrain et un autre servant d’interface UI. Lorsque le joueur ramasse un objet sur le terrain, l’objet UI correspondant est ajoutĂ© au tableau ItemUI.

ObjetID
PistoletWPPSTL
Fusil Ă  pompeWPPASG
MinigunWPMING
Passif de vitesse de déplacementPVMSPD
Passif de vitesse d’attaquePVATKR




Les identifiants d’objets sont composĂ©s de 2 caractĂšres indiquant le type suivis de 4 caractĂšres pour le nom de l’objet. Ce qui Ă©tait amusant, c’est que sur le moment, je ne m’en rendais pas compte, mais Ă  mesure que les objets s’accumulaient, l’idĂ©e de crĂ©er des codes uniques pour les distinguer m’est venue naturellement — ce que j’ai dĂ©couvert plus tard ĂȘtre le concept d‘“identifiant”. C’était assez utile, et je compte continuer Ă  l’utiliser.

Tir d’armes

shooting

if (shotTimer > fireThreshold)
{
    for (int i = 0; i < bulletCount; i++)
    {
        instantBullet = Instantiate(
            bullet,
            FirePosition.transform.position,
            Quaternion.Euler(
                0, 0, transform.rotation.eulerAngles.z + Random.Range(MOA * -1, MOA) + 180
            )
        );
        Destroy(instantBullet, 1);
    }
}

shotTimer += Time.deltaTime;
void hasHitEnemy()
{
    hit = Physics2D.Raycast(transform.position, transform.right, 100);

    if (hit.collider != null && hit.distance < 1)
    {
        if (hit.collider.gameObject.tag == "enemy")
        {
            if (hit.collider.GetComponent<Enemy>().HP > 0)
                Destroy(gameObject);
            /* ... */
        }
    }
}

Les balles sont gĂ©nĂ©rĂ©es depuis l’objet enfant FirePosition de l’arme, avancent en ligne droite dans la direction visĂ©e par le joueur, puis disparaissent aprĂšs 1 seconde. Pour implĂ©menter la dispersion des tirs, la valeur Z de l’angle est lĂ©gĂšrement ajustĂ©e avec Random.Range() dans la limite de la variable MOA dĂ©finie par arme lors de la gĂ©nĂ©ration de la balle.

La dĂ©tection de collision utilisait un raycast. Cependant, la vitesse des balles Ă©tant trop Ă©levĂ©e, la dĂ©tection par raycast ne fonctionnait pas correctement et les balles traversaient les ennemis. Augmenter la longueur du Ray ou Ă©largir la plage du Collider n’a pas rĂ©solu le problĂšme, mais l’ajout de la condition hit.distance < 1 a permis de le rĂ©soudre.

AprĂšs avoir tout implĂ©mentĂ©, j’ai dĂ©couvert qu’il existe une technique d’optimisation appelĂ©e object pooling pour les cas d’instanciation frĂ©quente comme le tir de balles. Je compte l’appliquer plus tard quand j’aurai le temps.

Conception de l’expĂ©rience utilisateur

Alors que la section prĂ©cĂ©dente portait sur “crĂ©er un jeu d’action oĂč l’on se dĂ©place sur le terrain pour Ă©liminer des ennemis”, celle-ci concerne “implĂ©menter une expĂ©rience utilisateur fluide et originale”. La plupart des travaux visuels sĂ©rieux ayant eu lieu en phase de dĂ©veloppement avancĂ©, je les aborderai dans le prochain article.

Caméra

camera

void Move()
{
    transform.position = Vector3.Lerp(
        transform.position,
        player.transform.position,
        Time.deltaTime * moveSpeed
    );
}

void Vignette()
{
    targetVignetteValue = inventoryIsOpen ? 0.35f : 0f;

    vignette.intensity.value  = Mathf.Lerp(
        vignette.intensity.value,
        targetVignetteValue,
        Time.deltaTime * vignetteSpeed
    );
}

Il y a une option appelĂ©e vignettage que j’observe attentivement en retouchant des photos. Cette fonction assombrit les bords de l’écran pour concentrer le regard au centre. Le post-processing d’Unity ayant la mĂȘme option, j’ai pensĂ© qu’il serait parfait de l’appliquer Ă  l’ouverture de l’inventaire.

J’ai donc fait en sorte que la valeur du vignettage passe Ă  environ 0,35 lors de l’accĂšs Ă  l’inventaire. Comme pour le mouvement de la camĂ©ra, le vignettage est gĂ©rĂ© en douceur avec Lerp. Pendant le dĂ©veloppement, trouver les bonnes valeurs pour vignetteSpeed, moveSpeed et autres paramĂštres Ă©tait difficile. Je jouais, ajustais, rejouais, rĂ©ajustais, et quand je n’étais pas satisfait en travaillant sur d’autres parties, je revenais modifier — j’ai cherchĂ© la bonne valeur tout au long du dĂ©veloppement.

URP

urp
Chaque fois qu’une arme tire, une ombre se projette derriùre l’ennemi.

Au dĂ©but, j’utilisais tant bien que mal les effets de lumiĂšre de base d’Unity 2D, mais il y avait des insatisfactions Ă  plusieurs endroits. En appliquant URP (Universal Render Pipeline) comme alternative, le rendu visuel s’est nettement amĂ©liorĂ©. Il offre des effets lumineux agrĂ©ables et doux par dĂ©faut, tout en permettant de rĂ©gler l’option Falloff Strength pour crĂ©er une lumiĂšre plus subtile ou Ă©clatante, ou d’utiliser l’option Shadows pour des effets de lumiĂšre et d’ombre comme ci-dessus — vraiment utile.

Cependant, en ajoutant Light2D Ă  chaque balle ou ennemi, le tĂ©lĂ©phone chauffait rapidement. Cela semblait consommer pas mal de ressources GPU, donc je n’ai pas pu l’utiliser activement, ne le gardant que pour enrichir l’effet de flamme lors des tirs.

Conclusion

J’ai briĂšvement rĂ©sumĂ© les activitĂ©s de dĂ©veloppement initial. En Ă©crivant, je rĂ©alise que je ne me souviens pas trĂšs bien de ce que j’ai ressenti pendant cette pĂ©riode. N’ayant pas pu capturer toutes mes pensĂ©es et mes efforts, j’essaierai de prendre des notes plus souvent au cours du dĂ©veloppement.

MalgrĂ© tout, en implĂ©mentant diverses choses moi-mĂȘme, j’ai dĂ©couvert que crĂ©er un jeu est bien plus complexe que je ne le pensais. En particulier, les autres jeux qui ne suivent pas les tendances populaires mais cherchent de nouveaux paradigmes, et qui les implĂ©mentent avec succĂšs, sont vraiment impressionnants. Et personnellement, en tant que quelqu’un qui trouve cela admirable, cela m’a un peu donnĂ© envie.