diff --git a/docs/item-data.md b/docs/item-data.md index 7ecd40e067..3f246c231d 100644 --- a/docs/item-data.md +++ b/docs/item-data.md @@ -111,6 +111,7 @@ variants or option words. An online keyword need not select the same items offli | Field | Meaning | |---|---| | `tags` | Categories the game code asks for, e.g. `["mount", "flying"]`; see [Tags](#tags) | +| `description` | Lines the tooltip shows for the item, after its damage, defense and speed lines and before its durability, requirements and options (the original client's items still have their texts in the tooltip code, written after the description). The Dark Horse and the Dark Raven show it only in shops: elsewhere the pet window writes their tooltip. Its texts are in the UI's language, also when the item names stay English ("Translate item names" off). Each line is `{"text": ..., "color": ...}`: `text` is written like `name` (a plain English text, or the texts by language) and checked like it (the English text is required, a language code has only letters, digits and `-`, and a language the game has no texts in is a warning), an empty text (`{"text": ""}`) is a blank line, a long line wraps at about the width of the original tooltips' widest lines, and `\n` in a text breaks the line there (`\n\n` leaves a blank line). The description draws at most 32 rows, which it has to itself, so a long one never takes the room of the tooltip's other lines. `color` is one of the tooltip colors: `white` (left out), `blue`, `red`, `yellow`, `green`, `darkRed`, `purple`, `darkBlue`, `darkYellow`, `greenBlue`, `gray`, `redPurple`, `violet`, `orange`. A client-only field; the bmd import keeps it | | `width`, `height` | Size in inventory slots | | `slot` | Equipment slot: `mainHand`, `offHand`, `helm`, `armor`, `pants`, `gloves`, `boots`, `wings`, `pet`, `pendant`, `ring`. Left out = not equippable | | `wingTier` | Only for wings: `small`, `first`, `second`, `third`. The wing formulas (defense, defense per level, damage increase, absorption) depend on it; excellent second and third tier wings get no extra durability, and the chaos mix knows a wing's tier from it | diff --git a/docs/superpowers/specs/2026-09-25-data-driven-items-design.md b/docs/superpowers/specs/2026-09-25-data-driven-items-design.md index 7895169abc..f77be4b255 100644 --- a/docs/superpowers/specs/2026-09-25-data-driven-items-design.md +++ b/docs/superpowers/specs/2026-09-25-data-driven-items-design.md @@ -49,12 +49,12 @@ share with items) is planned in the | D12 | Id ranges | No reserved number ranges for custom items, and no range checks (`type >= X && type <= Y`) in new code; everything an item "is" comes from its data. OpenMU must keep supporting the original Season 6 client **and** this client (see section 7). | | D13 | Other item files | `ItemAddOption`, `SocketItem`, `Mix`, `pet` and set options are decided later, when we know more. | | D14 | Item options | The item file only holds **links** to the option groups an item can have (Luck, additional option, excellent, wing, harmony, guardian, socket, …). The option definitions (values, chances, levels) live in their own files. Related data stays together in one file, e.g. an option definition with all its levels and values. | -| D15 | Tooltips | Tooltips become data-driven with JSON data, converted from `ItemTooltip*` / `ItemLevelTooltip` (see "Unused item files in the repo"). Phase 8, after the item phases; moved earlier if the item changes turn out to need it. The tooltip bmd files stay in the repo until then. | +| D15 | Tooltips | Tooltips become data-driven with JSON data, converted from `ItemTooltip*` / `ItemLevelTooltip` (see "Unused item files in the repo"). Phase 8, after the item phases; moved earlier if the item changes turn out to need it. The tooltip bmd files stay in the repo until then. **Update (2026-10-22, #815):** an item's own tooltip texts (its `description` lines) live in its item file, with their translations, as client-only fields that the OpenMU exchange leaves out (D22). Texts and layouts that many items share get a tooltip file of their own. | | D16 | Unused item bmd copies | Removed on this branch; the game only loads `Item_.bmd`. | | D17 | Translations in phase 2 | Item names moved into phase 2, because the per-language bmd files were the only place the Portuguese and Spanish names existed. Names are `LocalizedString`s imported from all three files; the UI locale (the same setting as the `.resx` texts) picks the shown name, also when it changes at runtime. On disk, each item's `name` is an object of names by language (`{"en": "Blade", "es": "Espada", "pt": "Lâmina"}`, English first) in the item files; separate translation files were tried and dropped as extra maintenance. Phase 5 keeps only the translation tooling. | | D18 | Repairing the legacy data | The bmd import recovers names that ran past the 30-byte name field and takes the fields they overwrote from a language whose name did not reach them (defaults when none has them). Names that are not UTF-8 are read as Windows-1252. The changes are listed in the phase 2 PR and in `docs/item-data.md`. | | D19 | Editor sync | Every item editor change goes into the database right away (phase 2), so the editor and the database never differ. Moving the editor fully onto the database stays in phase 6 (done: the item tools edit the database, the item table is gone). | -| D22 | Model data files | Model and display data (model file, textures, inventory and ground display, cloth, effects) lives in separate files, `Data/Items/Models/GroupNN_*.json`, one per item group, items by `number`. It is client-only: the item files keep what client and server share, and only those take part in the OpenMU exchange. Separate files also keep the item files small and let the model editor and the stats editor change different files. | +| D22 | Model data files | Model and display data (model file, textures, inventory and ground display, cloth, effects) lives in separate files, `Data/Items/Models/GroupNN_*.json`, one per item group, items by `number`. It is client-only: the item files keep what client and server share, and only those take part in the OpenMU exchange. The exception is an item's own tooltip texts (D15): they stay in the item file next to its name, as client-only fields that the exchange leaves out. Separate files also keep the item files small and let the model editor and the stats editor change different files. | | D23 | Model slots | Two steps. **Own model slots (phase 4a–4c):** every item keeps its own model slot, `MODEL_ITEM + item type`, as today; the data only says which file is loaded into it, so the ~100 places that compute `+ MODEL_ITEM` / `- MODEL_ITEM` stay unchanged. **Shared models (phase 4d, right after them):** models become entries of their own, and items reference them by name, so several items can share one model (loaded once; 4d1, done). Every item keeps a slot of its own, and the conversions between item types and model slots go through one place, `ToModelSlot` / `ToItemType` (4d2), so a moved item can keep its slot (phase 12) and the named slots of items (`MODEL_KRIS`, …) stay valid. One slot per shared model, with objects keeping item types, is not planned: about 240 places identify items by their model slot (see "Item-specific code that is left"), and none of them gains anything while each item has its own slot. Phase 12 (level variants as items of their own) shares models without loading them twice. | | D24 | Render effects | The effect code stays code, but which item uses which effect moves into the model data: the glow as values (color names from a glow color list, `Data/Effects/GlowColors.json`, which has the colors of the old `PartObjectColor*` palettes; the meshes it is drawn on; the level it glows like), a render style (`RenderPartObjectBody` recipes; identical recipes share one name) and an item effect (the item branches of `RenderPartObjectEffect`). Effects that depend on the item level (+7 glow, excellent, ancient) stay generic code, and the entries for monsters and NPCs stay in code. The named looks are code for now; D25 moves what they are made of into data. | | D25 | Looks in data | Items keep naming their looks; what a named look is made of moves from code into data (phase 13). Skills, monsters and NPCs use the same look format, so the decision lives in the [roadmap](2026-09-29-data-driven-content-roadmap-design.md). | @@ -292,6 +292,7 @@ Each field belongs to one of two groups: | Categories/tags | wing, flying, mount, pet, potion, jewel, ticket, cash shop, event, … | client-only at first; shared where OpenMU gets matching data | | Model | model file, texture folders, cloth flag (in the model files, D22) | client-only | | Rendering | inventory offset, rotation and scale; ground rotation and scale; glow color, render style, effects (D24) | client-only | +| Tooltip texts | `description` (D15) | client-only, in the item files; the exchange file leaves them out | ### 4. Rule code on top of data @@ -413,9 +414,9 @@ in both repos (as separate PRs, one per repo). | 4e | Clear model loading errors | Client | MuMain | 4 | One message for missing model files and textures of item models that names the item, the model entry, the texture and the searched folders. | | 5 | Translation tooling | Client | MuMain | 2 | Translations editor (items × languages), missing names counted in the editor. The names themselves moved to phase 2 (D17). Done before phase 6. | | 6 | Editors | Client | MuMain | 2–5 | Focused MuEditor tools (section 9) on the rules of D26, including add/remove items and picking the looks of an item from lists. Takes over phase 5's item list. Done. | -| 7 | Item sync, client side | Client | MuMain | 2, 6 | MuEditor import/export of the item exchange file, with diff. | +| 7 | Item sync, client side | Client | MuMain | 2, 6 | MuEditor import/export of the item exchange file, with diff. The client-only fields of the item files (tags at first, `description`) are left out. | | B | Item sync, server side | Server | OpenMU | 7 (file format) | Admin panel import/export pages for the item exchange file, with diff. | -| 8 | Data-driven tooltips | Client | MuMain | 2, 5 | Tooltip JSON converted from the `ItemTooltip*` files; `RenderItemInfo()` reads data; tooltip editor. Moved earlier if needed. | +| 8 | Data-driven tooltips | Client | MuMain | 2, 5 | Tooltip JSON converted from the `ItemTooltip*` files; `RenderItemInfo()` reads data; tooltip editor. Moved earlier if needed. In steps (#815): an item's own texts go into its item file (`description`, D15), shared texts and layouts into a tooltip file. | | 9 | Item option definitions | Both | MuMain + C | 2 | Option groups in their own JSON files matching OpenMU's `ItemOptionDefinition`; exchange file and editor. | | 10 | Item sets | Both | MuMain + C | 2 | Item sets in JSON matching OpenMU's `ItemSetGroup`; exchange file and editor; based on the `item-set-editor` branch. Moved earlier if needed. | | C | Option and set sync, server side | Server | OpenMU | 9, 10 | Admin panel import/export for the option definition and item set exchange files. | diff --git a/src/source/Core/Text/TextLineWrap.cpp b/src/source/Core/Text/TextLineWrap.cpp index d22990298c..6bf319f9ed 100644 --- a/src/source/Core/Text/TextLineWrap.cpp +++ b/src/source/Core/Text/TextLineWrap.cpp @@ -125,48 +125,68 @@ std::vector WrapTextToWidth(const std::wstring& text, int maxWidth // has seen. npos while it has none. size_t breakAt = std::wstring::npos; + const auto fits = [&](size_t length) { return measureWidth(line.c_str(), length) <= maxWidth; }; + // Called when a word ends: the line is measured once per word, not after + // every letter (a tooltip is built every frame, and each measured piece + // is a new text for the renderer's text cache). + const auto wrapAfterWord = [&]() + { + // Spaces never make a line too wide: it is measured up to its last + // word, and the spaces it ends with are dropped when it is broken. + if (line.empty() || line.back() == L' ' || fits(line.size())) + { + return; + } + if (breakAt != std::wstring::npos) + { + std::wstring word = line.substr(breakAt); + line.erase(breakAt); + TrimTrailingSpaces(line); + // Only spaces before the word (an indented line): no line of + // their own, or an empty line would appear above the word. + if (!line.empty()) + { + lines.push_back(line); + } + line = std::move(word); + breakAt = std::wstring::npos; + } + // A single word which is wider than a whole line. It has to be broken + // somewhere, otherwise it would never fit: where it stops fitting. + while (line.size() > 1 && !fits(line.size())) + { + size_t length = 1; + while (length + 1 < line.size() && fits(length + 1)) + { + ++length; + } + lines.push_back(line.substr(0, length)); + line.erase(0, length); + } + }; + for (const wchar_t character : text) { if (character == L'\n') { + wrapAfterWord(); TrimTrailingSpaces(line); lines.push_back(line); line.clear(); breakAt = std::wstring::npos; continue; } - - line += character; if (character == L' ') { + wrapAfterWord(); + line += character; breakAt = line.size(); continue; } - - if (measureWidth(line.c_str(), line.size()) <= maxWidth) - { - continue; - } - - if (breakAt != std::wstring::npos) - { - std::wstring rest = line.substr(breakAt); - line.erase(breakAt); - TrimTrailingSpaces(line); - lines.push_back(line); - line = std::move(rest); - } - else if (line.size() > 1) - { - // A single word which is wider than a whole line. It has to be - // broken somewhere, otherwise it would never fit. - lines.push_back(line.substr(0, line.size() - 1)); - line = line.substr(line.size() - 1); - } - - breakAt = std::wstring::npos; + line += character; } + wrapAfterWord(); TrimTrailingSpaces(line); if (!line.empty()) { diff --git a/src/source/Data/GameData/ItemData/ItemAttributeConversion.cpp b/src/source/Data/GameData/ItemData/ItemAttributeConversion.cpp index a1a20fc450..ab665c1335 100644 --- a/src/source/Data/GameData/ItemData/ItemAttributeConversion.cpp +++ b/src/source/Data/GameData/ItemData/ItemAttributeConversion.cpp @@ -109,6 +109,7 @@ void CopyItemAttributeStats(const ITEM_ATTRIBUTE& attribute, ItemDefinition& def void CopyFieldsNotInItemAttribute(const ItemDefinition& source, ItemDefinition& target) { target.tags = source.tags; + target.description = source.description; target.wingTier = source.wingTier; target.weaponKind = source.weaponKind; target.statProfile = source.statProfile; diff --git a/src/source/Data/GameData/ItemData/ItemDataValidation.cpp b/src/source/Data/GameData/ItemData/ItemDataValidation.cpp index 3bebfb8c52..d6378549da 100644 --- a/src/source/Data/GameData/ItemData/ItemDataValidation.cpp +++ b/src/source/Data/GameData/ItemData/ItemDataValidation.cpp @@ -79,6 +79,33 @@ void ValidateName(const ItemDefinition& definition, std::vector& check(locale, text); } } +// A description line's text is written like a name: an English text, and translations under language codes. A blank +// line has no text in any language. +void ValidateDescription(const ItemDefinition& definition, std::vector& issues) +{ + for (size_t index = 0; index < definition.description.size(); ++index) + { + const LocalizedString& text = definition.description[index].text; + if (text.IsEmpty()) + { + continue; + } + const std::string field = "description[" + std::to_string(index) + "].text"; + if (text.GetNeutral().empty()) + { + AddIssue(issues, ItemDataIssueSeverity::Error, definition, "the English text is missing", field.c_str()); + } + for (const auto& [locale, translation] : text.GetTranslations()) + { + if (!IsValidLocaleCode(locale)) + { + AddIssue(issues, ItemDataIssueSeverity::Error, definition, + "\"" + locale + "\" is not a language code (letters, digits and \"-\" only)", field.c_str()); + } + } + } +} + // The slot of each weapon kind: the left hand (offHand) for bows, shields and // books, the right hand (mainHand) for the others. The code looks for a bow or // a shield only in the left hand and for a crossbow or a spear only in the @@ -224,6 +251,7 @@ void ValidateItems(std::span items, std::vector items, std::span(ItemTextColor::Count); + +// One line of an item's description in its tooltip: a text with its translations (empty: a blank line) and its color. +struct ItemDescriptionLine +{ + LocalizedString text; + ItemTextColor color = ItemTextColor::White; + + bool operator==(const ItemDescriptionLine& other) const = default; +}; + // What a player may do with an item. See the rule flags in ItemDefinition. enum class ItemAction : BYTE { @@ -171,6 +201,10 @@ struct ItemDefinition ItemTagSet tags; + // What the tooltip says about the item, line by line, after its own lines (empty for most items; the jewels' + // texts of the original client are still in the tooltip code). + std::vector description; + BYTE width = 0; BYTE height = 0; ItemSlot slot = ItemSlot::None; diff --git a/src/source/Data/GameData/ItemData/ItemEnumNames.cpp b/src/source/Data/GameData/ItemData/ItemEnumNames.cpp index 644071157a..65cd30e71a 100644 --- a/src/source/Data/GameData/ItemData/ItemEnumNames.cpp +++ b/src/source/Data/GameData/ItemData/ItemEnumNames.cpp @@ -89,6 +89,38 @@ constexpr std::array, ItemRequirementCount> ItemRequir {ItemRequirement::Vitality, "vitality"}, {ItemRequirement::Leadership, "leadership"}, }}; + +constexpr std::array, ItemTextColorCount> ItemTextColorNames = {{ + {ItemTextColor::White, "white"}, + {ItemTextColor::Blue, "blue"}, + {ItemTextColor::Red, "red"}, + {ItemTextColor::Yellow, "yellow"}, + {ItemTextColor::Green, "green"}, + {ItemTextColor::DarkRed, "darkRed"}, + {ItemTextColor::Purple, "purple"}, + {ItemTextColor::DarkBlue, "darkBlue"}, + {ItemTextColor::DarkYellow, "darkYellow"}, + {ItemTextColor::GreenBlue, "greenBlue"}, + {ItemTextColor::Gray, "gray"}, + {ItemTextColor::RedPurple, "redPurple"}, + {ItemTextColor::Violet, "violet"}, + {ItemTextColor::Orange, "orange"}, +}}; + +// One entry per color, in the order of their values: a color added to ItemTextColor without a name here fails to +// compile, instead of being saved as a number that reading the file then refuses. +constexpr bool NamesEveryTextColor() +{ + for (size_t index = 0; index < ItemTextColorNames.size(); ++index) + { + if (ItemTextColorNames[index].name == nullptr || static_cast(ItemTextColorNames[index].value) != index) + { + return false; + } + } + return true; +} +static_assert(NamesEveryTextColor(), "every ItemTextColor needs its name in ItemTextColorNames"); } // namespace std::span> GetEnumNames(ItemSlot) @@ -111,6 +143,11 @@ std::span> GetEnumNames(ItemTag) return ItemTagNames; } +std::span> GetEnumNames(ItemTextColor) +{ + return ItemTextColorNames; +} + std::span> GetEnumNames(ItemRequirement) { return ItemRequirementNames; diff --git a/src/source/Data/GameData/ItemData/ItemEnumNames.h b/src/source/Data/GameData/ItemData/ItemEnumNames.h index a6873abee2..ff4c4a20ec 100644 --- a/src/source/Data/GameData/ItemData/ItemEnumNames.h +++ b/src/source/Data/GameData/ItemData/ItemEnumNames.h @@ -19,6 +19,7 @@ std::span> GetEnumNames(ItemSlot); std::span> GetEnumNames(WingTier); std::span> GetEnumNames(WeaponKind); std::span> GetEnumNames(ItemTag); +std::span> GetEnumNames(ItemTextColor); // The keys of "requirements" in the item files. std::span> GetEnumNames(ItemRequirement); diff --git a/src/source/Data/GameData/ItemData/ItemJsonFormat.cpp b/src/source/Data/GameData/ItemData/ItemJsonFormat.cpp index 6b2c393188..a17a5df231 100644 --- a/src/source/Data/GameData/ItemData/ItemJsonFormat.cpp +++ b/src/source/Data/GameData/ItemData/ItemJsonFormat.cpp @@ -24,6 +24,9 @@ using namespace Json::Keys; constexpr const char* Items = "items"; constexpr const char* Name = "name"; constexpr const char* Tags = "tags"; +constexpr const char* Description = "description"; +constexpr const char* Text = "text"; +constexpr const char* Color = "color"; constexpr const char* Requirements = "requirements"; constexpr const char* RequirementsByLevel = "requirementsByLevel"; constexpr const char* ClassRequirements = "classRequirements"; @@ -144,6 +147,24 @@ OrderedJson WriteTags(const ItemTagSet& tags) return json; } +// One object per line: its text (as a name is written; "" for a blank line) and its color unless white. +OrderedJson WriteDescription(const std::vector& description) +{ + OrderedJson json = OrderedJson::array(); + for (const ItemDescriptionLine& line : description) + { + OrderedJson entry; + // A blank line is a plain empty text, as it is written by hand. + entry[Keys::Text] = line.text.IsEmpty() ? OrderedJson("") : Json::WriteLocalizedString(line.text); + if (line.color != ItemTextColor::White) + { + entry[Keys::Color] = WriteValue(line.color); + } + json.push_back(std::move(entry)); + } + return json; +} + OrderedJson WriteItem(const ItemDefinition& definition) { OrderedJson item; @@ -153,6 +174,10 @@ OrderedJson WriteItem(const ItemDefinition& definition) { item[Keys::Tags] = WriteTags(definition.tags); } + if (!definition.description.empty()) + { + item[Keys::Description] = WriteDescription(definition.description); + } const auto writeIfNotDefault = [](OrderedJson& target) { @@ -213,6 +238,7 @@ class ItemReader bool ReadIdentity(const OrderedJson& json, ItemDefinition& definition); bool ReadNames(const OrderedJson& json, LocalizedString& names); void ReadTags(const OrderedJson& json, ItemTagSet& tags); + void ReadDescription(const OrderedJson& json, std::vector& description); void ReadStats(const OrderedJson& json, ItemDefinition& definition); void ReadRequirements(const OrderedJson& json, ItemDefinition& definition); void ReadRequirementsByLevel(const OrderedJson& json, ItemDefinition& definition); @@ -355,6 +381,47 @@ void ItemReader::ReadTags(const OrderedJson& json, ItemTagSet& tags) } } +void ItemReader::ReadDescription(const OrderedJson& json, std::vector& description) +{ + const auto list = json.find(Keys::Description); + if (list == json.end()) + { + return; + } + if (!list->is_array()) + { + AddIssue(ItemDataIssueSeverity::Error, Keys::Description, "must be a list of lines"); + return; + } + + for (size_t index = 0; index < list->size(); ++index) + { + const OrderedJson& entry = (*list)[index]; + const std::string field = std::string(Keys::Description) + "[" + std::to_string(index) + "]"; + const auto text = entry.is_object() ? entry.find(Keys::Text) : entry.end(); + if (!entry.is_object() || text == entry.end()) + { + AddIssue(ItemDataIssueSeverity::Error, field, "must be an object with a text"); + continue; + } + + ItemDescriptionLine line; + const bool read = Json::ReadLocalizedString(*text, field + "." + Keys::Text, line.text, + [this](const std::string& errorField, const std::string& message) + { AddIssue(ItemDataIssueSeverity::Error, errorField, message); }); + const auto color = entry.find(Keys::Color); + if (color != entry.end()) + { + ReadValue(*color, field + "." + Keys::Color, line.color); + } + WarnAboutUnknownKeys(entry, {Keys::Text, Keys::Color}, field + "."); + if (read) + { + description.push_back(std::move(line)); + } + } +} + void ItemReader::ReadStats(const OrderedJson& json, ItemDefinition& definition) { VisitStatFields(definition, @@ -468,6 +535,7 @@ std::set> GetKnownItemKeys() std::set> keys{Keys::Number, Keys::Name, Keys::Tags, + Keys::Description, Keys::Requirements, Keys::RequirementsByLevel, Keys::ClassRequirements, @@ -490,6 +558,7 @@ bool ItemReader::Read(const OrderedJson& json, ItemDefinition& definition) } ReadTags(json, definition.tags); + ReadDescription(json, definition.description); ReadStats(json, definition); ReadRequirements(json, definition); ReadRequirementsByLevel(json, definition); diff --git a/src/source/Engine/Object/ZzzInterface.cpp b/src/source/Engine/Object/ZzzInterface.cpp index a78108bd0c..735f0e73ca 100644 --- a/src/source/Engine/Object/ZzzInterface.cpp +++ b/src/source/Engine/Object/ZzzInterface.cpp @@ -3621,10 +3621,6 @@ bool CheckSkillUseCondition(OBJECT* o, int Type) return true; } -extern wchar_t TextList[50][100]; -extern int TextListColor[50]; -extern int TextBold[50]; - void GetTime(DWORD time, std::wstring& timeText, bool isSecond) { wchar_t buff[100]; diff --git a/src/source/Engine/Object/ZzzInventory.cpp b/src/source/Engine/Object/ZzzInventory.cpp index b513f19593..e602fb560e 100644 --- a/src/source/Engine/Object/ZzzInventory.cpp +++ b/src/source/Engine/Object/ZzzInventory.cpp @@ -40,6 +40,9 @@ #include "GameLogic/Items/ItemStats.h" #include "Render/Items/ItemDisplay.h" #include "Data/GameData/ItemData/ItemModelSlots.h" +#include "Data/GameData/ItemData/ItemDatabase.h" +#include "Core/Text/TextLineWrap.h" +#include "Core/Text/Utf8.h" #include "UI/Scaling/UITransform.h" #include "GameLogic/Items/ShopRestrictions.h" #include "GameLogic/Items/TradeRestrictions.h" @@ -187,10 +190,10 @@ int getLevelGeneration(int level, unsigned int* color) return lvl; } -wchar_t TextList[50][100]; -int TextListColor[50]; -int TextBold[50]; -SIZE Size[50]; +wchar_t TextList[MaxTextListRows][100]; +int TextListColor[MaxTextListRows]; +int TextBold[MaxTextListRows]; +SIZE Size[MaxTextListRows]; int RenderTextList(int sx, int sy, int TextNum, int Tab, int iSort = RT3_SORT_CENTER) { @@ -1623,6 +1626,44 @@ void GetSpecialOptionText(int Type, wchar_t* Text, WORD Option, BYTE Value, int } } +// How wide a description line is drawn before it wraps, in the tooltip's text units: about the widest lines of the +// original tooltips. +static constexpr int ItemDescriptionWrapWidth = 200; + +void WriteItemDescriptionLines(const Data::Items::ItemDefinition& definition, ItemTooltipTextMeasure measure) +{ + const int lastRow = std::min(TextNum + MaxItemDescriptionRows, static_cast(std::size(TextList))); + for (const Data::Items::ItemDescriptionLine& line : definition.description) + { + // In the UI's language, also when the item names stay English ("Translate item names" off). + const std::wstring text = Core::Text::FromUtf8(line.text.Get(I18N::GetCurrentLocale())); + // A blank line is one blank piece. So is a blank line inside a text (`\n\n`, or a text that starts with + // `\n`): a row left empty would end the tooltip there. + const std::vector pieces = + text.empty() ? std::vector(1) : WrapTextToWidth(text, ItemDescriptionWrapWidth, measure); + for (const std::wstring& piece : pieces) + { + if (TextNum >= lastRow) + { + return; + } + if (piece.empty()) + { + // As the tooltip writes its own blank rows. + mu_swprintf(TextList[TextNum], L"\n"); + SkipNum++; + } + else + { + mu_swprintf(TextList[TextNum], L"%.*ls", static_cast(std::size(TextList[0]) - 1), piece.c_str()); + } + TextListColor[TextNum] = static_cast(line.color); + TextBold[TextNum] = false; + TextNum++; + } + } +} + // (sx, sy) in the screen's 640x480 stretch, `screen`. static void RenderItemInfo(const UI::Scaling::Transform& screen, int sx, int sy, ITEM* ip, bool Sell, int Inventype, bool bItemTextListBoxUse) @@ -3748,6 +3789,12 @@ static void RenderItemInfo(const UI::Scaling::Transform& screen, int sx, int sy, TextListColor[TextNum] = TEXT_COLOR_WHITE; TextBold[TextNum] = false; TextNum++; } } + if (const Data::Items::ItemDefinition* definition = g_ItemDatabase.Find(ip->Type)) + { + g_pRenderText->SetFont(g_hFont); + WriteItemDescriptionLines(*definition, [](const wchar_t* text, size_t length) -> int + { return g_pRenderText->MeasureText(text, static_cast(length)).cx; }); + } if (ip->Type == ITEM_JEWEL_OF_BLESS) { mu_swprintf(TextList[TextNum], I18N::Game::ItIsUsedToIncreaseYourItemLevelUpTo6); diff --git a/src/source/Engine/Object/ZzzInventory.h b/src/source/Engine/Object/ZzzInventory.h index 68222ec4b7..d7732b3089 100644 --- a/src/source/Engine/Object/ZzzInventory.h +++ b/src/source/Engine/Object/ZzzInventory.h @@ -82,10 +82,27 @@ extern int AllRepairGold; ////////////////////////////////////////////////////////////////////////// // text 관련 ////////////////////////////////////////////////////////////////////////// -extern wchar_t TextList[50][100]; -extern int TextListColor[50]; -extern int TextBold[50]; -extern SIZE Size[50]; +// The rows an item's description may write into a tooltip, on top of the original client's 50: the description +// gets rows of its own, so it never takes the room the rest of the tooltip needs. +constexpr int MaxItemDescriptionRows = 32; +constexpr int MaxTextListRows = 50 + MaxItemDescriptionRows; + +extern wchar_t TextList[MaxTextListRows][100]; +extern int TextListColor[MaxTextListRows]; +extern int TextBold[MaxTextListRows]; +extern SIZE Size[MaxTextListRows]; + +namespace Data::Items +{ +struct ItemDefinition; +} + +// How wide a text is in the tooltip's text units. +using ItemTooltipTextMeasure = int (*)(const wchar_t* text, size_t length); +// Writes the item data's description in the UI's language into TextList, TextListColor and TextBold from TextNum on: +// each line wrapped to the tooltip's width and at its line breaks, the pieces in the line's color, a blank line or +// piece as the tooltip's own blank rows. At most MaxItemDescriptionRows rows. +void WriteItemDescriptionLines(const Data::Items::ItemDefinition& definition, ItemTooltipTextMeasure measure); /////////////////////////////////////////////////////////////////////////////// // party diff --git a/src/source/GameLogic/Events/w_CursedTemple.cpp b/src/source/GameLogic/Events/w_CursedTemple.cpp index 2db70bc338..dde068b96b 100644 --- a/src/source/GameLogic/Events/w_CursedTemple.cpp +++ b/src/source/GameLogic/Events/w_CursedTemple.cpp @@ -1,5 +1,6 @@  #include "stdafx.h" +#include "Engine/Object/ZzzInventory.h" #include "w_CursedTemple.h" #include "Render/Textures/ZzzOpenglUtil.h" #include "Render/Textures/ZzzTexture.h" @@ -21,8 +22,6 @@ extern int TextNum; -extern wchar_t TextList[50][100]; -extern int TextListColor[50]; using namespace SEASON3A; diff --git a/src/source/GameLogic/Items/CSItemOption.cpp b/src/source/GameLogic/Items/CSItemOption.cpp index f9ce378eee..8643ddfa2d 100644 --- a/src/source/GameLogic/Items/CSItemOption.cpp +++ b/src/source/GameLogic/Items/CSItemOption.cpp @@ -29,9 +29,6 @@ #include #include -extern wchar_t TextList[50][100]; -extern int TextListColor[50]; -extern int TextBold[50]; namespace { diff --git a/src/source/GameLogic/Pets/CSPetSystem.cpp b/src/source/GameLogic/Pets/CSPetSystem.cpp index 80c0c2f2b9..25d34090d3 100644 --- a/src/source/GameLogic/Pets/CSPetSystem.cpp +++ b/src/source/GameLogic/Pets/CSPetSystem.cpp @@ -41,9 +41,6 @@ bool IsValidCharacterIndex(int index) extern bool g_PetEnableDuel; extern double WorldTime; -extern wchar_t TextList[50][100]; -extern int TextListColor[50]; -extern int TextBold[50]; CSPetSystem::CSPetSystem() : m_PetOwner(nullptr), m_PetTarget(nullptr), m_PetCharacter(), m_PetType(PET_TYPE_NONE), m_pPetInfo(nullptr), diff --git a/src/source/GameLogic/Pets/GIPetManager.cpp b/src/source/GameLogic/Pets/GIPetManager.cpp index c513ab1344..81b842f34e 100644 --- a/src/source/GameLogic/Pets/GIPetManager.cpp +++ b/src/source/GameLogic/Pets/GIPetManager.cpp @@ -34,9 +34,6 @@ #include "Render/Text/CUIRenderText.h" extern bool SkillEnable; -extern wchar_t TextList[50][100]; -extern int TextListColor[50]; -extern int TextBold[50]; extern int CheckX; extern int CheckY; extern int CheckSkill; diff --git a/src/source/UI/Core/UIManager.cpp b/src/source/UI/Core/UIManager.cpp index 0bf4c86b51..4a1fd28f7f 100644 --- a/src/source/UI/Core/UIManager.cpp +++ b/src/source/UI/Core/UIManager.cpp @@ -31,16 +31,16 @@ extern int TextNum; namespace mu::ui::window { - int& MouseY = ::MouseY; - int& g_iCancelSkillTarget = ::g_iCancelSkillTarget; - wchar_t (&TextList)[50][100] = ::TextList; - int (&TextListColor)[50] = ::TextListColor; - int (&TextBold)[50] = ::TextBold; - int& TextNum = ::TextNum; - int& ItemHelp = ::ItemHelp; - JewelHarmonyInfo*& g_pUIJewelHarmonyinfo = ::g_pUIJewelHarmonyinfo; - int& SelectedCharacter = ::SelectedCharacter; -} +int& MouseY = ::MouseY; +int& g_iCancelSkillTarget = ::g_iCancelSkillTarget; +wchar_t (&TextList)[MaxTextListRows][100] = ::TextList; +int (&TextListColor)[MaxTextListRows] = ::TextListColor; +int (&TextBold)[MaxTextListRows] = ::TextBold; +int& TextNum = ::TextNum; +int& ItemHelp = ::ItemHelp; +JewelHarmonyInfo*& g_pUIJewelHarmonyinfo = ::g_pUIJewelHarmonyinfo; +int& SelectedCharacter = ::SelectedCharacter; +} // namespace mu::ui::window extern int ShopInventoryStartX; extern int ShopInventoryStartY; diff --git a/src/source/UI/Events/CursedTempleSystem.cpp b/src/source/UI/Events/CursedTempleSystem.cpp index 383419dce3..fa43d4edcc 100644 --- a/src/source/UI/Events/CursedTempleSystem.cpp +++ b/src/source/UI/Events/CursedTempleSystem.cpp @@ -44,8 +44,6 @@ #include extern int TextNum; -extern wchar_t TextList[50][100]; -extern int TextListColor[50]; using namespace SEASON3B; using namespace mu::ui::window; diff --git a/src/source/World/GameMaps/GMHellas.cpp b/src/source/World/GameMaps/GMHellas.cpp index c234ea2965..fd435d8332 100644 --- a/src/source/World/GameMaps/GMHellas.cpp +++ b/src/source/World/GameMaps/GMHellas.cpp @@ -2,6 +2,7 @@ // GMHellas.cpp ////////////////////////////////////////////////////////////////////////// #include "stdafx.h" +#include "Engine/Object/ZzzInventory.h" #include "Render/Textures/ZzzOpenglUtil.h" #include "Render/Renderer/MuRenderer.h" #include "Render/Textures/ZzzTexture.h" @@ -24,9 +25,6 @@ #include "Render/Text/CUIRenderText.h" extern int WaterTextureNumber; -extern wchar_t TextList[50][100]; -extern int TextListColor[50]; -extern int TextBold[50]; static CSWaterTerrain* g_pCSWaterTerrain = NULL; static std::queue g_qObjDes; diff --git a/tests/data/CMakeLists.txt b/tests/data/CMakeLists.txt index 33c8a9a9f4..e5f4fdb8b5 100644 --- a/tests/data/CMakeLists.txt +++ b/tests/data/CMakeLists.txt @@ -56,6 +56,7 @@ mu_add_test( SOURCES test_helper_pickup_filters.cpp test_item_database_files.cpp + test_item_descriptions.cpp test_item_model_files.cpp test_item_requirement_weights.cpp test_item_rules.cpp diff --git a/tests/data/test_item_descriptions.cpp b/tests/data/test_item_descriptions.cpp new file mode 100644 index 0000000000..37a0f94d54 --- /dev/null +++ b/tests/data/test_item_descriptions.cpp @@ -0,0 +1,153 @@ +#include "stdafx.h" + +#include "doctest.h" + +#include "Data/GameData/ItemData/ItemDataValidation.h" +#include "Data/GameData/ItemData/ItemDatabase.h" +#include "I18N/All.h" +#include "Engine/Object/ZzzInventory.h" + +#include +#include +#include + +extern int TextNum; +extern int SkipNum; + +using namespace Data::Items; + +namespace +{ +// Five units a letter: the description wraps at 200, so 40 letters fit a row. +int FiveUnitsPerLetter(const wchar_t*, size_t length) +{ + return static_cast(length) * 5; +} + +ItemDescriptionLine Line(const char* english, ItemTextColor color = ItemTextColor::White) +{ + ItemDescriptionLine line; + line.text.Set("en", english); + line.color = color; + return line; +} + +// The tooltip rows the description writes, from an empty list. +std::vector WriteRows(const ItemDefinition& definition) +{ + TextNum = 0; + SkipNum = 0; + WriteItemDescriptionLines(definition, FiveUnitsPerLetter); + return {std::begin(TextList), std::begin(TextList) + TextNum}; +} + +std::vector DescriptionIssues(const ItemDefinition& definition, std::span uiLocales) +{ + std::vector issues; + ValidateItems(std::vector{definition}, issues); + ValidateNameLocales(std::vector{definition}, uiLocales, issues); + std::erase_if(issues, [](const ItemDataIssue& issue) { return !issue.field.starts_with("description"); }); + return issues; +} + +ItemDefinition MakeJewel() +{ + ItemDefinition jewel; + jewel.group = 14; + jewel.number = 13; + jewel.names.Set("en", "Jewel of Bless"); + return jewel; +} +} // namespace + +TEST_CASE("A blank line inside a description text is a blank tooltip row, not the tooltip's end [data][items]") +{ + ItemDefinition jewel = MakeJewel(); + jewel.description = {Line("Line one\n\nLine two"), Line("\nLeading break"), Line("")}; + + const std::vector rows = WriteRows(jewel); + + // An empty row would end the tooltip there: a blank one is "\n", as the tooltip writes its own. + const std::vector expected = {L"Line one", L"\n", L"Line two", L"\n", L"Leading break", L"\n"}; + CHECK(rows == expected); + CHECK(std::none_of(rows.begin(), rows.end(), [](const std::wstring& row) { return row.empty(); })); + CHECK(SkipNum == 3); +} + +TEST_CASE("A description wraps to the tooltip's width in its line's color [data][items]") +{ + ItemDefinition jewel = MakeJewel(); + jewel.description = { + Line("It is used to increase your item level up to 6 with a little more", ItemTextColor::Blue)}; + + const std::vector rows = WriteRows(jewel); + + REQUIRE(rows.size() == 2); + CHECK(rows[0] == L"It is used to increase your item level"); + CHECK(rows[1] == L"up to 6 with a little more"); + CHECK(TextListColor[0] == TEXT_COLOR_BLUE); + CHECK(TextListColor[1] == TEXT_COLOR_BLUE); +} + +TEST_CASE("A description writes at most its own rows, leaving the tooltip's rows to the rest [data][items]") +{ + ItemDefinition jewel = MakeJewel(); + jewel.description.assign(MaxItemDescriptionRows + 10, Line("A line")); + + TextNum = 5; + WriteItemDescriptionLines(jewel, FiveUnitsPerLetter); + CHECK(TextNum == 5 + MaxItemDescriptionRows); + + // Near the end of the list it stops at the list's end. + TextNum = MaxTextListRows - 3; + WriteItemDescriptionLines(jewel, FiveUnitsPerLetter); + CHECK(TextNum == MaxTextListRows); +} + +TEST_CASE("Description texts are checked like item names [data][items]") +{ + const char* uiLocales[] = {"en", "de"}; + + ItemDefinition jewel = MakeJewel(); + ItemDescriptionLine noEnglish; + noEnglish.text.Set("de", "Nur Deutsch"); + ItemDescriptionLine badCode = Line("A text"); + badCode.text.Set("d e", "Ein Text"); + ItemDescriptionLine unknownLanguage = Line("A text"); + unknownLanguage.text.Set("xx", "Ein Text"); + jewel.description = {Line(""), noEnglish, badCode, unknownLanguage}; + + const std::vector issues = DescriptionIssues(jewel, uiLocales); + + // The blank line has none; "d e" is no language code and, like "xx", no language the game shows. + REQUIRE(issues.size() == 4); + CHECK(issues[0].severity == ItemDataIssueSeverity::Error); + CHECK(issues[0].field == "description[1].text"); + CHECK(issues[0].message == "the English text is missing"); + CHECK(issues[1].severity == ItemDataIssueSeverity::Error); + CHECK(issues[1].field == "description[2].text"); + CHECK(issues[2].severity == ItemDataIssueSeverity::Warning); + CHECK(issues[2].field == "description[2].text"); + CHECK(issues[3].severity == ItemDataIssueSeverity::Warning); + CHECK(issues[3].field == "description[3].text"); +} + +TEST_CASE("A description is in the UI's language also when the item names stay English [data][items]") +{ + ItemDefinition jewel = MakeJewel(); + ItemDescriptionLine line = Line("It is used to increase your item level up to 6"); + line.text.Set("de", "Wird verwendet, um das Gegenstandslevel auf bis zu 6 zu erhöhen"); + jewel.description = {line}; + + // "Translate item names" off: the item names are English, the UI German. + const std::string itemNameLocale = g_ItemDatabase.GetDisplayLocale(); + const std::string uiLocale = I18N::GetCurrentLocale(); + g_ItemDatabase.SetDisplayLocale("en"); + I18N::SetLocale("de"); + const std::vector rows = WriteRows(jewel); + I18N::SetLocale(uiLocale.c_str()); + g_ItemDatabase.SetDisplayLocale(itemNameLocale); + + REQUIRE_FALSE(rows.empty()); + CHECK(rows[0].starts_with(L"Wird verwendet")); +} diff --git a/tests/data/test_item_json.cpp b/tests/data/test_item_json.cpp index 016be075cb..ce9c0166b8 100644 --- a/tests/data/test_item_json.cpp +++ b/tests/data/test_item_json.cpp @@ -285,6 +285,46 @@ TEST_CASE("Item tags, wing tier, potion priority and rule flags are kept through CHECK(read.sellable); } +TEST_CASE("An item's description is kept through write and read [data][items]") +{ + ItemDefinition jewel = MakeKris(); + jewel.group = 14; + jewel.number = 13; + jewel.slot = ItemSlot::None; + jewel.weaponKind = WeaponKind::None; + jewel.statProfile.clear(); + // The Jewel of Bless's own tooltip line, as the client's translations have it. + ItemDescriptionLine first; + first.text.Set("en", "It is used to increase your item level up to 6"); + first.text.Set("de", "Wird verwendet, um das Gegenstandslevel auf bis zu 6 zu erhöhen"); + first.text.Set("es", "Se utiliza para aumentar el nivel de tu objeto hasta 6."); + first.text.Set("pt", "É usado para aumentar o nível do seu item até 6"); + ItemDescriptionLine blank; + ItemDescriptionLine last = first; + last.color = ItemTextColor::Blue; + jewel.description = {first, blank, last}; + + const std::string text = WriteItemGroupJson(14, std::vector{jewel}); + CHECK(text.find("\"color\": \"blue\"") != std::string::npos); + const ReadResult result = Read(text); + REQUIRE(result.items.size() == 1); + CHECK(result.items.front().description == jewel.description); + CHECK_FALSE(HasErrors(result.issues)); + CHECK(WriteItemGroupJson(14, result.items) == text); + + // A line break in a text is kept; the tooltip starts a new line there. + ItemDefinition twoLines = jewel; + twoLines.description = {first}; + twoLines.description[0].text.Set("en", "It is used to increase\nyour item level up to 6"); + const ReadResult broken = Read(WriteItemGroupJson(14, std::vector{twoLines})); + REQUIRE(broken.items.size() == 1); + CHECK(broken.items.front().description == twoLines.description); + + const ReadResult badColor = Read(R"({"formatVersion": 1, "group": 14, "items": [ + {"number": 13, "name": "Jewel of Bless", "description": [{"text": "x", "color": "pink"}]}]})"); + CHECK(HasErrors(badColor.issues)); +} + TEST_CASE("An item's stat profile is kept through write and read [data][items]") { ItemDefinition kris = MakeKris(); diff --git a/tests/text/test_text_line_wrap.cpp b/tests/text/test_text_line_wrap.cpp index 2a535d55a1..f4684b1d96 100644 --- a/tests/text/test_text_line_wrap.cpp +++ b/tests/text/test_text_line_wrap.cpp @@ -260,3 +260,64 @@ TEST_CASE("wrap to width: an own line break starts a new line") CHECK(lines[0] == L"first"); CHECK(lines[1] == L"second"); } + +TEST_CASE("wrap to width: a blank line inside the text is an empty line") +{ + const auto lines = WrapTextToWidth(L"Line one\n\nLine two", 32, OneUnitPerCharacter()); + + REQUIRE(lines.size() == 3); + CHECK(lines[0] == L"Line one"); + CHECK(lines[1].empty()); + CHECK(lines[2] == L"Line two"); + + const auto leading = WrapTextToWidth(L"\nLeading break", 32, OneUnitPerCharacter()); + REQUIRE(leading.size() == 2); + CHECK(leading[0].empty()); + CHECK(leading[1] == L"Leading break"); +} + +TEST_CASE("wrap to width: a line is measured once per word, not per letter") +{ + int measured = 0; + const MeasureTextWidth counting = [&measured](const wchar_t*, size_t length) + { + ++measured; + return static_cast(length); + }; + + const auto lines = WrapTextToWidth(L"Handles the chat command", 12, counting); + + REQUIRE(lines.size() == 2); + CHECK(measured <= 5); // four words, and the line measured again after its break +} + +TEST_CASE("wrap to width: a word wider than the line after a break is broken too") +{ + const auto lines = WrapTextToWidth(L"set masterleveluppoints", 8, OneUnitPerCharacter()); + + REQUIRE(lines.size() == 4); + CHECK(lines[0] == L"set"); + CHECK(lines[1] == L"masterle"); + CHECK(lines[2] == L"veluppoi"); + CHECK(lines[3] == L"nts"); +} + +TEST_CASE("wrap to width: an indented word wider than the line gets no empty line above it") +{ + const auto lines = WrapTextToWidth(L" masterleveluppoints", 8, OneUnitPerCharacter()); + + REQUIRE(lines.size() == 3); + CHECK(lines[0] == L"masterle"); + CHECK(lines[1] == L"veluppoi"); + CHECK(lines[2] == L"nts"); + + // The same after a line break: the text's own blank lines stay. + const auto afterBreak = WrapTextToWidth(L"set\n masterleveluppoints\n\nend", 8, OneUnitPerCharacter()); + REQUIRE(afterBreak.size() == 6); + CHECK(afterBreak[0] == L"set"); + CHECK(afterBreak[1] == L"masterle"); + CHECK(afterBreak[2] == L"veluppoi"); + CHECK(afterBreak[3] == L"nts"); + CHECK(afterBreak[4].empty()); + CHECK(afterBreak[5] == L"end"); +}