Загрузка данных


using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using BepInEx;
using BepInEx.Logging;
using BepInEx.Unity.IL2CPP;
using Il2CppInterop.Runtime.Injection;
using Il2CppInterop.Runtime.InteropTypes.Arrays;
using UnityEngine;
using UnityEngine.SceneManagement;

[BepInPlugin("my.cheatmenu", "CheatMenu", "1.5")]
public class Plugin : BasePlugin
{
    public static ManualLogSource L;
    public override void Load()
    {
        L = Log;
        ClassInjector.RegisterTypeInIl2Cpp<MenuBehaviour>();
        AddComponent<MenuBehaviour>();
        Log.LogInfo("CheatMenu loaded, press Insert");
    }
}

public class MenuBehaviour : MonoBehaviour
{
    public MenuBehaviour(IntPtr ptr) : base(ptr) { }

    bool open, fly, stamina;
    float flySpeed = 15f;
    Transform player;
    CharacterController cc;
    Rigidbody rb;
    string filter = "";
    int scenePage = 0;
    float nextApply;

    // режим шахеда
    bool drone, exploding;
    float droneSpeed = 30f, crashSpeed = 20f, spinSpeed = 360f;
    float droneStartTime, respawnAt, explodeTime = -100f;
    Vector3 startPos;
    GameObject flash;
    AudioClip boomClip, buzzClip, musicClip;
    AudioSource buzzSrc, musicSrc, remoteBuzz, remoteMusic;
    string lastHit = "-";
    string soundStatus = "звуки не загружены";
    string statsStatus = "";
    string spawnStatus = "";

    // сеть (всё статическое, чтобы Il2Cpp не пытался это регистрировать)
    static UdpClient udp;
    static Thread thr;
    static volatile bool netOn;
    static string friendIp = "";
    static int netPort = 27777;
    static string netStatus = "выключена";
    static readonly ConcurrentQueue<string> inbox = new ConcurrentQueue<string>();

    // спавн
    static readonly List<GameObject> found = new List<GameObject>();
    static string lastFilter = null;

    // стамина
    static Assembly gameAsm;
    static readonly Dictionary<string, Type> typeCache = new Dictionary<string, Type>();
    static readonly Dictionary<string, double> best = new Dictionary<string, double>();
    static readonly HashSet<string> loggedSet = new HashSet<string>();
    static readonly string[] StaminaWords = { "stamina", "energy" };
    static readonly string[] BadWords = { "regen", "rate", "damage", "cost", "drain", "speed", "time", "delay", "max", "min", "percent", "ratio", "fov", "view", "camera", "zoom", "field", "bar", "text", "ui", "image", "slider" };

    static readonly string[] FuseKeys = { "fuse" };
    static readonly string[] RobberKeys = { "robber", "thief", "burglar", "bandit" };

    // ---------- звук ----------

    bool ClipOk(AudioClip c)
    {
        if (c == null) return false;
        try { return c.length > 0f; } catch { return false; }
    }

    void EnsureSounds()
    {
        if (!ClipOk(boomClip)) boomClip = LoadWav("boom.wav");
        if (!ClipOk(boomClip)) { boomClip = MakeBoom(); }
        if (!ClipOk(buzzClip)) buzzClip = LoadWav("buzz.wav");
        if (!ClipOk(musicClip)) musicClip = LoadWav("music.wav");
        soundStatus = "boom:" + (ClipOk(boomClip) ? "ok" : "нет")
                    + " buzz:" + (ClipOk(buzzClip) ? "ok" : "нет")
                    + " music:" + (ClipOk(musicClip) ? "ok" : "нет");
    }

    AudioClip MakeClip(string name, float[] d, int ch, int rate)
    {
        var clip = AudioClip.Create(name, d.Length / ch, ch, rate, false);
        clip.SetData((Il2CppStructArray<float>)d, 0);
        clip.hideFlags = HideFlags.HideAndDontSave;
        return clip;
    }

