9.9 KiB
막별 맵 전환 + 맵별 인카운터 (P4) 구현 계획
For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development. T3·T4는 컨트롤러 직접.
Goal: 보스 클리어 시 다음 막의 맵(map02, map03)으로 텔레포트하고, 각 맵에 테마 몬스터 그룹(combat3/elite2/boss1)을 자동 구성.
Architecture: 신규 tools/map/gen-map-encounters.mjs가 map02~11 몬스터를 전면 교체(결정론). 컨트롤러는 RegisterMonster에 mapName 차원 추가 + BuildMonsters 플레이어 맵 필터 + 보스 클리어 시 TeleportToActMap.
배경 (구현자용)
- 생성물은 단일 소스 규칙(gen-slaydeck → ui/codeblock/common). 맵 파일은 전용 생성기가 직접 패치. 산출물(slaydeck 3종)은 마지막에 일괄, 맵 파일은 T1에서 바로 커밋.
- 현재
RegisterMonster(monster, enemyId, group)(3인자),BuildMonsters는r.group == g필터.CombatMonster.codeblock은tools/monster/gen-combat-monster.mjs가 생성(OnBeginPlay에서 3인자 등록). - gen-maps의
MONSTER_VARIANTS9종(sprite/stand/hit/die RUID)과mapGuid(nn, idx)·rng(seed)패턴 참조:tools/map/gen-maps.mjs. - CheckCombatEnd 보스 분기:
self.Floor = self.Floor + 1 ... self:ShowMap(). - JS 상수: writeCodeblocks 안
ACT_COUNT = 3존재.
Task 1: gen-map-encounters.mjs (map02~11 인카운터)
Files: Create tools/map/gen-map-encounters.mjs; Modify(산출) map/map02.map~map11.map
- Step 1: 생성기 작성.
tools/map/gen-maps.mjs를 READ해MONSTER_VARIANTS(9종 배열 — 그대로 복사)·rng·mapGuid패턴을 가져와 아래 구조로 작성:
import { readFileSync, writeFileSync } from 'node:fs';
// map02~11에 노드 타입별 몬스터 그룹(combat3/elite2/boss1)을 맵별 테마로 자동 구성.
// 기존 몬스터 엔티티를 전부 제거하고 첫 몬스터를 템플릿으로 6마리 재생성(결정론).
const MAP_NUMBERS = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
const COMBAT_POOL = ['orange_mushroom', 'green_mushroom', 'pig', 'blue_mushroom'];
const ELITE_POOL = ['mushmom', 'modified_snail'];
const BOSS_POOL = ['king_slime', 'slime_boss'];
const LAYOUT = [
{ group: 'combat', x: 2.3 }, { group: 'combat', x: 3.8 }, { group: 'combat', x: 5.2 },
{ group: 'elite', x: 3.0 }, { group: 'elite', x: 5.0 },
{ group: 'boss', x: 4.0 },
];
const MONSTER_VARIANTS = [ /* gen-maps.mjs에서 9종 그대로 복사 */ ];
function rng(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
function encGuid(nn, idx) {
const n = (nn * 1000 + 500 + idx) >>> 0; // gen-maps의 mapGuid(idx 0~)와 비충돌(+500 오프셋)
return `${n.toString(16).padStart(8, '0')}-0000-4000-8000-${n.toString(16).padStart(12, '0')}`;
}
const isMonster = (e) => typeof e.componentNames === 'string' && e.componentNames.includes('script.Monster');
const compOf = (e, t) => e.jsonString['@components'].find((c) => c['@type'] === t);
function pick(rand, pool) { return pool[Math.floor(rand() * pool.length)]; }
function pickN(rand, pool, n) { // 중복 없는 n개(부족하면 순환)
const a = pool.slice();
const out = [];
for (let i = 0; i < n; i++) {
if (a.length === 0) a.push(...pool);
out.push(a.splice(Math.floor(rand() * a.length), 1)[0]);
}
return out;
}
function patchMap(nn) {
const tag = String(nn).padStart(2, '0');
const file = `map/map${tag}.map`;
const map = JSON.parse(readFileSync(file, 'utf8'));
const ents = map.ContentProto.Entities;
const monsters = ents.filter(isMonster);
if (monsters.length === 0) throw new Error(`[gen-map-encounters] ${file} 몬스터 템플릿 없음`);
const template = monsters[0];
map.ContentProto.Entities = ents.filter((e) => !isMonster(e));
const rand = rng(nn * 7919 + 17);
const combatIds = pickN(rand, COMBAT_POOL, 3);
const eliteIds = pickN(rand, ELITE_POOL, 2);
const bossId = pick(rand, BOSS_POOL);
const variants = pickN(rand, MONSTER_VARIANTS, 6);
LAYOUT.forEach((slot, idx) => {
const m = JSON.parse(JSON.stringify(template));
const enemyId = slot.group === 'combat' ? combatIds[idx] : slot.group === 'elite' ? eliteIds[idx - 3] : bossId;
const name = `${slot.group}_${idx + 1}`;
m.id = encGuid(nn, idx);
m.path = `/maps/map${tag}/${name}`;
m.jsonString.path = m.path;
m.jsonString.name = name;
const o = m.jsonString.origin;
if (o) { if (o.root_entity_id) o.root_entity_id = m.id; if (o.sub_entity_id) o.sub_entity_id = m.id; }
const tr = compOf(m, 'MOD.Core.TransformComponent');
if (tr && tr.Position) tr.Position.x = slot.x;
const v = variants[idx];
const sp = compOf(m, 'MOD.Core.SpriteRendererComponent');
if (sp) sp.SpriteRUID = v.stand;
const sa = compOf(m, 'MOD.Core.StateAnimationComponent');
if (sa) sa.ActionSheet = { stand: v.stand, hit: v.hit, die: v.die };
let cm = compOf(m, 'script.CombatMonster');
if (!cm) {
cm = { '@type': 'script.CombatMonster', Enable: true };
m.jsonString['@components'].push(cm);
const names = (m.componentNames || '').split(',').filter((s) => s && s !== 'script.CombatMonster');
names.push('script.CombatMonster');
m.componentNames = names.join(',');
}
cm.EnemyId = enemyId;
cm.Group = slot.group;
map.ContentProto.Entities.push(m);
});
writeFileSync(file, JSON.stringify(map, null, 2), 'utf8');
return `map${tag}(${combatIds.join('/')}|${eliteIds.join('/')}|${bossId})`;
}
const made = MAP_NUMBERS.map(patchMap);
console.log('Encounters:', made.join(', '));
- Step 2: 실행 + 검증: 각 맵 6마리·그룹 3/2/1·EnemyId 전부 enemies.json 존재·dup guid 0:
node tools/map/gen-map-encounters.mjs && node -e "const en=JSON.parse(require('fs').readFileSync('data/enemies.json','utf8')).enemies;let bad=0;for(let n=2;n<=11;n++){const t=String(n).padStart(2,'0');const m=JSON.parse(require('fs').readFileSync('map/map'+t+'.map','utf8'));const ms=m.ContentProto.Entities.filter(e=>(e.componentNames||'').includes('script.CombatMonster'));const g={combat:0,elite:0,boss:0};for(const e of ms){const c=e.jsonString['@components'].find(x=>x['@type']==='script.CombatMonster');g[c.Group]++;if(!en[c.EnemyId]){bad++;console.log('BAD enemy',t,c.EnemyId);}}if(!(g.combat===3&&g.elite===2&&g.boss===1)){bad++;console.log('BAD groups',t,JSON.stringify(g));}const ids=m.ContentProto.Entities.map(e=>e.id);if(ids.length!==new Set(ids).size){bad++;console.log('DUP guid',t);}}console.log(bad===0?'all maps OK':'BAD:'+bad)"2회 실행 동일(결정론) 확인. - Step 3: Commit:
git add tools/map/gen-map-encounters.mjs map/ && git commit -m "feat(act-maps): map02~11 인카운터 자동 구성 (combat3/elite2/boss1·맵별 테마)"
Task 2: 컨트롤러 — 맵 필터 + 막 텔레포트
Files: Modify tools/deck/gen-slaydeck.mjs, tools/monster/gen-combat-monster.mjs
- Step 1 (gen-combat-monster): OnBeginPlay 등록 호출을 4인자로 — 자기 맵 이름 전달:
local mapName = ""
if self.Entity.CurrentMapName ~= nil then
mapName = self.Entity.CurrentMapName
end
c.SlayDeckController:RegisterMonster(self.Entity, self.EnemyId, self.Group, mapName)
(reg 함수 내 기존 RegisterMonster 줄 교체. CurrentMapName 미지원이면 빈 문자열 — BuildMonsters에서 빈 값은 항상 통과시켜 하위 호환.)
- Step 2 (gen-slaydeck):
RegisterMonster에 4번째 인자mapName(string) 추가, 저장 항목에map = mapName(nil/빈 처리:local mp = mapName; if mp == nil then mp = "" end). - Step 3 (gen-slaydeck BuildMonsters): 그룹 필터 줄을 확장:
local pmap = ""
local lp = _UserService.LocalPlayer
if lp ~= nil and lp.CurrentMapName ~= nil then pmap = lp.CurrentMapName end
(reg 수집 루프 앞에 추가) 그리고 필터 조건을 r.group == g and (r.map == nil or r.map == "" or pmap == "" or r.map == pmap) 로.
- Step 4 (gen-slaydeck 막 전환): writeCodeblocks에
const ACT_MAPS = ['map01', 'map02', 'map03'];추가(ACT_COUNT 옆).CheckCombatEnd보스 분기의self:RenderRun()다음,self:ShowMap()앞에self:TeleportToActMap()삽입. 신규 메서드:
method('TeleportToActMap', `local maps = { ${ACT_MAPS.map((m) => `"${m}"`).join(', ')} }
local target = maps[self.Floor]
if target == nil then
return
end
local lp = _UserService.LocalPlayer
if lp == nil then
return
end
if lp.CurrentMapName == target then
return
end
_TeleportService:TeleportToMapPosition(lp, Vector3(-6, 0.03, 0), target)`),
- Step 5:
node --check둘 다 → gen-combat-monster 실행(코드블록 재생성+맵 no-clobber 확인) → gen-slaydeck 실행 → codeblock에 TeleportToActMap·4인자 등록·맵 필터 확인 → slaydeck 산출물 복원(codeblock/ui/common), CombatMonster.codeblock은 커밋 대상. - Step 6: Commit:
git add tools/deck/gen-slaydeck.mjs tools/monster/gen-combat-monster.mjs RootDesk/MyDesk/CombatMonster.codeblock map/ && git commit -m "feat(act-maps): 막별 맵 텔레포트 + 등록 맵 필터"(map/은 gen-combat-monster 재실행이 기존 맵 값 보존하므로 변화 없을 것 — 변화 있으면 확인 후 포함)
Task 3 (컨트롤러 직접): 재생성·검증·커밋
P2/P3 T5와 동일: gen-slaydeck 재생성→dup0·심볼(TeleportToActMap)·결정성·sim→산출물 커밋.
Task 4 (컨트롤러 직접): 메이커 검증 + 푸시 + PR + 머지
1막 보스 처치(스크립트)→Floor2 텔레포트→map02 도착(스크린샷: 새 배경·새 몬스터들)→전투 진입(combat 그룹 3마리·새 EnemyId/외형)→registered 맵 필터 로그. 통과 후 푸시→PR→머지.
Self-Review
- 스펙 §2.1→T2 Step4, §2.2→T2 1
3, §2.3→T1. encGuid +500 오프셋은 gen-maps idx(몬스터 2)와 비충돌. CombatMonster 값은 T1이 직접 태그(no-clobber 생성기와 호환 — 이미 존재라 keep). CurrentMapName 불확실성은 빈 값 통과 폴백으로 하위 호환(T4 검증).