From 526f9e9c7731c9024547cef01ed83f1a4cc0eb2e Mon Sep 17 00:00:00 2001 From: MrMaho Date: Sun, 27 Sep 2026 16:59:01 +0200 Subject: [PATCH] Door/hood sync via hinge spring instead of teleport (fixes NaN crash and parts falling off); join/leave toasts Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++ HingeSync.cs | 100 +++++++++++++++++++++++++----------------- MSCO.csproj | 1 + MultiplayerManager.cs | 13 ++++-- NetSession.cs | 2 +- Toasts.cs | 100 ++++++++++++++++++++++++++++++++++++++++++ WheelSpin.cs | 1 + 7 files changed, 178 insertions(+), 44 deletions(-) create mode 100644 Toasts.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 15cce75..2078430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ Server und Mod müssen beide 2.8 sein (Server: `git pull && docker compose up -d - **Backups** mit Datum/Uhrzeit, **RESTORE** holt einen älteren Stand zurück (der aktuelle wird vorher gesichert) - Jede Aktion mit Sicherheitsabfrage; nur für Admins (vom Server geprüft) +**Hinweise** +- Kleine Benachrichtigung oben rechts, wenn jemand beitritt oder das Spiel verlässt + **Trennen & Neu-Verbinden** - Gekickt, gebannt, Server beendet, Verbindung verloren: eigenes Fenster in der Bildmitte mit **Grund** und OK (im Spiel wird der Grund vorher 2,5 s groß angezeigt) @@ -30,6 +33,8 @@ Server und Mod müssen beide 2.8 sein (Server: `git pull && docker compose up -d Nach Kick, Ban oder eigenem Trennen wird nicht neu verbunden **Behoben** +- Absturz („NaN … collider“) und abfallende Teile am Satsuma: Türen/Haube/Kofferraum wurden gegen das Scharnier an + ihre Position teleportiert – das erzeugte riesige Kräfte. Jetzt zieht die Feder des Scharniers sie sanft auf den Winkel - Beim anderen Spieler „orgelte“ der Anlasser endlos: erzwungene Geräusche/Lichter werden losgelassen, sobald keine Daten mehr vom Fahrer kommen - Beim Neuladen der Welt wurden herumliegende Objekte als „verbraucht“ gemeldet und bei allen gelöscht. Jetzt werden diff --git a/HingeSync.cs b/HingeSync.cs index 404e791..4a68575 100644 --- a/HingeSync.cs +++ b/HingeSync.cs @@ -7,8 +7,10 @@ namespace MSCO { /// /// Türen, Motorhaube und Kofferraum an Fahrzeugen: hängen mit einem Scharnier (HingeJoint) an der Karosserie - /// und werden deshalb nicht vom Objekt-Sync bewegt. Übertragen wird die Stellung relativ zum Auto, damit eine - /// offene Tür beim anderen genauso weit offen steht. + /// und werden deshalb nicht vom Objekt-Sync bewegt. Übertragen wird der Öffnungswinkel des Scharniers. + /// Beim Empfänger wird die Tür NICHT hingesetzt (Teleportieren gegen das Gelenk erzeugt riesige Kräfte - Teile + /// reißen ab, im schlimmsten Fall stürzt die Physik ab), sondern die Feder des Scharniers zieht sie sanft auf + /// den Winkel. Danach bekommt das Scharnier seine ursprüngliche Einstellung zurück. /// Wer sendet: Fährt jemand das Auto, der Fahrer. Ist es frei, wer gerade daneben steht (Tür auf/zu gemacht). /// public class HingeSync @@ -17,19 +19,23 @@ namespace MSCO const float SendInterval = 0.1f; const float MinAngle = 2f; // Grad - kleiner ist Zittern const float NearRange = 4f; // so nah muss man an einer Tür stehen, um sie zu bewegen - const float ApplyHold = 1.5f; // so lange nach einer Nachricht die Stellung halten und selbst nicht senden + const float ApplyHold = 1.5f; // so lange nach einer Nachricht die Feder halten und selbst nicht senden + const float SpringForce = 120f; + const float SpringDamper = 12f; class Hinge { public int Key; public string Root; + public HingeJoint Joint; public Rigidbody Rb; - public Rigidbody Body; // woran das Scharnier hängt - public Quaternion SentRot; + public float SentAngle; public bool HasSent; - public Quaternion TargetRot; - public Vector3 TargetPos; + public float TargetAngle; public float TargetTime = -100f; + public bool Driving; // Feder gerade von uns gesetzt + public bool OrigUseSpring; + public JointSpring OrigSpring; } readonly Dictionary hinges = new Dictionary(); @@ -41,6 +47,7 @@ namespace MSCO public void Reset() { + foreach (Hinge h in hinges.Values) StopDriving(h); hinges.Clear(); seen.Clear(); nextScan = 0f; @@ -60,15 +67,16 @@ namespace MSCO outgoing.Clear(); foreach (Hinge h in hinges.Values) { - if (h.Rb == null || h.Body == null || !h.Rb.gameObject.activeInHierarchy) continue; + if (h.Joint == null || h.Rb == null || !h.Rb.gameObject.activeInHierarchy) continue; if (Time.time - h.TargetTime < ApplyHold) continue; // gerade von anderen gesetzt int owner = objects.VehicleOwner(h.Root); if (owner != SyncedBody.NoOwner && owner != localId) continue; // der Fahrer schickt if (owner == SyncedBody.NoOwner && (h.Rb.position - playerPos).sqrMagnitude > NearRange * NearRange) continue; - Quaternion rel = Quaternion.Inverse(h.Body.rotation) * h.Rb.rotation; - if (h.HasSent && Quaternion.Angle(rel, h.SentRot) < MinAngle) continue; - h.SentRot = rel; + float angle = h.Joint.angle; + if (float.IsNaN(angle) || float.IsInfinity(angle)) continue; + if (h.HasSent && Mathf.Abs(angle - h.SentAngle) < MinAngle) continue; + h.SentAngle = angle; h.HasSent = true; outgoing.Add(h); } @@ -77,14 +85,7 @@ namespace MSCO send(MsgType.HingeStates, NetSession.Build(w => { w.Write(outgoing.Count); - foreach (Hinge h in outgoing) - { - Vector3 p = h.Body.transform.InverseTransformPoint(h.Rb.position); - Quaternion q = h.SentRot; - w.Write(h.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); - } + foreach (Hinge h in outgoing) { w.Write(h.Key); w.Write(h.SentAngle); } })); } @@ -94,35 +95,57 @@ namespace MSCO 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()); + float angle = r.ReadSingle(); Hinge h; if (!hinges.TryGetValue(key, out h)) { nextScan = 0f; continue; } - if (float.IsNaN(p.x) || float.IsNaN(q.w) || p.sqrMagnitude > 25f) continue; // ein Scharnier ist nie weit weg - h.TargetPos = p; - h.TargetRot = q; + if (float.IsNaN(angle) || float.IsInfinity(angle) || Mathf.Abs(angle) > 180f) continue; + h.TargetAngle = angle; h.TargetTime = Time.time; - h.SentRot = q; // nicht als eigene Bewegung zurückschicken + h.SentAngle = angle; // nicht als eigene Bewegung zurückschicken h.HasSent = true; } } - /// Empfangene Stellungen halten (die Physik würde die Tür sonst beim Fahren weiterschwingen lassen). + /// Feder des Scharniers auf den empfangenen Winkel stellen bzw. wieder zurückgeben. public void FixedUpdate() { foreach (Hinge h in hinges.Values) { - if (Time.time - h.TargetTime > ApplyHold || h.Rb == null || h.Body == null) continue; - Quaternion rot = h.Body.rotation * h.TargetRot; - Vector3 pos = h.Body.transform.TransformPoint(h.TargetPos); - float t = Mathf.Clamp01(Time.fixedDeltaTime * 15f); - h.Rb.MoveRotation(Quaternion.Slerp(h.Rb.rotation, rot, t)); - h.Rb.MovePosition(Vector3.Lerp(h.Rb.position, pos, t)); - h.Rb.angularVelocity = Vector3.zero; - h.Rb.velocity = h.Body.velocity; + if (h.Joint == null || h.Rb == null) continue; + bool fresh = Time.time - h.TargetTime < ApplyHold && !h.Rb.isKinematic; + if (!fresh) { StopDriving(h); continue; } + + if (!h.Driving) + { + h.OrigUseSpring = h.Joint.useSpring; + h.OrigSpring = h.Joint.spring; + h.Driving = true; + } + float target = h.TargetAngle; + if (h.Joint.useLimits) + { + JointLimits l = h.Joint.limits; + target = Mathf.Clamp(target, Mathf.Min(l.min, l.max), Mathf.Max(l.min, l.max)); + } + JointSpring s = h.Joint.spring; + s.spring = SpringForce; + s.damper = SpringDamper; + s.targetPosition = target; + h.Joint.spring = s; + h.Joint.useSpring = true; + h.Rb.WakeUp(); } } + static void StopDriving(Hinge h) + { + if (!h.Driving) return; + h.Driving = false; + if (h.Joint == null) return; + h.Joint.spring = h.OrigSpring; + h.Joint.useSpring = h.OrigUseSpring; + } + void Scan() { int before = hinges.Count; @@ -132,17 +155,14 @@ namespace MSCO Rigidbody rb = j.GetComponent(); Rigidbody body = j.connectedBody; if (rb == null || body == null || !IsVehicle(body.transform.root.name)) continue; - int key = Hash(FsmSync.PathOf(j.transform)); + int key = Hash(FsmSync.PathOf(j.transform) + "|" + FsmSync.PathOf(body.transform)); if (hinges.ContainsKey(key)) continue; - hinges[key] = new Hinge { Key = key, Root = body.transform.root.name, Rb = rb, Body = body }; + hinges[key] = new Hinge { Key = key, Root = body.transform.root.name, Joint = j, Rb = rb }; } // Abgebaute Teile (Scharnier weg) vergessen var gone = new List(); foreach (KeyValuePair kv in hinges) - { - Hinge h = kv.Value; - if (h.Rb == null || h.Body == null || h.Rb.GetComponent() == null) gone.Add(kv.Key); - } + if (kv.Value.Joint == null || kv.Value.Rb == null) gone.Add(kv.Key); foreach (int k in gone) hinges.Remove(k); if (gone.Count > 0) seen.RemoveWhere(x => x == null); if (hinges.Count != before) MscoLog.Info("[MSCO] Vehicle doors/hood/trunk: " + hinges.Count + " synced"); diff --git a/MSCO.csproj b/MSCO.csproj index e121b68..01907b7 100644 --- a/MSCO.csproj +++ b/MSCO.csproj @@ -79,6 +79,7 @@ + diff --git a/MultiplayerManager.cs b/MultiplayerManager.cs index 0895c22..44d3518 100644 --- a/MultiplayerManager.cs +++ b/MultiplayerManager.cs @@ -65,6 +65,10 @@ namespace MSCO readonly TrafficSync traffic = new TrafficSync(); readonly HingeSync hinges = new HingeSync(); readonly ControlSync controls = new ControlSync(); + readonly Toasts toasts = new Toasts(); + + void ToastJoined(string name) { toasts.Add(name, "joined the game", UiTheme.Online); } + void ToastLeft(string name) { toasts.Add(name, "left the game", UiTheme.Muted); } 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 @@ -928,6 +932,7 @@ namespace MSCO RemovePlayer(peerId); session.SendToAll(MsgType.PlayerLeft, NetSession.Build(w => w.Write(peerId))); SetStatus(p.Name + " left (" + reason + ")"); + ToastLeft(p.Name); } string serverReason; // Grund, den der Server/Host beim Trennen mitgeschickt hat @@ -1144,6 +1149,7 @@ namespace MSCO foreach (PlayerInfo p in list) { w.Write(p.Id); w.Write(p.Name); } })); session.SendToAll(MsgType.PlayerJoined, NetSession.Build(w => { w.Write(peerId); w.Write(name); }), peerId); + ToastJoined(name); if (MenuHelper.InGame) { @@ -1216,6 +1222,7 @@ namespace MSCO string name = r.ReadString(); AddPlayer(id, name, false); SetStatus(name + " joined"); + ToastJoined(name); break; } @@ -1223,7 +1230,7 @@ namespace MSCO { int id = r.ReadInt32(); PlayerInfo p; - if (players.TryGetValue(id, out p)) SetStatus(p.Name + " left"); + if (players.TryGetValue(id, out p)) { SetStatus(p.Name + " left"); ToastLeft(p.Name); } RemovePlayer(id); break; } @@ -1960,7 +1967,7 @@ namespace MSCO if (MenuHelper.InMenu) { lobby.Draw(this); - DrawPing(); + DrawPing(); toasts.Draw(); overlays.Draw(this, false); return; } @@ -1977,7 +1984,7 @@ namespace MSCO } if (session != null) DrawHud(); - DrawPing(); + DrawPing(); toasts.Draw(); overlays.Draw(this, session != null); } diff --git a/NetSession.cs b/NetSession.cs index 19ec529..d2ea0bc 100644 --- a/NetSession.cs +++ b/NetSession.cs @@ -50,7 +50,7 @@ namespace MSCO 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] - HingeStates = 42, // Autotüren/Haube/Kofferraum: int n, je [int Schlüssel, 3 float Pos, 4 float Rot relativ zum Auto] + HingeStates = 42, // Autotüren/Haube/Kofferraum: int n, je [int Schlüssel, float Scharnier-Winkel] VehicleControls = 43 // Zündung/Motor/Licht ...: int n, je [int FSM-Schlüssel, string Zustand] } diff --git a/Toasts.cs b/Toasts.cs new file mode 100644 index 0000000..ce49370 --- /dev/null +++ b/Toasts.cs @@ -0,0 +1,100 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace MSCO +{ + /// + /// Kleine Hinweise oben rechts (unter der Ping-Anzeige), z.B. "Bob joined" / "Bob left". + /// Gleiten von rechts herein, bleiben ein paar Sekunden und blenden dann aus. Höchstens 4 gleichzeitig. + /// + public class Toasts + { + const float Lifetime = 4.5f; + const float SlideIn = 0.25f; + const float FadeOut = 0.6f; + const float Width = 250f; + const float Height = 36f; + const int MaxShown = 4; + + class Toast + { + public string Text; + public string Detail; + public Color Dot; + public float Start; + } + + readonly List toasts = new List(); + GUIStyle box, title, detail; + Texture2D dotTex; + + public void Add(string text, string detail, Color dot) + { + toasts.Add(new Toast { Text = text, Detail = detail, Dot = dot, Start = Time.realtimeSinceStartup }); + if (toasts.Count > MaxShown) toasts.RemoveAt(0); + } + + public void Draw() + { + if (toasts.Count == 0) return; + EnsureStyles(); + float now = Time.realtimeSinceStartup; + toasts.RemoveAll(t => now - t.Start > Lifetime); + + Color before = GUI.color; + float y = 40f; // unter der Ping-Anzeige + for (int i = toasts.Count - 1; i >= 0; i--) // neueste oben + { + Toast t = toasts[i]; + float age = now - t.Start; + float slide = Mathf.Clamp01(age / SlideIn); + slide = 1f - (1f - slide) * (1f - slide); // weich abbremsen + float alpha = Mathf.Clamp01((Lifetime - age) / FadeOut); + + var r = new Rect(Screen.width - 12 - Width + (1f - slide) * (Width + 20f), y, Width, Height); + GUI.color = new Color(1f, 1f, 1f, alpha); + GUI.Box(r, GUIContent.none, box); + + GUI.color = new Color(t.Dot.r, t.Dot.g, t.Dot.b, alpha); + GUI.DrawTexture(new Rect(r.x + 14, r.y + Height / 2f - 4, 8, 8), dotTex); + + GUI.color = new Color(1f, 1f, 1f, alpha); + GUI.Label(new Rect(r.x + 32, r.y, r.width - 40, Height), t.Text, title); + if (!string.IsNullOrEmpty(t.Detail)) + { + float w = title.CalcSize(new GUIContent(t.Text)).x; + GUI.Label(new Rect(r.x + 32 + w + 6, r.y, r.width - 46 - w, Height), t.Detail, detail); + } + y += Height + 6f; + } + GUI.color = before; + } + + void EnsureStyles() + { + if (box != null && box.normal.background != null) return; + box = UiTheme.Panel9(new Color(0.06f, 0.06f, 0.07f, 0.88f), 6); + title = new GUIStyle(GUI.skin.label) { fontSize = 13, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + title.normal.textColor = Color.white; + title.wordWrap = false; + detail = new GUIStyle(title) { fontStyle = FontStyle.Normal, fontSize = 12 }; + detail.normal.textColor = UiTheme.Muted; + dotTex = RoundDot(16); + } + + static Texture2D RoundDot(int size) + { + var t = new Texture2D(size, size, TextureFormat.ARGB32, false); + float c = (size - 1) / 2f; + for (int x = 0; x < size; x++) + for (int y = 0; y < size; y++) + { + float d = Mathf.Sqrt((x - c) * (x - c) + (y - c) * (y - c)); + t.SetPixel(x, y, new Color(1f, 1f, 1f, Mathf.Clamp01(c + 0.5f - d))); + } + t.Apply(); + t.hideFlags = HideFlags.HideAndDontSave; + return t; + } + } +} diff --git a/WheelSpin.cs b/WheelSpin.cs index b2e8322..f4b7030 100644 --- a/WheelSpin.cs +++ b/WheelSpin.cs @@ -58,6 +58,7 @@ namespace MSCO 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); + if (float.IsNaN(w.Angle) || float.IsInfinity(w.Angle)) w.Angle = 0f; // Hat das Spiel das Modell in diesem Frame selbst gedreht (z.B. Lenkung), ist das die neue Basis Quaternion current = w.Model.localRotation;