feat(rogue-map): 로그라이크 절차 생성 맵·층 시스템·유물 방 (배포 퀄리티 P8) #41

Merged
gahusb merged 6 commits from feature/p8-rogue-map into main 2026-06-12 10:25:41 +09:00
2 changed files with 118 additions and 41 deletions
Showing only changes of commit 1624ef6f3b - Show all commits

View File

@@ -1,12 +0,0 @@
{
"start": ["A", "B"],
"nodes": {
"A": { "type": "combat", "enemy": "slime", "row": 1, "col": -1, "next": ["C", "D"] },
"B": { "type": "combat", "enemy": "slime", "row": 1, "col": 1, "next": ["C", "D"] },
"C": { "type": "rest", "row": 2, "col": -1, "next": ["E", "F"] },
"D": { "type": "shop", "row": 2, "col": 1, "next": ["E", "F"] },
"E": { "type": "elite", "enemy": "slime_elite", "row": 3, "col": -1, "next": ["BOSS"] },
"F": { "type": "combat", "enemy": "slime", "row": 3, "col": 1, "next": ["BOSS"] },
"BOSS": { "type": "boss", "enemy": "slime_boss", "row": 4, "col": 0, "next": [] }
}
}

View File

@@ -13,17 +13,9 @@ if (!ENEMIES.enemies[ENEMIES.activeEnemy]) {
throw new Error(`[gen-slaydeck] activeEnemy가 enemies에 없음: ${ENEMIES.activeEnemy}`);
}
const MAP = JSON.parse(readFileSync('data/map.json', 'utf8'));
for (const id of MAP.start) {
if (!MAP.nodes[id]) throw new Error(`[gen-slaydeck] map.start에 없는 노드 id: ${id}`);
}
for (const [id, n] of Object.entries(MAP.nodes)) {
if (n.enemy && !ENEMIES.enemies[n.enemy]) throw new Error(`[gen-slaydeck] 노드 ${id}의 enemy 없음: ${n.enemy}`);
for (const nx of n.next) {
if (!MAP.nodes[nx]) throw new Error(`[gen-slaydeck] 노드 ${id}.next에 없는 노드 id: ${nx}`);
}
}
const MAX_ROW = Math.max(...Object.values(MAP.nodes).map((n) => n.row));
// 맵은 런타임 절차 생성(GenerateMap Lua ↔ tools/map/rogue-map.mjs 미러). 정적 data/map.json 제거됨.
const MAP_ROWS = 7; // 걷는 행 1..7, 보스 row 8
const MAP_COLS = 4;
const RELICS = JSON.parse(readFileSync('data/relics.json', 'utf8'));
if (!RELICS.relics[RELICS.startingRelic]) throw new Error(`[gen-slaydeck] startingRelic 없음: ${RELICS.startingRelic}`);
@@ -58,18 +50,6 @@ function luaEnemiesTable(enemies) {
`\t${id} = { name = ${luaStr(e.name)}, maxHp = ${e.maxHp}, intents = ${luaIntentsArray(e.intents)} },`);
return `self.Enemies = {\n${lines.join('\n')}\n}`;
}
function luaMapNodesTable(nodes) {
const lines = Object.entries(nodes).map(([id, n]) => {
const nx = '{ ' + n.next.map(luaStr).join(', ') + ' }';
const enemyField = n.enemy ? `enemy = ${luaStr(n.enemy)}, ` : '';
return `\t${id} = { type = ${luaStr(n.type)}, ${enemyField}row = ${n.row}, col = ${n.col}, next = ${nx} },`;
});
return `self.MapNodes = {\n${lines.join('\n')}\n}`;
}
function luaStartArray(start) {
return 'self.MapStart = { ' + start.map(luaStr).join(', ') + ' }';
}
// Lua 직렬화 헬퍼
function luaStr(s) {
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
@@ -2121,6 +2101,9 @@ function writeCodeblocks() {
prop('boolean', 'FirstHpLossDone', 'false'),
prop('number', 'ClayBlockNext', '0'),
prop('number', 'PotionMenuSlot', '0'),
prop('number', 'Depth', '0'),
prop('any', 'VisitedNodes'),
prop('boolean', 'ChestOpened', 'false'),
], [
method('OnBeginPlay', `self:ShowMainMenu()`),
method('HideGameHud', `self:SetEntityEnabled("/ui/DefaultGroup/Button_Attack", false)
@@ -2225,10 +2208,9 @@ ${luaPotionsTable(POTIONS.potions)}
${luaRelicsTable(RELICS.relics)}
self.RelicPool = { ${RELICS.relicPool.map(luaStr).join(', ')} }
${luaEnemiesTable(ENEMIES.enemies)}
${luaMapNodesTable(MAP.nodes)}
${luaStartArray(MAP.start)}
self.CurrentNodeId = ""
self.CurrentEnemyId = ""
self:GenerateMap()
self:BindButtons()
self:AddRelic("${RELICS.startingRelic}")
self:RenderPotions()
@@ -2400,7 +2382,13 @@ local skip = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud/Skip")
if skip ~= nil and skip.ButtonComponent ~= nil then
skip:ConnectEvent(ButtonClickEvent, function() self:PickReward(0) end)
end
local mapNodeIds = { ${Object.keys(MAP.nodes).map(luaStr).join(', ')} }
local mapNodeIds = {}
for r = 1, ${MAP_ROWS} do
for c = 1, ${MAP_COLS} do
table.insert(mapNodeIds, "r" .. tostring(r) .. "c" .. tostring(c))
end
end
table.insert(mapNodeIds, "boss")
for i = 1, #mapNodeIds do
local nid = mapNodeIds[i]
local mn = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud/Node_" .. nid)
@@ -3067,6 +3055,7 @@ if anyAlive == false then
self.Floor = self.Floor + 1
self.CurrentNodeId = ""
self.CurrentEnemyId = ""
self:GenerateMap()
self:RenderRun()
self:TeleportToActMap()
self:ShowMap()
@@ -3215,8 +3204,8 @@ end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], N
self.TargetIndex = slot
self:RenderCombat()
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))
self:SetText("/ui/DefaultGroup/CombatHud/TopBar/Gold", "골드 " .. string.format("%d", self.Gold))`),
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('OfferReward', `self:SetEntityEnabled("/ui/DefaultGroup/CardHand", false)
self:SetEntityEnabled("/ui/DefaultGroup/DeckHud", false)
local pool = {}
@@ -3473,6 +3462,98 @@ end`, [
method('HideTooltip', `self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/TooltipBox", false)`),
method('ShowMap', `self:ShowState("map")
self:RenderMap()`),
method('GenerateMap', `-- 절차 생성 — tools/map/rogue-map.mjs(JS 미러)와 로직 동기화 유지
self.MapNodes = {}
self.MapStart = {}
self.VisitedNodes = {}
self.Depth = 0
self.MapNodes["boss"] = { type = "boss", row = ${MAP_ROWS} + 1, col = 0, next = {} }
local cols = { 1, 2, 3, 4 }
for i = #cols, 2, -1 do
local j = math.random(1, i)
cols[i], cols[j] = cols[j], cols[i]
end
local starts = { cols[1], cols[2], math.random(1, ${MAP_COLS}), math.random(1, ${MAP_COLS}) }
for p = 1, 4 do
local c = starts[p]
local sid = "r1c" .. tostring(c)
if self.MapNodes[sid] == nil then
self.MapNodes[sid] = { type = "combat", row = 1, col = c, next = {} }
end
local found = false
for i = 1, #self.MapStart do
if self.MapStart[i] == sid then found = true end
end
if found == false then
table.insert(self.MapStart, sid)
end
for r = 1, ${MAP_ROWS} - 1 do
local nc = c + math.random(-1, 1)
if nc < 1 then nc = 1 end
if nc > ${MAP_COLS} then nc = ${MAP_COLS} end
local nid = "r" .. tostring(r + 1) .. "c" .. tostring(nc)
if self.MapNodes[nid] == nil then
self.MapNodes[nid] = { type = "combat", row = r + 1, col = nc, next = {} }
end
local fid = "r" .. tostring(r) .. "c" .. tostring(c)
local dup = false
for i = 1, #self.MapNodes[fid].next do
if self.MapNodes[fid].next[i] == nid then dup = true end
end
if dup == false then
table.insert(self.MapNodes[fid].next, nid)
end
c = nc
end
local lid = "r" .. tostring(${MAP_ROWS}) .. "c" .. tostring(c)
local bdup = false
for i = 1, #self.MapNodes[lid].next do
if self.MapNodes[lid].next[i] == "boss" then bdup = true end
end
if bdup == false then
table.insert(self.MapNodes[lid].next, "boss")
end
end
for r = 3, ${MAP_ROWS} do
for c = 1, ${MAP_COLS} do
local id = "r" .. tostring(r) .. "c" .. tostring(c)
local node = self.MapNodes[id]
if node ~= nil then
local eliteParent = false
for pid, pn in pairs(self.MapNodes) do
if pn.row == r - 1 and pn.type == "elite" then
for i = 1, #pn.next do
if pn.next[i] == id then eliteParent = true end
end
end
end
local w
if r == ${MAP_ROWS} then
w = { { "rest", 50 }, { "combat", 25 }, { "shop", 10 }, { "elite", 8 }, { "treasure", 7 } }
elseif r >= 4 then
w = { { "combat", 45 }, { "elite", 16 }, { "shop", 12 }, { "rest", 12 }, { "treasure", 15 } }
else
w = { { "combat", 45 }, { "shop", 12 }, { "rest", 12 } }
end
local total = 0
for i = 1, #w do
if w[i][1] == "elite" and eliteParent == true then
w[i][2] = 0
end
total = total + w[i][2]
end
local roll = math.random() * total
local acc = 0
for i = 1, #w do
acc = acc + w[i][2]
if roll <= acc then
node.type = w[i][1]
break
end
end
end
end
end`),
method('IsReachable', `local list
if self.CurrentNodeId == "" then
list = self.MapStart
@@ -3512,17 +3593,25 @@ if self:IsReachable(id) ~= true then
return
end
self.CurrentNodeId = id
if self.VisitedNodes == nil then
self.VisitedNodes = {}
end
table.insert(self.VisitedNodes, id)
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud")
if hud ~= nil then
hud.Enable = false
end
local node = self.MapNodes[id]
self.Depth = node.row
self:RenderRun()
if node.type == "shop" then
self:ShowShop()
elseif node.type == "rest" then
self:ShowRest()
elseif node.type == "treasure" then
self:ShowTreasure()
else
self.CurrentEnemyId = node.enemy
self.CurrentEnemyId = ""
self:StartCombat()
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]),
method('ShowShop', `local pool = {}