12 Commits
Author SHA1 Message Date
MrMahoandClaude Opus 5.5 3970ef130b Changelog for 2.7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 00:06:28 +02:00
MrMahoandClaude Opus 5.5 c3f84895a7 Nicer player figure: chest + belly instead of box and ball, bigger head, straight legs, flat belt, cap brim down, matte materials; free vehicles only sync big value jumps
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 00:04:02 +02:00
MrMahoandClaude Opus 5.5 dfdb78db17 Version 2.7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:58:05 +02:00
MrMahoandClaude Opus 5.5 d7a9479baa Pause game input while typing (chat/console/admin); isolate sync subsystems so one error doesn't stop everything
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:56:14 +02:00
MrMahoandClaude Opus 5.5 f4d154d4a5 Freeze attached parts of remote-driven vehicles (joints broke, car fell apart); don't sync bolt tightness
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:39:43 +02:00
MrMahoandClaude Opus 5.5 e050712c0d Own MSC-style player figure instead of cloned NPCs; stop objects flung away by physics
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:28:43 +02:00
MrMahoandClaude Opus 5.5 24cd485bcc Validate stored vehicle/object poses, show positions in 'vehicles', admin: Satsuma to me
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:25:00 +02:00
MrMahoandClaude Opus 5.5 b33cd82e68 Fix: vehicles were treated as held items (car tilted with the driver's head)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:53:53 +02:00
MrMahoandClaude Opus 5.5 d32cdd5693 Player models: bind pose + procedural animation (no more ragdoll poses); passenger feedback in chat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:50:52 +02:00
MrMahoandClaude Opus 5.5 6d349399ab Ping measurement and display (console: ping)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:45:11 +02:00
MrMahoandClaude Opus 5.5 b408be7559 Fixed master address, admin cheats (money, needs, goto/bring), docs page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 21:56:17 +02:00
MrMahoandClaude Opus 5.5 bf01651ccd Production ready: hardening, limits, docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:55:29 +02:00
18 changed files with 741 additions and 552 deletions
+37
View File
@@ -0,0 +1,37 @@
# Changelog
## 2.7
Server und Mod müssen beide 2.7 sein (Server: `git pull && docker compose up -d --build`).
**Neu**
- Eigene Spielerfigur im MSC-Stil (Karohemd, Jeans, Arbeitsschuhe, Schnurrbart, Kappe oder Haare, je Spieler etwas
anders) statt kopierter NPCs – keine liegenden/verbuggten Figuren mehr
- Ping-Anzeige oben rechts: `ping` in der F8-Konsole (grün/gelb/rot)
- Admin-Menü: Cheats (+1.000 / +10.000 MK, Bedürfnisse auffüllen, Satsuma zu sich holen), GOTO / BRING (teleportieren)
- Fahrzeugzustand-Sync: Tank, Öl, Kühlwasser, Bremsflüssigkeit, Batterie, Schäden, Verschleiß, Kilometerstand, Lack
- `vehicles` in der Konsole zeigt die Position jedes Fahrzeugs
**Behoben**
- Auto fiel beim Fahren eines anderen Spielers auseinander (angebaute Teile rissen an ihren Gelenken ab)
- Auto kippte mit dem Kopf des Fahrers mit und zitterte (Fahrzeug wurde als „in der Hand gehalten“ erkannt)
- Satsuma wurde weggeschleudert und war „verschwunden“ – ungültige/im Boden liegende Positionen werden jetzt
verworfen bzw. angehoben, weggeschleuderte Objekte sofort gestoppt
- Items und Autos zitterten nach dem Loslassen (Ownership-Pingpong)
- Absturz beim Beitreten durch einen Kreis in der Objekt-Hierarchie (nachgespielte Montage-Aktion)
- Tippen im Chat/in der Konsole löste Spieltasten aus (Benutzen, Beifahrer-Taste P …)
- Maus sprang bei offenem Admin-Menü/Chat immer in die Bildmitte
- Ein Fehler in einem Sync-Teil legt nicht mehr den ganzen Sync lahm
- Freie Fahrzeuge: nur noch deutliche Wertänderungen werden abgeglichen (kein gegenseitiges Überschreiben)
**Sicherheit (Server)**
- Admin-Rechte nur noch für von Steam bestätigte SteamIDs – der Server braucht dafür einen `SteamWebApiKey`
(sonst könnte sich jeder als Admin ausgeben). Unbestätigte SteamIDs können niemanden mehr vom Server drängen.
- Ungültige Positionen werden nicht gespeichert, Paketgröße und Weltzustand sind begrenzt
**Server & Keymaster**
- Master-Adresse fest (`msco.mbm.wtf`), nicht mehr änderbar
- Docker läuft als dein Linux-Benutzer (kein „Permission denied“ in `data/` mehr), klare Meldungen statt Absturz
- `docker exec msco-server msco <befehl>` für Server-Befehle
- Keymaster: Rate-Limits, Security-Header, Healthcheck, Docs-Seite
- Automatische Tests: Server `dotnet run --project Tests` (30 Checks), Keymaster `npm run smoke` (24 Checks)
+55
View File
@@ -0,0 +1,55 @@
using System.Collections.Generic;
using HutongGames.PlayMaker;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Admin-Cheats für den eigenen Spieler (Geld, Bedürfnisse). Nur aus dem Admin-Menü erreichbar, also nur für
/// Server-Admins bzw. den P2P-Host. Geld wird über MoneySync automatisch an alle weitergegeben.
/// </summary>
public static class Cheats
{
// Globale PlayMaker-Variablen des Spielers: Wert, auf den "auffüllen" sie setzt
static readonly KeyValuePair<string, float>[] Needs =
{
new KeyValuePair<string, float>("PlayerHunger", 0f),
new KeyValuePair<string, float>("PlayerThirst", 0f),
new KeyValuePair<string, float>("PlayerStress", 0f),
new KeyValuePair<string, float>("PlayerUrine", 0f),
new KeyValuePair<string, float>("PlayerFatigue", 0f),
new KeyValuePair<string, float>("PlayerDirtiness", 0f),
};
public static string AddMoney(float amount)
{
FsmFloat money = FsmVariables.GlobalVariables.FindFsmFloat("PlayerMoney");
if (money == null) return "Money not found (only works in the game)";
money.Value += amount;
return "+" + amount.ToString("0") + " MK (now " + money.Value.ToString("0") + " MK)";
}
public static string FillNeeds()
{
int done = 0;
foreach (KeyValuePair<string, float> need in Needs)
{
FsmFloat v = FsmVariables.GlobalVariables.FindFsmFloat(need.Key);
if (v == null) continue;
v.Value = need.Value;
done++;
}
return done > 0 ? "Hunger, thirst, stress, fatigue, bladder and dirt reset" : "Player needs not found (only works in the game)";
}
/// <summary>Eigenen Spieler an eine Position setzen (etwas darüber, damit er nicht im Boden steckt).</summary>
public static bool Teleport(Transform player, Vector3 position)
{
if (player == null) return false;
player.position = position + Vector3.up * 0.3f;
Rigidbody rb = player.GetComponent<Rigidbody>();
if (rb != null && !rb.isKinematic) rb.velocity = Vector3.zero;
return true;
}
}
}
+9 -7
View File
@@ -11,12 +11,12 @@ namespace MSCO
public const string Help =
"connect <host[:port]> - join a dedicated server\n" +
"disconnect - leave the server / game\n" +
"host [port] / join <ip> [port] - LAN test session\n" +
"host [port] / join <ip> [port] - direct LAN game (no Steam)\n" +
"save - save the game (host / singleplayer)\n" +
"/admin - admin menu (kick / ban)\n" +
"ping [on|off] - show your ping to the server (toggle)\n" +
"/admin - admin menu (kick / ban / teleport / cheats)\n" +
"vehicles | rig - debug info about vehicles / player models\n" +
"debug - toggle sync debug output\n" +
"master [url] - show / change the server list address\n" +
"clear - clear this console";
public static void Execute(string[] args, Action<string> print)
@@ -59,13 +59,15 @@ namespace MSCO
mp.JoinServer(host, serverPort);
break;
}
case "master":
if (args.Length > 1) ServerBrowser.MasterUrl = args[1];
print("Master server: " + ServerBrowser.MasterUrl);
break;
case "save":
mp.SaveGame();
break;
case "ping":
// "ping" schaltet die Anzeige um, "ping on/off" setzt sie
if (args.Length > 1) mp.ShowPing = args[1].ToLower() == "on" || args[1] == "1";
else mp.ShowPing = !mp.ShowPing;
print("Ping: " + mp.PingText + " (display " + (mp.ShowPing ? "on" : "off") + ")");
break;
case "admin":
case "/admin":
mp.OpenAdminMenu();
-405
View File
@@ -1,405 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Mitspieler-Figur aus einem echten My-Summer-Car-NPC (Körper-Mesh "bodymesh" + Skelett mit "pelvis").
/// Der NPC wird kopiert und von seiner Logik befreit; es bleiben Aussehen und die Spiel-Animationen
/// (Idle/Walk). Nach der Animation werden die Knochen für Ducken, Lehnen, Kopf-Nicken, Sitzen und
/// das Greifen zum Item verbogen (Zwei-Knochen-IK, unabhängig von den Achsen des Skeletts).
/// </summary>
public class GameCharacter
{
const float StandingEyeHeight = 1.65f;
const float EyeAboveHeadBone = 0.1f; // Augen liegen etwas über dem Kopf-Knochen
const float AnkleHeight = 0.07f; // Fuß-Knochen über dem Boden
int frames;
bool calibrated;
public Transform Root { get; private set; }
public string Description { get; private set; }
Animation anim;
string walkClip, idleClip;
Transform pelvis, spine, head;
readonly Transform[] thigh = new Transform[2], knee = new Transform[2], foot = new Transform[2];
readonly Transform[] upperArm = new Transform[2], forearm = new Transform[2], hand = new Transform[2];
readonly Vector3[] animatedFoot = new Vector3[2];
/// <summary>
/// Knochen, die wir verbiegen. Bewegt die Animation einen davon nicht selbst, würden sich unsere
/// Änderungen jedes Frame aufsummieren - dann wird er vorher auf die Grundhaltung zurückgesetzt.
/// </summary>
class BoneState
{
public Transform Bone;
public Vector3 RestPos, OutPos;
public Quaternion RestRot, OutRot;
public bool HasOut;
}
readonly List<BoneState> posed = new List<BoneState>();
public Vector3 HeadPosition { get { return head.position + Root.up * EyeAboveHeadBone; } }
// ---------------------------------------------------------------- Vorlage finden und kopieren
static List<GameObject> templates;
/// <summary>Kopiert einen NPC aus der Spielszene als Figur. null, wenn keine passende Vorlage da ist.</summary>
public static GameCharacter TryCreate(Transform parent, int variant)
{
List<GameObject> candidates = FindTemplates();
if (candidates.Count == 0) return null;
GameObject template = candidates[Mathf.Abs(variant) % candidates.Count];
GameObject clone = (GameObject)UnityEngine.Object.Instantiate(template);
clone.name = "Model";
Strip(clone);
clone.SetActive(true);
clone.transform.SetParent(parent, false);
clone.transform.localPosition = Vector3.zero;
clone.transform.localRotation = Quaternion.identity;
clone.transform.localScale = Vector3.one;
var c = new GameCharacter { Root = clone.transform };
if (!c.FindBones())
{
UnityEngine.Object.Destroy(clone);
return null;
}
c.Normalize();
c.SetupAnimation();
c.RememberRestPose();
c.Description = template.name + " (" + candidates.Count + " templates) - " + c.BoneReport();
return c;
}
/// <summary>NPC-Körper in der Szene (auch inaktive): SkinnedMeshRenderer "bodymesh" mit "pelvis"-Knochen.</summary>
static List<GameObject> FindTemplates()
{
if (templates != null)
{
templates.RemoveAll(t => t == null);
if (templates.Count > 0) return templates;
}
var scored = new List<KeyValuePair<GameObject, int>>();
var seen = new HashSet<string>();
foreach (UnityEngine.Object o in Resources.FindObjectsOfTypeAll(typeof(SkinnedMeshRenderer)))
{
var smr = (SkinnedMeshRenderer)o;
if (smr == null || !smr.name.ToLower().Contains("bodymesh") || smr.transform.parent == null) continue;
bool hasPelvis = false;
if (smr.bones != null)
foreach (Transform b in smr.bones)
if (b != null && b.name == "pelvis") { hasPelvis = true; break; }
if (!hasPelvis) continue;
GameObject root = smr.transform.parent.gameObject; // Körper + Skelett liegen gemeinsam darunter
if (root.transform.root.name == "PLAYER" || root.name.StartsWith("MSCO")) continue;
// Keine Sammelgruppen mit mehreren Personen kopieren (nur eine Figur, evtl. mit Varianten dünn/normal/dick)
if (PelvisCount(root.transform) != 1 || root.GetComponentsInChildren<Transform>(true).Length > 600) continue;
int score = AnimationScore(ref root);
if (!seen.Add(root.name + "/" + smr.sharedMesh)) continue; // gleiche Figur nicht mehrfach
scored.Add(new KeyValuePair<GameObject, int>(root, score));
}
// Nur die besten: am liebsten NPCs mit Lauf- UND Steh-Animation (ohne Gesten wie Anhalter/Betrunken)
int best = 0;
foreach (var kv in scored) best = Mathf.Max(best, kv.Value);
templates = new List<GameObject>();
foreach (var kv in scored) if (kv.Value == best) templates.Add(kv.Key);
templates.Sort((a, b) => string.CompareOrdinal(a.name, b.name));
return templates;
}
static int PelvisCount(Transform root)
{
int count = 0;
foreach (Transform t in root.GetComponentsInChildren<Transform>(true)) if (t.name == "pelvis") count++;
return count;
}
// Animationen, die keine normale Steh-/Laufhaltung sind
static readonly string[] GestureWords = { "hitch", "drunk", "fat", "lift", "sit", "wave", "point", "dance", "angry", "back", "theatre", "car" };
static bool IsGesture(string clip)
{
string n = clip.ToLower();
foreach (string g in GestureWords) if (n.Contains(g)) return true;
return false;
}
/// <summary>
/// Bewertet die Animationen eines NPCs (liegt die Animation eine Ebene höher, wird dieser Knoten
/// die Vorlage). 3 = normales Laufen + Stehen, 2 = nur Laufen, 1 = nur Stehen, 0 = keine.
/// </summary>
static int AnimationScore(ref GameObject root)
{
Animation[] found = root.GetComponentsInChildren<Animation>(true); // Unity 5.0: auch inaktive finden
Animation anim = found.Length > 0 ? found[0] : null;
if (anim == null)
{
Transform p = root.transform.parent;
for (int i = 0; i < 2 && p != null && anim == null; i++, p = p.parent)
{
var candidate = p.GetComponent<Animation>();
if (candidate != null && PelvisCount(p) == 1)
{
anim = candidate;
root = p.gameObject;
}
}
}
if (anim == null) return 0;
bool walk = false, idle = false;
foreach (AnimationState s in anim)
{
if (IsGesture(s.name)) continue;
string n = s.name.ToLower();
if (n.Contains("walk")) walk = true;
if (n.Contains("idle")) idle = true;
}
return (walk ? 2 : 0) + (idle ? 1 : 0);
}
/// <summary>Alles außer Aussehen und Animation entfernen (KI-FSMs, Physik, Sounds, Trigger ...).</summary>
static void Strip(GameObject go)
{
foreach (Joint j in go.GetComponentsInChildren<Joint>(true)) UnityEngine.Object.DestroyImmediate(j);
foreach (Rigidbody r in go.GetComponentsInChildren<Rigidbody>(true)) UnityEngine.Object.DestroyImmediate(r);
foreach (Collider c in go.GetComponentsInChildren<Collider>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (Behaviour b in go.GetComponentsInChildren<Behaviour>(true))
{
if (b is Animation || b is Animator) continue;
UnityEngine.Object.DestroyImmediate(b);
}
foreach (AudioSource a in go.GetComponentsInChildren<AudioSource>(true)) UnityEngine.Object.DestroyImmediate(a);
}
bool FindBones()
{
pelvis = Find("pelvis");
spine = Find("spine_upper") ?? Find("spine_middle") ?? Find("spine_mid");
head = Find("head");
if (pelvis == null || spine == null || head == null) return false;
string[] sides = { "left", "right" };
for (int i = 0; i < 2; i++)
{
knee[i] = Find("knee_" + sides[i]);
if (knee[i] != null)
{
thigh[i] = knee[i].parent;
foot[i] = knee[i].childCount > 0 ? knee[i].GetChild(0) : null;
}
hand[i] = Find("hand_" + sides[i]);
if (hand[i] != null && hand[i].parent != null)
{
forearm[i] = hand[i].parent;
upperArm[i] = forearm[i].parent;
}
}
return true;
}
Transform Find(string name)
{
foreach (Transform t in Root.GetComponentsInChildren<Transform>(true))
if (string.Equals(t.name, name, StringComparison.OrdinalIgnoreCase)) return t;
return null;
}
/// <summary>Größe auf Spielergröße bringen, Füße auf den Boden, Blickrichtung nach +Z.</summary>
void Normalize()
{
// Blickrichtung: Zehen (Kind des Fußes) liegen vor dem Fuß
Transform toe = foot[0] != null && foot[0].childCount > 0 ? foot[0].GetChild(0) : null;
if (toe != null && Root.parent.InverseTransformPoint(toe.position).z < Root.parent.InverseTransformPoint(foot[0].position).z - 0.01f)
Root.localRotation = Quaternion.Euler(0f, 180f, 0f);
float eye = Root.parent.InverseTransformPoint(head.position).y + EyeAboveHeadBone;
if (eye > 0.5f) Root.localScale = Vector3.one * Mathf.Clamp(StandingEyeHeight / eye, 0.6f, 1.6f);
float lowest = float.MaxValue;
for (int i = 0; i < 2; i++)
if (foot[i] != null) lowest = Mathf.Min(lowest, Root.parent.InverseTransformPoint(foot[i].position).y);
if (lowest < float.MaxValue) Root.localPosition = new Vector3(0f, -lowest + 0.06f, 0f);
}
void SetupAnimation()
{
anim = Root.GetComponentInChildren<Animation>();
if (anim == null) return;
foreach (AnimationState s in anim)
{
if (IsGesture(s.name)) continue;
string n = s.name.ToLower();
if (walkClip == null && n.Contains("walk")) walkClip = s.name;
if (idleClip == null && n.Contains("idle")) idleClip = s.name;
}
// Keine Steh-Animation: im Stand die Laufanimation angehalten zeigen (statt einer Geste)
if (idleClip == null && walkClip == null && anim.clip != null && !IsGesture(anim.clip.name)) idleClip = anim.clip.name;
if (walkClip != null) anim[walkClip].wrapMode = WrapMode.Loop;
if (idleClip != null) anim[idleClip].wrapMode = WrapMode.Loop;
if (idleClip != null) anim.Play(idleClip);
}
void RememberRestPose()
{
var bones = new List<Transform> { pelvis, spine, head };
bones.AddRange(thigh);
bones.AddRange(knee);
bones.AddRange(upperArm);
bones.AddRange(forearm);
foreach (Transform b in bones)
if (b != null)
posed.Add(new BoneState { Bone = b, RestPos = b.localPosition, RestRot = b.localRotation });
}
void ResetUnanimatedBones()
{
foreach (BoneState s in posed)
{
if (s.HasOut && s.Bone.localPosition == s.OutPos && s.Bone.localRotation == s.OutRot)
{
s.Bone.localPosition = s.RestPos;
s.Bone.localRotation = s.RestRot;
}
}
}
void RememberOutput()
{
foreach (BoneState s in posed)
{
s.OutPos = s.Bone.localPosition;
s.OutRot = s.Bone.localRotation;
s.HasOut = true;
}
}
string BoneReport()
{
var sb = new StringBuilder();
sb.Append("legs ").Append(knee[0] != null && foot[0] != null ? "ok" : "missing");
sb.Append(", arms ").Append(hand[1] != null && upperArm[1] != null ? "ok" : "missing");
sb.Append(", anim idle=").Append(idleClip ?? "-").Append(" walk=").Append(walkClip ?? "-");
return sb.ToString();
}
// ---------------------------------------------------------------- Pro Frame (LateUpdate, nach der Animation)
public void Animate(float speed)
{
if (anim == null) return;
bool walking = speed > 0.4f && walkClip != null;
string clip = walking ? walkClip : (idleClip ?? walkClip);
if (clip == null) return;
if (!anim.IsPlaying(clip)) anim.CrossFade(clip, 0.25f);
if (clip == walkClip) anim[walkClip].speed = walking ? Mathf.Clamp(speed / 1.4f, 0.6f, 2.2f) : 0f; // angehalten = stehen
}
public void Pose(Transform body, Vector3 headTarget, float pitch, bool seated, bool holding, Vector3 heldPos)
{
Vector3 up = body.up, forward = body.forward, right = body.right;
ResetUnanimatedBones();
frames++;
// 1) Auf den Boden stellen: die (animierten) Füße stehen auf der Bodenhöhe des Spielers.
// Die Grundhaltung eines NPCs passt oft nicht zur Animation - deshalb jedes Frame nachmessen.
float lowestFoot = float.MaxValue;
for (int i = 0; i < 2; i++)
if (foot[i] != null) lowestFoot = Mathf.Min(lowestFoot, Vector3.Dot(foot[i].position - body.position, up));
if (lowestFoot < float.MaxValue)
{
Vector3 lp = Root.localPosition;
lp.y -= lowestFoot - AnkleHeight;
Root.localPosition = lp;
}
// 2) Größe einmal an die laufende Animation anpassen (Augenhöhe wie der Spieler)
if (!calibrated && frames > 5 && !seated)
{
calibrated = true;
float eye = Vector3.Dot(head.position + up * EyeAboveHeadBone - body.position, up);
if (eye > 0.5f) Root.localScale *= Mathf.Clamp(StandingEyeHeight / eye, 0.7f, 1.4f);
RememberOutput();
return; // nächstes Frame mit neuer Größe neu auf den Boden stellen
}
// Fußpositionen der Animation merken (die sollen stehen bleiben)
for (int i = 0; i < 2; i++)
if (foot[i] != null) animatedFoot[i] = foot[i].position;
// 3) Becken: beim Ducken/Sitzen absenken, beim Lehnen nach hinten ausweichen
Vector3 eyeNow = head.position + up * EyeAboveHeadBone;
Vector3 offset = headTarget - eyeNow;
Vector3 horizontal = offset - up * Vector3.Dot(offset, up);
if (horizontal.magnitude > 0.6f) horizontal = horizontal.normalized * 0.6f;
float drop = Mathf.Clamp(Vector3.Dot(offset, up), -1.1f, 0f);
pelvis.position += up * drop - horizontal * 0.25f;
// Wirbelsäule so drehen, dass der Kopf an der Kamera sitzt (nur wenn nötig)
if (horizontal.sqrMagnitude > 0.0025f || drop < -0.05f)
Aim(spine, head.position + up * EyeAboveHeadBone, head.position + up * EyeAboveHeadBone + horizontal * 0.75f);
// Kopf nicken
head.rotation = Quaternion.AngleAxis(pitch, right) * head.rotation;
// Beine
for (int i = 0; i < 2; i++)
{
if (thigh[i] == null || knee[i] == null || foot[i] == null) continue;
Vector3 target = seated
? thigh[i].position + forward * 0.45f - up * 0.4f
: animatedFoot[i];
SolveTwoBone(thigh[i], knee[i], foot[i], target, forward);
}
// Arme: rechts zum gehaltenen Item, im Auto beide nach vorne (Lenkrad)
for (int i = 0; i < 2; i++)
{
if (upperArm[i] == null || forearm[i] == null || hand[i] == null) continue;
if (i == 1 && holding)
SolveTwoBone(upperArm[i], forearm[i], hand[i], heldPos, -forward - up * 0.5f);
else if (seated)
SolveTwoBone(upperArm[i], forearm[i], hand[i], upperArm[i].position + forward * 0.38f - up * 0.28f, -up);
}
RememberOutput();
}
/// <summary>Knochen so drehen, dass "child" in Richtung "target" zeigt - egal wie das Skelett ausgerichtet ist.</summary>
static void Aim(Transform bone, Vector3 childPos, Vector3 target)
{
Vector3 from = childPos - bone.position;
Vector3 to = target - bone.position;
if (from.sqrMagnitude < 1e-6f || to.sqrMagnitude < 1e-6f) return;
bone.rotation = Quaternion.FromToRotation(from, to) * bone.rotation;
}
static void SolveTwoBone(Transform upper, Transform lower, Transform end, Vector3 target, Vector3 bendHint)
{
float a = (lower.position - upper.position).magnitude;
float b = (end.position - lower.position).magnitude;
if (a < 0.01f || b < 0.01f) return;
Vector3 root = upper.position;
Vector3 toTarget = target - root;
float d = Mathf.Clamp(toTarget.magnitude, 0.01f, a + b - 0.001f);
Vector3 dir = toTarget.normalized;
float along = (a * a - b * b + d * d) / (2f * d);
float sideways = Mathf.Sqrt(Mathf.Max(0f, a * a - along * along));
Vector3 bend = bendHint - dir * Vector3.Dot(bendHint, dir);
if (bend.sqrMagnitude < 1e-6f) bend = Vector3.up - dir * Vector3.Dot(Vector3.up, dir);
Vector3 joint = root + dir * along + bend.normalized * sideways;
Aim(upper, lower.position, joint);
Aim(lower, end.position, root + dir * d);
}
}
}
+45 -1
View File
@@ -74,11 +74,15 @@ namespace MSCO
Cursor.lockState = CursorLockMode.None;
}
/// <summary>PlayMaker-Aktionen, die den Mauszeiger setzen, überspringen, solange ein Fenster offen ist.</summary>
/// <summary>
/// PlayMaker-Aktionen, die den Mauszeiger setzen, überspringen, solange ein Fenster offen ist. Außerdem die
/// Tastenabfragen des Spiels (cInput) sperren: sonst löst Tippen im Chat Spielaktionen aus (Benutzen, Hupe ...).
/// </summary>
static void PatchCursorActions()
{
if (patched) return;
patched = true;
PatchGameInput();
try
{
Type action = null;
@@ -104,5 +108,45 @@ namespace MSCO
}
static bool SkipWhileLocked() { return !locked; }
static void PatchGameInput()
{
try
{
Type input = typeof(cInput);
HarmonyInstance harmony = HarmonyInstance.Create("mbm.msco.inputlock.keys");
var boolPrefix = new HarmonyMethod(typeof(InputLock).GetMethod("NoKeyWhileLocked", BindingFlags.Static | BindingFlags.NonPublic));
var floatPrefix = new HarmonyMethod(typeof(InputLock).GetMethod("NoAxisWhileLocked", BindingFlags.Static | BindingFlags.NonPublic));
int count = 0;
foreach (MethodInfo m in input.GetMethods(BindingFlags.Static | BindingFlags.Public))
{
bool keyQuery = m.Name == "GetKey" || m.Name == "GetKeyDown" || m.Name == "GetKeyUp" ||
m.Name == "GetButton" || m.Name == "GetButtonDown" || m.Name == "GetButtonUp";
bool axisQuery = m.Name == "GetAxis" || m.Name == "GetAxisRaw";
if (keyQuery && m.ReturnType == typeof(bool)) { harmony.Patch(m, boolPrefix, null); count++; }
else if (axisQuery && m.ReturnType == typeof(float)) { harmony.Patch(m, floatPrefix, null); count++; }
}
ModConsole.Print("[MSCO] Game input is paused while typing (" + count + " cInput queries)");
}
catch (Exception e)
{
ModConsole.Warning("[MSCO] Could not pause game input while typing: " + e.Message);
}
}
// Harmony-Prefixe: solange ein Eingabefenster offen ist, meldet cInput "nichts gedrückt"
static bool NoKeyWhileLocked(ref bool __result)
{
if (!locked) return true;
__result = false;
return false;
}
static bool NoAxisWhileLocked(ref float __result)
{
if (!locked) return true;
__result = 0f;
return false;
}
}
}
+2 -2
View File
@@ -216,7 +216,7 @@ namespace MSCO
float nameX = row.x + 26;
if (s.IsLocal)
{
// Server auf diesem PC (wie bei FiveM "localhost")
// Server auf diesem PC (localhost)
nameX += Pill(nameX, row, "LOCAL", pillStyle);
}
if (!string.IsNullOrEmpty(s.Version) && s.Version != MSCO.ModVersion)
@@ -283,7 +283,7 @@ namespace MSCO
}
}
/// <summary>Server-Profil wie bei FiveM: wem der Server gehört, Version, Spieler, Laufzeit ...</summary>
/// <summary>Server-Profil: wem der Server gehört, Version, Spieler, Laufzeit ...</summary>
void DrawServerProfile(MultiplayerManager mp, ServerListEntry s)
{
if (GUILayout.Button("◂ BACK", tabStyle, GUILayout.Height(26), GUILayout.Width(90)))
+1 -1
View File
@@ -5,7 +5,7 @@ namespace MSCO
{
public class MSCO : Mod
{
public const string ModVersion = "2.6";
public const string ModVersion = "2.7";
public override string ID => "MSCO"; // Your (unique) mod ID
public override string Name => "MSCO"; // Your mod name
+1 -1
View File
@@ -75,6 +75,7 @@
<Compile Include="MoneySync.cs" />
<Compile Include="MpCommand.cs" />
<Compile Include="Commands.cs" />
<Compile Include="Cheats.cs" />
<Compile Include="InputLock.cs" />
<Compile Include="Overlays.cs" />
<Compile Include="SafetyPatches.cs" />
@@ -85,7 +86,6 @@
<Compile Include="DiscordPresence.cs" />
<Compile Include="DoorSync.cs" />
<Compile Include="FsmSync.cs" />
<Compile Include="GameCharacter.cs" />
<Compile Include="JoinLinks.cs" />
<Compile Include="LobbyUI.cs" />
<Compile Include="MenuHelper.cs" />
+219 -26
View File
@@ -64,6 +64,9 @@ namespace MSCO
Steamworks.CSteamID pendingLobby; // Einladung, die noch angenommen werden muss
Steamworks.CSteamID steamHost; // Client: Steam-ID des Hosts
float nextPing;
float pingMs = -1f; // geglättete Laufzeit zum Server/Host (-1 = noch unbekannt)
const string PrefShowPing = "MSCO_ShowPing";
GUIStyle pingStyle;
float savingMessageUntil;
GUIStyle hudStyle, savingStyle;
Texture2D hudBackground;
@@ -221,6 +224,121 @@ namespace MSCO
return list;
}
// ---------------------------------------------------------------- Ping
/// <summary>Laufzeit zum Server/Host in ms (-1 = unbekannt, z.B. alter Server oder man ist selbst Host).</summary>
public float PingMs { get { return pingMs; } }
/// <summary>Ping-Anzeige oben rechts (Konsole: "ping"), bleibt gespeichert.</summary>
public bool ShowPing
{
get { return PlayerPrefs.GetInt(PrefShowPing, 0) == 1; }
set { PlayerPrefs.SetInt(PrefShowPing, value ? 1 : 0); }
}
/// <summary>Text für Anzeige und Konsole.</summary>
public string PingText
{
get
{
if (session == null) return "not connected";
if (session.IsHost) return "you are the host";
return pingMs < 0f ? "measuring..." : Mathf.RoundToInt(pingMs) + " ms";
}
}
void DrawPing()
{
if (!ShowPing || session == null) return;
if (pingStyle == null)
{
pingStyle = UiTheme.Panel9(new Color(0.06f, 0.06f, 0.07f, 0.85f), 6);
pingStyle.fontSize = 12;
pingStyle.fontStyle = FontStyle.Bold;
pingStyle.alignment = TextAnchor.MiddleCenter;
pingStyle.padding = new RectOffset(10, 10, 0, 0);
}
// Farbe nach Qualität: grün < 80 ms, gelb < 150 ms, sonst rot
Color color = pingMs < 0f ? UiTheme.Muted : pingMs < 80f ? UiTheme.Online : pingMs < 150f ? UiTheme.Pending : UiTheme.Danger;
pingStyle.normal.textColor = color;
var content = new GUIContent("PING " + PingText);
float width = pingStyle.CalcSize(content).x;
GUI.Box(new Rect(Screen.width - width - 12, 8, width, 24), content, pingStyle);
}
// ---------------------------------------------------------------- Cheats (Admin-Menü)
/// <summary>Cheats nur im Spiel und nur für Server-Admins bzw. den P2P-Host.</summary>
public bool CanCheat { get { return session != null && MenuHelper.InGame && (dedicated ? isAdmin : session.IsHost); } }
public void CheatMoney(float amount)
{
if (!CanCheat) return;
overlays.Log(Cheats.AddMoney(amount));
AnnounceCheat("gave themselves " + amount.ToString("0") + " MK");
}
public void CheatNeeds()
{
if (!CanCheat) return;
overlays.Log(Cheats.FillNeeds());
AnnounceCheat("refilled their needs");
}
/// <summary>Zu einem Mitspieler teleportieren.</summary>
public void GotoPlayer(int playerId)
{
PlayerInfo p;
if (!CanCheat || !players.TryGetValue(playerId, out p) || p.Avatar == null) { overlays.Log("Player position unknown"); return; }
passenger.Exit(localPlayer, playerCamera);
PlayerState s = p.Avatar.LastState;
Vector3 side = Quaternion.Euler(0f, s.RotationY, 0f) * Vector3.right;
if (Cheats.Teleport(localPlayer, s.Position + side * TeleportSideOffset)) overlays.Log("Teleported to " + p.Name);
}
/// <summary>Mitspieler zu sich holen (Server prüft, ob man Admin ist).</summary>
public void BringPlayer(int playerId)
{
if (!CanCheat || localPlayer == null || playerId == localId) return;
Vector3 pos = localPlayer.position + localPlayer.forward * TeleportSideOffset;
if (dedicated)
{
session.SendTo(NetSession.HostId, MsgType.AdminAction, NetSession.Build(w =>
{
w.Write((byte)3);
w.Write(playerId);
w.Write("");
w.Write(pos.x); w.Write(pos.y); w.Write(pos.z);
}));
}
else if (players.ContainsKey(playerId))
{
session.SendTo(playerId, MsgType.Teleport, NetSession.Build(w => { w.Write(pos.x); w.Write(pos.y); w.Write(pos.z); }));
overlays.Log("Brought " + players[playerId].Name + " to you");
}
}
/// <summary>Admin: Satsuma neben sich holen (z.B. wenn er verschwunden oder im Boden ist).</summary>
public void BringCar()
{
if (!CanCheat || localPlayer == null) return;
Vector3 right = localPlayer.right;
Vector3 pos = localPlayer.position + right * 4f;
Quaternion rot = Quaternion.LookRotation(localPlayer.forward, Vector3.up);
string result = objects.BringVehicle("SATSUMA", pos, rot);
overlays.Log(result);
overlays.AddChat("", result, true);
if (result.EndsWith("next to you")) AnnounceCheat("brought the Satsuma to themselves");
}
/// <summary>Cheats für alle sichtbar im Chat melden.</summary>
void AnnounceCheat(string what)
{
string text = OwnName() + " " + what + " (admin)";
overlays.AddChat("", text, true);
SendGame(MsgType.Chat, NetSession.Build(w => w.Write("* " + text)));
}
public void AdminAction(bool ban, int playerId, string reason)
{
if (session == null) return;
@@ -577,7 +695,8 @@ namespace MSCO
if (Time.realtimeSinceStartup >= nextPing)
{
nextPing = Time.realtimeSinceStartup + PingInterval;
session.SendToAll(MsgType.Ping);
float sentAt = Time.realtimeSinceStartup;
session.SendToAll(MsgType.Ping, NetSession.Build(w => w.Write(sentAt))); // Zeitstempel für die Ping-Messung
}
foreach (NetEvent e in session.Poll())
@@ -644,6 +763,7 @@ namespace MSCO
if (session != null && session.IsSteam) steam.Leave();
session = null;
handshakeDone = false;
pingMs = -1f;
teleportToHost = false;
dedicated = false;
connectingToServer = false;
@@ -692,6 +812,22 @@ namespace MSCO
{
BinaryReader r = m.Reader();
// Ping-Messung: der Host schickt den Zeitstempel sofort zurück, der Client rechnet die Laufzeit aus
if (m.Type == MsgType.Ping)
{
if (session.IsHost && m.Payload.Length >= 4) session.SendTo(peerId, MsgType.Pong, m.Payload);
return;
}
if (m.Type == MsgType.Pong)
{
if (!session.IsHost && m.Payload.Length >= 4)
{
float rtt = (Time.realtimeSinceStartup - r.ReadSingle()) * 1000f;
if (rtt >= 0f && rtt < 60000f) pingMs = pingMs < 0f ? rtt : Mathf.Lerp(pingMs, rtt, 0.4f);
}
return;
}
if (IsGameMessage(m.Type))
{
int from;
@@ -835,6 +971,17 @@ namespace MSCO
SaveSync.WriteFile(file, data);
break;
case MsgType.Teleport:
{
// Ein Admin holt uns zu sich
var pos = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
if (!MenuHelper.InGame || localPlayer == null) break;
passenger.Exit(localPlayer, playerCamera);
Cheats.Teleport(localPlayer, pos);
overlays.AddChat("", "An admin teleported you", true);
break;
}
case MsgType.AdminStatus:
isAdmin = r.ReadBoolean();
if (isAdmin) overlays.Log("You are an admin on this server - type /admin in chat or 'admin' here.");
@@ -1086,67 +1233,107 @@ namespace MSCO
StartCoroutine(ApplyWorldSnapshot(snapshot));
}
if (MSCO.PassengerKey != null && MSCO.PassengerKey.GetKeybindDown())
if (MSCO.PassengerKey != null && !overlays.AnyOpen && MSCO.PassengerKey.GetKeybindDown())
{
string result = passenger.Toggle(localPlayer, playerCamera, objects, players);
if (result != null) SetStatus(result);
if (result != null)
{
SetStatus(result);
overlays.AddChat("", result, true); // im Spiel sichtbar, nicht nur in der Konsole
}
}
bool ready = IsPeerReady;
if (ready)
{
// Jedes Teilsystem einzeln abgesichert: ein Fehler in einem (z.B. an einem gerade zerstörten Objekt)
// soll nicht den ganzen Sync stoppen
sendTimer += Time.unscaledDeltaTime; // läuft auch im Pausenmenü (timeScale = 0) weiter
if (sendTimer >= SendInterval)
{
sendTimer = 0f;
SendGame(MsgType.Player, BuildLocalState().Serialize());
try { SendGame(MsgType.Player, BuildLocalState().Serialize()); }
catch (System.Exception e) { ReportError("player state", e); }
}
objects.Update(localPlayer, Time.deltaTime);
vehicleFx.Update(objects, SendGame, Time.deltaTime);
worldSync.Update(IsWorldAuthority, SendGame, Time.unscaledDeltaTime);
try { objects.Update(localPlayer, Time.deltaTime); }
catch (System.Exception e) { ReportError("object sync", e); }
try { vehicleFx.Update(objects, SendGame, Time.deltaTime); }
catch (System.Exception e) { ReportError("vehicle lights/sound", e); }
try { worldSync.Update(IsWorldAuthority, SendGame, Time.unscaledDeltaTime); }
catch (System.Exception e) { ReportError("time/weather sync", e); }
// Geld erst beobachten, wenn das Spiel fertig geladen ist
if (objects.ScanComplete) moneySync.Begin();
moneySync.Update(SendGame);
try
{
if (objects.ScanComplete) moneySync.Begin();
moneySync.Update(SendGame);
}
catch (System.Exception e) { ReportError("money sync", e); }
// Türen, Tore, Hauben (z.B. Garagentor)
if (objects.ScanComplete) doorSync.Update(SendGame);
try { if (objects.ScanComplete) doorSync.Update(SendGame); }
catch (System.Exception e) { ReportError("door sync", e); }
// Gesamtzustand der Fahrzeuge (Tank, Öl, Schrauben, Verschleiß ...)
if (objects.ScanComplete) vehicleState.Update(objects, localId, SendGame);
// Gesamtzustand der Fahrzeuge (Tank, Öl, Verschleiß ...)
try { if (objects.ScanComplete) vehicleState.Update(objects, localId, SendGame); }
catch (System.Exception e) { ReportError("vehicle state sync", e); }
}
foreach (PlayerInfo p in players.Values)
{
if (p.Avatar == null) continue;
p.Avatar.Tick(Time.unscaledDeltaTime);
try
{
p.Avatar.Tick(Time.unscaledDeltaTime);
// Gehaltenes Item an die Hand der Figur setzen
Vector3 pos;
Quaternion rot;
if (p.Avatar.TryGetHeldPose(out pos, out rot))
objects.ApplyAvatarHold(p.Id, p.Avatar.HeldId, pos, rot);
// Gehaltenes Item an die Hand der Figur setzen
Vector3 pos;
Quaternion rot;
if (p.Avatar.TryGetHeldPose(out pos, out rot))
objects.ApplyAvatarHold(p.Id, p.Avatar.HeldId, pos, rot);
}
catch (System.Exception e) { ReportError("player figure", e); }
}
// Abgefangene Schraub-/Montage-Aktionen verschicken
FsmSync.Enabled = ready;
FsmSync.Tick();
foreach (FsmEventMsg msg in FsmSync.Outbox) SendGame(MsgType.FsmEvent, msg.Serialize());
try
{
FsmSync.Enabled = ready;
FsmSync.Tick();
foreach (FsmEventMsg msg in FsmSync.Outbox) SendGame(MsgType.FsmEvent, msg.Serialize());
}
catch (System.Exception e) { ReportError("part/bolt sync", e); }
FsmSync.Outbox.Clear();
}
readonly Dictionary<string, float> lastErrorReport = new Dictionary<string, float>();
/// <summary>Fehler eines Teilsystems melden (höchstens alle 30 s pro Teilsystem, damit das Log nicht vollläuft).</summary>
void ReportError(string what, System.Exception e)
{
float now = Time.realtimeSinceStartup, last;
if (lastErrorReport.TryGetValue(what, out last) && now - last < 30f) return;
lastErrorReport[what] = now;
ModConsole.Error("[MSCO] " + what + " failed: " + e);
overlays.Log("Error in " + what + ": " + e.Message);
}
void FixedUpdate()
{
if (IsPeerReady && MenuHelper.InGame) objects.FixedUpdate();
if (!IsPeerReady || !MenuHelper.InGame) return;
try { objects.FixedUpdate(); }
catch (System.Exception e) { ReportError("object movement", e); }
}
void LateUpdate()
{
InputLock.KeepCursorFree(); // das Spiel sperrt den Zeiger im selben Frame evtl. wieder
// Nach den Spiel-FSMs, damit Lichter/Motorgeräusch der Fahrzeuge anderer gewinnen
if (IsPeerReady && MenuHelper.InGame) vehicleFx.Apply(objects);
try { if (IsPeerReady && MenuHelper.InGame) vehicleFx.Apply(objects); }
catch (System.Exception e) { ReportError("vehicle lights/sound", e); }
// Beifahrer nach der Fahrzeugbewegung auf den Sitz setzen
if (localPlayer != null && MenuHelper.InGame) passenger.LateUpdate(localPlayer, playerCamera, objects);
try { if (localPlayer != null && MenuHelper.InGame) passenger.LateUpdate(localPlayer, playerCamera, objects); }
catch (System.Exception e) { ReportError("passenger", e); }
}
/// <summary>
@@ -1284,8 +1471,12 @@ namespace MSCO
static Color PlayerColor(int id)
{
Color[] colors = { Color.cyan, new Color(1f, 0.55f, 0.1f), Color.green, Color.magenta,
Color.yellow, new Color(0.4f, 0.6f, 1f), Color.red, Color.white };
// Gedeckte Farben wie Flanellhemden/Arbeitsjacken in MSC
Color[] colors =
{
new Color(0.6f, 0.13f, 0.12f), new Color(0.16f, 0.25f, 0.5f), new Color(0.2f, 0.38f, 0.2f), new Color(0.7f, 0.52f, 0.14f),
new Color(0.42f, 0.28f, 0.17f), new Color(0.33f, 0.36f, 0.4f), new Color(0.45f, 0.14f, 0.3f), new Color(0.38f, 0.4f, 0.16f)
};
return colors[id % colors.Length];
}
@@ -1303,6 +1494,7 @@ namespace MSCO
if (MenuHelper.InMenu)
{
lobby.Draw(this);
DrawPing();
overlays.Draw(this, false);
return;
}
@@ -1312,6 +1504,7 @@ namespace MSCO
GUI.Label(new Rect(0, Screen.height / 2f - 40, Screen.width, 80), "SAVING GAME...", savingStyle);
if (session != null) DrawHud();
DrawPing();
overlays.Draw(this, session != null);
}
+3 -1
View File
@@ -37,7 +37,9 @@ namespace MSCO
AdminAction = 26, // Client -> Server: byte (1 = kick, 2 = ban), int Spieler-ID, string Grund
AdminResult = 27, // Server -> Client: string Meldung
Chat = 28, // Chatnachricht (string)
VarSync = 29 // Fahrzeugzustand: int n, je [int key, byte typ, wert]
VarSync = 29, // Fahrzeugzustand: int n, je [int key, byte typ, wert]
Teleport = 30, // Admin holt einen Spieler zu sich: 3 float Position
Pong = 31 // Antwort auf Ping mit demselben Zeitstempel (Laufzeit messen)
}
public struct Message
+134 -11
View File
@@ -22,6 +22,9 @@ 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
// Angebaute Teile eines Fahrzeugs (eigene Rigidbodies mit Gelenken), solange ein anderer fährt: kinematisch
public Dictionary<Rigidbody, bool> FrozenParts;
public float NextPartScan;
public float StillTime;
public float NoMotionClaimUntil; // kurz nach einer Übergabe nicht wegen Nachfedern/Nachrollen übernehmen
@@ -63,6 +66,7 @@ namespace MSCO
const float ReleaseAfterStill = 1f;
const float SnapDistance = 10f;
const float RemoteTimeout = 3f; // Besitzer meldet sich nicht mehr (z.B. Teil wurde dort montiert)
const float RunawaySpeed = 90f; // m/s - schneller fährt in MSC nichts; darüber hat die Physik es weggeschleudert
const float HandoverCooldown = 3f; // nach Freigabe/Übergabe keine Übernahme durch Bewegung
const float MaxHoldDistance = 1.5f; // gehaltene Items sind so nah an der Kamera
const float VehicleClaimSpeed = 0.7f; // Fahrzeuge erst ab echter Bewegung übernehmen (nicht Federn)
@@ -102,6 +106,8 @@ namespace MSCO
{
SyncedBody b;
if (!byId.TryGetValue(id, out b) || b.Rb == null || b.OwnerId != SyncedBody.NoOwner) return false;
if (!IsValidPose(pos, rot)) return false;
if (b.IsVehicle) pos = AboveGround(pos, b.Rb.transform); // gespeicherte Position evtl. knapp im Boden
b.Rb.transform.position = pos;
b.Rb.transform.rotation = rot;
if (!b.Rb.isKinematic)
@@ -132,7 +138,8 @@ namespace MSCO
if (!b.IsVehicle) continue;
string owner = b.OwnerId == SyncedBody.NoOwner ? "free" : IsLocal(b) ? "ME" : "player " + b.OwnerId;
lines.Add(b.RootName + " id=" + b.Id + " owner=" + owner + (b.OwnerActive ? " (active)" : "") +
(b.Rb == null ? " [rigidbody gone]" : " kinematic=" + b.Rb.isKinematic + " speed=" + b.Rb.velocity.magnitude.ToString("0.0")));
(b.Rb == null ? " [rigidbody gone]" : " kinematic=" + b.Rb.isKinematic + " speed=" + b.Rb.velocity.magnitude.ToString("0.0") +
" pos=" + b.Rb.position.ToString("F0") + (b.Rb.gameObject.activeInHierarchy ? "" : " [inactive]")));
}
if (lines.Count == 1) lines.Add("No vehicles registered! (SATSUMA, HAYOSIKO, ... not found as root objects with a Rigidbody)");
return lines;
@@ -173,7 +180,7 @@ namespace MSCO
public void Reset()
{
foreach (SyncedBody b in byId.Values)
if (IsRemoteOwned(b) && b.Rb != null) b.Rb.isKinematic = b.WasKinematic;
if (IsRemoteOwned(b) && b.Rb != null) RestoreKinematic(b);
byId.Clear();
byRb.Clear();
rejected.Clear();
@@ -190,7 +197,7 @@ namespace MSCO
foreach (SyncedBody b in byId.Values)
{
if (b.OwnerId != playerId || b.Rb == null) continue;
b.Rb.isKinematic = b.WasKinematic;
RestoreKinematic(b);
b.OwnerId = SyncedBody.NoOwner;
b.NoMotionClaimUntil = Time.time + HandoverCooldown;
}
@@ -242,6 +249,7 @@ namespace MSCO
if (!b.Rb.gameObject.activeInHierarchy) continue;
bool active = b == held || b == driven;
if (!IsRemoteOwned(b)) StopRunaway(b);
if (b.OwnerId == SyncedBody.NoOwner)
{
if (active) Claim(b, true);
@@ -285,6 +293,7 @@ namespace MSCO
foreach (SyncedBody b in byId.Values)
{
if (!IsRemoteOwned(b) || b.Rb == null) continue;
if (b.IsVehicle && Time.time >= b.NextPartScan) FreezeParts(b);
if (Time.time - b.AvatarHoldTime < 0.2f) continue; // sitzt gerade in der Hand eines Mitspielers
if ((b.Rb.position - b.TargetPos).sqrMagnitude > SnapDistance * SnapDistance)
{
@@ -306,7 +315,7 @@ namespace MSCO
public void ApplyAvatarHold(int playerId, int bodyId, Vector3 pos, Quaternion rot)
{
SyncedBody b;
if (!byId.TryGetValue(bodyId, out b) || b.Rb == null) return;
if (!byId.TryGetValue(bodyId, out b) || b.Rb == null || b.IsVehicle) return; // ältere Mods melden evtl. ein Auto als "gehalten"
if (b.OwnerId != playerId)
{
if (!Wins(playerId, true, b.OwnerId, b.OwnerActive)) return;
@@ -328,7 +337,7 @@ namespace MSCO
void Claim(SyncedBody b, bool active)
{
if (IsRemoteOwned(b)) b.Rb.isKinematic = b.WasKinematic;
if (IsRemoteOwned(b)) RestoreKinematic(b);
b.OwnerId = LocalId;
b.OwnerActive = active;
b.StillTime = 0f;
@@ -357,8 +366,7 @@ namespace MSCO
if (b.OwnerId == ownerId) return;
if (!IsRemoteOwned(b))
{
b.WasKinematic = b.Rb.isKinematic;
b.Rb.isKinematic = true;
FreezeForRemote(b);
b.TargetPos = b.Rb.position;
b.TargetRot = b.Rb.rotation;
}
@@ -367,11 +375,53 @@ namespace MSCO
void SetFree(SyncedBody b)
{
if (IsRemoteOwned(b)) b.Rb.isKinematic = b.WasKinematic;
if (IsRemoteOwned(b)) RestoreKinematic(b);
b.OwnerId = SyncedBody.NoOwner;
b.NoMotionClaimUntil = Time.time + HandoverCooldown;
}
/// <summary>
/// Ein anderer Spieler bewegt das Objekt: bei uns nur noch folgen (kinematisch). Bei Fahrzeugen auch alle
/// angebauten Teile - die hängen in MSC mit Gelenken (mit Bruchkraft) am Auto. Würde nur das Auto ruckartig
/// versetzt, rissen die Gelenke und das Auto fiele auseinander.
/// </summary>
void FreezeForRemote(SyncedBody b)
{
b.WasKinematic = b.Rb.isKinematic;
b.Rb.isKinematic = true;
if (b.IsVehicle) FreezeParts(b);
}
void FreezeParts(SyncedBody b)
{
b.NextPartScan = Time.time + 1f;
if (b.FrozenParts == null) b.FrozenParts = new Dictionary<Rigidbody, bool>();
foreach (Rigidbody part in b.Rb.GetComponentsInChildren<Rigidbody>())
{
if (part == b.Rb || b.FrozenParts.ContainsKey(part)) continue;
b.FrozenParts[part] = part.isKinematic;
part.isKinematic = true;
}
}
/// <summary>Wieder selbst simulieren: Auto und angebaute Teile zurück auf ihren vorherigen Zustand.</summary>
void RestoreKinematic(SyncedBody b)
{
if (b.Rb != null) b.Rb.isKinematic = b.WasKinematic;
if (b.FrozenParts == null) return;
foreach (KeyValuePair<Rigidbody, bool> kv in b.FrozenParts)
{
if (kv.Key == null) continue;
kv.Key.isKinematic = kv.Value;
if (!kv.Value)
{
kv.Key.velocity = Vector3.zero; // keinen Schwung aus dem Folgen mitnehmen
kv.Key.angularVelocity = Vector3.zero;
}
}
b.FrozenParts = null;
}
void SendOwnedStates()
{
sendList.Clear();
@@ -426,7 +476,7 @@ namespace MSCO
int id; Vector3 pos; Quaternion rot; Vector3 vel;
ReadState(r, out id, out pos, out rot, out vel);
SyncedBody b;
if (!byId.TryGetValue(id, out b) || b.Rb == null) continue;
if (!byId.TryGetValue(id, out b) || b.Rb == null || !IsValidPose(pos, rot)) continue;
// Nur dem aktuellen Besitzer folgen (oder freie Objekte übernehmen)
if (b.OwnerId != from && b.OwnerId != SyncedBody.NoOwner) continue;
SetRemote(b, from, b.OwnerId == from && b.OwnerActive);
@@ -442,9 +492,10 @@ namespace MSCO
ReadState(r, out id, out pos, out rot, out vel);
SyncedBody b;
if (!byId.TryGetValue(id, out b) || b.Rb == null || b.OwnerId != from) return;
if (!IsValidPose(pos, rot)) { SetFree(b); return; }
b.Rb.transform.position = pos;
b.Rb.transform.rotation = rot;
b.Rb.isKinematic = b.WasKinematic;
RestoreKinematic(b);
if (!b.Rb.isKinematic)
{
b.Rb.velocity = vel;
@@ -457,6 +508,76 @@ namespace MSCO
}
}
/// <summary>
/// Wird ein Objekt von der Physik weggeschleudert (z.B. nach dem Setzen einer Position, die im Boden lag),
/// sofort anhalten und auf die letzte gute Position zurücksetzen - statt es quer über die Karte fliegen zu lassen.
/// </summary>
void StopRunaway(SyncedBody b)
{
Rigidbody rb = b.Rb;
if (rb.isKinematic || rb.velocity.sqrMagnitude < RunawaySpeed * RunawaySpeed) return;
rb.velocity = Vector3.zero;
rb.angularVelocity = Vector3.zero;
if (IsLocal(b) && b.LastSentTime > 0f && IsValidPose(b.LastSentPos, b.LastSentRot))
{
rb.transform.position = AboveGround(b.LastSentPos, rb.transform);
rb.transform.rotation = b.LastSentRot;
}
else rb.transform.position = AboveGround(rb.position, rb.transform);
ModConsole.Warning("[MSCO] Stopped " + b.RootName + " - it was flung away by the physics");
}
/// <summary>Position innerhalb der Karte, Drehung gültig (gleiche Regel wie auf dem Server).</summary>
static bool IsValidPose(Vector3 p, Quaternion q)
{
if (float.IsNaN(p.x) || float.IsNaN(p.y) || float.IsNaN(p.z) || float.IsInfinity(p.x) || float.IsInfinity(p.y) || float.IsInfinity(p.z))
return false;
if (Mathf.Abs(p.x) > 6000f || Mathf.Abs(p.z) > 6000f || p.y < -200f || p.y > 3000f) return false;
float len = Mathf.Sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w);
return len > 0.9f && len < 1.1f;
}
/// <summary>
/// Liegt die Position im Boden, auf die Bodenoberfläche heben. Kurzer Strahl ab 2 m über dem Punkt
/// (nicht aufs Garagendach), die eigenen Teile des Fahrzeugs werden ignoriert.
/// </summary>
static Vector3 AboveGround(Vector3 pos, Transform ignore)
{
float ground = float.MinValue;
foreach (RaycastHit hit in Physics.RaycastAll(pos + Vector3.up * 2f, Vector3.down, 4f))
{
if (hit.collider.isTrigger || (ignore != null && hit.transform.IsChildOf(ignore))) continue;
if (hit.transform.root.name == "PLAYER") continue;
ground = Mathf.Max(ground, hit.point.y);
}
return ground > pos.y - 0.2f ? new Vector3(pos.x, ground + 0.6f, pos.z) : pos;
}
/// <summary>
/// Admin: Fahrzeug (Wurzelname beginnt mit rootPrefix) an eine Position holen. Fährt es gerade ein
/// anderer Spieler, geht das nicht. Gibt einen Statustext zurück.
/// </summary>
public string BringVehicle(string rootPrefix, Vector3 pos, Quaternion rot)
{
foreach (SyncedBody b in byId.Values)
{
if (!b.IsVehicle || b.Rb == null || !b.RootName.StartsWith(rootPrefix)) continue;
if (IsRemoteOwned(b) && b.OwnerActive) return rootPrefix + " is being driven by another player";
Claim(b, false); // wir besitzen es jetzt -> Position geht an alle und an den Server
b.Rb.transform.position = AboveGround(pos, b.Rb.transform);
b.Rb.transform.rotation = rot;
if (!b.Rb.isKinematic)
{
b.Rb.velocity = Vector3.zero;
b.Rb.angularVelocity = Vector3.zero;
}
b.StillTime = 0f;
b.LastSentTime = -100f;
return rootPrefix + " is next to you";
}
return rootPrefix + " not found (mp vehicles shows all vehicles)";
}
static void WriteState(BinaryWriter w, SyncedBody b)
{
Vector3 p = b.Rb.position;
@@ -534,7 +655,9 @@ namespace MSCO
{
Rigidbody rb = t.GetComponent<Rigidbody>();
SyncedBody b;
if (rb != null && byRb.TryGetValue(rb, out b)) return IsReallyInHand(b) ? b : null;
// Fahrzeuge hält man nie in der Hand: im Auto zeigt PickedObject z.B. aufs Lenkrad, dessen
// Physik-Körper ist das Auto selbst - das Auto würde sonst mit dem Kopf mitkippen
if (rb != null && byRb.TryGetValue(rb, out b)) return !b.IsVehicle && IsReallyInHand(b) ? b : null;
}
return null;
}
+26 -1
View File
@@ -263,7 +263,7 @@ namespace MSCO
void DrawAdmin(MultiplayerManager mp)
{
float width = Mathf.Min(600, Screen.width - 40), height = Mathf.Min(460, Screen.height - 40);
float width = Mathf.Min(680, Screen.width - 40), height = Mathf.Min(560, Screen.height - 40);
var rect = new Rect((Screen.width - width) / 2f, (Screen.height - height) / 2f, width, height);
GUI.Box(rect, GUIContent.none, panel);
@@ -284,6 +284,23 @@ namespace MSCO
GUILayout.Label(mp.IsDedicated ? "Players on this server. Banned players cannot join again." : "Players in your game.", small);
GUILayout.Space(10);
// Cheats für den eigenen Spieler (nur im Spiel)
if (mp.CanCheat)
{
GUILayout.Label("CHEATS", caption);
GUILayout.BeginHorizontal();
if (GUILayout.Button("+1.000 MK", primary, GUILayout.Height(30))) mp.CheatMoney(1000f);
GUILayout.Space(6);
if (GUILayout.Button("+10.000 MK", primary, GUILayout.Height(30))) mp.CheatMoney(10000f);
GUILayout.Space(6);
if (GUILayout.Button("REFILL NEEDS", primary, GUILayout.Height(30))) mp.CheatNeeds();
GUILayout.Space(6);
if (GUILayout.Button("SATSUMA TO ME", primary, GUILayout.Height(30))) mp.BringCar();
GUILayout.EndHorizontal();
GUILayout.Label("Cheats are announced in the chat. GOTO / BRING teleport you to a player or a player to you.", small);
GUILayout.Space(10);
}
GUILayout.Label("REASON (optional)", caption);
adminReason = GUILayout.TextField(adminReason, 100, field, GUILayout.Height(32));
GUILayout.Space(12);
@@ -304,6 +321,14 @@ namespace MSCO
GUILayout.FlexibleSpace();
bool self = p.Id == mp.LocalId;
if (mp.CanCheat)
{
GUI.enabled = !self;
if (GUILayout.Button("GOTO", secondary, GUILayout.Height(30), GUILayout.Width(70))) mp.GotoPlayer(p.Id);
GUILayout.Space(6);
if (GUILayout.Button("BRING", secondary, GUILayout.Height(30), GUILayout.Width(70))) mp.BringPlayer(p.Id);
GUILayout.Space(12);
}
GUI.enabled = !self && !p.IsAdmin;
if (GUILayout.Button("KICK", secondary, GUILayout.Height(30), GUILayout.Width(80)))
mp.AdminAction(false, p.Id, adminReason);
+12 -4
View File
@@ -11,7 +11,7 @@ namespace MSCO
/// </summary>
public class Passenger
{
const float EnterRange = 4f; // so nah muss man am Fahrzeug stehen
const float EnterRange = 5f; // so nah muss man am Fahrzeug stehen (Abstand zur Fahrzeugmitte)
const float CenterSeatThreshold = 0.2f;
const float BehindCenterSeat = 0.7f;
const float ExitSideOffset = 1.4f; // beim Aussteigen so weit neben den Sitz
@@ -40,14 +40,22 @@ namespace MSCO
SyncedBody best = null;
RemotePlayer driver = null;
float bestDist = EnterRange * EnterRange;
foreach (SyncedBody b in objects.RemoteDrivenVehicles())
List<SyncedBody> driven = objects.RemoteDrivenVehicles();
float nearest = float.MaxValue;
foreach (SyncedBody b in driven)
{
float d = (b.Rb.worldCenterOfMass - player.position).sqrMagnitude;
nearest = Mathf.Min(nearest, d);
PlayerInfo owner;
if (!players.TryGetValue(b.OwnerId, out owner) || owner.Avatar == null) continue;
float d = (b.Rb.worldCenterOfMass - player.position).sqrMagnitude;
if (d < bestDist) { bestDist = d; best = b; driver = owner.Avatar; }
}
if (best == null) return "No vehicle driven by another player nearby";
if (best == null)
{
// Genau sagen, woran es liegt
if (driven.Count == 0) return "Passenger: nobody is driving a vehicle right now (the driver must sit in the driver's seat)";
return "Passenger: get closer to the car (" + Mathf.Sqrt(nearest).ToString("0") + " m away, max " + EnterRange.ToString("0") + " m)";
}
Transform vt = best.Rb.transform;
Vector3 driverHead = vt.InverseTransformPoint(driver.HeadPosition);
+3 -3
View File
@@ -8,7 +8,7 @@ using System.Runtime.InteropServices;
[assembly: AssemblyTitle("MSCO")]
[assembly: AssemblyDescription("MSCLoader Mod file")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyCompany("MBM")]
[assembly: AssemblyProduct("MSCO")]
[assembly: AssemblyCopyright("Copyright © 2026 by MBM")]
[assembly: AssemblyTrademark("")]
@@ -32,5 +32,5 @@ using System.Runtime.InteropServices;
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]
[assembly: AssemblyVersion("2.7.0.0")]
[assembly: AssemblyFileVersion("2.7.0.0")]
+22 -5
View File
@@ -10,7 +10,7 @@ Multiplayer-Mod für **My Summer Car** (MSCLoader). Spiel mit bis zu 3 Spielern
## Features
**Verbinden**
- Launcher im Hauptmenü im Stil von FiveM: P2P-Seite und Server-Browser
- Launcher im Hauptmenü: P2P-Seite und Server-Browser
- **P2P über Steam:** HOST GAME, Freunde einladen, Beitritt über die Steam-Freundesliste (max. 3 Spieler)
- **Server-Browser:** Suche, Filter (Steam / Non-Steam / volle ausblenden), Ping, Server-Profil
(Besitzer, Version, Laufzeit, Spieler), lokale Server auf diesem PC werden automatisch gefunden
@@ -20,7 +20,8 @@ Multiplayer-Mod für **My Summer Car** (MSCLoader). Spiel mit bis zu 3 Spielern
und nach dem Spielen wiederhergestellt
**Synchronisiert**
- Spieler als Spielfigur (Modell aus den NPCs des Spiels) mit Blickrichtung, Ducken/Lehnen, Namensschild
- Spieler als eigene Figur im MSC-Stil (Karohemd, Jeans, Schnurrbart, Kappe – je Spieler etwas anders) mit
Blickrichtung, Laufen, Ducken/Lehnen, Sitzen, gehaltenem Item und Namensschild
- Items, lose Teile, Schrauben und Fahrzeuge (auch mit G geschoben) mit Besitzer-System – inkl. was jemand in der Hand hält
- Fahrzeuge: Position, Lichter, Motorgeräusch; als **Beifahrer** mitfahren (Taste P)
- **Fahrzeugzustand:** Tank, Öl, Kühlwasser, Bremsflüssigkeit, Batterie, Schrauben-Festigkeit, Schäden, Verschleiß,
@@ -31,7 +32,8 @@ Multiplayer-Mod für **My Summer Car** (MSCLoader). Spiel mit bis zu 3 Spielern
**Im Spiel**
- **T** – Chat
- **F8** – MSCO-Konsole mit allen Befehlen
- **/admin** – Admin-Menü (Spieler kicken/bannen) für Server-Admins bzw. den P2P-Host
- **/admin** – Admin-Menü für Server-Admins bzw. den P2P-Host: Spieler kicken/bannen, GOTO/BRING (teleportieren),
Cheats (+1.000 / +10.000 MK, Bedürfnisse auffüllen, Satsuma zu sich holen) – Cheats werden im Chat angezeigt
- **P** – als Beifahrer ein-/aussteigen
- Alle Tasten sind in den MSCLoader-Einstellungen der Mod änderbar
- Discord Rich Presence („In the Menus“, Server, Spielerzahl)
@@ -43,6 +45,21 @@ Multiplayer-Mod für **My Summer Car** (MSCLoader). Spiel mit bis zu 3 Spielern
Mods-Ordner kopieren (Standard: `Documents\MySummerCar\Mods`).
3. Spiel **über Steam** starten – ohne Steam gehen nur Non-Steam-Server.
### Einen eigenen Server betreiben
Siehe [MSCO-Dedicated-Server](https://git.mbm.wtf/MrMaho/MSCO-Dedicated-Server) – Lizenzschlüssel gibt es kostenlos
unter https://msco.mbm.wtf/keys.
## Probleme?
| Problem | Lösung |
|---|---|
| Server fehlt im Browser | REFRESH drücken; Filter (STEAM / NON-STEAM) prüfen; der Server muss von außen erreichbar sein |
| „This server requires Steam“ | Spiel über Steam starten |
| Warnung „Version mismatch“ | Mod und Server auf dieselbe Version bringen (Releases) |
| Eigener Spielstand weg | Wird nach dem Verlassen automatisch wiederhergestellt; Sicherung liegt in `AppData\LocalLow\Amistech\My Summer Car\MSCO_Backup` |
| Absturz / Hänger beim Beitreten | `output_log.txt` im Spielordner an den Server-Betreiber bzw. als Issue schicken |
## Befehle
In der F8-Konsole direkt, in der MSCLoader-Konsole mit `mp` davor (z.B. `mp connect ...`):
@@ -53,10 +70,10 @@ In der F8-Konsole direkt, in der MSCLoader-Konsole mit `mp` davor (z.B. `mp conn
| `disconnect` | Server / Spiel verlassen |
| `host [port]` / `join <ip> [port]` | LAN-Testsitzung ohne Steam |
| `save` | Spiel speichern (Host / Einzelspieler) |
| `ping [on\|off]` | Ping zum Server oben rechts anzeigen (grün < 80 ms, gelb < 150 ms, rot darüber) |
| `admin` bzw. `/admin` im Chat | Admin-Menü |
| `vehicles`, `rig` | Diagnose: Fahrzeuge / Spielermodelle |
| `debug` | Sync-Debugausgaben an/aus |
| `master [url]` | Adresse der Serverliste anzeigen/ändern |
| `clear` | Konsole leeren |
## Selbst bauen
@@ -83,7 +100,7 @@ msbuild MSCO.csproj -p:Configuration=Release
| `ObjectSync.cs` | Items/Teile/Fahrzeuge mit Besitzer-System |
| `VehicleStateSync.cs`, `VehicleFx.cs`, `Passenger.cs` | Fahrzeugzustand, Lichter/Sound, Beifahrer |
| `FsmSync.cs`, `DoorSync.cs`, `WorldSync.cs`, `MoneySync.cs` | Montage-Events, Türen, Zeit/Wetter, Geld |
| `RemotePlayer.cs`, `GameCharacter.cs` | Spielfiguren der Mitspieler |
| `RemotePlayer.cs` | Spielfiguren der Mitspieler |
| `SaveSync.cs`, `MenuHelper.cs` | Spielstand übertragen/sichern, Laden aus dem Menü |
| `SteamLobby.cs`, `JoinLinks.cs`, `DiscordPresence.cs` | Steam-Lobbys, `msco://`-Links, Discord |
| `SafetyPatches.cs`, `InputLock.cs`, `SceneCache.cs` | Absturzschutz, Eingabesperre, geteilte FSM-Suche |
+152 -66
View File
@@ -3,10 +3,11 @@ using UnityEngine;
namespace MSCO
{
/// <summary>
/// Figur eines Mitspielers mit echter Körperhaltung. Aus Kopfposition (Kamera) und Bodenhöhe wird
/// jedes Frame die Pose berechnet: Ducken beugt die Knie, nach vorne Lehnen neigt den Oberkörper,
/// Laufen bewegt Beine und Arme, beim Halten greift der rechte Arm (mit Ellbogen) zum Item, im Auto
/// sitzt die Figur. Die Körperteile sind abgerundete Kapseln zwischen den Gelenken.
/// Figur eines Mitspielers im Stil der My-Summer-Car-Leute (selbst gebaut, keine NPC-Kopie):
/// Karohemd/Arbeitsjacke, Jeans, Arbeitsschuhe, Schnurrbart, Kappe oder Haare, leichter Bauch.
/// Aus Kopfposition (Kamera) und Bodenhöhe wird jedes Frame die Pose berechnet: Ducken beugt die Knie,
/// nach vorne Lehnen neigt den Oberkörper, Laufen bewegt Beine und Arme, beim Halten greift der rechte Arm
/// zum Item, im Auto sitzt die Figur.
/// </summary>
public class RemotePlayer : MonoBehaviour
{
@@ -15,22 +16,30 @@ namespace MSCO
const float MaxHeadPitch = 55f;
// Körpermaße (Meter)
const float ThighLength = 0.45f, ShinLength = 0.45f;
const float ThighLength = 0.41f, ShinLength = 0.41f; // im Stehen fast gestreckt
const float UpperArmLength = 0.3f, ForearmLength = 0.29f;
const float TorsoLength = 0.55f; // Hüfte bis Hals
const float HipWidth = 0.1f, ShoulderWidth = 0.21f;
const float AnkleHeight = 0.07f;
static readonly Color Skin = new Color(0.93f, 0.76f, 0.62f);
static readonly Color Jeans = new Color(0.18f, 0.25f, 0.42f);
static readonly Color Boots = new Color(0.24f, 0.16f, 0.1f);
static readonly Color Skin = new Color(0.86f, 0.66f, 0.52f);
static readonly Color Jeans = new Color(0.2f, 0.26f, 0.38f);
static readonly Color Boots = new Color(0.22f, 0.15f, 0.09f);
static readonly Color Sole = new Color(0.07f, 0.06f, 0.05f);
static readonly Color Dark = new Color(0.08f, 0.08f, 0.08f);
static readonly Color Metal = new Color(0.62f, 0.62f, 0.6f);
static readonly Color[] HairColors =
{
new Color(0.23f, 0.15f, 0.09f), new Color(0.45f, 0.33f, 0.2f), new Color(0.12f, 0.1f, 0.08f), new Color(0.55f, 0.5f, 0.45f)
};
Color jacket;
int variant;
Transform headPivot, view;
Transform pelvis, torso, belt, neck;
Transform pelvis, torso, belt, neck, belly, collar, placket, buckle;
readonly Transform[] thigh = new Transform[2], shin = new Transform[2], boot = new Transform[2];
readonly Transform[] upperArm = new Transform[2], forearm = new Transform[2], hand = new Transform[2];
readonly Transform[] sole = new Transform[2], cuff = new Transform[2];
Vector3 targetFeet;
Vector3 targetHead;
@@ -42,13 +51,10 @@ namespace MSCO
Vector3 lastPosition;
float walkPhase, walkAmount, speed;
// Echtes Spielmodell (NPC aus der Szene); null = eigene Figur als Rückfall
GameCharacter model;
public int HeldId { get { return last.HeldId; } }
public PlayerState LastState { get { return last; } }
public Vector3 HeadPosition { get { return model != null ? model.HeadPosition : headPivot.position; } }
public string ModelDescription { get { return model != null ? "game model: " + model.Description : "fallback figure (no NPC template found)"; } }
public Vector3 HeadPosition { get { return headPivot.position; } }
public string ModelDescription { get { return "MSCO figure, variant " + variant; } }
// ---------------------------------------------------------------- Aufbau
@@ -57,43 +63,48 @@ namespace MSCO
var root = new GameObject(name);
var rp = root.AddComponent<RemotePlayer>();
rp.jacket = jacketColor;
rp.variant = Mathf.Abs(variant);
rp.Build();
rp.SetTarget(state);
root.transform.position = rp.targetFeet;
root.transform.rotation = Quaternion.Euler(0f, rp.targetYaw, 0f);
// Wenn möglich einen echten NPC aus dem Spiel als Figur nehmen, sonst die eigene Figur
try { rp.model = GameCharacter.TryCreate(root.transform, variant); }
catch (System.Exception e) { MSCLoader.ModConsole.Warning("[MSCO] Game model failed: " + e.Message); }
if (rp.model != null)
foreach (Renderer r in root.GetComponentsInChildren<Renderer>(true))
if (!r.transform.IsChildOf(rp.model.Root)) r.enabled = false;
rp.headWorld = rp.targetHead;
rp.pitch = rp.targetPitch;
rp.view.rotation = rp.targetViewRot;
rp.lastPosition = root.transform.position;
if (rp.model == null) rp.Pose(0f);
rp.Pose(0f);
return rp;
}
void Build()
{
Transform r = transform;
Color jacketDark = jacket * 0.75f;
Color jacketDark = jacket * 0.7f;
jacketDark.a = 1f;
Color hairColor = HairColors[variant % HairColors.Length];
bool wearsCap = variant % 3 != 2;
bool sunglasses = variant % 4 == 1;
Texture2D plaid = Plaid(jacket);
// Körper: Karohemd/Jacke, Jeans, Gürtel, leichter Bauch (wie die Leute in MSC)
pelvis = Capsule("Pelvis", r, Jeans);
torso = Capsule("Torso", r, jacket);
belt = Capsule("Belt", r, Dark);
torso = Textured(Capsule("Chest", r, Color.white), plaid, new Vector2(2f, 2f));
belly = Textured(Capsule("Belly", r, Color.white), plaid, new Vector2(2f, 1f));
belt = Box("Belt", r, new Color(0.2f, 0.13f, 0.08f));
buckle = Box("Buckle", r, Metal);
placket = Box("Buttons", r, jacketDark);
collar = Capsule("Collar", r, jacketDark);
neck = Capsule("Neck", r, Skin);
for (int i = 0; i < 2; i++)
{
thigh[i] = Capsule("Thigh", r, Jeans);
shin[i] = Capsule("Shin", r, Jeans);
shin[i] = Capsule("Shin", r, Jeans * 0.92f);
boot[i] = Box("Boot", r, Boots);
upperArm[i] = Capsule("UpperArm", r, jacket);
forearm[i] = Capsule("Forearm", r, jacketDark);
sole[i] = Box("Sole", r, Sole);
upperArm[i] = Textured(Capsule("UpperArm", r, Color.white), plaid, new Vector2(1f, 2f));
forearm[i] = Textured(Capsule("Forearm", r, Color.white), plaid, new Vector2(1f, 2f));
cuff[i] = Capsule("Cuff", r, jacketDark);
hand[i] = Sphere("Hand", r, Skin, 0.1f);
}
@@ -101,31 +112,96 @@ namespace MSCO
headPivot = new GameObject("Head").transform;
headPivot.SetParent(r, false);
Transform skull = Sphere("Skull", headPivot, Skin, 0.24f);
skull.localScale = new Vector3(0.22f, 0.26f, 0.24f);
Transform hair = Sphere("Hair", headPivot, new Color(0.25f, 0.17f, 0.1f), 0.24f);
hair.localPosition = new Vector3(0f, 0.02f, -0.025f);
hair.localScale = new Vector3(0.23f, 0.25f, 0.23f);
Transform cap = Sphere("Cap", headPivot, jacketDark, 0.25f);
cap.localPosition = new Vector3(0f, 0.07f, -0.005f);
cap.localScale = new Vector3(0.245f, 0.16f, 0.255f);
Transform brim = Box("CapBrim", headPivot, jacketDark);
brim.localPosition = new Vector3(0f, 0.075f, 0.13f);
brim.localScale = new Vector3(0.2f, 0.02f, 0.11f);
brim.localRotation = Quaternion.Euler(-8f, 0f, 0f);
skull.localScale = new Vector3(0.225f, 0.27f, 0.25f);
Transform jaw = Sphere("Jaw", headPivot, Skin, 0.2f);
jaw.localPosition = new Vector3(0f, -0.07f, 0.022f);
jaw.localScale = new Vector3(0.19f, 0.14f, 0.2f);
Transform hair = Sphere("Hair", headPivot, hairColor, 0.24f);
hair.localPosition = new Vector3(0f, 0.028f, -0.032f);
hair.localScale = new Vector3(0.235f, 0.255f, 0.235f);
for (int i = 0; i < 2; i++)
{
Transform eye = Sphere("Eye", headPivot, Dark, 0.035f);
eye.localPosition = new Vector3(i == 0 ? -0.045f : 0.045f, 0.015f, 0.105f);
float side = i == 0 ? -1f : 1f;
Transform ear = Sphere("Ear", headPivot, Skin * 0.96f, 0.05f);
ear.localPosition = new Vector3(side * 0.112f, -0.005f, 0f);
ear.localScale = new Vector3(0.035f, 0.065f, 0.05f);
Transform eye = Sphere("Eye", headPivot, Dark, 0.026f);
eye.localPosition = new Vector3(side * 0.045f, 0.018f, 0.112f);
Transform brow = Box("Brow", headPivot, hairColor);
brow.localPosition = new Vector3(side * 0.046f, 0.046f, 0.117f);
brow.localScale = new Vector3(0.045f, 0.012f, 0.012f);
brow.localRotation = Quaternion.Euler(0f, 0f, side * -8f);
}
Transform nose = Box("Nose", headPivot, Skin * 0.93f);
nose.localPosition = new Vector3(0f, -0.012f, 0.128f);
nose.localScale = new Vector3(0.035f, 0.055f, 0.045f);
// Der typische Schnurrbart
Transform mustache = Box("Mustache", headPivot, hairColor);
mustache.localPosition = new Vector3(0f, -0.054f, 0.12f);
mustache.localScale = new Vector3(0.085f, 0.024f, 0.03f);
Transform mouth = Box("Mouth", headPivot, new Color(0.45f, 0.25f, 0.22f));
mouth.localPosition = new Vector3(0f, -0.082f, 0.114f);
mouth.localScale = new Vector3(0.05f, 0.01f, 0.01f);
if (sunglasses)
{
Transform glasses = Box("Sunglasses", headPivot, Dark);
glasses.localPosition = new Vector3(0f, 0.018f, 0.121f);
glasses.localScale = new Vector3(0.15f, 0.035f, 0.012f);
}
if (wearsCap)
{
// Kappe ("lippis") in der Jackenfarbe
Transform cap = Sphere("Cap", headPivot, jacketDark, 0.25f);
cap.localPosition = new Vector3(0f, 0.08f, -0.005f);
cap.localScale = new Vector3(0.25f, 0.16f, 0.26f);
Transform brim = Box("CapBrim", headPivot, jacketDark);
brim.localPosition = new Vector3(0f, 0.07f, 0.15f);
brim.localScale = new Vector3(0.2f, 0.014f, 0.1f);
brim.localRotation = Quaternion.Euler(8f, 0f, 0f); // Schirm leicht nach unten
Transform patch = Box("CapPatch", headPivot, new Color(0.9f, 0.88f, 0.8f));
patch.localPosition = new Vector3(0f, 0.115f, 0.118f);
patch.localScale = new Vector3(0.06f, 0.035f, 0.01f);
patch.localRotation = Quaternion.Euler(-25f, 0f, 0f);
}
Transform nose = Box("Nose", headPivot, Skin * 0.95f);
nose.localPosition = new Vector3(0f, -0.02f, 0.12f);
nose.localScale = new Vector3(0.035f, 0.05f, 0.04f);
// Unsichtbarer "Blick" mit der exakten Kameradrehung - daran hängt das gehaltene Item
view = new GameObject("View").transform;
view.SetParent(r, false);
}
/// <summary>Kleines Karomuster (Flanellhemd) in der Grundfarbe; wird auf die Kapseln gekachelt.</summary>
static Texture2D Plaid(Color baseColor)
{
const int size = 16;
var tex = new Texture2D(size, size, TextureFormat.RGB24, false);
Color light = Color.Lerp(baseColor, Color.white, 0.18f);
Color dark = baseColor * 0.62f;
for (int y = 0; y < size; y++)
{
for (int x = 0; x < size; x++)
{
bool bandX = (x / 4) % 2 == 0, bandY = (y / 4) % 2 == 0;
Color c = bandX && bandY ? dark : bandX || bandY ? baseColor : light;
if (x % 8 == 1 || y % 8 == 1) c = Color.Lerp(c, Color.black, 0.45f); // dünne dunkle Linien
c.a = 1f;
tex.SetPixel(x, y, c);
}
}
tex.filterMode = FilterMode.Point;
tex.wrapMode = TextureWrapMode.Repeat;
tex.Apply();
return tex;
}
static Transform Textured(Transform part, Texture2D tex, Vector2 tiling)
{
Material m = part.GetComponent<Renderer>().material;
m.mainTexture = tex;
m.mainTextureScale = tiling;
return part;
}
static Transform Capsule(string name, Transform parent, Color color) { return Primitive(PrimitiveType.Capsule, name, parent, color); }
static Transform Box(string name, Transform parent, Color color) { return Primitive(PrimitiveType.Cube, name, parent, color); }
@@ -144,6 +220,9 @@ namespace MSCO
Destroy(go.GetComponent<Collider>()); // stört sonst Physik/Trigger
var renderer = go.GetComponent<Renderer>();
renderer.material.color = color;
// Matt wie Stoff/Haut statt glänzendem Plastik (Standard-Shader)
if (renderer.material.HasProperty("_Glossiness")) renderer.material.SetFloat("_Glossiness", 0.08f);
if (renderer.material.HasProperty("_Metallic")) renderer.material.SetFloat("_Metallic", 0f);
renderer.receiveShadows = true;
go.transform.SetParent(parent, false);
return go.transform;
@@ -186,18 +265,7 @@ namespace MSCO
view.rotation = Quaternion.Slerp(view.rotation, targetViewRot, t);
UpdateWalk(dt);
if (model != null) model.Animate(speed); // Spiel-Animation (Idle/Walk) läuft vor LateUpdate
else Pose(dt);
}
/// <summary>Spielmodell: nach der Animation Ducken/Lehnen/Kopf/Arme anpassen.</summary>
void LateUpdate()
{
if (model == null) return;
Vector3 heldPos;
Quaternion heldRot;
bool holding = TryGetHeldPose(out heldPos, out heldRot);
model.Pose(transform, headWorld, pitch, last.Seated, holding, heldPos);
Pose(dt);
}
void UpdateWalk(float dt)
@@ -240,10 +308,22 @@ namespace MSCO
Vector3 forward = Vector3.Cross(right, up).normalized;
// Rumpf
Segment(torso, hip + up * 0.08f, neckBase, 0.38f, 0.24f);
Segment(belt, hip + up * 0.06f, hip + up * 0.1f, 0.37f, 0.23f);
Segment(pelvis, hip - right * HipWidth, hip + right * HipWidth, 0.2f, 0.22f);
Segment(neck, neckBase, neckTop, 0.09f, 0.09f);
Quaternion torsoRot = Quaternion.LookRotation(forward, up);
// Brust (breit oben) und Bauch (etwas tiefer = leichter Bauch) - fließend statt Klotz mit Kugel
Segment(torso, hip + up * 0.26f, neckBase - up * 0.04f, 0.37f, 0.22f);
Segment(belly, hip + up * 0.07f + forward * 0.015f, hip + up * 0.3f + forward * 0.015f, 0.33f, 0.25f);
belt.localPosition = hip + up * 0.06f;
belt.localRotation = torsoRot;
belt.localScale = new Vector3(0.345f, 0.045f, 0.275f);
buckle.localPosition = hip + up * 0.06f + forward * 0.14f;
buckle.localRotation = torsoRot;
buckle.localScale = new Vector3(0.055f, 0.04f, 0.012f);
placket.localPosition = Vector3.Lerp(hip + up * 0.12f, neckBase, 0.6f) + forward * 0.124f;
placket.localRotation = torsoRot;
placket.localScale = new Vector3(0.022f, 0.3f, 0.01f);
Segment(pelvis, hip - right * (HipWidth * 0.8f) - up * 0.02f, hip + right * (HipWidth * 0.8f) - up * 0.02f, 0.2f, 0.22f);
Segment(collar, neckBase - up * 0.02f, neckBase + up * 0.035f, 0.17f, 0.15f);
Segment(neck, neckBase, neckTop, 0.1f, 0.1f);
// Kopf
headPivot.localPosition = head;
@@ -263,11 +343,14 @@ namespace MSCO
else
ankle = new Vector3(HipWidth * side, AnkleHeight + Mathf.Max(0f, Mathf.Cos(walkPhase + i * Mathf.PI)) * 0.07f * walkAmount, phase * 0.3f);
Vector3 knee = Joint(hipJoint, ankle, ThighLength, ShinLength, Vector3.forward);
Segment(thigh[i], hipJoint, knee, 0.17f, 0.17f);
Segment(shin[i], knee, ankle, 0.14f, 0.14f);
boot[i].localPosition = ankle + new Vector3(0f, -0.02f, 0.05f);
Segment(thigh[i], hipJoint, knee, 0.15f, 0.16f);
Segment(shin[i], knee, ankle, 0.12f, 0.13f);
boot[i].localPosition = ankle + new Vector3(0f, -0.015f, 0.05f);
boot[i].localRotation = Quaternion.identity;
boot[i].localScale = new Vector3(0.12f, 0.1f, 0.27f);
sole[i].localPosition = ankle + new Vector3(0f, -0.065f, 0.05f);
sole[i].localRotation = Quaternion.identity;
sole[i].localScale = new Vector3(0.13f, 0.025f, 0.29f);
// Arme
Vector3 shoulder = neckBase - up * 0.06f + right * (ShoulderWidth * side);
@@ -279,7 +362,7 @@ namespace MSCO
else if (last.Seated)
handTarget = shoulder + new Vector3(-side * 0.05f, -0.3f, 0.38f); // Hände nach vorne (Lenkrad)
else
handTarget = shoulder + new Vector3(side * 0.03f, -(UpperArmLength + ForearmLength) + 0.04f, -phase * 0.22f);
handTarget = shoulder + new Vector3(side * 0.06f, -(UpperArmLength + ForearmLength) + 0.05f, 0.02f - phase * 0.22f);
// Nicht weiter als die Armlänge
Vector3 reach = handTarget - shoulder;
@@ -287,9 +370,12 @@ namespace MSCO
if (reach.magnitude > maxReach) handTarget = shoulder + reach.normalized * maxReach;
Vector3 elbow = Joint(shoulder, handTarget, UpperArmLength, ForearmLength, new Vector3(side * 0.4f, -0.2f, -1f));
Segment(upperArm[i], shoulder, elbow, 0.12f, 0.12f);
Segment(forearm[i], elbow, handTarget, 0.11f, 0.11f);
Segment(upperArm[i], shoulder, elbow, 0.11f, 0.11f);
Segment(forearm[i], elbow, handTarget, 0.095f, 0.095f);
Segment(cuff[i], Vector3.Lerp(elbow, handTarget, 0.82f), Vector3.Lerp(elbow, handTarget, 0.9f), 0.105f, 0.105f);
hand[i].localPosition = handTarget;
hand[i].localRotation = Quaternion.LookRotation(Vector3.forward, (handTarget - elbow).sqrMagnitude > 0f ? elbow - handTarget : Vector3.up);
hand[i].localScale = new Vector3(0.075f, 0.115f, 0.1f);
}
}
+3 -10
View File
@@ -37,11 +37,11 @@ namespace MSCO
/// </summary>
public class ServerBrowser
{
public const string DefaultMasterUrl = "https://msco.mbm.wtf";
const string PrefMasterUrl = "MSCO_MasterUrl";
/// <summary>Offizieller Master (Serverliste) - fest eingebaut.</summary>
public const string MasterUrl = "https://msco.mbm.wtf";
const int QueryTimeoutMs = 3000;
// Wie bei FiveM: ein Server auf diesem PC erscheint automatisch oben in der Liste
// Ein Server auf diesem PC erscheint automatisch oben in der Liste
static readonly int[] LocalPorts = { 7777, 7778, 7779, 7780 };
public const float AutoRefreshSeconds = 15f;
@@ -54,13 +54,6 @@ namespace MSCO
/// <summary>Zeitpunkt der letzten Aktualisierung (Time.realtimeSinceStartup), für das Auto-Refresh.</summary>
public float LastRefresh { get; private set; }
/// <summary>Adresse des Master-Servers (änderbar mit "mp master &lt;url&gt;", z.B. zum lokalen Testen).</summary>
public static string MasterUrl
{
get { return PlayerPrefs.GetString(PrefMasterUrl, DefaultMasterUrl).TrimEnd('/'); }
set { PlayerPrefs.SetString(PrefMasterUrl, string.IsNullOrEmpty(value) ? DefaultMasterUrl : value.TrimEnd('/')); }
}
/// <summary>Lokale Server zuerst, dann die öffentliche Liste.</summary>
public List<ServerListEntry> Servers
{
+17 -8
View File
@@ -21,13 +21,14 @@ namespace MSCO
static readonly string[] Keywords =
{
"fuel", "oil", "coolant", "water", "fluid", "brake", "clutchfluid", "battery", "charge",
"bolt", "tight", "damage", "wear", "condition", "odo", "km", "dirt", "paint", "color", "colour",
"damage", "wear", "condition", "odo", "km", "dirt", "paint", "color", "colour",
"tune", "tuning", "ratio", "broken", "fixed", "flat", "pressure", "level", "mixture", "timing",
"idle", "air", "jet", "spark", "gap", "rust", "health", "burn", "cool", "temp"
};
// Montiert-Flags nicht direkt setzen: das echte Anbauen läuft über FsmSync (Events)
static readonly string[] Excluded = { "install", "rpm", "speed", "steer", "throttle", "gear", "raycast", "distance" };
// Schrauben/Festigkeit laufen über FsmSync (Events) - ein falscher Wert hier würde Teile lockern und abfallen lassen
static readonly string[] Excluded = { "install", "bolt", "tight", "detach", "attach", "rpm", "speed", "steer", "throttle", "gear", "raycast", "distance" };
const float ScanInterval = 10f;
const float CheckInterval = 0.5f;
@@ -73,11 +74,16 @@ namespace MSCO
outgoing.Clear();
foreach (Var v in vars.Values)
{
if (!Changed(v)) continue;
Remember(v);
// Fährt/schiebt ein anderer das Fahrzeug, ist seine Version maßgeblich
// Fährt/schiebt ein anderer das Fahrzeug, ist seine Version maßgeblich - nichts senden
int owner = objects.VehicleOwner(v.Root);
if (owner == SyncedBody.NoOwner || owner == localId) outgoing.Add(v);
if (owner != SyncedBody.NoOwner && owner != localId) { if (Changed(v, false)) Remember(v); continue; }
// 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;
if (!Changed(v, big)) continue;
Remember(v);
outgoing.Add(v);
}
for (int start = 0; start < outgoing.Count; start += MaxPerMessage)
@@ -175,13 +181,16 @@ namespace MSCO
return false;
}
static bool Changed(Var v)
static bool Changed(Var v, bool bigOnly)
{
switch (v.Type)
{
case 1:
float f = ((FsmFloat)v.Variable).Value;
return Mathf.Abs(f - v.LastF) > Mathf.Max(0.01f, Mathf.Abs(v.LastF) * 0.002f);
float delta = Mathf.Abs(f - v.LastF);
return bigOnly
? delta > Mathf.Max(0.5f, Mathf.Abs(v.LastF) * 0.05f)
: delta > Mathf.Max(0.01f, Mathf.Abs(v.LastF) * 0.002f);
case 2: return ((FsmInt)v.Variable).Value != v.LastI;
default: return ((FsmBool)v.Variable).Value != v.LastB;
}