    AudioClip MakeBoom()
    {
        try
        {
            int rate = 22050, n = rate * 2;
            var d = new float[n];
            var r = new System.Random(1);
            for (int i = 0; i < n; i++)
            {
                float t = (float)i / rate;
                d[i] = ((float)r.NextDouble() * 2f - 1f) * Mathf.Exp(-t * 3f) * 0.9f;
            }
            return MakeClip("boom_gen", d, 1, rate);
        }
        catch (Exception e) { Plugin.L.LogInfo("MakeBoom error: " + e.Message); return null; }
    }

    AudioClip LoadWav(string name)
    {
        try
        {
            using (var s = Assembly.GetExecutingAssembly().GetManifestResourceStream(name))
            {
                if (s == null) { Plugin.L.LogInfo("No embedded sound: " + name); return null; }
                var ms = new MemoryStream();
                s.CopyTo(ms);
                byte[] b = ms.ToArray();
                int pos = 12, ch = 1, rate = 44100, bits = 16, fmt = 1, dataPos = -1, dataLen = 0;
                while (pos + 8 <= b.Length)
                {
                    string id = Encoding.ASCII.GetString(b, pos, 4);
                    int size = BitConverter.ToInt32(b, pos + 4);
                    if (id == "fmt ")
                    {
                        fmt = BitConverter.ToInt16(b, pos + 8);
                        ch = BitConverter.ToInt16(b, pos + 10);
                        rate = BitConverter.ToInt32(b, pos + 12);
                        bits = BitConverter.ToInt16(b, pos + 22);
                    }
                    else if (id == "data") { dataPos = pos + 8; dataLen = Math.Min(size, b.Length - dataPos); break; }
                    pos += 8 + size + (size % 2);
                }
                if (dataPos < 0) { Plugin.L.LogInfo(name + ": no data chunk"); return null; }
                if (fmt != 1 && fmt != 65534) { Plugin.L.LogInfo(name + ": unsupported format " + fmt + " (need PCM)"); return null; }
                if (bits != 16) { Plugin.L.LogInfo(name + ": need 16-bit, got " + bits); return null; }
                int samples = dataLen / 2;
                var f = new float[samples];
                for (int i = 0; i < samples; i++) f[i] = BitConverter.ToInt16(b, dataPos + i * 2) / 32768f;
                var clip = MakeClip(name, f, ch, rate);
                Plugin.L.LogInfo("Loaded sound: " + name + " (" + ch + "ch, " + rate + "Hz, " + samples + " samples)");
                return clip;
            }
        }
        catch (Exception e) { Plugin.L.LogInfo("LoadWav error (" + name + "): " + e.Message); return null; }
    }

    AudioSource MakeLoopSource(string name, AudioClip clip, float volume)
    {
        var g = new GameObject(name);
        UnityEngine.Object.DontDestroyOnLoad(g);
        var src = g.AddComponent<AudioSource>();
        src.clip = clip;
        src.loop = true;
        src.spatialBlend = 0f;
        src.volume = volume;
        src.ignoreListenerPause = true;
        src.Play();
        return src;
    }

    void KillSource(ref AudioSource src)
    {
        if (src == null) return;
        try { src.Stop(); UnityEngine.Object.Destroy(src.gameObject); } catch { }
        src = null;
    }

    void StopDroneSounds()
    {
        KillSource(ref buzzSrc);
        KillSource(ref musicSrc);
    }

    void PlayMusic()
    {
        EnsureSounds();
        StopDroneSounds();
        if (ClipOk(buzzClip)) buzzSrc = MakeLoopSource("DroneBuzz", buzzClip, 1f);
        if (ClipOk(musicClip)) musicSrc = MakeLoopSource("DroneMusic", musicClip, 0.8f);
    }

    void PlayBoom(float volume)
    {
        EnsureSounds();
        if (!ClipOk(boomClip)) return;
        var g = new GameObject("BoomSound");
        UnityEngine.Object.DontDestroyOnLoad(g);
        var src = g.AddComponent<AudioSource>();
        src.clip = boomClip;
        src.spatialBlend = 0f;
        src.volume = volume;
        src.ignoreListenerPause = true;
        src.Play();
        UnityEngine.Object.Destroy(g, boomClip.length + 0.5f);
    }

