From 9e2c36579b1dc4f02d73e2a185027780f5f0b882 Mon Sep 17 00:00:00 2001 From: kimsungsu Date: Thu, 16 Apr 2026 10:12:13 +0900 Subject: [PATCH 001/199] Add /verify skill for post-implementation code quality checks 8-item automated verification: event subscription safety, null guards, performance patterns (transform.Find caching), Bootstrap registration, save compatibility, monolith impact, code conventions, data matching. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/verify.md | 84 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 .claude/skills/verify.md diff --git a/.claude/skills/verify.md b/.claude/skills/verify.md new file mode 100644 index 0000000..5b50a0f --- /dev/null +++ b/.claude/skills/verify.md @@ -0,0 +1,84 @@ +--- +name: verify +description: 기능 구현 후 코드 품질 검증을 실행합니다 +user_invocable: true +--- + +# 구현 후 코드 검증 + +기능 구현이 완료된 후 잠재 버그와 코드 품질을 점검합니다. + +## 검증 항목 (순서대로 실행) + +### 1. 이벤트 구독 안전성 +변경된 파일에서 `event Action` 또는 `+=` / `-=` 패턴 검색: +- **OnEnable + AutoWire 이중 구독**: 둘 다 같은 이벤트를 구독하면 2번 호출됨. 원칙: AutoWire에서만 구독, OnDisable에서 해제 +- OnDisable에서 해제 누락 +- null 체크 없는 구독/해제 +- 구독 전 -= 로 기존 구독 해제하는지 확인 + +### 2. null 가드 패턴 +변경된 코드에서: +- FindFirstObjectByType 결과에 null 체크 +- AutoWire로 주입받는 필드의 사용 전 null 체크 +- Singleton.Instance 사용 시 null 가능성 (UITheme 같은 케이스) +- GetComponent 결과 null 체크 + +### 3. 성능 패턴 (Update/OnGUI에서) +매 프레임 호출되는 코드에서: +- **transform.Find()**: 캐싱 필수 (private Transform 필드에 저장) +- **GameObject.Find()**: 캐싱 필수 +- **Physics.OverlapSphere/Raycast**: 이동 없을 때 스킵, 또는 N프레임마다 호출 +- **GetComponent/GetComponentsInChildren**: 캐싱 필수 +- **new 배열/리스트/GUIStyle**: GC 압력. GUIStyle은 필드로 캐싱, 배열은 재사용 +- **FindObjectsByType**: Update에서 금지, Start/이벤트에서만 + +### 4. Bootstrap 등록 +새 MonoBehaviour가 추가되었으면: +- PlaySceneBootstrap.Build()에 등록되었는지 +- 초기화 순서가 의존성 그래프에 맞는지 +- AutoWire 호출이 누락되지 않았는지 + +### 5. 세이브 호환성 +세이브 관련 클래스가 변경되었으면: +- 새 필드에 기본값이 있는지 +- CloudSaveManager 동기화 필요 여부 +- 기존 데이터 역직렬화 호환성 + +### 6. 모놀리스 영향 +BattleScreenUI / RaidBattleUI / PlaySceneBootstrap 수정 시: +- 변경이 최소한인지 +- Phase별 영향 범위 확인 + +### 7. 코드 컨벤션 +- `[SerializeField] private` 패턴 준수 +- PascalCase / camelCase 네이밍 +- public 필드 직접 노출 여부 +- MonoBehaviour 생성자 사용 여부 +- `InsectGame.*` 네임스페이스 사용 여부 + +### 8. 데이터 매칭 무결성 +ID 기반 매칭 로직이 변경되었으면: +- 모든 데이터 ID가 매칭 조건에 포함되는지 (곤충 ID → Build 메서드, 아이템 ID → 처리 분기) +- contains 순서로 인한 오매칭 (예: "antlion"이 "ant"에 먼저 매칭) +- 새 데이터 추가 시 매칭 분기 추가 여부 + +## 출력 형식 +``` +=== 구현 후 검증 결과 === +[PASS] 이벤트 구독: 중복/누락 없음 +[PASS] null 가드: 모든 외부 참조 체크 완료 +[PASS] 성능: Update 내 캐싱 확인, 불필요한 호출 없음 +[PASS] Bootstrap: 새 시스템 정상 등록 +[PASS] 세이브: 변경 없음 +[PASS] 모놀리스: 최소 수정 +[PASS] 컨벤션: 위반 없음 +[PASS] 데이터 매칭: 전체 ID 매칭 확인 + +총 8항목 중 8 PASS / 0 WARN / 0 FAIL +``` + +## 규칙 +- FAIL이 있으면 즉시 수정 +- WARN은 사용자에게 보고 후 판단 +- 모든 항목 PASS일 때만 구현 완료로 보고 From 7d7ec69d875cfd13072db5e3113357aad93efb15 Mon Sep 17 00:00:00 2001 From: kimsungsu Date: Sat, 6 Jun 2026 12:03:04 +0900 Subject: [PATCH 002/199] =?UTF-8?q?=EA=B3=A4=EC=B6=A9=EA=B2=8C=EC=9E=84:?= =?UTF-8?q?=20=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=B9=98=EB=B9=84=20=EB=A6=AC?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20+=20=EA=B3=A4=EC=B6=A9=20?= =?UTF-8?q?=EB=8F=84=EA=B0=90=20=ED=92=88=EC=A7=88=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=ED=8F=AC=EC=9D=B8=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 캐릭터(필드/포트레이트): - 필드 캐릭터 ~3.5등신 치비 리디자인 (큰 머리·큰 눈·통통 비례) - 포트레이트 비례·얼굴 부품 머리 비례화로 필드와 정합 - 숲/유적/산 지면 고도 평탄화로 캐릭터 차폐 버그 해결 - 도구(잠자리채 등) 망 디스크 회전·손 위치 정합, 카메라 줌인 곤충 도감: - InsectLore 64종 설명 전면 리라이트 - 도감에 3D 곤충 RenderTexture 프리뷰 표시 (신규 InsectModelPreviewRenderer) - 곤충 3D 모델 셰이딩(PBR)·눈·다리·더듬이·뿔·대표종 심화 ※ 작업트리 전체 상태의 체크포인트 (이전 미커밋 WIP 포함, 임시·정크 제외) Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/agents/ui-dev.md | 2 + .claude/agents/visual-dev.md | 8 + .claude/audit-progress.md | 159 +++ .claude/hooks/audit_flow_inject.py | 82 ++ .claude/hooks/audit_reminder.py | 72 ++ .claude/hooks/auto_verify_trigger.py | 88 ++ .claude/hooks/check_namespace.py | 36 +- .claude/hooks/quick_compile_check.py | 226 ++++ .claude/hooks/scan_static_patterns.py | 255 ++++ .claude/hooks/warn_monolith.py | 72 +- .claude/rules/agent-coordination.md | 20 + .claude/rules/audit-flow.md | 70 ++ .claude/scripts/data_lint.py | 473 +++++++ .claude/scripts/economy_sim.py | 274 +++++ .claude/scripts/gacha_sim.py | 370 ++++++ .claude/scripts/progression_sim.py | 236 ++++ .claude/scripts/verify_coverage.py | 16 +- .claude/settings.json | 80 +- .claude/skills/add-item.md | 108 ++ .claude/skills/add-region.md | 170 +++ .claude/skills/add-skill.md | 124 ++ .claude/skills/audit.md | 136 ++ .claude/skills/balance-sim.md | 18 +- .claude/skills/build-check.md | 61 +- .claude/skills/data-lint.md | 83 ++ .claude/skills/economy-sim.md | 94 ++ .claude/skills/explore-standard.md | 60 + .claude/skills/gacha-sim.md | 103 ++ .claude/skills/progression-sim.md | 95 ++ .claude/skills/verify.md | 65 +- Assets/Resources/InsectLore.json | 264 ++-- .../Scripts/Battle/BattleArenaController.cs | 487 +++++++- .../Scripts/Battle/InsectBattleController.cs | 88 +- .../Battle/InsectBattleUIController.cs | 32 +- Assets/Scripts/Battle/RaidBattleController.cs | 102 +- Assets/Scripts/Capture/CaptureController.cs | 10 +- .../Scripts/Capture/CaptureInputController.cs | 6 +- .../Capture/CaptureMinigameController.cs | 79 +- Assets/Scripts/Core/AtomicFileWriter.cs | 38 + Assets/Scripts/Core/AtomicFileWriter.cs.meta | 2 + Assets/Scripts/Core/AudioManager.cs | 241 +++- Assets/Scripts/Core/AuthManager.cs | 96 +- Assets/Scripts/Core/BattleTeamManager.cs | 24 +- Assets/Scripts/Core/CameraFollower.cs | 159 ++- Assets/Scripts/Core/CashShopManager.cs | 114 +- Assets/Scripts/Core/CharacterOutfitManager.cs | 362 +++++- Assets/Scripts/Core/CloudSaveManager.cs | 201 ++- Assets/Scripts/Core/GachaBoxManager.cs | 158 ++- Assets/Scripts/Core/GameClock.cs | 5 + Assets/Scripts/Core/GameConstants.cs | 1 + Assets/Scripts/Core/ItemEffectManager.cs | 2 + Assets/Scripts/Core/PlaySceneBootstrap.cs | 670 ++++------ Assets/Scripts/Core/PlayerCandyInventory.cs | 21 +- Assets/Scripts/Core/PlayerCurrencyWallet.cs | 25 +- Assets/Scripts/Core/PlayerInsectCollection.cs | 79 +- .../Core/PlayerInsectLevelUpUIController.cs | 47 +- Assets/Scripts/Core/PlayerItemInventory.cs | 60 +- .../PlayerItemInventoryGridUIController.cs | 22 +- Assets/Scripts/Core/PlayerMovement.cs | 372 +++++- .../Scripts/Core/PlayerProgressController.cs | 33 + .../Scripts/Core/PlayerProgressSaveService.cs | 22 +- Assets/Scripts/Core/PlayerVisualBuilder.cs | 913 ++++++++++++++ .../Scripts/Core/PlayerVisualBuilder.cs.meta | 2 + .../Scripts/Core/ProceduralAudioGenerator.cs | 696 +++++++++++ Assets/Scripts/Core/RegionDefinitions.cs | 344 ++++++ Assets/Scripts/Core/RegionDefinitions.cs.meta | 2 + Assets/Scripts/Core/RegionManager.cs | 121 +- Assets/Scripts/Core/RegionTerrainBuilder.cs | 665 ++++++++++ .../Scripts/Core/RegionTerrainBuilder.cs.meta | 2 + Assets/Scripts/Core/ShopUIController.cs | 30 +- Assets/Scripts/Core/SubAreaEnvironment.cs | 327 +++++ .../Scripts/Core/SubAreaEnvironment.cs.meta | 2 + Assets/Scripts/Core/SubAreaWorldBuilder.cs | 1092 +++++++++++++++++ .../Scripts/Core/SubAreaWorldBuilder.cs.meta | 2 + Assets/Scripts/Core/TrainingManager.cs | 8 +- Assets/Scripts/Core/TutorialQuestManager.cs | 33 +- Assets/Scripts/Core/UIAudioBinder.cs | 58 + Assets/Scripts/Core/UIAudioBinder.cs.meta | 2 + Assets/Scripts/Core/WorldChannelManager.cs | 65 +- Assets/Scripts/Core/WorldTerrainBuilder.cs | 410 +++++++ .../Scripts/Core/WorldTerrainBuilder.cs.meta | 2 + Assets/Scripts/Data/InsectDatabase.cs | 16 + Assets/Scripts/Data/OutfitSetData.cs | 77 ++ Assets/Scripts/Data/RegionData.cs | 13 + Assets/Scripts/Dex/DexController.cs | 20 + Assets/Scripts/Dex/DexDetailUIController.cs | 23 +- Assets/Scripts/Dex/DexSaveService.cs | 15 +- Assets/Scripts/Dex/DexScreenUI.cs | 446 ++++--- .../Scripts/Dex/InsectModelPreviewRenderer.cs | 113 ++ .../Dex/InsectModelPreviewRenderer.cs.meta | 2 + Assets/Scripts/Spawning/CaptureItemPickup.cs | 1 + Assets/Scripts/Spawning/InsectEntity.cs | 355 +++++- Assets/Scripts/Spawning/InsectSpawner.cs | 228 +++- Assets/Scripts/UI/BattleScreenUI.cs | 837 ++++++++----- Assets/Scripts/UI/BattleTeamUI.cs | 197 +-- Assets/Scripts/UI/CaptureChoiceUI.cs | 8 +- Assets/Scripts/UI/CapturePopupUI.cs | 155 ++- Assets/Scripts/UI/CashShopUI.cs | 389 ++++-- Assets/Scripts/UI/CharacterOutfitUI.cs | 189 ++- .../Scripts/UI/CharacterPortraitRenderer.cs | 926 ++++++++++++++ .../UI/CharacterPortraitRenderer.cs.meta | 2 + Assets/Scripts/UI/CharacterViewerUI.cs | 268 ++-- Assets/Scripts/UI/CollectionUI.cs | 421 ++++--- Assets/Scripts/UI/KeyGuideHUD.cs | 87 +- Assets/Scripts/UI/LoginUI.cs | 433 +++---- Assets/Scripts/UI/ModalUIRegistry.cs | 66 + Assets/Scripts/UI/ModalUIRegistry.cs.meta | 2 + Assets/Scripts/UI/PlayerStatusHUD.cs | 310 +++-- Assets/Scripts/UI/QuickAccessBarUI.cs | 22 +- Assets/Scripts/UI/RaidBattleUI.cs | 695 +++++++---- Assets/Scripts/UI/RegionMapUI.cs | 666 ++++++++-- Assets/Scripts/UI/TrainingUI.cs | 443 ++++--- Assets/Scripts/UI/TutorialQuestUI.cs | 247 ++-- Assets/Scripts/UI/UIHelper.cs | 3 + Assets/Scripts/UI/UIScale.cs | 83 ++ Assets/Scripts/UI/UIScale.cs.meta | 2 + Assets/Scripts/UI/UITheme.cs | 2 + Assets/Scripts/UI/WorldLobbyUI.cs | 45 +- CLAUDE.md | 1 + Packages/manifest.json | 1 - Packages/packages-lock.json | 8 - 121 files changed, 16797 insertions(+), 3274 deletions(-) create mode 100644 .claude/audit-progress.md create mode 100644 .claude/hooks/audit_flow_inject.py create mode 100644 .claude/hooks/audit_reminder.py create mode 100644 .claude/hooks/auto_verify_trigger.py create mode 100644 .claude/hooks/quick_compile_check.py create mode 100644 .claude/hooks/scan_static_patterns.py create mode 100644 .claude/rules/audit-flow.md create mode 100644 .claude/scripts/data_lint.py create mode 100644 .claude/scripts/economy_sim.py create mode 100644 .claude/scripts/gacha_sim.py create mode 100644 .claude/scripts/progression_sim.py create mode 100644 .claude/skills/add-item.md create mode 100644 .claude/skills/add-region.md create mode 100644 .claude/skills/add-skill.md create mode 100644 .claude/skills/audit.md create mode 100644 .claude/skills/data-lint.md create mode 100644 .claude/skills/economy-sim.md create mode 100644 .claude/skills/explore-standard.md create mode 100644 .claude/skills/gacha-sim.md create mode 100644 .claude/skills/progression-sim.md create mode 100644 Assets/Scripts/Core/AtomicFileWriter.cs create mode 100644 Assets/Scripts/Core/AtomicFileWriter.cs.meta create mode 100644 Assets/Scripts/Core/PlayerVisualBuilder.cs create mode 100644 Assets/Scripts/Core/PlayerVisualBuilder.cs.meta create mode 100644 Assets/Scripts/Core/RegionDefinitions.cs create mode 100644 Assets/Scripts/Core/RegionDefinitions.cs.meta create mode 100644 Assets/Scripts/Core/RegionTerrainBuilder.cs create mode 100644 Assets/Scripts/Core/RegionTerrainBuilder.cs.meta create mode 100644 Assets/Scripts/Core/SubAreaEnvironment.cs create mode 100644 Assets/Scripts/Core/SubAreaEnvironment.cs.meta create mode 100644 Assets/Scripts/Core/SubAreaWorldBuilder.cs create mode 100644 Assets/Scripts/Core/SubAreaWorldBuilder.cs.meta create mode 100644 Assets/Scripts/Core/UIAudioBinder.cs create mode 100644 Assets/Scripts/Core/UIAudioBinder.cs.meta create mode 100644 Assets/Scripts/Core/WorldTerrainBuilder.cs create mode 100644 Assets/Scripts/Core/WorldTerrainBuilder.cs.meta create mode 100644 Assets/Scripts/Dex/InsectModelPreviewRenderer.cs create mode 100644 Assets/Scripts/Dex/InsectModelPreviewRenderer.cs.meta create mode 100644 Assets/Scripts/UI/CharacterPortraitRenderer.cs create mode 100644 Assets/Scripts/UI/CharacterPortraitRenderer.cs.meta create mode 100644 Assets/Scripts/UI/ModalUIRegistry.cs create mode 100644 Assets/Scripts/UI/ModalUIRegistry.cs.meta create mode 100644 Assets/Scripts/UI/UIScale.cs create mode 100644 Assets/Scripts/UI/UIScale.cs.meta diff --git a/.claude/agents/ui-dev.md b/.claude/agents/ui-dev.md index 00b838c..9f8c7a1 100644 --- a/.claude/agents/ui-dev.md +++ b/.claude/agents/ui-dev.md @@ -36,7 +36,9 @@ tools: - `Assets/Scripts/UI/QuickAccessBarUI.cs` - 퀵액세스 바 - `Assets/Scripts/UI/TutorialQuestUI.cs` - 튜토리얼 퀘스트 UI - `Assets/Scripts/UI/WorldLobbyUI.cs` - 월드 로비 +- `Assets/Scripts/UI/CharacterPortraitRenderer.cs` - 통합 캐릭터 포트레이트 렌더러 - `Assets/Scripts/UI/UIHelper.cs` - UI 유틸리티 +- `Assets/Scripts/UI/UIScale.cs` - 1920×1080 기준 가상 좌표계 / GUI.matrix 자동 스케일링 - `Assets/Scripts/UI/UITheme.cs` - UI 테마/스타일 - `Assets/Scripts/UI/UITween.cs` - UI 트윈 애니메이션 diff --git a/.claude/agents/visual-dev.md b/.claude/agents/visual-dev.md index fbef85e..836f5b0 100644 --- a/.claude/agents/visual-dev.md +++ b/.claude/agents/visual-dev.md @@ -22,7 +22,15 @@ tools: - `Assets/Scripts/Battle/BattleArenaController.cs` - 배틀 아레나 환경 구축 - `Assets/Scripts/Core/ProceduralAudioGenerator.cs` - 프로시저럴 오디오 - `Assets/Scripts/Core/AudioManager.cs` - 오디오 매니저 (싱글턴) +- `Assets/Scripts/Core/UIAudioBinder.cs` - UI 버튼 자동 hover/click 사운드 부착 - `Assets/Scripts/Data/ItemRarityPalette.cs` - 레어도별 색상 ※data-architect 공유 +- `Assets/Scripts/Dex/RarityIconProvider.cs` - 레어도 아이콘 렌더링 ※data-architect 공유 + +### 환경 비주얼 +- `Assets/Scripts/Core/SubAreaEnvironment.cs` - 서브에리어 환경 전환 (조명, 안개, 앰비언트) +- `Assets/Scripts/Core/WorldTerrainBuilder.cs` - 월드 지형 생성 (절벽, 강, 다리, 경사면) +- `Assets/Scripts/Core/SubAreaWorldBuilder.cs` - 서브에리어 프로시저럴 던전/환경 생성 +- `Assets/Scripts/Core/RegionTerrainBuilder.cs` - 리전별 필드 지형 생성 (언덕, 길, 바위, 나무) ### 캐릭터/의상 비주얼 - `Assets/Scripts/Core/CharacterOutfitManager.cs` - 의상 관리 diff --git a/.claude/audit-progress.md b/.claude/audit-progress.md new file mode 100644 index 0000000..fa239b6 --- /dev/null +++ b/.claude/audit-progress.md @@ -0,0 +1,159 @@ +# Audit 진척 + +이 파일은 `/audit` 슬래시 커맨드가 자동으로 읽고 갱신합니다. 수동 편집도 가능하지만 구조 유지 필수. + +- **Covered**: 누적 P0/P1 처리 완료 시스템 +- **Uncovered**: 다음 audit 후보 (우선순위순 — 위에서 아래로) +- **Round Log**: 일자별 처리 요약 + +## Covered (P0/P1 처리 완료) + +- [x] CharacterPortraitRenderer (P0:2, P1:2, 2026-05-20) — DrawWithOutfit 캐싱, DrawItemPreview 추가, footHalfGap 0.18→0.28, Proportions 헬퍼 통일. **사용자 명시 요청**: 캐릭터 미리보기 도구가 magnify만 표시되던 회귀 → DrawCharacterTool 9종 분기 추가 (gun/wand/lasso/shuriken/sword/web_shooter/magnify/camera/기본). ApplyToolShape ↔ DrawToolPreview ↔ DrawCharacterTool 3곳 분기 순서 1:1 정합. +- [x] PlayerVisualBuilder (P1:5, 2026-05-20) — Body Cube화, 어깨 ±0.29, 신발 ±0.19, SafeDestroyMat 가드, 도구 손 좌표 +- [x] CloudSaveManager (P1:2, 2026-05-20) — 401 토큰 갱신 재시도 (Load/Save), pendingSave 회귀 수정 +- [x] PlaySceneBootstrap (P1:1, 2026-05-20) — CriticalBootstrapException fail-fast + OnGUI 사용자 알림, GachaBox.AutoWire(database), RegionDefinitions 분리 +- [x] CharacterOutfitUI (P1:2, 2026-05-20) — 카드 미리보기 DrawItemPreview 호출, bigTitle/bigBonus CachedStyle +- [x] CharacterOutfitManager (P1:2, 2026-05-20) — ApplyToolShape 손 좌표 재배치, magnify/camera 분리, **도구별 mesh 교체** (Cube/Cylinder/Sphere)로 막대기 회귀 차단 +- [x] PlayerMovement (P1:1, 2026-05-20) — OutfitChanged 구독 + InvalidateToolBase, 도구 swing 동기 +- [x] CashShopManager (P1:1, 2026-05-20) — wallet 단일 소스, 1회 PlayerPrefs 마이그레이션 +- [x] CaptureMinigameController (P0:1, P1:1, 2026-05-20) — InitMinigameStyles 6 캐시 필드, OnDisable 안전망 +- [x] GachaBoxManager (P1:1, 2026-05-20) — ValidateInsectId + DB 캐시 +- [x] TutorialQuestManager (P1:1, P0:4, 2026-05-20) — CompleteQuest 후 CloudSave 즉시 동기. **튜토리얼 진행 회귀 P0:4건** — NotifyTeamSet/NotifyItemUsed/NotifyTraining/NotifySkillEquipped 호출 누락으로 q_equip/q_item/q_training/q_team이 영구 정지 + 후속 13개 퀘스트 연쇄 정지. 4곳에 Notify 호출 추가 (BattleTeamManager.TeamChanged 구독, ItemEffectManager.ActivateItem, TrainingManager.TrainSkill, TrainingUI EquipSkill 사용자 액션). +- [x] BattleScreenUI (P2:1 DrawCombo + P1:2 P2:1 추가, 2026-05-20) — DrawCombo ComboCol, DrawSwapSelect SwapHeaderBase + alpha 갱신, DisableCanvasBattleUI + CheckGuardianDefeat FindFirstObjectByType 캐싱 +- [x] SubAreaWorldBuilder (P0:8 + UI:1, 2026-05-20) — FindSafeSpawnPosition spiral 탐색, BuildCave/BuildTemple 입구 끼임 차단. 진입/퇴장 토스트 알림(3초) + F2 수동 Exit + 우측 하단 안내. RegionManager.ForceExitSubArea(). **사용자 명시 요청 2차**: 9개 환경 boundary wall halfSize를 floor 안쪽으로 조정 (Cave 16→14, DeepForest 18→16, Underwater 16→14, FogSwamp 16→14, MountainPeak 14→11, Temple 14→13, Reeds 14→11, GenericArea 12→9), BuildGreenhouse CreateGlassWall NoCollider 제거(collider 보존), Y 안전망(Y<-3 → 자동 RequestExit + 알림) + cachedPlayerTransform lazy 캐싱. +- [x] AuthManager (P1:2, 2026-05-20) — TryRefreshTokenForRetry public + refreshInProgress 가드. **사용자 명시 요청**: ValidateCredentials 헬퍼 + Register/Login 진입 시 호출 (빈 이메일/짧은 비밀번호 차단, Firebase API 호출 절약). 인증 흐름 전반 검증: TryAutoLogin 단일 호출, idTokenAcquiredAt 갱신 모든 경로 OK, AuthFailed→LoginUI 복귀, 회원가입→CharacterCreate 흐름 정상. +- [x] LoginUI (P1:2, 2026-05-20) — AuthFailed 구독 + 외형 변경 후 InvalidateCache. **사용자 명시 요청**: OnGUI 매 프레임 17개 new GUIStyle 변형 → base 스타일 fontSize 동적 갱신 패턴으로 교체 (BattleScreenUI/DexScreenUI 패턴). OnGUI 본문 잔존 new GUIStyle = 0. +- [x] RaidBattleUI (P0:2, P2:1, 2026-05-20) — EndRaid 재진입 누수(타이머 4개 + 팀 캐시 3개 초기화), GameObject.Find Player → playerMovement.transform 캐싱 +- [x] RaidBattleController (P1:1, P0:1, 2026-05-20) — UseUniteAttack 데미지 표시 정합성, CheckEnd 패배 시 BossEntity.Despawn 누락 + OnRaidVictory의 Despawn 가드 분리 +- [x] InsectBattleController (P0:2, 2026-05-20) — 패배 + 도주 성공 시 enemyEntity.Despawn 누락 — 둘 다 추가. 옛은 전투 후 곤충 필드 잔존, 중첩 발동 가능 +- [x] CaptureController (P0:1, 2026-05-20) — 미니게임 캡처 실패 시 50% Despawn → 100% Despawn. 사용자 명시: "미니게임 끝나면 사라져야" +- [x] CaptureFeedbackController (clean, 2026-05-20) — Explore 점검 완료. P0/P1 회귀 없음. CaptureResolved += -= 짝 정상, AudioManager/TutorialQuestManager.Instance null 가드 모두 있음, OnGUI 메서드 없음(TextMeshPro 사용), 타이머 흐름 순차적 +- [x] BattleArenaController (P1:1, 2026-05-20) — OnDisable ↔ CleanupArena 중복 정리 → OnDisable이 CleanupArena 위임으로 단일화. CleanupArena가 model 필드 null화까지 포함 (DRY + 옛 OnDisable 누락 보강). Explore P0:2 거짓양성 (Rect는 struct stack 할당, SetupNormalBattle 첫 줄 CleanupArena 호출됨) +- [x] DexController (clean, 2026-05-20) — Explore P0/P1 모두 거짓양성. SaveAndNotify는 RegisterEncounter/RegisterCapture 끝에 무조건 호출됨 (라인 51, 70), encounter/capture는 별개 dictionary라 race 없음, 0.1초 debounce 시나리오는 정상 게임 흐름에서 발생 불가. DexListUIController는 별도 시스템 (DexController audit 범위 외). +- [x] DexDetailUIController (clean, 2026-05-20) — Canvas 기반 UI (TMP_Text/Image), OnGUI 없음. 이벤트 구독 없음 (직접 Show/Hide 호출), AutoWire 안전, null 가드 견고 (라인 35-44). Resources.Load는 Show 1회 호출이라 P0 아님. 부수 발견: DexScreenUI에 OnGUI new GUIStyle 다수 → Uncovered 1순위로 분리. +- [x] DexScreenUI (P0:31, 2026-05-20) — 매 OnGUI 31개 new GUIStyle + 수십 개 new Color 회귀. InitDexStyles 패턴 + 31개 캐시 필드 + 매 프레임 핫패스 Color 38개 static readonly. OnGUI 본문 잔존 new GUIStyle = 0. 매 프레임 GC 압박 105~150 객체 → 0 객체. DrawTopBar(6) + DrawPokedex loop(6) + DrawDetail(12) + DrawOwnedCard loop(5) + DrawItems/Row(4) + DrawNoSelection(1) + DrawCentered(1) 모두 캐시 사용. +- [x] DexListUIController (clean, 2026-05-20) — Explore P0/P1 모두 거짓양성. SortList의 dexController null 가드는 BuildList:60 early return으로 보장됨. AutoWire 이벤트 짝은 모든 시나리오(null/동일/새 인스턴스)에서 정상. BuildList 재생성은 사용자 액션 시만 호출 (매 프레임 아님). + +## Uncovered (우선순위순) + +가장 위가 다음 `/audit`에서 자동 선택됩니다. + +**2026-05-21 — 원 18개 Uncovered 모두 Covered 완료. 추가 발굴 항목 신규 등록:** + +- [x] SaveService 글로벌 atomic write 라운드 (P1:8, 2026-05-21) — 신규 AtomicFileWriter (Core/AtomicFileWriter.cs, 38줄) — tmp 파일 쓰기 + File.Replace로 OS 레벨 atomic rename. 8곳 일괄 교체: PlayerProgressSaveService/PlayerCandyInventory/PlayerCurrencyWallet/PlayerItemInventory/PlayerInsectCollection/BattleTeamManager/DexSaveService/CloudSaveManager.SaveLocalFile. 게임 종료 직전 크래시/SIGKILL 시 부분 쓰기로 인한 세이브 손상 차단. CashShopManager는 PlayerPrefs 사용으로 글로벌 라운드 제외. DexSaveService에 Save(null) 가드도 일관성 추가. +- [x] CollectionUI 핫스팟 (P0:1, P1:3, 2026-05-21) — (a) P0: DrawInsectItem 핫스팟 5개 GUIStyle + 4개 Color 캐시화 — owned.Count × 5 = 최대 100+/프레임 → 0. InitItemStyles + itemNameStyle/itemInfoStyle/itemGradeStyle/itemStatMiniStyle/itemViewStyle + 6 static readonly Color. nameStyle/gradeStyle textColor 동적 갱신(BattleScreenUI 패턴). (b) P1: GetAllOwned 캐싱 — cachedOwned + ownedCacheDirty 플래그, HandleInsectUpdated에서 invalidate. line 116(DrawInsectList) + line 320(DrawStats) 매 프레임 List 할당 제거. (c) P1: OnEnable/OnDisable/AutoWire에 InsectUpdated -=/+= 짝 추가. 옛은 OnDisable이 ModalUIRegistry.Unregister만 호출, 이벤트 해제 누락. AutoWire가 OnEnable 후 호출되는 경우 isActiveAndEnabled 가드로 구독. (d) P1: DrawCenteredLabel의 new Color 2곳 static readonly. **잔여**: DrawPanel(P1:5)/DrawDetailPanel(P1:10)/DrawStats(P1:5)/DrawLevelUpSection(P1:10)/DrawStatBar(P1:5) — 호출 빈도 1회/프레임으로 P1, 별도 라운드 권고. +- [x] TrainingUI 핫스팟 (P0:1, P1:3, 2026-05-21) — (a) P0: DrawInsectSelect loop 4 GUIStyle + 4 Color 캐시 — owned.Count × 4 = 최대 80+/프레임 → 0. InitInsectSelectStyles + insectSelectSub/Name/Info/BtnStyle + 4 static readonly Color. NameStyle textColor 동적 갱신. (b) P1: GetAllOwned 캐싱 — cachedOwned + ownedCacheDirty + HandleInsectUpdated invalidate (CollectionUI 패턴). (c) P1: OnEnable/OnDisable/AutoWire에 InsectUpdated -=/+= 짝 추가. 옛은 OnDisable이 ModalUIRegistry.Unregister만 호출, 이벤트 해제 누락. AutoWire가 OnEnable 후 호출되는 경우 isActiveAndEnabled 가드. (d) **잔여**: DrawPanel/DrawMethodSelect/DrawSkillLearn/DrawSkillEquip/DrawSkillReplace — 약 40+ new GUIStyle/Color, 호출 빈도 1회/프레임으로 P1, 별도 라운드 권고. +- [x] CollectionUI 잔여 영역 (P1:27, 2026-05-21) — DrawPanel/DrawDetailPanel/DrawStats/DrawLevelUpSection/DrawStatBar/DrawCenteredLabel 27개 new GUIStyle 일괄 캐시화. InitDetailStyles + 25 캐시 필드(panelTitle/Close/TabActive/TabInactive, detailBack/Name/Rarity/GradeDisp/GradePerc/Desc/Hint, statsLabel/Value/CandyVal, luLvLabel/Num/XpLabel/XpVal/MaxLv/Btn/CandyInfo/Msg, barLabel/Iv/Total/IvLabel, centeredLabel) + 25 static readonly Color. 5개 메서드 모두 textColor 동적 갱신 패턴(detailName/Rarity/GradeDisp/GradePerc + luCandyInfo/Msg + barIv + centeredLabel). DrawStats에 GetCachedOwned 사용 추가(매 프레임 GetAllOwned 회피). 동적 rarityCol scaled/alpha scaled new Color는 struct stack 거짓양성. OnGUI 본문 잔존 new GUIStyle = 0. +- [x] TrainingUI 잔여 영역 (P1:34, 2026-05-21) — DrawPanel/DrawBackButton/DrawMethodSelect/DrawSkillLearn/DrawSkillEquip/DrawSkillReplace/DrawSkillCard/DrawFeedback 34 new GUIStyle 일괄 캐시화. InitTrainDetailStyles + 33 캐시 필드 + 38 static readonly Color. 8 메서드 textColor 동적 갱신 패턴(methodName/CardName/Cost + learnHeader + equipName/SlotName/LearnedName + replaceNew/OldName + cardSkillName + feedback). themeColor scaled new Color (line 246)는 struct stack 거짓양성, feedback alpha 동적 new Color도 거짓양성. OnGUI 본문 잔존 new GUIStyle = 0. +- [x] PlayerStatusHUD (P0:1, P1:2, 2026-05-21) — (a) P0: OnGUI 본문 new Color 16곳 → 16 static readonly Color 일괄 추출 (PanelBg/AccentBlue/Divider, LvBadgeBgDark/Accent, XpBarBg/FillDark/Light, StatCandyPink/CoinGold/GemBlue/TeamOrange/OwnedGreen/DiscoveredBlue/CapturedGold, RegionDefault). DrawResourceSection 4 + DrawCollectionSection 3 + DrawLevelSection 5 + DrawPanel/DrawRegionSection 3+1. (b) P1: GetAllOwned 캐싱 — cachedOwnedCount + ownedCountCacheDirty + HandleInsectUpdated invalidate (CollectionUI/TrainingUI 패턴). (c) P1: OnEnable/OnDisable에 InsectUpdated 구독/해제 짝 추가 (subscribedInsects 플래그). 옛은 매 프레임 DrawCollectionSection이 GetAllOwned() 호출하여 새 List 할당. shine alpha 동적/regionCol scaled/accent scaled/alertCol alpha 동적 new Color는 struct stack 거짓양성. new Rect 좌표 동적도 거짓양성. +- [x] TrainingManager (P1:1, 2026-05-21) — TrainSkill의 SpendCandy 호출을 LearnSkill/ReplaceSkill 성공 후로 이동 + 실패 시 early return. 옛은 ReplaceSkill 실패(replaceSkillId가 learnedSkillIds에 없는 경우 등) 시 candy 차감 후 학습 안 됨 → candy 손실. 정상 흐름(TrainingUI.DrawSkillReplace의 learned 리스트에서 선택)에선 안전하나 방어 부재. LearnSkill은 line 64(HasLearnedSkill) + line 65(ContainsKey) + line 75(IsSkillsFull) 사전 가드로 실패 불가(거짓양성)이나 일관성 차원에서 동일 패턴 적용. CanTrain vs SpendCandy race / 자동 장착 NotifySkillEquipped 미발화 / TrainingCompleted vs NotifyTraining 순서 모두 거짓양성. +- [x] CashShopManager 잔여 영역 (P1:3, 2026-05-22) — (a) PurchaseWithGems line 119 stale `gems` 캐시 → `Gems` 프로퍼티(wallet 우선) 사용 — 외부 wallet 변경 후 잘못된 잔액 검사 차단. (b) line 137 `gemsBefore = gems` → `Gems` — 환불 액수 stale 방지. (c) AddGems(0) early return — 무의미한 GemsChanged 발화 + 구독자 무의미한 갱신 차단. 단일 스레드 race는 모두 거짓양성, FindFirstObjectByType 매 호출은 결제 빈도 낮음으로 P2, shopItems 데이터 검증은 inline 정의로 수동 책임 P2. +- [x] InsectSpawner Spawn/Despawn 다중 호출 검증 (clean, 2026-05-22) — 진짜 P0/P1 회귀 없음. 직전 라운드 InsectEntity.despawnedThisCycle 플래그 동작과 일관. DespawnAllActiveInsects snapshot + Clear 패턴 안전(콜백 Remove no-op), DespawnFarInsects 역순 for + Remove 안전(i-- 인덱스 보존), 풀 재사용 시 Initialize의 cache/플래그 리셋으로 안전, OnSubAreaChanged 빠른 Exit는 currentSubArea != subArea 가드(line 698), DespawnEntity 외부 직접 호출 없음, activeInsects 중복 추가 불가, Unity 단일 스레드로 모든 race 거짓양성. SpawnSubAreaInsects maxActiveTotal 검사 부재는 DespawnAll 직후 호출이라 P2 수용. Covered 이동만. +- [x] RegionMapUI 깊은 점검 (clean P0, P1 보류, 2026-05-27) — Explore P0:2(이벤트 누락/null 가드) 모두 거짓양성. 라인 143 `regionManager == null || regionManager.Regions == null` early return 가드 있음. CurrentRegion stale은 OnGUI 매 프레임 regionManager.CurrentRegion 직접 조회로 정합. 30+ new GUIStyle 매 OnGUI는 진짜 P0이나 변경 폭 크고 RegionMapUI는 모달 토글 시만 진입(매 프레임 아님) → P1 별도 라운드 권고. +- [x] TutorialQuestUI DrawQuestPanel 캐시 (P0:1, 2026-05-27) — DrawQuestPanel은 OnGUI에서 매 프레임 호출되어 핫스팟. 5 GUIStyle(doneStyle/title/desc/prog/hint) 매 프레임 new → InitQuestPanelStyles + 5 캐시 필드 + 3 static readonly Color (DoneTextCol/QuestTitleCol/QuestHintBaseCol). hintStyle은 alpha 동적이라 textColor만 갱신 (BattleScreenUI 패턴). AutoWire 이벤트 중복 구독은 라인 499-504 `-=` + `+=` idempotent로 거짓양성. 잔존 8곳 new GUIStyle은 일시 표시(DrawCompletionNotification 3초 / DrawNewQuestNotification 2초 / DrawDetailPanel 수동 토글)이라 별도 라운드. +- [x] WorldChannelManager 깊은 점검 (clean P0/P1, 2026-05-27) — Explore P1:3 모두 거짓양성/P2 강등. BestEffortLeaveWorld dispose 누락은 OnApplicationQuit 직후 OS 프로세스 정리. LeaveWorld race는 leaveInProgress 플래그 미적용이나 LeaveWorld 호출 빈도 매우 낮음(사용자 의도 액션 1회) + isUpdatingPresence 이전 처리로 PATCH 일반 race 차단됨 → P2 수용. 401 자동 갱신은 WorldChannel 영역 외(이전 AuthManager TryRefreshTokenForRetry 처리는 CloudSaveManager 한정 의도된 범위). +- [x] **RegionTerrainBuilder c.y 이중 합산 버그 — 진짜 원인** (P0:21, 2026-05-27) — 사용자 명시 "고대유적/숲 layer 층이 위에 떠있어 캐릭터/곤충 가려짐" 보고. WorldTerrainBuilder.ApplyElevation(라인 22-39)이 region center.y를 forest:4 / ruins:8 / mountain:12 / pond:-3 / swamp:-2로 변경한 후, BuildXxxTerrain의 `c + new Vector3(x, c.y + offset, z)` 패턴이 c.y를 또 더해 결과 Y = **2 × c.y + offset**. ruins(c.y=8) 환경 모두 Y=16m로 솟아 캐릭터(Y=8.2 머리) 위 8m 떠있던 게 "층이 위에" 인상의 진짜 원인. 21곳 일괄 수정: `, c.y, ` → `, 0f,`, `, c.y + ` → `,`, `, c.y - ` → `, -`. Pond/Swamp/Mountain/Garden 동일 버그 함께 수정. Forest/Ruins 환경 축소 이전 라운드들(P1:4 + P0:11 누적)은 효과 거의 없었음 — 진짜 원인은 이 이중 합산 버그. +- [x] Forest/Ruins 환경 추가 축소 — "layer 층 전체 위에" (P1:2, 2026-05-27) — 사용자 추가 보고 "layer 층 전체가 위에 떠있어 캐릭터/곤충이 가려짐". 이전 절반 축소(treeH 1.2~2.5, wh 0.5~1.5, ph 1~2)로 부족 → 추가 강제 축소. (a) Forest treeH 0.8~1.5(낮은 관목), leafS 1~1.8 → 잎사귀 Y 1~2.5m로 캐릭터 머리(2.2) 이하/근처. (b) Ruins wh 0.3~0.8(잔해), ph 0.7~1.3(캐릭터 어깨 아래). 25 트리/8 기둥이 평면 layer로 모이지 않음. +- [x] 메인 월드 Forest/Ruins region 환경 높이 (P1:2, 2026-05-27) — 사용자 명시 "고대유적과 숲이 메인 월드에서 하늘에 있는 것처럼 보임" 보고. RegionTerrainBuilder.BuildForestTerrain/BuildRuinsTerrain (이전 SubArea 변경은 사용자 의도 영역 외였음, 영향 없으나 무관). (a) **Forest**: treeH 3~6→1.2~2.5(절반 축소), leafS 2~4→1.5~2.5. 트리 trunk Y 0~6m→0~2.5m, 잎사귀 Y 3.6~7.2m→1.8~3.5m로 캐릭터 머리(2.2) 부근. 잎사귀 ShadowsOnly 제거 — 옛 ShadowsOnly가 leaf mesh 안 보이고 그림자만 공중에 떠 보이는 "환경이 하늘에 떠있어" 인상의 진짜 원인이었음. 정상 렌더링 + leafS 축소로 카메라 시야 차단 최소화. (b) **Ruins**: wh 1~3→0.5~1.5, ph 2~4→1~2. 벽 Y 0~3m→0~1.5m, 기둥 standing Y 0~4m→0~2m로 캐릭터와 비슷한 평면감. +- [x] SubArea 덮개 가시성 (P1:2, 2026-05-27) — 사용자 명시 "고대유적/숲이 하늘처럼 덮여서 캐릭터 안 보임" 보고. (a) DeepForest 잎사귀 ShadowsOnly 적용 — 그늘 인상 유지하며 카메라(Y=10)와 캐릭터(Y=1.04) 사이 시야선 차단 해제 (Cave 천장 동일 패턴). (b) CameraFollower SubAreaOffset (0,16,-12)→(0,10,-14) 측면화 — 부감 53도→36도로 줄이고 z 거리 -12→-14로 늘려 캐릭터 옆에서 보이도록. 환경 위 평면에서 캐릭터 가려지던 회귀 차단. Temple 기둥은 양옆 배치라 측면 시야로 충분히 보임 (ShadowsOnly 미적용). +- [x] 어깨 추가 + SubArea 부유/환경 높이 후속 (P1:4, 2026-05-27) — 사용자 명시 후속 보고. (a) **어깨 추가 하향**: ArmL/R Y 1.55→1.40 (캡슐 범위 1.15~1.65m, Body 중간 위치). HandL/R Y 0.98→0.95. (b) **SubArea 부유 해결**: SubAreaOrigin Y 0→0.5로 한 줄 변경 → 8개 모든 환경(subAreaRoot 자식)이 world Y=0.5로 일괄 올라가서 캐릭터/곤충(Y=0.5 텔레포트→Y=1.0)과 같은 평면감. (c) **DeepForest 높이 축소**: 트리 기둥 Y 3→1.5 scale 3→1.5, 잎사귀 Y 6.5→3 scale (3.5,2.5,3.5)→(2.5,1.5,2.5). (d) **Temple 기둥 축소**: Y 3→1.8 scale 3→1.8. 카메라 SubAreaOffset (0,16,-12) 유지 — 환경이 낮아져 차폐 가능성 추가 감소. 다른 6개 환경(Cave/Underwater/FogSwamp/MountainPeak/Reeds/Greenhouse/GenericArea) 높이 축소는 사용자 추가 보고 시 별도 라운드. +- [x] 캐릭터 시각 버그 3종 (P1:3+구조 변경, 2026-05-27) — 사용자 명시 요청. (a) **어깨**: ArmL/R Y 1.62→1.55 + scale Y 0.55→0.50 + HandL/R Y 1.0→0.98. 캡슐 상단 1.895→1.80m로 Body 상단(1.79)과 일치, 머리(2.20) 영역 침투 차단. (b) **발 swing**: BootL/R을 Player 직접 자식 → LegLPivot/LegRPivot 빈 컨테이너 자식으로 구조 변경 (Capsule scale 비균등이라 Pivot scale=1 유지 필수). PlayerMovement cachedLegL/R → cachedLegPivotL/R, AnimateWalk 회전 대상 Pivot으로 교체 → Leg+Boot 둘 다 자동 전파. CharacterOutfitManager.ApplyPartColor는 FindDeep 재귀 검색이라 호환. (c) **SubArea 차폐**: SubAreaWorldBuilder에 GetSubAreaEnvLayer(Layer 31 fallback) + SetLayerRecursively 헬퍼 추가, EnterSubArea의 8개 환경 빌드 끝에 1회 호출(개별 빌드 메서드 미수정). CameraFollower.ResolveObstruction hit loop에 layer 제외 1줄. CameraFollower.SetSubAreaMode(bool) 추가 (NormalOffset (0,12,-8) ↔ SubAreaOffset (0,16,-12)). SubAreaWorldBuilder EnterSubArea/ExitSubArea에서 SetSubAreaMode(true/false) 호출 (ResetBaseline 내장). +- [x] TutorialQuestUI 잔존 캐시 (P1:8, 2026-05-27) — DrawCompletionNotification 3 + DrawNewQuestNotification 1 + DrawDetailPanel 4 (header/close/row/status) GUIStyle 매 호출 new → InitNotifStyles + InitDetailStyles + 8 캐시 필드 + 9 static readonly Color. 알림 4 textColor alpha 동적, row/status textColor 분기 동적(완료/활성/잠금/대기). 17 quest×2(row+status)=34 GUIStyle/프레임 차단. OnGUI 본문 잔존 new GUIStyle=0. +- [x] CapturePopupUI 캐시 (P1:12, 2026-05-27) — DrawSuccessPopup 8 + DrawFailPopup 2 + DrawMiniIVBar 2 GUIStyle 매 OnGUI(popupTimer>0 5초) new → InitPopupStyles + 12 캐시 필드 + 7 static readonly Color (SubGrayBase/GradeTitleGrayBase/RewardCandyBase/RewardExpBase/FailMsgBase/FailSubBase/IvLblBase). 12 textColor alpha+rarityCol+gc 동적 갱신. OnGUI 본문 잔존 new GUIStyle=0. +- [x] RegionMapUI 캐시 + OnDisable 정리 (P1:28+1, 2026-05-27) — (a) DrawMap 5 + DrawErrorMessage 1 + DrawMiniMap 5 + DrawRegionList 5 + DrawRegionDetail 6 + DrawDexItem 6 + DrawMapTerrain symStyle 1 = 28 GUIStyle 매 OnGUI new → InitMapStyles + 28 캐시 필드 + 3 static readonly Color. DrawMap/DrawRegionDetail 진입에 InitMapStyles 호출. textColor 분기/alpha/themeColor 동적 갱신. (b) OnDisable에 isOpen=false + selectedRegionId=null + ModalUIRegistry.Unregister 추가 (CharacterOutfitUI 동일 P1 패턴, GO SetActive 토글 시 stale 모달 차단). OnGUI 본문 잔존 new GUIStyle=0. +- [x] WorldLobbyUI MakeTex 누수 (P0:7, 2026-05-27) — DrawWorldSelectPanel/DrawWorldRow/DrawJoiningPanel/DrawInWorldPanel의 OnGUI 매 프레임 7곳 `MakeTex(1,1,...)` 새 Texture2D 생성 → UIHelper.GetCachedTex 교체. static readonly Color 10개 추출(BgOverlayCol/RowFull/Almost/OkCol/BarBgCol/FillFull/Almost/OkCol/LineSepCol/MeRowBgCol). 월드 1개당 4 Texture2D(rowTex/barBg/fillTex)/프레임 + 배경 1 = 월드 수×4+1 객체/프레임 누적 누수 차단. InitStyles의 4곳 MakeTex(라인 397/426/429/430)은 1회만 호출이라 유지. +- [x] CharacterOutfitUI infoStyle 캐시 + OnDisable 정리 (P0:1+P1:1, 2026-05-27) — (a) P0: OnGUI 라인 234/239의 infoStyle/infoNameStyle 매 프레임 new GUIStyle 생성 → InitStyles에 infoStyleCache/infoNameStyleCache 캐시 추가 + DrawPanel에서 직접 참조. InfoLabelCol static readonly로 추출. (b) P1: OnDisable이 ModalUIRegistry.Unregister만 호출하고 isOpen=true 잔존 → 같은 GO SetActive 토글 시 isOpen=true이지만 Registry 미등록 → HandleEscape가 모달 무시. isOpen=false 추가. +- [x] CapturePopupUI 점검 (clean P0, P1:1 보류, 2026-05-27) — 알파 계산 라인 117은 의도된 fade-in(animTime/0.3, 0.3초)/fade-out(popupTimer/0.5, 마지막 0.5초) 분기로 P2 수용. OnGUI 매 프레임 new GUIStyle 14개+new Color 다수는 popupTimer>0(약 5초 표시 시간)에만 진입하여 일시적 → P1이나 다른 핫패스 처리 후 별도 라운드 권고. 이벤트 구독/null 가드/UIHelper 사용 모두 정합. +- [x] CharacterPortraitRenderer 깊은 정합성 (clean, 2026-05-27) — Explore P0:2+P1:2 모두 거짓양성. hatId 처리 분기는 hat_none 시 customHat=true(라인 332)→Draw 내부 모자 투명 + 라인 501 hat_none 조건 거짓→형태 분기 안 들어감 → 모자 미표시 일관. cutlass(라인 435-441) DrawCharacterTool은 손잡이+칼날 2단계, DrawToolPreview의 차이는 별도 미리보기 컨텍스트(아이템 카드 vs 캐릭터)이라 의도된 시각적 차이. InvalidateCache 트리거는 LoginUI에서 직접 호출(라인 590)로 처리됨. 9종 도구 결합도는 구조적 P2. +- [x] PlaySceneBootstrap 깊은 점검 (clean, 2026-05-27) — Explore P0:1+P1:2 모두 거짓양성. RegionChanged 람다 누수는 RegionManager가 DontDestroyOnLoad 아님(grep 결과 AudioManager/Auth/CloudSave 3개만) → 씬 재로드 시 옛 RegionManager+옛 람다 함께 GC. AuthManager.Instance 라인 401은 != null 가드 있음. playerMovement.AutoWire는 라인 330에서 실제 호출됨(Awake → Build() → Update 라이프사이클상 안전). +- [x] SubAreaWorldBuilder 깊은 점검 (clean, 2026-05-27) — Explore P0:1+P1:6 모두 거짓양성. EnterSubArea race는 Unity Input 단일 프레임 폴링이라 같은 프레임 F2 불가능. ShowMainWorld 복원은 hiddenMainObjects 기반(HideMainWorld가 add한 것만)이라 이름 규칙 벗어난 오브젝트는 add도 안 되어 복원 필요 없음(정합). sticky 회피는 0.01s < 1.5s 쿨다운으로 유지. Destroy 비동기는 Unity 표준 동작. 코루틴 누수는 현재 횃불 애니메이션 없음(future bug). cachedPlayerTransform은 Player Transform 자체라 위치만 변하지 stale 참조 아님. InsectSpawner 부모 mismatch는 SubAreaWorldBuilder 영역 외(별도 라운드 후보). +- [x] TutorialQuestManager 깊은 점검 (clean, 2026-05-27) — Explore P0:1+P1:3 모두 거짓양성. NotifyAction 비활성 무시는 prerequisite 체인 강제 순차 진행으로 비순차 시나리오 발생 불가. CloudSave race는 pendingSave 플래그로 단일 대기 처리. 옛 세이브 activeQuestId 불일치는 LoadProgress가 completedQuests 함께 로드 후 ApplySaveData 적용 + Start의 ActivateNextQuest 흐름이 일관. 마스터 계정 activeQuestId=null은 17 퀘스트 모두 완료 상태에서 의도된 동작(퀘스트 다 끝낸 유저는 UI 표시 안 하는 게 자연스러움). 의도 변경 필요 시 별도 디자인 작업. +- [x] BattleScreenUI 깊은 로직 (clean, 2026-05-27) — Explore P0:2+P1:3 모두 거짓양성. cachedRegionMgr는 라인 313에 `regionMgr == null` 가드 있음. EndBattle 라인 3094-3099 finally 블록에 ExitBattleMode + SetFrozen(false) + Time.timeScale 복구 완비. SwapSelect↔PlayerTurn 키 처리는 phase별 격리(라인 379-384/405-412) + OnGUI Event 라인 726-748 phase 체크 후 set + Update phase 체크 후 consume + 즉시 reset(라인 411-412)으로 안전. InsectBattleUIController.ShowResult는 별개 Canvas UI 시스템, BattleScreenUI(OnGUI)와 무관. lastDamageToEnemy/Player phase 전환 미초기화는 다음 OnBattleUpdated에서 덮어쓰니 P2 수용. nameTagCache.fontSize 동적 할당은 GUIStyle 자체 캐시 + 프로퍼티만 갱신이라 P2. +- [x] RaidBattleUI 깊은 로직 (clean, 2026-05-27) — Explore P0:3+P1:5 모두 거짓양성. EndRaid 라인 2380-2384 displayTeamHp/teamShake/selectedSlot/cameraFollower/playerMovement 모두 정리(이전 라운드 P0:2 처리분과 정합). AutoWire 라인 3005 `raidController != rc` 가드로 동일 rc 재호출 시 중복 구독 차단, null rc 시 라인 3005 false라 raidController=null 미설정 안전. Result→None은 EndRaid에서만 세팅, 라인 309 None 진입은 새 레이드 시작이라 정상. lastDmgToBoss 게이지 누적은 RaidController 턴 단위 관리로 race 없음. SelectInsect KO 차단은 TrySelectInsect 라인 585-586 CurrentHp > 0 체크 있음. +- [x] BattleArenaController 이펙트 (clean, 2026-05-27) — Explore P0:3+P1:3 모두 거짓양성. Material 누수는 Unity가 GameObject Destroy 시 attached Renderer.material 인스턴스 자동 정리. StopAllCoroutines 후 arenaRoot Destroy하면 child GameObjects + 그 Material 인스턴스 일괄 정리. ExitBattleMode/SetFrozen(false)는 호출자(BattleScreenUI EndBattle:3097-3098, RaidBattleUI EndRaid:2383-2384)에서 책임 분리 패턴으로 처리. 다중 SetupNormalBattle 호출은 라인 57/92에서 CleanupArena가 진입 첫 줄이라 안전. 투사체는 라인 623 arenaRoot.SetParent로 자동 정리. 팀 배치 정적 계산은 게임이 runtime 팀 변경 미지원으로 비해당. +- [x] PlayerMovement 모달 차단 + InputAction 흐름 (clean, 2026-05-27) — 559줄, 8 책임 매트릭스 점검 결과 진짜 P0/P1 회귀 0건. 모달 차단: HandleEscape() + IsAnyOpen() 이중 방어(Line 137-143 ESC return + Line 169 마우스 클릭 차단). Frozen ESC 처리는 모달 우선(Line 121-131). 이중 ESC 회귀는 ModalUIRegistry.IsAnyOpen 자동 정리(Line 37-40)로 거짓양성. InputAction 흐름은 Unity 구식 Input.GetKey + OnGUI Event 백업(guiKey*/guiEsc/guiClick) 이중 경로로 F9/F11 OS 인터셉트·Editor·접근성 장치 우회. KeyUp에서 guiKey* 명시 false로 누적 없음(Line 318-330). OutfitChanged 동기는 InvalidateToolBase 즉시 호출로 stale base 1프레임 지연 차단(2026-05-20 P1:1 완료). CameraFollower 점프는 SetTarget baselineValid 리셋(2026-05-21 P1:2 완료). FindFirstObjectByType 호출 0건. cachedArm/Leg/Body/HeadPivot은 transform.Find 1회 후 캐싱(Line 42-46) 정상. outfitBonus null 가드(Line 240) 있음. **Covered 이동만**. + +- [x] InsectSpawner (P1:2, 2026-05-20) — OnGUI debugStyleCache lazy 캐싱(매 프레임 new GUIStyle 차단), GetPlayerTransform lazy 캐싱(매 호출 FindWithTag+Find 회피). Explore P0:2 거짓양성 (CleanupDeadEntities는 외부 SetActive(false) 직접 호출 없음 + DespawnFarInsects null entity는 이미 destroy 상태라 RemoveAt만 정상), P1 Sin/Cos는 8초당 8회 = 초당 1회 거짓양성. +- [x] RegionManager (P1:3, 2026-05-20) — IsRegionAccessible 마스터 우회 (AuthManager.IsMasterAccount race 차단), DefeatGuardian 중복 가드 (HashSet idempotent 명시 + SaveUnlockState 중복 PlayerPrefs.Save 회피), LoadUnlockState unlockedRegions Split RemoveEmptyEntries (옛은 빈 항목 누적). P2 Time.time vs realtimeSinceStartup은 일반 게임 timeScale 영향 적어 수용. +- [x] RegionTerrainBuilder + RegionMapUI (UI:2, 2026-05-21) — 사용자 명시 요청. BuildBoundaries 신규 (60-segment fence + 자동 인접 검출 gateway gap + 노란 마커, 환경별 fence shape). RegionData.connections 선택 필드 추가. RegionMapUI: 사각 테두리 → 원형 ring(32 sample) + 현재 리전 펄스 alpha + gateway 노란 점. +- [x] ItemEffectManager (clean, 2026-05-21) — Explore P0:1+P1:2+P2:1 모두 거짓양성/자체 모순. ActivateItem 옛 item 덮어쓰기는 의도된 동작, 게임 종료 시 메모리 해제, ActiveItemChanged 구독자가 OnDisable에서 해제. +- [x] WorldStateProvider (clean, 2026-05-21) — Explore P0:2+P1:1+P2:1 모두 거짓양성. WorldState는 struct(stack 할당, GC 없음), pull 모델이라 이벤트 구독 디자인상 불필요, null 가드 fallback(DayPhase.Day/hour 12/Clear) 정상. +- [x] WorldChannelManager (P1:1, 2026-05-21) — Update 코루틴 중복 실행 차단(isRefreshingWorld/isUpdatingPresence 플래그). 네트워크 지연으로 30초 timeout 초과 시 매 프레임 새 코루틴 시작 → CurrentWorld 동시 접근/PATCH race. Explore P0 AuthManager null 거짓양성(모든 public API에 IsFirebaseReady 가드), WorldLobbyUI OnDestroy 거짓양성(Unity OnDestroy 전 OnDisable 자동), BestEffortLeaveWorld 중복 거짓양성(RemoveAll idempotent). +- [x] BattleTeamManager (P1:2, 2026-05-21) — SetSlot no-op 가드(같은 값 재설정 시 TeamChanged 발화 안 함), MigrateLegacySlots TeamChanged 발화 제거(시스템 내부 정리는 사용자 액션 아님). 옛은 TutorialQuestManager가 TeamChanged 구독해 q_team 진행도 자동/중복 가산 위험. Explore P0:2+P1:1+P2:1 모두 거짓양성(슬롯 부족 자동 채움, saveData fallback, 사본 반환, single execution). +- [x] BattleTeamUI (P1:16+22, 2026-05-21) — 16개 GUIStyle 캐시 필드 + InitTeamStyles + 21개 static readonly Color. DrawTeamPanel(3) + DrawSlot(7) + DrawInsectPicker(2) + DrawPickerItem(4) 모두 캐시 사용. railCol/pickerNameCache.textColor 동적 갱신. OnGUI 본문 잔존 new GUIStyle/Color = 0. 매 프레임 owned.Count × 5+ 객체 → 0. +- [x] PlayerInsectCollection (P1:3, 2026-05-21) — (a) Awake에서 instanceId 중복 시 새 GUID 발급 + dirty mark — 옛은 dictionary가 1개만 보존되어 BattleTeam.GetByInstanceId가 나머지 인스턴스 못 찾는 회귀(옛 세이브 마이그레이션 시). (b) GainXp(data, insect, amount)에 insect null 가드 — 형제 오버로드(line 169)와 비대칭으로 line 185 NRE 위험. 호출자 0건 확인했으나 방어 코드 유지. (c) GetAllOwned null 항목 sanitize — 손상 세이브 시 호출자 6곳(BattleTeamUI/CollectionUI/DexScreenUI/TrainingUI/PlayerStatusHUD/PlayerInsectSelectionUIController) NRE 차단. 슬롯 인덱스 race는 Unity 단일 스레드 + BattleTeamManager.SetSlot 중복 가드(line 59-63)로 거짓양성. +- [x] PlayerProgressController (P1:3, 2026-05-21) — (a) Awake sanitize: 손상 세이브 시 level 1~maxLevel 클램프 + currentXp ≥ 0 클램프 + dirty 시 즉시 Save. 옛은 외부 JSON 편집/손상 파일로 GetXpToNextLevel 음수 진입 위험. (b) 만렙 도달 시 currentXp = 0 클램프 — `Lv 50 / XP 99999` UI 불일치 차단. (c) 만렙 후 GainXp 호출 시 early return — 잉여 XP 누적 + 무의미한 디스크 쓰기/SaveCloud 차단. level/xp 동기 race는 단일 writer(GainXp) + private data로 거짓양성. 디스크 IO 디바운스는 호출 빈도 낮아(배틀/캡처/튜토리얼 시점만, burst 없음) P2 수용. CloudSaveManager.Instance null 가드는 SaveToCloud 내부 3중 가드(IsFirebaseConfigured + IsLoggedIn + IsSaving)로 거짓양성. +- [x] PlayerProgressSaveService (P1:1, 2026-05-21) — Save(null) 가드 추가. static service라 임의 호출자가 진입 가능, 옛은 JsonUtility.ToJson(null)이 빈 문자열 → WriteAllText로 빈 파일 → 다음 Load 데이터 손실. atomic write 부재(P2)는 다른 SaveService(PlayerInsectCollection/BattleTeamManager/CashShopManager 등) 모두 동일 패턴이라 일관성 차원에서 별도 글로벌 라운드로 이관. 단일 writer(PlayerProgressController.GainXp)라 동시 쓰기 race 없음, FromJson 손상 회복은 catch + ?? new로 충분. +- [x] PlayerCandyInventory (P1:2, 2026-05-21) — (a) AddCandy/SpendCandy 내부 data null 가드 추가 — Candies 프로퍼티(line 17)는 null 가드 있으나 두 변경 메서드는 비대칭, Awake 실패/순서 race 시 NRE. (b) Save(null) 가드 — PlayerProgressSaveService와 일관성. 호출자 6곳(InsectBattleController/CaptureController/RaidBattleController/CashShopManager/GachaBoxManager/UI)은 외부 `candyInventory?.` 가드 사용 중. 디스크 IO 디바운스는 호출 빈도 낮음(배틀/캡처/가챠 보상만)으로 P2 수용, atomic write는 글로벌 라운드로 이관. +- [x] PlayerCurrencyWallet (P1:5, 2026-05-21) — (a-d) AddGems/SpendGems/AddCoins/SpendCoins 4개 변경 메서드 data null 가드 추가 — Gems/Coins 프로퍼티(line 18-19)와 비대칭 회귀. (e) Save() 내부 data null 가드 — 빈 파일 쓰기 + CurrencyChanged 구독자에게 null 전파 차단. CashShopManager gems 단일 소스 패턴(이전 audit)과 정합 유지. atomic write는 글로벌 라운드로 이관. +- [x] PlayerItemInventory (P1:3, 2026-05-21) — (a) Awake sanitize: 손상 세이브 null record / 빈 itemId / 중복 itemId 처리. 중복 시 count 합치고 stale record 제거 — 옛은 lookup이 마지막 record만 보존해 AddItem 변경이 stale로 동기화 깨짐. (b) GetSnapshot 얕은 복사 반환 — DexScreenUI:659 등 호출자가 save.items 직접 변경 시 lookup race 차단. (c) Save(null) 가드 — 다른 SaveService와 일관성. UseItem 0 잔량 제거 로직(line 78-82)은 정상, GetCount 음수 클램프(line 96)도 정상. +- [x] PlayerInsectLevelUpUIController (P1:3, 2026-05-21) — (a) Refresh에서 current null 가드 — selectedInstanceId가 stale(곤충 삭제/강화 후 변경)이면 GetByInstanceId null 반환 → line 75 current.insectId NRE. (b) OnEnable/AutoWire 이중 구독 차단 — subscribed 플래그 + Subscribe/Unsubscribe 헬퍼. 옛은 AutoWire 먼저 → 구독 → OnEnable → 또 구독 시나리오에서 HandleInsectUpdated 중복 발화, Refresh 2회 호출. (c) AutoWire가 isActiveAndEnabled 시에만 Subscribe — OnDisable 동안 구독 잔존 차단. 호출자 곤충 변경 시 자동 갱신 정상. +- [x] ShopUIController (P1:1, 2026-05-21) — Start line 65 itemDatabase null 가드 추가 — EnsureDatabase의 Resources.Load("ItemDatabase") 실패 시 itemDatabase는 여전히 null → FindById NRE. UpdatePriceLabels(line 188-205) dead code (호출처 없음, allowCoinPayment [SerializeField]라 런타임 불변)이나 제거 risky로 P2 보류. 토글 무한 루프는 SetCoinPayment/SetGemPayment의 `if (!enabled) return;` first guard로 거짓양성. CashShopManager.Instance race는 단일 스레드로 거짓양성, wallet 동기화는 이전 CashShopManager audit에서 단일 소스 정리 완료. +- [x] GachaBoxUI → CashShopUI 가챠 영역 (P0:11, 2026-05-21) — GachaBoxUI 클래스는 존재하지 않음(가챠 코드가 CashShopUI에 통합). DrawGachaTab/DrawBoxCard/DrawGachaResultScreen 매 OnGUI new GUIStyle 11개 + new Color 5개 회귀. InitGachaStyles + 12개 캐시 필드 + 5개 static readonly Color (BoxBronzeCol/BoxSilverCol/BoxGoldCol/GachaFlashBlue/GachaFlashGold/GachaPriceAffordCol/GachaRateGrayCol/GachaCandyPinkCol). boxPriceStyle/gachaTitleStyle은 textColor만 동적 갱신(BattleScreenUI 패턴). Phase 2의 new Color(alpha 동적)와 new Rect는 struct stack 할당으로 거짓양성. **잔여 작업**: CashShopUI 보석 충전(Tab 0)/아이템 상점(Tab 1) 영역도 동일 회귀 가능성, 별도 라운드 권고. +- [x] ModalUIRegistry (clean, 2026-05-21) — 진짜 회귀 없음. IModalUI 구현체 7개(BattleTeamUI/CaptureChoiceUI/CharacterOutfitUI/CashShopUI/CharacterViewerUI/CollectionUI/DexScreenUI) 모두 OnDisable에서 Unregister 호출 일관. HandleEscape는 PlayerMovement:127 단일 진입점. Register LRU 패턴 정상(Remove + Add), IsAnyOpen/TopModal null/IsOpen 자동 정리, Unregister null 가드 있음. static state scene 잔존은 IsOpen 체크로 회복(P2 수용), race는 Unity 단일 스레드로 불가, TopModal 역순 반복은 stack 변경 안전. Covered 이동만. +- [x] CashShopUI 보석 충전/아이템 상점 영역 (P0:16, 2026-05-21) — Tab 0/Tab 1 + OnGUI 본문 new GUIStyle 16개 + new Color 다수 → InitMainStyles + 16 캐시 필드 + 14 static readonly Color (Backdrop/Panel/CharArea/ResTitle/CoinGold/BonusGreen×2/GemLabel/BuyButton/GemGlow/Border/GradTop/GradBot/Highlight + ItemBuyBlue). resStyle/itemPriceStyle textColor 동적 갱신 패턴(BattleScreenUI 동일). Phase 2 new Color(alpha 동적)와 new Rect(좌표 동적, struct stack)는 거짓양성으로 수용. OnGUI 본문 잔존 new GUIStyle = 0. +- [x] UIHelper (P0:1, 2026-05-21) — GetCachedTex 캐시 가득(256개) 시 texCache.Clear()만 하고 styleCache 미invalidate 회귀. styleCache의 GUIStyle.normal.background이 Destroy된 텍스처를 참조 → 다음 렌더링에서 MissingReferenceException 또는 invisible 버튼. GetButtonStyle(line 189)이 정확히 그 패턴(`style.normal.background = GetCachedTex(bgColor)`). GetCachedTex 캐시 클리어 분기에 styleCache.Clear() 동반 호출 추가. new Color/Rect는 struct stack 거짓양성, Color32 정밀도 손실은 시각적 무해, 동시 호출 race는 Unity 단일 스레드로 거짓양성. +- [x] AudioManager (P1:1, 2026-05-21) — StopBGM이 crossfadeCoroutine만 중단하고 deferredBgmCoroutine 미중단 회귀. PlayBGM(X) → deferred yield 대기 → 즉시 StopBGM 호출 시 fade out 시작 그 후 deferred가 다음 프레임에 CrossfadeBGM(X) 시작 → StopBGM 의도 깨짐 + 페이드 충돌. StopBGM에 deferredBgmCoroutine 중단 추가. FindFirstObjectByType(GameClock) 1초 폴링은 캐시 후 안 호출(P2 수용), ApplyVolumes fade 중 덮어쓰기는 사용자 설정 변경 빈도 낮음(P2 수용), gameClock stale은 Unity == null overload로 자동 재캐시(거짓양성), 다중 PlayOneShot은 AudioSource 특성상 동시 재생(거짓양성). +- [x] ProceduralAudioGenerator (clean, 2026-05-21) — 1957줄 모놀리스이나 구조 단순. public 진입점 3개(GetBGM/GetSFX/GetAmbient) + 단일 Dictionary 캐시 + 나머지는 PCM 생성 알고리즘(audit 범위 외, 캐시 hit 시 호출 안 됨). 모든 진입점이 TryGetValue → switch → cache 저장 패턴 일관, default LogWarning + null 반환, 호출자(AudioManager) null 처리 OK. Unknown key LogWarning spam은 정상 흐름에서 발생 안 함(P2), 캐시 무제한 증가는 총 ~64개로 안정 상태 도달 후 변동 없음(P2), Unity 단일 스레드로 동시 호출 race 거짓양성. Covered 이동만. +- [x] InsectEntity 외부 로직 (P0:1, P1:3, 2026-05-21) — BuildModel은 visual-dev 영역으로 제외. (a) P0: Update의 Camera.main 매 프레임 호출 → static cachedMainCam lazy 캐싱. 최대 20마리×매 프레임 FindGameObjectWithTag 핫패스 회피. (b) P1: Despawn 다중 호출 가드(despawnedThisCycle 플래그) — Battle/Capture 동시 Despawn 시 풀 중복 반환 + onDespawn 두 번 발화 차단. (c) P1: Initialize/BuildForBattle에 cachedNameLabel/cachedShinySparkle/despawnedThisCycle 리셋 추가 — 풀 재사용 시 stale Transform 참조 회피. (d) P1: cachedMainCam과 일괄 처리로 NameLabel 미존재 인스턴스 매 프레임 Find 절감. new Vector3/Color는 struct stack 거짓양성, BuildModel/AnimateWings/AnimateShinySparkle 내부 알고리즘은 visual-dev 영역. +- [x] CameraFollower (P1:2, 2026-05-21) — (a) SetTarget에 baselineValid=false 리셋 추가 — SubArea 좌표 전환 시 옛 baselinePos 기준 Lerp로 첫 프레임 카메라 점프 회귀. (b) EnterBattleMode의 battleTransition=0 무조건 리셋 제거 — fade-out 진행 중 재진입 시 transition 보존으로 시각적 끊김 차단(배틀→이탈→배틀 빠른 전환). new Vector3 다수는 struct stack 거짓양성, Shake 다중 호출 timer 리셋은 의도된 동작. +- [x] InsectDatabase (P1:1, 2026-05-21) — GetWeightedRandom/GetWeightedRandomWithRareBoost 4곳 foreach에 `if (data == null) continue;` 가드 추가. GetCandidates는 null 필터링하나 외부에서 직접 candidates 전달(테스트/디버그 코드) 시 data.spawnWeight NRE 위험. insects 중복 insectId/null/빈 ID 검증은 ScriptableObject Editor 시점 보장(P2), Weight 두 번 계산은 결과에 영향 없음(P2), 부동소수점 누적 오차는 일반 범위에서 무해 + fallback 있음, new List 매 호출은 InsectSpawner 8초 burst 없음. +- [x] OutfitSetData (clean, 2026-05-21) — 진짜 P0/P1 회귀 없음. GetAllSets()는 OutfitBonusProvider.AutoWire에서 1회만 호출되어 allSets 필드에 캐시(매 프레임 호출 거짓양성). 12개 세트 정적 데이터 정합성 시각 review 결과 정상: requiredItemIds 중복/null 없음, partialThreshold ≤ requiredItemIds.Length 모두 만족(set_wizard threshold=2 items=4), partial/full Bonus 모두 정의, setColor 시각용 OK. GetAllSets static readonly 필드화는 변경 risky(P2 보류). Editor 시점 검증 도구는 별도 디자인 작업. Covered 이동만. +- [x] PlayerItemInventoryGridUIController (P1:1, 2026-05-21) — HandleActiveChanged에서 UpdateRemainingTime() 즉시 호출 + remainingTextTimer=0 리셋 추가. 옛은 activeItemText만 갱신하고 남은 시간 표시는 Update의 1초 디바운스 대기 → 만료 시 "남은 시간: 00:00" 표시 최대 1초 지연. 주석(line 56)이 "만료 시점은 ActiveItemChanged 즉시 처리" 명시했으나 실제 호출 누락 회귀. OnEnable/AutoWire 이중 구독은 -=/+= 패턴으로 안전(거짓양성), BuildGrid Destroy+Instantiate 1프레임 중복은 정상 Unity 동작, EnsureDatabase 매 진입은 null 가드로 1회만 Load. + +## Round Log + +- 2026-05-20: 누적 15개 시스템 처리. P0:5, P1:16, P2:1, 총 ~22건 수정. 회귀 자체 발견 6건(매번 즉시 수정). +- 2026-05-20: RaidBattleUI — P0:2 (EndRaid 타이머/캐시 누수), P2:1 (Find Player 캐싱). 거짓양성 1건 (DrawUniteGaugeBar 게이지 정합성 — CanUniteAttack 내부에 AliveCount 검사 이미 포함). +- 2026-05-20: BattleScreenUI 나머지 — P1:2 (DrawSwapSelect new Color 매 프레임 → SwapHeaderBase static, DisableCanvasBattleUI FindFirstObjectByType 캐싱), P2:1 (CheckGuardianDefeat FindFirstObjectByType 캐싱). Explore P0 분류는 호출 빈도 1회/배틀이라 P1로 재분류. +- 2026-05-20: RaidBattleController — P1:1 (UseUniteAttack 데미지 표시 정합성). 거짓양성 3건: 라인 157 UniteGauge AOE 가드는 이미 `if (LastDamageToBoss > 0)` 있음, 라인 231 KO 자동전환은 정상, 라인 203 +18 고정값은 의도된 디자인. +- 2026-05-20: 곤충 Despawn 회귀 (사용자 명시 요청) — P0:4건. (a) InsectBattleController 패배 (b) InsectBattleController 도주 성공 (c) CaptureController 캡처 실패 50%→100% (d) RaidBattleController 레이드 패배. 곤충 숫자(maxActiveTotal=20)/레벨(메인 power=3.5, sub=2.0)/위치(60m 디스폰, 8초 재배치) 점검 — 모두 정상. +- 2026-05-20: F11 unstick 안 됨 → F9로 변경 (OS 인터셉트 회피) + frozen 상태 우회 + OnGUI Event 백업. 도구 mesh 회귀 (P1:1) — ApplyToolShape이 scale만 변경하고 mesh는 Cylinder 고정 → 어떤 도구든 막대기. 도구별 PrimitiveType (Cube/Cylinder/Sphere) 교체 + sharedMesh 캐싱. +- 2026-05-20: CaptureFeedbackController — P0/P1 회귀 없음. 이벤트 구독/해제 짝 정상, null 가드 모두 있음, OnGUI 미사용(TextMeshPro). Covered로 이동만. +- 2026-05-20: 튜토리얼 진행 회귀 (사용자 명시 요청) — P0:4건. q_equip/q_item/q_training/q_team이 Notify 호출 누락으로 영구 정지 → 후속 13개 퀘스트 연쇄 정지. (a) BattleTeamManager.TeamChanged 구독 + OnTeamChanged 핸들러 (b) ItemEffectManager.ActivateItem 단일 진입점 NotifyItemUsed (c) TrainingManager.TrainSkill 성공 NotifyTraining (d) TrainingUI 사용자 EquipSkill 후 NotifySkillEquipped. 정상 동작 9개 (Movement/Capture/BattleWon/GuardianDefeated/DexOpened/CollectionOpened/LevelUp/RaidCompleted/Region) 확인. +- 2026-05-20: BattleArenaController — P1:1 (OnDisable ↔ CleanupArena 중복 → 위임으로 단일화). 거짓양성 2건 (Rect struct 매 프레임 new Rect는 stack 할당이라 GC 없음, SetupNormalBattle 첫 줄에서 CleanupArena 호출되므로 rendererCache 이미 Clear됨). P2:1 (effectText shadow new Color 매 프레임 — struct로 영향 작음). +- 2026-05-20: DexController — P0/P1 회귀 없음. Explore 4건 모두 거짓양성 또는 범위 외. SaveAndNotify는 RegisterEncounter/Capture 끝에 무조건 호출. encounter/capture 별개 dictionary로 race 없음. Covered 이동만. DexListUIController는 별도 audit 대상으로 Uncovered 추가. +- 2026-05-20: DexDetailUIController — clean. Canvas 기반 UI라 OnGUI new GUIStyle 회귀 없음, 이벤트 구독 없음, null 가드 견고. Explore가 부수적으로 DexScreenUI의 OnGUI new GUIStyle 다수 P0 발견 → Uncovered 1순위로 분리. +- 2026-05-20: DexScreenUI — P0:31 (매 OnGUI new GUIStyle 31개 + Color 다수). InitDexStyles + 캐시 필드 31개 + 정적 Color 38개. OnGUI 본문 잔존 new GUIStyle 0. 매 프레임 GC 객체 105~150 → 0. 단일 파일 819 → ~1080줄로 증가하나 모놀리스 아님. +- 2026-05-20: SubArea UI + 도구 미리보기 정합성 (사용자 명시 요청). (a) SubAreaWorldBuilder — 진입/퇴장 토스트 알림 (3초 페이드, NotifyEnter/Exit 색), F2 수동 Exit 단축키 (Input + OnGUI Event 백업), 우측 하단 안내 + RegionManager.ForceExitSubArea(). (b) CharacterPortraitRenderer — DrawCharacterTool 9종 분기 신규 (옛은 magnify만), 필드/카드/캐릭터 미리보기 3곳 분기 순서 1:1 정합. +- 2026-05-20: SubArea 바닥 빠짐 P0:8 (사용자 명시 요청). 9개 환경 boundary wall halfSize를 floor 안쪽으로 조정 (Cave 16→14, DeepForest 18→16, Underwater 16→14, FogSwamp 16→14, MountainPeak 14→11, Temple 14→13, Reeds 14→11, GenericArea 12→9), BuildGreenhouse CreateGlassWall NoCollider 제거(유리 벽 collider 보존), Y 안전망(Y<-3 → 자동 RequestExit + 알림 + cachedPlayerTransform lazy 캐싱). 첫 위치 끼임 재검증: FindSafeSpawnPosition spiral radius 7m가 새 벽(halfSize 9~16) 안에 안전하게 위치 — 회귀 없음. +- 2026-05-20: DexListUIController — clean. Explore P0/P1 거짓양성 (SortList null 가드는 BuildList early return으로 보장, AutoWire 이벤트 짝 정상). Covered 이동만. +- 2026-05-20: 인증/로그인 흐름 (사용자 명시 요청) — P1:2. (a) AuthManager.ValidateCredentials 헬퍼 — 빈 이메일/짧은 비밀번호 Firebase API 호출 전 차단, AuthFailed + LoginCompleted/RegisterCompleted false 이중 보고. (b) LoginUI OnGUI 17개 GUIStyle 변형 → base.fontSize 동적 갱신, OnGUI 본문 new GUIStyle 0. 인증 흐름 전반 검증 (TryAutoLogin race 없음, idTokenAcquiredAt 모든 경로, AuthFailed→Login 복귀, 회원가입→CharacterCreate) 정상 확인. +- 2026-05-20: InsectSpawner — P1:2 자체 발견 (Explore P0:2 + P1:1 모두 거짓양성). OnGUI debugStyleCache lazy 캐싱(매 프레임 new GUIStyle), GetPlayerTransform lazy 캐싱(매 호출 FindWithTag+Find). CleanupDeadEntities 풀 회수 누락 거짓양성은 직전 라운드와 동일 (외부 SetActive(false) 직접 호출 없음 grep 검증). +- 2026-05-20: RegionManager — P1:3. IsRegionAccessible 마스터 우회 (AuthManager.IsMasterAccount race 차단), DefeatGuardian 중복 가드 + SaveUnlockState 비용 차단, LoadUnlockState unlockedRegions Split RemoveEmptyEntries (defeatedGuardians만 있던 옵션을 일관 적용). P2 Time.time vs realtimeSinceStartup은 수용. +- 2026-05-21: PlayerInsectCollection — P1:3 자체 발견. (a) Awake instanceId 중복 가드: 옛 세이브에 같은 GUID 인스턴스 2개 이상 있을 시 옛은 dictionary가 1개만 보존 → BattleTeam.GetByInstanceId가 나머지를 못 찾고 슬롯 표시 빈 칸. 충돌 감지 시 새 GUID 재발급 + needsSave 트리거. (b) GainXp(data, insect, amount) insect null 가드: 형제 GainXp(InsectData, int) 오버로드는 line 169에 null 가드 있으나 line 185는 insect.levelCurve 직접 접근 — NRE. 현재 호출자 0건이나 방어 코드 유지. (c) GetAllOwned null sanitize: saveData.insects에 null 항목 있을 시 호출자 6곳 NRE 위험 — foreach + null skip. 슬롯 인덱스 race는 Unity 단일 스레드 + BattleTeamManager.SetSlot 중복 가드(line 59-63) + ResolveLegacyOrInstanceId 정규화로 거짓양성. 가챠 10연 디바운스 저장은 의도된 디자인(OnApplicationQuit/Pause flush 있음). +- 2026-05-21: PlayerProgressController — P1:3. (a) Awake sanitize (level 1~maxLevel, currentXp ≥ 0 클램프 + dirty Save) — 손상 세이브 음수 level/xp 진입 차단. (b) 만렙 도달 시 currentXp = 0 클램프 — UI Lv 50 / XP N>0 불일치 차단. (c) 만렙 후 GainXp early return — 잉여 누적 + 무의미한 Save/SaveCloud 차단. 디스크 디바운스는 호출 빈도 낮음(배틀/캡처/튜토리얼만 burst 없음)으로 P2 수용. CloudSaveManager 가드는 SaveToCloud 내부 3중 가드로 거짓양성. +- 2026-05-21: PlayerProgressSaveService — P1:1. Save(null) 가드 추가. static service라 임의 호출자가 진입 가능, JsonUtility.ToJson(null)이 빈 문자열 → 빈 파일 → 다음 Load 데이터 손실. atomic write 부재는 다른 SaveService 모두 동일 패턴이라 글로벌 라운드로 이관 (P2 보류). +- 2026-05-21: PlayerCandyInventory — P1:2. (a) AddCandy/SpendCandy data null 가드 (Candies 프로퍼티는 가드 있으나 변경 메서드 비대칭). (b) Save(null) 가드 — PlayerProgressSaveService와 일관성. 호출자 6곳 외부 가드 OK. +- 2026-05-21: PlayerCurrencyWallet — P1:5. AddGems/SpendGems/AddCoins/SpendCoins 4곳 + Save() data null 가드. Gems/Coins 프로퍼티(line 18-19)와 비대칭 회귀 복구. Save 안의 CurrencyChanged 발화도 null 전파 차단. CashShopManager 단일 소스 패턴 정합 유지. +- 2026-05-21: PlayerItemInventory — P1:3. (a) Awake sanitize: null record / 빈 itemId / 중복 itemId 합치기 — 옛은 중복 시 lookup이 마지막만 보존해 AddItem 변경이 stale로 동기화 깨짐 (드물지만 손상 세이브/옛 마이그레이션 시 가능). (b) GetSnapshot 얕은 복사 — DexScreenUI:659 외부 호출자 race 차단. (c) Save(null) 가드 — 다른 SaveService와 일관성. +- 2026-05-21: PlayerInsectLevelUpUIController — P1:3. (a) Refresh current null 가드 — selectedInstanceId stale 시 NRE. (b) OnEnable/AutoWire 이중 구독 차단 (subscribed 플래그 + Subscribe/Unsubscribe 헬퍼). (c) AutoWire isActiveAndEnabled 가드 — OnDisable 동안 구독 잔존 차단. +- 2026-05-21: ShopUIController — P1:1. Start line 65 itemDatabase null 가드 — Resources.Load 실패 시 FindById NRE. UpdatePriceLabels dead code(allowCoinPayment 런타임 불변)는 P2 보류. 토글 무한 루프, 인덱스 OOB, CashShop race 모두 거짓양성. +- 2026-05-21: GachaBoxUI → CashShopUI 가챠 영역 — P0:11. GachaBoxUI 클래스 자체 미존재(CashShopUI 통합). DrawGachaTab+DrawBoxCard+DrawGachaResultScreen new GUIStyle 11개+new Color 5개 → InitGachaStyles + 12 캐시 + 5 static readonly Color. boxPriceStyle/gachaTitleStyle textColor 동적 갱신. Phase 2 new Color/Rect는 struct stack로 거짓양성. CashShopUI Tab 0/Tab 1 영역은 별도 Uncovered 추가. +- 2026-05-21: ModalUIRegistry — clean. 진짜 회귀 없음. IModalUI 구현체 7개 OnDisable Unregister 일관, HandleEscape PlayerMovement:127 단일 진입점, Register LRU/IsAnyOpen 자동 정리/Unregister null 가드 모두 정상. Covered 이동만. +- 2026-05-21: CashShopUI 보석 충전/아이템 상점 영역 — P0:16. Tab 0/Tab 1 + OnGUI 본문 new GUIStyle 16개 + new Color 다수. InitMainStyles + 16 캐시 필드 + 14 static readonly Color. resStyle/itemPriceStyle textColor 동적 갱신. Phase 2 new Color(alpha 동적)와 new Rect(좌표 동적, struct stack)는 거짓양성. OnGUI 본문 잔존 new GUIStyle = 0. 같은 라운드에 가챠 영역(P0:11) + Tab 0/Tab 1(P0:16) 총 P0:27 처리. +- 2026-05-21: UIHelper — P0:1. GetCachedTex 캐시 256개 도달 시 texCache.Clear() 후 styleCache.Clear() 동반 호출 추가. 옛은 GUIStyle.normal.background이 Destroy된 텍스처 stale 참조 → MissingReferenceException 또는 invisible 버튼. GetButtonStyle이 정확히 그 패턴. new Color/Rect struct는 stack로 거짓양성. +- 2026-05-21: AudioManager — P1:1. StopBGM에 deferredBgmCoroutine 중단 추가. 옛은 PlayBGM → 즉시 StopBGM 시 deferred coroutine이 다음 프레임에 CrossfadeBGM 시작 → StopBGM 의도 깨짐 + 페이드 충돌. FindFirstObjectByType 1초 폴링 / ApplyVolumes fade 덮어쓰기 / gameClock stale / 다중 PlayOneShot 모두 거짓양성 또는 P2 수용. +- 2026-05-21: ProceduralAudioGenerator — clean. 1957줄 모놀리스이나 GetBGM/SFX/Ambient + 단일 Dictionary 캐시 패턴 일관, generator 본문은 캐시 hit 시 호출 안 되어 audit 범위 외. Covered 이동만. +- 2026-05-21: InsectEntity 외부 로직 — P0:1 + P1:3. (a) Camera.main 매 프레임 → static cachedMainCam (최대 20마리 핫패스). (b) Despawn 다중 호출 가드 (Battle/Capture 동시 호출 시 풀 중복 반환 + onDespawn 이중 발화 차단). (c) Initialize/BuildForBattle cache 리셋 (풀 재사용 stale Transform 회피). (d) cachedMainCam과 NameLabel Find 일괄 처리. BuildModel/AnimateWings/AnimateShinySparkle 내부는 visual-dev 영역. +- 2026-05-21: CameraFollower — P1:2. (a) SetTarget baselineValid=false 리셋 — SubArea 전환 시 첫 프레임 점프 차단. (b) EnterBattleMode 무조건 transition=0 리셋 제거 — fade-out 중 재진입 시각적 끊김 차단. +- 2026-05-21: InsectDatabase — P1:1. GetWeightedRandom/GetWeightedRandomWithRareBoost 4곳 foreach data null 가드. 외부 직접 candidates 전달 시 spawnWeight NRE 차단. 중복 ID 검증/weight 재계산/부동소수점 오차 모두 거짓양성 또는 P2 수용. +- 2026-05-21: OutfitSetData — clean. GetAllSets는 AutoWire 1회 호출 후 캐시. 12 세트 정적 데이터 정합성 정상. Covered 이동만. +- 2026-05-21: PlayerItemInventoryGridUIController — P1:1. HandleActiveChanged에 UpdateRemainingTime + remainingTextTimer 리셋. 옛은 만료/시작 시점 "남은 시간: 00:00" 표시 1초 지연. 주석이 즉시 처리 명시했으나 실제 호출 누락 회귀. +- 2026-05-21: SaveService 글로벌 atomic write — P1:8 + 신규 38줄. AtomicFileWriter 헬퍼(.tmp 쓰기 + File.Replace OS atomic) 신설 + 8곳 File.WriteAllText 일괄 교체(PlayerProgress/Candy/Currency/Item/InsectCollection/BattleTeam/Dex Save + CloudSaveManager.SaveLocalFile). 게임 종료 직전 크래시 시 부분 쓰기 손상 차단. CashShopManager는 PlayerPrefs라 제외. 누적 SaveService 일관성 100%. +- 2026-05-21: CollectionUI 핫스팟 — P0:1 + P1:3. DrawInsectItem 5 GUIStyle + 4 Color 캐시(InitItemStyles + 6 static readonly), GetAllOwned 캐싱(cachedOwned + InsectUpdated invalidate), OnEnable/OnDisable/AutoWire 이벤트 짝 + AutoWire isActiveAndEnabled 가드, DrawCenteredLabel new Color 2곳 static. 잔여 DrawPanel/Detail/Stats/LevelUp/StatBar는 별도 라운드(Uncovered 추가). +- 2026-05-21: TrainingUI 핫스팟 — P0:1 + P1:3. DrawInsectSelect loop 4 GUIStyle + 4 Color 캐시(InitInsectSelectStyles + 4 static readonly), GetAllOwned 캐싱(CollectionUI 동일 패턴), OnEnable/OnDisable/AutoWire 이벤트 짝. 잔여 DrawPanel/MethodSelect/SkillLearn/SkillEquip/SkillReplace는 별도 라운드(Uncovered 추가). +- 2026-05-21: CollectionUI 잔여 영역 — P1:27. DrawPanel/DrawDetailPanel/DrawStats/DrawLevelUpSection/DrawStatBar/DrawCenteredLabel 27 new GUIStyle 캐시화. InitDetailStyles + 25 캐시 필드 + 25 static readonly Color. DrawStats GetCachedOwned 추가. OnGUI 본문 잔존 new GUIStyle = 0. CollectionUI 핫스팟+잔여 합산: P0:1 + P1:30 (전체 처리 완료). +- 2026-05-21: TrainingUI 잔여 영역 — P1:34. DrawPanel/Back/MethodSelect/SkillLearn/SkillEquip/SkillReplace/SkillCard/Feedback 34 new GUIStyle 캐시화. InitTrainDetailStyles + 33 캐시 필드 + 38 static readonly Color. 8 메서드 textColor 동적 갱신. OnGUI 본문 잔존 new GUIStyle = 0. TrainingUI 핫스팟+잔여 합산: P0:1 + P1:37 (전체 처리 완료). +- 2026-05-21: PlayerStatusHUD — P0:1 + P1:2. OnGUI 본문 new Color 16곳 → 16 static readonly Color. GetAllOwned 캐싱(cachedOwnedCount + InsectUpdated invalidate), OnEnable/OnDisable에 InsectUpdated 짝 추가(subscribedInsects 플래그). GUIStyle 캐시는 이미 InitStyles 패턴으로 기존 구현됨. shine/regionCol scaled/accent scaled/alert alpha 동적 new Color는 struct stack 거짓양성. +- 2026-05-21: TrainingManager — P1:1. TrainSkill의 SpendCandy를 LearnSkill/ReplaceSkill 성공 후로 이동 + 실패 early return. 옛은 ReplaceSkill 실패 시 candy 손실 위험. LearnSkill은 사전 가드로 실패 불가이나 일관성 차원에서 동일 패턴 적용. +- 2026-05-22: CashShopManager 잔여 — P1:3. PurchaseWithGems의 잔액 검사(line 119)와 환불 기준값(line 137) 모두 stale `gems` 캐시 → `Gems` 프로퍼티로 교체(wallet 단일 소스 일관성). AddGems(0) early return 추가로 무의미한 GemsChanged 발화 차단. +- 2026-05-27: PlayerMovement 모달 차단 + InputAction 흐름 — clean. Explore 8 책임 매트릭스 점검 결과 진짜 P0/P1 회귀 0건. 모달 차단(HandleEscape+IsAnyOpen 이중 방어), InputAction(Input.GetKey+OnGUI Event 백업 이중 경로), OutfitChanged 동기(2026-05-20 완료), CameraFollower 점프(2026-05-21 완료) 모두 견고. FindFirstObjectByType 0건, transform.Find 1회 캐싱 정상, outfitBonus null 가드 있음. Covered 이동만. **누적 Uncovered 0개 도달.** +- 2026-06-06: **모델 심화 잔여 + 치비 폴리시 2차**. **모델**: Butterfly(몸통 0.15,0.4→0.18,0.34 짧고통통·머리 0.25→0.3 확대·WingTip 0.12,0.18→0.18,0.24 둥글게·눈 z 0.4→0.45), Bee(MakeTopGloss 광택). 날개는 ApplyColor 반투명 경로의 _Glossiness 0.2로 자동 무광. **치비 폴리시(감사 통합목록 P1)**: ① 필드 HairTop 정수리 덮기(ShortHair y0.15→0.22 scale 0.52,0.3,0.5→0.62,0.34,0.60 / UpHair y0.2→0.26 scale→0.55,0.30,0.52) 두피 노출 해소 ② 포트레이트 모자 본체 높이 8s→headH*0.22·오프셋 headH*0.20(두 곳, 큰 머리 정수리 덮음) ③ 포트레이트 헤어 윗덮개 10~12s→headH*0.32~0.36·시작 headH*0.10(얇은 띠→볼륨) ④ 포트레이트 도구 손위치 handY 58s→36s(짧아진 팔 armH 34와 정합, 옛 손보다 24s 아래 허공) ⑤ 카메라 lookTarget 1.0→0.85(치비 가슴~목 중앙, 머리 위 여백↓; 시선벡터 (0,-0.8,0.6) 불변→도구 디스크 정합 유지). **잔여(저우선/위험)**: 필드 도구 스케일(머리높이라 비긴급), 포트레이트 안경렌즈/옆가닥/toolX/bag_science, 필드 헤어 옆·뒷가닥 y, 콜라이더/OverlapSphere 높이(자기가드 회귀 위험). 8항목 PASS. +- 2026-06-06: **곤충 3D 모델 심화** (설계서 `_dex_modelPolish.txt` 적용). **공용 헬퍼(전 34종 동시)**: ① dark 음영색 단순절반→HSV(채도 유지·명도만 ↓)로 색감 살림 ② MakeAntennae 직선 캡슐1개→2분절 굴절 S곡선 + feathered 깃털(나방/모기) ③ MakeLegs 발끝(tarsus) 6개 추가 + 앞·중·뒷다리 각도 변주(upAng 26+4i/loAng 8+3i)·z 부채꼴(기계적 동일각 해소) + 무릎 0.045→0.05 ④ MakeEyes xSpread 파라미터화(넓은 머리 종 대응, 기본 0.12 무변경) ⑤ MakeTopGloss 헬퍼 신설(등껍질 상단 반투명 광택). **대표 종**: GenericBeetle(가슴마디 prothorax 추가+머리 확대 0.4→0.46+직접눈→MakeEyes+TopGloss), HornBeetle(직접눈→MakeEyes xSpread 0.2), RhinocerosBeetle(뿔 3분절 곡선 HornCurve+Y자 분기 HornFork L/R+직접눈→MakeEyes 0.22). 직전 ApplyColor PBR·MakeEyes 하이라이트/글린트와 합쳐 무광 점토→광택 키틴+촉촉한 큰 눈. 신규 노드명(TopGloss/Foot/AntBase·Mid/HornCurve·Fork/Prothorax) WingL/R·NameLabel 예약어 비충돌 확인. MakePart/풀/forBattle 보존, 수치공식無→테스트 영향 없음. 8항목 PASS. **참고**: ApplyColor가 파트마다 new Material(기존 구조, 본 변경으로 종당 +~10파트) — 머티리얼 캐싱은 별도 리팩터(보고만). **잔여**: Butterfly/Bee 대표종 심화(공용헬퍼+MakeEyes 자동 수혜라 우선도 낮음). 실측 Unity 권장. +- 2026-06-06: **도감에 진짜 곤충 3D 표시 완료** (직전 미완 ③ 해결 — dex-shape 핵심). 신규 `InsectModelPreviewRenderer`(Dex): 플레이 영역 밖(Y-5000) 전용 레이어 30에 직교 카메라+전용 광원(cullingMask 30, 월드 이중조명 방지) 리그. `InsectEntity.BuildForBattle`로 34종 3D 모델 재사용 생성→RenderTexture 렌더→insectId별 캐싱. 렌더는 OnGUI 아닌 Update에서 프레임당 1개 lazy(GUI 중 Camera.Render 회피), 모델은 enabled=false로 정적 프레임, 렌더 후 Destroy, OnDestroy에서 RT Release. DexScreenUI.DrawDetail: 포획 곤충은 박스 자리에 3D 프리뷰(ScaleToFit, 알파합성), 미준비/미포획은 기존 DrawTinyInsect 폴백(미포획 실루엣 reveal 로직 보존). Bootstrap EnsureComponent+AutoWire 등록. 직전 PBR 광택+촉촉한 눈 개선이 프리뷰에도 반영. 레이어 30 미사용 확인. 8항목 검증 PASS(new Rect는 struct 거짓양성). **실측은 Unity 에디터 권장**(orthoSize 1.1/회전 150°/조명 미세조정 가능). +- 2026-06-06: **곤충 도감 품질 개선 (동적 워크플로 동시 진행)** — 사용자 "도감 곤충 모양·내용 퀄리티 저하". 진단: (1)도감(DexScreenUI)이 곤충 자리에 단색 박스(whiteTexture)만 그림, 스프라이트 0개, 정작 InsectEntity 34종 3D 빌더는 도감 미사용. (2)InsectLore.json 64종 설명이 건조한 1줄. **완료**: ① **콘텐츠 64/64 리라이트**(InsectLore.json) — 1줄→2~3문장 매력·정확·재미요소 한글(워크플로 병렬 53종 + 직접 11종, 원본ID 머지로 중복제거·누락방지). ② **3D 모델 셰이딩 개선**(InsectEntity): ApplyColor에 PBR(_Glossiness 0.55/_Metallic 0.15, 셰이더명 가드, 날개는 0.2 무광) → 전 34종 무광 점토→키틴 광택 동시 개선(최고 ROI). MakeEyes 하이라이트 0.2→0.28·동공 0.6→0.64·서브 글린트 2개 추가(치비 촉촉한 눈). **세션 한도로 미완**: ③ 도감에 진짜 곤충 표시(RenderTexture 프리뷰) 에이전트 실패 — **도감은 아직 색 박스, dex-shape 핵심 미해결**(다음 단계, 설계 `.claude/_dex_modelPolish.txt` 참조). ④ 모델 심화(다리 발끝/더듬이 곡선/뿔 굴절/대표종) 설계만. +- 2026-06-06: **치비 부작용 감사 적용 (1차)** — 4-에이전트 감사로 치비 리디자인 부작용 23건 도출(포트레이트 디테일 고정크기, 액세서리 허공, 도구 과대, 헤어). **적용(P0)**: 포트레이트 얼굴 부품(눈/동공/하이라이트/코/입/귀/눈썹/볼터치) 전부 headW/headH 비례 전환(옛 7×8 고정 콩알눈 → headW×0.30 큰 치비눈, 필드와 정합). 필드 액세서리 Y(안경 2.20→1.20·목걸이 1.88→1.00·배지 1.65→0.85) 허공 부유 수정+안경 확대. **잔여**: 필드 도구 과대 스케일, 헤어 정수리/옆가닥, 포트레이트 모자/헤어/도구 비례, 카메라 lookTarget, 콜라이더 등 ~20건(P1/P2, 통합목록 워크플로 산출). +- 2026-06-06: **필드 캐릭터 귀여운 치비 리디자인** (사용자 명시 "필드 캐릭터 너무 못생기고 매력없음 개선"). 방향: 사용자가 ~3.5등신 치비 선택. 원인 분석: 사실적 ~6.8등신을 단순 프리미티브로 표현 → 작은 머리+긴 박스 몸통+막대 팔다리+달걀형 머리+작은 눈 = 뻣뻣·비매력. 변경(PlayerVisualBuilder.BuildAll+BuildFace): (a) headPivotScale 0.42→0.60(머리 확대, 얼굴부품 자식이라 함께 스케일=배치 자동유지), (b) 머리구체 달걀형(−0.10,+0.12,−0.04)→둥글게(−0.02,−0.04,−0.04) **Z유지로 얼굴 z재배치 회피**, (c) 눈 0.11→0.15/0.17·동공/하이라이트 확대(큰 눈), 코 축소·입 하향·볼터치 확대, (d) 몸통 Y0.78→0.46 짧고통통, 팔다리 단축(arm 0.50→0.23, leg 0.62→0.20)+좁은스탠스, 목 최소화, head Y2.20→1.22 → ~3.5등신(총높이 ~2.38→~1.43). 연동: 손 Y0.95→0.52 → ApplyToolShape hx/hy + 도구 기본좌표 동기, 카메라 (0,12,-8)→(0,9,-6) 줌인(**Y:Z=1.5 비율유지로 시선벡터·도구 디스크 회전 -20° 보존**)+lookTarget 1.4→1.0, 포트레이트 CalculateProportions도 치비(head42/body40/leg40, ~3.3등신)로 창=필드 정합, OutfitUI 미리보기 2.4→2.9. 노드명 전부 보존(ApplyPartColor/AnimateWalk 무영향). **잔여**: CashShopUI 미리보기 scale(박스폭 미확인으로 미변경), 헤어 정수리 미세 노출 가능, 콜라이더 height 2 유지(치비보다 큼, 자기가드 회귀 회피). 실측은 Unity 에디터 권장. +- 2026-06-06: **도구 필드 vs 캐릭터창 불일치 + "막대기 뒤에 보임"** (P1:6). 사용자 보고 "캐릭터창과 필드 도구가 매칭 안 됨, 도구 끼면 막대기가 뒤에". 3-에이전트 적대검증(high). 원인: 필드 잠자리채 망(NetRing) 디스크가 rot(0,0,90)이라 법선이 ±X(옆) → 부감 카메라(시선 0,-0.8,0.6)에서 edge-on(테두리만)으로 collapse → 망 소멸, 손잡이 Cylinder만 막대기로 잔존. 창(DrawCharacterTool 2D)은 정면 사각 망을 또렷이 그려 불일치. lasso(rot 0,0,70)·magnify(rot 60→투영12%)도 동일 결함. 추가: 손 기준상수 hx/hy(0.34,0.91)가 실제 HandR(0.29,0.95)와 어긋나 도구 분리, PlayerVisualBuilder 기본 NetHandle/NetRing 좌표가 머리높이(2.28)·뒤(Z<0) stale 레거시. 수정(CharacterOutfitManager.ApplyToolShape + PlayerVisualBuilder.BuildAll): (a) hx/hy → (0.29,0.95) 손 정합, (b) net/lasso/magnify 디스크 rot → (-20,0,0)로 법선을 위-카메라쪽 정렬(워크플로 제안 rot(37) 부호오류를 기하 재검증으로 -20 보정 — 플레이어 전 방향에서 57~95% 가시, swing ±25° 더해도 edge-on 없음), (c) 빌더 기본 NetHandle/NetRing을 net 분기 최종값과 일치(레이스 잔재 제거+안전망). 필드를 source of truth로 고쳐 창과 자동 정합. **잔여(경미)**: gun/blaster/tranq 필드 정면뷰 vs 2D 측면 권총, shuriken Y축45° 수평, web_shooter 발사구 방향 — 별도 라운드 후보. net/golden/diamond 등급이 형태 미구별(색만 차이)도 디자인 개선 여지. +- 2026-06-06: **숲/유적/산 캐릭터 차폐 — 진짜 근본원인 최종 확정·수정** (P0:1). 사용자 재신고 "고대유적과 숲에서 캐릭터 위 레이어가 점령해 안 보임". 3-에이전트 적대검증(반증 실패, confidence high) 결과: 차폐 주체는 장식물이 아니라 **상승된 리전 지면 평면 그 자체**. WorldTerrainBuilder.ApplyElevation이 Region_{id} 평면을 forest Y=4.08 / ruins Y=8.08 / mountain Y=12.08로 끌어올리는데, PlayerMovement는 아래방향 2유닛 raycast로만 지면을 따라가 4~8유닛 단차를 못 올라 Y=0에 갇힘 → 불투명 collider 평면이 카메라(0,12,-8) 하향 시선과 플레이어 사이를 가림. CameraFollower.ResolveObstruction은 Region_/Ground/Floor를 차폐검사에서 제외해 보정 불가. **이 고도는 리전판정(ContainsPoint=XZ만)·스폰(player.y 추종)에 영향 0인 순수 장식**이라 확인 → 2026-05-27 이중합산/장식축소/카메라오프셋 등 6+ 라운드 시도가 모두 증상만 건드려 실패한 진짜 원인. 수정: WorldTerrainBuilder.GetRegionElevation의 forest/ruins/mountain을 0f로 평탄화(pond -3/swamp -2 보존), BuildElevationSlopes의 Slope_Meadow_Forest(0→0)·Slope_Mountain_Ruins(0→0) 허공 램프 잔재 정리. 평면·장식·플레이어·곤충 모두 Y=0 수렴. **남은 권고**: GetRegionElevation 회귀 테스트(private+MonoBehaviour라 InternalsVisibleTo 또는 ApplyElevation 통합 테스트 필요 — architect 상담), PlayerMovement.cs:253 Mathf.Max 상향전용 스냅+scenery collider 핀고정 2차 증상(경미, 별도 라운드). diff --git a/.claude/hooks/audit_flow_inject.py b/.claude/hooks/audit_flow_inject.py new file mode 100644 index 0000000..06fcbde --- /dev/null +++ b/.claude/hooks/audit_flow_inject.py @@ -0,0 +1,82 @@ +"""UserPromptSubmit hook: 사용자 메시지 직전에 audit 진척 컨텍스트를 자동 주입. + +audit 자동 플로우(.claude/rules/audit-flow.md)를 매 응답에 reminder로 주입한다. +사용자가 매번 `/audit` 입력하지 않아도 작업 완료 후 자동 audit이 실행되도록 컨텍스트 강화. + +조건: +- audit-progress.md 존재 + Uncovered ≥ 1 +- 사용자 메시지에 audit 거부 의사 없음 ("audit 안 해", "그만" 등) + +출력: hookSpecificOutput.additionalContext로 1줄 룰 reminder + 다음 영역 명시. +""" + +import sys, os, io, json, re + +try: + sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8', errors='replace') + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') +except Exception: + pass + +raw = sys.stdin.read() +try: + d = json.loads(raw) +except Exception: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +user_prompt = d.get("prompt", "") or d.get("user_message", "") or "" + +# 사용자가 audit 거부 의사 표명 — 자동 플로우 차단 +deny_patterns = [ + r"audit\s*안\s*해", r"audit\s*하지\s*마", r"audit\s*그만", r"audit\s*skip", + r"skip\s*audit", r"audit\s*건너", r"감사\s*안\s*해" +] +for pat in deny_patterns: + if re.search(pat, user_prompt, re.IGNORECASE): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# 진척 파일 확인 +progress_path = os.path.join(".claude", "audit-progress.md") +if not os.path.exists(progress_path): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +try: + with open(progress_path, "r", encoding="utf-8") as fh: + text = fh.read() +except Exception: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +m = re.search(r"##\s+Uncovered.*?\n(.*?)(?=\n##\s|\Z)", text, re.DOTALL) +if not m: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +section = m.group(1) +uncovered_count = len(re.findall(r"^\s*-\s*\[\s*\]\s+", section, re.MULTILINE)) +if uncovered_count == 0: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +first_match = re.search(r"^\s*-\s*\[\s*\]\s+(.+?)$", section, re.MULTILINE) +next_target = first_match.group(1).strip() if first_match else "?" +if len(next_target) > 70: + next_target = next_target[:67] + "..." + +# 룰 reminder — 작업 완료 후 자동 audit 권장 +ctx = ( + f"[AUDIT-FLOW] audit 자동 플로우 활성. Uncovered {uncovered_count}개 — 다음 대상: {next_target}\n" + f"규칙(.claude/rules/audit-flow.md): 사용자 요청 작업 완료 후 라운드 결과 보고 직후 " + f"audit skill 자동 실행. 같은 응답 안에서 1회만, P0/P1 자동 처리, P2는 보고 후 결정 위임. " + f"사용자가 \"audit 안 해\" 등 거부 의사 표명 시 차단." +) + +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": ctx + } +})) diff --git a/.claude/hooks/audit_reminder.py b/.claude/hooks/audit_reminder.py new file mode 100644 index 0000000..42ee17a --- /dev/null +++ b/.claude/hooks/audit_reminder.py @@ -0,0 +1,72 @@ +"""Stop hook: 응답 끝날 때 audit 미검토 영역 카운트를 알린다. + +사용자가 매번 "문제점 분석 및 개선해" 입력 부담 → Stop hook이 진척 상황 보여줌. + +조건: +- .claude/audit-progress.md 존재 +- Uncovered 섹션의 `- [ ]` 카운트 ≥ 1 +- 짧은 한 줄로 알림: "audit 미검토 N개 남음 — /audit으로 다음 1개 자동 처리" + +알림이 노이즈가 되지 않게: +- Uncovered = 0이면 침묵 +- 진척 파일이 없으면 침묵 +""" + +import sys, os, io, json, re + +try: + sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8', errors='replace') + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') +except Exception: + pass + +# Stop hook은 stdin으로 JSON을 받지만 본 hook은 출력만 결정하므로 입력은 무시. +try: + _ = sys.stdin.read() +except Exception: + pass + +# 진척 파일 경로 — settings.json이 프로젝트 루트에서 실행되므로 상대 경로. +progress_path = os.path.join(".claude", "audit-progress.md") + +if not os.path.exists(progress_path): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +try: + with open(progress_path, "r", encoding="utf-8") as fh: + text = fh.read() +except Exception: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# `## Uncovered` 섹션 시작 ~ 다음 `##` 또는 EOF +m = re.search(r"##\s+Uncovered.*?\n(.*?)(?=\n##\s|\Z)", text, re.DOTALL) +if not m: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +section = m.group(1) +# `- [ ] ` 패턴 카운트 (체크 안 된 항목) +uncovered_count = len(re.findall(r"^\s*-\s*\[\s*\]\s+", section, re.MULTILINE)) + +if uncovered_count == 0: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# 첫 번째 미검토 항목 추출 (다음 audit 대상 미리 보여줌) +first_match = re.search(r"^\s*-\s*\[\s*\]\s+(.+?)$", section, re.MULTILINE) +next_target = first_match.group(1).strip() if first_match else "?" +# 너무 긴 라인 자르기 +if len(next_target) > 60: + next_target = next_target[:57] + "..." + +msg = ( + f"audit 미검토 {uncovered_count}개 — 다음: {next_target}. /audit 자동 실행 권장." +) + +# Stop hook은 hookSpecificOutput.additionalContext 미지원 — systemMessage 사용 +print(json.dumps({ + "systemMessage": msg, + "suppressOutput": True +})) diff --git a/.claude/hooks/auto_verify_trigger.py b/.claude/hooks/auto_verify_trigger.py new file mode 100644 index 0000000..6350061 --- /dev/null +++ b/.claude/hooks/auto_verify_trigger.py @@ -0,0 +1,88 @@ +"""PostToolUse hook: 큰 변경 감지 시 verify 8항목 자동 점검 권유. + +memory rule (feedback_post_impl_verify)이 "구현 후 /verify 자동 점검"인데 트리거 메커니즘이 +없었다. 이 hook이 Edit/Write 변경 크기를 보고 임계값 초과 시 다음 응답에 점검 권유 컨텍스트를 +주입한다. 작은 fix는 노이즈 방지 위해 스킵.""" + +import sys, json, io, os + +try: + sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8', errors='replace') + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') +except Exception: + pass + +raw = sys.stdin.read() +try: + d = json.loads(raw) +except Exception: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +tool_name = d.get("tool_name", "") +tool_input = d.get("tool_input", {}) +file_path = tool_input.get("file_path", "") + +# .cs 파일만 대상 (게임 코드 변경만). .md/.json/.py 변경은 자체 검증 없음. +if not file_path or not file_path.endswith(".cs"): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# 변경 라인 수 계산 +trigger = False +reason = "" + +if tool_name == "Write": + # 신규 파일 또는 전체 덮어쓰기 — 무조건 권유 + content = tool_input.get("content", "") or "" + lines = content.count("\n") + if lines >= 20: + trigger = True + reason = f"신규/전체 작성 ({lines}줄)" +elif tool_name == "Edit": + old_s = tool_input.get("old_string", "") or "" + new_s = tool_input.get("new_string", "") or "" + # 추가/삭제된 총 라인 수의 근사치 + line_diff = abs(new_s.count("\n") - old_s.count("\n")) + max( + old_s.count("\n"), new_s.count("\n") + ) + if tool_input.get("replace_all"): + # replace_all은 영향 범위가 커서 임계값 낮춤 + threshold = 10 + else: + threshold = 50 + if line_diff >= threshold: + trigger = True + reason = f"{line_diff}줄 변경 (임계 {threshold})" + +if not trigger: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# 변경 파일 경로 short (가독성) +short = file_path +try: + short = os.path.relpath(file_path, "C:/Project/곤충게임").replace("\\", "/") +except Exception: + pass + +ctx = ( + f"AUTO-VERIFY: 큰 변경 감지 — {short} | {reason}\n" + "다음 8항목 중 변경에 해당하는 것만 점검 후 결과를 사용자에게 보고:\n" + "1. 이벤트 구독: OnEnable/AutoWire/OnDisable 짝, 람다 해제 누락\n" + "2. null 가드: FindFirstObjectByType/GetComponent/Singleton.Instance 즉시 사용\n" + "3. 성능: Update/OnGUI 안 new GUIStyle/배열, GameObject.Find 캐싱\n" + "4. Bootstrap: 새 MonoBehaviour 등록, AutoWire 누락\n" + "5. 세이브 호환: 새 필드 기본값, CloudSaveManager 동기화\n" + "6. 모놀리스: BattleScreenUI/RaidBattleUI/PlaySceneBootstrap 최소 변경 확인\n" + "7. 컨벤션: [SerializeField] private, InsectGame.* 네임스페이스\n" + "8. 데이터 매칭: ID contains 순서 (구체적 먼저), enum↔switch 1:1\n" + "거짓양성 보고 금지 — 직전 라운드 처리 영역 자동 제외." +) + +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": ctx + } +})) diff --git a/.claude/hooks/check_namespace.py b/.claude/hooks/check_namespace.py index 5b86c9f..1a6b880 100644 --- a/.claude/hooks/check_namespace.py +++ b/.claude/hooks/check_namespace.py @@ -1,13 +1,31 @@ """PostToolUse hook: C# 네임스페이스 검증""" -import sys, json, re +import sys, json, re, os, io + +# Windows cp949 stdin/stdout이 한글 경로를 mojibake로 만들어 Failed to verify 경고가 매번 +# 출력되는 문제 차단. claude harness가 JSON을 UTF-8로 보내므로 강제. +try: + sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8', errors='replace') + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') +except Exception: + pass raw = sys.stdin.read() -# Windows 역슬래시 경로를 슬래시로 치환하여 JSON 파싱 오류 방지 -raw = raw.replace("\\", "/") -d = json.loads(raw) +# 주의: raw.replace("\\", "/")는 JSON escape (\n, \uXXXX, \\) 모두 망가뜨려 라인 카운트/문자열 +# 파싱 오류 유발. json.loads는 표준 escape를 정상 처리하므로 치환 불필요. +try: + d = json.loads(raw) +except Exception: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + f = d.get("tool_response", {}).get("filePath", "") or d.get("tool_input", {}).get("file_path", "") -if not f.endswith(".cs"): +if not f or not f.endswith(".cs"): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# 파일 존재 검증: 경로가 mojibake로 깨졌으면 실제로 못 열리므로 침묵 (잘못된 경고 차단). +if not os.path.exists(f): print(json.dumps({"suppressOutput": True})) sys.exit(0) @@ -35,9 +53,5 @@ } })) except Exception as e: - print(json.dumps({ - "hookSpecificOutput": { - "hookEventName": "PostToolUse", - "additionalContext": f"WARNING: Failed to verify namespace in {f}: {e}" - } - })) + # 깨진 경로/권한 등 어떤 이유로든 실패하면 침묵 (mojibake 경고 노이즈 차단). + print(json.dumps({"suppressOutput": True})) diff --git a/.claude/hooks/quick_compile_check.py b/.claude/hooks/quick_compile_check.py new file mode 100644 index 0000000..d88dfe3 --- /dev/null +++ b/.claude/hooks/quick_compile_check.py @@ -0,0 +1,226 @@ +"""PostToolUse hook: 변경된 .cs 파일에 대한 regex 기반 빠른 컴파일 점검. + +Unity Editor 호출 없이 정적 패턴으로 흔한 컴파일 에러를 감지한다. 100% 정확하지 않지만 +빠른 피드백 제공. 직전 라운드 `subAreaRespawnTimer` 미선언 사용 같은 케이스를 잡는다. + +점검 항목: +1. 중괄호 매칭 (열림 vs 닫힘 카운트) +2. 미선언 식별자 사용 의심 (private/[SerializeField]/public 선언 없이 사용) +3. using 누락 (IEnumerator/List/StringBuilder 등 사용 시) + +의심이 명확하지 않으면 침묵. 노이즈 방지.""" + +import sys, json, re, os, io + +try: + sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8', errors='replace') + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') +except Exception: + pass + +raw = sys.stdin.read() +try: + d = json.loads(raw) +except Exception: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +tool_input = d.get("tool_input", {}) +file_path = ( + d.get("tool_response", {}).get("filePath", "") + or tool_input.get("file_path", "") +) + +if not file_path or not file_path.endswith(".cs"): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +if not os.path.exists(file_path): + # 깨진 경로/존재하지 않음 — 침묵 (mojibake 노이즈 방지) + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +warnings = [] + +try: + with open(file_path, "r", encoding="utf-8") as fh: + text = fh.read() +except Exception: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# 1. 중괄호 매칭 — 문자열/주석/char literal 제외 후 카운트 (false positive 차단) +cleaned = text +# // 한 줄 주석 제거 +cleaned = re.sub(r"//[^\n]*", "", cleaned) +# /* ... */ 블록 주석 제거 +cleaned = re.sub(r"/\*[\s\S]*?\*/", "", cleaned) +# char literal '{', '}' 제거 (verbatim, interpolated 포함은 아님) +cleaned = re.sub(r"'(?:\\.|[^'\\])'", "", cleaned) +# string literal "..." 제거 (이스케이프 처리) +cleaned = re.sub(r'"(?:\\.|[^"\\])*"', "", cleaned) +# verbatim string @"..." 제거 +cleaned = re.sub(r'@"(?:[^"]|"")*"', "", cleaned) + +opens = cleaned.count("{") +closes = cleaned.count("}") +diff = abs(opens - closes) +if diff > 2: + warnings.append( + f"중괄호 불균형: {{ {opens}개 vs }} {closes}개 (차이 {diff}, 주석/문자열 제외 후)" + ) + +# 2. 미선언 식별자 사용 의심 +# 클래스 본문 안의 필드 선언 패턴 수집 +declared = set() +# private/protected/public/internal + type + name 패턴 (배열, generic 포함) +field_re = re.compile( + r"^\s*(?:\[[^\]]+\]\s*)*" # attribute (e.g., [SerializeField]) + r"(?:public|private|protected|internal|static|readonly|const|" + r"new|virtual|override|abstract|sealed|partial|extern|unsafe|volatile|\s)+" + r"[\w<>\[\],\s\.\?]+?\s+" # type + r"(\w+)\s*[=;\(]", # name followed by =, ;, or ( (method) + re.MULTILINE +) +# 모든 매칭 — 메서드 이름도 포함되지만 무해 (사용 시 declared 안에 있으면 OK) +for m in field_re.finditer(text): + declared.add(m.group(1)) + +# 로컬 변수 선언도 포함 (var x, int y, string s 등) +local_re = re.compile( + r"\b(?:var|int|float|double|bool|string|long|short|byte|char|" + r"void|object|decimal|uint|ulong|ushort|sbyte|" + r"[A-Z]\w*(?:<[\w<>,\s\.\?]+>)?(?:\[\])?)\s+" + r"(\w+)\s*[=;)]" +) +for m in local_re.finditer(text): + declared.add(m.group(1)) + +# foreach 변수 +foreach_re = re.compile(r"\bforeach\s*\(\s*\S+\s+(\w+)\s+in\b") +for m in foreach_re.finditer(text): + declared.add(m.group(1)) + +# 파라미터 (대부분의 메서드 시그니처에서 잡힘) +param_re = re.compile(r"[,(]\s*(?:ref\s+|out\s+|in\s+|params\s+|this\s+)?\S+\s+(\w+)\s*[,)=]") +for m in param_re.finditer(text): + declared.add(m.group(1)) + +# C# 키워드/내장 식별자/매우 흔한 Unity API는 화이트리스트 (검증 면제) +WHITELIST = { + "true", "false", "null", "this", "base", "value", "var", + "Time", "Mathf", "Vector2", "Vector3", "Vector4", "Quaternion", "Color", + "GameObject", "Component", "Transform", "Camera", "Light", "Material", + "Debug", "Input", "Screen", "Application", "Resources", "Object", + "MonoBehaviour", "ScriptableObject", "PlayerPrefs", "Rect", "Random", + "Texture", "Texture2D", "TextureFormat", "Shader", "GUI", "GUIStyle", + "FontStyle", "TextAnchor", "Event", "EventType", "KeyCode", + "Coroutine", "WaitForSeconds", "WaitForEndOfFrame", "WaitUntil", + "Physics", "Collider", "Rigidbody", "MeshRenderer", "MeshFilter", + "AudioSource", "AudioClip", "Animator", "Animation", + "System", "UnityEngine", "Math", "Convert", "Activator", + "Mathf", "DateTime", "TimeSpan", "Guid", + "Encoding", "File", "Directory", "Path", "FileStream", "StreamReader", + "StreamWriter", "BinaryReader", "BinaryWriter", + # 흔한 namespace + "InsectGame", "Core", "Data", "Battle", "Capture", "Dex", "Spawning", "UI", +} + +# 식별자 사용 검사: 메서드 본문 안에서 increment/decrement 좌변 식별자 추출. +# `(?, IEnumerator, Dictionary 등 declared 패턴이 못 잡는 케이스 보호.) + name_count = len(re.findall(r"\b" + re.escape(name) + r"\b", text)) + if name_count >= 2: + continue + suspicious.append(name) + +if suspicious: + # 최대 3개만 보고 (노이즈 방지) + sample = sorted(suspicious)[:3] + warnings.append( + f"미선언 의심 식별자 사용: {', '.join(sample)} " + f"(증감 연산자 좌변인데 클래스 필드 선언이 없는 듯 — 컴파일 에러 가능성)" + ) + +# 3. using 누락 의심 +needs_using = { + "IEnumerator": "System.Collections", + "IEnumerable": "System.Collections", + "List<": "System.Collections.Generic", + "Dictionary<": "System.Collections.Generic", + "HashSet<": "System.Collections.Generic", + "StringBuilder": "System.Text", + "Encoding": "System.Text", + "Stopwatch": "System.Diagnostics", + "File.": "System.IO", + "Path.": "System.IO", + "Directory.": "System.IO", + "Action<": "System", + "Action ": "System", + "Func<": "System", + "Task<": "System.Threading.Tasks", + "UnityWebRequest": "UnityEngine.Networking", + "Image ": "UnityEngine.UI", + "Button ": "UnityEngine.UI", + "Text ": "UnityEngine.UI", +} +# using 선언 추출 +using_re = re.compile(r"^\s*using\s+([\w.]+)\s*;", re.MULTILINE) +usings = set(using_re.findall(text)) + +for token, ns in needs_using.items(): + if token in text and ns not in usings: + # System은 보통 빠져있어도 .NET implicit가 해결. 그러나 명시적 검증 위해 경고만 추가. + # 다만 token이 주석/문자열에만 있을 수도 → 짧은 단순 검사: 코드 내 식별자 패턴인지 + # 100% 정확 어려움. P2 수준이므로 단일 사용처로 false positive 줄임. + # 일단 확실한 케이스(IEnumerator, List<)만 보고 + if token in ("IEnumerator", "List<", "Dictionary<", "HashSet<"): + # using이 부분 매치 (e.g., System.Collections.Generic > System.Collections) 허용 + if any(u.startswith(ns) for u in usings): + continue + warnings.append(f"using 누락 의심: {token} 사용 — `using {ns};` 필요") + break # 한 번에 1개만 보고 + +if not warnings: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +short = file_path +try: + short = os.path.relpath(file_path, "C:/Project/곤충게임").replace("\\", "/") +except Exception: + pass + +ctx = ( + f"QUICK-COMPILE-CHECK: {short}\n" + + "\n".join(f" - {w}" for w in warnings) + + "\n(정적 점검이라 오탐 가능. Unity Editor 컴파일로 최종 확인 권장.)" +) + +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": ctx + } +})) diff --git a/.claude/hooks/scan_static_patterns.py b/.claude/hooks/scan_static_patterns.py new file mode 100644 index 0000000..e0c4937 --- /dev/null +++ b/.claude/hooks/scan_static_patterns.py @@ -0,0 +1,255 @@ +"""PostToolUse hook: 4가지 정적 회귀 패턴을 변경 영역에서 검출. + +직전 라운드들에서 반복 발견된 회귀: +1. OnGUI/Update 안 `new Color(...)` / `new Rect(...)` / `new GUIStyle` (매 프레임 할당) +2. OnGUI/Update 안 `FindFirstObjectByType()` / `GetComponent()` (캐싱 누락) +3. `EventName += Handler` 후 같은 클래스에 `EventName -= Handler` 짝 없음 +4. 4000줄+ 모놀리스의 편집 영역 — method 분해 후보 제안 + +quick_compile_check.py와 동일 패턴 (UTF-8, JSON I/O, false positive 가드). +""" + +import sys, json, re, os, io + +try: + sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8', errors='replace') + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') +except Exception: + pass + +raw = sys.stdin.read() +try: + d = json.loads(raw) +except Exception: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +tool_input = d.get("tool_input", {}) +file_path = ( + d.get("tool_response", {}).get("filePath", "") + or tool_input.get("file_path", "") +) + +if not file_path or not file_path.endswith(".cs"): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +if not os.path.exists(file_path): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +try: + with open(file_path, "r", encoding="utf-8") as fh: + text = fh.read() +except Exception: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# 변경 영역 (new_string)을 추출 — false positive 차단 (파일 다른 곳의 패턴은 보고 안 함) +changed_text = tool_input.get("new_string", "") or tool_input.get("content", "") + +warnings = [] + +# 주석/문자열 제거 헬퍼 (quick_compile_check와 동일) +def strip_comments_and_strings(s): + s = re.sub(r"//[^\n]*", "", s) + s = re.sub(r"/\*[\s\S]*?\*/", "", s) + s = re.sub(r"'(?:\\.|[^'\\])'", "", s) + s = re.sub(r'"(?:\\.|[^"\\])*"', "", s) + s = re.sub(r'@"(?:[^"]|"")*"', "", s) + return s + +# 메서드 본문 영역 추출 — `private void OnGUI()` ~ 매칭 닫는 `}` +def extract_method_body(text_cleaned, method_name_pattern): + """method_name_pattern: 예 r'\\bOnGUI\\b' — 클린된 텍스트에서 매칭. 본문 문자열 반환.""" + matches = [] + for m in re.finditer(method_name_pattern + r"\s*\([^)]*\)", text_cleaned): + start = m.end() + # 다음 { 찾기 + brace_start = text_cleaned.find("{", start) + if brace_start < 0: + continue + # 중첩 brace 매칭 + depth = 1 + i = brace_start + 1 + while i < len(text_cleaned) and depth > 0: + c = text_cleaned[i] + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + i += 1 + if depth == 0: + matches.append(text_cleaned[brace_start + 1: i - 1]) + return matches + +cleaned_full = strip_comments_and_strings(text) +cleaned_changed = strip_comments_and_strings(changed_text) + +# ── 1. OnGUI/Update 안 new Color/new Rect/new GUIStyle 매 프레임 할당 ── +# changed_text 안에 OnGUI 또는 Update 메서드가 있으면 그 본문에서 패턴 검색. +# 또는 changed_text 자체가 OnGUI 본문의 일부일 수도 있으니 전체 파일의 OnGUI/Update 본문도 검사. + +perf_patterns = [ + (r"\bnew\s+GUIStyle\s*\(", "new GUIStyle"), + (r"\bnew\s+Color\s*\(", "new Color"), + (r"\bnew\s+Rect\s*\(", "new Rect"), +] + +# changed_text가 OnGUI/Update 메서드 본문 안에 위치하는지 추정. +# 정확한 위치 매칭 어렵지만 changed_text 안에 OnGUI/Update 시그니처가 포함되면 +# 해당 method 본문이 새로 추가됐을 가능성 큼. +# 또는 changed_text가 method 시그니처 없이 본문 일부만 변경 → 전체 파일 method 본문에서 검사. + +frame_methods = ["OnGUI", "Update", "LateUpdate", "FixedUpdate"] + +# 전략: 전체 파일의 frame method 본문을 추출 후, 그 본문에 changed_text가 포함되거나 일부 겹치면 검사 +# 단순화: 전체 파일의 frame method 본문에서 패턴 검색하되, changed_text에도 같은 패턴이 있어야 보고 +perf_warnings_set = set() +for method in frame_methods: + bodies = extract_method_body(cleaned_full, r"\b" + method + r"\b") + for body in bodies: + for pat, label in perf_patterns: + if re.search(pat, body): + # changed_text에도 같은 패턴이 있는지 확인 (false positive 차단) + if re.search(pat, cleaned_changed): + perf_warnings_set.add((method, label)) + +for method, label in sorted(perf_warnings_set): + warnings.append( + f"{method}() 안 매 프레임 `{label}(...)` 할당 — 필드 캐싱 권장 " + f"(UIHelper.CachedStyle 또는 static readonly 패턴 참고)" + ) + +# ── 2. OnGUI/Update 안 FindFirstObjectByType/GetComponent ── +# GetComponent가 캐싱되지 않고 매 프레임 호출되면 비싼 작업. +# 단, GetComponent(...)는 Awake/Start에서 1회 호출이 정상 패턴. frame method 본문에서만 검사. + +lookup_patterns = [ + (r"\bFindFirstObjectByType\s*<", "FindFirstObjectByType"), + (r"\bFindObjectOfType\s*<", "FindObjectOfType"), + (r"\bGameObject\.Find\s*\(", "GameObject.Find"), + # GetComponent는 frame method에 진짜 캐싱 안 한 경우만 — 너무 많은 false positive 우려로 일단 제외 +] + +lookup_warnings_set = set() +for method in frame_methods: + bodies = extract_method_body(cleaned_full, r"\b" + method + r"\b") + for body in bodies: + for pat, label in lookup_patterns: + if re.search(pat, body): + if re.search(pat, cleaned_changed): + lookup_warnings_set.add((method, label)) + +for method, label in sorted(lookup_warnings_set): + warnings.append( + f"{method}() 안 `{label}(...)` — 캐싱 권장 (AutoWire 또는 Awake/Start 1회 호출 패턴)" + ) + +# ── 3. 이벤트 += 후 -= 짝 누락 ── +# changed_text 안에 `EventName += HandlerName` 발견 시 전체 파일에서 `EventName -= HandlerName` 검색. +# 람다 식 `EventName += () =>` 또는 `EventName += (x) =>`는 해제 불가 — 별도 경고. + +subscribe_re = re.compile( + r"(\w+(?:\.\w+)*)\s*\+=\s*(?:\([^)]*\)\s*=>|\w+(?:\.\w+)*)" +) +lambda_subscribe_re = re.compile( + r"(\w+(?:\.\w+)*)\s*\+=\s*\([^)]*\)\s*=>" +) +method_subscribe_re = re.compile( + r"(\w+(?:\.\w+)*)\s*\+=\s*(\w+(?:\.\w+)*)\s*;" +) + +# changed_text 안 + 패턴 +for m in method_subscribe_re.finditer(cleaned_changed): + event_name = m.group(1) + handler_name = m.group(2) + # 매우 흔한 false positive 화이트리스트 (단일 문자 변수) + if event_name in ("a", "b", "x", "y", "i", "j", "n"): + continue + # 좌변 first segment가 camelCase 시작이면 산술 누적으로 간주 (event는 PascalCase 컨벤션). + # 예: `totalDmg += actual;`, `count += 1;`은 이벤트 아님. + # `obj.EventName += ...`처럼 점 접근이면 마지막 segment가 PascalCase여야 이벤트. + last_segment = event_name.split(".")[-1] + if not last_segment[0].isupper(): + continue + # 우변 handler가 PascalCase 메서드 이름이어야 이벤트 핸들러 패턴 + last_handler = handler_name.split(".")[-1] + if not last_handler[0].isupper(): + continue + # 짝 -= 검색 (전체 파일) + unsub_pat = re.escape(event_name) + r"\s*-=\s*" + re.escape(handler_name) + if not re.search(unsub_pat, cleaned_full): + warnings.append( + f"이벤트 구독 `{event_name} += {handler_name}` 짝 `-=` 누락 — " + f"OnDisable/OnDestroy에서 해제 권장 (메모리 누수 + 죽은 ref 호출 방지)" + ) + break # 1건만 보고 + +# 람다 구독 (해제 불가) +for m in lambda_subscribe_re.finditer(cleaned_changed): + event_name = m.group(1) + if event_name in ("a", "b", "x", "y", "i", "j", "n"): + continue + warnings.append( + f"람다 이벤트 구독 `{event_name} += (...) => ...` — 해제 불가. " + f"메서드 참조로 변경 후 OnDisable에서 -=로 해제 권장" + ) + break + +# ── 4. 모놀리스 method 분해 제안 ── +# 4000줄+ 파일에서 편집된 method 본문이 200줄+이면 분해 후보로 제안. +line_count = text.count("\n") +if line_count > 4000: + # changed_text 안 method 시그니처 추출 후 본문 길이 확인 + method_sig_re = re.compile( + r"(?:public|private|protected|internal|static|\s)+(?:void|IEnumerator|[\w<>]+)\s+" + r"(\w+)\s*\([^)]*\)\s*\{" + ) + long_methods = [] + for m in method_sig_re.finditer(text): + method_name = m.group(1) + start = m.end() + depth = 1 + i = start + while i < len(text) and depth > 0: + c = text[i] + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + i += 1 + body_lines = text[start:i].count("\n") + if body_lines > 200 and method_name in changed_text: + long_methods.append((method_name, body_lines)) + + if long_methods: + sample = sorted(long_methods, key=lambda x: -x[1])[:2] + names = ", ".join(f"{n}({l}줄)" for n, l in sample) + warnings.append( + f"모놀리스 {line_count}줄 — 편집한 메서드 {names}은 별도 파일/클래스로 추출 후보. " + f"Phase enum 또는 기능별 grouping 검토" + ) + +if not warnings: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +short = file_path +try: + short = os.path.relpath(file_path, "C:/Project/곤충게임").replace("\\", "/") +except Exception: + pass + +ctx = ( + f"STATIC-SCAN: {short}\n" + + "\n".join(f" - {w}" for w in warnings) + + "\n(정적 정규식 점검 — 오탐 가능. 검토 후 진행.)" +) + +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": ctx + } +})) diff --git a/.claude/hooks/warn_monolith.py b/.claude/hooks/warn_monolith.py index f272aec..b0ffec9 100644 --- a/.claude/hooks/warn_monolith.py +++ b/.claude/hooks/warn_monolith.py @@ -1,10 +1,22 @@ -"""PreToolUse hook: 모놀리스 파일 수정 경고""" -import sys, json +"""PreToolUse hook: 모놀리스 파일 수정 경고 (변경 크기 조건부)""" +import sys, json, io + +try: + sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8', errors='replace') + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') +except Exception: + pass raw = sys.stdin.read() -raw = raw.replace("\\", "/") -d = json.loads(raw) -f = d.get("tool_input", {}).get("file_path", "") +try: + d = json.loads(raw) +except Exception: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +tool_name = d.get("tool_name", "") +tool_input = d.get("tool_input", {}) +f = tool_input.get("file_path", "") MONOLITHS = { "PlaySceneBootstrap.cs": "4987줄 Bootstrap 모놀리스", @@ -12,17 +24,43 @@ "RaidBattleUI.cs": "2875줄 레이드UI 모놀리스", } +target_name = None +target_desc = None for name, desc in MONOLITHS.items(): if f.endswith(name): - print(json.dumps({ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "additionalContext": ( - f"CAUTION: {name} ({desc}) 수정 중. " - "Phase별 영향 범위를 확인하고 최소한의 변경만 하세요." - ) - } - })) - sys.exit(0) - -print(json.dumps({"suppressOutput": True})) + target_name = name + target_desc = desc + break + +if not target_name: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# 변경 라인 수 계산: 작은 fix(<20줄)는 위험도 낮으므로 경고 스킵 +# Write tool: 전체 덮어쓰기라 항상 경고 +should_warn = True +if tool_name == "Edit": + old_s = tool_input.get("old_string", "") or "" + new_s = tool_input.get("new_string", "") or "" + line_diff = abs(new_s.count("\n") - old_s.count("\n")) + max( + old_s.count("\n"), new_s.count("\n") + ) + # replace_all 모드면 영향 더 크므로 임계값 낮춤 + if tool_input.get("replace_all"): + should_warn = line_diff >= 5 + else: + should_warn = line_diff >= 20 + +if not should_warn: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "additionalContext": ( + f"CAUTION: {target_name} ({target_desc}) 수정 중. " + "Phase별 영향 범위를 확인하고 최소한의 변경만 하세요." + ) + } +})) diff --git a/.claude/rules/agent-coordination.md b/.claude/rules/agent-coordination.md index ce61f93..7404a91 100644 --- a/.claude/rules/agent-coordination.md +++ b/.claude/rules/agent-coordination.md @@ -35,6 +35,26 @@ | | visual-dev | 색상값, 그라디언트 | | `RarityIconProvider.cs` | data-architect | 아이콘 매핑 데이터 | | | visual-dev | 아이콘 렌더링, 크기/위치 | +| `ItemData.cs` | data-architect | 필드 정의, SO 구조, 직렬화 | +| | game-designer | captureChanceBonus / expMultiplier / candyMultiplier 등 효과 매개변수 값 | +| `RegionData.cs` | data-architect | SO 필드 구조, 직렬화 | +| | game-designer | insectIds 풀, requiredLevel, guardianLevel 등 게임 디자인 수치 | +| `RegionManager.cs` | game-designer | GetNextRegionId/GetPreviousRegionId switch, 진행 로직, 가디언 격파 | +| | data-architect | unlockedRegions/defeatedGuardians PlayerPrefs 직렬화 | +| `RegionTerrainBuilder.cs` | visual-dev | BuildXxxTerrain() 프로시저럴 지형/오브젝트 디자인 | +| | game-designer | BuildAllRegions switch 분기 추가 (신규 리전 등록 트리거) | +| `PlayerVisualBuilder.cs` | visual-dev | BuildAll() 슬림 비례, BuildHair*, Accessory 노드 추가, Material/슈더 fallback | +| | data-architect | 8슬롯 ↔ 노드 매핑(Hat/Top/Outerwear 등), RefreshOutfitColors 흐름 | +| `SubAreaWorldBuilder.cs` | visual-dev | BuildCave/BuildDeepForest 등 프로시저럴 지형 + 조명 | +| | capture-dev | EnterSubArea/ExitSubArea/HideMainWorld 흐름, sticky 모드 연계, 25m 이탈 트리거 | +| `ModalUIRegistry.cs` | ui-dev | IModalUI 인터페이스, 스택 push/pop, HandleEscape 우선순위 | +| `RegionManager.cs` SubArea 처리 | capture-dev | subAreaSticky/쿨다운/RestoreLastSubArea 등 진입·이탈 동기화 | +| | game-designer | switch (region 진행 순서, 가디언) 분리 유지 | +| `CashShopManager.cs` | architect | gems 이중 관리 동기화(wallet AutoWire), pendingSave/즉시 클라우드 저장 트리거 | +| | game-designer | shopItems 가격/카테고리, 보석 패키지 구성 | +| `CharacterOutfitManager.cs` | data-architect | 8슬롯 enum, allOutfits 정의, LoadOwnership/SaveEquipment 직렬화 | +| | game-designer | unlockedByDefault/price/gemPrice/statBonus 게임 디자인 수치 | +| | visual-dev | ApplyToCharacter / ApplyPartColor / ApplyToolShape 좌표·색상 적용 | ## 충돌 방지 절차 diff --git a/.claude/rules/audit-flow.md b/.claude/rules/audit-flow.md new file mode 100644 index 0000000..a5eb24b --- /dev/null +++ b/.claude/rules/audit-flow.md @@ -0,0 +1,70 @@ +# Audit 자동 플로우 규칙 + +`.claude/audit-progress.md`의 Uncovered 영역을 매 작업 흐름에 자연스럽게 포함시키는 규칙. + +## 자동 트리거 조건 + +다음 모두 충족 시 사용자 작업이 끝나면 **자동으로 `/audit` 1회 실행**: + +1. 사용자 요청 작업이 완료됨 (코드 수정 / 기능 구현 / 버그 수정 등) +2. `.claude/audit-progress.md`의 Uncovered ≥ 1개 +3. 같은 응답 안에서 이미 `/audit`이 실행된 적 없음 (이중 실행 차단) +4. Auto Mode 활성 또는 사용자가 audit을 명시적으로 거부하지 않음 + +## 자동 실행 금지 조건 + +다음 중 하나라도 해당하면 자동 audit **금지**: + +1. 사용자가 다음 단어 포함: "audit 안 해", "그만", "건너뛰", "skip audit" +2. 사용자가 plan 모드에 있음 +3. 다음 사용자 메시지가 대기 중 (인터럽트된 작업) +4. 사용자가 명시적으로 다른 작업을 즉시 요청 ("다음", "계속" 등은 작업 신호이지 audit trigger 아님) +5. 직전 응답에서 audit 처리한 영역의 후속 검증 작업 중 + +## 실행 방식 + +1. 사용자 요청 작업 완료 후 라운드 결과 보고 +2. 라운드 결과 보고 직후 **자동으로 audit skill 실행** (별도 응답 아님, 같은 응답 연장) +3. audit이 처리한 영역을 Covered로 이동, Round Log 갱신 +4. Stop hook이 `audit_reminder.py`로 남은 Uncovered 카운트 알림 + +## 사용자 흐름 예 + +**Before** (audit 자동화 없음): +``` +사용자: "기능 X 구현해" +Claude: 구현 완료, 라운드 결과 보고 +사용자: "/audit" (수동 입력) +Claude: audit 처리 +사용자: "/audit" (또 수동 입력) +``` + +**After** (audit 자동 플로우): +``` +사용자: "기능 X 구현해" +Claude: 구현 완료, 라운드 결과 보고 + ↓ (자동 연장) + audit-progress.md Uncovered ≥ 1 확인 → /audit 자동 실행 + RaidBattleUI 처리 완료, 진척 갱신 + Stop hook: "audit 미검토 N-1개 남음" +``` + +사용자 입력 부담: 0회. 매 작업 완료마다 audit 1영역씩 자동 진행 → 누적 회귀 자동 검출/처리. + +## 거부 의사 표명 후 동작 + +사용자가 한 번 "audit 안 해" 라고 말하면: +- 같은 세션 동안 자동 audit 비활성 +- 명시적 `/audit` 호출은 여전히 동작 +- Stop hook reminder는 유지 (사용자가 다시 원할 때 알림) + +세션 종료 후 재진입 시 다시 활성. + +## 룰 위반 방지 + +이 규칙은 다음과 충돌하지 않음: +- `agent-coordination.md`: audit이 다른 에이전트 영역 침범 금지 — Explore가 거부 또는 main이 재위임 +- `architecture.md`: audit이 모놀리스 깊은 변경 금지 — 표면 회귀만 자동 처리 +- `unity-csharp.md`: 컨벤션 위반 자동 수정 금지 — 보고만 + +audit이 처리하는 범위는 P0/P1만. P2는 보고 후 사용자 결정 위임. diff --git a/.claude/scripts/data_lint.py b/.claude/scripts/data_lint.py new file mode 100644 index 0000000..ab360bc --- /dev/null +++ b/.claude/scripts/data_lint.py @@ -0,0 +1,473 @@ +"""데이터 정합성 자동 검증. 코드 내 하드코딩된 ID 정의/참조 비교, 고아/누락/중복 검출.""" +import argparse +import os +import re +import sys + +if hasattr(sys.stdout, "reconfigure") and sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined] + +# === 임계값 === +THRESHOLD_DEAD_METHOD_FAIL = True # 호출자 0건 메서드 = FAIL +THRESHOLD_MISSING_REF_FAIL = True # 참조하는데 정의 없는 ID = FAIL +THRESHOLD_ORPHAN_DEF_WARN = True # 정의했는데 참조 0건 = WARN +THRESHOLD_DUPLICATE_DEF_WARN = True # 같은 ID 다중 정의 = WARN +THRESHOLD_GACHA_PROB_TOLERANCE = 0.01 # 가챠 확률 합 100% ± 0.01 + +# === Unity 메서드 콜백 (반사 호출되므로 dead 검출 제외) === +UNITY_CALLBACKS = { + "Awake", "Start", "Update", "FixedUpdate", "LateUpdate", + "OnEnable", "OnDisable", "OnDestroy", "OnGUI", + "OnApplicationQuit", "OnApplicationPause", "OnApplicationFocus", + "OnTriggerEnter", "OnTriggerStay", "OnTriggerExit", + "OnCollisionEnter", "OnCollisionStay", "OnCollisionExit", + "OnDrawGizmos", "OnDrawGizmosSelected", "OnValidate", "Reset", +} + +# === 데이터 소스 경로 === +PATHS = { + "bootstrap": "Assets/Scripts/Core/PlaySceneBootstrap.cs", + "region_mgr": "Assets/Scripts/Core/RegionManager.cs", + "region_terrain": "Assets/Scripts/Core/RegionTerrainBuilder.cs", + "region_map_ui": "Assets/Scripts/UI/RegionMapUI.cs", + "audio_mgr": "Assets/Scripts/Core/AudioManager.cs", + "cash_shop": "Assets/Scripts/Core/CashShopManager.cs", + "cash_shop_ui": "Assets/Scripts/UI/CashShopUI.cs", + "gacha_mgr": "Assets/Scripts/Core/GachaBoxManager.cs", + "shop_ui": "Assets/Scripts/Core/ShopUIController.cs", + "spawner": "Assets/Scripts/Spawning/InsectSpawner.cs", +} + + +def _read(path: str) -> str: + if not os.path.isfile(path): + return "" + try: + with open(path, encoding="utf-8") as f: + return f.read() + except (OSError, UnicodeDecodeError): + return "" + + +def _read_clean(path: str) -> str: + """주석 제거된 파일 내용 반환. 추출기에서 false positive 방지용.""" + return _strip_comments(_read(path)) + + +def _strip_comments(content: str) -> str: + """C# 한 줄 주석 (//...) 제거. 블록 주석(/*...*/)은 단순 처리만.""" + lines = [] + for line in content.splitlines(): + # `//`이 라인의 첫 비공백이면 주석 라인. 인라인 주석은 보존 (코드도 살릴 수 있음) + stripped = line.lstrip() + if stripped.startswith("//"): + lines.append("") # 빈 줄로 대체 (라인 번호 보존) + else: + # 인라인 주석 제거 (간단 처리, 문자열 안의 //는 무시 못 함 — 보수적 처리) + idx = line.find("//") + if idx >= 0 and '"' not in line[:idx]: + lines.append(line[:idx]) + else: + lines.append(line) + return "\n".join(lines) + + +# ===== 추출기 ===== + +def extract_region_ids(scope: str = "expanded") -> set: + """CreateRegions() 또는 CreateExpandedRegions() 안의 regionId 추출. 다음 메서드 정의까지.""" + content = _read_clean(PATHS["bootstrap"]) + method_name = "CreateRegions" if scope == "legacy" else "CreateExpandedRegions" + pattern = re.compile( + rf"{method_name}\s*\(\)\s*\{{(.*?)(?=\n\s*(?:private|public)\s+(?:static\s+)?[\w\.\[\]<>]+\s+\w+\s*\()", + re.DOTALL + ) + m = pattern.search(content) + if not m: + return set() + return set(re.findall(r'regionId\s*=\s*"(\w+)"', m.group(1))) + + +def extract_region_refs() -> dict: + """파일별로 regionId 참조 수집. environmentType case (GetSubAreaColor)는 제외.""" + refs = {} + # RegionManager switch + content = _read_clean(PATHS["region_mgr"]) + refs["RegionManager"] = set(re.findall(r'case\s+"(\w+)":\s*return\s+"\w+"', content)) + refs["RegionManager"].update(re.findall(r'return\s+"(\w+)";', content)) + # RegionTerrainBuilder + content = _read_clean(PATHS["region_terrain"]) + refs["RegionTerrainBuilder"] = set(re.findall(r'case\s+"(\w+)":', content)) + # RegionMapUI: connections 배열 + GetRegionSymbol만 (GetSubAreaColor는 environmentType이라 제외) + content = _read_clean(PATHS["region_map_ui"]) + refs["RegionMapUI"] = set(re.findall(r'\{\s*"(\w+)"\s*,\s*"\w+"\s*\}', content)) + refs["RegionMapUI"].update(re.findall(r'\{\s*"\w+"\s*,\s*"(\w+)"\s*\}', content)) + # GetRegionSymbol 메서드 본체에서만 case 추출 (다음 메서드 정의 전까지) + sym_match = re.search( + r'GetRegionSymbol\s*\([^)]*\)\s*\{(.*?)(?=\n\s*private|\n\s*public)', + content, re.DOTALL + ) + if sym_match: + refs["RegionMapUI"].update(re.findall(r'case\s+"(\w+)":', sym_match.group(1))) + # AudioManager + content = _read_clean(PATHS["audio_mgr"]) + refs["AudioManager"] = set(re.findall(r'case\s+"(\w+)":', content)) + return refs + + +def detect_dead_methods() -> list: + """PlaySceneBootstrap의 private 메서드 중 참조 0건 검출. + 참조 = 정의 라인 외에서 메서드명이 등장하는 라인 (직접 호출 + 이벤트 += 구독 + 델리게이트 할당 포함). + Unity 콜백(Awake/Update 등)은 반사 호출이므로 제외.""" + content = _read_clean(PATHS["bootstrap"]) + methods = {} # name -> 정의 라인 + for m in re.finditer( + r'^\s*private\s+(?:static\s+)?[\w\.\[\]<>]+\s+(\w+)\s*\(', + content, re.MULTILINE + ): + name = m.group(1) + if name in UNITY_CALLBACKS: + continue + methods[name] = content[:m.start()].count("\n") + 1 + + dead = [] + lines = content.splitlines() + for name, def_line in methods.items(): + ref_count = 0 + for i, line in enumerate(lines, 1): + if i == def_line: + continue + if re.search(rf'\b{re.escape(name)}\b', line): + ref_count += 1 + break + if ref_count == 0: + dead.append((name, def_line)) + return sorted(dead, key=lambda x: x[1]) + + +def extract_cashshop_items() -> tuple: + """CashShopManager의 itemId, rewardItemId 추출. 주석 라인 제외.""" + content = _read_clean(PATHS["cash_shop"]) + items = re.findall(r'itemId\s*=\s*"(\w+)"', content) + rewards = re.findall(r'rewardItemId\s*=\s*"(\w+)"', content) + return set(items), set(rewards) + + +def extract_capture_items() -> set: + """PlaySceneBootstrap.CreateCaptureItems의 itemId 추출. nested {} 회피 위해 다음 메서드 정의까지로 한정.""" + content = _read_clean(PATHS["bootstrap"]) + pattern = re.compile( + r"CreateCaptureItems\s*\(\)\s*\{(.*?)(?=\n\s*(?:private|public)\s+(?:static\s+)?[\w\.\[\]<>]+\s+\w+\s*\()", + re.DOTALL + ) + m = pattern.search(content) + if not m: + return set() + return set(re.findall(r'itemId\s*=\s*"(\w+)"', m.group(1))) + + +def extract_cashshop_ui_boxes() -> dict: + """CashShopUI.DrawBoxCard 호출에서 boxId/price/rateText 추출. + rateText는 'C:55% U:30% ...' 형식. 반환: {boxId: {'price': int, 'rates': {grade: pct}}}""" + content = _read_clean(PATHS["cash_shop_ui"]) + boxes = {} + # 시그니처: DrawBoxCard("boxId", "title", new Color(...), "rateText", price, gems) + # `new Color(...)` 안의 콤마 때문에 lazy 매칭 사용 + pattern = re.compile( + r'DrawBoxCard\(\s*"(box_\w+)"\s*,\s*"[^"]+"\s*,.+?,\s*"([^"]+)"\s*,\s*(\d+)\s*,', + re.DOTALL + ) + for m in pattern.finditer(content): + box_id = m.group(1) + rate_text = m.group(2) + rates = {r.group(1).upper(): float(r.group(2)) + for r in re.finditer(r'([CUREL])\s*:\s*([\d.]+)%', rate_text)} + boxes[box_id] = {"price": int(m.group(3)), "rates": rates} + return boxes + + +def extract_cashshop_manager_box_prices() -> dict: + """CashShopManager.shopItems에서 box_* itemId의 gemPrice 추출.""" + content = _read_clean(PATHS["cash_shop"]) + prices = {} + for m in re.finditer( + r'itemId\s*=\s*"(box_\w+)".*?gemPrice\s*=\s*(\d+)', + content + ): + prices[m.group(1)] = int(m.group(2)) + return prices + + +def extract_gacha_pools() -> dict: + """GachaBoxManager의 gachaExclusives 사전 영역만 추출.""" + content = _read_clean(PATHS["gacha_mgr"]) + # gachaExclusives 사전 본체만 분리 (`Dictionary gachaExclusives = new ... { ... };`) + excl_match = re.search( + r'gachaExclusives\s*=\s*new\s+Dictionary\s*\{(.*?)\};', + content, re.DOTALL + ) + pools = {} + if excl_match: + body = excl_match.group(1) + # { InsectRarity.Rare, new[] { "id1", "id2" } } + for m in re.finditer(r'InsectRarity\.(\w+)\s*,\s*new\[\]\s*\{([^}]*)\}', body): + rarity = m.group(1) + ids = re.findall(r'"(gacha_\w+)"', m.group(2)) + pools[rarity] = set(ids) + # exclusiveDisplayNames: { "id", "표시명" } + name_map = dict(re.findall(r'\{\s*"(gacha_\w+)"\s*,\s*"([^"]+)"\s*\}', content)) + return {"pools": pools, "names": name_map} + + +def extract_gacha_probabilities() -> dict: + """GetBronzeRarity / Silver / Gold 메서드 본체를 다음 메서드 정의 전까지로 분리 추출.""" + content = _read_clean(PATHS["gacha_mgr"]) + results = {} + for box in ["Bronze", "Silver", "Gold"]: + # 메서드 시그니처부터 다음 메서드 정의 전까지 본체 추출 + pattern = re.compile( + rf'Get{box}Rarity\s*\([^)]*\)\s*\{{(.*?)(?=\n\s*(?:private|public)\s+(?:static\s+)?[\w\.\[\]<>]+\s+\w+\s*\()', + re.DOTALL + ) + m = pattern.search(content) + if not m: + results[box] = None + continue + # 첫 return InsectRarity.Legendary까지의 임계값만 + body = m.group(1) + legendary_idx = body.find("return InsectRarity.Legendary") + if legendary_idx >= 0: + body = body[:legendary_idx] + thresholds = [float(x) for x in re.findall(r'roll\s*<\s*([\d.]+)f', body)] + results[box] = thresholds + return results + + +# ===== 평가 ===== + +def evaluate_signals() -> list: + signals = [] + + # 1. 리전 정의 vs 참조 정합성 + expanded = extract_region_ids("expanded") + legacy = extract_region_ids("legacy") + refs = extract_region_refs() + all_refs = set() + for v in refs.values(): + all_refs.update(v) + + missing = all_refs - expanded + orphan = expanded - all_refs + judge = "FAIL" if missing else "PASS" + signals.append(( + "리전 참조 정합성 (정의 없는데 참조)", + "0건", + f"{len(missing)}건 ({sorted(missing)})" if missing else "0건", + judge + )) + + judge = "WARN" if orphan else "PASS" + signals.append(( + "리전 고아 (정의했는데 참조 0건)", + "0건", + f"{len(orphan)}건 ({sorted(orphan)})" if orphan else "0건", + judge + )) + + # 2. 리전 중복 정의 (Legacy vs Expanded) + duplicates = expanded & legacy + judge = "WARN" if duplicates else "PASS" + signals.append(( + "리전 중복 정의 (CreateRegions vs CreateExpandedRegions)", + "0건 중복", + f"{len(duplicates)}건 중복 ({sorted(duplicates)})" if duplicates else "0건", + judge + )) + + # 3. dead method 일반 검출 (PlaySceneBootstrap의 private 메서드 중 참조 0건) + dead_methods = detect_dead_methods() + judge = "FAIL" if dead_methods else "PASS" + detail = ", ".join(f"{n}() L{l}" for n, l in dead_methods) if dead_methods else "0건" + signals.append(( + "dead method 일반 검출 (PlaySceneBootstrap)", + "0건", + detail, + judge + )) + + # 4. CashShop rewardItemId ↔ CreateCaptureItems 매칭 + shop_items, reward_items = extract_cashshop_items() + capture_items = extract_capture_items() + # rewardItemId 중 캐시샵 아이템(gem_*, shop_*, box_*) 외의 ID는 실제 아이템 풀에 있어야 + expected_pool = capture_items.union({"candy", "exp_boost"}) # 알려진 외부 아이템 + unmapped = reward_items - expected_pool + judge = "WARN" if unmapped else "PASS" + signals.append(( + "CashShop rewardItemId ↔ 아이템 풀 매칭", + "0건 미매칭", + f"{len(unmapped)}건 ({sorted(unmapped)})" if unmapped else "0건", + judge + )) + + # 5. itemId 중복 (CreateCaptureItems와 CashShop shopItems 충돌) + overlap = capture_items & shop_items + judge = "FAIL" if overlap else "PASS" + signals.append(( + "itemId 중복 (CreateCaptureItems ∩ shopItems)", + "0건 중복", + f"{len(overlap)}건 ({sorted(overlap)})" if overlap else "0건", + judge + )) + + # 6. Gacha 풀 ↔ displayName 매핑 + gacha = extract_gacha_pools() + pool_ids = set() + for ids in gacha["pools"].values(): + pool_ids.update(ids) + name_ids = set(gacha["names"].keys()) + missing_names = pool_ids - name_ids + orphan_names = name_ids - pool_ids + judge = "FAIL" if missing_names else "PASS" + signals.append(( + "Gacha 풀 ID ↔ displayName 매핑 (풀에 있고 이름 없음)", + "0건", + f"{len(missing_names)}건 ({sorted(missing_names)})" if missing_names else "0건", + judge + )) + judge = "WARN" if orphan_names else "PASS" + signals.append(( + "Gacha displayName 고아 (이름 있는데 풀에 없음)", + "0건", + f"{len(orphan_names)}건 ({sorted(orphan_names)})" if orphan_names else "0건", + judge + )) + + # 7. Gacha 확률 합 100% 검증 — 마지막 임계값과 100 사이 격차가 Legendary 확률(>0)이어야 정상 + probs = extract_gacha_probabilities() + for box, thresholds in probs.items(): + if thresholds is None: + signals.append((f"Gacha {box} 확률 합", "100%", "추출 실패", "WARN")) + continue + last = thresholds[-1] if thresholds else 0 + legendary_pct = 100 - last + # Legendary 확률은 양수이고 정렬되어 있어야 정상 + is_sorted = thresholds == sorted(thresholds) and len(thresholds) >= 4 + judge = "PASS" if (legendary_pct > 0 and is_sorted) else "FAIL" + signals.append(( + f"Gacha {box} 누적 확률 분포 (Legendary={legendary_pct:.2f}%)", + "임계값 단조증가 + Legendary > 0", + f"임계값 {thresholds}, Legendary {legendary_pct:.2f}%", + judge + )) + + # 8. CashShop UI 박스 가격 ↔ Manager gemPrice 정합성 (UI 하드코딩 검출) + ui_boxes = extract_cashshop_ui_boxes() + mgr_prices = extract_cashshop_manager_box_prices() + price_mismatches = [] + for box_id, ui in ui_boxes.items(): + mgr = mgr_prices.get(box_id) + if mgr is None: + price_mismatches.append(f"{box_id}: UI={ui['price']} / Manager 미정의") + elif mgr != ui["price"]: + price_mismatches.append(f"{box_id}: UI={ui['price']} ≠ Manager={mgr}") + judge = "FAIL" if price_mismatches else "PASS" + signals.append(( + "CashShop UI 박스 가격 ↔ Manager gemPrice", + "0건 불일치", + f"{len(price_mismatches)}건 ({price_mismatches})" if price_mismatches else "0건", + judge + )) + + # 9. CashShop UI 박스 확률 텍스트 ↔ GachaBoxManager 임계값 정합성 + box_to_box_name = {"box_bronze": "Bronze", "box_silver": "Silver", "box_gold": "Gold"} + grades = ["C", "U", "R", "E", "L"] + prob_mismatches = [] + for box_id, ui in ui_boxes.items(): + box_name = box_to_box_name.get(box_id) + if box_name is None: + continue + thresholds = probs.get(box_name) + if thresholds is None or len(thresholds) < 4: + continue + # 누적 임계값 → 단계별 확률 + code_rates = {} + prev = 0.0 + for grade, t in zip(grades[:4], thresholds): + code_rates[grade] = t - prev + prev = t + code_rates["L"] = 100.0 - prev + for grade in grades: + ui_v = ui["rates"].get(grade) + code_v = code_rates.get(grade) + if ui_v is None or code_v is None: + continue + if abs(ui_v - code_v) > THRESHOLD_GACHA_PROB_TOLERANCE: + prob_mismatches.append(f"{box_id}.{grade}: UI={ui_v} ≠ Code={code_v:.2f}") + judge = "FAIL" if prob_mismatches else "PASS" + signals.append(( + "CashShop UI 박스 확률 텍스트 ↔ GachaBoxManager 임계값", + f"0건 불일치 (tolerance ±{THRESHOLD_GACHA_PROB_TOLERANCE})", + f"{len(prob_mismatches)}건 ({prob_mismatches})" if prob_mismatches else "0건", + judge + )) + + return signals + + +def render_signals(signals: list) -> str: + out = ["| 항목 | 임계값 | 측정값 | 판정 |", "|------|--------|--------|------|"] + for name, threshold, value, judge in signals: + out.append(f"| {name} | {threshold} | {value} | **{judge}** |") + fail_n = sum(1 for s in signals if s[3] == "FAIL") + warn_n = sum(1 for s in signals if s[3] == "WARN") + pass_n = sum(1 for s in signals if s[3] == "PASS") + out.append("") + out.append(f"요약: **{fail_n} FAIL** / {warn_n} WARN / {pass_n} PASS") + if fail_n > 0: + out.append("→ FAIL 1건 이상. PASS 보고 금지. 즉시 정정 필요.") + return "\n".join(out) + + +def main(): + p = argparse.ArgumentParser(description="데이터 정합성 자동 검증") + p.add_argument("--detail", action="store_true", help="추출된 데이터셋 상세 출력") + args = p.parse_args() + + print("# data-lint — 곤충게임 데이터 정합성 검증\n") + + if args.detail: + print("## 추출된 데이터셋") + expanded = extract_region_ids("expanded") + legacy = extract_region_ids("legacy") + refs = extract_region_refs() + shop_items, reward_items = extract_cashshop_items() + capture_items = extract_capture_items() + gacha = extract_gacha_pools() + print(f"- CreateExpandedRegions regionId: {sorted(expanded)}") + print(f"- CreateRegions regionId (legacy): {sorted(legacy)}") + for src, ids in refs.items(): + print(f"- {src} 참조: {sorted(ids)}") + print(f"- CashShop itemIds: {sorted(shop_items)}") + print(f"- CashShop rewardItemIds: {sorted(reward_items)}") + print(f"- CreateCaptureItems itemIds: {sorted(capture_items)}") + print(f"- Gacha 풀: {gacha['pools']}") + print(f"- Gacha displayNames: {sorted(gacha['names'].keys())}") + print() + + print("## 위험 신호 표") + signals = evaluate_signals() + print(render_signals(signals)) + print() + + print("## 가정 / 한계") + print("- 코드 내 하드코딩된 ID만 검증. ScriptableObject(.asset) 직렬화는 미지원") + print("- 정규식 기반 추출 — 코드 포맷 변경 시 false negative 가능") + print("- InsectDatabase의 insectId는 .asset 파일에 있어 미검증 (후속 작업)") + print("- Inspector 직렬화 필드(예: ShopUIController.itemIds[])는 grep 미지원") + + fail_count = sum(1 for s in signals if s[3] == "FAIL") + return 1 if fail_count > 0 else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/scripts/economy_sim.py b/.claude/scripts/economy_sim.py new file mode 100644 index 0000000..b19192a --- /dev/null +++ b/.claude/scripts/economy_sim.py @@ -0,0 +1,274 @@ +"""경제 시뮬레이션. 캔디/젬/코인 수입·지출 균형, 코드 결함(데드 화폐, 교환 부재) 자동 점검.""" +import argparse +import os +import re +import sys + +# Windows cp949 환경에서 유니코드 출력 보장 +if hasattr(sys.stdout, "reconfigure") and sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined] + +# === 임계값 (디자이너가 조정 가능) === +# 근거: 일일 1캡처 권장 페이스. 4일+ 대기는 단일 비용이 일주일 행동량 초과 — 진행 막힘. +THRESHOLD_CANDY_BOTTLENECK_DAYS_FAIL = 4.0 # 6마리 팀 동시 레벨업 4일+ 대기 = FAIL +# 근거: 화폐 정의는 있는데 발행/사용 경로 0건이면 dead 시스템. TryPurchase 호출부 0건(코인) 알려진 결함. +THRESHOLD_DEAD_CURRENCY_FAIL = True # 발행 경로 0건이면 FAIL +# 근거: 14일 후 젬 잉여는 신규 지출 경로 부족 신호. 골드박스 4회분(4800) 초과는 디자인 의도 외 축적. +THRESHOLD_GEM_HOARD_DAYS_WARN = 14 # 14일 후 젬 잔고 > 골드박스 4회분 = WARN +# 근거: 캔디 부족분을 젬으로 보충 못 하면 P2W 외 우회로 0 — 경제 유연성 부재. +THRESHOLD_NO_EXCHANGE_WARN = True # 캔디↔젬 교환 경로 0건 = WARN +# 근거: 효과 1% 차이에 가격 5배(diamond_net 2000젬 vs normal_net 150코인)는 명백한 P2W 함정. +THRESHOLD_P2W_GAP_RATIO_WARN = 5.0 # 프리미엄/베이직 가격 비율 5배+ = WARN + +# === 정본 (코드와 동기화 필요) === +# Assets/Scripts/Core/CashShopManager.cs:44-65 +CASH_GEM_PACKAGES = [(2000, 150), (5000, 400), (10000, 900)] # (KRW, gems) +GACHA_BOX_PRICES = {"bronze": 500, "silver": 800, "gold": 1200} +CASH_ITEM_GEMS = {"net_silver": 200, "net_gold": 400, "incense_rare": 350, + "candy": 300, "exp_boost": 300} + +# Assets/Scripts/Data/InsectLevelCurve.cs (progression_sim과 동일) +BASE_CANDY = 4 +CANDY_GROWTH = 1.14 + +# 사용자 행동 프로파일 +PROFILES = { + "ftp": {"gem_topup_per_week": 0, "label": "Free-to-play (충전 없음)"}, + "mid": {"gem_topup_per_week": 400, "label": "중과금 (gem_550 주 1회)"}, + "whale": {"gem_topup_per_week": 2700, "label": "고래 (gem_1200 주 3회)"}, +} + + +def candy_cost(level: int) -> int: + return max(1, int(BASE_CANDY * (CANDY_GROWTH ** (level - 1)))) + + +def estimate_daily_candy(captures_per_day: int, battles_per_day: int, + raids_per_week: int, avg_candy_per_event: float = 5.0) -> float: + """행동량 기반 일일 캔디 평균 수입 추정. 레이드는 ×3 보상 + 주->일 환산.""" + capture_income = captures_per_day * avg_candy_per_event + battle_income = battles_per_day * avg_candy_per_event + raid_income = raids_per_week * avg_candy_per_event * 3.0 / 7.0 + return capture_income + battle_income + raid_income + + +def candy_bottleneck_days(team_size: int, current_level: int, + daily_candy_income: float) -> float: + """팀원 동시 레벨업 비용 / 일일 캔디 수입.""" + cost_per_member = candy_cost(current_level) + total = cost_per_member * team_size + if daily_candy_income <= 0: + return float("inf") + return total / daily_candy_income + + +def gem_balance_after(days: int, gem_topup_per_week: float, + gacha_per_week: int) -> int: + """N일 후 젬 잔고. 매주 충전 - 매주 가챠(silver 기준).""" + weeks = days / 7.0 + income = gem_topup_per_week * weeks + spend = gacha_per_week * GACHA_BOX_PRICES["silver"] * weeks + return int(income - spend) + + +def _scan_files(root: str = "Assets/Scripts", suffix: str = ".cs"): + """root 하위 .cs 파일 경로 yield.""" + if not os.path.isdir(root): + return + for dirpath, _, filenames in os.walk(root): + for fn in filenames: + if fn.endswith(suffix): + yield os.path.join(dirpath, fn) + + +def _count_matches(pattern: str, exclude_definitions: bool = True) -> int: + """pattern 매칭 라인 수. 메서드 정의 라인 제외 옵션.""" + rx = re.compile(pattern) + def_rx = re.compile(r"\b(public|private|protected|internal)\s+\w+\s+\w+\s*\(") + total = 0 + for path in _scan_files(): + try: + with open(path, encoding="utf-8") as f: + for line in f: + if rx.search(line): + if exclude_definitions and def_rx.search(line): + continue + total += 1 + except (OSError, UnicodeDecodeError): + continue + return total + + +def coin_income_count() -> int: + """AddCoins 호출부 (정의 제외).""" + return _count_matches(r"\.AddCoins\s*\(|\bwallet\.AddCoins\s*\(") + + +def coin_spend_count() -> int: + return _count_matches(r"\.SpendCoins\s*\(|\bwallet\.SpendCoins\s*\(") + + +def candy_gem_exchange_count() -> int: + """같은 라인에 (Add|Spend)Gems와 (Add|Spend)Candy 동시 등장.""" + rx = re.compile(r"(Add|Spend)Gems.*?(Add|Spend)Candy|(Add|Spend)Candy.*?(Add|Spend)Gems") + total = 0 + for path in _scan_files(): + try: + with open(path, encoding="utf-8") as f: + for line in f: + if rx.search(line): + total += 1 + except (OSError, UnicodeDecodeError): + continue + return total + + +def p2w_outfit_gap() -> dict: + """프리미엄 의상 평균 젬 가격 / 베이직 의상 평균 코인 가격.""" + # 정본은 CharacterOutfitManager.cs MakeBaseItem/MakePremiumItem 호출 + # 단순화: 사용자 입력 또는 휴리스틱 + return {"basic_avg_coin": 300, "premium_avg_gem": 1100, + "ratio_note": "평균 의상가 — 정확값은 CharacterOutfitManager.cs 인스펙터 참조"} + + +def evaluate_signals(args) -> list: + signals = [] + + # 1. 코인 발행 경로 (수입) + coin_income = coin_income_count() + coin_spend = coin_spend_count() + if coin_income == 0 and coin_spend > 0: + signals.append(("코인 발행 경로", ">= 1", f"수입 {coin_income} / 지출 {coin_spend} (데드 화폐)", "FAIL")) + else: + signals.append(("코인 발행 경로", ">= 1", f"수입 {coin_income} / 지출 {coin_spend}", "PASS")) + + # 2. 캔디 병목 + bottleneck = candy_bottleneck_days(args.team_size, args.current_level, args.daily_candy_income) + judge = "FAIL" if bottleneck >= THRESHOLD_CANDY_BOTTLENECK_DAYS_FAIL else "PASS" + signals.append((f"중반 캔디 병목 ({args.team_size}마리, Lv{args.current_level})", + f"< {THRESHOLD_CANDY_BOTTLENECK_DAYS_FAIL:.1f}일", + f"{bottleneck:.1f}일", judge)) + + # 3. 젬 잔고 — 음수(부족)와 양수(과잉) 양방향 점검 + profile = PROFILES.get(args.profile, PROFILES["ftp"]) + gem_balance = gem_balance_after(args.days, profile["gem_topup_per_week"], args.gacha_per_week) + upper = GACHA_BOX_PRICES["gold"] * 4 # 골드 4회분 = 과잉 임계 + if gem_balance < 0: + # 부족 — 충전 압박 + judge = "FAIL" if gem_balance < -upper else "WARN" + signals.append((f"{args.days}일 후 젬 잔고 ({profile['label']})", + f">= 0젬", + f"{gem_balance:,}젬 (충전 부족 / 결제 압박)", judge)) + elif gem_balance > upper: + # 과잉 — 지출 경로 부족 + signals.append((f"{args.days}일 후 젬 잔고 ({profile['label']})", + f"<= {upper:,}젬", + f"{gem_balance:,}젬 (지출 경로 부족)", "WARN")) + else: + # 균형 + signals.append((f"{args.days}일 후 젬 잔고 ({profile['label']})", + f"0 ~ {upper:,}젬", + f"{gem_balance:,}젬 (균형)", "PASS")) + + # 4. 캔디↔젬 교환 + exchange = candy_gem_exchange_count() + judge = "WARN" if exchange == 0 else "PASS" + signals.append(("캔디↔젬 교환 경로", ">= 1", + f"{exchange}건 (강제 결제 유도)" if exchange == 0 else f"{exchange}건", + judge)) + + # 5. P2W 격차 (의상 가격대비) + p2w = p2w_outfit_gap() + ratio = p2w["premium_avg_gem"] / max(1, p2w["basic_avg_coin"]) + judge = "WARN" if ratio >= THRESHOLD_P2W_GAP_RATIO_WARN else "PASS" + signals.append(("프리미엄 vs 베이직 의상 가격비", + f"< {THRESHOLD_P2W_GAP_RATIO_WARN:.1f}x", + f"{ratio:.1f}x (코인 {p2w['basic_avg_coin']} vs 젬 {p2w['premium_avg_gem']})", + judge)) + + return signals + + +def render_signals(signals: list) -> str: + out = ["| 항목 | 임계값 | 측정값 | 판정 |", "|------|--------|--------|------|"] + for name, threshold, value, judge in signals: + out.append(f"| {name} | {threshold} | {value} | **{judge}** |") + fail_n = sum(1 for s in signals if s[3] == "FAIL") + warn_n = sum(1 for s in signals if s[3] == "WARN") + pass_n = sum(1 for s in signals if s[3] == "PASS") + out.append("") + out.append(f"요약: **{fail_n} FAIL** / {warn_n} WARN / {pass_n} PASS") + if fail_n > 0: + out.append("→ FAIL 1건 이상. PASS 보고 금지. Phase 4 권장사항 적용 필요.") + return "\n".join(out) + + +def main(): + p = argparse.ArgumentParser(description="경제 균형 시뮬") + p.add_argument("--days", type=int, default=14, help="시뮬 기간 (일, 기본 14)") + p.add_argument("--profile", default="ftp", choices=list(PROFILES.keys())) + p.add_argument("--gacha-per-week", type=int, default=2) + p.add_argument("--captures-per-day", type=int, default=20) + p.add_argument("--battles-per-day", type=int, default=10) + p.add_argument("--raids-per-week", type=int, default=3) + p.add_argument("--daily-candy-income", type=float, default=None, + help="일일 캔디 평균 수입. 지정 안 하면 captures/battles/raids에서 자동 계산") + p.add_argument("--avg-candy-per-event", type=float, default=5.0, + help="이벤트(포획/전투/레이드 1회)당 평균 캔디 (기본 5)") + p.add_argument("--team-size", type=int, default=6) + p.add_argument("--current-level", type=int, default=25, + help="병목 점검 시 곤충 레벨 (기본 25)") + args = p.parse_args() + + # daily-candy-income이 None이면 행동량 기반 자동 계산 + if args.daily_candy_income is None: + args.daily_candy_income = estimate_daily_candy( + args.captures_per_day, args.battles_per_day, + args.raids_per_week, args.avg_candy_per_event + ) + income_source = f"자동 계산 (포획 {args.captures_per_day}/일, 전투 {args.battles_per_day}/일, 레이드 {args.raids_per_week}/주, 이벤트당 {args.avg_candy_per_event} 캔디)" + else: + income_source = "사용자 직접 입력" + + print(f"# economy-sim — {args.days}일 시뮬 ({PROFILES[args.profile]['label']})\n") + + print("## 입력 파라미터") + print(f"- days: {args.days}, profile: {args.profile}") + print(f"- 행동: 가챠 {args.gacha_per_week}/주, 포획 {args.captures_per_day}/일, 전투 {args.battles_per_day}/일, 레이드 {args.raids_per_week}/주") + print(f"- daily_candy_income: {args.daily_candy_income:.1f} ({income_source})") + print(f"- team_size: {args.team_size} / current_level: Lv{args.current_level}") + print() + + print("## 캐시샵 정본") + for krw, gems in CASH_GEM_PACKAGES: + print(f"- {krw:,}원 → {gems}젬 (KRW/gem = {krw/gems:.2f})") + print(f"- 가챠 박스: bronze {GACHA_BOX_PRICES['bronze']}젬 / silver {GACHA_BOX_PRICES['silver']}젬 / gold {GACHA_BOX_PRICES['gold']}젬") + print() + + print("## 코드 결함 점검") + coin_in = coin_income_count() + coin_out = coin_spend_count() + exch = candy_gem_exchange_count() + print(f"- AddCoins 호출부: {coin_in}건 (0이면 데드 화폐)") + print(f"- SpendCoins 호출부: {coin_out}건") + print(f"- 캔디↔젬 교환: {exch}건 (0이면 강제 결제 유도)") + print() + + print("## 위험 신호 표") + signals = evaluate_signals(args) + print(render_signals(signals)) + print() + + print("## 가정 / 한계") + print(f"- 일일 캔디 수입 {args.daily_candy_income} 가정 (사용자 입력 의존)") + print("- 사용자 행동 균등 가정 (실제는 초반 폭주 후반 정체)") + print("- 이벤트/시즌 보상 미반영") + print("- P2W 격차는 의상 평균가 휴리스틱 — 정확한 검증은 CharacterOutfitManager.cs 인스펙터 참조") + print("- 텔레메트리 수집 후 재검증 필수") + + fail_count = sum(1 for s in signals if s[3] == "FAIL") + return 1 if fail_count > 0 else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/scripts/gacha_sim.py b/.claude/scripts/gacha_sim.py new file mode 100644 index 0000000..f9d67ea --- /dev/null +++ b/.claude/scripts/gacha_sim.py @@ -0,0 +1,370 @@ +"""가챠 박스 몬테카를로 시뮬 + UI-코드 정합성 자동 검증.""" +import argparse +import random +import sys + +if hasattr(sys.stdout, "reconfigure") and sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined] + +# === 임계값 === +# 근거: 100연차에 Legendary 0개 확률 50%+ = 천장 없는 가챠 = 사용자 보호 부재. 산업 관행 50~100연 천장. +THRESHOLD_NO_PITY_LEGENDARY_FAIL = 0.50 # 100연차 Legendary 0개 확률 50%+ = FAIL +# 근거: UI 텍스트와 코드 확률 분리는 UX 신뢰 위반. 사용자에게 표시된 값과 실 확률 불일치 = 환불 사유. +THRESHOLD_UI_CODE_MISMATCH_FAIL = True # UI 텍스트 ≠ 코드 = FAIL +# 근거: UI 가격(300/500/700)과 Manager(500/800/1200) 분리 — 알려진 결함. 결제 표시 오인 위험. +THRESHOLD_PRICE_MISMATCH_FAIL = True # CashShopUI 가격 ≠ Manager 가격 = FAIL +# 근거: 실버는 브론즈 대비 가격 +60%(500→800). 기댓값 +60% 미만이면 가성비 함정 — 차상위 박스 매력 부족. +# 의미: 차상위 박스의 EV 증가율이 가격 증가율 × 이 비율 미만이면 가성비 함정 WARN. +# 예) 가격 +60%일 때 EV 증가율이 +36%(60×0.6) 미만이면 WARN. +THRESHOLD_VALUE_TRAP_RATIO_WARN = 0.6 # 가격 증가율 대비 EV 증가율 최소 비율 +# 근거: 100연차에 평균 15회 이상 중복 = 신규 곤충 획득 효율 너무 낮음. 풀 크기 확대 또는 천장 신설 필요. +THRESHOLD_EXCLUSIVE_DUP_WARN = 15 # 100연차 평균 중복 15+ = WARN +# 근거: 필드 샤이니 1% vs 가챠 0%는 가챠 매력 저하. 가챠 자체 보너스 디자인 부재 신호. +THRESHOLD_SHINY_GACHA_GAP_WARN = True # 가챠 샤이니 0% vs 필드 1% = WARN + +# === 정본 (GachaBoxManager.cs:130-180과 동기화) === +BOX_DEFS = { + "bronze": { + "price_manager": 500, + "rarity_pcts": {"Common": 55, "Uncommon": 30, "Rare": 12, "Epic": 2.5, "Legendary": 0.5}, + "exclusive_chance": 0.20, + "candy_bonus_min": 5, "candy_bonus_max": 15, + }, + "silver": { + "price_manager": 800, + "rarity_pcts": {"Common": 20, "Uncommon": 35, "Rare": 30, "Epic": 12, "Legendary": 3}, + "exclusive_chance": 0.30, + "candy_bonus_min": 10, "candy_bonus_max": 30, + }, + "gold": { + "price_manager": 1200, + "rarity_pcts": {"Common": 10, "Uncommon": 25, "Rare": 35, "Epic": 25, "Legendary": 5}, + "exclusive_chance": 0.50, + "candy_bonus_min": 20, "candy_bonus_max": 50, + }, +} + +# GachaBoxManager.cs:160-180 전용곤충 풀 크기 +EXCLUSIVE_POOL_SIZE = {"Rare": 3, "Epic": 4, "Legendary": 3} + +# Assets/Scripts/Data/InsectRewardCalculator.cs (progression_sim과 동일). +# 가챠 EV 가중치 = 게임 내 실제 보상 배율 — 박스 1회 가치를 보상 가치 기준으로 환산. +RARITY_MULT = {"Common": 1.0, "Uncommon": 1.2, "Rare": 1.5, "Epic": 2.0, "Legendary": 2.8} + +# Assets/Scripts/Spawning/InsectEntity.cs (필드 샤이니 1%) +FIELD_SHINY_PCT = 1.0 +GACHA_SHINY_PCT = 0.0 # GachaBoxManager에 샤이니 로직 없음 + +# Lv30 캔디 비용 (progression_sim과 일치) +LV30_CANDY_COST = int(4 * (1.14 ** 29)) + + +def draw_rarity(box: str, rng: random.Random) -> str: + """확률 분포에 따라 레어도 1개 추첨.""" + r = rng.uniform(0, 100) + cum = 0.0 + for rarity in ["Common", "Uncommon", "Rare", "Epic", "Legendary"]: + cum += BOX_DEFS[box]["rarity_pcts"][rarity] + if r < cum: + return rarity + return "Legendary" + + +def simulate_pulls(box: str, pulls: int, rng: random.Random) -> dict: + """1회 시도(pulls회 가챠)의 결과.""" + counts = {"Common": 0, "Uncommon": 0, "Rare": 0, "Epic": 0, "Legendary": 0} + candy = 0 + for _ in range(pulls): + rarity = draw_rarity(box, rng) + counts[rarity] += 1 + candy += rng.randint(BOX_DEFS[box]["candy_bonus_min"], + BOX_DEFS[box]["candy_bonus_max"]) + return {"counts": counts, "candy": candy} + + +def monte_carlo(box: str, pulls: int, trials: int, seed: int) -> dict: + """몬테카를로 trials회 반복. 5%/50%/95% 분위 + 평균.""" + rng = random.Random(seed) + legendary_results = [] + epic_results = [] + candy_results = [] + for _ in range(trials): + r = simulate_pulls(box, pulls, rng) + legendary_results.append(r["counts"]["Legendary"]) + epic_results.append(r["counts"]["Epic"]) + candy_results.append(r["candy"]) + + def stats(arr): + s = sorted(arr) + n = len(s) + return { + "min": s[0], + "p5": s[n // 20], + "p50": s[n // 2], + "mean": sum(s) / n, + "p95": s[int(n * 0.95)], + "max": s[-1], + "zero": sum(1 for x in arr if x == 0) / n, + } + + return { + "legendary": stats(legendary_results), + "epic": stats(epic_results), + "candy": stats(candy_results), + } + + +def expected_value(box: str) -> float: + """RARITY_MULT(보상 배율 정본)로 박스 1회 기댓값 산출.""" + pcts = BOX_DEFS[box]["rarity_pcts"] + return sum(RARITY_MULT[r] * pcts[r] / 100.0 for r in RARITY_MULT) + + +def render_box_comparison() -> str: + """박스 간 가격당 EV 비교 표. 차상위 박스의 효율 증가가 가격 증가에 부합하는지 가시화.""" + out = ["| 박스 | 가격(젬) | Legendary % | EV (가중) | 가격당 EV(×1000) | bronze 대비 효율 |", + "|------|---------:|------------:|----------:|----------------:|----------------:|"] + bronze_ev_per_gem = expected_value("bronze") / BOX_DEFS["bronze"]["price_manager"] + for box, defs in BOX_DEFS.items(): + ev = expected_value(box) + ev_per_gem = ev / defs["price_manager"] + ratio = ev_per_gem / bronze_ev_per_gem + out.append(f"| {box} | {defs['price_manager']} | {defs['rarity_pcts']['Legendary']}% | {ev:.2f} | {ev_per_gem*1000:.3f} | {ratio:.2f}x |") + return "\n".join(out) + + +def epic_exclusive_duplicates(box: str, pulls: int, trials: int, seed: int) -> float: + """Epic 전용곤충 풀에서 중복 등장 평균. (Epic 추첨 횟수 - Epic 풀 크기)의 평균.""" + rng = random.Random(seed + 1) # 메인 시뮬과 다른 시드 + pool_size = EXCLUSIVE_POOL_SIZE["Epic"] + exclusive_chance = BOX_DEFS[box]["exclusive_chance"] + duplicates = [] + for _ in range(trials): + epic_exclusive_count = 0 + for _ in range(pulls): + rarity = draw_rarity(box, rng) + if rarity == "Epic" and rng.random() < exclusive_chance: + epic_exclusive_count += 1 + # pool_size 이상 뽑으면 그 만큼 중복 발생 (균등 분포 가정 단순화) + dup = max(0, epic_exclusive_count - pool_size) + duplicates.append(dup) + return sum(duplicates) / len(duplicates) + + +def prob_zero_legendary_analytic(box: str, pulls: int) -> float: + """해석적 P(Legendary 0개 in N연차) = (1-p)^N.""" + p = BOX_DEFS[box]["rarity_pcts"]["Legendary"] / 100.0 + return (1 - p) ** pulls + + +# === UI-코드 정합성 grep === +# data_lint의 추출기를 재사용해 정규식 중복 제거. 같은 디렉토리에 있으므로 sys.path 추가. +import os.path # noqa: E402 +_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +if _SCRIPT_DIR not in sys.path: + sys.path.insert(0, _SCRIPT_DIR) +from data_lint import extract_cashshop_ui_boxes # noqa: E402 + +_RARITY_FULL = {"C": "Common", "U": "Uncommon", "R": "Rare", "E": "Epic", "L": "Legendary"} + + +def grep_ui_prices() -> dict: + """CashShopUI에서 박스 가격 추출 (box_ prefix 제거 후 단축 키로 반환).""" + return {b.replace("box_", ""): info["price"] for b, info in extract_cashshop_ui_boxes().items()} + + +def grep_ui_rarity_text() -> dict: + """CashShopUI에서 박스별 확률 텍스트 추출 (등급명 풀네임으로 반환).""" + result = {} + for box, info in extract_cashshop_ui_boxes().items(): + short = box.replace("box_", "") + result[short] = {_RARITY_FULL[g]: pct for g, pct in info["rates"].items()} + return result + + +def check_price_mismatch() -> list: + """UI 가격 vs 정본 가격 비교.""" + ui_prices = grep_ui_prices() + mismatches = [] + for box, manager_price in [(b, BOX_DEFS[b]["price_manager"]) for b in BOX_DEFS]: + ui_price = ui_prices.get(box) + if ui_price is None: + mismatches.append((box, "UI에서 추출 실패", manager_price)) + elif ui_price != manager_price: + mismatches.append((box, ui_price, manager_price)) + return mismatches + + +def check_rarity_text_mismatch() -> list: + """UI 확률 텍스트 vs 정본 확률 비교.""" + ui_rarity = grep_ui_rarity_text() + mismatches = [] + for box, defs in BOX_DEFS.items(): + ui_pcts = ui_rarity.get(box, {}) + for rarity, pct in defs["rarity_pcts"].items(): + ui_val = ui_pcts.get(rarity) + if ui_val is None or abs(ui_val - pct) > 0.001: + mismatches.append((box, rarity, ui_val, pct)) + return mismatches + + +def evaluate_signals(args) -> list: + signals = [] + + # 1. 천장 부재 — Legendary 0개 확률 + p0 = prob_zero_legendary_analytic(args.box, args.pulls) + judge = "FAIL" if p0 >= THRESHOLD_NO_PITY_LEGENDARY_FAIL else "PASS" + signals.append((f"천장 부재: {args.box} {args.pulls}연차 Legendary 0개", + f"< {THRESHOLD_NO_PITY_LEGENDARY_FAIL*100:.0f}%", + f"{p0*100:.1f}%", judge)) + + # 2. 가격 불일치 (UI vs Manager) + price_mm = check_price_mismatch() + judge = "FAIL" if price_mm else "PASS" + if price_mm: + details = ", ".join(f"{b}: UI={u} 정본={m}" for b, u, m in price_mm) + else: + details = "0건 불일치" + signals.append(("CashShopUI 가격 vs Manager 정합성", "0건 불일치", details, judge)) + + # 3. 확률 텍스트 불일치 + rt_mm = check_rarity_text_mismatch() + judge = "FAIL" if rt_mm else "PASS" + if rt_mm: + details = f"{len(rt_mm)}건 불일치" + else: + details = "0건 불일치" + signals.append(("UI 확률 텍스트 vs 코드 정합성", "0건 불일치", details, judge)) + + # 4. 실버 가성비 + bronze_ev = expected_value("bronze") + silver_ev = expected_value("silver") + ev_gain = (silver_ev - bronze_ev) / bronze_ev + price_gain = (BOX_DEFS["silver"]["price_manager"] - BOX_DEFS["bronze"]["price_manager"]) / BOX_DEFS["bronze"]["price_manager"] + judge = "WARN" if ev_gain < price_gain * THRESHOLD_VALUE_TRAP_RATIO_WARN else "PASS" + signals.append(("실버 가성비 (브론즈 대비)", + f"기댓값 +{price_gain*THRESHOLD_VALUE_TRAP_RATIO_WARN*100:.0f}%+", + f"가격 +{price_gain*100:.0f}% / 기댓값 +{ev_gain*100:.0f}%", judge)) + + # 4-b. 골드 가성비 (실버 대비) — 차상위 박스가 효율적인지 + silver_ev_b = expected_value("silver") + gold_ev = expected_value("gold") + ev_gain_g = (gold_ev - silver_ev_b) / silver_ev_b if silver_ev_b > 0 else 0 + price_gain_g = (BOX_DEFS["gold"]["price_manager"] - BOX_DEFS["silver"]["price_manager"]) / BOX_DEFS["silver"]["price_manager"] + judge = "WARN" if ev_gain_g < price_gain_g * THRESHOLD_VALUE_TRAP_RATIO_WARN else "PASS" + signals.append(("골드 가성비 (실버 대비)", + f"기댓값 +{price_gain_g*THRESHOLD_VALUE_TRAP_RATIO_WARN*100:.0f}%+", + f"가격 +{price_gain_g*100:.0f}% / 기댓값 +{ev_gain_g*100:.0f}%", judge)) + + # 4-c. 박스 가성비 역전 — 차상위 박스의 가격당 EV가 차하위보다 낮으면 가성비 함정 신호 + ev_per_gem = {b: expected_value(b) / BOX_DEFS[b]["price_manager"] for b in BOX_DEFS} + inversions = [] + if ev_per_gem["silver"] < ev_per_gem["bronze"]: + inversions.append(f"silver({ev_per_gem['silver']*1000:.3f}) < bronze({ev_per_gem['bronze']*1000:.3f})") + if ev_per_gem["gold"] < ev_per_gem["silver"]: + inversions.append(f"gold({ev_per_gem['gold']*1000:.3f}) < silver({ev_per_gem['silver']*1000:.3f})") + if ev_per_gem["gold"] < ev_per_gem["bronze"]: + inversions.append(f"gold({ev_per_gem['gold']*1000:.3f}) < bronze({ev_per_gem['bronze']*1000:.3f})") + judge = "WARN" if inversions else "PASS" + signals.append(("박스 가성비 역전 (×1000 단위)", + "역전 0건", + "; ".join(inversions) if inversions else "0건", + judge)) + + # 5. 골드 캔디보너스 가치 + avg_candy = (BOX_DEFS["gold"]["candy_bonus_min"] + BOX_DEFS["gold"]["candy_bonus_max"]) / 2 + candy_ratio = avg_candy / LV30_CANDY_COST + judge = "WARN" if candy_ratio < 0.5 else "PASS" + signals.append(("골드 캔디보너스 / Lv30 레벨업비", + ">= 50%", + f"{candy_ratio*100:.1f}% ({avg_candy:.0f}/{LV30_CANDY_COST})", judge)) + + # 6. Epic 전용곤충 중복 (풀이 작아 중복 누적) + avg_dup = epic_exclusive_duplicates(args.box, args.pulls, min(args.trials, 5000), args.seed) + judge = "WARN" if avg_dup >= THRESHOLD_EXCLUSIVE_DUP_WARN else "PASS" + signals.append((f"Epic 전용곤충 {args.pulls}연차 평균 중복 (풀 {EXCLUSIVE_POOL_SIZE['Epic']}종)", + f"< {THRESHOLD_EXCLUSIVE_DUP_WARN}", + f"{avg_dup:.1f}", judge)) + + # 7. 가챠 샤이니 + judge = "WARN" if GACHA_SHINY_PCT < FIELD_SHINY_PCT else "PASS" + signals.append(("가챠 샤이니 적용", + f"{FIELD_SHINY_PCT}%", + f"{GACHA_SHINY_PCT}% (코드 미구현)", judge)) + + return signals + + +def render_distribution(mc: dict) -> str: + out = ["| 항목 | min | p5 | p50 | mean | p95 | max | 0개 비율 |", + "|------|----:|---:|----:|-----:|----:|----:|---------:|"] + for key in ["legendary", "epic", "candy"]: + s = mc[key] + out.append(f"| {key.title()} | {s['min']} | {s['p5']} | {s['p50']} | {s['mean']:.1f} | {s['p95']} | {s['max']} | {s['zero']*100:.1f}% |") + return "\n".join(out) + + +def render_signals(signals: list) -> str: + out = ["| 항목 | 임계값 | 측정값 | 판정 |", "|------|--------|--------|------|"] + for name, threshold, value, judge in signals: + out.append(f"| {name} | {threshold} | {value} | **{judge}** |") + fail_n = sum(1 for s in signals if s[3] == "FAIL") + warn_n = sum(1 for s in signals if s[3] == "WARN") + pass_n = sum(1 for s in signals if s[3] == "PASS") + out.append("") + out.append(f"요약: **{fail_n} FAIL** / {warn_n} WARN / {pass_n} PASS") + if fail_n > 0: + out.append("→ FAIL 1건 이상. PASS 보고 금지.") + return "\n".join(out) + + +def main(): + p = argparse.ArgumentParser(description="가챠 박스 몬테카를로 + UI-코드 정합성") + p.add_argument("--box", default="bronze", choices=["bronze", "silver", "gold", "all"]) + p.add_argument("--pulls", type=int, default=100) + p.add_argument("--trials", type=int, default=10000) + p.add_argument("--seed", type=int, default=42) + args = p.parse_args() + + boxes = ["bronze", "silver", "gold"] if args.box == "all" else [args.box] + + print(f"# gacha-sim — 몬테카를로 {args.trials:,}회, {args.pulls}연차/시도\n") + + print("## 박스 정본 (GachaBoxManager.cs:130-180)") + for b, d in BOX_DEFS.items(): + pcts = d["rarity_pcts"] + print(f"- {b} ({d['price_manager']}젬): C{pcts['Common']}/U{pcts['Uncommon']}/R{pcts['Rare']}/E{pcts['Epic']}/L{pcts['Legendary']}, 픽업{int(d['exclusive_chance']*100)}%") + print() + + print("## 박스 간 가성비 비교 (가격당 가중 기댓값)") + print(render_box_comparison()) + print() + + for box in boxes: + args.box = box + print(f"## {box} 박스 분포 ({args.pulls}연차 × {args.trials:,}회 시도)") + mc = monte_carlo(box, args.pulls, args.trials, args.seed) + print(render_distribution(mc)) + print(f"- 해석적 P(Legendary 0): {prob_zero_legendary_analytic(box, args.pulls)*100:.2f}%") + print(f"- 박스 1회 기댓값(가중): {expected_value(box):.2f}") + print() + + # 신호는 box=bronze 기준 1회만 (가격/확률 정합성은 박스 무관 전체) + args.box = boxes[0] + print("## 위험 신호 표") + signals = evaluate_signals(args) + print(render_signals(signals)) + print() + + print("## 가정 / 한계") + print("- 천장(pity) 미구현 가정 — 실제 코드와 일치") + print("- 곤충 풀 균등 무작위 가정 (가중치 데이터 미확인)") + print("- IV 미적용 — 곤충별 스탯 편차 무시") + print(f"- random.seed={args.seed} (--seed로 변경 가능)") + + fail_count = sum(1 for s in signals if s[3] == "FAIL") + return 1 if fail_count > 0 else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/scripts/progression_sim.py b/.claude/scripts/progression_sim.py new file mode 100644 index 0000000..b3ed738 --- /dev/null +++ b/.claude/scripts/progression_sim.py @@ -0,0 +1,236 @@ +"""Lv1->50 진행 곡선 시뮬레이션. 캔디/EXP 누적, 전투 횟수 추정, 비관적 위험 신호 점검.""" +import argparse +import sys + +# Windows cp949 환경에서 유니코드 출력 보장 +if hasattr(sys.stdout, "reconfigure") and sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined] + +# === 임계값 (디자이너가 조정 가능) === +# 근거: 평균 전투 30초 가정 시 4000전투 = 33시간. 이 이상은 그라인딩 한계로 간주. +THRESHOLD_BATTLES_LV50_FAIL = 4000 # 캔디만 경로에서 Lv50 도달 4000+ 전투 = FAIL +# 근거: 후반 곡선이 초반 대비 10배 이상이면 데드존(이탈 구간) — Lv1~20과 Lv35~50 평균 EXP 비교. +THRESHOLD_DEADZONE_RATIO_WARN = 10.0 # Lv35-50 평균 EXP / Lv1-20 평균 EXP > 10x = WARN +# 근거: RARITY_MULT(Common=1.0 vs Legendary=2.8) 자체가 2.8배 격차 — 진행속도 200% 초과 시 P2W 압박. +THRESHOLD_RARITY_GAP_WARN = 2.0 # Common vs Legendary 진행속도 격차 200%+ = WARN +# 근거: 5% 미만은 신규 사용자 진입 인센티브 부족. 현재 1.3%(261/500 캔디+EXP) 알려진 결함. +THRESHOLD_TUTORIAL_RATIO_WARN = 0.05 # 튜토리얼 보상 / 전체 누적 < 5% = WARN +# 근거: 주 5시간 캐주얼 가정 시 80h = 4개월. 이 이상이면 콘텐츠 소진 후 이탈. +THRESHOLD_PLAY_HOURS_WARN = 80.0 # Lv50 도달 추정 플레이 시간 80h+ = WARN +# 근거: 6마리 팀 평균 Lv30 도달 시 캔디 비용 합산 — 100k 초과는 캔디 인플레이션 신호. +THRESHOLD_TEAM_CANDY_FAIL = 100000 # 팀 동시 캔디 비용 100k+ = FAIL + +# === 정본 파라미터 (코드와 동기화 필요) === +# Assets/Scripts/Data/InsectLevelCurve.cs +MAX_LEVEL = 50 +BASE_XP = 20 +XP_GROWTH = 1.12 +BASE_CANDY = 4 +CANDY_GROWTH = 1.14 + +# Assets/Scripts/Data/InsectRewardCalculator.cs +RARITY_MULT = {"Common": 1.0, "Uncommon": 1.2, "Rare": 1.5, "Epic": 2.0, "Legendary": 2.8} + +# Assets/Scripts/Battle/RaidBattleController.cs:239-248 +RAID_MULT = 3.0 + +# Assets/Scripts/Core/TutorialQuestManager.cs:107-284 +TUTORIAL_CANDY_TOTAL = 261 +TUTORIAL_EXP_TOTAL = 500 + + +def xp_to_next_level(level: int) -> int: + """Lv->Lv+1 필요 EXP. InsectLevelCurve.cs와 동일 공식.""" + return max(1, int(BASE_XP * (XP_GROWTH ** (level - 1)))) + + +def candy_cost_for_level(level: int) -> int: + """Lv->Lv+1 필요 캔디. InsectLevelCurve.cs와 동일 공식.""" + return max(1, int(BASE_CANDY * (CANDY_GROWTH ** (level - 1)))) + + +def total_xp_to_level(target: int) -> int: + return sum(xp_to_next_level(L) for L in range(1, target)) + + +def total_candy_to_level(target: int) -> int: + return sum(candy_cost_for_level(L) for L in range(1, target)) + + +def battles_required(target_level: int, rarity: str, base_xp_per_kill: float = 8.0) -> int: + """경험치만 경로에서 Lv50까지 필요한 전투 수.""" + total_xp = total_xp_to_level(target_level) + avg_xp = base_xp_per_kill * RARITY_MULT.get(rarity, 1.0) + return int(total_xp / avg_xp + 0.5) + + +def candy_battles_required(target_level: int, rarity: str, base_candy_per_kill: float = 3.0) -> int: + """캔디만 경로에서 Lv50까지 필요한 전투 수.""" + total_candy = total_candy_to_level(target_level) + avg_candy = base_candy_per_kill * RARITY_MULT.get(rarity, 1.0) + return int(total_candy / avg_candy + 0.5) + + +def deadzone_ratio(target: int) -> float: + """후반(Lv35+) EXP 평균 / 초반(Lv1-20) EXP 평균.""" + early = [xp_to_next_level(L) for L in range(1, min(21, target))] + late = [xp_to_next_level(L) for L in range(35, target)] + if not early or not late: + return 0.0 + return (sum(late) / len(late)) / (sum(early) / len(early)) + + +def rarity_gap(target: int, base_xp_per_kill: float) -> float: + """Common 대비 Legendary 진행 속도 비율 (배수).""" + common = battles_required(target, "Common", base_xp_per_kill) + legendary = battles_required(target, "Legendary", base_xp_per_kill) + if legendary == 0: + return 0.0 + return common / legendary + + +def tutorial_share(target: int) -> float: + """튜토리얼 보상이 전체 누적 보상에서 차지하는 비율 (캔디 기준).""" + total = total_candy_to_level(target) + if total == 0: + return 0.0 + return TUTORIAL_CANDY_TOTAL / total + + +def render_curve_table(target: int) -> str: + out = ["| Lv | XP/Lv | Candy/Lv | XP누적 | Candy누적 |", + "|----|------:|---------:|-------:|----------:|"] + cum_xp = 0 + cum_candy = 0 + checkpoints = [1, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50] + for L in range(1, target + 1): + cum_xp += xp_to_next_level(L) if L < target else 0 + cum_candy += candy_cost_for_level(L) if L < target else 0 + if L in checkpoints: + out.append(f"| {L} | {xp_to_next_level(L):,} | {candy_cost_for_level(L):,} | {cum_xp:,} | {cum_candy:,} |") + return "\n".join(out) + + +def evaluate_signals(args) -> list: + """위험 신호 표 생성. 각 항목: (이름, 임계값, 측정값, 판정)""" + signals = [] + + # 1. 캔디만 경로 전투 수 + candy_battles = candy_battles_required(args.target_level, args.rarity, args.base_candy) + judge1 = "FAIL" if candy_battles >= THRESHOLD_BATTLES_LV50_FAIL else "PASS" + signals.append(("캔디만 경로 전투 수", f"< {THRESHOLD_BATTLES_LV50_FAIL:,}", + f"{candy_battles:,}", judge1)) + + # 2. 데드존 비율 + dz = deadzone_ratio(args.target_level) + judge2 = "WARN" if dz > THRESHOLD_DEADZONE_RATIO_WARN else "PASS" + signals.append(("Lv35-50 / Lv1-20 EXP 평균비", f"<= {THRESHOLD_DEADZONE_RATIO_WARN:.1f}x", + f"{dz:.1f}x", judge2)) + + # 3. 레어도 격차 + gap = rarity_gap(args.target_level, args.base_xp) + judge3 = "WARN" if gap >= THRESHOLD_RARITY_GAP_WARN else "PASS" + signals.append(("Common vs Legendary 진행속도 격차", f"< {THRESHOLD_RARITY_GAP_WARN:.1f}x", + f"{gap:.2f}x", judge3)) + + # 4. 튜토리얼 비중 + ts = tutorial_share(args.target_level) + judge4 = "WARN" if ts < THRESHOLD_TUTORIAL_RATIO_WARN else "PASS" + signals.append(("튜토리얼 보상 / 전체 누적", f">= {THRESHOLD_TUTORIAL_RATIO_WARN*100:.0f}%", + f"{ts*100:.1f}%", judge4)) + + # 5. 훈련 EXP 기여 (코드 정본: 0) + signals.append(("훈련 EXP 기여", "> 0", "0 (캔디만 소비)", "WARN")) + + # 6. 추정 플레이 시간 (avg-battle-sec 반영) + xp_battles = battles_required(args.target_level, args.rarity, args.base_xp) + est_hours = xp_battles * args.avg_battle_sec / 3600 + judge = "WARN" if est_hours >= THRESHOLD_PLAY_HOURS_WARN else "PASS" + signals.append((f"추정 플레이 시간 (Lv{args.target_level}, {args.avg_battle_sec}s/전투)", + f"< {THRESHOLD_PLAY_HOURS_WARN:.0f}h", + f"{est_hours:.1f}h", judge)) + + # 7. 팀 동시 캔디 비용 (team-size 반영) + total_candy = total_candy_to_level(args.target_level) + team_total = total_candy * args.team_size + judge = "FAIL" if team_total >= THRESHOLD_TEAM_CANDY_FAIL else "PASS" + signals.append((f"팀 {args.team_size}마리 동시 캔디 비용", + f"< {THRESHOLD_TEAM_CANDY_FAIL:,}", + f"{team_total:,}", judge)) + + return signals + + +def render_signals(signals: list) -> str: + out = ["| 항목 | 임계값 | 측정값 | 판정 |", + "|------|--------|--------|------|"] + for name, threshold, value, judge in signals: + out.append(f"| {name} | {threshold} | {value} | **{judge}** |") + fail_n = sum(1 for s in signals if s[3] == "FAIL") + warn_n = sum(1 for s in signals if s[3] == "WARN") + pass_n = sum(1 for s in signals if s[3] == "PASS") + out.append("") + out.append(f"요약: **{fail_n} FAIL** / {warn_n} WARN / {pass_n} PASS") + if fail_n > 0: + out.append("→ FAIL 1건 이상. PASS 보고 금지. Phase 4 권장사항 적용 필요.") + return "\n".join(out) + + +def main(): + p = argparse.ArgumentParser(description="Lv1->50 진행 곡선 시뮬") + p.add_argument("--target-level", type=int, default=50, help="목표 곤충 레벨 (기본 50)") + p.add_argument("--rarity", default="Common", choices=list(RARITY_MULT.keys())) + p.add_argument("--base-xp", type=float, default=8.0, + help="기본 곤충 1마리당 EXP (배율 적용 전, 기본 8)") + p.add_argument("--base-candy", type=float, default=3.0, + help="기본 곤충 1마리당 캔디 (배율 적용 전, 기본 3)") + p.add_argument("--avg-battle-sec", type=float, default=30.0, + help="평균 전투 소요 시간 초 (기본 30)") + p.add_argument("--team-size", type=int, default=6, help="동시 육성 팀 크기 (기본 6)") + args = p.parse_args() + + print(f"# progression-sim — Lv1 → Lv{args.target_level} ({args.rarity})\n") + print("## 입력 파라미터") + print(f"- target_level: {args.target_level}") + print(f"- rarity: {args.rarity} (보상 배율 {RARITY_MULT[args.rarity]}x)") + print(f"- base_xp_per_kill: {args.base_xp} / base_candy_per_kill: {args.base_candy}") + print(f"- avg_battle_sec: {args.avg_battle_sec}s / team_size: {args.team_size}") + print() + + print("## 누적 곡선 (체크포인트)") + print(render_curve_table(args.target_level)) + print() + + total_xp = total_xp_to_level(args.target_level) + total_candy = total_candy_to_level(args.target_level) + xp_battles = battles_required(args.target_level, args.rarity, args.base_xp) + candy_battles = candy_battles_required(args.target_level, args.rarity, args.base_candy) + est_hours = xp_battles * args.avg_battle_sec / 3600 + + print("## 핵심 지표") + print(f"- Lv{args.target_level} 도달 총 EXP 필요: **{total_xp:,}**") + print(f"- Lv{args.target_level} 도달 총 캔디 필요: **{total_candy:,}**") + print(f"- EXP 경로 필요 전투 수: **{xp_battles:,}회**") + print(f"- 캔디 경로 필요 전투 수: **{candy_battles:,}회**") + print(f"- 추정 플레이 시간 (EXP 경로): **{est_hours:.1f}h**") + print(f"- 팀 {args.team_size}마리 동시 캔디 비용: **{total_candy * args.team_size:,}** 캔디") + print() + + print("## 위험 신호 표") + signals = evaluate_signals(args) + print(render_signals(signals)) + print() + + print("## 가정 / 한계") + print("- IV(개체값) 미적용 — 실제 전투 시간 ±15% 편차") + print(f"- 평균 전투 시간 {args.avg_battle_sec}s 가정 (사용자 입력 의존)") + print("- 사용자 행동 균등 가정 (실제는 초반 폭주 후반 정체)") + print("- 이벤트/시즌 보상 미반영") + print("- 텔레메트리 수집 후 재검증 필요") + + fail_count = sum(1 for s in signals if s[3] == "FAIL") + return 1 if fail_count > 0 else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/scripts/verify_coverage.py b/.claude/scripts/verify_coverage.py index f49f917..bcb1e04 100644 --- a/.claude/scripts/verify_coverage.py +++ b/.claude/scripts/verify_coverage.py @@ -1,5 +1,10 @@ """에이전트 커버리지 재검증""" -import glob +import glob, sys, io + +try: + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') +except Exception: + pass agents = { "architect": [ @@ -50,6 +55,7 @@ "Assets/Scripts/UI/CaptureChoiceUI.cs", ], "data-architect": [ + "Assets/Scripts/Core/RegionDefinitions.cs", "Assets/Scripts/Data/InsectData.cs", "Assets/Scripts/Data/InsectDatabase.cs", "Assets/Scripts/Data/InsectSkill.cs", @@ -124,9 +130,11 @@ "Assets/Scripts/UI/QuickAccessBarUI.cs", "Assets/Scripts/UI/TutorialQuestUI.cs", "Assets/Scripts/UI/WorldLobbyUI.cs", + "Assets/Scripts/UI/CharacterPortraitRenderer.cs", "Assets/Scripts/UI/UIHelper.cs", "Assets/Scripts/UI/UITheme.cs", "Assets/Scripts/UI/UITween.cs", + "Assets/Scripts/UI/ModalUIRegistry.cs", "Assets/Scripts/Core/PlayerCurrencyUIController.cs", "Assets/Scripts/Core/PlayerProgressUIController.cs", "Assets/Scripts/Core/PlayerInsectLevelUpUIController.cs", @@ -142,13 +150,19 @@ "Assets/Scripts/UI/BattleScreenUI.cs", "Assets/Scripts/UI/RaidBattleUI.cs", "Assets/Scripts/Data/ItemRarityPalette.cs", + "Assets/Scripts/Core/SubAreaEnvironment.cs", + "Assets/Scripts/Core/WorldTerrainBuilder.cs", + "Assets/Scripts/Core/SubAreaWorldBuilder.cs", + "Assets/Scripts/Core/RegionTerrainBuilder.cs", "Assets/Scripts/Core/ProceduralAudioGenerator.cs", "Assets/Scripts/Core/AudioManager.cs", + "Assets/Scripts/Core/UIAudioBinder.cs", "Assets/Scripts/Core/CharacterOutfitManager.cs", "Assets/Scripts/Core/OutfitBonusProvider.cs", "Assets/Scripts/Core/CameraFollower.cs", "Assets/Scripts/Core/GameplayTuningApplier.cs", "Assets/Scripts/Core/GameplayTuningProfile.cs", + "Assets/Scripts/Core/PlayerVisualBuilder.cs", "Assets/Scripts/Dex/RarityIconProvider.cs", ], } diff --git a/.claude/settings.json b/.claude/settings.json index cba9661..7b6848a 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,7 +4,7 @@ "UNITY_EDITOR_PATH": "C:/Program Files/Unity/Hub/Editor/6000.3.10f1/Editor/Unity.exe" }, "permissions": { - "defaultMode": "dontAsk", + "defaultMode": "acceptEdits", "allow": [ "Read", "Edit", @@ -12,6 +12,40 @@ "Glob", "Grep", "Bash", + "Bash(python:*)", + "Bash(python3:*)", + "Bash(ls:*)", + "Bash(mkdir:*)", + "Bash(mv:*)", + "Bash(cp:*)", + "Bash(cat:*)", + "Bash(echo:*)", + "Bash(find:*)", + "Bash(wc:*)", + "Bash(grep:*)", + "Bash(rg:*)", + "Bash(which:*)", + "Bash(where:*)", + "Bash(git:*)", + "Bash(gh:*)", + "Bash(choco:*)", + "Bash(powershell:*)", + "Bash(npm:*)", + "Bash(node:*)", + "Bash(dotnet:*)", + "Bash(curl:*)", + "Bash(head:*)", + "Bash(tail:*)", + "Bash(sed:*)", + "Bash(awk:*)", + "Bash(sort:*)", + "Bash(uniq:*)", + "Bash(diff:*)", + "Bash(test:*)", + "Bash([ :*)", + "Bash(true)", + "Bash(false)", + "Bash(export:*)", "Agent", "WebFetch", "WebSearch", @@ -46,7 +80,7 @@ "hooks": [ { "type": "command", - "command": "python .claude/hooks/warn_monolith.py", + "command": "python -X utf8 .claude/hooks/warn_monolith.py", "timeout": 5, "statusMessage": "모놀리스 파일 확인 중..." } @@ -59,9 +93,49 @@ "hooks": [ { "type": "command", - "command": "python .claude/hooks/check_namespace.py", + "command": "python -X utf8 .claude/hooks/check_namespace.py", "timeout": 5, "statusMessage": "C# 네임스페이스 검증 중..." + }, + { + "type": "command", + "command": "python -X utf8 .claude/hooks/auto_verify_trigger.py", + "timeout": 5, + "statusMessage": "변경 크기 분석 중..." + }, + { + "type": "command", + "command": "python -X utf8 .claude/hooks/quick_compile_check.py", + "timeout": 5, + "statusMessage": "빠른 컴파일 점검 중..." + }, + { + "type": "command", + "command": "python -X utf8 .claude/hooks/scan_static_patterns.py", + "timeout": 5, + "statusMessage": "정적 회귀 패턴 스캔 중..." + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python -X utf8 .claude/hooks/audit_reminder.py", + "timeout": 5 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python -X utf8 .claude/hooks/audit_flow_inject.py", + "timeout": 5 } ] } diff --git a/.claude/skills/add-item.md b/.claude/skills/add-item.md new file mode 100644 index 0000000..1dbd3a0 --- /dev/null +++ b/.claude/skills/add-item.md @@ -0,0 +1,108 @@ +--- +name: add-item +description: 새 아이템을 추가하고 8개 등록 지점 + 4경로 획득 가능성을 강제 검증합니다 +user_invocable: true +args: " " +--- + +# 새 아이템 추가 + +ItemData SO 생성 + 데이터베이스 등록 + 효과 분기 + 4가지 획득 경로 중 최소 1개 보장. +**원칙: grep 결과 0건이 하나라도 있으면 PASS 보고 금지. 4경로 합계 0이면 FAIL.** + +## Phase 1: 필요 정보 + +| 필드 | 설명 | 예시 | +|------|------|------| +| itemId | 고유 ID (영문 소문자) | `incense_legendary` | +| itemType | capture(미니게임)/consumable(보너스)/cash(샵 판매) | `capture` | +| rarity | ItemRarity enum | Common/Uncommon/Rare/Epic/Legendary | +| displayName | 한글 표시명 | "전설의 향" | +| description | 설명 | "..." | +| themeColor | 필드 드롭 색상 (capture 한정) | new Color(1, 0.7, 0.2) | +| 효과 매개변수 | itemType별 다름 (Phase 2 참조) | — | + +## Phase 2: itemType별 자동화 작업 + +### capture (포획 미니게임 아이템) +필드: `speedMultiplier`, `zoneSizeMultiplier`, `timeLimitMultiplier`, `captureBonus`, `spawnWeight` +등록 위치: `Assets/Scripts/Core/PlaySceneBootstrap.cs:2953-2994` `CreateCaptureItems()` 배열 끝에 `CaptureItemData` 추가. + +### consumable (글로벌 효과 아이템) +필드: `captureChanceBonus`, `expMultiplier`, `candyMultiplier`, `rareSpawnMultiplier`, `durationSeconds` +등록 위치: ItemData SO + ItemDatabase.items 추가. +효과 처리: `Assets/Scripts/Core/ItemEffectManager.cs` getter 메서드는 분기 없음 — 새 효과 타입이면 ItemData에 필드 추가 + getter 추가 + 사용처에서 호출. + +### cash (캐시샵 판매 아이템) +등록 위치: `Assets/Scripts/Core/CashShopManager.cs:44-65` `InitializeShopItems()` 배열에 `CashShopItem` 추가. +필드: `itemId = "shop_xxx"`, `rewardItemId = 실제 itemId`, `category`, `gemPrice`, `rewardCount`. + +## Phase 3: 8개 등록 지점 (비관적 체크리스트) + +| # | 등록 지점 | itemType 적용 | 누락 시 증상 | grep 검증 | +|---|---|---|---|---| +| 1 | ItemData SO + ItemDatabase 등록 | 모두 | `FindById()` null → 효과 발동 안 함 | `Grep "" Assets/Scripts/Data/ItemDatabase.cs` (Inspector 직렬화는 안내만) | +| 2 | `PlaySceneBootstrap.cs:2953-2994` CreateCaptureItems() | capture | 미니게임 채집망 종류로 안 나옴 | `Grep "itemId = \"\"" Assets/Scripts/Core/PlaySceneBootstrap.cs` | +| 3 | `CashShopManager.cs:44-65` InitializeShopItems() | cash | 캐시샵에서 구매 불가 | `Grep "" Assets/Scripts/Core/CashShopManager.cs` | +| 4 | `PlaySceneBootstrap.cs:226-231` 초기 인벤토리 지급 | 선택 | 신규 유저 기본 지급 누락 | `Grep "AddItem.*\"\"" Assets/Scripts/Core/PlaySceneBootstrap.cs` | +| 5 | `ShopUIController.cs:15-16` itemIds[] / prices[] | 일반 샵 | 샵 슬롯에 안 보임 | **Inspector 직렬화 — 텍스트 안내** (아래 참조) | +| 6 | ItemData.rarityIcon 지정 | 모두 | UI 아이콘 회색 박스 | **Inspector 직렬화 — 텍스트 안내** | +| 7 | ItemEffectManager 새 효과 분기 | consumable (신규 효과 시) | 사용해도 아무 일 없음 | `Grep "\|new field name" Assets/Scripts/Core/ItemEffectManager.cs` | +| 8 | `CaptureMinigameController.cs:47-71` StartMinigame 매개변수 | capture (신규 효과 시) | 미니게임 효과 미적용 | `Grep "\|item\." Assets/Scripts/Capture/CaptureMinigameController.cs` | + +⚠️ **Inspector 직렬화 필드 (#5, #6)**: `.prefab`/`.unity` grep은 false negative 위험으로 미실행. 다음 텍스트 안내로 마무리: +- `Unity Editor → Project 창 → ShopUIController prefab 검색 → Inspector의 itemIds 배열에 "" 추가, prices 배열에 가격 입력` +- `Unity Editor → ItemData SO 선택 → Inspector의 rarityIcon 슬롯에 스프라이트 드래그` + +## Phase 4: 4경로 획득 가능성 (FAIL 조건) + +아이템이 게임에 존재해도 유저가 획득할 수 없으면 죽은 콘텐츠. 4경로 중 최소 1개 매칭 필수: + +| 경로 | grep 명령 | 통과 조건 | +|---|---|---| +| 캐시샵 | `Grep "" Assets/Scripts/Core/CashShopManager.cs` | ≥ 1 | +| 가챠 | `Grep "" Assets/Scripts/Core/GachaBoxManager.cs` | ≥ 1 | +| 전투/포획/퀘스트 보상 | `Grep "" Assets/Scripts/Core` (Reward/Loot 키워드 동반) | ≥ 1 | +| 초기 지급 | `Grep "AddItem.*\"\"" Assets/Scripts/Core/PlaySceneBootstrap.cs` | ≥ 1 | + +**4경로 합계 0건이면 FAIL**: +> "이 아이템은 게임 내에서 획득할 수 없습니다. 최소 1개 경로를 추가하세요. (예: CashShopManager.InitializeShopItems()에 등록)" + +## Phase 5: 에이전트 위임 가이드 + +| 영역 | 주담당 | 부수 | +|---|---|---| +| ItemData SO / ItemDatabase 데이터 모델 | data-architect | — | +| ItemEffectManager / CashShopManager 로직 | game-designer | — | +| CaptureItemData / CaptureMinigameController | capture-dev | — | +| ShopUIController / 인벤토리 UI 표시 | ui-dev | — | +| rarityIcon 스프라이트 / 시각 효과 | visual-dev | — | + +`.claude/rules/agent-coordination.md` 갱신 — `ItemData/ItemDatabase` 행 신규 추가 (Phase D에서 일괄 처리). + +## Phase 6: 세이브 호환성 경고 + +⚠️ **기존 itemId 변경 금지**. `PlayerItemRecord.itemId`가 JSON에 문자열로 저장. 변경 시 기존 유저 인벤토리에서 해당 아이템 데이터 매칭 실패 (`PlayerItemInventory.GetCount()`는 0 반환하지만 UI에 null 바인딩 발생 가능). + +⚠️ **삭제된 itemId 잔존**: 세이브에는 기록이 남아있고 ItemDatabase에는 없으면 인벤토리 UI에서 null 참조. 삭제 대신 `obsoleteItem = true` 같은 마이그레이션 권장. + +마이그레이션 필요 시 → `/save-migration` 호출. + +## Phase 7: 완료 후 /verify 강제 호출 + +``` +/verify +``` +특히 다음 항목 직격: +- 항목 5 (세이브 호환성: PlayerItemSave 영향) +- 항목 8 (데이터 매칭 무결성: itemId↔Database, itemType↔효과 분기) + +## 체크리스트 요약 + +- [ ] Phase 1 정보 수집 + itemType 분류 +- [ ] Phase 2 itemType별 등록 위치 식별 +- [ ] Phase 3 8개 grep 모두 통과 (Inspector 항목은 사용자가 Editor 작업 확인) +- [ ] Phase 4 **4경로 중 최소 1개 매칭** (FAIL 절대 회피) +- [ ] Phase 5 적절한 에이전트 위임 +- [ ] Phase 6 기존 itemId 변경 없음 +- [ ] Phase 7 `/verify` 호출 완료 diff --git a/.claude/skills/add-region.md b/.claude/skills/add-region.md new file mode 100644 index 0000000..ccc7ec7 --- /dev/null +++ b/.claude/skills/add-region.md @@ -0,0 +1,170 @@ +--- +name: add-region +description: 새 리전을 추가하고 19개 항목(필수 8 + 시나리오 6 + 선택 5) 누락을 강제 검증합니다 +user_invocable: true +args: " " +--- + +# 새 리전 추가 + +리전 1개 추가는 코드 9곳, 데이터 6곳, 비주얼 1곳을 동시에 손대야 하는 가장 복잡한 콘텐츠 작업. +**원칙: 필수 8개 grep 중 0건이 하나라도 있으면 PASS 보고 금지. 비관적 시나리오 A~F 모두 사전 점검.** + +## Phase 1: 필요 정보 + +| 필드 | 설명 | 예시 | +|------|------|------| +| regionId | 고유 ID (영문 소문자) | `tundra` | +| displayName | 한글 표시명 | "툰드라" | +| description | 설명 | "..." | +| themeColor | 맵 UI 색상 | new Color(0.7, 0.85, 1.0) | +| centerPosition | 월드 중심 좌표 | new Vector3(-150, 0, 200) | +| radius | 리전 범위 (40~55 권장) | 50f | +| requiredLevel | 진입 필요 레벨 | 1~50 | +| insectIds[] | 출현 곤충 ID 목록 (≥1 필수) | { "ice_beetle", "frost_moth" } | +| guardianInsectId | 수문장 곤충 ID (meadow 제외 필수) | "ice_titan" | +| guardianLevel | 수문장 레벨 | 30 | +| subAreas[] | 서브에리어 배열 (선택) | { tundra_cave, tundra_glacier } | +| environmentType | 서브에리어 환경 (기존 11종 권장) | cave/deep_forest/underwater/pond/fog/reeds/peak/flower_maze/greenhouse/temple/underground | + +⚠️ **environmentType은 기존 11종에서 선택 권장**. 신규 추가 시 `SubAreaWorldBuilder.cs:81-115` + `SubAreaEnvironment.GetProfileForType()` 양쪽에 분기 추가 필요. + +## Phase 2: 자동화 작업 + +### 2-1. RegionData 코드 스니펫 (CreateExpandedRegions 끝에 추가) +```csharp +// PlaySceneBootstrap.cs CreateExpandedRegions() 끝 +new RegionData { + regionId = "", + displayName = "", + centerPosition = new Vector3(...), + radius = 50f, + requiredLevel = , + insectIds = new[] { ... }, + guardianInsectId = "...", + guardianLevel = ..., + subAreas = new SubAreaData[] { ... }, +} +``` + +### 2-2. RegionManager switch 분기 추가 위치 +- `RegionManager.cs:170-181` GetNextRegionId()에 `case "": return "";` +- `RegionManager.cs:183-195` GetPreviousRegionId()에 `case "": return "";` + +### 2-3. BuildXxxTerrain() 시그니처 +```csharp +// RegionTerrainBuilder.cs +private void BuildTerrain() +{ + // 기존 BuildMeadowTerrain / BuildPondTerrain 패턴 참조 + // 실제 지형 디자인은 visual-dev 에이전트 위임 +} +``` + +## Phase 3: 필수 8개 등록 지점 + +| # | 등록 지점 | 누락 시 증상 | grep 검증 | +|---|---|---|---| +| 1 | `PlaySceneBootstrap.cs` CreateExpandedRegions() RegionData 추가 | 리전 자체가 존재 안 함 | `Grep "regionId = \"\"" Assets/Scripts/Core/PlaySceneBootstrap.cs` | +| 2 | RegionData 7필드 (insectIds, centerPosition, radius, requiredLevel, guardianInsectId, subAreas, environmentType) | 빈 맵 / 가디언 없음 / 잘못된 좌표 | RegionData 인스턴스 필드 사람 검토 | +| 3 | `RegionManager.cs:170-181` GetNextRegionId() switch | 진입 후 다음 리전 해금 안 됨 | `Grep "case .*return \"\"" Assets/Scripts/Core/RegionManager.cs` | +| 4 | `RegionManager.cs:183-195` GetPreviousRegionId() switch | 가디언 좌표 계산 오류(시나리오 E) | `Grep "case \"\":" Assets/Scripts/Core/RegionManager.cs` | +| 5 | `RegionTerrainBuilder.cs:13-30` BuildAllRegions switch + Build`<리전>`Terrain() 메서드 | 지형 안 그려짐 → 평지 | `Grep "case \"\":\|BuildTerrain" Assets/Scripts/Core/RegionTerrainBuilder.cs` | +| 6 | SpawnPoint.regionId / regionInsectIds 할당 | 곤충 스폰 0마리 | `Grep "" Assets/Scripts/Spawning Assets/Scripts/Core/PlaySceneBootstrap.cs` | +| 7 | `RegionMapUI.cs:554-557` connections + `:670-683` GetRegionSymbol switch | 미니맵 길/심볼 표시 안 됨 → "???" | `Grep -c "" Assets/Scripts/UI/RegionMapUI.cs` (≥ 2 요구) | +| 8 | AudioManager.PlayBGMForRegion 리전별 BGM 키 | 무음 또는 이전 BGM 잔류 | `Grep "" Assets/Scripts/Core/AudioManager.cs` | + +## Phase 4: 비관적 시나리오 6개 (사전 점검) + +각 시나리오는 위 필수 8개의 부분집합을 누락했을 때 발생하는 구체적 증상. 매 추가 시 6개 모두 grep 점검: + +| 시나리오 | 증상 | 검증 grep | +|---|---|---| +| **A. 스폰 0마리** | 진입했는데 잡을 곤충 없음 | `Grep "insectIds = new\[\]" PlaySceneBootstrap.cs` 영역에서 `` insectIds 길이 ≥ 1 + 모든 ID가 InsectDatabase에 존재 | +| **B. 진입 불가 (dead-end)** | NextRegionId 무한 루프 또는 도달 불가 | `Grep "return \"\"" Assets/Scripts/Core/RegionManager.cs` (1+ 있어야 어딘가에서 해금됨) | +| **C. 빈 맵** | 시각적으로 황무지 | `Grep "case \"\":\|BuildTerrain" Assets/Scripts/Core/RegionTerrainBuilder.cs` 모두 매칭 | +| **D. 서브에리어 깡통** | 서브에리어 진입했더니 빈 공간 | environmentType이 11종 중 하나여야 함. 신규 타입이면 `SubAreaWorldBuilder.cs:81-115` + `SubAreaEnvironment.cs:159-309`에 분기 추가됐는지 검증 | +| **E. 가디언 좌표 오류** | 가디언이 맵 원점(0,0,0)과 리전 중점 사이에 잘못 스폰 | GetPreviousRegionId()에 `` case 누락 시 `prevId == null` → fromCenter = Vector3.zero. Phase 3 #4 grep 통과 필수 | +| **F. 세이브 오염** | 기존 PlayerPrefs(`InsectGame.UnlockedRegions` / `InsectGame.DefeatedGuardians`) 깨짐 | **기존 regionId 변경 절대 금지**. 새 ID만 추가 | + +## Phase 5: 선택 5개 등록 지점 (gameplay 향상) + +| # | 항목 | 효과 | +|---|---|---| +| 9 | `TutorialQuestManager` 리전별 퀘스트 추가 | 신규 리전 진입 튜토리얼 | +| 10 | environmentType 신규 정의 (D 회피하려면 11종 사용 권장) | 새로운 시각/환경 효과 | +| 11 | `DexController` 리전별 도감 필터 | 도감에서 리전별 진행률 표시 | +| 12 | `BattleArenaController` 리전 환경 효과 | 리전별 배틀 배경 변경 | +| 13 | `InsectSpawner` 디스폰 거리 60m → 리전별 조정 | 넓은 리전에서 디스폰 조기 발생 방지 | + +## Phase 6: 자동 grep 검증 시퀀스 (예: regionId=`tundra`) + +```bash +# 필수 8개 +Grep "regionId = \"tundra\"" Assets/Scripts/Core/PlaySceneBootstrap.cs +Grep "case .*return \"tundra\"" Assets/Scripts/Core/RegionManager.cs +Grep "case \"tundra\":" Assets/Scripts/Core/RegionManager.cs +Grep "case \"tundra\":\|BuildTundraTerrain" Assets/Scripts/Core/RegionTerrainBuilder.cs +Grep "tundra" Assets/Scripts/Spawning Assets/Scripts/Core/PlaySceneBootstrap.cs +Grep -c "tundra" Assets/Scripts/UI/RegionMapUI.cs # ≥ 2 요구 +Grep "tundra" Assets/Scripts/Core/AudioManager.cs + +# 시나리오 A (스폰) +Grep "ice_beetle\|frost_moth" Assets/Scripts/Data Assets/Scripts/Core # insectIds 모두 존재 확인 + +# 시나리오 D (서브에리어) +Grep "environmentType = \"\"" Assets/Scripts/Core/PlaySceneBootstrap.cs # 11종에 포함되는지 + +# 시나리오 F (세이브) +Grep "tundra" "$env:APPDATA\..\LocalLow\\InsectGame" # PlayerPrefs 잔존 확인 (선택) +``` + +0건 매칭이 하나라도 있으면 → **FAIL 표 작성 + 누락 위치별 담당 에이전트 안내**. + +## Phase 7: 에이전트 위임 가이드 + +| 영역 | 주담당 | 부수 | +|---|---|---| +| RegionData / SubAreaData SO 데이터 모델 | data-architect | — | +| RegionManager 진행 로직 (switch 분기) | game-designer | — | +| RegionTerrainBuilder Build`<리전>`Terrain() 시각 디자인 | visual-dev | — | +| RegionMapUI connections / GetRegionSymbol | ui-dev | — | +| SpawnPoint.regionId 할당 + 곤충 풀 | capture-dev | — | +| AudioManager BGM 키 등록 | visual-dev | — | +| BattleArenaController 리전 환경 효과 (선택) | battle-dev | visual-dev | + +`.claude/rules/agent-coordination.md` 갱신 — `RegionData/RegionManager/RegionTerrainBuilder` 행 신규 추가 (Phase D에서 일괄). + +## Phase 8: 세이브 호환성 경고 + +⚠️ **기존 regionId 변경 금지**. 다음 PlayerPrefs 키가 쉼표 구분 문자열로 저장: +- `InsectGame.UnlockedRegions` ("meadow,pond,garden,...") +- `InsectGame.DefeatedGuardians` ("meadow,pond,...") + +기존 ID 변경 시 unlockedRegions HashSet에 매칭되는 RegionData가 없어 사실상 잠금 해제 무효. + +⚠️ **regionId 삭제 금지**: PlayerPrefs에 잔존하면 `GetRegionById(deletedId)` → null. 사용처 null 가드 확인. + +마이그레이션 필요 시 → `/save-migration` 호출. + +## Phase 9: 완료 후 /verify 강제 호출 + +``` +/verify +``` +특히 다음 항목 직격: +- 항목 4 (Bootstrap 등록: RegionData가 CreateExpandedRegions에 들어갔는지) +- 항목 5 (세이브 호환성: PlayerPrefs 영향) +- 항목 8 (데이터 매칭 무결성: regionId↔switch 5곳, insectIds↔InsectDatabase) + +## 체크리스트 요약 + +- [ ] Phase 1 정보 수집 + environmentType 11종에서 선택 +- [ ] Phase 2 RegionData 스니펫 + switch 분기 위치 + Build`<리전>`Terrain 시그니처 준비 +- [ ] Phase 3 필수 8개 grep 모두 통과 +- [ ] Phase 4 비관적 시나리오 A~F 모두 사전 점검 +- [ ] Phase 5 선택 5개 중 적용 항목 결정 +- [ ] Phase 6 grep 시퀀스 0건 매칭 0건 +- [ ] Phase 7 적절한 에이전트 위임 (특히 BuildXxxTerrain은 visual-dev) +- [ ] Phase 8 기존 regionId 변경/삭제 없음 +- [ ] Phase 9 `/verify` 호출 완료 diff --git a/.claude/skills/add-skill.md b/.claude/skills/add-skill.md new file mode 100644 index 0000000..4246941 --- /dev/null +++ b/.claude/skills/add-skill.md @@ -0,0 +1,124 @@ +--- +name: add-skill +description: 새 스킬을 InsectSkill DB에 추가하고 6개 등록 지점 누락을 강제 검증합니다 +user_invocable: true +args: " " +--- + +# 새 스킬 추가 + +InsectSkill SO를 생성하고 곤충 데이터에 연결한 뒤, 등록 누락을 grep으로 강제 점검합니다. +**원칙: grep 결과 0건이 하나라도 있으면 PASS 보고 금지.** + +## Phase 1: 필요 정보 + +| 필드 | 설명 | 예시 | +|------|------|------| +| skillId | 고유 ID (영문 소문자_스네이크) | `ember_strike` | +| displayName | 한글 표시명 | "불꽃 일격" | +| description | 도감/UI 설명 | "..." | +| element | InsectElement enum 값 | Bug/Poison/Water/Leaf/Wind/Electric/Earth/Light/Dark/Metal | +| power | 데미지 위력 (Damage 타입 시) | 10~80 | +| cooldownTurns | 쿨다운 턴 | 0~5 | +| effectType | SkillEffectType enum | Damage / BuffAttack / DebuffAttack | +| effectValue | 버프/디버프 배율 | 0.2~0.6 | +| effectDurationTurns | 효과 지속 턴 | 1~5 | + +밸런스 기준: 새 스킬 power가 기존 분포(15~60)에서 벗어나면 `/balance-sim battle` 실행 권장. + +## Phase 2: 자동화 작업 + +### 2-1. ScriptableObject 생성 +2가지 방식: +- **코드 헬퍼**: `PlaySceneBootstrap.CreateSkill(skillId, displayName, ...)` 패턴 사용 (런타임 생성) +- **에셋 SO**: Unity Editor → Create → InsectGame/Insect Skill → Resources/Skills/ 배치 + +`iconResourcePath`가 비어 있으면 `Resources/SkillIcons/{skillId}.png`를 자동 로드 (`InsectSkill.cs` 참조). + +### 2-2. effectType 신규 여부 자동 판정 +```bash +Grep -n "SkillEffectType\s*\{" Assets/Scripts/Data/InsectSkill.cs +``` +열거형 멤버 목록에 인자 effectType이 없으면 → **신규 effectType**. Phase 4의 5곳 분기 검증 강제 발동. + +### 2-3. skillId 중복 검사 +```bash +Grep -c "\"\"" Assets/Scripts +``` +2건 이상이면 의심. CreateSkill 호출 외 등장 위치를 모두 사람이 검토. + +## Phase 3: 6개 등록 지점 (비관적 체크리스트) + +| # | 등록 지점 | 누락 시 증상 | grep 검증 | +|---|---|---|---| +| A | InsectSkill SO 생성 (`PlaySceneBootstrap.CreateSkill`) | 게임 내 스킬 미존재 | `Grep "CreateSkill\(\"\"\|skillId = \"\"" Assets/Scripts` | +| B | InsectData.skills 또는 learnset 배치 | 어떤 곤충도 학습 불가 → UI 빈 슬롯 | `Grep "" Assets/Scripts/Data Assets/Scripts/Core` (B-2 외 1+ 매칭 필수) | +| C | skillId 중복 0건 | learnset HashSet에서 두 번째 무시 → 학습 안 됨 | `Grep -c "\"\"" Assets/Scripts` ≤ 의도된 횟수 | +| D | **새 effectType이면 5곳 switch 분기** (Phase 4 참조) | 데미지 0, 효과 무반응, UI 회색 | Phase 4 grep 시퀀스 | +| E | UI 효과 이름 문자열 (`BattleScreenUI`/`RaidBattleUI`/`TrainingUI`) | "Unknown" 또는 빈 문자열 | `Grep "GetSkillColor\|effectType ==" Assets/Scripts/UI` | +| F | TrainingMethod.skillPool (훈련 습득 시) | 훈련으로 영구 미해금 | `Grep "" Assets/Scripts/Data/TrainingMethod.cs Assets/Scripts/Core/TrainingManager.cs` | + +## Phase 4: 새 effectType 시 5곳 switch 분기 + +신규 effectType 감지 시, 자동 코드 생성하지 **않고** 다음 5곳의 정확한 위치만 출력합니다. +실제 분기 추가는 **battle-dev 에이전트**에 위임 (잘못된 데미지 공식 위험 회피). + +| # | 파일:라인 | 추가할 분기 | 누락 시 증상 | +|---|---|---|---| +| 1 | `Assets/Scripts/Battle/InsectBattleController.cs:213-235` ApplySkill() switch | 새 case | 1v1 default 분기로 빠져 의도와 다른 처리 | +| 2 | `Assets/Scripts/Battle/RaidBattleController.cs:113-142` UseSkill() if/else | 새 분기 | 레이드 보스 ATK DOWN 등으로 잘못 매칭 | +| 3 | `Assets/Scripts/UI/BattleScreenUI.cs:1665-1672, 3049-3055` 타입문자열 + GetSkillColor | 표시 문자열 + 색상 | 효과 이름 빈 문자열, 색상 회색 | +| 4 | `Assets/Scripts/Battle/BattleArenaController.cs:462-474` PlaySkillEffect() | 시각 이펙트 분기 | 이펙트 미재생 | +| 5 | `Assets/Scripts/UI/RaidBattleUI.cs:1504-1511, 2855-2861` + `Assets/Scripts/UI/TrainingUI.cs:344-345, 537-538, 569-575` | 표시 문자열/색상 | 레이드/훈련 UI 누락 | + +자동 검증 grep (예: `effectType=Heal`): +```bash +Grep "SkillEffectType\.Heal\|case Heal" Assets/Scripts/Battle/InsectBattleController.cs +Grep "SkillEffectType\.Heal" Assets/Scripts/Battle/RaidBattleController.cs +Grep "SkillEffectType\.Heal" Assets/Scripts/UI/BattleScreenUI.cs +Grep "SkillEffectType\.Heal" Assets/Scripts/Battle/BattleArenaController.cs +Grep "SkillEffectType\.Heal" Assets/Scripts/UI/RaidBattleUI.cs Assets/Scripts/UI/TrainingUI.cs +``` +5개 grep 중 0건이 하나라도 있으면 → **FAIL: "신규 effectType 분기 누락. battle-dev 에이전트 위임 필수."** + +## Phase 5: 에이전트 위임 가이드 + +`.claude/rules/agent-coordination.md` 표에 명시된 수정 경계 준수. + +| 영역 | 주담당 | 부수 | +|---|---|---| +| InsectSkill SO 데이터/직렬화 | data-architect | — | +| effectType 분기 로직 (5곳) | battle-dev | — | +| BattleScreenUI/RaidBattleUI 표시 문자열 | ui-dev | — | +| BattleArenaController 시각 이펙트 | visual-dev | — | +| power/cooldown 밸런스 검토 | game-designer | — | + +여러 에이전트 협업 시: 메인 모델이 파일별 경계에 따라 분리 위임. + +## Phase 6: 세이브 호환성 경고 + +⚠️ **기존 skillId 변경 금지**. PlayerInsectData.learnedSkillIds / equippedSkillIds가 JSON에 문자열로 저장되어 있어, 변경 시 모든 유저 곤충의 스킬이 사라집니다 (`ResolveSkill()` 실패 → null). + +⚠️ **SkillEffectType enum 정수 값 변경 금지**. 기존 값에 새 값을 끼워넣지 말고 enum 끝에만 추가. JsonUtility 직렬화는 정수 기반. + +마이그레이션 필요 시 → `/save-migration` 호출. + +## Phase 7: 완료 후 /verify 강제 호출 + +스킬 1개 추가 작업 완료 후 반드시: +``` +/verify +``` +사용자 메모리 룰(`feedback_post_impl_verify.md`)에 따라 8항목 검증 루프 자동 실행. 특히 다음 항목이 신규 스킬에 직격: +- 항목 5 (세이브 호환성) +- 항목 8 (데이터 매칭 무결성: skillId↔learnset, effectType↔switch) + +## 체크리스트 요약 + +- [ ] Phase 1 정보 수집 완료 +- [ ] Phase 2 ID 중복 0건, effectType 분류 확정 +- [ ] Phase 3 A~F 6항목 grep 모두 통과 +- [ ] Phase 4 (신규 effectType 시) 5곳 분기 추가됨 — battle-dev 위임 결과 확인 +- [ ] Phase 5 적절한 에이전트 위임 +- [ ] Phase 6 기존 ID/enum 값 변경 없음 +- [ ] Phase 7 `/verify` 호출 완료 diff --git a/.claude/skills/audit.md b/.claude/skills/audit.md new file mode 100644 index 0000000..c023aef --- /dev/null +++ b/.claude/skills/audit.md @@ -0,0 +1,136 @@ +--- +name: audit +description: 미검토 영역 1개 자동 탐색·개선. "문제점 분석 및 개선해" 수동 요청 부담 제거. +user_invocable: true +--- + +# Audit — 자동 영역 탐색·개선 + +`.claude/audit-progress.md`의 Uncovered 목록에서 다음 1개 영역을 자동으로 골라 P0/P1 후보를 탐색하고 자동 수정합니다. + +## 실행 순서 + +### Step 1. 진척 파일 읽기 + +``` +Read .claude/audit-progress.md +``` + +- `## Covered` 섹션 → 누적 처리 영역 목록 추출 (Explore 프롬프트 거짓양성 가드용) +- `## Uncovered` 섹션 → **첫 번째 항목**을 대상으로 선택 (우선순위 정렬은 파일 작성 시점에 이미 적용됨) + +대상이 비어있으면 사용자에게 "Uncovered 0개 — audit 완료" 알림 후 종료. + +### Step 2. Explore 위임 + +단일 Explore agent (subagent_type: Explore) 호출. 프롬프트에 반드시 포함: + +``` +점검 대상: +파일: Assets/Scripts/<해당 경로> + +이미 처리된 영역 (재보고 금지): + + +점검 항목: +1. OnGUI/Update 안 new GUIStyle/new Color/new Rect (매 프레임 할당) +2. OnGUI/Update 안 FindFirstObjectByType/GetComponent (캐싱 누락) +3. 이벤트 += 후 -= 짝 누락 (OnEnable/OnDisable) +4. Singleton.Instance 즉시 사용 (null 가드 누락) +5. 모놀리스이면 method 그룹 분해 후보 식별 +6. 데이터 모델 변경에 따른 세이브 호환성 +7. AutoWire 누락 / Bootstrap 등록 누락 + +거짓양성 보고 금지 — 실제 회귀/누락만. 보고 형식: 파일:라인 + 1줄 문제 + 1줄 수정 권고. 최대 P0/P1 3건. +700단어 이내. +``` + +### Step 3. 자동 처리 (P0/P1만) + +Explore 보고에서: +- **P0/P1**: Edit/Write로 즉시 수정. auto_verify_trigger.py가 자동 발화 → 8항목 점검 +- **P2**: 보고만 하고 사용자에게 결정 요청 ("P2 N건 발견 — 처리할까요?") +- **거짓양성**: 검증 후 보고에서 제외 + +각 수정 후 scan_static_patterns.py가 회귀 즉시 검출. 회귀 발생 시 추가 수정 1회 시도 후 보고. + +### Step 4. 진척 파일 갱신 + +처리 완료 후 `.claude/audit-progress.md`를 Edit: + +1. **Covered 섹션 끝에 추가**: + ``` + - [x] <영역명> (P0:N, P1:M, <오늘 날짜>) — <변경 요약 한 줄> + ``` + +2. **Uncovered 섹션에서 해당 줄 제거** + +3. **Round Log 끝에 한 줄 추가**: + ``` + - YYYY-MM-DD: <영역명> — P0:N, P1:M 처리. <자체 발견 회귀 K건> + ``` + +날짜는 시스템 reminder의 `currentDate` 값 사용. + +### Step 5. 결과 보고 + +라운드 결과 표준 보고 형식 (verify.md와 일관): + +``` +## /audit 결과 — <영역명> + +| # | 우선순위 | 이슈 | 파일 | 변경 | +|---|---------|------|------|------| +| 1 | P0/P1 | ... | ... | ... | + +### Explore 거짓양성 N건 (있으면) +### 자체 발견·즉시 수정 N건 (있으면) +### 다음 라운드 대상: +``` + +## 우선순위 정렬 (Uncovered) + +audit-progress.md의 Uncovered는 다음 기준으로 정렬되어 있음: + +1. **모놀리스 (2000줄+)** 최상위 — 회귀 위험 최대 +2. **사용자 직접 영향 시스템** 다음 — Battle/Capture/Cloud 등 +3. **데이터 모델 + Save** — 마이그레이션 위험 +4. **UI 컨트롤러** — OnGUI 회귀 검출 우선 +5. **인프라/유틸** — 최후 + +사용자가 특정 영역을 우선시키고 싶으면 Uncovered 항목 순서를 수동 편집 가능. + +## 거짓양성 차단 메커니즘 + +1. **Covered 자동 주입** — Explore 프롬프트에 누적 처리 영역 목록 포함. 같은 영역 재보고 차단. +2. **explore-standard.md 가드** — "거짓양성 보고 금지" 규칙 (이미 정의됨) +3. **scan_static_patterns hook** — Edit 후 즉시 새 회귀 검출. Explore가 미탐지한 패턴 보완. + +## 비-자동 트리거 모드 + +Auto Mode가 비활성이고 사용자가 결정을 보류하고 싶으면: +- `/audit dry-run` → Step 1~2만 실행 (보고만, 수정 안 함) +- 인자 무시하고 일반 실행 — `/audit` + +## 호출 예 + +``` +사용자: /audit +Claude: + Step 1. audit-progress.md 읽음 → Uncovered[0] = RaidBattleUI + Step 2. Explore 위임 (Covered 15개 자동 주입) + Step 3. P0:1 P1:2 발견 → 즉시 수정. 회귀 1건 자체 발견·수정 + Step 4. progress.md 갱신 (Covered에 RaidBattleUI 추가, Uncovered에서 제거) + Step 5. 보고 + "다음 라운드: BattleScreenUI 나머지" + +Stop hook 자동 발화: "audit 미검토 31개 — /audit으로 계속" +``` + +## 관련 자산 + +- `.claude/audit-progress.md` — 단일 진척 파일 +- `.claude/hooks/audit_reminder.py` — Stop reminder +- `.claude/hooks/scan_static_patterns.py` — 회귀 패턴 즉시 검출 +- `.claude/hooks/auto_verify_trigger.py` — 8항목 자동 권유 +- `.claude/skills/explore-standard.md` — Explore 가드 +- `.claude/skills/verify.md` — 8항목 검증 템플릿 diff --git a/.claude/skills/balance-sim.md b/.claude/skills/balance-sim.md index 84155fe..1755915 100644 --- a/.claude/skills/balance-sim.md +++ b/.claude/skills/balance-sim.md @@ -1,8 +1,8 @@ --- name: balance-sim -description: 배틀/포획 밸런스를 시뮬레이션합니다 +description: 배틀/포획/레이드 단발 시뮬 (한 시점의 수치) user_invocable: true -args: "<시스템> -- 예: battle, capture, raid, levelup" +args: "<시스템> -- 예: battle, capture, raid" --- # 밸런스 시뮬레이션 @@ -27,12 +27,20 @@ args: "<시스템> -- 예: battle, capture, raid, levelup" - 유나이트 게이지 충전 속도 - 생존 턴 수 추정 -### levelup (레벨업) -- 레벨별 필요 캔디/경험치 -- 보상 배율 적용 시 레벨업 소요 시간 +### levelup (이관됨) +레벨업/진행 시뮬은 `/progression-sim`이 더 정밀하게 다룹니다 (누적 곡선 + 데드존 + 레어도 격차 + 플레이시간 + 팀 비용 등 7개 위험 신호 자동 검출). + +**`/balance-sim levelup` 호출 시 동작**: 본 스킬은 레벨업 시뮬을 직접 실행하지 않고 `/progression-sim`으로 자동 위임합니다. 사용자에게 "레벨업/진행 시뮬은 /progression-sim 호출이 필요합니다. 인자 없이 실행하면 기본값(Lv50, Common)으로 동작합니다." 안내 후 종료. ## 절차 1. 현재 코드에서 관련 공식/상수를 읽습니다 2. Python 스크립트로 시뮬레이션을 실행합니다 3. 결과를 테이블/그래프(텍스트)로 출력합니다 4. 밸런스 이슈가 있으면 조정안을 제시합니다 + +## 장기 시뮬은 별도 스킬 사용 + +이 스킬은 **단일 시점의 수치**(전투 1회/포획 1회 등)에 집중합니다. 장기 진행/누적 분포가 필요하면: +- `/progression-sim` — Lv1→50 캔디·EXP 누적 곡선, 데드존 검출 +- `/economy-sim` — 통화 수입·지출 균형, 데드 화폐/병목/P2W 검출 +- `/gacha-sim` — 박스 몬테카를로(10000회) + UI-코드 정합성 자동 점검 diff --git a/.claude/skills/build-check.md b/.claude/skills/build-check.md index e1af684..e9f9975 100644 --- a/.claude/skills/build-check.md +++ b/.claude/skills/build-check.md @@ -1,25 +1,62 @@ --- name: build-check -description: C# 스크립트의 컴파일 오류를 검사합니다 +description: C# 스크립트의 컴파일 오류를 검사합니다 (정적 점검 + Unity CLI fallback) user_invocable: true --- # 빌드 검사 -C# 코드의 컴파일 오류를 검사합니다. +C# 코드의 컴파일 오류를 검사합니다. 두 가지 방식 — 빠른 정적 점검 + Unity CLI 정확 검증. -## 절차 +## 자동 점검 (이미 활성) -1. LSP 진단(csharp-lsp)을 활용하여 현재 열린 파일들의 에러를 확인합니다 -2. 에러가 있으면 파일명, 라인, 메시지를 정리하여 보고합니다 -3. 에러 원인을 분석하고 수정 방안을 제시합니다 +`.claude/hooks/quick_compile_check.py`가 PostToolUse hook으로 매 Edit/Write 후 자동 실행: +- 중괄호 매칭 (열림 vs 닫힘 카운트) +- 미선언 식별자 사용 의심 (`x +=` 같은 증감 좌변인데 클래스 필드/로컬 선언 없음) +- 흔한 using 누락 (`IEnumerator`, `List<>`, `Dictionary<>` 등) -## 대안 (LSP 미사용 시) +이미 작은 컴파일 에러는 변경 직후 권유 컨텍스트로 알림. 별도 호출 불필요. + +## 명시적 호출 절차 + +빠른 점검으로 부족할 때 (대규모 변경 후, 새 시스템 추가 후): + +### 1. LSP 진단 (가장 빠름) + +LSP 도구가 사용 가능한 경우 (csharp-lsp) 변경된 .cs 파일들의 진단을 즉시 확인. + +### 2. Unity CLI 배치모드 (가장 정확, ~30초) -Unity CLI 배치모드로 컴파일 검증: ``` -"$UNITY_EDITOR_PATH" -batchmode -nographics -projectPath "C:/Project/곤충게임" -logFile - -quit +"$UNITY_EDITOR_PATH" -batchmode -nographics \ + -projectPath "C:/Project/곤충게임" \ + -logFile - -quit ``` -- 환경변수 `UNITY_EDITOR_PATH`가 settings.json에 설정되어 있습니다 -- 로그에서 `error CS` 패턴을 검색하여 컴파일 에러를 추출합니다 -- 경고(`warning CS`)도 함께 보고합니다 + +- `UNITY_EDITOR_PATH`는 `.claude/settings.json` env에 정의됨 +- 로그에서 `error CS\d+:` 패턴 grep으로 추출 +- `warning CS\d+:`도 함께 보고 (정보용) +- 결과를 `.claude/cache/last-build.log`에 캐싱 (재실행 비용 절감) + +### 3. dotnet build (선택) + +`.csproj`가 있으면 `dotnet build` 가능. Unity 종속성 때문에 일반적으로 Unity CLI가 더 정확. + +## 결과 보고 형식 + +``` +=== 컴파일 검사 결과 === +파일: <상대경로> +에러: N개 + - <파일>:<라인> error CS<번호>: <메시지> +경고: M개 + - <파일>:<라인> warning CS<번호>: <메시지> +``` + +에러 0개면 "통과". 에러 있으면 각 에러의 원인을 분석하고 수정 방안 제시. + +## 캐싱 + +- `.claude/cache/last-build.log` (있다면) +- 직전 빌드 시점이 변경된 .cs 파일 mtime보다 새것이면 재사용 +- 그렇지 않으면 Unity CLI 재실행 diff --git a/.claude/skills/data-lint.md b/.claude/skills/data-lint.md new file mode 100644 index 0000000..49bb8f8 --- /dev/null +++ b/.claude/skills/data-lint.md @@ -0,0 +1,83 @@ +--- +name: data-lint +description: 코드 내 하드코딩된 ID(리전/아이템/가챠) 정의·참조 정합성을 자동 검증하고 dead code, 누락 ID, 중복 정의를 검출합니다 +user_invocable: true +args: "[--detail]" +--- + +# 데이터 정합성 자동 검증 + +리전·아이템·가챠 시스템의 코드 내 하드코딩 데이터를 정규식으로 추출 후 cross-reference 검증. dead method, 미참조 정의, 누락 참조, 중복 정의를 자동 검출합니다. +**원칙: FAIL 1건이라도 있으면 PASS 보고 금지.** + +## Phase 1: 검증 항목 (10개) + +| # | 항목 | 검출 대상 | +|---|------|----------| +| 1 | 리전 참조 정합성 | RegionManager/RegionTerrainBuilder/RegionMapUI/AudioManager의 case "..." switch가 CreateExpandedRegions 정의에 없는 ID 참조 시 FAIL | +| 2 | 리전 고아 | CreateExpandedRegions에 정의했지만 4개 참조 위치 모두에서 미참조 시 WARN | +| 3 | 리전 중복 정의 | CreateRegions(legacy) ↔ CreateExpandedRegions(active) 양쪽에 동일 regionId 정의 시 WARN | +| 4 | dead method 일반 검출 | PlaySceneBootstrap의 private 메서드 중 참조 0건 시 FAIL (Unity 콜백 제외) | +| 5 | CashShop rewardItemId ↔ 아이템 풀 | CashShopManager.shopItems의 rewardItemId가 CreateCaptureItems / 알려진 풀에 없으면 WARN | +| 6 | itemId 중복 | CreateCaptureItems ∩ CashShop shopItems 교집합 시 FAIL | +| 7 | Gacha 풀 ID ↔ displayName | gachaExclusives 풀에 있고 exclusiveDisplayNames에 없는 ID는 FAIL | +| 8 | Gacha displayName 고아 | exclusiveDisplayNames에 있고 풀에 없는 ID는 WARN | +| 9-11 | Gacha Bronze/Silver/Gold 확률 분포 | 누적 임계값 단조증가 + Legendary > 0 검증. 위반 시 FAIL | +| 12 | CashShop UI 박스 가격 ↔ Manager gemPrice | UI `DrawBoxCard` 가격 인자가 `CashShopManager.shopItems[].gemPrice`와 다르면 FAIL | +| 13 | CashShop UI 박스 확률 텍스트 ↔ Gacha 임계값 | UI rateText의 등급별 % vs `GachaBoxManager.GetXxxRarity` 누적 임계값 차이 > 0.01 시 FAIL | + +## Phase 2: 시뮬 실행 + +```bash +python .claude/scripts/data_lint.py +python .claude/scripts/data_lint.py --detail # 추출된 데이터셋 상세 출력 +``` + +`--detail`은 grep으로 추출된 모든 ID 집합을 노출 — false positive 디버그용. + +## Phase 3: 출력 — 위험 신호 표 + +각 항목별 PASS/WARN/FAIL 표로 출력. FAIL 1건 이상 시 종료 코드 1. + +## Phase 4: 비관적 권장사항 (FAIL/WARN별) + +| 위험 신호 | 권장 조치 | +|---|---| +| 리전 참조 정합성 FAIL | RegionManager/Terrain/MapUI/Audio switch에 등장하는 미정의 regionId를 CreateExpandedRegions에 추가하거나 switch에서 제거 | +| 리전 고아 WARN | 정의했는데 미참조 — 게임에 등장 안 하는 리전. 4개 참조 위치(switch/case) 추가 또는 정의 제거 | +| 리전 중복 정의 WARN | 보통 dead 메서드(CreateRegions)에 잔존. dead method 제거로 자동 해소 | +| **dead method FAIL** | 검출된 메서드 본체 통째 제거. PlaySceneBootstrap 모놀리스라 hook 경고 받지만 **단순 dead 코드 제거는 안전 영역**. 단, 이벤트 구독자/델리게이트 할당이 grep에서 정상 인식되므로 실제 사용처 재확인 권장 | +| CashShop rewardItemId 미매칭 WARN | (a) ItemData SO를 ItemDatabase에 추가, (b) `add-item` 스킬로 신규 등록, (c) `rewardItemId`를 정의된 풀로 변경 | +| itemId 중복 FAIL | CreateCaptureItems와 CashShop이 같은 itemId 정의 — 한쪽 제거 | +| Gacha displayName 누락 FAIL | `gachaExclusives` 풀에 있는 모든 ID는 `exclusiveDisplayNames` 사전에 한국어 이름 추가 필수 | +| Gacha 확률 분포 FAIL | 임계값 단조증가 위반 또는 Legendary 0% — `GetBronzeRarity`/`GetSilverRarity`/`GetGoldRarity` 정정 | +| **CashShop UI 가격 ↔ Manager FAIL** | UI 하드코딩 가격(`CashShopUI.DrawBoxCard`)이 정본(`CashShopManager.gemPrice`)과 어긋남. UI 텍스트를 매니저 값으로 정정 (ui-dev 위임). 장기적으로 SO 도입 검토 (data-architect) | +| **CashShop UI 확률 텍스트 ↔ Gacha 임계값 FAIL** | UI rateText가 코드 확률과 어긋남. 매니저 임계값에서 단계별 확률 재계산해 UI 갱신 (ui-dev 위임) | + +## Phase 5: 에이전트 위임 가이드 + +| 영역 | 주담당 | 부수 | +|---|---|---| +| `PlaySceneBootstrap.CreateExpandedRegions` 리전 정의 | game-designer | data-architect | +| `RegionManager` switch 분기 정정 | game-designer | — | +| `RegionTerrainBuilder` BuildXxxTerrain 추가 | visual-dev | — | +| `RegionMapUI` connections / GetRegionSymbol | ui-dev | — | +| `CashShopManager.shopItems` 가격/풀 | game-designer | data-architect | +| `GachaBoxManager.gachaExclusives` 풀 | game-designer | data-architect | +| ItemData SO 신규 추가 | data-architect | game-designer | + +코드 수정 후 `/verify` 호출 → 사용자 메모리 룰 적용. **수정 후 data-lint 재실행으로 회귀 검증 필수**. + +## 가정 / 한계 + +- 코드 내 하드코딩된 ID만 검증. **ScriptableObject(.asset) 직렬화 미지원** +- 정규식 기반 추출 — 코드 포맷 변경 시 false negative 가능 +- `InsectDatabase.insects[]`의 insectId는 .asset 파일에 있어 미검증 (후속 작업) +- Inspector 직렬화 필드(예: `ShopUIController.itemIds[]`)는 grep 미지원 +- `ItemDatabase.asset`이 비어있거나 미존재 시 rewardItemId 검증이 휴리스틱 풀에 의존 + +## 관련 스킬 + +- `/add-skill` `/add-item` `/add-region` — 신규 콘텐츠 추가 (Phase #1) +- `/balance-sim` `/progression-sim` `/economy-sim` `/gacha-sim` — 시뮬레이션 (Phase #2) +- `/verify` — 코드 수정 후 8항목 검증 diff --git a/.claude/skills/economy-sim.md b/.claude/skills/economy-sim.md new file mode 100644 index 0000000..e03418d --- /dev/null +++ b/.claude/skills/economy-sim.md @@ -0,0 +1,94 @@ +--- +name: economy-sim +description: 캔디/젬/코인 수입·지출 균형을 일·주 단위로 시뮬레이션해 데드 화폐, 병목, P2W 격차를 자동 검출합니다 +user_invocable: true +args: "[--days=14] [--profile=ftp|mid|whale] [--gacha-per-week=2] [--captures-per-day=20] [--battles-per-day=10] [--raids-per-week=3] [--avg-candy-per-event=5] [--daily-candy-income=auto] [--team-size=6] [--current-level=25]" +--- + +# 경제 균형 시뮬 + +캔디(F2P)·젬(P2P)·코인(베이직 의상) 3종 통화의 수입·지출을 모델링하고, 코드 grep으로 데드 화폐(발행 경로 0건) / 캔디↔젬 교환 부재 / 의상 P2W 격차를 자동 점검합니다. +**원칙: FAIL 1건이라도 있으면 PASS 보고 금지.** + +## Phase 1: 입력 수집 + +### 코드 자동 추출 +- `CashShopManager.cs:44-65` 캐시샵 가격: 2,000원/150젬, 5,000원/400젬, 10,000원/900젬 +- `GachaBoxManager` 박스 가격: bronze 500젬 / silver 800젬 / gold 1200젬 +- `InsectLevelCurve.cs` 캔디 비용 공식 (BASE=4, GROWTH=1.14) + +### grep 자동 점검 +- `AddCoins` 호출부 (코인 발행 경로) — 0건이면 FAIL +- `SpendCoins` 호출부 (코인 지출 경로) +- `(Add|Spend)Gems`와 `(Add|Spend)Candy` 동시 라인 (교환 메서드) + +### 사용자 입력 +| 인자 | 기본값 | 설명 | +|---|---|---| +| `--days` | 14 | 시뮬 기간 | +| `--profile` | ftp | ftp(0젬/주) / mid(400젬/주) / whale(2700젬/주) | +| `--gacha-per-week` | 2 | 주당 가챠 횟수 (silver 기준) | +| `--daily-candy-income` | 100 | 일일 캔디 평균 수입 | +| `--team-size` | 6 | 동시 육성 팀 크기 | +| `--current-level` | 25 | 병목 점검 시 곤충 레벨 | + +## Phase 2: 시뮬 실행 + +```bash +python .claude/scripts/economy_sim.py --days 14 --profile ftp +python .claude/scripts/economy_sim.py --days 30 --profile whale --gacha-per-week 5 +``` + +권장 시나리오: +- **F2P 14일**: 무과금 유저 2주 진행 (캔디 병목 + 젬 부족) +- **whale 30일**: 고래 한 달 (젬 잔고 인플레/디플레) +- **mid 14일**: 중과금 균형점 + +## Phase 3: 출력 — 위험 신호 표 (FAIL/WARN/PASS) + +5개 항목 자동 평가: + +| 항목 | 임계값 | 의미 | +|---|---|---| +| 코인 발행 경로 | >= 1 | `AddCoins` 호출 0건 = 데드 화폐 (의상 구매 불가) | +| 중반 캔디 병목 | < 4.0일 | 6마리 동시 레벨업 4일+ 대기 = 이탈 위험 | +| N일 후 젬 잔고 | <= 골드박스 4회분 | 잔고 과대 = 지출 경로 부족 (카테고리 부재) | +| 캔디↔젬 교환 경로 | >= 1 | 0건이면 강제 결제 유도 | +| 프리미엄 vs 베이직 의상 가격비 | < 5x | 5배+ 차이는 P2W | + +## Phase 4: 비관적 권장사항 (FAIL/WARN별) + +| 위험 신호 | 권장 조정 | +|---|---| +| 코인 데드 화폐 FAIL | (a) `InsectRewardCalculator`에 코인 보상 추가 / (b) 코인 시스템 완전 제거 후 베이직 의상도 캔디 결제 | +| 중반 캔디 병목 FAIL | `CANDY_GROWTH` 1.14 → 1.10 또는 `daily_candy_income` 증가 (보상 배율 상향) | +| 젬 잔고 과대 WARN | 젬 카테고리 추가 (이벤트 박스/한정 의상) 또는 캔디↔젬 교환 도입 | +| 캔디↔젬 교환 부재 WARN | `PlayerCurrencyWallet`에 `ExchangeCandyToGems(rate)` 추가 (예: 100캔디 = 10젬) | +| P2W 격차 WARN | 베이직 의상 효과 상향 또는 프리미엄 의상 가격 인하 | + +## Phase 5: 에이전트 위임 가이드 + +| 영역 | 주담당 | 부수 | +|---|---|---| +| `PlayerCurrencyWallet.cs` 통화 발행/교환 메서드 | data-architect | game-designer | +| `InsectRewardCalculator.cs` 코인 보상 추가 | game-designer | data-architect | +| `CharacterOutfitManager.cs` 의상 가격 조정 | game-designer | data-architect | +| `CashShopManager.cs` 가챠 박스 가격 | game-designer | data-architect | +| `ShopUIController.cs` 신규 카테고리 (젬 지출 경로) | ui-dev | game-designer | + +코드 수정 후 `/verify` 호출 → 사용자 메모리 룰 적용. + +## 가정 / 한계 + +- 일일 캔디 수입은 사용자 입력 의존 — 정확값은 텔레메트리 필요 +- 사용자 행동 균등 가정 (실제는 초반 폭주 후반 정체) +- 이벤트/시즌 보상 미반영 +- 의상 P2W는 평균가 휴리스틱 — 정확한 검증은 `CharacterOutfitManager.cs` 인스펙터 참조 +- 동적 호출(Reflection)은 grep 미탐지 + +## 관련 스킬 + +- `/balance-sim battle` — 단일 전투 데미지 +- `/progression-sim` — Lv1→50 캔디/EXP 곡선 +- `/gacha-sim` — 가챠 박스 기댓값 +- `/verify` — 코드 수정 후 8항목 검증 diff --git a/.claude/skills/explore-standard.md b/.claude/skills/explore-standard.md new file mode 100644 index 0000000..72b655f --- /dev/null +++ b/.claude/skills/explore-standard.md @@ -0,0 +1,60 @@ +--- +name: explore-standard +description: Explore 위임 시 거짓양성을 줄이기 위한 표준 가드/보고 형식 +user_invocable: false +--- + +# Explore 표준 가드 + +Explore 서브에이전트에게 잠재 문제 탐색을 위임할 때, 다음 가드를 프롬프트에 명시한다. 직전 라운드들에서 거짓양성률이 평균 50%였던 원인을 차단하기 위함. + +## 위임 프롬프트에 반드시 포함할 가드 + +### 1. 거짓양성 금지 조건 (보고 전 자기검증) +다음에 해당하면 **보고 금지**: +- **Unity 메인스레드 단일 실행 모델 race**: `lock`/`Thread-safe collection`/`Interlocked` 권장 → Unity는 메인 스레드 단일이라 같은 프레임 다중 호출도 순차. 보고 금지. +- **이미 가드 있는 코드**: `if (x != null)` / `if (x > 0)` 같은 가드가 이미 있는데 "null 가드 누락"으로 보고 금지. **반드시 해당 라인을 read하여 가드 존재 확인 후 보고**. +- **idempotent 표준 패턴**: `-=` 후 `+=` (이벤트 재구독 idempotent), `OnDisable` 해제 (표준 라이프사이클) 보고 금지. +- **메인 호출처 0개인 silent fail**: 외부 호출처가 0개이면 영향 없음. grep으로 호출처 확인 후 P2 미만으로 격하 또는 미보고. + +### 2. 사용자 프롬프트에 명시되는 "이미 처리 영역" 자동 제외 +위임자가 "**이미 처리됨 (제외)**" 섹션을 명시하면 그 영역은 절대 보고하지 말 것. 항목 하나라도 해당 영역에 속하면 스킵. + +### 3. 실제 코드 검증 필수 +- 보고하기 전 해당 파일/라인을 Read로 직접 읽고 가드 존재 확인 +- grep 결과만으로 보고 금지 (가드 메서드는 grep에 안 잡힐 수 있음) + +### 4. UI/이벤트 비대칭 보고 시 양쪽 모두 확인 +- `OnEnable += X; OnDisable -= X` 짝이 맞는지 +- `AutoWire`에서만 구독한다면 `OnEnable`도 같은 패턴인지 확인 후 보고 + +## 보고 형식 (강제) + +각 보고 항목은 정확히 다음 5줄: + +``` +N. [심각도] 문제 한 줄 요약 +파일: <상대경로>:<라인번호> +원인: <왜 문제인지 1~2줄> +권장: <수정 방향 1줄> +이미 처리됐을 가능성: 낮음|중간|높음 (이유 1줄) +``` + +### 심각도 기준 +- **P0**: 데이터 손실/크래시/사용자 진행 차단. 즉시 수정 필요. +- **P1**: 메모리 누수/UI 비일관/주요 흐름 결함. 우선 수정. +- **P2**: silent fail/잠재 최적화. 후순위. +- **P3**: 가독성/스타일. 보고 생략 가능. + +P3은 별도 요청 없으면 생략. P2도 5건 이상이면 P1 우선. + +## 보고 길이 제약 + +총 300단어 이내. 항목 5건 이상 발견 시 P0/P1만 우선 보고. P2는 묶어서 1줄 요약. + +## 위임자 책임 + +위임 프롬프트에 반드시 포함: +- "**이미 처리됨 (제외)**" 섹션 (직전 라운드 수정 목록) +- "**점검 영역**" 섹션 (이번 라운드 의심 후보) +- "위 explore-standard.md 가드 준수" 명시 (이 파일 인용 또는 핵심 가드 인라인) diff --git a/.claude/skills/gacha-sim.md b/.claude/skills/gacha-sim.md new file mode 100644 index 0000000..e34b764 --- /dev/null +++ b/.claude/skills/gacha-sim.md @@ -0,0 +1,103 @@ +--- +name: gacha-sim +description: 가챠 박스 기댓값/천장/픽업률을 몬테카를로(10000회)로 분포까지 검증하고 UI-코드 가격·확률 정합성을 자동 점검합니다 +user_invocable: true +args: "[--box=bronze|silver|gold|all] [--pulls=100] [--trials=10000] [--seed=42]" +--- + +# 가챠 박스 시뮬 + +`GachaBoxManager.cs:130-180` 확률 분포로 몬테카를로 시뮬레이션 + `CashShopUI.cs`의 UI 표시값과 정본의 정합성을 자동 검증합니다. +**원칙: FAIL 1건이라도 있으면 PASS 보고 금지.** + +## Phase 1: 입력 수집 + +### 코드 자동 추출 (스크립트 상수 BOX_DEFS) +- 브론즈 500젬: C55/U30/R12/E2.5/L0.5, 픽업 20%, 캔디 [5-15] +- 실버 800젬: C20/U35/R30/E12/L3, 픽업 30%, 캔디 [10-30] +- 골드 1200젬: C10/U25/R35/E25/L5, 픽업 50%, 캔디 [20-50] + +### 사용자 입력 +| 인자 | 기본값 | 설명 | +|---|---|---| +| `--box` | bronze | 시뮬할 박스 (또는 `all`) | +| `--pulls` | 100 | 1회 시도당 가챠 횟수 | +| `--trials` | 10000 | 몬테카를로 반복 횟수 | +| `--seed` | 42 | RNG 시드 (재현성) | + +## Phase 2: 시뮬 실행 + +```bash +python .claude/scripts/gacha_sim.py --box bronze --pulls 100 +python .claude/scripts/gacha_sim.py --box all --trials 50000 +python .claude/scripts/gacha_sim.py --box gold --pulls 10 --trials 100000 +``` + +권장 시나리오: +- **천장 부재 검증**: `--box bronze --pulls 100` +- **고래 한계점**: `--box gold --pulls 30` +- **실버 함정 비교**: `--box all` + +## Phase 3: 출력 — 분포 표 + 위험 신호 표 + +### 3-A. 몬테카를로 분포 +박스별 5%/50%/95% 분위 + 평균 + 0개 시나리오 비율. + +### 3-B. 박스 간 가성비 비교 표 + +박스별 가격당 가중 EV(Common=1, Uncommon=2, Rare=4, Epic=10, Legendary=25 가중치)와 브론즈 대비 효율 비율 출력. 차상위 박스의 효율이 차하위 미만이면 함정. + +### 3-C. 위험 신호 (9개 항목) + +| 항목 | 임계값 | 의미 | +|---|---|---| +| 천장 부재: N연차 Legendary 0개 | < 50% | 100연차 후에도 60%+ 확률로 0개면 운게임 | +| CashShopUI 가격 vs Manager 정본 | 0건 불일치 | UI 표시값이 실제 차감과 다르면 결제 분쟁 | +| UI 확률 텍스트 vs 코드 정합성 | 0건 불일치 | 텍스트 하드코딩 동기화 누락 | +| 실버 가성비 (브론즈 대비) | 기댓값 +60%+ | 가격 +60% 비싼데 기댓값 < 60%면 함정 | +| 골드 가성비 (실버 대비) | 기댓값 +30%+ | 가격 +50% 비싼데 기댓값 < 30%면 차상위 매력 부족 | +| 박스 가성비 역전 | 역전 0건 | 차상위 박스의 가격당 EV가 차하위 미만이면 명백한 함정 | +| 골드 캔디보너스 / Lv30 레벨업비 | >= 50% | 보너스가 1회 레벨업 비용의 절반도 안 되면 형식적 | +| Epic 전용곤충 N연차 평균 중복 | < 15 | 풀 4종이라 중복 누적 → 가챠 가치 하락 | +| 가챠 샤이니 적용 | 필드 1% 동일 | 가챠 0%면 필드 대비 격차 | + +## Phase 4: 비관적 권장사항 (FAIL/WARN별) + +| 위험 신호 | 권장 조정 | +|---|---| +| **천장 부재 FAIL** | `GachaBoxManager`에 pity counter 도입 (예: 50연차 보장 Epic+, 100연차 보장 Legendary) | +| **UI 가격 불일치 FAIL** | `CashShopUI.cs:313-324` 하드코딩 제거 → `GachaBoxConfig` SO에서 단일 소스 로드 | +| **UI 확률 텍스트 불일치 FAIL** | 동일 — `GachaBoxConfig` SO에 확률 필드 추가 | +| **실버 가성비 WARN** | 실버 가격 인하 또는 픽업률 +5% 상향 | +| **골드 가성비 WARN** | 골드 EV 증가가 가격 증가 대비 부족 — Legendary % 상향 또는 가격 인하 | +| **박스 가성비 역전 WARN** | 명백한 디자인 결함 — 차상위 박스의 효율이 차하위 미만. 가격/확률 재조정 필수 (예: 골드 Legendary 5%→7%, 실버 3%→2.5%) | +| **골드 캔디보너스 WARN** | 골드 캔디 [20-50] → [50-150] 또는 별도 보상 (가챠 티켓 등) | +| **가챠 샤이니 WARN** | `GachaResult`에 샤이니 필드 추가, `InsectEntity`처럼 1% 적용 | + +## Phase 5: 에이전트 위임 가이드 + +| 영역 | 주담당 | 부수 | +|---|---|---| +| `GachaBoxManager.cs` 확률/풀/천장 로직 | game-designer | data-architect | +| `CashShopUI.cs` 하드코딩 제거 | ui-dev | data-architect | +| `GachaBoxConfig` SO 신설 (단일 소스) | data-architect | game-designer | +| `GachaResult`에 isShiny 필드 추가 | data-architect | battle-dev (필드 샤이니와 일관성) | +| `CashShopManager.cs` 가격 정의 | game-designer | data-architect | + +코드 수정 후 `/verify` 호출 → 사용자 메모리 룰 적용. 특히 가격/확률 변경 시 `gacha-sim` 재실행으로 정합성 재검증. + +## 가정 / 한계 + +- 천장(pity) 미구현 가정 — 실제 코드와 일치 +- 곤충 풀 균등 무작위 가정 (가중치 데이터 미확인) +- IV 미적용 — 곤충별 스탯 편차 무시 +- 시드 고정으로 재현성 보장 (`--seed`로 변경) +- UI 정합성은 정규식 기반 — 텍스트 포맷 변경 시 false negative 가능 +- 텔레메트리 수집 후 재검증 필수 + +## 관련 스킬 + +- `/balance-sim battle` — 단일 전투 데미지 +- `/progression-sim` — Lv1→50 캔디/EXP 곡선 +- `/economy-sim` — 통화 수입·지출 균형 +- `/verify` — 코드 수정 후 8항목 검증 diff --git a/.claude/skills/progression-sim.md b/.claude/skills/progression-sim.md new file mode 100644 index 0000000..e71ac11 --- /dev/null +++ b/.claude/skills/progression-sim.md @@ -0,0 +1,95 @@ +--- +name: progression-sim +description: Lv1→50 장기 진행 곡선과 캔디/EXP 수급 데드존을 시뮬레이션하고 비관적 위험 신호를 자동 적재합니다 +user_invocable: true +args: "[--target-level=50] [--rarity=Common|Uncommon|Rare|Epic|Legendary] [--base-xp=8] [--base-candy=3] [--avg-battle-sec=30] [--team-size=6]" +--- + +# Lv1→50 진행 시뮬 + +곤충 레벨 곡선(`InsectLevelCurve.cs`), 보상 배율(`InsectRewardCalculator.cs`), 레이드 ×3(`RaidBattleController.cs`), 튜토리얼 보상 합(`TutorialQuestManager.cs`)을 결정론적으로 합산해 장기 진행 시 발생하는 데드존/병목/격차를 사전 검출합니다. +**원칙: FAIL 1건이라도 있으면 PASS 보고 금지. 스크립트 종료 코드 1.** + +## Phase 1: 입력 수집 + +### 코드 자동 추출 (스크립트 상수) +- `MAX_LEVEL = 50`, `BASE_XP=20, XP_GROWTH=1.12`, `BASE_CANDY=4, CANDY_GROWTH=1.14` (`InsectLevelCurve.cs`) +- `RARITY_MULT` (C 1.0 / U 1.2 / R 1.5 / E 2.0 / L 2.8) (`InsectRewardCalculator.cs`) +- `RAID_MULT = 3.0` (`RaidBattleController.cs:239-248`) +- `TUTORIAL_CANDY_TOTAL = 261, TUTORIAL_EXP_TOTAL = 500` (`TutorialQuestManager.cs:107-284`) + +### 사용자 입력 (선택) +| 인자 | 기본값 | 설명 | +|---|---|---| +| `--target-level` | 50 | 목표 곤충 레벨 | +| `--rarity` | Common | 곤충 레어도 | +| `--base-xp` | 8 | 기본 곤충 1마리당 EXP (배율 적용 전) | +| `--base-candy` | 3 | 기본 곤충 1마리당 캔디 | +| `--avg-battle-sec` | 30 | 평균 전투 소요 시간 (h 환산용) | +| `--team-size` | 6 | 동시 육성 팀 크기 | + +데이터 부족 영역(`--base-xp`, `--avg-battle-sec`)은 사용자 입력 의존. 정확도가 입력값에 묶임. + +## Phase 2: 시뮬 실행 + +```bash +python .claude/scripts/progression_sim.py --target-level 50 --rarity Common +``` + +옵션 조합 권장: +- 베이스 라인: `--rarity Common` +- 최단 경로: `--rarity Legendary --base-xp 8` +- 후반 검증: `--target-level 50 --avg-battle-sec 45` + +## Phase 3: 출력 — 위험 신호 표 (FAIL/WARN/PASS) + +스크립트가 자동 출력. 7개 항목: + +| 항목 | 임계값 | 의미 | +|---|---|---| +| 캔디만 경로 전투 수 | < 4,000 | 캔디만으로 Lv50 도달 시 4,000+ 전투면 동기 부족 | +| Lv35-50 / Lv1-20 EXP 평균비 | <= 10x | 후반 데드존 (레벨업 1회당 시간이 초반의 10배 이상) | +| Common vs Legendary 진행속도 격차 | < 2.0x | 레어도 편향 시 진행속도 차이 200%+ | +| 튜토리얼 보상 / 전체 누적 | >= 5% | 튜토리얼이 전체 진행의 5% 미만이면 동기 부족 | +| 훈련 EXP 기여 | > 0 | `TrainingManager`가 EXP 0 = 캔디 낭비 인식 | +| 추정 플레이 시간 | < 80h | `--avg-battle-sec`이 반영. Lv50까지 80h+면 이탈 위험 | +| 팀 동시 캔디 비용 | < 100,000 | `--team-size`가 반영. 6마리 동시 육성 비용 검증 | + +## Phase 4: 비관적 권장사항 (FAIL/WARN별) + +| 위험 신호 | 권장 조정 | +|---|---| +| 캔디 전투 수 FAIL | `CANDY_GROWTH` 1.14 → 1.10 (Lv50 비용 17,495 → ~9,000) 또는 `MAX_LEVEL` 40 | +| 데드존 WARN | `XP_GROWTH` 1.12 → 1.08 (후반 격차 완화) | +| 레어도 격차 WARN | `RARITY_MULT[Legendary]` 2.8 → 2.0 (격차 200% → 150%) | +| 튜토리얼 비중 WARN | TutorialQuestManager 보상 +5배 또는 신규 데일리 퀘스트 추가 | +| 훈련 EXP 0 WARN | `TrainingManager.cs`에 expReward 필드 추가 검토 | + +조정 후 재시뮬 → FAIL 0/WARN 최소화 확인. + +## Phase 5: 에이전트 위임 가이드 + +| 영역 | 주담당 | 부수 | +|---|---|---| +| `InsectLevelCurve.cs` 곡선 파라미터 변경 | data-architect | game-designer | +| `InsectRewardCalculator.cs` 배율 변경 | game-designer | data-architect | +| `RaidBattleController.cs` 레이드 배수 | battle-dev | game-designer | +| `TutorialQuestManager.cs` 퀘스트 보상 | game-designer | — | +| `TrainingManager.cs` EXP 추가 | game-designer | data-architect | + +코드 수정 후 → 사용자 메모리 룰에 따라 `/verify` 호출. + +## 가정 / 한계 + +- IV(개체값) 미적용 — 실제 전투 시간 ±15% 편차 +- 평균 전투 시간/포획 시도가 사용자 입력 의존 +- 사용자 행동 균등 가정 (실제는 초반 폭주 후반 정체) +- 이벤트/시즌 보상 미반영 +- 텔레메트리 수집 후 재검증 필수 — 시뮬은 디자인 의사결정 보조 + +## 관련 스킬 + +- `/balance-sim battle` — 단일 전투 데미지 분포 +- `/economy-sim` — 통화 수입·지출 균형 +- `/gacha-sim` — 가챠 박스 기댓값 +- `/verify` — 코드 수정 후 8항목 검증 diff --git a/.claude/skills/verify.md b/.claude/skills/verify.md index 5b50a0f..81d4847 100644 --- a/.claude/skills/verify.md +++ b/.claude/skills/verify.md @@ -1,21 +1,36 @@ --- name: verify -description: 기능 구현 후 코드 품질 검증을 실행합니다 +description: 기능 구현 후 코드 품질 검증을 3회 반복 루프로 실행합니다 user_invocable: true --- -# 구현 후 코드 검증 +# 구현 후 코드 검증 (3회 반복 루프) -기능 구현이 완료된 후 잠재 버그와 코드 품질을 점검합니다. +기능 구현이 완료된 후 잠재 버그와 코드 품질을 **3회 반복** 점검합니다. +각 회차에서 발견된 문제를 수정한 뒤 다음 회차에서 재검증하여, 수정으로 인한 신규 문제까지 잡습니다. -## 검증 항목 (순서대로 실행) +## 검증 루프 + +``` +[Round 1] 8항목 검증 → 발견 문제 수정 +[Round 2] 8항목 재검증 (수정 부분 + 놓친 부분) → 신규 문제 수정 +[Round 3] 8항목 최종 검증 → 안정화 확인 (또는 잔여 문제 수정) +``` + +### 루프 종료 조건 +- **3회 모두 PASS**: 검증 완료 → 사용자에게 결과 보고 +- **3회 후에도 FAIL 잔존**: 사용자에게 보고하고 결정 위임 +- **이전 회차 수정이 새 회차에서 신규 문제 유발**: 추가 회차 진행 + +## 검증 항목 (각 회차마다 8개 모두 점검) ### 1. 이벤트 구독 안전성 변경된 파일에서 `event Action` 또는 `+=` / `-=` 패턴 검색: - **OnEnable + AutoWire 이중 구독**: 둘 다 같은 이벤트를 구독하면 2번 호출됨. 원칙: AutoWire에서만 구독, OnDisable에서 해제 - OnDisable에서 해제 누락 - null 체크 없는 구독/해제 -- 구독 전 -= 로 기존 구독 해제하는지 확인 +- 구독 전 `-=` 로 기존 구독 해제하는지 확인 +- 람다 구독은 해제 어려움 → 가능하면 메서드 참조 사용 ### 2. null 가드 패턴 변경된 코드에서: @@ -62,23 +77,41 @@ ID 기반 매칭 로직이 변경되었으면: - 모든 데이터 ID가 매칭 조건에 포함되는지 (곤충 ID → Build 메서드, 아이템 ID → 처리 분기) - contains 순서로 인한 오매칭 (예: "antlion"이 "ant"에 먼저 매칭) - 새 데이터 추가 시 매칭 분기 추가 여부 +- enum↔switch 1:1 매핑 (BgmType, SfxType 등) +- 코루틴 race 컨디션 (PlayBGM 빠른 연속 호출 등) -## 출력 형식 +## 회차별 출력 형식 + +### Round N 결과 ``` -=== 구현 후 검증 결과 === -[PASS] 이벤트 구독: 중복/누락 없음 -[PASS] null 가드: 모든 외부 참조 체크 완료 -[PASS] 성능: Update 내 캐싱 확인, 불필요한 호출 없음 -[PASS] Bootstrap: 새 시스템 정상 등록 +=== Round N 검증 결과 === +[PASS] 이벤트 구독: ... +[WARN] null 가드: FileX.cs:42 — XX null 체크 누락 +[FAIL] 성능: FileY.cs Update에서 transform.Find 매 프레임 +[PASS] Bootstrap: ... [PASS] 세이브: 변경 없음 [PASS] 모놀리스: 최소 수정 [PASS] 컨벤션: 위반 없음 -[PASS] 데이터 매칭: 전체 ID 매칭 확인 +[PASS] 데이터 매칭: ... + +총 8항목 중 6 PASS / 1 WARN / 1 FAIL +→ FAIL 즉시 수정 후 Round N+1 진행 +``` + +### 최종 보고 (3회 완료 후) +``` +=== 검증 루프 완료 (3회) === +Round 1: 6 PASS / 1 WARN / 1 FAIL → 1건 수정 +Round 2: 7 PASS / 1 WARN / 0 FAIL → 1건 수정 +Round 3: 8 PASS / 0 WARN / 0 FAIL → 안정화 완료 -총 8항목 중 8 PASS / 0 WARN / 0 FAIL +수정 내역: +- [Round 1 발견] InsectEntity.cs: transform.Find 매 프레임 → 캐싱 추가 +- [Round 2 발견] PlayerStatusHUD.cs: OnEnable+AutoWire 이중 구독 → OnEnable 제거 ``` ## 규칙 -- FAIL이 있으면 즉시 수정 -- WARN은 사용자에게 보고 후 판단 -- 모든 항목 PASS일 때만 구현 완료로 보고 +- 각 회차에서 **FAIL이 있으면 즉시 수정**, 그 후 다음 회차 진행 +- WARN은 회차 진행하면서 누적 추적, 최종 보고 시 사용자 결정 +- 3회차 완료 시 항상 최종 요약을 보고에 포함 +- 신규 회차에서 **이전 수정이 만든 신규 문제**도 적극 탐색 diff --git a/Assets/Resources/InsectLore.json b/Assets/Resources/InsectLore.json index 7d0440d..39e90e8 100644 --- a/Assets/Resources/InsectLore.json +++ b/Assets/Resources/InsectLore.json @@ -3,514 +3,514 @@ { "insectId": "beetle_basic", "displayName": "풀색딱정벌레", - "description": "수풀 근처에서 자주 발견되는 기본 딱정벌레다.", - "habitatHint": "맑은 낮 시간, 풀밭 주변", + "description": "반짝이는 청록빛 등껍질을 두른 땅 위의 작은 사냥꾼이에요. 날개가 거의 퇴화해 잘 날지 못하는 대신, 길고 튼튼한 다리로 풀밭을 빠르게 누비며 달팽이나 애벌레를 잡아먹는답니다. 밤사이 부지런히 해충을 잡아주는 농부의 친구라서, 옛날부터 사람들에게 고마운 벌레로 여겨졌어요!", + "habitatHint": "맑은 날 낮, 풀밭과 돌 틈 사이 땅바닥", "spritePath": "Sprites/Insects/beetle_basic", "rewardCoins": 5 }, { "insectId": "mantis_green", "displayName": "초록사마귀", - "description": "긴 팔을 이용해 먹이를 낚아채는 사마귀다.", - "habitatHint": "아침 또는 저녁, 나뭇잎 주변", + "description": "톱니가 돋은 앞다리를 두 손처럼 모으고 가만히 기도하듯 기다리는 풀잎의 명사수예요. 머리를 거의 180도 돌려 사방을 살피다가, 먹잇감이 다가오면 눈 깜짝할 사이에 앞다리를 뻗어 낚아챈답니다. 몸 색깔이 주변 잎과 똑같아서 나비도 메뚜기도 코앞까지 다가올 때까지 전혀 눈치채지 못해요!", + "habitatHint": "선선한 아침이나 해 질 녘, 넓은 나뭇잎 위", "spritePath": "Sprites/Insects/mantis_green", "rewardCoins": 12 }, { "insectId": "moth_night", "displayName": "밤나방", - "description": "빛을 따라 날아다니는 밤나방이다.", - "habitatHint": "밤 시간, 불빛 근처", + "description": "두툼한 가루 날개를 펄럭이며 어둠 속을 헤매는 밤의 여행자예요. 사실 달빛을 나침반 삼아 일정한 각도로 날도록 진화했는데, 가로등이나 전등 불빛을 달로 착각하는 바람에 자꾸 불빛 주위를 빙빙 돌게 된답니다. 더듬이로 아주 먼 곳의 꽃향기까지 맡아내는 코의 달인이기도 해요!", + "habitatHint": "구름 낀 밤, 가로등이나 창문 불빛 근처", "spritePath": "Sprites/Insects/moth_night", "rewardCoins": 18 }, { "insectId": "bee_worker", "displayName": "일꿀벌", - "description": "꽃가루를 모으는 부지런한 벌이다.", - "habitatHint": "낮 시간, 꽃밭", + "description": "노란 줄무늬 옷을 입고 온종일 꽃밭을 오가는 부지런한 일꾼이에요. 다리에 달린 '꽃가루 주머니'에 노란 가루를 가득 담아 벌집으로 나르고, 좋은 꽃을 찾으면 8자 모양으로 춤을 춰서 친구들에게 위치를 알려준답니다. 이 작은 친구가 꽃가루를 옮겨준 덕분에 우리가 사과와 딸기를 먹을 수 있어요!", + "habitatHint": "햇살 좋은 낮, 활짝 핀 꽃밭", "spritePath": "Sprites/Insects/bee_worker", "rewardCoins": 10 }, { "insectId": "dragonfly_lake", "displayName": "호수잠자리", - "description": "물가를 빠르게 스치는 잠자리다.", - "habitatHint": "낮 시간, 연못 주변", + "description": "투명한 두 쌍의 날개로 물가를 스치듯 날아다니는 하늘의 사냥꾼이에요. 머리의 대부분을 차지하는 커다란 겹눈으로 거의 모든 방향을 한꺼번에 볼 수 있어서, 날아가는 모기를 공중에서 척척 낚아챈답니다. 어릴 적엔 물속에서 '수채'로 지내다가 어른이 되어서야 비로소 하늘로 날아오르는 신기한 벌레예요!", + "habitatHint": "햇볕 따뜻한 낮, 잔잔한 연못과 호숫가", "spritePath": "Sprites/Insects/dragonfly_lake", "rewardCoins": 14 }, { "insectId": "stag_beetle", "displayName": "왕사슴벌레", - "description": "단단한 껍질과 큰 턱을 가진 사슴벌레다.", - "habitatHint": "저녁 시간, 나무 그늘", + "description": "사슴뿔처럼 멋지게 휘어진 큰턱을 자랑하는 숲속의 힘센 장군이에요. 그 우람한 턱은 사실 라이벌 수컷과 힘겨루기를 할 때 상대를 번쩍 들어 던지는 씨름 무기랍니다. 단단한 갑옷 같은 등껍질을 두르고 밤마다 참나무 수액을 찾아 모여드는데, 수명이 길어 잘 키우면 몇 년을 함께할 수 있어요!", + "habitatHint": "여름 해 질 녘부터 밤, 참나무 그늘과 수액 주변", "spritePath": "Sprites/Insects/stag_beetle", "rewardCoins": 20 }, { "insectId": "firefly_glow", "displayName": "반딧불이", - "description": "밤에 은은하게 빛나는 반딧불이다.", - "habitatHint": "밤 시간, 풀숲", + "description": "캄캄한 여름밤 풀숲에 별처럼 노란 불빛을 깜빡이는 빛의 요정이에요. 배 끝의 발광 기관에서 나오는 이 빛은 신기하게도 거의 열이 없는 '차가운 빛'이라, 깜빡이는 신호로 서로 짝을 찾는 말 대신 쓴답니다. 맑고 깨끗한 물가에서만 사는 까다로운 친구라서, 반딧불이가 보인다는 건 그곳이 무척 깨끗하다는 증거예요!", + "habitatHint": "습한 여름밤, 깨끗한 시냇가 풀숲", "spritePath": "Sprites/Insects/firefly_glow", "rewardCoins": 25 }, { "insectId": "butterfly_azure", "displayName": "푸른부전나비", - "description": "푸른 날개를 가진 희귀한 나비다.", - "habitatHint": "맑은 낮, 언덕 위", + "description": "동전만큼 작지만 하늘을 오려 붙인 듯 새파란 날개가 눈부신 보석 같은 나비예요. 날개 윗면은 반짝이는 파란색이고 아랫면은 점무늬 회색이라, 앉아서 날개를 접으면 감쪽같이 모습을 숨길 수 있답니다. 놀랍게도 어떤 부전나비 애벌레는 개미와 친구가 되어 개미집에서 보살핌을 받으며 자라기도 해요!", + "habitatHint": "맑고 화창한 낮, 햇살 드는 언덕 위 풀꽃밭", "spritePath": "Sprites/Insects/butterfly_azure", "rewardCoins": 30 }, { "insectId": "cicada_summer", "displayName": "참매미", - "description": "한여름 나무 위에서 우렁차게 우는 매미다. 소리로 위치를 알 수 있다.", - "habitatHint": "낮 시간, 풀밭 및 나무 주변", + "description": "한여름 나무 위에서 '맴맴맴' 우렁차게 울어대는 우리나라의 대표 매미예요. 우는 건 오직 수컷뿐이고, 배에 있는 작은 진동막을 떨어 암컷을 부른답니다. 땅속에서 몇 년이나 애벌레로 지내다가 한여름에야 어른벌레가 되어 나무 수액을 빨아 먹어요.", + "habitatHint": "맑은 한여름 한낮, 참나무나 버드나무 줄기 높은 곳", "spritePath": "Sprites/Insects/cicada_summer", "rewardCoins": 8 }, { "insectId": "cricket_field", "displayName": "왕귀뚜라미", - "description": "가을밤 풀숲에서 아름다운 소리를 내는 귀뚜라미다.", - "habitatHint": "밤 시간, 풀밭", + "description": "가을밤 풀숲에서 '귀뚤귀뚤' 맑은 소리로 노래하는 까만 귀뚜라미예요. 두 앞날개를 비벼서 소리를 내는데, 노래하는 건 짝을 찾는 수컷이랍니다. 밤에 풀씨나 떨어진 잎을 갉아 먹으며 돌 틈이나 풀뿌리 사이에 숨어 지내요.", + "habitatHint": "선선한 가을밤, 풀밭이나 돌담 틈새", "spritePath": "Sprites/Insects/cricket_field", "rewardCoins": 7 }, { "insectId": "ant_soldier", "displayName": "왕개미", - "description": "무리를 지어 이동하며 강한 턱으로 먹이를 운반하는 개미다.", - "habitatHint": "아무 시간, 땅 위", + "description": "우리나라에서 가장 큰 개미로, 줄을 지어 부지런히 먹이를 나르는 일꾼이에요. 강한 큰턱으로 자기 몸보다 무거운 먹이도 척척 운반하고, 동료들과 더듬이를 맞대며 길을 알려준답니다. 죽은 벌레나 단물을 좋아해서 진딧물을 돌보며 단물을 받아먹기도 해요.", + "habitatHint": "낮이든 밤이든, 양지바른 땅 위나 썩은 나무 둘레", "spritePath": "Sprites/Insects/ant_soldier", "rewardCoins": 5 }, { "insectId": "water_strider_pond", "displayName": "소금쟁이", - "description": "수면 위를 미끄러지듯 이동하는 신기한 곤충이다.", - "habitatHint": "낮 시간, 연못 수면", + "description": "연못 수면 위를 스케이트 타듯 미끄러지는 신기한 곤충이에요. 가느다란 다리 끝의 잔털이 물을 밀어내서 가라앉지 않고 둥둥 떠 다닐 수 있답니다. 물에 떨어진 작은 벌레의 움직임을 물결로 느끼고 재빠르게 다가가 즙을 빨아 먹어요.", + "habitatHint": "바람 잔잔한 낮, 잔물결이 이는 연못이나 웅덩이 수면", "spritePath": "Sprites/Insects/water_strider_pond", "rewardCoins": 15 }, { "insectId": "diving_beetle_deep", "displayName": "물방개", - "description": "물속에서 빠르게 헤엄치며 작은 수서곤충을 사냥한다.", - "habitatHint": "아무 시간, 연못 깊은 곳", + "description": "물속을 쏜살같이 헤엄치는 매끈한 수서 딱정벌레예요. 날개 밑에 공기방울을 잠수부 산소통처럼 품고 들어가 한참을 물속에서 버틸 수 있답니다. 뒷다리를 노처럼 저어 헤엄치며 작은 물벌레나 올챙이를 사냥하는 연못 속 솜씨 좋은 사냥꾼이에요.", + "habitatHint": "낮이든 밤이든, 수초가 우거진 연못 깊은 곳", "spritePath": "Sprites/Insects/diving_beetle_deep", "rewardCoins": 16 }, { "insectId": "jewel_beetle_gold", "displayName": "비단벌레", - "description": "금빛으로 빛나는 아름다운 날개를 가진 귀한 딱정벌레다.", - "habitatHint": "맑은 낮, 나무 줄기", + "description": "보는 각도마다 금빛과 초록빛으로 영롱하게 빛나는 보석 같은 딱정벌레예요. 화려한 날개 색은 색소가 아니라 빛이 반사되는 구조에서 나와서, 죽은 뒤에도 그 빛깔이 변하지 않는답니다. 햇볕 따뜻한 낮에 활엽수 줄기를 천천히 기어 다니며 잎을 갉아 먹어요.", + "habitatHint": "햇살 쨍한 한낮, 팽나무나 벚나무 줄기", "spritePath": "Sprites/Insects/jewel_beetle_gold", "rewardCoins": 25 }, { "insectId": "rhinoceros_beetle", "displayName": "장수풍뎅이", - "description": "커다란 뿔을 가진 힘센 풍뎅이다. 나무 수액에 모여든다.", - "habitatHint": "저녁 시간, 나무 수액 주변", + "description": "이마에 우뚝 솟은 큰 뿔이 마치 작은 코뿔소 같은 힘센 풍뎅이예요. 밤이 되면 참나무 수액 냄새를 맡고 모여들어 달콤한 즙을 쪽쪽 빨아 먹는답니다. 자기 몸무게의 수십 배를 들어 올릴 만큼 곤충 세계의 천하장사로 유명해요!", + "habitatHint": "한여름 무더운 밤, 참나무 수액이 흐르는 나무 줄기", "spritePath": "Sprites/Insects/rhinoceros_beetle", "rewardCoins": 28 }, { "insectId": "luna_moth_silver", "displayName": "산누에나방", - "description": "달빛 아래 은빛 날개를 펄럭이는 신비로운 나방이다.", - "habitatHint": "밤 시간, 달빛 아래", + "description": "연한 은빛 초록 날개에 긴 꼬리가 우아하게 늘어진 아름다운 나방이에요. 어른벌레는 입이 없어 아무것도 먹지 않고, 오직 짝을 찾는 일에만 짧은 일생을 보낸답니다. 달빛이 환한 밤에 날개를 펄럭이면 마치 살아있는 보석처럼 반짝여요.", + "habitatHint": "달빛 밝은 늦봄 밤, 활엽수가 우거진 숲 가장자리", "spritePath": "Sprites/Insects/luna_moth_silver", "rewardCoins": 40 }, { "insectId": "atlas_moth_giant", "displayName": "아틀라스나방", - "description": "세계에서 가장 큰 날개를 가진 거대한 나방이다.", - "habitatHint": "밤 시간, 깊은 숲", + "description": "세계에서 가장 큰 날개를 가진 어마어마한 나방으로, 두 날개를 펼치면 어른 손바닥보다도 훨씬 커요. 날개 끝이 뱀 머리처럼 생겨서 적을 깜짝 놀라게 쫓아낸답니다. 애벌레 때 잔뜩 먹어 둔 영양분으로 살기 때문에 어른이 되면 먹지 않고 며칠만 살아요.", + "habitatHint": "후텁지근한 밤, 깊은 숲속 나무 그늘", "spritePath": "Sprites/Insects/atlas_moth_giant", "rewardCoins": 45 }, { "insectId": "butterfly_monarch", "displayName": "왕나비", - "description": "주황색 날개에 검은 무늬가 특징인 대형 나비다.", - "habitatHint": "낮 시간, 꽃밭", + "description": "주황빛 날개에 검은 그물 무늬와 흰 점이 콕콕 박힌 늠름한 대형 나비예요. 꽃밭을 천천히 누비며 달콤한 꿀을 빨아 먹는 걸 가장 좋아한답니다. 친척뻘인 제왕나비는 수천 킬로미터를 날아 이동하는 것으로 유명한, 정말 대단한 여행가 나비예요!", + "habitatHint": "햇살 따스한 낮, 꽃이 활짝 핀 들판", "spritePath": "Sprites/Insects/butterfly_monarch", "rewardCoins": 16 }, { "insectId": "scarab_ancient", "displayName": "풍뎅이", - "description": "둥근 몸에 단단한 등껍질을 가진 고대부터 알려진 곤충이다.", - "habitatHint": "저녁 시간, 모래땅", + "description": "동글동글한 몸에 반질반질 단단한 등껍질을 두른 귀여운 곤충이에요. 흙 속을 부지런히 파고들거나 식물 뿌리 근처에서 지내며 살아간답니다. 옛날 이집트 사람들은 둥근 풍뎅이를 행운과 부활의 상징으로 여겨 무척 소중히 여겼대요.", + "habitatHint": "해 질 무렵, 부드러운 모래흙이 많은 땅 위", "spritePath": "Sprites/Insects/scarab_ancient", "rewardCoins": 22 }, { "insectId": "mantis_orchid", "displayName": "꽃사마귀", - "description": "꽃잎처럼 화려한 몸으로 위장해 먹이를 기다리는 전설적인 사마귀다.", - "habitatHint": "맑은 낮, 꽃밭", + "description": "마치 활짝 핀 난초 꽃처럼 분홍빛 다리와 몸으로 변장하는 신비로운 사마귀예요. 꽃인 척 가만히 숨어 있다가 꿀을 찾아온 곤충을 번개같이 낚아챈답니다. 꽃보다 더 꽃 같아서 나비조차 속아 넘어가는, 자연이 만든 최고의 위장술사예요!", + "habitatHint": "맑고 환한 낮, 꽃이 흐드러진 꽃밭 한가운데", "spritePath": "Sprites/Insects/mantis_orchid", "rewardCoins": 80 }, { "insectId": "ladybug_seven", "displayName": "칠성무당벌레", - "description": "빨간 등에 검은 점 7개, 풀잎 위에서 흔히 발견되는 귀여운 벌레다.", - "habitatHint": "맑은 낮, 풀잎 위", + "description": "새빨간 등에 까만 점 일곱 개가 콕콕 찍힌 동글동글 귀여운 벌레예요. 식물을 괴롭히는 진딧물을 하루에도 수십 마리씩 잡아먹는 농부들의 든든한 친구랍니다. 위험할 땐 다리에서 쓴 액체를 살짝 내보내 새들이 함부로 먹지 못하게 한대요.", + "habitatHint": "맑은 봄날 낮, 진딧물이 모인 풀잎과 어린 새싹 위", "spritePath": "Sprites/Insects/ladybug_seven", "rewardCoins": 5 }, { "insectId": "grasshopper_green", "displayName": "풀무치", - "description": "풀밭에서 폴짝폴짝 뛰어다니는 초록 메뚜기다.", - "habitatHint": "낮 시간, 풀밭", + "description": "기다란 뒷다리로 폴짝폴짝 멀리 뛰어오르는 초록빛 메뚜기예요. 풀잎을 오물오물 갉아 먹으며 따뜻한 풀밭에서 살아간답니다. 떼를 지으면 몸 색깔이 변하고 무리가 함께 멀리 이동하기도 하는, 변신술을 부리는 신기한 곤충이에요!", + "habitatHint": "햇볕 쨍쨍한 낮, 키 큰 풀이 우거진 풀밭", "spritePath": "Sprites/Insects/grasshopper_green", "rewardCoins": 6 }, { "insectId": "mosquito_common", "displayName": "모기", - "description": "여름밤 어디서든 나타나는 성가신 곤충이다.", - "habitatHint": "밤 시간, 풀숲", + "description": "가느다란 다리와 투명한 날개로 윙윙 소리를 내며 날아드는 여름밤의 단골손님이에요. 피를 빠는 건 알을 낳아야 하는 암컷뿐이고, 평소에는 암수 모두 꽃의 꿀을 먹고 산답니다. 사람이 내쉬는 숨과 따뜻한 체온을 멀리서도 척척 알아채는 놀라운 감각을 지녔어요.", + "habitatHint": "후텁지근한 여름밤, 물기가 고인 풀숲과 웅덩이 가까이", "spritePath": "Sprites/Insects/mosquito_common", "rewardCoins": 3 }, { "insectId": "fly_house", "displayName": "집파리", - "description": "어디서든 윙윙거리며 날아다니는 파리다.", - "habitatHint": "아무 시간, 마을 주변", + "description": "윙윙 날아다니며 어디서든 마주치는 친숙한 회색빛 파리예요. 발끝으로도 맛을 느낄 수 있어 음식 위에 살짝 앉기만 해도 단맛인지 바로 알아챈답니다. 눈 깜짝할 사이에 방향을 홱 바꾸며 날아 손바닥을 요리조리 피하는, 알고 보면 대단한 비행 솜씨의 주인공이에요.", + "habitatHint": "시간을 가리지 않고, 사람이 사는 마을과 집 주변", "spritePath": "Sprites/Insects/fly_house", "rewardCoins": 3 }, { "insectId": "pill_bug_garden", "displayName": "공벌레", - "description": "건드리면 동그랗게 말리는 귀여운 벌레다.", - "habitatHint": "아무 시간, 돌 밑", + "description": "살짝 건드리면 데구르르 몸을 동그랗게 마는 모습이 너무 귀여운 작은 친구예요. 곤충이 아니라 게나 새우의 먼 친척인 갑각류라서, 등에 단단한 마디 갑옷을 두르고 축축한 곳을 좋아한답니다. 낙엽과 썩은 것을 야금야금 먹어 흙으로 되돌려 주는 자연의 청소부랍니다!", + "habitatHint": "시간을 가리지 않고, 축축한 돌 밑이나 낙엽 더미 아래", "spritePath": "Sprites/Insects/pill_bug_garden", "rewardCoins": 5 }, { "insectId": "earwig_common", "displayName": "집게벌레", - "description": "꼬리에 집게를 가진 벌레다. 밤에 돌 밑에서 발견된다.", - "habitatHint": "밤 시간, 돌 밑", + "description": "꽁무니에 멋진 집게를 단 야행성 곤충이에요. 무섭게 생긴 집게는 사실 적을 위협하거나 먹이를 다룰 때 쓰는 도구라 사람을 해치진 않는답니다. 어미가 알과 새끼를 정성껏 돌보는, 곤충 중에서는 보기 드문 자상한 부모예요!", + "habitatHint": "축축한 밤, 돌 밑이나 화분 아래 어두운 틈", "spritePath": "Sprites/Insects/earwig_common", "rewardCoins": 6 }, { "insectId": "caterpillar_green", "displayName": "배추벌레", - "description": "느릿느릿 기어가는 초록 애벌레다.", - "habitatHint": "낮 시간, 풀밭", + "description": "잎사귀를 야금야금 갉아 먹으며 무럭무럭 자라는 통통한 초록 애벌레예요. 먹고 또 먹어 허물을 여러 번 벗으며 몸집을 불린 뒤, 번데기가 되어 어느 날 나비나 나방으로 변신한답니다. 작은 몸에 숨은 가짜 눈무늬로 천적을 깜짝 놀라게 하기도 해요!", + "habitatHint": "따뜻한 낮, 연하고 푸른 잎이 무성한 나뭇가지", "spritePath": "Sprites/Insects/caterpillar_green", "rewardCoins": 4 }, { "insectId": "aphid_colony", "displayName": "진딧물", - "description": "떼로 모여 식물 즙을 빨아먹는 작은 벌레다.", - "habitatHint": "아무 시간, 나뭇잎", + "description": "새순에 다닥다닥 붙어 식물의 단물을 빨아 먹는 아주 작은 곤충이에요. 엉덩이에서 달콤한 단물을 내놓아 개미와 거래를 하는데, 대신 개미가 무당벌레 같은 천적으로부터 지켜준답니다. 알을 낳지 않고도 새끼를 쑥쑥 불리는 놀라운 번식왕이에요!", + "habitatHint": "포근한 봄낮, 여린 새순과 잎 뒷면", "spritePath": "Sprites/Insects/aphid_colony", "rewardCoins": 2 }, { "insectId": "moth_brown", "displayName": "갈색밤나방", - "description": "밤에 불빛에 모여드는 흔한 나방이다.", - "habitatHint": "밤 시간, 불빛 근처", + "description": "수수한 갈색 날개로 나무껍질에 앉으면 감쪽같이 사라지는 위장의 달인이에요. 화려하진 않지만 그 흐릿한 무늬 덕분에 새의 눈을 잘 피한답니다. 밤이면 더듬이로 향기를 좇아 꽃과 수액을 찾아 조용히 날아다녀요.", + "habitatHint": "선선한 밤, 나무껍질과 울타리 그늘", "spritePath": "Sprites/Insects/moth_brown", "rewardCoins": 5 }, { "insectId": "beetle_dung", "displayName": "똥풍뎅이", - "description": "열심히 흙을 굴리는 풍뎅이다.", - "habitatHint": "낮 시간, 땅 위", + "description": "동물의 똥을 동그랗게 빚어 데굴데굴 굴리는 부지런한 청소부예요. 자기 몸보다 훨씬 큰 똥 경단을 뒷다리로 밀어 안전한 곳에 묻고, 그 속에 알을 낳는답니다. 놀랍게도 밤에는 은하수를 길잡이 삼아 똑바로 굴러가는 천문학자이기도 해요!", + "habitatHint": "햇볕 따가운 낮, 풀 뜯는 동물이 다니는 들판", "spritePath": "Sprites/Insects/beetle_dung", "rewardCoins": 6 }, { "insectId": "centipede_common", "displayName": "왕지네", - "description": "많은 다리로 빠르게 달리는 지네다.", - "habitatHint": "밤 시간, 돌 밑", + "description": "수많은 다리를 물결처럼 움직이며 빠르게 기어 다니는 밤의 사냥꾼이에요. 머리 앞의 독 발톱으로 작은 벌레를 마비시켜 잡아먹는답니다. 다리가 백 개라 백족이라 불리지만 실제로는 종마다 다리 수가 제각각인 게 비밀이에요!", + "habitatHint": "축축한 밤, 낙엽 더미와 썩은 통나무 밑", "spritePath": "Sprites/Insects/centipede_common", "rewardCoins": 7 }, { "insectId": "katydid_leaf", "displayName": "여치", - "description": "나뭇잎을 닮은 초록 곤충이다. 밤에 아름답게 운다.", - "habitatHint": "밤 시간, 풀숲", + "description": "나뭇잎을 쏙 빼닮은 연두색 몸으로 풀숲에 숨는 여름밤의 가수예요. 앞날개를 비벼 쩌르르 노래하며, 잎맥까지 흉내 낸 날개 덕분에 천적도 진짜 잎과 구별하지 못한답니다. 긴 더듬이로 어둠 속에서도 길을 척척 찾아내요.", + "habitatHint": "무더운 여름밤, 키 큰 풀과 관목의 잎 사이", "spritePath": "Sprites/Insects/katydid_leaf", "rewardCoins": 14 }, { "insectId": "wasp_paper", "displayName": "쌍살벌", - "description": "종이처럼 얇은 벌집을 짓는 벌이다. 주의가 필요하다.", - "habitatHint": "낮 시간, 처마 밑", + "description": "씹은 나무를 침과 섞어 종이 같은 육각형 집을 짓는 솜씨 좋은 건축가예요. 가느다란 허리와 매끈한 몸으로 날아다니며 애벌레를 사냥해 새끼에게 먹인답니다. 꿀벌과 달리 침을 여러 번 쏠 수 있으니 집 근처에선 살살 피해 주는 게 좋아요!", + "habitatHint": "맑은 낮, 처마 밑이나 나뭇가지에 매단 종이집 주변", "spritePath": "Sprites/Insects/wasp_paper", "rewardCoins": 16 }, { "insectId": "damselfly_blue", "displayName": "실잠자리", - "description": "잠자리보다 가늘고 우아한 파란 몸을 가진 곤충이다.", - "habitatHint": "낮 시간, 연못", + "description": "잠자리의 가느다란 친척으로, 실처럼 가는 파란 몸이 풀잎 끝에서 살랑살랑 흔들려요. 쉴 때는 잠자리와 달리 네 장의 날개를 등 위로 가지런히 접는답니다. 작은 날벌레를 공중에서 낚아채 먹는 솜씨 좋은 사냥꾼이에요.", + "habitatHint": "맑은 한낮, 물풀이 우거진 연못 가장자리", "spritePath": "Sprites/Insects/damselfly_blue", "rewardCoins": 13 }, { "insectId": "longhorn_beetle", "displayName": "하늘소", - "description": "몸보다 긴 더듬이가 특징인 딱정벌레다.", - "habitatHint": "저녁 시간, 나무 줄기", + "description": "제 몸길이보다도 긴 더듬이를 휘저으며 나무 위를 누비는 멋쟁이 딱정벌레예요. 위협을 느끼면 가슴을 비벼 '끼익' 하는 소리를 내 적을 놀래킨답니다. 애벌레는 나무 속을 파고들며 자라서 농부들에겐 골칫거리이기도 해요.", + "habitatHint": "해 질 무렵, 참나무 줄기와 갓 베어낸 통나무 주변", "spritePath": "Sprites/Insects/longhorn_beetle", "rewardCoins": 15 }, { "insectId": "butterfly_cabbage", "displayName": "배추흰나비", - "description": "밭 주위를 날아다니는 하얀 나비다.", - "habitatHint": "낮 시간, 밭", + "description": "텃밭과 들판을 팔랑팔랑 날아다니는, 봄을 가장 먼저 알리는 하얀 나비예요. 앞날개의 까만 점으로 암수를 구분할 수 있는데, 점이 두 개면 암컷이랍니다. 애벌레 시절엔 배춧잎을 좋아하는 통통한 초록 '배추벌레'였다는 사실, 알고 있었나요?", + "habitatHint": "따스한 봄낮, 배추와 유채꽃이 핀 밭", "spritePath": "Sprites/Insects/butterfly_cabbage", "rewardCoins": 12 }, { "insectId": "beetle_click", "displayName": "방아벌레", - "description": "뒤집으면 딸깍 소리를 내며 튀어오르는 재미있는 벌레다.", - "habitatHint": "아무 시간, 나무 아래", + "description": "뒤집혀도 걱정 없어요! 가슴의 용수철 같은 장치로 '딸깍' 소리를 내며 공중으로 톡 튀어올라 몸을 바로잡는 재주꾼이거든요. 그 모습이 방아를 찧는 것 같다고 해서 이런 이름이 붙었답니다. 깜짝 놀라 죽은 척하다가 갑자기 튀어오르는 장난꾸러기예요.", + "habitatHint": "시간 상관없이, 썩은 나무와 낙엽이 쌓인 나무 그늘 아래", "spritePath": "Sprites/Insects/beetle_click", "rewardCoins": 14 }, { "insectId": "spider_garden", "displayName": "무당거미", - "description": "화려한 무늬의 거미줄 장인이다.", - "habitatHint": "아침, 풀숲", + "description": "노랑과 검정 줄무늬가 화려한, 거미줄 짜기의 진정한 장인이에요. 끈끈한 가로줄과 매끈한 세로줄을 구분해 짜기 때문에 자기 줄에는 절대 들러붙지 않는답니다. 가을이 깊어지면 큼직한 암컷 곁에 작디작은 수컷이 함께 매달려 있는 모습을 볼 수 있어요.", + "habitatHint": "이슬 맺힌 아침, 처마 밑이나 나뭇가지 사이에 친 큰 거미줄", "spritePath": "Sprites/Insects/spider_garden", "rewardCoins": 15 }, { "insectId": "diving_beetle_small", "displayName": "꼬마물방개", - "description": "작고 빠른 수서 딱정벌레다.", - "habitatHint": "낮 시간, 웅덩이", + "description": "작지만 날쌘 물속의 다이버예요. 뒷다리에 노처럼 생긴 털이 나 있어 물살을 가르며 쏜살같이 헤엄친답니다. 숨을 쉴 땐 꽁무니를 수면에 살짝 내밀어 공기 방울을 등껍질 아래 챙겨 다시 물속으로 잠수해요.", + "habitatHint": "낮 시간, 물풀이 떠 있는 얕은 웅덩이와 논두렁", "spritePath": "Sprites/Insects/diving_beetle_small", "rewardCoins": 13 }, { "insectId": "butterfly_swallowtail", "displayName": "호랑나비", - "description": "노란 날개에 호랑이 줄무늬가 있는 대형 나비다.", - "habitatHint": "맑은 낮, 꽃밭", + "description": "노란 바탕에 호랑이 같은 검은 줄무늬를 두른 늠름한 대형 나비예요. 뒷날개 끝에 제비 꼬리처럼 길게 뻗은 돌기가 있어 우아하게 나는 모습이 일품이랍니다. 애벌레는 적을 만나면 머리에서 주황색 뿔을 쏙 내밀어 고약한 냄새로 쫓아내요.", + "habitatHint": "햇살 가득한 맑은 낮, 귤나무와 산초나무가 있는 꽃밭", "spritePath": "Sprites/Insects/butterfly_swallowtail", "rewardCoins": 22 }, { "insectId": "stick_insect_long", "displayName": "대벌레", - "description": "나뭇가지처럼 생겨 좀처럼 발견하기 어렵다.", - "habitatHint": "낮 시간, 나뭇가지", + "description": "나뭇가지인 줄 알았는데 살아 있어요! 길고 비쩍 마른 몸을 나뭇가지로 완벽하게 위장해 천적의 눈을 감쪽같이 속인답니다. 바람이 불면 가지처럼 살랑살랑 몸을 흔들고, 다리가 떨어져도 다시 자라나는 신기한 곤충이에요.", + "habitatHint": "낮 시간, 잎이 무성한 참나무와 밤나무 가지 사이", "spritePath": "Sprites/Insects/stick_insect_long", "rewardCoins": 24 }, { "insectId": "beetle_longhorn_rosalia", "displayName": "꽃하늘소", - "description": "하늘색 몸에 검은 점이 아름다운 희귀 하늘소다.", - "habitatHint": "낮 시간, 활엽수", + "description": "몸길이만큼 길고 우아한 더듬이를 자랑하는 하늘빛 고운 딱정벌레예요. 푸른 잿빛 날개에 검은 점이 박힌 모습이 무척 아름다워 비단 하늘소라 불린답니다. 튼튼한 큰턱으로 나무를 갉으며, 애벌레 때는 몇 년이나 나무속에서 자라요.", + "habitatHint": "한여름 한낮, 오래된 활엽수 줄기와 그루터기", "spritePath": "Sprites/Insects/beetle_longhorn_rosalia", "rewardCoins": 28 }, { "insectId": "hornet_asian", "displayName": "장수말벌", - "description": "세계 최대급 말벌이다. 강한 독과 턱을 가졌다.", - "habitatHint": "낮 시간, 숲속", + "description": "우리나라에서 가장 크고 힘센 말벌로, 윙윙 묵직한 날갯소리만으로도 위풍당당해요. 강한 큰턱으로 다른 벌의 집을 습격할 만큼 사납지만, 그만큼 숲의 곤충 수를 조절하는 든든한 포식자랍니다. 절대 자극하지 말고 멀리서 지켜보는 게 안전해요!", + "habitatHint": "한여름 낮, 깊은 숲속 나무 구멍이나 땅속 집 근처", "spritePath": "Sprites/Insects/hornet_asian", "rewardCoins": 26 }, { "insectId": "dragonfly_emperor", "displayName": "왕잠자리", - "description": "잠자리 중 가장 크고 빠른 공중의 사냥꾼이다.", - "habitatHint": "낮 시간, 연못", + "description": "잠자리 무리 중에서 가장 크고 빠른 하늘의 사냥꾼이에요. 네 장의 투명한 날개를 따로따로 움직여 공중에서 멈추거나 뒤로도 날 수 있고, 작은 날벌레를 날아다니며 척척 잡아먹는답니다. 머리 대부분을 차지하는 커다란 겹눈으로 거의 사방을 한꺼번에 볼 수 있어요!", + "habitatHint": "맑고 더운 한낮, 연못이나 저수지 위를 빠르게 순찰하듯 나는 모습", "spritePath": "Sprites/Insects/dragonfly_emperor", "rewardCoins": 24 }, { "insectId": "cicada_evening", "displayName": "쓰르라미", - "description": "저녁 무렵에만 우는 희귀한 매미다.", - "habitatHint": "저녁 시간, 나무", + "description": "하루 종일이 아니라 해 질 무렵에만 '쓰르람 쓰르람' 하고 우는 수줍은 매미예요. 다른 매미들이 잠잠해지는 저녁에 노래를 시작해서 숲에 시원한 가을 분위기를 더해 준답니다. 우는 건 수컷뿐인데, 배에 있는 얇은 막을 빠르게 떨어 그 큰 소리를 낸대요.", + "habitatHint": "해가 뉘엿뉘엿 지는 늦여름 저녁, 산기슭 나무줄기", "spritePath": "Sprites/Insects/cicada_evening", "rewardCoins": 22 }, { "insectId": "firefly_blue", "displayName": "파란반딧불이", - "description": "희귀한 파란빛을 내는 반딧불이다.", - "habitatHint": "밤 시간, 계곡", + "description": "노란 반딧불이와 달리 차가운 파란빛을 깜빡이는 아주 귀한 반딧불이예요. 배 끝에서 빛을 내는데, 이 빛은 뜨겁지 않은 '차가운 불빛'이라 손으로 만져도 데지 않는답니다. 짝을 찾으려고 빛으로 신호를 주고받기 때문에 깨끗한 물과 어둠이 있어야만 만날 수 있어요.", + "habitatHint": "바람 없는 깊은 밤, 맑은 물이 흐르는 계곡가 풀숲", "spritePath": "Sprites/Insects/firefly_blue", "rewardCoins": 28 }, { "insectId": "butterfly_morpho", "displayName": "모르포나비", - "description": "메탈릭 파란 날개가 보는 각도마다 색이 변하는 신비로운 나비다.", - "habitatHint": "맑은 낮, 깊은 숲", + "description": "보는 각도에 따라 반짝이는 금속 같은 파란 날개를 가진 신비로운 나비예요. 사실 이 파란색은 물감 색이 아니라, 날개 비늘이 빛을 잘게 쪼개 만들어 내는 빛의 마술이랍니다. 날개 뒷면은 칙칙한 갈색이라 접으면 나뭇잎처럼 감쪽같이 숨을 수 있어요.", + "habitatHint": "햇살이 쏟아지는 한낮, 습기 가득한 깊은 숲속 빈터", "spritePath": "Sprites/Insects/butterfly_morpho", "rewardCoins": 42 }, { "insectId": "beetle_hercules", "displayName": "헤라클레스장수풍뎅이", - "description": "세계에서 가장 큰 딱정벌레다. 거대한 뿔이 위압적이다.", - "habitatHint": "밤 시간, 깊은 숲 나무", + "description": "뿔까지 합치면 세상에서 가장 긴 딱정벌레로, 이름처럼 어마어마한 힘을 자랑해요. 위아래로 맞물린 두 개의 커다란 뿔로 다른 수컷을 번쩍 들어 던지며 싸운답니다. 제 몸무게의 수십 배나 되는 무게도 거뜬히 들 수 있어서 곤충 세계의 진정한 힘짱이에요!", + "habitatHint": "무덥고 습한 한밤중, 깊은 숲 큰 나무의 수액이 흐르는 자리", "spritePath": "Sprites/Insects/beetle_hercules", "rewardCoins": 48 }, { "insectId": "mantis_ghost", "displayName": "유령사마귀", - "description": "유령처럼 바람에 흔들리는 기괴한 자태의 사마귀다.", - "habitatHint": "밤 시간, 깊은 숲", + "description": "마른 나뭇잎과 나뭇가지를 똑 닮아 바람이 불면 함께 흔들흔들, 마치 유령처럼 보이는 사마귀예요. 이렇게 감쪽같이 위장한 채 가만히 기다리다가 가까이 온 벌레를 앞다리로 번개같이 낚아챈답니다. 위험을 느끼면 죽은 척 바닥에 툭 떨어지기도 해요.", + "habitatHint": "고요한 밤, 깊은 숲 마른 덤불과 낙엽 사이", "spritePath": "Sprites/Insects/mantis_ghost", "rewardCoins": 44 }, { "insectId": "spider_golden_orb", - "displayName": "무인거미", - "description": "금빛 거미줄을 짓는 거대한 거미다.", - "habitatHint": "아침, 깊은 숲", + "displayName": "황금왕거미", + "description": "햇빛에 황금빛으로 반짝이는 튼튼한 거미줄을 짓는 커다란 거미예요. 이 거미줄은 어찌나 질긴지 같은 굵기의 강철보다도 끈끈하고 잘 늘어난답니다. 알록달록한 큰 몸집은 대부분 암컷이고, 그 곁에 붙은 아주 작은 거미가 수컷이라는 점이 재미있어요.", + "habitatHint": "이슬 맺힌 이른 아침, 깊은 숲 나무 사이에 크게 펼쳐진 거미줄", "spritePath": "Sprites/Insects/spider_golden_orb", "rewardCoins": 40 }, { "insectId": "leaf_insect_phantom", - "displayName": "잎벌레유령", - "description": "나뭇잎과 완벽히 동화되어 찾기 불가능에 가까운 곤충이다.", - "habitatHint": "낮 시간, 깊은 숲", + "displayName": "잎사귀벌레", + "description": "진짜 나뭇잎인지 벌레인지 헷갈릴 만큼 완벽하게 잎을 흉내 낸 곤충이에요. 몸에는 잎맥 무늬와 벌레 먹은 자국까지 그려져 있고, 걸을 땐 바람에 흔들리는 잎처럼 몸을 살랑살랑 움직여 천적을 속인답니다. 한자리에서 잎을 갉아 먹으며 거의 움직이지 않아 찾아내기가 정말 어려워요.", + "habitatHint": "바람 잔잔한 한낮, 깊은 숲 활엽수의 잎이 우거진 가지", "spritePath": "Sprites/Insects/leaf_insect_phantom", "rewardCoins": 46 }, { "insectId": "butterfly_alexandras", "displayName": "알렉산드라비단나비", - "description": "세계에서 가장 큰 나비다. 날개 폭이 30cm에 달한다.", - "habitatHint": "맑은 낮, 전설의 꽃밭", + "description": "날개를 펼치면 거의 30cm에 달하는, 세계에서 가장 큰 나비예요. 파푸아뉴기니의 깊은 숲에만 사는 아주 귀한 나비라, 새처럼 크고 우아하게 활공한답니다. 수컷은 영롱한 청록빛, 암컷은 점잖은 갈색이라 한 종인데도 전혀 다른 모습이에요!", + "habitatHint": "햇살 비치는 한낮, 깊은 열대우림의 높은 나무 꼭대기", "spritePath": "Sprites/Insects/butterfly_alexandras", "rewardCoins": 100 }, { "insectId": "beetle_golden_stag", "displayName": "황금사슴벌레", - "description": "온몸이 황금빛으로 빛나는 전설의 사슴벌레다.", - "habitatHint": "보름달 밤, 고목나무", + "description": "온몸이 진짜 황금처럼 반짝반짝 빛나는 전설의 사슴벌레예요. 보통 사슴벌레처럼 커다란 뿔 같은 턱으로 나무 수액을 차지하려 다른 곤충과 힘겨루기를 하지요. 보름달 밤에만 모습을 드러낸다고 전해져, 한 번이라도 본 사람은 행운이 따른다는 이야기가 있어요.", + "habitatHint": "보름달이 뜬 맑은 밤, 숲 깊은 곳의 고목나무 수액 주변", "spritePath": "Sprites/Insects/beetle_golden_stag", "rewardCoins": 100 }, { "insectId": "dragonfly_ancient", "displayName": "고대잠자리", - "description": "선사시대에서 온 것 같은 거대한 원시 잠자리다.", - "habitatHint": "비 온 뒤 저녁, 연못", + "description": "공룡이 살던 시절부터 날아다닌 것 같은 거대한 원시 잠자리예요. 옛날 아주 먼 옛날에는 날개를 펴면 사람 팔만큼 컸다고 하는데, 지금도 네 장의 날개를 자유자재로 움직여 공중에서 멈췄다 쏜살같이 날아가지요. 작은 벌레들을 사냥하는 하늘의 최강 사냥꾼이랍니다.", + "habitatHint": "비가 그친 직후의 저녁 무렵, 안개가 옅게 깔린 연못 위", "spritePath": "Sprites/Insects/dragonfly_ancient", "rewardCoins": 120 }, { "insectId": "gacha_golden_ladybug", "displayName": "황금무당벌레", - "description": "온몸이 순금빛으로 빛나는 전설적인 무당벌레. 랜덤 상자에서만 만날 수 있다.", - "habitatHint": "랜덤 상자 전용", + "description": "보통 무당벌레는 빨간 등에 검은 점이 콕콕 박혀 있지만, 이 친구는 온몸이 순금처럼 반짝여요. 작은 몸으로 또르르 굴러가듯 잎사귀 위를 누비며 진딧물을 사냥하는 부지런한 사냥꾼이지요. 너무나 귀해서 오직 랜덤 상자 속에서만 만날 수 있는 행운의 벌레랍니다.", + "habitatHint": "랜덤 상자에서만 만날 수 있어요", "spritePath": "Sprites/Insects/gacha_golden_ladybug", "rewardCoins": 35 }, { "insectId": "gacha_crystal_dragonfly", "displayName": "수정잠자리", - "description": "투명한 수정 날개를 가진 신비로운 잠자리. 햇빛에 무지개빛으로 빛난다.", - "habitatHint": "랜덤 상자 전용", + "description": "투명한 수정으로 빚은 듯 맑은 날개를 가진 신비로운 잠자리예요. 햇빛이 비치면 날개가 무지개처럼 알록달록 빛나서 마치 보석이 날아다니는 것 같답니다. 잠자리답게 큰 눈으로 사방을 살피며 공중에서 작은 곤충을 낚아채는 솜씨가 일품이에요.", + "habitatHint": "랜덤 상자에서만 만날 수 있어요", "spritePath": "Sprites/Insects/gacha_crystal_dragonfly", "rewardCoins": 50 }, { "insectId": "gacha_shadow_mantis", "displayName": "그림자사마귀", - "description": "검은 안개를 두른 사마귀. 어둠 속에서 붉은 눈만 빛난다.", - "habitatHint": "랜덤 상자 전용", + "description": "온몸을 검은 안개로 휘감은 신비로운 사마귀예요. 어둠 속에 완전히 녹아들어 오직 붉게 타오르는 두 눈만 반짝이며, 낫처럼 굽은 앞다리를 소리 없이 휘둘러 먹이를 사냥하지요. 그림자처럼 조용해서, 곁에 있어도 좀처럼 눈치채기 어려운 수집가들의 로망이랍니다.", + "habitatHint": "랜덤 상자 전용 — 칠흑 같은 그믐밤의 기운을 머금은 희귀 곤충", "spritePath": "Sprites/Insects/gacha_shadow_mantis", "rewardCoins": 55 }, { "insectId": "gacha_rainbow_butterfly", "displayName": "무지개나비", - "description": "7색 날개를 가진 전설의 나비. 날갯짓할 때마다 무지개가 펼쳐진다.", - "habitatHint": "랜덤 상자 전용", + "description": "일곱 빛깔이 한데 어우러진 전설의 나비예요. 살랑살랑 날갯짓을 할 때마다 공중에 작은 무지개가 그려져, 지나간 자리마다 알록달록한 빛 가루가 흩날린답니다. 비 갠 하늘에 걸린 무지개를 그대로 옮겨 담은 듯, 보기만 해도 마음이 환해지는 귀한 친구예요.", + "habitatHint": "랜덤 상자 전용 — 비 갠 뒤 무지개가 뜬 하늘의 빛을 품은 전설 곤충", "spritePath": "Sprites/Insects/gacha_rainbow_butterfly", "rewardCoins": 120 }, { "insectId": "gacha_diamond_beetle", "displayName": "다이아몬드풍뎅이", - "description": "다이아몬드처럼 빛나는 등껍질을 가진 풍뎅이. 모든 수집가의 꿈.", - "habitatHint": "랜덤 상자 전용", + "description": "등껍질이 다이아몬드처럼 영롱하게 반짝이는 풍뎅이예요. 빛을 받으면 단단한 겉날개가 수백 개의 보석 조각처럼 반짝거려, 가만히 있어도 주위가 환해진답니다. 좀처럼 만나기 힘든 보물 같은 존재라, 모든 수집가가 한 번쯤 품에 안기를 꿈꾸는 곤충이지요.", + "habitatHint": "랜덤 상자 전용 — 보석처럼 빛나는 최상급 수집 곤충", "spritePath": "Sprites/Insects/gacha_diamond_beetle", "rewardCoins": 130 }, { "insectId": "gacha_neon_firefly", "displayName": "네온반딧불이", - "description": "형광색으로 빛나는 반딧불이. 밤하늘에 네온사인처럼 흔적을 남긴다.", - "habitatHint": "랜덤 상자 전용", + "description": "형광빛으로 또렷하게 빛나는 신기한 반딧불이예요. 보통 반딧불이가 은은하게 깜빡인다면, 이 친구는 마치 거리의 네온사인처럼 선명한 빛줄기를 남기며 밤하늘을 가른답니다. 어두울수록 빛이 더 또렷해져, 까만 밤을 작은 빛의 그림으로 수놓는 멋진 친구예요.", + "habitatHint": "랜덤 상자 전용 — 칠흑 같은 밤일수록 더 또렷이 빛나는 희귀 곤충", "spritePath": "Sprites/Insects/gacha_neon_firefly", "rewardCoins": 30 }, { "insectId": "gacha_ice_spider", "displayName": "얼음거미", - "description": "차가운 푸른빛의 거미. 얼음 결정으로 된 거미줄을 친다.", - "habitatHint": "랜덤 상자 전용", + "description": "차가운 푸른빛이 감도는 신비로운 거미예요. 거미줄마저 반짝이는 얼음 결정으로 짜내어, 햇빛이 닿으면 서리꽃처럼 영롱하게 빛난답니다. 닿으면 손끝이 시릴 것 같은 서늘한 분위기를 풍기지만, 그 정교한 얼음 거미줄은 누구나 한 번쯤 들여다보고 싶어 하는 작은 예술이에요.", + "habitatHint": "랜덤 상자 전용 — 한겨울 서리의 냉기를 머금은 희귀 곤충", "spritePath": "Sprites/Insects/gacha_ice_spider", "rewardCoins": 48 }, { "insectId": "gacha_phantom_moth", "displayName": "환영나방", - "description": "반투명한 날개를 가진 신비로운 나방. 달빛 아래 환영처럼 나타났다 사라진다.", - "habitatHint": "랜덤 상자 전용", + "description": "반투명한 날개를 가진 신비로운 나방이에요. 달빛이 날개를 비추면 유리처럼 속이 비쳐 보이고, 한 번 날아오르면 환영처럼 스르르 나타났다가 어느새 사라져 버린답니다. 잡았다 싶으면 손가락 사이로 빠져나가는 듯한 몽환적인 매력 때문에, 한 번 보면 잊기 힘든 곤충이지요.", + "habitatHint": "랜덤 상자 전용 — 환한 달빛 아래 어른거리는 몽환의 곤충", "spritePath": "Sprites/Insects/gacha_phantom_moth", "rewardCoins": 32 }, { "insectId": "gacha_storm_hornet", "displayName": "폭풍말벌", - "description": "번개를 두른 강력한 말벌. 날갯짓에서 전기가 튄다.", - "habitatHint": "랜덤 상자 전용", + "description": "번개의 기운을 두른 강력한 말벌이에요. 빠르게 날갯짓할 때마다 몸 주위로 작은 전기가 찌릿찌릿 튀어, 마치 폭풍을 통째로 데리고 다니는 것 같답니다. 윙윙거리는 날개 소리가 천둥처럼 우렁차서, 가까이 다가가면 등골이 오싹해지는 박력 만점의 친구예요.", + "habitatHint": "랜덤 상자 전용 — 천둥번개 치는 폭풍의 기운을 두른 희귀 곤충", "spritePath": "Sprites/Insects/gacha_storm_hornet", "rewardCoins": 52 }, { "insectId": "gacha_celestial_beetle", - "displayName": "천상의풍뎅이", - "description": "별빛을 담은 최상위 풍뎅이. 밤하늘의 별자리가 등껍질에 새겨져 있다.", - "habitatHint": "랜덤 상자 전용", + "displayName": "천상의 풍뎅이", + "description": "밤하늘의 별빛을 그대로 담은 최상위 풍뎅이예요. 둥근 등껍질에는 반짝이는 별자리가 또렷이 새겨져 있어, 가만히 들여다보면 작은 우주를 손에 쥔 듯한 기분이 든답니다. 좀처럼 만날 수 없는 전설 중의 전설이라, 이 친구를 품에 안으면 모든 수집가가 부러워할 거예요.", + "habitatHint": "랜덤 상자 전용 — 별이 쏟아지는 밤하늘의 정수를 담은 최상위 곤충", "spritePath": "Sprites/Insects/gacha_celestial_beetle", "rewardCoins": 150 } ] -} +} \ No newline at end of file diff --git a/Assets/Scripts/Battle/BattleArenaController.cs b/Assets/Scripts/Battle/BattleArenaController.cs index 6ed23e5..5c496b6 100644 --- a/Assets/Scripts/Battle/BattleArenaController.cs +++ b/Assets/Scripts/Battle/BattleArenaController.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Collections.Generic; using InsectGame.Core; using InsectGame.Data; using InsectGame.Spawning; @@ -8,8 +9,6 @@ namespace InsectGame.Battle { public class BattleArenaController : MonoBehaviour { - public static BattleArenaController Instance { get; private set; } - private GameObject arenaRoot; private GameObject playerModel; private GameObject enemyModel; @@ -24,6 +23,7 @@ public class BattleArenaController : MonoBehaviour private Vector3 bossBattlePos; private bool isActive; + private CameraFollower cachedCameraFollower; private int selectedTeamIndex = -1; @@ -31,13 +31,23 @@ public class BattleArenaController : MonoBehaviour public Vector3 ArenaCenter => arenaCenter; public Vector3 PlayerModelPos => playerBattlePos; public Vector3 EnemyModelPos => enemyBattlePos; + public GameObject PlayerModel => playerModel; + public GameObject EnemyModel => enemyModel; + public GameObject BossModel => bossModel; + public int TeamModelCount => teamModels != null ? teamModels.Length : 0; + public GameObject GetTeamModel(int index) + { + if (teamModels == null || index < 0 || index >= teamModels.Length) return null; + return teamModels[index]; + } public void SetSelectedTeamIndex(int index) { selectedTeamIndex = index; } - private void Awake() + // 씬 전환/컴포넌트 비활성화 시 정리 — CleanupArena 위임 (DRY + model 필드 null화 포함). + // 옛은 OnDisable과 CleanupArena가 동일 작업을 중복 수행 + OnDisable에 model null화 누락. + private void OnDisable() { - if (Instance != null && Instance != this) { Destroy(gameObject); return; } - Instance = this; + CleanupArena(); } public void SetupNormalBattle(InsectData playerInsect, int playerLevel, @@ -49,8 +59,9 @@ public void SetupNormalBattle(InsectData playerInsect, int playerLevel, // 아레나를 필드와 완전 분리된 위치에 생성 (필드 오브젝트와 겹침 방지) arenaCenter = new Vector3(1000f, 0f, 1000f); - playerBattlePos = arenaCenter + new Vector3(-2.5f, 0.5f, 0f); - enemyBattlePos = arenaCenter + new Vector3(2.5f, 0.5f, 0f); + // 플레이어 앞(가까이), 적 뒤(멀리) — 깊이감 있는 대결 구도 + playerBattlePos = arenaCenter + new Vector3(-1.2f, 0.5f, -2.5f); + enemyBattlePos = arenaCenter + new Vector3(1.2f, 0.5f, 2.5f); arenaRoot = new GameObject("BattleArena"); arenaRoot.transform.position = arenaCenter; @@ -63,7 +74,10 @@ public void SetupNormalBattle(InsectData playerInsect, int playerLevel, enemyModel = CreateBattleInsect(enemyInsect, enemyLevel, enemyShiny, enemyBattlePos, 1.5f); enemyModel.name = "BattleInsect_Enemy"; - enemyModel.transform.rotation = Quaternion.Euler(0f, 180f, 0f); + + // 서로 마주보게 + playerModel.transform.LookAt(enemyBattlePos); + enemyModel.transform.LookAt(playerBattlePos); // 카메라를 아레나 정면으로 이동 SetupBattleCamera(); @@ -80,8 +94,9 @@ public void SetupRaidBattle(InsectData bossInsect, int bossLevel, bool bossShiny // 아레나를 필드와 완전 분리된 위치에 생성 (필드 오브젝트와 겹침 방지) arenaCenter = new Vector3(1000f, 0f, 1000f); - bossBattlePos = arenaCenter + new Vector3(0f, 1f, 3f); - playerBattlePos = arenaCenter + new Vector3(0f, 0.5f, -3f); + // 보스: 멀리 위에, 팀: 가까이 아래 — 올려보는 구도 + bossBattlePos = arenaCenter + new Vector3(0f, 1.2f, 4f); + playerBattlePos = arenaCenter + new Vector3(0f, 0.5f, -3.5f); enemyBattlePos = bossBattlePos; arenaRoot = new GameObject("RaidArena"); @@ -98,15 +113,18 @@ public void SetupRaidBattle(InsectData bossInsect, int bossLevel, bool bossShiny int count = teamInsects != null ? teamInsects.Length : 0; teamModels = new GameObject[count]; - float spacing = count > 1 ? 4f / (count - 1) : 0f; - float startX = count > 1 ? -2f : 0f; + // 호 형태 배치: 카메라에서 봤을 때 겹치지 않게 for (int i = 0; i < count; i++) { if (teamInsects[i] == null) continue; - Vector3 pos = arenaCenter + new Vector3(startX + i * spacing, 0.5f, -3f); + float t = count > 1 ? (float)i / (count - 1) : 0.5f; + float x = Mathf.Lerp(-2.5f, 2.5f, t); + float z = -3.5f + Mathf.Abs(t - 0.5f) * 2f; // 양 끝이 약간 뒤로 + Vector3 pos = arenaCenter + new Vector3(x, 0.5f, z); teamModels[i] = CreateBattleInsect(teamInsects[i], teamLevels[i], false, pos, 1.0f); teamModels[i].name = $"RaidInsect_Team_{i}"; + teamModels[i].transform.LookAt(bossBattlePos); // 보스를 바라봄 } SetupBattleCamera(); @@ -126,6 +144,26 @@ private GameObject CreateBattleInsect(InsectData insectData, int level, bool isS return insectObj; } + // 1v1 배틀 중 플레이어 곤충 교체 시 호출. 이전 모델을 파괴하고 새 곤충으로 재생성. + public void RebuildPlayerInsect(InsectData newInsect, int newLevel) + { + if (!isActive || arenaRoot == null || newInsect == null) return; + + if (playerModel != null) + { + Destroy(playerModel); + playerModel = null; + } + + playerModel = CreateBattleInsect(newInsect, newLevel, false, playerBattlePos, 1.5f); + playerModel.name = "BattleInsect_Player"; + + if (enemyModel != null) + playerModel.transform.LookAt(enemyModel.transform.position); + else + playerModel.transform.LookAt(enemyBattlePos); + } + private void CreateArenaFloor() { arenaFloor = GameObject.CreatePrimitive(PrimitiveType.Cylinder); @@ -226,20 +264,23 @@ public void PlayAttackAnimation(bool isPlayerAttacking, System.Action onImpact = if (attacker == null || target == null) return; - StartCoroutine(AttackCoroutine(attacker, attacker.transform.position, target.transform.position, onImpact)); + StartCoroutine(AttackCoroutine(attacker, target, onImpact)); } - private IEnumerator AttackCoroutine(GameObject attacker, Vector3 startPos, Vector3 targetPos, System.Action onImpact) + private IEnumerator AttackCoroutine(GameObject attacker, GameObject target, System.Action onImpact) { - float t = 0f; + Vector3 startPos = attacker.transform.position; + Vector3 targetPos = target != null ? target.transform.position : startPos; - // Phase 1: rush (0~0.3s) + // 근접: 적 앞 95%까지 러시 → 임팩트 → 원위치 복귀 + // Phase 1: rush 95% (0~0.3s) + float t = 0f; while (t < 0.3f) { t += Time.deltaTime; float progress = t / 0.3f; float eased = progress * progress * (3f - 2f * progress); - Vector3 pos = Vector3.Lerp(startPos, targetPos, eased * 0.7f); + Vector3 pos = Vector3.Lerp(startPos, targetPos, eased * 0.95f); pos.y += Mathf.Sin(eased * Mathf.PI) * 0.8f; attacker.transform.position = pos; yield return null; @@ -248,17 +289,17 @@ private IEnumerator AttackCoroutine(GameObject attacker, Vector3 startPos, Vecto onImpact?.Invoke(); CreateImpactEffect(targetPos); - GameObject target = (attacker == playerModel) ? enemyModel : playerModel; if (target != null) StartCoroutine(ShakeModel(target, 0.3f, 0.3f)); // Phase 2: return (0.3s) t = 0f; + Vector3 contactPos = Vector3.Lerp(startPos, targetPos, 0.95f); while (t < 0.3f) { t += Time.deltaTime; float progress = t / 0.3f; - attacker.transform.position = Vector3.Lerp(targetPos * 0.7f + startPos * 0.3f, startPos, progress); + attacker.transform.position = Vector3.Lerp(contactPos, startPos, progress); yield return null; } @@ -417,28 +458,50 @@ private void SetupBattleCamera() Camera cam = Camera.main; if (cam == null) return; - // 카메라를 아레나 정면 위에서 약간 비스듬히 내려다보게 - Vector3 camPos = arenaCenter + new Vector3(0f, 5f, -7f); + bool isRaid = bossModel != null; + Vector3 camPos; + Vector3 lookTarget; + + if (isRaid) + { + // 레이드: 팀 뒤쪽 높은 곳에서 보스를 올려보는 각도 + // 보스의 거대함과 위압감을 강조 + camPos = arenaCenter + new Vector3(0f, 4f, -7.5f); + lookTarget = bossBattlePos + Vector3.up * 0.8f; + } + else + { + // 1v1: 플레이어 뒤 어깨 너머에서 적을 바라보는 각도 + // 대결 긴장감 + 플레이어 곤충이 가까이 보임 + camPos = playerBattlePos + new Vector3(1.5f, 2.8f, -4f); + lookTarget = Vector3.Lerp(playerBattlePos, enemyBattlePos, 0.6f) + Vector3.up * 0.3f; + } + cam.transform.position = camPos; - cam.transform.LookAt(arenaCenter + Vector3.up * 0.5f); + cam.transform.LookAt(lookTarget); - // CameraFollower의 배틀 모드도 이 위치로 오버라이드 CameraFollower follower = cam.GetComponent(); if (follower != null) { follower.EnterBattleMode(playerBattlePos, enemyBattlePos); - // 오버라이드: 직접 위치 설정 cam.transform.position = camPos; - cam.transform.LookAt(arenaCenter + Vector3.up * 0.5f); + cam.transform.LookAt(lookTarget); } } // ===== 3D Skill Effect System ===== public void PlaySkillEffect(bool isPlayerAttacking, InsectElement element, SkillEffectType effectType, System.Action onImpact = null) + { + PlaySkillEffect(isPlayerAttacking, element, effectType, onImpact, false); + } + + public void PlaySkillEffect(bool isPlayerAttacking, InsectElement element, SkillEffectType effectType, System.Action onImpact, bool isMelee) { if (!isActive) return; + if (AudioManager.Instance != null) AudioManager.Instance.PlaySkillSFX(element); + if (effectType == SkillEffectType.BuffAttack) { PlayBuffEffect(isPlayerAttacking, element); @@ -452,10 +515,10 @@ public void PlaySkillEffect(bool isPlayerAttacking, InsectElement element, Skill return; } - StartCoroutine(SkillAttackCoroutine(isPlayerAttacking, element, onImpact)); + StartCoroutine(SkillAttackCoroutine(isPlayerAttacking, element, onImpact, isMelee)); } - private IEnumerator SkillAttackCoroutine(bool isPlayerAttacking, InsectElement element, System.Action onImpact) + private IEnumerator SkillAttackCoroutine(bool isPlayerAttacking, InsectElement element, System.Action onImpact, bool isMelee) { GameObject attacker = isPlayerAttacking ? playerModel : enemyModel; if (isPlayerAttacking && teamModels != null && selectedTeamIndex >= 0 && selectedTeamIndex < teamModels.Length) @@ -468,15 +531,61 @@ private IEnumerator SkillAttackCoroutine(bool isPlayerAttacking, InsectElement e Vector3 targetPos = target.transform.position; Color elemColor = GetElementColor3D(element); + if (isMelee) + { + // 근접 스킬: 러시 → 임팩트 → 복귀 (투사체 없음) + float t = 0f; + while (t < 0.3f) + { + t += Time.deltaTime; + float progress = t / 0.3f; + float eased = progress * progress * (3f - 2f * progress); + Vector3 pos = Vector3.Lerp(startPos, targetPos, eased * 0.95f); + pos.y += Mathf.Sin(eased * Mathf.PI) * 0.8f; + attacker.transform.position = pos; + CreateTrailParticle(pos, elemColor, 0.1f); + yield return null; + } + + onImpact?.Invoke(); + CreateElementImpact3D(targetPos, element, elemColor); + StartCoroutine(ShakeModel(target, 0.4f, 0.35f)); + + if (cachedCameraFollower == null && Camera.main != null) + cachedCameraFollower = Camera.main.GetComponent(); + if (cachedCameraFollower != null) + { + float intensity = (element == InsectElement.Earth || element == InsectElement.Metal) ? 0.35f : 0.2f; + cachedCameraFollower.Shake(intensity, 0.25f); + } + + if (AudioManager.Instance != null) + AudioManager.Instance.PlaySFX(SfxType.Hit); + + // 복귀 + t = 0f; + Vector3 contactPos = Vector3.Lerp(startPos, targetPos, 0.95f); + while (t < 0.3f) + { + t += Time.deltaTime; + float progress = t / 0.3f; + attacker.transform.position = Vector3.Lerp(contactPos, startPos, progress); + yield return null; + } + attacker.transform.position = startPos; + yield break; + } + + // 원거리: 투사체 발사 // Phase 1: element projectile (0~0.4s) GameObject projectile = CreateElementProjectile(element, elemColor); projectile.transform.position = startPos + Vector3.up * 0.5f; - float t = 0f; - while (t < 0.4f) + float tt = 0f; + while (tt < 0.4f) { - t += Time.deltaTime; - float progress = t / 0.4f; + tt += Time.deltaTime; + float progress = tt / 0.4f; float eased = progress * progress * (3f - 2f * progress); Vector3 pos = Vector3.Lerp(startPos + Vector3.up * 0.5f, targetPos + Vector3.up * 0.5f, eased); pos += GetElementTrajectoryOffset(element, progress); @@ -493,6 +602,15 @@ private IEnumerator SkillAttackCoroutine(bool isPlayerAttacking, InsectElement e CreateElementImpact3D(targetPos, element, elemColor); StartCoroutine(ShakeModel(target, 0.4f, 0.35f)); + // 카메라 쉐이크 (속성별 강도 차이) + if (cachedCameraFollower == null && Camera.main != null) + cachedCameraFollower = Camera.main.GetComponent(); + if (cachedCameraFollower != null) + { + float intensity = (element == InsectElement.Earth || element == InsectElement.Metal) ? 0.35f : 0.2f; + cachedCameraFollower.Shake(intensity, 0.25f); + } + if (AudioManager.Instance != null) AudioManager.Instance.PlaySFX(SfxType.Hit); @@ -1437,6 +1555,25 @@ private IEnumerator DebuffEffectCoroutine(Vector3 pos) // --- Skill Effect Helpers --- + // UI 효과 텍스트 색상 (1v1 / 5v1 컨트롤러 공용) + public static Color GetUIElementColor(InsectElement element) + { + switch (element) + { + case InsectElement.Bug: return new Color(0.7f, 0.85f, 0.3f); + case InsectElement.Metal: return new Color(0.75f, 0.78f, 0.85f); + case InsectElement.Earth: return new Color(0.65f, 0.45f, 0.25f); + case InsectElement.Leaf: return new Color(0.35f, 0.8f, 0.35f); + case InsectElement.Water: return new Color(0.3f, 0.65f, 1f); + case InsectElement.Wind: return new Color(0.7f, 0.95f, 0.9f); + case InsectElement.Light: return new Color(1f, 0.95f, 0.55f); + case InsectElement.Electric: return new Color(1f, 0.95f, 0.3f); + case InsectElement.Dark: return new Color(0.45f, 0.35f, 0.6f); + case InsectElement.Poison: return new Color(0.7f, 0.4f, 0.85f); + default: return new Color(0.9f, 0.9f, 0.9f); + } + } + private Color GetElementColor3D(InsectElement element) { switch (element) @@ -1515,8 +1652,292 @@ private static Material CreateTransparentMaterial(Color color) // ===== End Skill Effect System ===== + // ===== Faint / Hit Flash / Floating Effect Text ===== + + public class EffectTextEntry + { + public string text; + public Color color; + public float startTime; + public float duration; + } + + private readonly List activeEffectTexts = new List(); + private const int MaxConcurrentEffectTexts = 3; + private GUIStyle effectTextStyle; + private GUIStyle effectTextShadowStyle; + + // HitFlash/Faint 호출 시마다 GetComponentsInChildren 반복 방지용 캐시 + private readonly Dictionary rendererCache = new Dictionary(); + + private MeshRenderer[] GetRenderersCached(GameObject model) + { + if (model == null) return new MeshRenderer[0]; + if (rendererCache.TryGetValue(model, out var cached)) + { + if (cached != null && cached.Length > 0 && cached[0] != null) + return cached; + rendererCache.Remove(model); + } + var fresh = model.GetComponentsInChildren(); + rendererCache[model] = fresh; + return fresh; + } + + public IReadOnlyList GetActiveEffectTexts() => activeEffectTexts; + + public void PlayEffectText(string text, Color color) + { + if (string.IsNullOrEmpty(text)) return; + // 가장 오래된 항목 제거 (최대 3개 유지) + if (activeEffectTexts.Count >= MaxConcurrentEffectTexts) + activeEffectTexts.RemoveAt(0); + + activeEffectTexts.Add(new EffectTextEntry + { + text = text, + color = color, + startTime = Time.time, + duration = 1.0f + }); + } + + private void OnGUI() + { + if (!isActive) return; + if (activeEffectTexts.Count == 0) return; + + float now = Time.time; + // 만료 항목 정리 + for (int i = activeEffectTexts.Count - 1; i >= 0; i--) + { + EffectTextEntry e = activeEffectTexts[i]; + if (e == null || now - e.startTime >= e.duration) + activeEffectTexts.RemoveAt(i); + } + + if (activeEffectTexts.Count == 0) return; + + if (effectTextStyle == null) + { + effectTextStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 40, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.MiddleCenter + }; + effectTextShadowStyle = new GUIStyle(effectTextStyle); + } + GUIStyle style = effectTextStyle; + GUIStyle shadowStyle = effectTextShadowStyle; + + float centerX = Screen.width * 0.5f; + float baseY = Screen.height * 0.35f; + float stackSpacing = 56f; + float width = 600f; + float height = 50f; + + Color prevColor = GUI.color; + for (int i = 0; i < activeEffectTexts.Count; i++) + { + EffectTextEntry e = activeEffectTexts[i]; + if (e == null) continue; + + float elapsed = now - e.startTime; + float progress = Mathf.Clamp01(elapsed / e.duration); + float alpha = 1f - progress; + float yOffset = -40f * progress; // 위로 떠오름 + float y = baseY + i * stackSpacing + yOffset; + Rect rect = new Rect(centerX - width * 0.5f, y, width, height); + + // Shadow + GUI.color = new Color(0f, 0f, 0f, alpha * 0.7f); + Rect shadowRect = new Rect(rect.x + 2f, rect.y + 2f, rect.width, rect.height); + GUI.Label(shadowRect, e.text, shadowStyle); + + // Main + Color c = e.color; + c.a *= alpha; + GUI.color = c; + GUI.Label(rect, e.text, style); + } + GUI.color = prevColor; + } + + public IEnumerator PlayFaintCoroutine(GameObject model) + { + if (model == null) yield break; + + Vector3 originalPos = model.transform.position; + Quaternion originalRot = model.transform.rotation; + Vector3 targetPos = originalPos + new Vector3(0f, -0.2f, 0f); + Quaternion targetRot = originalRot * Quaternion.Euler(0f, 0f, 90f); + + MeshRenderer[] renderers = GetRenderersCached(model); + int count = renderers != null ? renderers.Length : 0; + Color[] originalColors = new Color[count]; + bool[] hasBaseColor = new bool[count]; + Color[] originalBaseColors = new Color[count]; + + for (int i = 0; i < count; i++) + { + if (renderers[i] == null) continue; + Material mat = renderers[i].material; // 인스턴스 + // Standard/URP 셰이더를 Transparent(Fade) 모드로 전환해야 알파 페이드가 시각적으로 보임. + SetMaterialTransparentFade(mat); + originalColors[i] = mat.HasProperty("_Color") ? mat.color : Color.white; + if (mat.HasProperty("_BaseColor")) + { + hasBaseColor[i] = true; + originalBaseColors[i] = mat.GetColor("_BaseColor"); + } + } + + float duration = 0.6f; + float t = 0f; + while (t < duration) + { + if (model == null) yield break; + t += Time.deltaTime; + float progress = Mathf.Clamp01(t / duration); + float eased = progress * progress * (3f - 2f * progress); + + model.transform.position = Vector3.Lerp(originalPos, targetPos, eased); + model.transform.rotation = Quaternion.Slerp(originalRot, targetRot, eased); + + float alpha = 1f - progress; + for (int i = 0; i < count; i++) + { + if (renderers[i] == null) continue; + Material mat = renderers[i].material; + if (mat.HasProperty("_Color")) + { + Color c = originalColors[i]; + c.a = originalColors[i].a * alpha; + mat.color = c; + } + if (hasBaseColor[i] && mat.HasProperty("_BaseColor")) + { + Color c = originalBaseColors[i]; + c.a = originalBaseColors[i].a * alpha; + mat.SetColor("_BaseColor", c); + } + } + yield return null; + } + + if (model != null) model.SetActive(false); + } + + // Standard / URP Lit 머티리얼을 Transparent(Fade) 모드로 전환. + // Opaque 모드에서는 알파 변경이 시각적으로 보이지 않으므로 페이드 전에 반드시 호출. + private static void SetMaterialTransparentFade(Material mat) + { + if (mat == null) return; + // Standard (Built-in) + if (mat.HasProperty("_Mode")) + { + mat.SetFloat("_Mode", 2f); // Fade + mat.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha); + mat.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); + mat.SetInt("_ZWrite", 0); + mat.DisableKeyword("_ALPHATEST_ON"); + mat.EnableKeyword("_ALPHABLEND_ON"); + mat.DisableKeyword("_ALPHAPREMULTIPLY_ON"); + } + // URP Lit + if (mat.HasProperty("_Surface")) + { + mat.SetFloat("_Surface", 1f); // Transparent + if (mat.HasProperty("_Blend")) mat.SetFloat("_Blend", 0f); // Alpha + mat.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha); + mat.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); + mat.SetInt("_ZWrite", 0); + mat.DisableKeyword("_SURFACE_TYPE_OPAQUE"); + mat.EnableKeyword("_SURFACE_TYPE_TRANSPARENT"); + } + mat.renderQueue = 3000; + } + + public IEnumerator PlayHitFlashCoroutine(GameObject model) + { + if (model == null) yield break; + + Vector3 originalPos = model.transform.position; + // 모델 정면 방향 기준 뒤로 0.3 백스텝 (forward의 반대) + Vector3 backDir = -model.transform.forward; + if (backDir.sqrMagnitude < 0.001f) backDir = Vector3.back; + Vector3 backPos = originalPos + backDir.normalized * 0.3f; + + MeshRenderer[] renderers = GetRenderersCached(model); + int count = renderers != null ? renderers.Length : 0; + Color[] originalColors = new Color[count]; + bool[] hasBaseColor = new bool[count]; + Color[] originalBaseColors = new Color[count]; + + for (int i = 0; i < count; i++) + { + if (renderers[i] == null) continue; + Material mat = renderers[i].material; + originalColors[i] = mat.HasProperty("_Color") ? mat.color : Color.white; + if (mat.HasProperty("_BaseColor")) + { + hasBaseColor[i] = true; + originalBaseColors[i] = mat.GetColor("_BaseColor"); + } + } + + float duration = 0.15f; + float t = 0f; + while (t < duration) + { + if (model == null) yield break; + t += Time.deltaTime; + float progress = Mathf.Clamp01(t / duration); + // 0~0.5 백스텝, 0.5~1 복귀 + float backWeight = progress < 0.5f ? (progress / 0.5f) : (1f - (progress - 0.5f) / 0.5f); + model.transform.position = Vector3.Lerp(originalPos, backPos, backWeight); + + // 빨강 추가: 0 → 0.5 → 0 (피크 0.5) + float redWeight = Mathf.Sin(progress * Mathf.PI) * 0.5f; + for (int i = 0; i < count; i++) + { + if (renderers[i] == null) continue; + Material mat = renderers[i].material; + if (mat.HasProperty("_Color")) + { + Color flashed = Color.Lerp(originalColors[i], Color.red, redWeight); + flashed.a = originalColors[i].a; + mat.color = flashed; + } + if (hasBaseColor[i] && mat.HasProperty("_BaseColor")) + { + Color flashed = Color.Lerp(originalBaseColors[i], Color.red, redWeight); + flashed.a = originalBaseColors[i].a; + mat.SetColor("_BaseColor", flashed); + } + } + yield return null; + } + + // 원위치 / 원색 복원 + if (model == null) yield break; + model.transform.position = originalPos; + for (int i = 0; i < count; i++) + { + if (renderers[i] == null) continue; + Material mat = renderers[i].material; + if (mat.HasProperty("_Color")) mat.color = originalColors[i]; + if (hasBaseColor[i] && mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", originalBaseColors[i]); + } + } + + // ===== End Faint / Hit Flash / Floating Effect Text ===== + public void CleanupArena() { + // 진행 중인 Faint/HitFlash/스킬 코루틴이 destroyed GameObject에 접근하지 않도록 우선 정지. + StopAllCoroutines(); isActive = false; if (arenaRoot != null) { @@ -1527,6 +1948,8 @@ public void CleanupArena() enemyModel = null; bossModel = null; teamModels = null; + activeEffectTexts.Clear(); + rendererCache.Clear(); } public void HighlightTeamMember(int index) diff --git a/Assets/Scripts/Battle/InsectBattleController.cs b/Assets/Scripts/Battle/InsectBattleController.cs index 9cbecb1..346000f 100644 --- a/Assets/Scripts/Battle/InsectBattleController.cs +++ b/Assets/Scripts/Battle/InsectBattleController.cs @@ -3,6 +3,7 @@ using InsectGame.Data; using InsectGame.Spawning; using UnityEngine; +using System.Collections; using System.Collections.Generic; namespace InsectGame.Battle @@ -14,6 +15,7 @@ public class InsectBattleController : MonoBehaviour [SerializeField] private PlayerProgressController playerProgress; [SerializeField] private PlayerItemInventory itemInventory; [SerializeField] private Dex.DexController dexController; + [SerializeField] private BattleArenaController arena; public event Action BattleEnded; public event Action BattleUpdated; @@ -32,6 +34,8 @@ public class InsectBattleController : MonoBehaviour private int lastItemCount; private bool lastPlayerWon; + private BattleArenaController Arena => arena; + public void StartBattle(InsectData playerInsect, int playerLevel, InsectEntity enemy, Action onStarted = null, InsectSkill[] equippedSkills = null, Core.PlayerInsectData playerPid = null) { if (playerInsect == null || enemy == null || enemy.Data == null) @@ -96,6 +100,7 @@ public void UseBasicAttack() int damage = Mathf.Max(1, Mathf.RoundToInt(playerStats.Attack * 0.7f)); enemyStats.ApplyDamage(damage, playerStats.Attack, enemyStats.Defense); + TryPlayHitFlash(false); if (enemyStats.CurrentHp > 0) { @@ -121,6 +126,9 @@ public bool TryEscape() if (escaped) { + // 도주 성공 시에도 enemyEntity Despawn — 사용자 의도("전투 끝나면 사라져야"). + // 옛은 도주 후 곤충이 필드에 잔존했고 같은 적이 그대로 다시 만남 가능. + if (enemyEntity != null) enemyEntity.Despawn(); BattleEnded?.Invoke(false); return true; } @@ -212,25 +220,38 @@ private InsectSkill GetSkill(InsectData data, int index) private void ApplySkill(InsectBattleStats attacker, InsectBattleStats defender, InsectSkill skill, bool isPlayer) { + // 방어자(맞는 쪽)가 플래시되어야 함. isPlayer는 공격자 기준이므로 반전. + bool defenderIsPlayer = !isPlayer; + if (skill == null) { int damage = GetDamage(attacker, 10); defender.ApplyDamage(damage, attacker.Attack, defender.Defense); + TryPlayHitFlash(defenderIsPlayer); return; } + // 스킬 이름 효과 텍스트 (속성 색상) + if (!string.IsNullOrEmpty(skill.displayName)) + { + TryPlayEffectText($"{skill.displayName}!", BattleArenaController.GetUIElementColor(skill.element)); + } + switch (skill.effectType) { case SkillEffectType.BuffAttack: AddEffect(isPlayer, skill.effectValue, skill.effectDurationTurns); + TryPlayEffectText("공격력 상승!", new Color(1f, 0.8f, 0.3f)); break; case SkillEffectType.DebuffAttack: AddEffect(!isPlayer, -skill.effectValue, skill.effectDurationTurns); + TryPlayEffectText("공격력 하락!", new Color(0.6f, 0.4f, 0.9f)); break; default: int baseDamage = skill.power; int damage = GetDamage(attacker, baseDamage); defender.ApplyDamage(damage, attacker.Attack, defender.Defense); + TryPlayHitFlash(defenderIsPlayer); break; } } @@ -362,7 +383,10 @@ private void CheckEnd() } bool entityShiny = enemyEntity != null && enemyEntity.IsShiny; - playerCollection?.AddCapturedInsect(enemyEntity.Data.insectId, enemyEntity.Level, entityShiny); + if (playerCollection != null) + playerCollection.AddCapturedInsect(enemyEntity.Data.insectId, enemyEntity.Level, entityShiny); + else + Debug.LogError("[Battle] playerCollection null — 캡처 보상 손실: " + enemyEntity.Data.insectId); if (dexController != null) { @@ -375,6 +399,7 @@ private void CheckEnd() lastItemId = itemId; lastItemCount = itemCount; + TryPlayFaint(false); enemyEntity.Despawn(); } @@ -385,13 +410,57 @@ private void CheckEnd() } else if (playerStats.CurrentHp <= 0) { - if (PlayerFainted != null) - PlayerFainted.Invoke(); - else - BattleEnded?.Invoke(false); + TryPlayFaint(true); + TryPlayEffectText("쓰러졌다!", new Color(0.9f, 0.2f, 0.2f)); + // 패배 시에도 enemyEntity Despawn — 옛은 패배 시 곤충이 필드에 잔존했고, 다음 + // 진입 시 같은 곤충 중첩 발동 가능 (사용자 보고: "전투 끝나면 사라져야"). + if (enemyEntity != null) enemyEntity.Despawn(); + // 핸들러 예외 격리 — 한 구독자 예외가 BattleEnded fallback을 차단하지 않게 + bool fainted = false; + try + { + if (PlayerFainted != null) + { + PlayerFainted.Invoke(); + fainted = true; + } + } + catch (System.Exception e) + { + Debug.LogWarning($"[InsectBattle] PlayerFainted 핸들러 예외: {e.Message}"); + } + if (!fainted) BattleEnded?.Invoke(false); + } + } + + // ── 액션 헬퍼 (BattleArenaController 시각 코루틴 호출 wrapper) ── + + private void TryPlayHitFlash(bool isPlayerSide) + { + if (Arena == null) return; + GameObject model = isPlayerSide ? Arena.PlayerModel : Arena.EnemyModel; + if (model != null && model.activeInHierarchy) + { + StartCoroutine(Arena.PlayHitFlashCoroutine(model)); + } + } + + private void TryPlayFaint(bool isPlayerSide) + { + if (Arena == null) return; + GameObject model = isPlayerSide ? Arena.PlayerModel : Arena.EnemyModel; + if (model != null && model.activeInHierarchy) + { + StartCoroutine(Arena.PlayFaintCoroutine(model)); } } + private void TryPlayEffectText(string text, Color color) + { + if (Arena == null || string.IsNullOrEmpty(text)) return; + Arena.PlayEffectText(text, color); + } + public void AutoWire(PlayerInsectCollection collection, PlayerCandyInventory candies, PlayerProgressController progress, PlayerItemInventory items) { if (playerCollection == null) @@ -420,6 +489,11 @@ public void AutoWire(Dex.DexController dex) if (dexController == null) dexController = dex; } + public void AutoWire(BattleArenaController a) + { + if (arena == null) arena = a; + } + private OutfitBonusProvider outfitBonus; public void AutoWire(OutfitBonusProvider bonus) @@ -437,6 +511,10 @@ public void SwapPlayerInsect(InsectData newInsect, int newLevel, InsectSkill[] e playerCooldowns = new int[skillCount]; effects.Clear(); RecalculateBonuses(); + + // Faint 후 비활성/페이드된 PlayerModel을 새 곤충으로 재생성 + if (Arena != null) Arena.RebuildPlayerInsect(newInsect, newLevel); + BattleUpdated?.Invoke(playerStats, enemyStats); } diff --git a/Assets/Scripts/Battle/InsectBattleUIController.cs b/Assets/Scripts/Battle/InsectBattleUIController.cs index 04c49f2..cfb741a 100644 --- a/Assets/Scripts/Battle/InsectBattleUIController.cs +++ b/Assets/Scripts/Battle/InsectBattleUIController.cs @@ -2,6 +2,7 @@ using InsectGame.Core; using InsectGame.Data; using InsectGame.Spawning; +using InsectGame.UI; using TMPro; using UnityEngine; using UnityEngine.UI; @@ -22,12 +23,6 @@ public class InsectBattleUIController : MonoBehaviour [SerializeField] private Image[] skillIconImages; [SerializeField] private Image[] skillCooldownImages; [SerializeField] private Image[] skillBorderImages; - [Header("Rarity Colors")] - [SerializeField] private Color commonColor = new Color(0.8f, 0.8f, 0.8f, 1f); - [SerializeField] private Color uncommonColor = new Color(0.5f, 0.9f, 0.5f, 1f); - [SerializeField] private Color rareColor = new Color(0.4f, 0.7f, 1f, 1f); - [SerializeField] private Color epicColor = new Color(0.8f, 0.4f, 1f, 1f); - [SerializeField] private Color legendaryColor = new Color(1f, 0.75f, 0.2f, 1f); [SerializeField] private string skillIconResourceRoot = "SkillIcons"; [SerializeField] private Text playerHpTextLegacy; [SerializeField] private Text enemyHpTextLegacy; @@ -81,11 +76,8 @@ private void Start() private void OnEnable() { - if (battleController != null) - { - battleController.BattleUpdated += HandleBattleUpdated; - battleController.BattleEnded += HandleBattleEnded; - } + // 구독은 AutoWire에서만 수행 (중복 구독 방지). + // OnEnable 호출 시점에 battleController가 이미 wired 되어 있다면 AutoWire 안의 -= 후 += 패턴이 안전 보장. } private void OnDisable() @@ -178,7 +170,7 @@ private void RefreshSkillButtons() if (skillBorderImages != null && i < skillBorderImages.Length && skillBorderImages[i] != null) { - skillBorderImages[i].color = playerInsect != null ? GetRarityColor(playerInsect.rarity) : commonColor; + skillBorderImages[i].color = GetRarityColor(playerInsect != null ? playerInsect.rarity : InsectRarity.Common); skillBorderImages[i].enabled = true; } } @@ -326,21 +318,7 @@ private int GetSkillCooldownMax(int index) private Color GetRarityColor(InsectRarity rarity) { - switch (rarity) - { - case InsectRarity.Common: - return commonColor; - case InsectRarity.Uncommon: - return uncommonColor; - case InsectRarity.Rare: - return rareColor; - case InsectRarity.Epic: - return epicColor; - case InsectRarity.Legendary: - return legendaryColor; - default: - return commonColor; - } + return UITheme.Instance.GetInsectRarityColor(rarity); } private Sprite ResolveSkillIcon(InsectSkill skill) diff --git a/Assets/Scripts/Battle/RaidBattleController.cs b/Assets/Scripts/Battle/RaidBattleController.cs index 3bcbbfc..fee4e89 100644 --- a/Assets/Scripts/Battle/RaidBattleController.cs +++ b/Assets/Scripts/Battle/RaidBattleController.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using InsectGame.Core; using InsectGame.Data; @@ -14,6 +15,7 @@ public class RaidBattleController : MonoBehaviour [SerializeField] private PlayerProgressController playerProgress; [SerializeField] private Dex.DexController dexController; [SerializeField] private TrainingManager trainingManager; + [SerializeField] private BattleArenaController arena; public event Action RaidUpdated; public event Action RaidEnded; @@ -123,23 +125,30 @@ public void UseSkill(int skillIndex) int damage = Mathf.RoundToInt((baseDmg + attacker.Level * 2) * mult); damage = Mathf.Max(1, damage); + // 스킬 이름 효과 텍스트 (속성 색상) + if (skill != null && !string.IsNullOrEmpty(skill.displayName)) + TryPlayEffectText($"{skill.displayName}!", BattleArenaController.GetUIElementColor(skill.element)); + if (skill.effectType == SkillEffectType.Damage) { BossStats.ApplyDamage(damage, attacker.Attack, BossStats.Defense); LastDamageToBoss = Mathf.Max(1, Mathf.RoundToInt(damage * (attacker.Attack / (float)Mathf.Max(1, BossStats.Defense)))); LastActionText = $"{attacker.Data.displayName}의 {skill.displayName}!"; + TryPlayBossHitFlash(); } else if (skill.effectType == SkillEffectType.BuffAttack) { attacker.AttackBonus += skill.effectValue; LastDamageToBoss = 0; LastActionText = $"{attacker.Data.displayName}의 {skill.displayName}! ATK UP!"; + TryPlayEffectText("공격력 상승!", new Color(1f, 0.8f, 0.3f)); } else { BossStats.AttackBonus -= skill.effectValue; LastDamageToBoss = 0; LastActionText = $"{attacker.Data.displayName}의 {skill.displayName}! Boss ATK DOWN!"; + TryPlayEffectText("보스 공격력 하락!", new Color(0.6f, 0.4f, 0.9f)); } TeamCooldowns[ActiveSlot][skillIndex] = skill.cooldownTurns; @@ -176,10 +185,17 @@ private void ExecuteBossTurn() bossCooldown = 3; BossUsedAoe = true; int aoeDmg = Mathf.Max(1, bossDmg * 2 / 3); + TryPlayEffectText("전체 공격!", new Color(1f, 0.5f, 0.2f)); for (int i = 0; i < TeamStats.Length; i++) { if (TeamStats[i].CurrentHp > 0) + { + bool wasAlive = TeamStats[i].CurrentHp > 0; TeamStats[i].ApplyDamage(aoeDmg, BossStats.Attack, TeamStats[i].Defense); + TryPlayTeamHitFlash(i); + if (wasAlive && TeamStats[i].CurrentHp <= 0) + TryPlayTeamFaint(i); + } } LastDamageToTeam = aoeDmg; LastHitSlot = -1; @@ -195,15 +211,24 @@ private void ExecuteBossTurn() if (alive.Count > 0) { int target = alive[UnityEngine.Random.Range(0, alive.Count)]; + bool wasAlive = TeamStats[target].CurrentHp > 0; TeamStats[target].ApplyDamage(bossDmg, BossStats.Attack, TeamStats[target].Defense); LastDamageToTeam = Mathf.Max(1, Mathf.RoundToInt(bossDmg * (BossStats.Attack / (float)Mathf.Max(1, TeamStats[target].Defense)))); LastHitSlot = target; LastActionText += $"\n{bd.displayName}이(가) {TeamStats[target].Data.displayName}을(를) 공격!"; UniteGauge = Mathf.Min(UniteGauge + 10f, UniteGaugeMax); + + TryPlayTeamHitFlash(target); + if (wasAlive && TeamStats[target].CurrentHp <= 0) + { + TryPlayTeamFaint(target); + TryPlayEffectText($"{TeamStats[target].Data.displayName} 쓰러짐!", new Color(0.9f, 0.2f, 0.2f)); + } } } - if (TeamStats[ActiveSlot].CurrentHp <= 0) + if (ActiveSlot >= 0 && ActiveSlot < TeamStats.Length + && TeamStats[ActiveSlot] != null && TeamStats[ActiveSlot].CurrentHp <= 0) { int next = FindFirstAlive(); if (next >= 0) ActiveSlot = next; @@ -223,6 +248,8 @@ private void CheckEnd() { if (BossStats.CurrentHp <= 0) { + TryPlayBossFaint(); + TryPlayEffectText("보스 격파!", new Color(0.3f, 1f, 0.5f)); PlayerWon = true; IsActive = false; OnRaidVictory(); @@ -230,8 +257,12 @@ private void CheckEnd() } else if (FindFirstAlive() < 0) { + TryPlayEffectText("팀 전멸!", new Color(0.9f, 0.2f, 0.2f)); PlayerWon = false; IsActive = false; + // 레이드 패배 시에도 BossEntity Despawn — 옛은 패배 시 보스가 필드에 잔존, + // 다음 진입 시 같은 보스 중첩 발동 가능 (사용자 보고: "전투 끝나면 사라져야"). + if (BossEntity != null) BossEntity.Despawn(); RaidEnded?.Invoke(false); } } @@ -251,10 +282,14 @@ private void OnRaidVictory() dexController.RegisterEncounter(bd.insectId); dexController.RegisterCapture(bd.insectId); } - if (playerCollection != null && BossEntity != null) + if (BossEntity != null) { - bool bossShiny = BossEntity.IsShiny; - playerCollection.AddCapturedInsect(bd.insectId, BossEntity.Level, bossShiny); + // playerCollection 가드 분리 — null이어도 보스 Despawn은 항상 보장 + if (playerCollection != null) + { + bool bossShiny = BossEntity.IsShiny; + playerCollection.AddCapturedInsect(bd.insectId, BossEntity.Level, bossShiny); + } BossEntity.Despawn(); } } @@ -269,6 +304,8 @@ public void UseUniteAttack() LastDamageToBoss = 0; UniteSlotDamages = new int[TeamStats.Length]; + TryPlayEffectText("★ 합체공격! ★", new Color(1f, 0.9f, 0.3f)); + int totalDmg = 0; List names = new List(); for (int i = 0; i < TeamStats.Length; i++) @@ -282,13 +319,18 @@ public void UseUniteAttack() int baseDmg = 15 + atk.Level * 2; float mult = Mathf.Clamp(1f + atk.AttackBonus, 0.3f, 3f); int dmg = Mathf.Max(1, Mathf.RoundToInt(baseDmg * mult * 1.5f)); - int actual = Mathf.Max(1, Mathf.RoundToInt(dmg * (atk.Attack / (float)Mathf.Max(1, BossStats.Defense)))); + // 실제 보스 HP 감소량과 동일 공식 사용 — ApplyDamage 내부 clamp(0.5, 2.5) 정합. + // 옛은 actual = dmg × (atk/def) (clamp 없음) → UI 표시가 실제보다 크게 보였고, + // totalDmg += dmg (방어 미반영)는 LastDamageToBoss를 과대 표시. + float ratio = Mathf.Clamp(atk.Attack / (float)Mathf.Max(1, BossStats.Defense), 0.5f, 2.5f); + int actual = Mathf.Max(1, Mathf.RoundToInt(dmg * ratio)); UniteSlotDamages[i] = actual; - totalDmg += dmg; + totalDmg += actual; names.Add(atk.Data.displayName); BossStats.ApplyDamage(dmg, atk.Attack, BossStats.Defense); } + TryPlayBossHitFlash(); LastDamageToBoss = totalDmg; LastActionText = $"★ 합체공격! {string.Join(" + ", names)} ★"; @@ -330,6 +372,54 @@ public void AutoWire(PlayerInsectCollection col, PlayerCandyInventory candy, if (dexController == null) dexController = dex; if (trainingManager == null) trainingManager = tm; } + + public void AutoWire(BattleArenaController a) + { + if (arena == null) arena = a; + } + + // ── 액션 헬퍼 (BattleArenaController 시각 코루틴 wrapper) ── + + private BattleArenaController Arena => arena; + + private void TryPlayBossHitFlash() + { + if (Arena == null) return; + GameObject m = Arena.BossModel; + if (m != null && m.activeInHierarchy) + StartCoroutine(Arena.PlayHitFlashCoroutine(m)); + } + + private void TryPlayTeamHitFlash(int index) + { + if (Arena == null) return; + GameObject m = Arena.GetTeamModel(index); + if (m != null && m.activeInHierarchy) + StartCoroutine(Arena.PlayHitFlashCoroutine(m)); + } + + private void TryPlayBossFaint() + { + if (Arena == null) return; + GameObject m = Arena.BossModel; + if (m != null && m.activeInHierarchy) + StartCoroutine(Arena.PlayFaintCoroutine(m)); + } + + private void TryPlayTeamFaint(int index) + { + if (Arena == null) return; + GameObject m = Arena.GetTeamModel(index); + if (m != null && m.activeInHierarchy) + StartCoroutine(Arena.PlayFaintCoroutine(m)); + } + + private void TryPlayEffectText(string text, Color color) + { + if (Arena == null || string.IsNullOrEmpty(text)) return; + Arena.PlayEffectText(text, color); + } + } public class RaidBossStats : InsectBattleStats diff --git a/Assets/Scripts/Capture/CaptureController.cs b/Assets/Scripts/Capture/CaptureController.cs index d1192a2..4418890 100644 --- a/Assets/Scripts/Capture/CaptureController.cs +++ b/Assets/Scripts/Capture/CaptureController.cs @@ -36,7 +36,9 @@ public void AttemptCapture(InsectEntity target, float timing01, float extraBonus float chance = Mathf.Clamp01(CalculateSuccessChance(target.Data, target.Level, timing01) + extraBonus); bool success = UnityEngine.Random.value <= chance; - CaptureResolved?.Invoke(target, success); + // 이벤트 핸들러 예외가 DEX/보상 처리를 차단하지 않도록 격리 + try { CaptureResolved?.Invoke(target, success); } + catch (System.Exception e) { Debug.LogWarning($"[CaptureController] CaptureResolved 핸들러 예외: {e.Message}"); } if (dexController != null) { @@ -67,6 +69,12 @@ public void AttemptCapture(InsectEntity target, float timing01, float extraBonus insectCollection?.AddCapturedInsect(target.Data.insectId, target.Level); target.Despawn(); } + else + { + // 포획 실패 시에도 항상 Despawn — 사용자 의도("미니게임 끝나면 사라져야"). + // 옛은 50% 확률 잔존이라 같은 곤충에 중첩 미니게임 발동 + 필드 중복 인스턴스 가능. + target.Despawn(); + } } private float CalculateSuccessChance(InsectData data, int insectLevel, float timing01) diff --git a/Assets/Scripts/Capture/CaptureInputController.cs b/Assets/Scripts/Capture/CaptureInputController.cs index 5e4bc69..c61b423 100644 --- a/Assets/Scripts/Capture/CaptureInputController.cs +++ b/Assets/Scripts/Capture/CaptureInputController.cs @@ -13,6 +13,7 @@ public class CaptureInputController : MonoBehaviour [SerializeField] private CaptureMinigameController minigame; [SerializeField] private CaptureChoiceUI choiceUi; [SerializeField] private BattleScreenUI battleScreen; + [SerializeField] private RaidBattleUI raidScreen; [SerializeField] private DexScreenUI dexScreen; private InsectEntity nearestInsect; @@ -30,6 +31,7 @@ private void Update() bool anyBlockingUI = (minigame != null && minigame.IsActive) || (choiceUi != null && choiceUi.IsChoiceOpen) || (battleScreen != null && battleScreen.IsBattleActive) + || (raidScreen != null && raidScreen.IsRaidActive) || (dexScreen != null && dexScreen.IsOpen); if (Input.GetKeyDown(KeyCode.E) && !anyBlockingUI) @@ -49,6 +51,7 @@ private void OnGUI() bool anyUI = (minigame != null && minigame.IsActive) || (choiceUi != null && choiceUi.IsChoiceOpen) || (battleScreen != null && battleScreen.IsBattleActive) + || (raidScreen != null && raidScreen.IsRaidActive) || (dexScreen != null && dexScreen.IsOpen); Event evt = Event.current; @@ -146,9 +149,10 @@ public void AutoWire(CaptureChoiceUI choice) if (choiceUi == null) choiceUi = choice; } - public void AutoWire(BattleScreenUI battle, DexScreenUI dex) + public void AutoWire(BattleScreenUI battle, RaidBattleUI raid, DexScreenUI dex) { if (battleScreen == null) battleScreen = battle; + if (raidScreen == null) raidScreen = raid; if (dexScreen == null) dexScreen = dex; } } diff --git a/Assets/Scripts/Capture/CaptureMinigameController.cs b/Assets/Scripts/Capture/CaptureMinigameController.cs index c4876ff..c2e4ecf 100644 --- a/Assets/Scripts/Capture/CaptureMinigameController.cs +++ b/Assets/Scripts/Capture/CaptureMinigameController.cs @@ -44,6 +44,42 @@ private enum Phase { Ready, Attempt1, Attempt2, Attempt3, Done } private float itemTimeMult = 1f; private float itemCaptureBonus; + // OnGUI 스타일 캐시 — 옛은 매 프레임 6개 new GUIStyle (라인 244/279/288/309/318/337). + // 콤보 별은 loop 안이라 매 프레임 최대 3개 추가 → 60 FPS × 6~9 = 360~540회/초 회귀. + // textColor가 동적인 스타일(title/phase/result)은 베이스만 캐시 후 textColor 매번 할당. + private GUIStyle titleStyleCache; + private GUIStyle phaseStyleCache; + private GUIStyle starStyleCache; + private GUIStyle captureBtnCache; + private GUIStyle cancelBtnCache; + private GUIStyle resultStyleCache; + private bool stylesInitialized; + + private void InitMinigameStyles() + { + if (stylesInitialized) return; + stylesInitialized = true; + + titleStyleCache = new GUIStyle(GUI.skin.label) + { fontSize = 20, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + + phaseStyleCache = new GUIStyle(GUI.skin.label) + { fontSize = 16, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + + starStyleCache = new GUIStyle(GUI.skin.label) + { fontSize = 26, alignment = TextAnchor.MiddleCenter }; + starStyleCache.normal.textColor = Color.green; + + captureBtnCache = new GUIStyle(GUI.skin.button) + { fontSize = 18, fontStyle = FontStyle.Bold }; + + cancelBtnCache = new GUIStyle(GUI.skin.button) + { fontSize = 16 }; + + resultStyleCache = new GUIStyle(GUI.skin.label) + { fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + } + public void StartMinigame(InsectEntity target) { StartMinigame(target, 1f, 1f, 1f, 0f); @@ -193,6 +229,13 @@ private void StopMinigame() if (playerMovement != null) playerMovement.SetFrozen(false); } + private void OnDisable() + { + // 외부에서 컴포넌트 disable되어도 isActive/playerMovement.frozen이 잔존하지 않도록 보장. + // 옛은 OnDisable 없음 → 씬 전환 시 player가 영구 멈춤 + 다음 활성화 시 OnGUI 미니게임 잔존. + if (isActive) StopMinigame(); + } + private void OnGUI() { if (isActive) @@ -241,11 +284,10 @@ private void DrawMinigame() string rarityName = currentTarget != null && currentTarget.Data != null ? currentTarget.Data.rarity.ToString() : ""; - GUIStyle titleStyle = new GUIStyle(GUI.skin.label) - { fontSize = 20, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - titleStyle.normal.textColor = GetRarityGUIColor(); + InitMinigameStyles(); + titleStyleCache.normal.textColor = GetRarityGUIColor(); GUI.color = Color.white; - GUI.Label(new Rect(x, y + 8, panelW, 30), $"{targetName} [{rarityName}]", titleStyle); + GUI.Label(new Rect(x, y + 8, panelW, 30), $"{targetName} [{rarityName}]", titleStyleCache); float barX = x + 30; float barY = y + 48; @@ -276,20 +318,15 @@ private void DrawMinigame() GUI.DrawTexture(new Rect(cursorX, barY - 4, 6, barH + 8), Texture2D.whiteTexture); GUI.color = Color.white; - GUIStyle phaseStyle = new GUIStyle(GUI.skin.label) - { fontSize = 16, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; string phaseLabel = phase == Phase.Attempt1 ? "1st" : phase == Phase.Attempt2 ? "2nd - Faster!" : "FINAL!"; - phaseStyle.normal.textColor = phase == Phase.Attempt3 ? Color.yellow : Color.white; - GUI.Label(new Rect(x, barY + barH + 4, panelW, 22), phaseLabel, phaseStyle); + phaseStyleCache.normal.textColor = phase == Phase.Attempt3 ? Color.yellow : Color.white; + GUI.Label(new Rect(x, barY + barH + 4, panelW, 22), phaseLabel, phaseStyleCache); for (int i = 0; i < comboHits && i < 3; i++) { - GUIStyle starStyle = new GUIStyle(GUI.skin.label) - { fontSize = 26, alignment = TextAnchor.MiddleCenter }; - starStyle.normal.textColor = Color.green; float starX = x + panelW / 2f - 45 + i * 30; - GUI.Label(new Rect(starX, barY + barH + 22, 28, 28), "*", starStyle); + GUI.Label(new Rect(starX, barY + barH + 22, 28, 28), "*", starStyleCache); } float timerRatio = Mathf.Clamp01(timer / timeLimit); @@ -306,18 +343,14 @@ private void DrawMinigame() float btnW = 140f; float btnH = 34f; - GUIStyle captureBtn = new GUIStyle(GUI.skin.button) - { fontSize = 18, fontStyle = FontStyle.Bold }; GUI.backgroundColor = new Color(0.2f, 0.8f, 0.3f); - if (GUI.Button(new Rect(x + panelW / 2f - btnW - 10, btnY, btnW, btnH), "포획! [Space/클릭]", captureBtn)) + if (GUI.Button(new Rect(x + panelW / 2f - btnW - 10, btnY, btnW, btnH), "포획! [Space/클릭]", captureBtnCache)) { wantConfirm = true; } GUI.backgroundColor = new Color(0.6f, 0.2f, 0.2f); - GUIStyle cancelBtn = new GUIStyle(GUI.skin.button) - { fontSize = 16 }; - if (GUI.Button(new Rect(x + panelW / 2f + 10, btnY, btnW, btnH), "취소 [ESC]", cancelBtn)) + if (GUI.Button(new Rect(x + panelW / 2f + 10, btnY, btnW, btnH), "취소 [ESC]", cancelBtnCache)) { wantCancel = true; } @@ -334,20 +367,20 @@ private void DrawResult() float bounce = 1f + Mathf.Sin(progress * Mathf.PI) * 0.15f; int fontSize = (int)(42 * bounce); - GUIStyle style = new GUIStyle(GUI.skin.label) - { fontSize = fontSize, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - style.normal.textColor = resultSuccess + InitMinigameStyles(); + resultStyleCache.fontSize = fontSize; // fontSize는 bounce에 따라 동적 + resultStyleCache.normal.textColor = resultSuccess ? new Color(0.3f, 1f, 0.5f, alpha) : new Color(1f, 0.4f, 0.3f, alpha); float w = 400; GUI.color = new Color(1, 1, 1, alpha); - GUI.Label(new Rect(cx - w / 2f, baseY, w, 60), resultMessage, style); + GUI.Label(new Rect(cx - w / 2f, baseY, w, 60), resultMessage, resultStyleCache); if (resultSuccess) { float glowSize = 120f + progress * 60f; - Color glowCol = style.normal.textColor; + Color glowCol = resultStyleCache.normal.textColor; GUI.color = new Color(glowCol.r, glowCol.g, glowCol.b, 0.08f * alpha); GUI.DrawTexture(new Rect(cx - glowSize / 2, baseY + 30 - glowSize / 2, glowSize, glowSize), Texture2D.whiteTexture); } diff --git a/Assets/Scripts/Core/AtomicFileWriter.cs b/Assets/Scripts/Core/AtomicFileWriter.cs new file mode 100644 index 0000000..8c9d051 --- /dev/null +++ b/Assets/Scripts/Core/AtomicFileWriter.cs @@ -0,0 +1,38 @@ +using System.IO; +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 원자적 파일 쓰기 헬퍼. 게임 종료 직전 크래시 시 부분 쓰기로 인한 세이브 파일 손상을 차단합니다. + /// 패턴: .tmp 파일에 쓰고 atomic rename으로 원본 교체. File.Replace는 OS 레벨 atomic 보장. + /// SaveService 7개(Progress/Candy/Currency/Item/InsectCollection/BattleTeam/Dex) + CloudSaveManager 캐시 공용. + /// + public static class AtomicFileWriter + { + public static void WriteAllText(string path, string content) + { + if (string.IsNullOrEmpty(path) || content == null) return; + + string tempPath = path + ".tmp"; + try + { + File.WriteAllText(tempPath, content); + if (File.Exists(path)) + { + File.Replace(tempPath, path, null); + } + else + { + File.Move(tempPath, path); + } + } + catch (System.Exception e) + { + Debug.LogWarning($"[AtomicFileWriter] {path} 쓰기 실패: {e.Message}"); + // tmp 파일 잔존 시 정리 + try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { } + } + } + } +} diff --git a/Assets/Scripts/Core/AtomicFileWriter.cs.meta b/Assets/Scripts/Core/AtomicFileWriter.cs.meta new file mode 100644 index 0000000..d4911d1 --- /dev/null +++ b/Assets/Scripts/Core/AtomicFileWriter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3e1ad8fe7e562fd4b9bd94f8ecd93694 \ No newline at end of file diff --git a/Assets/Scripts/Core/AudioManager.cs b/Assets/Scripts/Core/AudioManager.cs index 39b38a4..7b95002 100644 --- a/Assets/Scripts/Core/AudioManager.cs +++ b/Assets/Scripts/Core/AudioManager.cs @@ -11,7 +11,15 @@ public enum BgmType RaidBattle, Victory, Defeat, - Menu + Menu, + // 리전별 탐험 BGM + ExploreMeadow, + ExploreForest, + ExplorePond, + ExploreSwamp, + ExploreMountain, + ExploreGarden, + ExploreRuins } public enum SfxType @@ -37,7 +45,13 @@ public enum SfxType ItemPickup, ItemUse, Equip, - SetComplete + SetComplete, + // 신규 + Footstep, + LevelUpGain, + MenuHover, + Purchase, + Error } /// @@ -51,6 +65,7 @@ public class AudioManager : MonoBehaviour private AudioSource bgmSource; private AudioSource sfxSource; private AudioSource ambientSource; + private AudioSource ambientSource2; // 크로스페이드용 private float masterVolume; private float bgmVolume; @@ -58,6 +73,15 @@ public class AudioManager : MonoBehaviour private BgmType? currentBgmType; private Coroutine crossfadeCoroutine; + private Coroutine ambientCrossfadeCoroutine; + + private float battleIntensity; // 0~1, HP 낮을수록 높음 + private string currentAmbientId; + private bool useSecondaryAmbient; + + private GameClock gameClock; + private float dayNightCheckTimer; + private bool inSubArea; // 서브에리어 진입 시 GameClock 폴링 차단 private readonly Dictionary bgmCache = new Dictionary(); private readonly Dictionary sfxCache = new Dictionary(); @@ -67,10 +91,33 @@ public class AudioManager : MonoBehaviour private void Awake() { + if (Instance != null && Instance != this) + { + Destroy(gameObject); + return; + } Instance = this; + if (transform.parent == null) DontDestroyOnLoad(gameObject); EnsureInitialized(); } + // 모바일 백그라운드 진입 시 BGM/Ambient 음소거 (배터리 절감 + UX) + private void OnApplicationPause(bool pauseStatus) + { + if (bgmSource != null) bgmSource.mute = pauseStatus; + if (ambientSource != null) ambientSource.mute = pauseStatus; + if (ambientSource2 != null) ambientSource2.mute = pauseStatus; + } + + // 다른 앱에서 복귀 시 음소거 해제 + private void OnApplicationFocus(bool hasFocus) + { + if (!hasFocus) return; + if (bgmSource != null) bgmSource.mute = false; + if (ambientSource != null) ambientSource.mute = false; + if (ambientSource2 != null) ambientSource2.mute = false; + } + private void EnsureInitialized() { if (initialized) return; @@ -88,6 +135,10 @@ private void EnsureInitialized() ambientSource.loop = true; ambientSource.playOnAwake = false; + ambientSource2 = gameObject.AddComponent(); + ambientSource2.loop = true; + ambientSource2.playOnAwake = false; + masterVolume = PlayerPrefs.GetFloat( GameConstants.PrefsKeys.MasterVolume, GameConstants.Defaults.MasterVolume); @@ -101,6 +152,8 @@ private void EnsureInitialized() // ── BGM ── + private Coroutine deferredBgmCoroutine; + public void PlayBGM(BgmType type) { EnsureInitialized(); @@ -108,7 +161,10 @@ public void PlayBGM(BgmType type) return; currentBgmType = type; - StartCoroutine(PlayBGMDeferred(type)); + // 이전 deferred 코루틴이 진행 중이면 중단 (BGM 변경 race 방지) + if (deferredBgmCoroutine != null) + StopCoroutine(deferredBgmCoroutine); + deferredBgmCoroutine = StartCoroutine(PlayBGMDeferred(type)); } private System.Collections.IEnumerator PlayBGMDeferred(BgmType type) @@ -123,10 +179,19 @@ private System.Collections.IEnumerator PlayBGMDeferred(BgmType type) StopCoroutine(crossfadeCoroutine); crossfadeCoroutine = StartCoroutine(CrossfadeBGM(clip, 1f)); + deferredBgmCoroutine = null; } public void StopBGM(float fadeTime = 1f) { + // deferred BGM 진행 중이면 함께 중단 — 옛은 PlayBGM 직후 StopBGM 시 + // deferredBgm이 다음 프레임에 CrossfadeBGM 시작하여 StopBGM 의도 깨짐. + if (deferredBgmCoroutine != null) + { + StopCoroutine(deferredBgmCoroutine); + deferredBgmCoroutine = null; + } + if (crossfadeCoroutine != null) StopCoroutine(crossfadeCoroutine); @@ -164,24 +229,121 @@ public void PlayAmbient(string environmentId) { EnsureInitialized(); if (string.IsNullOrEmpty(environmentId)) return; + if (currentAmbientId == environmentId) return; - StartCoroutine(PlayAmbientDeferred(environmentId)); + currentAmbientId = environmentId; + if (ambientCrossfadeCoroutine != null) StopCoroutine(ambientCrossfadeCoroutine); + ambientCrossfadeCoroutine = StartCoroutine(CrossfadeAmbient(environmentId, 0.8f)); } - private System.Collections.IEnumerator PlayAmbientDeferred(string environmentId) + private System.Collections.IEnumerator CrossfadeAmbient(string environmentId, float duration) { yield return null; - AudioClip clip = GetOrCreateAmbientClip(environmentId); if (clip == null) yield break; - ambientSource.clip = clip; - ambientSource.Play(); + // 새 클립을 보조 소스에 로드 + AudioSource newSrc = useSecondaryAmbient ? ambientSource : ambientSource2; + AudioSource oldSrc = useSecondaryAmbient ? ambientSource2 : ambientSource; + useSecondaryAmbient = !useSecondaryAmbient; + + float targetVol = masterVolume * bgmVolume * 0.4f; + newSrc.clip = clip; + newSrc.volume = 0f; + newSrc.Play(); + + float startOldVol = oldSrc.volume; + float elapsed = 0f; + while (elapsed < duration) + { + elapsed += Time.deltaTime; + float t = elapsed / duration; + newSrc.volume = Mathf.Lerp(0f, targetVol, t); + oldSrc.volume = Mathf.Lerp(startOldVol, 0f, t); + yield return null; + } + + newSrc.volume = targetVol; + oldSrc.Stop(); + oldSrc.volume = 0f; + ambientCrossfadeCoroutine = null; } public void StopAmbient() { ambientSource.Stop(); + if (ambientSource2 != null) ambientSource2.Stop(); + currentAmbientId = null; + } + + /// 배틀 인텐시티 (0~1). HP 낮을수록 1에 가까워짐 → BGM pitch/volume 상승. + public void SetBattleIntensity(float intensity01) + { + battleIntensity = Mathf.Clamp01(intensity01); + } + + public void ClearBattleIntensity() + { + battleIntensity = 0f; + if (bgmSource != null) bgmSource.pitch = 1f; + } + + private void Update() + { + if (bgmSource == null) return; + + // 배틀 BGM 인텐시티 + if (bgmSource.isPlaying) + { + if (currentBgmType == BgmType.Battle || currentBgmType == BgmType.RaidBattle) + { + float targetPitch = Mathf.Lerp(1.0f, 1.10f, battleIntensity); + bgmSource.pitch = Mathf.MoveTowards(bgmSource.pitch, targetPitch, Time.deltaTime * 0.3f); + } + else + { + bgmSource.pitch = Mathf.MoveTowards(bgmSource.pitch, 1f, Time.deltaTime * 0.5f); + } + } + + // 낮/밤 환경음 자동 전환 (1초마다 체크, 서브에리어 진입 중에는 스킵) + dayNightCheckTimer += Time.deltaTime; + if (dayNightCheckTimer >= 1f && !inSubArea) + { + dayNightCheckTimer = 0f; + if (gameClock == null) gameClock = FindFirstObjectByType(); + if (gameClock != null) + { + DayPhase phase = gameClock.GetDayPhase(); + string targetAmbient = (phase == DayPhase.Night || phase == DayPhase.Evening) ? "night" : "day"; + if (currentAmbientId != targetAmbient) + PlayAmbient(targetAmbient); + } + } + } + + public void SetSubAreaActive(bool active) + { + bool wasInSubArea = inSubArea; + inSubArea = active; + + // SubArea 이탈 시: 환경음을 day/night으로 즉시 복원 (그렇지 않으면 cave 등 ambient가 계속 재생됨) + if (wasInSubArea && !active) + { + if (gameClock == null) gameClock = FindFirstObjectByType(); + if (gameClock != null) + { + DayPhase phase = gameClock.GetDayPhase(); + string targetAmbient = (phase == DayPhase.Night || phase == DayPhase.Evening) ? "night" : "day"; + PlayAmbient(targetAmbient); + } + else + { + StopAmbient(); + } + // 다음 dayNightCheck가 즉시 동작하도록 타이머 강제 트리거 + dayNightCheckTimer = 1f; + } } // ── 마스터 볼륨 ── @@ -203,6 +365,8 @@ private void ApplyVolumes() sfxSource.volume = masterVolume * sfxVolume; if (ambientSource != null) ambientSource.volume = masterVolume * bgmVolume * 0.4f; + if (ambientSource2 != null) + ambientSource2.volume = masterVolume * bgmVolume * 0.4f; } private IEnumerator CrossfadeBGM(AudioClip newClip, float duration) @@ -301,10 +465,66 @@ private static string BgmTypeToString(BgmType type) case BgmType.Victory: return "victory"; case BgmType.Defeat: return "defeat"; case BgmType.Menu: return "menu"; + case BgmType.ExploreMeadow: return "explore_meadow"; + case BgmType.ExploreForest: return "explore_forest"; + case BgmType.ExplorePond: return "explore_pond"; + case BgmType.ExploreSwamp: return "explore_swamp"; + case BgmType.ExploreMountain: return "explore_mountain"; + case BgmType.ExploreGarden: return "explore_garden"; + case BgmType.ExploreRuins: return "explore_ruins"; default: return "explore"; } } + public void PlayBGMForRegion(string regionId) + { + BgmType type = RegionIdToBgmType(regionId); + PlayBGM(type); + } + + private static BgmType RegionIdToBgmType(string regionId) + { + switch (regionId) + { + case "meadow": return BgmType.ExploreMeadow; + case "forest": return BgmType.ExploreForest; + case "pond": return BgmType.ExplorePond; + case "swamp": return BgmType.ExploreSwamp; + case "mountain": return BgmType.ExploreMountain; + case "garden": return BgmType.ExploreGarden; + case "ruins": return BgmType.ExploreRuins; + default: return BgmType.Explore; + } + } + + public void PlaySkillSFX(InsectGame.Data.InsectElement element) + { + EnsureInitialized(); + string key = "skill_" + ElementToString(element); + AudioClip clip = ProceduralAudioGenerator.GetSFX(key); + if (clip == null) clip = GetOrCreateSfxClip(SfxType.SkillUse); + if (clip != null) + sfxSource.PlayOneShot(clip, masterVolume * sfxVolume); + } + + private static string ElementToString(InsectGame.Data.InsectElement element) + { + switch (element) + { + case InsectGame.Data.InsectElement.Bug: return "bug"; + case InsectGame.Data.InsectElement.Poison: return "poison"; + case InsectGame.Data.InsectElement.Water: return "water"; + case InsectGame.Data.InsectElement.Leaf: return "leaf"; + case InsectGame.Data.InsectElement.Wind: return "wind"; + case InsectGame.Data.InsectElement.Electric: return "electric"; + case InsectGame.Data.InsectElement.Earth: return "earth"; + case InsectGame.Data.InsectElement.Light: return "light"; + case InsectGame.Data.InsectElement.Dark: return "dark"; + case InsectGame.Data.InsectElement.Metal: return "metal"; + default: return "bug"; + } + } + private static string SfxTypeToString(SfxType type) { switch (type) @@ -331,6 +551,11 @@ private static string SfxTypeToString(SfxType type) case SfxType.ItemUse: return "item_use"; case SfxType.Equip: return "equip"; case SfxType.SetComplete: return "set_complete"; + case SfxType.Footstep: return "footstep"; + case SfxType.LevelUpGain: return "level_up_gain"; + case SfxType.MenuHover: return "menu_hover"; + case SfxType.Purchase: return "purchase"; + case SfxType.Error: return "error"; default: return "hit"; } } diff --git a/Assets/Scripts/Core/AuthManager.cs b/Assets/Scripts/Core/AuthManager.cs index fd88669..0c99504 100644 --- a/Assets/Scripts/Core/AuthManager.cs +++ b/Assets/Scripts/Core/AuthManager.cs @@ -25,6 +25,8 @@ public class AuthManager : MonoBehaviour public event Action LoginCompleted; public event Action RegisterCompleted; public event Action LoggedOut; + // 토큰 갱신/인증 실패로 silent 로그아웃되는 경우 사용자에게 알릴 채널 (LoginUI 등 구독). + public event Action AuthFailed; // ── PlayerPrefs 키 ── private const string TokenKey = "InsectGame.Auth.RefreshToken"; @@ -39,6 +41,7 @@ private void Awake() if (Instance == null) { Instance = this; + if (transform.parent == null) DontDestroyOnLoad(gameObject); } else if (Instance != this) { @@ -63,9 +66,32 @@ private void TryAutoLogin() public void RegisterWithEmail(string email, string password, string displayName) { + string validationError = ValidateCredentials(email, password); + if (validationError != null) + { + // Firebase 호출 전 클라이언트 검증 — 빈 칸/짧은 비밀번호 즉시 차단 + AuthFailed?.Invoke(validationError); + RegisterCompleted?.Invoke(false, validationError); + return; + } StartCoroutine(RegisterCoroutine(email, password, displayName)); } + // 이메일/비밀번호 사전 검증 — 빈 칸/형식 오류/짧은 비밀번호 즉시 차단. + // 마스터 계정은 별도 분기에서 처리(LoginWithEmail 라인 115)되므로 검증 우회. + private static string ValidateCredentials(string email, string password) + { + if (string.IsNullOrWhiteSpace(email)) + return "이메일을 입력해주세요"; + if (email.IndexOf('@') < 0 || email.IndexOf('.') < 0) + return "이메일 형식이 올바르지 않습니다"; + if (string.IsNullOrEmpty(password)) + return "비밀번호를 입력해주세요"; + if (password.Length < 6) + return "비밀번호는 6자 이상이어야 합니다"; + return null; + } + private IEnumerator RegisterCoroutine(string email, string password, string displayName) { string json = JsonUtility.ToJson(new AuthRequest @@ -108,7 +134,7 @@ private IEnumerator RegisterCoroutine(string email, string password, string disp public void LoginWithEmail(string email, string password) { - // 마스터 계정 체크 — Firebase 없이 로컬 즉시 로그인 + // 마스터 계정 체크 — Firebase 없이 로컬 즉시 로그인 (검증 우회) if (email == "pride1119" && password == "qksqhf11!!") { SetLoggedIn("master_admin_001", "pride1119", "마스터", "master_token", "master_refresh"); @@ -117,6 +143,14 @@ public void LoginWithEmail(string email, string password) return; } + string validationError = ValidateCredentials(email, password); + if (validationError != null) + { + AuthFailed?.Invoke(validationError); + LoginCompleted?.Invoke(false, validationError); + return; + } + StartCoroutine(LoginCoroutine(email, password)); } @@ -285,6 +319,30 @@ private IEnumerator LoginWithIdpCoroutine(string providerId, string idToken) // ── 토큰 갱신 ── + // CloudSave 등이 401 응답 받았을 때 외부에서 즉시 갱신을 트리거하기 위한 진입점. + // 자동 갱신(AutoRefreshIfNeeded)과 동시 호출 방지 위해 refreshInProgress 가드 공유. + // 결과: onComplete(true)=갱신 성공 후 재시도 가능, onComplete(false)=실패(AuthFailed 발화됨). + public IEnumerator TryRefreshTokenForRetry(Action onComplete) + { + if (!IsLoggedIn || string.IsNullOrEmpty(RefreshToken)) + { + onComplete?.Invoke(false); + yield break; + } + // 이미 진행 중이면 끝날 때까지 대기 후 결과 판정 (IdToken 갱신 여부) + string oldToken = IdToken; + if (refreshInProgress) + { + while (refreshInProgress) yield return null; + onComplete?.Invoke(IsLoggedIn && IdToken != oldToken); + yield break; + } + refreshInProgress = true; + yield return RefreshIdTokenCoroutine(); + refreshInProgress = false; + onComplete?.Invoke(IsLoggedIn && IdToken != oldToken); + } + private IEnumerator RefreshIdTokenCoroutine() { string json = "{\"grant_type\":\"refresh_token\",\"refresh_token\":\"" @@ -310,12 +368,20 @@ private IEnumerator RefreshIdTokenCoroutine() Email = PlayerPrefs.GetString(EmailKey, ""); DisplayName = PlayerPrefs.GetString(NameKey, ""); IsLoggedIn = true; + // SetLoggedIn 경로를 거치지 않는 갱신이라 acquiredAt를 여기서 직접 갱신. + // 빠뜨리면 TryAutoLogin → 3000초 후 매 프레임 자동 갱신 폭주. + idTokenAcquiredAt = Time.realtimeSinceStartup; SaveTokens(); LoginCompleted?.Invoke(true, null); } else { + // 토큰 갱신 실패 — silent ClearAuth 대신 사용자에게 알림 후 로그아웃 + string reason = req.responseCode == 401 || req.responseCode == 403 + ? "세션 만료 — 다시 로그인 해주세요" + : "인증 갱신 실패 — 네트워크 확인 후 다시 로그인 해주세요"; ClearAuth(); + AuthFailed?.Invoke(reason); } } } @@ -345,6 +411,12 @@ private void ClearAuth() // ── 헬퍼 ── + // Firebase IdToken은 발급 후 1시간 유효. 50분 경과 시 자동 갱신. + private float idTokenAcquiredAt; + private const float IdTokenLifetimeSeconds = 3600f; + private const float IdTokenRefreshAheadSeconds = 600f; // 만료 10분 전 갱신 + private bool refreshInProgress; + private void SetLoggedIn(string uid, string email, string name, string idToken, string refreshToken) { @@ -355,9 +427,31 @@ private void SetLoggedIn(string uid, string email, string name, IdToken = idToken; RefreshToken = refreshToken; IsLoggedIn = true; + idTokenAcquiredAt = Time.realtimeSinceStartup; SaveTokens(); } + private void Update() + { + // 토큰 자동 갱신: 만료 임박 시 1회 RefreshIdTokenCoroutine 호출 + if (!IsLoggedIn || refreshInProgress || string.IsNullOrEmpty(RefreshToken)) return; + float elapsed = Time.realtimeSinceStartup - idTokenAcquiredAt; + if (elapsed >= IdTokenLifetimeSeconds - IdTokenRefreshAheadSeconds) + { + refreshInProgress = true; + StartCoroutine(AutoRefreshThenClear()); + } + } + + private IEnumerator AutoRefreshThenClear() + { + yield return StartCoroutine(RefreshIdTokenCoroutine()); + // RefreshIdTokenCoroutine 성공 시 SetLoggedIn 분기를 안 거치고 IdToken만 갱신하므로 + // idTokenAcquiredAt를 여기서 직접 갱신. + idTokenAcquiredAt = Time.realtimeSinceStartup; + refreshInProgress = false; + } + private void ClearMasterDataIfNeeded(string newUid) { string savedUid = PlayerPrefs.GetString(UidKey, ""); diff --git a/Assets/Scripts/Core/BattleTeamManager.cs b/Assets/Scripts/Core/BattleTeamManager.cs index 99398ae..63256e5 100644 --- a/Assets/Scripts/Core/BattleTeamManager.cs +++ b/Assets/Scripts/Core/BattleTeamManager.cs @@ -63,7 +63,12 @@ public bool SetSlot(int index, string instanceId) } } - saveData.slotIds[index] = normalizedId ?? string.Empty; + string newValue = normalizedId ?? string.Empty; + // no-op 가드 — 같은 값 재설정 시 TeamChanged 발화 안 함. + // 옛은 TutorialQuestManager가 TeamChanged 구독해 q_team 진행도 중복 가산 위험. + if (saveData.slotIds[index] == newValue) return true; + + saveData.slotIds[index] = newValue; Save(saveData); TeamChanged?.Invoke(); return true; @@ -118,7 +123,8 @@ private void MigrateLegacySlots() if (changed) { Save(saveData); - TeamChanged?.Invoke(); + // 마이그레이션은 시스템 내부 정리 — 사용자 액션 아님. TeamChanged 발화 시 TutorialQuestManager가 + // q_team 진행도 자동 가산하는 회귀 차단. UI는 다음 사용자 SetSlot 또는 첫 OnGUI 호출에서 갱신. } } @@ -127,14 +133,22 @@ private BattleTeamSave Load() string path = GetPath(); if (!System.IO.File.Exists(path)) return new BattleTeamSave(); - string json = System.IO.File.ReadAllText(path); - return JsonUtility.FromJson(json) ?? new BattleTeamSave(); + try + { + string json = System.IO.File.ReadAllText(path); + return JsonUtility.FromJson(json) ?? new BattleTeamSave(); + } + catch (System.Exception e) + { + Debug.LogWarning($"[BattleTeamManager] 손상된 세이브 — 기본값으로 시작: {e.Message}"); + return new BattleTeamSave(); + } } private void Save(BattleTeamSave data) { string json = JsonUtility.ToJson(data, true); - System.IO.File.WriteAllText(GetPath(), json); + AtomicFileWriter.WriteAllText(GetPath(), json); } private string GetPath() diff --git a/Assets/Scripts/Core/CameraFollower.cs b/Assets/Scripts/Core/CameraFollower.cs index 9b3164a..00356ad 100644 --- a/Assets/Scripts/Core/CameraFollower.cs +++ b/Assets/Scripts/Core/CameraFollower.cs @@ -1,14 +1,29 @@ using UnityEngine; +using InsectGame.Spawning; namespace InsectGame.Core { public class CameraFollower : MonoBehaviour { [SerializeField] private Transform target; - [SerializeField] private Vector3 offset = new Vector3(0f, 12f, -8f); + // 치비(작아진 캐릭터)에 맞춰 줌인 (0,12,-8)→(0,9,-6). Y:Z=1.5 비율 유지 → 부감 각도 동일, + // 시선벡터 (0,-0.8,0.6) 보존 → 도구 망 디스크 회전(-20°) 정합 그대로. + [SerializeField] private Vector3 offset = new Vector3(0f, 9f, -6f); [SerializeField] private float smoothSpeed = 6f; [SerializeField] private float lookAheadDistance = 2f; + // SubArea 카메라 — 메인 월드와 동일 NormalOffset 사용. 옛 (0,16,-12)는 환경이 캐릭터로부터 + // 분리되어 "공중에 떠있는" 인상, (0,10,-14)도 측면 분리 인상 회귀(사용자 명시 보고). + // 환경 차폐는 SubAreaEnv layer 제외(ResolveObstruction)로 차단, SetSubAreaMode는 baseline 리셋만. + private static readonly Vector3 NormalOffset = new Vector3(0f, 9f, -6f); + private static readonly Vector3 SubAreaOffset = new Vector3(0f, 9f, -6f); + // 카메라-플레이어 사이 큰 정적 scenery(나무 trunk, 벽, 기둥 등) 시야 차단 시 카메라 당김. + // 사용자 보고: 숲/유적 등에서 다 가려져서 안 보임 — 모든 필드 일괄 처리. + [SerializeField] private float minObstructionDistance = 3.5f; + [SerializeField] private float obstructionProbeRadius = 0.4f; + // SphereCastNonAlloc 버퍼 — 매 LateUpdate RaycastHit[] 할당 회피. + private readonly RaycastHit[] obstructionBuffer = new RaycastHit[16]; + private bool battleMode; private Vector3 battlePos; private Quaternion battleRot; @@ -16,15 +31,25 @@ public class CameraFollower : MonoBehaviour private Quaternion normalRot; private float battleTransition; + private float shakeIntensity; + private float shakeDuration; + private float shakeTimer; + private Vector3 baselinePos; // 쉐이크 영향 없는 기본 위치 (다음 프레임 Lerp 기준) + private bool baselineValid; + public void SetTarget(Transform newTarget) { target = newTarget; + // SubArea 좌표 전환 시 옛 baselinePos에서 새 target으로 Lerp되어 첫 프레임 카메라 점프 차단. + baselineValid = false; } public void EnterBattleMode(Vector3 playerPos, Vector3 enemyPos) { battleMode = true; - battleTransition = 0f; + // 이탈 fade-out(battleTransition > 0) 진행 중 재진입 시 transition 보존 — 시각적 끊김 차단. + // 새 진입(이미 normal)이면 0부터 시작. + // 옛은 무조건 0 리셋 → 배틀→이탈→배틀 빠른 전환 시 카메라 끊김. normalPos = transform.position; normalRot = transform.rotation; @@ -38,23 +63,64 @@ public void EnterBattleMode(Vector3 playerPos, Vector3 enemyPos) public void ExitBattleMode() { battleMode = false; + // battle→normal 전환 시 baselinePos가 옛 battlePos 잔존 → normal 첫 프레임 lerpFrom이 + // 멀리서 슬슬 들어오는 어색함. transition 보간이 별도 수행되므로 baseline은 reset. + baselineValid = false; + } + + // 외부에서 target 위치를 직접 점프시킨 경우(SubArea 진입/종료 등) 호출. + // SetTarget과 동일하게 baselinePos stale 차단하나 target 자체는 변경 안 함. + public void ResetBaseline() + { + baselineValid = false; + } + + // SubArea(숲/유적 등) 진입 시 카메라 offset을 더 멀리·높이 조정 — 환경 밀집 차폐 완화. + // 옛은 SubArea에서도 NormalOffset이라 트리/기둥에 시야 차단 빈번 → SphereCast가 카메라를 + // minObstructionDistance(3.5m)까지 당겨 캐릭터가 환경에 묻혀 안 보임. 사용자 보고와 정합. + public void SetSubAreaMode(bool active) + { + offset = active ? SubAreaOffset : NormalOffset; + baselineValid = false; } public bool InBattleMode => battleMode; + /// 카메라 쉐이크 트리거. intensity: 흔들림 강도(0.1~0.5 권장), duration: 지속 시간(초). + public void Shake(float intensity, float duration) + { + shakeIntensity = Mathf.Max(shakeIntensity, intensity); + shakeDuration = Mathf.Max(shakeDuration, duration); + shakeTimer = shakeDuration; + } + + private Vector3 GetShakeOffset() + { + if (shakeTimer <= 0f) return Vector3.zero; + float decay = shakeTimer / shakeDuration; + float curIntensity = shakeIntensity * decay; + return new Vector3( + (Random.value * 2f - 1f) * curIntensity, + (Random.value * 2f - 1f) * curIntensity * 0.6f, + (Random.value * 2f - 1f) * curIntensity); + } + private void LateUpdate() { if (target == null) return; + Vector3 finalPos; + Quaternion finalRot; + if (battleMode) { battleTransition = Mathf.Clamp01(battleTransition + Time.deltaTime * 2.5f); float t = Mathf.SmoothStep(0f, 1f, battleTransition); Vector3 followPos = target.position + offset + target.forward * lookAheadDistance; - transform.position = Vector3.Lerp(followPos, battlePos, t); + finalPos = Vector3.Lerp(followPos, battlePos, t); Quaternion followRot = Quaternion.LookRotation(target.position + Vector3.up - followPos); - transform.rotation = Quaternion.Slerp(followRot, battleRot, t); + finalRot = Quaternion.Slerp(followRot, battleRot, t); } else { @@ -63,19 +129,94 @@ private void LateUpdate() battleTransition = Mathf.Clamp01(battleTransition - Time.deltaTime * 3f); float t = Mathf.SmoothStep(0f, 1f, battleTransition); Vector3 followPos = target.position + offset + target.forward * lookAheadDistance; - transform.position = Vector3.Lerp(followPos, battlePos, t); + finalPos = Vector3.Lerp(followPos, battlePos, t); Quaternion followRot = Quaternion.LookRotation(target.position + Vector3.up - followPos); - transform.rotation = Quaternion.Slerp(followRot, battleRot, t); + finalRot = Quaternion.Slerp(followRot, battleRot, t); } else { Vector3 lookAhead = target.forward * lookAheadDistance; Vector3 desiredPosition = target.position + offset + lookAhead; - transform.position = Vector3.Lerp(transform.position, desiredPosition, smoothSpeed * Time.deltaTime); - Vector3 lookTarget = target.position + Vector3.up * 1f; - transform.LookAt(lookTarget); + Vector3 lookTarget = target.position + Vector3.up * 0.85f; + // 카메라 시야 차단 보정 — 나무/벽/기둥 등 정적 scenery가 카메라-플레이어 + // 사이를 가리면 카메라를 플레이어 쪽으로 당겨 시야 확보. + desiredPosition = ResolveObstruction(lookTarget, desiredPosition); + // 쉐이크 이전의 깨끗한 위치를 기준으로 Lerp (쉐이크 누적 방지) + Vector3 lerpFrom = baselineValid ? baselinePos : transform.position; + finalPos = Vector3.Lerp(lerpFrom, desiredPosition, smoothSpeed * Time.deltaTime); + // LookAt은 쉐이크 없는 기본 위치 기준으로 계산 + finalRot = Quaternion.LookRotation(lookTarget - finalPos, Vector3.up); + } + } + + // 쉐이크 이전 기본 위치 저장 (다음 프레임 Lerp 기준) + baselinePos = finalPos; + baselineValid = true; + + // 쉐이크 오프셋 적용 + if (shakeTimer > 0f) + { + shakeTimer -= Time.unscaledDeltaTime; + finalPos += GetShakeOffset(); + if (shakeTimer <= 0f) + { + shakeIntensity = 0f; + shakeDuration = 0f; } } + + transform.position = finalPos; + transform.rotation = finalRot; + } + + // 카메라 방향 SphereCast로 큰 정적 scenery 차단 검출 후 카메라 거리 단축. + // 모든 필드 일괄 적용: forest trunk, swamp DeadTree, mountain rock, garden hedge/arch, + // ruins wall/pillar 등 — Floor/Ground/Region/Path/Water 평면과 InsectEntity는 제외. + private Vector3 ResolveObstruction(Vector3 lookTarget, Vector3 desiredPos) + { + Vector3 toCam = desiredPos - lookTarget; + float desiredDist = toCam.magnitude; + if (desiredDist <= minObstructionDistance) return desiredPos; + + Vector3 dirToCam = toCam / desiredDist; + int hitCount = Physics.SphereCastNonAlloc(lookTarget, obstructionProbeRadius, dirToCam, obstructionBuffer, desiredDist); + if (hitCount <= 0) return desiredPos; + + float minHitDist = desiredDist; + for (int i = 0; i < hitCount; i++) + { + Collider h = obstructionBuffer[i].collider; + if (h == null || h.isTrigger) continue; + if (target != null && (h.transform.IsChildOf(target) || h.gameObject == target.gameObject)) continue; + // 곤충은 차단 대상 아님 — 화면에 보여야 캡처/배틀 진입 가능 + if (h.GetComponentInParent() != null) continue; + + string n = h.gameObject.name; + // 바닥/길/물/리전 plane 제외 — 카메라 위쪽에서 잡힐 일 거의 없지만 안전망. + // 옛 EndsWith("_Floor")는 "Ruins_MossFloor_0"/"ArenaFloor" 회귀(suffix index/접두 없음). + if (n == "Ground" + || n.StartsWith("Region_") + || n.StartsWith("Ground_") + || n.Contains("Floor") + || n.Contains("Ground") + || n.Contains("Path") + || n.Contains("Water") + || n.Contains("Bank") + || n.Contains("Creek") + || n.Contains("Snow")) continue; + // 너무 낮은 collider(꽃/돌 등)는 시야 거의 안 가림 + if (h.bounds.size.y < 0.6f) continue; + // SubArea 환경(나무/기둥/잎사귀 등)은 차폐 무시 — 캐릭터 가시성 우선. + // 옛은 트리/기둥이 모두 차폐로 잡혀 카메라가 캐릭터까지 당겨져 환경에 묻힘. + if (h.gameObject.layer == SubAreaWorldBuilder.GetSubAreaEnvLayer()) continue; + + float d = obstructionBuffer[i].distance; + if (d < minHitDist) minHitDist = d; + } + + // 최소 거리 보장 — 너무 가까이 가면 1인칭처럼 되어 어색 + float finalDist = Mathf.Max(minObstructionDistance, minHitDist - 0.3f); + return lookTarget + dirToCam * finalDist; } } } diff --git a/Assets/Scripts/Core/CashShopManager.cs b/Assets/Scripts/Core/CashShopManager.cs index dd21ccf..0d5ecd5 100644 --- a/Assets/Scripts/Core/CashShopManager.cs +++ b/Assets/Scripts/Core/CashShopManager.cs @@ -25,8 +25,31 @@ public class CashShopManager : MonoBehaviour private CashShopItem[] shopItems; private int gems; + private PlayerCurrencyWallet wallet; // 보석 이중 관리 동기화용 - public int Gems => gems; + // wallet이 single source of truth. wallet 있으면 wallet.Gems 우선, AutoWire 전에는 캐시 사용. + public int Gems => wallet != null ? wallet.Gems : gems; + + public void AutoWire(PlayerCurrencyWallet w) + { + if (wallet == null) wallet = w; + if (wallet == null) return; + + // 1회 마이그레이션: PlayerPrefs "InsectGame.Gems" 값이 wallet보다 크면 wallet에 반영 후 키 삭제. + // 이후 사이클부터 wallet 단일 소스로 동작 — 이중 관리 종료. + if (wallet.Gems < gems) + { + wallet.AddGems(gems - wallet.Gems); + } + // PlayerPrefs 키는 더 이상 진실의 원천이 아님 — 다음 세션 혼동 방지 + if (PlayerPrefs.HasKey(GemsKey)) + { + PlayerPrefs.DeleteKey(GemsKey); + PlayerPrefs.Save(); + } + gems = wallet.Gems; // 캐시 동기화 + GemsChanged?.Invoke(); + } public event Action ItemPurchased; public event Action GemsChanged; @@ -53,14 +76,14 @@ private void InitializeShopItems() // -- 미니게임 아이템 (보석으로 구매) -- new CashShopItem { itemId = "shop_net_silver", displayName = "은빛 채집망", description = "포획 확률 +15% 증가", category = CashItemCategory.MinigameItem, priceKRW = 0, gemPrice = 200, rewardItemId = "net_silver", rewardCount = 5 }, new CashShopItem { itemId = "shop_net_gold", displayName = "황금 채집망", description = "포획 확률 +30% 증가", category = CashItemCategory.MinigameItem, priceKRW = 0, gemPrice = 400, rewardItemId = "net_gold", rewardCount = 3 }, - new CashShopItem { itemId = "shop_rare_incense", displayName = "희귀 향로", description = "10분간 Rare+ 출현율 2배", category = CashItemCategory.MinigameItem, priceKRW = 0, gemPrice = 350, rewardItemId = "incense_rare", rewardCount = 1 }, + // shop_rare_incense (rewardItemId="incense_rare") — ItemData SO 미정의로 비활성화. /add-item 스킬로 추가 후 복원 new CashShopItem { itemId = "shop_candy_pack", displayName = "캔디 대량팩", description = "캔디 50개", category = CashItemCategory.MinigameItem, priceKRW = 0, gemPrice = 300, rewardItemId = "candy", rewardCount = 50 }, new CashShopItem { itemId = "shop_exp_boost", displayName = "경험치 부스터", description = "10분간 경험치 2배", category = CashItemCategory.MinigameItem, priceKRW = 0, gemPrice = 300, rewardItemId = "exp_boost", rewardCount = 1 }, // -- 랜덤 상자 (보석으로 구매) -- - new CashShopItem { itemId = "box_bronze", displayName = "브론즈 상자", description = "기본 곤충 + 소량 희귀 확률", category = CashItemCategory.GachaBox, priceKRW = 0, gemPrice = 500, rewardCount = 1 }, - new CashShopItem { itemId = "box_silver", displayName = "실버 상자", description = "희귀 곤충 확률 UP!", category = CashItemCategory.GachaBox, priceKRW = 0, gemPrice = 800, rewardCount = 1 }, - new CashShopItem { itemId = "box_gold", displayName = "골드 상자", description = "전설 곤충 확률 대폭 UP!", category = CashItemCategory.GachaBox, priceKRW = 0, gemPrice = 1200, rewardCount = 1 }, + new CashShopItem { itemId = "box_bronze", displayName = "브론즈 상자", description = "기본 곤충 + 소량 희귀 확률", category = CashItemCategory.GachaBox, priceKRW = 0, gemPrice = 500, rewardCount = 1 }, + new CashShopItem { itemId = "box_silver", displayName = "실버 상자", description = "희귀 곤충 확률 UP!", category = CashItemCategory.GachaBox, priceKRW = 0, gemPrice = 600, rewardCount = 1 }, + new CashShopItem { itemId = "box_gold", displayName = "골드 상자", description = "전설 곤충 확률 대폭 UP!", category = CashItemCategory.GachaBox, priceKRW = 0, gemPrice = 750, rewardCount = 1 }, }; } @@ -76,6 +99,9 @@ public bool PurchaseWithRealMoney(string itemId) if (item.itemId.StartsWith("gem_")) { AddGems(item.rewardCount); + // 결제 손실 방지: 보석 충전 직후 클라우드 즉시 저장(자동저장 120초 대기 안 함). + if (CloudSaveManager.Instance != null) + CloudSaveManager.Instance.SaveToCloud(); ItemPurchased?.Invoke(item); return true; } @@ -90,30 +116,66 @@ public bool PurchaseWithGems(string itemId) if (item == null || item.gemPrice <= 0) return false; bool isMaster = AuthManager.Instance != null && AuthManager.Instance.IsMasterAccount; - if (!isMaster && gems < item.gemPrice) return false; - if (!isMaster) gems -= item.gemPrice; - SaveGems(); - GemsChanged?.Invoke(); + // Gems 프로퍼티 사용 — wallet 우선 (single source of truth). + // 옛은 stale gems 캐시 사용으로 외부 wallet 변경 후 잘못된 검사 가능. + if (!isMaster && Gems < item.gemPrice) return false; - // 아이템 지급 + // 1단계: 지급 가능 여부 사전 검증 (수령 시스템 존재 확인). 차감 전이라 환불 불필요. + bool deliverable = true; if (item.category == CashItemCategory.GachaBox) { - if (GachaBoxManager.Instance != null) - GachaBoxManager.Instance.OpenBox(item.itemId); + deliverable = GachaBoxManager.Instance != null; } else if (!string.IsNullOrEmpty(item.rewardItemId)) { if (item.rewardItemId == "candy") + deliverable = FindFirstObjectByType() != null; + else + deliverable = FindFirstObjectByType() != null; + } + if (!deliverable) return false; + + // 2단계: 결제 (보석 차감) — AddGems(-...) 경로로 wallet 동기화 보장. + // Gems 프로퍼티로 환불 기준값 캡처 (wallet 변경 시 stale 방지). + int gemsBefore = Gems; + if (!isMaster) AddGems(-item.gemPrice); + + // 3단계: 지급. 실패 시 환불. + bool delivered = false; + try + { + if (item.category == CashItemCategory.GachaBox) { - PlayerCandyInventory candy = FindFirstObjectByType(); - if (candy != null) candy.AddCandy(item.rewardCount); + GachaBoxManager.Instance.OpenBox(item.itemId); + delivered = true; } - else + else if (!string.IsNullOrEmpty(item.rewardItemId)) { - PlayerItemInventory inv = FindFirstObjectByType(); - if (inv != null) inv.AddItem(item.rewardItemId, item.rewardCount); + if (item.rewardItemId == "candy") + { + PlayerCandyInventory candy = FindFirstObjectByType(); + if (candy != null) { candy.AddCandy(item.rewardCount); delivered = true; } + } + else + { + PlayerItemInventory inv = FindFirstObjectByType(); + if (inv != null) { inv.AddItem(item.rewardItemId, item.rewardCount); delivered = true; } + } } } + catch (System.Exception e) + { + Debug.LogWarning($"[CashShop] 지급 실패 — 환불 진행: {e.Message}"); + delivered = false; + } + + if (!delivered) + { + // 환불: AddGems 양수 경로로 wallet 동기화 보장. gemsBefore - 현재 Gems 차이만큼 복원. + int refund = gemsBefore - Gems; + if (refund > 0) AddGems(refund); + return false; + } ItemPurchased?.Invoke(item); return true; @@ -121,8 +183,22 @@ public bool PurchaseWithGems(string itemId) public void AddGems(int amount) { - gems += amount; - SaveGems(); + // amount 0 early return — 무의미한 GemsChanged 발화 + 구독자 갱신 차단. + if (amount == 0) return; + + // wallet 단일 소스 경로. wallet 있으면 wallet으로만 변경, 캐시는 read-back. + if (wallet != null) + { + if (amount > 0) wallet.AddGems(amount); + else wallet.SpendGems(-amount); + gems = wallet.Gems; + } + else + { + // AutoWire 전(매우 짧은 부트스트랩 구간) fallback — PlayerPrefs 캐시 + gems += amount; + SaveGems(); + } GemsChanged?.Invoke(); } diff --git a/Assets/Scripts/Core/CharacterOutfitManager.cs b/Assets/Scripts/Core/CharacterOutfitManager.cs index c9ab8f4..f591f20 100644 --- a/Assets/Scripts/Core/CharacterOutfitManager.cs +++ b/Assets/Scripts/Core/CharacterOutfitManager.cs @@ -256,6 +256,129 @@ private void Initialize() MakePremiumItem("acc_halo", "천사의 후광", "머리 위에 빛나는 후광", OutfitSlot.Accessory, new Color(1f, 1f, 0.7f), new Color(1f, 0.95f, 0.5f), 1200, new OutfitStatBonus { expMultiplier = 0.03f, defBonus = 0.02f }), + + // ══════════════════════════════════════ + // 코스튬 세트 (프리미엄) + // ══════════════════════════════════════ + + // ── 카우보이 세트 ── + MakePremiumItem("hat_cowboy", "카우보이 모자", "서부의 바람이 느껴지는 가죽 모자", OutfitSlot.Hat, + new Color(0.45f, 0.3f, 0.15f), new Color(0.35f, 0.22f, 0.1f), 800, + new OutfitStatBonus { captureChanceBonus = 0.02f, moveSpeedBonus = 0.02f }), + MakePremiumItem("top_cowboy", "카우보이 조끼", "프린지 장식의 가죽 조끼", OutfitSlot.Top, + new Color(0.5f, 0.35f, 0.15f), new Color(0.4f, 0.25f, 0.1f), 700, + new OutfitStatBonus { atkBonus = 0.02f, defBonus = 0.01f }), + MakePremiumItem("bot_cowboy", "카우보이 팬츠", "가죽 챕스가 달린 청바지", OutfitSlot.Bottom, + new Color(0.2f, 0.3f, 0.5f), new Color(0.45f, 0.3f, 0.15f), 600, + new OutfitStatBonus { defBonus = 0.02f, moveSpeedBonus = 0.02f }), + MakePremiumItem("shoe_cowboy", "카우보이 부츠", "박차가 달린 가죽 부츠", OutfitSlot.Shoes, + new Color(0.4f, 0.25f, 0.1f), new Color(0.7f, 0.7f, 0.7f), 600, + new OutfitStatBonus { moveSpeedBonus = 0.06f }), + MakePremiumItem("tool_lasso", "올가미", "곤충을 잡는 카우보이 올가미", OutfitSlot.Tool, + new Color(0.6f, 0.5f, 0.3f), new Color(0.5f, 0.4f, 0.2f), 900, + new OutfitStatBonus { captureChanceBonus = 0.03f }), + MakePremiumItem("acc_bandana", "빨간 반다나", "서부 스타일 반다나", OutfitSlot.Accessory, + new Color(0.85f, 0.15f, 0.1f), new Color(0.7f, 0.1f, 0.05f), 500, + new OutfitStatBonus { atkBonus = 0.02f }), + + // ── 히어로 세트 (스파이더맨 오마주) ── + MakePremiumItem("hat_hero_mask", "히어로 마스크", "정의의 거미줄 마스크", OutfitSlot.Hat, + new Color(0.8f, 0.1f, 0.1f), new Color(0.15f, 0.15f, 0.4f), 1000, + new OutfitStatBonus { atkBonus = 0.03f, moveSpeedBonus = 0.02f }), + MakePremiumItem("top_hero_suit", "히어로 슈트 상의", "거미줄 패턴의 강화 슈트", OutfitSlot.Top, + new Color(0.8f, 0.1f, 0.1f), new Color(0.1f, 0.1f, 0.35f), 900, + new OutfitStatBonus { atkBonus = 0.03f, defBonus = 0.02f }), + MakePremiumItem("bot_hero_suit", "히어로 슈트 하의", "탄력 있는 강화 타이츠", OutfitSlot.Bottom, + new Color(0.1f, 0.1f, 0.35f), new Color(0.8f, 0.1f, 0.1f), 800, + new OutfitStatBonus { defBonus = 0.02f, moveSpeedBonus = 0.04f }), + MakePremiumItem("tool_web_shooter", "거미줄 발사기", "곤충을 잡는 거미줄 발사 장치", OutfitSlot.Tool, + new Color(0.15f, 0.15f, 0.15f), new Color(0.8f, 0.1f, 0.1f), 1500, + new OutfitStatBonus { captureChanceBonus = 0.04f, atkBonus = 0.01f }), + MakePremiumItem("acc_spider_emblem", "거미 엠블럼", "가슴에 빛나는 거미 문양", OutfitSlot.Accessory, + new Color(0.1f, 0.1f, 0.1f), new Color(0.9f, 0.1f, 0.1f), 700, + new OutfitStatBonus { atkBonus = 0.02f, defBonus = 0.01f }), + + // ── 닌자 세트 ── + MakePremiumItem("hat_ninja", "닌자 두건", "그림자에 녹아드는 검은 두건", OutfitSlot.Hat, + new Color(0.08f, 0.08f, 0.1f), new Color(0.15f, 0.15f, 0.2f), 900, + new OutfitStatBonus { moveSpeedBonus = 0.04f, captureChanceBonus = 0.01f }), + MakePremiumItem("top_ninja", "닌자 상의", "어둠의 닌자 도복 상의", OutfitSlot.Top, + new Color(0.1f, 0.1f, 0.12f), new Color(0.2f, 0.15f, 0.25f), 800, + new OutfitStatBonus { atkBonus = 0.03f, moveSpeedBonus = 0.02f }), + MakePremiumItem("bot_ninja", "닌자 하의", "가볍고 빠른 닌자 바지", OutfitSlot.Bottom, + new Color(0.1f, 0.1f, 0.12f), new Color(0.15f, 0.15f, 0.18f), 700, + new OutfitStatBonus { moveSpeedBonus = 0.05f, defBonus = 0.01f }), + MakePremiumItem("tool_shuriken", "수리검", "곤충을 기절시키는 닌자 수리검", OutfitSlot.Tool, + new Color(0.6f, 0.6f, 0.65f), new Color(0.1f, 0.1f, 0.1f), 1200, + new OutfitStatBonus { captureChanceBonus = 0.03f, atkBonus = 0.02f }), + MakePremiumItem("acc_ninja_scarf", "닌자 머플러", "바람에 휘날리는 보라색 머플러", OutfitSlot.Accessory, + new Color(0.4f, 0.15f, 0.5f), new Color(0.3f, 0.1f, 0.4f), 600, + new OutfitStatBonus { moveSpeedBonus = 0.03f }), + + // ── 해적 세트 ── + MakePremiumItem("hat_pirate", "해적 삼각모", "해골 마크가 새겨진 삼각모", OutfitSlot.Hat, + new Color(0.1f, 0.1f, 0.1f), new Color(1f, 1f, 1f), 800, + new OutfitStatBonus { atkBonus = 0.02f, captureChanceBonus = 0.02f }), + MakePremiumItem("top_pirate", "해적 코트", "금장 단추의 해적 코트", OutfitSlot.Top, + new Color(0.5f, 0.1f, 0.1f), new Color(0.85f, 0.7f, 0.15f), 900, + new OutfitStatBonus { atkBonus = 0.03f, defBonus = 0.01f }), + MakePremiumItem("bot_pirate", "해적 바지", "줄무늬 해적 바지", OutfitSlot.Bottom, + new Color(0.15f, 0.15f, 0.15f), new Color(0.3f, 0.3f, 0.3f), 600, + new OutfitStatBonus { defBonus = 0.02f }), + MakePremiumItem("tool_cutlass", "해적 곡도", "곤충을 놀라게 하는 곡도", OutfitSlot.Tool, + new Color(0.7f, 0.7f, 0.75f), new Color(0.4f, 0.25f, 0.1f), 1100, + new OutfitStatBonus { atkBonus = 0.03f, captureChanceBonus = 0.01f }), + MakePremiumItem("acc_eyepatch", "해적 안대", "한쪽 눈을 가리는 안대", OutfitSlot.Accessory, + new Color(0.1f, 0.1f, 0.1f), new Color(0.3f, 0.3f, 0.3f), 400, + new OutfitStatBonus { captureChanceBonus = 0.02f }), + + // ── 사이버펑크 세트 ── + MakePremiumItem("hat_cyber_visor", "사이버 바이저", "AR 기능이 탑재된 미래형 바이저", OutfitSlot.Hat, + new Color(0.1f, 0.1f, 0.15f), new Color(0f, 0.9f, 1f), 1200, + new OutfitStatBonus { rareSpawnBonus = 0.03f, expMultiplier = 0.02f }), + MakePremiumItem("top_cyber", "사이버 자켓", "네온 라인이 빛나는 자켓", OutfitSlot.Top, + new Color(0.1f, 0.1f, 0.15f), new Color(1f, 0f, 0.8f), 1000, + new OutfitStatBonus { atkBonus = 0.02f, rareSpawnBonus = 0.02f }), + MakePremiumItem("bot_cyber", "사이버 팬츠", "홀로그램 라인이 달린 바지", OutfitSlot.Bottom, + new Color(0.1f, 0.1f, 0.12f), new Color(0f, 1f, 0.5f), 800, + new OutfitStatBonus { defBonus = 0.02f, moveSpeedBonus = 0.03f }), + MakePremiumItem("tool_blaster", "포톤 블래스터", "곤충을 마비시키는 광선총", OutfitSlot.Tool, + new Color(0.15f, 0.15f, 0.2f), new Color(0f, 0.9f, 1f), 1800, + new OutfitStatBonus { captureChanceBonus = 0.04f, atkBonus = 0.02f }), + MakePremiumItem("acc_neon_ring", "네온 팔찌", "빛나는 네온 LED 팔찌", OutfitSlot.Accessory, + new Color(0f, 1f, 0.5f), new Color(1f, 0f, 0.8f), 600, + new OutfitStatBonus { rareSpawnBonus = 0.02f }), + + // ── 마법사 세트 ── + MakePremiumItem("hat_wizard", "마법사 모자", "별이 수놓인 뾰족한 마법사 모자", OutfitSlot.Hat, + new Color(0.15f, 0.1f, 0.35f), new Color(0.8f, 0.7f, 0.2f), 900, + new OutfitStatBonus { rareSpawnBonus = 0.03f, captureChanceBonus = 0.01f }), + MakePremiumItem("outer_wizard", "마법사 로브", "신비로운 보라색 마법사 로브", OutfitSlot.Outerwear, + new Color(0.2f, 0.1f, 0.4f), new Color(0.6f, 0.4f, 0.9f), 1100, + new OutfitStatBonus { atkBonus = 0.02f, rareSpawnBonus = 0.02f, defBonus = 0.01f }), + MakePremiumItem("tool_wand", "마법 지팡이", "곤충을 매혹하는 마법 지팡이", OutfitSlot.Tool, + new Color(0.4f, 0.25f, 0.12f), new Color(0.6f, 0.3f, 0.9f), 1400, + new OutfitStatBonus { captureChanceBonus = 0.05f }), + MakePremiumItem("acc_crystal_orb", "수정구", "미래를 보여주는 수정 오브", OutfitSlot.Accessory, + new Color(0.6f, 0.4f, 0.9f), new Color(0.8f, 0.7f, 1f), 800, + new OutfitStatBonus { rareSpawnBonus = 0.03f, expMultiplier = 0.01f }), + + // ── 군인 세트 ── + MakePremiumItem("hat_military", "군용 헬멧", "위장 패턴의 전투 헬멧", OutfitSlot.Hat, + new Color(0.3f, 0.35f, 0.2f), new Color(0.25f, 0.3f, 0.15f), 700, + new OutfitStatBonus { defBonus = 0.03f }), + MakePremiumItem("top_military", "군용 전투복 상의", "위장 패턴 전투복", OutfitSlot.Top, + new Color(0.3f, 0.35f, 0.2f), new Color(0.2f, 0.25f, 0.15f), 800, + new OutfitStatBonus { atkBonus = 0.02f, defBonus = 0.02f }), + MakePremiumItem("bot_military", "군용 카고 팬츠", "수납 가능한 군용 카고 팬츠", OutfitSlot.Bottom, + new Color(0.3f, 0.33f, 0.2f), new Color(0.25f, 0.28f, 0.15f), 600, + new OutfitStatBonus { defBonus = 0.02f, candyMultiplier = 0.02f }), + MakePremiumItem("tool_tranq_gun", "마취총", "곤충을 안전하게 포획하는 마취총", OutfitSlot.Tool, + new Color(0.2f, 0.2f, 0.22f), new Color(0.3f, 0.35f, 0.2f), 1600, + new OutfitStatBonus { captureChanceBonus = 0.05f, atkBonus = 0.01f }), + MakePremiumItem("acc_dog_tag", "군번줄", "전투 경험의 증표", OutfitSlot.Accessory, + new Color(0.6f, 0.6f, 0.65f), new Color(0.5f, 0.5f, 0.55f), 400, + new OutfitStatBonus { atkBonus = 0.02f, defBonus = 0.01f }), }; outfitLookup = new Dictionary(); @@ -322,9 +445,18 @@ private static OutfitItem MakePremiumItem(string id, string name, string desc, O public void Equip(string itemId) { - if (!outfitLookup.ContainsKey(itemId)) return; + if (!outfitLookup.ContainsKey(itemId)) + { + Debug.LogWarning($"[Outfit] Equip 실패 — 알 수 없는 itemId: {itemId}"); + return; + } OutfitItem item = outfitLookup[itemId]; - if (!ownedItems.Contains(itemId)) return; + if (!ownedItems.Contains(itemId)) + { + // TryPurchase → ownedItems.Add → Equip 순서가 정상. 그 외 경로에서 미보유 장착 시도 시 로깅. + Debug.LogWarning($"[Outfit] Equip 실패 — 미보유 itemId: {itemId} (slot={item.slot})"); + return; + } equippedItems[item.slot] = itemId; SaveEquipment(); ApplyToCharacter(); @@ -412,6 +544,13 @@ public void ApplyToCharacter() GameObject player = GameObject.Find("Player"); if (player == null) return; + // 안전한 default 색상 (의상 미장착 시에도 캐릭터가 보이도록) + Color defaultJacket = new Color(0.2f, 0.4f, 0.85f); + Color defaultShirt = new Color(0.98f, 0.96f, 0.92f); + Color defaultPants = new Color(0.25f, 0.25f, 0.28f); + Color defaultBoot = new Color(0.35f, 0.22f, 0.1f); + Color skinColor = new Color(0.92f, 0.78f, 0.62f); + // 모자 OutfitItem hat = GetEquipped(OutfitSlot.Hat); ApplyPartColor(player, "Cap", hat != null ? hat.primaryColor : Color.clear); @@ -419,32 +558,202 @@ public void ApplyToCharacter() // 상의 OutfitItem top = GetEquipped(OutfitSlot.Top); - ApplyPartColor(player, "Shirt", top != null ? top.primaryColor : Color.white); + ApplyPartColor(player, "Shirt", top != null ? top.primaryColor : defaultShirt); - // 겉옷 + // 겉옷: outer_none이면 Body는 셔츠 색, 팔은 피부색으로 (몸통/팔이 사라지지 않게) OutfitItem outer = GetEquipped(OutfitSlot.Outerwear); - ApplyPartColor(player, "Body", outer != null ? outer.primaryColor : Color.blue); - ApplyPartColor(player, "ArmL", outer != null ? outer.primaryColor : Color.blue); - ApplyPartColor(player, "ArmR", outer != null ? outer.primaryColor : Color.blue); + Color bodyCol, armCol; + if (outer == null) + { + bodyCol = defaultJacket; armCol = defaultJacket; + } + else if (outer.primaryColor.a < 0.01f) + { + // outer_none: 외피 벗음 → Body는 셔츠 색, 팔은 피부색 + Color shirtCol = top != null ? top.primaryColor : defaultShirt; + bodyCol = shirtCol; armCol = skinColor; + } + else + { + bodyCol = outer.primaryColor; armCol = outer.primaryColor; + } + ApplyPartColor(player, "Body", bodyCol); + ApplyPartColor(player, "ArmL", armCol); + ApplyPartColor(player, "ArmR", armCol); // 하의 OutfitItem bot = GetEquipped(OutfitSlot.Bottom); - ApplyPartColor(player, "LegL", bot != null ? bot.primaryColor : Color.gray); - ApplyPartColor(player, "LegR", bot != null ? bot.primaryColor : Color.gray); + ApplyPartColor(player, "LegL", bot != null ? bot.primaryColor : defaultPants); + ApplyPartColor(player, "LegR", bot != null ? bot.primaryColor : defaultPants); // 신발 OutfitItem shoe = GetEquipped(OutfitSlot.Shoes); - ApplyPartColor(player, "BootL", shoe != null ? shoe.primaryColor : Color.black); - ApplyPartColor(player, "BootR", shoe != null ? shoe.primaryColor : Color.black); + ApplyPartColor(player, "BootL", shoe != null ? shoe.primaryColor : defaultBoot); + ApplyPartColor(player, "BootR", shoe != null ? shoe.primaryColor : defaultBoot); // 가방 OutfitItem bag = GetEquipped(OutfitSlot.Backpack); ApplyPartColor(player, "Backpack", bag != null ? bag.primaryColor : Color.clear); - // 도구 + // 도구 (종류별 형태 변경) OutfitItem tool = GetEquipped(OutfitSlot.Tool); ApplyPartColor(player, "NetHandle", tool != null ? tool.primaryColor : Color.clear); ApplyPartColor(player, "NetRing", tool != null ? tool.secondaryColor : Color.clear); + ApplyToolShape(player, tool); + } + + // 도구별 mesh 캐싱 — 옛은 handle/ring이 항상 Cylinder로 어떤 도구든 막대기 모양. + // PrimitiveType별 sharedMesh를 1회 추출 후 재사용 (CreatePrimitive 매번 호출 회피). + private static System.Collections.Generic.Dictionary primMeshCache; + + private static Mesh GetPrimMesh(PrimitiveType type) + { + if (primMeshCache == null) + primMeshCache = new System.Collections.Generic.Dictionary(); + if (!primMeshCache.TryGetValue(type, out Mesh m)) + { + GameObject temp = GameObject.CreatePrimitive(type); + m = temp.GetComponent().sharedMesh; + // collider 제거 + GameObject destroy (mesh asset은 built-in이라 생존) + UnityEngine.Object.Destroy(temp.GetComponent()); + UnityEngine.Object.Destroy(temp); + primMeshCache[type] = m; + } + return m; + } + + private static void SetMesh(Transform t, PrimitiveType type) + { + MeshFilter mf = t.GetComponent(); + if (mf != null) mf.sharedMesh = GetPrimMesh(type); + } + + private void ApplyToolShape(GameObject player, OutfitItem tool) + { + Transform handle = FindDeep(player.transform, "NetHandle"); + Transform ring = FindDeep(player.transform, "NetRing"); + if (handle == null || ring == null) return; + + string id = tool != null ? tool.itemId ?? "" : ""; + + // 손 위치 기준 — PlayerVisualBuilder HandR localPosition (0.29, 0.52, 0)와 일치시켜 + // 도구가 손에 붙도록. 치비 비례 적용으로 손 높이가 0.95→0.52로 내려옴(HandR과 동기 필수). + const float hx = 0.29f; + const float hy = 0.52f; + if (id.Contains("gun") || id.Contains("blaster") || id.Contains("tranq")) + { + // 총: 박스형 본체 + 원통 총구 — handle Cube, ring Cylinder + SetMesh(handle, PrimitiveType.Cube); + SetMesh(ring, PrimitiveType.Cylinder); + handle.localPosition = new Vector3(hx, hy, 0.18f); + handle.localScale = new Vector3(0.08f, 0.05f, 0.22f); + handle.localRotation = Quaternion.identity; + ring.localPosition = new Vector3(hx, hy, 0.32f); + ring.localScale = new Vector3(0.06f, 0.06f, 0.04f); + ring.localRotation = Quaternion.Euler(90f, 0f, 0f); + } + else if (id.Contains("wand")) + { + // 지팡이: 가는 막대 + 구체 오브 + SetMesh(handle, PrimitiveType.Cylinder); + SetMesh(ring, PrimitiveType.Sphere); + handle.localPosition = new Vector3(hx, hy + 0.25f, 0.05f); + handle.localScale = new Vector3(0.03f, 0.55f, 0.03f); + handle.localRotation = Quaternion.Euler(10f, 0f, -15f); + ring.localPosition = new Vector3(hx + 0.10f, hy + 0.78f, 0.05f); + ring.localScale = new Vector3(0.12f, 0.12f, 0.12f); + ring.localRotation = Quaternion.identity; + } + else if (id.Contains("lasso")) + { + // 올가미: 짧은 막대 + 고리(디스크). net과 동일 edge-on 결함(옛 rot(0,0,70)은 법선이 + // 대부분 ±X) → 고리를 X축 -20°로 눕혀 부감 카메라에서 또렷한 원으로 보이게. + SetMesh(handle, PrimitiveType.Cylinder); + SetMesh(ring, PrimitiveType.Cylinder); + handle.localPosition = new Vector3(hx, hy + 0.13f, 0f); + handle.localScale = new Vector3(0.04f, 0.25f, 0.04f); + handle.localRotation = Quaternion.Euler(20f, 0f, -12f); + ring.localPosition = new Vector3(hx + 0.06f, hy + 0.42f, 0.06f); + ring.localScale = new Vector3(0.28f, 0.02f, 0.28f); + ring.localRotation = Quaternion.Euler(-20f, 0f, 0f); + } + else if (id.Contains("shuriken")) + { + // 수리검: 납작한 별 — Cube 십자형 + SetMesh(handle, PrimitiveType.Cube); + SetMesh(ring, PrimitiveType.Cube); + handle.localPosition = new Vector3(hx, hy, 0.10f); + handle.localScale = new Vector3(0.18f, 0.02f, 0.05f); + handle.localRotation = Quaternion.Euler(0f, 45f, 0f); + ring.localPosition = new Vector3(hx, hy, 0.10f); + ring.localScale = new Vector3(0.05f, 0.02f, 0.18f); + ring.localRotation = Quaternion.Euler(0f, 45f, 0f); + } + else if (id.Contains("cutlass") || id.Contains("sword")) + { + // 검: 박스 손잡이 + 긴 박스 칼날 + SetMesh(handle, PrimitiveType.Cube); + SetMesh(ring, PrimitiveType.Cube); + handle.localPosition = new Vector3(hx, hy + 0.08f, 0.05f); + handle.localScale = new Vector3(0.05f, 0.12f, 0.05f); + handle.localRotation = Quaternion.identity; + ring.localPosition = new Vector3(hx, hy + 0.42f, 0.05f); + ring.localScale = new Vector3(0.04f, 0.55f, 0.10f); + ring.localRotation = Quaternion.identity; + } + else if (id.Contains("web_shooter")) + { + // 발사기: 손목 박스 + 구체 발사구 + SetMesh(handle, PrimitiveType.Cube); + SetMesh(ring, PrimitiveType.Sphere); + handle.localPosition = new Vector3(hx, hy + 0.08f, 0.05f); + handle.localScale = new Vector3(0.08f, 0.06f, 0.12f); + handle.localRotation = Quaternion.identity; + ring.localPosition = new Vector3(hx, hy + 0.08f, 0.15f); + ring.localScale = new Vector3(0.04f, 0.04f, 0.04f); + ring.localRotation = Quaternion.identity; + } + else if (id.Contains("magnify")) + { + // 돋보기: 가는 막대 + 렌즈(디스크). 렌즈를 X축 -20°로 눕혀 부감 카메라에서 정원에 + // 가깝게. 옛 rot(60,0,0)은 법선이 위로 너무 서(투영 ~12%) 얇은 타원으로 찌그러짐. + SetMesh(handle, PrimitiveType.Cylinder); + SetMesh(ring, PrimitiveType.Cylinder); + handle.localPosition = new Vector3(hx, hy + 0.05f, 0.10f); + handle.localScale = new Vector3(0.03f, 0.18f, 0.03f); + handle.localRotation = Quaternion.Euler(35f, 0f, 0f); + ring.localPosition = new Vector3(hx, hy + 0.22f, 0.20f); + ring.localScale = new Vector3(0.16f, 0.02f, 0.16f); + ring.localRotation = Quaternion.Euler(-20f, 0f, 0f); + } + else if (id.Contains("camera")) + { + // 카메라: 박스 본체 + 원통 렌즈 + SetMesh(handle, PrimitiveType.Cube); + SetMesh(ring, PrimitiveType.Cylinder); + handle.localPosition = new Vector3(hx, hy + 0.05f, 0.18f); + handle.localScale = new Vector3(0.16f, 0.10f, 0.10f); + handle.localRotation = Quaternion.identity; + ring.localPosition = new Vector3(hx, hy + 0.05f, 0.26f); + ring.localScale = new Vector3(0.07f, 0.07f, 0.06f); + ring.localRotation = Quaternion.Euler(90f, 0f, 0f); + } + else + { + // 기본 잠자리채: 막대 + 망(디스크). 핵심: 망 디스크를 X축 -20°로 눕혀 법선이 + // 위-카메라쪽(0,0.94,-0.34)을 향하게 함. 옛 rot(0,0,90)은 법선이 ±X(옆)라 부감 + // 카메라(시선 0,-0.8,0.6)에서 edge-on(테두리만)으로 collapse → 망이 사라지고 + // 손잡이 막대만 남던 "막대기 뒤에 보임" 회귀의 직접 원인. -20°는 플레이어가 + // 어느 방향을 보든 57~95% 가시라 회전에 강건(법선 Y성분 우세). + SetMesh(handle, PrimitiveType.Cylinder); + SetMesh(ring, PrimitiveType.Cylinder); + handle.localPosition = new Vector3(hx, hy + 0.30f, 0.02f); + handle.localScale = new Vector3(0.04f, 0.55f, 0.04f); + handle.localRotation = Quaternion.Euler(20f, 0f, -15f); + ring.localPosition = new Vector3(hx + 0.06f, hy + 0.85f, 0.06f); + ring.localScale = new Vector3(0.26f, 0.02f, 0.26f); + ring.localRotation = Quaternion.Euler(-20f, 0f, 0f); + } } private void ApplyPartColor(GameObject root, string partName, Color color) @@ -530,18 +839,33 @@ private void SaveOwnership() private void LoadOwnership() { - string saved = PlayerPrefs.GetString(OwnedKey, ""); - if (string.IsNullOrEmpty(saved)) return; + // unlockedByDefault=true 아이템은 항상 ownedItems에 자동 등록 + // (Equip은 ownedItems 가드가 있어서 누락 시 기본 장착이 silent fail됨) + bool addedDefault = false; + if (allOutfits != null) + { + foreach (var item in allOutfits) + { + if (item != null && item.unlockedByDefault && ownedItems.Add(item.itemId)) + addedDefault = true; + } + } - string[] ids = saved.Split(','); - foreach (string id in ids) + string saved = PlayerPrefs.GetString(OwnedKey, ""); + if (!string.IsNullOrEmpty(saved)) { - string trimmed = id.Trim(); - if (trimmed.Length > 0) + string[] ids = saved.Split(','); + foreach (string id in ids) { - ownedItems.Add(trimmed); + string trimmed = id.Trim(); + if (trimmed.Length > 0) + ownedItems.Add(trimmed); } } + + // 신규 유저(저장 없음) 또는 기본 아이템 신규 추가 시 PlayerPrefs와 동기화 + if (string.IsNullOrEmpty(saved) || addedDefault) + SaveOwnership(); } } } diff --git a/Assets/Scripts/Core/CloudSaveManager.cs b/Assets/Scripts/Core/CloudSaveManager.cs index 4ffa28d..1ca44b0 100644 --- a/Assets/Scripts/Core/CloudSaveManager.cs +++ b/Assets/Scripts/Core/CloudSaveManager.cs @@ -31,6 +31,7 @@ private void Awake() if (Instance == null) { Instance = this; + if (transform.parent == null) DontDestroyOnLoad(gameObject); } else if (Instance != this) { @@ -60,17 +61,54 @@ private void Update() } } + // 종료 직전 강제 저장 — 자동저장 120초 사이클로 발생하는 마지막 1분 데이터 손실 방지 + private void OnApplicationQuit() + { + if (!IsFirebaseConfigured()) return; + if (AuthManager.Instance == null || !AuthManager.Instance.IsLoggedIn) return; + SaveToCloud(); + } + + // 모바일 백그라운드 전환 시에도 강제 저장 (앱 강제 종료 대비) + private void OnApplicationPause(bool pauseStatus) + { + if (!pauseStatus) return; + if (!IsFirebaseConfigured()) return; + if (AuthManager.Instance == null || !AuthManager.Instance.IsLoggedIn) return; + SaveToCloud(); + } + // ── 저장 ── + private bool pendingSave; + private const string LastSaveTsKey = "InsectGame.LastSaveTs"; + public void SaveToCloud() { if (!IsFirebaseConfigured()) return; if (AuthManager.Instance == null || !AuthManager.Instance.IsLoggedIn) return; - if (IsSaving) return; + if (IsSaving) + { + // 진행 중인 저장이 끝난 직후 1회 더 저장 (보석 결제 등 사용자 액션 손실 방지) + pendingSave = true; + return; + } StartCoroutine(SaveCoroutine()); } private IEnumerator SaveCoroutine() + { + yield return SaveCoroutineInternal(allowRetry: true); + + // 진행 중 들어온 요청 처리 (옛 SaveCoroutine 끝 로직 — 분기와 무관하게 항상 실행) + if (pendingSave) + { + pendingSave = false; + StartCoroutine(SaveCoroutine()); + } + } + + private IEnumerator SaveCoroutineInternal(bool allowRetry) { IsSaving = true; @@ -80,6 +118,10 @@ private IEnumerator SaveCoroutine() string url = FirebaseConfig.FirestoreBaseUrl + "/users/" + AuthManager.Instance.UserId; + long responseCode = 0; + bool success = false; + string errorMsg = null; + using (UnityWebRequest req = new UnityWebRequest(url, "PATCH")) { byte[] bodyRaw = System.Text.Encoding.UTF8.GetBytes(firestoreJson); @@ -91,18 +133,39 @@ private IEnumerator SaveCoroutine() yield return req.SendWebRequest(); - IsSaving = false; - if (req.result == UnityWebRequest.Result.Success) - { - LastError = null; - SaveCompleted?.Invoke(); - } - else + responseCode = req.responseCode; + success = req.result == UnityWebRequest.Result.Success; + errorMsg = req.error; + } + + IsSaving = false; + + if (success) + { + LastError = null; + PlayerPrefs.SetString(LastSaveTsKey, data.lastSaveTimestamp.ToString()); + PlayerPrefs.Save(); + SaveCompleted?.Invoke(); + yield break; + } + + // 401/403: 토큰 갱신 후 1회 재시도 (보석 결제 등 사용자 액션 손실 방지) + if ((responseCode == 401 || responseCode == 403) && allowRetry) + { + bool refreshed = false; + yield return AuthManager.Instance.TryRefreshTokenForRetry(r => refreshed = r); + if (refreshed) { - LastError = req.error; - Debug.LogWarning("[CloudSave] Save failed: " + req.error); + yield return SaveCoroutineInternal(allowRetry: false); + yield break; } + LastError = "session_expired"; + Debug.LogWarning("[CloudSave] Save failed: session expired, refresh denied"); + yield break; } + + LastError = errorMsg; + Debug.LogWarning("[CloudSave] Save failed: " + errorMsg); } // ── 로드 ── @@ -121,12 +184,22 @@ public void LoadFromCloud() } private IEnumerator LoadCoroutine() + { + yield return LoadCoroutineInternal(allowRetry: true); + } + + private IEnumerator LoadCoroutineInternal(bool allowRetry) { IsLoading = true; string url = FirebaseConfig.FirestoreBaseUrl + "/users/" + AuthManager.Instance.UserId; + long responseCode = 0; + bool success = false; + string errorMsg = null; + string downloadText = null; + using (UnityWebRequest req = UnityWebRequest.Get(url)) { req.SetRequestHeader("Authorization", @@ -134,34 +207,55 @@ private IEnumerator LoadCoroutine() yield return req.SendWebRequest(); - IsLoading = false; - if (req.result == UnityWebRequest.Result.Success) + responseCode = req.responseCode; + success = req.result == UnityWebRequest.Result.Success; + errorMsg = req.error; + if (success) downloadText = req.downloadHandler.text; + } + + IsLoading = false; + + if (success) + { + GameSaveData data = ParseFirestoreDocument(downloadText); + if (data != null) { - GameSaveData data = ParseFirestoreDocument(req.downloadHandler.text); - if (data != null) - { - ApplySaveData(data); - LoadCompleted?.Invoke(true); - } - else - { - LoadCompleted?.Invoke(false); - } + ApplySaveData(data); + LoadCompleted?.Invoke(true); } else { - if (req.responseCode == 404 || req.responseCode == 401 || req.responseCode == 403) - { - // 404=새유저, 401/403=Firebase 미설정 → 새 유저 취급 - LoadCompleted?.Invoke(false); - } - else - { - LastError = req.error; - Debug.LogWarning("[CloudSave] Load failed: " + req.error); - LoadCompleted?.Invoke(false); // 에러여도 진행 가능하게 - } + LoadCompleted?.Invoke(false); + } + yield break; + } + + // 401/403: 토큰 만료 가능성 → 1회 갱신 후 재시도. 갱신 실패면 새 유저 취급. + // 옛 코드는 401/403을 "Firebase 미설정"으로 단일 처리해서 정상 유저도 데이터 손실 위험. + if ((responseCode == 401 || responseCode == 403) && allowRetry) + { + bool refreshed = false; + yield return AuthManager.Instance.TryRefreshTokenForRetry(r => refreshed = r); + if (refreshed) + { + yield return LoadCoroutineInternal(allowRetry: false); + yield break; } + // 갱신 실패 = AuthFailed 발화됨 + ClearAuth 완료. 새 유저 취급. + LoadCompleted?.Invoke(false); + yield break; + } + + if (responseCode == 404 || responseCode == 401 || responseCode == 403) + { + // 404=새유저, 갱신 후에도 401/403=Firebase 미설정 또는 영구 거부 + LoadCompleted?.Invoke(false); + } + else + { + LastError = errorMsg; + Debug.LogWarning("[CloudSave] Load failed: " + errorMsg); + LoadCompleted?.Invoke(false); } } @@ -176,6 +270,8 @@ private GameSaveData CollectSaveData() playerXp = PlayerPrefs.GetInt("player_xp", 0), candies = PlayerPrefs.GetInt("player_candies", 0), coins = PlayerPrefs.GetInt("player_coins", 0), + // gems는 PlayerCurrencyWallet 단일 소스. CashShopManager가 wallet 경유 노출. + gems = CashShopManager.Instance != null ? CashShopManager.Instance.Gems : 0, ownedInsects = LoadLocalFile(GameConstants.SaveFiles.PlayerInsects), battleTeam = LoadLocalFile(GameConstants.SaveFiles.BattleTeam), dexData = LoadLocalFile(GameConstants.SaveFiles.DexSave), @@ -199,10 +295,31 @@ private GameSaveData CollectSaveData() private void ApplySaveData(GameSaveData data) { + // 타임스탬프 가드: 로컬 lastSaveTimestamp가 클라우드보다 새것이면 덮어쓰기 거부. + // 현재 LoadFromCloud는 LoginUI 로그인 직후 1회만 호출되지만, 미래 어디서든 LoadFromCloud가 + // 게임 진행 중 호출될 수 있는 케이스(계정 전환, 강제 동기화 등)에 대비. + // long을 PlayerPrefs에 직접 저장 불가 → string으로 보관. + long localTs = 0; + long.TryParse(PlayerPrefs.GetString(LastSaveTsKey, "0"), out localTs); + if (localTs > 0 && data.lastSaveTimestamp > 0 && localTs > data.lastSaveTimestamp) + { + Debug.LogWarning( + "[CloudSave] 로컬 데이터(" + localTs + ")가 클라우드(" + data.lastSaveTimestamp + + ")보다 새것. 덮어쓰기 거부."); + return; + } + PlayerPrefs.SetInt("player_level", data.playerLevel); PlayerPrefs.SetInt("player_xp", data.playerXp); PlayerPrefs.SetInt("player_candies", data.candies); PlayerPrefs.SetInt("player_coins", data.coins); + // gems: wallet 단일 소스. data.gems가 음수(옛 클라우드)면 로컬 유지. + // CashShopManager가 wallet에 직접 반영(여러 UI 이벤트 동기 발화 보장). + if (data.gems >= 0 && CashShopManager.Instance != null) + { + int diff = data.gems - CashShopManager.Instance.Gems; + if (diff != 0) CashShopManager.Instance.AddGems(diff); + } PlayerPrefs.SetString("InsectGame.Equipped", data.equippedOutfit ?? ""); PlayerPrefs.SetString("InsectGame.OwnedOutfits", data.ownedOutfits ?? ""); PlayerPrefs.SetString("InsectGame.UnlockedRegions", @@ -236,6 +353,7 @@ private string ConvertToFirestoreDocument(GameSaveData data) sb.Append(","); AppendIntField(sb, "playerXp", data.playerXp); sb.Append(","); AppendIntField(sb, "candies", data.candies); sb.Append(","); AppendIntField(sb, "coins", data.coins); + sb.Append(","); AppendIntField(sb, "gems", data.gems); sb.Append(","); AppendStringField(sb, "ownedInsects", data.ownedInsects); sb.Append(","); AppendStringField(sb, "battleTeam", data.battleTeam); sb.Append(","); AppendStringField(sb, "dexData", data.dexData); @@ -264,6 +382,9 @@ private GameSaveData ParseFirestoreDocument(string json) data.playerXp = ExtractIntValue(json, "playerXp"); data.candies = ExtractIntValue(json, "candies"); data.coins = ExtractIntValue(json, "coins"); + // gems는 신규 필드 — 옛 클라우드 데이터에 없으면 -1 sentinel. + // ApplySaveData에서 음수면 PlayerPrefs 덮어쓰기 스킵(기존 로컬 보석 보존). + data.gems = ExtractIntValueOrDefault(json, "gems", -1); data.ownedInsects = ExtractStringValue(json, "ownedInsects"); data.battleTeam = ExtractStringValue(json, "battleTeam"); data.dexData = ExtractStringValue(json, "dexData"); @@ -345,18 +466,23 @@ private string ExtractStringValue(string json, string fieldName) /// {"fieldName":{"integerValue":"123"}} 형식에서 값을 꺼냅니다. /// private int ExtractIntValue(string json, string fieldName) + { + return ExtractIntValueOrDefault(json, fieldName, 0); + } + + private int ExtractIntValueOrDefault(string json, string fieldName, int defaultValue) { string marker = "\"" + fieldName + "\":{\"integerValue\":\""; int start = json.IndexOf(marker, StringComparison.Ordinal); - if (start < 0) return 0; + if (start < 0) return defaultValue; start += marker.Length; int end = json.IndexOf("\"", start, StringComparison.Ordinal); - if (end < 0) return 0; + if (end < 0) return defaultValue; string numStr = json.Substring(start, end - start); if (int.TryParse(numStr, out int result)) return result; - return 0; + return defaultValue; } // ── 로컬 파일 IO ── @@ -371,7 +497,7 @@ private string LoadLocalFile(string fileName) private void SaveLocalFile(string fileName, string content) { string path = Path.Combine(Application.persistentDataPath, fileName); - File.WriteAllText(path, content); + AtomicFileWriter.WriteAllText(path, content); } } @@ -385,6 +511,7 @@ public class GameSaveData public int playerXp; public int candies; public int coins; + public int gems; public string ownedInsects; public string battleTeam; public string dexData; diff --git a/Assets/Scripts/Core/GachaBoxManager.cs b/Assets/Scripts/Core/GachaBoxManager.cs index 1d9f8a4..11249ba 100644 --- a/Assets/Scripts/Core/GachaBoxManager.cs +++ b/Assets/Scripts/Core/GachaBoxManager.cs @@ -70,59 +70,107 @@ public void AutoWire(PlayerInsectCollection col, PlayerCandyInventory candy) if (candyInventory == null) candyInventory = candy; } - public void OpenBox(string boxId) + public void AutoWire(InsectDatabase db) { - float roll = UnityEngine.Random.value * 100f; - InsectRarity rarity; - int bonusCandy; + if (database == null) database = db; + } - switch (boxId) + // 캐시 — GetInsectDisplayName이 매 가챠 결과마다 FindFirstObjectByType 호출하던 회귀 차단 + private InsectDatabase database; + + // PickRandomInsect 결과 검증: data drift로 코드 상수의 ID가 DB에 없을 수 있음. + // 옛은 검증 없이 AddCapturedInsect 호출 → DB에서 못 찾으면 에러 + 보상 일부만 지급. + // 무효 ID면 fallback "beetle_basic"(Meadow Common, 항상 존재) 사용. + private string ValidateInsectId(string id) + { + if (string.IsNullOrEmpty(id)) return "beetle_basic"; + InsectDatabase db = database; + if (db == null) db = FindFirstObjectByType(); + if (db == null || db.insects == null) return id; // DB 미로드 — 검증 스킵 + for (int i = 0; i < db.insects.Count; i++) { - case "box_bronze": - rarity = GetBronzeRarity(roll); - bonusCandy = UnityEngine.Random.Range(5, 16); - break; - case "box_silver": - rarity = GetSilverRarity(roll); - bonusCandy = UnityEngine.Random.Range(10, 31); - break; - case "box_gold": - rarity = GetGoldRarity(roll); - bonusCandy = UnityEngine.Random.Range(20, 51); - break; - default: - return; + if (db.insects[i] != null && db.insects[i].insectId == id) return id; } + Debug.LogWarning($"[Gacha] insectId '{id}' DB 미존재 — fallback beetle_basic 사용"); + return "beetle_basic"; + } + + // 동시/중복 OpenBox 호출 차단 (버튼 rapid-click, 네트워크 지연 시 보상 이중 지급 방지) + private bool isOpening; - string insectId = PickRandomInsect(rarity, boxId); - bool isExclusive = insectId.StartsWith("gacha_"); + public void OpenBox(string boxId) + { + if (isOpening) return; + isOpening = true; + try + { + float roll = UnityEngine.Random.value * 100f; + InsectRarity rarity; + int bonusCandy; - // 곤충 지급 - if (insectCollection != null) - insectCollection.AddCapturedInsect(insectId, GetGachaLevel(rarity)); + switch (boxId) + { + case "box_bronze": + rarity = GetBronzeRarity(roll); + bonusCandy = UnityEngine.Random.Range(5, 16); + break; + case "box_silver": + rarity = GetSilverRarity(roll); + bonusCandy = UnityEngine.Random.Range(10, 31); + break; + case "box_gold": + rarity = GetGoldRarity(roll); + bonusCandy = UnityEngine.Random.Range(20, 51); + break; + default: + return; + } - // 캔디 보너스 - if (candyInventory != null) - candyInventory.AddCandy(bonusCandy); + string insectId = ValidateInsectId(PickRandomInsect(rarity, boxId)); + bool isExclusive = insectId.StartsWith("gacha_"); - // 도감 등록 - Dex.DexController dex = FindFirstObjectByType(); - if (dex != null) - { - dex.RegisterEncounter(insectId); - dex.RegisterCapture(insectId); - } + // 각 보상을 독립 try-catch로 감싸 한 단계 실패가 나머지를 막지 않게 함 + // (곤충 지급 예외로 캔디/도감 미실행되는 회귀 방지). + try + { + if (insectCollection != null) + insectCollection.AddCapturedInsect(insectId, GetGachaLevel(rarity)); + } + catch (System.Exception e) { Debug.LogError($"[Gacha] 곤충 지급 실패: {e.Message}"); } + + try + { + if (candyInventory != null) + candyInventory.AddCandy(bonusCandy); + } + catch (System.Exception e) { Debug.LogError($"[Gacha] 캔디 지급 실패: {e.Message}"); } + + try + { + Dex.DexController dex = FindFirstObjectByType(); + if (dex != null) + { + dex.RegisterEncounter(insectId); + dex.RegisterCapture(insectId); + } + } + catch (System.Exception e) { Debug.LogError($"[Gacha] 도감 등록 실패: {e.Message}"); } + + LastResult = new GachaResult + { + insectId = insectId, + displayName = GetInsectDisplayName(insectId), + rarity = rarity, + isExclusive = isExclusive, + bonusCandy = bonusCandy + }; - LastResult = new GachaResult + BoxOpened?.Invoke(LastResult); + } + finally { - insectId = insectId, - displayName = GetInsectDisplayName(insectId), - rarity = rarity, - isExclusive = isExclusive, - bonusCandy = bonusCandy - }; - - BoxOpened?.Invoke(LastResult); + isOpening = false; + } } // -- 등급 결정 -- @@ -138,20 +186,21 @@ private InsectRarity GetBronzeRarity(float roll) private InsectRarity GetSilverRarity(float roll) { - if (roll < 20f) return InsectRarity.Common; - if (roll < 55f) return InsectRarity.Uncommon; - if (roll < 85f) return InsectRarity.Rare; - if (roll < 97f) return InsectRarity.Epic; + // C:12% U:25% R:33% E:22% L:8% — 가격 600젬, EV/gem×1000 = 2.632 (bronze 2.308 대비 1.140x) + if (roll < 12f) return InsectRarity.Common; + if (roll < 37f) return InsectRarity.Uncommon; + if (roll < 70f) return InsectRarity.Rare; + if (roll < 92f) return InsectRarity.Epic; return InsectRarity.Legendary; } private InsectRarity GetGoldRarity(float roll) { - // C:10% U:25% R:35% E:25% L:5% - if (roll < 10f) return InsectRarity.Common; - if (roll < 35f) return InsectRarity.Uncommon; - if (roll < 70f) return InsectRarity.Rare; - if (roll < 95f) return InsectRarity.Epic; + // C:5% U:5% R:13% E:32% L:45% — 가격 750젬, EV/gem×1000 = 2.940 (silver 2.632 대비 1.117x) + if (roll < 5f) return InsectRarity.Common; + if (roll < 10f) return InsectRarity.Uncommon; + if (roll < 23f) return InsectRarity.Rare; + if (roll < 55f) return InsectRarity.Epic; return InsectRarity.Legendary; } @@ -198,8 +247,9 @@ private string GetInsectDisplayName(string insectId) return gachaDisplayNames[insectId]; // InsectLore.json 기반 이름은 InsectLoreService에서 이미 적용됨 - // database에서 찾기 시도 - InsectDatabase db = FindFirstObjectByType(); + // database 캐시 우선, 없으면 FindFirstObjectByType fallback (옛은 매번 Find 호출) + InsectDatabase db = database; + if (db == null) db = FindFirstObjectByType(); if (db != null && db.insects != null) { foreach (var insect in db.insects) diff --git a/Assets/Scripts/Core/GameClock.cs b/Assets/Scripts/Core/GameClock.cs index dbc893f..c373715 100644 --- a/Assets/Scripts/Core/GameClock.cs +++ b/Assets/Scripts/Core/GameClock.cs @@ -16,6 +16,7 @@ public class GameClock : MonoBehaviour [Range(0f, 1f)] [SerializeField] private float startTime01 = 0.25f; private float time01; + private bool paused; // 모바일 백그라운드 시 시간 정지 public float Time01 => time01; @@ -26,6 +27,7 @@ private void Awake() private void Update() { + if (paused) return; if (dayLengthMinutes <= 0f) { return; @@ -35,6 +37,9 @@ private void Update() time01 = (time01 + delta) % 1f; } + private void OnApplicationPause(bool pauseStatus) { paused = pauseStatus; } + private void OnApplicationFocus(bool hasFocus) { if (hasFocus) paused = false; } + public int GetHour24() { return Mathf.FloorToInt(time01 * 24f); diff --git a/Assets/Scripts/Core/GameConstants.cs b/Assets/Scripts/Core/GameConstants.cs index 924b367..f96c951 100644 --- a/Assets/Scripts/Core/GameConstants.cs +++ b/Assets/Scripts/Core/GameConstants.cs @@ -37,6 +37,7 @@ public static class PrefsKeys public const string QuestProgress = "InsectGame.QuestProgress"; public const string QuestCompleted = "InsectGame.QuestCompleted"; public const string ActiveQuest = "InsectGame.ActiveQuest"; + public const string LastSubAreaId = "InsectGame.SubArea.LastEntered"; } // ── 플레이어 ── diff --git a/Assets/Scripts/Core/ItemEffectManager.cs b/Assets/Scripts/Core/ItemEffectManager.cs index 2799ae4..87483d7 100644 --- a/Assets/Scripts/Core/ItemEffectManager.cs +++ b/Assets/Scripts/Core/ItemEffectManager.cs @@ -41,6 +41,8 @@ public bool ActivateItem(ItemData item) activeItem = item; remainingSeconds = Mathf.Max(1f, item.durationSeconds); ActiveItemChanged?.Invoke(item); + // q_item 진행도 — 단일 진입점 (CaptureChoiceUI/PlayerItemInventoryGridUIController 모두 통과) + TutorialQuestManager.Instance?.NotifyItemUsed(); return true; } diff --git a/Assets/Scripts/Core/PlaySceneBootstrap.cs b/Assets/Scripts/Core/PlaySceneBootstrap.cs index a78d9ee..395dbad 100644 --- a/Assets/Scripts/Core/PlaySceneBootstrap.cs +++ b/Assets/Scripts/Core/PlaySceneBootstrap.cs @@ -70,12 +70,53 @@ public void Build() BuildSystems(player, camera); Debug.Log("[PlaySceneBootstrap] BuildSystems 완료"); } + catch (CriticalBootstrapException e) + { + // 핵심 시스템 (InsectDatabase, InsectSpawner 등) 실패 — 게임 진행 불가능. + // catch-all로 묻으면 "곤충 안 보임" 같은 비명시적 장애로 이어짐 → 사용자 알림. + Debug.LogError($"[PlaySceneBootstrap] 핵심 시스템 실패 — 게임 중단: {e.Message}\n{e.StackTrace}"); + criticalError = e.Message; + } catch (System.Exception e) { + // 부가 시스템 (UI/Battle/Capture 등) 부분 실패 — 옛 동작 유지(게임 계속). Debug.LogError($"[PlaySceneBootstrap] 시스템 초기화 중 에러 발생 (게임은 계속 실행됩니다): {e.Message}\n{e.StackTrace}"); } } + // 핵심 시스템 실패는 별도 예외로 분리하여 catch에서 게임 중단 처리. + // 옛 catch-all은 database null 같은 치명 오류도 묻어 곤충 안 스폰 등의 비명시적 장애 발생. + private class CriticalBootstrapException : System.Exception + { + public CriticalBootstrapException(string msg) : base(msg) { } + } + + private string criticalError; + private GUIStyle criticalErrStyle; // OnGUI 매 프레임 new GUIStyle 차단 — lazy 1회 생성 + + private void OnGUI() + { + if (string.IsNullOrEmpty(criticalError)) return; + if (criticalErrStyle == null) + { + criticalErrStyle = new GUIStyle(GUI.skin.box) + { + fontSize = 18, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.MiddleCenter, + wordWrap = true + }; + criticalErrStyle.normal.textColor = new Color(1f, 0.9f, 0.9f); + } + float w = Mathf.Min(640f, Screen.width - 40f); + float h = 200f; + Rect r = new Rect((Screen.width - w) * 0.5f, (Screen.height - h) * 0.5f, w, h); + GUI.color = new Color(0f, 0f, 0f, 0.85f); + GUI.DrawTexture(r, Texture2D.whiteTexture); + GUI.color = Color.white; + GUI.Label(r, $"⚠ 핵심 시스템 초기화 실패\n\n{criticalError}\n\n게임을 재시작 하세요.", criticalErrStyle); + } + private void BuildSystems(GameObject player, Camera camera) { // 인증/클라우드/로그인 UI (다른 시스템보다 먼저 생성) @@ -94,8 +135,16 @@ private void BuildSystems(GameObject player, Camera camera) worldState.AutoWire(clock, weather); InsectDatabase database = EnsureExpandedDatabase(); + if (database == null || database.insects == null || database.insects.Count == 0) + { + throw new CriticalBootstrapException("InsectDatabase 생성 실패 — 곤충 데이터 로드 불가"); + } InsectSpawner spawner = EnsureComponent("World/InsectSpawner"); + if (spawner == null) + { + throw new CriticalBootstrapException("InsectSpawner 컴포넌트 생성 실패"); + } if (buildSpawns) { spawner.AutoWire(database, worldState, EnsureSpawnPoints()); @@ -127,6 +176,9 @@ private void BuildSystems(GameObject player, Camera camera) DexScreenUI dexScreen = EnsureComponent("UI/DexScreen"); dexScreen.AutoWire(database, dex); dexScreen.AutoWire(insectCollection, itemInventory); + // 곤충 3D 모델을 도감에 RenderTexture로 표시(옛 단색 박스/약식 2D 대체) + InsectModelPreviewRenderer insectPreview = EnsureComponent("UI/InsectModelPreview"); + dexScreen.AutoWire(insectPreview); CaptureController capture = EnsureComponent("Capture/CaptureController"); capture.AutoWire(dex); @@ -212,15 +264,20 @@ private void BuildSystems(GameObject player, Camera camera) raidBattleUi.AutoWire(raidController, camFollower, playerMov); Battle.BattleArenaController arenaController = EnsureComponent("World/BattleArena"); + battleController.AutoWire(arenaController); + raidController.AutoWire(arenaController); + battleScreen.AutoWire(arenaController); + raidBattleUi.AutoWire(arenaController); InsectGame.UI.CaptureChoiceUI captureChoice = EnsureComponent("UI/CaptureChoice"); captureChoice.AutoWire(minigame, battleController, battleUi, battleTeam, insectCollection, proximityTrigger, capture, dex, trainingMgr, itemInventory, raidController); captureChoice.AutoWire(playerMov); captureChoice.SetCaptureItems(captureItemDefs); - Spawning.CaptureItemSpawner itemSpawner = EnsureComponent("World/CaptureItemSpawner"); - itemSpawner.AutoWire(itemInventory); - itemSpawner.Initialize(captureItemDefs, player.transform); + // 필드 아이템 스폰 비활성화 — 아이템은 샵/보상에서만 획득 + // Spawning.CaptureItemSpawner itemSpawner = EnsureComponent("World/CaptureItemSpawner"); + // itemSpawner.AutoWire(itemInventory); + // itemSpawner.Initialize(captureItemDefs, player.transform); if (itemInventory.GetCount("net_basic") < 1) itemInventory.AddItem("net_basic", 5); @@ -232,7 +289,7 @@ private void BuildSystems(GameObject player, Camera camera) CaptureInputController inputController = EnsureComponent("Capture/InputController"); inputController.AutoWire(modeController, raycastTrigger, proximityTrigger); inputController.AutoWire(captureChoice); - inputController.AutoWire(battleScreen, dexScreen); + inputController.AutoWire(battleScreen, raidBattleUi, dexScreen); inputController.GetType().GetField("minigame", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) ?.SetValue(inputController, minigame); @@ -249,23 +306,43 @@ private void BuildSystems(GameObject player, Camera camera) InsectGame.UI.BattleTeamUI battleTeamUi = EnsureComponent("UI/BattleTeamUI"); battleTeamUi.AutoWire(battleTeam, insectCollection); - Data.RegionData[] regionDefs = CreateExpandedRegions(); + Data.RegionData[] regionDefs = RegionDefinitions.CreateAll(); RegionManager regionMgr = EnsureComponent("World/RegionManager"); regionMgr.Initialize(regionDefs); regionMgr.AutoWire(progress); + SubAreaEnvironment subAreaEnv = EnsureComponent("World/SubAreaEnvironment"); + subAreaEnv.AutoWire(regionMgr); + + SubAreaWorldBuilder subAreaWorld = EnsureComponent("World/SubAreaWorld"); + subAreaWorld.AutoWire(regionMgr, camFollower); + + spawner.AutoWire(regionMgr); + + // 리전 진입 시 BGM 자동 전환 + regionMgr.RegionChanged += region => + { + if (region != null && AudioManager.Instance != null) + AudioManager.Instance.PlayBGMForRegion(region.regionId); + }; + // 수문장 곤충 스폰 (3D 엔티티) CreateGuardians(regionDefs, database); PlayerMovement playerMovement = player.GetComponent(); if (playerMovement != null) playerMovement.AutoWire(regionMgr); - // 로그인 완료 전까지 이동 제한 - if (playerMovement != null) - playerMovement.SetFrozen(true); + // 재진입 자동 복귀: 이전 종료 시 SubArea 안이었으면 그 위치로 텔레포트. + // 다음 RegionManager.Update가 ContainsPoint 감지 → 자동 SubArea 진입 트리거. + regionMgr.RestoreLastSubArea(player.transform); + + // 시작 시 frozen은 LoginUI/WorldLobbyUI가 OnGUI로 입력 흡수하므로 불필요. + // 월드 선택 미완료 시 frozen이 풀리지 않는 버그 회피 (이전: SetFrozen(true) 호출). + // 마우스 클릭은 PlayerMovement:84-85의 pointerOverUI 체크로 UI 위에서 캐릭터 이동 안 함. InsectGame.UI.RegionMapUI mapUi = EnsureComponent("UI/RegionMapUI"); mapUi.AutoWire(regionMgr, progress, dex, database); + mapUi.AutoWire(spawner); InsectGame.UI.KeyGuideHUD keyGuide = EnsureComponent("UI/KeyGuideHUD"); keyGuide.AutoWire(minigame, battleController, battleUi); @@ -277,6 +354,7 @@ private void BuildSystems(GameObject player, Camera camera) InsectGame.UI.PlayerStatusHUD statusHud = EnsureComponent("UI/PlayerStatusHUD"); statusHud.AutoWire(progress, candyInventory, insectCollection, itemInventory, dex, battleTeam, regionMgr); + statusHud.AutoWire(wallet); CharacterOutfitManager outfitManager = EnsureComponent("World/CharacterOutfit"); outfitManager.AutoWire(wallet); @@ -311,8 +389,10 @@ private void BuildSystems(GameObject player, Camera camera) // 캐시 상점 + 가챠 시스템 CashShopManager cashShop = EnsureComponent("World/CashShop"); + cashShop.AutoWire(wallet); // 보석 동기화 (PlayerCurrencyWallet ↔ CashShopManager) GachaBoxManager gachaBox = EnsureComponent("World/GachaBox"); gachaBox.AutoWire(insectCollection, candyInventory); + gachaBox.AutoWire(database); // PickRandomInsect 결과 검증 + DisplayName 캐싱 InsectGame.UI.CashShopUI cashShopUI = EnsureComponent("UI/CashShopUI"); InsectGame.UI.CharacterViewerUI viewerUI = EnsureComponent("UI/CharacterViewerUI"); @@ -345,7 +425,8 @@ private System.Collections.IEnumerator DelayedAudioStart(AudioManager audioManag if (audioManager != null) { audioManager.PlayBGM(BgmType.Explore); - audioManager.PlayAmbient("forest"); + audioManager.PlayAmbient("day"); + EnsureComponent("World/UIAudioBinder"); } } @@ -410,6 +491,8 @@ private GameObject EnsurePlayer() if (player.GetComponent() == null) { + // 충돌 캡슐은 옛 값 유지 (시각은 7등신, 충돌은 옛 캡슐 분리). + // 새 캡슐(height 2.5, center 1.2)로 변경 시 OverlapSphere 자기 자신 가드 실패로 이동 차단 회귀 발생. CapsuleCollider cc = player.AddComponent(); cc.height = 2f; cc.radius = 0.5f; @@ -423,55 +506,65 @@ private GameObject EnsurePlayer() if (player.GetComponentInChildren() == null) { - BuildPlayerVisual(player); + player.AddComponent(); } return player; } - private void BuildPlayerVisual(GameObject player) + // BuildPlayerVisual + BuildHair 계열 메서드는 PlayerVisualBuilder.cs로 이전됨. + // (필드 캐릭터 6.4등신 슬림 비례 + CharacterOutfitManager 연동을 위해 분리) + // 호출처: EnsurePlayer()에서 player.AddComponent(). + // ───── 아래 잔존 코드는 모두 비활성 폐기 블록. 컴파일 통과를 위해 #if false로 차단. ───── +#if false + private void __DeadCode_PlayerVisualLegacy(GameObject player) { - Material jacketMat = CreateSafeMaterial(new Color(0.16f, 0.32f, 0.72f)); - Material shirtMat = CreateSafeMaterial(new Color(0.95f, 0.93f, 0.86f)); - Material pantsMat = CreateSafeMaterial(new Color(0.18f, 0.22f, 0.28f)); - Material skinMat = CreateSafeMaterial(new Color(0.92f, 0.78f, 0.62f)); - Material accentMat = CreateSafeMaterial(new Color(0.94f, 0.58f, 0.18f)); - Material bootMat = CreateSafeMaterial(new Color(0.12f, 0.10f, 0.08f)); - - int gender = PlayerPrefs.GetInt("InsectGame.Character.Gender", 0); + const float headPivotScale = 0.42f; + int gender = 0; + Material jacketMat = null, shirtMat = null, pantsMat = null, skinMat = null, accentMat = null, bootMat = null; - // 남자: 기존 크기 그대로 / 여자: 몸통 약간 가늘게, 머리 약간 크게, 다리 약간 가늘게 - float bodyScaleX = gender == 1 ? 0.65f : 0.72f; - float bodyScaleZ = gender == 1 ? 0.55f : 0.62f; - float headScale = gender == 1 ? 0.5f : 0.48f; - float legScale = gender == 1 ? 0.18f : 0.2f; + float bodyScaleX = gender == 1 ? 0.72f : 0.78f; + float bodyScaleZ = gender == 1 ? 0.6f : 0.66f; + float headScale = gender == 1 ? 0.74f : 0.72f; + float legScale = gender == 1 ? 0.2f : 0.22f; GameObject body = GameObject.CreatePrimitive(PrimitiveType.Capsule); body.name = "Body"; body.transform.SetParent(player.transform, false); - body.transform.localPosition = new Vector3(0f, 1f, 0f); - body.transform.localScale = new Vector3(bodyScaleX, 0.68f, bodyScaleZ); + body.transform.localPosition = new Vector3(0f, 1.40f, 0f); + body.transform.localScale = new Vector3(bodyScaleX, 0.5f, bodyScaleZ); body.GetComponent().material = jacketMat; Object.Destroy(body.GetComponent()); GameObject shirt = GameObject.CreatePrimitive(PrimitiveType.Cube); shirt.name = "Shirt"; shirt.transform.SetParent(player.transform, false); - shirt.transform.localPosition = new Vector3(0f, 1.08f, 0.18f); + shirt.transform.localPosition = new Vector3(0f, 1.48f, 0.18f); shirt.transform.localScale = new Vector3(0.42f, 0.62f, 0.24f); shirt.GetComponent().material = shirtMat; Object.Destroy(shirt.GetComponent()); - // HeadPivot: 스케일 (1,1,1) 빈 오브젝트 — 자식 파츠의 스케일 왜곡 방지 + // 목: 작아진 머리와 몸통 사이 공백을 메움 + GameObject neck = GameObject.CreatePrimitive(PrimitiveType.Cylinder); + neck.name = "Neck"; + neck.transform.SetParent(player.transform, false); + neck.transform.localPosition = new Vector3(0f, 1.96f, 0.02f); + neck.transform.localScale = new Vector3(0.16f, 0.08f, 0.14f); + neck.GetComponent().material = skinMat; + Object.Destroy(neck.GetComponent()); + + // HeadPivot: 자식 파츠가 동일 비율로 함께 축소되도록 균일 스케일 적용 GameObject headPivot = new GameObject("HeadPivot"); headPivot.transform.SetParent(player.transform, false); - headPivot.transform.localPosition = new Vector3(0f, 1.86f, 0.04f); + headPivot.transform.localPosition = new Vector3(0f, 2.20f, 0.04f); + headPivot.transform.localScale = Vector3.one * headPivotScale; GameObject head = GameObject.CreatePrimitive(PrimitiveType.Sphere); head.name = "Head"; head.transform.SetParent(headPivot.transform, false); head.transform.localPosition = Vector3.zero; - head.transform.localScale = new Vector3(headScale, headScale + 0.04f, headScale - 0.02f); + // 갸름한 오발형: X 좁게, Y 길게, Z 약간 좁게 + head.transform.localScale = new Vector3(headScale - 0.10f, headScale + 0.12f, headScale - 0.04f); head.GetComponent().material = skinMat; Object.Destroy(head.GetComponent()); @@ -479,7 +572,7 @@ private void BuildPlayerVisual(GameObject player) cap.name = "Cap"; cap.transform.SetParent(headPivot.transform, false); cap.transform.localPosition = new Vector3(0f, 0.3f, -0.02f); - cap.transform.localScale = new Vector3(0.34f, 0.12f, 0.34f); + cap.transform.localScale = new Vector3(0.30f, 0.12f, 0.30f); cap.GetComponent().material = accentMat; Object.Destroy(cap.GetComponent()); @@ -487,7 +580,7 @@ private void BuildPlayerVisual(GameObject player) brim.name = "CapBrim"; brim.transform.SetParent(headPivot.transform, false); brim.transform.localPosition = new Vector3(0f, 0.14f, 0.28f); - brim.transform.localScale = new Vector3(0.32f, 0.03f, 0.16f); + brim.transform.localScale = new Vector3(0.28f, 0.03f, 0.14f); brim.GetComponent().material = accentMat; Object.Destroy(brim.GetComponent()); @@ -497,35 +590,53 @@ private void BuildPlayerVisual(GameObject player) GameObject eyeL = GameObject.CreatePrimitive(PrimitiveType.Sphere); eyeL.name = "EyeL"; eyeL.transform.SetParent(headPivot.transform, false); - eyeL.transform.localPosition = new Vector3(-0.06f, 0.02f, 0.22f); - eyeL.transform.localScale = new Vector3(0.06f, 0.06f, 0.03f); + eyeL.transform.localPosition = new Vector3(-0.1f, 0.0f, 0.32f); + eyeL.transform.localScale = new Vector3(0.11f, 0.11f, 0.05f); eyeL.GetComponent().material = eyeMat; Object.Destroy(eyeL.GetComponent()); GameObject eyeR = GameObject.CreatePrimitive(PrimitiveType.Sphere); eyeR.name = "EyeR"; eyeR.transform.SetParent(headPivot.transform, false); - eyeR.transform.localPosition = new Vector3(0.06f, 0.02f, 0.22f); - eyeR.transform.localScale = new Vector3(0.06f, 0.06f, 0.03f); + eyeR.transform.localPosition = new Vector3(0.1f, 0.0f, 0.32f); + eyeR.transform.localScale = new Vector3(0.11f, 0.11f, 0.05f); eyeR.GetComponent().material = eyeMat; Object.Destroy(eyeR.GetComponent()); GameObject pupilL = GameObject.CreatePrimitive(PrimitiveType.Sphere); pupilL.name = "PupilL"; pupilL.transform.SetParent(headPivot.transform, false); - pupilL.transform.localPosition = new Vector3(-0.06f, 0.02f, 0.24f); - pupilL.transform.localScale = new Vector3(0.025f, 0.025f, 0.01f); + pupilL.transform.localPosition = new Vector3(-0.1f, 0.0f, 0.35f); + pupilL.transform.localScale = new Vector3(0.055f, 0.055f, 0.02f); pupilL.GetComponent().material = pupilMat; Object.Destroy(pupilL.GetComponent()); GameObject pupilR = GameObject.CreatePrimitive(PrimitiveType.Sphere); pupilR.name = "PupilR"; pupilR.transform.SetParent(headPivot.transform, false); - pupilR.transform.localPosition = new Vector3(0.06f, 0.02f, 0.24f); - pupilR.transform.localScale = new Vector3(0.025f, 0.025f, 0.01f); + pupilR.transform.localPosition = new Vector3(0.1f, 0.0f, 0.35f); + pupilR.transform.localScale = new Vector3(0.055f, 0.055f, 0.02f); pupilR.GetComponent().material = pupilMat; Object.Destroy(pupilR.GetComponent()); + // 눈 하이라이트 (반짝임) + Material hlMat = CreateSafeMaterial(new Color(1f, 1f, 1f, 0.9f)); + GameObject hlL = GameObject.CreatePrimitive(PrimitiveType.Sphere); + hlL.name = "HighlightL"; + hlL.transform.SetParent(headPivot.transform, false); + hlL.transform.localPosition = new Vector3(-0.08f, 0.03f, 0.36f); + hlL.transform.localScale = new Vector3(0.025f, 0.025f, 0.01f); + hlL.GetComponent().material = hlMat; + Object.Destroy(hlL.GetComponent()); + + GameObject hlR = GameObject.CreatePrimitive(PrimitiveType.Sphere); + hlR.name = "HighlightR"; + hlR.transform.SetParent(headPivot.transform, false); + hlR.transform.localPosition = new Vector3(0.08f, 0.03f, 0.36f); + hlR.transform.localScale = new Vector3(0.025f, 0.025f, 0.01f); + hlR.GetComponent().material = hlMat; + Object.Destroy(hlR.GetComponent()); + // ── 얼굴 ── int faceType = PlayerPrefs.GetInt("InsectGame.Character.FaceType", 0); @@ -534,16 +645,16 @@ private void BuildPlayerVisual(GameObject player) GameObject browL = GameObject.CreatePrimitive(PrimitiveType.Cube); browL.name = "BrowL"; browL.transform.SetParent(headPivot.transform, false); - browL.transform.localPosition = new Vector3(-0.06f, 0.07f, 0.22f); - browL.transform.localScale = new Vector3(0.05f, 0.01f, 0.015f); + browL.transform.localPosition = new Vector3(-0.1f, 0.08f, 0.32f); + browL.transform.localScale = new Vector3(0.08f, 0.015f, 0.02f); browL.GetComponent().material = browMat; Object.Destroy(browL.GetComponent()); GameObject browR = GameObject.CreatePrimitive(PrimitiveType.Cube); browR.name = "BrowR"; browR.transform.SetParent(headPivot.transform, false); - browR.transform.localPosition = new Vector3(0.06f, 0.07f, 0.22f); - browR.transform.localScale = new Vector3(0.05f, 0.01f, 0.015f); + browR.transform.localPosition = new Vector3(0.1f, 0.08f, 0.32f); + browR.transform.localScale = new Vector3(0.08f, 0.015f, 0.02f); browR.GetComponent().material = browMat; Object.Destroy(browR.GetComponent()); @@ -551,8 +662,8 @@ private void BuildPlayerVisual(GameObject player) GameObject nose = GameObject.CreatePrimitive(PrimitiveType.Sphere); nose.name = "Nose"; nose.transform.SetParent(headPivot.transform, false); - nose.transform.localPosition = new Vector3(0f, -0.01f, 0.24f); - nose.transform.localScale = new Vector3(0.03f, 0.025f, 0.025f); + nose.transform.localPosition = new Vector3(0f, -0.04f, 0.35f); + nose.transform.localScale = new Vector3(0.04f, 0.035f, 0.03f); nose.GetComponent().material = skinMat; Object.Destroy(nose.GetComponent()); @@ -570,8 +681,8 @@ private void BuildPlayerVisual(GameObject player) case 2: mouthWidth = 0.03f; mouthY = -0.04f; break; // 차분 case 3: mouthWidth = 0.02f; mouthY = -0.045f; break; // 무표정 } - mouth.transform.localPosition = new Vector3(0f, mouthY, 0.22f); - mouth.transform.localScale = new Vector3(mouthWidth, 0.008f, 0.01f); + mouth.transform.localPosition = new Vector3(0f, mouthY, 0.32f); + mouth.transform.localScale = new Vector3(mouthWidth, 0.015f, 0.015f); mouth.GetComponent().material = mouthMat; Object.Destroy(mouth.GetComponent()); @@ -582,16 +693,16 @@ private void BuildPlayerVisual(GameObject player) GameObject blushL = GameObject.CreatePrimitive(PrimitiveType.Sphere); blushL.name = "BlushL"; blushL.transform.SetParent(headPivot.transform, false); - blushL.transform.localPosition = new Vector3(-0.08f, -0.02f, 0.20f); - blushL.transform.localScale = new Vector3(0.04f, 0.025f, 0.015f); + blushL.transform.localPosition = new Vector3(-0.14f, -0.04f, 0.28f); + blushL.transform.localScale = new Vector3(0.06f, 0.035f, 0.02f); blushL.GetComponent().material = blushMat; Object.Destroy(blushL.GetComponent()); GameObject blushR = GameObject.CreatePrimitive(PrimitiveType.Sphere); blushR.name = "BlushR"; blushR.transform.SetParent(headPivot.transform, false); - blushR.transform.localPosition = new Vector3(0.08f, -0.02f, 0.20f); - blushR.transform.localScale = new Vector3(0.04f, 0.025f, 0.015f); + blushR.transform.localPosition = new Vector3(0.14f, -0.04f, 0.28f); + blushR.transform.localScale = new Vector3(0.06f, 0.035f, 0.02f); blushR.GetComponent().material = blushMat; Object.Destroy(blushR.GetComponent()); @@ -651,8 +762,8 @@ private void BuildPlayerVisual(GameObject player) GameObject armL = GameObject.CreatePrimitive(PrimitiveType.Capsule); armL.name = "ArmL"; armL.transform.SetParent(player.transform, false); - armL.transform.localPosition = new Vector3(-0.48f, 1.08f, 0f); - armL.transform.localScale = new Vector3(0.18f, 0.48f, 0.18f); + armL.transform.localPosition = new Vector3(-0.5f, 1.48f, 0f); + armL.transform.localScale = new Vector3(0.18f, 0.42f, 0.18f); armL.transform.localRotation = Quaternion.Euler(0f, 0f, 14f); armL.GetComponent().material = jacketMat; Object.Destroy(armL.GetComponent()); @@ -660,57 +771,74 @@ private void BuildPlayerVisual(GameObject player) GameObject armR = GameObject.CreatePrimitive(PrimitiveType.Capsule); armR.name = "ArmR"; armR.transform.SetParent(player.transform, false); - armR.transform.localPosition = new Vector3(0.48f, 1.08f, 0f); - armR.transform.localScale = new Vector3(0.18f, 0.48f, 0.18f); + armR.transform.localPosition = new Vector3(0.5f, 1.48f, 0f); + armR.transform.localScale = new Vector3(0.18f, 0.42f, 0.18f); armR.transform.localRotation = Quaternion.Euler(0f, 0f, -14f); armR.GetComponent().material = jacketMat; Object.Destroy(armR.GetComponent()); + // 손 (피부색 구체): 팔이 14° 기울어 끝나는 위치 (x ≈ ±0.40, y ≈ 1.07)에 손 약간 바깥쪽으로 배치 + GameObject handL = GameObject.CreatePrimitive(PrimitiveType.Sphere); + handL.name = "HandL"; + handL.transform.SetParent(player.transform, false); + handL.transform.localPosition = new Vector3(-0.55f, 1.07f, 0f); + handL.transform.localScale = new Vector3(0.11f, 0.11f, 0.11f); + handL.GetComponent().material = skinMat; + Object.Destroy(handL.GetComponent()); + + GameObject handR = GameObject.CreatePrimitive(PrimitiveType.Sphere); + handR.name = "HandR"; + handR.transform.SetParent(player.transform, false); + handR.transform.localPosition = new Vector3(0.55f, 1.07f, 0f); + handR.transform.localScale = new Vector3(0.11f, 0.11f, 0.11f); + handR.GetComponent().material = skinMat; + Object.Destroy(handR.GetComponent()); + GameObject legL = GameObject.CreatePrimitive(PrimitiveType.Capsule); legL.name = "LegL"; legL.transform.SetParent(player.transform, false); - legL.transform.localPosition = new Vector3(-0.18f, 0.36f, 0f); - legL.transform.localScale = new Vector3(legScale, 0.55f, legScale); + legL.transform.localPosition = new Vector3(-0.2f, 0.55f, 0f); + legL.transform.localScale = new Vector3(legScale, 0.62f, legScale); legL.GetComponent().material = pantsMat; Object.Destroy(legL.GetComponent()); GameObject legR = GameObject.CreatePrimitive(PrimitiveType.Capsule); legR.name = "LegR"; legR.transform.SetParent(player.transform, false); - legR.transform.localPosition = new Vector3(0.18f, 0.36f, 0f); - legR.transform.localScale = new Vector3(legScale, 0.55f, legScale); + legR.transform.localPosition = new Vector3(0.2f, 0.55f, 0f); + legR.transform.localScale = new Vector3(legScale, 0.62f, legScale); legR.GetComponent().material = pantsMat; Object.Destroy(legR.GetComponent()); GameObject bootL = GameObject.CreatePrimitive(PrimitiveType.Cube); bootL.name = "BootL"; bootL.transform.SetParent(player.transform, false); - bootL.transform.localPosition = new Vector3(-0.18f, 0.02f, 0.08f); - bootL.transform.localScale = new Vector3(0.2f, 0.12f, 0.32f); + bootL.transform.localPosition = new Vector3(-0.2f, 0.04f, 0.08f); + bootL.transform.localScale = new Vector3(0.22f, 0.14f, 0.32f); bootL.GetComponent().material = bootMat; Object.Destroy(bootL.GetComponent()); GameObject bootR = GameObject.CreatePrimitive(PrimitiveType.Cube); bootR.name = "BootR"; bootR.transform.SetParent(player.transform, false); - bootR.transform.localPosition = new Vector3(0.18f, 0.02f, 0.08f); - bootR.transform.localScale = new Vector3(0.2f, 0.12f, 0.32f); + bootR.transform.localPosition = new Vector3(0.2f, 0.04f, 0.08f); + bootR.transform.localScale = new Vector3(0.22f, 0.14f, 0.32f); bootR.GetComponent().material = bootMat; Object.Destroy(bootR.GetComponent()); GameObject pack = GameObject.CreatePrimitive(PrimitiveType.Cube); pack.name = "Backpack"; pack.transform.SetParent(player.transform, false); - pack.transform.localPosition = new Vector3(0f, 1.02f, -0.28f); - pack.transform.localScale = new Vector3(0.34f, 0.46f, 0.18f); + pack.transform.localPosition = new Vector3(0f, 1.42f, -0.28f); + pack.transform.localScale = new Vector3(0.34f, 0.42f, 0.2f); pack.GetComponent().material = accentMat; Object.Destroy(pack.GetComponent()); GameObject netHandle = GameObject.CreatePrimitive(PrimitiveType.Cylinder); netHandle.name = "NetHandle"; netHandle.transform.SetParent(player.transform, false); - netHandle.transform.localPosition = new Vector3(0.62f, 1.18f, -0.18f); - netHandle.transform.localScale = new Vector3(0.04f, 0.75f, 0.04f); + netHandle.transform.localPosition = new Vector3(0.55f, 1.62f, -0.18f); + netHandle.transform.localScale = new Vector3(0.04f, 0.7f, 0.04f); netHandle.transform.localRotation = Quaternion.Euler(18f, 0f, -38f); netHandle.GetComponent().material = bootMat; Object.Destroy(netHandle.GetComponent()); @@ -718,7 +846,7 @@ private void BuildPlayerVisual(GameObject player) GameObject netRing = GameObject.CreatePrimitive(PrimitiveType.Cylinder); netRing.name = "NetRing"; netRing.transform.SetParent(player.transform, false); - netRing.transform.localPosition = new Vector3(0.95f, 1.92f, -0.1f); + netRing.transform.localPosition = new Vector3(0.82f, 2.28f, -0.1f); netRing.transform.localScale = new Vector3(0.24f, 0.02f, 0.24f); netRing.transform.localRotation = Quaternion.Euler(0f, 0f, 90f); netRing.GetComponent().material = shirtMat; @@ -939,6 +1067,8 @@ private void BuildUpHair(GameObject headPivot, int gender, Material mat) } } } +#endif + // ───── 폐기 블록 끝. 이후 정상 코드. ───── private void EnsureGround() { @@ -947,7 +1077,7 @@ private void EnsureGround() return; } - Data.RegionData[] regionDefs = CreateExpandedRegions(); + Data.RegionData[] regionDefs = RegionDefinitions.CreateAll(); // --- Base ground --- Material baseMat = CreateSafeMaterial(new Color(0.25f, 0.45f, 0.18f)); @@ -984,7 +1114,7 @@ private void EnsureGround() Material mat = CreateSafeMaterial(new Color(col.r * 0.5f + 0.1f, col.g * 0.5f + 0.1f, col.b * 0.4f + 0.08f)); GameObject regionGround = GameObject.CreatePrimitive(PrimitiveType.Plane); regionGround.name = $"Region_{region.regionId}"; - regionGround.transform.position = region.centerPosition + new Vector3(0f, 0.02f, 0f); + regionGround.transform.position = region.centerPosition + new Vector3(0f, 0.08f, 0f); float s = region.radius / 5f; regionGround.transform.localScale = new Vector3(s, 1f, s); regionGround.GetComponent().material = mat; @@ -1036,6 +1166,14 @@ private void EnsureGround() AddSceneryObjects(baseMat); AddRegionScenery(regionDefs); + // 지형 구축 (고저차, 절벽, 강, 다리) + WorldTerrainBuilder terrainBuilder = new GameObject("WorldTerrainBuilder").AddComponent(); + terrainBuilder.BuildTerrain(regionDefs); + + // 리전별 게임 필드 지형 (언덕, 길, 바위, 나무 등) + RegionTerrainBuilder regionTerrain = new GameObject("RegionTerrainBuilder").AddComponent(); + regionTerrain.BuildAllRegions(regionDefs); + // 수문장은 BuildSystems에서 database와 함께 생성 CreateSubAreaEntries(regionDefs); } @@ -1069,7 +1207,7 @@ private void CreatePath(Material mat, Vector3 from, Vector3 to, float width) // Path plane segment GameObject path = GameObject.CreatePrimitive(PrimitiveType.Plane); path.name = $"Path_Seg_{seg}"; - path.transform.position = segMid + new Vector3(0f, 0.015f, 0f); + path.transform.position = segMid + new Vector3(0f, 0.12f, 0f); path.transform.rotation = Quaternion.Euler(0f, segAngle, 0f); path.transform.localScale = new Vector3(width / 10f, 1f, segLen / 10f); path.GetComponent().material = mat; @@ -1088,7 +1226,7 @@ private void CreatePath(Material mat, Vector3 from, Vector3 to, float width) GameObject gravel = GameObject.CreatePrimitive(PrimitiveType.Sphere); gravel.name = $"Path_Gravel_{pathIdx}"; float gs = Random.Range(0.15f, 0.3f); - gravel.transform.position = gPos + new Vector3(0f, gs * 0.15f + 0.02f, 0f); + gravel.transform.position = gPos + new Vector3(0f, gs * 0.15f + 0.1f, 0f); gravel.transform.localScale = new Vector3(gs * 1.3f, gs * 0.3f, gs); gravel.GetComponent().material = gravelMat; Object.Destroy(gravel.GetComponent()); @@ -2931,57 +3069,6 @@ private Data.CaptureItemData[] CreateCaptureItems() }; } - private Data.RegionData[] CreateRegions() - { - return new Data.RegionData[] - { - new Data.RegionData - { - regionId = "meadow", - displayName = "초원", - description = "평화로운 초원 - 초보자에게 적합", - themeColor = new Color(0.4f, 0.8f, 0.3f), - centerPosition = Vector3.zero, - radius = 50f, - requiredLevel = 1, - insectIds = new[] { "beetle_basic", "mantis_green", "moth_night", "bee_worker", "cricket_field", "ant_soldier" } - }, - new Data.RegionData - { - regionId = "pond", - displayName = "연못", - description = "물가에 사는 곤충들의 서식지", - themeColor = new Color(0.3f, 0.6f, 1f), - centerPosition = new Vector3(100f, 0f, 30f), - radius = 45f, - requiredLevel = 3, - insectIds = new[] { "dragonfly_lake", "water_strider", "diving_beetle", "jewel_beetle" } - }, - new Data.RegionData - { - regionId = "forest", - displayName = "숲", - description = "울창한 숲 속 강력한 곤충이 서식", - themeColor = new Color(0.2f, 0.5f, 0.15f), - centerPosition = new Vector3(-80f, 0f, 80f), - radius = 55f, - requiredLevel = 5, - insectIds = new[] { "stag_beetle", "firefly_glow", "cicada_summer", "rhinoceros_beetle", "atlas_moth" } - }, - new Data.RegionData - { - regionId = "garden", - displayName = "꽃밭", - description = "희귀한 나비와 전설의 곤충이 숨어있는 곳", - themeColor = new Color(1f, 0.5f, 0.7f), - centerPosition = new Vector3(60f, 0f, -90f), - radius = 40f, - requiredLevel = 8, - insectIds = new[] { "butterfly_azure", "luna_moth", "golden_scarab" } - } - }; - } - private InsectDatabase EnsureExpandedDatabase() { InsectDatabase database = ScriptableObject.CreateInstance(); @@ -3355,337 +3442,6 @@ private InsectSkill[] CollectAllSkills(InsectDatabase database, InsectSkill[] tr return result; } - private Data.RegionData[] CreateExpandedRegions() - { - return new Data.RegionData[] - { - // ── 초원: Lv.1~10, 입문 지역, Common/Uncommon 위주 ── - new Data.RegionData - { - regionId = "meadow", - displayName = "초원", - description = "평화로운 초원 — 흔한 곤충이 많아 초보자에게 적합합니다.", - themeColor = new Color(0.4f, 0.8f, 0.3f), - centerPosition = Vector3.zero, - radius = 50f, - requiredLevel = 1, - insectIds = new[] - { - "beetle_basic", "bee_worker", "cricket_field", "ant_soldier", - "grasshopper_green", "ladybug_seven", "caterpillar_green", "aphid_colony", - "moth_brown", "beetle_dung" - }, - guardianInsectId = "mantis_green", - guardianDisplayName = "초원의 수호자 사마귀", - guardianLevel = 13, - subAreas = new Data.SubAreaData[] - { - new Data.SubAreaData - { - subAreaId = "meadow_cave", - displayName = "초원 동굴", - description = "초원 아래 숨겨진 동굴 — 어둠 속 곤충이 서식합니다.", - centerPosition = new Vector3(15f, 0f, 25f), - radius = 10f, - exclusiveInsectIds = new[] { "centipede_common", "earwig_common", "pill_bug_garden" }, - minLevel = 3, - maxLevel = 10, - environmentType = "cave" - }, - new Data.SubAreaData - { - subAreaId = "meadow_pond", - displayName = "숨겨진 웅덩이", - description = "초원 한쪽에 숨겨진 작은 웅덩이 — 물가 곤충이 출현합니다.", - centerPosition = new Vector3(-20f, 0f, 15f), - radius = 8f, - exclusiveInsectIds = new[] { "mosquito_common", "damselfly_blue" }, - minLevel = 2, - maxLevel = 8, - environmentType = "pond" - } - } - }, - // ── 연못: Lv.6~16, 수서곤충 + 희귀종 등장 ── - new Data.RegionData - { - regionId = "pond", - displayName = "연못", - description = "물가에 사는 곤충들의 서식지 — 수서곤충과 희귀종이 출현합니다.", - themeColor = new Color(0.3f, 0.6f, 1f), - centerPosition = new Vector3(100f, 0f, 30f), - radius = 45f, - requiredLevel = 6, - insectIds = new[] - { - "dragonfly_lake", "water_strider_pond", "fly_house", - "dragonfly_emperor" - }, - guardianInsectId = "dragonfly_emperor", - guardianDisplayName = "연못의 파수꾼 왕잠자리", - guardianLevel = 20, - subAreas = new Data.SubAreaData[] - { - new Data.SubAreaData - { - subAreaId = "pond_deep", - displayName = "연못 깊은 곳", - description = "연못 깊숙한 곳 — 수중 곤충만이 살아남을 수 있습니다.", - centerPosition = new Vector3(105f, 0f, 25f), - radius = 10f, - exclusiveInsectIds = new[] { "diving_beetle_deep", "diving_beetle_small" }, - minLevel = 8, - maxLevel = 16, - environmentType = "underwater" - }, - new Data.SubAreaData - { - subAreaId = "pond_reeds", - displayName = "갈대 밀림", - description = "갈대가 빽빽이 우거진 곳 — 야행성 곤충이 숨어있습니다.", - centerPosition = new Vector3(90f, 0f, 40f), - radius = 12f, - exclusiveInsectIds = new[] { "firefly_blue", "cicada_evening" }, - minLevel = 6, - maxLevel = 14, - environmentType = "reeds" - } - } - }, - // ── 숲: Lv.12~24, 강력한 곤충 ── - new Data.RegionData - { - regionId = "forest", - displayName = "숲", - description = "울창한 숲 속 강력한 곤충이 서식 — 높은 레벨의 도전이 필요합니다.", - themeColor = new Color(0.2f, 0.5f, 0.15f), - centerPosition = new Vector3(-80f, 0f, 80f), - radius = 55f, - requiredLevel = 12, - insectIds = new[] - { - "stag_beetle", "rhinoceros_beetle", "cicada_summer", "moth_night", - "mantis_green", "longhorn_beetle", "stick_insect_long", "beetle_longhorn_rosalia", - "hornet_asian" - }, - guardianInsectId = "beetle_hercules", - guardianDisplayName = "숲의 문지기 헤라클레스", - guardianLevel = 28, - subAreas = new Data.SubAreaData[] - { - new Data.SubAreaData - { - subAreaId = "forest_deep", - displayName = "깊은 숲", - description = "빛이 닿지 않는 깊은 숲 — 유령 곤충과 거대 나방이 출현합니다.", - centerPosition = new Vector3(-90f, 0f, 95f), - radius = 14f, - exclusiveInsectIds = new[] { "mantis_ghost", "leaf_insect_phantom", "atlas_moth_giant" }, - minLevel = 18, - maxLevel = 28, - environmentType = "deep_forest" - }, - new Data.SubAreaData - { - subAreaId = "forest_cave", - displayName = "숲속 동굴", - description = "숲 깊은 곳의 동굴 — 보스급 곤충이 서식합니다.", - centerPosition = new Vector3(-70f, 0f, 70f), - radius = 10f, - exclusiveInsectIds = new[] { "beetle_hercules", "scarab_ancient" }, - minLevel = 15, - maxLevel = 24, - environmentType = "cave" - } - } - }, - // ── 습지: Lv.20~32, 독/어둠 곤충 (신규) ── - new Data.RegionData - { - regionId = "swamp", - displayName = "습지", - description = "안개가 자욱한 습지 — 독을 가진 곤충과 어둠의 포식자가 서식합니다.", - themeColor = new Color(0.25f, 0.35f, 0.2f), - centerPosition = new Vector3(-30f, 0f, -60f), - radius = 45f, - requiredLevel = 20, - insectIds = new[] - { - "centipede_common", "earwig_common", "mosquito_common", "pill_bug_garden", - "damselfly_blue", "firefly_blue", "cicada_evening" - }, - guardianInsectId = "mantis_ghost", - guardianDisplayName = "습지의 유령 사마귀", - guardianLevel = 37, - subAreas = new Data.SubAreaData[] - { - new Data.SubAreaData - { - subAreaId = "swamp_fog", - displayName = "안개 습지", - description = "짙은 안개가 시야를 가리는 곳 — 유령 곤충이 출몰합니다.", - centerPosition = new Vector3(-25f, 0f, -70f), - radius = 12f, - exclusiveInsectIds = new[] { "mantis_ghost", "leaf_insect_phantom" }, - minLevel = 24, - maxLevel = 32, - environmentType = "fog" - }, - new Data.SubAreaData - { - subAreaId = "swamp_cave", - displayName = "습지 동굴", - description = "습지 깊숙한 동굴 — 고대 곤충이 잠들어 있습니다.", - centerPosition = new Vector3(-40f, 0f, -55f), - radius = 10f, - exclusiveInsectIds = new[] { "scarab_ancient", "beetle_hercules" }, - minLevel = 22, - maxLevel = 30, - environmentType = "cave" - } - } - }, - // ── 산: Lv.28~40, 고산 곤충 (신규) ── - new Data.RegionData - { - regionId = "mountain", - displayName = "산", - description = "험준한 산악 지형 — 강인한 고산 곤충만이 살아남는 극한 환경입니다.", - themeColor = new Color(0.5f, 0.45f, 0.4f), - centerPosition = new Vector3(-120f, 0f, -30f), - radius = 50f, - requiredLevel = 28, - insectIds = new[] - { - "hornet_asian", "beetle_longhorn_rosalia", "stick_insect_long", - "katydid_leaf", "beetle_click", "spider_garden" - }, - guardianInsectId = "atlas_moth_giant", - guardianDisplayName = "산의 거신 아틀라스나방", - guardianLevel = 45, - subAreas = new Data.SubAreaData[] - { - new Data.SubAreaData - { - subAreaId = "mountain_peak", - displayName = "산 정상", - description = "바람이 휘몰아치는 정상 — 전설급 곤충이 날아다닙니다.", - centerPosition = new Vector3(-130f, 0f, -20f), - radius = 12f, - exclusiveInsectIds = new[] { "dragonfly_ancient", "butterfly_morpho" }, - minLevel = 34, - maxLevel = 42, - environmentType = "peak" - }, - new Data.SubAreaData - { - subAreaId = "mountain_cave", - displayName = "산속 동굴", - description = "산 깊은 곳의 동굴 — 거대 거미와 나방이 서식합니다.", - centerPosition = new Vector3(-110f, 0f, -40f), - radius = 10f, - exclusiveInsectIds = new[] { "spider_golden_orb", "atlas_moth_giant" }, - minLevel = 30, - maxLevel = 38, - environmentType = "cave" - } - } - }, - // ── 꽃밭: Lv.18~35, 희귀 나비 (분기 경로) ── - new Data.RegionData - { - regionId = "garden", - displayName = "꽃밭", - description = "희귀한 나비와 전설의 곤충이 숨어있는 비밀의 정원입니다.", - themeColor = new Color(1f, 0.5f, 0.7f), - centerPosition = new Vector3(60f, 0f, -90f), - radius = 40f, - requiredLevel = 18, - insectIds = new[] - { - "butterfly_azure", "butterfly_monarch", "butterfly_swallowtail", - "luna_moth_silver", "jewel_beetle_gold", "firefly_glow", - "wasp_paper", "butterfly_cabbage" - }, - guardianInsectId = "butterfly_swallowtail", - guardianDisplayName = "꽃밭의 문지기 호랑나비", - guardianLevel = 13, - subAreas = new Data.SubAreaData[] - { - new Data.SubAreaData - { - subAreaId = "garden_maze", - displayName = "꽃 미로", - description = "거대한 꽃으로 이루어진 미로 — 전설급 곤충이 숨어있습니다.", - centerPosition = new Vector3(55f, 0f, -100f), - radius = 12f, - exclusiveInsectIds = new[] { "butterfly_alexandras", "mantis_orchid" }, - minLevel = 30, - maxLevel = 40, - environmentType = "flower_maze" - }, - new Data.SubAreaData - { - subAreaId = "garden_greenhouse", - displayName = "온실", - description = "유리로 된 온실 — 최상위 곤충이 서식합니다.", - centerPosition = new Vector3(70f, 0f, -80f), - radius = 10f, - exclusiveInsectIds = new[] { "spider_golden_orb", "beetle_golden_stag" }, - minLevel = 25, - maxLevel = 35, - environmentType = "greenhouse" - } - } - }, - // ── 고대 유적: Lv.36~50, 전설급 (신규) ── - new Data.RegionData - { - regionId = "ruins", - displayName = "고대 유적", - description = "잊혀진 문명의 유적 — 전설급 곤충만이 서식하는 최종 지역입니다.", - themeColor = new Color(0.4f, 0.35f, 0.3f), - centerPosition = new Vector3(0f, 0f, 140f), - radius = 45f, - requiredLevel = 36, - insectIds = new[] - { - "mantis_orchid", "butterfly_alexandras", "beetle_golden_stag", - "dragonfly_ancient", "butterfly_morpho" - }, - guardianInsectId = null, - guardianDisplayName = null, - guardianLevel = 0, - subAreas = new Data.SubAreaData[] - { - new Data.SubAreaData - { - subAreaId = "ruins_temple", - displayName = "고대 신전", - description = "유적 깊숙한 신전 — 필드 전설 곤충만이 출현합니다.", - centerPosition = new Vector3(5f, 0f, 150f), - radius = 12f, - exclusiveInsectIds = new[] { "mantis_orchid", "butterfly_alexandras" }, - minLevel = 40, - maxLevel = 50, - environmentType = "temple" - }, - new Data.SubAreaData - { - subAreaId = "ruins_underground", - displayName = "유적 지하", - description = "유적 아래 봉인된 지하 — 고대 곤충이 잠들어 있습니다.", - centerPosition = new Vector3(-10f, 0f, 135f), - radius = 10f, - exclusiveInsectIds = new[] { "beetle_hercules", "leaf_insect_phantom" }, - minLevel = 38, - maxLevel = 48, - environmentType = "underground" - } - } - } - }; - } private int GetStableValue(string text) { @@ -3709,7 +3465,7 @@ private SpawnPoint[] EnsureSpawnPoints() return existing; } - Data.RegionData[] regionDefs = CreateExpandedRegions(); + Data.RegionData[] regionDefs = RegionDefinitions.CreateAll(); List points = new List(); int pointsPerRegion = Mathf.Max(4, Mathf.CeilToInt(spawnPointCount / (float)Mathf.Max(1, regionDefs.Length))); @@ -4656,6 +4412,7 @@ private void CreateGuardians(Data.RegionData[] regions, InsectDatabase database) platform.transform.position = guardianPos + new Vector3(0, 0.15f, 0); platform.transform.localScale = new Vector3(4f, 0.15f, 4f); platform.GetComponent().material = platMat; + Object.Destroy(platform.GetComponent()); // 수문장 기둥 (양쪽) Material pillarMat = CreateSafeMaterial(new Color(0.4f, 0.3f, 0.25f)); @@ -4666,6 +4423,7 @@ private void CreateGuardians(Data.RegionData[] regions, InsectDatabase database) pillar.transform.position = guardianPos + new Vector3(side * 3f, 2f, 0); pillar.transform.localScale = new Vector3(0.5f, 2f, 0.5f); pillar.GetComponent().material = pillarMat; + Object.Destroy(pillar.GetComponent()); } // 오라 (빨간 반투명) diff --git a/Assets/Scripts/Core/PlayerCandyInventory.cs b/Assets/Scripts/Core/PlayerCandyInventory.cs index c6cca0e..2b1deff 100644 --- a/Assets/Scripts/Core/PlayerCandyInventory.cs +++ b/Assets/Scripts/Core/PlayerCandyInventory.cs @@ -25,7 +25,8 @@ private void Awake() public void AddCandy(int amount) { - if (amount <= 0) + // data null 가드 — Awake 실패/순서 race 시 NRE 차단. Candies 프로퍼티(line 17)와 비대칭 회귀. + if (amount <= 0 || data == null) { return; } @@ -37,7 +38,7 @@ public void AddCandy(int amount) public bool SpendCandy(int amount) { - if (amount <= 0 || data.candies < amount) + if (amount <= 0 || data == null || data.candies < amount) { return false; } @@ -56,14 +57,24 @@ private PlayerCandyData Load() return new PlayerCandyData(); } - string json = File.ReadAllText(path); - return JsonUtility.FromJson(json) ?? new PlayerCandyData(); + try + { + string json = File.ReadAllText(path); + return JsonUtility.FromJson(json) ?? new PlayerCandyData(); + } + catch (System.Exception e) + { + Debug.LogWarning($"[PlayerCandyInventory] 손상된 세이브 — 기본값으로 시작: {e.Message}"); + return new PlayerCandyData(); + } } private void Save(PlayerCandyData save) { + // PlayerProgressSaveService와 일관성 — null 진입 시 빈 파일 쓰기 차단. + if (save == null) return; string json = JsonUtility.ToJson(save, true); - File.WriteAllText(GetPath(), json); + AtomicFileWriter.WriteAllText(GetPath(), json); } private string GetPath() diff --git a/Assets/Scripts/Core/PlayerCurrencyWallet.cs b/Assets/Scripts/Core/PlayerCurrencyWallet.cs index 5bd0af1..9086a93 100644 --- a/Assets/Scripts/Core/PlayerCurrencyWallet.cs +++ b/Assets/Scripts/Core/PlayerCurrencyWallet.cs @@ -27,7 +27,8 @@ private void Awake() public void AddGems(int amount) { - if (amount <= 0) + // data null 가드 — Gems/Coins 프로퍼티(line 18-19)와 비대칭, Awake 실패 시 NRE 차단. + if (amount <= 0 || data == null) { return; } @@ -38,7 +39,7 @@ public void AddGems(int amount) public bool SpendGems(int amount) { - if (amount <= 0 || data.gems < amount) + if (amount <= 0 || data == null || data.gems < amount) { return false; } @@ -50,7 +51,7 @@ public bool SpendGems(int amount) public void AddCoins(int amount) { - if (amount <= 0) + if (amount <= 0 || data == null) { return; } @@ -61,7 +62,7 @@ public void AddCoins(int amount) public bool SpendCoins(int amount) { - if (amount <= 0 || data.coins < amount) + if (amount <= 0 || data == null || data.coins < amount) { return false; } @@ -73,7 +74,9 @@ public bool SpendCoins(int amount) private void Save() { - File.WriteAllText(GetPath(), JsonUtility.ToJson(data, true)); + // data null 가드 — 빈 파일 쓰기 + 구독자에게 null 전파 차단. + if (data == null) return; + AtomicFileWriter.WriteAllText(GetPath(), JsonUtility.ToJson(data, true)); CurrencyChanged?.Invoke(data); } @@ -85,8 +88,16 @@ private PlayerCurrencyData Load() return new PlayerCurrencyData(); } - string json = File.ReadAllText(path); - return JsonUtility.FromJson(json) ?? new PlayerCurrencyData(); + try + { + string json = File.ReadAllText(path); + return JsonUtility.FromJson(json) ?? new PlayerCurrencyData(); + } + catch (System.Exception e) + { + Debug.LogWarning($"[PlayerCurrencyWallet] 손상된 세이브 — 기본값으로 시작: {e.Message}"); + return new PlayerCurrencyData(); + } } private string GetPath() diff --git a/Assets/Scripts/Core/PlayerInsectCollection.cs b/Assets/Scripts/Core/PlayerInsectCollection.cs index 76abd9d..69653e3 100644 --- a/Assets/Scripts/Core/PlayerInsectCollection.cs +++ b/Assets/Scripts/Core/PlayerInsectCollection.cs @@ -20,8 +20,44 @@ public class PlayerInsectCollection : MonoBehaviour private PlayerInsectCollectionSave saveData; private readonly Dictionary lookup = new Dictionary(); + // 디스크 IO 디바운스: 가챠 10연 등 연속 변경 시 매번 File.WriteAllText 안 하고 0.5초 후 1회 저장. + private bool saveDirty; + private float saveDebounceTimer; + private const float SaveDebounceSeconds = 0.5f; + public event Action InsectUpdated; + private void MarkDirty() + { + saveDirty = true; + saveDebounceTimer = 0f; + } + + private void Update() + { + if (!saveDirty) return; + saveDebounceTimer += Time.unscaledDeltaTime; + if (saveDebounceTimer >= SaveDebounceSeconds) + { + saveDebounceTimer = 0f; + saveDirty = false; + if (saveData != null) Save(saveData); + } + } + + private void OnDisable() + { + // 종료 직전 강제 flush — 디바운스 대기 중 변경 손실 방지 + if (saveDirty && saveData != null) + { + Save(saveData); + saveDirty = false; + } + } + + private void OnApplicationQuit() { OnDisable(); } + private void OnApplicationPause(bool pauseStatus) { if (pauseStatus) OnDisable(); } + private void Awake() { saveData = Load(); @@ -34,6 +70,15 @@ private void Awake() } data.EnsureInstanceId(); + + // 옛 세이브에서 instanceId 중복 발견 시 새 GUID 발급. + // 옛은 dictionary에 1개만 보존되어 BattleTeam.GetByInstanceId가 나머지 인스턴스 못 찾는 회귀. + if (lookup.ContainsKey(data.instanceId)) + { + data.instanceId = Guid.NewGuid().ToString("N"); + needsSave = true; + } + InsectData insect = GetInsectData(data.insectId); if (EnsureLevelSkills(data, insect)) { @@ -67,7 +112,7 @@ public PlayerInsectData AddCapturedInsect(string insectId, int level, bool isShi EnsureLevelSkills(data, insect); lookup[data.instanceId] = data; saveData.insects.Add(data); - Save(saveData); + MarkDirty(); InsectUpdated?.Invoke(data); return data; } @@ -146,7 +191,9 @@ public void GainXp(PlayerInsectData data, InsectData insect, int amount) return; } - InsectLevelCurve curve = insect.levelCurve != null ? insect.levelCurve : defaultCurve; + // insect null 가드 — 형제 GainXp(InsectData, int) 오버로드와 대칭. + // 외부에서 PlayerInsectData만 가진 채 insect=null로 호출하면 옛은 NRE. + InsectLevelCurve curve = insect != null && insect.levelCurve != null ? insect.levelCurve : defaultCurve; data.currentXp += amount; int maxLevel = curve != null ? curve.maxLevel : 50; while (data.level < maxLevel && curve != null && data.currentXp >= curve.GetXpToNextLevel(data.level)) @@ -157,7 +204,7 @@ public void GainXp(PlayerInsectData data, InsectData insect, int amount) EnsureLevelSkills(data, insect); - Save(saveData); + MarkDirty(); InsectUpdated?.Invoke(data); } @@ -227,7 +274,7 @@ public bool TryLevelUpWithCandy(PlayerInsectData data) data.level++; data.currentXp = 0; EnsureLevelSkills(data, insect); - Save(saveData); + MarkDirty(); InsectUpdated?.Invoke(data); return true; } @@ -251,7 +298,13 @@ public List GetAllOwned() return new List(); } - return new List(saveData.insects); + // 손상된 세이브에서 null 항목이 섞일 경우 호출자(BattleTeamUI/CollectionUI/DexScreenUI 등) NRE 방지. + var result = new List(saveData.insects.Count); + foreach (PlayerInsectData d in saveData.insects) + { + if (d != null) result.Add(d); + } + return result; } public string ResolveLegacyOrInstanceId(string id) @@ -291,7 +344,7 @@ public InsectSkill[] GetEquippedSkills(PlayerInsectData data) InsectData insect = GetInsectData(data.insectId); if (EnsureLevelSkills(data, insect)) { - Save(saveData); + MarkDirty(); } InsectSkill[] result = new InsectSkill[PlayerInsectData.MaxEquipSlots]; @@ -449,14 +502,22 @@ private PlayerInsectCollectionSave Load() return new PlayerInsectCollectionSave(); } - string json = System.IO.File.ReadAllText(path); - return JsonUtility.FromJson(json) ?? new PlayerInsectCollectionSave(); + try + { + string json = System.IO.File.ReadAllText(path); + return JsonUtility.FromJson(json) ?? new PlayerInsectCollectionSave(); + } + catch (System.Exception e) + { + Debug.LogWarning($"[PlayerInsectCollection] 손상된 세이브 — 기본값으로 시작: {e.Message}"); + return new PlayerInsectCollectionSave(); + } } private void Save(PlayerInsectCollectionSave data) { string json = JsonUtility.ToJson(data, true); - System.IO.File.WriteAllText(GetPath(), json); + AtomicFileWriter.WriteAllText(GetPath(), json); } private string GetPath() diff --git a/Assets/Scripts/Core/PlayerInsectLevelUpUIController.cs b/Assets/Scripts/Core/PlayerInsectLevelUpUIController.cs index 88af91f..5093eb3 100644 --- a/Assets/Scripts/Core/PlayerInsectLevelUpUIController.cs +++ b/Assets/Scripts/Core/PlayerInsectLevelUpUIController.cs @@ -22,6 +22,7 @@ public class PlayerInsectLevelUpUIController : MonoBehaviour private PlayerInsectData current; private string selectedInstanceId; + private bool subscribed; // OnEnable/AutoWire 이중 구독 차단 private void Start() { @@ -36,18 +37,26 @@ private void Start() private void OnEnable() { - if (collection != null) - { - collection.InsectUpdated += HandleInsectUpdated; - } + Subscribe(); } private void OnDisable() { - if (collection != null) - { - collection.InsectUpdated -= HandleInsectUpdated; - } + Unsubscribe(); + } + + private void Subscribe() + { + if (subscribed || collection == null) return; + collection.InsectUpdated += HandleInsectUpdated; + subscribed = true; + } + + private void Unsubscribe() + { + if (!subscribed || collection == null) return; + collection.InsectUpdated -= HandleInsectUpdated; + subscribed = false; } private void HandleInsectUpdated(PlayerInsectData data) @@ -72,6 +81,13 @@ public void Refresh() return; } + // selectedInstanceId가 stale(곤충 삭제/강화 후 변경)이면 GetByInstanceId가 null 반환 → NRE 차단. + if (current == null) + { + SetTexts("-", "-", "-"); + return; + } + InsectData insect = collection.GetInsectData(current.insectId); string name = insect != null ? $"{insect.displayName} #{GetShortInstanceId(current)}" @@ -103,6 +119,8 @@ public void LevelUpCurrent() } bool success = collection.TryLevelUpWithCandyByInstance(current.instanceId); + if (AudioManager.Instance != null) + AudioManager.Instance.PlaySFX(success ? SfxType.LevelUpGain : SfxType.Error); if (resultText != null) { resultText.text = success ? successMessage : failMessage; @@ -147,14 +165,11 @@ private void SetTexts(string name, string level, string cost) public void AutoWire(PlayerInsectCollection playerCollection) { - if (collection == null || collection != playerCollection) - { - if (collection != null) - collection.InsectUpdated -= HandleInsectUpdated; - collection = playerCollection; - if (collection != null) - collection.InsectUpdated += HandleInsectUpdated; - } + if (collection == playerCollection) return; + Unsubscribe(); + collection = playerCollection; + // 컴포넌트가 enable 상태일 때만 구독 — OnDisable 동안 구독 잔존 차단. + if (isActiveAndEnabled) Subscribe(); } private static string GetShortInstanceId(PlayerInsectData data) diff --git a/Assets/Scripts/Core/PlayerItemInventory.cs b/Assets/Scripts/Core/PlayerItemInventory.cs index 7f23eb8..a753354 100644 --- a/Assets/Scripts/Core/PlayerItemInventory.cs +++ b/Assets/Scripts/Core/PlayerItemInventory.cs @@ -28,13 +28,34 @@ public class PlayerItemInventory : MonoBehaviour private void Awake() { save = Load(); - foreach (PlayerItemRecord record in save.items) + + // 손상 세이브 sanitize — null record + 빈 itemId + 중복 itemId 처리. + // 옛은 (a) null record에서 NRE, (b) 중복 itemId 시 lookup은 마지막만 보존, save.items엔 둘 다 잔존 → + // AddItem 변경이 stale record로 동기화 깨짐. + bool dirty = false; + for (int i = save.items.Count - 1; i >= 0; i--) { - if (!string.IsNullOrEmpty(record.itemId)) + PlayerItemRecord record = save.items[i]; + if (record == null || string.IsNullOrEmpty(record.itemId)) + { + save.items.RemoveAt(i); + dirty = true; + continue; + } + + if (lookup.TryGetValue(record.itemId, out PlayerItemRecord existing)) + { + existing.count += record.count; + save.items.RemoveAt(i); + dirty = true; + } + else { lookup[record.itemId] = record; } } + + if (dirty) Save(save); } public void AddItem(string itemId, int amount) @@ -68,14 +89,18 @@ public bool UseItem(string itemId, int amount = 1) return false; } - int effective = Mathf.Max(record.count, 1); - if (effective < amount) + if (record.count < amount) { return false; } - record.count = effective - amount; - if (record.count < 1) record.count = 1; + record.count -= amount; + // 0 이하 잔량은 보유 목록에서 제거 (음수 잔존/강제 1 잔존 방지) + if (record.count <= 0) + { + lookup.Remove(itemId); + save.items.Remove(record); + } Save(save); ItemsChanged?.Invoke(save); return true; @@ -89,13 +114,16 @@ public int GetCount(string itemId) } if (lookup.TryGetValue(itemId, out PlayerItemRecord record)) - return Mathf.Max(record.count, 1); + return record.count > 0 ? record.count : 0; return 0; } public PlayerItemSave GetSnapshot() { - return save; + // 내부 참조 노출 시 호출자(DexScreenUI 등)가 save.items 변경하면 lookup과 동기화 깨짐. + // 얕은 복사 — items 리스트만 새로 (record 자체는 read-only 접근 의도). + if (save == null) return new PlayerItemSave(); + return new PlayerItemSave { items = new List(save.items) }; } private PlayerItemSave Load() @@ -106,14 +134,24 @@ private PlayerItemSave Load() return new PlayerItemSave(); } - string json = File.ReadAllText(path); - return JsonUtility.FromJson(json) ?? new PlayerItemSave(); + try + { + string json = File.ReadAllText(path); + return JsonUtility.FromJson(json) ?? new PlayerItemSave(); + } + catch (System.Exception e) + { + Debug.LogWarning($"[PlayerItemInventory] 손상된 세이브 — 기본값으로 시작: {e.Message}"); + return new PlayerItemSave(); + } } private void Save(PlayerItemSave data) { + // PlayerProgressSaveService/PlayerCandyInventory/PlayerCurrencyWallet과 일관성 — null 진입 시 빈 파일 차단. + if (data == null) return; string json = JsonUtility.ToJson(data, true); - File.WriteAllText(GetPath(), json); + AtomicFileWriter.WriteAllText(GetPath(), json); } private string GetPath() diff --git a/Assets/Scripts/Core/PlayerItemInventoryGridUIController.cs b/Assets/Scripts/Core/PlayerItemInventoryGridUIController.cs index 6f7532a..633b481 100644 --- a/Assets/Scripts/Core/PlayerItemInventoryGridUIController.cs +++ b/Assets/Scripts/Core/PlayerItemInventoryGridUIController.cs @@ -24,11 +24,13 @@ private void OnEnable() EnsureDatabase(); if (inventory != null) { + inventory.ItemsChanged -= HandleItemsChanged; inventory.ItemsChanged += HandleItemsChanged; } if (effectManager != null) { + effectManager.ActiveItemChanged -= HandleActiveChanged; effectManager.ActiveItemChanged += HandleActiveChanged; } @@ -50,9 +52,18 @@ private void OnDisable() } } + // 잔여시간 표시는 1초 단위라 매 프레임 갱신 불필요. 디바운스로 60fps→1fps 갱신. + // 만료 시점은 ItemEffectManager.ActiveItemChanged 이벤트가 즉시 처리하므로 무해. + private float remainingTextTimer; + private void Update() { - UpdateRemainingTime(); + remainingTextTimer += Time.unscaledDeltaTime; + if (remainingTextTimer >= 1f) + { + remainingTextTimer = 0f; + UpdateRemainingTime(); + } } private void HandleItemsChanged(PlayerItemSave save) @@ -62,12 +73,15 @@ private void HandleItemsChanged(PlayerItemSave save) private void HandleActiveChanged(ItemData item) { - if (activeItemText == null) + if (activeItemText != null) { - return; + activeItemText.text = item != null ? $"사용중: {item.displayName}" : "사용중: 없음"; } - activeItemText.text = item != null ? $"사용중: {item.displayName}" : "사용중: 없음"; + // 만료/시작 시점에 남은 시간 표시 즉시 갱신 — 옛은 Update 1초 디바운스 대기로 "남은 시간: 00:00" 표시 지연. + // 주석(line 56)이 "만료 시점은 ActiveItemChanged 즉시 처리" 명시했으나 실제 호출 누락 회귀. + UpdateRemainingTime(); + remainingTextTimer = 0f; } private void UpdateRemainingTime() diff --git a/Assets/Scripts/Core/PlayerMovement.cs b/Assets/Scripts/Core/PlayerMovement.cs index 0fc70d3..442d51f 100644 --- a/Assets/Scripts/Core/PlayerMovement.cs +++ b/Assets/Scripts/Core/PlayerMovement.cs @@ -1,3 +1,4 @@ +using InsectGame.Spawning; using UnityEngine; using UnityEngine.EventSystems; @@ -27,6 +28,27 @@ public class PlayerMovement : MonoBehaviour private bool guiEscPressed; private bool guiClickRequest; private Vector2 guiClickPos; + private bool guiUnstickPressed; // OnGUI Event 경로 F9 백업 + + // OnGUI 매 프레임 new GUIStyle 회귀 차단 — DexScreenUI/BattleScreenUI 패턴. + private GUIStyle hintStyle, frozenStyle, blockStyle; + private bool stylesInited; + private static readonly Color HintBgCol = new Color(0f, 0f, 0f, 0.7f); + private static readonly Color FrozenTextCol = new Color(1f, 1f, 0.5f, 0.7f); + + private float walkAnimTimer; + private bool isWalking; + // AnimateWalk가 매 Update 프레임 호출되어 transform.Find 6번 발생. lazy 캐싱으로 차단. + // LegPivot은 Leg+Boot 모두 자식으로 묶은 빈 컨테이너(PlayerVisualBuilder). Pivot 회전 시 + // Leg/Boot 둘 다 자동 전파 → 옛 cachedLegL/R 직접 회전(발 동기 누락) 회귀 차단. + private Transform cachedArmL, cachedArmR, cachedLegPivotL, cachedLegPivotR, cachedBody, cachedHeadPivot; + // 도구(NetHandle/NetRing)는 오른팔과 동기 회전 — 손과 분리되어 공중에 떠 있는 인상 차단. + private Transform cachedNetHandle, cachedNetRing; + private Vector3 netHandleBasePos, netRingBasePos; + private Quaternion netHandleBaseRot, netRingBaseRot; + private bool toolBaseCached; + private float footstepTimer; + private float bodyBaseY = float.NaN; public bool IsFrozen => frozen; @@ -36,11 +58,60 @@ public void SetFrozen(bool value) if (value) frozenTimer = 0f; } + private bool outfitSubscribed; + + private void OnEnable() + { + TrySubscribeOutfit(); + } + + private void Start() + { + TrySubscribeOutfit(); + } + + private void OnDisable() + { + if (outfitSubscribed && CharacterOutfitManager.Instance != null) + { + CharacterOutfitManager.Instance.OutfitChanged -= InvalidateToolBase; + } + outfitSubscribed = false; + } + + private void TrySubscribeOutfit() + { + if (outfitSubscribed) return; + CharacterOutfitManager mgr = CharacterOutfitManager.Instance; + if (mgr == null) return; + mgr.OutfitChanged += InvalidateToolBase; + outfitSubscribed = true; + } + + // 도구가 OutfitChanged로 좌표 재배치되면 다음 AnimateWalk가 새 base를 캐싱하도록 무효화. + // walking 중에도 즉시 base 무효 — 이전엔 walking=false 1프레임까지 기다려 옛 base × swing 곱셈 + // 으로 도구가 잘못 회전됨. + private void InvalidateToolBase() + { + toolBaseCached = false; + } + private void Update() { if (EventSystem.current != null && EventSystem.current.currentSelectedGameObject != null) EventSystem.current.SetSelectedGameObject(null); + // F9: 끼임 escape — frozen 상태/모달 무관 항상 우선 처리. + // OnGUI Event 경로(guiUnstickPressed)도 백업 (Unity Editor에서 F11 OS 인터셉트 우회). + // 옛 F11은 Windows/Editor 풀스크린 토글로 잡혀 안 됐다는 사용자 보고 → F9로 변경. + if (Input.GetKeyDown(KeyCode.F9) || guiUnstickPressed) + { + guiUnstickPressed = false; + UnstickToSafePosition(); + if (frozen) frozen = false; // frozen 상태도 자동 해제 (끼임은 frozen 동반 가능) + return; + } + if (frozen) { frozenTimer += Time.unscaledDeltaTime; @@ -51,13 +122,29 @@ private void Update() if (Input.GetKeyDown(KeyCode.Escape) || guiEscPressed) { - frozen = false; guiEscPressed = false; + // frozen + 모달 동시 상태: 모달 먼저 닫기 (모달 OnDisable이 SetFrozen(false) 호출). + // 옛은 frozen만 해제 → 모달 UI는 화면에 남고 PlayerMovement만 움직여 게임 깨짐. + if (InsectGame.UI.ModalUIRegistry.HandleEscape()) + { + return; + } + frozen = false; } return; } + // ESC: 활성 모달 우선 닫기 (한 번에 최상위 한 개만), 모달 없으면 기본 동작 (frozen 해제 등) + if (Input.GetKeyDown(KeyCode.Escape) || guiEscPressed) + { + guiEscPressed = false; + if (InsectGame.UI.ModalUIRegistry.HandleEscape()) + { + return; // 모달 닫음 — 이번 프레임 이동 처리 스킵 + } + } + float h = 0f; float v = 0f; @@ -69,40 +156,52 @@ private void Update() bool hasKeyboard = h != 0f || v != 0f; if (hasKeyboard) hasReceivedInput = true; - bool wantClick = Input.GetMouseButtonDown(0) || guiClickRequest; + // 옛: line 145에서 guiClickRequest=false 즉시 reset → line 149 ternary 항상 false. + // guiClickPos 분기가 dead branch였음. 로컬에 보존 후 reset. + bool useGuiClick = guiClickRequest; guiClickRequest = false; + bool wantClick = Input.GetMouseButtonDown(0) || useGuiClick; if (wantClick && Camera.main != null) { - Vector2 mousePos = guiClickRequest ? guiClickPos : (Vector2)Input.mousePosition; + Vector2 mousePos = useGuiClick ? guiClickPos : (Vector2)Input.mousePosition; bool pointerOverUI = EventSystem.current != null && EventSystem.current.IsPointerOverGameObject(); - if (!pointerOverUI) + // OnGUI 모달 열려있으면 마우스 클릭 흡수 (캐릭터 이동 차단) + bool anyModalOpen = InsectGame.UI.ModalUIRegistry.IsAnyOpen(); + if (!pointerOverUI && !anyModalOpen) { Ray ray = Camera.main.ScreenPointToRay(mousePos); int playerLayer = gameObject.layer; - gameObject.layer = 2; Collider[] childColliders = GetComponentsInChildren(); int[] childLayers = new int[childColliders.Length]; - for (int i = 0; i < childColliders.Length; i++) + // try-finally: Physics.Raycast 예외 시 player layer 복원 누락으로 본인 raycast 통과 회귀 차단. + try { - childLayers[i] = childColliders[i].gameObject.layer; - childColliders[i].gameObject.layer = 2; + gameObject.layer = 2; + for (int i = 0; i < childColliders.Length; i++) + { + childLayers[i] = childColliders[i].gameObject.layer; + childColliders[i].gameObject.layer = 2; + } + + if (Physics.Raycast(ray, out RaycastHit clickHit, 200f)) + { + clickTarget = clickHit.point; + clickTarget.y = transform.position.y; + movingToClick = true; + hasReceivedInput = true; + } } - - if (Physics.Raycast(ray, out RaycastHit clickHit, 200f)) + finally { - clickTarget = clickHit.point; - clickTarget.y = transform.position.y; - movingToClick = true; - hasReceivedInput = true; - } - - gameObject.layer = playerLayer; - for (int i = 0; i < childColliders.Length; i++) - { - childColliders[i].gameObject.layer = childLayers[i]; + gameObject.layer = playerLayer; + for (int i = 0; i < childColliders.Length; i++) + { + if (childColliders[i] != null) + childColliders[i].gameObject.layer = childLayers[i]; + } } } } @@ -162,7 +261,8 @@ private void Update() move.y = verticalVelocity * Time.deltaTime; Vector3 nextPos = transform.position + move; - if (regionManager != null && IsBlockedPosition(nextPos)) + bool hasMovement = move.x != 0f || move.z != 0f; + if (hasMovement && regionManager != null && IsBlockedPosition(nextPos)) { move.x = 0f; move.z = 0f; @@ -171,6 +271,27 @@ private void Update() transform.position += move; + // 걷기 애니메이션 + bool walking = direction.sqrMagnitude > 0.01f; + if (walking) + { + walkAnimTimer += Time.deltaTime * 8f; + isWalking = true; + footstepTimer += Time.deltaTime; + if (footstepTimer >= 0.4f) + { + footstepTimer = 0f; + if (AudioManager.Instance != null) AudioManager.Instance.PlaySFX(SfxType.Footstep); + } + } + else if (isWalking) + { + walkAnimTimer = 0f; + isWalking = false; + footstepTimer = 0f; + } + AnimateWalk(walking); + if (blockedMsgTimer > 0f) blockedMsgTimer -= Time.deltaTime; } @@ -193,6 +314,7 @@ private void OnGUI() case KeyCode.LeftArrow: guiKeyLeft = true; break; case KeyCode.RightArrow: guiKeyRight = true; break; case KeyCode.Escape: guiEscPressed = true; break; + case KeyCode.F9: guiUnstickPressed = true; break; } } else if (e.type == EventType.KeyUp) @@ -211,34 +333,22 @@ private void OnGUI() } } + InitStyles(); + if (!hasReceivedInput && !frozen) { - GUIStyle bigStyle = new GUIStyle(GUI.skin.label) - { - fontSize = 32, - fontStyle = FontStyle.Bold, - alignment = TextAnchor.MiddleCenter - }; - bigStyle.normal.textColor = Color.white; - float w = 500f; float h = 80f; Rect rect = new Rect((Screen.width - w) / 2f, Screen.height * 0.4f, w, h); - GUI.color = new Color(0, 0, 0, 0.7f); + GUI.color = HintBgCol; GUI.DrawTexture(rect, Texture2D.whiteTexture); GUI.color = Color.white; - GUI.Label(rect, "WASD 또는 마우스 클릭으로 이동하세요", bigStyle); + GUI.Label(rect, "WASD 또는 마우스 클릭으로 이동하세요", hintStyle); } if (frozen) { - GUIStyle frozenStyle = new GUIStyle(GUI.skin.label) - { - fontSize = 24, - alignment = TextAnchor.MiddleCenter - }; - frozenStyle.normal.textColor = new Color(1f, 1f, 0.5f, 0.7f); GUI.Label(new Rect(0, Screen.height - 50, Screen.width, 30), "ESC를 누르면 이동 잠금을 해제합니다", frozenStyle); } @@ -246,34 +356,200 @@ private void OnGUI() if (blockedMsgTimer > 0f) { float alpha = Mathf.Clamp01(blockedMsgTimer / 0.5f); - GUIStyle blockStyle = new GUIStyle(GUI.skin.label) - { - fontSize = 22, - fontStyle = FontStyle.Bold, - alignment = TextAnchor.MiddleCenter - }; + // alpha 동적이라 textColor 매 프레임 갱신 (BattleScreenUI 패턴) — struct stack 할당. blockStyle.normal.textColor = new Color(1f, 0.4f, 0.3f, alpha); float bw = 400f; Rect bRect = new Rect((Screen.width - bw) / 2f, Screen.height * 0.6f, bw, 30); GUI.Label(bRect, $"{blockedRegionName} 진입에 레벨이 부족합니다!", blockStyle); } + + } + + private void InitStyles() + { + if (stylesInited) return; + hintStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 32, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.MiddleCenter + }; + hintStyle.normal.textColor = Color.white; + + frozenStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 24, + alignment = TextAnchor.MiddleCenter + }; + frozenStyle.normal.textColor = FrozenTextCol; + + blockStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 22, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.MiddleCenter + }; + stylesInited = true; + } + + private void AnimateWalk(bool walking) + { + float swing = walking ? Mathf.Sin(walkAnimTimer) : 0f; + float swingDeg = swing * 25f; + float bobY = walking ? Mathf.Abs(Mathf.Sin(walkAnimTimer * 2f)) * 0.06f : 0f; + + // 팔 흔들기 (좌우 반대) — PlayerVisualBuilder 노드 안정적이므로 lazy 캐싱 + // Z 회전 6°는 PlayerVisualBuilder의 ArmL/R 초기 각도와 동기 (14° V자 어색함 차단). + if (cachedArmL == null) cachedArmL = transform.Find("ArmL"); + if (cachedArmR == null) cachedArmR = transform.Find("ArmR"); + // Z=0 수직 자세 — 옛 ±6° V자 회전이 사용자 보고 "어깨 언밸런스" 원인. swing은 X축만. + if (cachedArmL != null) cachedArmL.localRotation = Quaternion.Euler(swingDeg, 0f, 0f); + if (cachedArmR != null) cachedArmR.localRotation = Quaternion.Euler(-swingDeg, 0f, 0f); + + // 도구 = 오른팔과 동일 X swing. walking=false면 swing=0이라 ApplyToolShape이 갓 설정한 + // 좌표가 그대로 base로 캐싱됨 → 도구 변경 후에도 멈춤 1프레임에 자동 재동기. + if (cachedNetHandle == null) cachedNetHandle = transform.Find("NetHandle"); + if (cachedNetRing == null) cachedNetRing = transform.Find("NetRing"); + if (!walking && cachedNetHandle != null && cachedNetRing != null) + { + netHandleBaseRot = cachedNetHandle.localRotation; + netRingBaseRot = cachedNetRing.localRotation; + toolBaseCached = true; + } + if (toolBaseCached) + { + if (cachedNetHandle != null) + cachedNetHandle.localRotation = Quaternion.Euler(-swingDeg, 0f, 0f) * netHandleBaseRot; + if (cachedNetRing != null) + cachedNetRing.localRotation = Quaternion.Euler(-swingDeg, 0f, 0f) * netRingBaseRot; + } + + // 다리 흔들기 (팔과 반대) — LegPivot 회전 시 Leg+Boot 모두 함께 전파. + // 옛 LegL/R 직접 회전은 Boot이 Player 직접 자식이라 발 동기 누락 회귀 (사용자 보고). + if (cachedLegPivotL == null) cachedLegPivotL = transform.Find("LegLPivot"); + if (cachedLegPivotR == null) cachedLegPivotR = transform.Find("LegRPivot"); + if (cachedLegPivotL != null) cachedLegPivotL.localRotation = Quaternion.Euler(-swingDeg * 0.8f, 0f, 0f); + if (cachedLegPivotR != null) cachedLegPivotR.localRotation = Quaternion.Euler(swingDeg * 0.8f, 0f, 0f); + + // 몸통 미세 상하 바운스 (BuildPlayerVisual에서 정한 초기 Y를 baseline으로 사용) + if (cachedBody == null) cachedBody = transform.Find("Body"); + if (cachedBody != null) + { + Vector3 bp = cachedBody.localPosition; + if (float.IsNaN(bodyBaseY)) bodyBaseY = bp.y; + bp.y = bodyBaseY + bobY; + cachedBody.localPosition = bp; + } + + // 머리 약간 흔들림 + if (cachedHeadPivot == null) cachedHeadPivot = transform.Find("HeadPivot"); + if (cachedHeadPivot != null) + { + float headTilt = walking ? Mathf.Sin(walkAnimTimer * 0.5f) * 3f : 0f; + cachedHeadPivot.localRotation = Quaternion.Euler(0f, headTilt, 0f); + } } private bool IsBlockedPosition(Vector3 pos) { if (regionManager == null || regionManager.Regions == null) return false; - foreach (var r in regionManager.Regions) + + // SubArea 안: region 가드는 무의미(다른 좌표계). collider 가드는 유지(벽 차단을 위해) + bool inSubArea = regionManager.CurrentSubArea != null; + if (!inSubArea) { - if (r.ContainsPoint(pos) && !regionManager.IsRegionAccessible(r)) + foreach (var r in regionManager.Regions) { - blockedRegionName = r.displayName; - blockedMsgTimer = 2f; - return true; + if (r.ContainsPoint(pos) && !regionManager.IsRegionAccessible(r)) + { + blockedRegionName = r.displayName; + blockedMsgTimer = 2f; + return true; + } } } + + // 지형 장애물 체크: 본인 CapsuleCollider 직접 참조로 확실히 제외. + // (옛 IsChildOf만으로는 새 CapsuleCollider center 변경 이후 충돌 회귀가 발생) + Collider[] hits = Physics.OverlapSphere(pos + Vector3.up * 1.4f, 0.4f); + foreach (var h in hits) + { + if (h == null || h.isTrigger) continue; + if (h.gameObject == gameObject) continue; + if (h.transform.IsChildOf(transform)) continue; + if (h.attachedRigidbody != null && h.attachedRigidbody.gameObject == gameObject) continue; + + GameObject go = h.gameObject; + + // 곤충 엔티티는 부모 체인 어디에 있어도 통과 (BuildForBattle 구조 모델은 손자 단위 가능) + if (go.GetComponentInParent() != null) continue; + + // Guardian 정적 장식(플랫폼/기둥/사인/오라)은 이름으로 통과 — 시각용일 뿐 차단 의도 없음 + if (go.name.StartsWith("Guardian_")) continue; + + if (h.bounds.size.y < 0.25f) continue; + + return true; + } + return false; } + // F11 unstick — 끼임 escape. SubArea 안이면 SubAreaWorldBuilder의 FindSafeSpawnPosition과 + // 동일 패턴으로 벽 없는 입구 좌표 탐색. 메인 월드면 (0, 0.1, 0) 초기 스폰 위치. + private void UnstickToSafePosition() + { + bool inSubArea = regionManager != null && regionManager.CurrentSubArea != null; + Vector3 target; + if (inSubArea) + { + // SubArea 안 — Origin 부근 spiral 탐색 + Vector3 origin = new Vector3(2000f, 0f, 2000f); + target = FindClearSpot(origin + new Vector3(0f, 0.5f, -8f), origin); + } + else + { + // 메인 월드 초기 스폰 위치 + target = FindClearSpot(new Vector3(0f, 0.1f, 0f), Vector3.zero); + } + transform.position = target; + verticalVelocity = 0f; + movingToClick = false; + } + + private Vector3 FindClearSpot(Vector3 preferred, Vector3 fallbackCenter) + { + if (IsClearAt(preferred)) return preferred; + float[] radii = { 3f, 5f, 7f }; + for (int r = 0; r < radii.Length; r++) + { + for (int dir = 0; dir < 8; dir++) + { + float angle = dir * 45f * Mathf.Deg2Rad; + Vector3 candidate = preferred + new Vector3( + Mathf.Sin(angle) * radii[r], 0f, Mathf.Cos(angle) * radii[r]); + if (IsClearAt(candidate)) return candidate; + } + } + return fallbackCenter + new Vector3(0f, 0.5f, 0f); + } + + private bool IsClearAt(Vector3 pos) + { + Collider[] hits = Physics.OverlapSphere(pos + Vector3.up * 1.0f, 0.5f); + for (int i = 0; i < hits.Length; i++) + { + Collider h = hits[i]; + if (h == null || h.isTrigger) continue; + if (h.gameObject == gameObject) continue; + if (h.transform.IsChildOf(transform)) continue; + if (h.attachedRigidbody != null && h.attachedRigidbody.gameObject == gameObject) continue; + if (h.bounds.size.y < 0.5f) continue; + return false; + } + return true; + } + public void AutoWire(RegionManager rm) { if (regionManager == null) regionManager = rm; diff --git a/Assets/Scripts/Core/PlayerProgressController.cs b/Assets/Scripts/Core/PlayerProgressController.cs index 8fdbcdc..3a305e9 100644 --- a/Assets/Scripts/Core/PlayerProgressController.cs +++ b/Assets/Scripts/Core/PlayerProgressController.cs @@ -20,6 +20,14 @@ public class PlayerProgressController : MonoBehaviour private void Awake() { data = PlayerProgressSaveService.Load(); + + // 손상 세이브 sanitize — UI/배틀 시스템에 음수/0 level이나 음수 XP 진입 차단. + // 옛은 외부 JSON 직접 편집 또는 손상 파일 → GetXpToNextLevel 음수, UI Lv 0 표시 등 회귀. + bool dirty = false; + if (data.level < 1) { data.level = 1; dirty = true; } + if (data.level > maxLevel) { data.level = maxLevel; data.currentXp = 0; dirty = true; } + if (data.currentXp < 0) { data.currentXp = 0; dirty = true; } + if (dirty) PlayerProgressSaveService.Save(data); } public void GainXp(int amount) @@ -29,6 +37,20 @@ public void GainXp(int amount) return; } + // 만렙 도달 후 GainXp 호출은 잉여 XP 누적 차단 — UI 표시 일관성. + // 옛은 currentXp 무한 누적, 디스크 쓰기/SaveCloud 무의미하게 발생. + if (data.level >= maxLevel) + { + if (data.currentXp != 0) + { + data.currentXp = 0; + PlayerProgressSaveService.Save(data); + ProgressChanged?.Invoke(data); + } + return; + } + + int levelBefore = data.level; data.currentXp += amount; while (data.level < maxLevel && data.currentXp >= GetXpToNextLevel(data.level)) { @@ -36,8 +58,19 @@ public void GainXp(int amount) data.level++; } + // 만렙 도달 시 잉여 XP 클램프 — Lv max / XP 0 일관성. + if (data.level >= maxLevel) + { + data.currentXp = 0; + } + PlayerProgressSaveService.Save(data); ProgressChanged?.Invoke(data); + + // 레벨업은 중요한 진행이라 자동저장 120초 대기 안 하고 즉시 클라우드 동기화 + // (앱 강제 종료 시 레벨업 손실 방지) + if (data.level > levelBefore && CloudSaveManager.Instance != null) + CloudSaveManager.Instance.SaveToCloud(); } private int GetXpToNextLevel(int level) diff --git a/Assets/Scripts/Core/PlayerProgressSaveService.cs b/Assets/Scripts/Core/PlayerProgressSaveService.cs index a49051d..a00b5dc 100644 --- a/Assets/Scripts/Core/PlayerProgressSaveService.cs +++ b/Assets/Scripts/Core/PlayerProgressSaveService.cs @@ -15,14 +15,30 @@ public static PlayerProgressData Load() return new PlayerProgressData(); } - string json = File.ReadAllText(path); - return JsonUtility.FromJson(json) ?? new PlayerProgressData(); + try + { + string json = File.ReadAllText(path); + return JsonUtility.FromJson(json) ?? new PlayerProgressData(); + } + catch (System.Exception e) + { + Debug.LogWarning($"[PlayerProgressSaveService] 손상된 세이브 — 기본값으로 시작: {e.Message}"); + return new PlayerProgressData(); + } } public static void Save(PlayerProgressData data) { + // null 거부 — static service는 임의 호출자가 진입 가능. + // 옛은 JsonUtility.ToJson(null)이 빈 문자열 → WriteAllText로 빈 파일 → 다음 Load에서 데이터 손실. + if (data == null) + { + Debug.LogWarning("[PlayerProgressSaveService] Save(null) 호출 무시 — 빈 파일 쓰기 차단"); + return; + } + string json = JsonUtility.ToJson(data, true); - File.WriteAllText(GetPath(), json); + AtomicFileWriter.WriteAllText(GetPath(), json); } private static string GetPath() diff --git a/Assets/Scripts/Core/PlayerVisualBuilder.cs b/Assets/Scripts/Core/PlayerVisualBuilder.cs new file mode 100644 index 0000000..dc0fe10 --- /dev/null +++ b/Assets/Scripts/Core/PlayerVisualBuilder.cs @@ -0,0 +1,913 @@ +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 필드 3D 플레이어 캐릭터의 프로시저럴 생성/외형 갱신 담당. + /// - 6.4등신 슬림 비례 (CharacterPortraitRenderer와 일관) + /// - CharacterOutfitManager.OutfitChanged 구독 → 머티리얼 색만 갱신 (재생성 X) + /// - 8개 슬롯 시각 매핑 + Accessory 시각 노드 추가 + /// 노드 이름은 PlayerMovement.cs(transform.Find)와 CharacterOutfitManager.ApplyToCharacter()가 + /// 의존하므로 변경하지 말 것: Body/Shirt/ArmL/ArmR/HandL/HandR/LegL/LegR/BootL/BootR/ + /// Backpack/NetHandle/NetRing/Cap/CapBrim/HeadPivot. + /// + public class PlayerVisualBuilder : MonoBehaviour + { + // ── 슬림 비례 상수 (포트레이트 6.4등신 ↔ 3D 매핑) ── + // CharacterPortraitRenderer: headW=24/headH=30, bodyW=42(38)/bodyH=62, legW=13/legH=95. + // 3D 변환: 캡슐 본래 폭 1m → 0.5×0.5면 정사이즈, 옛 0.78×0.66은 과체중. + + // ── 슬롯별 머티리얼 캐시 (RefreshOutfitColors가 사용) ── + private Material hatMat; // Cap + CapBrim + private Material topMat; // Shirt + private Material bottomMat; // LegL + LegR + private Material outerwearMat; // Body + ArmL + ArmR (자켓 외피) + private Material shoesMat; // BootL + BootR + private Material backpackMat; // Backpack 본체 + private Material backpackStrapMat; // Backpack 어깨끈 (어둡게) + private Material toolMat; // NetHandle + private Material toolRingMat; // NetRing + private Material accessoryMat; // Accessory 노드들 + private Material skinMat; // Neck/Head/Hand/Ear 등 피부 노출 부위 + private Material hairMat; // Hair (PlayerPrefs HairColor) + + // ── 노드 참조 (SetActive on/off에 사용) ── + private GameObject hatRoot; + private GameObject shirtRoot; + private GameObject outerwearArmL; + private GameObject outerwearArmR; + private GameObject backpackRoot; + private GameObject backpackStrap; + private GameObject toolHandle; + private GameObject toolRing; + private GameObject accessoryRoot; // 부모 컨테이너 + private GameObject accGlassesL; + private GameObject accGlassesR; + private GameObject accNecklace; + private GameObject accBadge; // 가슴팍 기본 큐브 + + private bool subscribedToOutfit; + + private void Awake() + { + BuildAll(); + } + + private void OnEnable() + { + TrySubscribeOutfitChanged(); + } + + private void Start() + { + // OutfitManager가 Awake 순서상 늦게 살아있을 수 있어 Start에서도 1회 시도 + TrySubscribeOutfitChanged(); + // 초기 외형 동기화 (저장된 장착 반영) + RefreshOutfitColors(); + } + + private void OnDisable() + { + if (subscribedToOutfit && CharacterOutfitManager.Instance != null) + { + CharacterOutfitManager.Instance.OutfitChanged -= RefreshOutfitColors; + } + subscribedToOutfit = false; + } + + private void OnDestroy() + { + // Unity의 MeshRenderer.material = X (setter)는 인스턴스화 없이 sharedMaterial 직접 할당. + // 즉 Body.sharedMaterial == outerwearMat (동일 객체). ApplyToCharacter가 renderer.material + // (getter)로 인스턴스화하기 전까지는 캐시가 그대로 sharedMaterial. + // 만약 ApplyToCharacter가 호출 안 된 상태에서 캐시를 Destroy하면 sharedMaterial이 파괴된 + // 머티리얼을 가리켜 캐릭터가 검은색/분홍색으로 렌더링됨 (회귀 보고됨). + // → 안전한 destroy: 자식 노드 중 이 머티리얼을 sharedMaterial로 쓰는 게 있으면 스킵. + // (player가 영구 객체라 누수 영향 실질적 0) + SafeDestroyMat(ref hatMat); + SafeDestroyMat(ref topMat); + SafeDestroyMat(ref bottomMat); + SafeDestroyMat(ref outerwearMat); + SafeDestroyMat(ref shoesMat); + SafeDestroyMat(ref backpackMat); + SafeDestroyMat(ref backpackStrapMat); + SafeDestroyMat(ref toolMat); + SafeDestroyMat(ref toolRingMat); + SafeDestroyMat(ref accessoryMat); + SafeDestroyMat(ref skinMat); + SafeDestroyMat(ref hairMat); + } + + private void SafeDestroyMat(ref Material m) + { + if (m == null) return; + // sharedMaterial로 아직 사용 중인지 확인 — 사용 중이면 Unity가 자체 cleanup + var renderers = GetComponentsInChildren(true); + for (int i = 0; i < renderers.Length; i++) + { + if (renderers[i] != null && renderers[i].sharedMaterial == m) + { + m = null; + return; + } + } + Destroy(m); + m = null; + } + + private void TrySubscribeOutfitChanged() + { + if (subscribedToOutfit) return; + CharacterOutfitManager mgr = CharacterOutfitManager.Instance; + if (mgr == null) return; + mgr.OutfitChanged += RefreshOutfitColors; + subscribedToOutfit = true; + } + + private static bool shaderDiagLogged; + private static Material MakeMaterial(Color color) + { + // Unity 6 + Built-in Pipeline 환경 가정. Standard 못 찾으면 URP/Unlit 순으로 fallback. + // 최종 fallback도 실패하면 캐릭터가 검정/마젠타 → 진단 로그로 알림. + Shader shader = Shader.Find("Standard"); + string usedName = "Standard"; + if (shader == null) { shader = Shader.Find("Universal Render Pipeline/Lit"); usedName = "URP/Lit"; } + if (shader == null) { shader = Shader.Find("Unlit/Color"); usedName = "Unlit/Color"; } + if (shader == null) { shader = Shader.Find("Sprites/Default"); usedName = "Sprites/Default"; } + if (shader == null) + { + if (!shaderDiagLogged) + { + Debug.LogError( + "[PlayerVisualBuilder] 모든 fallback shader를 찾을 수 없습니다 — 캐릭터가 검은색/마젠타로 렌더링됩니다. " + + "ProjectSettings → Graphics에서 Standard/URP shader가 Always Included Shaders에 포함되어 있는지 확인하세요."); + shaderDiagLogged = true; + } + shader = Shader.Find("Hidden/InternalErrorShader"); + } + else if (!shaderDiagLogged && usedName != "Standard") + { + Debug.LogWarning($"[PlayerVisualBuilder] Standard shader 미발견, fallback={usedName} 사용. " + + "mat.color가 적용 안 되는 shader면 캐릭터가 검은색으로 보일 수 있음."); + shaderDiagLogged = true; + } + Material mat = new Material(shader); + mat.color = color; + // URP 환경 호환: _BaseColor property도 함께 설정 (Standard는 무시) + if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color); + return mat; + } + + private void BuildAll() + { + // ── 진단 로그 (검은 캐릭터 보고 시 추적용) ── + Debug.Log("[PlayerVisualBuilder] BuildAll 시작 — player에 머티리얼 + 자식 노드 생성"); + + // ── 기본 색상 (의상 미장착 시) ── + outerwearMat = MakeMaterial(new Color(0.2f, 0.4f, 0.85f)); // 자켓 + topMat = MakeMaterial(new Color(0.98f, 0.96f, 0.92f)); // 셔츠 + bottomMat = MakeMaterial(new Color(0.18f, 0.22f, 0.28f)); // 바지 + skinMat = MakeMaterial(new Color(0.92f, 0.78f, 0.62f)); // 클래스 필드로 승격 (OnDestroy 정리) + hatMat = MakeMaterial(new Color(1.0f, 0.65f, 0.2f)); // 모자 + shoesMat = MakeMaterial(new Color(0.2f, 0.12f, 0.06f)); // 부츠 + backpackMat = MakeMaterial(new Color(1.0f, 0.65f, 0.2f)); // 배낭 + backpackStrapMat = MakeMaterial(new Color(0.7f, 0.45f, 0.14f)); + toolMat = MakeMaterial(new Color(0.6f, 0.4f, 0.2f)); // 잠자리채 손잡이 + toolRingMat = MakeMaterial(new Color(0.95f, 0.92f, 0.88f)); // 잠자리채 망 + accessoryMat = MakeMaterial(new Color(0.1f, 0.1f, 0.1f)); // 악세서리 + + int gender = PlayerPrefs.GetInt("InsectGame.Character.Gender", 0); + + // 미리보기(CharacterPortraitRenderer)는 직사각형 몸통 (bodyW=42, bodyH=62, X:Y≈0.68). + // 옛 Capsule은 위아래 반구로 통처럼 둥글어 미리보기 대비 뚱뚱하게 보임 → Cube로 변경. + // Cube scale: 어깨 너비 ≈ 0.45m, 높이 ≈ 0.7m, 깊이 ≈ 0.28m (슬림 사람 몸통). + // 귀여운 치비(~3.5등신): 머리 크게(headPivotScale 0.42→0.60), 몸통 짧고 통통, + // 팔다리 짧게, 목 최소화. 머리 확대는 headPivotScale로 — 얼굴 부품(눈/코/입/모자)이 + // headPivot 자식이라 함께 스케일되어 배치가 자동 유지됨(재배치 불필요). + float bodyScaleX = gender == 1 ? 0.44f : 0.48f; + float bodyScaleZ = gender == 1 ? 0.34f : 0.38f; + float headScale = gender == 1 ? 0.74f : 0.72f; + float legScale = gender == 1 ? 0.19f : 0.20f; + const float headPivotScale = 0.60f; + + Transform t = transform; + + // ── 몸통 (자켓 외피) — Cube로 변경: 미리보기 직사각형 비례와 정합. ── + // Y 0.7 → 0.78 (조금 길게). 위치 1.40 유지 (Y range 1.01~1.79). + GameObject body = GameObject.CreatePrimitive(PrimitiveType.Cube); + body.name = "Body"; + body.transform.SetParent(t, false); + body.transform.localPosition = new Vector3(0f, 0.77f, 0f); + body.transform.localScale = new Vector3(bodyScaleX, 0.46f, bodyScaleZ); + body.GetComponent().material = outerwearMat; + Object.Destroy(body.GetComponent()); + + // ── 셔츠 (Top) — Body 안쪽 면적으로 살짝 작게, 자켓 열린 사이로 보이는 영역 ── + shirtRoot = GameObject.CreatePrimitive(PrimitiveType.Cube); + shirtRoot.name = "Shirt"; + shirtRoot.transform.SetParent(t, false); + shirtRoot.transform.localPosition = new Vector3(0f, 0.83f, 0.15f); + shirtRoot.transform.localScale = new Vector3(0.34f, 0.40f, 0.20f); + shirtRoot.GetComponent().material = topMat; + Object.Destroy(shirtRoot.GetComponent()); + + // ── 목 ── + GameObject neck = GameObject.CreatePrimitive(PrimitiveType.Cylinder); + neck.name = "Neck"; + neck.transform.SetParent(t, false); + neck.transform.localPosition = new Vector3(0f, 1.00f, 0.02f); + neck.transform.localScale = new Vector3(0.14f, 0.05f, 0.12f); + neck.GetComponent().material = skinMat; + Object.Destroy(neck.GetComponent()); + + // ── 머리 ── + GameObject headPivot = new GameObject("HeadPivot"); + headPivot.transform.SetParent(t, false); + headPivot.transform.localPosition = new Vector3(0f, 1.22f, 0.03f); + headPivot.transform.localScale = Vector3.one * headPivotScale; + + GameObject head = GameObject.CreatePrimitive(PrimitiveType.Sphere); + head.name = "Head"; + head.transform.SetParent(headPivot.transform, false); + head.transform.localPosition = Vector3.zero; + // 치비 둥근 머리: 옛 (−0.10, +0.12, −0.04) 달걀형(세로로 긺) → X/Y를 거의 균등하게. + // Z는 −0.04 유지 — 얼굴 부품 z위치(0.32 등)가 머리 앞면에 그대로 얹히도록(재배치 회피). + head.transform.localScale = new Vector3(headScale - 0.02f, headScale - 0.04f, headScale - 0.04f); + head.GetComponent().material = skinMat; + Object.Destroy(head.GetComponent()); + + // ── 모자 (Hat) — hatRoot 컨테이너로 묶어 SetActive(false)로 통째로 숨기기 가능 ── + hatRoot = new GameObject("HatRoot"); + hatRoot.transform.SetParent(headPivot.transform, false); + hatRoot.transform.localPosition = Vector3.zero; + + GameObject cap = GameObject.CreatePrimitive(PrimitiveType.Cylinder); + cap.name = "Cap"; + cap.transform.SetParent(hatRoot.transform, false); + cap.transform.localPosition = new Vector3(0f, 0.3f, -0.02f); + cap.transform.localScale = new Vector3(0.30f, 0.12f, 0.30f); + cap.GetComponent().material = hatMat; + Object.Destroy(cap.GetComponent()); + + GameObject brim = GameObject.CreatePrimitive(PrimitiveType.Cube); + brim.name = "CapBrim"; + brim.transform.SetParent(hatRoot.transform, false); + brim.transform.localPosition = new Vector3(0f, 0.14f, 0.28f); + brim.transform.localScale = new Vector3(0.28f, 0.03f, 0.14f); + brim.GetComponent().material = hatMat; + Object.Destroy(brim.GetComponent()); + + // ── 눈/얼굴 (BuildPlayerVisual 원본 그대로) ── + BuildFace(headPivot, headScale, gender); + + // ── 귀 ── + BuildEars(headPivot, skinMat); + + // ── 머리카락 ── + int hairStyle = PlayerPrefs.GetInt("InsectGame.Character.HairStyle", 0); + int hairColorIdx = PlayerPrefs.GetInt("InsectGame.Character.HairColor", 0); + Color[] hairColors = { + new Color(0.12f, 0.08f, 0.05f), + new Color(0.35f, 0.2f, 0.1f), + new Color(0.85f, 0.7f, 0.3f), + new Color(0.6f, 0.15f, 0.1f), + new Color(0.2f, 0.15f, 0.35f), + new Color(0.15f, 0.3f, 0.5f), + }; + Color hairColor = hairColors[Mathf.Clamp(hairColorIdx, 0, hairColors.Length - 1)]; + hairMat = MakeMaterial(hairColor); + BuildHair(headPivot, hairStyle, gender, hairMat); + + // ── 팔 (어깨 ±0.29 / Y 1.40 / 캡슐 길이 0.50 / 회전 0°) ── + // 옛 Y=1.55 + 길이 0.50 → 상단 1.80m로 Body 상단(1.79)과 일치하나 시각적으로 어깨가 여전히 높음. + // Y 1.40 + 길이 0.50 → 캡슐 범위 1.15~1.65m로 Body(1.01~1.79) 중간 → 자연스러운 인체 비례. + // 머리(2.20) 영역과 충분히 분리. X ±0.29 Body 가장자리 겹침, Z=0° 수직, swing은 X만. + outerwearArmL = GameObject.CreatePrimitive(PrimitiveType.Capsule); + outerwearArmL.name = "ArmL"; + outerwearArmL.transform.SetParent(t, false); + outerwearArmL.transform.localPosition = new Vector3(-0.29f, 0.78f, 0f); + outerwearArmL.transform.localScale = new Vector3(0.135f, 0.23f, 0.135f); + outerwearArmL.transform.localRotation = Quaternion.identity; + outerwearArmL.GetComponent().material = outerwearMat; + Object.Destroy(outerwearArmL.GetComponent()); + + outerwearArmR = GameObject.CreatePrimitive(PrimitiveType.Capsule); + outerwearArmR.name = "ArmR"; + outerwearArmR.transform.SetParent(t, false); + outerwearArmR.transform.localPosition = new Vector3(0.29f, 0.78f, 0f); + outerwearArmR.transform.localScale = new Vector3(0.135f, 0.23f, 0.135f); + outerwearArmR.transform.localRotation = Quaternion.identity; + outerwearArmR.GetComponent().material = outerwearMat; + Object.Destroy(outerwearArmR.GetComponent()); + + // ── 손 (팔 끝점: y = 1.40 - 0.25 = 1.15. 손목/손바닥 자연 매달림 = 0.95) ── + GameObject handL = GameObject.CreatePrimitive(PrimitiveType.Sphere); + handL.name = "HandL"; + handL.transform.SetParent(t, false); + handL.transform.localPosition = new Vector3(-0.29f, 0.52f, 0f); + handL.transform.localScale = new Vector3(0.115f, 0.115f, 0.115f); + handL.GetComponent().material = skinMat; + Object.Destroy(handL.GetComponent()); + + GameObject handR = GameObject.CreatePrimitive(PrimitiveType.Sphere); + handR.name = "HandR"; + handR.transform.SetParent(t, false); + handR.transform.localPosition = new Vector3(0.29f, 0.52f, 0f); + handR.transform.localScale = new Vector3(0.115f, 0.115f, 0.115f); + handR.GetComponent().material = skinMat; + Object.Destroy(handR.GetComponent()); + + // ── 다리 + 부츠 (LegPivot으로 묶어 회전 시 발도 함께 움직이도록) ── + // 옛은 BootL/R이 Player 직접 자식이라 PlayerMovement.AnimateWalk의 LegL/R 회전이 발에 전파 안 됨 + // → 사용자 보고 "다리는 움직이는데 발이 안 움직임". LegPivot(빈, scale 1) 안에 Leg+Boot 둘 다 자식으로 + // 배치하여 Pivot 회전 시 Leg+Boot 모두 자동 전파. Pivot Y=0.86 = 다리 최상단(허벅지 윗부분)으로 + // 회전 중심이 골반에 위치 → 자연스러운 보행. Leg/Boot scale 비균등이라 Pivot scale=1 유지 필수. + // 치비: 짧고 통통한 다리 + 좁은 스탠스(X ±0.13). Pivot Y 0.48 = 골반(회전 중심). + GameObject legLPivot = new GameObject("LegLPivot"); + legLPivot.transform.SetParent(t, false); + legLPivot.transform.localPosition = new Vector3(-0.13f, 0.48f, 0f); + + GameObject legL = GameObject.CreatePrimitive(PrimitiveType.Capsule); + legL.name = "LegL"; + legL.transform.SetParent(legLPivot.transform, false); + legL.transform.localPosition = new Vector3(0f, -0.14f, 0f); // Pivot 아래 0.14m = 절대 Y 0.34 + legL.transform.localScale = new Vector3(legScale, 0.20f, legScale); + legL.GetComponent().material = bottomMat; + Object.Destroy(legL.GetComponent()); + + GameObject bootL = GameObject.CreatePrimitive(PrimitiveType.Cube); + bootL.name = "BootL"; + bootL.transform.SetParent(legLPivot.transform, false); + bootL.transform.localPosition = new Vector3(0f, -0.36f, 0.07f); // Pivot 아래 0.36m = 절대 Y 0.12, 발바닥 ~0.045 + bootL.transform.localScale = new Vector3(0.21f, 0.15f, 0.30f); + bootL.GetComponent().material = shoesMat; + Object.Destroy(bootL.GetComponent()); + + GameObject legRPivot = new GameObject("LegRPivot"); + legRPivot.transform.SetParent(t, false); + legRPivot.transform.localPosition = new Vector3(0.13f, 0.48f, 0f); + + GameObject legR = GameObject.CreatePrimitive(PrimitiveType.Capsule); + legR.name = "LegR"; + legR.transform.SetParent(legRPivot.transform, false); + legR.transform.localPosition = new Vector3(0f, -0.14f, 0f); + legR.transform.localScale = new Vector3(legScale, 0.20f, legScale); + legR.GetComponent().material = bottomMat; + Object.Destroy(legR.GetComponent()); + + GameObject bootR = GameObject.CreatePrimitive(PrimitiveType.Cube); + bootR.name = "BootR"; + bootR.transform.SetParent(legRPivot.transform, false); + bootR.transform.localPosition = new Vector3(0f, -0.36f, 0.07f); + bootR.transform.localScale = new Vector3(0.21f, 0.15f, 0.30f); + bootR.GetComponent().material = shoesMat; + Object.Destroy(bootR.GetComponent()); + + // ── 배낭 (Backpack) ── + backpackRoot = GameObject.CreatePrimitive(PrimitiveType.Cube); + backpackRoot.name = "Backpack"; + backpackRoot.transform.SetParent(t, false); + backpackRoot.transform.localPosition = new Vector3(0f, 0.80f, -0.22f); + backpackRoot.transform.localScale = new Vector3(0.30f, 0.34f, 0.16f); + backpackRoot.GetComponent().material = backpackMat; + Object.Destroy(backpackRoot.GetComponent()); + + backpackStrap = GameObject.CreatePrimitive(PrimitiveType.Cube); + backpackStrap.name = "BackpackStrap"; + backpackStrap.transform.SetParent(backpackRoot.transform, false); + backpackStrap.transform.localPosition = new Vector3(0f, 0.3f, 1.6f); // backpackRoot 자식, world Z 앞 방향 + backpackStrap.transform.localScale = new Vector3(0.9f, 0.18f, 0.2f); + backpackStrap.GetComponent().material = backpackStrapMat; + Object.Destroy(backpackStrap.GetComponent()); + + // ── 도구 (NetHandle/NetRing) — CharacterOutfitManager.ApplyToolShape가 이름 의존 ── + // 기본 좌표 = ApplyToolShape의 net(기본 잠자리채) else 분기 최종값과 일치시킴. + // 옛 좌표(handle 0.55,1.62,-0.18 / ring 0.82,2.28,-0.1 머리높이·뒤쪽)는 stale 레거시로, + // ApplyToolShape이 첫 프레임 전 덮어쓰지만 도구 미장착/early-return 시 머리 위 뒤에 망이 + // 둥둥 뜨는 잠재버그였음. 단일 출처화 + 안전망 이중 목적. + toolHandle = GameObject.CreatePrimitive(PrimitiveType.Cylinder); + toolHandle.name = "NetHandle"; + toolHandle.transform.SetParent(t, false); + toolHandle.transform.localPosition = new Vector3(0.29f, 0.82f, 0.02f); + toolHandle.transform.localScale = new Vector3(0.04f, 0.55f, 0.04f); + toolHandle.transform.localRotation = Quaternion.Euler(20f, 0f, -15f); + toolHandle.GetComponent().material = toolMat; + Object.Destroy(toolHandle.GetComponent()); + + toolRing = GameObject.CreatePrimitive(PrimitiveType.Cylinder); + toolRing.name = "NetRing"; + toolRing.transform.SetParent(t, false); + toolRing.transform.localPosition = new Vector3(0.35f, 1.37f, 0.06f); + toolRing.transform.localScale = new Vector3(0.26f, 0.02f, 0.26f); + toolRing.transform.localRotation = Quaternion.Euler(-20f, 0f, 0f); + toolRing.GetComponent().material = toolRingMat; + Object.Destroy(toolRing.GetComponent()); + + // ── 악세서리 (신규) ── + BuildAccessoryNodes(); + } + + private void BuildFace(GameObject headPivot, float headScale, int gender) + { + Material eyeMat = MakeMaterial(Color.white); + Material pupilMat = MakeMaterial(new Color(0.12f, 0.08f, 0.05f)); + + GameObject eyeL = GameObject.CreatePrimitive(PrimitiveType.Sphere); + eyeL.name = "EyeL"; + eyeL.transform.SetParent(headPivot.transform, false); + // 치비 큰 눈: 옛 0.11 → 0.15/0.17(세로로 큰 동그란 눈), 살짝 아래·바깥(귀여운 인상). + eyeL.transform.localPosition = new Vector3(-0.12f, -0.03f, 0.32f); + eyeL.transform.localScale = new Vector3(0.15f, 0.17f, 0.06f); + eyeL.GetComponent().material = eyeMat; + Object.Destroy(eyeL.GetComponent()); + + GameObject eyeR = GameObject.CreatePrimitive(PrimitiveType.Sphere); + eyeR.name = "EyeR"; + eyeR.transform.SetParent(headPivot.transform, false); + eyeR.transform.localPosition = new Vector3(0.12f, -0.03f, 0.32f); + eyeR.transform.localScale = new Vector3(0.15f, 0.17f, 0.06f); + eyeR.GetComponent().material = eyeMat; + Object.Destroy(eyeR.GetComponent()); + + GameObject pupilL = GameObject.CreatePrimitive(PrimitiveType.Sphere); + pupilL.name = "PupilL"; + pupilL.transform.SetParent(headPivot.transform, false); + pupilL.transform.localPosition = new Vector3(-0.12f, -0.04f, 0.35f); + pupilL.transform.localScale = new Vector3(0.09f, 0.11f, 0.02f); + pupilL.GetComponent().material = pupilMat; + Object.Destroy(pupilL.GetComponent()); + + GameObject pupilR = GameObject.CreatePrimitive(PrimitiveType.Sphere); + pupilR.name = "PupilR"; + pupilR.transform.SetParent(headPivot.transform, false); + pupilR.transform.localPosition = new Vector3(0.12f, -0.04f, 0.35f); + pupilR.transform.localScale = new Vector3(0.09f, 0.11f, 0.02f); + pupilR.GetComponent().material = pupilMat; + Object.Destroy(pupilR.GetComponent()); + + Material hlMat = MakeMaterial(new Color(1f, 1f, 1f, 0.9f)); + GameObject hlL = GameObject.CreatePrimitive(PrimitiveType.Sphere); + hlL.name = "HighlightL"; + hlL.transform.SetParent(headPivot.transform, false); + hlL.transform.localPosition = new Vector3(-0.10f, 0.01f, 0.36f); + hlL.transform.localScale = new Vector3(0.045f, 0.045f, 0.01f); + hlL.GetComponent().material = hlMat; + Object.Destroy(hlL.GetComponent()); + + GameObject hlR = GameObject.CreatePrimitive(PrimitiveType.Sphere); + hlR.name = "HighlightR"; + hlR.transform.SetParent(headPivot.transform, false); + hlR.transform.localPosition = new Vector3(0.10f, 0.01f, 0.36f); + hlR.transform.localScale = new Vector3(0.045f, 0.045f, 0.01f); + hlR.GetComponent().material = hlMat; + Object.Destroy(hlR.GetComponent()); + + int faceType = PlayerPrefs.GetInt("InsectGame.Character.FaceType", 0); + Material browMat = MakeMaterial(new Color(0.2f, 0.15f, 0.1f)); + GameObject browL = GameObject.CreatePrimitive(PrimitiveType.Cube); + browL.name = "BrowL"; + browL.transform.SetParent(headPivot.transform, false); + browL.transform.localPosition = new Vector3(-0.12f, 0.11f, 0.32f); + browL.transform.localScale = new Vector3(0.09f, 0.016f, 0.02f); + browL.GetComponent().material = browMat; + Object.Destroy(browL.GetComponent()); + + GameObject browR = GameObject.CreatePrimitive(PrimitiveType.Cube); + browR.name = "BrowR"; + browR.transform.SetParent(headPivot.transform, false); + browR.transform.localPosition = new Vector3(0.12f, 0.11f, 0.32f); + browR.transform.localScale = new Vector3(0.09f, 0.016f, 0.02f); + browR.GetComponent().material = browMat; + Object.Destroy(browR.GetComponent()); + + Material skinMat = MakeMaterial(new Color(0.92f, 0.78f, 0.62f)); + GameObject nose = GameObject.CreatePrimitive(PrimitiveType.Sphere); + nose.name = "Nose"; + nose.transform.SetParent(headPivot.transform, false); + // 치비: 코는 작은 점으로 (큰 눈 강조). 위치도 눈 아래로 내림. + nose.transform.localPosition = new Vector3(0f, -0.10f, 0.35f); + nose.transform.localScale = new Vector3(0.024f, 0.022f, 0.02f); + nose.GetComponent().material = skinMat; + Object.Destroy(nose.GetComponent()); + + Material mouthMat = MakeMaterial(new Color(0.8f, 0.4f, 0.35f)); + GameObject mouth = GameObject.CreatePrimitive(PrimitiveType.Cube); + mouth.name = "Mouth"; + mouth.transform.SetParent(headPivot.transform, false); + float mouthWidth = 0.04f; + float mouthY = -0.05f; + switch (faceType) + { + case 0: mouthWidth = 0.04f; mouthY = -0.05f; break; + case 1: mouthWidth = 0.05f; mouthY = -0.06f; break; + case 2: mouthWidth = 0.03f; mouthY = -0.04f; break; + case 3: mouthWidth = 0.02f; mouthY = -0.045f; break; + } + // 치비: 큰 눈/작은 코에 맞춰 입을 아래로 내려 균형 (옛 위치는 코와 겹쳐 답답). + mouth.transform.localPosition = new Vector3(0f, mouthY - 0.08f, 0.32f); + mouth.transform.localScale = new Vector3(mouthWidth, 0.015f, 0.015f); + mouth.GetComponent().material = mouthMat; + Object.Destroy(mouth.GetComponent()); + + if (gender == 1) + { + Material blushMat = MakeMaterial(new Color(1f, 0.6f, 0.6f, 0.4f)); + GameObject blushL = GameObject.CreatePrimitive(PrimitiveType.Sphere); + blushL.name = "BlushL"; + blushL.transform.SetParent(headPivot.transform, false); + blushL.transform.localPosition = new Vector3(-0.16f, -0.10f, 0.30f); + blushL.transform.localScale = new Vector3(0.08f, 0.05f, 0.02f); + blushL.GetComponent().material = blushMat; + Object.Destroy(blushL.GetComponent()); + + GameObject blushR = GameObject.CreatePrimitive(PrimitiveType.Sphere); + blushR.name = "BlushR"; + blushR.transform.SetParent(headPivot.transform, false); + blushR.transform.localPosition = new Vector3(0.16f, -0.10f, 0.30f); + blushR.transform.localScale = new Vector3(0.08f, 0.05f, 0.02f); + blushR.GetComponent().material = blushMat; + Object.Destroy(blushR.GetComponent()); + + Material lashMat = MakeMaterial(new Color(0.1f, 0.08f, 0.05f)); + GameObject lashL = GameObject.CreatePrimitive(PrimitiveType.Cube); + lashL.name = "LashL"; + lashL.transform.SetParent(headPivot.transform, false); + lashL.transform.localPosition = new Vector3(-0.12f, 0.07f, 0.31f); + lashL.transform.localScale = new Vector3(0.09f, 0.006f, 0.01f); + lashL.GetComponent().material = lashMat; + Object.Destroy(lashL.GetComponent()); + + GameObject lashR = GameObject.CreatePrimitive(PrimitiveType.Cube); + lashR.name = "LashR"; + lashR.transform.SetParent(headPivot.transform, false); + lashR.transform.localPosition = new Vector3(0.12f, 0.07f, 0.31f); + lashR.transform.localScale = new Vector3(0.09f, 0.006f, 0.01f); + lashR.GetComponent().material = lashMat; + Object.Destroy(lashR.GetComponent()); + } + } + + private void BuildEars(GameObject headPivot, Material skinMat) + { + GameObject earL = GameObject.CreatePrimitive(PrimitiveType.Sphere); + earL.name = "EarL"; + earL.transform.SetParent(headPivot.transform, false); + earL.transform.localPosition = new Vector3(-0.34f, -0.02f, 0f); + earL.transform.localScale = new Vector3(0.05f, 0.08f, 0.06f); + earL.GetComponent().material = skinMat; + Object.Destroy(earL.GetComponent()); + + GameObject earR = GameObject.CreatePrimitive(PrimitiveType.Sphere); + earR.name = "EarR"; + earR.transform.SetParent(headPivot.transform, false); + earR.transform.localPosition = new Vector3(0.34f, -0.02f, 0f); + earR.transform.localScale = new Vector3(0.05f, 0.08f, 0.06f); + earR.GetComponent().material = skinMat; + Object.Destroy(earR.GetComponent()); + } + + private void BuildHair(GameObject headPivot, int style, int gender, Material hairMat) + { + switch (style) + { + case 0: BuildShortHair(headPivot, gender, hairMat); break; + case 1: BuildMediumHair(headPivot, gender, hairMat); break; + case 2: BuildLongHair(headPivot, gender, hairMat); break; + case 3: BuildUpHair(headPivot, gender, hairMat); break; + } + } + + private void BuildShortHair(GameObject headPivot, int gender, Material mat) + { + GameObject hairTop = GameObject.CreatePrimitive(PrimitiveType.Sphere); + hairTop.name = "HairTop"; + hairTop.transform.SetParent(headPivot.transform, false); + hairTop.transform.localPosition = new Vector3(0f, 0.22f, -0.02f); + hairTop.transform.localScale = new Vector3(0.62f, 0.34f, 0.60f); + hairTop.GetComponent().material = mat; + Object.Destroy(hairTop.GetComponent()); + + GameObject hairSideL = GameObject.CreatePrimitive(PrimitiveType.Sphere); + hairSideL.name = "HairSideL"; + hairSideL.transform.SetParent(headPivot.transform, false); + hairSideL.transform.localPosition = new Vector3(-0.2f, 0.05f, -0.02f); + hairSideL.transform.localScale = new Vector3(0.12f, 0.2f, 0.35f); + hairSideL.GetComponent().material = mat; + Object.Destroy(hairSideL.GetComponent()); + + GameObject hairSideR = GameObject.CreatePrimitive(PrimitiveType.Sphere); + hairSideR.name = "HairSideR"; + hairSideR.transform.SetParent(headPivot.transform, false); + hairSideR.transform.localPosition = new Vector3(0.2f, 0.05f, -0.02f); + hairSideR.transform.localScale = new Vector3(0.12f, 0.2f, 0.35f); + hairSideR.GetComponent().material = mat; + Object.Destroy(hairSideR.GetComponent()); + + GameObject hairBack = GameObject.CreatePrimitive(PrimitiveType.Sphere); + hairBack.name = "HairBack"; + hairBack.transform.SetParent(headPivot.transform, false); + hairBack.transform.localPosition = new Vector3(0f, 0.08f, -0.15f); + hairBack.transform.localScale = new Vector3(0.45f, 0.28f, 0.2f); + hairBack.GetComponent().material = mat; + Object.Destroy(hairBack.GetComponent()); + + if (gender == 1) + { + GameObject bangs = GameObject.CreatePrimitive(PrimitiveType.Cube); + bangs.name = "HairBangs"; + bangs.transform.SetParent(headPivot.transform, false); + bangs.transform.localPosition = new Vector3(0f, 0.18f, 0.16f); + bangs.transform.localScale = new Vector3(0.4f, 0.08f, 0.1f); + bangs.GetComponent().material = mat; + Object.Destroy(bangs.GetComponent()); + } + } + + private void BuildMediumHair(GameObject headPivot, int gender, Material mat) + { + BuildShortHair(headPivot, gender, mat); + + GameObject hairExtL = GameObject.CreatePrimitive(PrimitiveType.Capsule); + hairExtL.name = "HairExtL"; + hairExtL.transform.SetParent(headPivot.transform, false); + hairExtL.transform.localPosition = new Vector3(-0.2f, -0.1f, -0.05f); + hairExtL.transform.localScale = new Vector3(0.1f, 0.18f, 0.12f); + hairExtL.GetComponent().material = mat; + Object.Destroy(hairExtL.GetComponent()); + + GameObject hairExtR = GameObject.CreatePrimitive(PrimitiveType.Capsule); + hairExtR.name = "HairExtR"; + hairExtR.transform.SetParent(headPivot.transform, false); + hairExtR.transform.localPosition = new Vector3(0.2f, -0.1f, -0.05f); + hairExtR.transform.localScale = new Vector3(0.1f, 0.18f, 0.12f); + hairExtR.GetComponent().material = mat; + Object.Destroy(hairExtR.GetComponent()); + + GameObject hairBackExt = GameObject.CreatePrimitive(PrimitiveType.Capsule); + hairBackExt.name = "HairBackExt"; + hairBackExt.transform.SetParent(headPivot.transform, false); + hairBackExt.transform.localPosition = new Vector3(0f, -0.08f, -0.18f); + hairBackExt.transform.localScale = new Vector3(0.35f, 0.2f, 0.15f); + hairBackExt.GetComponent().material = mat; + Object.Destroy(hairBackExt.GetComponent()); + } + + private void BuildLongHair(GameObject headPivot, int gender, Material mat) + { + BuildShortHair(headPivot, gender, mat); + + for (int i = 0; i < 3; i++) + { + float x = (i - 1) * 0.12f; + GameObject strand = GameObject.CreatePrimitive(PrimitiveType.Capsule); + strand.name = $"HairLong_{i}"; + strand.transform.SetParent(headPivot.transform, false); + strand.transform.localPosition = new Vector3(x, -0.3f, -0.12f); + strand.transform.localScale = new Vector3(0.12f, 0.35f, 0.1f); + strand.GetComponent().material = mat; + Object.Destroy(strand.GetComponent()); + } + + GameObject frontL = GameObject.CreatePrimitive(PrimitiveType.Capsule); + frontL.name = "HairFrontL"; + frontL.transform.SetParent(headPivot.transform, false); + frontL.transform.localPosition = new Vector3(-0.18f, -0.12f, 0.1f); + frontL.transform.localScale = new Vector3(0.06f, 0.22f, 0.06f); + frontL.GetComponent().material = mat; + Object.Destroy(frontL.GetComponent()); + + GameObject frontR = GameObject.CreatePrimitive(PrimitiveType.Capsule); + frontR.name = "HairFrontR"; + frontR.transform.SetParent(headPivot.transform, false); + frontR.transform.localPosition = new Vector3(0.18f, -0.12f, 0.1f); + frontR.transform.localScale = new Vector3(0.06f, 0.22f, 0.06f); + frontR.GetComponent().material = mat; + Object.Destroy(frontR.GetComponent()); + + if (gender == 1) + { + GameObject bangs = GameObject.CreatePrimitive(PrimitiveType.Cube); + bangs.name = "HairBangs"; + bangs.transform.SetParent(headPivot.transform, false); + bangs.transform.localPosition = new Vector3(0f, 0.18f, 0.16f); + bangs.transform.localScale = new Vector3(0.4f, 0.1f, 0.1f); + bangs.GetComponent().material = mat; + Object.Destroy(bangs.GetComponent()); + + for (int i = 0; i < 2; i++) + { + float x = (i == 0) ? -0.08f : 0.08f; + GameObject longStrand = GameObject.CreatePrimitive(PrimitiveType.Capsule); + longStrand.name = $"HairVeryLong_{i}"; + longStrand.transform.SetParent(headPivot.transform, false); + longStrand.transform.localPosition = new Vector3(x, -0.55f, -0.12f); + longStrand.transform.localScale = new Vector3(0.1f, 0.3f, 0.08f); + longStrand.GetComponent().material = mat; + Object.Destroy(longStrand.GetComponent()); + } + } + } + + private void BuildUpHair(GameObject headPivot, int gender, Material mat) + { + GameObject hairTop = GameObject.CreatePrimitive(PrimitiveType.Sphere); + hairTop.name = "HairTop"; + hairTop.transform.SetParent(headPivot.transform, false); + hairTop.transform.localPosition = new Vector3(0f, 0.26f, -0.02f); + hairTop.transform.localScale = new Vector3(0.55f, 0.30f, 0.52f); + hairTop.GetComponent().material = mat; + Object.Destroy(hairTop.GetComponent()); + + if (gender == 0) + { + GameObject spike = GameObject.CreatePrimitive(PrimitiveType.Capsule); + spike.name = "HairSpike"; + spike.transform.SetParent(headPivot.transform, false); + spike.transform.localPosition = new Vector3(0f, 0.35f, 0.05f); + spike.transform.localScale = new Vector3(0.15f, 0.18f, 0.12f); + spike.transform.localRotation = Quaternion.Euler(-20f, 0f, 0f); + spike.GetComponent().material = mat; + Object.Destroy(spike.GetComponent()); + + for (int side = -1; side <= 1; side += 2) + { + GameObject sideSpike = GameObject.CreatePrimitive(PrimitiveType.Capsule); + sideSpike.name = $"HairSideSpike_{(side > 0 ? "R" : "L")}"; + sideSpike.transform.SetParent(headPivot.transform, false); + sideSpike.transform.localPosition = new Vector3(side * 0.15f, 0.28f, 0f); + sideSpike.transform.localScale = new Vector3(0.08f, 0.12f, 0.08f); + sideSpike.transform.localRotation = Quaternion.Euler(0f, 0f, -side * 30f); + sideSpike.GetComponent().material = mat; + Object.Destroy(sideSpike.GetComponent()); + } + } + else + { + GameObject bun = GameObject.CreatePrimitive(PrimitiveType.Sphere); + bun.name = "HairBun"; + bun.transform.SetParent(headPivot.transform, false); + bun.transform.localPosition = new Vector3(0f, 0.15f, -0.22f); + bun.transform.localScale = new Vector3(0.22f, 0.22f, 0.22f); + bun.GetComponent().material = mat; + Object.Destroy(bun.GetComponent()); + + Material ribbonMat = MakeMaterial(new Color(0.9f, 0.3f, 0.4f)); + GameObject ribbon = GameObject.CreatePrimitive(PrimitiveType.Cylinder); + ribbon.name = "HairRibbon"; + ribbon.transform.SetParent(headPivot.transform, false); + ribbon.transform.localPosition = new Vector3(0f, 0.15f, -0.22f); + ribbon.transform.localScale = new Vector3(0.24f, 0.02f, 0.24f); + ribbon.GetComponent().material = ribbonMat; + Object.Destroy(ribbon.GetComponent()); + + GameObject bangs = GameObject.CreatePrimitive(PrimitiveType.Cube); + bangs.name = "HairBangs"; + bangs.transform.SetParent(headPivot.transform, false); + bangs.transform.localPosition = new Vector3(0f, 0.18f, 0.16f); + bangs.transform.localScale = new Vector3(0.4f, 0.08f, 0.1f); + bangs.GetComponent().material = mat; + Object.Destroy(bangs.GetComponent()); + + for (int side = -1; side <= 1; side += 2) + { + GameObject sideHair = GameObject.CreatePrimitive(PrimitiveType.Capsule); + sideHair.name = $"HairSide_{(side > 0 ? "R" : "L")}"; + sideHair.transform.SetParent(headPivot.transform, false); + sideHair.transform.localPosition = new Vector3(side * 0.2f, -0.05f, 0.05f); + sideHair.transform.localScale = new Vector3(0.06f, 0.15f, 0.06f); + sideHair.GetComponent().material = mat; + Object.Destroy(sideHair.GetComponent()); + } + } + } + + // ────────────────────────────────────────────── + // Accessory: 안경/목걸이/배지(기본 큐브) 세 가지를 미리 생성하고 + // itemId에 따라 SetActive 분기. + // ────────────────────────────────────────────── + private void BuildAccessoryNodes() + { + accessoryRoot = new GameObject("AccessoryRoot"); + accessoryRoot.transform.SetParent(transform, false); + accessoryRoot.transform.localPosition = Vector3.zero; + + // 안경: 치비 눈 월드높이(headPivot 1.22 + 눈 local×0.60 ≈ 1.20)에 맞춤. 옛 Y2.20은 + // 캐릭터 키 2.38 기준이라 치비에서 머리 위 ~1유닛 허공에 떴음. 커진 눈에 맞춰 렌즈도 확대. + accGlassesL = GameObject.CreatePrimitive(PrimitiveType.Cube); + accGlassesL.name = "AccGlassesL"; + accGlassesL.transform.SetParent(accessoryRoot.transform, false); + accGlassesL.transform.localPosition = new Vector3(-0.072f, 1.20f, 0.21f); + accGlassesL.transform.localScale = new Vector3(0.10f, 0.09f, 0.02f); + accGlassesL.GetComponent().material = accessoryMat; + Object.Destroy(accGlassesL.GetComponent()); + + accGlassesR = GameObject.CreatePrimitive(PrimitiveType.Cube); + accGlassesR.name = "AccGlassesR"; + accGlassesR.transform.SetParent(accessoryRoot.transform, false); + accGlassesR.transform.localPosition = new Vector3(0.072f, 1.20f, 0.21f); + accGlassesR.transform.localScale = new Vector3(0.10f, 0.09f, 0.02f); + accGlassesR.GetComponent().material = accessoryMat; + Object.Destroy(accGlassesR.GetComponent()); + + // 목걸이/펜던트: 목/쇄골(치비 목 ~1.0). 옛 Y1.88 → 1.00. + accNecklace = GameObject.CreatePrimitive(PrimitiveType.Sphere); + accNecklace.name = "AccNecklace"; + accNecklace.transform.SetParent(accessoryRoot.transform, false); + accNecklace.transform.localPosition = new Vector3(0f, 1.00f, 0.20f); + accNecklace.transform.localScale = new Vector3(0.06f, 0.06f, 0.06f); + accNecklace.GetComponent().material = accessoryMat; + Object.Destroy(accNecklace.GetComponent()); + + // 기본 배지: 가슴팍(치비 Body Y0.77 앞면 ~0.85). 옛 Y1.65 → 0.85. + accBadge = GameObject.CreatePrimitive(PrimitiveType.Cube); + accBadge.name = "AccBadge"; + accBadge.transform.SetParent(accessoryRoot.transform, false); + accBadge.transform.localPosition = new Vector3(0f, 0.85f, 0.20f); + accBadge.transform.localScale = new Vector3(0.10f, 0.10f, 0.04f); + accBadge.GetComponent().material = accessoryMat; + Object.Destroy(accBadge.GetComponent()); + + // 초기 상태: 모두 숨김 (RefreshOutfitColors에서 itemId에 맞춰 켬) + accGlassesL.SetActive(false); + accGlassesR.SetActive(false); + accNecklace.SetActive(false); + accBadge.SetActive(false); + } + + // ────────────────────────────────────────────── + // OutfitChanged 핸들러: 머티리얼 색 + Accessory 노드 가시성 갱신. + // GameObject 재생성 X. + // ────────────────────────────────────────────── + public void RefreshOutfitColors() + { + CharacterOutfitManager mgr = CharacterOutfitManager.Instance; + if (mgr == null) return; + + // 7개 슬롯 (Hat/Top/Bottom/Outerwear/Shoes/Backpack/Tool): ApplyToCharacter가 노드별 + // 머티리얼 색 + 알파 0 시 SetActive(false) 적용. 게임 시작 시점에도 저장 의상 동기화. + mgr.ApplyToCharacter(); + + // Accessory: ApplyToCharacter에 미포함이라 PlayerVisualBuilder가 자체 노드로 처리. + ApplyAccessory(mgr.GetEquipped(OutfitSlot.Accessory)); + } + + private void ApplyAccessory(OutfitItem item) + { + if (accessoryRoot == null) return; + + // 모두 꺼두고 itemId에 맞는 노드만 켠다. + if (accGlassesL != null) accGlassesL.SetActive(false); + if (accGlassesR != null) accGlassesR.SetActive(false); + if (accNecklace != null) accNecklace.SetActive(false); + if (accBadge != null) accBadge.SetActive(false); + + if (item == null) return; + if (item.primaryColor.a < 0.01f) return; // acc_none + + string id = item.itemId ?? ""; + Color c = item.primaryColor; + + if (id.Contains("glasses") || id.Contains("visor") || id.Contains("eyepatch")) + { + if (accGlassesL != null) + { + accGlassesL.SetActive(true); + SetColor(accGlassesL, c); + } + if (accGlassesR != null) + { + accGlassesR.SetActive(true); + // eyepatch는 한쪽만 + if (id.Contains("eyepatch")) accGlassesR.SetActive(false); + else SetColor(accGlassesR, c); + } + } + else if (id.Contains("necklace") || id.Contains("pendant") || id.Contains("orb") || id.Contains("crystal_orb")) + { + if (accNecklace != null) + { + accNecklace.SetActive(true); + SetColor(accNecklace, c); + } + } + else + { + // 스카프/배지/엠블럼/완장/머플러/반다나/날개/오라/후광/네온/도그태그 등 기본 가슴팍 큐브 + if (accBadge != null) + { + accBadge.SetActive(true); + SetColor(accBadge, c); + } + } + } + + private static void SetColor(GameObject go, Color c) + { + MeshRenderer mr = go.GetComponent(); + if (mr == null) return; + Material mat = mr.material; + if (mat == null) return; + mat.color = c; + } + } +} diff --git a/Assets/Scripts/Core/PlayerVisualBuilder.cs.meta b/Assets/Scripts/Core/PlayerVisualBuilder.cs.meta new file mode 100644 index 0000000..18c7531 --- /dev/null +++ b/Assets/Scripts/Core/PlayerVisualBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6a62d0ddcee836748b84177cac8e3389 \ No newline at end of file diff --git a/Assets/Scripts/Core/ProceduralAudioGenerator.cs b/Assets/Scripts/Core/ProceduralAudioGenerator.cs index 2ab53d3..0e0095b 100644 --- a/Assets/Scripts/Core/ProceduralAudioGenerator.cs +++ b/Assets/Scripts/Core/ProceduralAudioGenerator.cs @@ -36,6 +36,13 @@ public static AudioClip GetBGM(string type) case "victory": clip = GenerateVictoryBGM(); break; case "defeat": clip = GenerateDefeatBGM(); break; case "menu": clip = GenerateMenuBGM(); break; + case "explore_meadow": clip = GenerateRegionBGM("Meadow", 110, new[] { C4, D4, E4, G4, A4, C5 }, new[] { C3, F4 * 0.5f, G3, C3 }, 42); break; + case "explore_forest": clip = GenerateRegionBGM("Forest", 95, new[] { D4, E4, G4, A4, B4, D5 }, new[] { D2, A2, G3, D2 }, 88); break; + case "explore_pond": clip = GenerateRegionBGM("Pond", 100, new[] { F4, G4, A4, C5, D5, F5 }, new[] { F4 * 0.5f, C3, G3, F4 * 0.5f }, 134); break; + case "explore_swamp": clip = GenerateRegionBGM("Swamp", 75, new[] { E4, G4, A4, B4, D5, E5 }, new[] { E3, A2, B3, E3 }, 56); break; + case "explore_mountain": clip = GenerateRegionBGM("Mountain", 105, new[] { G4, A4, C5, D5, E5, G5 }, new[] { G3, D4, A4, G3 }, 167); break; + case "explore_garden": clip = GenerateRegionBGM("Garden", 120, new[] { C5, D5, E5, G5, A5, C6 }, new[] { F4 * 0.5f, A2, G3, C3 }, 73); break; + case "explore_ruins": clip = GenerateRegionBGM("Ruins", 80, new[] { D4, F4, G4, A4, C5, D5 }, new[] { D2, A2, F4 * 0.5f, D2 }, 199); break; default: Debug.LogWarning($"[ProceduralAudio] Unknown BGM type: {type}"); return null; @@ -77,6 +84,21 @@ public static AudioClip GetSFX(string type) case "item_use": clip = GenerateItemUseSFX(); break; case "equip": clip = GenerateEquipSFX(); break; case "set_complete": clip = GenerateSetCompleteSFX(); break; + case "footstep": clip = GenerateFootstepSFX(); break; + case "level_up_gain": clip = GenerateLevelUpGainSFX(); break; + case "menu_hover": clip = GenerateMenuHoverSFX(); break; + case "purchase": clip = GeneratePurchaseSFX(); break; + case "error": clip = GenerateErrorSFX(); break; + case "skill_bug": clip = GenerateElementSkillSFX(0); break; + case "skill_poison": clip = GenerateElementSkillSFX(1); break; + case "skill_water": clip = GenerateElementSkillSFX(2); break; + case "skill_leaf": clip = GenerateElementSkillSFX(3); break; + case "skill_wind": clip = GenerateElementSkillSFX(4); break; + case "skill_electric": clip = GenerateElementSkillSFX(5); break; + case "skill_earth": clip = GenerateElementSkillSFX(6); break; + case "skill_light": clip = GenerateElementSkillSFX(7); break; + case "skill_dark": clip = GenerateElementSkillSFX(8); break; + case "skill_metal": clip = GenerateElementSkillSFX(9); break; default: Debug.LogWarning($"[ProceduralAudio] Unknown SFX type: {type}"); return null; @@ -100,6 +122,17 @@ public static AudioClip GetAmbient(string type) case "forest": clip = GenerateForestAmbient(); break; case "pond": clip = GeneratePondAmbient(); break; case "night": clip = GenerateNightAmbient(); break; + case "cave": clip = GenerateCaveAmbient(); break; + case "underground": clip = GenerateUndergroundAmbient(); break; + case "deep_forest": clip = GenerateDeepForestAmbient(); break; + case "underwater": clip = GenerateUnderwaterAmbient(); break; + case "fog": clip = GenerateFogAmbient(); break; + case "reeds": clip = GenerateReedsAmbient(); break; + case "peak": clip = GeneratePeakAmbient(); break; + case "flower_maze": clip = GenerateFlowerMazeAmbient(); break; + case "greenhouse": clip = GenerateGreenhouseAmbient(); break; + case "temple": clip = GenerateTempleAmbient(); break; + case "day": clip = GenerateDayAmbient(); break; default: Debug.LogWarning($"[ProceduralAudio] Unknown Ambient type: {type}"); return null; @@ -135,6 +168,89 @@ private static float Noise(System.Random rng) return (float)(rng.NextDouble() * 2.0 - 1.0); } + /// 배음 합산 (1f, 0.5f, 0.33f, 0.25f 비율로 부분배음 추가) — 풍부한 음색. + private static float Harmonic(float freq, int sample, float[] partials = null) + { + float[] amps = partials ?? new[] { 1f, 0.5f, 0.33f, 0.25f }; + float sum = 0f; + float total = 0f; + for (int p = 0; p < amps.Length; p++) + { + sum += SinWave(freq * (p + 1), sample) * amps[p]; + total += amps[p]; + } + return total > 0f ? sum / total : 0f; + } + + /// 간이 reverb: comb filter 4탭 합산 in-place. + private static void ApplySimpleReverb(float[] data, float decay = 0.4f, int delayMs = 80) + { + int delaySamples = SampleRate * delayMs / 1000; + int[] delays = { delaySamples, delaySamples * 2, (int)(delaySamples * 1.5f), (int)(delaySamples * 2.7f) }; + float[] gains = { decay, decay * 0.7f, decay * 0.5f, decay * 0.35f }; + + float[] buffer = new float[data.Length]; + System.Array.Copy(data, buffer, data.Length); + + for (int i = 0; i < data.Length; i++) + { + float wet = 0f; + for (int t = 0; t < delays.Length; t++) + { + int srcIdx = i - delays[t]; + if (srcIdx >= 0) wet += buffer[srcIdx] * gains[t]; + } + data[i] = Mathf.Clamp(buffer[i] + wet * 0.5f, -1f, 1f); + } + } + + /// 간이 echo: 단일 탭 + 피드백. + private static void ApplyEcho(float[] data, int delayMs = 200, float feedback = 0.4f, float wetMix = 0.3f) + { + int delaySamples = SampleRate * delayMs / 1000; + float[] buffer = new float[data.Length]; + System.Array.Copy(data, buffer, data.Length); + + for (int i = 0; i < data.Length; i++) + { + float echo = 0f; + int srcIdx = i - delaySamples; + if (srcIdx >= 0) echo = buffer[srcIdx] * feedback; + int srcIdx2 = i - delaySamples * 2; + if (srcIdx2 >= 0) echo += buffer[srcIdx2] * feedback * 0.5f; + data[i] = Mathf.Clamp(buffer[i] + echo * wetMix, -1f, 1f); + } + } + + /// 모티프 기반 멜로디 시퀀스 생성. motif는 음계 인덱스 배열, bars만큼 반복하며 변조. + private static int[] GenerateMotifMelody(int[] motif, int scaleSize, int bars, int notesPerBar, int seed) + { + System.Random rng = new System.Random(seed); + int[] sequence = new int[bars * notesPerBar]; + + for (int b = 0; b < bars; b++) + { + int variation = rng.Next(4); // 0=원본, 1=transpose, 2=invert, 3=retrograde + int transpose = rng.Next(-2, 3); + + for (int n = 0; n < notesPerBar; n++) + { + int motifIdx = n % motif.Length; + int note; + switch (variation) + { + case 1: note = motif[motifIdx] + transpose; break; + case 2: note = (scaleSize - 1) - motif[motifIdx]; break; // invert + case 3: note = motif[(motif.Length - 1) - motifIdx]; break; // retrograde + default: note = motif[motifIdx]; break; + } + sequence[b * notesPerBar + n] = ((note % scaleSize) + scaleSize) % scaleSize; + } + } + + return sequence; + } + // ──────────────────────────────────────────── // 헬퍼 — 엔벨로프 // ──────────────────────────────────────────── @@ -1257,5 +1373,585 @@ private static AudioClip GenerateNightAmbient() return CreateClip("Ambient_Night", data, true); } + + // ──────────────────────────────────────────── + // 신규 BGM (리전별) + // ──────────────────────────────────────────── + + /// 리전별 탐험 BGM 공통 생성기 — 음계/베이스/BPM/시드만 다름. + private static AudioClip GenerateRegionBGM(string label, float bpm, float[] melodyScale, float[] bassNotes, int seed) + { + const float durationSec = 16f; + int totalSamples = SecondsToSamples(durationSec); + float[] data = new float[totalSamples]; + System.Random rng = new System.Random(seed); + + float secPerBeat = 60f / bpm; + int samplesPerBeat = SecondsToSamples(secPerBeat); + int samplesPerEighth = samplesPerBeat / 2; + + int totalEighths = (int)(durationSec / (secPerBeat * 0.5f)); + // 모티프 기반 멜로디: 4음 모티프 + 변조 + int[] motif = { 0, 2, 4, 3 }; + int notesPerBar = 8; + int bars = totalEighths / notesPerBar; + int[] melodySequence = GenerateMotifMelody(motif, melodyScale.Length, bars, notesPerBar, seed); + + float[] arpNotes = { melodyScale[0], melodyScale[2], melodyScale[4], melodyScale[5] }; + + for (int i = 0; i < totalSamples; i++) + { + float t = (float)i / SampleRate; + float beatPos = t / secPerBeat; + + int bassIndex = ((int)(beatPos / 4f)) % bassNotes.Length; + float bass = SinWave(bassNotes[bassIndex], i) * 0.3f; + + int eighthIndex = ((int)(beatPos * 2f)) % melodySequence.Length; + float melFreq = melodyScale[melodySequence[eighthIndex]]; + int posInEighth = i % samplesPerEighth; + float melEnv = Envelope(posInEighth, samplesPerEighth / 10, samplesPerEighth / 5, + 0.6f, samplesPerEighth / 4, samplesPerEighth); + // 배음 합산으로 더 풍부한 음색 + float melody = Harmonic(melFreq, i) * 0.22f * melEnv; + + int arpIndex = ((int)(beatPos * 4f)) % arpNotes.Length; + int posInSixteenth = i % (samplesPerEighth / 2); + int sixteenthLen = samplesPerEighth / 2; + float arpEnv = Envelope(posInSixteenth, sixteenthLen / 8, sixteenthLen / 6, + 0.4f, sixteenthLen / 3, sixteenthLen); + float arp = TriWave(arpNotes[arpIndex], i) * 0.13f * arpEnv; + + float pad = Noise(rng) * 0.015f; + + data[i] = bass + melody + arp + pad; + } + + return CreateClip("BGM_" + label, data, true); + } + + // ──────────────────────────────────────────── + // 신규 SFX + // ──────────────────────────────────────────── + + /// 발소리: 짧은 노이즈 버스트 (0.08초). + private static AudioClip GenerateFootstepSFX() + { + int totalSamples = SecondsToSamples(0.08f); + float[] data = new float[totalSamples]; + System.Random rng = new System.Random(30); + + for (int i = 0; i < totalSamples; i++) + { + float env = Decay(i, totalSamples, 25f); + float thump = SinWave(120f, i) * 0.3f * env; + float scuff = Noise(rng) * 0.15f * env; + data[i] = (thump + scuff) * 0.5f; + } + + return CreateClip("SFX_Footstep", data, false); + } + + /// 레벨업 획득 SFX: 부드러운 상승 (0.25초). + private static AudioClip GenerateLevelUpGainSFX() + { + int totalSamples = SecondsToSamples(0.25f); + float[] data = new float[totalSamples]; + + for (int i = 0; i < totalSamples; i++) + { + float t = (float)i / totalSamples; + float freq = Mathf.Lerp(440f, 880f, t); + float env = Envelope(i, totalSamples / 10, totalSamples / 5, + 0.7f, totalSamples / 3, totalSamples); + float sparkle = SinWave(freq * 2f, i) * 0.1f * t; + data[i] = (SinWave(freq, i) * 0.4f + sparkle) * env; + } + + return CreateClip("SFX_LevelUpGain", data, false); + } + + /// 메뉴 호버 SFX: 짧은 사인 (0.04초). + private static AudioClip GenerateMenuHoverSFX() + { + int totalSamples = SecondsToSamples(0.04f); + float[] data = new float[totalSamples]; + + for (int i = 0; i < totalSamples; i++) + { + float env = Decay(i, totalSamples, 35f); + data[i] = SinWave(1800f, i) * env * 0.3f; + } + + return CreateClip("SFX_MenuHover", data, false); + } + + /// 구매 SFX: 코인 짤랑 (0.3초). + private static AudioClip GeneratePurchaseSFX() + { + int totalSamples = SecondsToSamples(0.3f); + float[] data = new float[totalSamples]; + System.Random rng = new System.Random(31); + + float[] notes = { C5, E5, G5 }; + float noteLen = 0.08f; + int noteSamples = SecondsToSamples(noteLen); + + for (int i = 0; i < totalSamples; i++) + { + float t = (float)i / SampleRate; + float sample = 0f; + + for (int n = 0; n < notes.Length; n++) + { + float noteStart = n * noteLen * 0.6f; + if (t >= noteStart && t < noteStart + noteLen) + { + int posInNote = SecondsToSamples(t - noteStart); + float env = Decay(posInNote, noteSamples, 12f); + sample += SinWave(notes[n], i) * 0.35f * env; + } + } + + // 짤랑 노이즈 + if (t < 0.15f) + { + float chimeEnv = Decay(i, SecondsToSamples(0.15f), 18f); + sample += Noise(rng) * 0.05f * chimeEnv; + } + + data[i] = sample; + } + + return CreateClip("SFX_Purchase", data, false); + } + + /// 오류 SFX: 하강 2음 (0.25초). + private static AudioClip GenerateErrorSFX() + { + int totalSamples = SecondsToSamples(0.25f); + float[] data = new float[totalSamples]; + + float[] notes = { D4, A2 }; + float noteLen = 0.12f; + + for (int i = 0; i < totalSamples; i++) + { + float t = (float)i / SampleRate; + float sample = 0f; + + for (int n = 0; n < notes.Length; n++) + { + float noteStart = n * noteLen; + if (t >= noteStart) + { + int posInNote = SecondsToSamples(t - noteStart); + int remaining = totalSamples - SecondsToSamples(noteStart); + float env = Decay(posInNote, remaining, 8f); + sample += SquareWave(notes[n], i) * 0.25f * env; + } + } + + data[i] = sample; + } + + return CreateClip("SFX_Error", data, false); + } + + /// 속성별 스킬 효과음 (0.3초). element index: 0=Bug, 1=Poison, 2=Water, ... + private static AudioClip GenerateElementSkillSFX(int elementIndex) + { + int totalSamples = SecondsToSamples(0.3f); + float[] data = new float[totalSamples]; + System.Random rng = new System.Random(40 + elementIndex); + + // 속성별 파라미터: (베이스 주파수, 파형 종류 0=Sin/1=Tri/2=Sqr, 노이즈 비율, 변조 깊이) + float baseFreq = 600f; + int waveType = 0; + float noiseAmt = 0.1f; + float modDepth = 200f; + switch (elementIndex) + { + case 0: baseFreq = 800f; waveType = 0; noiseAmt = 0.2f; modDepth = 100f; break; // Bug: 가벼운 윙 + case 1: baseFreq = 350f; waveType = 1; noiseAmt = 0.3f; modDepth = 150f; break; // Poison: 부글부글 + case 2: baseFreq = 500f; waveType = 0; noiseAmt = 0.4f; modDepth = 300f; break; // Water: 물 튀김 + case 3: baseFreq = 700f; waveType = 1; noiseAmt = 0.15f; modDepth = 200f; break; // Leaf: 나뭇잎 스침 + case 4: baseFreq = 1000f; waveType = 0; noiseAmt = 0.5f; modDepth = 400f; break; // Wind: 휘이잉 + case 5: baseFreq = 1500f; waveType = 2; noiseAmt = 0.3f; modDepth = 600f; break; // Electric: 지지직 + case 6: baseFreq = 200f; waveType = 2; noiseAmt = 0.4f; modDepth = 80f; break; // Earth: 쿵 + case 7: baseFreq = 1200f; waveType = 0; noiseAmt = 0.05f; modDepth = 500f; break; // Light: 반짝 + case 8: baseFreq = 280f; waveType = 1; noiseAmt = 0.25f; modDepth = 120f; break; // Dark: 어두운 윙 + case 9: baseFreq = 900f; waveType = 2; noiseAmt = 0.2f; modDepth = 50f; break; // Metal: 챙 + } + + for (int i = 0; i < totalSamples; i++) + { + float env = Decay(i, totalSamples, 6f); + float modFreq = baseFreq + modDepth * SinWave(8f, i); + + float wave = waveType == 0 ? SinWave(modFreq, i) + : waveType == 1 ? TriWave(modFreq, i) + : SquareWave(modFreq, i); + + float noise = Noise(rng) * noiseAmt; + data[i] = (wave * 0.4f + noise) * env * 0.6f; + } + + return CreateClip($"SFX_Skill_{elementIndex}", data, false); + } + + // ──────────────────────────────────────────── + // 신규 환경음 (서브에리어) + // ──────────────────────────────────────────── + + /// 동굴 환경음: 물방울 + 낮은 윙 + 메아리 노이즈 (20초). + private static AudioClip GenerateCaveAmbient() + { + const float durationSec = 20f; + int totalSamples = SecondsToSamples(durationSec); + float[] data = new float[totalSamples]; + System.Random rng = new System.Random(300); + + int dripCount = 8; + float[] dripTimes = new float[dripCount]; + for (int d = 0; d < dripCount; d++) + dripTimes[d] = (float)(rng.NextDouble() * durationSec); + + System.Random noiseRng = new System.Random(301); + + for (int i = 0; i < totalSamples; i++) + { + float t = (float)i / SampleRate; + + // 낮은 동굴 윙 (저주파 드론) + float drone = SinWave(60f, i) * 0.06f + SinWave(90f, i) * 0.04f; + + // 메아리 노이즈 + float echo = Noise(noiseRng) * 0.015f; + + // 물방울 + float drip = 0f; + for (int d = 0; d < dripCount; d++) + { + float dripStart = dripTimes[d]; + float dripDur = 0.2f; + if (t >= dripStart && t < dripStart + dripDur) + { + float localT = t - dripStart; + int localSample = SecondsToSamples(localT); + int dripSamples = SecondsToSamples(dripDur); + float env = Decay(localSample, dripSamples, 18f); + drip += SinWave(900f - localT * 600f, i) * env * 0.1f; + } + } + + data[i] = drone + echo + drip; + } + + ApplySimpleReverb(data, 0.5f, 120); + return CreateClip("Ambient_Cave", data, true); + } + + /// 지하 환경음: 더 깊은 동굴, 미세한 바람 (20초). + private static AudioClip GenerateUndergroundAmbient() + { + const float durationSec = 20f; + int totalSamples = SecondsToSamples(durationSec); + float[] data = new float[totalSamples]; + System.Random noiseRng = new System.Random(311); + + for (int i = 0; i < totalSamples; i++) + { + float drone = SinWave(40f, i) * 0.07f + SinWave(55f, i) * 0.04f; + float wind = Noise(noiseRng) * 0.02f; + if (i > 0) wind = data[i - 1] * 0.85f + wind * 0.15f; + data[i] = drone + wind; + } + + ApplySimpleReverb(data, 0.6f, 180); + return CreateClip("Ambient_Underground", data, true); + } + + /// 깊은 숲: 더 어두운 노이즈 + 멀리서 새 (20초). + private static AudioClip GenerateDeepForestAmbient() + { + const float durationSec = 20f; + int totalSamples = SecondsToSamples(durationSec); + float[] data = new float[totalSamples]; + System.Random rng = new System.Random(320); + System.Random noiseRng = new System.Random(321); + + int birdCount = 5; + float[] birdTimes = new float[birdCount]; + for (int b = 0; b < birdCount; b++) + birdTimes[b] = (float)(rng.NextDouble() * durationSec); + + for (int i = 0; i < totalSamples; i++) + { + float t = (float)i / SampleRate; + float bg = Noise(noiseRng) * 0.04f; + + float bird = 0f; + for (int b = 0; b < birdCount; b++) + { + if (t >= birdTimes[b] && t < birdTimes[b] + 0.2f) + { + float localT = t - birdTimes[b]; + int localSample = SecondsToSamples(localT); + float env = Decay(localSample, SecondsToSamples(0.2f), 12f); + bird += SinWave(1500f + 300f * SinWave(20f, i), i) * env * 0.04f; + } + } + + data[i] = bg + bird; + } + + return CreateClip("Ambient_DeepForest", data, true); + } + + /// 수중 환경음: 거품 + 저음 드론 (20초). + private static AudioClip GenerateUnderwaterAmbient() + { + const float durationSec = 20f; + int totalSamples = SecondsToSamples(durationSec); + float[] data = new float[totalSamples]; + System.Random rng = new System.Random(330); + System.Random noiseRng = new System.Random(331); + + int bubbleCount = 15; + float[] bubbleTimes = new float[bubbleCount]; + for (int b = 0; b < bubbleCount; b++) + bubbleTimes[b] = (float)(rng.NextDouble() * durationSec); + + for (int i = 0; i < totalSamples; i++) + { + float t = (float)i / SampleRate; + float drone = SinWave(80f, i) * 0.06f; + float water = Noise(noiseRng) * 0.025f; + if (i > 0) water = data[i - 1] * 0.9f + water * 0.1f; + + float bubble = 0f; + for (int b = 0; b < bubbleCount; b++) + { + if (t >= bubbleTimes[b] && t < bubbleTimes[b] + 0.15f) + { + float localT = t - bubbleTimes[b]; + int localSample = SecondsToSamples(localT); + float env = Decay(localSample, SecondsToSamples(0.15f), 20f); + bubble += SinWave(400f + localT * 400f, i) * env * 0.05f; + } + } + + data[i] = drone + water + bubble; + } + + return CreateClip("Ambient_Underwater", data, true); + } + + /// 안개 늪: 미스터리한 윙윙 + 물 (20초). + private static AudioClip GenerateFogAmbient() + { + const float durationSec = 20f; + int totalSamples = SecondsToSamples(durationSec); + float[] data = new float[totalSamples]; + System.Random noiseRng = new System.Random(341); + + for (int i = 0; i < totalSamples; i++) + { + float t = (float)i / SampleRate; + float mistDrone = SinWave(110f + 30f * SinWave(0.2f, i), i) * 0.05f; + float wisp = SinWave(220f, i) * 0.03f * (0.5f + 0.5f * SinWave(0.3f, i)); + float bg = Noise(noiseRng) * 0.025f; + data[i] = mistDrone + wisp + bg; + } + + return CreateClip("Ambient_Fog", data, true); + } + + /// 갈대밭: 바람에 흔들리는 갈대 (20초). + private static AudioClip GenerateReedsAmbient() + { + const float durationSec = 20f; + int totalSamples = SecondsToSamples(durationSec); + float[] data = new float[totalSamples]; + System.Random noiseRng = new System.Random(351); + + for (int i = 0; i < totalSamples; i++) + { + float windEnv = 0.3f + 0.7f * (0.5f + 0.5f * SinWave(0.4f, i)); + float rustleNoise = Noise(noiseRng) * 0.05f * windEnv; + if (i > 0) rustleNoise = data[i - 1] * 0.7f + rustleNoise * 0.3f; + data[i] = rustleNoise; + } + + return CreateClip("Ambient_Reeds", data, true); + } + + /// 산 정상: 바람 휘이잉 (20초). + private static AudioClip GeneratePeakAmbient() + { + const float durationSec = 20f; + int totalSamples = SecondsToSamples(durationSec); + float[] data = new float[totalSamples]; + System.Random noiseRng = new System.Random(361); + + for (int i = 0; i < totalSamples; i++) + { + float windPower = 0.5f + 0.5f * SinWave(0.15f, i); + float wind = Noise(noiseRng) * 0.06f * windPower; + if (i > 0) wind = data[i - 1] * 0.75f + wind * 0.25f; + float whistle = SinWave(280f + 80f * SinWave(0.5f, i), i) * 0.02f * windPower; + data[i] = wind + whistle; + } + + return CreateClip("Ambient_Peak", data, true); + } + + /// 꽃밭: 평화로운 새소리 + 벌 윙윙 (20초). + private static AudioClip GenerateFlowerMazeAmbient() + { + const float durationSec = 20f; + int totalSamples = SecondsToSamples(durationSec); + float[] data = new float[totalSamples]; + System.Random rng = new System.Random(370); + System.Random noiseRng = new System.Random(371); + + int birdCount = 14; + float[] birdTimes = new float[birdCount]; + float[] birdFreqs = new float[birdCount]; + for (int b = 0; b < birdCount; b++) + { + birdTimes[b] = (float)(rng.NextDouble() * durationSec); + birdFreqs[b] = 2200f + (float)(rng.NextDouble() * 1500f); + } + + for (int i = 0; i < totalSamples; i++) + { + float t = (float)i / SampleRate; + float bg = Noise(noiseRng) * 0.02f; + float beeBuzz = SinWave(220f, i) * 0.015f * (0.5f + 0.5f * SinWave(0.7f, i)); + + float bird = 0f; + for (int b = 0; b < birdCount; b++) + { + if (t >= birdTimes[b] && t < birdTimes[b] + 0.12f) + { + float localT = t - birdTimes[b]; + int localSample = SecondsToSamples(localT); + float env = Decay(localSample, SecondsToSamples(0.12f), 18f); + bird += SinWave(birdFreqs[b] * (1f + 0.05f * SinWave(40f, i)), i) * env * 0.06f; + } + } + + data[i] = bg + bird + beeBuzz; + } + + return CreateClip("Ambient_FlowerMaze", data, true); + } + + /// 온실: 잔잔한 물 + 환풍 (20초). + private static AudioClip GenerateGreenhouseAmbient() + { + const float durationSec = 20f; + int totalSamples = SecondsToSamples(durationSec); + float[] data = new float[totalSamples]; + System.Random noiseRng = new System.Random(381); + + for (int i = 0; i < totalSamples; i++) + { + float fanHum = SinWave(120f, i) * 0.04f + SinWave(180f, i) * 0.02f; + float bg = Noise(noiseRng) * 0.02f; + if (i > 0) bg = data[i - 1] * 0.8f + bg * 0.2f; + data[i] = fanHum + bg; + } + + return CreateClip("Ambient_Greenhouse", data, true); + } + + /// 사원: 신비로운 메아리 + 종소리 (20초). + private static AudioClip GenerateTempleAmbient() + { + const float durationSec = 20f; + int totalSamples = SecondsToSamples(durationSec); + float[] data = new float[totalSamples]; + System.Random rng = new System.Random(390); + + int bellCount = 4; + float[] bellTimes = new float[bellCount]; + for (int b = 0; b < bellCount; b++) + bellTimes[b] = (float)(rng.NextDouble() * durationSec); + + for (int i = 0; i < totalSamples; i++) + { + float t = (float)i / SampleRate; + + // 신비한 패드 + float pad = (SinWave(165f, i) + SinWave(220f, i) + SinWave(330f, i)) * 0.025f; + + // 메아리 종 + float bell = 0f; + for (int b = 0; b < bellCount; b++) + { + if (t >= bellTimes[b]) + { + float localT = t - bellTimes[b]; + if (localT < 3f) + { + int localSample = SecondsToSamples(localT); + float env = Decay(localSample, SecondsToSamples(3f), 1.5f); + bell += (SinWave(523f, i) + SinWave(659f, i) * 0.6f) * 0.08f * env; + } + } + } + + data[i] = pad + bell; + } + + ApplySimpleReverb(data, 0.55f, 150); + return CreateClip("Ambient_Temple", data, true); + } + + /// 낮 환경음: 밝은 새소리 + 부드러운 바람 (20초). + private static AudioClip GenerateDayAmbient() + { + const float durationSec = 20f; + int totalSamples = SecondsToSamples(durationSec); + float[] data = new float[totalSamples]; + System.Random rng = new System.Random(400); + System.Random noiseRng = new System.Random(401); + + int birdCount = 10; + float[] birdTimes = new float[birdCount]; + float[] birdFreqs = new float[birdCount]; + for (int b = 0; b < birdCount; b++) + { + birdTimes[b] = (float)(rng.NextDouble() * durationSec); + birdFreqs[b] = 2500f + (float)(rng.NextDouble() * 1500f); + } + + for (int i = 0; i < totalSamples; i++) + { + float t = (float)i / SampleRate; + float breeze = Noise(noiseRng) * 0.025f; + if (i > 0) breeze = data[i - 1] * 0.6f + breeze * 0.4f; + + float bird = 0f; + for (int b = 0; b < birdCount; b++) + { + if (t >= birdTimes[b] && t < birdTimes[b] + 0.18f) + { + float localT = t - birdTimes[b]; + int localSample = SecondsToSamples(localT); + float env = Decay(localSample, SecondsToSamples(0.18f), 14f); + float vib = 1f + 0.08f * SinWave(35f, i); + bird += SinWave(birdFreqs[b] * vib, i) * env * 0.07f; + } + } + + data[i] = breeze + bird; + } + + return CreateClip("Ambient_Day", data, true); + } } } diff --git a/Assets/Scripts/Core/RegionDefinitions.cs b/Assets/Scripts/Core/RegionDefinitions.cs new file mode 100644 index 0000000..8f014d0 --- /dev/null +++ b/Assets/Scripts/Core/RegionDefinitions.cs @@ -0,0 +1,344 @@ +using UnityEngine; +using InsectGame.Data; + +namespace InsectGame.Core +{ + /// + /// 전 지역(RegionData[]) 정의를 PlaySceneBootstrap 모놀리스에서 분리. + /// 데이터 생성만 담당 — 부작용/의존성 없음. CreateAll()이 매 호출마다 새 인스턴스 반환. + /// + public static class RegionDefinitions + { + public static RegionData[] CreateAll() + { + return new RegionData[] + { + // ── 초원: Lv.1~10, 입문 지역, Common/Uncommon 위주 ── + new RegionData + { + regionId = "meadow", + displayName = "초원", + description = "평화로운 초원 — 흔한 곤충이 많아 초보자에게 적합합니다.", + themeColor = new Color(0.4f, 0.8f, 0.3f), + centerPosition = Vector3.zero, + radius = 50f, + requiredLevel = 1, + insectIds = new[] + { + "beetle_basic", "bee_worker", "cricket_field", "ant_soldier", + "grasshopper_green", "ladybug_seven", "caterpillar_green", "aphid_colony", + "moth_brown", "beetle_dung" + }, + guardianInsectId = "mantis_green", + guardianDisplayName = "초원의 수호자 사마귀", + guardianLevel = 13, + subAreas = new SubAreaData[] + { + new SubAreaData + { + subAreaId = "meadow_cave", + displayName = "초원 동굴", + description = "초원 아래 숨겨진 동굴 — 어둠 속 곤충이 서식합니다.", + centerPosition = new Vector3(15f, 0f, 25f), + radius = 10f, + exclusiveInsectIds = new[] { "centipede_common", "earwig_common", "pill_bug_garden" }, + minLevel = 3, + maxLevel = 10, + environmentType = "cave" + }, + new SubAreaData + { + subAreaId = "meadow_pond", + displayName = "숨겨진 웅덩이", + description = "초원 한쪽에 숨겨진 작은 웅덩이 — 물가 곤충이 출현합니다.", + centerPosition = new Vector3(-20f, 0f, 15f), + radius = 8f, + exclusiveInsectIds = new[] { "mosquito_common", "damselfly_blue" }, + minLevel = 2, + maxLevel = 8, + environmentType = "pond" + } + } + }, + // ── 연못: Lv.6~16, 수서곤충 + 희귀종 등장 ── + new RegionData + { + regionId = "pond", + displayName = "연못", + description = "물가에 사는 곤충들의 서식지 — 수서곤충과 희귀종이 출현합니다.", + themeColor = new Color(0.3f, 0.6f, 1f), + centerPosition = new Vector3(100f, 0f, 30f), + radius = 45f, + requiredLevel = 6, + insectIds = new[] + { + "dragonfly_lake", "water_strider_pond", "fly_house", + "dragonfly_emperor" + }, + guardianInsectId = "dragonfly_emperor", + guardianDisplayName = "연못의 파수꾼 왕잠자리", + guardianLevel = 20, + subAreas = new SubAreaData[] + { + new SubAreaData + { + subAreaId = "pond_deep", + displayName = "연못 깊은 곳", + description = "연못 깊숙한 곳 — 수중 곤충만이 살아남을 수 있습니다.", + centerPosition = new Vector3(105f, 0f, 25f), + radius = 10f, + exclusiveInsectIds = new[] { "diving_beetle_deep", "diving_beetle_small" }, + minLevel = 8, + maxLevel = 16, + environmentType = "underwater" + }, + new SubAreaData + { + subAreaId = "pond_reeds", + displayName = "갈대 밀림", + description = "갈대가 빽빽이 우거진 곳 — 야행성 곤충이 숨어있습니다.", + centerPosition = new Vector3(90f, 0f, 40f), + radius = 12f, + exclusiveInsectIds = new[] { "firefly_blue", "cicada_evening" }, + minLevel = 6, + maxLevel = 14, + environmentType = "reeds" + } + } + }, + // ── 숲: Lv.12~24, 강력한 곤충 ── + new RegionData + { + regionId = "forest", + displayName = "숲", + description = "울창한 숲 속 강력한 곤충이 서식 — 높은 레벨의 도전이 필요합니다.", + themeColor = new Color(0.2f, 0.5f, 0.15f), + centerPosition = new Vector3(-80f, 0f, 80f), + radius = 55f, + requiredLevel = 12, + insectIds = new[] + { + "stag_beetle", "rhinoceros_beetle", "cicada_summer", "moth_night", + "mantis_green", "longhorn_beetle", "stick_insect_long", "beetle_longhorn_rosalia", + "hornet_asian" + }, + guardianInsectId = "beetle_hercules", + guardianDisplayName = "숲의 문지기 헤라클레스", + guardianLevel = 28, + subAreas = new SubAreaData[] + { + new SubAreaData + { + subAreaId = "forest_deep", + displayName = "깊은 숲", + description = "빛이 닿지 않는 깊은 숲 — 유령 곤충과 거대 나방이 출현합니다.", + centerPosition = new Vector3(-90f, 0f, 95f), + radius = 14f, + exclusiveInsectIds = new[] { "mantis_ghost", "leaf_insect_phantom", "atlas_moth_giant" }, + minLevel = 18, + maxLevel = 28, + environmentType = "deep_forest" + }, + new SubAreaData + { + subAreaId = "forest_cave", + displayName = "숲속 동굴", + description = "숲 깊은 곳의 동굴 — 보스급 곤충이 서식합니다.", + centerPosition = new Vector3(-70f, 0f, 70f), + radius = 10f, + exclusiveInsectIds = new[] { "beetle_hercules", "scarab_ancient" }, + minLevel = 15, + maxLevel = 24, + environmentType = "cave" + } + } + }, + // ── 습지: Lv.20~32, 독/어둠 곤충 (신규) ── + new RegionData + { + regionId = "swamp", + displayName = "습지", + description = "안개가 자욱한 습지 — 독을 가진 곤충과 어둠의 포식자가 서식합니다.", + themeColor = new Color(0.25f, 0.35f, 0.2f), + centerPosition = new Vector3(-30f, 0f, -60f), + radius = 45f, + requiredLevel = 20, + insectIds = new[] + { + "centipede_common", "earwig_common", "mosquito_common", "pill_bug_garden", + "damselfly_blue", "firefly_blue", "cicada_evening" + }, + guardianInsectId = "mantis_ghost", + guardianDisplayName = "습지의 유령 사마귀", + guardianLevel = 37, + subAreas = new SubAreaData[] + { + new SubAreaData + { + subAreaId = "swamp_fog", + displayName = "안개 습지", + description = "짙은 안개가 시야를 가리는 곳 — 유령 곤충이 출몰합니다.", + centerPosition = new Vector3(-25f, 0f, -70f), + radius = 12f, + exclusiveInsectIds = new[] { "mantis_ghost", "leaf_insect_phantom" }, + minLevel = 24, + maxLevel = 32, + environmentType = "fog" + }, + new SubAreaData + { + subAreaId = "swamp_cave", + displayName = "습지 동굴", + description = "습지 깊숙한 동굴 — 고대 곤충이 잠들어 있습니다.", + centerPosition = new Vector3(-40f, 0f, -55f), + radius = 10f, + exclusiveInsectIds = new[] { "scarab_ancient", "beetle_hercules" }, + minLevel = 22, + maxLevel = 30, + environmentType = "cave" + } + } + }, + // ── 산: Lv.28~40, 고산 곤충 (신규) ── + new RegionData + { + regionId = "mountain", + displayName = "산", + description = "험준한 산악 지형 — 강인한 고산 곤충만이 살아남는 극한 환경입니다.", + themeColor = new Color(0.5f, 0.45f, 0.4f), + centerPosition = new Vector3(-120f, 0f, -30f), + radius = 50f, + requiredLevel = 28, + insectIds = new[] + { + "hornet_asian", "beetle_longhorn_rosalia", "stick_insect_long", + "katydid_leaf", "beetle_click", "spider_garden" + }, + guardianInsectId = "atlas_moth_giant", + guardianDisplayName = "산의 거신 아틀라스나방", + guardianLevel = 45, + subAreas = new SubAreaData[] + { + new SubAreaData + { + subAreaId = "mountain_peak", + displayName = "산 정상", + description = "바람이 휘몰아치는 정상 — 전설급 곤충이 날아다닙니다.", + centerPosition = new Vector3(-130f, 0f, -20f), + radius = 12f, + exclusiveInsectIds = new[] { "dragonfly_ancient", "butterfly_morpho" }, + minLevel = 34, + maxLevel = 42, + environmentType = "peak" + }, + new SubAreaData + { + subAreaId = "mountain_cave", + displayName = "산속 동굴", + description = "산 깊은 곳의 동굴 — 거대 거미와 나방이 서식합니다.", + centerPosition = new Vector3(-110f, 0f, -40f), + radius = 10f, + exclusiveInsectIds = new[] { "spider_golden_orb", "atlas_moth_giant" }, + minLevel = 30, + maxLevel = 38, + environmentType = "cave" + } + } + }, + // ── 꽃밭: Lv.18~35, 희귀 나비 (분기 경로) ── + new RegionData + { + regionId = "garden", + displayName = "꽃밭", + description = "희귀한 나비와 전설의 곤충이 숨어있는 비밀의 정원입니다.", + themeColor = new Color(1f, 0.5f, 0.7f), + centerPosition = new Vector3(60f, 0f, -90f), + radius = 40f, + requiredLevel = 18, + insectIds = new[] + { + "butterfly_azure", "butterfly_monarch", "butterfly_swallowtail", + "luna_moth_silver", "jewel_beetle_gold", "firefly_glow", + "wasp_paper", "butterfly_cabbage" + }, + guardianInsectId = "butterfly_swallowtail", + guardianDisplayName = "꽃밭의 문지기 호랑나비", + guardianLevel = 13, + subAreas = new SubAreaData[] + { + new SubAreaData + { + subAreaId = "garden_maze", + displayName = "꽃 미로", + description = "거대한 꽃으로 이루어진 미로 — 전설급 곤충이 숨어있습니다.", + centerPosition = new Vector3(55f, 0f, -100f), + radius = 12f, + exclusiveInsectIds = new[] { "butterfly_alexandras", "mantis_orchid" }, + minLevel = 30, + maxLevel = 40, + environmentType = "flower_maze" + }, + new SubAreaData + { + subAreaId = "garden_greenhouse", + displayName = "온실", + description = "유리로 된 온실 — 최상위 곤충이 서식합니다.", + centerPosition = new Vector3(70f, 0f, -80f), + radius = 10f, + exclusiveInsectIds = new[] { "spider_golden_orb", "beetle_golden_stag" }, + minLevel = 25, + maxLevel = 35, + environmentType = "greenhouse" + } + } + }, + // ── 고대 유적: Lv.36~50, 전설급 (신규) ── + new RegionData + { + regionId = "ruins", + displayName = "고대 유적", + description = "잊혀진 문명의 유적 — 전설급 곤충만이 서식하는 최종 지역입니다.", + themeColor = new Color(0.4f, 0.35f, 0.3f), + centerPosition = new Vector3(0f, 0f, 140f), + radius = 45f, + requiredLevel = 36, + insectIds = new[] + { + "mantis_orchid", "butterfly_alexandras", "beetle_golden_stag", + "dragonfly_ancient", "butterfly_morpho" + }, + guardianInsectId = null, + guardianDisplayName = null, + guardianLevel = 0, + subAreas = new SubAreaData[] + { + new SubAreaData + { + subAreaId = "ruins_temple", + displayName = "고대 신전", + description = "유적 깊숙한 신전 — 필드 전설 곤충만이 출현합니다.", + centerPosition = new Vector3(5f, 0f, 150f), + radius = 12f, + exclusiveInsectIds = new[] { "mantis_orchid", "butterfly_alexandras" }, + minLevel = 40, + maxLevel = 50, + environmentType = "temple" + }, + new SubAreaData + { + subAreaId = "ruins_underground", + displayName = "유적 지하", + description = "유적 아래 봉인된 지하 — 고대 곤충이 잠들어 있습니다.", + centerPosition = new Vector3(-10f, 0f, 135f), + radius = 10f, + exclusiveInsectIds = new[] { "beetle_hercules", "leaf_insect_phantom" }, + minLevel = 38, + maxLevel = 48, + environmentType = "underground" + } + } + } + }; + } + } +} diff --git a/Assets/Scripts/Core/RegionDefinitions.cs.meta b/Assets/Scripts/Core/RegionDefinitions.cs.meta new file mode 100644 index 0000000..6ac0e0e --- /dev/null +++ b/Assets/Scripts/Core/RegionDefinitions.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1abfaf1db3ea735439336fcee19a83ac \ No newline at end of file diff --git a/Assets/Scripts/Core/RegionManager.cs b/Assets/Scripts/Core/RegionManager.cs index 07c4bb2..c7a4b58 100644 --- a/Assets/Scripts/Core/RegionManager.cs +++ b/Assets/Scripts/Core/RegionManager.cs @@ -14,6 +14,15 @@ public class RegionManager : MonoBehaviour private HashSet unlockedRegions = new HashSet(); private HashSet defeatedGuardians = new HashSet(); + private Transform cachedPlayerTransform; // Update 매 프레임 GameObject.Find 회피 + + // SubArea 진입 시 SubAreaWorldBuilder가 플레이어를 (2000,0,2000)로 텔레포트하므로 + // Update의 위치 기반 SubArea 판정이 false가 되어 SubAreaChanged(null) 무한 토글이 발생. + // sticky=true 동안 위치 판정 자체를 스킵 → SubAreaWorldBuilder가 명시적 Exit 트리거. + private bool subAreaSticky; + private string lastExitedSubAreaId; + private float lastExitedAtTime; + private const float SubAreaReentryCooldown = 1.5f; private const string UnlockKey = "InsectGame.UnlockedRegions"; private const string GuardianKey = "InsectGame.DefeatedGuardians"; @@ -21,9 +30,38 @@ public class RegionManager : MonoBehaviour public RegionData[] Regions => regions; public RegionData CurrentRegion => currentRegion; public SubAreaData CurrentSubArea => currentSubArea; + public bool SubAreaSticky => subAreaSticky; + + // 사용자가 영역 안에 있지만 아직 진입 안 한 상태. SubAreaProximityChanged로 UI 표시. + // 옛은 ContainsPoint 시 SubAreaChanged 자동 발화 → 자동 진입. 사용자 명시 요청: [E] 키 선택. + private SubAreaData nearbySubArea; + public SubAreaData NearbySubArea => nearbySubArea; public event System.Action RegionChanged; public event System.Action SubAreaChanged; + public event System.Action SubAreaProximityChanged; + + public void SetSubAreaSticky(bool sticky, string exitedId = null) + { + subAreaSticky = sticky; + if (!sticky && !string.IsNullOrEmpty(exitedId)) + { + lastExitedSubAreaId = exitedId; + lastExitedAtTime = Time.time; + } + } + + /// F2 또는 외부 트리거로 SubArea 강제 종료 — sticky 풀고 즉시 SubAreaChanged(null) 발화. + public void ForceExitSubArea() + { + if (currentSubArea == null) return; + string exitedId = currentSubArea.subAreaId; + currentSubArea = null; + subAreaSticky = false; + lastExitedSubAreaId = exitedId; + lastExitedAtTime = Time.time; + SubAreaChanged?.Invoke(null); + } public void Initialize(RegionData[] regionList) { @@ -40,10 +78,19 @@ private void Update() { if (regions == null || regions.Length == 0) return; - GameObject player = GameObject.Find("Player"); - if (player == null) return; + // 플레이어 transform 캐싱 (매 프레임 GameObject.Find 비용 회피) + if (cachedPlayerTransform == null) + { + GameObject p = GameObject.Find("Player"); + if (p == null) return; + cachedPlayerTransform = p.transform; + } + + // SubArea sticky 모드: 텔레포트 좌표(2000,0,2000)에 있는 동안 위치 기반 판정 스킵 + // (SubAreaWorldBuilder가 명시적으로 SetSubAreaSticky(false)를 호출할 때까지 유지) + if (subAreaSticky) return; - Vector3 pos = player.transform.position; + Vector3 pos = cachedPlayerTransform.position; RegionData found = null; foreach (var r in regions) { @@ -74,10 +121,60 @@ private void Update() } } - if (foundSub != currentSubArea) + // 방금 Exit한 SubArea로의 자동 재진입 차단 (쿨다운 1.5초) + if (foundSub != null + && foundSub.subAreaId == lastExitedSubAreaId + && Time.time - lastExitedAtTime < SubAreaReentryCooldown) { - currentSubArea = foundSub; - SubAreaChanged?.Invoke(currentSubArea); + foundSub = null; + } + + // 옛은 currentSubArea를 자동 설정해 SubAreaChanged 발화 → 자동 진입. + // 새는 nearbySubArea만 갱신, 사용자가 [E] 키로 RequestEnterSubArea() 호출해야 진입. + // currentSubArea는 EnterSubArea/Exit 시점에만 변경됨. + if (foundSub != nearbySubArea) + { + nearbySubArea = foundSub; + SubAreaProximityChanged?.Invoke(nearbySubArea); + } + } + + /// 사용자 [E] 키 또는 UI 버튼 트리거 — nearbySubArea로 명시적 진입. + public void RequestEnterSubArea() + { + if (nearbySubArea == null || currentSubArea != null) return; + currentSubArea = nearbySubArea; + SubAreaChanged?.Invoke(currentSubArea); + PlayerPrefs.SetString(GameConstants.PrefsKeys.LastSubAreaId, currentSubArea.subAreaId); + PlayerPrefs.Save(); + } + + // --- SubArea 재진입 자동 복귀 --- + + public void RestoreLastSubArea(Transform playerTransform) + { + if (playerTransform == null || regions == null) return; + string lastId = PlayerPrefs.GetString(GameConstants.PrefsKeys.LastSubAreaId, ""); + if (string.IsNullOrEmpty(lastId)) return; + + foreach (var r in regions) + { + if (r == null || r.subAreas == null) continue; + foreach (var sub in r.subAreas) + { + if (sub != null && sub.subAreaId == lastId) + { + Vector3 dest = sub.centerPosition; + dest.y = playerTransform.position.y; + playerTransform.position = dest; + + // 첫 Update 전에 currentSubArea를 즉시 설정 → PlayerMovement의 region 가드 회귀 차단 + currentRegion = r; + currentSubArea = sub; + SubAreaChanged?.Invoke(currentSubArea); + return; + } + } } } @@ -86,6 +183,9 @@ private void Update() public bool IsRegionAccessible(RegionData region) { if (region == null) return false; + // 마스터 계정은 모든 리전 우회 — AuthManager.ApplyMasterPrivileges가 PlayerPrefs를 갱신하지만 + // RegionManager.LoadUnlockState 이후 마스터 로그인 시 HashSet에 반영 안 되는 race 차단. + if (AuthManager.Instance != null && AuthManager.Instance.IsMasterAccount) return true; if (region.regionId == "meadow") return true; return unlockedRegions.Contains(region.regionId); } @@ -121,6 +221,10 @@ public bool IsGuardianDefeated(string regionId) public void DefeatGuardian(string regionId) { + // 중복 격파 가드 — BattleScreenUI.CheckGuardianDefeat가 IsGuardianDefeated 가드 후 호출하지만 + // 명시적 idempotent 보장 + SaveUnlockState 중복 PlayerPrefs.Save 비용 차단. + if (defeatedGuardians.Contains(regionId)) return; + defeatedGuardians.Add(regionId); string nextRegion = GetNextRegionId(regionId); @@ -198,8 +302,11 @@ private string GetPreviousRegionId(string regionId) private void LoadUnlockState() { + // RemoveEmptyEntries — 옛은 "meadow,," 같은 문자열에서 빈 항목이 HashSet에 누적되어 + // SaveUnlockState 시 string.Join이 ",,meadow,," 형태로 PlayerPrefs에 잔존. string saved = PlayerPrefs.GetString(UnlockKey, "meadow"); - unlockedRegions = new HashSet(saved.Split(',')); + unlockedRegions = new HashSet( + saved.Split(new[] { ',' }, System.StringSplitOptions.RemoveEmptyEntries)); string guardians = PlayerPrefs.GetString(GuardianKey, ""); defeatedGuardians = new HashSet( diff --git a/Assets/Scripts/Core/RegionTerrainBuilder.cs b/Assets/Scripts/Core/RegionTerrainBuilder.cs new file mode 100644 index 0000000..6d58e6c --- /dev/null +++ b/Assets/Scripts/Core/RegionTerrainBuilder.cs @@ -0,0 +1,665 @@ +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 각 리전에 게임 필드 느낌의 지형을 생성합니다. + /// 언덕/절벽/길/바위/나무/물 등으로 각 리전을 구분되는 공간으로 만듭니다. + /// EnsureGround() 뒤에 호출됩니다. + /// + public class RegionTerrainBuilder : MonoBehaviour + { + public void BuildAllRegions(RegionData[] regions) + { + foreach (var r in regions) + { + Vector3 c = r.centerPosition; + float rad = r.radius; + switch (r.regionId) + { + case "meadow": BuildMeadowTerrain(c, rad); break; + case "pond": BuildPondTerrain(c, rad); break; + case "forest": BuildForestTerrain(c, rad); break; + case "swamp": BuildSwampTerrain(c, rad); break; + case "mountain": BuildMountainTerrain(c, rad); break; + case "garden": BuildGardenTerrain(c, rad); break; + case "ruins": BuildRuinsTerrain(c, rad); break; + } + } + // 옛은 리전 경계가 빈 공간이라 어디서든 자유 이동 가능 + "네모 박스" 인상. + // 각 리전 외곽에 fence + 인접 리전으로 향하는 gateway(좁은 통로) 생성. + BuildBoundaries(regions); + } + + // ======= 리전 경계 fence + gateway ======= + // 각 리전 외곽 원주에 환경별 fence(돌담/나무/바위 등). 인접 리전(중심거리 ≤ r1+r2+30m) + // 방향의 gateway angle에는 fence 빠뜨려 좁은 통로 형성. collider 보존으로 통과 차단. + public void BuildBoundaries(RegionData[] regions) + { + if (regions == null || regions.Length == 0) return; + const float NeighborThresholdExtra = 30f; + const float DefaultGatewayWidthDeg = 22f; // 약 5m gateway (r=50 기준 호 길이) + for (int i = 0; i < regions.Length; i++) + { + RegionData r = regions[i]; + if (r == null) continue; + + // 인접 리전 자동 검출 → gateway angle 목록 + var gateways = new System.Collections.Generic.List<(float angleDeg, float widthDeg)>(); + // RegionData.connections가 명시되면 우선 사용 (수동 통제) + if (r.connections != null) + { + foreach (var conn in r.connections) + { + if (conn == null || string.IsNullOrEmpty(conn.targetRegionId)) continue; + // gatewayWidth(m) → 각도 변환: 호 = width, 반지름 = r.radius → arc deg = width/radius * Rad2Deg + float widthDeg = Mathf.Min(60f, conn.gatewayWidth / Mathf.Max(1f, r.radius) * Mathf.Rad2Deg); + gateways.Add((NormalizeAngle(conn.gatewayAngle), Mathf.Max(8f, widthDeg))); + } + } + else + { + // 자동 검출 — 가장 가까운 인접 리전 1곳만 gateway (사용자 명시 요청: 각 리전 외부 입구 1곳). + // 옛은 N개 인접 모두 gateway → "어디든 들어갈 수 있다" 인상. + RegionData closest = null; + float closestDist = float.MaxValue; + for (int j = 0; j < regions.Length; j++) + { + if (i == j) continue; + RegionData other = regions[j]; + if (other == null) continue; + Vector3 d = other.centerPosition - r.centerPosition; + float dist = new Vector2(d.x, d.z).magnitude; + if (dist > r.radius + other.radius + NeighborThresholdExtra) continue; + if (dist < closestDist) { closest = other; closestDist = dist; } + } + if (closest != null) + { + Vector3 dc = closest.centerPosition - r.centerPosition; + float angle = Mathf.Atan2(dc.z, dc.x) * Mathf.Rad2Deg; + gateways.Add((NormalizeAngle(angle), DefaultGatewayWidthDeg)); + } + } + + BuildFenceArc(r, gateways); + } + } + + private static float NormalizeAngle(float deg) + { + float a = deg % 360f; + if (a < 0f) a += 360f; + return a; + } + + private static bool IsInGateway(float angleDeg, + System.Collections.Generic.List<(float angleDeg, float widthDeg)> gateways) + { + for (int g = 0; g < gateways.Count; g++) + { + float diff = Mathf.Abs(Mathf.DeltaAngle(angleDeg, gateways[g].angleDeg)); + if (diff <= gateways[g].widthDeg * 0.5f) return true; + } + return false; + } + + // 리전 외곽 원주 fence — 각도 sample마다 fence post 배치. gateway angle 범위는 빈 공간. + private void BuildFenceArc(RegionData r, + System.Collections.Generic.List<(float angleDeg, float widthDeg)> gateways) + { + Material fenceMat = GetFenceMaterial(r.regionId); + Material gatewayMarkerMat = Mat(new Color(1f, 0.85f, 0.3f)); + float fenceR = r.radius - 1f; // 외곽 살짝 안쪽 + int segments = 60; // 6° 간격 (60 × 6 = 360°) + for (int s = 0; s < segments; s++) + { + float angDeg = (360f / segments) * s; + if (IsInGateway(angDeg, gateways)) continue; + float rad = angDeg * Mathf.Deg2Rad; + Vector3 pos = r.centerPosition + new Vector3(Mathf.Cos(rad) * fenceR, 0f, Mathf.Sin(rad) * fenceR); + BuildFencePost(pos, angDeg, fenceMat, r.regionId); + } + // gateway 위치에 노란 표지등(시각 anchor) — 통과 가능 시각 신호 + for (int g = 0; g < gateways.Count; g++) + { + float rad = gateways[g].angleDeg * Mathf.Deg2Rad; + Vector3 pos = r.centerPosition + new Vector3(Mathf.Cos(rad) * fenceR, 0.5f, Mathf.Sin(rad) * fenceR); + GameObject marker = Prim(PrimitiveType.Cylinder, $"GatewayMarker_{r.regionId}_{g}"); + marker.transform.position = pos; + marker.transform.localScale = new Vector3(0.4f, 1.2f, 0.4f); + Apply(marker, gatewayMarkerMat); + Destroy(marker.GetComponent()); + } + } + + private Material GetFenceMaterial(string regionId) + { + switch (regionId) + { + case "meadow": return Mat(new Color(0.55f, 0.45f, 0.3f)); // 돌담 + case "pond": return Mat(new Color(0.35f, 0.5f, 0.3f)); // 갈대 + case "forest": return Mat(new Color(0.25f, 0.18f, 0.1f)); // 나무 wall + case "swamp": return Mat(new Color(0.3f, 0.25f, 0.15f)); // 진흙 둔덕 + case "mountain": return Mat(new Color(0.45f, 0.45f, 0.45f));// 바위 벽 + case "garden": return Mat(new Color(0.3f, 0.6f, 0.3f)); // 생울타리 + case "ruins": return Mat(new Color(0.55f, 0.5f, 0.45f)); // 폐허 벽 + default: return Mat(new Color(0.5f, 0.45f, 0.35f)); + } + } + + private void BuildFencePost(Vector3 pos, float facingDeg, Material mat, string regionId) + { + // 환경에 따라 fence shape 변경: forest는 나무(Cylinder 큼), garden은 생울타리(Cube 낮음), 등 + GameObject post; + Vector3 scale; + switch (regionId) + { + case "forest": + // 옛 (0.5, 2.5, 0.5) 60개는 시야를 막음 — 짧게 변경 (카메라 위로 비침) + post = Prim(PrimitiveType.Cylinder, "FenceTree"); + scale = new Vector3(0.5f, 1.5f, 0.5f); + break; + case "mountain": + case "ruins": + // 옛 scale (1.6, 2, 1.6) 60개는 region 외곽 둘러싸서 카메라 부감 시점에 큰 사각형 + // layer가 캐릭터 위로 떠 보이는 회귀(사용자 명시 보고, ruins 스크린샷 확인). + // (0.8, 0.8, 0.8) 작은 돌무더기 → 외곽 경계 시각 유지하며 layer 인상 제거. + post = Prim(PrimitiveType.Cube, "FenceRock"); + scale = new Vector3(0.8f, 0.8f, 0.8f); + break; + case "garden": + post = Prim(PrimitiveType.Cube, "FenceHedge"); + scale = new Vector3(1.5f, 1.5f, 1.5f); + break; + case "pond": + post = Prim(PrimitiveType.Cylinder, "FenceReed"); + scale = new Vector3(0.15f, 2.5f, 0.15f); + break; + case "swamp": + post = Prim(PrimitiveType.Cube, "FenceMound"); + scale = new Vector3(1.4f, 1.2f, 1.4f); + break; + default: + post = Prim(PrimitiveType.Cube, "FencePost"); + scale = new Vector3(0.4f, 1.5f, 1.2f); + break; + } + post.transform.position = pos + new Vector3(0f, scale.y * 0.5f, 0f); + post.transform.localScale = scale; + post.transform.rotation = Quaternion.Euler(0f, -facingDeg + 90f, 0f); // 원 접선 방향 + Apply(post, mat); + // collider 보존 — PlayerMovement.IsBlockedPosition OverlapSphere가 통과 차단 + } + + + // ======= 초원: 완만한 언덕 + 개울 + 울타리 길 ======= + private void BuildMeadowTerrain(Vector3 c, float rad) + { + Material hillMat = Mat(new Color(0.32f, 0.52f, 0.22f)); + Material pathMat = Mat(new Color(0.55f, 0.48f, 0.32f)); + Material stoneMat = Mat(new Color(0.5f, 0.48f, 0.42f)); + + // 완만한 언덕 5개 (걸어 올라갈 수 있는 경사) + for (int i = 0; i < 5; i++) + { + float a = i * Mathf.PI * 2f / 5f + 0.3f; + float d = rad * Random.Range(0.25f, 0.55f); + Vector3 pos = c + new Vector3(Mathf.Cos(a) * d, 0f, Mathf.Sin(a) * d); + float hs = Random.Range(4f, 8f); + + GameObject hill = Prim(PrimitiveType.Sphere, $"Scenery_MeadowHill_{i}"); + hill.transform.position = pos + new Vector3(0f, -hs * 0.25f, 0f); + hill.transform.localScale = new Vector3(hs * 2f, hs * 0.5f, hs * 2f); + Apply(hill, hillMat); + Destroy(hill.GetComponent()); + } + + // 개울 (얇은 물길) + Material waterMat = Mat(new Color(0.3f, 0.5f, 0.7f, 0.5f)); + SetTransparent(waterMat); + for (int i = 0; i < 4; i++) + { + float t = (float)i / 4f; + Vector3 from = c + new Vector3(-rad * 0.4f, 0.03f, -rad * 0.3f + t * rad * 0.6f); + GameObject creek = Prim(PrimitiveType.Plane, $"Scenery_Creek_{i}"); + creek.transform.position = from; + creek.transform.localScale = new Vector3(0.2f, 1f, 0.15f); + creek.transform.rotation = Quaternion.Euler(0f, 20f, 0f); + Apply(creek, waterMat); + Destroy(creek.GetComponent()); + } + + // 돌담길 (리전 내 메인 경로) + CreateInternalPath(c, c + new Vector3(rad * 0.7f, 0f, 0f), 2f, pathMat, stoneMat); + CreateInternalPath(c, c + new Vector3(0f, 0f, rad * 0.7f), 2f, pathMat, stoneMat); + } + + // ======= 연못: 큰 호수 + 데크/부두 + 갈대 ======= + private void BuildPondTerrain(Vector3 c, float rad) + { + Material waterMat = Mat(new Color(0.15f, 0.35f, 0.6f, 0.6f)); + SetTransparent(waterMat); + Material deckMat = Mat(new Color(0.45f, 0.32f, 0.15f)); + Material reedMat = Mat(new Color(0.4f, 0.5f, 0.2f)); + + // 큰 호수 (리전 중심) + GameObject lake = Prim(PrimitiveType.Cylinder, "Scenery_Lake"); + lake.transform.position = c + new Vector3(3f,0.04f, 2f); + lake.transform.localScale = new Vector3(rad * 0.5f / 5f, 0.02f, rad * 0.5f / 5f); + Apply(lake, waterMat); + Destroy(lake.GetComponent()); + + // 부두/데크 + GameObject deck = Prim(PrimitiveType.Cube, "Scenery_Dock"); + deck.transform.position = c + new Vector3(-rad * 0.15f,0.2f, -rad * 0.2f); + deck.transform.localScale = new Vector3(2f, 0.15f, 6f); + Apply(deck, deckMat); + Destroy(deck.GetComponent()); + // 부두 기둥 + for (int i = 0; i < 4; i++) + { + GameObject post = Prim(PrimitiveType.Cylinder, $"Scenery_DockPost_{i}"); + float z = -rad * 0.2f - 2f + i * 1.8f; + post.transform.position = c + new Vector3(-rad * 0.15f + (i % 2 == 0 ? -0.8f : 0.8f), -0.5f, z); + post.transform.localScale = new Vector3(0.1f, 0.7f, 0.1f); + Apply(post, deckMat); + Destroy(post.GetComponent()); + } + + // 갈대 군락 + for (int i = 0; i < 20; i++) + { + float a = Random.Range(0f, Mathf.PI * 2f); + float d = rad * Random.Range(0.2f, 0.35f); + Vector3 pos = c + new Vector3(Mathf.Cos(a) * d + 3f, 0f,Mathf.Sin(a) * d + 2f); + + GameObject reed = Prim(PrimitiveType.Cylinder, $"Scenery_PondReed_{i}"); + reed.transform.position = pos + new Vector3(0f, 1f, 0f); + reed.transform.localScale = new Vector3(0.06f, 1f, 0.06f); + reed.transform.rotation = Quaternion.Euler(Random.Range(-5f, 5f), 0f, Random.Range(-5f, 5f)); + Apply(reed, reedMat); + Destroy(reed.GetComponent()); + } + + // 징검다리 + Material stepMat = Mat(new Color(0.5f, 0.48f, 0.42f)); + for (int i = 0; i < 6; i++) + { + float t = (float)i / 6f; + Vector3 pos = c + new Vector3(3f + t * 8f,0.1f, 2f + Mathf.Sin(t * 3f) * 2f); + GameObject step = Prim(PrimitiveType.Cylinder, $"Scenery_SteppingStone_{i}"); + step.transform.position = pos; + step.transform.localScale = new Vector3(0.5f, 0.05f, 0.5f); + Apply(step, stepMat); + Destroy(step.GetComponent()); + } + } + + // ======= 숲: 나무 군락 + 오솔길 + 통나무 ======= + private void BuildForestTerrain(Vector3 c, float rad) + { + Material trunkMat = Mat(new Color(0.25f, 0.15f, 0.08f)); + Material leafMat = Mat(new Color(0.1f, 0.35f, 0.08f)); + Material darkLeafMat = Mat(new Color(0.06f, 0.22f, 0.04f)); + Material logMat = Mat(new Color(0.35f, 0.22f, 0.1f)); + Material pathMat = Mat(new Color(0.35f, 0.28f, 0.18f)); + Material mossMat = Mat(new Color(0.2f, 0.35f, 0.12f)); + + // 나무 군락 — 사용자 추가 보고 "layer 층 전체가 위에 떠있어 캐릭터/곤충이 가려짐". + // 이전 1.2~2.5 축소로도 부족 → treeH 0.8~1.5(낮은 관목)로 캐릭터 머리(2.2) 한참 아래로 강제. + // 25개 트리가 region 전역에 흩어져 모이면 "위쪽 layer" 인상 회피. + for (int i = 0; i < 25; i++) + { + float a = Random.Range(0f, Mathf.PI * 2f); + float d = Random.Range(5f, rad * 0.65f); + Vector3 pos = c + new Vector3(Mathf.Cos(a) * d, 0f,Mathf.Sin(a) * d); + + float treeH = Random.Range(0.8f, 1.5f); + GameObject trunk = Prim(PrimitiveType.Cylinder, $"Scenery_ForestTree_{i}"); + trunk.transform.position = pos + new Vector3(0f, treeH * 0.5f, 0f); + trunk.transform.localScale = new Vector3(0.35f, treeH * 0.5f, 0.35f); + Apply(trunk, trunkMat); + + // 잎사귀 leafS 1~1.8로 더 작게 → 잎사귀 Y 범위 약 1~2.5m로 캐릭터 머리 이하/근처. + // ShadowsOnly 제거 유지 — 정상 렌더링이라 mesh 보이지만 작아서 카메라 시야 차단 적음. + float leafS = Random.Range(1f, 1.8f); + GameObject leaf = Prim(PrimitiveType.Sphere, $"Scenery_ForestLeaf_{i}"); + leaf.transform.position = pos + new Vector3(0f, treeH + leafS * 0.3f, 0f); + leaf.transform.localScale = new Vector3(leafS, leafS * 0.7f, leafS); + Apply(leaf, i % 3 == 0 ? darkLeafMat : leafMat); + Destroy(leaf.GetComponent()); + } + + // 쓰러진 통나무 (장애물 + 분위기) + for (int i = 0; i < 4; i++) + { + float a = Random.Range(0f, Mathf.PI * 2f); + float d = Random.Range(8f, rad * 0.5f); + Vector3 pos = c + new Vector3(Mathf.Cos(a) * d,0.25f, Mathf.Sin(a) * d); + + GameObject log = Prim(PrimitiveType.Cylinder, $"Scenery_Log_{i}"); + log.transform.position = pos; + log.transform.localScale = new Vector3(0.25f, 2f, 0.25f); + log.transform.rotation = Quaternion.Euler(0f, Random.Range(0f, 360f), 90f); + Apply(log, logMat); + } + + // 이끼 낀 바위 + for (int i = 0; i < 6; i++) + { + float a = Random.Range(0f, Mathf.PI * 2f); + float d = Random.Range(5f, rad * 0.6f); + Vector3 pos = c + new Vector3(Mathf.Cos(a) * d, 0f,Mathf.Sin(a) * d); + float rs = Random.Range(0.6f, 1.5f); + + GameObject rock = Prim(PrimitiveType.Sphere, $"Scenery_ForestRock_{i}"); + rock.transform.position = pos + new Vector3(0f, rs * 0.2f, 0f); + rock.transform.localScale = new Vector3(rs * 1.5f, rs * 0.5f, rs); + Apply(rock, mossMat); + } + + // 오솔길 + CreateInternalPath(c + new Vector3(-rad * 0.5f, 0f, 0f), c + new Vector3(rad * 0.5f, 0f, 0f), 1.5f, pathMat, logMat); + } + + // ======= 늪: 수렁 + 고목 + 안개 ======= + private void BuildSwampTerrain(Vector3 c, float rad) + { + Material mudMat = Mat(new Color(0.2f, 0.22f, 0.12f)); + Material waterMat = Mat(new Color(0.12f, 0.2f, 0.15f, 0.5f)); + SetTransparent(waterMat); + Material deadWoodMat = Mat(new Color(0.3f, 0.22f, 0.15f)); + + // 수렁 (진흙 + 물 웅덩이) + for (int i = 0; i < 8; i++) + { + float a = Random.Range(0f, Mathf.PI * 2f); + float d = Random.Range(3f, rad * 0.55f); + Vector3 pos = c + new Vector3(Mathf.Cos(a) * d,0.02f, Mathf.Sin(a) * d); + float ps = Random.Range(2f, 4f); + + GameObject pool = Prim(PrimitiveType.Cylinder, $"Scenery_SwampPool_{i}"); + pool.transform.position = pos; + pool.transform.localScale = new Vector3(ps / 5f, 0.01f, ps / 5f); + Apply(pool, waterMat); + Destroy(pool.GetComponent()); + } + + // 고목 (죽은 나무) + for (int i = 0; i < 8; i++) + { + float a = Random.Range(0f, Mathf.PI * 2f); + float d = Random.Range(5f, rad * 0.6f); + Vector3 pos = c + new Vector3(Mathf.Cos(a) * d, 0f,Mathf.Sin(a) * d); + + GameObject trunk = Prim(PrimitiveType.Cylinder, $"Scenery_DeadTree_{i}"); + float h = Random.Range(2f, 4f); + trunk.transform.position = pos + new Vector3(0f, h * 0.5f, 0f); + trunk.transform.localScale = new Vector3(0.2f, h * 0.5f, 0.2f); + trunk.transform.rotation = Quaternion.Euler(Random.Range(-10f, 10f), 0f, Random.Range(-10f, 10f)); + Apply(trunk, deadWoodMat); + } + + // 징검다리 길 (안전 경로) + Material stepMat = Mat(new Color(0.45f, 0.4f, 0.35f)); + Vector3 pathStart = c + new Vector3(-rad * 0.4f, 0f,0f); + Vector3 pathEnd = c + new Vector3(rad * 0.4f, 0f,0f); + for (int i = 0; i < 10; i++) + { + float t = (float)i / 10f; + Vector3 pos = Vector3.Lerp(pathStart, pathEnd, t); + pos.z += Mathf.Sin(t * Mathf.PI * 2f) * 3f; + + GameObject step = Prim(PrimitiveType.Cylinder, $"Scenery_SwampStep_{i}"); + step.transform.position = pos + new Vector3(0f, 0.1f, 0f); + step.transform.localScale = new Vector3(0.5f, 0.05f, 0.5f); + Apply(step, stepMat); + Destroy(step.GetComponent()); + } + } + + // ======= 산: 바위 절벽 + 계단 길 + 눈 ======= + private void BuildMountainTerrain(Vector3 c, float rad) + { + Material rockMat = Mat(new Color(0.45f, 0.42f, 0.38f)); + Material snowMat = Mat(new Color(0.88f, 0.9f, 0.95f)); + Material pathMat = Mat(new Color(0.42f, 0.38f, 0.32f)); + + // 바위 절벽 (큰 바위) + for (int i = 0; i < 10; i++) + { + float a = Random.Range(0f, Mathf.PI * 2f); + float d = Random.Range(8f, rad * 0.6f); + Vector3 pos = c + new Vector3(Mathf.Cos(a) * d, 0f,Mathf.Sin(a) * d); + float rs = Random.Range(1.5f, 4f); + + GameObject rock = Prim(PrimitiveType.Sphere, $"Scenery_MountainRock_{i}"); + rock.transform.position = pos + new Vector3(0f, rs * 0.3f, 0f); + rock.transform.localScale = new Vector3(rs * 1.8f, rs * 0.8f, rs * 1.2f); + Apply(rock, rockMat); + } + + // 눈 패치 + for (int i = 0; i < 6; i++) + { + float a = Random.Range(0f, Mathf.PI * 2f); + float d = Random.Range(5f, rad * 0.5f); + Vector3 pos = c + new Vector3(Mathf.Cos(a) * d,0.06f, Mathf.Sin(a) * d); + + GameObject snow = Prim(PrimitiveType.Plane, $"Scenery_Snow_{i}"); + snow.transform.position = pos; + float ss = Random.Range(0.3f, 0.6f); + snow.transform.localScale = new Vector3(ss, 1f, ss); + Apply(snow, snowMat); + Destroy(snow.GetComponent()); + } + + // 계단길 (중심에서 바깥으로) + CreateStairPath(c, c + new Vector3(rad * 0.5f, 0f, rad * 0.3f), 8, pathMat); + } + + // ======= 꽃밭: 화단 + 아치 + 나비길 ======= + private void BuildGardenTerrain(Vector3 c, float rad) + { + Material hedgeMat = Mat(new Color(0.18f, 0.45f, 0.12f)); + Material archMat = Mat(new Color(0.7f, 0.7f, 0.7f)); + Material pathMat = Mat(new Color(0.6f, 0.55f, 0.45f)); + Material stoneMat = Mat(new Color(0.55f, 0.52f, 0.48f)); + + // 생울타리 구역 나눔 + for (int i = 0; i < 4; i++) + { + float angle = i * Mathf.PI * 0.5f; + float d = rad * 0.35f; + Vector3 pos = c + new Vector3(Mathf.Cos(angle) * d,0.6f, Mathf.Sin(angle) * d); + + GameObject hedge = Prim(PrimitiveType.Cube, $"Scenery_GardenHedge_{i}"); + hedge.transform.position = pos; + hedge.transform.localScale = new Vector3(rad * 0.35f, 1.2f, 0.5f); + hedge.transform.rotation = Quaternion.Euler(0f, angle * Mathf.Rad2Deg, 0f); + Apply(hedge, hedgeMat); + } + + // 아치 입구 + for (int side = -1; side <= 1; side += 2) + { + GameObject archPillar = Prim(PrimitiveType.Cylinder, $"Scenery_Arch_{side}"); + archPillar.transform.position = c + new Vector3(side * 2f,2f, -rad * 0.35f); + archPillar.transform.localScale = new Vector3(0.2f, 2f, 0.2f); + Apply(archPillar, archMat); + + GameObject archTop = Prim(PrimitiveType.Cube, $"Scenery_ArchTop_{side}"); + archTop.transform.position = c + new Vector3(0f,4.2f, -rad * 0.35f); + archTop.transform.localScale = new Vector3(5f, 0.3f, 0.5f); + Apply(archTop, archMat); + Destroy(archTop.GetComponent()); + } + + // 정원 길 + CreateInternalPath(c + new Vector3(0f, 0f, -rad * 0.5f), c + new Vector3(0f, 0f, rad * 0.5f), 2f, pathMat, stoneMat); + } + + // ======= 유적: 무너진 벽 + 기둥 + 계단 ======= + private void BuildRuinsTerrain(Vector3 c, float rad) + { + Material stoneMat = Mat(new Color(0.4f, 0.38f, 0.32f)); + Material darkStoneMat = Mat(new Color(0.3f, 0.28f, 0.24f)); + Material mossRock = Mat(new Color(0.3f, 0.35f, 0.25f)); + Material pathMat = Mat(new Color(0.38f, 0.35f, 0.3f)); + + // 무너진 벽 — 사용자 추가 보고 "layer 층 전체 위에 떠있음". wh 0.3~0.8 잔해 수준. + for (int i = 0; i < 6; i++) + { + float a = Random.Range(0f, Mathf.PI * 2f); + float d = Random.Range(8f, rad * 0.5f); + Vector3 pos = c + new Vector3(Mathf.Cos(a) * d, 0f,Mathf.Sin(a) * d); + + float wh = Random.Range(0.3f, 0.8f); + float ww = Random.Range(3f, 6f); + GameObject wall = Prim(PrimitiveType.Cube, $"Scenery_RuinWall_{i}"); + wall.transform.position = pos + new Vector3(0f, wh * 0.5f, 0f); + wall.transform.localScale = new Vector3(ww, wh, 0.5f); + wall.transform.rotation = Quaternion.Euler(0f, Random.Range(0f, 360f), Random.Range(-5f, 5f)); + Apply(wall, i % 2 == 0 ? stoneMat : darkStoneMat); + } + + // 기둥 (일부 무너진) — ph 0.7~1.3로 캐릭터 어깨 아래까지만. 옛 1~2도 layer 인상. + for (int i = 0; i < 8; i++) + { + float a = i * Mathf.PI * 2f / 8f; + float d = rad * 0.3f; + Vector3 pos = c + new Vector3(Mathf.Cos(a) * d, 0f,Mathf.Sin(a) * d); + bool fallen = Random.value > 0.6f; + + GameObject pillar = Prim(PrimitiveType.Cylinder, $"Scenery_RuinPillar_{i}"); + if (fallen) + { + pillar.transform.position = pos + new Vector3(0f, 0.3f, 0f); + pillar.transform.localScale = new Vector3(0.4f, 1.5f, 0.4f); + pillar.transform.rotation = Quaternion.Euler(0f, Random.Range(0f, 360f), 85f); + } + else + { + float ph = Random.Range(0.7f, 1.3f); + pillar.transform.position = pos + new Vector3(0f, ph * 0.5f, 0f); + pillar.transform.localScale = new Vector3(0.4f, ph * 0.5f, 0.4f); + } + Apply(pillar, stoneMat); + } + + // 유적 계단 + CreateStairPath(c + new Vector3(0f, 0f, -rad * 0.3f), c, 6, pathMat); + + // 이끼 바위 + for (int i = 0; i < 5; i++) + { + float a = Random.Range(0f, Mathf.PI * 2f); + float d = Random.Range(4f, rad * 0.5f); + Vector3 pos = c + new Vector3(Mathf.Cos(a) * d, 0f,Mathf.Sin(a) * d); + float rs = Random.Range(0.5f, 1.2f); + + GameObject moss = Prim(PrimitiveType.Sphere, $"Scenery_MossRock_{i}"); + moss.transform.position = pos + new Vector3(0f, rs * 0.2f, 0f); + moss.transform.localScale = new Vector3(rs * 1.3f, rs * 0.4f, rs); + Apply(moss, mossRock); + Destroy(moss.GetComponent()); + } + } + + // ======= 공통 유틸 ======= + + private void CreateInternalPath(Vector3 from, Vector3 to, float width, Material pathMat, Material edgeMat) + { + Vector3 dir = to - from; + float dist = new Vector3(dir.x, 0f, dir.z).magnitude; + if (dist < 1f) return; + float angle = Mathf.Atan2(dir.x, dir.z) * Mathf.Rad2Deg; + + int segCount = Mathf.Max(2, Mathf.RoundToInt(dist / 8f)); + for (int i = 0; i < segCount; i++) + { + float t = ((float)i + 0.5f) / segCount; + Vector3 mid = Vector3.Lerp(from, to, t); + float segLen = dist / segCount; + + GameObject path = Prim(PrimitiveType.Plane, $"Scenery_InPath_{i}"); + path.transform.position = mid + new Vector3(0f, 0.06f, 0f); + path.transform.rotation = Quaternion.Euler(0f, angle, 0f); + path.transform.localScale = new Vector3(width / 10f, 1f, segLen / 10f); + Apply(path, pathMat); + Destroy(path.GetComponent()); + } + + // 길 가장자리 돌 + Vector3 perp = new Vector3(-dir.normalized.z, 0f, dir.normalized.x); + for (int i = 0; i < Mathf.RoundToInt(dist / 4f); i++) + { + float t = (float)i / Mathf.Max(1, Mathf.RoundToInt(dist / 4f)); + Vector3 pos = Vector3.Lerp(from, to, t); + for (int side = -1; side <= 1; side += 2) + { + GameObject stone = Prim(PrimitiveType.Sphere, $"Scenery_PathEdge_{i}_{side}"); + float ss = Random.Range(0.15f, 0.3f); + stone.transform.position = pos + perp * (width * 0.55f) * side + new Vector3(0f, ss * 0.15f, 0f); + stone.transform.localScale = new Vector3(ss * 1.3f, ss * 0.3f, ss); + Apply(stone, edgeMat); + Destroy(stone.GetComponent()); + } + } + } + + private void CreateStairPath(Vector3 from, Vector3 to, int stepCount, Material mat) + { + Vector3 dir = to - from; + float yDiff = dir.y; + for (int i = 0; i < stepCount; i++) + { + float t = (float)i / stepCount; + Vector3 pos = Vector3.Lerp(from, to, t); + + GameObject step = Prim(PrimitiveType.Cube, $"Scenery_Stair_{i}"); + step.transform.position = pos + new Vector3(0f, 0.1f, 0f); + step.transform.localScale = new Vector3(3f, 0.2f, 1.5f); + float angle = Mathf.Atan2(dir.x, dir.z) * Mathf.Rad2Deg; + step.transform.rotation = Quaternion.Euler(0f, angle, 0f); + Apply(step, mat); + Destroy(step.GetComponent()); + } + } + + private GameObject Prim(PrimitiveType type, string name) + { + GameObject obj = GameObject.CreatePrimitive(type); + obj.name = name; + return obj; + } + + private void Apply(GameObject obj, Material mat) + { + MeshRenderer mr = obj.GetComponent(); + if (mr != null) mr.material = mat; + } + + private Material Mat(Color color) + { + Shader shader = Shader.Find("Standard"); + if (shader == null) shader = Shader.Find("Universal Render Pipeline/Lit"); + if (shader == null) shader = Shader.Find("Unlit/Color"); + if (shader == null) shader = Shader.Find("Sprites/Default"); + Material mat = shader != null ? new Material(shader) : new Material(Shader.Find("Hidden/InternalErrorShader")); + mat.color = color; + return mat; + } + + private void SetTransparent(Material mat) + { + mat.SetFloat("_Mode", 3); + mat.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha); + mat.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); + mat.SetInt("_ZWrite", 0); + mat.EnableKeyword("_ALPHABLEND_ON"); + mat.renderQueue = 3000; + } + } +} diff --git a/Assets/Scripts/Core/RegionTerrainBuilder.cs.meta b/Assets/Scripts/Core/RegionTerrainBuilder.cs.meta new file mode 100644 index 0000000..25a4cb6 --- /dev/null +++ b/Assets/Scripts/Core/RegionTerrainBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2675fc93792cd0c4e891d3dcf3ab65b6 \ No newline at end of file diff --git a/Assets/Scripts/Core/ShopUIController.cs b/Assets/Scripts/Core/ShopUIController.cs index 9849384..c226426 100644 --- a/Assets/Scripts/Core/ShopUIController.cs +++ b/Assets/Scripts/Core/ShopUIController.cs @@ -62,7 +62,8 @@ private void Start() if (buyLabels != null && index < buyLabels.Length && buyLabels[index] != null) { string itemId = index < itemIds.Length ? itemIds[index] : string.Empty; - ItemData data = !string.IsNullOrEmpty(itemId) ? itemDatabase.FindById(itemId) : null; + // itemDatabase null 가드 — EnsureDatabase의 Resources.Load 실패 시 NRE 차단. + ItemData data = (!string.IsNullOrEmpty(itemId) && itemDatabase != null) ? itemDatabase.FindById(itemId) : null; int price = index < prices.Length ? prices[index] : 0; buyLabels[index].text = data != null ? $"{data.displayName} ({price})" : "구매"; } @@ -93,9 +94,28 @@ private void Buy(int index) int price = index < prices.Length ? prices[index] : 0; if (price > 0) { - bool paid = payWithCoins && allowCoinPayment - ? wallet.SpendCoins(price) - : wallet.SpendGems(price); + bool paid; + if (payWithCoins && allowCoinPayment) + { + paid = wallet.SpendCoins(price); + } + else + { + // 보석 차감은 CashShopManager 경로로 통일 (이중 관리 동기화). + // CashShopManager.AddGems(-price)가 wallet.SpendGems도 함께 호출. + bool hasEnough = CashShopManager.Instance != null + ? CashShopManager.Instance.Gems >= price + : wallet.Gems >= price; + if (hasEnough && CashShopManager.Instance != null) + { + CashShopManager.Instance.AddGems(-price); + paid = true; + } + else + { + paid = wallet.SpendGems(price); + } + } if (!paid) { @@ -103,11 +123,13 @@ private void Buy(int index) { resultText.text = failMessage; } + if (AudioManager.Instance != null) AudioManager.Instance.PlaySFX(SfxType.Error); return; } } inventory.AddItem(itemId, 1); + if (AudioManager.Instance != null) AudioManager.Instance.PlaySFX(SfxType.Purchase); if (resultText != null) { resultText.text = successMessage; diff --git a/Assets/Scripts/Core/SubAreaEnvironment.cs b/Assets/Scripts/Core/SubAreaEnvironment.cs new file mode 100644 index 0000000..123d0ed --- /dev/null +++ b/Assets/Scripts/Core/SubAreaEnvironment.cs @@ -0,0 +1,327 @@ +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.Core +{ + public class SubAreaEnvironment : MonoBehaviour + { + [SerializeField] private RegionManager regionManager; + + private Light directionalLight; + private Camera mainCamera; + + // 기본 환경 (메인 필드) + private Color defaultLightColor = new Color(1f, 0.96f, 0.84f); + private float defaultLightIntensity = 1.2f; + private Quaternion defaultLightRotation = Quaternion.Euler(50f, 30f, 0f); + private Color defaultAmbient = new Color(0.45f, 0.5f, 0.55f); + private Color defaultFogColor = new Color(0.75f, 0.82f, 0.88f); + private bool defaultFogEnabled; + private float defaultFogDensity; + private Color defaultCameraBg; + + // 전환 상태 + private EnvironmentProfile targetProfile; + private EnvironmentProfile currentState; + private float transitionProgress = 1f; + private float transitionSpeed = 2f; + + private bool initialized; + + public void AutoWire(RegionManager rm) + { + if (regionManager != null) + regionManager.SubAreaChanged -= OnSubAreaChanged; + regionManager = rm; + if (regionManager != null) + regionManager.SubAreaChanged += OnSubAreaChanged; + } + + private void OnDisable() + { + if (regionManager != null) + regionManager.SubAreaChanged -= OnSubAreaChanged; + } + + private void Start() + { + CaptureDefaults(); + } + + private void CaptureDefaults() + { + if (initialized) return; + + directionalLight = FindFirstObjectByType(); + mainCamera = Camera.main; + + if (directionalLight != null) + { + defaultLightColor = directionalLight.color; + defaultLightIntensity = directionalLight.intensity; + defaultLightRotation = directionalLight.transform.rotation; + } + + defaultAmbient = RenderSettings.ambientLight; + defaultFogEnabled = RenderSettings.fog; + defaultFogColor = RenderSettings.fogColor; + defaultFogDensity = RenderSettings.fogDensity; + + if (mainCamera != null) + defaultCameraBg = mainCamera.backgroundColor; + + currentState = BuildDefaultProfile(); + targetProfile = currentState; + initialized = true; + } + + private void Update() + { + if (!initialized) return; + if (transitionProgress >= 1f) return; + + transitionProgress = Mathf.Clamp01(transitionProgress + Time.deltaTime * transitionSpeed); + float t = Mathf.SmoothStep(0f, 1f, transitionProgress); + ApplyLerp(currentState, targetProfile, t); + } + + private void OnSubAreaChanged(SubAreaData subArea) + { + if (!initialized) CaptureDefaults(); + + EnvironmentProfile profile; + if (subArea == null) + profile = BuildDefaultProfile(); + else + profile = GetProfileForType(subArea.environmentType); + + // 현재 렌더 상태를 스냅샷으로 캡처 + currentState = SnapshotCurrent(); + targetProfile = profile; + transitionProgress = 0f; + } + + private void ApplyLerp(EnvironmentProfile from, EnvironmentProfile to, float t) + { + if (directionalLight != null) + { + directionalLight.color = Color.Lerp(from.lightColor, to.lightColor, t); + directionalLight.intensity = Mathf.Lerp(from.lightIntensity, to.lightIntensity, t); + directionalLight.transform.rotation = Quaternion.Slerp(from.lightRotation, to.lightRotation, t); + } + + RenderSettings.ambientLight = Color.Lerp(from.ambientColor, to.ambientColor, t); + // 페이드 도중: from이 fog이고 t<1일 때만 유지. to가 fog면 항상 ON. 둘 다 off이면 즉시 false. + RenderSettings.fog = (from.fogEnabled && t < 1f) || to.fogEnabled; + RenderSettings.fogColor = Color.Lerp(from.fogColor, to.fogColor, t); + // to가 fog 없으면 fogDensity를 0으로 보간 (잔여 안개 제거) + float targetDensity = to.fogEnabled ? to.fogDensity : 0f; + float sourceDensity = from.fogEnabled ? from.fogDensity : 0f; + RenderSettings.fogDensity = Mathf.Lerp(sourceDensity, targetDensity, t); + RenderSettings.fogMode = FogMode.Exponential; + + if (mainCamera != null) + mainCamera.backgroundColor = Color.Lerp(from.cameraBg, to.cameraBg, t); + + // 안개 해제: 전환 완료 + 대상이 안개 없음이면 끔 + if (t >= 1f && !to.fogEnabled) + RenderSettings.fog = false; + } + + private EnvironmentProfile SnapshotCurrent() + { + var p = new EnvironmentProfile(); + if (directionalLight != null) + { + p.lightColor = directionalLight.color; + p.lightIntensity = directionalLight.intensity; + p.lightRotation = directionalLight.transform.rotation; + } + p.ambientColor = RenderSettings.ambientLight; + p.fogEnabled = RenderSettings.fog; + p.fogColor = RenderSettings.fogColor; + p.fogDensity = RenderSettings.fogDensity; + p.cameraBg = mainCamera != null ? mainCamera.backgroundColor : Color.black; + return p; + } + + private EnvironmentProfile BuildDefaultProfile() + { + return new EnvironmentProfile + { + lightColor = defaultLightColor, + lightIntensity = defaultLightIntensity, + lightRotation = defaultLightRotation, + ambientColor = defaultAmbient, + fogEnabled = defaultFogEnabled, + fogColor = defaultFogColor, + fogDensity = defaultFogDensity, + cameraBg = defaultCameraBg + }; + } + + private EnvironmentProfile GetProfileForType(string envType) + { + switch (envType) + { + case "cave": + return new EnvironmentProfile + { + lightColor = new Color(0.4f, 0.35f, 0.3f), + lightIntensity = 0.35f, + lightRotation = Quaternion.Euler(80f, 30f, 0f), + ambientColor = new Color(0.08f, 0.06f, 0.05f), + fogEnabled = true, + fogColor = new Color(0.05f, 0.04f, 0.03f), + fogDensity = 0.06f, + cameraBg = new Color(0.02f, 0.02f, 0.02f) + }; + + case "deep_forest": + return new EnvironmentProfile + { + lightColor = new Color(0.5f, 0.7f, 0.35f), + lightIntensity = 0.5f, + lightRotation = Quaternion.Euler(70f, 45f, 0f), + ambientColor = new Color(0.1f, 0.18f, 0.06f), + fogEnabled = true, + fogColor = new Color(0.12f, 0.2f, 0.08f), + fogDensity = 0.04f, + cameraBg = new Color(0.05f, 0.08f, 0.03f) + }; + + case "underwater": + return new EnvironmentProfile + { + lightColor = new Color(0.3f, 0.55f, 0.8f), + lightIntensity = 0.45f, + lightRotation = Quaternion.Euler(85f, 0f, 0f), + ambientColor = new Color(0.08f, 0.15f, 0.25f), + fogEnabled = true, + fogColor = new Color(0.1f, 0.25f, 0.45f), + fogDensity = 0.08f, + cameraBg = new Color(0.04f, 0.1f, 0.2f) + }; + + case "pond": + return new EnvironmentProfile + { + lightColor = new Color(0.7f, 0.85f, 0.95f), + lightIntensity = 0.8f, + lightRotation = Quaternion.Euler(55f, 20f, 0f), + ambientColor = new Color(0.2f, 0.3f, 0.35f), + fogEnabled = true, + fogColor = new Color(0.5f, 0.6f, 0.7f), + fogDensity = 0.015f, + cameraBg = new Color(0.15f, 0.25f, 0.35f) + }; + + case "fog": + return new EnvironmentProfile + { + lightColor = new Color(0.6f, 0.58f, 0.55f), + lightIntensity = 0.4f, + lightRotation = Quaternion.Euler(60f, 30f, 0f), + ambientColor = new Color(0.2f, 0.2f, 0.18f), + fogEnabled = true, + fogColor = new Color(0.55f, 0.52f, 0.48f), + fogDensity = 0.1f, + cameraBg = new Color(0.35f, 0.33f, 0.3f) + }; + + case "reeds": + return new EnvironmentProfile + { + lightColor = new Color(0.85f, 0.8f, 0.55f), + lightIntensity = 0.9f, + lightRotation = Quaternion.Euler(45f, 60f, 0f), + ambientColor = new Color(0.25f, 0.28f, 0.15f), + fogEnabled = true, + fogColor = new Color(0.55f, 0.58f, 0.4f), + fogDensity = 0.02f, + cameraBg = new Color(0.2f, 0.22f, 0.12f) + }; + + case "peak": + return new EnvironmentProfile + { + lightColor = new Color(0.95f, 0.95f, 1f), + lightIntensity = 1.5f, + lightRotation = Quaternion.Euler(35f, 30f, 0f), + ambientColor = new Color(0.4f, 0.42f, 0.5f), + fogEnabled = true, + fogColor = new Color(0.8f, 0.85f, 0.95f), + fogDensity = 0.02f, + cameraBg = new Color(0.5f, 0.55f, 0.7f) + }; + + case "flower_maze": + return new EnvironmentProfile + { + lightColor = new Color(1f, 0.88f, 0.75f), + lightIntensity = 1.1f, + lightRotation = Quaternion.Euler(45f, 50f, 0f), + ambientColor = new Color(0.35f, 0.25f, 0.3f), + fogEnabled = true, + fogColor = new Color(0.85f, 0.7f, 0.75f), + fogDensity = 0.025f, + cameraBg = new Color(0.3f, 0.2f, 0.25f) + }; + + case "greenhouse": + return new EnvironmentProfile + { + lightColor = new Color(0.85f, 1f, 0.8f), + lightIntensity = 1.0f, + lightRotation = Quaternion.Euler(50f, 30f, 0f), + ambientColor = new Color(0.25f, 0.35f, 0.2f), + fogEnabled = false, + fogColor = defaultFogColor, + fogDensity = 0f, + cameraBg = new Color(0.15f, 0.22f, 0.12f) + }; + + case "temple": + return new EnvironmentProfile + { + lightColor = new Color(0.7f, 0.6f, 0.9f), + lightIntensity = 0.5f, + lightRotation = Quaternion.Euler(75f, 10f, 0f), + ambientColor = new Color(0.12f, 0.08f, 0.2f), + fogEnabled = true, + fogColor = new Color(0.2f, 0.15f, 0.3f), + fogDensity = 0.045f, + cameraBg = new Color(0.08f, 0.05f, 0.15f) + }; + + case "underground": + return new EnvironmentProfile + { + lightColor = new Color(0.3f, 0.25f, 0.2f), + lightIntensity = 0.25f, + lightRotation = Quaternion.Euler(85f, 0f, 0f), + ambientColor = new Color(0.05f, 0.04f, 0.03f), + fogEnabled = true, + fogColor = new Color(0.04f, 0.03f, 0.02f), + fogDensity = 0.08f, + cameraBg = new Color(0.01f, 0.01f, 0.01f) + }; + + default: + return BuildDefaultProfile(); + } + } + + private struct EnvironmentProfile + { + public Color lightColor; + public float lightIntensity; + public Quaternion lightRotation; + public Color ambientColor; + public bool fogEnabled; + public Color fogColor; + public float fogDensity; + public Color cameraBg; + } + } +} diff --git a/Assets/Scripts/Core/SubAreaEnvironment.cs.meta b/Assets/Scripts/Core/SubAreaEnvironment.cs.meta new file mode 100644 index 0000000..4c16c6d --- /dev/null +++ b/Assets/Scripts/Core/SubAreaEnvironment.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8ef9f19c63a5a6249a059e8c518eae19 \ No newline at end of file diff --git a/Assets/Scripts/Core/SubAreaWorldBuilder.cs b/Assets/Scripts/Core/SubAreaWorldBuilder.cs new file mode 100644 index 0000000..aba13e7 --- /dev/null +++ b/Assets/Scripts/Core/SubAreaWorldBuilder.cs @@ -0,0 +1,1092 @@ +using InsectGame.Data; +using UnityEngine; +using System.Collections.Generic; + +namespace InsectGame.Core +{ + /// + /// 서브에리어 진입 시 완전히 다른 공간을 프로시저럴 생성합니다. + /// 메인 월드를 숨기고, 별도 위치(2000,0,2000)에 미니 던전/환경을 구축합니다. + /// + public class SubAreaWorldBuilder : MonoBehaviour + { + [SerializeField] private RegionManager regionManager; + [SerializeField] private CameraFollower cameraFollower; + + private GameObject subAreaRoot; + private bool isInSubArea; + private Vector3 savedPlayerPos; + private SubAreaData currentSubArea; + + // UI 알림 — 진입/퇴장 시 화면 상단에 토스트 표시 (3초) + private string notifyText; + private float notifyTimer; + private bool notifyIsEnter; + private GUIStyle notifyStyleCache; + private static readonly Color NotifyEnterCol = new Color(0.3f, 0.85f, 0.5f); + private static readonly Color NotifyExitCol = new Color(0.85f, 0.75f, 0.3f); + private static readonly Color NotifyBgCol = new Color(0f, 0f, 0f, 0.78f); + private static readonly Color NotifyHintCol = new Color(0.7f, 0.75f, 0.85f); + + // Y=0 — 메인 월드 ground와 동일 평면. 옛 Y=0.5는 환경이 캐릭터로부터 위로 분리되어 + // "공중에 떠있는" 인상 회귀(사용자 명시 보고). 캐릭터 부유(Y=0.5 텔레포트 vs floor Y=0)는 + // 메인 월드와 동일 관례이라 시각적 위화감 없음. + private static readonly Vector3 SubAreaOrigin = new Vector3(2000f, 0f, 2000f); + + // SubArea 환경 layer — CameraFollower.ResolveObstruction에서 차폐 제외 (캐릭터 가시성 우선). + // Editor에서 User Layer 31에 "SubAreaEnv" 등록 권고. 미등록 시 fallback layer 31 사용. + private static int subAreaEnvLayerCached = -1; + public static int GetSubAreaEnvLayer() + { + if (subAreaEnvLayerCached < 0) + { + int idx = LayerMask.NameToLayer("SubAreaEnv"); + subAreaEnvLayerCached = idx >= 0 ? idx : 31; + } + return subAreaEnvLayerCached; + } + + private static void SetLayerRecursively(GameObject root, int layer) + { + if (root == null) return; + root.layer = layer; + foreach (Transform child in root.transform) + SetLayerRecursively(child.gameObject, layer); + } + + // 메인 월드 오브젝트 숨김/복원용 + private readonly List hiddenMainObjects = new List(); + // Update의 Y 안전망용 player transform 캐싱 — 매 프레임 GameObject.Find 회귀 차단 + private Transform cachedPlayerTransform; + + public bool IsInSubArea => isInSubArea; + + public void AutoWire(RegionManager rm, CameraFollower cam) + { + if (regionManager != null) + regionManager.SubAreaChanged -= OnSubAreaChanged; + regionManager = rm; + if (regionManager != null) + regionManager.SubAreaChanged += OnSubAreaChanged; + if (cameraFollower == null) cameraFollower = cam; + } + + private void OnDisable() + { + if (regionManager != null) + regionManager.SubAreaChanged -= OnSubAreaChanged; + } + + private void Update() + { + if (notifyTimer > 0f) notifyTimer -= Time.deltaTime; + // F2: SubArea 안일 때 수동 Exit 트리거 (자동 25m 이탈을 기다리지 않음) + if (isInSubArea && Input.GetKeyDown(KeyCode.F2)) RequestExit(); + + // [E]: 메인 월드에서 nearbySubArea 있으면 진입 트리거 (사용자 선택) + if (!isInSubArea && regionManager != null && regionManager.NearbySubArea != null + && Input.GetKeyDown(KeyCode.E)) + { + regionManager.RequestEnterSubArea(); + } + + if (!isInSubArea) return; + + // player transform lazy 캐싱 — 매 프레임 GameObject.Find 회귀 차단 + if (cachedPlayerTransform == null) + { + GameObject p = GameObject.Find("Player"); + if (p == null) return; + cachedPlayerTransform = p.transform; + } + + // Y 안전망 — 바닥 밖 낙하 시 자동 메인 복귀 (외곽 벽/floor 사이 무한 낙하 방지) + if (cachedPlayerTransform.position.y < -3f) + { + ShowNotify("⚠ 바닥 밖으로 떨어져 메인 월드로 복귀", false); + RequestExit(); + return; + } + + // 25m 이상 이탈 시 자동 Exit (입구 외곽으로 걸어가면 자연 복귀) + float dx = cachedPlayerTransform.position.x - SubAreaOrigin.x; + float dz = cachedPlayerTransform.position.z - SubAreaOrigin.z; + if (dx * dx + dz * dz > 25f * 25f) + { + ExitSubArea(); + if (AudioManager.Instance != null) + AudioManager.Instance.SetSubAreaActive(false); + } + } + + private void OnGUI() + { + // F2 OnGUI Event 백업 (Input.GetKeyDown이 focus/IME 이슈로 놓칠 때) + Event e = Event.current; + if (e != null && e.type == EventType.KeyDown && e.keyCode == KeyCode.F2 && isInSubArea) + { + RequestExit(); + e.Use(); + } + + // SubArea 안일 때 우측 상단에 출입 안내 + 토스트 알림 + if (notifyStyleCache == null) + { + notifyStyleCache = new GUIStyle(GUI.skin.label) + { fontSize = 28, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + } + + // 진입/퇴장 토스트 (3초 페이드) + if (notifyTimer > 0f && !string.IsNullOrEmpty(notifyText)) + { + float alpha = Mathf.Clamp01(notifyTimer / 3f); + float w = 560f; + float h = 56f; + Rect r = new Rect((Screen.width - w) * 0.5f, 90f, w, h); + Color bg = NotifyBgCol; + bg.a = 0.78f * alpha; + GUI.color = bg; + GUI.DrawTexture(r, Texture2D.whiteTexture); + Color textCol = notifyIsEnter ? NotifyEnterCol : NotifyExitCol; + textCol.a = alpha; + notifyStyleCache.normal.textColor = textCol; + GUI.color = Color.white; + GUI.Label(r, notifyText, notifyStyleCache); + } + + // SubArea 안 상시 안내 ("F2: 메인 월드로 나가기") + if (isInSubArea) + { + float w = 280f; + float h = 40f; + Rect r = new Rect(Screen.width - w - 20f, Screen.height - h - 20f, w, h); + GUI.color = NotifyBgCol; + GUI.DrawTexture(r, Texture2D.whiteTexture); + notifyStyleCache.normal.textColor = NotifyHintCol; + GUI.color = Color.white; + GUI.Label(r, "F2: 메인 월드로 나가기", notifyStyleCache); + } + // 메인 월드 + 영역 안 → 진입 안내 ([E] 키) + else if (regionManager != null && regionManager.NearbySubArea != null) + { + SubAreaData sub = regionManager.NearbySubArea; + float w = 420f; + float h = 80f; + Rect r = new Rect((Screen.width - w) * 0.5f, Screen.height - h - 60f, w, h); + GUI.color = NotifyBgCol; + GUI.DrawTexture(r, Texture2D.whiteTexture); + notifyStyleCache.normal.textColor = NotifyEnterCol; + GUI.color = Color.white; + GUI.Label(r, $"[E] {GetSubAreaDisplayName(sub)} 진입", notifyStyleCache); + } + GUI.color = Color.white; + } + + private void OnSubAreaChanged(SubAreaData subArea) + { + if (subArea != null && !isInSubArea) + { + EnterSubArea(subArea); + ShowNotify($"✨ {GetSubAreaDisplayName(subArea)} 진입", true); + if (AudioManager.Instance != null) + { + AudioManager.Instance.SetSubAreaActive(true); + if (!string.IsNullOrEmpty(subArea.environmentType)) + AudioManager.Instance.PlayAmbient(subArea.environmentType); + } + } + else if (subArea == null && isInSubArea) + { + string exitedName = currentSubArea != null ? GetSubAreaDisplayName(currentSubArea) : "서브지역"; + ExitSubArea(); + ShowNotify($"← {exitedName}에서 나옴 (메인 월드 복귀)", false); + if (AudioManager.Instance != null) + AudioManager.Instance.SetSubAreaActive(false); + } + } + + private static string GetSubAreaDisplayName(SubAreaData sub) + { + if (sub == null) return "서브지역"; + // SubAreaData에 displayName 또는 subAreaId 필드 — 둘 다 fallback으로 + string n = sub.displayName; + if (string.IsNullOrEmpty(n)) n = sub.subAreaId; + return string.IsNullOrEmpty(n) ? "서브지역" : n; + } + + private void ShowNotify(string text, bool isEnter) + { + notifyText = text; + notifyTimer = 3f; + notifyIsEnter = isEnter; + } + + /// F2 단축키 또는 외부 트리거로 수동 Exit 요청. + public void RequestExit() + { + if (!isInSubArea || regionManager == null) return; + // sticky 풀고 SubArea 강제 종료 — RegionManager가 SubAreaChanged(null) 발화 후 ExitSubArea 호출 + regionManager.ForceExitSubArea(); + } + + private void EnterSubArea(SubAreaData subArea) + { + currentSubArea = subArea; + isInSubArea = true; + + // RegionManager가 텔레포트된 좌표(2000,0,2000)에서 SubAreaChanged(null) 무한 토글하지 + // 않도록 sticky 모드 ON. ExitSubArea에서 OFF + 쿨다운 설정. + if (regionManager != null) + regionManager.SetSubAreaSticky(true); + + // 플레이어 위치 저장 + GameObject player = GameObject.Find("Player"); + if (player != null) savedPlayerPos = player.transform.position; + + // 메인 월드 숨기기 + HideMainWorld(); + + // 서브에리어 생성 + if (subAreaRoot != null) Destroy(subAreaRoot); + subAreaRoot = new GameObject($"SubArea_{subArea.subAreaId}"); + subAreaRoot.transform.position = SubAreaOrigin; + + switch (subArea.environmentType) + { + case "cave": + case "underground": + BuildCave(subArea); + break; + case "deep_forest": + BuildDeepForest(subArea); + break; + case "underwater": + case "pond": + BuildUnderwater(subArea); + break; + case "fog": + BuildFogSwamp(subArea); + break; + case "peak": + BuildMountainPeak(subArea); + break; + case "temple": + BuildTemple(subArea); + break; + case "flower_maze": + BuildFlowerMaze(subArea); + break; + case "greenhouse": + BuildGreenhouse(subArea); + break; + case "reeds": + BuildReeds(subArea); + break; + default: + BuildGenericArea(subArea); + break; + } + + // 환경 전체에 SubArea layer 일괄 설정 — CameraFollower 차폐 제외용. + // 8개 환경 빌드에 개별 추가하지 않고 subAreaRoot 자식 트리 전체 재귀 처리. + SetLayerRecursively(subAreaRoot, GetSubAreaEnvLayer()); + + // 플레이어를 서브에리어 입구로 텔레포트 — 벽 겹침 회피. + // BuildCave의 무작위 미로(7×7), BuildTemple의 z=-8 pillar 등으로 옛 고정 좌표는 + // 환경별로 벽에 끼는 회귀 발생. OverlapSphere로 빈 공간 검사 후 보정. + if (player != null) + { + player.transform.position = FindSafeSpawnPosition(SubAreaOrigin); + // 좌표 점프 후 카메라 baseline 리셋 — 옛 메인 월드 좌표에서 SubArea(2000m)로 + // 슬슬 들어오는 시각적 끊김 차단. SetSubAreaMode가 내부적으로 ResetBaseline 호출. + if (cameraFollower != null) cameraFollower.SetSubAreaMode(true); + } + } + + // SubArea 입구 안전 좌표: 옛 (0, 0.5, -8)을 1차 시도 후 벽 충돌 시 8방향 × 3반경(3/5/7m) + // 으로 spiral 탐색. 모두 실패 시 SubArea 중심(0, 0.5, 0)을 마지막 fallback. + private static Vector3 FindSafeSpawnPosition(Vector3 origin) + { + Vector3 preferred = origin + new Vector3(0f, 0.5f, -8f); + if (IsSpawnPositionClear(preferred)) return preferred; + + float[] radii = { 3f, 5f, 7f }; + for (int r = 0; r < radii.Length; r++) + { + for (int dir = 0; dir < 8; dir++) + { + float angle = dir * 45f * Mathf.Deg2Rad; + Vector3 candidate = origin + new Vector3( + Mathf.Sin(angle) * radii[r], + 0.5f, + -8f + Mathf.Cos(angle) * radii[r]); + if (IsSpawnPositionClear(candidate)) return candidate; + } + } + // 모든 시도 실패 — SubArea 중심으로 fallback (마지막 안전망) + return origin + new Vector3(0f, 0.5f, 0f); + } + + private static bool IsSpawnPositionClear(Vector3 pos) + { + // 플레이어 캡슐(반경 ~0.4, 높이 ~1.4) 점유 영역과 정합. PlayerMovement.IsBlockedPosition과 동일 패턴. + Collider[] hits = Physics.OverlapSphere(pos + Vector3.up * 1.0f, 0.5f); + for (int i = 0; i < hits.Length; i++) + { + Collider h = hits[i]; + if (h == null || h.isTrigger) continue; + // 바닥은 통과(높이 낮음). y 두께 0.5m 이상이면 벽/장애물로 판정. + if (h.bounds.size.y < 0.5f) continue; + // Player 자신은 제외 (FindSafeSpawnPosition 호출 시점에 이미 옛 좌표에서 텔레포트 직전) + if (h.gameObject.name == "Player") continue; + if (h.attachedRigidbody != null && h.attachedRigidbody.gameObject.name == "Player") continue; + return false; + } + return true; + } + + private void ExitSubArea() + { + isInSubArea = false; + string exitedId = currentSubArea != null ? currentSubArea.subAreaId : null; + SubAreaData exited = currentSubArea; + currentSubArea = null; + + // 서브에리어 파괴 + if (subAreaRoot != null) + { + Destroy(subAreaRoot); + subAreaRoot = null; + } + + // 메인 월드 복원 + ShowMainWorld(); + + // 플레이어를 원래 위치로. 단 savedPlayerPos가 방금 나온 SubArea 안이면 + // 자동 재진입을 막기 위해 중심에서 약간 밖으로 밀어낸다. + GameObject player = GameObject.Find("Player"); + if (player != null) + { + Vector3 dest = savedPlayerPos; + if (exited != null && exited.ContainsPoint(savedPlayerPos)) + { + Vector3 dir = savedPlayerPos - exited.centerPosition; + dir.y = 0f; + if (dir.sqrMagnitude < 0.01f) dir = Vector3.back; + dir.Normalize(); + dest = exited.centerPosition + dir * (exited.radius + 2f); + dest.y = savedPlayerPos.y; + } + player.transform.position = dest; + // 좌표 점프 후 카메라 baseline 리셋 + 일반 모드로 offset 복귀. + // SetSubAreaMode(false)가 내부적으로 ResetBaseline 호출하여 한 번에 처리. + if (cameraFollower != null) cameraFollower.SetSubAreaMode(false); + } + + // RegionManager sticky 해제 + 같은 SubArea 재진입 쿨다운 시작 + if (regionManager != null) + regionManager.SetSubAreaSticky(false, exitedId); + } + + // SubArea Exit는 SubAreaOrigin에서 25m 이상 이탈하면 자동 트리거(출구 가장자리). + // ESC 키는 ModalUIRegistry.HandleEscape와 충돌하므로 사용하지 않음 — 사용자가 입구 외곽으로 + // 걸어가면 자연스럽게 메인 월드로 복귀. 25m 이탈 로직은 통합된 Update() (라인 56+)로 이전됨. + + private void HideMainWorld() + { + hiddenMainObjects.Clear(); + string[] rootNames = { "Ground", "WorldTerrainBuilder" }; + foreach (string n in rootNames) + { + GameObject obj = GameObject.Find(n); + if (obj != null && obj.activeSelf) + { + hiddenMainObjects.Add(obj); + obj.SetActive(false); + } + } + + // Region_, Barrier_, Path_, Scenery_ 등 프리픽스 오브젝트 숨기기 + // 주의: 본인의 subAreaRoot은 SubArea_ prefix를 가지므로 명시적으로 제외해야 함 + Transform rootT = subAreaRoot != null ? subAreaRoot.transform : null; + foreach (GameObject obj in Object.FindObjectsByType(FindObjectsSortMode.None)) + { + if (obj == null || !obj.activeSelf) continue; + if (obj == subAreaRoot) continue; // 본인 root 제외 (HideMainWorld가 본인을 끄는 회귀 방지) + if (rootT != null && obj.transform.IsChildOf(rootT)) continue; // 본인 자식도 제외 + string name = obj.name; + if (name.StartsWith("Region_") || name.StartsWith("Barrier_") || + name.StartsWith("Path_") || name.StartsWith("Scenery_") || + name.StartsWith("SubArea_") || name.StartsWith("Ground_Hill") || + name.StartsWith("Slope_") || name.StartsWith("Cliff_") || + name.StartsWith("River_") || name.StartsWith("Bridge_") || + name.StartsWith("Boundary_") || name.StartsWith("Swamp_") || + name.StartsWith("SpawnPoint_")) + { + hiddenMainObjects.Add(obj); + obj.SetActive(false); + } + } + } + + private void ShowMainWorld() + { + foreach (var obj in hiddenMainObjects) + { + if (obj != null) obj.SetActive(true); + } + hiddenMainObjects.Clear(); + } + + // ========== 동굴 ========== + private void BuildCave(SubAreaData sub) + { + Material wallMat = Mat(new Color(0.25f, 0.22f, 0.18f)); + Material floorMat = Mat(new Color(0.15f, 0.13f, 0.1f)); + Material ceilingMat = Mat(new Color(0.1f, 0.08f, 0.06f)); + Material torchMat = Mat(new Color(1f, 0.7f, 0.2f)); + Material torchHandleMat = Mat(new Color(0.3f, 0.2f, 0.1f)); + + // 바닥 + CreateFloor(floorMat, 30f); + + // 천장 — 카메라(약 y=12)보다 위로 배치하여 시야 가리지 않게. + // ShadowsOnly로 렌더링하여 천장 그림자/어두운 분위기는 유지하되 카메라 시야는 가리지 않음. + GameObject ceiling = Prim(PrimitiveType.Cube, "Ceiling"); + ceiling.transform.localPosition = new Vector3(0f, 14f, 0f); + ceiling.transform.localScale = new Vector3(30f, 0.3f, 30f); + Apply(ceiling, ceilingMat); + NoCollider(ceiling); + MeshRenderer ceilMr = ceiling.GetComponent(); + if (ceilMr != null) + ceilMr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.ShadowsOnly; + + // 미로형 벽 생성 + int[,] maze = GenerateSimpleMaze(7, 7); + // 입구 영역 (x=3~4, z=1~2) 4개 셀을 강제 빈 공간으로. + // FindSafeSpawnPosition 선호 좌표 (0, 0.5, -8) 주변 셀이 벽이면 spiral 탐색해도 좁은 통로에 끼임. + maze[3, 1] = 0; + maze[3, 2] = 0; + maze[4, 1] = 0; + maze[4, 2] = 0; + float cellSize = 4f; + float offsetX = -cellSize * 3.5f; + float offsetZ = -cellSize * 3.5f; + + for (int x = 0; x < 7; x++) + { + for (int z = 0; z < 7; z++) + { + if (maze[x, z] == 1) + { + GameObject wall = Prim(PrimitiveType.Cube, $"CaveWall_{x}_{z}"); + wall.transform.localPosition = new Vector3(offsetX + x * cellSize, 2.5f, offsetZ + z * cellSize); + wall.transform.localScale = new Vector3(cellSize - 0.2f, 5f, cellSize - 0.2f); + Apply(wall, wallMat); + } + } + } + + // 횃불 (통로에 배치) + int torchCount = 0; + for (int x = 0; x < 7 && torchCount < 12; x++) + { + for (int z = 0; z < 7 && torchCount < 12; z++) + { + if (maze[x, z] == 0 && (x + z) % 3 == 0) + { + Vector3 pos = new Vector3(offsetX + x * cellSize + 1.5f, 0f, offsetZ + z * cellSize); + CreateTorch(pos, torchHandleMat, torchMat); + torchCount++; + } + } + } + + // 포인트 라이트 (횃불에서) + CreatePointLight(new Vector3(0f, 3f, 0f), new Color(1f, 0.6f, 0.2f), 8f, 1.2f); + CreatePointLight(new Vector3(10f, 3f, 5f), new Color(1f, 0.6f, 0.2f), 6f, 0.8f); + CreatePointLight(new Vector3(-8f, 3f, -4f), new Color(1f, 0.6f, 0.2f), 6f, 0.8f); + + // 외곽 벽 — 바닥(30 = ±15) 안쪽에 배치하여 벽-바닥 사이 빠짐 방지 + CreateBoundaryWalls(wallMat, 14f, 5f); + } + + // ========== 깊은 숲 ========== + private void BuildDeepForest(SubAreaData sub) + { + Material groundMat = Mat(new Color(0.1f, 0.18f, 0.06f)); + Material trunkMat = Mat(new Color(0.2f, 0.12f, 0.06f)); + Material leafMat = Mat(new Color(0.05f, 0.25f, 0.03f)); + Material pathMat = Mat(new Color(0.3f, 0.25f, 0.15f)); + + CreateFloor(groundMat, 35f); + + // 빽빽한 나무 (통로 제외) + int[,] maze = GenerateSimpleMaze(9, 9); + float cellSize = 3.5f; + float off = -cellSize * 4.5f; + + for (int x = 0; x < 9; x++) + { + for (int z = 0; z < 9; z++) + { + if (maze[x, z] == 1) + { + Vector3 pos = new Vector3(off + x * cellSize, 0f, off + z * cellSize); + // 나무 기둥 — 옛 Y=3 scale 3(범위 1.5~4.5)은 캐릭터 머리(2.2)보다 한참 솟아 부감 시 + // 환경이 캐릭터 위에 떠 있는 인상. Y 1.5 scale 1.5(범위 0.75~2.25)로 머리 부근. + GameObject trunk = Prim(PrimitiveType.Cylinder, $"Tree_{x}_{z}"); + trunk.transform.localPosition = pos + new Vector3(0f, 1.5f, 0f); + trunk.transform.localScale = new Vector3(0.8f, 1.5f, 0.8f); + Apply(trunk, trunkMat); + // 나뭇잎 — Y 3 scale 1.5(범위 1.5~4.5)로 캐릭터 머리 부근. 카메라 NormalOffset + // (0,12,-8) 시야선에서 잎사귀 위치는 시야선 아래이므로 캐릭터 가림 없음. + // ShadowsOnly 적용 시 잎사귀 자체가 안 보여 숲다움 사라짐(사용자 보고) — 정상 렌더링. + GameObject leaf = Prim(PrimitiveType.Sphere, $"Leaf_{x}_{z}"); + leaf.transform.localPosition = pos + new Vector3(0f, 3f, 0f); + leaf.transform.localScale = new Vector3(2.5f, 1.5f, 2.5f); + Apply(leaf, leafMat); + NoCollider(leaf); + } + else + { + // 통로 바닥 + GameObject path = Prim(PrimitiveType.Plane, $"Path_{x}_{z}"); + path.transform.localPosition = new Vector3(off + x * cellSize, 0.05f, off + z * cellSize); + path.transform.localScale = new Vector3(cellSize / 10f, 1f, cellSize / 10f); + Apply(path, pathMat); + NoCollider(path); + } + } + } + + // 안개 구체 + Material fogMat = Mat(new Color(0.15f, 0.25f, 0.1f, 0.15f)); + SetTransparent(fogMat); + for (int i = 0; i < 6; i++) + { + GameObject fog = Prim(PrimitiveType.Sphere, $"Fog_{i}"); + fog.transform.localPosition = new Vector3(Random.Range(-12f, 12f), 2f, Random.Range(-12f, 12f)); + fog.transform.localScale = Vector3.one * Random.Range(5f, 10f); + Apply(fog, fogMat); + NoCollider(fog); + } + + CreatePointLight(Vector3.up * 8f, new Color(0.3f, 0.6f, 0.2f), 20f, 0.6f); + CreateBoundaryWalls(trunkMat, 16f, 7f); + } + + // ========== 수중 ========== + private void BuildUnderwater(SubAreaData sub) + { + Material floorMat = Mat(new Color(0.1f, 0.15f, 0.25f)); + Material coralMat = Mat(new Color(0.8f, 0.3f, 0.4f)); + Material seaweedMat = Mat(new Color(0.1f, 0.4f, 0.15f)); + Material waterMat = Mat(new Color(0.1f, 0.25f, 0.5f, 0.3f)); + SetTransparent(waterMat); + + CreateFloor(floorMat, 30f); + + // 물 표면 (위에서 덮개) + GameObject waterTop = Prim(PrimitiveType.Plane, "WaterSurface"); + waterTop.transform.localPosition = new Vector3(0f, 6f, 0f); + waterTop.transform.localScale = new Vector3(4f, 1f, 4f); + Apply(waterTop, waterMat); + NoCollider(waterTop); + + // 산호 + for (int i = 0; i < 15; i++) + { + GameObject coral = Prim(PrimitiveType.Cylinder, $"Coral_{i}"); + Vector3 pos = new Vector3(Random.Range(-12f, 12f), 0f, Random.Range(-12f, 12f)); + float h = Random.Range(1f, 3f); + coral.transform.localPosition = pos + new Vector3(0f, h * 0.5f, 0f); + coral.transform.localScale = new Vector3(0.3f, h * 0.5f, 0.3f); + Color cCol = new Color(Random.Range(0.5f, 1f), Random.Range(0.2f, 0.5f), Random.Range(0.3f, 0.7f)); + Apply(coral, Mat(cCol)); + NoCollider(coral); + } + + // 해초 + for (int i = 0; i < 10; i++) + { + Vector3 pos = new Vector3(Random.Range(-10f, 10f), 0f, Random.Range(-10f, 10f)); + for (int j = 0; j < 3; j++) + { + GameObject sw = Prim(PrimitiveType.Capsule, $"Seaweed_{i}_{j}"); + sw.transform.localPosition = pos + new Vector3(j * 0.3f, 1f + j * 0.8f, 0f); + sw.transform.localScale = new Vector3(0.15f, 0.6f, 0.15f); + Apply(sw, seaweedMat); + NoCollider(sw); + } + } + + // 기포 + Material bubbleMat = Mat(new Color(0.6f, 0.8f, 1f, 0.3f)); + SetTransparent(bubbleMat); + for (int i = 0; i < 20; i++) + { + GameObject bubble = Prim(PrimitiveType.Sphere, $"Bubble_{i}"); + bubble.transform.localPosition = new Vector3(Random.Range(-10f, 10f), Random.Range(1f, 5f), Random.Range(-10f, 10f)); + float bs = Random.Range(0.1f, 0.3f); + bubble.transform.localScale = Vector3.one * bs; + Apply(bubble, bubbleMat); + NoCollider(bubble); + } + + CreatePointLight(new Vector3(0f, 5f, 0f), new Color(0.2f, 0.5f, 0.8f), 18f, 0.8f); + CreateBoundaryWalls(Mat(new Color(0.15f, 0.2f, 0.3f)), 14f, 6f); + } + + // ========== 안개 늪 ========== + private void BuildFogSwamp(SubAreaData sub) + { + Material mudMat = Mat(new Color(0.18f, 0.2f, 0.1f)); + Material waterMat = Mat(new Color(0.12f, 0.2f, 0.15f, 0.5f)); + SetTransparent(waterMat); + + CreateFloor(mudMat, 30f); + + // 물웅덩이 + for (int i = 0; i < 8; i++) + { + GameObject pool = Prim(PrimitiveType.Cylinder, $"Pool_{i}"); + pool.transform.localPosition = new Vector3(Random.Range(-10f, 10f), 0.02f, Random.Range(-10f, 10f)); + float ps = Random.Range(1.5f, 3f); + pool.transform.localScale = new Vector3(ps, 0.02f, ps); + Apply(pool, waterMat); + NoCollider(pool); + } + + // 안개 구체 (밀집) + Material fogMat = Mat(new Color(0.5f, 0.5f, 0.45f, 0.12f)); + SetTransparent(fogMat); + for (int i = 0; i < 15; i++) + { + GameObject fog = Prim(PrimitiveType.Sphere, $"Fog_{i}"); + fog.transform.localPosition = new Vector3(Random.Range(-14f, 14f), Random.Range(1f, 3f), Random.Range(-14f, 14f)); + fog.transform.localScale = Vector3.one * Random.Range(4f, 8f); + Apply(fog, fogMat); + NoCollider(fog); + } + + // 고목 + Material deadWood = Mat(new Color(0.25f, 0.2f, 0.15f)); + for (int i = 0; i < 6; i++) + { + GameObject tree = Prim(PrimitiveType.Cylinder, $"DeadTree_{i}"); + Vector3 pos = new Vector3(Random.Range(-10f, 10f), 0f, Random.Range(-10f, 10f)); + tree.transform.localPosition = pos + new Vector3(0f, 1.5f, 0f); + tree.transform.localScale = new Vector3(0.3f, 1.5f, 0.3f); + tree.transform.localRotation = Quaternion.Euler(Random.Range(-10f, 10f), 0f, Random.Range(-10f, 10f)); + Apply(tree, deadWood); + } + + // 윌오위스프 라이트 + CreatePointLight(new Vector3(3f, 2f, 5f), new Color(0.4f, 0.8f, 0.3f), 6f, 0.5f); + CreatePointLight(new Vector3(-5f, 2f, -3f), new Color(0.3f, 0.6f, 0.8f), 5f, 0.4f); + CreatePointLight(Vector3.up * 6f, new Color(0.5f, 0.5f, 0.4f), 15f, 0.4f); + CreateBoundaryWalls(mudMat, 14f, 4f); + } + + // ========== 산 정상 ========== + private void BuildMountainPeak(SubAreaData sub) + { + Material rockMat = Mat(new Color(0.5f, 0.48f, 0.44f)); + Material snowMat = Mat(new Color(0.9f, 0.92f, 0.95f)); + Material pathMat = Mat(new Color(0.45f, 0.42f, 0.38f)); + + CreateFloor(rockMat, 25f); + + // 눈 패치 + for (int i = 0; i < 8; i++) + { + GameObject snow = Prim(PrimitiveType.Plane, $"Snow_{i}"); + snow.transform.localPosition = new Vector3(Random.Range(-10f, 10f), 0.08f, Random.Range(-10f, 10f)); + float ss = Random.Range(0.3f, 0.6f); + snow.transform.localScale = new Vector3(ss, 1f, ss); + Apply(snow, snowMat); + NoCollider(snow); + } + + // 바위 + for (int i = 0; i < 12; i++) + { + GameObject rock = Prim(PrimitiveType.Sphere, $"Rock_{i}"); + Vector3 pos = new Vector3(Random.Range(-10f, 10f), 0f, Random.Range(-10f, 10f)); + float rs = Random.Range(0.5f, 2f); + rock.transform.localPosition = pos + new Vector3(0f, rs * 0.3f, 0f); + rock.transform.localScale = new Vector3(rs * 1.3f, rs * 0.5f, rs); + Apply(rock, rockMat); + } + + CreatePointLight(Vector3.up * 10f, new Color(0.9f, 0.95f, 1f), 25f, 1.5f); + CreateBoundaryWalls(rockMat, 11f, 5f); + } + + // ========== 사원 ========== + private void BuildTemple(SubAreaData sub) + { + Material stoneMat = Mat(new Color(0.35f, 0.3f, 0.28f)); + Material floorMat = Mat(new Color(0.25f, 0.22f, 0.2f)); + Material glowMat = Mat(new Color(0.5f, 0.3f, 0.8f)); + Material torchMat = Mat(new Color(0.7f, 0.5f, 1f)); + + CreateFloor(floorMat, 28f); + + // 기둥 (양옆 배치) — 옛 Y=3 scale 3(범위 1.5~4.5)은 캐릭터 머리(2.2)보다 솟음. + // Y 1.8 scale 1.8(범위 0.9~2.7)로 캐릭터와 같은 평면감. + for (int i = 0; i < 6; i++) + { + for (int side = -1; side <= 1; side += 2) + { + GameObject pillar = Prim(PrimitiveType.Cylinder, $"Pillar_{i}_{side}"); + pillar.transform.localPosition = new Vector3(side * 5f, 1.8f, -8f + i * 3.5f); + pillar.transform.localScale = new Vector3(0.6f, 1.8f, 0.6f); + Apply(pillar, stoneMat); + } + } + + // 제단 + GameObject altar = Prim(PrimitiveType.Cube, "Altar"); + altar.transform.localPosition = new Vector3(0f, 0.75f, 10f); + altar.transform.localScale = new Vector3(3f, 1.5f, 2f); + Apply(altar, stoneMat); + + // 신비로운 빛 + GameObject glow = Prim(PrimitiveType.Sphere, "AltarGlow"); + glow.transform.localPosition = new Vector3(0f, 2.5f, 10f); + glow.transform.localScale = Vector3.one * 1.5f; + Apply(glow, glowMat); + NoCollider(glow); + + // 횃불 + for (int i = 0; i < 4; i++) + { + float z = -6f + i * 5f; + CreateTorch(new Vector3(6f, 0f, z), stoneMat, torchMat); + CreateTorch(new Vector3(-6f, 0f, z), stoneMat, torchMat); + } + + CreatePointLight(new Vector3(0f, 4f, 10f), new Color(0.6f, 0.3f, 0.9f), 10f, 1.2f); + CreatePointLight(new Vector3(0f, 5f, 0f), new Color(0.4f, 0.3f, 0.6f), 15f, 0.5f); + CreateBoundaryWalls(stoneMat, 13f, 6f); + } + + // ========== 꽃 미로 ========== + private void BuildFlowerMaze(SubAreaData sub) + { + Material hedgeMat = Mat(new Color(0.15f, 0.4f, 0.1f)); + Material floorMat = Mat(new Color(0.3f, 0.4f, 0.2f)); + + CreateFloor(floorMat, 30f); + + int[,] maze = GenerateSimpleMaze(8, 8); + float cellSize = 3f; + float off = -cellSize * 4f; + + for (int x = 0; x < 8; x++) + { + for (int z = 0; z < 8; z++) + { + if (maze[x, z] == 1) + { + // 생울타리 + GameObject hedge = Prim(PrimitiveType.Cube, $"Hedge_{x}_{z}"); + hedge.transform.localPosition = new Vector3(off + x * cellSize, 1.2f, off + z * cellSize); + hedge.transform.localScale = new Vector3(cellSize - 0.1f, 2.4f, cellSize - 0.1f); + Apply(hedge, hedgeMat); + + // 꽃 장식 (위에) + if ((x + z) % 2 == 0) + { + Color fc = new Color(Random.Range(0.7f, 1f), Random.Range(0.2f, 0.6f), Random.Range(0.3f, 0.8f)); + GameObject flower = Prim(PrimitiveType.Sphere, $"Flower_{x}_{z}"); + flower.transform.localPosition = new Vector3(off + x * cellSize, 2.6f, off + z * cellSize); + flower.transform.localScale = new Vector3(0.5f, 0.3f, 0.5f); + Apply(flower, Mat(fc)); + NoCollider(flower); + } + } + } + } + + CreatePointLight(Vector3.up * 8f, new Color(1f, 0.9f, 0.7f), 20f, 1.2f); + CreateBoundaryWalls(hedgeMat, 14f, 3f); + } + + // ========== 온실 ========== + private void BuildGreenhouse(SubAreaData sub) + { + Material frameMat = Mat(new Color(0.7f, 0.7f, 0.7f)); + Material glassMat = Mat(new Color(0.8f, 0.9f, 0.8f, 0.15f)); + SetTransparent(glassMat); + Material soilMat = Mat(new Color(0.3f, 0.22f, 0.12f)); + + CreateFloor(soilMat, 20f); + + // 유리 벽 + CreateGlassWall(glassMat, frameMat, 10f, 5f); + + // 화분 + Material potMat = Mat(new Color(0.6f, 0.35f, 0.15f)); + for (int i = 0; i < 8; i++) + { + float x = (i < 4) ? -6f : 6f; + float z = -6f + (i % 4) * 4f; + GameObject pot = Prim(PrimitiveType.Cylinder, $"Pot_{i}"); + pot.transform.localPosition = new Vector3(x, 0.4f, z); + pot.transform.localScale = new Vector3(0.6f, 0.4f, 0.6f); + Apply(pot, potMat); + // 식물 + Color plantCol = new Color(Random.Range(0.1f, 0.3f), Random.Range(0.4f, 0.8f), Random.Range(0.1f, 0.3f)); + GameObject plant = Prim(PrimitiveType.Sphere, $"Plant_{i}"); + plant.transform.localPosition = new Vector3(x, 1.2f, z); + plant.transform.localScale = new Vector3(0.8f, 0.6f, 0.8f); + Apply(plant, Mat(plantCol)); + NoCollider(plant); + } + + CreatePointLight(Vector3.up * 4f, new Color(0.85f, 1f, 0.8f), 18f, 1.0f); + } + + // ========== 갈대밭 ========== + private void BuildReeds(SubAreaData sub) + { + Material waterMat = Mat(new Color(0.15f, 0.25f, 0.35f, 0.5f)); + SetTransparent(waterMat); + Material reedMat = Mat(new Color(0.4f, 0.5f, 0.2f)); + Material mudMat = Mat(new Color(0.25f, 0.22f, 0.15f)); + + CreateFloor(mudMat, 25f); + + // 물 표면 + GameObject water = Prim(PrimitiveType.Plane, "Water"); + water.transform.localPosition = new Vector3(0f, 0.05f, 0f); + water.transform.localScale = new Vector3(2.5f, 1f, 2.5f); + Apply(water, waterMat); + NoCollider(water); + + // 갈대 (빽빽하게, 통로 제외) + int[,] maze = GenerateSimpleMaze(10, 10); + float cellSize = 2.5f; + float off = -cellSize * 5f; + + for (int x = 0; x < 10; x++) + { + for (int z = 0; z < 10; z++) + { + if (maze[x, z] == 1) + { + for (int r = 0; r < 3; r++) + { + GameObject reed = Prim(PrimitiveType.Cylinder, $"Reed_{x}_{z}_{r}"); + float rx = off + x * cellSize + Random.Range(-0.5f, 0.5f); + float rz = off + z * cellSize + Random.Range(-0.5f, 0.5f); + reed.transform.localPosition = new Vector3(rx, 1.5f, rz); + reed.transform.localScale = new Vector3(0.08f, 1.5f, 0.08f); + reed.transform.localRotation = Quaternion.Euler(Random.Range(-5f, 5f), 0f, Random.Range(-5f, 5f)); + Apply(reed, reedMat); + NoCollider(reed); + } + } + } + } + + CreatePointLight(Vector3.up * 6f, new Color(0.8f, 0.75f, 0.5f), 18f, 0.9f); + CreateBoundaryWalls(mudMat, 11f, 3f); + } + + private void BuildGenericArea(SubAreaData sub) + { + CreateFloor(Mat(new Color(0.3f, 0.35f, 0.25f)), 20f); + CreatePointLight(Vector3.up * 8f, Color.white, 20f, 1f); + CreateBoundaryWalls(Mat(new Color(0.4f, 0.4f, 0.4f)), 9f, 4f); + } + + // ========== 공통 유틸 ========== + + private int[,] GenerateSimpleMaze(int w, int h) + { + int[,] grid = new int[w, h]; + // 기본: 벽으로 채우기 + for (int x = 0; x < w; x++) + for (int z = 0; z < h; z++) + grid[x, z] = 1; + + // DFS로 통로 파기 + System.Collections.Generic.Stack stack = new System.Collections.Generic.Stack(); + Vector2Int start = new Vector2Int(1, 1); + grid[start.x, start.y] = 0; + stack.Push(start); + + int[] dx = { 0, 0, 2, -2 }; + int[] dz = { 2, -2, 0, 0 }; + + while (stack.Count > 0) + { + Vector2Int cur = stack.Peek(); + List dirs = new List(); + for (int d = 0; d < 4; d++) + { + int nx = cur.x + dx[d]; + int nz = cur.y + dz[d]; + if (nx > 0 && nx < w - 1 && nz > 0 && nz < h - 1 && grid[nx, nz] == 1) + dirs.Add(d); + } + + if (dirs.Count > 0) + { + int d = dirs[Random.Range(0, dirs.Count)]; + int mx = cur.x + dx[d] / 2; + int mz = cur.y + dz[d] / 2; + int nx = cur.x + dx[d]; + int nz = cur.y + dz[d]; + grid[mx, mz] = 0; + grid[nx, nz] = 0; + stack.Push(new Vector2Int(nx, nz)); + } + else + { + stack.Pop(); + } + } + + // 입구/출구 보장 + grid[w / 2, 0] = 0; + grid[w / 2, 1] = 0; + grid[w / 2, h - 1] = 0; + grid[w / 2, h - 2] = 0; + + return grid; + } + + private void CreateFloor(Material mat, float size) + { + GameObject floor = Prim(PrimitiveType.Plane, "Floor"); + floor.transform.localPosition = Vector3.zero; + floor.transform.localScale = new Vector3(size / 10f, 1f, size / 10f); + Apply(floor, mat); + } + + private void CreateBoundaryWalls(Material mat, float halfSize, float height) + { + string[] names = { "Wall_N", "Wall_S", "Wall_E", "Wall_W" }; + Vector3[] pos = { + new Vector3(0f, height / 2f, halfSize), + new Vector3(0f, height / 2f, -halfSize), + new Vector3(halfSize, height / 2f, 0f), + new Vector3(-halfSize, height / 2f, 0f) + }; + Vector3[] scl = { + new Vector3(halfSize * 2f, height, 1f), + new Vector3(halfSize * 2f, height, 1f), + new Vector3(1f, height, halfSize * 2f), + new Vector3(1f, height, halfSize * 2f) + }; + for (int i = 0; i < 4; i++) + { + GameObject wall = Prim(PrimitiveType.Cube, names[i]); + wall.transform.localPosition = pos[i]; + wall.transform.localScale = scl[i]; + Apply(wall, mat); + } + } + + private void CreateTorch(Vector3 pos, Material handleMat, Material flameMat) + { + GameObject handle = Prim(PrimitiveType.Cylinder, "TorchHandle"); + handle.transform.localPosition = pos + new Vector3(0f, 1.2f, 0f); + handle.transform.localScale = new Vector3(0.08f, 0.6f, 0.08f); + Apply(handle, handleMat); + NoCollider(handle); + + GameObject flame = Prim(PrimitiveType.Sphere, "TorchFlame"); + flame.transform.localPosition = pos + new Vector3(0f, 2f, 0f); + flame.transform.localScale = new Vector3(0.25f, 0.35f, 0.25f); + Apply(flame, flameMat); + NoCollider(flame); + } + + private void CreatePointLight(Vector3 localPos, Color color, float range, float intensity) + { + GameObject lightObj = new GameObject("SubAreaLight"); + lightObj.transform.SetParent(subAreaRoot.transform, false); + lightObj.transform.localPosition = localPos; + Light light = lightObj.AddComponent(); + light.type = LightType.Point; + light.color = color; + light.range = range; + light.intensity = intensity; + } + + private void CreateGlassWall(Material glassMat, Material frameMat, float halfSize, float height) + { + // 4면 유리 + for (int i = 0; i < 4; i++) + { + float angle = i * 90f; + Vector3 dir = Quaternion.Euler(0f, angle, 0f) * Vector3.forward; + GameObject glass = Prim(PrimitiveType.Cube, $"Glass_{i}"); + glass.transform.localPosition = dir * halfSize + new Vector3(0f, height / 2f, 0f); + glass.transform.localScale = new Vector3( + (i % 2 == 0) ? halfSize * 2f : 0.1f, + height, + (i % 2 == 0) ? 0.1f : halfSize * 2f); + Apply(glass, glassMat); + // 유리지만 collider 보존 — 옛은 NoCollider 호출로 통과 가능, 바닥 밖으로 빠짐. + } + // 프레임 기둥 4개 + Vector3[] corners = { new Vector3(-1, 0, -1), new Vector3(1, 0, -1), new Vector3(-1, 0, 1), new Vector3(1, 0, 1) }; + foreach (var c in corners) + { + GameObject post = Prim(PrimitiveType.Cylinder, "Frame"); + post.transform.localPosition = c * halfSize + new Vector3(0f, height / 2f, 0f); + post.transform.localScale = new Vector3(0.15f, height / 2f, 0.15f); + Apply(post, frameMat); + } + } + + // 프리미티브 생성 헬퍼 + private GameObject Prim(PrimitiveType type, string name) + { + GameObject obj = GameObject.CreatePrimitive(type); + obj.name = name; + obj.transform.SetParent(subAreaRoot.transform, false); + return obj; + } + + private void Apply(GameObject obj, Material mat) + { + MeshRenderer mr = obj.GetComponent(); + if (mr != null) mr.material = mat; + } + + private void NoCollider(GameObject obj) + { + Collider c = obj.GetComponent(); + if (c != null) Destroy(c); + } + + private Material Mat(Color color) + { + Shader shader = Shader.Find("Standard"); + if (shader == null) shader = Shader.Find("Universal Render Pipeline/Lit"); + if (shader == null) shader = Shader.Find("Unlit/Color"); + if (shader == null) shader = Shader.Find("Sprites/Default"); + Material mat = shader != null ? new Material(shader) : new Material(Shader.Find("Hidden/InternalErrorShader")); + mat.color = color; + return mat; + } + + private void SetTransparent(Material mat) + { + mat.SetFloat("_Mode", 3); + mat.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha); + mat.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); + mat.SetInt("_ZWrite", 0); + mat.EnableKeyword("_ALPHABLEND_ON"); + mat.renderQueue = 3000; + } + } +} diff --git a/Assets/Scripts/Core/SubAreaWorldBuilder.cs.meta b/Assets/Scripts/Core/SubAreaWorldBuilder.cs.meta new file mode 100644 index 0000000..6546a97 --- /dev/null +++ b/Assets/Scripts/Core/SubAreaWorldBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5331a394d0bde5943924a4843f9c8c4b \ No newline at end of file diff --git a/Assets/Scripts/Core/TrainingManager.cs b/Assets/Scripts/Core/TrainingManager.cs index c864708..a16c25a 100644 --- a/Assets/Scripts/Core/TrainingManager.cs +++ b/Assets/Scripts/Core/TrainingManager.cs @@ -72,16 +72,18 @@ public bool TrainSkill(TrainingMethod method, PlayerInsectData insect, string sk } if (!inPool) return false; + // 액션 성공 후 SpendCandy — 옛은 SpendCandy 먼저라 ReplaceSkill 실패(replaceSkillId가 + // learnedSkillIds에 없는 경우 등) 시 candy 손실 회귀. if (insect.IsSkillsFull()) { if (string.IsNullOrEmpty(replaceSkillId)) return false; + if (!insect.ReplaceSkill(replaceSkillId, skillId)) return false; candyInventory.SpendCandy(method.candyCost); - insect.ReplaceSkill(replaceSkillId, skillId); } else { + if (!insect.LearnSkill(skillId)) return false; candyInventory.SpendCandy(method.candyCost); - insect.LearnSkill(skillId); if (insect.EquippedCount() < PlayerInsectData.MaxEquipSlots) { @@ -100,6 +102,8 @@ public bool TrainSkill(TrainingMethod method, PlayerInsectData insect, string sk collection.ForceSave(); TrainingCompleted?.Invoke(); + // q_training 진행도 — TrainSkill 성공 분기 끝 + TutorialQuestManager.Instance?.NotifyTraining(); return true; } diff --git a/Assets/Scripts/Core/TutorialQuestManager.cs b/Assets/Scripts/Core/TutorialQuestManager.cs index e20b36b..bca3054 100644 --- a/Assets/Scripts/Core/TutorialQuestManager.cs +++ b/Assets/Scripts/Core/TutorialQuestManager.cs @@ -299,6 +299,10 @@ private void SubscribeEvents() regionManager.RegionChanged += OnRegionChanged; regionManager.SubAreaChanged += OnSubAreaChanged; } + + // 팀 편성 변경 (q_team) — 옛은 NotifyTeamSet 호출처 없어 영구 정지 + if (battleTeamManager != null) + battleTeamManager.TeamChanged += OnTeamChanged; } private void UnsubscribeEvents() @@ -314,8 +318,13 @@ private void UnsubscribeEvents() regionManager.RegionChanged -= OnRegionChanged; regionManager.SubAreaChanged -= OnSubAreaChanged; } + + if (battleTeamManager != null) + battleTeamManager.TeamChanged -= OnTeamChanged; } + private void OnTeamChanged() => NotifyAction(QuestType.SetTeam); + // --- 이벤트 핸들러 --- private void OnBattleEnded(bool playerWon) @@ -471,17 +480,29 @@ private void CompleteQuest(string questId) TutorialQuest quest = GetQuest(questId); if (quest == null) return; - if (quest.rewardCandy > 0 && candyInventory != null) - candyInventory.AddCandy(quest.rewardCandy); + if (quest.rewardCandy > 0) + { + if (candyInventory != null) candyInventory.AddCandy(quest.rewardCandy); + else Debug.LogWarning($"[Quest] candyInventory null — 캔디 보상 손실: {questId} (+{quest.rewardCandy})"); + } - if (quest.rewardExp > 0 && progressController != null) - progressController.GainXp(quest.rewardExp); + if (quest.rewardExp > 0) + { + if (progressController != null) progressController.GainXp(quest.rewardExp); + else Debug.LogWarning($"[Quest] progressController null — XP 보상 손실: {questId} (+{quest.rewardExp})"); + } - if (!string.IsNullOrEmpty(quest.rewardItemId) && quest.rewardItemCount > 0 && itemInventory != null) - itemInventory.AddItem(quest.rewardItemId, quest.rewardItemCount); + if (!string.IsNullOrEmpty(quest.rewardItemId) && quest.rewardItemCount > 0) + { + if (itemInventory != null) itemInventory.AddItem(quest.rewardItemId, quest.rewardItemCount); + else Debug.LogWarning($"[Quest] itemInventory null — 아이템 보상 손실: {questId} {quest.rewardItemId}x{quest.rewardItemCount}"); + } QuestCompleted?.Invoke(quest); SaveProgress(); + // 퀘스트 완료 보상은 캔디/XP/아이템 → 클라우드 즉시 동기 (다른 기기 진입 시 재진행 방지). + // IncrementProgress의 잦은 호출은 120초 자동저장에 맡겨 API 폭주 차단. + if (CloudSaveManager.Instance != null) CloudSaveManager.Instance.SaveToCloud(); ActivateNextQuest(); } diff --git a/Assets/Scripts/Core/UIAudioBinder.cs b/Assets/Scripts/Core/UIAudioBinder.cs new file mode 100644 index 0000000..aa0255c --- /dev/null +++ b/Assets/Scripts/Core/UIAudioBinder.cs @@ -0,0 +1,58 @@ +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +namespace InsectGame.Core +{ + /// + /// 씬 내 모든 UnityEngine.UI.Button에 자동으로 hover/click 사운드를 부착합니다. + /// PlaySceneBootstrap에서 1회 등록하면 이후 동적 생성된 버튼은 다음 BindAll() 호출 시 처리됩니다. + /// + public class UIAudioBinder : MonoBehaviour + { + private float rebindTimer; + private const float RebindIntervalSec = 5f; + + public void BindAll() + { + Button[] buttons = FindObjectsByType