Player models: bind pose + procedural animation (no more ragdoll poses); passenger feedback in chat

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 22:50:52 +02:00
co-authored by Claude Opus 5.5
parent 6d349399ab
commit d32cdd5693
3 changed files with 119 additions and 81 deletions
+102 -76
View File
@@ -6,10 +6,11 @@ using UnityEngine;
namespace MSCO namespace MSCO
{ {
/// <summary> /// <summary>
/// Mitspieler-Figur aus einem echten My-Summer-Car-NPC (Körper-Mesh "bodymesh" + Skelett mit "pelvis"). /// Mitspieler-Figur mit dem Aussehen eines echten My-Summer-Car-NPCs (Körper-Mesh "bodymesh" + Skelett "pelvis").
/// Der NPC wird kopiert und von seiner Logik befreit; es bleiben Aussehen und die Spiel-Animationen /// Vom NPC wird nur das Aussehen übernommen: Seine aktuelle Haltung (evtl. Ragdoll, liegend, betrunken) wird
/// (Idle/Walk). Nach der Animation werden die Knochen für Ducken, Lehnen, Kopf-Nicken, Sitzen und /// verworfen und das Skelett auf die Bind-Pose des Meshes gesetzt. Bewegt wird die Figur komplett selbst -
/// das Greifen zum Item verbogen (Zwei-Knochen-IK, unabhängig von den Achsen des Skeletts). /// wie die frühere Ersatzfigur: Laufzyklus der Beine, schwingende Arme, Ducken, Lehnen, Kopf-Nicken, Sitzen
/// und Greifen zum Item (Zwei-Knochen-IK, unabhängig von den Achsen des Skeletts).
/// </summary> /// </summary>
public class GameCharacter public class GameCharacter
{ {
@@ -23,23 +24,19 @@ namespace MSCO
public Transform Root { get; private set; } public Transform Root { get; private set; }
public string Description { get; private set; } public string Description { get; private set; }
Animation anim;
string walkClip, idleClip;
Transform pelvis, spine, head; Transform pelvis, spine, head;
float walkPhase, walkAmount; // eigener Laufzyklus (0..2π) und wie stark gerade gelaufen wird
string bindInfo = "";
readonly Transform[] thigh = new Transform[2], knee = new Transform[2], foot = new Transform[2]; 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 Transform[] upperArm = new Transform[2], forearm = new Transform[2], hand = new Transform[2];
readonly Vector3[] animatedFoot = new Vector3[2]; readonly Vector3[] animatedFoot = new Vector3[2];
/// <summary> /// <summary>Grundhaltung aller Knochen (Bind-Pose); jedes Frame wird von hier aus neu gebogen.</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 class BoneState
{ {
public Transform Bone; public Transform Bone;
public Vector3 RestPos, OutPos; public Vector3 RestPos;
public Quaternion RestRot, OutRot; public Quaternion RestRot;
public bool HasOut;
} }
readonly List<BoneState> posed = new List<BoneState>(); readonly List<BoneState> posed = new List<BoneState>();
@@ -66,14 +63,16 @@ namespace MSCO
clone.transform.localScale = Vector3.one; clone.transform.localScale = Vector3.one;
var c = new GameCharacter { Root = clone.transform }; var c = new GameCharacter { Root = clone.transform };
// Haltung des Vorbilds verwerfen (Ragdoll/liegend/sitzend) -> saubere Grundhaltung des Meshes
int bound = c.ApplyBindPose();
if (!c.FindBones()) if (!c.FindBones())
{ {
UnityEngine.Object.Destroy(clone); UnityEngine.Object.Destroy(clone);
return null; return null;
} }
c.Normalize(); c.Normalize();
c.SetupAnimation();
c.RememberRestPose(); c.RememberRestPose();
c.bindInfo = bound > 0 ? "bind pose " + bound + " bones" : "no bind pose";
c.Description = template.name + " (" + candidates.Count + " templates) - " + c.BoneReport(); c.Description = template.name + " (" + candidates.Count + " templates) - " + c.BoneReport();
return c; return c;
} }
@@ -168,18 +167,62 @@ namespace MSCO
return (walk ? 2 : 0) + (idle ? 1 : 0); return (walk ? 2 : 0) + (idle ? 1 : 0);
} }
/// <summary>Alles außer Aussehen und Animation entfernen (KI-FSMs, Physik, Sounds, Trigger ...).</summary> /// <summary>
/// Alles außer dem Aussehen entfernen: KI-FSMs, Physik (Ragdoll-Gelenke), Sounds, Trigger und auch die
/// Spiel-Animationen - die Figur wird komplett selbst bewegt.
/// </summary>
static void Strip(GameObject go) static void Strip(GameObject go)
{ {
foreach (Joint j in go.GetComponentsInChildren<Joint>(true)) UnityEngine.Object.DestroyImmediate(j); 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 (Rigidbody r in go.GetComponentsInChildren<Rigidbody>(true)) UnityEngine.Object.DestroyImmediate(r);
foreach (Collider c in go.GetComponentsInChildren<Collider>(true)) UnityEngine.Object.DestroyImmediate(c); foreach (Collider c in go.GetComponentsInChildren<Collider>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (Behaviour b in go.GetComponentsInChildren<Behaviour>(true)) foreach (Behaviour b in go.GetComponentsInChildren<Behaviour>(true)) UnityEngine.Object.DestroyImmediate(b);
{ foreach (Animation a in go.GetComponentsInChildren<Animation>(true)) UnityEngine.Object.DestroyImmediate(a);
if (b is Animation || b is Animator) continue;
UnityEngine.Object.DestroyImmediate(b);
}
foreach (AudioSource a in go.GetComponentsInChildren<AudioSource>(true)) UnityEngine.Object.DestroyImmediate(a); foreach (AudioSource a in go.GetComponentsInChildren<AudioSource>(true)) UnityEngine.Object.DestroyImmediate(a);
// Figur nicht ausblenden, nur weil ihr Ursprung gerade außerhalb des Bildes liegt
foreach (SkinnedMeshRenderer smr in go.GetComponentsInChildren<SkinnedMeshRenderer>(true))
smr.updateWhenOffscreen = true;
}
/// <summary>
/// Setzt alle Knochen auf die Haltung, in der das Mesh gebaut wurde (Bind-Pose aus sharedMesh.bindposes).
/// So ist die Figur aufrecht, egal wie das Vorbild gerade lag. Gibt die Zahl der gesetzten Knochen zurück.
/// </summary>
int ApplyBindPose()
{
SkinnedMeshRenderer body = null;
foreach (SkinnedMeshRenderer smr in Root.GetComponentsInChildren<SkinnedMeshRenderer>(true))
if (smr.name.ToLower().Contains("bodymesh") && smr.sharedMesh != null) { body = smr; break; }
if (body == null || body.bones == null) return 0;
Matrix4x4[] bindposes = body.sharedMesh.bindposes;
Transform[] bones = body.bones;
int count = Mathf.Min(bindposes.Length, bones.Length);
// Eltern vor Kindern setzen (Weltwerte; Kinder überschreiben danach nur sich selbst)
var order = new List<int>();
for (int i = 0; i < count; i++) if (bones[i] != null) order.Add(i);
order.Sort((a, b) => Depth(bones[a]).CompareTo(Depth(bones[b])));
Matrix4x4 meshToWorld = body.transform.localToWorldMatrix;
int set = 0;
foreach (int i in order)
{
Matrix4x4 world = meshToWorld * bindposes[i].inverse;
Vector3 forward = world.GetColumn(2), up = world.GetColumn(1);
if (forward.sqrMagnitude < 1e-8f || up.sqrMagnitude < 1e-8f) continue;
bones[i].position = world.GetColumn(3);
bones[i].rotation = Quaternion.LookRotation(forward, up);
set++;
}
return set;
}
static int Depth(Transform t)
{
int d = 0;
for (; t != null; t = t.parent) d++;
return d;
} }
bool FindBones() bool FindBones()
@@ -232,55 +275,22 @@ namespace MSCO
if (lowest < float.MaxValue) Root.localPosition = new Vector3(0f, -lowest + 0.06f, 0f); if (lowest < float.MaxValue) Root.localPosition = new Vector3(0f, -lowest + 0.06f, 0f);
} }
void SetupAnimation() /// <summary>Grundhaltung aller Knochen merken (nicht Root selbst - den stellt Pose auf den Boden).</summary>
{
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() void RememberRestPose()
{ {
var bones = new List<Transform> { pelvis, spine, head }; foreach (Transform b in Root.GetComponentsInChildren<Transform>(true))
bones.AddRange(thigh); if (b != Root)
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 }); posed.Add(new BoneState { Bone = b, RestPos = b.localPosition, RestRot = b.localRotation });
} }
void ResetUnanimatedBones() /// <summary>Jedes Frame zurück zur Grundhaltung, damit sich nichts aufsummiert.</summary>
void ResetToRest()
{ {
foreach (BoneState s in posed) foreach (BoneState st in posed)
{ {
if (s.HasOut && s.Bone.localPosition == s.OutPos && s.Bone.localRotation == s.OutRot) if (st.Bone == null) continue;
{ st.Bone.localPosition = st.RestPos;
s.Bone.localPosition = s.RestPos; st.Bone.localRotation = st.RestRot;
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;
} }
} }
@@ -289,26 +299,27 @@ namespace MSCO
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.Append("legs ").Append(knee[0] != null && foot[0] != null ? "ok" : "missing"); 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(", arms ").Append(hand[1] != null && upperArm[1] != null ? "ok" : "missing");
sb.Append(", anim idle=").Append(idleClip ?? "-").Append(" walk=").Append(walkClip ?? "-"); sb.Append(", ").Append(bindInfo).Append(", procedural animation");
return sb.ToString(); return sb.ToString();
} }
// ---------------------------------------------------------------- Pro Frame (LateUpdate, nach der Animation) // ---------------------------------------------------------------- Pro Frame (LateUpdate, nach der Animation)
/// <summary>Eigener Laufzyklus: Tempo aus der Geschwindigkeit, weiches Ein-/Ausblenden beim Anhalten.</summary>
public void Animate(float speed) public void Animate(float speed)
{ {
if (anim == null) return; float dt = Time.deltaTime;
bool walking = speed > 0.4f && walkClip != null; float target = Mathf.Clamp01((speed - 0.2f) / 1.2f);
string clip = walking ? walkClip : (idleClip ?? walkClip); walkAmount = Mathf.MoveTowards(walkAmount, target, dt * 4f);
if (clip == null) return; // ca. 1,8 Schritte pro Sekunde beim Gehen, schneller beim Rennen
if (!anim.IsPlaying(clip)) anim.CrossFade(clip, 0.25f); walkPhase += dt * Mathf.Lerp(0f, Mathf.Clamp(speed * 2.6f, 5f, 13f), walkAmount);
if (clip == walkClip) anim[walkClip].speed = walking ? Mathf.Clamp(speed / 1.4f, 0.6f, 2.2f) : 0f; // angehalten = stehen if (walkPhase > Mathf.PI * 2f) walkPhase -= Mathf.PI * 2f;
} }
public void Pose(Transform body, Vector3 headTarget, float pitch, bool seated, bool holding, Vector3 heldPos) 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; Vector3 up = body.up, forward = body.forward, right = body.right;
ResetUnanimatedBones(); ResetToRest();
frames++; frames++;
// 1) Auf den Boden stellen: die (animierten) Füße stehen auf der Bodenhöhe des Spielers. // 1) Auf den Boden stellen: die (animierten) Füße stehen auf der Bodenhöhe des Spielers.
@@ -329,13 +340,19 @@ namespace MSCO
calibrated = true; calibrated = true;
float eye = Vector3.Dot(head.position + up * EyeAboveHeadBone - body.position, up); 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); 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 return; // nächstes Frame mit neuer Größe neu auf den Boden stellen
} }
// Fußpositionen der Animation merken (die sollen stehen bleiben) // Fußziele: Grundstellung unter der Hüfte plus Laufzyklus (Schritt vor/zurück, Fuß anheben)
float stride = 0.28f * walkAmount, lift = 0.13f * walkAmount;
for (int i = 0; i < 2; i++) for (int i = 0; i < 2; i++)
if (foot[i] != null) animatedFoot[i] = foot[i].position; {
if (foot[i] == null) continue;
float phase = walkPhase + (i == 0 ? 0f : Mathf.PI);
animatedFoot[i] = foot[i].position + forward * (Mathf.Sin(phase) * stride) + up * (Mathf.Max(0f, Mathf.Cos(phase)) * lift);
}
// Leichtes Wippen beim Gehen
pelvis.position += up * (Mathf.Abs(Mathf.Sin(walkPhase)) * 0.03f * walkAmount);
// 3) Becken: beim Ducken/Sitzen absenken, beim Lehnen nach hinten ausweichen // 3) Becken: beim Ducken/Sitzen absenken, beim Lehnen nach hinten ausweichen
Vector3 eyeNow = head.position + up * EyeAboveHeadBone; Vector3 eyeNow = head.position + up * EyeAboveHeadBone;
@@ -361,16 +378,25 @@ namespace MSCO
SolveTwoBone(thigh[i], knee[i], foot[i], target, forward); SolveTwoBone(thigh[i], knee[i], foot[i], target, forward);
} }
// Arme: rechts zum gehaltenen Item, im Auto beide nach vorne (Lenkrad) // Arme: rechts zum gehaltenen Item, im Auto beide nach vorne (Lenkrad), sonst locker hängend und beim
// Gehen gegengleich zu den Beinen schwingend (die Grundhaltung ist oft ein T mit waagrechten Armen)
for (int i = 0; i < 2; i++) for (int i = 0; i < 2; i++)
{ {
if (upperArm[i] == null || forearm[i] == null || hand[i] == null) continue; if (upperArm[i] == null || forearm[i] == null || hand[i] == null) continue;
Vector3 shoulder = upperArm[i].position;
float armLength = (forearm[i].position - shoulder).magnitude + (hand[i].position - forearm[i].position).magnitude;
float side = Vector3.Dot(shoulder - body.position, right) >= 0f ? 1f : -1f;
if (i == 1 && holding) if (i == 1 && holding)
SolveTwoBone(upperArm[i], forearm[i], hand[i], heldPos, -forward - up * 0.5f); SolveTwoBone(upperArm[i], forearm[i], hand[i], heldPos, -forward - up * 0.5f);
else if (seated) else if (seated)
SolveTwoBone(upperArm[i], forearm[i], hand[i], upperArm[i].position + forward * 0.38f - up * 0.28f, -up); SolveTwoBone(upperArm[i], forearm[i], hand[i], shoulder + forward * 0.38f - up * 0.28f, -up);
else
{
float swing = Mathf.Sin(walkPhase + (i == 0 ? Mathf.PI : 0f)) * 0.22f * walkAmount;
Vector3 handTarget = shoulder - up * (armLength * 0.92f) + right * (side * 0.07f) + forward * (0.05f + swing);
SolveTwoBone(upperArm[i], forearm[i], hand[i], handTarget, -forward);
}
} }
RememberOutput();
} }
/// <summary>Knochen so drehen, dass "child" in Richtung "target" zeigt - egal wie das Skelett ausgerichtet ist.</summary> /// <summary>Knochen so drehen, dass "child" in Richtung "target" zeigt - egal wie das Skelett ausgerichtet ist.</summary>
+5 -1
View File
@@ -1223,7 +1223,11 @@ namespace MSCO
if (MSCO.PassengerKey != null && MSCO.PassengerKey.GetKeybindDown()) if (MSCO.PassengerKey != null && MSCO.PassengerKey.GetKeybindDown())
{ {
string result = passenger.Toggle(localPlayer, playerCamera, objects, players); 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; bool ready = IsPeerReady;
+12 -4
View File
@@ -11,7 +11,7 @@ namespace MSCO
/// </summary> /// </summary>
public class Passenger 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 CenterSeatThreshold = 0.2f;
const float BehindCenterSeat = 0.7f; const float BehindCenterSeat = 0.7f;
const float ExitSideOffset = 1.4f; // beim Aussteigen so weit neben den Sitz const float ExitSideOffset = 1.4f; // beim Aussteigen so weit neben den Sitz
@@ -40,14 +40,22 @@ namespace MSCO
SyncedBody best = null; SyncedBody best = null;
RemotePlayer driver = null; RemotePlayer driver = null;
float bestDist = EnterRange * EnterRange; 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; PlayerInfo owner;
if (!players.TryGetValue(b.OwnerId, out owner) || owner.Avatar == null) continue; 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 (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; Transform vt = best.Rb.transform;
Vector3 driverHead = vt.InverseTransformPoint(driver.HeadPosition); Vector3 driverHead = vt.InverseTransformPoint(driver.HeadPosition);