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 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 10:36:50 +02:00
co-authored by Claude Opus 5.5
parent 983291a4b3
commit cd8c1293e0
6 changed files with 370 additions and 3 deletions
+7
View File
@@ -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)
+8
View File
@@ -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 <filter> - 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;
+311
View File
@@ -0,0 +1,311 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// 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.
/// </summary>
public class GameCharacter
{
/// <summary>Gelenkpunkte der MSCO-Figur im lokalen Raum des Spielers (jedes Frame von RemotePlayer.Pose).</summary>
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<SkinnedMeshRenderer> templates;
static float nextTemplateSearch;
/// <summary>Passende Menschen im Spiel (sortiert nach Pfad: bei allen Spielern gleiche Reihenfolge).</summary>
static List<SkinnedMeshRenderer> Templates()
{
if (templates != null && templates.Count > 0 && templates.TrueForAll(t => t != null)) return templates;
if (Time.realtimeSinceStartup < nextTemplateSearch) return templates ?? new List<SkinnedMeshRenderer>();
nextTemplateSearch = Time.realtimeSinceStartup + 10f;
var found = new List<KeyValuePair<string, SkinnedMeshRenderer>>();
var seenMeshes = new HashSet<Mesh>();
foreach (SkinnedMeshRenderer smr in Resources.FindObjectsOfTypeAll<SkinnedMeshRenderer>())
{
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<string, SkinnedMeshRenderer>(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<string>();
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;
}
/// <summary>Kleinster gemeinsamer Vorfahre von Mesh und Skelett - das wird kopiert.</summary>
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;
}
/// <summary>
/// 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).
/// </summary>
static bool SafeToClone(Transform root)
{
if (!root.gameObject.activeInHierarchy) return true;
return root.GetComponentsInChildren<MonoBehaviour>(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<Bone> spine = new List<Bone>();
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; }
/// <summary>Figur aus einer Spielvorlage bauen (variant wählt den Menschen). null, wenn keine passt.</summary>
public static GameCharacter Create(Transform owner, int variant)
{
List<SkinnedMeshRenderer> 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<SkinnedMeshRenderer>(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<string, Transform>();
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<int>();
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<Transform> { 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<Renderer>(true)) r.enabled = true;
clone.SetActive(true);
return true;
}
/// <summary>
/// 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.
/// </summary>
static Transform[] Limb(Transform end, HashSet<Transform> trunk)
{
var chain = new List<Transform>();
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;
}
/// <summary>Alles außer Aussehen entfernen (Skripte, Physik, Kollision, Ton, Animation).</summary>
static void Strip(GameObject go)
{
foreach (Joint j in go.GetComponentsInChildren<Joint>(true)) UnityEngine.Object.DestroyImmediate(j);
foreach (Component c in go.GetComponentsInChildren<Component>(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;
}
}
}
+1
View File
@@ -106,6 +106,7 @@
<Compile Include="NetSession.cs" />
<Compile Include="ObjectSync.cs" />
<Compile Include="Passenger.cs" />
<Compile Include="GameCharacter.cs" />
<Compile Include="SaveSync.cs" />
<Compile Include="ServerBrowser.cs" />
<Compile Include="SteamLobby.cs" />
+8 -2
View File
@@ -211,6 +211,12 @@ namespace MSCO
return lines;
}
/// <summary>Konsole "model": Figuren neu bauen (kommen mit dem nächsten Spielerzustand wieder).</summary>
public void RebuildAvatars()
{
foreach (PlayerInfo p in players.Values) DestroyAvatar(p);
}
/// <summary>Konsole "vars <filter>": synchronisierte Fahrzeugwerte (Diagnose).</summary>
public List<string> 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);
}
+35 -1
View File
@@ -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; } }
/// <summary>Mitspieler als echte MSC-Menschen zeigen (Konsole "model game|classic", gespeichert).</summary>
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<Renderer>(true))
if (modelRoot == null || !r.transform.IsChildOf(modelRoot)) r.enabled = false;
}
/// <summary>Kleines Karomuster (Flanellhemd) in der Grundfarbe; wird auf die Kapseln gekachelt.</summary>
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);
}
/// <summary>Zwei-Knochen-Gelenk (Knie/Ellbogen): Punkt zwischen a und b, zur Seite "bend" geknickt.</summary>