diff --git a/CHANGELOG.md b/CHANGELOG.md index e29b559..b89a37a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ Server und Mod müssen beide 2.7 sein (Server: `git pull && docker compose up -d - Admin-Menü: Cheats (+1.000 / +10.000 MK, Bedürfnisse auffüllen, Satsuma zu sich holen), GOTO / BRING (teleportieren) - Fahrzeugzustand-Sync: Tank, Öl, Kühlwasser, Bremsflüssigkeit, Batterie, Schäden, Verschleiß, Kilometerstand, Lack - `vehicles` in der Konsole zeigt die Position jedes Fahrzeugs +- Admin-Menü mit Reitern: **PLAYERS** (Kick/Ban/GOTO/BRING, Cheats), **ITEMS** (Items spawnen – alle sehen sie, auf dem + Server gespeichert), **BANS** (Sperren aufheben); Server-Befehle `bans` / `unban` +- Tacho, Drehzahlmesser, Tank-/Temperaturanzeige und Lenkrad werden beim Beifahrer mitbewegt +- Flüssigerer Sync: Position wird aus der Geschwindigkeit vorausberechnet, 30 Updates/s +- Letzte Position wird zuverlässig gespeichert (nicht mehr immer an der Toilette spawnen) +- Server-Browser: Klick auf den Server öffnet das Profil (JOIN-Knopf in der Liste entfernt) +- Alle Mod-Meldungen nur noch in der MSCO-Konsole (F7) **Behoben** - Auto fiel beim Fahren eines anderen Spielers auseinander (angebaute Teile rissen an ihren Gelenken ab) diff --git a/ItemSpawner.cs b/ItemSpawner.cs new file mode 100644 index 0000000..6ee291c --- /dev/null +++ b/ItemSpawner.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace MSCO +{ + /// + /// Admin: Items spawnen. Kopiert ein vorhandenes Item des Spiels (z.B. "beer case(itemx)" aus dem Laden) und + /// setzt die Kopie an die gewünschte Stelle. Die Kopie behält den Namen des Originals, damit sich die Spiel-Logik + /// (Trinken, Essen, Tanken ...) wie beim echten Item verhält und der Objekt-Sync sie bei allen gleich benennt. + /// + public static class ItemSpawner + { + /// Auswahl im Admin-Menü (Name ohne "(itemx)"). + public static readonly string[] Items = + { + "beer case", "sausages", "pizza", "macaron box", "milk", "juice", "ground coffee", "sugar", "yeast", "booze", + "gasoline", "diesel", "spray can", "fire extinguisher", "flashlight", "lantern", "car jack", "floor jack", + "spanner set", "sledgehammer", "radio", "camera", "grill charcoal", "fireworks bag", "water bucket" + }; + + static readonly Dictionary templates = new Dictionary(); + + static GameObject FindTemplate(string item) + { + GameObject cached; + if (templates.TryGetValue(item, out cached) && cached != null) return cached; + + string name = item + "(itemx)"; + GameObject best = null; + foreach (Object o in Resources.FindObjectsOfTypeAll(typeof(GameObject))) + { + var go = (GameObject)o; + if (go.name != name || go.GetComponent() == null) continue; + if (best == null || (go.activeInHierarchy && !best.activeInHierarchy)) best = go; // am liebsten ein echtes in der Welt + } + templates[item] = best; + return best; + } + + /// Item erzeugen. Gibt false zurück, wenn es im Spiel kein solches Item gibt. + public static bool Spawn(string item, Vector3 position, Quaternion rotation) + { + GameObject template = FindTemplate(item); + if (template == null) + { + MscoLog.Warning("Cannot spawn '" + item + "' - no such item in this game"); + return false; + } + var clone = (GameObject)Object.Instantiate(template, position, rotation); + clone.name = template.name; + clone.transform.SetParent(null, true); + clone.SetActive(true); + Rigidbody rb = clone.GetComponent(); + if (rb != null) + { + rb.isKinematic = false; + rb.velocity = Vector3.zero; + rb.angularVelocity = Vector3.zero; + } + return true; + } + + public static void ClearCache() { templates.Clear(); } + } +} diff --git a/LobbyUI.cs b/LobbyUI.cs index 4754cbb..ce3f456 100644 --- a/LobbyUI.cs +++ b/LobbyUI.cs @@ -217,8 +217,8 @@ namespace MSCO Rect row = GUILayoutUtility.GetRect(10, 34, GUILayout.ExpandWidth(true)); GUI.Box(new Rect(row.x, row.y + 2, row.width, row.height - 4), GUIContent.none, rowBox); - // Klick auf die Zeile (außer JOIN) öffnet das Server-Profil - if (GUI.Button(new Rect(row.x, row.y, row.width - 82, row.height), GUIContent.none, GUIStyle.none)) + // Klick auf die Zeile öffnet das Server-Profil (dort wird verbunden) + if (GUI.Button(new Rect(row.x, row.y, row.width, row.height), GUIContent.none, GUIStyle.none)) selectedServer = s.Address + ":" + s.Port; GUI.DrawTexture(new Rect(row.x + 10, row.y + 13, 8, 8), s.Ping < 0 ? texPending : texOnline); @@ -236,12 +236,7 @@ namespace MSCO GUI.Label(new Rect(row.x + row.width * 0.5f, row.y, 70, row.height), s.Players + "/" + s.MaxPlayers, rowNameStyle); Pill(row.x + row.width * 0.62f, row, s.RequireSteam ? "STEAM" : "NO STEAM", s.RequireSteam ? pillStyle : pillMutedStyle); GUI.Label(new Rect(row.x + row.width * 0.76f, row.y, 50, row.height), s.Ping < 0 ? "..." : s.Ping + " ms", rowNameStyle); - - // Andere Version: trotzdem joinbar, beim Verbinden kommt eine Warnung - GUI.enabled = CanJoin(mp, s); - if (GUI.Button(new Rect(row.xMax - 76, row.y + 4, 70, row.height - 8), s.Players >= s.MaxPlayers ? "FULL" : "JOIN", primaryStyle)) - mp.JoinServer(s.Address, s.Port, s.Name, s.MaxPlayers); - GUI.enabled = true; + GUI.Label(new Rect(row.xMax - 30, row.y, 20, row.height), "›", headerCellStyle); // Hinweis: Profil öffnen } GUILayout.EndScrollView(); diff --git a/MSCO.csproj b/MSCO.csproj index 3e4c1b7..35c6fc1 100644 --- a/MSCO.csproj +++ b/MSCO.csproj @@ -76,6 +76,7 @@ + diff --git a/MultiplayerManager.cs b/MultiplayerManager.cs index 0dbba41..5d801ba 100644 --- a/MultiplayerManager.cs +++ b/MultiplayerManager.cs @@ -206,7 +206,11 @@ namespace MSCO overlays.OpenAdmin(null); RequestAdminList(); } - else if (session.IsHost) overlays.OpenAdmin(LocalAdminList()); + else if (session.IsHost) + { + overlays.OpenAdmin(LocalAdminList()); + overlays.SetBanned(new List(bannedNames)); + } else overlays.Log("Only the host can kick or ban players."); } @@ -214,7 +218,11 @@ namespace MSCO { if (session == null) return; if (dedicated) session.SendTo(NetSession.HostId, MsgType.AdminListRequest); - else if (session.IsHost) overlays.SetAdminPlayers(LocalAdminList()); + else if (session.IsHost) + { + overlays.SetAdminPlayers(LocalAdminList()); + overlays.SetBanned(new List(bannedNames)); + } } List LocalAdminList() @@ -332,6 +340,56 @@ namespace MSCO if (result.EndsWith("next to you")) AnnounceCheat("brought the Satsuma to themselves"); } + /// Admin: Item vor sich spawnen - bei allen, und auf dem Server gespeichert. + public void SpawnItem(string item) + { + if (!CanCheat || localPlayer == null) return; + Transform head = playerCamera != null ? playerCamera.transform : localPlayer; + Vector3 forward = new Vector3(head.forward.x, 0f, head.forward.z).normalized; + if (forward.sqrMagnitude < 0.01f) forward = localPlayer.forward; + Vector3 pos = head.position + forward * 1.2f; + Quaternion rot = Quaternion.LookRotation(forward, Vector3.up); + if (!ItemSpawner.Spawn(item, pos, rot)) { overlays.Log("No '" + item + "' in this game"); return; } + rescanSoon = true; + SendGame(MsgType.SpawnItem, NetSession.Build(w => + { + w.Write(item); + w.Write(pos.x); w.Write(pos.y); w.Write(pos.z); + w.Write(rot.x); w.Write(rot.y); w.Write(rot.z); w.Write(rot.w); + })); + AnnounceCheat("spawned " + item); + } + + /// Gespawntes Item eines Admins (live oder aus dem Weltzustand) nachbauen. + static bool ApplySpawn(BinaryReader r) + { + string item = r.ReadString(); + var pos = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); + var rot = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); + if (System.Array.IndexOf(ItemSpawner.Items, item) < 0) return false; // nur bekannte Items + return ItemSpawner.Spawn(item, pos, rot); + } + + /// Admin: Bann aufheben (Server: SteamID oder name:..., P2P: Name). + public void Unban(string id) + { + if (session == null) return; + if (dedicated) + { + session.SendTo(NetSession.HostId, MsgType.AdminAction, NetSession.Build(w => + { + w.Write((byte)4); + w.Write(-1); + w.Write(id); + })); + return; + } + if (!session.IsHost) return; + bannedNames.Remove(id); + overlays.SetBanned(new List(bannedNames)); + overlays.AddChat("", id + " is no longer banned", true); + } + /// Cheats für alle sichtbar im Chat melden. void AnnounceCheat(string what) { @@ -807,7 +865,7 @@ namespace MSCO { return t == MsgType.Player || t == MsgType.ObjStates || t == MsgType.ObjOwn || t == MsgType.ObjRelease || t == MsgType.FsmEvent || t == MsgType.VehicleFx || t == MsgType.WorldTime || t == MsgType.MoneyDelta || t == MsgType.DoorState - || t == MsgType.Chat || t == MsgType.VarSync; + || t == MsgType.Chat || t == MsgType.VarSync || t == MsgType.SpawnItem; } void OnMessage(int peerId, Message m) @@ -996,6 +1054,13 @@ namespace MSCO for (int i = 0; i < count; i++) list.Add(new AdminPlayer { Id = r.ReadInt32(), Name = r.ReadString(), SteamId = r.ReadString(), IsAdmin = r.ReadBoolean() }); overlays.SetAdminPlayers(list); + var banned = new List(); + if (r.BaseStream.Length - r.BaseStream.Position >= 4) + { + int bans = r.ReadInt32(); + for (int i = 0; i < bans; i++) banned.Add(r.ReadString()); + } + overlays.SetBanned(banned); break; } @@ -1066,6 +1131,9 @@ namespace MSCO case MsgType.VarSync: vehicleState.Handle(from, r, objects, localId); break; + case MsgType.SpawnItem: + if (ApplySpawn(r)) rescanSoon = true; // neues Item gleich in den Objekt-Sync aufnehmen + break; } } @@ -1407,13 +1475,6 @@ namespace MSCO if ((i + 1) % SnapshotEventsPerFrame == 0) yield return null; // nicht alles in einem Frame } - // Abgebaute Teile sind jetzt eigene Objekte: erfassen, dann alle Positionen setzen - yield return null; - objects.ScanNow(); - int applied = 0; - foreach (KeyValuePair p in poses) - if (objects.ApplySnapshotPose(p.Key, p.Value.Position, p.Value.Rotation)) applied++; - // Geld: Summe aller Änderungen seit dem Spielstand des Servers if (r.BaseStream.Length - r.BaseStream.Position >= 4) moneySync.Apply(r.ReadSingle()); @@ -1427,6 +1488,27 @@ namespace MSCO // Fahrzeugzustand (Tank, Öl, Schrauben ...) if (r.BaseStream.Length - r.BaseStream.Position >= 4) vehicleState.ApplySnapshot(r); + // Von Admins gespawnte Items (in der Reihenfolge des Servers, damit alle sie gleich benennen) + int spawned = 0; + if (r.BaseStream.Length - r.BaseStream.Position >= 4) + { + int spawns = r.ReadInt32(); + for (int i = 0; i < spawns; i++) + { + byte[] payload = r.ReadBytes(r.ReadInt32()); + try { if (ApplySpawn(new BinaryReader(new MemoryStream(payload)))) spawned++; } + catch (System.Exception e) { MscoLog.Warning("Could not spawn a saved item: " + e.Message); } + } + } + + // Abgebaute Teile und gespawnte Items sind jetzt eigene Objekte: erfassen, dann alle Positionen setzen + yield return null; + objects.ScanNow(); + int applied = 0; + foreach (KeyValuePair p in poses) + if (objects.ApplySnapshotPose(p.Key, p.Value.Position, p.Value.Rotation)) applied++; + if (spawned > 0) MscoLog.Info(spawned + " spawned item(s) restored"); + MscoLog.Info("[MSCO] World synced: " + applied + "/" + objectCount + " objects, " + eventCount + " part/bolt actions"); SetStatus("Connected"); } diff --git a/NetSession.cs b/NetSession.cs index b34fd35..3d3835a 100644 --- a/NetSession.cs +++ b/NetSession.cs @@ -39,7 +39,8 @@ namespace MSCO Chat = 28, // Chatnachricht (string) VarSync = 29, // Fahrzeugzustand: int n, je [int key, byte typ, wert] Teleport = 30, // Admin holt einen Spieler zu sich: 3 float Position - Pong = 31 // Antwort auf Ping mit demselben Zeitstempel (Laufzeit messen) + Pong = 31, // Antwort auf Ping mit demselben Zeitstempel (Laufzeit messen) + SpawnItem = 32 // Admin spawnt ein Item: string Item-Name, 3 float Position, 4 float Drehung } public struct Message diff --git a/Overlays.cs b/Overlays.cs index 88951e3..4950267 100644 --- a/Overlays.cs +++ b/Overlays.cs @@ -51,6 +51,8 @@ namespace MSCO // Admin public bool AdminOpen { get; private set; } List adminPlayers; + List bannedIds; + int adminTab; string adminReason = ""; int confirmBanId = -1; Vector2 adminScroll; @@ -92,6 +94,7 @@ namespace MSCO } public void SetAdminPlayers(List players) { adminPlayers = players; } + public void SetBanned(List ids) { bannedIds = ids; } public void CloseAll() { @@ -263,7 +266,7 @@ namespace MSCO void DrawAdmin(MultiplayerManager mp) { - float width = Mathf.Min(680, Screen.width - 40), height = Mathf.Min(560, Screen.height - 40); + float width = Mathf.Min(720, Screen.width - 40), height = Mathf.Min(580, Screen.height - 40); var rect = new Rect((Screen.width - width) / 2f, (Screen.height - height) / 2f, width, height); GUI.Box(rect, GUIContent.none, panel); @@ -281,13 +284,30 @@ namespace MSCO GUILayout.Space(8); if (GUILayout.Button("CLOSE", secondary, GUILayout.Height(30), GUILayout.Width(90))) AdminOpen = false; GUILayout.EndHorizontal(); - GUILayout.Label(mp.IsDedicated ? "Players on this server. Banned players cannot join again." : "Players in your game.", small); + GUILayout.Space(6); + + // Reiter + GUILayout.BeginHorizontal(); + string[] tabs = { "PLAYERS", "ITEMS", "BANS" + (bannedIds != null && bannedIds.Count > 0 ? " (" + bannedIds.Count + ")" : "") }; + for (int i = 0; i < tabs.Length; i++) + { + if (GUILayout.Button(tabs[i], adminTab == i ? primary : secondary, GUILayout.Height(30), GUILayout.Width(130))) adminTab = i; + GUILayout.Space(6); + } + GUILayout.EndHorizontal(); GUILayout.Space(10); + if (adminTab == 1) DrawAdminItems(mp); + else if (adminTab == 2) DrawAdminBans(mp); + else DrawAdminPlayers(mp); + GUILayout.EndArea(); + } + + void DrawAdminPlayers(MultiplayerManager mp) + { // Cheats für den eigenen Spieler (nur im Spiel) if (mp.CanCheat) { - GUILayout.Label("CHEATS", caption); GUILayout.BeginHorizontal(); if (GUILayout.Button("+1.000 MK", primary, GUILayout.Height(30))) mp.CheatMoney(1000f); GUILayout.Space(6); @@ -297,13 +317,13 @@ namespace MSCO GUILayout.Space(6); if (GUILayout.Button("SATSUMA TO ME", primary, GUILayout.Height(30))) mp.BringCar(); GUILayout.EndHorizontal(); - GUILayout.Label("Cheats are announced in the chat. GOTO / BRING teleport you to a player or a player to you.", small); - GUILayout.Space(10); + GUILayout.Label("Cheats are announced in the chat. GOTO teleports you to a player, BRING teleports a player to you.", small); + GUILayout.Space(8); } - GUILayout.Label("REASON (optional)", caption); + GUILayout.Label("REASON FOR KICK / BAN (optional)", caption); adminReason = GUILayout.TextField(adminReason, 100, field, GUILayout.Height(32)); - GUILayout.Space(12); + GUILayout.Space(10); adminScroll = GUILayout.BeginScrollView(adminScroll); if (adminPlayers == null) GUILayout.Label("Loading players...", small); @@ -330,11 +350,11 @@ namespace MSCO GUILayout.Space(12); } GUI.enabled = !self && !p.IsAdmin; - if (GUILayout.Button("KICK", secondary, GUILayout.Height(30), GUILayout.Width(80))) + if (GUILayout.Button("KICK", secondary, GUILayout.Height(30), GUILayout.Width(70))) mp.AdminAction(false, p.Id, adminReason); GUILayout.Space(6); bool confirming = confirmBanId == p.Id; - if (GUILayout.Button(confirming ? "SURE?" : "BAN", danger, GUILayout.Height(30), GUILayout.Width(80))) + if (GUILayout.Button(confirming ? "SURE?" : "BAN", danger, GUILayout.Height(30), GUILayout.Width(70))) { if (confirming) { mp.AdminAction(true, p.Id, adminReason); confirmBanId = -1; } else confirmBanId = p.Id; @@ -345,7 +365,55 @@ namespace MSCO } } GUILayout.EndScrollView(); - GUILayout.EndArea(); + } + + void DrawAdminItems(MultiplayerManager mp) + { + if (!mp.CanCheat) + { + GUILayout.Label("Items can only be spawned in the game.", small); + return; + } + GUILayout.Label("Click an item to spawn it in front of you. Everyone sees it, and it is saved on the server.", small); + GUILayout.Space(8); + adminScroll = GUILayout.BeginScrollView(adminScroll); + const int perRow = 4; + for (int i = 0; i < ItemSpawner.Items.Length; i += perRow) + { + GUILayout.BeginHorizontal(); + for (int k = i; k < Mathf.Min(i + perRow, ItemSpawner.Items.Length); k++) + { + string item = ItemSpawner.Items[k]; + if (GUILayout.Button(item.ToUpper(), secondary, GUILayout.Height(34), GUILayout.Width(155))) mp.SpawnItem(item); + GUILayout.Space(6); + } + GUILayout.EndHorizontal(); + GUILayout.Space(6); + } + GUILayout.EndScrollView(); + } + + void DrawAdminBans(MultiplayerManager mp) + { + GUILayout.Label(mp.IsDedicated ? "Banned players cannot join this server. UNBAN lets them in again." + : "Players banned from your game in this session.", small); + GUILayout.Space(8); + adminScroll = GUILayout.BeginScrollView(adminScroll); + if (bannedIds == null) GUILayout.Label("Loading...", small); + else if (bannedIds.Count == 0) GUILayout.Label("Nobody is banned.", small); + else + { + foreach (string id in bannedIds) + { + GUILayout.BeginHorizontal(rowStyle, GUILayout.Height(40)); + GUILayout.Label(id.StartsWith("name:") ? id.Substring(5) + " (no Steam)" : "SteamID " + id, chatName); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("UNBAN", primary, GUILayout.Height(28), GUILayout.Width(90))) mp.Unban(id); + GUILayout.EndHorizontal(); + GUILayout.Space(4); + } + } + GUILayout.EndScrollView(); } // ---------------------------------------------------------------- Stile diff --git a/VehicleFx.cs b/VehicleFx.cs index 4484427..436a090 100644 --- a/VehicleFx.cs +++ b/VehicleFx.cs @@ -7,13 +7,22 @@ using UnityEngine; namespace MSCO { /// - /// Lichter und Dauergeräusche (Motor, Blinker, Hupe, Radio ...) von Fahrzeugen. - /// Wer ein Fahrzeug besitzt (fährt/schiebt), schickt regelmäßig deren Zustand; - /// bei allen anderen wird dieser Zustand jedes Frame übernommen. + /// Lichter, Dauergeräusche (Motor, Blinker, Hupe, Radio ...) und Anzeigen (Tacho, Drehzahlmesser, Tank-/Temperatur- + /// zeiger, Lenkrad) von Fahrzeugen. Wer ein Fahrzeug besitzt (fährt/schiebt), schickt regelmäßig deren Zustand; + /// bei allen anderen wird dieser Zustand jedes Frame übernommen. Die Zeiger bewegen sich beim Beifahrer sonst nicht, + /// weil das Auto dort nicht selbst fährt (die Spiel-Logik sieht Geschwindigkeit/Drehzahl 0). /// public class VehicleFx { - const float SendInterval = 0.2f; + const float SendInterval = 0.1f; + const float DialSmoothing = 14f; // Zeiger gleiten zwischen den Paketen weich zur neuen Stellung + + /// Objekte, deren Drehung eine Anzeige ist: Tacho-/Drehzahl-/Tank-/Temperaturzeiger, Lenkrad. + static bool IsDial(string name) + { + string n = name.ToLower(); + return n.Contains("needle") || n.Contains("steeringwheel"); + } const float CacheRefresh = 5f; // Teile können an-/abgebaut werden const float ApplyTimeout = 2f; // ohne neue Daten nichts mehr erzwingen @@ -30,6 +39,7 @@ namespace MSCO public Transform Root; public readonly Dictionary Lights = new Dictionary(); public readonly Dictionary Audio = new Dictionary(); + public readonly Dictionary Dials = new Dictionary(); } class Snapshot @@ -37,6 +47,7 @@ namespace MSCO public float ReceivedAt; public readonly Dictionary Lights = new Dictionary(); public readonly Dictionary Audio = new Dictionary(); + public readonly Dictionary Dials = new Dictionary(); } readonly Dictionary components = new Dictionary(); @@ -82,6 +93,17 @@ namespace MSCO w.Write(a.Value.pitch); w.Write(a.Value.volume); } + // Anzeigen (am Ende angehängt - ältere Versionen lesen hier nicht weiter) + int dialCount = 0; + foreach (Transform t in c.Dials.Values) if (t != null) dialCount++; + w.Write(dialCount); + foreach (KeyValuePair d in c.Dials) + { + if (d.Value == null) continue; + Quaternion q = d.Value.localRotation; + w.Write(d.Key); + w.Write(q.x); w.Write(q.y); w.Write(q.z); w.Write(q.w); + } })); } } @@ -100,6 +122,16 @@ namespace MSCO int key = r.ReadInt32(); s.Audio[key] = new AudioState { Playing = r.ReadBoolean(), Pitch = r.ReadSingle(), Volume = r.ReadSingle() }; } + if (r.BaseStream.Length - r.BaseStream.Position >= 4) + { + int dials = r.ReadInt32(); + for (int i = 0; i < dials; i++) + { + int key = r.ReadInt32(); + var q = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); + if (!float.IsNaN(q.x) && !float.IsNaN(q.w)) s.Dials[key] = q; + } + } incoming[id] = s; } @@ -124,6 +156,13 @@ namespace MSCO AudioSource src; if (c.Audio.TryGetValue(a.Key, out src) && src != null) SetAudio(src, a.Value); } + float t = Mathf.Clamp01(Time.deltaTime * DialSmoothing); + foreach (KeyValuePair d in kv.Value.Dials) + { + Transform dial; + if (c.Dials.TryGetValue(d.Key, out dial) && dial != null) + dial.localRotation = Quaternion.Slerp(dial.localRotation, d.Value, t); + } } } @@ -164,6 +203,9 @@ namespace MSCO seen.Clear(); foreach (AudioSource a in b.Rb.GetComponentsInChildren(true)) if (a.loop) c.Audio[Key(a.transform, c.Root, seen)] = a; // nur Dauergeräusche + seen.Clear(); + foreach (Transform t in b.Rb.GetComponentsInChildren(true)) + if (IsDial(t.name)) c.Dials[Key(t, c.Root, seen)] = t; components[b.Id] = c; return c; }