One handler per node type
A node type's logic lives in its own class — a handler the session routes to, after the step event everything else listens to.
All documentation — Playing a story
Start here
Concepts
Authoring for a build
Playing a story
The generated assets and code
The plugin and sync
On this page
- The handler
- Installing them
- The order, which is the contract
- What is refused, and what is not
- What a handler must never do
- A node that carries several lines
- A node whose picture carries places
- What an outcome hands over
- A node that places rows in time
- A node that teaches a control
- A checkpoint, and retry()
- Next
A project’s node types are invented by the person writing the story, and a game usually wants to do something different for each of them: a dialogue node draws a line, a combat node starts a fight, a beat node only sets a variable. The obvious way to write that is a switch on the type in one event, and it stops being readable at about the fifth type.
Instead, a node type’s logic lives in its own class. You write one TaleShardNodeHandler per
type you care about — as a Blueprint, in C++, or a Blueprint over a C++ base — and the session
sends each step to the one handler that claims its type.
The handler
Derive TaleShardNodeHandler, pick the node type in Class Defaults, and implement On Node Played.
#pragma once
#include "CoreMinimal.h"#include "TaleShardNodeHandler.h"#include "DocCombatNodeHandler.generated.h"
// One node type's logic: what the game does when a combat node is played.UCLASS()class UDocCombatNodeHandler : public UTaleShardNodeHandler{ GENERATED_BODY()
public: UDocCombatNodeHandler();
virtual void OnNodePlayed_Implementation(UTaleShardSession* Session, const FTaleShardPlayStep& Step) override;};#include "DocCombatNodeHandler.h"
#include "Support/DocFictions.h"#include "TaleShardFieldLibrary.h"#include "TaleShardStepLibrary.h"
UDocCombatNodeHandler::UDocCombatNodeHandler(){ // The type this handler answers for. In a Blueprint handler this is the Node Type field in // Class Defaults; in C++ it is the type's tag, requested by name. A tag no project declares // stays empty, and a handler with no node type is refused when the list is installed. NodeType = FGameplayTag::RequestGameplayTag(TEXT("TaleShard.NodeType.Combat"), false);}
void UDocCombatNodeHandler::OnNodePlayed_Implementation(UTaleShardSession* Session, const FTaleShardPlayStep& Step){ // Fields are read by CODE, on the step you were handed. There is no table of this type's // field keys to consult, and none can be written: a field inside a repeater is keyed by // the row instance, which exists only in one node's own data. FTaleShardResolvedField EnemyField; if (!UTaleShardStepLibrary::FindField(Step, FGameplayTag::RequestGameplayTag(TEXT("TaleShard.Field.enemy"), false), EnemyField)) { return; }
FTaleShardResolvedEntity Enemy; if (!UTaleShardFieldLibrary::GetEntity(EnemyField, Enemy)) { return; }
// Your code, not the plugin's. FDocEnemyFactory::Spawn(Enemy.Code);}FDocEnemyFactory there is not plugin API. It stands in for your own game code, and it is declared
in a shim header on this site so that is visible rather than implied.
Node Type is a gameplay tag under TaleShard.NodeType, picked from the tag tree rather than
typed: a node type’s stored id is custom:<uuid>, so the only readable way to name one is the tag
the sync registers for its code — and when the type is deleted in the editor and the project
re-synced, the tag leaves the tree, and a handler still naming it shows the break in the editor
instead of matching nothing in a shipped build.
A handler can hold references of its own — the widget this type opens, the sound it plays, the actor class it spawns — as ordinary properties on the Blueprint.
In a Blueprint handler, read the node’s fields with Get Node Field: right-click, open TaleShard > Node Fields > the type, and pick the field. The node takes the step On Node Played hands you, and its output pin is the field’s own type. A field that links to a node, a board, a document, a variable or a command has no typed pin of its own: it reads as a story value.
Installing them
Add each handler class to the Node Handlers list on the TaleShard Story actor or the TaleShard Player component. The order of that list means nothing: every handler names its own node type, so nothing about what your game does can change by dragging a row.
From code it is one call on the session: Use Node Handlers, with the list of classes. Install them before the story starts. A handler installed after a step has been presented does not see that step — nothing is replayed.
The session creates one object of each handler class for itself — never the class default object, and never shared with another session. So a handler may keep per-playthrough state on itself, and it lives exactly as long as the session it was installed into. From inside one, Get Session, Get Owning Component and Get Owning Actor reach the session that presented the step and whatever owns it.
The order, which is the contract
There is one event and a router in front of it, not two ways of hearing about a step.
- On Step Presented fires, for every step, handler or not.
- Then the handler whose node type matches runs, if there is one.
So the split is: whatever is the same for every step goes on the event — clearing the panel, writing the transcript line, enabling the Continue button — and whatever belongs to one node type goes in that type’s handler, which can assume the event has already run.
There is deliberately no default handler. The event is the default.
What is refused, and what is not
Three things are refused when the list is installed, all for the same reason — the alternative would change what your game does with nothing to read:
| Two handlers claiming one node type | Refused, naming both. Which one ran would otherwise be decided by the order of the list. |
| A handler naming no node type | Refused, naming the class. Nothing could ever reach it. |
| An empty row in the list | Refused, naming its position. An empty row is a handler somebody believes is installed. |
A node type with no handler is not an error. The step already reached On Step Presented, so
nothing was lost. The runtime notes it once per distinct node type on the diagnostics channel
under the code node-handler-missing — once per type, not once per step, because a note on every
step is a synonym for silence.
What a handler must never do
A handler is handed a resolved step. It reads fields by code with Find Field and repeater rows with Get Blocks.
It must never ask what fields its node type has. That table cannot be written by anyone: a field inside a repeater is keyed by the repeater, the row instance and the field, and the row instance exists only inside one node’s own data — so a type’s field keys are a property of each node, not of the type. Field codes are a property of the type, the sync registers a tag for each, and they are what a handler uses. The reasoning is node meaning is resolved once.
A node that carries several lines
The Exchange, a premade type a project adds from TaleShard’s Library, is a run of lines between several characters that plays as one
moment: each row of its lines repeater carries a speaker, the line, a pause in seconds to wait
after it, and an optional beat note; the player’s replies are the rows of its replies repeater,
one exit each, and they arrive as the step’s choices — never as lines.
Its handler reads the rows in order and hands them to your own presenter, which shows each line,
waits its pause, and only then offers the step’s choices — the order the editor’s own player keeps.
Which field is the pause is a fact the editor decides, not the handler: the block’s first
number field, whether or not the author hides it on the card. With generated types on it
reaches you as an accessor on the type’s library beside GetLinesBlocks — GetLinesPauseField(),
answering that field’s tag — so the handler hands it to Find Block Field on each row, exactly
as it hands a lane’s start, and paces by the field the editor’s own player paces by. A repeater
that is not drawn as a conversation has no pause accessor at all. A read block whose rows are
gated — a repeater that is not ported and whose block carries a condition field — gets a
Get<Repeater>GateField() accessor the same way, answering the tag of the field the engine skips
rows by: the rows that reach a handler are the ones whose gate passed, and the accessor is how a
handler finds the condition’s text on each block. A read repeater whose Plays is The first
that holds, One at random or Least recently said is narrowed the same way before the step
exists: a handler receives only the one row that plays, the random one drawn from the
playthrough’s seeded generator and the least recently said one read from the playthrough’s memory
of what it has said, so a save and a rollback show the same row — among the rows with a condition
whenever one of those holds, so a line that fits the moment is said before an “any time” filler.
A row whose Once field is
ticked plays once per playthrough; after that it is skipped exactly as a row whose gate failed. A
node whose track is taken by a variable — an hour, a turn, a day — is narrowed the same way:
the handler receives only the track row whose span holds the variable’s value, and when that row
is an exit the engine follows it rather than offering it.
A line the world triggers rather than the story — a guard spotting the player, a shopkeeper
greeting them — comes from the session’s Next Line: in Blueprint the node of that name, taking
the board’s tag (left empty, the project’s start board) and the node’s id; in C++
Session->NextLine(Board, Node, OutFields, OutReason). It answers from the node’s first read list
that has a row, by the same gate, Once, taken-by and Plays rules, with one row: the
Plays pick, or the first row that holds when the list has no Plays, so a list of Once
lines reads out in order, one per call. It remembers the row as said, and it presents no step,
moves no story, runs no script and counts no visit. It answers false, with the reason, when the
node has no read list or no row that holds, and when the board or the node cannot be found. Hand
the fields to the same presenter the handler uses. Under the session, the engine’s own
FTaleShardDialogueEngine::NextLine(BoardId, NodeId) answers the same row as core fields.
It also answers nothing, with an engine-busy warning, while the engine is drawing. Drawing
reports what it could not read on the diagnostic channel, so game code can run in the middle of it,
in one of two windows:
- Re-drawing a past moment — during Set Language, Redraw or Restore, each of which draws every moment of the history again against that moment’s own state and said lines. A Next Line asked there would read and record against a past moment and lose its record when the live state is put back.
- Drawing a live step — from the start of a node’s draw until its step is recorded and its row draws are spent. The draw only looks at the numbers its rows are picked with and spends them once the step is recorded, so a Next Line asked there would take the number the step’s own row was picked with, and a redraw, a rollback or a load of that step could show a different line.
Ask again once the call that draws has returned. A Next Line asked from a node handler is answered: the step is recorded and spent before the handler receives it.
#pragma once
#include "CoreMinimal.h"#include "TaleShardNodeHandler.h"#include "DocExchangeNodeHandler.generated.h"
// One node type's logic: what the game does when a node that carries several lines is played.UCLASS()class UDocExchangeNodeHandler : public UTaleShardNodeHandler{ GENERATED_BODY()
public: UDocExchangeNodeHandler();
virtual void OnNodePlayed_Implementation(UTaleShardSession* Session, const FTaleShardPlayStep& Step) override;};#include "DocExchangeNodeHandler.h"
#include "Support/DocFictions.h"#include "TaleShardFieldLibrary.h"#include "TaleShardStepLibrary.h"
UDocExchangeNodeHandler::UDocExchangeNodeHandler(){ NodeType = FGameplayTag::RequestGameplayTag(TEXT("TaleShard.NodeType.Exchange"), false);}
void UDocExchangeNodeHandler::OnNodePlayed_Implementation(UTaleShardSession* Session, const FTaleShardPlayStep& Step){ // A node that carries several lines: its repeater's rows, in the author's order. Each row is // a block of resolved fields read by code, like the node's own; the repeater is named by its // tag, and so is each field of a row. The pause is the one field the editor's own player // paces by: with generated types on, the type's library answers it as the field's tag, such // as UTaleShardExchangeFields::GetLinesPauseField; by tag the pause is an ordinary block field. TArray<FDocSpokenLine> Lines; const FGameplayTag LinesTag = FGameplayTag::RequestGameplayTag(TEXT("TaleShard.Repeater.lines"), false); const FGameplayTag PauseTag = FGameplayTag::RequestGameplayTag(TEXT("TaleShard.Field.pauseAfter"), false); for (const FTaleShardFieldBlock& Row : UTaleShardStepLibrary::GetBlocks(Step, LinesTag)) { FString SpeakerName; FString Text; for (const FTaleShardResolvedField& Field : Row.Fields) { if (Field.Code == TEXT("speaker")) { FTaleShardResolvedEntity Speaker; if (UTaleShardFieldLibrary::GetEntity(Field, Speaker)) { SpeakerName = Speaker.Name; } } else if (Field.Code == TEXT("line")) { Text = UTaleShardFieldLibrary::GetText(Field).ToString(); } } // A line with no speaker is the narrator's. A row whose line is empty is still a beat: the // editor's own player shows its bubble empty and waits its pause. The pause is seconds to // wait AFTER this line before the next one; an unfilled pause is no wait at all. FTaleShardResolvedField PauseField; double PauseAfter = 0.0; if (UTaleShardStepLibrary::FindBlockField(Row, PauseTag, PauseField)) { UTaleShardFieldLibrary::GetNumber(PauseField, PauseAfter); } Lines.Add(FDocSpokenLine{ SpeakerName, Text, static_cast<float>(PauseAfter) }); }
// Your code, not the plugin's: show each line, wait its pause, and only then offer the // step's choices, which is the order the editor's own player keeps. FDocConversationPresenter::Play(Lines, UTaleShardStepLibrary::GetChoices(Step));}FDocSpokenLine and FDocConversationPresenter are your code, not plugin API — the presenter is
where the pacing lives, on your own timer. The handler’s job is the plugin’s half: the rows come
from Get Blocks in the author’s order, every field of a row is read by its code — the pause by its
role’s tag, which is one field read by code on every side — a row whose speaker is empty is the
narrator’s, and a row whose line is empty is still a beat: the editor’s own player shows its
bubble empty and waits its pause, so the sample presents it too.
A node whose picture carries places
A Location is a picture with places on it: a spots repeater whose rows each carry a name and a
position — a point on the picture — and a zones repeater whose rows carry a name and an
area — a rectangle on it. Both arrive in the picture’s own space: Get Point answers a 2D vector
whose X and Y are fractions 0..1 of the picture’s box from its top-left corner, and Get Rect answers
a 2D box whose Min and Max are the corners the same way. The plugin never sees pixels; multiply by
the drawn picture’s size. Which picture a point belongs to is a fact the author chose in the editor,
and with generated types on it reaches you as a second accessor beside the field’s own, suffixed
PictureField, answering the picture field’s tag.
#pragma once
#include "CoreMinimal.h"#include "TaleShardNodeHandler.h"#include "DocLocationNodeHandler.generated.h"
// One node type's logic: what the game does when a node whose picture carries places is played.UCLASS()class UDocLocationNodeHandler : public UTaleShardNodeHandler{ GENERATED_BODY()
public: UDocLocationNodeHandler();
virtual void OnNodePlayed_Implementation(UTaleShardSession* Session, const FTaleShardPlayStep& Step) override;};#include "DocLocationNodeHandler.h"
#include "Support/DocFictions.h"#include "TaleShardFieldLibrary.h"#include "TaleShardStepLibrary.h"
UDocLocationNodeHandler::UDocLocationNodeHandler(){ NodeType = FGameplayTag::RequestGameplayTag(TEXT("TaleShard.NodeType.Location"), false);}
void UDocLocationNodeHandler::OnNodePlayed_Implementation(UTaleShardSession* Session, const FTaleShardPlayStep& Step){ // The picture the places are drawn on. With generated types on, each position or area names // the picture field it belongs to through a second accessor beside its own, such as // UTaleShardLocationFields::GetSpotsWherePictureField; by tag the picture is an ordinary // field of the node. FTaleShardResolvedField PictureField; if (!UTaleShardStepLibrary::FindField(Step, FGameplayTag::RequestGameplayTag(TEXT("TaleShard.Field.picture"), false), PictureField)) { return; } FTaleShardMediaRef Picture; if (!UTaleShardFieldLibrary::GetMedia(PictureField, Picture)) { return; }
// A spot is a named point on the picture: Get Point answers X and Y, each 0..1 of the // picture's box from its top-left corner. Multiply by the drawn picture's size to place it; // the plugin never sees pixels. TArray<FDocSpawnPoint> SpawnPoints; const FGameplayTag SpotsTag = FGameplayTag::RequestGameplayTag(TEXT("TaleShard.Repeater.spots"), false); for (const FTaleShardFieldBlock& Row : UTaleShardStepLibrary::GetBlocks(Step, SpotsTag)) { FDocSpawnPoint Spot; bool bPlaced = false; for (const FTaleShardResolvedField& Field : Row.Fields) { if (Field.Code == TEXT("name")) { Spot.Name = UTaleShardFieldLibrary::GetText(Field).ToString(); } else if (Field.Code == TEXT("where")) { bPlaced = UTaleShardFieldLibrary::GetPoint(Field, Spot.Where); } } if (bPlaced) { SpawnPoints.Add(Spot); } }
// A zone is a named rectangle: Get Rect answers a box whose Min is its top-left corner and // whose Max is its bottom-right, in the same 0..1 space. TArray<FDocTriggerBox> TriggerBoxes; const FGameplayTag ZonesTag = FGameplayTag::RequestGameplayTag(TEXT("TaleShard.Repeater.zones"), false); for (const FTaleShardFieldBlock& Row : UTaleShardStepLibrary::GetBlocks(Step, ZonesTag)) { FDocTriggerBox Zone; bool bPlaced = false; for (const FTaleShardResolvedField& Field : Row.Fields) { if (Field.Code == TEXT("name")) { Zone.Name = UTaleShardFieldLibrary::GetText(Field).ToString(); } else if (Field.Code == TEXT("bounds")) { bPlaced = UTaleShardFieldLibrary::GetRect(Field, Zone.Bounds); } } if (bPlaced) { TriggerBoxes.Add(Zone); } }
// Your code, not the plugin's: draw the picture, place the points and boxes on it, and take // the door the player walks through. The doors are the step's choices, so taking one is an // ordinary Choose on the session. FDocLevelMapPresenter::Show(Picture, SpawnPoints, TriggerBoxes, UTaleShardStepLibrary::GetChoices(Step), Session);}FDocSpawnPoint, FDocTriggerBox and FDocLevelMapPresenter are your code, not plugin API. What a
game does with a position is the handler’s business — a 2D level’s map is the obvious use, with the
spots as spawn points and the zones as trigger boxes. The doors are the step’s choices, so the door
the player walks through is taken with an ordinary Choose on the session.
What an outcome hands over
A Loot roll is a node whose outcome rows each carry items — entity items with a count each,
the itemCounts field type — beside the row’s weight and its changes. One outcome is drawn when
the player leaves the node, and that outcome’s items are handed over as the exit fires: they
arrive on the step after the roll, on the step’s trace as Received, exactly the way the exit’s
own scripts arrive on that step’s script runs. Each entry is an Item Count: the item, resolved like
any entity reference when that step is composed — after the outcome’s own scripts have run, so a
script that writes the item’s field is seen on it — and its count. The plugin keeps no inventory and merges nothing — an author
who wrote Gel Pack ×2 and Gel Pack ×1 on one row hands over two entries, and a game that wants
one stack sums them itself. The items are never on the step’s Cast: they are the things a row hands
over, not who is on stage.
This is not a per-type handler’s job, and the sample is deliberately not one. The step that carries the haul is whatever node the drawn outcome’s wire leads to, of any type, so the read belongs with the global work that runs for every step:
#pragma once
#include "CoreMinimal.h"#include "GameFramework/Actor.h"#include "TaleShardStepTypes.h"#include "DocReceivedItemsListener.generated.h"
class UTaleShardPlayerComponent;class UTaleShardSession;
// An actor that takes what a story hands the player: the items an exit's row handed over ride// on the step AFTER the exit, whatever that step's type is, so the read belongs with the work// every step gets, before any per-type handler.UCLASS()class ADocReceivedItemsListener : public AActor{ GENERATED_BODY()
public: ADocReceivedItemsListener();
protected: virtual void BeginPlay() override;
private: UFUNCTION() void HandleStepPresented(UTaleShardSession* Session, const FTaleShardPlayStep& Step);
UPROPERTY(VisibleAnywhere, Category = "TaleShard") TObjectPtr<UTaleShardPlayerComponent> Player;};#include "DocReceivedItemsListener.h"
#include "Support/DocFictions.h"#include "TaleShardPlayerComponent.h"#include "TaleShardStepLibrary.h"
ADocReceivedItemsListener::ADocReceivedItemsListener(){ Player = CreateDefaultSubobject<UTaleShardPlayerComponent>(TEXT("Player")); Player->bPlayOnAttach = false;}
void ADocReceivedItemsListener::BeginPlay(){ Super::BeginPlay();
// What an outcome handed over rides on the step AFTER the roll, whatever that step's type // is, so the read belongs with the global work: every step, before any per-type handler. Player->OnStepPresented.AddDynamic(this, &ADocReceivedItemsListener::HandleStepPresented);
FText Reason; if (!Player->Play(FGameplayTag(), NAME_None, Reason)) { FDocGameLog::Note(Reason.ToString()); }}
void ADocReceivedItemsListener::HandleStepPresented(UTaleShardSession* Session, const FTaleShardPlayStep& Step){ // Empty on most steps. Each entry is the item, resolved like any entity, and a count; a // repeated item arrives as repeated entries, in the author's order, and the plugin has // summed nothing. for (const FTaleShardItemCount& Entry : UTaleShardStepLibrary::GetTrace(Step).Received) { // Your code, not the plugin's: the plugin keeps no inventory. FDocInventory::Receive(Entry.Item.Code, Entry.Count); FDocGameLog::Note(FString::Printf(TEXT("Received %s x%d"), *Entry.Item.Name, Entry.Count)); }}The inventory it hands each entry to is your code, not plugin API. The same field type on an entity definition — a recipe’s ingredients, say — is an ordinary field: Get Items answers the same Item Count list, the entities zipped with their counts in the author’s order. A language change redraws a step with the same entries; a restored save lists none, as it lists no script runs.
A node that places rows in time
A Sequence is a track: lanes of rows on one time ruler. Each lane is an ordinary repeater whose
block marks one number field as a row’s start and, when the rows are spans rather than
instants, one as its duration — two flags the author sets in the editor, in whatever unit the
type’s numbers use. A type may also name one node-level number field as the track’s length, the
ruler’s end. With generated types on, all three reach you as accessors on the type’s library
beside the ones you already know: GetCuesStartField() and GetCuesDurationField() next to
GetCuesBlocks, GetTrackLengthField() next to IsSequence. Each answers the field’s tag, so
a handler hands it to Find Block Field on a row — or to Find Field on the step, for the length —
exactly as it hands a picture accessor’s tag to Find Field. A lane whose rows are instants has no
duration accessor at all.
A sequence is a presented node: its scripts have run and every command they awaited has settled before the handler sees the step, so the rows are final. The handler reads every lane, schedules the rows on its own clock, waits for the length — or for the last row to end, when the type names no length or the author left it blank or at zero — and then leaves. A row whose start is blank sits at zero, where the editor’s card draws it and its own player plays it.
#pragma once
#include "CoreMinimal.h"#include "TaleShardNodeHandler.h"#include "DocSequenceNodeHandler.generated.h"
// One node type's logic: what the game does when a node that places its rows in time is played.UCLASS()class UDocSequenceNodeHandler : public UTaleShardNodeHandler{ GENERATED_BODY()
public: UDocSequenceNodeHandler();
virtual void OnNodePlayed_Implementation(UTaleShardSession* Session, const FTaleShardPlayStep& Step) override;};#include "DocSequenceNodeHandler.h"
#include "Support/DocFictions.h"#include "TaleShardFieldLibrary.h"#include "TaleShardSession.h"#include "TaleShardStepLibrary.h"
UDocSequenceNodeHandler::UDocSequenceNodeHandler(){ NodeType = FGameplayTag::RequestGameplayTag(TEXT("TaleShard.NodeType.Sequence"), false);}
void UDocSequenceNodeHandler::OnNodePlayed_Implementation(UTaleShardSession* Session, const FTaleShardPlayStep& Step){ // A node that places its rows in time. By the time it reaches a handler its scripts have run // and every command they awaited has settled, so the rows are final. Each lane is a repeater // whose block marks one number field as a row's START and, when the rows are spans, one as its // DURATION. With generated types on, the type's library answers each as the field's tag, such // as UTaleShardSequenceFields::GetCuesStartField; by tag the start is an ordinary block field. FDocTimeline Timeline; double LastEnd = 0.0;
// A lane of spans: each row starts at its start field and lasts its duration field. const FGameplayTag CuesTag = FGameplayTag::RequestGameplayTag(TEXT("TaleShard.Repeater.cues"), false); const FGameplayTag CueStartTag = FGameplayTag::RequestGameplayTag(TEXT("TaleShard.Field.at"), false); const FGameplayTag CueDurationTag = FGameplayTag::RequestGameplayTag(TEXT("TaleShard.Field.lasts"), false); for (const FTaleShardFieldBlock& Row : UTaleShardStepLibrary::GetBlocks(Step, CuesTag)) { FString Cue; for (const FTaleShardResolvedField& Field : Row.Fields) { if (Field.Code == TEXT("cue")) { Cue = UTaleShardFieldLibrary::GetText(Field).ToString(); } }
// A row with no start sits at the ruler's zero, where the editor's card and its own player // place it; an unfilled duration is an instant. FTaleShardResolvedField Field; double At = 0.0; if (UTaleShardStepLibrary::FindBlockField(Row, CueStartTag, Field)) { UTaleShardFieldLibrary::GetNumber(Field, At); } double Duration = 0.0; if (UTaleShardStepLibrary::FindBlockField(Row, CueDurationTag, Field)) { UTaleShardFieldLibrary::GetNumber(Field, Duration); } Timeline.Add(TEXT("cues"), Cue, static_cast<float>(At), static_cast<float>(Duration)); LastEnd = FMath::Max(LastEnd, At + Duration); }
// A lane of instants: the block marks a start and no duration, so there is no duration field // to look for, and every row is a mark at one time. const FGameplayTag MarksTag = FGameplayTag::RequestGameplayTag(TEXT("TaleShard.Repeater.marks"), false); const FGameplayTag MarkStartTag = FGameplayTag::RequestGameplayTag(TEXT("TaleShard.Field.at"), false); for (const FTaleShardFieldBlock& Row : UTaleShardStepLibrary::GetBlocks(Step, MarksTag)) { FString Cue; for (const FTaleShardResolvedField& Field : Row.Fields) { if (Field.Code == TEXT("cue")) { Cue = UTaleShardFieldLibrary::GetText(Field).ToString(); } } FTaleShardResolvedField Field; double At = 0.0; if (UTaleShardStepLibrary::FindBlockField(Row, MarkStartTag, Field)) { UTaleShardFieldLibrary::GetNumber(Field, At); } Timeline.Add(TEXT("marks"), Cue, static_cast<float>(At), 0.f); LastEnd = FMath::Max(LastEnd, At); }
// The ruler's end: the type's own length field when the author filled it with a number // above zero, else the end of the last row. A blank or a zero is an unfilled length. The // handler waits for the end, then leaves. double Length = 0.0; FTaleShardResolvedField LengthField; if (!UTaleShardStepLibrary::FindField(Step, FGameplayTag::RequestGameplayTag(TEXT("TaleShard.Field.length"), false), LengthField) || !UTaleShardFieldLibrary::GetNumber(LengthField, Length) || !(Length > 0.0)) { Length = LastEnd; }
// Your code, not the plugin's: a clock that fires each entry at its start, holds it for its // duration, and moves the story on when it reaches the end. A game that ends the sequence // early takes one of the step's choices instead, exactly as it would on any other node. const FString StepId = Step.Id; const TArray<FTaleShardResolvedChoice> Ends = UTaleShardStepLibrary::GetChoices(Step); TWeakObjectPtr<UTaleShardSession> WeakSession(Session); Timeline.Play(static_cast<float>(Length), [WeakSession, StepId, Ends]() { // Only for THIS step: a story that has already moved on, by a choice taken early or a // save restored, is not advanced by a clock it left behind. UTaleShardSession* Live = WeakSession.Get(); FTaleShardPlayStep Current; if (!Live || !Live->GetCurrentStep(Current) || Current.Id != StepId) { return; } // A sequence with one way out advances. One whose ends are labelled offers them as the // step's choices and is waiting for one, so Advance is refused: the clock takes the end // the game reserves for running out, here the last one offered. FText Reason; if (Ends.Num() == 0) { Live->Advance(Reason); } else { Live->Choose(Ends.Last(), Reason); } });}FDocTimeline is your code, not plugin API — the clock lives there, on your own timer, and it
calls back when the clock runs out. What the callback does is the part worth copying: it checks
the step on screen is still the one it was armed for, then advances a sequence with one way out
and chooses for one whose ends are labelled — labelled ends are the step’s choices, the
session is awaiting one, and Advance is refused while it is. Which end the clock takes is your
rule; the sample takes the last one offered. The handler’s job is the plugin’s half: which field
of a row is its start is a fact the author chose, it arrives as a tag, and a rename in the editor
removes the tag from the tree rather than placing every row at zero.
A node that teaches a control
The Puzzles & world pack’s Teach card is a prompt that waits until the player has done what
it teaches — Hold @Crouch to get under the pipes — and then moves the story on. Its action field
lists Input action records, in order: each record’s code is the action code your game maps
to one of its own input actions, and its name is the action’s label. Its prompt is rich text in
which a mention of an action draws as the player’s current binding for it — the handler formats it
with a mention hook that answers that binding as a glyph, as
drawing a line of text describes.
doneWhen says what counts as done, and the card shows the one number that rule needs:
doneWhen | Done when | Its number |
|---|---|---|
pressed | the action is pressed once — several actions: held down together, a chord | — |
held | every action held down for that long without letting go | heldFor, seconds |
repeated | the actions pressed that many times in all | times |
moved | a 2D move used for that long in all | movedFor, seconds |
sequence | the actions pressed in the card’s order | — |
dismissed | the player closes the prompt: a pop-up | — |
dontWait | at once — the story flows on and the tip stays up that long: a tip | shownFor, seconds |
While the player has not done it, the nudges rows fire in the order of their after seconds:
pulse asks your prompt to draw attention to itself, and say has the row’s speaker say its line.
On done the handler moves the story on by the done way and sets remembersIn to true; a Teach
whose remembersIn already holds leaves by its gated knows way at once, with no prompt. Neither
way carries a label, so Advance takes the first one whose gate holds — and the card lists knows
before done.
#pragma once
#include "CoreMinimal.h"#include "TaleShardNodeHandler.h"#include "Text/TaleShardStoryText.h"#include "UObject/Interface.h"#include "DocTeachNodeHandler.generated.h"
class UTaleShardSession;
// What the Teach handler asks of your game's input, by an action's CODE: the Input action// record's own code, which your game maps to one of its own input actions. Implement it on the// actor that owns the TaleShard Player component, over whatever your game reads input with. The// handler names no input API, so it works the same over Enhanced Input, CommonUI or raw keys.UINTERFACE(MinimalAPI)class UDocTeachInput : public UInterface{ GENERATED_BODY()};
class IDocTeachInput{ GENERATED_BODY()
public: // Is the action held down right now? virtual bool IsDown(const FString& ActionCode) const = 0;
// How many times the action has been pressed since the game started. It only ever grows, so // a press between two reads is still counted. virtual int32 GetPressCount(const FString& ActionCode) const = 0;
// How far a 2D move action is pushed right now, from 0 to 1. virtual float GetMoveAmount(const FString& ActionCode) const = 0;
// The player's CURRENT binding for the action, as rich-text markup your prompt's image // decorator draws - such as <img id="Crouch.Gamepad"/> - or empty to draw the action's name. // Asked every time the prompt is drawn, so a rebind or a new pad shows at once. virtual FString GetGlyph(const FString& ActionCode) const = 0;};
// One nudge on a Teach card: after how many seconds of waiting, and what it does.struct FDocTeachNudge{ double After = 0.0; bool bSay = false; FString SpeakerName; FString Line;};
// One node type's logic: what the game does when a Teach node is played - a prompt that waits// until the player has done what it teaches, then moves the story on.UCLASS()class UDocTeachNodeHandler : public UTaleShardNodeHandler{ GENERATED_BODY()
public: UDocTeachNodeHandler();
virtual void OnNodePlayed_Implementation(UTaleShardSession* Session, const FTaleShardPlayStep& Step) override;
private: void ReadTheCard(const FTaleShardPlayStep& Step); FString FormatPrompt(UTaleShardSession* Session, const IDocTeachInput& Input) const; void DrawPrompt(UTaleShardSession* Session, const IDocTeachInput& Input); void Listen(); bool IsDone(const IDocTeachInput& Input, double DeltaSeconds); bool AllDown(const IDocTeachInput& Input) const; void CountPressesFromNow(const IDocTeachInput& Input); void Finish(UTaleShardSession* Session);
// The step being taught. A redraw - a language change - hands the same step again. FTaleShardPlayStep Taught; bool bTeaching = false;
// What the card asks: the actions by code, in order, the done rule and its one number, the // variable that remembers it was learned, and the nudges in the order they fire. FString Title; FTaleShardStoryText Prompt; TArray<FString> Actions; FString DoneWhen; double Amount = 0.0; FGameplayTag RemembersIn; TArray<FDocTeachNudge> Nudges;
// Progress, on the game's clock. double StartedAt = 0.0; double LastListenedAt = 0.0; double HeldFor = 0.0; double MovedFor = 0.0; int32 NextNudge = 0; int32 NextInSequence = 0; TArray<int32> PressesBefore; FString DrawnMarkup;};#include "DocTeachNodeHandler.h"
#include "Engine/World.h"#include "GameFramework/Actor.h"#include "Support/DocFictions.h"#include "TaleShardEntityDisplay.h"#include "TaleShardFieldLibrary.h"#include "TaleShardRichTextMarkup.h"#include "TaleShardSession.h"#include "TaleShardStepLibrary.h"#include "TaleShardTags.h"#include "TimerManager.h"
namespace{ // How far a 2D move must be pushed before it counts as moving. constexpr float MoveDeadZone = 0.2f;
bool FindByTag(const FTaleShardPlayStep& Step, const TCHAR* Tag, FTaleShardResolvedField& OutField) { return UTaleShardStepLibrary::FindField(Step, FGameplayTag::RequestGameplayTag(Tag, false), OutField); }
double NumberOf(const FTaleShardPlayStep& Step, const TCHAR* Tag, double Fallback) { FTaleShardResolvedField Field; double Number = 0.0; return FindByTag(Step, Tag, Field) && UTaleShardFieldLibrary::GetNumber(Field, Number) ? Number : Fallback; }}
UDocTeachNodeHandler::UDocTeachNodeHandler(){ NodeType = FGameplayTag::RequestGameplayTag(TEXT("TaleShard.NodeType.Teach"), false);}
void UDocTeachNodeHandler::OnNodePlayed_Implementation(UTaleShardSession* Session, const FTaleShardPlayStep& Step){ const IDocTeachInput* Input = Cast<IDocTeachInput>(GetOwningActor());
// The same step handed again is a redraw - the player changed language. Keep the progress and // draw the prompt again in the new language. if (bTeaching && Step.Id == Taught.Id) { Taught = Step; ReadTheCard(Step); DrawnMarkup.Reset(); if (Input) DrawPrompt(Session, *Input); return; }
Taught = Step; bTeaching = false; ReadTheCard(Step);
// Already learned: take the gated way without prompting. Advance leaves by the first way out // whose gate holds, and the card lists its knows way, gated on this very variable, before done. FText Reason; if (RemembersIn.IsValid() && Session->GetBoolVariable(RemembersIn)) { Session->Advance(Reason); return; }
AActor* Owner = GetOwningActor(); UWorld* World = Owner ? Owner->GetWorld() : nullptr; if (!Input || !World) { FDocGameLog::Note(TEXT("A Teach node needs the actor that owns the player to implement IDocTeachInput.")); return; }
// A tip: the story flows on at once, and the tip stays up for its seconds on your own clock. if (DoneWhen == TEXT("dontWait")) { FDocPromptPanel::ShowTip(Title, FormatPrompt(Session, *Input), static_cast<float>(Amount)); Finish(Session); return; }
StartedAt = World->GetTimeSeconds(); LastListenedAt = StartedAt; HeldFor = 0.0; MovedFor = 0.0; NextNudge = 0; NextInSequence = 0; CountPressesFromNow(*Input); DrawnMarkup.Reset(); DrawPrompt(Session, *Input); bTeaching = true; World->GetTimerManager().SetTimerForNextTick(FTimerDelegate::CreateUObject(this, &UDocTeachNodeHandler::Listen));}
void UDocTeachNodeHandler::ReadTheCard(const FTaleShardPlayStep& Step){ FTaleShardResolvedField Field;
Title = FindByTag(Step, TEXT("TaleShard.Field.name"), Field) ? UTaleShardFieldLibrary::GetText(Field).ToString() : FString();
// The prompt is rich text: parsed once here, formatted each time it is drawn. FString Html; Prompt = FindByTag(Step, TEXT("TaleShard.Field.prompt"), Field) && UTaleShardFieldLibrary::GetRichHtml(Field, Html) ? FTaleShardStoryText::Parse(Html) : FTaleShardStoryText();
// The actions, in the author's order, by the code each Input action record carries. Actions.Reset(); if (FindByTag(Step, TEXT("TaleShard.Field.action"), Field)) { for (const FTaleShardResolvedEntity& Action : UTaleShardFieldLibrary::GetEntities(Field)) { if (!Action.Code.IsEmpty()) Actions.Add(Action.Code); } }
// The done rule, and the one number that goes with it. DoneWhen.Reset(); if (FindByTag(Step, TEXT("TaleShard.Field.doneWhen"), Field)) UTaleShardFieldLibrary::GetOption(Field, DoneWhen); if (DoneWhen == TEXT("held")) Amount = NumberOf(Step, TEXT("TaleShard.Field.heldFor"), 0.0); else if (DoneWhen == TEXT("moved")) Amount = NumberOf(Step, TEXT("TaleShard.Field.movedFor"), 0.0); else if (DoneWhen == TEXT("repeated")) Amount = NumberOf(Step, TEXT("TaleShard.Field.times"), 1.0); else if (DoneWhen == TEXT("dontWait")) Amount = NumberOf(Step, TEXT("TaleShard.Field.shownFor"), 0.0); else Amount = 0.0;
// Where "learned" is remembered, by the variable's tag. RemembersIn = FGameplayTag(); FTaleShardVariableRef Variable; if (FindByTag(Step, TEXT("TaleShard.Field.remembersIn"), Field) && UTaleShardFieldLibrary::GetVariable(Field, Variable)) { RemembersIn = FTaleShardTags::TagOf(FTaleShardTags::VariableRoot(), Variable.Name); }
// The nudges, in the order they fire: by their seconds, the author's order breaking a tie. Nudges.Reset(); for (const FTaleShardFieldBlock& Row : UTaleShardStepLibrary::GetBlocks(Step, FGameplayTag::RequestGameplayTag(TEXT("TaleShard.Repeater.nudges"), false))) { FDocTeachNudge Nudge; for (const FTaleShardResolvedField& Cell : Row.Fields) { if (Cell.Code == TEXT("after")) { UTaleShardFieldLibrary::GetNumber(Cell, Nudge.After); } else if (Cell.Code == TEXT("does")) { FString Does; Nudge.bSay = UTaleShardFieldLibrary::GetOption(Cell, Does) && Does == TEXT("say"); } else if (Cell.Code == TEXT("speaker")) { FTaleShardResolvedEntity Speaker; if (UTaleShardFieldLibrary::GetEntity(Cell, Speaker)) Nudge.SpeakerName = Speaker.Name; } else if (Cell.Code == TEXT("line")) { Nudge.Line = UTaleShardFieldLibrary::GetText(Cell).ToString(); } } Nudges.Add(Nudge); } Nudges.StableSort([](const FDocTeachNudge& A, const FDocTeachNudge& B) { return A.After < B.After; });}
FString UDocTeachNodeHandler::FormatPrompt(UTaleShardSession* Session, const IDocTeachInput& Input) const{ // A mention of an action draws as the player's CURRENT binding for it, asked of your input // every time the prompt is formatted. Any other mention draws its name, as it does anywhere. const FTaleShardEntityPalette* Palette = Session->GetEntityPalette() ? Session->GetEntityPalette()->Get() : nullptr; return FTaleShardRichTextMarkup::Format( Prompt, FTaleShardStoryTextStyle(), [Palette, &Input](const FString& EntityId) { const TOptional<FTaleShardEntityDisplay> Display = Palette ? Palette->ResolveMention(EntityId) : TOptional<FTaleShardEntityDisplay>(); if (!Display.IsSet()) return TOptional<FTaleShardMentionLook>(); FTaleShardMentionLook Look = FTaleShardRichTextMarkup::MentionLookOf(*Display); if (Look.bIsAction) Look.Glyph = Input.GetGlyph(Look.ActionCode); return TOptional<FTaleShardMentionLook>(Look); });}
void UDocTeachNodeHandler::DrawPrompt(UTaleShardSession* Session, const IDocTeachInput& Input){ // Drawn again only when it changed: a rebind, a pad plugged in, a new language. const FString Markup = FormatPrompt(Session, Input); if (Markup.Equals(DrawnMarkup, ESearchCase::CaseSensitive)) return; DrawnMarkup = Markup; FDocPromptPanel::Show(Title, Markup, DoneWhen == TEXT("dismissed"));}
void UDocTeachNodeHandler::Listen(){ UTaleShardSession* Session = GetSession(); AActor* Owner = GetOwningActor(); UWorld* World = Owner ? Owner->GetWorld() : nullptr; const IDocTeachInput* Input = Cast<IDocTeachInput>(Owner);
// Only for THIS step: a story that moved on without the player doing it - a save restored, // the playthrough stopped - takes its prompt down and stops listening. FTaleShardPlayStep Current; if (!bTeaching || !Session || !World || !Input || !Session->GetCurrentStep(Current) || Current.Id != Taught.Id) { if (bTeaching) FDocPromptPanel::Hide(); bTeaching = false; return; }
const double Now = World->GetTimeSeconds(); const double DeltaSeconds = Now - LastListenedAt; LastListenedAt = Now;
// The nudges fire in order, each once, when the player has been waiting that long. while (NextNudge < Nudges.Num() && Now - StartedAt >= Nudges[NextNudge].After) { const FDocTeachNudge& Nudge = Nudges[NextNudge++]; if (Nudge.bSay) FDocSpeechBubble::Say(Nudge.SpeakerName, Nudge.Line); else FDocPromptPanel::Pulse(); }
DrawPrompt(Session, *Input); if (IsDone(*Input, DeltaSeconds)) { Finish(Session); return; } World->GetTimerManager().SetTimerForNextTick(FTimerDelegate::CreateUObject(this, &UDocTeachNodeHandler::Listen));}
bool UDocTeachNodeHandler::IsDone(const IDocTeachInput& Input, double DeltaSeconds){ // Dismissed: a pop-up the player closes, when they close it. if (DoneWhen == TEXT("dismissed")) return !FDocPromptPanel::IsOpen(); if (Actions.Num() == 0) return false; if (PressesBefore.Num() != Actions.Num()) CountPressesFromNow(Input);
// Held: every action held down together, for that long without letting go. if (DoneWhen == TEXT("held")) { if (!AllDown(Input)) { HeldFor = 0.0; return false; } HeldFor += DeltaSeconds; return HeldFor >= Amount; }
// Moved: a 2D move used for that long in all. if (DoneWhen == TEXT("moved")) { if (Input.GetMoveAmount(Actions[0]) <= MoveDeadZone) return false; MovedFor += DeltaSeconds; return MovedFor >= Amount; }
// Repeated: that many presses, of any of the actions. if (DoneWhen == TEXT("repeated")) { int32 Presses = 0; for (int32 Index = 0; Index < Actions.Num(); ++Index) { Presses += Input.GetPressCount(Actions[Index]) - PressesBefore[Index]; } return Presses >= FMath::Max(1.0, Amount); }
// Sequence: the actions in the card's order. Only the next one counts, and each is counted // from the moment the one before it was done. if (DoneWhen == TEXT("sequence")) { if (NextInSequence < Actions.Num() && Input.GetPressCount(Actions[NextInSequence]) > PressesBefore[NextInSequence]) { ++NextInSequence; CountPressesFromNow(Input); } return NextInSequence >= Actions.Num(); }
// Pressed: one action pressed once, or several held down at once as a chord. if (Actions.Num() == 1) return Input.GetPressCount(Actions[0]) > PressesBefore[0]; return AllDown(Input);}
bool UDocTeachNodeHandler::AllDown(const IDocTeachInput& Input) const{ for (const FString& Action : Actions) { if (!Input.IsDown(Action)) return false; } return true;}
void UDocTeachNodeHandler::CountPressesFromNow(const IDocTeachInput& Input){ PressesBefore.SetNum(Actions.Num()); for (int32 Index = 0; Index < Actions.Num(); ++Index) { PressesBefore[Index] = Input.GetPressCount(Actions[Index]); }}
void UDocTeachNodeHandler::Finish(UTaleShardSession* Session){ if (bTeaching) FDocPromptPanel::Hide(); bTeaching = false;
// Advance FIRST, then remember. The knows way comes before done and is gated on the same // variable, and Advance takes the first way out whose gate holds, so writing it before would // leave by knows, not done. // Held in a local: Advance can present the next Teach step, which this same handler reads. const FGameplayTag Learned = RemembersIn; FText Reason; Session->Advance(Reason); if (Learned.IsValid()) Session->SetBoolVariable(Learned, true, Reason);}IDocTeachInput is the small interface your game implements, on the actor that owns the TaleShard
Player component: four reads by action code. It names no input API, so neither the handler nor the
plugin takes an Enhanced Input or CommonUI dependency — a game on Enhanced Input answers Is Down from
its action’s value, Get Press Count from a counter it bumps when the action’s trigger starts, and
Get Glyph from the key the player has mapped to the action and its own icon for that key. Get Press
Count only ever grows, so a press that begins and ends between two frames still counts. The prompt
panel and the speech bubble are your code, not plugin API.
The prompt is also a presented field of the step, so a presenter that draws every step’s prose on
On Step Presented draws it a second time. When the handler’s panel is where the prompt belongs, list
Teach’s prompt under the player policy’s Silent Fields and the Teach type under Always
Present Node Types — a node with nothing left to present never becomes a step, and would never
reach its handler.
Three things in it are worth copying exactly:
- Advance first, then remember. The
knowsway comes beforedoneand is gated on the same variable the handler sets, and Advance leaves by the first way out whose gate holds, so writing the variable first would leave byknowsinstead ofdone. - The clock is the world’s. The handler listens once a frame on the world’s timer, so the wait, a hold and the nudges pause with the game, and it stops listening the moment the story is no longer on its step.
- The prompt is formatted every frame and drawn again only when it changed, so a rebind or a new pad shows at once and nothing else redraws it. A redraw of the step — a language change — keeps the progress and draws the prompt in the new language.
A checkpoint, and retry()
The Checkpoint card marks a moment to come back to. When the story arrives, its handler writes
the first try, 1, into triesIn, takes a snapshot with the session’s Save, and flows on. A
failure inside the story ends in a Code node calling retry(), a command the pack brings; a failure
the story never sees — a fall, a stray shot — is your game’s own death. Either way the handler puts
the story back to the snapshot with Restore, lays what the card keeps over it, writes the next
try, moves the player to restartAt, and says the line for that try.
A checkpoint is a beat, so the handler moves the story on as soon as it has done that. The card lists
its gated skip way before on, and Advance leaves by the first way out whose gate holds: once the
author’s condition on skip holds — typically a count of tries, which reads the try just written —
the story goes round by it, and by on until then.
What it keeps. Every variable in keeps, as the player left it, and for every record in
keepsThings every field this playthrough has written on it — its overlay, read with Get Entity
Overlay. The other variables and records, and which cards were visited, go back to how they were on
arrival, so a first time here line plays again. The handler restores a copy of the snapshot that
keeps the random generator as it is now — a retry that rolled the same numbers would be a replay —
and the language the player reads in. It keeps the lines already said and the choices already taken
too, but only when the story can be saved: a story waiting on a command cannot, and the story’s own
retry() is such a wait, so a retry the story asks for puts those back with the rest. A death your
game calls in during play keeps them. The try count is always written. Your level — the guards, the pickups, the player’s body — is yours to
reload when you move the player. restartAt is a variable link that may also hold a name the author
typed; either way it names one of your spawn points, read as Get Variable’s Name, else Get Text.
Two policy settings it needs. List the Checkpoint type under the player policy’s Always
Present Node Types: a checkpoint usually has nothing to show, and a node that presents nothing
never becomes a step, so it would never reach its handler. And list its line field under Silent
Fields: the step is drawn before the handler writes the try, so its own row answers the try
before, and the handler says the right line itself.
#pragma once
#include "CoreMinimal.h"#include "TaleShardNodeHandler.h"#include "TaleShardSessionTypes.h"#include "TaleShardValue.h"#include "DocCheckpointNodeHandler.generated.h"
class UTaleShardCommandCompletion;class UTaleShardSession;
// One node type's logic: what the game does at a Checkpoint - remember this moment, and put the// story back to it when the player fails, keeping what the card says to keep.UCLASS()class UDocCheckpointNodeHandler : public UTaleShardNodeHandler{ GENERATED_BODY()
public: UDocCheckpointNodeHandler();
virtual void OnNodePlayed_Implementation(UTaleShardSession* Session, const FTaleShardPlayStep& Step) override;
// Send the player back to the last checkpoint passed. The story's retry() calls it, and so // does the game's own death. Safe to call from anywhere, a story callback included: the // return itself happens on the next tick. void Retry();
private: // The story's retry(). Registered as awaited, as the pack declares it, so the story stands // still at the call until the return replaces the playthrough. UFUNCTION() void HandleRetryCommand(const TArray<FTaleShardValue>& Args, UTaleShardCommandCompletion* Completion);
void ArriveFresh(UTaleShardSession* Session, const FTaleShardPlayStep& Step); void ArriveOnRetry(UTaleShardSession* Session); void SpeakTheLineForThisTry(UTaleShardSession* Session, const FTaleShardPlayStep& Step); void ReturnToCheckpoint(); bool HasCheckpoint() const;
// The moment the story arrived at the last checkpoint passed, and the playthrough it was // taken in: a restart or a loaded save begins another, and the snapshot is not that one's. UPROPERTY() FTaleShardPlaythroughSave Snapshot; bool bHasSnapshot = false; int32 SnapshotGeneration = 0; FString CheckpointNodeId; FString ArrivedStepId;
// What the card says, read when the story arrives. FString RestartAt; TArray<FGameplayTag> Keeps; TArray<FGameplayTag> KeepsThings; FGameplayTag TriesIn;
// What a retry carries across the restore. TArray<TPair<FGameplayTag, FTaleShardValue>> KeptValues; TArray<TPair<FGameplayTag, TMap<FName, FTaleShardValue>>> KeptRecords; double NextTry = 1.0; bool bRetryQueued = false; bool bArrivingOnRetry = false; bool bListening = false;
UPROPERTY() TObjectPtr<UTaleShardCommandCompletion> PendingRetry;};#include "DocCheckpointNodeHandler.h"
#include "Engine/World.h"#include "GameFramework/Actor.h"#include "Script/TaleShardScriptRuntime.h"#include "Support/DocFictions.h"#include "TaleShardCommandCompletionObject.h"#include "TaleShardFieldLibrary.h"#include "TaleShardSession.h"#include "TaleShardStepLibrary.h"#include "TaleShardTags.h"#include "TimerManager.h"
namespace{ // Let the story carry on past a retry() that is still waiting. A wait the restore replaced // reports itself stale, and is left alone. void SettleIfWaiting(UTaleShardCommandCompletion* Completion) { if (Completion && !Completion->IsCompleted() && !Completion->IsStale()) Completion->CompleteWithNothing(); }}
UDocCheckpointNodeHandler::UDocCheckpointNodeHandler(){ NodeType = FGameplayTag::RequestGameplayTag(TEXT("TaleShard.NodeType.Checkpoint"), false);}
void UDocCheckpointNodeHandler::OnNodePlayed_Implementation(UTaleShardSession* Session, const FTaleShardPlayStep& Step){ // The same step handed again is a redraw - a language change, a picture landing - and not an // arrival. if (Step.Id == ArrivedStepId) return; ArrivedStepId = Step.Id;
if (bArrivingOnRetry && Step.Node.Id == CheckpointNodeId) { // Back here because of a retry: put back what is kept and count the try, but take no new // snapshot and do not start the count again. bArrivingOnRetry = false; ArriveOnRetry(Session); } else { ArriveFresh(Session, Step); }
SpeakTheLineForThisTry(Session, Step);
// A checkpoint is a beat: the story flows on. The card lists its gated skip way before on, and // Advance leaves by the first way out whose gate holds, so the story goes round by skip once // its gate holds - it reads the try written above - and by on otherwise. FText Reason; Session->Advance(Reason);}
void UDocCheckpointNodeHandler::ArriveFresh(UTaleShardSession* Session, const FTaleShardPlayStep& Step){ CheckpointNodeId = Step.Node.Id; FTaleShardResolvedField Field;
// Where the player starts again: a variable's name, or a name the author typed. Either way it // names one of your level's spawn points. RestartAt.Reset(); if (UTaleShardStepLibrary::FindField(Step, FGameplayTag::RequestGameplayTag(TEXT("TaleShard.Field.restartAt"), false), Field)) { FTaleShardVariableRef Variable; RestartAt = UTaleShardFieldLibrary::GetVariable(Field, Variable) ? Variable.Name : UTaleShardFieldLibrary::GetText(Field).ToString(); }
// What a retry leaves as the player left it: variables, and records. Keeps.Reset(); if (UTaleShardStepLibrary::FindField(Step, FGameplayTag::RequestGameplayTag(TEXT("TaleShard.Field.keeps"), false), Field)) { for (const FTaleShardVariableRef& Variable : UTaleShardFieldLibrary::GetVariableList(Field)) { Keeps.Add(FTaleShardTags::TagOf(FTaleShardTags::VariableRoot(), Variable.Name)); } } KeepsThings.Reset(); if (UTaleShardStepLibrary::FindField(Step, FGameplayTag::RequestGameplayTag(TEXT("TaleShard.Field.keepsThings"), false), Field)) { for (const FTaleShardResolvedEntity& Thing : UTaleShardFieldLibrary::GetEntities(Field)) { KeepsThings.Add(FTaleShardTags::TagOf(FTaleShardTags::EntityRoot(), Thing.Code)); } }
// The variable that counts the tries, and the first try. TriesIn = FGameplayTag(); FTaleShardVariableRef Tries; if (UTaleShardStepLibrary::FindField(Step, FGameplayTag::RequestGameplayTag(TEXT("TaleShard.Field.triesIn"), false), Field) && UTaleShardFieldLibrary::GetVariable(Field, Tries)) { TriesIn = FTaleShardTags::TagOf(FTaleShardTags::VariableRoot(), Tries.Name); } FText Reason; if (TriesIn.IsValid()) Session->SetNumberVariable(TriesIn, 1.0, Reason);
// The moment to come back to, taken after the first try is counted so it comes back with it. bHasSnapshot = Session->Save(Snapshot, Reason); SnapshotGeneration = Session->GetGeneration(); if (!bHasSnapshot) FDocGameLog::Note(Reason.ToString());
// Listen once, for the story's retry() and for your game's own death. retry() is registered as // awaited, which is how the pack declares it, and this registration replaces any other for // retry - a generated command class's included - so implement nothing for it there. if (!bListening) { bListening = true; FTaleShardAsyncCommandDelegate RetryCommand; RetryCommand.BindDynamic(this, &UDocCheckpointNodeHandler::HandleRetryCommand); Session->RegisterAsyncCommand(FGameplayTag::RequestGameplayTag(TEXT("TaleShard.Command.retry"), false), RetryCommand, NAME_None, Reason); FDocLevel::OnPlayerDied().AddUObject(this, &UDocCheckpointNodeHandler::Retry); }}
void UDocCheckpointNodeHandler::ArriveOnRetry(UTaleShardSession* Session){ // The restore put the story back as it was on arrival, but for what stays the player's. Lay // what is kept over it, then the try, last, so a count that is also on the keep list still // moves on. FText Reason; for (const TPair<FGameplayTag, FTaleShardValue>& Kept : KeptValues) { Session->SetVariable(Kept.Key, Kept.Value, Reason); } for (const TPair<FGameplayTag, TMap<FName, FTaleShardValue>>& Record : KeptRecords) { for (const TPair<FName, FTaleShardValue>& Written : Record.Value) { Session->SetEntityFieldValue(Record.Key, Written.Key, Written.Value, Reason); } } if (TriesIn.IsValid()) Session->SetNumberVariable(TriesIn, NextTry, Reason);
// Your code, not the plugin's: reload what of the level a retry resets, and put the player // at the spawn point the card names. FDocLevel::MovePlayerTo(RestartAt);}
void UDocCheckpointNodeHandler::SpeakTheLineForThisTry(UTaleShardSession* Session, const FTaleShardPlayStep& Step){ // The step was drawn BEFORE this arrival counted its try, so its own rows answer the try // before. Ask the node again now: Next Line reads its lines against the count just written, // and a lines list playing "first that holds" answers the first row whose condition holds. FTaleShardDialogueEngine* Engine = Session->GetEngine(); if (!Engine) return; const TOptional<TArray<FTaleShardCoreResolvedField>> Line = Engine->NextLine(Step.Node.BoardId, Step.Node.Id); if (!Line.IsSet()) return;
FString SpeakerName; FString Text; for (const FTaleShardCoreResolvedField& Core : Line.GetValue()) { const FTaleShardResolvedField Field = FTaleShardResolvedField::FromCore(Core); if (Field.Code == TEXT("speaker")) { FTaleShardResolvedEntity Speaker; if (UTaleShardFieldLibrary::GetEntity(Field, Speaker)) SpeakerName = Speaker.Name; } else if (Field.Code == TEXT("line")) { Text = UTaleShardFieldLibrary::GetText(Field).ToString(); } } if (!Text.IsEmpty()) FDocSpeechBubble::Say(SpeakerName, Text);}
void UDocCheckpointNodeHandler::HandleRetryCommand(const TArray<FTaleShardValue>& Args, UTaleShardCommandCompletion* Completion){ // Held, never settled, while the return is on its way: the restore replaces the playthrough, // which abandons this wait. With no checkpoint to go back to it is settled at once, and the // story carries on past the call. PendingRetry = Completion; Retry(); if (!bRetryQueued) { SettleIfWaiting(PendingRetry); PendingRetry = nullptr; }}
void UDocCheckpointNodeHandler::Retry(){ if (bRetryQueued || !HasCheckpoint()) return;
// NEVER restore from inside the call that asked for it. That call can be a story callback, // and the story would go on running the rest of it over the restored playthrough. The return // happens on the next tick, once the call has unwound. AActor* Owner = GetOwningActor(); UWorld* World = Owner ? Owner->GetWorld() : nullptr; if (!World) return; bRetryQueued = true; World->GetTimerManager().SetTimerForNextTick(FTimerDelegate::CreateUObject(this, &UDocCheckpointNodeHandler::ReturnToCheckpoint));}
void UDocCheckpointNodeHandler::ReturnToCheckpoint(){ bRetryQueued = false;
// The retry() this return answers, if the story asked for one. A retry() the story reaches // again on its way back is a new request, with a completion of its own. UTaleShardCommandCompletion* Answering = PendingRetry; PendingRetry = nullptr;
UTaleShardSession* Session = GetSession(); if (!Session || !Session->IsAttached() || !HasCheckpoint()) { SettleIfWaiting(Answering); return; }
// What a retry leaves as the player left it, read BEFORE the restore puts everything else // back: the kept variables, every field this playthrough has written on a kept record, and // the tries so far. KeptValues.Reset(); for (const FGameplayTag& Keep : Keeps) { KeptValues.Emplace(Keep, Session->GetVariable(Keep)); } KeptRecords.Reset(); for (const FGameplayTag& Thing : KeepsThings) { KeptRecords.Emplace(Thing, Session->GetEntityOverlay(Thing)); } NextTry = (TriesIn.IsValid() ? FMath::Max(1.0, Session->GetNumberVariable(TriesIn)) : 1.0) + 1.0;
// The snapshot's position and state, with what stays the player's: the dice (a retry that // rolled the same numbers would be a replay), the language they read in and, when the story // can be saved, the lines already said and the choices already taken. A story waiting on a // command cannot be saved, and the story's own retry() is such a wait, so a retry it asked for // puts those two back with the rest. FTaleShardDialogueEngine* Engine = Session->GetEngine(); FTaleShardCorePlaythroughSave Now; const bool bSavedNow = !Session->IsSuspended() && Engine->SavePlaythrough(Now); FTaleShardCorePlaythroughSave Combined = *Snapshot.GetParsed(); Combined.State.RngState = Session->GetRuntime()->CaptureState().RngState; Combined.Language = Engine->GetLanguage(); if (bSavedNow) { Combined.TakenPorts = Now.TakenPorts; Combined.SaidRows = Now.SaidRows; } const FTaleShardPlaythroughSave Back = FTaleShardPlaythroughSave::FromCore(Combined);
// The restore presents the checkpoint again, which reaches OnNodePlayed as an arrival on a // retry: that is where the kept values go back, over the restored state. bArrivingOnRetry = true; FTaleShardSaveLoadReport Report; FText Reason; if (!Session->Restore(Back, Report, Reason)) FDocGameLog::Note(Reason.ToString()); bArrivingOnRetry = false; SnapshotGeneration = Session->GetGeneration(); SettleIfWaiting(Answering);}
bool UDocCheckpointNodeHandler::HasCheckpoint() const{ const UTaleShardSession* Session = GetSession(); // A snapshot this session's Save took carries its parsed form, which the retry builds on. return bHasSnapshot && Snapshot.GetParsed().IsValid() && Session && Session->GetGeneration() == SnapshotGeneration;}FDocLevel — where a spawn point really is, and the event your game raises when the player dies —
is your code, not plugin API. The traps the handler handles are the part worth copying:
| Trap | What the handler does |
|---|---|
Restoring from inside retry() | Never restores inside the call that asked. That call can be a story callback, and the story would run the rest of it over the restored playthrough; the return happens on the next tick instead |
The story running on past retry() | Registers retry() as awaited — which matches how the pack declares it — so the story stands still at the call. Registered as answering at once, the Code node that called it would carry on and — with no way out — end the playthrough, raising On Ended, a frame before the restore. The restore abandons the wait; with no checkpoint to go back to, the handler settles it at once and the story carries on |
| Coming back because of a retry | The restore presents the checkpoint again. The handler knows it is its own retry arriving, so it takes no new snapshot and does not count from one again |
| The line for this try | The step was drawn before the try was written, so the handler asks the node again with the engine’s NextLine, which reads the lines against the count just written and takes the board’s id the step carries — the session’s Next Line takes the board’s tag |
| A snapshot from another playthrough | A restart or a loaded save begins a new playthrough, which the session’s GetGeneration counts; a snapshot taken in an earlier one is dropped rather than restored into this one |
The registration of retry() replaces any other — a generated command class’s included — so
implement nothing for retry in your command handler.