Press "Enter" to skip to content

16.2: Refaktoryzacja funkcji ruchu gracza

Cele lekcji

Po zakończeniu tej lekcji…

  • Dowiesz się, jak podzielić większą funkcję na kilka mniejszych – prostszych w obsłudze.
  • Będziesz potrafić przenieść funkcje z interfejsu użytkownika do „klas biznesowych”.

 

W poprzedniej lekcji stworzyliśmy ogromną funkcję odpowiadającą za przechodzenie gracza do nowej lokacji. Jednak nie była ona tylko duża, ona była ZA duża, więc jej obsługa była bardzo trudna.

Teraz skrócimy ją i poprzenosimy jej elementy, obniżając jej poziom komplikacji. Takie operacje są nazywane „refaktoryzacją”.

Refaktoryzacja to niezwykle obszerny temat obejmujący wiele technik. W naszej lekcji skupimy się na kilku popularnych, prostych metodach zapewniających największe korzyści. W miarę nauki programowania poznacie z pewnością kolejne techniki refaktoryzacji.

 

Tworzenie funkcji obsługujących dane wprowadzane przez użytkownika

 

Etap 1: Uruchom aplikację Visual Studio i otwórz swoje rozwiązanie.

 

Etap 2: Kliknij prawym przyciskiem myszy formularz SuperAdventure.cs w projekcie SuperAdventure, a następnie wybierz pozycję View Code.

 

Etap 3: Kliknij dwukrotnie klasę Player w projekcie Engine i zastąp ją kodem z klasy Player (nie aktualizuj jeszcze kodu SuperAdventure): https://gist.github.com/ScottLilly/6670da749c4ad7e7bff7

 

Etapy refaktoryzacji

Refaktoryzacja polega tylko na przenoszeniu kodu, co w rezultacie pozwala na wydajniejsze korzystanie z niego. Nie będziemy dodawać żadnych nowych funkcji, naprawiać błędów ani poprawiać wydajności.

Podczas refaktoryzacji można wykonać wiele operacji, ale my skoncentrujemy się na kilku najbardziej popularnych technikach.

 

Etap 1: Wyszukiwanie powielonego kodu.

Jeśli ten sam kod znajduje się w wielu miejscach, to taki kod zazwyczaj można przenieść do własnej funkcji. Następnie, w miejscach, które korzystały z tego kodu, należy wprowadzić zmiany, powodujące odwoływanie się do tej nowej funkcji.

Jest to szczególnie ważne w kontekście wprowadzania zmian w przyszłości.

Kiedy tworzyliśmy funkcję MoveTo(), mogliśmy wstawić cały ten kod do czterech funkcji, które powodowały przemieszczanie gracza. Jeśli jednak w przyszłości postanowimy zmienić sposób działania logiki ruchu, to będziemy musieli wprowadzić zmiany w czterech miejscach w kodzie. Może się więc okazać, że nieuważny programista wprowadzi zmiany tylko w trzech miejscach, zapominając o czwartym. Wtedy nagle postać zacznie zachowywać się dziwacznie, gdy będzie się poruszać w jednym kierunku – tym, w przypadku którego, nie wprowadzono zmiany.

W tej funkcji nie mamy powielonego kodu. W kilku miejscach kod jest bardzo podobny do siebie, ale nie jest identyczny, więc musimy wymyślić inną metodę refaktoryzacji tej funkcji.

 

Etap 2: Wyszukiwanie kodu mającego jeden cel i przeniesienie go do własnej funkcji.

W naszej ogromnej funkcji znajduje się mnóstwo kodu podpadającego pod ten przypadek.

Na przykład wiersze od 56 do 78 sprawdzają, czy istnieje przedmiot wymagany w danej lokalizacji, a jeśli tak, czy gracz ma go w swoim ekwipunku. Tę operację możemy przenieść do osobnej, mniejszej funkcji.

Przenosząc tę sekcję kodu, warto się zastanowić, czy można ją umieścić w lepszym miejscu, np. w innej klasie, ponieważ teraz znajduje się ona w sekcji kodu należącej do interfejsu użytkownika. Ten kod jest powiązany z ekwipunkiem gracza, więc uzasadnione jest przeniesienie do klasy Player.