    // звук от друга
    void RemoteOn()
    {
        EnsureSounds();
        RemoteOff();
        if (ClipOk(buzzClip)) remoteBuzz = MakeLoopSource("RemoteBuzz", buzzClip, 0.6f);
        if (ClipOk(musicClip)) remoteMusic = MakeLoopSource("RemoteMusic", musicClip, 0.6f);
    }

    void RemoteOff()
    {
        KillSource(ref remoteBuzz);
        KillSource(ref remoteMusic);
    }

    // ---------- сеть ----------

    void StartNet()
    {
        try
        {
            udp = new UdpClient(netPort);
            netOn = true;
            thr = new Thread(RecvLoop);
            thr.IsBackground = true;
            thr.Start();
            netStatus = "слушаю порт " + netPort;
        }
        catch (Exception e) { netOn = false; netStatus = "ошибка: " + e.Message; }
    }

    void StopNet()
    {
        netOn = false;
        try { if (udp != null) udp.Close(); } catch { }
        udp = null;
        netStatus = "выключена";
    }

    static void RecvLoop()
    {
        var ep = new IPEndPoint(IPAddress.Any, 0);
        while (netOn)
        {
            try
            {
                byte[] data = udp.Receive(ref ep);
                if (data.Length == 0 || data.Length > 16) continue;
                string ip = friendIp.Trim();
                if (ip.Length == 0 || ep.Address.ToString() != ip) continue;
                string s = Encoding.ASCII.GetString(data);
                if (s == "BUZZ_ON" || s == "BUZZ_OFF" || s == "BOOM") inbox.Enqueue(s);
            }
            catch { if (!netOn) break; }
        }
    }

    static void Send(string msg)
    {
        if (!netOn || udp == null) return;
        string ip = friendIp.Trim();
        if (ip.Length == 0) return;
        try
        {
            byte[] b = Encoding.ASCII.GetBytes(msg);
            udp.Send(b, b.Length, ip, netPort);
        }
        catch { }
    }

    void HandleRemote(string s)
    {
        if (s == "BUZZ_ON") RemoteOn();
        else if (s == "BUZZ_OFF") RemoteOff();
        else if (s == "BOOM") PlayBoom(0.7f);
    }

    void OnApplicationQuit() { StopNet(); }

    // ---------- основной цикл ----------

    void Update()
    {
        string msg;
        while (inbox.TryDequeue(out msg)) HandleRemote(msg);

        if (Input.GetKeyDown(KeyCode.Insert)) open = !open;
        if (Input.GetKeyDown(KeyCode.F6)) SetFly(!fly);
        if (Input.GetKeyDown(KeyCode.F8)) SetDrone(!drone);

        if (open) { Cursor.visible = true; Cursor.lockState = CursorLockMode.None; }

        // обычный полёт
        if (fly && !drone && player != null && Camera.main != null && !open)
        {
            var cam = Camera.main.transform;
            Vector3 dir = Vector3.zero;
            if (Input.GetKey(KeyCode.W)) dir += cam.forward;
            if (Input.GetKey(KeyCode.S)) dir -= cam.forward;
            if (Input.GetKey(KeyCode.D)) dir += cam.right;
            if (Input.GetKey(KeyCode.A)) dir -= cam.right;
            if (Input.GetKey(KeyCode.Space)) dir += Vector3.up;
            if (Input.GetKey(KeyCode.LeftControl)) dir -= Vector3.up;
            float sp = Input.GetKey(KeyCode.LeftShift) ? flySpeed * 3f : flySpeed;
            player.position += dir.normalized * sp * Time.deltaTime;
        }

        // режим шахеда
        if (drone && !exploding && player != null && Camera.main != null && !open)
        {
            var cam = Camera.main.transform;
            Vector3 dir = cam.forward;
            if (Input.GetKey(KeyCode.W)) droneSpeed += 25f * Time.deltaTime;
            if (Input.GetKey(KeyCode.S)) droneSpeed -= 25f * Time.deltaTime;
            droneSpeed = Mathf.Clamp(droneSpeed, 5f, 80f);

            float dist = droneSpeed * Time.deltaTime + 0.6f;
            bool hit = false;
            var hits = Physics.SphereCastAll(player.position, 0.5f, dir, dist);
            foreach (var h in hits)
            {
                if (h.collider == null || h.collider.isTrigger) continue;
                if (h.transform == player || h.transform.IsChildOf(player)) continue;
                hit = true;
                lastHit = h.collider.name;
                break;
            }

            if (hit && Time.time - droneStartTime > 1.5f)
            {
                if (droneSpeed >= crashSpeed) Explode(player.position);
            }
            else
            {
                player.position += dir * droneSpeed * Time.deltaTime;
            }
            player.Rotate(dir, spinSpeed * Time.deltaTime, Space.World);
        }

        // сфера взрыва
        if (flash != null)
        {
            float k = (Time.time - explodeTime) / 0.6f;
            if (k >= 1f) { UnityEngine.Object.Destroy(flash); flash = null; }
            else flash.transform.localScale = Vector3.one * (2f + k * 14f);
        }

        // возврат на старт
        if (exploding && Time.time >= respawnAt)
        {
            if (player != null) player.position = startPos;
            exploding = false;
            SetFly(false);
        }

        if (stamina && Time.time >= nextApply)
        {
            nextApply = Time.time + 0.25f;
            ApplyStats(0);
        }
    }

