【Unity】資源を消費してもUIが更新されねえ問題を解決した話。イベント処理をぶん殴る

ゲーム制作

こんにちは、とまです。

今回は、Unityで制作している「放置系・終末世界の街づくりゲーム」で、資源管理とUI表示がうまくかみ合ってねえという問題を解決したのでここに書いていきます。

今回の目標はシンプルです。

  • 木材・食料・金属などの資源を管理する
  • 建物を建設すると資源を消費する
  • 資源を消費したら、画面上の資源表示も自動的に減らす

問題としては、

「建物を建てると資源はちゃんと減っているのに、UIの数字だけ減らない」

という問題が発生しました。

最終的には、イベントの購読タイミングが原因だったのですが…

この記事では、その原因をどのように切り分けて解決したのかをまとめます。

今回作った資源システム

今回の資源システムは、主に次の4つのクラスで構成しています。

ResourceData
↓
ResourceManager
↓
ResourceUI
↓
ResourceItemUI

それぞれの役割は次の通りです。

ResourceData

「木材」「食料」「金属」など、資源そのものの情報を持ちます。

ResourceManager

ゲーム内の資源量を管理します。

例えば、

木材 100
食料 50
金属 30

といった現在の所持量を管理する役割です。

ResourceUI

画面に表示する資源一覧を管理します。

ResourceItemUI

「木材 100」「食料 50」のように、個々の資源を表示するUIです。

ResourceManagerで資源を管理する

まず、ゲーム内の資源量を管理するResourceManagerを用意します。

重要なのは、資源を消費するSpend()です。

public bool Spend(ResourceData resource, int amount)
{
    if (!Has(resource, amount))
        return false;

    resources[resource] -= amount;

    NotifyResourceChanged(resource);

    return true;
}

Spend()では、まず必要な資源を持っているか確認します。

if (!Has(resource, amount))
    return false;

所持数が足りていれば、資源を減らします。

resources[resource] -= amount;

そして重要なのが、その直後の

NotifyResourceChanged(resource);

です。

資源が変化したことをイベントで通知する

ResourceManagerには、資源が変更されたことを知らせるイベントを用意しています。

public event Action<ResourceData, int> OnResourceChanged;

そして、資源量が変わったら、

private void NotifyResourceChanged(ResourceData resource)
{
    if (resource == null)
        return;

    int currentAmount = GetAmount(resource);

    OnResourceChanged?.Invoke(resource, currentAmount);
}

を実行します。

これによって、

木材が100 → 70になった

という情報を、ResourceUIなどの別のクラスに通知できます。

この仕組みを使うことで、ResourceManagerから直接UIを操作する必要がなくなります。

建物を建設すると資源を消費する

建物の配置を担当しているのがPlacementManagerです。

建設費を消費する処理は、

private bool SpendConstructionCost(
    BuildingData building)
{
    if (ResourceManager.Instance == null)
        return false;

    foreach (
        BuildingData.ResourceCost cost
        in building.ConstructionCosts)
    {
        if (cost == null || cost.resource == null)
            continue;

        if (!ResourceManager.Instance.Spend(
            cost.resource,
            cost.amount))
        {
            return false;
        }
    }

    return true;
}

となっています。

例えば建物の建設費が、

木材:30
食料:10

だった場合、

木材 100 → 70
食料 10 → 0

となります。

ここまでは正常に動作していました。

ところがUIの数字が減らない

実際に建物を建設してみると、Consoleには、

資源消費:Wood -30 → 残り 70
資源消費:Food -10 → 残り 0

と表示されました。(デバッグログを設定しました。)

つまり、

ResourceManager内部では資源が正しく減っています。

それなのに画面を見ると、

木材 100
食料 10

のまま。
なんでやねん…

もう怒り狂いそう

ここで、

「ResourceManagerの計算がおかしいのでは?」

と考えました。

しかし、ログを見る限り計算は正常です。

たぶんですが、ユニティ君には自我が芽生えており、俺のことを弄んで遊んでいます…

