feat(card-frames): 커스텀 카드 프레임 — 직업×등급 프레임·보상 가중 추첨 (P13) #50

Merged
gahusb merged 8 commits from feature/p13-card-frames into main 2026-06-13 00:11:02 +09:00
18 changed files with 11576 additions and 65672 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://c357d2daf31a489d95b8fa47e50dd879",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/c357d2daf31a489d95b8fa47e50dd879/639168711224334184",
"upload_hash": "A1639991C7A8CB1025C97E6BE93F618088971DC609F03106C50D8B6EA145F6A2",
"name": "bandit_legend",
"resource_guid": "c357d2daf31a489d95b8fa47e50dd879",
"resource_version": "6a2c16d2cc7e89479f128ffb"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://9487b06867bc46269ed1d855420f457f",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/9487b06867bc46269ed1d855420f457f/639168711224307886",
"upload_hash": "FD9F2140D16EFC7A77F0B09CA230702CA6CA25E3178AAF6ACD63EF07D5C2C83E",
"name": "bandit_normal",
"resource_guid": "9487b06867bc46269ed1d855420f457f",
"resource_version": "6a2c16d23d5de2eb0c7d16a2"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://b3081fb2fb1445fa90b12b01481a78ef",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/b3081fb2fb1445fa90b12b01481a78ef/639168711224396185",
"upload_hash": "1F5218270148F873D060223A617DFC184AEEE61344D26C85705E40EECC086D5E",
"name": "bandit_unique",
"resource_guid": "b3081fb2fb1445fa90b12b01481a78ef",
"resource_version": "6a2c16d21a7908d59b5dc059"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://cff71f2e472041ce80c6fbd296f42e2d",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/cff71f2e472041ce80c6fbd296f42e2d/639168711224422293",
"upload_hash": "040CA63C5D3D52E1661F3A008D229A182B1A7C09D919BB74E23D1305B1AA56A7",
"name": "mage_legend",
"resource_guid": "cff71f2e472041ce80c6fbd296f42e2d",
"resource_version": "6a2c16d2a0766b148f66d799"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://d788d09f6f50467ebc67f01dec45f9e2",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/d788d09f6f50467ebc67f01dec45f9e2/639168711224258598",
"upload_hash": "5D91E6E333564F15A76554AE07402DC0ED39ABC02439F65C2CCB818C71FB6994",
"name": "mage_normal",
"resource_guid": "d788d09f6f50467ebc67f01dec45f9e2",
"resource_version": "6a2c16d2e75b0d4ccdfcee8b"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://f5def2e8022b4e59a17d3c16414034fe",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/f5def2e8022b4e59a17d3c16414034fe/639168711224271729",
"upload_hash": "B07075BE5F13B1D7BFB4AE36F4C47D97A10A3CC7EF763F02DD2A11C2AFC61E67",
"name": "mage_unique",
"resource_guid": "f5def2e8022b4e59a17d3c16414034fe",
"resource_version": "6a2c16d2e75b0d4ccdfcee8a"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://6d741a60c60743cb98ee740a1e2dbfed",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/6d741a60c60743cb98ee740a1e2dbfed/639168711258121433",
"upload_hash": "80E2D8FFC8E56ECE4694BED5A387413F49633841DD37B7AA9FA9AC713962602C",
"name": "warior_legend",
"resource_guid": "6d741a60c60743cb98ee740a1e2dbfed",
"resource_version": "6a2c16d59c3c6c308bfd4bf8"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://4bb57ef88ef449fdaf958f6cf37fe44b",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/4bb57ef88ef449fdaf958f6cf37fe44b/639168711258172386",
"upload_hash": "2832F3B6C4C9530DE69DF061E590FD314D8646BE6BDB65931AFBE68D38DBB0ED",
"name": "warior_normal",
"resource_guid": "4bb57ef88ef449fdaf958f6cf37fe44b",
"resource_version": "6a2c16d53d5de2eb0c7d16a3"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://4f71c124c8bc4e13b5e9fad392995f68",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/4f71c124c8bc4e13b5e9fad392995f68/639168711257524633",
"upload_hash": "F27B2D713455FB18C10E924D381C4582E5E039D5DCCA9CECD2FB0D8E9A4C8135",
"name": "warior_unique",
"resource_guid": "4f71c124c8bc4e13b5e9fad392995f68",
"resource_version": "6a2c16d5a43134e1b8a34b65"
}
}
}

12
data/cardframes.json Normal file
View File

@@ -0,0 +1,12 @@
{
"frames": {
"warrior": { "normal": "4bb57ef88ef449fdaf958f6cf37fe44b", "unique": "4f71c124c8bc4e13b5e9fad392995f68", "legend": "6d741a60c60743cb98ee740a1e2dbfed" },
"magician": { "normal": "d788d09f6f50467ebc67f01dec45f9e2", "unique": "f5def2e8022b4e59a17d3c16414034fe", "legend": "cff71f2e472041ce80c6fbd296f42e2d" },
"bandit": { "normal": "9487b06867bc46269ed1d855420f457f", "unique": "b3081fb2fb1445fa90b12b01481a78ef", "legend": "c357d2daf31a489d95b8fa47e50dd879" }
},
"classToFrame": {
"warrior": "warrior", "fighter": "warrior", "page": "warrior", "spearman": "warrior",
"magician": "magician", "firepoison": "magician", "icelightning": "magician", "cleric": "magician"
},
"rewardWeights": { "normal": 70, "unique": 25, "legend": 5 }
}

View File

