feat(job): 클래스 풀 필터·전직 선택 흐름·전직 HUD (생성기)

- CardPool(클래스 필터) — 보상·상점 공용
- 보스 분기: 1차+비최종막 → JobChoiceHud(유물/전직), ContinueAfterBoss 추출
- JobSelectHud 3직업 패널, SetJob(대표 카드 지급·직업명 갱신)
- guid 'job'=0xe4, HideGameHud·BindButtons 등록

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:36:46 +09:00
parent 74e3a70a19
commit 2c9a1b351e

View File

@@ -95,6 +95,8 @@ const GENERATED_UI_SECTIONS = [
'ShopHud',
'RestHud',
'TreasureHud',
'JobChoiceHud',
'JobSelectHud',
'MainMenu',
'CharacterSelectHud',
];
@@ -106,6 +108,8 @@ const UI_APPEND_ORDER = [
'ShopHud',
'RestHud',
'TreasureHud',
'JobChoiceHud',
'JobSelectHud',
'DeckInspectHud',
'DeckAllHud',
'MainMenu',
@@ -134,7 +138,7 @@ 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 : prefix === 'menu' ? 0xe0 : prefix === 'ins' ? 0xe1 : prefix === 'all' ? 0xe2 : prefix === 'trs' ? 0xe3 : 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 : 0xfe;
const v = (ns * 0x100000 + n) >>> 0;
return `${v.toString(16).padStart(8, '0')}-0000-4000-8000-${v.toString(16).padStart(12, '0')}`;
}
@@ -1874,6 +1878,138 @@ function upsertUi() {
}));
emit('TreasureHud', treasure);
// 전직 선택 (P9) — 보스 보상: 유물 vs 2차 전직
const jobChoice = [];
const jobChoiceHud = entity({
id: guid('job', 0),
path: '/ui/DefaultGroup/JobChoiceHud',
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.06, b: 0.09, a: 0.92 }, type: 1, raycast: true }),
],
});
jobChoiceHud.jsonString.enable = false;
jobChoice.push(jobChoiceHud);
jobChoice.push(entity({
id: guid('job', 1),
path: '/ui/DefaultGroup/JobChoiceHud/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: 800, y: 60 }, pos: { x: 0, y: 220 } }),
sprite({ color: TRANSPARENT }),
text({ value: '보스 처치 보상을 선택하세요', fontSize: 36, bold: true, color: GOLD, alignment: 4 }),
],
}));
const jcButtons = [
['RelicButton', '유물 획득', -240, { r: 0.7, g: 0.55, b: 0.85, a: 1 }],
['JobButton', '2차 전직', 240, { r: 0.86, g: 0.6, b: 0.3, a: 1 }],
];
jcButtons.forEach(([suffix, label, x, color], bi) => {
jobChoice.push(entity({
id: guid('job', 2 + bi),
path: `/ui/DefaultGroup/JobChoiceHud/${suffix}`,
modelId: 'uibutton', entryId: 'UIButton',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
displayOrder: 1 + bi,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 380, y: 140 }, pos: { x, y: 0 } }),
sprite({ color, type: 1, raycast: true }),
button(),
text({ value: label, fontSize: 32, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
],
}));
});
emit('JobChoiceHud', jobChoice);
const jobSelect = [];
const jobSelectHud = entity({
id: guid('job', 10),
path: '/ui/DefaultGroup/JobSelectHud',
modelId: 'uisprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 10,
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.94 }, type: 1, raycast: true }),
],
});
jobSelectHud.jsonString.enable = false;
jobSelect.push(jobSelectHud);
jobSelect.push(entity({
id: guid('job', 11),
path: '/ui/DefaultGroup/JobSelectHud/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: 800, y: 60 }, pos: { x: 0, y: 300 } }),
sprite({ color: TRANSPARENT }),
text({ value: '2차 전직 — 직업을 선택하세요', fontSize: 36, bold: true, color: GOLD, alignment: 4 }),
],
}));
const jobs = [
['fighter', '파이터', '공격 특화\n콤보 어택 · 버서크\n라이징 어택', '대표 카드: 콤보 어택', -440, { r: 0.82, g: 0.4, b: 0.34, a: 1 }],
['page', '페이지', '속성 차지 특화\n썬더/블리자드 차지\n파워 가드', '대표 카드: 썬더 차지', 0, { r: 0.4, g: 0.55, b: 0.85, a: 1 }],
['spearman', '스피어맨', '방어·관통 특화\n피어스 · 아이언 월\n하이퍼 바디', '대표 카드: 피어스', 440, { r: 0.42, g: 0.72, b: 0.46, a: 1 }],
];
jobs.forEach(([jobId, name, desc, starter, x, color], ji) => {
const base = `/ui/DefaultGroup/JobSelectHud/Job_${jobId}`;
jobSelect.push(entity({
id: guid('job', 12 + ji * 4),
path: base,
modelId: 'uibutton', entryId: 'UIButton',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
displayOrder: 1 + ji,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 380, y: 420 }, pos: { x, y: -20 } }),
sprite({ color, type: 1, raycast: true }),
button(),
],
}));
jobSelect.push(entity({
id: guid('job', 13 + ji * 4),
path: `${base}/Name`,
modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 0,
components: [
transform({ parentW: 380, parentH: 420, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 360, y: 50 }, pos: { x: 0, y: 150 } }),
sprite({ color: TRANSPARENT }),
text({ value: name, fontSize: 34, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
],
}));
jobSelect.push(entity({
id: guid('job', 14 + ji * 4),
path: `${base}/Desc`,
modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 1,
components: [
transform({ parentW: 380, parentH: 420, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 340, y: 160 }, pos: { x: 0, y: 0 } }),
sprite({ color: TRANSPARENT }),
text({ value: desc, fontSize: 22, bold: false, color: { r: 0.95, g: 0.95, b: 0.97, a: 1 }, alignment: 4 }),
],
}));
jobSelect.push(entity({
id: guid('job', 15 + ji * 4),
path: `${base}/Starter`,
modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 2,
components: [
transform({ parentW: 380, parentH: 420, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 340, y: 32 }, pos: { x: 0, y: -160 } }),
sprite({ color: TRANSPARENT }),
text({ value: starter, fontSize: 18, bold: true, color: GOLD, alignment: 4 }),
],
}));
});
emit('JobSelectHud', jobSelect);
const menu = [];
menu.push(entity({
id: guid('menu', 0),
@@ -2256,6 +2392,8 @@ self:SetEntityEnabled("/ui/DefaultGroup/MapHud", false)
self:SetEntityEnabled("/ui/DefaultGroup/ShopHud", false)
self:SetEntityEnabled("/ui/DefaultGroup/RestHud", false)
self:SetEntityEnabled("/ui/DefaultGroup/TreasureHud", false)
self:SetEntityEnabled("/ui/DefaultGroup/JobChoiceHud", false)
self:SetEntityEnabled("/ui/DefaultGroup/JobSelectHud", false)
self:SetEntityEnabled("/ui/DefaultGroup/DeckInspectHud", false)
self:SetEntityEnabled("/ui/DefaultGroup/DeckAllHud", false)`),
method('ShowState', `self:HideGameHud()
@@ -2362,6 +2500,7 @@ self:ShowMap()`),
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/Result", false)
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/PotionMenu", false)
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/TooltipBox", false)
self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/Name", self:JobLabel())
self.MaxEnergy = 3
self.Turn = 0
self.PlayerBlock = 0
@@ -2615,6 +2754,22 @@ end
local treasureLeave = _EntityService:GetEntityByPath("/ui/DefaultGroup/TreasureHud/Leave")
if treasureLeave ~= nil and treasureLeave.ButtonComponent ~= nil then
treasureLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)
end
local jcRelic = _EntityService:GetEntityByPath("/ui/DefaultGroup/JobChoiceHud/RelicButton")
if jcRelic ~= nil and jcRelic.ButtonComponent ~= nil then
jcRelic:ConnectEvent(ButtonClickEvent, function() self:PickJobReward("relic") end)
end
local jcJob = _EntityService:GetEntityByPath("/ui/DefaultGroup/JobChoiceHud/JobButton")
if jcJob ~= nil and jcJob.ButtonComponent ~= nil then
jcJob:ConnectEvent(ButtonClickEvent, function() self:PickJobReward("job") end)
end
local jobIds = { "fighter", "page", "spearman" }
for i = 1, #jobIds do
local jid = jobIds[i]
local jb = _EntityService:GetEntityByPath("/ui/DefaultGroup/JobSelectHud/Job_" .. jid)
if jb ~= nil and jb.ButtonComponent ~= nil then
jb:ConnectEvent(ButtonClickEvent, function() self:SetJob(jid) end)
end
end`),
method('StartPlayerTurn', `self.Turn = self.Turn + 1
self.Energy = self.MaxEnergy
@@ -3210,26 +3365,18 @@ if anyAlive == false then
end
end
if node ~= nil and node.type == "boss" then
local bid = self:PickNewRelic()
if bid ~= "" then
self:AddRelic(bid)
local br = self.Relics[bid]
if br ~= nil then
self:Toast("유물 획득: " .. br.name)
end
end
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:GenerateMap()
self:RenderRun()
self:TeleportToActMap()
self:ShowMap()
if self.PlayerJob == "" and self.Floor < self.RunLength then
self:ShowJobChoice()
else
self:EndRun("런 클리어!")
local bid = self:PickNewRelic()
if bid ~= "" then
self:AddRelic(bid)
local br = self.Relics[bid]
if br ~= nil then
self:Toast("유물 획득: " .. br.name)
end
end
self:ContinueAfterBoss()
end
else
self:OfferReward()
@@ -3238,6 +3385,64 @@ elseif self.PlayerHp <= 0 then
self.CombatOver = true
self:EndRun("패배...")
end`),
method('ContinueAfterBoss', `if self.Floor < self.RunLength then
self.Floor = self.Floor + 1
self.CurrentNodeId = ""
self.CurrentEnemyId = ""
self:GenerateMap()
self:RenderRun()
self:TeleportToActMap()
self:ShowMap()
else
self:EndRun("런 클리어!")
end`),
method('ShowJobChoice', `self:SetEntityEnabled("/ui/DefaultGroup/CardHand", false)
self:SetEntityEnabled("/ui/DefaultGroup/DeckHud", false)
self:SetEntityEnabled("/ui/DefaultGroup/JobChoiceHud", true)`),
method('PickJobReward', `self:SetEntityEnabled("/ui/DefaultGroup/JobChoiceHud", false)
if kind == "relic" then
local bid = self:PickNewRelic()
if bid ~= "" then
self:AddRelic(bid)
local br = self.Relics[bid]
if br ~= nil then
self:Toast("유물 획득: " .. br.name)
end
end
self:ContinueAfterBoss()
else
self:SetEntityEnabled("/ui/DefaultGroup/JobSelectHud", true)
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'kind' }]),
method('JobLabel', `if self.PlayerJob == "fighter" then
return "파이터"
elseif self.PlayerJob == "page" then
return "페이지"
elseif self.PlayerJob == "spearman" then
return "스피어맨"
end
if self.SelectedClass == "warrior" then
return "전사"
end
return "플레이어"`, [], 0, 'string'),
method('SetJob', `self.PlayerJob = jobId
local starter = ""
if jobId == "fighter" then
starter = "ComboAttack"
elseif jobId == "page" then
starter = "ThunderCharge"
elseif jobId == "spearman" then
starter = "Pierce"
end
if starter ~= "" then
table.insert(self.RunDeck, starter)
local sc = self.Cards[starter]
if sc ~= nil then
self:Toast("2차 전직: " .. self:JobLabel() .. "! 신규 카드 — " .. sc.name)
end
end
self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/Name", self:JobLabel())
self:SetEntityEnabled("/ui/DefaultGroup/JobSelectHud", false)
self:ContinueAfterBoss()`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'jobId' }]),
method('TeleportToActMap', `local maps = { ${ACT_MAPS.map((m) => `"${m}"`).join(', ')} }
local target = maps[self.Floor]
if target == nil then
@@ -3375,12 +3580,17 @@ end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], N
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('RenderRun', `self:SetText("/ui/DefaultGroup/CombatHud/TopBar/Floor", "막 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength) .. " · " .. string.format("%d", self.Depth) .. "층")
self:SetText("/ui/DefaultGroup/CombatHud/TopBar/Gold", "메소 " .. string.format("%d", self.Gold))`),
method('CardPool', `local pool = {}
for id, c in pairs(self.Cards) do
if c.class == self.SelectedClass or (self.PlayerJob ~= "" and c.class == self.PlayerJob) then
table.insert(pool, id)
end
end
table.sort(pool)
return pool`, [], 0, 'any'),
method('OfferReward', `self:SetEntityEnabled("/ui/DefaultGroup/CardHand", false)
self:SetEntityEnabled("/ui/DefaultGroup/DeckHud", false)
local pool = {}
for id, _ in pairs(self.Cards) do
table.insert(pool, id)
end
local pool = self:CardPool()
self.RewardChoices = {}
for i = 1, 3 do
self.RewardChoices[i] = pool[math.random(1, #pool)]
@@ -3875,10 +4085,7 @@ else
self.CurrentEnemyId = ""
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
method('ShowShop', `local pool = self:CardPool()
self.ShopChoices = {}
self.ShopBought = { false, false, false }
for i = 1, 3 do