Бронежилеты (armor packs)¶
Армор-секция админ-панели — про custom-броники: модели и текстуры жилетов на персонажа в GTA. По структуре похожа на gunpack'и, но проще — нет проблем conflicts, нет meta-merge.
Структура¶
erDiagram
armor_packs ||--o{ armor_pieces : contains
armor_packs {
uuid id PK
text name
text author
text cover_url
text rpf_url
}
armor_pieces {
uuid id PK
uuid armor_pack_id FK
text name
text glb_url
text png_url
int display_order
}
Каждый «armor_piece» — это одна модель жилета (level 1 / level 2 / level 3 / fancy). Юзер выбирает один из пака, устанавливает.
Workflow upload¶
HunterGraphics.Shell/Bridge/AppBridge.cs (армор pipeline)
public async Task<ArmorUploadResultDto> AdminArmorUploadAsync(ArmorUploadDraftDto draft)
{
using var temp = new TempDirectoryScope();
await ExtractZipAsync(draft.ZipPath, temp.Path);
// armor.zip содержит:
// ├── manifest.json (list pieces)
// ├── armor_lvl1.ydr
// ├── armor_lvl1.png
// └── ...
var manifest = JsonSerializer.Deserialize<ArmorManifestDto>(
await File.ReadAllTextAsync(Path.Combine(temp.Path, "manifest.json")));
var pieces = new List<ArmorPieceDto>();
foreach (var piece in manifest.Pieces)
{
var glbBytes = await _ydrConverter.ConvertAsync(
Path.Combine(temp.Path, piece.YdrFile));
var glbUrl = await _r2.UploadAsync(
$"armor/{draft.PackId}/{piece.Name}.glb", glbBytes);
// PNG превью либо берём готовое из ZIP, либо рендерим
byte[] pngBytes;
if (File.Exists(Path.Combine(temp.Path, piece.PngFile)))
pngBytes = await File.ReadAllBytesAsync(Path.Combine(temp.Path, piece.PngFile));
else
pngBytes = await _previewRenderer.RenderAsync(glbBytes);
var pngUrl = await _r2.UploadAsync(
$"armor/{draft.PackId}/{piece.Name}.png", pngBytes);
pieces.Add(new ArmorPieceDto(piece.Name, glbUrl, pngUrl, piece.DisplayOrder));
}
// Заливаем .rpf со всеми ассетами для install'а
var rpfUrl = await _r2.UploadAsync(
$"armor/{draft.PackId}/pack.rpf",
Path.Combine(temp.Path, "pack.rpf"));
await _supa.InsertArmorPackAsync(draft.PackId, draft.Name, rpfUrl);
foreach (var p in pieces)
await _supa.InsertArmorPieceAsync(draft.PackId, p);
return new ArmorUploadResultDto(draft.PackId, pieces.Count);
}
Почему проще gunpack'ов¶
- Нет meta-patch'а. Армор не редактирует
weapons.meta— это просто 3D-модель в одномcomponentpeds.ymfслоте. Один armor_pack ставится — выбранный piece становится «moments». - Нет conflicts между паками. Юзер всегда поставит один armor_pack за раз. Старый pack просто replace'ится.
- Single .rpf install. В отличие от gunpack'ов с whitelist'ом, армор всегда ставится целиком — установка простая, без cherry-pick.
Install pipeline¶
ArmorInstallAsync короче gunpack'а:
public async Task<InjectResultDto> ArmorInstallAsync(string packId, string pieceId)
{
using var _mtx = await UpdateRpfMutex.AcquireAsync("armor-install");
var pack = await _supa.GetArmorPackAsync(packId);
var piece = await _supa.GetArmorPieceAsync(pieceId);
// 1. Download pack.rpf в кеш
var rpfPath = await _assetCache.GetOrDownloadAsync(pack.RpfUrl);
// 2. Copy в dlcpacks/hunter_armor/
var dest = Path.Combine(_paths.DlcPacksDir, "hunter_armor", "dlc.rpf");
AtomicCopy(rpfPath, dest);
// 3. Update dlclist.xml — добавляем hunter_armor если его нет
await _dlclistUpdater.EnsureEntryAsync("dlcpacks:/hunter_armor/");
return new InjectResultDto(true, null);
}
3D-preview в UI работает через стандартный glb-viewer. Никаких специальных шейдеров (см. 3D-render history — это касалось ганов).
Что в UI¶
Админ-таблица показывает все армор-паки, кликабельный список piece'ов. Кнопки:
- Создать pack — модал с drop zone для ZIP'а.
- Pack → Edit — поменять name, author, cover.
- Piece → Edit display_order — переставить порядок отображения у юзера.
- Piece → Delete — убрать конкретный piece из пака. Также убирает его из R2.