* Write the Unreal documentation set: design, steps, ideas, decisions and specs Rewrites the design and engineering docs of the two earlier Unity projects (Adventurer Guild, Project Malleable) for an Unreal Engine 5 prototype that builds and proves three things in order: a movement controller, fighting and crafting. Neither earlier core loop is carried; their ideas are catalogued as features with what each needs. - Docs/Design.md: the human summary (pillars, fixed decisions, glossary) - Docs/Steps.md: a ladder of fifteen proofs, the first six in full - Docs/Ideas.md: salvaged ideas from both projects, none scheduled - Docs/Decisions.md: D-01..D-36, open decisions OD-01..OD-08, deferrals - Docs/Spec/: notation, Architecture, Movement, Interaction, Combat, Crafting, Networking, Telemetry, UI, written as Unreal C++ skeletons - CLAUDE.md and README.md for the repository * Add the stat block: one attribute set on every body, every influence an effect Every body (player, enemy, NPC) carries the same UStatBlockAttributeSet, and every outside influence on it is a gameplay effect: base values, buffs, debuffs, ground surfaces, carried weight, gear, being downed. Counters are tags and attributes on the receiver: immunity blocks by tag, resistance scales by attribute, cleanse removes by tag. Same status from several sources: strongest wins; different statuses multiply. - Docs/Spec/Stats.md: the block, effects and their five sources (abilities, areas, surfaces, items, the world), stacking, counters, readers, tests - Movement: the movement component's tagged speed-multiplier map is removed; speed is tuning times the MoveSpeed attribute; the ground surface trace turns mud into an effect; the gym gets mud and ice patches - Combat: the attribute set moves to Stats; enemies and kits carry FStatBlockDefaults; the funnel reads MagicResist and Immune.Damage tags; taunt and mark are statuses - Interaction: carried weight is GE_Encumbered against CarryCapacity - Crafting: class bonuses are the WorkSpeed and WorkQuality attributes; enchantments may grant a holder effect - Steps: step 5 builds the block with mud, a volume and an immunity - Decisions D-37 and D-38; design summary, CLAUDE.md, indexes, worklog
446 lines
26 KiB
Markdown
446 lines
26 KiB
Markdown
# Movement
|
|
|
|
Owns the character, the movement component, input, the look model, the two camera modes, the gym level and the
|
|
hooks other systems use to change how a body moves. It does **not** own abilities that move a body (dodge, blink,
|
|
charge: [Combat.md](Combat.md), built on the root-motion hooks here) or picking things up
|
|
([Interaction.md](Interaction.md), which supplies the carry-weight penalty this document consumes).
|
|
|
|
Read [Architecture.md](Architecture.md) first. This is the first system built and the one everything else stands
|
|
on, which is why it gets three steps of its own in [`../Steps.md`](../Steps.md) before a single enemy exists.
|
|
|
|
## Why the controller comes first
|
|
|
|
A crafting game with bad movement is a menu with a walk between screens. A combat game with bad movement is unfair
|
|
before the first swing. Both earlier projects put movement in a corner of a combat step and tuned it by feel once,
|
|
against one enemy; neither ever wrote down what "good" meant. This time the controller is the first thing built,
|
|
it is built against a level made of nothing but movement problems, and it is not called done until a written
|
|
checklist passes with a person at the keyboard.
|
|
|
|
## Decisions
|
|
|
|
```
|
|
[DECIDED] UCharacterMovementComponent, extended. Not a custom controller, not the Mover plugin.
|
|
|
|
The engine's character movement is server-authoritative with client prediction and server correction built in,
|
|
which is exactly the posture Architecture.md demands and the thing the earlier Unity project never had (its
|
|
movement was client-authoritative, which does not survive contact with a real server). It integrates with root
|
|
motion sources, which is how abilities move a body without a second movement system. It is mature, documented and
|
|
what the engine's own animation tooling assumes.
|
|
|
|
The Mover plugin is the engine's future answer and is still marked experimental. Q2 keeps it in view; the
|
|
extension points used here (a saved-move flag, a tuning asset, root motion sources) are the ones Mover also
|
|
exposes, so a later move is a port, not a rewrite.
|
|
```
|
|
|
|
```
|
|
[DECIDED] One rig, two camera modes. The camera is presentation and never enters the authority path.
|
|
|
|
First person is the engine's mannequin seen from a socket on its own head; third person is the same mannequin
|
|
seen over its shoulder. There is no first-person arms rig, no second animation set. Every activity, prop and
|
|
fight must be completable in both modes, and nothing about the camera is ever replicated, saved or used by the
|
|
server to decide anything. Which mode is the default is Q1; both exist from step 3 so the answer can be played
|
|
rather than argued.
|
|
```
|
|
|
|
```
|
|
[DECIDED] Units are the engine's: centimetres, Z up, 1 uu = 1 cm. Every figure in this doc is in those.
|
|
```
|
|
|
|
## Layout
|
|
|
|
```
|
|
Source/<Project>Core/Movement/
|
|
├── LookModel.h / .cpp // pure: the deadzone-then-body-follows look model
|
|
└── MovementTuning.h // the tuning asset class and its validation
|
|
|
|
Source/<Project>/Movement/
|
|
├── BaseCharacter.h // ACharacter subclass shared by players and enemies: mesh, team, ASC access
|
|
├── PlayerCharacter.h // adds camera, input, interaction and carry components
|
|
├── ExtendedCharacterMovement.h // the CMC subclass: sprint flag, coyote time, jump buffer, speed multipliers
|
|
├── CameraModeDefinition.h // UPrimaryDataAsset per mode
|
|
└── CameraModeComponent.h // owns the camera and spring arm, blends between modes, applies the look model
|
|
|
|
Content/Movement/
|
|
├── Definitions/DA_Tuning_Player, DA_CameraMode_FirstPerson, DA_CameraMode_ThirdPerson
|
|
├── Input/IMC_Gameplay, IA_* // one mapping context for play; a second (IMC_Menu) arrives with the menu
|
|
├── BP_PlayerCharacter // sets the mesh, animation blueprint, tuning and camera assets. No logic.
|
|
├── Surfaces/PM_Mud, PM_Ice // UPhysicalMaterialWithTags for the gym's surfaces (Stats.md), step 5
|
|
└── Maps/L_Gym // the movement test level
|
|
```
|
|
|
|
## Types at a glance
|
|
|
|
| Type | Module | Lifetime | Notes |
|
|
| --- | --- | --- | --- |
|
|
| `FLookModelParams`, `FLookModelState`, `LookModel::Tick` | Core | value | pure, tested without a world |
|
|
| `UMovementTuning` | Core | asset | numbers, with `IsDataValid` checks |
|
|
| `ABaseCharacter` | Gameplay | per body | `IAbilitySystemInterface`, `IGenericTeamAgentInterface` |
|
|
| `APlayerCharacter` | Gameplay | per body | camera, input binding, interaction, carry |
|
|
| `UExtendedCharacterMovement` | Gameplay | per body | the CMC subclass, predicted |
|
|
| `UCameraModeDefinition` | Gameplay | asset | one per mode |
|
|
| `UCameraModeComponent` | Gameplay | per player body | local only, never replicated |
|
|
|
|
## Input
|
|
|
|
Enhanced Input. One mapping context, `IMC_Gameplay`, added at priority 0 in `APlayerCharacter::SetupPlayerInputComponent`
|
|
through the local player's `UEnhancedInputLocalPlayerSubsystem`. Actions are assets under `Content/Movement/Input/`
|
|
and are bound by the action asset, never by key, so a rebind is a settings change and not a code change. Rebinding
|
|
itself uses the engine's `UEnhancedInputUserSettings` (player-mappable key settings on each action), which persists
|
|
to the player's save folder; no custom rebinding code is written.
|
|
|
|
The default layout. Keyboard bindings are the ones the earlier project settled on, with the one conflict resolved
|
|
in Q4's favour: sprint takes Shift, the fourth ability slot moves.
|
|
|
|
| Action | Keyboard and mouse | Gamepad | Value | Notes |
|
|
| --- | --- | --- | --- | --- |
|
|
| `IA_Move` | W A S D | Left stick | Axis2D | Relative to the body in first person, to the camera in third |
|
|
| `IA_Look` | Mouse | Right stick | Axis2D | Mouse deltas are **not** scaled by delta time; stick rates are |
|
|
| `IA_Jump` | Space | A | Bool | Buffered and coyote-timed, see below |
|
|
| `IA_Sprint` | Left Shift (hold) | Left stick click | Bool | A predicted movement flag, not an ability |
|
|
| `IA_Crouch` | Left Ctrl (toggle) | B (hold) | Bool | Engine crouch, capsule shrinks |
|
|
| `IA_Dodge` | Left Alt | B (tap) | Bool | Activates the dodge ability once it exists, step 5 |
|
|
| `IA_Attack` | Left mouse | Right trigger | Bool | Owned by Combat; bound here so the map is in one place |
|
|
| `IA_Interact` | F | X | Bool | Owned by Interaction |
|
|
| `IA_Drop` | G (tap drops, hold throws) | Y | Bool | Owned by Interaction |
|
|
| `IA_Ability1..4` | Q, E, R, C | LB, RB, Y, LT | Bool | Owned by Combat, routed through input tags |
|
|
| `IA_CameraToggle` | V | D-pad down | Bool | Swaps camera mode |
|
|
| `IA_Menu` | Escape | Start | Bool | Owned by UI |
|
|
|
|
Every action carries an `Input.*` gameplay tag in its player-mappable key settings so the ability system, the prompt
|
|
and the action bar can ask "which key is `Input.Interact` on the device this player touched last" and get the
|
|
live binding back. No view ever prints a literal key.
|
|
|
|
## The character and the movement component
|
|
|
|
```cpp
|
|
// Source/<Project>Core/Movement/MovementTuning.h
|
|
UCLASS(BlueprintType)
|
|
class UMovementTuning : public UPrimaryDataAsset
|
|
{
|
|
GENERATED_BODY()
|
|
public:
|
|
// Speeds, cm/s. All guesses until the gym says otherwise. Walk is the old project's 4.5 m/s.
|
|
UPROPERTY(EditDefaultsOnly, Category = "Speed") float WalkSpeed = 450.f;
|
|
UPROPERTY(EditDefaultsOnly, Category = "Speed") float SprintSpeed = 650.f;
|
|
UPROPERTY(EditDefaultsOnly, Category = "Speed") float CrouchSpeed = 250.f;
|
|
UPROPERTY(EditDefaultsOnly, Category = "Speed") float MaxAcceleration = 2048.f;
|
|
UPROPERTY(EditDefaultsOnly, Category = "Speed") float BrakingDeceleration = 2048.f;
|
|
UPROPERTY(EditDefaultsOnly, Category = "Speed") float AirControl = 0.35f;
|
|
|
|
// Jump. Apex ≈ JumpZ² / (2 · 980 · GravityScale): 560 at 1.5 gravity is about 106 cm, the old 1.1 m jump.
|
|
UPROPERTY(EditDefaultsOnly, Category = "Jump") float JumpZVelocity = 560.f;
|
|
UPROPERTY(EditDefaultsOnly, Category = "Jump") float GravityScale = 1.5f;
|
|
UPROPERTY(EditDefaultsOnly, Category = "Jump") float CoyoteTime = 0.10f; // seconds after leaving a ledge a jump still counts
|
|
UPROPERTY(EditDefaultsOnly, Category = "Jump") float JumpBufferTime = 0.12f; // seconds before landing a press is remembered
|
|
|
|
// Ground. The engine defaults are right for stairs up to 45 cm and slopes to 45 degrees; listed so they are tuned here.
|
|
UPROPERTY(EditDefaultsOnly, Category = "Ground") float MaxStepHeight = 45.f;
|
|
UPROPERTY(EditDefaultsOnly, Category = "Ground") float WalkableFloorAngle = 45.f;
|
|
|
|
// Landing. A drop taller than this costs a brief recovery; taller than the second, fall damage (Combat.md).
|
|
UPROPERTY(EditDefaultsOnly, Category = "Landing") float HardLandingHeight = 300.f;
|
|
UPROPERTY(EditDefaultsOnly, Category = "Landing") float HardLandingRecovery = 0.2f;
|
|
|
|
#if WITH_EDITOR
|
|
virtual EDataValidationResult IsDataValid(FDataValidationContext& Context) const override;
|
|
// SprintSpeed > WalkSpeed > CrouchSpeed > 0; CoyoteTime and JumpBufferTime under 0.3 s; angles in (0, 90).
|
|
#endif
|
|
};
|
|
```
|
|
|
|
```cpp
|
|
// Source/<Project>/Movement/ExtendedCharacterMovement.h
|
|
/**
|
|
* The engine's character movement plus the four things every feel pass ends up adding: a sprint flag that
|
|
* predicts correctly, coyote time, a jump buffer, and the ground surface trace that turns mud into an effect.
|
|
*
|
|
* Sprint is a compressed flag in the saved move, which is the engine's mechanism for predicted input state.
|
|
* It is deliberately NOT a gameplay ability: an ability round-trips through the ability system for something the
|
|
* movement component already replicates for free. Dodge and blink ARE abilities, because they apply root motion.
|
|
*/
|
|
UCLASS()
|
|
class UExtendedCharacterMovement : public UCharacterMovementComponent
|
|
{
|
|
GENERATED_BODY()
|
|
public:
|
|
void ApplyTuning(const UMovementTuning& Tuning); // called by the character on BeginPlay and on tuning change
|
|
|
|
// Input state, set by the owning character, carried in the saved move
|
|
void SetWantsToSprint(bool bWants);
|
|
void PressJumpBuffered(); // remembers a press for JumpBufferTime
|
|
|
|
// Speed is the state's tuning value times the body's MoveSpeed attribute (Stats.md), and nothing else. Carrying,
|
|
// being downed, mud, a haste and gear all change that one attribute through effects; this component never
|
|
// holds a multiplier of its own. Before the stat block exists (steps 3 and 4) the attribute reads as one.
|
|
float GetMoveSpeedAttribute() const; // 1.0 when the owner has no ability system component yet
|
|
void UpdateGroundSurface(); // server, ~5 Hz: the floor's UPhysicalMaterialWithTags -> its surface effect
|
|
|
|
// UCharacterMovementComponent
|
|
virtual float GetMaxSpeed() const override; // walk/sprint/crouch by state, times MoveSpeed
|
|
virtual bool CanAttemptJump() const override; // grounded, OR within CoyoteTime of leaving the ground
|
|
virtual void UpdateFromCompressedFlags(uint8 Flags) override;
|
|
virtual FNetworkPredictionData_Client* GetPredictionData_Client() const override;
|
|
virtual void OnMovementModeChanged(EMovementMode PrevMode, uint8 PrevCustomMode) override; // starts the coyote clock
|
|
|
|
protected:
|
|
bool bWantsToSprint = false;
|
|
float TimeLeftGround = -1.f;
|
|
float JumpBufferedAt = -1.f;
|
|
TWeakObjectPtr<UPhysicalMaterial> CurrentSurface;
|
|
FActiveGameplayEffectHandle SurfaceEffect;
|
|
|
|
// FSavedMove_Character subclass carrying bWantsToSprint in FLAG_Custom_0; FNetworkPredictionData_Client_Character
|
|
// subclass allocating it. Standard engine pattern; see the engine's own ACharacter crouch flag for the shape.
|
|
};
|
|
```
|
|
|
|
```cpp
|
|
// Source/<Project>/Movement/PlayerCharacter.h
|
|
/**
|
|
* The player's body. Owner-only concerns (camera, input, interaction, carrying) live here as components;
|
|
* everything shared with enemies is on ABaseCharacter. The character decides nothing: it reads input, hands it to
|
|
* the movement component and the ability system, and lets the camera component look.
|
|
*/
|
|
UCLASS()
|
|
class APlayerCharacter : public ABaseCharacter
|
|
{
|
|
GENERATED_BODY()
|
|
public:
|
|
APlayerCharacter(const FObjectInitializer& OI);
|
|
|
|
protected:
|
|
UPROPERTY(EditDefaultsOnly, Category = "Movement") TObjectPtr<UMovementTuning> Tuning;
|
|
UPROPERTY(EditDefaultsOnly, Category = "Input") TObjectPtr<UInputMappingContext> GameplayContext;
|
|
UPROPERTY(VisibleAnywhere) TObjectPtr<UCameraModeComponent> CameraMode;
|
|
UPROPERTY(VisibleAnywhere) TObjectPtr<UInteractionComponent> Interaction; // Interaction.md
|
|
UPROPERTY(VisibleAnywhere) TObjectPtr<UCarryComponent> Carry; // Interaction.md
|
|
|
|
// Replicated so remote players can see where this one is looking. RemoteViewPitch is the engine's; yaw is ours.
|
|
UPROPERTY(Replicated) uint8 RemoteHeadYaw; // HeadYaw compressed to a byte, written by the owner every tick
|
|
|
|
virtual void SetupPlayerInputComponent(UInputComponent* Input) override; // binds IA_* to the handlers below
|
|
void OnMove(const FInputActionValue& V); // AddMovementInput relative to body (FP) or camera (TP)
|
|
void OnLook(const FInputActionValue& V); // feeds CameraMode->AddLookInput
|
|
void OnJumpPressed(); void OnJumpReleased(); // Jump() plus the buffer
|
|
void OnSprint(const FInputActionValue& V); // Movement->SetWantsToSprint
|
|
void OnCameraToggle(); // CameraMode->CycleMode()
|
|
// Attack, Interact, Drop and Ability1..4 forward to Combat and Interaction; they decide nothing here.
|
|
|
|
virtual void Landed(const FHitResult& Hit) override; // hard-landing recovery, fall damage event, telemetry
|
|
};
|
|
```
|
|
|
|
`ABaseCharacter` sets `UExtendedCharacterMovement` as the movement class (so an enemy is slowed by the same mud a
|
|
player is), sets the capsule to the mannequin's 42 cm radius and 96 cm half-height, implements
|
|
`IAbilitySystemInterface` (returning the player state's component for players, its own for enemies) and
|
|
`IGenericTeamAgentInterface`, and carries the `USkeletalMeshComponent` and animation blueprint reference. It has no
|
|
input.
|
|
|
|
## The look model
|
|
|
|
```
|
|
[SALVAGED] The head turns freely inside a yaw deadzone; past it, the body eases around to follow. Inside the
|
|
deadzone the body does not move at all, and that "nothing" is the whole feel: you can glance at a teammate or the
|
|
thing on the bench beside you without stepping out of position. The deadzone widens while carrying something big,
|
|
so you can peek round your own load.
|
|
```
|
|
|
|
```cpp
|
|
// Source/<Project>Core/Movement/LookModel.h // pure, no UObject, tested
|
|
struct FLookModelParams
|
|
{
|
|
float YawDeadzone = 45.f; // degrees either side before the body turns
|
|
float YawDeadzoneCarry = 70.f; // while carrying a two-handed object
|
|
float MaxTurnRate = 540.f; // degrees per second the body may turn to catch up
|
|
float MovingDeadzoneScale = 0.35f; // smaller while walking: you face where you go
|
|
float ReCenterRate = 90.f; // degrees per second the head drifts forward while moving
|
|
float PitchMin = -80.f, PitchMax = 70.f;
|
|
float SpinePitchShare = 0.5f; // how much of the pitch bends the spine, for the visible body
|
|
};
|
|
|
|
struct FLookModelState { float HeadYaw = 0.f; float BodyYaw = 0.f; float Pitch = 0.f; };
|
|
|
|
struct FLookModelInput { FVector2D LookDelta; bool bMoving; bool bStrafing; bool bCarryingTwoHanded; float DeltaSeconds; };
|
|
|
|
namespace LookModel
|
|
{
|
|
// pure. Returns the new state; the caller writes BodyYaw to the actor and BodyYaw + HeadYaw to the camera.
|
|
FLookModelState Tick(const FLookModelState& State, const FLookModelParams& Params, const FLookModelInput& In);
|
|
// HeadYaw += LookDelta.X
|
|
// deadzone = (carrying ? YawDeadzoneCarry : YawDeadzone) * (moving ? MovingDeadzoneScale : 1)
|
|
// if |HeadYaw| > deadzone: turn = min(|HeadYaw| - deadzone, MaxTurnRate * dt) * sign; BodyYaw += turn; HeadYaw -= turn
|
|
// if moving && !strafing: HeadYaw = MoveTowards(HeadYaw, 0, ReCenterRate * dt)
|
|
// Pitch = clamp(Pitch + LookDelta.Y, PitchMin, PitchMax)
|
|
}
|
|
```
|
|
|
|
How it meets the engine: `bUseControllerRotationYaw` is off. In **first person** the character's actor yaw is
|
|
`BodyYaw`, the camera yaw is `BodyYaw + HeadYaw`, and movement input is relative to the body, so you walk where you
|
|
face and look elsewhere. In **third person** the camera is free (spring arm on control rotation),
|
|
`bOrientRotationToMovement` is on so the body faces where it walks, and the model drives only the head and spine aim
|
|
in the animation blueprint so teammates can see where you are looking. One implementation, both modes; `Pitch`
|
|
reaches the rig through the engine's replicated `RemoteViewPitch`, `HeadYaw` through `RemoteHeadYaw`.
|
|
|
|
## Camera modes
|
|
|
|
```cpp
|
|
UCLASS(BlueprintType)
|
|
class UCameraModeDefinition : public UPrimaryDataAsset
|
|
{
|
|
GENERATED_BODY()
|
|
public:
|
|
UPROPERTY(EditDefaultsOnly) FGameplayTag ModeTag; // Camera.Mode.FirstPerson / .ThirdPerson
|
|
UPROPERTY(EditDefaultsOnly) float FieldOfView = 90.f;
|
|
UPROPERTY(EditDefaultsOnly) bool bFirstPerson = false;
|
|
// third person
|
|
UPROPERTY(EditDefaultsOnly) float ArmLength = 300.f;
|
|
UPROPERTY(EditDefaultsOnly) FVector SocketOffset = FVector(0, 60, 40); // over the right shoulder
|
|
// first person
|
|
UPROPERTY(EditDefaultsOnly) FName HeadSocket = TEXT("head");
|
|
UPROPERTY(EditDefaultsOnly) FVector EyeOffset = FVector(10, 0, 0);
|
|
UPROPERTY(EditDefaultsOnly) float HeadStabilization = 20.f; // damping stiffness; 0 = rigidly on the bone (do not ship that)
|
|
UPROPERTY(EditDefaultsOnly) bool bHideLocalHead = true;
|
|
UPROPERTY(EditDefaultsOnly) FLookModelParams Look;
|
|
UPROPERTY(EditDefaultsOnly) float BlendSeconds = 0.25f;
|
|
};
|
|
|
|
/** Owns the camera and spring arm, blends between definitions, runs the look model. Local player only. */
|
|
UCLASS()
|
|
class UCameraModeComponent : public UActorComponent
|
|
{
|
|
GENERATED_BODY()
|
|
public:
|
|
void SetMode(FGameplayTag ModeTag); void CycleMode();
|
|
void AddLookInput(FVector2D Delta);
|
|
FRotator GetCameraRotation() const; // BodyYaw + HeadYaw, Pitch
|
|
// TickComponent: run LookModel::Tick, write BodyYaw to the owner, position the camera:
|
|
// FP: camera location = damped follow of Mesh->GetSocketLocation(HeadSocket) + EyeOffset, in LateUpdate order
|
|
// (tick group PostUpdateWork) so it runs after animation; rotation from the look model, NEVER from the bone.
|
|
// Mesh->HideBoneByName(head) on the locally controlled pawn only; other clients see the whole body.
|
|
// TP: spring arm with collision test on, length and socket offset from the definition, camera on control rotation.
|
|
protected:
|
|
UPROPERTY(EditDefaultsOnly) TArray<TObjectPtr<UCameraModeDefinition>> Modes;
|
|
UPROPERTY(VisibleAnywhere) TObjectPtr<USpringArmComponent> SpringArm;
|
|
UPROPERTY(VisibleAnywhere) TObjectPtr<UCameraComponent> Camera;
|
|
FLookModelState Look;
|
|
};
|
|
```
|
|
|
|
The stabilised head socket is the make-or-break piece. Rigid parenting to the bone turns every walk-cycle bob into
|
|
camera shake, which the earlier project found out the hard way and fixed twice. The camera follows the socket's
|
|
position through a damped spring and takes its rotation from the look model only. An optional procedural bob (off
|
|
by default, scaled by the motion accessibility setting) is the only bob there is.
|
|
|
|
Comfort settings are launch requirements for a first-person mode, not options: stabilisation strength, FOV per
|
|
mode, bob toggle, and the motion scale that also governs camera kicks in combat.
|
|
|
|
## Networking
|
|
|
|
| State | Authority | Mechanism |
|
|
| --- | --- | --- |
|
|
| Position, velocity, movement mode | Server, client predicts | Character movement's own prediction and correction |
|
|
| Sprint, crouch | Server, client predicts | Compressed flags in the saved move |
|
|
| Jump | Server, client predicts | Engine jump plus the buffered press, resolved in the predicted move |
|
|
| Look pitch | Owner writes | `RemoteViewPitch`, engine built-in, for the rig only |
|
|
| Head yaw | Owner writes | `RemoteHeadYaw` byte, for the rig only |
|
|
| Camera mode, FOV, stabilisation | Local only | Never replicated |
|
|
| `MoveSpeed`, `JumpPower` | Server | Attributes on the stat block, replicated; a server-applied change costs one small correction on the owner, see [Stats.md](Stats.md) |
|
|
|
|
Remote bodies interpolate through the movement component's network smoothing (exponential). Test every step of this
|
|
document with the editor's network emulation profile set to 100 ms and 5 % loss, and `p.NetShowCorrections 1` on;
|
|
a correction you can see at those settings is a bug in the saved move.
|
|
|
|
## The gym
|
|
|
|
`L_Gym` is a level made only of movement problems, greyboxed from engine primitives with a material per problem
|
|
kind. It is the level every movement step is proved in, and it stays in the project forever as the regression
|
|
test for the controller.
|
|
|
|
| Section | What it holds |
|
|
| --- | --- |
|
|
| Stairs | Risers of 15, 20, 30 and 45 cm, straight and spiral |
|
|
| Slopes | 15, 30, 45 and 60 degrees, up and down; the last must not be walkable |
|
|
| Gaps | 150, 200, 250 and 300 cm, flat; 250 makeable at sprint only |
|
|
| Ledges | Drops of 100, 200, 300 and 500 cm onto flat ground |
|
|
| Doorways | 110 by 220 cm, and a 90 by 200 cm one that a crouch fits |
|
|
| Beams | 30 cm wide walkways over a drop |
|
|
| Corridor | A 40 m straight for speed and stop-distance measurement, marked every 5 m |
|
|
| Surfaces | Patches of mud and ice on the corridor's second half, on tagged physical materials; inert until step 5 |
|
|
| Arena | An open 30 by 30 m circle for the combat steps later |
|
|
|
|
## The feel checklist
|
|
|
|
Step 4 closes when a person at the keyboard ticks every line, in both camera modes, and the numbers that made it
|
|
pass are committed in `DA_Tuning_Player`.
|
|
|
|
- Stairs of every riser at walk and sprint: no camera stutter, no snag, no bounce at the top.
|
|
- Slopes: walkable to 45 degrees, slides off 60; speed on a 30 degree climb reads as effort, not a wall.
|
|
- Gaps: 150 and 200 at walk, 250 at sprint, 300 never. The 250 is the one that teaches sprint.
|
|
- Coyote time: stepping off a ledge and pressing jump within a tenth of a second still jumps. Buffer: pressing jump
|
|
just before landing jumps on landing.
|
|
- Landing from 100 and 200 cm: nothing. From 300: a visible knee-bend and a fifth of a second of no input. From 500:
|
|
fall damage (once Combat exists) and the same recovery.
|
|
- Stop distance from sprint under 150 cm; from walk under 60 cm. Turning at sprint has a radius, not a pivot.
|
|
- Air control is enough to correct a jump onto the beam, not enough to reverse mid-air.
|
|
- Doorways: the wide one at sprint without touching, the narrow one only crouched.
|
|
- First person: a five-minute walk of the whole gym without discomfort with stabilisation at its default. Looking
|
|
down shows your own feet. The deadzone lets you glance at a wall sign without turning.
|
|
- Third person: the spring arm never clips through a wall; the body faces where it walks; the head turns to follow
|
|
the look.
|
|
- Two clients and a dedicated server in editor, emulation at 100 ms and 5 %: the remote body is smooth on stairs,
|
|
slopes and jumps, and no correction snap is visible on the local one.
|
|
|
|
## Hooks for other systems
|
|
|
|
- **`MoveSpeed` and `JumpPower`** on the stat block ([Stats.md](Stats.md)) are the only way anything slows or
|
|
speeds a body: carrying, being downed, mud, gear and every buff or debuff are effects on those two attributes,
|
|
and the movement component multiplies. It never holds a multiplier of its own.
|
|
- **Root motion sources** are how abilities move a body. Dodge is a `FRootMotionSource_ConstantForce` over its
|
|
duration with i-frames granted as a `State.Invulnerable` tag; blink is a `MoveToForce` along the aim, flattened;
|
|
the shoulder charge is a constant force with a hit window. All three predict through the movement component's
|
|
existing root-motion prediction and are specified in [Combat.md](Combat.md).
|
|
- **Downed** sets the crouch capsule, overrides `MoveSpeed` to zero through `GE_Downed` and disables jump; the
|
|
camera drops to the downed eye height through the camera component, which reads the `State.Downed` tag.
|
|
- **Impulses** (a shove, a heal-launch) call `LaunchCharacter`, which the movement component already replicates.
|
|
|
|
## Telemetry
|
|
|
|
| Event | When | Payload |
|
|
| --- | --- | --- |
|
|
| `movement_sample` | Every 5 s while moving | `speed`, `mode`, `camera_mode`, `sprinting` |
|
|
| `jump` | A jump begins | `coyote` (bool), `buffered` (bool) |
|
|
| `land` | `Landed` | `fall_height`, `hard` (bool) |
|
|
| `camera_mode_changed` | Mode swap | `from`, `to` |
|
|
| `settings_changed` | A comfort or binding setting is committed | `setting_id`, `value` |
|
|
|
|
`movement_sample` is what later says which camera mode people actually live in and how much of the gym's speed
|
|
range is used, which is what decides Q1 and the sprint tuning with data instead of taste.
|
|
|
|
## Tests
|
|
|
|
- `LookModel` automation: inside the deadzone the body yaw does not change; an overshoot turns the body by the
|
|
overshoot and never faster than `MaxTurnRate`; moving without strafing recenters the head; carrying widens the
|
|
deadzone; pitch clamps. Five tests, no world.
|
|
- `UMovementTuning::IsDataValid` refuses sprint slower than walk, negative times, and angles outside (0, 90).
|
|
- Functional: `FT_Gym_Stairs` drives an `APlayerCharacter` up each riser with `AddMovementInput` and asserts it
|
|
reaches the top; `FT_Gym_Slopes` asserts the 60 degree slope is not climbable; `FT_Gym_Gaps` asserts the 300 cm
|
|
gap is not crossable at sprint.
|
|
|
|
## Open questions
|
|
|
|
- **Q1. Which camera mode is the default?** Both earlier projects chose first person: it is where close manual
|
|
work reads best and it is cheapest to make feel good with one rig. The case for third person is seeing your own
|
|
crafted gear and a better read of a crowded fight. Build both in step 3, play the gym and the first fight in each,
|
|
and let `camera_mode_changed` and `movement_sample` settle it by step 8.
|
|
- **Q2. The Mover plugin.** Revisit when it leaves experimental. The port cost is bounded by keeping the custom
|
|
surface to the saved-move flag, the tuning asset and root-motion sources.
|
|
- **Q3. Does sprint cost stamina?** Not in step 3. If combat wants a stamina attribute, sprint may draw from it
|
|
through the ability system's attribute, but the flag itself stays in the movement component.
|
|
- **Q4. The Shift conflict.** The earlier layout put the third ability slot on Shift because that game had no sprint.
|
|
This one does. Decided for now: Shift sprints, the slots are Q, E, R, C. Revisit if the fourth slot is unreachable
|
|
in a fight.
|
|
- **Q5. Head bob.** Off by default, one amplitude setting, scaled by the motion setting. Whether it earns a place
|
|
in first person is a playtest question after step 4.
|
|
- **Q6. Mantling and vaulting.** Not now. The gym has no ledge you are meant to climb. If the world later wants it,
|
|
it is a movement ability on root motion, not a change to the component.
|