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 <noreply@anthropic.com>
This commit is contained in:
Nico Meister
2026-09-28 20:52:56 +02:00
co-authored by Claude Opus 5.5
parent 33a415139b
commit ce2fbdfc10
11 changed files with 274 additions and 24 deletions
+14
View File
@@ -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`).
+46 -9
View File
@@ -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<ServerListEntry> 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<Favorites.Favorite> 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);
}
/// <summary>Ping farbig: grün gut, gelb geht so, rot schlecht.</summary>
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);
+18 -1
View File
@@ -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)
{
+27 -4
View File
@@ -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;
}
+25 -5
View File
@@ -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<Joint, Vector2> 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
/// <summary>Fährt der lokale Spieler gerade selbst?</summary>
public bool IsDriving { get; private set; }
/// <summary>Das Fahrzeug, das der lokale Spieler gerade fährt (sonst null).</summary>
public SyncedBody Driven { get; private set; }
/// <summary>Fahrzeuge, die gerade ein anderer Spieler aktiv fährt.</summary>
public List<SyncedBody> 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
/// <summary>Wieder selbst simulieren: Auto zurück auf seinen Zustand, Gelenke wieder mit ihrer Bruchkraft.</summary>
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<Joint, Vector2> 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;
}
+4 -2
View File
@@ -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);
+4
View File
@@ -29,6 +29,10 @@ namespace MSCO
readonly List<Rigidbody> frozenBodies = new List<Rigidbody>();
public bool IsRiding { get { return vehicle != null; } }
/// <summary>Fahrzeug, in dem wir mitfahren (sonst null).</summary>
public SyncedBody Vehicle { get { return vehicle; } }
/// <summary>Kopf auf dem Sitz, relativ zum Fahrzeug (genau, nicht aus der Weltposition des letzten Frames).</summary>
public Vector3 SeatHeadLocal { get { return seatHeadLocal; } }
/// <summary>Taste gedrückt: einsteigen oder aussteigen. Gibt einen Statustext zurück.</summary>
public string Toggle(Transform player, Camera cam, ObjectSync objects, IDictionary<int, PlayerInfo> players)
+41
View File
@@ -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);
}
/// <summary>
/// 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.
/// </summary>
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;
+2 -1
View File
@@ -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;
+2 -2
View File
@@ -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; }
+91
View File
@@ -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);
/// <summary>Feiner heller Rand um Fenster und Karten (wie auf der Website).</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Fenster/Karte mit feinem Rand; mit GUI.Box zeichnen (vorher DrawShadow für Tiefe).</summary>
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;
}
/// <summary>Orange Hauptknopf mit leichtem Verlauf (wie .btn auf der Website).</summary>
public static GUIStyle PrimaryButton(int fontSize = 13)
{
GUIStyle s = Button(Accent, AccentHover, Color.white, fontSize);
ApplyPrimaryGradient(s);
return s;
}
/// <summary>Verlauf auf einen vorhandenen Knopf-Stil legen (Rundung und Rand bleiben).</summary>
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);
}
/// <summary>Weicher Schatten unter einem Fenster (etwas nach unten versetzt). Vor dem Fenster zeichnen.</summary>
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);