    void SetFly(bool value)
    {
        if (value == fly) return;
        fly = value;
        if (fly)
        {
            cc = FindObjectOfType<CharacterController>();
            if (cc != null) { player = cc.transform; cc.enabled = false; }
            else
            {
                var go = GameObject.FindWithTag("Player");
                rb = go != null ? go.GetComponent<Rigidbody>() : null;
                if (rb != null) { player = rb.transform; rb.isKinematic = true; }
            }
            if (player == null) { fly = false; Plugin.L.LogInfo("Player not found (no CharacterController / Player-tag Rigidbody)"); }
        }
        else
        {
            if (drone) Send("BUZZ_OFF");
            drone = false;
            StopDroneSounds();
            if (cc != null) cc.enabled = true;
            if (rb != null) rb.isKinematic = false;
        }
    }

    void SetDrone(bool value)
    {
        if (value)
        {
            SetFly(true);
            if (!fly || player == null) return;
            startPos = player.position;
            droneStartTime = Time.time;
            exploding = false;
            drone = true;
            PlayMusic();
            Send("BUZZ_ON");
        }
        else
        {
            drone = false;
            StopDroneSounds();
            Send("BUZZ_OFF");
        }
    }

    void Explode(Vector3 pos)
    {
        exploding = true;
        respawnAt = Time.time + 1.5f;
        explodeTime = Time.time;
        StopDroneSounds();
        Send("BUZZ_OFF");
        Send("BOOM");
        Plugin.L.LogInfo("EXPLODE at " + pos + ", hit: " + lastHit);
        PlayBoom(1f);

        try
        {
            if (flash != null) UnityEngine.Object.Destroy(flash);
            flash = GameObject.CreatePrimitive(PrimitiveType.Sphere);
            flash.transform.position = pos;
            flash.transform.localScale = Vector3.one * 2f;
            var col = flash.GetComponent<Collider>();
            if (col != null) UnityEngine.Object.Destroy(col);
            var rend = flash.GetComponent<Renderer>();
            if (rend != null) rend.material.color = new Color(1f, 0.55f, 0.1f);
        }
        catch { }

        var near = Physics.OverlapSphere(pos, 10f);
        foreach (var c in near)
        {
            if (c == null) continue;
            var body = c.attachedRigidbody;
            if (body != null && body != rb) body.AddExplosionForce(800f, pos, 10f);
        }
    }

    // ---------- стамина ----------

