import { readFileSync, writeFileSync } from 'node:fs'; import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from './lib/data.mjs'; import { prop, method, codeblock, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from './lib/codeblock.mjs'; import { bootMethods } from './cb/boot.mjs'; import { stateMethods } from './cb/state.mjs'; import { soulMethods } from './cb/soul.mjs'; import { charSelectMethods } from './cb/charselect.mjs'; import { runMethods } from './cb/run.mjs'; import { deckTurnMethods } from './cb/deckturn.mjs'; import { deckViewMethods } from './cb/deckview.mjs'; import { handMethods } from './cb/hand.mjs'; import { combatMethods } from './cb/combat.mjs'; import { jobMethods } from './cb/jobs.mjs'; import { runEndMethods } from './cb/runend.mjs'; import { renderMethods } from './cb/render.mjs'; import { rewardMethods } from './cb/reward.mjs'; import { itemMethods } from './cb/items.mjs'; import { tooltipMethods } from './cb/tooltip.mjs'; import { mapMethods } from './cb/map.mjs'; import { shopMethods } from './cb/shop.mjs'; import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, 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'; 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'); } function writeCodeblocks() { const combat = codeblock('SlayDeckController', 'SlayDeckController', [ prop('any', 'DrawPile'), prop('any', 'DiscardPile'), prop('any', 'ExhaustPile'), prop('any', 'Hand'), prop('number', 'Energy', '0'), prop('number', 'MaxEnergy', '3'), prop('number', 'Turn', '0'), prop('number', 'TweenEventId', '0'), prop('number', 'CardHoverTweenId', '0'), prop('number', 'DmgPopSeq', '0'), prop('any', 'EndTurnHandler'), prop('any', 'NewGameHandler'), prop('any', 'WarriorSelectHandler'), prop('any', 'ThiefSelectHandler'), prop('any', 'MageSelectHandler'), prop('any', 'WarriorDeckTabHandler'), prop('any', 'ThiefDeckTabHandler'), prop('any', 'MageDeckTabHandler'), prop('any', 'AscMinusHandler'), prop('any', 'AscPlusHandler'), prop('any', 'JobOpts'), prop('any', 'Jobs'), prop('number', 'AscensionLevel', '0'), prop('number', 'AscensionUnlocked', '0'), prop('any', 'StartGameHandler'), prop('any', 'CharBackHandler'), prop('string', 'SelectedClass', '""'), prop('any', 'DrawPileHandler'), prop('any', 'DiscardPileHandler'), prop('any', 'ExhaustPileHandler'), prop('any', 'DeckInspectCloseHandler'), prop('any', 'AllDeckHandler'), prop('any', 'AllDeckCloseHandler'), prop('number', 'SoulPoints', '0'), prop('boolean', 'LobbyBound', 'false'), prop('number', 'LobbyTpTries', '0'), prop('boolean', 'CodexMode', 'false'), prop('any', 'CodexCards'), prop('boolean', 'ClassDeckMode', 'false'), prop('boolean', 'DebugCardPickerMode', 'false'), prop('boolean', 'DebugCtrlDown', 'false'), prop('boolean', 'DebugShiftDown', 'false'), prop('any', 'ClassDeckCards'), prop('string', 'ClassDeckTitle', '""'), prop('string', 'ClassDeckClass', '""'), prop('any', 'SoulUnlocks'), prop('any', 'SoulShopDef'), prop('boolean', 'SoulShopBound', 'false'), prop('string', 'DeckInspectKind', '""'), prop('boolean', 'DeckAllOpen', 'false'), prop('any', 'Cards'), prop('any', 'CardFrames'), prop('any', 'NodeIcons'), prop('any', 'ClassPortraits'), prop('any', 'ClassToFrame'), prop('number', 'PlayerHp', '0'), prop('number', 'PlayerMaxHp', '80'), prop('number', 'PlayerBlock', '0'), prop('number', 'PlayerDex', '0'), prop('number', 'PlayerThorns', '0'), prop('boolean', 'CombatOver', 'false'), prop('any', 'Monsters'), prop('any', 'Registered'), prop('number', 'TargetIndex', '1'), prop('any', 'RunDeck'), prop('number', 'Gold', '0'), prop('number', 'Floor', '0'), prop('number', 'RunLength', String(RUN_LENGTH)), prop('any', 'RewardChoices'), prop('boolean', 'RunActive', 'false'), prop('any', 'Enemies'), prop('any', 'MapNodes'), prop('any', 'MapStart'), prop('string', 'CurrentNodeId', '""'), prop('string', 'CurrentEnemyId', '""'), prop('any', 'ShopChoices'), prop('any', 'ShopBought'), prop('any', 'Relics'), prop('any', 'RunRelics'), prop('any', 'RelicPool'), prop('string', 'ShopRelic', '""'), prop('boolean', 'ShopRelicBought', 'false'), prop('number', 'DragSlot', '0'), prop('number', 'DragTargetIndex', '0'), prop('boolean', 'FxBusy', 'false'), prop('boolean', 'TurnBusy', 'false'), prop('number', 'PlayerStr', '0'), prop('number', 'PlayerWeak', '0'), prop('number', 'PlayerVuln', '0'), prop('any', 'PlayerPowers'), prop('any', 'Potions'), prop('any', 'RunPotions'), prop('number', 'PotionSlots', String(POTIONS.baseSlots)), prop('string', 'ShopPotion', '""'), prop('boolean', 'ShopPotionBought', 'false'), prop('number', 'FightAttackCount', '0'), prop('boolean', 'FirstHpLossDone', 'false'), prop('number', 'ClayBlockNext', '0'), prop('number', 'PotionMenuSlot', '0'), prop('number', 'Depth', '0'), prop('any', 'VisitedNodes'), prop('boolean', 'ChestOpened', 'false'), prop('string', 'PlayerJob', '""'), prop('number', 'DiscardSelectRemaining', '0'), prop('number', 'DiscardSelectTotal', '0'), prop('number', 'DiscardPostShiv', '0'), prop('number', 'DiscardShivPerPick', '0'), ], [ ...bootMethods, ...stateMethods, ...soulMethods, ...charSelectMethods, ...runMethods, ...deckTurnMethods, ...deckViewMethods, ...handMethods, ...combatMethods, ...jobMethods, ...runEndMethods, ...renderMethods, ...rewardMethods, ...itemMethods, ...tooltipMethods, ...mapMethods, ...shopMethods, ]); for (const m of combat.ContentProto.Json.Methods) { if (m.ExecSpace === 0) m.ExecSpace = 6; // 기본은 ClientOnly(6), 서버 RPC(Server=1·Client=2) 명시값은 보존 } writeFileSync('RootDesk/MyDesk/SlayDeckController.codeblock', JSON.stringify(combat, null, 2), 'utf8'); } function patchCommon() { const common = JSON.parse(readFileSync(COMMON_FILE, 'utf8')); const entity = common.ContentProto.Entities.find((e) => e.path === '/common'); entity.componentNames = 'script.SlayDeckController'; entity.jsonString['@components'] = [ { '@type': 'script.SlayDeckController', Enable: true, Energy: 0, MaxEnergy: 3, Turn: 0, TweenEventId: 0 }, ]; JSON.parse(JSON.stringify(common)); writeFileSync(COMMON_FILE, JSON.stringify(common, null, 2), 'utf8'); } upsertUi(); writeCodeblocks(); patchCommon(); console.log('Slay deck UI and combat codeblocks generated.');