"하나 고치면 다른 게 깨진다" — 이 문제의 근본 원인을 잡았습니다.
문제
main.js가 4,474줄짜리 IIFE 모놀리스. 50+개 let 클로저 변수를 100+ 함수가 자유롭게 읽고 쓴다. 특히:
conversationFocusNpcId— 8개 함수에서 쓰기, 15곳player.moveTarget— 6곳에서 쓰기- 비동기 LLM 콜백이 2~5초 뒤 상태 변경 → 레이스 컨디션
Phase 0: ConversationManager
가장 위험한 변수 conversationFocusNpcId를 상태 머신으로 캡슐화.
Before: conversationFocusNpcId = npc.id; (15곳에서 자유롭게)
After: convoMgr.startConversation(npcId, holdMs, "reason");
비동기 잠금으로 레이스 컨디션 방지:
convoMgr.lockForAsync("proactive_greet");
// LLM 호출...
convoMgr.unlockAsync("proactive_greet");
유저가 NPC 클릭 → proactive 잠금 걸려있으면 거부. 기존에는 마지막 쓰기가 승리하는 구조.
Phase 1: ChatManager + GameState
채팅 관련 상태 5개(npcChatHistories, globalChats, speechBubbles, logs, systemToasts)와 함수 10개를 chat-manager.js로 이관.
game-state.js는 world/player/npcs/quest를 하나의 컨테이너로 묶어 서브시스템에 전달.
Phase 2: 7개 서브시스템 추출
| 모듈 | 이관 내용 | 캡슐화된 let 변수 |
|---|---|---|
ambient-speech.js |
NPC 혼잣말, 선제 인사, 자동 대화 | 9개 (타이밍/플래그) |
npc-social-events.js |
NPC간 대화, 가십 시스템 | 3개 |
guide-greeting.js |
도슨트 접근 + 인사 시퀀스 | 2개 |
intro-sequence.js |
인트로 카메라, NPC 사전 시뮬레이션 | 6개 |
scene-manager.js |
건물 진입/퇴장, 페이드 | 1개 |
save-load.js |
localStorage 저장/복원 | 0개 |
camera.js |
대화 카메라, 관조모드, 팬/줌 | 6개 |
에이전트 4명이 isolated worktree에서 병렬 작업 → 모듈 파일 생성 → 리드가 main.js 통합.
Phase 3: PlayerController
updatePlayer(dt)와 autoWalk 로직을 player-controller.js로 이관. player.moveTarget 쓰기를 playerCtrl.setMoveTarget()으로 통일.
Phase 4: AsyncGuard + 프레임 루프 문서화
비동기 연산 동시성 가드:
asyncGuard.guarded('ambient', async () => {
const line = await llmReplyOrEmpty(npc, prompt);
if (line) chatMgr.upsertSpeechBubble(npc.id, line, 4000);
});
// 이미 ambient 슬롯이 사용 중이면 자동 스킵
frame() 함수에 실행 순서 문서화:
Phase A: 시간
Phase B: 플레이어 이동 (NPC보다 먼저)
Phase C: NPC 이동 (가이드 → 일반)
Phase D: NPC 소셜
Phase E: 환경 (날씨, 발견)
Phase F: 카메라
Phase G: 네트워크
결과
| 지표 | Before | After |
|---|---|---|
| main.js 줄 수 | 4,474 | 3,370 |
| 새 모듈 | 0 | 11개 |
| conversationFocusNpcId 직접 쓰기 | 15곳 | 0곳 |
| 캡슐화된 let 변수 | 0 | 30+ |
| 프레임 루프 문서 | 없음 | Phase A-G |
이번 커밋들
| 커밋 | 내용 |
|---|---|
| Phase 0: ConversationManager | conversationFocusNpcId 상태 머신 |
| Phase 1: ChatManager + GameState | 채팅/로그 캡슐화, 상태 컨테이너 |
| Phase 2: Extract 7 subsystems | 7개 서브시스템 모듈화 |
| Phase 2 complete: Wire remaining | 나머지 3개 모듈 연결 |
| Phase 3+4: PlayerController + AsyncGuard | 플레이어 이동 + 비동기 가드 + 프레임 루프 문서화 |