    Type ResolveType(MonoBehaviour mb)
    {
        string n = null;
        try { n = mb.GetIl2CppType().FullName; } catch { }
        if (n == null) return null;
        Type t;
        if (typeCache.TryGetValue(n, out t)) return t;
        if (gameAsm == null)
            foreach (var a in AppDomain.CurrentDomain.GetAssemblies())
                if (a.GetName().Name == "Assembly-CSharp") { gameAsm = a; break; }
        t = gameAsm != null ? gameAsm.GetType(n) : null;
        typeCache[n] = t;
        return t;
    }

    static string[] Words(string name)
    {
        string s = Regex.Replace(name, "([a-z0-9])([A-Z])", "$1 $2");
        s = s.Replace("_", " ").ToLower();
        return s.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
    }

    static bool HasAny(string[] words, string[] list)
    {
        foreach (var w in words) foreach (var l in list) if (w == l) return true;
        return false;
    }

    // mode 0 = применить, mode 1 = записать найденное в лог
    void ApplyStats(int mode)
    {
        int count = 0;
        var all = UnityEngine.Object.FindObjectsOfType<MonoBehaviour>();
        foreach (var mb in all)
        {
            if (mb == null) continue;
            try
            {
                var t = ResolveType(mb);
                if (t == null) continue;
                object obj = Activator.CreateInstance(t, mb.Pointer);
                var props = t.GetProperties(BindingFlags.Public | BindingFlags.Instance);
                foreach (var p in props)
                {
                    if (!p.CanRead || !p.CanWrite) continue;
                    if (p.PropertyType != typeof(float) && p.PropertyType != typeof(int)) continue;
                    var w = Words(p.Name);
                    if (HasAny(w, BadWords)) continue;
                    if (!HasAny(w, StaminaWords)) continue;

                    object cur = p.GetValue(obj);
                    double curV = cur is float cf ? cf : (cur is int ci ? ci : 0);

                    if (mode == 1)
                    {
                        Plugin.L.LogInfo("FOUND: " + t.FullName + "." + p.Name + " = " + curV);
                        count++;
                        continue;
                    }

                    string key = t.FullName + "." + p.Name + "#" + mb.Pointer.ToString();
                    double b;
                    if (!best.TryGetValue(key, out b) || curV > b) { best[key] = curV; b = curV; }
                    if (curV < b)
                    {
                        if (p.PropertyType == typeof(float)) p.SetValue(obj, (float)b);
                        else p.SetValue(obj, (int)b);
                        if (loggedSet.Add(t.FullName + "." + p.Name))
                            Plugin.L.LogInfo("SET: " + t.FullName + "." + p.Name + " -> " + b);
                    }
                    count++;
                }
            }
            catch { }
        }
        statsStatus = "полей стамины: " + count;
        if (mode == 1) Plugin.L.LogInfo("Scan done, fields: " + count);
    }

    // ---------- спавн ----------

    static bool MatchAny(string lower, string[] keys)
    {
        foreach (var k in keys) if (lower.Contains(k)) return true;
        return false;
    }

    GameObject FindBest(string[] keys)
    {
        GameObject bestGo = null;
        int bestScore = -1;
        var all = Resources.FindObjectsOfTypeAll<GameObject>();
        foreach (var go in all)
        {
            if (go == null) continue;
            string n = go.name.ToLower();
            if (n.Contains("(clone)") || !MatchAny(n, keys)) continue;
            int score = 0;
            try { if (!go.scene.IsValid()) score += 2; } catch { }
            if (go.transform.parent == null) score += 1;
            if (score > bestScore) { bestScore = score; bestGo = go; }
        }
        return bestGo;
    }

    void SpawnObject(GameObject go)
    {
        if (go == null) { spawnStatus = "объект не найден"; return; }
        Vector3 pos = Vector3.zero;
        if (Camera.main != null) pos = Camera.main.transform.position + Camera.main.transform.forward * 3f;
        try
        {
            RaycastHit hit;
            if (Physics.Raycast(pos + Vector3.up * 2f, Vector3.down, out hit, 20f))
                pos = hit.point + Vector3.up * 0.1f;
        }
        catch { }
        var copy = UnityEngine.Object.Instantiate(go, pos, Quaternion.identity);
        copy.SetActive(true);
        spawnStatus = "создан: " + go.name;
        Plugin.L.LogInfo("SPAWNED: " + go.name);
    }

