くまのてサーバー

Harmonyを利用したmod作成2

3年ぶりの今夜のレシピは

Zombieの負荷を減らすmod

数年越しの改良を加えてた負荷軽減modを公開

たったこれだけ?そんなもんよ。



using HarmonyLib;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;

namespace hjkwPlus
{
    public class ZombieUpdateSkip
    {
        public static void Patch()
        {
            string patched_class = "EAIManager";
            string patched_func = "Update";
            string prefix_func = "EAIManager_Update_prefix";


            Log.Out("[hjkwPlus] " + patched_class + "." + patched_func + " Patch start");
            Harmony harmony = new Harmony("hjkwPlus." + patched_class + "." + patched_func + " patch");

            MethodInfo original = AccessTools.Method(typeof(EAIManager), patched_func);

            if (original == null)
            {
                Log.Out(string.Format("[hjkwPlus] patched error : " + patched_class + "." + patched_func + " Method was not found"));
            }
            else
            {
                MethodInfo prefix = typeof(ZombieUpdateSkip).GetMethod(prefix_func);
                if (prefix == null)
                {
                    Log.Out(string.Format("[hjkwPlus] PatchSetting: " + prefix_func + " was not found"));
                    return;
                }
                harmony.Patch(original, new HarmonyMethod(prefix));
            }
            Log.Out("[hjkwPlus] " + patched_class + "." + patched_func + " Patched");
        }

        public static int skip_counter = 0;

        public static bool EAIManager_Update_prefix(EAIManager __instance, EntityAlive ___entity)
        {
            // 1. サーバー(またはホスト)側でなければ、同期を保つためバニラ通り処理する
            //if (!ConnectionManager.Instance.IsServer) return true;
            //サーバー専用modなので↑はコメントアウトした

            // 2. 対象が死亡している、またはゾンビ(EntityZombie)ではない場合はスルー
            if (___entity == null || ___entity.IsDead() || !(___entity is EntityZombie)) return true;

            intervalEAI.TryGetValue(___entity.entityId, out int skip_interval);

            if (skip_interval > 0)
            {
                if ((___entity.entityId + Time.frameCount) % skip_interval != 0)
                {
                    // ★重要: AIの思考(ターゲット選定や経路変更)だけをスキップする!
                    // OnUpdateLive自体は動いているので、重力計算や物理は毎フレーム実行されます。
                    skip_counter++;
                    return false;
                }
            }
            return true; // 通常通りAIを動かす
        }

        //定期的に起動し、敵のEAIupdateの頻度を計算しentityIDと紐づけて格納しておく
        public static Dictionary intervalEAI = new Dictionary();

        //プレイヤーが抱えてるアクティブゾンビの数
        public static Dictionary enemyCnt = new Dictionary();

