From 82bf22d4cce01dca61baa0b2043eb7e94526251a Mon Sep 17 00:00:00 2001 From: gahusb Date: Sun, 14 Jun 2026 12:15:44 +0900 Subject: [PATCH] =?UTF-8?q?docs(p15):=20=EB=A1=9C=EB=B9=84=20=EB=A7=B5=20+?= =?UTF-8?q?=20=EC=9B=94=EB=93=9C=20NPC=20=EA=B5=AC=ED=98=84=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-14-lobby-map-npc.md | 524 ++++++++++++++++++ 1 file changed, 524 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-14-lobby-map-npc.md diff --git a/docs/superpowers/plans/2026-06-14-lobby-map-npc.md b/docs/superpowers/plans/2026-06-14-lobby-map-npc.md new file mode 100644 index 0000000..8844238 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-lobby-map-npc.md @@ -0,0 +1,524 @@ +# 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에서 채움) +- WALK_SPEED = TBD +- JUMP_FORCE = TBD +- BODY_KIND = TBD (Rigidbody/Sideviewbody/none) +- 추가 바디 set 필요 = TBD +- ATTACK_KEY = TBD (LeftControl/Space) +- LOBBY_SPAWN = TBD +- TOWN_BG = TBD, MARK_RUID = TBD