Spójrzmy teraz na wiersze 28-48 nowego kodu klasy Player, które właśnie dodaliśmy.

public bool HasRequiredItemToEnterThisLocation(Location location)
{
    if(location.ItemRequiredToEnter == null)
    {
        // There is no required item for this location, so return "true"
        return true;
    }
    // See if the player has the required item in their inventory
    foreach(InventoryItem ii in Inventory)
    {
        if(ii.Details.ID == location.ItemRequiredToEnter.ID)
        {
            // We found the required item, so return "true"
            return true;
        }
    }
    // We didn't find the required item in their inventory, so return "false"
    return false;
}

 

W tej funkcji podajemy lokację i sprawdzamy, czy gracz może się tam przenieść – czyli weryfikujemy, że w lokacji nie jest wymagany żaden przedmiot albo że gracz ma wymagany przedmiot.

Nowa funkcja HasRequiredItemToMoveToThisLocation() składa się z 20 wierszy, wykonuje tylko jedną operację i jest na tyle mała, że łatwo można ją zrozumieć. Jeśli kiedykolwiek będziemy chcieli zmienić tę logikę, to modyfikacje będą wymagane tylko w tym jednym miejscu.

Na przykład, możemy zmienić zasady gry i wprowadzić wymóg minimalnego poziomu niezbędnego do przejścia do niektórych lokacji. Można to zrobić w tej funkcji i nie trzeba się przebijać przez 300 wierszy kody.

Funkcję już mamy w klasie Player, więc możemy oczyścić klasę SuperAdventure. Zastąp kod w wierszach 57-78 na następujący:

//Does the location have any required items
if(!_player.HasRequiredItemToEnterThisLocation(newLocation))
{
    rtbMessages.Text += "You must have a " + newLocation.ItemRequiredToEnter.Name + " to enter this location." + Environment.NewLine;
    return;
}

 

Właśnie 20 wierszy kodu zmieniło się w 6, a nasza funkcja stała się bardziej przejrzysta. Na tym właśnie polega refaktoryzacja.

 

Teraz do klasy Player przeniesiemy kod sprawdzający, czy gracz przyjął zadanie i wykonał je.

Spójrzmy na dwie funkcje w klasie Player w wierszach od 50 do 74:

public bool HasThisQuest(Quest quest)
{
    foreach(PlayerQuest playerQuest in Quests)
    {
        if(playerQuest.Details.ID == quest.ID)
        {
            return true;
        }
    }
    return false;
}
public bool CompletedThisQuest(Quest quest)
{
    foreach(PlayerQuest playerQuest in Quests)
    {
        if(playerQuest.Details.ID == quest.ID)
        {
            return playerQuest.IsCompleted;
        }
    }
    return false;
}

 

W klasie SuperAdventure.cs zastąp wiersze 86 i 87 tymi wywołaniami do obiektu Player i usuń wiersze 88-100.

bool playerAlreadyHasQuest = _player.HasThisQuest(newLocation.QuestAvailableHere);
bool playerAlreadyCompletedQuest = _player.CompletedThisQuest(newLocation.QuestAvailableHere);

 

Teraz przesuniemy kod sprawdzający, czy gracz ma w swoim ekwipunku wszystkie przedmioty wymagane do wykonania zadania.

W klasie Player spójrz na wiersze 76-106:

public bool HasAllQuestCompletionItems(Quest quest)
{
    // See if the player has all the items needed to complete the quest here
    foreach(QuestCompletionItem qci in quest.QuestCompletionItems)
    {
        bool foundItemInPlayersInventory = false;
        // Check each item in the player's inventory, to see if they have it, and enough of it
        foreach(InventoryItem ii in Inventory)
        {
            if(ii.Details.ID == qci.Details.ID) // The player has the item in their inventory
            {
                foundItemInPlayersInventory = true;
                if(ii.Quantity < qci.Quantity) // The player does not have enough of this item to complete the quest
                {
                    return false;
                }
            }
        }
        // The player does not have any of this quest completion item in their inventory
        if(!foundItemInPlayersInventory)
        {
            return false;
        }
    }
    // If we got here, then the player must have all the required items, and enough of them, to complete the quest.
    return true;
}

 

