Cards·시작덱·적 정의를 data/cards.json·data/enemies.json으로 분리, gen-slaydeck가 로드·검증·주입. - data/cards.json: 카드 정의(name/cost/kind/damage|block/desc) + starterDeck - data/enemies.json: 적 정의(name/maxHp/intents) + activeEnemy - 생성기: JSON 로드 + fail-fast 검증(미존재 카드/적 id) + Lua 직렬화 헬퍼 - StartCombat·EnemyMaxHp·카드 미리보기·CombatHud 초기텍스트를 데이터에서 생성 - codeblock 출력은 기존과 동일(순수 리팩터), ui 미리보기는 카드 종류 순환 표시 - 검증: 데이터 1장 수치 변경→재생성 반영 확인, 결정성, fail-fast(exit1), 메이커 Play 정상 - 수치는 임시 placeholder, 추후 메이플 IP대로 카드/적 확장 예정 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
818 lines
30 KiB
JavaScript
818 lines
30 KiB
JavaScript
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];
|
|
|
|
// 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 : 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'));
|
|
|
|
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 }),
|
|
],
|
|
}));
|
|
}
|
|
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);
|
|
|
|
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) {
|
|
return {
|
|
Return: { Type: 'void', 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 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'),
|
|
], [
|
|
method('OnBeginPlay', `self:StartCombat()`),
|
|
method('StartCombat', `self.MaxEnergy = 3
|
|
self.Turn = 0
|
|
self.PlayerMaxHp = 80
|
|
self.PlayerHp = self.PlayerMaxHp
|
|
self.PlayerBlock = 0
|
|
self.EnemyName = ${luaStr(ACTIVE_ENEMY.name)}
|
|
self.EnemyMaxHp = ${ACTIVE_ENEMY.maxHp}
|
|
self.EnemyHp = self.EnemyMaxHp
|
|
self.EnemyBlock = 0
|
|
${luaIntentsTable(ACTIVE_ENEMY.intents)}
|
|
self.EnemyIntentIndex = 1
|
|
self.CombatOver = false
|
|
self.DiscardPile = {}
|
|
self.Hand = {}
|
|
${luaCardsTable(CARDS.cards)}
|
|
${luaDeckTable(CARDS.starterDeck)}
|
|
self:Shuffle(self.DrawPile)
|
|
self:BindButtons()
|
|
self:RenderCombat()
|
|
self:StartPlayerTurn()`),
|
|
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`),
|
|
method('StartPlayerTurn', `self.Turn = self.Turn + 1
|
|
self.Energy = self.MaxEnergy
|
|
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
|
|
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:ShowResult("승리!")
|
|
-- TODO(E): 전투 보상 훅 — 카드 보상/골드/유물 선택 진입점
|
|
elseif self.PlayerHp <= 0 then
|
|
self.CombatOver = true
|
|
self:ShowResult("패배...")
|
|
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))`),
|
|
]);
|
|
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.');
|