Building books in C#¶
A SMAPI mod can build a book in code instead of shipping it as a content pack. This page covers the builder; for fetching the API and opening books that already exist, see C# API.
Reach for this when a book's contents depend on something a content pack can't see: a quest log that reflects what the player has done, a bestiary that fills in as creatures are found, a reference generated from another mod's data.
If your book is the same every time, a content pack is still the better home. It's translatable, other authors can patch it and you don't need a C# mod at all.
A complete example¶
IBookBuilder book = parchment.CreateBook("you.CampingGuide");
book.Sprite("you.CampingGuide/book");
IPageBuilder cover = book.AddPage("cover");
cover.AddTitle("Camping Guide").Alignment("Center");
cover.AddImage("you.CampingGuide/cover").Alignment("Center").Scale(3);
cover.AddButton("Begin", "PeacefulEnd.Parchment_NextPage").Alignment("Center");
IPageBuilder tents = book.AddPage("tents", "chapter-tents");
tents.AddHeading("Tents");
tents.AddParagraph("A starter tent sleeps one, and packs down to nothing.");
tents.AddItemImage("(O)24").Alignment("Center");
if (book.TryRegister(out string error) is false)
{
Monitor.Log($"Couldn't register the camping guide, because {error}.", LogLevel.Warn);
}
Hold each page in a local rather than trying to write the whole book as one chain. Every Add method returns the new element's builder so you can configure it, which means chaining walks away from the page rather than back to it.
Registering or opening¶
Two ways to finish a book, and they behave very differently.
TryRegister |
TryOpen |
|
|---|---|---|
| Where the book goes | Into Data/PeacefulEnd.Parchment/Books |
Nowhere, it opens immediately |
| Opened by ID later | Yes, by item, tile action or TryOpenBook |
No |
| Patchable by Content Patcher | Yes | No |
| Rebuilt | On every asset load | Every time you call it |
| Good for | A book that exists in the world | A book assembled fresh for this reading |
Registered books stay patchable
Registrations are added to the books asset before content packs are applied, so Content Patcher can edit, translate or replace anything you register. Other authors can extend your book without you exposing an API of your own.
Registering the same book ID again replaces your earlier registration, so re-registering is how you update a book. Call it from GameLaunched for a book that always exists, or later (after a save loads, say) for one that depends on the save.
You can only remove books your own mod registered. Books from content packs, and from other mods, are left alone.
Refreshing an open book¶
A builder holds the values you gave it, not the code that produced them. AddParagraph(fish.DisplayName) stored the string, so rebuilding the same builder produces the same book however much the world has moved on. To change what a reader is looking at, assemble a fresh builder from your current state and hand that to TryRefresh:
private void RefreshLogbook()
{
IBookBuilder book = BuildLogbook();
if (book.TryRefresh(out string error) is false)
{
Monitor.Log($"Couldn't refresh the logbook, because {error}.", LogLevel.Trace);
}
}
The reader keeps their place. Parchment notes the page they were on and returns them to it by ID, so a rebuild that adds or removes pages doesn't move them. When that page is gone entirely they land at the same position in the book instead.
A book shut on its cover rebuilds as readily as an open one, since the book's own Underlay and Overlay are what's on screen there. It stays shut, so a refresh behind a cover button doesn't open the book out from under the reader.
Flags, input text and seen pages all survive, since the book is swapped inside the open menu rather than a new one being put up. Nothing reopens, so there's no open animation.
Refreshing from inside the book¶
A button in the book can ask for the rebuild itself, without your mod watching for the click. Hand the builder an OnRefresh before you open or register it:
Then PeacefulEnd.Parchment_RefreshBook runs it:
settingsPage.AddButton("Show fish names", $"PeacefulEnd.Parchment_ToggleVariable {BOOK_ID} forceFishNames")
.Action("PeacefulEnd.Parchment_RefreshBook");
Put the refresh last
Actions run in the order they're given, and the rebuild reads whatever the state is at the moment it runs. A refresh placed before the ToggleVariable would rebuild against the old value.
The callback carries over each time, so the builder you pass to TryRefresh doesn't need its own OnRefresh unless you want to replace it. A refresh asking for another refresh while it's still running is ignored rather than recursing.
The action reports plainly when the open book has no callback, which is every book from a content pack.
This works for a registered book as well as one opened with TryOpen. A registered book is opened from the books asset rather than from your builder, so Parchment finds its callback through the registration instead. Give OnRefresh to the builder you register and the button works however the reader got there (item, tile action or TryOpenBook).
| Returns false when | |
|---|---|
| Nothing is open | Or the open menu isn't a book |
| A different book is open | Compared by book ID |
| The book is mid-animation | Sliding in, opening, turning, closing or going to its cover |
| The rebuilt book is invalid | The same validation TryOpen runs |
None of those are worth treating as a problem, so log at Trace rather than Warn unless you know the book should have been open.
Conditions are often enough
A refresh rebuilds everything. When the change is only which of a few known things to show, a Condition on each variant is lighter and updates on its own within a few ticks. Reach for TryRefresh when the page count, ordering or content genuinely can't be known ahead of time.
Registered books refresh in two places
TryRefresh swaps the rebuilt book into the open menu, which leaves the copy in the books asset as it was. Call TryRegister on the fresh builder too when the change should outlast the reading, and the reader stays on their page either way (a re-registration under an open book is held until the menu closes rather than applied under them).
Re-registering without restating OnRefresh keeps the callback the earlier registration was given, so a rebuild only needs to mention it when you want to replace it.
Rebuilding before the book opens¶
TryRefresh swaps a rebuilt book into a menu the reader already has open, which means they see the old copy first and the new one a frame or two later. When the change happened while nobody was reading, there was nothing to swap into and no reason to have rebuilt at all.
OnOpening runs your callback just before the book is built for the menu, so the reader gets the current book from its first frame:
The callback assembles a fresh builder and registers it, exactly as a refresh would:
private void RebuildLogbook()
{
IBookBuilder book = BuildLogbook();
if (book.TryRegister(out string error) is false)
{
Monitor.Log($"Couldn't register the logbook, because {error}.", LogLevel.Warn);
}
}
This runs however the reader got there (item, tile action, trigger action or TryOpenBook), since every route builds the book from the books asset. A book opened with TryOpen skips it, as that route builds from your builder as it stands and is already current.
Only when something changed¶
Rebuilding on every opening is wasteful when the book usually hasn't changed. TryMarkBookStale lets you say a rebuild is owed without doing one:
// Called whenever your own state changes, which may be many times between readings
parchment.TryMarkBookStale("Your.BookId", out string error);
The mark costs nothing, and the rebuild happens at the next opening through the book's OnRefresh callback. Twenty changes between readings then cost one rebuild rather than twenty, and a change nobody ever reads costs none. Registering the book again clears the mark, since the registered copy is current by then.
Which of the three to reach for
TryRefresh is for a change the reader should see while they're looking at it, such as a button on a settings page. TryMarkBookStale is for a change made while they're elsewhere. OnOpening is for anything that has to be worked out fresh every reading, such as the time of day or where the player is standing.
Keep it short
Both callbacks run inside the call that opens the book, so whatever they do lands before the first frame is drawn. That's the point, since the work is hidden by the opening animation, but a slow rebuild is a stutter on the way in. Anything that can be worked out ahead of time belongs in your own code rather than here.
A callback which throws is logged and the book opens as it stood, rather than the opening failing. A callback which opens its own book is ignored rather than recursing.
The book builder¶
| Method | What it does |
|---|---|
Set(field, value) |
Sets any book field by name. Dotted paths reach nested groups, such as "Appearance.Scale". |
Sprite(path) |
The sprite for the book item. |
AddPage(pageId) |
Adds a page, in reading order. |
AddPage(pageId, chapterId) |
Adds a page belonging to a chapter. Pages sharing a chapter are read together wherever they're added, in the order they were added. |
AddUnderlay(type) |
Adds an element drawn behind the book sprite. |
AddOverlay(type) |
Adds an element drawn in front of everything. |
AddVariable(variableId) |
Declares a variable and returns its builder. Readable straight away, before the book is registered or opened. |
OnKeyPress(keybind) |
Adds a key pressed on any page of the book, or on its shut cover, and returns its keybind builder. A page binding the same key takes it over. |
OnRefresh(onRefresh) |
What to run when the book is asked to rebuild. See Refreshing an open book. |
OnOpening(onOpening) |
What to run just before the book is put on screen. See Rebuilding before the book opens. |
TryRegister(out error) |
Validates and registers the book. |
TryOpen(out error) |
Validates and opens the book without registering it. |
TryRefresh(out error) |
Rebuilds and swaps into the open book, keeping the reader's page. See Refreshing an open book. |
The page builder¶
| Method | What it does |
|---|---|
Set(field, value) |
Sets any page field by name. |
Add(type) |
Adds an element to the page's stacked content, by type name. |
AddBackground(type) |
Adds an element behind the page's content, placed by Position. |
AddForeground(type) |
Adds an element over the page's content, placed by Position. |
AddTitle(text) |
Shorthand for Add("Title").Text(text). |
AddHeading(text) |
Shorthand for Add("Heading").Text(text). |
AddParagraph(text) |
Shorthand for Add("Paragraph").Text(text). |
AddBanner(text) |
Shorthand for Add("Banner").Text(text). |
AddDivider() |
Shorthand for Add("Divider"). |
AddPanel() |
Shorthand for Add("Panel"). |
AddGrid(cellWidth, cellHeight, columns, rows) |
A Grid, with the three fields it can't do without. rows is optional and caps its height. Four adjacent numbers, so name them: AddGrid(20, 20, columns: 6). |
AddPageNumber() |
The page's own number, filled in from its position. |
AddImage(texturePath) |
Shorthand for Add("Image").Texture(texturePath). |
AddItemImage(itemId) |
An image drawn from an item's icon, using a qualified ID such as "(O)24". |
AddButton(text, action) |
A button running a trigger action when clicked. |
Condition(condition) |
A game state query deciding whether the page is part of the book. Checked once as the book is built, so the page is left out rather than hidden. See Hiding a page. |
Tag(tag) |
Adds a keyword for a contents entry or search box to match against. Call it more than once to build a list. |
OnView(action) |
Runs a trigger action each time the page becomes visible. |
OnView(action, condition) |
The same, gated by a game state query. |
OnKeyPress(keybind) |
Adds a key pressed while the page is visible and returns its keybind builder, taking the key over from the menu and from the book's own binds. |
The keybind builder¶
OnKeyPress returns this, on the book builder and on the page builder alike. It reaches everything OnKeyPress offers, and follows the same rule as AddPage and the Add element methods: it hands back the new keybind's builder rather than the thing you called it on.
| Method | Sets |
|---|---|
Set(field, value) |
Any keybind field by name. |
Action(action) |
Adds one action. Call it more than once to run several in order, and at least one is required. |
Condition(condition) |
Condition |
Sound(sound) |
Sound |
SuppressDefault(suppressDefault) |
SuppressDefault. The argument is optional and defaults to true. |
book.OnKeyPress("Escape").Action("PeacefulEnd.Parchment_GoBack").Action($"PeacefulEnd.Parchment_SetVariable {BOOK_ID} seenEscapeHint true").Sound("shwip");
That pairs with a hint drawn only while the variable is false, which the reader dismisses by doing the thing it describes. A Global variable makes it once per player rather than once per save.
The variable builder¶
AddVariable returns this rather than the book builder, the same way AddPage does. Keep your own reference to the book builder for TryRegister.
| Method | Sets |
|---|---|
Set(field, value) |
Any variable field by name. |
Type(variableType) |
Type, one of "Boolean", "Number", "Text" |
Default(defaultValue) |
Default |
Scope(variableScope) |
Scope, either "Save" or "Global" |
Min(min) |
Min |
Max(max) |
Max |
Range(min, max) |
Min and Max together |
AllowedValue(value) |
Adds one entry to AllowedValues. Call it more than once to build the list. |
var book = api.CreateBook($"{ModManifest.UniqueID}_Almanac");
book.AddVariable("showSpoilers").Scope("Global");
book.AddVariable("units").Type("Text").Default("metric").Scope("Global").AllowedValue("metric").AllowedValue("imperial");
AllowedValue needs a Default
The starting value has to be one of the allowed ones. A Text variable with allowed values and no Default starts as empty text, which isn't in the list, and registration fails. Set Default whenever you call AllowedValue.
The element builder¶
Most methods are named after the field they set, so anything you've written in a content pack carries over. The handful that aren't are the ones doing more than an assignment, such as Margin, Spacing and AddFrame.
| Method | Sets |
|---|---|
Set(field, value) |
Any element field by name. |
WithId(id) |
Id. Needed by anything that names the element later, such as ShowElement. |
WithTag(tag) |
A tag other mods can read off the hovered element. Call it more than once to build a list. |
Text(text) |
Text |
Alignment(alignment) |
Alignment, one of "Left", "Center", "Right" |
VerticalAlignment(alignment) |
VerticalAlignment, one of "Top", "Center", "Bottom". Only used on a placed element |
TextAlignment(alignment) |
TextAlignment, one of "Left", "Center", "Right" |
Font(fontType) |
FontType, one of "Dialogue", "Small", "Tiny", "SpriteText" |
TextColor(color) |
TextColor |
ShadowColor(color) |
ShadowColor |
TextScale(scale) |
TextScale |
Scale(scale) |
Scale |
Rotation(rotation) |
Rotation |
Origin(x, y) |
Origin |
Position(x, y) |
Position |
Texture(path) |
TexturePath |
TextureSource(x, y, width, height) |
TextureSourceRectangle |
HoverTextureSource(x, y, width, height) |
HoverTextureSourceRectangle |
Tint(color) |
TintColor |
Item(itemId) |
ItemId |
Action(action) / Action(action, sound) |
A click action. Call it more than once to build a list. |
HoverAction(action) |
A hover action. Call it more than once to build a list. |
SubmitAction(action) |
An action run when enter is pressed in an Input. Call it more than once to build a list. |
TextChangedAction(action) |
An action run once an Input's text settles. Call it more than once to build a list. |
TextChangedDelay(textChangedDelay) |
An Input's TextChangedDelay |
InputId(inputId) |
An Input's InputId |
Placeholder(placeholder) |
An Input's Placeholder |
MaxLength(maxLength) |
An Input's MaxLength |
Sound(sound) |
Sound |
Condition(condition) |
Condition |
IgnoreCursor(ignoreCursor) |
IgnoreCursor. The argument is optional and defaults to true. |
Lifetime(lifetime) |
Lifetime |
FadeAfter(fadeAfter) |
FadeAfter |
Sizing(mode) |
Sizing, one of "Fill", "ShrinkToFit", "Fixed" |
Width(width) / Height(height) |
Width and Height. Width is taken by a Panel, Divider, Banner or Input with a Fixed Sizing, and by a Paragraph on its own. Height is a Panel's or an Input's. |
Padding(padding) |
Padding |
Columns(columns) / Rows(rows) |
A Grid's Columns and Rows |
CellWidth(width) / CellHeight(height) |
A Grid's CellWidth and CellHeight |
CellSpacing(columnSpacing, rowSpacing) |
A Grid's ColumnSpacing and RowSpacing |
Source(itemQuery) |
Fills a Grid's cells from an item query. See Source. |
SourceFilter(inputId) |
The Input narrowing those candidates. |
SourceCondition(perItemCondition) |
A game state query each candidate must pass. |
SourceOrder(order) |
The item property the candidates are sorted by, such as "Name" or "Price", or "None". |
SourceOrderDescending(descending) |
Reverses that order. |
SourceCount(count) |
How many cells the results fill, when the grid has no Rows. |
AddSourceTemplate(elementType) |
The element each cell is built from. Returns the template's builder. |
Scope(scope) |
A PageNumber's Scope, either "Book" or "Chapter" |
Format(format) |
A PageNumber's Format, such as "Page {0}" |
Spacing(spacingAfter) |
SpacingAfter |
Margin(left, right) |
MarginLeft and MarginRight |
Tooltip(displayName, description) |
DisplayName and Description |
AddFrame(x, y, duration, scale, condition) |
An animation frame on an Image. Every argument after y is optional. |
AddFrameInPlace(duration, scale, condition) |
An animation frame that keeps whatever the element already draws. Every argument is optional. |
AddHoverFrame(x, y, duration, scale, condition) |
A hover frame, played while the cursor is over the element. |
AddHoverFrameInPlace(duration, scale, condition) |
A hover frame that keeps whatever the element already draws. |
FrameOffset(x, y) |
Shifts the frame added last. See Frame offset. |
FrameAction(action) |
A trigger action run when the frame added last starts. Call it more than once to build a list. |
AddChild(type) |
A child element on a container such as a Panel |
AddBackground(type) |
An element behind a container's children, placed by Position within its content area |
AddForeground(type) |
An element over a container's children, placed the same way |
Not every method applies to every element type. Padding on a Heading isn't valid, and asking for it fails at registration with a message naming the fields that type does accept.
Setting anything else¶
The methods above cover the common fields. Set covers the rest, using the same field names the JSON uses:
book.Set("Format", "1.4.0");
book.Set("Appearance.Scale", 4);
book.Set("Layout.MarginTop", 40);
cover.AddImage("you.CampingGuide/tent").Set("Rotation", 0.2f).Set("SpriteEffects", "FlipHorizontally");
Names are matched ignoring case, and enums are given as strings. A dotted path walks into a nested group, which is how the book's Appearance, Layout, PageCurl and Animation groups are reached.
Because Set reads the field names off the model, anything the JSON schema gains works here immediately, without waiting for a matching builder method.
Running several actions¶
Action and HoverAction accumulate. Call either one repeatedly and you get a list run in order, so nothing changes about how you write the single-action case:
page.AddButton("Take the map", "PeacefulEnd.Parchment_NextPage");
page.Add("Button")
.Text("Accept the quest")
.Texture("you.CampingGuide/button")
.Action("AddQuest 101")
.Action("AddMail Current you.QuestAccepted")
.Action("PeacefulEnd.Parchment_CloseBook")
.Sound("questcomplete");
Building the list in a loop works the same way, which is the case a content pack can't cover:
IElementBuilder claim = page.AddButton("Claim rewards", "PeacefulEnd.Parchment_CloseBook");
foreach (string itemId in unclaimedRewards)
{
claim.Action($"AddItem {itemId}");
}
The list runs to the end regardless of what happens partway, and there's no per-action condition. Since you're in C#, decide in code whether to add an action at all, and keep If <query> ## <action> for state that changes while the book is open.
Sound is separate because it plays once however many actions run. Action(action, sound) sets both in one call, for the common single-action button.
Animating in code¶
Frames are added to an Image after its source rectangle, which is what gives them their size:
IElementBuilder junimo = page.AddImage("Characters/Junimo").TextureSource(48, 0, 16, 16).Scale(4).Alignment("Center");
junimo.AddFrame(48, 0, 400);
junimo.AddFrame(64, 0, 400);
junimo.AddFrame(80, 0, 400, 1.1f);
Every argument after y is optional, so AddFrame(48, 0) is a frame at the element's own duration and scale. A duration of 0 means the same thing as omitting it: the element's FrameDuration applies.
AddHoverFrame builds the separate list played while the cursor is over the element. Leaving it empty means the idle animation just keeps running:
FrameOffset shifts whatever frame you added last, in unscaled sprite pixels × the element's scale, without moving where the element sits. It reads as a modifier on the line above it, and it doesn't care which of the four Add methods put the frame there:
junimo.AddFrame(48, 0, 400);
junimo.AddFrame(48, 0, 400).FrameOffset(0, -1); // the same cell, a pixel higher
junimo.AddHoverFrameInPlace().FrameOffset(0, -2); // a whole hover lift
FrameAction works the same way, attaching an action to the frame above it:
Calling either before any frame exists fails registration with a message saying so, rather than passing silently.
AddFrameInPlace and AddHoverFrameInPlace are the same thing without a coordinate, for a frame that keeps whatever the element already draws and varies only its timing, scale or condition. That's what animates an item icon, which has no source rectangle of your own to point at:
IElementBuilder parsnip = page.AddItemImage("(O)24").Scale(4).Origin(8f, 8f).Alignment("Center");
parsnip.AddFrameInPlace(900);
parsnip.AddFrameInPlace(150, 1.1f);
parsnip.AddFrameInPlace(250);
A condition on a frame is a game state query, skipped rather than paused on when it fails. Since you're building in code you can often decide in C# whether to add the frame at all, which is clearer than a query. Reserve the condition for state that changes while the book is open:
if (isNighttime is true)
{
junimo.AddFrame(96, 0, 200); // decided now, at build time
}
junimo.AddFrame(112, 0, 200, 1f, "WEATHER Here Rain"); // re-checked while the book is open
When something's wrong¶
Both TryRegister and TryOpen return false with an error rather than throwing, and the book is validated exactly the way a content pack's is.
if (book.TryRegister(out string error) is false)
{
Monitor.Log($"Couldn't register the camping guide, because {error}.", LogLevel.Warn);
}
Typical messages:
| Error | Cause |
|---|---|
| there's no element type named "Headding" | A typo in Add. |
| [Heading] there's no field named "Padding" on HeadingElementData. It accepts: ... | A field that type doesn't have. The list tells you what it does have. |
| [Image] "Alignment" expects one of Left, Center, Right but got "Centre" | A bad enum value. |
| "Pages" must contain at least one page | The usual book validation, same as content packs get. |
| there's more than one page with the ID "cover" | Duplicate page IDs within the book. |
Rules and limits¶
| Rule | Behaviour |
|---|---|
| Prefix your book IDs with your mod's unique ID | Unprefixed IDs are accepted but logged as a warning. |
| Two mods, one book ID | The second mod is rejected with an error naming the first. |
| Same mod, same book ID | Your earlier registration is replaced. |
| Registering after launch | Supported. The books asset is rebuilt and Content Patcher edits are reapplied. |
Two things the builder can't do:
- Content Patcher tokens.
{{Season}},{{i18n:key}}and the rest are a Content Patcher feature, not a Parchment one. Substitute values yourself before building, or keep the book in a content pack. - Translations. There's no
i18nlayer here. Pull strings from your ownHelper.Translationas you build.