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,248 @@
|
||||
# Architecture
|
||||
|
||||
How the project is put together in Unreal Engine 5: modules, lifetimes, authority, content, the C++ and Blueprint
|
||||
boundary, testing and the conventions that follow from all of it. Read this before writing gameplay code; nearly
|
||||
every wiring decision in the other specs follows from something here.
|
||||
|
||||
The two projects this grew out of solved the same problems in Unity with a dependency-injection container, assembly
|
||||
definitions and a hand-rolled tag type. Unreal has native answers to each: subsystems for lifetimes, modules for
|
||||
dependency direction, Gameplay Tags for vocabulary, the Gameplay Ability System for attributes and abilities, and
|
||||
server-authoritative replication built into the actor model. This document maps the old discipline onto those rather
|
||||
than reintroducing a container the engine does not need.
|
||||
|
||||
## Modules
|
||||
|
||||
Two runtime modules from step 1. Modules are Unreal's real dependency boundary, the equivalent of the old assembly
|
||||
definitions, and the direction between them is the only architectural rule that is enforced by the build.
|
||||
|
||||
```
|
||||
Source/
|
||||
├── <Project>Core/ rules, data types, tags, the telemetry contract. Knows no AActor, no UWorld.
|
||||
│ ├── Crafting/ pure crafting rules and the definition asset classes
|
||||
│ ├── Combat/ damage maths, threat table, weapon profile
|
||||
│ ├── Movement/ the look model, tuning asset validation
|
||||
│ ├── Stats/ CarryMath and the resistance formula: the pure parts of the stat block
|
||||
│ ├── Tags/ native gameplay tag declarations
|
||||
│ ├── Telemetry/ ITelemetrySink, the event struct, the event name constants
|
||||
│ └── Tests/ automation tests for everything above
|
||||
└── <Project>/ gameplay. Depends on Core. Actors, components, subsystems, abilities, UI.
|
||||
├── Core/ GameMode, GameState, PlayerState, PlayerController, GameInstance, subsystems
|
||||
├── Stats/ the stat block attribute set, the effect helper, effect volumes, surface materials
|
||||
├── Movement/
|
||||
├── Interaction/
|
||||
├── Combat/
|
||||
├── Crafting/
|
||||
├── UI/
|
||||
└── Tests/ functional and integration tests that need a world
|
||||
```
|
||||
|
||||
`<Project>Core` depends on `Core`, `CoreUObject`, `Engine` (for `UPrimaryDataAsset` and `FGameplayTag`),
|
||||
`GameplayTags` and `GameplayAbilities` (for `FGameplayAttribute` in the damage maths). It does **not** depend on the
|
||||
gameplay module, and the gameplay module never has a type the core module needs. If the core module seems to need an
|
||||
actor, the thing it needs is data and belongs in a struct the actor fills in.
|
||||
|
||||
An editor module (`<Project>Editor`) arrives when the first editor tool does, never before. A third runtime module
|
||||
appears when a feature is stable enough to be built on its own; splitting is cheap in Unreal (a folder and a
|
||||
`Build.cs`), so the default is one gameplay module with a folder per feature.
|
||||
|
||||
**The rule between feature folders:** a feature may use another feature's data types, tags, pure rules and
|
||||
subsystem API. It may never reach into another feature's actors or components by class. Combat asks the crafting
|
||||
subsystem for a weapon profile; it does not `Cast<>` a bench.
|
||||
|
||||
## Lifetimes
|
||||
|
||||
Unreal already has the three lifetimes the old projects modelled with container scopes.
|
||||
|
||||
| Lives for | Unreal type | Examples here |
|
||||
| --- | --- | --- |
|
||||
| The whole application | `UGameInstanceSubsystem` | Telemetry, the persistence provider, user settings, the content catalogue |
|
||||
| One world (a map, on the server or a client) | `UWorldSubsystem` | Interaction registry, crafting service, activity runtime, encounter spawner |
|
||||
| One player, across pawn deaths and map travel | `APlayerState` (replicated) | Ability system component, attributes, class, the player's persistent identity |
|
||||
| One body | `APawn` / `ACharacter` | Movement, camera, mesh, the interaction and carry components |
|
||||
| A single local player's machine | `ULocalPlayerSubsystem` | Input mode, prompt glyphs, HUD state |
|
||||
|
||||
Two consequences worth stating:
|
||||
|
||||
- **The ability system component lives on the player state for players and on the pawn for enemies.** A player's
|
||||
attributes and cooldowns must survive their body dying and respawning; an enemy's die with it. This is the standard
|
||||
arrangement and every ability in [Combat.md](Combat.md) assumes it.
|
||||
- **Everything about a player that would matter outside this session is on the player state or behind the
|
||||
persistence provider, never on the character actor.** Class, progression, identity. A body is a thing the world
|
||||
lends the player for a while.
|
||||
|
||||
A world subsystem exists on the server and on every client. Authority-only work checks `GetWorld()->GetNetMode()`
|
||||
or lives in the `AGameModeBase` subclass, which only exists on the server. There is no other "is this the host"
|
||||
branch anywhere in gameplay code.
|
||||
|
||||
## Authority
|
||||
|
||||
**Server-authoritative, always.** The client sends intent (an input, a request), the server validates and mutates,
|
||||
replication carries the result back. The client predicts exactly two things: its own movement through the character
|
||||
movement component and the animation of its own abilities through the ability system. Nothing else is predicted,
|
||||
because everything else has a visible victim when the prediction is wrong.
|
||||
|
||||
This is not a choice made for a four-player game and it is not a choice that scales down. It is the one posture
|
||||
that stays correct as the world grows, and every actor, subsystem and RPC in this project is written as if the
|
||||
server were a separate process on another machine, because in the intended build it is.
|
||||
|
||||
[Networking.md](Networking.md) has the full authority table and the posture on tick rate, prediction and
|
||||
rollback. The rule that keeps it all playable: **no rollback of world state a player can already see.** If a
|
||||
design would need one, move the decision to the server and cover the round trip with animation.
|
||||
|
||||
## Content
|
||||
|
||||
All authored gameplay content is a `UPrimaryDataAsset` subclass, registered with the asset manager under a primary
|
||||
asset type per definition class, and addressed by primary asset id or gameplay tag. String ids do not exist.
|
||||
|
||||
```cpp
|
||||
// A definition asset. Designers fill these in; code reads them. Never the other way round.
|
||||
UCLASS(BlueprintType)
|
||||
class UWeaponDefinition : public UPrimaryDataAsset
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UPROPERTY(EditDefaultsOnly, Category = "Identity")
|
||||
FGameplayTag WeaponTag; // Item.Weapon.Sword, the stable identity
|
||||
|
||||
UPROPERTY(EditDefaultsOnly, Category = "Identity")
|
||||
FText DisplayName; // FText, never FString: localisable from day one
|
||||
|
||||
UPROPERTY(EditDefaultsOnly, Category = "Visual")
|
||||
TSoftObjectPtr<UStaticMesh> Mesh; // soft, so a definition never hard-loads its art
|
||||
|
||||
// GetPrimaryAssetId() uses the class name as the type; the asset manager scans Content/<Feature>/Definitions.
|
||||
};
|
||||
```
|
||||
|
||||
Rules that follow:
|
||||
|
||||
- **Soft references for art and other assets** (`TSoftObjectPtr`, `TSoftClassPtr`). A definition is small data and
|
||||
must be loadable on a headless server without dragging meshes in.
|
||||
- **Polymorphic inline content** (a list of effects, a spawn rule) uses `Instanced` `UObject` properties with
|
||||
`EditInlineNew` classes, or `FInstancedStruct` for value types. Both serialise the concrete type; both survive a
|
||||
rename only with a core redirect, so name such classes carefully and early.
|
||||
- **Tables for tuning numbers**, data assets for things with identity. A `UDataTable` of `FMovementTuningRow` is
|
||||
fine; an enemy is a data asset.
|
||||
- **Every visual defaults to an engine primitive or the engine mannequin**, so the first art pass is a content edit.
|
||||
|
||||
## Gameplay tags
|
||||
|
||||
Gameplay Tags are the project's vocabulary: hierarchical, parent-matching, cheap to compare, replicated as indices,
|
||||
native to the ability system. They replace every enum that would otherwise leak across features and every string
|
||||
that would otherwise be typed twice.
|
||||
|
||||
**Source of truth:** `Config/Tags/<Feature>.ini`, one file per top-level namespace, reviewed in pull requests like
|
||||
code. Tags that code references are additionally declared natively so a typo fails at compile time:
|
||||
|
||||
```cpp
|
||||
// <Project>Core/Tags/NativeTags.h
|
||||
UE_DECLARE_GAMEPLAY_TAG_EXTERN(TAG_State_Downed);
|
||||
UE_DECLARE_GAMEPLAY_TAG_EXTERN(TAG_Damage_Type_Physical);
|
||||
// NativeTags.cpp
|
||||
UE_DEFINE_GAMEPLAY_TAG_COMMENT(TAG_State_Downed, "State.Downed", "At zero health, immobile, revivable. Not dead.");
|
||||
```
|
||||
|
||||
The taxonomy, top level only. Each spec owns the children of its namespaces.
|
||||
|
||||
```
|
||||
Input.* Input.Move, Input.Jump, Input.Sprint, Input.Interact, Input.Drop, Input.Attack, Input.Ability.1..4
|
||||
Ability.* asset tags on abilities: Ability.Melee, Ability.Dodge, Ability.Taunt, Ability.Signature
|
||||
Cooldown.* granted by cooldown effects, one per ability
|
||||
Data.* SetByCaller keys: Data.Damage, Data.Heal, Data.Impulse
|
||||
Damage.Type.* Physical, Magic, True Damage.Source.* Melee, Projectile, Ability, Fall, Hazard, Shove
|
||||
State.* what a body IS: Downed, Dead, Invulnerable, Sprinting, Dodging, Carrying, Carrying.TwoHanded
|
||||
Status.* an outside influence on a body: Status.Buff.Haste, Status.Debuff.Slow, .Stunned ... (Stats.md)
|
||||
Immune.* Immune.Status.<Name>, Immune.Damage.<Type>: the counters a body carries
|
||||
Surface.* Mud, Ice, Hot: on physical materials, applied by the ground trace
|
||||
Event.* gameplay events between animation and abilities: Event.Montage.Hit, Event.Montage.WindupEnd
|
||||
Class.* Warrior, Ranger, Mage, Cleric, Rogue
|
||||
Item.* Item.Family.*, Item.Part.*, Item.PieceType.*, Item.Substance.*, Item.Enchant.*, Item.Weapon.*
|
||||
Piece.Layer.* Characteristic, Substance, Enchantment
|
||||
Substance.* Substance.Class.*, Substance.Rarity.*, Substance.State.*
|
||||
Theme.* synergy vocabulary shared by characteristics and enchantments
|
||||
Domain.* Forge, Wood, Enchant
|
||||
Station.* Station.State.*, Station.Type.* Activity.* Heat, Strike, Assemble Fault.* Mark.*
|
||||
Interact.* Interact.Reason.* (why a verb is unavailable), Interact.Verb.*
|
||||
GameplayCue.* hit impacts, hit stop, outlines: presentation only
|
||||
UI.* theme roles, see UI.md
|
||||
```
|
||||
|
||||
## The C++ and Blueprint boundary
|
||||
|
||||
C++ owns every rule, every state machine and every replicated property. Blueprint owns composition, tuning and
|
||||
cosmetics: which mesh, which montage, which numbers, what particle plays. The contract each C++ type declares:
|
||||
|
||||
| Surface | Meaning |
|
||||
| --- | --- |
|
||||
| `UFUNCTION(BlueprintCallable)` | a command a designer or UI may invoke; verb-noun names |
|
||||
| `UFUNCTION(BlueprintPure)` `Get*` / `Preview*` | side-effect-free query, safe every frame |
|
||||
| `UFUNCTION(BlueprintNativeEvent)` | a working C++ default a subclass may replace, for cosmetic reactions only |
|
||||
| `UPROPERTY(BlueprintAssignable)` delegate, `*_Cosmetic` | an Inspector-wired reaction; never load-bearing |
|
||||
| `UPROPERTY(EditDefaultsOnly)` | tuning data, authored not called |
|
||||
| `protected` / `private` C++ | authoritative internals; no Blueprint access |
|
||||
|
||||
A Blueprint may subclass a C++ actor to set its assets and numbers and to wire cosmetic events. A Blueprint never
|
||||
implements a rule, never writes a replicated property and never contains a `Server` RPC. Blueprints are binary and
|
||||
unreviewable in a diff, which is the practical reason for the line; the principled reason is that a rule in
|
||||
Blueprint cannot run in a headless test.
|
||||
|
||||
## Testing
|
||||
|
||||
Two kinds, and the split mirrors the module split.
|
||||
|
||||
- **Automation tests in `<Project>Core/Tests/`** for every pure rule, using `IMPLEMENT_SIMPLE_AUTOMATION_TEST` with
|
||||
the `ProductFilter` flag. They run with no world, in the editor's Session Frontend or headless:
|
||||
|
||||
```
|
||||
UnrealEditor-Cmd <Project>.uproject -ExecCmds="Automation RunTests <Project>.Core; Quit" -unattended -nopause -NullRHI -log
|
||||
```
|
||||
|
||||
Every rejection reason has a named test, not just the happy path. A test name is
|
||||
`<Project>.Core.<Feature>.<Rule>.<Case>` so a filter can run one feature.
|
||||
- **Functional tests in maps** (`AFunctionalTest` actors in `Content/Tests/`) for anything that needs a world: the
|
||||
gym's step-up heights, a swing landing on a dummy, two clients seeing the same assembled item. Run through the
|
||||
same command with the `Project.Functional` filter.
|
||||
|
||||
There is no continuous integration and there will not be until there is a reason for it. `Scripts/run-tests.sh`
|
||||
wraps the command above and is run by hand before a step is called done.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Naming follows Unreal:** `A` actors, `U` objects, `F` structs, `E` enums, `I` interfaces, no project prefix on
|
||||
classes. Assets: `BP_`, `DA_` (data asset), `DT_`, `IA_`, `IMC_`, `GA_`, `GE_`, `GC_` (gameplay cue), `ABP_`,
|
||||
`AM_` (montage), `SK_`, `SM_`, `M_`, `MI_`, `WBP_`, `T_`, `L_` (map). Placeholder assets carry `_Proto` before the
|
||||
descriptor so they can be purged in one search.
|
||||
- **Identity is `FGuid`** for anything that persists or is referenced across peers: an item, a piece, a substance
|
||||
object, an activity session. Never an index, never a name.
|
||||
- **No world searches in gameplay code.** `GetAllActorsOfClass`, `FindComponentByClass` on arbitrary actors, and
|
||||
static singletons holding gameplay state are all banned. A subsystem is the registry; actors register with it in
|
||||
`BeginPlay` and unregister in `EndPlay`.
|
||||
- **`TObjectPtr<T>` for `UPROPERTY` object references**, raw pointers only for function-local use.
|
||||
- **`FText` for anything a player reads**, from a string table. `FString` for identifiers and logs.
|
||||
- **Private members `Name`, not `_name`**: Unreal's own style, and the engine code the project reads uses it.
|
||||
- **One header, one class.** A struct shared by several may have its own header.
|
||||
- **Comments say why.** What the code does is visible; why it does it that way is the part that gets lost.
|
||||
|
||||
## Gotchas
|
||||
|
||||
Real ones, from the engine, that cost a day each if met in the wild rather than here.
|
||||
|
||||
- **Replication needs a registered property.** A `UPROPERTY(Replicated)` does nothing until it is listed in
|
||||
`GetLifetimeReplicatedProps`. There is no warning.
|
||||
- **Server RPCs need an owning connection.** `UFUNCTION(Server)` on an actor the client does not own silently
|
||||
drops. Components on the player's pawn or player state are owned; a bench is not, so interaction RPCs go through
|
||||
the player's own interaction component, never through the prop.
|
||||
- **The ability system component must be initialised on both sides.** `InitAbilityActorInfo` in `PossessedBy` on
|
||||
the server and in `OnRep_PlayerState` on the client, and `UAbilitySystemGlobals::Get().InitGlobalData()` once at
|
||||
startup or target data will not serialise.
|
||||
- **`BeginPlay` order between actors is not defined.** A component that needs another actor reads it on first use,
|
||||
not in `BeginPlay`.
|
||||
- **Blueprint child of a C++ class: `Super::` calls are on the Blueprint author.** A cosmetic override that forgets
|
||||
to call the parent event silently drops the C++ default.
|
||||
- **`CustomTimeDilation` on the server changes the simulation.** Hit stop is a client-side gameplay cue, never a
|
||||
server-side dilation.
|
||||
- **`FGameplayTag` matching is hierarchical by default.** `HasTag(State)` is true for `State.Downed`. Use `HasTagExact`
|
||||
when the parent must not match.
|
||||
- **Dedicated server packages need the engine from source.** The launcher build compiles no server target. Play In
|
||||
Editor's "Run Dedicated Server" works on the launcher build and is the everyday test; the source build arrives when
|
||||
the first packaged server does.
|
||||
@@ -0,0 +1,459 @@
|
||||
# Combat
|
||||
|
||||
Owns attributes, the one damage funnel, health and the downed state, melee hit detection and feel, enemies, the
|
||||
ability system, class kits, and the rules that keep gear and mods from dissolving the classes. It does **not** own
|
||||
how a body moves ([Movement.md](Movement.md), whose root-motion hooks the movement abilities use), how a player
|
||||
touches a downed teammate ([Interaction.md](Interaction.md)), or where a weapon's numbers come from when the weapon
|
||||
was crafted ([Crafting.md](Crafting.md), which produces the `FWeaponProfile` defined here).
|
||||
|
||||
Read [Architecture.md](Architecture.md) first. Steps 7 to 11 in [`../Steps.md`](../Steps.md).
|
||||
|
||||
## Decisions
|
||||
|
||||
```
|
||||
[DECIDED] The Gameplay Ability System. Attributes, abilities, effects, cooldowns, tags, cues.
|
||||
|
||||
The earlier project hand-built a damage resolver, a health service, a cooldown tracker, an ability runner and a
|
||||
client-to-host relay, then spent a step making them replicate. GAS is those five things, server-authoritative
|
||||
with client prediction, already replicated, and the engine's own combat samples are written on it. The cost is
|
||||
its learning curve, which is paid once, and its opinions, which happen to be this project's: one attribute set,
|
||||
effects as data, abilities as classes, everything addressed by tag.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] One damage funnel. Every point of damage in the game passes through one execution calculation.
|
||||
|
||||
Melee, projectiles, abilities, falling, hazards, a shove into a pit. One place, for three reasons: modifiers from
|
||||
gear and upgrades apply in one place, telemetry is emitted in one place, and the no-friendly-fire rule is enforced
|
||||
in one place rather than remembered at twenty call sites. No ability, weapon or hazard writes Health directly.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] Down before death. Zero health is downed: immobile, revivable, bleeding out. Death is what happens when
|
||||
nobody comes. Any player can revive; some kits do it faster. When no player is standing, everyone downed dies at
|
||||
once, because nobody can revive anybody.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] The ability system component is on the player state for players and on the pawn for enemies.
|
||||
A player's cooldowns, attributes and class survive their body; an enemy's die with it.
|
||||
```
|
||||
|
||||
```
|
||||
[SALVAGED] Kits define ability access. Gear defines stat and utility access, and crosses class lines. Mods bend
|
||||
numbers and change how an ability you have behaves, and never grant another class's signature ability. Without that
|
||||
line, free-form gear dissolves five classes into five slightly different damage dealers within one content patch.
|
||||
Gear and mods themselves are not built in these steps; the flag that guards the line is, because it is free now.
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
Source/<Project>Core/Combat/
|
||||
├── WeaponProfile.h // FWeaponProfile: the one shape both authored and crafted weapons produce
|
||||
├── DamageMath.h / .cpp // pure: ComputeDamage(context) -> FDamageResult
|
||||
└── ThreatTable.h / .cpp // pure: who an enemy wants to hit
|
||||
|
||||
Source/<Project>/Combat/ // the stat block and the ability system component live in Stats/, see Stats.md
|
||||
├── DamageExecution.h // UGameplayEffectExecutionCalculation: THE funnel
|
||||
├── CombatantComponent.h // per body: team, downed/dead state machine, revive target, hit reaction
|
||||
├── WeaponDefinition.h // UPrimaryDataAsset for authored weapons
|
||||
├── WeaponActor.h // the held weapon: mesh on the hand socket, its profile, its trace sockets
|
||||
├── Abilities/
|
||||
│ ├── KitAbility.h // UGameplayAbility base: InputTag, bSignature, activation policy
|
||||
│ ├── GA_MeleeAttack.h // montage, hit window, server sweep, apply damage
|
||||
│ ├── GA_Dodge.h GA_Blink.h GA_ShoulderCharge.h GA_Taunt.h GA_Mark.h GA_Heal.h GA_Downed.h GA_Revive.h
|
||||
│ └── GA_Fireball.h GA_Volley.h // step 11, projectiles
|
||||
├── ClassKitDefinition.h // UPrimaryDataAsset: abilities in slot order, starting weapon, base attributes
|
||||
├── Enemies/
|
||||
│ ├── EnemyDefinition.h // UPrimaryDataAsset: stats, montages, drops later
|
||||
│ ├── EnemyCharacter.h // ABaseCharacter with its own ASC
|
||||
│ ├── EnemyController.h // AAIController + StateTree + perception + the threat table
|
||||
│ └── EncounterSubsystem.h // UWorldSubsystem, server: spawns and counts, party-size scaling
|
||||
├── ProjectileActor.h // step 11
|
||||
└── Cues/ // GameplayCue notifies: impact, hit stop, camera kick, outline flash
|
||||
|
||||
Content/Combat/
|
||||
├── Definitions/DA_Weapon_Sword, DA_Weapon_Mace, DA_Weapon_Dagger, DA_Weapon_Staff, DA_Weapon_Bow
|
||||
├── Definitions/DA_Kit_Warrior, DA_Kit_Ranger, DA_Kit_Mage, DA_Kit_Cleric, DA_Kit_Rogue
|
||||
├── Definitions/DA_Enemy_Goblin
|
||||
├── Effects/GE_Damage, GE_Heal, GE_Cooldown_*, GE_BleedOut, GE_Invulnerable, GE_FallDamage
|
||||
├── Abilities/GA_* // Blueprint children setting montages and cues only
|
||||
├── Animations/AM_Melee_*, AM_Hit_* // montages with the hit-window notify states
|
||||
└── Cues/GC_*
|
||||
```
|
||||
|
||||
## Types at a glance
|
||||
|
||||
| Type | Module | Lifetime | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `FWeaponProfile` | Core | value | Authored or crafted, same struct |
|
||||
| `DamageMath::ComputeDamage` | Core | pure | The maths the execution calls |
|
||||
| `FThreatTable` | Core | value | Owned by an enemy controller |
|
||||
| `UStatBlockAttributeSet` | Gameplay (Stats) | per ASC | The stat block, see [Stats.md](Stats.md) |
|
||||
| `UDamageExecution` | Gameplay | asset-referenced | Referenced by `GE_Damage` |
|
||||
| `UCombatantComponent` | Gameplay | per body | Team, downed state, revive target, hit reaction |
|
||||
| `UWeaponDefinition`, `UClassKitDefinition`, `UEnemyDefinition` | Gameplay | asset | Content |
|
||||
| `AWeaponActor` | Gameplay | per equipped weapon | Attached to the hand socket |
|
||||
| `UKitAbility` and children | Gameplay | granted per ASC | Abilities |
|
||||
| `AEnemyCharacter`, `AEnemyController` | Gameplay | per enemy | Server-driven |
|
||||
| `UEncounterSubsystem` | Gameplay | world, server | Spawning and the wipe |
|
||||
|
||||
## Attributes
|
||||
|
||||
The numbers combat reads and writes are on the one stat block every body carries, `UStatBlockAttributeSet`,
|
||||
specified in [Stats.md](Stats.md): `Health` and `MaxHealth`, `AttackPower` and `AttackSpeed`, `Armour`,
|
||||
`MagicResist` and `KnockbackResist`, and the two meta attributes the funnel writes. Combat owns what they mean;
|
||||
it does not own the set, and it adds nothing to it without a row in that document's table.
|
||||
|
||||
```cpp
|
||||
// UStatBlockAttributeSet, the parts combat uses
|
||||
ATTRIBUTE_ACCESSORS(UStatBlockAttributeSet, IncomingDamage) // META: written by the execution, consumed below, never replicated
|
||||
ATTRIBUTE_ACCESSORS(UStatBlockAttributeSet, IncomingHeal) // META, same
|
||||
|
||||
virtual void PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) override;
|
||||
// IncomingDamage: Health = clamp(Health - Incoming, 0, Max); if it reached zero and was not zero, raise
|
||||
// OnOutOfHealth(instigator, causer, context). Emit player_damaged / enemy_damaged. Clear the meta attribute.
|
||||
// IncomingHeal: overheal past MaxHealth is returned to the caller as a value, because a heal that overflows
|
||||
// is a launch (the salvaged joke, see Abilities). Health clamps; the surplus goes out through OnOverheal.
|
||||
```
|
||||
|
||||
Base values are a `FStatBlockDefaults` on the kit or the enemy definition, applied through the one init effect
|
||||
on grant or spawn; nothing sets an attribute directly. A body's speed does not live in combat at all: it is
|
||||
`MoveSpeed` on the block, and a slow from a frost bolt changes it the same way mud does.
|
||||
|
||||
## The damage funnel
|
||||
|
||||
```cpp
|
||||
// Source/<Project>Core/Combat/DamageMath.h // pure
|
||||
struct FDamageContext
|
||||
{
|
||||
float BaseAmount; // from the weapon profile or the ability's SetByCaller Data.Damage
|
||||
FGameplayTag DamageType; // Damage.Type.Physical | Magic | True
|
||||
FGameplayTag Source; // Damage.Source.Melee | Projectile | Ability | Fall | Hazard | Shove
|
||||
float SourceAttackPower = 1.f; // captured attribute
|
||||
float TargetArmour = 0.f; // captured attribute, physical
|
||||
float TargetMagicResist = 0.f; // captured attribute, magic
|
||||
bool bSourceIsPlayer = false, bTargetIsPlayer = false;
|
||||
bool bTargetMarked = false; // Status.Debuff.Marked: everyone hits harder
|
||||
bool bTargetInvulnerable = false; // dodge i-frames, a shield
|
||||
FGameplayTagContainer TargetImmunities; // Immune.Damage.<Type> tags on the target: matching damage is zero
|
||||
FGameplayTagContainer GrantedTags; // from an enchanted weapon: Item.Enchant.Fire.Penetration ...
|
||||
};
|
||||
struct FDamageResult { float FinalAmount = 0.f; bool bDiscardedFriendlyFire = false; bool bCritical = false; };
|
||||
|
||||
namespace DamageMath
|
||||
{
|
||||
// pure. Tested for: the friendly-fire gate; True damage ignores armour and resist; a mark multiplies;
|
||||
// invulnerable is zero; an Immune.Damage.<Type> tag zeroes that type; fall damage is never friendly fire
|
||||
// even when a player caused the fall.
|
||||
FDamageResult ComputeDamage(const FDamageContext& Ctx, const struct FDamageTuning& Tuning);
|
||||
}
|
||||
```
|
||||
|
||||
```cpp
|
||||
/**
|
||||
* THE funnel. Referenced by GE_Damage and by nothing else; every damaging effect in the project is GE_Damage with
|
||||
* SetByCaller magnitudes, or a subclass of it. Runs on the server only, as executions do.
|
||||
*/
|
||||
UCLASS()
|
||||
class UDamageExecution : public UGameplayEffectExecutionCalculation
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UDamageExecution(); // captures AttackPower from the source; Armour, MagicResist and the State, Status and Immune tags from the target
|
||||
virtual void Execute_Implementation(const FGameplayEffectCustomExecutionParameters& Params,
|
||||
FGameplayEffectCustomExecutionOutput& Out) const override;
|
||||
// 1. Build FDamageContext from the spec: Data.Damage, the type and source tags on the spec, the captured
|
||||
// attributes, team membership of instigator and target (IGenericTeamAgentInterface), target state tags.
|
||||
// 2. THE FRIENDLY FIRE GATE. Both players: FinalAmount is zero. The effect still applies, so the impulse
|
||||
// and the cue survive. Player-on-player is impulse and ragdoll, never health loss. This is the
|
||||
// consequence-free griefing rule from the earlier project, enforced here so no ability can violate it.
|
||||
// 3. DamageMath::ComputeDamage. Modifiers from gear read the source's granted tags and attributes; nothing
|
||||
// is pushed into this class by another system, it reads.
|
||||
// 4. AddOutputModifier(IncomingDamage, Additive, FinalAmount).
|
||||
// 5. The attribute set's PostGameplayEffectExecute emits telemetry and raises OnOutOfHealth. This class
|
||||
// emits nothing itself: one emitting site per event.
|
||||
};
|
||||
```
|
||||
|
||||
Fall damage is a `GE_FallDamage` applied by the character's `Landed` through the same funnel with
|
||||
`Damage.Source.Fall` and no instigator; hazards are the same with `Hazard`. A shove is `GE_Damage` with zero base
|
||||
amount and an `Impulse` magnitude: the gate discards nothing because there is nothing, and the impulse cue lands.
|
||||
|
||||
## Health, down, revive, wipe
|
||||
|
||||
```cpp
|
||||
/** Per body. Turns attribute events into the downed/dead state and exposes the revive prop. */
|
||||
UCLASS()
|
||||
class UCombatantComponent : public UActorComponent, public IInteractable
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UPROPERTY(EditDefaultsOnly) FGenericTeamId Team; // players 1, enemies 2; the attitude solver says 1 vs 2 is hostile
|
||||
FGuid CombatantId; // stable runtime identity for the threat table and telemetry
|
||||
|
||||
// Server, on OnOutOfHealth:
|
||||
// Enemies: grant State.Dead, play the death montage through a cue, disable collision, the encounter counts it.
|
||||
// Players: ASC->TryActivateAbilityByClass(GA_Downed). GA_Downed grants State.Downed, applies GE_BleedOut
|
||||
// (30 s duration, on expiry -> Die), applies GE_Downed (MoveSpeed overridden to zero), forces a drop through the carry
|
||||
// component, drops the camera, and enables this component's revive collider on the Interactable channel.
|
||||
// Then: if no player in the world is standing, every downed player dies now (a solo down is an instant wipe;
|
||||
// bleed-out only matters with a party) and the encounter subsystem's wipe fires.
|
||||
// IInteractable, the revive prop:
|
||||
// GetPrompt: Interact.Verb.Revive, enabled when the target has State.Downed and the asker does not.
|
||||
// Interact (server): activate GA_Revive on the reviver, which holds for ReviveSeconds (faster for a Cleric),
|
||||
// then removes GE_BleedOut and restores a fraction of MaxHealth. Emit player_revived with down_duration_s.
|
||||
// Hit reaction: OnDamaged raises HitReaction_Cosmetic(direction); the animation blueprint bends the spine with
|
||||
// a damped spring in the direction of the hit, on top of whatever plays, and never moves the hands.
|
||||
};
|
||||
```
|
||||
|
||||
A downed player is the first prop in the game, and reviving gets the server-side range and permission re-check
|
||||
for free from [Interaction.md](Interaction.md). It is the reason interaction is built before combat.
|
||||
|
||||
## Weapons and the melee swing
|
||||
|
||||
```cpp
|
||||
// Source/<Project>Core/Combat/WeaponProfile.h
|
||||
/**
|
||||
* The one shape a weapon has at the moment of the swing. An authored UWeaponDefinition produces it directly; a
|
||||
* crafted item produces it through CraftingRules::MakeWeaponProfile. The melee ability, the action bar and the
|
||||
* damage funnel read this and nothing else, which is the seam that lets a crafted sword be a sword.
|
||||
*/
|
||||
struct FWeaponProfile
|
||||
{
|
||||
FGameplayTag WeaponTag; // Item.Weapon.Sword; a crafted item carries its family tag here
|
||||
float Damage = 12.f;
|
||||
float ReachCm = 180.f;
|
||||
float ArcHalfWidthCm = 60.f, ArcHalfHeightCm = 50.f;
|
||||
float WindupSeconds = 0.12f; // press to hit check: the tell
|
||||
float SwingSeconds = 0.55f; // press to next press
|
||||
float HitStopSeconds = 0.06f; // cosmetic, client side
|
||||
FGameplayTag DamageType; // Damage.Type.Physical by default
|
||||
FGameplayTagContainer GrantedTags; // an enchanted blade grants Item.Enchant.* here
|
||||
bool bTwoHanded = false; // a greatsword occupies both hands, see Interaction.md
|
||||
FSoftObjectPath Montage; // AM_Melee_*; a crafted weapon uses its family's montage
|
||||
};
|
||||
```
|
||||
|
||||
```cpp
|
||||
UCLASS()
|
||||
class UGA_MeleeAttack : public UKitAbility
|
||||
{
|
||||
GENERATED_BODY()
|
||||
// Net execution policy: LocalPredicted. The montage plays immediately on the owner; the server plays it too and
|
||||
// does the hit check itself. Activation: on Input.Attack, blocked by State.Downed, State.Carrying.TwoHanded,
|
||||
// State.Dodging, and by its own cooldown (SwingSeconds, a GE_Cooldown_Melee with SetByCaller duration).
|
||||
//
|
||||
// ActivateAbility:
|
||||
// Profile = Owner->GetWeaponActor()->GetProfile(); timings divided by the body's AttackSpeed attribute
|
||||
// PlayMontageAndWait(Profile.Montage, rate = AttackSpeed) // predicted
|
||||
// WaitGameplayEvent(Event.Montage.Hit) // fired by ANS_HitWindow on the montage, at WindupSeconds
|
||||
// on event, SERVER ONLY (HasAuthority):
|
||||
// sweep a box (ArcHalfWidth x ArcHalfHeight x Reach) from the eye along the body's aim on the Enemy channel
|
||||
// for each hit, once per swing: Linecast eye -> hit point on the world channel; a wall blocks it
|
||||
// for each surviving hit: MakeOutgoingSpec(GE_Damage), SetByCaller(Data.Damage, Profile.Damage),
|
||||
// add DamageType and Damage.Source.Melee to the spec, ApplyToTarget
|
||||
// execute cue GameplayCue.Melee.Impact at the hit point on every peer
|
||||
// EndAbility on montage end.
|
||||
//
|
||||
// Why a single box after the wind-up rather than a per-frame sweep along the blade: it is what the earlier
|
||||
// project shipped, it hits the whole pack, and it reads. A blade-socket sweep is a change to this one ability
|
||||
// if the feel pass wants it; nothing else would move.
|
||||
};
|
||||
```
|
||||
|
||||
Feel, all cosmetic and all client-side through gameplay cues: **hit stop** (a brief `CustomTimeDilation` dip on the
|
||||
local player and the struck enemy, never on the server), a **camera kick** through a camera shake on the owner's
|
||||
controller, and the **procedural hit reaction** on the struck body. Every duration passes through the motion
|
||||
accessibility scale, so reduced motion turns them down.
|
||||
|
||||
The wind-up is what makes the animation and the rules agree: the player's hit check is delayed past the press by
|
||||
`WindupSeconds`, and the goblin's damage lands after its own wind-up, missing if the target has stepped out of
|
||||
reach. The tell is real; reading it and stepping back works.
|
||||
|
||||
**Combat feel is the honest unknown of these steps.** No document settles whether a swing lands well. Step 7 is not
|
||||
done until one training dummy and then one goblin are satisfying to hit, and the numbers that made it so are
|
||||
committed.
|
||||
|
||||
## Enemies
|
||||
|
||||
```cpp
|
||||
UCLASS(BlueprintType)
|
||||
class UEnemyDefinition : public UPrimaryDataAsset
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag EnemyTag; // Enemy.Goblin
|
||||
UPROPERTY(EditDefaultsOnly) FStatBlockDefaults BaseStats; // 30 health, MoveSpeed 1: the same block a player has (Stats.md)
|
||||
UPROPERTY(EditDefaultsOnly) TObjectPtr<UMovementTuning> Tuning; // the goblin's 320 cm/s walk; the block multiplies it like anyone's
|
||||
UPROPERTY(EditDefaultsOnly) FWeaponProfile Attack; // 8 damage, 1.2 s, 0.35 s wind-up: the goblin's claws
|
||||
UPROPERTY(EditDefaultsOnly) float AggroRangeCm = 1000.f;
|
||||
UPROPERTY(EditDefaultsOnly) float LeashRangeCm = 2500.f;
|
||||
UPROPERTY(EditDefaultsOnly) float FleeSecondsOnAllyDeath = 2.5f; // reads as cowardice; funnier than fighting to the death
|
||||
UPROPERTY(EditDefaultsOnly) int32 ThreatCost = 1; // what it costs an encounter budget
|
||||
UPROPERTY(EditDefaultsOnly) bool bRequiresRole = false; // ineligible below three players, see scaling
|
||||
UPROPERTY(EditDefaultsOnly) TSoftClassPtr<AEnemyCharacter> Body;
|
||||
UPROPERTY(EditDefaultsOnly) TSoftObjectPtr<UStateTree> Brain;
|
||||
};
|
||||
```
|
||||
|
||||
```cpp
|
||||
// Source/<Project>Core/Combat/ThreatTable.h // pure
|
||||
struct FThreatTable
|
||||
{
|
||||
void AddThreat(FGuid Combatant, float Amount); // damage dealt to me, healing done near me
|
||||
void ForceTarget(FGuid Combatant, float Seconds); // a taunt
|
||||
void Decay(float DeltaSeconds);
|
||||
TOptional<FGuid> GetTarget() const; // forced target while it lasts, else highest threat
|
||||
void Remove(FGuid Combatant);
|
||||
};
|
||||
```
|
||||
|
||||
The controller is an `AAIController` running a StateTree from the definition, with a perception component for
|
||||
sight and the threat table for choice. States: Idle, Chase, Attack (wind-up, hit through the same melee ability
|
||||
class the player uses, on the enemy's own ASC), Flee (on an ally's death, for `FleeSecondsOnAllyDeath`), Leash
|
||||
(back to the spawn point past `LeashRangeCm`). Threat accrues from damage dealt and from healing done, which is why
|
||||
a healer needs a tank. The threat table is what makes a taunt mean something.
|
||||
|
||||
**Party-size scaling is a reserved rule, not a built one.** The encounter subsystem reads the party size from the
|
||||
game state and scales the enemy budget sublinearly: 1 player 1.0, 2 players 1.75, 3 players 2.4, 4 players 3.0.
|
||||
Linear scaling is the obvious choice and it is wrong: four coordinated players are worth far more than four times
|
||||
one. Enemies with `bRequiresRole` are ineligible below three players, which is what "playable alone" means
|
||||
concretely: a constraint on which enemies may appear, not a damage multiplier. Only the goblin exists, so the
|
||||
subsystem multiplies a count and filters a list; the table is written down so the first second enemy inherits it.
|
||||
|
||||
## Abilities and kits
|
||||
|
||||
```cpp
|
||||
UCLASS(Abstract)
|
||||
class UKitAbility : public UGameplayAbility
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UPROPERTY(EditDefaultsOnly, Category = "Kit") FGameplayTag InputTag; // Input.Ability.1..4, or Input.Attack, Input.Dodge
|
||||
UPROPERTY(EditDefaultsOnly, Category = "Kit") bool bSignature = false; // THE flag a mod may never cross
|
||||
UPROPERTY(EditDefaultsOnly, Category = "Kit") FText DisplayName; // the action bar's label
|
||||
// Cooldown and cost are the engine's: a GE_Cooldown_<Ability> with a Cooldown.<Ability> granted tag,
|
||||
// and an optional stamina cost effect. CommitAbility applies both. The HUD reads the cooldown tag's remaining time.
|
||||
};
|
||||
|
||||
USTRUCT()
|
||||
struct FKitAbilitySlot
|
||||
{
|
||||
GENERATED_BODY()
|
||||
UPROPERTY(EditDefaultsOnly) TSubclassOf<UKitAbility> Ability;
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag InputTag; // which slot; the ability's own InputTag is the default
|
||||
};
|
||||
|
||||
UCLASS(BlueprintType)
|
||||
class UClassKitDefinition : public UPrimaryDataAsset
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag ClassTag; // Class.Warrior
|
||||
UPROPERTY(EditDefaultsOnly) FText DisplayName;
|
||||
UPROPERTY(EditDefaultsOnly) TArray<FKitAbilitySlot> Abilities; // slot order; at most four on the bar
|
||||
UPROPERTY(EditDefaultsOnly) FStatBlockDefaults BaseStats; // applied through the one init effect (Stats.md)
|
||||
UPROPERTY(EditDefaultsOnly) TObjectPtr<UWeaponDefinition> StartingWeapon;
|
||||
UPROPERTY(EditDefaultsOnly) float ReviveSeconds = 4.f; // the Cleric's is 2
|
||||
// IsDataValid: at most one bSignature ability, at most four slots, distinct input tags.
|
||||
};
|
||||
```
|
||||
|
||||
`UExtendedAbilitySystemComponent` routes input by tag: on `Input.Ability.2` pressed it activates every granted
|
||||
ability whose asset tags contain that input tag. The player character forwards its `IA_Ability*` and `IA_Attack`
|
||||
presses there and decides nothing. Granting a kit is `GrantKit(const UClassKitDefinition&)` on the player state's
|
||||
component: `ApplyDefaults(BaseStats)`, give each ability with its input tag, equip the starting weapon. Changing
|
||||
class regrants.
|
||||
|
||||
The kits, sketched. Each is a role, a signature no mod may grant elsewhere, and a silly escalation that later
|
||||
content pushes on. Two abilities each for the steps; slots three and four are content.
|
||||
|
||||
| Kit | Role | Weapon | Signature | Second ability | Escalation later |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Warrior | Hold attention, absorb, open a path | Sword, or shield and mace | **Taunt**: force nearby enemies onto you | Shoulder charge: damage to enemies in a cone, impulse to teammates | Charge launches teammates across rooms |
|
||||
| Ranger | Sustained ranged damage, mobility | Bow (step 11); dagger until then | **Mark**: a marked target takes more from everyone | Volley | Arrows that carry things |
|
||||
| Mage | Burst and area, crowd control | Staff | **Blink**: short teleport along the aim | Fireball (step 11) | Blink takes whoever is touching you |
|
||||
| Cleric | Sustain, revives, buffs | Mace | **Mass heal** | Mend: targeted heal; **overheal launches the target** | The heal launches people over walls |
|
||||
| Rogue | Burst, mobility | Dagger | **Backstab**: aimed, high damage, short cooldown | Shadowstep: a self blink along the aim | |
|
||||
|
||||
Effects an ability may apply, each through the engine's own mechanism and never by writing a value: **Damage**
|
||||
(`GE_Damage` through the funnel), **Heal** (`GE_Heal` into `IncomingHeal`), **Impulse** (`LaunchCharacter` on the
|
||||
target through a client RPC to its owner, since movement is owner-predicted, scaled by the target's
|
||||
`KnockbackResist`), **Taunt** (`Status.Debuff.Taunted` through `ApplyStatus`, which the enemy controller turns
|
||||
into `ForceTarget`; a boss's `Immune.Status.Taunted` blocks it like any status), **Mark** (`Status.Debuff.Marked`
|
||||
for a duration, read by the funnel), **Slow**, **Haste** and every other buff or debuff (`ApplyStatus`, see
|
||||
[Stats.md](Stats.md)), **Blink** and **Dodge** (root-motion sources through the movement component, with
|
||||
`State.Invulnerable` for the dodge's i-frames).
|
||||
|
||||
**Parity target:** every kit completes the same gym fight solo in roughly the same time. Not equal damage, equal
|
||||
viability. `enemy_killed.killer_class` against `time_to_kill_s` is the measurement.
|
||||
|
||||
**The recoverability contract**, salvaged whole: any verb that lets one player inconvenience another (a charge
|
||||
into a carrier, a heal off a ledge, a blink with a passenger) must have a recovery available to the victim that
|
||||
takes **less time than the verb took to perform**, and none may down, kill or permanently deprive. Damage is gated
|
||||
in the funnel; things are never destroyed. Every such verb emits `grief_action`, because a rule nobody can measure
|
||||
is a rule nobody can defend.
|
||||
|
||||
## Projectiles (step 11)
|
||||
|
||||
`AProjectileActor` with a `UProjectileMovementComponent`, spawned on the server by `GA_Volley` and `GA_Fireball`
|
||||
and replicated; the firing client spawns a local cosmetic copy on activation so the shot leaves the hand without a
|
||||
round trip (predict the animation, not the ownership), and the cosmetic copy hides itself when the replicated one
|
||||
arrives. On hit the server applies `GE_Damage` with `Damage.Source.Projectile`. Travel time and drop are real, so
|
||||
aim matters. The bow's `FWeaponProfile` has reach zero and a montage that fires `Event.Montage.Release` instead of
|
||||
`Hit`.
|
||||
|
||||
## Networking
|
||||
|
||||
| State | Authority | Mechanism |
|
||||
| --- | --- | --- |
|
||||
| Attributes | Server | Replicated attribute set on the ASC (Mixed mode for players, Minimal for enemies) |
|
||||
| Ability activation | Client requests, server decides | GAS prediction keys; LocalPredicted for the player's abilities, ServerOnly for GA_Downed |
|
||||
| Damage | Server | Executions run on the server only |
|
||||
| Hit detection | Server, using the owner's replicated aim | A little client trust in the aim, which is fine in cooperative play |
|
||||
| Cooldowns | Server, client predicts | Cooldown effects with prediction; the bar shows the prediction and takes the correction |
|
||||
| Enemy brains | Server | StateTree ticks on the server; bodies replicate movement and montages |
|
||||
| Impulses on players | Server decides, owner applies | Client RPC to the owner's body, because movement is owner-predicted |
|
||||
| Statuses on bodies | Server, own-ability ones predicted | `ApplyStatus`, see [Stats.md](Stats.md) |
|
||||
| Cues | Everywhere | Gameplay cues, unreliable multicast; presentation only |
|
||||
|
||||
## Telemetry
|
||||
|
||||
| Event | When | Payload |
|
||||
| --- | --- | --- |
|
||||
| `enemy_spawned` | The encounter spawns | `enemy`, `spawn_point`, `party_size` |
|
||||
| `enemy_killed` | Health reaches zero | `enemy`, `killer_class`, `weapon`, `ability`, `time_to_kill_s` |
|
||||
| `ability_used` | Activation commits | `ability`, `class`, `target_count` |
|
||||
| `player_damaged` | Damage lands on a player | `source`, `attacker`, `amount`, `health_after` |
|
||||
| `player_downed` | Health reaches zero | `source`, `party_alive`, `party_size` |
|
||||
| `player_revived` | Revive completes | `by_class`, `down_duration_s` |
|
||||
| `player_died` | Bleed-out or wipe | `down_duration_s`, `cause` |
|
||||
| `party_wiped` | Nobody standing | `enemies_alive`, `duration_s` |
|
||||
| `grief_action` | A recoverable verb on a teammate | `verb`, `actor`, `target` |
|
||||
|
||||
## Tests
|
||||
|
||||
- Automation: `DamageMath` for every branch above; `FThreatTable` for forced target expiry, decay and removal;
|
||||
`UClassKitDefinition::IsDataValid` for two signatures and five slots.
|
||||
- Functional: `FT_Combat_Funnel` applies `GE_Damage` from a player to a player and asserts health is unchanged and
|
||||
the cue fired; `FT_Combat_Down` drives a player to zero, asserts `State.Downed`, revives through the interaction
|
||||
path and asserts health; `FT_Combat_Swing` swings at a dummy behind a wall and asserts no damage.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Q1. Does a shove into a pit count as friendly fire?** The gate discards player-to-player damage, but a shove
|
||||
that causes a fall produces fall damage from the world. Recoverable if the victim is downed rather than killed
|
||||
and can be revived. Decide when the first pit exists; the funnel already distinguishes the cases.
|
||||
- **Q2. Resource model.** Cooldowns only for these steps. Stamina is an attribute in the set so that adding a cost
|
||||
is a cost effect, not a refactor.
|
||||
- **Q3. Hit registration latency.** Server-authoritative with the owner's aim is the plan. At what latency does a
|
||||
swing feel wrong? Test at 100 ms and 150 ms in step 7 and write the number down.
|
||||
- **Q4. A blade sweep instead of a box.** If the box reads as imprecise once real animations arrive, sweep the
|
||||
weapon actor's tip and base sockets per frame during the hit window. Contained to `GA_MeleeAttack`.
|
||||
- **Q5. Which kits exist at all.** Five are named because two source projects between them named these five and
|
||||
their signatures survive contact with the guardrail. Steps 9 and 10 build two; the rest are content.
|
||||
- **Q6. Cross-class weapon animation.** A Warrior carrying a bow uses the bow's montage, which is authored per
|
||||
weapon family, not per class. That is the cheap answer and it is the plan; revisit when animation gets real.
|
||||
@@ -0,0 +1,717 @@
|
||||
# Crafting
|
||||
|
||||
Owns what an item is made of and how it comes to exist: families, parts, pieces and their three trait layers; the
|
||||
substances pieces are shaped from and the pipeline that refines them; the pure rules for legality, naming and
|
||||
quality; stations and the one activity runtime their hands-on work runs on; enchanting; and the seam through which
|
||||
a crafted weapon becomes a weapon you fight with. It does **not** own how a piece is picked up or inserted
|
||||
([Interaction.md](Interaction.md)), what happens when the weapon lands ([Combat.md](Combat.md)), or any economy,
|
||||
customer or shop: none of those exist here, and the ideas are parked in [`../Ideas.md`](../Ideas.md).
|
||||
|
||||
Read [Architecture.md](Architecture.md) first. Steps 12 to 15 in [`../Steps.md`](../Steps.md). This is the
|
||||
largest spec because the earlier smithing project had already worked its crafting model through several review
|
||||
passes; what follows is that model, restated for Unreal and cut to what a fighting prototype needs.
|
||||
|
||||
## Decisions
|
||||
|
||||
```
|
||||
[DECIDED] Three words, three levels. A FAMILY declares PARTS. A PIECE is a crafted object filling one part. A
|
||||
piece carries three TRAIT layers: a characteristic (its shape), a substance (what it is made of) and zero or more
|
||||
enchantments (its ornament). An item is a family's parts, filled.
|
||||
|
||||
Blade: mithril, blunt · Guard: mithril, pointy · Handle: dragonhide, of embers · Pommel: adamantine
|
||||
-> "Blunt Mithril Sword of Embers". The player made that; they did not pick it from a list.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] The design word "material" is "substance" in this project's code and docs. UMaterial is the engine's
|
||||
render material and the collision would be permanent. Substance is the word the earlier design used in its own
|
||||
subtitle, so nothing of meaning is lost.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] No recipes, anywhere. A family lists slots and filters. Any slot-complete, filter-satisfying set of
|
||||
pieces is a legal item. If a combination needs a table to be legal, the filters are wrong.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] Substances are objects, not stacks. Twelve iron ingots are twelve actors, each with its own identity
|
||||
and quality. Quality variance is then free, provenance is free, and "the sword made from that dragon's bone" is a
|
||||
queryable fact. Affordable only because storage is physical and caps the count: any future feature that lets
|
||||
substance accumulate without occupying space breaks the assumption and is refused on that ground.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] Discovery unlocks, choice applies. You discover characteristics through play; you choose them at the
|
||||
station. Strike quality decides how well a shape came out, never which shape you got, so a crafted item is always a
|
||||
deliberate act and a request for "a serrated blade" is always fillable.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] One activity runtime, three guarantees. Every hands-on station task runs on one runtime that owns
|
||||
participation, authority, progress, pause, quality accumulation and telemetry; a minigame implements only its own
|
||||
rules. The guarantees, binding forever: completion is never gated on skill; skill modulates a quality band and
|
||||
nothing else; a second pair of hands is always additive and never required.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] The assembly bench is domain-neutral. Every pipeline converges there; gating it would make one player a
|
||||
bottleneck. Domains gate the WORK (a station refuses a piece of the wrong domain) and never the WORKER (anyone may
|
||||
use any station). Class changes speed and quality only.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] Durability is a part property summed into an item total, and the item is lost at zero. Nothing in the
|
||||
shop wears out from crafting; wear is something the world does to gear later.
|
||||
```
|
||||
|
||||
## Vocabulary and tags
|
||||
|
||||
| Term | Meaning | Tag namespace |
|
||||
| --- | --- | --- |
|
||||
| Family | A kind of item and the schema of its parts, as a tree | `Item.Family.Sword`, `.Axe`, `.Bow`, `.Shield` |
|
||||
| Part | A position in an item | `Item.Part.Blade`, `.Guard`, `.Handle`, `.Pommel` |
|
||||
| Piece type | What a piece is, before its traits | `Item.PieceType.Blade`, `.Guard`, `.Handle`, `.Pommel` |
|
||||
| Piece | A crafted object filling a part; has an `FGuid` | runtime, `FPieceInstance` |
|
||||
| Trait layer | Characteristic, substance, enchantment | `Piece.Layer.*` |
|
||||
| Characteristic | Shape: serrated, blunt, curved, heavy | `Item.Characteristic.*` |
|
||||
| Substance | What a piece is made of | `Item.Substance.Iron`, `.Oak`, `.Mithril` ... |
|
||||
| Enchantment | Ornament: of embers, of frost | `Item.Enchant.Fire.Penetration` ... |
|
||||
| Substance object | One physical ingot, log, hide; has an `FGuid` | runtime, `FSubstanceInstance` |
|
||||
| Domain | Which station family works a thing | `Domain.Forge`, `.Wood`, `.Enchant` |
|
||||
| Theme | Synergy vocabulary shared by characteristics and enchantments | `Theme.Aggressive`, `.Defensive`, `.Fire` ... |
|
||||
| Coherence | An item's quality score: piece values plus synergy | runtime |
|
||||
|
||||
Other namespaces this spec owns: `Substance.Class.[Metal|Wood|Hide|Bone|Arcane|Fuel]`,
|
||||
`Substance.Rarity.[Common|Uncommon|Rare|Unique]`, `Substance.State.[Raw|Processed]`,
|
||||
`Station.Type.*`, `Station.State.[Idle|Occupied|Working|Blocked|Paused]`, `Activity.[Heat|Strike|Assemble]`,
|
||||
`Activity.State.*`, `Fault.*`, `Mark.*`, and `Craft.Reason.*` for every rejection.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
Source/<Project>Core/Crafting/
|
||||
├── CraftingDefinitions.h // the definition asset classes (data only, no world)
|
||||
├── CraftingTypes.h // FPieceInstance, FAssembledItem, FSubstanceInstance, FProvenance, verdict structs
|
||||
├── CraftingRules.h / .cpp // pure: CanFillSlot, ValidateAssembly, ResolveAttachChains, ComposeName, ScoreCoherence,
|
||||
│ // PieceValue, PartDurability, MakeWeaponProfile
|
||||
└── ActivityTypes.h // FActivityResult, FActivitySession, the strike and heat rule structs (pure parts)
|
||||
|
||||
Source/<Project>/Crafting/
|
||||
├── CraftingSubsystem.h // UWorldSubsystem, server: the Server* flows, the item registry, discovery
|
||||
├── SubstanceActor.h // a carryable physical substance object
|
||||
├── PieceActor.h // a carryable piece
|
||||
├── AssembledItemActor.h // a carryable assembled item; builds its visual from sockets; equippable if a weapon
|
||||
├── Stations/
|
||||
│ ├── StationDefinition.h // UPrimaryDataAsset
|
||||
│ ├── StationActor.h // IInteractable receptacle, buffers, operators, state machine
|
||||
│ ├── AssemblyBench.h // staging, preview, commit
|
||||
│ ├── AnvilStation.h // hosts the strike activity
|
||||
│ └── ForgeStation.h // hosts the heat activity, the bed
|
||||
└── Activities/
|
||||
├── ActivityRuntime.h // UWorldSubsystem: sessions, participants, pause, the one result funnel
|
||||
├── ActivityDefinition.h // UPrimaryDataAsset per activity
|
||||
├── StrikeActivity.h // the growing zone
|
||||
└── HeatActivity.h // the coal bed grid, two LODs
|
||||
|
||||
Content/Crafting/
|
||||
├── Definitions/Families/DA_Family_Sword
|
||||
├── Definitions/PieceTypes/DA_PieceType_Blade, _Guard, _Handle, _Pommel
|
||||
├── Definitions/Characteristics/DA_Char_Straight, _Serrated, _Blunt, _Curved
|
||||
├── Definitions/Substances/DA_Sub_IronOre, _IronIngot, _OakLog, _OakPlank, _Coal
|
||||
├── Definitions/Enchantments/DA_Ench_Embers, _Frost
|
||||
├── Definitions/Stations/DA_Station_AssemblyBench, _Anvil, _Forge
|
||||
├── Definitions/Activities/DA_Activity_Strike, _Heat
|
||||
├── DA_CraftingConfig // the weights and thresholds every pure rule reads
|
||||
└── Meshes/SM_Proto_Blade ... // primitives with named sockets, tinted by substance
|
||||
```
|
||||
|
||||
## Data
|
||||
|
||||
```cpp
|
||||
// Source/<Project>Core/Crafting/CraftingDefinitions.h
|
||||
|
||||
UCLASS(BlueprintType)
|
||||
class UPieceTypeDefinition : public UPrimaryDataAsset // "Blade", "Guard", "Handle", "Pommel"
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag PieceTypeTag; // Item.PieceType.Blade
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag PartTag; // Item.Part.Blade: which part it can fill
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag DomainTag; // Domain.Forge | Domain.Wood: who shapes it
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTagContainer FitsFamilies; // Item.Family.Sword, .Dagger ...
|
||||
UPROPERTY(EditDefaultsOnly) FText NameFragment; // "Sword": root candidate when this part dominates
|
||||
UPROPERTY(EditDefaultsOnly) int32 PartWeight = 1; // dominance for naming and scoring: blade 4, guard 2, handle 2, pommel 1
|
||||
UPROPERTY(EditDefaultsOnly) int32 BaseValue = 10;
|
||||
UPROPERTY(EditDefaultsOnly) int32 SubstanceObjects = 1; // objects consumed to shape one: blade 3, guard 1, handle 2, pommel 1
|
||||
UPROPERTY(EditDefaultsOnly) int32 BaseDurability = 100;
|
||||
UPROPERTY(EditDefaultsOnly) int32 AddonSockets = 1; // enchantment capacity
|
||||
UPROPERTY(EditDefaultsOnly) int32 HotSpots = 4; // for the strike activity, 3..6
|
||||
UPROPERTY(EditDefaultsOnly) FVector2D ScaleRange = FVector2D(0.7f, 1.6f); // absurd proportions are legal, bounded
|
||||
UPROPERTY(EditDefaultsOnly) FWeaponContribution Weapon; // what this part adds to a weapon profile, see the seam
|
||||
UPROPERTY(EditDefaultsOnly) TSoftObjectPtr<UStaticMesh> Mesh; // carries the named sockets
|
||||
};
|
||||
|
||||
UCLASS(BlueprintType)
|
||||
class UCharacteristicDefinition : public UPrimaryDataAsset // "Serrated", "Blunt", "Curved", "Heavy"
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag CharacteristicTag;
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTagContainer ApplicableToParts; // a blade can be serrated; a pommel cannot
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag ThemeTag; // synergy input
|
||||
UPROPERTY(EditDefaultsOnly) FText NameFragment; // "Serrated", the prefix
|
||||
UPROPERTY(EditDefaultsOnly) int32 ValueDelta = 0;
|
||||
UPROPERTY(EditDefaultsOnly) FWeaponContribution Weapon; // serrated: +damage, -durability, say
|
||||
UPROPERTY(EditDefaultsOnly) bool bKnownFromStart = false; // else discovered
|
||||
};
|
||||
|
||||
UCLASS(BlueprintType)
|
||||
class USubstanceDefinition : public UPrimaryDataAsset // the TYPE: "Iron Ingot", "Oak Log"
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag SubstanceTag; // Item.Substance.Iron
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag ClassTag; // Substance.Class.Metal
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag RarityTag; // Substance.Rarity.Common
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag StateTag; // Substance.State.Raw | .Processed
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag ThemeTag;
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTagContainer WorkableBy; // Domain.Forge ...
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTagContainer ArcaneAffinity; // which enchantment themes it accepts
|
||||
UPROPERTY(EditDefaultsOnly) int32 BaseValue = 5;
|
||||
UPROPERTY(EditDefaultsOnly) float DurabilityFactor = 1.f; // dragonbone 1.5, oak 0.1
|
||||
UPROPERTY(EditDefaultsOnly) FLinearColor PaletteColour; // the substance IS the palette in flat-shaded low poly
|
||||
// heat, for the forge: working band and burn point in arbitrary heat units, thermal mass
|
||||
UPROPERTY(EditDefaultsOnly) float WorkingTempMin = 0.f, WorkingTempMax = 0.f, BurnPoint = 0.f, ThermalMass = 1.f;
|
||||
UPROPERTY(EditDefaultsOnly) bool bTwoHanded = false; // a log takes both hands
|
||||
// processing: Raw -> Processed
|
||||
UPROPERTY(EditDefaultsOnly) TObjectPtr<USubstanceDefinition> ProcessesInto;
|
||||
UPROPERTY(EditDefaultsOnly) int32 ProcessInputCount = 1, ProcessOutputCount = 1;
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag ProcessDomainTag;
|
||||
UPROPERTY(EditDefaultsOnly) TSoftObjectPtr<UStaticMesh> Mesh;
|
||||
};
|
||||
|
||||
UCLASS(BlueprintType)
|
||||
class UEnchantmentDefinition : public UPrimaryDataAsset // "of Embers"
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag EnchantmentTag;
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTagContainer ApplicableToSubstances; // affinity gate, both sides must agree
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag ThemeTag; // Theme.Fire
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTagContainer GrantsTags; // Item.Enchant.Fire.Penetration: reaches the weapon profile
|
||||
UPROPERTY(EditDefaultsOnly) TSubclassOf<UGameplayEffect> HolderEffect; // optional: granted to whoever equips the item, e.g. GE_Immune_Burning (Stats.md)
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag DamageType; // Damage.Type.Magic, optional
|
||||
UPROPERTY(EditDefaultsOnly) FText NameFragment; // "of Embers", the suffix
|
||||
UPROPERTY(EditDefaultsOnly) int32 ValueDelta = 0, SocketCost = 1;
|
||||
};
|
||||
|
||||
USTRUCT()
|
||||
struct FPartSlot
|
||||
{
|
||||
GENERATED_BODY()
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag PartTag; // Item.Part.Guard
|
||||
UPROPERTY(EditDefaultsOnly) bool bRequired = false; // blade and handle yes; guard and pommel no
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTagContainer Accepts; // piece-type tags this slot takes
|
||||
UPROPERTY(EditDefaultsOnly) TArray<FGameplayTag> AttachesTo; // ordered parent preference: [Guard, Handle]
|
||||
UPROPERTY(EditDefaultsOnly) FName ParentSocket; // "Socket_Attach_Blade" on the parent's mesh
|
||||
UPROPERTY(EditDefaultsOnly) FIntPoint GridPos; // the assembly panel's layout, traces the silhouette
|
||||
};
|
||||
|
||||
UCLASS(BlueprintType)
|
||||
class UItemFamilyDefinition : public UPrimaryDataAsset // "Sword"
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag FamilyTag; // Item.Family.Sword
|
||||
UPROPERTY(EditDefaultsOnly) TArray<FPartSlot> Parts; // the schema; exactly one slot has an empty AttachesTo (the root)
|
||||
UPROPERTY(EditDefaultsOnly) FText RootFragment; // "Sword", if no part supplies a root
|
||||
UPROPERTY(EditDefaultsOnly) bool bIsWeapon = true;
|
||||
UPROPERTY(EditDefaultsOnly) FSoftObjectPath Montage; // the swing animation every sword shares
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag HandSocket; // where an equipped one attaches
|
||||
// IsDataValid: one root, no duplicate part tags, every AttachesTo names a part in this family, sockets non-empty.
|
||||
};
|
||||
|
||||
UCLASS(BlueprintType)
|
||||
class UCraftingConfig : public UPrimaryDataAsset // the weights every pure rule reads; one asset
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UPROPERTY(EditDefaultsOnly) float SubstanceSynergyWeight = 3.f, ThemeSynergyWeight = 4.f, CompletenessWeight = 5.f;
|
||||
UPROPERTY(EditDefaultsOnly) float SubstanceMajorityThreshold = 0.5f; // share of PartWeight one substance needs to be named
|
||||
UPROPERTY(EditDefaultsOnly) int32 MaxNameWords = 5;
|
||||
UPROPERTY(EditDefaultsOnly) FVector2D QualityFactorRange = FVector2D(0.85f, 1.15f); // what MaterialQuality and CraftQuality scale between
|
||||
UPROPERTY(EditDefaultsOnly) float DamagePerQualityPoint = 0.2f; // the seam's exchange rate
|
||||
UPROPERTY(EditDefaultsOnly) float ScrapReturnFraction = 0.2f;
|
||||
};
|
||||
```
|
||||
|
||||
The sword family, as data:
|
||||
|
||||
```
|
||||
DA_Family_Sword (Item.Family.Sword)
|
||||
Handle required root (AttachesTo empty)
|
||||
├── Guard optional AttachesTo [Handle] ParentSocket "Socket_Attach_Guard"
|
||||
│ └── Blade required AttachesTo [Guard, Handle] ParentSocket "Socket_Attach_Blade"
|
||||
└── Pommel optional AttachesTo [Handle] ParentSocket "Socket_Attach_Pommel"
|
||||
```
|
||||
|
||||
`Blade.AttachesTo = [Guard, Handle]` is what makes optional parts work without special cases: resolution walks
|
||||
the list and attaches to the first ancestor present, so a guardless sword's blade lands on the handle's own
|
||||
`Socket_Attach_Blade`, which every handle mesh therefore carries. Other families (axe: haft root with head and grip;
|
||||
bow: riser root with limbs and string; shield: face root with rim, boss and straps) are the same shape and are
|
||||
content, not code. A part tag is unique within a family; a second instance of the same kind of part is its own tag
|
||||
(`Item.Part.Pauldron.Left`), which the part-keyed map enforces structurally.
|
||||
|
||||
Sockets are the engine's static mesh sockets, authored on the mesh asset and resolved by name, never by index, so
|
||||
an artist can move `Socket_Attach_Guard` without touching gameplay data. A missing socket logs a content error and
|
||||
degrades to the parent's origin rather than failing: a greybox item must always assemble.
|
||||
|
||||
## Runtime state
|
||||
|
||||
```cpp
|
||||
USTRUCT()
|
||||
struct FProvenance
|
||||
{
|
||||
GENERATED_BODY()
|
||||
UPROPERTY() FGameplayTag Kind; // Provenance.Starting | .Crafted | .Found | .Reward ...; extensible, never an enum
|
||||
UPROPERTY() FGuid Ref; // the crafter's player id, or whatever the kind refers to
|
||||
UPROPERTY() int32 Day = 0; // reserved
|
||||
};
|
||||
|
||||
USTRUCT()
|
||||
struct FSubstanceInstance // THE physical object: one per ingot, log, hide
|
||||
{
|
||||
GENERATED_BODY()
|
||||
UPROPERTY() FGuid ObjectId;
|
||||
UPROPERTY() TObjectPtr<USubstanceDefinition> Type;
|
||||
UPROPERTY() float Quality = 0.5f; // 0..1, set by the activity that made it
|
||||
UPROPERTY() FProvenance Origin;
|
||||
UPROPERTY() FGameplayTagContainer Marks; // Mark.Overheated, Mark.Salvaged, Mark.Dented: flavour and valuation
|
||||
// Temperature is NOT here. It lives on the forge bed while the object is in it (HeatActivity), and it is not saved.
|
||||
};
|
||||
|
||||
USTRUCT()
|
||||
struct FPieceInstance // a crafted object filling one part
|
||||
{
|
||||
GENERATED_BODY()
|
||||
UPROPERTY() FGuid PieceId;
|
||||
UPROPERTY() TObjectPtr<UPieceTypeDefinition> Type;
|
||||
UPROPERTY() TObjectPtr<UCharacteristicDefinition> Characteristic;
|
||||
UPROPERTY() TObjectPtr<USubstanceDefinition> Substance;
|
||||
UPROPERTY() float SubstanceQuality = 0.5f; // average of the objects consumed
|
||||
UPROPERTY() TArray<FGuid> SourceObjectIds; // WHICH objects: provenance and unique-substance binding
|
||||
UPROPERTY() float CraftQuality = 0.5f; // the shaping activity's contribution
|
||||
UPROPERTY() TArray<TObjectPtr<UEnchantmentDefinition>> Enchantments; // bounded by Type->AddonSockets
|
||||
// DERIVED, recomputed on load, never persisted: Value, Durability
|
||||
};
|
||||
|
||||
USTRUCT()
|
||||
struct FAssembledItem
|
||||
{
|
||||
GENERATED_BODY()
|
||||
UPROPERTY() FGuid ItemId;
|
||||
UPROPERTY() TObjectPtr<UItemFamilyDefinition> Family;
|
||||
UPROPERTY() TMap<FGameplayTag, FPieceInstance> PartsFilled; // Item.Part.* -> piece. A map, so "what is in the Guard slot" is the question
|
||||
UPROPERTY() int32 Durability = 0; // CURRENT: persisted, it is history
|
||||
// DERIVED, recomputed on load: DerivedName, QualityScore, MaxDurability, the weapon profile
|
||||
};
|
||||
```
|
||||
|
||||
`SourceObjectIds` looks like overhead on the first day and is load-bearing later: it is what lets a finished sword
|
||||
answer "is this the bone from that particular kill" years on, and it is the field an audit trail needs.
|
||||
|
||||
## The pure rules
|
||||
|
||||
All in `CraftingRules`, in the core module, no world, every one tested. They are the single authority consulted
|
||||
by the bench preview, the server commit, and later anything that asks whether an item satisfies a request.
|
||||
Preview and reality call the same function and cannot disagree.
|
||||
|
||||
```cpp
|
||||
namespace CraftingRules
|
||||
{
|
||||
struct FSlotVerdict { bool bAccept; FGameplayTag Reason; }; // Craft.Reason.* on rejection
|
||||
struct FAssemblyVerdict
|
||||
{
|
||||
bool bValid; FGameplayTag Reason; FGameplayTag OffendingPart;
|
||||
FText PreviewName; int32 PreviewQuality; int32 PreviewSynergy; int32 PreviewSubstanceValue;
|
||||
};
|
||||
struct FAttachment { FGameplayTag Child, Parent; FName Socket; };
|
||||
|
||||
// pure. Both sides must agree: the slot accepts the type, the type fits the family, the shape fits the part.
|
||||
// No scale gating: a greatsword blade on a dagger handle is legal because it is funny; ScaleRange bounds it.
|
||||
FSlotVerdict CanFillSlot(const FPieceInstance& Piece, const FPartSlot& Slot, const UItemFamilyDefinition& Family);
|
||||
// Type->PartTag != Slot.PartTag -> WrongPart
|
||||
// !Slot.Accepts.HasTag(Type->PieceTypeTag) -> SlotRejectsType
|
||||
// !Type->FitsFamilies.HasTag(Family.FamilyTag) -> FamilyMismatch
|
||||
// !Characteristic->ApplicableToParts.HasTag(part) -> IllegalShape
|
||||
|
||||
// pure. Every required slot filled, every filled slot legal, no foreign part, every attach chain resolves.
|
||||
FAssemblyVerdict ValidateAssembly(const UItemFamilyDefinition& Family, const TMap<FGameplayTag, FPieceInstance>& Parts,
|
||||
const UCraftingConfig& Config);
|
||||
// MissingPart, ForeignPart (a pommel on a bow), UnreachablePart (an authoring error), else Valid with previews
|
||||
|
||||
// pure. Walks each slot's AttachesTo and emits child -> first present parent -> socket, parent before child.
|
||||
TArray<FAttachment> ResolveAttachChains(const UItemFamilyDefinition& Family, const TMap<FGameplayTag, FPieceInstance>& Parts);
|
||||
|
||||
// pure. Prefix + [substance] + root + suffix, at most one fragment per role. Word salad is structurally impossible.
|
||||
FText ComposeName(const UItemFamilyDefinition& Family, const TMap<FGameplayTag, FPieceInstance>& Parts, const UCraftingConfig& Config);
|
||||
// dominant = the piece with the highest PartWeight
|
||||
// prefix = dominant.Characteristic->NameFragment
|
||||
// core = the substance holding >= SubstanceMajorityThreshold of total PartWeight, else nothing
|
||||
// (this is what stops "Iron Dragonhide Mithril Sword" from ever being generated)
|
||||
// root = dominant.Type->NameFragment, else Family.RootFragment
|
||||
// suffix = the highest-value enchantment's NameFragment across all pieces
|
||||
// over MaxNameWords: drop suffix, then core, then prefix. Prefix sharing a stem with root drops the prefix.
|
||||
|
||||
// pure. A coherent item beats an expensive one.
|
||||
int32 ScoreCoherence(const UItemFamilyDefinition& Family, const TMap<FGameplayTag, FPieceInstance>& Parts,
|
||||
const UCraftingConfig& Config, int32* OutSynergy = nullptr);
|
||||
// base = Σ PieceValue(piece) * PartWeight
|
||||
// synergy = SubstanceSynergy (n parts of one substance score n(n-1)/2 pairs: commitment, not accident)
|
||||
// + ThemeSynergy (matching pairs among all characteristic and enchantment themes)
|
||||
// + CompletenessBonus (optional parts filled; without it "required parts only" is strictly optimal)
|
||||
|
||||
int32 PieceValue(const FPieceInstance& Piece, const UCraftingConfig& Config);
|
||||
// (Type->BaseValue + Substance->BaseValue + Characteristic->ValueDelta + Σ enchant ValueDelta)
|
||||
// * lerp(QualityFactorRange, SubstanceQuality) * lerp(QualityFactorRange, CraftQuality)
|
||||
|
||||
int32 PartDurability(const FPieceInstance& Piece, const UCraftingConfig& Config);
|
||||
// Type->BaseDurability * Substance->DurabilityFactor * the two quality factors
|
||||
int32 MaxDurability(const FAssembledItem& Item, const UCraftingConfig& Config); // Σ PartDurability
|
||||
|
||||
// pure. The seam to Combat.md. See "From item to weapon".
|
||||
FWeaponProfile MakeWeaponProfile(const FAssembledItem& Item, const UCraftingConfig& Config);
|
||||
}
|
||||
```
|
||||
|
||||
The reference example, run through: dominant is the blade (weight 4), prefix "Blunt"; mithril holds blade plus
|
||||
guard, six of nine weight, so core "Mithril"; root "Sword"; the handle's fire enchantment supplies "of Embers".
|
||||
*Blunt Mithril Sword of Embers*. That is the first automation test.
|
||||
|
||||
## Substances
|
||||
|
||||
**The pipeline, enforced by one tag.** A raw object is never valid input to a shaping station and a processed
|
||||
object is never valid input to a processing station; `StateTag` alone enforces it, with no per-station recipe list.
|
||||
|
||||
```
|
||||
RAW PROCESSED PIECE
|
||||
Iron Ore -> Iron Ingot -> Iron Blade
|
||||
Oak Log -> Oak Plank -> Oak Handle
|
||||
Coal -> (fuel: never processed, burned by the forge bed)
|
||||
```
|
||||
|
||||
Cost is authored on the piece type, not per type and substance, so substance choice is a free axis: any workable
|
||||
substance makes any piece of that type at the same object cost, with different value, colour and heat behaviour.
|
||||
Combinatorial variety, no combinatorial authoring.
|
||||
|
||||
**Object semantics.** Identity always. No merging, no splitting: objects are created and consumed whole.
|
||||
Aggregated for display only ("Iron Ingot ×12, avg quality 0.62" is a HUD affordance over twelve distinct actors).
|
||||
Consumption picks **worst-fit-first** by default: a station needing three ingots from a rack of nine takes the three
|
||||
lowest-quality qualifying ones, shows which before starting, and the player may override. Good stock is saved for
|
||||
good work by default.
|
||||
|
||||
`ASubstanceActor` is a carryable ([Interaction.md](Interaction.md)) with a `UStaticMeshComponent` tinted by the
|
||||
substance's palette colour through custom primitive data (no dynamic material instance per object), net dormancy on
|
||||
while at rest, and its `FSubstanceInstance` replicated to whoever is in range. Rare and unique objects on the floor
|
||||
are never swept away by any future tidy-up rule; they glow faintly.
|
||||
|
||||
**Quality variance is the natural case.** The smelt activity's result lands in `Quality` and scales the object's
|
||||
value; a careless smelt is worth less, a careful one more, both are usable. **Class bonus applies to time and
|
||||
quality, never to yield**: a better smith's smelt is faster and purer, never more numerous, because a yield bonus is
|
||||
an efficiency lock. Concretely, a bonus is the `WorkSpeed` and `WorkQuality` attributes on the body's stat block
|
||||
([Stats.md](Stats.md)), which the activity runtime reads; a class is base numbers there and nothing else.
|
||||
|
||||
Supply, for these steps, is a spawner in the gym: `bs.SpawnSubstance <tag> <count>` and a crate that refills. Where
|
||||
substances come from in a world (purchase, contracts, the world itself) is [`../Ideas.md`](../Ideas.md).
|
||||
|
||||
## Stations
|
||||
|
||||
```cpp
|
||||
UCLASS(BlueprintType)
|
||||
class UStationDefinition : public UPrimaryDataAsset
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag StationTag; // Station.Type.Anvil
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag DomainTag; // Domain.Forge; empty on the assembly bench
|
||||
UPROPERTY(EditDefaultsOnly) TObjectPtr<UActivityDefinition> Activity; // null for a plain station
|
||||
UPROPERTY(EditDefaultsOnly) int32 MaxOperators = 1; // MinOperators is 1, always, everywhere
|
||||
UPROPERTY(EditDefaultsOnly) int32 InputCapacity = 4, OutputCapacity = 4;
|
||||
UPROPERTY(EditDefaultsOnly) float BaseSpeedMultiplier = 1.f;
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTagContainer AcceptsInsert; // Substance.State.Processed for an anvil, Item.PieceType for a bench
|
||||
};
|
||||
|
||||
/**
|
||||
* Every station. An interactable (join, leave, take output) and an insert receptacle. Operators are a LIST because
|
||||
* the anvil takes two smiths; a second operator raises the ceiling and never the floor. Work at a station PAUSES
|
||||
* when everyone leaves and never fails: losing progress would punish exactly the behaviour a co-op game wants
|
||||
* (dropping what you are doing to help someone). The clock that matters runs elsewhere.
|
||||
*/
|
||||
UCLASS()
|
||||
class AStationActor : public AActor, public IInteractable
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UPROPERTY(EditDefaultsOnly) TObjectPtr<UStationDefinition> Definition;
|
||||
UPROPERTY(Replicated) FGameplayTag StateTag; // Station.State.*
|
||||
UPROPERTY(Replicated) TArray<TObjectPtr<APlayerState>> Operators;
|
||||
UPROPERTY(Replicated) FGuid ActivitySession; // 0 when idle
|
||||
UPROPERTY(Replicated) TArray<FGuid> InputBuffer, OutputBuffer; // object ids; the forge's "buffer" is its bed
|
||||
|
||||
// IInteractable: Interact.Verb.Operate joins (or leaves when already an operator); the prompt names who is on it
|
||||
// when full, in their colour, and nothing else happens: occupied stations do not queue you. TakeOutput is a
|
||||
// second verb when the output buffer holds something and your hands are free.
|
||||
// Insert (the receptacle path from Interaction.md): validate AcceptsInsert and DomainTag against the carried
|
||||
// object's tags; reject WrongDomain or NotAccepted with the reason; move the object into InputBuffer.
|
||||
// ServerStartWork: an operator, inputs present -> ActivityRuntime->ServerBeginActivity(...). On completion,
|
||||
// if the output buffer has no room the station is Blocked, never destroying anything, until someone empties it.
|
||||
// No queueing. Batching is physical: the forge bed holds many items at once because it is a bed.
|
||||
};
|
||||
```
|
||||
|
||||
**The assembly bench** is a station with no activity and no domain. Inserting a piece stages it into the part its
|
||||
type fills (the map makes a second piece in one part structurally impossible); every insertion re-runs
|
||||
`ValidateAssembly` and shows the preview name, quality, synergy segment and the failing reason on the bench's
|
||||
world-space panel; the `Assemble` verb commits through `ServerAssembleItem`, which re-runs the verdict server-side
|
||||
and never trusts the preview. The session's initiator commits; anyone may insert. The assembled item appears in
|
||||
the output buffer as an `AAssembledItemActor` built from the resolved attach chains, each piece's mesh on its
|
||||
parent's named socket, tinted by its substance.
|
||||
|
||||
## Activities
|
||||
|
||||
```
|
||||
[SALVAGED] One runtime, so that the forge's interactivity is not lost because the anvil learned to take two
|
||||
players, and so that five copied minigames cannot drift into five bug surfaces. A minigame overrides OnBegin,
|
||||
ApplyInput, Tick, EvaluateProgress and CollectFaults. It cannot invent its own participation model, save shape or
|
||||
way of producing quality; those are closed.
|
||||
```
|
||||
|
||||
```cpp
|
||||
UCLASS(BlueprintType)
|
||||
class UActivityDefinition : public UPrimaryDataAsset
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UPROPERTY(EditDefaultsOnly) FGameplayTag ActivityTag; // Activity.Strike
|
||||
UPROPERTY(EditDefaultsOnly) TSubclassOf<UActivityRules> Rules; // UStrikeActivity, UHeatActivity
|
||||
UPROPERTY(EditDefaultsOnly) int32 MaxOperators = 1; // MinOperators is 1: serialised as a constant so it is visible
|
||||
UPROPERTY(EditDefaultsOnly) FVector2D QualityBand = FVector2D(0.85f, 1.15f); // guarantee 2: skill modulates this and nothing else
|
||||
UPROPERTY(EditDefaultsOnly) float BaselineSeconds = 6.f; // "steady work" duration at one operator
|
||||
UPROPERTY(EditDefaultsOnly) float BaselineScore = 0.4f; // what steady work scores: guarantee 1
|
||||
UPROPERTY(EditDefaultsOnly) bool bAllowAutoPlay = true; // hold-to-work is a first-class setting
|
||||
UPROPERTY(EditDefaultsOnly) float AssistWindowScale = 1.f; // accessibility: widens timing tolerances, never gated
|
||||
};
|
||||
|
||||
USTRUCT()
|
||||
struct FActivityResult // the ONE funnel; nothing else may produce quality
|
||||
{
|
||||
GENERATED_BODY()
|
||||
float QualityContribution = 0.f; // 0..1, mapped through QualityBand by the consumer
|
||||
float TimeSpent = 0.f;
|
||||
TMap<FGuid, float> ContributionByPlayer; // attribution: never clawed back, feeds telemetry and later XP
|
||||
FGameplayTagContainer Faults; // Fault.Overheated, Fault.Misstruck
|
||||
};
|
||||
|
||||
/** UWorldSubsystem, server. Owns every session; the station owns none of this. */
|
||||
UCLASS()
|
||||
class UActivityRuntime : public UTickableWorldSubsystem
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
FGuid ServerBeginActivity(APlayerState* Instigator, AStationActor* Host, const UActivityDefinition& Def, const FActivityPayload& Payload);
|
||||
bool ServerJoinActivity(APlayerState* Who, FGuid Session); // additive, always optional; ActivityFull when at max
|
||||
void ServerLeaveActivity(APlayerState* Who, FGuid Session); // keeps their contribution; last one out pauses
|
||||
void ServerActivityInput(APlayerState* Who, FGuid Session, const FActivityInput& Input); // THE per-frame path
|
||||
// validates participant and reach (with a short leash and a visible warning before dropping out),
|
||||
// delta = Rules->ApplyInput(session, who, input); accumulates and attributes
|
||||
// Tick: Rules->Tick per running session; steady-work accrual when auto-play is engaged; progress advances at
|
||||
// the operators' summed WorkSpeed and the quality floor is raised by their best WorkQuality (both attributes on
|
||||
// the stat block, Stats.md); complete at progress 1:
|
||||
// result -> Host->DeliverResult(result, payload) -> the substance or piece is made; emit activity_completed.
|
||||
};
|
||||
```
|
||||
|
||||
**The anvil: the growing zone.** Not a rhythm game; a reaction-and-placement game scored as a percentage. The
|
||||
heated piece exposes 3 to 6 hot spots (authored per piece type, in the piece's own space so the data survives an
|
||||
art swap). Each round one becomes the active zone, a circle that starts small and grows; the player moves the hammer
|
||||
over it and strikes. Inside the zone, score is `InverseLerp(MaxRadius, MinRadius, radiusAtHit)`: tiny is excellent,
|
||||
huge is poor. Outside is `Fault.Misstruck` and the zone keeps growing. Never struck, the round expires at zero. No
|
||||
input at all, each round auto-resolves at `BaselineScore`: completion is never gated on reaction. After the authored
|
||||
round count, `QualityContribution` is the mean. **Temperature gates the whole thing:** below the substance's working
|
||||
band the metal resists, growth speeds up and scores are capped. Go back to the forge. That one rule is the loop
|
||||
between the two stations. Two strikers alternate the active zone; each hit is attributed.
|
||||
|
||||
Because the zone's radius is a deterministic function of time, a strike is lag-compensated by arithmetic, not
|
||||
history: the client stamps its input, the server evaluates `radius(clientTime)` directly, clamped to a 250 ms
|
||||
window and never scoring better than the best achievable at the earliest legitimate arrival. Two strikers resolve
|
||||
in timestamp order. See [Networking.md](Networking.md), tier three; it is the only timing-scored input in the game.
|
||||
|
||||
**The forge: heat in a bed.** A coal bed is a fixed 5×5 grid of cells with temperature and fuel. On the server at
|
||||
10 Hz: fuel burns into temperature, temperature conducts to four neighbours (never all pairs), items in the bed
|
||||
sample the cells they overlap and move toward that temperature at their own conductivity over thermal mass, and an
|
||||
item over its substance's burn point takes `Fault.Overheated`. Inputs: place and move items, rake coals, add fuel,
|
||||
work the bellows (an airflow spike, the cleanest second role in the game: pure help, zero risk). An item is done
|
||||
when it has held its working band for the hold time; quality is time inside the band against time outside. The
|
||||
bed holds many items at once, which is the only batching there is.
|
||||
|
||||
Heat runs at **two levels of detail**: the full grid while a player is within the sim radius, one lumped
|
||||
temperature and fuel pool ticked at 1 Hz when nobody is, with the spatial pattern stored across the collapse so
|
||||
the bed you walk back to is the bed you left. The binding rule: far-mode outcomes must match near-mode outcomes
|
||||
on average over the same wall-clock time, or walking away becomes an exploit. The comparison harness is thirty
|
||||
lines and is written with the first heat prototype, not after. Heat is scoped to a bed; an object outside any
|
||||
bed cools on a curve and stops simulating at ambient. Replication: 25 quantised bytes at 5 Hz to near clients, one
|
||||
byte to far ones, coal glow and metal colour driven locally from the values.
|
||||
|
||||
**Discovery.** Working metal turns up shapes nobody told you about: a run of excellent strikes on an unusual
|
||||
hot-spot pattern, an odd substance at an odd temperature, a fault that turned out well. `ServerDiscoverCharacteristic`
|
||||
adds the definition to the player's known set (per player, on the player state, so it travels with them), fires
|
||||
`OnCharacteristicDiscovered` as a moment (the piece named and glowing on the anvil, a line in the log, never a
|
||||
screen-centre banner), and from then on the characteristic is an ordinary choice at the station. The glyph
|
||||
discovery the earlier project planned for enchanting is the same shape and should generalise this rather than
|
||||
grow beside it; for these steps enchantments are a fixed authored set.
|
||||
|
||||
**Scrapping** runs no activity: a plain interaction returning `ScrapReturnFraction` of the constituent objects,
|
||||
rounded down, quality scaled, marked `Mark.Salvaged`. A frictionless sink for failed work.
|
||||
|
||||
## Enchanting
|
||||
|
||||
Applied to a piece, before or after assembly, into addon sockets bounded by the piece type. Both filters must
|
||||
agree: the enchantment's `ApplicableToSubstances` and the substance's `ArcaneAffinity` meet in the middle, so a new
|
||||
substance declares what it accepts rather than every enchantment being edited. Enchanting an assembled item routes
|
||||
to the same call per target piece; there is no second code path.
|
||||
|
||||
```cpp
|
||||
// UCraftingSubsystem
|
||||
bool ServerApplyEnchantment(APlayerState* Instigator, FGuid PieceId, const UEnchantmentDefinition& Ench);
|
||||
// Validate: at a Domain.Enchant station; UsedSockets + SocketCost <= Type->AddonSockets else NoFreeSocket;
|
||||
// affinity both ways else SubstanceRejectsEnchant. Add; recompute derived; OnPieceEnchanted; emit piece_enchanted.
|
||||
```
|
||||
|
||||
## Server flows
|
||||
|
||||
```cpp
|
||||
// UCraftingSubsystem (world, server). Every entry validates the instigator, re-runs the pure verdict, mutates
|
||||
// through the registry, then notifies. In solo exactly as in co-op.
|
||||
|
||||
bool ServerProcessSubstance(APlayerState* Instigator, AStationActor* Station, TArray<FGuid> Inputs);
|
||||
// all inputs one type and Raw else AlreadyProcessed; station domain matches ProcessDomainTag else WrongDomain;
|
||||
// count >= ProcessInputCount else NotEnough. Begin the heat activity with the inputs as payload.
|
||||
// On result: consume inputs; emit ProcessOutputCount objects of ProcessesInto with Quality from the result,
|
||||
// Origin Crafted(instigator), Mark.Overheated if the fault is present. OnSubstanceProcessed.
|
||||
|
||||
bool ServerShapePiece(APlayerState* Instigator, AStationActor* Station, const UPieceTypeDefinition& Type,
|
||||
const UCharacteristicDefinition& Char, TArray<FGuid> Objects);
|
||||
// station domain == Type->DomainTag; Char applicable to Type->PartTag; Char known to instigator;
|
||||
// Objects.Num() >= Type->SubstanceObjects, one substance, all at working temperature (the anvil reads the bed).
|
||||
// Begin the strike activity. On result: new FPieceInstance with SubstanceQuality = avg, SourceObjectIds,
|
||||
// CraftQuality = result.QualityContribution; consume objects; output buffer; OnPieceShaped.
|
||||
|
||||
bool ServerAssembleItem(APlayerState* Instigator, AAssemblyBench* Bench, const UItemFamilyDefinition& Family);
|
||||
// verdict = ValidateAssembly(Family, Bench->Staged, Config); if invalid reject(verdict.Reason) even if the
|
||||
// client's preview said otherwise. New FAssembledItem; Durability = MaxDurability; consume pieces (they cease
|
||||
// to exist as objects); spawn the item actor into the output buffer; OnItemAssembled; emit item_assembled.
|
||||
```
|
||||
|
||||
## From item to weapon
|
||||
|
||||
```
|
||||
[PROPOSED] A crafted item of a weapon family IS a weapon. Nothing about the melee ability, the damage funnel or the
|
||||
action bar knows whether the sword in hand was authored or crafted; both are an FWeaponProfile.
|
||||
```
|
||||
|
||||
```cpp
|
||||
USTRUCT()
|
||||
struct FWeaponContribution // on a piece type and on a characteristic: what this part adds
|
||||
{
|
||||
GENERATED_BODY()
|
||||
UPROPERTY(EditDefaultsOnly) float Damage = 0.f, ReachCm = 0.f, WindupSeconds = 0.f, SwingSeconds = 0.f;
|
||||
UPROPERTY(EditDefaultsOnly) float DamageMultiplier = 1.f; // characteristics multiply; piece types add
|
||||
};
|
||||
|
||||
// CraftingRules::MakeWeaponProfile, pure:
|
||||
// Damage = (Σ piece Type->Weapon.Damage) * Π characteristic DamageMultiplier
|
||||
// + QualityScore * Config.DamagePerQualityPoint // coherence is what makes it hit harder
|
||||
// ReachCm = Σ Type->Weapon.ReachCm (the blade supplies most of it), scaled by the blade piece's authored scale
|
||||
// Windup, Swing = Σ contributions, clamped to sane bounds
|
||||
// DamageType = the highest-value enchantment's DamageType if set, else Damage.Type.Physical
|
||||
// GrantedTags = ∪ enchantment GrantsTags // the funnel and future rules read these
|
||||
// bTwoHanded = the family says so, or the blade piece's scale is past its range's midpoint
|
||||
// Montage = Family.Montage; WeaponTag = Family.FamilyTag
|
||||
```
|
||||
|
||||
Equipping: an `AAssembledItemActor` whose family is a weapon offers `Interact.Verb.Equip` when it is on the
|
||||
ground or in your hand; the server builds an `AWeaponActor` from the profile, attaches it to the family's hand
|
||||
socket, applies every piece's enchantment `HolderEffect` to the wielder for as long as it is held, and drops the
|
||||
previously equipped weapon where you stand. One weapon equipped, whatever you carry. The combat step that proves
|
||||
this is step 13: a crafted sword's coherence score visibly changes the damage number on a dummy.
|
||||
|
||||
## Persistence shape
|
||||
|
||||
Nothing persists in these steps; the shapes are written down now because the data-contract rules are cheap on the
|
||||
first day and expensive later.
|
||||
|
||||
```
|
||||
FSubstanceRecord { FGuid Id; FPrimaryAssetId Type; float Quality; FProvenance Origin; TArray<FName> Marks; }
|
||||
FPieceRecord { FGuid Id; FPrimaryAssetId Type, Characteristic, Substance; float SubstanceQuality, CraftQuality;
|
||||
TArray<FGuid> SourceObjectIds; TArray<FPrimaryAssetId> Enchantments; } // no Value, no Durability
|
||||
FItemRecord { FGuid Id; FPrimaryAssetId Family; int32 Durability; TMap<FName, FGuid> Parts; } // current durability only
|
||||
- Content by primary asset id, tags by name. Derived fields recomputed on load, so a rebalanced config re-scores old items.
|
||||
- Pieces recorded once and referenced by id from items, so a partial write can never duplicate one.
|
||||
- Each record carries a schema_version and an adjacent migration function.
|
||||
```
|
||||
|
||||
## Networking
|
||||
|
||||
| State | Authority | Mechanism |
|
||||
| --- | --- | --- |
|
||||
| Substance and piece objects | Server | Replicated actors, dormant at rest, relevant by distance |
|
||||
| Station state, operators, buffers | Server | Replicated properties on the station |
|
||||
| Bench staging and the preview | Client previews, server verdicts | Same pure function on both; the client's preview is a question, never a claim |
|
||||
| Activity sessions and inputs | Server | Inputs are server RPCs; the strike is timestamped |
|
||||
| Heat grid | Server | 25 bytes at 5 Hz near, 1 byte far |
|
||||
| Assembled items | Server | Replicated actor; the item struct replicated to the owner for the panel |
|
||||
| Known characteristics | Server | On the player state, replicated to the owner |
|
||||
|
||||
## Telemetry
|
||||
|
||||
| Event | When | Payload |
|
||||
| --- | --- | --- |
|
||||
| `substance_processed` | Smelt completes | `type`, `count`, `quality_avg`, `faults` |
|
||||
| `piece_shaped` | Strike completes | `type`, `characteristic`, `substance`, `craft_quality`, `rounds`, `round_scores[]` |
|
||||
| `piece_enchanted` | Enchant applied | `piece_type`, `enchantment` |
|
||||
| `item_assembled` | Commit succeeds | `family`, `piece_ids[]`, `name`, `quality`, `synergy`, `substance_value`, `time_at_bench_s` |
|
||||
| `assembly_rejected` | Commit or insert refused | `family`, `reason`, `part` |
|
||||
| `activity_completed` | Any activity | `activity`, `operators`, `quality`, `auto_play`, `faults`, `contribution_by_player` |
|
||||
| `characteristic_discovered` | The moment | `characteristic`, `cause` |
|
||||
| `weapon_equipped` | Equip | `family`, `crafted` (bool), `damage`, `quality` |
|
||||
|
||||
`assembly_rejected` by reason is the friction signal on the crafting UX before anyone says "I didn't get it";
|
||||
`round_scores[]` against `auto_play` says whether the anvil is fun or a chore.
|
||||
|
||||
## Tests
|
||||
|
||||
- Automation, every rejection reason named: `CanFillSlot` for the four reasons; `ValidateAssembly` for missing,
|
||||
foreign and unreachable parts and for the guardless sword validating; `ComposeName` for the reference example,
|
||||
no prefix, no suffix, competing substances suppressing the core, the word limit and the stem collision;
|
||||
`ScoreCoherence` for synergy rising with matching themes and for completeness making optional parts worth it;
|
||||
`PartDurability` and `MaxDurability` for the dragonbone-hilt-oak-blade case; `MakeWeaponProfile` for a crafted
|
||||
sword landing inside the authored sword's numbers at mid quality; the heat LOD equivalence harness.
|
||||
- Functional: `FT_Crafting_Bench` spawns three pieces, inserts, commits and asserts the name and score; two clients
|
||||
see the same assembled mesh with the blade on the handle's socket; `FT_Crafting_Anvil` with no input completes
|
||||
at `BaselineScore`.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Q1. Is the known-characteristic set per player or per world?** Per player, on the player state: it travels
|
||||
with them and gives a visiting veteran something real to bring. Cheap to move if wrong.
|
||||
- **Q2. Two strikers: alternating or simultaneous?** Alternating first; it is the one that still works when the two
|
||||
players have very different reaction speeds.
|
||||
- **Q3. Hot-spot count and growth curve.** 3 to 6 spots and the radius numbers are authored per piece type and the
|
||||
feel lives entirely there. A greybox pass, not a decision.
|
||||
- **Q4. Where do pieces and items live beyond hands and station buffers?** Nowhere yet. Storage as a place
|
||||
(containers with slots, no auto-pull, no infinite chest) is the earlier design and is parked in Ideas until a
|
||||
step needs it. The two-hand rule and the object model already assume it.
|
||||
- **Q5. Alloys.** A kiln combining two raw types into one processed type wants `ProcessesInto` to become a small
|
||||
recipe struct. Not now, and **nobody hard-codes 1:1 processing** in the meantime.
|
||||
- **Q6. Does substance quality want a visible tell?** A dull versus bright ingot at a glance would make the smith's
|
||||
care legible across a room. Cheap (a tint lerp), competes with substance identity colour. An art call.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,445 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Networking
|
||||
|
||||
Owns the authority model, the responsiveness tiers, the posture on prediction and tick rate, the session shape,
|
||||
and the two seams (identity and persistence) that let the game outgrow one machine without a rewrite. It does
|
||||
**not** own gameplay: what a shove does is in [Combat.md](Combat.md), what an insert does in
|
||||
[Crafting.md](Crafting.md). Each feature spec states what is authoritative where; this document is the rule they
|
||||
all follow and the reasoning behind it.
|
||||
|
||||
Read [Architecture.md](Architecture.md) first.
|
||||
|
||||
## The decision
|
||||
|
||||
```
|
||||
[DECIDED] Server-authoritative from the first line, on the engine's own replication, with the dedicated server
|
||||
as the real target.
|
||||
|
||||
There is no listen-server mode in the design and no "are we multiplayer" branch anywhere in gameplay code.
|
||||
Standalone and listen-server play exist because the editor offers them and they are convenient; every actor,
|
||||
subsystem and RPC is written as if the server were a separate process on another machine, and it is tested that
|
||||
way from step 1 with Play In Editor's dedicated server option.
|
||||
|
||||
The engine's actor model already is this posture: AGameModeBase exists only on the server, properties replicate
|
||||
one way, Server RPCs need an owning connection, movement is server-corrected. The work is to never undermine it,
|
||||
not to build it.
|
||||
```
|
||||
|
||||
Why this and not the earlier project's listen server with a relay: a host-authoritative peer is the one topology
|
||||
that cannot grow. It ties the simulation to a player's machine, makes the host able to cheat freely, and turns
|
||||
every later step toward a persistent shared world into a rewrite. Choosing the server posture now costs nothing
|
||||
(the engine defaults to it) and closes no door.
|
||||
|
||||
## The authority table
|
||||
|
||||
The rollup. Each feature spec carries its own rows and the two must agree.
|
||||
|
||||
| State | Authority | Mechanism |
|
||||
| --- | --- | --- |
|
||||
| Player movement, jump, sprint, crouch | Server, owner predicts | Character movement's prediction and correction |
|
||||
| Look pitch and head yaw | Owner writes | Replicated bytes, for the rig only; the server never reads them to decide anything |
|
||||
| Camera | Local only | Never replicated, saved or read by the server |
|
||||
| Ability activation | Client requests, server decides | GAS prediction keys; the owner's animation starts immediately |
|
||||
| Damage, health, downed, dead | Server | The one execution; replicated attributes and tags |
|
||||
| Hit detection | Server, using the owner's replicated aim | A little client trust in the aim; fine in cooperative play |
|
||||
| Cooldowns | Server, client predicts | Cooldown effects; the HUD shows the prediction and takes the correction |
|
||||
| Enemy brains and spawning | Server | StateTree ticks on the server; bodies replicate |
|
||||
| Interaction | Client requests, server re-validates reach and permission | One RPC on the player's own component |
|
||||
| Held objects | Server | Attached to the carrier, `CarriedBy` replicated; no physics while held |
|
||||
| Dropped and thrown objects | Server | Server physics, replicated movement, predictive interpolation |
|
||||
| Station state, buffers, operators | Server | Replicated properties |
|
||||
| Crafting previews | Client computes | Hypotheticals; a wrong preview is a UI bug, never an exploit |
|
||||
| Crafting verdicts and commits | Server | The same pure function, on the server, is the only one that counts |
|
||||
| Activity inputs | Server | RPCs; the anvil strike is timestamped |
|
||||
| Attributes and statuses on a body | Server; effects from a body's own abilities predicted | The stat block replicates; a server-applied speed change costs one small correction, see [Stats.md](Stats.md) |
|
||||
| Player identity, class, known discoveries | Server | On the player state; replicated to the owner |
|
||||
| Session membership | Server | Game mode and game state |
|
||||
|
||||
## Three responsiveness tiers
|
||||
|
||||
Several players in one room, all touching the same physical objects. The question is what each may do instantly
|
||||
and what has to wait for the server, and one test assigns the tier: **does a rollback have a visible victim?**
|
||||
|
||||
```
|
||||
TIER 1: PURELY LOCAL, never networked authority
|
||||
camera and look, interaction targeting and prompts, every crafting preview, HUD, and your own movement
|
||||
through the engine's prediction. Remote players interpolate; head and spine aim replicate so gaze survives.
|
||||
|
||||
TIER 2: PREDICT THE ANIMATION, NOT THE OWNERSHIP
|
||||
pick up, insert, take output, equip, hand over, fire a projectile.
|
||||
The reach or throw ANIMATION starts instantly and locally. The object changes hands when the server confirms.
|
||||
Predicting your own arm costs nothing when wrong. Predicting who got the last ingot costs a snatch-back the
|
||||
whole room can see, and with substances as individual contested objects that case is frequent, not theoretical.
|
||||
|
||||
TIER 3: TIMESTAMPED AND LAG-COMPENSATED
|
||||
scored activity inputs: the anvil strike, and nothing else today.
|
||||
The client stamps the input; the server evaluates it against what was true then. Because the zone's radius is
|
||||
a deterministic function of time, that is arithmetic, not a rewind buffer: radius(clientTime), clamped to a
|
||||
250 ms window, never scoring better than the best achievable at the earliest legitimate arrival.
|
||||
```
|
||||
|
||||
**Standing rule:** no rollback of world state a player can already see. If a design would need one, move the
|
||||
decision to the server and cover the round trip with animation (tier 2), or make it a pure function of time
|
||||
(tier 3).
|
||||
|
||||
## Ask, then show
|
||||
|
||||
Every commit-shaped moment in the game (an item assembled, a substance processed, a weapon equipped, later a
|
||||
purchase or a payout) already has a beat of feedback: the assembled sword materialising on the bench, the ingot
|
||||
coming out of the bed. That beat is the latency budget. A server round trip fits inside it with room to spare, so
|
||||
the client asks, waits for the commit, and then shows the result. No prediction, no reconciliation path, no
|
||||
possibility of a corrected number. **Never show a number as final before it is committed.** A UI that does not lie
|
||||
needs no reconciliation.
|
||||
|
||||
## Posture
|
||||
|
||||
This is a game played with friends, not a tick-perfect competition. Nothing here is adversarial, ranked or decided
|
||||
by milliseconds, so the bar is "feels good to a few people in a room", and anything argued for on competitive
|
||||
integrity grounds is arguing from a genre this game is not in.
|
||||
|
||||
| Not building | Why not |
|
||||
| --- | --- |
|
||||
| Rollback netcode or deterministic lockstep | Solves fighting-game and RTS problems; this is a cooperative sim |
|
||||
| High tick rates | 20 to 30 Hz is ample when one input in the game is timing-scored |
|
||||
| Client prediction beyond own movement and animation | Every further step buys responsiveness we do not need and costs rollbacks players can see |
|
||||
| Lag compensation anywhere but the anvil | Nothing else scores timing |
|
||||
| An anti-cheat arms race | Server authority removes the motive: no PvP, nothing to win by cheating but spoiling your own game |
|
||||
|
||||
The tie-breaker when a choice is close: prefer the option that fails gracefully and legibly over the one that is
|
||||
technically optimal. Rejections explain themselves, nothing is destroyed silently, a stalled station says why.
|
||||
|
||||
## Prediction hygiene
|
||||
|
||||
- Test every replicated feature with the editor's network emulation at 100 ms and 5 % loss before calling it
|
||||
done, and `p.NetShowCorrections 1` on. A correction you can see is a bug in a saved move or an ability's
|
||||
prediction, not a fact of life.
|
||||
- Server RPCs go through components on actors the client owns: the player's pawn, player state and controller.
|
||||
Never through a prop. An RPC on an unowned actor silently drops.
|
||||
- Every `Server` RPC validates its inputs and its instigator. `WithValidation` is not enough; the body re-checks
|
||||
reach, permission and state, because the client's claim is a claim.
|
||||
- Replicated arrays with per-element churn (buffers, hands, a future inventory) use `FFastArraySerializer`, not a
|
||||
plain `TArray`, so a change sends the element and not the array.
|
||||
- Actors at rest are dormant (`DORM_DormantAll`, flushed on change). A room of two hundred ingots must cost nothing
|
||||
while nobody touches them.
|
||||
- Relevancy is by distance from day one (`NetCullDistanceSquared` on every replicated actor that is not a player).
|
||||
A world larger than one room cannot afford everything being relevant to everyone, and a rule that exists from the
|
||||
first actor is free.
|
||||
- Never do per-client work on the server that scales with the square of the player count. If a system needs
|
||||
every player to know about every player, that is a design flag.
|
||||
|
||||
## Sessions
|
||||
|
||||
There is one session shape: a server running a map, players joining it. The game mode handles login, spawns a
|
||||
`APlayerCharacter` per controller at a spawn point and re-spawns on death. Party size is the game state's player
|
||||
count and is read by anything that scales (the encounter budget) and stamped onto every telemetry envelope.
|
||||
|
||||
Steam, lobbies, invitations and matchmaking are not built in these steps and are not designed here. The earlier
|
||||
project's Steam lobby and friends-list join are in [`../Ideas.md`](../Ideas.md); when they arrive they sit in front
|
||||
of the session and never inside gameplay.
|
||||
|
||||
**Two instances on one machine:** Play In Editor with two clients and the dedicated server option is the everyday
|
||||
test. A packaged server (`<Project>Server` target) needs the engine built from source and arrives when the first
|
||||
packaged build does; nothing in the code changes for it.
|
||||
|
||||
## Identity
|
||||
|
||||
```
|
||||
[DECIDED] A player is an identity that outlives a body, a session and a server. Everything about a player that
|
||||
would matter tomorrow lives on the player state or behind the persistence provider, never on the character actor.
|
||||
```
|
||||
|
||||
Concretely: `APlayerState` carries a `FGuid PlayerId` minted by the server on first login (hashed for telemetry,
|
||||
never a platform id or a name), the class kit, the ability system component with its attributes and cooldowns,
|
||||
known characteristics, and later progression. The character is a body the world lends the player. A body dying,
|
||||
a map changing or a player reconnecting never loses any of it.
|
||||
|
||||
## Persistence
|
||||
|
||||
```
|
||||
[DECIDED] Persistence is behind one provider interface with a local-file implementation. Gameplay writes records
|
||||
to the provider and never to disk, so a service-backed provider is a second implementation and not a refactor.
|
||||
```
|
||||
|
||||
```cpp
|
||||
// Source/<Project>Core/Persistence/PersistenceProvider.h
|
||||
class IPersistenceProvider
|
||||
{
|
||||
public:
|
||||
virtual ~IPersistenceProvider() = default;
|
||||
virtual bool Load(const FString& Key, FString& OutJson) = 0; // records are JSON snapshots, idempotent, never deltas
|
||||
virtual bool Save(const FString& Key, const FString& Json) = 0; // atomic: temp file then move
|
||||
virtual void Remove(const FString& Key) = 0;
|
||||
};
|
||||
// FLocalFilePersistenceProvider writes under FPaths::ProjectSavedDir()/Records/. Registered on the
|
||||
// persistence game-instance subsystem at startup. Nothing is persisted in steps 1 to 15; the seam exists so the
|
||||
// first thing that is (a player's class, a crafted sword) does not choose the file system by accident.
|
||||
```
|
||||
|
||||
Record rules, from the first record: identity is `FGuid`; content is referenced by primary asset id; every record
|
||||
carries a `schema_version` with an adjacent migration function and a monotonic `revision`; derived fields are
|
||||
recomputed on load; save-facing records are separate types from runtime instances.
|
||||
|
||||
## Telemetry over the wire
|
||||
|
||||
Every peer emits its own events. There is no forwarding to the server, because the interesting questions (did this
|
||||
client's frame rate drop, which player did the shoving, did this preview disagree with the verdict) are per-peer
|
||||
by nature. `is_server` and `player_id` on the envelope let a run be reassembled later. A `session_id` is minted
|
||||
by the server and adopted by every client on join, so one session's events are one session and not four.
|
||||
|
||||
## Tests
|
||||
|
||||
- Functional, run with emulation on: two clients see the same held object on a third's body; a request from beyond
|
||||
reach is refused; an ability activated at 100 ms shows no visible correction; a thrown object lands within
|
||||
tolerance on every peer; the anvil's strike scores the same for a client at 0 ms and one at 150 ms who pressed
|
||||
at the same moment.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Q1. Reconnect.** A dropped player should reconnect into the same session with the world exactly as it
|
||||
continued without them. The identity decision makes it possible; the session shape does not yet do it. Write the
|
||||
rule down before it is discovered in a playtest.
|
||||
- **Q2. Voice.** Proximity voice is a large part of what makes a room of friends a room. Not in these steps;
|
||||
parked in Ideas.
|
||||
- **Q3. Seamless travel.** One map today. Travelling between maps with the party and the player state intact is
|
||||
the engine's seamless travel and arrives with the second map.
|
||||
- **Q4. Replication at scale.** The Replication Graph and the newer Iris system exist for worlds with many actors
|
||||
and many players. Not now; the dormancy and relevancy rules above are what keep the option cheap.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Specifications
|
||||
|
||||
These documents are written for an implementer that reads carefully and lifts code, which today means Claude and
|
||||
whoever reviews its pull requests. They are not the human summary; that is [`../Design.md`](../Design.md). Read the
|
||||
summary first, then [`Architecture.md`](Architecture.md), then the document for the system you are touching.
|
||||
|
||||
Each spec is pseudocode plus the reasoning behind it. The pseudocode is C++-shaped Unreal code and it is meant to be
|
||||
implemented, not admired: a class skeleton here is the class we expect to find in `Source/`, with the same name, the
|
||||
same members and the same comments about why. Where a spec says `// pure`, that is a promise about testability the
|
||||
real code is expected to keep.
|
||||
|
||||
## Documents
|
||||
|
||||
| Doc | Owns | Read when |
|
||||
| --- | --- | --- |
|
||||
| [Architecture.md](Architecture.md) | Modules, lifetimes, authority, data assets, gameplay tags, the C++/Blueprint boundary, testing, conventions | Before writing any code |
|
||||
| [Stats.md](Stats.md) | The one stat block every body carries, effects and their sources (abilities, areas, surfaces, items, the world), stacking, counters | Step 5, and anything that changes a number on a body |
|
||||
| [Movement.md](Movement.md) | The character, the movement component, the look model, camera modes, input, the gym level | Steps 3 to 5 |
|
||||
| [Interaction.md](Interaction.md) | The one interaction system every prop uses, carrying, throwing | Step 6, and any prop |
|
||||
| [Combat.md](Combat.md) | Attributes, the one damage funnel, melee hit detection, enemies, abilities, kits, downed and revive | Steps 7 to 11 |
|
||||
| [Crafting.md](Crafting.md) | Families, parts, pieces, traits, substances, the pure rules, stations, activities, the link back to combat | Steps 12 to 15 |
|
||||
| [Networking.md](Networking.md) | The authority model, the responsiveness tiers, prediction posture, persistence and identity seams | Any replicated feature |
|
||||
| [Telemetry.md](Telemetry.md) | The sink, the envelope, the event catalogue | Any feature (every feature emits) |
|
||||
| [UI.md](UI.md) | HUD, prompts, the theme asset, world-space text, localisation | Any screen or prop text |
|
||||
|
||||
## Notation
|
||||
|
||||
```
|
||||
Server*(...) runs on the authority only. Validates the instigator and re-checks every precondition,
|
||||
then mutates. A UFUNCTION(Server, Reliable) RPC or a plain method guarded by HasAuthority().
|
||||
Get* / Preview* side-effect-free query. Safe to call from UI every frame, on any peer.
|
||||
// pure deterministic, no world access, unit-tested, reused by the client preview and the server verdict.
|
||||
On* (delegate) a multicast delegate a service raises. UI subscribes; UI never polls.
|
||||
*_Cosmetic a BlueprintAssignable hook a designer may wire. Never load-bearing.
|
||||
UFooDefinition a UPrimaryDataAsset, authored content.
|
||||
FFooInstance runtime state with identity (an FGuid).
|
||||
FFooRecord the persisted snapshot of an instance. Separate type from the instance on purpose.
|
||||
TAG_Foo_Bar a native gameplay tag, "Foo.Bar", declared once in code. Never a string literal at a call site.
|
||||
```
|
||||
|
||||
Status markers inside a spec:
|
||||
|
||||
```
|
||||
[DECIDED] settled, with the reason. Logged in ../Decisions.md.
|
||||
[PROPOSED] the recommended shape, not yet built on. Becomes [DECIDED] when the step that builds it closes.
|
||||
[SALVAGED] an idea carried over from the two earlier projects, reshaped for this one. See ../Ideas.md.
|
||||
Qn an open question, collected at the end of each document.
|
||||
```
|
||||
|
||||
## Cross-cutting rules
|
||||
|
||||
Every spec obeys these. They are stated once here so no spec has to restate them, and any spec that appears to
|
||||
break one is wrong.
|
||||
|
||||
1. **The server decides. The client asks, and predicts only its own body.** Every mutation has an explicit
|
||||
instigator and is re-validated on the authority, even when the game is being played alone. Standalone and
|
||||
listen-server play are development conveniences; the dedicated server is the real target and nothing may assume
|
||||
it is absent. See [Networking.md](Networking.md).
|
||||
2. **One formula, many consumers.** A preview and the real thing call the same pure function. Damage, coherence,
|
||||
naming, assembly validation, spec matching and interaction prompts all follow this. If a UI-side "fast copy" of a
|
||||
rule ever appears, it is a bug.
|
||||
3. **One funnel per kind of consequence.** All damage goes through one execution. All interactions go through one
|
||||
service. All activity quality goes through one result type. A second path is where rules quietly diverge.
|
||||
4. **Rules are plain C++ in the core module; actors and components adapt them to the world.** The core module knows
|
||||
no `AActor`. This is what makes rules testable without a level and runnable on a headless server.
|
||||
5. **Content is data, addressed by gameplay tag or primary asset id, never by string.** A new sword, enemy or
|
||||
substance is an asset, not a code change. If adding a variant needs code, the model is wrong.
|
||||
6. **Every rejection carries a reason a player can read.** A refused interaction, an illegal assembly, a blocked
|
||||
ability: each returns a reason tag that the prompt or panel shows. Silent refusal is a failed step.
|
||||
7. **Nothing is destroyed silently.** Dropped things persist on the floor, full containers refuse with a reason,
|
||||
thrown things land where they land. The rare deliberate deletion is announced before it happens.
|
||||
8. **Bonuses, never locks.** A class, a level or a piece of gear changes speed, quality or numbers. It never changes
|
||||
what a station accepts or what an assembly permits, and it never grants another class's signature ability.
|
||||
9. **Solo viability is a gate, not a mode.** Every rule is checked against one player. Where it costs a solo player
|
||||
something, the spec says so and names the tuning knob.
|
||||
10. **Derived state is recomputed on load, never trusted from storage.** A rebalance re-scores old items everywhere.
|
||||
11. **Telemetry from the first line.** Every feature emits through the injected sink into a catalogue that exists
|
||||
before there is anyone to measure. Retrofitting emit calls is the tax this avoids.
|
||||
12. **Determinism where it is promised.** Anything generated from a seed uses its own `FRandomStream`, integer
|
||||
coordinates and ordered collections, and never reads physics, time or frame counts. Nothing today is generated
|
||||
from a seed; the rule exists so the first thing that is inherits it.
|
||||
13. **One stat block; every outside influence is an effect on it.** Every body carries the same attribute set.
|
||||
A buff, a debuff, ground, weight, gear and base values are all gameplay effects applied to it; counters are
|
||||
tags and attributes on the receiver. No system keeps its own multiplier. See [Stats.md](Stats.md).
|
||||
|
||||
## How a spec relates to the steps
|
||||
|
||||
[`../Steps.md`](../Steps.md) schedules; these documents specify. A step links to the sections it builds and states
|
||||
what proves it done. If a step and its spec disagree, the spec wins and the step is corrected. A spec that turns out
|
||||
wrong in the building is corrected in the same pull request as the code, under a **What was built, and where it
|
||||
differs** heading at the end of the affected document, so the next reader is not misled by the sketch.
|
||||
@@ -0,0 +1,307 @@
|
||||
# Stats and effects
|
||||
|
||||
Owns the one stat block every body carries, the vocabulary of effects that change it, where effects come from
|
||||
(abilities, areas, surfaces, items, the world), how they stack, and the counters (immunity, resistance, cleanse)
|
||||
that stop them. It does **not** own what any stat means to a system: the movement component reads `MoveSpeed`
|
||||
([Movement.md](Movement.md)), the damage funnel reads `Armour` ([Combat.md](Combat.md)), the activity runtime
|
||||
reads `WorkSpeed` ([Crafting.md](Crafting.md)). This document says what the numbers are and how they change; the
|
||||
system docs say what they do.
|
||||
|
||||
Read [Architecture.md](Architecture.md) first. Built in step 5, before combat, because the first debuff and the
|
||||
first patch of mud must land on a system that already exists rather than each inventing one.
|
||||
|
||||
## The problem this exists to prevent
|
||||
|
||||
Every system that can affect a body is tempted to keep its own number: a speed multiplier on the movement
|
||||
component, a slow timer on the enemy brain, a carry penalty on the carry component, a mud check in the character.
|
||||
Then the goblin is not slowed by the mud the player is slowed by, the haste buff cannot cancel the carry penalty
|
||||
because they live in different places, and nobody can say why a body is at forty percent speed. That is the
|
||||
spaghetti, and it is not hypothetical: the first draft of [Movement.md](Movement.md) had exactly that multiplier
|
||||
map. It is gone.
|
||||
|
||||
```
|
||||
[DECIDED] One stat block, and everything with a body has it.
|
||||
|
||||
Players, enemies, NPCs: one UAttributeSet on the body's ability system component, the same class for all of
|
||||
them. A thing that does not have one cannot be affected, and giving it one is how a thing is made affectable.
|
||||
A wooden crate that should burn gets a stat block; a wall that should not does not.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] Every outside influence is a gameplay effect on the block. No exceptions.
|
||||
|
||||
A buff, a debuff, muddy ground, carried weight, an aura, gear, a class's base numbers, being downed: each is a
|
||||
UGameplayEffect applied to the body's component. No system writes an attribute. No system keeps its own list
|
||||
of modifiers. If you find yourself adding `float SpeedMultiplier` to a component, stop: it is an effect.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] Counters live on the receiver, as tags and attributes.
|
||||
|
||||
Immunity blocks an effect by tag. Resistance scales an effect by attribute. Cleanse removes effects by tag.
|
||||
A counter is therefore something a kit, an enchantment, a piece of gear or an ability GRANTS, and it works
|
||||
against every source of that status at once, including ones written next year. Every status has a visible
|
||||
tell, and being immune to one is visible too.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] The same status from several sources: the strongest applies and the longest remaining duration is
|
||||
kept. Different statuses multiply. A slow and a haste both apply and the product is the speed.
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
Source/<Project>Core/Stats/
|
||||
├── StatMath.h / .cpp // pure: Resisted(magnitude, kind, resist)
|
||||
└── CarryMath.h / .cpp // pure: SpeedFactor(weight, capacity)
|
||||
|
||||
Source/<Project>/Stats/
|
||||
├── StatBlockAttributeSet.h // the block
|
||||
├── StatBlockDefaults.h // FStatBlockDefaults
|
||||
├── ExtendedAbilitySystemComponent.h // input-tag routing (Combat.md), ApplyDefaults, ApplyStatus
|
||||
├── EffectVolume.h // areas
|
||||
└── PhysicalMaterialWithTags.h // surfaces
|
||||
|
||||
Content/Stats/
|
||||
├── Effects/GE_InitStats, GE_Status_Slow, GE_Status_Haste, GE_Encumbered, GE_Downed, GE_Immune_Slow, GE_Cleanse ...
|
||||
├── Cues/GC_Status_*, GC_Status_Blocked
|
||||
└── DT_StatusIcons // Status.* tag -> icon, read by the HUD
|
||||
```
|
||||
|
||||
## The block
|
||||
|
||||
One `UAttributeSet` subclass, `UStatBlockAttributeSet`, in `Source/<Project>/Stats/`. Multipliers have a
|
||||
baseline of one; flat values of zero; fractions run from zero to one. Every attribute clamps in
|
||||
`PreAttributeChange` to the range in the table, so no effect can push a body into nonsense.
|
||||
|
||||
| Attribute | Kind | Baseline | Range | Read by |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `Health`, `MaxHealth` | flat | from defaults | 0 .. Max | Combat: downed at zero |
|
||||
| `HealthRegen` | flat per second | 0 | 0 .. | a periodic regen effect, when one exists |
|
||||
| `Stamina`, `MaxStamina`, `StaminaRegen` | flat | from defaults | 0 .. Max | reserved; nothing draws yet |
|
||||
| `MoveSpeed` | multiplier | 1 | 0 .. 3 | Movement: every state's speed times this |
|
||||
| `JumpPower` | multiplier | 1 | 0 .. 3 | Movement: jump velocity times this |
|
||||
| `AttackPower` | multiplier | 1 | 0 .. 5 | Combat: outgoing damage |
|
||||
| `AttackSpeed` | multiplier | 1 | 0.25 .. 3 | Combat: wind-up and swing divided by this |
|
||||
| `Armour` | flat | 0 | 0 .. | Combat: mitigation on physical damage |
|
||||
| `MagicResist` | fraction | 0 | 0 .. 0.9 | Combat: mitigation on magic damage |
|
||||
| `StatusResist` | fraction | 0 | 0 .. 0.9 | Stats: scales every incoming status toward neutral |
|
||||
| `KnockbackResist` | fraction | 0 | 0 .. 1 | Combat: scales impulses; 1 is unmovable |
|
||||
| `WorkSpeed` | multiplier | 1 | 0.25 .. 3 | Crafting: activity baseline duration divided by this |
|
||||
| `WorkQuality` | flat | 0 | -0.5 .. 0.5 | Crafting: added to an activity's quality floor |
|
||||
| `CarryCapacity` | flat | 1 | 0 .. | Interaction: weight a body carries before slowing |
|
||||
| `IncomingDamage`, `IncomingHeal` | meta | | | the funnel writes, `PostGameplayEffectExecute` consumes, never replicated |
|
||||
|
||||
The block is the whole list of what an outside influence may touch. A system that wants to be affected by
|
||||
something not on it adds an attribute here, in its own pull request, with a row in this table and a reader. A
|
||||
system that wants to affect something reads this table to learn the name, and never invents a second one.
|
||||
|
||||
### Base values are an effect too
|
||||
|
||||
```cpp
|
||||
USTRUCT(BlueprintType)
|
||||
struct FStatBlockDefaults // the numbers a designer authors on a kit or an enemy definition
|
||||
{
|
||||
GENERATED_BODY()
|
||||
UPROPERTY(EditDefaultsOnly) float MaxHealth = 100.f;
|
||||
UPROPERTY(EditDefaultsOnly) float MaxStamina = 100.f;
|
||||
UPROPERTY(EditDefaultsOnly) float Armour = 0.f;
|
||||
UPROPERTY(EditDefaultsOnly) float AttackPower = 1.f;
|
||||
UPROPERTY(EditDefaultsOnly) float MoveSpeed = 1.f;
|
||||
UPROPERTY(EditDefaultsOnly) float CarryCapacity = 1.f;
|
||||
// and so on for every non-meta attribute, each with the table's baseline as its default
|
||||
};
|
||||
|
||||
// UExtendedAbilitySystemComponent
|
||||
void ApplyDefaults(const FStatBlockDefaults& Defaults);
|
||||
// One GE_InitStats with an Override modifier per attribute, magnitudes SetByCaller from the struct, applied
|
||||
// once on grant (a kit) or spawn (an enemy), then Health = MaxHealth and Stamina = MaxStamina.
|
||||
// One way in, so that everything after the defaults is an effect like everything else.
|
||||
```
|
||||
|
||||
A kit's `BaseStats` and an enemy definition's `BaseStats` are both this struct. Designers edit numbers; code
|
||||
applies them through one effect; there is no per-kit or per-enemy effect asset to keep in step.
|
||||
|
||||
## Effects
|
||||
|
||||
An effect in this project is a `UGameplayEffect` asset that follows one shape, so that every reader (the HUD,
|
||||
the cleanse, the immunity, telemetry) can treat them alike:
|
||||
|
||||
| Part | Engine mechanism | Rule here |
|
||||
| --- | --- | --- |
|
||||
| What it is | asset tags (`UAssetTagsGameplayEffectComponent`) | exactly one `Status.*` tag; `Status.Buff` or `Status.Debuff` as its parent |
|
||||
| What it grants while active | granted tags (`UTargetTagsGameplayEffectComponent`) | the same `Status.*` tag, so `HasTag(Status.Slow)` answers "is this body slowed" |
|
||||
| What it changes | modifiers | attributes from the table only; multiply for multipliers, add for flats |
|
||||
| How strong | magnitude | `SetByCaller` under `Data.Magnitude`, or attribute-based when the source's stats matter |
|
||||
| How long | duration policy | instant (a hit), timed (a debuff), infinite (ground, weight, a state); never a hand-rolled timer |
|
||||
| Over time | period | periodic effects tick the funnel or a regen; never a tick in a component |
|
||||
| What it looks like | gameplay cues | `GameplayCue.Status.<Name>` while active: the visible tell, on every peer |
|
||||
| Who it stacks with | stacking | aggregate by target, limit one; the helper below decides who wins |
|
||||
|
||||
```cpp
|
||||
// UExtendedAbilitySystemComponent, the one helper every status goes through
|
||||
FActiveGameplayEffectHandle ApplyStatus(TSubclassOf<UGameplayEffect> Status, float Magnitude, float DurationSeconds,
|
||||
const FGameplayEffectContextHandle& Context);
|
||||
// SERVER, or a locally predicted ability applying to its own owner.
|
||||
// 1. If this body is immune (the effect's asset tags match an active immunity), the engine blocks it and raises
|
||||
// OnImmunityBlockGameplayEffectDelegate; play GameplayCue.Status.Blocked, emit status_blocked, return null.
|
||||
// 2. Magnitude = StatMath::Resisted(Magnitude, kind, StatusResist): mult' = 1 + (mult - 1) * (1 - resist);
|
||||
// flat' = flat * (1 - resist). Pure, in the core module, tested.
|
||||
// 3. If an active effect with the same Status.* tag exists: keep the stronger magnitude and the longer remaining
|
||||
// duration, refreshed, and return the existing handle. Strongest wins; durations do not add.
|
||||
// 4. Otherwise apply with SetByCaller Data.Magnitude and Data.Duration. Emit status_applied.
|
||||
```
|
||||
|
||||
The `Status.*` vocabulary, top level. A status is a name for an effect's *kind*, not for its source: a slow from
|
||||
mud and a slow from a frost bolt are both `Status.Slow` and the counter for one is the counter for the other.
|
||||
|
||||
```
|
||||
Status.Buff.* Haste, Fortified, Empowered, Regenerating, Focused (work speed), Steady (work quality)
|
||||
Status.Debuff.* Slow, Rooted, Weakened, Exposed (armour down), Poisoned, Burning, Marked, Taunted, Stunned, Encumbered
|
||||
```
|
||||
|
||||
`State.*` tags are different and stay: they describe what a body *is* (`State.Downed`, `State.Carrying`,
|
||||
`State.Dodging`, `State.Invulnerable`), granted by the systems that own those states. Some states are also
|
||||
effects (downed applies `GE_Downed`, which overrides `MoveSpeed` to zero); the tag says what the body is, the
|
||||
effect says what it does to the numbers.
|
||||
|
||||
## Where effects come from
|
||||
|
||||
Five sources, and every one ends in the same call on the receiver's component.
|
||||
|
||||
| Source | Mechanism | Who applies | Example |
|
||||
| --- | --- | --- | --- |
|
||||
| **An ability** | the ability applies to its target or itself | server; the owner predicts self-applied ones | Haste on self; Weakened on a struck enemy; Taunted on everything in range |
|
||||
| **An area** | `AEffectVolume`: an actor with an overlap shape, an effect class and a magnitude; applies on enter, removes on leave, with the handle kept per body | server | a frost cloud, a healing circle, a poison bog |
|
||||
| **A surface** | `UPhysicalMaterialWithTags` on the floor material carries `Surface.*` tags and an effect; the movement component's ground trace applies it when the surface changes and removes it when it changes again | server | mud slows, ice removes braking, a hot plate burns |
|
||||
| **An item or a kit** | granted on equip or grant, removed on unequip; infinite duration | server | a kit's base stats; an enchanted blade's `Immune.Status.Burning`; a class's `WorkSpeed` |
|
||||
| **The world** | the same as an ability, from a non-body instigator | server | fall damage through the funnel; carried weight through `GE_Encumbered` |
|
||||
|
||||
```cpp
|
||||
UCLASS()
|
||||
class AEffectVolume : public AActor
|
||||
{
|
||||
GENERATED_BODY()
|
||||
UPROPERTY(EditAnywhere) TSubclassOf<UGameplayEffect> Effect; // a Status.* effect
|
||||
UPROPERTY(EditAnywhere) float Magnitude = 0.6f; // Data.Magnitude
|
||||
UPROPERTY(EditAnywhere) bool bAffectsPlayers = true, bAffectsEnemies = true;
|
||||
UPROPERTY(VisibleAnywhere) TObjectPtr<UShapeComponent> Shape;
|
||||
// Server: OnActorBeginOverlap -> if the actor has a component, ApplyStatus(Effect, Magnitude, infinite) and
|
||||
// remember the handle by body; OnActorEndOverlap -> RemoveActiveGameplayEffect(handle). A body that dies inside
|
||||
// is cleaned up by the component's own teardown. Nothing here ticks.
|
||||
};
|
||||
|
||||
UCLASS()
|
||||
class UPhysicalMaterialWithTags : public UPhysicalMaterial
|
||||
{
|
||||
GENERATED_BODY()
|
||||
UPROPERTY(EditAnywhere) FGameplayTagContainer Tags; // Surface.Mud, Surface.Ice, Surface.Hot
|
||||
UPROPERTY(EditAnywhere) TSubclassOf<UGameplayEffect> SurfaceEffect; // optional: what standing on it does
|
||||
UPROPERTY(EditAnywhere) float SurfaceMagnitude = 1.f;
|
||||
};
|
||||
// UExtendedCharacterMovement::UpdateGroundSurface(), server, on floor change (a short trace with
|
||||
// bReturnPhysicalMaterial at ~5 Hz, the same trace footsteps will use): if the surface's effect differs from the
|
||||
// one applied, remove the old and ApplyStatus the new. Enemies use the same movement component, so the goblin
|
||||
// is slowed by the same mud, which is the whole point.
|
||||
```
|
||||
|
||||
Carried weight is the world case: `UCarryComponent` applies `GE_Encumbered` on pick-up with a magnitude from
|
||||
`CarryMath::SpeedFactor(weight, CarryCapacity)` (pure, in the core module) and removes it on drop. A haste that
|
||||
multiplies `MoveSpeed` therefore counteracts a heavy crate exactly as it counteracts mud, because they are the
|
||||
same number.
|
||||
|
||||
## Counters
|
||||
|
||||
Three kinds, each doing one thing, each granted like any other effect so that a kit, an enchantment or a piece of
|
||||
gear can hand it out.
|
||||
|
||||
| Counter | Mechanism | Effect on the incoming status | Example grant |
|
||||
| --- | --- | --- | --- |
|
||||
| **Immunity** | an infinite `GE_Immune_<Status>` with an immunity component (`UImmunityGameplayEffectComponent`) matching `Status.<Name>` | blocked entirely; the blocked cue plays; nothing is applied | a boss immune to `Status.Taunted`; boots granting `Immune.Status.Slow`; a dodge's `State.Invulnerable` |
|
||||
| **Resistance** | the `StatusResist` attribute (and `MagicResist`, `KnockbackResist` for their kinds) | scaled toward neutral by the helper before application | a Warrior's base 0.25 status resist; a Fortified buff |
|
||||
| **Cleanse** | an instant effect with a remove-by-tag component (`URemoveOtherGameplayEffectComponent`) matching `Status.Debuff` | every active debuff removed | the Cleric's Mend removes one debuff; a shrine removes all |
|
||||
| **Suspension** | ongoing tag requirements on the status (`UTargetTagRequirementsGameplayEffectComponent`) | the effect stays applied but stops modifying while a tag is present | `Status.Slow` suspended while `State.Dodging` |
|
||||
|
||||
Immunity tags are `Immune.Status.<Name>` and `Immune.Damage.<Type>`; the damage funnel reads the second kind and
|
||||
discards matching damage, so fire immunity is one tag and not a branch in every fire ability. Being immune is
|
||||
never silent: the blocked cue is the "Immune" flash every player has seen in every game with a status system.
|
||||
|
||||
**Counters are obvious by construction.** A player learns that mud slows and that the frost cloud slows; the
|
||||
same boots stop both, because both are `Status.Slow`. If two things that feel the same need different counters,
|
||||
they are different statuses and should be named so.
|
||||
|
||||
## Stacking
|
||||
|
||||
- **One status class per kind.** Every slow is `GE_Status_Slow`; a frost bolt and a mud patch differ in magnitude,
|
||||
duration and cue context, never in class. This is what makes the helper's strongest-wins rule possible without
|
||||
a custom aggregator.
|
||||
- **Strongest wins within a status**, longest remaining duration is kept, durations never add. Three goblins
|
||||
hitting you with the same slow is one slow.
|
||||
- **Different statuses multiply.** `MoveSpeed` is the product of every active multiplier on it; a 0.6 slow and a
|
||||
1.3 haste give 0.78. That is the engine's default aggregation for multiplicative modifiers and it is kept.
|
||||
- **Flat values add.** Armour from gear and Armour from Fortified sum.
|
||||
- **Overrides win and do not stack.** Downed overrides `MoveSpeed` to zero whatever else is applied.
|
||||
|
||||
## Who reads what
|
||||
|
||||
The readers, so that a new effect knows what it will move and a new reader knows what to read.
|
||||
|
||||
| System | Reads | How |
|
||||
| --- | --- | --- |
|
||||
| Movement | `MoveSpeed`, `JumpPower` | `GetMaxSpeed` = the state's tuning speed × `MoveSpeed`; `JumpZVelocity` × `JumpPower`; nothing else scales movement |
|
||||
| Combat, outgoing | `AttackPower`, `AttackSpeed` | the funnel captures `AttackPower`; the swing divides its timings by `AttackSpeed` |
|
||||
| Combat, incoming | `Armour`, `MagicResist`, `KnockbackResist`, `Immune.Damage.*` | the funnel and the impulse |
|
||||
| Crafting | `WorkSpeed`, `WorkQuality` | the activity runtime: baseline duration ÷ `WorkSpeed`; quality floor + `WorkQuality`. This is where "class bonuses, never locks" lives: a class is a `BaseStats` with better work numbers and nothing else |
|
||||
| Interaction | `CarryCapacity` | `CarryMath::SpeedFactor` |
|
||||
| The HUD | active `Status.*` tags, remaining durations | a row of status icons from a tag-to-icon table on the theme |
|
||||
| Enemies | everything, the same way | an enemy body's component is the same class with the same block |
|
||||
|
||||
## Networking
|
||||
|
||||
| State | Authority | Mechanism |
|
||||
| --- | --- | --- |
|
||||
| Attributes | Server | replicated attribute set, `REPNOTIFY_Always` so the HUD sees every change |
|
||||
| Effects from own abilities | Client predicts, server confirms | the engine's effect prediction |
|
||||
| Effects from areas, surfaces, other players, the world | Server | applied on the server; the attribute replicates |
|
||||
| Immunity and resistance | Server | the helper runs where the effect is applied |
|
||||
| Cues | Everywhere | unreliable multicast; presentation only |
|
||||
|
||||
The one cost worth naming: a server-applied speed change lands on the owning client one round trip after the
|
||||
server saw it, and the client's predicted moves in between are corrected. At 100 ms that is a few centimetres at
|
||||
the moment you step into mud, smoothed by the engine. Accepted; it is the tier-two rule from
|
||||
[Networking.md](Networking.md) applied to a number instead of an object. If it ever reads as a snag, the owning
|
||||
client may predict *surface* effects (it knows the floor too); nothing else.
|
||||
|
||||
## Telemetry
|
||||
|
||||
| Event | When | Payload |
|
||||
| --- | --- | --- |
|
||||
| `status_applied` | The helper applies or strengthens a status | `status`, `magnitude`, `duration`, `source_kind` (ability, area, surface, item, world), `source`, `target_kind` |
|
||||
| `status_blocked` | Immunity blocks one | `status`, `immunity`, `source_kind` |
|
||||
| `status_removed` | Expiry, cleanse or leaving the source | `status`, `cause`, `time_active_s` |
|
||||
|
||||
`status_applied` by `source_kind` against `target_kind` is the first thing to look at when a fight feels unfair:
|
||||
it says whether players or enemies are the ones spending the fight slowed.
|
||||
|
||||
## Tests
|
||||
|
||||
- Automation: `CarryMath::SpeedFactor` (capacity and above is one; heavier is slower; never below the floor);
|
||||
`StatMath::Resisted` (a 0.6 slow at 0.5 resist is 0.8; a flat -10 at 0.5 is -5; 1.0 resist is neutral); the
|
||||
clamp ranges in the table.
|
||||
- Functional: a body in `AEffectVolume(Slow, 0.6)` moves at 60 %; a second stronger volume raises nothing when
|
||||
weaker and replaces when stronger; leaving restores 100 %; a body with `GE_Immune_Slow` in the same volume
|
||||
moves at 100 % and the blocked cue fired; walking onto the gym's mud slows a player and a goblin alike; a haste
|
||||
applied while encumbered multiplies rather than replacing.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Q1. Should the owning client predict surface effects?** Not until the correction on entering mud is felt.
|
||||
The trace runs on both; the effect application is the only thing that would move client-side.
|
||||
- **Q2. Resistances per damage type.** `Armour` and `MagicResist` cover physical and magic. Fire, frost and
|
||||
poison as damage types with their own resist would each be a row in the table; add them when a second
|
||||
elemental type exists, not before.
|
||||
- **Q3. A block on things that are not bodies.** A wooden crate that burns, a door that freezes shut. The rule
|
||||
says give it a component and a block; the cost is a component per affectable prop. Decide with the first one.
|
||||
- **Q4. Stamina as a cost.** Reserved in the block; whether sprint or abilities draw from it is Movement Q3 and
|
||||
Combat Q2, and the answer is a cost effect either way.
|
||||
@@ -0,0 +1,180 @@
|
||||
# Telemetry
|
||||
|
||||
Owns the seam gameplay emits events through, the envelope, the format on disk and the catalogue of events. It
|
||||
does **not** own any backend, storage or analysis; there is none, and the format is chosen so one can be bolted
|
||||
on later without touching the game.
|
||||
|
||||
Read [Architecture.md](Architecture.md) first. Step 2 in [`../Steps.md`](../Steps.md), before the character
|
||||
exists, on purpose.
|
||||
|
||||
## Why this exists before there is anyone to measure
|
||||
|
||||
Two reasons, and the second is the real one.
|
||||
|
||||
1. **Retrofitting emit calls is expensive and lossy.** Instrumenting a finished system means re-reading it and
|
||||
guessing what mattered at the time. Instrumenting as it is written costs a line.
|
||||
2. **Some questions can only be answered with data that starts early.** Which camera mode people live in, whether
|
||||
the anvil is fun or a chore, whether a crafted sword's damage curve is sane, whether prompts explain themselves.
|
||||
Every one of those is a distribution over sessions, and the sessions that never emitted are gone.
|
||||
|
||||
So the schema is fixed now, the sink writes a local file, and the catalogue grows one step at a time the way the
|
||||
cheats do.
|
||||
|
||||
## Decisions
|
||||
|
||||
```
|
||||
[DECIDED] One subsystem, one sink interface, three sinks on day one: null (the shipping default), log (the
|
||||
editor), JSON Lines to a file (playtests). The emitter never knows where events go.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] JSON Lines. One event per line, UTF-8, snake_case keys. Append-only, so a crash mid-write loses one line
|
||||
and not the file; schema-flexible, so a new field breaks no old reader; greppable during development; and the
|
||||
native food of every log and analytics pipeline that might sit downstream. Events, never aggregates: every metric is
|
||||
computed from raw facts later, because aggregation choices change and raw events do not.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] No personal data. Player identity on the wire is the server-minted player id, hashed. No names, no
|
||||
platform ids, no chat, no free text. Established now because a field is much harder to remove than to never add.
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
Source/<Project>Core/Telemetry/
|
||||
├── TelemetrySink.h // ITelemetrySink, FNullTelemetrySink, FLogTelemetrySink, FJsonlTelemetrySink
|
||||
├── TelemetryEvent.h // FTelemetryEvent, FTelemetryEnvelope
|
||||
└── TelemetryEvents.h // the event name constants: TelemetryEvents::PlayerDowned etc. Never a literal at a call site.
|
||||
|
||||
Source/<Project>/Core/
|
||||
└── TelemetrySubsystem.h // UGameInstanceSubsystem: owns the sink, stamps the envelope, exposes Emit
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
```cpp
|
||||
// Source/<Project>Core/Telemetry/TelemetryEvent.h
|
||||
struct FTelemetryEvent
|
||||
{
|
||||
FName Name; // from TelemetryEvents, never a literal
|
||||
TSharedPtr<FJsonObject> Payload; // event-specific fields; built with a small fluent helper
|
||||
// The subsystem stamps the envelope; call sites fill Name and Payload only.
|
||||
};
|
||||
|
||||
struct FTelemetryEnvelope // stamped by the subsystem onto every event
|
||||
{
|
||||
int32 SchemaVersion = 1;
|
||||
FGuid EventId; // unique per emission, for de-duplication
|
||||
FDateTime TimestampUtc;
|
||||
float GameTime; // seconds since the world began
|
||||
FGuid SessionId; // minted by the server, adopted by clients on join
|
||||
FString PlayerId; // hashed server-minted id; empty on the server
|
||||
bool bIsServer; // authoritative events versus observed ones
|
||||
int32 PartySize;
|
||||
FString Build; // FApp::GetBuildVersion() plus the git hash from Scripts/
|
||||
FString Map;
|
||||
bool bCheatsUsed; // sticky true after the first cheat this session: tainted sessions are filterable, not deleted
|
||||
};
|
||||
|
||||
// Source/<Project>Core/Telemetry/TelemetrySink.h
|
||||
class ITelemetrySink
|
||||
{
|
||||
public:
|
||||
virtual ~ITelemetrySink() = default;
|
||||
virtual void Emit(const FTelemetryEnvelope& Envelope, const FTelemetryEvent& Event) = 0;
|
||||
virtual void Flush() {}
|
||||
};
|
||||
// FNullTelemetrySink: does nothing. The shipping default until there is somewhere to send anything.
|
||||
// FLogTelemetrySink: one line of JSON to the output log under LogTelemetry. Verifies a step emitted what it claims.
|
||||
// FJsonlTelemetrySink: appends to Saved/Telemetry/session_<utc>_<id>.jsonl through a queue drained by a background
|
||||
// task every two seconds and on Flush; the game thread only builds a small object per event. Flushed on quit and
|
||||
// from the unhandled-exception handler so a crash loses at most two seconds of events.
|
||||
```
|
||||
|
||||
```cpp
|
||||
// Source/<Project>/Core/TelemetrySubsystem.h
|
||||
UCLASS()
|
||||
class UTelemetrySubsystem : public UGameInstanceSubsystem
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
void Emit(FName Name, TSharedPtr<FJsonObject> Payload = nullptr); // stamps the envelope, hands to the sink
|
||||
void BeginSession(FGuid SessionId); // server mints; a client adopts the replicated id
|
||||
void SetSink(TUniquePtr<ITelemetrySink> Sink); // Log in the editor, Jsonl with -telemetry, Null otherwise
|
||||
void MarkCheatUsed(); // called by every bs.* console command
|
||||
};
|
||||
```
|
||||
|
||||
**There is no static `Telemetry::Log(...)` and there will not be one.** Gameplay code resolves the subsystem from
|
||||
its game instance once and keeps the pointer. A static helper is a global with gameplay state in it, which
|
||||
[Architecture.md](Architecture.md) bans, and it makes every emitting class untestable.
|
||||
|
||||
## Where the sink lives on each peer
|
||||
|
||||
Every peer emits its own events to its own file. The server's file is the authoritative record of what happened;
|
||||
a client's file is the record of what that player saw and did. `session_id` is the same across all of them and
|
||||
`is_server` tells them apart. Nothing is forwarded, because the interesting questions are per-peer by nature.
|
||||
|
||||
Which sink is installed: the log sink in the editor, the file sink when the executable is launched with
|
||||
`-telemetry` or the console variable `telemetry.File 1` is set, the null sink otherwise. Playtests run with the file
|
||||
on and the folder is collected by hand afterwards; that is the whole backend for now.
|
||||
|
||||
## The catalogue
|
||||
|
||||
Each feature spec's Telemetry section lists what that feature emits; this table is the rollup and the two must
|
||||
agree. A row is added to a spec and here in the same pull request as the emit call.
|
||||
|
||||
| Group | Events |
|
||||
| --- | --- |
|
||||
| Session | `app_started`, `session_started`, `session_ended`, `player_joined`, `player_left`, `settings_changed`, `cheat_used` |
|
||||
| Movement | `movement_sample`, `jump`, `land`, `camera_mode_changed` |
|
||||
| Interaction | `prop_interacted`, `interaction_refused`, `object_picked_up`, `object_dropped`, `object_thrown`, `object_handed_over` |
|
||||
| Stats | `status_applied`, `status_blocked`, `status_removed` |
|
||||
| Combat | `enemy_spawned`, `enemy_killed`, `ability_used`, `player_damaged`, `player_downed`, `player_revived`, `player_died`, `party_wiped`, `grief_action` |
|
||||
| Crafting | `substance_processed`, `piece_shaped`, `piece_enchanted`, `item_assembled`, `assembly_rejected`, `activity_completed`, `characteristic_discovered`, `weapon_equipped` |
|
||||
| Technical | `perf_sample`, `error_logged`, `net_correction` (count of visible movement corrections per 30 s, from `p.NetShowCorrections`) |
|
||||
|
||||
### The rows that carry weight
|
||||
|
||||
```
|
||||
item_assembled family, piece_ids[], name, quality, synergy, substance_value, time_at_bench_s
|
||||
weapon_equipped family, crafted, damage, quality
|
||||
enemy_killed enemy, killer_class, weapon, ability, time_to_kill_s
|
||||
```
|
||||
|
||||
Together these are the crafting-to-combat loop as data: does a better-made sword kill faster, and by how much.
|
||||
That is the one question the whole prototype exists to answer, and it cannot be answered from memory.
|
||||
|
||||
```
|
||||
activity_completed activity, operators, quality, auto_play, faults, contribution_by_player
|
||||
```
|
||||
|
||||
`quality` against `auto_play` says whether the anvil is fun or a chore: if the hold-to-work floor is where most
|
||||
sessions live, the minigame is not earning its place.
|
||||
|
||||
```
|
||||
interaction_refused prop, reason
|
||||
assembly_rejected family, reason, part
|
||||
```
|
||||
|
||||
The two cheapest possible measures of whether the game explains itself. A reason that dominates is a prompt that
|
||||
does not.
|
||||
|
||||
## Tests
|
||||
|
||||
- Automation: the JSONL sink writes one valid line per event and never blocks the calling thread longer than
|
||||
building the object; the envelope carries every field; an event with no payload serialises as an empty object.
|
||||
- Functional: launching the gym with the log sink installed emits `app_started` and `session_started` in that
|
||||
order; every event name a feature test triggers is a constant in `TelemetryEvents`.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Q1. Sampling.** `movement_sample` and `player_damaged` are the high-frequency rows. Every 5 s and every hit
|
||||
are fine at prototype scale; revisit when a file exceeds a few megabytes per session.
|
||||
- **Q2. An analytics provider.** The engine has an `IAnalyticsProvider` interface that several backends implement.
|
||||
When there is a backend, a fourth sink adapts to it; the emitter does not change.
|
||||
- **Q3. Opt-out.** A playtest build needs a telemetry toggle in settings before strangers play it. With the
|
||||
settings pass, not before.
|
||||
- **Q4. Typed payloads.** A JSON object per event is convenient and unsafe. A typed struct per event name is the
|
||||
later answer if typos in payload keys start costing analysis time.
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
# UI
|
||||
|
||||
Owns what interface exists, what it is built on, and the rules that keep it one interface rather than a dozen:
|
||||
the HUD, the prompt, the action bar, the one theme asset, world-space text on props, localisation and the menu.
|
||||
It does **not** own what any readout means; the health number belongs to [Combat.md](Combat.md), the bench panel to
|
||||
[Crafting.md](Crafting.md).
|
||||
|
||||
Read [Architecture.md](Architecture.md) first. There is deliberately little here. A prototype about moving,
|
||||
fighting and crafting needs a crosshair, a health number, a prompt, four ability slots and a way to quit.
|
||||
|
||||
## The rule
|
||||
|
||||
```
|
||||
[SALVAGED] The player acts on the world physically; the game reports state conventionally.
|
||||
|
||||
Everything the player DOES is a thing in the world reached through the interaction system: a bench, a station,
|
||||
a weapon on the ground, a downed teammate. Screen space is for readouts: health, the prompt, the crosshair, the
|
||||
action bar. The test for anything new: can the player do it by touching something in the world? If yes it is a
|
||||
prop. If it only tells them something, it may be HUD.
|
||||
|
||||
The earlier guild project made this a pillar and forbade all menus but Escape. This project keeps the test and
|
||||
drops the absolutism: a world this is headed for will need screens the prototype does not, and the rule's job is
|
||||
to make every one of them earn its place, not to forbid them.
|
||||
```
|
||||
|
||||
## Decisions
|
||||
|
||||
```
|
||||
[DECIDED] UMG with CommonUI. Widgets are Blueprint children of C++ base classes that own the data binding; the
|
||||
Blueprint owns layout and look only. CommonUI supplies input routing, gamepad focus and per-device glyphs, which
|
||||
is exactly the part nobody wants to write twice.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] One theme asset. Every colour, type size, spacing step and motion duration is a token on one data asset,
|
||||
named by role and never by hue. No widget holds a literal colour, size or duration. A colourblind palette is a
|
||||
second asset with the same roles.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] Prop text is world-space and has no canvas. Text on a thing in the world is a UTextRenderComponent, or a
|
||||
world-space UWidgetComponent when it needs layout (the bench panel). It reads the same theme.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] Everything a player reads is FText from a string table. FString is for identifiers and logs.
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
Source/<Project>/UI/
|
||||
├── UITheme.h // UPrimaryDataAsset: the tokens
|
||||
├── ThemedText.h // UCommonTextBlock subclass reading a type token and a colour role
|
||||
├── HudWidget.h // the base: health, crosshair, prompt, action bar, downed state; binds to the local player
|
||||
├── InteractionPromptWidget.h // verb, target, reason, hold fill, live glyph
|
||||
├── ActionBarWidget.h // four slots: glyph, name, cooldown; reads the ability system and the input subsystem
|
||||
├── MenuWidget.h // Escape: Resume, Settings, Quit. Never pauses.
|
||||
└── SettingsWidget.h // comfort sliders, motion scale, rebinding rows through the engine's user settings
|
||||
|
||||
Content/UI/
|
||||
├── DA_Theme_Default
|
||||
├── WBP_Hud, WBP_InteractionPrompt, WBP_ActionBar, WBP_ActionBarSlot, WBP_Menu, WBP_Settings
|
||||
└── ST_Game // the string table; one key per readable string, prop prompts included
|
||||
```
|
||||
|
||||
## The theme
|
||||
|
||||
```cpp
|
||||
UCLASS(BlueprintType)
|
||||
class UUITheme : public UPrimaryDataAsset
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
// Roles, never hues. A widget names a role; the asset says what it looks like.
|
||||
UPROPERTY(EditDefaultsOnly, Category = "Colour") FLinearColor HudNeutral, HudGood, HudWarning, HudDanger;
|
||||
UPROPERTY(EditDefaultsOnly, Category = "Colour") FLinearColor PromptAvailable, PromptBlocked;
|
||||
UPROPERTY(EditDefaultsOnly, Category = "Colour") FLinearColor OutlineInRange, OutlineTargeted;
|
||||
UPROPERTY(EditDefaultsOnly, Category = "Colour") FLinearColor Scrim, PanelSurface, PanelText;
|
||||
UPROPERTY(EditDefaultsOnly, Category = "Colour") TMap<FGameplayTag, FLinearColor> DomainColours; // Domain.Forge, .Wood, .Enchant
|
||||
UPROPERTY(EditDefaultsOnly, Category = "Colour") TArray<FLinearColor> PlayerColours; // by join order, low saturation, rings not fills
|
||||
|
||||
// Type scale, in steps rather than free numbers. HUD sizes in pixels at 1080p; world sizes in centimetres.
|
||||
UPROPERTY(EditDefaultsOnly, Category = "Type") FSlateFontInfo HudPrimary, HudSecondary, Prompt, Caption;
|
||||
UPROPERTY(EditDefaultsOnly, Category = "Type") float PropTitleCm = 12.f, PropBodyCm = 7.f, PropSmallCm = 5.f;
|
||||
|
||||
UPROPERTY(EditDefaultsOnly, Category = "Space") TArray<float> SpacingSteps = {4, 8, 16, 24, 40};
|
||||
UPROPERTY(EditDefaultsOnly, Category = "Motion") float Fast = 0.12f, Normal = 0.2f, Slow = 0.4f; // every one scaled by the motion setting
|
||||
};
|
||||
```
|
||||
|
||||
Meaning never rides on hue alone: a blocked prompt is grey **and** worded differently; a danger readout is red
|
||||
**and** changes shape. `HudGood` leans teal and `HudDanger` leans orange so the two separate under deuteranopia.
|
||||
Substance colours are not theme tokens; they are on the substance definitions because the substance is the palette.
|
||||
|
||||
The placeholder look: neutral, stylised, legible. The two source projects each had a fully specified palette (a
|
||||
parchment-and-ink guild hall; a warm forge with domain tints). Neither is this game's, so the default asset is
|
||||
plain white HUD over greybox and the domain tints, and an art direction decides the rest when there is one.
|
||||
|
||||
## The HUD
|
||||
|
||||
One widget, added by the player controller for the local player, reading the local player state's ability system
|
||||
and the pawn's interaction component. It never computes: health is the attribute, the prompt is
|
||||
`UInteractionComponent::GetCurrentPrompt`, the cooldown is the cooldown tag's remaining time.
|
||||
|
||||
| Element | Reads | Shows |
|
||||
| --- | --- | --- |
|
||||
| Crosshair | nothing | four pixels, `HudNeutral` |
|
||||
| Health | `Health`, `MaxHealth`, `State.Downed` | number plus a status line; colour by fraction, and shape when downed |
|
||||
| Prompt | `GetCurrentPrompt` | `[glyph] Verb Target`, greyed with the reason when blocked, a radial fill while holding |
|
||||
| Action bar | the four `Input.Ability.n` abilities, the input subsystem | glyph (live binding, per device), name, seconds left; dim while empty or cooling |
|
||||
| Downed | `State.Downed`, `GE_BleedOut` remaining | "Downed: 24 s" and who is nearest |
|
||||
| Carry | `UCarryComponent::GetHeld` | the held object's name, and "hands full" on a blocked verb |
|
||||
| Statuses | active `Status.*` tags and remaining durations | a row of icons from `DT_StatusIcons`, debuffs first; the "Immune" flash on a blocked one comes from the cue, not the HUD |
|
||||
|
||||
Two canvases, and the split is a rebuild-cost decision: the static one (crosshair, chrome) and the volatile one
|
||||
(everything that changes). Neither has hit testing; the HUD is never clicked.
|
||||
|
||||
## Input modes
|
||||
|
||||
One place decides what the cursor does and which mapping context is live, and it is CommonUI's input routing on
|
||||
the activatable widget stack. Gameplay input stops while the menu is up; **the world does not.** Enemies keep
|
||||
moving, the forge keeps burning. This is the difference between a mode switch and a pause, and it is the design:
|
||||
opening Settings in a fight is a bad idea, which is correct, and it means no second behaviour for the same key that
|
||||
would be untested in one of the two modes.
|
||||
|
||||
The prompt's glyph and the action bar's keys come from the engine's Enhanced Input user settings through the
|
||||
input subsystem, by the device the player touched last. No widget prints a literal key.
|
||||
|
||||
## World-space text
|
||||
|
||||
Text on a prop is a `UTextRenderComponent` reading the theme's world sizes and a colour role, on the prop itself,
|
||||
with no canvas: it stays readable as you walk round it and lights with the scene. The surface rule: light surface,
|
||||
`PanelText`; dark surface, `HudNeutral`. The bench's assembly panel, which needs slots and a gauge, is a
|
||||
`UWidgetComponent` in world space reading the same theme and laid out by the family's authored grid so a sword
|
||||
reads as a sword. Its quality gauge shows the synergy segment separately, so players learn why matching themes
|
||||
score higher.
|
||||
|
||||
## The menu
|
||||
|
||||
Escape: Resume, Settings, Quit. Settings: FOV per mode, head stabilisation, bob, motion scale, text scale, and a
|
||||
rebind row per bindable action through the engine's user settings. Nothing else, until a step needs something
|
||||
else and says why here.
|
||||
|
||||
## Localisation
|
||||
|
||||
Every readable string is a key in `ST_Game`, resolved through `FText::FromStringTable`. Prompts are keyed by verb
|
||||
tag (`Interact.Verb.Revive` resolves to `prompt.verb.revive`), reasons by reason tag, ability names on the ability
|
||||
class as `FText`. The engine's localisation dashboard gathers from string tables and `FText` properties; nothing
|
||||
else is needed until there is a second language.
|
||||
|
||||
## Accessibility hooks
|
||||
|
||||
Hooks now, content later; none of the later work touches a widget.
|
||||
|
||||
| Hook | Exists | Filled in later |
|
||||
| --- | --- | --- |
|
||||
| Text scale | `ThemedText` multiplies every size by it | a slider |
|
||||
| Colourblind palettes | roles on the theme, no literals in widgets | alternative theme assets |
|
||||
| Motion scale | every theme duration, camera kick, hit stop and bob multiplies by it | the slider ships in step 3, a comfort requirement for first person |
|
||||
| Rebinding | prompts read the live binding | the rows ship in step 3 through engine settings |
|
||||
| Hold-to-confirm | on irreversible verbs already | an option to extend it to every verb |
|
||||
|
||||
## Telemetry
|
||||
|
||||
`settings_changed` with `setting_id` as the dotted path (`camera.fov.first_person`, `input.bindings.interact`) and
|
||||
the new value. Nothing else: prompt visibility and menu opens are high frequency and low value, and
|
||||
`prop_interacted` already answers whether players find things.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Q1. World-space widget legibility.** A `UWidgetComponent` at bench distance in both camera modes has to be
|
||||
checked once with real text before the assembly panel is built on it. Ten minutes in step 13, not a spike.
|
||||
- **Q2. A font.** The engine's default until a look exists. When one is chosen it is one face with material
|
||||
presets, not three font assets.
|
||||
- **Q3. Does the HUD scale with resolution?** Scale with screen size at 1080p reference is assumed; ultrawide needs
|
||||
one look.
|
||||
Reference in New Issue
Block a user