Skip to content

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
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
ColorHexThe colour behind the letters — a highlight’s, as authored. A hex string, or null.
TextColorHexThe colour of the letters, as authored. A hex string, or null.
FontSizeCssThe size the author set, as a CSS length with its unit — "18px". Null when they set none.
HrefA link’s target
RefIdA mention’s entity id — what INodeViewContext.ResolveEntity takes
RefLabelThe label as it was when the mention was inserted
ExpressionCodeAn 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 · ColorHexWhat an ordinary mention draws
ActionCodeThe 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 ""
IsActionActionCode is not null
GlyphMarkup 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.Fields by the names ResolvedEntity.MentionAs gives. If you draw one as a last resort, pass it through TmpMarkup.Escape first, 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.

MemberDefault
MentionFallbackColorHex#D9D9D9What a mention falls back to when its entity has no colour
MentionsResolved
InterpolationsValue
BoldMentionstrueBold as well as coloured
HeadingSizePercent{ 160, 140, 120 }Levels 1, 2 and 3, as a percentage
UseNativeUnderlinetrueUnderline is a native TMP tag

MentionDisplay — how a mention is drawn:

ResolvedThe entity’s current name, from its id. The default.
AuthoredLabelThe label the author saw when they inserted it. Goes stale on a rename — pick it only when reproducing an authored snapshot.
OmitDraw nothing

InterpolationDisplay — how an inline expression is drawn when your resolveInterpolation supplies no value for it:

ValueThe text the span carries: the value the engine filled in, or the source in braces when it could not. The default.
BracedThe source wrapped in braces, as the author wrote it, whatever its value
OmitNothing, even when a value is available
Written for
Unity developers · Advanced integrators
Last updated