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,120 @@
|
|||||||
|
# Unreal prototype
|
||||||
|
|
||||||
|
An Unreal Engine 5 project, C++ first, that builds and proves three things in order: a movement controller,
|
||||||
|
fighting, and crafting. The engine version and the project name are open decisions (OD-01, OD-02 in
|
||||||
|
[`Docs/Decisions.md`](Docs/Decisions.md)); pin both here in step 1. Until the `.uproject` exists, `<Project>`
|
||||||
|
in the docs stands for its name.
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
Read [`Docs/Spec/README.md`](Docs/Spec/README.md) (the notation and the twelve cross-cutting rules) and
|
||||||
|
[`Docs/Spec/Architecture.md`](Docs/Spec/Architecture.md) before writing or changing any code. Nearly every wiring
|
||||||
|
decision follows from them.
|
||||||
|
|
||||||
|
For *what* to build next, read [`Docs/Steps.md`](Docs/Steps.md). It is a ladder, not a roadmap: each rung says
|
||||||
|
what exists afterwards, links the spec sections that define it, and states the proof that closes it. The spec
|
||||||
|
docs under `Docs/Spec/` are the specification; each is C++-shaped pseudocode plus the reasoning, and the
|
||||||
|
pseudocode is meant to be implemented rather than admired. If a step and its spec disagree, the spec wins and the
|
||||||
|
step is corrected.
|
||||||
|
|
||||||
|
The human summary is [`Docs/Design.md`](Docs/Design.md). Ideas from the two earlier projects that are not built
|
||||||
|
are in [`Docs/Ideas.md`](Docs/Ideas.md); do not build one inside a step about something else.
|
||||||
|
|
||||||
|
## Where the project actually is
|
||||||
|
|
||||||
|
Nothing is built. The repository holds the documentation set and a stock `.gitignore`. Step 1 (the project, two
|
||||||
|
modules, tests, a dedicated server in the editor) is next and waits on OD-01 and OD-02. Update this section when a
|
||||||
|
step closes: what exists, what the next step is.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
Planned; created by step 1. The `.uproject` sits at the repository root, which is what the `.gitignore` assumes.
|
||||||
|
|
||||||
|
| Path | What it is |
|
||||||
|
| --- | --- |
|
||||||
|
| `<Project>.uproject`, `Source/<Project>.Target.cs`, `<Project>Editor.Target.cs`, `<Project>Server.Target.cs` | The project and its three build targets |
|
||||||
|
| `Source/<Project>Core/` | Rules, data types, tags, the telemetry contract. Knows no `AActor`. Tests in `Tests/` |
|
||||||
|
| `Source/<Project>/` | Gameplay: `Core/`, `Stats/`, `Movement/`, `Interaction/`, `Combat/`, `Crafting/`, `UI/`, one folder per feature |
|
||||||
|
| `Content/<Feature>/` | Assets per feature: `Definitions/` for data assets, Blueprints, meshes, montages |
|
||||||
|
| `Content/Maps/L_Gym` | The movement test level. Never leaves the project |
|
||||||
|
| `Content/Tests/` | Functional test maps |
|
||||||
|
| `Config/Tags/<Feature>.ini` | Gameplay tag source of truth, one file per top-level namespace |
|
||||||
|
| `Scripts/` | `run-tests.sh`, `build.sh`, and `Authoring/` for editor Python scripts. Run by hand; there is no CI |
|
||||||
|
| `Docs/` | The documentation set. Update it when you change how something works |
|
||||||
|
| `Docs/Worklog.md` | One terse line per step closed or wall hit |
|
||||||
|
|
||||||
|
Module dependency is one way: `<Project>` depends on `<Project>Core`, never the reverse. If the core module seems
|
||||||
|
to need an actor, the thing it needs is data.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **The server decides.** Every mutation is a `Server*` path that validates its instigator and re-checks its
|
||||||
|
preconditions, even in standalone play. The client predicts its own movement and its own animation, nothing
|
||||||
|
else. Never write a listen-server assumption or an "is this the host" branch.
|
||||||
|
- **Rules are pure C++ in the core module; actors and components adapt them.** A preview and the real thing call
|
||||||
|
the same function. Every pure rule has automation tests, one per rejection reason.
|
||||||
|
- **One funnel per consequence.** All damage through `UDamageExecution`; all interactions through
|
||||||
|
`UInteractionSubsystem`; all activity quality through `FActivityResult`. Do not add a second path.
|
||||||
|
- **One stat block; everything is an effect on it.** Every body has `UStatBlockAttributeSet`; every buff,
|
||||||
|
debuff, surface, weight, gear and base value is a gameplay effect applied through `ApplyStatus` or
|
||||||
|
`ApplyDefaults`. Never add a multiplier, a modifier list or a status timer to a component. Counters are
|
||||||
|
`Immune.*` tags and resist attributes on the receiver. See `Docs/Spec/Stats.md`.
|
||||||
|
- **Content is data.** `UPrimaryDataAsset` subclasses, addressed by gameplay tag or primary asset id, art through
|
||||||
|
soft references. No string ids. If a variant needs code, the model is wrong.
|
||||||
|
- **Gameplay Tags are the vocabulary.** Defined in `Config/Tags/`; tags code references are declared natively
|
||||||
|
with `UE_DECLARE_GAMEPLAY_TAG_EXTERN` / `UE_DEFINE_GAMEPLAY_TAG`. No string literal tag at a call site.
|
||||||
|
- **C++ owns rules, replicated properties and RPCs. Blueprints own composition, tuning and cosmetics.** A
|
||||||
|
Blueprint is a child of a C++ class that sets assets and numbers and wires `*_Cosmetic` hooks.
|
||||||
|
- **Lifetimes are the engine's.** Game-instance subsystems for the application, world subsystems for a map,
|
||||||
|
the player state for a player across bodies, the pawn for a body. Nothing about a player that would matter
|
||||||
|
tomorrow lives on the character actor.
|
||||||
|
- **No world searches in gameplay code.** No `GetAllActorsOfClass`, no static gameplay singletons. Subsystems are
|
||||||
|
the registries; actors register in `BeginPlay` and unregister in `EndPlay`.
|
||||||
|
- **Every rejection carries a reason tag** a player can read. **Nothing is destroyed silently.**
|
||||||
|
- **Emit telemetry through `UTelemetrySubsystem` with names from `TelemetryEvents`**, never a literal, never a
|
||||||
|
static helper. Every feature emits; the catalogue in `Docs/Spec/Telemetry.md` and the feature spec agree.
|
||||||
|
- **Naming is Unreal's:** `A`/`U`/`F`/`E`/`I` prefixes, no project prefix on classes, `PascalCase` members, asset
|
||||||
|
prefixes `BP_ DA_ DT_ IA_ IMC_ GA_ GE_ GC_ ABP_ AM_ SK_ SM_ M_ MI_ WBP_ T_ L_`. Placeholders carry `_Proto`.
|
||||||
|
- **Units are centimetres and seconds.** `FText` for anything a player reads. `TObjectPtr` for object properties.
|
||||||
|
- **The design word "material" is "substance"** in code and docs. `UMaterial` is the engine's.
|
||||||
|
- Do not add an interface, a module or an abstraction for one implementation with no test double. The two source
|
||||||
|
projects were each trimmed of speculative abstraction once already; the interfaces that exist here are the ones
|
||||||
|
with several implementations named in the specs.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Created in step 1; the shapes are fixed now so the docs can refer to them.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Scripts/run-tests.sh # headless: UnrealEditor-Cmd <Project>.uproject -ExecCmds="Automation RunTests <Project>; Quit" -unattended -nopause -NullRHI
|
||||||
|
Scripts/run-tests.sh Core # a filter: <Project>.Core.*
|
||||||
|
Scripts/build.sh Win64 Development # UAT BuildCookRun for the game target; add -server for the server target once the engine is a source build
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no CI, deliberately, and there will not be until there is a reason. Run the tests yourself before saying
|
||||||
|
a step is done. Every replicated step is also played in the editor with two clients, "Run Dedicated Server" on, and
|
||||||
|
network emulation at 100 ms and 5 % loss with `p.NetShowCorrections 1`.
|
||||||
|
|
||||||
|
## Working with the editor
|
||||||
|
|
||||||
|
- Prefer editing C++ and `.ini` files directly. Blueprints and maps are binary; keep them thin and keep logic out
|
||||||
|
of them so a diff can be reviewed.
|
||||||
|
- Batch authoring (a folder of definition assets, a greybox level from a table) goes through the editor's Python
|
||||||
|
API in `Scripts/Authoring/`, idempotent, kept so it can be rerun. Do not hand-edit `.uasset` files.
|
||||||
|
- Blueprint-side wiring an authoring script cannot express is done once in the editor and recorded in the step's
|
||||||
|
worklog line so it can be redone.
|
||||||
|
- Watch for the compile trap: a C++ error that stops the editor launching is fixed in the files, with the build
|
||||||
|
output, not by guessing. The editor's Live Coding is fine for iteration and unreliable for header changes; a
|
||||||
|
header change means a full rebuild.
|
||||||
|
- Every `.uasset` and `.umap` goes through Git LFS; `.gitattributes` is created in step 1. Run `git lfs install`
|
||||||
|
once per machine. Never commit `Saved/`, `Intermediate/`, `DerivedDataCache/` or `Binaries/`; the `.gitignore`
|
||||||
|
covers them.
|
||||||
|
|
||||||
|
## Documentation upkeep
|
||||||
|
|
||||||
|
- A step that lands updates: its status in `Docs/Steps.md`; a **What was built, and where it differs** section at
|
||||||
|
the end of the spec it built from; a line in `Docs/Worklog.md`; a line in `Docs/Decisions.md` if a decision was
|
||||||
|
taken; and the "Where the project actually is" section above.
|
||||||
|
- A design change that contradicts a decision names the decision it supersedes in the log. Never delete a log
|
||||||
|
line.
|
||||||
|
- Do not turn `Docs/Steps.md` into a roadmap. Detail the next step when the current one closes, not before.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Decisions
|
||||||
|
|
||||||
|
One line per decision, newest first, with a pointer to where it lives in full. A decision belongs here the moment
|
||||||
|
it is made, even if the doc it affects has not caught up. Never delete an entry; supersede it with a newer one and
|
||||||
|
say so.
|
||||||
|
|
||||||
|
Status: `Decided` · `Supersedes` (contradicts an earlier decision, names it) · `Deferred` (deliberately not now,
|
||||||
|
with a trigger)
|
||||||
|
|
||||||
|
## 2026-09-15 — The stat block
|
||||||
|
|
||||||
|
Taken on the same day, after the first read of the doc set.
|
||||||
|
|
||||||
|
| # | Decision | Where |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| D-38 | **Counters live on the receiver as tags and attributes**: immunity blocks by tag, resistance scales by attribute, cleanse removes by tag, and every status and every block is visible. The same status from several sources: strongest wins, longest remaining duration; different statuses multiply. | [Stats](Spec/Stats.md) |
|
||||||
|
| D-37 | **One stat block on everything with a body, and every outside influence is a gameplay effect on it.** Base values, buffs, debuffs, ground, carried weight, gear and being downed all go through the same effects; no system keeps its own multiplier. `Supersedes` the tagged speed-multiplier map the first draft of Movement gave the movement component. | [Stats](Spec/Stats.md) |
|
||||||
|
|
||||||
|
## 2026-09-15 — The move to Unreal and the reframing
|
||||||
|
|
||||||
|
Taken while rewriting the two earlier projects' documentation into this one. Everything below is `Decided`
|
||||||
|
unless marked.
|
||||||
|
|
||||||
|
| # | Decision | Where |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| D-01 | **Unreal Engine 5, C++ first.** Blueprints compose, tune and decorate; they never hold a rule, a replicated property or a server RPC. | [Architecture](Spec/Architecture.md) |
|
||||||
|
| D-02 | **Two runtime modules**, `<Project>Core` (rules, data, tags, telemetry contract; no actors) and `<Project>` (gameplay). Dependency one way. A third arrives when a feature is stable, never before. | [Architecture](Spec/Architecture.md) |
|
||||||
|
| D-03 | **Server-authoritative from the first line; the dedicated server is the real target.** No listen-server design, no "are we multiplayer" branch. Tested with the editor's dedicated server option from step 1. | [Networking](Spec/Networking.md) |
|
||||||
|
| D-04 | **The client predicts only its own movement and its own animation.** Three responsiveness tiers assigned by one test: does a rollback have a visible victim? No rollback of world state a player can already see. | [Networking](Spec/Networking.md) |
|
||||||
|
| D-05 | **Ask, then show.** Commit-shaped moments wait for the server inside their own feedback beat. Never show a number as final before it is committed. | [Networking](Spec/Networking.md) |
|
||||||
|
| D-06 | **A player is an identity, not a body.** Class, attributes, cooldowns, discoveries and identity live on the player state; the character actor is lent. | [Networking](Spec/Networking.md), [Architecture](Spec/Architecture.md) |
|
||||||
|
| D-07 | **Persistence behind one provider interface with a local-file implementation.** Nothing is persisted yet; the seam exists first. | [Networking](Spec/Networking.md) |
|
||||||
|
| D-08 | **Gameplay Tags are the vocabulary.** `Config/Tags/<Feature>.ini` is the source; code-referenced tags are declared natively. No string ids anywhere. | [Architecture](Spec/Architecture.md) |
|
||||||
|
| D-09 | **Content is `UPrimaryDataAsset`s** addressed by primary asset id or tag, with soft references for art. | [Architecture](Spec/Architecture.md) |
|
||||||
|
| D-10 | **Rules are pure functions in the core module**, tested without a world, called by both the client preview and the server verdict. | [Architecture](Spec/Architecture.md) |
|
||||||
|
| D-11 | **Telemetry from step 2**, one subsystem, one sink interface, JSON Lines to a local file, no personal data, events not aggregates. | [Telemetry](Spec/Telemetry.md) |
|
||||||
|
| D-12 | **`UCharacterMovementComponent`, extended**, not a custom controller and not the Mover plugin. Sprint is a saved-move flag; dodge and blink are root-motion abilities. | [Movement](Spec/Movement.md) |
|
||||||
|
| D-13 | **One rig, two camera modes.** First and third person on the same mannequin; the camera never enters the authority path; both modes exist from step 3; the default is decided by playing (OD-03). | [Movement](Spec/Movement.md) |
|
||||||
|
| D-14 | **The deadzone look model**, salvaged: the head turns freely inside a yaw deadzone, then the body follows; the deadzone widens while carrying. | [Movement](Spec/Movement.md) |
|
||||||
|
| D-15 | **Sprint takes Shift.** The earlier four-slot layout (Q, E, Shift, R) becomes Q, E, R, C. Interact F, drop G tap, throw G hold. `Supersedes` the earlier project's Controls decision. | [Movement](Spec/Movement.md) |
|
||||||
|
| D-16 | **The controller is built first and proved against a written checklist in a gym level** that never leaves the project. | [Movement](Spec/Movement.md), [Steps](Steps.md) |
|
||||||
|
| D-17 | **One interaction system.** An interface, a world subsystem as the funnel, the player's component as the only RPC path; reach re-checked server-side with tolerance; prompt and permission from one function. | [Interaction](Spec/Interaction.md) |
|
||||||
|
| D-18 | **Interaction is a discrete act on a fixed prop; carrying is continuous possession.** Pick-up is routed by the interaction key but is a carry verb. | [Interaction](Spec/Interaction.md) |
|
||||||
|
| D-19 | **Two hands; two-handed objects block interaction and, in first person, the view.** Held objects attach and stop simulating; dropped and thrown ones are server physics. Throwing is in and scrappy; nothing is destroyed by it. | [Interaction](Spec/Interaction.md) |
|
||||||
|
| D-20 | **The Gameplay Ability System** for attributes, abilities, effects, cooldowns and cues. The component is on the player state for players and on the pawn for enemies. | [Combat](Spec/Combat.md) |
|
||||||
|
| D-21 | **One damage funnel**: one execution calculation every damaging effect uses. **No friendly fire**: the funnel discards player-on-player damage and keeps the impulse. | [Combat](Spec/Combat.md) |
|
||||||
|
| D-22 | **Down before death.** Bleed-out, revive by anyone, a solo down is an instant wipe. A downed player is a prop. | [Combat](Spec/Combat.md) |
|
||||||
|
| D-23 | **Kits define ability access; gear defines stats and crosses classes; mods never grant a signature.** The `bSignature` flag exists from the first ability; gear and mods do not. | [Combat](Spec/Combat.md) |
|
||||||
|
| D-24 | **The recoverability contract** applies to any verb that inconveniences a teammate: recovery takes less time than the verb, and none may down, kill or deprive. All emit `grief_action`. | [Combat](Spec/Combat.md) |
|
||||||
|
| D-25 | **Sublinear party scaling and role relaxation** are reserved rules applied to a count. | [Combat](Spec/Combat.md) |
|
||||||
|
| D-26 | **Part, piece, trait.** A family declares parts; a piece fills one and carries three trait layers. No recipes anywhere. | [Crafting](Spec/Crafting.md) |
|
||||||
|
| D-27 | **"Material" is "substance"** in code and docs, because `UMaterial` is the engine's. | [Crafting](Spec/Crafting.md) |
|
||||||
|
| D-28 | **Substances are objects, not stacks**, with identity, quality and provenance. Affordable only because storage is physical; an abstract bank is refused on that ground. | [Crafting](Spec/Crafting.md) |
|
||||||
|
| D-29 | **Discovery unlocks, choice applies.** Characteristics are discovered through play and chosen at the station. Known set is per player. | [Crafting](Spec/Crafting.md) |
|
||||||
|
| D-30 | **One activity runtime, three guarantees**: completion never gated on skill; skill modulates a quality band only; a second operator is additive, never required. The anvil is the growing zone; the forge is a 5×5 bed with two levels of detail bound by an equivalence rule. | [Crafting](Spec/Crafting.md) |
|
||||||
|
| D-31 | **The assembly bench is domain-neutral; domains gate the work, never the worker.** Class changes speed and quality only. | [Crafting](Spec/Crafting.md) |
|
||||||
|
| D-32 | **Durability is per part, summed; the item is lost at zero; nothing wears from crafting.** | [Crafting](Spec/Crafting.md) |
|
||||||
|
| D-33 | **A crafted weapon-family item is a weapon**: both authored and crafted weapons produce one `FWeaponProfile` that the swing, the funnel and the bar read. `[PROPOSED]` until step 13 closes. | [Crafting](Spec/Crafting.md), [Combat](Spec/Combat.md) |
|
||||||
|
| D-34 | **UMG with CommonUI; one theme asset by role; prop text is world-space with no canvas; every readable string is `FText` from a string table.** The menu never pauses the world. | [UI](Spec/UI.md) |
|
||||||
|
| D-35 | **Steps, not a roadmap.** A ladder closed by proofs; the next few steps concrete, the rest sketched and detailed only when reached. | [Steps](Steps.md), [Design](Design.md) |
|
||||||
|
| D-36 | **The two earlier loops are not carried.** Their ideas are catalogued as features with needs and constraints, none scheduled. | [Ideas](Ideas.md) |
|
||||||
|
|
||||||
|
## Open decisions
|
||||||
|
|
||||||
|
Questions that block steps. Each names what it blocks and the recommendation on record, so closing one is a
|
||||||
|
confirmation, not a fresh discussion. When one is taken it moves up as a D-line and the row is marked closed.
|
||||||
|
|
||||||
|
| id | Question | Blocks | Recommendation | Status |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| OD-01 | **Engine version.** | 1 | The newest release with a hotfix out (5.6.x at the time of writing). Pin it in `CLAUDE.md` and the `.uproject` in step 1; upgrade deliberately, never mid-step. | Open |
|
||||||
|
| OD-02 | **Project and module name.** | 1 | Short, one word, no spaces. It becomes the `.uproject`, the module prefix and the test filter root. The docs write `<Project>` until it exists. | Open |
|
||||||
|
| OD-03 | **Default camera mode.** | none until 8 | Build both in step 3, play the gym and the first fight in each, let `camera_mode_changed` and `movement_sample` settle it by step 8. | Recommended |
|
||||||
|
| OD-04 | **Engine from source.** | the first packaged server | The launcher build until then; the editor's dedicated-server option covers every step in the ladder. | Recommended |
|
||||||
|
| OD-05 | **Placeholder character and animations.** | 3 | The engine's Third Person template mannequin and its animation blueprint; a retargeted pack only when the swing needs a clip the template lacks. | Recommended |
|
||||||
|
| OD-06 | **Physics replication mode for thrown objects.** | 6 | The engine's predictive interpolation mode; resimulation is not needed for objects nobody predicts. | Recommended |
|
||||||
|
| OD-07 | **Which two kits ship first.** | 9 | Warrior and Cleric: a taunt and a heal are what make the goblin's threat table and the downed state legible. | Recommended |
|
||||||
|
| OD-08 | **Crafting professions against combat kits.** | after 15 | A design session, not a step. See Ideas. | Open |
|
||||||
|
|
||||||
|
## Deferred, with triggers
|
||||||
|
|
||||||
|
Not gates. Listed so nobody reopens them as if they were undecided.
|
||||||
|
|
||||||
|
| Deferred | Until |
|
||||||
|
| --- | --- |
|
||||||
|
| The Mover plugin | It leaves experimental and a movement feature needs what it has (Movement Q2) |
|
||||||
|
| Stamina as a cost | A kit wants one (Combat Q2); the attribute exists so it is a cost effect, not a refactor |
|
||||||
|
| A blade sweep instead of the box hit check | Real animation makes the box read wrong (Combat Q4) |
|
||||||
|
| Storage beyond hands and station buffers | A step needs more than hands can hold (Crafting Q4, Ideas) |
|
||||||
|
| Alloys and the kiln | Post-fifteen; meanwhile nobody hard-codes one-to-one processing (Crafting Q5) |
|
||||||
|
| Generated glyph discovery | Its own session; the composition path never changes, any seed is global |
|
||||||
|
| Reconnect into a running session | Written down before the first playtest with strangers (Networking Q1) |
|
||||||
|
| Seamless travel, a second map | The second map |
|
||||||
|
| Replication Graph or Iris | A world with many actors and many players; dormancy and relevancy keep it cheap |
|
||||||
|
| Steam, lobbies, invitations, voice | A session layer in front of the server; never inside gameplay |
|
||||||
|
| Continuous integration | A reason for it; tests run by hand until then |
|
||||||
|
| Resistances per damage type beyond physical and magic | A second elemental damage type (Stats Q2) |
|
||||||
|
| Predicting surface effects on the owning client | The correction on entering mud is felt (Stats Q1) |
|
||||||
|
| A stat block on props that are not bodies | The first prop that should burn or freeze (Stats Q3) |
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
A fantasy world you move through, fight in and craft for. This project builds those three things, in that order,
|
||||||
|
and proves each one feels right before anything is built around it. There is no game loop yet, no economy, no
|
||||||
|
hall, no dungeon, and no roadmap that says when there will be. The world this is headed for is large, persistent
|
||||||
|
and shared, so nothing built now may assume otherwise; and nothing built now needs to be that yet.
|
||||||
|
|
||||||
|
This is the human summary. The specifications an implementer works from are in [`Spec/`](Spec/README.md), the
|
||||||
|
order of work is in [`Steps.md`](Steps.md), and the ideas carried over from the two earlier projects but not yet
|
||||||
|
built are in [`Ideas.md`](Ideas.md).
|
||||||
|
|
||||||
|
## The three things
|
||||||
|
|
||||||
|
Each has a rejection clause, because a pillar that never rules anything out is decoration.
|
||||||
|
|
||||||
|
| | What it means | What we say no to |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Move** | A body that feels good to walk, run, jump and look with, in first or third person, on a server that has the final say. Proved in a level made of nothing but movement problems, against a written checklist, with a person at the keyboard. | Movement the client owns. Movement tuned once against one enemy and never written down. A camera the server has to know about. A speed number that lives anywhere but the stat block. |
|
||||||
|
| **Fight** | Melee that lands where the animation says, one funnel every point of damage goes through, enemies with a readable tell, classes with a signature nobody else can have, and going down before dying so a friend can pick you up. | Friendly fire. A second damage path. A class that is a stat block. An ability that writes health directly. |
|
||||||
|
| **Craft** | Items assembled from pieces, each with a shape, a substance and an ornament; a name and a quality derived from what you put in, never picked from a list; hands-on station work where skill raises the ceiling and never gates the floor; and a crafted sword that is a sword when you swing it. | Recipes. Stacks. A minigame you can fail. A crafting system that does not know combat exists. |
|
||||||
|
|
||||||
|
The order is the dependency: fighting needs a body, crafting needs something to make weapons for. It is also the
|
||||||
|
risk order. The controller is the cheapest thing to get wrong and the most expensive to fix late.
|
||||||
|
|
||||||
|
## Fixed decisions
|
||||||
|
|
||||||
|
Settled, with the reason, so nobody relitigates them in month four. The full log with ids is
|
||||||
|
[`Decisions.md`](Decisions.md).
|
||||||
|
|
||||||
|
| Decision | Why |
|
||||||
|
| --- | --- |
|
||||||
|
| **Unreal Engine 5, C++ first** | The engine's native answers (subsystems, Gameplay Ability System, Gameplay Tags, character movement, replication) are the systems the earlier projects hand-built in Unity. Blueprints compose and tune; they never hold a rule. |
|
||||||
|
| **The server decides, always** | Every mutation is validated on the authority; the client predicts only its own body and its own animation. The dedicated server is the real target; standalone and listen-server are dev conveniences. This is the one posture that stays right as the world grows. |
|
||||||
|
| **One funnel per consequence** | All damage through one execution, all interactions through one service, all activity quality through one result. A second path is where rules quietly diverge. |
|
||||||
|
| **One stat block, and everything is an effect on it** | Every body, player or not, carries the same set of numbers. Mud, a debuff, a buff, a heavy crate, gear and a class's base values are all effects on that set, so anything that can affect one body affects every body, and a counter (immunity, resistance, cleanse) works against every source at once. No system keeps a number of its own. |
|
||||||
|
| **Rules are pure and shared** | A preview and the real thing call the same function. Preview and reality cannot disagree, and the same rules run headless on a server and in a test. |
|
||||||
|
| **Content is data, addressed by tag** | A new sword, enemy or substance is an asset. Strings never identify anything. |
|
||||||
|
| **One rig, two cameras** | First and third person on the same mannequin; the camera is presentation and never touches the simulation. Which is the default is decided by playing, not arguing. |
|
||||||
|
| **Bonuses, never locks** | Class, level and gear change speed, quality and numbers. They never change what a station accepts or what an assembly permits, and never grant another class's signature. |
|
||||||
|
| **Solo is a gate, not a mode** | Every rule is checked against one player. Where it costs a solo player, the doc names the knob. |
|
||||||
|
| **Telemetry from the first line** | Events into a no-op sink from step 2. The questions this prototype exists to answer are distributions over sessions, and sessions that never emitted are gone. |
|
||||||
|
| **A player is an identity, not a body** | Class, progression and discoveries live on the player state and behind a persistence seam, never on the character actor. A body is a thing the world lends you. |
|
||||||
|
| **Steps, not a roadmap** | Work is a ladder of small proofs. The next few steps are concrete; the rest are sketched and get detailed when reached. |
|
||||||
|
|
||||||
|
## How the work runs
|
||||||
|
|
||||||
|
[`Steps.md`](Steps.md) is a numbered ladder. Each step says what exists afterwards that did not before, links the
|
||||||
|
spec section that defines it, and states what proves it done: a test passes, a checklist is ticked, a person
|
||||||
|
performs an action. "Implemented" never closes a step. The first steps are written in full; later ones are
|
||||||
|
sketches that are detailed only when the step before them closes, because a plan written fifteen steps ahead is
|
||||||
|
a roadmap with a different name.
|
||||||
|
|
||||||
|
A step that turns out wrong in the building corrects its spec in the same change, under a "what was built, and
|
||||||
|
where it differs" heading, so the next reader is not misled by the sketch. Decisions go in the log the moment they
|
||||||
|
are made. The worklog gets one terse line per step closed or wall hit.
|
||||||
|
|
||||||
|
## What this is not, yet
|
||||||
|
|
||||||
|
Named so that scope drift has to be a decision rather than an accident.
|
||||||
|
|
||||||
|
- **Not a vertical slice.** There is no loop to prove. There are three feels to prove.
|
||||||
|
- **Not an economy.** No gold, no customers, no orders, no shop. Substances are spawned by a cheat.
|
||||||
|
- **Not a place.** One test level. No hall, no town, no generated dungeon.
|
||||||
|
- **Not a progression.** No upgrades, no reputation, no skill trees. Classes exist as kits with two abilities.
|
||||||
|
- **Not social infrastructure.** No lobbies, no invitations, no voice. Two clients and a server in the editor.
|
||||||
|
- **Not player versus player.** The damage funnel discards it. Players can inconvenience each other; they cannot
|
||||||
|
hurt each other.
|
||||||
|
|
||||||
|
Every one of these has a home in [`Ideas.md`](Ideas.md) with what it would need and what it must not break.
|
||||||
|
|
||||||
|
## Tone and look
|
||||||
|
|
||||||
|
Stylised, chunky, readable at a glance. The placeholder is the engine's mannequin in a greybox gym with a plain
|
||||||
|
white HUD; the two earlier projects each had a finished palette and neither is this game's, so an art direction
|
||||||
|
decides when there is one. Until then, saturated colour in the world means one of two things: a substance's
|
||||||
|
identity, or the thing you are meant to reach.
|
||||||
|
|
||||||
|
Comedy, when it happens, comes from physics and other players, never from writing. A thrown ingot skidding under
|
||||||
|
the anvil is the game working.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
Every document uses these words with exactly these meanings.
|
||||||
|
|
||||||
|
| Term | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| **Body** | The character actor a player or enemy currently occupies. Not the player. |
|
||||||
|
| **Player state** | The replicated per-player object that outlives bodies: identity, class, attributes, cooldowns, discoveries. |
|
||||||
|
| **Kit** | A class's abilities, in slot order. Fixed by class. |
|
||||||
|
| **Signature** | The one ability in a kit no gear or mod may grant to another class. |
|
||||||
|
| **Funnel** | The single path a kind of consequence takes: the damage execution, the interaction service, the activity result. |
|
||||||
|
| **Prop** | A thing in the world a player can act on through the interaction system. |
|
||||||
|
| **Carryable** | A thing a player can hold. Carrying is not an interaction. |
|
||||||
|
| **Downed** | At zero health, immobile, revivable, bleeding out. Not dead. |
|
||||||
|
| **Stat block** | The one set of numbers every body carries: health, speed, attack, armour, resistances, work speed. |
|
||||||
|
| **Effect** | The only way a number on a stat block changes. Timed, infinite or instant; from an ability, an area, a surface, an item or the world. |
|
||||||
|
| **Status** | A named kind of effect: slow, haste, poisoned. The same name from any source, so one counter stops all of them. |
|
||||||
|
| **Counter** | Immunity (blocks a status by tag), resistance (scales it), cleanse (removes it). Lives on the receiver. |
|
||||||
|
| **Family** | A kind of item and the tree of parts it has: sword, axe, bow. |
|
||||||
|
| **Part** | A position in an item: blade, guard, handle, pommel. |
|
||||||
|
| **Piece** | A crafted object filling one part. Has identity. |
|
||||||
|
| **Trait** | One of a piece's three layers: characteristic (shape), substance (what it is made of), enchantment (ornament). |
|
||||||
|
| **Substance** | The design word "material", renamed because the engine owns that word. Iron, oak, mithril. A substance object is one physical ingot or log. |
|
||||||
|
| **Coherence** | An item's quality: piece values plus a bonus for pieces that agree with each other. |
|
||||||
|
| **Station** | A prop where work happens: bench, anvil, forge. |
|
||||||
|
| **Activity** | Hands-on work at a station, on the one shared runtime. Skill raises the ceiling, never gates the floor. |
|
||||||
|
| **Domain** | Which station family works a thing: forge, wood, enchant. Gates the work, never the worker. |
|
||||||
|
| **Step** | One rung of the ladder, closed by a proof. |
|
||||||
|
| **The gym** | The test level made of movement problems. It never leaves the project. |
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
# Ideas
|
||||||
|
|
||||||
|
The two earlier projects, Adventurer Guild and Project Malleable, each designed a whole game around a core loop.
|
||||||
|
This project keeps neither loop. It keeps the ideas, reshaped as features a world could hold rather than as the
|
||||||
|
thing the game is about, and it keeps them here rather than in the specs so that none of them can be smuggled into
|
||||||
|
a step as if it were already decided.
|
||||||
|
|
||||||
|
Each entry says what the idea is, which mechanism is worth keeping, what it would need, and what it must not
|
||||||
|
break. "Adopted" means the idea already lives in a spec. Nothing here is scheduled.
|
||||||
|
|
||||||
|
## From Adventurer Guild
|
||||||
|
|
||||||
|
### Contracts and the board
|
||||||
|
|
||||||
|
Take a parchment off a wall, go and do it, come back and have it stamped. The mechanism worth keeping is the
|
||||||
|
**stamp**: a ritual at the end of a job where the value is counted out in front of everyone, which is a better
|
||||||
|
stopping point than a results screen because it gives a group a shared moment. Contracts as data (a target, a
|
||||||
|
count, a reward, a standing requirement) and progress reported through one funnel from combat kills and item
|
||||||
|
deliveries. Needs: a place to put the board, a reason to want the reward. Must not: replace playing with
|
||||||
|
fetching; a contract is a reason to go somewhere, not the game.
|
||||||
|
|
||||||
|
### The cart
|
||||||
|
|
||||||
|
A physical, pushable container that carries the haul out. Tips, spills a fraction, gets righted. Pushed from the
|
||||||
|
handles by taking them (an interaction), which routes movement input into the cart. The mechanism worth keeping
|
||||||
|
is **hauling as a physical problem**: what you bring back is what fits and what you managed not to lose. Needs:
|
||||||
|
somewhere to haul from and to; the physics of a pushed body on a server. Must not: be the only way to carry
|
||||||
|
things; hands and (later) containers come first.
|
||||||
|
|
||||||
|
### Grief verbs and the recoverability contract
|
||||||
|
|
||||||
|
Shove, take from the cart, heal-launch, tip the cart. **Adopted as a rule** in [Combat.md](Spec/Combat.md): any
|
||||||
|
verb that lets one player inconvenience another must have a recovery that takes less time than the verb did, and
|
||||||
|
none may down, kill or permanently deprive. Every such verb emits `grief_action`. The verbs themselves arrive with
|
||||||
|
the systems they act on.
|
||||||
|
|
||||||
|
### Down, revive, bleed-out, wipe
|
||||||
|
|
||||||
|
**Adopted whole** in [Combat.md](Spec/Combat.md).
|
||||||
|
|
||||||
|
### Seeded generation
|
||||||
|
|
||||||
|
A dungeon generated from a seed on every peer, byte-identical, so the server replicates eight bytes instead of
|
||||||
|
megabytes. The determinism rules (own random stream, integer grid, ordered collections, no physics, no time), a
|
||||||
|
critical path the cart can traverse, side branches that split the party, a shortcut door that only opens from the
|
||||||
|
deep side, rooms as one-cell tiles in folders, typed spawn anchors with always/chance/budget rules, weighted
|
||||||
|
pools, and an append-only pass that guarantees a contract's target exists. The mechanism worth keeping is all of
|
||||||
|
it; it is the most worked-through part of the earlier project. Needs: a reason to have instanced places.
|
||||||
|
Must not: assume one instance per server or that generation runs on the client only; both hold in the earlier
|
||||||
|
design and both survive the move.
|
||||||
|
|
||||||
|
### Party-size scaling
|
||||||
|
|
||||||
|
Sublinear enemy budget (1.0, 1.75, 2.4, 3.0) and role relaxation below three players. **Adopted as a reserved
|
||||||
|
rule** in [Combat.md](Spec/Combat.md), applied to a count for now.
|
||||||
|
|
||||||
|
### Reputation, tiers, zones, upgrades
|
||||||
|
|
||||||
|
Standing that gates content, tiers that pay more, an upgrade bench where gold buys modifiers that combat pulls
|
||||||
|
by name (`GetModifier("cart.capacity")`), and the cross-class gear rule. The pull direction is the mechanism:
|
||||||
|
combat asks for its numbers, progression never pushes into combat. The signature guardrail is **adopted** as the
|
||||||
|
`bSignature` flag. Needs: a currency, a reason to spend. Must not: become per-body state; it lives on the player.
|
||||||
|
|
||||||
|
### The hall as hub and lobby
|
||||||
|
|
||||||
|
You launch into a place, not a menu. The lobby is a board by the door, readiness is a plinth you stand on, class
|
||||||
|
is a locker with racks, and everyone is physically together before the run. The mechanism is **the lobby is a
|
||||||
|
room**. Needs: a second map and session travel. Must not: assume a listen server; the room is on the same server
|
||||||
|
the world is.
|
||||||
|
|
||||||
|
### Steam lobbies and the friends-list join
|
||||||
|
|
||||||
|
Every launch hosts a lobby of one; opening it to friends is a physical act; a friend's "join game" carries them
|
||||||
|
in. Needs: a session layer in front of the server. Must not: put a session type inside gameplay code.
|
||||||
|
|
||||||
|
### Content authoring tools
|
||||||
|
|
||||||
|
Weighted pools, typed anchors, a tile template with red doorway volumes and a footprint validator, a project
|
||||||
|
content catalogue feeding dropdowns, and one browser window over every content asset. The engine's data
|
||||||
|
validation and asset manager do half of this natively. Needs: enough content to hunt through. Must not: arrive
|
||||||
|
before the third tileset.
|
||||||
|
|
||||||
|
### The design system
|
||||||
|
|
||||||
|
A palette by role, one label component, one theme, meaning never on hue alone. **Adopted** in
|
||||||
|
[UI.md](Spec/UI.md); the parchment-and-ink look itself is not.
|
||||||
|
|
||||||
|
## From Project Malleable
|
||||||
|
|
||||||
|
### Orders as tickets on a rail
|
||||||
|
|
||||||
|
A customer hands over a parchment; it hangs on a rail from the desk to the workshop; taking it off is the claim;
|
||||||
|
clipping it to a station shows what that station is working; handing it back with the item is the delivery. The
|
||||||
|
spec on the ticket is explicit (family, required tags, minimum quality) and matched by one pure function that
|
||||||
|
also explains why an item does not match. Five customer tiers as data, archetypes with patience and reactions.
|
||||||
|
The mechanism is **demand as a physical object you can pick up, hand over and drop**. Needs: a shop, an
|
||||||
|
economy, customers. Must not: be the only source of demand once players are the adventurers.
|
||||||
|
|
||||||
|
### The economy
|
||||||
|
|
||||||
|
Gold only. A payout split 20/40/40 between base, quality and speed against a tier price, clamped so the ceiling
|
||||||
|
belongs to the order and profit is margin. A shared wallet with per-player attribution. A front shop with bid and
|
||||||
|
ask prices, NPC valuation computed independently so player prices cannot mint money, and haggling as a turn-based
|
||||||
|
exchange with a **truthful** likelihood meter. Needs: everything above it. Must not: introduce a lying surface;
|
||||||
|
every gauge in this project tells the truth and a haggling meter that dramatised would poison the others.
|
||||||
|
|
||||||
|
### Storage as a place
|
||||||
|
|
||||||
|
No shop-wide inventory, no stack counters, no station pulling from a container across the room. Containers with
|
||||||
|
slots, designated floor areas typed by what they hold, reservations per object so two players racing for the last
|
||||||
|
ingot find out at claim time, nothing destroyed by a full container, and a satchel bought later that adds pocket
|
||||||
|
slots (2, 3, 4, 6, 8, 10). The mechanism is **hauling is the game**, and the substance object model already
|
||||||
|
assumes it. Needs: more things than hands can hold. Must not: add an abstract bank; the object model's cost is
|
||||||
|
only affordable because storage caps the count.
|
||||||
|
|
||||||
|
### Crafting domains as professions
|
||||||
|
|
||||||
|
Five domains mapped one to one to five crafting classes (Forge Worker, Woodworker, Enchanter, Shopkeep,
|
||||||
|
Quartermaster) with speed and quality bonuses that never lock anything. Three domains are **adopted** as the
|
||||||
|
station gate in [Crafting.md](Spec/Crafting.md). The open question worth a real session: this project has combat
|
||||||
|
kits; are crafting professions a second axis on the same player, a choice against a kit, or folded into kits?
|
||||||
|
Needs: that decision. Must not: gate a station on a class; the rule is bonuses, never locks.
|
||||||
|
|
||||||
|
### Stations you place, upgrade and pack
|
||||||
|
|
||||||
|
Movable stations on a grid, per-station upgrades with a mesh per tier so level reads across the room, fittings as
|
||||||
|
visible sub-upgrades in named sockets, and stations repackaged into a carryable crate rather than sold, so nothing
|
||||||
|
is lost and no gold comes back. Needs: a place that is yours. Must not: put station state on the actor's
|
||||||
|
Blueprint; it is a replicated record.
|
||||||
|
|
||||||
|
### World events as a modifier service
|
||||||
|
|
||||||
|
A standalone service any system asks for its own modifier (`GetModifier(PriceBias, tag)`), events as assets with
|
||||||
|
scopes and channels, seeded per world, pushable from outside. The mechanism is **consumers ask, they never test
|
||||||
|
for an event**. Needs: a second system that wants tuning from outside. Must not: be consulted from inside an
|
||||||
|
activity tick.
|
||||||
|
|
||||||
|
### Travel and the town
|
||||||
|
|
||||||
|
The cart as the travel system: sit in it, pick a destination, cut to the scene. A town with a purchase surface
|
||||||
|
and a small bounded warehouse, staged so that a local town cannot smuggle in shared population or a marketplace
|
||||||
|
before the service that hosts them exists. Needs: a second place. Must not: make the warehouse a bank.
|
||||||
|
|
||||||
|
### Persistence and portable characters
|
||||||
|
|
||||||
|
Worlds keep the relationship, characters belong to the player and travel between worlds; ironman days with no
|
||||||
|
rewinds and a mid-day autosave; records as idempotent snapshots with schema versions and recomputed derived
|
||||||
|
fields; a provider interface with a local implementation forever. **Adopted as the identity and persistence
|
||||||
|
seams** in [Networking.md](Spec/Networking.md); the day, the world record and the character record are not built.
|
||||||
|
|
||||||
|
### The service posture
|
||||||
|
|
||||||
|
Dedicated servers, a validation layer behind the simulation, three responsiveness tiers, ask-then-show, no
|
||||||
|
rollback of visible world state, lag compensation only where timing is scored, and an incident doctrine (detect,
|
||||||
|
contain with a modifier push, trace by object id, remediate surgically, restore last). **Adopted as posture** in
|
||||||
|
[Networking.md](Spec/Networking.md) and [Telemetry.md](Spec/Telemetry.md). The layers themselves are not built and
|
||||||
|
nothing in gameplay code would change when they are.
|
||||||
|
|
||||||
|
### Adventure contracts, turned inside out
|
||||||
|
|
||||||
|
Malleable sent NPC parties out with your gear to bring rare substances back. Here the players are the party.
|
||||||
|
The loop that closes when the three feels exist: **craft a weapon, fight with it, bring back what only fighting
|
||||||
|
finds, craft something better.** Rare substances as drops and finds with provenance already in the data model
|
||||||
|
(`Provenance.Found`, `SourceObjectIds`). This is the most likely next step after fifteen and it is deliberately
|
||||||
|
not written as one.
|
||||||
|
|
||||||
|
### Discovery at scale, alloys, deciphering
|
||||||
|
|
||||||
|
Generated glyphs discovered like skill unlocks (with the constraint that the composition path never changes and
|
||||||
|
any seed is global), a kiln that alloys two raw types (with the constraint that nobody hard-codes one-to-one
|
||||||
|
processing), and customer requests as rough descriptions to interpret rather than specs to read. Each is its own
|
||||||
|
session. Must not: happen by accident in a step about something else.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Documentation
|
||||||
|
|
||||||
|
Two layers, kept apart on purpose.
|
||||||
|
|
||||||
|
**For people.** Short, and meant to be read end to end.
|
||||||
|
|
||||||
|
| Doc | Covers |
|
||||||
|
| --- | --- |
|
||||||
|
| [Design.md](Design.md) | What this is, the three things it proves, the fixed decisions, what it is not yet, the glossary. Start here. |
|
||||||
|
| [Steps.md](Steps.md) | The ladder of work: fifteen rungs, each closed by a proof. The next few in full, the rest sketched. |
|
||||||
|
| [Ideas.md](Ideas.md) | The ideas carried over from the two earlier projects as features a world could hold, with what each needs. None scheduled. |
|
||||||
|
| [Decisions.md](Decisions.md) | One line per decision with a pointer, the open decisions that block steps, and what is deliberately deferred. |
|
||||||
|
| [Worklog.md](Worklog.md) | One terse line per step closed or wall hit. The memory. |
|
||||||
|
|
||||||
|
**For an implementer that lifts code**, which today means Claude. Long, C++-shaped, and meant to be built from.
|
||||||
|
|
||||||
|
| Doc | Covers |
|
||||||
|
| --- | --- |
|
||||||
|
| [Spec/README.md](Spec/README.md) | Notation, status markers, the twelve cross-cutting rules every spec obeys. Read before any spec. |
|
||||||
|
| [Spec/Architecture.md](Spec/Architecture.md) | Modules, lifetimes, authority, content, tags, the C++/Blueprint line, testing, conventions, gotchas. |
|
||||||
|
| [Spec/Stats.md](Spec/Stats.md) | The one stat block every body carries, effects and where they come from, stacking, counters. |
|
||||||
|
| [Spec/Movement.md](Spec/Movement.md) | The character, the movement component, input, the look model, camera modes, the gym, the feel checklist. |
|
||||||
|
| [Spec/Interaction.md](Spec/Interaction.md) | The one interaction system, carrying, throwing, highlighting. |
|
||||||
|
| [Spec/Combat.md](Spec/Combat.md) | Attributes, the damage funnel, the swing, enemies, downed and revive, abilities and kits, projectiles. |
|
||||||
|
| [Spec/Crafting.md](Spec/Crafting.md) | Families, parts, pieces, traits, substances, the pure rules, stations, activities, enchanting, the weapon seam. |
|
||||||
|
| [Spec/Networking.md](Spec/Networking.md) | The authority table, the responsiveness tiers, the posture, sessions, identity, persistence. |
|
||||||
|
| [Spec/Telemetry.md](Spec/Telemetry.md) | The sink, the envelope, the catalogue. |
|
||||||
|
| [Spec/UI.md](Spec/UI.md) | The HUD, the prompt, the theme, world-space text, localisation, the menu. |
|
||||||
|
|
||||||
|
## Reading order
|
||||||
|
|
||||||
|
New here: `Design.md`, then `Steps.md`, then `Spec/README.md` and `Spec/Architecture.md`. About to build a step:
|
||||||
|
the step's entry in `Steps.md`, then the spec sections it links. About to decide something: `Decisions.md` first,
|
||||||
|
to see whether it was already decided or deliberately deferred.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Version and status live in a document's header where it has one, never in its filename.
|
||||||
|
- Links are relative so the folder survives being moved.
|
||||||
|
- A spec that turns out wrong in the building is corrected in the same change as the code, under a **What was
|
||||||
|
built, and where it differs** heading at its end.
|
||||||
|
- A decision goes in `Decisions.md` the moment it is made. Newest first; never delete, supersede.
|
||||||
|
- The two earlier projects are referred to by name (Adventurer Guild, Project Malleable) when an idea's origin
|
||||||
|
matters, and not otherwise. Their docs are not copied here; they are in their own repositories.
|
||||||
@@ -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.
|
||||||
+270
@@ -0,0 +1,270 @@
|
|||||||
|
# Steps
|
||||||
|
|
||||||
|
The order of work. Not a roadmap: there are no phases, no dates and no milestones, only a ladder of small proofs
|
||||||
|
where each rung stands on the one below. The next few steps are written in full. The rest are sketches, and a
|
||||||
|
sketch is detailed only when the step before it closes; anything written further ahead than that would be a plan
|
||||||
|
pretending not to be one.
|
||||||
|
|
||||||
|
The [`Spec/`](Spec/README.md) documents specify; this document schedules. A step links the sections it builds and
|
||||||
|
never restates them. If a step and its spec disagree, the spec wins and this file is corrected.
|
||||||
|
|
||||||
|
## The rules of the ladder
|
||||||
|
|
||||||
|
- **A step is closed by a proof.** A test passes, a checklist is ticked, a person performs the sentence.
|
||||||
|
"Implemented" closes nothing.
|
||||||
|
- **A step lands with its docs.** The spec's "what was built, and where it differs" section, a decision-log line
|
||||||
|
if a decision was taken, a worklog line, and the status here, all in the same change.
|
||||||
|
- **A step that cannot be closed by one proof is two steps.** A step under half a day belongs inside a neighbour.
|
||||||
|
- **Ids are stable.** A dropped step is struck through with a reason, never renumbered.
|
||||||
|
- **Every replicated step is proved with a dedicated server** in the editor and network emulation at 100 ms and
|
||||||
|
5 % loss. See [Networking.md](Spec/Networking.md).
|
||||||
|
|
||||||
|
Status: `☐` not started · `◐` in progress · `☑` done · `⊘` dropped · `⏸` blocked (names the blocker)
|
||||||
|
|
||||||
|
## The ladder
|
||||||
|
|
||||||
|
| # | Step | Proves | Status |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | The project, two modules, tests, a dedicated server in the editor | It builds, tests run headless, and the server posture is real from the first commit | ☐ |
|
||||||
|
| 2 | The telemetry seam | Every later step can emit into something | ☐ |
|
||||||
|
| 3 | A body, a camera, an input map and the gym | You can walk, in both camera modes, on a server | ☐ |
|
||||||
|
| 4 | The feel pass | The written checklist passes with a person at the keyboard | ☐ |
|
||||||
|
| 5 | The ability foundation and the stat block, proved on dodge, blink and mud | Abilities predict cleanly, and every body has the one set of numbers every effect changes | ☐ |
|
||||||
|
| 6 | Interaction and carrying | One system touches every prop; things you hold are real | ☐ |
|
||||||
|
| 7 | The swing | One damage funnel, one weapon, one dummy, and it feels good to hit | ☐ |
|
||||||
|
| 8 | The goblin | An enemy with a tell, going down, being revived, a wipe | ☐ |
|
||||||
|
| 9 | Kits and the action bar | Two classes with a signature each, and the bar tells the truth about keys | ☐ |
|
||||||
|
| 10 | The second pair of kits | Parity: four kits finish the same fight | ☐ |
|
||||||
|
| 11 | Projectiles | Aim matters at range | ☐ |
|
||||||
|
| 12 | Crafting data and rules | The pure rules pass their tests; the reference example composes | ☐ |
|
||||||
|
| 13 | The bench | You assemble a sword, equip it, and its quality changes the damage number | ☐ |
|
||||||
|
| 14 | Substances and the forge | Ore becomes ingot through a bed you tend, and walking away is not an exploit | ☐ |
|
||||||
|
| 15 | The anvil and enchanting | Ore to ingot to blade to a sword of embers, all physical, all server-validated | ☐ |
|
||||||
|
|
||||||
|
What comes after fifteen is decided when fifteen closes. The candidates are in [`Ideas.md`](Ideas.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1 · The project, two modules, tests, a dedicated server in the editor
|
||||||
|
|
||||||
|
**Build:** The `.uproject` at the repository root; runtime modules `<Project>Core` and `<Project>` with the folder
|
||||||
|
layout from Architecture; the `Server` build target; plugins enabled: Gameplay Abilities, Gameplay Tags, Enhanced
|
||||||
|
Input, CommonUI, StateTree; `Config/Tags/` with the top-level namespaces; the asset manager configured for the
|
||||||
|
definition types; `Scripts/run-tests.sh` and `Scripts/build.sh`; `.gitattributes` with Git LFS for binary assets;
|
||||||
|
one placeholder automation test in Core; an empty `L_Gym`.
|
||||||
|
**Spec:** [Architecture.md](Spec/Architecture.md) (modules, content, tags, testing, conventions).
|
||||||
|
**Depends:** the open decisions on engine version and project name ([Decisions.md](Decisions.md), OD-01, OD-02).
|
||||||
|
**Done when:** the editor opens the project with no warnings about the modules; `Scripts/run-tests.sh` runs the
|
||||||
|
placeholder test headless and reports green; Play In Editor with two clients and "Run Dedicated Server" puts two
|
||||||
|
default pawns in the empty gym; `git lfs ls-files` lists the first `.uasset`.
|
||||||
|
**Notes:** The launcher build of the engine cannot compile a packaged server; the editor's dedicated-server option
|
||||||
|
is the everyday test and is enough until a packaged build is wanted. Do not create a third module, a plugin or an
|
||||||
|
editor module here; each arrives when something needs it.
|
||||||
|
|
||||||
|
### 2 · The telemetry seam
|
||||||
|
|
||||||
|
**Build:** `ITelemetrySink` with the null, log and JSON Lines sinks; `FTelemetryEvent` and the envelope; the
|
||||||
|
event-name constants; `UTelemetrySubsystem` on the game instance; `app_started`, `session_started`,
|
||||||
|
`session_ended`, `cheat_used`; the session id minted by the game mode and adopted by clients through the game state.
|
||||||
|
**Spec:** [Telemetry.md](Spec/Telemetry.md).
|
||||||
|
**Depends:** 1.
|
||||||
|
**Done when:** launching the gym with `-telemetry` produces `Saved/Telemetry/session_*.jsonl` with `app_started`
|
||||||
|
then `session_started`, every line parses, and a client's file carries the server's session id; the sink test
|
||||||
|
passes; `bs.TelemetryTest` emits an event and sets `cheats_used`.
|
||||||
|
**Notes:** Before the character, deliberately. Adding emit calls to a system that already exists means reading
|
||||||
|
it again and guessing what mattered; adding them as it is written costs a line.
|
||||||
|
|
||||||
|
### 3 · A body, a camera, an input map and the gym
|
||||||
|
|
||||||
|
**Build:** `ABaseCharacter`, `APlayerCharacter`, `UExtendedCharacterMovement` with the sprint flag in the saved
|
||||||
|
move, `UMovementTuning` and `DA_Tuning_Player`; `IMC_Gameplay` and every `IA_*` from the layout table, bound by
|
||||||
|
asset; `FLookModel` in Core with its tests; `UCameraModeDefinition` for both modes and `UCameraModeComponent` with
|
||||||
|
the stabilised head socket, the hidden local head, the spring arm; `RemoteHeadYaw`; the mannequin with the
|
||||||
|
template's animation blueprint; the gym greyboxed to the section table; the first `movement_sample`, `jump`,
|
||||||
|
`land` and `camera_mode_changed` events; the Settings widget's comfort sliders (FOV, stabilisation, bob, motion
|
||||||
|
scale) and the rebind rows through the engine's user settings.
|
||||||
|
**Spec:** [Movement.md](Spec/Movement.md) (input, the character, the look model, camera modes, the gym),
|
||||||
|
[UI.md](Spec/UI.md) (the menu and settings).
|
||||||
|
**Depends:** 2.
|
||||||
|
**Done when:** the whole gym can be walked in both camera modes; the five look-model tests pass; two clients and a
|
||||||
|
dedicated server at 100 ms and 5 % show smooth remote bodies and no visible local correction with
|
||||||
|
`p.NetShowCorrections 1`; a rebind of Interact survives a restart; every setting change emits `settings_changed`.
|
||||||
|
**Notes:** Sprint is a compressed flag, not an ability. Coyote time and the jump buffer are in this step because
|
||||||
|
they are the movement component's business; the feel numbers are guesses until step 4. `GetMaxSpeed` already
|
||||||
|
multiplies by the `MoveSpeed` attribute, which reads as one until the stat block exists in step 5. The head is
|
||||||
|
hidden by bone on the local pawn only; this is the rigging rule that everything head-worn must parent to the head
|
||||||
|
bone.
|
||||||
|
|
||||||
|
### 4 · The feel pass
|
||||||
|
|
||||||
|
**Build:** Nothing new. The tuning asset's numbers, the gym's sections adjusted where they lied, the three gym
|
||||||
|
functional tests (`FT_Gym_Stairs`, `FT_Gym_Slopes`, `FT_Gym_Gaps`), and a worklog entry with the numbers that made
|
||||||
|
the checklist pass and the ones that did not.
|
||||||
|
**Spec:** [Movement.md, The feel checklist](Spec/Movement.md#the-feel-checklist).
|
||||||
|
**Depends:** 3.
|
||||||
|
**Done when:** every line of the checklist is ticked in both camera modes by a person at the keyboard, the three
|
||||||
|
functional tests pass, and `DA_Tuning_Player` is committed with the values that did it.
|
||||||
|
**Notes:** This is the step most likely to be called done early. It is not done while any line says "mostly".
|
||||||
|
Q1 in Movement (the default camera mode) is not decided here; both modes must pass.
|
||||||
|
|
||||||
|
### 5 · The ability foundation and the stat block, proved on dodge, blink and mud
|
||||||
|
|
||||||
|
**Build:** `UExtendedAbilitySystemComponent` on the player state with input-tag routing, `ApplyDefaults` and
|
||||||
|
`ApplyStatus`; `InitAbilityActorInfo` on both sides; `UStatBlockAttributeSet` with every attribute in the table
|
||||||
|
and its clamps, `FStatBlockDefaults` and `GE_InitStats`; the movement component reading `MoveSpeed` and
|
||||||
|
`JumpPower`; `GE_Status_Slow`, `GE_Status_Haste`, `GE_Immune_Slow`, `GE_Cleanse` with their cues and the
|
||||||
|
`Status.*`, `Immune.*` and `Surface.*` tags; `AEffectVolume`; `UPhysicalMaterialWithTags`, the ground trace and
|
||||||
|
the gym's mud and ice patches; `UKitAbility`; `GA_Dodge` (constant-force root motion, `State.Invulnerable` for its
|
||||||
|
duration, a cooldown effect) and `GA_Blink` (move-to-force along the aim, flattened); `IA_Dodge` and `IA_Ability1`
|
||||||
|
routed by tag; the HUD's status icon row; `ability_used`, `status_applied`, `status_blocked`, `status_removed`.
|
||||||
|
**Spec:** [Stats.md](Spec/Stats.md), [Combat.md](Spec/Combat.md) (abilities and kits),
|
||||||
|
[Movement.md, Hooks for other systems](Spec/Movement.md#hooks-for-other-systems).
|
||||||
|
**Depends:** 4.
|
||||||
|
**Done when:** dodge and blink activate on their keys, predict locally and show no visible correction at 100 ms and
|
||||||
|
5 %; a dodge through a debug damage volume takes no damage during its i-frames and damage after; walking onto the
|
||||||
|
mud slows the body to the surface's magnitude and the icon appears, walking off restores it; a slow volume over
|
||||||
|
the mud does not stack with it but a haste multiplies against it; `bs.ApplyStatus Slow 0.5 10` then
|
||||||
|
`bs.GrantImmunity Slow` shows the blocked cue on the next application; the resistance and carry-factor tests and
|
||||||
|
the attribute clamp test pass; `bs.GrantAbility`, `bs.ApplyStatus`, `bs.GrantImmunity` and `bs.Cleanse` exist.
|
||||||
|
**Notes:** GAS proves itself on movement before combat depends on it, because a movement ability that snaps is
|
||||||
|
the most visible thing prediction can get wrong. The stat block lands here rather than in combat so that the
|
||||||
|
first debuff, the first patch of ground and the carry penalty in step 6 all arrive on a system that exists,
|
||||||
|
instead of each growing a field of its own. `UAbilitySystemGlobals::InitGlobalData` is called from the asset
|
||||||
|
manager's startup or target data will not serialise; it is the classic first-day GAS bug.
|
||||||
|
|
||||||
|
### 6 · Interaction and carrying
|
||||||
|
|
||||||
|
**Build:** `IInteractable`, `FInteractionPrompt`, `UInteractionSubsystem` with the server funnel,
|
||||||
|
`UInteractionComponent` with the camera sweep, the highlight sweep and hold-to-confirm; the `Interactable` and
|
||||||
|
`Carryable` trace channels; `UInteractionHighlightComponent` and the outline post-process; `WBP_InteractionPrompt`
|
||||||
|
reading the live glyph; `ICarryable`, `UCarryableComponent`, `UCarryComponent` with hands, pick up, drop, throw
|
||||||
|
(G tap and hold) and hand over; `State.Carrying` tags blocking the right verbs; `GE_Encumbered` from
|
||||||
|
`CarryMath::SpeedFactor` against `CarryCapacity`; two test props in the gym (a lever that toggles a light, a
|
||||||
|
crate that is two-handed); all six interaction events.
|
||||||
|
**Spec:** [Interaction.md](Spec/Interaction.md).
|
||||||
|
**Depends:** 5.
|
||||||
|
**Done when:** the lever's prompt appears only in reach and greys with a reason from too far; a request forged
|
||||||
|
from beyond reach is refused and emits `interaction_refused`; the crate blocks the lever with "hands full"; a throw
|
||||||
|
lands within tolerance on two clients; the outline shows which of two crates the press will take; the interaction
|
||||||
|
functional test passes.
|
||||||
|
**Notes:** Before combat, because a downed teammate is a prop and reviving must not invent a mechanism. Pick-up is
|
||||||
|
routed by the interaction component but is not an interaction; the boundary is in the spec and it is load-bearing.
|
||||||
|
|
||||||
|
### 7 · The swing
|
||||||
|
|
||||||
|
**Build:** `DamageMath` and its tests; `UDamageExecution`, `GE_Damage` and the friendly-fire gate through
|
||||||
|
`IGenericTeamAgentInterface`; `FWeaponProfile`, `UWeaponDefinition`, `AWeaponActor` on the hand socket;
|
||||||
|
`GA_MeleeAttack` with the montage, `ANS_HitWindow`, the server box sweep and the line-of-sight check; the hit
|
||||||
|
cues: impact, client-side hit stop, camera kick, the procedural hit reaction; `GE_FallDamage` from `Landed`; a
|
||||||
|
training dummy in the arena with a health number over it; the health HUD; `player_damaged`, `enemy_damaged`.
|
||||||
|
**Spec:** [Combat.md](Spec/Combat.md) (the damage funnel, weapons and the melee swing).
|
||||||
|
**Depends:** 6.
|
||||||
|
**Done when:** the dummy takes exactly the profile's damage once per swing and none through a wall; a swing at
|
||||||
|
another player moves them and costs no health, and the functional test asserts both; a 500 cm drop damages; hit
|
||||||
|
stop never runs on the server; and a person says hitting the dummy feels good and writes the numbers down.
|
||||||
|
**Notes:** Combat feel is the honest unknown of the whole ladder. Budget iteration here and do not move on until
|
||||||
|
one dummy is satisfying. The box after the wind-up is the shipped shape; a blade sweep is the fallback in Q4.
|
||||||
|
|
||||||
|
### 8 · The goblin
|
||||||
|
|
||||||
|
**Build:** `UEnemyDefinition` with its `BaseStats` and `DA_Enemy_Goblin`; `AEnemyCharacter` with its own ability
|
||||||
|
system and the same stat block, so the mud from step 5 slows it;
|
||||||
|
`AEnemyController` with perception, the StateTree (idle, chase, attack with wind-up, flee, leash) and
|
||||||
|
`FThreatTable`; `UCombatantComponent` with the downed state, `GA_Downed`, `GE_BleedOut`, the revive prop,
|
||||||
|
`GA_Revive`, the solo-down-is-a-wipe rule; `UEncounterSubsystem` spawning from spawn points in the arena with the
|
||||||
|
sublinear table; respawn on wipe; every remaining combat event.
|
||||||
|
**Spec:** [Combat.md](Spec/Combat.md) (health, down, revive, wipe; enemies).
|
||||||
|
**Depends:** 7.
|
||||||
|
**Done when:** three goblins fight one player and the tells can be read and stepped out of; a goblin chasing
|
||||||
|
across the mud is slowed exactly as the player is; a downed player is revived through the interaction path by a
|
||||||
|
second client; alone, a down is an immediate wipe and the arena resets; the goblin flees for its flee time when an
|
||||||
|
ally dies; the threat table tests pass.
|
||||||
|
**Notes:** The enemy uses the same melee ability class as the player on its own component. Numbers are the earlier
|
||||||
|
project's guesses (30 health, 8 damage every 1.2 s with a 0.35 s wind-up) until they are not.
|
||||||
|
|
||||||
|
### 9 · Kits and the action bar
|
||||||
|
|
||||||
|
**Build:** `UClassKitDefinition` with `BaseStats`, `GrantKit`; the Warrior (Taunt as `Status.Debuff.Taunted`, Shoulder charge) and the
|
||||||
|
Cleric (Mass heal, Mend with the overheal launch, faster revive); `bs.SetClass`; cooldown effects per ability;
|
||||||
|
`WBP_ActionBar` with live glyphs and cooldowns; `grief_action` from the charge and the launch.
|
||||||
|
**Spec:** [Combat.md, Abilities and kits](Spec/Combat.md#abilities-and-kits), [UI.md, The HUD](Spec/UI.md#the-hud).
|
||||||
|
**Depends:** 8.
|
||||||
|
**Done when:** a taunt pulls every goblin in range off a teammate; a charge damages goblins and launches a
|
||||||
|
teammate without damaging them; an overheal launches; the bar shows the rebound key after a rebind; the kit
|
||||||
|
validation test refuses a second signature.
|
||||||
|
**Notes:** Class lives on the player state and survives a body dying. The recoverability contract applies to the
|
||||||
|
charge and the launch from the first day they exist: the victim's recovery is standing up and walking back.
|
||||||
|
|
||||||
|
### 10 · The second pair of kits
|
||||||
|
|
||||||
|
**Build:** The Rogue (Backstab, Shadowstep) and the Mage's Blink promoted into a kit with a placeholder second
|
||||||
|
ability; the parity measurement: `enemy_killed.killer_class` against `time_to_kill_s` over a scripted arena wave.
|
||||||
|
**Spec:** [Combat.md, Abilities and kits](Spec/Combat.md#abilities-and-kits).
|
||||||
|
**Depends:** 9.
|
||||||
|
**Done when:** four kits each clear the same three-goblin wave solo within a spread the worklog records; no
|
||||||
|
ability writes health directly (grep the abilities for the attribute setter and find nothing).
|
||||||
|
**Notes:** Sketch. Detail when 9 closes.
|
||||||
|
|
||||||
|
### 11 · Projectiles
|
||||||
|
|
||||||
|
**Build:** `AProjectileActor`, the local cosmetic copy on activation, `GA_Volley` and the bow profile, `GA_Fireball`
|
||||||
|
for the Mage; the Ranger kit (Mark, Volley).
|
||||||
|
**Spec:** [Combat.md, Projectiles](Spec/Combat.md#projectiles-step-11).
|
||||||
|
**Depends:** 10.
|
||||||
|
**Done when:** an arrow leaves the hand without a visible round trip at 100 ms, drops over distance, and damages
|
||||||
|
through the funnel with `Damage.Source.Projectile`; a marked goblin takes more from a teammate's sword.
|
||||||
|
**Notes:** Sketch.
|
||||||
|
|
||||||
|
### 12 · Crafting data and rules
|
||||||
|
|
||||||
|
**Build:** Every definition class, `FPieceInstance`, `FAssembledItem`, `FSubstanceInstance`, `UCraftingConfig`;
|
||||||
|
the `Item.*`, `Piece.*`, `Substance.*`, `Theme.*`, `Domain.*` and `Craft.Reason.*` tags; every pure rule in
|
||||||
|
`CraftingRules` with its named tests; `DA_Family_Sword` with the four piece types, four characteristics, iron and
|
||||||
|
oak, two enchantments; `bs.SpawnPiece`.
|
||||||
|
**Spec:** [Crafting.md](Spec/Crafting.md) (data, runtime state, the pure rules).
|
||||||
|
**Depends:** 6 (for the carryable piece actor). Can run in parallel with 7 to 11 in the core module.
|
||||||
|
**Done when:** the reference example composes to *Blunt Mithril Sword of Embers*; every rejection reason has a
|
||||||
|
passing named test; a guardless sword validates; the durability case computes; `MakeWeaponProfile` lands a
|
||||||
|
mid-quality crafted sword inside the authored sword's numbers.
|
||||||
|
**Notes:** Sketch. The rules exist before a bench does, on purpose: word salad in names is the risk that surfaces
|
||||||
|
earliest this way.
|
||||||
|
|
||||||
|
### 13 · The bench
|
||||||
|
|
||||||
|
**Build:** `AStationActor`, `AAssemblyBench` with staging, the live preview on a world-space panel laid out by the
|
||||||
|
family's grid, and the commit through `ServerAssembleItem`; `AAssembledItemActor` built from resolved attach
|
||||||
|
chains on named sockets, tinted by substance; the Equip verb and the crafted weapon in hand; `item_assembled`,
|
||||||
|
`assembly_rejected`, `weapon_equipped`.
|
||||||
|
**Spec:** [Crafting.md, Stations](Spec/Crafting.md#stations), [From item to weapon](Spec/Crafting.md#from-item-to-weapon).
|
||||||
|
**Depends:** 12, 7.
|
||||||
|
**Done when:** three spawned pieces are inserted, the panel shows the name and the synergy segment, the commit
|
||||||
|
produces the sword on the bench, a second client sees the blade on the handle's socket, equipping it changes the
|
||||||
|
damage number on the dummy in proportion to coherence, and a piece the slot rejects shows the reason.
|
||||||
|
**Notes:** Sketch. This is the step where crafting and fighting meet; it is why both exist.
|
||||||
|
|
||||||
|
### 14 · Substances and the forge
|
||||||
|
|
||||||
|
**Build:** `ASubstanceActor` as a carryable; the raw-to-processed pipeline; `UActivityRuntime`,
|
||||||
|
`UActivityDefinition`, `FActivityResult`; `AForgeStation` with the 5×5 bed, the near simulation at 10 Hz, the far
|
||||||
|
lumped model, the collapse and expand, the equivalence harness; place, rake, fuel and bellows inputs; the
|
||||||
|
bellows as a second operator; the substance spawner cheat.
|
||||||
|
**Spec:** [Crafting.md, Substances](Spec/Crafting.md#substances), [Activities](Spec/Crafting.md#activities).
|
||||||
|
**Depends:** 13.
|
||||||
|
**Done when:** iron ore in the bed becomes an ingot with a quality that reflects time in the working band; a
|
||||||
|
second client on the bellows raises the ceiling; walking away and coming back yields the same ingot as staying,
|
||||||
|
within the harness's tolerance; the output buffer full stalls the forge and says so.
|
||||||
|
**Notes:** Sketch. The heat harness is written with the first heat code, not after.
|
||||||
|
|
||||||
|
### 15 · The anvil and enchanting
|
||||||
|
|
||||||
|
**Build:** `AAnvilStation` and the strike activity with the growing zone, the temperature gate, the timestamped
|
||||||
|
strike, two strikers alternating; characteristic discovery on the player state; an enchanting station and
|
||||||
|
`ServerApplyEnchantment`; the enchantment reaching the weapon profile's damage type and granted tags.
|
||||||
|
**Spec:** [Crafting.md, Activities](Spec/Crafting.md#activities), [Enchanting](Spec/Crafting.md#enchanting).
|
||||||
|
**Depends:** 14.
|
||||||
|
**Done when:** ore to ingot to blade to a sword of embers, all physical objects, all server-validated, in one
|
||||||
|
session with two clients; a strike at 150 ms of emulated latency scores the same as one at 0 ms pressed at the
|
||||||
|
same moment; with no input the anvil completes at the baseline score; the enchanted sword's hit carries
|
||||||
|
`Item.Enchant.Fire.Penetration` into the funnel.
|
||||||
|
**Notes:** Sketch. When this closes, the three feels exist, and what to build next is a decision taken then, from
|
||||||
|
[`Ideas.md`](Ideas.md), with the telemetry of fifteen steps to take it with.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Worklog
|
||||||
|
|
||||||
|
One line per item, newest at the top of each section. What was done, what worked, what did not. Keep it terse;
|
||||||
|
the reasoning lives in the specs, this is the memory.
|
||||||
|
|
||||||
|
## Done
|
||||||
|
|
||||||
|
- 2026-09-15 — `Spec/Stats.md`: one stat block on every body, every outside influence an effect, counters on the
|
||||||
|
receiver (D-37, D-38). Replaced the movement component's own speed-multiplier map, which was the first strand
|
||||||
|
of the spaghetti this exists to prevent; enemies and kits now carry `FStatBlockDefaults`; step 5 builds it.
|
||||||
|
- 2026-09-15 — The documentation set written for Unreal from the two earlier projects' docs: `Design.md`,
|
||||||
|
`Steps.md` (fifteen rungs, the first six in full), `Ideas.md`, `Decisions.md` (D-01 to D-36, OD-01 to OD-08),
|
||||||
|
and the eight specs under `Spec/`. No code, no project yet; step 1 is next and waits on OD-01 and OD-02.
|
||||||
|
|
||||||
|
## Worked
|
||||||
|
|
||||||
|
- (nothing built yet)
|
||||||
|
|
||||||
|
## Did not work
|
||||||
|
|
||||||
|
- (nothing built yet)
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
- OD-01 (engine version) and OD-02 (project name) block step 1.
|
||||||
|
- The default camera mode (OD-03) is deliberately undecided until step 8.
|
||||||
|
- The numbers in every spec are the earlier projects' guesses in centimetres and seconds. None has been played.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Unreal prototype
|
||||||
|
|
||||||
|
An Unreal Engine 5 project that builds three things in order and proves each one feels right before anything is
|
||||||
|
built around it: a movement controller, fighting, and crafting. Server-authoritative from the first line, C++
|
||||||
|
first, data-driven, telemetry from step 2, and worked as a ladder of small proofs rather than a roadmap.
|
||||||
|
|
||||||
|
Nothing is built yet. The repository currently holds the documentation set.
|
||||||
|
|
||||||
|
## Read
|
||||||
|
|
||||||
|
- [`Docs/Design.md`](Docs/Design.md): what this is, in about two hundred lines. Start here.
|
||||||
|
- [`Docs/Steps.md`](Docs/Steps.md): the order of work and what closes each step.
|
||||||
|
- [`Docs/Spec/`](Docs/Spec/README.md): the specifications an implementer builds from.
|
||||||
|
- [`Docs/Ideas.md`](Docs/Ideas.md): what was carried over from the two earlier projects and not yet built.
|
||||||
|
- [`Docs/Decisions.md`](Docs/Decisions.md): every decision, the open ones, the deferred ones.
|
||||||
|
- [`CLAUDE.md`](CLAUDE.md): the instructions an AI agent works from in this repository.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
UnrealEnginePrototyping/
|
||||||
|
├── CLAUDE.md Instructions for an AI agent: conventions, layout, commands, upkeep.
|
||||||
|
├── README.md This file.
|
||||||
|
├── Docs/ Design, steps, ideas, decisions, worklog, and Spec/ with one document per system.
|
||||||
|
├── <Project>.uproject Created in step 1, at the root, which is what the .gitignore assumes.
|
||||||
|
├── Source/ <Project>Core (rules, no actors) and <Project> (gameplay).
|
||||||
|
├── Content/ Assets, one folder per feature; Maps/L_Gym; Tests/.
|
||||||
|
├── Config/ Engine and game config; Tags/ holds the gameplay tag sources.
|
||||||
|
└── Scripts/ Test and build scripts run by hand; Authoring/ for editor Python.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
After step 1: open `<Project>.uproject` with the pinned engine version, run `Scripts/run-tests.sh`, and press Play
|
||||||
|
in `L_Gym` with two clients and "Run Dedicated Server" on. Until then there is nothing to run.
|
||||||
|
|
||||||
|
Binary assets go through Git LFS; run `git lfs install` once per machine.
|
||||||
Reference in New Issue
Block a user