Jak nie tworzyć gier w Unity 3D

11

Niedawno zakończył się konkurs #ROG4Creators ( https://rog4creators.pl/code ).

Uczestnicy konkursu mieli do dyspozycji wstępną wersję projektu, który trzeba jak najlepiej rozwinąć.
Tak aby gra była grywalna, dodać kilka rzeczy, ulepszyć istniejące itd.

Chciałbym w tym poscie skomentować właśnie stan tego projektu startowego, bo autorzy albo go przygotowywali w dużym pośpiechu, albo nie są legendami za jakie się uważają :]

Sprite

1. Brak pivotów.

Wszelkie animacje postaci w klatkach kluczowych zmieniają pozycję i obrót obrazka względem standardowego pivotu centralnego (0.5, 0.5).
Powinno się ustawić odpowiedni pivot i zmieniać sam kąt, żeby nie utrudniać sobie roboty.

screenshot-20211225014136.png

2. Różne skale obiektów.

Np. gracz ma skalę:

screenshot-20211225014822.png

Chodzący robot ma skalę:

screenshot-20211225015145.png

Nie jest to może ciężki grzech. Ale ja bym raczej starał się zawsze mieć skalę (1, 1, 1).
Wielkość spriteów można regulować w opcjach importera:
screenshot-20211225020230.png

3. Rozmiar tekstur.

  • są wielgachne dla takich małych postaci (nie cierpię kiedy graficy przygotowują dla programisty assety, nadające się tylko do dalszej obróbki...),
  • NPOT (wymiary to nie są potęgi liczby 2),
  • brak atlasu.

Kod

1. Bullet.cs.

    private IEnumerator SpawnImpactEffect()
    {
        var impactGameObject = Instantiate(impactEffect, transform.position, transform.rotation);

        yield return new WaitForSeconds(1f);
        if (impactGameObject != null)
        {
            Destroy(impactGameObject);
        }

        Destroy(gameObject);
    }
  • Kula po uderzeniu leci dalej jeszcze przez 1s i uderza kolejne przeszkody, zanim zostanie zniszczona,
  • Metoda Destroy może przyjąć drugi parametr, który określa po jakim czasie ma nastąpić zniszczenie obiektu, więc metoda SpawnImpactEffect() nawet nie musi być Coroutine.
    private void SpawnImpactEffect()
    {
        Destroy(Instantiate(
            impactEffect,
            transform.position,
            transform.rotation), 1.0f);
        
        Destroy(gameObject);
    }

2. Serializowane pola.

Żebyśmy mogli przeciągnąć inne obiekty na pola klasy w Inspektorze Unity, muszą być one albo publiczne, albo prywatne z atrybutem SerializeField.
Jeśli wybierzemy opcję numer dwa, to wypadałoby pozbyć się warningu o nigdy nie inicjowanym polu.
Czyli zamiast:

    [SerializeField]
    private GameObject deathEffect;

piszemy:

    [SerializeField]
    private GameObject deathEffect = null;

3. Szukanie obiektów.

Takie coś jest nieprofesjonalne:

waveManager = FindObjectOfType<WaveManager>();

Lepiej zrobić jeden singleton, który nam trzyma referencje do menadżerów itp.
A tam gdzie się da (obiekty nie tworzone podczas rozgrywki), deklarować serializowane pole, jak wyżej.

4. Enemy.cs.

    private void OnCollisionEnter2D(Collision2D other)
    {
        if ( other.transform.GetComponent<CharacterController2D>() )
        {
            waveManager.PlayerDead();
        }
    }

Trzeba wprowadzić do projektu różne warstwy i ustawić macierz kolizji.

screenshot-20211225023425.png

Nie trzeba wtedy sprawdzić czy wróg, który w coś dobił, uderzył akurat w gracza.

5. Pole dla Prefaba.

Prefab nie musi być deklarowany jako GameObject.
Tutaj autor poradnika tworzy nowy obiekt z prefaba metodą Instantiate().
A potem wyciąga z tego obiektu komponent Bullet.

Gdyby prefab był od początku zadeklarowany jako Bullet (a nie GameObject), to wywołanie Instantiate() od razu dałoby nam referencję do komponentu Bullet utworzonego obiektu. Nie trzeba by było wywoływać metody GetComponent<Bullet>().

Tak poza tym, ja ten problem też załatwiłbym macierzą kolizji. Jeśli chciałbym, żeby bronie gracza strzelały tymi samymi pociskami, co bronie wrogów, to moje skrypty ustawiałyby tylko warstwy obiektów pocisków, w momencie wystrzału.

6. Uruchamianie Coroutine.

W kodzie trafiłem na wyjątkowo brzydki sposób uruchamiania Coroutine.

