import { readFileSync, writeFileSync } from 'node:fs'; const CARDS = JSON.parse(readFileSync('data/cards.json', 'utf8')); const ENEMIES = JSON.parse(readFileSync('data/enemies.json', 'utf8')); // 검증 (fail-fast): 잘못된 데이터면 생성 중단 for (const id of CARDS.starterDeck) { if (!CARDS.cards[id]) { throw new Error(`[gen-slaydeck] starterDeck에 없는 카드 id 참조: ${id}`); } } if (!ENEMIES.enemies[ENEMIES.activeEnemy]) { throw new Error(`[gen-slaydeck] activeEnemy가 enemies에 없음: ${ENEMIES.activeEnemy}`); } const ACTIVE_ENEMY = ENEMIES.enemies[ENEMIES.activeEnemy]; const MAP = JSON.parse(readFileSync('data/map.json', 'utf8')); for (const id of MAP.start) { if (!MAP.nodes[id]) throw new Error(`[gen-slaydeck] map.start에 없는 노드 id: ${id}`); } for (const [id, n] of Object.entries(MAP.nodes)) { if (n.enemy && !ENEMIES.enemies[n.enemy]) throw new Error(`[gen-slaydeck] 노드 ${id}의 enemy 없음: ${n.enemy}`); for (const nx of n.next) { if (!MAP.nodes[nx]) throw new Error(`[gen-slaydeck] 노드 ${id}.next에 없는 노드 id: ${nx}`); } } const MAX_ROW = Math.max(...Object.values(MAP.nodes).map((n) => n.row)); const RELICS = JSON.parse(readFileSync('data/relics.json', 'utf8')); if (!RELICS.relics[RELICS.startingRelic]) throw new Error(`[gen-slaydeck] startingRelic 없음: ${RELICS.startingRelic}`); for (const id of RELICS.relicPool) { if (!RELICS.relics[id]) throw new Error(`[gen-slaydeck] relicPool에 없는 유물 id: ${id}`); } function luaRelicsTable(relics) { const lines = Object.entries(relics).map(([id, r]) => `\t${id} = { name = ${luaStr(r.name)}, desc = ${luaStr(r.desc)}, hook = ${luaStr(r.hook)}, effect = ${luaStr(r.effect)}, value = ${r.value} },`); return `self.Relics = {\n${lines.join('\n')}\n}`; } function luaIntentsArray(intents) { return '{ ' + intents.map((it) => `{ kind = ${luaStr(it.kind)}, value = ${it.value} }`).join(', ') + ' }'; } function luaEnemiesTable(enemies) { const lines = Object.entries(enemies).map(([id, e]) => `\t${id} = { name = ${luaStr(e.name)}, maxHp = ${e.maxHp}, intents = ${luaIntentsArray(e.intents)} },`); return `self.Enemies = {\n${lines.join('\n')}\n}`; } function luaMapNodesTable(nodes) { const lines = Object.entries(nodes).map(([id, n]) => { const nx = '{ ' + n.next.map(luaStr).join(', ') + ' }'; const enemyField = n.enemy ? `enemy = ${luaStr(n.enemy)}, ` : ''; return `\t${id} = { type = ${luaStr(n.type)}, ${enemyField}row = ${n.row}, col = ${n.col}, next = ${nx} },`; }); return `self.MapNodes = {\n${lines.join('\n')}\n}`; } function luaStartArray(start) { return 'self.MapStart = { ' + start.map(luaStr).join(', ') + ' }'; } // Lua 직렬화 헬퍼 function luaStr(s) { return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; } function luaCardsTable(cards) { const lines = Object.entries(cards).map(([id, c]) => { const fields = [`name = ${luaStr(c.name)}`, `cost = ${c.cost}`, `desc = ${luaStr(c.desc)}`, `kind = ${luaStr(c.kind)}`]; if (c.damage != null) fields.push(`damage = ${c.damage}`); if (c.block != null) fields.push(`block = ${c.block}`); return `\t${id} = { ${fields.join(', ')} },`; }); return `self.Cards = {\n${lines.join('\n')}\n}`; } function luaDeckTable(deck) { return `self.DrawPile = { ${deck.map(luaStr).join(', ')} }`; } function luaIntentsTable(intents) { const lines = intents.map((it) => `\t{ kind = ${luaStr(it.kind)}, value = ${it.value} },`); return `self.EnemyIntents = {\n${lines.join('\n')}\n}`; } function intentText(it) { if (it.kind === 'Attack') return `의도: 공격 ${it.value}`; if (it.kind === 'Defend') return `의도: 방어 ${it.value}`; return ''; } const UI_FILE = 'ui/DefaultGroup.ui'; const COMMON_FILE = 'Global/common.gamelogic'; const TRANSPARENT = { r: 0, g: 0, b: 0, a: 0 }; const DARK = { r: 0.08, g: 0.09, b: 0.11, a: 0.92 }; const GOLD = { r: 0.94, g: 0.74, b: 0.26, a: 1 }; const ATTACK = { r: 0.86, g: 0.42, b: 0.38, a: 1 }; const DEFEND = { r: 0.42, g: 0.55, b: 0.85, a: 1 }; const SKILL = { r: 0.46, g: 0.68, b: 0.52, a: 1 }; const CARD_W = 180; const CARD_H = 250; const CARD_SPACING = 200; const CARD_XS = [-400, -200, 0, 200, 400]; const ALIGN_CENTER = 0; const ALIGN_BOTTOM_CENTER = 6; function guid(prefix, n) { // 유효한 8-4-4-4-12 hex GUID 생성. prefix는 충돌 방지용 네임스페이스 바이트로 매핑. const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : prefix === 'map' ? 0xcd : prefix === 'shp' ? 0xce : prefix === 'rst' ? 0xcf : 0xfe; const v = (ns * 0x100000 + n) >>> 0; return `${v.toString(16).padStart(8, '0')}-0000-4000-8000-${v.toString(16).padStart(12, '0')}`; } function transform({ parentW, parentH, anchor, pivot, size, pos, align = 0 }) { const offMin = { x: pos.x - pivot.x * size.x, y: pos.y - pivot.y * size.y }; const offMax = { x: pos.x + (1 - pivot.x) * size.x, y: pos.y + (1 - pivot.y) * size.y }; return { '@type': 'MOD.Core.UITransformComponent', ActivePlatform: 255, AlignmentOption: align, AnchorsMax: anchor, AnchorsMin: anchor, MobileOnly: false, OffsetMax: offMax, OffsetMin: offMin, Pivot: pivot, RectSize: size, UIMode: 1, UIScale: { x: 1, y: 1, z: 1 }, UIVersion: 2, anchoredPosition: pos, Position: { x: anchor.x * parentW - parentW / 2 + pos.x, y: anchor.y * parentH - parentH / 2 + pos.y, z: 0 }, QuaternionRotation: { x: 0, y: 0, z: 0, w: 1 }, Scale: { x: 1, y: 1, z: 1 }, Enable: true, }; } function sprite({ dataId = '', color = TRANSPARENT, type = 1, raycast = false }) { return { '@type': 'MOD.Core.SpriteGUIRendererComponent', AnimClipPlayType: 0, EndFrameIndex: 2147483647, ImageRUID: { DataId: dataId }, LocalPosition: { x: 0, y: 0 }, LocalScale: { x: 1, y: 1 }, OverrideSorting: false, PlayRate: 1, PreserveSprite: 0, StartFrameIndex: 0, Color: color, DropShadow: false, DropShadowAngle: 30, DropShadowColor: { r: 0, g: 0, b: 0, a: 0.72 }, DropShadowDistance: 32, FillAmount: 1, FillCenter: true, FillClockWise: true, FillMethod: 0, FillOrigin: 0, FlipX: false, FlipY: false, FrameColumn: 1, FrameRate: 0, FrameRow: 1, Outline: false, OutlineColor: { r: 0, g: 0, b: 0, a: 1 }, OutlineWidth: 3, RaycastTarget: raycast, Type: type, Enable: true, }; } function button() { return { '@type': 'MOD.Core.ButtonComponent', Colors: { NormalColor: { r: 1, g: 1, b: 1, a: 1 }, HighlightedColor: { r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1 }, PressedColor: { r: 0.784313738, g: 0.784313738, b: 0.784313738, a: 1 }, SelectedColor: { r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1 }, DisabledColor: { r: 0.784313738, g: 0.784313738, b: 0.784313738, a: 0.5019608 }, ColorMultiplier: 1, FadeDuration: 0.1, }, ImageRUIDs: { HighlightedSprite: null, PressedSprite: null, SelectedSprite: null, DisabledSprite: null, }, KeyCode: 0, OverrideSorting: false, Transition: 1, Enable: true, }; } function text({ value, fontSize, bold = false, color = { r: 1, g: 1, b: 1, a: 1 }, alignment = 4 }) { return { '@type': 'MOD.Core.TextComponent', Alignment: alignment, Bold: bold, DropShadow: false, DropShadowAngle: 30, DropShadowColor: { r: 0, g: 0, b: 0, a: 0.72 }, DropShadowDistance: 32, Font: 0, FontColor: color, FontSize: fontSize, MaxSize: fontSize, MinSize: 8, OutlineColor: { r: 0.08, g: 0.08, b: 0.08, a: 1 }, OutlineDistance: { x: 1, y: -1 }, OutlineWidth: 1, Overflow: 0, OverrideSorting: false, Padding: { left: 0, right: 0, top: 0, bottom: 0 }, SizeFit: false, Text: value, UseOutLine: true, Enable: true, }; } function entity({ id, path, modelId, entryId, componentNames, components, displayOrder }) { const parts = path.split('/'); const name = parts[parts.length - 1]; return { id, path, componentNames, jsonString: { name, path, nameEditable: true, enable: true, visible: true, localize: true, displayOrder, pathConstraints: '/'.repeat(parts.length - 1), revision: 1, origin: { type: 'Model', entry_id: entryId, sub_entity_id: null, root_entity_id: null, replaced_model_id: null, }, modelId, '@components': components, '@version': 1, }, }; } function upsertUi() { const ui = JSON.parse(readFileSync(UI_FILE, 'utf8')); const E = ui.ContentProto.Entities; ui.ContentProto.Entities = E.filter((e) => !e.path.startsWith('/ui/DefaultGroup/DeckHud') && !e.path.startsWith('/ui/DefaultGroup/CombatHud') && !e.path.startsWith('/ui/DefaultGroup/RewardHud') && !e.path.startsWith('/ui/DefaultGroup/MapHud') && !e.path.startsWith('/ui/DefaultGroup/ShopHud') && !e.path.startsWith('/ui/DefaultGroup/RestHud')); const byPath = new Map(ui.ContentProto.Entities.map((e) => [e.path, e])); // 카드 미리보기(초기 정적 표시 — 런타임 RenderHand가 덮어씀): 카드 종류를 순환해 다양성 표시 const previewIds = Object.keys(CARDS.cards); const cards = Array.from({ length: 5 }, (_, i) => { const c = CARDS.cards[previewIds[i % previewIds.length]]; return { name: c.name, cost: String(c.cost), desc: c.desc, tint: c.kind === 'Attack' ? ATTACK : DEFEND }; }); for (let i = 1; i <= 5; i++) { const card = byPath.get(`/ui/DefaultGroup/CardHand/Card${i}`); if (!card) continue; const tr = card.jsonString['@components'].find((c) => c['@type'] === 'MOD.Core.UITransformComponent'); const sp = card.jsonString['@components'].find((c) => c['@type'] === 'MOD.Core.SpriteGUIRendererComponent'); tr.RectSize = { x: CARD_W, y: CARD_H }; tr.anchoredPosition = { x: CARD_XS[i - 1], y: 0 }; tr.OffsetMin = { x: CARD_XS[i - 1] - CARD_W / 2, y: -CARD_H / 2 }; tr.OffsetMax = { x: CARD_XS[i - 1] + CARD_W / 2, y: CARD_H / 2 }; sp.ImageRUID = { DataId: '' }; sp.Type = 1; sp.Color = cards[i - 1].tint; 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'; } card.jsonString.enable = true; card.jsonString.visible = true; const children = [ ['Cost', { size: { x: 50, y: 50 }, pos: { x: -60, y: 95 }, value: cards[i - 1].cost, fontSize: 34, bold: true }], ['Name', { size: { x: 160, y: 50 }, pos: { x: 0, y: 50 }, value: cards[i - 1].name, fontSize: 26, bold: true }], ['Desc', { size: { x: 160, y: 82 }, pos: { x: 0, y: -80 }, value: cards[i - 1].desc, fontSize: 20, bold: false }], ]; for (const [suffix, cfg] of children) { const path = `/ui/DefaultGroup/CardHand/Card${i}/${suffix}`; 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: suffix === 'Cost' ? 0 : suffix === 'Name' ? 1 : 2, 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 }), ], }); 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['@components'][2].Text = cfg.value; child.jsonString['@components'][2].FontSize = cfg.fontSize; child.jsonString['@components'][2].MaxSize = cfg.fontSize; } } } const hud = []; const add = (e) => hud.push(e); add(entity({ id: guid('hud', 0), path: '/ui/DefaultGroup/DeckHud', modelId: 'uiempty', entryId: 'UIEmpty', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', displayOrder: 5, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1280, y: 330 }, pos: { x: 0, y: 180 }, align: ALIGN_BOTTOM_CENTER }), sprite({ color: TRANSPARENT }), ], })); for (const pile of [ { key: 'DrawPile', x: -590, label: '뽑을 덱', count: '10', color: { r: 0.17, g: 0.20, b: 0.25, a: 1 } }, { key: 'DiscardPile', x: 590, label: '버린 덱', count: '0', color: { r: 0.22, g: 0.18, b: 0.16, a: 1 } }, ]) { add(entity({ id: guid('hud', hud.length), path: `/ui/DefaultGroup/DeckHud/${pile.key}`, modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', displayOrder: pile.key === 'DrawPile' ? 0 : 1, components: [ transform({ parentW: 1280, parentH: 330, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 132, y: 186 }, pos: { x: pile.x, y: 8 }, align: ALIGN_CENTER }), sprite({ color: pile.color, type: 1, raycast: true }), ], })); add(entity({ id: guid('hud', hud.length), path: `/ui/DefaultGroup/DeckHud/${pile.key}/Label`, modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 0, components: [ transform({ parentW: 132, parentH: 186, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 120, y: 42 }, pos: { x: 0, y: 45 } }), sprite({ color: TRANSPARENT }), text({ value: pile.label, fontSize: 21, bold: true, color: GOLD }), ], })); add(entity({ id: guid('hud', hud.length), path: `/ui/DefaultGroup/DeckHud/${pile.key}/Count`, modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 1, components: [ transform({ parentW: 132, parentH: 186, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 120, y: 72 }, pos: { x: 0, y: -20 } }), sprite({ color: TRANSPARENT }), text({ value: pile.count, fontSize: 42, bold: true }), ], })); } add(entity({ id: guid('hud', hud.length), path: '/ui/DefaultGroup/DeckHud/EndTurnButton', modelId: 'uibutton', entryId: 'UIButton', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent', displayOrder: 2, components: [ transform({ parentW: 1280, parentH: 330, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 170, y: 58 }, pos: { x: 0, y: 135 }, align: ALIGN_CENTER }), sprite({ color: DARK, type: 1, raycast: true }), button(), text({ value: '턴 종료', fontSize: 25, bold: true, color: GOLD, alignment: 0 }), ], })); add(entity({ id: guid('hud', hud.length), path: '/ui/DefaultGroup/DeckHud/Energy', modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 3, components: [ transform({ parentW: 1280, parentH: 330, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 220, y: 42 }, pos: { x: 0, y: 90 }, align: ALIGN_CENTER }), sprite({ color: TRANSPARENT }), text({ value: '에너지 3/3', fontSize: 24, bold: true, color: { r: 0.6, g: 0.9, b: 1, a: 1 }, alignment: 0 }), ], })); ui.ContentProto.Entities.push(...hud); const PANEL_BG = { r: 0.08, g: 0.09, b: 0.11, a: 0.78 }; const combat = []; combat.push(entity({ id: guid('cmb', 0), path: '/ui/DefaultGroup/CombatHud', modelId: 'uiempty', entryId: 'UIEmpty', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', displayOrder: 4, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }), sprite({ color: TRANSPARENT }), ], })); combat.push(entity({ id: guid('cmb', 1), path: '/ui/DefaultGroup/CombatHud/EnemyBg', modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', displayOrder: 0, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 380, y: 170 }, pos: { x: 0, y: 300 }, align: ALIGN_CENTER }), sprite({ color: PANEL_BG, type: 1 }), ], })); const enemyTexts = [ ['EnemyName', { x: 0, y: 358 }, { x: 360, y: 44 }, ACTIVE_ENEMY.name, 28, true, GOLD], ['EnemyHp', { x: 0, y: 316 }, { x: 360, y: 40 }, `HP ${ACTIVE_ENEMY.maxHp}/${ACTIVE_ENEMY.maxHp}`, 24, true, { r: 1, g: 1, b: 1, a: 1 }], ['EnemyBlock', { x: 0, y: 280 }, { x: 360, y: 36 }, '방어 0', 20, false, { r: 0.6, g: 0.8, b: 1, a: 1 }], ['EnemyIntent', { x: 0, y: 244 }, { x: 360, y: 38 }, intentText(ACTIVE_ENEMY.intents[0]), 22, true, { r: 1, g: 0.72, b: 0.5, a: 1 }], ]; let cmbN = 2; for (const [suffix, pos, size, value, fontSize, bold, color] of enemyTexts) { combat.push(entity({ id: guid('cmb', cmbN++), path: `/ui/DefaultGroup/CombatHud/${suffix}`, modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: enemyTexts.findIndex(([s]) => s === suffix) + 1, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size, pos }), sprite({ color: TRANSPARENT }), text({ value, fontSize, bold, color }), ], })); } combat.push(entity({ id: guid('cmb', cmbN++), path: '/ui/DefaultGroup/CombatHud/PlayerBg', modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', displayOrder: 5, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 300, y: 110 }, pos: { x: -760, y: -260 }, align: ALIGN_CENTER }), sprite({ color: PANEL_BG, type: 1 }), ], })); const playerTexts = [ ['PlayerHp', { x: -760, y: -238 }, { x: 280, y: 44 }, 'HP 80/80', 26, true, { r: 1, g: 1, b: 1, a: 1 }], ['PlayerBlock', { x: -760, y: -284 }, { x: 280, y: 38 }, '방어 0', 22, false, { r: 0.6, g: 0.8, b: 1, a: 1 }], ]; for (const [suffix, pos, size, value, fontSize, bold, color] of playerTexts) { combat.push(entity({ id: guid('cmb', cmbN++), path: `/ui/DefaultGroup/CombatHud/${suffix}`, modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 6 + playerTexts.findIndex(([s]) => s === suffix), components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size, pos }), sprite({ color: TRANSPARENT }), text({ value, fontSize, bold, color }), ], })); } for (const [suffix, pos, value, color] of [ ['Floor', { x: -820, y: 480 }, '층 1/3', GOLD], ['Gold', { x: 820, y: 480 }, '골드 0', { r: 0.98, g: 0.85, b: 0.4, a: 1 }], ]) { combat.push(entity({ id: guid('cmb', cmbN++), path: `/ui/DefaultGroup/CombatHud/${suffix}`, modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 9, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 240, y: 44 }, pos }), sprite({ color: TRANSPARENT }), text({ value, fontSize: 26, bold: true, color, alignment: 4 }), ], })); } combat.push(entity({ id: guid('cmb', cmbN++), path: '/ui/DefaultGroup/CombatHud/Relics', modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 9, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1000, y: 40 }, pos: { x: 0, y: 430 } }), sprite({ color: TRANSPARENT }), text({ value: '유물: 없음', fontSize: 22, bold: true, color: { r: 0.8, g: 0.7, b: 0.95, a: 1 }, alignment: 4 }), ], })); const result = entity({ id: guid('cmb', cmbN++), path: '/ui/DefaultGroup/CombatHud/Result', modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 8, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 140 }, pos: { x: 0, y: 120 } }), sprite({ color: TRANSPARENT }), text({ value: '', fontSize: 64, bold: true, color: GOLD, alignment: 4 }), ], }); result.jsonString.enable = false; combat.push(result); ui.ContentProto.Entities.push(...combat); const reward = []; const rewardHud = entity({ id: guid('rwd', 0), path: '/ui/DefaultGroup/RewardHud', modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', displayOrder: 6, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }), sprite({ color: { r: 0.04, g: 0.05, b: 0.07, a: 0.86 }, type: 1, raycast: true }), ], }); rewardHud.jsonString.enable = false; reward.push(rewardHud); reward.push(entity({ id: guid('rwd', 1), path: '/ui/DefaultGroup/RewardHud/Title', modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 0, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 64 }, pos: { x: 0, y: 300 } }), sprite({ color: TRANSPARENT }), text({ value: '보상 카드 선택', fontSize: 44, bold: true, color: GOLD, alignment: 4 }), ], })); let rwdN = 2; const rewardXs = [-300, 0, 300]; for (let i = 1; i <= 3; i++) { const cardPath = `/ui/DefaultGroup/RewardHud/Reward${i}`; reward.push(entity({ id: guid('rwd', rwdN++), path: cardPath, modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent', displayOrder: i, 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: rewardXs[i - 1], y: 0 } }), sprite({ color: ATTACK, type: 1, raycast: true }), button(), ], })); for (const [suffix, cfg] of [ ['Cost', { size: { x: 50, y: 50 }, pos: { x: -60, y: 95 }, value: '1', fontSize: 34, bold: true }], ['Name', { size: { x: 160, y: 50 }, pos: { x: 0, y: 50 }, value: '카드', fontSize: 26, bold: true }], ['Desc', { size: { x: 160, y: 82 }, pos: { x: 0, y: -80 }, value: '', fontSize: 20, bold: false }], ]) { reward.push(entity({ id: guid('rwd', rwdN++), path: `${cardPath}/${suffix}`, modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: suffix === 'Cost' ? 0 : suffix === 'Name' ? 1 : 2, 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 }), ], })); } } reward.push(entity({ id: guid('rwd', rwdN++), path: '/ui/DefaultGroup/RewardHud/Skip', modelId: 'uibutton', entryId: 'UIButton', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent', displayOrder: 10, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 60 }, pos: { x: 0, y: -260 } }), sprite({ color: DARK, type: 1, raycast: true }), button(), text({ value: '건너뛰기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }), ], })); ui.ContentProto.Entities.push(...reward); const TYPE_KO = { combat: '전투', elite: '엘리트', boss: '보스', shop: '상점', rest: '휴식' }; const map = []; const mapHud = entity({ id: guid('map', 0), path: '/ui/DefaultGroup/MapHud', modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', displayOrder: 7, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }), sprite({ color: { r: 0.05, g: 0.06, b: 0.09, a: 0.9 }, type: 1, raycast: true }), ], }); mapHud.jsonString.enable = false; map.push(mapHud); map.push(entity({ id: guid('map', 1), path: '/ui/DefaultGroup/MapHud/Title', modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 0, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 60 }, pos: { x: 0, y: 510 } }), sprite({ color: TRANSPARENT }), text({ value: '다음 노드 선택', fontSize: 40, bold: true, color: GOLD, alignment: 4 }), ], })); let mapN = 2; for (const [id, node] of Object.entries(MAP.nodes)) { const nodePath = `/ui/DefaultGroup/MapHud/Node_${id}`; const pos = { x: node.col * 180, y: (node.row - (MAX_ROW + 1) / 2) * 140 }; map.push(entity({ id: guid('map', mapN++), path: nodePath, modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent', displayOrder: node.row, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 150, y: 80 }, pos }), sprite({ color: { r: 0.3, g: 0.55, b: 0.85, a: 1 }, type: 1, raycast: true }), button(), ], })); map.push(entity({ id: guid('map', mapN++), path: `${nodePath}/Label`, modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 0, components: [ transform({ parentW: 150, parentH: 80, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 144, y: 72 }, pos: { x: 0, y: 0 } }), sprite({ color: TRANSPARENT }), text({ value: node.enemy ? `${TYPE_KO[node.type]}\n${ENEMIES.enemies[node.enemy].name}` : TYPE_KO[node.type], fontSize: 20, bold: true }), ], })); } ui.ContentProto.Entities.push(...map); const shop = []; const shopHud = entity({ id: guid('shp', 0), path: '/ui/DefaultGroup/ShopHud', modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', displayOrder: 8, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }), sprite({ color: { r: 0.05, g: 0.06, b: 0.09, a: 0.92 }, type: 1, raycast: true }), ], }); shopHud.jsonString.enable = false; shop.push(shopHud); shop.push(entity({ id: guid('shp', 1), path: '/ui/DefaultGroup/ShopHud/Title', modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 0, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 60 }, pos: { x: 0, y: 400 } }), sprite({ color: TRANSPARENT }), text({ value: '상점', fontSize: 44, bold: true, color: GOLD, alignment: 4 }), ], })); shop.push(entity({ id: guid('shp', 2), path: '/ui/DefaultGroup/ShopHud/Gold', modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 1, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 300, y: 44 }, pos: { x: 0, y: 330 } }), sprite({ color: TRANSPARENT }), text({ value: '골드 0', fontSize: 28, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }), ], })); let shpN = 3; const shopXs = [-300, 0, 300]; for (let i = 1; i <= 3; i++) { const cardPath = `/ui/DefaultGroup/ShopHud/Card${i}`; shop.push(entity({ id: guid('shp', shpN++), path: cardPath, modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent', displayOrder: i, 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: shopXs[i - 1], y: 20 } }), sprite({ color: ATTACK, type: 1, raycast: true }), button(), ], })); for (const [suffix, cfg] of [ ['Cost', { size: { x: 50, y: 50 }, pos: { x: -60, y: 95 }, value: '1', fontSize: 34, bold: true, color: { r: 1, g: 1, b: 1, a: 1 } }], ['Name', { size: { x: 160, y: 50 }, pos: { x: 0, y: 50 }, value: '카드', fontSize: 26, bold: true, color: { r: 1, g: 1, b: 1, a: 1 } }], ['Desc', { size: { x: 160, y: 60 }, pos: { x: 0, y: -50 }, value: '', fontSize: 20, bold: false, color: { r: 1, g: 1, b: 1, a: 1 } }], ['Price', { size: { x: 160, y: 40 }, pos: { x: 0, y: -105 }, value: '30 골드', fontSize: 22, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 } }], ]) { shop.push(entity({ id: guid('shp', shpN++), path: `${cardPath}/${suffix}`, modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: suffix === 'Cost' ? 0 : suffix === 'Name' ? 1 : suffix === 'Desc' ? 2 : 3, 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 }), ], })); } } shop.push(entity({ id: guid('shp', shpN++), path: '/ui/DefaultGroup/ShopHud/Relic', modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent', displayOrder: 9, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 560, y: 76 }, pos: { x: 0, y: -190 } }), sprite({ color: { r: 0.7, g: 0.55, b: 0.85, a: 1 }, type: 1, raycast: true }), button(), ], })); shop.push(entity({ id: guid('shp', shpN++), path: '/ui/DefaultGroup/ShopHud/Relic/Label', modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 0, components: [ transform({ parentW: 560, parentH: 76, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 540, y: 40 }, pos: { x: 0, y: 12 } }), sprite({ color: TRANSPARENT }), text({ value: '유물', fontSize: 22, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }), ], })); shop.push(entity({ id: guid('shp', shpN++), path: '/ui/DefaultGroup/ShopHud/Relic/Price', modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 1, components: [ transform({ parentW: 560, parentH: 76, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 540, y: 30 }, pos: { x: 0, y: -22 } }), sprite({ color: TRANSPARENT }), text({ value: '60 골드', fontSize: 20, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }), ], })); shop.push(entity({ id: guid('shp', shpN++), path: '/ui/DefaultGroup/ShopHud/Leave', modelId: 'uibutton', entryId: 'UIButton', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent', displayOrder: 10, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 60 }, pos: { x: 0, y: -300 } }), sprite({ color: DARK, type: 1, raycast: true }), button(), text({ value: '나가기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }), ], })); ui.ContentProto.Entities.push(...shop); const rest = []; const restHud = entity({ id: guid('rst', 0), path: '/ui/DefaultGroup/RestHud', modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', displayOrder: 9, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }), sprite({ color: { r: 0.05, g: 0.08, b: 0.06, a: 0.92 }, type: 1, raycast: true }), ], }); restHud.jsonString.enable = false; rest.push(restHud); rest.push(entity({ id: guid('rst', 1), path: '/ui/DefaultGroup/RestHud/Title', modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 0, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 60 }, pos: { x: 0, y: 140 } }), sprite({ color: TRANSPARENT }), text({ value: '휴식', fontSize: 44, bold: true, color: GOLD, alignment: 4 }), ], })); rest.push(entity({ id: guid('rst', 2), path: '/ui/DefaultGroup/RestHud/Info', modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 1, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 600, y: 50 }, pos: { x: 0, y: 30 } }), sprite({ color: TRANSPARENT }), text({ value: 'HP 회복', fontSize: 30, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }), ], })); rest.push(entity({ id: guid('rst', 3), path: '/ui/DefaultGroup/RestHud/Leave', modelId: 'uibutton', entryId: 'UIButton', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent', displayOrder: 2, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 60 }, pos: { x: 0, y: -120 } }), sprite({ color: DARK, type: 1, raycast: true }), button(), text({ value: '나가기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }), ], })); ui.ContentProto.Entities.push(...rest); JSON.parse(JSON.stringify(ui)); writeFileSync(UI_FILE, JSON.stringify(ui, null, 2), 'utf8'); } function prop(Type, Name, DefaultValue = 'nil') { return { Type, DefaultValue, SyncDirection: 0, Attributes: [], Name }; } function method(Name, Code, Arguments = [], ExecSpace = 0, ReturnType = 'void') { return { Return: { Type: ReturnType, DefaultValue: null, SyncDirection: 0, Attributes: [], Name: null }, Arguments, Code, Scope: 2, ExecSpace, Attributes: [], Name, }; } function codeblock(id, name, properties, methods) { return { Id: '', GameId: '', EntryKey: `codeblock://${id}`, ContentType: 'x-mod/codeblock', Content: '', Usage: 0, UsePublish: 1, UseService: 0, CoreVersion: '26.5.0.0', StudioVersion: '', DynamicLoading: 0, ContentProto: { Use: 'Json', Json: { CoreVersion: { Major: 0, Minor: 2 }, ScriptVersion: { Major: 1, Minor: 0 }, Description: '', Id: id, Language: 1, Name: name, Type: 1, Source: 0, Target: null, Properties: properties, Methods: methods, EntityEventHandlers: [], }, }, }; } function writeCodeblocks() { const RUN_LENGTH = 3; const GOLD_PER_WIN = 15; const CARD_PRICE = 30; const REST_HEAL = 30; const RELIC_PRICE = 60; const ACT_COUNT = 3; const combat = codeblock('SlayDeckController', 'SlayDeckController', [ prop('any', 'DrawPile'), prop('any', 'DiscardPile'), prop('any', 'Hand'), prop('number', 'Energy', '0'), prop('number', 'MaxEnergy', '3'), prop('number', 'Turn', '0'), prop('number', 'TweenEventId', '0'), prop('any', 'EndTurnHandler'), prop('any', 'Cards'), prop('number', 'PlayerHp', '0'), prop('number', 'PlayerMaxHp', '80'), prop('number', 'PlayerBlock', '0'), prop('number', 'EnemyHp', '0'), prop('number', 'EnemyMaxHp', String(ACTIVE_ENEMY.maxHp)), prop('number', 'EnemyBlock', '0'), prop('number', 'EnemyIntentIndex', '1'), prop('boolean', 'CombatOver', 'false'), prop('any', 'EnemyIntents'), prop('any', 'EnemyName'), 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'), ], [ method('OnBeginPlay', `self:StartRun()`), method('StartRun', `self.PlayerMaxHp = 80 self.PlayerHp = self.PlayerMaxHp self.Gold = 0 self.Floor = 1 self.RunLength = ${ACT_COUNT} self.RunDeck = { ${CARDS.starterDeck.map(luaStr).join(', ')} } self.RunActive = true self.RunRelics = {} ${luaRelicsTable(RELICS.relics)} self.RelicPool = { ${RELICS.relicPool.map(luaStr).join(', ')} } ${luaEnemiesTable(ENEMIES.enemies)} ${luaMapNodesTable(MAP.nodes)} ${luaStartArray(MAP.start)} self.CurrentNodeId = "" self.CurrentEnemyId = "" self:BindButtons() self:AddRelic("${RELICS.startingRelic}") self:ShowMap()`), method('StartCombat', `self.MaxEnergy = 3 self.Turn = 0 local enemy = self.Enemies[self.CurrentEnemyId] local mult = 1 + (self.Floor - 1) * 0.6 self.PlayerBlock = 0 self.EnemyName = enemy.name self.EnemyMaxHp = math.floor(enemy.maxHp * mult) self.EnemyHp = self.EnemyMaxHp self.EnemyBlock = 0 self.EnemyIntents = {} for i = 1, #enemy.intents do self.EnemyIntents[i] = { kind = enemy.intents[i].kind, value = math.floor(enemy.intents[i].value * mult) } end self.EnemyIntentIndex = 1 self.CombatOver = false self.DiscardPile = {} self.Hand = {} ${luaCardsTable(CARDS.cards)} self.DrawPile = {} for i = 1, #self.RunDeck do self.DrawPile[i] = self.RunDeck[i] end self:Shuffle(self.DrawPile) self:RenderCombat() self:StartPlayerTurn() self:ApplyRelics("combatStart") self:RenderCombat()`), method('Shuffle', `if list == nil then \treturn end for i = #list, 2, -1 do \tlocal j = math.random(1, i) \tlist[i], list[j] = list[j], list[i] end`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'list' }]), method('BindButtons', `local endTurn = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckHud/EndTurnButton") if endTurn ~= nil and endTurn.ButtonComponent ~= nil then if self.EndTurnHandler ~= nil then endTurn:DisconnectEvent(ButtonClickEvent, self.EndTurnHandler) self.EndTurnHandler = nil end self.EndTurnHandler = endTurn:ConnectEvent(ButtonClickEvent, function() self:EndPlayerTurn() end) end for i = 1, 5 do local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i)) if cardEntity ~= nil and cardEntity.ButtonComponent ~= nil then cardEntity:ConnectEvent(ButtonClickEvent, function() self:PlayCard(i) end) end end for i = 1, 3 do local rc = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud/Reward" .. tostring(i)) if rc ~= nil and rc.ButtonComponent ~= nil then rc:ConnectEvent(ButtonClickEvent, function() self:PickReward(i) end) end end local skip = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud/Skip") if skip ~= nil and skip.ButtonComponent ~= nil then skip:ConnectEvent(ButtonClickEvent, function() self:PickReward(0) end) end local mapNodeIds = { ${Object.keys(MAP.nodes).map(luaStr).join(', ')} } for i = 1, #mapNodeIds do local nid = mapNodeIds[i] local mn = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud/Node_" .. nid) if mn ~= nil and mn.ButtonComponent ~= nil then mn:ConnectEvent(ButtonClickEvent, function() self:PickNode(nid) end) end end for i = 1, 3 do local sc = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Card" .. tostring(i)) if sc ~= nil and sc.ButtonComponent ~= nil then sc:ConnectEvent(ButtonClickEvent, function() self:BuyCard(i) end) end end local shopLeave = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Leave") if shopLeave ~= nil and shopLeave.ButtonComponent ~= nil then shopLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end) end local shopRelic = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Relic") if shopRelic ~= nil and shopRelic.ButtonComponent ~= nil then shopRelic:ConnectEvent(ButtonClickEvent, function() self:BuyRelic() end) end local restLeave = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud/Leave") if restLeave ~= nil and restLeave.ButtonComponent ~= nil then restLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end) end`), method('StartPlayerTurn', `self.Turn = self.Turn + 1 self.Energy = self.MaxEnergy self:ApplyRelics("turnStart") self.PlayerBlock = 0 self:DrawCards(5) self:RenderHand(true) self:RenderCombat()`), method('EndPlayerTurn', `if self.CombatOver == true then return end for i = 1, #self.Hand do \ttable.insert(self.DiscardPile, self.Hand[i]) end self.Hand = {} self:RenderHand(false) self:RenderPiles() self:EnemyTurn() self:CheckCombatEnd() if self.CombatOver == true then return end _TimerService:SetTimerOnce(function() self:StartPlayerTurn() end, 0.45)`), method('DrawCards', `for i = 1, amount do \tif #self.DrawPile <= 0 then \t\tself:RecycleDiscardIntoDraw() \tend \tif #self.DrawPile <= 0 then \t\tbreak \tend \tlocal cardId = table.remove(self.DrawPile) \ttable.insert(self.Hand, cardId) end self:RenderPiles()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]), method('RecycleDiscardIntoDraw', `if self.DiscardPile == nil or #self.DiscardPile <= 0 then \treturn end self.DrawPile = {} for i = 1, #self.DiscardPile do \tself.DrawPile[i] = self.DiscardPile[i] end self.DiscardPile = {} self:Shuffle(self.DrawPile)`), method('RenderPiles', `self:SetText("/ui/DefaultGroup/DeckHud/DrawPile/Count", tostring(#self.DrawPile)) self:SetText("/ui/DefaultGroup/DeckHud/DiscardPile/Count", tostring(#self.DiscardPile)) self:SetText("/ui/DefaultGroup/DeckHud/Energy", "에너지 " .. string.format("%d", self.Energy) .. "/" .. string.format("%d", self.MaxEnergy))`), method('RenderHand', `local drawStart = Vector2(-590, 8) for i = 1, 5 do \tlocal cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i)) \tif cardEntity ~= nil then \t\tlocal cardId = self.Hand[i] \t\tif cardId == nil then \t\t\tcardEntity.Enable = false \t\telse \t\t\tcardEntity.Enable = true \t\t\tself:ApplyCardVisual(i, cardId) \t\t\tif animate == true then \t\t\t\tself:AnimateCardFrom(i, drawStart, Vector2((i - 3) * 200, 0), 0.16 + i * 0.045) \t\t\tend \t\tend \tend end self:RenderPiles()`, [{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'animate' }]), method('ApplyCardVisual', `local c = self.Cards[cardId] if c == nil then c = { name = cardId, cost = 0, desc = "", kind = "Skill" } end self:SetText("/ui/DefaultGroup/CardHand/Card" .. tostring(slot) .. "/Cost", tostring(c.cost)) self:SetText("/ui/DefaultGroup/CardHand/Card" .. tostring(slot) .. "/Name", c.name) self:SetText("/ui/DefaultGroup/CardHand/Card" .. tostring(slot) .. "/Desc", c.desc) local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(slot)) if cardEntity ~= nil and cardEntity.SpriteGUIRendererComponent ~= nil then if c.kind == "Attack" then cardEntity.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1) elseif c.kind == "Skill" then cardEntity.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1) else cardEntity.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1) end end`, [ { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }, { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' }, ]), method('SetText', `local entity = _EntityService:GetEntityByPath(path) if entity ~= nil and entity.TextComponent ~= nil then \tentity.TextComponent.Text = value end`, [ { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' }, { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'value' }, ]), method('AnimateCardFrom', `local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(slot)) if cardEntity == nil or cardEntity.UITransformComponent == nil then \treturn end local tr = cardEntity.UITransformComponent tr.anchoredPosition = fromPos local elapsed = 0 local eventId = 0 eventId = _TimerService:SetTimerRepeat(function() \telapsed = elapsed + 1 / 60 \tlocal t = math.min(elapsed / duration, 1) \tlocal eased = _TweenLogic:Ease(0, 1, 1, EaseType.SineEaseOut, t) \ttr.anchoredPosition = Vector2(fromPos.x + (toPos.x - fromPos.x) * eased, fromPos.y + (toPos.y - fromPos.y) * eased) \tif t >= 1 then \t\t_TimerService:ClearTimer(eventId) \tend end, 1 / 60)`, [ { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }, { Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'fromPos' }, { Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'toPos' }, { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'duration' }, ]), method('PlayCard', `if self.CombatOver == true then return end if self.Hand == nil then return end local cardId = self.Hand[slot] if cardId == nil then return end local c = self.Cards[cardId] if c == nil then return end if self.Energy < c.cost then self:Toast("에너지가 부족합니다") return end self.Energy = self.Energy - c.cost if c.kind == "Attack" then if c.damage ~= nil then self:DealDamageToEnemy(c.damage) end self:ApplyRelics("cardPlayed") elseif c.kind == "Skill" then if c.block ~= nil then self.PlayerBlock = self.PlayerBlock + c.block end end table.remove(self.Hand, slot) table.insert(self.DiscardPile, cardId) self:RenderHand(false) self:RenderPiles() self:RenderCombat() self:CheckCombatEnd()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]), method('Toast', `log(message)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'message' }]), method('DealDamageToEnemy', `local dmg = amount if self.EnemyBlock > 0 then local absorbed = math.min(self.EnemyBlock, dmg) self.EnemyBlock = self.EnemyBlock - absorbed dmg = dmg - absorbed end self.EnemyHp = self.EnemyHp - dmg if self.EnemyHp < 0 then self.EnemyHp = 0 end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]), method('DealDamageToPlayer', `local dmg = amount if self.PlayerBlock > 0 then local absorbed = math.min(self.PlayerBlock, dmg) self.PlayerBlock = self.PlayerBlock - absorbed dmg = dmg - absorbed end self.PlayerHp = self.PlayerHp - dmg if self.PlayerHp < 0 then self.PlayerHp = 0 end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]), method('EnemyTurn', `self.EnemyBlock = 0 local intent = self.EnemyIntents[self.EnemyIntentIndex] if intent ~= nil then if intent.kind == "Attack" then self:DealDamageToPlayer(intent.value) elseif intent.kind == "Defend" then self.EnemyBlock = self.EnemyBlock + intent.value end end self.EnemyIntentIndex = self.EnemyIntentIndex + 1 if self.EnemyIntentIndex > #self.EnemyIntents then self.EnemyIntentIndex = 1 end self:RenderCombat()`), method('CheckCombatEnd', `if self.EnemyHp <= 0 then self.CombatOver = true self.Gold = self.Gold + ${GOLD_PER_WIN} self:ApplyRelics("combatReward") self:RenderRun() local node = self.MapNodes[self.CurrentNodeId] if node ~= nil and node.type == "elite" then self:AddRelic(self.RelicPool[math.random(1, #self.RelicPool)]) end if node ~= nil and node.type == "boss" then if self.Floor < self.RunLength then self.Floor = self.Floor + 1 self.CurrentNodeId = "" self.CurrentEnemyId = "" self:RenderRun() self:ShowMap() else self:ShowResult("런 클리어!") self.RunActive = false end else self:OfferReward() end elseif self.PlayerHp <= 0 then self.CombatOver = true self:ShowResult("패배...") self.RunActive = false end`), method('ShowResult', `self:SetText("/ui/DefaultGroup/CombatHud/Result", text) local entity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/Result") if entity ~= nil then entity.Enable = true end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'text' }]), method('RenderCombat', `self:SetText("/ui/DefaultGroup/CombatHud/EnemyName", self.EnemyName) self:SetText("/ui/DefaultGroup/CombatHud/EnemyHp", "HP " .. string.format("%d", self.EnemyHp) .. "/" .. string.format("%d", self.EnemyMaxHp)) self:SetText("/ui/DefaultGroup/CombatHud/EnemyBlock", "방어 " .. string.format("%d", self.EnemyBlock)) local intent = self.EnemyIntents[self.EnemyIntentIndex] local intentText = "" if intent ~= nil then if intent.kind == "Attack" then intentText = "의도: 공격 " .. tostring(intent.value) elseif intent.kind == "Defend" then intentText = "의도: 방어 " .. tostring(intent.value) end end self:SetText("/ui/DefaultGroup/CombatHud/EnemyIntent", intentText) self:SetText("/ui/DefaultGroup/CombatHud/PlayerHp", "HP " .. string.format("%d", self.PlayerHp) .. "/" .. string.format("%d", self.PlayerMaxHp)) self:SetText("/ui/DefaultGroup/CombatHud/PlayerBlock", "방어 " .. string.format("%d", self.PlayerBlock)) self:RenderRun()`), method('RenderRun', `self:SetText("/ui/DefaultGroup/CombatHud/Floor", "막 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength)) self:SetText("/ui/DefaultGroup/CombatHud/Gold", "골드 " .. string.format("%d", self.Gold))`), method('OfferReward', `local pool = {} for id, _ in pairs(self.Cards) do table.insert(pool, id) end self.RewardChoices = {} for i = 1, 3 do self.RewardChoices[i] = pool[math.random(1, #pool)] self:ApplyRewardVisual(i, self.RewardChoices[i]) end local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud") if hud ~= nil then hud.Enable = true end`), method('ApplyRewardVisual', `local c = self.Cards[cardId] if c == nil then return end local base = "/ui/DefaultGroup/RewardHud/Reward" .. tostring(slot) self:SetText(base .. "/Name", c.name) self:SetText(base .. "/Cost", tostring(c.cost)) self:SetText(base .. "/Desc", c.desc) local e = _EntityService:GetEntityByPath(base) if e ~= nil and e.SpriteGUIRendererComponent ~= nil then if c.kind == "Attack" then e.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1) elseif c.kind == "Skill" then e.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1) else e.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1) end end`, [ { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }, { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' }, ]), method('PickReward', `if self.CombatOver ~= true or self.RunActive ~= true then return end if slot ~= 0 and self.RewardChoices ~= nil then local id = self.RewardChoices[slot] if id ~= nil then table.insert(self.RunDeck, id) end end local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud") if hud ~= nil then hud.Enable = false end self:ShowMap()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]), method('ApplyRelics', `if self.RunRelics == nil then return end for i = 1, #self.RunRelics do local r = self.Relics[self.RunRelics[i]] if r ~= nil and r.hook == hook then if r.effect == "block" then self.PlayerBlock = self.PlayerBlock + r.value elseif r.effect == "energy" then self.Energy = self.Energy + r.value elseif r.effect == "healOnAttack" then self.PlayerHp = self.PlayerHp + r.value if self.PlayerHp > self.PlayerMaxHp then self.PlayerHp = self.PlayerMaxHp end elseif r.effect == "gold" then self.Gold = self.Gold + r.value end end end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hook' }]), method('AddRelic', `if self.RunRelics == nil then self.RunRelics = {} end table.insert(self.RunRelics, id) self:RenderRelics()`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]), method('RenderRelics', `local names = "" if self.RunRelics ~= nil then for i = 1, #self.RunRelics do local r = self.Relics[self.RunRelics[i]] if r ~= nil then if names == "" then names = r.name else names = names .. ", " .. r.name end end end end if names == "" then names = "없음" end self:SetText("/ui/DefaultGroup/CombatHud/Relics", "유물: " .. names)`), method('ShowMap', `self:RenderMap() local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud") if hud ~= nil then hud.Enable = true end`), method('IsReachable', `local list if self.CurrentNodeId == "" then list = self.MapStart else local node = self.MapNodes[self.CurrentNodeId] if node == nil then return false end list = node.next end for i = 1, #list do if list[i] == id then return true end end return false`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }], 0, 'boolean'), method('RenderMap', `for id, node in pairs(self.MapNodes) do local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud/Node_" .. id) if e ~= nil then local reachable = self:IsReachable(id) if e.SpriteGUIRendererComponent ~= nil then if reachable then e.SpriteGUIRendererComponent.Color = Color(0.3, 0.55, 0.85, 1) else e.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6) end end if e.ButtonComponent ~= nil then e.ButtonComponent.Enable = reachable end end end`), method('PickNode', `if self.RunActive ~= true then return end if self:IsReachable(id) ~= true then return end self.CurrentNodeId = id local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud") if hud ~= nil then hud.Enable = false end local node = self.MapNodes[id] if node.type == "shop" then self:ShowShop() elseif node.type == "rest" then self:ShowRest() else self.CurrentEnemyId = node.enemy self:StartCombat() end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]), method('ShowShop', `local pool = {} for cid, _ in pairs(self.Cards) do table.insert(pool, cid) end self.ShopChoices = {} self.ShopBought = { false, false, false } for i = 1, 3 do self.ShopChoices[i] = pool[math.random(1, #pool)] end self.ShopRelic = self.RelicPool[math.random(1, #self.RelicPool)] self.ShopRelicBought = false self:RenderShop() local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud") if hud ~= nil then hud.Enable = true end`), method('RenderShop', `self:SetText("/ui/DefaultGroup/ShopHud/Gold", "골드 " .. string.format("%d", self.Gold)) for i = 1, 3 do local cid = self.ShopChoices[i] local c = self.Cards[cid] local base = "/ui/DefaultGroup/ShopHud/Card" .. tostring(i) if c ~= nil then self:SetText(base .. "/Name", c.name) self:SetText(base .. "/Cost", tostring(c.cost)) self:SetText(base .. "/Desc", c.desc) self:SetText(base .. "/Price", string.format("%d", ${CARD_PRICE}) .. " 골드") local e = _EntityService:GetEntityByPath(base) if e ~= nil and e.SpriteGUIRendererComponent ~= nil then if self.ShopBought[i] == true then e.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6) elseif c.kind == "Attack" then e.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1) elseif c.kind == "Skill" then e.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1) else e.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1) end end end end local rr = self.Relics[self.ShopRelic] if rr ~= nil then self:SetText("/ui/DefaultGroup/ShopHud/Relic/Label", rr.name .. " — " .. rr.desc) self:SetText("/ui/DefaultGroup/ShopHud/Relic/Price", string.format("%d", ${RELIC_PRICE}) .. " 골드") local re = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Relic") if re ~= nil and re.SpriteGUIRendererComponent ~= nil then if self.ShopRelicBought == true then re.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6) else re.SpriteGUIRendererComponent.Color = Color(0.7, 0.55, 0.85, 1) end end end`), method('BuyRelic', `if self.ShopRelicBought == true then return end if self.Gold < ${RELIC_PRICE} then return end self.Gold = self.Gold - ${RELIC_PRICE} self:AddRelic(self.ShopRelic) self.ShopRelicBought = true self:RenderShop() self:RenderRun()`), method('BuyCard', `if self.ShopBought == nil or self.ShopBought[slot] == true then return end if self.Gold < ${CARD_PRICE} then return end self.Gold = self.Gold - ${CARD_PRICE} table.insert(self.RunDeck, self.ShopChoices[slot]) self.ShopBought[slot] = true self:RenderShop() self:RenderRun()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]), method('ShowRest', `local old = self.PlayerHp self.PlayerHp = self.PlayerHp + ${REST_HEAL} if self.PlayerHp > self.PlayerMaxHp then self.PlayerHp = self.PlayerMaxHp end local healed = self.PlayerHp - old self:SetText("/ui/DefaultGroup/RestHud/Info", "HP " .. string.format("%d", old) .. " → " .. string.format("%d", self.PlayerHp) .. " (+" .. string.format("%d", healed) .. ")") self:RenderCombat() local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud") if hud ~= nil then hud.Enable = true end`), method('LeaveNode', `local s = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud") if s ~= nil then s.Enable = false end local r = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud") if r ~= nil then r.Enable = false end self:ShowMap()`), ]); for (const m of combat.ContentProto.Json.Methods) { m.ExecSpace = 6; } 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.');