        public static void RecordNewZombie()
        {
            World world = GameManager.Instance.World;

            intervalEAI.Clear();
            enemyCnt.Clear();
            foreach (var key in world.Entities.dict.Keys)
            {
                Entity _entity = world.Entities.dict[key];
                if ((_entity.entityType == EntityType.Zombie) || (_entity.entityType == EntityType.Animal) || (_entity.entityType == EntityType.Bandit))
                {
                    EntityAlive _entityalive = _entity as EntityAlive;
                    bool flg_active = false;
                    if (_entityalive.aiManager?.targetTasks != null && _entityalive.aiManager?.targetTasks.executingTasks != null && _entityalive.aiManager.targetTasks.executingTasks.Count > 0)
                    {
                        //何かしらのAI taskをもって居たら活発に動くべきものだと判断
                        flg_active = true;
                        //var task = _entityalive.aiManager.targetTasks.executingTasks[0];
                        //Log.Out("[test] " + task.action.GetType().Name);
                    }

                    //10m以内にプレイヤーがいるなら更新頻度の間引きは最低の2、ただし抱えてる敵の数が8を越えた分は4回に1回まで間引かれる
                    EntityPlayer closestPlayer = world.GetClosestPlayer(_entity.position, 10f, _isDead: false);

                    //近くのプレイヤーが居ない場合はentityidが-1のプレイヤーが抱えてると仮定する。
                    int entityid = -1;
                    if (closestPlayer != null)
                    {
                        entityid = closestPlayer.entityId;
                    }

                    enemyCnt.TryGetValue(entityid, out int count);

                    //抱えてる敵の数記録を先に更新しておく
                    enemyCnt[entityid] = count + 1;

                    if (count > 8)
                    {
                        if ((world.worldTime - _entity.WorldTimeBorn) < 100)
                        {
                            intervalEAI[_entity.entityId] = 3;
                        }
                        else
                        {
                            intervalEAI[_entity.entityId] = 5;
                        }

                        if(count > 16)
                        {
                            intervalEAI[_entity.entityId] += 2;
                        }

                        if (!flg_active)
                        {
                            //aiTaskを持っていないものは更新頻度をさらに下げる
                            intervalEAI[_entity.entityId] += 4;
                        }
                    }
                    else
                    {
                        //ゲーム内1日がリアル2時間設定でリアル30秒以内(worldTimeで100)ならEAIupdateは間引きされない
                        if ((world.worldTime - _entity.WorldTimeBorn) > 100)
                        {
                            intervalEAI[_entity.entityId] = 2;
                        }
                        else
                        {
                            intervalEAI[_entity.entityId] = 0;
                        }

                        if (!flg_active)
                        {
                            //aiTaskを持っていないものは更新頻度をさらに下げる
                            intervalEAI[_entity.entityId] += 2;
                        }
                    }
                }
            }
        }
    }
}

とりあえずコード晒し

ZombieUpdateSkip.Patch()を呼び出してパッチをあててあげてね

あとZombieUpdateSkip.RecordNewZombie()を3~5秒に1回呼び出して更新してね

この辺りは自分で適当に書いてね


EAIManagerのUpdateは各entityが1フレーム毎に呼び出されるものなので出来るだけ実行数は減らす必要がある

ゾンビ100体でFPS20の状態としたら理想的に50ms毎に呼び出されるので1秒に2000回呼び出される。

1行判定式と実行分を増やすだけで数千行分の実行を増やすので要注意

なので複雑な判定分を数秒毎に実行されるRecordNewZombie()でまとめて処理

Geminiに聞いたらC#のDict型は速度的には遅くないとのことなのでDictでAIのskip頻度を持たせた

前バージョンは別言語の癖でDict型は重いと思い込んでHashSetを3つ使って処理してたけどDict型でかなり早くできたと思う


ここから削るとしたら

if (___entity == null || ___entity.IsDead() || !(___entity is EntityZombie)) return true;

の部分でIsDeadとEntityZombieは削っても良い気がする(根拠のないカン)

とりあえずはGeminiちゃんとの共同作業なのでとりあえず残しておく。まぁ大分削った後だけどね。

精度を上げるとしたらRecordNewZombie()の中身を書き換えてください

多少重たい処理をしても大丈夫だしなんなら別スレッドに飛ばしやすいのでその方がいいかも




肝心なことを書き忘れてたけどEAIManagerのupdateは全てのEntityが1フレーム毎に処理が入るもの。

ゾンビの経路計算や何をするかの行動決定を毎度50ms毎にしなおしてます。

なのでこのRecordNewZombie()ではプレイヤーの10m以内では攻撃頻度が上がると考えてスキップ処理は減らす。

それ以上の距離は近づく必要あるからいちいち経路や行動再計算はせんでよか。

ただし、大量にプレイヤーが大量に抱えすぎてる状態やゾンビが発生して時間が立ったゾンビについては再計算のスキップ頻度を上げる様にしています。



harmony.PatchAll();を使う書き方しないのはなぜ?って個別に制御する場面があるからこの書き方がメイン。

harmony.PatchAll()の書き方も別のModではやってるよ



skip_counterは別場所で1分毎に動いて数値の記録を取ってカウンターを0にしています。動いているかどうかの確認用なので消した方が軽くなります



最後に、IModApiでZombieUpdateSkip.Patch()を最初に起動し、定期的にRecordNewZombie()を起動するコードとなっています。

C#でMod開発しようとしてる人なら理解してくれると思います。(他力本願)