ResourceItemUIを確認する

次に、実際の数字を書き換えているResourceItemUIを確認しました。

資源量を更新する処理は、

public void UpdateAmount(int amount)
{
    if (amountText == null)
        return;

    amountText.text = amount.ToString();
}

となっています。

例えば、

UpdateAmount(70);

が呼ばれれば、

木材 70

と表示されるはずです。

つまり、ここにも大きな問題はありません。

ResourceUIが資源変更を受け取る仕組み

そこで次に確認したのがResourceUIです。

ResourceUIでは、資源が変更されたときに、

private void OnResourceChanged(
    ResourceData resource,
    int amount)
{
    if (resource == null)
        return;

    if (!resourceItems.TryGetValue(
        resource,
        out ResourceItemUI item))
    {
        return;
    }

    item.UpdateAmount(amount);
}

を実行しています。

ここで、

item.UpdateAmount(amount);

が呼ばれれば、最終的に画面の数字が更新されます。

どこで止まっているのか確認する

そこで、OnResourceChanged()にログを追加しました。

private void OnResourceChanged(
    ResourceData resource,
    int amount)
{
    Debug.Log(
        $"ResourceUI受信:{resource.ResourceName} → {amount}"
    );

    if (resource == null)
        return;

    if (!resourceItems.TryGetValue(
        resource,
        out ResourceItemUI item))
    {
        return;
    }

    item.UpdateAmount(amount);
}

そして建物を建設しました。

結果は、先ほどの

資源消費:Wood -30 → 残り 70
資源消費:Food -10 → 残り 0

だけ。

期待していた、

ResourceUI受信:Wood → 70
ResourceUI受信:Food → 0

が表示されませんでした。

つまり、

ここで原因がかなり明確になりました。

ResourceManagerからResourceUIへイベントが届いていません。

原因はイベントの購読タイミングだった

最初のResourceUIでは、イベントの購読をOnEnable()で行っていました。

private void OnEnable()
{
    if (ResourceManager.Instance == null)
        return;

    ResourceManager.Instance.OnResourceChanged += OnResourceChanged;
}

一見すると問題なさそうです。

しかし、OnEnable()が呼ばれた時点で、

ResourceManager.Instance == null

だった場合、

return;

してしまいます。

つまり、

ResourceUI OnEnable
↓
ResourceManagerがまだ存在しない
↓
イベント購読を中止
↓
その後ResourceManagerが生成される
↓
ResourceUIはイベントを購読していない

という状態になっていました。

そのため、ResourceManagerでは正常に資源が減っていても、
ResourceUIには通知されなかったわけです。

分かるかこんなもん

Startでイベントを購読する

そこで、イベントの購読をStart()で行うように変更しました。

private void Start()
{
    if (ResourceManager.Instance != null)
    {
        ResourceManager.Instance.OnResourceChanged += OnResourceChanged;
    }

    CreateResourceItems();
}

そして、破棄されるときにはイベントの購読を解除します。

private void OnDestroy()
{
    if (ResourceManager.Instance != null)
    {
        ResourceManager.Instance.OnResourceChanged -= OnResourceChanged;
    }
}

これで、

Start()
↓
ResourceManagerを確認
↓
イベントを購読
↓
ResourceUIを生成

という流れになります。

修正後のResourceUI

最終的なResourceUIは次のようになりました。

using System.Collections.Generic;
using UnityEngine;

public class ResourceUI : MonoBehaviour
{
    [Header("資源一覧")]
    [SerializeField] private Transform resourceListParent;

    [SerializeField] private ResourceItemUI resourceItemPrefab;


    [Header("表示する資源")]
    [SerializeField] private List<ResourceData> resources = new();


    private readonly Dictionary<ResourceData, ResourceItemUI> resourceItems = new();


    private void Start()
    {
        if (ResourceManager.Instance != null)
        {
            ResourceManager.Instance.OnResourceChanged += OnResourceChanged;
        }

        CreateResourceItems();
    }