Wróć do klasy SuperAdventure.cs i wiersz 96, zastąp poniższym kodem:

bool playerHasAllItemsToCompleteQuest = _player.HasAllQuestCompletionItems(newLocation.QuestAvailableHere);

 

Następnie usuń wiersze 97-133.

 

Możemy również przenieść kod usuwający przedmioty wymagane do wykonania zadania z ekwipunku gracza do klasy Player.

W klasie Player w wierszach 108-122 mamy nową funkcję:

public void RemoveQuestCompletionItems(Quest quest)
{
    foreach(QuestCompletionItem qci in quest.QuestCompletionItems)
    {
        foreach(InventoryItem ii in Inventory)
        {
            if(ii.Details.ID == qci.Details.ID)
            {
                // Subtract the quantity from the player's inventory that was needed to complete the quest
                ii.Quantity -= qci.Quantity;
                break;
            }
        }
    }
}

 

Teraz wróć do klasy SuperAdventure.cs i usuń wiersze 106-117 i w ich miejsce wprowadź ten kod:

_player.RemoveQuestCompletionItems(newLocation.QuestAvailableHere);

 

Możesz również do klasy Player przenieść kod dodający nagrodę do ekwipunku gracza.

W klasie Player.cs spójrz na wiersze 124-139:

public void AddItemToInventory(Item itemToAdd)
{
    foreach(InventoryItem ii in Inventory)
    {
        if(ii.Details.ID == itemToAdd.ID)
        {
            // They have the item in their inventory, so increase the quantity by one
            ii.Quantity++;
            return; // We added the item, and are done, so get out of this function
        }
    }
    // They didn't have the item, so add it to their inventory, with a quantity of 1
    Inventory.Add(new InventoryItem(itemToAdd, 1));
}

 

W klasie SuperAdventure.cs wiersze 119-138 zastąp następującym kodem:

_player.AddItemToInventory(newLocation.QuestAvailableHere.RewardItem);

 

W klasie Player.cs spójrz na funkcję w wierszach 141-154:

public void MarkQuestCompleted(Quest quest)
{
    // Find the quest in the player's quest list
    foreach(PlayerQuest pq in Quests)
    {
        if(pq.Details.ID == quest.ID)
        {
            // Mark it as completed
            pq.IsCompleted = true;
            return; // We found the quest, and marked it complete, so get out of this function
        }
    }
}

 

Wróć do klasy SuperAdventure.cs. Wiersze 122-132 zastąp kodem:

_player.MarkQuestCompleted(newLocation.QuestAvailableHere);

 

W pozostałej części funkcji znajduje się kod aktualizujący formanty ComboBox i DataGridView, ponieważ ekwipunek gracza mógł ulec zmianie z powodu wykonania zadania.

W klasie SuperAdventure.cs utwórz następujące nowe funkcje:

