From 7b4a89d50fea98fccf072dcb0f6379380fdef398 Mon Sep 17 00:00:00 2001 From: MrMaho Date: Sat, 3 Oct 2026 12:02:21 +0200 Subject: [PATCH] MSCO 2.10.2: passenger only while moving, drain plug sync, admin VEHICLE tab, profile layout - Passenger: auto ride-along only when the vehicle moves (speed from its motion, also for kinematic remote cars) - no more getting stuck while working in the engine bay or re-captured - Bolts with a function (drain plug: stage 0-5 = oil leak on) are synced as stage and applied through the bolt's own stage logic; world authority sends its state once after joining. Fixes endless oil leaking when players had the drain plug in different states - Admin menu VEHICLE tab: fluids with FILL/EMPTY, all Database parts with wear/damage, REPAIR/BREAK per part, REPAIR ALL, filter - Server profile: info and players as two equal cards, no wrapping - fsmdump also lists FSM variables Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 ++++ FsmDump.cs | 11 ++++ LobbyUI.cs | 97 ++++++++++++++++++++++-------- MSCO.cs | 2 +- MSCO.csproj | 1 + MultiplayerManager.cs | 8 ++- Overlays.cs | 116 +++++++++++++++++++++++++++++++++-- Passenger.cs | 37 +++++++++++- README.md | 3 +- VehicleAdmin.cs | 137 ++++++++++++++++++++++++++++++++++++++++++ VehicleStateSync.cs | 78 +++++++++++++++++++++++- 11 files changed, 462 insertions(+), 41 deletions(-) create mode 100644 VehicleAdmin.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cbd97b..dcf5694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 2.10.2 + +- **Beifahrer**: automatisch festgehalten wird man nur noch, wenn das Fahrzeug fährt (egal wer fährt) – am stehenden + Auto kann man sich frei bewegen und schrauben (vorher hing man fest, sobald man sich in den Motorraum beugte, und + wurde nach dem Aussteigen gleich wieder eingefangen) +- **Öl lief endlos aus**: die Ablassschraube war bei Mitspielern unterschiedlich weit eingedreht (offen = Leck). Schrauben + mit Funktion (Ablassschraube) werden jetzt als Stufe übertragen und über die Logik des Spiels angewendet; beim Beitreten + gibt die Welt-Autorität ihren Stand vor +- **Admin-Menü, Reiter VEHICLE**: Füllstände (Benzin, Öl, Brems-/Kupplungsflüssigkeit, Kühlwasser) mit FILL/EMPTY, alle + Teile mit Verschleiß und Zustand – einzeln REPAIR/BREAK, REPAIR ALL, Filter (zum Testen; im Chat angezeigt) +- **Server-Profil** im Server-Browser aufgeräumt: Infos und Spieler als zwei gleich hohe Karten, nichts bricht mehr um +- Diagnose `fsmdump` zeigt jetzt auch die Variablen der FSMs + ## 2.10.1 **Türen** diff --git a/FsmDump.cs b/FsmDump.cs index c9a82bd..1531a2e 100644 --- a/FsmDump.cs +++ b/FsmDump.cs @@ -55,6 +55,17 @@ namespace MSCO { sb.Append("==== ").Append(path).Append(" | ").Append(fsm.FsmName).Append(" | active ").Append(fsm.gameObject.activeInHierarchy) .Append(" | state ").Append(fsm.ActiveStateName).AppendLine(); + try + { + FsmVariables fv = fsm.FsmVariables; + var vb = new StringBuilder(); + foreach (FsmFloat f in fv.FloatVariables) vb.Append(" ").Append(f.Name).Append("=").Append(f.Value.ToString("0.###")); + foreach (FsmInt n in fv.IntVariables) vb.Append(" ").Append(n.Name).Append("=").Append(n.Value); + foreach (FsmBool b in fv.BoolVariables) vb.Append(" ").Append(b.Name).Append("=").Append(b.Value); + foreach (FsmString t in fv.StringVariables) vb.Append(" ").Append(t.Name).Append("=\"").Append(t.Value).Append("\""); + if (vb.Length > 0) sb.Append(" VARS").Append(vb).AppendLine(); + } + catch { } if (fsm.FsmGlobalTransitions != null) foreach (FsmTransition t in fsm.FsmGlobalTransitions) sb.Append(" GLOBAL ").Append(t.EventName).Append(" -> ").Append(t.ToState).AppendLine(); diff --git a/LobbyUI.cs b/LobbyUI.cs index 1fcd966..d6e8366 100644 --- a/LobbyUI.cs +++ b/LobbyUI.cs @@ -599,8 +599,8 @@ namespace MSCO GUILayout.BeginVertical(); GUILayout.Label(s.Name, titleStyle); GUILayout.BeginHorizontal(); - Rect badgeRow = GUILayoutUtility.GetRect(S(10), S(20), GUILayout.ExpandWidth(true)); - float bx = badgeRow.x; + Rect badgeRow = GUILayoutUtility.GetRect(S(10), S(22), GUILayout.ExpandWidth(true)); + float bx = badgeRow.x + titleStyle.padding.left; if (s.Featured) bx += Pill(bx, badgeRow, "FEATURED", pillFeaturedStyle); bool official = s.Tags != null && s.Tags.Contains("official"); // vergibt nur ein MSCO-Admin if (official) bx += Pill(bx, badgeRow, "OFFICIAL", pillStyle); @@ -651,39 +651,84 @@ namespace MSCO GUILayout.Space(S(10)); } - // Infos links, Spielerliste rechts + // Zwei Karten gleicher Höhe: Infos links, Spielerliste rechts bool versionOk = MSCO.Compatible(s.Version); - GUILayout.BeginHorizontal(); - GUILayout.BeginVertical(GUILayout.Width(S(250))); - InfoLine("HOSTED BY", string.IsNullOrEmpty(s.Owner) ? "unknown" : s.Owner, rowNameStyle); - 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", PingStyle(s.Ping)); - InfoLine("ADDRESS", s.Address + ":" + s.Port, rowNameStyle); - if (s.Mods.Count > 0) InfoLine("MODS", string.Join(", ", s.Mods.ToArray()), textStyle); - GUILayout.EndVertical(); - GUILayout.Space(S(16)); + float cardHeight = Mathf.Clamp(contentHeight - S(250), S(240), S(380)); + Rect area = GUILayoutUtility.GetRect(S(10), cardHeight, GUILayout.ExpandWidth(true)); + float gap = S(16), leftWidth = Mathf.Round(area.width * 0.44f); + var info = new Rect(area.x, area.y, leftWidth, area.height); + var list = new Rect(area.x + leftWidth + gap, area.y, area.width - leftWidth - gap, area.height); + GUI.Box(info, GUIContent.none, rowBox); + GUI.Box(list, GUIContent.none, rowBox); - GUILayout.BeginVertical(rowBox, GUILayout.ExpandWidth(true), GUILayout.MinHeight(S(150))); - GUILayout.Space(S(6)); - GUILayout.Label("PLAYERS " + s.Players + " / " + s.MaxPlayers, captionStyle); - profileScroll = GUILayout.BeginScrollView(profileScroll, GUILayout.Height(ListHeight(S(330f), S(130f)))); + EnsureProfileStyles(); + float x = info.x + S(20), w = info.width - S(40), y = info.y + S(16); + GUI.Label(new Rect(x, y, w, S(18)), "SERVER INFO", captionStyle); + y += S(28); + y = ProfileRow(x, y, w, "HOSTED BY", string.IsNullOrEmpty(s.Owner) ? "unknown" : s.Owner, profileValueStyle); + y = ProfileRow(x, y, w, "VERSION", "v" + s.Version + (versionOk ? "" : " (you: v" + MSCO.ModVersion + ")"), versionOk ? profileValueStyle : badgeStyle); + y = ProfileRow(x, y, w, "STEAM", s.RequireSteam ? "Required" : "Not required", profileValueStyle); + y = ProfileRow(x, y, w, "ONLINE FOR", s.UptimeSeconds >= 0 ? FormatUptime(s.UptimeSeconds) : "-", profileValueStyle); + y = ProfileRow(x, y, w, "PING", s.Ping < 0 ? "-" : s.Ping + " ms", PingStyle(s.Ping)); + y = ProfileRow(x, y, w, "ADDRESS", s.Address + ":" + s.Port, profileValueStyle); + if (s.Mods.Count > 0) ProfileRow(x, y, w, "MODS", string.Join(", ", s.Mods.ToArray()), profileWrapStyle); + + // Spieler + float lx = list.x + S(20), lw = list.width - S(40), ly = list.y + S(16); + GUI.Label(new Rect(lx, ly, lw, S(18)), "PLAYERS " + s.Players + " / " + s.MaxPlayers, captionStyle); + ly += S(30); + var listArea = new Rect(lx, ly, lw, list.yMax - ly - S(14)); if (s.PlayerNames == null || s.PlayerNames.Count == 0) - GUILayout.Label("Nobody online right now.", textStyle); + { + GUI.Label(listArea, "Nobody online right now.", profileEmptyStyle); + } else - foreach (string name in s.PlayerNames) + { + float rowH = S(32); + var inner = new Rect(0, 0, lw - S(14), rowH * s.PlayerNames.Count); + profileScroll = GUI.BeginScrollView(listArea, profileScroll, inner); + for (int i = 0; i < s.PlayerNames.Count; i++) { - Rect line = GUILayoutUtility.GetRect(S(10), S(24), GUILayout.ExpandWidth(true)); - GUI.DrawTexture(new Rect(line.x + S(2), line.y + S(8), S(8), S(8)), texOnline); - GUI.Label(new Rect(line.x + S(18), line.y, line.width - S(18), line.height), name, rowNameStyle); + var line = new Rect(0, i * rowH, inner.width, rowH); + if (i > 0) GUI.DrawTexture(new Rect(line.x, line.y, line.width, S(1)), texDivider); + GUI.DrawTexture(new Rect(line.x + S(2), line.y + (rowH - S(8)) / 2f, S(8), S(8)), texOnline); + GUI.Label(new Rect(line.x + S(20), line.y, line.width - S(20), rowH), s.PlayerNames[i], profileValueStyle); } - GUILayout.EndScrollView(); - GUILayout.EndVertical(); - GUILayout.EndHorizontal(); + GUI.EndScrollView(); + } GUILayout.FlexibleSpace(); } + GUIStyle profileValueStyle, profileWrapStyle, profileCaptionStyle, profileEmptyStyle; + + void EnsureProfileStyles() + { + if (profileValueStyle != null && profileValueStyle.fontSize == P(15)) return; + profileValueStyle = new GUIStyle(rowNameStyle) { fontSize = P(15), wordWrap = false, clipping = TextClipping.Clip, alignment = TextAnchor.MiddleLeft }; + profileWrapStyle = new GUIStyle(profileValueStyle) { wordWrap = true, fontSize = P(13), alignment = TextAnchor.UpperLeft }; + profileWrapStyle.normal.textColor = Muted; + profileCaptionStyle = new GUIStyle(captionStyle) { alignment = TextAnchor.MiddleLeft }; + profileCaptionStyle.margin = new RectOffset(); + profileEmptyStyle = new GUIStyle(textStyle) { alignment = TextAnchor.MiddleCenter }; + } + + /// Info-Zeile der Server-Karte: Bezeichnung links, Wert daneben, feine Linie darunter. Gibt das nächste y zurück. + float ProfileRow(float x, float y, float w, string caption, string value, GUIStyle valueStyle) + { + const float captionWidth = 120f; + float valueWidth = w - S(captionWidth); + float h = S(36); + if (valueStyle.wordWrap) h = Mathf.Max(h, valueStyle.CalcHeight(new GUIContent(value), valueWidth) + S(16)); + GUI.Label(new Rect(x, y, S(captionWidth), S(36)), caption, profileCaptionStyle); + var valueRect = valueStyle.wordWrap ? new Rect(x + S(captionWidth), y + S(9), valueWidth, h - S(9)) : new Rect(x + S(captionWidth), y, valueWidth, S(36)); + GUIStyle style = valueStyle; + if (!valueStyle.wordWrap && valueStyle.alignment != TextAnchor.MiddleLeft) + style = new GUIStyle(valueStyle) { alignment = TextAnchor.MiddleLeft }; + GUI.Label(valueRect, value, style); + GUI.DrawTexture(new Rect(x, y + h, w, S(1)), texDivider); + return y + h + S(1); + } + /// Eine Info-Zeile: Bezeichnung links (grau), Wert rechts. void InfoLine(string caption, string value, GUIStyle valueStyle) { diff --git a/MSCO.cs b/MSCO.cs index c1f89f7..319e908 100644 --- a/MSCO.cs +++ b/MSCO.cs @@ -11,7 +11,7 @@ namespace MSCO /// Version der Mod. Kleine Updates: nur die dritte Stelle erhöhen (2.9.1, 2.9.2 ...) - sie bleiben mit /// Servern und Mitspielern derselben ersten zwei Stellen kompatibel. /// - public const string ModVersion = "2.10.1"; + public const string ModVersion = "2.10.2"; /// /// Netzwerk-Version (die ersten beiden Stellen). Nur erhöhen, wenn sich das Protokoll inkompatibel ändert - dann /// auch GameServer.ModVersion im Server. diff --git a/MSCO.csproj b/MSCO.csproj index 80d6d5d..24c34cb 100644 --- a/MSCO.csproj +++ b/MSCO.csproj @@ -96,6 +96,7 @@ + diff --git a/MultiplayerManager.cs b/MultiplayerManager.cs index 3d68a63..4399ba9 100644 --- a/MultiplayerManager.cs +++ b/MultiplayerManager.cs @@ -569,6 +569,12 @@ namespace MSCO } /// Cheats für alle sichtbar im Chat melden. + /// Admin-Menü (Reiter VEHICLE): Änderung am Fahrzeug im Chat melden. + public void VehicleCheat(string what) + { + if (CanCheat) AnnounceCheat(what); + } + void AnnounceCheat(string what) { string text = OwnName() + " " + what + " (admin)"; @@ -2162,7 +2168,7 @@ namespace MSCO // Gesamtzustand der Fahrzeuge (Tank, Öl, Verschleiß ...) PartStart(); - try { if (objects.ScanComplete) vehicleState.Update(objects, localId, SendGame); } + try { if (objects.ScanComplete) vehicleState.Update(objects, localId, SendGame, IsWorldAuthority); } catch (System.Exception e) { ReportError("vehicle state sync", e); } PartEnd("vehicle state sync"); // Unfallschäden (verbeulte Karosserie) diff --git a/Overlays.cs b/Overlays.cs index b087193..8095b05 100644 --- a/Overlays.cs +++ b/Overlays.cs @@ -293,24 +293,128 @@ namespace MSCO // Reiter GUILayout.BeginHorizontal(); - string[] tabs = { "PLAYERS", "TELEPORT", "ITEMS", "BANS" + (bannedIds != null && bannedIds.Count > 0 ? " (" + bannedIds.Count + ")" : ""), "WORLD" }; + string[] tabs = { "PLAYERS", "TELEPORT", "VEHICLE", "ITEMS", "BANS" + (bannedIds != null && bannedIds.Count > 0 ? " (" + bannedIds.Count + ")" : ""), "WORLD" }; for (int i = 0; i < tabs.Length; i++) { - if (GUILayout.Button(tabs[i], adminTab == i ? primary : secondary, GUILayout.Height(30), GUILayout.Width(120))) adminTab = i; + if (GUILayout.Button(tabs[i], adminTab == i ? primary : secondary, GUILayout.Height(30), GUILayout.Width(104))) adminTab = i; GUILayout.Space(6); } GUILayout.EndHorizontal(); GUILayout.Space(10); if (adminTab == 1) DrawAdminTeleport(mp); - else if (adminTab == 2) DrawAdminItems(mp); - else if (adminTab == 3) DrawAdminBans(mp); - else if (adminTab == 4) DrawAdminWorld(mp); + else if (adminTab == 2) DrawAdminVehicle(mp); + else if (adminTab == 3) DrawAdminItems(mp); + else if (adminTab == 4) DrawAdminBans(mp); + else if (adminTab == 5) DrawAdminWorld(mp); else DrawAdminPlayers(mp); GUILayout.EndArea(); } - Vector2 teleportScroll; + Vector2 teleportScroll, vehicleScroll; + Texture2D texBarBack, texBarFill, texBarLow; + string partFilter = ""; + + /// Reiter VEHICLE: Füllstände und Teile des Satsuma reparieren/zerstören (zum Testen). + void DrawAdminVehicle(MultiplayerManager mp) + { + if (!mp.CanCheat) + { + GUILayout.Label("Only works in the game.", small); + return; + } + if (texBarBack == null) + { + texBarBack = UiTheme.Solid(new Color(1f, 1f, 1f, 0.1f)); + texBarFill = UiTheme.Solid(UiTheme.Online); + texBarLow = UiTheme.Solid(UiTheme.Danger); + } + + GUILayout.BeginHorizontal(); + if (GUILayout.Button("REPAIR ALL", primary, GUILayout.Height(30), GUILayout.Width(130))) + { + int n = VehicleAdmin.RepairAll(); + Log(n + " part(s) repaired"); + if (n > 0) mp.VehicleCheat("repaired the Satsuma (" + n + " parts)"); + } + GUILayout.Space(6); + if (GUILayout.Button("REFRESH", secondary, GUILayout.Height(30), GUILayout.Width(100))) VehicleAdmin.Refresh(true); + GUILayout.Space(12); + GUILayout.Label("Filter", caption, GUILayout.Width(44)); + partFilter = GUILayout.TextField(partFilter ?? "", 30, field, GUILayout.Height(30)); + GUILayout.EndHorizontal(); + GUILayout.Space(8); + + vehicleScroll = GUILayout.BeginScrollView(vehicleScroll); + GUILayout.Label("FLUIDS", caption); + List fluids = VehicleAdmin.Fluids; + if (fluids.Count == 0) GUILayout.Label("No fluids found (only works in the game).", small); + foreach (VehicleAdmin.Fluid f in fluids) + { + GUILayout.BeginHorizontal(rowStyle, GUILayout.Height(36)); + GUILayout.Label(f.Label, chatName, GUILayout.Width(170)); + Rect bar = GUILayoutUtility.GetRect(120, 36, GUILayout.ExpandWidth(true)); + float frac = f.Max > 0f ? Mathf.Clamp01(f.Value.Value / f.Max) : 0f; + var back = new Rect(bar.x, bar.y + 15, bar.width, 6); + GUI.DrawTexture(back, texBarBack); + GUI.DrawTexture(new Rect(back.x, back.y, Mathf.Max(2f, back.width * frac), back.height), frac < 0.25f ? texBarLow : texBarFill); + GUILayout.Label(f.Value.Value.ToString("0.00") + " / " + f.Max.ToString("0.#"), small, GUILayout.Width(90)); + if (GUILayout.Button("FILL", secondary, GUILayout.Height(26), GUILayout.Width(64))) + { + VehicleAdmin.SetFluid(f, true); + mp.VehicleCheat("filled up " + f.Label.ToLower()); + } + GUILayout.Space(4); + if (GUILayout.Button("EMPTY", danger, GUILayout.Height(26), GUILayout.Width(70))) + { + VehicleAdmin.SetFluid(f, false); + mp.VehicleCheat("emptied " + f.Label.ToLower()); + } + GUILayout.EndHorizontal(); + GUILayout.Space(3); + } + + GUILayout.Space(10); + GUILayout.Label("PARTS (wear: 100 = new)", caption); + string filter = (partFilter ?? "").Trim().ToLowerInvariant(); + int shown = 0; + foreach (VehicleAdmin.Part p in VehicleAdmin.Parts) + { + if (filter.Length > 0 && !(p.Name + " " + p.Category).ToLowerInvariant().Contains(filter)) continue; + shown++; + bool installed = p.Installed == null || p.Installed.Value; + bool damaged = p.Damaged != null && p.Damaged.Value; + GUILayout.BeginHorizontal(rowStyle, GUILayout.Height(36)); + GUILayout.BeginVertical(GUILayout.Width(220)); + GUILayout.Label(p.Name, chatName); + GUILayout.Label(p.Category + (installed ? "" : " · not installed"), small); + GUILayout.EndVertical(); + Rect bar = GUILayoutUtility.GetRect(100, 36, GUILayout.ExpandWidth(true)); + if (p.Wear != null) + { + float frac = Mathf.Clamp01(p.Wear.Value / 100f); + var back = new Rect(bar.x, bar.y + 15, bar.width, 6); + GUI.DrawTexture(back, texBarBack); + GUI.DrawTexture(new Rect(back.x, back.y, Mathf.Max(2f, back.width * frac), back.height), frac < 0.3f ? texBarLow : texBarFill); + } + GUILayout.Label(damaged ? "DAMAGED" : p.Wear != null ? p.Wear.Value.ToString("0") + " %" : "OK", damaged ? danger : small, GUILayout.Width(84)); + if (GUILayout.Button("REPAIR", secondary, GUILayout.Height(26), GUILayout.Width(76))) + { + VehicleAdmin.Repair(p); + mp.VehicleCheat("repaired " + p.Name); + } + GUILayout.Space(4); + if (GUILayout.Button("BREAK", danger, GUILayout.Height(26), GUILayout.Width(70))) + { + VehicleAdmin.Break(p); + mp.VehicleCheat("broke " + p.Name + " (testing)"); + } + GUILayout.EndHorizontal(); + GUILayout.Space(3); + } + if (shown == 0) GUILayout.Label(VehicleAdmin.Parts.Count == 0 ? "No parts found (only works in the game)." : "No part matches the filter.", small); + GUILayout.EndScrollView(); + } void DrawAdminTeleport(MultiplayerManager mp) { diff --git a/Passenger.cs b/Passenger.cs index 03da5ca..59ea29a 100644 --- a/Passenger.cs +++ b/Passenger.cs @@ -4,8 +4,9 @@ using UnityEngine; namespace MSCO { /// - /// Mitfahren: Wer mit dem Kopf im Fahrzeug ist (Kabine, offene Tür, Ladefläche ...), wird genau dort festgehalten und - /// fährt mit - egal ob schon jemand fährt oder das Auto noch steht. Aussteigen mit Leertaste oder der Beifahrer-Taste. + /// Mitfahren: Wer mit dem Kopf im Fahrzeug ist (Kabine, offene Tür, Ladefläche ...), wird genau dort festgehalten, + /// sobald das Fahrzeug FÄHRT (egal wer fährt). Am stehenden Auto wird niemand automatisch festgehalten - sonst hing + /// man fest, sobald man sich zum Schrauben in den Motorraum beugte. Aussteigen mit Leertaste oder der Beifahrer-Taste. /// Steht man per Taste neben dem Auto, kommt man auf den Beifahrersitz (Fahrerposition des Spiels, gespiegelt). /// Während der Fahrt wird der Spieler jedes Frame an seine Stelle im Fahrzeug gesetzt und dreht mit; umschauen geht. /// @@ -15,7 +16,8 @@ namespace MSCO const float AutoEnterRange = 1.0f; // automatisch: Kopf so nah am Beifahrersitz ... const float AutoEnterSide = 0.6f; // ... und seitlich höchstens so weit außerhalb des Sitzes - an der // geschlossenen Tür steht man weiter draußen, nur in der offenen Tür so nah - const float AutoEnterDwell = 0.35f; // ... so lange (nicht beim bloßen Vorbeilaufen) + const float AutoEnterDwell = 0.15f; // ... so lange + const float MovingSpeed = 1.2f; // m/s: ab hier "fährt" das Fahrzeug (dann wird man festgehalten) const float AutoRearmRange = 2f; // nach dem Aussteigen erst wieder, wenn man so weit weg war const float AutoCheckInterval = 0.15f; const float CenterSeatThreshold = 0.2f; @@ -40,6 +42,10 @@ namespace MSCO const float AfterDrivingPause = 4f; // nach dem Fahren (Ein-/Aussteigen am Fahrersitz) nicht automatisch SyncedBody dwellVehicle; bool autoArmed = true; + // Geschwindigkeit je Fahrzeug aus der Bewegung (Fahrzeuge anderer sind kinematisch - deren velocity ist 0) + readonly Dictionary lastVehiclePos = new Dictionary(); + readonly Dictionary vehicleSpeed = new Dictionary(); + float lastSpeedSample = -1f; Vector3 lastExitSeat; public bool IsRiding { get { return vehicle != null; } } @@ -101,6 +107,7 @@ namespace MSCO if ((cc != null && !cc.enabled) || player.parent != null) { lastDriving = Time.time; dwellStart = -1f; return null; } if (Time.time < nextAutoCheck) return null; nextAutoCheck = Time.time + AutoCheckInterval; + SampleSpeeds(objects); Vector3 head = cam.transform.position; // Nach dem Aussteigen erst einmal aus dem Fahrzeug heraus, sonst säße man sofort wieder drin @@ -111,6 +118,7 @@ namespace MSCO } SyncedBody found = VehicleAround(objects, head); + if (found != null && SpeedOf(found) < MovingSpeed) found = null; // steht: frei bewegen (z.B. schrauben) if (found == null || found != dwellVehicle) { dwellVehicle = found; @@ -124,6 +132,26 @@ namespace MSCO return "Riding along (Space or the passenger key to get out)"; } + /// Geschwindigkeit aller Fahrzeuge in der Nähe aus ihrer Bewegung seit der letzten Messung. + void SampleSpeeds(ObjectSync objects) + { + float now = Time.time, dt = lastSpeedSample < 0f ? 0f : now - lastSpeedSample; + lastSpeedSample = now; + foreach (SyncedBody b in objects.AllVehicles()) + { + if (b.Rb == null) continue; + Vector3 pos = b.Rb.position, last; + if (dt > 0.01f && lastVehiclePos.TryGetValue(b.Rb, out last)) vehicleSpeed[b.Rb] = (pos - last).magnitude / dt; + lastVehiclePos[b.Rb] = pos; + } + } + + float SpeedOf(SyncedBody b) + { + float v; + return b.Rb != null && vehicleSpeed.TryGetValue(b.Rb, out v) ? v : 0f; + } + /// Fahrzeug, in dessen Umriss der Kopf gerade ist (null = in keinem). SyncedBody VehicleAround(ObjectSync objects, Vector3 head) { @@ -280,6 +308,9 @@ namespace MSCO dwellStart = -1f; driverHeads.Clear(); innerBounds.Clear(); + lastVehiclePos.Clear(); + vehicleSpeed.Clear(); + lastSpeedSample = -1f; SetMovementEnabled(null, true); } diff --git a/README.md b/README.md index 582c9e4..43c13ea 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,8 @@ einem dedizierten Server aus dem Server-Browser. MSCO läuft **eigenständig, oh - **T** – Chat - **F7** – MSCO-Konsole mit allen Befehlen - **/admin** – Admin-Menü für Server-Admins bzw. den P2P-Host: Spieler kicken/bannen, **Admin geben/nehmen**, - GOTO/BRING (teleportieren), **TELEPORT** zu Orten (Haus, Laden, Werkstatt, Prüfstelle, Peräjärvi …), Cheats (+1.000 / +10.000 MK, Bedürfnisse auffüllen, Satsuma zu sich holen), Items spawnen, + GOTO/BRING (teleportieren), **TELEPORT** zu Orten (Haus, Laden, Werkstatt, Prüfstelle, Peräjärvi …), **VEHICLE** + (Füllstände auffüllen/leeren, Teile reparieren/zerstören – zum Testen), Cheats (+1.000 / +10.000 MK, Bedürfnisse auffüllen, Satsuma zu sich holen), Items spawnen, Welt zurücksetzen/Backups – Cheats werden im Chat angezeigt - **P** / **Leertaste** – aussteigen; **P** neben einem Auto – auf den Beifahrersitz - Tasten ändern mit `bind` in der F7-Konsole (z.B. `bind passenger O`) diff --git a/VehicleAdmin.cs b/VehicleAdmin.cs new file mode 100644 index 0000000..302b228 --- /dev/null +++ b/VehicleAdmin.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using HutongGames.PlayMaker; +using UnityEngine; + +namespace MSCO +{ + /// + /// Admin-Menü, Reiter VEHICLE: Füllstände und Teile des Satsuma aus der "Database" des Spiels anzeigen und zum Testen + /// reparieren/zerstören. Jedes Teil hat dort ein Objekt mit der FSM "Data" (Installed, Bolted, Damaged, teils Wear); + /// die Füllstände stehen in Tank, Ölwanne, Brems- und Kupplungszylinder. Die Werte überträgt VehicleStateSync an alle. + /// + public static class VehicleAdmin + { + public sealed class Fluid + { + public string Label; + public FsmFloat Value; + public float Max; + } + + public sealed class Part + { + public string Name, Category; + public FsmBool Damaged, Installed; + public FsmFloat Wear; + } + + static readonly List fluids = new List(); + static readonly List parts = new List(); + static float scannedAt = -100f; + + public static List Fluids { get { Refresh(false); return fluids; } } + public static List Parts { get { Refresh(false); return parts; } } + + // Füllstand: Teil in der Database, Variable, Anzeige, Maximum (Tank: aus MaxCapacity) + static readonly string[][] FluidDefs = + { + new[] { "FuelTank", "FuelLevel", "Fuel", "36" }, + new[] { "Oilpan", "Oil", "Engine oil", "3" }, + new[] { "BrakeMasterCylinder", "BrakeFluidF", "Brake fluid (front)", "1" }, + new[] { "BrakeMasterCylinder", "BrakeFluidR", "Brake fluid (rear)", "1" }, + new[] { "ClutchMasterCylinder", "ClutchFluid", "Clutch fluid", "0.5" }, + new[] { "Radiator", "Water", "Coolant", "6" }, + }; + + /// Database neu einlesen (höchstens alle 5 s, außer erzwungen). + public static void Refresh(bool force) + { + if (!force && Time.realtimeSinceStartup - scannedAt < 5f) return; + scannedAt = Time.realtimeSinceStartup; + fluids.Clear(); + parts.Clear(); + if (!MenuHelper.InGame) return; + var byName = new Dictionary(); + foreach (PlayMakerFSM fsm in Resources.FindObjectsOfTypeAll()) + { + if (fsm == null || fsm.FsmName != "Data" || fsm.gameObject.hideFlags != HideFlags.None) continue; + Transform t = fsm.transform; + if (t.root.name != "Database" || t.parent == null || t.parent.parent != t.root) continue; + string category = t.parent.name; + if (category == "DatabaseOrders" || category == "PlayerDatabase") continue; // Bestellungen/Spieler: keine Fahrzeugteile + byName[t.name] = fsm; + + FsmBool installed = fsm.FsmVariables.FindFsmBool("Installed"); + FsmBool damaged = fsm.FsmVariables.FindFsmBool("Damaged"); + FsmFloat wear = fsm.FsmVariables.FindFsmFloat("Wear"); + if (damaged == null && wear == null) continue; + parts.Add(new Part + { + Name = Pretty(t.name), + Category = category.Replace("Database", ""), + Damaged = damaged, Installed = installed, Wear = wear, + }); + } + parts.Sort((a, b) => string.Compare(a.Category + a.Name, b.Category + b.Name, StringComparison.Ordinal)); + + foreach (string[] def in FluidDefs) + { + PlayMakerFSM fsm; + if (!byName.TryGetValue(def[0], out fsm)) continue; + FsmFloat value = fsm.FsmVariables.FindFsmFloat(def[1]); + if (value == null) continue; + float max = float.Parse(def[3], System.Globalization.CultureInfo.InvariantCulture); + FsmFloat capacity = fsm.FsmVariables.FindFsmFloat("MaxCapacity"); + if (def[0] == "FuelTank" && capacity != null && capacity.Value > 0f) max = capacity.Value; + fluids.Add(new Fluid { Label = def[2], Value = value, Max = max }); + } + } + + /// "RadiatorHose1" -> "Radiator Hose 1", "Drumbrake_RR" -> "Drumbrake RR". + static string Pretty(string name) + { + var sb = new System.Text.StringBuilder(); + for (int i = 0; i < name.Length; i++) + { + char c = name[i]; + if (c == '_') { sb.Append(' '); continue; } + if (i > 0 && ((char.IsUpper(c) && char.IsLower(name[i - 1])) || (char.IsDigit(c) && !char.IsDigit(name[i - 1])))) sb.Append(' '); + sb.Append(c); + } + return sb.ToString(); + } + + public static void SetFluid(Fluid f, bool full) + { + if (f.Value != null) f.Value.Value = full ? f.Max : 0f; + } + + public static void Repair(Part p) + { + if (p.Damaged != null) p.Damaged.Value = false; + if (p.Wear != null) p.Wear.Value = 100f; + } + + public static void Break(Part p) + { + if (p.Damaged != null) p.Damaged.Value = true; + if (p.Wear != null) p.Wear.Value = 0f; + } + + public static int RepairAll() + { + Refresh(true); + int n = 0; + foreach (Part p in parts) + { + if (p.Installed != null && !p.Installed.Value) continue; + bool needs = (p.Damaged != null && p.Damaged.Value) || (p.Wear != null && p.Wear.Value < 100f); + if (!needs) continue; + Repair(p); + n++; + } + return n; + } + } +} diff --git a/VehicleStateSync.cs b/VehicleStateSync.cs index 397b032..fb33e72 100644 --- a/VehicleStateSync.cs +++ b/VehicleStateSync.cs @@ -60,6 +60,10 @@ namespace MSCO public float LastF; public int LastI; public bool LastB; + // Schraube mit Funktion (z.B. Ablassschraube der Ölwanne: offen = Öl läuft aus): die Stufe wird als Zustand + // übertragen und über die Stufen-Logik der Schraube angewendet (Position, Dichtung, Zähler des Teils) + public FsmState BoltDispatch; + public bool InitialSent; } readonly Dictionary vars = new Dictionary(); @@ -103,7 +107,7 @@ namespace MSCO nextScan = -1f; // erster Suchlauf verteilt, siehe Update } - public void Update(ObjectSync objects, int localId, Action send) + public void Update(ObjectSync objects, int localId, Action send, bool worldAuthority = false) { if (nextScan < 0f) nextScan = Time.time + 3.1f; // nicht alle Sync-Teile im selben Frame suchen lassen if (Time.time >= nextScan) @@ -124,7 +128,15 @@ namespace MSCO // Eigenes Fahrzeug: jede Änderung. Freies Fahrzeug: nur deutliche Sprünge (Öl nachfüllen, tanken ...) - // langsames Abdriften (z.B. Abkühlen) rechnet jeder selbst, sonst überschreiben sich alle gegenseitig - bool big = owner == SyncedBody.NoOwner; + bool big = owner == SyncedBody.NoOwner && v.BoltDispatch == null; + if (v.BoltDispatch != null && worldAuthority && !v.InitialSent) + { + // Einmal den eigenen Stand vorgeben, damit alle gleich sind (vorher auseinandergelaufene Schrauben) + v.InitialSent = true; + Remember(v); + outgoing.Add(v); + continue; + } if (!Changed(v, big)) continue; Remember(v); outgoing.Add(v); @@ -186,6 +198,7 @@ namespace MSCO if (!IsTracked(root)) { Forget(fsm); continue; } scanned[fsm] = fsm.transform.parent; Forget(fsm); + if (RegisterFunctionalBolt(fsm, root)) continue; // FSMs, die Größen/Positionen setzen (z.B. Füllstands-Anzeigen), NICHT anfassen: ein übertragener Wert // kann dort eine Größe 0 ergeben - Unity stürzt dann in der Physik ab ("cleaning the mesh failed") if (TouchesTransform(fsm)) continue; @@ -205,6 +218,54 @@ namespace MSCO if (vars.Count != before) MscoLog.Info("[MSCO] Vehicle state: " + vars.Count + " values synced"); } + /// + /// Schrauben, deren Stufen etwas ein-/ausschalten (ActivateGameObject - z.B. die Ablassschraube: Stufe 0-5 = das + /// Leck "Oil" ist an). Sonst laufen Schrauben über FsmSync (Ereignisse): ging eins verloren, war die Ablassschraube + /// bei einem offen und beim anderen zu - bei ihm lief dann endlos Öl aus (der Ölstand kam ja ständig nach). + /// + bool RegisterFunctionalBolt(PlayMakerFSM fsm, string root) + { + if (fsm.FsmName != "Screw") return false; + FsmInt stage = fsm.FsmVariables.FindFsmInt("Stage"); + if (stage == null) return false; + FsmState dispatch = fsm.Fsm.GetState("Stages 2") ?? fsm.Fsm.GetState("Stages"); + if (dispatch == null) return false; + bool switches = false; + try + { + foreach (FsmState state in fsm.FsmStates) + { + if (!state.ActionsLoaded) state.LoadActions(); + if (state.Actions == null) continue; + foreach (FsmStateAction a in state.Actions) + if (a != null && a.GetType().Name == "ActivateGameObject") { switches = true; break; } + if (switches) break; + } + } + catch { return false; } + if (!switches) return false; + + string name = FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName + "|Stage"; + int key = Hash(name); + Var existing; + if (vars.TryGetValue(key, out existing) && existing.Variable == stage) return true; + var v = new Var { Key = key, Name = name, Root = root, Variable = stage, Type = 2, Fsm = fsm, BoltDispatch = dispatch }; + Remember(v); + vars[key] = v; + List keys; + if (!keysByFsm.TryGetValue(fsm, out keys)) keysByFsm[fsm] = keys = new List(); + keys.Add(key); + byte[] stored; + if (pending.TryGetValue(key, out stored)) + { + pending.Remove(key); + byte[] value = new byte[stored.Length - 1]; + Array.Copy(stored, 1, value, 0, value.Length); + Apply(v, stored[0], value); + } + return true; + } + static readonly string[] TransformActionWords = { "Scale", "Position", "Rotate", "Rotation", "Translate", "Parent" }; static bool TouchesTransform(PlayMakerFSM fsm) @@ -369,7 +430,18 @@ namespace MSCO if (float.IsNaN(f) || float.IsInfinity(f)) return; // nie ungültige Werte übernehmen SetFloat(v, f); break; - case 2: ((FsmInt)v.Variable).Value = BitConverter.ToInt32(value, 0); break; + case 2: + int n = BitConverter.ToInt32(value, 0); + if (v.BoltDispatch != null) + { + if (n < 0 || n > 8 || ((FsmInt)v.Variable).Value == n) { Remember(v); return; } + ((FsmInt)v.Variable).Value = n; + if (v.Fsm != null && v.Fsm.gameObject.activeInHierarchy) FsmSync.SwitchStateSilently(v.Fsm, v.BoltDispatch); + if (FsmSync.Debug) MscoLog.Info("[MSCO] << BOLT " + v.Name + " -> " + n); + break; + } + ((FsmInt)v.Variable).Value = n; + break; default: ((FsmBool)v.Variable).Value = value[0] != 0; break; } Remember(v); // nicht als eigene Änderung zurückschicken