From cd8c1293e00c5eddfa0a20b9214e6ef5af9fffb2 Mon Sep 17 00:00:00 2001 From: MrMaho Date: Fri, 2 Oct 2026 10:36:50 +0200 Subject: [PATCH] Other players as real MSC characters GameCharacter clones a MSC human (bodymesh + skeleton, no scripts, physics or animation), puts it into its bind pose, aligns and scales it, and drives the bones every frame from the joint points of the existing MSCO figure (hip, knee, ankle, shoulder, elbow, hand, head pitch, torso lean) by rotating each limb from its rest direction. Limb bones are picked geometrically, so bone naming conventions do not matter. Variant per player id; console 'model classic|game' switches, the MSCO figure is the automatic fallback. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 + Commands.cs | 8 ++ GameCharacter.cs | 311 ++++++++++++++++++++++++++++++++++++++++++ MSCO.csproj | 1 + MultiplayerManager.cs | 10 +- RemotePlayer.cs | 36 ++++- 6 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 GameCharacter.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c568dfe..9ff163e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ Web-Panel (Einstellungen), **OFFICIAL** vergeben nur die MSCO-Admins und wird hervorgehoben - Braucht keinen neuen Server: die Infos kommen vom Master (msco.mbm.wtf) und funktionieren mit 2.8-Servern +**Mitspieler als echte MSC-Menschen** +- Andere Spieler sehen aus wie die Leute im Spiel: MSCO nimmt eine Kopie eines MSC-Menschen (Körper, Gesicht, + Kleidung) und bewegt dessen Skelett mit der bisherigen Animation - laufen, springen, ducken, sitzen, Items halten +- Jeder Spieler bekommt (nach Spieler-Nummer) einen anderen Menschen; bei allen Mitspielern derselbe +- Konsole (F7): `model classic` zeigt wieder die MSCO-Figur, `model game` die MSC-Menschen; `rig` zeigt, wer welches + Modell hat. Findet MSCO keinen passenden Menschen, bleibt automatisch die MSCO-Figur + **Beifahrer** - **Automatisch einsteigen**: in die offene Beifahrertür an den Sitz treten - man setzt sich von selbst hin, keine Taste nötig. Aussteigen mit Leertaste oder der Beifahrer-Taste (P) diff --git a/Commands.cs b/Commands.cs index f7146ca..06fd638 100644 --- a/Commands.cs +++ b/Commands.cs @@ -21,6 +21,7 @@ namespace MSCO "fps [on|off] - show the frames per second (toggle)\n" + "/admin - admin menu (kick / ban / teleport / cheats)\n" + "vehicles | rig - debug info about vehicles / player models\n" + + "model [game|classic] - other players as MSC characters or the MSCO figure\n" + "vars - synced vehicle values, e.g. vars oil (debug)\n" + "debug - toggle sync debug output\n" + "clear - clear this console"; @@ -127,6 +128,13 @@ namespace MSCO case "rig": foreach (string line in mp.DescribeRigs()) print(line); break; + case "model": + // Mitspieler als echte MSC-Menschen ("game") oder als MSCO-Figur ("classic") + if (args.Length > 1) RemotePlayer.UseGameModels = args[1].ToLower() != "classic"; + else RemotePlayer.UseGameModels = !RemotePlayer.UseGameModels; + mp.RebuildAvatars(); + print("Player models: " + (RemotePlayer.UseGameModels ? "MSC characters (model classic = MSCO figure)" : "MSCO figure (model game = MSC characters)")); + break; case "vehicles": foreach (string line in mp.DescribeVehicles()) print(line); break; diff --git a/GameCharacter.cs b/GameCharacter.cs new file mode 100644 index 0000000..d1e74ad --- /dev/null +++ b/GameCharacter.cs @@ -0,0 +1,311 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace MSCO +{ + /// + /// Echte Spielfigur für Mitspieler: eine Kopie eines MSC-Menschen (SkinnedMeshRenderer "bodymesh" mit Skelett + /// pelvis/knee_left/ankle_left/shoulder_left/arm_left/hand_left/head ...). Die Knochen werden jedes Frame aus der + /// Pose der MSCO-Figur gesetzt (Hüfte, Knie, Knöchel, Schulter, Ellbogen, Hand, Kopf) - laufen, springen, ducken, + /// sitzen und Items halten funktionieren dadurch genau wie vorher. Die Bewegung wird über Richtungen übertragen + /// (Ruhelage aus der Bind-Pose des Modells), dadurch ist egal, wie die Knochen im Modell ausgerichtet sind. + /// + public class GameCharacter + { + /// Gelenkpunkte der MSCO-Figur im lokalen Raum des Spielers (jedes Frame von RemotePlayer.Pose). + public class Rig + { + public Vector3 Hip, NeckBase, Head; + public Vector3 Up = Vector3.up; + public float Pitch; + public readonly Vector3[] HipJoint = new Vector3[2], Knee = new Vector3[2], Ankle = new Vector3[2]; + public readonly Vector3[] Shoulder = new Vector3[2], Elbow = new Vector3[2], Hand = new Vector3[2]; + } + + // ---------------------------------------------------------------- Vorlagen im Spiel finden + + static readonly string[] RequiredBones = + { "pelvis", "head", "knee_left", "knee_right", "ankle_left", "ankle_right", "hand_left", "hand_right" }; + + static List templates; + static float nextTemplateSearch; + + /// Passende Menschen im Spiel (sortiert nach Pfad: bei allen Spielern gleiche Reihenfolge). + static List Templates() + { + if (templates != null && templates.Count > 0 && templates.TrueForAll(t => t != null)) return templates; + if (Time.realtimeSinceStartup < nextTemplateSearch) return templates ?? new List(); + nextTemplateSearch = Time.realtimeSinceStartup + 10f; + + var found = new List>(); + var seenMeshes = new HashSet(); + foreach (SkinnedMeshRenderer smr in Resources.FindObjectsOfTypeAll()) + { + if (smr == null || smr.sharedMesh == null || !smr.name.StartsWith("bodymesh") || smr.name.Contains("kid")) continue; + if (smr.hideFlags != HideFlags.None || smr.bones == null || smr.bones.Length < 10) continue; + if (smr.sharedMesh.bindposes == null || smr.sharedMesh.bindposes.Length != smr.bones.Length) continue; + if (!HasBones(smr)) continue; + Transform root = CloneRoot(smr); + if (root == null || !SafeToClone(root)) continue; + if (!seenMeshes.Add(smr.sharedMesh)) continue; // gleiches Aussehen nur einmal + found.Add(new KeyValuePair(PathOf(smr.transform), smr)); + } + found.Sort((a, b) => string.CompareOrdinal(a.Key, b.Key)); + templates = found.ConvertAll(kv => kv.Value); + if (templates.Count > 0) MscoLog.Info("[MSCO] Player models: " + templates.Count + " MSC characters found"); + return templates; + } + + static bool HasBones(SkinnedMeshRenderer smr) + { + var names = new HashSet(); + foreach (Transform b in smr.bones) if (b != null) names.Add(b.name); + foreach (string r in RequiredBones) if (!names.Contains(r)) return false; + return true; + } + + /// Kleinster gemeinsamer Vorfahre von Mesh und Skelett - das wird kopiert. + static Transform CloneRoot(SkinnedMeshRenderer smr) + { + Transform bone = smr.rootBone != null ? smr.rootBone : smr.bones[0]; + if (bone == null) return null; + for (Transform a = smr.transform; a != null; a = a.parent) + if (bone == a || bone.IsChildOf(a)) return a; + return null; + } + + /// + /// Kopieren darf keine Spiel-Logik starten: entweder hängen in dem Teilbaum keine Skripte (PlayMaker ...), + /// oder er ist ohnehin gerade inaktiv (dann wird er für die Kopie kurz ausgeschaltet, ohne Folgen). + /// + static bool SafeToClone(Transform root) + { + if (!root.gameObject.activeInHierarchy) return true; + return root.GetComponentsInChildren(true).Length == 0; + } + + static string PathOf(Transform t) + { + string path = t.name; + for (Transform p = t.parent; p != null; p = p.parent) path = p.name + "/" + path; + return path; + } + + // ---------------------------------------------------------------- Instanz + + class Bone + { + public Transform T; + public Quaternion RestRot; // relativ zum Spieler + public Vector3 RestPos; // relativ zum Spieler + public Vector3 RestDir; // zum Kind-Gelenk (Arme/Beine) + } + + GameObject clone; + Transform owner; + Bone pelvis, head; + readonly List spine = new List(); + readonly Bone[] upperLeg = new Bone[2], lowerLeg = new Bone[2], foot = new Bone[2]; + readonly Bone[] upperArm = new Bone[2], lowerArm = new Bone[2]; + Vector3 pelvisOffset; + public string Description { get; private set; } + + /// Figur aus einer Spielvorlage bauen (variant wählt den Menschen). null, wenn keine passt. + public static GameCharacter Create(Transform owner, int variant) + { + List list = Templates(); + if (list.Count == 0) return null; + var c = new GameCharacter(); + try + { + if (c.Build(owner, list[Mathf.Abs(variant) % list.Count])) return c; + } + catch (Exception e) + { + MscoLog.Warning("[MSCO] Player model could not be built: " + e.Message); + } + c.Destroy(); + return null; + } + + bool Build(Transform owner, SkinnedMeshRenderer template) + { + this.owner = owner; + Transform tplRoot = CloneRoot(template); + GameObject tpl = tplRoot.gameObject; + + // Kopieren, ohne dass Skripte der Vorlage anlaufen (siehe SafeToClone) + bool wasActive = tpl.activeSelf; + bool toggle = wasActive && !tpl.activeInHierarchy; + if (toggle) tpl.SetActive(false); + try { clone = (GameObject)UnityEngine.Object.Instantiate(tpl); } + finally { if (toggle) tpl.SetActive(true); } + clone.name = "MSCO_Model"; + Strip(clone); + + SkinnedMeshRenderer smr = null; + foreach (SkinnedMeshRenderer s in clone.GetComponentsInChildren(true)) + if (s.sharedMesh == template.sharedMesh) { smr = s; break; } + if (smr == null) return false; + Description = "MSC character " + PathOf(template.transform); + + // Knochen nach Namen + var byName = new Dictionary(); + foreach (Transform b in smr.bones) if (b != null && !byName.ContainsKey(b.name)) byName[b.name] = b; + + // In die Bind-Pose (Ruhelage des Modells) setzen: Eltern vor Kindern + Matrix4x4[] bind = smr.sharedMesh.bindposes; + var order = new List(); + for (int i = 0; i < smr.bones.Length; i++) if (smr.bones[i] != null) order.Add(i); + order.Sort((a, b) => Depth(smr.bones[a]).CompareTo(Depth(smr.bones[b]))); + foreach (int i in order) + { + Matrix4x4 m = smr.transform.localToWorldMatrix * bind[i].inverse; + smr.bones[i].position = m.GetColumn(3); + smr.bones[i].rotation = Quaternion.LookRotation(m.GetColumn(2), m.GetColumn(1)); + } + + // Gliedmaßen geometrisch bestimmen: in jeder Kette (Becken -> ... -> Knöchel -> Zehen bzw. Rumpf -> ... -> Hand + // -> Finger) sind die zwei längsten aufeinanderfolgenden Stücke Ober-/Unterschenkel bzw. Ober-/Unterarm. + // So ist egal, ob ein Knochen nach seinem Anfang oder seinem Ende benannt ist. + clone.transform.SetParent(owner, true); + Transform headT = byName["head"], pelvisT = byName["pelvis"]; + var trunk = new HashSet { pelvisT }; + for (Transform t = headT; t != null && t != pelvisT; t = t.parent) trunk.Add(t); + Transform[][] legs = { Limb(byName["ankle_left"], trunk), Limb(byName["ankle_right"], trunk) }; + Transform[][] arms = { Limb(byName["hand_left"], trunk), Limb(byName["hand_right"], trunk) }; + foreach (Transform[] l in legs) if (l == null) return false; + foreach (Transform[] l in arms) if (l == null) return false; + + Vector3 up = (headT.position - pelvisT.position).normalized; + Vector3 right = (arms[1][0].position - arms[0][0].position).normalized; + Vector3 forward = Vector3.Cross(right, up).normalized; + if (forward.sqrMagnitude < 0.5f) return false; + Quaternion facing = Quaternion.LookRotation(forward, up); + float legLength = (legs[0][1].position - legs[0][0].position).magnitude + (legs[0][2].position - legs[0][1].position).magnitude; + if (legLength < 0.01f) return false; + float scale = 0.84f / legLength; // Bein der MSCO-Figur (Oberschenkel + Schienbein + etwas Knöchel) + + // Erst drehen und skalieren, dann Ruhelage relativ zum Spieler merken + clone.transform.localScale = clone.transform.localScale * scale; + clone.transform.rotation = owner.rotation * Quaternion.Inverse(facing) * clone.transform.rotation; + clone.transform.position += owner.position - pelvisT.position; + + pelvis = MakeBone(pelvisT, null); + head = MakeBone(headT, null); + for (Transform s = headT.parent; s != null && s != pelvisT; s = s.parent) spine.Add(MakeBone(s, null)); + for (int i = 0; i < 2; i++) + { + upperLeg[i] = MakeBone(legs[i][0], legs[i][1]); + lowerLeg[i] = MakeBone(legs[i][1], legs[i][2]); + foot[i] = MakeBone(legs[i][2], null); + upperArm[i] = MakeBone(arms[i][0], arms[i][1]); + lowerArm[i] = MakeBone(arms[i][1], arms[i][2]); + } + Vector3 hipCenter = (upperLeg[0].RestPos + upperLeg[1].RestPos) * 0.5f; + pelvisOffset = pelvis.RestPos - hipCenter; + + smr.updateWhenOffscreen = true; // Knochen bewegen sich weit weg von der gespeicherten Hülle + foreach (Renderer r in clone.GetComponentsInChildren(true)) r.enabled = true; + clone.SetActive(true); + return true; + } + + /// + /// Gliedmaße als [Anfang oberes Stück, Gelenk (Knie/Ellbogen), Ende (Knöchel/Handgelenk)]: Kette vom Rumpf bis zum + /// Ende (über end hinaus entlang des längsten Kindes), dann die zwei längsten aufeinanderfolgenden Stücke. + /// + static Transform[] Limb(Transform end, HashSet trunk) + { + var chain = new List(); + for (Transform t = end; t != null && !trunk.Contains(t); t = t.parent) chain.Insert(0, t); + for (Transform t = end; t.childCount > 0; ) + { + Transform next = null; + float best = -1f; + foreach (Transform c in t) + { + float d = (c.position - t.position).sqrMagnitude; + if (d > best) { best = d; next = c; } + } + chain.Add(next); + t = next; + } + if (chain.Count < 3) return null; + int bestK = -1; + float bestLen = 0f; + for (int k = 0; k + 2 < chain.Count; k++) + { + float len = Mathf.Min((chain[k + 1].position - chain[k].position).magnitude, (chain[k + 2].position - chain[k + 1].position).magnitude); + if (len > bestLen) { bestLen = len; bestK = k; } + } + return bestK < 0 ? null : new[] { chain[bestK], chain[bestK + 1], chain[bestK + 2] }; + } + + static int Depth(Transform t) + { + int d = 0; + for (; t != null; t = t.parent) d++; + return d; + } + + Bone MakeBone(Transform t, Transform child) + { + var b = new Bone + { + T = t, + RestRot = Quaternion.Inverse(owner.rotation) * t.rotation, + RestPos = owner.InverseTransformPoint(t.position) + }; + if (child != null) b.RestDir = owner.InverseTransformPoint(child.position) - b.RestPos; + return b; + } + + /// Alles außer Aussehen entfernen (Skripte, Physik, Kollision, Ton, Animation). + static void Strip(GameObject go) + { + foreach (Joint j in go.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(j); + foreach (Component c in go.GetComponentsInChildren(true)) + { + if (c == null || c is Transform || c is Renderer || c is MeshFilter) continue; + UnityEngine.Object.DestroyImmediate(c); + } + } + + // ---------------------------------------------------------------- jedes Frame + + public void Apply(Rig rig) + { + if (clone == null) return; + Quaternion root = owner.rotation; + Quaternion lean = Quaternion.FromToRotation(Vector3.up, rig.Up); + + pelvis.T.position = owner.TransformPoint(rig.Hip + lean * pelvisOffset); + pelvis.T.rotation = root * lean * pelvis.RestRot; + for (int i = spine.Count - 1; i >= 0; i--) spine[i].T.rotation = root * lean * spine[i].RestRot; // von unten nach oben + head.T.rotation = root * Quaternion.Euler(rig.Pitch, 0f, 0f) * head.RestRot; + + for (int i = 0; i < 2; i++) + { + Aim(upperLeg[i], rig.Knee[i] - rig.HipJoint[i], root); + Aim(lowerLeg[i], rig.Ankle[i] - rig.Knee[i], root); + foot[i].T.rotation = root * foot[i].RestRot; // Füße bleiben flach + Aim(upperArm[i], rig.Elbow[i] - rig.Shoulder[i], root); + Aim(lowerArm[i], rig.Hand[i] - rig.Elbow[i], root); + } + } + + static void Aim(Bone b, Vector3 desired, Quaternion root) + { + if (desired.sqrMagnitude < 1e-6f || b.RestDir.sqrMagnitude < 1e-6f) return; + b.T.rotation = root * Quaternion.FromToRotation(b.RestDir, desired) * b.RestRot; + } + + public void Destroy() + { + if (clone != null) UnityEngine.Object.Destroy(clone); + clone = null; + } + } +} diff --git a/MSCO.csproj b/MSCO.csproj index 9d4481d..5ae1c37 100644 --- a/MSCO.csproj +++ b/MSCO.csproj @@ -106,6 +106,7 @@ + diff --git a/MultiplayerManager.cs b/MultiplayerManager.cs index a3b8c1c..40a0a2a 100644 --- a/MultiplayerManager.cs +++ b/MultiplayerManager.cs @@ -211,6 +211,12 @@ namespace MSCO return lines; } + /// Konsole "model": Figuren neu bauen (kommen mit dem nächsten Spielerzustand wieder). + public void RebuildAvatars() + { + foreach (PlayerInfo p in players.Values) DestroyAvatar(p); + } + /// Konsole "vars ": synchronisierte Fahrzeugwerte (Diagnose). public List DescribeVehicleValues(string filter) { return vehicleState.Describe(filter, 60); } @@ -2319,9 +2325,9 @@ namespace MSCO if (!players.TryGetValue(from, out p)) return; if (p.Avatar == null) { - // Platzhalter-Figur: Kapsel mit Kopf und Hand + // Echter MSC-Mensch (oder die eigene MSCO-Figur, wenn keiner passt / "model classic") p.Avatar = RemotePlayer.Create("MSCO_Player_" + from, PlayerColor(from), state, from); - MscoLog.Info("[MSCO] " + p.Name + " is in the game"); + MscoLog.Info("[MSCO] " + p.Name + " is in the game (" + p.Avatar.ModelDescription + ")"); } p.Avatar.SetTarget(state); } diff --git a/RemotePlayer.cs b/RemotePlayer.cs index 88f638a..59f14c2 100644 --- a/RemotePlayer.cs +++ b/RemotePlayer.cs @@ -62,7 +62,18 @@ namespace MSCO public int HeldId { get { return last.HeldId; } } public PlayerState LastState { get { return last; } } public Vector3 HeadPosition { get { return headPivot.position; } } - public string ModelDescription { get { return "MSCO figure, variant " + variant; } } + public string ModelDescription { get { return model != null ? model.Description : "MSCO figure, variant " + variant; } } + + /// Mitspieler als echte MSC-Menschen zeigen (Konsole "model game|classic", gespeichert). + public static bool UseGameModels + { + get { return PlayerPrefs.GetInt("MSCO_GameModels", 1) == 1; } + set { PlayerPrefs.SetInt("MSCO_GameModels", value ? 1 : 0); PlayerPrefs.Save(); } + } + + // Echte Spielfigur (null = eigene MSCO-Figur sichtbar); die MSCO-Figur liefert dann nur noch die Gelenkpunkte + GameCharacter model; + readonly GameCharacter.Rig rig = new GameCharacter.Rig(); // ---------------------------------------------------------------- Aufbau @@ -73,6 +84,7 @@ namespace MSCO rp.jacket = jacketColor; rp.variant = Mathf.Abs(variant); rp.Build(); + if (UseGameModels) rp.UseGameModel(); rp.SetTarget(state); root.transform.position = rp.targetFeet; root.transform.rotation = Quaternion.Euler(0f, rp.targetYaw, 0f); @@ -180,6 +192,16 @@ namespace MSCO view.SetParent(r, false); } + void UseGameModel() + { + model = GameCharacter.Create(transform, variant); + if (model == null) return; + // Eigene Figur unsichtbar (sie bewegt sich weiter und gibt die Gelenke vor) + Transform modelRoot = transform.Find("MSCO_Model"); + foreach (Renderer r in GetComponentsInChildren(true)) + if (modelRoot == null || !r.transform.IsChildOf(modelRoot)) r.enabled = false; + } + /// Kleines Karomuster (Flanellhemd) in der Grundfarbe; wird auf die Kapseln gekachelt. static Texture2D Plaid(Color baseColor) { @@ -379,6 +401,11 @@ namespace MSCO // Kopf headPivot.localPosition = head; headPivot.localRotation = Quaternion.Euler(pitch, 0f, 0f); + rig.Hip = hip; + rig.NeckBase = neckBase; + rig.Head = head; + rig.Up = up; + rig.Pitch = pitch; float swing = Mathf.Sin(walkPhase) * walkAmount * (1f - airAmount); for (int i = 0; i < 2; i++) @@ -400,6 +427,9 @@ namespace MSCO } Vector3 knee = Joint(hipJoint, ankle, ThighLength, ShinLength, Vector3.forward); Segment(thigh[i], hipJoint, knee, 0.15f, 0.16f); + rig.HipJoint[i] = hipJoint; + rig.Knee[i] = knee; + rig.Ankle[i] = ankle; Segment(shin[i], knee, ankle, 0.12f, 0.13f); // Stiefel: Kapsel von der Ferse zur Spitze, darunter eine flache Sohle Segment(boot[i], ankle + new Vector3(0f, -0.025f, -0.06f), ankle + new Vector3(0f, -0.03f, 0.14f), 0.12f, 0.1f); @@ -427,12 +457,16 @@ namespace MSCO Vector3 elbow = Joint(shoulder, handTarget, UpperArmLength, ForearmLength, new Vector3(side * 0.4f, -0.2f, -1f)); Segment(upperArm[i], shoulder, elbow, 0.11f, 0.11f); + rig.Shoulder[i] = shoulder; + rig.Elbow[i] = elbow; + rig.Hand[i] = handTarget; 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.085f, 0.125f, 0.11f); } + if (model != null) model.Apply(rig); } /// Zwei-Knochen-Gelenk (Knie/Ellbogen): Punkt zwischen a und b, zur Seite "bend" geknickt.