24 Commits

Author SHA1 Message Date
sindoring c905d0558e добавлены новые питомцы 2026-08-04 20:11:52 +04:00
sindoring 76ca0cc851 fix 2026-08-04 08:49:51 +04:00
sindoring 398245d27d fix dbc 2026-07-27 00:31:44 +04:00
sindoring 887ab7879b одиночные подземелья 2026-07-26 22:48:10 +04:00
sindoring e3b8d2649e Update WarcraftXL storage fallback for loose models 2026-07-23 09:51:05 +04:00
sindoring e916cdea19 Add MDX aliases for modern creature models 2026-07-23 01:41:39 +04:00
sindoring befdaa456d Stage modern creature assets through WarcraftXL host 2026-07-23 01:36:59 +04:00
sindoring 7291fff664 Add server model metadata for custom pets 2026-07-23 01:25:12 +04:00
sindoring 790506fc7a Generate companion summon spells 2026-07-23 01:14:05 +04:00
sindoring 87329f2633 Automate custom mount and pet DBC generation 2026-07-23 00:37:42 +04:00
sindoring 8375b95f7b Add retail catslime model support 2026-07-23 00:19:46 +04:00
sindoring 01f476ca1b fix case-insensitive manifest merging 2026-07-19 16:19:36 +04:00
sindoring 63c9af7846 fix production manifest publishing 2026-07-19 16:09:51 +04:00
sindoring f02f4d73f0 fix ej 2026-07-19 15:39:52 +04:00
sindoring 721833bb50 Merge branch 'encounter-journal' 2026-07-19 12:50:06 +04:00
sindoring b30a3554cf фикс журнала 2026-07-19 12:42:10 +04:00
sindoring 595ed5091d восстановил камеру создания персонажа 2026-07-18 19:08:12 +04:00
sindoring 85f80445d1 план 2026-07-18 17:05:18 +04:00
sindoring 2291ace9f7 Merge branch 'master' into encounter-journal 2026-07-18 15:49:11 +04:00
sindoring d5e2dce359 правки по encounter journal 2026-06-13 15:07:58 +04:00
sindoring 22ae941c4a Merge branch 'master' into encounter-journal 2026-06-13 11:52:07 +04:00
sindoring b0da9ec10b базовый encounter journal 2026-06-13 11:46:39 +04:00
sindoring d8d94f10b0 стабильная версия encounter journal 2026-05-16 23:43:39 +04:00
sindoring 255485daca work in progress 2026-05-06 23:15:58 +04:00
882 changed files with 57084 additions and 135 deletions
+4
View File
@@ -6,3 +6,7 @@ AWS_DEFAULT_REGION=
AWS_BUCKET=
AWS_ENDPOINT=
AWS_USE_PATH_STYLE_ENDPOINT=
PRODUCTION_REALMLIST=
PTR_REALMLIST=
LOCAL_REALMLIST=
+5
View File
@@ -1,15 +1,20 @@
[submodule "vendor/warcraftxl"]
path = vendor/warcraftxl
url = https://github.com/WarcraftXL/wxl-core.git
branch = main
[submodule "vendor/modules/wxl-modern-assets"]
path = vendor/modules/wxl-modern-assets
url = https://github.com/WarcraftXL/wxl-modern-assets.git
branch = main
[submodule "vendor/modules/wxl-modern-render"]
path = vendor/modules/wxl-modern-render
url = https://github.com/WarcraftXL/wxl-modern-render.git
branch = main
[submodule "vendor/modules/wxl-unit-outline"]
path = vendor/modules/wxl-unit-outline
url = https://github.com/WarcraftXL/wxl-unit-outline.git
branch = main
[submodule "vendor/modules/wxl-modern-adt"]
path = vendor/modules/wxl-modern-adt
url = https://github.com/WarcraftXL/wxl-modern-adt.git
branch = main
+3
View File
@@ -41,6 +41,8 @@ if(TARGET WarcraftXL)
endif()
if(TARGET WarcraftXLHost)
wxl_collect_sources(WXL_MOONWELL_HOST
"${CMAKE_CURRENT_SOURCE_DIR}/modules/moonwell/host/*.cpp")
wxl_collect_sources(WXL_MODERN_ASSETS_HOST
"${WXL_EXTERNAL_MODULES_DIR}/wxl-modern-assets/host/*.cpp"
"${WXL_EXTERNAL_MODULES_DIR}/wxl-modern-assets/shared/*.cpp")
@@ -53,6 +55,7 @@ if(TARGET WarcraftXLHost)
"${WXL_EXTERNAL_MODULES_DIR}/wxl-modern-adt/shared/*.cpp")
target_sources(WarcraftXLHost PRIVATE
${WXL_MOONWELL_HOST}
${WXL_MODERN_ASSETS_HOST}
${WXL_MODERN_ADT_HOST})
endif()
+788
View File
@@ -0,0 +1,788 @@
# План расширенной кастомизации персонажей MoonWell
## 1. Цель
Создать для клиента WoW 3.3.5a (build 12340) расширенную систему внешности, визуально и по удобству близкую к retail, сохранив оригинальный `Wow.exe` и используя WarcraftXL для всех клиентских расширений.
Система должна:
- поддерживать стандартные параметры 3.3.5a и дополнительные параметры MoonWell;
- показывать одинаковую внешность при создании, выборе персонажа, входе в мир и другим игрокам;
- хранить дополнительные параметры на сервере и считать сервер источником истины;
- поддерживать текстурные слои, geoset базовой модели и отдельные прикрепляемые M2-модели;
- работать без Eluna/AIO в качестве постоянного транспорта состояния;
- позволять добавлять новые варианты преимущественно данными и ассетами;
- иметь версионирование формата и безопасный откат.
## 2. Не входит в первую версию
- полное копирование внутренних `ChrCustomization*` DB2 и протокола актуального retail-клиента;
- поддержка всех retail-рас, которых нет в MoonWell;
- динамическое изменение скелета или пропорций тела;
- автоматический импорт всех retail-ассетов без ручной проверки;
- публикация полного дампа современных файлов Blizzard;
- изменение бинарника `Wow.exe` на диске.
## 3. Базовые ограничения
### Клиент
- WoW 3.3.5a, build 12340, Lua 5.1, `## Interface: 30300`.
- `Wow.exe` должен оставаться оригинальным с контролируемым SHA-256.
- Новое нативное поведение реализуется модулем MoonWell внутри WarcraftXL.
- GlueXML и FrameXML не используют retail API (`C_Timer`, `CreateFramePool`, `PixelUtil` и т. п.).
- Для анимаций интерфейса используется `OnUpdate`.
- UI-текстуры: `.blp`/`.tga`, размеры по степеням двойки, не более 512×512 на отдельный UI-тайл.
- В Lua/XML использовать `SetWidth`/`SetHeight`, а не `SetSize`.
### Ассеты и рендер
- Исходные современные M2/MD21 обрабатываются `wxl-modern-assets`.
- Каждая модель проверяется на поддерживаемую версию формата, лимит костей, материалы, анимации и attachment points.
- Не использовать OBJ/FBX/glTF как вход для игрового клиента: нужны исходные `.m2`, `.skin`, `.skel`, `.anim` и `.blp`.
- UI-превью и модель в мире должны использовать один каталог вариантов и одинаковые идентификаторы.
### Сервер
- Сервер валидирует расу, пол, доступность и совместимость каждого выбора.
- Клиент не может назначить себе недоступный вариант внешности.
- Дополнительные параметры хранятся отдельно от стандартных байтов внешности 3.3.5a.
- Изменения схемы БД поставляются миграциями и имеют обратимый путь.
## 4. Целевая архитектура
```text
Retail CDN (выбранные FileDataID)
│
▼
Asset manifest + downloader + validation
│
▼
Patch*.MPQ / loose Patch-WXL assets
│
├── wxl-modern-assets
├── адаптированный wxl-equip-extension
└── MoonWell customization runtime
│
Lua API / GlueXML UI
│
▼
MoonWell customization protocol
│
▼
AzerothCore storage + validation + broadcast
```
### Компоненты
1. **Asset pipeline** — получает только перечисленные зависимости по `FileDataID`, сохраняет манифест и проверяет комплектность.
2. **`wxl-modern-assets`** — загружает и приводит современные M2/MD21 и текстуры к контракту клиента 3.3.5a.
3. **Адаптированный `wxl-equip-extension`** — прикрепляет M2, поддерживает collection M2, два модельных канала, attachment points и фильтрацию geoset.
4. **MoonWell customization runtime** — управляет независимыми косметическими каналами, текстурными слоями, geoset базовой модели и Lua API.
5. **GlueXML UI** — категории, список вариантов, камера, случайный выбор, сброс и предварительный просмотр.
6. **Серверный модуль** — хранение, валидация, загрузка, изменение и распространение внешности.
## 5. Типы параметров внешности
### Стандартные параметры 3.3.5a
- пол;
- цвет кожи;
- лицо;
- причёска;
- цвет волос;
- стандартная особенность лица/борода.
Они продолжают использовать штатные поля персонажа и стандартный пакет создания.
### Дополнительные текстурные параметры
- шрамы;
- татуировки тела;
- татуировки лица;
- макияж;
- рисунок/отметины;
- дополнительные оттенки кожи;
- дополнительные слои лица.
Реализация: расширение `CharSections`, каталог слоёв и модификация/перехват клиентского композитора текстуры персонажа.
### Geoset базовой модели
- дополнительные причёски;
- части бороды и усов;
- форма ушей/бровей;
- варианты клыков;
- встроенные рога;
- скрытие частей базовой модели.
Реализация: таблица выбора geoset по расе, полу, категории и варианту.
### Прикрепляемые M2-элементы
- серьги и пирсинг;
- украшения для волос;
- объёмные бороды;
- рога и дополнительные клыки;
- протезы;
- венцы, диадемы и прочие аксессуары;
- независимые объёмные элементы тела.
Реализация: косметические каналы поверх механизма `wxl-equip-extension`.
## 6. Косметические каналы
Кастомизация не должна занимать реальные слоты экипировки. Нужен отдельный реестр каналов:
```text
hair_extra
beard_extra
horns
ears
tusks
piercing_1
piercing_2
accessory_head
accessory_face
accessory_body_1
accessory_body_2
```
Для каждого канала задаются:
- стабильный числовой `option_id`;
- допустимые расы и пол;
- `display_id` или прямое описание M2;
- Model1/Model2;
- Texture1/Texture2;
- attachment point для каждого модельного канала;
- список разрешённых geoset collection M2;
- флаги суффиксов расы/пола и структуры каталогов;
- правила конфликта со шлемом и настоящей экипировкой;
- приоритет и взаимоисключающие каналы;
- значение сброса (`choice_id = 0`).
## 7. Адаптация `wxl-equip-extension`
### Обязательные работы
- закрепить точную ревизию репозитория как submodule;
- адаптировать сборочную структуру к текущему WarcraftXL MoonWell;
- проверить пересечение хуков с `wxl-modern-assets` и существующими `OnItemSlotChange`, `OnItemSlotClear`, `OnM2PerFrameUpdate`;
- вынести разбор Model1/Model2, `Icon2`, attachment points и collection geoset в переиспользуемый слой;
- добавить API независимых косметических каналов, не привязанных к надетому предмету;
- устранить утечку/остаток collection M2 после снятия или смены варианта;
- обеспечить корректное уничтожение дочерних моделей при смене персонажа, карты, формы и пола;
- исключить двойное прикрепление модели при повторной синхронизации;
- очистить мусорные Model1/Model2 в старых строках `ItemDisplayInfo.dbc`;
- добавить диагностический лог разрешения пути, модели, текстуры, attachment point и geoset;
- реализовать безопасный fallback при отсутствии ассета.
### Проверки совместимости
- персонаж игрока;
- другие игроки;
- NPC с тем же `display_id`;
- экран создания;
- экран выбора;
- парикмахерская;
- смена экипировки;
- превращения и shapeshift;
- транспорт;
- телепорт и смена карты;
- повторный вход и `/reload`.
## 8. Каталог данных кастомизации
Нужен единый, версионируемый источник данных, из которого генерируются клиентское и серверное представления.
Предлагаемая запись:
```text
option_id
choice_id
race_mask
gender_mask
category
label_key
render_type
char_section_id
geoset_ids
model_1
model_2
texture_1
texture_2
attachment_1
attachment_2
flags
conflict_group
unlock_condition
asset_manifest_id
enabled
```
Требования:
- `(option_id, choice_id)` никогда не переиспользуется с другим смыслом;
- удалённый вариант остаётся распознаваемым для миграции старых персонажей;
- порядок отображения UI хранится отдельно от стабильного идентификатора;
- клиент и сервер собираются из одной версии каталога;
- версия каталога входит в сетевое рукопожатие.
## 9. Серверное хранение
### Предлагаемая таблица
```sql
character_customization (
guid INT UNSIGNED NOT NULL,
option_id SMALLINT UNSIGNED NOT NULL,
choice_id INT UNSIGNED NOT NULL,
updated_at TIMESTAMP NOT NULL,
PRIMARY KEY (guid, option_id)
)
```
Дополнительно:
- индекс по `guid`;
- внешний ключ или очистка записей при удалении персонажа;
- миграция с начальными значениями для существующих персонажей;
- сервис сброса неизвестных/отключённых вариантов;
- аудит изменения внешности при необходимости.
### Серверная логика
- загрузить набор при `Character::LoadFromDB` или эквивалентном этапе;
- валидировать выбор по расе, полу, режиму игры и доступности;
- сохранять транзакционно;
- включать состояние в данные экрана выбора персонажа;
- отправлять полный snapshot при входе в мир;
- отправлять delta при изменении;
- рассылать актуальное состояние игрокам в зоне видимости;
- отправлять snapshot при появлении объекта в зоне видимости нового клиента;
- ограничить частоту изменений;
- запретить недоступные комбинации независимо от клиентского UI.
## 10. Сетевой протокол MoonWell
### Общие требования
- собственный namespace/opcode или надёжный WarcraftXL transport;
- версия протокола и версия каталога;
- явная длина сообщения и лимит числа записей;
- little-endian поля с фиксированной шириной;
- сервер является источником истины;
- неизвестные поля пропускаются или отклоняются согласно версии;
- отсутствие расширения не ломает стандартный вход в игру.
### Сообщения
```text
CMSG_MOONWELL_CUSTOMIZATION_CREATE
protocol_version
catalog_version
option_count
repeated(option_id, choice_id)
SMSG_MOONWELL_CUSTOMIZATION_RESULT
result_code
normalized choices
SMSG_MOONWELL_CUSTOMIZATION_SNAPSHOT
player_guid
catalog_version
option_count
repeated(option_id, choice_id)
CMSG_MOONWELL_CUSTOMIZATION_UPDATE
option_count
repeated(option_id, choice_id)
SMSG_MOONWELL_CUSTOMIZATION_DELTA
player_guid
option_count
repeated(option_id, choice_id)
```
### Экран выбора персонажа
Предпочтительный вариант — отдельный snapshot после получения списка персонажей. Не расширять штатный пакет без строгой проверки длины и совместимости.
Клиент должен:
- сопоставить snapshot с GUID персонажа;
- применить его к модели выбранного персонажа;
- хранить данные только до обновления от сервера;
- показывать стандартную внешность, если snapshot отсутствует или версия несовместима.
## 11. Клиентский runtime API
Планируемый API WarcraftXL:
```text
MoonWellCustomizationGetCatalogVersion()
MoonWellCustomizationGetOptions(race, gender)
MoonWellCustomizationGetChoices(optionId)
MoonWellCustomizationGetCurrent(optionId)
MoonWellCustomizationPreview(optionId, choiceId)
MoonWellCustomizationResetPreview()
MoonWellCustomizationCommitPreview()
MoonWellCustomizationRandomize()
MoonWellCustomizationApplySnapshot(guid, data)
MoonWellCustomizationClear(guid)
```
Требования:
- Lua получает только безопасные операции высокого уровня;
- нативный слой владеет указателями моделей и их жизненным циклом;
- повторное применение snapshot идемпотентно;
- preview не меняет серверное состояние;
- при ошибке модель возвращается к последнему подтверждённому snapshot;
- смена расы или пола пересчитывает допустимость всех параметров;
- отсутствие M2/текстуры отключает только конкретный вариант, а не весь экран.
## 12. Применение внешности
Порядок применения должен быть фиксированным:
1. базовая раса и пол;
2. стандартные параметры 3.3.5a;
3. `CharSections` и текстурные слои;
4. geoset базовой модели;
5. скрытие/конфликты с экипировкой;
6. независимые косметические M2-каналы;
7. обновление материалов и композитной текстуры;
8. окончательное обновление позы и камеры превью.
При смене одного параметра обновляется только затронутый слой, если это безопасно. Полная пересборка модели используется как fallback.
## 13. Интерфейс создания персонажа
### Структура
- категории слева или справа от модели;
- прокручиваемая сетка вариантов;
- текстовое название активной категории;
- состояние выбранного варианта;
- кнопки случайного выбора и сброса;
- возврат к выбору расы/класса без потери валидных параметров;
- подтверждение создания только после серверной валидации;
- плавное приближение камеры к зоне редактирования.
### Категории первой версии
- кожа;
- лицо;
- причёска;
- цвет волос;
- борода/особенность лица;
- шрамы;
- татуировки;
- серьги/пирсинг;
- расовый аксессуар.
Категории скрываются, если для текущей расы/пола нет вариантов.
### Камера
- профиль камеры задаётся по расе, полу и категории;
- отдельно настраиваются distance, вертикальная цель, горизонтальный сдвиг и FOV;
- переход использует easing через `OnUpdate`;
- короткие и средние расы имеют отдельные профили;
- при выходе из кастомизации камера плавно возвращается к полному росту;
- вращение персонажа доступно во всех режимах;
- UI не перекрывает лицо и активный аксессуар на 4:3, 16:9 и 16:10.
### Ограничения UI 3.3.5a
- XML загружается раньше Lua;
- именованные Button и ScrollFrame;
- `relativeTo`, а не `relativeKey`;
- `$parentX`, а не `$parent.X`;
- `SetWidth`/`SetHeight` вместо `SetSize`;
- таймеры и анимации через `OnUpdate`;
- UI-атласы режутся на тайлы не более 512×512.
## 14. Парикмахерская и изменение существующего персонажа
- использовать тот же каталог и preview API;
- получать текущий серверный snapshot при открытии;
- считать стоимость на сервере;
- подтверждать изменение одним атомарным запросом;
- откатывать preview при закрытии или отказе сервера;
- блокировать недоступные варианты;
- поддерживать бесплатные/платные категории отдельными правилами;
- обновлять внешность окружающим только после подтверждения.
## 15. Получение retail-ассетов без установки клиента
### Инструменты
- `wow.export` в online-режиме — поиск и предварительный просмотр;
- Wago Tools/wow-listfile — поиск пути и `FileDataID`;
- TACTSharp/TACTTool — точечная загрузка исходных файлов с CDN Blizzard.
### Манифест ассета
```text
asset_id
source_product
source_build
file_data_id
original_path
target_path
sha256
kind
parent_asset_id
license_note
```
### Пайплайн
1. Выбрать модель и исходный retail build.
2. Зафиксировать корневой `FileDataID`.
3. Выгрузить M2 и определить зависимости.
4. Загрузить `.skin`, `.skel`, `.anim` и текстуры.
5. Проверить хэши и комплектность.
6. Проверить модель в изолированном preview.
7. Проверить downport через `wxl-modern-assets`.
8. Добавить запись в каталог кастомизации.
9. Упаковать только выбранные файлы.
10. Не помещать массовый дамп retail-клиента в Git.
### Автоматические проверки ассета
- поддерживаемая версия M2/MD21;
- наличие всех skin-профилей;
- разрешимые FileDataID и пути текстур;
- наличие внешнего скелета/анимаций;
- attachment points для целевой расы;
- лимит костей на draw после разбиения;
- валидные материалы и blend modes;
- отсутствие коллизий целевых путей;
- размер итогового MPQ и память после загрузки;
- корректное освобождение ресурсов.
## 16. Этапы реализации
### Этап 0. Исследование и фиксация контрактов
- [ ] Зафиксировать ревизии WarcraftXL и всех модулей.
- [ ] Зафиксировать исходный retail build для ассетов.
- [ ] Составить карту существующих хуков персонажа и DBC.
- [ ] Проверить структуру штатных пакетов создания/выбора персонажа.
- [ ] Зафиксировать каталог option/choice ID.
- [ ] Утвердить правила доступности и монетизации.
- [ ] Выбрать первую расу для вертикального среза: человек.
**Результат:** техническая спецификация без конфликтующих точек расширения.
### Этап 1. Интеграция equip-extension
- [ ] Добавить репозиторий как закреплённый submodule.
- [ ] Адаптировать CMake/module layout.
- [ ] Собрать Win32 runtime и x64 host.
- [ ] Устранить конфликт хуков с текущим WarcraftXL.
- [ ] Исправить снятие collection M2.
- [ ] Очистить мусорные поля `ItemDisplayInfo.dbc`.
- [ ] Добавить smoke-тест Model1/Model2 на каждом поддержанном слоте.
- [ ] Добавить диагностическое логирование.
**Результат:** современная объёмная экипировка работает и корректно снимается.
### Этап 2. Независимые косметические каналы
- [ ] Отделить attachment runtime от реальных слотов экипировки.
- [ ] Реализовать реестр косметических каналов.
- [ ] Реализовать add/replace/remove/clear.
- [ ] Реализовать conflict groups и приоритеты.
- [ ] Реализовать скрытие под шлемом.
- [ ] Добавить API preview.
- [ ] Проверить смену карты, формы и пола.
**Результат:** аксессуар можно показать без создания фиктивного предмета.
### Этап 3. Asset pipeline
- [ ] Добавить входной manifest с FileDataID.
- [ ] Реализовать пакетную загрузку через CDN.
- [ ] Добавить локальный кэш по хэшу.
- [ ] Автоматически проверять зависимости.
- [ ] Формировать отчёт о пропущенных файлах.
- [ ] Интегрировать выбранные ассеты в MPQ build.
- [ ] Добавить контроль размера и дубликатов.
**Результат:** воспроизводимая сборка выбранных retail-ассетов без полного клиента.
### Этап 4. Серверное хранение и протокол
- [ ] Добавить миграцию `character_customization`.
- [ ] Реализовать серверный каталог допустимых вариантов.
- [ ] Реализовать create/update validation.
- [ ] Реализовать snapshot и delta.
- [ ] Подключить данные к character enum/select flow.
- [ ] Рассылать состояние в visibility range.
- [ ] Добавить rate limit и журнал ошибок.
- [ ] Добавить fallback для клиента без совместимой версии.
**Результат:** параметры сохраняются и одинаково видны всем клиентам.
### Этап 5. Текстурные слои и geoset
- [ ] Расширить/переопределить `CharSections` данными MoonWell.
- [ ] Реализовать слои шрамов, татуировок и макияжа.
- [ ] Реализовать таблицу geoset базовой модели.
- [ ] Синхронизировать цветовые зависимости.
- [ ] Обеспечить совместимость с надетой бронёй.
- [ ] Добавить полную пересборку как fallback.
**Результат:** работают не только прикрепляемые M2, но и полноценные параметры тела/лица.
### Этап 6. Новый UI создания персонажа
- [ ] Создать адаптивную панель категорий.
- [ ] Создать прокручиваемую сетку choices.
- [ ] Подключить preview API.
- [ ] Добавить race/gender/category camera profiles.
- [ ] Добавить randomize и reset.
- [ ] Добавить обработку недоступного ассета.
- [ ] Добавить серверное подтверждение создания.
- [ ] Проверить 4:3, 16:9, 16:10 и UI scale.
**Результат:** законченный retail-подобный пользовательский сценарий.
### Этап 7. Вертикальный срез — человек
- [ ] Мужская и женская модели.
- [ ] Стандартные категории.
- [ ] Минимум одна новая текстурная категория.
- [ ] Минимум два M2-косметических канала.
- [ ] Сохранение, выбор персонажа и отображение в мире.
- [ ] Видимость другим игрокам.
- [ ] Парикмахерская/изменение существующего персонажа.
- [ ] Полный regression-тест экипировки.
**Результат:** одна раса полностью проходит production-сценарий.
### Этап 8. Масштабирование на все расы WotLK
- [ ] Дворфы.
- [ ] Ночные эльфы.
- [ ] Гномы.
- [ ] Дренеи.
- [ ] Орки.
- [ ] Нежить.
- [ ] Таурены.
- [ ] Тролли.
- [ ] Эльфы крови.
- [ ] Отдельная настройка камер для каждого пола.
- [ ] Матрица конфликтов расовых элементов с экипировкой.
**Результат:** единый уровень качества для всех доступных рас.
### Этап 9. Стабилизация и выпуск
- [ ] Нагрузочный тест большого скопления игроков.
- [ ] Проверка утечек модели/текстур при многократной смене вариантов.
- [ ] Проверка холодного кэша WarcraftXLHost.
- [ ] Проверка отсутствующих/повреждённых ассетов.
- [ ] Версионирование manifest, каталога и протокола.
- [ ] Миграция существующих персонажей.
- [ ] Rollback-пакет.
- [ ] Документация для добавления новой категории/расы.
- [ ] Обновление deploy-скрипта и launcher manifest.
**Результат:** воспроизводимый production-релиз.
## 17. Матрица тестирования
### Персонажи
- каждая раса × каждый пол;
- стандартный режим × предатель;
- новый персонаж × существующий персонаж;
- минимальное × максимальное число выбранных параметров;
- случайная комбинация × преднамеренно конфликтная комбинация.
### Состояния клиента
- создание;
- выбор;
- загрузочный экран;
- вход в мир;
- появление другого игрока;
- телепорт;
- смерть/дух;
- транспорт;
- shapeshift/превращение;
- парикмахерская;
- смена экипировки;
- скрытие шлема/плаща;
- `/reload`;
- переподключение;
- выход и повторный вход.
### Графика
- native renderer и основной D3D9On12 путь;
- разные уровни component texture quality;
- включённые/выключенные SSAA, SSAO и AA;
- дальние LOD;
- тени;
- прозрачные и emissive материалы;
- несколько одинаковых моделей одновременно;
- 4:3, 16:9, 16:10 и разные UI scale.
### Сеть и сервер
- потеря/повтор snapshot;
- snapshot до появления модели и после неё;
- неизвестная версия каталога;
- неизвестный option/choice;
- запрещённый выбор;
- слишком большое сообщение;
- быстрое повторное изменение;
- одновременное изменение и телепорт;
- сохранение при logout/crash;
- удаление и восстановление персонажа.
## 18. Производительность
Целевые ограничения:
- не выполнять разбор каталога каждый кадр;
- не пересобирать всю модель при изменении независимого аксессуара;
- кэшировать разрешённые пути и обработанные modern assets;
- не создавать дубликаты одной и той же дочерней M2-модели;
- освобождать attachment при удалении/скрытии объекта;
- ограничить число одновременных косметических M2 на персонажа;
- иметь настройку отключения дальних косметических attachment;
- измерять время первой загрузки, потребление RAM/VRAM и frame time;
- логирование hot path отключено в release-сборке.
Предварительный бюджет первой версии:
- до 6 независимых M2-каналов на персонажа;
- не более двух моделей на канал;
- отсутствие постоянной работы Lua `OnUpdate`, кроме активной UI-анимации;
- пересборка полной внешности только при snapshot, смене расы/пола или восстановлении после ошибки.
## 19. Безопасность и целостность
- сервер проверяет все ID и комбинации;
- клиент не передаёт пути файлов серверу;
- протокол содержит только числовые option/choice ID;
- сообщения имеют максимальный размер и максимальное число записей;
- сервер нормализует дубликаты option ID;
- неизвестные значения не записываются в БД;
- стоимость изменения рассчитывается сервером;
- unlock condition проверяется сервером;
- клиентский каталог не считается доказательством доступности;
- журналировать несовпадение версии и попытки недопустимых изменений.
## 20. Сборка, доставка и откат
### Сборка
- закреплённые submodule revisions;
- asset manifest с SHA-256;
- генерация клиентского и серверного каталога из одного источника;
- сборка WarcraftXL Win32 и Host x64;
- сборка MPQ;
- автоматическая проверка оригинального `Wow.exe`;
- формирование launcher manifest.
### Версионирование
- `customization_protocol_version`;
- `customization_catalog_version`;
- `customization_asset_version`;
- совместимая версия WarcraftXL runtime.
### Откат
- сервер умеет не отправлять расширенные сообщения;
- клиент при отсутствии данных показывает стандартную внешность;
- отключение модуля не блокирует вход персонажа;
- миграция БД не удаляет стандартные параметры;
- предыдущие MPQ/runtime хранятся как отдельный release artifact;
- неизвестные сохранённые choices не удаляются автоматически без миграции.
## 21. Оценка трудоёмкости
Для одного разработчика при наличии подготовленных ассетов:
| Работа | Оценка |
|---|---:|
| Адаптация и исправление equip-extension | 4–7 дней |
| Независимые косметические каналы | 4–7 дней |
| Asset pipeline | 3–5 дней |
| Серверное хранение и протокол | 4–7 дней |
| Текстурные слои и geoset | 5–10 дней |
| Новый UI создания/изменения | 5–8 дней |
| Полный вертикальный срез одной расы | 5–10 дней |
| Каждая следующая раса | 2–5 дней |
| Стабилизация и regression | 7–12 дней |
Ориентиры:
- технический прототип одной расы: 1–2 недели;
- production-вертикаль людей: 3–5 недель;
- все десять рас WotLK: 2–3 месяца;
- насыщенность категориями, близкая к retail: 3–6 месяцев.
Главный переменный фактор — подготовка и проверка ассетов, а не серверная схема.
## 22. Рекомендуемый первый milestone
Вертикальный срез для людей обоих полов:
- штатные пять категорий;
- одна текстурная категория: шрамы;
- два независимых M2-канала: серьги и аксессуар для волос;
- серверное сохранение;
- snapshot на экране выбора;
- видимость другим игрокам;
- изменение через парикмахерскую;
- камера по категориям;
- корректная работа со шлемом;
- один полный набор automated/manual acceptance checks.
Этот milestone должен подтвердить всю архитектуру до масштабирования на остальные расы.
## 23. Критерии готовности production-версии
- [ ] `Wow.exe` идентичен оригиналу по SHA-256.
- [ ] Все расширения поставляются через WarcraftXL и MPQ.
- [ ] Клиент и сервер используют одинаковую версию каталога.
- [ ] Внешность сохраняется после выхода и перезапуска клиента.
- [ ] Экран создания, экран выбора и модель в мире совпадают.
- [ ] Другие игроки видят подтверждённую сервером внешность.
- [ ] Недоступные варианты невозможно применить изменённым клиентом.
- [ ] Смена/снятие варианта не оставляет collection M2.
- [ ] Нет placeholder-моделей из мусорных строк `ItemDisplayInfo.dbc`.
- [ ] Нет заметных утечек памяти при многократном preview.
- [ ] Отсутствующий ассет не приводит к падению клиента.
- [ ] Все расы и оба пола проходят тестовую матрицу.
- [ ] Камера корректно кадрирует низких, средних и высоких персонажей.
- [ ] UI работает на 4:3, 16:9, 16:10 и разных UI scale.
- [ ] Реализован и проверен rollback.
- [ ] Описан процесс добавления новой категории, choice и ассета.
## 24. Открытые решения перед началом реализации
- точная ревизия `wxl-equip-extension` для закрепления;
- список категорий первого релиза;
- исходный retail build для моделей;
- набор моделей для вертикального среза людей;
- формат единого каталога: JSON, CSV или генератор из SQL/YAML;
- транспорт custom packets между WarcraftXL и AzerothCore;
- способ передачи snapshots на экране выбора;
- политика скрытия аксессуаров под шлемом;
- правила доступности и разблокировки вариантов;
- использование парикмахерской или отдельного UI изменения внешности;
- лимит косметических M2 на одного персонажа;
- политика хранения выбранных Blizzard-ассетов и доступа к ним;
- минимальная поддерживаемая версия launcher/runtime.
+102
View File
@@ -0,0 +1,102 @@
# Автоматический импорт mount и pet
Список современных моделей находится в `custom-creatures.json`. В нём есть две независимые
секции:
```json
{
"mounts": [
"assets/creatures/my_mount"
],
"pets": [
"assets/creatures/my_pet"
]
}
```
Папка должна быть результатом **Raw M2 export** из wow.export и содержать:
- один основной `.m2`;
- `*.manifest.json`;
- все упомянутые в manifest текстуры, skin, skeleton, bone и animation-файлы.
Разрешены абсолютные пути, например `C:/Users/sindo/wow.export/creature/catslime`, но для
воспроизводимой сборки папку лучше скопировать внутрь репозитория и указать относительный путь.
## Что делает сборщик
При обычном запуске `tool.exe`, `run.ps1` или `deploy.ps1` сборщик:
1. читает manifest каждой модели и размещает retail M2 со всеми зависимостями в loose-каталоге
`Data/Patch-WXL.MPQ`, создавая рядом `.mdx`-алиас для загрузчика WoW 3.3.5, чтобы WarcraftXL всегда
преобразовывал MD21 до загрузки клиентом;
2. генерирует `WXLFileData.csv` для WarcraftXL;
3. дописывает модели в `CreatureModelData.dbc` и `CreatureDisplayInfo.dbc`;
4. сохраняет стабильные ID в `custom-creatures.lock.json`;
5. для `pets` создаёт companion summon spell и строку skill line `778`;
6. кладёт серверные DBC, SQL и таблицу ID в `build/server-dbc`.
Полный `Spell.dbc` в патч не копируется. WarcraftXL читает компактный
`WXLSpellOverrides.tsv` и при открытии DBC добавляет новые spell-записи к штатному файлу клиента.
Перестановка моделей в списке не меняет уже выданные ID. Lock-файл необходимо хранить в Git.
## Дополнительные параметры
Если значений по умолчанию недостаточно, вместо строки используется объект:
```json
{
"folder": "assets/creatures/my_mount",
"key": "my_mount",
"name": "My Mount",
"name_ru": "Мой транспорт",
"race_mask": 1101,
"icon": "my_icon.jpg",
"archive": "Creature/MyMount",
"model_file": "my_mount.m2",
"template_display_id": 2404,
"display_scale": 1.0,
"model_scale": 1.0,
"collision_width": 0.6111,
"collision_height": 2.031,
"mount_height": 1.8657,
"flags": 0
}
```
Для `mounts` по умолчанию клонируется верховая лошадь с display ID `2404`, для `pets` —
компаньон-кот с display ID `5448`. Шаблон задаёт физические размеры, звук и прочие поля DBC;
параметры объекта позволяют их переопределить.
Для `pets` поле `race_mask` ограничивает строку коллекции набором рас (`1101` — Альянс,
`690` — Орда, `0` или отсутствие поля — все расы). Поле `icon` указывает JPG, PNG или TGA рядом
с моделью либо путь от корня проекта. Сборщик конвертирует изображение в BLP2, выдаёт стабильный
`spellIconID`, добавляет запись в `SpellIcon.dbc` через MoonWell provider и назначает её summon-spell.
## Конвертация иконок
Встроенная команда CLI преобразует JPG, PNG или TGA в совместимый с клиентом BLP2:
```powershell
tool.exe icon "путь/к/icon.jpg" ["путь/к/icon.blp"]
```
Если выходной путь не указан, `.blp` создаётся рядом с исходным файлом. Конвертер автоматически
приводит изображение к ближайшему квадратному размеру степени двойки (не более 512×512), кодирует
его в DXT1 и создаёт mipmap-уровни. Например, иконка 56×56 будет преобразована в 64×64.
## Установка на сервер
Для каждого элемента `pets` сборщик автоматически выдаёт `creatureEntry`, `summonSpellID` и
`skillLineAbilityID`. Значения находятся в `build/server-dbc/custom-creatures.csv`.
1. Скопировать `CreatureModelData.dbc` и `CreatureDisplayInfo.dbc` из `build/server-dbc` в
серверный каталог `data/dbc`.
2. Применить `build/server-dbc/custom-creatures.sql` к `acore_world`.
3. Перезапустить worldserver: DBC и таблицы `spell_dbc` загружаются только при старте.
4. Для проверки изучить выданный spell командой `.learn <summonSpellID>`.
SQL создаёт `creature_template`, `creature_model_info`, `creature_template_model`, companion spell и строку skill line.
Генерация полноценного mount spell для секции `mounts` остаётся отдельным этапом: mount требует
другой spell-шаблон с аурами скорости и `SPELL_AURA_MOUNTED`.
Binary file not shown.
Binary file not shown.
+40
View File
@@ -0,0 +1,40 @@
{
"base_dbc_dir": "assets/dbc/3.3.5a",
"model_data_id_start": 50000,
"display_info_id_start": 50000,
"creature_entry_id_start": 5000000,
"summon_spell_id_start": 110000,
"skill_line_ability_id_start": 30000,
"spell_icon_id_start": 50000,
"defaults": {
"mount_template_display_id": 2404,
"pet_template_display_id": 5448
},
"mounts": [],
"pets": [
{
"folder": "src/Data/patch-Z/Creature/Catslime",
"name": "Cat Slime",
"name_ru": "Котослизень",
"display_scale": 0.3
},
{
"folder": "src/Data/patch-Z/Creature/MoonkinHatchling_Alliance",
"key": "moonkin_hatchling_alliance",
"race_mask": 1101,
"icon": "inv_misc_petmoonkinne.jpg",
"name": "Moonkin Hatchling",
"name_ru": "Лунный совушек",
"display_scale": 0.3
},
{
"folder": "src/Data/patch-Z/Creature/MoonkinHatchling_Horde",
"key": "moonkin_hatchling_horde",
"race_mask": 690,
"icon": "inv_misc_petmoonkinta.jpg",
"name": "Moonkin Hatchling",
"name_ru": "Лунный совушек",
"display_scale": 0.3
}
]
}
+27
View File
@@ -0,0 +1,27 @@
{
"models": {
"catslime": {
"model_data_id": 50000,
"display_info_id": 50000,
"creature_entry": 5000000,
"summon_spell_id": 110000,
"skill_line_ability_id": 30000
},
"moonkin_hatchling_alliance": {
"model_data_id": 50001,
"display_info_id": 50001,
"creature_entry": 5000001,
"summon_spell_id": 110001,
"skill_line_ability_id": 30001,
"spell_icon_id": 50000
},
"moonkin_hatchling_horde": {
"model_data_id": 50002,
"display_info_id": 50002,
"creature_entry": 5000002,
"summon_spell_id": 110002,
"skill_line_ability_id": 30002,
"spell_icon_id": 50001
}
}
}
+4 -3
View File
@@ -76,11 +76,12 @@ New-Item -ItemType Directory -Path $PackagePath -Force | Out-Null
if (-not $SkipDataBuild) {
Write-Host '[2/5] Building MPQ patches...'
$tool = Join-Path $repoRoot 'tool\target\release\tool.exe'
if (-not (Test-Path -LiteralPath $tool -PathType Leaf)) {
$cargo = Get-Command cargo -ErrorAction SilentlyContinue
if (-not $cargo) { throw 'Cargo was not found and the MPQ builder is not compiled.' }
$cargo = Get-Command cargo -ErrorAction SilentlyContinue
if ($cargo) {
& $cargo.Source build --release --manifest-path (Join-Path $repoRoot 'tool\Cargo.toml')
if ($LASTEXITCODE -ne 0) { throw 'MPQ builder compilation failed.' }
} elseif (-not (Test-Path -LiteralPath $tool -PathType Leaf)) {
throw 'Cargo was not found and the MPQ builder is not compiled.'
}
& $tool (Join-Path $repoRoot 'src') $PackagePath
@@ -0,0 +1,354 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Appends compact MoonWell spell overrides to the stock 3.3.5 DBCs at open time.
#include "Host.hpp"
#include "core/Logger.hpp"
#include "mpq/MpqStore.hpp"
#include <cctype>
#include <charconv>
#include <cstdint>
#include <cstring>
#include <mutex>
#include <string>
#include <string_view>
#include <system_error>
#include <vector>
namespace moonwell::host
{
namespace
{
constexpr std::string_view kOverridesPath = "WXLSpellOverrides.tsv";
constexpr std::string_view kSpellPath = "DBFilesClient\\Spell.dbc";
constexpr std::string_view kSkillPath = "DBFilesClient\\SkillLineAbility.dbc";
constexpr std::string_view kSpellIconPath = "DBFilesClient\\SpellIcon.dbc";
constexpr uint32_t kSpellFields = 234;
constexpr uint32_t kSpellRecordSize = kSpellFields * 4;
constexpr uint32_t kSkillFields = 14;
constexpr uint32_t kSkillRecordSize = kSkillFields * 4;
constexpr uint32_t kSpellIconFields = 2;
constexpr uint32_t kSpellIconRecordSize = kSpellIconFields * 4;
constexpr char8_t kDescriptionPrefixUtf8[] =
u8"\u041F\u0440\u0438\u0437\u044B\u0432\u0430\u0435\u0442 \u0438\u043B\u0438 \u043E\u0442\u043F\u0443\u0441\u043A\u0430\u0435\u0442 \u0441\u043F\u0443\u0442\u043D\u0438\u043A\u0430: ";
struct Override
{
uint32_t spellId = 0;
uint32_t templateSpellId = 0;
uint32_t creatureEntry = 0;
uint32_t skillLineAbilityId = 0;
uint32_t raceMask = 0;
uint32_t spellIconId = 0;
std::string iconPath;
std::string name;
};
std::once_flag g_loadOnce;
bool g_ready = false;
std::vector<uint8_t> g_spellDbc;
std::vector<uint8_t> g_skillDbc;
std::vector<uint8_t> g_spellIconDbc;
uint32_t ReadU32(const uint8_t* data)
{
uint32_t value = 0;
std::memcpy(&value, data, sizeof(value));
return value;
}
void WriteU32(uint8_t* data, uint32_t value)
{
std::memcpy(data, &value, sizeof(value));
}
std::string_view Trim(std::string_view value)
{
while (!value.empty() && std::isspace(static_cast<unsigned char>(value.front())))
value.remove_prefix(1);
while (!value.empty() && std::isspace(static_cast<unsigned char>(value.back())))
value.remove_suffix(1);
return value;
}
bool ParseU32(std::string_view text, uint32_t& value)
{
text = Trim(text);
const auto parsed = std::from_chars(text.data(), text.data() + text.size(), value);
return parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size();
}
bool SamePath(std::string_view left, std::string_view right)
{
if (left.size() != right.size()) return false;
for (size_t i = 0; i < left.size(); ++i)
{
const unsigned char a = static_cast<unsigned char>(left[i] == '/' ? '\\' : left[i]);
const unsigned char b = static_cast<unsigned char>(right[i] == '/' ? '\\' : right[i]);
if (std::tolower(a) != std::tolower(b)) return false;
}
return true;
}
bool ParseOverrides(const std::vector<uint8_t>& bytes, std::vector<Override>& out)
{
const std::string_view text(reinterpret_cast<const char*>(bytes.data()), bytes.size());
size_t lineStart = 0;
while (lineStart < text.size())
{
size_t lineEnd = text.find('\n', lineStart);
if (lineEnd == std::string_view::npos) lineEnd = text.size();
std::string_view line = Trim(text.substr(lineStart, lineEnd - lineStart));
lineStart = lineEnd + 1;
if (line.empty() || line.front() == '#') continue;
std::vector<std::string_view> fields;
size_t fieldStart = 0;
while (true)
{
const size_t tab = line.find('\t', fieldStart);
if (tab == std::string_view::npos)
{
fields.push_back(Trim(line.substr(fieldStart)));
break;
}
fields.push_back(Trim(line.substr(fieldStart, tab - fieldStart)));
fieldStart = tab + 1;
}
Override item;
if (fields.size() == 5)
{
if (!ParseU32(fields[0], item.spellId) ||
!ParseU32(fields[1], item.templateSpellId) ||
!ParseU32(fields[2], item.creatureEntry) ||
!ParseU32(fields[3], item.skillLineAbilityId) || fields[4].empty())
return false;
item.name.assign(fields[4]);
}
else if (fields.size() == 8)
{
if (!ParseU32(fields[0], item.spellId) ||
!ParseU32(fields[1], item.templateSpellId) ||
!ParseU32(fields[2], item.creatureEntry) ||
!ParseU32(fields[3], item.skillLineAbilityId) ||
!ParseU32(fields[4], item.raceMask) ||
!ParseU32(fields[5], item.spellIconId) || fields[7].empty())
return false;
if (item.spellIconId != 0)
{
if (fields[6].empty() || fields[6] == "-") return false;
item.iconPath.assign(fields[6]);
}
item.name.assign(fields[7]);
}
else
{
return false;
}
out.push_back(std::move(item));
}
return !out.empty();
}
bool ValidateWdbc(const std::vector<uint8_t>& bytes, uint32_t fields, uint32_t recordSize,
uint32_t& count, uint32_t& stringSize, size_t& recordsEnd)
{
if (bytes.size() < 20 || std::memcmp(bytes.data(), "WDBC", 4) != 0) return false;
count = ReadU32(bytes.data() + 4);
if (ReadU32(bytes.data() + 8) != fields || ReadU32(bytes.data() + 12) != recordSize)
return false;
stringSize = ReadU32(bytes.data() + 16);
recordsEnd = 20ull + static_cast<size_t>(count) * recordSize;
return recordsEnd <= bytes.size() && recordsEnd + stringSize == bytes.size();
}
const uint8_t* FindSpell(const std::vector<uint8_t>& bytes, uint32_t count, uint32_t id)
{
for (uint32_t i = 0; i < count; ++i)
{
const uint8_t* row = bytes.data() + 20 + static_cast<size_t>(i) * kSpellRecordSize;
if (ReadU32(row) == id) return row;
}
return nullptr;
}
const uint8_t* FindSkillRow(const std::vector<uint8_t>& bytes, uint32_t count, uint32_t spellId)
{
for (uint32_t i = 0; i < count; ++i)
{
const uint8_t* row = bytes.data() + 20 + static_cast<size_t>(i) * kSkillRecordSize;
if (ReadU32(row + 4) == 778 && ReadU32(row + 8) == spellId) return row;
}
return nullptr;
}
bool BuildSpellDbc(const std::vector<uint8_t>& base, const std::vector<Override>& overrides,
std::vector<uint8_t>& out)
{
uint32_t count = 0, stringSize = 0;
size_t recordsEnd = 0;
if (!ValidateWdbc(base, kSpellFields, kSpellRecordSize, count, stringSize, recordsEnd))
return false;
std::vector<uint8_t> records;
records.reserve(overrides.size() * kSpellRecordSize);
std::vector<uint8_t> strings;
for (const Override& item : overrides)
{
const uint8_t* source = FindSpell(base, count, item.templateSpellId);
if (!source) return false;
const size_t recordStart = records.size();
records.insert(records.end(), source, source + kSpellRecordSize);
uint8_t* row = records.data() + recordStart;
WriteU32(row, item.spellId);
WriteU32(row + 110 * 4, item.creatureEntry);
if (item.spellIconId != 0) WriteU32(row + 133 * 4, item.spellIconId);
const uint32_t nameOffset = stringSize + static_cast<uint32_t>(strings.size());
strings.insert(strings.end(), item.name.begin(), item.name.end());
strings.push_back(0);
const std::string description =
std::string(reinterpret_cast<const char*>(kDescriptionPrefixUtf8)) + item.name + ".";
const uint32_t descriptionOffset = stringSize + static_cast<uint32_t>(strings.size());
strings.insert(strings.end(), description.begin(), description.end());
strings.push_back(0);
for (uint32_t field = 136; field <= 151; ++field) WriteU32(row + field * 4, nameOffset);
for (uint32_t field = 170; field <= 185; ++field) WriteU32(row + field * 4, descriptionOffset);
for (uint32_t field = 187; field <= 202; ++field) WriteU32(row + field * 4, 0);
}
out.reserve(base.size() + records.size() + strings.size());
out.insert(out.end(), base.begin(), base.begin() + 20);
WriteU32(out.data() + 4, count + static_cast<uint32_t>(overrides.size()));
WriteU32(out.data() + 16, stringSize + static_cast<uint32_t>(strings.size()));
out.insert(out.end(), base.begin() + 20, base.begin() + recordsEnd);
out.insert(out.end(), records.begin(), records.end());
out.insert(out.end(), base.begin() + recordsEnd, base.end());
out.insert(out.end(), strings.begin(), strings.end());
return true;
}
bool BuildSkillDbc(const std::vector<uint8_t>& base, const std::vector<Override>& overrides,
std::vector<uint8_t>& out)
{
uint32_t count = 0, stringSize = 0;
size_t recordsEnd = 0;
if (!ValidateWdbc(base, kSkillFields, kSkillRecordSize, count, stringSize, recordsEnd))
return false;
std::vector<uint8_t> records;
records.reserve(overrides.size() * kSkillRecordSize);
for (const Override& item : overrides)
{
const uint8_t* source = FindSkillRow(base, count, item.templateSpellId);
if (!source) return false;
const size_t recordStart = records.size();
records.insert(records.end(), source, source + kSkillRecordSize);
uint8_t* row = records.data() + recordStart;
WriteU32(row, item.skillLineAbilityId);
WriteU32(row + 8, item.spellId);
WriteU32(row + 12, item.raceMask);
}
out.reserve(base.size() + records.size());
out.insert(out.end(), base.begin(), base.begin() + 20);
WriteU32(out.data() + 4, count + static_cast<uint32_t>(overrides.size()));
out.insert(out.end(), base.begin() + 20, base.begin() + recordsEnd);
out.insert(out.end(), records.begin(), records.end());
out.insert(out.end(), base.begin() + recordsEnd, base.end());
return true;
}
bool BuildSpellIconDbc(const std::vector<uint8_t>& base, const std::vector<Override>& overrides,
std::vector<uint8_t>& out)
{
uint32_t count = 0, stringSize = 0;
size_t recordsEnd = 0;
if (!ValidateWdbc(base, kSpellIconFields, kSpellIconRecordSize, count, stringSize, recordsEnd))
return false;
std::vector<uint8_t> records;
std::vector<uint8_t> strings;
for (const Override& item : overrides)
{
if (item.spellIconId == 0) continue;
for (uint32_t i = 0; i < count; ++i)
{
const uint8_t* row = base.data() + 20 + static_cast<size_t>(i) * kSpellIconRecordSize;
if (ReadU32(row) == item.spellIconId) return false;
}
for (size_t i = 0; i < records.size(); i += kSpellIconRecordSize)
if (ReadU32(records.data() + i) == item.spellIconId) return false;
const uint32_t pathOffset = stringSize + static_cast<uint32_t>(strings.size());
const size_t recordStart = records.size();
records.resize(recordStart + kSpellIconRecordSize);
WriteU32(records.data() + recordStart, item.spellIconId);
WriteU32(records.data() + recordStart + 4, pathOffset);
strings.insert(strings.end(), item.iconPath.begin(), item.iconPath.end());
strings.push_back(0);
}
if (records.empty()) return true;
out.reserve(base.size() + records.size() + strings.size());
out.insert(out.end(), base.begin(), base.begin() + 20);
WriteU32(out.data() + 4, count + static_cast<uint32_t>(records.size() / kSpellIconRecordSize));
WriteU32(out.data() + 16, stringSize + static_cast<uint32_t>(strings.size()));
out.insert(out.end(), base.begin() + 20, base.begin() + recordsEnd);
out.insert(out.end(), records.begin(), records.end());
out.insert(out.end(), base.begin() + recordsEnd, base.end());
out.insert(out.end(), strings.begin(), strings.end());
return true;
}
void Load()
{
const std::string root = wxl::host::ClientRoot();
wxl::host::mpq::MpqStore store;
std::vector<uint8_t> overrideBytes, spellBase, skillBase, spellIconBase;
if (root.empty() || !store.Mount(root) || !store.ReadAll(kOverridesPath, overrideBytes))
return;
std::vector<Override> overrides;
if (!ParseOverrides(overrideBytes, overrides) ||
!store.ReadAll(kSpellPath, spellBase) || !store.ReadAll(kSkillPath, skillBase) ||
!store.ReadAll(kSpellIconPath, spellIconBase) ||
!BuildSpellDbc(spellBase, overrides, g_spellDbc) ||
!BuildSkillDbc(skillBase, overrides, g_skillDbc) ||
!BuildSpellIconDbc(spellIconBase, overrides, g_spellIconDbc))
{
WLOG_ERROR("moonwell-spells: failed to build custom companion DBCs");
return;
}
g_ready = true;
WLOG_INFO("moonwell-spells: appended %zu companion spell(s)", overrides.size());
}
bool Provide(std::string_view name, std::vector<uint8_t>& out)
{
if (!SamePath(name, kSpellPath) && !SamePath(name, kSkillPath) &&
!SamePath(name, kSpellIconPath))
return false;
std::call_once(g_loadOnce, &Load);
if (!g_ready) return false;
if (SamePath(name, kSpellPath))
out = g_spellDbc;
else if (SamePath(name, kSkillPath))
out = g_skillDbc;
else
{
if (g_spellIconDbc.empty()) return false;
out = g_spellIconDbc;
}
return true;
}
struct Registrar
{
Registrar() { wxl::host::RegisterProvider("moonwell-companion-spells", &Provide); }
};
Registrar g_registrar;
}
}
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// FileDataID resolver for selected retail assets shipped in MoonWell patches.
#include "Host.hpp"
#include "core/Logger.hpp"
#include "mpq/MpqStore.hpp"
#include <charconv>
#include <cstdint>
#include <mutex>
#include <string>
#include <string_view>
#include <system_error>
#include <unordered_map>
#include <vector>
namespace moonwell::host
{
namespace
{
constexpr std::string_view kFileDataMap = "WXLFileData.csv";
std::once_flag g_loadOnce;
std::unordered_map<uint32_t, std::string> g_paths;
std::string_view Trim(std::string_view value)
{
while (!value.empty() && (value.front() == ' ' || value.front() == '\t' ||
value.front() == '\r' || value.front() == '\n'))
value.remove_prefix(1);
while (!value.empty() && (value.back() == ' ' || value.back() == '\t' ||
value.back() == '\r' || value.back() == '\n'))
value.remove_suffix(1);
return value;
}
void Load()
{
const std::string root = wxl::host::ClientRoot();
wxl::host::mpq::MpqStore store;
std::vector<uint8_t> bytes;
if (root.empty() || !store.Mount(root) || !store.ReadAll(kFileDataMap, bytes))
{
WLOG_WARN("moonwell: FileDataID map '%.*s' was not found",
int(kFileDataMap.size()), kFileDataMap.data());
return;
}
const std::string_view text(reinterpret_cast<const char*>(bytes.data()), bytes.size());
size_t lineStart = 0;
while (lineStart < text.size())
{
size_t lineEnd = text.find('\n', lineStart);
if (lineEnd == std::string_view::npos) lineEnd = text.size();
std::string_view line = Trim(text.substr(lineStart, lineEnd - lineStart));
lineStart = lineEnd + 1;
if (line.empty() || line.front() == '#') continue;
const size_t comma = line.find(',');
if (comma == std::string_view::npos) continue;
const std::string_view idText = Trim(line.substr(0, comma));
std::string_view pathText = Trim(line.substr(comma + 1));
uint32_t fileDataId = 0;
const auto parsed = std::from_chars(idText.data(), idText.data() + idText.size(), fileDataId);
if (parsed.ec != std::errc{} || parsed.ptr != idText.data() + idText.size() ||
fileDataId == 0 || pathText.empty())
continue;
std::string path(pathText);
for (char& c : path) if (c == '/') c = '\\';
g_paths[fileDataId] = std::move(path);
}
WLOG_INFO("moonwell: loaded %zu FileDataID path(s) from %.*s",
g_paths.size(), int(kFileDataMap.size()), kFileDataMap.data());
}
bool Resolve(uint32_t fileDataId, std::string& outPath)
{
std::call_once(g_loadOnce, &Load);
const auto found = g_paths.find(fileDataId);
if (found == g_paths.end()) return false;
outPath = found->second;
return true;
}
struct Registrar
{
Registrar() { wxl::host::RegisterResolver("moonwell-filedata", &Resolve); }
};
Registrar g_registrar;
}
}
+85 -1
View File
@@ -6,6 +6,7 @@
// boot phase, before GlueXML is loaded.
#include "core/Logger.hpp"
#include "core/Hook.hpp"
#include "core/Mem.hpp"
#include "runtime/LuaBindings.hpp"
#include "runtime/ModuleInstall.hpp"
@@ -28,6 +29,88 @@ namespace moonwell
using GxSetProjectionFn = wxl::offsets::engine::gx::GxSetProjectionFn;
GxSetProjectionFn g_nextSetProjection = nullptr;
// FrameXML's stock SetCreature only accepts a creature entry and waits
// for its client cache record. Encounter Journal already has the exact
// CreatureDisplayInfo ID, so accept it as an optional second argument.
constexpr uintptr_t kSetCreature = 0x00597960;
constexpr uintptr_t kGetCurrentModelFrame = 0x004A81B0;
constexpr uintptr_t kModelFrameTypeToken = 0x00C0E4D4;
constexpr uintptr_t kApplyCreatureCacheRecord = 0x00597700;
using SetCreatureFn = int(__cdecl*)(void* state);
using ApplyCreatureCacheRecordFn = void(__fastcall*)(void* frame, void* edx,
const void* cacheRecord);
SetCreatureFn g_nextSetCreature = nullptr;
alignas(4) std::array<uint32_t, 10> g_encounterJournalCreatureRecord{};
void* GetCurrentModelFrame(void* state, uint32_t typeToken)
{
// FrameScript_GetObject takes the type token on the stack, but the
// 3.3.5 client also expects lua_State in ESI (an internal calling
// convention not expressible with a regular C function pointer).
void* frame = nullptr;
__asm
{
mov esi, state
push typeToken
mov eax, kGetCurrentModelFrame
call eax
add esp, 4
mov frame, eax
}
return frame;
}
int __cdecl SetCreatureDisplayInfoHook(void* state)
{
const int result = g_nextSetCreature ? g_nextSetCreature(state) : 0;
if (!state || !wxl::runtime::lua::IsNumber(state, 3))
return result;
const double requestedDisplayInfo = wxl::runtime::lua::ToNumber(state, 3);
if (requestedDisplayInfo <= 0.0 || requestedDisplayInfo > 4294967295.0)
return result;
const auto displayInfo = static_cast<uint32_t>(requestedDisplayInfo);
__try
{
const uint32_t typeToken = *reinterpret_cast<const uint32_t*>(kModelFrameTypeToken);
if (!typeToken)
return result;
void* frame = GetCurrentModelFrame(state, typeToken);
if (!frame)
return result;
// Both the initial loader and the later character-appearance
// pass read displayInfo at +0x24. Keep this record alive and
// attach it to the frame: character models use it asynchronously
// to resolve CreatureDisplayInfoExtra, baked skin and equipment.
g_encounterJournalCreatureRecord.fill(0);
g_encounterJournalCreatureRecord[9] = displayInfo;
*reinterpret_cast<const void**>(static_cast<uint8_t*>(frame) + 0x378) =
g_encounterJournalCreatureRecord.data();
reinterpret_cast<ApplyCreatureCacheRecordFn>(kApplyCreatureCacheRecord)(
frame, nullptr, g_encounterJournalCreatureRecord.data());
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
WLOG_ERROR("moonwell: SetCreature displayInfo bridge failed for %u", displayInfo);
}
return result;
}
void InstallEncounterJournalModelPreview()
{
if (!wxl::core::hook::Install("MoonWellSetCreatureDisplayInfo", kSetCreature,
&SetCreatureDisplayInfoHook, &g_nextSetCreature))
{
WLOG_ERROR("moonwell: encounter journal model hook installation failed");
return;
}
WLOG_INFO("moonwell: encounter journal displayInfo model bridge installed");
}
struct CameraTransition
{
float zoom = 1.0f;
@@ -60,7 +143,6 @@ namespace moonwell
return;
}
// Smoothstep avoids a visible jerk at both ends of the move.
t = t * t * (3.0f - 2.0f * t);
camera.zoom = camera.startZoom + (camera.targetZoom - camera.startZoom) * t;
camera.verticalOffset = camera.startVerticalOffset
@@ -338,6 +420,8 @@ namespace moonwell
wxl::runtime::modules::RegisterBoot("moonwell", &InstallBoot);
wxl::runtime::modules::Register(
"moonwell-character-create-camera", &InstallCharacterCreateCamera);
wxl::runtime::modules::Register(
"moonwell-encounter-journal-models", &InstallEncounterJournalModelPreview);
wxl::runtime::modules::Register("moonwell-log-flush", &InstallRuntimeLogFlush);
}
} g_registration;
+106 -5
View File
@@ -1,3 +1,9 @@
param(
[Parameter(Mandatory=$false)]
[ValidateSet("production", "ptr", "local")]
[string]$Env = "local"
)
# Stop on errors
$ErrorActionPreference = "Stop"
@@ -31,6 +37,42 @@ if (-not $env:WOW_HOME) {
$WOW_HOME = $env:WOW_HOME
Write-Host "WOW_HOME = $WOW_HOME"
function Test-ExistingFileWriteAccess {
param([Parameter(Mandatory=$true)][string]$Path)
try {
$stream = [System.IO.File]::Open(
$Path,
[System.IO.FileMode]::Open,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::ReadWrite
)
$stream.Close()
return $true
} catch [System.UnauthorizedAccessException] {
return $false
} catch {
# Sharing violations are handled later by Stop-WowRuntime. They do not
# indicate that the whole script needs an elevated token.
return $true
}
}
$installedWarcraftXL = Join-Path $WOW_HOME "WarcraftXL.dll"
$isAdministrator = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator
)
if ((Test-Path -LiteralPath $installedWarcraftXL -PathType Leaf) -and
-not (Test-ExistingFileWriteAccess -Path $installedWarcraftXL) -and
-not $isAdministrator) {
Write-Host "WarcraftXL.dll requires administrator access. Requesting elevation..."
$elevatedProcess = Start-Process -FilePath "powershell.exe" -Verb RunAs -PassThru -Wait `
-WorkingDirectory $ROOT `
-ArgumentList "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$($MyInvocation.MyCommand.Path)`"", "-Env", $Env
exit $elevatedProcess.ExitCode
}
# --- Paths
$SRC_DIR = Join-Path $ROOT "src"
$DIST_DIR = Join-Path $ROOT "dist"
@@ -38,11 +80,49 @@ $TOOL = Join-Path $ROOT "tool\target\release\tool.exe"
$RELOAD_SCRIPT = Join-Path $ROOT "reload_wow.bat"
$WXL_BUILD_SCRIPT = Join-Path $ROOT "build-warcraftxl.ps1"
# --- Ensure tool exists
if (!(Test-Path $TOOL)) {
Write-Host "Building Rust tool..."
Push-Location (Join-Path $ROOT "tool")
cargo build --release
function Stop-WowRuntime {
param([Parameter(Mandatory=$true)][string]$ClientPath)
$clientRoot = [System.IO.Path]::GetFullPath($ClientPath).TrimEnd('\') + '\'
$runtimeProcesses = @(Get-Process -Name "Wow", "WarcraftXLHost" -ErrorAction SilentlyContinue | Where-Object {
try {
$_.Path -and $_.Path.StartsWith($clientRoot, [System.StringComparison]::OrdinalIgnoreCase)
} catch {
$false
}
})
if (-not $runtimeProcesses.Count) {
return
}
Write-Host "Stopping the running WoW client and WarcraftXL host..."
$runtimeProcesses | Stop-Process -Force
try {
$runtimeProcesses | Wait-Process -Timeout 10 -ErrorAction Stop
} catch {
$stillRunning = @($runtimeProcesses | Where-Object { -not $_.HasExited })
if ($stillRunning.Count) {
$processNames = ($stillRunning | ForEach-Object { "$($_.ProcessName) (PID $($_.Id))" }) -join ", "
throw "Could not stop processes that lock WarcraftXL files: $processNames"
}
}
}
# --- Always rebuild the tool so generated DBC/SQL formats match the current sources.
$cargo = Get-Command cargo -ErrorAction SilentlyContinue
if (-not $cargo) {
throw "Cargo was not found; the MPQ/DBC builder cannot be refreshed."
}
Write-Host "Building Rust tool..."
Push-Location (Join-Path $ROOT "tool")
try {
& $cargo.Source build --release
if ($LASTEXITCODE -ne 0) {
throw "Rust tool build failed with exit code $LASTEXITCODE."
}
} finally {
Pop-Location
}
@@ -62,6 +142,12 @@ if (!(Test-Path (Join-Path $DIST_DIR "Data"))) {
# --- Build/package/deploy WarcraftXL before the dist sync. The packaged files
# are uploaded by upload_to_s3.py and consumed by the launcher manifest.
Stop-WowRuntime -ClientPath $WOW_HOME
$creatureCache = Join-Path $WOW_HOME "Cache\WDB\ruRU\creaturecache.wdb"
if (Test-Path -LiteralPath $creatureCache -PathType Leaf) {
Remove-Item -LiteralPath $creatureCache -Force
Write-Host "Cleared stale creature cache: $creatureCache"
}
Write-Host "Building and packaging WarcraftXL..."
& $WXL_BUILD_SCRIPT -Configuration Release -ClientPath $WOW_HOME `
-PackagePath $DIST_DIR -Deploy
@@ -81,6 +167,21 @@ if ($LASTEXITCODE -ge 8) {
Write-Host "MPQ archives deployed to $WOW_HOME"
# --- Write realmlist.wtf based on selected environment
$realmlist = switch ($Env) {
"production" { $env:PRODUCTION_REALMLIST }
"ptr" { $env:PTR_REALMLIST }
"local" { $env:LOCAL_REALMLIST }
}
if ($realmlist) {
$realmlistPath = Join-Path $WOW_HOME "Data\ruRU\realmlist.wtf"
Set-Content -Path $realmlistPath -Value "set realmlist $realmlist" -Encoding ASCII
Write-Host "Realmlist ($Env): $realmlist"
} else {
Write-Warning "REALMLIST for '$Env' is not set in .env -- skipping realmlist.wtf"
}
# --- Run WoW reload script
Write-Host "Launching WoW..."
cmd /c $RELOAD_SCRIPT
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 MiB

Binary file not shown.
@@ -0,0 +1,95 @@
{
"fileDataID": 3730958,
"textures": [
{
"fileDataID": 3732460,
"file": "catslime_3732460.blp"
},
{
"fileDataID": 3732461,
"file": "catslime_3732461.blp"
}
],
"skins": [
{
"fileDataID": 3732472,
"file": "catslime00.skin"
}
],
"lodSkins": [
{
"fileDataID": 3732473,
"file": "catslime_lod01.skin"
},
{
"fileDataID": 3732474,
"file": "catslime_lod02.skin"
},
{
"fileDataID": 3732475,
"file": "catslime_lod03.skin"
}
],
"anims": [
{
"fileDataID": 3732462,
"file": "catslime0097-00.anim",
"animID": 97,
"subAnimID": 0
},
{
"fileDataID": 3732463,
"file": "catslime0096-00.anim",
"animID": 96,
"subAnimID": 0
},
{
"fileDataID": 3732464,
"file": "catslime0098-00.anim",
"animID": 98,
"subAnimID": 0
},
{
"fileDataID": 3732465,
"file": "catslime0100-00.anim",
"animID": 100,
"subAnimID": 0
},
{
"fileDataID": 3732466,
"file": "catslime0099-00.anim",
"animID": 99,
"subAnimID": 0
},
{
"fileDataID": 3732467,
"file": "catslime0101-00.anim",
"animID": 101,
"subAnimID": 0
},
{
"fileDataID": 3732468,
"file": "catslime0069-00.anim",
"animID": 69,
"subAnimID": 0
},
{
"fileDataID": 3732469,
"file": "catslime0123-00.anim",
"animID": 123,
"subAnimID": 0
},
{
"fileDataID": 3732470,
"file": "catslime0061-00.anim",
"animID": 61,
"subAnimID": 0
},
{
"fileDataID": 3732471,
"file": "catslime0075-00.anim",
"animID": 75,
"subAnimID": 0
}
]
}
Binary file not shown.
@@ -0,0 +1,160 @@
{
"fileDataID": 467885,
"textures": [
{
"fileDataID": 467884,
"file": "babymoonkinne.blp"
},
{
"fileDataID": 944915,
"file": "armorreflect4.blp"
}
],
"skins": [
{
"fileDataID": 473055,
"file": "babymoonkin_ne00.skin"
}
],
"lodSkins": [],
"anims": [
{
"fileDataID": 473051,
"file": "babymoonkin_ne0074-00.anim",
"animID": 74,
"subAnimID": 0
},
{
"fileDataID": 473072,
"file": "babymoonkin_ne0062-00.anim",
"animID": 62,
"subAnimID": 0
},
{
"fileDataID": 473068,
"file": "babymoonkin_ne0060-00.anim",
"animID": 60,
"subAnimID": 0
},
{
"fileDataID": 473061,
"file": "babymoonkin_ne0070-00.anim",
"animID": 70,
"subAnimID": 0
},
{
"fileDataID": 473038,
"file": "babymoonkin_ne0097-00.anim",
"animID": 97,
"subAnimID": 0
},
{
"fileDataID": 473067,
"file": "babymoonkin_ne0096-00.anim",
"animID": 96,
"subAnimID": 0
},
{
"fileDataID": 473047,
"file": "babymoonkin_ne0098-00.anim",
"animID": 98,
"subAnimID": 0
},
{
"fileDataID": 473040,
"file": "babymoonkin_ne0100-00.anim",
"animID": 100,
"subAnimID": 0
},
{
"fileDataID": 473035,
"file": "babymoonkin_ne0099-00.anim",
"animID": 99,
"subAnimID": 0
},
{
"fileDataID": 473041,
"file": "babymoonkin_ne0101-00.anim",
"animID": 101,
"subAnimID": 0
},
{
"fileDataID": 473031,
"file": "babymoonkin_ne0069-00.anim",
"animID": 69,
"subAnimID": 0
},
{
"fileDataID": 473050,
"file": "babymoonkin_ne0069-01.anim",
"animID": 69,
"subAnimID": 1
},
{
"fileDataID": 473046,
"file": "babymoonkin_ne0069-02.anim",
"animID": 69,
"subAnimID": 2
},
{
"fileDataID": 473042,
"file": "babymoonkin_ne0069-03.anim",
"animID": 69,
"subAnimID": 3
},
{
"fileDataID": 473060,
"file": "babymoonkin_ne0061-00.anim",
"animID": 61,
"subAnimID": 0
},
{
"fileDataID": 473030,
"file": "babymoonkin_ne0067-00.anim",
"animID": 67,
"subAnimID": 0
},
{
"fileDataID": 473037,
"file": "babymoonkin_ne0075-00.anim",
"animID": 75,
"subAnimID": 0
},
{
"fileDataID": 473069,
"file": "babymoonkin_ne0133-00.anim",
"animID": 133,
"subAnimID": 0
},
{
"fileDataID": 473057,
"file": "babymoonkin_ne0134-00.anim",
"animID": 134,
"subAnimID": 0
},
{
"fileDataID": 473074,
"file": "babymoonkin_ne0123-00.anim",
"animID": 123,
"subAnimID": 0
},
{
"fileDataID": 473056,
"file": "babymoonkin_ne0128-00.anim",
"animID": 128,
"subAnimID": 0
},
{
"fileDataID": 473029,
"file": "babymoonkin_ne0129-00.anim",
"animID": 129,
"subAnimID": 0
},
{
"fileDataID": 473073,
"file": "babymoonkin_ne0079-00.anim",
"animID": 79,
"subAnimID": 0
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,160 @@
{
"fileDataID": 467887,
"textures": [
{
"fileDataID": 467886,
"file": "babymoonkinta.blp"
},
{
"fileDataID": 249237,
"file": "..\\..\\item\\objectcomponents\\shield\\armorreflect4.blp"
}
],
"skins": [
{
"fileDataID": 473058,
"file": "babymoonkin_ta00.skin"
}
],
"lodSkins": [],
"anims": [
{
"fileDataID": 473066,
"file": "babymoonkin_ta0074-00.anim",
"animID": 74,
"subAnimID": 0
},
{
"fileDataID": 473039,
"file": "babymoonkin_ta0062-00.anim",
"animID": 62,
"subAnimID": 0
},
{
"fileDataID": 473064,
"file": "babymoonkin_ta0060-00.anim",
"animID": 60,
"subAnimID": 0
},
{
"fileDataID": 473075,
"file": "babymoonkin_ta0070-00.anim",
"animID": 70,
"subAnimID": 0
},
{
"fileDataID": 473053,
"file": "babymoonkin_ta0097-00.anim",
"animID": 97,
"subAnimID": 0
},
{
"fileDataID": 473032,
"file": "babymoonkin_ta0096-00.anim",
"animID": 96,
"subAnimID": 0
},
{
"fileDataID": 473076,
"file": "babymoonkin_ta0098-00.anim",
"animID": 98,
"subAnimID": 0
},
{
"fileDataID": 473071,
"file": "babymoonkin_ta0100-00.anim",
"animID": 100,
"subAnimID": 0
},
{
"fileDataID": 473036,
"file": "babymoonkin_ta0099-00.anim",
"animID": 99,
"subAnimID": 0
},
{
"fileDataID": 473048,
"file": "babymoonkin_ta0101-00.anim",
"animID": 101,
"subAnimID": 0
},
{
"fileDataID": 473065,
"file": "babymoonkin_ta0069-00.anim",
"animID": 69,
"subAnimID": 0
},
{
"fileDataID": 473044,
"file": "babymoonkin_ta0069-01.anim",
"animID": 69,
"subAnimID": 1
},
{
"fileDataID": 473070,
"file": "babymoonkin_ta0069-02.anim",
"animID": 69,
"subAnimID": 2
},
{
"fileDataID": 473052,
"file": "babymoonkin_ta0069-03.anim",
"animID": 69,
"subAnimID": 3
},
{
"fileDataID": 473062,
"file": "babymoonkin_ta0061-00.anim",
"animID": 61,
"subAnimID": 0
},
{
"fileDataID": 473043,
"file": "babymoonkin_ta0067-00.anim",
"animID": 67,
"subAnimID": 0
},
{
"fileDataID": 473045,
"file": "babymoonkin_ta0075-00.anim",
"animID": 75,
"subAnimID": 0
},
{
"fileDataID": 473034,
"file": "babymoonkin_ta0133-00.anim",
"animID": 133,
"subAnimID": 0
},
{
"fileDataID": 473063,
"file": "babymoonkin_ta0134-00.anim",
"animID": 134,
"subAnimID": 0
},
{
"fileDataID": 473054,
"file": "babymoonkin_ta0123-00.anim",
"animID": 123,
"subAnimID": 0
},
{
"fileDataID": 473049,
"file": "babymoonkin_ta0128-00.anim",
"animID": 128,
"subAnimID": 0
},
{
"fileDataID": 473033,
"file": "babymoonkin_ta0129-00.anim",
"animID": 129,
"subAnimID": 0
},
{
"fileDataID": 473059,
"file": "babymoonkin_ta0079-00.anim",
"animID": 79,
"subAnimID": 0
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.
@@ -23,7 +23,7 @@ FACTION_BACKDROP_COLOR_TABLE = {
["Alliance"] = {0.5, 0.5, 0.5, 0.09, 0.09, 0.19},
["Horde"] = {0.5, 0.2, 0.2, 0.19, 0.05, 0.05},
};
FRAMES_TO_BACKDROP_COLOR = {
FRAMES_TO_BACKDROP_COLOR = {
"CharacterCreateCharacterRace",
"CharacterCreateCharacterClass",
-- "CharacterCreateCharacterFaction",
@@ -36,11 +36,11 @@ RACE_ICON_TCOORDS = {
["NIGHTELF_MALE"] = {0.126953125, 0.189453125, 0.000000000, 0.125953125},
["DRAENEI_MALE"] = {0.255859375, 0.318359375, 0.000000000, 0.125953125},
["HUMAN_FEMALE"] = {0.000000000, 0.062500000, 0.128906250, 0.253906250},
["DWARF_FEMALE"] = {0.063476563, 0.125976563, 0.128906250, 0.253906250},
["GNOME_FEMALE"] = {0.192382813, 0.254882813, 0.128906250, 0.253906250},
["NIGHTELF_FEMALE"] = {0.127441406, 0.189941406, 0.128906250, 0.253906250},
["DRAENEI_FEMALE"] = {0.256347656, 0.318359375, 0.128906250, 0.253906250},
["HUMAN_FEMALE"] = {0.000000000, 0.062500000, 0.128906250, 0.253906250},
["DWARF_FEMALE"] = {0.063476563, 0.125976563, 0.128906250, 0.253906250},
["GNOME_FEMALE"] = {0.192382813, 0.254882813, 0.128906250, 0.253906250},
["NIGHTELF_FEMALE"] = {0.127441406, 0.189941406, 0.128906250, 0.253906250},
["DRAENEI_FEMALE"] = {0.256347656, 0.318359375, 0.128906250, 0.253906250},
["ORC_MALE"] = {0.062011719, 0.000000000, 0.262695313, 0.384765625},
["SCOURGE_MALE"] = {0.125976563, 0.063964844, 0.262695313, 0.384765625},
@@ -48,10 +48,10 @@ RACE_ICON_TCOORDS = {
["TROLL_MALE"] = {0.253906250, 0.192382813, 0.262695313, 0.384765625},
["BLOODELF_MALE"] = {0.317871094, 0.256835938, 0.262695313, 0.384765625},
["ORC_FEMALE"] = {0.060546875, 0.000000000, 0.391601563, 0.512695313},
["SCOURGE_FEMALE"] = {0.124023438, 0.063476563, 0.391601563, 0.512695313},
["TAUREN_FEMALE"] = {0.187988281, 0.127441406, 0.391601563, 0.512695313},
["TROLL_FEMALE"] = {0.252441406, 0.191894531, 0.391601563, 0.512695313},
["ORC_FEMALE"] = {0.060546875, 0.000000000, 0.391601563, 0.512695313},
["SCOURGE_FEMALE"] = {0.124023438, 0.063476563, 0.391601563, 0.512695313},
["TAUREN_FEMALE"] = {0.187988281, 0.127441406, 0.391601563, 0.512695313},
["TROLL_FEMALE"] = {0.252441406, 0.191894531, 0.391601563, 0.512695313},
["BLOODELF_FEMALE"] = {0.316894531, 0.256835938, 0.391601563, 0.512695313},
};
CLASS_ICON_TCOORDS = {
@@ -137,23 +137,23 @@ local function HideAllTooltips()
if AllianceTooltip then
AllianceTooltip:Hide()
end
if HordeTooltip then
HordeTooltip:Hide()
end
end
local backdrop = {
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true, tileSize = 16, edgeSize = 16,
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true, tileSize = 16, edgeSize = 16,
insets = { left = 4, right = 4, top = 4, bottom = 4 }
};
local Backdrop2 = {
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
edgeFile = "Interface\\Tooltips\\ui-tooltip-border-maw",
tile = true, tileSize = 16, edgeSize = 16,
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
edgeFile = "Interface\\Tooltips\\ui-tooltip-border-maw",
tile = true, tileSize = 16, edgeSize = 16,
insets = { left = 4, right = 4, top = 4, bottom = 4 }
};
@@ -164,10 +164,10 @@ local function GetOrCreateRaceTooltip(button)
tooltip:SetSize(280, 150)
tooltip:SetFrameStrata("TOOLTIP")
tooltip:SetBackdropColor(0, 0, 0, 1)
local raceID = button:GetID()
local faction = _G.GetFactionForRaceID(raceID)
if faction == "Alliance" then
tooltip:SetPoint("LEFT", CharacterCreateFrame, "LEFT", 160, 50)
elseif faction == "Horde" then
@@ -194,7 +194,7 @@ local function GetOrCreateRaceTooltip(button)
tooltip.rightClickText:SetPoint("TOP", tooltip.detailsText, "BOTTOM", 0, -10)
tooltip.rightClickText:SetTextColor(0.8, 0.8, 0.8)
tooltip.rightClickText:SetJustifyH("CENTER")
raceTooltips[button] = tooltip
end
return raceTooltips[button]
@@ -217,7 +217,7 @@ local function UpdateRaceTooltip(button, toggleDetails)
tooltip.text:ClearAllPoints()
tooltip.text:SetPoint("TOPLEFT", tooltip, "TOPLEFT", 10, -5)
tooltip.detailsText:ClearAllPoints()
tooltip.detailsText:SetPoint("TOPLEFT", tooltip.text, "BOTTOMLEFT", 0, -5)
@@ -271,7 +271,7 @@ local function UpdateRaceTooltip(button, toggleDetails)
local containerHeight = tooltip["spellIcon"..i]:GetHeight() + tooltip["spellDesc"..i]:GetHeight() + 3
tooltip["spellContainer"..i]:SetHeight(containerHeight)
lastElement = tooltip["spellContainer"..i]
end
end
@@ -308,10 +308,10 @@ local function UpdateRaceTooltip(button, toggleDetails)
end
end
local baseHeight = tooltip.text:GetHeight() +
tooltip.detailsText:GetHeight() +
local baseHeight = tooltip.text:GetHeight() +
tooltip.detailsText:GetHeight() +
tooltip.rightClickText:GetHeight() + 25
tooltip:SetHeight(baseHeight + heightToAdd)
detailedRaceTooltips[button] = toggleDetails
tooltip:ClearAllPoints()
@@ -346,7 +346,7 @@ local function GetOrCreateClassTooltip(button)
tooltip.Roles:SetWidth(284)
tooltip.Roles:SetWordWrap(true)
tooltip.Roles:SetJustifyH("LEFT")
classTooltips[button] = tooltip
end
@@ -370,7 +370,7 @@ local function GetValidRacesForClass(classID)
table.insert(hordeRaces, raceName)
end
end
return allianceRaces, hordeRaces
end
local function UpdateClassTooltip(button)
@@ -382,7 +382,7 @@ local function UpdateClassTooltip(button)
local buttonX, buttonY = button:GetCenter()
local parentX, parentY = CharacterCreateFrame:GetCenter()
if buttonX > parentX then
tooltip:SetPoint("BOTTOMRIGHT", button, "TOPRIGHT", 0, 10)
else
@@ -394,7 +394,7 @@ local function UpdateClassTooltip(button)
tooltip.text:ClearAllPoints()
tooltip.text:SetPoint("TOPLEFT", tooltip, "TOPLEFT", 8, -8)
tooltip.detailsText:ClearAllPoints()
tooltip.detailsText:SetPoint("TOPLEFT", tooltip.text, "BOTTOMLEFT", 0, -5)
@@ -405,7 +405,7 @@ local function UpdateClassTooltip(button)
tooltip.RestrictionText = tooltip.RestrictionText or tooltip:CreateFontString(nil, "OVERLAY")
tooltip.RestrictionText:SetFont("Fonts\\FRIZQT__.TTF", 12, "OUTLINE")
tooltip.RestrictionText:SetJustifyH("LEFT")
tooltip.FactionText = tooltip.FactionText or tooltip:CreateFontString(nil, "OVERLAY")
tooltip.FactionText:SetFont("Fonts\\FRIZQT__.TTF", 11)
tooltip.FactionText:SetWordWrap(true)
@@ -416,12 +416,12 @@ local function UpdateClassTooltip(button)
coloredRoles = string.gsub(coloredRoles, "Дальний бой", "|cffff2020Дальний бой|r")
coloredRoles = string.gsub(coloredRoles, "Танк", "|cff0070ddТанк|r")
coloredRoles = string.gsub(coloredRoles, "Лекарь", "|cff20c000Лекарь|r")
if not tooltip.Roles then
tooltip.Roles = tooltip:CreateFontString(nil, "OVERLAY")
tooltip.Roles:SetFont("Fonts\\FRIZQT__.TTF", 11)
end
tooltip.Roles:SetText("|cFFFFFFFF"..FUNTION_INF.."|r\n\n "..coloredRoles)
if showRestriction then
@@ -435,20 +435,20 @@ local function UpdateClassTooltip(button)
tooltip.RestrictionText:Show()
local allianceRaces, hordeRaces = GetValidRacesForClass(classID)
local factionText = ""
if #allianceRaces > 0 then
factionText = ALLIANCE_RACE .. " |cFFFFFFFF" .. table.concat(allianceRaces, ", ") .. "|r"
end
if #hordeRaces > 0 then
if factionText ~= "" then
factionText = factionText .. "\n\n"
end
factionText = factionText .. "\n" .. HORDE_RACE .. " |cFFFFFFFF" .. table.concat(hordeRaces, ", ") .. "|r"
end
tooltip.FactionText:SetPoint("TOPLEFT", tooltip.RestrictionText, "BOTTOMLEFT", 0, -15)
tooltip.FactionText:SetTextColor(1, 0.82, 0)
tooltip.FactionText:SetText(factionText)
@@ -459,18 +459,18 @@ local function UpdateClassTooltip(button)
maxWidth = math.max(maxWidth, tooltip.RestrictionText:GetWidth())
maxWidth = math.max(maxWidth, tooltip.FactionText:GetWidth())
maxWidth = math.max(280, math.min(maxWidth + 16, 400))
tooltip:SetWidth(maxWidth)
tooltip.FactionText:SetWidth(maxWidth - 16)
local restrictionHeight = tooltip.text:GetHeight() +
tooltip.detailsText:GetHeight() +
local restrictionHeight = tooltip.text:GetHeight() +
tooltip.detailsText:GetHeight() +
tooltip.Roles:GetHeight() +
tooltip.RestrictionText:GetHeight() +
tooltip.RestrictionText:GetHeight() +
tooltip.FactionText:GetHeight() + 80
tooltip:SetHeight(restrictionHeight)
else
tooltip.RestrictionText:Hide()
tooltip.FactionText:Hide()
@@ -484,10 +484,10 @@ local function UpdateClassTooltip(button)
maxWidth = math.max(280, math.min(maxWidth + 16, 400))
tooltip:SetWidth(maxWidth)
local baseHeight = tooltip.text:GetHeight() +
tooltip.detailsText:GetHeight() +
local baseHeight = tooltip.text:GetHeight() +
tooltip.detailsText:GetHeight() +
tooltip.Roles:GetHeight() + 30
tooltip:SetHeight(baseHeight)
end
end
@@ -498,7 +498,7 @@ function CharacterCreate_MoveTexturesToBackground()
if button then
local normalTex = _G[button:GetName().."NormalTexture"]
local pushedTex = _G[button:GetName().."PushedTexture"]
if normalTex then
normalTex:SetDrawLayer("BACKGROUND")
end
@@ -513,7 +513,7 @@ function CharacterCreate_MoveTexturesToBackground()
if button then
local normalTex = _G[button:GetName().."NormalTexture"]
local pushedTex = _G[button:GetName().."PushedTexture"]
if normalTex then
normalTex:SetDrawLayer("BACKGROUND")
end
@@ -525,12 +525,12 @@ function CharacterCreate_MoveTexturesToBackground()
local maleButton = CharacterCreateGenderButtonMale
local femaleButton = CharacterCreateGenderButtonFemale
if maleButton then
_G[maleButton:GetName().."NormalTexture"]:SetDrawLayer("BACKGROUND")
_G[maleButton:GetName().."PushedTexture"]:SetDrawLayer("BACKGROUND")
end
if femaleButton then
_G[femaleButton:GetName().."NormalTexture"]:SetDrawLayer("BACKGROUND")
_G[femaleButton:GetName().."PushedTexture"]:SetDrawLayer("BACKGROUND")
@@ -558,7 +558,7 @@ local function CreateFactionTooltip(parent, factionName)
tooltip.description:SetWordWrap(true)
tooltip.description:SetJustifyH("LEFT")
tooltip.description:SetTextColor(0.9, 0.9, 0.9)
return tooltip
end
@@ -566,7 +566,7 @@ local function UpdateTooltipSize(tooltip)
local titleHeight = tooltip.title:GetHeight()
local descHeight = tooltip.description:GetHeight()
local totalHeight = titleHeight + descHeight + 30
tooltip:SetHeight(totalHeight)
tooltip:SetWidth(300)
end
@@ -580,7 +580,7 @@ function CharacterCreate_PositionRaceButtons()
local button = _G["CharacterCreateRaceButton"..i]
if button then
button:ClearAllPoints()
if i == 1 then
button:SetPoint("TOP", AllianceLogoFrame, "BOTTOM", -horizontalOffset, verticalStart)
else
@@ -594,7 +594,7 @@ function CharacterCreate_PositionRaceButtons()
local button = _G["CharacterCreateRaceButton"..i]
if button then
button:ClearAllPoints()
if i == 6 then
button:SetPoint("TOP", HordeLogoFrame, "BOTTOM", horizontalOffset, verticalStart)
else
@@ -607,12 +607,12 @@ end
function CharacterCreate_PositionClassButtons()
local buttonSpacing = 80
for i = 1, MAX_CLASSES_PER_RACE do
local button = _G["CharacterCreateClassButton"..i]
if button then
button:ClearAllPoints()
if i == 1 then
button:SetPoint("CENTER", CharacterCreateFrame, "BOTTOM", -360, 80)
else
@@ -624,16 +624,16 @@ function CharacterCreate_PositionClassButtons()
end
function CharacterCreate_PositionGenderButtons()
local genderSpacing = 310
local genderSpacing = 310
local maleButton = CharacterCreateGenderButtonMale
local femaleButton = CharacterCreateGenderButtonFemale
if maleButton then
maleButton:ClearAllPoints()
maleButton:SetPoint("CENTER", CharacterCreateFrame, "CENTER", -(genderSpacing/2), -250)
end
if femaleButton then
femaleButton:ClearAllPoints()
femaleButton:SetPoint("CENTER", CharacterCreateFrame, "CENTER", (genderSpacing/2), -250)
@@ -714,6 +714,44 @@ local function CharacterCreate_UpdateCamera(immediate)
end
end
-- The stock creation scene frames each race at a different height. Projection
-- zoom therefore needs a per-race vertical target to keep the face in frame.
-- Values are indexed by GetSelectedSex(): 2 male, 3 female.
local CHARACTER_CREATE_CAMERA_PROFILES = {
HUMAN = { [2] = -0.28, [3] = -0.20 },
ORC = { [2] = -0.50, [3] = -0.30 },
DWARF = { [2] = 0.30, [3] = 0.38 },
NIGHTELF = { [2] = -0.65, [3] = -0.65 },
SCOURGE = { [2] = 0.18, [3] = 0.27 },
TAUREN = { [2] = -0.78, [3] = -0.62 },
GNOME = { [2] = 0.64, [3] = 0.72 },
TROLL = { [2] = -0.70, [3] = -0.55 },
BLOODELF = { [2] = -0.30, [3] = -0.22 },
DRAENEI = { [2] = -0.62, [3] = -0.55 },
};
local function CharacterCreate_GetCameraProfile()
local _, raceFile = GetNameForRace();
local raceProfile = raceFile and CHARACTER_CREATE_CAMERA_PROFILES[strupper(raceFile)];
local sex = GetSelectedSex();
return 2.0, raceProfile and raceProfile[sex] or -0.20;
end
local function CharacterCreate_UpdateCamera(immediate)
local showFace = CharacterCreate.personalizationMode and not CharacterCreate.gameModeSelectionMode;
local mode = showFace and 1 or 0;
if CharacterCreate.cameraMode == mode and not immediate then
return;
end
CharacterCreate.cameraMode = mode;
CharacterCreate:SetCamera(0);
if MoonWellSetCharacterCreateCamera then
local zoom, verticalOffset = CharacterCreate_GetCameraProfile();
MoonWellSetCharacterCreateCamera(mode, zoom, verticalOffset, immediate and 0 or 500);
end
end
local function CharacterCreate_GetEnteredName()
local text = "";
@@ -818,7 +856,7 @@ function CharacterCreate_OnLoad(self)
self:SetSequence(0)
self:SetCamera(0)
SetCharCustomizeFrame("CharacterCreate")
CharacterCreate.numRaces = 0
CharacterCreate.selectedRace = 0
CharacterCreate.numClasses = 0
@@ -860,7 +898,7 @@ function CharacterCreate_OnLoad(self)
AllianceLogoFrame:SetSize(100, 100)
AllianceLogoFrame:SetPoint("TOPLEFT", -16, 16)
AllianceLogoFrame:EnableMouse(true)
AllianceLogoFrame:SetScript("OnEnter", function(self)
AllianceTooltip.title:SetText(ALLIANCE)
AllianceTooltip.description:SetText(FACTION_ALLIANCE_DESCRIPTION)
@@ -869,7 +907,7 @@ function CharacterCreate_OnLoad(self)
AllianceTooltip:SetPoint("TOPLEFT", self, "BOTTOMLEFT", 90, 60)
AllianceTooltip:Show()
end)
AllianceLogoFrame:SetScript("OnLeave", function(self)
AllianceTooltip:Hide()
end)
@@ -890,7 +928,7 @@ function CharacterCreate_OnLoad(self)
HordeLogoFrame:SetSize(100, 100)
HordeLogoFrame:SetPoint("TOPRIGHT", 16, 16)
HordeLogoFrame:EnableMouse(true)
HordeLogoFrame:SetScript("OnEnter", function(self)
HordeTooltip.title:SetText(HORDE)
HordeTooltip.description:SetText(FACTION_HORDE_DESCRIPTION)
@@ -899,7 +937,7 @@ function CharacterCreate_OnLoad(self)
HordeTooltip:SetPoint("TOPRIGHT", self, "BOTTOMRIGHT", -90, 60)
HordeTooltip:Show()
end)
HordeLogoFrame:SetScript("OnLeave", function(self)
HordeTooltip:Hide()
end)
@@ -913,7 +951,7 @@ function CharacterCreate_SetupCustomButtons()
{button = CharCreateRandomizeButton, width = 36, height = 36},
{button = CharacterCreateRandomName, width = 30, height = 30}
}
local texturePath = "Interface\\Glues\\CharacterCreate\\charactercreate"
for _, btnInfo in ipairs(buttons) do
@@ -1002,7 +1040,7 @@ end)
function CharacterCreate_OnShow()
CharacterCreate.personalizationMode = false;
for i=1, MAX_CLASSES_PER_RACE, 1 do
local button = _G["CharacterCreateClassButton"..i];
button:Enable();
@@ -1026,7 +1064,7 @@ function CharacterCreate_OnShow()
CharacterCreate.personalizationMode = false;
CharacterCreate.gameModeSelectionMode = false;
CharCreateOkayButton:Hide();
for i=1, NUM_CHAR_CUSTOMIZATIONS do
_G["CharacterCustomizationButtonFrame"..i]:Hide();
end
@@ -1113,11 +1151,11 @@ function CharacterCreateEnumerateRaces(...)
local raceID = index
local faction = _G.GetFactionForRaceID(raceID)
local borderColor
if faction == "Alliance" then
borderColor = {0.0, 0.4, 1.0}
else
borderColor = {1.0, 0.0, 0.0}
borderColor = {1.0, 0.0, 0.0}
end
if not button.staticTexture then
@@ -1155,10 +1193,10 @@ function CharacterCreateEnumerateRaces(...)
if not button.texturesMoved then
button:SetScript("OnMouseDown", nil);
button:SetScript("OnMouseUp", nil);
button.texturesMoved = true;
end
button:Show();
if ( select(i+2, ...) == 1 ) then
button.enable = true;
@@ -1180,7 +1218,7 @@ function CharacterCreateEnumerateRaces(...)
tooltip:Hide()
end
end)
button:SetScript("OnMouseDown", function(self, clickedButton)
if self:IsEnabled() and clickedButton == "RightButton" then
local tooltip = GetOrCreateRaceTooltip(self)
@@ -1246,7 +1284,7 @@ function CharacterCreateEnumerateClasses(...)
button.nameFrame = CreateFrame("Frame", nil, button);
button.nameFrame:SetSize(112, 40);
button.nameFrame:SetPoint("TOP", button, "BOTTOM", 0, -5);
button.nameFrame.text = button.nameFrame:CreateFontString(nil, "OVERLAY");
button.nameFrame.text:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE");
button.nameFrame.text:SetPoint("CENTER", 0, 0);
@@ -1261,10 +1299,10 @@ function CharacterCreateEnumerateClasses(...)
if not button.texturesMoved then
button:SetScript("OnMouseDown", nil);
button:SetScript("OnMouseUp", nil);
button.texturesMoved = true;
end
button:Show();
local className = select(i, ...);
@@ -1311,7 +1349,7 @@ function CharacterCreateEnumerateClasses(...)
tooltip:Hide()
end
end)
button:SetScript("OnMouseDown", function(self, clickedButton)
if self:IsEnabled() and clickedButton == "RightButton" then
local tooltip = GetOrCreateClassTooltip(self)
@@ -1356,14 +1394,14 @@ function CharacterCreate_CreateGenderButtonTextures()
maleButton.staticTexture:SetTexture("Interface\\Glues\\CharacterCreate\\IconBorder_F1");
maleButton.staticTexture:SetSize(86, 86);
maleButton.staticTexture:SetPoint("CENTER", 0, 0);
maleButton.highlightTexture = maleButton:CreateTexture(maleButton:GetName().."HighlightTexture", "HIGHLIGHT");
maleButton.highlightTexture:SetTexture("Interface\\Glues\\CharacterCreate\\IconBorder_F1");
maleButton.highlightTexture:SetAlpha(0.5);
maleButton.highlightTexture:SetBlendMode("ADD");
maleButton.highlightTexture:SetSize(86, 86);
maleButton.highlightTexture:SetPoint("CENTER", 0, 0);
maleButton.checkedTexture = maleButton:CreateTexture(maleButton:GetName().."CheckedTexture", "OVERLAY");
maleButton.checkedTexture:SetDrawLayer("OVERLAY", 7);
maleButton.checkedTexture:SetTexture("Interface\\Glues\\CharacterCreate\\IconBorderRace_H");
@@ -1382,14 +1420,14 @@ function CharacterCreate_CreateGenderButtonTextures()
femaleButton.staticTexture:SetTexture("Interface\\Glues\\CharacterCreate\\IconBorder_F1");
femaleButton.staticTexture:SetSize(86, 86);
femaleButton.staticTexture:SetPoint("CENTER", 0, 0);
femaleButton.highlightTexture = femaleButton:CreateTexture(femaleButton:GetName().."HighlightTexture", "HIGHLIGHT");
femaleButton.highlightTexture:SetTexture("Interface\\Glues\\CharacterCreate\\IconBorder_F1");
femaleButton.highlightTexture:SetAlpha(0.5);
femaleButton.highlightTexture:SetBlendMode("ADD");
femaleButton.highlightTexture:SetSize(86, 86);
femaleButton.highlightTexture:SetPoint("CENTER", 0, 0);
femaleButton.checkedTexture = femaleButton:CreateTexture(femaleButton:GetName().."CheckedTexture", "OVERLAY");
femaleButton.checkedTexture:SetDrawLayer("OVERLAY", 7);
femaleButton.checkedTexture:SetTexture("Interface\\Glues\\CharacterCreate\\IconBorderRace_H");
@@ -1480,7 +1518,7 @@ function SetCharacterClass(id)
end
CharacterCreate_UpdateButtonCheckedStates();
local className, classFileName, _, tank, healer, damage = GetSelectedClass();
local abilityIndex = 0;
local tempText = _G["CLASS_INFO_"..classFileName..abilityIndex];
@@ -1493,7 +1531,7 @@ function SetCharacterClass(id)
local coords = CLASS_ICON_TCOORDS[classFileName];
CharacterCreateClassIcon:SetTexCoord(coords[1], coords[2], coords[3], coords[4]);
CharacterCreateClassLabel:SetText(className);
CharacterCreateClassRolesText:SetText(abilityText);
CharacterCreateClassRolesText:SetText(abilityText);
CharacterCreateClassText:SetText(GetFlavorText("CLASS_"..strupper(classFileName), GetSelectedSex()).."|n|n");
CharacterCreateClassScrollFrameScrollBar:SetValue(0);
end
@@ -1529,7 +1567,7 @@ function CharacterCreate_UpdateButtonCheckedStates()
local maleButton = CharacterCreateGenderButtonMale;
local femaleButton = CharacterCreateGenderButtonFemale;
if maleButton and maleButton.checkedTexture then
if maleButton:GetChecked() then
maleButton.checkedTexture:Show();
@@ -1537,7 +1575,7 @@ function CharacterCreate_UpdateButtonCheckedStates()
maleButton.checkedTexture:Hide();
end
end
if femaleButton and femaleButton.checkedTexture then
if femaleButton:GetChecked() then
femaleButton.checkedTexture:Show();
@@ -1641,7 +1679,7 @@ function CharacterRace_OnClick(self, id)
SetCharacterClass(classIndex);
CharacterCreate_UpdateHairCustomization();
CharacterChangeFixup();
CharacterCreate_UpdateButtonCheckedStates();
@@ -1666,7 +1704,7 @@ function SetCharacterGender(sex)
CharacterCreateEnumerateRaces(GetAvailableRaces());
CharacterCreateEnumerateClasses(GetAvailableClasses());
SetCharacterRace(GetSelectedRace());
local _,_,classIndex = GetSelectedClass();
if ( PAID_SERVICE_TYPE ) then
classIndex = PaidChange_GetCurrentClassIndex();
@@ -1680,7 +1718,7 @@ function SetCharacterGender(sex)
fileString = strupper(fileString);
local coords = RACE_ICON_TCOORDS[fileString.."_"..gender];
CharacterCreateRaceIcon:SetTexCoord(coords[1], coords[2], coords[3], coords[4]);
CharacterChangeFixup();
CharacterCreate_UpdateButtonCheckedStates();
@@ -1707,7 +1745,7 @@ function CharacterCreate_ResetState(immediate)
CharCreatePersonalizeButton:Show();
CharCreateOkayButton:Hide();
CharacterCreate_UpdateGameModeVisibility();
for i=1, NUM_CHAR_CUSTOMIZATIONS do
_G["CharacterCustomizationButtonFrame"..i]:Hide();
end
@@ -1753,7 +1791,7 @@ end
function CharacterCreate_UpdateHairCustomization()
CharacterCustomizationButtonFrame3Text:SetText(_G["HAIR_"..GetHairCustomization().."_STYLE"]);
CharacterCustomizationButtonFrame4Text:SetText(_G["HAIR_"..GetHairCustomization().."_COLOR"]);
CharacterCustomizationButtonFrame5Text:SetText(_G["FACIAL_HAIR_"..GetFacialHairCustomization()]);
CharacterCustomizationButtonFrame5Text:SetText(_G["FACIAL_HAIR_"..GetFacialHairCustomization()]);
end
function SetButtonDesaturated(button, desaturated, r, g, b)
@@ -1775,7 +1813,7 @@ function SetButtonDesaturated(button, desaturated, r, g, b)
g = 0.5;
b = 0.5;
end
icon:SetVertexColor(r, g, b);
end
@@ -69,25 +69,26 @@ local MainMenuBarBackpackButton = _G.MainMenuBarBackpackButton;
local HelpMicroButton = _G.HelpMicroButton;
local KeyRingButton = _G.KeyRingButton;
-- Button collections (dynamically set based on server)
local MICRO_BUTTONS
-- Button collections (rebuilt after addons create optional custom buttons).
local function BuildMicroButtonList()
if isAscensionServer then
return {
_G.CharacterMicroButton,
_G.SpellbookMicroButton,
_G.TalentMicroButton,
_G.AchievementMicroButton,
_G.QuestLogMicroButton,
_G.SocialsMicroButton,
_G.LFDMicroButton,
_G.EncounterJournalMicroButton,
_G.PathToAscensionMicroButton,
_G.ChallengesMicroButton,
_G.MainMenuMicroButton,
_G.HelpMicroButton
}
end
if isAscensionServer then
MICRO_BUTTONS = {
_G.CharacterMicroButton,
_G.SpellbookMicroButton,
_G.TalentMicroButton,
_G.AchievementMicroButton,
_G.QuestLogMicroButton,
_G.SocialsMicroButton,
_G.LFDMicroButton,
_G.PathToAscensionMicroButton,
_G.ChallengesMicroButton,
_G.MainMenuMicroButton,
_G.HelpMicroButton
}
else
MICRO_BUTTONS = {
return {
_G.CharacterMicroButton,
_G.SpellbookMicroButton,
_G.TalentMicroButton,
@@ -95,6 +96,7 @@ else
_G.QuestLogMicroButton,
_G.SocialsMicroButton,
_G.LFDMicroButton,
_G.EncounterJournalMicroButton,
_G.CollectionsMicroButton,
_G.PVPMicroButton,
_G.MainMenuMicroButton,
@@ -102,6 +104,7 @@ else
}
end
local MICRO_BUTTONS = BuildMicroButtonList()
local bagslots = {_G.CharacterBag0Slot, _G.CharacterBag1Slot, _G.CharacterBag2Slot, _G.CharacterBag3Slot};
@@ -783,6 +786,8 @@ local function ApplyMicromenuSystem()
return
end
MICRO_BUTTONS = BuildMicroButtonList()
-- Store original states first
StoreOriginalMicroButtonStates()
@@ -911,6 +916,8 @@ local function ApplyMicromenuSystem()
or (_G.PVPParentFrame and _G.PVPParentFrame:IsVisible() and true or false)
or (_G.BattlefieldFrame and _G.BattlefieldFrame:IsVisible() and true or false)
or (_G.HonorFrame and _G.HonorFrame:IsVisible() and true or false)
elseif buttonName == "EncounterJournal" then
return (_G.EncounterJournal and _G.EncounterJournal:IsVisible() and true or false)
end
return pressed
@@ -919,6 +926,152 @@ local function ApplyMicromenuSystem()
-- ============================================================================
-- SECTION 5: SPECIALIZED BUTTON SETUP
-- ============================================================================
local function SetupEncounterJournalButton(button)
local iconTexture = 'Interface\\EncounterJournal\\UI-EJ-PortraitIcon'
local backgroundTexture = 'Interface\\AddOns\\DragonUI\\Textures\\Micromenu\\uimicromenu2x'
local buttonWidth, buttonHeight = button:GetSize()
local dx, dy = -1, 1
local offX, offY = button:GetPushedTextOffset()
if not button.DragonUIEncounterJournalIcon then
button.DragonUIEncounterJournalIcon = button:CreateTexture(nil, 'ARTWORK')
end
local icon = button.DragonUIEncounterJournalIcon
icon:SetTexture(iconTexture)
icon:SetTexCoord(0, 1, 0, 1)
icon:ClearAllPoints()
icon:SetPoint('CENTER', button, 'CENTER', 0, 0)
icon:SetSize(buttonWidth - 8, buttonWidth - 8)
icon:SetAlpha(1)
icon:Show()
local highlightTexture = button:GetHighlightTexture()
if highlightTexture then
highlightTexture:SetTexture(iconTexture)
highlightTexture:SetTexCoord(0, 1, 0, 1)
highlightTexture:ClearAllPoints()
highlightTexture:SetAllPoints(icon)
highlightTexture:SetBlendMode('ADD')
highlightTexture:SetAlpha(0.55)
end
if not button.DragonUIBackground then
local bg = button:CreateTexture(nil, 'BACKGROUND')
bg:SetTexture(backgroundTexture)
bg:SetSize(buttonWidth, buttonHeight + 1)
bg:SetTexCoord(0.0654297, 0.12793, 0.330078, 0.490234)
bg:SetPoint('CENTER', dx, dy)
button.DragonUIBackground = bg
local bgPushed = button:CreateTexture(nil, 'BACKGROUND')
bgPushed:SetTexture(backgroundTexture)
bgPushed:SetSize(buttonWidth, buttonHeight + 1)
bgPushed:SetTexCoord(0.0654297, 0.12793, 0.494141, 0.654297)
bgPushed:SetPoint('CENTER', dx + offX, dy + offY)
bgPushed:Hide()
button.DragonUIBackgroundPushed = bgPushed
else
button.DragonUIBackground:SetTexture(backgroundTexture)
button.DragonUIBackground:SetTexCoord(0.0654297, 0.12793, 0.330078, 0.490234)
button.DragonUIBackground:ClearAllPoints()
button.DragonUIBackground:SetPoint('CENTER', dx, dy)
button.DragonUIBackground:SetSize(buttonWidth, buttonHeight + 1)
if button.DragonUIBackgroundPushed then
button.DragonUIBackgroundPushed:SetTexture(backgroundTexture)
button.DragonUIBackgroundPushed:SetTexCoord(0.0654297, 0.12793, 0.494141, 0.654297)
button.DragonUIBackgroundPushed:ClearAllPoints()
button.DragonUIBackgroundPushed:SetPoint('CENTER', dx + offX, dy + offY)
button.DragonUIBackgroundPushed:SetSize(buttonWidth, buttonHeight + 1)
end
end
button.dragonUIState = button.dragonUIState or {}
button.dragonUIState.pushed = IsSpecialMicroButtonActive(button, "EncounterJournal")
button.dragonUILastState = button.dragonUIState.pushed
button.dragonUITimer = button.dragonUITimer or 0
button.HandleDragonUIState = function()
local state = button.dragonUIState
local hlTex = button:GetHighlightTexture()
if state and state.pushed then
if icon then
icon:ClearAllPoints()
icon:SetPoint('CENTER', button, 'CENTER', offX, offY)
icon:SetAlpha(0.7)
end
if button.DragonUIBackground then
button.DragonUIBackground:Hide()
end
if button.DragonUIBackgroundPushed then
button.DragonUIBackgroundPushed:Show()
end
if hlTex then
hlTex:ClearAllPoints()
hlTex:SetPoint('TOPLEFT', icon, 'TOPLEFT', 0, 0)
hlTex:SetPoint('BOTTOMRIGHT', icon, 'BOTTOMRIGHT', 0, 0)
end
else
if icon then
icon:ClearAllPoints()
icon:SetPoint('CENTER', button, 'CENTER', 0, 0)
icon:SetAlpha(1)
end
if button.DragonUIBackground then
button.DragonUIBackground:Show()
end
if button.DragonUIBackgroundPushed then
button.DragonUIBackgroundPushed:Hide()
end
if hlTex then
hlTex:ClearAllPoints()
hlTex:SetAllPoints(icon)
end
end
end
button:SetScript('OnUpdate', function(self, elapsed)
self.dragonUITimer = (self.dragonUITimer or 0) + elapsed
if self.dragonUITimer >= 0.1 then
self.dragonUITimer = 0
local currentState = IsSpecialMicroButtonActive(self, "EncounterJournal")
if currentState ~= self.dragonUILastState then
self.dragonUILastState = currentState
if self.dragonUIState then
self.dragonUIState.pushed = currentState
end
if self.HandleDragonUIState then
self.HandleDragonUIState()
end
end
end
end)
if not button.DragonUIStateHooks then
button:HookScript('OnMouseDown', function(self)
if self.dragonUIState then
self.dragonUIState.pushed = true
end
if self.HandleDragonUIState then
self.HandleDragonUIState()
end
end)
button:HookScript('OnMouseUp', function(self)
local currentState = IsSpecialMicroButtonActive(self, "EncounterJournal")
if self.dragonUIState then
self.dragonUIState.pushed = currentState
end
if self.HandleDragonUIState then
self.HandleDragonUIState()
end
end)
button.DragonUIStateHooks = true
end
button.HandleDragonUIState()
end
local function SetupPVPButton(button)
-- Mirror the Character button pattern:
-- Instead of fighting WoW's internal NormalTexture alpha management,
@@ -1582,6 +1735,8 @@ local function ApplyMicromenuSystem()
end
local function setupMicroButtons(xOffset)
MICRO_BUTTONS = BuildMicroButtonList()
local buttonxOffset = 0
local useGrayscale = addon.db.profile.micromenu.grayscale_icons
@@ -1705,9 +1860,11 @@ local function ApplyMicromenuSystem()
local isCharacterButton = (buttonName == "Character")
local isPVPButton = (buttonName == "PVP")
local isEncounterJournalButton = (buttonName == "EncounterJournal")
local upCoords = not isCharacterButton and not isPVPButton and GetColoredTextureCoords(name, "Up") or nil
local shouldUseGrayscale = useGrayscale or (not isPVPButton and not upCoords and not isCharacterButton)
local upCoords = not isCharacterButton and not isPVPButton and not isEncounterJournalButton and GetColoredTextureCoords(name, "Up") or nil
local shouldUseGrayscale = (useGrayscale and not isEncounterJournalButton) or
(not isPVPButton and not isEncounterJournalButton and not upCoords and not isCharacterButton)
if shouldUseGrayscale then
-- Grayscale icons
@@ -1741,6 +1898,8 @@ local function ApplyMicromenuSystem()
end
elseif isPVPButton then
SetupPVPButton(button)
elseif isEncounterJournalButton then
SetupEncounterJournalButton(button)
elseif isCharacterButton then
SetupCharacterButton(button)
else
@@ -2245,6 +2404,8 @@ end
return
end
MICRO_BUTTONS = BuildMicroButtonList()
local useGrayscale = addon.db.profile.micromenu.grayscale_icons
local configMode = useGrayscale and "grayscale" or "normal"
local config = addon.db.profile.micromenu[configMode]
@@ -0,0 +1,605 @@
local SHOW_RECOMMENDATIONS = true
local DENSITY = "regular"
local ACCENT = { r = 1.0, g = 0.84, b = 0.42 }
local ROMAN = { "I","II","III","IV","V","VI","VII","VIII","IX","X" }
local T = "Interface\\AddOns\\MoonWellClient\\EncounterJournal\\Textures\\PlayerGuide\\"
local BIOME_TEX = {
fire = T .. "Biome-Fire",
arcane = T .. "Biome-Arcane",
storm = T .. "Biome-Storm",
shadow = T .. "Biome-Shadow",
nature = T .. "Biome-Nature",
holy = T .. "Biome-Holy",
}
local BADGE_TEX = {
active = T .. "StageBadge-Active",
done = T .. "StageBadge-Done",
locked = T .. "StageBadge-Locked",
}
local TYPE_ICON = {
level = T .. "TypeIcon-Level",
quest = T .. "TypeIcon-Quest",
quest_chain = T .. "TypeIcon-QuestChain",
dungeon = T .. "TypeIcon-Dungeon",
raid = T .. "TypeIcon-Raid",
boss = T .. "TypeIcon-Boss",
achievement = T .. "TypeIcon-Achievement",
reputation = T .. "TypeIcon-Reputation",
profession = T .. "TypeIcon-Profession",
item = T .. "TypeIcon-Item",
currency = T .. "TypeIcon-Currency",
custom = T .. "TypeIcon-Custom",
done = T .. "TypeIcon-Done",
locked = T .. "TypeIcon-Locked",
}
local function showOrHide(frame, show)
if not frame then return end
if show then frame:Show() else frame:Hide() end
end
local function getScrollBar(scrollFrame)
if not scrollFrame then return nil end
return scrollFrame.ScrollBar or _G[scrollFrame:GetName() .. "ScrollBar"]
end
local function updateScrollBar(scrollFrame, contentHeight, contentWidth)
if not scrollFrame then return end
local viewHeight = scrollFrame:GetHeight() or 0
local viewWidth = scrollFrame:GetWidth() or 0
local scrollBar = getScrollBar(scrollFrame)
if scrollFrame.ScrollChild and viewWidth > 0 then
scrollFrame.ScrollChild:SetWidth(math.max(1, contentWidth or viewWidth))
end
if scrollBar then
if contentHeight and viewHeight > 0 and contentHeight > viewHeight + 2 then
scrollBar:Show()
else
scrollBar:Hide()
end
end
end
local function scrollFrameOnMouseWheel(scrollFrame, delta)
if not scrollFrame then return end
if ScrollFrameTemplate_OnMouseWheel then
ScrollFrameTemplate_OnMouseWheel(scrollFrame, delta)
return
end
local scrollBar = getScrollBar(scrollFrame)
if scrollBar then
local minValue, maxValue = scrollBar:GetMinMaxValues()
local step = scrollBar:GetValueStep() or 20
scrollBar:SetValue(math.min(maxValue, math.max(minValue, scrollBar:GetValue() - delta * step)))
end
end
local function hideScrollBar(scrollFrame)
local scrollBar = getScrollBar(scrollFrame)
if scrollBar then scrollBar:Hide() end
end
local function getBodyChild()
local body = PlayerGuideFrame and PlayerGuideFrame.BodyScroll
return body and body.ScrollChild
end
local resolveEncounterJournalInstanceID
local function getRecommendationArtwork(data)
if type(data) ~= "table" then return nil end
local artwork = data.artwork or data.cover or data.coverImage or data.buttonImage or data.bgImage or data.icon
if artwork and artwork ~= "" then return artwork end
if not resolveEncounterJournalInstanceID or not EJ_GetInstanceInfo then return nil end
local instanceID = resolveEncounterJournalInstanceID(data)
if not instanceID then return nil end
local ok, _, _, bgImage, buttonImage, loreImage, buttonSmallImage = pcall(EJ_GetInstanceInfo, instanceID)
if ok then
return buttonImage or bgImage or loreImage or buttonSmallImage
end
end
-- ─────────────────────────────────── Цепочка этапов ──
local stageBadges = {}
local function buildStageChain(stages)
if not PlayerGuideFrame then return end
local container = PlayerGuideFrame.StageChain
for i = (#stages + 1), #stageBadges do stageBadges[i]:Hide() end
if #stages == 0 then return end
local width = container:GetWidth()
if width == 0 then width = 600 end
local slot = width / #stages
for i, s in ipairs(stages) do
local badge = stageBadges[i]
if not badge then
badge = CreateFrame("Frame", "PlayerGuideStageBadge"..i, container, "MW_PG_StageBadgeTemplate")
stageBadges[i] = badge
end
badge:ClearAllPoints()
badge:SetPoint("LEFT", container, "LEFT", (i - 0.5) * slot - 19, 0)
badge:Show()
badge.Roman:SetText(ROMAN[s.id] or tostring(s.id))
badge.Label:SetText(s.short or s.name or "")
local status = s.status or "locked"
badge.BadgeBG:SetTexture(BADGE_TEX[status] or BADGE_TEX.locked)
if status == "active" then
badge.Roman:SetTextColor(1.0, 0.91, 0.63)
badge.Label:SetTextColor(1.0, 0.84, 0.42)
elseif status == "done" then
badge.Roman:SetText("v")
badge.Roman:SetTextColor(0.79, 0.65, 0.38)
badge.Label:SetTextColor(0.79, 0.65, 0.38)
else
badge.Roman:SetTextColor(0.40, 0.34, 0.22)
badge.Label:SetTextColor(0.40, 0.34, 0.22)
end
end
end
-- ─────────────────────────────────── Карточки целей ──
local objectiveCards = {}
local CARD_HEIGHT = { compact = 54, regular = 70, cozy = 82 }
local CARD_GAP = { compact = 6, regular = 8, cozy = 10 }
local function renderObjectives()
local section = PlayerGuideFrame.ObjectivesSection
local parent = section.ScrollFrame.ScrollChild
local n = C_PlayerGuide.GetNumObjectives()
local width = 0
if PlayerGuideFrame.BodyScroll then
width = PlayerGuideFrame.BodyScroll:GetWidth()
end
if width == 0 then width = section:GetWidth() end
if width == 0 then width = section.ScrollFrame:GetWidth() end
if width == 0 then width = 860 end
-- Body child right inset 8, section right inset 0, scrollframe left/right insets 6/8.
-- The card then keeps 8px equal visual padding on both sides of the objective row.
parent:SetWidth(math.max(1, width - 30))
for i = (n + 1), #objectiveCards do objectiveCards[i]:Hide() end
local h = CARD_HEIGHT[DENSITY] or 44
local gap = CARD_GAP[DENSITY] or 4
local done = 0
for i = 1, n do
local o = C_PlayerGuide.GetObjectiveInfo(i)
if not o then break end
local card = objectiveCards[i]
if not card then
card = CreateFrame("Frame", "PlayerGuideObjective"..i, parent, "MW_PG_ObjectiveCardTemplate")
objectiveCards[i] = card
end
card:ClearAllPoints()
card:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, -(i - 1) * (h + gap))
card:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, -(i - 1) * (h + gap))
card:SetHeight(h)
card.objectiveData = o
card:Show()
if card.SetBackdropColor then
card:SetBackdropColor(0, 0, 0, 0.72)
card:SetBackdropBorderColor(0.64, 0.45, 0.15, 0.95)
end
card.TypeLabel:SetText(_G["MW_PG_TYPE_" .. string.upper(o.type or "custom")] or string.upper(o.type or ""))
card.Title:SetText(o.title or "")
card.Subtitle:SetText(o.subtitle or o.description or "")
card.ProgressBar:SetMinMaxValues(0, o.required or 1)
card.ProgressBar:SetValue(o.progress or 0)
card.Count:SetText(string.format("%d / %d", o.progress or 0, o.required or 1))
if o.completed then
done = done + 1
card.Icon:SetTexture(TYPE_ICON.done)
card.Icon:SetTexCoord(0, 1, 0, 1)
card.Title:SetTextColor(0.62, 0.84, 0.47)
card.TypeLabel:SetTextColor(0.49, 0.65, 0.35)
card.Count:SetTextColor(0.62, 0.84, 0.47)
card.ProgressBar:SetStatusBarColor(0.55, 0.85, 0.40)
card.IconBorder:SetVertexColor(0.40, 0.65, 0.30, 0.7)
if card.SetBackdropBorderColor then
card:SetBackdropBorderColor(0.26, 0.52, 0.18, 0.9)
end
else
card.Icon:SetTexture(TYPE_ICON[o.type] or TYPE_ICON.custom)
card.Icon:SetTexCoord(0, 1, 0, 1)
card.Title:SetTextColor(1.0, 0.91, 0.63)
card.TypeLabel:SetTextColor(0.63, 0.46, 0.19)
card.Count:SetTextColor(0.79, 0.65, 0.38)
card.ProgressBar:SetStatusBarColor(ACCENT.r, ACCENT.g, ACCENT.b)
card.IconBorder:SetVertexColor(ACCENT.r, ACCENT.g, ACCENT.b, 0.55)
end
if o.required == 1 and (o.progress or 0) == 0 then
card.ProgressBar:Hide()
else
card.ProgressBar:Show()
end
end
parent:SetHeight(math.max(1, n * h + math.max(0, n - 1) * gap))
section:SetHeight(48 + parent:GetHeight())
hideScrollBar(section.ScrollFrame)
section.Count:SetText(string.format(" %d / %d", done, n))
end
-- ─────────────────────────────── Карточки рекомендаций ──
local recCards = {}
local function renderRecommendations()
local section = PlayerGuideFrame.RecommendationsSection
if not SHOW_RECOMMENDATIONS then section:Hide(); return end
local parent = section.ScrollFrame.ScrollChild
local n = C_PlayerGuide.GetNumRecommendations()
if n == 0 then section:Hide(); return end
section:Show()
for i = (n + 1), #recCards do recCards[i]:Hide() end
local cardW = 176
local gap = 16
for i = 1, n do
local r = C_PlayerGuide.GetRecommendationInfo(i)
if not r then break end
local card = recCards[i]
if not card then
card = CreateFrame("Button", "PlayerGuideRec"..i, parent, "MW_PG_RecCardTemplate")
recCards[i] = card
end
card:ClearAllPoints()
card:SetPoint("TOPLEFT", parent, "TOPLEFT", (i - 1) * (cardW + gap), 0)
card.recData = r
card:Show()
if card.SetBackdropColor then
card:SetBackdropColor(0, 0, 0, 0.88)
card:SetBackdropBorderColor(0.64, 0.45, 0.15, 0.95)
end
local artwork = getRecommendationArtwork(r)
card.Artwork:SetTexCoord(0, 0.68359375, 0, 0.7421875)
card.Artwork:SetVertexColor(1, 1, 1, 1)
if not artwork or not card.Artwork:SetTexture(artwork) then
card.Artwork:SetTexture(BIOME_TEX[r.biome] or BIOME_TEX.fire)
end
card.Name:SetText(r.name or r.title or "")
card.Loc:SetText(r.loc or r.description or "")
card.Level:SetText(r.level or "")
end
parent:SetWidth(math.max(1, n * cardW + math.max(0, n - 1) * gap))
parent:SetHeight(116)
section:SetHeight(162)
hideScrollBar(section.ScrollFrame)
section.Count:SetText(" " .. n)
end
local function updateBodyContent()
local F = PlayerGuideFrame
local child = getBodyChild()
if not F or not child then return end
local width = F.BodyScroll:GetWidth()
if width == 0 then width = 860 end
child:SetWidth(math.max(1, width - 8))
local y = 0
F.ObjectivesSection:ClearAllPoints()
F.ObjectivesSection:SetPoint("TOPLEFT", child, "TOPLEFT", 0, y)
F.ObjectivesSection:SetPoint("TOPRIGHT", child, "TOPRIGHT", 0, y)
y = y - F.ObjectivesSection:GetHeight() - 16
if F.RecommendationsSection:IsShown() then
F.RecommendationsSection:ClearAllPoints()
F.RecommendationsSection:SetPoint("TOPLEFT", child, "TOPLEFT", 0, y)
F.RecommendationsSection:SetPoint("TOPRIGHT", child, "TOPRIGHT", 0, y)
y = y - F.RecommendationsSection:GetHeight()
end
local contentHeight = math.max(1, -y + 8)
child:SetHeight(contentHeight)
updateScrollBar(F.BodyScroll, contentHeight, child:GetWidth())
end
-- ─────────────────────────────────────── Layout ──
local function applyLayout(self, hasChain)
local chain = self.StageChain
local hdr = self.Header
local body = self.BodyScroll
chain:ClearAllPoints()
chain:SetPoint("TOPLEFT", self, "TOPLEFT", 28, -8)
chain:SetPoint("TOPRIGHT", self, "TOPRIGHT", -28, -8)
hdr:ClearAllPoints()
if hasChain then
hdr:SetPoint("TOPLEFT", chain, "BOTTOMLEFT", 0, -6)
hdr:SetPoint("TOPRIGHT", chain, "BOTTOMRIGHT", 0, -6)
else
hdr:SetPoint("TOPLEFT", self, "TOPLEFT", 30, -68)
hdr:SetPoint("TOPRIGHT", self, "TOPRIGHT", -30, -68)
end
body:ClearAllPoints()
body:SetPoint("TOPLEFT", hdr, "BOTTOMLEFT", -4, -8)
body:SetPoint("BOTTOMRIGHT", self, "BOTTOMRIGHT", -30, 16)
end
-- ─────────────────────────────────────── Show/hide ──
local function showState(state)
local F = PlayerGuideFrame
local hasData = (state == "data")
-- StageChain visibility controlled in refresh() based on #stages
showOrHide(F.Header, hasData)
showOrHide(F.BodyScroll, hasData)
showOrHide(F.ObjectivesSection, hasData)
showOrHide(F.RecommendationsSection, hasData and SHOW_RECOMMENDATIONS)
showOrHide(F.EmptyState, state == "empty")
showOrHide(F.LoadingState, state == "loading")
if not hasData then showOrHide(F.StageChain, false) end
end
-- ─────────────────────────────────────── Render ──
local function refresh()
if not PlayerGuideFrame then return end
local state = C_PlayerGuide.GetState()
showState(state)
if state ~= "data" then return end
local id, name, desc, progress = C_PlayerGuide.GetStageInfo()
local stages = C_PlayerGuide.GetStages() or {}
local F = PlayerGuideFrame
if #stages > 0 then
F.Header.Pretitle:SetText(string.format(
MW_PLAYER_GUIDE_STAGE_OF or "ЭТАП %d ИЗ %d", id or 0, #stages))
else
F.Header.Pretitle:SetText(string.format("ЭТАП %d", id or 0))
end
F.Header.Title:SetText(name or "")
F.Header.Description:SetText(desc or "")
F.Header.ProgressLabel:SetText(MW_PLAYER_GUIDE_STAGE_PROGRESS or "ПРОГРЕСС ЭТАПА")
F.Header.ProgressPercent:SetText((progress or 0) .. "%")
F.Header.ProgressBar:SetMinMaxValues(0, 100)
F.Header.ProgressBar:SetValue(progress or 0)
local hasChain = #stages > 0
showOrHide(F.StageChain, hasChain)
applyLayout(F, hasChain)
buildStageChain(stages)
updateBodyContent()
renderObjectives()
renderRecommendations()
updateBodyContent()
end
-- ─────────────────────────────────── XML callbacks ──
function MW_PlayerGuide_OnLoad(self)
EncounterJournal.playerGuideFrame = self
self:EnableMouse(true)
self:EnableMouseWheel(true)
local bodyChild = self.BodyScroll and self.BodyScroll.ScrollChild
if bodyChild then
self.ObjectivesSection:SetParent(bodyChild)
self.RecommendationsSection:SetParent(bodyChild)
end
applyLayout(self, false)
local objScrollBar = getScrollBar(self.ObjectivesSection.ScrollFrame)
if objScrollBar then objScrollBar:Hide() end
local recScrollBar = getScrollBar(self.RecommendationsSection.ScrollFrame)
if recScrollBar then recScrollBar:Hide() end
hideScrollBar(self.BodyScroll)
C_PlayerGuide.RegisterListener(function() refresh() end)
end
function MW_PlayerGuide_OnMouseWheel(self, delta)
if self.BodyScroll and self.BodyScroll:IsShown() then
scrollFrameOnMouseWheel(self.BodyScroll, delta)
end
end
function MW_PlayerGuide_OnShow()
if not C_PlayerGuide.HasData() then
C_PlayerGuide.SetLoading()
C_PlayerGuide.RequestRefresh()
end
refresh()
end
function MW_PlayerGuide_RefreshButton_OnClick()
C_PlayerGuide.SetLoading()
C_PlayerGuide.RequestRefresh()
end
local function validEncounterJournalInstanceID(value)
local instanceID = tonumber(value)
if not instanceID or instanceID <= 0 or not EJ_GetInstanceInfo then return nil end
local ok, instanceName = pcall(EJ_GetInstanceInfo, instanceID)
if ok and instanceName then return instanceID end
end
local function mapIDToEncounterJournalInstanceID(value)
local mapID = tonumber(value)
if not mapID or mapID <= 0 then return nil end
if not C_EncounterJournal or not C_EncounterJournal.GetInstanceIDByMapID then return nil end
local ok, instanceID = pcall(C_EncounterJournal.GetInstanceIDByMapID, mapID)
if ok then
return validEncounterJournalInstanceID(instanceID)
end
end
function resolveEncounterJournalInstanceID(data)
if type(data) == "number" or type(data) == "string" then
return validEncounterJournalInstanceID(data) or mapIDToEncounterJournalInstanceID(data)
end
if type(data) ~= "table" then return nil end
local instanceID =
validEncounterJournalInstanceID(data.ej_instanceID) or
validEncounterJournalInstanceID(data.ejInstanceID) or
validEncounterJournalInstanceID(data.journalInstanceID) or
validEncounterJournalInstanceID(data.instanceID) or
validEncounterJournalInstanceID(data.dungeonID) or
validEncounterJournalInstanceID(data.targetID)
if instanceID then return instanceID end
return mapIDToEncounterJournalInstanceID(data.mapID) or
mapIDToEncounterJournalInstanceID(data.worldMapAreaID) or
mapIDToEncounterJournalInstanceID(data.instanceID) or
mapIDToEncounterJournalInstanceID(data.dungeonID) or
mapIDToEncounterJournalInstanceID(data.targetID)
end
local function requestOpenTarget(data)
if type(data) == "table" and data.id and C_PlayerGuide and C_PlayerGuide.RequestOpenTarget then
C_PlayerGuide.RequestOpenTarget(data.id)
end
end
local function openEncounterJournalInstance(data, encounterID)
if not EncounterJournal then return nil end
local instanceID = resolveEncounterJournalInstanceID(data)
if not instanceID then
requestOpenTarget(data)
return nil
end
if type(data) == "table" and not encounterID then
encounterID = data.encounterID
end
if not EncounterJournal:IsShown() then
ShowUIPanel(EncounterJournal)
end
if EncounterJournal.encounter then
EncounterJournal.encounter:Show()
end
if EncounterJournal.instanceSelect then
EJ_ContentTab_SelectAppropriateInstanceTab(instanceID)
end
if NavBar_Reset and EncounterJournal.navBar then
NavBar_Reset(EncounterJournal.navBar)
end
EncounterJournal_DisplayInstance(instanceID)
if encounterID and encounterID > 0 then
EncounterJournal_DisplayEncounter(encounterID)
end
end
function MW_PlayerGuide_Objective_OnClick(self)
local o = self.objectiveData
if not o then return end
if o.type == "dungeon" or o.type == "raid" then
openEncounterJournalInstance(o)
elseif o.type == "boss" then
if o.encounterID and o.encounterID > 0 then
if not EncounterJournal:IsShown() then ShowUIPanel(EncounterJournal) end
EncounterJournal_DisplayEncounter(o.encounterID)
end
elseif o.type == "achievement" then
if o.achievementID then
if AchievementFrame_LoadUI then AchievementFrame_LoadUI() end
if AchievementFrame_SelectAchievement then
AchievementFrame_SelectAchievement(o.achievementID)
end
end
elseif o.type == "item" then
if o.itemID and o.itemID > 0 then
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
GameTooltip:SetItemByID(o.itemID)
GameTooltip:Show()
end
elseif o.type == "quest" or o.type == "quest_chain" then
local qid = o.questID or o.questChainID or o.targetID
if qid and qid > 0 then
pcall(SetItemRef, "quest:" .. qid .. ":-1", "", "LeftButton")
end
else
C_PlayerGuide.RequestOpenTarget(o.id)
end
end
function MW_PlayerGuide_Objective_OnEnter(self)
local o = self.objectiveData
if not o then return end
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
GameTooltip:AddLine(o.title or "", 1, 0.91, 0.63)
if o.subtitle and o.subtitle ~= "" then
GameTooltip:AddLine(o.subtitle, 0.66, 0.59, 0.47, true)
end
if not o.completed then
GameTooltip:AddLine(
string.format("Прогресс: %d / %d", o.progress or 0, o.required or 1),
0.79, 0.65, 0.38)
end
GameTooltip:Show()
end
function MW_PlayerGuide_Objective_OnLeave() GameTooltip:Hide() end
function MW_PlayerGuide_Rec_OnClick(self)
local r = self.recData
if r then openEncounterJournalInstance(r) end
end
function MW_PlayerGuide_Rec_OnEnter(self)
local r = self.recData
if not r then return end
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
GameTooltip:AddLine(r.name or r.title or "", 1, 0.91, 0.63)
if r.loc then GameTooltip:AddLine(r.loc, 0.66, 0.59, 0.47) end
if r.level then GameTooltip:AddLine("Уровень: " .. r.level, 0.79, 0.65, 0.38) end
GameTooltip:Show()
end
function MW_PlayerGuide_Rec_OnLeave() GameTooltip:Hide() end
-- ─────────────────────────────── Экспорт для отладки ──
MW_PlayerGuide = {
Refresh = refresh,
InjectDevData = function() C_PlayerGuide._InjectDevPayload() end,
}

Some files were not shown because too many files have changed in this diff Show More