waveCoroutine = StartCoroutine(nameof(WaveController));

Skoro wiemy jaka funkcja będzie wykonywana, to nie przekazujmy jej nazwy, tylko jej wywołanie.

waveCoroutine = StartCoroutine(WaveController());

Jak na razie THE END ;)

2

az odpowiem :P

Spine napisał(a):

Niedawno zakończył się konkurs #ROG4Creators ( https://rog4creators.pl/code ).

Sprite

1. Brak pivotów.

Wszelkie animacje postaci w klatkach kluczowych zmieniają pozycję i obrót obrazka względem standardowego pivotu centralnego (0.5, 0.5).
Powinno się ustawić odpowiedni pivot i zmieniać sam kąt, żeby nie utrudniać sobie roboty.

screenshot-20211225014136.png

niechlujne, zgadzam sie.

2. Różne skale obiektów.

Np. gracz ma skalę:

screenshot-20211225014822.png

Chodzący robot ma skalę:

screenshot-20211225015145.png

Nie jest to może ciężki grzech. Ale ja bym raczej starał się zawsze mieć skalę (1, 1, 1).
Wielkość spriteów można regulować w opcjach importera:
screenshot-20211225020230.png

detal, ale moze w okreslonych przypadkach byc problemem - najmniejszy blad.

3. Rozmiar tekstur.

  • są wielgachne dla takich małych postaci (nie cierpię kiedy graficy przygotowują dla programisty assety, nadające się tylko do dalszej obróbki...),
  • NPOT (wymiary to nie są potęgi liczby 2),
  • brak atlasu.

sama wielkosc tekstur nie jest bledem, bo przeciez mozna je zmniejszyc na imporcie, wiec w czym problem. Oczywiscie potegi 2 to podstawa.

Kod

1. Bullet.cs.

    private IEnumerator SpawnImpactEffect()
    {
        var impactGameObject = Instantiate(impactEffect, transform.position, transform.rotation);

        yield return new WaitForSeconds(1f);
        if (impactGameObject != null)
        {
            Destroy(impactGameObject);
        }

        Destroy(gameObject);
    }

oczywiscie, wykorzystywanie coroutin do samego czekania jest nieoptymalne.

async await jest najszybsze z tego, co sprawdzalem. musialbym sprawdzic, czy drugi parametr destroy nie jest oparty o invoke i nie orze refleksja, co i tak jest lepsze niz zasmiecanie pamieci przez tworzenie coroutiny.

Co najwazniejsze, tworzenie GO, na pocisk, a nastepne usmiercanie go to no go zone.
Tutaj lepiej zastosowac object pooling.

  • Kula po uderzeniu leci dalej jeszcze przez 1s i uderza kolejne przeszkody, zanim zostanie zniszczona,
  • Metoda Destroy może przyjąć drugi parametr, który określa po jakim czasie ma nastąpić zniszczenie obiektu, więc metoda SpawnImpactEffect() nawet nie musi być Coroutine.
    private void SpawnImpactEffect()
    {
        Destroy(Instantiate(
            impactEffect,
            transform.position,
            transform.rotation), 1.0f);
        
        Destroy(gameObject);
    }

2. Serializowane pola.

Żebyśmy mogli przeciągnąć inne obiekty na pola klasy w Inspektorze Unity, muszą być one albo publiczne, albo prywatne z atrybutem SerializeField.
Jeśli wybierzemy opcję numer dwa, to wypadałoby pozbyć się warningu o nigdy nie inicjowanym polu.
Czyli zamiast:

    [SerializeField]
    private GameObject deathEffect;

piszemy:

    [SerializeField]
    private GameObject deathEffect = null;

no takiego detalu bym sie nie czepial, szczegolnie, ze w wiekszosci przypadkow i tak, gdzies bedzie przypisanie wartosci, a jak nie, to moze warto rozwazyc consty lub gettery.

3. Szukanie obiektów.

Takie coś jest nieprofesjonalne:

waveManager = FindObjectOfType<WaveManager>();

Lepiej zrobić jeden singleton, który nam trzyma referencje do menadżerów itp.
A tam gdzie się da (obiekty nie tworzone podczas rozgrywki), deklarować serializowane pole, jak wyżej.

szukamie to dramat, zgadza sie. Singletona nie uznalbym tutaj za najlepszy wzorzec. Wolalbym statyczna klase observera, do ktorego subscrybowallyby sie inne klasy/

4. Enemy.cs.

    private void OnCollisionEnter2D(Collision2D other)
    {
        if ( other.transform.GetComponent<CharacterController2D>() )
        {
            waveManager.PlayerDead();
        }
    }

Trzeba wprowadzić do projektu różne warstwy i ustawić macierz kolizji.

