Files
maplecontest/docs/superpowers/plans/2026-06-09-map-nodes.md
2026-06-09 03:18:49 +09:00

18 KiB

분기 맵 노드 진행 (TODO E3) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 플레이어가 작성된 분기 맵(DAG)에서 다음 노드를 선택해 전투/엘리트/보스로 진행, 보스 클리어 시 "런 클리어".

Architecture: data/map.json(그래프)·data/enemies.json(다중 적)을 gen-slaydeck.mjs가 로드·주입. SlayDeckController에 맵 상태·네비게이션 메서드 추가, MapHud UI 생성. 자동 진행 대신 ShowMap→PickNode→StartCombat→보상→ShowMap 루프.

Tech Stack: Node.js ESM 생성기, MSW Lua codeblock/UI. 검증은 node --check+재생성+결정성+메이커 Play.


File Structure

  • Create: data/map.json — 분기 맵.
  • Modify: data/enemies.json — slime_elite·slime_boss 추가.
  • Modify: tools/gen-slaydeck.mjs — 맵/적 로드·검증·직렬화 헬퍼, method() returnType, 속성·메서드·MapHud UI.

검증: MSW Lua 단위테스트 불가 → 생성기 문법·재생성·결정성·메이커 Play.


Task 1: 데이터 + 로드·검증·직렬화 헬퍼

Files: Create data/map.json; Modify data/enemies.json, tools/gen-slaydeck.mjs

  • Step 1: data/map.json 작성
{
  "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": ["D", "E"] },
    "C": { "type": "elite", "enemy": "slime_elite", "row": 2, "col": -2, "next": ["BOSS"] },
    "D": { "type": "combat", "enemy": "slime", "row": 2, "col": 0, "next": ["BOSS"] },
    "E": { "type": "combat", "enemy": "slime", "row": 2, "col": 2, "next": ["BOSS"] },
    "BOSS": { "type": "boss", "enemy": "slime_boss", "row": 3, "col": 0, "next": [] }
  }
}
  • Step 2: data/enemies.json에 엘리트·보스 추가slime 항목 다음에:
    "slime_elite": {
      "name": "정예 슬라임",
      "maxHp": 70,
      "intents": [
        { "kind": "Attack", "value": 14 },
        { "kind": "Attack", "value": 8 },
        { "kind": "Defend", "value": 10 }
      ]
    },
    "slime_boss": {
      "name": "슬라임 킹",
      "maxHp": 120,
      "intents": [
        { "kind": "Attack", "value": 18 },
        { "kind": "Defend", "value": 12 },
        { "kind": "Attack", "value": 10 },
        { "kind": "Attack", "value": 22 }
      ]
    }
  • Step 3: 생성기 상단에 map 로드·검증·헬퍼 추가const ACTIVE_ENEMY = ...; 다음에:
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(', ') + ' }';
}
  • Step 4: method()에 ReturnType 파라미터 추가 — 기존 method 함수를:
function method(Name, Code, Arguments = [], ExecSpace = 0, ReturnType = 'void') {
  return {
    Return: { Type: ReturnType, DefaultValue: null, SyncDirection: 0, Attributes: [], Name: null },
    Arguments,
    Code,
    Scope: 2,
    ExecSpace,
    Attributes: [],
    Name,
  };
}
  • Step 5: JSON·문법 검사

Run: node -e "JSON.parse(require('fs').readFileSync('data/map.json','utf8')); JSON.parse(require('fs').readFileSync('data/enemies.json','utf8')); console.log('JSON OK')" && node --check tools/gen-slaydeck.mjs Expected: JSON OK + 오류 없음

  • Step 6: 커밋
git add data/map.json data/enemies.json tools/gen-slaydeck.mjs
git commit -m "data(E3): 분기 맵 map.json·엘리트/보스 적 + 직렬화 헬퍼"

Task 2: 맵 속성 + StartRun(맵 빌드·ShowMap)