@@ -7,7 +7,8 @@
"damage": 6, "damage": 6,
"desc": "피해 6", "desc": "피해 6",
"image": "a71b116807904ef2b38e1dc013e2f9a2", "image": "a71b116807904ef2b38e1dc013e2f9a2",
"class": "warrior" "class": "warrior",
"rarity": "normal"
}, },
"Defend": { "Defend": {
"name": "아이언 바디", "name": "아이언 바디",
@@ -16,7 +17,8 @@
"block": 5, "block": 5,
"desc": "방어도 5", "desc": "방어도 5",
"image": "1ae9b6741c5947a8b528a0f515b50e3e", "image": "1ae9b6741c5947a8b528a0f515b50e3e",
"class": "warrior" "class": "warrior",
"rarity": "normal"
}, },
"Bash": { "Bash": {
"name": "슬래시 블러스트", "name": "슬래시 블러스트",
@@ -25,7 +27,8 @@
"damage": 10, "damage": 10,
"desc": "피해 10", "desc": "피해 10",
"image": "d5bc2953fcab4cfe9062af81c35aff86", "image": "d5bc2953fcab4cfe9062af81c35aff86",
"class": "warrior" "class": "warrior",
"rarity": "normal"
}, },
"WarLeap": { "WarLeap": {
"name": "워 리프", "name": "워 리프",
@@ -35,7 +38,8 @@
"block": 3, "block": 3,
"desc": "피해 4, 방어도 3", "desc": "피해 4, 방어도 3",
"image": "992dabf6aff2400e92b2f4f705d8ebe7", "image": "992dabf6aff2400e92b2f4f705d8ebe7",
"class": "warrior" "class": "warrior",
"rarity": "normal"
}, },
"Brandish": { "Brandish": {
"name": "브랜디시", "name": "브랜디시",
@@ -44,7 +48,8 @@
"damage": 13, "damage": 13,
"desc": "피해 13", "desc": "피해 13",
"image": "21af4bccc5054a5dbc8245dfa7f08681", "image": "21af4bccc5054a5dbc8245dfa7f08681",
"class": "warrior" "class": "warrior",
"rarity": "unique"
}, },
"ChargedBlow": { "ChargedBlow": {
"name": "차지 블로우", "name": "차지 블로우",
@@ -54,7 +59,8 @@
"vuln": 2, "vuln": 2,
"desc": "피해 8, 취약 2", "desc": "피해 8, 취약 2",
"image": "fe83c7635b0e49ed83d75a2833adb53e", "image": "fe83c7635b0e49ed83d75a2833adb53e",
"class": "warrior" "class": "warrior",
"rarity": "unique"
}, },
"Threaten": { "Threaten": {
"name": "위협", "name": "위협",
@@ -63,7 +69,8 @@
"weak": 2, "weak": 2,
"desc": "약화 2 부여", "desc": "약화 2 부여",
"image": "64daadf1a98e490d9c14ef52ec776e63", "image": "64daadf1a98e490d9c14ef52ec776e63",
"class": "warrior" "class": "warrior",
"rarity": "normal"
}, },
"Enrage": { "Enrage": {
"name": "인레이지", "name": "인레이지",
@@ -72,7 +79,8 @@
"strength": 2, "strength": 2,
"desc": "힘 +2", "desc": "힘 +2",
"image": "09370fc7551e47238fd103a80fba558e", "image": "09370fc7551e47238fd103a80fba558e",
"class": "warrior" "class": "warrior",
"rarity": "unique"
}, },
"Rage": { "Rage": {
"name": "분노", "name": "분노",
@@ -82,7 +90,8 @@
"value": 1, "value": 1,
"desc": "매 턴 시작 시 힘 +1", "desc": "매 턴 시작 시 힘 +1",
"image": "379d86e3de064959aa4612f71e84ccfb", "image": "379d86e3de064959aa4612f71e84ccfb",
"class": "warrior" "class": "warrior",
"rarity": "legend"
}, },
"ComboAttack": { "ComboAttack": {
"name": "콤보 어택", "name": "콤보 어택",
@@ -92,7 +101,8 @@
"damage": 5, "damage": 5,
"hits": 2, "hits": 2,
"desc": "피해 5 × 2회", "desc": "피해 5 × 2회",
"image": "1bc3e52b330648faae9eafd5a205e37b" "image": "1bc3e52b330648faae9eafd5a205e37b",
"rarity": "unique"
}, },
"Berserk": { "Berserk": {
"name": "버서크", "name": "버서크",
@@ -103,7 +113,8 @@
"value": 1, "value": 1,
"selfVuln": 1, "selfVuln": 1,
"desc": "매턴 에너지 +1, 취약 1 자가", "desc": "매턴 에너지 +1, 취약 1 자가",
"image": "cef30ea340c74e768bcee4e2cbe0577a" "image": "cef30ea340c74e768bcee4e2cbe0577a",
"rarity": "legend"
}, },
"RisingAttack": { "RisingAttack": {
"name": "라이징 어택", "name": "라이징 어택",
@@ -112,7 +123,8 @@
"class": "fighter", "class": "fighter",
"damage": 12, "damage": 12,
"desc": "피해 12", "desc": "피해 12",
"image": "3a3d4b8bb5bd4137847caf883e4bf38e" "image": "3a3d4b8bb5bd4137847caf883e4bf38e",
"rarity": "unique"
}, },
"ThunderCharge": { "ThunderCharge": {
"name": "썬더 차지", "name": "썬더 차지",
@@ -122,7 +134,8 @@
"damage": 7, "damage": 7,
"weak": 1, "weak": 1,
"desc": "피해 7, 약화 1", "desc": "피해 7, 약화 1",
"image": "f1b7e3041909411eb67af884b446e1e1" "image": "f1b7e3041909411eb67af884b446e1e1",
"rarity": "unique"
}, },
"BlizzardCharge": { "BlizzardCharge": {
"name": "블리자드 차지", "name": "블리자드 차지",
@@ -132,7 +145,8 @@
"damage": 7, "damage": 7,
"vuln": 1, "vuln": 1,
"desc": "피해 7, 취약 1", "desc": "피해 7, 취약 1",
"image": "7915c70952ad432f99519ad79bf929a4" "image": "7915c70952ad432f99519ad79bf929a4",
"rarity": "unique"
}, },
"PowerGuard": { "PowerGuard": {
"name": "파워 가드", "name": "파워 가드",
@@ -141,7 +155,8 @@
"class": "page", "class": "page",
"block": 10, "block": 10,
"desc": "방어도 10", "desc": "방어도 10",
"image": "90a9bf8eeb844b578b4e2d93ac43fedf" "image": "90a9bf8eeb844b578b4e2d93ac43fedf",
"rarity": "unique"
}, },
"Pierce": { "Pierce": {
"name": "피어스", "name": "피어스",
@@ -151,7 +166,8 @@
"damage": 9, "damage": 9,
"pierce": true, "pierce": true,
"desc": "피해 9, 방어 무시", "desc": "피해 9, 방어 무시",
"image": "e312e535a2bc4fed82d36f9c6027c9db" "image": "e312e535a2bc4fed82d36f9c6027c9db",
"rarity": "unique"
}, },
"IronWall": { "IronWall": {
"name": "아이언 월", "name": "아이언 월",
@@ -160,7 +176,8 @@
"class": "spearman", "class": "spearman",
"block": 12, "block": 12,
"desc": "방어도 12", "desc": "방어도 12",
"image": "92021d62341a4bce9cfd09d1b4b865db" "image": "92021d62341a4bce9cfd09d1b4b865db",
"rarity": "unique"
}, },
"HyperBody": { "HyperBody": {
"name": "하이퍼 바디", "name": "하이퍼 바디",
@@ -170,7 +187,8 @@
"powerEffect": "blockPerTurn", "powerEffect": "blockPerTurn",
"value": 3, "value": 3,
"desc": "매턴 방어도 +3", "desc": "매턴 방어도 +3",
"image": "b4020dbadee6401f9893a020fe4154b1" "image": "b4020dbadee6401f9893a020fe4154b1",
"rarity": "legend"
}, },
"EnergyBolt": { "EnergyBolt": {
"name": "에너지 볼트", "name": "에너지 볼트",
@@ -179,7 +197,8 @@
"class": "magician", "class": "magician",
"damage": 6, "damage": 6,
"desc": "피해 6", "desc": "피해 6",
"image": "a1ee3069fce14498b92998542679ae40" "image": "a1ee3069fce14498b92998542679ae40",
"rarity": "normal"
}, },
"MagicGuard": { "MagicGuard": {
"name": "매직 가드", "name": "매직 가드",
@@ -188,7 +207,8 @@
"class": "magician", "class": "magician",
"block": 5, "block": 5,
"desc": "방어도 5", "desc": "방어도 5",
"image": "01b249c26eb34b8aaab774bf221907a1" "image": "01b249c26eb34b8aaab774bf221907a1",
"rarity": "normal"
}, },
"MagicClaw": { "MagicClaw": {
"name": "매직 클로", "name": "매직 클로",
@@ -198,7 +218,8 @@
"damage": 3, "damage": 3,
"hits": 2, "hits": 2,
"desc": "피해 3 × 2회", "desc": "피해 3 × 2회",
"image": "d6e7c04c436f42f19e9806ac5b4401ae" "image": "d6e7c04c436f42f19e9806ac5b4401ae",
"rarity": "normal"
}, },
"Teleport": { "Teleport": {
"name": "텔레포트", "name": "텔레포트",
@@ -208,7 +229,8 @@
"block": 3, "block": 3,
"draw": 1, "draw": 1,
"desc": "방어도 3, 드로 1", "desc": "방어도 3, 드로 1",
"image": "80c98c8e032b4f6c8371a24b4e1d8f14" "image": "80c98c8e032b4f6c8371a24b4e1d8f14",
"rarity": "normal"
}, },
"Slow": { "Slow": {
"name": "슬로우", "name": "슬로우",
@@ -217,7 +239,8 @@
"class": "magician", "class": "magician",
"weak": 2, "weak": 2,
"desc": "약화 2 부여", "desc": "약화 2 부여",
"image": "16f79f571a964430bf1953edc9a14c73" "image": "16f79f571a964430bf1953edc9a14c73",
"rarity": "normal"
}, },
"FireArrow": { "FireArrow": {
"name": "파이어 애로우", "name": "파이어 애로우",
@@ -226,7 +249,8 @@
"class": "firepoison", "class": "firepoison",
"damage": 8, "damage": 8,
"desc": "피해 8", "desc": "피해 8",
"image": "78b9be4e711c440f84fc21e51e812bae" "image": "78b9be4e711c440f84fc21e51e812bae",
"rarity": "unique"
}, },
"PoisonBreath": { "PoisonBreath": {
"name": "포이즌 브레스", "name": "포이즌 브레스",
@@ -235,7 +259,8 @@
"class": "firepoison", "class": "firepoison",
"poison": 4, "poison": 4,
"desc": "독 4 부여", "desc": "독 4 부여",
"image": "b4e8bd7508b54d208e4f2ad7414f8c0a" "image": "b4e8bd7508b54d208e4f2ad7414f8c0a",
"rarity": "unique"
}, },
"ElementAmp": { "ElementAmp": {
"name": "엘레멘트 앰플", "name": "엘레멘트 앰플",
@@ -245,7 +270,8 @@
"powerEffect": "strengthPerTurn", "powerEffect": "strengthPerTurn",
"value": 1, "value": 1,
"desc": "매 턴 힘 +1", "desc": "매 턴 힘 +1",
"image": "9859f3ab41b945f797d56cd83f95b25f" "image": "9859f3ab41b945f797d56cd83f95b25f",
"rarity": "legend"
}, },
"ThunderBolt": { "ThunderBolt": {
"name": "썬더 볼트", "name": "썬더 볼트",
@@ -255,7 +281,8 @@
"damage": 6, "damage": 6,
"aoe": true, "aoe": true,
"desc": "모든 적에게 피해 6", "desc": "모든 적에게 피해 6",
"image": "c6685d33cb2641f09d11cfa2d5cc820c" "image": "c6685d33cb2641f09d11cfa2d5cc820c",
"rarity": "legend"
}, },
"ColdBeam": { "ColdBeam": {
"name": "콜드 빔", "name": "콜드 빔",
@@ -265,7 +292,8 @@
"damage": 7, "damage": 7,
"weak": 2, "weak": 2,
"desc": "피해 7, 약화 2", "desc": "피해 7, 약화 2",
"image": "e8f7c148c79f497d83014e3361f59f5c" "image": "e8f7c148c79f497d83014e3361f59f5c",
"rarity": "unique"
}, },
"ChillingStep": { "ChillingStep": {
"name": "칠링 스텝", "name": "칠링 스텝",
@@ -274,7 +302,8 @@
"class": "icelightning", "class": "icelightning",
"block": 8, "block": 8,
"desc": "방어도 8", "desc": "방어도 8",
"image": "b2a7274d868241c78aa5780f2beecddf" "image": "b2a7274d868241c78aa5780f2beecddf",
"rarity": "unique"
}, },
"Heal": { "Heal": {
"name": "힐", "name": "힐",
@@ -283,7 +312,8 @@
"class": "cleric", "class": "cleric",
"heal": 10, "heal": 10,
"desc": "HP 10 회복", "desc": "HP 10 회복",
"image": "b4127c181e2942e38821d4a9a1f14596" "image": "b4127c181e2942e38821d4a9a1f14596",
"rarity": "unique"
}, },
"Bless": { "Bless": {
"name": "블레스", "name": "블레스",
@@ -293,7 +323,8 @@
"strength": 1, "strength": 1,
"block": 5, "block": 5,
"desc": "힘 +1, 방어도 5", "desc": "힘 +1, 방어도 5",
"image": "d45553db4a414011b67486dfa8a12fe5" "image": "d45553db4a414011b67486dfa8a12fe5",
"rarity": "unique"
}, },
"HolyArrow": { "HolyArrow": {
"name": "홀리 애로우", "name": "홀리 애로우",
@@ -302,7 +333,8 @@
"class": "cleric", "class": "cleric",
"damage": 8, "damage": 8,
"desc": "피해 8", "desc": "피해 8",
"image": "0265e103b4904f178b1c2bdcd54d5975" "image": "0265e103b4904f178b1c2bdcd54d5975",
"rarity": "unique"
} }
}, },
"starterDecks": { "starterDecks": {

View File

@@ -0,0 +1,79 @@
# P13 — 커스텀 카드 프레임 구현 계획
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. 설계: `2026-06-12-card-frames-design.md`
**Goal:** 사용자 제작 프레임 이미지(직업×등급)를 카드 UI 전체에 적용하고 등급을 보상 확률에 반영.
**Architecture:** 단일 소스(`data/*.json` + `gen-slaydeck.mjs`) → 산출물 재생성. 카드 배경 스프라이트를 프레임 ImageRUID로 교체(A안), `ApplyCardFace` 중앙 함수에서 class×rarity 조회.
**Tech Stack:** Node.js 생성기, MSW Lua, node --test.
### Task 1: 리소스 커밋
- [ ] `.sprite` 9종 커밋: `git add RootDesk/MyDesk/*.sprite && git commit -m "feat(card-frames): 카드 프레임 스프라이트 9종 로컬 임포트 (warior·mage·bandit × normal·unique·legend)"`
### Task 2: 데이터 — rarity + cardframes.json
- [ ] `data/cardframes.json` 신설 (설계서 JSON 그대로)
- [ ] `data/cards.json` 32종에 `"rarity"` 추가 (설계서 표 그대로 — node 스크립트로 일괄 주입 권장)
- [ ] 커밋: `feat(card-frames): 카드 등급 배정·프레임 RUID 매핑 데이터`
### Task 3: 생성기 — 프레임 렌더링
- [ ] `CARDFRAMES = JSON.parse(readFileSync('data/cardframes.json'))` 로드, 카드별 검증(throw): rarity ∈ {normal,unique,legend}, class ∈ classToFrame
- [ ] `luaCardsTable`: `fields.push(\`rarity = ${luaStr(c.rarity)}\`)`
- [ ] OnBeginPlay 주입(luaCardsTable 옆): `luaFramesTable()``self.CardFrames = {...}` + `self.ClassToFrame = {...}` / `prop('any','CardFrames')`·`prop('any','ClassToFrame')` 선언
- [ ] `ApplyCardFace` Lua: kind 틴트 분기 → 프레임 적용
```lua
local frames = self.CardFrames[self.ClassToFrame[c.class] or "warrior"]
local ruid = frames ~= nil and frames[c.rarity or "normal"] or nil
if ruid ~= nil then
e.SpriteGUIRendererComponent.ImageRUID = ruid
e.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
end
```
- [ ] `cardFaceLayout(W)` 헬퍼 신설(s=W/180): Cost pos(-68s,103s)/size 44s/font 26s · Name pos(4s,97s)/size(150s,26s)/font 18s · Art pos(0,16s)/size 110s · Desc pos(0,-85s)/size(152s,64s)/font 16s
- [ ] 카드 생성 5곳(upsertUi 손패 ~523 · 조회 ~787 · 전체덱 ~928 · 보상 ~1443 · 상점 ~1660)에 헬퍼 적용, NamePlate/CostPlate 생성 제거, 카드 스프라이트 type 0·흰색·프리뷰 프레임 RUID
- [ ] CardHand 잔존 단색판 제거: upsertUi 초입 필터에 `/ui/DefaultGroup/CardHand/Card\d+/(NamePlate|CostPlate)` 경로 제거 추가
- [ ] 커밋: `feat(card-frames): 생성기 — 프레임 렌더링·레이아웃 통합`
### Task 4: 보상 가중 추첨 (TDD)
- [ ] `tools/balance/sim-balance.test.mjs`에 실패 테스트: `rarityForRoll(70)==='normal'`, `(71)==='unique'`, `(95)==='unique'`, `(96)==='legend'` → 실행해 FAIL 확인
- [ ] `tools/balance/sim-balance.mjs`: `export function rarityForRoll(roll){ if (roll > 95) return 'legend'; if (roll > 70) return 'unique'; return 'normal'; }` → PASS 확인
- [ ] `OfferReward` Lua 교체:
```lua
local pool = self:CardPool()
local byRarity = {}
for _, id in ipairs(pool) do
local r = self.Cards[id].rarity or "normal"
if byRarity[r] == nil then byRarity[r] = {} end
table.insert(byRarity[r], id)
end
self.RewardChoices = {}
for i = 1, 3 do
local roll = math.random(1, 100)
local want = "normal"
if roll > 95 then want = "legend" elseif roll > 70 then want = "unique" end
local bucket = byRarity[want]
if bucket == nil or #bucket == 0 then bucket = pool end
self.RewardChoices[i] = bucket[math.random(1, #bucket)]
self:ApplyRewardVisual(i, self.RewardChoices[i])
end
```
- [ ] 커밋: `feat(card-frames): 보상 등급 가중 추첨 70/25/5 (+JS 미러 테스트)`
### Task 5: 재생성·검증·산출물 커밋
- [ ] `node tools/deck/gen-slaydeck.mjs``grep -c "CardFrames" RootDesk/MyDesk/SlayDeckController.codeblock` ≥1, `grep -c "4bb57ef88ef449fdaf958f6cf37fe44b" ui/DefaultGroup.ui` ≥1
- [ ] `node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs` 전건 통과
- [ ] 커밋: `feat(card-frames): 산출물 재생성`
### Task 6: 메이커 검증·튜닝
- [ ] maker_refresh_workspace → 빌드 콘솔 0에러 → 플레이: 손패 프레임·등급 구분, `_ResourceService` 로드 확인, 보상·덱 조회 스크린샷
- [ ] 텍스트/아트 위치 어긋나면 `cardFaceLayout` 수치 조정 → 재생성 → 재확인 (수정 시 커밋)
### Task 7: PR·머지·메모리
- [ ] push → `node tools/git/gitea-pr.mjs create <spec.json>` → merge → main pull → 메모리 갱신 (slaymaple-build-status에 P13 추가)
## Self-Review
- 설계 전 항목에 대응 Task 존재 ✓ / 코드 블록 placeholder 없음 ✓ / CardFrames·ClassToFrame·rarityForRoll 명칭 일관 ✓ / maker_save 덮어쓰기 주의(설계서 '주의' 절) Task 6에서 refresh만 사용 ✓

View File

@@ -0,0 +1,81 @@
# P13 — 커스텀 카드 프레임 설계
날짜: 2026-06-12 (사용자 승인 완료)
브랜치: `feature/p13-card-frames`
## 범위
사용자 제작 카드 프레임 이미지(직업 3종 × 등급 3종)를 인게임 카드 UI 전체(손패·보상·상점·덱 조회)에 적용한다. 카드에 등급(rarity)을 도입하고 전투 보상 추첨 확률에 반영한다.
## 리소스 (임포트 완료 — RUID 수확됨)
원본: `C:\Users\jaeoh\Desktop\workspace\source\images\maple\card\*.png` (263×366, 카드 비율 180×250과 동일한 0.72)
메이커 로컬 임포트 → `RootDesk/MyDesk/<name>.sprite` 디스크립터 9종 (커밋 대상).
| 프레임 | normal | unique | legend |
|---|---|---|---|
| warior | `4bb57ef88ef449fdaf958f6cf37fe44b` | `4f71c124c8bc4e13b5e9fad392995f68` | `6d741a60c60743cb98ee740a1e2dbfed` |
| mage | `d788d09f6f50467ebc67f01dec45f9e2` | `f5def2e8022b4e59a17d3c16414034fe` | `cff71f2e472041ce80c6fbd296f42e2d` |
| bandit | `9487b06867bc46269ed1d855420f457f` | `b3081fb2fb1445fa90b12b01481a78ef` | `c357d2daf31a489d95b8fa47e50dd879` |
bandit은 RUID 등록만 하고 보류 (도적 클래스 추가 시 사용).
프레임 슬롯 구조: 좌상단 육각 코스트 · 상단 이름 배너 · 중앙 아트 영역 · 하단 설명 박스.
## 데이터
### `data/cardframes.json` (신설)
```json
{
"frames": {
"warrior": { "normal": "4bb57ef88ef449fdaf958f6cf37fe44b", "unique": "4f71c124c8bc4e13b5e9fad392995f68", "legend": "6d741a60c60743cb98ee740a1e2dbfed" },
"magician": { "normal": "d788d09f6f50467ebc67f01dec45f9e2", "unique": "f5def2e8022b4e59a17d3c16414034fe", "legend": "cff71f2e472041ce80c6fbd296f42e2d" },
"bandit": { "normal": "9487b06867bc46269ed1d855420f457f", "unique": "b3081fb2fb1445fa90b12b01481a78ef", "legend": "c357d2daf31a489d95b8fa47e50dd879" }
},
"classToFrame": {
"warrior": "warrior", "fighter": "warrior", "page": "warrior", "spearman": "warrior",
"magician": "magician", "firepoison": "magician", "icelightning": "magician", "cleric": "magician"
},
"rewardWeights": { "normal": 70, "unique": 25, "legend": 5 }
}
```
### `data/cards.json` — 전 카드에 `rarity` 추가
| 등급 | 카드 (32종) |
|---|---|
| normal (10) | Strike, Defend, Bash, WarLeap, Threaten, EnergyBolt, MagicGuard, MagicClaw, Teleport, Slow |
| unique (17) | Brandish, ChargedBlow, Enrage, ComboAttack, RisingAttack, ThunderCharge, BlizzardCharge, PowerGuard, Pierce, IronWall, FireArrow, PoisonBreath, ColdBeam, ChillingStep, Heal, Bless, HolyArrow |
| legend (5) | Rage, Berserk, HyperBody, ElementAmp, ThunderBolt |
기준: 시작 덱·기본기 = normal / 강화·2차 전직 주력기 = unique / 파워 카드·전체 공격 = legend.
생성기 검증: `rarity` 누락 또는 normal|unique|legend 외 값이면 throw. 카드 class가 `classToFrame`에 없으면 throw.
## 렌더링 (생성기 — A안: 카드 배경 교체)
- 카드 루트 스프라이트: 단색 틴트(kind별) → 프레임 `ImageRUID`(Type 0, 흰색). NamePlate/CostPlate 단색판 제거 — RewardHud 등 생성 섹션은 생성 중단으로 충분, **CardHand는 .ui에 잔존하므로 upsert 시 경로 매칭으로 명시 제거**.
- `ApplyCardFace`(Lua): kind 틴트 분기 제거 → `self.CardFrames[self.ClassToFrame[c.class]][c.rarity]` 적용. `CardFrames`/`ClassToFrame`는 OnBeginPlay에서 Lua 테이블 주입 + `prop('any', …)` 선언(LIA 1114 예방).
- 자식 레이아웃 공용 헬퍼 `cardFaceLayout(W)` 신설 — 중복 5곳(손패 523·조회 787·전체덱 928·보상 1443·상점 1660 부근) 일괄 적용. 180×250 기준값(스케일 s=W/180):
- Cost: pos(-68, 103)·size 44·font 26 (현 위치와 거의 일치)
- Name: pos(4, 97)·size 150×26·font 18 — 상단 배너로 이동
- Art: pos(0, 16)·size 110 — 중앙 아트 영역 확대
- Desc: pos(0, -85)·size 152×64·font 16 — 하단 박스
- 초깃값이며 메이커 스크린샷으로 미세 튜닝.
- 정적 프리뷰(Card1~5)도 동일 프레임 적용.
## 보상 가중 추첨
`OfferReward`(Lua): 풀을 rarity 버킷으로 분류 후 1~100 롤 — ≤70 normal / ≤95 unique / >95 legend. 해당 버킷이 비면 전체 풀 폴백. 상점·전투 계산은 변경 없음 (sim-balance 전투 미러 무관).
JS 미러: `tools/balance/sim-balance.mjs``rarityForRoll(roll)` export + 경계 테스트(70/71/95/96).
## 검증
재생성 → `grep -c` 카운트(CardFrames·rarity) → 기존 테스트 40건 + 신규 통과 → 메이커 refresh·빌드 0에러 → 플레이 스크린샷(손패 프레임·등급 색 구분·보상·덱 조회) → 텍스트 위치 튜닝.
## 주의 (이번 세션 실측)
- maker_save 시 메이커가 산출물을 재직렬화(0→0.0 등)하고 `Mislocated/`로 엔티티를 옮길 수 있음 → 임포트 후 `.sprite`만 남기고 산출물은 `git restore`로 복원했음. 재발 시 동일 절차.
- sprite RUID는 map01.map에 등록되지 않고 `.sprite` 디스크립터 자체가 등록 메커니즘.

View File

@@ -29,6 +29,13 @@ export function shuffle(arr, rng) {
// 공격 피해 공식 — Lua CalcPlayerAttack(힘·약화) + DealDamageToTarget(취약)과 동기화. // 공격 피해 공식 — Lua CalcPlayerAttack(힘·약화) + DealDamageToTarget(취약)과 동기화.
// floor((base + str) * (weak>0 ? 0.75 : 1)) → floor(... * (vulnOnTarget>0 ? 1.5 : 1)) // floor((base + str) * (weak>0 ? 0.75 : 1)) → floor(... * (vulnOnTarget>0 ? 1.5 : 1))
// 보상 카드 등급 추첨 (Lua OfferReward 미러) — roll ∈ 1..100, normal 70 / unique 25 / legend 5
export function rarityForRoll(roll) {
if (roll > 95) return 'legend';
if (roll > 70) return 'unique';
return 'normal';
}
export function calcAttack(base, str, weak, vulnOnTarget) { export function calcAttack(base, str, weak, vulnOnTarget) {
let dmg = base + str; let dmg = base + str;
if (weak > 0) dmg = Math.floor(dmg * 0.75); if (weak > 0) dmg = Math.floor(dmg * 0.75);

View File

@@ -1,9 +1,18 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch, calcAttack, mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch, calcAttack, rarityForRoll,
} from './sim-balance.mjs'; } from './sim-balance.mjs';
test('rarityForRoll: 70/25/5 경계 (Lua OfferReward 미러)', () => {
assert.equal(rarityForRoll(1), 'normal');
assert.equal(rarityForRoll(70), 'normal');
assert.equal(rarityForRoll(71), 'unique');
assert.equal(rarityForRoll(95), 'unique');
assert.equal(rarityForRoll(96), 'legend');
assert.equal(rarityForRoll(100), 'legend');
});
test('applyDamage: 방어 우선 차감 후 hp', () => { test('applyDamage: 방어 우선 차감 후 hp', () => {
assert.deepEqual(applyDamage(80, 0, 10), { hp: 70, block: 0 }); assert.deepEqual(applyDamage(80, 0, 10), { hp: 70, block: 0 });
assert.deepEqual(applyDamage(80, 5, 10), { hp: 75, block: 0 }); assert.deepEqual(applyDamage(80, 5, 10), { hp: 75, block: 0 });

View File

@@ -36,6 +36,29 @@ if (!ENEMIES.enemies[ENEMIES.activeEnemy]) {
throw new Error(`[gen-slaydeck] activeEnemy가 enemies에 없음: ${ENEMIES.activeEnemy}`); throw new Error(`[gen-slaydeck] activeEnemy가 enemies에 없음: ${ENEMIES.activeEnemy}`);
} }
// 카드 프레임 (사용자 제작 이미지 — 로컬 임포트 .sprite RUID, 직업 3종 × 등급 3종)
const CARDFRAMES = JSON.parse(readFileSync('data/cardframes.json', 'utf8'));
const RARITIES = ['normal', 'unique', 'legend'];
for (const [fid, fr] of Object.entries(CARDFRAMES.frames)) {
for (const r of RARITIES) {
if (!fr[r]) throw new Error(`[gen-slaydeck] cardframes.frames.${fid}.${r} RUID 없음`);
}
}
for (const [id, c] of Object.entries(CARDS.cards)) {
if (!RARITIES.includes(c.rarity)) throw new Error(`[gen-slaydeck] 카드 ${id} rarity 누락/오류: ${c.rarity}`);
const fc = CARDFRAMES.classToFrame[c.class];
if (!fc || !CARDFRAMES.frames[fc]) throw new Error(`[gen-slaydeck] 카드 ${id} class ${c.class} → 프레임 매핑 없음`);
}
function frameRuid(card) {
return CARDFRAMES.frames[CARDFRAMES.classToFrame[card.class]][card.rarity];
}
function luaFramesTable() {
const frames = Object.entries(CARDFRAMES.frames).map(([fid, fr]) =>
`\t${fid} = { normal = ${luaStr(fr.normal)}, unique = ${luaStr(fr.unique)}, legend = ${luaStr(fr.legend)} },`).join('\n');
const cls = Object.entries(CARDFRAMES.classToFrame).map(([c, f]) => `\t${c} = ${luaStr(f)},`).join('\n');
return `self.CardFrames = {\n${frames}\n}\nself.ClassToFrame = {\n${cls}\n}`;
}
// 맵은 런타임 절차 생성(GenerateMap Lua ↔ tools/map/rogue-map.mjs 미러). 정적 data/map.json 제거됨. // 맵은 런타임 절차 생성(GenerateMap Lua ↔ tools/map/rogue-map.mjs 미러). 정적 data/map.json 제거됨.
const MAP_ROWS = 7; // 걷는 행 1..7, 보스 row 8 const MAP_ROWS = 7; // 걷는 행 1..7, 보스 row 8
const MAP_COLS = 4; const MAP_COLS = 4;
@@ -100,6 +123,7 @@ function luaCardsTable(cards) {
if (c.value != null) fields.push(`value = ${c.value}`); if (c.value != null) fields.push(`value = ${c.value}`);
if (!c.class) throw new Error(`[gen-slaydeck] 카드 ${id}에 class 누락`); if (!c.class) throw new Error(`[gen-slaydeck] 카드 ${id}에 class 누락`);
fields.push(`class = ${luaStr(c.class)}`); fields.push(`class = ${luaStr(c.class)}`);
fields.push(`rarity = ${luaStr(c.rarity)}`);
if (c.hits != null) fields.push(`hits = ${c.hits}`); if (c.hits != null) fields.push(`hits = ${c.hits}`);
if (c.pierce === true) fields.push('pierce = true'); if (c.pierce === true) fields.push('pierce = true');
if (c.selfVuln != null) fields.push(`selfVuln = ${c.selfVuln}`); if (c.selfVuln != null) fields.push(`selfVuln = ${c.selfVuln}`);
@@ -162,6 +186,21 @@ const MAX_MONSTERS = 4;
const HEAD_OFFSET_Y = 1.4; // 몬스터 월드 원점 위로 띄울 높이(머리 위) — world→screen 변환 전 가산 const HEAD_OFFSET_Y = 1.4; // 몬스터 월드 원점 위로 띄울 높이(머리 위) — world→screen 변환 전 가산
const HP_BAR_W = 140; const HP_BAR_W = 140;
const WHITE = { r: 1, g: 1, b: 1, a: 1 };
const INK = { r: 0.13, g: 0.11, b: 0.09, a: 1 }; // 밝은 배너·설명 박스 위 먹색 글자
// 카드 프레임(263×366 원본, 0.72 비율) 슬롯 레이아웃 — 180×250 기준값을 폭 비례 스케일
function cardFaceLayout(W) {
const s = W / 180;
const r = (v) => Math.round(v * s);
return {
texts: [
['Cost', { size: { x: r(44), y: r(44) }, pos: { x: r(-68), y: r(103) }, fontSize: r(26), bold: true, color: WHITE }],
['Name', { size: { x: r(150), y: r(26) }, pos: { x: r(4), y: r(97) }, fontSize: r(18), bold: true, color: INK }],
['Desc', { size: { x: r(152), y: r(64) }, pos: { x: 0, y: r(-85) }, fontSize: r(16), bold: false, color: INK }],
],
art: { size: { x: r(110), y: r(110) }, pos: { x: 0, y: r(16) } },
};
}
const CARD_W = 180; const CARD_W = 180;
const CARD_H = 250; const CARD_H = 250;
const CARD_SPACING = 200; const CARD_SPACING = 200;
@@ -172,7 +211,8 @@ const ALIGN_BOTTOM_CENTER = 6;
function guid(prefix, n) { function guid(prefix, n) {
// 유효한 8-4-4-4-12 hex GUID 생성. prefix는 충돌 방지용 네임스페이스 바이트로 매핑. // 유효한 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 : prefix === 'menu' ? 0xe0 : prefix === 'ins' ? 0xe1 : prefix === 'all' ? 0xe2 : prefix === 'trs' ? 0xe3 : prefix === 'job' ? 0xe4 : 0xfe; const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : prefix === 'map' ? 0xcd : prefix === 'shp' ? 0xce : prefix === 'rst' ? 0xcf : prefix === 'menu' ? 0xe0 : prefix === 'ins' ? 0xe1 : prefix === 'all' ? 0xe2 : prefix === 'trs' ? 0xe3 : prefix === 'job' ? 0xe4
: prefix === 'ins2' ? 0xe5 : prefix === 'all2' ? 0xe6 : prefix === 'rwd2' ? 0xe7 : prefix === 'shp2' ? 0xe8 : 0xfe;
const v = (ns * 0x100000 + n) >>> 0; const v = (ns * 0x100000 + n) >>> 0;
return `${v.toString(16).padStart(8, '0')}-0000-4000-8000-${v.toString(16).padStart(12, '0')}`; return `${v.toString(16).padStart(8, '0')}-0000-4000-8000-${v.toString(16).padStart(12, '0')}`;
} }
@@ -417,7 +457,9 @@ function appendUiSection(ui, section, entities) {
function upsertUi() { function upsertUi() {
const ui = JSON.parse(readFileSync(UI_FILE, 'utf8')); const ui = JSON.parse(readFileSync(UI_FILE, 'utf8'));
const E = ui.ContentProto.Entities; const E = ui.ContentProto.Entities;
ui.ContentProto.Entities = E.filter((e) => !isGeneratedUiEntity(e)); // 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 byPath = new Map(ui.ContentProto.Entities.map((e) => [e.path, e]));
const uiSections = new Map(); const uiSections = new Map();
@@ -444,7 +486,7 @@ function upsertUi() {
const previewIds = Object.keys(CARDS.cards); const previewIds = Object.keys(CARDS.cards);
const cards = Array.from({ length: 5 }, (_, i) => { const cards = Array.from({ length: 5 }, (_, i) => {
const c = CARDS.cards[previewIds[i % previewIds.length]]; 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 }; return { name: c.name, cost: String(c.cost), desc: c.desc, frame: frameRuid(c) };
}); });
for (let i = 1; i <= 5; i++) { for (let i = 1; i <= 5; i++) {
@@ -456,9 +498,9 @@ function upsertUi() {
tr.anchoredPosition = { x: CARD_XS[i - 1], y: 0 }; tr.anchoredPosition = { x: CARD_XS[i - 1], y: 0 };
tr.OffsetMin = { x: CARD_XS[i - 1] - CARD_W / 2, y: -CARD_H / 2 }; 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 }; tr.OffsetMax = { x: CARD_XS[i - 1] + CARD_W / 2, y: CARD_H / 2 };
sp.ImageRUID = { DataId: '' }; sp.ImageRUID = { DataId: cards[i - 1].frame };
sp.Type = 1; sp.Type = 0;
sp.Color = cards[i - 1].tint; sp.Color = WHITE;
sp.RaycastTarget = true; sp.RaycastTarget = true;
const comps = card.jsonString['@components']; const comps = card.jsonString['@components'];
if (!comps.some((c) => c['@type'] === 'MOD.Core.ButtonComponent')) { if (!comps.some((c) => c['@type'] === 'MOD.Core.ButtonComponent')) {
@@ -476,11 +518,9 @@ function upsertUi() {
card.jsonString.enable = true; card.jsonString.enable = true;
card.jsonString.visible = true; card.jsonString.visible = true;
const children = [ const handLayout = cardFaceLayout(CARD_W);
['Cost', { size: { x: 44, y: 44 }, pos: { x: -68, y: 103 }, value: cards[i - 1].cost, fontSize: 26, bold: true }], const previewValues = { Cost: cards[i - 1].cost, Name: cards[i - 1].name, Desc: cards[i - 1].desc };
['Name', { size: { x: 168, y: 30 }, pos: { x: 0, y: -8 }, value: cards[i - 1].name, fontSize: 20, bold: true }], const children = handLayout.texts.map(([suffix, cfg]) => [suffix, { ...cfg, value: previewValues[suffix] }]);
['Desc', { size: { x: 164, y: 70 }, pos: { x: 0, y: -62 }, value: cards[i - 1].desc, fontSize: 18, bold: false }],
];
for (const [suffix, cfg] of children) { for (const [suffix, cfg] of children) {
const path = `/ui/DefaultGroup/CardHand/Card${i}/${suffix}`; const path = `/ui/DefaultGroup/CardHand/Card${i}/${suffix}`;
const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : 8; const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : 8;
@@ -496,7 +536,7 @@ function upsertUi() {
components: [ 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 }), 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 }), sprite({ color: TRANSPARENT }),
text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold }), text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold, color: cfg.color }),
], ],
}); });
ui.ContentProto.Entities.push(child); ui.ContentProto.Entities.push(child);
@@ -517,18 +557,19 @@ function upsertUi() {
child.jsonString['@components'][2].Text = cfg.value; child.jsonString['@components'][2].Text = cfg.value;
child.jsonString['@components'][2].FontSize = cfg.fontSize; child.jsonString['@components'][2].FontSize = cfg.fontSize;
child.jsonString['@components'][2].MaxSize = cfg.fontSize; child.jsonString['@components'][2].MaxSize = cfg.fontSize;
child.jsonString['@components'][2].FontColor = cfg.color;
} }
} }
// 프레임 이미지가 이름판·코스트판을 내장하므로 Art만 유지 (잔존 NamePlate/CostPlate는 upsertUi 초입에서 제거)
const frameKids = [ const frameKids = [
['NamePlate', 3, { size: { x: 168, y: 34 }, pos: { x: 0, y: -8 } }, { r: 0.07, g: 0.08, b: 0.1, a: 0.92 }, 1], ['Art', 5, handLayout.art, WHITE, 0],
['CostPlate', 4, { size: { x: 44, y: 44 }, pos: { x: -68, y: 103 } }, { r: 0.07, g: 0.08, b: 0.1, a: 0.95 }, 1],
['Art', 5, { size: { x: 96, y: 96 }, pos: { x: 0, y: 52 } }, { r: 1, g: 1, b: 1, a: 1 }, 0],
]; ];
for (const [suffix, dOrder, cfg, color, spriteType] of frameKids) { for (const [suffix, dOrder, cfg, color, spriteType] of frameKids) {
const fPath = `/ui/DefaultGroup/CardHand/Card${i}/${suffix}`; const fPath = `/ui/DefaultGroup/CardHand/Card${i}/${suffix}`;
if (!byPath.get(fPath)) { let fe = byPath.get(fPath);
const fe = entity({ if (!fe) {
fe = entity({
id: guid('dck', 200 + i * 10 + dOrder), id: guid('dck', 200 + i * 10 + dOrder),
path: fPath, path: fPath,
modelId: 'uisprite', modelId: 'uisprite',
@@ -542,6 +583,14 @@ function upsertUi() {
}); });
ui.ContentProto.Entities.push(fe); ui.ContentProto.Entities.push(fe);
byPath.set(fPath, 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 };
}
} }
} }
} }
@@ -743,14 +792,16 @@ function upsertUi() {
scrollLayoutGroup({ cellSize: { x: 158, y: 214 }, spacing: { x: 22, y: 22 }, columns: 5 }), scrollLayoutGroup({ cellSize: { x: 158, y: 214 }, spacing: { x: 22, y: 22 }, columns: 5 }),
], ],
})); }));
let insN = 6;
const INSPECT_CARD_COUNT = 60; const INSPECT_CARD_COUNT = 60;
const INSPECT_CARD_W = 158; const INSPECT_CARD_W = 158;
const INSPECT_CARD_H = 214; const INSPECT_CARD_H = 214;
// 카드 단위 엔티티는 v2 네임스페이스(ins2/all2/rwd2/shp2) — 자식 구성이 바뀌면 id를 통째로 새로 발급해야 함.
// 구 id를 다른 path에 재사용하면 메이커 refresh의 id 기준 in-place 병합이 꼬여 자식이 소실됨 (P13 실측).
for (let i = 1; i <= INSPECT_CARD_COUNT; i++) { for (let i = 1; i <= INSPECT_CARD_COUNT; i++) {
const insBase = 6 + (i - 1) * 7;
const cardPath = `/ui/DefaultGroup/DeckInspectHud/Grid/Card${i}`; const cardPath = `/ui/DefaultGroup/DeckInspectHud/Grid/Card${i}`;
const card = entity({ const card = entity({
id: guid('ins', insN++), id: guid('ins2', insBase),
path: cardPath, path: cardPath,
modelId: 'uisprite', modelId: 'uisprite',
entryId: 'UISprite', entryId: 'UISprite',
@@ -758,19 +809,16 @@ function upsertUi() {
displayOrder: i, displayOrder: i,
components: [ components: [
transform({ parentW: 950, parentH: 610, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: INSPECT_CARD_W, y: INSPECT_CARD_H }, pos: { x: 0, y: 0 } }), transform({ parentW: 950, parentH: 610, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: INSPECT_CARD_W, y: INSPECT_CARD_H }, pos: { x: 0, y: 0 } }),
sprite({ color: ATTACK, type: 1 }), sprite({ dataId: CARDFRAMES.frames.warrior.normal, color: WHITE, type: 0 }),
], ],
}); });
card.jsonString.enable = false; card.jsonString.enable = false;
inspect.push(card); inspect.push(card);
for (const [suffix, cfg] of [ const inspectLayout = cardFaceLayout(INSPECT_CARD_W);
['Cost', { size: { x: 38, y: 38 }, pos: { x: -58, y: 86 }, value: '1', fontSize: 22, bold: true }], for (const [tIdx, [suffix, cfg]] of inspectLayout.texts.map(([sfx, c]) => [sfx, { ...c, value: sfx === 'Cost' ? '1' : '' }]).entries()) {
['Name', { size: { x: 148, y: 26 }, pos: { x: 0, y: -8 }, value: '', fontSize: 17, bold: true }],
['Desc', { size: { x: 144, y: 60 }, pos: { x: 0, y: -54 }, value: '', fontSize: 15, bold: false }],
]) {
const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : 8; const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : 8;
inspect.push(entity({ inspect.push(entity({
id: guid('ins', insN++), id: guid('ins2', insBase + 1 + tIdx),
path: `${cardPath}/${suffix}`, path: `${cardPath}/${suffix}`,
modelId: 'uitext', modelId: 'uitext',
entryId: 'UIText', entryId: 'UIText',
@@ -779,29 +827,23 @@ function upsertUi() {
components: [ components: [
transform({ parentW: INSPECT_CARD_W, parentH: INSPECT_CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }), transform({ parentW: INSPECT_CARD_W, parentH: INSPECT_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 }), sprite({ color: TRANSPARENT }),
text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold }), text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold, color: cfg.color }),
], ],
})); }));
} }
for (const [suffix, dOrder, cfg, color, spriteType] of [
['NamePlate', 3, { size: { x: 148, y: 30 }, pos: { x: 0, y: -8 } }, { r: 0.07, g: 0.08, b: 0.1, a: 0.92 }, 1],
['CostPlate', 4, { size: { x: 38, y: 38 }, pos: { x: -58, y: 86 } }, { r: 0.07, g: 0.08, b: 0.1, a: 0.95 }, 1],
['Art', 5, { size: { x: 84, y: 84 }, pos: { x: 0, y: 44 } }, { r: 1, g: 1, b: 1, a: 1 }, 0],
]) {
inspect.push(entity({ inspect.push(entity({
id: guid('ins', insN++), id: guid('ins2', insBase + 6),
path: `${cardPath}/${suffix}`, path: `${cardPath}/Art`,
modelId: 'uisprite', modelId: 'uisprite',
entryId: 'UISprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: dOrder, displayOrder: 5,
components: [ components: [
transform({ parentW: INSPECT_CARD_W, parentH: INSPECT_CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }), transform({ parentW: INSPECT_CARD_W, parentH: INSPECT_CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: inspectLayout.art.size, pos: inspectLayout.art.pos }),
sprite({ color, type: spriteType, raycast: false }), sprite({ color: WHITE, type: 0, raycast: false }),
], ],
})); }));
} }
}
emit('DeckInspectHud', inspect); emit('DeckInspectHud', inspect);
const allDeck = []; const allDeck = [];
@@ -884,14 +926,15 @@ function upsertUi() {
scrollLayoutGroup({ cellSize: { x: 158, y: 214 }, spacing: { x: 22, y: 22 }, columns: 5 }), scrollLayoutGroup({ cellSize: { x: 158, y: 214 }, spacing: { x: 22, y: 22 }, columns: 5 }),
], ],
})); }));
let allN = 6;
const ALL_DECK_CARD_COUNT = 120; const ALL_DECK_CARD_COUNT = 120;
const ALL_DECK_CARD_W = 158; const ALL_DECK_CARD_W = 158;
const ALL_DECK_CARD_H = 214; const ALL_DECK_CARD_H = 214;
// 카드 단위 엔티티 v2 네임스페이스 — DeckInspectHud 주석 참조
for (let i = 1; i <= ALL_DECK_CARD_COUNT; i++) { for (let i = 1; i <= ALL_DECK_CARD_COUNT; i++) {
const allBase = 6 + (i - 1) * 7;
const cardPath = `/ui/DefaultGroup/DeckAllHud/Grid/Card${i}`; const cardPath = `/ui/DefaultGroup/DeckAllHud/Grid/Card${i}`;
const card = entity({ const card = entity({
id: guid('all', allN++), id: guid('all2', allBase),
path: cardPath, path: cardPath,
modelId: 'uisprite', modelId: 'uisprite',
entryId: 'UISprite', entryId: 'UISprite',
@@ -899,19 +942,16 @@ function upsertUi() {
displayOrder: i, displayOrder: i,
components: [ components: [
transform({ parentW: 980, parentH: 620, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: ALL_DECK_CARD_W, y: ALL_DECK_CARD_H }, pos: { x: 0, y: 0 } }), transform({ parentW: 980, parentH: 620, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: ALL_DECK_CARD_W, y: ALL_DECK_CARD_H }, pos: { x: 0, y: 0 } }),
sprite({ color: ATTACK, type: 1 }), sprite({ dataId: CARDFRAMES.frames.warrior.normal, color: WHITE, type: 0 }),
], ],
}); });
card.jsonString.enable = false; card.jsonString.enable = false;
allDeck.push(card); allDeck.push(card);
for (const [suffix, cfg] of [ const allDeckLayout = cardFaceLayout(ALL_DECK_CARD_W);
['Cost', { size: { x: 38, y: 38 }, pos: { x: -58, y: 86 }, value: '1', fontSize: 22, bold: true }], for (const [tIdx, [suffix, cfg]] of allDeckLayout.texts.map(([sfx, c]) => [sfx, { ...c, value: sfx === 'Cost' ? '1' : '' }]).entries()) {
['Name', { size: { x: 148, y: 26 }, pos: { x: 0, y: -8 }, value: '', fontSize: 17, bold: true }],
['Desc', { size: { x: 144, y: 60 }, pos: { x: 0, y: -54 }, value: '', fontSize: 15, bold: false }],
]) {
const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : 8; const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : 8;
allDeck.push(entity({ allDeck.push(entity({
id: guid('all', allN++), id: guid('all2', allBase + 1 + tIdx),
path: `${cardPath}/${suffix}`, path: `${cardPath}/${suffix}`,
modelId: 'uitext', modelId: 'uitext',
entryId: 'UIText', entryId: 'UIText',
@@ -920,29 +960,23 @@ function upsertUi() {
components: [ components: [
transform({ parentW: ALL_DECK_CARD_W, parentH: ALL_DECK_CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }), transform({ parentW: ALL_DECK_CARD_W, parentH: ALL_DECK_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 }), sprite({ color: TRANSPARENT }),
text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold }), text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold, color: cfg.color }),
], ],
})); }));
} }
for (const [suffix, dOrder, cfg, color, spriteType] of [
['NamePlate', 3, { size: { x: 148, y: 30 }, pos: { x: 0, y: -8 } }, { r: 0.07, g: 0.08, b: 0.1, a: 0.92 }, 1],
['CostPlate', 4, { size: { x: 38, y: 38 }, pos: { x: -58, y: 86 } }, { r: 0.07, g: 0.08, b: 0.1, a: 0.95 }, 1],
['Art', 5, { size: { x: 84, y: 84 }, pos: { x: 0, y: 44 } }, { r: 1, g: 1, b: 1, a: 1 }, 0],
]) {
allDeck.push(entity({ allDeck.push(entity({
id: guid('all', allN++), id: guid('all2', allBase + 6),
path: `${cardPath}/${suffix}`, path: `${cardPath}/Art`,
modelId: 'uisprite', modelId: 'uisprite',
entryId: 'UISprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: dOrder, displayOrder: 5,
components: [ components: [
transform({ parentW: ALL_DECK_CARD_W, parentH: ALL_DECK_CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }), transform({ parentW: ALL_DECK_CARD_W, parentH: ALL_DECK_CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: allDeckLayout.art.size, pos: allDeckLayout.art.pos }),
sprite({ color, type: spriteType, raycast: false }), sprite({ color: WHITE, type: 0, raycast: false }),
], ],
})); }));
} }
}
emit('DeckAllHud', allDeck); emit('DeckAllHud', allDeck);
const PANEL_BG = { r: 0.08, g: 0.09, b: 0.11, a: 0.78 }; const PANEL_BG = { r: 0.08, g: 0.09, b: 0.11, a: 0.78 };
@@ -1402,12 +1436,13 @@ function upsertUi() {
text({ value: '보상 카드 선택', fontSize: 44, bold: true, color: GOLD, alignment: 4 }), text({ value: '보상 카드 선택', fontSize: 44, bold: true, color: GOLD, alignment: 4 }),
], ],
})); }));
let rwdN = 2;
const rewardXs = [-300, 0, 300]; const rewardXs = [-300, 0, 300];
// 카드 단위 엔티티 v2 네임스페이스 — DeckInspectHud 주석 참조
for (let i = 1; i <= 3; i++) { for (let i = 1; i <= 3; i++) {
const rwdBase = 2 + (i - 1) * 7;
const cardPath = `/ui/DefaultGroup/RewardHud/Reward${i}`; const cardPath = `/ui/DefaultGroup/RewardHud/Reward${i}`;
reward.push(entity({ reward.push(entity({
id: guid('rwd', rwdN++), id: guid('rwd2', rwdBase),
path: cardPath, path: cardPath,
modelId: 'uisprite', modelId: 'uisprite',
entryId: 'UISprite', entryId: 'UISprite',
@@ -1415,18 +1450,15 @@ function upsertUi() {
displayOrder: i, displayOrder: i,
components: [ 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 } }), 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 }), sprite({ dataId: CARDFRAMES.frames.warrior.normal, color: WHITE, type: 0, raycast: true }),
button(), button(),
], ],
})); }));
for (const [suffix, cfg] of [ const rewardLayout = cardFaceLayout(CARD_W);
['Cost', { size: { x: 44, y: 44 }, pos: { x: -68, y: 103 }, value: '1', fontSize: 26, bold: true }], for (const [tIdx, [suffix, cfg]] of rewardLayout.texts.map(([sfx, c]) => [sfx, { ...c, value: sfx === 'Cost' ? '1' : sfx === 'Name' ? '카드' : '' }]).entries()) {
['Name', { size: { x: 168, y: 30 }, pos: { x: 0, y: -8 }, value: '카드', fontSize: 20, bold: true }],
['Desc', { size: { x: 164, y: 70 }, pos: { x: 0, y: -62 }, value: '', fontSize: 18, bold: false }],
]) {
const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : 8; const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : 8;
reward.push(entity({ reward.push(entity({
id: guid('rwd', rwdN++), id: guid('rwd2', rwdBase + 1 + tIdx),
path: `${cardPath}/${suffix}`, path: `${cardPath}/${suffix}`,
modelId: 'uitext', modelId: 'uitext',
entryId: 'UIText', entryId: 'UIText',
@@ -1435,29 +1467,24 @@ function upsertUi() {
components: [ 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 }), 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 }), sprite({ color: TRANSPARENT }),
text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold }), text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold, color: cfg.color }),
], ],
})); }));
} }
for (const [suffix, dOrder, cfg, color, spriteType] of [
['NamePlate', 3, { size: { x: 168, y: 34 }, pos: { x: 0, y: -8 } }, { r: 0.07, g: 0.08, b: 0.1, a: 0.92 }, 1],
['CostPlate', 4, { size: { x: 44, y: 44 }, pos: { x: -68, y: 103 } }, { r: 0.07, g: 0.08, b: 0.1, a: 0.95 }, 1],
['Art', 5, { size: { x: 96, y: 96 }, pos: { x: 0, y: 52 } }, { r: 1, g: 1, b: 1, a: 1 }, 0],
]) {
reward.push(entity({ reward.push(entity({
id: guid('rwd', rwdN++), id: guid('rwd2', rwdBase + 6),
path: `${cardPath}/${suffix}`, path: `${cardPath}/Art`,
modelId: 'uisprite', modelId: 'uisprite',
entryId: 'UISprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: dOrder, displayOrder: 5,
components: [ 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 }), transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: rewardLayout.art.size, pos: rewardLayout.art.pos }),
sprite({ color, type: spriteType, raycast: false }), sprite({ color: WHITE, type: 0, raycast: false }),
], ],
})); }));
} }
} let rwdN = 2 + 3 * 7; // 구 시퀀스의 루프 종료 시점 값(23) 보존 — Skip 등 후속 id 불변
reward.push(entity({ reward.push(entity({
id: guid('rwd', rwdN++), id: guid('rwd', rwdN++),
path: '/ui/DefaultGroup/RewardHud/Skip', path: '/ui/DefaultGroup/RewardHud/Skip',
@@ -1618,12 +1645,13 @@ function upsertUi() {
text({ value: '메소 0', fontSize: 28, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }), 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]; const shopXs = [-300, 0, 300];
// 카드 단위 엔티티 v2 네임스페이스 (stride 8: Price 포함) — DeckInspectHud 주석 참조
for (let i = 1; i <= 3; i++) { for (let i = 1; i <= 3; i++) {
const shpBase = 3 + (i - 1) * 8;
const cardPath = `/ui/DefaultGroup/ShopHud/Card${i}`; const cardPath = `/ui/DefaultGroup/ShopHud/Card${i}`;
shop.push(entity({ shop.push(entity({
id: guid('shp', shpN++), id: guid('shp2', shpBase),
path: cardPath, path: cardPath,
modelId: 'uisprite', modelId: 'uisprite',
entryId: 'UISprite', entryId: 'UISprite',
@@ -1631,19 +1659,17 @@ function upsertUi() {
displayOrder: i, displayOrder: i,
components: [ 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 } }), 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 }), sprite({ dataId: CARDFRAMES.frames.warrior.normal, color: WHITE, type: 0, raycast: true }),
button(), button(),
], ],
})); }));
for (const [suffix, cfg] of [ const shopLayout = cardFaceLayout(CARD_W);
['Cost', { size: { x: 44, y: 44 }, pos: { x: -68, y: 103 }, value: '1', fontSize: 26, bold: true, color: { r: 1, g: 1, b: 1, a: 1 } }], const shopTexts = shopLayout.texts.map(([sfx, c]) => [sfx, { ...c, value: sfx === 'Cost' ? '1' : sfx === 'Name' ? '카드' : '' }]);
['Name', { size: { x: 168, y: 30 }, pos: { x: 0, y: -8 }, value: '카드', fontSize: 20, bold: true, color: { r: 1, g: 1, b: 1, a: 1 } }], shopTexts.push(['Price', { size: { x: 160, y: 40 }, pos: { x: 0, y: -135 }, value: '30 메소', fontSize: 22, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 } }]);
['Desc', { size: { x: 164, y: 56 }, pos: { x: 0, y: -58 }, value: '', fontSize: 18, bold: false, color: { r: 1, g: 1, b: 1, a: 1 } }], for (const [tIdx, [suffix, cfg]] of shopTexts.entries()) {
['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 } }],
]) {
const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : suffix === 'Desc' ? 8 : 9; const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : suffix === 'Desc' ? 8 : 9;
shop.push(entity({ shop.push(entity({
id: guid('shp', shpN++), id: guid('shp2', shpBase + 1 + tIdx),
path: `${cardPath}/${suffix}`, path: `${cardPath}/${suffix}`,
modelId: 'uitext', modelId: 'uitext',
entryId: 'UIText', entryId: 'UIText',
@@ -1656,25 +1682,20 @@ function upsertUi() {
], ],
})); }));
} }
for (const [suffix, dOrder, cfg, color, spriteType] of [
['NamePlate', 3, { size: { x: 168, y: 34 }, pos: { x: 0, y: -8 } }, { r: 0.07, g: 0.08, b: 0.1, a: 0.92 }, 1],
['CostPlate', 4, { size: { x: 44, y: 44 }, pos: { x: -68, y: 103 } }, { r: 0.07, g: 0.08, b: 0.1, a: 0.95 }, 1],
['Art', 5, { size: { x: 96, y: 96 }, pos: { x: 0, y: 52 } }, { r: 1, g: 1, b: 1, a: 1 }, 0],
]) {
shop.push(entity({ shop.push(entity({
id: guid('shp', shpN++), id: guid('shp2', shpBase + 7),
path: `${cardPath}/${suffix}`, path: `${cardPath}/Art`,
modelId: 'uisprite', modelId: 'uisprite',
entryId: 'UISprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: dOrder, displayOrder: 5,
components: [ 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 }), transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: shopLayout.art.size, pos: shopLayout.art.pos }),
sprite({ color, type: spriteType, raycast: false }), sprite({ color: WHITE, type: 0, raycast: false }),
], ],
})); }));
} }
} let shpN = 3 + 3 * 8; // 구 시퀀스의 루프 종료 시점 값(27) 보존 — Relic 등 후속 id 불변
shop.push(entity({ shop.push(entity({
id: guid('shp', shpN++), id: guid('shp', shpN++),
path: '/ui/DefaultGroup/ShopHud/Relic', path: '/ui/DefaultGroup/ShopHud/Relic',
@@ -2329,6 +2350,14 @@ function upsertUi() {
appendUiSection(ui, section, entities); 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)); JSON.parse(JSON.stringify(ui));
writeFileSync(UI_FILE, JSON.stringify(ui, null, 2), 'utf8'); writeFileSync(UI_FILE, JSON.stringify(ui, null, 2), 'utf8');
} }
@@ -2418,6 +2447,8 @@ function writeCodeblocks() {
prop('string', 'DeckInspectKind', '""'), prop('string', 'DeckInspectKind', '""'),
prop('boolean', 'DeckAllOpen', 'false'), prop('boolean', 'DeckAllOpen', 'false'),
prop('any', 'Cards'), prop('any', 'Cards'),
prop('any', 'CardFrames'),
prop('any', 'ClassToFrame'),
prop('number', 'PlayerHp', '0'), prop('number', 'PlayerHp', '0'),
prop('number', 'PlayerMaxHp', '80'), prop('number', 'PlayerMaxHp', '80'),
prop('number', 'PlayerBlock', '0'), prop('number', 'PlayerBlock', '0'),
@@ -2667,6 +2698,7 @@ self.CurrentNodeId = ""
self.CurrentEnemyId = "" self.CurrentEnemyId = ""
self.PlayerJob = "" self.PlayerJob = ""
${luaJobsTable(JOBS)} ${luaJobsTable(JOBS)}
${luaFramesTable()}
self:GenerateMap() self:GenerateMap()
self:BindButtons() self:BindButtons()
self:AddRelic("${RELICS.startingRelic}") self:AddRelic("${RELICS.startingRelic}")
@@ -3148,16 +3180,18 @@ end
self:RenderPiles()`, [{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'animate' }]), self:RenderPiles()`, [{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'animate' }]),
method('ApplyCardFace', `local c = self.Cards[cardId] method('ApplyCardFace', `local c = self.Cards[cardId]
if c == nil then if c == nil then
c = { name = cardId, cost = 0, desc = "", kind = "Skill" } c = { name = cardId, cost = 0, desc = "", kind = "Skill", class = "warrior", rarity = "normal" }
end end
local e = _EntityService:GetEntityByPath(base) local e = _EntityService:GetEntityByPath(base)
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
if c.kind == "Attack" then local frames = self.CardFrames[self.ClassToFrame[c.class] or "warrior"]
e.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1) local ruid = nil
elseif c.kind == "Power" then if frames ~= nil then
e.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1) ruid = frames[c.rarity or "normal"]
else end
e.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1) if ruid ~= nil then
e.SpriteGUIRendererComponent.ImageRUID = ruid
e.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
end end
end end
self:SetText(base .. "/Cost", string.format("%d", c.cost)) self:SetText(base .. "/Cost", string.format("%d", c.cost))
@@ -3981,9 +4015,20 @@ return pool`, [], 0, 'any'),
method('OfferReward', `self:SetEntityEnabled("/ui/DefaultGroup/CardHand", false) method('OfferReward', `self:SetEntityEnabled("/ui/DefaultGroup/CardHand", false)
self:SetEntityEnabled("/ui/DefaultGroup/DeckHud", false) self:SetEntityEnabled("/ui/DefaultGroup/DeckHud", false)
local pool = self:CardPool() local pool = self:CardPool()
local byRarity = {}
for _, id in ipairs(pool) do
local r = self.Cards[id].rarity or "normal"
if byRarity[r] == nil then byRarity[r] = {} end
table.insert(byRarity[r], id)
end
self.RewardChoices = {} self.RewardChoices = {}
for i = 1, 3 do for i = 1, 3 do
self.RewardChoices[i] = pool[math.random(1, #pool)] local roll = math.random(1, 100)
local want = "normal"
if roll > 95 then want = "legend" elseif roll > 70 then want = "unique" end
local bucket = byRarity[want]
if bucket == nil or #bucket == 0 then bucket = pool end
self.RewardChoices[i] = bucket[math.random(1, #bucket)]
self:ApplyRewardVisual(i, self.RewardChoices[i]) self:ApplyRewardVisual(i, self.RewardChoices[i])
end end
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud") local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud")

File diff suppressed because it is too large Load Diff