* 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
460 lines
28 KiB
Markdown
460 lines
28 KiB
Markdown
# 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.
|