Add comprehensive design and specification documentation (#1)
* 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
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
# Interaction, carrying and throwing
|
||||
|
||||
Owns the one system through which a player acts on the world: what can be touched, how it is targeted, what the
|
||||
prompt says, how the request reaches the server, and what happens to things a player picks up. It does **not** own
|
||||
any prop's behaviour: a bench belongs to [Crafting.md](Crafting.md), a downed teammate to [Combat.md](Combat.md).
|
||||
This document says how a player acts on a thing; the feature docs say what the thing does.
|
||||
|
||||
Read [Architecture.md](Architecture.md) first. Built in step 6, before combat, because reviving a teammate and
|
||||
inserting a piece into a bench are the same act on different props and neither should invent its own mechanism.
|
||||
|
||||
## One system
|
||||
|
||||
Before the earlier projects had this, their design docs described four implicit interaction models: a raycast
|
||||
"use", a proximity push, a trigger volume you stood on, and an undefined "confirm". In a game where the world is
|
||||
the interface that becomes a dozen mechanisms that each feel slightly different. One system instead, and one
|
||||
interface, which is justified by the count: the two source projects between them named a bench, a station, a
|
||||
container, a ticket, a contract card, a counter, a locker, a plinth, a lever, a cart's handles, a cart being righted,
|
||||
and a downed player. Twelve implementations is not speculative abstraction.
|
||||
|
||||
**The boundary that keeps it one system:** an interaction is a **discrete act on a fixed prop**. Carrying is
|
||||
**continuous possession of a moving object**. Pushing is continuous force and is neither. Picking something up
|
||||
therefore goes through the carry component, not the interaction service, even though both are on the same key;
|
||||
the interaction component routes the press.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
Source/<Project>/Interaction/
|
||||
├── Interactable.h // IInteractable, the UInterface every prop implements (on an actor or a component)
|
||||
├── InteractionTypes.h // FInteractionPrompt, FInteractionQuery, the reason tags
|
||||
├── InteractionSubsystem.h // UWorldSubsystem: registry, server-side validation, the one dispatch path
|
||||
├── InteractionComponent.h // on the player: targeting, prompt, the request RPC, hold-to-confirm
|
||||
├── InteractionHighlightComponent.h // on a prop: custom depth outline, in-range and targeted states
|
||||
├── Carryable.h // ICarryable and UCarryableComponent: what can be held, and by whom
|
||||
└── CarryComponent.h // on the player: hands, pick up, drop, throw, hand over
|
||||
|
||||
Content/Interaction/
|
||||
├── M_Outline_PP // the post-process outline material reading custom stencil
|
||||
└── WBP_InteractionPrompt // one prompt widget, see UI.md
|
||||
```
|
||||
|
||||
Trace channel `Interactable` is `ECC_GameTraceChannel1`, declared in `DefaultEngine.ini`; every interactable's
|
||||
collider responds to it and nothing else does. Carryables additionally sit on `Carryable` (`ECC_GameTraceChannel2`).
|
||||
|
||||
## Types at a glance
|
||||
|
||||
| Type | Lifetime | Notes |
|
||||
| --- | --- | --- |
|
||||
| `IInteractable` | interface | Implemented by a prop actor, or by a component when one actor holds several props |
|
||||
| `FInteractionPrompt`, `FInteractionQuery` | value | The prompt is the output of one function, on both peers |
|
||||
| `UInteractionSubsystem` | world | Registry and the server-side funnel |
|
||||
| `UInteractionComponent` | per player body | Owner only; the only thing that sends the request |
|
||||
| `UInteractionHighlightComponent` | per prop | Presentation |
|
||||
| `ICarryable`, `UCarryableComponent` | per carryable actor | Replicated `CarriedBy` |
|
||||
| `UCarryComponent` | per player body | Two hands |
|
||||
|
||||
## The interactable contract
|
||||
|
||||
```cpp
|
||||
USTRUCT(BlueprintType)
|
||||
struct FInteractionQuery
|
||||
{
|
||||
GENERATED_BODY()
|
||||
TObjectPtr<AController> Instigator; // who is asking
|
||||
TObjectPtr<APawn> Body; // their body, for range and carried state
|
||||
FGameplayTagContainer BodyTags; // State.Carrying, State.Downed, ... from the ability system
|
||||
};
|
||||
|
||||
USTRUCT(BlueprintType)
|
||||
struct FInteractionPrompt
|
||||
{
|
||||
GENERATED_BODY()
|
||||
FGameplayTag Verb; // Interact.Verb.Open, .Take, .Insert, .Revive, .Pull ...; localised by the widget
|
||||
FText TargetName; // "Assembly bench", "Rainer" (a downed player's name is a name, not a key)
|
||||
bool bEnabled = true;
|
||||
FGameplayTag Reason; // Interact.Reason.HandsFull, .Occupied, .NotYours, .OutOfReach ... shown when disabled
|
||||
float HoldSeconds = 0.f; // > 0 for irreversible verbs: scrap, cancel, leave
|
||||
};
|
||||
|
||||
UINTERFACE(BlueprintType)
|
||||
class UInteractable : public UInterface { GENERATED_BODY() };
|
||||
|
||||
/**
|
||||
* Anything a player can touch. The prompt and the permission come from ONE function so what the prompt says is
|
||||
* possible and what the server allows can never disagree. Interact() runs on the authority only.
|
||||
*/
|
||||
class IInteractable
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
virtual FInteractionPrompt GetPrompt(const FInteractionQuery& Query) const = 0; // pure, called every frame locally
|
||||
virtual void Interact(const FInteractionQuery& Query) = 0; // SERVER ONLY, after re-validation
|
||||
virtual FVector GetInteractionLocation() const = 0; // what the server measures reach against
|
||||
virtual bool IsLocalOnly() const { return false; } // true: acts on this machine's own state, never forwarded
|
||||
virtual float GetPriorityBias() const { return 1.f; } // a person outranks the furniture they stand at
|
||||
virtual FGameplayTag GetPropTag() const = 0; // stable identity for telemetry: Prop.Bench, Prop.ReviveTarget
|
||||
};
|
||||
```
|
||||
|
||||
`IsLocalOnly` exists for props like a personal locker or a settings surface: things that only change this
|
||||
player's own local state. They run their `Interact` on the client without a round trip and are never sent to the
|
||||
server. Everything touching shared state stays forwarded. Without the flag, an earlier project had a guest's locker
|
||||
setting the host's class.
|
||||
|
||||
## The subsystem: one funnel
|
||||
|
||||
```cpp
|
||||
UCLASS()
|
||||
class UInteractionSubsystem : public UWorldSubsystem
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
// Reach. Tokens, not literals, so they are tuned in one place and the prompt and the server agree.
|
||||
static constexpr float ReachCm = 180.f;
|
||||
static constexpr float HighlightCm = 350.f;
|
||||
static constexpr float ReachToleranceCm = 50.f; // the client's frame and the server's are a tick apart
|
||||
|
||||
bool IsInReach(const FVector& BodyLocation, const IInteractable& Target, float Tolerance = 0.f) const;
|
||||
|
||||
/**
|
||||
* SERVER ONLY. The only path an interaction takes. Never trusts the client's claim that it was close enough
|
||||
* or that the prompt was enabled: re-checks reach with tolerance, re-runs GetPrompt for permission, then calls
|
||||
* Interact. Emits prop_interacted. One distance comparison is the difference between a convenience and an
|
||||
* exploit.
|
||||
*/
|
||||
bool ServerTryInteract(const FInteractionQuery& Query, TScriptInterface<IInteractable> Target);
|
||||
|
||||
// Registry, so the highlight sweep and later systems can ask "what is near" without a world search.
|
||||
void Register(TScriptInterface<IInteractable> Prop); void Unregister(TScriptInterface<IInteractable> Prop);
|
||||
void QueryNear(const FVector& Point, float Radius, TArray<TScriptInterface<IInteractable>>& Out) const;
|
||||
};
|
||||
```
|
||||
|
||||
Props register in `BeginPlay` on every peer and unregister in `EndPlay`. The RPC carries the prop as an object
|
||||
reference: map-placed actors and replicated actors both resolve on the server through their network identity, which
|
||||
is why there is no hand-rolled handle table. A prop that is neither placed nor replicated cannot be interacted with
|
||||
over the network, by construction, which is the correct failure.
|
||||
|
||||
## The player's component
|
||||
|
||||
```cpp
|
||||
/**
|
||||
* The targeting, the prompt, the press. Owner only. Finds what you are looking at, offers it, and on the press
|
||||
* either routes to the carry component (a carryable with free hands) or sends the request to the server.
|
||||
*/
|
||||
UCLASS()
|
||||
class UInteractionComponent : public UActorComponent
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
TScriptInterface<IInteractable> GetCurrentTarget() const;
|
||||
FInteractionPrompt GetCurrentPrompt() const; // the HUD reads this; it never computes its own
|
||||
void OnInteractPressed(); void OnInteractReleased(); // released matters for hold-to-confirm
|
||||
|
||||
protected:
|
||||
// TickComponent, owner only:
|
||||
// 1. Sweep a 15 cm sphere from the camera along its forward for ReachCm on the Interactable channel.
|
||||
// What you LOOK at wins; the earlier overlap-and-angle-score model is used only for the highlight set.
|
||||
// 2. If the hit resolves to an IInteractable (on the hit component first, then its owner) and it is in
|
||||
// reach per the subsystem, it is the target; GetPrompt(query) is the prompt. Otherwise no target, no prompt.
|
||||
// 3. Every 0.25 s, QueryNear(HighlightCm) drives the in-range outline set; the target gets the strong outline.
|
||||
// Press:
|
||||
// target is ICarryable and hands are free -> Carry->RequestPickUp(target) (not an interaction)
|
||||
// carrying and target accepts an insert -> ServerRequestInteract(target) (Insert is an interaction)
|
||||
// target.IsLocalOnly() -> target->Interact(query) locally
|
||||
// prompt.HoldSeconds > 0 -> start the hold; send on completion; release cancels silently
|
||||
// otherwise -> ServerRequestInteract(target)
|
||||
|
||||
UFUNCTION(Server, Reliable) void ServerRequestInteract(UObject* Target);
|
||||
// builds the query from the owning controller and calls the subsystem's ServerTryInteract.
|
||||
// The client sends the target and nothing else; the server reads the body's position itself.
|
||||
};
|
||||
```
|
||||
|
||||
Two players interacting with one prop in the same frame resolve host-side by arrival order: the second fails the
|
||||
re-run `GetPrompt` if the first changed the prop's state. Contested props that need a soft lock ("someone is using
|
||||
this", with a name) get it in their own `GetPrompt`; the system does not know what contention means.
|
||||
|
||||
## Carrying
|
||||
|
||||
```
|
||||
[SALVAGED] Two hands, and things you carry are real. Most objects take one hand; big ones take both and, in
|
||||
first person, sit in the middle of your view. Carrying something two-handed blocks Interact, Insert and attacking
|
||||
until you put it down. Drop is always available and instant. Nothing you carry is ever destroyed by dropping,
|
||||
throwing or bumping into a wall with it.
|
||||
```
|
||||
|
||||
```cpp
|
||||
UINTERFACE() class UCarryable : public UInterface { GENERATED_BODY() };
|
||||
class ICarryable
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
virtual UCarryableComponent* GetCarryable() = 0;
|
||||
};
|
||||
|
||||
/** On any actor that can be held. The actor is the object; this is its carry state. */
|
||||
UCLASS(meta = (BlueprintSpawnableComponent))
|
||||
class UCarryableComponent : public UActorComponent
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UPROPERTY(EditDefaultsOnly) bool bTwoHanded = false;
|
||||
UPROPERTY(EditDefaultsOnly) float CarryWeight = 1.f; // against the carrier's CarryCapacity, see Stats.md
|
||||
UPROPERTY(EditDefaultsOnly) bool bBlocksAttack = false; // one-handed loot does not; a crate does
|
||||
UPROPERTY(EditDefaultsOnly) FName AttachSocket = TEXT("carry_r"); // or carry_both
|
||||
|
||||
UPROPERTY(ReplicatedUsing = OnRep_CarriedBy) TObjectPtr<APlayerState> CarriedBy; // null on the ground
|
||||
bool IsCarried() const { return CarriedBy != nullptr; }
|
||||
|
||||
// Server: attach to the carrier's mesh socket, physics off, collision to Ignore, dormancy off.
|
||||
// Release: detach, physics on, collision back, an impulse if thrown, dormancy back on when at rest.
|
||||
// OnRep_CarriedBy does the same attach/detach on clients so the carried object rides the body without
|
||||
// per-frame movement replication.
|
||||
};
|
||||
|
||||
/** On the player. Two hands, the verbs, and the encumbrance effect that is the only way weight slows a body. */
|
||||
UCLASS()
|
||||
class UCarryComponent : public UActorComponent
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UPROPERTY(Replicated) TArray<TObjectPtr<UCarryableComponent>> Hands; // 0, 1 or 2 entries; two-handed fills both
|
||||
bool HasFreeHandsFor(const UCarryableComponent& Object) const;
|
||||
UCarryableComponent* GetHeld() const; // the one-handed object, or the two-handed one
|
||||
|
||||
void RequestPickUp(UCarryableComponent* Object); // owner -> ServerPickUp
|
||||
void RequestDrop(); // G tap
|
||||
void RequestThrow(float ChargeSeconds); // G held then released; the interaction component tracks the hold
|
||||
|
||||
UFUNCTION(Server, Reliable) void ServerPickUp(UCarryableComponent* Object);
|
||||
// Validate: in reach (subsystem, with tolerance), not carried by anyone, not State.Downed, hands free.
|
||||
// Object->AttachTo(this). ApplyStatus(GE_Encumbered, CarryMath::SpeedFactor(weight, CarryCapacity), infinite):
|
||||
// weight is an effect on MoveSpeed like mud or a slow (Stats.md). Grant State.Carrying(.TwoHanded).
|
||||
// Emit object_picked_up.
|
||||
UFUNCTION(Server, Reliable) void ServerDrop();
|
||||
// Always legal while carrying. Release at the hands with no velocity. Remove GE_Encumbered and the tags. Emit object_dropped.
|
||||
UFUNCTION(Server, Reliable) void ServerThrow(FVector_NetQuantizeNormal Aim, float ChargeSeconds);
|
||||
// Two-handed objects cannot be thrown (reason: TooHeavy). power = clamp(charge / MaxCharge) * ThrowForce(mass).
|
||||
// Release with impulse along Aim. It lands where it lands. Fragile things take a Mark (Crafting.md), never damage.
|
||||
// Emit object_thrown.
|
||||
UFUNCTION(Server, Reliable) void ServerHandOver(UCarryComponent* Other);
|
||||
// Both in reach, other has free hands. The co-op verb. Emit object_handed_over.
|
||||
|
||||
// A down drops what you hold (Combat.md calls RequestDrop on the server when State.Downed is granted).
|
||||
};
|
||||
```
|
||||
|
||||
The attach-not-simulate approach is the whole reason carrying feels instant: while held, the object is a child of
|
||||
the carrier's mesh and rides its predicted movement; there is no physics to predict and nothing to reconcile. The
|
||||
moment it leaves the hands it is a server-simulated physics body again, replicated with the engine's predictive
|
||||
interpolation mode, and a thrown object arriving a tick late on a remote screen reads as weight, not lag.
|
||||
|
||||
**Throwing is in, and it is allowed to be scrappy.** It is the cheapest physical comedy available and it serves
|
||||
coordination: tossing an ingot across a room beats walking it, at the cost of accuracy. Rules that keep it fun:
|
||||
objects never break from a throw; a thrown object that comes to rest is an ordinary floor object; thrown objects
|
||||
collide with the world but never knock something out of another player's hands; catching is not a mechanic, aim at
|
||||
the feet.
|
||||
|
||||
**Blocking is deliberate.** A two-handed object commits your hands and, in first person, your eyes. The deadzone
|
||||
widening while carrying ([Movement.md](Movement.md)) is the safety valve: you can peek round your load without
|
||||
turning your body. If playtests read the block as friction rather than weight, the valve is per-object
|
||||
(`bTwoHanded` is data) and never a global rule change.
|
||||
|
||||
## Highlighting
|
||||
|
||||
Custom depth with stencil. `UInteractionHighlightComponent` sets `bRenderCustomDepth` and a stencil value on the
|
||||
prop's primitives: 1 for in-range, 2 for targeted, 0 otherwise. One post-process material in the player camera's
|
||||
volume draws the outline from the stencil, reading its two colours from the theme ([UI.md](UI.md)). The prompt says
|
||||
*what*; the outline says *which*, so two ingots lying on each other show which one the press takes. No prop draws
|
||||
its own outline and no prop chooses a colour.
|
||||
|
||||
## Networking
|
||||
|
||||
| State | Authority | Mechanism |
|
||||
| --- | --- | --- |
|
||||
| Targeting and the prompt | Client, owner | Local every frame; cosmetic; never replicated |
|
||||
| The request | Client asks, server decides | `ServerRequestInteract` on the player's own component |
|
||||
| Reach and permission | Server | Re-checked with tolerance; the client's claim is never trusted |
|
||||
| The prop's effect | Server | Whatever the owning feature does; replicated by that feature |
|
||||
| Local-only props | Client | Never sent |
|
||||
| Held objects | Server | `CarriedBy` replicated; attachment mirrored in `OnRep` |
|
||||
| Thrown and dropped objects | Server | Physics simulated on the server, replicated movement |
|
||||
|
||||
## Telemetry
|
||||
|
||||
| Event | When | Payload |
|
||||
| --- | --- | --- |
|
||||
| `prop_interacted` | A successful interaction | `prop`, `verb`, `held_seconds`, `player_class` |
|
||||
| `interaction_refused` | The server refused a request | `prop`, `reason` |
|
||||
| `object_picked_up` | Carry begins | `object`, `two_handed`, `weight` |
|
||||
| `object_dropped` | Drop or forced drop | `object`, `forced` |
|
||||
| `object_thrown` | Throw | `object`, `charge`, `distance_landed` |
|
||||
| `object_handed_over` | Hand over | `object` |
|
||||
|
||||
`prop_interacted` earns its place because a world-as-interface game has a failure mode a menu game does not: a
|
||||
player who cannot find the bench has nothing to fall back on. If a prop exists and nobody in a session ever
|
||||
touches it, it is not discoverable, and this event is the only way to learn that before someone says so.
|
||||
`interaction_refused` by reason is the cheapest possible measure of whether prompts explain themselves.
|
||||
|
||||
## Tests
|
||||
|
||||
- Automation: `IsInReach` with and without tolerance; the prompt of a test prop is disabled with `HandsFull` when
|
||||
the query carries `State.Carrying.TwoHanded`.
|
||||
- Functional: two clients and a server; client A picks up a crate, client B sees it on A's body; A throws it, both
|
||||
see it land within tolerance of the server's resting place; a request from beyond reach plus tolerance is
|
||||
refused and `interaction_refused` carries `OutOfReach`.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Q1. Contested props.** Arrival order is the rule. Whether a bench or a station wants a visible soft lock with a
|
||||
name is each prop's call in its own `GetPrompt`; the first prop that needs one sets the pattern.
|
||||
- **Q2. A greyed prompt or no prompt for an unusable prop.** Greyed, with the reason: more discoverable and more
|
||||
honest. Revisit if prompts clutter a crowded room.
|
||||
- **Q3. Hold-to-interact as an accessibility option.** The hold mechanism exists for irreversible verbs; making it
|
||||
optional for every verb is a settings toggle over the same code. With the settings pass, not before.
|
||||
- **Q4. Which objects are two-handed.** Data per object. The first crate, the first log and the first assembled
|
||||
greatsword decide the initial list; the earlier projects chose crates, logs, ore chunks and packed stations.
|
||||
- **Q5. A satchel.** The earlier smithing design added purchasable pocket slots later in progression. Not now;
|
||||
parked in [`../Ideas.md`](../Ideas.md). Hands only until something needs more.
|
||||
Reference in New Issue
Block a user