Files: Modify tools/gen-slaydeck.mjs

  • Step 1: 맵 상태 속성 추가prop('boolean', 'RunActive', 'false'), 다음에:
    prop('any', 'Enemies'),
    prop('any', 'MapNodes'),
    prop('any', 'MapStart'),
    prop('string', 'CurrentNodeId', '""'),
    prop('string', 'CurrentEnemyId', '""'),
  • Step 2: StartRun 교체 — 맵 빌드 + ShowMap:
    method('StartRun', `self.PlayerMaxHp = 80
self.PlayerHp = self.PlayerMaxHp
self.Gold = 0
self.Floor = 0
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:ShowMap()`),
  • Step 3: 문법 검사

Run: node --check tools/gen-slaydeck.mjs Expected: 오류 없음

  • Step 4: 커밋
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E3): 맵 상태 속성·StartRun 맵 빌드/ShowMap"

Task 3: StartCombat·CheckCombatEnd·PickReward (맵 연동)

Files: Modify tools/gen-slaydeck.mjs

  • Step 1: StartCombat 교체 — 적을 self.Enemies에서 로드, Floor=노드 row:
    method('StartCombat', `self.MaxEnergy = 3
self.Turn = 0
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 = enemy.name
self.EnemyMaxHp = enemy.maxHp
self.EnemyHp = self.EnemyMaxHp
self.EnemyBlock = 0
self.EnemyIntents = enemy.intents
self.EnemyIntentIndex = 1
self.CombatOver = false
self.DiscardPile = {}
self.Hand = {}
${luaCardsTable(CARDS.cards)}
self.DrawPile = {}
for i = 1, #self.RunDeck do
	self.DrawPile[i] = self.RunDeck[i]
end
self:Shuffle(self.DrawPile)
self:RenderCombat()
self:StartPlayerTurn()`),
  • Step 2: CheckCombatEnd 교체 — 보스 노드면 런 클리어:
    method('CheckCombatEnd', `if self.EnemyHp <= 0 then
	self.CombatOver = true
	self.Gold = self.Gold + ${GOLD_PER_WIN}
	self:RenderRun()
	local node = self.MapNodes[self.CurrentNodeId]
	if node ~= nil and node.type == "boss" then
		self:ShowResult("런 클리어!")
		self.RunActive = false
	else
		self:OfferReward()
	end
elseif self.PlayerHp <= 0 then
	self.CombatOver = true
	self:ShowResult("패배...")
	self.RunActive = false
end`),
  • Step 3: PickReward 마지막을 ShowMap으로 — PickReward 코드의 마지막 self:StartCombat()self:ShowMap()로 교체. (그 외 동일)
    method('PickReward', `if self.CombatOver ~= true or self.RunActive ~= true then
	return
end
if slot ~= 0 and self.RewardChoices ~= nil then
	local id = self.RewardChoices[slot]
	if id ~= nil then
		table.insert(self.RunDeck, id)
	end
end
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud")
if hud ~= nil then
	hud.Enable = false
end
self:ShowMap()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
  • Step 4: 문법 검사

Run: node --check tools/gen-slaydeck.mjs Expected: 오류 없음

  • Step 5: 커밋
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E3): StartCombat 적 데이터화·보스 런클리어·보상후 맵복귀"

Task 4: ShowMap·IsReachable·PickNode·RenderMap + BindButtons

Files: Modify tools/gen-slaydeck.mjs

  • Step 1: 맵 메서드 추가 — PickReward 메서드 다음(마지막 ]); 직전)에 삽입:
    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' }]),
  • Step 2: BindButtons에 맵 노드 버튼 바인딩 추가 — BindButtons 코드의 마지막 end(skip 바인딩) 다음에 추가. BindButtons 끝부분의 skip 블록 다음에 붙이도록, skip 블록을 아래로 교체:
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`),

