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,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.
|
||||
Reference in New Issue
Block a user