feat(E3): 분기 맵 노드 진행 — 경로 선택·적 차등·보스 클리어
단일 경로 자동 진행을 분기 맵 네비게이션으로 확장. - data/map.json: 분기 DAG(start + nodes: type/enemy/row/col/next). A,B→C,D,E→BOSS - data/enemies.json: slime_elite(HP70)·slime_boss(HP120) 추가 - SlayDeckController: Enemies(전체 적)·MapNodes·MapStart·CurrentNodeId·CurrentEnemyId 속성 - StartRun→맵 빌드·ShowMap, PickNode(도달성 검증)→StartCombat(적은 self.Enemies에서) - ShowMap/IsReachable(boolean)/RenderMap(도달 가능 노드만 활성·강조)/PickNode - 승리→보상→ShowMap 복귀, 보스 노드 승리 시 '런 클리어!' - MapHud UI: 노드 버튼(행=y/col=x), 타입+적 라벨, 모달 배경 - 생성기: method() returnType 파라미터, 다중 적/맵 Lua 직렬화 헬퍼 - 메이커 Play 검증: 맵→A→보상→C(엘리트)→보스→런 클리어, 도달불가 노드 무시 - 범위 밖(후속): 상점/휴식(E4)·유물(E5)·저장(E6)·절차적 맵·연결선 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,37 @@ if (!ENEMIES.enemies[ENEMIES.activeEnemy]) {
|
||||
}
|
||||
const ACTIVE_ENEMY = ENEMIES.enemies[ENEMIES.activeEnemy];
|
||||
|
||||
const MAP = JSON.parse(readFileSync('data/map.json', 'utf8'));
|
||||
for (const id of MAP.start) {
|
||||
if (!MAP.nodes[id]) throw new Error(`[gen-slaydeck] map.start에 없는 노드 id: ${id}`);
|
||||
}
|
||||
for (const [id, n] of Object.entries(MAP.nodes)) {
|
||||
if (!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));
|
||||
|
||||
function luaIntentsArray(intents) {
|
||||
return '{ ' + intents.map((it) => `{ kind = ${luaStr(it.kind)}, value = ${it.value} }`).join(', ') + ' }';
|
||||
}
|
||||
function luaEnemiesTable(enemies) {
|
||||
const lines = Object.entries(enemies).map(([id, e]) =>
|
||||
`\t${id} = { name = ${luaStr(e.name)}, maxHp = ${e.maxHp}, intents = ${luaIntentsArray(e.intents)} },`);
|
||||
return `self.Enemies = {\n${lines.join('\n')}\n}`;
|
||||
}
|
||||
function luaMapNodesTable(nodes) {
|
||||
const lines = Object.entries(nodes).map(([id, n]) => {
|
||||
const nx = '{ ' + n.next.map(luaStr).join(', ') + ' }';
|
||||
return `\t${id} = { type = ${luaStr(n.type)}, enemy = ${luaStr(n.enemy)}, 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, '\\"') + '"';
|
||||
@@ -60,7 +91,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 : 0xfe;
|
||||
const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : prefix === 'map' ? 0xcd : 0xfe;
|
||||
const v = (ns * 0x100000 + n) >>> 0;
|
||||
return `${v.toString(16).padStart(8, '0')}-0000-4000-8000-${v.toString(16).padStart(12, '0')}`;
|
||||
}
|
||||
@@ -212,7 +243,7 @@ function entity({ id, path, modelId, entryId, componentNames, components, displa
|
||||
function upsertUi() {
|
||||
const ui = JSON.parse(readFileSync(UI_FILE, 'utf8'));
|
||||
const E = ui.ContentProto.Entities;
|
||||
ui.ContentProto.Entities = E.filter((e) => !e.path.startsWith('/ui/DefaultGroup/DeckHud') && !e.path.startsWith('/ui/DefaultGroup/CombatHud') && !e.path.startsWith('/ui/DefaultGroup/RewardHud'));
|
||||
ui.ContentProto.Entities = E.filter((e) => !e.path.startsWith('/ui/DefaultGroup/DeckHud') && !e.path.startsWith('/ui/DefaultGroup/CombatHud') && !e.path.startsWith('/ui/DefaultGroup/RewardHud') && !e.path.startsWith('/ui/DefaultGroup/MapHud'));
|
||||
|
||||
const byPath = new Map(ui.ContentProto.Entities.map((e) => [e.path, e]));
|
||||
|
||||
@@ -567,6 +598,68 @@ function upsertUi() {
|
||||
}));
|
||||
ui.ContentProto.Entities.push(...reward);
|
||||
|
||||
const TYPE_KO = { combat: '전투', elite: '엘리트', boss: '보스' };
|
||||
const map = [];
|
||||
const mapHud = entity({
|
||||
id: guid('map', 0),
|
||||
path: '/ui/DefaultGroup/MapHud',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 7,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.05, g: 0.06, b: 0.09, a: 0.9 }, type: 1, raycast: true }),
|
||||
],
|
||||
});
|
||||
mapHud.jsonString.enable = false;
|
||||
map.push(mapHud);
|
||||
map.push(entity({
|
||||
id: guid('map', 1),
|
||||
path: '/ui/DefaultGroup/MapHud/Title',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 60 }, pos: { x: 0, y: 510 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '다음 노드 선택', fontSize: 40, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
let mapN = 2;
|
||||
for (const [id, node] of Object.entries(MAP.nodes)) {
|
||||
const nodePath = `/ui/DefaultGroup/MapHud/Node_${id}`;
|
||||
const pos = { x: node.col * 180, y: node.row * 170 - 80 };
|
||||
map.push(entity({
|
||||
id: guid('map', mapN++),
|
||||
path: nodePath,
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
|
||||
displayOrder: node.row,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 150, y: 80 }, pos }),
|
||||
sprite({ color: { r: 0.3, g: 0.55, b: 0.85, a: 1 }, type: 1, raycast: true }),
|
||||
button(),
|
||||
],
|
||||
}));
|
||||
map.push(entity({
|
||||
id: guid('map', mapN++),
|
||||
path: `${nodePath}/Label`,
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 150, parentH: 80, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 144, y: 72 }, pos: { x: 0, y: 0 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: `${TYPE_KO[node.type]}\n${ENEMIES.enemies[node.enemy].name}`, fontSize: 20, bold: true }),
|
||||
],
|
||||
}));
|
||||
}
|
||||
ui.ContentProto.Entities.push(...map);
|
||||
|
||||
JSON.parse(JSON.stringify(ui));
|
||||
writeFileSync(UI_FILE, JSON.stringify(ui, null, 2), 'utf8');
|
||||
}
|
||||
@@ -575,9 +668,9 @@ function prop(Type, Name, DefaultValue = 'nil') {
|
||||
return { Type, DefaultValue, SyncDirection: 0, Attributes: [], Name };
|
||||
}
|
||||
|
||||
function method(Name, Code, Arguments = [], ExecSpace = 0) {
|
||||
function method(Name, Code, Arguments = [], ExecSpace = 0, ReturnType = 'void') {
|
||||
return {
|
||||
Return: { Type: 'void', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: null },
|
||||
Return: { Type: ReturnType, DefaultValue: null, SyncDirection: 0, Attributes: [], Name: null },
|
||||
Arguments,
|
||||
Code,
|
||||
Scope: 2,
|
||||
@@ -649,26 +742,40 @@ function writeCodeblocks() {
|
||||
prop('number', 'RunLength', String(RUN_LENGTH)),
|
||||
prop('any', 'RewardChoices'),
|
||||
prop('boolean', 'RunActive', 'false'),
|
||||
prop('any', 'Enemies'),
|
||||
prop('any', 'MapNodes'),
|
||||
prop('any', 'MapStart'),
|
||||
prop('string', 'CurrentNodeId', '""'),
|
||||
prop('string', 'CurrentEnemyId', '""'),
|
||||
], [
|
||||
method('OnBeginPlay', `self:StartRun()`),
|
||||
method('StartRun', `self.PlayerMaxHp = 80
|
||||
self.PlayerHp = self.PlayerMaxHp
|
||||
self.Gold = 0
|
||||
self.Floor = 0
|
||||
self.RunLength = ${RUN_LENGTH}
|
||||
self.RunLength = ${MAX_ROW}
|
||||
self.RunDeck = { ${CARDS.starterDeck.map(luaStr).join(', ')} }
|
||||
self.RunActive = true
|
||||
${luaEnemiesTable(ENEMIES.enemies)}
|
||||
${luaMapNodesTable(MAP.nodes)}
|
||||
${luaStartArray(MAP.start)}
|
||||
self.CurrentNodeId = ""
|
||||
self.CurrentEnemyId = ""
|
||||
self:BindButtons()
|
||||
self:StartCombat()`),
|
||||
self:ShowMap()`),
|
||||
method('StartCombat', `self.MaxEnergy = 3
|
||||
self.Turn = 0
|
||||
self.Floor = self.Floor + 1
|
||||
local node = self.MapNodes[self.CurrentNodeId]
|
||||
if node ~= nil then
|
||||
self.Floor = node.row
|
||||
end
|
||||
local enemy = self.Enemies[self.CurrentEnemyId]
|
||||
self.PlayerBlock = 0
|
||||
self.EnemyName = ${luaStr(ACTIVE_ENEMY.name)}
|
||||
self.EnemyMaxHp = ${ACTIVE_ENEMY.maxHp}
|
||||
self.EnemyName = enemy.name
|
||||
self.EnemyMaxHp = enemy.maxHp
|
||||
self.EnemyHp = self.EnemyMaxHp
|
||||
self.EnemyBlock = 0
|
||||
${luaIntentsTable(ACTIVE_ENEMY.intents)}
|
||||
self.EnemyIntents = enemy.intents
|
||||
self.EnemyIntentIndex = 1
|
||||
self.CombatOver = false
|
||||
self.DiscardPile = {}
|
||||
@@ -711,6 +818,14 @@ end
|
||||
local skip = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud/Skip")
|
||||
if skip ~= nil and skip.ButtonComponent ~= nil then
|
||||
skip:ConnectEvent(ButtonClickEvent, function() self:PickReward(0) end)
|
||||
end
|
||||
local mapNodeIds = { ${Object.keys(MAP.nodes).map(luaStr).join(', ')} }
|
||||
for i = 1, #mapNodeIds do
|
||||
local nid = mapNodeIds[i]
|
||||
local mn = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud/Node_" .. nid)
|
||||
if mn ~= nil and mn.ButtonComponent ~= nil then
|
||||
mn:ConnectEvent(ButtonClickEvent, function() self:PickNode(nid) end)
|
||||
end
|
||||
end`),
|
||||
method('StartPlayerTurn', `self.Turn = self.Turn + 1
|
||||
self.Energy = self.MaxEnergy
|
||||
@@ -895,7 +1010,8 @@ self:RenderCombat()`),
|
||||
self.CombatOver = true
|
||||
self.Gold = self.Gold + ${GOLD_PER_WIN}
|
||||
self:RenderRun()
|
||||
if self.Floor >= self.RunLength then
|
||||
local node = self.MapNodes[self.CurrentNodeId]
|
||||
if node ~= nil and node.type == "boss" then
|
||||
self:ShowResult("런 클리어!")
|
||||
self.RunActive = false
|
||||
else
|
||||
@@ -976,7 +1092,57 @@ local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud")
|
||||
if hud ~= nil then
|
||||
hud.Enable = false
|
||||
end
|
||||
self:StartCombat()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
self:ShowMap()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('ShowMap', `self:RenderMap()
|
||||
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud")
|
||||
if hud ~= nil then
|
||||
hud.Enable = true
|
||||
end`),
|
||||
method('IsReachable', `local list
|
||||
if self.CurrentNodeId == "" then
|
||||
list = self.MapStart
|
||||
else
|
||||
local node = self.MapNodes[self.CurrentNodeId]
|
||||
if node == nil then
|
||||
return false
|
||||
end
|
||||
list = node.next
|
||||
end
|
||||
for i = 1, #list do
|
||||
if list[i] == id then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }], 0, 'boolean'),
|
||||
method('RenderMap', `for id, node in pairs(self.MapNodes) do
|
||||
local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud/Node_" .. id)
|
||||
if e ~= nil then
|
||||
local reachable = self:IsReachable(id)
|
||||
if e.SpriteGUIRendererComponent ~= nil then
|
||||
if reachable then
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.3, 0.55, 0.85, 1)
|
||||
else
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6)
|
||||
end
|
||||
end
|
||||
if e.ButtonComponent ~= nil then
|
||||
e.ButtonComponent.Enable = reachable
|
||||
end
|
||||
end
|
||||
end`),
|
||||
method('PickNode', `if self.RunActive ~= true then
|
||||
return
|
||||
end
|
||||
if self:IsReachable(id) ~= true then
|
||||
return
|
||||
end
|
||||
self.CurrentNodeId = id
|
||||
self.CurrentEnemyId = self.MapNodes[id].enemy
|
||||
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud")
|
||||
if hud ~= nil then
|
||||
hud.Enable = false
|
||||
end
|
||||
self:StartCombat()`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]),
|
||||
]);
|
||||
for (const m of combat.ContentProto.Json.Methods) {
|
||||
m.ExecSpace = 6;
|
||||
|
||||
Reference in New Issue
Block a user