Drawing a line of text
StoryText, the TextMeshPro formatter and the field converter — turning a field into a drawn line without losing a stage direction to a stray angle bracket.
All documentation — Reference
Start here
Concepts
Authoring for a game
Playing a story
The generated code
The plugin and sync
On this page
A field holds one of three things, and each needs a different conversion. Getting that wrong is
the single most common way a line comes out damaged, so start at the end: FieldMarkup already
decides for you.
FieldMarkup
TaleShard.Runtime.Text.FieldMarkup — static. One field in, TextMeshPro markup out.
| Method | |
|---|---|
string Of(ResolvedField? field, StoryTextStyle? style = null, Func<string, MentionLook?>? resolveMention = null, Func<string, string?>? resolveInterpolation = null) | One field. A null field reads as empty. |
string OfStep(PlayStep? step, …) | Every presentable field of a step, joined by newlines — the whole of what this moment says. A field carrying nothing is skipped rather than contributing a blank line. |
FieldMarkup picks between the three. That is its whole job, and it is why it exists rather than
being two lines at the call site.
Nothing in this namespace references TextMeshPro. The product is a string, so a project that does not use TMP is unaffected.
StoryText
StoryText.Parse(html) scans the payload’s rich text into blocks and spans your game can act on, so
a host does not have to write its own converter.
Parse once, format at runtime. What a mention or a heading should look like is a per-game answer, so this model carries structure and marks, never presentation.
| Member | |
|---|---|
IReadOnlyList<StoryBlock> Blocks | |
string PlainText() | Every block joined by newlines. Marks are lost. |
IReadOnlyList<string> MentionedEntityIds() | In order, without duplicates — what to preload portraits for |
StoryBlock
Kind · Spans · Level · Align, and PlainText().
StoryBlockKind is Paragraph · Heading · ListItem · CodeBlock. Level is a heading’s 1 to 3,
and zero for every other kind.
StoryAlign is Left · Center · Right · Justify — the author’s choice, and a host may ignore
it.
StorySpan
One run of text wearing one set of marks.
| Member | |
|---|---|
Text · Marks | |
ColorHex | The colour behind the letters — a highlight’s, as authored. A hex string, or null. |
TextColorHex | The colour of the letters, as authored. A hex string, or null. |
FontSizeCss | The size the author set, as a CSS length with its unit — "18px". Null when they set none. |
Href | A link’s target |
RefId | A mention’s entity id — what INodeViewContext.ResolveEntity takes |
RefLabel | The label as it was when the mention was inserted |
ExpressionCode | An inline expression’s source. The span’s Text already holds its value |
bool Has(StoryMarks mark) |
StoryMarks
Flags — a span can be bold and a link.
None · Bold · Italic · Strike · Underline · Highlight · Subscript · Superscript ·
Code · Mention · Interpolation · Link
Interpolation arrives filled. The engine evaluates every inline expression ({gold},
{when(…)}) where it resolves a field or a choice, so the span’s Text, the field’s plain text and
every accessor already hold the value — your code does nothing. ExpressionCode keeps the source
for a host that styles values differently. An expression that cannot be worked out shows its source
in braces and reports an interpolation-failed diagnostic; a field whose only content fills to
nothing counts as empty. resolveInterpolation still overrides the filled value when you pass one.
TmpMarkup
Formats a StoryText as TextMeshPro tags.
| Member | |
|---|---|
string Escape(string? text) | Make a runtime string safe to place in a formatted line |
string Format(StoryText? story, StoryTextStyle? style = null, Func<string, MentionLook?>? resolveMention = null, Func<string, string?>? resolveInterpolation = null) | One overload. There is no other. |
resolveMention is called with a mentioned entity’s id and supplies its current name and colour —
and, for an input action, the glyph of the player’s current binding. resolveInterpolation is called
with an expression’s source and supplies its value. Returning null from either falls through to the
style’s own setting rather than drawing nothing.
A mention’s fallback chain, in order: omit it if the style says so; use the authored label if the style says so; otherwise resolve by id — drawing the look’s glyph instead of any name when it has one — then the authored label, then the span’s own text.
An authored colour is checked against ContrastPolicy.IsSafeHex before it is written into a tag —
the one allow-list, because an authored colour can be any string that reached the project’s data.
An authored size answers to StoryText.IsDrawableFontSize for the same reason: a number and a
px, em or %, or no tag at all. A refused value never becomes markup and is never lost either —
it is still on StorySpan.FontSizeCss.
MentionLook
What a host answers about a mentioned entity.
EntityDisplay.ToMentionLook() builds one, and EntityPalette.ResolveMention is the
whole delegate for a game that draws no glyphs.
| Member | |
|---|---|
MentionLook(string name, string? colorHex) | An ordinary mention: no action, no glyph |
MentionLook(string name, string? colorHex, string? actionCode, string? glyph) | |
Name · ColorHex | What an ordinary mention draws |
ActionCode | The record’s code when it is an input action — what your game maps to its own action. Null otherwise; an action whose record has no code answers "" |
IsAction | ActionCode is not null |
Glyph | Markup drawn in place of the name. Null or empty draws the name |
MentionLook WithGlyph(string? glyph) | The same look with a glyph — what a hook returns for an action |
An input action’s glyph
A record can be an input action: its entity definition says a mention of it draws as a keycap
(the payload’s mentionLook). The project names the
action — Crouch — and never a key, because a key is wrong the moment the player rebinds, plugs
in another pad or plays on another console. What that action looks like right now is your game’s
answer, and your mention hook gives it.
EntityPalette.ResolveMention marks such a mention — IsAction, with the record’s code as
ActionCode — and leaves the glyph empty. Wrap it in a hook of your own that adds the glyph for an
action:
- Answered every time it is asked. The formatter calls the hook each time it draws, so the glyph is the binding the player has now: redraw the line after a rebind or a device switch and it follows. Nothing in the project stores a glyph.
- The idiomatic glyph is a TextMeshPro sprite tag for the current binding —
<sprite name="pad_south">, or<sprite="Keyboard" name="Ctrl">for a sprite asset of your own. The sprite atlas is yours; so is the choice between an Xbox and a PlayStation face button. - Written as it came. A glyph is not escaped, and it does not take the mention’s colour or bold, so it carries its own look. That is safe only because your code wrote it.
- A record’s name is still escaped exactly as before. An action with no glyph draws its name as any mention does, and an action with a glyph never lets its name reach the markup at all.
- A fallback built from project data is project data. The definition’s preview fields — a key
name, a pad position, a touch gesture — are the editor’s preview, readable on
ResolvedEntity.Fieldsby the namesResolvedEntity.MentionAsgives. If you draw one as a last resort, pass it throughTmpMarkup.Escapefirst, or return no glyph and let the name draw.
The same hook serves every line, not only a tutorial prompt: a Dialogue line that says Press @Interact to talk draws the binding too. The Teach handler is a worked example.
StoryTextStyle
Colours and sizes. Every member has a default, so new StoryTextStyle() is a working style.
| Member | Default | |
|---|---|---|
MentionFallbackColorHex | #D9D9D9 | What a mention falls back to when its entity has no colour |
Mentions | Resolved | |
Interpolations | Value | |
BoldMentions | true | Bold as well as coloured |
HeadingSizePercent | { 160, 140, 120 } | Levels 1, 2 and 3, as a percentage |
UseNativeUnderline | true | Underline is a native TMP tag |
MentionDisplay — how a mention is drawn:
Resolved | The entity’s current name, from its id. The default. |
AuthoredLabel | The label the author saw when they inserted it. Goes stale on a rename — pick it only when reproducing an authored snapshot. |
Omit | Draw nothing |
InterpolationDisplay — how an inline expression is drawn when your resolveInterpolation supplies
no value for it:
Value | The text the span carries: the value the engine filled in, or the source in braces when it could not. The default. |
Braced | The source wrapped in braces, as the author wrote it, whatever its value |
Omit | Nothing, even when a value is available |