From ce2fbdfc102c96f4be3be1449e6b1a1841948679 Mon Sep 17 00:00:00 2001 From: Nico Meister Date: Mon, 28 Sep 2026 20:52:56 +0200 Subject: [PATCH] Passenger/driver sit still in moving cars: seat pose sent relative to the vehicle (backward-compatible), no backward pull; remote cars/traffic extrapolate in physics time and interpolate; UI polish (shadows, borders, gradient buttons, server list hover, fill bars, colored ping) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 14 +++++++ LobbyUI.cs | 55 +++++++++++++++++++++----- MultiplayerManager.cs | 19 ++++++++- NetSession.cs | 31 +++++++++++++-- ObjectSync.cs | 30 +++++++++++--- Overlays.cs | 6 ++- Passenger.cs | 4 ++ RemotePlayer.cs | 41 +++++++++++++++++++ Toasts.cs | 3 +- TrafficSync.cs | 4 +- UiTheme.cs | 91 +++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 274 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 471702a..2411f4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ - **Tags** der Server (z.B. `OFFICIAL`) erscheinen im Server-Profil; die Suche findet auch Tags - Braucht keinen neuen Server: die Infos kommen vom Master (msco.mbm.wtf) und funktionieren mit 2.8-Servern +**Mitfahren ohne Ruckeln** +- Fahrer und Beifahrer sitzen beim anderen fest auf dem Sitz: die Figur wird relativ zum Auto übertragen und geglättet, + statt in Weltkoordinaten hinter dem fahrenden Auto herzuhinken (vorher wurde der Beifahrer bei Tempo nach hinten gezogen) +- Der Beifahrersitz wird jetzt aus der richtigen Fahrerposition berechnet (vorher aus der hinterherhinkenden Figur) +- Autos anderer Spieler ruckeln nicht mehr bei jedem Netzwerkpaket zurück (Vorausberechnung in Physik-Zeit) und werden + zwischen den Physik-Schritten flüssig bewegt – auch der NPC-Verkehr +- Kompatibel: ältere Mods und der Server ignorieren die neuen Sitz-Daten + +**Schönere Oberfläche** +- Fenster (Launcher, Konsole, Admin-Menü, Hinweise) mit weichem Schatten und feinem Rand, orange Knöpfe mit Verlauf +- Server-Browser: mehr Platz für die Liste, Zeilen leuchten beim Überfahren auf, Füllbalken für die Spielerzahl, + Ping farbig (grün/gelb/rot), goldener Streifen bei hervorgehobenen Servern +- Seitenleiste mit Trennlinie, Menüpunkte werden beim Überfahren hell + ## 2.8 Server und Mod müssen beide 2.8 sein (Server: `git pull && docker compose up -d --build`). diff --git a/LobbyUI.cs b/LobbyUI.cs index 18c7c9d..7c4352d 100644 --- a/LobbyUI.cs +++ b/LobbyUI.cs @@ -13,7 +13,7 @@ namespace MSCO enum Page { P2P, ServerBrowser, Favorites } const float Width = 760f; - const float Height = 460f; + const float Height = 500f; const float SidebarWidth = 190f; const float StatusHeight = 36f; @@ -51,6 +51,8 @@ namespace MSCO bool stylesReady; Texture2D texBackground, texSidebar, texAccent, texOnline, texPending, texMuted, texRow; + Texture2D texDivider, texGold, texBarBack, texBarFill, texBarFull; + GUIStyle rowHoverBox, pingGoodStyle, pingMidStyle, pingBadStyle; List shownAll, shownServers; // Stand des letzten Layout-Durchgangs string layoutError; GUIStyle dangerStyle; @@ -87,6 +89,7 @@ namespace MSCO var window = new Rect((Screen.width - Width) / 2f, (Screen.height - Height) / 2f, Width, Height); ScreenRect = window; + UiTheme.DrawShadow(window); GUI.Box(window, GUIContent.none, windowBox); DrawSidebar(new Rect(window.x, window.y, SidebarWidth, Height - StatusHeight)); @@ -120,6 +123,7 @@ namespace MSCO } GUI.Label(new Rect(textX, r.y + 24, r.xMax - textX, 40), "MSCO", logoStyle); GUI.Label(new Rect(r.x + 22, r.y + 70, r.width - 30, 20), "MY SUMMER CAR ONLINE", taglineStyle); + GUI.DrawTexture(new Rect(r.x + 20, r.y + 98, r.width - 40, 1), texDivider); float y = r.y + 110; if (NavButton(new Rect(r.x, y, r.width, 42), "P2P", page == Page.P2P)) page = Page.P2P; @@ -234,7 +238,7 @@ namespace MSCO GUI.Label(new Rect(header.x + header.width * 0.62f, header.y, 70, header.height), "TYPE", headerCellStyle); GUI.Label(new Rect(header.x + header.width * 0.76f, header.y, 50, header.height), "PING", headerCellStyle); - serverScroll = GUILayout.BeginScrollView(serverScroll, GUILayout.Height(140)); + serverScroll = GUILayout.BeginScrollView(serverScroll, GUILayout.Height(180)); if (shownError != null) GUILayout.Label(shownError, textStyle); if (servers.Count == 0) GUILayout.Label(browser.Loading ? "Loading servers..." : all.Count > 0 ? "No servers match your filters." : "No servers online right now.", textStyle); @@ -242,7 +246,10 @@ namespace MSCO { Rect row = GUILayoutUtility.GetRect(10, 34, GUILayout.ExpandWidth(true)); // Hervorgehobene Server (Keymaster-Admin) stehen oben - der Master liefert sie zuerst - GUI.Box(new Rect(row.x, row.y + 2, row.width, row.height - 4), GUIContent.none, s.Featured ? featuredRowBox : rowBox); + var rowRect = new Rect(row.x, row.y + 2, row.width, row.height - 4); + bool hover = rowRect.Contains(Event.current.mousePosition); + GUI.Box(rowRect, GUIContent.none, s.Featured ? featuredRowBox : hover ? rowHoverBox : rowBox); + if (s.Featured) GUI.DrawTexture(new Rect(row.x, row.y + 8, 3, row.height - 16), texGold); // Herz: als Favorit merken / entfernen (vor dem Zeilen-Knopf, damit es den Klick bekommt) Heart(new Rect(row.xMax - 58, row.y + 7, 20, 20), s); @@ -266,9 +273,14 @@ namespace MSCO nameX += Pill(nameX, row, "v" + s.Version, pillStyle); // abweichende Version } GUI.Label(new Rect(nameX, row.y, row.x + row.width * 0.49f - nameX, row.height), s.Name, rowNameStyle); - GUI.Label(new Rect(row.x + row.width * 0.5f, row.y, 70, row.height), s.Players + "/" + s.MaxPlayers, rowNameStyle); + // Spielerzahl mit Füllbalken (wie auf der Website) + float px = row.x + row.width * 0.5f; + GUI.Label(new Rect(px, row.y - 3, 70, row.height), s.Players + "/" + s.MaxPlayers, rowNameStyle); + float fill = s.MaxPlayers > 0 ? Mathf.Clamp01((float)s.Players / s.MaxPlayers) : 0f; + GUI.DrawTexture(new Rect(px, row.y + 24, 48, 3), texBarBack); + GUI.DrawTexture(new Rect(px, row.y + 24, Mathf.Max(2f, 48 * fill), 3), fill >= 1f ? texBarFull : texBarFill); 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); + GUI.Label(new Rect(row.x + row.width * 0.76f, row.y, 60, row.height), s.Ping < 0 ? "..." : s.Ping + " ms", PingStyle(s.Ping)); GUI.Label(new Rect(row.xMax - 30, row.y, 20, row.height), "›", headerCellStyle); // Hinweis: Profil öffnen } GUILayout.EndScrollView(); @@ -354,7 +366,7 @@ namespace MSCO GUILayout.Space(8); List favs = Favorites.All; - favoritesScroll = GUILayout.BeginScrollView(favoritesScroll, GUILayout.Height(220)); + favoritesScroll = GUILayout.BeginScrollView(favoritesScroll, GUILayout.Height(250)); if (favs.Count == 0) GUILayout.Label("No favorites yet. Click the heart next to a server in the Server Browser, or add one by address below.", textStyle); Favorites.Favorite remove = null; @@ -562,7 +574,7 @@ namespace MSCO InfoLine("VERSION", "v" + s.Version + (versionOk ? "" : " (you: v" + MSCO.ModVersion + ")"), versionOk ? rowNameStyle : badgeStyle); InfoLine("STEAM", s.RequireSteam ? "Required" : "Not required", rowNameStyle); InfoLine("ONLINE FOR", s.UptimeSeconds >= 0 ? FormatUptime(s.UptimeSeconds) : "-", rowNameStyle); - InfoLine("PING", s.Ping < 0 ? "-" : s.Ping + " ms", rowNameStyle); + InfoLine("PING", s.Ping < 0 ? "-" : s.Ping + " ms", PingStyle(s.Ping)); InfoLine("ADDRESS", s.Address + ":" + s.Port, rowNameStyle); GUILayout.EndVertical(); GUILayout.Space(16); @@ -617,6 +629,13 @@ namespace MSCO GUILayout.Space(2); } + /// Ping farbig: grün gut, gelb geht so, rot schlecht. + GUIStyle PingStyle(int ping) + { + if (ping < 0) return rowNameStyle; + return ping < 80 ? pingGoodStyle : ping < 160 ? pingMidStyle : pingBadStyle; + } + static string FormatUptime(int seconds) { int days = seconds / 86400, hours = seconds / 3600 % 24, minutes = seconds / 60 % 60; @@ -698,6 +717,7 @@ namespace MSCO var r = new Rect((Screen.width - PromptWidth) / 2f, (Screen.height - PromptHeight) / 2f, PromptWidth, PromptHeight); ScreenRect = new Rect(0, 0, Screen.width, Screen.height); // Spielmenü dahinter komplett sperren + UiTheme.DrawShadow(r); GUI.Box(r, GUIContent.none, windowBox); string title = reconnecting ? ((mp.ReconnectMessage ?? "").StartsWith("Switching") ? "SWITCHING SERVER" : "RECONNECTING") @@ -783,6 +803,7 @@ namespace MSCO { var r = new Rect((Screen.width - 220) / 2f, 0, 220, 30); ScreenRect = r; + UiTheme.DrawShadow(r); GUI.Box(r, GUIContent.none, sidebarBox); GUI.Box(new Rect(r.x + 10, r.yMax - 3, r.width - 20, 3), GUIContent.none, accentBox); Texture2D dot = mp.IsFullyConnected ? texOnline : mp.IsInSession || mp.IsReconnecting ? texPending : texMuted; @@ -809,9 +830,16 @@ namespace MSCO texPending = UiTheme.Rounded(Pending, 4); texMuted = UiTheme.Rounded(Muted, 4); texRow = Solid(Field); - windowBox = UiTheme.Panel9(Background, 10); - sidebarBox = UiTheme.Panel9(Sidebar, 10); + // Fenster und Seitenleiste mit feinem Rand (Seitenleiste: ihr Rand trennt sie vom Inhalt) + windowBox = UiTheme.Card(Background, 10); + sidebarBox = UiTheme.Card(Sidebar, 10); rowBox = UiTheme.Panel9(Field, UiTheme.Radius); + rowHoverBox = UiTheme.Card(FieldFocus, UiTheme.Radius); + texDivider = Solid(new Color(1f, 1f, 1f, 0.07f)); + texGold = Solid(Gold); + texBarBack = Solid(new Color(1f, 1f, 1f, 0.1f)); + texBarFill = Solid(Online); + texBarFull = Solid(UiTheme.Danger); accentBox = UiTheme.Panel9(Accent, 1); pillStyle = UiTheme.Panel9(new Color(Accent.r, Accent.g, Accent.b, 0.18f), 4); @@ -846,6 +874,12 @@ namespace MSCO headerCellStyle.alignment = TextAnchor.MiddleLeft; rowNameStyle = Label(14, Color.white, FontStyle.Normal); rowNameStyle.alignment = TextAnchor.MiddleLeft; + pingGoodStyle = new GUIStyle(rowNameStyle); + pingGoodStyle.normal.textColor = Online; + pingMidStyle = new GUIStyle(rowNameStyle); + pingMidStyle.normal.textColor = Pending; + pingBadStyle = new GUIStyle(rowNameStyle); + pingBadStyle.normal.textColor = UiTheme.DangerHover; rowTagStyle = Label(11, Accent, FontStyle.Bold); rowTagStyle.alignment = TextAnchor.MiddleRight; badgeStyle = new GUIStyle(rowTagStyle); @@ -859,6 +893,7 @@ namespace MSCO toggleStyle.fontStyle = FontStyle.Bold; navStyle = Button(Sidebar, Secondary, Muted, 14, FontStyle.Normal); + navStyle.hover.textColor = Color.white; // Hover: Text hell (vorher blieb er grau) navStyle.alignment = TextAnchor.MiddleLeft; navStyle.padding = new RectOffset(26, 10, 0, 0); navActiveStyle = Button(Secondary, Secondary, Color.white, 14, FontStyle.Bold); @@ -866,9 +901,11 @@ namespace MSCO navActiveStyle.padding = new RectOffset(26, 10, 0, 0); primaryStyle = Button(Accent, AccentHover, Color.white, 14, FontStyle.Bold); + UiTheme.ApplyPrimaryGradient(primaryStyle); dangerStyle = Button(UiTheme.Danger, UiTheme.DangerHover, Color.white, 13, FontStyle.Bold); secondaryStyle = Button(Secondary, SecondaryHover, Color.white, 14, FontStyle.Bold); tabStyle = Button(Sidebar, Secondary, Muted, 11, FontStyle.Bold); + tabStyle.hover.textColor = Color.white; fieldStyle = UiTheme.Field(14); diff --git a/MultiplayerManager.cs b/MultiplayerManager.cs index 21fcade..15a1c09 100644 --- a/MultiplayerManager.cs +++ b/MultiplayerManager.cs @@ -1789,6 +1789,10 @@ namespace MSCO if (p.Avatar == null) continue; try { + // Sitzt der Mitspieler in einem Fahrzeug, die Figur an unsere Kopie dieses Fahrzeugs hängen + int seatId = p.Avatar.LastState.SeatId; + SyncedBody seat = seatId != 0 ? objects.Get(seatId) : null; + p.Avatar.Seat = seat != null && seat.Rb != null && seat.IsVehicle ? seat.Rb.transform : null; p.Avatar.Tick(Time.unscaledDeltaTime); // Gehaltenes Item an die Hand der Figur setzen @@ -2057,9 +2061,22 @@ namespace MSCO CamRot = head.rotation, Seated = passenger.IsRiding || objects.IsDriving, FeetY = GroundBelowPlayer(head.position), - HeldLocalRot = Quaternion.identity + HeldLocalRot = Quaternion.identity, + SeatView = Quaternion.identity }; + // Im Fahrzeug: Kopf und Blick relativ zum Fahrzeug mitschicken + SyncedBody seat = passenger.IsRiding ? passenger.Vehicle : objects.Driven; + if (seat != null && seat.Rb != null) + { + Transform vt = seat.Rb.transform; + state.SeatId = seat.Id; + // Beifahrer: der Sitz ist bekannt - die Weltposition stammt noch vom letzten Frame, das Auto ist seitdem + // weitergefahren (bei Tempo ein halber Meter) + state.SeatHead = passenger.IsRiding ? passenger.SeatHeadLocal : vt.InverseTransformPoint(head.position); + state.SeatView = Quaternion.Inverse(vt.rotation) * head.rotation; + } + SyncedBody held = objects.Held; if (held != null) { diff --git a/NetSession.cs b/NetSession.cs index d47216a..a81598b 100644 --- a/NetSession.cs +++ b/NetSession.cs @@ -77,6 +77,12 @@ namespace MSCO public Quaternion HeldLocalRot; // relativ zur Kamera public bool Seated; // fährt oder sitzt als Beifahrer public float FeetY; // Bodenhöhe unter dem Spieler (für Ducken/Lehnen der Figur) + // Im Fahrzeug: Kopf und Blick relativ zum Fahrzeug (ID des SyncedBody, 0 = nicht im Fahrzeug). Damit sitzt die + // Figur beim anderen fest auf dem Sitz, statt in Weltkoordinaten hinter dem fahrenden Auto herzuhinken. + // Steht am Ende der Nachricht: ältere Versionen und der Server lesen es einfach nicht mit. + public int SeatId; + public Vector3 SeatHead; + public Quaternion SeatView; public byte[] Serialize() { @@ -90,9 +96,15 @@ namespace MSCO w.Write(s.Seated); w.Write(s.FeetY); w.Write(s.HeldId); - if (s.HeldId == 0) return; - Write(w, s.HeldLocalPos); - Write(w, s.HeldLocalRot); + if (s.HeldId != 0) + { + Write(w, s.HeldLocalPos); + Write(w, s.HeldLocalRot); + } + w.Write(s.SeatId); + if (s.SeatId == 0) return; + Write(w, s.SeatHead); + Write(w, s.SeatView); }); } @@ -107,13 +119,24 @@ namespace MSCO Seated = r.ReadBoolean(), FeetY = r.ReadSingle(), HeldId = r.ReadInt32(), - HeldLocalRot = Quaternion.identity + HeldLocalRot = Quaternion.identity, + SeatView = Quaternion.identity }; if (s.HeldId != 0) { s.HeldLocalPos = ReadVector(r); s.HeldLocalRot = ReadQuaternion(r); } + // Ältere Versionen senden keinen Sitz + if (r.BaseStream.Length - r.BaseStream.Position >= 4) + { + s.SeatId = r.ReadInt32(); + if (s.SeatId != 0) + { + s.SeatHead = ReadVector(r); + s.SeatView = ReadQuaternion(r); + } + } return s; } diff --git a/ObjectSync.cs b/ObjectSync.cs index 2c86afc..0ed1cf4 100644 --- a/ObjectSync.cs +++ b/ObjectSync.cs @@ -25,6 +25,8 @@ namespace MSCO public int OwnerId = NoOwner; public bool OwnerActive; // Besitzer hält/fährt es gerade aktiv public bool WasKinematic; // Zustand bevor ein anderer Spieler es übernommen hat + public RigidbodyInterpolation WasInterpolation; + public bool InterpolationSet; // Fahrzeug eines anderen: bei uns interpoliert (siehe FreezeForRemote) // Gelenke der angebauten Teile, solange ein anderer das Fahrzeug fährt: unzerbrechlich (vorherige Bruchkraft/-moment) public Dictionary HeldJoints; public float NextPartScan; @@ -42,7 +44,7 @@ namespace MSCO public Vector3 TargetPos; public Quaternion TargetRot; public Vector3 TargetVel; // Geschwindigkeit beim Besitzer: damit wird die Position vorausberechnet - public float TargetTime; // wann TargetPos ankam + public float TargetTime; // wann TargetPos ankam (Physik-Zeit, Time.fixedTime) public float LastRecvTime; public float AvatarHoldTime = -100f; // zuletzt von der Hand eines Mitspielers platziert @@ -209,6 +211,8 @@ namespace MSCO /// Fährt der lokale Spieler gerade selbst? public bool IsDriving { get; private set; } + /// Das Fahrzeug, das der lokale Spieler gerade fährt (sonst null). + public SyncedBody Driven { get; private set; } /// Fahrzeuge, die gerade ein anderer Spieler aktiv fährt. public List RemoteDrivenVehicles() @@ -315,6 +319,7 @@ namespace MSCO SyncedBody held = HeldBody(); SyncedBody driven = DrivenBody(); + Driven = driven; IsDriving = driven != null; bool motionClaimAllowed = Time.time - loadTime > MotionClaimGrace; @@ -387,7 +392,9 @@ namespace MSCO if (Time.time - b.AvatarHoldTime < 0.2f) continue; // sitzt gerade in der Hand eines Mitspielers // Dort, wo das Objekt beim Besitzer JETZT ungefähr ist: letzte Position + Geschwindigkeit * Alter. // Ohne das hinken schnelle Objekte (fahrende Autos) immer einen Paketabstand hinterher und ruckeln. - float age = Mathf.Min(Time.time - b.TargetTime, MaxExtrapolation); + // Beides in Physik-Zeit: mit der Bildzeit (Time.time beim Empfang) wurde das Alter in den ersten + // Physik-Schritten danach negativ - das Auto sprang bei jedem Paket ein Stück zurück (Ruckeln) + float age = Mathf.Clamp(Time.fixedTime - b.TargetTime, 0f, MaxExtrapolation); Vector3 predicted = b.TargetPos + b.TargetVel * age; if ((b.Rb.position - predicted).sqrMagnitude > SnapDistance * SnapDistance) { @@ -489,7 +496,15 @@ namespace MSCO { b.WasKinematic = b.Rb.isKinematic; b.Rb.isKinematic = true; - if (b.IsVehicle) HoldParts(b); + if (b.IsVehicle) + { + HoldParts(b); + // Physik läuft mit 50 Hz, das Bild öfter: ohne Interpolation springt das Auto nur in Physik-Schritten + // weiter - wer darin mitfährt, sieht die Welt ruckeln. Nur bei Fahrzeugen (Items setzt die Hand direkt). + b.WasInterpolation = b.Rb.interpolation; + b.InterpolationSet = true; + b.Rb.interpolation = RigidbodyInterpolation.Interpolate; + } } void HoldParts(SyncedBody b) @@ -508,7 +523,12 @@ namespace MSCO /// Wieder selbst simulieren: Auto zurück auf seinen Zustand, Gelenke wieder mit ihrer Bruchkraft. void RestoreKinematic(SyncedBody b) { - if (b.Rb != null) b.Rb.isKinematic = b.WasKinematic; + if (b.Rb != null) + { + b.Rb.isKinematic = b.WasKinematic; + if (b.InterpolationSet) b.Rb.interpolation = b.WasInterpolation; + } + b.InterpolationSet = false; if (b.HeldJoints == null) return; foreach (KeyValuePair kv in b.HeldJoints) { @@ -580,7 +600,7 @@ namespace MSCO b.TargetPos = pos; b.TargetRot = rot; b.TargetVel = vel.sqrMagnitude < 90f * 90f ? vel : Vector3.zero; - b.TargetTime = Time.time; + b.TargetTime = Time.fixedTime; } break; } diff --git a/Overlays.cs b/Overlays.cs index 9cfe5cf..01816f6 100644 --- a/Overlays.cs +++ b/Overlays.cs @@ -136,6 +136,7 @@ namespace MSCO { float width = Mathf.Min(Screen.width - 40, 960); var rect = new Rect((Screen.width - width) / 2f, 20, width, Mathf.Min(340, Screen.height * 0.5f)); + UiTheme.DrawShadow(rect); GUI.Box(rect, GUIContent.none, panel); GUI.Label(new Rect(rect.x + 16, rect.y + 10, 300, 20), "MSCO CONSOLE", caption); @@ -271,6 +272,7 @@ namespace MSCO { 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); + UiTheme.DrawShadow(rect); GUI.Box(rect, GUIContent.none, panel); if (Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Escape) @@ -493,7 +495,7 @@ namespace MSCO void EnsureStyles() { if (stylesReady && panel.normal.background != null) return; - panel = UiTheme.Panel9(UiTheme.Background); + panel = UiTheme.Card(UiTheme.Background, 10); rowStyle = UiTheme.Panel9(new Color(0.06f, 0.06f, 0.07f, 0.72f)); rowStyle.padding = new RectOffset(12, 12, 6, 6); field = UiTheme.Field(14); @@ -505,7 +507,7 @@ namespace MSCO title = UiTheme.Label(22, UiTheme.Text, FontStyle.Bold); title.wordWrap = false; small = UiTheme.Label(11, UiTheme.Muted); - primary = UiTheme.Button(UiTheme.Accent, UiTheme.AccentHover, Color.white); + primary = UiTheme.PrimaryButton(); secondary = UiTheme.Button(UiTheme.Secondary, UiTheme.SecondaryHover, UiTheme.Text, 12); danger = UiTheme.Button(UiTheme.Danger, UiTheme.DangerHover, Color.white, 12); chatName = UiTheme.Label(13, UiTheme.AccentHover, FontStyle.Bold, TextAnchor.MiddleLeft); diff --git a/Passenger.cs b/Passenger.cs index 13ee09e..af3d8ea 100644 --- a/Passenger.cs +++ b/Passenger.cs @@ -29,6 +29,10 @@ namespace MSCO readonly List frozenBodies = new List(); public bool IsRiding { get { return vehicle != null; } } + /// Fahrzeug, in dem wir mitfahren (sonst null). + public SyncedBody Vehicle { get { return vehicle; } } + /// Kopf auf dem Sitz, relativ zum Fahrzeug (genau, nicht aus der Weltposition des letzten Frames). + public Vector3 SeatHeadLocal { get { return seatHeadLocal; } } /// Taste gedrückt: einsteigen oder aussteigen. Gibt einen Statustext zurück. public string Toggle(Transform player, Camera cam, ObjectSync objects, IDictionary players) diff --git a/RemotePlayer.cs b/RemotePlayer.cs index 04a45de..88f638a 100644 --- a/RemotePlayer.cs +++ b/RemotePlayer.cs @@ -49,6 +49,12 @@ namespace MSCO Quaternion targetViewRot = Quaternion.identity; PlayerState last; + // Fahrzeug, in dem die Figur sitzt (setzt der Manager jedes Frame; null = läuft frei) + public Transform Seat; + Transform lastSeat; + Vector3 seatHead; + Quaternion seatView = Quaternion.identity; + Vector3 lastPosition; float walkPhase, walkAmount, speed; float airAmount; // 0 = steht am Boden, 1 = in der Luft (Sprung, Fallen) @@ -258,6 +264,15 @@ namespace MSCO public void Tick(float dt) { + if (Seat != null && last.SeatId != 0) + { + TickSeated(dt); + UpdateWalk(dt); + Pose(dt); + return; + } + lastSeat = null; + // Bei großen Sprüngen (Teleport, Laden) direkt setzen statt gleiten bool snap = (transform.position - targetFeet).sqrMagnitude > 100f; float t = snap ? 1f : Mathf.Clamp01(dt * Smoothing); @@ -273,6 +288,32 @@ namespace MSCO Pose(dt); } + /// + /// Im Fahrzeug: relativ zum Fahrzeug glätten und jedes Frame an dessen aktuelle Lage setzen. In Weltkoordinaten + /// hinkt die Figur einem fahrenden Auto um Tempo / Glättung hinterher (bei 90 km/h fast 2 m) und ruckelt. + /// + void TickSeated(float dt) + { + bool fresh = lastSeat != Seat; // gerade eingestiegen: direkt auf den Sitz + lastSeat = Seat; + float t = fresh ? 1f : Mathf.Clamp01(dt * Smoothing); + seatHead = Vector3.Lerp(seatHead, last.SeatHead, t); + seatView = Quaternion.Slerp(seatView, last.SeatView, t); + + Vector3 up = Seat.up; + Quaternion viewRot = Seat.rotation * seatView; + Vector3 forward = viewRot * Vector3.forward; + Vector3 flat = forward - up * Vector3.Dot(forward, up); + if (flat.sqrMagnitude < 0.0001f) flat = Seat.forward; + + headWorld = Seat.TransformPoint(seatHead); + transform.rotation = Quaternion.LookRotation(flat, up); // neigt sich mit dem Auto (Berg, Kurve) + transform.position = headWorld - up * 1.2f; + pitch = Mathf.Clamp(-Mathf.Asin(Mathf.Clamp(Vector3.Dot(forward.normalized, up), -1f, 1f)) * Mathf.Rad2Deg, -MaxHeadPitch, MaxHeadPitch); + view.position = headWorld; + view.rotation = viewRot; + } + void UpdateWalk(float dt) { Vector3 moved = transform.position - lastPosition; diff --git a/Toasts.cs b/Toasts.cs index ce49370..1c4dea7 100644 --- a/Toasts.cs +++ b/Toasts.cs @@ -53,6 +53,7 @@ namespace MSCO var r = new Rect(Screen.width - 12 - Width + (1f - slide) * (Width + 20f), y, Width, Height); GUI.color = new Color(1f, 1f, 1f, alpha); + UiTheme.DrawShadow(r); GUI.Box(r, GUIContent.none, box); GUI.color = new Color(t.Dot.r, t.Dot.g, t.Dot.b, alpha); @@ -73,7 +74,7 @@ namespace MSCO void EnsureStyles() { if (box != null && box.normal.background != null) return; - box = UiTheme.Panel9(new Color(0.06f, 0.06f, 0.07f, 0.88f), 6); + box = UiTheme.Card(new Color(0.06f, 0.06f, 0.07f, 0.92f), 6); title = new GUIStyle(GUI.skin.label) { fontSize = 13, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; title.normal.textColor = Color.white; title.wordWrap = false; diff --git a/TrafficSync.cs b/TrafficSync.cs index c57d959..8a28726 100644 --- a/TrafficSync.cs +++ b/TrafficSync.cs @@ -100,7 +100,7 @@ namespace MSCO c.TargetPos = p; c.TargetRot = q; c.TargetVel = v; - c.TargetTime = Time.time; + c.TargetTime = Time.fixedTime; // Physik-Zeit wie in FixedUpdate (sonst negatives Alter = Rucken) if (!c.Following) { c.WasKinematic = c.Rb.isKinematic; @@ -117,7 +117,7 @@ namespace MSCO foreach (Car c in cars.Values) { if (!c.Following || c.Rb == null) continue; - float ahead = Mathf.Min(Time.time - c.TargetTime, MaxExtrapolation); + float ahead = Mathf.Clamp(Time.fixedTime - c.TargetTime, 0f, 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; } diff --git a/UiTheme.cs b/UiTheme.cs index 9960cc3..e2c58d9 100644 --- a/UiTheme.cs +++ b/UiTheme.cs @@ -26,8 +26,13 @@ namespace MSCO public static readonly Color Muted = Hex(0x8e, 0x8e, 0x99); public static readonly Color Online = Hex(0x3d, 0xd6, 0x8c); public static readonly Color Pending = Hex(0xf2, 0xc2, 0x1d); + public static readonly Color Gold = Hex(0xff, 0xb4, 0x5a); + /// Feiner heller Rand um Fenster und Karten (wie auf der Website). + public static readonly Color Border = new Color(1f, 1f, 1f, 0.08f); public const int Radius = 6; + const int ShadowBlur = 16; + static GUIStyle shadowStyle; public static Color Hex(int r, int g, int b, float a = 1f) { @@ -58,6 +63,92 @@ namespace MSCO return tex; } + /// + /// Wie Rounded, aber mit senkrechtem Verlauf (oben -> unten) und optional 1 px Rand. Die Textur ist höher als + /// breit, damit der Verlauf beim 9-Slice-Strecken im Mittelteil erhalten bleibt. + /// + public static Texture2D RoundedEx(Color top, Color bottom, Color border, bool withBorder, int radius = Radius, int height = 48) + { + int width = radius * 2 + 4; + height = Mathf.Max(height, width); + var tex = new Texture2D(width, height, TextureFormat.ARGB32, false); + tex.wrapMode = TextureWrapMode.Clamp; + tex.filterMode = FilterMode.Bilinear; + for (int y = 0; y < height; y++) + { + // Texturzeile 0 ist unten + Color fill = Color.Lerp(bottom, top, (y + 0.5f) / height); + // Rand deckend auf die Fläche mischen (ein durchsichtiger Rand ließe das Spiel durchscheinen) + Color edge = Color.Lerp(fill, new Color(border.r, border.g, border.b, fill.a), border.a); + for (int x = 0; x < width; x++) + { + float cx = Mathf.Clamp(x + 0.5f, radius, width - radius); + float cy = Mathf.Clamp(y + 0.5f, radius, height - radius); + float d = Vector2.Distance(new Vector2(x + 0.5f, y + 0.5f), new Vector2(cx, cy)); + Color c = withBorder ? Color.Lerp(edge, fill, Mathf.Clamp01(radius - 1f - d + 0.5f)) : fill; + c.a *= Mathf.Clamp01(radius - d + 0.5f); + tex.SetPixel(x, y, c); + } + } + tex.Apply(); + tex.hideFlags = HideFlags.HideAndDontSave; + return tex; + } + + /// Fenster/Karte mit feinem Rand; mit GUI.Box zeichnen (vorher DrawShadow für Tiefe). + public static GUIStyle Card(Color color, int radius = Radius) + { + var s = new GUIStyle(); + s.normal.background = RoundedEx(color, color, Border, true, radius, radius * 2 + 4); + s.border = new RectOffset(radius + 2, radius + 2, radius + 2, radius + 2); + return s; + } + + /// Orange Hauptknopf mit leichtem Verlauf (wie .btn auf der Website). + public static GUIStyle PrimaryButton(int fontSize = 13) + { + GUIStyle s = Button(Accent, AccentHover, Color.white, fontSize); + ApplyPrimaryGradient(s); + return s; + } + + /// Verlauf auf einen vorhandenen Knopf-Stil legen (Rundung und Rand bleiben). + public static void ApplyPrimaryGradient(GUIStyle s) + { + s.normal.background = s.focused.background = RoundedEx(AccentHover, Accent, Color.clear, false); + s.hover.background = s.active.background = s.onNormal.background = + RoundedEx(Color.Lerp(AccentHover, Color.white, 0.12f), AccentHover, Color.clear, false); + } + + /// Weicher Schatten unter einem Fenster (etwas nach unten versetzt). Vor dem Fenster zeichnen. + public static void DrawShadow(Rect r) + { + if (shadowStyle == null || shadowStyle.normal.background == null) + { + const int radius = 10; + int reach = radius + ShadowBlur; + int size = reach * 2 + 4; + var tex = new Texture2D(size, size, TextureFormat.ARGB32, false); + tex.wrapMode = TextureWrapMode.Clamp; + tex.filterMode = FilterMode.Bilinear; + for (int y = 0; y < size; y++) + for (int x = 0; x < size; x++) + { + float cx = Mathf.Clamp(x + 0.5f, reach, size - reach); + float cy = Mathf.Clamp(y + 0.5f, reach, size - reach); + float outside = Mathf.Max(0f, Vector2.Distance(new Vector2(x + 0.5f, y + 0.5f), new Vector2(cx, cy)) - radius); + float fade = 1f - Mathf.Clamp01(outside / ShadowBlur); + tex.SetPixel(x, y, new Color(0f, 0f, 0f, 0.45f * fade * fade)); + } + tex.Apply(); + tex.hideFlags = HideFlags.HideAndDontSave; + shadowStyle = new GUIStyle(); + shadowStyle.normal.background = tex; + shadowStyle.border = new RectOffset(reach + 2, reach + 2, reach + 2, reach + 2); + } + GUI.Box(new Rect(r.x - ShadowBlur, r.y - ShadowBlur + 6, r.width + ShadowBlur * 2, r.height + ShadowBlur * 2), GUIContent.none, shadowStyle); + } + public static Texture2D Solid(Color color) { var tex = new Texture2D(1, 1);