    void SpawnQuick(string[] keys, string label)
    {
        var go = FindBest(keys);
        if (go == null) { spawnStatus = label + ": не найден (см. список имён)"; return; }
        SpawnObject(go);
    }

    void RefreshFound()
    {
        found.Clear();
        string f = filter.ToLower();
        if (f.Length < 2) return;
        var all = Resources.FindObjectsOfTypeAll<GameObject>();
        var seen = new HashSet<string>();
        foreach (var go in all)
        {
            if (go == null) continue;
            if (!go.name.ToLower().Contains(f)) continue;
            if (!seen.Add(go.name)) continue;
            found.Add(go);
            if (found.Count >= 8) break;
        }
    }

    void LogNames()
    {
        var keys = new[] { "fuse", "robber", "thief", "burglar", "bandit", "criminal" };
        var all = Resources.FindObjectsOfTypeAll<GameObject>();
        var seen = new HashSet<string>();
        int n = 0;
        foreach (var go in all)
        {
            if (go == null) continue;
            string low = go.name.ToLower();
            if (!MatchAny(low, keys)) continue;
            string kind = "сцена";
            try { if (!go.scene.IsValid()) kind = "префаб/ассет"; } catch { }
            if (seen.Add(go.name + kind)) { Plugin.L.LogInfo("NAME: " + go.name + " [" + kind + "]"); n++; }
            if (n >= 150) break;
        }
        Plugin.L.LogInfo("Names logged: " + n);
        spawnStatus = "имён в логе: " + n;
    }

    // ---------- интерфейс ----------

