# P15 — 로비 맵 + 월드 NPC 구현 계획 > **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. 설계: `docs/superpowers/specs/2026-06-14-lobby-map-npc-design.md`. 산출물(`map/*.map`,`ui/DefaultGroup.ui`,`*.codeblock`,`Global/*`)은 Read/Edit 금지 — 생성기 소스(`tools/`)만 수정 후 재생성. 검증은 `grep -c`(카운트)와 메이커 플레이테스트. **Goal:** UI 패널 로비를 폐기하고, 전용 물리 맵 `lobby`에 공식 메이플 NPC 4종을 월드 엔티티로 배치해 근접(↑키)·클릭으로 기능을 열며, 이동·공격 모션은 로비 맵에서만 풀린다. **Architecture:** 단일 소스(`tools/*` 생성기 + `data/*.json`) → 산출물 재생성. 신규 생성기 2개(`gen-lobby-map.mjs`=맵+NPC 엔티티, `gen-lobby-npc.mjs`=LobbyNpc+LobbyMobility codeblock) + `gen-slaydeck.mjs`(흐름·UI) + `gen-player-lock.mjs`(전투맵 이동 재잠금 보강) 수정. 기존 기능 패널(CharacterSelect/Codex/SoulShop/Board)·전투 흐름 재사용. **Tech Stack:** Node.js ESM 생성기, MSW Lua(codeblock), MSW MCP(플레이테스트·asset). **확정 사실(조사):** - gen-slaydeck 편집 지점: OnBeginPlay `2830-2840`, ShowLobby `2986-2993`, LobbyHud npcs배열 `2469-2474`+버튼루프 `2475-2524`, lobTexts `2433-2439`, Asc버튼 `2454-2468`, BindLobbyButtons `2997-3014`, ShowState `2906-2922`, StartRun `3199-3232`, EndRun `4391-4403`, TeleportToActMap `4373-4385`, PlayerAttackMotion `4491-4500`, guid prefix `244-245`, ACT_MAPS `2745`. - **1막 텔레포트 공백**: StartRun(`3199-3232`)에 map01 텔레포트가 없음 → `self:TeleportToActMap()` 추가 필요(`RenderPotions` 다음, `ShowMap` 직전). `TeleportToActMap`은 `maps[self.Floor]` 사용 + 가드 `if lp.CurrentMapName==target then return`(멱등). - **NPC 공식 RUID**(maplestory, 흰박스 위험 없음): 모험가 `122095fd155c4633867b0da4f375bc3c`, 사서 `4c264be6a64f4ac3970b2e6818d04e40`, 상인 `69987ccdc486423f8bedd786bd6cb5d9`, 안내원 `8a99bd87d667482cb1f3b2193f8a19c1`. - **MSW API**: 월드 클릭 = 엔티티에 `TouchReceiveComponent` + `self.Entity:ConnectEvent(TouchEvent, fn)`. 키 = `_InputService:ConnectEvent(KeyDownEvent, fn)` + `KeyboardKey.UpArrow`(273)/`Space`(32)/`LeftControl`. 거리 = `Vector2.Distance(Vector2(a.x,a.y),Vector2(b.x,b.y))`. 이동복원 = `pc.Enable=true; pc.FixedLookAt=false; mv.InputSpeed=; mv.JumpForce=`(client 공간). 표시토글 = `entity:SetVisible(bool)`. - **맵 생성 패턴**(gen-maps.mjs): `JSON.parse(readFileSync('map/map01.map'))` → deep clone → 경로 `/maps/map01`→`/maps/lobby` 치환 → GUID 재발급(+origin fixup) → `compOf(e,'MOD.Core.X')`로 컴포넌트 접근 → `writeFileSync('map/lobby.map', JSON.stringify(map,null,2))`. 배경=`/Background`의 `BackgroundComponent.TemplateRUID`, 타일=`/TileMap`의 `TileMapComponent.TileSetRUID={DataId}`. 컴포넌트 부착=`@components` push + `componentNames` CSV 둘 다. SectorConfig=`Sectors[0].entries`에 `map://lobby` push. - **codeblock 패턴**(gen-combat-monster.mjs): `prop()/method()` 팩토리 + 봉투(`CoreVersion:'26.5.0.0'`, `EntryKey:'codeblock://x'`) → `writeFileSync('RootDesk/MyDesk/X.codeblock', JSON.stringify(cb,null,2))`. 컨트롤러 호출=`_EntityService:GetEntityByPath("/common").SlayDeckController:Method(...)`. 폴 idiom=`_TimerService:SetTimerRepeat(fn,0.1)`+try카운트 가드+`:ClearTimer(id)`. --- ### Task 0: 메이커 사전 정찰 (이동값·키·바디 컴포넌트·스폰좌표 확정) **목적:** LobbyMobility의 이동 복원 수치·공격 키·바디 컴포넌트 종류·로비 스폰 좌표를 추측이 아니라 실측으로 확정. 산출물 작성 전 선행. - [ ] **Step 1:** 메이커가 켜져 있는지 확인하고 현재 빌드 플레이. `mcp__msw-maker-mcp__maker_play` → `maker_screenshot`로 현재 화면(UI 로비) 확인. - [ ] **Step 2:** execute_script로 LocalPlayer 컴포넌트·이동값·바디 종류 덤프: ```lua local lp = _UserService.LocalPlayer local s = "pc="..tostring(lp.PlayerControllerComponent ~= nil) local mv = lp.MovementComponent if mv ~= nil then s = s.." InputSpeed="..tostring(mv.InputSpeed).." JumpForce="..tostring(mv.JumpForce) end s = s.." Rigidbody="..tostring(lp.RigidbodyComponent ~= nil) s = s.." Sideviewbody="..tostring(lp.SideviewbodyComponent ~= nil) local p = lp.TransformComponent.WorldPosition s = s.." pos=("..tostring(p.x)..","..tostring(p.y)..","..tostring(p.z)..")" s = s.." map="..tostring(lp.CurrentMapName) log(s) return s ``` Run via `maker_execute_script`. 기대: 현재 InputSpeed/JumpForce(0일 것), 어떤 바디 컴포넌트가 존재하는지(Rigidbody vs Sideviewbody), 현재 맵 이름·좌표. - [ ] **Step 3:** 이동 복원값 실측 — execute_script로 직접 켜 보고 걸어지는지 확인: ```lua local lp = _UserService.LocalPlayer lp.PlayerControllerComponent.Enable = true lp.PlayerControllerComponent.FixedLookAt = false lp.MovementComponent.InputSpeed = 5 lp.MovementComponent.JumpForce = 5 return "applied: try walking with arrow keys" ``` `maker_keyboard_input`로 방향키를 눌러 실제 이동 여부 확인(screenshot 비교). 걸으면 InputSpeed 값 후보 = 5. 안 걸으면 RigidbodyComponent.WalkSpeed/WalkJump 등도 set해보고(아래) 동작하는 최소 set을 기록. ```lua local rb = _UserService.LocalPlayer.RigidbodyComponent if rb ~= nil then rb.Enable = true end ``` - [ ] **Step 4:** 공격 키 enum 확정 — `mlua_api_retriever`(이미 검증됨: UpArrow=273, Space=32)에서 공격용 키 `LeftControl`의 정확한 enum 멤버명 확인(예: `KeyboardKey.LeftControl`). 확인 안 되면 공격 키를 `KeyboardKey.Space`로 폴백(이동 점프는 MSW 기본 Alt 가정). - [ ] **Step 5:** 결정 기록 — 이 plan 파일 하단 "정찰 결과" 섹션에 확정값 적기: - `WALK_SPEED` = (Step3에서 걸어진 InputSpeed), `JUMP_FORCE` = (걸어진 JumpForce), `BODY_KIND` = Rigidbody|Sideviewbody|none, 추가 바디 set 필요 여부, `ATTACK_KEY` = LeftControl|Space, `LOBBY_SPAWN` = 적당한 지면 좌표(현재 map 좌표 참고, 예 `Vector3(0, 0.03, 0)`). - 이후 Task에서 이 값을 JS 상수로 사용. - [ ] **Step 6:** `maker_stop`으로 플레이 종료(상태 churn 방지). --- ### Task 1: `gen-lobby-map.mjs` — 로비 맵 + NPC 엔티티 생성 **Files:** - Create: `tools/map/gen-lobby-map.mjs` - Output(산출물, 직접 편집 금지): `map/lobby.map`, `Global/SectorConfig.config`(갱신) NPC 4종 + `!` 마크 4종을 월드 엔티티로 배치. 마크는 자식이 아니라 **형제 엔티티**(NPC 위 고정 위치, 정적이라 무방). 각 NPC에 `TouchReceiveComponent` + `script.LobbyNpc`(NpcId), 맵 루트에 `script.LobbyMobility` 부착. - [ ] **Step 1:** `tools/map/gen-maps.mjs`를 참고 헤더로 새 파일 생성. 상수: ```js import { readFileSync, writeFileSync } from 'node:fs'; const TEMPLATE = 'map/map01.map'; const OUT = 'map/lobby.map'; const SECTOR = 'Global/SectorConfig.config'; const TOWN_BG = ''; // Task1 Step2에서 확정 const NPCS = [ { name: 'NpcRun', id: 'run', x: -4.5, ruid: '122095fd155c4633867b0da4f375bc3c' }, { name: 'NpcCodex', id: 'codex', x: -1.5, ruid: '4c264be6a64f4ac3970b2e6818d04e40' }, { name: 'NpcShop', id: 'shop', x: 1.5, ruid: '69987ccdc486423f8bedd786bd6cb5d9' }, { name: 'NpcBoard', id: 'board', x: 4.5, ruid: '8a99bd87d667482cb1f3b2193f8a19c1' }, ]; const MARK_RUID = ''; const NPC_Y = 0.0; // 지면 (Task0 좌표 참고로 조정) const MARK_DY = 1.6; // NPC 머리 위 오프셋 function compOf(e, type) { return e.jsonString['@components'].find((c) => c['@type'] === type); } function lobbyGuid(idx) { const n = (900000 + idx) >>> 0; // 기존 생성기와 충돌 없는 고유 오프셋 return `${n.toString(16).padStart(8,'0')}-0000-4000-8000-${n.toString(16).padStart(12,'0')}`; } ``` - [ ] **Step 2:** TOWN_BG·MARK_RUID 확정 — `gen-maps.mjs`를 열어 `BACKGROUNDS` 배열에서 타운 느낌 RUID 하나 골라 `TOWN_BG`에 박는다. MARK_RUID는 메이커 MCP `asset_search_resources`(source=maplestory, query "느낌표"/"balloon"/"emotion")로 1개 확정(못 찾으면 `!` 대신 작은 화살표/별 공식 스프라이트, 최후엔 NPC RUID 재사용+tint). - [ ] **Step 3:** 맵 로드·클론·정리(몬스터 제거)·배경: ```js const map = JSON.parse(JSON.stringify(JSON.parse(readFileSync(TEMPLATE, 'utf8')))); map.EntryKey = 'map://lobby'; let ents = map.ContentProto.Entities; const isMonster = (e) => typeof e.componentNames === 'string' && (e.componentNames.includes('script.Monster') || e.componentNames.includes('script.CombatMonster')); // 경로/이름 치환 for (const e of ents) { if (typeof e.path === 'string') e.path = e.path.replace('/maps/map01', '/maps/lobby'); if (e.jsonString) { if (typeof e.jsonString.path === 'string') e.jsonString.path = e.jsonString.path.replace('/maps/map01', '/maps/lobby'); if (e.jsonString.name === 'map01') e.jsonString.name = 'lobby'; } if ((e.path || '').endsWith('/Background')) { const bg = compOf(e, 'MOD.Core.BackgroundComponent'); if (bg) bg.TemplateRUID = TOWN_BG; } } // 몬스터 엔티티 제거 + PlayerLock/MapCamera는 유지(로비엔 PlayerLock 불필요하니 루트에서 제거) ents = ents.filter((e) => !isMonster(e)); const root = ents.find((e) => e.path === '/maps/lobby'); if (!root) throw new Error('[gen-lobby-map] 맵 루트 없음'); // 로비엔 PlayerLock 컴포넌트가 있으면 제거(이동 잠금 방지) root.jsonString['@components'] = root.jsonString['@components'].filter((c) => c['@type'] !== 'script.PlayerLock'); { const names = (root.componentNames || '').split(',').filter((s) => s && s !== 'script.PlayerLock'); root.componentNames = names.join(','); } ``` - [ ] **Step 4:** NPC 엔티티 + 마크 엔티티 생성(몬스터 템플릿을 클론해 몬스터 컴포넌트 제거 후 재사용). 몬스터 템플릿은 클론 전에 원본 ents(`map.ContentProto.Entities`)에서 확보: ```js const orig = JSON.parse(readFileSync(TEMPLATE, 'utf8')).ContentProto.Entities; const tmpl = orig.find((e) => typeof e.componentNames === 'string' && e.componentNames.includes('script.Monster')); if (!tmpl) throw new Error('[gen-lobby-map] 몬스터 템플릿(스프라이트 엔티티) 없음'); let gi = 1; function makeSpriteEntity(name, x, y, ruid, extraComps, extraNames, visible) { const m = JSON.parse(JSON.stringify(tmpl)); m.id = lobbyGuid(gi++); m.path = `/maps/lobby/${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.x = x; tr.Position.y = y; } const sp = compOf(m, 'MOD.Core.SpriteRendererComponent'); if (sp) sp.SpriteRUID = ruid; // 몬스터/전투 컴포넌트 전부 제거 m.jsonString['@components'] = m.jsonString['@components'].filter((c) => !['script.Monster','script.CombatMonster'].includes(c['@type'])); let names = (m.componentNames || '').split(',').filter((s) => s && !['script.Monster','script.CombatMonster'].includes(s)); // StateAnimationComponent가 있으면 die/hit 시트 제거(정적 stand) for (const [comp, props] of extraComps) { m.jsonString['@components'].push({ '@type': comp, Enable: true, ...props }); names.push(comp); } names = names.concat(extraNames).filter(Boolean); m.componentNames = names.join(','); // 마크 숨김은 Enable=false 금지(SetVisible가 안 먹음). codeblock OnBeginPlay가 SetVisible(false)로 숨기므로 // 여기선 별도 처리 안 함. (한 프레임 깜빡임 우려 시 SpriteRendererComponent.Visible=false 시도 — 필드 확인 후.) void visible; return m; } const added = []; for (const npc of NPCS) { // NPC: TouchReceiveComponent(자동맞춤) + script.LobbyNpc(NpcId) added.push(makeSpriteEntity(npc.name, npc.x, NPC_Y, npc.ruid, [['MOD.Core.TouchReceiveComponent', { AutoFitToSize: true }], ['script.LobbyNpc', { NpcId: npc.id, Tries: 0, InRange: false, MarkName: npc.name + 'Mark' }]], ['MOD.Core.TouchReceiveComponent', 'script.LobbyNpc'], true)); // 마크: NPC 위, 기본 숨김 added.push(makeSpriteEntity(npc.name + 'Mark', npc.x, NPC_Y + MARK_DY, MARK_RUID, [], [], false)); } ents = ents.concat(added); ``` > 주: `script.LobbyNpc` props(NpcId/MarkName 등)는 Task2의 codeblock 속성 정의와 **이름이 정확히 일치**해야 한다. - [ ] **Step 5:** 맵 루트에 `script.LobbyMobility` 부착 + 쓰기 + SectorConfig 등록: ```js root.jsonString['@components'] = root.jsonString['@components'].filter((c) => c['@type'] !== 'script.LobbyMobility'); root.jsonString['@components'].push({ '@type': 'script.LobbyMobility', Enable: true, Tries: 0 }); { const names = (root.componentNames || '').split(',').filter((s) => s && s !== 'script.LobbyMobility'); names.push('script.LobbyMobility'); root.componentNames = names.join(','); } map.ContentProto.Entities = ents; writeFileSync(OUT, JSON.stringify(map, null, 2), 'utf8'); // SectorConfig: map://lobby 등록(멱등) + 시작 섹터를 lobby로 const sector = JSON.parse(readFileSync(SECTOR, 'utf8')); const sec0 = sector.ContentProto.Json.Sectors[0]; if (!sec0.entries.includes('map://lobby')) sec0.entries.push('map://lobby'); writeFileSync(SECTOR, JSON.stringify(sector, null, 2), 'utf8'); console.log('[gen-lobby-map] lobby.map 생성 + SectorConfig 등록 완료'); ``` - [ ] **Step 6:** 실행 + 카운트 검증(내용 출력 금지): ```bash node tools/map/gen-lobby-map.mjs grep -c "script.LobbyNpc" map/lobby.map # 4 기대 grep -c "script.LobbyMobility" map/lobby.map # 1 기대 grep -c "TouchReceiveComponent" map/lobby.map # 4(+ 템플릿 잔존 가능) 기대 grep -lc "map://lobby" Global/SectorConfig.config node tools/verify/count.mjs 2>/dev/null || true ``` 기대: LobbyNpc=4, LobbyMobility=1. 어긋나면 생성기 수정. - [ ] **Step 7:** 커밋: ```bash git add tools/map/gen-lobby-map.mjs map/lobby.map Global/SectorConfig.config git commit -m "feat(lobby): 로비 전용 맵 + NPC 4종 월드 엔티티 생성기 (P15)" ``` --- ### Task 2: `gen-lobby-npc.mjs` — LobbyNpc + LobbyMobility codeblock **Files:** - Create: `tools/player/gen-lobby-npc.mjs` - Output(산출물): `RootDesk/MyDesk/LobbyNpc.codeblock`, `RootDesk/MyDesk/LobbyMobility.codeblock` `gen-combat-monster.mjs`의 `prop()/method()`/봉투 패턴을 그대로 복사. **Lua 문자열은 실제 탭 들여쓰기 사용**(RULES.md 메모리: 실탭↔`\t` 혼재 금지 — 템플릿 리터럴 안 실제 탭). - [ ] **Step 1:** 헤더·팩토리(gen-combat-monster.mjs:9-17 복사) + 봉투 함수: ```js import { writeFileSync } from 'node:fs'; const WALK_SPEED = /* Task0 정찰값 */ 5; const JUMP_FORCE = /* Task0 정찰값 */ 5; const ATTACK_KEY = /* Task0: 'LeftControl' 또는 'Space' */ 'LeftControl'; function prop(Type, Name, DefaultValue = 'nil') { return { Type, DefaultValue, SyncDirection: 0, Attributes: [], Name }; } function method(Name, Code, Arguments = [], ExecSpace = 6) { return { Return: { Type: 'void', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: null }, Arguments, Code, Scope: 2, ExecSpace, Attributes: [], Name }; } function writeCodeblock(id, name, properties, methods) { const cb = { Id: '', GameId: '', EntryKey: `codeblock://${id.toLowerCase()}`, ContentType: 'x-mod/codeblock', Content: '', Usage: 0, UsePublish: 1, UseService: 0, CoreVersion: '26.5.0.0', StudioVersion: '', DynamicLoading: 0, ContentProto: { Use: 'Json', Json: { CoreVersion: { Major: 0, Minor: 2 }, ScriptVersion: { Major: 1, Minor: 0 }, Description: '', Id: name, Language: 1, Name: name, Type: 1, Source: 0, Target: null, Properties: properties, Methods: methods, EntityEventHandlers: [] } } }; writeFileSync(`RootDesk/MyDesk/${name}.codeblock`, JSON.stringify(cb, null, 2), 'utf8'); } ``` - [ ] **Step 2:** LobbyNpc codeblock — 근접 폴링 + 마크 토글 + Touch/Key → Interact. (아래 Lua의 들여쓰기는 실제 탭으로 입력) ```js const npcInteract = method('Interact', `local c = _EntityService:GetEntityByPath("/common") if c ~= nil and c.SlayDeckController ~= nil then c.SlayDeckController:OnLobbyNpcInteract(self.NpcId) end`); const npcBegin = method('OnBeginPlay', `self.Tries = 0 self.InRange = false local mark = _EntityService:GetEntityByPath("/maps/lobby/" .. self.MarkName) if mark ~= nil then mark:SetVisible(false) end self.Entity:ConnectEvent(TouchEvent, function(e) self:Interact() end) _InputService:ConnectEvent(KeyDownEvent, function(e) if self.InRange and e.key == KeyboardKey.UpArrow then self:Interact() end end) local eventId = 0 local function tick() local lp = _UserService.LocalPlayer if lp == nil then return end local a = lp.TransformComponent.WorldPosition local b = self.Entity.TransformComponent.WorldPosition local d = Vector2.Distance(Vector2(a.x, a.y), Vector2(b.x, b.y)) local near = d < 1.8 if near ~= self.InRange then self.InRange = near if mark ~= nil then mark:SetVisible(near) end end end eventId = _TimerService:SetTimerRepeat(tick, 0.15)`); writeCodeblock('LobbyNpc', 'LobbyNpc', [ prop('string', 'NpcId', '""'), prop('string', 'MarkName', '""'), prop('boolean', 'InRange', 'false'), prop('number', 'Tries', '0'), ], [npcBegin, npcInteract]); ``` - [ ] **Step 3:** LobbyMobility codeblock — 이동 복원 + 공격 키. (들여쓰기 실제 탭) ```js const mobBegin = method('OnBeginPlay', `self.Tries = 0 local eventId = 0 local function apply() self.Tries = self.Tries + 1 local lp = _UserService.LocalPlayer if lp ~= nil and lp.PlayerControllerComponent ~= nil then local pc = lp.PlayerControllerComponent pc.Enable = true pc.FixedLookAt = false local mv = lp.MovementComponent if mv ~= nil then mv.InputSpeed = ${WALK_SPEED} mv.JumpForce = ${JUMP_FORCE} end local rb = lp.RigidbodyComponent if rb ~= nil then rb.Enable = true end _TimerService:ClearTimer(eventId) elseif self.Tries > 50 then _TimerService:ClearTimer(eventId) end end eventId = _TimerService:SetTimerRepeat(apply, 0.1) _InputService:ConnectEvent(KeyDownEvent, function(e) if e.key == KeyboardKey.${ATTACK_KEY} then local c = _EntityService:GetEntityByPath("/common") if c ~= nil and c.SlayDeckController ~= nil then c.SlayDeckController:PlayerAttackMotion() end end end)`); writeCodeblock('LobbyMobility', 'LobbyMobility', [prop('number', 'Tries', '0')], [mobBegin]); console.log('[gen-lobby-npc] LobbyNpc/LobbyMobility codeblock 생성 완료'); ``` - [ ] **Step 4:** 실행 + 카운트 검증: ```bash node tools/player/gen-lobby-npc.mjs grep -c "OnLobbyNpcInteract" RootDesk/MyDesk/LobbyNpc.codeblock # >=1 grep -c "PlayerAttackMotion" RootDesk/MyDesk/LobbyMobility.codeblock # >=1 ls -la RootDesk/MyDesk/LobbyNpc.codeblock RootDesk/MyDesk/LobbyMobility.codeblock ``` - [ ] **Step 5:** 커밋: ```bash git add tools/player/gen-lobby-npc.mjs RootDesk/MyDesk/LobbyNpc.codeblock RootDesk/MyDesk/LobbyMobility.codeblock git commit -m "feat(lobby): LobbyNpc(근접·클릭 상호작용)·LobbyMobility(이동·공격 해제) codeblock (P15)" ``` --- ### Task 3: `gen-player-lock.mjs` — 전투맵 이동 재잠금 보강 (방어) **Files:** Modify `tools/player/gen-player-lock.mjs` 로비에서 푼 이동이 텔레포트 후 전투맵에 누설돼도, 전투맵 PlayerLock이 런타임으로 MovementComponent를 0으로 재설정해 확실히 잠그도록 보강. - [ ] **Step 1:** `gen-player-lock.mjs`의 PlayerLock Lua에서 `pc.Enable = false` 직후 라인을 추가(생성기 내 해당 Lua 템플릿 리터럴, 실제 탭 들여쓰기): ```lua pc.Enable = false local mv = lp.MovementComponent if mv ~= nil then mv.InputSpeed = 0; mv.JumpForce = 0 end ``` (정확한 삽입 지점은 `gen-player-lock.mjs`에서 `pc.Enable`가 들어간 Lua 문자열. `LocalPlayer.PlayerControllerComponent`를 `lp`로 잡는 변수명이 기존 코드와 일치하는지 확인 — 다르면 기존 변수명 사용.) - [ ] **Step 2:** 재생성 + 카운트: ```bash node tools/player/gen-player-lock.mjs grep -c "InputSpeed = 0" RootDesk/MyDesk/PlayerLock.codeblock # >=1 기대(파일명은 생성기 출력명 확인) ``` - [ ] **Step 3:** 커밋: ```bash git add tools/player/gen-player-lock.mjs RootDesk/MyDesk/PlayerLock.codeblock map/map0*.map git commit -m "fix(lobby): 전투맵 PlayerLock에 이동값 런타임 0 재설정 보강 (P15)" ``` --- ### Task 4: `gen-slaydeck.mjs` — 흐름·UI 통합 **Files:** Modify `tools/deck/gen-slaydeck.mjs` - [ ] **Step 1:** guid prefix 등록(`244-245`) — 신규 prefix 불필요(LobbyHud 슬림화만, 기존 `lob` 재사용). 확인만. - [ ] **Step 2:** ACT_MAPS 아래(`2745`)에 로비 상수 추가: ```js const ACT_MAPS = ['map01', 'map02', 'map03', 'map04', 'map05']; const LOBBY_MAP = 'lobby'; const LOBBY_SPAWN = 'Vector3(0, 0.03, 0)'; // Task0 정찰 좌표로 조정 ``` - [ ] **Step 3:** LobbyHud 슬림화 — `npcs` 배열(`2469-2474`)과 버튼 생성 루프(`2475-2524`) **삭제**. `lobTexts`(`2433-2439`)는 SoulLabel/AscLabel + 안내문(Hint)만 남기고 Title/Subtitle은 "마을" 정도로 축소 or 제거. AscMinus/AscPlus(`2454-2468`)는 유지. → LobbyHud가 상단 정보바(영혼/승천)만 남음. - [ ] **Step 4:** BindLobbyButtons(`2997-3014`) — NPC 4개 `bindClick` 라인 **삭제**(NpcRun/NpcCodex/NpcShop/NpcBoard). AscMinus/AscPlus/BoardHud.Close/SoulShopHud.Close bindClick은 유지. - [ ] **Step 5:** ShowLobby(`2986-2993`) — 끝에 로비 맵 텔레포트 추가: ```js method('ShowLobby', `self.SelectedClass = "" self:RenderAscension() self:RenderSoulLabel() self:ShowState("lobby") self:SetEntityEnabled("/ui/DefaultGroup/BoardHud", false) self:SetEntityEnabled("/ui/DefaultGroup/SoulShopHud", false) self:BindLobbyButtons() self:BindMenuButtons() self:GoLobbyMap()`), ``` - [ ] **Step 6:** 신규 method `GoLobbyMap`(ShowLobby 근처에 추가, ExecSpace 기본): ```js method('GoLobbyMap', `self.LobbyTpTries = 0 local eventId = 0 local function go() self.LobbyTpTries = self.LobbyTpTries + 1 local lp = _UserService.LocalPlayer if lp ~= nil then if lp.CurrentMapName ~= "${LOBBY_MAP}" then _TeleportService:TeleportToMapPosition(lp, ${LOBBY_SPAWN}, "${LOBBY_MAP}") end _TimerService:ClearTimer(eventId) elseif self.LobbyTpTries > 50 then _TimerService:ClearTimer(eventId) end end eventId = _TimerService:SetTimerRepeat(go, 0.1)`), ``` - [ ] **Step 7:** 신규 method `OnLobbyNpcInteract`(인자 id) — NPC codeblock이 호출: ```js method('OnLobbyNpcInteract', `if self.RunActive == true then return end if id == "run" then self:ShowCharacterSelect() elseif id == "codex" then self:ShowCodex() elseif id == "shop" then self:ShowSoulShop() elseif id == "board" then self:ShowBoard() end`, [{ Type: 'string', DefaultValue: '""', SyncDirection: 0, Attributes: [], Name: 'id' }]), ``` (인자 객체 형태는 기존 `EndRun`의 `text` 인자/`ShowState`의 `state` 인자 정의를 참고해 동일 구조로.) - [ ] **Step 8:** StartRun(`3199-3232`) — `RenderPotions()` 다음, `ShowMap()` 직전에 1막 텔레포트 추가: ```js // ... self:RenderPotions() (기존) 다음 줄에 self:TeleportToActMap() // ... self:ShowMap() (기존) ``` (StartRun의 Lua 문자열 내부에 `self:TeleportToActMap()` 한 줄 삽입. Floor=1이 이미 세팅돼 map01 타깃.) - [ ] **Step 9:** EndRun(`4391-4403`) 복귀 — 기존 타이머 `self:ShowLobby()`가 GoLobbyMap을 호출하므로 **별도 변경 불필요**(ShowLobby가 로비 맵 텔레포트 포함). 확인만. - [ ] **Step 10:** 재생성 + 카운트 검증: ```bash node tools/deck/gen-slaydeck.mjs grep -c "OnLobbyNpcInteract" RootDesk/MyDesk/SlayDeckController.codeblock # >=1 (이 파일엔 정의만; 호출은 LobbyNpc.codeblock) grep -c "GoLobbyMap" RootDesk/MyDesk/SlayDeckController.codeblock # >=2 (정의+ShowLobby 호출) grep -c "TeleportToActMap" RootDesk/MyDesk/SlayDeckController.codeblock # >=3 (정의+ContinueAfterBoss+StartRun) grep -c "NpcRun" ui/DefaultGroup.ui # 0 기대(버튼-행 제거됨) ``` - [ ] **Step 11:** 커밋: ```bash git add tools/deck/gen-slaydeck.mjs ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock Global/common.gamelogic map/lobby.map map/map0*.map git commit -m "feat(lobby): 로비 맵 흐름 통합 — OnBeginPlay/EndRun 텔레포트·NPC 상호작용 디스패치·StartRun map01 텔레포트·LobbyHud 슬림화 (P15)" ``` --- ### Task 5: 미러/회귀 테스트 전투 규칙·맵 그래프 알고리즘 미변경 → 미러 동기화 불필요. 기존 테스트 회귀만 확인. - [ ] **Step 1:** 기존 테스트 실행: ```bash node --test tools/balance/sim-balance.test.mjs node --test tools/map/rogue-map.test.mjs ``` 기대: 전부 PASS(이번 변경은 전투/맵그래프 무관이라 회귀 없어야 함). - [ ] **Step 2:** `git status --short`로 의도치 않은 산출물 변경 없는지 확인(산출물 diff는 보지 않음). --- ### Task 6: 메이커 플레이테스트 검증 - [ ] **Step 1:** git 상태 정리 후 메이커에서 **로컬 워크스페이스 refresh**(RULES.md §5 — 안 하면 stale 상태가 디스크 덮어씀). `maker_refresh_workspace` → 빌드 콘솔 0 에러 확인(`maker_logs`). - [ ] **Step 2:** `maker_play` → `maker_screenshot`. 검증 시나리오(스크린샷·로그로): 1. 월드 시작 → **로비 맵에 스폰**(타운 배경, NPC 4명 보임), 방향키로 **이동됨**, 공격 키로 **공격 모션** 나옴. 2. NPC 근접 → 머리 위 `!` 표시 → `↑`키로 기능 패널 오픈. NPC `maker_mouse_input` 클릭으로도 오픈(버튼 클릭 불가 메모리 주의 — 월드 엔티티 TouchEvent라 mouse_input 좌표 클릭 시도, 안 되면 ↑키 경로로 검증). 3. 모험가→직업선택→런 시작 → **map01로 텔레포트**, 이동/공격 **잠김**. 1막 전투 몬스터 정상 등장(CurrentMapName 필터 통과). 4. 사서→도감, 상인→영혼상점, 안내원→게시판 각각 오픈/닫기. 5. 런 종료(빠른 패배 유도: execute_script로 `c.Combat.PlayerHp=0` 등 or 정상 진행) → 4초 후 **로비 맵 복귀**, 이동/공격 재해제. 6. 상단 미니 HUD에 영혼/승천 표시 정상. - [ ] **Step 2b:** 실패 시 디버깅 — 이동 안 됨→Task0 값 재확認/RigidbodyComponent 추가 set, 클릭 안 됨→TouchReceiveComponent 필드/근접↑키 폴백, 몬스터 안 나옴→StartRun 텔레포트·spawn 좌표 확인. 생성기 수정→재생성→refresh→재플레이. - [ ] **Step 3:** `maker_stop`. 스크린샷을 사용자에게 공유. --- ### Task 7: PR - [ ] **Step 1:** push: ```bash git push -u origin feature/p15-lobby-map-npc ``` - [ ] **Step 2:** PR spec JSON(UTF-8) 작성 후 `node tools/git/gitea-pr.mjs create ` (RULES.md §4 — 인라인 curl 한글 금지). 제목 예: "feat: P15 — 로비 맵 + 월드 NPC(근접·클릭) + 로비 전용 이동·공격". 본문에 변경 요약·검증 결과·스크린샷 언급. - [ ] **Step 3:** 사용자에게 PR 번호 보고 + 머지 여부 확인. --- ## 정찰 결과 (Task0 실측 완료) - **이동 레버 = `RigidbodyComponent.WalkAcceleration` (freeze가 0으로 만든 값). 복원값 0.7로 이동·점프 정상 확인** (InputSpeed/JumpForce는 무관 — WalkSpeed=1.4·WalkJump=1.23는 freeze가 안 건드림). - 이동 해제 = `pc.Enable=true; pc.FixedLookAt=false; rb.WalkAcceleration=0.7` (rb.Enable는 이미 true). - BODY_KIND = Rigidbody가 구동(Sideviewbody도 존재하나 WalkSpeed=nil). 추가 바디 set 불필요. - ATTACK_KEY = `LeftControl` (KeyboardKey.LeftControl 유효, PlayerAttackMotion() 호출 정상). - 상호작용 키 = `UpArrow` 유효. 클릭 = TouchReceiveComponent+TouchEvent. - 현재 플레이어 위치 map01 (-5,-0.039,0) → LOBBY_SPAWN = `Vector3(-5, 0.03, 0)`. NPC x = -3 / -0.5 / 2 / 4.5, 근접 임계 1.2. - TOWN_BG = Task1에서 gen-maps BACKGROUNDS 풀에서 선택, MARK_RUID = Task1 asset 검색.