LifePlay-Revival/Docs/Migration/etat/sandbox/S5/S5-IMPL-H.md
edujiro5972 04b053554f Sandbox addon : brancher le jeu sur LifePlay-Sandbox (plugin LPSandbox).
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>
2026-10-06 21:10:53 +02:00

257 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 Development` **et** `LifePlayEditor Win64 Development`, 0 erreur / 0 avertissement
- **Commit addon :** `c1fdd29` — `S5-T2: LPCoupleSim dans LPAnimMeasure, source de verite geometrie du couple` (poussé sur `origin/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
```cpp
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
```cpp
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)
```cpp
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
```cpp
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
```cpp
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.bStartPenetrating` de la sandbox est conservée ;
- le jeu projette `Wish` **sans** facteur de temps sur la normale du plus profond, puis **avec**
`1 - Time` sur celle du bloqueur : les deux appels à `SlideWish` passent `1.f` et `1.f - Time`.
### 5.2 Les cinq différences réelles
1. **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 fige
`BoxOffset = 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.
2. **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'arrondi `double` près,
`bSweep` restant `false`.
3. **Journal `bloque par` du jeu : une ligne par frame au lieu d'une par passe.** `FSweepResult` ne
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éduplication `LastBlocker` et le
format du message sont inchangés ; la coque reste journalisée une seule fois, via `OnIgnoredShell`.
4. **Étiquette de requête de collision.** `LPPairBox` (sandbox) et `LPShowCouple` (jeu) deviennent
`LPCoupleSim`. Visible uniquement dans `STATGROUP_CollisionTags`.
5. **Précision `float` → `double` dans le recalage du fond.** Les deux copies calculaient `Bottom` et
`Cut` en `float` ; `FitFromAnimBounds` les calcule en `double`. Écart de l'ordre de 10⁻⁵ cm.
Le seuil `PlayerSpot.Z + Center.Z - Extent.Z < 18` de la sandbox est réécrit en
`Center.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 %s` et
`Show couple : boite %s centre %s etendue %s` avant / après ce lot (écart attendu : **aucun**, le
plancher restant différent par option). Passer `LPPairCollisionTest`.
- **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`,
`FitFromLiveBounds` et `SlideWish` ne touchent ni `UObject` ni `UWorld` : ils sont testables dans
un commandlet sans monde, ce qui n'était pas possible avant ce lot.