    void OnGUI()
    {
        // вспышка взрыва
        float fk = (Time.time - explodeTime) / 0.8f;
        if (fk >= 0f && fk < 1f)
        {
            GUI.color = new Color(1f, 0.6f, 0.1f, 1f - fk);
            GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), Texture2D.whiteTexture);
            GUI.color = Color.white;
        }

        // строка состояния
        if (open || fly || drone || stamina)
        {
            string st = "Полёт:" + (fly ? "да" : "нет") + "  Игрок:" + (player != null ? "найден" : "НЕТ")
                      + "  Шахед:" + (drone ? ("да, v=" + (int)droneSpeed) : "нет") + "\n"
                      + "Удар:" + lastHit + "  " + soundStatus + "\n" + statsStatus + "  Сеть: " + netStatus;
            GUI.Label(new Rect(Screen.width - 540, 10, 530, 70), st);
        }

        if (!open) return;

        // ----- левая колонка -----
        float x = 20, y = 20, w = 320, h = 26;
        GUI.Box(new Rect(10, 10, w + 20, 760), "Меню (Insert)");
        y += 20;

        bool nf = GUI.Toggle(new Rect(x, y, w, h), fly, " Полёт / ноклип (F6)"); y += h;
        if (nf != fly) SetFly(nf);
        GUI.Label(new Rect(x, y, w, 20), "Скорость полёта: " + (int)flySpeed); y += 20;
        flySpeed = GUI.HorizontalSlider(new Rect(x, y, w, 20), flySpeed, 3f, 100f); y += 26;

        bool nd = GUI.Toggle(new Rect(x, y, w, h), drone, " Режим шахеда (F8)"); y += h;
        if (nd != drone) SetDrone(nd);
        GUI.Label(new Rect(x, y, w, 20), "Скорость дрона: " + (int)droneSpeed + " (W/S)"); y += 20;
        droneSpeed = GUI.HorizontalSlider(new Rect(x, y, w, 20), droneSpeed, 5f, 80f); y += 22;
        GUI.Label(new Rect(x, y, w, 20), "Порог взрыва: " + (int)crashSpeed); y += 20;
        crashSpeed = GUI.HorizontalSlider(new Rect(x, y, w, 20), crashSpeed, 5f, 80f); y += 22;
        GUI.Label(new Rect(x, y, w, 20), "Вращение: " + (int)spinSpeed + " град/с"); y += 20;
        spinSpeed = GUI.HorizontalSlider(new Rect(x, y, w, 20), spinSpeed, 0f, 1080f); y += 26;

        if (GUI.Button(new Rect(x, y, 150, 24), "Тест взрыва"))
        {
            Vector3 p = Camera.main != null ? Camera.main.transform.position + Camera.main.transform.forward * 5f : Vector3.zero;
            Explode(p);
            exploding = false;
        }
        if (GUI.Button(new Rect(x + 170, y, 150, 24), "Тест музыки")) { PlayMusic(); Send("BUZZ_ON"); }
        y += 28;
        if (GUI.Button(new Rect(x, y, w, 24), "Стоп звук")) { StopDroneSounds(); Send("BUZZ_OFF"); }
        y += 34;

        stamina = GUI.Toggle(new Rect(x, y, w, h), stamina, " Бесконечная стамина"); y += h;
        if (GUI.Button(new Rect(x, y, w, 24), "Найти поля стамины (в лог)")) ApplyStats(1);
        y += 36;

        GUI.Label(new Rect(x, y, w, h), "Сцены (индекс в билде):"); y += 22;
        int count = SceneManager.sceneCountInBuildSettings;
        for (int i = scenePage * 4; i < Math.Min(count, scenePage * 4 + 4); i++)
        {
            string path = SceneUtility.GetScenePathByBuildIndex(i);
            string nm = Path.GetFileNameWithoutExtension(path);
            if (GUI.Button(new Rect(x, y, w, 24), i + ": " + nm)) SceneManager.LoadScene(i);
            y += 26;
        }
        if (GUI.Button(new Rect(x, y, 150, 24), "< Назад")) scenePage = Math.Max(0, scenePage - 1);
        if (GUI.Button(new Rect(x + 170, y, 150, 24), "Вперёд >")) { if ((scenePage + 1) * 4 < count) scenePage++; }

        // ----- правая колонка -----
        float x2 = 380, y2 = 20, w2 = 320;
        GUI.Box(new Rect(370, 10, w2 + 20, 600), "Спавн и сеть");
        y2 += 20;

        if (GUI.Button(new Rect(x2, y2, 150, 28), "Предохранитель")) SpawnQuick(FuseKeys, "предохранитель");
        if (GUI.Button(new Rect(x2 + 170, y2, 150, 28), "Грабитель")) SpawnQuick(RobberKeys, "грабитель");
        y2 += 34;
        if (GUI.Button(new Rect(x2, y2, w2, 24), "Показать подходящие имена (в лог)")) LogNames();
        y2 += 28;
        GUI.Label(new Rect(x2, y2, w2, 40), spawnStatus); y2 += 24;

        GUI.Label(new Rect(x2, y2, w2, 22), "Поиск объекта по имени:"); y2 += 22;
        filter = GUI.TextField(new Rect(x2, y2, w2, 24), filter); y2 += 28;
        if (lastFilter != filter) { lastFilter = filter; RefreshFound(); }
        if (GUI.Button(new Rect(x2, y2, w2, 22), "Обновить список")) RefreshFound();
        y2 += 26;
        foreach (var go in found)
        {
            if (go == null) continue;
            if (GUI.Button(new Rect(x2, y2, w2, 24), go.name)) SpawnObject(go);
            y2 += 26;
        }

        y2 = 20 + 20 + 34 + 28 + 24 + 22 + 28 + 26 + 8 * 26 + 10;
        GUI.Label(new Rect(x2, y2, w2, 22), "Звук для друга (оба ставят этот плагин):"); y2 += 24;
        GUI.Label(new Rect(x2, y2, w2, 22), "IP друга (из VPN или белый IP):"); y2 += 22;
        friendIp = GUI.TextField(new Rect(x2, y2, w2, 24), friendIp); y2 += 28;
        bool nn = GUI.Toggle(new Rect(x2, y2, w2, h), netOn, " Включить связь (UDP " + netPort + ")"); y2 += h;
        if (nn && !netOn) StartNet();
        if (!nn && netOn) StopNet();
        GUI.Label(new Rect(x2, y2, w2, 22), "Статус: " + netStatus);
    }
}