Translate and word-wrap cutscene modal text

uiModalOpen now resolves title/message/option strings as locale
message IDs first, falling back to the literal string if no match is
found, and word-wraps the result to stay within the screen's scan
width via the new shared textWrap helper. sceneinitial.c's no-device
modal now uses locale keys instead of hardcoded English strings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 18:53:47 -05:00
parent 28f5e66662
commit 0b4ba062bb
8 changed files with 153 additions and 21 deletions
+26
View File
@@ -5,6 +5,32 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : n==2 ? 1 : (n<7 ? 2 : 3));\n" "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : n==2 ? 1 : (n<7 ? 2 : 3));\n"
msgid "initial.checking_save.title"
msgstr "Checking for save data"
msgid "initial.checking_save.message"
msgstr "Please wait..."
msgid "initial.no_device.title"
msgstr "No Save Device Found"
msgid "initial.no_device.message"
msgstr "Could not find a save device, ensure it is connected and try again. You can continue, but progress will not be saved."
msgid "initial.no_device.retry"
msgstr "Try again"
msgid "initial.no_device.continue"
msgstr "Continue without saving"
#: ui/menu.c:10 #: ui/menu.c:10
msgid "ui.title" msgid "ui.title"
msgstr "" msgstr ""
+34
View File
@@ -187,3 +187,37 @@ int32_t textMeasure(
return spriteCount; return spriteCount;
} }
void textWrap(char_t *text, const font_t *font, const float_t maxWidth) {
assertNotNull(text, "Text cannot be NULL");
assertNotNull(font, "Font cannot be NULL");
float_t fontWidth = (float_t)font->tileset->tileWidth;
if(fontWidth <= 0.0f) return;
int32_t charsPerLine = (int32_t)(maxWidth / fontWidth);
if(charsPerLine <= 0) return;
int32_t lineWidth = 0;
int32_t lastSpace = -1;
for(int32_t i = 0; text[i] != '\0'; i++) {
if(text[i] == '\n') {
lineWidth = 0;
lastSpace = -1;
continue;
}
if(text[i] == ' ') {
lastSpace = i;
}
lineWidth++;
if(lineWidth > charsPerLine && lastSpace != -1) {
text[lastSpace] = '\n';
lineWidth = i - lastSpace;
lastSpace = -1;
}
}
}
+14
View File
@@ -104,3 +104,17 @@ int32_t textMeasure(
int32_t *outWidth, int32_t *outWidth,
int32_t *outHeight int32_t *outHeight
); );
/**
* Word-wraps text in place for display at up to maxWidth pixels wide,
* by replacing the space nearest each overflow point with a newline.
* Length is unchanged - this only ever swaps existing spaces for
* newlines, never inserts characters - so it's always safe to call on a
* fixed-size buffer. A single word wider than maxWidth on its own is
* left unbroken.
*
* @param text Null-terminated, caller-owned buffer to wrap in place.
* @param font Font to measure character width with.
* @param maxWidth Maximum line width, in pixels.
*/
void textWrap(char_t *text, const font_t *font, const float_t maxWidth);
+7 -2
View File
@@ -89,6 +89,9 @@ typedef struct cutscene_s {
// the dialog to be dismissed. Script the rest of the interaction (e.g. // the dialog to be dismissed. Script the rest of the interaction (e.g.
// CUTSCENE_CALLBACK to kick off work, CUTSCENE_WAIT, then // CUTSCENE_CALLBACK to kick off work, CUTSCENE_WAIT, then
// CUTSCENE_MODAL_CLOSE) as later items in the same cutscene. // CUTSCENE_MODAL_CLOSE) as later items in the same cutscene.
// TITLE and MESSAGE are each displayed as-is unless they match a
// locale message ID, in which case the translated string is shown
// instead - see uiModalLocalize.
#define CUTSCENE_MODAL(TITLE, MESSAGE) \ #define CUTSCENE_MODAL(TITLE, MESSAGE) \
{ \ { \
.type = CUTSCENE_ITEM_TYPE_MODAL, \ .type = CUTSCENE_ITEM_TYPE_MODAL, \
@@ -100,8 +103,10 @@ typedef struct cutscene_s {
// CALLBACK fires with the selected option index (or UI_MODAL_RESULT_NONE // CALLBACK fires with the selected option index (or UI_MODAL_RESULT_NONE
// if backed out of) once the dialog closes. Option labels are passed as // if backed out of) once the dialog closes. Option labels are passed as
// trailing arguments, e.g. CUTSCENE_MODAL_OPTIONS(title, message, // trailing arguments, e.g. CUTSCENE_MODAL_OPTIONS(title, message,
// callback, "Retry", "Cancel") - their strings are not copied, so they // callback, "Retry", "Cancel") - their strings are not copied by this
// must outlive the modal (string literals are fine). // item, so they must stay valid until the modal opens (string literals
// are fine). Like TITLE and MESSAGE, each option is translated if it
// matches a locale message ID.
#define CUTSCENE_MODAL_OPTIONS(TITLE, MESSAGE, CALLBACK, ...) \ #define CUTSCENE_MODAL_OPTIONS(TITLE, MESSAGE, CALLBACK, ...) \
{ \ { \
.type = CUTSCENE_ITEM_TYPE_MODAL, \ .type = CUTSCENE_ITEM_TYPE_MODAL, \
@@ -28,12 +28,16 @@ typedef void (*cutscenemodaloptioncallback_t)(
); );
typedef struct { typedef struct {
// Display text, or a locale message ID to translate - see
// uiModalLocalize.
char_t title[CUTSCENE_MODAL_TITLE_MAX_CHARS]; char_t title[CUTSCENE_MODAL_TITLE_MAX_CHARS];
char_t message[CUTSCENE_MODAL_MESSAGE_MAX_CHARS]; char_t message[CUTSCENE_MODAL_MESSAGE_MAX_CHARS];
// NOT copied - these pointers are stored directly by the underlying // NOT copied by this struct - these pointers are only read once,
// buttons, so they must stay valid for as long as the modal is open // while uiModalOpen is opening the modal in cutsceneModalStart, so
// (e.g. string literals, as CUTSCENE_MODAL_OPTIONS produces). // they only need to stay valid until then (e.g. string literals, as
// CUTSCENE_MODAL_OPTIONS produces). Each is displayed text or a
// locale message ID, same as title/message.
const char_t **options; const char_t **options;
uint8_t optionCount; uint8_t optionCount;
+4 -5
View File
@@ -22,8 +22,7 @@ void sceneInitialSaveDeviceRetryCallback(const uint8_t opt, void *u);
CUTSCENE(INITIAL, 0, DEFAULT, CUTSCENE(INITIAL, 0, DEFAULT,
CUTSCENE_MODAL( CUTSCENE_MODAL(
"Checking for save data...", "initial.checking_save.title", "initial.checking_save.message"
"Please wait."
), ),
CUTSCENE_WAIT(1.0f), CUTSCENE_WAIT(1.0f),
CUTSCENE_CALLBACK(sceneInitialFindDevices), CUTSCENE_CALLBACK(sceneInitialFindDevices),
@@ -31,10 +30,10 @@ CUTSCENE(INITIAL, 0, DEFAULT,
CUTSCENE_MARKER("NO_DEVICE"), CUTSCENE_MARKER("NO_DEVICE"),
CUTSCENE_MODAL_CLOSE(), CUTSCENE_MODAL_CLOSE(),
CUTSCENE_MODAL_OPTIONS( CUTSCENE_MODAL_OPTIONS(
"No save device found", "initial.no_device.title",
"Please ensure a save device is connected and try again.", "initial.no_device.message",
sceneInitialSaveDeviceRetryCallback, sceneInitialSaveDeviceRetryCallback,
"Retry", "Continue without saving" "initial.no_device.retry", "initial.no_device.continue"
), ),
CUTSCENE_MARKER("RETRY"), CUTSCENE_MARKER("RETRY"),
+27 -3
View File
@@ -17,6 +17,8 @@
#include "display/spritebatch/spritebatch.h" #include "display/spritebatch/spritebatch.h"
#include "display/texture/texture.h" #include "display/texture/texture.h"
#include "display/shader/shaderunlit.h" #include "display/shader/shaderunlit.h"
#include "locale/localemanager.h"
#include "asset/loader/locale/assetlocaleloader.h"
#define UI_MODAL_BACKDROP_COLOR color4b(0, 0, 0, 160) #define UI_MODAL_BACKDROP_COLOR color4b(0, 0, 0, 160)
@@ -119,6 +121,20 @@ errorret_t uiModalDraw(void) {
errorOk(); errorOk();
} }
void uiModalLocalize(
const char_t *key,
char_t *buffer,
const size_t bufferSize
) {
errorret_t result = assetLocaleGetString(
&LOCALE.entry->data.locale, key, 0, buffer, bufferSize
);
if(errorIsNotOk(result)) {
errorCatch(result);
stringCopy(buffer, key, bufferSize - 1);
}
}
bool_t uiModalIsOpen(void) { bool_t uiModalIsOpen(void) {
return UI_MODAL.open; return UI_MODAL.open;
} }
@@ -146,11 +162,16 @@ void uiModalOpen(
optionCount <= UI_MODAL_OPTIONS_MAX, "Too many options for modal" optionCount <= UI_MODAL_OPTIONS_MAX, "Too many options for modal"
); );
stringCopy(UI_MODAL.titleText, title, UI_MODAL_TITLE_TEXT_MAX - 1); float_t maxContentWidth = ((float_t)SCREEN.scanWidth * UI_MODAL_MAX_WIDTH_RATIO)
- (UI_FRAME_START_X * 2.0f);
uiModalLocalize(title, UI_MODAL.titleText, UI_MODAL_TITLE_TEXT_MAX);
textWrap(UI_MODAL.titleText, &FONT_DEFAULT, maxContentWidth);
UI_MODAL.titleLabel.dirty = true; UI_MODAL.titleLabel.dirty = true;
uiLabelRebuffer(&UI_MODAL.titleLabel); uiLabelRebuffer(&UI_MODAL.titleLabel);
stringCopy(UI_MODAL.messageText, message, UI_MODAL_MESSAGE_TEXT_MAX - 1); uiModalLocalize(message, UI_MODAL.messageText, UI_MODAL_MESSAGE_TEXT_MAX);
textWrap(UI_MODAL.messageText, &FONT_DEFAULT, maxContentWidth);
UI_MODAL.messageLabel.dirty = true; UI_MODAL.messageLabel.dirty = true;
uiLabelRebuffer(&UI_MODAL.messageLabel); uiLabelRebuffer(&UI_MODAL.messageLabel);
@@ -165,7 +186,10 @@ void uiModalOpen(
&UI_MODAL.menu, UI_MODAL.options, uiModalSelected, uiModalClosed, NULL &UI_MODAL.menu, UI_MODAL.options, uiModalSelected, uiModalClosed, NULL
); );
for(uint8_t i = 0; i < optionCount; i++) { for(uint8_t i = 0; i < optionCount; i++) {
MENU_BUTTON(options[i]); uiModalLocalize(
options[i], UI_MODAL.optionsText[i], UI_MODAL_OPTION_TEXT_MAX
);
MENU_BUTTON(UI_MODAL.optionsText[i]);
} }
MENU_END(UI_MODAL.options, menuIndex); MENU_END(UI_MODAL.options, menuIndex);
+34 -8
View File
@@ -15,7 +15,12 @@
#define UI_MODAL_MESSAGE_TEXT_MAX 256 #define UI_MODAL_MESSAGE_TEXT_MAX 256
#define UI_MODAL_MESSAGE_SPRITES_MAX UI_MODAL_MESSAGE_TEXT_MAX #define UI_MODAL_MESSAGE_SPRITES_MAX UI_MODAL_MESSAGE_TEXT_MAX
#define UI_MODAL_OPTIONS_MAX 4 #define UI_MODAL_OPTIONS_MAX 4
#define UI_MODAL_OPTION_TEXT_MAX 32
#define UI_MODAL_MIN_WIDTH 160.0f #define UI_MODAL_MIN_WIDTH 160.0f
// Title/message text wraps at word boundaries to stay within this
// fraction of the screen's scan width, so long (e.g. translated)
// strings don't run off-screen.
#define UI_MODAL_MAX_WIDTH_RATIO 0.8f
#define UI_MODAL_RESULT_NONE 0xFF #define UI_MODAL_RESULT_NONE 0xFF
/** /**
@@ -54,6 +59,7 @@ typedef struct {
uimenu_t menu; uimenu_t menu;
uimenuitem_t options[UI_MODAL_OPTIONS_MAX]; uimenuitem_t options[UI_MODAL_OPTIONS_MAX];
char_t optionsText[UI_MODAL_OPTIONS_MAX][UI_MODAL_OPTION_TEXT_MAX];
uimodaloptioncallback_t callback; uimodaloptioncallback_t callback;
uimodalopenedcallback_t onOpen; uimodalopenedcallback_t onOpen;
@@ -99,14 +105,19 @@ uint8_t uiModalGetResult(void);
* callback is invoked exactly once with the result, whether the dialog * callback is invoked exactly once with the result, whether the dialog
* was dismissed by selecting an option or by pressing cancel/back. * was dismissed by selecting an option or by pressing cancel/back.
* *
* @param title Display title; copied internally, safe to be transient. * title, message, and each option are treated as locale message IDs
* @param message Display message; copied internally, safe to be * first: if one matches an entry in the active locale file its
* transient. * translated string is used, otherwise the string itself is displayed
* @param options Array of option label strings; NOT copied internally, * verbatim. See uiModalLocalize.
* the pointers are stored directly by the underlying buttons, so they *
* must remain valid for as long as the modal is open (e.g. string * @param title Display title, or a locale message ID; copied
* literals or locale-owned strings). May be NULL for a message-only * internally, safe to be transient.
* dialog with no option buttons, in which case optionCount must be 0. * @param message Display message, or a locale message ID; copied
* internally, safe to be transient.
* @param options Array of option label strings/locale message IDs;
* copied internally, safe to be transient. May be NULL for a
* message-only dialog with no option buttons, in which case optionCount
* must be 0.
* @param optionCount Number of options, from 0 to UI_MODAL_OPTIONS_MAX. * @param optionCount Number of options, from 0 to UI_MODAL_OPTIONS_MAX.
* @param callback Called with the result once the dialog closes. May be * @param callback Called with the result once the dialog closes. May be
* NULL. * NULL.
@@ -139,3 +150,18 @@ void uiModalClose(uimodalclosedcallback_t callback);
* @return Any error that occurs. * @return Any error that occurs.
*/ */
errorret_t uiModalDispose(void); errorret_t uiModalDispose(void);
/**
* Resolves display text for uiModalOpen: if key matches a locale
* message ID in the active locale file, buffer is filled with its
* translated string, otherwise key is copied into buffer verbatim.
*
* @param key Display string or locale message ID.
* @param buffer Destination buffer.
* @param bufferSize Size of buffer, in bytes.
*/
void uiModalLocalize(
const char_t *key,
char_t *buffer,
const size_t bufferSize
);