import { readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { CARDS, frameRuid } from '../lib/data.mjs'; import { UI_FILE, isGeneratedUiEntity, DISABLED_STOCK_CONTROLS, uiPath, guid, entity, transform, sprite, button, text, WHITE, TRANSPARENT, CARD_W, CARD_H, cardFaceLayout, UI_APPEND_ORDER, appendUiSection } from '../lib/ui-helpers.mjs'; import { buildDeckHud } from './hud/deckhud.mjs'; import { buildDeckInspect } from './hud/deckinspect.mjs'; import { buildDeckAll } from './hud/deckall.mjs'; import { buildCombat } from './hud/combat.mjs'; import { buildReward } from './hud/reward.mjs'; import { buildMap } from './hud/map.mjs'; import { buildShop } from './hud/shop.mjs'; import { buildRest } from './hud/rest.mjs'; import { buildTreasure } from './hud/treasure.mjs'; import { buildJobChoice } from './hud/jobchoice.mjs'; import { buildJobSelect } from './hud/jobselect.mjs'; import { buildLobby } from './hud/lobby.mjs'; import { buildBoard } from './hud/board.mjs'; import { buildSoulShop } from './hud/soulshop.mjs'; import { buildMainMenu } from './hud/mainmenu.mjs'; // ⚠️ 휴면(LEGACY): 메이커 저작 전환 후 생성기는 .ui를 안 만든다. 이 파일은 옛 DefaultGroup.ui // 단일 저작 로직의 롤백/참조용. import는 무해(함수만 정의), 직접 실행할 때만 .ui를 옛 생성본으로 덮어쓴다. function upsertUi() { const ui = JSON.parse(readFileSync(UI_FILE, 'utf8')); const E = ui.ContentProto.Entities; // CardHand는 스톡 섹션이라 과거 생성된 단색판(NamePlate/CostPlate)이 잔존 → 프레임 이미지 도입으로 제거 const obsoletePlate = /^\/ui\/DefaultGroup\/CardHand\/Card\d+\/(NamePlate|CostPlate)$/; ui.ContentProto.Entities = E.filter((e) => !isGeneratedUiEntity(e) && !obsoletePlate.test(e.path)); const byPath = new Map(ui.ContentProto.Entities.map((e) => [e.path, e])); const uiSections = new Map(); const emit = (section, entities) => { if (uiSections.has(section)) { throw new Error(`[gen-slaydeck] duplicate generated UI section: ${section}`); } uiSections.set(section, entities); }; for (const path of DISABLED_STOCK_CONTROLS.map((name) => uiPath(name))) { const e = byPath.get(path); if (e != null) { e.jsonString.enable = false; e.jsonString.visible = false; for (const component of e.jsonString['@components'] || []) { component.Enable = false; if (component.RaycastTarget != null) component.RaycastTarget = false; } } } // 카드 미리보기(초기 정적 표시 — 런타임 RenderHand가 덮어씀): 카드 종류를 순환해 다양성 표시 const previewIds = Object.keys(CARDS.cards); const cards = Array.from({ length: 10 }, (_, i) => { const c = CARDS.cards[previewIds[i % previewIds.length]]; return { name: c.name, cost: String(c.cost), desc: c.desc, frame: frameRuid(c) }; }); // 손패 슬롯 10개 (최대 손패 한도). Card1~5는 기존 엔티티, Card6~10은 신규 생성. for (let i = 1; i <= 10; i++) { const cardPath = `/ui/DefaultGroup/CardHand/Card${i}`; let card = byPath.get(cardPath); if (!card) { card = entity({ id: guid('dck', 500 + i), path: cardPath, modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.UITouchReceiveComponent', displayOrder: 4, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: CARD_W, y: CARD_H }, pos: { x: 0, y: 0 } }), sprite({ color: WHITE, type: 0, raycast: true }), button(), ], }); ui.ContentProto.Entities.push(card); byPath.set(cardPath, card); } const tr = card.jsonString['@components'].find((c) => c['@type'] === 'MOD.Core.UITransformComponent'); const sp = card.jsonString['@components'].find((c) => c['@type'] === 'MOD.Core.SpriteGUIRendererComponent'); const sx = -680 + (i - 1) * 150; tr.RectSize = { x: CARD_W, y: CARD_H }; tr.anchoredPosition = { x: sx, y: 0 }; tr.OffsetMin = { x: sx - CARD_W / 2, y: -CARD_H / 2 }; tr.OffsetMax = { x: sx + CARD_W / 2, y: CARD_H / 2 }; sp.ImageRUID = { DataId: cards[i - 1].frame }; sp.Type = 0; sp.Color = WHITE; sp.RaycastTarget = true; const comps = card.jsonString['@components']; if (!comps.some((c) => c['@type'] === 'MOD.Core.ButtonComponent')) { comps.push(button()); } if (!card.componentNames.includes('MOD.Core.ButtonComponent')) { card.componentNames += ',MOD.Core.ButtonComponent'; } if (!comps.some((c) => c['@type'] === 'MOD.Core.UITouchReceiveComponent')) { comps.push({ '@type': 'MOD.Core.UITouchReceiveComponent', Enable: true }); } if (!card.componentNames.includes('MOD.Core.UITouchReceiveComponent')) { card.componentNames += ',MOD.Core.UITouchReceiveComponent'; } card.jsonString.enable = true; card.jsonString.visible = true; const handLayout = cardFaceLayout(CARD_W); const previewValues = { Cost: cards[i - 1].cost, Name: cards[i - 1].name, Desc: cards[i - 1].desc }; const children = handLayout.texts.map(([suffix, cfg]) => [suffix, { ...cfg, value: previewValues[suffix] }]); for (const [suffix, cfg] of children) { const path = `/ui/DefaultGroup/CardHand/Card${i}/${suffix}`; const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : 8; let child = byPath.get(path); if (!child) { child = entity({ id: guid('dck', i * 10 + children.findIndex(([s]) => s === suffix)), path, modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: dOrder, components: [ transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }), sprite({ color: TRANSPARENT }), text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold, color: cfg.color, dropShadow: cfg.dropShadow, outlineWidth: cfg.outlineWidth }), ], }); ui.ContentProto.Entities.push(child); byPath.set(path, child); } else { child.id = guid('dck', i * 10 + children.findIndex(([s]) => s === suffix)); child.jsonString.enable = true; child.jsonString.visible = true; child.jsonString.displayOrder = dOrder; const ctr = child.jsonString['@components'].find((c) => c['@type'] === 'MOD.Core.UITransformComponent'); if (ctr) { const pivot = { x: 0.5, y: 0.5 }; ctr.RectSize = cfg.size; ctr.anchoredPosition = cfg.pos; ctr.OffsetMin = { x: cfg.pos.x - pivot.x * cfg.size.x, y: cfg.pos.y - pivot.y * cfg.size.y }; ctr.OffsetMax = { x: cfg.pos.x + (1 - pivot.x) * cfg.size.x, y: cfg.pos.y + (1 - pivot.y) * cfg.size.y }; } child.jsonString['@components'][2].Text = cfg.value; child.jsonString['@components'][2].FontSize = cfg.fontSize; child.jsonString['@components'][2].MaxSize = cfg.fontSize; child.jsonString['@components'][2].FontColor = cfg.color; child.jsonString['@components'][2].Bold = cfg.bold; child.jsonString['@components'][2].DropShadow = cfg.dropShadow === true; child.jsonString['@components'][2].DropShadowDistance = cfg.dropShadow === true ? 18 : 32; child.jsonString['@components'][2].OutlineWidth = cfg.outlineWidth || 1; } } // 프레임 이미지가 이름판·코스트판을 내장하므로 Art만 유지 (잔존 NamePlate/CostPlate는 upsertUi 초입에서 제거) const frameKids = [ ['Art', 5, handLayout.art, WHITE, 0], ]; for (const [suffix, dOrder, cfg, color, spriteType] of frameKids) { const fPath = `/ui/DefaultGroup/CardHand/Card${i}/${suffix}`; let fe = byPath.get(fPath); if (!fe) { fe = entity({ id: guid('dck', 200 + i * 10 + dOrder), path: fPath, modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', displayOrder: dOrder, components: [ transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }), sprite({ color, type: spriteType, raycast: false }), ], }); ui.ContentProto.Entities.push(fe); byPath.set(fPath, fe); } else { const ftr = fe.jsonString['@components'].find((c) => c['@type'] === 'MOD.Core.UITransformComponent'); if (ftr) { ftr.RectSize = cfg.size; ftr.anchoredPosition = cfg.pos; ftr.OffsetMin = { x: cfg.pos.x - cfg.size.x / 2, y: cfg.pos.y - cfg.size.y / 2 }; ftr.OffsetMax = { x: cfg.pos.x + cfg.size.x / 2, y: cfg.pos.y + cfg.size.y / 2 }; } } } } emit('DeckHud', buildDeckHud()); emit('DeckInspectHud', buildDeckInspect()); emit('DeckAllHud', buildDeckAll()); emit('CombatHud', buildCombat()); emit('RewardHud', buildReward()); emit('MapHud', buildMap()); emit('ShopHud', buildShop()); emit('RestHud', buildRest()); // 유물 방 — 보물 상자 (P8) emit('TreasureHud', buildTreasure()); // 전직 선택 (P9) — 보스 보상: 유물 vs 2차 전직 emit('JobChoiceHud', buildJobChoice()); emit('JobSelectHud', buildJobSelect()); emit('MainMenu', buildMainMenu()); // ── LobbyHud — 반복 런의 허브. NPC 클릭으로 런시작/도감/영혼상점/게시판 ── emit('LobbyHud', buildLobby()); // ── BoardHud — 게시판(공지/팁) ── emit('BoardHud', buildBoard()); // ── SoulShopHud — 영혼 메타 상점 (Phase 9에서 해금 항목·구매 로직 채움) ── emit('SoulShopHud', buildSoulShop()); for (const section of UI_APPEND_ORDER) { const entities = uiSections.get(section); if (entities == null) { throw new Error(`[gen-slaydeck] missing generated UI section: ${section}`); } appendUiSection(ui, section, entities); } // 엔티티 id 유일성 검증 — 같은 id가 다른 path에 재배정되면 메이커 refresh 병합이 꼬임 const seenIds = new Map(); for (const e of ui.ContentProto.Entities) { const prev = seenIds.get(e.id); if (prev != null) throw new Error(`[gen-slaydeck] 엔티티 id 중복: ${e.id} (${prev} ↔ ${e.path})`); seenIds.set(e.id, e.path); } JSON.parse(JSON.stringify(ui)); writeFileSync(UI_FILE, JSON.stringify(ui, null, 2), 'utf8'); } // 롤백/참조용 직접 실행 시에만 동작 (import 시에는 실행 안 함) if (process.argv[1] === fileURLToPath(import.meta.url)) { upsertUi(); }