screenshot-20211225023425.png

Nie trzeba wtedy sprawdzić czy wróg, który w coś dobił, uderzył akurat w gracza.

5. Pole dla Prefaba.

Prefab nie musi być deklarowany jako GameObject.
Tutaj autor poradnika tworzy nowy obiekt z prefaba metodą Instantiate().
A potem wyciąga z tego obiektu komponent Bullet.

Gdyby prefab był od początku zadeklarowany jako Bullet (a nie GameObject), to wywołanie Instantiate() od razu dałoby nam referencję do komponentu Bullet utworzonego obiektu. Nie trzeba by było wywoływać metody GetComponent<Bullet>().

to z tym getcomponent to tylko roznica w skladni. Dla dzialania, nie ma to znaczenia. Wyglada na jakies nieistotne przeoczenie. Moze do czegos chcieli to GO, a potem zmienili zdanie.

Troche taka powierzchowna analiza kodu. Nie znalazly sie zadne bledy architektoniczne? polamane solid? Ja juz nawet widze, ze metody lamia srp.

Tak poza tym, ja ten problem też załatwiłbym macierzą kolizji. Jeśli chciałbym, żeby bronie gracza strzelały tymi samymi pociskami, co bronie wrogów, to moje skrypty ustawiałyby tylko warstwy obiektów pocisków, w momencie wystrzału.

6. Uruchamianie Coroutine.

W kodzie trafiłem na wyjątkowo brzydki sposób uruchamiania Coroutine.

waveCoroutine = StartCoroutine(nameof(WaveController));

Skoro wiemy jaka funkcja będzie wykonywana, to nie przekazujmy jej nazwy, tylko jej wywołanie.

waveCoroutine = StartCoroutine(WaveController());

Jak na razie THE END ;)

1
renderme napisał(a):

3. Rozmiar tekstur.

  • są wielgachne dla takich małych postaci (nie cierpię kiedy graficy przygotowują dla programisty assety, nadające się tylko do dalszej obróbki...),
  • NPOT (wymiary to nie są potęgi liczby 2),
  • brak atlasu.

sama wielkosc tekstur nie jest bledem, bo przeciez mozna je zmniejszyc na imporcie, wiec w czym problem.

Duże tekstury zajmują miejsce w repozytorium i wydłużają pierwsze uruchomienie projektu - kiedy Unity wszystko przetwarza do folderu Library.

renderme napisał(a):

2. Serializowane pola.

Żebyśmy mogli przeciągnąć inne obiekty na pola klasy w Inspektorze Unity, muszą być one albo publiczne, albo prywatne z atrybutem SerializeField.
Jeśli wybierzemy opcję numer dwa, to wypadałoby pozbyć się warningu o nigdy nie inicjowanym polu.
Czyli zamiast:

    [SerializeField]
    private GameObject deathEffect;

piszemy:

    [SerializeField]
    private GameObject deathEffect = null;

no takiego detalu bym sie nie czepial, szczegolnie, ze w wiekszosci przypadkow i tak, gdzies bedzie przypisanie wartosci, a jak nie, to moze warto rozwazyc consty lub gettery.

Przypisanie wartości będzie, jeśli użytkownik Unity w edytorze przeciągnie na to pole jakiś obiekt.
Ale jeśli w naszym kodzie nie przypiszemy do takich pól choćby nulla, to konsola Unity i tak sypie warningami.

renderme napisał(a):

5. Pole dla Prefaba.

Prefab nie musi być deklarowany jako GameObject.
Tutaj autor poradnika tworzy nowy obiekt z prefaba metodą Instantiate().
A potem wyciąga z tego obiektu komponent Bullet.

Gdyby prefab był od początku zadeklarowany jako Bullet (a nie GameObject), to wywołanie Instantiate() od razu dałoby nam referencję do komponentu Bullet utworzonego obiektu. Nie trzeba by było wywoływać metody GetComponent<Bullet>().

to z tym getcomponent to tylko roznica w skladni. Dla dzialania, nie ma to znaczenia. Wyglada na jakies nieistotne przeoczenie. Moze do czegos chcieli to GO, a potem zmienili zdanie.

Po prostu robiąc po mojemu, mniej motamy kod i wiemy dokładnie jakiego obiektu nasza klasa oczekuje.

renderme napisał(a):

Troche taka powierzchowna analiza kodu. Nie znalazly sie zadne bledy architektoniczne? polamane solid? Ja juz nawet widze, ze metody lamia srp.

No nie wchodziłem w tym opisie na takie tematy.
Jak widać, skupiłem się na czepianiu się bardziej przyziemnych niedociągnięć ;)

1 użytkowników online, w tym zalogowanych: 0, gości: 1