diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d2c29a..249b7bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 2.8 + +Server und Mod müssen beide 2.8 sein (Server: `git pull && docker compose up -d --build`). + +**Mehr Sync** +- **Einkäufe**: gekaufte/ausgepackte Items tauchen bei allen auf (auch bei später Beitretenden) +- **Verbrauchte Items**: Gegessenes, Getrunkenes und Verbrauchtes verschwindet bei allen +- **Räder** drehen sich bei Autos, die ein anderer Spieler fährt +- **Unfallschäden**: verbeulte Karosserie wird an alle übertragen +- **Jobs, Aufträge und Laden**: Fortschritt (Auftrag angenommen, erledigt, bestellt ...) wird synchronisiert +- **Verkehr**: NPC-Autos fahren bei allen an derselben Stelle (der erste Spieler simuliert, die anderen folgen) +- `vehicles` in der F7-Konsole zeigt, wie viele Karosserie-Meshes, Job-Werte und Verkehrsautos synchronisiert werden + ## 2.7 Server und Mod müssen beide 2.7 sein (Server: `git pull && docker compose up -d --build`). diff --git a/DamageSync.cs b/DamageSync.cs new file mode 100644 index 0000000..7a802cf --- /dev/null +++ b/DamageSync.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using UnityEngine; + +namespace MSCO +{ + /// + /// Unfallschäden (verbeulte Karosserie): Das Spiel verformt die Meshes der Fahrzeuge bei Kollisionen + /// (CarDamage, CarDamage2, Deformable). Das lässt sich nicht nachspielen, also übertragen wir das Ergebnis: + /// Wer das Fahrzeug fährt (oder bei freien Fahrzeugen: bei wem es passiert), schickt die geänderten Eckpunkte. + /// + public class DamageSync + { + const float ScanInterval = 10f; + const float CheckInterval = 1f; + const float MinMove = 0.002f; + const int MaxPerMessage = 4000; + + static readonly string[] DamageTypes = { "CarDamage", "CarDamage2", "Deformable" }; + + class DamageMesh + { + public int Key; + public string Root; + public MeshFilter Filter; + public Vector3[] Known; // zuletzt gesendet/empfangen + } + + readonly Dictionary meshes = new Dictionary(); + readonly HashSet seen = new HashSet(); + readonly List changed = new List(); + float nextScan, nextCheck; + static List types; + + public int Count { get { return meshes.Count; } } + + public void Reset() + { + meshes.Clear(); + seen.Clear(); + nextScan = 0f; + } + + public void Update(ObjectSync objects, int localId, Action send) + { + if (Time.time >= nextScan) + { + nextScan = Time.time + ScanInterval; + Scan(); + } + if (Time.time < nextCheck) return; + nextCheck = Time.time + CheckInterval; + + foreach (DamageMesh m in meshes.Values) + { + if (m.Filter == null) continue; + int owner = objects.VehicleOwner(m.Root); + if (owner != SyncedBody.NoOwner && owner != localId) continue; // der Fahrer schickt die Schäden + + Mesh mesh = m.Filter.sharedMesh; + if (mesh == null) continue; + Vector3[] now = mesh.vertices; + if (now.Length != m.Known.Length) { m.Known = now; continue; } + + changed.Clear(); + for (int i = 0; i < now.Length; i++) + if ((now[i] - m.Known[i]).sqrMagnitude > MinMove * MinMove) changed.Add(i); + if (changed.Count == 0) continue; + + for (int start = 0; start < changed.Count; start += MaxPerMessage) + { + int from = start, count = Math.Min(MaxPerMessage, changed.Count - start); + int key = m.Key, total = now.Length; + send(MsgType.VehicleDamage, NetSession.Build(w => + { + w.Write(key); + w.Write(total); + w.Write(count); + for (int i = from; i < from + count; i++) + { + int idx = changed[i]; + Vector3 v = now[idx]; + w.Write(idx); w.Write(v.x); w.Write(v.y); w.Write(v.z); + } + })); + } + m.Known = now; + } + } + + public void Handle(BinaryReader r) + { + int key = r.ReadInt32(); + int total = r.ReadInt32(); + int count = r.ReadInt32(); + DamageMesh m; + if (!meshes.TryGetValue(key, out m) || m.Filter == null) { nextScan = 0f; return; } + Mesh mesh = m.Filter.mesh; // eigene Kopie, falls das Mesh (noch) mit anderen geteilt ist + if (mesh == null) return; + Vector3[] verts = mesh.vertices; + if (verts.Length != total) return; // anderes Mesh (anderes Teil verbaut) + + for (int i = 0; i < count; i++) + { + int idx = r.ReadInt32(); + var v = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); + if (idx < 0 || idx >= verts.Length || float.IsNaN(v.x) || float.IsNaN(v.y) || float.IsNaN(v.z)) continue; + if (v.sqrMagnitude > 1e6f) continue; // Unsinn nie übernehmen + verts[idx] = v; + } + mesh.vertices = verts; + mesh.RecalculateNormals(); + mesh.RecalculateBounds(); + m.Known = verts; + } + + void Scan() + { + if (types == null) + { + types = new List(); + foreach (Assembly a in AppDomain.CurrentDomain.GetAssemblies()) + { + if (a.GetName().Name != "Assembly-CSharp") continue; + foreach (string n in DamageTypes) + { + Type t = a.GetType(n); + if (t != null) types.Add(t); + } + } + } + + int before = meshes.Count; + foreach (Type t in types) + { + foreach (UnityEngine.Object o in UnityEngine.Object.FindObjectsOfType(t)) + { + var c = o as Component; + if (c == null || !IsVehicle(c.transform.root.name)) continue; + foreach (FieldInfo f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + { + if (f.FieldType == typeof(MeshFilter)) Register(f.GetValue(c) as MeshFilter); + else if (f.FieldType == typeof(MeshFilter[])) + { + var arr = f.GetValue(c) as MeshFilter[]; + if (arr != null) foreach (MeshFilter mf in arr) Register(mf); + } + } + } + } + if (meshes.Count != before) MscoLog.Info("[MSCO] Crash damage: " + meshes.Count + " body meshes synced"); + } + + void Register(MeshFilter mf) + { + if (mf == null || !seen.Add(mf)) return; + Mesh mesh = mf.sharedMesh; + if (mesh == null || mesh.vertexCount == 0 || mesh.vertexCount > 65000) return; + int key = Hash(FsmSync.PathOf(mf.transform)); + if (meshes.ContainsKey(key)) return; // nicht eindeutig + meshes[key] = new DamageMesh { Key = key, Root = mf.transform.root.name, Filter = mf, Known = mesh.vertices }; + } + + static bool IsVehicle(string root) + { + foreach (string v in ObjectSync.VehicleRoots) if (root.StartsWith(v)) return true; + return false; + } + + static int Hash(string s) + { + unchecked + { + uint h = 2166136261; + foreach (char c in s) { h ^= c; h *= 16777619; } + return (int)h; + } + } + } +} diff --git a/ItemSpawner.cs b/ItemSpawner.cs index dc1b8a3..36fdcf5 100644 --- a/ItemSpawner.cs +++ b/ItemSpawner.cs @@ -47,12 +47,13 @@ namespace MSCO return objectNames.TryGetValue(item, out name) ? name : item + "(itemx)"; } - static GameObject FindTemplate(string item) + static GameObject FindTemplate(string item) { return FindTemplateByObjectName(ObjectName(item)); } + + static GameObject FindTemplateByObjectName(string name) { GameObject cached; - if (templates.TryGetValue(item, out cached) && cached != null) return cached; + if (templates.TryGetValue(name, out cached) && cached != null) return cached; - string name = ObjectName(item); GameObject best = null; foreach (Object o in Resources.FindObjectsOfTypeAll(typeof(GameObject))) { @@ -62,20 +63,36 @@ namespace MSCO if (go.transform.parent != null && go.transform.parent.GetComponentInParent() != null) continue; if (best == null || (go.activeInHierarchy && !best.activeInHierarchy)) best = go; // am liebsten eins aus der Welt } - templates[item] = best; + templates[name] = best; return best; } + /// Zuletzt erzeugtes Objekt (damit der Objekt-Sync es nicht als "neu gekauft" meldet). + public static GameObject LastSpawned { get; private set; } + + /// Item nach seinem Objektnamen erzeugen (z.B. "beer case(itemx)") - für Einkäufe anderer Spieler. + public static bool SpawnObjectNamed(string objectName, Vector3 position, Quaternion rotation) + { + if (string.IsNullOrEmpty(objectName) || !objectName.EndsWith("(itemx)") || objectName.Length > 100) return false; + return SpawnTemplate(FindTemplateByObjectName(objectName), objectName, position, rotation); + } + /// 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); + return SpawnTemplate(FindTemplate(item), item, position, rotation); + } + + static bool SpawnTemplate(GameObject template, string item, Vector3 position, Quaternion rotation) + { + LastSpawned = null; if (template == null) { MscoLog.Warning("Cannot spawn '" + item + "' - no such item in this game"); return false; } var clone = (GameObject)Object.Instantiate(template, position, rotation); + LastSpawned = clone; clone.name = template.name; clone.transform.SetParent(null, true); clone.SetActive(true); diff --git a/MSCO.cs b/MSCO.cs index 86f34c8..34307d4 100644 --- a/MSCO.cs +++ b/MSCO.cs @@ -5,7 +5,7 @@ namespace MSCO { public class MSCO : Mod { - public const string ModVersion = "2.7"; + public const string ModVersion = "2.8"; public override string ID => "MSCO"; // Your (unique) mod ID public override string Name => "MSCO"; // Your mod name diff --git a/MSCO.csproj b/MSCO.csproj index 35c6fc1..ae071d0 100644 --- a/MSCO.csproj +++ b/MSCO.csproj @@ -73,6 +73,10 @@ + + + + diff --git a/MultiplayerManager.cs b/MultiplayerManager.cs index 0a17ce8..e9a350f 100644 --- a/MultiplayerManager.cs +++ b/MultiplayerManager.cs @@ -52,6 +52,7 @@ namespace MSCO readonly LobbyUI lobby = new LobbyUI(); readonly ObjectSync objects = new ObjectSync(); readonly VehicleFx vehicleFx = new VehicleFx(); + readonly WheelSpin wheelSpin = new WheelSpin(); readonly WorldSync worldSync = new WorldSync(); readonly DiscordPresence discord = new DiscordPresence(); readonly MoneySync moneySync = new MoneySync(); @@ -59,6 +60,9 @@ namespace MSCO readonly Passenger passenger = new Passenger(); readonly SteamLobby steam = new SteamLobby(); readonly VehicleStateSync vehicleState = new VehicleStateSync(); + readonly DamageSync damageSync = new DamageSync(); + readonly WorldVarSync worldVars = new WorldVarSync(); + readonly TrafficSync traffic = new TrafficSync(); readonly Overlays overlays = new Overlays(); bool isAdmin; // Server hat uns als Admin gemeldet readonly HashSet bannedNames = new HashSet(); // P2P-Host: für diese Sitzung gebannt @@ -96,6 +100,26 @@ namespace MSCO Application.runInBackground = true; playerName = PlayerPrefs.GetString(PrefName, System.Environment.UserName); objects.Send = SendGame; + // Einkäufe / verbrauchte Items bei uns: allen melden + objects.ItemAppeared = b => + { + Vector3 pos = b.Rb.position; + Quaternion rot = b.Rb.rotation; + string name = b.Go.name; + int id = b.Id; + SendGame(MsgType.ItemAppeared, NetSession.Build(w => + { + w.Write(id); + w.Write(name); + 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); + })); + }; + objects.ItemDestroyed = b => + { + int id = b.Id; + SendGame(MsgType.ObjDestroy, NetSession.Build(w => w.Write(id))); + }; FsmSync.Init(); SafetyPatches.Apply(); // verhindert den Absturz durch Kreis in der Objekt-Hierarchie discord.Start(); @@ -182,6 +206,9 @@ namespace MSCO var lines = objects.DescribeVehicles(); lines.Add("tracked doors/gates/hoods: " + doorSync.Count); lines.Add("synced vehicle values (fuel, oil, bolts, wear ...): " + vehicleState.Count); + lines.Add("synced body meshes (crash damage): " + damageSync.Count); + lines.Add("synced job/shop values: " + worldVars.Count); + lines.Add("synced traffic cars: " + traffic.Count + (IsWorldAuthority ? " (simulated here)" : " (following)")); return lines; } @@ -357,6 +384,7 @@ namespace MSCO 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; } + objects.MarkNetworkCreated(ItemSpawner.LastSpawned); // wird per SpawnItem gemeldet, nicht als "gekauft" rescanSoon = true; SendGame(MsgType.SpawnItem, NetSession.Build(w => { @@ -374,7 +402,22 @@ namespace MSCO var pos = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); var rot = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); if (!ItemSpawner.IsKnown(item) || item == ItemSpawner.RatchetSet) return false; // nur bekannte Gegenstände - return ItemSpawner.Spawn(item, pos, rot); + bool ok = ItemSpawner.Spawn(item, pos, rot); + if (ok && Instance != null) Instance.objects.MarkNetworkCreated(ItemSpawner.LastSpawned); + return ok; + } + + /// Bei einem anderen Spieler ist ein Item aufgetaucht (Einkauf ...): hier auch erzeugen. + bool ApplyAppeared(BinaryReader r) + { + int id = r.ReadInt32(); + string name = 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 (objects.HasNear(id, pos, 5f)) return false; // haben wir schon (z.B. war nur kurz ausgeblendet) + if (!ItemSpawner.SpawnObjectNamed(name, pos, rot)) return false; + objects.MarkNetworkCreated(ItemSpawner.LastSpawned); + return true; } /// Admin: Bann aufheben (Server: SteamID oder name:..., P2P: Name). @@ -926,11 +969,11 @@ namespace MSCO foreach (PlayerInfo p in players.Values) DestroyAvatar(p); players.Clear(); objects.Reset(); - vehicleFx.Reset(); + vehicleFx.Reset(); wheelSpin.Reset(); worldSync.Reset(); moneySync.Reset(); doorSync.Reset(); - vehicleState.Reset(); + vehicleState.Reset(); damageSync.Reset(); worldVars.Reset(); traffic.Reset(); isAdmin = false; if (overlays.AdminOpen || overlays.ChatOpen) overlays.CloseAll(); FsmSync.Enabled = false; @@ -957,7 +1000,9 @@ 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.SpawnItem; + || t == MsgType.Chat || t == MsgType.VarSync || t == MsgType.SpawnItem + || t == MsgType.ItemAppeared || t == MsgType.ObjDestroy || t == MsgType.VehicleDamage + || t == MsgType.TrafficStates || t == MsgType.WorldVars; } void OnMessage(int peerId, Message m) @@ -1245,9 +1290,24 @@ namespace MSCO case MsgType.VarSync: vehicleState.Handle(from, r, objects, localId); break; + case MsgType.VehicleDamage: + damageSync.Handle(r); + break; + case MsgType.WorldVars: + worldVars.Handle(r); + break; + case MsgType.TrafficStates: + traffic.Handle(r, IsWorldAuthority); + break; case MsgType.SpawnItem: if (ApplySpawn(r)) rescanSoon = true; // neues Item gleich in den Objekt-Sync aufnehmen break; + case MsgType.ItemAppeared: + if (ApplyAppeared(r)) rescanSoon = true; + break; + case MsgType.ObjDestroy: + objects.DestroyRemote(r.ReadInt32()); + break; } } @@ -1356,8 +1416,8 @@ namespace MSCO passenger.Reset(); localPlayer = null; objects.OnGameLoaded(); - vehicleState.Reset(); - vehicleFx.Reset(); + vehicleState.Reset(); damageSync.Reset(); worldVars.Reset(); traffic.Reset(); + vehicleFx.Reset(); wheelSpin.Reset(); worldSync.Reset(); moneySync.Reset(); doorSync.Reset(); @@ -1377,7 +1437,7 @@ namespace MSCO passenger.Reset(); localPlayer = null; objects.Reset(); - vehicleFx.Reset(); + vehicleFx.Reset(); wheelSpin.Reset(); worldSync.Reset(); moneySync.Reset(); doorSync.Reset(); @@ -1477,6 +1537,15 @@ namespace MSCO // Gesamtzustand der Fahrzeuge (Tank, Öl, Verschleiß ...) try { if (objects.ScanComplete) vehicleState.Update(objects, localId, SendGame); } catch (System.Exception e) { ReportError("vehicle state sync", e); } + // Unfallschäden (verbeulte Karosserie) + try { if (objects.ScanComplete) damageSync.Update(objects, localId, SendGame); } + catch (System.Exception e) { ReportError("crash damage sync", e); } + // Jobs, Aufträge, Laden + try { if (objects.ScanComplete) worldVars.Update(SendGame); } + catch (System.Exception e) { ReportError("jobs/shop sync", e); } + // Verkehr: die Welt-Autorität simuliert, alle anderen fahren nach + try { if (objects.ScanComplete) traffic.Update(IsWorldAuthority, Time.deltaTime, SendGame); } + catch (System.Exception e) { ReportError("traffic sync", e); } } foreach (PlayerInfo p in players.Values) @@ -1523,6 +1592,8 @@ namespace MSCO if (!IsPeerReady || !MenuHelper.InGame) return; try { objects.FixedUpdate(); } catch (System.Exception e) { ReportError("object movement", e); } + try { traffic.FixedUpdate(); } + catch (System.Exception e) { ReportError("traffic movement", e); } } void LateUpdate() @@ -1531,6 +1602,8 @@ namespace MSCO // Nach den Spiel-FSMs, damit Lichter/Motorgeräusch der Fahrzeuge anderer gewinnen try { if (IsPeerReady && MenuHelper.InGame) vehicleFx.Apply(objects); } catch (System.Exception e) { ReportError("vehicle lights/sound", e); } + try { if (IsPeerReady && MenuHelper.InGame) wheelSpin.Apply(objects, Time.deltaTime); } + catch (System.Exception e) { ReportError("wheels", e); } // Beifahrer nach der Fahrzeugbewegung auf den Sitz setzen try { if (localPlayer != null && MenuHelper.InGame) passenger.LateUpdate(localPlayer, playerCamera, objects); } catch (System.Exception e) { ReportError("passenger", e); } @@ -1620,9 +1693,28 @@ namespace MSCO } } + // Seit dem letzten Spielstand gekaufte bzw. verbrauchte Items + var destroyedIds = new List(); + if (r.BaseStream.Length - r.BaseStream.Position >= 4) + { + int appeared = r.ReadInt32(); + for (int i = 0; i < appeared; i++) + { + byte[] payload = r.ReadBytes(r.ReadInt32()); + try { ApplyAppeared(new BinaryReader(new MemoryStream(payload))); } + catch (System.Exception e) { MscoLog.Warning("Could not restore a bought item: " + e.Message); } + } + } + if (r.BaseStream.Length - r.BaseStream.Position >= 4) + { + int destroyed = r.ReadInt32(); + for (int i = 0; i < destroyed; i++) destroyedIds.Add(r.ReadInt32()); + } + // Abgebaute Teile und gespawnte Items sind jetzt eigene Objekte: erfassen, dann alle Positionen setzen yield return null; objects.ScanNow(); + foreach (int id in destroyedIds) objects.DestroyRemote(id); int applied = 0; foreach (KeyValuePair p in poses) if (objects.ApplySnapshotPose(p.Key, p.Value.Position, p.Value.Rotation)) applied++; diff --git a/NetSession.cs b/NetSession.cs index 2852425..5ebb2bf 100644 --- a/NetSession.cs +++ b/NetSession.cs @@ -44,7 +44,12 @@ namespace MSCO Reconnect = 33, // Server -> Client: string Meldung; ins Menü und neu verbinden (z.B. Welt zurückgesetzt) SaveRequest = 34, // Server -> Client: int Anfrage; im Spiel speichern und den Spielstand hochladen SaveUploadPart = 35, // Client -> Server: int Anfrage, string Datei, int Gesamtgröße, int Offset, int Länge, bytes - SaveUploadDone = 36 // Client -> Server: int Anfrage, int Anzahl Dateien + SaveUploadDone = 36, // Client -> Server: int Anfrage, int Anzahl Dateien + ItemAppeared = 37, // neues Item bei einem Spieler (Einkauf, Tüte ausgepackt ...): int Objekt-ID, string Objektname, 3 float Pos, 4 float Rot + ObjDestroy = 38, // Item wurde verbraucht/zerstört (gegessen, getrunken ...): int Objekt-ID + VehicleDamage = 39, // Unfallschaden: int Mesh-Schlüssel, int Eckpunkte gesamt, int n, je [int Index, 3 float] + TrafficStates = 40, // Verkehr (nur Welt-Autorität): int n, je [int Schlüssel, 3 float Pos, 4 float Rot, 3 float Geschw.] + WorldVars = 41 // Jobs/Laden: int n, je [int Schlüssel, byte Typ (2 int, 3 bool), Wert] } public struct Message diff --git a/ObjectSync.cs b/ObjectSync.cs index 92f58bc..09f16c8 100644 --- a/ObjectSync.cs +++ b/ObjectSync.cs @@ -16,6 +16,8 @@ namespace MSCO public int Id; public string Path; public Rigidbody Rb; + public GameObject Go; // zum Unterscheiden: nur Rigidbody weg (angebaut) oder ganzes Objekt weg (verbraucht) + public Vector3 LastPos; public bool IsVehicle; public string RootName; @@ -89,6 +91,32 @@ namespace MSCO public int LocalId; /// Senden an alle anderen Spieler (setzt der MultiplayerManager). public Action Send; + /// Neues Item bei uns aufgetaucht (Einkauf ...) bzw. bei uns verbraucht - der Manager meldet es allen. + public Action ItemAppeared, ItemDestroyed; + + // Übers Netz erzeugte Items (nicht nochmal als "neu" melden) + readonly HashSet networkCreated = new HashSet(); + Vector3 lastPlayerPos; + const float LocalEventRange = 8f; // Aufgetaucht/verbraucht in dieser Nähe = wir haben es ausgelöst + + public void MarkNetworkCreated(GameObject go) { if (go != null) networkCreated.Add(go); } + + /// Gibt es dieses Objekt bei uns schon an dieser Stelle? (dann nicht nochmal erzeugen) + public bool HasNear(int id, Vector3 pos, float range) + { + SyncedBody b; + return byId.TryGetValue(id, out b) && b.Rb != null && (b.Rb.position - pos).sqrMagnitude < range * range; + } + + /// Ein anderer Spieler hat dieses Item verbraucht: bei uns auch entfernen. + public bool DestroyRemote(int id) + { + SyncedBody b; + if (!byId.TryGetValue(id, out b)) return false; + if (b.Go != null) UnityEngine.Object.Destroy(b.Go); + Unregister(b); + return true; + } readonly Dictionary byId = new Dictionary(); readonly Dictionary byRb = new Dictionary(); @@ -207,6 +235,7 @@ namespace MSCO byId.Clear(); byRb.Clear(); rejected.Clear(); + networkCreated.Clear(); drivenName = null; drivenCache = null; ScanComplete = false; @@ -252,6 +281,7 @@ namespace MSCO public void Update(Transform player, float dt) { + if (player != null) lastPlayerPos = player.position; if (Time.time >= nextScan) { nextScan = Time.time + ScanInterval; @@ -268,7 +298,17 @@ namespace MSCO removeList.Clear(); foreach (SyncedBody b in byId.Values) { - if (b.Rb == null) { removeList.Add(b); continue; } // z.B. Teil wurde montiert + if (b.Rb == null) + { + // Ganzes Objekt weg (gegessen, getrunken, verbraucht) - nicht nur der Rigidbody (Teil angebaut). + // Nur melden, wenn es bei uns passiert ist: wir besaßen es oder standen daneben. + if (b.Go == null && !b.IsVehicle && ItemDestroyed != null + && (IsLocal(b) || (b.LastPos - lastPlayerPos).sqrMagnitude < LocalEventRange * LocalEventRange)) + ItemDestroyed(b); + removeList.Add(b); + continue; + } + b.LastPos = b.Rb.position; if (!b.Rb.gameObject.activeInHierarchy) continue; if (UpdateAttached(b)) continue; // angebaut: bewegt sich mit dem Auto @@ -847,12 +887,21 @@ namespace MSCO Id = id, Path = path, Rb = c.Key, + Go = c.Key.gameObject, + LastPos = c.Key.position, IsVehicle = c.Value, RootName = c.Key.transform.root.name }; byId[id] = b; byRb[c.Key] = b; added++; + + // Neues Item nach dem ersten Suchlauf, bei uns in der Nähe und nicht übers Netz entstanden: + // wahrscheinlich gekauft / ausgepackt / geliefert - den anderen melden + bool isItem = !b.IsVehicle && b.Go.name.EndsWith("(itemx)"); + if (ScanComplete && isItem && ItemAppeared != null && !networkCreated.Contains(b.Go) + && (b.LastPos - lastPlayerPos).sqrMagnitude < LocalEventRange * LocalEventRange) + ItemAppeared(b); } if (added > 0) MscoLog.Info("[MSCO] Syncing " + byId.Count + " objects (+" + added + ")"); } diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs index f5784f5..24d1bf4 100644 --- a/Properties/AssemblyInfo.cs +++ b/Properties/AssemblyInfo.cs @@ -32,5 +32,5 @@ using System.Runtime.InteropServices; // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("2.7.0.0")] -[assembly: AssemblyFileVersion("2.7.0.0")] +[assembly: AssemblyVersion("2.8.0.0")] +[assembly: AssemblyFileVersion("2.8.0.0")] diff --git a/TrafficSync.cs b/TrafficSync.cs new file mode 100644 index 0000000..c57d959 --- /dev/null +++ b/TrafficSync.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEngine; + +namespace MSCO +{ + /// + /// Verkehr (NPC-Autos): Der Spieler mit der kleinsten ID (Welt-Autorität, gibt auch Uhrzeit/Wetter vor) + /// simuliert den Verkehr und schickt die Positionen. Bei allen anderen fahren die Autos dann nur noch nach + /// (kinematisch) - so stehen sie überall an derselben Stelle und man kann sich gemeinsam darüber ärgern. + /// + public class TrafficSync + { + static readonly string[] Roots = { "TRAFFIC", "NPC_CARS" }; + + const float ScanInterval = 10f; + const float SendInterval = 0.1f; + const float Stale = 1.5f; // so lange ohne Daten: wieder selbst simulieren + const float Smoothing = 12f; + const float MaxExtrapolation = 0.3f; + + class Car + { + public int Key; + public Rigidbody Rb; + public bool WasKinematic; + public bool Following; + public Vector3 TargetPos, TargetVel; + public Quaternion TargetRot; + public float TargetTime = -100f; + } + + readonly Dictionary cars = new Dictionary(); + readonly HashSet seen = new HashSet(); + float nextScan, sendTimer; + + public int Count { get { return cars.Count; } } + + public void Reset() + { + foreach (Car c in cars.Values) StopFollowing(c); + cars.Clear(); + seen.Clear(); + nextScan = 0f; + } + + public void Update(bool authority, float dt, Action send) + { + if (Time.time >= nextScan) + { + nextScan = Time.time + ScanInterval; + Scan(); + } + + if (!authority) + { + foreach (Car c in cars.Values) + if (c.Following && Time.time - c.TargetTime > Stale) StopFollowing(c); + return; + } + + // Wir simulieren: eventuell noch nachfahrende Autos freigeben, Positionen senden + foreach (Car c in cars.Values) if (c.Following) StopFollowing(c); + sendTimer += dt; + if (sendTimer < SendInterval) return; + sendTimer = 0f; + + var list = new List(); + foreach (Car c in cars.Values) + if (c.Rb != null && c.Rb.gameObject.activeInHierarchy) list.Add(c); + if (list.Count == 0) return; + send(MsgType.TrafficStates, NetSession.Build(w => + { + w.Write(list.Count); + foreach (Car c in list) + { + Vector3 p = c.Rb.position, v = c.Rb.velocity; + Quaternion q = c.Rb.rotation; + w.Write(c.Key); + w.Write(p.x); w.Write(p.y); w.Write(p.z); + w.Write(q.x); w.Write(q.y); w.Write(q.z); w.Write(q.w); + w.Write(v.x); w.Write(v.y); w.Write(v.z); + } + })); + } + + public void Handle(BinaryReader r, bool authority) + { + int count = r.ReadInt32(); + for (int i = 0; i < count; i++) + { + int key = r.ReadInt32(); + var p = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); + var q = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); + var v = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); + Car c; + if (authority || !cars.TryGetValue(key, out c) || c.Rb == null) continue; + if (float.IsNaN(p.x) || float.IsNaN(q.w) || p.sqrMagnitude > 1e8f || v.sqrMagnitude > 1e4f) continue; + c.TargetPos = p; + c.TargetRot = q; + c.TargetVel = v; + c.TargetTime = Time.time; + if (!c.Following) + { + c.WasKinematic = c.Rb.isKinematic; + c.Rb.isKinematic = true; + c.Rb.position = p; + c.Rb.rotation = q; + c.Following = true; + } + } + } + + public void FixedUpdate() + { + foreach (Car c in cars.Values) + { + if (!c.Following || c.Rb == null) continue; + float ahead = Mathf.Min(Time.time - c.TargetTime, MaxExtrapolation); + Vector3 goal = c.TargetPos + c.TargetVel * ahead; + float t = Mathf.Clamp01(Time.fixedDeltaTime * Smoothing); + if ((c.Rb.position - goal).sqrMagnitude > 25f) { c.Rb.position = goal; c.Rb.rotation = c.TargetRot; continue; } + c.Rb.MovePosition(Vector3.Lerp(c.Rb.position, goal, t)); + c.Rb.MoveRotation(Quaternion.Slerp(c.Rb.rotation, c.TargetRot, t)); + } + } + + static void StopFollowing(Car c) + { + if (!c.Following) return; + c.Following = false; + if (c.Rb != null) c.Rb.isKinematic = c.WasKinematic; + } + + void Scan() + { + int before = cars.Count; + foreach (Rigidbody rb in UnityEngine.Object.FindObjectsOfType()) + { + if (rb == null || !seen.Add(rb)) continue; + if (Array.IndexOf(Roots, rb.transform.root.name) < 0) continue; + // nur das oberste Rigidbody je Auto (keine Räder/Türen) + Transform parent = rb.transform.parent; + if (parent != null && parent.GetComponentInParent() != null) continue; + int key = Hash(FsmSync.PathOf(rb.transform)); + if (cars.ContainsKey(key)) continue; + cars[key] = new Car { Key = key, Rb = rb }; + } + if (cars.Count != before) MscoLog.Info("[MSCO] Traffic: " + cars.Count + " cars synced"); + } + + static int Hash(string s) + { + unchecked + { + uint h = 2166136261; + foreach (char c in s) { h ^= c; h *= 16777619; } + return (int)h; + } + } + } +} diff --git a/WheelSpin.cs b/WheelSpin.cs new file mode 100644 index 0000000..b2e8322 --- /dev/null +++ b/WheelSpin.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using System.Reflection; +using UnityEngine; + +namespace MSCO +{ + /// + /// Räder an Fahrzeugen, die ein anderer Spieler fährt, drehen sich mit. Bei uns wird so ein Auto nur verschoben + /// (kinematisch), die Rad-Physik des Spiels (CarDynamics "Wheel") dreht die Räder dann nicht. Wir drehen das + /// Rad-Modell passend zur Geschwindigkeit um seine Achse - nach dem Spiel (LateUpdate), ohne dessen Werte zu ändern. + /// + public class WheelSpin + { + const float CacheRefresh = 5f; // Räder können ab-/angebaut werden + + class WheelInfo + { + public Transform Model; + public Transform Hub; // Transform der Wheel-Komponente (Fahrtrichtung) + public float Radius; + public float Angle; + public Quaternion Base; // Drehung ohne unsere Rollbewegung + public Quaternion Written; // zuletzt von uns gesetzt + public bool HasWritten; + } + + class Vehicle + { + public float BuiltAt; + public readonly List Wheels = new List(); + } + + readonly Dictionary vehicles = new Dictionary(); + static System.Type wheelType; + static FieldInfo modelField, radiusField; + static bool lookedUp; + + public void Reset() { vehicles.Clear(); } + + public void Apply(ObjectSync objects, float dt) + { + if (!Lookup()) return; + List driven = objects.RemoteDrivenVehicles(); + if (driven.Count == 0) { if (vehicles.Count > 0) vehicles.Clear(); return; } + + foreach (SyncedBody b in driven) + { + Vehicle v; + if (!vehicles.TryGetValue(b.Id, out v) || Time.time - v.BuiltAt > CacheRefresh) + { + v = Build(b.Rb.transform); + vehicles[b.Id] = v; + } + + Vector3 vel = b.TargetVel; + foreach (WheelInfo w in v.Wheels) + { + if (w.Model == null || w.Hub == null) continue; + float speed = Vector3.Dot(vel, w.Hub.forward); + w.Angle = Mathf.Repeat(w.Angle + speed / w.Radius * Mathf.Rad2Deg * dt, 360f); + + // Hat das Spiel das Modell in diesem Frame selbst gedreht (z.B. Lenkung), ist das die neue Basis + Quaternion current = w.Model.localRotation; + if (!w.HasWritten || current != w.Written) w.Base = current; + w.Written = w.Base * Quaternion.Euler(w.Angle, 0f, 0f); + w.Model.localRotation = w.Written; + w.HasWritten = true; + } + } + } + + static Vehicle Build(Transform root) + { + var v = new Vehicle { BuiltAt = Time.time }; + foreach (Component c in root.GetComponentsInChildren(wheelType, true)) + { + var model = modelField.GetValue(c) as GameObject; + float radius = radiusField != null ? (float)radiusField.GetValue(c) : 0.3f; + if (model == null || radius < 0.05f || radius > 2f) continue; + v.Wheels.Add(new WheelInfo { Model = model.transform, Hub = c.transform, Radius = radius }); + } + return v; + } + + static bool Lookup() + { + if (lookedUp) return wheelType != null; + lookedUp = true; + foreach (Assembly a in System.AppDomain.CurrentDomain.GetAssemblies()) + { + if (a.GetName().Name != "Assembly-CSharp") continue; + wheelType = a.GetType("Wheel"); + break; + } + if (wheelType == null) { MscoLog.Warning("[MSCO] Wheel type not found - remote wheels will not spin"); return false; } + modelField = wheelType.GetField("model", BindingFlags.Public | BindingFlags.Instance); + radiusField = wheelType.GetField("radius", BindingFlags.Public | BindingFlags.Instance); + if (modelField == null) { wheelType = null; return false; } + return true; + } + } +} diff --git a/WorldVarSync.cs b/WorldVarSync.cs new file mode 100644 index 0000000..f84ec36 --- /dev/null +++ b/WorldVarSync.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using HutongGames.PlayMaker; +using UnityEngine; + +namespace MSCO +{ + /// + /// Jobs, Aufträge und Laden: deren Fortschritt steht in PlayMaker-FSMs (z.B. "Auftrag angenommen", Anzahl + /// geleerter Klärgruben, bestellte Teile). Übertragen werden nur Ganzzahlen und Schalter - keine Kommazahlen + /// (Timer, Positionen), die jeder selbst weiterzählt. Wer etwas ändert, schickt es; Werte, die ständig hin und her + /// springen (interne Zähler), werden nach kurzer Zeit nicht mehr übertragen. + /// + public class WorldVarSync + { + /// Wurzelobjekte mit Jobs/Aufträgen/Laden. + public static readonly string[] Roots = { "JOBS", "STORE", "REPAIRSHOP", "INSPECTION", "LANDFILL", "DANCEHALL", "COTTAGE", "RALLY" }; + + const float ScanInterval = 15f; + const float CheckInterval = 0.5f; + const float MinResend = 2f; // höchstens so oft pro Wert + const int FlipLimit = 6; // so viele Änderungen ... + const float FlipWindow = 30f; // ... in dieser Zeit = interner Zähler, nicht mehr übertragen + const int MaxPerMessage = 200; + + class Var + { + public int Key; + public NamedVariable Variable; + public byte Type; // 2 int, 3 bool + public int LastI; + public bool LastB; + public float LastSent = -100f; + public float WindowStart; + public int Flips; + public bool Muted; + } + + readonly Dictionary vars = new Dictionary(); + readonly HashSet scanned = new HashSet(); + readonly List outgoing = new List(); + float nextScan, nextCheck; + + public int Count { get { return vars.Count; } } + + public void Reset() + { + vars.Clear(); + scanned.Clear(); + nextScan = 0f; + } + + public void Update(Action send) + { + if (Time.time >= nextScan) + { + nextScan = Time.time + ScanInterval; + Scan(); + } + if (Time.time < nextCheck) return; + nextCheck = Time.time + CheckInterval; + + outgoing.Clear(); + foreach (Var v in vars.Values) + { + if (v.Muted || !Changed(v)) continue; + if (Time.time - v.LastSent < MinResend) continue; // später nochmal prüfen + Remember(v); + if (Time.time - v.WindowStart > FlipWindow) { v.WindowStart = Time.time; v.Flips = 0; } + if (++v.Flips > FlipLimit) { v.Muted = true; continue; } + v.LastSent = Time.time; + outgoing.Add(v); + } + + for (int start = 0; start < outgoing.Count; start += MaxPerMessage) + { + int from = start, count = Math.Min(MaxPerMessage, outgoing.Count - start); + send(MsgType.WorldVars, NetSession.Build(w => + { + w.Write(count); + for (int i = from; i < from + count; i++) + { + Var v = outgoing[i]; + w.Write(v.Key); + w.Write(v.Type); + if (v.Type == 2) w.Write(v.LastI); else w.Write(v.LastB); + } + })); + } + } + + public void Handle(BinaryReader r) + { + int count = r.ReadInt32(); + for (int i = 0; i < count; i++) + { + int key = r.ReadInt32(); + byte type = r.ReadByte(); + int iv = 0; bool bv = false; + if (type == 2) iv = r.ReadInt32(); else if (type == 3) bv = r.ReadBoolean(); else return; + Var v; + if (!vars.TryGetValue(key, out v) || v.Type != type) continue; + if (type == 2) ((FsmInt)v.Variable).Value = iv; else ((FsmBool)v.Variable).Value = bv; + Remember(v); // nicht zurückschicken + } + } + + void Scan() + { + int before = vars.Count; + foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f)) + { + if (fsm == null || !scanned.Add(fsm)) continue; + if (Array.IndexOf(Roots, fsm.transform.root.name) < 0) continue; + string prefix = FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName + "|"; + FsmVariables fv = fsm.FsmVariables; + foreach (FsmInt n in fv.IntVariables) Register(prefix, n, 2); + foreach (FsmBool b in fv.BoolVariables) Register(prefix, b, 3); + } + if (vars.Count != before) MscoLog.Info("[MSCO] Jobs/shop: " + vars.Count + " values synced"); + } + + void Register(string prefix, NamedVariable variable, byte type) + { + if (variable == null || string.IsNullOrEmpty(variable.Name)) return; + int key = Hash(prefix + variable.Name); + if (vars.ContainsKey(key)) return; + var v = new Var { Key = key, Variable = variable, Type = type }; + Remember(v); + vars[key] = v; + } + + static bool Changed(Var v) + { + return v.Type == 2 ? ((FsmInt)v.Variable).Value != v.LastI : ((FsmBool)v.Variable).Value != v.LastB; + } + + static void Remember(Var v) + { + if (v.Type == 2) v.LastI = ((FsmInt)v.Variable).Value; else v.LastB = ((FsmBool)v.Variable).Value; + } + + static int Hash(string s) + { + unchecked + { + uint h = 2166136261; + foreach (byte c in Encoding.UTF8.GetBytes(s)) { h ^= c; h *= 16777619; } + return (int)h; + } + } + } +}