Charge le plugin via AdditionalPluginDirectories, conserve ShowCouple et les commandlets anim côté jeu, et documente les lots S0–S8 (CHG-212 à CHG-220). Co-authored-by: Cursor <cursoragent@cursor.com>
16 KiB
S5-IMPL-H — LPCoupleSim : source de vérité de la géométrie du couple (fiche S5-T2)
- Date : 2026-10-06
- Fiche : S5-T2 | Agent : A-IMPL-H | Rôle : Implémentation
- Modèle prévu :
claude-opus-5-thinking-high(M3) | Modèle utilisé :claude-opus-5-thinking-high - Amont :
S5/S5-CART.md(S5-T1, lecture seule),S4/S4-IMPL-H.md - Statut : compilé —
LPSandboxEditor Win64 DevelopmentetLifePlayEditor Win64 Development, 0 erreur / 0 avertissement - Commit addon :
c1fdd29—S5-T2: LPCoupleSim dans LPAnimMeasure, source de verite geometrie du couple(poussé surorigin/main) - Repo jeu :
Source/LifePlay/LPShowCoupleSubsystem.{h,cpp}modifiés sans commit (ces deux fichiers ne sont pas encore suivis par git dans le repo jeu ; la fiche ne demande que le commit addon).
1. Où vit le code
Il n'existe qu'une seule copie de LPAnimMeasure : celle de l'addon. LifePlay.uproject déclare
"AdditionalPluginDirectories": ["../LifePlay-Sandbox/Plugins"] et LifePlay.Build.cs dépend
publiquement de LPAnimMeasure (l. 45). Un header posé dans l'addon est donc immédiatement visible
par le jeu, sans aucun changement de .Build.cs et sans recopie.
| Fichier | LOC | État |
|---|---|---|
Plugins/LPSandbox/Source/LPAnimMeasure/LPCoupleSim.h |
215 | nouveau |
Plugins/LPSandbox/Source/LPAnimMeasure/LPCoupleSimCore.cpp |
474 | nouveau |
Plugins/LPSandbox/Source/LPSandbox/LPCoupleSim.cpp |
477 → 384 | −93 (−119 / +26) |
Source/LifePlay/LPShowCoupleSubsystem.cpp |
1048 → 805 | −243 |
Source/LifePlay/LPShowCoupleSubsystem.h |
77 → 72 | FSavedMove imbriquée supprimée |
Nom de fichier d'implémentation : LPCoupleSimCore.cpp, comme le recommandait la cartographie
(risque n° 7), pour ne pas collisionner avec LPSandbox/LPCoupleSim.cpp.
336 lignes de code dupliqué disparaissent des deux appelants, contre 689 lignes de module partagé. Le solde est positif en volume (+353) et assumé : il paie les commentaires du contrat, les deux structures d'options, et les deux stratégies de balayage, qui vivaient auparavant dans deux modules différents sans aucun lien.
2. API livrée (namespace LPCoupleSim, exports LPANIMMEASURE_API)
2.1 Constantes
inline constexpr double DefaultCapsuleHalf = 88.0;
inline constexpr int32 PoseSamples = 8;
inline constexpr float WalkableNormalZ = 0.6f;
inline constexpr float MinPenetrationDepth = 1.f;
inline constexpr float MoveEpsilon = 0.1f;
inline constexpr double MeshYawDegrees = -90.0;
2.2 Paramètres de mesure
struct FBoxFitParams
{
double CapsuleHalf = DefaultCapsuleHalf;
FVector AnimPad = FVector(12.0, 12.0, 6.0); // voie animations
double AnimMaxSize = 800.0;
FVector LivePad = FVector(4.0, 4.0, 4.0); // voie corps vivants
FVector LiveMinExtent = FVector(8.0, 8.0, 8.0);
double LiveMaxSize = 400.0;
double LiveAnchorRadius = 140.0;
double FloorLocalZ = -DefaultCapsuleHalf; // fond de boîte, Z local
double MinExtentZ = 20.0;
};
struct FBoxFit { FVector Center; FVector Extent; bool bValid = false; };
FloorLocalZ par défaut = -DefaultCapsuleHalf, comme demandé par la fiche.
2.3 Niveau pur (aucun UObject, aucun UWorld — testable sans monde)
| Fonction | Rôle |
|---|---|
FTransform MeshToActor(double CapsuleHalf) |
lacet −90, décalage -CapsuleHalf |
void ComposeComponentSpace(const TArray<int32>& Parents, const TArray<FTransform>& Local, TArray<FTransform>& OutComponent) |
locales + parents → espace composant |
void AccumulatePoints(const TArray<FTransform>& Component, const FTransform& ToActor, FBox&) |
ajoute chaque os à la boîte |
bool IsExcludedLiveBone(const FString& BoneName) |
hand, finger, foot, toe, twist, breast, hair, eye, jaw, weapon, ik |
FBoxFit FitFromAnimBounds(const FBox&, const FBoxFitParams&) |
padding, plafond, recalage du fond sur FloorLocalZ |
FBoxFit FitFromLiveBounds(const FBox&, const FBoxFitParams&) |
padding, étendue minimale, plafond (pas de recalage) |
FVector SlideWish(const FVector& Wish, const FVector& Normal, float TimeFraction, bool bFlattenZ) |
glissement, Z = 0 optionnel |
2.4 Niveau lecture (assets et composants, sans effet de bord)
double CapsuleHalfOf(const ACharacter* Who, double Fallback = DefaultCapsuleHalf);
bool AccumulateAnimBounds(const UAnimSequence*, const FTransform& ToActor, FBox&, int32 Samples = PoseSamples);
void AccumulateLiveBody(const USkeletalMeshComponent*, const FBoxFitParams&, FBox&);
2.5 Niveau effets de bord
UBoxComponent* EnsureBox(ACharacter* Lead, UBoxComponent* Existing, FName Name, bool bEnabled);
void ApplyBoxFit(UBoxComponent* Box, const FBoxFit& Fit);
void PlaceBodyMesh(ACharacter* Body, double CapsuleHalf);
struct FSavedMove { TWeakObjectPtr<UCharacterMovementComponent> Move; TEnumAsByte<EMovementMode> Mode; float Gravity; bool bTick; };
FSavedMove SaveAndFreeze(ACharacter* Body);
void RestoreMove(FSavedMove& Saved);
void AttachPartner(ACharacter* Lead, ACharacter* Partner);
void Unweld(ACharacter* Partner);
EnsureBox prend et retourne un UBoxComponent* au lieu du UBoxComponent*& proposé par la
cartographie : la sandbox stocke sa boîte dans un TObjectPtr, le jeu dans un TWeakObjectPtr,
aucun des deux ne peut lier une référence à UBoxComponent*.
2.6 Balayage
struct FSweepOptions
{
int32 MaxIterations = 3;
bool bMultiSweep = true; // SweepMulti (jeu) ou SweepSingle (sandbox)
bool bFlattenZ = false;
bool bAcceptWalkableStep = false; // voie simple seulement
bool bDepenetrate = true;
bool bIgnoreAllCharacters = true;
TFunction<bool(const UPrimitiveComponent*)> IsWalkableSupport; // jeu : IsBedMesh
TFunction<bool(const AActor*)> IsIgnoredShell; // jeu : Wall / Wall_*
TFunction<void(const AActor*)> OnIgnoredShell; // journal de l'appelant
static FSweepOptions Show(); // 3, multi, dépénétration, PNJ + coque ignorés
static FSweepOptions Sandbox(); // 2, simple, arrêt net, marches, Z à plat, rien d'ignoré
};
struct FSweepResult { FVector Location; FString Blocker; FVector BlockerNormal; float BlockerDepth; bool bMoved; };
FSweepResult ResolveCoupleMove(UWorld*, const UBoxComponent*, const ACharacter* Lead,
const ACharacter* Partner, const FVector& From, const FVector& Wish, const FSweepOptions&);
ResolveCoupleMove ne déplace rien : elle rend la position d'acteur retenue, et chaque appelant
l'applique à sa façon (AddActorWorldOffset pour la sandbox, SetActorLocation + TeleportPhysics
pour le jeu). C'est ce qui permet de partager la résolution sans toucher au contrat de chaque côté.
Deux stratégies, une seule API. bMultiSweep choisit ResolveSingle (sandbox) ou ResolveMulti
(jeu), deux fonctions statiques de LPCoupleSimCore.cpp. C'est un choix délibéré : la fiche demande
de garder SweepSingle 2 itérations d'un côté et SweepMulti 3 itérations de l'autre, et les deux
séquences ne sont pas superposables sans changer un comportement (voir §5.1). Tout le reste du
balayage — paramètres de requête, liste des ignorés, forme, rotation, décalage de la boîte, tolérance
finale — est commun.
3. Branchements
| Appelant | Avant | Après |
|---|---|---|
Sandbox FitCoupleBox |
lambda Accumulate (53 l.), padding + clamp inline, création de boîte inline, ResetBody inline |
MeshToActor + AccumulateAnimBounds + FitFromAnimBounds + EnsureBox + ApplyBoxFit + PlaceBodyMesh |
Sandbox Weld |
3 appels de gel + capsule + attache | SaveAndFreeze + capsule + AttachPartner |
Sandbox MoveCouple |
boucle de balayage (30 l.) | ResolveCoupleMove(..., FSweepOptions::Sandbox()) |
Jeu FitBox |
AddPose (51 l.) + padding + clamp + boîte inline |
mêmes helpers, FloorLocalZ = -CapsuleHalfOf(Lead) |
Jeu FitToBodies |
lambda AddBody (54 l.) + étendues + boîte inline |
AccumulateLiveBody + FitFromLiveBounds + EnsureBox + ApplyBoxFit |
Jeu Weld |
attache + placement des deux meshes inline | AttachPartner + 2 × PlaceBodyMesh |
Jeu Engage / Release |
FSavedMove imbriquée, gel et restitution inline |
SaveAndFreeze / Unweld + RestoreMove |
Jeu KeepTogether |
boucle de balayage (95 l.) | ResolveCoupleMove(..., FSweepOptions::Show()) + 3 lambdas d'options |
AddPose (jeu) et la lambda Accumulate (sandbox) — les deux copies « caractère par caractère »
signalées au §1 de la cartographie — n'existent plus. Idem pour les deux blocs de création de
UBoxComponent et les deux placements de mesh.
4. Options retenues de chaque côté (écarts volontairement conservés)
| Réglage | Sandbox | Jeu |
|---|---|---|
| Fonction de balayage | SweepSingleByChannel |
SweepMultiByChannel |
| Itérations | 2 | 3 |
| Point de départ | position courante du porteur | LastSafe |
| Départ en pénétration | arrêt net | dépénétration (profondeur ≥ 1) |
| Marches | Normal.Z > 0.6 → pas entier accepté |
pas de marche générique ; lits seulement |
| Glissement | Z annulé |
Z libre |
| Ignorés | porteur + partenaire | + tous les ACharacter + coque Wall* |
FloorLocalZ |
18 - PlayerSpot.Z (= −90) |
-CapsuleHalfOf(Lead) (= −88) |
| Nom du composant | CoupleBox |
LPShowCoupleBox |
L'écart de plancher de 2 cm (C4 / E7 de la cartographie) est conservé et devient une seule ligne
visible dans FitCoupleBox ; l'aligner est désormais un changement d'un caractère, à valider en S5-T3.
5. Écarts de comportement introduits (liste exhaustive)
5.1 Ce qui n'a pas bougé
Les deux séquences de balayage sont recopiées pas à pas, y compris les points où elles divergent :
- le jeu teste
Wish.IsNearlyZero()après la dépénétration, la sandbox en condition de boucle ; les deux ordres sont préservés dans leur fonction respective (les unifier ferait sauter une passe de dépénétration au jeu) ; - la garde
!StepDelta.IsNearlyZero() && !Hit.bStartPenetratingde la sandbox est conservée ; - le jeu projette
Wishsans facteur de temps sur la normale du plus profond, puis avec1 - Timesur celle du bloqueur : les deux appels àSlideWishpassent1.fet1.f - Time.
5.2 Les cinq différences réelles
- Position de la boîte pendant la résolution. Les deux versions recalculaient le point de départ
différemment (
Box->GetComponentLocation()relu à chaque passe côté sandbox,FTransform(ActorQuat, Base).TransformPosition(Rel)côté jeu). La version commune figeBoxOffset = Box->GetComponentLocation() - Lead->GetActorLocation()avant la boucle. Identique tant que la rotation du porteur ne change pas pendant la résolution (c'est le cas des deux) ; plus robuste que la version jeu, qui ignorait l'échelle de l'acteur. - Déplacement de la sandbox appliqué en une fois.
AddActorWorldOffsetétait appelé à chaque passe ; il l'est maintenant une fois, avec la somme. Même résultat à l'arrondidoubleprès,bSweeprestantfalse. - Journal
bloque pardu jeu : une ligne par frame au lieu d'une par passe.FSweepResultne retient que le dernier bloqueur en pénétration. Si deux bloqueurs différents apparaissent dans deux passes d'une même frame, seul le second est journalisé. La déduplicationLastBlockeret le format du message sont inchangés ; la coque reste journalisée une seule fois, viaOnIgnoredShell. - Étiquette de requête de collision.
LPPairBox(sandbox) etLPShowCouple(jeu) deviennentLPCoupleSim. Visible uniquement dansSTATGROUP_CollisionTags. - Précision
float→doubledans le recalage du fond. Les deux copies calculaientBottometCutenfloat;FitFromAnimBoundsles calcule endouble. Écart de l'ordre de 10⁻⁵ cm. Le seuilPlayerSpot.Z + Center.Z - Extent.Z < 18de la sandbox est réécrit enCenter.Z - Extent.Z < 18 - PlayerSpot.Z, algébriquement identique.
Hors balayage, aucune autre formule n'a été retouchée : padding, plafonds, étendue minimale, ancre de
pelvis, os exclus, échantillonnage à 8 pas, GetBoneTransform(..., raw = false), os animés seuls,
rejet des poses NaN, profil de collision de la boîte et rotation relative nulle sont recopiés.
6. Compilation
| Cible | Mode d'unité | Résultat |
|---|---|---|
LPSandboxEditor Win64 Development |
non-unity (LPCoupleSim.cpp et LPCoupleSimCore.cpp exclus du blob, car dans le working set git de l'addon) |
succès, 0 erreur / 0 avertissement |
LifePlayEditor Win64 Development |
unity (Module.LPAnimMeasure.cpp, Module.LPSandbox.cpp, Module.LifePlay.cpp) |
succès, 0 erreur / 0 avertissement |
Les deux modes sont couverts (risque n° 8 de la cartographie, UseExplicitOrSharedPCHs) : le passage
non-unity prouve que les #include de LPCoupleSimCore.cpp se suffisent, le passage unity valide le
header dans le blob. Aucun include n'a dû être corrigé ; la compilation est passée du premier coup
des deux côtés. LPCoupleSim.h ne déclare que des types anticipés (ACharacter, UBoxComponent,
UAnimSequence, USkeletalMeshComponent, UCharacterMovementComponent, UPrimitiveComponent,
AActor, UWorld) ; les en-têtes lourds restent dans le .cpp.
UHT : 1 fichier de réflexion régénéré (LPShowCoupleSubsystem.h a changé, aucune UCLASS /
UPROPERTY touchée — la structure supprimée était une structure C++ nue dans la partie privée).
Deux includes devenus inutiles ont été retirés : Animation/Skeleton.h dans
LPSandbox/LPCoupleSim.cpp et dans LPShowCoupleSubsystem.cpp (seul AddPose / Accumulate les
utilisaient).
7. Ce qui n'a pas été partagé, et pourquoi
| Élément | Décision |
|---|---|
PairSibling, IsIdleClip, CompletePair, KeepShowClips |
restent dans le jeu. La sandbox ne gère pas _0/_1 et n'a pas de LastAnimation ; les déplacer sans appelant côté addon n'apporte rien à cette fiche (§4.4 de la cartographie, à reprendre si la sandbox gagne la complétion de paire). |
FlipResponse / RestoreResponses (canaux des meubles, capsules assises) |
hors fiche S5-T2. La sandbox bascule un tableau Furniture, le jeu parcourt la chambre par réflexion : le partage demande d'abord d'unifier la collecte des primitives, pas seulement l'inversion de réponse. |
Réflexion GI / Room, IsBedMesh, IsUseFurniture, OpenUseFurniture, lp.ShowCouple, sélection du couple |
restent dans le jeu (risque n° 5 de la cartographie). IsBedMesh est injecté dans le balayage par FSweepOptions::IsWalkableSupport, il n'a pas besoin de migrer. |
| Mode « corps vivants » côté sandbox | AccumulateLiveBody et FitFromLiveBounds sont exportés et prêts ; aucun appelant sandbox n'a été ajouté (risque n° 2 de la cartographie). |
CapsuleRadius, vitesse 250, verrouillage de PlayerSpot.Z, ConfigureCapsule, corps Quaternius |
spécifiques sandbox, inchangés. |
8. Reste à faire
- S5-T3 (validation / parité). Rien n'a été exécuté ici : seuls les deux builds sont la preuve.
Comparer, sur une même paire, les lignes
Sandbox boite %s : centre %s, etendue %setShow couple : boite %s centre %s etendue %savant / après ce lot (écart attendu : aucun, le plancher restant différent par option). PasserLPPairCollisionTest. - Commit du repo jeu.
LPShowCoupleSubsystem.{h,cpp}ne sont pas suivis par git côté jeu ; leur première mise sous suivi sort du périmètre de cette fiche. - Alignement du plancher (−90 → −88 côté sandbox) : une ligne de
FitCoupleBox, à décider en S5-T3 avec une capture avant / après. - Tests unitaires du niveau pur.
MeshToActor,ComposeComponentSpace,FitFromAnimBounds,FitFromLiveBoundsetSlideWishne touchent niUObjectniUWorld: ils sont testables dans un commandlet sans monde, ce qui n'était pas possible avant ce lot.