    private void OnDestroy()
    {
        if (ResourceManager.Instance != null)
        {
            ResourceManager.Instance.OnResourceChanged -= OnResourceChanged;
        }
    }


    private void CreateResourceItems()
    {
        if (resourceListParent == null ||
            resourceItemPrefab == null)
        {
            return;
        }


        for (int i = resourceListParent.childCount - 1; i >= 0; i--)
        {
            Destroy(resourceListParent.GetChild(i).gameObject);
        }


        resourceItems.Clear();


        foreach (ResourceData resource in resources)
        {
            if (resource == null)
                continue;


            ResourceItemUI item =
                Instantiate(
                    resourceItemPrefab,
                    resourceListParent
                );


            item.Setup(resource);


            resourceItems[resource] = item;
        }


        RefreshAll();
    }


    private void RefreshAll()
    {
        if (ResourceManager.Instance == null)
            return;


        foreach (ResourceData resource in resources)
        {
            if (resource == null)
                continue;


            if (!resourceItems.TryGetValue(
                resource,
                out ResourceItemUI item))
            {
                continue;
            }


            int amount =
                ResourceManager.Instance.GetAmount(resource);


            item.UpdateAmount(amount);
        }
    }


    private void OnResourceChanged(
        ResourceData resource,
        int amount)
    {
        if (resource == null)
            return;


        if (!resourceItems.TryGetValue(
            resource,
            out ResourceItemUI item))
        {
            return;
        }


        item.UpdateAmount(amount);
    }
}

修正後の動作

修正後に同じ建物を建設すると、

建設前

木材 100
食料 10
金属 50

から、
木材30・食料10を消費
↓
木材 70
食料 0
金属 50

と、内部の資源量とUIの表示が一致するようになりました。

今回分かったこと

今回の問題で重要だったのは、

「資源が減っていない」のか「減っているけどUIに伝わっていない」のかを切り分けることでした。

今回のシステムを順番に追うと、

PlacementManager
↓
ResourceManager.Spend()
↓
資源量を変更
↓
NotifyResourceChanged()
↓
OnResourceChanged
↓
ResourceUI
↓
ResourceItemUI.UpdateAmount()
↓
画面表示

という流れになっています。

今回止まっていたのは、

ResourceManager
↓
ResourceUI

の部分でした。

ResourceManagerの計算やResourceItemUIの表示処理を闇雲に修正するのではなく、ログを追加して「どこまで処理が届いているか」を確認したことで原因を特定できました。

まとめ

今回のポイントは3つです。

1. 内部の数値とUI表示は別物

ResourceManagerで資源が正しく減っていても、UIが自動的に変わるわけではありません。

UIに変更を通知する仕組みが必要です。

2. イベントは便利

今回のように、

OnResourceChanged

を使うことで、

資源が変更された
↓
必要なUIだけ更新

という仕組みを作れます。

ResourceManagerが直接UIを操作しなくてもいいため、役割を分離できます。

3. イベント購読のタイミングに注意

今回の最大の原因はここでした。

if (ResourceManager.Instance == null)
    return;

その時点でイベント購読を諦めてしまうと、後からResourceManagerが生成されても通知を受け取れません。

Unityでは、複数のMonoBehaviourが存在する場合、

「どのタイミングで初期化されるのか」

を意識することが重要だと実感しました。

今回の修正によって、建物を建設したときに資源を消費し、その結果がリアルタイムでUIにも反映されるところまで完成しました。

最後に

ということで、今回は一般的なデバッグの流れを殺意の波動を抑えながら終了しました。


どちらかというと原因究明よりも、
なぜこれでうまくいかないのかを考えることに時間を使ったような気がします。

もしこの記事を読んでくれた方は、
自分のような落とし穴に落ちないようにしましょう。

お疲れ様です!今回はここまでです!
ここまでお付き合いいただきありがとうございました!
またお会いしましょう~

コメント

タイトルとURLをコピーしました