private void UpdateInventoryListInUI()
{
    dgvInventory.RowHeadersVisible = false;
    dgvInventory.ColumnCount = 2;
    dgvInventory.Columns[0].Name = "Name";
    dgvInventory.Columns[0].Width = 197;
    dgvInventory.Columns[1].Name = "Quantity";
    dgvInventory.Rows.Clear();
    foreach(InventoryItem inventoryItem in _player.Inventory)
    {
        if(inventoryItem.Quantity > 0)
        {
            dgvInventory.Rows.Add(new[] { inventoryItem.Details.Name, inventoryItem.Quantity.ToString() });
        }
    }
}
private void UpdateQuestListInUI()
{
    dgvQuests.RowHeadersVisible = false;
    dgvQuests.ColumnCount = 2;
    dgvQuests.Columns[0].Name = "Name";
    dgvQuests.Columns[0].Width = 197;
    dgvQuests.Columns[1].Name = "Done?";
    dgvQuests.Rows.Clear();
    foreach(PlayerQuest playerQuest in _player.Quests)
    {
        dgvQuests.Rows.Add(new[] { playerQuest.Details.Name, playerQuest.IsCompleted.ToString() });
    }
}
private void UpdateWeaponListInUI()
{
    List<Weapon> weapons = new List<Weapon>();
    foreach(InventoryItem inventoryItem in _player.Inventory)
    {
        if(inventoryItem.Details is Weapon)
        {
            if(inventoryItem.Quantity > 0)
            {
                weapons.Add((Weapon)inventoryItem.Details);
            }
        }
    }
    if(weapons.Count == 0)
    {
        // The player doesn't have any weapons, so hide the weapon combobox and "Use" button
        cboWeapons.Visible = false;
        btnUseWeapon.Visible = false;
    }
    else
    {
        cboWeapons.DataSource = weapons;
        cboWeapons.DisplayMember = "Name";
        cboWeapons.ValueMember = "ID";
        cboWeapons.SelectedIndex = 0;
    }
}
private void UpdatePotionListInUI()
{
    List<HealingPotion> healingPotions = new List<HealingPotion>();
    foreach(InventoryItem inventoryItem in _player.Inventory)
    {
        if(inventoryItem.Details is HealingPotion)
        {
            if(inventoryItem.Quantity > 0)
            {
                healingPotions.Add((HealingPotion)inventoryItem.Details);
            }
        }
    }
    if(healingPotions.Count == 0)
    {
        // The player doesn't have any potions, so hide the potion combobox and "Use" button
        cboPotions.Visible = false;
        btnUsePotion.Visible = false;
    }
    else
    {
        cboPotions.DataSource = healingPotions;
        cboPotions.DisplayMember = "Name";
        cboPotions.ValueMember = "ID";
        cboPotions.SelectedIndex = 0;
    }
}

 

Następnie wiersze 183-272 zastąp kodem:

// Refresh player's inventory list
UpdateInventoryListInUI();
// Refresh player's quest list
UpdateQuestListInUI();
// Refresh player's weapons combobox
UpdateWeaponListInUI();
// Refresh player's potions combobox
UpdatePotionListInUI();

 

Czy funkcja jest teraz prostsza?

Wcześniej funkcja MoveTo() miała ponad 300 wierszy długości, a teraz składa się tylko ze 140.

Nadal jest ona długa, ale teraz znacznie łatwiej się ją czyta i jest prostsza do zrozumienia.

Wykonaliśmy również inną ważną operację – przenieśliśmy sporo kodu „logiki” poza klasę interfejsu użytkownika. Kod stosowany w klasie interfejsu użytkownika powinien odpowiadać tylko za obsługę danych wejściowych przekazywanych przez użytkownika i wyświetlanie danych wyjściowych, więc powinien zawierać jak najmniej kodu obsługującego logikę.

Teraz znaczna część logiki gry znajduje się w projekcie Engine – czyli tam, gdzie powinna.

Tę funkcję moglibyśmy jeszcze bardziej zrefaktoryzować, ale jak na nasze potrzeby operacje, które wykonaliśmy, są wystarczające.

Jeśli te kwestie Cię interesują, zapoznaj się z technologią .Net LINQ. Za jej pomocą można tworzyć jeszcze mniejsze nowe funkcje w klasie Player. Ale LINQ to zupełnie osobna kwestia i nie będę jej wprowadzał w naszym poradniku dla początkujących.

 

Podsumowanie

Refaktoryzacja nie zmienia działania programu, tylko oczyszcza istniejący kod, ułatwiając jego zrozumienie i obsługę.

Często operacje te polegają na wyszukiwaniu fragmentów funkcji, które można przenieść do własnych funkcji. Następnie pierwotna funkcja wywołuje te nowe, mniejsze funkcje, które łatwiej zrozumieć programiście, ponieważ nie są one „ukryte” w olbrzymich funkcjach. Natomiast duże funkcje łatwiej się czyta, ponieważ są one „odchudzone” i zawierają mniej kodu.

UWAGA: W kodzie wprowadziłem wiele zmian. Jeśli nie masz pewności, czy wszystko wpisujesz poprawnie, kod z poniższego łącza możesz wkleić do klasy SuperAdventure.cs.

 

Łącza do tej lekcji

Kod źródłowy w serwisie GitHub

Kod źródłowy w serwisie Dropbox

    Leave a Reply

    Your email address will not be published. Required fields are marked *