(BindButtons 전체에서 기존 skip 블록 local skip = ... end)` 부분을 위 블록으로 교체)

  • Step 3: 문법 검사

Run: node --check tools/gen-slaydeck.mjs Expected: 오류 없음

  • Step 4: 커밋
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E3): ShowMap/IsReachable/PickNode/RenderMap·맵 노드 바인딩"

Task 5: MapHud UI 생성

Files: Modify tools/gen-slaydeck.mjs (guid, upsertUi)

  • Step 1: guid 'map' 분기 — ns 매핑에 추가:
  const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : prefix === 'map' ? 0xcd : 0xfe;
  • Step 2: 필터 확장 — upsertUi 필터에 MapHud 추가:
  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'));
  • Step 3: MapHud 그룹 생성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);
  • Step 4: 문법 검사

Run: node --check tools/gen-slaydeck.mjs Expected: 오류 없음

  • Step 5: 커밋
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E3): MapHud 노드 맵 UI 생성"

Task 6: 재생성 + 검증

Files: 생성물

  • Step 1: 생성

Run: node tools/gen-slaydeck.mjs Expected: Slay deck UI and combat codeblocks generated.

  • Step 2: 메서드·UI·적 주입 확인

Run: node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const n=j.ContentProto.Json.Methods.map(m=>m.Name); console.log(['ShowMap','PickNode','IsReachable','RenderMap'].every(x=>n.includes(x))?'METHODS OK':'MISSING'); const sc=j.ContentProto.Json.Methods.find(m=>m.Name==='StartRun').Code; console.log(/slime_boss/.test(sc)&&/슬라임 킹/.test(sc)?'ENEMIES OK':'NO ENEMIES'); const u=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8')); const has=p=>u.ContentProto.Entities.some(e=>e.path===p); console.log(has('/ui/DefaultGroup/MapHud')&&has('/ui/DefaultGroup/MapHud/Node_BOSS')&&has('/ui/DefaultGroup/MapHud/Node_A/Label')?'UI OK':'UI MISSING')" Expected: METHODS OK / ENEMIES OK / UI OK

  • Step 3: 결정성

Run: node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/a.sha && node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/b.sha && diff /tmp/a.sha /tmp/b.sha && echo DETERMINISTIC Expected: DETERMINISTIC

  • Step 4: git status

Run: git checkout -- Global/common.gamelogic 2>/dev/null; git status --short Expected: data/*, tools/gen-slaydeck.mjs, ui/DefaultGroup.ui, RootDesk/MyDesk/SlayDeckController.codeblock (+docs).

  • Step 5: 생성물 커밋
git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock
git commit -m "재생성(E3): 분기 맵·다중 적 반영"
  • Step 6: 메이커 Play 수동 검증 (MCP)

reload→Play: StartRun → MapHud(A·B만 클릭 가능) → PickNode("A") → 슬라임 전투 → 승리 → 보상 → 맵(C·D 활성) → PickNode("C") → 정예 슬라임(HP70) → ... → BOSS → 슬라임킹(HP120) → 승리 → "런 클리어!". 도달 불가 노드 PickNode → 무시. MCP는 PickNode/PlayCard/PickReward 직접 호출 + 상태 로그로 검증.


Self-Review

  • Spec coverage: map.json/적(Task1), 맵 상태·StartRun(Task2), StartCombat 적데이터·보스클리어·보상후맵(Task3), Show/Pick/Reachable/RenderMap·바인딩(Task4), MapHud UI(Task5), 검증(Task6). 스펙 전 항목 매핑.
  • Placeholder scan: 모든 단계 실제 코드/명령.
  • Type consistency: 메서드 StartRun/ShowMap/IsReachable/PickNode/RenderMap/StartCombat/CheckCombatEnd/PickReward 정의·호출 일치. 속성 Enemies/MapNodes/MapStart/CurrentNodeId/CurrentEnemyId 정의(Task2)·사용(Task3·4) 일치. UI 경로 /ui/DefaultGroup/MapHud/Node_{id}·/Label가 codeblock(RenderMap/PickNode/BindButtons)·생성(Task5)에서 동일(노드 id는 map.json 키). IsReachable는 boolean 반환(method returnType param, Task1). enemy 필드 name/maxHp/intents가 데이터·luaEnemiesTable·StartCombat에서 일치.